Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c5d11c03ec | ||
|
|
52165471c0 | ||
|
|
03f61a2b5e | ||
|
|
79bdb457cb |
@@ -0,0 +1,77 @@
|
||||
name: Bundle size delta
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "src/**"
|
||||
- "package.json"
|
||||
- "pnpm-lock.yaml"
|
||||
- "vite.config.*"
|
||||
- "scripts/check-bundle-size.mjs"
|
||||
- ".github/workflows/bundle-size-pr.yml"
|
||||
|
||||
# Compute the gzip-bundle-size delta between the PR head and the base branch
|
||||
# and post it as a single sticky comment on the pull request. Lets reviewers
|
||||
# spot regressions ("did this 5 KB ship intentionally?") without digging
|
||||
# through CI logs.
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
jobs:
|
||||
size-delta:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
# Setting permissions: explicitly REMOVES default scopes — must list
|
||||
# contents: read so actions/checkout can fetch the repo, otherwise
|
||||
# the runner gets "Repository not found" with a 404.
|
||||
contents: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout PR head
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
path: head
|
||||
- name: Checkout base
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
path: base
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
# Explicit version because both checkouts land in subdirs
|
||||
# (head/, base/) so there's no package.json at the runner's CWD
|
||||
# root for the action to autodetect "packageManager" from.
|
||||
version: 9
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: pnpm
|
||||
cache-dependency-path: head/pnpm-lock.yaml
|
||||
- name: Build + measure HEAD
|
||||
working-directory: head
|
||||
run: |
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm build:extension
|
||||
node scripts/check-bundle-size.mjs --json ../sizes/head.json
|
||||
- name: Build + measure BASE
|
||||
working-directory: base
|
||||
run: |
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm build:extension
|
||||
# The size script resolves ../js relative to its own file URL,
|
||||
# so we overwrite base/scripts/check-bundle-size.mjs with the head
|
||||
# version to guarantee the JSON manifest format matches what
|
||||
# bundle-size-diff expects, while keeping the relative-path read.
|
||||
mkdir -p scripts
|
||||
cp ../head/scripts/check-bundle-size.mjs scripts/check-bundle-size.mjs
|
||||
node scripts/check-bundle-size.mjs --json ../sizes/base.json
|
||||
continue-on-error: true
|
||||
- name: Diff manifests
|
||||
run: node head/scripts/bundle-size-diff.mjs sizes/base.json sizes/head.json > report.md
|
||||
- name: Upsert PR comment
|
||||
uses: marocchino/sticky-pull-request-comment@v2
|
||||
with:
|
||||
header: bundle-size
|
||||
path: report.md
|
||||
@@ -39,7 +39,14 @@ jobs:
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm typecheck
|
||||
- run: pnpm lint
|
||||
- run: pnpm test
|
||||
- run: pnpm test:coverage
|
||||
- run: pnpm build:extension
|
||||
- run: pnpm size
|
||||
- run: pnpm build:manager
|
||||
- name: Upload coverage report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: coverage
|
||||
path: coverage/
|
||||
retention-days: 14
|
||||
|
||||
@@ -16,6 +16,7 @@ pnpm-debug.log
|
||||
js/
|
||||
web_dist/
|
||||
coverage/
|
||||
.vitest-cache/
|
||||
|
||||
# Editor / OS
|
||||
.vscode/
|
||||
@@ -25,7 +26,10 @@ Thumbs.db
|
||||
|
||||
# Claude / Superpowers workspace artifacts
|
||||
.superpowers/
|
||||
.claude/worktrees/
|
||||
.claude/
|
||||
docs/superpowers/
|
||||
# Backups dropped by /caveman:compress next to compressed files
|
||||
*.original.md
|
||||
|
||||
# Semantic release
|
||||
.releaserc.local.js
|
||||
|
||||
@@ -3,6 +3,12 @@ pnpm lint || exit 1
|
||||
pnpm typecheck || exit 1
|
||||
pnpm test || exit 1
|
||||
|
||||
# Bundle size budget — build the extension chunk and run the size check so
|
||||
# regressions get caught locally before the CI flags them on PR. Skips when
|
||||
# js/ output already matches the source by mtime to keep repeat commits fast.
|
||||
pnpm build:extension || exit 1
|
||||
pnpm size || exit 1
|
||||
|
||||
# Python checks — only if ruff/pytest reachable on PATH
|
||||
if command -v ruff >/dev/null 2>&1; then
|
||||
ruff check . || exit 1
|
||||
|
||||
@@ -1,3 +1,17 @@
|
||||
# [1.3.0](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/compare/v1.2.0...v1.3.0) (2026-04-26)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* i18n locales (en) + per-node help pages ([#2](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/issues/2)) ([5216547](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/52165471c0da315db59d2b2fb72537d7b06d62ef))
|
||||
|
||||
# [1.2.0](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/compare/v1.1.0...v1.2.0) (2026-04-26)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* polish suite — MVP frontend + subgraph support + DX + a11y ([79bdb45](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/79bdb457cbc98e191766e3fa7c84df2e1106ecc2))
|
||||
|
||||
# [1.1.0](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/compare/v1.0.0...v1.1.0) (2026-04-24)
|
||||
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# CLAUDE.md — project conventions
|
||||
|
||||
This file teaches future Claude (and human) contributors the load-bearing conventions of this codebase. Keep it short and honest.
|
||||
File teach future Claude (and human) contributors load-bearing conventions of codebase. Keep short, honest.
|
||||
|
||||
## Stack
|
||||
|
||||
- Python ≥ 3.10 (target 3.10/3.11/3.12 in CI; embedded runtime is 3.12.10).
|
||||
- Python ≥ 3.10 (target 3.10/3.11/3.12 in CI; embedded runtime 3.12.10).
|
||||
- ComfyUI V3 API (`comfy_api.latest`) — no V1 `NODE_CLASS_MAPPINGS`.
|
||||
- Vue 3 + Vite 5 + pnpm. Strict TypeScript.
|
||||
- Vitest for frontend, pytest for Python.
|
||||
@@ -12,11 +12,12 @@ This file teaches future Claude (and human) contributors the load-bearing conven
|
||||
## Directory contract
|
||||
|
||||
- `engine/` — pure Python, **zero ComfyUI imports**. Testable with vanilla pytest.
|
||||
- `nodes/` — ComfyUI V3 nodes. All ComfyUI imports wrapped in `try/except ImportError` so the package remains importable outside ComfyUI for tests.
|
||||
- `src/` — Vue/TS frontend. Two Vite targets: `extension` (critical path, budget-gated) and `manager` (post-MVP SPA).
|
||||
- `tests/` — Python tests. Uses `conftest.py` sys.path shim.
|
||||
- `scripts/` — Node helpers (bundle-size gate, release helpers).
|
||||
- `docs/superpowers/{specs,plans}/` — design docs and implementation plans.
|
||||
- `wp_nodes/` — ComfyUI V3 nodes. Named `wp_nodes` (not `nodes`) because ComfyUI repo-level `nodes.py` shadow any top-level `nodes/` package on `sys.path`.
|
||||
- `src/` — Vue/TS frontend. Two Vite targets: `extension` (critical path, budget-gated) + `manager` (post-MVP SPA).
|
||||
- `tests/` — Python tests. Use `conftest.py` sys.path shim.
|
||||
- `scripts/` — Node helpers (bundle-size gate, size-delta diff, release helpers).
|
||||
- `docs/widget-context-contract.md` — single canonical engine ↔ widget JSON shape doc. Update both Python (`engine/modules.py`) + TS (`src/widgets/_shared.ts`) sides in one PR when shape change.
|
||||
- `docs/superpowers/{specs,plans}/` — gitignored per-contributor design docs + impl plans.
|
||||
|
||||
## Commands
|
||||
|
||||
@@ -24,31 +25,45 @@ This file teaches future Claude (and human) contributors the load-bearing conven
|
||||
pnpm build:extension # produces js/main.js
|
||||
pnpm build:manager # produces web_dist/
|
||||
pnpm test # Vitest
|
||||
pnpm test:coverage # Vitest + v8 coverage (with thresholds)
|
||||
pnpm typecheck # vue-tsc --noEmit
|
||||
pnpm lint # ESLint flat config
|
||||
pnpm size # bundle-size gate
|
||||
pnpm size # bundle-size gate (--json <path> for manifest)
|
||||
pytest # Python unit tests
|
||||
ruff check . # Python lint
|
||||
```
|
||||
|
||||
## Load-bearing conventions
|
||||
|
||||
- **Engine isolation** — never import anything from `nodes/`, `comfy_api`, or `torch` in `engine/`. Engine is dict-free of ComfyUI globals. Breaking this breaks tests.
|
||||
- **Internal keys** — context keys starting with `__` are engine-internal; strip them at socket boundaries (see `nodes/types.py:strip_internals`).
|
||||
- **Module IDs** — 8-hex-char short UUIDs. Matches the wildcard `@{uuid}` reference syntax planned for the next spec.
|
||||
- **Engine isolation** — never import from `wp_nodes/`, `comfy_api`, or `torch` in `engine/`. Engine dict-free of ComfyUI globals. Break this break tests.
|
||||
- **Internal keys** — context keys starting with `__` engine-internal; strip at socket boundaries (see `wp_nodes/types.py:strip_internals`).
|
||||
- **Module IDs** — 8-hex-char short UUIDs. Match wildcard `@{uuid}` reference syntax planned for next spec.
|
||||
- **V3 node IDs** — prefix `WP_`, category `wildcard-pipeline`.
|
||||
- **Custom widget types** — `WP_CONTEXT_MODULES`, `WP_DEBUG_VIEWER`. Registered via `app.registerExtension({ getCustomWidgets })`. The Assembler helper widget is injected via `beforeRegisterNodeDef → onNodeCreated`, not `getCustomWidgets`, because it sits alongside a native STRING template widget.
|
||||
- **Conflict scanner** — advisory only; never block execution. Runtime is last-write-wins.
|
||||
- **Bundle-size gate** — entry `main.js` must stay ≤ 30 KB gzipped; total `js/` ≤ 250 KB. Raising the budget requires explicit PR discussion.
|
||||
- **Caching** — `WP_Context` and `WP_Debug` set `not_idempotent=True` so seed cycling always re-executes.
|
||||
- **Conventional Commits** — enforced by commitlint. semantic-release reads the log to cut versions.
|
||||
- **Custom widget types** — `WP_CONTEXT_MODULES`, `WP_DEBUG_VIEWER`. Registered via `app.registerExtension({ getCustomWidgets })`. Assembler helper widget injected via `beforeRegisterNodeDef → onNodeCreated`, not `getCustomWidgets`, because sit alongside native STRING template widget.
|
||||
- **Toast singleton** — one Vue app mounted to body-level div renders all toasts pushed via `src/components/shared/toast-store.ts`. Every Context node import same store so toasts surface from anywhere. Position mirror ComfyUI native PrimeVue toast anchor (rect-relative to `.graph-canvas-container`).
|
||||
- **Subgraph traversal** — `collectUpstreamVariables` / `collectUpstreamValues` / `findDownstreamAssemblers` cross subgraph boundaries via `-10` SubgraphInputNode + `-20` SubgraphOutputNode sentinel ids. Walkers expect `app.graph` (root); per-call `buildSubgraphParents` map handle step-out. Use `walkAllNodes(rootGraph)` for graph-wide scans (e.g. pre-run validation).
|
||||
- **Subgraph badges** — `extension/subgraph-badge.ts` attach `LGraphBadge` (via `window.LGraphBadge`) to any SubgraphNode containing WP nodes. Badge surface worst inner conflict severity with human label (e.g. `missing $foo`, `2 conflicts`). Wire via `nodeCreated` + `loadedGraphNode` hooks — `beforeRegisterNodeDef` does NOT fire for SubgraphNodes.
|
||||
- **Conflict scanner** — advisory only; never block execution. Runtime last-write-wins. Three types: `shadows_upstream` (info), `duplicate_variable` (warning), `missing_template_variable` (warning).
|
||||
- **Bundle-size gate** — entry `main.js` must stay ≤ 30 KB gzipped; total `js/` ≤ 250 KB. Pre-commit run `pnpm build:extension && pnpm size`. Raising budget require explicit PR discussion.
|
||||
- **Coverage gate** — vitest v8 coverage with thresholds (lines/statements 50%, functions 40%, branches 65%). Anchored to current baseline; ratchet up alongside new tests.
|
||||
- **Caching** — `WP_Context` + `WP_Debug` set `not_idempotent=True` so seed cycling always re-executes.
|
||||
- **Conventional Commits** — enforced by commitlint. semantic-release read log to cut versions.
|
||||
|
||||
## Frontend overview
|
||||
|
||||
- **Entry** `src/main.ts` → `js/main.js` (~1.6 KB gzip). Top-level await preload widget chunks so `getCustomWidgets` factories run sync (Promise return → `{widget: undefined}` and widget never mount). Register extension + lazy-import widget code only when ComfyUI hand matching node.
|
||||
- **Lazy chunks** `src/widgets/{context,debug,assembler}.ts` — one mount glue per widget. Each `import()` become own asset chunk, plus sibling SFC chunk (`ContextWidget`, `DebugViewer`, `AssemblerHelper`).
|
||||
- **Shared** `src/widgets/_shared.ts` expose `createDomWidgetHost`, JSON helpers (`parseWidgetJson`, `parseWidgetJsonWithRecovery` for corrupt-workflow recovery path), shared types. Largest shared chunk (`_shared`, ~33 KB gzip) is Vue runtime + plugin-vue helper, pulled in by every widget on first use.
|
||||
- **Graph + conflicts** `src/extension/{graph,conflicts,subgraph-badge,reactive,graph-events}.ts` — pure logic, no DOM. Imported by widgets, easy to unit-test isolated.
|
||||
- **Reactivity** `extension/reactive.ts:reactiveFromGraph` — wires `onConnectionsChange` chain + 400ms polling fallback + `afterConfigureGraph` re-sync so widgets recompute on graph edits + workflow loads without flashing stale state.
|
||||
- **Adding new widget**: (1) write SFC under `src/components/<name>/`, (2) add `src/widgets/<name>.ts` exposing `create(node, inputName)` that return `createDomWidgetHost(...)`, (3) wire in `src/main.ts` — either as `getCustomWidgets` entry (for inputs declared with matching widget-type) or via `beforeRegisterNodeDef → onNodeCreated` (for free-floating helpers). Use `import("./widgets/<name>")` so stay out of entry chunk + size gate keep holding.
|
||||
|
||||
## Anti-patterns (inherited from reference project `ComfyUI-Wildcard-Pipeline`)
|
||||
|
||||
- Never use V1 `NODE_CLASS_MAPPINGS` / `EXTENSION_WEB_DIRS`.
|
||||
- Never `console.log` for user warnings — use the ComfyUI toast API.
|
||||
- Never `console.log` for user warnings — use ComfyUI toast API.
|
||||
- Never use `getInputNode()` / `findInputSlot()` — walk via `node.inputs[].link → app.graph.links[linkId] → graph.getNodeById(link.origin_id)`.
|
||||
- Never use `requestAnimationFrame` in `onConnectionsChange`; the link is already established when the callback fires.
|
||||
- Never register an SPA catch-all route before API routes.
|
||||
- Never use `requestAnimationFrame` in `onConnectionsChange`; link already established when callback fires.
|
||||
- Never register SPA catch-all route before API routes.
|
||||
- Never suppress types with `as any`, `@ts-ignore`, `@ts-expect-error`.
|
||||
- Never `cssInjectedByJsPlugin` on the extension bundle — inflates critical-path load.
|
||||
- `cssInjectedByJsPlugin` required, not optional. Vite library mode emit CSS files but never inject `<link>` tags, and ComfyUI load our `main.js` as bare ES module so `<link rel="modulepreload">`-style auto-loading does not apply. Per-chunk runtime injection (`relativeCSSInjection: true`) keep entry chunk CSS-free — styles only get injected when owning lazy chunk loads. This what every Vue-based ComfyUI extension in wild does (see `ComfyUI-Easy-Use/web_version/v2/easyuse.js`).
|
||||
@@ -1,4 +1,10 @@
|
||||
# ComfyUI-WildcardPipeline
|
||||
<p align="center">
|
||||
<img src="public/images/favicon.svg" width="120" alt="wildcard-pipeline logo" />
|
||||
</p>
|
||||
|
||||
<h1 align="center">wildcard-pipeline</h1>
|
||||
|
||||
<p align="center"><em>Composable prompt context for ComfyUI.</em></p>
|
||||
|
||||
Modular procedural prompt generation for ComfyUI — wildcards, constraints, derivations, and context pipelines.
|
||||
|
||||
|
||||
@@ -1,23 +1,38 @@
|
||||
"""ComfyUI-WildcardPipeline — modular procedural prompt generation.
|
||||
"""ComfyUI-WildcardPipeline — modular procedural prompt generation."""
|
||||
|
||||
MVP scaffold. Real node registration lands in Plan P3. This stub exists
|
||||
so ComfyUI's custom-node loader doesn't error on directory load.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
from __future__ import annotations
|
||||
# Tells ComfyUI to serve files from this folder under
|
||||
# /extensions/<package-name>/ and auto-load any `*.js` it finds.
|
||||
WEB_DIRECTORY = "./js"
|
||||
|
||||
try:
|
||||
from comfy_api.latest import ComfyExtension # pyright: ignore[reportMissingImports]
|
||||
# Expose engine/ and wp_nodes/ as top-level packages so internal absolute imports
|
||||
# (``from engine.X import ...``, ``from wp_nodes.X import ...``) resolve the same
|
||||
# whether ComfyUI's custom-node loader or pytest invokes us. ComfyUI sets up
|
||||
# ``submodule_search_locations`` on this package but does not add the package
|
||||
# dir to ``sys.path``, so absolute imports to ``engine``/``wp_nodes`` would fail
|
||||
# without this shim. ``wp_nodes`` (not ``nodes``) avoids collision with
|
||||
# ComfyUI's top-level ``nodes.py`` module.
|
||||
_PKG_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
if _PKG_DIR not in sys.path:
|
||||
sys.path.insert(0, _PKG_DIR)
|
||||
|
||||
class WildcardPipelineExtension(ComfyExtension):
|
||||
"""Empty scaffold extension — nodes added in P3."""
|
||||
# pyright: reportMissingImports=false
|
||||
# ruff: noqa: E402 — imports must follow the sys.path shim above
|
||||
from comfy_api.latest import ComfyExtension
|
||||
|
||||
async def get_node_list(self):
|
||||
return []
|
||||
from wp_nodes.assembler_node import WPPromptAssembler
|
||||
from wp_nodes.context_node import WPContext
|
||||
from wp_nodes.debug_node import WPDebug
|
||||
|
||||
async def comfy_entrypoint() -> WildcardPipelineExtension:
|
||||
return WildcardPipelineExtension()
|
||||
|
||||
except ImportError:
|
||||
# Running outside ComfyUI (e.g. pytest) — nothing to register.
|
||||
pass
|
||||
class WildcardPipelineExtension(ComfyExtension):
|
||||
"""Registers all WP nodes with ComfyUI."""
|
||||
|
||||
async def get_node_list(self):
|
||||
return [WPContext, WPPromptAssembler, WPDebug]
|
||||
|
||||
|
||||
async def comfy_entrypoint() -> WildcardPipelineExtension:
|
||||
return WildcardPipelineExtension()
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
"""Root conftest — lets pytest import engine/nodes without installing the package.
|
||||
"""Root conftest — lets pytest import engine/wp_nodes without installing the package.
|
||||
|
||||
Also collects-ignores ComfyUI-dependent directories when comfy_api is absent.
|
||||
If ``comfy_api`` is not importable (running outside ComfyUI), inject the
|
||||
``tests/stubs/comfy_api_stub`` module into ``sys.modules`` as both
|
||||
``comfy_api`` and ``comfy_api.latest`` so production code that does
|
||||
``from comfy_api.latest import ComfyExtension, io`` succeeds in tests.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -13,9 +16,11 @@ ROOT = Path(__file__).parent
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
collect_ignore: list[str] = []
|
||||
|
||||
# Tests for node wrappers require comfy_api; skip them when it's unavailable
|
||||
# (e.g. running pytest outside ComfyUI's Python environment).
|
||||
if importlib.util.find_spec("comfy_api") is None:
|
||||
collect_ignore.append("tests/test_nodes.py")
|
||||
from tests.stubs import comfy_api_stub as _stub
|
||||
|
||||
sys.modules.setdefault("comfy_api", _stub)
|
||||
sys.modules.setdefault("comfy_api.latest", _stub)
|
||||
sys.modules.setdefault("comfy_api.latest._io", _stub.io._io)
|
||||
|
||||
collect_ignore: list[str] = []
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# WP Context
|
||||
|
||||
Hold a list of named values you can use later in your prompt. Each value gets a name (like `style` or `subject`) and the value you want it to be (like `watercolor` or `a cat`). Then a **WP Prompt Assembler** can plug those values into your prompt wherever you write `$style` or `$subject`.
|
||||
|
||||
## Adding values
|
||||
|
||||
A **module** is one small group of values. Click the big purple **Add your first module** button, or **+ add module** if you already have some.
|
||||
|
||||
Each module appears as a small card. Right-click the card and pick **Edit** to:
|
||||
|
||||
- Give it a name (just a label so you remember what's inside, like "Character details")
|
||||
- Add as many `name = value` lines as you need
|
||||
- Paste a bunch of lines at once if you have them ready
|
||||
|
||||
Press **Enter** on the value field to add another row quickly. **Ctrl+Enter** saves and closes.
|
||||
|
||||
## What the icons on a card mean
|
||||
|
||||
- **☰** Drag handle — drag a card to reorder, or drag onto another Context node to move it there
|
||||
- **▾ / ▸** Show or hide the values preview
|
||||
- **Toggle (checkbox)** — turn a module off without deleting it. Useful for trying variations.
|
||||
- **✕** Remove the module. Don't worry about misclicks — an **Undo** button appears for a few seconds.
|
||||
|
||||
## Stacking multiple Contexts
|
||||
|
||||
Connect another **WP Context** node into the **upstream** input. The upstream Context's values flow through. If both define the same name, this Context wins (it's the closer one to your prompt).
|
||||
|
||||
This lets you build a library: "character" Context → "scene" Context → "style" Context → Prompt Assembler.
|
||||
|
||||
## Conflict dots
|
||||
|
||||
A card may show a small colored dot:
|
||||
|
||||
- 🔵 **Blue (info)** — You're overriding something an upstream Context already provides. Usually that's exactly what you want.
|
||||
- 🟡 **Amber (warning)** — Two of your modules in this Context use the same name. One quietly overwrites the other; check that's intentional.
|
||||
|
||||
Hover the dot to see exactly which variable.
|
||||
|
||||
## Output
|
||||
|
||||
Wire **context** out into a **WP Prompt Assembler** (or another **WP Context** to chain).
|
||||
@@ -0,0 +1,22 @@
|
||||
# WP Debug
|
||||
|
||||
Drop this anywhere in your pipeline to see exactly what variables are flowing through that point. Like a window into the chain.
|
||||
|
||||
## How to use
|
||||
|
||||
1. Connect the **context** input to any WP node (Context or Prompt Assembler).
|
||||
2. Run the workflow once.
|
||||
3. The viewer below shows every variable name and its value at that point in the chain.
|
||||
|
||||
That's it. There's nothing to wire on the output side — Debug is read-only.
|
||||
|
||||
## When it's useful
|
||||
|
||||
- **"Why is `$style` showing as empty?"** Drop a Debug after each Context to see where the value gets lost or overridden.
|
||||
- **"Did my new module override the old one correctly?"** The trace shows which module set each variable.
|
||||
- **"Are upstream variables coming through the chain?"** Drop a Debug right before your Prompt Assembler to confirm.
|
||||
|
||||
## Tips
|
||||
|
||||
- The viewer fills the node body. Drag the bottom-right corner of the node to make it bigger if you have lots of variables.
|
||||
- Debug nodes always re-run, even when nothing visually changed. Helpful when you're cycling seeds and want to see the values shift.
|
||||
@@ -0,0 +1,47 @@
|
||||
# WP Prompt Assembler
|
||||
|
||||
Write your prompt as a fill-in-the-blank template. Anywhere you put `$something`, the Assembler swaps in the matching value from the upstream Context.
|
||||
|
||||
## Writing a template
|
||||
|
||||
Type your prompt in the **template** box. Use `$name` to reference any variable from the connected Context.
|
||||
|
||||
**Example template:**
|
||||
```
|
||||
a portrait of $subject, $style, $mood
|
||||
```
|
||||
|
||||
If the upstream Context provides:
|
||||
- `subject = a cat`
|
||||
- `style = watercolor`
|
||||
- `mood = sleepy`
|
||||
|
||||
…the final prompt becomes:
|
||||
```
|
||||
a portrait of a cat, watercolor, sleepy
|
||||
```
|
||||
|
||||
## The helper below the template
|
||||
|
||||
**Variables** lists every value available from the upstream Context. Click any chip to insert `$name` into your template at the cursor — saves typing and avoids typos.
|
||||
|
||||
**Preview** shows what the final prompt will look like, color-coded:
|
||||
|
||||
- **Filled in (purple highlight)** — the variable is resolved, you see its actual value.
|
||||
- **Outlined chip** — the name will resolve, just briefly during loading.
|
||||
- **Amber wavy underline** — missing! Nothing upstream provides this name, so it'll render empty.
|
||||
|
||||
If your template is empty, the preview shows a placeholder instead.
|
||||
|
||||
## Special characters
|
||||
|
||||
- Use `$$` if you want a literal `$` in your prompt (e.g. `$$10` to mean "ten dollars" rather than a variable named `10`).
|
||||
- Variable names can contain letters, numbers, and underscores only. `$my_var` works; `$my-var` doesn't.
|
||||
|
||||
## Output
|
||||
|
||||
**prompt** is the final assembled string. Connect it to a **CLIP Text Encode** node (or any text input).
|
||||
|
||||
## Pre-flight warnings
|
||||
|
||||
When you press the **Run** button, the extension scans your template for `$variables` that aren't provided anywhere upstream. If it finds any, you'll see one toast at the top of the screen listing them. The run still happens — missing variables just become empty strings — but it's a heads-up in case you have a typo.
|
||||
@@ -1,733 +0,0 @@
|
||||
# ComfyUI-WildcardPipeline — MVP Walking Skeleton (Design)
|
||||
|
||||
- **Date:** 2026-04-24
|
||||
- **Author:** DumiFlex
|
||||
- **Status:** Approved — ready for implementation planning
|
||||
- **Scope class:** MVP (follow-up sub-specs listed in §9)
|
||||
- **Reference project (not to be copied wholesale):** `E:\AI\ComfyUI\custom_nodes\ComfyUI-Wildcard-Pipeline`
|
||||
|
||||
## 1. Vision & MVP Goals
|
||||
|
||||
**Project vision.** Modular, procedural prompt generation for ComfyUI — *not* chaotic random wildcards. Users compose reusable logic (wildcards, constraints, derivations, combines, fixed values) inside a `WP_Context` node, chain an unlimited number of context nodes for layered generation, and feed the resulting structured context to a `WP_PromptAssembler` that fills `$var` placeholders in a user-authored template. A separate SPA (post-MVP) makes modules and whole pipelines reusable/shareable. Workflows always remain portable because module data is serialized inline into the workflow JSON.
|
||||
|
||||
**MVP goals (this spec).** Prove the walking skeleton end-to-end:
|
||||
|
||||
1. Render a Vue-powered custom DOM widget inside a ComfyUI V3 node.
|
||||
2. Manage an ordered list of modules of a single type — **`fixed_values`** — via that widget.
|
||||
3. Serialize module list into `node.widgets_values` so workflow save/load round-trips.
|
||||
4. Custom `PIPELINE_CONTEXT` socket type carries context across an unlimited chain of `WP_Context` nodes.
|
||||
5. Downstream-overwrite semantics: runtime silently last-write-wins; UI warns via conflict scanner.
|
||||
6. `WP_PromptAssembler` consumes context, resolves `$var` and `$$` escape.
|
||||
7. `WP_Debug` node renders context as a readable Vue viewer widget.
|
||||
8. Engine stays pure Python (`engine/` with zero ComfyUI imports), typed via dataclasses + `TypedDict`.
|
||||
9. Tests: engine unit tests + conflict scanner tests, no ComfyUI runtime required.
|
||||
10. Extension frontend bundle cold-loads fast (hard budget, see §6).
|
||||
|
||||
**MVP non-goals (deferred to named follow-up specs in §9).** Wildcards, constraints, derivations, combines, `WP_ContextInject` node, sqlite library, SPA manager, pipeline presets, `WP_DEBUG` env socket, instance-override UI, save-to-library context menu, module version history.
|
||||
|
||||
## 2. Architecture Overview
|
||||
|
||||
Three-layer split, same isolation principle as the reference project.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Frontend (Vue 3 + Vite + PrimeVue + Pinia) │
|
||||
│ src/main.ts — ComfyUI extension entry │
|
||||
│ src/extension/widgets.ts — Vue-in-DOMWidget factory │
|
||||
│ src/extension/graph.ts — upstream/downstream walkers │
|
||||
│ src/extension/conflicts.ts — conflict scanner │
|
||||
│ src/components/context/* — Context widget + module list │
|
||||
│ src/components/assembler/* — Assembler helper widget │
|
||||
│ src/components/debug/* — Debug viewer widget │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
↕ serialize JSON string
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Nodes (ComfyUI V3 — comfy_api.latest) │
|
||||
│ nodes/context_node.py — WP_Context │
|
||||
│ nodes/assembler_node.py — WP_PromptAssembler │
|
||||
│ nodes/debug_node.py — WP_Debug │
|
||||
│ nodes/types.py — PipelineContext custom type │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
↕ dataclass from_dict / to_dict
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Engine (pure Python, zero ComfyUI imports) │
|
||||
│ engine/context.py — Context TypedDict + helpers │
|
||||
│ engine/modules.py — Module dataclasses + union │
|
||||
│ engine/pipeline.py — PipelineEngine.run() │
|
||||
│ engine/handlers.py — fixed_values handler │
|
||||
│ engine/template.py — resolve_variables ($var,$$) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Boundary contracts.**
|
||||
|
||||
- Frontend ⇄ Nodes: only via ComfyUI widget serialization (a single JSON string per node stored in `widgets_values`).
|
||||
- Nodes ⇄ Engine: nodes deserialize the widget JSON into engine dataclasses, call `PipelineEngine.run()`, then wrap the returned context into a `ContextPayload` and emit it on the `PIPELINE_CONTEXT` socket.
|
||||
- Engine ⇄ anything else: pure. Imports stdlib only. Testable with plain `pytest`, no ComfyUI required.
|
||||
|
||||
**Execution dataflow per workflow run.**
|
||||
|
||||
```
|
||||
upstream WP_Context output ──▶ PIPELINE_CONTEXT socket ──┐
|
||||
▼
|
||||
WP_Context.execute()
|
||||
├── inherit upstream ctx
|
||||
├── deserialize modules widget
|
||||
├── engine.run(modules, ctx, seed)
|
||||
└── emit ContextPayload(ctx, debug)
|
||||
│
|
||||
▼
|
||||
WP_PromptAssembler.execute()
|
||||
└── resolve $vars in template
|
||||
│
|
||||
▼
|
||||
STRING output
|
||||
```
|
||||
|
||||
No cross-cutting side channels. Every boundary is a single serialized payload.
|
||||
|
||||
## 3. Data Model
|
||||
|
||||
### 3.1 Context (engine-side)
|
||||
|
||||
Flat dict of string-keyed vars plus `__`-prefixed internal keys. A `TypedDict` documents the reserved internals; user vars remain open.
|
||||
|
||||
```python
|
||||
# engine/context.py
|
||||
from typing import TypedDict, Any
|
||||
|
||||
class ContextInternals(TypedDict, total=False):
|
||||
__wp_node_seed__: int # current node's seed
|
||||
__wp_internal_flags__: dict[str, bool] # varname → internal?
|
||||
__wp_trace__: list[dict[str, Any]] # per-module execution log
|
||||
|
||||
Context = dict[str, Any] # union of user vars + ContextInternals keys
|
||||
```
|
||||
|
||||
### 3.2 Modules (engine-side)
|
||||
|
||||
Dataclass per type + discriminated union via `type: Literal[...]`. MVP ships one type plus a shared metadata block.
|
||||
|
||||
```python
|
||||
# engine/modules.py
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Literal, Union
|
||||
|
||||
@dataclass
|
||||
class ModuleMeta:
|
||||
name: str = "" # free-form display label, duplicates allowed
|
||||
description: str = ""
|
||||
category: str = "" # user-chosen grouping
|
||||
tags: list[str] = field(default_factory=list)
|
||||
|
||||
@dataclass
|
||||
class FixedValueEntry:
|
||||
variable_name: str
|
||||
value: str
|
||||
|
||||
@dataclass
|
||||
class FixedValueModule:
|
||||
id: str # 8-hex-char short UUID (~4.3B space)
|
||||
type: Literal["fixed_values"] = "fixed_values"
|
||||
enabled: bool = True
|
||||
meta: ModuleMeta = field(default_factory=ModuleMeta)
|
||||
entries: list[FixedValueEntry] = field(default_factory=list)
|
||||
|
||||
Module = Union[FixedValueModule] # widens as new types land
|
||||
|
||||
def module_from_dict(d: dict) -> Module: ... # dispatch on d["type"]
|
||||
def module_to_dict(m: Module) -> dict: ... # stdlib asdict
|
||||
```
|
||||
|
||||
Short UUID format matches the reference project's `@{uuid}` nested-wildcard reference syntax — forward-compatible when the Wildcards spec lands.
|
||||
|
||||
### 3.3 Widget payload (frontend ↔ node)
|
||||
|
||||
One DOM widget per `WP_Context` node holds the ordered module list as a JSON string in `widgets_values`.
|
||||
|
||||
```jsonc
|
||||
// widget value (JSON string)
|
||||
{
|
||||
"version": 1,
|
||||
"modules": [
|
||||
{
|
||||
"id": "…",
|
||||
"type": "fixed_values",
|
||||
"enabled": true,
|
||||
"meta": { "name": "style defaults", "tags": ["character"] },
|
||||
"entries": [{ "variable_name": "style", "value": "photorealistic" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`version` enables forward-compatible migrations when new module types land.
|
||||
|
||||
### 3.4 `PIPELINE_CONTEXT` socket payload
|
||||
|
||||
```python
|
||||
# nodes/types.py
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
from comfy_api.latest._io import comfytype, ComfyTypeIO
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ContextPayload:
|
||||
context: dict[str, Any] = field(default_factory=dict) # user vars only
|
||||
debug: dict[str, Any] = field(default_factory=dict) # trace + meta
|
||||
|
||||
@comfytype(io_type="PIPELINE_CONTEXT")
|
||||
class PipelineContext(ComfyTypeIO):
|
||||
Type = ContextPayload
|
||||
```
|
||||
|
||||
Internals (`__…`) are stripped at the socket boundary so downstream nodes can't accidentally depend on private state.
|
||||
|
||||
### 3.5 Seed model
|
||||
|
||||
`WP_Context` has a standard `io.Int` seed widget (with `control_after_generate`). Engine stores it as `ctx["__wp_node_seed__"]` and passes through. `fixed_values` doesn't consume it. The Wildcards follow-up spec will hash `(node_seed, capture_name)` via SHA-256 as the reference project does.
|
||||
|
||||
### 3.6 Debug JSON shape (MVP)
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"node_seed": 12345,
|
||||
"modules": [
|
||||
{
|
||||
"id": "…",
|
||||
"type": "fixed_values",
|
||||
"enabled": true,
|
||||
"writes": [
|
||||
{ "variable": "style", "value": "photorealistic", "source": "fixed_values" }
|
||||
]
|
||||
}
|
||||
],
|
||||
"conflicts": []
|
||||
}
|
||||
```
|
||||
|
||||
## 4. Engine Internals
|
||||
|
||||
Pure Python, stdlib only. One file per concern.
|
||||
|
||||
### 4.1 `engine/template.py`
|
||||
|
||||
```python
|
||||
def resolve_variables(template: str, ctx: dict[str, Any]) -> str:
|
||||
"""Replace $var with ctx[var]. $$ escapes literal $. __-keys skipped."""
|
||||
```
|
||||
|
||||
Rules: `$var` → `str(ctx[var])` when present, otherwise left as-is; `$$` → literal `$`; `$__internal` never substituted.
|
||||
|
||||
### 4.2 `engine/handlers.py`
|
||||
|
||||
```python
|
||||
ModuleHandler = Callable[[Module, Context, random.Random], Context]
|
||||
|
||||
def handle_fixed_values(module: FixedValueModule, ctx: Context, rng: random.Random) -> Context:
|
||||
for entry in module.entries:
|
||||
name = entry.variable_name.lstrip("$")
|
||||
if not name:
|
||||
continue
|
||||
ctx[name] = entry.value
|
||||
return ctx
|
||||
```
|
||||
|
||||
`fixed_values` ignores `rng`. Signature keeps parity with future seed-consuming handlers (wildcard, derivation).
|
||||
|
||||
### 4.3 `engine/pipeline.py`
|
||||
|
||||
```python
|
||||
class PipelineEngine:
|
||||
HANDLERS: dict[str, ModuleHandler] = {
|
||||
"fixed_values": handle_fixed_values,
|
||||
}
|
||||
|
||||
def run(self, modules: list[Module], ctx: Context | None = None,
|
||||
seed: int = 0) -> Context:
|
||||
ctx = ctx if ctx is not None else {}
|
||||
ctx["__wp_node_seed__"] = seed
|
||||
ctx.setdefault("__wp_trace__", [])
|
||||
rng = random.Random(seed)
|
||||
|
||||
for index, module in enumerate(modules):
|
||||
if not module.enabled:
|
||||
continue
|
||||
handler = self.HANDLERS.get(module.type)
|
||||
if handler is None:
|
||||
logger.warning("Unknown module type '%s' at index %s", module.type, index)
|
||||
continue
|
||||
before = dict(ctx)
|
||||
ctx = handler(module, ctx, rng)
|
||||
ctx["__wp_trace__"].append(self._trace_entry(module, before, ctx))
|
||||
|
||||
return ctx
|
||||
```
|
||||
|
||||
### 4.4 Tracing
|
||||
|
||||
`_trace_entry(module, before, after)` yields one dict per handled module:
|
||||
|
||||
```python
|
||||
{
|
||||
"id": module.id,
|
||||
"type": module.type,
|
||||
"enabled": module.enabled,
|
||||
"writes": (
|
||||
[
|
||||
{"variable": k, "value": after[k], "source": module.type}
|
||||
for k in after.keys() - before.keys()
|
||||
if not k.startswith("__")
|
||||
]
|
||||
+ [
|
||||
{"variable": k, "value": after[k], "source": module.type, "overwrite": True}
|
||||
for k in after.keys() & before.keys()
|
||||
if not k.startswith("__") and after[k] != before[k]
|
||||
]
|
||||
),
|
||||
}
|
||||
```
|
||||
|
||||
### 4.5 Chain behaviour
|
||||
|
||||
`WP_Context.execute(upstream, seed, modules_json)`:
|
||||
|
||||
1. `ctx = dict(upstream.context) if upstream else {}` — seed from upstream user vars.
|
||||
2. `PipelineEngine().run(deserialize(modules_json), ctx, seed=seed)` — mutate with this node's modules.
|
||||
3. Emit `ContextPayload(context=strip_internals(ctx), debug={…})`.
|
||||
|
||||
Runtime silently last-write-wins. Conflict scanner (§7) warns in UI pre-execution.
|
||||
|
||||
### 4.6 Isolation invariants
|
||||
|
||||
- `engine/` imports stdlib only — no `torch`, no ComfyUI, no frontend.
|
||||
- No `print`; only `logging.getLogger(__name__)`.
|
||||
- No global mutable state. `PipelineEngine.HANDLERS` is a class attribute; extending requires subclass or a future registration API.
|
||||
- Pure functions where possible; handlers mutate context in place (performance over immutability — chains get long).
|
||||
|
||||
## 5. ComfyUI V3 Nodes
|
||||
|
||||
All three under category `wildcard-pipeline`; node IDs prefixed `WP_`.
|
||||
|
||||
### 5.1 Registration
|
||||
|
||||
```python
|
||||
# __init__.py
|
||||
from comfy_api.latest import ComfyExtension, io
|
||||
from .nodes.context_node import WPContext
|
||||
from .nodes.assembler_node import WPPromptAssembler
|
||||
from .nodes.debug_node import WPDebug
|
||||
|
||||
class WildcardPipelineExtension(ComfyExtension):
|
||||
async def get_node_list(self):
|
||||
return [WPContext, WPPromptAssembler, WPDebug]
|
||||
|
||||
async def comfy_entrypoint():
|
||||
return WildcardPipelineExtension()
|
||||
```
|
||||
|
||||
All ComfyUI imports are wrapped in `try/except ImportError` so `pytest` can import the package outside ComfyUI (ref. project pattern).
|
||||
|
||||
### 5.2 `WP_Context`
|
||||
|
||||
```python
|
||||
ContextModulesInput = io.Custom("WP_CONTEXT_MODULES") # widget-only custom type
|
||||
|
||||
class WPContext(io.ComfyNode):
|
||||
@classmethod
|
||||
def define_schema(cls):
|
||||
return io.Schema(
|
||||
node_id="WP_Context",
|
||||
display_name="WP Context",
|
||||
category="wildcard-pipeline",
|
||||
inputs=[
|
||||
PipelineContext.Input("upstream", optional=True),
|
||||
io.Int.Input("seed", default=0, min=0,
|
||||
max=0xFFFFFFFFFFFFFFFF, control_after_generate=True),
|
||||
ContextModulesInput.Input("modules", socketless=True),
|
||||
],
|
||||
outputs=[PipelineContext.Output("context")],
|
||||
not_idempotent=True,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def execute(cls, seed, modules, upstream=None):
|
||||
# `modules` arrives as the serialized JSON string produced by the DOM widget
|
||||
upstream_ctx = upstream.context if upstream else {}
|
||||
upstream_dbg = upstream.debug if upstream else {}
|
||||
module_list = deserialize_modules(modules)
|
||||
ctx = dict(upstream_ctx)
|
||||
ctx = PipelineEngine().run(module_list, ctx, seed=seed)
|
||||
payload = ContextPayload(
|
||||
context=strip_internals(ctx),
|
||||
debug={"upstream": upstream_dbg, "node_seed": seed,
|
||||
"trace": ctx.get("__wp_trace__", [])},
|
||||
)
|
||||
return io.NodeOutput(payload)
|
||||
```
|
||||
|
||||
- `upstream` is optional — root context nodes work without an input connection.
|
||||
- `modules` is a **custom-typed, `socketless=True` input** named `WP_CONTEXT_MODULES`. The frontend registers a widget factory under that exact type key via `app.registerExtension({ getCustomWidgets() })`. The factory creates the DOM element, mounts Vue, and manages JSON serialization into `widgets_values` via `node.addDOMWidget(inputName, "custom", container, { serialize: true, getValue, setValue })`.
|
||||
- `not_idempotent=True` prevents ComfyUI caching so seed cycling always re-executes.
|
||||
- Internals stripped at the socket boundary (§3.4).
|
||||
|
||||
### 5.3 `WP_PromptAssembler`
|
||||
|
||||
```python
|
||||
class WPPromptAssembler(io.ComfyNode):
|
||||
@classmethod
|
||||
def define_schema(cls):
|
||||
return io.Schema(
|
||||
node_id="WP_PromptAssembler",
|
||||
display_name="WP Prompt Assembler",
|
||||
category="wildcard-pipeline",
|
||||
inputs=[
|
||||
PipelineContext.Input("context"),
|
||||
io.String.Input("template", multiline=True,
|
||||
default="A $style portrait of $subject"),
|
||||
],
|
||||
outputs=[io.String.Output("prompt")],
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def execute(cls, context, template):
|
||||
return io.NodeOutput(resolve_variables(template, context.context))
|
||||
```
|
||||
|
||||
**DOM helper widget.** Template stays a native multiline STRING widget — *explicitly kept* so users can right-click → *Convert to Input* and wire a string-producing node into it. A separate read-only Vue helper widget is injected *after* the template widget via the `beforeRegisterNodeDef` lifecycle hook (matches reference project's assembler pattern — `getCustomWidgets` can't add widgets to existing typed inputs, but `onNodeCreated` can call `node.addDOMWidget(...)` directly after the node's default widgets are built):
|
||||
|
||||
```ts
|
||||
// simplified — full code in src/extension/widgets.ts
|
||||
app.registerExtension({
|
||||
async beforeRegisterNodeDef(nodeType, nodeData) {
|
||||
if (nodeData.name !== "WP_PromptAssembler") return;
|
||||
const origOnCreated = nodeType.prototype.onNodeCreated;
|
||||
nodeType.prototype.onNodeCreated = function (...args) {
|
||||
origOnCreated?.apply(this, args);
|
||||
import("./widgets/assembler").then(m => m.mountHelper(this));
|
||||
};
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
The helper widget contains:
|
||||
|
||||
- **Variable chips row.** Pills for each variable captured by upstream chain (collected via `collectUpstreamVariables`). Click → inserts `$var` into the textarea at cursor position. Hover shows origin node name. When template is converted-to-input (no textarea in DOM), chip-insert handler feature-detects and disables; chips remain visible in read-only mode.
|
||||
- **Preview pane.** Static analysis only: renders template text with each `$var` tagged ✅ *defined* if found in upstream captured set, ⚠️ *unresolved* otherwise. No actual value substitution in MVP (that requires running the engine client-side or an API call — deferred to Wildcards spec via a dedicated preview endpoint).
|
||||
|
||||
Helper widget calls `addDOMWidget(..., { serialize: false })` — template value is owned exclusively by the native template widget, preventing double-source-of-truth.
|
||||
|
||||
### 5.4 `WP_Debug`
|
||||
|
||||
```python
|
||||
DebugViewerInput = io.Custom("WP_DEBUG_VIEWER") # widget-only custom type
|
||||
|
||||
class WPDebug(io.ComfyNode):
|
||||
@classmethod
|
||||
def define_schema(cls):
|
||||
return io.Schema(
|
||||
node_id="WP_Debug",
|
||||
display_name="WP Debug",
|
||||
category="wildcard-pipeline",
|
||||
inputs=[
|
||||
PipelineContext.Input("context"),
|
||||
DebugViewerInput.Input("viewer", socketless=True),
|
||||
],
|
||||
outputs=[],
|
||||
is_output_node=True,
|
||||
not_idempotent=True,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def execute(cls, context, viewer):
|
||||
import json
|
||||
dumped = json.dumps(
|
||||
{"context": context.context, "debug": context.debug},
|
||||
default=str, indent=2,
|
||||
)
|
||||
return io.NodeOutput(ui={"wp_debug_snapshot": [dumped]})
|
||||
```
|
||||
|
||||
**DOM viewer widget (`WP_DEBUG_VIEWER`).** Registered via `getCustomWidgets` keyed by the custom input type name. Two tabs:
|
||||
|
||||
1. **Context tab.** Tree / key-value viewer of user vars. Collapsible, type badges (string/number/bool/object). Copy-value button per row.
|
||||
2. **Trace tab.** Ordered list of module executions with per-module writes (variable, value, overwrite flag), node seed. Filter/search bar. Colour-coded by source module type.
|
||||
|
||||
Data source: the `ui.wp_debug_snapshot` payload. Widget subscribes to the ComfyUI `executed` event bus, filters by its own node id, parses the snapshot, re-renders. Serialises nothing into `widgets_values` — debug output is transient per run (widget uses `addDOMWidget(..., { serialize: false })`).
|
||||
|
||||
## 6. DOM Widgets, Vue Mount Pattern, Bundle Strategy
|
||||
|
||||
Frontend cold-load is a first-class constraint: the reference project takes ~30–60 s to load on first canvas open. MVP hard budget: **entry bundle ≤ 30 KB gzipped; widget chunks lazy-loaded; total extension assets ≤ 250 KB gzipped**.
|
||||
|
||||
### 6.1 Bundle targets
|
||||
|
||||
| Target | Entry | Output | Loaded when | Budget |
|
||||
|---|---|---|---|---|
|
||||
| **Extension** | `src/main.ts` | `js/main.js` + `js/assets/*.js` | ComfyUI graph opens (critical path) | ≤ 30 KB gzipped entry; widget chunks lazy |
|
||||
| **Manager SPA** | `src/manager.ts` | `web_dist/` | User visits `/wildcard-pipeline/` | Route-split; no hard cap (MVP ships empty stub) |
|
||||
|
||||
### 6.2 Reference-project cold-load diagnosis
|
||||
|
||||
- `minify: false` shipped un-minified JS.
|
||||
- `cssInjectedByJsPlugin` forced CSS through JS parser.
|
||||
- No manual chunking — one monolithic `main.js` carried Vue + Pinia + every widget + PrimeVue even when no WP nodes were on canvas.
|
||||
- `sourcemap: true` inline inflated download.
|
||||
- Pinia + PrimeVue imported eagerly for widgets that didn't need them.
|
||||
|
||||
### 6.3 Extension bundle rules (hard requirements)
|
||||
|
||||
1. **Tiny eager entry.** `src/main.ts` only calls `app.registerExtension({name, getCustomWidgets})`. No Vue imports at top level. No widget component imports at top level.
|
||||
2. **Lazy-load widget modules.** Two injection paths, both dynamic-imported so first use pulls the chunk:
|
||||
```ts
|
||||
app.registerExtension({
|
||||
name: "wildcard-pipeline",
|
||||
|
||||
// Custom-typed inputs → widget factory keyed by type name
|
||||
async getCustomWidgets() {
|
||||
return {
|
||||
WP_CONTEXT_MODULES: (node, inputName) =>
|
||||
import("./widgets/context").then(m => m.create(node, inputName)),
|
||||
WP_DEBUG_VIEWER: (node, inputName) =>
|
||||
import("./widgets/debug").then(m => m.create(node, inputName)),
|
||||
};
|
||||
},
|
||||
|
||||
// Native-typed inputs on a specific node → mount helper after default widgets
|
||||
async beforeRegisterNodeDef(nodeType, nodeData) {
|
||||
if (nodeData.name !== "WP_PromptAssembler") return;
|
||||
const origOnCreated = nodeType.prototype.onNodeCreated;
|
||||
nodeType.prototype.onNodeCreated = function (...args) {
|
||||
origOnCreated?.apply(this, args);
|
||||
import("./widgets/assembler").then(m => m.mountHelper(this));
|
||||
};
|
||||
},
|
||||
});
|
||||
```
|
||||
3. **No PrimeVue in extension bundle.** Widgets use plain HTML + scoped CSS. PrimeVue + its Aria/theme peers are reserved for the manager SPA.
|
||||
4. **No Pinia in extension bundle.** Widget state is local (`ref` / `reactive`). Cross-widget coordination via graph walking.
|
||||
5. **Vue runtime-only.** Vite alias `vue → vue/dist/vue.runtime.esm-bundler.js`. Templates pre-compiled from `.vue` files.
|
||||
6. **`minify: "esbuild"` + `sourcemap: "hidden"`.** Production ships minified JS with an out-of-band sourcemap file.
|
||||
7. **`cssCodeSplit: true`.** Per-chunk CSS loaded via dynamic-import auto `<link>`, not inlined in JS.
|
||||
8. **Externalise where possible.** Check `/scripts/app.js` interop. If ComfyUI exposes a global Vue, add to `rollupOptions.external`; otherwise bundle Vue exactly once.
|
||||
9. **Tree-shakeable imports.** No barrel files. Named imports only.
|
||||
|
||||
### 6.4 Shared utility chunk
|
||||
|
||||
`src/widgets/_shared.ts` holds the Vue-in-DOMWidget factory, JSON serialiser, and conflict-scanner glue. Imported by each widget module → Vite de-duplicates into one shared chunk, so Vue exists exactly once in memory.
|
||||
|
||||
### 6.5 Manager SPA (stub now, budgeted from day one)
|
||||
|
||||
- Separate HTML entry (`src/manager.html` + `src/manager.ts`).
|
||||
- Route-based code splitting via dynamic `import()`.
|
||||
- PrimeVue + Pinia permitted; `unplugin-vue-components` with `PrimeVueResolver` for tree-shaking.
|
||||
- Served via aiohttp static handler with gzip response.
|
||||
- MVP ships an empty stub to validate the build target; fully implemented in the **Manager SPA** sub-spec.
|
||||
|
||||
### 6.6 CI-enforced performance gate
|
||||
|
||||
`scripts/check-bundle-size.mjs` runs in CI after `pnpm build:extension`. Fails the build if:
|
||||
|
||||
- entry gzip > 30 KB, **or**
|
||||
- total `js/` gzip > 250 KB.
|
||||
|
||||
Hard gate prevents regression. Numbers reviewable per PR.
|
||||
|
||||
## 7. Conflict Scanner (MVP scope)
|
||||
|
||||
Pure static analysis in the browser, recomputed on widget change. Three conflict types apply while only `fixed_values` exists:
|
||||
|
||||
| Type | Severity | Trigger | Scope |
|
||||
|---|---|---|---|
|
||||
| `context_overwrite` | error | New entry writes a var that an **upstream node** already exported | cross-node (chain) |
|
||||
| `duplicate_variable` | warning | Entry writes a var that an **earlier module in the same node** already wrote | intra-node |
|
||||
| `missing_template_variable` | warning | Assembler template uses `$var` with no writer in its upstream chain | template resolution |
|
||||
|
||||
Mutually exclusive per capture (upstream check → same-node check → else clean), matching the reference project's `conflicts.ts` `if/else if` pattern.
|
||||
|
||||
**Examples.**
|
||||
|
||||
```
|
||||
[Context A: style=photo] → [Context B: style=cinematic]
|
||||
→ B raises context_overwrite on its entry.
|
||||
|
||||
[Context A with 2 fixed_values modules both writing $style]
|
||||
→ 2nd module raises duplicate_variable.
|
||||
|
||||
[Context A: style=photo] → [Assembler: "a $stle image"]
|
||||
→ Assembler raises missing_template_variable on $stle.
|
||||
```
|
||||
|
||||
**Severity rationale.** `context_overwrite` is an *error* because silently stomping upstream is almost never intentional. `duplicate_variable` is a *warning* because intra-node overrides are sometimes deliberate. `missing_template_variable` is a *warning* because it's common during editing.
|
||||
|
||||
All advisory. No runtime enforcement — matches the last-write-wins runtime behaviour approved for the chain.
|
||||
|
||||
**Implementation notes.** Graph walkers live in `src/extension/graph.ts` under the same API names as the reference project (`collectUpstreamVariables`, `findDownstreamAssemblers`) but reimplemented avoiding reference-project anti-patterns: walk via `node.inputs[].link → app.graph.links[linkId] → graph.getNodeById(link.origin_id)`; never via `getInputNode` / `findInputSlot`. Deferred conflict types: `unresolved_constraint_target`, `unresolved_constraint_when_variable`, wildcard-specific rules.
|
||||
|
||||
Scanner output (`Conflict[]`) feeds widget UI as per-module-card badges + hover tooltips. Optional "Jump to module" click scrolls the offending module into view.
|
||||
|
||||
## 8. Repo Layout, Build, Tooling, Tests
|
||||
|
||||
### 8.1 Repo layout
|
||||
|
||||
```
|
||||
ComfyUI-WildcardPipeline/
|
||||
├── __init__.py # V3 comfy_entrypoint + extension
|
||||
├── pyproject.toml # package metadata + ruff + pytest
|
||||
├── package.json # pnpm, Vue deps, scripts
|
||||
├── pnpm-workspace.yaml # single-package workspace (future-proof)
|
||||
├── vite.config.mts # --mode extension | manager
|
||||
├── tsconfig.json # strict, alias @→./src
|
||||
├── conftest.py # sys.path shim for test-without-ComfyUI
|
||||
├── .releaserc.js # semantic-release config
|
||||
├── commitlint.config.js # conventional commits
|
||||
├── .husky/ # pre-commit + commit-msg hooks
|
||||
├── .github/workflows/
|
||||
│ ├── ci.yml # pytest matrix + frontend typecheck/build + size gate
|
||||
│ └── release.yml # semantic-release on main push
|
||||
├── .gitignore # js/, web_dist/, node_modules/, .pytest_cache/, __pycache__/, .superpowers/
|
||||
│
|
||||
├── engine/
|
||||
│ ├── __init__.py
|
||||
│ ├── context.py
|
||||
│ ├── modules.py
|
||||
│ ├── handlers.py
|
||||
│ ├── pipeline.py
|
||||
│ └── template.py
|
||||
│
|
||||
├── nodes/
|
||||
│ ├── __init__.py
|
||||
│ ├── types.py
|
||||
│ ├── context_node.py
|
||||
│ ├── assembler_node.py
|
||||
│ └── debug_node.py
|
||||
│
|
||||
├── src/
|
||||
│ ├── main.ts
|
||||
│ ├── manager.ts # stub for post-MVP
|
||||
│ ├── env.d.ts
|
||||
│ ├── typings/comfyui.d.ts
|
||||
│ ├── widgets/
|
||||
│ │ ├── _shared.ts
|
||||
│ │ ├── context.ts
|
||||
│ │ ├── assembler.ts
|
||||
│ │ └── debug.ts
|
||||
│ ├── extension/
|
||||
│ │ ├── graph.ts
|
||||
│ │ └── conflicts.ts
|
||||
│ └── components/
|
||||
│ ├── context/ContextWidget.vue
|
||||
│ ├── assembler/AssemblerHelper.vue
|
||||
│ └── debug/DebugViewer.vue
|
||||
│
|
||||
├── tests/
|
||||
│ ├── test_template.py
|
||||
│ ├── test_modules.py
|
||||
│ ├── test_engine.py
|
||||
│ └── test_nodes.py
|
||||
│
|
||||
├── docs/
|
||||
│ └── superpowers/specs/
|
||||
│
|
||||
├── js/ # built extension (gitignored)
|
||||
└── web_dist/ # built manager (gitignored, empty in MVP)
|
||||
```
|
||||
|
||||
### 8.2 Package manager & scripts
|
||||
|
||||
**pnpm** is the package manager. `packageManager` field pinned in `package.json`. `pnpm-workspace.yaml` declares the root package.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"scripts": {
|
||||
"dev": "vite build --watch --mode extension",
|
||||
"build:extension": "vite build --mode extension",
|
||||
"build:manager": "vite build --mode manager",
|
||||
"build": "pnpm build:extension && pnpm build:manager",
|
||||
"typecheck": "vue-tsc --noEmit",
|
||||
"lint": "eslint src --ext .ts,.vue",
|
||||
"size": "node scripts/check-bundle-size.mjs",
|
||||
"test": "vitest run",
|
||||
"test:py": "pytest",
|
||||
"prepare": "husky"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 8.3 Python tooling
|
||||
|
||||
- Python ≥ 3.10 (floor). Runtime target on this machine: embedded 3.12.10 at `E:\ComfyUIDev\ComfyUI-Easy-Install\python_embeded\python.exe`.
|
||||
- `pyproject.toml` with `[project]` metadata, `[tool.ruff]` (line-length 100, select `E/F/I/UP/B`), `[tool.pytest.ini_options]`.
|
||||
- No runtime deps beyond stdlib in `engine/`. Nodes depend on `comfy_api` (provided by ComfyUI host, not a pip install).
|
||||
- Dev deps (extras group `dev`): `pytest`, `pytest-asyncio`, `ruff`.
|
||||
|
||||
### 8.4 Dev-runtime environment separation
|
||||
|
||||
| Context | Python used | Purpose |
|
||||
|---|---|---|
|
||||
| **Runtime** (ComfyUI loads package) | embedded `python_embeded\python.exe` 3.12.10 | Runs nodes |
|
||||
| **Dev / tests** | `.venv` with system Python OR embedded | `pytest`, `ruff`, tooling |
|
||||
| **CI** (GitHub Actions) | `setup-python` matrix 3.10/3.11/3.12 | Cross-version pytest + typecheck + build |
|
||||
|
||||
Engine's stdlib-only constraint means embedded Python loads the package without any `pip install`. CI's lowest matrix row (3.10) is authoritative for what ships.
|
||||
|
||||
Two dev paths documented in `CONTRIBUTING.md`:
|
||||
|
||||
- **Recommended:** `python -m venv .venv` with system Python ≥ 3.10, then `.venv\Scripts\pip install -e ".[dev]"`.
|
||||
- **Zero-install:** `"$EMBED\python.exe" -m pip install pytest ruff` into embedded. Works, but mixes dev deps into ComfyUI's interpreter.
|
||||
|
||||
Husky pre-commit resolves Python via `PATH`; if missing, fails fast with a helpful message. Helper batch script `scripts/run-tests.cmd` auto-detects `.venv` then falls back to embedded.
|
||||
|
||||
### 8.5 Open-source tooling
|
||||
|
||||
**`.releaserc.js` — semantic-release.** Branches: `main`. Plugins: `@semantic-release/commit-analyzer`, `release-notes-generator`, `changelog`, `github`, `git`. Versioning via Conventional Commits → SemVer bump. Artifacts published to GitHub Releases; ComfyUI Registry publish via `comfy-cli` in `release.yml` after tag.
|
||||
|
||||
**`commitlint.config.js`.** `extends: ["@commitlint/config-conventional"]`. Enforced on `commit-msg` hook.
|
||||
|
||||
**Husky hooks (`.husky/`).**
|
||||
- `pre-commit`: `pnpm typecheck && pnpm lint && pnpm test` (frontend) + `ruff check` + `pytest -q` (backend).
|
||||
- `commit-msg`: `commitlint --edit $1`.
|
||||
|
||||
**GitHub Actions.**
|
||||
- `ci.yml` — pytest on Py 3.10/3.11/3.12, frontend typecheck + build + bundle-size gate.
|
||||
- `release.yml` — on `main` push, semantic-release → tag + changelog + Registry publish.
|
||||
|
||||
**Repo hygiene.** `CODEOWNERS → @DumiFlex`. `LICENSE` (to confirm; reference project used GPL-3.0). `CODE_OF_CONDUCT.md`, `CONTRIBUTING.md` ported and adapted.
|
||||
|
||||
### 8.6 Tests (MVP coverage)
|
||||
|
||||
- **engine/** — unit tests for `resolve_variables`, `module_from_dict`, `PipelineEngine.run` with `fixed_values`, chain merge behaviour, trace shape.
|
||||
- **nodes/** — mock `comfy_api` stubs in `conftest.py`; verify `execute()` wires upstream → engine → `ContextPayload` correctly.
|
||||
- **src/extension/conflicts.ts** — Vitest unit tests for the 3 conflict types.
|
||||
- **src/widgets/** — no UI tests in MVP; manual QA + later Playwright sub-spec.
|
||||
|
||||
CI badge + coverage badge added to `README.md`.
|
||||
|
||||
### 8.7 CLAUDE.md
|
||||
|
||||
Written at the **start of implementation** (not now). Captures: build/test commands, conventions, anti-patterns inherited from the reference project, engine isolation invariant, bundle-size gate, custom type name, embedded-vs-dev Python split. Writing earlier risks encoding decisions that haven't survived first contact with code.
|
||||
|
||||
## 9. Out of Scope / Future Sub-Specs
|
||||
|
||||
Every item below is a distinct follow-up brainstorming session → spec → plan → implementation cycle. Ordering is by dependency; items marked *flexible* can swap with neighbours based on demand.
|
||||
|
||||
1. **Wildcards module + seed propagation.** `wildcard` module type with weighted options + categories. Nested `@{uuid}` refs with cycle detection + depth limit. Per-capture seed via `sha256(node_seed, capture_name)`. Pick-mode (pin a specific value).
|
||||
2. **Combine module.** *(flexible with #3)* `combine` handler: `template + capture_as`. Adds `missing_combine_variable` to scanner.
|
||||
3. **Constraints module.** *(flexible with #2)* Category rule matrix + exceptions. Applied to wildcard options pre-sampling. Adds `unresolved_constraint_target` + `unresolved_constraint_when_variable` to scanner.
|
||||
4. **Derivations module.** Conditional mutations after wildcards. To brainstorm: any/all conditions, priority/rounds, output templates, `sample_from`.
|
||||
5. **`WP_ContextInject` node.** 3 optional socket inputs + autogrow-style slot-to-variable mapping. Widget: connected-slot detector + variable-name editor.
|
||||
6. **Library — sqlite backend + REST API.** Schema (alembic migrations): modules, pipelines, history (≤3 versions). aiohttp routes under `/wildcard-pipeline/api/*`. Inline serialised module remains authoritative in workflows; library `id` is a weak link; UI warns on missing. Context menu: *Save module to library*, *Link instance to library entry*, *Edit instance overrides*.
|
||||
7. **Manager SPA.** Vue 3 + PrimeVue + Pinia at `/wildcard-pipeline/`. Routes: modules list (per type), module editor, pipelines list, pipeline editor, test playground, import/export. Route-based code splitting. Playwright e2e.
|
||||
8. **Pipeline presets.** Node right-click menu *Save as pipeline* / *Import pipeline* (append/replace). Sharable JSON + library storage. Rename node on import.
|
||||
9. **`WP_DEBUG` env socket + advanced debug UX.** `os.environ["WP_DEBUG"]` toggles an extra DEBUG_JSON socket output on every node. `WP_Debug` gains a second input accepting debug JSON directly. Viewer: timeline view, diff view between runs.
|
||||
10. **Instance overrides UI.** Per-instance overrides on library-linked modules (weight tweaks, enable/disable values). Diff indicator in widget. Revert-to-library button.
|
||||
|
||||
### Explicitly not planned
|
||||
|
||||
- Workflow migration from `ComfyUI-Wildcard-Pipeline` (users re-author).
|
||||
- Multi-user / collaboration features.
|
||||
- Cloud sync for library.
|
||||
- Non-Vue widgets (React/Svelte).
|
||||
|
||||
### Governance
|
||||
|
||||
Each spec starts with its own brainstorming → spec doc in `docs/superpowers/specs/` → implementation plan → PR. No implementation of #1 begins until MVP lands and has surfaced real constraints from use.
|
||||
@@ -0,0 +1,146 @@
|
||||
# Widget ↔ Engine JSON Contract
|
||||
|
||||
The **single canonical shape** flowing between the frontend Vue widgets and the
|
||||
Python engine for `WP_Context` (and any future Pipeline Context node) is the
|
||||
`ContextWidgetValue` JSON object below. Both sides MUST round-trip cleanly:
|
||||
|
||||
- Frontend: `parseWidgetJson` / `serializeWidgetJson` in
|
||||
`src/widgets/_shared.ts`
|
||||
- Backend: `module_from_dict` / `module_to_dict` in `engine/modules.py`
|
||||
|
||||
If you change the shape on one side, change it on the other in the same PR
|
||||
**and** add round-trip tests covering the new field.
|
||||
|
||||
---
|
||||
|
||||
## `ContextWidgetValue`
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"version": 1,
|
||||
"modules": [ /* ModuleEntry[] */ ]
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Notes |
|
||||
|---|---|---|
|
||||
| `version` | `1` (literal) | Bump on breaking shape changes; readers should fall back to empty when unknown. |
|
||||
| `modules` | `ModuleEntry[]` | Ordered list. Order is significant — runtime applies modules top→bottom (last write wins per variable). |
|
||||
|
||||
### Module ordering semantics
|
||||
|
||||
Order in the `modules` array IS the runtime application order. The reference
|
||||
project's last-write-wins rule means downstream-ordered modules override
|
||||
upstream ones for the same variable. Reorder operations in the UI must
|
||||
preserve this contract.
|
||||
|
||||
---
|
||||
|
||||
## `ModuleEntry` (discriminated by `type`)
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"id": "ab12cd34",
|
||||
"type": "fixed_values",
|
||||
"enabled": true,
|
||||
"collapsed": false,
|
||||
"meta": {
|
||||
"name": "style",
|
||||
"description": "",
|
||||
"tags": []
|
||||
},
|
||||
"entries": [
|
||||
{ "variable_name": "subject", "value": "a cat" },
|
||||
{ "variable_name": "style", "value": "watercolor" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `id` | `string` | yes | 8-char hex. Stable per-module across saves. Generated by `newModuleId()` (`crypto.getRandomValues`) — never reused. |
|
||||
| `type` | `"fixed_values"` (extensible) | yes | Discriminator. New module types add new literals to this union; readers MUST treat unknown types as no-ops. |
|
||||
| `enabled` | `boolean` | yes | When `false`, the runtime skips the module entirely. Frontend conflict scans also skip disabled modules. |
|
||||
| `collapsed` | `boolean` | no (UI-only) | Persists the card's collapsed state across reloads. Engine ignores it. |
|
||||
| `meta` | `ModuleMeta` | yes | Display metadata (name shown in card header, description for tooltips, tags for SPA library filtering). |
|
||||
| `entries` | depends on `type` | yes | Type-specific payload. For `fixed_values`: `Array<{variable_name, value}>`. |
|
||||
|
||||
### `ModuleMeta`
|
||||
|
||||
| Field | Type | Default | Notes |
|
||||
|---|---|---|---|
|
||||
| `name` | `string` | `""` | Human-friendly label for the card. Empty renders as `"(unnamed)"`. |
|
||||
| `description` | `string` | `""` | Optional long-form text. Reserved for SPA library tooltips. |
|
||||
| `category` | `string` | `""` | SPA library categorization. Frontend currently doesn't render this. |
|
||||
| `tags` | `string[]` | `[]` | SPA library filtering. Frontend currently doesn't render this. |
|
||||
|
||||
### `fixed_values` entries
|
||||
|
||||
```jsonc
|
||||
{ "variable_name": "subject", "value": "a cat" }
|
||||
```
|
||||
|
||||
| Field | Type | Notes |
|
||||
|---|---|---|
|
||||
| `variable_name` | `string` | Stored **bare** — no leading `$`. The sigil is rendering chrome only, applied by the UI when displaying the chip / preview. Engine reads bare names too. |
|
||||
| `value` | `string` | Literal string the variable resolves to. Empty strings are valid (drop a variable to "nothing"). |
|
||||
|
||||
---
|
||||
|
||||
## Variable name normalization
|
||||
|
||||
Both sides MUST normalize variable names identically:
|
||||
|
||||
```python
|
||||
# engine + frontend
|
||||
name = raw_name.replace("$", "", 1).strip()
|
||||
```
|
||||
|
||||
- Strip a single leading `$` (defensive — historical workflows that stored
|
||||
the sigil should still load).
|
||||
- Strip surrounding whitespace.
|
||||
- Empty after normalization → variable is dropped from the runtime context.
|
||||
|
||||
The frontend regex used for entry validation:
|
||||
```ts
|
||||
/^[A-Za-z_][A-Za-z0-9_]*$/
|
||||
```
|
||||
|
||||
Names that don't match this regex still SAVE (we don't want to lose user
|
||||
edits mid-typing) but render with an invalid badge in the editor. The
|
||||
engine drops invalid names silently from the runtime context.
|
||||
|
||||
---
|
||||
|
||||
## Recovery & versioning
|
||||
|
||||
When the widget loads malformed JSON:
|
||||
|
||||
1. `parseWidgetJsonWithRecovery` returns `{value: emptyContextValue(), error, raw}`.
|
||||
2. The Context node renders a recovery panel (red border, "View raw" / "Reset
|
||||
to empty" buttons) instead of the normal module list.
|
||||
3. The user can copy the raw payload before resetting.
|
||||
4. Reset clears the value and pushes an info toast confirming the action.
|
||||
|
||||
Engine-side equivalent: `module_from_dict` raises `ValueError` for missing
|
||||
`id`/`type` or unknown `type`. The runtime should catch and skip rather
|
||||
than abort the whole graph evaluation; an unrelated downstream node
|
||||
shouldn't fail because one Context has corrupt data.
|
||||
|
||||
---
|
||||
|
||||
## Adding a new module type
|
||||
|
||||
1. **Engine:** add a dataclass in `engine/modules.py`, extend the
|
||||
`Module` union, add a branch in `module_from_dict`.
|
||||
2. **Frontend types:** extend `ModuleEntry["type"]` literal in
|
||||
`src/widgets/_shared.ts`.
|
||||
3. **Frontend UI:** extend the `kinds` array in
|
||||
`ModulePickerModal.vue`, add an icon + color, add an editor pane in
|
||||
`ModuleEditModal.vue`.
|
||||
4. **Theme:** pick a hue from the brand palette in `theme.css` and add a
|
||||
`.type-<new_type>` rule for the card's type icon.
|
||||
5. **Round-trip test:** Python `pytest` round-trips a fixture; Vitest
|
||||
verifies parseWidgetJson + serializeWidgetJson preserves shape.
|
||||
6. **This doc:** add the new type's entry shape under the "ModuleEntry"
|
||||
section.
|
||||
@@ -7,13 +7,22 @@ from typing import Any
|
||||
|
||||
_VAR_RE = re.compile(r"\$(\w+)")
|
||||
_SENTINEL = "\x00DOLLAR\x00"
|
||||
# Two-or-more whitespace runs left by removed variables. Single space stays.
|
||||
_WS_RUN = re.compile(r"[ \t]{2,}")
|
||||
# Trailing/leading whitespace inside a comma-separated list left by drops.
|
||||
_COMMA_GAP = re.compile(r",\s*,")
|
||||
_TRIM_PUNCT = re.compile(r"\s+([,.;:!?])")
|
||||
|
||||
|
||||
def resolve_variables(template: str, ctx: dict[str, Any]) -> str:
|
||||
"""Replace ``$var`` with ``str(ctx[var])``. ``$$`` escapes a literal ``$``.
|
||||
"""Replace ``$var`` with ``str(ctx[var])``.
|
||||
|
||||
Keys beginning with ``__`` are engine internals and are never substituted.
|
||||
Missing vars are left unchanged.
|
||||
- ``$$`` escapes a literal ``$``.
|
||||
- Keys beginning with ``__`` are engine internals — never substituted.
|
||||
- Missing vars are dropped (replaced with empty string), and the
|
||||
surrounding whitespace is normalized so the output reads cleanly:
|
||||
doubled spaces collapse to one, ``", ,"`` collapses to ``","``,
|
||||
whitespace before punctuation is trimmed.
|
||||
"""
|
||||
result = template.replace("$$", _SENTINEL)
|
||||
|
||||
@@ -23,7 +32,12 @@ def resolve_variables(template: str, ctx: dict[str, Any]) -> str:
|
||||
return match.group(0)
|
||||
if name in ctx:
|
||||
return str(ctx[name])
|
||||
return match.group(0)
|
||||
return ""
|
||||
|
||||
result = _VAR_RE.sub(_replace, result)
|
||||
return result.replace(_SENTINEL, "$")
|
||||
result = result.replace(_SENTINEL, "$")
|
||||
# Cleanup gaps left by dropped variables.
|
||||
result = _WS_RUN.sub(" ", result)
|
||||
result = _COMMA_GAP.sub(",", result)
|
||||
result = _TRIM_PUNCT.sub(r"\1", result)
|
||||
return result.strip()
|
||||
|
||||
@@ -2,10 +2,33 @@ import vue from "eslint-plugin-vue";
|
||||
import tsParser from "@typescript-eslint/parser";
|
||||
import tsPlugin from "@typescript-eslint/eslint-plugin";
|
||||
import vueParser from "vue-eslint-parser";
|
||||
import a11y from "eslint-plugin-vuejs-accessibility";
|
||||
|
||||
// Rules picked from typescript-eslint's "strict" preset that are SAFE
|
||||
// without `parserOptions.project` (so we don't pay the cost of a full
|
||||
// type-checker pass on every lint). The type-checked variants are skipped;
|
||||
// they need a TS project, and our typecheck script already covers them.
|
||||
const STRICTER_TS_RULES = {
|
||||
"@typescript-eslint/ban-ts-comment": "error",
|
||||
"@typescript-eslint/no-explicit-any": "warn",
|
||||
"@typescript-eslint/no-non-null-assertion": "warn",
|
||||
"@typescript-eslint/no-empty-object-type": "error",
|
||||
"@typescript-eslint/no-unsafe-function-type": "error",
|
||||
"@typescript-eslint/no-wrapper-object-types": "error",
|
||||
"@typescript-eslint/no-extraneous-class": "error",
|
||||
"@typescript-eslint/no-namespace": "error",
|
||||
};
|
||||
|
||||
const GENERAL_STRICT_RULES = {
|
||||
eqeqeq: ["error", "always", { null: "ignore" }],
|
||||
"prefer-const": "error",
|
||||
"no-var": "error",
|
||||
"no-console": ["warn", { allow: ["warn", "error", "info"] }],
|
||||
};
|
||||
|
||||
export default [
|
||||
{
|
||||
ignores: ["js/**", "web_dist/**", "node_modules/**", "dist/**"],
|
||||
ignores: ["js/**", "web_dist/**", "node_modules/**", "dist/**", "coverage/**"],
|
||||
},
|
||||
{
|
||||
files: ["src/**/*.ts"],
|
||||
@@ -19,6 +42,8 @@ export default [
|
||||
plugins: { "@typescript-eslint": tsPlugin },
|
||||
rules: {
|
||||
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
|
||||
...STRICTER_TS_RULES,
|
||||
...GENERAL_STRICT_RULES,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -31,9 +56,27 @@ export default [
|
||||
sourceType: "module",
|
||||
},
|
||||
},
|
||||
plugins: { vue },
|
||||
plugins: { vue, "vuejs-accessibility": a11y, "@typescript-eslint": tsPlugin },
|
||||
rules: {
|
||||
...vue.configs["vue3-recommended"].rules,
|
||||
...a11y.configs.recommended.rules,
|
||||
...STRICTER_TS_RULES,
|
||||
...GENERAL_STRICT_RULES,
|
||||
// Single-word component names are fine for our generic shared components
|
||||
// (Logo, Toast); enforcing the multi-word rule would force renames with
|
||||
// no ergonomic benefit.
|
||||
"vue/multi-word-component-names": "off",
|
||||
// Our context menu / hero / drag handles are intentionally ARIA-light —
|
||||
// raise to "warn" so issues surface in CI but don't block development
|
||||
// until we audit each one individually.
|
||||
"vuejs-accessibility/click-events-have-key-events": "warn",
|
||||
"vuejs-accessibility/no-static-element-interactions": "warn",
|
||||
"vuejs-accessibility/anchor-has-content": "warn",
|
||||
// Wrap-style labels (`<label><input/></label>`) are accessible but the
|
||||
// rule prefers explicit `for=`. Downgrade to warn pending an audit.
|
||||
"vuejs-accessibility/label-has-for": "warn",
|
||||
"vuejs-accessibility/interactive-supports-focus": "warn",
|
||||
"vuejs-accessibility/mouse-events-have-key-events": "warn",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"settingsCategories": {
|
||||
"Wildcard Pipeline": "Wildcard Pipeline",
|
||||
"Accessibility": "Accessibility"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"WP_Context": {
|
||||
"display_name": "WP Context",
|
||||
"description": "Holds named values you can plug into a prompt template. Wire into a WP Prompt Assembler to use them. Stack multiple Contexts to layer values from different sources.",
|
||||
"inputs": {
|
||||
"upstream": {
|
||||
"name": "upstream",
|
||||
"tooltip": "Optional: another WP Context to inherit values from. Values you set here override matching ones from upstream."
|
||||
},
|
||||
"seed": {
|
||||
"name": "seed",
|
||||
"tooltip": "Reserved for upcoming randomized module types. Has no effect on the current fixed-values modules."
|
||||
},
|
||||
"modules": {
|
||||
"name": "modules",
|
||||
"tooltip": "Your stack of value modules. Right-click a card → Edit to add or change values."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "context",
|
||||
"tooltip": "Wire into a WP Prompt Assembler, or chain into another WP Context."
|
||||
}
|
||||
}
|
||||
},
|
||||
"WP_PromptAssembler": {
|
||||
"display_name": "WP Prompt Assembler",
|
||||
"description": "Write your prompt as a template with $variable tokens. The Assembler fills them in from the connected Context, then outputs the final prompt string.",
|
||||
"inputs": {
|
||||
"context": {
|
||||
"name": "context",
|
||||
"tooltip": "The WP Context that provides values for your $variables."
|
||||
},
|
||||
"template": {
|
||||
"name": "template",
|
||||
"tooltip": "Your prompt template. Use $name to reference variables. Click chips below to insert at the cursor."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "prompt",
|
||||
"tooltip": "Final prompt string with all $variables filled in. Connect to a CLIP Text Encode (or any text input)."
|
||||
}
|
||||
}
|
||||
},
|
||||
"WP_Debug": {
|
||||
"display_name": "WP Debug",
|
||||
"description": "Read-only window into a WP Context. Shows every variable + value flowing through this point of the chain. Useful for confirming what your Prompt Assembler will see.",
|
||||
"inputs": {
|
||||
"context": {
|
||||
"name": "context",
|
||||
"tooltip": "The WP Context (or Assembler input) to inspect."
|
||||
},
|
||||
"viewer": {
|
||||
"name": "viewer",
|
||||
"tooltip": "Read-only display. Nothing to edit here."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"wildcardPipeline_a11y_reduceMotion": {
|
||||
"name": "Reduce motion",
|
||||
"tooltip": "Disables Wildcard Pipeline animations and transitions. Match system honors the OS prefers-reduced-motion setting.",
|
||||
"options": {
|
||||
"auto": "Match system (prefers-reduced-motion)",
|
||||
"on": "Always reduce",
|
||||
"off": "Always allow"
|
||||
}
|
||||
},
|
||||
"wildcardPipeline_a11y_contrast": {
|
||||
"name": "Contrast",
|
||||
"tooltip": "Bumps border + text contrast on Wildcard Pipeline widgets. Match system honors the OS prefers-contrast setting.",
|
||||
"options": {
|
||||
"auto": "Match system (prefers-contrast)",
|
||||
"on": "High contrast",
|
||||
"off": "Standard"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "comfyui-wildcard-pipeline",
|
||||
"version": "1.1.0",
|
||||
"version": "1.3.0",
|
||||
"private": true,
|
||||
"description": "Modular procedural prompt generation for ComfyUI",
|
||||
"author": "DumiFlex",
|
||||
@@ -9,14 +9,16 @@
|
||||
"packageManager": "pnpm@9.12.0",
|
||||
"scripts": {
|
||||
"dev": "vite build --watch --mode extension",
|
||||
"build:extension": "vite build --mode extension",
|
||||
"build:extension": "vite build --mode extension && pnpm docs:copy",
|
||||
"build:manager": "vite build --mode manager",
|
||||
"build": "pnpm build:extension && pnpm build:manager",
|
||||
"docs:copy": "node scripts/copy-help-docs.mjs",
|
||||
"typecheck": "vue-tsc --noEmit",
|
||||
"lint": "eslint \"src/**/*.{ts,vue}\"",
|
||||
"size": "node scripts/check-bundle-size.mjs",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"test:py": "pytest",
|
||||
"prepare": "husky"
|
||||
},
|
||||
@@ -24,6 +26,7 @@
|
||||
"vue": "^3.5.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@comfyorg/comfyui-frontend-types": "^1.42.15",
|
||||
"@commitlint/cli": "^19.5.0",
|
||||
"@commitlint/config-conventional": "^19.5.0",
|
||||
"@semantic-release/changelog": "^6.0.3",
|
||||
@@ -32,13 +35,18 @@
|
||||
"@typescript-eslint/eslint-plugin": "^8.59.0",
|
||||
"@typescript-eslint/parser": "^8.59.0",
|
||||
"@vitejs/plugin-vue": "^5.1.4",
|
||||
"@vitest/coverage-v8": "^2.1.9",
|
||||
"@vue/test-utils": "^2.4.8",
|
||||
"@vue/tsconfig": "^0.5.1",
|
||||
"eslint": "^9.14.0",
|
||||
"eslint-plugin-vue": "^9.31.0",
|
||||
"eslint-plugin-vuejs-accessibility": "^2.5.0",
|
||||
"husky": "^9.1.6",
|
||||
"jsdom": "^29.0.2",
|
||||
"semantic-release": "^25.0.3",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.10",
|
||||
"vite-plugin-css-injected-by-js": "^3.5.2",
|
||||
"vitest": "^2.1.4",
|
||||
"vue-eslint-parser": "^10.4.0",
|
||||
"vue-tsc": "^2.1.10"
|
||||
|
||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,35 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" id="Layer_1" data-name="Layer 1" viewBox="0 0 1024 1024" width="1024" height="1024"><defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: url(#linear-gradient-2);
|
||||
}
|
||||
|
||||
.cls-2 {
|
||||
fill: #6366f1;
|
||||
}
|
||||
|
||||
.cls-3 {
|
||||
fill: #33d6c6;
|
||||
}
|
||||
|
||||
.cls-4 {
|
||||
fill: #a970ff;
|
||||
}
|
||||
|
||||
.cls-5 {
|
||||
fill: url(#linear-gradient);
|
||||
}
|
||||
</style>
|
||||
<linearGradient id="linear-gradient" x1="312.56" y1="897.19" x2="312.56" y2="2" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#a970ff"></stop>
|
||||
<stop offset="1" stop-color="#6366f1"></stop>
|
||||
</linearGradient>
|
||||
<linearGradient id="linear-gradient-2" x1="711.44" y1="126.81" x2="711.44" y2="1022" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#6366f1"></stop>
|
||||
<stop offset="1" stop-color="#33d6c6"></stop>
|
||||
</linearGradient>
|
||||
</defs><path class="cls-5" d="m512,2c-53.6,0-98.47,38.16-108.84,88.73-75.07,19.29-143.32,58.26-198.74,113.68-55.42,55.42-94.39,123.68-113.68,198.74-50.58,10.38-88.73,55.24-88.73,108.84s38.16,98.47,88.73,108.84c13.62,53.14,37.03,102.64,69.77,147.47,37.14,50.86,85.83,93.86,140.81,124.33,5.54,3.08,11.56,4.54,17.48,4.54,12.7,0,25.02-6.72,31.61-18.61,9.68-17.44,3.38-39.42-14.07-49.09-85.32-47.32-147.22-125.71-173.38-218.21,36.3-18.29,61.27-55.92,61.27-99.28s-24.94-80.97-61.23-99.27c34.22-119.96,129.79-215.52,249.74-249.74,18.3,36.29,55.92,61.23,99.27,61.23,61.27,0,111.11-49.84,111.11-111.11S573.27,2,512,2ZM113.11,473.11c21.44,0,38.89,17.44,38.89,38.89,0,20.1-15.32,36.69-34.91,38.69h-.01c-.18.02-.36.03-.53.04-1.13.1-2.28.16-3.43.16-21.44,0-38.89-17.44-38.89-38.89s17.44-38.89,38.89-38.89Zm398.89-321.11c-21.44,0-38.89-17.44-38.89-38.89s17.44-38.89,38.89-38.89,38.89,17.44,38.89,38.89-17.44,38.89-38.89,38.89Z"></path><path class="cls-1" d="m933.27,403.16c-13.62-53.14-37.03-102.64-69.77-147.47-37.14-50.86-85.83-93.86-140.81-124.33-17.44-9.68-39.42-3.38-49.09,14.07-9.68,17.44-3.38,39.42,14.07,49.09,85.32,47.32,147.22,125.71,173.38,218.21-36.3,18.29-61.27,55.92-61.27,99.28s24.94,80.97,61.23,99.27c-34.22,119.96-129.79,215.52-249.74,249.74-18.3-36.29-55.92-61.23-99.27-61.23-61.27,0-111.11,49.84-111.11,111.11s49.84,111.11,111.11,111.11c53.6,0,98.47-38.16,108.84-88.73,75.07-19.29,143.32-58.26,198.74-113.68,55.42-55.42,94.39-123.68,113.68-198.74,50.58-10.38,88.73-55.24,88.73-108.84s-38.16-98.47-88.73-108.84Zm-421.27,546.62c-21.44,0-38.89-17.44-38.89-38.89s17.44-38.89,38.89-38.89,38.89,17.44,38.89,38.89-17.44,38.89-38.89,38.89Zm398.89-398.89c-21.44,0-38.89-17.44-38.89-38.89,0-20.03,15.22-36.58,34.72-38.67.38-.04.77-.08,1.16-.11h.02c.99-.08,1.99-.11,2.99-.11,21.44,0,38.89,17.44,38.89,38.89s-17.44,38.89-38.89,38.89Z"></path><g>
|
||||
<path class="cls-4" d="m735.02,517.56c-153.82,30.87-186.59,63.64-217.46,217.46-1.22,6.08-9.9,6.08-11.12,0-30.87-153.82-63.64-186.59-217.46-217.46-6.08-1.22-6.08-9.9,0-11.12,153.82-30.87,186.59-63.64,217.46-217.46,1.22-6.08,9.9-6.08,11.12,0,30.87,153.82,63.64,186.59,217.46,217.46,6.08,1.22,6.08,9.9,0,11.12Z"></path>
|
||||
<path class="cls-2" d="m434.06,665.85c-51.08,10.25-61.96,21.13-72.22,72.22-.41,2.02-3.29,2.02-3.69,0-10.25-51.08-21.13-61.96-72.22-72.22-2.02-.41-2.02-3.29,0-3.69,51.08-10.25,61.96-21.13,72.22-72.22.41-2.02,3.29-2.02,3.69,0,10.25,51.08,21.13,61.96,72.22,72.22,2.02.41,2.02,3.29,0,3.69Z"></path>
|
||||
<path class="cls-3" d="m738.07,361.84c-51.08,10.25-61.96,21.13-72.22,72.22-.41,2.02-3.29,2.02-3.69,0-10.25-51.08-21.13-61.96-72.22-72.22-2.02-.41-2.02-3.29,0-3.69,51.08-10.25,61.96-21.13,72.22-72.22.41-2.02,3.29-2.02,3.69,0,10.25,51.08,21.13,61.96,72.22,72.22,2.02.41,2.02,3.29,0,3.69Z"></path>
|
||||
</g></svg>
|
||||
|
After Width: | Height: | Size: 3.6 KiB |
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "MyWebSite",
|
||||
"short_name": "MySite",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/web-app-manifest-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "/web-app-manifest-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
],
|
||||
"theme_color": "#ffffff",
|
||||
"background_color": "#ffffff",
|
||||
"display": "standalone"
|
||||
}
|
||||
|
After Width: | Height: | Size: 9.9 KiB |
|
After Width: | Height: | Size: 31 KiB |
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "comfyui-wildcard-pipeline"
|
||||
version = "1.1.0"
|
||||
version = "1.3.0"
|
||||
description = "Modular procedural prompt generation for ComfyUI — wildcards, constraints, derivations, and context pipelines."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
@@ -47,7 +47,7 @@ ignore = []
|
||||
# conftest.py prepends the project root to sys.path; mirror that for Pyright
|
||||
# so `from engine.template import ...` resolves in tests and editor.
|
||||
extraPaths = ["."]
|
||||
include = ["engine", "nodes", "tests", "conftest.py"]
|
||||
include = ["engine", "wp_nodes", "tests", "conftest.py"]
|
||||
exclude = ["node_modules", "js", "web_dist", ".venv", "**/__pycache__"]
|
||||
|
||||
# Dispatch-pattern handlers (engine.handlers.handle_*) share a uniform
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env node
|
||||
// Diff two bundle-size manifests (produced by check-bundle-size.mjs --json)
|
||||
// and emit a Markdown report on stdout. Used by the bundle-size-pr workflow
|
||||
// to comment on PRs with the size delta.
|
||||
//
|
||||
// node scripts/bundle-size-diff.mjs <base.json> <head.json> > report.md
|
||||
//
|
||||
// Output is compact: total + per-file delta table. Files unchanged are
|
||||
// omitted; new files show as "+" and removed as "−".
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
const [basePath, headPath] = process.argv.slice(2);
|
||||
if (!basePath || !headPath) {
|
||||
console.error("Usage: bundle-size-diff <base.json> <head.json>");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const base = JSON.parse(readFileSync(basePath, "utf-8"));
|
||||
const head = JSON.parse(readFileSync(headPath, "utf-8"));
|
||||
|
||||
function fmtKB(bytes) {
|
||||
return `${(bytes / 1024).toFixed(2)} KB`;
|
||||
}
|
||||
function fmtDelta(delta) {
|
||||
if (delta === 0) return "—";
|
||||
const sign = delta > 0 ? "+" : "−";
|
||||
const kb = Math.abs(delta) / 1024;
|
||||
return `${sign}${kb.toFixed(2)} KB`;
|
||||
}
|
||||
|
||||
const allKeys = new Set([...Object.keys(base.files), ...Object.keys(head.files)]);
|
||||
const rows = [];
|
||||
for (const key of [...allKeys].sort()) {
|
||||
const b = base.files[key] ?? 0;
|
||||
const h = head.files[key] ?? 0;
|
||||
if (b === h) continue;
|
||||
const status = b === 0 ? "🆕 new" : h === 0 ? "🗑️ removed" : "📦 changed";
|
||||
rows.push({ key, base: b, head: h, delta: h - b, status });
|
||||
}
|
||||
|
||||
const totalDelta = head.total - base.total;
|
||||
const entryDelta = (head.entry ?? 0) - (base.entry ?? 0);
|
||||
|
||||
let md = "## Bundle size report\n\n";
|
||||
md += `| | base | head | delta |\n`;
|
||||
md += `|---|---:|---:|---:|\n`;
|
||||
md += `| **Total (gzip)** | ${fmtKB(base.total)} | ${fmtKB(head.total)} | **${fmtDelta(totalDelta)}** |\n`;
|
||||
md += `| **Entry (main.js)** | ${fmtKB(base.entry ?? 0)} | ${fmtKB(head.entry ?? 0)} | ${fmtDelta(entryDelta)} |\n`;
|
||||
|
||||
if (rows.length === 0) {
|
||||
md += `\n_No per-chunk changes._\n`;
|
||||
} else {
|
||||
md += `\n### Per-chunk changes\n\n`;
|
||||
md += `| file | base | head | delta | |\n`;
|
||||
md += `|---|---:|---:|---:|---|\n`;
|
||||
for (const r of rows) {
|
||||
md += `| \`${r.key}\` | ${fmtKB(r.base)} | ${fmtKB(r.head)} | ${fmtDelta(r.delta)} | ${r.status} |\n`;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(md);
|
||||
@@ -1,11 +1,18 @@
|
||||
#!/usr/bin/env node
|
||||
import { gzipSync } from "node:zlib";
|
||||
import { readFileSync, readdirSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { readFileSync, readdirSync, statSync, writeFileSync, mkdirSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const JS_DIR = fileURLToPath(new URL("../js", import.meta.url));
|
||||
|
||||
// `--json <path>` writes a machine-readable manifest alongside the human
|
||||
// console report. Used by .github/workflows/bundle-size-pr.yml to compute
|
||||
// the delta between base and head.
|
||||
const args = process.argv.slice(2);
|
||||
const jsonIdx = args.indexOf("--json");
|
||||
const jsonPath = jsonIdx >= 0 ? args[jsonIdx + 1] : null;
|
||||
|
||||
// Budgets (gzipped bytes) — tune only with explicit review
|
||||
const ENTRY_LIMIT = 30 * 1024; // 30 KB
|
||||
const TOTAL_LIMIT = 250 * 1024; // 250 KB
|
||||
@@ -31,18 +38,28 @@ let failed = false;
|
||||
|
||||
const files = allJsFiles(JS_DIR);
|
||||
let total = 0;
|
||||
const manifest = { entry: null, total: 0, files: {} };
|
||||
console.log(`\nBundle size report (gzipped):\n`);
|
||||
for (const file of files) {
|
||||
const size = gzipSize(file);
|
||||
total += size;
|
||||
const rel = file.substring(JS_DIR.length + 1);
|
||||
// Strip the rolled-up content hash so PR ↔ base file names match. Vite
|
||||
// emits `assets/<name>-<hash>.js` per chunk; the hash changes with any
|
||||
// content edit and would otherwise show every chunk as added/removed.
|
||||
// Normalize Windows backslashes so the manifest diffs cleanly against
|
||||
// CI runs (which always emit forward slashes on Linux).
|
||||
const stable = rel.replace(/-[A-Za-z0-9_-]{8,}\.js$/, ".js").replaceAll("\\", "/");
|
||||
manifest.files[stable] = size;
|
||||
console.log(` ${rel.padEnd(40)} ${(size / 1024).toFixed(2).padStart(7)} KB`);
|
||||
}
|
||||
manifest.total = total;
|
||||
console.log(` ${"TOTAL".padEnd(40)} ${(total / 1024).toFixed(2).padStart(7)} KB`);
|
||||
|
||||
const entry = files.find((f) => f.endsWith("main.js"));
|
||||
if (entry) {
|
||||
const entrySize = gzipSize(entry);
|
||||
manifest.entry = entrySize;
|
||||
if (entrySize > ENTRY_LIMIT) {
|
||||
console.error(`\n✗ Entry main.js gzip ${entrySize} bytes exceeds budget ${ENTRY_LIMIT}`);
|
||||
failed = true;
|
||||
@@ -58,4 +75,10 @@ if (total > TOTAL_LIMIT) {
|
||||
console.log(`✓ Total within budget (${total} / ${TOTAL_LIMIT} bytes gzipped)`);
|
||||
}
|
||||
|
||||
if (jsonPath) {
|
||||
mkdirSync(dirname(jsonPath), { recursive: true });
|
||||
writeFileSync(jsonPath, JSON.stringify(manifest, null, 2));
|
||||
console.log(`\nWrote size manifest to ${jsonPath}`);
|
||||
}
|
||||
|
||||
process.exit(failed ? 1 : 0);
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env node
|
||||
// Copy markdown help pages from docs/help/ to js/docs/ so ComfyUI's
|
||||
// node-help-page system can serve them under /extensions/<package>/docs/.
|
||||
// ComfyUI auto-scans `${WEB_DIRECTORY}/docs/<NodeId>.md` (plus optional
|
||||
// `<NodeId>/<locale>.md` overrides) — see
|
||||
// https://docs.comfy.org/custom-nodes/help_page
|
||||
//
|
||||
// Run as part of `pnpm build:extension` so a fresh `js/` always has the
|
||||
// latest docs alongside the bundled JS chunks.
|
||||
|
||||
import { readdirSync, statSync, mkdirSync, copyFileSync, rmSync } from "node:fs";
|
||||
import { join, relative } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const ROOT = fileURLToPath(new URL("..", import.meta.url));
|
||||
const SRC = join(ROOT, "docs", "help");
|
||||
const DST = join(ROOT, "js", "docs");
|
||||
|
||||
function walk(dir) {
|
||||
const out = [];
|
||||
for (const name of readdirSync(dir)) {
|
||||
const full = join(dir, name);
|
||||
if (statSync(full).isDirectory()) out.push(...walk(full));
|
||||
else out.push(full);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
try {
|
||||
rmSync(DST, { recursive: true, force: true });
|
||||
const files = walk(SRC);
|
||||
for (const file of files) {
|
||||
const rel = relative(SRC, file);
|
||||
const target = join(DST, rel);
|
||||
mkdirSync(join(target, ".."), { recursive: true });
|
||||
copyFileSync(file, target);
|
||||
console.log(` docs/help/${rel} → js/docs/${rel}`);
|
||||
}
|
||||
console.log(`Copied ${files.length} help file(s).`);
|
||||
} catch (err) {
|
||||
console.error("[copy-help-docs] failed:", err);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import AssemblerHelper from "./AssemblerHelper.vue";
|
||||
|
||||
const baseProps = {
|
||||
upstreamVars: [] as string[],
|
||||
upstreamValues: {} as Record<string, string>,
|
||||
template: "",
|
||||
onInsert: () => {},
|
||||
};
|
||||
|
||||
describe("AssemblerHelper.vue", () => {
|
||||
it("renders a chip for each upstream variable", () => {
|
||||
const wrapper = mount(AssemblerHelper, {
|
||||
props: { ...baseProps, upstreamVars: ["style", "subject"] },
|
||||
});
|
||||
expect(wrapper.findAll('[data-testid="chip"]')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("calls onInsert with $var when chip is clicked", async () => {
|
||||
let inserted = "";
|
||||
const wrapper = mount(AssemblerHelper, {
|
||||
props: { ...baseProps, upstreamVars: ["style"], onInsert: (s: string) => { inserted = s; } },
|
||||
});
|
||||
await wrapper.find('[data-testid="chip"]').trigger("click");
|
||||
expect(inserted).toBe("$style");
|
||||
});
|
||||
|
||||
it("renders an empty hint when no upstream vars", () => {
|
||||
const wrapper = mount(AssemblerHelper, { props: baseProps });
|
||||
expect(wrapper.text()).toContain("Connect a Pipeline Context");
|
||||
});
|
||||
|
||||
it("highlights resolved values in the preview HTML", () => {
|
||||
const wrapper = mount(AssemblerHelper, {
|
||||
props: {
|
||||
...baseProps,
|
||||
upstreamVars: ["style", "subject"],
|
||||
upstreamValues: { style: "cinematic", subject: "fox" },
|
||||
template: "A $style portrait of $subject",
|
||||
},
|
||||
});
|
||||
const html = wrapper.find(".wp-asm__rendered").html();
|
||||
expect(html).toContain("wp-tok-resolved");
|
||||
expect(html).toContain("cinematic");
|
||||
expect(html).toContain("fox");
|
||||
});
|
||||
|
||||
it("flags unresolved $name with missing token + UNRESOLVED chip", () => {
|
||||
const wrapper = mount(AssemblerHelper, {
|
||||
props: {
|
||||
...baseProps,
|
||||
upstreamValues: { style: "noir" },
|
||||
upstreamVars: ["style"],
|
||||
template: "$style and $missing",
|
||||
},
|
||||
});
|
||||
const rendered = wrapper.find(".wp-asm__rendered").html();
|
||||
expect(rendered).toContain("wp-tok-resolved");
|
||||
expect(rendered).toContain("wp-tok-miss");
|
||||
expect(wrapper.find('[data-testid="missing-chip"]').text()).toBe("$missing");
|
||||
});
|
||||
|
||||
it("marks chips of in-template upstream vars as `used` (green) and others as `available` (blue)", () => {
|
||||
const wrapper = mount(AssemblerHelper, {
|
||||
props: {
|
||||
...baseProps,
|
||||
upstreamVars: ["style", "subject"],
|
||||
upstreamValues: { style: "noir", subject: "knight" },
|
||||
template: "$style only",
|
||||
},
|
||||
});
|
||||
const chips = wrapper.findAll('[data-testid="chip"]');
|
||||
expect(chips[0].classes()).toContain("used");
|
||||
expect(chips[1].classes()).toContain("available");
|
||||
});
|
||||
|
||||
it("$$ escape stays literal and does not produce a missing chip", () => {
|
||||
const wrapper = mount(AssemblerHelper, {
|
||||
props: { ...baseProps, template: "$$cost is 5" },
|
||||
});
|
||||
expect(wrapper.find(".wp-asm__rendered").text()).toContain("$cost is 5");
|
||||
expect(wrapper.find('[data-testid="missing-chip"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it("escapes HTML in upstream values to prevent injection", () => {
|
||||
const wrapper = mount(AssemblerHelper, {
|
||||
props: {
|
||||
...baseProps,
|
||||
upstreamVars: ["x"],
|
||||
upstreamValues: { x: "<script>alert(1)</script>" },
|
||||
template: "$x",
|
||||
},
|
||||
});
|
||||
const html = wrapper.find(".wp-asm__rendered").html();
|
||||
expect(html).not.toContain("<script>");
|
||||
expect(html).toContain("<script>");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,328 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
upstreamVars: string[];
|
||||
upstreamValues: Record<string, string>;
|
||||
template: string;
|
||||
onInsert: (token: string) => void;
|
||||
}>();
|
||||
|
||||
// Sentinel survives both regex passes — � is the Unicode replacement
|
||||
// character; doubled, it can't appear in real prompts.
|
||||
const ESCAPE_PLACEHOLDER = "��";
|
||||
const ESCAPE_RE = /��/g;
|
||||
const VAR_RE = /\$([A-Za-z_][A-Za-z0-9_]*)/g;
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
// Stable per-name hue. djb2-ish hash → 0..359°. Deterministic across runs
|
||||
// so the same variable always gets the same color in chip + preview.
|
||||
function hueFor(name: string): number {
|
||||
let h = 5381;
|
||||
for (let i = 0; i < name.length; i++) h = ((h << 5) + h + name.charCodeAt(i)) >>> 0;
|
||||
return h % 360;
|
||||
}
|
||||
|
||||
function chipColor(name: string): string {
|
||||
return `hsl(${hueFor(name)}, 65%, 65%)`;
|
||||
}
|
||||
function chipBg(name: string): string {
|
||||
return `hsla(${hueFor(name)}, 65%, 55%, 0.15)`;
|
||||
}
|
||||
|
||||
const templateVars = computed(() => {
|
||||
const sanitized = props.template.replace(/\$\$/g, ESCAPE_PLACEHOLDER);
|
||||
const matches = sanitized.match(VAR_RE);
|
||||
if (!matches) return [];
|
||||
return [...new Set(matches.map((m) => m.slice(1)).filter((v) => !v.startsWith("__")))];
|
||||
});
|
||||
|
||||
const missingVars = computed(() =>
|
||||
templateVars.value.filter((v) => !Object.prototype.hasOwnProperty.call(props.upstreamValues, v)),
|
||||
);
|
||||
|
||||
function isUsed(v: string): boolean {
|
||||
return templateVars.value.includes(v);
|
||||
}
|
||||
|
||||
const previewHtml = computed(() => {
|
||||
if (!props.template) {
|
||||
return '<span class="wp-asm-empty-inline">Template is empty.</span>';
|
||||
}
|
||||
const escaped = props.template.replace(/\$\$/g, ESCAPE_PLACEHOLDER);
|
||||
const safe = escapeHtml(escaped).replace(VAR_RE, (full, name: string) => {
|
||||
if (name.startsWith("__")) return full;
|
||||
const hue = hueFor(name);
|
||||
const colorStyle = `style="color: hsl(${hue}, 65%, 70%); background: hsla(${hue}, 65%, 55%, 0.15);"`;
|
||||
if (Object.prototype.hasOwnProperty.call(props.upstreamValues, name)) {
|
||||
return `<span class="wp-tok-resolved" ${colorStyle}>${escapeHtml(props.upstreamValues[name])}</span>`;
|
||||
}
|
||||
if (props.upstreamVars.includes(name)) {
|
||||
return `<span class="wp-tok-ok" ${colorStyle}>$${name}</span>`;
|
||||
}
|
||||
return `<span class="wp-tok-miss">$${name}</span>`;
|
||||
});
|
||||
return safe.replace(ESCAPE_RE, "$");
|
||||
});
|
||||
|
||||
// Ripple effect — track most-recent click coords per chip element to drive
|
||||
// the radial-gradient animation. Cleared after the animation duration.
|
||||
const ripples = ref<Map<string, { x: number; y: number; t: number }>>(new Map());
|
||||
|
||||
function onChipClick(ev: MouseEvent, v: string) {
|
||||
const el = ev.currentTarget as HTMLElement;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const x = ev.clientX - rect.left;
|
||||
const y = ev.clientY - rect.top;
|
||||
ripples.value.set(v, { x, y, t: Date.now() });
|
||||
// Clear after animation finishes so the chip re-renders without it.
|
||||
setTimeout(() => {
|
||||
const cur = ripples.value.get(v);
|
||||
if (cur && Date.now() - cur.t >= 380) ripples.value.delete(v);
|
||||
}, 420);
|
||||
props.onInsert(`$${v}`);
|
||||
}
|
||||
|
||||
function rippleStyle(v: string): Record<string, string> {
|
||||
const r = ripples.value.get(v);
|
||||
if (!r) return {};
|
||||
return {
|
||||
"--wp-ripple-x": `${r.x}px`,
|
||||
"--wp-ripple-y": `${r.y}px`,
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="wp-asm">
|
||||
<div v-if="upstreamVars.length" class="wp-asm__section">
|
||||
<label class="wp-asm__label">VARIABLES</label>
|
||||
<div class="wp-asm__chips">
|
||||
<button
|
||||
v-for="v in upstreamVars" :key="v"
|
||||
type="button"
|
||||
class="wp-asm__chip"
|
||||
:class="[isUsed(v) ? 'used' : 'available', { 'wp-asm__chip--ripple': ripples.has(v) }]"
|
||||
:style="{ ...rippleStyle(v), color: chipColor(v), background: chipBg(v) }"
|
||||
data-testid="chip"
|
||||
:title="upstreamValues[v] ?? ''"
|
||||
@click="(ev) => onChipClick(ev, v)"
|
||||
>${{ v }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="wp-asm__section wp-asm__section--empty">
|
||||
<span class="wp-asm__empty">Connect a Pipeline Context to see available variables</span>
|
||||
</div>
|
||||
|
||||
<div class="wp-asm__section">
|
||||
<label class="wp-asm__label">PREVIEW</label>
|
||||
<div v-if="!template" class="wp-asm__skeleton">
|
||||
<span class="wp-asm__skel-line"></span>
|
||||
<span class="wp-asm__skel-line wp-asm__skel-line--short"></span>
|
||||
</div>
|
||||
<div v-else class="wp-asm__rendered" v-html="previewHtml"></div>
|
||||
</div>
|
||||
|
||||
<div v-if="missingVars.length" class="wp-asm__section">
|
||||
<label class="wp-asm__label wp-asm__label--warn">UNRESOLVED · dropped from prompt</label>
|
||||
<div class="wp-asm__chips">
|
||||
<span
|
||||
v-for="v in missingVars"
|
||||
:key="v"
|
||||
class="wp-asm__chip missing"
|
||||
data-testid="missing-chip"
|
||||
>${{ v }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
@import "../shared/theme.css";
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
.wp-asm, .wp-asm * { box-sizing: border-box; }
|
||||
.wp-asm {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
font-family: var(--wp-font-sans, sans-serif);
|
||||
font-size: 12px;
|
||||
color: var(--wp-text);
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.wp-asm__section {
|
||||
padding: 8px 10px;
|
||||
}
|
||||
.wp-asm__section + .wp-asm__section {
|
||||
border-top: 1px solid var(--wp-border);
|
||||
}
|
||||
|
||||
.wp-asm__label {
|
||||
font-size: 9px;
|
||||
font-family: var(--wp-font-mono, monospace);
|
||||
color: var(--wp-text3);
|
||||
letter-spacing: 0.08em;
|
||||
margin-bottom: 6px;
|
||||
display: block;
|
||||
}
|
||||
.wp-asm__label--warn { color: var(--wp-amber); }
|
||||
|
||||
.wp-asm__chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.wp-asm__chip {
|
||||
font-size: 10px;
|
||||
font-family: var(--wp-font-mono, monospace);
|
||||
padding: 2px 7px;
|
||||
border-radius: 3px;
|
||||
user-select: none;
|
||||
border: none;
|
||||
transition: background 0.12s, color 0.12s, transform 0.12s;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.wp-asm__chip.available { cursor: pointer; }
|
||||
.wp-asm__chip.used { cursor: pointer; }
|
||||
.wp-asm__chip.used::after {
|
||||
/* Tiny indicator pinned to the top-right corner. No padding override —
|
||||
* chip stays the same width as available chips. The dot sits in the
|
||||
* tight corner gap above the variable glyphs' ascender. */
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
.wp-asm__chip.available:hover,
|
||||
.wp-asm__chip.used:hover {
|
||||
/* filter brightens whatever inline hsla() the chip got from its name hash —
|
||||
* works without overriding the inline style. */
|
||||
filter: brightness(1.3) saturate(1.15);
|
||||
}
|
||||
.wp-asm__chip.missing {
|
||||
background: var(--wp-amber-bg) !important;
|
||||
color: var(--wp-amber) !important;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Ripple — radial expanding overlay anchored to click coords */
|
||||
.wp-asm__chip--ripple::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: var(--wp-ripple-x, 50%);
|
||||
top: var(--wp-ripple-y, 50%);
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
transform: translate(-50%, -50%);
|
||||
animation: wp-asm-ripple 0.4s ease-out;
|
||||
pointer-events: none;
|
||||
}
|
||||
@keyframes wp-asm-ripple {
|
||||
0% { width: 0; height: 0; opacity: 0.6; }
|
||||
100% { width: 120px; height: 120px; opacity: 0; }
|
||||
}
|
||||
|
||||
.wp-asm__rendered {
|
||||
background: var(--wp-bg3);
|
||||
border: 1px solid var(--wp-border);
|
||||
border-radius: var(--wp-radius-sm);
|
||||
padding: 6px 8px;
|
||||
font-family: var(--wp-font-mono, monospace);
|
||||
font-size: 11px;
|
||||
line-height: 1.6;
|
||||
color: var(--wp-text2);
|
||||
word-break: break-word;
|
||||
white-space: pre-wrap;
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
/* Skeleton when template is empty (also briefly visible during async chunk
|
||||
* resolution before Vue patches the props in). */
|
||||
.wp-asm__skeleton {
|
||||
background: var(--wp-bg3);
|
||||
border: 1px solid var(--wp-border);
|
||||
border-radius: var(--wp-radius-sm);
|
||||
padding: 8px;
|
||||
min-height: 32px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.wp-asm__skel-line {
|
||||
display: block;
|
||||
height: 8px;
|
||||
border-radius: 3px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--wp-bg2) 0%,
|
||||
var(--wp-bg4) 50%,
|
||||
var(--wp-bg2) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: wp-asm-shimmer 1.4s ease-in-out infinite;
|
||||
}
|
||||
.wp-asm__skel-line--short { width: 60%; }
|
||||
@keyframes wp-asm-shimmer {
|
||||
0% { background-position: 100% 0; }
|
||||
100% { background-position: -100% 0; }
|
||||
}
|
||||
|
||||
.wp-asm__section--empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 32px;
|
||||
}
|
||||
.wp-asm__empty {
|
||||
color: var(--wp-text3);
|
||||
font-size: 11px;
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
/* Unscoped — rendered via v-html. */
|
||||
.wp-tok-resolved {
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
/* 600 not 500 — we only bundle Inter 400 + 600 to keep the chunk small.
|
||||
* 500 would substitute via system fallback and look out of family. */
|
||||
font-weight: 600;
|
||||
}
|
||||
.wp-tok-ok {
|
||||
padding: 1px 3px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.wp-tok-miss {
|
||||
color: var(--wp-amber, #d4a843);
|
||||
background: var(--wp-amber-bg, rgba(212, 168, 67, 0.1));
|
||||
padding: 1px 3px;
|
||||
border-radius: 3px;
|
||||
text-decoration: underline wavy;
|
||||
}
|
||||
.wp-asm-empty-inline {
|
||||
color: var(--wp-text3, #5a5a72);
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,90 @@
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import ContextWidget from "./ContextWidget.vue";
|
||||
|
||||
afterEach(() => {
|
||||
// Modals teleport to body — strip leftover overlays between tests.
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
const moduleJson = (vars: Array<{ name: string; value: string }>): string =>
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
modules: [
|
||||
{
|
||||
id: "m1",
|
||||
type: "fixed_values",
|
||||
enabled: true,
|
||||
meta: { name: "test mod", description: "", tags: [] },
|
||||
entries: vars.map((v) => ({ variable_name: v.name, value: v.value })),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
describe("ContextWidget.vue", () => {
|
||||
it("renders an empty state when no modules", () => {
|
||||
const wrapper = mount(ContextWidget, {
|
||||
props: { nodeId: 1, initialJson: '{"version":1,"modules":[]}', upstreamVars: [], onChange: () => {} },
|
||||
});
|
||||
// Empty hero shows the brand title + the "Add your first module" CTA.
|
||||
expect(wrapper.text()).toContain("Wildcard Pipeline");
|
||||
expect(wrapper.text()).toContain("Add your first module");
|
||||
});
|
||||
|
||||
it("opens picker, picks fixed_values, emits new JSON via onChange", async () => {
|
||||
let last = "";
|
||||
const wrapper = mount(ContextWidget, {
|
||||
attachTo: document.body,
|
||||
props: { nodeId: 2, initialJson: '{"version":1,"modules":[]}', upstreamVars: [], onChange: (v: string) => { last = v; } },
|
||||
});
|
||||
await wrapper.find('[data-testid="open-picker"]').trigger("click");
|
||||
const pick = document.querySelector<HTMLButtonElement>('[data-testid="pick-fixed_values"]');
|
||||
expect(pick).not.toBeNull();
|
||||
pick!.click();
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(last).toMatch(/"type":"fixed_values"/);
|
||||
});
|
||||
|
||||
it("renders a summary line per module — first 2 vars + '+N more' for the rest", () => {
|
||||
const wrapper = mount(ContextWidget, {
|
||||
props: {
|
||||
nodeId: 3,
|
||||
initialJson: moduleJson([
|
||||
{ name: "style", value: "noir" },
|
||||
{ name: "subject", value: "fox" },
|
||||
{ name: "mood", value: "calm" },
|
||||
{ name: "light", value: "soft" },
|
||||
]),
|
||||
upstreamVars: [],
|
||||
onChange: () => {},
|
||||
},
|
||||
});
|
||||
const summary = wrapper.find(".wp-summary");
|
||||
expect(summary.exists()).toBe(true);
|
||||
expect(summary.text()).toBe("$style, $subject, +2 more");
|
||||
});
|
||||
|
||||
it("module name renders read-only as text (not an input)", () => {
|
||||
const wrapper = mount(ContextWidget, {
|
||||
props: { nodeId: 4, initialJson: moduleJson([]), upstreamVars: [], onChange: () => {} },
|
||||
});
|
||||
expect(wrapper.find("input.wp-module-name").exists()).toBe(false);
|
||||
expect(wrapper.find(".wp-module-name").text()).toBe("test mod");
|
||||
});
|
||||
|
||||
it("summary line is read-only — not a button, no click handler", () => {
|
||||
const wrapper = mount(ContextWidget, {
|
||||
props: {
|
||||
nodeId: 5,
|
||||
initialJson: moduleJson([{ name: "style", value: "noir" }]),
|
||||
upstreamVars: [],
|
||||
onChange: () => {},
|
||||
},
|
||||
});
|
||||
const summary = wrapper.find(".wp-summary");
|
||||
expect(summary.exists()).toBe(true);
|
||||
// Read-only div, not a button — edit access is via right-click → Edit
|
||||
// or Enter on the focused card.
|
||||
expect(summary.element.tagName).toBe("DIV");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,504 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, nextTick, onBeforeUnmount } from "vue";
|
||||
import ModalShell from "../shared/ModalShell.vue";
|
||||
import type { ModuleEntry } from "../../widgets/_shared";
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean;
|
||||
module: ModuleEntry | null;
|
||||
/** Variable names defined upstream — used for autocomplete + validity checks. */
|
||||
upstreamVars?: string[];
|
||||
/** Variable names defined by OTHER modules in the same node. */
|
||||
siblingVars?: string[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "save", value: ModuleEntry): void;
|
||||
(e: "close"): void;
|
||||
}>();
|
||||
|
||||
// Draft state — owned by the modal. Cancel discards, Save commits via emit.
|
||||
// `module` prop is the source-of-truth snapshot at open time; we deep-clone
|
||||
// via JSON round-trip (Proxy-safe at every depth, unlike structuredClone).
|
||||
const draft = ref<ModuleEntry | null>(null);
|
||||
const firstNameInput = ref<HTMLInputElement | null>(null);
|
||||
const moduleNameInput = ref<HTMLInputElement | null>(null);
|
||||
|
||||
watch(() => props.visible, async (v) => {
|
||||
if (v && props.module) {
|
||||
draft.value = JSON.parse(JSON.stringify(props.module));
|
||||
await nextTick();
|
||||
if (draft.value && draft.value.entries.length > 0) {
|
||||
firstNameInput.value?.focus();
|
||||
firstNameInput.value?.select();
|
||||
} else {
|
||||
moduleNameInput.value?.focus();
|
||||
moduleNameInput.value?.select();
|
||||
}
|
||||
window.addEventListener("keydown", onKeydown);
|
||||
} else {
|
||||
window.removeEventListener("keydown", onKeydown);
|
||||
draft.value = null;
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => window.removeEventListener("keydown", onKeydown));
|
||||
|
||||
function onKeydown(ev: KeyboardEvent) {
|
||||
if (!props.visible) return;
|
||||
if ((ev.ctrlKey || ev.metaKey) && ev.key === "Enter") {
|
||||
ev.preventDefault();
|
||||
save();
|
||||
}
|
||||
}
|
||||
|
||||
function iconFor(type: ModuleEntry["type"]): string {
|
||||
if (type === "fixed_values") return "pi-tag";
|
||||
return "pi-question";
|
||||
}
|
||||
|
||||
// Per-entry validity:
|
||||
// ok — name is set and unique
|
||||
// empty — name not set yet (no glyph)
|
||||
// shadow — name exists upstream OR in another module in this node
|
||||
// (informational; runtime is last-write-wins anyway)
|
||||
// dup — same name appears earlier in THIS module (real bug — the
|
||||
// second write inside the same module is silently ignored)
|
||||
type EntryStatus = "ok" | "empty" | "shadow" | "dup";
|
||||
|
||||
const entryStatuses = computed<EntryStatus[]>(() => {
|
||||
if (!draft.value) return [];
|
||||
const known = new Set([...(props.upstreamVars ?? []), ...(props.siblingVars ?? [])]);
|
||||
const seen = new Set<string>();
|
||||
return draft.value.entries.map((e) => {
|
||||
const name = e.variable_name.trim();
|
||||
if (!name) return "empty";
|
||||
if (seen.has(name)) return "dup";
|
||||
seen.add(name);
|
||||
if (known.has(name)) return "shadow";
|
||||
return "ok";
|
||||
});
|
||||
});
|
||||
|
||||
function statusTooltip(s: EntryStatus): string {
|
||||
if (s === "ok") return "Unique name";
|
||||
if (s === "shadow") return "Already defined upstream or in another module here";
|
||||
if (s === "dup") return "Duplicate name in this module";
|
||||
return "";
|
||||
}
|
||||
|
||||
function addEntry() {
|
||||
if (!draft.value) return;
|
||||
draft.value.entries.push({ variable_name: "", value: "" });
|
||||
}
|
||||
|
||||
function removeEntry(idx: number) {
|
||||
if (!draft.value) return;
|
||||
draft.value.entries.splice(idx, 1);
|
||||
}
|
||||
|
||||
function updateVarName(idx: number, v: string) {
|
||||
if (!draft.value) return;
|
||||
draft.value.entries[idx].variable_name = v.replace(/^\$+/, "");
|
||||
}
|
||||
|
||||
function onValueEnter(idx: number, ev: KeyboardEvent) {
|
||||
if (!draft.value) return;
|
||||
ev.preventDefault();
|
||||
const isLast = idx === draft.value.entries.length - 1;
|
||||
if (isLast) {
|
||||
addEntry();
|
||||
nextTick(() => {
|
||||
const inputs = document.querySelectorAll<HTMLInputElement>(".wp-medit__entry-var");
|
||||
inputs[inputs.length - 1]?.focus();
|
||||
});
|
||||
} else {
|
||||
nextTick(() => {
|
||||
const inputs = document.querySelectorAll<HTMLInputElement>(".wp-medit__entry-var");
|
||||
inputs[idx + 1]?.focus();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Bulk paste — multi-line `name=value` text becomes N entries auto-parsed.
|
||||
// Triggered when the user pastes into a variable-name input. If the paste
|
||||
// is single-line, defer to native input behavior.
|
||||
function onNamePaste(idx: number, ev: ClipboardEvent) {
|
||||
if (!draft.value) return;
|
||||
const text = ev.clipboardData?.getData("text") ?? "";
|
||||
if (!text.includes("\n") && !text.includes("=")) return; // single token — let browser paste
|
||||
const lines = text.split(/\r?\n/).map((l) => l.trim()).filter((l) => l.length > 0);
|
||||
if (lines.length === 0) return;
|
||||
// Each line: name=value, name : value, or just name
|
||||
const parsed = lines.map((line) => {
|
||||
const m = /^\s*\$?([A-Za-z_][A-Za-z0-9_]*)\s*[=:]\s*(.*?)\s*$/.exec(line);
|
||||
if (m) return { variable_name: m[1], value: m[2] };
|
||||
const nameOnly = /^\s*\$?([A-Za-z_][A-Za-z0-9_]*)\s*$/.exec(line);
|
||||
if (nameOnly) return { variable_name: nameOnly[1], value: "" };
|
||||
return null;
|
||||
}).filter((x): x is { variable_name: string; value: string } => x !== null);
|
||||
if (parsed.length === 0) return;
|
||||
ev.preventDefault();
|
||||
// Replace the current row with first parsed entry, splice the rest after.
|
||||
draft.value.entries[idx] = parsed[0];
|
||||
if (parsed.length > 1) {
|
||||
draft.value.entries.splice(idx + 1, 0, ...parsed.slice(1));
|
||||
}
|
||||
}
|
||||
|
||||
function save() {
|
||||
if (!draft.value) return;
|
||||
emit("save", JSON.parse(JSON.stringify(draft.value)));
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
emit("close");
|
||||
}
|
||||
|
||||
// HTML <datalist> id needs to be unique per modal mount so multiple Context
|
||||
// nodes don't share a list (though only one modal is visible at a time).
|
||||
const datalistId = `wp-medit-vars-${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
// Autocomplete options = upstream + sibling modules' variables, deduped.
|
||||
const autocompleteOptions = computed<string[]>(() => {
|
||||
const set = new Set([...(props.upstreamVars ?? []), ...(props.siblingVars ?? [])]);
|
||||
return [...set].sort();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ModalShell :visible="visible" @close="cancel">
|
||||
<div v-if="draft" class="wp-medit">
|
||||
<header class="wp-medit__head">
|
||||
<i :class="['pi', iconFor(draft.type), 'wp-medit__head-icon', `type-${draft.type}`]" aria-hidden="true"></i>
|
||||
<input
|
||||
ref="moduleNameInput"
|
||||
v-model="draft.meta.name"
|
||||
class="wp-medit__name-input"
|
||||
placeholder="module name"
|
||||
spellcheck="false"
|
||||
/>
|
||||
<button type="button" class="wp-medit__close" aria-label="Close" @click="cancel">
|
||||
<i class="pi pi-times" aria-hidden="true"></i>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="wp-medit__body">
|
||||
<section class="wp-medit__section">
|
||||
<label class="wp-medit__section-label">DESCRIPTION</label>
|
||||
<input
|
||||
v-model="draft.meta.description"
|
||||
class="wp-medit__meta-input"
|
||||
placeholder="optional — what this module is for"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section class="wp-medit__section">
|
||||
<label class="wp-medit__section-label">ENTRIES</label>
|
||||
<p class="wp-medit__hint-line">
|
||||
Tip — paste multi-line <code>name=value</code> text into a name field to bulk-add.
|
||||
</p>
|
||||
<div v-if="draft.entries.length === 0" class="wp-medit__empty">
|
||||
No entries yet.
|
||||
</div>
|
||||
<TransitionGroup v-else tag="div" name="wp-medit-list" class="wp-medit__entries">
|
||||
<datalist :id="datalistId" key="datalist">
|
||||
<option v-for="v in autocompleteOptions" :key="v" :value="v"></option>
|
||||
</datalist>
|
||||
<div v-for="(e, i) in draft.entries" :key="`row-${i}`" class="wp-medit__entry">
|
||||
<span
|
||||
class="wp-medit__status"
|
||||
:class="`wp-medit__status--${entryStatuses[i]}`"
|
||||
:title="statusTooltip(entryStatuses[i])"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<i v-if="entryStatuses[i] === 'ok'" class="pi pi-check"></i>
|
||||
<i v-else-if="entryStatuses[i] === 'dup'" class="pi pi-exclamation-circle"></i>
|
||||
<i v-else-if="entryStatuses[i] === 'shadow'" class="pi pi-info-circle"></i>
|
||||
</span>
|
||||
<div class="wp-medit__entry-var-wrap">
|
||||
<span class="wp-medit__entry-prefix" aria-hidden="true">$</span>
|
||||
<input
|
||||
:ref="(el) => { if (i === 0) firstNameInput = el as HTMLInputElement }"
|
||||
class="wp-medit__entry-input wp-medit__entry-var"
|
||||
:value="e.variable_name"
|
||||
placeholder="name"
|
||||
spellcheck="false"
|
||||
:list="datalistId"
|
||||
@input="(ev) => updateVarName(i, (ev.target as HTMLInputElement).value)"
|
||||
@paste="(ev) => onNamePaste(i, ev)"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
v-model="e.value"
|
||||
class="wp-medit__entry-input wp-medit__entry-value"
|
||||
placeholder="value"
|
||||
@keydown.enter="(ev) => onValueEnter(i, ev)"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="remove entry"
|
||||
title="Remove entry"
|
||||
class="wp-medit__entry-remove"
|
||||
@click="removeEntry(i)"
|
||||
><i class="pi pi-times" aria-hidden="true"></i></button>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
<button type="button" class="wp-medit__add-entry" @click="addEntry">
|
||||
<i class="pi pi-plus" aria-hidden="true"></i> add entry
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<footer class="wp-medit__foot">
|
||||
<span class="wp-medit__hint">Esc to cancel · Ctrl+Enter to save</span>
|
||||
<div class="wp-medit__buttons">
|
||||
<button type="button" class="wp-medit__btn" @click="cancel">Cancel</button>
|
||||
<button type="button" class="wp-medit__btn wp-medit__btn--primary" @click="save">Save</button>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</ModalShell>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
@import "../shared/theme.css";
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
.wp-medit, .wp-medit * { box-sizing: border-box; }
|
||||
.wp-medit {
|
||||
background: var(--wp-bg2);
|
||||
border: 1px solid var(--wp-border);
|
||||
border-radius: var(--wp-radius);
|
||||
width: 540px;
|
||||
max-width: 100%;
|
||||
max-height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
color: var(--wp-text);
|
||||
font-family: var(--wp-font-sans, sans-serif);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.wp-medit__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid var(--wp-border);
|
||||
background: var(--wp-brand-gradient);
|
||||
position: relative;
|
||||
}
|
||||
.wp-medit__head::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(35, 35, 35, 0.85);
|
||||
pointer-events: none;
|
||||
}
|
||||
.wp-medit__head > * { position: relative; z-index: 1; }
|
||||
.wp-medit__head-icon {
|
||||
font-size: 14px;
|
||||
color: var(--wp-text2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.wp-medit__head-icon.type-fixed_values { color: var(--wp-rose); }
|
||||
.wp-medit__name-input {
|
||||
flex: 1;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
border: 1px solid var(--wp-border);
|
||||
border-radius: var(--wp-radius-sm);
|
||||
color: var(--wp-text);
|
||||
font-family: var(--wp-font-sans, sans-serif);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
padding: 4px 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
.wp-medit__name-input:focus { outline: none; border-color: var(--wp-accent); }
|
||||
.wp-medit__close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--wp-text3);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
padding: 4px 6px;
|
||||
}
|
||||
.wp-medit__close:hover { color: var(--wp-text); }
|
||||
|
||||
.wp-medit__body {
|
||||
padding: 12px 14px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
.wp-medit__section { display: flex; flex-direction: column; gap: 6px; }
|
||||
.wp-medit__section-label {
|
||||
font-size: 9px;
|
||||
font-family: var(--wp-font-mono, monospace);
|
||||
color: var(--wp-text3);
|
||||
letter-spacing: 0.08em;
|
||||
font-weight: 600;
|
||||
}
|
||||
.wp-medit__hint-line {
|
||||
font-size: 11px;
|
||||
color: var(--wp-text3);
|
||||
margin: 0 0 2px;
|
||||
}
|
||||
.wp-medit__hint-line code {
|
||||
font-family: var(--wp-font-mono, monospace);
|
||||
background: var(--wp-bg);
|
||||
padding: 0 4px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.wp-medit__meta-input {
|
||||
background: var(--wp-bg);
|
||||
border: 1px solid var(--wp-border);
|
||||
border-radius: var(--wp-radius-sm);
|
||||
color: var(--wp-text);
|
||||
font-family: var(--wp-font-sans, sans-serif);
|
||||
font-size: 12px;
|
||||
padding: 5px 8px;
|
||||
width: 100%;
|
||||
}
|
||||
.wp-medit__meta-input:focus { outline: none; border-color: var(--wp-accent); }
|
||||
|
||||
.wp-medit__empty {
|
||||
color: var(--wp-text3);
|
||||
font-style: italic;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.wp-medit__entries {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
}
|
||||
.wp-medit__entry {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
}
|
||||
.wp-medit__entry > .wp-medit__entry-var-wrap,
|
||||
.wp-medit__entry > .wp-medit__entry-value {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Per-entry validity glyph */
|
||||
.wp-medit__status {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
flex-shrink: 0;
|
||||
cursor: help;
|
||||
}
|
||||
.wp-medit__status--empty,
|
||||
.wp-medit__status--empty i { display: none; }
|
||||
.wp-medit__status--ok i { color: var(--wp-green); }
|
||||
.wp-medit__status--shadow i { color: var(--wp-accent); }
|
||||
.wp-medit__status--dup i { color: var(--wp-red); }
|
||||
|
||||
.wp-medit__entry-var-wrap {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
.wp-medit__entry-prefix {
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--wp-accent);
|
||||
font-family: var(--wp-font-mono, monospace);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
pointer-events: none;
|
||||
}
|
||||
.wp-medit__entry-var { padding-left: 16px !important; }
|
||||
.wp-medit__entry-input {
|
||||
background: var(--wp-bg);
|
||||
border: 1px solid var(--wp-border);
|
||||
border-radius: var(--wp-radius-sm);
|
||||
color: var(--wp-text);
|
||||
font-family: var(--wp-font-mono, monospace);
|
||||
font-size: 12px;
|
||||
padding: 5px 8px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
.wp-medit__entry-input:focus { outline: none; border-color: var(--wp-accent); }
|
||||
.wp-medit__entry-remove {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--wp-text3);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
.wp-medit__entry-remove:hover { color: var(--wp-red); }
|
||||
|
||||
.wp-medit__add-entry {
|
||||
background: none;
|
||||
border: 1px dashed var(--wp-border2);
|
||||
border-radius: var(--wp-radius-sm);
|
||||
color: var(--wp-text2);
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
padding: 5px;
|
||||
align-self: flex-start;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.wp-medit__add-entry:hover { color: var(--wp-accent); border-color: var(--wp-accent); }
|
||||
|
||||
.wp-medit__foot {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 14px;
|
||||
border-top: 1px solid var(--wp-border);
|
||||
gap: 12px;
|
||||
}
|
||||
.wp-medit__hint {
|
||||
font-size: 10px;
|
||||
color: var(--wp-text3);
|
||||
font-family: var(--wp-font-mono, monospace);
|
||||
}
|
||||
.wp-medit__buttons { display: flex; gap: 8px; }
|
||||
.wp-medit__btn {
|
||||
background: var(--wp-bg3);
|
||||
border: 1px solid var(--wp-border);
|
||||
border-radius: var(--wp-radius-sm);
|
||||
color: var(--wp-text);
|
||||
font-family: var(--wp-font-sans, sans-serif);
|
||||
font-size: 12px;
|
||||
padding: 5px 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.wp-medit__btn:hover { border-color: var(--wp-border2); }
|
||||
.wp-medit__btn--primary {
|
||||
background: var(--wp-accent);
|
||||
border-color: var(--wp-accent);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
.wp-medit__btn--primary:hover { background: var(--wp-accent2); border-color: var(--wp-accent2); }
|
||||
|
||||
/* Entry-row enter + reorder animations. Leave is instant — fade-out felt
|
||||
* laggy when chained with a FLIP move to fill the gap. */
|
||||
.wp-medit-list-move { transition: transform 0.2s ease-out; }
|
||||
.wp-medit-list-enter-active { transition: opacity 0.18s, transform 0.18s; }
|
||||
.wp-medit-list-enter-from { opacity: 0; transform: translateY(-6px); }
|
||||
</style>
|
||||
@@ -0,0 +1,163 @@
|
||||
<template>
|
||||
<ModalShell :visible="visible" @close="$emit('close')">
|
||||
<div class="wp-picker">
|
||||
<header class="wp-picker__head">
|
||||
<Logo :size="32" />
|
||||
<div class="wp-picker__head-text">
|
||||
<div class="wp-picker__title">Add module</div>
|
||||
<div class="wp-picker__subtitle">Composable prompt context for ComfyUI</div>
|
||||
</div>
|
||||
<button type="button" class="wp-picker__close" aria-label="Close" @click="$emit('close')">
|
||||
<i class="pi pi-times" aria-hidden="true"></i>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="wp-picker__body">
|
||||
<button
|
||||
v-for="kind in kinds"
|
||||
:key="kind.type"
|
||||
type="button"
|
||||
class="wp-picker__item"
|
||||
:data-testid="`pick-${kind.type}`"
|
||||
@click="$emit('select', kind.type)"
|
||||
>
|
||||
<div class="wp-picker__item-row">
|
||||
<i
|
||||
:class="['pi', kind.icon, 'wp-picker__item-icon']"
|
||||
:style="{ color: kind.color }"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="wp-picker__item-name">{{ kind.label }}</span>
|
||||
</div>
|
||||
<div class="wp-picker__item-desc">{{ kind.description }}</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalShell>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ModalShell from "../shared/ModalShell.vue";
|
||||
import Logo from "../shared/Logo.vue";
|
||||
|
||||
defineProps<{ visible: boolean }>();
|
||||
defineEmits<{ (e: "select", type: "fixed_values"): void; (e: "close"): void }>();
|
||||
|
||||
// MVP only ships fixed_values. New types append here; the type-icon mapping
|
||||
// lives in shared/theme.css comments + this kinds array (color via CSS class
|
||||
// per type, icon via PrimeIcons name).
|
||||
const kinds = [
|
||||
{
|
||||
type: "fixed_values" as const,
|
||||
label: "Fixed values",
|
||||
icon: "pi-tag",
|
||||
color: "var(--wp-rose)",
|
||||
description: "Hard-coded $variable = value pairs.",
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@import "../shared/theme.css";
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
.wp-picker {
|
||||
background: var(--wp-bg2);
|
||||
border: 1px solid var(--wp-border);
|
||||
border-radius: var(--wp-radius);
|
||||
width: 480px;
|
||||
max-width: 100%;
|
||||
max-height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
color: var(--wp-text);
|
||||
}
|
||||
.wp-picker__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--wp-border);
|
||||
background: var(--wp-brand-gradient);
|
||||
background-size: 200% 200%;
|
||||
position: relative;
|
||||
}
|
||||
.wp-picker__head::after {
|
||||
/* Translucent overlay so the gradient sits behind a dark wash — same
|
||||
* effect as the rest of the modal but with a hint of brand color. */
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(35, 35, 35, 0.85);
|
||||
pointer-events: none;
|
||||
}
|
||||
.wp-picker__head > * { position: relative; z-index: 1; }
|
||||
.wp-picker__head-text { flex: 1; min-width: 0; }
|
||||
.wp-picker__title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.wp-picker__subtitle {
|
||||
font-size: 11px;
|
||||
color: var(--wp-text2);
|
||||
margin-top: 2px;
|
||||
}
|
||||
.wp-picker__close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--wp-text3);
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
padding: 4px 6px;
|
||||
}
|
||||
.wp-picker__close:hover { color: var(--wp-text); }
|
||||
|
||||
.wp-picker__body {
|
||||
padding: 12px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.wp-picker__item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
background: var(--wp-bg3);
|
||||
border: 1px solid var(--wp-border);
|
||||
border-radius: var(--wp-radius-sm);
|
||||
padding: 10px 12px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
color: var(--wp-text);
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.wp-picker__item:hover {
|
||||
border-color: var(--wp-accent);
|
||||
background: var(--wp-bg4);
|
||||
}
|
||||
.wp-picker__item-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.wp-picker__item-icon {
|
||||
font-size: 14px;
|
||||
color: var(--wp-text2);
|
||||
width: 16px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.wp-picker__item-name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.wp-picker__item-desc {
|
||||
font-size: 11px;
|
||||
color: var(--wp-text2);
|
||||
padding-left: 26px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,15 @@
|
||||
import { ref } from "vue";
|
||||
import type { ModuleEntry } from "../../widgets/_shared";
|
||||
|
||||
// Module-scoped reactive state shared across every ContextWidget Vue app
|
||||
// instance on the page. Each Context node mounts its own Vue app, so we
|
||||
// can't use provide/inject — but plain module imports give us a single
|
||||
// source of truth for "what is currently being dragged".
|
||||
export interface DragPayload {
|
||||
sourceNodeId: number;
|
||||
module: ModuleEntry;
|
||||
/** Set by the receiving node on cross-node drop so source can clean up. */
|
||||
consumedBy?: number;
|
||||
}
|
||||
|
||||
export const dragState = ref<DragPayload | null>(null);
|
||||
@@ -0,0 +1,15 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import DebugViewer from "./DebugViewer.vue";
|
||||
|
||||
describe("DebugViewer.vue", () => {
|
||||
it("renders a placeholder when no snapshot has been received", () => {
|
||||
const wrapper = mount(DebugViewer, { props: { snapshot: "" } });
|
||||
expect(wrapper.text()).toContain("Run the graph");
|
||||
});
|
||||
|
||||
it("renders the snapshot JSON pretty-printed", () => {
|
||||
const wrapper = mount(DebugViewer, { props: { snapshot: '{"context":{"a":1},"debug":{}}' } });
|
||||
expect(wrapper.text()).toContain('"a": 1');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
|
||||
const props = defineProps<{ snapshot: string }>();
|
||||
|
||||
const pretty = computed(() => {
|
||||
if (!props.snapshot) return "";
|
||||
try { return JSON.stringify(JSON.parse(props.snapshot), null, 2); }
|
||||
catch { return props.snapshot; }
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="wp-debug">
|
||||
<pre v-if="pretty" class="wp-debug__pre">{{ pretty }}</pre>
|
||||
<p v-else class="wp-debug__empty">Run the graph to capture a snapshot.</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
@import "../shared/theme.css";
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
.wp-debug {
|
||||
font-family: var(--wp-font-mono, monospace);
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
padding: 6px;
|
||||
color: var(--wp-text);
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.wp-debug__pre {
|
||||
background: var(--wp-bg);
|
||||
color: var(--wp-text);
|
||||
border: 1px solid var(--wp-border);
|
||||
border-radius: var(--wp-radius-sm);
|
||||
padding: 8px;
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.wp-debug__empty {
|
||||
color: var(--wp-text3);
|
||||
font-style: italic;
|
||||
font-family: var(--wp-font-sans, sans-serif);
|
||||
text-align: center;
|
||||
margin: auto 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,182 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, ref, watch, nextTick } from "vue";
|
||||
|
||||
export interface ContextMenuItem {
|
||||
label: string;
|
||||
/** PrimeIcons class (e.g. "pi-trash"). ComfyUI loads PrimeIcons globally. */
|
||||
icon?: string;
|
||||
/** Optional separator above the item. */
|
||||
divider?: boolean;
|
||||
/** Render the label in red (destructive action). */
|
||||
danger?: boolean;
|
||||
disabled?: boolean;
|
||||
onSelect: () => void;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean;
|
||||
x: number;
|
||||
y: number;
|
||||
items: ContextMenuItem[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{ (e: "close"): void }>();
|
||||
|
||||
const activeIndex = ref(-1);
|
||||
|
||||
function pickableIndices(): number[] {
|
||||
const out: number[] = [];
|
||||
for (let i = 0; i < props.items.length; i++) {
|
||||
const it = props.items[i];
|
||||
if (!it.disabled) out.push(i);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function moveActive(delta: 1 | -1) {
|
||||
const idxs = pickableIndices();
|
||||
if (!idxs.length) return;
|
||||
const cur = idxs.indexOf(activeIndex.value);
|
||||
const next = cur < 0 ? (delta > 0 ? 0 : idxs.length - 1) : (cur + delta + idxs.length) % idxs.length;
|
||||
activeIndex.value = idxs[next];
|
||||
}
|
||||
|
||||
function gotoEdge(edge: "first" | "last") {
|
||||
const idxs = pickableIndices();
|
||||
if (!idxs.length) return;
|
||||
activeIndex.value = edge === "first" ? idxs[0] : idxs[idxs.length - 1];
|
||||
}
|
||||
|
||||
function onWindowClick() {
|
||||
emit("close");
|
||||
}
|
||||
|
||||
function onKey(ev: KeyboardEvent) {
|
||||
switch (ev.key) {
|
||||
case "Escape": ev.preventDefault(); emit("close"); break;
|
||||
case "ArrowDown": ev.preventDefault(); moveActive(1); break;
|
||||
case "ArrowUp": ev.preventDefault(); moveActive(-1); break;
|
||||
case "Home": ev.preventDefault(); gotoEdge("first"); break;
|
||||
case "End": ev.preventDefault(); gotoEdge("last"); break;
|
||||
case "Enter":
|
||||
case " ": {
|
||||
ev.preventDefault();
|
||||
const it = props.items[activeIndex.value];
|
||||
if (it && !it.disabled) pick(it);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.visible, async (v) => {
|
||||
if (v) {
|
||||
activeIndex.value = -1;
|
||||
window.addEventListener("click", onWindowClick);
|
||||
window.addEventListener("keydown", onKey);
|
||||
// Defer one tick so the click that opened the menu doesn't immediately
|
||||
// close it — and so the first ArrowDown lands on the first item.
|
||||
await nextTick();
|
||||
} else {
|
||||
window.removeEventListener("click", onWindowClick);
|
||||
window.removeEventListener("keydown", onKey);
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener("click", onWindowClick);
|
||||
window.removeEventListener("keydown", onKey);
|
||||
});
|
||||
|
||||
function pick(item: ContextMenuItem) {
|
||||
if (item.disabled) return;
|
||||
item.onSelect();
|
||||
emit("close");
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<ul
|
||||
v-if="visible"
|
||||
class="wp-ctxmenu"
|
||||
:style="{ left: `${x}px`, top: `${y}px` }"
|
||||
role="menu"
|
||||
@click.stop
|
||||
@contextmenu.prevent
|
||||
>
|
||||
<template v-for="(it, i) in items" :key="i">
|
||||
<li v-if="it.divider" class="wp-ctxmenu__sep" aria-hidden="true"></li>
|
||||
<li
|
||||
class="wp-ctxmenu__item"
|
||||
:class="{
|
||||
'wp-ctxmenu__item--danger': it.danger,
|
||||
'wp-ctxmenu__item--disabled': it.disabled,
|
||||
'wp-ctxmenu__item--active': activeIndex === i,
|
||||
}"
|
||||
role="menuitem"
|
||||
:aria-disabled="it.disabled || undefined"
|
||||
@mouseenter="!it.disabled && (activeIndex = i)"
|
||||
@click="pick(it)"
|
||||
>
|
||||
<i v-if="it.icon" :class="['pi', it.icon, 'wp-ctxmenu__icon']" aria-hidden="true"></i>
|
||||
<span v-else class="wp-ctxmenu__icon-spacer" aria-hidden="true"></span>
|
||||
<span class="wp-ctxmenu__label">{{ it.label }}</span>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
@import "./theme.css";
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
.wp-ctxmenu {
|
||||
position: fixed;
|
||||
z-index: 10000;
|
||||
background: var(--wp-bg2);
|
||||
border: 1px solid var(--wp-border);
|
||||
border-radius: var(--wp-radius-sm);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
|
||||
padding: 4px 0;
|
||||
min-width: 180px;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
font-family: var(--wp-font-sans, sans-serif);
|
||||
font-size: 12px;
|
||||
color: var(--wp-text);
|
||||
user-select: none;
|
||||
}
|
||||
.wp-ctxmenu__item {
|
||||
padding: 5px 12px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.wp-ctxmenu__item--active,
|
||||
.wp-ctxmenu__item:hover { background: var(--wp-accent-glow); color: var(--wp-accent); }
|
||||
.wp-ctxmenu__item--danger { color: var(--wp-red); }
|
||||
.wp-ctxmenu__item--danger.wp-ctxmenu__item--active,
|
||||
.wp-ctxmenu__item--danger:hover { background: var(--wp-red-bg); color: var(--wp-red); }
|
||||
.wp-ctxmenu__item--disabled {
|
||||
color: var(--wp-text3);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.wp-ctxmenu__item--disabled:hover { background: transparent; color: var(--wp-text3); }
|
||||
.wp-ctxmenu__icon,
|
||||
.wp-ctxmenu__icon-spacer {
|
||||
width: 14px;
|
||||
font-size: 11px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.wp-ctxmenu__label { flex: 1; }
|
||||
.wp-ctxmenu__sep {
|
||||
height: 1px;
|
||||
background: var(--wp-border);
|
||||
margin: 4px 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(defineProps<{ size?: number }>(), { size: 28 });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 1024 1024"
|
||||
:width="size"
|
||||
:height="size"
|
||||
role="img"
|
||||
aria-label="Wildcard Pipeline"
|
||||
class="wp-logo"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="wp-logo-grad-1" x1="312.56" y1="897.19" x2="312.56" y2="2" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#a970ff" />
|
||||
<stop offset="1" stop-color="#6366f1" />
|
||||
</linearGradient>
|
||||
<linearGradient id="wp-logo-grad-2" x1="711.44" y1="126.81" x2="711.44" y2="1022" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#6366f1" />
|
||||
<stop offset="1" stop-color="#33d6c6" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path fill="url(#wp-logo-grad-1)" d="m512,2c-53.6,0-98.47,38.16-108.84,88.73-75.07,19.29-143.32,58.26-198.74,113.68-55.42,55.42-94.39,123.68-113.68,198.74-50.58,10.38-88.73,55.24-88.73,108.84s38.16,98.47,88.73,108.84c13.62,53.14,37.03,102.64,69.77,147.47,37.14,50.86,85.83,93.86,140.81,124.33,5.54,3.08,11.56,4.54,17.48,4.54,12.7,0,25.02-6.72,31.61-18.61,9.68-17.44,3.38-39.42-14.07-49.09-85.32-47.32-147.22-125.71-173.38-218.21,36.3-18.29,61.27-55.92,61.27-99.28s-24.94-80.97-61.23-99.27c34.22-119.96,129.79-215.52,249.74-249.74,18.3,36.29,55.92,61.23,99.27,61.23,61.27,0,111.11-49.84,111.11-111.11S573.27,2,512,2ZM113.11,473.11c21.44,0,38.89,17.44,38.89,38.89,0,20.1-15.32,36.69-34.91,38.69h-.01c-.18.02-.36.03-.53.04-1.13.1-2.28.16-3.43.16-21.44,0-38.89-17.44-38.89-38.89s17.44-38.89,38.89-38.89Zm398.89-321.11c-21.44,0-38.89-17.44-38.89-38.89s17.44-38.89,38.89-38.89,38.89,17.44,38.89,38.89-17.44,38.89-38.89,38.89Z" />
|
||||
<path fill="url(#wp-logo-grad-2)" d="m933.27,403.16c-13.62-53.14-37.03-102.64-69.77-147.47-37.14-50.86-85.83-93.86-140.81-124.33-17.44-9.68-39.42-3.38-49.09,14.07-9.68,17.44-3.38,39.42,14.07,49.09,85.32,47.32,147.22,125.71,173.38,218.21-36.3,18.29-61.27,55.92-61.27,99.28s24.94,80.97,61.23,99.27c-34.22,119.96-129.79,215.52-249.74,249.74-18.3-36.29-55.92-61.23-99.27-61.23-61.27,0-111.11,49.84-111.11,111.11s49.84,111.11,111.11,111.11c53.6,0,98.47-38.16,108.84-88.73,75.07-19.29,143.32-58.26,198.74-113.68,55.42-55.42,94.39-123.68,113.68-198.74,50.58-10.38,88.73-55.24,88.73-108.84s-38.16-98.47-88.73-108.84Zm-421.27,546.62c-21.44,0-38.89-17.44-38.89-38.89s17.44-38.89,38.89-38.89,38.89,17.44,38.89,38.89-17.44,38.89-38.89,38.89Zm398.89-398.89c-21.44,0-38.89-17.44-38.89-38.89,0-20.03,15.22-36.58,34.72-38.67.38-.04.77-.08,1.16-.11h.02c.99-.08,1.99-.11,2.99-.11,21.44,0,38.89,17.44,38.89,38.89s-17.44,38.89-38.89,38.89Z" />
|
||||
<path fill="#a970ff" d="m735.02,517.56c-153.82,30.87-186.59,63.64-217.46,217.46-1.22,6.08-9.9,6.08-11.12,0-30.87-153.82-63.64-186.59-217.46-217.46-6.08-1.22-6.08-9.9,0-11.12,153.82-30.87,186.59-63.64,217.46-217.46,1.22-6.08,9.9-6.08,11.12,0,30.87,153.82,63.64,186.59,217.46,217.46,6.08,1.22,6.08,9.9,0,11.12Z" />
|
||||
<path fill="#6366f1" d="m434.06,665.85c-51.08,10.25-61.96,21.13-72.22,72.22-.41,2.02-3.29,2.02-3.69,0-10.25-51.08-21.13-61.96-72.22-72.22-2.02-.41-2.02-3.29,0-3.69,51.08-10.25,61.96-21.13,72.22-72.22.41-2.02,3.29-2.02,3.69,0,10.25,51.08,21.13,61.96,72.22,72.22,2.02.41,2.02,3.29,0,3.69Z" />
|
||||
<path fill="#33d6c6" d="m738.07,361.84c-51.08,10.25-61.96,21.13-72.22,72.22-.41,2.02-3.29,2.02-3.69,0-10.25-51.08-21.13-61.96-72.22-72.22-2.02-.41-2.02-3.29,0-3.69,51.08-10.25,61.96-21.13,72.22-72.22.41-2.02,3.29-2.02,3.69,0,10.25,51.08,21.13,61.96,72.22,72.22,2.02.41,2.02,3.29,0,3.69Z" />
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wp-logo { display: block; flex-shrink: 0; }
|
||||
</style>
|
||||
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-if="visible" class="wp-modal-overlay" @click="$emit('close')">
|
||||
<div class="wp-modal-wrapper" role="dialog" aria-modal="true" @click.stop>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { watch, onBeforeUnmount } from "vue";
|
||||
|
||||
const props = defineProps<{ visible: boolean }>();
|
||||
const emit = defineEmits<{ (e: "close"): void }>();
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if (props.visible && event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
emit("close");
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
(visible) => {
|
||||
if (visible) window.addEventListener("keydown", onKeydown);
|
||||
else window.removeEventListener("keydown", onKeydown);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => window.removeEventListener("keydown", onKeydown));
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@import "./theme.css";
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
.wp-modal-overlay,
|
||||
.wp-modal-overlay * {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.wp-modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
background: rgba(0, 0, 0, 0.62);
|
||||
font-family: var(--wp-font-sans, sans-serif);
|
||||
cursor: default;
|
||||
}
|
||||
.wp-modal-wrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,162 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount } from "vue";
|
||||
import { toasts, dismissToast, type Toast } from "./toast-store";
|
||||
|
||||
function severityIcon(s: Toast["severity"]): string {
|
||||
if (s === "success") return "pi-check-circle";
|
||||
if (s === "warning") return "pi-exclamation-triangle";
|
||||
if (s === "error") return "pi-times-circle";
|
||||
return "pi-info-circle";
|
||||
}
|
||||
|
||||
function pickAction(t: Toast) {
|
||||
t.action?.onSelect();
|
||||
dismissToast(t.id);
|
||||
}
|
||||
|
||||
// Anchor the stack relative to ComfyUI's graph canvas container, matching
|
||||
// the native PrimeVue Toast positioning. Reading GlobalToast.js: top = rect.top
|
||||
// + 100px (clears the menu bar), right = innerWidth - rect.right + 20px (sits
|
||||
// just inside the canvas right edge). Updates on resize + canvas mutations.
|
||||
const stackTop = ref<number>(56);
|
||||
const stackRight = ref<number>(16);
|
||||
|
||||
function updatePosition() {
|
||||
const canvas = document.querySelector<HTMLElement>(".graph-canvas-container");
|
||||
if (!canvas) return;
|
||||
const r = canvas.getBoundingClientRect();
|
||||
stackTop.value = r.top + 100;
|
||||
stackRight.value = Math.max(8, window.innerWidth - (r.left + r.width) + 20);
|
||||
}
|
||||
|
||||
let observer: ResizeObserver | null = null;
|
||||
onMounted(() => {
|
||||
updatePosition();
|
||||
window.addEventListener("resize", updatePosition);
|
||||
// Recompute when the canvas resizes (sidebar toggle, panel resize, etc.).
|
||||
const canvas = document.querySelector(".graph-canvas-container");
|
||||
if (canvas && typeof ResizeObserver !== "undefined") {
|
||||
observer = new ResizeObserver(updatePosition);
|
||||
observer.observe(canvas);
|
||||
}
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener("resize", updatePosition);
|
||||
observer?.disconnect();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<TransitionGroup
|
||||
tag="div"
|
||||
name="wp-toast"
|
||||
class="wp-toast-stack"
|
||||
:style="{ top: `${stackTop}px`, right: `${stackRight}px` }"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div
|
||||
v-for="t in toasts"
|
||||
:key="t.id"
|
||||
class="wp-toast"
|
||||
:class="`wp-toast--${t.severity}`"
|
||||
role="status"
|
||||
>
|
||||
<i :class="['pi', severityIcon(t.severity), 'wp-toast__icon']" aria-hidden="true"></i>
|
||||
<span class="wp-toast__msg">{{ t.message }}</span>
|
||||
<button
|
||||
v-if="t.action"
|
||||
type="button"
|
||||
class="wp-toast__action"
|
||||
@click="pickAction(t)"
|
||||
>{{ t.action.label }}</button>
|
||||
<button
|
||||
type="button"
|
||||
class="wp-toast__close"
|
||||
aria-label="Dismiss"
|
||||
@click="dismissToast(t.id)"
|
||||
><i class="pi pi-times" aria-hidden="true"></i></button>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
@import "./theme.css";
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
.wp-toast-stack {
|
||||
position: fixed;
|
||||
/* top + right set inline so we mirror ComfyUI's native toast anchor —
|
||||
* relative to .graph-canvas-container so sidebars don't push us off. */
|
||||
z-index: 10001;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
max-width: min(420px, calc(100vw - 32px));
|
||||
pointer-events: none;
|
||||
}
|
||||
.wp-toast {
|
||||
pointer-events: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
background: var(--wp-bg2);
|
||||
border: 1px solid var(--wp-border);
|
||||
border-left: 3px solid var(--wp-accent);
|
||||
border-radius: var(--wp-radius-sm);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
|
||||
font-family: var(--wp-font-sans, sans-serif);
|
||||
font-size: 12px;
|
||||
color: var(--wp-text);
|
||||
}
|
||||
.wp-toast--success { border-left-color: var(--wp-green); }
|
||||
.wp-toast--warning { border-left-color: var(--wp-amber); }
|
||||
.wp-toast--error { border-left-color: var(--wp-red); }
|
||||
|
||||
.wp-toast__icon {
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.wp-toast--info .wp-toast__icon { color: var(--wp-accent); }
|
||||
.wp-toast--success .wp-toast__icon { color: var(--wp-green); }
|
||||
.wp-toast--warning .wp-toast__icon { color: var(--wp-amber); }
|
||||
.wp-toast--error .wp-toast__icon { color: var(--wp-red); }
|
||||
|
||||
.wp-toast__msg {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.wp-toast__action {
|
||||
background: var(--wp-accent-glow);
|
||||
border: 1px solid var(--wp-accent);
|
||||
border-radius: var(--wp-radius-sm);
|
||||
color: var(--wp-accent);
|
||||
font-family: var(--wp-font-sans, sans-serif);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 3px 10px;
|
||||
cursor: pointer;
|
||||
transition: filter 0.12s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.wp-toast__action:hover { filter: brightness(1.2); }
|
||||
.wp-toast__close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--wp-text3);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
padding: 2px 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.wp-toast__close:hover { color: var(--wp-text); }
|
||||
|
||||
/* Slide-in from the right; instant out for snappiness. */
|
||||
.wp-toast-enter-active { transition: opacity 0.2s, transform 0.2s; }
|
||||
.wp-toast-enter-from { opacity: 0; transform: translateX(20px); }
|
||||
.wp-toast-move { transition: transform 0.2s ease-out; }
|
||||
</style>
|
||||
@@ -0,0 +1,53 @@
|
||||
/* Accessibility overrides driven by ComfyUI settings (see
|
||||
* src/extension/settings.ts). The `.wp-a11y-*` markers are toggled on
|
||||
* <body>; rules here scope the override to our own widgets so we don't
|
||||
* mutate ComfyUI core or other extensions. */
|
||||
|
||||
/* ── Reduce motion ──────────────────────────────────────────────────── */
|
||||
.wp-a11y-no-motion .wp-context,
|
||||
.wp-a11y-no-motion .wp-context *,
|
||||
.wp-a11y-no-motion .wp-asm,
|
||||
.wp-a11y-no-motion .wp-asm *,
|
||||
.wp-a11y-no-motion .wp-toast-stack,
|
||||
.wp-a11y-no-motion .wp-toast-stack *,
|
||||
.wp-a11y-no-motion .wp-emptystate,
|
||||
.wp-a11y-no-motion .wp-emptystate *,
|
||||
.wp-a11y-no-motion .wp-medit,
|
||||
.wp-a11y-no-motion .wp-medit *,
|
||||
.wp-a11y-no-motion .wp-picker,
|
||||
.wp-a11y-no-motion .wp-picker * {
|
||||
/* Keep just enough duration that Vue's transitionend listeners still
|
||||
* fire and cleanly tear down — instant 0ms can confuse them. */
|
||||
transition-duration: 0.01ms !important;
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
|
||||
/* ── High contrast — bump border + text tones, thicken focus outlines ── */
|
||||
.wp-a11y-high-contrast {
|
||||
--wp-border: #6e6e6e;
|
||||
--wp-border2: #9a9a9a;
|
||||
--wp-text2: #e6e6e6;
|
||||
--wp-text3: #b0b0b0;
|
||||
--wp-text4: #c5c5c5;
|
||||
}
|
||||
.wp-a11y-high-contrast .wp-module,
|
||||
.wp-a11y-high-contrast .wp-recovery,
|
||||
.wp-a11y-high-contrast .wp-asm__rendered,
|
||||
.wp-a11y-high-contrast .wp-recovery__btn,
|
||||
.wp-a11y-high-contrast .wp-emptystate__cta,
|
||||
.wp-a11y-high-contrast .wp-add-btn,
|
||||
.wp-a11y-high-contrast .wp-toast {
|
||||
border-width: 2px !important;
|
||||
}
|
||||
.wp-a11y-high-contrast .wp-module:focus,
|
||||
.wp-a11y-high-contrast .wp-module:focus-visible,
|
||||
.wp-a11y-high-contrast .wp-emptystate__cta:focus-visible,
|
||||
.wp-a11y-high-contrast .wp-add-btn:focus-visible {
|
||||
outline: 3px solid var(--wp-violet) !important;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.wp-a11y-high-contrast .wp-toggle-mark {
|
||||
border-width: 2px !important;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/* Theme tokens shared across WP widgets.
|
||||
* Brand palette pinned to public/images/favicon.svg — indigo / violet / teal.
|
||||
*/
|
||||
:root {
|
||||
/* Backgrounds — match ComfyUI's node interior */
|
||||
--wp-bg: #1e1e1e;
|
||||
--wp-bg2: #232323;
|
||||
--wp-bg3: #2a2a2a;
|
||||
--wp-bg4: #333;
|
||||
|
||||
/* Borders */
|
||||
--wp-border: #3a3a3a;
|
||||
--wp-border2: #4a4a4a;
|
||||
|
||||
/* Text — four tiers from primary to ghost.
|
||||
* text3 stays #666 to preserve every existing usage; text4 #888 is the
|
||||
* new "slightly brighter ghost" available for cases that need a bit
|
||||
* more readability without bumping all the way to text2. */
|
||||
--wp-text: #ddd;
|
||||
--wp-text2: #aaa;
|
||||
--wp-text3: #666;
|
||||
--wp-text4: #888;
|
||||
|
||||
/* Brand — logo palette (favicon.svg) */
|
||||
--wp-accent: #6366f1; /* indigo, primary brand */
|
||||
--wp-accent2: #a970ff; /* violet, hover / second tier */
|
||||
--wp-accent-glow: rgba(99, 102, 241, 0.15);
|
||||
--wp-violet: #a970ff; /* alias for accent2 — used by module-type tags */
|
||||
--wp-violet-bg: rgba(169, 112, 255, 0.12);
|
||||
--wp-teal: #33d6c6; /* exact logo teal */
|
||||
--wp-teal-bg: rgba(51, 214, 198, 0.12);
|
||||
--wp-brand-gradient: linear-gradient(135deg, #6366f1, #33d6c6);
|
||||
|
||||
/* Semantic — crisper red/amber/green for sharper alerting on dark */
|
||||
--wp-green: #6bc96f;
|
||||
--wp-green-bg: rgba(107, 201, 111, 0.12);
|
||||
--wp-amber: #fbbf24;
|
||||
--wp-amber-bg: rgba(251, 191, 36, 0.12);
|
||||
--wp-red: #f87171;
|
||||
--wp-red-bg: rgba(248, 113, 113, 0.12);
|
||||
|
||||
/* Module-type colors — one hue per type so the icon alone identifies the
|
||||
* module at a glance. fixed_values uses rose because it's a tag/label
|
||||
* module; the other types reuse brand colors per the type-mapping spec. */
|
||||
--wp-rose: #f472b6;
|
||||
--wp-rose-bg: rgba(244, 114, 182, 0.12);
|
||||
|
||||
/* Geometry */
|
||||
--wp-radius: 6px;
|
||||
--wp-radius-sm: 4px;
|
||||
|
||||
/* Typography — bundled webfonts (Inter + JetBrains Mono) loaded via
|
||||
* src/extension/fonts.ts. System stacks remain as fallback so the UI
|
||||
* stays readable if the woff2 files fail to load. */
|
||||
--wp-font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
--wp-font-mono: 'JetBrains Mono', 'Consolas', 'Monaco', 'Courier New', monospace;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { ref } from "vue";
|
||||
|
||||
export type ToastSeverity = "info" | "success" | "warning" | "error";
|
||||
|
||||
export interface Toast {
|
||||
id: number;
|
||||
message: string;
|
||||
severity: ToastSeverity;
|
||||
/** Optional inline action — e.g. "Undo" with a callback. */
|
||||
action?: { label: string; onSelect: () => void };
|
||||
/** Auto-dismiss after this many ms. 0 = sticky (must be dismissed manually). */
|
||||
lifeMs: number;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
let nextId = 1;
|
||||
export const toasts = ref<Toast[]>([]);
|
||||
|
||||
interface PushToastOptions {
|
||||
severity?: ToastSeverity;
|
||||
action?: Toast["action"];
|
||||
lifeMs?: number;
|
||||
}
|
||||
|
||||
export function pushToast(message: string, options: PushToastOptions = {}): number {
|
||||
const id = nextId++;
|
||||
const t: Toast = {
|
||||
id,
|
||||
message,
|
||||
severity: options.severity ?? "info",
|
||||
action: options.action,
|
||||
lifeMs: options.lifeMs ?? 5000,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
toasts.value = [...toasts.value, t];
|
||||
if (t.lifeMs > 0) {
|
||||
window.setTimeout(() => dismissToast(id), t.lifeMs);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
export function dismissToast(id: number): void {
|
||||
toasts.value = toasts.value.filter((t) => t.id !== id);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" id="Layer_1" data-name="Layer 1" viewBox="0 0 1024 1024" width="1024" height="1024"><defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: url(#linear-gradient-2);
|
||||
}
|
||||
|
||||
.cls-2 {
|
||||
fill: #6366f1;
|
||||
}
|
||||
|
||||
.cls-3 {
|
||||
fill: #33d6c6;
|
||||
}
|
||||
|
||||
.cls-4 {
|
||||
fill: #a970ff;
|
||||
}
|
||||
|
||||
.cls-5 {
|
||||
fill: url(#linear-gradient);
|
||||
}
|
||||
</style>
|
||||
<linearGradient id="linear-gradient" x1="312.56" y1="897.19" x2="312.56" y2="2" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#a970ff"></stop>
|
||||
<stop offset="1" stop-color="#6366f1"></stop>
|
||||
</linearGradient>
|
||||
<linearGradient id="linear-gradient-2" x1="711.44" y1="126.81" x2="711.44" y2="1022" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#6366f1"></stop>
|
||||
<stop offset="1" stop-color="#33d6c6"></stop>
|
||||
</linearGradient>
|
||||
</defs><path class="cls-5" d="m512,2c-53.6,0-98.47,38.16-108.84,88.73-75.07,19.29-143.32,58.26-198.74,113.68-55.42,55.42-94.39,123.68-113.68,198.74-50.58,10.38-88.73,55.24-88.73,108.84s38.16,98.47,88.73,108.84c13.62,53.14,37.03,102.64,69.77,147.47,37.14,50.86,85.83,93.86,140.81,124.33,5.54,3.08,11.56,4.54,17.48,4.54,12.7,0,25.02-6.72,31.61-18.61,9.68-17.44,3.38-39.42-14.07-49.09-85.32-47.32-147.22-125.71-173.38-218.21,36.3-18.29,61.27-55.92,61.27-99.28s-24.94-80.97-61.23-99.27c34.22-119.96,129.79-215.52,249.74-249.74,18.3,36.29,55.92,61.23,99.27,61.23,61.27,0,111.11-49.84,111.11-111.11S573.27,2,512,2ZM113.11,473.11c21.44,0,38.89,17.44,38.89,38.89,0,20.1-15.32,36.69-34.91,38.69h-.01c-.18.02-.36.03-.53.04-1.13.1-2.28.16-3.43.16-21.44,0-38.89-17.44-38.89-38.89s17.44-38.89,38.89-38.89Zm398.89-321.11c-21.44,0-38.89-17.44-38.89-38.89s17.44-38.89,38.89-38.89,38.89,17.44,38.89,38.89-17.44,38.89-38.89,38.89Z"></path><path class="cls-1" d="m933.27,403.16c-13.62-53.14-37.03-102.64-69.77-147.47-37.14-50.86-85.83-93.86-140.81-124.33-17.44-9.68-39.42-3.38-49.09,14.07-9.68,17.44-3.38,39.42,14.07,49.09,85.32,47.32,147.22,125.71,173.38,218.21-36.3,18.29-61.27,55.92-61.27,99.28s24.94,80.97,61.23,99.27c-34.22,119.96-129.79,215.52-249.74,249.74-18.3-36.29-55.92-61.23-99.27-61.23-61.27,0-111.11,49.84-111.11,111.11s49.84,111.11,111.11,111.11c53.6,0,98.47-38.16,108.84-88.73,75.07-19.29,143.32-58.26,198.74-113.68,55.42-55.42,94.39-123.68,113.68-198.74,50.58-10.38,88.73-55.24,88.73-108.84s-38.16-98.47-88.73-108.84Zm-421.27,546.62c-21.44,0-38.89-17.44-38.89-38.89s17.44-38.89,38.89-38.89,38.89,17.44,38.89,38.89-17.44,38.89-38.89,38.89Zm398.89-398.89c-21.44,0-38.89-17.44-38.89-38.89,0-20.03,15.22-36.58,34.72-38.67.38-.04.77-.08,1.16-.11h.02c.99-.08,1.99-.11,2.99-.11,21.44,0,38.89,17.44,38.89,38.89s-17.44,38.89-38.89,38.89Z"></path><g>
|
||||
<path class="cls-4" d="m735.02,517.56c-153.82,30.87-186.59,63.64-217.46,217.46-1.22,6.08-9.9,6.08-11.12,0-30.87-153.82-63.64-186.59-217.46-217.46-6.08-1.22-6.08-9.9,0-11.12,153.82-30.87,186.59-63.64,217.46-217.46,1.22-6.08,9.9-6.08,11.12,0,30.87,153.82,63.64,186.59,217.46,217.46,6.08,1.22,6.08,9.9,0,11.12Z"></path>
|
||||
<path class="cls-2" d="m434.06,665.85c-51.08,10.25-61.96,21.13-72.22,72.22-.41,2.02-3.29,2.02-3.69,0-10.25-51.08-21.13-61.96-72.22-72.22-2.02-.41-2.02-3.29,0-3.69,51.08-10.25,61.96-21.13,72.22-72.22.41-2.02,3.29-2.02,3.69,0,10.25,51.08,21.13,61.96,72.22,72.22,2.02.41,2.02,3.29,0,3.69Z"></path>
|
||||
<path class="cls-3" d="m738.07,361.84c-51.08,10.25-61.96,21.13-72.22,72.22-.41,2.02-3.29,2.02-3.69,0-10.25-51.08-21.13-61.96-72.22-72.22-2.02-.41-2.02-3.29,0-3.69,51.08-10.25,61.96-21.13,72.22-72.22.41-2.02,3.29-2.02,3.69,0,10.25,51.08,21.13,61.96,72.22,72.22,2.02.41,2.02,3.29,0,3.69Z"></path>
|
||||
</g></svg>
|
||||
|
After Width: | Height: | Size: 3.6 KiB |
@@ -2,6 +2,12 @@
|
||||
|
||||
declare module "*.vue" {
|
||||
import type { DefineComponent } from "vue";
|
||||
const component: DefineComponent<{}, {}, any>;
|
||||
// `Record<string, never>` instead of `{}` — same intent (no defined props
|
||||
// to constrain the SFC default-export typing) but doesn't trip
|
||||
// typescript-eslint's no-empty-object-type rule.
|
||||
const component: DefineComponent<Record<string, never>, Record<string, never>, unknown>;
|
||||
export default component;
|
||||
}
|
||||
|
||||
// Side-effect CSS imports (cssInjectedByJsPlugin handles runtime injection).
|
||||
declare module "*.css";
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
// Single ComfyUI About-panel badge for the extension. Other extensions
|
||||
// (rgthree-comfy, EasyUse, ComfyUI-Manager, …) follow the convention
|
||||
// "<extension name> v<version>" with monochrome icons that pick up the
|
||||
// panel's text color. We mirror that so the row blends with surrounding
|
||||
// badges instead of standing out as a colored sticker.
|
||||
//
|
||||
// The icon slot accepts a CSS class name; PrimeIcons (`pi pi-github`)
|
||||
// work out of the box but here we register a custom rule that uses our
|
||||
// favicon SVG as a CSS mask — `background-color: currentColor` then
|
||||
// fills it with whatever color the panel applies to the icon, so the
|
||||
// shape stays branded but the tone matches surrounding badges.
|
||||
//
|
||||
// Docs: https://docs.comfy.org/custom-nodes/js/javascript_about_panel_badges
|
||||
|
||||
// `?raw` returns the file contents as a string — Vite inlines into the
|
||||
// JS chunk so we don't depend on a runtime asset URL. Stays self-contained.
|
||||
import wpLogoSvg from "../components/shared/wp-logo.svg?raw";
|
||||
import pkg from "../../package.json" with { type: "json" };
|
||||
|
||||
export interface AboutPageBadge {
|
||||
label: string;
|
||||
url: string;
|
||||
/** CSS class — PrimeIcons or our own. */
|
||||
icon: string;
|
||||
}
|
||||
|
||||
const REPO = "https://github.com/DumiFlex/ComfyUI-WildcardPipeline";
|
||||
const ICON_CLASS = "wp-about-badge-icon";
|
||||
let styleInjected = false;
|
||||
|
||||
/**
|
||||
* Inject the icon-class rule once. Idempotent — safe to call from main
|
||||
* boot even though we only register a single badge today.
|
||||
*/
|
||||
function ensureIconStyle(): void {
|
||||
if (styleInjected || typeof document === "undefined") return;
|
||||
styleInjected = true;
|
||||
const dataUri = `url("data:image/svg+xml;utf8,${encodeURIComponent(wpLogoSvg)}")`;
|
||||
const style = document.createElement("style");
|
||||
style.id = "wp-about-badge-style";
|
||||
// mask-image renders the SVG silhouette only; background-color fills
|
||||
// it with currentColor so the icon picks up the panel's badge text
|
||||
// color and matches the surrounding monochrome icons (pi pi-github,
|
||||
// pi pi-discord, etc.). 14px square matches PrimeIcons sizing on the
|
||||
// About panel badges. -webkit-* prefixed properties cover Safari /
|
||||
// older WebKit which haven't standardized the unprefixed names yet.
|
||||
style.textContent = `
|
||||
.${ICON_CLASS} {
|
||||
display: inline-block;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background-color: currentColor;
|
||||
mask-image: ${dataUri};
|
||||
-webkit-mask-image: ${dataUri};
|
||||
mask-size: contain;
|
||||
-webkit-mask-size: contain;
|
||||
mask-repeat: no-repeat;
|
||||
-webkit-mask-repeat: no-repeat;
|
||||
mask-position: center;
|
||||
-webkit-mask-position: center;
|
||||
vertical-align: -2px;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
ensureIconStyle();
|
||||
|
||||
export const ABOUT_BADGES: AboutPageBadge[] = [
|
||||
{
|
||||
label: `Wildcard Pipeline v${pkg.version}`,
|
||||
url: REPO,
|
||||
icon: ICON_CLASS,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { scanConflicts } from "./conflicts";
|
||||
import type { ContextWidgetValue } from "../widgets/_shared";
|
||||
|
||||
const mod = (id: string, vars: string[]): ContextWidgetValue["modules"][number] => ({
|
||||
id, type: "fixed_values", enabled: true, meta: { name: "" },
|
||||
entries: vars.map((v) => ({ variable_name: v, value: "x" })),
|
||||
});
|
||||
|
||||
describe("scanConflicts", () => {
|
||||
it("flags shadows_upstream (info) when a module writes a var present upstream", () => {
|
||||
const value: ContextWidgetValue = { version: 1, modules: [mod("m1", ["style"])] };
|
||||
const out = scanConflicts(value, ["style"]);
|
||||
expect(out).toEqual([{ moduleId: "m1", variable: "style", type: "shadows_upstream", severity: "info" }]);
|
||||
});
|
||||
|
||||
it("flags duplicate_variable (warning) when two modules in same node both write the same var", () => {
|
||||
const value: ContextWidgetValue = { version: 1, modules: [mod("m1", ["style"]), mod("m2", ["style"])] };
|
||||
const out = scanConflicts(value, []);
|
||||
expect(out).toEqual([{ moduleId: "m2", variable: "style", type: "duplicate_variable", severity: "warning" }]);
|
||||
});
|
||||
|
||||
it("upstream shadow wins over in-node duplicate (mutually exclusive)", () => {
|
||||
const value: ContextWidgetValue = { version: 1, modules: [mod("m1", ["style"]), mod("m2", ["style"])] };
|
||||
const out = scanConflicts(value, ["style"]);
|
||||
expect(out.every((c) => c.type === "shadows_upstream")).toBe(true);
|
||||
expect(out).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("does not flag unrelated names — `sty` does not shadow `style`", () => {
|
||||
const value: ContextWidgetValue = { version: 1, modules: [mod("m1", ["sty"])] };
|
||||
expect(scanConflicts(value, ["style"])).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { ContextWidgetValue } from "../widgets/_shared";
|
||||
|
||||
// `shadows_upstream` — informational: this module overrides a value that
|
||||
// already exists upstream. Last-write-wins is the *intended* runtime
|
||||
// behavior, so this is a low-key hint, not an error.
|
||||
// `duplicate_variable` — warning: two enabled modules in the SAME node both
|
||||
// write the same name. Almost always a bug in this node's local config.
|
||||
// `missing_template_variable` — warning: assembler template references a
|
||||
// variable nothing upstream provides; it'll render literally as `$name`.
|
||||
export type ConflictType = "shadows_upstream" | "duplicate_variable" | "missing_template_variable";
|
||||
export type Severity = "info" | "warning" | "error";
|
||||
export interface Conflict {
|
||||
moduleId: string;
|
||||
variable: string;
|
||||
type: ConflictType;
|
||||
severity: Severity;
|
||||
}
|
||||
|
||||
export function scanConflicts(value: ContextWidgetValue, upstreamVars: string[]): Conflict[] {
|
||||
const upstream = new Set(upstreamVars);
|
||||
const written = new Set<string>();
|
||||
const out: Conflict[] = [];
|
||||
for (const m of value.modules) {
|
||||
if (!m.enabled) continue;
|
||||
for (const e of m.entries) {
|
||||
const name = e.variable_name.replace(/^\$/, "").trim();
|
||||
if (!name) continue;
|
||||
if (upstream.has(name)) {
|
||||
// Intended override — surface it but don't scream.
|
||||
out.push({ moduleId: m.id, variable: name, type: "shadows_upstream", severity: "info" });
|
||||
} else if (written.has(name)) {
|
||||
out.push({ moduleId: m.id, variable: name, type: "duplicate_variable", severity: "warning" });
|
||||
} else {
|
||||
written.add(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const TEMPLATE_VAR = /(?<!\$)\$([A-Za-z_][A-Za-z0-9_]*)/g;
|
||||
|
||||
export function scanTemplateConflicts(template: string, knownVars: string[]): Conflict[] {
|
||||
const known = new Set(knownVars);
|
||||
const out: Conflict[] = [];
|
||||
for (const m of template.matchAll(TEMPLATE_VAR)) {
|
||||
const v = m[1];
|
||||
if (!known.has(v)) {
|
||||
out.push({ moduleId: "", variable: v, type: "missing_template_variable", severity: "warning" });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// Webfonts (Inter + JetBrains Mono, latin variable axis 100-900) injected
|
||||
// at extension load. We side-step `cssInjectedByJsPlugin`'s URL-inlining
|
||||
// behavior by wiring the @font-face rules in JS — `new URL(path,
|
||||
// import.meta.url)` keeps woff2 files as separate assets (NOT
|
||||
// base64-inlined into the JS chunk) and resolves the URL at runtime
|
||||
// relative to the loaded module's location, which ComfyUI serves at
|
||||
// `/extensions/wildcard-pipeline/assets/...`.
|
||||
//
|
||||
// Trade-off vs. fontsource @import:
|
||||
// - Single latin subset only — no cyrillic/greek/vietnamese coverage.
|
||||
// If a future module entry uses non-latin glyphs they'll fall back
|
||||
// to the system stack, which is acceptable for the dev tool surface.
|
||||
// - Variable axis covers every weight from 100..900 in one file each,
|
||||
// so we don't pay per-weight asset cost.
|
||||
|
||||
const interUrl = new URL("../assets/fonts/inter-latin.woff2", import.meta.url).href;
|
||||
const jetBrainsMonoUrl = new URL("../assets/fonts/jetbrains-mono-latin.woff2", import.meta.url).href;
|
||||
|
||||
let injected = false;
|
||||
|
||||
function ensureFontFaces(): void {
|
||||
if (injected || typeof document === "undefined") return;
|
||||
injected = true;
|
||||
const style = document.createElement("style");
|
||||
style.id = "wp-webfonts";
|
||||
// font-display: swap renders the system stack first then re-paints when
|
||||
// the woff2 lands — avoids invisible text during the 100–300ms fetch on
|
||||
// first load. font-weight `100 900` is the variable axis range.
|
||||
style.textContent = `
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: url(${interUrl}) format('woff2-variations'),
|
||||
url(${interUrl}) format('woff2');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono';
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: url(${jetBrainsMonoUrl}) format('woff2-variations'),
|
||||
url(${jetBrainsMonoUrl}) format('woff2');
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
ensureFontFaces();
|
||||
@@ -0,0 +1,14 @@
|
||||
// Vanilla-JS event registry shared between main.ts (publisher) and the Vue
|
||||
// widget chunks (subscribers). Lives in its own module so importing it from
|
||||
// main.ts doesn't drag Vue into the entry bundle.
|
||||
|
||||
const graphLoadListeners = new Set<() => void>();
|
||||
|
||||
export function onGraphLoaded(listener: () => void): () => void {
|
||||
graphLoadListeners.add(listener);
|
||||
return () => graphLoadListeners.delete(listener);
|
||||
}
|
||||
|
||||
export function notifyGraphLoaded(): void {
|
||||
for (const fn of graphLoadListeners) fn();
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
collectUpstreamVariables,
|
||||
findDownstreamAssemblers,
|
||||
type LiteGraphLike,
|
||||
type LiteNodeLike,
|
||||
} from "./graph";
|
||||
|
||||
function fakeContextNode(id: number, vars: string[], upstreamLink?: number): LiteNodeLike {
|
||||
return {
|
||||
id,
|
||||
type: "WP_Context",
|
||||
inputs: [{ name: "upstream", link: upstreamLink ?? null }],
|
||||
outputs: [{ name: "context", links: [], type: "PIPELINE_CONTEXT" }],
|
||||
widgets: [{
|
||||
name: "modules",
|
||||
value: JSON.stringify({
|
||||
version: 1,
|
||||
modules: [{
|
||||
id: "a",
|
||||
type: "fixed_values",
|
||||
enabled: true,
|
||||
meta: { name: "" },
|
||||
entries: vars.map((v) => ({ variable_name: v, value: "x" })),
|
||||
}],
|
||||
}),
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
describe("collectUpstreamVariables", () => {
|
||||
it("returns the upstream chain's writes only — never the starting node's own", () => {
|
||||
const a = fakeContextNode(1, ["style"]);
|
||||
const b = fakeContextNode(2, ["mood"], 100);
|
||||
const graph: LiteGraphLike = {
|
||||
_nodes: [a, b],
|
||||
links: { 100: { id: 100, origin_id: 1, origin_slot: 0, target_id: 2, target_slot: 0 } },
|
||||
getNodeById: (id) => ({ 1: a, 2: b } as Record<number, LiteNodeLike>)[id] ?? null,
|
||||
};
|
||||
// From b's POV: only "style" comes from upstream; b's own "mood" is not included.
|
||||
expect(collectUpstreamVariables(graph, b)).toEqual(["style"]);
|
||||
});
|
||||
|
||||
it("returns empty when no upstream link, regardless of own writes", () => {
|
||||
const a = fakeContextNode(1, ["style"]);
|
||||
const graph: LiteGraphLike = { _nodes: [a], links: {}, getNodeById: () => null };
|
||||
expect(collectUpstreamVariables(graph, a)).toEqual([]);
|
||||
});
|
||||
|
||||
it("walks via assembler's `context` input too — any PIPELINE_CONTEXT slot works", () => {
|
||||
const ctx = fakeContextNode(1, ["style", "subject"]);
|
||||
const asm: LiteNodeLike = {
|
||||
id: 2,
|
||||
type: "WP_PromptAssembler",
|
||||
// Note: input is named "context", not "upstream".
|
||||
inputs: [{ name: "context", link: 300 }],
|
||||
};
|
||||
const graph: LiteGraphLike = {
|
||||
_nodes: [ctx, asm],
|
||||
links: { 300: { id: 300, origin_id: 1, origin_slot: 0, target_id: 2, target_slot: 0 } },
|
||||
getNodeById: (id) => ({ 1: ctx, 2: asm } as Record<number, LiteNodeLike>)[id] ?? null,
|
||||
};
|
||||
expect(collectUpstreamVariables(graph, asm).sort()).toEqual(["style", "subject"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findDownstreamAssemblers", () => {
|
||||
it("returns assembler ids reachable via PIPELINE_CONTEXT outgoing links", () => {
|
||||
const ctx: LiteNodeLike = {
|
||||
id: 1,
|
||||
type: "WP_Context",
|
||||
outputs: [{ name: "context", links: [200], type: "PIPELINE_CONTEXT" }],
|
||||
};
|
||||
const asm: LiteNodeLike = { id: 2, type: "WP_PromptAssembler", inputs: [{ name: "context", link: 200 }] };
|
||||
const graph: LiteGraphLike = {
|
||||
_nodes: [ctx, asm],
|
||||
links: { 200: { id: 200, origin_id: 1, origin_slot: 0, target_id: 2, target_slot: 0 } },
|
||||
getNodeById: (id) => ({ 1: ctx, 2: asm } as Record<number, LiteNodeLike>)[id] ?? null,
|
||||
};
|
||||
expect(findDownstreamAssemblers(graph, ctx).map((n) => n.id)).toEqual([2]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Subgraph boundary traversal ────────────────────────────────────────
|
||||
// The chain crosses litegraph subgraph boundaries via two proxy nodes:
|
||||
// - SubgraphInputNode (id = -10) — boundary inside the subgraph that
|
||||
// proxies external inputs.
|
||||
// - SubgraphOutputNode (id = -20) — boundary inside the subgraph that
|
||||
// proxies external outputs.
|
||||
// We model both shapes here and assert the walk doesn't get stuck on either.
|
||||
|
||||
describe("collectUpstreamVariables across subgraph boundaries", () => {
|
||||
it("steps OUT of a subgraph to reach an upstream Context in the parent graph", () => {
|
||||
// Layout:
|
||||
// root: ctx(1) ──[100]──▶ subNode(10)
|
||||
// inside subNode.subgraph: -10 ──[200]──▶ asm(2)
|
||||
// subNode's "ctx" input maps by name to the inputNode's "ctx" slot.
|
||||
const ctx = fakeContextNode(1, ["style"]);
|
||||
ctx.outputs = [{ name: "context", links: [100], type: "PIPELINE_CONTEXT" }];
|
||||
|
||||
const innerAsm: LiteNodeLike = {
|
||||
id: 2,
|
||||
type: "WP_PromptAssembler",
|
||||
inputs: [{ name: "context", link: 200 }],
|
||||
};
|
||||
const subgraph: LiteGraphLike = {
|
||||
id: "sg-1",
|
||||
_nodes: [innerAsm],
|
||||
links: { 200: { id: 200, origin_id: -10, origin_slot: 0, target_id: 2, target_slot: 0 } },
|
||||
getNodeById: (id) => ({ 2: innerAsm } as Record<number, LiteNodeLike>)[id] ?? null,
|
||||
inputNode: { id: -10, slots: [{ name: "ctx", linkIds: [200] }] },
|
||||
outputNode: { id: -20, slots: [] },
|
||||
};
|
||||
innerAsm.graph = subgraph;
|
||||
|
||||
const subNode: LiteNodeLike = {
|
||||
id: 10,
|
||||
type: "Subgraph",
|
||||
inputs: [{ name: "ctx", link: 100 }],
|
||||
outputs: [{ name: "ctx_out", links: [], type: "PIPELINE_CONTEXT" }],
|
||||
subgraph,
|
||||
isSubgraphNode: () => true,
|
||||
};
|
||||
const root: LiteGraphLike = {
|
||||
_nodes: [ctx, subNode],
|
||||
links: { 100: { id: 100, origin_id: 1, origin_slot: 0, target_id: 10, target_slot: 0 } },
|
||||
getNodeById: (id) => ({ 1: ctx, 10: subNode } as Record<number, LiteNodeLike>)[id] ?? null,
|
||||
};
|
||||
ctx.graph = root;
|
||||
subNode.graph = root;
|
||||
|
||||
expect(collectUpstreamVariables(root, innerAsm)).toEqual(["style"]);
|
||||
});
|
||||
|
||||
it("steps INTO a subgraph when the upstream Context lives inside one", () => {
|
||||
// Layout:
|
||||
// inside subgraph: ctx(1) ──[200]──▶ -20 (output proxy, slot "ctx_out")
|
||||
// root: subNode(10) ──[300]──▶ asm(2)
|
||||
const innerCtx = fakeContextNode(1, ["style"]);
|
||||
innerCtx.outputs = [{ name: "context", links: [200], type: "PIPELINE_CONTEXT" }];
|
||||
|
||||
const subgraph: LiteGraphLike = {
|
||||
id: "sg-1",
|
||||
_nodes: [innerCtx],
|
||||
links: { 200: { id: 200, origin_id: 1, origin_slot: 0, target_id: -20, target_slot: 0 } },
|
||||
getNodeById: (id) => ({ 1: innerCtx } as Record<number, LiteNodeLike>)[id] ?? null,
|
||||
inputNode: { id: -10, slots: [] },
|
||||
outputNode: { id: -20, slots: [{ name: "ctx_out", linkIds: [200] }] },
|
||||
};
|
||||
innerCtx.graph = subgraph;
|
||||
|
||||
const subNode: LiteNodeLike = {
|
||||
id: 10,
|
||||
type: "Subgraph",
|
||||
inputs: [],
|
||||
outputs: [{ name: "ctx_out", links: [300], type: "PIPELINE_CONTEXT" }],
|
||||
subgraph,
|
||||
isSubgraphNode: () => true,
|
||||
};
|
||||
const asm: LiteNodeLike = {
|
||||
id: 2,
|
||||
type: "WP_PromptAssembler",
|
||||
inputs: [{ name: "context", link: 300 }],
|
||||
};
|
||||
const root: LiteGraphLike = {
|
||||
_nodes: [subNode, asm],
|
||||
links: { 300: { id: 300, origin_id: 10, origin_slot: 0, target_id: 2, target_slot: 0 } },
|
||||
getNodeById: (id) => ({ 10: subNode, 2: asm } as Record<number, LiteNodeLike>)[id] ?? null,
|
||||
};
|
||||
subNode.graph = root;
|
||||
asm.graph = root;
|
||||
|
||||
expect(collectUpstreamVariables(root, asm)).toEqual(["style"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,403 @@
|
||||
import { parseWidgetJson, type ContextWidgetValue } from "../widgets/_shared";
|
||||
|
||||
// ── Subgraph boundary primer ────────────────────────────────────────────
|
||||
// ComfyUI subgraphs are nested LGraph instances attached to a parent node
|
||||
// via `node.subgraph`. They have boundary proxy nodes:
|
||||
// - SubgraphInputNode (id = -10) — sits inside the subgraph; inner nodes
|
||||
// wire INPUTs from it. Each slot maps by name to the parent SubgraphNode's
|
||||
// external input slot.
|
||||
// - SubgraphOutputNode (id = -20) — sits inside the subgraph; inner nodes
|
||||
// wire OUTPUTs into it. Each slot maps by name to the parent SubgraphNode's
|
||||
// external output slot.
|
||||
// - The IO nodes are NOT in `subgraph._nodes` — only on `subgraph.inputNode`
|
||||
// / `subgraph.outputNode`. Inner links can still reference them by id.
|
||||
// To traverse the PIPELINE_CONTEXT chain across boundaries we transparently
|
||||
// "step in" through SubgraphOutputNode and "step out" through SubgraphInputNode.
|
||||
|
||||
const SUBGRAPH_INPUT_NODE_ID = -10;
|
||||
const SUBGRAPH_OUTPUT_NODE_ID = -20;
|
||||
const BOUNDARY_RECURSION_LIMIT = 32;
|
||||
|
||||
export interface LiteLink {
|
||||
id: number;
|
||||
origin_id: number;
|
||||
origin_slot: number;
|
||||
target_id: number;
|
||||
target_slot: number;
|
||||
}
|
||||
|
||||
export interface SubgraphIOSlot {
|
||||
name: string;
|
||||
type?: string;
|
||||
/** Inner-graph link ids touching this slot. */
|
||||
linkIds?: number[];
|
||||
}
|
||||
|
||||
export interface SubgraphIONode {
|
||||
id: number;
|
||||
slots: SubgraphIOSlot[];
|
||||
}
|
||||
|
||||
export interface LiteNodeLike {
|
||||
id: number;
|
||||
type: string;
|
||||
inputs?: { name: string; link: number | null; type?: string }[];
|
||||
outputs?: { name: string; links?: number[] | null; type?: string }[];
|
||||
widgets?: { name: string; value: unknown }[];
|
||||
/** Inner LGraph for SubgraphNodes. Mirrors `node.subgraph` from litegraph. */
|
||||
subgraph?: LiteGraphLike;
|
||||
/** Litegraph SubgraphNode marker. Also detected via `subgraph != null`. */
|
||||
isSubgraphNode?(): boolean;
|
||||
/** Containing LGraph — set by litegraph on every node that's been added. */
|
||||
graph?: LiteGraphLike;
|
||||
}
|
||||
|
||||
/**
|
||||
* Litegraph LGraph surface we touch. Both root and subgraph instances satisfy
|
||||
* this. `id` is undefined on the root graph and a UUID on subgraphs.
|
||||
*/
|
||||
export interface LiteGraphLike {
|
||||
id?: string;
|
||||
_nodes: LiteNodeLike[];
|
||||
/** In current litegraph this is a Proxy over a Map; bracket access works. */
|
||||
links: Record<number, LiteLink>;
|
||||
getNodeById(id: number): LiteNodeLike | null;
|
||||
/** Boundary IO nodes — only populated on subgraph LGraphs. */
|
||||
inputNode?: SubgraphIONode;
|
||||
outputNode?: SubgraphIONode;
|
||||
}
|
||||
|
||||
function widgetValue(node: LiteNodeLike, name: string): string {
|
||||
const w = node.widgets?.find((x) => x.name === name);
|
||||
return typeof w?.value === "string" ? w.value : "";
|
||||
}
|
||||
|
||||
function moduleWrites(node: LiteNodeLike): string[] {
|
||||
const v = parseWidgetJson<ContextWidgetValue>(widgetValue(node, "modules"), { version: 1, modules: [] });
|
||||
const out: string[] = [];
|
||||
for (const m of v.modules) {
|
||||
if (!m.enabled) continue;
|
||||
for (const e of m.entries) {
|
||||
const name = e.variable_name.replace(/^\$/, "").trim();
|
||||
if (name) out.push(name);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function isSubgraphNode(n: LiteNodeLike): boolean {
|
||||
if (typeof n.isSubgraphNode === "function") {
|
||||
try { return n.isSubgraphNode() === true; } catch { /* fall through */ }
|
||||
}
|
||||
return n.subgraph != null;
|
||||
}
|
||||
|
||||
/** Containing LGraph — falls back to passed graph when litegraph hasn't set it. */
|
||||
function graphOf(node: LiteNodeLike, fallback: LiteGraphLike): LiteGraphLike {
|
||||
return node.graph ?? fallback;
|
||||
}
|
||||
|
||||
type SubgraphParent = { node: LiteNodeLike; graph: LiteGraphLike };
|
||||
|
||||
/**
|
||||
* Walk root → leaves once and record each subgraph's parent SubgraphNode +
|
||||
* the LGraph that contains it. Used to step OUT through SubgraphInputNode
|
||||
* boundaries: given a subgraph id, we know which outer node + graph to
|
||||
* resume traversal from.
|
||||
*/
|
||||
function buildSubgraphParents(root: LiteGraphLike): Map<string, SubgraphParent> {
|
||||
const out = new Map<string, SubgraphParent>();
|
||||
function descend(g: LiteGraphLike) {
|
||||
for (const n of g._nodes ?? []) {
|
||||
const sg = n.subgraph;
|
||||
if (sg && sg.id) {
|
||||
out.set(sg.id, { node: n, graph: g });
|
||||
descend(sg);
|
||||
}
|
||||
}
|
||||
}
|
||||
descend(root);
|
||||
return out;
|
||||
}
|
||||
|
||||
interface BoundaryStep {
|
||||
graph: LiteGraphLike;
|
||||
node: LiteNodeLike;
|
||||
/** Output slot index on `node` we just arrived through. */
|
||||
originSlot: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Step INTO a SubgraphNode: given the outer SubgraphNode and the output slot
|
||||
* we're walking against, find the inner producer feeding that output via the
|
||||
* SubgraphOutputNode slot of the same name.
|
||||
*/
|
||||
function stepIntoSubgraph(subNode: LiteNodeLike, originSlot: number): BoundaryStep | null {
|
||||
const out = subNode.outputs?.[originSlot];
|
||||
const subgraph = subNode.subgraph;
|
||||
const outputNode = subgraph?.outputNode;
|
||||
if (!out || !subgraph || !outputNode) return null;
|
||||
// Slot names match between the outer SubgraphNode and the inner
|
||||
// SubgraphOutputNode — that's how litegraph routes the boundary.
|
||||
const slot = outputNode.slots.find((s) => s.name === out.name);
|
||||
if (!slot || !slot.linkIds?.length) return null;
|
||||
// Take the first wired inner link. PIPELINE_CONTEXT is a single-source
|
||||
// chain so multi-write at this boundary would be a graph error anyway.
|
||||
const link = subgraph.links[slot.linkIds[0]];
|
||||
if (!link) return null;
|
||||
const inner = subgraph.getNodeById(link.origin_id);
|
||||
if (!inner) return null;
|
||||
return { graph: subgraph, node: inner, originSlot: link.origin_slot };
|
||||
}
|
||||
|
||||
/**
|
||||
* Step OUT through a SubgraphInputNode: given the inner LGraph and the
|
||||
* input-slot index on its inputNode that the link arrived from, resolve the
|
||||
* matching named input on the parent SubgraphNode and follow ITS link in
|
||||
* the parent graph.
|
||||
*/
|
||||
function stepOutOfSubgraph(
|
||||
innerGraph: LiteGraphLike,
|
||||
inputNodeSlot: number,
|
||||
parents: Map<string, SubgraphParent>,
|
||||
): BoundaryStep | null {
|
||||
if (!innerGraph.id) return null;
|
||||
const parent = parents.get(innerGraph.id);
|
||||
const inputNode = innerGraph.inputNode;
|
||||
if (!parent || !inputNode) return null;
|
||||
const innerSlot = inputNode.slots[inputNodeSlot];
|
||||
if (!innerSlot) return null;
|
||||
// Find the parent SubgraphNode's input slot with the same name.
|
||||
const outerSlot = parent.node.inputs?.find((s) => s.name === innerSlot.name);
|
||||
if (!outerSlot || outerSlot.link == null) return null;
|
||||
const outerLink = parent.graph.links[outerSlot.link];
|
||||
if (!outerLink) return null;
|
||||
const outer = parent.graph.getNodeById(outerLink.origin_id);
|
||||
if (!outer) return null;
|
||||
return { graph: parent.graph, node: outer, originSlot: outerLink.origin_slot };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a (graph, node, originSlot) tuple through any number of stacked
|
||||
* subgraph boundaries until it points at a real producer (not an IO proxy
|
||||
* and not a SubgraphNode wrapper). Returns null if any hop fails.
|
||||
*/
|
||||
function resolveThroughBoundaries(
|
||||
start: BoundaryStep,
|
||||
parents: Map<string, SubgraphParent>,
|
||||
): BoundaryStep | null {
|
||||
let cur: BoundaryStep | null = start;
|
||||
for (let i = 0; cur && i < BOUNDARY_RECURSION_LIMIT; i++) {
|
||||
if (cur.node.id === SUBGRAPH_INPUT_NODE_ID) {
|
||||
cur = stepOutOfSubgraph(cur.graph, cur.originSlot, parents);
|
||||
continue;
|
||||
}
|
||||
if (isSubgraphNode(cur.node)) {
|
||||
cur = stepIntoSubgraph(cur.node, cur.originSlot);
|
||||
continue;
|
||||
}
|
||||
return cur;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the upstream PIPELINE_CONTEXT producer for `node`, transparently
|
||||
* stepping through subgraph boundaries. Returns the node WITH its containing
|
||||
* graph since the chain can leave the starting subgraph.
|
||||
*/
|
||||
function pipelineUpstreamOf(
|
||||
node: LiteNodeLike,
|
||||
graph: LiteGraphLike,
|
||||
parents: Map<string, SubgraphParent>,
|
||||
): { graph: LiteGraphLike; node: LiteNodeLike } | null {
|
||||
for (const slot of node.inputs ?? []) {
|
||||
if (slot.link == null) continue;
|
||||
const link = graph.links[slot.link];
|
||||
if (!link) continue;
|
||||
|
||||
let step: BoundaryStep | null;
|
||||
if (link.origin_id === SUBGRAPH_INPUT_NODE_ID) {
|
||||
// Boundary at this very link — step OUT before we can even read origin.
|
||||
step = stepOutOfSubgraph(graph, link.origin_slot, parents);
|
||||
} else {
|
||||
const origin = graph.getNodeById(link.origin_id);
|
||||
if (!origin) continue;
|
||||
step = { graph, node: origin, originSlot: link.origin_slot };
|
||||
}
|
||||
|
||||
const resolved = step ? resolveThroughBoundaries(step, parents) : null;
|
||||
if (!resolved) continue;
|
||||
|
||||
// Confirm we landed on a PIPELINE_CONTEXT producer slot.
|
||||
const out = resolved.node.outputs?.[resolved.originSlot];
|
||||
if (out?.type === "PIPELINE_CONTEXT") return { graph: resolved.graph, node: resolved.node };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk upstream from `node` collecting variable names written by every
|
||||
* upstream WP_Context. Does NOT include the starting node's own writes — the
|
||||
* caller (a Context widget rendering its own conflict UI) wants to know what
|
||||
* is coming IN from upstream so it can compare against its own writes.
|
||||
*
|
||||
* Crosses subgraph boundaries transparently: if the upstream Context lives
|
||||
* outside a subgraph the starting node is in, we step OUT through the
|
||||
* SubgraphInputNode proxy; if the upstream chain crosses INTO a subgraph,
|
||||
* we step IN through the SubgraphOutputNode proxy.
|
||||
*
|
||||
* `rootGraph` should be `app.graph` (top-level). The starting node may live
|
||||
* anywhere in the nested graph tree.
|
||||
*/
|
||||
export function collectUpstreamVariables(rootGraph: LiteGraphLike, node: LiteNodeLike): string[] {
|
||||
const parents = buildSubgraphParents(rootGraph);
|
||||
const seen = new Set<string>([locator(graphOf(node, rootGraph), node)]);
|
||||
const vars = new Set<string>();
|
||||
let cur = pipelineUpstreamOf(node, graphOf(node, rootGraph), parents);
|
||||
while (cur && !seen.has(locator(cur.graph, cur.node))) {
|
||||
seen.add(locator(cur.graph, cur.node));
|
||||
if (cur.node.type === "WP_Context") moduleWrites(cur.node).forEach((v) => vars.add(v));
|
||||
cur = pipelineUpstreamOf(cur.node, cur.graph, parents);
|
||||
}
|
||||
return Array.from(vars);
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk upstream collecting (variable → value) pairs. Last-write-wins matches
|
||||
* runtime semantics: chain order is upstream-first → downstream-last; we
|
||||
* reverse the walk so downstream values override upstream ones in the map.
|
||||
*
|
||||
* Crosses subgraph boundaries identically to `collectUpstreamVariables`.
|
||||
*/
|
||||
export function collectUpstreamValues(rootGraph: LiteGraphLike, node: LiteNodeLike): Record<string, string> {
|
||||
const parents = buildSubgraphParents(rootGraph);
|
||||
const seen = new Set<string>([locator(graphOf(node, rootGraph), node)]);
|
||||
const chain: LiteNodeLike[] = [];
|
||||
let cur = pipelineUpstreamOf(node, graphOf(node, rootGraph), parents);
|
||||
while (cur && !seen.has(locator(cur.graph, cur.node))) {
|
||||
seen.add(locator(cur.graph, cur.node));
|
||||
chain.push(cur.node);
|
||||
cur = pipelineUpstreamOf(cur.node, cur.graph, parents);
|
||||
}
|
||||
const out: Record<string, string> = {};
|
||||
for (let i = chain.length - 1; i >= 0; i--) {
|
||||
const n = chain[i];
|
||||
if (n.type !== "WP_Context") continue;
|
||||
const v = parseWidgetJson<ContextWidgetValue>(widgetValue(n, "modules"), { version: 1, modules: [] });
|
||||
for (const m of v.modules) {
|
||||
if (!m.enabled) continue;
|
||||
for (const e of m.entries) {
|
||||
const name = e.variable_name.replace(/^\$/, "").trim();
|
||||
if (!name) continue;
|
||||
out[name] = e.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Locator — node ids are unique only within a single LGraph; nested
|
||||
* subgraphs reuse the same id space. Pair `(graph.id, node.id)` to dedupe
|
||||
* across the whole nested tree.
|
||||
*/
|
||||
function locator(graph: LiteGraphLike, node: LiteNodeLike): string {
|
||||
return `${graph.id ?? "root"}:${node.id}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* BFS downstream from `node` along PIPELINE_CONTEXT edges, returning every
|
||||
* WP_PromptAssembler in reach. Walks through subgraph boundaries: a
|
||||
* SubgraphNode in the chain is unwrapped by following its inputNode slot
|
||||
* inside; an outgoing link to the SubgraphOutputNode (target_id === -20)
|
||||
* resumes from the parent SubgraphNode's matching named output in the
|
||||
* parent graph.
|
||||
*/
|
||||
export function findDownstreamAssemblers(rootGraph: LiteGraphLike, node: LiteNodeLike): LiteNodeLike[] {
|
||||
const parents = buildSubgraphParents(rootGraph);
|
||||
const out: LiteNodeLike[] = [];
|
||||
const seen = new Set<string>([locator(graphOf(node, rootGraph), node)]);
|
||||
const queue: { graph: LiteGraphLike; node: LiteNodeLike }[] = [
|
||||
{ graph: graphOf(node, rootGraph), node },
|
||||
];
|
||||
|
||||
while (queue.length) {
|
||||
const cur = queue.shift();
|
||||
if (!cur) break;
|
||||
for (const o of cur.node.outputs ?? []) {
|
||||
if (o.type !== "PIPELINE_CONTEXT") continue;
|
||||
for (const linkId of o.links ?? []) {
|
||||
const link = cur.graph.links[linkId];
|
||||
if (!link) continue;
|
||||
const stepped = downstreamStep(cur.graph, link, parents);
|
||||
if (!stepped) continue;
|
||||
const key = locator(stepped.graph, stepped.node);
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
if (stepped.node.type === "WP_PromptAssembler") out.push(stepped.node);
|
||||
else queue.push({ graph: stepped.graph, node: stepped.node });
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a downstream link target through any subgraph boundaries:
|
||||
* - target_id === -20: link enters the SubgraphOutputNode → resume from
|
||||
* the parent SubgraphNode's matching output in the parent graph.
|
||||
* - target is a SubgraphNode: link enters via SubgraphInputNode proxy →
|
||||
* fan out to each inner consumer (we pick the first; downstream walk
|
||||
* recurses anyway via the BFS queue).
|
||||
*/
|
||||
function downstreamStep(
|
||||
graph: LiteGraphLike,
|
||||
link: LiteLink,
|
||||
parents: Map<string, SubgraphParent>,
|
||||
): { graph: LiteGraphLike; node: LiteNodeLike } | null {
|
||||
if (link.target_id === SUBGRAPH_OUTPUT_NODE_ID) {
|
||||
// Find which output of the parent SubgraphNode this proxies, then resume.
|
||||
const innerSlot = graph.outputNode?.slots[link.target_slot];
|
||||
if (!innerSlot || !graph.id) return null;
|
||||
const parent = parents.get(graph.id);
|
||||
if (!parent) return null;
|
||||
return { graph: parent.graph, node: parent.node };
|
||||
}
|
||||
const target = graph.getNodeById(link.target_id);
|
||||
if (!target) return null;
|
||||
if (isSubgraphNode(target)) {
|
||||
// Step into subgraph — find SubgraphInputNode slot matching the input
|
||||
// slot's name and follow its first inner link.
|
||||
const targetInputName = target.inputs?.[link.target_slot]?.name;
|
||||
const inputNode = target.subgraph?.inputNode;
|
||||
if (!targetInputName || !target.subgraph || !inputNode) return null;
|
||||
const slot = inputNode.slots.find((s) => s.name === targetInputName);
|
||||
if (!slot || !slot.linkIds?.length) return null;
|
||||
const innerLink = target.subgraph.links[slot.linkIds[0]];
|
||||
if (!innerLink) return null;
|
||||
const inner = target.subgraph.getNodeById(innerLink.target_id);
|
||||
if (!inner) return null;
|
||||
return { graph: target.subgraph, node: inner };
|
||||
}
|
||||
return { graph, node: target };
|
||||
}
|
||||
|
||||
/**
|
||||
* Yield every node in `rootGraph` and any nested subgraph, paired with the
|
||||
* LGraph that contains it. Use for graph-wide scans (e.g. pre-run
|
||||
* validation finding every WP_PromptAssembler regardless of nesting depth).
|
||||
*/
|
||||
export function* walkAllNodes(
|
||||
rootGraph: LiteGraphLike,
|
||||
): Generator<{ graph: LiteGraphLike; node: LiteNodeLike }> {
|
||||
const stack: LiteGraphLike[] = [rootGraph];
|
||||
while (stack.length) {
|
||||
const g = stack.pop();
|
||||
if (!g) break;
|
||||
for (const n of g._nodes ?? []) {
|
||||
yield { graph: g, node: n };
|
||||
if (n.subgraph) stack.push(n.subgraph);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { ref, type Ref, onScopeDispose } from "vue";
|
||||
import { onGraphLoaded } from "./graph-events";
|
||||
|
||||
export { notifyGraphLoaded } from "./graph-events";
|
||||
|
||||
/**
|
||||
* Re-evaluate `compute` whenever the host node's input/output connections
|
||||
* change. Returns a reactive ref that mirrors the latest computation result.
|
||||
*
|
||||
* LiteGraph dispatches `onConnectionsChange(type, slot, connected, link, slot)`
|
||||
* on each affected node when a wire is added/removed. We chain onto whatever
|
||||
* was already there so other extensions keep working. We also re-run on a
|
||||
* coarse polling interval to catch upstream-chain mutations that don't fire
|
||||
* the local node's callback (e.g. a wire added two nodes upstream).
|
||||
*
|
||||
* Workflow-load races: the snapshot computed at first mount sees upstream
|
||||
* widgets in their pre-restore state. We subscribe to a global "graph just
|
||||
* fully loaded" signal (fired from main.ts via afterConfigureGraph) so every
|
||||
* reactive widget re-syncs the moment ComfyUI finishes restoring values —
|
||||
* no flash of stale state.
|
||||
*/
|
||||
export interface ConnectableNode {
|
||||
onConnectionsChange?: (...args: unknown[]) => void;
|
||||
}
|
||||
|
||||
export function reactiveFromGraph<T>(
|
||||
node: ConnectableNode,
|
||||
compute: () => T,
|
||||
equals: (a: T, b: T) => boolean = Object.is,
|
||||
pollMs = 400,
|
||||
): Ref<T> {
|
||||
const state = ref(compute()) as Ref<T>;
|
||||
|
||||
function refresh() {
|
||||
const next = compute();
|
||||
if (!equals(state.value, next)) state.value = next;
|
||||
}
|
||||
|
||||
const orig = node.onConnectionsChange;
|
||||
node.onConnectionsChange = function (...args: unknown[]) {
|
||||
orig?.apply(this, args);
|
||||
refresh();
|
||||
};
|
||||
|
||||
// Polling fallback for upstream-chain edits that don't fire on this node.
|
||||
const interval = window.setInterval(refresh, pollMs);
|
||||
|
||||
// Re-sync on workflow load. Several ticks because ComfyUI restores widget
|
||||
// values asynchronously through cascading callbacks; a single refresh can
|
||||
// still catch us mid-cascade. requestAnimationFrame chain settles within
|
||||
// two frames in practice — cheap insurance against a late restore.
|
||||
const unsubscribe = onGraphLoaded(() => {
|
||||
refresh();
|
||||
requestAnimationFrame(() => {
|
||||
refresh();
|
||||
requestAnimationFrame(refresh);
|
||||
});
|
||||
});
|
||||
|
||||
onScopeDispose(() => {
|
||||
window.clearInterval(interval);
|
||||
unsubscribe();
|
||||
node.onConnectionsChange = orig;
|
||||
});
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
/** Cheap structural equality for string[] (order matters). */
|
||||
export function stringArrayEqual(a: string[], b: string[]): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
// Settings registered into ComfyUI's settings panel via
|
||||
// app.registerExtension({ settings: [...] }) — see
|
||||
// https://docs.comfy.org/custom-nodes/js/javascript_settings
|
||||
//
|
||||
// Two accessibility toggles, both defaulting to "auto" so they respect
|
||||
// OS-level prefers-reduced-motion / prefers-contrast media queries until
|
||||
// the user overrides them. Each setting writes a class onto <body>; CSS
|
||||
// targets `.wp-*` descendants of that class to apply or strip the
|
||||
// matching style.
|
||||
|
||||
// Side-effect import — owns the a11y CSS chunk so cssInjectedByJsPlugin
|
||||
// can attach the runtime injection to a JS module. A bare CSS import
|
||||
// from main.ts emits a chunk with no JS owner and the plugin warns.
|
||||
import "../components/shared/a11y.css";
|
||||
|
||||
export type A11yMode = "auto" | "on" | "off";
|
||||
|
||||
export interface ComfySetting {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "boolean" | "text" | "number" | "slider" | "combo" | "color" | "image" | "hidden";
|
||||
defaultValue: unknown;
|
||||
tooltip?: string;
|
||||
options?: Array<{ text: string; value: string }>;
|
||||
category?: string[];
|
||||
onChange?: (newVal: unknown, oldVal: unknown) => void;
|
||||
}
|
||||
|
||||
const SETTING_ID_REDUCE_MOTION = "wildcardPipeline.a11y.reduceMotion";
|
||||
const SETTING_ID_HIGH_CONTRAST = "wildcardPipeline.a11y.contrast";
|
||||
|
||||
const MOTION_OPTIONS = [
|
||||
{ text: "Match system (prefers-reduced-motion)", value: "auto" },
|
||||
{ text: "Always reduce", value: "on" },
|
||||
{ text: "Always allow", value: "off" },
|
||||
];
|
||||
|
||||
const CONTRAST_OPTIONS = [
|
||||
{ text: "Match system (prefers-contrast)", value: "auto" },
|
||||
{ text: "High contrast", value: "on" },
|
||||
{ text: "Standard", value: "off" },
|
||||
];
|
||||
|
||||
interface ExtensionManager {
|
||||
setting?: { get(id: string): unknown };
|
||||
}
|
||||
interface AppLike {
|
||||
extensionManager?: ExtensionManager;
|
||||
}
|
||||
|
||||
// Module-state holds the current values directly. Updated synchronously
|
||||
// by onChange callbacks so the marker classes flip without waiting for
|
||||
// extensionManager.setting.get() to reflect the new value (it can lag
|
||||
// the onChange fire by a tick, which made settings only "stick" after a
|
||||
// hard refresh in the live UI).
|
||||
const state: { reduceMotion: A11yMode; contrast: A11yMode } = {
|
||||
reduceMotion: "auto",
|
||||
contrast: "auto",
|
||||
};
|
||||
|
||||
function asMode(v: unknown, fallback: A11yMode): A11yMode {
|
||||
return v === "on" || v === "off" || v === "auto" ? v : fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply current state + matchMedia to the body marker classes. Pure read
|
||||
* of the in-memory `state` map — no async layer. CSS keys off the markers:
|
||||
* .wp-a11y-no-motion .wp-* { transition-duration: 0 !important; }
|
||||
* .wp-a11y-high-contrast { --wp-border: ...; }
|
||||
*/
|
||||
function syncMarkers(): void {
|
||||
const reduceMotion = state.reduceMotion === "on"
|
||||
|| (state.reduceMotion === "auto" && window.matchMedia?.("(prefers-reduced-motion: reduce)").matches);
|
||||
const highContrast = state.contrast === "on"
|
||||
|| (state.contrast === "auto" && window.matchMedia?.("(prefers-contrast: more)").matches);
|
||||
|
||||
document.body.classList.toggle("wp-a11y-no-motion", !!reduceMotion);
|
||||
document.body.classList.toggle("wp-a11y-high-contrast", !!highContrast);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read both settings from extensionManager (used for initial sync at boot)
|
||||
* and re-apply markers. After this, `onChange` callbacks own state updates
|
||||
* directly via their newVal arg.
|
||||
*/
|
||||
export function applyA11yClasses(app: AppLike): void {
|
||||
state.reduceMotion = asMode(app.extensionManager?.setting?.get(SETTING_ID_REDUCE_MOTION), "auto");
|
||||
state.contrast = asMode(app.extensionManager?.setting?.get(SETTING_ID_HIGH_CONTRAST), "auto");
|
||||
syncMarkers();
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire matchMedia listeners so "auto" mode reflects OS-level changes
|
||||
* without requiring a page reload (e.g. user toggles "Reduce motion" in
|
||||
* macOS System Settings while ComfyUI is open).
|
||||
*/
|
||||
export function watchA11ySystemPrefs(): () => void {
|
||||
const motionMQ = window.matchMedia?.("(prefers-reduced-motion: reduce)");
|
||||
const contrastMQ = window.matchMedia?.("(prefers-contrast: more)");
|
||||
const handler = () => syncMarkers();
|
||||
motionMQ?.addEventListener?.("change", handler);
|
||||
contrastMQ?.addEventListener?.("change", handler);
|
||||
return () => {
|
||||
motionMQ?.removeEventListener?.("change", handler);
|
||||
contrastMQ?.removeEventListener?.("change", handler);
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSettings(_app: AppLike): ComfySetting[] {
|
||||
return [
|
||||
{
|
||||
id: SETTING_ID_REDUCE_MOTION,
|
||||
name: "Reduce motion",
|
||||
type: "combo",
|
||||
options: MOTION_OPTIONS,
|
||||
defaultValue: "auto",
|
||||
tooltip:
|
||||
"Disables Wildcard Pipeline animations and transitions. " +
|
||||
"Match system honors the OS prefers-reduced-motion setting.",
|
||||
category: ["Wildcard Pipeline", "Accessibility", "Reduce motion"],
|
||||
// Use newVal directly — extensionManager.setting.get() can lag the
|
||||
// onChange fire by a tick, which delayed the marker swap until a
|
||||
// page reload in the live UI.
|
||||
onChange: (newVal) => {
|
||||
state.reduceMotion = asMode(newVal, "auto");
|
||||
syncMarkers();
|
||||
},
|
||||
},
|
||||
{
|
||||
id: SETTING_ID_HIGH_CONTRAST,
|
||||
name: "Contrast",
|
||||
type: "combo",
|
||||
options: CONTRAST_OPTIONS,
|
||||
defaultValue: "auto",
|
||||
tooltip:
|
||||
"Bumps border + text contrast on Wildcard Pipeline widgets. " +
|
||||
"Match system honors the OS prefers-contrast setting.",
|
||||
category: ["Wildcard Pipeline", "Accessibility", "Contrast"],
|
||||
onChange: (newVal) => {
|
||||
state.contrast = asMode(newVal, "auto");
|
||||
syncMarkers();
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
import { walkAllNodes, collectUpstreamVariables, type LiteGraphLike, type LiteNodeLike } from "./graph";
|
||||
import { scanConflicts, scanTemplateConflicts, type Conflict, type Severity } from "./conflicts";
|
||||
import { parseWidgetJson, type ContextWidgetValue } from "../widgets/_shared";
|
||||
import { onGraphLoaded } from "./graph-events";
|
||||
|
||||
// ── Subgraph conflict badge ────────────────────────────────────────────
|
||||
// Users can drop our nodes inside a subgraph. From the parent graph, the
|
||||
// SubgraphNode is opaque — opening it is the only way to spot a problem.
|
||||
// We push an LGraphBadge onto `node.badges` for every SubgraphNode that
|
||||
// contains WP_Context or WP_PromptAssembler descendants and surface the
|
||||
// worst severity found in their conflict scan.
|
||||
//
|
||||
// Why LGraphBadge and not onDrawForeground:
|
||||
// - Badges render ABOVE the title bar (drawBadges places them at
|
||||
// y = -(NODE_TITLE_HEIGHT + gap) - height), so they never overlap the
|
||||
// built-in enter_subgraph button which lives INSIDE the title bar.
|
||||
// - Built-in stacking + positioning handles multi-badge cases for free
|
||||
// if other extensions also want to badge the same node.
|
||||
|
||||
interface LGraphBadgeOpts {
|
||||
text?: string;
|
||||
fgColor?: string;
|
||||
bgColor?: string;
|
||||
fontSize?: number;
|
||||
padding?: number;
|
||||
height?: number;
|
||||
cornerRadius?: number;
|
||||
xOffset?: number;
|
||||
yOffset?: number;
|
||||
}
|
||||
|
||||
interface LGraphBadgeInstance {
|
||||
text: string;
|
||||
fgColor: string;
|
||||
bgColor: string;
|
||||
}
|
||||
|
||||
interface LGraphBadgeCtor {
|
||||
new (opts: LGraphBadgeOpts): LGraphBadgeInstance;
|
||||
}
|
||||
|
||||
/** ComfyUI exposes the badge class on window via useGlobalLitegraph(). */
|
||||
function getBadgeCtor(): LGraphBadgeCtor | null {
|
||||
return (window as unknown as { LGraphBadge?: LGraphBadgeCtor }).LGraphBadge ?? null;
|
||||
}
|
||||
|
||||
type SubgraphNodeLike = LiteNodeLike & {
|
||||
size?: [number, number] | number[];
|
||||
badges?: unknown[];
|
||||
setDirtyCanvas?: (foreground: boolean, background: boolean) => void;
|
||||
onConnectionsChange?: (...args: unknown[]) => void;
|
||||
onRemoved?: (...args: unknown[]) => void;
|
||||
/** Internal: latest computed severity so we can avoid pushing duplicate badges. */
|
||||
_wpBadgeSeverity?: Severity | null;
|
||||
/** Internal: badge instance currently in `node.badges` (or null). */
|
||||
_wpBadge?: LGraphBadgeInstance | null;
|
||||
/** Internal: cleanup handle so we can detach observers on node removal. */
|
||||
_wpBadgeCleanup?: () => void;
|
||||
};
|
||||
|
||||
interface SeverityColors { bg: string; fg: string }
|
||||
|
||||
const SEVERITY_COLORS: Record<Severity, SeverityColors> = {
|
||||
info: { bg: "#6366f1", fg: "#ffffff" }, // indigo, --wp-accent
|
||||
warning: { bg: "#fbbf24", fg: "#0F1F0F" }, // amber, --wp-amber
|
||||
error: { bg: "#f87171", fg: "#ffffff" }, // red, --wp-red
|
||||
};
|
||||
|
||||
interface BadgeState {
|
||||
severity: Severity;
|
||||
/** Human-readable label that explains the issue at a glance. */
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the badge label. Casual users won't recognize "i" or "!" — they
|
||||
* need a phrase. We pick the worst-severity conflict and either name the
|
||||
* specific variable (when there's a single issue) or report the count.
|
||||
*/
|
||||
function formatBadgeText(worst: Conflict, sameSeverityCount: number): string {
|
||||
if (sameSeverityCount > 1) {
|
||||
if (worst.severity === "error") return `${sameSeverityCount} errors`;
|
||||
if (worst.severity === "warning") return `${sameSeverityCount} conflicts`;
|
||||
return `${sameSeverityCount} overrides`;
|
||||
}
|
||||
// Single issue — name the variable so the user knows exactly what to fix.
|
||||
if (worst.type === "missing_template_variable") return `missing $${worst.variable}`;
|
||||
if (worst.type === "duplicate_variable") return `duplicate $${worst.variable}`;
|
||||
if (worst.type === "shadows_upstream") return `shadows $${worst.variable}`;
|
||||
return worst.severity;
|
||||
}
|
||||
|
||||
const SEVERITY_RANK: Record<Severity, number> = { info: 1, warning: 2, error: 3 };
|
||||
|
||||
function widgetValue(node: LiteNodeLike, name: string): string {
|
||||
const w = node.widgets?.find((x) => x.name === name);
|
||||
return typeof w?.value === "string" ? w.value : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk every WP node nested anywhere inside `subgraph` (across nested
|
||||
* subgraphs too) and collect all conflicts found. `rootGraph` is needed
|
||||
* because Context/Assembler conflict scans walk upstream across subgraph
|
||||
* boundaries — they need the root to build the subgraph-parents map.
|
||||
*/
|
||||
function collectInnerConflicts(rootGraph: LiteGraphLike, subgraph: LiteGraphLike): Conflict[] {
|
||||
const out: Conflict[] = [];
|
||||
for (const { node } of walkAllNodes(subgraph)) {
|
||||
if (node.type === "WP_Context") {
|
||||
const v = parseWidgetJson<ContextWidgetValue>(widgetValue(node, "modules"), { version: 1, modules: [] });
|
||||
const upstream = collectUpstreamVariables(rootGraph, node);
|
||||
// Only enabled modules count — disabled ones don't ship to runtime.
|
||||
const enabledOnly: ContextWidgetValue = { ...v, modules: v.modules.filter((m) => m.enabled) };
|
||||
out.push(...scanConflicts(enabledOnly, upstream));
|
||||
} else if (node.type === "WP_PromptAssembler") {
|
||||
const tmpl = widgetValue(node, "template");
|
||||
if (!tmpl) continue;
|
||||
const upstream = collectUpstreamVariables(rootGraph, node);
|
||||
out.push(...scanTemplateConflicts(tmpl, upstream));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Roll a list of conflicts up into a single badge state — picks the
|
||||
* worst severity, formats a human-readable label. Returns null when
|
||||
* there's nothing to surface.
|
||||
*/
|
||||
function computeBadgeState(rootGraph: LiteGraphLike, subgraph: LiteGraphLike): BadgeState | null {
|
||||
const conflicts = collectInnerConflicts(rootGraph, subgraph);
|
||||
if (!conflicts.length) return null;
|
||||
let worst = conflicts[0];
|
||||
for (const c of conflicts) {
|
||||
if (SEVERITY_RANK[c.severity] > SEVERITY_RANK[worst.severity]) worst = c;
|
||||
}
|
||||
const sameSeverityCount = conflicts.filter((c) => c.severity === worst.severity).length;
|
||||
return { severity: worst.severity, text: formatBadgeText(worst, sameSeverityCount) };
|
||||
}
|
||||
|
||||
function removeBadge(node: SubgraphNodeLike) {
|
||||
if (!node._wpBadge || !Array.isArray(node.badges)) return;
|
||||
const idx = node.badges.indexOf(node._wpBadge);
|
||||
if (idx >= 0) node.badges.splice(idx, 1);
|
||||
node._wpBadge = null;
|
||||
}
|
||||
|
||||
function applyBadge(node: SubgraphNodeLike, state: BadgeState, BadgeCtor: LGraphBadgeCtor) {
|
||||
const colors = SEVERITY_COLORS[state.severity];
|
||||
// Reuse the existing instance when possible — mutating fields keeps
|
||||
// ComfyUI's draw loop happy without churning the badges array.
|
||||
if (node._wpBadge) {
|
||||
node._wpBadge.text = state.text;
|
||||
node._wpBadge.bgColor = colors.bg;
|
||||
node._wpBadge.fgColor = colors.fg;
|
||||
return;
|
||||
}
|
||||
const badge = new BadgeCtor({
|
||||
text: state.text,
|
||||
bgColor: colors.bg,
|
||||
fgColor: colors.fg,
|
||||
fontSize: 12,
|
||||
padding: 6,
|
||||
height: 18,
|
||||
cornerRadius: 4,
|
||||
});
|
||||
if (!Array.isArray(node.badges)) node.badges = [];
|
||||
node.badges.push(badge);
|
||||
node._wpBadge = badge;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach badge logic to a single SubgraphNode. Idempotent — a node already
|
||||
* attached gets its existing observer left alone. Cleanup is wired through
|
||||
* `node.onRemoved` so we don't leak intervals on node deletion.
|
||||
*/
|
||||
export function attachSubgraphBadge(node: SubgraphNodeLike, rootGraph: LiteGraphLike): void {
|
||||
if (node._wpBadgeCleanup) return; // already attached
|
||||
// Captured into a const here so the closure below sees a non-nullable
|
||||
// type — avoids the no-non-null-assertion lint while staying as cheap
|
||||
// as the previous `BadgeCtor!`.
|
||||
const Ctor: LGraphBadgeCtor | null = getBadgeCtor();
|
||||
if (!Ctor) {
|
||||
console.warn("[wildcard-pipeline] LGraphBadge not on window — badge skipped");
|
||||
return;
|
||||
}
|
||||
|
||||
function recompute() {
|
||||
if (!node.subgraph || !Ctor) return;
|
||||
const next = computeBadgeState(rootGraph, node.subgraph);
|
||||
// Cache key includes both severity AND text — same severity with a new
|
||||
// variable name (e.g. user fixed $foo but $bar is still missing) needs a
|
||||
// re-render even though severity is unchanged.
|
||||
const sameAsBefore =
|
||||
(next === null && node._wpBadgeSeverity === null) ||
|
||||
(!!next && next.severity === node._wpBadgeSeverity && next.text === node._wpBadge?.text);
|
||||
if (sameAsBefore) return;
|
||||
node._wpBadgeSeverity = next?.severity ?? null;
|
||||
if (!next) removeBadge(node);
|
||||
else applyBadge(node, next, Ctor);
|
||||
node.setDirtyCanvas?.(true, true);
|
||||
}
|
||||
|
||||
// Initial compute happens on next tick — workflow may still be loading
|
||||
// and the inner subgraph's nodes might not have widget values restored yet.
|
||||
queueMicrotask(recompute);
|
||||
|
||||
// Polling fallback. 800ms is coarse but the badge isn't latency-critical;
|
||||
// the user is already used to seeing conflict dots within ~1s elsewhere.
|
||||
const interval = window.setInterval(recompute, 800);
|
||||
|
||||
// Recompute on connection changes — wires added/removed inside the
|
||||
// subgraph or to/from the SubgraphNode itself.
|
||||
const origCC = node.onConnectionsChange;
|
||||
node.onConnectionsChange = function (...args: unknown[]) {
|
||||
origCC?.apply(this, args);
|
||||
recompute();
|
||||
};
|
||||
|
||||
// Recompute when ComfyUI finishes restoring a workflow — a node loaded
|
||||
// mid-cascade may not have widget values yet on first compute.
|
||||
const unsubGraphLoaded = onGraphLoaded(() => {
|
||||
recompute();
|
||||
requestAnimationFrame(recompute);
|
||||
});
|
||||
|
||||
// Cleanup wired through onRemoved so a deleted SubgraphNode tears down
|
||||
// its observers cleanly. We chain so other extensions still run.
|
||||
const origRemoved = node.onRemoved;
|
||||
node._wpBadgeCleanup = () => {
|
||||
window.clearInterval(interval);
|
||||
unsubGraphLoaded();
|
||||
node.onConnectionsChange = origCC;
|
||||
node.onRemoved = origRemoved;
|
||||
removeBadge(node);
|
||||
node._wpBadgeCleanup = undefined;
|
||||
};
|
||||
node.onRemoved = function (...args: unknown[]) {
|
||||
node._wpBadgeCleanup?.();
|
||||
origRemoved?.apply(this, args);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk the whole graph (root + nested subgraphs) and attach a badge to
|
||||
* every SubgraphNode. Used on workflow load, when nodes pre-exist.
|
||||
*/
|
||||
export function attachAllSubgraphBadges(rootGraph: LiteGraphLike): void {
|
||||
for (const { node } of walkAllNodes(rootGraph)) {
|
||||
if ((node as SubgraphNodeLike).subgraph) {
|
||||
attachSubgraphBadge(node as SubgraphNodeLike, rootGraph);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,178 @@
|
||||
import { app } from "#comfyui/app";
|
||||
import { createApp } from "vue";
|
||||
import type { LiteGraphLike } from "./extension/graph";
|
||||
|
||||
// ComfyUI's `getCustomWidgets` invokes each factory synchronously and destructures
|
||||
// the return value. Returning a Promise from `import()` yields `{ widget: undefined }`
|
||||
// and the widget never mounts. Resolve all widget chunks at module load via top-level
|
||||
// await so factories can run sync. Vite still emits the chunks separately, so the
|
||||
// entry stays small (~0.5 KB gzip) and the heavy `_shared` chunk only fetches once.
|
||||
const [
|
||||
ctxMod,
|
||||
dbgMod,
|
||||
asmMod,
|
||||
graphEventsMod,
|
||||
ToastModule,
|
||||
graphMod,
|
||||
toastStoreMod,
|
||||
badgeMod,
|
||||
settingsMod,
|
||||
aboutMod,
|
||||
] = await Promise.all([
|
||||
import("./widgets/context"),
|
||||
import("./widgets/debug"),
|
||||
import("./widgets/assembler"),
|
||||
import("./extension/graph-events"),
|
||||
import("./components/shared/Toast.vue"),
|
||||
import("./extension/graph"),
|
||||
import("./components/shared/toast-store"),
|
||||
import("./extension/subgraph-badge"),
|
||||
import("./extension/settings"),
|
||||
import("./extension/about-badges"),
|
||||
// Webfonts (Inter 400/600 + JetBrains Mono 400) — side-effect import,
|
||||
// the module just owns the @font-face CSS chunk.
|
||||
import("./extension/fonts"),
|
||||
]);
|
||||
|
||||
// Singleton toast container — one Vue app mounted to a body-level div renders
|
||||
// every toast pushed via shared/toast-store. Each Context node's Vue app
|
||||
// imports the same store module, so toasts surface from anywhere.
|
||||
const toastRoot = document.createElement("div");
|
||||
toastRoot.id = "wp-toast-root";
|
||||
document.body.appendChild(toastRoot);
|
||||
createApp(ToastModule.default).mount(toastRoot);
|
||||
|
||||
// ComfyUI hands us untyped LiteGraph nodes; we only care about the surface
|
||||
// the glue files import. Typing the param as the parameter of `create` /
|
||||
// `mountHelper` makes TS pick the right shape without explicit casts.
|
||||
type ContextCreateNode = Parameters<typeof ctxMod.create>[0];
|
||||
type DebugCreateNode = Parameters<typeof dbgMod.create>[0];
|
||||
type AssemblerHelperNode = Parameters<typeof asmMod.mountHelper>[0];
|
||||
|
||||
interface NodeData { name: string }
|
||||
interface NodeType { prototype: { onNodeCreated?: (this: AssemblerHelperNode, ...args: unknown[]) => void } }
|
||||
|
||||
// Build the settings array eagerly — `app` is the same singleton ComfyUI
|
||||
// will hand back at runtime, so onChange callbacks captured here read the
|
||||
// extensionManager via the same reference.
|
||||
const settings = settingsMod.buildSettings(app);
|
||||
|
||||
// Apply current a11y prefs on load + listen for OS-level changes so
|
||||
// "Match system" reflects toggles in OS settings without a page reload.
|
||||
// Note: ComfyUI also fires each setting's onChange on page load with the
|
||||
// stored value, so applyA11yClasses is mostly belt-and-suspenders for
|
||||
// the case where the extensionManager is ready before the load-fire.
|
||||
settingsMod.applyA11yClasses(app);
|
||||
settingsMod.watchA11ySystemPrefs();
|
||||
|
||||
app.registerExtension({
|
||||
name: "wildcard-pipeline",
|
||||
|
||||
// About panel — small badges under our extension's row, link out to
|
||||
// the repo, issue tracker, and docs. Uses PrimeIcons shipped by ComfyUI.
|
||||
aboutPageBadges: aboutMod.ABOUT_BADGES,
|
||||
|
||||
// ComfyUI Settings panel entries — accessibility tri-state combos. Their
|
||||
// onChange handlers re-run applyA11yClasses to flip the body markers.
|
||||
settings,
|
||||
|
||||
getCustomWidgets() {
|
||||
return {
|
||||
WP_CONTEXT_MODULES: (node: ContextCreateNode, inputName: string) =>
|
||||
ctxMod.create(node, inputName),
|
||||
WP_DEBUG_VIEWER: (node: DebugCreateNode, inputName: string) =>
|
||||
dbgMod.create(node, inputName),
|
||||
};
|
||||
},
|
||||
|
||||
beforeRegisterNodeDef(nodeType: unknown, nodeData: NodeData) {
|
||||
if (nodeData.name !== "WP_PromptAssembler") return;
|
||||
const nt = nodeType as NodeType;
|
||||
const orig = nt.prototype.onNodeCreated;
|
||||
nt.prototype.onNodeCreated = function (this: AssemblerHelperNode, ...args: unknown[]) {
|
||||
orig?.apply(this, args);
|
||||
asmMod.mountHelper(this);
|
||||
};
|
||||
},
|
||||
|
||||
// Fires after ComfyUI restores all widget values from the loaded workflow.
|
||||
// Without this, assembler's snapshot is computed BEFORE upstream Context
|
||||
// values land — preview briefly shows missing-var warnings then settles
|
||||
// ~400ms later when the polling fallback catches up. Notify all reactive
|
||||
// widgets to recompute immediately.
|
||||
afterConfigureGraph() {
|
||||
graphEventsMod.notifyGraphLoaded();
|
||||
// Re-attach badges in case the loaded workflow contained SubgraphNodes
|
||||
// that bypassed nodeCreated (loaded path uses different hooks).
|
||||
const root = (app as unknown as { graph?: LiteGraphLike }).graph;
|
||||
if (root) badgeMod.attachAllSubgraphBadges(root);
|
||||
},
|
||||
|
||||
// Fires for every node when it's instantiated — including SubgraphNodes,
|
||||
// which beforeRegisterNodeDef does NOT see (litegraph registers them at
|
||||
// runtime, not via ComfyUI's node-def pipeline). We attach a corner
|
||||
// conflict badge so users can spot WP issues nested inside a subgraph
|
||||
// without opening it.
|
||||
nodeCreated(node: unknown) {
|
||||
const n = node as { isSubgraphNode?: () => boolean };
|
||||
if (!n.isSubgraphNode?.()) return;
|
||||
const root = (app as unknown as { graph?: LiteGraphLike }).graph;
|
||||
if (root) badgeMod.attachSubgraphBadge(node as Parameters<typeof badgeMod.attachSubgraphBadge>[0], root);
|
||||
},
|
||||
|
||||
// Same intent as nodeCreated but fires for nodes restored from a saved
|
||||
// workflow. ComfyUI splits the lifecycle so we cover both paths.
|
||||
loadedGraphNode(node: unknown) {
|
||||
const n = node as { isSubgraphNode?: () => boolean };
|
||||
if (!n.isSubgraphNode?.()) return;
|
||||
const root = (app as unknown as { graph?: LiteGraphLike }).graph;
|
||||
if (root) badgeMod.attachSubgraphBadge(node as Parameters<typeof badgeMod.attachSubgraphBadge>[0], root);
|
||||
},
|
||||
});
|
||||
|
||||
// Pre-run validation — when the user queues a prompt, scan every
|
||||
// WP_PromptAssembler in the graph for $vars in its template that no
|
||||
// upstream Context provides. Surface a single toast listing them so the
|
||||
// user can fix-or-accept (the runtime drops missing vars cleanly anyway).
|
||||
interface AppWithQueue {
|
||||
queuePrompt?: (...args: unknown[]) => unknown;
|
||||
graph?: unknown;
|
||||
}
|
||||
const appQ = app as unknown as AppWithQueue;
|
||||
const origQueuePrompt = appQ.queuePrompt;
|
||||
if (typeof origQueuePrompt === "function") {
|
||||
appQ.queuePrompt = function (...args: unknown[]) {
|
||||
try {
|
||||
const graph = appQ.graph as unknown as LiteGraphLike;
|
||||
const TEMPLATE_VAR = /(?<!\$)\$([A-Za-z_][A-Za-z0-9_]*)/g;
|
||||
const allMissing = new Set<string>();
|
||||
// walkAllNodes recurses into nested subgraphs so assemblers inside a
|
||||
// subgraph still get their templates scanned. collectUpstreamVariables
|
||||
// crosses boundaries the other direction.
|
||||
for (const { node } of graphMod.walkAllNodes(graph)) {
|
||||
if (node.type !== "WP_PromptAssembler") continue;
|
||||
const w = node.widgets?.find((x: { name: string; value: unknown }) => x.name === "template");
|
||||
const tmpl = typeof w?.value === "string" ? w.value : "";
|
||||
if (!tmpl) continue;
|
||||
const upstream = new Set(graphMod.collectUpstreamVariables(graph, node));
|
||||
for (const m of tmpl.matchAll(TEMPLATE_VAR)) {
|
||||
const name = m[1];
|
||||
if (!name.startsWith("__") && !upstream.has(name)) allMissing.add(name);
|
||||
}
|
||||
}
|
||||
if (allMissing.size > 0) {
|
||||
const list = [...allMissing].map((v) => `$${v}`).join(", ");
|
||||
toastStoreMod.pushToast(`Unresolved in templates: ${list} — will render empty.`, {
|
||||
severity: "warning",
|
||||
lifeMs: 6000,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
// Validation must never block the queue — log and proceed.
|
||||
console.warn("[wildcard-pipeline] pre-run scan failed:", err);
|
||||
}
|
||||
return origQueuePrompt.apply(this, args);
|
||||
};
|
||||
}
|
||||
|
||||
console.info("[wildcard-pipeline] extension loaded");
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
// Stub the ComfyUI app module so widgets can import without a live ComfyUI.
|
||||
import { vi } from "vitest";
|
||||
|
||||
vi.mock("#comfyui/app", () => ({
|
||||
app: {
|
||||
graph: { _nodes: [], links: {}, getNodeById: () => null },
|
||||
registerExtension: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// jsdom (<26) doesn't ship ResizeObserver. createDomWidgetHost uses it for
|
||||
// content-driven autosize; without a stub, instantiation throws.
|
||||
if (typeof globalThis.ResizeObserver === "undefined") {
|
||||
globalThis.ResizeObserver = class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
} as never;
|
||||
}
|
||||
@@ -1,11 +1,42 @@
|
||||
// Stubs for ComfyUI's frontend globals, extended as needed.
|
||||
// Stubs for ComfyUI's frontend globals, extended as needed. We keep our
|
||||
// hand-rolled shim instead of re-exporting `@comfyorg/comfyui-frontend-types`
|
||||
// directly because the upstream types insist on `NodeId = string | number`
|
||||
// and `LGraphNode` shape, while our internal `LiteNodeLike` uses `id: number`
|
||||
// throughout (subgraphs aside, our walkers handle string IDs explicitly via
|
||||
// the locator helper). The official types live in node_modules as a
|
||||
// reference for new hook signatures — copy fields in here as needed.
|
||||
declare module "#comfyui/app" {
|
||||
export interface ComfyApp {
|
||||
graph: unknown;
|
||||
canvas?: { graph?: unknown };
|
||||
/** Runtime-only — surfaced when ComfyUI mounts the Vue settings panel. */
|
||||
extensionManager?: {
|
||||
setting?: { get(id: string): unknown };
|
||||
};
|
||||
registerExtension(ext: {
|
||||
name: string;
|
||||
getCustomWidgets?: () => Promise<Record<string, unknown>> | Record<string, unknown>;
|
||||
beforeRegisterNodeDef?: (nodeType: unknown, nodeData: unknown) => Promise<void> | void;
|
||||
beforeRegisterNodeDef?: (nodeType: unknown, nodeData: { name: string }) => Promise<void> | void;
|
||||
/** Called after ComfyUI restores all widget values on workflow load. */
|
||||
afterConfigureGraph?: (...args: unknown[]) => void;
|
||||
/** Fires for every node when instantiated, including SubgraphNodes that
|
||||
* bypass beforeRegisterNodeDef. */
|
||||
nodeCreated?: (node: unknown) => Promise<void> | void;
|
||||
/** Fires for every node restored from a saved workflow. */
|
||||
loadedGraphNode?: (node: unknown, app: unknown) => Promise<void> | void;
|
||||
/** Badges shown in the "About" panel under our extension row. */
|
||||
aboutPageBadges?: Array<{ label: string; url: string; icon: string }>;
|
||||
/** Entries in the ComfyUI Settings panel. */
|
||||
settings?: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
type: "boolean" | "text" | "number" | "slider" | "combo" | "color" | "image" | "hidden";
|
||||
defaultValue: unknown;
|
||||
tooltip?: string;
|
||||
options?: Array<{ text: string; value: string }>;
|
||||
category?: string[];
|
||||
onChange?: (newVal: unknown, oldVal: unknown) => void;
|
||||
}>;
|
||||
}): void;
|
||||
}
|
||||
export const app: ComfyApp;
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
import { createApp, type App, type Component } from "vue";
|
||||
|
||||
export interface DomWidget {
|
||||
element: HTMLElement;
|
||||
options?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface DomWidgetHost {
|
||||
widget: DomWidget;
|
||||
element: HTMLElement;
|
||||
app: App;
|
||||
unmount: () => void;
|
||||
getValue: () => string;
|
||||
setValue: (v: string) => void;
|
||||
}
|
||||
|
||||
export interface MountTargetNode {
|
||||
addDOMWidget(name: string, type: string, el: HTMLElement, opts?: Record<string, unknown>): DomWidget & {
|
||||
computeLayoutSize?: (n: unknown) => { minWidth: number; minHeight: number };
|
||||
};
|
||||
}
|
||||
|
||||
export interface CreateDomWidgetHostOptions<P extends Record<string, unknown>> {
|
||||
initialValue?: string;
|
||||
/** Notified whenever ComfyUI's value setter is invoked — e.g. workflow load. */
|
||||
onValueRestored?: (v: string) => void;
|
||||
componentProps?: P;
|
||||
/** Minimum widget height in pixels. Skips per-frame DOM measurement. */
|
||||
minHeight?: number;
|
||||
/** Force the parent node to be at least this wide (px). LoRA Manager docs §4.3. */
|
||||
minWidth?: number;
|
||||
/**
|
||||
* Fill the host's allocated height instead of growing the node to fit
|
||||
* content. Use for read-only viewers (debug snapshot) where the node size
|
||||
* should drive the viewer, not the other way around. Disables autosize
|
||||
* observation; component CSS should set `height: 100%` on its root.
|
||||
*/
|
||||
fillHost?: boolean;
|
||||
}
|
||||
|
||||
interface ResizableNode {
|
||||
size?: number[];
|
||||
computeSize?: () => [number, number] | number[];
|
||||
setSize?: (size: [number, number] | number[]) => void;
|
||||
setDirtyCanvas?: (foreground: boolean, background: boolean) => void;
|
||||
}
|
||||
|
||||
export function createDomWidgetHost<P extends Record<string, unknown>>(
|
||||
node: MountTargetNode,
|
||||
widgetName: string,
|
||||
component: Component<P>,
|
||||
options: CreateDomWidgetHostOptions<P> = {},
|
||||
): DomWidgetHost {
|
||||
const host = document.createElement("div");
|
||||
host.classList.add("wp-widget");
|
||||
host.style.width = "100%";
|
||||
host.style.boxSizing = "border-box";
|
||||
|
||||
// Inner element receives Vue's mount. Its natural (content) height is what
|
||||
// we feed back as the node's required height. Observing the outer host
|
||||
// would only ever report ComfyUI's own assigned height (== getMinHeight),
|
||||
// which gives a tautology — height never grows because we measure the
|
||||
// value we just set.
|
||||
const inner = document.createElement("div");
|
||||
inner.classList.add("wp-widget__inner");
|
||||
inner.style.width = "100%";
|
||||
inner.style.boxSizing = "border-box";
|
||||
if (options.fillHost) {
|
||||
// Stretch to whatever ComfyUI gives us so the component can flex into the
|
||||
// available space.
|
||||
inner.style.height = "100%";
|
||||
host.style.height = "100%";
|
||||
}
|
||||
host.appendChild(inner);
|
||||
|
||||
let state = options.initialValue ?? "";
|
||||
const baseMin = options.minHeight ?? 80;
|
||||
let minHeight = baseMin;
|
||||
|
||||
const widgetOpts: Record<string, unknown> = {
|
||||
socketless: true,
|
||||
getValue: () => state,
|
||||
setValue: (v: string) => {
|
||||
state = v;
|
||||
options.onValueRestored?.(v);
|
||||
},
|
||||
// Cached number so ComfyUI skips per-frame getComputedStyle (perf hint
|
||||
// from LoRA Manager's DOM widget guide).
|
||||
getMinHeight: () => minHeight,
|
||||
};
|
||||
|
||||
const widget = node.addDOMWidget(widgetName, "wp-dom", host, widgetOpts);
|
||||
// Force a minimum node width if requested. LoRA Manager docs §4.3 — only
|
||||
// way to widen the node from a DOM widget is overriding computeLayoutSize.
|
||||
if (options.minWidth) {
|
||||
const minWidth = options.minWidth;
|
||||
widget.computeLayoutSize = () => ({ minWidth, minHeight });
|
||||
}
|
||||
// createApp's prop overload requires the second arg's keys to extend the
|
||||
// component's prop keys. With componentProps?: P (defaulting to {}), TS
|
||||
// won't narrow back to P at this call site. Cast through `unknown` once
|
||||
// here — the public API on createDomWidgetHost is still strict in P.
|
||||
const app = createApp(component, (options.componentProps ?? {}) as unknown as P);
|
||||
app.mount(inner);
|
||||
|
||||
// Observe inner content height. When Vue grows the DOM, bump minHeight (so
|
||||
// ComfyUI's getMinHeight callback returns the right value) and grow the node
|
||||
// ONLY when its current height is below what the content needs.
|
||||
//
|
||||
// Important: never call setSize unconditionally. computeSize() returns the
|
||||
// *minimum* layout size; calling setSize(computeSize()) on every observer
|
||||
// fire would (a) override the user's manual drag-larger, and (b) override
|
||||
// the saved-workflow size when ComfyUI restores it on page load.
|
||||
const resizable = node as unknown as ResizableNode;
|
||||
let scheduled = false;
|
||||
function pushSize(measured: number) {
|
||||
if (scheduled) return;
|
||||
scheduled = true;
|
||||
queueMicrotask(() => {
|
||||
scheduled = false;
|
||||
const next = Math.max(baseMin, measured);
|
||||
if (next !== minHeight) minHeight = next;
|
||||
|
||||
const min = resizable.computeSize?.();
|
||||
const cur = resizable.size;
|
||||
if (!min || !cur || !resizable.setSize) return;
|
||||
// Always match content size on the height axis. Width is left to the
|
||||
// user (drag wider preserved). If user wants more vertical space they
|
||||
// collapse modules / use whitespace.
|
||||
if (cur[1] !== min[1]) {
|
||||
resizable.setSize([Math.max(cur[0], min[0]), min[1]]);
|
||||
resizable.setDirtyCanvas?.(true, true);
|
||||
}
|
||||
});
|
||||
}
|
||||
// Skip the autosize observer in fill mode — node size is user-controlled,
|
||||
// content fills whatever space is given.
|
||||
const observer = options.fillHost
|
||||
? null
|
||||
: new ResizeObserver((entries) => {
|
||||
for (const e of entries) {
|
||||
const h = Math.ceil(e.borderBoxSize?.[0]?.blockSize ?? e.contentRect.height);
|
||||
if (h > 0) pushSize(h);
|
||||
}
|
||||
});
|
||||
if (observer) queueMicrotask(() => observer.observe(inner));
|
||||
|
||||
return {
|
||||
widget,
|
||||
element: inner,
|
||||
app,
|
||||
unmount: () => {
|
||||
observer?.disconnect();
|
||||
app.unmount();
|
||||
},
|
||||
getValue: () => state,
|
||||
setValue: (v) => {
|
||||
state = v;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface ContextWidgetValue {
|
||||
version: 1;
|
||||
modules: ModuleEntry[];
|
||||
}
|
||||
|
||||
export interface ModuleEntry {
|
||||
id: string;
|
||||
type: "fixed_values";
|
||||
enabled: boolean;
|
||||
/** UI-only: persists collapsed state so cards stay collapsed across reload. */
|
||||
collapsed?: boolean;
|
||||
meta: { name: string; description?: string; tags?: string[] };
|
||||
entries: { variable_name: string; value: string }[];
|
||||
}
|
||||
|
||||
export function parseWidgetJson<T>(raw: string, fallback: T): T {
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed && typeof parsed === "object") return parsed as T;
|
||||
} catch {
|
||||
// fall through to default
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export interface ParseRecovery<T> {
|
||||
/** Parsed value, or the fallback if parsing failed / shape was wrong. */
|
||||
value: T;
|
||||
/** Human-readable error message, or null on success. */
|
||||
error: string | null;
|
||||
/** Original raw input — preserved so a "View raw" UI can show the bad data. */
|
||||
raw: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse with recovery info attached. Use when the widget needs to surface a
|
||||
* "corrupt workflow" panel: callers branch on `error` and render a
|
||||
* View raw / Reset UI without throwing away the original payload.
|
||||
*
|
||||
* Empty input is NOT an error — fresh nodes have no value yet and should
|
||||
* just receive the fallback silently.
|
||||
*/
|
||||
export function parseWidgetJsonWithRecovery<T>(raw: string, fallback: T): ParseRecovery<T> {
|
||||
if (!raw) return { value: fallback, error: null, raw };
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed && typeof parsed === "object") return { value: parsed as T, error: null, raw };
|
||||
return { value: fallback, error: `Expected an object, got ${typeof parsed}.`, raw };
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
return { value: fallback, error: msg, raw };
|
||||
}
|
||||
}
|
||||
|
||||
export function serializeWidgetJson(value: unknown): string {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
export function emptyContextValue(): ContextWidgetValue {
|
||||
return { version: 1, modules: [] };
|
||||
}
|
||||
|
||||
export function newModuleId(): string {
|
||||
// spec §3.2 — 8-char hex
|
||||
const bytes = new Uint8Array(4);
|
||||
crypto.getRandomValues(bytes);
|
||||
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { defineAsyncComponent, h, type Component } from "vue";
|
||||
import { app } from "#comfyui/app";
|
||||
import { createDomWidgetHost, type MountTargetNode } from "./_shared";
|
||||
import {
|
||||
collectUpstreamVariables,
|
||||
collectUpstreamValues,
|
||||
type LiteGraphLike,
|
||||
type LiteNodeLike,
|
||||
} from "../extension/graph";
|
||||
import { reactiveFromGraph, stringArrayEqual } from "../extension/reactive";
|
||||
|
||||
const AssemblerHelper = defineAsyncComponent(() => import("../components/assembler/AssemblerHelper.vue"));
|
||||
|
||||
interface AssemblerNode extends LiteNodeLike, MountTargetNode {
|
||||
widgets?: { name: string; value: unknown }[];
|
||||
}
|
||||
|
||||
function templateOf(node: AssemblerNode): string {
|
||||
const w = node.widgets?.find((x) => x.name === "template");
|
||||
return typeof w?.value === "string" ? w.value : "";
|
||||
}
|
||||
|
||||
interface UpstreamSnapshot {
|
||||
vars: string[];
|
||||
values: Record<string, string>;
|
||||
template: string;
|
||||
}
|
||||
|
||||
function snapshotEqual(a: UpstreamSnapshot, b: UpstreamSnapshot): boolean {
|
||||
if (a.template !== b.template) return false;
|
||||
if (!stringArrayEqual(a.vars, b.vars)) return false;
|
||||
for (const k of a.vars) if (a.values[k] !== b.values[k]) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function mountHelper(node: AssemblerNode) {
|
||||
const wrapper: Component = {
|
||||
setup() {
|
||||
// Bundle vars + values + template into one snapshot — they all change
|
||||
// together (graph edits, module edits, template typing) and the polling
|
||||
// fallback covers all three uniformly.
|
||||
const snapshot = reactiveFromGraph<UpstreamSnapshot>(
|
||||
node as unknown as Parameters<typeof reactiveFromGraph>[0],
|
||||
() => {
|
||||
const g = app.graph as unknown as LiteGraphLike;
|
||||
return {
|
||||
vars: collectUpstreamVariables(g, node),
|
||||
values: collectUpstreamValues(g, node),
|
||||
template: templateOf(node),
|
||||
};
|
||||
},
|
||||
snapshotEqual,
|
||||
);
|
||||
return () => h(AssemblerHelper, {
|
||||
upstreamVars: snapshot.value.vars,
|
||||
upstreamValues: snapshot.value.values,
|
||||
template: snapshot.value.template,
|
||||
onInsert: (token: string) => insertIntoTemplate(node, token),
|
||||
});
|
||||
},
|
||||
};
|
||||
createDomWidgetHost(node, "assembler-helper", wrapper, {
|
||||
minHeight: 80,
|
||||
minWidth: 280,
|
||||
});
|
||||
}
|
||||
|
||||
function insertIntoTemplate(node: AssemblerNode, token: string) {
|
||||
const w = node.widgets?.find((x) => x.name === "template") as
|
||||
| { name: string; value: unknown; inputEl?: HTMLTextAreaElement | HTMLInputElement }
|
||||
| undefined;
|
||||
if (!w || typeof w.value !== "string") return;
|
||||
|
||||
// Multiline STRING widgets in ComfyUI back the input with a real <textarea>
|
||||
// exposed at widget.inputEl. If we can find it, splice the token at the
|
||||
// current caret. If not (collapsed canvas mode, etc.), fall back to a
|
||||
// smart-append.
|
||||
const el = w.inputEl;
|
||||
if (el && typeof el.selectionStart === "number") {
|
||||
const start = el.selectionStart ?? el.value.length;
|
||||
const end = el.selectionEnd ?? start;
|
||||
const needsLeadingSpace = start > 0 && !/\s$/.test(el.value.slice(0, start));
|
||||
const insert = `${needsLeadingSpace ? " " : ""}${token}`;
|
||||
el.value = el.value.slice(0, start) + insert + el.value.slice(end);
|
||||
w.value = el.value;
|
||||
// Restore focus + place caret immediately after the inserted token.
|
||||
el.focus();
|
||||
const caret = start + insert.length;
|
||||
el.setSelectionRange(caret, caret);
|
||||
el.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback: append with a single-space separator.
|
||||
const cur = w.value;
|
||||
w.value = `${cur}${cur.endsWith(" ") || !cur ? "" : " "}${token}`;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { defineAsyncComponent, h, ref, type Component } from "vue";
|
||||
import { app } from "#comfyui/app";
|
||||
import {
|
||||
createDomWidgetHost, parseWidgetJson, serializeWidgetJson, emptyContextValue,
|
||||
type ContextWidgetValue, type MountTargetNode,
|
||||
} from "./_shared";
|
||||
import { collectUpstreamVariables, type LiteGraphLike, type LiteNodeLike } from "../extension/graph";
|
||||
import { reactiveFromGraph, stringArrayEqual } from "../extension/reactive";
|
||||
|
||||
const ContextWidget = defineAsyncComponent(() => import("../components/context/ContextWidget.vue"));
|
||||
|
||||
type ContextNode = LiteNodeLike & MountTargetNode;
|
||||
|
||||
export function create(node: ContextNode, inputName: string) {
|
||||
const initial = serializeWidgetJson(parseWidgetJson<ContextWidgetValue>("", emptyContextValue()));
|
||||
// Reactive `initialJson` prop — workflow load races (setValue can fire
|
||||
// before the async SFC chunk mounts) are handled by the SFC's watch on this
|
||||
// prop. No imperative remount, no focus loss on edits.
|
||||
const currentJson = ref(initial);
|
||||
|
||||
const wrapper: Component = {
|
||||
setup() {
|
||||
const upstreamVars = reactiveFromGraph(
|
||||
node as unknown as Parameters<typeof reactiveFromGraph>[0],
|
||||
() => collectUpstreamVariables(app.graph as unknown as LiteGraphLike, node),
|
||||
stringArrayEqual,
|
||||
);
|
||||
return () => h(ContextWidget, {
|
||||
nodeId: node.id,
|
||||
initialJson: currentJson.value,
|
||||
upstreamVars: upstreamVars.value,
|
||||
onChange: (json: string) => host.setValue(json),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
const host = createDomWidgetHost(node, inputName, wrapper, {
|
||||
initialValue: initial,
|
||||
minHeight: 80,
|
||||
minWidth: 280,
|
||||
onValueRestored: (v: string) => {
|
||||
// Workflow load — push the restored value into the reactive prop so the
|
||||
// SFC picks it up whether it has already mounted or not.
|
||||
if (v !== currentJson.value) currentJson.value = v;
|
||||
},
|
||||
});
|
||||
|
||||
return host;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { defineAsyncComponent, h, ref, type Component } from "vue";
|
||||
import { createDomWidgetHost, type MountTargetNode } from "./_shared";
|
||||
|
||||
const DebugViewer = defineAsyncComponent(() => import("../components/debug/DebugViewer.vue"));
|
||||
|
||||
interface DebugNode extends MountTargetNode {
|
||||
onExecuted?: (output: { wp_debug_snapshot?: string[] }) => void;
|
||||
}
|
||||
|
||||
export function create(node: DebugNode, inputName: string) {
|
||||
const snapshot = ref("");
|
||||
const wrapper: Component = {
|
||||
setup: () => () => h(DebugViewer, { snapshot: snapshot.value }),
|
||||
};
|
||||
// Backend declares `viewer` as required but ignores it at runtime — seed an
|
||||
// empty string so workflow serialization and prompt validation both pass.
|
||||
const host = createDomWidgetHost(node, inputName, wrapper, {
|
||||
initialValue: "",
|
||||
// Fill mode — viewer fills whatever node size the user gives it. Snapshot
|
||||
// doesn't push the node larger; oversized snapshots scroll inside.
|
||||
fillHost: true,
|
||||
minHeight: 200,
|
||||
minWidth: 280,
|
||||
});
|
||||
const orig = node.onExecuted;
|
||||
node.onExecuted = function (output) {
|
||||
orig?.call(this, output);
|
||||
const snap = output?.wp_debug_snapshot;
|
||||
if (Array.isArray(snap) && typeof snap[0] === "string") snapshot.value = snap[0];
|
||||
};
|
||||
return host;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { defineComponent, h } from "vue";
|
||||
import { createDomWidgetHost, parseWidgetJson, serializeWidgetJson } from "./_shared";
|
||||
|
||||
describe("createDomWidgetHost", () => {
|
||||
it("mounts a Vue component into a fresh div and returns the host element", () => {
|
||||
const Hello = defineComponent({
|
||||
props: { msg: { type: String, required: true } },
|
||||
setup: (p) => () => h("span", { class: "hi" }, p.msg),
|
||||
});
|
||||
const fakeNode = { addDOMWidget: (_n: string, _t: string, el: HTMLElement) => ({ element: el }) };
|
||||
const { element, unmount } = createDomWidgetHost(fakeNode, "modules", Hello, {
|
||||
componentProps: { msg: "ok" },
|
||||
});
|
||||
expect(element.querySelector(".hi")?.textContent).toBe("ok");
|
||||
unmount();
|
||||
expect(element.querySelector(".hi")).toBeNull();
|
||||
});
|
||||
|
||||
it("exposes getValue/setValue backed by the closure state", () => {
|
||||
const Empty = defineComponent({ setup: () => () => h("span") });
|
||||
let captured: { getValue?: () => string; setValue?: (v: string) => void } = {};
|
||||
const fakeNode = {
|
||||
addDOMWidget: (_n: string, _t: string, el: HTMLElement, opts: Record<string, unknown> | undefined) => {
|
||||
captured = opts as never;
|
||||
return { element: el };
|
||||
},
|
||||
};
|
||||
const restored: string[] = [];
|
||||
const host = createDomWidgetHost(fakeNode, "modules", Empty, {
|
||||
initialValue: "seed",
|
||||
onValueRestored: (v) => restored.push(v),
|
||||
});
|
||||
expect(host.getValue()).toBe("seed");
|
||||
expect(captured.getValue?.()).toBe("seed");
|
||||
|
||||
captured.setValue?.("from-workflow");
|
||||
expect(host.getValue()).toBe("from-workflow");
|
||||
expect(restored).toEqual(["from-workflow"]);
|
||||
|
||||
host.setValue("from-host");
|
||||
expect(captured.getValue?.()).toBe("from-host");
|
||||
host.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseWidgetJson / serializeWidgetJson", () => {
|
||||
it("round-trips, returns parsed value, and falls back on invalid input", () => {
|
||||
const fallback = { version: 1, modules: [] };
|
||||
const real = { version: 1, modules: [{ id: "abc12345" }] };
|
||||
expect(parseWidgetJson(JSON.stringify(real), fallback)).toEqual(real);
|
||||
expect(parseWidgetJson("not-json", fallback)).toEqual(fallback);
|
||||
expect(parseWidgetJson("null", fallback)).toEqual(fallback);
|
||||
expect(serializeWidgetJson({ version: 1, modules: [] })).toBe('{"version":1,"modules":[]}');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,235 @@
|
||||
"""Minimal stub of the subset of ``comfy_api.latest`` the project uses.
|
||||
|
||||
Exposes:
|
||||
- ``ComfyExtension`` — base class with ``async get_node_list()``.
|
||||
- ``io.ComfyNode`` — base class for V3 nodes.
|
||||
- ``io.Schema`` — dataclass capturing node_id, display_name, category,
|
||||
inputs, outputs, and boolean flags like ``not_idempotent``,
|
||||
``is_output_node``.
|
||||
- ``io.Int``, ``io.String`` — built-in widget types.
|
||||
- ``io.Custom(name)`` — factory for custom socket/widget types.
|
||||
- ``io.NodeOutput`` — container for execute() return values.
|
||||
- ``io._io.comfytype`` / ``ComfyTypeIO`` — decorator + base for strongly
|
||||
typed custom types.
|
||||
|
||||
The stub aims for **structural** parity: attribute access + .Input()/.Output()
|
||||
factory calls succeed, and io.NodeOutput carries the positional values plus
|
||||
an optional ``ui`` dict. Actual runtime semantics (caching, widget rendering,
|
||||
socket wiring) are not simulated — those are verified via manual QA in ComfyUI.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
# ---------------------------------------------------------------- Schema ---
|
||||
|
||||
|
||||
@dataclass
|
||||
class Schema:
|
||||
node_id: str
|
||||
display_name: str = ""
|
||||
category: str = ""
|
||||
description: str = ""
|
||||
inputs: list[Any] = field(default_factory=list)
|
||||
outputs: list[Any] = field(default_factory=list)
|
||||
hidden: list[Any] = field(default_factory=list)
|
||||
is_output_node: bool = False
|
||||
not_idempotent: bool = False
|
||||
is_experimental: bool = False
|
||||
is_deprecated: bool = False
|
||||
|
||||
|
||||
# ------------------------------------------------------------ NodeOutput ---
|
||||
|
||||
|
||||
class NodeOutput:
|
||||
"""Structural stub — matches real ``io.NodeOutput`` surface."""
|
||||
|
||||
def __init__(self, *values: Any, ui: dict[str, Any] | None = None):
|
||||
self.values = values
|
||||
self.ui = ui
|
||||
|
||||
|
||||
# ------------------------------------------------------------- ComfyNode ---
|
||||
|
||||
|
||||
class ComfyNode:
|
||||
"""V3 node base class. Real ComfyUI instantiates these via classmethods."""
|
||||
|
||||
@classmethod
|
||||
def define_schema(cls) -> Schema: # pragma: no cover - overridden
|
||||
raise NotImplementedError
|
||||
|
||||
@classmethod
|
||||
def execute(cls, *args: Any, **kwargs: Any) -> NodeOutput: # pragma: no cover
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
# ----------------------------------------- Input/Output descriptor helpers ---
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Slot:
|
||||
"""A single input or output slot descriptor captured by tests."""
|
||||
|
||||
kind: str # "input" or "output"
|
||||
type_name: str
|
||||
name: str = ""
|
||||
default: Any = None
|
||||
optional: bool = False
|
||||
multiline: bool = False
|
||||
socketless: bool = False
|
||||
min: Any = None
|
||||
max: Any = None
|
||||
control_after_generate: bool = False
|
||||
|
||||
|
||||
class _IOType:
|
||||
"""Factory for a concrete io type (Int, String, custom)."""
|
||||
|
||||
def __init__(self, type_name: str):
|
||||
self._type_name = type_name
|
||||
|
||||
def Input(self, name: str = "", **kwargs: Any) -> _Slot:
|
||||
return _Slot(kind="input", type_name=self._type_name, name=name, **kwargs)
|
||||
|
||||
def Output(self, name: str = "") -> _Slot:
|
||||
return _Slot(kind="output", type_name=self._type_name, name=name)
|
||||
|
||||
|
||||
Int = _IOType("INT")
|
||||
String = _IOType("STRING")
|
||||
|
||||
|
||||
def Custom(type_name: str) -> _IOType:
|
||||
"""Create an io type with the given custom type name."""
|
||||
return _IOType(type_name)
|
||||
|
||||
|
||||
# ------------------------------------------------- @comfytype / ComfyTypeIO ---
|
||||
|
||||
|
||||
class ComfyTypeIO:
|
||||
"""Base for typed custom types — set ``Type = ...`` in subclass."""
|
||||
|
||||
Type: Any = None
|
||||
|
||||
@classmethod
|
||||
def Input(cls, name: str = "", **kwargs: Any) -> _Slot:
|
||||
return _Slot(
|
||||
kind="input",
|
||||
type_name=getattr(cls, "_io_type", "CUSTOM"),
|
||||
name=name,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def Output(cls, name: str = "") -> _Slot:
|
||||
return _Slot(
|
||||
kind="output",
|
||||
type_name=getattr(cls, "_io_type", "CUSTOM"),
|
||||
name=name,
|
||||
)
|
||||
|
||||
|
||||
def comfytype(io_type: str):
|
||||
"""Decorator that tags a class (and any nested Input/Output) with an io type."""
|
||||
|
||||
def _wrap(cls):
|
||||
cls._io_type = io_type
|
||||
# Propagate io_type to nested Input/Output subclasses so ``WidgetInput``
|
||||
# subclass instances expose ``.type_name`` correctly without manual wiring.
|
||||
# Only propagate when ``Input``/``Output`` is an actual class — the
|
||||
# ``ComfyTypeIO`` base exposes them as classmethods which can't take
|
||||
# arbitrary attributes.
|
||||
nested_input = getattr(cls, "Input", None)
|
||||
if isinstance(nested_input, type):
|
||||
nested_input._io_type = io_type
|
||||
nested_output = getattr(cls, "Output", None)
|
||||
if isinstance(nested_output, type):
|
||||
nested_output._io_type = io_type
|
||||
return cls
|
||||
|
||||
return _wrap
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ io ns ---
|
||||
|
||||
|
||||
class WidgetInput:
|
||||
"""Stub matching ``comfy_api.latest.WidgetInput`` surface.
|
||||
|
||||
Custom widget types in ``nodes/types.py`` subclass this so they can accept
|
||||
``socketless`` at declaration time — a kwarg the base ``Input`` does not
|
||||
support.
|
||||
"""
|
||||
|
||||
# Subclass should set this (by convention, via @comfytype descending through
|
||||
# the ``Parent.io_type`` chain in the real API). In our stub, tests check
|
||||
# ``type_name`` directly, so subclasses of WidgetInput should set
|
||||
# ``type_name`` on ``self`` in their ``__init__`` after calling super, OR
|
||||
# the @comfytype decorator propagates an ``_io_type`` class attribute.
|
||||
_io_type: str = "WIDGET_INPUT"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
id: str,
|
||||
display_name: str | None = None,
|
||||
optional: bool = False,
|
||||
tooltip: str | None = None,
|
||||
lazy: bool | None = None,
|
||||
default: Any = None,
|
||||
socketless: bool | None = None,
|
||||
widget_type: str | None = None,
|
||||
force_input: bool | None = None,
|
||||
extra_dict: dict[str, Any] | None = None,
|
||||
raw_link: bool | None = None,
|
||||
advanced: bool | None = None,
|
||||
):
|
||||
self.id = id
|
||||
self.name = id # back-compat for tests
|
||||
self.type_name = self._io_type
|
||||
self.display_name = display_name
|
||||
self.optional = optional
|
||||
self.tooltip = tooltip
|
||||
self.lazy = lazy
|
||||
self.default = default
|
||||
self.socketless = socketless
|
||||
self.widget_type = widget_type
|
||||
self.force_input = force_input
|
||||
self.extra_dict = extra_dict
|
||||
self.raw_link = raw_link
|
||||
self.advanced = advanced
|
||||
|
||||
|
||||
class _IONamespace:
|
||||
"""Mirrors ``comfy_api.latest.io``. Attribute access for types."""
|
||||
|
||||
ComfyNode = ComfyNode
|
||||
Schema = Schema
|
||||
NodeOutput = NodeOutput
|
||||
Int = Int
|
||||
String = String
|
||||
Custom = staticmethod(Custom)
|
||||
WidgetInput = WidgetInput
|
||||
_io: "_IOInternal"
|
||||
|
||||
|
||||
# Sub-namespace: io._io exposes @comfytype + ComfyTypeIO (real module path).
|
||||
class _IOInternal:
|
||||
comfytype = staticmethod(comfytype)
|
||||
ComfyTypeIO = ComfyTypeIO
|
||||
|
||||
|
||||
io = _IONamespace()
|
||||
io._io = _IOInternal()
|
||||
|
||||
|
||||
# ----------------------------------------------------------- ComfyExtension ---
|
||||
|
||||
|
||||
class ComfyExtension:
|
||||
"""Real base class exposes async ``get_node_list``. Stub mirrors shape."""
|
||||
|
||||
async def get_node_list(self) -> list[type[ComfyNode]]: # pragma: no cover
|
||||
return []
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Unit test for the extension's comfy_entrypoint wiring."""
|
||||
|
||||
import asyncio
|
||||
import importlib.util
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _load_root_init():
|
||||
"""Load the project-root __init__.py as a package-aware module."""
|
||||
project_root = Path(__file__).parent.parent
|
||||
init_path = project_root / "__init__.py"
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"wildcard_pipeline_root",
|
||||
init_path,
|
||||
submodule_search_locations=[str(project_root)],
|
||||
)
|
||||
assert spec and spec.loader
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules["wildcard_pipeline_root"] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def test_entrypoint_exposes_three_node_classes():
|
||||
ext = _load_root_init()
|
||||
extension = asyncio.run(ext.comfy_entrypoint())
|
||||
node_list = asyncio.run(extension.get_node_list())
|
||||
|
||||
ids = sorted(cls.define_schema().node_id for cls in node_list)
|
||||
assert ids == ["WP_Context", "WP_Debug", "WP_PromptAssembler"]
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Unit tests for nodes.assembler_node.WPPromptAssembler."""
|
||||
|
||||
from wp_nodes.assembler_node import WPPromptAssembler
|
||||
from wp_nodes.types import ContextPayload
|
||||
|
||||
|
||||
class TestWPPromptAssemblerSchema:
|
||||
def test_schema_basic(self):
|
||||
schema = WPPromptAssembler.define_schema()
|
||||
assert schema.node_id == "WP_PromptAssembler"
|
||||
assert schema.display_name == "WP Prompt Assembler"
|
||||
assert schema.category == "wildcard-pipeline"
|
||||
|
||||
def test_inputs(self):
|
||||
schema = WPPromptAssembler.define_schema()
|
||||
names = [s.name for s in schema.inputs]
|
||||
assert names == ["context", "template"]
|
||||
|
||||
ctx_in, tmpl_in = schema.inputs
|
||||
assert ctx_in.type_name == "PIPELINE_CONTEXT"
|
||||
assert tmpl_in.type_name == "STRING"
|
||||
assert tmpl_in.multiline is True
|
||||
|
||||
def test_outputs(self):
|
||||
schema = WPPromptAssembler.define_schema()
|
||||
assert len(schema.outputs) == 1
|
||||
assert schema.outputs[0].type_name == "STRING"
|
||||
|
||||
|
||||
class TestWPPromptAssemblerExecute:
|
||||
def test_resolves_single_var(self):
|
||||
payload = ContextPayload(context={"style": "photoreal"}, debug={})
|
||||
out = WPPromptAssembler.execute(context=payload, template="A $style shot")
|
||||
assert out.values == ("A photoreal shot",)
|
||||
|
||||
def test_resolves_multiple_vars(self):
|
||||
payload = ContextPayload(
|
||||
context={"style": "photo", "subject": "knight", "light": "soft"},
|
||||
debug={},
|
||||
)
|
||||
out = WPPromptAssembler.execute(
|
||||
context=payload,
|
||||
template="A $style $subject in $light light",
|
||||
)
|
||||
assert out.values == ("A photo knight in soft light",)
|
||||
|
||||
def test_missing_var_dropped_with_whitespace_cleanup(self):
|
||||
# Missing vars resolve to empty string and surrounding whitespace is
|
||||
# collapsed so the prompt stays clean.
|
||||
payload = ContextPayload(context={}, debug={})
|
||||
out = WPPromptAssembler.execute(context=payload, template="$unknown here")
|
||||
assert out.values == ("here",)
|
||||
|
||||
def test_dollar_escape(self):
|
||||
payload = ContextPayload(context={"x": "1"}, debug={})
|
||||
out = WPPromptAssembler.execute(context=payload, template="$$ costs $x")
|
||||
assert out.values == ("$ costs 1",)
|
||||
|
||||
def test_empty_template(self):
|
||||
payload = ContextPayload(context={"x": "1"}, debug={})
|
||||
out = WPPromptAssembler.execute(context=payload, template="")
|
||||
assert out.values == ("",)
|
||||
@@ -0,0 +1,123 @@
|
||||
"""Unit tests for nodes.context_node.WPContext."""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from wp_nodes.context_node import WPContext
|
||||
from wp_nodes.types import ContextPayload
|
||||
|
||||
|
||||
class TestWPContextSchema:
|
||||
def test_schema_basic(self):
|
||||
schema = WPContext.define_schema()
|
||||
assert schema.node_id == "WP_Context"
|
||||
assert schema.display_name == "WP Context"
|
||||
assert schema.category == "wildcard-pipeline"
|
||||
assert schema.not_idempotent is True
|
||||
|
||||
def test_schema_inputs(self):
|
||||
schema = WPContext.define_schema()
|
||||
names = [s.name for s in schema.inputs]
|
||||
assert names == ["upstream", "seed", "modules"]
|
||||
|
||||
upstream, seed, modules = schema.inputs
|
||||
assert upstream.type_name == "PIPELINE_CONTEXT"
|
||||
assert upstream.optional is True
|
||||
assert seed.type_name == "INT"
|
||||
assert modules.type_name == "WP_CONTEXT_MODULES"
|
||||
assert modules.socketless is True
|
||||
|
||||
def test_schema_outputs(self):
|
||||
schema = WPContext.define_schema()
|
||||
assert len(schema.outputs) == 1
|
||||
assert schema.outputs[0].type_name == "PIPELINE_CONTEXT"
|
||||
|
||||
|
||||
class TestWPContextExecute:
|
||||
def _modules_json(self, entries):
|
||||
return json.dumps({
|
||||
"version": 1,
|
||||
"modules": [{
|
||||
"id": "m1",
|
||||
"type": "fixed_values",
|
||||
"entries": entries,
|
||||
}],
|
||||
})
|
||||
|
||||
def test_root_context_no_upstream(self):
|
||||
payload = self._modules_json([
|
||||
{"variable_name": "style", "value": "photo"},
|
||||
])
|
||||
out = WPContext.execute(seed=0, modules=payload, upstream=None)
|
||||
|
||||
assert len(out.values) == 1
|
||||
result = out.values[0]
|
||||
assert isinstance(result, ContextPayload)
|
||||
assert result.context == {"style": "photo"}
|
||||
assert result.debug["node_seed"] == 0
|
||||
assert len(result.debug["trace"]) == 1
|
||||
assert result.debug["upstream"] == {}
|
||||
|
||||
def test_with_upstream_inherits_vars(self):
|
||||
upstream = ContextPayload(
|
||||
context={"subject": "knight"},
|
||||
debug={"node_seed": 7, "trace": [{"id": "up"}]},
|
||||
)
|
||||
payload = self._modules_json([
|
||||
{"variable_name": "style", "value": "painted"},
|
||||
])
|
||||
out = WPContext.execute(seed=42, modules=payload, upstream=upstream)
|
||||
result = out.values[0]
|
||||
|
||||
assert result.context == {"subject": "knight", "style": "painted"}
|
||||
assert result.debug["node_seed"] == 42
|
||||
assert result.debug["upstream"] == {
|
||||
"node_seed": 7,
|
||||
"trace": [{"id": "up"}],
|
||||
}
|
||||
assert [entry["id"] for entry in result.debug["trace"]] == ["m1"]
|
||||
|
||||
def test_empty_modules_still_emits_payload(self):
|
||||
out = WPContext.execute(
|
||||
seed=0,
|
||||
modules='{"version":1,"modules":[]}',
|
||||
upstream=None,
|
||||
)
|
||||
result = out.values[0]
|
||||
assert result.context == {}
|
||||
assert result.debug["trace"] == []
|
||||
|
||||
def test_malformed_modules_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
WPContext.execute(seed=0, modules="{not json", upstream=None)
|
||||
|
||||
def test_downstream_overwrite_flows(self):
|
||||
first = WPContext.execute(
|
||||
seed=1,
|
||||
modules=json.dumps({
|
||||
"version": 1,
|
||||
"modules": [{
|
||||
"id": "a",
|
||||
"type": "fixed_values",
|
||||
"entries": [{"variable_name": "style", "value": "photo"}],
|
||||
}],
|
||||
}),
|
||||
upstream=None,
|
||||
).values[0]
|
||||
|
||||
second = WPContext.execute(
|
||||
seed=2,
|
||||
modules=json.dumps({
|
||||
"version": 1,
|
||||
"modules": [{
|
||||
"id": "b",
|
||||
"type": "fixed_values",
|
||||
"entries": [{"variable_name": "style", "value": "painted"}],
|
||||
}],
|
||||
}),
|
||||
upstream=first,
|
||||
).values[0]
|
||||
|
||||
assert second.context == {"style": "painted"}
|
||||
assert second.debug["upstream"]["node_seed"] == 1
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Unit tests for nodes.debug_node.WPDebug."""
|
||||
|
||||
import json
|
||||
|
||||
from wp_nodes.debug_node import WPDebug
|
||||
from wp_nodes.types import ContextPayload
|
||||
|
||||
|
||||
class TestWPDebugSchema:
|
||||
def test_schema_basic(self):
|
||||
schema = WPDebug.define_schema()
|
||||
assert schema.node_id == "WP_Debug"
|
||||
assert schema.display_name == "WP Debug"
|
||||
assert schema.category == "wildcard-pipeline"
|
||||
assert schema.is_output_node is True
|
||||
assert schema.not_idempotent is True
|
||||
|
||||
def test_inputs(self):
|
||||
schema = WPDebug.define_schema()
|
||||
names = [s.name for s in schema.inputs]
|
||||
assert names == ["context", "viewer"]
|
||||
|
||||
ctx_in, viewer_in = schema.inputs
|
||||
assert ctx_in.type_name == "PIPELINE_CONTEXT"
|
||||
assert viewer_in.type_name == "WP_DEBUG_VIEWER"
|
||||
assert viewer_in.socketless is True
|
||||
|
||||
def test_no_outputs(self):
|
||||
schema = WPDebug.define_schema()
|
||||
assert schema.outputs == []
|
||||
|
||||
|
||||
class TestWPDebugExecute:
|
||||
def _parse_snapshot(self, out):
|
||||
snapshot_list = out.ui["wp_debug_snapshot"]
|
||||
assert len(snapshot_list) == 1
|
||||
return json.loads(snapshot_list[0])
|
||||
|
||||
def test_emits_ui_payload(self):
|
||||
payload = ContextPayload(
|
||||
context={"style": "photo"},
|
||||
debug={"node_seed": 42, "trace": []},
|
||||
)
|
||||
out = WPDebug.execute(context=payload, viewer=None)
|
||||
|
||||
assert out.ui is not None
|
||||
assert "wp_debug_snapshot" in out.ui
|
||||
snapshot = self._parse_snapshot(out)
|
||||
assert snapshot == {
|
||||
"context": {"style": "photo"},
|
||||
"debug": {"node_seed": 42, "trace": []},
|
||||
}
|
||||
|
||||
def test_complex_values_serialized_via_str(self):
|
||||
class Weird:
|
||||
def __str__(self): return "weird"
|
||||
|
||||
payload = ContextPayload(
|
||||
context={"obj": Weird()},
|
||||
debug={},
|
||||
)
|
||||
out = WPDebug.execute(context=payload, viewer=None)
|
||||
snapshot = self._parse_snapshot(out)
|
||||
assert snapshot["context"]["obj"] == "weird"
|
||||
|
||||
def test_empty_context(self):
|
||||
payload = ContextPayload()
|
||||
out = WPDebug.execute(context=payload, viewer=None)
|
||||
snapshot = self._parse_snapshot(out)
|
||||
assert snapshot == {"context": {}, "debug": {}}
|
||||
@@ -0,0 +1,72 @@
|
||||
"""End-to-end node integration — simulate a small ComfyUI workflow."""
|
||||
|
||||
import json
|
||||
|
||||
from wp_nodes.assembler_node import WPPromptAssembler
|
||||
from wp_nodes.context_node import WPContext
|
||||
from wp_nodes.debug_node import WPDebug
|
||||
from wp_nodes.types import ContextPayload
|
||||
|
||||
|
||||
def _mods(entries):
|
||||
return json.dumps({
|
||||
"version": 1,
|
||||
"modules": [{
|
||||
"id": "auto",
|
||||
"type": "fixed_values",
|
||||
"entries": entries,
|
||||
}],
|
||||
})
|
||||
|
||||
|
||||
def test_chain_of_two_context_nodes_into_assembler():
|
||||
# Upstream context sets style + subject.
|
||||
upstream = WPContext.execute(
|
||||
seed=1,
|
||||
modules=_mods([
|
||||
{"variable_name": "style", "value": "photoreal"},
|
||||
{"variable_name": "subject", "value": "knight"},
|
||||
]),
|
||||
upstream=None,
|
||||
).values[0]
|
||||
|
||||
# Downstream context adds environment vars + overrides style.
|
||||
downstream = WPContext.execute(
|
||||
seed=2,
|
||||
modules=_mods([
|
||||
{"variable_name": "style", "value": "painted"},
|
||||
{"variable_name": "location", "value": "forest"},
|
||||
]),
|
||||
upstream=upstream,
|
||||
).values[0]
|
||||
|
||||
assert isinstance(downstream, ContextPayload)
|
||||
assert downstream.context == {
|
||||
"subject": "knight",
|
||||
"style": "painted",
|
||||
"location": "forest",
|
||||
}
|
||||
|
||||
# Assembler renders the final template.
|
||||
assembled = WPPromptAssembler.execute(
|
||||
context=downstream,
|
||||
template="A $style $subject in the $location",
|
||||
)
|
||||
assert assembled.values == ("A painted knight in the forest",)
|
||||
|
||||
|
||||
def test_debug_node_snapshots_downstream_context():
|
||||
upstream = WPContext.execute(
|
||||
seed=7,
|
||||
modules=_mods([
|
||||
{"variable_name": "style", "value": "photoreal"},
|
||||
]),
|
||||
upstream=None,
|
||||
).values[0]
|
||||
|
||||
out = WPDebug.execute(context=upstream, viewer=None)
|
||||
snapshot = json.loads(out.ui["wp_debug_snapshot"][0])
|
||||
|
||||
assert snapshot["context"] == {"style": "photoreal"}
|
||||
assert snapshot["debug"]["node_seed"] == 7
|
||||
assert len(snapshot["debug"]["trace"]) == 1
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Unit tests for nodes.types."""
|
||||
|
||||
import json
|
||||
from dataclasses import FrozenInstanceError
|
||||
|
||||
import pytest
|
||||
|
||||
from engine.modules import FixedValueEntry, FixedValueModule
|
||||
from wp_nodes.types import (
|
||||
ContextPayload,
|
||||
build_payload,
|
||||
deserialize_modules,
|
||||
)
|
||||
|
||||
|
||||
class TestContextPayload:
|
||||
def test_defaults(self):
|
||||
payload = ContextPayload()
|
||||
assert payload.context == {}
|
||||
assert payload.debug == {}
|
||||
|
||||
def test_frozen(self):
|
||||
payload = ContextPayload(context={"a": 1}, debug={"b": 2})
|
||||
with pytest.raises(FrozenInstanceError):
|
||||
payload.context = {} # type: ignore[misc]
|
||||
|
||||
|
||||
class TestDeserializeModules:
|
||||
def test_empty_list(self):
|
||||
assert deserialize_modules('{"version":1,"modules":[]}') == []
|
||||
|
||||
def test_single_fixed_values(self):
|
||||
payload = json.dumps({
|
||||
"version": 1,
|
||||
"modules": [{
|
||||
"id": "abcd1234",
|
||||
"type": "fixed_values",
|
||||
"entries": [{"variable_name": "style", "value": "photo"}],
|
||||
}],
|
||||
})
|
||||
modules = deserialize_modules(payload)
|
||||
assert len(modules) == 1
|
||||
assert isinstance(modules[0], FixedValueModule)
|
||||
assert modules[0].entries == [FixedValueEntry("style", "photo")]
|
||||
|
||||
def test_missing_modules_key_treated_as_empty(self):
|
||||
assert deserialize_modules('{"version":1}') == []
|
||||
|
||||
def test_invalid_json_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
deserialize_modules("{not valid json")
|
||||
|
||||
def test_non_dict_root_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
deserialize_modules("[]")
|
||||
|
||||
|
||||
class TestBuildPayload:
|
||||
def test_strips_internals(self):
|
||||
ctx = {
|
||||
"style": "photo",
|
||||
"__wp_node_seed__": 42,
|
||||
"__wp_trace__": [{"id": "m1"}],
|
||||
"__wp_internal_flags__": {},
|
||||
}
|
||||
payload = build_payload(ctx, upstream_debug={}, seed=42)
|
||||
assert payload.context == {"style": "photo"}
|
||||
|
||||
def test_debug_contains_seed_and_trace(self):
|
||||
ctx = {"style": "photo", "__wp_trace__": [{"id": "m1"}]}
|
||||
payload = build_payload(ctx, upstream_debug={}, seed=42)
|
||||
assert payload.debug["node_seed"] == 42
|
||||
assert payload.debug["trace"] == [{"id": "m1"}]
|
||||
|
||||
def test_upstream_debug_preserved(self):
|
||||
ctx = {"__wp_trace__": []}
|
||||
payload = build_payload(
|
||||
ctx, upstream_debug={"node_seed": 1, "trace": [{"id": "prev"}]}, seed=2
|
||||
)
|
||||
assert payload.debug["upstream"] == {
|
||||
"node_seed": 1,
|
||||
"trace": [{"id": "prev"}],
|
||||
}
|
||||
|
||||
def test_missing_trace_defaults_to_empty_list(self):
|
||||
payload = build_payload({}, upstream_debug={}, seed=0)
|
||||
assert payload.debug["trace"] == []
|
||||
@@ -11,8 +11,19 @@ class TestResolveVariables:
|
||||
def test_basic_substitution(self):
|
||||
assert resolve_variables("$name", {"name": "Alice"}) == "Alice"
|
||||
|
||||
def test_missing_var_left_as_is(self):
|
||||
assert resolve_variables("$unknown", {}) == "$unknown"
|
||||
def test_missing_var_dropped(self):
|
||||
# Missing vars resolve to empty string so the prompt sent to the
|
||||
# model never contains literal "$unknown" tokens.
|
||||
assert resolve_variables("$unknown", {}) == ""
|
||||
|
||||
def test_missing_var_collapses_surrounding_whitespace(self):
|
||||
assert resolve_variables("a $missing b", {}) == "a b"
|
||||
|
||||
def test_missing_var_collapses_comma_gap(self):
|
||||
assert resolve_variables("photo, $missing, cinematic", {}) == "photo, cinematic"
|
||||
|
||||
def test_missing_var_trims_trailing_punct(self):
|
||||
assert resolve_variables("portrait of $subject.", {}) == "portrait of."
|
||||
|
||||
def test_dollar_escape(self):
|
||||
assert resolve_variables("$$name", {"name": "Alice"}) == "$name"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { defineConfig } from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import cssInjectedByJsPlugin from "vite-plugin-css-injected-by-js";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
// Two build targets controlled by --mode flag:
|
||||
@@ -25,7 +26,17 @@ export default defineConfig(({ mode }) => {
|
||||
if (isExtension) {
|
||||
return {
|
||||
...common,
|
||||
// cssInjectedByJsPlugin with relativeCSSInjection inlines each chunk's
|
||||
// CSS into that chunk's JS so dynamic imports auto-inject styles at
|
||||
// runtime. Vite library mode emits CSS files but never <link>s them, and
|
||||
// ComfyUI loads main.js as a bare ES module — no auto-loading. See
|
||||
// CLAUDE.md "Frontend overview" for the why. Entry chunk stays CSS-free
|
||||
// because all our styles live in lazy widget chunks.
|
||||
plugins: [vue(), cssInjectedByJsPlugin({ relativeCSSInjection: true })],
|
||||
build: {
|
||||
// ES2022 needed for top-level `await` in main.ts (preloads widget chunks
|
||||
// before registerExtension so getCustomWidgets factories can run sync).
|
||||
target: "es2022",
|
||||
lib: {
|
||||
entry: resolve(__dirname, "src/main.ts"),
|
||||
formats: ["es"] as const,
|
||||
@@ -38,6 +49,9 @@ export default defineConfig(({ mode }) => {
|
||||
entryFileNames: "main.js",
|
||||
chunkFileNames: "assets/[name]-[hash].js",
|
||||
assetFileNames: "assets/[name]-[hash][extname]",
|
||||
// Rewrite the alias to ComfyUI's runtime URL so the browser can
|
||||
// resolve it. ComfyUI serves /scripts/app.js as the live app.
|
||||
paths: { "#comfyui/app": "/scripts/app.js" },
|
||||
},
|
||||
},
|
||||
minify: "esbuild",
|
||||
@@ -47,8 +61,10 @@ export default defineConfig(({ mode }) => {
|
||||
emptyOutDir: true,
|
||||
},
|
||||
test: {
|
||||
environment: "node",
|
||||
environment: "jsdom",
|
||||
globals: true,
|
||||
include: ["src/**/*.test.ts"],
|
||||
setupFiles: ["./src/test-setup.ts"],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": resolve(__dirname, "./src"),
|
||||
vue: "vue/dist/vue.runtime.esm-bundler.js",
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
globals: true,
|
||||
include: ["src/**/*.test.ts"],
|
||||
setupFiles: ["./src/test-setup.ts"],
|
||||
coverage: {
|
||||
// V8 coverage works against the Vite test pipeline without a separate
|
||||
// transform — the runtime emits coverage data the reporter reads.
|
||||
provider: "v8",
|
||||
reporter: ["text", "html", "json-summary"],
|
||||
// Coverage scope: testable surface only. Files mounted against
|
||||
// ComfyUI runtime APIs (window.LGraphBadge, app.extensionManager,
|
||||
// app.graph, addDOMWidget, matchMedia listeners, Vue Teleport) can't
|
||||
// be unit-tested under jsdom because those APIs don't exist there —
|
||||
// they'd need integration tests against a live ComfyUI instance,
|
||||
// which is its own batch. Excluding them keeps thresholds anchored
|
||||
// to genuinely testable logic (scanners, traversal, helpers, SFCs).
|
||||
include: ["src/**/*.{ts,vue}"],
|
||||
exclude: [
|
||||
"src/**/*.test.ts",
|
||||
"src/test-setup.ts",
|
||||
"src/typings/**",
|
||||
// Entry glue + manager stub — top-level await, app singleton.
|
||||
"src/main.ts",
|
||||
"src/manager.ts",
|
||||
// Widget mount glue — calls createDomWidgetHost which needs
|
||||
// node.addDOMWidget at runtime.
|
||||
"src/widgets/context.ts",
|
||||
"src/widgets/debug.ts",
|
||||
"src/widgets/assembler.ts",
|
||||
// Extension hooks tied to ComfyUI globals + DOM events.
|
||||
"src/extension/about-badges.ts",
|
||||
"src/extension/fonts.ts",
|
||||
"src/extension/graph-events.ts",
|
||||
"src/extension/reactive.ts",
|
||||
"src/extension/settings.ts",
|
||||
"src/extension/subgraph-badge.ts",
|
||||
// Toast singleton uses Teleport + ResizeObserver + canvas rect.
|
||||
"src/components/shared/Toast.vue",
|
||||
],
|
||||
// Anchored to the measured baseline of the testable surface.
|
||||
// Tighten alongside new tests; loosen only with explicit PR
|
||||
// justification. Functions stays low because every Vue SFC has
|
||||
// helper functions only invoked under specific user-interaction
|
||||
// paths the existing tests don't cover yet.
|
||||
thresholds: {
|
||||
lines: 60,
|
||||
functions: 40,
|
||||
branches: 65,
|
||||
statements: 60,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
"""ComfyUI V3 node wrappers around the pure-Python engine."""
|
||||
@@ -0,0 +1,32 @@
|
||||
"""WP_PromptAssembler — fills $var placeholders from a PipelineContext."""
|
||||
|
||||
from comfy_api.latest import io # pyright: ignore[reportMissingImports]
|
||||
|
||||
from engine.template import resolve_variables
|
||||
from wp_nodes.types import PipelineContext
|
||||
|
||||
|
||||
class WPPromptAssembler(io.ComfyNode):
|
||||
"""Template-fills $var placeholders using the incoming PipelineContext."""
|
||||
|
||||
@classmethod
|
||||
def define_schema(cls):
|
||||
return io.Schema(
|
||||
node_id="WP_PromptAssembler",
|
||||
display_name="WP Prompt Assembler",
|
||||
category="wildcard-pipeline",
|
||||
inputs=[
|
||||
PipelineContext.Input("context"),
|
||||
io.String.Input(
|
||||
"template",
|
||||
multiline=True,
|
||||
default="A $style portrait of $subject",
|
||||
),
|
||||
],
|
||||
outputs=[io.String.Output("prompt")],
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def execute(cls, context, template):
|
||||
resolved = resolve_variables(template, context.context)
|
||||
return io.NodeOutput(resolved)
|
||||
@@ -0,0 +1,48 @@
|
||||
"""WP_Context — runs a module list and emits a PipelineContext payload."""
|
||||
|
||||
from comfy_api.latest import io # pyright: ignore[reportMissingImports]
|
||||
|
||||
from engine.pipeline import PipelineEngine
|
||||
from wp_nodes.types import (
|
||||
ContextModulesInput,
|
||||
PipelineContext,
|
||||
build_payload,
|
||||
deserialize_modules,
|
||||
)
|
||||
|
||||
|
||||
class WPContext(io.ComfyNode):
|
||||
"""Context node: runs an ordered list of modules, emits PipelineContext."""
|
||||
|
||||
@classmethod
|
||||
def define_schema(cls):
|
||||
return io.Schema(
|
||||
node_id="WP_Context",
|
||||
display_name="WP Context",
|
||||
category="wildcard-pipeline",
|
||||
inputs=[
|
||||
PipelineContext.Input("upstream", optional=True),
|
||||
io.Int.Input(
|
||||
"seed",
|
||||
default=0,
|
||||
min=0,
|
||||
max=0xFFFFFFFFFFFFFFFF,
|
||||
control_after_generate=True,
|
||||
),
|
||||
ContextModulesInput.Input("modules", socketless=True),
|
||||
],
|
||||
outputs=[PipelineContext.Output("context")],
|
||||
not_idempotent=True,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def execute(cls, seed, modules, upstream=None):
|
||||
upstream_ctx: dict = upstream.context if upstream is not None else {}
|
||||
upstream_debug: dict = upstream.debug if upstream is not None else {}
|
||||
|
||||
module_list = deserialize_modules(modules)
|
||||
ctx: dict = dict(upstream_ctx)
|
||||
ctx = PipelineEngine().run(module_list, ctx=ctx, seed=int(seed))
|
||||
|
||||
payload = build_payload(ctx, upstream_debug=upstream_debug, seed=int(seed))
|
||||
return io.NodeOutput(payload)
|
||||
@@ -0,0 +1,36 @@
|
||||
"""WP_Debug — terminal node that emits the PipelineContext for UI inspection."""
|
||||
|
||||
import json
|
||||
|
||||
from comfy_api.latest import io # pyright: ignore[reportMissingImports]
|
||||
|
||||
from wp_nodes.types import DebugViewerInput, PipelineContext
|
||||
|
||||
|
||||
class WPDebug(io.ComfyNode):
|
||||
"""Inspect the context at any point in the chain. Terminal output node."""
|
||||
|
||||
@classmethod
|
||||
def define_schema(cls):
|
||||
return io.Schema(
|
||||
node_id="WP_Debug",
|
||||
display_name="WP Debug",
|
||||
category="wildcard-pipeline",
|
||||
inputs=[
|
||||
PipelineContext.Input("context"),
|
||||
DebugViewerInput.Input("viewer", socketless=True),
|
||||
],
|
||||
outputs=[],
|
||||
is_output_node=True,
|
||||
not_idempotent=True,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def execute(cls, context, viewer):
|
||||
del viewer # accepted for widget binding parity; no runtime use
|
||||
snapshot = json.dumps(
|
||||
{"context": context.context, "debug": context.debug},
|
||||
default=str,
|
||||
indent=2,
|
||||
)
|
||||
return io.NodeOutput(ui={"wp_debug_snapshot": [snapshot]})
|
||||
@@ -0,0 +1,140 @@
|
||||
"""Custom ComfyUI socket type ``PIPELINE_CONTEXT`` and widget-only types."""
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from comfy_api.latest import io # pyright: ignore[reportMissingImports]
|
||||
from comfy_api.latest._io import ( # pyright: ignore[reportMissingImports]
|
||||
ComfyTypeIO,
|
||||
comfytype,
|
||||
)
|
||||
|
||||
from engine.context import strip_internals
|
||||
from engine.modules import Module, module_from_dict
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ContextPayload:
|
||||
"""Value carried across ``PIPELINE_CONTEXT`` sockets.
|
||||
|
||||
``context`` holds user-facing variables (internals stripped). ``debug``
|
||||
carries run metadata (upstream snapshot, seed, trace) for the debug node.
|
||||
"""
|
||||
|
||||
context: dict[str, Any] = field(default_factory=dict)
|
||||
debug: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@comfytype(io_type="PIPELINE_CONTEXT")
|
||||
class PipelineContext(ComfyTypeIO):
|
||||
"""Socket type carrying ContextPayload across nodes."""
|
||||
|
||||
Type = ContextPayload
|
||||
|
||||
|
||||
@comfytype(io_type="WP_CONTEXT_MODULES")
|
||||
class ContextModulesInput:
|
||||
"""Widget-only custom type — frontend binds ``getCustomWidgets["WP_CONTEXT_MODULES"]``."""
|
||||
|
||||
Type = str
|
||||
|
||||
class Input(io.WidgetInput):
|
||||
def __init__(
|
||||
self,
|
||||
id: str,
|
||||
display_name: str | None = None,
|
||||
optional: bool = False,
|
||||
tooltip: str | None = None,
|
||||
lazy: bool | None = None,
|
||||
default: str | None = None,
|
||||
socketless: bool | None = None,
|
||||
extra_dict: dict[str, Any] | None = None,
|
||||
raw_link: bool | None = None,
|
||||
advanced: bool | None = None,
|
||||
):
|
||||
super().__init__(
|
||||
id,
|
||||
display_name,
|
||||
optional,
|
||||
tooltip,
|
||||
lazy,
|
||||
default,
|
||||
socketless,
|
||||
None, # widget_type
|
||||
None, # force_input
|
||||
extra_dict,
|
||||
raw_link,
|
||||
advanced,
|
||||
)
|
||||
|
||||
|
||||
@comfytype(io_type="WP_DEBUG_VIEWER")
|
||||
class DebugViewerInput:
|
||||
"""Widget-only custom type — frontend binds ``getCustomWidgets["WP_DEBUG_VIEWER"]``."""
|
||||
|
||||
Type = str
|
||||
|
||||
class Input(io.WidgetInput):
|
||||
def __init__(
|
||||
self,
|
||||
id: str,
|
||||
display_name: str | None = None,
|
||||
optional: bool = False,
|
||||
tooltip: str | None = None,
|
||||
lazy: bool | None = None,
|
||||
default: str | None = None,
|
||||
socketless: bool | None = None,
|
||||
extra_dict: dict[str, Any] | None = None,
|
||||
raw_link: bool | None = None,
|
||||
advanced: bool | None = None,
|
||||
):
|
||||
super().__init__(
|
||||
id,
|
||||
display_name,
|
||||
optional,
|
||||
tooltip,
|
||||
lazy,
|
||||
default,
|
||||
socketless,
|
||||
None,
|
||||
None,
|
||||
extra_dict,
|
||||
raw_link,
|
||||
advanced,
|
||||
)
|
||||
|
||||
|
||||
def deserialize_modules(payload: str) -> list[Module]:
|
||||
"""Parse the widget JSON payload into a list of engine module dataclasses.
|
||||
|
||||
Raises ``ValueError`` on malformed JSON or a non-dict root.
|
||||
"""
|
||||
try:
|
||||
data = json.loads(payload)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError(f"Invalid modules JSON: {exc.msg}") from exc
|
||||
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError(
|
||||
f"Modules payload must be a JSON object, got {type(data).__name__}"
|
||||
)
|
||||
|
||||
raw_modules = data.get("modules") or []
|
||||
return [module_from_dict(entry) for entry in raw_modules]
|
||||
|
||||
|
||||
def build_payload(
|
||||
ctx: dict[str, Any],
|
||||
upstream_debug: dict[str, Any],
|
||||
seed: int,
|
||||
) -> ContextPayload:
|
||||
"""Construct the socket-boundary payload. Strips internals from context."""
|
||||
return ContextPayload(
|
||||
context=strip_internals(ctx),
|
||||
debug={
|
||||
"upstream": upstream_debug,
|
||||
"node_seed": seed,
|
||||
"trace": ctx.get("__wp_trace__", []),
|
||||
},
|
||||
)
|
||||