Compare commits

..

21 Commits

Author SHA1 Message Date
bymyself
c844711f6d [fix] Resolve Sentry issue CLOUD-FRONTEND-STAGING-13 - TypeError on undefined device
Fixes TypeError: Cannot read properties of undefined (reading 'name')
occurring in DeviceInfo component when device prop is undefined.

Changes:
- DeviceInfo: Add null checks and graceful error handling for undefined device prop
- DeviceInfo: Enhanced formatValue to return 'N/A' for null/undefined values
- SystemStatsPanel: Add guard clauses for empty devices array
- SystemStatsPanel: Display helpful message when no devices available
- Added monitoring to log when devices array is missing for debugging
- Added i18n keys for error messages

Root cause: Component attempted to access properties on undefined device object
when SystemStats API returned empty devices array or malformed data.

Solution: Defensive programming with null checks and user-friendly error messages.

Tests updated to verify graceful handling instead of throwing errors.

Sentry URL: https://comfy-org.sentry.io/issues/6804418395/?project=4509681221369857
2025-09-19 01:38:37 -07:00
bymyself
28cb3bbdb5 [test] Add tests for Sentry issue CLOUD-FRONTEND-STAGING-13 - TypeError on undefined device
Tests reproduce the exact error: "TypeError: Cannot read properties of undefined (reading 'name')"
occurring in DeviceInfo component when props.device is undefined.

Root cause scenarios tested:
- DeviceInfo component receives undefined device prop
- SystemStatsPanel passes undefined device when devices array is empty
- API response malformation leading to missing device data
- Edge cases with corrupted or missing SystemStats data

Sentry URL: https://comfy-org.sentry.io/issues/6804418395/?project=4509681221369857

Tests currently fail as expected, proving the bug exists.
Next: implement defensive programming to handle undefined device props.
2025-09-19 00:19:07 -07:00
snomiao
d59885839a fix: correct Claude PR review to use BASE_SHA for accurate diff comparison (#5654)
## Summary
- Fixes the Claude automated PR review comparing against wrong commits
- Updates the comprehensive-pr-review.md command to use `$BASE_SHA`
instead of `origin/$BASE_BRANCH`
- Resolves issue where Claude was reviewing unrelated changes from other
PRs

## Problem
As identified in #5651 (comment
https://github.com/Comfy-Org/ComfyUI_frontend/pull/5651#issuecomment-3310416767),
the Claude automated review was incorrectly analyzing changes that
weren't part of the PR being reviewed. The review was mentioning Turkish
language removal, linkRenderer changes, and other modifications that
weren't in the actual PR diff.

## Root Cause Analysis

### The Issue Explained (from Discord discussion)
When Christian Byrne noticed Claude was referencing things from previous
reviews on other PRs, we investigated and found:

1. **The backport branch was created from origin/main BEFORE Turkish
language support was merged**
   - Branch state: `main.A`
   - Backport changes committed: `main.A.Backport`

2. **Turkish language support was then merged into origin/main**
   - Main branch updated to: `main.A.Turkish`

3. **Claude review workflow checked out `main.A.Backport` and ran git
diff against `origin/main`**
   - This compared: `main.A.Backport <> main.A.Turkish`
   - The diff showed: `+++Backport` changes and `---Turkish` removal
   - Because the common parent of both branches was `main.A`

### Why This Happens
When using `origin/$BASE_BRANCH`, git resolves to the latest commit on
that branch. The diff includes:
1. The PR's actual changes (+++Backport)
2. The reverse of all commits merged to main since the PR was created
(---Turkish)

This causes Claude to review changes that appear as "removals" of code
from other merged PRs, leading to confusing comments about unrelated
code.

## Solution
Changed the git diff commands to use `$BASE_SHA` directly, which GitHub
Actions provides as the exact commit SHA that represents the merge base.
This ensures Claude only reviews the actual changes introduced by the
PR.

### Before (incorrect):
```bash
git diff --name-only "origin/$BASE_BRANCH"  # Compares against latest main
git diff "origin/$BASE_BRANCH"
git diff --name-status "origin/$BASE_BRANCH"
```

### After (correct):
```bash
git diff --name-only "$BASE_SHA"  # Compares against merge base
git diff "$BASE_SHA"
git diff --name-status "$BASE_SHA"
```

## Technical Details

### GitHub Actions Environment Variables
- `BASE_SHA`: The commit SHA of the merge base (where PR branched from
main)
- `BASE_BRANCH`: Not provided by GitHub Actions (this was the bug)
- Using `origin/$BASE_BRANCH` was falling back to comparing against the
latest main commit

### Alternative Approaches Considered
1. **Approach 1**: Rebase/update branch before running Claude review
   - Downside: Changes the PR's commits, not always desirable
2. **Approach 2**: Use BASE_SHA to diff against the merge base 
   - This is what GitHub's PR diff view does
   - Shows only the changes introduced by the PR

## Testing
The BASE_SHA environment variable is already correctly set in the
claude-pr-review.yml workflow (line 88), so this change will work
immediately once merged.

## Impact
- Claude reviews will now be accurate and only analyze the actual PR
changes
- No false positives about "removed" code from other PRs
- More reliable automated PR review process
- Developers won't be confused by comments about code they didn't change

## Verification
You can verify this fix by:
1. Creating a PR from an older branch
2. Merging another PR to main
3. Triggering Claude review with the label
4. Claude should only review the PR's changes, not show removals from
the newly merged commits

## Credits
Thanks to @Christian-Byrne for reporting the issue and @snomiao for the
root cause analysis.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: Claude <noreply@anthropic.com>
2025-09-18 22:09:20 -07:00
snomiao
cbb0f765b8 feat: enable verbatimModuleSyntax in TypeScript config (#5533)
## Summary
- Enable `verbatimModuleSyntax` compiler option in TypeScript
configuration
- Update all type imports to use explicit `import type` syntax
- This change will Improve tree-shaking and bundler compatibility

## Motivation
The `verbatimModuleSyntax` option ensures that type-only imports are
explicitly marked with the `type` keyword. This:
- Makes import/export intentions clearer
- Improves tree-shaking by helping bundlers identify what can be safely
removed
- Ensures better compatibility with modern bundlers
- Follows TypeScript best practices for module syntax

## Changes
- Added `"verbatimModuleSyntax": true` to `tsconfig.json`
- Updated another 48+ files to use explicit `import type` syntax for
type-only imports
- No functional changes, only import/export syntax improvements

## Test Plan
- [x] TypeScript compilation passes
- [x] Build completes successfully  
- [x] Tests pass
- [ ] No runtime behavior changes

🤖 Generated with [Claude Code](https://claude.ai/code)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5533-feat-enable-verbatimModuleSyntax-in-TypeScript-config-26d6d73d36508190b424ef9b379b5130)
by [Unito](https://www.unito.io)
2025-09-18 21:05:56 -07:00
Christian Byrne
726a2fbbc9 feat: add manual dispatch to backport workflow (#5651)
Enables manual backport triggering for scenarios where labels are added
after PR merge.

Adds workflow_dispatch trigger to the backport workflow with support
for:
- Specifying PR number to backport post-merge
- Force rerun option to override duplicate detection  
- Proper handling of multi-version backport scenarios

Solves the issue where adding version labels (e.g., 1.27) after a PR is
already merged and backported (e.g., to 1.26) would not trigger
additional backports.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5651-feat-add-manual-dispatch-to-backport-workflow-2736d73d365081b6ba00c7a43c9ba06b)
by [Unito](https://www.unito.io)
2025-09-18 21:01:07 -07:00
snomiao
553b5aa02b feat: Add Turkish language support (#5438)
## Summary
- Added complete Turkish language translation for ComfyUI Frontend
- Integrated Turkish locale into the i18n system
- Added Turkish as a selectable language option in settings

## Implementation Details
- Added Turkish translation files provided by @naxci1:
  - `src/locales/tr/main.json` - Main UI translations
  - `src/locales/tr/commands.json` - Command translations
  - `src/locales/tr/nodeDefs.json` - Node definitions translations
  - `src/locales/tr/settings.json` - Settings translations
- Updated `src/i18n.ts` to import and register Turkish locale
- Added Turkish option to language selector in
`src/constants/coreSettings.ts`

## Test Plan
- [ ] Verify Turkish translations load correctly
- [ ] Test language switching to/from Turkish
- [ ] Check all UI elements display properly in Turkish
- [ ] Verify node descriptions and tooltips in Turkish
- [ ] Test command palette in Turkish

Fixes #5437

🤖 Generated with [Claude Code](https://claude.ai/code)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5438-feat-Add-Turkish-language-support-2686d73d36508184bbf2dc1e0cd15350)
by [Unito](https://www.unito.io)
2025-09-18 19:43:53 -07:00
Benjamin Lu
2ff0d951ed Slot functionality for vue nodes (#5628)
Allows for simple slot functionality in vue nodes mode.

Has:
- Drag new link from slot
- Connect new link from dropping on slot

Now:
- Tests

After:
- Drop on reroute
- Correct link color on connect
- Drop on node
- Hover effects

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5628-Slot-functionality-for-vue-nodes-2716d73d365081c59a3cef7c8a5e539e)
by [Unito](https://www.unito.io)

---------

Co-authored-by: bymyself <cbyrne@comfy.org>
Co-authored-by: AustinMroz <austin@comfy.org>
Co-authored-by: Claude <noreply@anthropic.com>
2025-09-18 19:35:15 -07:00
Christian Byrne
1f88925144 fix: don't immediately close missing nodes dialog if manager is disabled (#5647)
If manager is disabled, it assumed all missing nodes are installed and
immediately closes the missing nodes warning when loading a workflow
with missing nodes.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5647-fix-don-t-immediately-close-missing-nodes-dialog-if-manager-is-disabled-2736d73d36508199a50bca2026528ab6)
by [Unito](https://www.unito.io)
2025-09-18 17:54:37 -07:00
AustinMroz
250433a91a Fix SaveAs (#5643)
Implementing subgraph blueprints (#5139) included changes to saving to
ensure that SaveAs generates a new workflow of the correct type. However
this code failed to utilize the pre-prepared state when performing the
actual save. This produced a couple of problems with both failing to
detach the workflow and failing to apply the correct state

This error is only encountered when using Save As from a non temporary
workflow (one loaded from the workflows sidebar tab).

As this state calculation code is only used in this code path, it has
been moved into the saveAs function of the workflowStore.

Resolves #5592

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5643-Fix-SaveAs-2726d73d3650818faa7af449d1f13c26)
by [Unito](https://www.unito.io)
2025-09-18 16:56:49 -07:00
AustinMroz
eb664f47af Fix cyclic prototype errors with subgraphNodes (#5637)
#5024 added support for connecting primitive nodes to subgraph inputs.
To accomplish this, it pulls WidgetLocator information from the node
owning the widget.

This `node` property does not exist on all IBaseWidget. `toConcrete` was
used to instead have a BaseWidget which is guaranteed to have a node
property. The issue that was missed, is that a widget which lacks this
information (such as most implemented by custom nodes) sets the node
value to the argument which was passed. Here that is the reference to
the subgraph node. Sometimes, this `#setWidget` call is made multiple
times, and when this occurs, the `input.widget` has itself set as the
protoyep, throwing an error.

This is resolved by instead taking an additional input which is
unambiguous.

For reference, this is a near minimal workflow using comfy_mtb that
replicates the issue

[cyclic.json](https://github.com/user-attachments/files/22412187/cyclic.json)

Special thanks to @melMass for assistance discovering this issue.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5637-Fix-cyclic-prototype-errors-with-subgraphNodes-2726d73d365081fea356f5197e4c2b42)
by [Unito](https://www.unito.io)
2025-09-18 16:06:12 -07:00
Christian Byrne
bc85d4e87b Make Vue nodes read-only when in panning mode (#5574)
## Summary

Integrated Vue node components with canvas panning mode to prevent UI
interference during navigation.

## Changes

- **What**: Added
[canCapturePointerEvents](https://docs.comfy.org/guide/vue-nodes)
computed property to `useCanvasInteractions` composable that checks
canvas read-only state
- **What**: Modified Vue node components (LGraphNode, NodeWidgets) to
conditionally handle pointer events based on canvas navigation mode
- **What**: Updated node event handlers to respect panning mode and
forward events to canvas when appropriate

## Review Focus

Event forwarding logic in panning mode and pointer event capture state
management across Vue node hierarchy.

```mermaid
graph TD
    A[User Interaction] --> B{Canvas in Panning Mode?}
    B -->|Yes| C[Forward to Canvas]
    B -->|No| D[Handle in Vue Component]
    C --> E[Canvas Navigation]
    D --> F[Node Selection/Widget Interaction]

    G[canCapturePointerEvents] --> H{read_only === false}
    H -->|Yes| I[Allow Vue Events]
    H -->|No| J[Block Vue Events]

    style A fill:#f9f9f9,stroke:#333,color:#000
    style E fill:#f9f9f9,stroke:#333,color:#000
    style F fill:#f9f9f9,stroke:#333,color:#000
    style I fill:#e1f5fe,stroke:#01579b,color:#000
    style J fill:#ffebee,stroke:#c62828,color:#000
```

## Screenshots




https://github.com/user-attachments/assets/00dc5e4a-2b56-43be-b92e-eaf511e52542

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5574-Make-Vue-nodes-read-only-when-in-panning-mode-26f6d73d3650818c951cd82c8fe58972)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2025-09-18 15:43:35 -07:00
Comfy Org PR Bot
7585444ce6 1.28.0 (#5640)
Minor version increment to 1.28.0

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5640-1-28-0-2726d73d3650818e846fcf78cbf33b73)
by [Unito](https://www.unito.io)

Co-authored-by: benceruleanlu <162923238+benceruleanlu@users.noreply.github.com>
2025-09-18 14:13:33 -07:00
Robin Huang
a886798a10 Explicitly add email scope for social auth login. (#5638)
## Summary

Some users were authenticating successfully but their email addresses
weren't being extracted from the Firebase token. This happened because
we weren't explicitly requesting the email scope during OAuth
authentication.
 
While Firebase's default configuration includes basic profile info, it
doesn't guarantee email access for all account types - particularly
Google Workspace accounts with restrictive policies or users with
privacy-conscious settings.

[Github
Scopes](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/scopes-for-oauth-apps)

## Changes

Adding email scope for Google + Github social OAuth.

## Review Focus
N/A

## Screenshots (if applicable)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5638-Explicitly-add-email-scope-for-social-auth-login-2726d73d3650817ab356fc9c04f8641b)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2025-09-18 14:09:16 -07:00
Christian Byrne
37975e4eac [test] Add component test for image compare widget (#5549)
## Summary

Added comprehensive component test suite for WidgetImageCompare widget
with 410 test assertions covering display, edge cases, and integration
scenarios.

## Changes

- **What**: Created [Vue Test Utils](https://vue-test-utils.vuejs.org/)
test suite for [WidgetImageCompare
component](src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.vue)
using [Vitest](https://vitest.dev/) testing framework

## Review Focus

Test coverage completeness for string vs object value handling,
accessibility attribute propagation, and edge case robustness including
malformed URLs and empty states.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5549-test-Add-component-test-for-image-compare-widget-26e6d73d365081189fe0d010f87d1eec)
by [Unito](https://www.unito.io)

---------

Co-authored-by: DrJKL <DrJKL@users.noreply.github.com>
2025-09-18 13:44:21 -07:00
Jin Yi
a41b8a6d4f refactor: Change manager flag from --disable-manager to --enable-manager (#5635)
## Summary
- Updated frontend to align with backend changes in ComfyUI core PR
#7555
- Changed manager startup argument from `--disable-manager` (opt-out) to
`--enable-manager` (opt-in)
- Manager is now disabled by default unless explicitly enabled

## Changes
- Modified `useManagerState.ts` to check for `--enable-manager` flag
presence
- Inverted logic: manager is disabled when the flag is NOT present
- Updated all related tests to reflect the new opt-in behavior
- Fixed edge case where `systemStats` is null

## Related
- Backend PR: https://github.com/comfyanonymous/ComfyUI/pull/7555

## Test Plan
- [x] All unit tests pass
- [x] Verified manager state logic with different flag combinations
- [x] TypeScript type checking passes
- [x] Linting passes

🤖 Generated with [Claude Code](https://claude.ai/code)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5635-refactor-Change-manager-flag-from-disable-manager-to-enable-manager-2726d73d36508153a88bd9f152132b2a)
by [Unito](https://www.unito.io)
2025-09-18 11:45:07 -07:00
Alexander Brown
b264685052 lint: add tsconfig for browser_tests, fix existing violations (#5633)
## Summary

See https://typescript-eslint.io/blog/project-service/ for context.
Creates a browser_tests specific tsconfig so that they can be linted.

Does not add a package.json script to do the linting yet, but `pnpm exec
eslint browser_tests` should work for now.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5633-lint-add-tsconfig-for-browser_tests-fix-existing-violations-2726d73d3650819d8ef2c4b0abc31e14)
by [Unito](https://www.unito.io)
2025-09-18 11:35:44 -07:00
Johnpaul Chiwetelu
78d0ea6fa5 LazyImage on Safari (#5626)
This pull request improves the lazy loading behavior and caching
strategy for images in the `LazyImage.vue` component. The most
significant changes are focused on optimizing image rendering and
resource management, as well as improving code clarity.

**Lazy loading behavior improvements:**

* Changed the `<img>` element to render only when `cachedSrc` is
available, ensuring that images are not displayed before they are ready.
* Updated watchers in `LazyImage.vue` to use clearer variable names
(`shouldLoadVal` instead of `shouldLoad`) for better readability and
maintainability.
[[1]](diffhunk://#diff-3a1bfa7eb8cb26b04bea73f7b4b4e3c01e9d20a7eba6c3738fb47f96da1a7c95L80-R81)
[[2]](diffhunk://#diff-3a1bfa7eb8cb26b04bea73f7b4b4e3c01e9d20a7eba6c3738fb47f96da1a7c95L96-R96)

**Caching strategy enhancement:**

* Modified the `fetch` call in `mediaCacheService.ts` to use `{ cache:
'force-cache' }`, which leverages the browser's cache more aggressively
when loading media, potentially improving performance and reducing
network requests.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5626-LazyImage-on-Safari-2716d73d365081eeb1d3c2a96be4d408)
by [Unito](https://www.unito.io)
2025-09-18 11:20:19 -07:00
Christian Byrne
ea4e57b602 Move VueFire persistence configuration to initialization (#5614)
Currently, we set persistence method in the auth store setup. This
creates pattern of using the default on init (indexed DB) up until the
firebase store is initialized and `setPersistence` is called. For
devices that don't support indexed DB or have the connection aggresively
terminated or cleared, like
[Safari](https://comfy-org.sentry.io/issues/6879071102/?project=4509681221369857&query=is%3Aunresolved&referrer=issue-stream),
this can create problems with maintaing auth persistence.

Fix by setting persistence method in the initialization in main.ts

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5614-Move-VueFire-persistence-configuration-to-initialization-2716d73d3650817480e0c8feb1f37b9a)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-09-18 11:18:05 -07:00
Johnpaul Chiwetelu
4789d86fe8 Line Selection toolbox up with Vue Nodes (#5601)
This pull request improves the selection toolbox behavior during node
dragging by ensuring that it correctly responds to both LiteGraph and
Vue node drag events. The main changes introduce a reactive drag state
for Vue nodes in the layout store and update the selection toolbox
composable and Vue node component to use this state.

**Selection toolbox behavior improvements:**

* Added a helper function and separate watchers in
`useSelectionToolboxPosition.ts` to hide the selection toolbox when
either LiteGraph or Vue nodes are being dragged. This ensures consistent
UI feedback regardless of node type.
[[1]](diffhunk://#diff-57a51ac5e656e64ae7fd276d71b115058631621755de33b1eb8e8a4731d48713L171-R172)
[[2]](diffhunk://#diff-57a51ac5e656e64ae7fd276d71b115058631621755de33b1eb8e8a4731d48713R212-R224)

**Vue node drag state management:**

* Added a reactive `isDraggingVueNodes` property to the
`LayoutStoreImpl` class, along with getter and setter methods to manage
Vue node drag state. This allows other components to reactively track
when Vue nodes are being dragged.
[[1]](diffhunk://#diff-80d32fe0fb72730c16cf7259adef8b20732ff214df240b1d39ae516737beaf3bR133-R135)
[[2]](diffhunk://#diff-80d32fe0fb72730c16cf7259adef8b20732ff214df240b1d39ae516737beaf3bR354-R367)
* Updated `LGraphNode.vue` to set and clear the Vue node dragging state
in the layout store during pointer down and up events, ensuring the
selection toolbox is hidden while dragging Vue nodes.
[[1]](diffhunk://#diff-a7744614cf842e54416047326db79ad81f7c7ab7bfb66ae2b46f5c73ac7d47f2R357-R360)
[[2]](diffhunk://#diff-a7744614cf842e54416047326db79ad81f7c7ab7bfb66ae2b46f5c73ac7d47f2R376-R378)

**Dependency updates:**

* Imported the `layoutStore` in `LGraphNode.vue` to access the new drag
state management methods.
* Added missing `ref` import in `layoutStore.ts` to support the new
reactive property.



https://github.com/user-attachments/assets/d6e9c15e-63b5-4de2-9688-ebbc6a3be545

---------

Co-authored-by: GitHub Action <action@github.com>
2025-09-18 11:17:14 -07:00
filtered
09e7d1040e Add desktop dialogs framework (#5605)
### Summary

Adds desktop dialog framework with data-driven dialog definitions.

### Changes

- Data-driven dialog structure in `desktopDialogs.ts`
- Dynamic dialog view component with i18n support
- Button action types: openUrl, close, cancel
- Button severity levels for styling (primary, secondary, danger, warn)
- Fallback invalid dialog for error handling
- i18n collection script updated for dialog strings
2025-09-17 20:32:53 -07:00
Arjan Singh
dfa1cbba4f Asset Browser Modal Component (#5607)
* [ci] ignore playwright mcp directory

* [feat] add AssetBrowserModal

And all related sub components

* [feat] reactive filter functions

* [ci] clean up storybook config

* [feat] add sematic AssetCard

* [fix] i love lucide

* [fix] AssetCard layout issues

* [fix] add AssetBadge type

* [fix] simplify useAssetBrowser

* [fix] modal layout

* [fix] simplify useAssetBrowserDialog

* [fix] add tailwind back to storybook

* [fix] better reponsive layout

* [fix] missed i18n string

* [fix] missing i18n translations

* [fix] remove erroneous prevent on keyboard.space

* [feat] add asset metadata validation utilities

* [fix] remove erroneous test code

* [fix] remove forced min and max width on AssetCard

* [fix] import statement nits
2025-09-17 16:17:09 -07:00
162 changed files with 15549 additions and 485 deletions

View File

@@ -67,9 +67,9 @@ This is critical for better file inspection:
Use git locally for much faster analysis:
1. Get list of changed files: `git diff --name-only "origin/$BASE_BRANCH" > changed_files.txt`
2. Get the full diff: `git diff "origin/$BASE_BRANCH" > pr_diff.txt`
3. Get detailed file changes with status: `git diff --name-status "origin/$BASE_BRANCH" > file_changes.txt`
1. Get list of changed files: `git diff --name-only "$BASE_SHA" > changed_files.txt`
2. Get the full diff: `git diff "$BASE_SHA" > pr_diff.txt`
3. Get detailed file changes with status: `git diff --name-status "$BASE_SHA" > file_changes.txt`
### Step 1.5: Create Analysis Cache

View File

@@ -4,10 +4,25 @@ on:
pull_request_target:
types: [closed, labeled]
branches: [main]
workflow_dispatch:
inputs:
pr_number:
description: 'PR number to backport'
required: true
type: string
force_rerun:
description: 'Force rerun even if backports exist'
required: false
type: boolean
default: false
jobs:
backport:
if: github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'needs-backport')
if: >
(github.event_name == 'pull_request_target' &&
github.event.pull_request.merged == true &&
contains(github.event.pull_request.labels.*.name, 'needs-backport')) ||
github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
permissions:
contents: write
@@ -15,6 +30,35 @@ jobs:
issues: write
steps:
- name: Validate inputs for manual triggers
if: github.event_name == 'workflow_dispatch'
run: |
# Validate PR number format
if ! [[ "${{ inputs.pr_number }}" =~ ^[0-9]+$ ]]; then
echo "::error::Invalid PR number format. Must be a positive integer."
exit 1
fi
# Validate PR exists and is merged
if ! gh pr view "${{ inputs.pr_number }}" --json merged >/dev/null 2>&1; then
echo "::error::PR #${{ inputs.pr_number }} not found or inaccessible."
exit 1
fi
MERGED=$(gh pr view "${{ inputs.pr_number }}" --json merged --jq '.merged')
if [ "$MERGED" != "true" ]; then
echo "::error::PR #${{ inputs.pr_number }} is not merged. Only merged PRs can be backported."
exit 1
fi
# Validate PR has needs-backport label
if ! gh pr view "${{ inputs.pr_number }}" --json labels --jq '.labels[].name' | grep -q "needs-backport"; then
echo "::error::PR #${{ inputs.pr_number }} does not have 'needs-backport' label."
exit 1
fi
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Checkout repository
uses: actions/checkout@v4
with:
@@ -29,7 +73,7 @@ jobs:
id: check-existing
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_NUMBER: ${{ github.event_name == 'workflow_dispatch' && inputs.pr_number || github.event.pull_request.number }}
run: |
# Check for existing backport PRs for this PR number
EXISTING_BACKPORTS=$(gh pr list --state all --search "backport-${PR_NUMBER}-to" --json title,headRefName,baseRefName | jq -r '.[].headRefName')
@@ -39,6 +83,13 @@ jobs:
exit 0
fi
# For manual triggers with force_rerun, proceed anyway
if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ "${{ inputs.force_rerun }}" = "true" ]; then
echo "skip=false" >> $GITHUB_OUTPUT
echo "::warning::Force rerun requested - existing backports will be updated"
exit 0
fi
echo "Found existing backport PRs:"
echo "$EXISTING_BACKPORTS"
echo "skip=true" >> $GITHUB_OUTPUT
@@ -50,8 +101,17 @@ jobs:
run: |
# Extract version labels (e.g., "1.24", "1.22")
VERSIONS=""
LABELS='${{ toJSON(github.event.pull_request.labels) }}'
for label in $(echo "$LABELS" | jq -r '.[].name'); do
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
# For manual triggers, get labels from the PR
LABELS=$(gh pr view ${{ inputs.pr_number }} --json labels | jq -r '.labels[].name')
else
# For automatic triggers, extract from PR event
LABELS='${{ toJSON(github.event.pull_request.labels) }}'
LABELS=$(echo "$LABELS" | jq -r '.[].name')
fi
for label in $LABELS; do
# Match version labels like "1.24" (major.minor only)
if [[ "$label" =~ ^[0-9]+\.[0-9]+$ ]]; then
# Validate the branch exists before adding to list
@@ -75,12 +135,20 @@ jobs:
if: steps.check-existing.outputs.skip != 'true'
id: backport
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_TITLE: ${{ github.event.pull_request.title }}
MERGE_COMMIT: ${{ github.event.pull_request.merge_commit_sha }}
PR_NUMBER: ${{ github.event_name == 'workflow_dispatch' && inputs.pr_number || github.event.pull_request.number }}
run: |
FAILED=""
SUCCESS=""
# Get PR data for manual triggers
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
PR_DATA=$(gh pr view ${{ inputs.pr_number }} --json title,mergeCommit)
PR_TITLE=$(echo "$PR_DATA" | jq -r '.title')
MERGE_COMMIT=$(echo "$PR_DATA" | jq -r '.mergeCommit.oid')
else
PR_TITLE="${{ github.event.pull_request.title }}"
MERGE_COMMIT="${{ github.event.pull_request.merge_commit_sha }}"
fi
for version in ${{ steps.versions.outputs.versions }}; do
echo "::group::Backporting to core/${version}"
@@ -133,10 +201,18 @@ jobs:
if: steps.check-existing.outputs.skip != 'true' && steps.backport.outputs.success
env:
GH_TOKEN: ${{ secrets.PR_GH_TOKEN }}
PR_TITLE: ${{ github.event.pull_request.title }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
PR_NUMBER: ${{ github.event_name == 'workflow_dispatch' && inputs.pr_number || github.event.pull_request.number }}
run: |
# Get PR data for manual triggers
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
PR_DATA=$(gh pr view ${{ inputs.pr_number }} --json title,author)
PR_TITLE=$(echo "$PR_DATA" | jq -r '.title')
PR_AUTHOR=$(echo "$PR_DATA" | jq -r '.author.login')
else
PR_TITLE="${{ github.event.pull_request.title }}"
PR_AUTHOR="${{ github.event.pull_request.user.login }}"
fi
for backport in ${{ steps.backport.outputs.success }}; do
IFS=':' read -r version branch <<< "${backport}"
@@ -165,9 +241,16 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
run: |
PR_NUMBER="${{ github.event.pull_request.number }}"
PR_AUTHOR="${{ github.event.pull_request.user.login }}"
MERGE_COMMIT="${{ github.event.pull_request.merge_commit_sha }}"
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
PR_DATA=$(gh pr view ${{ inputs.pr_number }} --json author,mergeCommit)
PR_NUMBER="${{ inputs.pr_number }}"
PR_AUTHOR=$(echo "$PR_DATA" | jq -r '.author.login')
MERGE_COMMIT=$(echo "$PR_DATA" | jq -r '.mergeCommit.oid')
else
PR_NUMBER="${{ github.event.pull_request.number }}"
PR_AUTHOR="${{ github.event.pull_request.user.login }}"
MERGE_COMMIT="${{ github.event.pull_request.merge_commit_sha }}"
fi
for failure in ${{ steps.backport.outputs.failed }}; do
IFS=':' read -r version reason conflicts <<< "${failure}"

4
.gitignore vendored
View File

@@ -78,8 +78,8 @@ vite.config.mts.timestamp-*.mjs
*storybook.log
storybook-static
# MCP Servers
.playwright-mcp/*
.nx/cache
.nx/workspace-data

View File

@@ -9,7 +9,7 @@ module.exports = defineConfig({
entry: 'src/locales/en',
entryLocale: 'en',
output: 'src/locales',
outputLocales: ['zh', 'zh-TW', 'ru', 'ja', 'ko', 'fr', 'es', 'ar'],
outputLocales: ['zh', 'zh-TW', 'ru', 'ja', 'ko', 'fr', 'es', 'ar', 'tr'],
reference: `Special names to keep untranslated: flux, photomaker, clip, vae, cfg, stable audio, stable cascade, stable zero, controlnet, lora, HiDream.
'latent' is the short form of 'latent space'.
'mask' is in the context of image processing.

View File

@@ -15,21 +15,32 @@ const config: StorybookConfig = {
async viteFinal(config) {
// Use dynamic import to avoid CJS deprecation warning
const { mergeConfig } = await import('vite')
const { default: tailwindcss } = await import('@tailwindcss/vite')
// Filter out any plugins that might generate import maps
if (config.plugins) {
config.plugins = config.plugins.filter((plugin: any) => {
if (plugin && plugin.name && plugin.name.includes('import-map')) {
return false
}
return true
})
config.plugins = config.plugins
// Type guard: ensure we have valid plugin objects with names
.filter(
(plugin): plugin is NonNullable<typeof plugin> & { name: string } => {
return (
plugin !== null &&
plugin !== undefined &&
typeof plugin === 'object' &&
'name' in plugin &&
typeof plugin.name === 'string'
)
}
)
// Business logic: filter out import-map plugins
.filter((plugin) => !plugin.name.includes('import-map'))
}
return mergeConfig(config, {
// Replace plugins entirely to avoid inheritance issues
plugins: [
// Only include plugins we explicitly need for Storybook
tailwindcss(),
Icons({
compiler: 'vue3',
customCollections: {

View File

@@ -1,7 +1,7 @@
import { definePreset } from '@primevue/themes'
import Aura from '@primevue/themes/aura'
import { setup } from '@storybook/vue3'
import type { Preview } from '@storybook/vue3-vite'
import type { Preview, StoryContext, StoryFn } from '@storybook/vue3-vite'
import { createPinia } from 'pinia'
import 'primeicons/primeicons.css'
import PrimeVue from 'primevue/config'
@@ -9,11 +9,9 @@ import ConfirmationService from 'primevue/confirmationservice'
import ToastService from 'primevue/toastservice'
import Tooltip from 'primevue/tooltip'
import '../src/assets/css/style.css'
import { i18n } from '../src/i18n'
import '../src/lib/litegraph/public/css/litegraph.css'
import { useWidgetStore } from '../src/stores/widgetStore'
import { useColorPaletteStore } from '../src/stores/workspace/colorPaletteStore'
import '@/assets/css/style.css'
import { i18n } from '@/i18n'
import '@/lib/litegraph/public/css/litegraph.css'
const ComfyUIPreset = definePreset(Aura, {
semantic: {
@@ -25,13 +23,11 @@ const ComfyUIPreset = definePreset(Aura, {
// Setup Vue app for Storybook
setup((app) => {
app.directive('tooltip', Tooltip)
// Create Pinia instance
const pinia = createPinia()
app.use(pinia)
// Initialize stores
useColorPaletteStore(pinia)
useWidgetStore(pinia)
app.use(i18n)
app.use(PrimeVue, {
theme: {
@@ -50,8 +46,8 @@ setup((app) => {
app.use(ToastService)
})
// Dark theme decorator
export const withTheme = (Story: any, context: any) => {
// Theme and dialog decorator
export const withTheme = (Story: StoryFn, context: StoryContext) => {
const theme = context.globals.theme || 'light'
// Apply theme class to document root
@@ -63,7 +59,7 @@ export const withTheme = (Story: any, context: any) => {
document.body.classList.remove('dark-theme')
}
return Story()
return Story(context.args, context)
}
const preview: Preview = {

View File

@@ -1,4 +1,5 @@
import { Page, test as base } from '@playwright/test'
import type { Page } from '@playwright/test'
import { test as base } from '@playwright/test'
export class UserSelectPage {
constructor(

View File

@@ -1,4 +1,4 @@
import { Locator, Page } from '@playwright/test'
import type { Locator, Page } from '@playwright/test'
export class ComfyNodeSearchFilterSelectionPanel {
constructor(public readonly page: Page) {}

View File

@@ -1,6 +1,6 @@
import { Page } from '@playwright/test'
import type { Page } from '@playwright/test'
import { ComfyPage } from '../ComfyPage'
import type { ComfyPage } from '../ComfyPage'
export class SettingDialog {
constructor(

View File

@@ -1,4 +1,4 @@
import { Locator, Page } from '@playwright/test'
import type { Locator, Page } from '@playwright/test'
class SidebarTab {
constructor(

View File

@@ -1,4 +1,5 @@
import { Locator, Page, expect } from '@playwright/test'
import type { Locator, Page } from '@playwright/test'
import { expect } from '@playwright/test'
export class Topbar {
private readonly menuLocator: Locator

View File

@@ -12,9 +12,10 @@ export const webSocketFixture = base.extend<{
// so we can look it up to trigger messages
const store: Record<string, WebSocket> = ((window as any).__ws__ = {})
window.WebSocket = class extends window.WebSocket {
constructor() {
// @ts-expect-error
super(...arguments)
constructor(
...rest: ConstructorParameters<typeof window.WebSocket>
) {
super(...rest)
store[this.url] = this
}
}

View File

@@ -1,4 +1,4 @@
import { FullConfig } from '@playwright/test'
import type { FullConfig } from '@playwright/test'
import dotenv from 'dotenv'
import { backupPath } from './utils/backupUtils'

View File

@@ -1,4 +1,4 @@
import { FullConfig } from '@playwright/test'
import type { FullConfig } from '@playwright/test'
import dotenv from 'dotenv'
import { restorePath } from './utils/backupUtils'

View File

@@ -1,4 +1,4 @@
import { Locator, Page } from '@playwright/test'
import type { Locator, Page } from '@playwright/test'
export class ManageGroupNode {
footer: Locator

View File

@@ -1,7 +1,7 @@
import { Locator, Page } from '@playwright/test'
import type { Locator, Page } from '@playwright/test'
import path from 'path'
import {
import type {
TemplateInfo,
WorkflowTemplates
} from '../../src/platform/workflow/templates/types/template'

View File

@@ -29,9 +29,9 @@ test.describe('Actionbar', () => {
// Intercept the prompt queue endpoint
let promptNumber = 0
comfyPage.page.route('**/api/prompt', async (route, req) => {
await comfyPage.page.route('**/api/prompt', async (route, req) => {
await new Promise((r) => setTimeout(r, 100))
route.fulfill({
await route.fulfill({
status: 200,
body: JSON.stringify({
prompt_id: promptNumber,

View File

@@ -1,5 +1,5 @@
import type { ComfyPage } from '../fixtures/ComfyPage'
import {
ComfyPage,
comfyExpect as expect,
comfyPageFixture as test
} from '../fixtures/ComfyPage'

View File

@@ -1,4 +1,5 @@
import { Page, expect } from '@playwright/test'
import type { Page } from '@playwright/test'
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'

View File

@@ -1,4 +1,5 @@
import { Locator, expect } from '@playwright/test'
import type { Locator } from '@playwright/test'
import { expect } from '@playwright/test'
import type { Keybinding } from '../../src/schemas/keyBindingSchema'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'

View File

@@ -1,6 +1,6 @@
import { expect } from '@playwright/test'
import { SettingParams } from '../../src/platform/settings/types'
import type { SettingParams } from '../../src/platform/settings/types'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe('Topbar commands', () => {

View File

@@ -1,6 +1,7 @@
import { expect } from '@playwright/test'
import { ComfyPage, comfyPageFixture as test } from '../fixtures/ComfyPage'
import type { ComfyPage } from '../fixtures/ComfyPage'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import type { NodeReference } from '../fixtures/utils/litegraphUtils'
test.describe('Group Node', () => {

View File

@@ -1,12 +1,13 @@
import { Locator, expect } from '@playwright/test'
import { Position } from '@vueuse/core'
import type { Locator } from '@playwright/test'
import { expect } from '@playwright/test'
import type { Position } from '@vueuse/core'
import {
type ComfyPage,
comfyPageFixture as test,
testComfySnapToGridGridSize
} from '../fixtures/ComfyPage'
import { type NodeReference } from '../fixtures/utils/litegraphUtils'
import type { NodeReference } from '../fixtures/utils/litegraphUtils'
test.describe('Item Interaction', () => {
test('Can select/delete all items', async ({ comfyPage }) => {

View File

@@ -1,6 +1,7 @@
import { expect } from '@playwright/test'
import { ComfyPage, comfyPageFixture as test } from '../fixtures/ComfyPage'
import type { ComfyPage } from '../fixtures/ComfyPage'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe('Remote COMBO Widget', () => {
const mockOptions = ['d', 'c', 'b', 'a']

View File

@@ -160,7 +160,9 @@ test.describe.skip('Queue sidebar', () => {
comfyPage
}) => {
await comfyPage.nextFrame()
expect(comfyPage.menu.queueTab.getGalleryImage(firstImage)).toBeVisible()
await expect(
comfyPage.menu.queueTab.getGalleryImage(firstImage)
).toBeVisible()
})
test('maintains active gallery item when new tasks are added', async ({
@@ -174,7 +176,9 @@ test.describe.skip('Queue sidebar', () => {
const newTask = comfyPage.menu.queueTab.tasks.getByAltText(newImage)
await newTask.waitFor({ state: 'visible' })
// The active gallery item should still be the initial image
expect(comfyPage.menu.queueTab.getGalleryImage(firstImage)).toBeVisible()
await expect(
comfyPage.menu.queueTab.getGalleryImage(firstImage)
).toBeVisible()
})
test.describe('Gallery navigation', () => {
@@ -196,7 +200,9 @@ test.describe.skip('Queue sidebar', () => {
delay: 256
})
await comfyPage.nextFrame()
expect(comfyPage.menu.queueTab.getGalleryImage(end)).toBeVisible()
await expect(
comfyPage.menu.queueTab.getGalleryImage(end)
).toBeVisible()
})
})
})

View File

@@ -1,4 +1,5 @@
import { Page, expect } from '@playwright/test'
import type { Page } from '@playwright/test'
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'

View File

@@ -1,6 +1,6 @@
import { expect } from '@playwright/test'
import { SystemStats } from '../../src/schemas/apiSchema'
import type { SystemStats } from '../../src/schemas/apiSchema'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe('Version Mismatch Warnings', () => {

View File

@@ -1,5 +1,5 @@
{
"extends": "./tsconfig.json",
"extends": "../tsconfig.json",
"compilerOptions": {
/* Test files should not be compiled */
"noEmit": true,
@@ -9,13 +9,6 @@
"resolveJsonModule": true
},
"include": [
"*.ts",
"*.mts",
"*.config.js",
"browser_tests/**/*.ts",
"scripts/**/*.js",
"scripts/**/*.ts",
"tests-ui/**/*.ts",
".storybook/**/*.ts"
"**/*.ts",
]
}

View File

@@ -1,5 +1,5 @@
import path from 'path'
import { Plugin } from 'vite'
import type { Plugin } from 'vite'
interface ShimResult {
code: string

View File

@@ -1,7 +1,7 @@
import glob from 'fast-glob'
import fs from 'fs-extra'
import { dirname, join } from 'node:path'
import { HtmlTagDescriptor, Plugin, normalizePath } from 'vite'
import { type HtmlTagDescriptor, type Plugin, normalizePath } from 'vite'
interface ImportMapSource {
name: string

View File

@@ -153,5 +153,14 @@ export default defineConfig([
}
]
}
},
{
files: ['tests-ui/**/*'],
rules: {
'@typescript-eslint/consistent-type-imports': [
'error',
{ disallowTypeAnnotations: false }
]
}
}
])

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.27.5",
"version": "1.28.0",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",

View File

@@ -2,6 +2,7 @@ import * as fs from 'fs'
import { comfyPageFixture as test } from '../browser_tests/fixtures/ComfyPage'
import { CORE_MENU_COMMANDS } from '../src/constants/coreMenuCommands'
import { DESKTOP_DIALOGS } from '../src/constants/desktopDialogs'
import { SERVER_CONFIG_ITEMS } from '../src/constants/serverConfig'
import type { FormItem, SettingParams } from '../src/platform/settings/types'
import type { ComfyCommandImpl } from '../src/stores/commandStore'
@@ -131,6 +132,23 @@ test('collect-i18n-general', async ({ comfyPage }) => {
])
)
// Desktop Dialogs
const allDesktopDialogsLocale = Object.fromEntries(
Object.values(DESKTOP_DIALOGS).map((dialog) => [
normalizeI18nKey(dialog.id),
{
title: dialog.title,
message: dialog.message,
buttons: Object.fromEntries(
dialog.buttons.map((button) => [
normalizeI18nKey(button.label),
button.label
])
)
}
])
)
fs.writeFileSync(
localePath,
JSON.stringify(
@@ -144,7 +162,8 @@ test('collect-i18n-general', async ({ comfyPage }) => {
...allSettingCategoriesLocale
},
serverConfigItems: allServerConfigsLocale,
serverConfigCategories: allServerConfigCategoriesLocale
serverConfigCategories: allServerConfigCategoriesLocale,
desktopDialogs: allDesktopDialogsLocale
},
null,
2

View File

@@ -66,6 +66,8 @@
--color-charcoal-700: #202121;
--color-charcoal-800: #171718;
--color-neutral-550: #636363;
--color-stone-100: #444444;
--color-stone-200: #828282;
--color-stone-300: #bbbbbb;
@@ -103,6 +105,10 @@
--color-danger-100: #c02323;
--color-danger-200: #d62952;
--color-coral-red-600: #973a40;
--color-coral-red-500: #c53f49;
--color-coral-red-400: #dd424e;
--color-bypass: #6a246a;
--color-error: #962a2a;

View File

@@ -1,5 +1,5 @@
<template>
<div class="grid grid-cols-2 gap-2">
<div v-if="props.device" class="grid grid-cols-2 gap-2">
<template v-for="col in deviceColumns" :key="col.field">
<div class="font-medium">
{{ col.header }}
@@ -9,6 +9,9 @@
</div>
</template>
</div>
<div v-else class="text-red-500">
{{ $t('g.deviceNotAvailable') }}
</div>
</template>
<script setup lang="ts">
@@ -16,7 +19,7 @@ import type { DeviceStats } from '@/schemas/apiSchema'
import { formatSize } from '@/utils/formatUtil'
const props = defineProps<{
device: DeviceStats
device: DeviceStats | undefined
}>()
const deviceColumns: { field: keyof DeviceStats; header: string }[] = [
@@ -29,6 +32,10 @@ const deviceColumns: { field: keyof DeviceStats; header: string }[] = [
]
const formatValue = (value: any, field: string) => {
if (value === undefined || value === null) {
return 'N/A'
}
if (
['vram_total', 'vram_free', 'torch_vram_total', 'torch_vram_free'].includes(
field

View File

@@ -10,7 +10,7 @@
class="absolute inset-0"
/>
<img
v-show="isImageLoaded"
v-if="cachedSrc"
ref="imageRef"
:src="cachedSrc"
:alt="alt"
@@ -77,8 +77,8 @@ const shouldLoad = computed(() => isIntersecting.value)
watch(
shouldLoad,
async (shouldLoad) => {
if (shouldLoad && src && !cachedSrc.value && !hasError.value) {
async (shouldLoadVal) => {
if (shouldLoadVal && src && !cachedSrc.value && !hasError.value) {
try {
const cachedMedia = await getCachedMedia(src)
if (cachedMedia.error) {
@@ -93,7 +93,7 @@ watch(
console.warn('Failed to load cached media:', error)
cachedSrc.value = src
}
} else if (!shouldLoad) {
} else if (!shouldLoadVal) {
if (cachedSrc.value?.startsWith('blob:')) {
releaseUrl(src)
}

View File

@@ -20,17 +20,22 @@
<h2 class="text-2xl font-semibold mb-4">
{{ $t('g.devices') }}
</h2>
<TabView v-if="props.stats.devices.length > 1">
<TabPanel
v-for="device in props.stats.devices"
:key="device.index"
:header="device.name"
:value="device.index"
>
<DeviceInfo :device="device" />
</TabPanel>
</TabView>
<DeviceInfo v-else :device="props.stats.devices[0]" />
<div v-if="props.stats.devices && props.stats.devices.length > 0">
<TabView v-if="props.stats.devices.length > 1">
<TabPanel
v-for="device in props.stats.devices"
:key="device.index"
:header="device.name"
:value="device.index"
>
<DeviceInfo :device="device" />
</TabPanel>
</TabView>
<DeviceInfo v-else :device="props.stats.devices[0]" />
</div>
<div v-else class="text-yellow-600">
{{ $t('g.noDevicesDetected') }}
</div>
</div>
</div>
</template>
@@ -39,7 +44,7 @@
import Divider from 'primevue/divider'
import TabPanel from 'primevue/tabpanel'
import TabView from 'primevue/tabview'
import { computed } from 'vue'
import { computed, watchEffect } from 'vue'
import DeviceInfo from '@/components/common/DeviceInfo.vue'
import type { SystemStats } from '@/schemas/apiSchema'
@@ -71,4 +76,15 @@ const formatValue = (value: any, field: string) => {
}
return value
}
// Monitor for missing devices scenario for debugging
watchEffect(() => {
if (!props.stats?.devices || props.stats.devices.length === 0) {
console.warn('[SystemStatsPanel] No devices available in SystemStats:', {
hasDevices: !!props.stats?.devices,
deviceCount: props.stats?.devices?.length || 0,
statsStructure: props.stats ? Object.keys(props.stats) : null
})
}
})
</script>

View File

@@ -138,7 +138,7 @@ const allMissingNodesInstalled = computed(() => {
})
// Watch for completion and close dialog
watch(allMissingNodesInstalled, async (allInstalled) => {
if (allInstalled) {
if (allInstalled && showInstallAllButton.value) {
// Use nextTick to ensure state updates are complete
await nextTick()

View File

@@ -96,7 +96,6 @@ import NodeSearchboxPopover from '@/components/searchbox/NodeSearchBoxPopover.vu
import SideToolbar from '@/components/sidebar/SideToolbar.vue'
import SecondRowWorkflowTabs from '@/components/topbar/SecondRowWorkflowTabs.vue'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { useCanvasInteractions } from '@/composables/graph/useCanvasInteractions'
import { useViewportCulling } from '@/composables/graph/useViewportCulling'
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
import { useNodeBadge } from '@/composables/node/useNodeBadge'
@@ -118,8 +117,9 @@ import { useWorkflowAutoSave } from '@/platform/workflow/persistence/composables
import { useWorkflowPersistence } from '@/platform/workflow/persistence/composables/useWorkflowPersistence'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { SelectedNodeIdsKey } from '@/renderer/core/canvas/injectionKeys'
import { attachSlotLinkPreviewRenderer } from '@/renderer/core/canvas/links/slotLinkPreviewRenderer'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import TransformPane from '@/renderer/core/layout/transform/TransformPane.vue'
import { attachSlotLinkPreviewRenderer } from '@/renderer/core/linkInteractions/slotLinkPreviewRenderer'
import MiniMap from '@/renderer/extensions/minimap/MiniMap.vue'
import VueGraphNode from '@/renderer/extensions/vueNodes/components/LGraphNode.vue'
import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers'

View File

@@ -124,11 +124,11 @@ import ButtonGroup from 'primevue/buttongroup'
import { computed, onBeforeUnmount, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useCanvasInteractions } from '@/composables/graph/useCanvasInteractions'
import { useZoomControls } from '@/composables/useZoomControls'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import { useMinimap } from '@/renderer/extensions/minimap/composables/useMinimap'
import { useCommandStore } from '@/stores/commandStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'

View File

@@ -5,12 +5,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import SelectionToolbox from '@/components/graph/SelectionToolbox.vue'
import { useCanvasInteractions } from '@/composables/graph/useCanvasInteractions'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import { useExtensionService } from '@/services/extensionService'
// Mock the composables and services
vi.mock('@/composables/graph/useCanvasInteractions', () => ({
vi.mock('@/renderer/core/canvas/useCanvasInteractions', () => ({
useCanvasInteractions: vi.fn(() => ({
handleWheel: vi.fn()
}))

View File

@@ -60,9 +60,9 @@ import MaskEditorButton from '@/components/graph/selectionToolbox/MaskEditorButt
import RefreshSelectionButton from '@/components/graph/selectionToolbox/RefreshSelectionButton.vue'
import PublishSubgraphButton from '@/components/graph/selectionToolbox/SaveToSubgraphLibrary.vue'
import { useSelectionToolboxPosition } from '@/composables/canvas/useSelectionToolboxPosition'
import { useCanvasInteractions } from '@/composables/graph/useCanvasInteractions'
import { useSelectionState } from '@/composables/graph/useSelectionState'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import { useMinimap } from '@/renderer/extensions/minimap/composables/useMinimap'
import { useExtensionService } from '@/services/extensionService'
import { type ComfyCommandImpl, useCommandStore } from '@/stores/commandStore'

View File

@@ -1,4 +1,4 @@
import { onUnmounted, ref, watch } from 'vue'
import { computed, onUnmounted, ref, watch } from 'vue'
import type { Ref } from 'vue'
import { useCanvasTransformSync } from '@/composables/canvas/useCanvasTransformSync'
@@ -170,50 +170,75 @@ export function useSelectionToolboxPosition(
}
)
// Watch for dragging state
watch(
() => canvasStore.canvas?.state?.draggingItems,
(dragging) => {
if (dragging) {
visible.value = false
if (moreOptionsOpen.value) {
const currentSig = buildSelectionSignature(canvasStore)
if (currentSig !== moreOptionsSelectionSignature) {
moreOptionsSelectionSignature = null
}
moreOptionsWasOpenBeforeDrag = true
moreOptionsOpen.value = false
moreOptionsRestorePending.value = !!moreOptionsSelectionSignature
if (moreOptionsRestorePending.value) {
forceCloseMoreOptionsSignal.value++
} else {
moreOptionsWasOpenBeforeDrag = false
}
} else {
moreOptionsRestorePending.value = false
moreOptionsWasOpenBeforeDrag = false
}
} else {
requestAnimationFrame(() => {
updateSelectionBounds()
const selectionMatches = currentSelectionMatchesSignature(canvasStore)
const shouldRestore =
moreOptionsWasOpenBeforeDrag &&
visible.value &&
moreOptionsRestorePending.value &&
selectionMatches
if (shouldRestore) {
restoreMoreOptionsSignal.value++
} else {
moreOptionsRestorePending.value = false
}
moreOptionsWasOpenBeforeDrag = false
})
}
const handleDragStateChange = (dragging: boolean) => {
if (dragging) {
handleDragStart()
return
}
)
handleDragEnd()
}
const handleDragStart = () => {
visible.value = false
// Early return if more options wasn't open
if (!moreOptionsOpen.value) {
moreOptionsRestorePending.value = false
moreOptionsWasOpenBeforeDrag = false
return
}
// Handle more options cleanup
const currentSig = buildSelectionSignature(canvasStore)
const selectionChanged = currentSig !== moreOptionsSelectionSignature
if (selectionChanged) {
moreOptionsSelectionSignature = null
}
moreOptionsOpen.value = false
moreOptionsWasOpenBeforeDrag = true
moreOptionsRestorePending.value = !!moreOptionsSelectionSignature
if (moreOptionsRestorePending.value) {
forceCloseMoreOptionsSignal.value++
return
}
moreOptionsWasOpenBeforeDrag = false
}
const handleDragEnd = () => {
requestAnimationFrame(() => {
updateSelectionBounds()
const selectionMatches = currentSelectionMatchesSignature(canvasStore)
const shouldRestore =
moreOptionsWasOpenBeforeDrag &&
visible.value &&
moreOptionsRestorePending.value &&
selectionMatches
// Single point of assignment for each ref
moreOptionsRestorePending.value =
shouldRestore && moreOptionsRestorePending.value
moreOptionsWasOpenBeforeDrag = false
if (shouldRestore) {
restoreMoreOptionsSignal.value++
}
})
}
// Unified dragging state - combines both LiteGraph and Vue node dragging
const isDragging = computed((): boolean => {
const litegraphDragging = canvasStore.canvas?.state?.draggingItems ?? false
const vueNodeDragging =
shouldRenderVueNodes.value && layoutStore.isDraggingVueNodes.value
return litegraphDragging || vueNodeDragging
})
watch(isDragging, handleDragStateChange)
onUnmounted(() => {
resetMoreOptionsState()

View File

@@ -1,5 +1,5 @@
import { useCanvasInteractions } from '@/composables/graph/useCanvasInteractions'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
import { fitDimensionsToNodeWidth } from '@/utils/imageUtil'

View File

@@ -42,7 +42,12 @@ export function useManagerState() {
)
// Check command line args first (highest priority)
if (systemStats.value?.system?.argv?.includes('--disable-manager')) {
// --enable-manager flag enables the manager (opposite of old --disable-manager)
const hasEnableManager =
systemStats.value?.system?.argv?.includes('--enable-manager')
// If --enable-manager is NOT present, manager is disabled
if (!hasEnableManager) {
return ManagerUIState.DISABLED
}

View File

@@ -0,0 +1,75 @@
export interface DialogAction {
readonly label: string
readonly action: 'openUrl' | 'close' | 'cancel'
readonly url?: string
readonly severity?: 'danger' | 'primary' | 'secondary' | 'warn'
readonly returnValue: string
}
interface DesktopDialog {
readonly title: string
readonly message: string
readonly buttons: DialogAction[]
}
export const DESKTOP_DIALOGS = {
/** Shown when a corrupt venv is detected. */
reinstallVenv: {
title: 'Reinstall ComfyUI (Fresh Start)?',
message: `Sorry, we can't launch ComfyUI because some installed packages aren't compatible.
Click Reinstall to restore ComfyUI and get back up and running.
Please note: if you've added custom nodes, you'll need to reinstall them after this process.`,
buttons: [
{
label: 'Learn More',
action: 'openUrl',
url: 'https://docs.comfy.org',
returnValue: 'openDocs'
},
{
label: 'Reinstall',
action: 'close',
severity: 'danger',
returnValue: 'resetVenv'
}
]
},
/** A dialog that is shown when an invalid dialog ID is provided. */
invalidDialog: {
title: 'Invalid Dialog',
message: `Invalid dialog ID was provided.`,
buttons: [
{
label: 'Close',
action: 'cancel',
returnValue: 'cancel'
}
]
}
} as const satisfies { [K: string]: DesktopDialog }
/** The ID of a desktop dialog. */
type DesktopDialogId = keyof typeof DESKTOP_DIALOGS
/**
* Checks if {@link id} is a valid dialog ID.
* @param id The string to check
* @returns `true` if the ID is a valid dialog ID, otherwise `false`
*/
function isDialogId(id: unknown): id is DesktopDialogId {
return typeof id === 'string' && id in DESKTOP_DIALOGS
}
/**
* Gets the dialog with the given ID.
* @param dialogId The ID of the dialog to get
* @returns The dialog with the given ID
*/
export function getDialog(
dialogId: string | string[]
): DesktopDialog & { id: DesktopDialogId } {
const id = isDialogId(dialogId) ? dialogId : 'invalidDialog'
return { id, ...structuredClone(DESKTOP_DIALOGS[id]) }
}

View File

@@ -11,16 +11,16 @@ import {
} from '@/lib/litegraph/src/litegraph'
import { useToastStore } from '@/platform/updates/common/toastStore'
import {
ComfyLink,
ComfyNode,
ComfyWorkflowJSON
type ComfyLink,
type ComfyNode,
type ComfyWorkflowJSON
} from '@/platform/workflow/validation/schemas/workflowSchema'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import { useDialogService } from '@/services/dialogService'
import { useExecutionStore } from '@/stores/executionStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useWidgetStore } from '@/stores/widgetStore'
import { ComfyExtension } from '@/types/comfy'
import { type ComfyExtension } from '@/types/comfy'
import { ExecutableGroupNodeChildDTO } from '@/utils/executableGroupNodeChildDTO'
import { GROUP } from '@/utils/executableGroupNodeDto'
import { deserialiseAndCreate, serialise } from '@/utils/vintageClipboard'

View File

@@ -9,7 +9,7 @@ import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import { t } from '@/i18n'
import type { IStringWidget } from '@/lib/litegraph/src/types/widgets'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { type CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { api } from '@/scripts/api'
import { ComfyApp, app } from '@/scripts/app'
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'

View File

@@ -1,9 +1,9 @@
import * as THREE from 'three'
import {
AnimationItem,
AnimationManagerInterface,
EventManagerInterface
type AnimationItem,
type AnimationManagerInterface,
type EventManagerInterface
} from '@/extensions/core/load3d/interfaces'
export class AnimationManager implements AnimationManagerInterface {

View File

@@ -2,11 +2,11 @@ import * as THREE from 'three'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
import {
CameraManagerInterface,
CameraState,
CameraType,
EventManagerInterface,
NodeStorageInterface
type CameraManagerInterface,
type CameraState,
type CameraType,
type EventManagerInterface,
type NodeStorageInterface
} from './interfaces'
export class CameraManager implements CameraManagerInterface {

View File

@@ -2,9 +2,9 @@ import * as THREE from 'three'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
import {
ControlsManagerInterface,
EventManagerInterface,
NodeStorageInterface
type ControlsManagerInterface,
type EventManagerInterface,
type NodeStorageInterface
} from './interfaces'
export class ControlsManager implements ControlsManagerInterface {

View File

@@ -1,4 +1,4 @@
import { EventCallback, EventManagerInterface } from './interfaces'
import { type EventCallback, type EventManagerInterface } from './interfaces'
export class EventManager implements EventManagerInterface {
private listeners: { [key: string]: EventCallback[] } = {}

View File

@@ -1,6 +1,9 @@
import * as THREE from 'three'
import { EventManagerInterface, LightingManagerInterface } from './interfaces'
import {
type EventManagerInterface,
type LightingManagerInterface
} from './interfaces'
export class LightingManager implements LightingManagerInterface {
lights: THREE.Light[] = []

View File

@@ -1,7 +1,7 @@
import * as THREE from 'three'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { type CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { CameraManager } from './CameraManager'
import { ControlsManager } from './ControlsManager'
@@ -16,11 +16,11 @@ import { SceneManager } from './SceneManager'
import { SceneModelManager } from './SceneModelManager'
import { ViewHelperManager } from './ViewHelperManager'
import {
CameraState,
CaptureResult,
Load3DOptions,
MaterialMode,
UpDirection
type CameraState,
type CaptureResult,
type Load3DOptions,
type MaterialMode,
type UpDirection
} from './interfaces'
class Load3d {

View File

@@ -4,7 +4,7 @@ import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { AnimationManager } from './AnimationManager'
import Load3d from './Load3d'
import { Load3DOptions } from './interfaces'
import { type Load3DOptions } from './interfaces'
class Load3dAnimation extends Load3d {
private animationManager: AnimationManager

View File

@@ -9,9 +9,9 @@ import { t } from '@/i18n'
import { useToastStore } from '@/platform/updates/common/toastStore'
import {
EventManagerInterface,
LoaderManagerInterface,
ModelManagerInterface
type EventManagerInterface,
type LoaderManagerInterface,
type ModelManagerInterface
} from './interfaces'
export class LoaderManager implements LoaderManagerInterface {

View File

@@ -1,6 +1,6 @@
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { NodeStorageInterface } from './interfaces'
import { type NodeStorageInterface } from './interfaces'
export class NodeStorage implements NodeStorageInterface {
private node: LGraphNode

View File

@@ -1,7 +1,10 @@
import * as THREE from 'three'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
import { EventManagerInterface, PreviewManagerInterface } from './interfaces'
import {
type EventManagerInterface,
type PreviewManagerInterface
} from './interfaces'
export class PreviewManager implements PreviewManagerInterface {
previewCamera: THREE.Camera

View File

@@ -1,6 +1,6 @@
import * as THREE from 'three'
import { EventManagerInterface } from './interfaces'
import { type EventManagerInterface } from './interfaces'
export class RecordingManager {
private mediaRecorder: MediaRecorder | null = null

View File

@@ -2,7 +2,10 @@ import * as THREE from 'three'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
import Load3dUtils from './Load3dUtils'
import { EventManagerInterface, SceneManagerInterface } from './interfaces'
import {
type EventManagerInterface,
type SceneManagerInterface
} from './interfaces'
export class SceneManager implements SceneManagerInterface {
scene: THREE.Scene

View File

@@ -2,7 +2,7 @@ import * as THREE from 'three'
import { LineMaterial } from 'three/examples/jsm/lines/LineMaterial'
import { LineSegments2 } from 'three/examples/jsm/lines/LineSegments2'
import { LineSegmentsGeometry } from 'three/examples/jsm/lines/LineSegmentsGeometry'
import { GLTF } from 'three/examples/jsm/loaders/GLTFLoader'
import { type GLTF } from 'three/examples/jsm/loaders/GLTFLoader'
import { mergeVertices } from 'three/examples/jsm/utils/BufferGeometryUtils'
import { ColoredShadowMaterial } from './conditional-lines/ColoredShadowMaterial'
@@ -11,11 +11,11 @@ import { ConditionalEdgesShader } from './conditional-lines/ConditionalEdgesShad
import { ConditionalLineMaterial } from './conditional-lines/Lines2/ConditionalLineMaterial'
import { ConditionalLineSegmentsGeometry } from './conditional-lines/Lines2/ConditionalLineSegmentsGeometry'
import {
EventManagerInterface,
Load3DOptions,
MaterialMode,
ModelManagerInterface,
UpDirection
type EventManagerInterface,
type Load3DOptions,
type MaterialMode,
type ModelManagerInterface,
type UpDirection
} from './interfaces'
export class SceneModelManager implements ModelManagerInterface {

View File

@@ -2,7 +2,10 @@ import * as THREE from 'three'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
import { ViewHelper } from 'three/examples/jsm/helpers/ViewHelper'
import { NodeStorageInterface, ViewHelperManagerInterface } from './interfaces'
import {
type NodeStorageInterface,
type ViewHelperManagerInterface
} from './interfaces'
export class ViewHelperManager implements ViewHelperManagerInterface {
viewHelper: ViewHelper = {} as ViewHelper

View File

@@ -2,13 +2,13 @@ import * as THREE from 'three'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
import { ViewHelper } from 'three/examples/jsm/helpers/ViewHelper'
import { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader'
import { GLTF, GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader'
import { type GLTF, GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader'
import { MTLLoader } from 'three/examples/jsm/loaders/MTLLoader'
import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader'
import { STLLoader } from 'three/examples/jsm/loaders/STLLoader'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { type CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
export type Load3DNodeType = 'Load3D' | 'Preview3D'

View File

@@ -4,7 +4,7 @@ https://github.com/rgthree/rgthree-comfy/blob/main/py/display_any.py
upstream requested in https://github.com/Kosinkadink/rfcs/blob/main/rfcs/0000-corenodes.md#preview-nodes
*/
import { app } from '@/scripts/app'
import { DOMWidget } from '@/scripts/domWidget'
import { type DOMWidget } from '@/scripts/domWidget'
import { ComfyWidgets } from '@/scripts/widgets'
import { useExtensionService } from '@/services/extensionService'

View File

@@ -2,7 +2,7 @@ import { nextTick } from 'vue'
import Load3D from '@/components/load3d/Load3D.vue'
import Load3DConfiguration from '@/extensions/core/load3d/Load3DConfiguration'
import { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { type CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
import { useExtensionService } from '@/services/extensionService'
import { useLoad3dService } from '@/services/load3dService'

View File

@@ -15,7 +15,7 @@ import type { ResultItemType } from '@/schemas/apiSchema'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import type { DOMWidget } from '@/scripts/domWidget'
import { useAudioService } from '@/services/audioService'
import { NodeLocatorId } from '@/types'
import { type NodeLocatorId } from '@/types'
import { getNodeByLocatorId } from '@/utils/graphTraversalUtil'
import { api } from '../../scripts/api'

View File

@@ -1,6 +1,6 @@
import {
ComfyNodeDef,
InputSpec,
type ComfyNodeDef,
type InputSpec,
isComboInputSpecV1
} from '@/schemas/nodeDefSchema'

View File

@@ -28,6 +28,10 @@ import ruCommands from './locales/ru/commands.json' with { type: 'json' }
import ru from './locales/ru/main.json' with { type: 'json' }
import ruNodes from './locales/ru/nodeDefs.json' with { type: 'json' }
import ruSettings from './locales/ru/settings.json' with { type: 'json' }
import trCommands from './locales/tr/commands.json' with { type: 'json' }
import tr from './locales/tr/main.json' with { type: 'json' }
import trNodes from './locales/tr/nodeDefs.json' with { type: 'json' }
import trSettings from './locales/tr/settings.json' with { type: 'json' }
import zhTWCommands from './locales/zh-TW/commands.json' with { type: 'json' }
import zhTW from './locales/zh-TW/main.json' with { type: 'json' }
import zhTWNodes from './locales/zh-TW/nodeDefs.json' with { type: 'json' }
@@ -55,7 +59,8 @@ const messages = {
ko: buildLocale(ko, koNodes, koCommands, koSettings),
fr: buildLocale(fr, frNodes, frCommands, frSettings),
es: buildLocale(es, esNodes, esCommands, esSettings),
ar: buildLocale(ar, arNodes, arCommands, arSettings)
ar: buildLocale(ar, arNodes, arCommands, arSettings),
tr: buildLocale(tr, trNodes, trCommands, trSettings)
}
export const i18n = createI18n({

View File

@@ -4,7 +4,10 @@ import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
import { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { LLink, type ResolvedConnection } from '@/lib/litegraph/src/LLink'
import { RecursionError } from '@/lib/litegraph/src/infrastructure/RecursionError'
import type { ISubgraphInput } from '@/lib/litegraph/src/interfaces'
import type {
ISubgraphInput,
IWidgetLocator
} from '@/lib/litegraph/src/interfaces'
import type {
INodeInputSlot,
ISlotType,
@@ -78,9 +81,10 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
const existingInput = this.inputs.find((i) => i.name == name)
if (existingInput) {
const linkId = subgraphInput.linkIds[0]
const { inputNode } = subgraph.links[linkId].resolve(subgraph)
const { inputNode, input } = subgraph.links[linkId].resolve(subgraph)
const widget = inputNode?.widgets?.find?.((w) => w.name == name)
if (widget) this.#setWidget(subgraphInput, existingInput, widget)
if (widget)
this.#setWidget(subgraphInput, existingInput, widget, input?.widget)
return
}
const input = this.addInput(name, type)
@@ -185,13 +189,14 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
subgraphInput.events.addEventListener(
'input-connected',
() => {
(e) => {
if (input._widget) return
const widget = subgraphInput._widget
if (!widget) return
this.#setWidget(subgraphInput, input, widget)
const widgetLocator = e.detail.input.widget
this.#setWidget(subgraphInput, input, widget, widgetLocator)
},
{ signal }
)
@@ -301,7 +306,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
const widget = resolved.inputNode.getWidgetFromSlot(resolved.input)
if (!widget) continue
this.#setWidget(subgraphInput, input, widget)
this.#setWidget(subgraphInput, input, widget, resolved.input.widget)
break
}
}
@@ -310,11 +315,13 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
#setWidget(
subgraphInput: Readonly<SubgraphInput>,
input: INodeInputSlot,
widget: Readonly<IBaseWidget>
widget: Readonly<IBaseWidget>,
inputWidget: IWidgetLocator | undefined
) {
// Use the first matching widget
const targetWidget = toConcreteWidget(widget, this)
const promotedWidget = targetWidget.createCopyForNode(this)
const promotedWidget = toConcreteWidget(widget, this).createCopyForNode(
this
)
Object.assign(promotedWidget, {
get name() {
@@ -372,11 +379,9 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
// NOTE: This code creates linked chains of prototypes for passing across
// multiple levels of subgraphs. As part of this, it intentionally avoids
// creating new objects. Have care when making changes.
const backingInput =
targetWidget.node.findInputSlot(widget.name, true)?.widget ?? {}
input.widget ??= { name: subgraphInput.name }
input.widget.name = subgraphInput.name
Object.setPrototypeOf(input.widget, backingInput)
if (inputWidget) Object.setPrototypeOf(input.widget, inputWidget)
input._widget = promotedWidget
}

View File

@@ -24,7 +24,7 @@ Add your language code to the `outputLocales` array:
```javascript
module.exports = defineConfig({
// ... existing config
outputLocales: ['zh', 'zh-TW', 'ru', 'ja', 'ko', 'fr', 'es'], // Add your language here
outputLocales: ['zh', 'zh-TW', 'ru', 'ja', 'ko', 'fr', 'es', 'tr'], // Add your language here
reference: `Special names to keep untranslated: flux, photomaker, clip, vae, cfg, stable audio, stable cascade, stable zero, controlnet, lora, HiDream.
'latent' is the short form of 'latent space'.
'mask' is in the context of image processing.

View File

@@ -11,6 +11,7 @@ Our project supports multiple languages using `vue-i18n`. This allows users arou
- ko (한국어)
- fr (Français)
- es (Español)
- tr (Türkçe)
## How to Add a New Language

View File

@@ -29,6 +29,8 @@
"insert": "Insert",
"systemInfo": "System Info",
"devices": "Devices",
"deviceNotAvailable": "Device information is not available.",
"noDevicesDetected": "No devices detected. This may occur if no GPU devices are available or device enumeration failed.",
"about": "About",
"add": "Add",
"confirm": "Confirm",
@@ -1862,5 +1864,17 @@
"showGroups": "Show Frames/Groups",
"renderBypassState": "Render Bypass State",
"renderErrorState": "Render Error State"
},
"assetBrowser": {
"assets": "Assets",
"browseAssets": "Browse Assets",
"noAssetsFound": "No assets found",
"tryAdjustingFilters": "Try adjusting your search or filters",
"loadingModels": "Loading {type}...",
"connectionError": "Please check your connection and try again",
"noModelsInFolder": "No {type} available in this folder",
"searchAssetsPlaceholder": "Search assets...",
"allModels": "All Models",
"unknown": "Unknown"
}
}

View File

@@ -0,0 +1,312 @@
{
"Comfy-Desktop_CheckForUpdates": {
"label": "Güncellemeleri Kontrol Et"
},
"Comfy-Desktop_Folders_OpenCustomNodesFolder": {
"label": "Özel Düğümler Klasörünü Aç"
},
"Comfy-Desktop_Folders_OpenInputsFolder": {
"label": "Girişler Klasörünü Aç"
},
"Comfy-Desktop_Folders_OpenLogsFolder": {
"label": "Kayıtlar Klasörünü Aç"
},
"Comfy-Desktop_Folders_OpenModelConfig": {
"label": "extra_model_paths.yaml dosyasını aç"
},
"Comfy-Desktop_Folders_OpenModelsFolder": {
"label": "Modeller Klasörünü Aç"
},
"Comfy-Desktop_Folders_OpenOutputsFolder": {
"label": ıktılar Klasörünü Aç"
},
"Comfy-Desktop_OpenDevTools": {
"label": "Geliştirici Araçlarını Aç"
},
"Comfy-Desktop_OpenUserGuide": {
"label": "Masaüstü Kullanıcı Kılavuzu"
},
"Comfy-Desktop_Quit": {
"label": ık"
},
"Comfy-Desktop_Reinstall": {
"label": "Yeniden Yükle"
},
"Comfy-Desktop_Restart": {
"label": "Yeniden Başlat"
},
"Comfy_3DViewer_Open3DViewer": {
"label": "Seçili Düğüm için 3D Görüntüleyiciyi (Beta) Aç"
},
"Comfy_BrowseTemplates": {
"label": "Şablonlara Gözat"
},
"Comfy_Canvas_DeleteSelectedItems": {
"label": "Seçili Öğeleri Sil"
},
"Comfy_Canvas_FitView": {
"label": "Görünümü seçili düğümlere sığdır"
},
"Comfy_Canvas_Lock": {
"label": "Tuvali Kilitle"
},
"Comfy_Canvas_MoveSelectedNodes_Down": {
"label": "Seçili Düğümleri Aşağı Taşı"
},
"Comfy_Canvas_MoveSelectedNodes_Left": {
"label": "Seçili Düğümleri Sola Taşı"
},
"Comfy_Canvas_MoveSelectedNodes_Right": {
"label": "Seçili Düğümleri Sağa Taşı"
},
"Comfy_Canvas_MoveSelectedNodes_Up": {
"label": "Seçili Düğümleri Yukarı Taşı"
},
"Comfy_Canvas_ResetView": {
"label": "Görünümü Sıfırla"
},
"Comfy_Canvas_Resize": {
"label": "Seçili Düğümleri Yeniden Boyutlandır"
},
"Comfy_Canvas_ToggleLinkVisibility": {
"label": "Tuval Bağlantı Görünürlüğünü Aç/Kapat"
},
"Comfy_Canvas_ToggleLock": {
"label": "Tuval Kilidini Aç/Kapat"
},
"Comfy_Canvas_ToggleMinimap": {
"label": "Mini Haritayı Aç/Kapat"
},
"Comfy_Canvas_ToggleSelected_Pin": {
"label": "Seçili Öğeleri Sabitle/Sabitlemeyi Kaldır"
},
"Comfy_Canvas_ToggleSelectedNodes_Bypass": {
"label": "Seçili Düğümleri Atla/Geri Al"
},
"Comfy_Canvas_ToggleSelectedNodes_Collapse": {
"label": "Seçili Düğümleri Daralt/Genişlet"
},
"Comfy_Canvas_ToggleSelectedNodes_Mute": {
"label": "Seçili Düğümleri Sessize Al/Sesi Aç"
},
"Comfy_Canvas_ToggleSelectedNodes_Pin": {
"label": "Seçili Düğümleri Sabitle/Sabitlemeyi Kaldır"
},
"Comfy_Canvas_Unlock": {
"label": "Tuvalin Kilidini Aç"
},
"Comfy_Canvas_ZoomIn": {
"label": "Yakınlaştır"
},
"Comfy_Canvas_ZoomOut": {
"label": "Uzaklaştır"
},
"Comfy_ClearPendingTasks": {
"label": "Bekleyen Görevleri Temizle"
},
"Comfy_ClearWorkflow": {
"label": "İş Akışını Temizle"
},
"Comfy_ContactSupport": {
"label": "Destekle İletişime Geç"
},
"Comfy_Dev_ShowModelSelector": {
"label": "Model Seçiciyi Göster (Geliştirici)"
},
"Comfy_DuplicateWorkflow": {
"label": "Mevcut İş Akışını Çoğalt"
},
"Comfy_ExportWorkflow": {
"label": "İş Akışını Dışa Aktar"
},
"Comfy_ExportWorkflowAPI": {
"label": "İş Akışını Dışa Aktar (API Formatı)"
},
"Comfy_Feedback": {
"label": "Geri Bildirim Ver"
},
"Comfy_Graph_ConvertToSubgraph": {
"label": "Seçimi Alt Grafiğe Dönüştür"
},
"Comfy_Graph_ExitSubgraph": {
"label": "Alt Grafikten Çık"
},
"Comfy_Graph_FitGroupToContents": {
"label": "Grubu İçeriğe Sığdır"
},
"Comfy_Graph_GroupSelectedNodes": {
"label": "Seçili Düğümleri Gruplandır"
},
"Comfy_Graph_UnpackSubgraph": {
"label": "Seçili Alt Grafiği Aç"
},
"Comfy_GroupNode_ConvertSelectedNodesToGroupNode": {
"label": "Seçili düğümleri grup düğümüne dönüştür"
},
"Comfy_GroupNode_ManageGroupNodes": {
"label": "Grup düğümlerini yönet"
},
"Comfy_GroupNode_UngroupSelectedGroupNodes": {
"label": "Seçili grup düğümlerinin grubunu çöz"
},
"Comfy_Help_AboutComfyUI": {
"label": "ComfyUI Hakkında'yı Aç"
},
"Comfy_Help_OpenComfyOrgDiscord": {
"label": "Comfy-Org Discord'unu Aç"
},
"Comfy_Help_OpenComfyUIDocs": {
"label": "ComfyUI Belgelerini Aç"
},
"Comfy_Help_OpenComfyUIForum": {
"label": "ComfyUI Forumunu Aç"
},
"Comfy_Help_OpenComfyUIIssues": {
"label": "ComfyUI Sorunlarını Aç"
},
"Comfy_Interrupt": {
"label": "Kes"
},
"Comfy_LoadDefaultWorkflow": {
"label": "Varsayılan İş Akışını Yükle"
},
"Comfy_Manager_CustomNodesManager_ShowCustomNodesMenu": {
"label": "Özel Düğüm Yöneticisi"
},
"Comfy_Manager_CustomNodesManager_ShowLegacyCustomNodesMenu": {
"label": "Özel Düğümler (Eski)"
},
"Comfy_Manager_ShowLegacyManagerMenu": {
"label": "Yönetici Menüsü (Eski)"
},
"Comfy_Manager_ShowMissingPacks": {
"label": "Eksik Özel Düğümleri Yükle"
},
"Comfy_Manager_ShowUpdateAvailablePacks": {
"label": "Özel Düğüm Güncellemelerini Kontrol Et"
},
"Comfy_Manager_ToggleManagerProgressDialog": {
"label": "Özel Düğüm Yöneticisi İlerleme Çubuğunu Aç/Kapat"
},
"Comfy_MaskEditor_BrushSize_Decrease": {
"label": "Maske Düzenleyicide Fırça Boyutunu Azalt"
},
"Comfy_MaskEditor_BrushSize_Increase": {
"label": "Maske Düzenleyicide Fırça Boyutunu Artır"
},
"Comfy_MaskEditor_OpenMaskEditor": {
"label": "Seçili Düğüm için Maske Düzenleyiciyi Aç"
},
"Comfy_Memory_UnloadModels": {
"label": "Modelleri Boşalt"
},
"Comfy_Memory_UnloadModelsAndExecutionCache": {
"label": "Modelleri ve Yürütme Önbelleğini Boşalt"
},
"Comfy_NewBlankWorkflow": {
"label": "Yeni Boş İş Akışı"
},
"Comfy_OpenClipspace": {
"label": "Clipspace"
},
"Comfy_OpenManagerDialog": {
"label": "Yönetici"
},
"Comfy_OpenWorkflow": {
"label": "İş Akışını Aç"
},
"Comfy_PublishSubgraph": {
"label": "Alt Grafiği Yayınla"
},
"Comfy_QueuePrompt": {
"label": "İstemi Kuyruğa Al"
},
"Comfy_QueuePromptFront": {
"label": "İstemi Kuyruğa Al (Ön)"
},
"Comfy_QueueSelectedOutputNodes": {
"label": "Seçili Çıktı Düğümlerini Kuyruğa Al"
},
"Comfy_Redo": {
"label": "Yinele"
},
"Comfy_RefreshNodeDefinitions": {
"label": "Düğüm Tanımlarını Yenile"
},
"Comfy_SaveWorkflow": {
"label": "İş Akışını Kaydet"
},
"Comfy_SaveWorkflowAs": {
"label": "İş Akışını Farklı Kaydet"
},
"Comfy_ShowSettingsDialog": {
"label": "Ayarlar İletişim Kutusunu Göster"
},
"Comfy_ToggleCanvasInfo": {
"label": "Tuval Performansı"
},
"Comfy_ToggleHelpCenter": {
"label": "Yardım Merkezi"
},
"Comfy_ToggleTheme": {
"label": "Temayı Değiştir (Karanlık/Açık)"
},
"Comfy_Undo": {
"label": "Geri Al"
},
"Comfy_User_OpenSignInDialog": {
"label": "Giriş Yapma İletişim Kutusunu Aç"
},
"Comfy_User_SignOut": {
"label": ıkış Yap"
},
"Workspace_CloseWorkflow": {
"label": "Mevcut İş Akışını Kapat"
},
"Workspace_NextOpenedWorkflow": {
"label": "Sonraki Açılan İş Akışı"
},
"Workspace_PreviousOpenedWorkflow": {
"label": "Önceki Açılan İş Akışı"
},
"Workspace_SearchBox_Toggle": {
"label": "Arama Kutusunu Aç/Kapat"
},
"Workspace_ToggleBottomPanel": {
"label": "Alt Paneli Aç/Kapat"
},
"Workspace_ToggleBottomPanel_Shortcuts": {
"label": "Tuş Atamaları İletişim Kutusunu Göster"
},
"Workspace_ToggleBottomPanelTab_command-terminal": {
"label": "Terminal Alt Panelini Aç/Kapat"
},
"Workspace_ToggleBottomPanelTab_logs-terminal": {
"label": "Kayıtlar Alt Panelini Aç/Kapat"
},
"Workspace_ToggleBottomPanelTab_shortcuts-essentials": {
"label": "Temel Alt Paneli Aç/Kapat"
},
"Workspace_ToggleBottomPanelTab_shortcuts-view-controls": {
"label": "Görünüm Kontrolleri Alt Panelini Aç/Kapat"
},
"Workspace_ToggleFocusMode": {
"label": "Odak Modunu Aç/Kapat"
},
"Workspace_ToggleSidebarTab_model-library": {
"label": "Model Kütüphanesi Kenar Çubuğunu Aç/Kapat",
"tooltip": "Model Kütüphanesi"
},
"Workspace_ToggleSidebarTab_node-library": {
"label": "Düğüm Kütüphanesi Kenar Çubuğunu Aç/Kapat",
"tooltip": "Düğüm Kütüphanesi"
},
"Workspace_ToggleSidebarTab_queue": {
"label": "Kuyruk Kenar Çubuğunu Aç/Kapat",
"tooltip": "Kuyruk"
},
"Workspace_ToggleSidebarTab_workflows": {
"label": "İş Akışları Kenar Çubuğunu Aç/Kapat",
"tooltip": "İş Akışları"
}
}

1800
src/locales/tr/main.json Normal file

File diff suppressed because it is too large Load Diff

8653
src/locales/tr/nodeDefs.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,424 @@
{
"Comfy-Desktop_AutoUpdate": {
"name": "Güncellemeleri otomatik olarak kontrol et"
},
"Comfy-Desktop_SendStatistics": {
"name": "Anonim kullanım metrikleri gönder"
},
"Comfy-Desktop_UV_PypiInstallMirror": {
"name": "Pypi Yükleme Yansısı",
"tooltip": "Varsayılan pip yükleme yansısı"
},
"Comfy-Desktop_UV_PythonInstallMirror": {
"name": "Python Yükleme Yansısı",
"tooltip": "Yönetilen Python kurulumları Astral python-build-standalone projesinden indirilir. Bu değişken, Python kurulumları için farklı bir kaynak kullanmak üzere bir yansıma URL'sine ayarlanabilir. Sağlanan URL, örneğin https://github.com/astral-sh/python-build-standalone/releases/download/20240713/cpython-3.12.4%2B20240713-aarch64-apple-darwin-install_only.tar.gz'deki https://github.com/astral-sh/python-build-standalone/releases/download'ın yerini alacaktır. Dağıtımlar, file:// URL şeması kullanılarak yerel bir dizinden okunabilir."
},
"Comfy-Desktop_UV_TorchInstallMirror": {
"name": "Torch Yükleme Yansısı",
"tooltip": "Pytorch için Pip yükleme yansısı"
},
"Comfy-Desktop_WindowStyle": {
"name": "Pencere Stili",
"tooltip": "Özel: Sistem başlık çubuğunu ComfyUI'nin Üst menüsüyle değiştirin",
"options": {
"default": "varsayılan",
"custom": "özel"
}
},
"Comfy_Canvas_BackgroundImage": {
"name": "Tuval arka plan resmi",
"tooltip": "Tuval arka planı için resim URL'si. Çıktılar panelindeki bir resme sağ tıklayıp \"Arka Plan Olarak Ayarla\"yı seçerek kullanabilir veya yükleme düğmesini kullanarak kendi resminizi yükleyebilirsiniz."
},
"Comfy_Canvas_NavigationMode": {
"name": "Tuval Gezinme Modu",
"options": {
"Standard (New)": "Standart (Yeni)",
"Drag Navigation": "Sürükleyerek Gezinme"
}
},
"Comfy_Canvas_SelectionToolbox": {
"name": "Seçim araç kutusunu göster"
},
"Comfy_ConfirmClear": {
"name": "İş akışını temizlerken onay iste"
},
"Comfy_DevMode": {
"name": "Geliştirici modu seçeneklerini etkinleştir (API kaydetme, vb.)"
},
"Comfy_DisableFloatRounding": {
"name": "Varsayılan ondalık sayı widget yuvarlamasını devre dışı bırak.",
"tooltip": "(sayfanın yeniden yüklenmesini gerektirir) Arka uçtaki düğüm tarafından yuvarlama ayarlandığında yuvarlama devre dışı bırakılamaz."
},
"Comfy_DisableSliders": {
"name": "Düğüm widget kaydırıcılarını devre dışı bırak"
},
"Comfy_DOMClippingEnabled": {
"name": "DOM öğesi kırpmayı etkinleştir (etkinleştirmek performansı düşürebilir)"
},
"Comfy_EditAttention_Delta": {
"name": "Ctrl+yukarı/aşağı hassasiyeti"
},
"Comfy_EnableTooltips": {
"name": "Araç İpuçlarını Etkinleştir"
},
"Comfy_EnableWorkflowViewRestore": {
"name": "İş akışlarında tuval konumunu ve yakınlaştırma seviyesini kaydet ve geri yükle"
},
"Comfy_FloatRoundingPrecision": {
"name": "Ondalık sayı widget yuvarlama ondalık basamakları [0 = otomatik].",
"tooltip": "(sayfanın yeniden yüklenmesini gerektirir)"
},
"Comfy_Graph_CanvasInfo": {
"name": "Sol alt köşede tuval bilgilerini göster (fps, vb.)"
},
"Comfy_Graph_CanvasMenu": {
"name": "Grafik tuval menüsünü göster"
},
"Comfy_Graph_CtrlShiftZoom": {
"name": "Hızlı yakınlaştırma kısayolunu etkinleştir (Ctrl + Shift + Sürükle)"
},
"Comfy_Graph_LinkMarkers": {
"name": "Bağlantı orta nokta işaretçileri",
"options": {
"None": "Yok",
"Circle": "Daire",
"Arrow": "Ok"
}
},
"Comfy_Graph_ZoomSpeed": {
"name": "Tuval yakınlaştırma hızı"
},
"Comfy_Group_DoubleClickTitleToEdit": {
"name": "Düzenlemek için grup başlığına çift tıkla"
},
"Comfy_GroupSelectedNodes_Padding": {
"name": "Seçili düğümleri gruplandırma dolgusu"
},
"Comfy_LinkRelease_Action": {
"name": "Bağlantı bırakıldığında eylem (Değiştirici yok)",
"options": {
"context menu": "bağlam menüsü",
"search box": "arama kutusu",
"no action": "eylem yok"
}
},
"Comfy_LinkRelease_ActionShift": {
"name": "Bağlantı bırakıldığında eylem (Shift)",
"options": {
"context menu": "bağlam menüsü",
"search box": "arama kutusu",
"no action": "eylem yok"
}
},
"Comfy_LinkRenderMode": {
"name": "Bağlantı Oluşturma Modu",
"options": {
"Straight": "Düz",
"Linear": "Doğrusal",
"Spline": "Eğri",
"Hidden": "Gizli"
}
},
"Comfy_Load3D_3DViewerEnable": {
"name": "3D Görüntüleyiciyi Etkinleştir (Beta)",
"tooltip": "Seçili düğümler için 3D Görüntüleyiciyi (Beta) etkinleştirir. Bu özellik, 3D modelleri doğrudan tam boyutlu 3D görüntüleyici içinde görselleştirmenize ve etkileşimde bulunmanıza olanak tanır."
},
"Comfy_Load3D_BackgroundColor": {
"name": "Başlangıç Arka Plan Rengi",
"tooltip": "3D sahnenin varsayılan arka plan rengini kontrol eder. Bu ayar, yeni bir 3D widget oluşturulduğunda arka plan görünümünü belirler, ancak oluşturulduktan sonra her widget için ayrı ayrı ayarlanabilir."
},
"Comfy_Load3D_CameraType": {
"name": "Başlangıç Kamera Tipi",
"tooltip": "Yeni bir 3D widget oluşturulduğunda kameranın varsayılan olarak perspektif mi yoksa ortografik mi olacağını kontrol eder. Bu varsayılan, oluşturulduktan sonra her widget için ayrı ayrı değiştirilebilir.",
"options": {
"perspective": "perspektif",
"orthographic": "ortografik"
}
},
"Comfy_Load3D_LightAdjustmentIncrement": {
"name": "Işık Ayarlama Artışı",
"tooltip": "3D sahnelerde ışık yoğunluğunu ayarlarken artış boyutunu kontrol eder. Daha küçük bir adım değeri, aydınlatma ayarlamaları üzerinde daha ince kontrol sağlarken, daha büyük bir değer ayarlama başına daha belirgin değişikliklere neden olur."
},
"Comfy_Load3D_LightIntensity": {
"name": "Başlangıç Işık Yoğunluğu",
"tooltip": "3D sahnedeki aydınlatmanın varsayılan parlaklık seviyesini ayarlar. Bu değer, yeni bir 3D widget oluşturulduğunda ışıkların nesneleri ne kadar yoğun aydınlatacağını belirler, ancak oluşturulduktan sonra her widget için ayrı ayrı ayarlanabilir."
},
"Comfy_Load3D_LightIntensityMaximum": {
"name": "Maksimum Işık Yoğunluğu",
"tooltip": "3D sahneler için izin verilen maksimum ışık yoğunluğu değerini ayarlar. Bu, herhangi bir 3D widget'ta aydınlatma ayarlanırken ayarlanabilecek üst parlaklık sınırını tanımlar."
},
"Comfy_Load3D_LightIntensityMinimum": {
"name": "Minimum Işık Yoğunluğu",
"tooltip": "3D sahneler için izin verilen minimum ışık yoğunluğu değerini ayarlar. Bu, herhangi bir 3D widget'ta aydınlatma ayarlanırken ayarlanabilecek alt parlaklık sınırını tanımlar."
},
"Comfy_Load3D_ShowGrid": {
"name": "Başlangıç Izgara Görünürlüğü",
"tooltip": "Yeni bir 3D widget oluşturulduğunda ızgaranın varsayılan olarak görünür olup olmadığını kontrol eder. Bu varsayılan, oluşturulduktan sonra her widget için ayrı ayrı değiştirilebilir."
},
"Comfy_Load3D_ShowPreview": {
"name": "Başlangıç Önizleme Görünürlüğü",
"tooltip": "Yeni bir 3D widget oluşturulduğunda önizleme ekranının varsayılan olarak görünür olup olmadığını kontrol eder. Bu varsayılan, oluşturulduktan sonra her widget için ayrı ayrı değiştirilebilir."
},
"Comfy_Locale": {
"name": "Dil"
},
"Comfy_MaskEditor_BrushAdjustmentSpeed": {
"name": "Fırça ayar hızı çarpanı",
"tooltip": "Ayarlama sırasında fırça boyutunun ve sertliğinin ne kadar hızlı değiştiğini kontrol eder. Daha yüksek değerler daha hızlı değişiklikler anlamına gelir."
},
"Comfy_MaskEditor_UseDominantAxis": {
"name": "Fırça ayarını baskın eksene kilitle",
"tooltip": "Etkinleştirildiğinde, fırça ayarları yalnızca daha fazla hareket ettiğiniz yöne bağlı olarak boyutu VEYA sertliği etkileyecektir"
},
"Comfy_MaskEditor_UseNewEditor": {
"name": "Yeni maske düzenleyiciyi kullan",
"tooltip": "Yeni maske düzenleyici arayüzüne geç"
},
"Comfy_ModelLibrary_AutoLoadAll": {
"name": "Tüm model klasörlerini otomatik olarak yükle",
"tooltip": "Doğruysa, model kütüphanesini açar açmaz tüm klasörler yüklenecektir (bu, yüklenirken gecikmelere neden olabilir). Yanlışsa, kök düzeyindeki model klasörleri yalnızca üzerlerine tıkladığınızda yüklenecektir."
},
"Comfy_ModelLibrary_NameFormat": {
"name": "Model kütüphanesi ağaç görünümünde hangi adın görüntüleneceği",
"tooltip": "Model listesinde ham dosya adının (dizin veya \".safetensors\" uzantısı olmadan) basitleştirilmiş bir görünümünü oluşturmak için \"dosyaadı\"nı seçin. Yapılandırılabilir model meta veri başlığını görüntülemek için \"başlık\"ı seçin.",
"options": {
"filename": "dosyaadı",
"title": "başlık"
}
},
"Comfy_Node_AllowImageSizeDraw": {
"name": "Görüntü önizlemesinin altında genişlik × yüksekliği göster"
},
"Comfy_Node_AutoSnapLinkToSlot": {
"name": "Bağlantıyı otomatik olarak düğüm yuvasına yapıştır",
"tooltip": "Bir bağlantıyı bir düğümün üzerine sürüklerken, bağlantı otomatik olarak düğüm üzerindeki uygun bir giriş yuvasına yapışır"
},
"Comfy_Node_BypassAllLinksOnDelete": {
"name": "Düğümleri silerken tüm bağlantıları koru",
"tooltip": "Bir düğümü silerken, tüm giriş ve çıkış bağlantılarını yeniden bağlamaya çalışın (silinen düğümü atlayarak)"
},
"Comfy_Node_DoubleClickTitleToEdit": {
"name": "Düzenlemek için düğüm başlığına çift tıkla"
},
"Comfy_Node_MiddleClickRerouteNode": {
"name": "Orta tıklama yeni bir Yeniden Yönlendirme düğümü oluşturur"
},
"Comfy_Node_Opacity": {
"name": "Düğüm opaklığı"
},
"Comfy_Node_ShowDeprecated": {
"name": "Aramada kullanımdan kaldırılmış düğümleri göster",
"tooltip": "Kullanımdan kaldırılmış düğümler arayüzde varsayılan olarak gizlidir, ancak bunları kullanan mevcut iş akışlarında işlevsel kalır."
},
"Comfy_Node_ShowExperimental": {
"name": "Aramada deneysel düğümleri göster",
"tooltip": "Deneysel düğümler arayüzde bu şekilde işaretlenmiştir ve gelecekteki sürümlerde önemli değişikliklere veya kaldırılmaya tabi olabilir. Üretim iş akışlarında dikkatli kullanın"
},
"Comfy_Node_SnapHighlightsNode": {
"name": "Yapıştırma düğümü vurgular",
"tooltip": "Uygun giriş yuvasına sahip bir düğümün üzerine bir bağlantı sürüklerken, düğümü vurgulayın"
},
"Comfy_NodeBadge_NodeIdBadgeMode": {
"name": "Düğüm ID rozeti modu",
"options": {
"None": "Yok",
"Show all": "Tümünü göster"
}
},
"Comfy_NodeBadge_NodeLifeCycleBadgeMode": {
"name": "Düğüm yaşam döngüsü rozeti modu",
"options": {
"None": "Yok",
"Show all": "Tümünü göster"
}
},
"Comfy_NodeBadge_NodeSourceBadgeMode": {
"name": "Düğüm kaynak rozeti modu",
"options": {
"None": "Yok",
"Show all": "Tümünü göster",
"Hide built-in": "Yerleşik olanı gizle"
}
},
"Comfy_NodeBadge_ShowApiPricing": {
"name": "API düğüm fiyatlandırma rozetini göster"
},
"Comfy_NodeSearchBoxImpl": {
"name": "Düğüm arama kutusu uygulaması",
"options": {
"default": "varsayılan",
"litegraph (legacy)": "litegraph (eski)"
}
},
"Comfy_NodeSearchBoxImpl_NodePreview": {
"name": "Düğüm önizlemesi",
"tooltip": "Yalnızca varsayılan uygulama için geçerlidir"
},
"Comfy_NodeSearchBoxImpl_ShowCategory": {
"name": "Arama sonuçlarında düğüm kategorisini göster",
"tooltip": "Yalnızca varsayılan uygulama için geçerlidir"
},
"Comfy_NodeSearchBoxImpl_ShowIdName": {
"name": "Arama sonuçlarında düğüm kimliği adını göster",
"tooltip": "Yalnızca varsayılan uygulama için geçerlidir"
},
"Comfy_NodeSearchBoxImpl_ShowNodeFrequency": {
"name": "Arama sonuçlarında düğüm sıklığını göster",
"tooltip": "Yalnızca varsayılan uygulama için geçerlidir"
},
"Comfy_NodeSuggestions_number": {
"name": "Düğüm öneri sayısı",
"tooltip": "Yalnızca litegraph arama kutusu/bağlam menüsü için"
},
"Comfy_Notification_ShowVersionUpdates": {
"name": "Sürüm güncellemelerini göster",
"tooltip": "Yeni modeller ve önemli yeni özellikler için güncellemeleri göster."
},
"Comfy_Pointer_ClickBufferTime": {
"name": "İşaretçi tıklama kayma gecikmesi",
"tooltip": "Bir işaretçi düğmesine bastıktan sonra, bu, işaretçi hareketinin göz ardı edilebileceği maksimum süredir (milisaniye cinsinden).\n\nTıklarken işaretçi hareket ettirilirse nesnelerin istemeden dürtülmesini önlemeye yardımcı olur."
},
"Comfy_Pointer_ClickDrift": {
"name": "İşaretçi tıklama kayması (maksimum mesafe)",
"tooltip": "İşaretçi bir düğmeyi basılı tutarken bu mesafeden daha fazla hareket ederse, bu sürükleme olarak kabul edilir (tıklama yerine).\n\nTıklarken işaretçi hareket ettirilirse nesnelerin istemeden dürtülmesini önlemeye yardımcı olur."
},
"Comfy_Pointer_DoubleClickTime": {
"name": "Çift tıklama aralığı (maksimum)",
"tooltip": "Çift tıklamanın iki tıklaması arasındaki milisaniye cinsinden maksimum süre. Bu değeri artırmak, çift tıklamaların bazen kaydedilmemesi durumunda yardımcı olabilir."
},
"Comfy_PreviewFormat": {
"name": "Önizleme görüntü formatı",
"tooltip": "Görüntü widget'ında bir önizleme görüntülerken, onu hafif bir görüntüye dönüştürün, örn. webp, jpeg, webp;50, vb."
},
"Comfy_PromptFilename": {
"name": "İş akışını kaydederken dosya adı iste"
},
"Comfy_Queue_MaxHistoryItems": {
"name": "Kuyruk geçmişi boyutu",
"tooltip": "Kuyruk geçmişinde gösterilen maksimum görev sayısı."
},
"Comfy_QueueButton_BatchCountLimit": {
"name": "Toplu iş sayısı sınırı",
"tooltip": "Tek bir düğme tıklamasıyla kuyruğa eklenen maksimum görev sayısı"
},
"Comfy_Sidebar_Location": {
"name": "Kenar çubuğu konumu",
"options": {
"left": "sol",
"right": "sağ"
}
},
"Comfy_Sidebar_Size": {
"name": "Kenar çubuğu boyutu",
"options": {
"normal": "normal",
"small": "küçük"
}
},
"Comfy_Sidebar_UnifiedWidth": {
"name": "Birleşik kenar çubuğu genişliği"
},
"Comfy_SnapToGrid_GridSize": {
"name": "Izgaraya yapıştırma boyutu",
"tooltip": "Shift tuşunu basılı tutarken düğümleri sürükleyip yeniden boyutlandırırken ızgaraya hizalanacaklar, bu o ızgaranın boyutunu kontrol eder."
},
"Comfy_TextareaWidget_FontSize": {
"name": "Metin alanı widget yazı tipi boyutu"
},
"Comfy_TextareaWidget_Spellcheck": {
"name": "Metin alanı widget yazım denetimi"
},
"Comfy_TreeExplorer_ItemPadding": {
"name": "Ağaç gezgini öğe dolgusu"
},
"Comfy_UseNewMenu": {
"name": "Yeni menüyü kullan",
"tooltip": "Menü çubuğu konumu. Mobil cihazlarda menü her zaman üstte gösterilir.",
"options": {
"Disabled": "Devre dışı",
"Top": "Üst",
"Bottom": "Alt"
}
},
"Comfy_Validation_Workflows": {
"name": "İş akışlarını doğrula"
},
"Comfy_VueNodes_Enabled": {
"name": "Vue düğüm oluşturmayı etkinleştir",
"tooltip": "Düğümleri tuval öğeleri yerine Vue bileşenleri olarak oluşturun. Deneysel özellik."
},
"Comfy_VueNodes_Widgets": {
"name": "Vue widget'larını etkinleştir",
"tooltip": "Widget'ları Vue düğümleri içinde Vue bileşenleri olarak oluşturun."
},
"Comfy_WidgetControlMode": {
"name": "Widget kontrol modu",
"tooltip": "Widget değerlerinin ne zaman güncelleneceğini (rastgele/artırma/azaltma), istem kuyruğa alınmadan önce veya sonra kontrol eder.",
"options": {
"before": "önce",
"after": "sonra"
}
},
"Comfy_Window_UnloadConfirmation": {
"name": "Pencereyi kapatırken onay göster"
},
"Comfy_Workflow_AutoSave": {
"name": "Otomatik Kaydet",
"options": {
"off": "kapalı",
"after delay": "gecikmeden sonra"
}
},
"Comfy_Workflow_AutoSaveDelay": {
"name": "Otomatik Kaydetme Gecikmesi (ms)",
"tooltip": "Yalnızca Otomatik Kaydetme \"gecikmeden sonra\" olarak ayarlandığında geçerlidir."
},
"Comfy_Workflow_ConfirmDelete": {
"name": "İş akışlarını silerken onay göster"
},
"Comfy_Workflow_Persist": {
"name": "İş akışı durumunu koru ve sayfayı (yeniden) yüklediğinde geri yükle"
},
"Comfy_Workflow_ShowMissingModelsWarning": {
"name": "Eksik model uyarısını göster"
},
"Comfy_Workflow_ShowMissingNodesWarning": {
"name": "Eksik düğüm uyarısını göster"
},
"Comfy_Workflow_SortNodeIdOnSave": {
"name": "İş akışını kaydederken düğüm kimliklerini sırala"
},
"Comfy_Workflow_WorkflowTabsPosition": {
"name": "Açılan iş akışları konumu",
"options": {
"Sidebar": "Kenar Çubuğu",
"Topbar": "Üst Çubuk",
"Topbar (2nd-row)": "Üst Çubuk (2. sıra)"
}
},
"LiteGraph_Canvas_MinFontSizeForLOD": {
"name": "Yakınlaştırma Düğümü Ayrıntı Seviyesi - yazı tipi boyutu eşiği",
"tooltip": "Düğümlerin ne zaman düşük kaliteli LOD oluşturmaya geçeceğini kontrol eder. Ne zaman geçiş yapılacağını belirlemek için piksel cinsinden yazı tipi boyutunu kullanır. Devre dışı bırakmak için 0'a ayarlayın. 1-24 arasındaki değerler LOD için minimum yazı tipi boyutu eşiğini ayarlar - daha yüksek değerler (24 piksel) = uzaklaştırırken düğümleri daha erken basitleştirilmiş oluşturmaya geçirin, daha düşük değerler (1 piksel) = tam düğüm kalitesini daha uzun süre koruyun."
},
"LiteGraph_Canvas_MaximumFps": {
"name": "Maksimum FPS",
"tooltip": "Tuvalin saniyede oluşturmasına izin verilen maksimum kare sayısı. Akıcılık pahasına GPU kullanımını sınırlar. 0 ise, ekran yenileme hızı kullanılır. Varsayılan: 0"
},
"LiteGraph_ContextMenu_Scaling": {
"name": "Yakınlaştırıldığında düğüm birleşik widget menülerini (listeleri) ölçeklendir"
},
"LiteGraph_Node_DefaultPadding": {
"name": "Yeni düğümleri her zaman küçült",
"tooltip": "Oluşturulduğunda düğümleri mümkün olan en küçük boyuta yeniden boyutlandırın. Devre dışı bırakıldığında, yeni eklenen bir düğüm widget değerlerini göstermek için biraz genişletilecektir."
},
"LiteGraph_Node_TooltipDelay": {
"name": "Araç İpucu Gecikmesi"
},
"LiteGraph_Reroute_SplineOffset": {
"name": "Yeniden yönlendirme eğri ofseti",
"tooltip": "Yeniden yönlendirme merkez noktasından bezier kontrol noktası ofseti"
},
"pysssss_SnapToGrid": {
"name": "Her zaman ızgaraya yapıştır"
}
}

View File

@@ -2,6 +2,11 @@ import { definePreset } from '@primevue/themes'
import Aura from '@primevue/themes/aura'
import * as Sentry from '@sentry/vue'
import { initializeApp } from 'firebase/app'
import {
browserLocalPersistence,
browserSessionPersistence,
indexedDBLocalPersistence
} from 'firebase/auth'
import { createPinia } from 'pinia'
import 'primeicons/primeicons.css'
import PrimeVue from 'primevue/config'
@@ -9,7 +14,7 @@ import ConfirmationService from 'primevue/confirmationservice'
import ToastService from 'primevue/toastservice'
import Tooltip from 'primevue/tooltip'
import { createApp } from 'vue'
import { VueFire, VueFireAuth } from 'vuefire'
import { VueFire, VueFireAuthWithDependencies } from 'vuefire'
import { FIREBASE_CONFIG } from '@/config/firebase'
import '@/lib/litegraph/public/css/litegraph.css'
@@ -66,6 +71,18 @@ app
.use(i18n)
.use(VueFire, {
firebaseApp,
modules: [VueFireAuth()]
modules: [
// Configure Firebase Auth persistence: localStorage first, IndexedDB last.
// Localstorage is preferred to IndexedDB for mobile Safari compatibility.
VueFireAuthWithDependencies({
dependencies: {
persistence: [
browserLocalPersistence,
browserSessionPersistence,
indexedDBLocalPersistence
]
}
})
]
})
.mount('#vue-app')

View File

@@ -0,0 +1,42 @@
<template>
<div class="absolute bottom-2 right-2 flex flex-wrap justify-end gap-1">
<span
v-for="badge in badges"
:key="badge.label"
:class="
cn(
'px-2 py-1 rounded text-xs font-medium uppercase tracking-wider text-white',
getBadgeColor(badge.type)
)
"
>
{{ badge.label }}
</span>
</div>
</template>
<script setup lang="ts">
import { cn } from '@/utils/tailwindUtil'
type AssetBadge = {
label: string
type: 'type' | 'base' | 'size'
}
defineProps<{
badges: AssetBadge[]
}>()
function getBadgeColor(type: AssetBadge['type']): string {
switch (type) {
case 'type':
return 'bg-blue-100/90 dark-theme:bg-blue-100/80'
case 'base':
return 'bg-success-100/90 dark-theme:bg-success-100/80'
case 'size':
return 'bg-stone-100/90 dark-theme:bg-charcoal-700/80'
default:
return 'bg-stone-100/90 dark-theme:bg-charcoal-700/80'
}
}
</script>

View File

@@ -0,0 +1,178 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import AssetBrowserModal from '@/platform/assets/components/AssetBrowserModal.vue'
import {
createMockAssets,
mockAssets
} from '@/platform/assets/fixtures/ui-mock-assets'
// Story arguments interface
interface StoryArgs {
nodeType: string
inputName: string
currentValue: string
showLeftPanel?: boolean
}
const meta: Meta<StoryArgs> = {
title: 'Platform/Assets/AssetBrowserModal',
component: AssetBrowserModal,
parameters: {
layout: 'fullscreen'
},
argTypes: {
nodeType: {
control: 'select',
options: ['CheckpointLoaderSimple', 'VAELoader', 'ControlNetLoader'],
description: 'ComfyUI node type for context'
},
inputName: {
control: 'select',
options: ['ckpt_name', 'vae_name', 'control_net_name'],
description: 'Widget input name'
},
currentValue: {
control: 'text',
description: 'Current selected asset value'
},
showLeftPanel: {
control: 'boolean',
description: 'Whether to show the left panel with categories'
}
}
}
export default meta
type Story = StoryObj<typeof meta>
// Modal Layout Stories
export const Default: Story = {
args: {
nodeType: 'CheckpointLoaderSimple',
inputName: 'ckpt_name',
currentValue: '',
showLeftPanel: false
},
render: (args) => ({
components: { AssetBrowserModal },
setup() {
const onAssetSelect = (asset: any) => {
console.log('Selected asset:', asset)
}
const onClose = () => {
console.log('Modal closed')
}
return {
...args,
onAssetSelect,
onClose,
assets: mockAssets
}
},
template: `
<div class="flex items-center justify-center min-h-screen bg-stone-200 dark-theme:bg-stone-200 p-4">
<AssetBrowserModal
:node-type="nodeType"
:input-name="inputName"
:show-left-panel="showLeftPanel"
:assets="assets"
@asset-select="onAssetSelect"
@close="onClose"
/>
</div>
`
})
}
// Story demonstrating single asset type (auto-hides left panel)
export const SingleAssetType: Story = {
args: {
nodeType: 'CheckpointLoaderSimple',
inputName: 'ckpt_name',
currentValue: '',
showLeftPanel: false
},
render: (args) => ({
components: { AssetBrowserModal },
setup() {
const onAssetSelect = (asset: any) => {
console.log('Selected asset:', asset)
}
const onClose = () => {
console.log('Modal closed')
}
// Create assets with only one type (checkpoints)
const singleTypeAssets = createMockAssets(15).map((asset) => ({
...asset,
type: 'checkpoint'
}))
return { ...args, onAssetSelect, onClose, assets: singleTypeAssets }
},
template: `
<div class="flex items-center justify-center min-h-screen bg-stone-200 dark-theme:bg-stone-200 p-4">
<AssetBrowserModal
:node-type="nodeType"
:input-name="inputName"
:show-left-panel="showLeftPanel"
:assets="assets"
@asset-select="onAssetSelect"
@close="onClose"
/>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'Modal with assets of only one type (checkpoint) - left panel auto-hidden.'
}
}
}
}
// Story with left panel explicitly hidden
export const NoLeftPanel: Story = {
args: {
nodeType: 'CheckpointLoaderSimple',
inputName: 'ckpt_name',
currentValue: '',
showLeftPanel: false
},
render: (args) => ({
components: { AssetBrowserModal },
setup() {
const onAssetSelect = (asset: any) => {
console.log('Selected asset:', asset)
}
const onClose = () => {
console.log('Modal closed')
}
return { ...args, onAssetSelect, onClose, assets: mockAssets }
},
template: `
<div class="flex items-center justify-center min-h-screen bg-stone-200 dark-theme:bg-stone-200 p-4">
<AssetBrowserModal
:node-type="nodeType"
:input-name="inputName"
:show-left-panel="showLeftPanel"
:assets="assets"
@asset-select="onAssetSelect"
@close="onClose"
/>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'Modal with left panel explicitly disabled via showLeftPanel=false.'
}
}
}
}

View File

@@ -0,0 +1,95 @@
<template>
<BaseModalLayout
data-component-id="AssetBrowserModal"
class="size-full max-h-full max-w-full min-w-0"
:content-title="contentTitle"
@close="handleClose"
>
<template v-if="shouldShowLeftPanel" #leftPanel>
<LeftSidePanel
v-model="selectedCategory"
data-component-id="AssetBrowserModal-LeftSidePanel"
:nav-items="availableCategories"
>
<template #header-icon>
<i-lucide:folder class="size-4" />
</template>
<template #header-title>{{ $t('assetBrowser.browseAssets') }}</template>
</LeftSidePanel>
</template>
<template #header>
<SearchBox
v-model="searchQuery"
size="lg"
:placeholder="$t('assetBrowser.searchAssetsPlaceholder')"
class="max-w-96"
/>
</template>
<template #content>
<AssetGrid
:assets="filteredAssets"
@asset-select="handleAssetSelectAndEmit"
/>
</template>
</BaseModalLayout>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import SearchBox from '@/components/input/SearchBox.vue'
import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
import LeftSidePanel from '@/components/widget/panel/LeftSidePanel.vue'
import AssetGrid from '@/platform/assets/components/AssetGrid.vue'
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
import { useAssetBrowser } from '@/platform/assets/composables/useAssetBrowser'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
const props = defineProps<{
nodeType?: string
inputName?: string
onSelect?: (assetPath: string) => void
onClose?: () => void
showLeftPanel?: boolean
assets?: AssetItem[]
}>()
const emit = defineEmits<{
'asset-select': [asset: AssetDisplayItem]
close: []
}>()
// Use AssetBrowser composable for all business logic
const {
searchQuery,
selectedCategory,
availableCategories,
contentTitle,
filteredAssets,
selectAsset
} = useAssetBrowser(props.assets)
// Dialog controls panel visibility via prop
const shouldShowLeftPanel = computed(() => {
return props.showLeftPanel ?? true
})
// Handle close button - call both the prop callback and emit the event
const handleClose = () => {
props.onClose?.()
emit('close')
}
// Handle asset selection and emit to parent
const handleAssetSelectAndEmit = (asset: AssetDisplayItem) => {
selectAsset(asset) // This logs the selection for dev mode
emit('asset-select', asset) // Emit the full asset object
// Call prop callback if provided
if (props.onSelect) {
props.onSelect(asset.name) // Use asset name as the asset path
}
}
</script>

View File

@@ -0,0 +1,182 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import AssetCard from '@/platform/assets/components/AssetCard.vue'
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
import { mockAssets } from '@/platform/assets/fixtures/ui-mock-assets'
// Use the first mock asset as base and transform it to display format
const baseAsset = mockAssets[0]
const createAssetData = (
overrides: Partial<AssetDisplayItem> = {}
): AssetDisplayItem => ({
...baseAsset,
description:
'High-quality realistic images with perfect detail and natural lighting effects for professional photography',
formattedSize: '2.1 GB',
badges: [
{ label: 'checkpoints', type: 'type' },
{ label: '2.1 GB', type: 'size' }
],
stats: {
formattedDate: '3/15/25',
downloadCount: '1.8k',
stars: '4.2k'
},
...overrides
})
const meta: Meta<typeof AssetCard> = {
title: 'Platform/Assets/AssetCard',
component: AssetCard,
parameters: {
layout: 'centered'
},
decorators: [
() => ({
template:
'<div class="p-8 bg-gray-50 dark-theme:bg-gray-900"><story /></div>'
})
]
}
export default meta
type Story = StoryObj<typeof meta>
export const Interactive: Story = {
args: {
asset: createAssetData(),
interactive: true
},
decorators: [
() => ({
template:
'<div class="p-8 bg-gray-50 dark-theme:bg-gray-900 max-w-96"><story /></div>'
})
],
parameters: {
docs: {
description: {
story:
'Default AssetCard with complete data including badges and all stats.'
}
}
}
}
export const NonInteractive: Story = {
args: {
asset: createAssetData(),
interactive: false
},
decorators: [
() => ({
template:
'<div class="p-8 bg-gray-50 dark-theme:bg-gray-900 max-w-96"><story /></div>'
})
],
parameters: {
docs: {
description: {
story:
'AssetCard in non-interactive mode - renders as div without button semantics.'
}
}
}
}
export const EdgeCases: Story = {
render: () => ({
components: { AssetCard },
setup() {
const edgeCases = [
// Default case for comparison
createAssetData({
name: 'Complete Data',
description: 'Asset with all data present for comparison'
}),
// No badges
createAssetData({
id: 'no-badges',
name: 'No Badges',
description: 'Testing graceful handling when badges are not provided',
badges: []
}),
// No stars
createAssetData({
id: 'no-stars',
name: 'No Stars',
description: 'Testing missing stars data gracefully',
stats: {
downloadCount: '1.8k',
formattedDate: '3/15/25'
}
}),
// No downloads
createAssetData({
id: 'no-downloads',
name: 'No Downloads',
description: 'Testing missing downloads data gracefully',
stats: {
stars: '4.2k',
formattedDate: '3/15/25'
}
}),
// No date
createAssetData({
id: 'no-date',
name: 'No Date',
description: 'Testing missing date data gracefully',
stats: {
stars: '4.2k',
downloadCount: '1.8k'
}
}),
// No stats at all
createAssetData({
id: 'no-stats',
name: 'No Stats',
description: 'Testing when all stats are missing',
stats: {}
}),
// Long description
createAssetData({
id: 'long-desc',
name: 'Long Description',
description:
'This is a very long description that should demonstrate how the component handles text overflow and truncation with ellipsis. The description continues with even more content to ensure we test the 2-line clamp behavior properly and see how it renders when there is significantly more text than can fit in the allocated space.'
}),
// Minimal data
createAssetData({
id: 'minimal',
name: 'Minimal',
description: 'Basic model',
tags: ['models'],
badges: [],
stats: {}
})
]
return { edgeCases }
},
template: `
<div class="grid grid-cols-4 gap-6 p-8 bg-gray-50 dark-theme:bg-gray-900">
<AssetCard
v-for="asset in edgeCases"
:key="asset.id"
:asset="asset"
:interactive="true"
@select="(asset) => console.log('Selected:', asset)"
/>
</div>
`
}),
parameters: {
layout: 'fullscreen',
docs: {
description: {
story:
'All AssetCard edge cases in a grid layout to test graceful handling of missing data, badges, stats, and long descriptions.'
}
}
}
}

View File

@@ -0,0 +1,111 @@
<template>
<component
:is="interactive ? 'button' : 'div'"
data-component-id="AssetCard"
:data-asset-id="asset.id"
v-bind="elementProps"
:class="
cn(
// Base layout and container styles (always applied)
'rounded-xl overflow-hidden transition-all duration-200',
// Button-specific styles
interactive && [
'appearance-none bg-transparent p-0 m-0 font-inherit text-inherit outline-none cursor-pointer text-left',
'bg-ivory-100 border border-gray-300 dark-theme:bg-charcoal-400 dark-theme:border-charcoal-600',
'hover:transform hover:-translate-y-0.5 hover:shadow-lg hover:shadow-black/10 hover:border-gray-400',
'dark-theme:hover:shadow-lg dark-theme:hover:shadow-black/30 dark-theme:hover:border-charcoal-700',
'focus:outline-none focus:ring-2 focus:ring-blue-500 dark-theme:focus:ring-blue-400'
],
// Div-specific styles
!interactive && [
'bg-ivory-100 border border-gray-300',
'dark-theme:bg-charcoal-400 dark-theme:border-charcoal-600'
]
)
"
@click="interactive && $emit('select', asset)"
@keydown.enter="interactive && $emit('select', asset)"
>
<div class="relative w-full aspect-square overflow-hidden">
<div
class="w-full h-full bg-gradient-to-br from-indigo-500 via-purple-500 to-pink-600 flex items-center justify-center"
></div>
<AssetBadgeGroup :badges="asset.badges" />
</div>
<div class="p-4 h-32 flex flex-col justify-between">
<div>
<h3
:class="
cn(
'mb-2 m-0 text-base font-semibold overflow-hidden text-ellipsis whitespace-nowrap',
'text-slate-800',
'dark-theme:text-white'
)
"
>
{{ asset.name }}
</h3>
<p
:class="
cn(
'm-0 text-sm leading-6 overflow-hidden [-webkit-box-orient:vertical] [-webkit-line-clamp:2] [display:-webkit-box]',
'text-stone-300',
'dark-theme:text-stone-200'
)
"
:title="asset.description"
>
{{ asset.description }}
</p>
</div>
<div
:class="
cn(
'flex gap-4 text-xs',
'text-stone-400',
'dark-theme:text-stone-300'
)
"
>
<span v-if="asset.stats.stars" class="flex items-center gap-1">
<i-lucide:star class="size-3" />
{{ asset.stats.stars }}
</span>
<span v-if="asset.stats.downloadCount" class="flex items-center gap-1">
<i-lucide:download class="size-3" />
{{ asset.stats.downloadCount }}
</span>
<span v-if="asset.stats.formattedDate" class="flex items-center gap-1">
<i-lucide:clock class="size-3" />
{{ asset.stats.formattedDate }}
</span>
</div>
</div>
</component>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import AssetBadgeGroup from '@/platform/assets/components/AssetBadgeGroup.vue'
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
import { cn } from '@/utils/tailwindUtil'
const props = defineProps<{
asset: AssetDisplayItem
interactive?: boolean
}>()
const elementProps = computed(() =>
props.interactive
? {
type: 'button',
'aria-label': `Select asset ${props.asset.name}`
}
: {}
)
defineEmits<{
select: [asset: AssetDisplayItem]
}>()
</script>

View File

@@ -0,0 +1,102 @@
<template>
<div :class="containerClasses" data-component-id="asset-filter-bar">
<div :class="leftSideClasses" data-component-id="asset-filter-bar-left">
<MultiSelect
v-model="fileFormats"
label="File formats"
:options="fileFormatOptions"
:class="selectClasses"
data-component-id="asset-filter-file-formats"
@update:model-value="handleFilterChange"
/>
<MultiSelect
v-model="baseModels"
label="Base models"
:options="baseModelOptions"
:class="selectClasses"
data-component-id="asset-filter-base-models"
@update:model-value="handleFilterChange"
/>
</div>
<div :class="rightSideClasses" data-component-id="asset-filter-bar-right">
<SingleSelect
v-model="sortBy"
label="Sort by"
:options="sortOptions"
:class="selectClasses"
data-component-id="asset-filter-sort"
@update:model-value="handleFilterChange"
>
<template #icon>
<i-lucide:arrow-up-down class="size-3" />
</template>
</SingleSelect>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import MultiSelect from '@/components/input/MultiSelect.vue'
import SingleSelect from '@/components/input/SingleSelect.vue'
import type { SelectOption } from '@/components/input/types'
import { cn } from '@/utils/tailwindUtil'
export interface FilterState {
fileFormats: string[]
baseModels: string[]
sortBy: string
}
const fileFormats = ref<SelectOption[]>([])
const baseModels = ref<SelectOption[]>([])
const sortBy = ref('name-asc')
// TODO: Make fileFormatOptions configurable via props or assetService
// Should support dynamic file formats based on available assets or server capabilities
const fileFormatOptions = [
{ name: '.ckpt', value: 'ckpt' },
{ name: '.safetensors', value: 'safetensors' },
{ name: '.pt', value: 'pt' }
]
// TODO: Make baseModelOptions configurable via props or assetService
// Should support dynamic base models based on available assets or server detection
const baseModelOptions = [
{ name: 'SD 1.5', value: 'sd15' },
{ name: 'SD XL', value: 'sdxl' },
{ name: 'SD 3.5', value: 'sd35' }
]
// TODO: Make sortOptions configurable via props
// Different asset types might need different sorting options
const sortOptions = [
{ name: 'A-Z', value: 'name-asc' },
{ name: 'Z-A', value: 'name-desc' },
{ name: 'Recent', value: 'recent' },
{ name: 'Popular', value: 'popular' }
]
const emit = defineEmits<{
filterChange: [filters: FilterState]
}>()
const containerClasses = cn(
'flex gap-4 items-center justify-between',
'px-6 pt-2 pb-6'
)
const leftSideClasses = cn('flex gap-4 items-center')
const rightSideClasses = cn('flex items-center')
const selectClasses = cn('min-w-32')
function handleFilterChange() {
emit('filterChange', {
fileFormats: fileFormats.value.map((option: SelectOption) => option.value),
baseModels: baseModels.value.map((option: SelectOption) => option.value),
sortBy: sortBy.value
})
}
</script>

View File

@@ -0,0 +1,70 @@
<template>
<div
data-component-id="AssetGrid"
:style="gridStyle"
role="grid"
aria-label="Asset collection"
:aria-rowcount="-1"
:aria-colcount="-1"
:aria-setsize="assets.length"
>
<AssetCard
v-for="asset in assets"
:key="asset.id"
:asset="asset"
:interactive="true"
role="gridcell"
@select="$emit('assetSelect', $event)"
/>
<!-- Empty state -->
<div
v-if="assets.length === 0"
:class="
cn(
'col-span-full flex flex-col items-center justify-center py-16',
'text-stone-300 dark-theme:text-stone-200'
)
"
>
<i-lucide:search class="size-10 mb-4" />
<h3 class="text-lg font-medium mb-2">
{{ $t('assetBrowser.noAssetsFound') }}
</h3>
<p class="text-sm">{{ $t('assetBrowser.tryAdjustingFilters') }}</p>
</div>
<!-- Loading state -->
<div
v-if="loading"
class="col-span-full flex items-center justify-center py-16"
>
<i-lucide:loader
:class="
cn('size-6 animate-spin', 'text-stone-300 dark-theme:text-stone-200')
"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import AssetCard from '@/platform/assets/components/AssetCard.vue'
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
import { createGridStyle } from '@/utils/gridUtil'
import { cn } from '@/utils/tailwindUtil'
defineProps<{
assets: AssetDisplayItem[]
loading?: boolean
}>()
defineEmits<{
assetSelect: [asset: AssetDisplayItem]
}>()
// Use same grid style as BaseModalLayout
const gridStyle = computed(() => createGridStyle())
</script>

View File

@@ -0,0 +1,188 @@
import { computed, ref } from 'vue'
import { t } from '@/i18n'
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import {
getAssetBaseModel,
getAssetDescription
} from '@/platform/assets/utils/assetMetadataUtils'
import { formatSize } from '@/utils/formatUtil'
type AssetBadge = {
label: string
type: 'type' | 'base' | 'size'
}
// Display properties for transformed assets
export interface AssetDisplayItem extends AssetItem {
description: string
formattedSize: string
badges: AssetBadge[]
stats: {
formattedDate?: string
downloadCount?: string
stars?: string
}
}
/**
* Asset Browser composable
* Manages search, filtering, asset transformation and selection logic
*/
export function useAssetBrowser(assets: AssetItem[] = []) {
// State
const searchQuery = ref('')
const selectedCategory = ref('all')
const sortBy = ref('name')
// Transform API asset to display asset
function transformAssetForDisplay(asset: AssetItem): AssetDisplayItem {
// Extract description from metadata or create from tags
const typeTag = asset.tags.find((tag) => tag !== 'models')
const description =
getAssetDescription(asset) ||
`${typeTag || t('assetBrowser.unknown')} model`
// Format file size
const formattedSize = formatSize(asset.size)
// Create badges from tags and metadata
const badges: AssetBadge[] = []
// Type badge from non-root tag
if (typeTag) {
badges.push({ label: typeTag, type: 'type' })
}
// Base model badge from metadata
const baseModel = getAssetBaseModel(asset)
if (baseModel) {
badges.push({
label: baseModel,
type: 'base'
})
}
// Size badge
badges.push({ label: formattedSize, type: 'size' })
// Create display stats from API data
const stats = {
formattedDate: new Date(asset.created_at).toLocaleDateString(),
downloadCount: undefined, // Not available in API
stars: undefined // Not available in API
}
return {
...asset,
description,
formattedSize,
badges,
stats
}
}
// Extract available categories from assets
const availableCategories = computed(() => {
const categorySet = new Set<string>()
assets.forEach((asset) => {
// Second tag is the category (after 'models' root tag)
if (asset.tags.length > 1 && asset.tags[0] === 'models') {
categorySet.add(asset.tags[1])
}
})
return [
{
id: 'all',
label: t('assetBrowser.allModels'),
icon: 'icon-[lucide--folder]'
},
...Array.from(categorySet)
.sort()
.map((category) => ({
id: category,
label: category.charAt(0).toUpperCase() + category.slice(1),
icon: 'icon-[lucide--package]'
}))
]
})
// Compute content title from selected category
const contentTitle = computed(() => {
if (selectedCategory.value === 'all') {
return t('assetBrowser.allModels')
}
const category = availableCategories.value.find(
(cat) => cat.id === selectedCategory.value
)
return category?.label || t('assetBrowser.assets')
})
// Filter functions
const filterByCategory = (category: string) => (asset: AssetItem) => {
if (category === 'all') return true
return asset.tags.includes(category)
}
const filterByQuery = (query: string) => (asset: AssetItem) => {
if (!query) return true
const lowerQuery = query.toLowerCase()
const description = getAssetDescription(asset)
return (
asset.name.toLowerCase().includes(lowerQuery) ||
(description && description.toLowerCase().includes(lowerQuery)) ||
asset.tags.some((tag) => tag.toLowerCase().includes(lowerQuery))
)
}
// Computed filtered and transformed assets
const filteredAssets = computed(() => {
const filtered = assets
.filter(filterByCategory(selectedCategory.value))
.filter(filterByQuery(searchQuery.value))
// Sort assets
filtered.sort((a, b) => {
switch (sortBy.value) {
case 'date':
return (
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
)
case 'name':
default:
return a.name.localeCompare(b.name)
}
})
// Transform to display format
return filtered.map(transformAssetForDisplay)
})
// Actions
function selectAsset(asset: AssetDisplayItem): UUID {
if (import.meta.env.DEV) {
console.log('Asset selected:', asset.id, asset.name)
}
return asset.id
}
return {
// State
searchQuery,
selectedCategory,
sortBy,
// Computed
availableCategories,
contentTitle,
filteredAssets,
// Actions
selectAsset,
transformAssetForDisplay
}
}

View File

@@ -0,0 +1,203 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import AssetBrowserModal from '@/platform/assets/components/AssetBrowserModal.vue'
import { mockAssets } from '@/platform/assets/fixtures/ui-mock-assets'
// Component that simulates the useAssetBrowserDialog functionality with working close
const DialogDemoComponent = {
components: { AssetBrowserModal },
setup() {
const isDialogOpen = ref(false)
const currentNodeType = ref('CheckpointLoaderSimple')
const currentInputName = ref('ckpt_name')
const currentValue = ref('')
const handleOpenDialog = (
nodeType: string,
inputName: string,
value = ''
) => {
currentNodeType.value = nodeType
currentInputName.value = inputName
currentValue.value = value
isDialogOpen.value = true
}
const handleCloseDialog = () => {
isDialogOpen.value = false
}
const handleAssetSelected = (assetPath: string) => {
console.log('Asset selected:', assetPath)
alert(`Selected asset: ${assetPath}`)
isDialogOpen.value = false // Auto-close like the real composable
}
const handleOpenWithCurrentValue = () => {
handleOpenDialog(
'CheckpointLoaderSimple',
'ckpt_name',
'realistic_vision_v5.safetensors'
)
}
return {
isDialogOpen,
currentNodeType,
currentInputName,
currentValue,
handleOpenDialog,
handleOpenWithCurrentValue,
handleCloseDialog,
handleAssetSelected,
mockAssets
}
},
template: `
<div class="relative">
<div class="p-8 space-y-4">
<h2 class="text-2xl font-bold mb-6">Asset Browser Dialog Demo</h2>
<div class="space-y-4">
<div>
<h3 class="text-lg font-semibold mb-2">Different Node Types</h3>
<div class="flex gap-3 flex-wrap">
<button
@click="handleOpenDialog('CheckpointLoaderSimple', 'ckpt_name')"
class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
>
Browse Checkpoints
</button>
<button
@click="handleOpenDialog('VAELoader', 'vae_name')"
class="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700"
>
Browse VAE
</button>
<button
@click="handleOpenDialog('ControlNetLoader', 'control_net_name')"
class="px-4 py-2 bg-purple-600 text-white rounded hover:bg-purple-700"
>
Browse ControlNet
</button>
</div>
</div>
<div>
<h3 class="text-lg font-semibold mb-2">With Current Value</h3>
<button
@click="handleOpenWithCurrentValue"
class="px-4 py-2 bg-orange-600 text-white rounded hover:bg-orange-700"
>
Change Current Model
</button>
<p class="text-sm text-gray-600 mt-1">
Opens with "realistic_vision_v5.safetensors" as current value
</p>
</div>
<div class="mt-8 p-4 bg-gray-100 rounded">
<h4 class="font-semibold mb-2">Instructions:</h4>
<ul class="text-sm space-y-1">
<li>• Click any button to open the Asset Browser dialog</li>
<li>• Select an asset to see the callback in action</li>
<li>• Check the browser console for logged events</li>
<li>• Try toggling the left panel with different asset types</li>
<li>• Close button will work properly in this demo</li>
</ul>
</div>
</div>
</div>
<!-- Dialog Modal Overlay -->
<div
v-if="isDialogOpen"
class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50"
@click.self="handleCloseDialog"
>
<div class="w-[80vw] h-[80vh] max-w-[80vw] max-h-[80vh] rounded-2xl overflow-hidden">
<AssetBrowserModal
:assets="mockAssets"
:node-type="currentNodeType"
:input-name="currentInputName"
:current-value="currentValue"
@asset-select="handleAssetSelected"
@close="handleCloseDialog"
/>
</div>
</div>
</div>
`
}
const meta: Meta = {
title: 'Platform/Assets/useAssetBrowserDialog',
parameters: {
layout: 'fullscreen',
docs: {
description: {
component:
'Demonstrates the AssetBrowserModal functionality as used by the useAssetBrowserDialog composable.'
}
}
}
}
export default meta
type Story = StoryObj<typeof meta>
export const Demo: Story = {
render: () => ({
components: { DialogDemoComponent },
template: `
<div>
<DialogDemoComponent />
<!-- Code Example Section -->
<div class="p-8 border-t border-gray-200 bg-gray-50">
<h2 class="text-2xl font-bold mb-4">Code Example</h2>
<p class="text-gray-600 mb-4">
This is how you would use the composable in your component:
</p>
<div class="bg-white p-4 rounded-lg border shadow-sm">
<pre><code class="text-sm">import { useAssetBrowserDialog } from '@/platform/assets/composables/useAssetBrowserDialog'
export default {
setup() {
const assetBrowserDialog = useAssetBrowserDialog()
const openBrowser = () => {
assetBrowserDialog.show({
nodeType: 'CheckpointLoaderSimple',
inputName: 'ckpt_name',
currentValue: '',
onAssetSelected: (assetPath) => {
console.log('Selected:', assetPath)
// Update your component state
}
})
}
return { openBrowser }
}
}</code></pre>
</div>
<div class="mt-4 p-3 bg-blue-50 border border-blue-200 rounded">
<p class="text-sm text-blue-800">
<strong>💡 Try it:</strong> Use the interactive buttons above to see this code in action!
</p>
</div>
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'Complete demo showing both interactive functionality and code examples for using useAssetBrowserDialog to open the Asset Browser modal programmatically.'
}
}
}
}

View File

@@ -0,0 +1,66 @@
import AssetBrowserModal from '@/platform/assets/components/AssetBrowserModal.vue'
import { useDialogStore } from '@/stores/dialogStore'
interface AssetBrowserDialogProps {
/** ComfyUI node type for context (e.g., 'CheckpointLoaderSimple') */
nodeType: string
/** Widget input name (e.g., 'ckpt_name') */
inputName: string
/** Current selected asset value */
currentValue?: string
/** Callback for when an asset is selected */
onAssetSelected?: (assetPath: string) => void
}
export const useAssetBrowserDialog = () => {
const dialogStore = useDialogStore()
const dialogKey = 'global-asset-browser'
function hide() {
dialogStore.closeDialog({ key: dialogKey })
}
function show(props: AssetBrowserDialogProps) {
const handleAssetSelected = (assetPath: string) => {
props.onAssetSelected?.(assetPath)
hide() // Auto-close on selection
}
const handleClose = () => {
hide()
}
// Default dialog configuration for AssetBrowserModal
const dialogComponentProps = {
headless: true,
modal: true,
closable: false,
pt: {
root: {
class: 'rounded-2xl overflow-hidden'
},
header: {
class: 'p-0 hidden'
},
content: {
class: 'p-0 m-0 h-full w-full'
}
}
}
dialogStore.showDialog({
key: dialogKey,
component: AssetBrowserModal,
props: {
nodeType: props.nodeType,
inputName: props.inputName,
currentValue: props.currentValue,
onSelect: handleAssetSelected,
onClose: handleClose
},
dialogComponentProps
})
}
return { show, hide }
}

View File

@@ -0,0 +1,56 @@
import { uniqWith } from 'es-toolkit'
import { computed } from 'vue'
import type { SelectOption } from '@/components/input/types'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
/**
* Composable that extracts available filter options from asset data
* Provides reactive computed properties for file formats and base models
*/
export function useAssetFilterOptions(assets: AssetItem[] = []) {
/**
* Extract unique file formats from asset names
* Returns sorted SelectOption array with extensions
*/
const availableFileFormats = computed<SelectOption[]>(() => {
const extensions = assets
.map((asset) => {
const extension = asset.name.split('.').pop()
return extension && extension !== asset.name ? extension : null
})
.filter((extension): extension is string => extension !== null)
const uniqueExtensions = uniqWith(extensions, (a, b) => a === b)
return uniqueExtensions.sort().map((format) => ({
name: `.${format}`,
value: format
}))
})
/**
* Extract unique base models from asset user metadata
* Returns sorted SelectOption array with base model names
*/
const availableBaseModels = computed<SelectOption[]>(() => {
const models = assets
.map((asset) => asset.user_metadata?.base_model)
.filter(
(baseModel): baseModel is string =>
baseModel !== undefined && typeof baseModel === 'string'
)
const uniqueModels = uniqWith(models, (a, b) => a === b)
return uniqueModels.sort().map((model) => ({
name: model,
value: model
}))
})
return {
availableFileFormats,
availableBaseModels
}
}

View File

@@ -0,0 +1,128 @@
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
// 🎭 OBVIOUSLY FAKE MOCK DATA - DO NOT USE IN PRODUCTION! 🎭
const fakeFunnyModelNames = [
'🎯_totally_real_model_v420.69',
'🚀_definitely_not_fake_v999',
'🎪_super_legit_checkpoint_pro_max',
'🦄_unicorn_dreams_totally_real.model',
'🍕_pizza_generator_supreme',
'🎸_rock_star_fake_data_v1337',
'🌮_taco_tuesday_model_deluxe',
'🦖_dino_nugget_generator_v3',
'🎮_gamer_fuel_checkpoint_xl',
'🍄_mushroom_kingdom_diffusion',
'🏴_pirate_treasure_model_arr',
'🦋_butterfly_effect_generator',
'🎺_jazz_hands_checkpoint_pro',
'🥨_pretzel_logic_model_v2',
'🌙_midnight_snack_generator',
'🎭_drama_llama_checkpoint',
'🧙_wizard_hat_diffusion_xl',
'🎪_circus_peanut_model_v4',
'🦒_giraffe_neck_generator',
'🎲_random_stuff_checkpoint_max'
]
const obviouslyFakeDescriptions = [
'⚠️ FAKE DATA: Generates 100% authentic fake images with premium mock quality',
'🎭 MOCK ALERT: This totally real model creates absolutely genuine fake content',
'🚨 NOT REAL: Professional-grade fake imagery for your mock data needs',
'🎪 DEMO ONLY: Circus-quality fake generation with extra mock seasoning',
'🍕 FAKE FOOD: Generates delicious fake pizzas (not edible in reality)',
"🎸 MOCK ROCK: Creates fake rock stars who definitely don't exist",
'🌮 TACO FAKERY: Tuesday-themed fake tacos for your mock appetite',
'🦖 PREHISTORIC FAKE: Generates extinct fake dinosaurs for demo purposes',
'🎮 FAKE GAMING: Level up your mock data with obviously fake content',
'🍄 FUNGI FICTION: Magical fake mushrooms from the demo dimension',
'🏴‍☠️ FAKE TREASURE: Arr! This be mock data for ye demo needs, matey!',
'🦋 DEMO EFFECT: Small fake changes create big mock differences',
'🎺 JAZZ FAKERY: Smooth fake jazz for your mock listening pleasure',
'🥨 MOCK LOGIC: Twisted fake reasoning for your demo requirements',
'🌙 MIDNIGHT MOCK: Late-night fake snacks for your demo hunger',
'🎭 FAKE DRAMA: Over-the-top mock emotions for demo entertainment',
'🧙‍♀️ WIZARD MOCK: Magically fake spells cast with demo ingredients',
'🎪 CIRCUS FAKE: Big top mock entertainment under the demo tent',
'🦒 TALL FAKE: Reaches new heights of obviously fake content',
'🎲 RANDOM MOCK: Generates random fake stuff for your demo pleasure'
]
// API-compliant tag structure: first tag must be root (models/input/output), second is category
const modelCategories = ['checkpoints', 'loras', 'embeddings', 'vae']
const baseModels = ['sd15', 'sdxl', 'sd35']
const fileExtensions = ['.safetensors', '.ckpt', '.pt']
const mimeTypes = [
'application/octet-stream',
'application/x-pytorch',
'application/x-safetensors'
]
function getRandomElement<T>(array: T[]): T {
return array[Math.floor(Math.random() * array.length)]
}
function getRandomNumber(min: number, max: number): number {
return Math.floor(Math.random() * (max - min + 1)) + min
}
function getRandomISODate(): string {
const start = new Date('2024-01-01').getTime()
const end = new Date('2024-12-31').getTime()
const randomTime = start + Math.random() * (end - start)
return new Date(randomTime).toISOString()
}
function generateFakeAssetHash(): string {
const chars = '0123456789abcdef'
let hash = 'blake3:'
for (let i = 0; i < 64; i++) {
hash += chars[Math.floor(Math.random() * chars.length)]
}
return hash
}
// 🎭 CREATES OBVIOUSLY FAKE ASSETS FOR DEMO/TEST PURPOSES ONLY! 🎭
export function createMockAssets(count: number = 20): AssetItem[] {
return Array.from({ length: count }, (_, index) => {
const category = getRandomElement(modelCategories)
const baseModel = getRandomElement(baseModels)
const extension = getRandomElement(fileExtensions)
const mimeType = getRandomElement(mimeTypes)
const sizeInBytes = getRandomNumber(
500 * 1024 * 1024,
8 * 1024 * 1024 * 1024
) // 500MB to 8GB
const createdAt = getRandomISODate()
const updatedAt = createdAt
const lastAccessTime = getRandomISODate()
const fakeFileName = `${fakeFunnyModelNames[index]}${extension}`
return {
id: `mock-asset-uuid-${(index + 1).toString().padStart(3, '0')}-fake`,
name: fakeFileName,
asset_hash: generateFakeAssetHash(),
size: sizeInBytes,
mime_type: mimeType,
tags: [
'models', // Root tag (required first)
category, // Category tag (required second for models)
'fake-data', // Obviously fake tag
...(Math.random() > 0.5 ? ['demo-mode'] : ['test-only']),
...(Math.random() > 0.7 ? ['obviously-mock'] : [])
],
preview_url: `/api/assets/mock-asset-uuid-${(index + 1).toString().padStart(3, '0')}-fake/content`,
created_at: createdAt,
updated_at: updatedAt,
last_access_time: lastAccessTime,
user_metadata: {
description: obviouslyFakeDescriptions[index],
base_model: baseModel,
original_name: fakeFunnyModelNames[index],
warning: '🚨 THIS IS FAKE DEMO DATA - NOT A REAL MODEL! 🚨'
}
}
})
}
export const mockAssets = createMockAssets(20)

View File

@@ -1,12 +1,19 @@
import { z } from 'zod'
// Zod schemas for asset API validation
// Zod schemas for asset API validation matching ComfyUI Assets REST API spec
const zAsset = z.object({
id: z.string(),
name: z.string(),
tags: z.array(z.string()),
asset_hash: z.string(),
size: z.number(),
created_at: z.string().optional()
mime_type: z.string(),
tags: z.array(z.string()),
preview_url: z.string().optional(),
created_at: z.string(),
updated_at: z.string(),
last_access_time: z.string(),
user_metadata: z.record(z.unknown()).optional(), // API allows arbitrary key-value pairs
preview_id: z.string().nullable().optional()
})
const zAssetResponse = z.object({
@@ -20,19 +27,22 @@ const zModelFolder = z.object({
folders: z.array(z.string())
})
// Zod schema for ModelFile to align with interface
const zModelFile = z.object({
name: z.string(),
pathIndex: z.number()
})
// Export schemas following repository patterns
export const assetResponseSchema = zAssetResponse
// Export types derived from Zod schemas
export type AssetItem = z.infer<typeof zAsset>
export type AssetResponse = z.infer<typeof zAssetResponse>
export type ModelFolder = z.infer<typeof zModelFolder>
export type ModelFile = z.infer<typeof zModelFile>
// Common interfaces for API responses
export interface ModelFile {
name: string
pathIndex: number
}
// Legacy interface for backward compatibility (now aligned with Zod schema)
export interface ModelFolderInfo {
name: string
folders: string[]

View File

@@ -67,7 +67,7 @@ function createAssetService() {
)
// Blacklist directories we don't want to show
const blacklistedDirectories = ['configs']
const blacklistedDirectories = new Set(['configs'])
// Extract directory names from assets that actually exist, exclude missing assets
const discoveredFolders = new Set<string>(
@@ -75,7 +75,7 @@ function createAssetService() {
?.filter((asset) => !asset.tags.includes(MISSING_TAG))
?.flatMap((asset) => asset.tags)
?.filter(
(tag) => tag !== MODELS_TAG && !blacklistedDirectories.includes(tag)
(tag) => tag !== MODELS_TAG && !blacklistedDirectories.has(tag)
) ?? []
)

View File

@@ -0,0 +1,27 @@
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
/**
* Type-safe utilities for extracting metadata from assets
*/
/**
* Safely extracts string description from asset metadata
* @param asset - The asset to extract description from
* @returns The description string or null if not present/not a string
*/
export function getAssetDescription(asset: AssetItem): string | null {
return typeof asset.user_metadata?.description === 'string'
? asset.user_metadata.description
: null
}
/**
* Safely extracts string base_model from asset metadata
* @param asset - The asset to extract base_model from
* @returns The base_model string or null if not present/not a string
*/
export function getAssetBaseModel(asset: AssetItem): string | null {
return typeof asset.user_metadata?.base_model === 'string'
? asset.user_metadata.base_model
: null
}

View File

@@ -404,7 +404,8 @@ export const CORE_SETTINGS: SettingParams[] = [
{ value: 'ko', text: '한국어' },
{ value: 'fr', text: 'Français' },
{ value: 'es', text: 'Español' },
{ value: 'ar', text: 'عربي' }
{ value: 'ar', text: 'عربي' },
{ value: 'tr', text: 'Türkçe' }
],
defaultValue: () => navigator.language.split('-')[0] || 'en'
},

View File

@@ -17,7 +17,7 @@ import { downloadBlob } from '@/scripts/utils'
import { useDialogService } from '@/services/dialogService'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { appendJsonExt, generateUUID } from '@/utils/formatUtil'
import { appendJsonExt } from '@/utils/formatUtil'
export const useWorkflowService = () => {
const settingStore = useSettingStore()
@@ -112,13 +112,6 @@ export const useWorkflowService = () => {
await renameWorkflow(workflow, newPath)
await workflowStore.saveWorkflow(workflow)
} else {
// Generate new id when saving existing workflow as a new file
const id = generateUUID()
const state = JSON.parse(
JSON.stringify(workflow.activeState)
) as ComfyWorkflowJSON
state.id = id
const tempWorkflow = workflowStore.saveAs(workflow, newPath)
await openWorkflow(tempWorkflow)
await workflowStore.saveWorkflow(tempWorkflow)

View File

@@ -20,7 +20,7 @@ import {
parseNodeExecutionId,
parseNodeLocatorId
} from '@/types/nodeIdentification'
import { getPathDetails } from '@/utils/formatUtil'
import { generateUUID, getPathDetails } from '@/utils/formatUtil'
import { syncEntities } from '@/utils/syncUtil'
import { isSubgraph } from '@/utils/typeGuardUtil'
@@ -320,12 +320,19 @@ export const useWorkflowStore = defineStore('workflow', () => {
existingWorkflow: ComfyWorkflow,
path: string
): ComfyWorkflow => {
// Generate new id when saving existing workflow as a new file
const id = generateUUID()
const state = JSON.parse(
JSON.stringify(existingWorkflow.activeState)
) as ComfyWorkflowJSON
state.id = id
const workflow: ComfyWorkflow = new (existingWorkflow.constructor as any)({
path,
modified: Date.now(),
size: -1
})
workflow.originalContent = workflow.content = existingWorkflow.content
workflow.originalContent = workflow.content = JSON.stringify(state)
workflowLookup.value[workflow.path] = workflow
return workflow
}

View File

@@ -1,12 +1,15 @@
import { getActivePinia } from 'pinia'
import type {
INodeInputSlot,
INodeOutputSlot
} from '@/lib/litegraph/src/interfaces'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import type {
SlotDragSource,
SlotDropCandidate
} from '@/renderer/core/linkInteractions/slotLinkDragState'
} from '@/renderer/core/canvas/links/slotLinkDragState'
import { app } from '@/scripts/app'
interface CompatibilityResult {
@@ -15,9 +18,10 @@ interface CompatibilityResult {
targetSlot?: INodeInputSlot | INodeOutputSlot
}
function resolveNode(nodeId: string | number) {
const canvas = app.canvas
const graph = canvas?.graph
function resolveNode(nodeId: NodeId) {
const pinia = getActivePinia()
const canvasStore = pinia ? useCanvasStore() : null
const graph = canvasStore?.canvas?.graph ?? app.canvas?.graph
if (!graph) return null
const id = typeof nodeId === 'string' ? Number(nodeId) : nodeId
if (Number.isNaN(id)) return null
@@ -28,10 +32,7 @@ export function evaluateCompatibility(
source: SlotDragSource,
candidate: SlotDropCandidate
): CompatibilityResult {
if (
candidate.layout.nodeId === source.nodeId &&
candidate.layout.index === source.slotIndex
) {
if (candidate.layout.nodeId === source.nodeId) {
return { allowable: false }
}
@@ -50,20 +51,9 @@ export function evaluateCompatibility(
return { allowable: false }
}
const sourceSlot = isOutputToInput
? sourceNode.outputs?.[source.slotIndex]
: sourceNode.inputs?.[source.slotIndex]
const targetSlot = isOutputToInput
? targetNode.inputs?.[candidate.layout.index]
: targetNode.outputs?.[candidate.layout.index]
if (!sourceSlot || !targetSlot) {
return { allowable: false }
}
if (isOutputToInput) {
const outputSlot = sourceSlot as INodeOutputSlot | undefined
const inputSlot = targetSlot as INodeInputSlot | undefined
const outputSlot = sourceNode.outputs?.[source.slotIndex]
const inputSlot = targetNode.inputs?.[candidate.layout.index]
if (!outputSlot || !inputSlot) {
return { allowable: false }
}
@@ -72,8 +62,8 @@ export function evaluateCompatibility(
return { allowable, targetNode, targetSlot: inputSlot }
}
const inputSlot = sourceSlot as INodeInputSlot | undefined
const outputSlot = targetSlot as INodeOutputSlot | undefined
const inputSlot = sourceNode.inputs?.[source.slotIndex]
const outputSlot = targetNode.outputs?.[candidate.layout.index]
if (!inputSlot || !outputSlot) {
return { allowable: false }
}

View File

@@ -1,9 +1,9 @@
import { reactive, readonly, shallowReactive } from 'vue'
import { reactive, readonly } from 'vue'
import type { LinkDirection } from '@/lib/litegraph/src/types/globalEnums'
import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import type { SlotLayout } from '@/renderer/core/layout/types'
import type { Point, SlotLayout } from '@/renderer/core/layout/types'
type SlotDragType = 'input' | 'output'
@@ -12,7 +12,7 @@ export interface SlotDragSource {
slotIndex: number
type: SlotDragType
direction: LinkDirection
position: Readonly<{ x: number; y: number }>
position: Readonly<Point>
}
export interface SlotDropCandidate {
@@ -21,8 +21,8 @@ export interface SlotDropCandidate {
}
interface PointerPosition {
client: Readonly<{ x: number; y: number }>
canvas: Readonly<{ x: number; y: number }>
client: Point
canvas: Point
}
interface SlotDragState {
@@ -33,24 +33,27 @@ interface SlotDragState {
candidate: SlotDropCandidate | null
}
const defaultPointer: PointerPosition = Object.freeze({
client: { x: 0, y: 0 },
canvas: { x: 0, y: 0 }
})
const state = reactive<SlotDragState>({
active: false,
pointerId: null,
source: null,
pointer: defaultPointer,
pointer: {
client: { x: 0, y: 0 },
canvas: { x: 0, y: 0 }
},
candidate: null
})
function updatePointerPosition(position: PointerPosition) {
state.pointer = shallowReactive({
client: position.client,
canvas: position.canvas
})
function updatePointerPosition(
clientX: number,
clientY: number,
canvasX: number,
canvasY: number
) {
state.pointer.client.x = clientX
state.pointer.client.y = clientY
state.pointer.canvas.x = canvasX
state.pointer.canvas.y = canvasY
}
function setCandidate(candidate: SlotDropCandidate | null) {
@@ -68,7 +71,10 @@ function endDrag() {
state.active = false
state.pointerId = null
state.source = null
state.pointer = defaultPointer
state.pointer.client.x = 0
state.pointer.client.y = 0
state.pointer.canvas.x = 0
state.pointer.canvas.y = 0
state.candidate = null
}

View File

@@ -6,11 +6,11 @@ import type {
} from '@/lib/litegraph/src/interfaces'
import { LinkDirection } from '@/lib/litegraph/src/types/globalEnums'
import { resolveConnectingLinkColor } from '@/lib/litegraph/src/utils/linkColors'
import type { LinkRenderContext } from '@/renderer/core/canvas/litegraph/litegraphLinkAdapter'
import {
type SlotDragSource,
useSlotLinkDragState
} from '@/renderer/core/linkInteractions/slotLinkDragState'
} from '@/renderer/core/canvas/links/slotLinkDragState'
import type { LinkRenderContext } from '@/renderer/core/canvas/litegraph/litegraphLinkAdapter'
function buildContext(canvas: LGraphCanvas): LinkRenderContext {
return {

View File

@@ -11,12 +11,21 @@ import { app } from '@/scripts/app'
*/
export function useCanvasInteractions() {
const settingStore = useSettingStore()
const { getCanvas } = useCanvasStore()
const canvasStore = useCanvasStore()
const { getCanvas } = canvasStore
const isStandardNavMode = computed(
() => settingStore.get('Comfy.Canvas.NavigationMode') === 'standard'
)
/**
* Whether Vue node components should handle pointer events.
* Returns false when canvas is in read-only/panning mode (e.g., space key held for panning).
*/
const shouldHandleNodePointerEvents = computed(
() => !(canvasStore.canvas?.read_only ?? false)
)
/**
* Handles wheel events from UI components that should be forwarded to canvas
* when appropriate (e.g., Ctrl+wheel for zoom in standard mode)
@@ -97,6 +106,7 @@ export function useCanvasInteractions() {
return {
handleWheel,
handlePointer,
forwardEventToCanvas
forwardEventToCanvas,
shouldHandleNodePointerEvents
}
}

View File

@@ -5,7 +5,7 @@
* CRDT ensures conflict-free operations for both single and multi-user scenarios.
*/
import log from 'loglevel'
import { type ComputedRef, type Ref, computed, customRef } from 'vue'
import { type ComputedRef, type Ref, computed, customRef, ref } from 'vue'
import * as Y from 'yjs'
import { ACTOR_CONFIG } from '@/renderer/core/layout/constants'
@@ -134,6 +134,9 @@ class LayoutStoreImpl implements LayoutStore {
private slotSpatialIndex: SpatialIndexManager // For slots
private rerouteSpatialIndex: SpatialIndexManager // For reroutes
// Vue dragging state for selection toolbox (public ref for direct mutation)
public isDraggingVueNodes = ref(false)
constructor() {
// Initialize Yjs data structures
this.ynodes = this.ydoc.getMap('nodes')

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