Compare commits

..

17 Commits

Author SHA1 Message Date
GitHub Action
63929f1802 [automated] Apply ESLint and Oxfmt fixes 2026-03-12 09:37:54 +00:00
bymyself
71fccf3d8b feat: add workflow execution state indicators to tabs
- Add WorkflowExecutionResult type and lastExecutionResultByWorkflowId to executionStore
- Create useWorkflowExecutionState composable for tracking execution state
- Create WorkflowExecutionIndicator.vue with running/completed/error icons
- Integrate indicator into WorkflowTab.vue with 5s auto-clear for completed
- Add WorkflowExecutionBadge.vue for sidebar display
- Add i18n accessibility labels

Amp-Thread-ID: https://ampcode.com/threads/T-019c2557-1ba9-726a-8e93-978864992fd4
2026-03-12 02:34:50 -07:00
Deep Mehta
7c2c59b9fb fix: center ComfyUI logo in sidebar menu button with chevron (#9722)
## Summary

Center the ComfyUI logo in the sidebar menu button after chevron icon
was added, and add padding to floating sidebar item groups.

## Changes

Before / after:
<img width="127" height="417" alt="image"
src="https://github.com/user-attachments/assets/aa327fd9-5550-49aa-b28d-a90435e9d1bc"
/>


- **What**: Use negative margin on chevron icon so it doesn't affect
logo centering. Add `p-0.5` padding to floating sidebar item groups.

## Review Focus

Visual alignment of the logo in both floating and connected sidebar
modes.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9722-fix-center-ComfyUI-logo-in-sidebar-menu-button-with-chevron-31f6d73d3650811a9392cda3070310f9)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: GitHub Action <action@github.com>
2026-03-12 01:10:26 -07:00
jaeone94
2f7f3c4e56 [feat] Surface missing models in Errors tab (Cloud) (#9743)
## Summary
When a workflow is loaded with missing models, users currently have no
way to identify or resolve them from within the UI. This PR adds a full
missing-model detection and resolution pipeline that surfaces missing
models in the Errors tab, allowing users to install or import them
without leaving the editor.

## Changes

### Missing Model Detection
- Scan all COMBO widgets across root graph and subgraphs for model-like
filenames during workflow load
- Enrich candidates with embedded workflow metadata (url, hash,
directory) when available
- Verify asset-supported candidates against the asset store
asynchronously to confirm installation status
- Propagate missing model state to `executionErrorStore` alongside
existing node/prompt errors

### Errors Tab UI — Model Resolution
- Group missing models by directory (e.g. `checkpoints`, `loras`, `vae`)
with collapsible category cards
- Each model row displays:
  - Model name with copy-to-clipboard button
  - Expandable list of referencing nodes with locate-on-canvas button
- **Library selector**: Pick an alternative from the user's existing
models to substitute the missing model with one click
- **URL import**: Paste a Civitai or HuggingFace URL to import a model
directly; debounced metadata fetch shows filename and file size before
confirming; type-mismatch warnings (e.g. importing a LoRA into
checkpoints directory) are surfaced with an "Import Anyway" option
- **Upgrade prompt**: In cloud environment, free-tier subscribers are
shown an upgrade modal when attempting URL import
- Separate "Import Not Supported" section for custom-node models that
cannot be auto-resolved
- Status card with live download progress, completion, failure, and
category-mismatch states

### Canvas Integration
- Highlight nodes and widgets that reference missing models with error
indicators
- Propagate missing-model badges through subgraph containers so issues
are visible at every graph level

### Code Cleanup
- Simplify `surfacePendingWarnings` in workflowService, remove stale
widget-detected model merging logic
- Add `flattenWorkflowNodes` utility to workflowSchema for traversing
nested subgraph structures
- Extract `MissingModelUrlInput`, `MissingModelLibrarySelect`,
`MissingModelStatusCard` as focused single-responsibility components

## Testing
- Unit tests for scan pipeline (`missingModelScan.test.ts`): enrichment,
skip-installed, subgraph flattening
- Unit tests for store (`missingModelStore.test.ts`): state management,
removal helpers
- Unit tests for interactions (`useMissingModelInteractions.test.ts`):
combo select, URL input, import flow, library confirm
- Component tests for `MissingModelCard` and error grouping
(`useErrorGroups.test.ts`)
- Updated `workflowService.test.ts` and `workflowSchema.test.ts` for new
logic

## Review Focus
- Missing model scan + enrichment pipeline in `missingModelScan.ts`
- Interaction composable `useMissingModelInteractions.ts` — URL metadata
fetch, library install, upload fallback
- Store integration and canvas-level error propagation

## Screenshots 


https://github.com/user-attachments/assets/339a6d5b-93a3-43cd-98dd-0fb00681b66f



┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9743-feat-Surface-missing-models-in-Errors-tab-Cloud-3206d73d365081678326d3a16c2165d8)
by [Unito](https://www.unito.io)
2026-03-12 16:21:54 +09:00
Christian Byrne
4c00d39ade fix: app mode widgets disappear after hard refresh (#9621)
## Summary

Fix all app mode widgets (including seed) disappearing after hard
refresh due to a race condition in `pruneLinearData` and a missing
reactivity dependency in `mappedSelections`.

## Changes

- **What**: Guard `pruneLinearData` with `!ChangeTracker.isLoadingGraph`
so inputs are preserved while `rootGraph.configure()` hasn't populated
nodes yet. Add `graphNodes` dependency to `mappedSelections` computed in
`LinearControls.vue` so it re-evaluates when the graph finishes
configuring.

## Review Focus

The core fix is a one-line guard change: `app.rootGraph &&
!ChangeTracker.isLoadingGraph` instead of just `app.rootGraph`. The
previous guard failed because `rootGraph` exists as an empty graph
during loading — `resolveNode()` returns `undefined` for all nodes and
everything gets filtered out.

Fixes COM-16193

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9621-fix-app-mode-widgets-disappear-after-hard-refresh-31d6d73d3650811193f5e1bc8f3c15c8)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-03-11 23:54:27 -07:00
Dante
f1fc5fa9b3 feat: add DropZone Storybook coverage for file upload states (#9690)
## Summary
- align the linear-mode `DropZone` upload indicator with the Figma file
upload states
- add a co-located Storybook story for the default and hover variants
- add a `forceHovered` preview prop so Storybook can render the hover
state deterministically

## Validation
- `pnpm typecheck` (run in the original workspace with dependencies
installed)
- `pnpm lint` (passes with one pre-existing warning in
`src/lib/litegraph/src/ContextMenu.ts`)
- Storybook smoke check is currently blocked by an existing workspace
issue: `vite-plugin-inspect` fails with `Can not found environment
context for client`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9690-feat-add-DropZone-Storybook-coverage-for-file-upload-states-31f6d73d365081ae9eabdde6b5915f26)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: GitHub Action <action@github.com>
2026-03-11 23:39:08 -07:00
Benjamin Lu
c111fb7758 fix: rename docked queue panel setting (#9620)
## Summary

Rename the `Comfy.Queue.QPOV2` settings label to `Docked job
history/queue panel` to improve searchability/discoverability in the
settings UI.

## Changes

- **What**: Updated the visible setting name in the core settings
definition and the English locale string.

## Review Focus

The change is intentionally limited to the display label. The persisted
setting key remains `Comfy.Queue.QPOV2`, so existing user configuration
is preserved.

## Screenshots (if applicable)

Not applicable.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9620-fix-rename-docked-queue-panel-setting-31d6d73d36508189a2d1d3a621739a22)
by [Unito](https://www.unito.io)
2026-03-11 23:35:16 -07:00
Kelly Yang
65655ba35f fix: update WidgetLayoutField border styling (#9456)
## Summary

This makes the focus ring only appear on keyboard navigation (Tab), not
on mouse click for widgets like toggle switches, while text inputs still
show the ring on click since browsers apply ` :focus-visible` to them.

## Mozilla Standard

Legacy `focus-within` triggers a highlight ring on every mouse click,
creating unnecessary visual noise during canvas navigation. Following
[MDN
standards](https://developer.mozilla.org/en-US/docs/Web/CSS/:focus-visible),
`:focus-visible` only triggers the highlight when the browser determines
a visual cue is needed (e.g., keyboard navigation).

Using the `:has()` relational selector allows the container to react to
the state of its children natively in CSS. Removes the need for Vue
event listeners or complex state bubbling to highlight the field border.
This reduces JavaScript overhead and simplifies component logic. FYI
[MDN
:has()](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Selectors/:has).

Reordered Tailwind classes to move `transition-all` to the end,
following official best practices. Groups layout/shape first, followed
by interaction states, and finally animations. This improves code
readability and maintainability.


## Screenshots 

before

<img width="558" height="252" alt="12efd5721fb792a7e2dab7e022c2bed6"
src="https://github.com/user-attachments/assets/f881fe13-9f4f-40fd-a8cc-f438b1ba4bde"
/>
 
after
<img width="538" height="235" alt="e5ffec0a34d3b237c4fca9818ec598dd"
src="https://github.com/user-attachments/assets/5ada4112-64bd-48a4-9e9c-b59de6984370"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9456-fix-update-WidgetLayoutField-border-styling-31b6d73d36508193a31ed02bfdef414f)
by [Unito](https://www.unito.io)

Co-authored-by: Terry Jia <terryjia88@gmail.com>
2026-03-11 23:34:40 -07:00
Terry Jia
852d77159e fix: prevent WebGLRenderer leak in app mode 3D preview (#9766)
## Summary
Reuse the Load3d instance when switching between 3D results in app mode
instead of creating a new WebGLRenderer each time. Add onUnmounted
cleanup to Preview3d to release WebGL resources when the component is
removed.

## Screenshots (if applicable)
before


https://github.com/user-attachments/assets/c9818d10-941f-4994-9b48-2710c88454e7



after


https://github.com/user-attachments/assets/36361763-6800-4bc8-8089-14d64b7fcd16

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9766-fix-prevent-WebGLRenderer-leak-in-app-mode-3D-preview-3216d73d365081e19305d7255b71bc49)
by [Unito](https://www.unito.io)
2026-03-12 01:12:20 -04:00
Jin Yi
b61029b9da fix: show correct empty state on Missing tab instead of misleading registry error (#9640)
## Summary

Show tab-specific empty state messages instead of a misleading registry
connection error on tabs that don't depend on the Manager API.

## Changes

- **What**: Scoped `comfyManagerStore.error` to only affect tabs that
actually use the Manager API (AllInstalled, UpdateAvailable,
Conflicting). Missing and Workflow tabs use the registry directly, so
the Manager API error is irrelevant to them. Previously, any prior
Manager API failure would cause `"Error connecting to the Comfy Node
Registry"` to appear on the Missing tab even when there are simply no
missing nodes.

## Review Focus

The `isManagerErrorRelevant` computed only shows the error for Manager
API-dependent tabs. Verify that AllInstalled/UpdateAvailable/Conflicting
still correctly show the error when Manager API fails.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9640-fix-show-correct-empty-state-on-Missing-tab-instead-of-misleading-registry-error-31e6d73d365081868da1d226a9bb5fdc)
by [Unito](https://www.unito.io)
2026-03-12 13:40:29 +09:00
Christian Byrne
0a62ea0b2c fix: call checkState after image input changes for proper undo tracking (#9623)
## Summary

Image input changes (dropdown selection and file upload) in app/linear
mode did not create their own undo entries, causing undo to skip or
bundle image changes with subsequent actions.

## Changes

- **What**: Add explicit `checkState()` calls in
`WidgetSelectDropdown.vue` after `modelValue` is set in
`updateSelectedItems()` (dropdown selection) and `handleFilesUpdate()`
(file upload), ensuring each image change gets its own undo entry.

## Review Focus

The fix is intentionally scoped to `WidgetSelectDropdown` rather than
the generic `updateHandler` in `NodeWidgets.vue`, which would create
excessive undo entries for text inputs. The pattern follows existing
usage in `useSelectedNodeActions.ts` and other composables.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9623-fix-call-checkState-after-image-input-changes-for-proper-undo-tracking-31d6d73d3650814781dbca5db459ab6d)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-03-11 19:44:16 -07:00
Dante
cccc0944a0 fix: restore widget.inputEl backward compatibility for custom nodes (#9759)
## Summary

Restores `widget.inputEl` assignment on STRING multiline widgets that
was removed in commit a7c211516 (PR #8594) when it was renamed to
`widget.element`. Custom nodes (e.g. comfyui-custom-scripts) rely on
`widget.inputEl` to call `addEventListener` or set `readOnly`.

- Fixes Comfy-Org/ComfyUI#12893

## Test plan

- Verify custom nodes that access `widget.inputEl` on STRING widgets
work correctly
- Verify `widget.element` still works as before

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 00:48:22 +00:00
Kelly Yang
aadec87bff fix(minimap): minimap re-render/perf issue (#9741)
## Summary

Fix #9732

To clarify how preventing the 60 FPS object assignment solves the
`vue-i18n` (intlify) issue, here is the complete chain reaction leading
to the performance loop:

1. The Root Cause: In `useMinimapViewport.ts`, `useRafFn` acts as a
timer bound to the browser's **refresh rate** (60 FPS). In the original
code, it unconditionally executed the `viewportTransform.value = { ... }
`assignment 60 times a second.

2. Vue's Reactivity Interception: Because `viewportTransform` is a
reactive variable (`ref`), updating it causes its corresponding
**computed** property (`viewportStyles`) to register a data dependency
update.

3. Forced Re-rendering: The `<template> ` in `MiniMap.vue` is bound to
`:style="viewportStyles"`. Since the dependent value changed, Vue's
Virtual DOM decides: "I must re-render the entire `MiniMap.vue`
interface **60 times** per second to ensure the element positions are
up-to-date!"

4. The Victim Emerges: Inside the template of `MiniMap.vue`, there are
several internationalization translation functions: `<button
:aria-label="$t('g.settings')" ...> <button :aria-label="$t('g.close')"
...> `In Vue, whenever a component re-renders, all functions within its
template (including `$t()`) must be re-evaluated. Because the component
was being forced to re-render **60 times** per second, and there are
approximately **6 calls** to `$t()` within this UI, it multiplied into
60 × 6 = **360** intlify compilation and evaluate events per second.


## Solution
Only assemble objects and hand them over to Vue for rendering when the
mouse is actually dragging the canvas.

By extracting the math into **stack-allocated** primitive variables `(x,
y, w, h) `and strictly comparing them, it completely halts the CPU burn
at the source with minimal runtime overhead.

## Screenshot

before
<img width="1820" height="908" alt="image"
src="https://github.com/user-attachments/assets/b48d1e76-6498-47c0-af41-e0594d4e7e2f"
/>

after
<img width="1566" height="486" alt="image"
src="https://github.com/user-attachments/assets/5848b7b7-c57c-494f-a99e-4f7c92889ed0"
/>
2026-03-11 20:20:51 -04:00
Robin Huang
f5088d6cfb feat: add remote config support for PostHog debug mode (#9755)
Allow PostHog debug mode to be toggled at runtime via the
`/api/features` endpoint (`posthog_debug`), falling back to
`VITE_POSTHOG_DEBUG` env var.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9755-feat-add-remote-config-support-for-PostHog-debug-mode-3206d73d365081fca3afd4cfef117eb1)
by [Unito](https://www.unito.io)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 14:11:07 -07:00
Robin Huang
fd7ce3a852 feat: add telemetry for workflow save and default view (#9734)
Add two new telemetry events: `app:workflow_saved` (with `is_app` and
`is_new` metadata) and `app:default_view_set` for App Builder (with the
chosen view mode). Instrumented in workflowService and
useAppSetDefaultView.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9734-feat-add-telemetry-for-workflow-save-and-default-view-3206d73d3650814e8678f83c8419625f)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 09:31:21 -07:00
Dante
08a2f8ae15 ci: deploy Storybook to fixed production URL on main merge (#9373)
## Summary
- Add `deploy-production` job to Storybook CI workflow
- Triggers on push to `main` branch
- Deploys to fixed Cloudflare Pages URL:
`https://comfy-storybook.pages.dev`
- PR preview deployments remain unchanged

## Linked Issues
- Notion:
[COM-15826](https://www.notion.so/Deploy-Storybook-to-fixed-production-URL-on-main-branch-merge-3196d73d36508159a875d42694a619d1)

## Test Plan
- [ ] Merge to main and verify `deploy-production` job runs in GitHub
Actions
- [ ] Confirm `https://comfy-storybook.pages.dev` serves latest
Storybook
- [ ] Verify existing PR preview deployments still work
- [ ] Verify other jobs (comment, chromatic) are not affected by the new
trigger

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9373-ci-deploy-Storybook-to-fixed-production-URL-on-main-merge-3196d73d3650816795ead1a5b839a571)
by [Unito](https://www.unito.io)
2026-03-11 16:38:29 +01:00
Johnpaul Chiwetelu
7b9f24f515 fix: Rename keybindingService.forwarding.test.ts to reflect canvas keybinding tests (#9498) (#9658) 2026-03-11 13:50:54 +01:00
110 changed files with 5266 additions and 1263 deletions

View File

@@ -4,6 +4,8 @@ name: 'CI: Tests Storybook'
on:
workflow_dispatch: # Allow manual triggering
pull_request:
push:
branches: [main]
jobs:
# Post starting comment for non-forked PRs
@@ -138,6 +140,29 @@ jobs:
"${{ github.head_ref }}" \
"completed"
# Deploy Storybook to production URL on main branch push
deploy-production:
runs-on: ubuntu-latest
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Setup frontend
uses: ./.github/actions/setup-frontend
- name: Build Storybook
run: pnpm build-storybook
- name: Deploy to Cloudflare Pages (production)
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
run: |
npx wrangler@^4.0.0 pages deploy storybook-static \
--project-name=comfy-storybook \
--branch=main
# Update comment with Chromatic URLs for version-bump branches
update-comment-with-chromatic:
needs: [chromatic-deployment, deploy-and-comment]

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

View File

@@ -1,110 +0,0 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../../../../fixtures/ComfyPage'
import type { ComfyPage } from '../../../../fixtures/ComfyPage'
async function getNodeHeader(comfyPage: ComfyPage, title: string) {
const node = comfyPage.vueNodes.getNodeByTitle(title).first()
await expect(node).toBeVisible()
return node.locator('.lg-node-header')
}
async function selectTwoNodes(comfyPage: ComfyPage) {
const checkpointHeader = await getNodeHeader(comfyPage, 'Load Checkpoint')
const ksamplerHeader = await getNodeHeader(comfyPage, 'KSampler')
await checkpointHeader.click()
await ksamplerHeader.click({ modifiers: ['Control'] })
await comfyPage.nextFrame()
}
test.describe('Vue Node Alignment', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.canvasOps.resetView()
await comfyPage.vueNodes.waitForNodes(6)
})
test('snaps a dragged node to another node in Vue nodes mode', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'Comfy.Canvas.AlignNodesWhileDragging',
true
)
const ksamplerNode = comfyPage.vueNodes.getNodeByTitle('KSampler').first()
const checkpointNode = comfyPage.vueNodes
.getNodeByTitle('Load Checkpoint')
.first()
const ksamplerHeader = ksamplerNode.locator('.lg-node-header')
const ksamplerBox = await ksamplerNode.boundingBox()
const checkpointBox = await checkpointNode.boundingBox()
const headerBox = await ksamplerHeader.boundingBox()
if (!ksamplerBox || !checkpointBox || !headerBox) {
throw new Error('Expected Vue node bounding boxes to be available')
}
const dragStart = {
x: headerBox.x + headerBox.width / 2,
y: headerBox.y + headerBox.height / 2
}
const targetLeft = checkpointBox.x + 5
const dragTarget = {
x: dragStart.x + (targetLeft - ksamplerBox.x),
y: dragStart.y
}
await comfyPage.canvasOps.dragAndDrop(dragStart, dragTarget)
await expect
.poll(async () => {
const draggedBox = await ksamplerNode.boundingBox()
return draggedBox ? Math.round(draggedBox.x) : null
})
.toBe(Math.round(checkpointBox.x))
})
test('shows center alignment actions from the multi-node right-click menu', async ({
comfyPage
}) => {
await selectTwoNodes(comfyPage)
const ksamplerHeader = await getNodeHeader(comfyPage, 'KSampler')
await ksamplerHeader.click({ button: 'right' })
const alignMenuItem = comfyPage.page.getByText('Align Selected To', {
exact: true
})
await expect(alignMenuItem).toBeVisible()
await alignMenuItem.hover()
await expect(
comfyPage.page.getByText('Horizontal Center', { exact: true })
).toBeVisible()
await expect(
comfyPage.page.getByText('Vertical Center', { exact: true })
).toBeVisible()
})
test('does not show alignment actions from the selection toolbox More Options menu', async ({
comfyPage
}) => {
await selectTwoNodes(comfyPage)
await expect(comfyPage.selectionToolbox).toBeVisible()
await comfyPage.page.click('[data-testid="more-options-button"]')
await expect(
comfyPage.page.getByText('Rename', { exact: true })
).toBeVisible()
await expect(
comfyPage.page.getByText('Align Selected To', { exact: true })
).not.toBeVisible()
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 107 KiB

1
global.d.ts vendored
View File

@@ -35,6 +35,7 @@ interface Window {
mixpanel_token?: string
posthog_project_token?: string
posthog_api_host?: string
posthog_debug?: boolean
require_whitelist?: boolean
subscription_required?: boolean
max_upload_size?: number

View File

@@ -43,6 +43,9 @@ export function useAppSetDefaultView() {
const extra = (app.rootGraph.extra ??= {})
extra.linearMode = openAsApp
workflow.changeTracker?.checkState()
useTelemetry()?.trackDefaultViewSet({
default_view: openAsApp ? 'app' : 'graph'
})
closeDialog()
showAppliedDialog(openAsApp)
}

View File

@@ -18,6 +18,6 @@ import Button from '@/components/ui/button/Button.vue'
import { toggleNodeOptions } from '@/composables/graph/useMoreOptionsMenu'
const handleClick = (event: Event) => {
toggleNodeOptions(event, 'toolbar')
toggleNodeOptions(event)
}
</script>

View File

@@ -13,6 +13,7 @@ import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import type { RightSidePanelTab } from '@/stores/workspace/rightSidePanelStore'
@@ -36,6 +37,7 @@ import TabErrors from './errors/TabErrors.vue'
const canvasStore = useCanvasStore()
const executionErrorStore = useExecutionErrorStore()
const missingModelStore = useMissingModelStore()
const rightSidePanelStore = useRightSidePanelStore()
const settingStore = useSettingStore()
const { t } = useI18n()
@@ -43,6 +45,8 @@ const { t } = useI18n()
const { hasAnyError, allErrorExecutionIds, activeMissingNodeGraphIds } =
storeToRefs(executionErrorStore)
const { activeMissingModelGraphIds } = storeToRefs(missingModelStore)
const { findParentGroup } = useGraphHierarchy()
const { selectedItems: directlySelectedItems } = storeToRefs(canvasStore)
@@ -118,12 +122,21 @@ const hasMissingNodeSelected = computed(
)
)
const hasMissingModelSelected = computed(
() =>
hasSelection.value &&
selectedNodes.value.some((node) =>
activeMissingModelGraphIds.value.has(String(node.id))
)
)
const hasRelevantErrors = computed(() => {
if (!hasSelection.value) return hasAnyError.value
return (
hasDirectNodeError.value ||
hasContainerInternalError.value ||
hasMissingNodeSelected.value
hasMissingNodeSelected.value ||
hasMissingModelSelected.value
)
})
@@ -314,7 +327,11 @@ function handleTitleCancel() {
:value="tab.value"
>
{{ tab.label() }}
<i v-if="tab.icon" :class="cn(tab.icon, 'size-4')" />
<i
v-if="tab.icon"
aria-hidden="true"
:class="cn(tab.icon, 'size-4')"
/>
</Tab>
</TabList>
</nav>

View File

@@ -12,7 +12,7 @@
</div>
<!-- Scrollable content -->
<div class="min-w-0 flex-1 overflow-y-auto">
<div class="min-w-0 flex-1 overflow-y-auto" aria-live="polite">
<TransitionGroup tag="div" name="list-scale" class="relative">
<div
v-if="filteredGroups.length === 0"
@@ -32,11 +32,7 @@
:key="group.title"
:collapse="isSectionCollapsed(group.title) && !isSearching"
class="border-b border-interface-stroke"
:size="
group.type === 'missing_node' || group.type === 'swap_nodes'
? 'lg'
: 'default'
"
:size="getGroupSize(group)"
@update:collapse="setSectionCollapsed(group.title, $event)"
>
<template #label>
@@ -130,6 +126,14 @@
@copy-to-clipboard="copyToClipboard"
/>
</div>
<!-- Missing Models -->
<MissingModelCard
v-else-if="group.type === 'missing_model'"
:missing-model-groups="missingModelGroups"
:show-node-id-badge="showNodeIdBadge"
@locate-model="handleLocateModel"
/>
</PropertiesAccordionItem>
</TransitionGroup>
</div>
@@ -187,12 +191,14 @@ import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/f
import ErrorNodeCard from './ErrorNodeCard.vue'
import MissingNodeCard from './MissingNodeCard.vue'
import SwapNodesCard from '@/platform/nodeReplacement/components/SwapNodesCard.vue'
import MissingModelCard from '@/platform/missingModel/components/MissingModelCard.vue'
import Button from '@/components/ui/button/Button.vue'
import DotSpinner from '@/components/common/DotSpinner.vue'
import { usePackInstall } from '@/workbench/extensions/manager/composables/nodePack/usePackInstall'
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
import { useErrorGroups } from './useErrorGroups'
import type { SwapNodeGroup } from './useErrorGroups'
import type { ErrorGroup } from './types'
import { useNodeReplacement } from '@/platform/nodeReplacement/useNodeReplacement'
const { t } = useI18n()
@@ -211,6 +217,15 @@ const { replaceGroup, replaceAllGroups } = useNodeReplacement()
const searchQuery = ref('')
const isSearching = computed(() => searchQuery.value.trim() !== '')
const fullSizeGroupTypes = new Set([
'missing_node',
'swap_nodes',
'missing_model'
])
function getGroupSize(group: ErrorGroup) {
return fullSizeGroupTypes.has(group.type) ? 'lg' : 'default'
}
const showNodeIdBadge = computed(
() =>
(settingStore.get('Comfy.NodeBadge.NodeIdBadgeMode') as NodeBadgeMode) !==
@@ -226,6 +241,7 @@ const {
errorNodeCache,
missingNodeCache,
missingPackGroups,
missingModelGroups,
swapNodeGroups
} = useErrorGroups(searchQuery, t)
@@ -283,6 +299,10 @@ function handleLocateMissingNode(nodeId: string) {
focusNode(nodeId, missingNodeCache.value)
}
function handleLocateModel(nodeId: string) {
focusNode(nodeId)
}
function handleOpenManagerInfo(packId: string) {
const isKnownToRegistry = missingNodePacks.value.some((p) => p.id === packId)
if (isKnownToRegistry) {

View File

@@ -23,3 +23,4 @@ export type ErrorGroup =
}
| { type: 'missing_node'; title: string; priority: number }
| { type: 'swap_nodes'; title: string; priority: number }
| { type: 'missing_model'; title: string; priority: number }

View File

@@ -47,6 +47,13 @@ vi.mock('@/utils/executableGroupNodeDto', () => ({
isGroupNode: vi.fn(() => false)
}))
vi.mock(
'@/platform/missingModel/composables/useMissingModelInteractions',
() => ({
clearMissingModelState: vi.fn()
})
)
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useErrorGroups } from './useErrorGroups'
@@ -520,4 +527,115 @@ describe('useErrorGroups', () => {
expect(typeof groups.collapseState).toBe('object')
})
})
describe('missingModelGroups', () => {
function makeModel(
name: string,
opts: {
nodeId?: string | number
widgetName?: string
directory?: string
isAssetSupported?: boolean
} = {}
) {
return {
name,
nodeId: opts.nodeId ?? '1',
nodeType: 'CheckpointLoaderSimple',
widgetName: opts.widgetName ?? 'ckpt_name',
isAssetSupported: opts.isAssetSupported ?? false,
isMissing: true as const,
directory: opts.directory
}
}
it('returns empty array when no missing models', () => {
const { groups } = createErrorGroups()
expect(groups.missingModelGroups.value).toEqual([])
})
it('groups asset-supported models by directory', async () => {
const { store, groups } = createErrorGroups()
store.surfaceMissingModels([
makeModel('model_a.safetensors', {
directory: 'checkpoints',
isAssetSupported: true
}),
makeModel('model_b.safetensors', {
nodeId: '2',
directory: 'checkpoints',
isAssetSupported: true
}),
makeModel('lora_a.safetensors', {
nodeId: '3',
directory: 'loras',
isAssetSupported: true
})
])
await nextTick()
expect(groups.missingModelGroups.value).toHaveLength(2)
const ckptGroup = groups.missingModelGroups.value.find(
(g) => g.directory === 'checkpoints'
)
expect(ckptGroup?.models).toHaveLength(2)
expect(ckptGroup?.isAssetSupported).toBe(true)
})
it('puts unsupported models in a separate group', async () => {
const { store, groups } = createErrorGroups()
store.surfaceMissingModels([
makeModel('model_a.safetensors', {
directory: 'checkpoints',
isAssetSupported: true
}),
makeModel('custom_model.safetensors', {
nodeId: '2',
isAssetSupported: false
})
])
await nextTick()
expect(groups.missingModelGroups.value).toHaveLength(2)
const unsupported = groups.missingModelGroups.value.find(
(g) => !g.isAssetSupported
)
expect(unsupported?.models).toHaveLength(1)
})
it('merges same-named models into one view model with multiple referencingNodes', async () => {
const { store, groups } = createErrorGroups()
store.surfaceMissingModels([
makeModel('shared_model.safetensors', {
nodeId: '1',
widgetName: 'ckpt_name',
directory: 'checkpoints',
isAssetSupported: true
}),
makeModel('shared_model.safetensors', {
nodeId: '2',
widgetName: 'ckpt_name',
directory: 'checkpoints',
isAssetSupported: true
})
])
await nextTick()
expect(groups.missingModelGroups.value).toHaveLength(1)
const model = groups.missingModelGroups.value[0].models[0]
expect(model.name).toBe('shared_model.safetensors')
expect(model.referencingNodes).toHaveLength(2)
})
it('includes missing_model group in allErrorGroups', async () => {
const { store, groups } = createErrorGroups()
store.surfaceMissingModels([makeModel('model_a.safetensors')])
await nextTick()
const modelGroup = groups.allErrorGroups.value.find(
(g) => g.type === 'missing_model'
)
expect(modelGroup).toBeDefined()
})
})
})

View File

@@ -3,6 +3,7 @@ import type { MaybeRefOrGetter } from 'vue'
import Fuse from 'fuse.js'
import type { IFuseOptions } from 'fuse.js'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useComfyRegistryStore } from '@/stores/comfyRegistryStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
@@ -23,6 +24,11 @@ import { st } from '@/i18n'
import type { MissingNodeType } from '@/types/comfy'
import type { ErrorCardData, ErrorGroup, ErrorItem } from './types'
import type { NodeExecutionId } from '@/types/nodeIdentification'
import type {
MissingModelCandidate,
MissingModelGroup
} from '@/platform/missingModel/types'
import { groupCandidatesByName } from '@/platform/missingModel/missingModelScan'
import {
isNodeExecutionId,
compareExecutionId
@@ -39,6 +45,9 @@ const KNOWN_PROMPT_ERROR_TYPES = new Set([
/** Sentinel: distinguishes "fetch in-flight" from "fetch done, pack not found (null)". */
const RESOLVING = '__RESOLVING__'
/** Sentinel key for grouping non-asset-supported missing models. */
const UNSUPPORTED = Symbol('unsupported')
export interface MissingPackGroup {
packId: string | null
nodeTypes: MissingNodeType[]
@@ -231,6 +240,7 @@ export function useErrorGroups(
t: (key: string) => string
) {
const executionErrorStore = useExecutionErrorStore()
const missingModelStore = useMissingModelStore()
const canvasStore = useCanvasStore()
const { inferPackFromNodeName } = useComfyRegistryStore()
const collapseState = reactive<Record<string, boolean>>({})
@@ -559,6 +569,60 @@ export function useErrorGroups(
return groups.sort((a, b) => a.priority - b.priority)
}
/** Groups missing models. Asset-supported models group by directory; others go into a separate group.
* Within each group, candidates with the same model name are merged into a single view model. */
const missingModelGroups = computed<MissingModelGroup[]>(() => {
const candidates = missingModelStore.missingModelCandidates
if (!candidates?.length) return []
type GroupKey = string | null | typeof UNSUPPORTED
const map = new Map<
GroupKey,
{ candidates: MissingModelCandidate[]; isAssetSupported: boolean }
>()
for (const c of candidates) {
const groupKey: GroupKey = c.isAssetSupported
? c.directory || null
: UNSUPPORTED
const existing = map.get(groupKey)
if (existing) {
existing.candidates.push(c)
} else {
map.set(groupKey, {
candidates: [c],
isAssetSupported: c.isAssetSupported
})
}
}
return Array.from(map.entries())
.sort(([dirA], [dirB]) => {
if (dirA === UNSUPPORTED) return 1
if (dirB === UNSUPPORTED) return -1
if (dirA === null) return 1
if (dirB === null) return -1
return dirA.localeCompare(dirB)
})
.map(([key, { candidates: groupCandidates, isAssetSupported }]) => ({
directory: typeof key === 'string' ? key : null,
models: groupCandidatesByName(groupCandidates),
isAssetSupported
}))
})
function buildMissingModelGroups(): ErrorGroup[] {
if (!missingModelGroups.value.length) return []
return [
{
type: 'missing_model' as const,
title: `${t('rightSidePanel.missingModels.missingModelsTitle')} (${missingModelGroups.value.reduce((count, group) => count + group.models.length, 0)})`,
priority: 2
}
]
}
const allErrorGroups = computed<ErrorGroup[]>(() => {
const groupsMap = new Map<string, GroupEntry>()
@@ -566,7 +630,11 @@ export function useErrorGroups(
processNodeErrors(groupsMap)
processExecutionError(groupsMap)
return [...buildMissingNodeGroups(), ...toSortedGroups(groupsMap)]
return [
...buildMissingNodeGroups(),
...buildMissingModelGroups(),
...toSortedGroups(groupsMap)
]
})
const tabErrorGroups = computed<ErrorGroup[]>(() => {
@@ -580,7 +648,11 @@ export function useErrorGroups(
? toSortedGroups(regroupByErrorMessage(groupsMap))
: toSortedGroups(groupsMap)
return [...buildMissingNodeGroups(), ...executionGroups]
return [
...buildMissingNodeGroups(),
...buildMissingModelGroups(),
...executionGroups
]
})
const filteredGroups = computed<ErrorGroup[]>(() => {
@@ -615,6 +687,7 @@ export function useErrorGroups(
missingNodeCache,
groupedErrorMessages,
missingPackGroups,
missingModelGroups,
swapNodeGroups
}
}

View File

@@ -50,12 +50,6 @@ const snapToGrid = computed({
set: (value) => settingStore.set('pysssss.SnapToGrid', value)
})
const alignNodesWhileDragging = computed({
get: () => settingStore.get('Comfy.Canvas.AlignNodesWhileDragging'),
set: (value) =>
settingStore.set('Comfy.Canvas.AlignNodesWhileDragging', value)
})
// CONNECTION LINKS settings
const linkShape = computed({
get: () => settingStore.get('Comfy.Graph.LinkMarkers'),
@@ -166,11 +160,6 @@ function openFullSettings() {
:label="t('rightSidePanel.globalSettings.snapNodesToGrid')"
:tooltip="t('settings.pysssss_SnapToGrid.tooltip')"
/>
<FieldSwitch
v-model="alignNodesWhileDragging"
:label="t('rightSidePanel.globalSettings.alignNodesWhileDragging')"
:tooltip="t('settings.Comfy_Canvas_AlignNodesWhileDragging.tooltip')"
/>
</div>
</PropertiesAccordionItem>

View File

@@ -11,13 +11,15 @@
}"
@click="onLogoMenuClick($event)"
>
<div class="flex items-center gap-0.5">
<div class="grid place-items-center-safe gap-0.5">
<i
class="col-span-full row-span-full icon-[lucide--chevron-down] size-3 translate-x-4 text-muted-foreground"
/>
<ComfyLogo
alt="ComfyUI Logo"
class="comfyui-logo h-[18px] w-[18px]"
class="comfyui-logo col-span-full row-span-full size-4.5"
mode="fill"
/>
<i class="icon-[lucide--chevron-down] size-3 text-muted-foreground" />
</div>
</div>

View File

@@ -0,0 +1,47 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { createI18n } from 'vue-i18n'
import type { WorkflowExecutionState } from '@/stores/executionStore'
import WorkflowExecutionIndicator from './WorkflowExecutionIndicator.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
workflowExecution: {
running: 'Workflow is running',
completed: 'Workflow completed successfully',
error: 'Workflow execution failed'
}
}
}
})
describe('WorkflowExecutionIndicator', () => {
const mountWithI18n = (props: { state: WorkflowExecutionState }) =>
mount(WorkflowExecutionIndicator, {
props,
global: {
plugins: [i18n]
}
})
it('renders nothing for idle state', () => {
const wrapper = mountWithI18n({ state: 'idle' })
expect(wrapper.find('i').exists()).toBe(false)
})
it.each<{ state: WorkflowExecutionState; label: string }>([
{ state: 'running', label: 'Workflow is running' },
{ state: 'completed', label: 'Workflow completed successfully' },
{ state: 'error', label: 'Workflow execution failed' }
])('renders accessible icon for $state state', ({ state, label }) => {
const wrapper = mountWithI18n({ state })
const icon = wrapper.find('i')
expect(icon.exists()).toBe(true)
expect(icon.attributes('aria-label')).toBe(label)
})
})

View File

@@ -0,0 +1,25 @@
<template>
<i
v-if="state === 'running'"
class="icon-[lucide--loader-circle] size-4 shrink-0 animate-spin text-muted-foreground"
:aria-label="$t('workflowExecution.running')"
/>
<i
v-else-if="state === 'completed'"
class="icon-[lucide--circle-check] size-4 shrink-0 text-jade-600"
:aria-label="$t('workflowExecution.completed')"
/>
<i
v-else-if="state === 'error'"
class="icon-[lucide--circle-alert] size-4 shrink-0 text-coral-600"
:aria-label="$t('workflowExecution.error')"
/>
</template>
<script setup lang="ts">
import type { WorkflowExecutionState } from '@/stores/executionStore'
const { state } = defineProps<{
state: WorkflowExecutionState
}>()
</script>

View File

@@ -20,6 +20,10 @@
>
{{ workflowOption.workflow.filename }}
</span>
<WorkflowExecutionIndicator
v-if="showExecutionIndicator"
:state="executionState"
/>
<div class="relative">
<span
v-if="shouldShowStatusIndicator"
@@ -68,11 +72,12 @@ import {
ContextMenuSeparator,
ContextMenuTrigger
} from 'reka-ui'
import { computed, onUnmounted, ref } from 'vue'
import { computed, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import WorkflowActionsList from '@/components/common/WorkflowActionsList.vue'
import Button from '@/components/ui/button/Button.vue'
import { useWorkflowExecutionState } from '@/composables/useWorkflowExecutionState'
import {
usePragmaticDraggable,
usePragmaticDroppable
@@ -87,6 +92,7 @@ import { useCommandStore } from '@/stores/commandStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import type { WorkflowMenuItem } from '@/types/workflowMenuItem'
import WorkflowExecutionIndicator from './WorkflowExecutionIndicator.vue'
import WorkflowTabPopover from './WorkflowTabPopover.vue'
interface WorkflowOption {
@@ -116,6 +122,33 @@ const workflowTabRef = ref<HTMLElement | null>(null)
const popoverRef = ref<InstanceType<typeof WorkflowTabPopover> | null>(null)
const workflowThumbnail = useWorkflowThumbnail()
const workflowId = computed(() => {
const activeState = props.workflowOption.workflow.activeState
const initialState = props.workflowOption.workflow.initialState
return activeState?.id ?? initialState?.id
})
const { state: executionState, clearResult } =
useWorkflowExecutionState(workflowId)
const clearTimeoutId = ref<ReturnType<typeof setTimeout> | null>(null)
watch(executionState, (newState) => {
if (clearTimeoutId.value) {
clearTimeout(clearTimeoutId.value)
clearTimeoutId.value = null
}
if (newState === 'completed') {
clearTimeoutId.value = setTimeout(() => {
clearResult()
clearTimeoutId.value = null
}, 5000)
}
})
const showExecutionIndicator = computed(() => executionState.value !== 'idle')
// Use computed refs to cache autosave settings
const autoSaveSetting = computed(() =>
settingStore.get('Comfy.Workflow.AutoSave')
@@ -287,6 +320,9 @@ usePragmaticDroppable(tabGetter, {
onUnmounted(() => {
popoverRef.value?.hidePopover()
if (clearTimeoutId.value) {
clearTimeout(clearTimeoutId.value)
}
})
</script>

View File

@@ -64,6 +64,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
)
"
>
<slot name="prepend" />
<SelectScrollUpButton />
<SelectViewport
:class="

View File

@@ -45,8 +45,6 @@ const CORE_MENU_ITEMS = new Set([
'Convert to Subgraph',
'Frame selection',
'Frame Nodes',
'Align Selected To',
'Distribute Nodes',
'Minimize Node',
'Expand',
'Collapse',
@@ -231,8 +229,6 @@ const MENU_ORDER: string[] = [
'Convert to Subgraph',
'Frame selection',
'Frame Nodes',
'Align Selected To',
'Distribute Nodes',
'Minimize Node',
'Expand',
'Collapse',
@@ -305,14 +301,14 @@ export function buildStructuredMenu(options: MenuOption[]): MenuOption[] {
// Section boundaries based on MENU_ORDER indices
// Section 1: 0-2 (Rename, Copy, Duplicate)
// Section 2: 3-8 (Run Branch, Pin, Unpin, Bypass, Remove Bypass, Mute)
// Section 3: 9-17 (Convert to Subgraph ... Clone)
// Section 4: 18-19 (Node Info, Color)
// Section 5: 20+ (Image operations and fallback items)
// Section 3: 9-15 (Convert to Subgraph, Frame selection, Minimize Node, Expand, Collapse, Resize, Clone)
// Section 4: 16-17 (Node Info, Color)
// Section 5: 18+ (Image operations and fallback items)
const getSectionNumber = (index: number): number => {
if (index <= 2) return 1
if (index <= 8) return 2
if (index <= 17) return 3
if (index <= 19) return 4
if (index <= 15) return 3
if (index <= 17) return 4
return 5
}

View File

@@ -1,108 +0,0 @@
import { computed, ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
showNodeOptions,
toggleNodeOptions,
useMoreOptionsMenu
} from '@/composables/graph/useMoreOptionsMenu'
const selectedItems = ref([{ id: 'node-1' }, { id: 'node-2' }])
const selectedNodes = ref([{ id: 'node-1' }, { id: 'node-2' }])
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({
canvas: null
})
}))
vi.mock('@/utils/litegraphUtil', () => ({
isLGraphGroup: () => false
}))
vi.mock('@/composables/graph/useSelectionState', () => ({
useSelectionState: () => ({
selectedItems: computed(() => selectedItems.value),
selectedNodes: computed(() => selectedNodes.value),
nodeDef: computed(() => null),
showNodeHelp: vi.fn(),
hasSubgraphs: computed(() => false),
hasImageNode: computed(() => false),
hasOutputNodesSelected: computed(() => false),
hasMultipleSelection: computed(() => true),
computeSelectionFlags: () => ({
collapsed: false,
pinned: false,
bypassed: false
})
})
}))
vi.mock('@/composables/graph/useImageMenuOptions', () => ({
useImageMenuOptions: () => ({
getImageMenuOptions: () => []
})
}))
vi.mock('@/composables/graph/useNodeMenuOptions', () => ({
useNodeMenuOptions: () => ({
getNodeInfoOption: () => ({ label: 'Node Info' }),
getNodeVisualOptions: () => [],
getPinOption: () => ({ label: 'Pin' }),
getBypassOption: () => ({ label: 'Bypass' }),
getRunBranchOption: () => ({ label: 'Run Branch' })
})
}))
vi.mock('@/composables/graph/useGroupMenuOptions', () => ({
useGroupMenuOptions: () => ({
getFitGroupToNodesOption: () => ({ label: 'Fit Group to Nodes' }),
getGroupColorOptions: () => ({ label: 'Group Color' }),
getGroupModeOptions: () => []
})
}))
vi.mock('@/composables/graph/useSelectionMenuOptions', () => ({
useSelectionMenuOptions: () => ({
getBasicSelectionOptions: () => [{ label: 'Rename' }],
getMultipleNodesOptions: () => [{ label: 'Frame Nodes' }],
getSubgraphOptions: () => [],
getAlignmentOptions: () => [{ label: 'Align Selected To' }]
})
}))
vi.mock('@/core/graph/subgraph/promotedWidgetTypes', () => ({
isPromotedWidgetView: () => false
}))
vi.mock('@/services/litegraphService', () => ({
getExtraOptionsForWidget: () => []
}))
describe('useMoreOptionsMenu', () => {
beforeEach(() => {
selectedItems.value = [{ id: 'node-1' }, { id: 'node-2' }]
selectedNodes.value = [{ id: 'node-1' }, { id: 'node-2' }]
toggleNodeOptions(new Event('click'), 'toolbar')
})
it('adds alignment options for right-click menus only', () => {
const { menuOptions } = useMoreOptionsMenu()
expect(
menuOptions.value.some((option) => option.label === 'Align Selected To')
).toBe(false)
showNodeOptions(new MouseEvent('contextmenu'))
expect(
menuOptions.value.some((option) => option.label === 'Align Selected To')
).toBe(true)
toggleNodeOptions(new Event('click'), 'toolbar')
expect(
menuOptions.value.some((option) => option.label === 'Align Selected To')
).toBe(false)
})
})

View File

@@ -43,8 +43,6 @@ export interface SubMenuOption {
disabled?: boolean
}
type NodeOptionsTriggerSource = 'contextmenu' | 'toolbar'
export enum BadgeVariant {
NEW = 'new',
DEPRECATED = 'deprecated'
@@ -52,7 +50,6 @@ export enum BadgeVariant {
// Global singleton for NodeOptions component reference
let nodeOptionsInstance: null | NodeOptionsInstance = null
const nodeOptionsTriggerSource = ref<NodeOptionsTriggerSource>('toolbar')
const hoveredWidget = ref<[string, NodeId | undefined]>()
@@ -60,11 +57,7 @@ const hoveredWidget = ref<[string, NodeId | undefined]>()
* Toggle the node options popover
* @param event - The trigger event
*/
export function toggleNodeOptions(
event: Event,
triggerSource: NodeOptionsTriggerSource = 'toolbar'
) {
nodeOptionsTriggerSource.value = triggerSource
export function toggleNodeOptions(event: Event) {
if (nodeOptionsInstance?.toggle) {
nodeOptionsInstance.toggle(event)
}
@@ -78,10 +71,8 @@ export function toggleNodeOptions(
export function showNodeOptions(
event: MouseEvent,
widgetName?: string,
nodeId?: NodeId,
triggerSource: NodeOptionsTriggerSource = 'contextmenu'
nodeId?: NodeId
) {
nodeOptionsTriggerSource.value = triggerSource
hoveredWidget.value = widgetName ? [widgetName, nodeId] : undefined
if (nodeOptionsInstance?.show) {
nodeOptionsInstance.show(event)
@@ -156,8 +147,7 @@ export function useMoreOptionsMenu() {
const {
getBasicSelectionOptions,
getMultipleNodesOptions,
getSubgraphOptions,
getAlignmentOptions
getSubgraphOptions
} = useSelectionMenuOptions()
const hasSubgraphs = hasSubgraphsComputed
@@ -237,9 +227,6 @@ export function useMoreOptionsMenu() {
)
if (hasMultipleNodes.value) {
options.push(...getMultipleNodesOptions())
if (nodeOptionsTriggerSource.value === 'contextmenu') {
options.push(...getAlignmentOptions(selectedNodes.value.length))
}
}
if (groupContext) {
options.push(getFitGroupToNodesOption(groupContext))

View File

@@ -1,6 +1,6 @@
import { useI18n } from 'vue-i18n'
import type { ArrangementDirection } from '@/lib/litegraph/src/interfaces'
import type { Direction } from '@/lib/litegraph/src/interfaces'
import { alignNodes, distributeNodes } from '@/lib/litegraph/src/utils/arrange'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { isLGraphNode } from '@/utils/litegraphUtil'
@@ -10,7 +10,7 @@ import { useCanvasRefresh } from './useCanvasRefresh'
interface AlignOption {
name: string
localizedName: string
value: ArrangementDirection
value: Direction
icon: string
}
@@ -52,18 +52,6 @@ export function useNodeArrangement() {
localizedName: t('contextMenu.Right'),
value: 'right',
icon: 'icon-[lucide--align-end-horizontal]'
},
{
name: 'horizontal-center',
localizedName: t('contextMenu.Horizontal Center'),
value: 'horizontal-center',
icon: 'icon-[lucide--align-center-horizontal]'
},
{
name: 'vertical-center',
localizedName: t('contextMenu.Vertical Center'),
value: 'vertical-center',
icon: 'icon-[lucide--align-center-vertical]'
}
]
@@ -87,7 +75,7 @@ export function useNodeArrangement() {
isLGraphNode(item)
)
if (selectedNodes.length < 2) {
if (selectedNodes.length === 0) {
return
}
@@ -102,7 +90,7 @@ export function useNodeArrangement() {
isLGraphNode(item)
)
if (selectedNodes.length < 3) {
if (selectedNodes.length < 2) {
return
}

View File

@@ -81,27 +81,6 @@ describe('useSelectionMenuOptions - multiple nodes options', () => {
)
expect(groupNodeOption).toBeDefined()
})
it('returns align options for two selected nodes', () => {
const { getAlignmentOptions } = useSelectionMenuOptions()
const options = getAlignmentOptions(2)
expect(options.map((option) => option.label)).toEqual([
'contextMenu.Align Selected To'
])
})
it('returns align and distribute options for three selected nodes', () => {
const { getAlignmentOptions } = useSelectionMenuOptions()
const options = getAlignmentOptions(3)
expect(options.map((option) => option.label)).toEqual([
'contextMenu.Align Selected To',
'contextMenu.Distribute Nodes'
])
})
})
describe('useSelectionMenuOptions - subgraph options', () => {

View File

@@ -125,31 +125,22 @@ export function useSelectionMenuOptions() {
]
}
const getAlignmentOptions = (selectedNodeCount: number): MenuOption[] => {
const options: MenuOption[] = []
if (selectedNodeCount >= 2) {
options.push({
label: t('contextMenu.Align Selected To'),
icon: 'icon-[lucide--align-start-horizontal]',
hasSubmenu: true,
submenu: alignSubmenu.value,
action: () => {}
})
const getAlignmentOptions = (): MenuOption[] => [
{
label: t('contextMenu.Align Selected To'),
icon: 'icon-[lucide--align-start-horizontal]',
hasSubmenu: true,
submenu: alignSubmenu.value,
action: () => {}
},
{
label: t('contextMenu.Distribute Nodes'),
icon: 'icon-[lucide--align-center-horizontal]',
hasSubmenu: true,
submenu: distributeSubmenu.value,
action: () => {}
}
if (selectedNodeCount >= 3) {
options.push({
label: t('contextMenu.Distribute Nodes'),
icon: 'icon-[lucide--align-center-horizontal]',
hasSubmenu: true,
submenu: distributeSubmenu.value,
action: () => {}
})
}
return options
}
]
const getDeleteOption = (): MenuOption => ({
label: t('contextMenu.Delete'),

View File

@@ -357,7 +357,8 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
}
/**
* Initialize viewer in standalone mode (for asset preview)
* Initialize viewer in standalone mode (for asset preview).
* Creates the Load3d instance once; subsequent calls reuse it.
*/
const initializeStandaloneViewer = async (
containerRef: HTMLElement,
@@ -366,6 +367,11 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
if (!containerRef) return
try {
if (load3d) {
await loadStandaloneModel(modelUrl)
return
}
isStandaloneMode.value = true
load3d = new Load3d(containerRef, {
@@ -392,6 +398,23 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
setupAnimationEvents()
} catch (error) {
console.error('Error initializing standalone 3D viewer:', error)
useToastStore().addAlert(t('toastMessages.failedToLoadModel'))
}
}
/**
* Load a new model into an existing standalone viewer,
* reusing the same WebGLRenderer.
*/
const loadStandaloneModel = async (modelUrl: string) => {
if (!load3d) return
try {
await load3d.loadModel(modelUrl)
isSplatModel.value = load3d.isSplatModel()
isPlyModel.value = load3d.isPlyModel()
} catch (error) {
console.error('Error loading model in standalone viewer:', error)
useToastStore().addAlert('Failed to load 3D model')
}
}

View File

@@ -0,0 +1,71 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import type { WorkflowExecutionState } from '@/stores/executionStore'
import { useWorkflowExecutionState } from './useWorkflowExecutionState'
const _workflowExecutionStates = ref(new Map<string, WorkflowExecutionState>())
const _clearWorkflowExecutionResult = vi.fn()
vi.mock('@/stores/executionStore', () => ({
useExecutionStore: () => ({
getWorkflowExecutionState: (wid: string | undefined) => {
if (!wid) return 'idle'
return _workflowExecutionStates.value.get(wid) ?? 'idle'
},
clearWorkflowExecutionResult: _clearWorkflowExecutionResult
})
}))
describe('useWorkflowExecutionState', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.clearAllMocks()
_workflowExecutionStates.value = new Map()
})
it('returns idle when workflowId is undefined', () => {
const { state } = useWorkflowExecutionState(undefined)
expect(state.value).toBe('idle')
})
it('returns idle when no execution data exists', () => {
const { state } = useWorkflowExecutionState('workflow-1')
expect(state.value).toBe('idle')
})
it('returns state from execution store map', () => {
_workflowExecutionStates.value = new Map([['workflow-1', 'running']])
const { state } = useWorkflowExecutionState('workflow-1')
expect(state.value).toBe('running')
})
it('reacts to workflowId ref changes', () => {
const wfId = ref<string | undefined>('workflow-1')
_workflowExecutionStates.value = new Map([
['workflow-1', 'running'],
['workflow-2', 'error']
])
const { state } = useWorkflowExecutionState(wfId)
expect(state.value).toBe('running')
wfId.value = 'workflow-2'
expect(state.value).toBe('error')
})
it('clearResult delegates to executionStore', () => {
const { clearResult } = useWorkflowExecutionState('workflow-1')
clearResult()
expect(_clearWorkflowExecutionResult).toHaveBeenCalledWith('workflow-1')
})
it('clearResult does nothing when workflowId is undefined', () => {
const { clearResult } = useWorkflowExecutionState(undefined)
clearResult()
expect(_clearWorkflowExecutionResult).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,27 @@
import type { MaybeRefOrGetter } from 'vue'
import { computed, toValue } from 'vue'
import type { WorkflowExecutionState } from '@/stores/executionStore'
import { useExecutionStore } from '@/stores/executionStore'
export function useWorkflowExecutionState(
workflowId: MaybeRefOrGetter<string | undefined>
) {
const executionStore = useExecutionStore()
const state = computed<WorkflowExecutionState>(() =>
executionStore.getWorkflowExecutionState(toValue(workflowId))
)
function clearResult() {
const wid = toValue(workflowId)
if (wid) {
executionStore.clearWorkflowExecutionResult(wid)
}
}
return {
state,
clearResult
}
}

View File

@@ -40,7 +40,7 @@ import type {
ContextMenuDivElement,
DefaultConnectionColors,
Dictionary,
ArrangementDirection,
Direction,
IBoundaryNodes,
IColorable,
IContextMenuOptions,
@@ -1053,7 +1053,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
*/
static alignNodes(
nodes: Dictionary<LGraphNode>,
direction: ArrangementDirection,
direction: Direction,
align_to?: LGraphNode
): void {
const newPositions = alignNodes(Object.values(nodes), direction, align_to)
@@ -1077,7 +1077,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
function inner_clicked(value: string) {
const newPositions = alignNodes(
Object.values(LGraphCanvas.active_canvas.selected_nodes),
value.toLowerCase() as ArrangementDirection,
value.toLowerCase() as Direction,
node
)
LGraphCanvas.active_canvas.repositionNodesVueMode(newPositions)
@@ -1100,7 +1100,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
function inner_clicked(value: string) {
const newPositions = alignNodes(
Object.values(LGraphCanvas.active_canvas.selected_nodes),
value.toLowerCase() as ArrangementDirection
value.toLowerCase() as Direction
)
LGraphCanvas.active_canvas.repositionNodesVueMode(newPositions)
LGraphCanvas.active_canvas.setDirty(true, true)
@@ -4923,7 +4923,6 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
if (!LiteGraph.vueNodesMode || !this.overlayCtx) {
this._drawConnectingLinks(ctx)
this._drawVueDragAlignmentGuides(ctx)
} else {
this._drawOverlayLinks()
}
@@ -5028,8 +5027,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
octx.clearRect(0, 0, overlayCanvas.width, overlayCanvas.height)
const hasDragGuides = layoutStore.vueDragSnapGuides.value.length > 0
if (!this.linkConnector.isConnecting && !hasDragGuides) return
if (!this.linkConnector.isConnecting) return
octx.save()
@@ -5038,39 +5036,11 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
this.ds.toCanvasContext(octx)
if (this.linkConnector.isConnecting) {
this._drawConnectingLinks(octx)
}
this._drawVueDragAlignmentGuides(octx)
this._drawConnectingLinks(octx)
octx.restore()
}
private _drawVueDragAlignmentGuides(ctx: CanvasRenderingContext2D): void {
const guides = layoutStore.vueDragSnapGuides.value
if (!guides.length) return
const scale = this.ds.scale || 1
ctx.save()
ctx.beginPath()
ctx.lineWidth = 1 / scale
ctx.strokeStyle = '#ff4d4f'
ctx.setLineDash([6 / scale, 4 / scale])
for (const guide of guides) {
if (guide.axis === 'vertical') {
ctx.moveTo(guide.coordinate, guide.start)
ctx.lineTo(guide.coordinate, guide.end)
} else {
ctx.moveTo(guide.start, guide.coordinate)
ctx.lineTo(guide.end, guide.coordinate)
}
}
ctx.stroke()
ctx.restore()
}
/** Get the target snap / highlight point in graph space */
private _getHighlightPosition(): Readonly<Point> {
return LiteGraph.snaps_for_comfy

View File

@@ -281,13 +281,7 @@ export interface IBoundaryNodes {
left: LGraphNode
}
export type ArrangementDirection =
| 'top'
| 'bottom'
| 'left'
| 'right'
| 'horizontal-center'
| 'vertical-center'
export type Direction = 'top' | 'bottom' | 'left' | 'right'
/** Resize handle positions (compass points) */
export type CompassCorners = 'NE' | 'SE' | 'SW' | 'NW'

View File

@@ -1,18 +1,5 @@
import type { LGraphNode } from '../LGraphNode'
import type {
ArrangementDirection,
IBoundaryNodes,
NewNodePosition
} from '../interfaces'
interface NodeSelectionBounds {
top: number
right: number
bottom: number
left: number
centerX: number
centerY: number
}
import type { Direction, IBoundaryNodes, NewNodePosition } from '../interfaces'
/**
* Finds the nodes that are farthest in all four directions, representing the boundary of the nodes.
@@ -58,7 +45,7 @@ export function distributeNodes(
horizontal?: boolean
): NewNodePosition[] {
const nodeCount = nodes?.length
if (!(nodeCount > 2)) return []
if (!(nodeCount > 1)) return []
const index = horizontal ? 0 : 1
@@ -101,7 +88,7 @@ export function distributeNodes(
*/
export function alignNodes(
nodes: LGraphNode[],
direction: ArrangementDirection,
direction: Direction,
align_to?: LGraphNode
): NewNodePosition[] {
if (!nodes) return []
@@ -113,16 +100,6 @@ export function alignNodes(
if (boundary === null) return []
const selectionBounds = getNodeSelectionBounds(nodes)
const alignToCenterX =
align_to === undefined
? selectionBounds.centerX
: align_to.pos[0] + align_to.size[0] * 0.5
const alignToCenterY =
align_to === undefined
? selectionBounds.centerY
: align_to.pos[1] + align_to.size[1] * 0.5
const nodePositions = nodes.map((node): NewNodePosition => {
switch (direction) {
case 'right':
@@ -157,22 +134,6 @@ export function alignNodes(
y: boundary.bottom.pos[1] + boundary.bottom.size[1] - node.size[1]
}
}
case 'horizontal-center':
return {
node,
newPos: {
x: node.pos[0],
y: alignToCenterY - node.size[1] * 0.5
}
}
case 'vertical-center':
return {
node,
newPos: {
x: alignToCenterX - node.size[0] * 0.5,
y: node.pos[1]
}
}
}
})
@@ -181,24 +142,3 @@ export function alignNodes(
}
return nodePositions
}
function getNodeSelectionBounds(nodes: LGraphNode[]): NodeSelectionBounds {
const boundary = getBoundaryNodes(nodes)
if (!boundary) {
throw new TypeError('Cannot calculate selection bounds without nodes.')
}
const top = boundary.top.pos[1]
const left = boundary.left.pos[0]
const right = boundary.right.pos[0] + boundary.right.size[0]
const bottom = boundary.bottom.pos[1] + boundary.bottom.size[1]
return {
top,
right,
bottom,
left,
centerX: left + (right - left) * 0.5,
centerY: top + (bottom - top) * 0.5
}
}

View File

@@ -546,8 +546,6 @@
"Bottom": "Bottom",
"Left": "Left",
"Right": "Right",
"Horizontal Center": "Horizontal Center",
"Vertical Center": "Vertical Center",
"Horizontal": "Horizontal",
"Vertical": "Vertical",
"new": "new",
@@ -1959,6 +1957,7 @@
"exportSuccess": "Successfully exported model as {format}",
"fileLoadError": "Unable to find workflow in {fileName}",
"dropFileError": "Unable to process dropped item: {error}",
"missingModelVerificationFailed": "Failed to verify missing models. Some models may not be shown in the Errors tab.",
"interrupted": "Execution has been interrupted",
"pendingTasksDeleted": "Pending tasks deleted",
"nothingToGroup": "Nothing to group",
@@ -3337,7 +3336,6 @@
"nodes2": "Nodes 2.0",
"gridSpacing": "Grid spacing",
"snapNodesToGrid": "Snap nodes to grid",
"alignNodesWhileDragging": "Align nodes while dragging",
"linkShape": "Link shape",
"showConnectedLinks": "Show connected links",
"viewAllSettings": "View all settings"
@@ -3391,6 +3389,33 @@
"viewInManager": "View in Manager",
"collapse": "Collapse",
"expand": "Expand"
},
"missingModels": {
"urlPlaceholder": "Paste Model URL (Civitai or Hugging Face)",
"or": "OR",
"useFromLibrary": "Use from Library",
"usingFromLibrary": "Using from Library",
"unsupportedUrl": "Only Civitai and Hugging Face URLs are supported.",
"metadataFetchFailed": "Failed to retrieve metadata. Please check the link and try again.",
"import": "Import",
"importing": "Importing...",
"imported": "Imported",
"importFailed": "Import failed",
"typeMismatch": "This model seems to be a \"{detectedType}\". Are you sure?",
"importAnyway": "Import Anyway",
"alreadyExistsInCategory": "This model already exists in \"{category}\"",
"customNodeDownloadDisabled": "Cloud environment does not support model imports for custom nodes in this section. Please use standard loader nodes or substitute with a model from the library below.",
"importNotSupported": "Import Not Supported",
"copyModelName": "Copy model name",
"confirmSelection": "Confirm selection",
"locateNode": "Locate node on canvas",
"cancelSelection": "Cancel selection",
"clearUrl": "Clear URL",
"expandNodes": "Show referencing nodes",
"collapseNodes": "Hide referencing nodes",
"unknownCategory": "Unknown",
"missingModelsTitle": "Missing Models",
"assetLoadTimeout": "Model detection timed out. Try reloading the workflow."
}
},
"errorOverlay": {
@@ -3564,5 +3589,10 @@
"builderMenu": {
"enterAppMode": "Enter app mode",
"exitAppBuilder": "Exit app builder"
},
"workflowExecution": {
"running": "Workflow is running",
"completed": "Workflow completed successfully",
"error": "Workflow execution failed"
}
}

View File

@@ -55,10 +55,6 @@
"name": "Show selection toolbox",
"tooltip": "Display a floating toolbar when nodes are selected, providing quick access to common actions."
},
"Comfy_Canvas_AlignNodesWhileDragging": {
"name": "Align nodes while dragging",
"tooltip": "When enabled in Nodes 2.0, dragging a node selection near another node will snap matching edges and centers together."
},
"Comfy_ConfirmClear": {
"name": "Require confirmation when clearing workflow"
},
@@ -352,8 +348,8 @@
"tooltip": "The maximum number of tasks that show in the queue history."
},
"Comfy_Queue_QPOV2": {
"name": "Use the unified job queue in the Assets side panel",
"tooltip": "Replaces the floating job queue panel with an equivalent job queue embedded in the Assets side panel. You can disable this to return to the floating panel layout."
"name": "Docked job history/queue panel",
"tooltip": "Replaces the floating job queue panel with an equivalent job queue embedded in the job history side panel. You can disable this to return to the floating panel layout."
},
"Comfy_QueueButton_BatchCountLimit": {
"name": "Batch count limit",

View File

@@ -0,0 +1,193 @@
import { mount } from '@vue/test-utils'
import PrimeVue from 'primevue/config'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import type {
MissingModelGroup,
MissingModelViewModel
} from '@/platform/missingModel/types'
vi.mock('./MissingModelRow.vue', () => ({
default: {
name: 'MissingModelRow',
template: '<div class="model-row" />',
props: ['model', 'directory', 'showNodeIdBadge', 'isAssetSupported'],
emits: ['locate-model']
}
}))
import MissingModelCard from './MissingModelCard.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
rightSidePanel: {
missingModels: {
importNotSupported: 'Import Not Supported',
customNodeDownloadDisabled:
'Cloud environment does not support model imports for custom nodes.',
unknownCategory: 'Unknown Category'
}
}
}
},
missingWarn: false,
fallbackWarn: false
})
function makeViewModel(
name: string,
nodeId: string = '1'
): MissingModelViewModel {
return {
name,
representative: {
name,
nodeId,
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
isAssetSupported: true,
isMissing: true
},
referencingNodes: [{ nodeId, widgetName: 'ckpt_name' }]
}
}
function makeGroup(
opts: {
directory?: string | null
isAssetSupported?: boolean
modelNames?: string[]
} = {}
): MissingModelGroup {
const names = opts.modelNames ?? ['model.safetensors']
return {
directory: 'directory' in opts ? (opts.directory ?? null) : 'checkpoints',
isAssetSupported: opts.isAssetSupported ?? true,
models: names.map((n, i) => makeViewModel(n, String(i + 1)))
}
}
function mountCard(
props: Partial<{
missingModelGroups: MissingModelGroup[]
showNodeIdBadge: boolean
}> = {}
) {
return mount(MissingModelCard, {
props: {
missingModelGroups: [makeGroup()],
showNodeIdBadge: false,
...props
},
global: {
plugins: [PrimeVue, i18n]
}
})
}
describe('MissingModelCard', () => {
describe('Rendering & Props', () => {
it('renders directory name in category header', () => {
const wrapper = mountCard({
missingModelGroups: [makeGroup({ directory: 'loras' })]
})
expect(wrapper.text()).toContain('loras')
})
it('renders translated unknown category when directory is null', () => {
const wrapper = mountCard({
missingModelGroups: [makeGroup({ directory: null })]
})
expect(wrapper.text()).toContain('Unknown Category')
})
it('renders model count in category header', () => {
const wrapper = mountCard({
missingModelGroups: [
makeGroup({ modelNames: ['a.safetensors', 'b.safetensors'] })
]
})
expect(wrapper.text()).toContain('(2)')
})
it('renders correct number of MissingModelRow components', () => {
const wrapper = mountCard({
missingModelGroups: [
makeGroup({
modelNames: ['a.safetensors', 'b.safetensors', 'c.safetensors']
})
]
})
expect(
wrapper.findAllComponents({ name: 'MissingModelRow' })
).toHaveLength(3)
})
it('renders multiple groups', () => {
const wrapper = mountCard({
missingModelGroups: [
makeGroup({ directory: 'checkpoints' }),
makeGroup({ directory: 'loras' })
]
})
expect(wrapper.text()).toContain('checkpoints')
expect(wrapper.text()).toContain('loras')
})
it('renders zero rows when missingModelGroups is empty', () => {
const wrapper = mountCard({ missingModelGroups: [] })
expect(
wrapper.findAllComponents({ name: 'MissingModelRow' })
).toHaveLength(0)
})
it('passes props correctly to MissingModelRow children', () => {
const wrapper = mountCard({ showNodeIdBadge: true })
const row = wrapper.findComponent({ name: 'MissingModelRow' })
expect(row.props('showNodeIdBadge')).toBe(true)
expect(row.props('isAssetSupported')).toBe(true)
expect(row.props('directory')).toBe('checkpoints')
})
})
describe('Asset Unsupported Group', () => {
it('shows "Import Not Supported" header for unsupported groups', () => {
const wrapper = mountCard({
missingModelGroups: [makeGroup({ isAssetSupported: false })]
})
expect(wrapper.text()).toContain('Import Not Supported')
})
it('shows info notice for unsupported groups', () => {
const wrapper = mountCard({
missingModelGroups: [makeGroup({ isAssetSupported: false })]
})
expect(wrapper.text()).toContain(
'Cloud environment does not support model imports'
)
})
it('hides info notice for supported groups', () => {
const wrapper = mountCard({
missingModelGroups: [makeGroup({ isAssetSupported: true })]
})
expect(wrapper.text()).not.toContain(
'Cloud environment does not support model imports'
)
})
})
describe('Event Handling', () => {
it('emits locateModel when child emits locate-model', async () => {
const wrapper = mountCard()
const row = wrapper.findComponent({ name: 'MissingModelRow' })
await row.vm.$emit('locate-model', '42')
expect(wrapper.emitted('locateModel')).toBeTruthy()
expect(wrapper.emitted('locateModel')?.[0]).toEqual(['42'])
})
})
})

View File

@@ -0,0 +1,78 @@
<template>
<div class="px-4 pb-2">
<!-- Category groups (by directory) -->
<div
v-for="group in missingModelGroups"
:key="`${group.isAssetSupported ? 'supported' : 'unsupported'}::${group.directory ?? '__unknown__'}`"
class="flex w-full flex-col border-t border-interface-stroke py-2 first:border-t-0 first:pt-0"
>
<!-- Category header -->
<div class="flex h-8 w-full items-center">
<p
class="min-w-0 flex-1 truncate text-sm font-medium"
:class="
!group.isAssetSupported || group.directory === null
? 'text-warning-background'
: 'text-destructive-background-hover'
"
>
<span v-if="!group.isAssetSupported" class="text-warning-background">
{{ t('rightSidePanel.missingModels.importNotSupported') }}
({{ group.models.length }})
</span>
<span v-else>
{{
group.directory ??
t('rightSidePanel.missingModels.unknownCategory')
}}
({{ group.models.length }})
</span>
</p>
</div>
<!-- Asset unsupported group notice -->
<div
v-if="!group.isAssetSupported"
class="flex items-start gap-1.5 px-0.5 py-1 pl-2"
>
<i
aria-hidden="true"
class="mt-0.5 icon-[lucide--info] size-3.5 shrink-0 text-muted-foreground"
/>
<span class="text-xs/tight text-muted-foreground">
{{ t('rightSidePanel.missingModels.customNodeDownloadDisabled') }}
</span>
</div>
<!-- Model rows -->
<div class="flex flex-col gap-1 overflow-hidden pl-2">
<MissingModelRow
v-for="model in group.models"
:key="model.name"
:model="model"
:directory="group.directory"
:show-node-id-badge="showNodeIdBadge"
:is-asset-supported="group.isAssetSupported"
@locate-model="emit('locateModel', $event)"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import type { MissingModelGroup } from '@/platform/missingModel/types'
import MissingModelRow from '@/platform/missingModel/components/MissingModelRow.vue'
const { missingModelGroups, showNodeIdBadge } = defineProps<{
missingModelGroups: MissingModelGroup[]
showNodeIdBadge: boolean
}>()
const emit = defineEmits<{
locateModel: [nodeId: string]
}>()
const { t } = useI18n()
</script>

View File

@@ -0,0 +1,113 @@
<template>
<div class="flex flex-col gap-2">
<div v-if="showDivider" class="flex items-center justify-center py-0.5">
<span class="text-xs font-bold text-muted-foreground">
{{ t('rightSidePanel.missingModels.or') }}
</span>
</div>
<Select
:model-value="modelValue"
:disabled="options.length === 0"
@update:model-value="handleSelect"
>
<SelectTrigger
size="md"
class="border-transparent bg-secondary-background text-xs hover:border-interface-stroke"
>
<SelectValue
:placeholder="t('rightSidePanel.missingModels.useFromLibrary')"
/>
</SelectTrigger>
<SelectContent>
<template v-if="options.length > 4" #prepend>
<div class="px-1 pb-1.5">
<div
class="flex items-center gap-1.5 rounded-md border border-border-default px-2"
>
<i
aria-hidden="true"
class="icon-[lucide--search] size-3.5 shrink-0 text-muted-foreground"
/>
<input
v-model="filterQuery"
type="text"
:aria-label="t('g.searchPlaceholder', { subject: '' })"
class="h-7 w-full border-none bg-transparent text-xs outline-none placeholder:text-muted-foreground"
:placeholder="t('g.searchPlaceholder', { subject: '' })"
@keydown.stop
/>
</div>
</div>
</template>
<SelectItem
v-for="option in filteredOptions"
:key="option.value"
:value="option.value"
class="text-xs"
>
{{ option.name }}
</SelectItem>
<div
v-if="filteredOptions.length === 0"
role="status"
class="px-3 py-2 text-xs text-muted-foreground"
>
{{ t('g.noResultsFound') }}
</div>
</SelectContent>
</Select>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useFuse } from '@vueuse/integrations/useFuse'
import Select from '@/components/ui/select/Select.vue'
import SelectContent from '@/components/ui/select/SelectContent.vue'
import SelectTrigger from '@/components/ui/select/SelectTrigger.vue'
import SelectValue from '@/components/ui/select/SelectValue.vue'
import SelectItem from '@/components/ui/select/SelectItem.vue'
const { options, showDivider = false } = defineProps<{
modelValue: string | undefined
options: { name: string; value: string }[]
showDivider?: boolean
}>()
const emit = defineEmits<{
select: [value: string]
}>()
const { t } = useI18n()
const filterQuery = ref('')
watch(
() => options.length,
(len) => {
if (len <= 4) filterQuery.value = ''
}
)
const { results: fuseResults } = useFuse(filterQuery, () => options, {
fuseOptions: {
keys: ['name'],
threshold: 0.4,
ignoreLocation: true
},
matchAllWhenSearchEmpty: true
})
const filteredOptions = computed(() => fuseResults.value.map((r) => r.item))
function handleSelect(value: unknown) {
if (typeof value === 'string') {
filterQuery.value = ''
emit('select', value)
}
}
</script>

View File

@@ -0,0 +1,224 @@
<template>
<div class="flex w-full flex-col pb-3">
<!-- Model header -->
<div class="flex h-8 w-full items-center gap-2">
<i
aria-hidden="true"
class="text-foreground icon-[lucide--file-check] size-4 shrink-0"
/>
<div class="flex min-w-0 flex-1 items-center">
<p
class="text-foreground min-w-0 truncate text-sm font-medium"
:title="model.name"
>
{{ model.name }} ({{ model.referencingNodes.length }})
</p>
<Button
variant="textonly"
size="icon-sm"
class="size-8 shrink-0 hover:bg-transparent"
:aria-label="t('rightSidePanel.missingModels.copyModelName')"
:title="t('rightSidePanel.missingModels.copyModelName')"
@click="copyToClipboard(model.name)"
>
<i
aria-hidden="true"
class="icon-[lucide--copy] size-3.5 text-muted-foreground"
/>
</Button>
</div>
<Button
variant="textonly"
size="icon-sm"
:aria-label="t('rightSidePanel.missingModels.confirmSelection')"
:disabled="!canConfirm"
:class="
cn(
'size-8 shrink-0 rounded-lg transition-colors',
canConfirm ? 'bg-primary/10 hover:bg-primary/15' : 'opacity-20'
)
"
@click="handleLibrarySelect"
>
<i
aria-hidden="true"
class="icon-[lucide--check] size-4"
:class="canConfirm ? 'text-primary' : 'text-foreground'"
/>
</Button>
<Button
v-if="model.referencingNodes.length > 0"
variant="textonly"
size="icon-sm"
:aria-label="
expanded
? t('rightSidePanel.missingModels.collapseNodes')
: t('rightSidePanel.missingModels.expandNodes')
"
:class="
cn(
'size-8 shrink-0 transition-transform duration-200 hover:bg-transparent',
expanded && 'rotate-180'
)
"
@click="toggleModelExpand(modelKey)"
>
<i
aria-hidden="true"
class="icon-[lucide--chevron-down] size-4 text-muted-foreground group-hover:text-base-foreground"
/>
</Button>
</div>
<!-- Referencing nodes -->
<TransitionCollapse>
<div
v-if="expanded"
class="mb-1 flex flex-col gap-0.5 overflow-hidden pl-6"
>
<div
v-for="ref in model.referencingNodes"
:key="`${String(ref.nodeId)}::${ref.widgetName}`"
class="flex h-7 items-center"
>
<span
v-if="showNodeIdBadge"
class="mr-1 shrink-0 rounded-md bg-secondary-background-selected px-2 py-0.5 font-mono text-xs font-bold text-muted-foreground"
>
#{{ ref.nodeId }}
</span>
<p class="min-w-0 flex-1 truncate text-xs text-muted-foreground">
{{ getNodeDisplayLabel(ref.nodeId, model.representative.nodeType) }}
</p>
<Button
variant="textonly"
size="icon-sm"
:aria-label="t('rightSidePanel.missingModels.locateNode')"
class="mr-1 size-6 shrink-0 text-muted-foreground hover:text-base-foreground"
@click="emit('locateModel', String(ref.nodeId))"
>
<i aria-hidden="true" class="icon-[lucide--locate] size-3" />
</Button>
</div>
</div>
</TransitionCollapse>
<!-- Status card -->
<TransitionCollapse>
<MissingModelStatusCard
v-if="selectedLibraryModel[modelKey]"
:model-name="selectedLibraryModel[modelKey]"
:is-download-active="isDownloadActive"
:download-status="downloadStatus"
:category-mismatch="importCategoryMismatch[modelKey]"
@cancel="cancelLibrarySelect(modelKey)"
/>
</TransitionCollapse>
<!-- Input area -->
<TransitionCollapse>
<div
v-if="!selectedLibraryModel[modelKey]"
class="mt-1 flex flex-col gap-2"
>
<template v-if="isAssetSupported">
<MissingModelUrlInput
:model-key="modelKey"
:directory="directory"
:type-mismatch="typeMismatch"
/>
</template>
<TransitionCollapse>
<MissingModelLibrarySelect
v-if="!urlInputs[modelKey]"
:model-value="getComboValue(model.representative)"
:options="comboOptions"
:show-divider="model.representative.isAssetSupported"
@select="handleComboSelect(modelKey, $event)"
/>
</TransitionCollapse>
</div>
</TransitionCollapse>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'
import { cn } from '@/utils/tailwindUtil'
import Button from '@/components/ui/button/Button.vue'
import TransitionCollapse from '@/components/rightSidePanel/layout/TransitionCollapse.vue'
import MissingModelStatusCard from '@/platform/missingModel/components/MissingModelStatusCard.vue'
import MissingModelUrlInput from '@/platform/missingModel/components/MissingModelUrlInput.vue'
import MissingModelLibrarySelect from '@/platform/missingModel/components/MissingModelLibrarySelect.vue'
import type { MissingModelViewModel } from '@/platform/missingModel/types'
import {
useMissingModelInteractions,
getModelStateKey,
getNodeDisplayLabel,
getComboValue
} from '@/platform/missingModel/composables/useMissingModelInteractions'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
const { model, directory, isAssetSupported } = defineProps<{
model: MissingModelViewModel
directory: string | null
showNodeIdBadge: boolean
isAssetSupported: boolean
}>()
const emit = defineEmits<{
locateModel: [nodeId: string]
}>()
const { t } = useI18n()
const { copyToClipboard } = useCopyToClipboard()
const modelKey = computed(() =>
getModelStateKey(model.name, directory, isAssetSupported)
)
const downloadStatus = computed(() => getDownloadStatus(modelKey.value))
const comboOptions = computed(() => getComboOptions(model.representative))
const canConfirm = computed(() => isSelectionConfirmable(modelKey.value))
const expanded = computed(() => isModelExpanded(modelKey.value))
const typeMismatch = computed(() => getTypeMismatch(modelKey.value, directory))
const isDownloadActive = computed(
() =>
downloadStatus.value?.status === 'running' ||
downloadStatus.value?.status === 'created'
)
const store = useMissingModelStore()
const { selectedLibraryModel, importCategoryMismatch, urlInputs } =
storeToRefs(store)
const {
toggleModelExpand,
isModelExpanded,
getComboOptions,
handleComboSelect,
isSelectionConfirmable,
cancelLibrarySelect,
confirmLibrarySelect,
getTypeMismatch,
getDownloadStatus
} = useMissingModelInteractions()
function handleLibrarySelect() {
confirmLibrarySelect(
modelKey.value,
model.name,
model.referencingNodes,
directory
)
}
</script>

View File

@@ -0,0 +1,108 @@
<template>
<div
aria-live="polite"
class="bg-foreground/5 relative mt-1 overflow-hidden rounded-lg border border-interface-stroke p-2"
>
<!-- Progress bar fill -->
<div
v-if="isDownloadActive"
class="absolute inset-y-0 left-0 bg-primary/10 transition-all duration-200 ease-linear"
:style="{ width: (downloadStatus?.progress ?? 0) * 100 + '%' }"
/>
<div class="relative z-10 flex items-center gap-2">
<div class="flex size-8 shrink-0 items-center justify-center">
<i
v-if="categoryMismatch"
aria-hidden="true"
class="mt-0.5 icon-[lucide--triangle-alert] size-5 text-warning-background"
/>
<i
v-else-if="downloadStatus?.status === 'failed'"
aria-hidden="true"
class="icon-[lucide--circle-alert] size-5 text-destructive-background"
/>
<i
v-else-if="downloadStatus?.status === 'completed'"
aria-hidden="true"
class="icon-[lucide--check-circle] size-5 text-success-background"
/>
<i
v-else-if="isDownloadActive"
aria-hidden="true"
class="icon-[lucide--loader-circle] size-5 animate-spin text-muted-foreground"
/>
<i
v-else
aria-hidden="true"
class="icon-[lucide--file-check] size-5 text-muted-foreground"
/>
</div>
<div class="flex min-w-0 flex-1 flex-col justify-center">
<span class="text-foreground truncate text-xs/tight font-medium">
{{ modelName }}
</span>
<span class="mt-0.5 text-xs/tight text-muted-foreground">
<template v-if="categoryMismatch">
{{
t('rightSidePanel.missingModels.alreadyExistsInCategory', {
category: categoryMismatch
})
}}
</template>
<template v-else-if="isDownloadActive">
{{ t('rightSidePanel.missingModels.importing') }}
{{ Math.round((downloadStatus?.progress ?? 0) * 100) }}%
</template>
<template v-else-if="downloadStatus?.status === 'completed'">
{{ t('rightSidePanel.missingModels.imported') }}
</template>
<template v-else-if="downloadStatus?.status === 'failed'">
{{
downloadStatus?.error ||
t('rightSidePanel.missingModels.importFailed')
}}
</template>
<template v-else>
{{ t('rightSidePanel.missingModels.usingFromLibrary') }}
</template>
</span>
</div>
<Button
variant="textonly"
size="icon-sm"
:aria-label="t('rightSidePanel.missingModels.cancelSelection')"
class="relative z-10 size-6 shrink-0 text-muted-foreground hover:text-base-foreground"
@click="emit('cancel')"
>
<i aria-hidden="true" class="icon-[lucide--circle-x] size-4" />
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import type { AssetDownload } from '@/stores/assetDownloadStore'
const {
modelName,
isDownloadActive,
downloadStatus = null,
categoryMismatch = null
} = defineProps<{
modelName: string
isDownloadActive: boolean
downloadStatus?: AssetDownload | null
categoryMismatch?: string | null
}>()
const emit = defineEmits<{
cancel: []
}>()
const { t } = useI18n()
</script>

View File

@@ -0,0 +1,156 @@
<template>
<div
:class="
cn(
'flex h-8 items-center rounded-lg border border-transparent bg-secondary-background px-3 transition-colors focus-within:border-interface-stroke',
!canImportModels && 'cursor-pointer'
)
"
v-bind="upgradePromptAttrs"
@click="!canImportModels && showUploadDialog()"
>
<label :for="`url-input-${modelKey}`" class="sr-only">
{{ t('rightSidePanel.missingModels.urlPlaceholder') }}
</label>
<input
:id="`url-input-${modelKey}`"
type="text"
:value="urlInputs[modelKey] ?? ''"
:readonly="!canImportModels"
:placeholder="t('rightSidePanel.missingModels.urlPlaceholder')"
:class="
cn(
'text-foreground w-full border-none bg-transparent text-xs outline-none placeholder:text-muted-foreground',
!canImportModels && 'pointer-events-none opacity-60'
)
"
@input="
handleUrlInput(modelKey, ($event.target as HTMLInputElement).value)
"
/>
<Button
v-if="urlInputs[modelKey]"
variant="textonly"
size="icon-sm"
:aria-label="t('rightSidePanel.missingModels.clearUrl')"
class="ml-1 shrink-0"
@click.stop="handleUrlInput(modelKey, '')"
>
<i aria-hidden="true" class="icon-[lucide--x] size-3.5" />
</Button>
</div>
<TransitionCollapse>
<div v-if="urlMetadata[modelKey]" class="flex flex-col gap-2">
<div class="flex items-center gap-2 px-0.5 pt-0.5">
<span class="text-foreground min-w-0 truncate text-xs font-bold">
{{ urlMetadata[modelKey]?.filename }}
</span>
<span
v-if="(urlMetadata[modelKey]?.content_length ?? 0) > 0"
class="shrink-0 rounded-sm bg-secondary-background-selected px-1.5 py-0.5 text-xs font-medium text-muted-foreground"
>
{{ formatSize(urlMetadata[modelKey]?.content_length ?? 0) }}
</span>
</div>
<div v-if="typeMismatch" class="flex items-start gap-1.5 px-0.5">
<i
aria-hidden="true"
class="mt-0.5 icon-[lucide--triangle-alert] size-3 shrink-0 text-warning-background"
/>
<span class="text-xs/tight text-warning-background">
{{
t('rightSidePanel.missingModels.typeMismatch', {
detectedType: typeMismatch
})
}}
</span>
</div>
<div class="pt-0.5">
<Button
variant="primary"
class="h-9 w-full justify-center gap-2 text-sm font-semibold"
:loading="urlImporting[modelKey]"
@click="handleImport(modelKey, directory)"
>
<i aria-hidden="true" class="icon-[lucide--download] size-4" />
{{
typeMismatch
? t('rightSidePanel.missingModels.importAnyway')
: t('rightSidePanel.missingModels.import')
}}
</Button>
</div>
</div>
</TransitionCollapse>
<TransitionCollapse>
<div
v-if="urlFetching[modelKey]"
aria-live="polite"
class="flex items-center justify-center py-2"
>
<i
aria-hidden="true"
class="icon-[lucide--loader-circle] size-4 animate-spin text-muted-foreground"
/>
<span class="sr-only">{{ t('g.loading') }}</span>
</div>
</TransitionCollapse>
<TransitionCollapse>
<div v-if="urlErrors[modelKey]" class="px-0.5" role="alert">
<span class="text-xs text-destructive-background-hover">
{{ urlErrors[modelKey] }}
</span>
</div>
</TransitionCollapse>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'
import { cn } from '@/utils/tailwindUtil'
import Button from '@/components/ui/button/Button.vue'
import TransitionCollapse from '@/components/rightSidePanel/layout/TransitionCollapse.vue'
import { formatSize } from '@/utils/formatUtil'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useMissingModelInteractions } from '@/platform/missingModel/composables/useMissingModelInteractions'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useModelUpload } from '@/platform/assets/composables/useModelUpload'
const { modelKey, directory, typeMismatch } = defineProps<{
modelKey: string
directory: string | null
typeMismatch: string | null
}>()
const { t } = useI18n()
const { flags } = useFeatureFlags()
const canImportModels = computed(() => flags.privateModelsEnabled)
const { showUploadDialog } = useModelUpload()
const store = useMissingModelStore()
const { urlInputs, urlMetadata, urlFetching, urlErrors, urlImporting } =
storeToRefs(store)
const { handleUrlInput, handleImport } = useMissingModelInteractions()
const upgradePromptAttrs = computed(() =>
canImportModels.value
? {}
: {
role: 'button',
tabindex: 0,
onKeydown: (e: KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
showUploadDialog()
}
}
}
)
</script>

View File

@@ -0,0 +1,516 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { MissingModelCandidate } from '@/platform/missingModel/types'
const mockGetNodeByExecutionId = vi.fn()
const mockResolveNodeDisplayName = vi.fn()
const mockValidateSourceUrl = vi.fn()
const mockGetAssetMetadata = vi.fn()
const mockGetAssetDisplayName = vi.fn((a: { name: string }) => a.name)
const mockGetAssetFilename = vi.fn((a: { name: string }) => a.name)
const mockGetAssets = vi.fn()
const mockUpdateModelsForNodeType = vi.fn()
const mockGetAllNodeProviders = vi.fn()
const mockDownloadList = vi.fn(
(): Array<{ taskId: string; status: string }> => []
)
vi.mock('@/i18n', () => ({
st: vi.fn((_key: string, fallback: string) => fallback)
}))
vi.mock('@/platform/distribution/types', () => ({
isCloud: false
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key
})
}))
vi.mock('@/scripts/app', () => ({
app: {
rootGraph: null
}
}))
vi.mock('@/utils/graphTraversalUtil', () => ({
getNodeByExecutionId: (...args: unknown[]) =>
mockGetNodeByExecutionId(...args)
}))
vi.mock('@/utils/nodeTitleUtil', () => ({
resolveNodeDisplayName: (...args: unknown[]) =>
mockResolveNodeDisplayName(...args)
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({})
}))
vi.mock('@/stores/assetsStore', () => ({
useAssetsStore: () => ({
getAssets: mockGetAssets,
updateModelsForNodeType: mockUpdateModelsForNodeType,
invalidateModelsForCategory: vi.fn(),
updateModelsForTag: vi.fn()
})
}))
vi.mock('@/stores/assetDownloadStore', () => ({
useAssetDownloadStore: () => ({
get downloadList() {
return mockDownloadList()
},
trackDownload: vi.fn()
})
}))
vi.mock('@/stores/modelToNodeStore', () => ({
useModelToNodeStore: () => ({
getAllNodeProviders: mockGetAllNodeProviders
})
}))
vi.mock('@/platform/assets/services/assetService', () => ({
assetService: {
getAssetMetadata: (...args: unknown[]) => mockGetAssetMetadata(...args)
}
}))
vi.mock('@/platform/assets/utils/assetMetadataUtils', () => ({
getAssetDisplayName: (a: { name: string }) => mockGetAssetDisplayName(a),
getAssetFilename: (a: { name: string }) => mockGetAssetFilename(a)
}))
vi.mock('@/platform/assets/importSources/civitaiImportSource', () => ({
civitaiImportSource: {
type: 'civitai',
name: 'Civitai',
hostnames: ['civitai.com']
}
}))
vi.mock('@/platform/assets/importSources/huggingfaceImportSource', () => ({
huggingfaceImportSource: {
type: 'huggingface',
name: 'Hugging Face',
hostnames: ['huggingface.co']
}
}))
vi.mock('@/platform/assets/utils/importSourceUtil', () => ({
validateSourceUrl: (...args: unknown[]) => mockValidateSourceUrl(...args)
}))
import { app } from '@/scripts/app'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import {
getComboValue,
getModelStateKey,
getNodeDisplayLabel,
useMissingModelInteractions
} from './useMissingModelInteractions'
function makeCandidate(
overrides: Partial<MissingModelCandidate> = {}
): MissingModelCandidate {
return {
name: 'model.safetensors',
nodeId: '1',
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
isAssetSupported: false,
isMissing: true,
...overrides
}
}
describe('useMissingModelInteractions', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.resetAllMocks()
mockGetAssetDisplayName.mockImplementation((a: { name: string }) => a.name)
mockGetAssetFilename.mockImplementation((a: { name: string }) => a.name)
mockDownloadList.mockImplementation(
(): Array<{ taskId: string; status: string }> => []
)
;(app as { rootGraph: unknown }).rootGraph = null
})
describe('getModelStateKey', () => {
it('returns key with supported prefix when asset is supported', () => {
expect(getModelStateKey('model.safetensors', 'checkpoints', true)).toBe(
'supported::checkpoints::model.safetensors'
)
})
it('returns key with unsupported prefix when asset is not supported', () => {
expect(getModelStateKey('model.safetensors', 'loras', false)).toBe(
'unsupported::loras::model.safetensors'
)
})
it('handles null directory', () => {
expect(getModelStateKey('model.safetensors', null, true)).toBe(
'supported::::model.safetensors'
)
})
})
describe('getNodeDisplayLabel', () => {
it('returns fallback when graph is null', () => {
;(app as { rootGraph: unknown }).rootGraph = null
expect(getNodeDisplayLabel('1', 'Node #1')).toBe('Node #1')
})
it('calls resolveNodeDisplayName when graph is available', () => {
const mockGraph = {}
const mockNode = { id: 1 }
;(app as { rootGraph: unknown }).rootGraph = mockGraph
mockGetNodeByExecutionId.mockReturnValue(mockNode)
mockResolveNodeDisplayName.mockReturnValue('My Checkpoint')
const result = getNodeDisplayLabel('1', 'Node #1')
expect(mockGetNodeByExecutionId).toHaveBeenCalledWith(mockGraph, '1')
expect(result).toBe('My Checkpoint')
})
})
describe('getComboValue', () => {
it('returns undefined when node is not found', () => {
;(app as { rootGraph: unknown }).rootGraph = {}
mockGetNodeByExecutionId.mockReturnValue(null)
const result = getComboValue(makeCandidate())
expect(result).toBeUndefined()
})
it('returns undefined when widget is not found', () => {
;(app as { rootGraph: unknown }).rootGraph = {}
mockGetNodeByExecutionId.mockReturnValue({
widgets: [{ name: 'other_widget', value: 'test' }]
})
const result = getComboValue(makeCandidate())
expect(result).toBeUndefined()
})
it('returns string value directly', () => {
;(app as { rootGraph: unknown }).rootGraph = {}
mockGetNodeByExecutionId.mockReturnValue({
widgets: [{ name: 'ckpt_name', value: 'v1-5.safetensors' }]
})
expect(getComboValue(makeCandidate())).toBe('v1-5.safetensors')
})
it('returns stringified number value', () => {
;(app as { rootGraph: unknown }).rootGraph = {}
mockGetNodeByExecutionId.mockReturnValue({
widgets: [{ name: 'ckpt_name', value: 42 }]
})
expect(getComboValue(makeCandidate())).toBe('42')
})
it('returns undefined for unexpected types', () => {
;(app as { rootGraph: unknown }).rootGraph = {}
mockGetNodeByExecutionId.mockReturnValue({
widgets: [{ name: 'ckpt_name', value: { complex: true } }]
})
expect(getComboValue(makeCandidate())).toBeUndefined()
})
it('returns undefined when nodeId is null', () => {
const result = getComboValue(makeCandidate({ nodeId: undefined }))
expect(result).toBeUndefined()
})
})
describe('toggleModelExpand / isModelExpanded', () => {
it('starts collapsed by default', () => {
const { isModelExpanded } = useMissingModelInteractions()
expect(isModelExpanded('key1')).toBe(false)
})
it('toggles to expanded', () => {
const { toggleModelExpand, isModelExpanded } =
useMissingModelInteractions()
toggleModelExpand('key1')
expect(isModelExpanded('key1')).toBe(true)
})
it('toggles back to collapsed', () => {
const { toggleModelExpand, isModelExpanded } =
useMissingModelInteractions()
toggleModelExpand('key1')
toggleModelExpand('key1')
expect(isModelExpanded('key1')).toBe(false)
})
})
describe('handleComboSelect', () => {
it('sets selectedLibraryModel in store', () => {
const store = useMissingModelStore()
const { handleComboSelect } = useMissingModelInteractions()
handleComboSelect('key1', 'model_v2.safetensors')
expect(store.selectedLibraryModel['key1']).toBe('model_v2.safetensors')
})
it('does not set value when undefined', () => {
const store = useMissingModelStore()
const { handleComboSelect } = useMissingModelInteractions()
handleComboSelect('key1', undefined)
expect(store.selectedLibraryModel['key1']).toBeUndefined()
})
})
describe('isSelectionConfirmable', () => {
it('returns false when no selection exists', () => {
const { isSelectionConfirmable } = useMissingModelInteractions()
expect(isSelectionConfirmable('key1')).toBe(false)
})
it('returns false when download is running', () => {
const store = useMissingModelStore()
store.selectedLibraryModel['key1'] = 'model.safetensors'
store.importTaskIds['key1'] = 'task-123'
mockDownloadList.mockReturnValue([
{ taskId: 'task-123', status: 'running' }
])
const { isSelectionConfirmable } = useMissingModelInteractions()
expect(isSelectionConfirmable('key1')).toBe(false)
})
it('returns false when importCategoryMismatch exists', () => {
const store = useMissingModelStore()
store.selectedLibraryModel['key1'] = 'model.safetensors'
store.importCategoryMismatch['key1'] = 'loras'
const { isSelectionConfirmable } = useMissingModelInteractions()
expect(isSelectionConfirmable('key1')).toBe(false)
})
it('returns true when selection is ready with no active download', () => {
const store = useMissingModelStore()
store.selectedLibraryModel['key1'] = 'model.safetensors'
mockDownloadList.mockReturnValue([])
const { isSelectionConfirmable } = useMissingModelInteractions()
expect(isSelectionConfirmable('key1')).toBe(true)
})
})
describe('cancelLibrarySelect', () => {
it('clears selectedLibraryModel and importCategoryMismatch', () => {
const store = useMissingModelStore()
store.selectedLibraryModel['key1'] = 'model.safetensors'
store.importCategoryMismatch['key1'] = 'loras'
const { cancelLibrarySelect } = useMissingModelInteractions()
cancelLibrarySelect('key1')
expect(store.selectedLibraryModel['key1']).toBeUndefined()
expect(store.importCategoryMismatch['key1']).toBeUndefined()
})
})
describe('confirmLibrarySelect', () => {
it('updates widget values on referencing nodes and removes missing model', () => {
const mockGraph = {}
;(app as { rootGraph: unknown }).rootGraph = mockGraph
const widget1 = { name: 'ckpt_name', value: 'old_model.safetensors' }
const widget2 = { name: 'ckpt_name', value: 'old_model.safetensors' }
const node1 = { widgets: [widget1] }
const node2 = { widgets: [widget2] }
mockGetNodeByExecutionId.mockImplementation(
(_graph: unknown, id: string) => {
if (id === '10') return node1
if (id === '20') return node2
return null
}
)
const store = useMissingModelStore()
store.selectedLibraryModel['key1'] = 'new_model.safetensors'
store.setMissingModels([
makeCandidate({ name: 'old_model.safetensors', nodeId: '10' }),
makeCandidate({ name: 'old_model.safetensors', nodeId: '20' })
])
const removeSpy = vi.spyOn(store, 'removeMissingModelByNameOnNodes')
const { confirmLibrarySelect } = useMissingModelInteractions()
confirmLibrarySelect(
'key1',
'old_model.safetensors',
[
{ nodeId: '10', widgetName: 'ckpt_name' },
{ nodeId: '20', widgetName: 'ckpt_name' }
],
null
)
expect(widget1.value).toBe('new_model.safetensors')
expect(widget2.value).toBe('new_model.safetensors')
expect(removeSpy).toHaveBeenCalledWith(
'old_model.safetensors',
new Set(['10', '20'])
)
expect(store.selectedLibraryModel['key1']).toBeUndefined()
})
it('does nothing when no selection exists', () => {
;(app as { rootGraph: unknown }).rootGraph = {}
const store = useMissingModelStore()
const removeSpy = vi.spyOn(store, 'removeMissingModelByNameOnNodes')
const { confirmLibrarySelect } = useMissingModelInteractions()
confirmLibrarySelect('key1', 'model.safetensors', [], null)
expect(removeSpy).not.toHaveBeenCalled()
})
it('does nothing when graph is null', () => {
;(app as { rootGraph: unknown }).rootGraph = null
const store = useMissingModelStore()
store.selectedLibraryModel['key1'] = 'new.safetensors'
const removeSpy = vi.spyOn(store, 'removeMissingModelByNameOnNodes')
const { confirmLibrarySelect } = useMissingModelInteractions()
confirmLibrarySelect('key1', 'model.safetensors', [], null)
expect(removeSpy).not.toHaveBeenCalled()
})
it('refreshes model cache when directory is provided', () => {
;(app as { rootGraph: unknown }).rootGraph = {}
mockGetNodeByExecutionId.mockReturnValue(null)
mockGetAllNodeProviders.mockReturnValue([
{ nodeDef: { name: 'CheckpointLoaderSimple' } }
])
const store = useMissingModelStore()
store.selectedLibraryModel['key1'] = 'new.safetensors'
const { confirmLibrarySelect } = useMissingModelInteractions()
confirmLibrarySelect('key1', 'model.safetensors', [], 'checkpoints')
expect(mockGetAllNodeProviders).toHaveBeenCalledWith('checkpoints')
})
})
describe('handleUrlInput', () => {
it('clears previous state on new input', () => {
const store = useMissingModelStore()
store.urlMetadata['key1'] = { name: 'old' } as never
store.urlErrors['key1'] = 'old error'
store.urlFetching['key1'] = true
const { handleUrlInput } = useMissingModelInteractions()
handleUrlInput('key1', 'https://civitai.com/models/123')
expect(store.urlInputs['key1']).toBe('https://civitai.com/models/123')
expect(store.urlMetadata['key1']).toBeUndefined()
expect(store.urlErrors['key1']).toBeUndefined()
expect(store.urlFetching['key1']).toBe(false)
})
it('does not set debounce timer for empty input', () => {
const store = useMissingModelStore()
const setTimerSpy = vi.spyOn(store, 'setDebounceTimer')
const { handleUrlInput } = useMissingModelInteractions()
handleUrlInput('key1', ' ')
expect(setTimerSpy).not.toHaveBeenCalled()
})
it('sets debounce timer for non-empty input', () => {
const store = useMissingModelStore()
const setTimerSpy = vi.spyOn(store, 'setDebounceTimer')
const { handleUrlInput } = useMissingModelInteractions()
handleUrlInput('key1', 'https://civitai.com/models/123')
expect(setTimerSpy).toHaveBeenCalledWith(
'key1',
expect.any(Function),
800
)
})
it('clears previous debounce timer', () => {
const store = useMissingModelStore()
const clearTimerSpy = vi.spyOn(store, 'clearDebounceTimer')
const { handleUrlInput } = useMissingModelInteractions()
handleUrlInput('key1', 'https://civitai.com/models/123')
expect(clearTimerSpy).toHaveBeenCalledWith('key1')
})
})
describe('getTypeMismatch', () => {
it('returns null when groupDirectory is null', () => {
const { getTypeMismatch } = useMissingModelInteractions()
expect(getTypeMismatch('key1', null)).toBeNull()
})
it('returns null when no metadata exists', () => {
const { getTypeMismatch } = useMissingModelInteractions()
expect(getTypeMismatch('key1', 'checkpoints')).toBeNull()
})
it('returns null when metadata has no tags', () => {
const store = useMissingModelStore()
store.urlMetadata['key1'] = { name: 'model', tags: [] } as never
const { getTypeMismatch } = useMissingModelInteractions()
expect(getTypeMismatch('key1', 'checkpoints')).toBeNull()
})
it('returns null when detected type matches directory', () => {
const store = useMissingModelStore()
store.urlMetadata['key1'] = {
name: 'model',
tags: ['checkpoints']
} as never
const { getTypeMismatch } = useMissingModelInteractions()
expect(getTypeMismatch('key1', 'checkpoints')).toBeNull()
})
it('returns detected type when it differs from directory', () => {
const store = useMissingModelStore()
store.urlMetadata['key1'] = {
name: 'model',
tags: ['loras']
} as never
const { getTypeMismatch } = useMissingModelInteractions()
expect(getTypeMismatch('key1', 'checkpoints')).toBe('loras')
})
it('returns null when tags contain no recognized model type', () => {
const store = useMissingModelStore()
store.urlMetadata['key1'] = {
name: 'model',
tags: ['other', 'random']
} as never
const { getTypeMismatch } = useMissingModelInteractions()
expect(getTypeMismatch('key1', 'checkpoints')).toBeNull()
})
})
})

View File

@@ -0,0 +1,393 @@
import { useI18n } from 'vue-i18n'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
import { st } from '@/i18n'
import { assetService } from '@/platform/assets/services/assetService'
import {
getAssetDisplayName,
getAssetFilename
} from '@/platform/assets/utils/assetMetadataUtils'
import { civitaiImportSource } from '@/platform/assets/importSources/civitaiImportSource'
import { huggingfaceImportSource } from '@/platform/assets/importSources/huggingfaceImportSource'
import { validateSourceUrl } from '@/platform/assets/utils/importSourceUtil'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useAssetsStore } from '@/stores/assetsStore'
import { useAssetDownloadStore } from '@/stores/assetDownloadStore'
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
import { app } from '@/scripts/app'
import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
import type {
MissingModelCandidate,
MissingModelViewModel
} from '@/platform/missingModel/types'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
const importSources = [civitaiImportSource, huggingfaceImportSource]
const MODEL_TYPE_TAGS = [
'checkpoints',
'loras',
'vae',
'text_encoders',
'diffusion_models'
] as const
const URL_DEBOUNCE_MS = 800
export function getModelStateKey(
modelName: string,
directory: string | null,
isAssetSupported: boolean
): string {
const prefix = isAssetSupported ? 'supported' : 'unsupported'
return `${prefix}::${directory ?? ''}::${modelName}`
}
export function getNodeDisplayLabel(
nodeId: string | number,
fallback: string
): string {
const graph = app.rootGraph
if (!graph) return fallback
const node = getNodeByExecutionId(graph, String(nodeId))
return resolveNodeDisplayName(node, {
emptyLabel: fallback,
untitledLabel: fallback,
st
})
}
function getModelComboWidget(
model: MissingModelCandidate
): { node: LGraphNode; widget: IBaseWidget } | null {
if (model.nodeId == null) return null
const graph = app.rootGraph
if (!graph) return null
const node = getNodeByExecutionId(graph, String(model.nodeId))
if (!node) return null
const widget = node.widgets?.find((w) => w.name === model.widgetName)
if (!widget) return null
return { node, widget }
}
export function getComboValue(
model: MissingModelCandidate
): string | undefined {
const result = getModelComboWidget(model)
if (!result) return undefined
const val = result.widget.value
if (typeof val === 'string') return val
if (typeof val === 'number') return String(val)
return undefined
}
export function useMissingModelInteractions() {
const { t } = useI18n()
const store = useMissingModelStore()
const assetsStore = useAssetsStore()
const assetDownloadStore = useAssetDownloadStore()
const modelToNodeStore = useModelToNodeStore()
const _requestTokens: Record<string, symbol> = {}
function toggleModelExpand(key: string) {
store.modelExpandState[key] = !isModelExpanded(key)
}
function isModelExpanded(key: string): boolean {
return store.modelExpandState[key] ?? false
}
function getComboOptions(
model: MissingModelCandidate
): { name: string; value: string }[] {
if (model.isAssetSupported && model.nodeType) {
const assets = assetsStore.getAssets(model.nodeType) ?? []
return assets.map((asset) => ({
name: getAssetDisplayName(asset),
value: getAssetFilename(asset)
}))
}
const result = getModelComboWidget(model)
if (!result) return []
const values = result.widget.options?.values
if (!Array.isArray(values)) return []
return values.map((v) => ({ name: String(v), value: String(v) }))
}
function handleComboSelect(key: string, value: string | undefined) {
if (value) {
store.selectedLibraryModel[key] = value
}
}
function isSelectionConfirmable(key: string): boolean {
if (!store.selectedLibraryModel[key]) return false
if (store.importCategoryMismatch[key]) return false
const status = getDownloadStatus(key)
if (
status &&
(status.status === 'running' || status.status === 'created')
) {
return false
}
return true
}
function cancelLibrarySelect(key: string) {
delete store.selectedLibraryModel[key]
delete store.importCategoryMismatch[key]
}
/** Apply selected model to referencing nodes, removing only that model from the error list. */
function confirmLibrarySelect(
key: string,
modelName: string,
referencingNodes: MissingModelViewModel['referencingNodes'],
directory: string | null
) {
const value = store.selectedLibraryModel[key]
if (!value) return
const graph = app.rootGraph
if (!graph) return
if (directory) {
const providers = modelToNodeStore.getAllNodeProviders(directory)
void Promise.allSettled(
providers.map((provider) =>
assetsStore.updateModelsForNodeType(provider.nodeDef.name)
)
).then((results) => {
for (const r of results) {
if (r.status === 'rejected') {
console.warn(
'[Missing Model] Failed to refresh model cache:',
r.reason
)
}
}
})
}
for (const ref of referencingNodes) {
const node = getNodeByExecutionId(graph, String(ref.nodeId))
if (node) {
const widget = node.widgets?.find((w) => w.name === ref.widgetName)
if (widget) {
widget.value = value
widget.callback?.(value)
}
node.graph?.setDirtyCanvas(true, true)
}
}
delete store.selectedLibraryModel[key]
const nodeIdSet = new Set(referencingNodes.map((ref) => String(ref.nodeId)))
store.removeMissingModelByNameOnNodes(modelName, nodeIdSet)
}
function handleUrlInput(key: string, value: string) {
store.urlInputs[key] = value
delete store.urlMetadata[key]
delete store.urlErrors[key]
delete store.importCategoryMismatch[key]
store.urlFetching[key] = false
store.clearDebounceTimer(key)
const trimmed = value.trim()
if (!trimmed) return
store.setDebounceTimer(
key,
() => {
void fetchUrlMetadata(key, trimmed)
},
URL_DEBOUNCE_MS
)
}
async function fetchUrlMetadata(key: string, url: string) {
const source = importSources.find((s) => validateSourceUrl(url, s))
if (!source) {
store.urlErrors[key] = t('rightSidePanel.missingModels.unsupportedUrl')
return
}
const token = Symbol()
_requestTokens[key] = token
store.urlFetching[key] = true
delete store.urlErrors[key]
try {
const metadata = await assetService.getAssetMetadata(url)
if (_requestTokens[key] !== token) return
if (metadata.filename) {
try {
const decoded = decodeURIComponent(metadata.filename)
const basename = decoded.split(/[/\\]/).pop() ?? decoded
if (!basename.includes('..')) {
metadata.filename = basename
}
} catch {
/* keep original */
}
}
store.urlMetadata[key] = metadata
} catch (error) {
if (_requestTokens[key] !== token) return
store.urlErrors[key] =
error instanceof Error
? error.message
: t('rightSidePanel.missingModels.metadataFetchFailed')
} finally {
if (_requestTokens[key] === token) {
store.urlFetching[key] = false
}
}
}
function getTypeMismatch(
key: string,
groupDirectory: string | null
): string | null {
if (!groupDirectory) return null
const metadata = store.urlMetadata[key]
if (!metadata?.tags?.length) return null
const detectedType = metadata.tags.find((tag) =>
MODEL_TYPE_TAGS.includes(tag as (typeof MODEL_TYPE_TAGS)[number])
)
if (!detectedType) return null
if (detectedType !== groupDirectory) {
return detectedType
}
return null
}
function getDownloadStatus(key: string) {
const taskId = store.importTaskIds[key]
if (!taskId) return null
return (
assetDownloadStore.downloadList.find((d) => d.taskId === taskId) ?? null
)
}
function handleAsyncPending(
key: string,
taskId: string,
modelType: string | undefined,
filename: string
) {
store.importTaskIds[key] = taskId
if (modelType) {
assetDownloadStore.trackDownload(taskId, modelType, filename)
}
}
function handleAsyncCompleted(modelType: string | undefined) {
if (modelType) {
assetsStore.invalidateModelsForCategory(modelType)
void assetsStore.updateModelsForTag(modelType)
}
}
function handleSyncResult(
key: string,
tags: string[],
modelType: string | undefined
) {
const existingCategory = tags.find((tag) =>
MODEL_TYPE_TAGS.includes(tag as (typeof MODEL_TYPE_TAGS)[number])
)
if (existingCategory && modelType && existingCategory !== modelType) {
store.importCategoryMismatch[key] = existingCategory
}
}
async function handleImport(key: string, groupDirectory: string | null) {
const metadata = store.urlMetadata[key]
if (!metadata) return
const url = store.urlInputs[key]?.trim()
if (!url) return
const source = importSources.find((s) => validateSourceUrl(url, s))
if (!source) return
const token = Symbol()
_requestTokens[key] = token
store.urlImporting[key] = true
delete store.urlErrors[key]
delete store.importCategoryMismatch[key]
try {
const modelType = groupDirectory || undefined
const tags = modelType ? ['models', modelType] : ['models']
const filename = metadata.filename || metadata.name || 'model'
const result = await assetService.uploadAssetAsync({
source_url: url,
tags,
user_metadata: {
source: source.type,
source_url: url,
model_type: modelType
}
})
if (_requestTokens[key] !== token) return
if (result.type === 'async' && result.task.status !== 'completed') {
handleAsyncPending(key, result.task.task_id, modelType, filename)
} else if (result.type === 'async') {
handleAsyncCompleted(modelType)
} else if (result.type === 'sync') {
handleSyncResult(key, result.asset.tags ?? [], modelType)
}
store.selectedLibraryModel[key] = filename
} catch (error) {
if (_requestTokens[key] !== token) return
store.urlErrors[key] =
error instanceof Error
? error.message
: t('rightSidePanel.missingModels.importFailed')
} finally {
if (_requestTokens[key] === token) {
store.urlImporting[key] = false
}
}
}
return {
toggleModelExpand,
isModelExpanded,
getComboOptions,
handleComboSelect,
isSelectionConfirmable,
cancelLibrarySelect,
confirmLibrarySelect,
handleUrlInput,
getTypeMismatch,
getDownloadStatus,
handleImport
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,386 @@
import type {
ComfyWorkflowJSON,
NodeId
} from '@/platform/workflow/validation/schemas/workflowSchema'
import { flattenWorkflowNodes } from '@/platform/workflow/validation/schemas/workflowSchema'
import type {
MissingModelCandidate,
MissingModelViewModel,
EmbeddedModelWithSource
} from './types'
import { getAssetFilename } from '@/platform/assets/utils/assetMetadataUtils'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { getSelectedModelsMetadata } from '@/workbench/utils/modelMetadataUtil'
import type { LGraph } from '@/lib/litegraph/src/LGraph'
import type {
IAssetWidget,
IBaseWidget,
IComboWidget
} from '@/lib/litegraph/src/types/widgets'
import {
collectAllNodes,
getExecutionIdByNode
} from '@/utils/graphTraversalUtil'
function isComboWidget(widget: IBaseWidget): widget is IComboWidget {
return widget.type === 'combo'
}
function isAssetWidget(widget: IBaseWidget): widget is IAssetWidget {
return widget.type === 'asset'
}
export const MODEL_FILE_EXTENSIONS = new Set([
'.safetensors',
'.ckpt',
'.pt',
'.pth',
'.bin',
'.sft',
'.onnx',
'.gguf'
])
export function isModelFileName(name: string): boolean {
const lower = name.toLowerCase()
for (const ext of MODEL_FILE_EXTENSIONS) {
if (lower.endsWith(ext)) return true
}
return false
}
function resolveComboOptions(widget: IComboWidget): string[] {
const values = widget.options.values
if (!values) return []
if (typeof values === 'function') return values(widget)
if (Array.isArray(values)) return values
return Object.keys(values)
}
/**
* Scan COMBO and asset widgets on configured graph nodes for model-like values.
* Must be called after `graph.configure()` so widget name/value mappings are accurate.
*
* Non-asset-supported nodes: `isMissing` resolved immediately via widget options.
* Asset-supported nodes: `isMissing` left `undefined` for async verification.
*/
export function scanAllModelCandidates(
rootGraph: LGraph,
isAssetSupported: (nodeType: string, widgetName: string) => boolean,
getDirectory?: (nodeType: string) => string | undefined
): MissingModelCandidate[] {
if (!rootGraph) return []
const allNodes = collectAllNodes(rootGraph)
const candidates: MissingModelCandidate[] = []
for (const node of allNodes) {
if (!node.widgets?.length) continue
const executionId = getExecutionIdByNode(rootGraph, node)
if (!executionId) continue
for (const widget of node.widgets) {
let candidate: MissingModelCandidate | null = null
if (isAssetWidget(widget)) {
candidate = scanAssetWidget(node, widget, executionId, getDirectory)
} else if (isComboWidget(widget)) {
candidate = scanComboWidget(
node,
widget,
executionId,
isAssetSupported,
getDirectory
)
}
if (candidate) candidates.push(candidate)
}
}
return candidates
}
function scanAssetWidget(
node: { type: string },
widget: IAssetWidget,
executionId: string,
getDirectory: ((nodeType: string) => string | undefined) | undefined
): MissingModelCandidate | null {
const value = widget.value
if (!value.trim()) return null
if (!isModelFileName(value)) return null
return {
nodeId: executionId as NodeId,
nodeType: node.type,
widgetName: widget.name,
isAssetSupported: true,
name: value,
directory: getDirectory?.(node.type),
isMissing: undefined
}
}
function scanComboWidget(
node: { type: string },
widget: IComboWidget,
executionId: string,
isAssetSupported: (nodeType: string, widgetName: string) => boolean,
getDirectory: ((nodeType: string) => string | undefined) | undefined
): MissingModelCandidate | null {
const value = widget.value
if (typeof value !== 'string' || !value.trim()) return null
if (!isModelFileName(value)) return null
const nodeIsAssetSupported = isAssetSupported(node.type, widget.name)
const options = resolveComboOptions(widget)
const inOptions = options.includes(value)
return {
nodeId: executionId as NodeId,
nodeType: node.type,
widgetName: widget.name,
isAssetSupported: nodeIsAssetSupported,
name: value,
directory: getDirectory?.(node.type),
isMissing: nodeIsAssetSupported ? undefined : !inOptions
}
}
export async function enrichWithEmbeddedMetadata(
candidates: readonly MissingModelCandidate[],
graphData: ComfyWorkflowJSON,
checkModelInstalled: (name: string, directory: string) => Promise<boolean>,
isAssetSupported?: (nodeType: string, widgetName: string) => boolean
): Promise<MissingModelCandidate[]> {
const allNodes = flattenWorkflowNodes(graphData)
const embeddedModels = collectEmbeddedModelsWithSource(allNodes, graphData)
const enriched = candidates.map((c) => ({ ...c }))
const candidatesByKey = new Map<string, MissingModelCandidate[]>()
for (const c of enriched) {
const dirKey = `${c.name}::${c.directory ?? ''}`
const dirList = candidatesByKey.get(dirKey)
if (dirList) dirList.push(c)
else candidatesByKey.set(dirKey, [c])
const nameKey = c.name
const nameList = candidatesByKey.get(nameKey)
if (nameList) nameList.push(c)
else candidatesByKey.set(nameKey, [c])
}
const deduped: EmbeddedModelWithSource[] = []
const enrichedKeys = new Set<string>()
for (const model of embeddedModels) {
const dedupeKey = `${model.name}::${model.directory}`
if (enrichedKeys.has(dedupeKey)) continue
enrichedKeys.add(dedupeKey)
deduped.push(model)
}
const unmatched: EmbeddedModelWithSource[] = []
for (const model of deduped) {
const dirKey = `${model.name}::${model.directory}`
const exact = candidatesByKey.get(dirKey)
const fallback = candidatesByKey.get(model.name)
const existing = exact?.length ? exact : fallback
if (existing) {
for (const c of existing) {
if (c.directory && c.directory !== model.directory) continue
c.directory ??= model.directory
c.url ??= model.url
c.hash ??= model.hash
c.hashType ??= model.hash_type
}
} else {
unmatched.push(model)
}
}
const settled = await Promise.allSettled(
unmatched.map(async (model) => {
const installed = await checkModelInstalled(model.name, model.directory)
if (installed) return null
const nodeIsAssetSupported = isAssetSupported
? isAssetSupported(model.sourceNodeType, model.sourceWidgetName)
: false
return {
nodeId: model.sourceNodeId,
nodeType: model.sourceNodeType,
widgetName: model.sourceWidgetName,
isAssetSupported: nodeIsAssetSupported,
name: model.name,
directory: model.directory,
url: model.url,
hash: model.hash,
hashType: model.hash_type,
isMissing: nodeIsAssetSupported ? undefined : true
} satisfies MissingModelCandidate
})
)
for (const r of settled) {
if (r.status === 'rejected') {
console.warn(
'[Missing Model Pipeline] checkModelInstalled failed:',
r.reason
)
continue
}
if (r.value) enriched.push(r.value)
}
return enriched
}
function collectEmbeddedModelsWithSource(
allNodes: ReturnType<typeof flattenWorkflowNodes>,
graphData: ComfyWorkflowJSON
): EmbeddedModelWithSource[] {
const result: EmbeddedModelWithSource[] = []
for (const node of allNodes) {
const selected = getSelectedModelsMetadata(
node as Parameters<typeof getSelectedModelsMetadata>[0]
)
if (!selected?.length) continue
for (const model of selected) {
result.push({
...model,
sourceNodeId: node.id,
sourceNodeType: node.type,
sourceWidgetName: findWidgetNameForModel(node, model.name)
})
}
}
// Workflow-level model entries have no originating node; sourceNodeId
// remains undefined and empty-string node type/widget are handled by
// groupCandidatesByName (no nodeId → no referencing node entry).
if (graphData.models?.length) {
for (const model of graphData.models) {
result.push({
...model,
sourceNodeType: '',
sourceWidgetName: ''
})
}
}
return result
}
function findWidgetNameForModel(
node: ReturnType<typeof flattenWorkflowNodes>[number],
modelName: string
): string {
if (Array.isArray(node.widgets_values) || !node.widgets_values) return ''
const wv = node.widgets_values as Record<string, unknown>
for (const [key, val] of Object.entries(wv)) {
if (val === modelName) return key
}
return ''
}
interface AssetVerifier {
updateModelsForNodeType: (nodeType: string) => Promise<void>
getAssets: (nodeType: string) => AssetItem[] | undefined
}
export async function verifyAssetSupportedCandidates(
candidates: MissingModelCandidate[],
signal?: AbortSignal,
assetsStore?: AssetVerifier
): Promise<void> {
if (signal?.aborted) return
const pendingNodeTypes = new Set<string>()
for (const c of candidates) {
if (c.isAssetSupported && c.isMissing === undefined) {
pendingNodeTypes.add(c.nodeType)
}
}
if (pendingNodeTypes.size === 0) return
const store =
assetsStore ?? (await import('@/stores/assetsStore')).useAssetsStore()
const failedNodeTypes = new Set<string>()
await Promise.allSettled(
[...pendingNodeTypes].map(async (nodeType) => {
if (signal?.aborted) return
try {
await store.updateModelsForNodeType(nodeType)
} catch (err) {
failedNodeTypes.add(nodeType)
console.warn(
`[Missing Model Pipeline] Failed to load assets for ${nodeType}:`,
err
)
}
})
)
if (signal?.aborted) return
for (const c of candidates) {
if (!c.isAssetSupported || c.isMissing !== undefined) continue
if (failedNodeTypes.has(c.nodeType)) continue
const assets = store.getAssets(c.nodeType) ?? []
c.isMissing = !isAssetInstalled(c, assets)
}
}
function normalizePath(path: string): string {
return path.replace(/\\/g, '/')
}
function isAssetInstalled(
candidate: MissingModelCandidate,
assets: AssetItem[]
): boolean {
if (candidate.hash && candidate.hashType) {
const candidateHash = `${candidate.hashType}:${candidate.hash}`
if (assets.some((a) => a.asset_hash === candidateHash)) return true
}
const normalizedName = normalizePath(candidate.name)
return assets.some((a) => {
const f = normalizePath(getAssetFilename(a))
return f === normalizedName || f.endsWith('/' + normalizedName)
})
}
export function groupCandidatesByName(
candidates: MissingModelCandidate[]
): MissingModelViewModel[] {
const map = new Map<string, MissingModelViewModel>()
for (const c of candidates) {
const existing = map.get(c.name)
if (existing) {
if (c.nodeId) {
existing.referencingNodes.push({
nodeId: c.nodeId,
widgetName: c.widgetName
})
}
} else {
map.set(c.name, {
name: c.name,
representative: c,
referencingNodes: c.nodeId
? [{ nodeId: c.nodeId, widgetName: c.widgetName }]
: []
})
}
}
return Array.from(map.values())
}

View File

@@ -0,0 +1,189 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { MissingModelCandidate } from '@/platform/missingModel/types'
vi.mock('@/i18n', () => ({
st: vi.fn((_key: string, fallback: string) => fallback)
}))
vi.mock('@/platform/distribution/types', () => ({
isCloud: false
}))
import { useMissingModelStore } from './missingModelStore'
function makeModelCandidate(
name: string,
opts: {
nodeId?: string | number
nodeType?: string
widgetName?: string
isAssetSupported?: boolean
} = {}
): MissingModelCandidate {
return {
name,
nodeId: opts.nodeId ?? '1',
nodeType: opts.nodeType ?? 'CheckpointLoaderSimple',
widgetName: opts.widgetName ?? 'ckpt_name',
isAssetSupported: opts.isAssetSupported ?? false,
isMissing: true
}
}
describe('missingModelStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
describe('setMissingModels', () => {
it('sets missingModelCandidates with provided models', () => {
const store = useMissingModelStore()
store.setMissingModels([makeModelCandidate('model_a.safetensors')])
expect(store.missingModelCandidates).not.toBeNull()
expect(store.missingModelCandidates).toHaveLength(1)
expect(store.hasMissingModels).toBe(true)
})
it('clears missingModelCandidates when given empty array', () => {
const store = useMissingModelStore()
store.setMissingModels([makeModelCandidate('model_a.safetensors')])
expect(store.missingModelCandidates).not.toBeNull()
store.setMissingModels([])
expect(store.missingModelCandidates).toBeNull()
expect(store.hasMissingModels).toBe(false)
})
it('includes model count in missingModelCount', () => {
const store = useMissingModelStore()
store.setMissingModels([
makeModelCandidate('model_a.safetensors'),
makeModelCandidate('model_b.safetensors', { nodeId: '2' })
])
expect(store.missingModelCount).toBe(2)
})
})
describe('hasMissingModelOnNode', () => {
it('returns true when node has missing model', () => {
const store = useMissingModelStore()
store.setMissingModels([
makeModelCandidate('model_a.safetensors', { nodeId: '5' })
])
expect(store.hasMissingModelOnNode('5')).toBe(true)
})
it('returns false when node has no missing model', () => {
const store = useMissingModelStore()
store.setMissingModels([
makeModelCandidate('model_a.safetensors', { nodeId: '5' })
])
expect(store.hasMissingModelOnNode('99')).toBe(false)
})
it('returns false when no models are missing', () => {
const store = useMissingModelStore()
expect(store.hasMissingModelOnNode('1')).toBe(false)
})
})
describe('removeMissingModelByNameOnNodes', () => {
it('removes only the named model from specified nodes', () => {
const store = useMissingModelStore()
store.setMissingModels([
makeModelCandidate('model_a.safetensors', {
nodeId: '1',
widgetName: 'ckpt_name'
}),
makeModelCandidate('model_b.safetensors', {
nodeId: '1',
widgetName: 'vae_name'
}),
makeModelCandidate('model_a.safetensors', {
nodeId: '2',
widgetName: 'ckpt_name'
})
])
store.removeMissingModelByNameOnNodes(
'model_a.safetensors',
new Set(['1'])
)
expect(store.missingModelCandidates).toHaveLength(2)
expect(store.missingModelCandidates![0].name).toBe('model_b.safetensors')
expect(store.missingModelCandidates![1].name).toBe('model_a.safetensors')
expect(String(store.missingModelCandidates![1].nodeId)).toBe('2')
})
it('sets missingModelCandidates to null when all removed', () => {
const store = useMissingModelStore()
store.setMissingModels([
makeModelCandidate('model_a.safetensors', { nodeId: '1' })
])
store.removeMissingModelByNameOnNodes(
'model_a.safetensors',
new Set(['1'])
)
expect(store.missingModelCandidates).toBeNull()
})
})
describe('clearMissingModels', () => {
it('clears missingModelCandidates and interaction state', () => {
const store = useMissingModelStore()
store.setMissingModels([
makeModelCandidate('model_a.safetensors', { nodeId: '1' })
])
store.urlInputs['test-key'] = 'https://example.com'
store.selectedLibraryModel['test-key'] = 'some-model'
expect(store.missingModelCandidates).not.toBeNull()
store.clearMissingModels()
expect(store.missingModelCandidates).toBeNull()
expect(store.hasMissingModels).toBe(false)
expect(store.urlInputs).toEqual({})
expect(store.selectedLibraryModel).toEqual({})
})
})
describe('isWidgetMissingModel', () => {
it('returns true when specific widget has missing model', () => {
const store = useMissingModelStore()
store.setMissingModels([
makeModelCandidate('model_a.safetensors', {
nodeId: '5',
widgetName: 'ckpt_name'
})
])
expect(store.isWidgetMissingModel('5', 'ckpt_name')).toBe(true)
})
it('returns false for different widget on same node', () => {
const store = useMissingModelStore()
store.setMissingModels([
makeModelCandidate('model_a.safetensors', {
nodeId: '5',
widgetName: 'ckpt_name'
})
])
expect(store.isWidgetMissingModel('5', 'lora_name')).toBe(false)
})
it('returns false when no models are missing', () => {
const store = useMissingModelStore()
expect(store.isWidgetMissingModel('1', 'ckpt_name')).toBe(false)
})
})
})

View File

@@ -0,0 +1,201 @@
import { defineStore } from 'pinia'
import { computed, onScopeDispose, ref } from 'vue'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { app } from '@/scripts/app'
import type { MissingModelCandidate } from '@/platform/missingModel/types'
import type { AssetMetadata } from '@/platform/assets/schemas/assetSchema'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { getAncestorExecutionIds } from '@/types/nodeIdentification'
import type { NodeExecutionId } from '@/types/nodeIdentification'
import { getActiveGraphNodeIds } from '@/utils/graphTraversalUtil'
/**
* Missing model error state and interaction state.
* Separated from executionErrorStore to keep domain boundaries clean.
* The executionErrorStore composes from this store for aggregate error flags.
*/
export const useMissingModelStore = defineStore('missingModel', () => {
const canvasStore = useCanvasStore()
const missingModelCandidates = ref<MissingModelCandidate[] | null>(null)
const hasMissingModels = computed(
() => !!missingModelCandidates.value?.length
)
const missingModelCount = computed(
() => missingModelCandidates.value?.length ?? 0
)
const missingModelNodeIds = computed<Set<string>>(() => {
const ids = new Set<string>()
if (!missingModelCandidates.value) return ids
for (const m of missingModelCandidates.value) {
if (m.nodeId != null) ids.add(String(m.nodeId))
}
return ids
})
const missingModelWidgetKeys = computed<Set<string>>(() => {
const keys = new Set<string>()
if (!missingModelCandidates.value) return keys
for (const m of missingModelCandidates.value) {
keys.add(`${String(m.nodeId)}::${m.widgetName}`)
}
return keys
})
/**
* Set of all execution ID prefixes derived from missing model node IDs,
* including the missing model nodes themselves.
*
* Example: missing model on node "65:70:63" → Set { "65", "65:70", "65:70:63" }
*/
const missingModelAncestorExecutionIds = computed<Set<NodeExecutionId>>(
() => {
const ids = new Set<NodeExecutionId>()
for (const nodeId of missingModelNodeIds.value) {
for (const id of getAncestorExecutionIds(nodeId)) {
ids.add(id)
}
}
return ids
}
)
const activeMissingModelGraphIds = computed<Set<string>>(() => {
if (!app.rootGraph) return new Set()
return getActiveGraphNodeIds(
app.rootGraph,
canvasStore.currentGraph ?? app.rootGraph,
missingModelAncestorExecutionIds.value
)
})
// Persists across component re-mounts so that download progress,
// URL inputs, etc. survive tab switches within the right-side panel.
const modelExpandState = ref<Record<string, boolean>>({})
const selectedLibraryModel = ref<Record<string, string>>({})
const importCategoryMismatch = ref<Record<string, string>>({})
const importTaskIds = ref<Record<string, string>>({})
const urlInputs = ref<Record<string, string>>({})
const urlMetadata = ref<Record<string, AssetMetadata | null>>({})
const urlFetching = ref<Record<string, boolean>>({})
const urlErrors = ref<Record<string, string>>({})
const urlImporting = ref<Record<string, boolean>>({})
const _urlDebounceTimers: Record<string, ReturnType<typeof setTimeout>> = {}
let _verificationAbortController: AbortController | null = null
onScopeDispose(cancelDebounceTimers)
function createVerificationAbortController(): AbortController {
_verificationAbortController?.abort()
_verificationAbortController = new AbortController()
return _verificationAbortController
}
function setMissingModels(models: MissingModelCandidate[]) {
missingModelCandidates.value = models.length ? models : null
}
function removeMissingModelByNameOnNodes(
modelName: string,
nodeIds: Set<string>
) {
if (!missingModelCandidates.value) return
missingModelCandidates.value = missingModelCandidates.value.filter(
(m) =>
m.name !== modelName ||
m.nodeId == null ||
!nodeIds.has(String(m.nodeId))
)
if (!missingModelCandidates.value.length)
missingModelCandidates.value = null
}
function hasMissingModelOnNode(nodeLocatorId: string): boolean {
return missingModelNodeIds.value.has(nodeLocatorId)
}
function isWidgetMissingModel(nodeId: string, widgetName: string): boolean {
return missingModelWidgetKeys.value.has(`${nodeId}::${widgetName}`)
}
function isContainerWithMissingModel(node: LGraphNode): boolean {
return activeMissingModelGraphIds.value.has(String(node.id))
}
function cancelDebounceTimers() {
for (const key of Object.keys(_urlDebounceTimers)) {
clearTimeout(_urlDebounceTimers[key])
delete _urlDebounceTimers[key]
}
}
function setDebounceTimer(
key: string,
callback: () => void,
delayMs: number
) {
if (_urlDebounceTimers[key]) {
clearTimeout(_urlDebounceTimers[key])
}
_urlDebounceTimers[key] = setTimeout(callback, delayMs)
}
function clearDebounceTimer(key: string) {
if (_urlDebounceTimers[key]) {
clearTimeout(_urlDebounceTimers[key])
delete _urlDebounceTimers[key]
}
}
function clearMissingModels() {
_verificationAbortController?.abort()
_verificationAbortController = null
missingModelCandidates.value = null
cancelDebounceTimers()
modelExpandState.value = {}
selectedLibraryModel.value = {}
importCategoryMismatch.value = {}
importTaskIds.value = {}
urlInputs.value = {}
urlMetadata.value = {}
urlFetching.value = {}
urlErrors.value = {}
urlImporting.value = {}
}
return {
missingModelCandidates,
hasMissingModels,
missingModelCount,
missingModelNodeIds,
activeMissingModelGraphIds,
setMissingModels,
removeMissingModelByNameOnNodes,
clearMissingModels,
createVerificationAbortController,
hasMissingModelOnNode,
isWidgetMissingModel,
isContainerWithMissingModel,
modelExpandState,
selectedLibraryModel,
importTaskIds,
importCategoryMismatch,
urlInputs,
urlMetadata,
urlFetching,
urlErrors,
urlImporting,
setDebounceTimer,
clearDebounceTimer
}
})

View File

@@ -0,0 +1,53 @@
import type {
ModelFile,
NodeId
} from '@/platform/workflow/validation/schemas/workflowSchema'
/**
* A single (node, widget, model) binding detected by the missing model pipeline.
* The same model name may appear multiple times across different nodes.
*/
export interface MissingModelCandidate {
/** Undefined for workflow-level models not tied to a specific node. */
nodeId?: NodeId
nodeType: string
widgetName: string
isAssetSupported: boolean
name: string
directory?: string
url?: string
hash?: string
hashType?: string
/**
* - `true` — confirmed missing
* - `false` — confirmed installed
* - `undefined` — pending async verification (asset-supported nodes only)
*/
isMissing: boolean | undefined
}
export interface EmbeddedModelWithSource extends ModelFile {
/** Undefined for workflow-level models not tied to a specific node. */
sourceNodeId?: NodeId
sourceNodeType: string
sourceWidgetName: string
}
/** View model grouping multiple candidate references under a single model name. */
export interface MissingModelViewModel {
name: string
representative: MissingModelCandidate
referencingNodes: Array<{
nodeId: NodeId
widgetName: string
}>
}
/** A category group of missing models sharing the same directory. */
export interface MissingModelGroup {
directory: string | null
models: MissingModelViewModel[]
isAssetSupported: boolean
}

View File

@@ -31,6 +31,7 @@ export type RemoteConfig = {
mixpanel_token?: string
posthog_project_token?: string
posthog_api_host?: string
posthog_debug?: boolean
subscription_required?: boolean
server_health_alert?: ServerHealthAlert
max_upload_size?: number

View File

@@ -996,16 +996,6 @@ export const CORE_SETTINGS: SettingParams[] = [
defaultValue: true,
versionAdded: '1.10.5'
},
{
id: 'Comfy.Canvas.AlignNodesWhileDragging',
category: ['LiteGraph', 'Canvas', 'AlignNodesWhileDragging'],
name: 'Align nodes while dragging',
tooltip:
'When enabled in Nodes 2.0, dragging a node selection near another node will snap matching edges and centers together.',
type: 'boolean',
defaultValue: false,
versionAdded: '1.31.0'
},
{
id: 'LiteGraph.Reroute.SplineOffset',
name: 'Reroute spline offset',
@@ -1211,10 +1201,10 @@ export const CORE_SETTINGS: SettingParams[] = [
{
id: 'Comfy.Queue.QPOV2',
category: ['Comfy', 'Queue', 'Layout'],
name: 'Use the unified job queue in the Assets side panel',
name: 'Docked job history/queue panel',
type: 'boolean',
tooltip:
'Replaces the floating job queue panel with an equivalent job queue embedded in the Assets side panel. You can disable this to return to the floating panel layout.',
'Replaces the floating job queue panel with an equivalent job queue embedded in the job history side panel. You can disable this to return to the floating panel layout.',
defaultValue: false,
experimental: true
},

View File

@@ -3,6 +3,7 @@ import type { AuditLog } from '@/services/customerEventsService'
import type {
AuthMetadata,
BeginCheckoutMetadata,
DefaultViewSetMetadata,
EnterLinearMetadata,
ShareFlowMetadata,
ExecutionErrorMetadata,
@@ -27,7 +28,8 @@ import type {
TemplateMetadata,
UiButtonClickMetadata,
WorkflowCreatedMetadata,
WorkflowImportMetadata
WorkflowImportMetadata,
WorkflowSavedMetadata
} from './types'
/**
@@ -157,6 +159,14 @@ export class TelemetryRegistry implements TelemetryDispatcher {
this.dispatch((provider) => provider.trackWorkflowOpened?.(metadata))
}
trackWorkflowSaved(metadata: WorkflowSavedMetadata): void {
this.dispatch((provider) => provider.trackWorkflowSaved?.(metadata))
}
trackDefaultViewSet(metadata: DefaultViewSetMetadata): void {
this.dispatch((provider) => provider.trackDefaultViewSet?.(metadata))
}
trackEnterLinear(metadata: EnterLinearMetadata): void {
this.dispatch((provider) => provider.trackEnterLinear?.(metadata))
}

View File

@@ -14,6 +14,7 @@ import { getExecutionContext } from '../../utils/getExecutionContext'
import type {
AuthMetadata,
CreditTopupMetadata,
DefaultViewSetMetadata,
EnterLinearMetadata,
ShareFlowMetadata,
ExecutionContext,
@@ -40,7 +41,8 @@ import type {
TemplateMetadata,
UiButtonClickMetadata,
WorkflowCreatedMetadata,
WorkflowImportMetadata
WorkflowImportMetadata,
WorkflowSavedMetadata
} from '../../types'
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
import type { RemoteConfig } from '@/platform/remoteConfig/types'
@@ -359,6 +361,14 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
this.trackEvent(TelemetryEvents.WORKFLOW_OPENED, metadata)
}
trackWorkflowSaved(metadata: WorkflowSavedMetadata): void {
this.trackEvent(TelemetryEvents.WORKFLOW_SAVED, metadata)
}
trackDefaultViewSet(metadata: DefaultViewSetMetadata): void {
this.trackEvent(TelemetryEvents.DEFAULT_VIEW_SET, metadata)
}
trackEnterLinear(metadata: EnterLinearMetadata): void {
this.trackEvent(TelemetryEvents.ENTER_LINEAR_MODE, metadata)
}

View File

@@ -80,27 +80,30 @@ describe('PostHogTelemetryProvider', () => {
createProvider()
await vi.dynamicImportSettled()
expect(hoisted.mockInit).toHaveBeenCalledWith('phc_test_token', {
api_host: 'https://t.comfy.org',
autocapture: false,
capture_pageview: false,
capture_pageleave: false,
persistence: 'localStorage+cookie',
debug: false
})
expect(hoisted.mockInit).toHaveBeenCalledWith(
'phc_test_token',
expect.objectContaining({
api_host: 'https://t.comfy.org',
ui_host: 'https://us.posthog.com',
autocapture: false,
capture_pageview: false,
capture_pageleave: false,
persistence: 'localStorage+cookie'
})
)
})
it('uses custom api_host from config when provided', async () => {
it('enables debug mode when posthog_debug is true in config', async () => {
window.__CONFIG__ = {
posthog_project_token: 'phc_test_token',
posthog_api_host: 'https://custom.host.com'
posthog_debug: true
} as typeof window.__CONFIG__
new PostHogTelemetryProvider()
await vi.dynamicImportSettled()
expect(hoisted.mockInit).toHaveBeenCalledWith(
'phc_test_token',
expect.objectContaining({ api_host: 'https://custom.host.com' })
expect.objectContaining({ debug: true })
)
})

View File

@@ -7,6 +7,7 @@ import type { RemoteConfig } from '@/platform/remoteConfig/types'
import type {
AuthMetadata,
DefaultViewSetMetadata,
EnterLinearMetadata,
ShareFlowMetadata,
ExecutionContext,
@@ -34,7 +35,8 @@ import type {
TemplateMetadata,
UiButtonClickMetadata,
WorkflowCreatedMetadata,
WorkflowImportMetadata
WorkflowImportMetadata,
WorkflowSavedMetadata
} from '../../types'
import { TelemetryEvents } from '../../types'
import { getExecutionContext } from '../../utils/getExecutionContext'
@@ -102,11 +104,14 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
this.posthog!.init(apiKey, {
api_host:
window.__CONFIG__?.posthog_api_host || 'https://t.comfy.org',
ui_host: 'https://us.posthog.com',
autocapture: false,
capture_pageview: false,
capture_pageleave: false,
persistence: 'localStorage+cookie',
debug: import.meta.env.VITE_POSTHOG_DEBUG === 'true'
debug:
window.__CONFIG__?.posthog_debug ??
import.meta.env.VITE_POSTHOG_DEBUG === 'true'
})
this.isInitialized = true
this.flushEventQueue()
@@ -344,6 +349,14 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
this.trackEvent(TelemetryEvents.WORKFLOW_OPENED, metadata)
}
trackWorkflowSaved(metadata: WorkflowSavedMetadata): void {
this.trackEvent(TelemetryEvents.WORKFLOW_SAVED, metadata)
}
trackDefaultViewSet(metadata: DefaultViewSetMetadata): void {
this.trackEvent(TelemetryEvents.DEFAULT_VIEW_SET, metadata)
}
trackEnterLinear(metadata: EnterLinearMetadata): void {
this.trackEvent(TelemetryEvents.ENTER_LINEAR_MODE, metadata)
}

View File

@@ -149,6 +149,15 @@ export interface EnterLinearMetadata {
source?: string
}
export interface WorkflowSavedMetadata {
is_app: boolean
is_new: boolean
}
export interface DefaultViewSetMetadata {
default_view: 'app' | 'graph'
}
type ShareFlowStep =
| 'dialog_opened'
| 'save_prompted'
@@ -377,6 +386,8 @@ export interface TelemetryProvider {
// Workflow management events
trackWorkflowImported?(metadata: WorkflowImportMetadata): void
trackWorkflowOpened?(metadata: WorkflowImportMetadata): void
trackWorkflowSaved?(metadata: WorkflowSavedMetadata): void
trackDefaultViewSet?(metadata: DefaultViewSetMetadata): void
trackEnterLinear?(metadata: EnterLinearMetadata): void
trackShareFlow?(metadata: ShareFlowMetadata): void
@@ -490,6 +501,8 @@ export const TelemetryEvents = {
// Workflow Creation
WORKFLOW_CREATED: 'app:workflow_created',
WORKFLOW_SAVED: 'app:workflow_saved',
DEFAULT_VIEW_SET: 'app:default_view_set',
// Execution Lifecycle
EXECUTION_START: 'execution_start',
@@ -540,4 +553,6 @@ export type TelemetryEventProperties =
| WorkflowCreatedMetadata
| EnterLinearMetadata
| ShareFlowMetadata
| WorkflowSavedMetadata
| DefaultViewSetMetadata
| SubscriptionMetadata

View File

@@ -149,6 +149,8 @@ export const useWorkflowService = () => {
await openWorkflow(tempWorkflow)
await workflowStore.saveWorkflow(tempWorkflow)
}
useTelemetry()?.trackWorkflowSaved({ is_app: isApp, is_new: true })
return true
}
@@ -189,6 +191,7 @@ export const useWorkflowService = () => {
}
await workflowStore.saveWorkflow(workflow)
useTelemetry()?.trackWorkflowSaved({ is_app: isApp, is_new: false })
}
}
@@ -544,15 +547,16 @@ export const useWorkflowService = () => {
if (settingStore.get('Comfy.Workflow.ShowMissingNodesWarning')) {
missingNodesDialog.show({ missingNodeTypes })
}
executionErrorStore.surfaceMissingNodes(missingNodeTypes)
}
if (
missingModels &&
settingStore.get('Comfy.Workflow.ShowMissingModelsWarning')
) {
missingModelsDialog.show(missingModels)
// Missing models are NOT surfaced to the Errors tab here.
// On Cloud, the dedicated pipeline in app.ts handles detection and
// surfacing via surfaceMissingModels(). OSS uses only this dialog.
if (missingModels) {
if (settingStore.get('Comfy.Workflow.ShowMissingModelsWarning')) {
missingModelsDialog.show(missingModels)
}
}
}

View File

@@ -3,9 +3,13 @@ import { describe, expect, it } from 'vitest'
import {
buildSubgraphExecutionPaths,
flattenWorkflowNodes,
validateComfyWorkflow
} from '@/platform/workflow/validation/schemas/workflowSchema'
import type { ComfyNode } from '@/platform/workflow/validation/schemas/workflowSchema'
import type {
ComfyNode,
ComfyWorkflowJSON
} from '@/platform/workflow/validation/schemas/workflowSchema'
import { defaultGraph } from '@/scripts/defaultGraph'
const WORKFLOW_DIR = 'src/platform/workflow/validation/schemas/__fixtures__'
@@ -274,3 +278,48 @@ describe('buildSubgraphExecutionPaths', () => {
).not.toThrow()
})
})
describe('flattenWorkflowNodes', () => {
it('returns root nodes when no subgraphs exist', () => {
const result = flattenWorkflowNodes({
nodes: [node(1, 'KSampler'), node(2, 'CLIPLoader')]
} as ComfyWorkflowJSON)
expect(result).toHaveLength(2)
expect(result.map((n) => n.id)).toEqual([1, 2])
})
it('returns empty array when nodes is undefined', () => {
const result = flattenWorkflowNodes({} as ComfyWorkflowJSON)
expect(result).toEqual([])
})
it('includes subgraph nodes with prefixed IDs', () => {
const result = flattenWorkflowNodes({
nodes: [node(5, 'def-A')],
definitions: {
subgraphs: [
subgraphDef('def-A', [node(10, 'Inner'), node(20, 'Inner2')])
]
}
} as unknown as ComfyWorkflowJSON)
expect(result).toHaveLength(3) // 1 root + 2 subgraph
expect(result.map((n) => n.id)).toEqual([5, '5:10', '5:20'])
})
it('prefixes nested subgraph nodes with full execution path', () => {
const result = flattenWorkflowNodes({
nodes: [node(5, 'def-A')],
definitions: {
subgraphs: [
subgraphDef('def-A', [node(10, 'def-B')]),
subgraphDef('def-B', [node(3, 'Leaf')])
]
}
} as unknown as ComfyWorkflowJSON)
// root:5, def-A inner: 5:10, def-B inner: 5:10:3
expect(result.map((n) => n.id)).toEqual([5, '5:10', '5:10:3'])
})
})

View File

@@ -592,3 +592,56 @@ export function buildSubgraphExecutionPaths(
build(rootNodes, '')
return pathMap
}
/**
* Recursively collect all subgraph definitions from root and nested levels.
*/
function collectAllSubgraphDefs(rootDefs: unknown[]): SubgraphDefinition[] {
const result: SubgraphDefinition[] = []
const seen = new Set<string>()
function collect(defs: unknown[]) {
for (const def of defs) {
if (!isSubgraphDefinition(def)) continue
if (seen.has(def.id)) continue
seen.add(def.id)
result.push(def)
if (def.definitions?.subgraphs?.length) {
collect(def.definitions.subgraphs)
}
}
}
collect(rootDefs)
return result
}
/**
* Flatten all workflow nodes (root + subgraphs) into a single array.
* Each node's `id` is prefixed with its execution path (e.g. node "3" inside container "11" → "11:3").
*/
export function flattenWorkflowNodes(
graphData: ComfyWorkflowJSON
): Readonly<ComfyNode>[] {
const rootNodes = graphData.nodes ?? []
const allDefs = collectAllSubgraphDefs(graphData.definitions?.subgraphs ?? [])
const pathMap = buildSubgraphExecutionPaths(rootNodes, allDefs)
const allNodes: ComfyNode[] = [...rootNodes]
const subgraphDefMap = new Map(allDefs.map((s) => [s.id, s]))
for (const [defId, paths] of pathMap.entries()) {
const def = subgraphDefMap.get(defId)
if (!def?.nodes) continue
for (const prefix of paths) {
for (const node of def.nodes) {
allNodes.push({
...node,
id: `${prefix}:${node.id}`
})
}
}
}
return allNodes
}

View File

@@ -9,7 +9,6 @@ import { computed, customRef, ref } from 'vue'
import type { ComputedRef, Ref } from 'vue'
import * as Y from 'yjs'
import type { NodeAlignmentGuide } from '@/renderer/extensions/vueNodes/layout/nodeAlignmentSnap'
import { removeNodeTitleHeight } from '@/renderer/core/layout/utils/nodeSizeUtil'
import { ACTOR_CONFIG } from '@/renderer/core/layout/constants'
@@ -139,7 +138,6 @@ class LayoutStoreImpl implements LayoutStore {
// Vue dragging state for selection toolbox (public ref for direct mutation)
public isDraggingVueNodes = ref(false)
public vueDragSnapGuides = ref<NodeAlignmentGuide[]>([])
// Vue resizing state to prevent drag from activating during resize
public isResizingVueNodes = ref(false)

View File

@@ -0,0 +1,131 @@
import type {
ComponentPropsAndSlots,
Meta,
StoryObj
} from '@storybook/vue3-vite'
import { ref } from 'vue'
import DropZone from './DropZone.vue'
type StoryArgs = ComponentPropsAndSlots<typeof DropZone>
const defaultLabel = 'Click to browse or drag an image'
const defaultIconClass = 'icon-[lucide--image]'
function createFileInput(onFile: (file: File) => void) {
const input = document.createElement('input')
input.type = 'file'
input.accept = 'image/*'
input.addEventListener('change', () => {
const file = input.files?.[0]
if (file) onFile(file)
})
return input
}
function fileToObjectUrl(file: File): string {
return URL.createObjectURL(file)
}
function extractDroppedImageFile(e: DragEvent): File | undefined {
return Array.from(e.dataTransfer?.files ?? []).find((f) =>
f.type.startsWith('image/')
)
}
const renderStory = (args: StoryArgs) => ({
components: { DropZone },
setup() {
const imageUrl = ref<string | undefined>(undefined)
const hovered = ref(false)
function handleFile(file: File) {
imageUrl.value = fileToObjectUrl(file)
}
const onDragOver = (e: DragEvent) => {
if (!e.dataTransfer?.items) return false
return Array.from(e.dataTransfer.items).some(
(item) => item.kind === 'file' && item.type.startsWith('image/')
)
}
const onDragDrop = (e: DragEvent) => {
const file = extractDroppedImageFile(e)
if (file) handleFile(file)
return !!file
}
const onClick = () => {
createFileInput(handleFile).click()
}
const dropIndicator = ref({
...args.dropIndicator,
onClick
})
return { args, onDragOver, onDragDrop, dropIndicator, imageUrl, hovered }
},
template: `
<div
@mouseenter="hovered = true"
@mouseleave="hovered = false"
>
<DropZone
v-bind="args"
:on-drag-over="onDragOver"
:on-drag-drop="onDragDrop"
:force-hovered="hovered"
:drop-indicator="{
...dropIndicator,
imageUrl: imageUrl ?? dropIndicator.imageUrl
}"
/>
</div>
`
})
const meta: Meta<StoryArgs> = {
title: 'Components/FileUpload',
component: DropZone,
tags: ['autodocs'],
parameters: {
layout: 'centered',
docs: {
description: {
component:
'Linear mode drag-and-drop target with a file upload indicator. Click to browse or drag an image file to upload.'
}
}
},
argTypes: {
onDragOver: { table: { disable: true } },
onDragDrop: { table: { disable: true } },
dropIndicator: { control: false },
forceHovered: { table: { disable: true } }
},
args: {
dropIndicator: {
label: defaultLabel,
iconClass: defaultIconClass
}
},
decorators: [
(story) => ({
components: { story },
template: `
<div class="w-[440px] rounded-xl bg-component-node-background p-4">
<story />
</div>
`
})
]
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: renderStory
}

View File

@@ -1,10 +1,15 @@
<script setup lang="ts">
import { useDropZone } from '@vueuse/core'
import { ref } from 'vue'
import { computed, ref } from 'vue'
import { cn } from '@/utils/tailwindUtil'
const props = defineProps<{
const {
onDragOver,
onDragDrop,
dropIndicator,
forceHovered = false
} = defineProps<{
onDragOver?: (e: DragEvent) => boolean
onDragDrop?: (e: DragEvent) => Promise<boolean> | boolean
dropIndicator?: {
@@ -13,6 +18,7 @@ const props = defineProps<{
label?: string
onClick?: (e: MouseEvent) => void
}
forceHovered?: boolean
}>()
const dropZoneRef = ref<HTMLElement | null>(null)
@@ -23,53 +29,83 @@ const { isOverDropZone } = useDropZone(dropZoneRef, {
// Stop propagation to prevent global handlers from creating a new node
event?.stopPropagation()
if (props.onDragDrop && event) {
props.onDragDrop(event)
if (onDragDrop && event) {
onDragDrop(event)
}
canAcceptDrop.value = false
},
onOver: (_, event) => {
if (props.onDragOver && event) {
canAcceptDrop.value = props.onDragOver(event)
if (onDragOver && event) {
canAcceptDrop.value = onDragOver(event)
}
},
onLeave: () => {
canAcceptDrop.value = false
}
})
const isHovered = computed(
() => forceHovered || (canAcceptDrop.value && isOverDropZone.value)
)
const indicatorTag = computed(() => (dropIndicator?.onClick ? 'button' : 'div'))
</script>
<template>
<div
v-if="onDragOver && onDragDrop"
ref="dropZoneRef"
data-slot="drop-zone"
:class="
cn(
'rounded-lg ring-primary-500 ring-inset',
canAcceptDrop && isOverDropZone && 'bg-primary-500/10 ring-4'
'rounded-lg transition-colors',
isHovered && 'bg-component-node-widget-background-hovered'
)
"
>
<slot />
<div
<component
:is="indicatorTag"
v-if="dropIndicator"
:type="dropIndicator?.onClick ? 'button' : undefined"
:aria-label="dropIndicator?.onClick ? dropIndicator.label : undefined"
data-slot="drop-zone-indicator"
:class="
cn(
'm-3 flex h-25 flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-border-subtle py-2',
'm-3 block w-[calc(100%-1.5rem)] appearance-none overflow-hidden rounded-lg border border-node-component-border bg-transparent p-1 text-left text-component-node-foreground-secondary transition-colors',
dropIndicator?.onClick && 'cursor-pointer'
)
"
@click.prevent="dropIndicator?.onClick?.($event)"
>
<img
v-if="dropIndicator?.imageUrl"
class="h-23"
:src="dropIndicator?.imageUrl"
/>
<template v-else>
<span v-if="dropIndicator.label" v-text="dropIndicator.label" />
<i v-if="dropIndicator.iconClass" :class="dropIndicator.iconClass" />
</template>
</div>
<div
:class="
cn(
'flex min-h-23 w-full flex-col items-center justify-center gap-2 rounded-[7px] p-6 text-center text-sm/tight transition-colors',
isHovered &&
!dropIndicator?.imageUrl &&
'border border-dashed border-component-node-foreground-secondary bg-component-node-widget-background-hovered'
)
"
>
<img
v-if="dropIndicator?.imageUrl"
class="max-h-23 rounded-md object-contain"
:alt="dropIndicator?.label ?? ''"
:src="dropIndicator?.imageUrl"
/>
<template v-else>
<span v-if="dropIndicator.label" v-text="dropIndicator.label" />
<i
v-if="dropIndicator.iconClass"
:class="
cn(
'size-4 text-component-node-foreground-secondary',
dropIndicator.iconClass
)
"
/>
</template>
</div>
</component>
</div>
<slot v-else />
</template>

View File

@@ -64,6 +64,7 @@ useEventListener(
)
const mappedSelections = computed(() => {
void graphNodes.value
let unprocessedInputs = appModeStore.selectedInputs.flatMap(
([nodeId, widgetName]) => {
const [node, widget] = resolveNodeWidget(nodeId, widgetName)

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, useTemplateRef, watch } from 'vue'
import { onUnmounted, ref, useTemplateRef, watch } from 'vue'
import Load3DControls from '@/components/load3d/Load3DControls.vue'
import AnimationControls from '@/components/load3d/controls/AnimationControls.vue'
@@ -19,6 +19,10 @@ watch([containerRef, () => modelUrl], async () => {
await viewer.value.initializeStandaloneViewer(containerRef.value, modelUrl)
})
onUnmounted(() => {
viewer.value.cleanup()
})
//TODO: refactor to add control buttons
</script>
<template>

View File

@@ -187,6 +187,46 @@ describe('useMinimapViewport', () => {
expect(transform.height).toBeCloseTo(viewportHeight * 0.5) // 300 * 0.5 = 150
})
it('should maintain strict reference equality for viewportTransform when canvas state is unchanged', () => {
vi.mocked(calculateNodeBounds).mockReturnValue({
minX: 0,
minY: 0,
maxX: 500,
maxY: 400,
width: 500,
height: 400
})
vi.mocked(enforceMinimumBounds).mockImplementation((bounds) => bounds)
vi.mocked(calculateMinimapScale).mockReturnValue(0.5)
const canvasRef = ref(mockCanvas) as Ref<MinimapCanvas | null>
const graphRef = ref(mockGraph) as Ref<LGraph | null>
const viewport = useMinimapViewport(canvasRef, graphRef, 250, 200)
mockCanvas.ds.scale = 2
mockCanvas.ds.offset = [-100, -50]
viewport.updateBounds()
viewport.updateCanvasDimensions()
viewport.updateViewport()
const initialTransform = viewport.viewportTransform.value
viewport.updateViewport()
const transformAfterIdle = viewport.viewportTransform.value
expect(transformAfterIdle).toBe(initialTransform)
mockCanvas.ds.offset = [-150, -50]
viewport.updateViewport()
const transformAfterPan = viewport.viewportTransform.value
expect(transformAfterPan).not.toBe(initialTransform)
expect(transformAfterPan.x).not.toBe(initialTransform.x)
})
it('should center view on world coordinates', () => {
const canvasRef = ref(mockCanvas) as Ref<MinimapCanvas | null>
const graphRef = ref(mockGraph) as Ref<LGraph | null>

View File

@@ -90,12 +90,16 @@ export function useMinimapViewport(
const centerOffsetX = (width - bounds.value.width * scale.value) / 2
const centerOffsetY = (height - bounds.value.height * scale.value) / 2
viewportTransform.value = {
x: (worldX - bounds.value.minX) * scale.value + centerOffsetX,
y: (worldY - bounds.value.minY) * scale.value + centerOffsetY,
width: viewportWidth * scale.value,
height: viewportHeight * scale.value
}
const x = (worldX - bounds.value.minX) * scale.value + centerOffsetX
const y = (worldY - bounds.value.minY) * scale.value + centerOffsetY
const w = viewportWidth * scale.value
const h = viewportHeight * scale.value
const curr = viewportTransform.value
if (curr.x === x && curr.y === y && curr.width === w && curr.height === h)
return
viewportTransform.value = { x, y, width: w, height: h }
}
const updateBounds = () => {

View File

@@ -289,6 +289,7 @@ import { useNodePreviewState } from '@/renderer/extensions/vueNodes/preview/useN
import { nonWidgetedInputs } from '@/renderer/extensions/vueNodes/utils/nodeDataUtils'
import { applyLightThemeColor } from '@/renderer/extensions/vueNodes/utils/nodeStyleUtils'
import { app } from '@/scripts/app'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
@@ -339,6 +340,7 @@ const isSelected = computed(() => {
const nodeLocatorId = computed(() => getLocatorIdFromNodeData(nodeData))
const { executing, progress } = useNodeExecutionState(nodeLocatorId)
const executionErrorStore = useExecutionErrorStore()
const missingModelStore = useMissingModelStore()
const hasExecutionError = computed(
() => executionErrorStore.lastExecutionErrorNodeId === nodeData.id
)
@@ -349,9 +351,11 @@ const hasAnyError = computed((): boolean => {
nodeData.hasErrors ||
error ||
executionErrorStore.getNodeErrors(nodeLocatorId.value) ||
missingModelStore.hasMissingModelOnNode(nodeLocatorId.value) ||
(lgraphNode.value &&
(executionErrorStore.isContainerWithInternalError(lgraphNode.value) ||
executionErrorStore.isContainerWithMissingNode(lgraphNode.value)))
executionErrorStore.isContainerWithMissingNode(lgraphNode.value) ||
missingModelStore.isContainerWithMissingModel(lgraphNode.value)))
)
})
@@ -426,7 +430,7 @@ const handleContextMenu = (event: MouseEvent) => {
handleNodeRightClick(event as PointerEvent, nodeData.id)
// Show the node options menu at the cursor position
showNodeOptions(event, undefined, undefined, 'contextmenu')
showNodeOptions(event)
}
/**

View File

@@ -115,6 +115,7 @@ import {
useWidgetValueStore
} from '@/stores/widgetValueStore'
import { usePromotionStore } from '@/stores/promotionStore'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import type { SimplifiedWidget, WidgetValue } from '@/types/simplifiedWidget'
import { cn } from '@/utils/tailwindUtil'
@@ -134,6 +135,7 @@ const canvasStore = useCanvasStore()
const { bringNodeToFront } = useNodeZIndex()
const promotionStore = usePromotionStore()
const executionErrorStore = useExecutionErrorStore()
const missingModelStore = useMissingModelStore()
function handleWidgetPointerEvent(event: PointerEvent) {
if (shouldHandleNodePointerEvents.value) return
@@ -202,6 +204,7 @@ const processedWidgets = computed((): ProcessedWidget[] => {
const graphId = canvasStore.canvas?.graph?.rootGraph.id
const nodeId = nodeData.id
const nodeIdStr = String(nodeId)
const { widgets } = nodeData
const result: ProcessedWidget[] = []
@@ -275,8 +278,7 @@ const processedWidgets = computed((): ProcessedWidget[] => {
widget.name,
widget.nodeId !== undefined
? String(stripGraphPrefix(widget.nodeId))
: undefined,
'contextmenu'
: undefined
)
}
@@ -285,9 +287,11 @@ const processedWidgets = computed((): ProcessedWidget[] => {
handleContextMenu,
hasLayoutSize: widget.hasLayoutSize ?? false,
hasError:
nodeErrors?.errors?.some(
(nodeErrors?.errors?.some(
(error) => error.extra_info?.input_name === widget.name
) ?? false,
) ??
false) ||
missingModelStore.isWidgetMissingModel(nodeIdStr, widget.name),
hidden: widget.options?.hidden ?? false,
id: String(bareWidgetId),
name: widget.name,

View File

@@ -102,7 +102,6 @@ export function useNodePointerInteractions(
function cleanupDragState() {
layoutStore.isDraggingVueNodes.value = false
layoutStore.vueDragSnapGuides.value = []
}
function safeDragStart(event: PointerEvent, nodeId: string) {

View File

@@ -1,128 +0,0 @@
import { describe, expect, it } from 'vitest'
import { resolveNodeAlignmentSnap } from './nodeAlignmentSnap'
describe('resolveNodeAlignmentSnap', () => {
const selectionBounds = {
x: 0,
y: 0,
width: 100,
height: 80
}
it('snaps matching edges within threshold', () => {
const result = resolveNodeAlignmentSnap({
selectionBounds,
candidateBounds: [
{
x: 200,
y: 0,
width: 100,
height: 80
}
],
delta: { x: 193, y: 0 },
zoomScale: 1
})
expect(result.delta).toEqual({ x: 200, y: 0 })
expect(result.guides).toContainEqual({
axis: 'vertical',
coordinate: 200,
start: 0,
end: 80
})
})
it('snaps matching centers within threshold', () => {
const result = resolveNodeAlignmentSnap({
selectionBounds,
candidateBounds: [
{
x: 0,
y: 200,
width: 100,
height: 80
}
],
delta: { x: 0, y: 196 },
zoomScale: 1
})
expect(result.delta).toEqual({ x: 0, y: 200 })
expect(result.guides).toContainEqual({
axis: 'horizontal',
coordinate: 200,
start: 0,
end: 100
})
})
it('prefers the nearest candidate correction', () => {
const result = resolveNodeAlignmentSnap({
selectionBounds,
candidateBounds: [
{
x: 200,
y: 0,
width: 100,
height: 80
},
{
x: 198,
y: 0,
width: 100,
height: 80
}
],
delta: { x: 193, y: 0 },
zoomScale: 1
})
expect(result.delta).toEqual({ x: 198, y: 0 })
expect(result.guides).toContainEqual({
axis: 'vertical',
coordinate: 198,
start: 0,
end: 80
})
})
it('does not snap when outside the zoom-adjusted threshold', () => {
const result = resolveNodeAlignmentSnap({
selectionBounds,
candidateBounds: [
{
x: 200,
y: 200,
width: 100,
height: 80
}
],
delta: { x: 183, y: 0 },
zoomScale: 0.5
})
expect(result.delta).toEqual({ x: 183, y: 0 })
expect(result.guides).toEqual([])
})
it('tightens the canvas threshold as zoom increases', () => {
const result = resolveNodeAlignmentSnap({
selectionBounds,
candidateBounds: [
{
x: 200,
y: 200,
width: 100,
height: 80
}
],
delta: { x: 195, y: 0 },
zoomScale: 2
})
expect(result.delta).toEqual({ x: 195, y: 0 })
expect(result.guides).toEqual([])
})
})

View File

@@ -1,225 +0,0 @@
import type { Bounds, Point } from '@/renderer/core/layout/types'
const DEFAULT_THRESHOLD_PX = 8
type HorizontalAnchor = 'bottom' | 'centerY' | 'top'
type VerticalAnchor = 'centerX' | 'left' | 'right'
export interface NodeAlignmentGuide {
axis: 'horizontal' | 'vertical'
coordinate: number
start: number
end: number
}
export interface NodeAlignmentSnapResult {
delta: Point
guides: NodeAlignmentGuide[]
}
interface AxisMatch {
axis: 'horizontal' | 'vertical'
anchor: HorizontalAnchor | VerticalAnchor
candidateBounds: Bounds
correction: number
}
interface ResolveNodeAlignmentSnapOptions {
candidateBounds: Bounds[]
delta: Point
selectionBounds: Bounds
thresholdPx?: number
zoomScale: number
}
export function resolveNodeAlignmentSnap({
candidateBounds,
delta,
selectionBounds,
thresholdPx = DEFAULT_THRESHOLD_PX,
zoomScale
}: ResolveNodeAlignmentSnapOptions): NodeAlignmentSnapResult {
if (!candidateBounds.length || zoomScale <= 0) {
return { delta, guides: [] }
}
const threshold = thresholdPx / zoomScale
const translatedSelectionBounds = translateBounds(selectionBounds, delta)
const verticalMatch = findBestVerticalMatch(
translatedSelectionBounds,
candidateBounds,
threshold
)
const horizontalMatch = findBestHorizontalMatch(
translatedSelectionBounds,
candidateBounds,
threshold
)
const snappedDelta = {
x: delta.x + (verticalMatch?.correction ?? 0),
y: delta.y + (horizontalMatch?.correction ?? 0)
}
const snappedSelectionBounds = translateBounds(selectionBounds, snappedDelta)
const guides = [
verticalMatch &&
createVerticalGuide(
snappedSelectionBounds,
verticalMatch.candidateBounds
),
horizontalMatch &&
createHorizontalGuide(
snappedSelectionBounds,
horizontalMatch.candidateBounds
)
].filter((guide): guide is NodeAlignmentGuide => guide !== undefined)
return {
delta: snappedDelta,
guides
}
}
function findBestVerticalMatch(
selectionBounds: Bounds,
candidateBounds: Bounds[],
threshold: number
): AxisMatch | undefined {
return findBestMatch<VerticalAnchor>(
'vertical',
getVerticalAnchorValues(selectionBounds),
candidateBounds,
threshold,
getVerticalAnchorValues
)
}
function findBestHorizontalMatch(
selectionBounds: Bounds,
candidateBounds: Bounds[],
threshold: number
): AxisMatch | undefined {
return findBestMatch<HorizontalAnchor>(
'horizontal',
getHorizontalAnchorValues(selectionBounds),
candidateBounds,
threshold,
getHorizontalAnchorValues
)
}
function findBestMatch<TAnchor extends HorizontalAnchor | VerticalAnchor>(
axis: 'horizontal' | 'vertical',
selectionAnchors: Record<TAnchor, number>,
candidateBounds: Bounds[],
threshold: number,
getCandidateAnchors: (bounds: Bounds) => Record<TAnchor, number>
): AxisMatch | undefined {
let bestMatch: AxisMatch | undefined
for (const bounds of candidateBounds) {
const candidateAnchors = getCandidateAnchors(bounds)
for (const anchor of Object.keys(selectionAnchors) as TAnchor[]) {
const correction = candidateAnchors[anchor] - selectionAnchors[anchor]
if (Math.abs(correction) > threshold) {
continue
}
if (!bestMatch || Math.abs(correction) < Math.abs(bestMatch.correction)) {
bestMatch = {
axis,
anchor,
candidateBounds: bounds,
correction
}
}
}
}
return bestMatch
}
function getVerticalAnchorValues(
bounds: Bounds
): Record<VerticalAnchor, number> {
return {
left: bounds.x,
centerX: bounds.x + bounds.width * 0.5,
right: bounds.x + bounds.width
}
}
function getHorizontalAnchorValues(
bounds: Bounds
): Record<HorizontalAnchor, number> {
return {
top: bounds.y,
centerY: bounds.y + bounds.height * 0.5,
bottom: bounds.y + bounds.height
}
}
function createVerticalGuide(
selectionBounds: Bounds,
candidateBounds: Bounds
): NodeAlignmentGuide {
const candidateAnchors = getVerticalAnchorValues(candidateBounds)
const selectionAnchors = getVerticalAnchorValues(selectionBounds)
const coordinate = getSharedAnchorValue(selectionAnchors, candidateAnchors)
return {
axis: 'vertical',
coordinate,
start: Math.min(selectionBounds.y, candidateBounds.y),
end: Math.max(
selectionBounds.y + selectionBounds.height,
candidateBounds.y + candidateBounds.height
)
}
}
function createHorizontalGuide(
selectionBounds: Bounds,
candidateBounds: Bounds
): NodeAlignmentGuide {
const candidateAnchors = getHorizontalAnchorValues(candidateBounds)
const selectionAnchors = getHorizontalAnchorValues(selectionBounds)
const coordinate = getSharedAnchorValue(selectionAnchors, candidateAnchors)
return {
axis: 'horizontal',
coordinate,
start: Math.min(selectionBounds.x, candidateBounds.x),
end: Math.max(
selectionBounds.x + selectionBounds.width,
candidateBounds.x + candidateBounds.width
)
}
}
function getSharedAnchorValue<
TAnchor extends HorizontalAnchor | VerticalAnchor
>(
selectionAnchors: Record<TAnchor, number>,
candidateAnchors: Record<TAnchor, number>
): number {
const anchors = Object.keys(selectionAnchors) as TAnchor[]
for (const anchor of anchors) {
if (selectionAnchors[anchor] === candidateAnchors[anchor]) {
return selectionAnchors[anchor]
}
}
return selectionAnchors[anchors[0]]
}
function translateBounds(bounds: Bounds, delta: Point): Bounds {
return {
...bounds,
x: bounds.x + delta.x,
y: bounds.y + delta.y
}
}

View File

@@ -1,150 +0,0 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { useNodeDrag } from '@/renderer/extensions/vueNodes/layout/useNodeDrag'
import type * as VueUseModule from '@vueuse/core'
const settingValues: Record<string, boolean | number> = {
'Comfy.Canvas.AlignNodesWhileDragging': false,
'Comfy.SnapToGrid.GridSize': 10,
'pysssss.SnapToGrid': false
}
vi.mock('@vueuse/core', async () => {
const actual = await vi.importActual<typeof VueUseModule>('@vueuse/core')
return {
...actual,
createSharedComposable: <TArgs extends unknown[], TResult>(
composable: (...args: TArgs) => TResult
) => composable
}
})
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: (key: string) => settingValues[key]
})
}))
vi.mock('@/scripts/app', () => ({
app: {
canvas: null
}
}))
describe('useNodeDrag', () => {
beforeEach(() => {
setActivePinia(createPinia())
layoutStore.initializeFromLiteGraph([
{ id: 'node-1', pos: [0, 40], size: [100, 60] },
{ id: 'node-2', pos: [40, 40], size: [100, 60] },
{ id: 'node-3', pos: [200, 40], size: [100, 60] }
])
layoutStore.vueDragSnapGuides.value = []
layoutStore.isDraggingVueNodes.value = false
LiteGraph.NODE_TITLE_HEIGHT = 30
const canvasStore = useCanvasStore()
canvasStore.selectedItems = [{ id: 'node-1' }, { id: 'node-2' }] as never[]
canvasStore.canvas = {
setDirty: vi.fn()
} as never
settingValues['Comfy.Canvas.AlignNodesWhileDragging'] = false
settingValues['pysssss.SnapToGrid'] = false
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
callback(0)
return 1
})
vi.stubGlobal('cancelAnimationFrame', vi.fn())
})
it('snaps the dragged multi-selection and clears guides on drag end', () => {
settingValues['Comfy.Canvas.AlignNodesWhileDragging'] = true
const { startDrag, handleDrag, endDrag } = useNodeDrag()
startDrag(
new PointerEvent('pointerdown', { clientX: 0, clientY: 0 }),
'node-1'
)
handleDrag(
new PointerEvent('pointermove', {
clientX: 193,
clientY: 0
}),
'node-1'
)
expect(layoutStore.getNodeLayoutRef('node-1').value?.position.x).toBe(200)
expect(layoutStore.getNodeLayoutRef('node-2').value?.position.x).toBe(240)
expect(layoutStore.vueDragSnapGuides.value).toContainEqual(
expect.objectContaining({
axis: 'vertical',
coordinate: 200
})
)
endDrag(
new PointerEvent('pointerup', { clientX: 193, clientY: 0 }),
'node-1'
)
expect(layoutStore.vueDragSnapGuides.value).toEqual([])
})
it('excludes already selected nodes from alignment candidates', () => {
settingValues['Comfy.Canvas.AlignNodesWhileDragging'] = true
layoutStore.initializeFromLiteGraph([
{ id: 'node-1', pos: [0, 40], size: [100, 60] },
{ id: 'node-2', pos: [60, 40], size: [100, 60] }
])
const { startDrag, handleDrag } = useNodeDrag()
startDrag(
new PointerEvent('pointerdown', { clientX: 0, clientY: 0 }),
'node-1'
)
handleDrag(
new PointerEvent('pointermove', {
clientX: 63,
clientY: 0
}),
'node-1'
)
expect(layoutStore.getNodeLayoutRef('node-1').value?.position.x).toBe(63)
expect(layoutStore.vueDragSnapGuides.value).toEqual([])
})
it('suppresses alignment snapping while grid snapping is active', () => {
settingValues['Comfy.Canvas.AlignNodesWhileDragging'] = true
const { startDrag, handleDrag } = useNodeDrag()
startDrag(
new PointerEvent('pointerdown', { clientX: 0, clientY: 0 }),
'node-1'
)
handleDrag(
new PointerEvent('pointermove', {
clientX: 193,
clientY: 0,
shiftKey: true
}),
'node-1'
)
expect(layoutStore.getNodeLayoutRef('node-1').value?.position.x).toBe(193)
expect(layoutStore.vueDragSnapGuides.value).toEqual([])
})
})

View File

@@ -1,15 +1,12 @@
import { storeToRefs } from 'pinia'
import { toValue } from 'vue'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
import type { LGraphGroup } from '@/lib/litegraph/src/LGraphGroup'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { LayoutSource } from '@/renderer/core/layout/types'
import type {
Bounds,
NodeBoundsUpdate,
NodeId,
Point
@@ -20,16 +17,11 @@ import { useTransformState } from '@/renderer/core/layout/transform/useTransform
import { isLGraphGroup } from '@/utils/litegraphUtil'
import { createSharedComposable } from '@vueuse/core'
import type { NodeAlignmentSnapResult } from './nodeAlignmentSnap'
import { resolveNodeAlignmentSnap } from './nodeAlignmentSnap'
export const useNodeDrag = createSharedComposable(useNodeDragIndividual)
function useNodeDragIndividual() {
const canvasStore = useCanvasStore()
const mutations = useLayoutMutations()
const { selectedNodeIds, selectedItems } = storeToRefs(canvasStore)
const settingStore = useSettingStore()
const { selectedNodeIds, selectedItems } = storeToRefs(useCanvasStore())
// Get transform utilities from TransformPane if available
const transformState = useTransformState()
@@ -50,8 +42,6 @@ function useNodeDragIndividual() {
// For groups: track the last applied canvas delta to compute frame delta
let lastCanvasDelta: Point | null = null
let selectedGroups: LGraphGroup[] | null = null
let draggedSelectionBounds: Bounds | null = null
let alignmentCandidateBounds: Bounds[] = []
function startDrag(event: PointerEvent, nodeId: NodeId) {
const layout = toValue(layoutStore.getNodeLayoutRef(nodeId))
@@ -64,15 +54,11 @@ function useNodeDragIndividual() {
dragStartPos = { ...position }
dragStartMouse = { x: event.clientX, y: event.clientY }
const selectedNodes = new Set(toValue(selectedNodeIds))
selectedNodes.add(nodeId)
draggedSelectionBounds = getDraggedSelectionBounds(selectedNodes)
alignmentCandidateBounds = getAlignmentCandidateBounds(selectedNodes)
updateDragSnapGuides([])
const selectedNodes = toValue(selectedNodeIds)
// capture the starting positions of all other selected nodes
// Only move other selected items if the dragged node is part of the selection
const isDraggedNodeInSelection = selectedNodes.has(nodeId)
const isDraggedNodeInSelection = selectedNodes?.has(nodeId)
if (isDraggedNodeInSelection && selectedNodes.size > 1) {
otherSelectedNodesStartPositions = new Map()
@@ -134,12 +120,11 @@ function useNodeDragIndividual() {
x: canvasWithDelta.x - canvasOrigin.x,
y: canvasWithDelta.y - canvasOrigin.y
}
const snappedCanvasDelta = maybeResolveAlignmentSnap(event, canvasDelta)
// Calculate new position for the current node
const newPosition = {
x: dragStartPos.x + snappedCanvasDelta.x,
y: dragStartPos.y + snappedCanvasDelta.y
x: dragStartPos.x + canvasDelta.x,
y: dragStartPos.y + canvasDelta.y
}
// Apply mutation through the layout system (Vue batches DOM updates automatically)
@@ -155,8 +140,8 @@ function useNodeDragIndividual() {
startPos
] of otherSelectedNodesStartPositions) {
const newOtherPosition = {
x: startPos.x + snappedCanvasDelta.x,
y: startPos.y + snappedCanvasDelta.y
x: startPos.x + canvasDelta.x,
y: startPos.y + canvasDelta.y
}
mutations.moveNode(otherNodeId, newOtherPosition)
}
@@ -166,8 +151,8 @@ function useNodeDragIndividual() {
// This matches LiteGraph's behavior which uses delta-based movement
if (selectedGroups && selectedGroups.length > 0 && lastCanvasDelta) {
const frameDelta = {
x: snappedCanvasDelta.x - lastCanvasDelta.x,
y: snappedCanvasDelta.y - lastCanvasDelta.y
x: canvasDelta.x - lastCanvasDelta.x,
y: canvasDelta.y - lastCanvasDelta.y
}
for (const group of selectedGroups) {
@@ -175,7 +160,7 @@ function useNodeDragIndividual() {
}
}
lastCanvasDelta = snappedCanvasDelta
lastCanvasDelta = canvasDelta
})
}
@@ -246,9 +231,6 @@ function useNodeDragIndividual() {
otherSelectedNodesStartPositions = null
selectedGroups = null
lastCanvasDelta = null
draggedSelectionBounds = null
alignmentCandidateBounds = []
updateDragSnapGuides([])
// Stop tracking shift key state
stopShiftSync?.()
@@ -266,99 +248,4 @@ function useNodeDragIndividual() {
handleDrag,
endDrag
}
function maybeResolveAlignmentSnap(
event: PointerEvent,
canvasDelta: Point
): Point {
if (!settingStore.get('Comfy.Canvas.AlignNodesWhileDragging')) {
updateDragSnapGuides([])
return canvasDelta
}
if (shouldSnap(event) || !draggedSelectionBounds) {
updateDragSnapGuides([])
return canvasDelta
}
const snapResult: NodeAlignmentSnapResult = resolveNodeAlignmentSnap({
selectionBounds: draggedSelectionBounds,
candidateBounds: alignmentCandidateBounds,
delta: canvasDelta,
zoomScale: transformState.camera.z
})
updateDragSnapGuides(snapResult.guides)
return snapResult.delta
}
function getDraggedSelectionBounds(nodeIds: Set<NodeId>): Bounds | null {
const bounds = Array.from(nodeIds)
.map((id) => layoutStore.getNodeLayoutRef(id).value)
.filter((layout): layout is NonNullable<typeof layout> => layout !== null)
.map(getRenderedNodeBounds)
return mergeBounds(bounds)
}
function getAlignmentCandidateBounds(selectedNodeSet: Set<NodeId>): Bounds[] {
const allNodes = layoutStore.getAllNodes().value
const candidates: Bounds[] = []
for (const [nodeId, layout] of allNodes) {
if (selectedNodeSet.has(nodeId)) {
continue
}
candidates.push(getRenderedNodeBounds(layout))
}
return candidates
}
function updateDragSnapGuides(
guides: typeof layoutStore.vueDragSnapGuides.value
) {
layoutStore.vueDragSnapGuides.value = guides
canvasStore.canvas?.setDirty(true, true)
}
}
function getRenderedNodeBounds(layout: {
position: Point
size: { width: number; height: number }
}): Bounds {
const titleHeight = LiteGraph.NODE_TITLE_HEIGHT || 0
return {
x: layout.position.x,
y: layout.position.y - titleHeight,
width: layout.size.width,
height: layout.size.height + titleHeight
}
}
function mergeBounds(boundsList: Bounds[]): Bounds | null {
const [firstBounds, ...remainingBounds] = boundsList
if (!firstBounds) {
return null
}
let left = firstBounds.x
let top = firstBounds.y
let right = firstBounds.x + firstBounds.width
let bottom = firstBounds.y + firstBounds.height
for (const bounds of remainingBounds) {
left = Math.min(left, bounds.x)
top = Math.min(top, bounds.y)
right = Math.max(right, bounds.x + bounds.width)
bottom = Math.max(bottom, bounds.y + bounds.height)
}
return {
x: left,
y: top,
width: right - left,
height: bottom - top
}
}

View File

@@ -15,7 +15,34 @@ import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import WidgetSelectDropdown from '@/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue'
import { createMockWidget } from './widgetTestUtils'
const mockCheckState = vi.hoisted(() => vi.fn())
const mockAssetsData = vi.hoisted(() => ({ items: [] as AssetItem[] }))
vi.mock('@/platform/workflow/management/stores/workflowStore', async () => {
const actual = await vi.importActual(
'@/platform/workflow/management/stores/workflowStore'
)
return {
...actual,
useWorkflowStore: () => ({
activeWorkflow: {
changeTracker: {
checkState: mockCheckState
}
}
})
}
})
vi.mock('@/scripts/api', () => ({
api: {
fetchApi: vi.fn(),
apiURL: vi.fn((url: string) => url),
addEventListener: vi.fn(),
removeEventListener: vi.fn()
}
}))
vi.mock(
'@/renderer/extensions/vueNodes/widgets/composables/useAssetWidgetData',
() => ({
@@ -456,3 +483,69 @@ describe('WidgetSelectDropdown cloud asset mode (COM-14333)', () => {
expect(selectedSet.has('missing-missing_model.safetensors')).toBe(true)
})
})
describe('WidgetSelectDropdown undo tracking', () => {
interface UndoTrackingInstance extends ComponentPublicInstance {
updateSelectedItems: (selectedSet: Set<string>) => void
handleFilesUpdate: (files: File[]) => Promise<void>
}
const mountForUndo = (
widget: SimplifiedWidget<string | undefined>,
modelValue: string | undefined
): VueWrapper<UndoTrackingInstance> => {
return mount(WidgetSelectDropdown, {
props: {
widget,
modelValue,
assetKind: 'image',
allowUpload: true,
uploadFolder: 'input'
},
global: {
plugins: [PrimeVue, createTestingPinia(), i18n]
}
}) as unknown as VueWrapper<UndoTrackingInstance>
}
beforeEach(() => {
mockCheckState.mockClear()
})
it('calls checkState after dropdown selection changes modelValue', () => {
const widget = createMockWidget<string | undefined>({
value: 'img_001.png',
name: 'test_image',
type: 'combo',
options: { values: ['img_001.png', 'photo_abc.jpg'] }
})
const wrapper = mountForUndo(widget, 'img_001.png')
wrapper.vm.updateSelectedItems(new Set(['input-1']))
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['photo_abc.jpg'])
expect(mockCheckState).toHaveBeenCalledOnce()
})
it('calls checkState after file upload completes', async () => {
const { api } = await import('@/scripts/api')
vi.mocked(api.fetchApi).mockResolvedValue({
status: 200,
json: () => Promise.resolve({ name: 'uploaded.png', subfolder: '' })
} as Response)
const widget = createMockWidget<string | undefined>({
value: 'img_001.png',
name: 'test_image',
type: 'combo',
options: { values: ['img_001.png'] }
})
const wrapper = mountForUndo(widget, 'img_001.png')
const file = new File(['test'], 'uploaded.png', { type: 'image/png' })
await wrapper.vm.handleFilesUpdate([file])
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['uploaded.png'])
expect(mockCheckState).toHaveBeenCalledOnce()
})
})

View File

@@ -17,6 +17,7 @@ import {
getAssetFilename
} from '@/platform/assets/utils/assetMetadataUtils'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import FormDropdown from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdown.vue'
import type {
FilterOption,
@@ -376,6 +377,7 @@ function updateSelectedItems(selectedItems: Set<string>) {
return
}
modelValue.value = name
useWorkflowStore().activeWorkflow?.changeTracker?.checkState()
}
const uploadFile = async (
@@ -450,6 +452,9 @@ async function handleFilesUpdate(files: File[]) {
if (props.widget.callback) {
props.widget.callback(uploadedPaths[0])
}
// 5. Snapshot undo state so the image change gets its own undo entry
useWorkflowStore().activeWorkflow?.changeTracker?.checkState()
} catch (error) {
console.error('Upload error:', error)
toastStore.addAlert(`Upload failed: ${error}`)

View File

@@ -33,7 +33,7 @@ const hideLayoutField = useHideLayoutField()
<div
:class="
cn(
'min-w-0 cursor-default rounded-lg transition-all focus-within:ring focus-within:ring-component-node-widget-background-highlighted',
'min-w-0 cursor-default rounded-lg transition-all has-focus-visible:ring has-focus-visible:ring-component-node-widget-background-highlighted',
widget.borderStyle
)
"

View File

@@ -40,6 +40,7 @@ function addMultilineWidget(
})
widget.element = inputEl
widget.inputEl = inputEl
widget.options.minNodeSize = [400, 200]
inputEl.addEventListener('input', (event) => {

View File

@@ -464,8 +464,7 @@ const zSettings = z.object({
'Comfy.VersionCompatibility.DisableWarnings': z.boolean(),
'Comfy.RightSidePanel.IsOpen': z.boolean(),
'Comfy.RightSidePanel.ShowErrorsTab': z.boolean(),
'Comfy.Node.AlwaysShowAdvancedWidgets': z.boolean(),
'Comfy.Canvas.AlignNodesWhileDragging': z.boolean()
'Comfy.Node.AlwaysShowAdvancedWidgets': z.boolean()
})
export type EmbeddingsResponse = z.infer<typeof zEmbeddingsResponse>

View File

@@ -73,6 +73,7 @@ import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
import { useModelStore } from '@/stores/modelStore'
import { SYSTEM_NODE_DEFS, useNodeDefStore } from '@/stores/nodeDefStore'
import { useNodeReplacementStore } from '@/platform/nodeReplacement/nodeReplacementStore'
import { useSubgraphStore } from '@/stores/subgraphStore'
import { useWidgetStore } from '@/stores/widgetStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
@@ -82,6 +83,15 @@ import type { NodeExecutionId } from '@/types/nodeIdentification'
import { graphToPrompt } from '@/utils/executionUtil'
import { getCnrIdFromProperties } from '@/workbench/extensions/manager/utils/missingNodeErrorUtil'
import { rescanAndSurfaceMissingNodes } from '@/platform/nodeReplacement/missingNodeScan'
import {
scanAllModelCandidates,
enrichWithEmbeddedMetadata,
verifyAssetSupportedCandidates
} from '@/platform/missingModel/missingModelScan'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { assetService } from '@/platform/assets/services/assetService'
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
import { anyItemOverlapsRect } from '@/utils/mathUtil'
import {
collectAllNodes,
@@ -104,7 +114,7 @@ import {
findLegacyRerouteNodes,
noNativeReroutes
} from '@/utils/migration/migrateReroute'
import { getSelectedModelsMetadata } from '@/workbench/utils/modelMetadataUtil'
import { deserialiseAndCreate } from '@/utils/vintageClipboard'
import { type ComfyApi, PromptExecutionError, api } from './api'
@@ -1124,6 +1134,8 @@ export class ComfyApp {
} = options
useWorkflowService().beforeLoadNewGraph()
useMissingModelStore().clearMissingModels()
if (clean !== false) {
this.clean()
}
@@ -1168,18 +1180,17 @@ export class ComfyApp {
useSubgraphService().loadSubgraphs(graphData)
const missingNodeTypes: MissingNodeType[] = []
const missingModels: ModelFile[] = []
await useExtensionService().invokeExtensionsAsync(
'beforeConfigureGraph',
graphData,
missingNodeTypes
)
const embeddedModels: ModelFile[] = []
const nodeReplacementStore = useNodeReplacementStore()
await nodeReplacementStore.load()
const collectMissingNodesAndModels = (
// Collect missing node types from all nodes (root + subgraphs)
const collectMissingNodes = (
nodes: ComfyWorkflowJSON['nodes'],
pathPrefix: string = '',
displayName: string = ''
@@ -1192,16 +1203,11 @@ export class ComfyApp {
return
}
for (let n of nodes) {
// Find missing node types
if (!(n.type in LiteGraph.registered_node_types)) {
const replacement = nodeReplacementStore.getReplacementFor(n.type)
// To access missing node information in the error tab
// we collect the cnr_id and execution_id here.
const cnrId = getCnrIdFromProperties(
n.properties as Record<string, unknown> | undefined
)
const executionId = pathPrefix
? `${pathPrefix}:${n.id}`
: String(n.id)
@@ -1219,65 +1225,25 @@ export class ComfyApp {
n.type = sanitizeNodeName(n.type)
}
// Collect models metadata from node
const selectedModels = getSelectedModelsMetadata(n)
if (selectedModels?.length) {
embeddedModels.push(...selectedModels)
}
}
}
// Process nodes at the top level
collectMissingNodesAndModels(graphData.nodes)
// Build map: subgraph definition UUID → full execution path prefix.
// Handles arbitrary nesting depth (e.g. root node 11 → "11", node 14 in sg 11 → "11:14").
collectMissingNodes(graphData.nodes)
const subgraphDefs = graphData.definitions?.subgraphs ?? []
const subgraphContainerIdMap = buildSubgraphExecutionPaths(
graphData.nodes,
graphData.definitions?.subgraphs ?? []
subgraphDefs
)
// Process nodes in subgraphs
if (graphData.definitions?.subgraphs) {
for (const subgraph of graphData.definitions.subgraphs) {
if (isSubgraphDefinition(subgraph)) {
const paths = subgraphContainerIdMap.get(subgraph.id) ?? []
for (const pathPrefix of paths) {
collectMissingNodesAndModels(
subgraph.nodes,
pathPrefix,
subgraph.name || subgraph.id
)
}
}
}
}
// Merge models from the workflow's root-level 'models' field
const workflowSchemaV1Models = graphData.models
if (workflowSchemaV1Models?.length)
embeddedModels.push(...workflowSchemaV1Models)
const getModelKey = (model: ModelFile) => model.url || model.hash
const validModels = embeddedModels.filter(getModelKey)
const uniqueModels = _.uniqBy(validModels, getModelKey)
if (
uniqueModels.length &&
useSettingStore().get('Comfy.Workflow.ShowMissingModelsWarning')
) {
const modelStore = useModelStore()
await modelStore.loadModelFolders()
for (const m of uniqueModels) {
const modelFolder = await modelStore.getLoadedModelFolder(m.directory)
const modelsAvailable = modelFolder?.models
const modelExists =
modelsAvailable &&
Object.values(modelsAvailable).some(
(model) => model.file_name === m.name
for (const subgraph of subgraphDefs) {
if (isSubgraphDefinition(subgraph)) {
const paths = subgraphContainerIdMap.get(subgraph.id) ?? []
for (const pathPrefix of paths) {
collectMissingNodes(
subgraph.nodes,
pathPrefix,
subgraph.name || subgraph.id
)
if (!modelExists) missingModels.push(m)
}
}
}
@@ -1423,21 +1389,12 @@ export class ComfyApp {
requestAnimationFrame(() => fitView())
}
// Store pending warnings on the workflow for deferred display
const activeWf = useWorkspaceStore().workflow.activeWorkflow
if (activeWf) {
const warnings: PendingWarnings = {}
if (missingNodeTypes.length && showMissingNodesDialog) {
warnings.missingNodeTypes = missingNodeTypes
}
if (missingModels.length && showMissingModelsDialog) {
const paths = await api.getFolderPaths()
warnings.missingModels = { missingModels: missingModels, paths }
}
if (warnings.missingNodeTypes || warnings.missingModels) {
activeWf.pendingWarnings = warnings
}
}
await this.runMissingModelPipeline(
graphData,
missingNodeTypes,
showMissingNodesDialog,
showMissingModelsDialog
)
if (!deferWarnings) {
useWorkflowService().showPendingWarnings()
@@ -1451,6 +1408,97 @@ export class ComfyApp {
}
}
private async runMissingModelPipeline(
graphData: ComfyWorkflowJSON,
missingNodeTypes: MissingNodeType[],
showMissingNodesDialog: boolean,
showMissingModelsDialog: boolean
): Promise<{ missingModels: ModelFile[] }> {
const missingModelStore = useMissingModelStore()
const candidates = isCloud
? scanAllModelCandidates(
this.rootGraph,
(nodeType, widgetName) =>
assetService.shouldUseAssetBrowser(nodeType, widgetName),
(nodeType) => useModelToNodeStore().getCategoryForNodeType(nodeType)
)
: []
const modelStore = useModelStore()
await modelStore.loadModelFolders()
const enrichedCandidates = await enrichWithEmbeddedMetadata(
candidates,
graphData,
async (name, directory) => {
const folder = await modelStore.getLoadedModelFolder(directory)
const models = folder?.models
return !!(
models && Object.values(models).some((m) => m.file_name === name)
)
},
isCloud
? (nodeType, widgetName) =>
assetService.shouldUseAssetBrowser(nodeType, widgetName)
: undefined
)
const missingModels: ModelFile[] = enrichedCandidates
.filter((c) => c.isMissing === true && c.url)
.map((c) => ({
name: c.name,
url: c.url ?? '',
directory: c.directory ?? '',
hash: c.hash,
hash_type: c.hashType
}))
const activeWf = useWorkspaceStore().workflow.activeWorkflow
if (activeWf) {
const warnings: PendingWarnings = {}
if (missingNodeTypes.length && showMissingNodesDialog) {
warnings.missingNodeTypes = missingNodeTypes
}
if (missingModels.length && showMissingModelsDialog) {
const paths = await api.getFolderPaths()
warnings.missingModels = { missingModels, paths }
}
if (warnings.missingNodeTypes || warnings.missingModels) {
activeWf.pendingWarnings = warnings
}
}
if (isCloud && enrichedCandidates.length) {
const controller = missingModelStore.createVerificationAbortController()
verifyAssetSupportedCandidates(enrichedCandidates, controller.signal)
.then(() => {
if (controller.signal.aborted) return
const confirmed = enrichedCandidates.filter(
(c) => c.isMissing === true
)
if (confirmed.length) {
useExecutionErrorStore().surfaceMissingModels(confirmed)
}
})
.catch((err) => {
console.warn(
'[Missing Model Pipeline] Asset verification failed:',
err
)
useToastStore().add({
severity: 'warn',
summary: st(
'toastMessages.missingModelVerificationFailed',
'Failed to verify missing models. Some models may not be shown in the Errors tab.'
),
life: 5000
})
})
}
return { missingModels }
}
async graphToPrompt(graph = this.rootGraph) {
return graphToPrompt(graph, {
sortNodes: useSettingStore().get('Comfy.Workflow.SortNodeIdOnSave')
@@ -1578,6 +1626,18 @@ export class ComfyApp {
}
}
const activeWorkflow = useWorkspaceStore().workflow
.activeWorkflow as ComfyWorkflow | undefined
const wid =
activeWorkflow?.activeState?.id ??
activeWorkflow?.initialState?.id
if (wid) {
executionStore.setWorkflowExecutionResultByWorkflowId(
String(wid),
'error'
)
}
if (useSettingStore().get('Comfy.RightSidePanel.ShowErrorsTab')) {
executionErrorStore.showErrorOverlay()
}

Some files were not shown because too many files have changed in this diff Show More