Compare commits
51 Commits
BulkDownlo
...
fix/remove
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
54107980c4 | ||
|
|
475d7035f7 | ||
|
|
eb6bf91e20 | ||
|
|
422227d2fc | ||
|
|
10e9bc2f8d | ||
|
|
f7b835e6a5 | ||
|
|
7f30d6b6a5 | ||
|
|
da56c9e554 | ||
|
|
79063edf54 | ||
|
|
d4c40f5255 | ||
|
|
1e1d5c8308 | ||
|
|
e411a104f4 | ||
|
|
19a724710c | ||
|
|
9ecbb3af27 | ||
|
|
581452d312 | ||
|
|
9dde4e7bc7 | ||
|
|
0288ea5b39 | ||
|
|
061e96e488 | ||
|
|
ff9642d0cb | ||
|
|
a6620a4ddc | ||
|
|
9209badd37 | ||
|
|
815be49112 | ||
|
|
adbfb83767 | ||
|
|
3238ad3d32 | ||
|
|
be515d6fcc | ||
|
|
b583c92c64 | ||
|
|
c0a209226d | ||
|
|
c91d811d00 | ||
|
|
e625b0351c | ||
|
|
a56f2d3883 | ||
|
|
2eb7b8c994 | ||
|
|
3eccf3ec61 | ||
|
|
1b73b5b31e | ||
|
|
882d595d4a | ||
|
|
10845cb865 | ||
|
|
6c8473e4e4 | ||
|
|
b7fef1c744 | ||
|
|
828323e263 | ||
|
|
6535138e0b | ||
|
|
ad6f856a31 | ||
|
|
7ed71c7769 | ||
|
|
442eff1094 | ||
|
|
dd4d36d459 | ||
|
|
69c8c84aef | ||
|
|
c5431de123 | ||
|
|
030d4fd4d5 | ||
|
|
473fa609e3 | ||
|
|
a2c8324c0a | ||
|
|
d9ce4ff5e0 | ||
|
|
e7932f2fc2 | ||
|
|
f53b0879ed |
200
.claude/skills/writing-playwright-tests/SKILL.md
Normal file
@@ -0,0 +1,200 @@
|
||||
---
|
||||
name: writing-playwright-tests
|
||||
description: 'Writes Playwright e2e tests for ComfyUI_frontend. Use when creating, modifying, or debugging browser tests. Triggers on: playwright, e2e test, browser test, spec file.'
|
||||
---
|
||||
|
||||
# Writing Playwright Tests for ComfyUI_frontend
|
||||
|
||||
## Golden Rules
|
||||
|
||||
1. **ALWAYS look at existing tests first.** Search `browser_tests/tests/` for similar patterns before writing new tests.
|
||||
|
||||
2. **ALWAYS read the fixture code.** The APIs are in `browser_tests/fixtures/` - read them directly instead of guessing.
|
||||
|
||||
3. **Use premade JSON workflow assets** instead of building workflows programmatically.
|
||||
- Assets live in `browser_tests/assets/`
|
||||
- Load with `await comfyPage.workflow.loadWorkflow('feature/my_workflow')`
|
||||
- Create new assets by starting with `browser_tests/assets/default.json` and manually editing the JSON to match your desired graph state
|
||||
|
||||
## Vue Nodes vs LiteGraph: Decision Guide
|
||||
|
||||
Choose based on **what you're testing**, not personal preference:
|
||||
|
||||
| Testing... | Use | Why |
|
||||
| ---------------------------------------------- | -------------------------------- | ---------------------------------------- |
|
||||
| Vue-rendered node UI, DOM widgets, CSS states | `comfyPage.vueNodes.*` | Nodes are DOM elements, use locators |
|
||||
| Canvas interactions, connections, legacy nodes | `comfyPage.nodeOps.*` | Canvas-based, use coordinates/references |
|
||||
| Both in same test | Pick primary, minimize switching | Avoid confusion |
|
||||
|
||||
**Vue Nodes requires explicit opt-in:**
|
||||
|
||||
```typescript
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
```
|
||||
|
||||
**Vue Node state uses CSS classes:**
|
||||
|
||||
```typescript
|
||||
const BYPASS_CLASS = /before:bg-bypass\/60/
|
||||
await expect(node).toHaveClass(BYPASS_CLASS)
|
||||
```
|
||||
|
||||
## Common Issues
|
||||
|
||||
These are frequent causes of flaky tests - check them first, but investigate if they don't apply:
|
||||
|
||||
| Symptom | Common Cause | Typical Fix |
|
||||
| ---------------------------------- | ------------------------- | -------------------------------------------------------------------------------------- |
|
||||
| Test passes locally, fails in CI | Missing nextFrame() | Add `await comfyPage.nextFrame()` after canvas ops (not needed after `loadWorkflow()`) |
|
||||
| Keyboard shortcuts don't work | Missing focus | Add `await comfyPage.canvas.click()` first |
|
||||
| Double-click doesn't trigger | Timing too fast | Add `{ delay: 5 }` option |
|
||||
| Elements end up in wrong position | Drag animation incomplete | Use `{ steps: 10 }` not `{ steps: 1 }` |
|
||||
| Widget value wrong after drag-drop | Upload incomplete | Add `{ waitForUpload: true }` |
|
||||
| Test fails when run with others | Test pollution | Add `afterEach` with `resetView()` |
|
||||
| Local screenshots don't match CI | Platform differences | Screenshots are Linux-only, use PR label |
|
||||
|
||||
## Test Tags
|
||||
|
||||
Add appropriate tags to every test:
|
||||
|
||||
| Tag | When to Use |
|
||||
| ------------- | ----------------------------------------- |
|
||||
| `@smoke` | Quick essential tests |
|
||||
| `@slow` | Tests > 10 seconds |
|
||||
| `@screenshot` | Visual regression tests |
|
||||
| `@canvas` | Canvas interactions |
|
||||
| `@node` | Node-related |
|
||||
| `@widget` | Widget-related |
|
||||
| `@mobile` | Mobile viewport (runs on Pixel 5 project) |
|
||||
| `@2x` | HiDPI tests (runs on 2x scale project) |
|
||||
|
||||
```typescript
|
||||
test.describe('Feature', { tag: ['@screenshot', '@canvas'] }, () => {
|
||||
```
|
||||
|
||||
## Retry Patterns
|
||||
|
||||
**Never use `waitForTimeout`** - it's always wrong.
|
||||
|
||||
| Pattern | Use Case |
|
||||
| ------------------------ | ---------------------------------------------------- |
|
||||
| Auto-retrying assertions | `toBeVisible()`, `toHaveText()`, etc. (prefer these) |
|
||||
| `expect.poll()` | Single value polling |
|
||||
| `expect().toPass()` | Multiple assertions that must all pass |
|
||||
|
||||
```typescript
|
||||
// Prefer auto-retrying assertions when possible
|
||||
await expect(node).toBeVisible()
|
||||
|
||||
// Single value polling
|
||||
await expect.poll(() => widget.getValue(), { timeout: 2000 }).toBe(100)
|
||||
|
||||
// Multiple conditions
|
||||
await expect(async () => {
|
||||
expect(await node1.getValue()).toBe('foo')
|
||||
expect(await node2.getValue()).toBe('bar')
|
||||
}).toPass({ timeout: 2000 })
|
||||
```
|
||||
|
||||
## Screenshot Baselines
|
||||
|
||||
- **Screenshots are Linux-only.** Don't commit local screenshots.
|
||||
- **To update baselines:** Add PR label `New Browser Test Expectations`
|
||||
- **Mask dynamic content:**
|
||||
```typescript
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('page.png', {
|
||||
mask: [page.locator('.timestamp')]
|
||||
})
|
||||
```
|
||||
|
||||
## CI Debugging
|
||||
|
||||
1. Download artifacts from failed CI run
|
||||
2. Extract and view trace: `npx playwright show-trace trace.zip`
|
||||
3. CI deploys HTML report to Cloudflare Pages (link in PR comment)
|
||||
4. Reproduce CI: `CI=true pnpm test:browser`
|
||||
5. Local runs: `pnpm test:browser:local`
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
Avoid these common mistakes:
|
||||
|
||||
1. **Arbitrary waits** - Use retrying assertions instead
|
||||
|
||||
```typescript
|
||||
// ❌ await page.waitForTimeout(500)
|
||||
// ✅ await expect(element).toBeVisible()
|
||||
```
|
||||
|
||||
2. **Implementation-tied selectors** - Use test IDs or semantic selectors
|
||||
|
||||
```typescript
|
||||
// ❌ page.locator('div.container > button.btn-primary')
|
||||
// ✅ page.getByTestId('submit-button')
|
||||
```
|
||||
|
||||
3. **Missing nextFrame after canvas ops** - Canvas needs sync time
|
||||
|
||||
```typescript
|
||||
await node.drag({ x: 50, y: 50 })
|
||||
await comfyPage.nextFrame() // Required
|
||||
```
|
||||
|
||||
4. **Shared state between tests** - Tests must be independent
|
||||
```typescript
|
||||
// ❌ let sharedData // Outside test
|
||||
// ✅ Define state inside each test
|
||||
```
|
||||
|
||||
## Quick Start Template
|
||||
|
||||
```typescript
|
||||
// Path depends on test file location - adjust '../' segments accordingly
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe('FeatureName', { tag: ['@canvas'] }, () => {
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.canvasOps.resetView()
|
||||
})
|
||||
|
||||
test('should do something', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('myWorkflow')
|
||||
|
||||
const node = (await comfyPage.nodeOps.getNodeRefsByTitle('KSampler'))[0]
|
||||
// ... test logic
|
||||
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('expected.png')
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
## Finding Patterns
|
||||
|
||||
```bash
|
||||
# Find similar tests
|
||||
grep -r "KSampler" browser_tests/tests/
|
||||
|
||||
# Find usage of a fixture method
|
||||
grep -r "loadWorkflow" browser_tests/tests/
|
||||
|
||||
# Find tests with specific tag
|
||||
grep -r '@screenshot' browser_tests/tests/
|
||||
```
|
||||
|
||||
## Key Files to Read
|
||||
|
||||
| Purpose | Path |
|
||||
| ----------------- | ------------------------------------------ |
|
||||
| Main fixture | `browser_tests/fixtures/ComfyPage.ts` |
|
||||
| Helper classes | `browser_tests/fixtures/helpers/` |
|
||||
| Component objects | `browser_tests/fixtures/components/` |
|
||||
| Test selectors | `browser_tests/fixtures/selectors.ts` |
|
||||
| Vue Node helpers | `browser_tests/fixtures/VueNodeHelpers.ts` |
|
||||
| Test assets | `browser_tests/assets/` |
|
||||
| Existing tests | `browser_tests/tests/` |
|
||||
|
||||
**Read the fixture code directly** - it's the source of truth for available methods.
|
||||
52
.github/workflows/ci-dist-telemetry-scan.yaml
vendored
Normal file
@@ -0,0 +1,52 @@
|
||||
name: 'CI: Dist Telemetry Scan'
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches-ignore: [wip/*, draft/*, temp/*]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
scan:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
node-version: 'lts/*'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build project
|
||||
run: pnpm build
|
||||
|
||||
- name: Scan dist for telemetry references
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if rg --no-ignore -n \
|
||||
-g '*.html' \
|
||||
-g '*.js' \
|
||||
-e 'Google Tag Manager' \
|
||||
-e '(?i)\bgtm\.js\b' \
|
||||
-e '(?i)googletagmanager\.com/gtm\.js\\?id=' \
|
||||
-e '(?i)googletagmanager\.com/ns\.html\\?id=' \
|
||||
dist; then
|
||||
echo 'Telemetry references found in dist assets.'
|
||||
exit 1
|
||||
fi
|
||||
echo 'No telemetry references found in dist assets.'
|
||||
2
.gitignore
vendored
@@ -96,3 +96,5 @@ vitest.config.*.timestamp*
|
||||
|
||||
# Weekly docs check output
|
||||
/output.txt
|
||||
|
||||
.amp
|
||||
@@ -59,7 +59,7 @@
|
||||
"function-no-unknown": [
|
||||
true,
|
||||
{
|
||||
"ignoreFunctions": ["theme", "v-bind"]
|
||||
"ignoreFunctions": ["theme", "v-bind", "from-folder", "from-json"]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
50
CODEOWNERS
@@ -2,57 +2,57 @@
|
||||
* @Comfy-org/comfy_frontend_devs
|
||||
|
||||
# Desktop/Electron
|
||||
/apps/desktop-ui/ @benceruleanlu
|
||||
/src/stores/electronDownloadStore.ts @benceruleanlu
|
||||
/src/extensions/core/electronAdapter.ts @benceruleanlu
|
||||
/vite.electron.config.mts @benceruleanlu
|
||||
/apps/desktop-ui/ @benceruleanlu @Comfy-org/comfy_frontend_devs
|
||||
/src/stores/electronDownloadStore.ts @benceruleanlu @Comfy-org/comfy_frontend_devs
|
||||
/src/extensions/core/electronAdapter.ts @benceruleanlu @Comfy-org/comfy_frontend_devs
|
||||
/vite.electron.config.mts @benceruleanlu @Comfy-org/comfy_frontend_devs
|
||||
|
||||
# Common UI Components
|
||||
/src/components/chip/ @viva-jinyi
|
||||
/src/components/card/ @viva-jinyi
|
||||
/src/components/button/ @viva-jinyi
|
||||
/src/components/input/ @viva-jinyi
|
||||
/src/components/chip/ @viva-jinyi @Comfy-org/comfy_frontend_devs
|
||||
/src/components/card/ @viva-jinyi @Comfy-org/comfy_frontend_devs
|
||||
/src/components/button/ @viva-jinyi @Comfy-org/comfy_frontend_devs
|
||||
/src/components/input/ @viva-jinyi @Comfy-org/comfy_frontend_devs
|
||||
|
||||
# Topbar
|
||||
/src/components/topbar/ @pythongosssss
|
||||
/src/components/topbar/ @pythongosssss @Comfy-org/comfy_frontend_devs
|
||||
|
||||
# Thumbnail
|
||||
/src/renderer/core/thumbnail/ @pythongosssss
|
||||
/src/renderer/core/thumbnail/ @pythongosssss @Comfy-org/comfy_frontend_devs
|
||||
|
||||
# Legacy UI
|
||||
/scripts/ui/ @pythongosssss
|
||||
/scripts/ui/ @pythongosssss @Comfy-org/comfy_frontend_devs
|
||||
|
||||
# Link rendering
|
||||
/src/renderer/core/canvas/links/ @benceruleanlu
|
||||
/src/renderer/core/canvas/links/ @benceruleanlu @Comfy-org/comfy_frontend_devs
|
||||
|
||||
# Partner Nodes
|
||||
/src/composables/node/useNodePricing.ts @jojodecayz @bigcat88
|
||||
/src/composables/node/useNodePricing.ts @jojodecayz @bigcat88 @Comfy-org/comfy_frontend_devs
|
||||
|
||||
# Node help system
|
||||
/src/utils/nodeHelpUtil.ts @benceruleanlu
|
||||
/src/stores/workspace/nodeHelpStore.ts @benceruleanlu
|
||||
/src/services/nodeHelpService.ts @benceruleanlu
|
||||
/src/utils/nodeHelpUtil.ts @benceruleanlu @Comfy-org/comfy_frontend_devs
|
||||
/src/stores/workspace/nodeHelpStore.ts @benceruleanlu @Comfy-org/comfy_frontend_devs
|
||||
/src/services/nodeHelpService.ts @benceruleanlu @Comfy-org/comfy_frontend_devs
|
||||
|
||||
# Selection toolbox
|
||||
/src/components/graph/selectionToolbox/ @Myestery
|
||||
/src/components/graph/selectionToolbox/ @Myestery @Comfy-org/comfy_frontend_devs
|
||||
|
||||
# Minimap
|
||||
/src/renderer/extensions/minimap/ @jtydhr88 @Myestery
|
||||
/src/renderer/extensions/minimap/ @jtydhr88 @Myestery @Comfy-org/comfy_frontend_devs
|
||||
|
||||
# Workflow Templates
|
||||
/src/platform/workflow/templates/ @Myestery @christian-byrne @comfyui-wiki
|
||||
/src/components/templates/ @Myestery @christian-byrne @comfyui-wiki
|
||||
/src/platform/workflow/templates/ @Myestery @christian-byrne @comfyui-wiki @Comfy-org/comfy_frontend_devs
|
||||
/src/components/templates/ @Myestery @christian-byrne @comfyui-wiki @Comfy-org/comfy_frontend_devs
|
||||
|
||||
# Mask Editor
|
||||
/src/extensions/core/maskeditor.ts @trsommer @brucew4yn3rp
|
||||
/src/extensions/core/maskEditorLayerFilenames.ts @trsommer @brucew4yn3rp
|
||||
/src/extensions/core/maskeditor.ts @trsommer @brucew4yn3rp @Comfy-org/comfy_frontend_devs
|
||||
/src/extensions/core/maskEditorLayerFilenames.ts @trsommer @brucew4yn3rp @Comfy-org/comfy_frontend_devs
|
||||
|
||||
# 3D
|
||||
/src/extensions/core/load3d.ts @jtydhr88
|
||||
/src/components/load3d/ @jtydhr88
|
||||
/src/extensions/core/load3d.ts @jtydhr88 @Comfy-org/comfy_frontend_devs
|
||||
/src/components/load3d/ @jtydhr88 @Comfy-org/comfy_frontend_devs
|
||||
|
||||
# Manager
|
||||
/src/workbench/extensions/manager/ @viva-jinyi @christian-byrne @ltdrdata
|
||||
/src/workbench/extensions/manager/ @viva-jinyi @christian-byrne @ltdrdata @Comfy-org/comfy_frontend_devs
|
||||
|
||||
# Translations
|
||||
/src/locales/ @Comfy-Org/comfy_maintainer @Comfy-org/comfy_frontend_devs
|
||||
|
||||
@@ -201,7 +201,7 @@ The project supports three types of icons, all with automatic imports (no manual
|
||||
2. **Iconify Icons** - 200,000+ icons from various libraries: `<i class="icon-[lucide--settings]" />`, `<i class="icon-[mdi--folder]" />`
|
||||
3. **Custom Icons** - Your own SVG icons: `<i-comfy:workflow />`
|
||||
|
||||
Icons are powered by the unplugin-icons system, which automatically discovers and imports icons as Vue components. Custom icons are stored in `packages/design-system/src/icons/` and processed by `packages/design-system/src/iconCollection.ts` with automatic validation.
|
||||
Icons are powered by the unplugin-icons system, which automatically discovers and imports icons as Vue components. Tailwind CSS icon classes (`icon-[comfy--template]`) are provided by `@iconify/tailwind4`, configured in `packages/design-system/src/css/style.css`. Custom icons are stored in `packages/design-system/src/icons/` and loaded via `from-folder` at build time.
|
||||
|
||||
For detailed instructions and code examples, see [packages/design-system/src/icons/README.md](packages/design-system/src/icons/README.md).
|
||||
|
||||
|
||||
@@ -4,11 +4,40 @@ See `@docs/guidance/playwright.md` for Playwright best practices (auto-loaded fo
|
||||
|
||||
## Directory Structure
|
||||
|
||||
- `assets/` - Test data (JSON workflows, fixtures)
|
||||
- Tests use premade JSON workflows to load desired graph state
|
||||
```text
|
||||
browser_tests/
|
||||
├── assets/ - Test data (JSON workflows, images)
|
||||
├── fixtures/
|
||||
│ ├── ComfyPage.ts - Main fixture (delegates to helpers)
|
||||
│ ├── ComfyMouse.ts - Mouse interaction helper
|
||||
│ ├── VueNodeHelpers.ts - Vue Nodes 2.0 helpers
|
||||
│ ├── selectors.ts - Centralized TestIds
|
||||
│ ├── components/ - Page object components
|
||||
│ │ ├── ContextMenu.ts
|
||||
│ │ ├── SettingDialog.ts
|
||||
│ │ ├── SidebarTab.ts
|
||||
│ │ └── Topbar.ts
|
||||
│ ├── helpers/ - Focused helper classes
|
||||
│ │ ├── CanvasHelper.ts
|
||||
│ │ ├── CommandHelper.ts
|
||||
│ │ ├── KeyboardHelper.ts
|
||||
│ │ ├── NodeOperationsHelper.ts
|
||||
│ │ ├── SettingsHelper.ts
|
||||
│ │ ├── WorkflowHelper.ts
|
||||
│ │ └── ...
|
||||
│ └── utils/ - Utility functions
|
||||
├── helpers/ - Test-specific utilities
|
||||
└── tests/ - Test files (*.spec.ts)
|
||||
```
|
||||
|
||||
## After Making Changes
|
||||
|
||||
- Run `pnpm typecheck:browser` after modifying TypeScript files in this directory
|
||||
- Run `pnpm exec eslint browser_tests/path/to/file.ts` to lint specific files
|
||||
- Run `pnpm exec oxlint browser_tests/path/to/file.ts` to check with oxlint
|
||||
|
||||
## Skill Documentation
|
||||
|
||||
A Playwright test-writing skill exists at `.claude/skills/writing-playwright-tests/SKILL.md`.
|
||||
|
||||
The skill documents **meta-level guidance only** (gotchas, anti-patterns, decision guides). It does **not** duplicate fixture APIs - agents should read the fixture code directly in `browser_tests/fixtures/`.
|
||||
|
||||
@@ -0,0 +1,599 @@
|
||||
{
|
||||
"id": "9a37f747-e96b-4304-9212-7abcaad7bdac",
|
||||
"revision": 0,
|
||||
"last_node_id": 5,
|
||||
"last_link_id": 5,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 5,
|
||||
"type": "1e38d8ea-45e1-48a5-aa20-966584201867",
|
||||
"pos": [788, 433.5],
|
||||
"size": [210, 108],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "string_a",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "string_a"
|
||||
},
|
||||
"link": 4
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "STRING",
|
||||
"type": "STRING",
|
||||
"links": [5]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"proxyWidgets": [["-1", "string_a"]]
|
||||
},
|
||||
"widgets_values": [""]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "PreviewAny",
|
||||
"pos": [1135, 429],
|
||||
"size": [250, 145.5],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "source",
|
||||
"type": "*",
|
||||
"link": 5
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"Node name for S&R": "PreviewAny"
|
||||
},
|
||||
"widgets_values": [null, null, false]
|
||||
},
|
||||
{
|
||||
"id": 1,
|
||||
"type": "PrimitiveStringMultiline",
|
||||
"pos": [456, 450],
|
||||
"size": [225, 121.5],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "STRING",
|
||||
"type": "STRING",
|
||||
"links": [4]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "PrimitiveStringMultiline"
|
||||
},
|
||||
"widgets_values": ["Outer\n"]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[4, 1, 0, 5, 0, "STRING"],
|
||||
[5, 5, 0, 2, 0, "STRING"]
|
||||
],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "1e38d8ea-45e1-48a5-aa20-966584201867",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 6,
|
||||
"lastLinkId": 9,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "New Subgraph",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [351, 432.5, 120, 60]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [1315, 432.5, 120, 60]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "7bf3e1d4-0521-4b5c-92f5-47ca598b7eb4",
|
||||
"name": "string_a",
|
||||
"type": "STRING",
|
||||
"linkIds": [1],
|
||||
"localized_name": "string_a",
|
||||
"pos": [451, 452.5]
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"id": "fbe975ba-d7c2-471e-a99a-a1e2c6ab466d",
|
||||
"name": "STRING",
|
||||
"type": "STRING",
|
||||
"linkIds": [9],
|
||||
"localized_name": "STRING",
|
||||
"pos": [1335, 452.5]
|
||||
}
|
||||
],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 3,
|
||||
"type": "StringConcatenate",
|
||||
"pos": [815, 373],
|
||||
"size": [347, 231],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "string_a",
|
||||
"name": "string_a",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "string_a"
|
||||
},
|
||||
"link": 1
|
||||
},
|
||||
{
|
||||
"localized_name": "string_b",
|
||||
"name": "string_b",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "string_b"
|
||||
},
|
||||
"link": 2
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "STRING",
|
||||
"name": "STRING",
|
||||
"type": "STRING",
|
||||
"links": [7]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "StringConcatenate"
|
||||
},
|
||||
"widgets_values": ["", "", ""]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "9be42452-056b-4c99-9f9f-7381d11c4454",
|
||||
"pos": [955, 775],
|
||||
"size": [210, 88],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "string_a",
|
||||
"name": "string_a",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "string_a"
|
||||
},
|
||||
"link": 7
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "STRING",
|
||||
"name": "STRING",
|
||||
"type": "STRING",
|
||||
"links": [9]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"proxyWidgets": [["-1", "string_a"]]
|
||||
},
|
||||
"widgets_values": [""]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "PrimitiveStringMultiline",
|
||||
"pos": [313, 685],
|
||||
"size": [325, 109],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "STRING",
|
||||
"name": "STRING",
|
||||
"type": "STRING",
|
||||
"links": [2]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "PrimitiveStringMultiline"
|
||||
},
|
||||
"widgets_values": ["Inner 1\n"]
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 2,
|
||||
"origin_id": 4,
|
||||
"origin_slot": 0,
|
||||
"target_id": 3,
|
||||
"target_slot": 1,
|
||||
"type": "STRING"
|
||||
},
|
||||
{
|
||||
"id": 1,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 3,
|
||||
"target_slot": 0,
|
||||
"type": "STRING"
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"origin_id": 3,
|
||||
"origin_slot": 0,
|
||||
"target_id": 6,
|
||||
"target_slot": 0,
|
||||
"type": "STRING"
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"origin_id": 6,
|
||||
"origin_slot": 0,
|
||||
"target_id": -20,
|
||||
"target_slot": 1,
|
||||
"type": "STRING"
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"origin_id": 6,
|
||||
"origin_slot": 0,
|
||||
"target_id": -20,
|
||||
"target_slot": 0,
|
||||
"type": "STRING"
|
||||
}
|
||||
],
|
||||
"extra": {}
|
||||
},
|
||||
{
|
||||
"id": "9be42452-056b-4c99-9f9f-7381d11c4454",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 9,
|
||||
"lastLinkId": 12,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "New Subgraph",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [680, 774, 120, 60]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [1320, 774, 120, 60]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "01c05c51-86b5-4bad-b32f-9c911683a13d",
|
||||
"name": "string_a",
|
||||
"type": "STRING",
|
||||
"linkIds": [4],
|
||||
"localized_name": "string_a",
|
||||
"pos": [780, 794]
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"id": "a8bcf3bf-a66a-4c71-8d92-17a2a4d03686",
|
||||
"name": "STRING",
|
||||
"type": "STRING",
|
||||
"linkIds": [12],
|
||||
"localized_name": "STRING",
|
||||
"pos": [1340, 794]
|
||||
}
|
||||
],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 5,
|
||||
"type": "StringConcatenate",
|
||||
"pos": [860, 719],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "string_a",
|
||||
"name": "string_a",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "string_a"
|
||||
},
|
||||
"link": 4
|
||||
},
|
||||
{
|
||||
"localized_name": "string_b",
|
||||
"name": "string_b",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "string_b"
|
||||
},
|
||||
"link": 7
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "STRING",
|
||||
"name": "STRING",
|
||||
"type": "STRING",
|
||||
"links": [11]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "StringConcatenate"
|
||||
},
|
||||
"widgets_values": ["", "", ""]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "PrimitiveStringMultiline",
|
||||
"pos": [401, 973],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "STRING",
|
||||
"name": "STRING",
|
||||
"type": "STRING",
|
||||
"links": [7]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "PrimitiveStringMultiline"
|
||||
},
|
||||
"widgets_values": ["Inner 2\n"]
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"type": "7c2915a5-5eb8-4958-a8fd-4beb30f370ce",
|
||||
"pos": [1046, 985],
|
||||
"size": [210, 88],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "string_a",
|
||||
"name": "string_a",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "string_a"
|
||||
},
|
||||
"link": 11
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "STRING",
|
||||
"name": "STRING",
|
||||
"type": "STRING",
|
||||
"links": [12]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"proxyWidgets": [["-1", "string_a"]]
|
||||
},
|
||||
"widgets_values": [""]
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 4,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 5,
|
||||
"target_slot": 0,
|
||||
"type": "STRING"
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"origin_id": 6,
|
||||
"origin_slot": 0,
|
||||
"target_id": 5,
|
||||
"target_slot": 1,
|
||||
"type": "STRING"
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"origin_id": 5,
|
||||
"origin_slot": 0,
|
||||
"target_id": 9,
|
||||
"target_slot": 0,
|
||||
"type": "STRING"
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"origin_id": 9,
|
||||
"origin_slot": 0,
|
||||
"target_id": -20,
|
||||
"target_slot": 0,
|
||||
"type": "STRING"
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"origin_id": 9,
|
||||
"origin_slot": 0,
|
||||
"target_id": -20,
|
||||
"target_slot": 0,
|
||||
"type": "STRING"
|
||||
}
|
||||
],
|
||||
"extra": {}
|
||||
},
|
||||
{
|
||||
"id": "7c2915a5-5eb8-4958-a8fd-4beb30f370ce",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 8,
|
||||
"lastLinkId": 10,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "New Subgraph",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [262, 1222, 120, 60]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [1330, 1222, 120, 60]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "934a8baa-d79c-428c-8ec9-814ad437d7c7",
|
||||
"name": "string_a",
|
||||
"type": "STRING",
|
||||
"linkIds": [9],
|
||||
"localized_name": "string_a",
|
||||
"pos": [362, 1242]
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"id": "4c3d243b-9ff6-4dcd-9dbf-e4ec8e1fc879",
|
||||
"name": "STRING",
|
||||
"type": "STRING",
|
||||
"linkIds": [10],
|
||||
"localized_name": "STRING",
|
||||
"pos": [1350, 1242]
|
||||
}
|
||||
],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 7,
|
||||
"type": "StringConcatenate",
|
||||
"pos": [870, 1038],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "string_a",
|
||||
"name": "string_a",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "string_a"
|
||||
},
|
||||
"link": 9
|
||||
},
|
||||
{
|
||||
"localized_name": "string_b",
|
||||
"name": "string_b",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "string_b"
|
||||
},
|
||||
"link": 8
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "STRING",
|
||||
"name": "STRING",
|
||||
"type": "STRING",
|
||||
"links": [10]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "StringConcatenate"
|
||||
},
|
||||
"widgets_values": ["", "", ""]
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"type": "PrimitiveStringMultiline",
|
||||
"pos": [442, 1296],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "STRING",
|
||||
"name": "STRING",
|
||||
"type": "STRING",
|
||||
"links": [8]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "PrimitiveStringMultiline"
|
||||
},
|
||||
"widgets_values": ["Inner 3\n"]
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 8,
|
||||
"origin_id": 8,
|
||||
"origin_slot": 0,
|
||||
"target_id": 7,
|
||||
"target_slot": 1,
|
||||
"type": "STRING"
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 7,
|
||||
"target_slot": 0,
|
||||
"type": "STRING"
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"origin_id": 7,
|
||||
"origin_slot": 0,
|
||||
"target_id": -20,
|
||||
"target_slot": 0,
|
||||
"type": "STRING"
|
||||
}
|
||||
],
|
||||
"extra": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 1,
|
||||
"offset": [-7, 144]
|
||||
},
|
||||
"frontendVersion": "1.38.13"
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -298,7 +298,10 @@ test.describe('Settings', () => {
|
||||
await input.press('Alt+n')
|
||||
|
||||
const requestPromise = comfyPage.page.waitForRequest(
|
||||
'**/api/settings/Comfy.Keybinding.NewBindings'
|
||||
(req) =>
|
||||
req.url().includes('/api/settings') &&
|
||||
!req.url().includes('/api/settings/') &&
|
||||
req.method() === 'POST'
|
||||
)
|
||||
|
||||
// Save keybinding
|
||||
|
||||
@@ -123,17 +123,14 @@ test.describe('Workflows sidebar', () => {
|
||||
test('Can save workflow as', async ({ comfyPage }) => {
|
||||
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
|
||||
await comfyPage.menu.topbar.saveWorkflowAs('workflow3.json')
|
||||
expect(await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()).toEqual([
|
||||
'*Unsaved Workflow.json',
|
||||
'workflow3.json'
|
||||
])
|
||||
await expect
|
||||
.poll(() => comfyPage.menu.workflowsTab.getOpenedWorkflowNames())
|
||||
.toEqual(['*Unsaved Workflow.json', 'workflow3.json'])
|
||||
|
||||
await comfyPage.menu.topbar.saveWorkflowAs('workflow4.json')
|
||||
expect(await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()).toEqual([
|
||||
'*Unsaved Workflow.json',
|
||||
'workflow3.json',
|
||||
'workflow4.json'
|
||||
])
|
||||
await expect
|
||||
.poll(() => comfyPage.menu.workflowsTab.getOpenedWorkflowNames())
|
||||
.toEqual(['*Unsaved Workflow.json', 'workflow3.json', 'workflow4.json'])
|
||||
})
|
||||
|
||||
test('Exported workflow does not contain localized slot names', async ({
|
||||
@@ -220,24 +217,22 @@ test.describe('Workflows sidebar', () => {
|
||||
await topbar.saveWorkflow('workflow1.json')
|
||||
await topbar.saveWorkflowAs('workflow2.json')
|
||||
await comfyPage.nextFrame()
|
||||
expect(await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()).toEqual([
|
||||
'workflow1.json',
|
||||
'workflow2.json'
|
||||
])
|
||||
expect(await comfyPage.menu.workflowsTab.getActiveWorkflowName()).toEqual(
|
||||
'workflow2.json'
|
||||
)
|
||||
await expect
|
||||
.poll(() => comfyPage.menu.workflowsTab.getOpenedWorkflowNames())
|
||||
.toEqual(['workflow1.json', 'workflow2.json'])
|
||||
await expect
|
||||
.poll(() => comfyPage.menu.workflowsTab.getActiveWorkflowName())
|
||||
.toEqual('workflow2.json')
|
||||
|
||||
await topbar.saveWorkflowAs('workflow1.json')
|
||||
await comfyPage.confirmDialog.click('overwrite')
|
||||
// The old workflow1.json should be deleted and the new one should be saved.
|
||||
expect(await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()).toEqual([
|
||||
'workflow2.json',
|
||||
'workflow1.json'
|
||||
])
|
||||
expect(await comfyPage.menu.workflowsTab.getActiveWorkflowName()).toEqual(
|
||||
'workflow1.json'
|
||||
)
|
||||
await expect
|
||||
.poll(() => comfyPage.menu.workflowsTab.getOpenedWorkflowNames())
|
||||
.toEqual(['workflow2.json', 'workflow1.json'])
|
||||
await expect
|
||||
.poll(() => comfyPage.menu.workflowsTab.getActiveWorkflowName())
|
||||
.toEqual('workflow1.json')
|
||||
})
|
||||
|
||||
test('Does not report warning when switching between opened workflows', async ({
|
||||
|
||||
107
browser_tests/tests/subgraph-duplicate-ids.spec.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Subgraph duplicate ID remapping', { tag: ['@subgraph'] }, () => {
|
||||
const WORKFLOW = 'subgraphs/subgraph-nested-duplicate-ids'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Graph.DeduplicateSubgraphNodeIds',
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
test('All node IDs are globally unique after loading', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
|
||||
const result = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
// TODO: Extract allGraphs accessor (root + subgraphs) into LGraph
|
||||
// TODO: Extract allNodeIds accessor into LGraph
|
||||
const allGraphs = [graph, ...graph.subgraphs.values()]
|
||||
const allIds = allGraphs
|
||||
.flatMap((g) => g._nodes)
|
||||
.map((n) => n.id)
|
||||
.filter((id): id is number => typeof id === 'number')
|
||||
|
||||
return { allIds, uniqueCount: new Set(allIds).size }
|
||||
})
|
||||
|
||||
expect(result.uniqueCount).toBe(result.allIds.length)
|
||||
expect(result.allIds.length).toBeGreaterThanOrEqual(10)
|
||||
})
|
||||
|
||||
test('Root graph node IDs are preserved as canonical', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
|
||||
const rootIds = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
return graph._nodes
|
||||
.map((n) => n.id)
|
||||
.filter((id): id is number => typeof id === 'number')
|
||||
.sort((a, b) => a - b)
|
||||
})
|
||||
|
||||
expect(rootIds).toEqual([1, 2, 5])
|
||||
})
|
||||
|
||||
test('All links reference valid nodes in their graph', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
|
||||
const invalidLinks = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
const labeledGraphs: [string, typeof graph][] = [
|
||||
['root', graph],
|
||||
...[...graph.subgraphs.entries()].map(
|
||||
([id, sg]) => [`subgraph:${id}`, sg] as [string, typeof graph]
|
||||
)
|
||||
]
|
||||
|
||||
const isNonNegative = (id: number | string) =>
|
||||
typeof id === 'number' && id >= 0
|
||||
|
||||
return labeledGraphs.flatMap(([label, g]) =>
|
||||
[...g._links.values()].flatMap((link) =>
|
||||
[
|
||||
isNonNegative(link.origin_id) &&
|
||||
!g._nodes_by_id[link.origin_id] &&
|
||||
`${label}: origin_id ${link.origin_id} not found`,
|
||||
isNonNegative(link.target_id) &&
|
||||
!g._nodes_by_id[link.target_id] &&
|
||||
`${label}: target_id ${link.target_id} not found`
|
||||
].filter(Boolean)
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
expect(invalidLinks).toEqual([])
|
||||
})
|
||||
|
||||
test('Subgraph navigation works after ID remapping', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('5')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const isInSubgraph = () =>
|
||||
comfyPage.page.evaluate(
|
||||
() => window.app!.canvas.graph?.isRootGraph === false
|
||||
)
|
||||
|
||||
expect(await isInSubgraph()).toBe(true)
|
||||
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
expect(await isInSubgraph()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 134 KiB After Width: | Height: | Size: 134 KiB |
|
Before Width: | Height: | Size: 136 KiB After Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 79 KiB After Width: | Height: | Size: 79 KiB |
@@ -3,7 +3,6 @@
|
||||
"style": "new-york",
|
||||
"typescript": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.ts",
|
||||
"css": "src/assets/css/style.css",
|
||||
"baseColor": "stone",
|
||||
"cssVariables": true,
|
||||
|
||||
@@ -287,12 +287,12 @@ export default defineConfig([
|
||||
files: ['**/*.vue'],
|
||||
rules: {
|
||||
'no-restricted-imports': [
|
||||
'warn',
|
||||
'error',
|
||||
{
|
||||
paths: [
|
||||
{
|
||||
name: '@/i18n',
|
||||
importNames: ['t', 'd', 'st', 'te'],
|
||||
importNames: ['t', 'd', 'te'],
|
||||
message:
|
||||
"In Vue components, use `const { t } = useI18n()` instead of importing from '@/i18n'."
|
||||
}
|
||||
@@ -301,13 +301,13 @@ export default defineConfig([
|
||||
]
|
||||
}
|
||||
},
|
||||
// Non-composable .ts files must use the global t/d/st/te, not useI18n()
|
||||
// Non-composable .ts files must use the global t/d/te, not useI18n()
|
||||
{
|
||||
files: ['**/*.ts'],
|
||||
ignores: ['**/use[A-Z]*.ts', '**/*.test.ts', 'src/i18n.ts'],
|
||||
rules: {
|
||||
'no-restricted-imports': [
|
||||
'warn',
|
||||
'error',
|
||||
{
|
||||
paths: [
|
||||
{
|
||||
|
||||
14
global.d.ts
vendored
@@ -5,8 +5,14 @@ declare const __ALGOLIA_APP_ID__: string
|
||||
declare const __ALGOLIA_API_KEY__: string
|
||||
declare const __USE_PROD_CONFIG__: boolean
|
||||
|
||||
interface ImpactQueueFunction {
|
||||
(...args: unknown[]): void
|
||||
a?: unknown[][]
|
||||
}
|
||||
|
||||
interface Window {
|
||||
__CONFIG__: {
|
||||
gtm_container_id?: string
|
||||
mixpanel_token?: string
|
||||
require_whitelist?: boolean
|
||||
subscription_required?: boolean
|
||||
@@ -30,6 +36,14 @@ interface Window {
|
||||
badge?: string
|
||||
}
|
||||
}
|
||||
__ga_identity__?: {
|
||||
client_id?: string
|
||||
session_id?: string
|
||||
session_number?: string
|
||||
}
|
||||
dataLayer?: Array<Record<string, unknown>>
|
||||
ire_o?: string
|
||||
ire?: ImpactQueueFunction
|
||||
}
|
||||
|
||||
interface Navigator {
|
||||
|
||||
42
index.html
@@ -35,18 +35,6 @@
|
||||
background-size: cover;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
#vue-app:has(#loading-logo) {
|
||||
display: contents;
|
||||
color: var(--fg-color);
|
||||
& #loading-logo {
|
||||
place-self: center;
|
||||
font-size: clamp(2px, 1vw, 6px);
|
||||
line-height: 1;
|
||||
overflow: hidden;
|
||||
max-width: 100vw;
|
||||
border-radius: 20ch;
|
||||
}
|
||||
}
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
@@ -65,36 +53,6 @@
|
||||
<body class="litegraph grid">
|
||||
<div id="vue-app">
|
||||
<span class="visually-hidden" role="status">Loading ComfyUI...</span>
|
||||
<svg
|
||||
width="520"
|
||||
height="520"
|
||||
viewBox="0 0 520 520"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
id="loading-logo"
|
||||
>
|
||||
<mask
|
||||
id="mask0_227_285"
|
||||
style="mask-type: alpha"
|
||||
maskUnits="userSpaceOnUse"
|
||||
x="0"
|
||||
y="0"
|
||||
width="520"
|
||||
height="520"
|
||||
>
|
||||
<path
|
||||
d="M0 184.335C0 119.812 0 87.5502 12.5571 62.9055C23.6026 41.2274 41.2274 23.6026 62.9055 12.5571C87.5502 0 119.812 0 184.335 0H335.665C400.188 0 432.45 0 457.094 12.5571C478.773 23.6026 496.397 41.2274 507.443 62.9055C520 87.5502 520 119.812 520 184.335V335.665C520 400.188 520 432.45 507.443 457.094C496.397 478.773 478.773 496.397 457.094 507.443C432.45 520 400.188 520 335.665 520H184.335C119.812 520 87.5502 520 62.9055 507.443C41.2274 496.397 23.6026 478.773 12.5571 457.094C0 432.45 0 400.188 0 335.665V184.335Z"
|
||||
fill="#EEFF30"
|
||||
/>
|
||||
</mask>
|
||||
<g mask="url(#mask0_227_285)">
|
||||
<rect y="0.751831" width="520" height="520" fill="#172DD7" />
|
||||
<path
|
||||
d="M176.484 428.831C168.649 428.831 162.327 425.919 158.204 420.412C153.966 414.755 152.861 406.857 155.171 398.749L164.447 366.178C165.187 363.585 164.672 360.794 163.059 358.636C161.446 356.483 158.921 355.216 156.241 355.216H129.571C121.731 355.216 115.409 352.308 111.289 346.802C107.051 341.14 105.946 333.242 108.258 325.134L140.124 213.748L143.642 201.51C148.371 184.904 165.62 171.407 182.097 171.407H214.009C217.817 171.407 221.167 168.868 222.215 165.183L232.769 128.135C237.494 111.545 254.742 98.048 271.219 98.048L339.468 97.9264L389.431 97.9221C397.268 97.9221 403.59 100.831 407.711 106.337C411.949 111.994 413.054 119.892 410.744 128L396.457 178.164C391.734 194.75 374.485 208.242 358.009 208.242L289.607 208.372H257.706C253.902 208.372 250.557 210.907 249.502 214.588L222.903 307.495C222.159 310.093 222.673 312.892 224.291 315.049C225.904 317.202 228.428 318.469 231.107 318.469C231.113 318.469 276.307 318.381 276.307 318.381H326.122C333.959 318.381 340.281 321.29 344.402 326.796C348.639 332.457 349.744 340.355 347.433 348.463L333.146 398.619C328.423 415.209 311.174 428.701 294.698 428.701L226.299 428.831H176.484Z"
|
||||
fill="#F0FF41"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<script type="module" src="src/main.ts"></script>
|
||||
</body>
|
||||
|
||||
@@ -20,10 +20,6 @@ const config: KnipConfig = {
|
||||
'packages/tailwind-utils': {
|
||||
project: ['src/**/*.{js,ts}']
|
||||
},
|
||||
'packages/design-system': {
|
||||
entry: ['src/**/*.ts'],
|
||||
project: ['src/**/*.{js,ts}', '*.{js,ts,mts}']
|
||||
},
|
||||
'packages/registry-types': {
|
||||
project: ['src/**/*.{js,ts}']
|
||||
}
|
||||
@@ -31,6 +27,7 @@ const config: KnipConfig = {
|
||||
ignoreBinaries: ['python3', 'gh'],
|
||||
ignoreDependencies: [
|
||||
// Weird importmap things
|
||||
'@iconify-json/lucide',
|
||||
'@iconify/json',
|
||||
'@primeuix/forms',
|
||||
'@primeuix/styled',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.39.8",
|
||||
"version": "1.39.12",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -18,7 +18,7 @@
|
||||
"collect-i18n": "pnpm exec playwright test --config=playwright.i18n.config.ts",
|
||||
"dev:cloud": "cross-env DEV_SERVER_COMFYUI_URL='https://testcloud.comfy.org/' nx serve",
|
||||
"dev:desktop": "nx dev @comfyorg/desktop-ui",
|
||||
"dev:electron": "nx serve --config vite.electron.config.mts",
|
||||
"dev:electron": "cross-env DISTRIBUTION=desktop nx serve --config vite.electron.config.mts",
|
||||
"dev:no-vue": "cross-env DISABLE_VUE_PLUGINS=true nx serve",
|
||||
"dev": "nx serve",
|
||||
"devtools:pycheck": "python3 -m compileall -q tools/devtools",
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
"description": "Shared design system for ComfyUI Frontend",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
"./tailwind-config": "./tailwind.config.ts",
|
||||
"./css/*": "./src/css/*"
|
||||
},
|
||||
"scripts": {
|
||||
@@ -12,7 +11,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@iconify-json/lucide": "catalog:",
|
||||
"@iconify/tailwind": "catalog:"
|
||||
"@iconify/tailwind4": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tailwindcss": "catalog:",
|
||||
|
||||
@@ -7,11 +7,16 @@
|
||||
|
||||
@plugin 'tailwindcss-primeui';
|
||||
|
||||
@config '../../tailwind.config.ts';
|
||||
@plugin "@iconify/tailwind4" {
|
||||
scale: 1.2;
|
||||
icon-sets: from-folder(comfy, './packages/design-system/src/icons');
|
||||
}
|
||||
|
||||
@custom-variant touch (@media (hover: none));
|
||||
|
||||
@theme {
|
||||
--shadow-interface: var(--interface-panel-box-shadow);
|
||||
|
||||
--text-xxs: 0.625rem;
|
||||
--text-xxs--line-height: calc(1 / 0.625);
|
||||
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
import { existsSync, readFileSync, readdirSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { dirname } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
const fileName = fileURLToPath(import.meta.url)
|
||||
const dirName = dirname(fileName)
|
||||
const customIconsPath = join(dirName, 'icons')
|
||||
|
||||
// Iconify collection structure
|
||||
interface IconifyIcon {
|
||||
body: string
|
||||
width?: number
|
||||
height?: number
|
||||
}
|
||||
|
||||
interface IconifyCollection {
|
||||
prefix: string
|
||||
icons: Record<string, IconifyIcon>
|
||||
width?: number
|
||||
height?: number
|
||||
}
|
||||
|
||||
// Create an Iconify collection for custom icons
|
||||
export const iconCollection: IconifyCollection = {
|
||||
prefix: 'comfy',
|
||||
icons: {},
|
||||
width: 16,
|
||||
height: 16
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that an SVG file contains valid SVG content
|
||||
*/
|
||||
function validateSvgContent(content: string, filename: string): void {
|
||||
if (!content.trim()) {
|
||||
throw new Error(`Empty SVG file: ${filename}`)
|
||||
}
|
||||
|
||||
if (!content.includes('<svg')) {
|
||||
throw new Error(`Invalid SVG file (missing <svg> tag): ${filename}`)
|
||||
}
|
||||
|
||||
// Basic XML structure validation
|
||||
const openTags = (content.match(/<svg[^>]*>/g) || []).length
|
||||
const closeTags = (content.match(/<\/svg>/g) || []).length
|
||||
|
||||
if (openTags !== closeTags) {
|
||||
throw new Error(`Malformed SVG file (mismatched svg tags): ${filename}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads custom SVG icons from the icons directory
|
||||
*/
|
||||
function loadCustomIcons(): void {
|
||||
if (!existsSync(customIconsPath)) {
|
||||
console.warn(`Custom icons directory not found: ${customIconsPath}`)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const files = readdirSync(customIconsPath)
|
||||
const svgFiles = files.filter((file) => file.endsWith('.svg'))
|
||||
|
||||
if (svgFiles.length === 0) {
|
||||
console.warn('No SVG files found in custom icons directory')
|
||||
return
|
||||
}
|
||||
|
||||
svgFiles.forEach((file) => {
|
||||
const name = file.replace('.svg', '')
|
||||
const filePath = join(customIconsPath, file)
|
||||
|
||||
try {
|
||||
const content = readFileSync(filePath, 'utf-8')
|
||||
validateSvgContent(content, file)
|
||||
|
||||
iconCollection.icons[name] = {
|
||||
body: content
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to load custom icon ${file}:`,
|
||||
error instanceof Error ? error.message : error
|
||||
)
|
||||
// Continue loading other icons instead of failing the entire build
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Failed to read custom icons directory:',
|
||||
error instanceof Error ? error.message : error
|
||||
)
|
||||
// Don't throw here - allow build to continue without custom icons
|
||||
}
|
||||
}
|
||||
|
||||
// Load icons when this module is imported
|
||||
loadCustomIcons()
|
||||
@@ -251,26 +251,25 @@ Icons are automatically imported using `unplugin-icons` - no manual imports need
|
||||
|
||||
The icon system has two layers:
|
||||
|
||||
1. **Build-time Processing** (`packages/design-system/src/iconCollection.ts`):
|
||||
- Scans `packages/design-system/src/icons/` for SVG files
|
||||
- Validates SVG content and structure
|
||||
- Creates Iconify collection for Tailwind CSS
|
||||
- Provides error handling for malformed files
|
||||
1. **Tailwind CSS Plugin** (`@iconify/tailwind4`):
|
||||
- Configured via `@plugin` directive in `packages/design-system/src/css/style.css`
|
||||
- Uses `from-folder(comfy, ...)` to load SVGs from `packages/design-system/src/icons/`
|
||||
- Auto-cleans and optimizes SVGs at build time
|
||||
|
||||
2. **Vite Runtime** (`vite.config.mts`):
|
||||
- Enables direct SVG import as Vue components
|
||||
- Supports dynamic icon loading
|
||||
|
||||
```typescript
|
||||
// Build script creates Iconify collection
|
||||
export const iconCollection: IconifyCollection = {
|
||||
prefix: 'comfy',
|
||||
icons: {
|
||||
workflow: { body: '<svg>...</svg>' },
|
||||
node: { body: '<svg>...</svg>' }
|
||||
}
|
||||
```css
|
||||
/* CSS configuration for Tailwind icon classes */
|
||||
@plugin "@iconify/tailwind4" {
|
||||
prefix: 'icon';
|
||||
scale: 1.2;
|
||||
icon-sets: from-folder(comfy, './packages/design-system/src/icons');
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// Vite configuration for component-based usage
|
||||
Icons({
|
||||
compiler: 'vue3',
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import lucide from '@iconify-json/lucide/icons.json' with { type: 'json' }
|
||||
import { addDynamicIconSelectors } from '@iconify/tailwind'
|
||||
|
||||
import { iconCollection } from './src/iconCollection'
|
||||
|
||||
export default {
|
||||
theme: {
|
||||
extend: {
|
||||
boxShadow: {
|
||||
interface: 'var(--interface-panel-box-shadow)'
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: [
|
||||
addDynamicIconSelectors({
|
||||
iconSets: {
|
||||
comfy: iconCollection,
|
||||
lucide
|
||||
},
|
||||
scale: 1.2,
|
||||
prefix: 'icon'
|
||||
})
|
||||
]
|
||||
}
|
||||
107
pnpm-lock.yaml
generated
@@ -21,9 +21,9 @@ catalogs:
|
||||
'@iconify/json':
|
||||
specifier: ^2.2.380
|
||||
version: 2.2.380
|
||||
'@iconify/tailwind':
|
||||
specifier: ^1.1.3
|
||||
version: 1.2.0
|
||||
'@iconify/tailwind4':
|
||||
specifier: ^1.2.0
|
||||
version: 1.2.1
|
||||
'@intlify/eslint-plugin-vue-i18n':
|
||||
specifier: ^4.1.0
|
||||
version: 4.1.0
|
||||
@@ -805,9 +805,9 @@ importers:
|
||||
'@iconify-json/lucide':
|
||||
specifier: 'catalog:'
|
||||
version: 1.2.79
|
||||
'@iconify/tailwind':
|
||||
'@iconify/tailwind4':
|
||||
specifier: 'catalog:'
|
||||
version: 1.2.0
|
||||
version: 1.2.1(tailwindcss@4.1.12)
|
||||
devDependencies:
|
||||
tailwindcss:
|
||||
specifier: 'catalog:'
|
||||
@@ -1528,6 +1528,9 @@ packages:
|
||||
peerDependencies:
|
||||
postcss-selector-parser: ^7.0.0
|
||||
|
||||
'@cyberalien/svg-utils@1.1.1':
|
||||
resolution: {integrity: sha512-i05Cnpzeezf3eJAXLx7aFirTYYoq5D1XUItp1XsjqkerNJh//6BG9sOYHbiO7v0KYMvJAx3kosrZaRcNlQPdsA==}
|
||||
|
||||
'@dual-bundle/import-meta-resolve@4.2.1':
|
||||
resolution: {integrity: sha512-id+7YRUgoUX6CgV0DtuhirQWodeeA7Lf4i2x71JS/vtA5pRb/hIGWlw+G6MeXvsM+MXrz0VAydTGElX1rAfgPg==}
|
||||
|
||||
@@ -2149,8 +2152,13 @@ packages:
|
||||
'@iconify/json@2.2.380':
|
||||
resolution: {integrity: sha512-+Al/Q+mMB/nLz/tawmJEOkCs6+RKKVUS/Yg9I80h2yRpu0kIzxVLQRfF0NifXz/fH92vDVXbS399wio4lMVF4Q==}
|
||||
|
||||
'@iconify/tailwind@1.2.0':
|
||||
resolution: {integrity: sha512-KgpIHWOTcRYw1XcoUqyNSrmYyfLLqZYu3AmP8zdfLk0F5TqRO8YerhlvlQmGfn7rJXgPeZN569xPAJnJ53zZxA==}
|
||||
'@iconify/tailwind4@1.2.1':
|
||||
resolution: {integrity: sha512-Hd7k8y7uzT3hk8ltw0jGku0r0wA8sc3d2iMvVTYv/9tMxBb+frZtWZGD9hDMU3EYuE+lMn58wi2lS8R2ZbwFcQ==}
|
||||
peerDependencies:
|
||||
tailwindcss: '>= 4.0.0'
|
||||
|
||||
'@iconify/tools@5.0.3':
|
||||
resolution: {integrity: sha512-W5nbH5fNv20TvU49Al19Foos/ViAnmppbCNV9ieGl6/dRMDRzxeFol6peXX/NAgaOytQwZZxTTJRq/Kxd4eWsA==}
|
||||
|
||||
'@iconify/types@2.0.0':
|
||||
resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==}
|
||||
@@ -4664,6 +4672,10 @@ packages:
|
||||
resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
commander@11.1.0:
|
||||
resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==}
|
||||
engines: {node: '>=16'}
|
||||
|
||||
commander@13.1.0:
|
||||
resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -4769,6 +4781,13 @@ packages:
|
||||
css-select@4.3.0:
|
||||
resolution: {integrity: sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ==}
|
||||
|
||||
css-select@5.2.2:
|
||||
resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==}
|
||||
|
||||
css-tree@2.2.1:
|
||||
resolution: {integrity: sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==}
|
||||
engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'}
|
||||
|
||||
css-tree@3.1.0:
|
||||
resolution: {integrity: sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==}
|
||||
engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0}
|
||||
@@ -4785,6 +4804,10 @@ packages:
|
||||
engines: {node: '>=4'}
|
||||
hasBin: true
|
||||
|
||||
csso@5.0.5:
|
||||
resolution: {integrity: sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==}
|
||||
engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'}
|
||||
|
||||
cssstyle@5.3.5:
|
||||
resolution: {integrity: sha512-GlsEptulso7Jg0VaOZ8BXQi3AkYM5BOJKEO/rjMidSCq70FkIC5y0eawrCXeYzxgt3OCf4Ls+eoxN+/05vN0Ag==}
|
||||
engines: {node: '>=20'}
|
||||
@@ -6490,6 +6513,9 @@ packages:
|
||||
mdast-util-to-string@4.0.0:
|
||||
resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==}
|
||||
|
||||
mdn-data@2.0.28:
|
||||
resolution: {integrity: sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==}
|
||||
|
||||
mdn-data@2.12.2:
|
||||
resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==}
|
||||
|
||||
@@ -6683,6 +6709,10 @@ packages:
|
||||
mlly@1.8.0:
|
||||
resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==}
|
||||
|
||||
modern-tar@0.7.3:
|
||||
resolution: {integrity: sha512-4W79zekKGyYU4JXVmB78DOscMFaJth2gGhgfTl2alWE4rNe3nf4N2pqenQ0rEtIewrnD79M687Ouba3YGTLOvg==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
mrmime@2.0.1:
|
||||
resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -7441,6 +7471,10 @@ packages:
|
||||
resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
sax@1.4.4:
|
||||
resolution: {integrity: sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw==}
|
||||
engines: {node: '>=11.0.0'}
|
||||
|
||||
saxes@6.0.0:
|
||||
resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
|
||||
engines: {node: '>=v12.22.7'}
|
||||
@@ -7704,6 +7738,11 @@ packages:
|
||||
svg-tags@1.0.0:
|
||||
resolution: {integrity: sha512-ovssysQTa+luh7A5Weu3Rta6FJlFBBbInjOh722LIt6klpU2/HtdUbszju/G4devcvk8PGt7FCLv5wftu3THUA==}
|
||||
|
||||
svgo@4.0.0:
|
||||
resolution: {integrity: sha512-VvrHQ+9uniE+Mvx3+C9IEe/lWasXCU0nXMY2kZeLrHNICuRiC8uMPyM14UEaMOFA5mhyQqEkB02VoQ16n3DLaw==}
|
||||
engines: {node: '>=16'}
|
||||
hasBin: true
|
||||
|
||||
swr@2.3.6:
|
||||
resolution: {integrity: sha512-wfHRmHWk/isGNMwlLGlZX5Gzz/uTgo0o2IRuTMcf4CPuPFJZlq0rDaKUx+ozB5nBOReNV1kiOyzMfj+MBMikLw==}
|
||||
peerDependencies:
|
||||
@@ -9435,6 +9474,10 @@ snapshots:
|
||||
dependencies:
|
||||
postcss-selector-parser: 7.1.1
|
||||
|
||||
'@cyberalien/svg-utils@1.1.1':
|
||||
dependencies:
|
||||
'@iconify/types': 2.0.0
|
||||
|
||||
'@dual-bundle/import-meta-resolve@4.2.1': {}
|
||||
|
||||
'@emnapi/core@1.7.1':
|
||||
@@ -10023,9 +10066,22 @@ snapshots:
|
||||
'@iconify/types': 2.0.0
|
||||
pathe: 1.1.2
|
||||
|
||||
'@iconify/tailwind@1.2.0':
|
||||
'@iconify/tailwind4@1.2.1(tailwindcss@4.1.12)':
|
||||
dependencies:
|
||||
'@iconify/tools': 5.0.3
|
||||
'@iconify/types': 2.0.0
|
||||
'@iconify/utils': 3.1.0
|
||||
tailwindcss: 4.1.12
|
||||
|
||||
'@iconify/tools@5.0.3':
|
||||
dependencies:
|
||||
'@cyberalien/svg-utils': 1.1.1
|
||||
'@iconify/types': 2.0.0
|
||||
'@iconify/utils': 3.1.0
|
||||
fflate: 0.8.2
|
||||
modern-tar: 0.7.3
|
||||
pathe: 2.0.3
|
||||
svgo: 4.0.0
|
||||
|
||||
'@iconify/types@2.0.0': {}
|
||||
|
||||
@@ -12834,6 +12890,8 @@ snapshots:
|
||||
|
||||
commander@10.0.1: {}
|
||||
|
||||
commander@11.1.0: {}
|
||||
|
||||
commander@13.1.0: {}
|
||||
|
||||
commander@14.0.2: {}
|
||||
@@ -12941,6 +12999,19 @@ snapshots:
|
||||
domutils: 2.8.0
|
||||
nth-check: 2.1.1
|
||||
|
||||
css-select@5.2.2:
|
||||
dependencies:
|
||||
boolbase: 1.0.0
|
||||
css-what: 6.1.0
|
||||
domhandler: 5.0.3
|
||||
domutils: 3.2.2
|
||||
nth-check: 2.1.1
|
||||
|
||||
css-tree@2.2.1:
|
||||
dependencies:
|
||||
mdn-data: 2.0.28
|
||||
source-map-js: 1.2.1
|
||||
|
||||
css-tree@3.1.0:
|
||||
dependencies:
|
||||
mdn-data: 2.12.2
|
||||
@@ -12952,6 +13023,10 @@ snapshots:
|
||||
|
||||
cssesc@3.0.0: {}
|
||||
|
||||
csso@5.0.5:
|
||||
dependencies:
|
||||
css-tree: 2.2.1
|
||||
|
||||
cssstyle@5.3.5:
|
||||
dependencies:
|
||||
'@asamuzakjp/css-color': 4.1.1
|
||||
@@ -14893,6 +14968,8 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/mdast': 4.0.4
|
||||
|
||||
mdn-data@2.0.28: {}
|
||||
|
||||
mdn-data@2.12.2: {}
|
||||
|
||||
mdurl@2.0.0: {}
|
||||
@@ -15193,6 +15270,8 @@ snapshots:
|
||||
pkg-types: 1.3.1
|
||||
ufo: 1.6.1
|
||||
|
||||
modern-tar@0.7.3: {}
|
||||
|
||||
mrmime@2.0.1: {}
|
||||
|
||||
ms@2.1.3: {}
|
||||
@@ -16233,6 +16312,8 @@ snapshots:
|
||||
is-regex: 1.2.1
|
||||
optional: true
|
||||
|
||||
sax@1.4.4: {}
|
||||
|
||||
saxes@6.0.0:
|
||||
dependencies:
|
||||
xmlchars: 2.2.0
|
||||
@@ -16570,6 +16651,16 @@ snapshots:
|
||||
|
||||
svg-tags@1.0.0: {}
|
||||
|
||||
svgo@4.0.0:
|
||||
dependencies:
|
||||
commander: 11.1.0
|
||||
css-select: 5.2.2
|
||||
css-tree: 3.1.0
|
||||
css-what: 6.1.0
|
||||
csso: 5.0.5
|
||||
picocolors: 1.1.1
|
||||
sax: 1.4.4
|
||||
|
||||
swr@2.3.6(react@19.2.3):
|
||||
dependencies:
|
||||
dequal: 2.0.3
|
||||
|
||||
@@ -8,7 +8,7 @@ catalog:
|
||||
'@eslint/js': ^9.39.1
|
||||
'@iconify-json/lucide': ^1.1.178
|
||||
'@iconify/json': ^2.2.380
|
||||
'@iconify/tailwind': ^1.1.3
|
||||
'@iconify/tailwind4': ^1.2.0
|
||||
'@intlify/eslint-plugin-vue-i18n': ^4.1.0
|
||||
'@lobehub/i18n-cli': ^1.25.1
|
||||
'@nx/eslint': 22.2.6
|
||||
|
||||
@@ -283,34 +283,27 @@ else
|
||||
done
|
||||
unset IFS
|
||||
|
||||
# Determine overall status
|
||||
# Determine overall status (flaky tests are treated as passing)
|
||||
if [ $total_failed -gt 0 ]; then
|
||||
status_icon="❌"
|
||||
status_text="Failed"
|
||||
elif [ $total_flaky -gt 0 ]; then
|
||||
status_icon="⚠️"
|
||||
status_text="Passed with flaky tests"
|
||||
elif [ $total_tests -gt 0 ]; then
|
||||
status_icon="✅"
|
||||
status_text="Passed"
|
||||
else
|
||||
status_icon="🕵🏻"
|
||||
status_text="No test results"
|
||||
fi
|
||||
|
||||
# Generate concise completion comment
|
||||
comment="$COMMENT_MARKER
|
||||
## 🎭 Playwright Tests: $status_icon **$status_text**"
|
||||
|
||||
# Add summary counts if we have test data
|
||||
if [ $total_tests -gt 0 ]; then
|
||||
comment="$comment
|
||||
|
||||
**Results:** $total_passed passed, $total_failed failed, $total_flaky flaky, $total_skipped skipped (Total: $total_tests)"
|
||||
# Build flaky indicator if any (small subtext, no warning icon)
|
||||
flaky_note=""
|
||||
if [ $total_flaky -gt 0 ]; then
|
||||
flaky_note=" · $total_flaky flaky"
|
||||
fi
|
||||
|
||||
# Generate compact single-line comment
|
||||
comment="$COMMENT_MARKER
|
||||
**Playwright:** $status_icon $total_passed passed, $total_failed failed$flaky_note"
|
||||
|
||||
# Extract and display failed tests from all browsers
|
||||
if [ $total_failed -gt 0 ] || [ $total_flaky -gt 0 ]; then
|
||||
# Extract and display failed tests from all browsers (flaky tests are treated as passing)
|
||||
if [ $total_failed -gt 0 ]; then
|
||||
comment="$comment
|
||||
|
||||
### ❌ Failed Tests"
|
||||
|
||||
@@ -19,7 +19,8 @@ import config from '@/config'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
|
||||
|
||||
import { electronAPI, isElectron } from './utils/envUtil'
|
||||
import { electronAPI } from '@/utils/envUtil'
|
||||
import { isDesktop } from '@/platform/distribution/types'
|
||||
import { app } from '@/scripts/app'
|
||||
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
@@ -42,7 +43,7 @@ const showContextMenu = (event: MouseEvent) => {
|
||||
onMounted(() => {
|
||||
window['__COMFYUI_FRONTEND_VERSION__'] = config.app_version
|
||||
|
||||
if (isElectron()) {
|
||||
if (isDesktop) {
|
||||
document.addEventListener('contextmenu', showContextMenu)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { downloadFile } from '@/base/common/downloadUtil'
|
||||
import {
|
||||
downloadFile,
|
||||
extractFilenameFromContentDisposition
|
||||
} from '@/base/common/downloadUtil'
|
||||
|
||||
let mockIsCloud = false
|
||||
|
||||
@@ -155,10 +158,14 @@ describe('downloadUtil', () => {
|
||||
const testUrl = 'https://storage.googleapis.com/bucket/file.bin'
|
||||
const blob = new Blob(['test'])
|
||||
const blobFn = vi.fn().mockResolvedValue(blob)
|
||||
const headersMock = {
|
||||
get: vi.fn().mockReturnValue(null)
|
||||
}
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
blob: blobFn
|
||||
blob: blobFn,
|
||||
headers: headersMock
|
||||
} as unknown as Response)
|
||||
|
||||
downloadFile(testUrl)
|
||||
@@ -195,5 +202,147 @@ describe('downloadUtil', () => {
|
||||
expect(createObjectURLSpy).not.toHaveBeenCalled()
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('uses filename from Content-Disposition header in cloud mode', async () => {
|
||||
mockIsCloud = true
|
||||
const testUrl = 'https://storage.googleapis.com/bucket/abc123.png'
|
||||
const blob = new Blob(['test'])
|
||||
const blobFn = vi.fn().mockResolvedValue(blob)
|
||||
const headersMock = {
|
||||
get: vi.fn().mockReturnValue('attachment; filename="user-friendly.png"')
|
||||
}
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
blob: blobFn,
|
||||
headers: headersMock
|
||||
} as unknown as Response)
|
||||
|
||||
downloadFile(testUrl)
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(testUrl)
|
||||
const fetchPromise = fetchMock.mock.results[0].value as Promise<Response>
|
||||
await fetchPromise
|
||||
const blobPromise = blobFn.mock.results[0].value as Promise<Blob>
|
||||
await blobPromise
|
||||
await Promise.resolve()
|
||||
expect(headersMock.get).toHaveBeenCalledWith('Content-Disposition')
|
||||
expect(mockLink.download).toBe('user-friendly.png')
|
||||
})
|
||||
|
||||
it('uses RFC 5987 filename from Content-Disposition header', async () => {
|
||||
mockIsCloud = true
|
||||
const testUrl = 'https://storage.googleapis.com/bucket/abc123.png'
|
||||
const blob = new Blob(['test'])
|
||||
const blobFn = vi.fn().mockResolvedValue(blob)
|
||||
const headersMock = {
|
||||
get: vi
|
||||
.fn()
|
||||
.mockReturnValue(
|
||||
'attachment; filename="fallback.png"; filename*=UTF-8\'\'%E4%B8%AD%E6%96%87.png'
|
||||
)
|
||||
}
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
blob: blobFn,
|
||||
headers: headersMock
|
||||
} as unknown as Response)
|
||||
|
||||
downloadFile(testUrl)
|
||||
|
||||
const fetchPromise = fetchMock.mock.results[0].value as Promise<Response>
|
||||
await fetchPromise
|
||||
const blobPromise = blobFn.mock.results[0].value as Promise<Blob>
|
||||
await blobPromise
|
||||
await Promise.resolve()
|
||||
expect(mockLink.download).toBe('中文.png')
|
||||
})
|
||||
|
||||
it('falls back to provided filename when Content-Disposition is missing', async () => {
|
||||
mockIsCloud = true
|
||||
const testUrl = 'https://storage.googleapis.com/bucket/abc123.png'
|
||||
const blob = new Blob(['test'])
|
||||
const blobFn = vi.fn().mockResolvedValue(blob)
|
||||
const headersMock = {
|
||||
get: vi.fn().mockReturnValue(null)
|
||||
}
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
blob: blobFn,
|
||||
headers: headersMock
|
||||
} as unknown as Response)
|
||||
|
||||
downloadFile(testUrl, 'my-fallback.png')
|
||||
|
||||
const fetchPromise = fetchMock.mock.results[0].value as Promise<Response>
|
||||
await fetchPromise
|
||||
const blobPromise = blobFn.mock.results[0].value as Promise<Blob>
|
||||
await blobPromise
|
||||
await Promise.resolve()
|
||||
expect(mockLink.download).toBe('my-fallback.png')
|
||||
})
|
||||
})
|
||||
|
||||
describe('extractFilenameFromContentDisposition', () => {
|
||||
it('returns null for null header', () => {
|
||||
expect(extractFilenameFromContentDisposition(null)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for empty header', () => {
|
||||
expect(extractFilenameFromContentDisposition('')).toBeNull()
|
||||
})
|
||||
|
||||
it('extracts filename from simple quoted format', () => {
|
||||
const header = 'attachment; filename="test-file.png"'
|
||||
expect(extractFilenameFromContentDisposition(header)).toBe(
|
||||
'test-file.png'
|
||||
)
|
||||
})
|
||||
|
||||
it('extracts filename from unquoted format', () => {
|
||||
const header = 'attachment; filename=test-file.png'
|
||||
expect(extractFilenameFromContentDisposition(header)).toBe(
|
||||
'test-file.png'
|
||||
)
|
||||
})
|
||||
|
||||
it('extracts filename from RFC 5987 format', () => {
|
||||
const header = "attachment; filename*=UTF-8''test%20file.png"
|
||||
expect(extractFilenameFromContentDisposition(header)).toBe(
|
||||
'test file.png'
|
||||
)
|
||||
})
|
||||
|
||||
it('prefers RFC 5987 format over simple format', () => {
|
||||
const header =
|
||||
'attachment; filename="fallback.png"; filename*=UTF-8\'\'preferred.png'
|
||||
expect(extractFilenameFromContentDisposition(header)).toBe(
|
||||
'preferred.png'
|
||||
)
|
||||
})
|
||||
|
||||
it('handles unicode characters in RFC 5987 format', () => {
|
||||
const header =
|
||||
"attachment; filename*=UTF-8''%E4%B8%AD%E6%96%87%E6%96%87%E4%BB%B6.png"
|
||||
expect(extractFilenameFromContentDisposition(header)).toBe('中文文件.png')
|
||||
})
|
||||
|
||||
it('falls back to simple format when RFC 5987 decoding fails', () => {
|
||||
const header =
|
||||
'attachment; filename="fallback.png"; filename*=UTF-8\'\'%invalid'
|
||||
expect(extractFilenameFromContentDisposition(header)).toBe('fallback.png')
|
||||
})
|
||||
|
||||
it('handles header with only attachment disposition', () => {
|
||||
const header = 'attachment'
|
||||
expect(extractFilenameFromContentDisposition(header)).toBeNull()
|
||||
})
|
||||
|
||||
it('handles case-insensitive filename parameter', () => {
|
||||
const header = 'attachment; FILENAME="test.png"'
|
||||
expect(extractFilenameFromContentDisposition(header)).toBe('test.png')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -75,14 +75,57 @@ const extractFilenameFromUrl = (url: string): string | null => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract filename from Content-Disposition header
|
||||
* Handles both simple format: attachment; filename="name.png"
|
||||
* And RFC 5987 format: attachment; filename="fallback.png"; filename*=UTF-8''encoded%20name.png
|
||||
* @param header - The Content-Disposition header value
|
||||
* @returns The extracted filename or null if not found
|
||||
*/
|
||||
export function extractFilenameFromContentDisposition(
|
||||
header: string | null
|
||||
): string | null {
|
||||
if (!header) return null
|
||||
|
||||
// Try RFC 5987 extended format first (filename*=UTF-8''...)
|
||||
const extendedMatch = header.match(/filename\*=UTF-8''([^;]+)/i)
|
||||
if (extendedMatch?.[1]) {
|
||||
try {
|
||||
return decodeURIComponent(extendedMatch[1])
|
||||
} catch {
|
||||
// Fall through to simple format
|
||||
}
|
||||
}
|
||||
|
||||
// Try simple quoted format: filename="..."
|
||||
const quotedMatch = header.match(/filename="([^"]+)"/i)
|
||||
if (quotedMatch?.[1]) {
|
||||
return quotedMatch[1]
|
||||
}
|
||||
|
||||
// Try unquoted format: filename=...
|
||||
const unquotedMatch = header.match(/filename=([^;\s]+)/i)
|
||||
if (unquotedMatch?.[1]) {
|
||||
return unquotedMatch[1]
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const downloadViaBlobFetch = async (
|
||||
href: string,
|
||||
filename: string
|
||||
fallbackFilename: string
|
||||
): Promise<void> => {
|
||||
const response = await fetch(href)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch ${href}: ${response.status}`)
|
||||
}
|
||||
|
||||
// Try to get filename from Content-Disposition header (set by backend)
|
||||
const contentDisposition = response.headers.get('Content-Disposition')
|
||||
const headerFilename =
|
||||
extractFilenameFromContentDisposition(contentDisposition)
|
||||
|
||||
const blob = await response.blob()
|
||||
downloadBlob(filename, blob)
|
||||
downloadBlob(headerFilename ?? fallbackFilename, blob)
|
||||
}
|
||||
|
||||
@@ -18,9 +18,8 @@ import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { TaskItemImpl, useQueueStore } from '@/stores/queueStore'
|
||||
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
||||
import { isElectron } from '@/utils/envUtil'
|
||||
|
||||
const mockData = vi.hoisted(() => ({ isLoggedIn: false }))
|
||||
const mockData = vi.hoisted(() => ({ isLoggedIn: false, isDesktop: false }))
|
||||
|
||||
vi.mock('@/composables/auth/useCurrentUser', () => ({
|
||||
useCurrentUser: () => {
|
||||
@@ -30,7 +29,13 @@ vi.mock('@/composables/auth/useCurrentUser', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/envUtil')
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
isCloud: false,
|
||||
isNightly: false,
|
||||
get isDesktop() {
|
||||
return mockData.isDesktop
|
||||
}
|
||||
}))
|
||||
vi.mock('@/stores/firebaseAuthStore', () => ({
|
||||
useFirebaseAuthStore: vi.fn(() => ({
|
||||
currentUser: null,
|
||||
@@ -107,6 +112,8 @@ describe('TopMenuSection', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
localStorage.clear()
|
||||
mockData.isDesktop = false
|
||||
mockData.isLoggedIn = false
|
||||
})
|
||||
|
||||
describe('authentication state', () => {
|
||||
@@ -129,7 +136,7 @@ describe('TopMenuSection', () => {
|
||||
|
||||
describe('on desktop platform', () => {
|
||||
it('should display LoginButton and not display CurrentUserButton', () => {
|
||||
vi.mocked(isElectron).mockReturnValue(true)
|
||||
mockData.isDesktop = true
|
||||
const wrapper = createWrapper()
|
||||
expect(wrapper.findComponent(LoginButton).exists()).toBe(true)
|
||||
expect(wrapper.findComponent(CurrentUserButton).exists()).toBe(false)
|
||||
|
||||
@@ -153,7 +153,7 @@ import { useQueueStore, useQueueUIStore } from '@/stores/queueStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { isElectron } from '@/utils/envUtil'
|
||||
import { isDesktop } from '@/platform/distribution/types'
|
||||
import { useConflictAcknowledgment } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment'
|
||||
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
|
||||
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
|
||||
@@ -163,7 +163,6 @@ const workspaceStore = useWorkspaceStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const managerState = useManagerState()
|
||||
const { isLoggedIn } = useCurrentUser()
|
||||
const isDesktop = isElectron()
|
||||
const { t, n } = useI18n()
|
||||
const { toastErrorHandler } = useErrorHandling()
|
||||
const commandStore = useCommandStore()
|
||||
|
||||
@@ -8,10 +8,10 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
import ComfyQueueButton from '@/components/actionbar/ComfyRunButton/ComfyQueueButton.vue'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import SubscribeToRunButton from '@/platform/cloud/subscription/components/SubscribeToRun.vue'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
|
||||
const { isActiveSubscription } = useSubscription()
|
||||
const { isActiveSubscription } = useBillingContext()
|
||||
|
||||
const currentButton = computed(() =>
|
||||
isActiveSubscription.value ? ComfyQueueButton : SubscribeToRunButton
|
||||
|
||||
@@ -55,10 +55,17 @@ vi.mock('@/composables/bottomPanelTabs/useTerminal', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/envUtil', () => ({
|
||||
isElectron: vi.fn(() => false),
|
||||
electronAPI: vi.fn(() => null)
|
||||
}))
|
||||
|
||||
const mockData = vi.hoisted(() => ({ isDesktop: false }))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
get isDesktop() {
|
||||
return mockData.isDesktop
|
||||
}
|
||||
}))
|
||||
|
||||
// Mock clipboard API
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
value: {
|
||||
|
||||
@@ -35,7 +35,8 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useTerminal } from '@/composables/bottomPanelTabs/useTerminal'
|
||||
import { electronAPI, isElectron } from '@/utils/envUtil'
|
||||
import { electronAPI } from '@/utils/envUtil'
|
||||
import { isDesktop } from '@/platform/distribution/types'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -85,7 +86,7 @@ const showContextMenu = (event: MouseEvent) => {
|
||||
electronAPI()?.showContextMenu({ type: 'text' })
|
||||
}
|
||||
|
||||
if (isElectron()) {
|
||||
if (isDesktop) {
|
||||
useEventListener(terminalEl, 'contextmenu', showContextMenu)
|
||||
}
|
||||
|
||||
|
||||
@@ -422,8 +422,9 @@ import { createGridStyle } from '@/utils/gridUtil'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const { onClose: originalOnClose } = defineProps<{
|
||||
const { onClose: originalOnClose, initialCategory = 'all' } = defineProps<{
|
||||
onClose: () => void
|
||||
initialCategory?: string
|
||||
}>()
|
||||
|
||||
// Track session time for telemetry
|
||||
@@ -547,7 +548,7 @@ const allTemplates = computed(() => {
|
||||
})
|
||||
|
||||
// Navigation
|
||||
const selectedNavItem = ref<string | null>('all')
|
||||
const selectedNavItem = ref<string | null>(initialCategory)
|
||||
|
||||
// Filter templates based on selected navigation item
|
||||
const navigationFilteredTemplates = computed(() => {
|
||||
|
||||
@@ -18,17 +18,35 @@
|
||||
<div class="flex justify-end gap-4">
|
||||
<div
|
||||
v-if="type === 'overwriteBlueprint'"
|
||||
class="flex justify-start gap-4"
|
||||
class="flex flex-col justify-start gap-1"
|
||||
>
|
||||
<Checkbox
|
||||
v-model="doNotAskAgain"
|
||||
class="flex justify-start gap-4"
|
||||
input-id="doNotAskAgain"
|
||||
binary
|
||||
/>
|
||||
<label for="doNotAskAgain" severity="secondary">{{
|
||||
t('missingModelsDialog.doNotAskAgain')
|
||||
}}</label>
|
||||
<div class="flex gap-4">
|
||||
<input
|
||||
id="doNotAskAgain"
|
||||
v-model="doNotAskAgain"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 cursor-pointer"
|
||||
/>
|
||||
<label for="doNotAskAgain">{{
|
||||
t('missingModelsDialog.doNotAskAgain')
|
||||
}}</label>
|
||||
</div>
|
||||
<i18n-t
|
||||
v-if="doNotAskAgain"
|
||||
keypath="missingModelsDialog.reEnableInSettings"
|
||||
tag="span"
|
||||
class="text-sm text-muted-foreground ml-8"
|
||||
>
|
||||
<template #link>
|
||||
<Button
|
||||
variant="textonly"
|
||||
class="underline cursor-pointer p-0 text-sm text-muted-foreground hover:bg-transparent"
|
||||
@click="openBlueprintOverwriteSetting"
|
||||
>
|
||||
{{ t('missingModelsDialog.reEnableInSettingsLink') }}
|
||||
</Button>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
@@ -92,13 +110,13 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Checkbox from 'primevue/checkbox'
|
||||
import Message from 'primevue/message'
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import type { ConfirmationDialogType } from '@/services/dialogService'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
@@ -114,6 +132,14 @@ const { t } = useI18n()
|
||||
|
||||
const onCancel = () => useDialogStore().closeDialog()
|
||||
|
||||
function openBlueprintOverwriteSetting() {
|
||||
useDialogStore().closeDialog()
|
||||
void useDialogService().showSettingsDialog(
|
||||
undefined,
|
||||
'Comfy.Workflow.WarnBlueprintOverwrite'
|
||||
)
|
||||
}
|
||||
|
||||
const doNotAskAgain = ref(false)
|
||||
|
||||
const onDeny = () => {
|
||||
|
||||
@@ -5,15 +5,38 @@
|
||||
:title="t('missingModelsDialog.missingModels')"
|
||||
:message="t('missingModelsDialog.missingModelsMessage')"
|
||||
/>
|
||||
<div class="mb-4 flex gap-1">
|
||||
<Checkbox v-model="doNotAskAgain" binary input-id="doNotAskAgain" />
|
||||
<label for="doNotAskAgain">{{
|
||||
t('missingModelsDialog.doNotAskAgain')
|
||||
}}</label>
|
||||
<div class="mb-4 flex flex-col gap-1">
|
||||
<div class="flex gap-1">
|
||||
<input
|
||||
id="doNotAskAgain"
|
||||
v-model="doNotAskAgain"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 cursor-pointer"
|
||||
/>
|
||||
<label for="doNotAskAgain">{{
|
||||
t('missingModelsDialog.doNotAskAgain')
|
||||
}}</label>
|
||||
</div>
|
||||
<i18n-t
|
||||
v-if="doNotAskAgain"
|
||||
keypath="missingModelsDialog.reEnableInSettings"
|
||||
tag="span"
|
||||
class="text-sm text-muted-foreground ml-6"
|
||||
>
|
||||
<template #link>
|
||||
<Button
|
||||
variant="textonly"
|
||||
class="underline cursor-pointer p-0 text-sm text-muted-foreground hover:bg-transparent"
|
||||
@click="openShowMissingModelsSetting"
|
||||
>
|
||||
{{ t('missingModelsDialog.reEnableInSettingsLink') }}
|
||||
</Button>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</div>
|
||||
<ListBox :options="missingModels" class="comfy-missing-models">
|
||||
<template #option="{ option }">
|
||||
<Suspense v-if="isElectron()">
|
||||
<Suspense v-if="isDesktop">
|
||||
<ElectronFileDownload
|
||||
:url="option.url"
|
||||
:label="option.label"
|
||||
@@ -31,16 +54,18 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Checkbox from 'primevue/checkbox'
|
||||
import ListBox from 'primevue/listbox'
|
||||
import { computed, onBeforeUnmount, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import ElectronFileDownload from '@/components/common/ElectronFileDownload.vue'
|
||||
import FileDownload from '@/components/common/FileDownload.vue'
|
||||
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
|
||||
import { isDesktop } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { isElectron } from '@/utils/envUtil'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
// TODO: Read this from server internal API rather than hardcoding here
|
||||
// as some installations may wish to use custom sources
|
||||
@@ -78,6 +103,14 @@ const { t } = useI18n()
|
||||
|
||||
const doNotAskAgain = ref(false)
|
||||
|
||||
function openShowMissingModelsSetting() {
|
||||
useDialogStore().closeDialog({ key: 'global-missing-models-warning' })
|
||||
void useDialogService().showSettingsDialog(
|
||||
undefined,
|
||||
'Comfy.Workflow.ShowMissingModelsWarning'
|
||||
)
|
||||
}
|
||||
|
||||
const modelDownloads = ref<Record<string, ModelInfo>>({})
|
||||
const missingModels = computed(() => {
|
||||
return props.missingModels.map((model) => {
|
||||
|
||||
@@ -1,55 +1,86 @@
|
||||
<template>
|
||||
<!-- Cloud mode: Learn More + Got It buttons -->
|
||||
<div
|
||||
v-if="isCloud"
|
||||
class="flex w-full items-center justify-between gap-2 py-2 px-4"
|
||||
>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="sm"
|
||||
as="a"
|
||||
href="https://www.comfy.org/cloud"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<i class="icon-[lucide--info]"></i>
|
||||
<span>{{ $t('missingNodes.cloud.learnMore') }}</span>
|
||||
</Button>
|
||||
<Button variant="secondary" size="md" @click="handleGotItClick">{{
|
||||
$t('missingNodes.cloud.gotIt')
|
||||
}}</Button>
|
||||
</div>
|
||||
<div class="flex w-full flex-col gap-2 py-2 px-4">
|
||||
<div class="flex flex-col gap-1 text-sm text-muted-foreground">
|
||||
<div class="flex items-center gap-1">
|
||||
<input
|
||||
id="doNotAskAgainNodes"
|
||||
v-model="doNotAskAgain"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 cursor-pointer"
|
||||
/>
|
||||
<label for="doNotAskAgainNodes">{{
|
||||
$t('missingModelsDialog.doNotAskAgain')
|
||||
}}</label>
|
||||
</div>
|
||||
<i18n-t
|
||||
v-if="doNotAskAgain"
|
||||
keypath="missingModelsDialog.reEnableInSettings"
|
||||
tag="span"
|
||||
class="text-sm text-muted-foreground ml-6"
|
||||
>
|
||||
<template #link>
|
||||
<Button
|
||||
variant="textonly"
|
||||
class="underline cursor-pointer p-0 text-sm text-muted-foreground hover:bg-transparent"
|
||||
@click="openShowMissingNodesSetting"
|
||||
>
|
||||
{{ $t('missingModelsDialog.reEnableInSettingsLink') }}
|
||||
</Button>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</div>
|
||||
|
||||
<!-- OSS mode: Open Manager + Install All buttons -->
|
||||
<div v-else-if="showManagerButtons" class="flex justify-end gap-1 py-2 px-4">
|
||||
<Button variant="textonly" @click="openManager">{{
|
||||
$t('g.openManager')
|
||||
}}</Button>
|
||||
<PackInstallButton
|
||||
v-if="showInstallAllButton"
|
||||
type="secondary"
|
||||
size="md"
|
||||
:disabled="
|
||||
isLoading || !!error || missingNodePacks.length === 0 || isInstalling
|
||||
"
|
||||
:is-loading="isLoading"
|
||||
:node-packs="missingNodePacks"
|
||||
:label="
|
||||
isLoading
|
||||
? $t('manager.gettingInfo')
|
||||
: $t('manager.installAllMissingNodes')
|
||||
"
|
||||
/>
|
||||
<!-- Cloud mode: Learn More + Got It buttons -->
|
||||
<div v-if="isCloud" class="flex w-full items-center justify-between gap-2">
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="sm"
|
||||
as="a"
|
||||
href="https://www.comfy.org/cloud"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<i class="icon-[lucide--info]"></i>
|
||||
<span>{{ $t('missingNodes.cloud.learnMore') }}</span>
|
||||
</Button>
|
||||
<Button variant="secondary" size="md" @click="handleGotItClick">{{
|
||||
$t('missingNodes.cloud.gotIt')
|
||||
}}</Button>
|
||||
</div>
|
||||
|
||||
<!-- OSS mode: Open Manager + Install All buttons -->
|
||||
<div v-else-if="showManagerButtons" class="flex justify-end gap-1">
|
||||
<Button variant="textonly" @click="openManager">{{
|
||||
$t('g.openManager')
|
||||
}}</Button>
|
||||
<PackInstallButton
|
||||
v-if="showInstallAllButton"
|
||||
type="secondary"
|
||||
size="md"
|
||||
:disabled="
|
||||
isLoading || !!error || missingNodePacks.length === 0 || isInstalling
|
||||
"
|
||||
:is-loading="isLoading"
|
||||
:node-packs="missingNodePacks"
|
||||
:label="
|
||||
isLoading
|
||||
? $t('manager.gettingInfo')
|
||||
: $t('manager.installAllMissingNodes')
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, watch } from 'vue'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import PackInstallButton from '@/workbench/extensions/manager/components/manager/button/PackInstallButton.vue'
|
||||
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
|
||||
@@ -60,10 +91,24 @@ import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTyp
|
||||
const dialogStore = useDialogStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
const doNotAskAgain = ref(false)
|
||||
|
||||
watch(doNotAskAgain, (value) => {
|
||||
void useSettingStore().set('Comfy.Workflow.ShowMissingNodesWarning', !value)
|
||||
})
|
||||
|
||||
const handleGotItClick = () => {
|
||||
dialogStore.closeDialog({ key: 'global-missing-nodes' })
|
||||
}
|
||||
|
||||
function openShowMissingNodesSetting() {
|
||||
dialogStore.closeDialog({ key: 'global-missing-nodes' })
|
||||
void useDialogService().showSettingsDialog(
|
||||
undefined,
|
||||
'Comfy.Workflow.ShowMissingNodesWarning'
|
||||
)
|
||||
}
|
||||
|
||||
const { missingNodePacks, isLoading, error } = useMissingNodes()
|
||||
const comfyManagerStore = useComfyManagerStore()
|
||||
const managerState = useManagerState()
|
||||
|
||||
@@ -158,6 +158,7 @@ import Button from '@/components/ui/button/Button.vue'
|
||||
import FormattedNumberStepper from '@/components/ui/stepper/FormattedNumberStepper.vue'
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { clearTopupTracking } from '@/platform/telemetry/topupTracker'
|
||||
@@ -176,8 +177,9 @@ const dialogService = useDialogService()
|
||||
const telemetry = useTelemetry()
|
||||
const toast = useToast()
|
||||
const { buildDocsUrl, docsPaths } = useExternalLink()
|
||||
const { isSubscriptionEnabled } = useSubscription()
|
||||
const { flags } = useFeatureFlags()
|
||||
|
||||
const { isSubscriptionEnabled } = useSubscription()
|
||||
// Constants
|
||||
const PRESET_AMOUNTS = [10, 25, 50, 100]
|
||||
const MIN_AMOUNT = 5
|
||||
@@ -256,9 +258,15 @@ async function handleBuy() {
|
||||
|
||||
// Close top-up dialog (keep tracking) and open credits panel to show updated balance
|
||||
handleClose(false)
|
||||
dialogService.showSettingsDialog(
|
||||
isSubscriptionEnabled() ? 'subscription' : 'credits'
|
||||
)
|
||||
|
||||
// In workspace mode (personal workspace), show workspace settings panel
|
||||
// Otherwise, show legacy subscription/credits panel
|
||||
const settingsPanel = flags.teamWorkspacesEnabled
|
||||
? 'workspace'
|
||||
: isSubscriptionEnabled()
|
||||
? 'subscription'
|
||||
: 'credits'
|
||||
dialogService.showSettingsDialog(settingsPanel)
|
||||
} catch (error) {
|
||||
console.error('Purchase failed:', error)
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex min-w-[460px] flex-col rounded-2xl border border-border-default bg-base-background shadow-[1px_1px_8px_0_rgba(0,0,0,0.4)]"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex py-8 items-center justify-between px-8">
|
||||
<h2 class="text-lg font-bold text-base-foreground m-0">
|
||||
{{
|
||||
isInsufficientCredits
|
||||
? $t('credits.topUp.addMoreCreditsToRun')
|
||||
: $t('credits.topUp.addMoreCredits')
|
||||
}}
|
||||
</h2>
|
||||
<button
|
||||
class="cursor-pointer rounded border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-base-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-secondary-foreground"
|
||||
@click="() => handleClose()"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-6" />
|
||||
</button>
|
||||
</div>
|
||||
<p
|
||||
v-if="isInsufficientCredits"
|
||||
class="text-sm text-muted-foreground m-0 px-8"
|
||||
>
|
||||
{{ $t('credits.topUp.insufficientWorkflowMessage') }}
|
||||
</p>
|
||||
|
||||
<!-- Preset amount buttons -->
|
||||
<div class="px-8">
|
||||
<h3 class="m-0 text-sm font-normal text-muted-foreground">
|
||||
{{ $t('credits.topUp.selectAmount') }}
|
||||
</h3>
|
||||
<div class="flex gap-2 pt-3">
|
||||
<Button
|
||||
v-for="amount in PRESET_AMOUNTS"
|
||||
:key="amount"
|
||||
:autofocus="amount === 50"
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
:class="
|
||||
cn(
|
||||
'h-10 text-base font-medium w-full focus-visible:ring-secondary-foreground',
|
||||
selectedPreset === amount && 'bg-secondary-background-selected'
|
||||
)
|
||||
"
|
||||
@click="handlePresetClick(amount)"
|
||||
>
|
||||
${{ amount }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Amount (USD) / Credits -->
|
||||
<div class="flex gap-2 px-8 pt-8">
|
||||
<!-- You Pay -->
|
||||
<div class="flex flex-1 flex-col gap-3">
|
||||
<div class="text-sm text-muted-foreground">
|
||||
{{ $t('credits.topUp.youPay') }}
|
||||
</div>
|
||||
<FormattedNumberStepper
|
||||
:model-value="payAmount"
|
||||
:min="0"
|
||||
:max="MAX_AMOUNT"
|
||||
:step="getStepAmount"
|
||||
@update:model-value="handlePayAmountChange"
|
||||
@max-reached="showCeilingWarning = true"
|
||||
>
|
||||
<template #prefix>
|
||||
<span class="shrink-0 text-base font-semibold text-base-foreground"
|
||||
>$</span
|
||||
>
|
||||
</template>
|
||||
</FormattedNumberStepper>
|
||||
</div>
|
||||
|
||||
<!-- You Get -->
|
||||
<div class="flex flex-1 flex-col gap-3">
|
||||
<div class="text-sm text-muted-foreground">
|
||||
{{ $t('credits.topUp.youGet') }}
|
||||
</div>
|
||||
<FormattedNumberStepper
|
||||
v-model="creditsModel"
|
||||
:min="0"
|
||||
:max="usdToCredits(MAX_AMOUNT)"
|
||||
:step="getCreditsStepAmount"
|
||||
@max-reached="showCeilingWarning = true"
|
||||
>
|
||||
<template #prefix>
|
||||
<i class="icon-[lucide--component] size-4 shrink-0 text-gold-500" />
|
||||
</template>
|
||||
</FormattedNumberStepper>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Warnings -->
|
||||
|
||||
<p
|
||||
v-if="isBelowMin"
|
||||
class="text-sm text-red-500 m-0 px-8 pt-4 text-center flex items-center justify-center gap-1"
|
||||
>
|
||||
<i class="icon-[lucide--component] size-4" />
|
||||
{{
|
||||
$t('credits.topUp.minRequired', {
|
||||
credits: formatNumber(usdToCredits(MIN_AMOUNT))
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
<p
|
||||
v-if="showCeilingWarning"
|
||||
class="text-sm text-gold-500 m-0 px-8 pt-4 text-center flex items-center justify-center gap-1"
|
||||
>
|
||||
<i class="icon-[lucide--component] size-4" />
|
||||
{{
|
||||
$t('credits.topUp.maxAllowed', {
|
||||
credits: formatNumber(usdToCredits(MAX_AMOUNT))
|
||||
})
|
||||
}}
|
||||
<span>{{ $t('credits.topUp.needMore') }}</span>
|
||||
<a
|
||||
href="https://www.comfy.org/cloud/enterprise"
|
||||
target="_blank"
|
||||
class="ml-1 text-inherit"
|
||||
>{{ $t('credits.topUp.contactUs') }}</a
|
||||
>
|
||||
</p>
|
||||
|
||||
<div class="pt-8 pb-8 flex flex-col gap-8 px-8">
|
||||
<Button
|
||||
:disabled="!isValidAmount || loading || isPolling"
|
||||
:loading="loading || isPolling"
|
||||
variant="primary"
|
||||
size="lg"
|
||||
class="h-10 justify-center"
|
||||
@click="handleBuy"
|
||||
>
|
||||
{{ $t('subscription.addCredits') }}
|
||||
</Button>
|
||||
<div class="flex items-center justify-center gap-1">
|
||||
<a
|
||||
:href="pricingUrl"
|
||||
target="_blank"
|
||||
class="flex items-center gap-1 text-sm text-muted-foreground no-underline transition-colors hover:text-base-foreground"
|
||||
>
|
||||
{{ $t('credits.topUp.viewPricing') }}
|
||||
<i class="icon-[lucide--external-link] size-4" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { creditsToUsd, usdToCredits } from '@/base/credits/comfyCredits'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import FormattedNumberStepper from '@/components/ui/stepper/FormattedNumberStepper.vue'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { clearTopupTracking } from '@/platform/telemetry/topupTracker'
|
||||
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useBillingOperationStore } from '@/stores/billingOperationStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { isInsufficientCredits = false } = defineProps<{
|
||||
isInsufficientCredits?: boolean
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const dialogStore = useDialogStore()
|
||||
const dialogService = useDialogService()
|
||||
const telemetry = useTelemetry()
|
||||
const toast = useToast()
|
||||
const { buildDocsUrl, docsPaths } = useExternalLink()
|
||||
const { fetchBalance } = useBillingContext()
|
||||
|
||||
const billingOperationStore = useBillingOperationStore()
|
||||
const isPolling = computed(() => billingOperationStore.hasPendingOperations)
|
||||
|
||||
// Constants
|
||||
const PRESET_AMOUNTS = [10, 25, 50, 100]
|
||||
const MIN_AMOUNT = 5
|
||||
const MAX_AMOUNT = 10000
|
||||
|
||||
// State
|
||||
const selectedPreset = ref<number | null>(50)
|
||||
const payAmount = ref(50)
|
||||
const showCeilingWarning = ref(false)
|
||||
const loading = ref(false)
|
||||
|
||||
// Computed
|
||||
const pricingUrl = computed(() =>
|
||||
buildDocsUrl(docsPaths.partnerNodesPricing, { includeLocale: true })
|
||||
)
|
||||
|
||||
const creditsModel = computed({
|
||||
get: () => usdToCredits(payAmount.value),
|
||||
set: (newCredits: number) => {
|
||||
payAmount.value = Math.round(creditsToUsd(newCredits))
|
||||
selectedPreset.value = null
|
||||
}
|
||||
})
|
||||
|
||||
const isValidAmount = computed(
|
||||
() => payAmount.value >= MIN_AMOUNT && payAmount.value <= MAX_AMOUNT
|
||||
)
|
||||
|
||||
const isBelowMin = computed(() => payAmount.value < MIN_AMOUNT)
|
||||
|
||||
// Utility functions
|
||||
function formatNumber(num: number): string {
|
||||
return num.toLocaleString('en-US')
|
||||
}
|
||||
|
||||
// Step amount functions
|
||||
function getStepAmount(currentAmount: number): number {
|
||||
if (currentAmount < 100) return 5
|
||||
if (currentAmount < 1000) return 50
|
||||
return 100
|
||||
}
|
||||
|
||||
function getCreditsStepAmount(currentCredits: number): number {
|
||||
const usdAmount = creditsToUsd(currentCredits)
|
||||
return usdToCredits(getStepAmount(usdAmount))
|
||||
}
|
||||
|
||||
// Event handlers
|
||||
function handlePayAmountChange(value: number) {
|
||||
payAmount.value = value
|
||||
selectedPreset.value = null
|
||||
showCeilingWarning.value = false
|
||||
}
|
||||
|
||||
function handlePresetClick(amount: number) {
|
||||
showCeilingWarning.value = false
|
||||
payAmount.value = amount
|
||||
selectedPreset.value = amount
|
||||
}
|
||||
|
||||
function handleClose(clearTracking = true) {
|
||||
if (clearTracking) {
|
||||
clearTopupTracking()
|
||||
}
|
||||
dialogStore.closeDialog({ key: 'top-up-credits' })
|
||||
}
|
||||
|
||||
async function handleBuy() {
|
||||
if (loading.value || !isValidAmount.value) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
telemetry?.trackApiCreditTopupButtonPurchaseClicked(payAmount.value)
|
||||
|
||||
const amountCents = payAmount.value * 100
|
||||
const response = await workspaceApi.createTopup(amountCents)
|
||||
|
||||
if (response.status === 'completed') {
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('credits.topUp.purchaseSuccess'),
|
||||
life: 5000
|
||||
})
|
||||
await fetchBalance()
|
||||
handleClose(false)
|
||||
dialogService.showSettingsDialog('workspace')
|
||||
} else if (response.status === 'pending') {
|
||||
billingOperationStore.startOperation(response.billing_op_id, 'topup')
|
||||
} else {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('credits.topUp.purchaseError'),
|
||||
detail: t('credits.topUp.unknownError'),
|
||||
life: 5000
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Purchase failed:', error)
|
||||
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : t('credits.topUp.unknownError')
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('credits.topUp.purchaseError'),
|
||||
detail: t('credits.topUp.purchaseErrorDetail', { error: errorMessage }),
|
||||
life: 5000
|
||||
})
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -116,9 +116,9 @@ import { computed, ref, watch } from 'vue'
|
||||
import UserCredit from '@/components/common/UserCredit.vue'
|
||||
import UsageLogsTable from '@/components/dialog/content/setting/UsageLogsTable.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
@@ -138,7 +138,7 @@ const authStore = useFirebaseAuthStore()
|
||||
const authActions = useFirebaseAuthActions()
|
||||
const commandStore = useCommandStore()
|
||||
const telemetry = useTelemetry()
|
||||
const { isActiveSubscription } = useSubscription()
|
||||
const { isActiveSubscription } = useBillingContext()
|
||||
const loading = computed(() => authStore.loading)
|
||||
const balanceLoading = computed(() => authStore.isFetchingBalance)
|
||||
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex w-full max-w-[400px] flex-col rounded-2xl border border-border-default bg-base-background"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div
|
||||
class="flex h-12 items-center justify-between border-b border-border-default px-4"
|
||||
>
|
||||
<h2 class="m-0 text-sm font-normal text-base-foreground">
|
||||
{{ $t('subscription.cancelDialog.title') }}
|
||||
</h2>
|
||||
<button
|
||||
class="cursor-pointer rounded border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-base-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-secondary-foreground"
|
||||
:aria-label="$t('g.close')"
|
||||
:disabled="isLoading"
|
||||
@click="onClose"
|
||||
>
|
||||
<i class="pi pi-times size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="flex flex-col gap-4 px-4 py-4">
|
||||
<p class="m-0 text-sm text-muted-foreground">
|
||||
{{ description }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex items-center justify-end gap-4 px-4 py-4">
|
||||
<Button variant="muted-textonly" :disabled="isLoading" @click="onClose">
|
||||
{{ $t('subscription.cancelDialog.keepSubscription') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="lg"
|
||||
:loading="isLoading"
|
||||
@click="onConfirmCancel"
|
||||
>
|
||||
{{ $t('subscription.cancelDialog.confirmCancel') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const props = defineProps<{
|
||||
cancelAt?: string
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const dialogStore = useDialogStore()
|
||||
const toast = useToast()
|
||||
const { cancelSubscription, fetchStatus, subscription } = useBillingContext()
|
||||
|
||||
const isLoading = ref(false)
|
||||
|
||||
const formattedEndDate = computed(() => {
|
||||
const dateStr = props.cancelAt ?? subscription.value?.endDate
|
||||
if (!dateStr) return t('subscription.cancelDialog.endOfBillingPeriod')
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
})
|
||||
})
|
||||
|
||||
const description = computed(() =>
|
||||
t('subscription.cancelDialog.description', { date: formattedEndDate.value })
|
||||
)
|
||||
|
||||
function onClose() {
|
||||
if (isLoading.value) return
|
||||
dialogStore.closeDialog({ key: 'cancel-subscription' })
|
||||
}
|
||||
|
||||
async function onConfirmCancel() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
await cancelSubscription()
|
||||
await fetchStatus()
|
||||
dialogStore.closeDialog({ key: 'cancel-subscription' })
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('subscription.cancelSuccess'),
|
||||
life: 5000
|
||||
})
|
||||
} catch (error) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('subscription.cancelDialog.failed'),
|
||||
detail: error instanceof Error ? error.message : t('g.unknownError'),
|
||||
life: 5000
|
||||
})
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div>
|
||||
<h2 class="px-4">
|
||||
<h2 :class="cn(flags.teamWorkspacesEnabled ? 'px-6' : 'px-4')">
|
||||
<i class="pi pi-cog" />
|
||||
<span>{{ $t('g.settings') }}</span>
|
||||
<Tag
|
||||
@@ -16,6 +16,9 @@
|
||||
import Tag from 'primevue/tag'
|
||||
|
||||
import { isStaging } from '@/config/staging'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
const { flags } = useFeatureFlags()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -76,6 +76,13 @@
|
||||
/>
|
||||
</TransformPane>
|
||||
|
||||
<LinkOverlayCanvas
|
||||
v-if="shouldRenderVueNodes && comfyApp.canvas && comfyAppReady"
|
||||
:canvas="comfyApp.canvas"
|
||||
@ready="onLinkOverlayReady"
|
||||
@dispose="onLinkOverlayDispose"
|
||||
/>
|
||||
|
||||
<!-- Selection rectangle overlay - rendered in DOM layer to appear above DOM widgets -->
|
||||
<SelectionRectangle v-if="comfyAppReady" />
|
||||
|
||||
@@ -87,6 +94,7 @@
|
||||
<template v-if="comfyAppReady">
|
||||
<TitleEditor />
|
||||
<SelectionToolbox v-if="selectionToolboxEnabled" />
|
||||
<NodeContextMenu />
|
||||
<!-- Render legacy DOM widgets only when Vue nodes are disabled -->
|
||||
<DomWidgets v-if="!shouldRenderVueNodes" />
|
||||
</template>
|
||||
@@ -104,6 +112,7 @@ import {
|
||||
watch,
|
||||
watchEffect
|
||||
} from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import LiteGraphCanvasSplitterOverlay from '@/components/LiteGraphCanvasSplitterOverlay.vue'
|
||||
import TopMenuSection from '@/components/TopMenuSection.vue'
|
||||
@@ -111,7 +120,9 @@ import BottomPanel from '@/components/bottomPanel/BottomPanel.vue'
|
||||
import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
|
||||
import DomWidgets from '@/components/graph/DomWidgets.vue'
|
||||
import GraphCanvasMenu from '@/components/graph/GraphCanvasMenu.vue'
|
||||
import LinkOverlayCanvas from '@/components/graph/LinkOverlayCanvas.vue'
|
||||
import NodeTooltip from '@/components/graph/NodeTooltip.vue'
|
||||
import NodeContextMenu from '@/components/graph/NodeContextMenu.vue'
|
||||
import SelectionToolbox from '@/components/graph/SelectionToolbox.vue'
|
||||
import TitleEditor from '@/components/graph/TitleEditor.vue'
|
||||
import NodePropertiesPanel from '@/components/rightSidePanel/RightSidePanel.vue'
|
||||
@@ -129,7 +140,6 @@ import { useCopy } from '@/composables/useCopy'
|
||||
import { useGlobalLitegraph } from '@/composables/useGlobalLitegraph'
|
||||
import { usePaste } from '@/composables/usePaste'
|
||||
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
|
||||
import { t } from '@/i18n'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { useLitegraphSettings } from '@/platform/settings/composables/useLitegraphSettings'
|
||||
import { CORE_SETTINGS } from '@/platform/settings/constants/coreSettings'
|
||||
@@ -167,6 +177,7 @@ import { isCloud } from '@/platform/distribution/types'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { useInviteUrlLoader } from '@/platform/workspace/composables/useInviteUrlLoader'
|
||||
|
||||
const { t } = useI18n()
|
||||
const emit = defineEmits<{
|
||||
ready: []
|
||||
}>()
|
||||
@@ -241,6 +252,18 @@ const allNodes = computed((): VueNodeData[] =>
|
||||
Array.from(vueNodeLifecycle.nodeManager.value?.vueNodeData?.values() ?? [])
|
||||
)
|
||||
|
||||
function onLinkOverlayReady(el: HTMLCanvasElement) {
|
||||
if (!canvasStore.canvas) return
|
||||
canvasStore.canvas.overlayCanvas = el
|
||||
canvasStore.canvas.overlayCtx = el.getContext('2d')
|
||||
}
|
||||
|
||||
function onLinkOverlayDispose() {
|
||||
if (!canvasStore.canvas) return
|
||||
canvasStore.canvas.overlayCanvas = null
|
||||
canvasStore.canvas.overlayCtx = null
|
||||
}
|
||||
|
||||
watchEffect(() => {
|
||||
LiteGraph.nodeOpacity = settingStore.get('Comfy.Node.Opacity')
|
||||
})
|
||||
|
||||
43
src/components/graph/LinkOverlayCanvas.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<canvas
|
||||
ref="canvasRef"
|
||||
class="pointer-events-none absolute inset-0 size-full"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
|
||||
|
||||
import { useRafFn } from '@vueuse/core'
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
|
||||
const { canvas } = defineProps<{
|
||||
canvas: LGraphCanvas
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
ready: [canvas: HTMLCanvasElement]
|
||||
dispose: []
|
||||
}>()
|
||||
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null)
|
||||
|
||||
useRafFn(() => {
|
||||
const el = canvasRef.value
|
||||
const mainCanvas = canvas.canvas
|
||||
if (!el || !mainCanvas) return
|
||||
|
||||
if (el.width !== mainCanvas.width || el.height !== mainCanvas.height) {
|
||||
el.width = mainCanvas.width
|
||||
el.height = mainCanvas.height
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (canvasRef.value) emit('ready', canvasRef.value)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
emit('dispose')
|
||||
})
|
||||
</script>
|
||||
@@ -42,7 +42,6 @@
|
||||
</Panel>
|
||||
</Transition>
|
||||
</div>
|
||||
<NodeContextMenu />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -69,7 +68,6 @@ import { useExtensionService } from '@/services/extensionService'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import type { ComfyCommandImpl } from '@/stores/commandStore'
|
||||
|
||||
import NodeContextMenu from './NodeContextMenu.vue'
|
||||
import FrameNodes from './selectionToolbox/FrameNodes.vue'
|
||||
import NodeOptionsButton from './selectionToolbox/NodeOptionsButton.vue'
|
||||
import VerticalDivider from './selectionToolbox/VerticalDivider.vue'
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useElementBounding, useEventListener } from '@vueuse/core'
|
||||
import { useElementBounding, useEventListener, whenever } from '@vueuse/core'
|
||||
import type { CSSProperties } from 'vue'
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||
|
||||
@@ -180,6 +180,8 @@ watch(
|
||||
mountElementIfVisible()
|
||||
}
|
||||
)
|
||||
|
||||
whenever(() => !canvasStore.linearMode, mountElementIfVisible)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -159,13 +159,13 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import PuzzleIcon from '@/components/icons/PuzzleIcon.vue'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { isCloud, isDesktop } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import type { ReleaseNote } from '@/platform/updates/common/releaseService'
|
||||
import { useReleaseStore } from '@/platform/updates/common/releaseStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { electronAPI, isElectron } from '@/utils/envUtil'
|
||||
import { electronAPI } from '@/utils/envUtil'
|
||||
import { formatVersionAnchor } from '@/utils/formatUtil'
|
||||
import { useConflictAcknowledgment } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment'
|
||||
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
|
||||
@@ -241,7 +241,7 @@ const moreItems = computed<MenuItem[]>(() => {
|
||||
key: 'desktop-guide',
|
||||
type: 'item',
|
||||
label: t('helpCenter.desktopUserGuide'),
|
||||
visible: isElectron(),
|
||||
visible: isDesktop,
|
||||
action: () => {
|
||||
trackResourceClick('docs', true)
|
||||
openExternalLink(
|
||||
@@ -257,7 +257,7 @@ const moreItems = computed<MenuItem[]>(() => {
|
||||
key: 'dev-tools',
|
||||
type: 'item',
|
||||
label: t('helpCenter.openDevTools'),
|
||||
visible: isElectron(),
|
||||
visible: isDesktop,
|
||||
action: () => {
|
||||
openDevTools()
|
||||
emit('close')
|
||||
@@ -266,13 +266,13 @@ const moreItems = computed<MenuItem[]>(() => {
|
||||
{
|
||||
key: 'divider-1',
|
||||
type: 'divider',
|
||||
visible: isElectron()
|
||||
visible: isDesktop
|
||||
},
|
||||
{
|
||||
key: 'reinstall',
|
||||
type: 'item',
|
||||
label: t('helpCenter.reinstall'),
|
||||
visible: isElectron(),
|
||||
visible: isDesktop,
|
||||
action: () => {
|
||||
onReinstall()
|
||||
emit('close')
|
||||
@@ -374,7 +374,7 @@ const menuItems = computed<MenuItem[]>(() => {
|
||||
})
|
||||
}
|
||||
// Update ComfyUI - only for non-desktop, non-cloud with new manager UI
|
||||
if (!isElectron() && !isCloud && isNewManagerUI.value) {
|
||||
if (!isDesktop && !isCloud && isNewManagerUI.value) {
|
||||
items.push({
|
||||
key: 'update-comfyui',
|
||||
type: 'item',
|
||||
@@ -551,13 +551,13 @@ const onSubmenuLeave = (): void => {
|
||||
}
|
||||
|
||||
const openDevTools = (): void => {
|
||||
if (isElectron()) {
|
||||
if (isDesktop) {
|
||||
electronAPI().openDevTools()
|
||||
}
|
||||
}
|
||||
|
||||
const onReinstall = (): void => {
|
||||
if (isElectron()) {
|
||||
if (isDesktop) {
|
||||
void electronAPI().reinstall()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,6 +94,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref, toRaw } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import AnimationControls from '@/components/load3d/controls/AnimationControls.vue'
|
||||
import CameraControls from '@/components/load3d/controls/viewer/ViewerCameraControls.vue'
|
||||
@@ -104,11 +105,11 @@ import SceneControls from '@/components/load3d/controls/viewer/ViewerSceneContro
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useLoad3dDrag } from '@/composables/useLoad3dDrag'
|
||||
import { useLoad3dViewer } from '@/composables/useLoad3dViewer'
|
||||
import { t } from '@/i18n'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { useLoad3dService } from '@/services/load3dService'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
const props = defineProps<{
|
||||
node?: LGraphNode
|
||||
modelUrl?: string
|
||||
|
||||
@@ -94,15 +94,16 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type {
|
||||
MaterialMode,
|
||||
UpDirection
|
||||
} from '@/extensions/core/load3d/interfaces'
|
||||
import { t } from '@/i18n'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { t } = useI18n()
|
||||
const {
|
||||
hideMaterialMode = false,
|
||||
isPlyModel = false,
|
||||
|
||||
@@ -19,13 +19,15 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Load3DViewerContent from '@/components/load3d/Load3dViewerContent.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { t } from '@/i18n'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { useLoad3dService } from '@/services/load3dService'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { node } = defineProps<{
|
||||
node: LGraphNode
|
||||
}>()
|
||||
|
||||
@@ -30,10 +30,11 @@
|
||||
import Select from 'primevue/select'
|
||||
import Slider from 'primevue/slider'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { CameraType } from '@/extensions/core/load3d/interfaces'
|
||||
import { t } from '@/i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
const cameras = [
|
||||
{ title: t('load3d.cameraType.perspective'), value: 'perspective' },
|
||||
{ title: t('load3d.cameraType.orthographic'), value: 'orthographic' }
|
||||
|
||||
@@ -25,13 +25,14 @@
|
||||
<script setup lang="ts">
|
||||
import Select from 'primevue/select'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type {
|
||||
MaterialMode,
|
||||
UpDirection
|
||||
} from '@/extensions/core/load3d/interfaces'
|
||||
import { t } from '@/i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { hideMaterialMode = false, isPlyModel = false } = defineProps<{
|
||||
hideMaterialMode?: boolean
|
||||
isPlyModel?: boolean
|
||||
|
||||
@@ -160,14 +160,15 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { BrushShape } from '@/extensions/core/maskeditor/types'
|
||||
import { t } from '@/i18n'
|
||||
import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import SliderControl from './controls/SliderControl.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const store = useMaskEditorStore()
|
||||
|
||||
const colorInputRef = ref<HTMLInputElement>()
|
||||
|
||||
@@ -61,14 +61,16 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { ColorComparisonMethod } from '@/extensions/core/maskeditor/types'
|
||||
import { t } from '@/i18n'
|
||||
import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
||||
|
||||
import DropdownControl from './controls/DropdownControl.vue'
|
||||
import SliderControl from './controls/SliderControl.vue'
|
||||
import ToggleControl from './controls/ToggleControl.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const store = useMaskEditorStore()
|
||||
|
||||
const methodOptions = Object.values(ColorComparisonMethod)
|
||||
|
||||
@@ -131,12 +131,12 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useCanvasManager } from '@/composables/maskeditor/useCanvasManager'
|
||||
import type { useToolManager } from '@/composables/maskeditor/useToolManager'
|
||||
import type { ImageLayer } from '@/extensions/core/maskeditor/types'
|
||||
import { MaskBlendMode, Tools } from '@/extensions/core/maskeditor/types'
|
||||
import { t } from '@/i18n'
|
||||
import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
||||
|
||||
import SliderControl from './controls/SliderControl.vue'
|
||||
@@ -145,6 +145,7 @@ const { toolManager } = defineProps<{
|
||||
toolManager?: ReturnType<typeof useToolManager>
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const store = useMaskEditorStore()
|
||||
const canvasManager = useCanvasManager()
|
||||
|
||||
|
||||
@@ -27,11 +27,13 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
||||
|
||||
import SliderControl from './controls/SliderControl.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const store = useMaskEditorStore()
|
||||
|
||||
const onToleranceChange = (value: number) => {
|
||||
|
||||
@@ -31,18 +31,19 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { useToolManager } from '@/composables/maskeditor/useToolManager'
|
||||
import { iconsHtml } from '@/extensions/core/maskeditor/constants'
|
||||
import type { Tools } from '@/extensions/core/maskeditor/types'
|
||||
import { allTools } from '@/extensions/core/maskeditor/types'
|
||||
import { t } from '@/i18n'
|
||||
import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
||||
|
||||
const { toolManager } = defineProps<{
|
||||
toolManager: ReturnType<typeof useToolManager>
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const store = useMaskEditorStore()
|
||||
|
||||
const onToolSelect = (tool: Tools) => {
|
||||
|
||||
@@ -128,15 +128,16 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCanvasTools } from '@/composables/maskeditor/useCanvasTools'
|
||||
import { useCanvasTransform } from '@/composables/maskeditor/useCanvasTransform'
|
||||
import { useMaskEditorSaver } from '@/composables/maskeditor/useMaskEditorSaver'
|
||||
import { t } from '@/i18n'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
const store = useMaskEditorStore()
|
||||
const dialogStore = useDialogStore()
|
||||
const canvasTools = useCanvasTools()
|
||||
|
||||
@@ -44,6 +44,7 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const executionStore = useExecutionStore()
|
||||
const {
|
||||
totalPercent,
|
||||
|
||||
@@ -36,6 +36,7 @@ const canvasStore = useCanvasStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const settingStore = useSettingStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
const { findParentGroup } = useGraphHierarchy()
|
||||
|
||||
const { selectedItems: directlySelectedItems } = storeToRefs(canvasStore)
|
||||
|
||||
@@ -46,6 +46,7 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
const favoritedWidgetsStore = useFavoritedWidgetsStore()
|
||||
const isEditing = ref(false)
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { t } from '@/i18n'
|
||||
import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
const canvasStore = useCanvasStore()
|
||||
const keybindingStore = useKeybindingStore()
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@
|
||||
import { useResizeObserver } from '@vueuse/core'
|
||||
import { debounce } from 'es-toolkit/compat'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import HelpCenterPopups from '@/components/helpcenter/HelpCenterPopups.vue'
|
||||
import ComfyMenuButton from '@/components/sidebar/ComfyMenuButton.vue'
|
||||
@@ -66,7 +67,6 @@ import SidebarBottomPanelToggleButton from '@/components/sidebar/SidebarBottomPa
|
||||
import SidebarSettingsButton from '@/components/sidebar/SidebarSettingsButton.vue'
|
||||
import SidebarShortcutsToggleButton from '@/components/sidebar/SidebarShortcutsToggleButton.vue'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { t } from '@/i18n'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
@@ -84,6 +84,7 @@ import SidebarIcon from './SidebarIcon.vue'
|
||||
import SidebarLogoutIcon from './SidebarLogoutIcon.vue'
|
||||
import SidebarTemplatesButton from './SidebarTemplatesButton.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const settingStore = useSettingStore()
|
||||
const userStore = useUserStore()
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
</div>
|
||||
</template>
|
||||
<template #body>
|
||||
<ElectronDownloadItems v-if="isElectron()" />
|
||||
<ElectronDownloadItems v-if="isDesktop" />
|
||||
|
||||
<Divider type="dashed" class="m-2" />
|
||||
<TreeExplorer
|
||||
@@ -69,7 +69,7 @@ import type { ComfyModelDef, ModelFolder } from '@/stores/modelStore'
|
||||
import { ResourceState, useModelStore } from '@/stores/modelStore'
|
||||
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
|
||||
import type { TreeExplorerNode, TreeNode } from '@/types/treeExplorerTypes'
|
||||
import { isElectron } from '@/utils/envUtil'
|
||||
import { isDesktop } from '@/platform/distribution/types'
|
||||
import { buildTree } from '@/utils/treeUtil'
|
||||
|
||||
const modelStore = useModelStore()
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
<template>
|
||||
<Toast />
|
||||
<Toast group="billing-operation" position="top-right">
|
||||
<template #message="slotProps">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="pi pi-spin pi-spinner text-primary" />
|
||||
<span>{{ slotProps.message.summary }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</Toast>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { t } from '@/i18n'
|
||||
import type { TopbarBadge as TopbarBadgeType } from '@/types/comfy'
|
||||
|
||||
import TopbarBadge from './TopbarBadge.vue'
|
||||
@@ -31,6 +31,8 @@ withDefaults(
|
||||
}
|
||||
)
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const cloudBadge = computed<TopbarBadgeType>(() => ({
|
||||
label: t('g.beta'),
|
||||
text: 'Comfy Cloud'
|
||||
|
||||
@@ -64,10 +64,10 @@ vi.mock('@/components/common/UserAvatar.vue', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
// Mock the CurrentUserPopover component
|
||||
vi.mock('./CurrentUserPopover.vue', () => ({
|
||||
// Mock the CurrentUserPopoverLegacy component
|
||||
vi.mock('./CurrentUserPopoverLegacy.vue', () => ({
|
||||
default: {
|
||||
name: 'CurrentUserPopoverMock',
|
||||
name: 'CurrentUserPopoverLegacyMock',
|
||||
render() {
|
||||
return h('div', 'Popover Content')
|
||||
},
|
||||
|
||||
@@ -45,14 +45,16 @@
|
||||
class: 'rounded-lg w-80'
|
||||
}
|
||||
}"
|
||||
@show="onPopoverShow"
|
||||
>
|
||||
<!-- Workspace mode: workspace-aware popover (only when ready) -->
|
||||
<CurrentUserPopoverWorkspace
|
||||
v-if="teamWorkspacesEnabled && initState === 'ready'"
|
||||
ref="workspacePopoverContent"
|
||||
@close="closePopover"
|
||||
/>
|
||||
<!-- Legacy mode: original popover -->
|
||||
<CurrentUserPopover
|
||||
<CurrentUserPopoverLegacy
|
||||
v-else-if="!teamWorkspacesEnabled"
|
||||
@close="closePopover"
|
||||
/>
|
||||
@@ -75,7 +77,7 @@ import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import CurrentUserPopover from './CurrentUserPopover.vue'
|
||||
import CurrentUserPopoverLegacy from './CurrentUserPopoverLegacy.vue'
|
||||
|
||||
const CurrentUserPopoverWorkspace = defineAsyncComponent(
|
||||
() => import('./CurrentUserPopoverWorkspace.vue')
|
||||
@@ -112,8 +114,15 @@ const workspaceName = computed(() => {
|
||||
})
|
||||
|
||||
const popover = ref<InstanceType<typeof Popover> | null>(null)
|
||||
const workspacePopoverContent = ref<{
|
||||
refreshBalance: () => void
|
||||
} | null>(null)
|
||||
|
||||
const closePopover = () => {
|
||||
popover.value?.hide()
|
||||
}
|
||||
|
||||
const onPopoverShow = () => {
|
||||
workspacePopoverContent.value?.refreshBalance()
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -7,7 +7,7 @@ import { createI18n } from 'vue-i18n'
|
||||
import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
|
||||
import CurrentUserPopover from './CurrentUserPopover.vue'
|
||||
import CurrentUserPopoverLegacy from './CurrentUserPopoverLegacy.vue'
|
||||
|
||||
// Mock all firebase modules
|
||||
vi.mock('firebase/app', () => ({
|
||||
@@ -172,7 +172,7 @@ vi.mock('@/platform/cloud/subscription/components/SubscribeButton.vue', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
describe('CurrentUserPopover', () => {
|
||||
describe('CurrentUserPopoverLegacy', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockAuthStoreState.balance = {
|
||||
@@ -190,7 +190,7 @@ describe('CurrentUserPopover', () => {
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
|
||||
return mount(CurrentUserPopover, {
|
||||
return mount(CurrentUserPopoverLegacy, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
@@ -87,18 +87,26 @@
|
||||
<SubscribeButton
|
||||
v-else-if="isPersonalWorkspace"
|
||||
:fluid="false"
|
||||
:label="$t('workspaceSwitcher.subscribe')"
|
||||
:label="
|
||||
isCancelled
|
||||
? $t('subscription.resubscribe')
|
||||
: $t('workspaceSwitcher.subscribe')
|
||||
"
|
||||
size="sm"
|
||||
variant="gradient"
|
||||
/>
|
||||
<!-- Non-personal workspace: Navigate to workspace settings -->
|
||||
<!-- Non-personal workspace: Show pricing table -->
|
||||
<Button
|
||||
v-else
|
||||
variant="primary"
|
||||
size="sm"
|
||||
@click="handleOpenPlanAndCreditsSettings"
|
||||
@click="handleOpenPlansAndPricing"
|
||||
>
|
||||
{{ $t('workspaceSwitcher.subscribe') }}
|
||||
{{
|
||||
isCancelled
|
||||
? $t('subscription.resubscribe')
|
||||
: $t('workspaceSwitcher.subscribe')
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -196,18 +204,19 @@ import { storeToRefs } from 'pinia'
|
||||
import Divider from 'primevue/divider'
|
||||
import Popover from 'primevue/popover'
|
||||
import Skeleton from 'primevue/skeleton'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
|
||||
import UserAvatar from '@/components/common/UserAvatar.vue'
|
||||
import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
|
||||
import WorkspaceSwitcherPopover from '@/components/topbar/WorkspaceSwitcherPopover.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import SubscribeButton from '@/platform/cloud/subscription/components/SubscribeButton.vue'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import { useSubscriptionCredits } from '@/platform/cloud/subscription/composables/useSubscriptionCredits'
|
||||
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
@@ -233,22 +242,30 @@ const { buildDocsUrl, docsPaths } = useExternalLink()
|
||||
|
||||
const { userDisplayName, userEmail, userPhotoUrl, handleSignOut } =
|
||||
useCurrentUser()
|
||||
const authActions = useFirebaseAuthActions()
|
||||
const dialogService = useDialogService()
|
||||
const { isActiveSubscription, subscriptionStatus } = useSubscription()
|
||||
const { totalCredits, isLoadingBalance } = useSubscriptionCredits()
|
||||
const { isActiveSubscription, subscription, balance, isLoading, fetchBalance } =
|
||||
useBillingContext()
|
||||
|
||||
const isCancelled = computed(() => subscription.value?.isCancelled ?? false)
|
||||
const subscriptionDialog = useSubscriptionDialog()
|
||||
|
||||
const { locale } = useI18n()
|
||||
const isLoadingBalance = isLoading
|
||||
|
||||
const displayedCredits = computed(() => {
|
||||
if (initState.value !== 'ready') return ''
|
||||
// Only personal workspaces have subscription status from useSubscription()
|
||||
// Team workspaces don't have backend subscription data yet
|
||||
if (isPersonalWorkspace.value) {
|
||||
// Wait for subscription status to load
|
||||
if (subscriptionStatus.value === null) return ''
|
||||
return isActiveSubscription.value ? totalCredits.value : '0'
|
||||
}
|
||||
return '0'
|
||||
|
||||
// API field is named _micros but contains cents (naming inconsistency)
|
||||
const cents =
|
||||
balance.value?.effectiveBalanceMicros ?? balance.value?.amountMicros ?? 0
|
||||
return formatCreditsFromCents({
|
||||
cents,
|
||||
locale: locale.value,
|
||||
numberOptions: {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 2
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const canUpgrade = computed(() => {
|
||||
@@ -322,7 +339,9 @@ const toggleWorkspaceSwitcher = (event: MouseEvent) => {
|
||||
workspaceSwitcherPopover.value?.toggle(event)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void authActions.fetchBalance()
|
||||
})
|
||||
const refreshBalance = () => {
|
||||
void fetchBalance()
|
||||
}
|
||||
|
||||
defineExpose({ refreshBalance })
|
||||
</script>
|
||||
|
||||
@@ -37,17 +37,18 @@
|
||||
import Popover from 'primevue/popover'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import { t } from '@/i18n'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { class: className } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const { isLoggedIn, handleSignIn } = useCurrentUser()
|
||||
const { buildDocsUrl } = useExternalLink()
|
||||
const apiNodesOverviewUrl = buildDocsUrl(
|
||||
|
||||
@@ -115,7 +115,7 @@ import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workfl
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { isElectron } from '@/utils/envUtil'
|
||||
import { isDesktop } from '@/platform/distribution/types'
|
||||
import { whileMouseDown } from '@/utils/mouseDownUtil'
|
||||
|
||||
import WorkflowOverflowMenu from './WorkflowOverflowMenu.vue'
|
||||
@@ -148,8 +148,6 @@ const showOverflowArrows = ref(false)
|
||||
const leftArrowEnabled = ref(false)
|
||||
const rightArrowEnabled = ref(false)
|
||||
|
||||
const isDesktop = isElectron()
|
||||
|
||||
const workflowToOption = (workflow: ComfyWorkflow): WorkflowOption => ({
|
||||
value: workflow.path,
|
||||
workflow
|
||||
|
||||
@@ -113,24 +113,24 @@ import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useWorkspaceSwitch } from '@/platform/auth/workspace/useWorkspaceSwitch'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import type {
|
||||
SubscriptionTier,
|
||||
WorkspaceRole,
|
||||
WorkspaceType
|
||||
} from '@/platform/workspace/api/workspaceApi'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
type SubscriptionPlan = 'PRO_MONTHLY' | 'PRO_YEARLY' | null
|
||||
|
||||
interface AvailableWorkspace {
|
||||
id: string
|
||||
name: string
|
||||
type: WorkspaceType
|
||||
role: WorkspaceRole
|
||||
isSubscribed: boolean
|
||||
subscriptionPlan: SubscriptionPlan
|
||||
subscriptionPlan: string | null
|
||||
subscriptionTier: SubscriptionTier | null
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -140,7 +140,34 @@ const emit = defineEmits<{
|
||||
|
||||
const { t } = useI18n()
|
||||
const { switchWithConfirmation } = useWorkspaceSwitch()
|
||||
const { subscriptionTierName: userSubscriptionTierName } = useSubscription()
|
||||
const { subscription } = useBillingContext()
|
||||
|
||||
const tierKeyMap: Record<string, string> = {
|
||||
STANDARD: 'standard',
|
||||
CREATOR: 'creator',
|
||||
PRO: 'pro',
|
||||
FOUNDER: 'founder',
|
||||
FOUNDERS_EDITION: 'founder'
|
||||
}
|
||||
|
||||
function formatTierName(
|
||||
tier: string | null | undefined,
|
||||
isYearly: boolean
|
||||
): string {
|
||||
if (!tier) return ''
|
||||
const key = tierKeyMap[tier] ?? 'standard'
|
||||
const baseName = t(`subscription.tiers.${key}.name`)
|
||||
return isYearly
|
||||
? t('subscription.tierNameYearly', { name: baseName })
|
||||
: baseName
|
||||
}
|
||||
|
||||
const currentSubscriptionTierName = computed(() => {
|
||||
const tier = subscription.value?.tier
|
||||
if (!tier) return ''
|
||||
const isYearly = subscription.value?.duration === 'ANNUAL'
|
||||
return formatTierName(tier, isYearly)
|
||||
})
|
||||
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
const { workspaceId, workspaces, canCreateWorkspace, isFetchingWorkspaces } =
|
||||
@@ -153,7 +180,8 @@ const availableWorkspaces = computed<AvailableWorkspace[]>(() =>
|
||||
type: w.type,
|
||||
role: w.role,
|
||||
isSubscribed: w.isSubscribed,
|
||||
subscriptionPlan: w.subscriptionPlan
|
||||
subscriptionPlan: w.subscriptionPlan,
|
||||
subscriptionTier: w.subscriptionTier
|
||||
}))
|
||||
)
|
||||
|
||||
@@ -168,19 +196,32 @@ function getRoleLabel(role: AvailableWorkspace['role']): string {
|
||||
}
|
||||
|
||||
function getTierLabel(workspace: AvailableWorkspace): string | null {
|
||||
// Personal workspace: use user's subscription tier
|
||||
if (workspace.type === 'personal') {
|
||||
return userSubscriptionTierName.value || null
|
||||
// For the current/active workspace, use billing context directly
|
||||
// This ensures we always have the most up-to-date subscription info
|
||||
if (isCurrentWorkspace(workspace)) {
|
||||
return currentSubscriptionTierName.value || null
|
||||
}
|
||||
// Team workspace: use workspace subscription plan
|
||||
if (!workspace.isSubscribed || !workspace.subscriptionPlan) return null
|
||||
if (workspace.subscriptionPlan === 'PRO_MONTHLY')
|
||||
return t('subscription.tiers.pro.name')
|
||||
if (workspace.subscriptionPlan === 'PRO_YEARLY')
|
||||
return t('subscription.tierNameYearly', {
|
||||
name: t('subscription.tiers.pro.name')
|
||||
})
|
||||
return null
|
||||
|
||||
// For non-active workspaces, use cached store data
|
||||
if (!workspace.isSubscribed) return null
|
||||
|
||||
if (workspace.subscriptionTier) {
|
||||
return formatTierName(workspace.subscriptionTier, false)
|
||||
}
|
||||
|
||||
if (!workspace.subscriptionPlan) return null
|
||||
|
||||
// Parse plan slug (format: TIER_DURATION, e.g. "CREATOR_MONTHLY", "PRO_YEARLY")
|
||||
const planSlug = workspace.subscriptionPlan
|
||||
|
||||
// Extract tier from plan slug (e.g., "CREATOR_MONTHLY" -> "CREATOR")
|
||||
const tierMatch = Object.keys(tierKeyMap).find((tier) =>
|
||||
planSlug.startsWith(tier)
|
||||
)
|
||||
if (!tierMatch) return null
|
||||
|
||||
const isYearly = planSlug.includes('YEARLY') || planSlug.includes('ANNUAL')
|
||||
return formatTierName(tierMatch, isYearly)
|
||||
}
|
||||
|
||||
async function handleSelectWorkspace(workspace: AvailableWorkspace) {
|
||||
|
||||
@@ -2,11 +2,11 @@ import { FirebaseError } from 'firebase/app'
|
||||
import { AuthErrorCodes } from 'firebase/auth'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import type { ErrorRecoveryStrategy } from '@/composables/useErrorHandling'
|
||||
import { t } from '@/i18n'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
@@ -83,7 +83,7 @@ export const useFirebaseAuthActions = () => {
|
||||
)
|
||||
|
||||
const purchaseCredits = wrapWithErrorHandlingAsync(async (amount: number) => {
|
||||
const { isActiveSubscription } = useSubscription()
|
||||
const { isActiveSubscription } = useBillingContext()
|
||||
if (!isActiveSubscription.value) return
|
||||
|
||||
const response = await authStore.initiateCreditPurchase({
|
||||
|
||||
76
src/composables/billing/types.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import type { ComputedRef, Ref } from 'vue'
|
||||
|
||||
import type {
|
||||
Plan,
|
||||
PreviewSubscribeResponse,
|
||||
SubscribeResponse,
|
||||
SubscriptionDuration,
|
||||
SubscriptionTier
|
||||
} from '@/platform/workspace/api/workspaceApi'
|
||||
|
||||
export type BillingType = 'legacy' | 'workspace'
|
||||
|
||||
export interface SubscriptionInfo {
|
||||
isActive: boolean
|
||||
tier: SubscriptionTier | null
|
||||
duration: SubscriptionDuration | null
|
||||
planSlug: string | null
|
||||
renewalDate: string | null
|
||||
endDate: string | null
|
||||
isCancelled: boolean
|
||||
hasFunds: boolean
|
||||
}
|
||||
|
||||
export interface BalanceInfo {
|
||||
amountMicros: number
|
||||
currency: string
|
||||
effectiveBalanceMicros?: number
|
||||
prepaidBalanceMicros?: number
|
||||
cloudCreditBalanceMicros?: number
|
||||
}
|
||||
|
||||
export interface BillingActions {
|
||||
initialize: () => Promise<void>
|
||||
fetchStatus: () => Promise<void>
|
||||
fetchBalance: () => Promise<void>
|
||||
subscribe: (
|
||||
planSlug: string,
|
||||
returnUrl?: string,
|
||||
cancelUrl?: string
|
||||
) => Promise<SubscribeResponse | void>
|
||||
previewSubscribe: (
|
||||
planSlug: string
|
||||
) => Promise<PreviewSubscribeResponse | null>
|
||||
manageSubscription: () => Promise<void>
|
||||
cancelSubscription: () => Promise<void>
|
||||
fetchPlans: () => Promise<void>
|
||||
/**
|
||||
* Ensures billing is initialized and subscription is active.
|
||||
* Shows subscription dialog if not subscribed.
|
||||
* Use this in extensions/entry points that require active subscription.
|
||||
*/
|
||||
requireActiveSubscription: () => Promise<void>
|
||||
/**
|
||||
* Shows the subscription dialog.
|
||||
*/
|
||||
showSubscriptionDialog: () => void
|
||||
}
|
||||
|
||||
export interface BillingState {
|
||||
isInitialized: Ref<boolean>
|
||||
subscription: ComputedRef<SubscriptionInfo | null>
|
||||
balance: ComputedRef<BalanceInfo | null>
|
||||
plans: ComputedRef<Plan[]>
|
||||
currentPlanSlug: ComputedRef<string | null>
|
||||
isLoading: Ref<boolean>
|
||||
error: Ref<string | null>
|
||||
/**
|
||||
* Convenience computed for checking if subscription is active.
|
||||
* Equivalent to `subscription.value?.isActive ?? false`
|
||||
*/
|
||||
isActiveSubscription: ComputedRef<boolean>
|
||||
}
|
||||
|
||||
export interface BillingContext extends BillingState, BillingActions {
|
||||
type: ComputedRef<BillingType>
|
||||
}
|
||||
164
src/composables/billing/useBillingContext.test.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useBillingContext } from './useBillingContext'
|
||||
|
||||
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => {
|
||||
const isInPersonalWorkspace = { value: true }
|
||||
const activeWorkspace = { value: { id: 'personal-123', type: 'personal' } }
|
||||
return {
|
||||
useTeamWorkspaceStore: () => ({
|
||||
isInPersonalWorkspace: isInPersonalWorkspace.value,
|
||||
activeWorkspace: activeWorkspace.value,
|
||||
_setPersonalWorkspace: (value: boolean) => {
|
||||
isInPersonalWorkspace.value = value
|
||||
activeWorkspace.value = value
|
||||
? { id: 'personal-123', type: 'personal' }
|
||||
: { id: 'team-456', type: 'team' }
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
|
||||
useSubscription: () => ({
|
||||
isActiveSubscription: { value: true },
|
||||
subscriptionTier: { value: 'PRO' },
|
||||
subscriptionDuration: { value: 'MONTHLY' },
|
||||
formattedRenewalDate: { value: 'Jan 1, 2025' },
|
||||
formattedEndDate: { value: '' },
|
||||
isCancelled: { value: false },
|
||||
fetchStatus: vi.fn().mockResolvedValue(undefined),
|
||||
manageSubscription: vi.fn().mockResolvedValue(undefined),
|
||||
subscribe: vi.fn().mockResolvedValue(undefined),
|
||||
showSubscriptionDialog: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/platform/cloud/subscription/composables/useSubscriptionDialog',
|
||||
() => ({
|
||||
useSubscriptionDialog: () => ({
|
||||
show: vi.fn(),
|
||||
hide: vi.fn()
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@/stores/firebaseAuthStore', () => ({
|
||||
useFirebaseAuthStore: () => ({
|
||||
balance: { amount_micros: 5000000 },
|
||||
fetchBalance: vi.fn().mockResolvedValue({ amount_micros: 5000000 })
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/cloud/subscription/composables/useBillingPlans', () => {
|
||||
const plans = { value: [] }
|
||||
const currentPlanSlug = { value: null }
|
||||
return {
|
||||
useBillingPlans: () => ({
|
||||
plans,
|
||||
currentPlanSlug,
|
||||
isLoading: { value: false },
|
||||
error: { value: null },
|
||||
fetchPlans: vi.fn().mockResolvedValue(undefined),
|
||||
getPlanBySlug: vi.fn().mockReturnValue(null)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/platform/workspace/api/workspaceApi', () => ({
|
||||
workspaceApi: {
|
||||
getBillingStatus: vi.fn().mockResolvedValue({
|
||||
is_active: true,
|
||||
has_funds: true,
|
||||
subscription_tier: 'PRO',
|
||||
subscription_duration: 'MONTHLY'
|
||||
}),
|
||||
getBillingBalance: vi.fn().mockResolvedValue({
|
||||
amount_micros: 10000000,
|
||||
currency: 'usd'
|
||||
}),
|
||||
subscribe: vi.fn().mockResolvedValue({ status: 'subscribed' }),
|
||||
previewSubscribe: vi.fn().mockResolvedValue({ allowed: true })
|
||||
}
|
||||
}))
|
||||
|
||||
describe('useBillingContext', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('returns legacy type for personal workspace', () => {
|
||||
const { type } = useBillingContext()
|
||||
expect(type.value).toBe('legacy')
|
||||
})
|
||||
|
||||
it('provides subscription info from legacy billing', () => {
|
||||
const { subscription } = useBillingContext()
|
||||
|
||||
expect(subscription.value).toEqual({
|
||||
isActive: true,
|
||||
tier: 'PRO',
|
||||
duration: 'MONTHLY',
|
||||
planSlug: null,
|
||||
renewalDate: 'Jan 1, 2025',
|
||||
endDate: null,
|
||||
isCancelled: false,
|
||||
hasFunds: true
|
||||
})
|
||||
})
|
||||
|
||||
it('provides balance info from legacy billing', () => {
|
||||
const { balance } = useBillingContext()
|
||||
|
||||
expect(balance.value).toEqual({
|
||||
amountMicros: 5000000,
|
||||
currency: 'usd',
|
||||
effectiveBalanceMicros: 5000000,
|
||||
prepaidBalanceMicros: 0,
|
||||
cloudCreditBalanceMicros: 0
|
||||
})
|
||||
})
|
||||
|
||||
it('exposes initialize action', async () => {
|
||||
const { initialize } = useBillingContext()
|
||||
await expect(initialize()).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('exposes fetchStatus action', async () => {
|
||||
const { fetchStatus } = useBillingContext()
|
||||
await expect(fetchStatus()).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('exposes fetchBalance action', async () => {
|
||||
const { fetchBalance } = useBillingContext()
|
||||
await expect(fetchBalance()).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('exposes subscribe action', async () => {
|
||||
const { subscribe } = useBillingContext()
|
||||
await expect(subscribe('pro-monthly')).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('exposes manageSubscription action', async () => {
|
||||
const { manageSubscription } = useBillingContext()
|
||||
await expect(manageSubscription()).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('provides isActiveSubscription convenience computed', () => {
|
||||
const { isActiveSubscription } = useBillingContext()
|
||||
expect(isActiveSubscription.value).toBe(true)
|
||||
})
|
||||
|
||||
it('exposes requireActiveSubscription action', async () => {
|
||||
const { requireActiveSubscription } = useBillingContext()
|
||||
await expect(requireActiveSubscription()).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('exposes showSubscriptionDialog action', () => {
|
||||
const { showSubscriptionDialog } = useBillingContext()
|
||||
expect(() => showSubscriptionDialog()).not.toThrow()
|
||||
})
|
||||
})
|
||||
242
src/composables/billing/useBillingContext.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
import { computed, ref, shallowRef, toValue, watch } from 'vue'
|
||||
import { createSharedComposable } from '@vueuse/core'
|
||||
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
|
||||
import type {
|
||||
BalanceInfo,
|
||||
BillingActions,
|
||||
BillingContext,
|
||||
BillingType,
|
||||
BillingState,
|
||||
SubscriptionInfo
|
||||
} from './types'
|
||||
import { useLegacyBilling } from './useLegacyBilling'
|
||||
import { useWorkspaceBilling } from './useWorkspaceBilling'
|
||||
|
||||
/**
|
||||
* Unified billing context that automatically switches between legacy (user-scoped)
|
||||
* and workspace billing based on the active workspace type.
|
||||
*
|
||||
* - Personal workspaces use legacy billing via /customers/* endpoints
|
||||
* - Team workspaces use workspace billing via /billing/* endpoints
|
||||
*
|
||||
* The context automatically initializes when the workspace changes and provides
|
||||
* a unified interface for subscription status, balance, and billing actions.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const {
|
||||
* type,
|
||||
* subscription,
|
||||
* balance,
|
||||
* isInitialized,
|
||||
* initialize,
|
||||
* subscribe
|
||||
* } = useBillingContext()
|
||||
*
|
||||
* // Wait for initialization
|
||||
* await initialize()
|
||||
*
|
||||
* // Check subscription status
|
||||
* if (subscription.value?.isActive) {
|
||||
* console.log(`Tier: ${subscription.value.tier}`)
|
||||
* }
|
||||
*
|
||||
* // Check balance
|
||||
* if (balance.value) {
|
||||
* const dollars = balance.value.amountMicros / 1_000_000
|
||||
* console.log(`Balance: $${dollars.toFixed(2)}`)
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
function useBillingContextInternal(): BillingContext {
|
||||
const store = useTeamWorkspaceStore()
|
||||
const { flags } = useFeatureFlags()
|
||||
|
||||
const legacyBillingRef = shallowRef<(BillingState & BillingActions) | null>(
|
||||
null
|
||||
)
|
||||
const workspaceBillingRef = shallowRef<
|
||||
(BillingState & BillingActions) | null
|
||||
>(null)
|
||||
|
||||
const getLegacyBilling = () => {
|
||||
if (!legacyBillingRef.value) {
|
||||
legacyBillingRef.value = useLegacyBilling()
|
||||
}
|
||||
return legacyBillingRef.value
|
||||
}
|
||||
|
||||
const getWorkspaceBilling = () => {
|
||||
if (!workspaceBillingRef.value) {
|
||||
workspaceBillingRef.value = useWorkspaceBilling()
|
||||
}
|
||||
return workspaceBillingRef.value
|
||||
}
|
||||
|
||||
const isInitialized = ref(false)
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
/**
|
||||
* Determines which billing type to use:
|
||||
* - If team workspaces feature is disabled: always use legacy (/customers)
|
||||
* - If team workspaces feature is enabled:
|
||||
* - Personal workspace: use legacy (/customers)
|
||||
* - Team workspace: use workspace (/billing)
|
||||
*/
|
||||
const type = computed<BillingType>(() => {
|
||||
if (!flags.teamWorkspacesEnabled) return 'legacy'
|
||||
return store.isInPersonalWorkspace ? 'legacy' : 'workspace'
|
||||
})
|
||||
|
||||
const activeContext = computed(() =>
|
||||
type.value === 'legacy' ? getLegacyBilling() : getWorkspaceBilling()
|
||||
)
|
||||
|
||||
// Proxy state from active context
|
||||
const subscription = computed<SubscriptionInfo | null>(() =>
|
||||
toValue(activeContext.value.subscription)
|
||||
)
|
||||
|
||||
const balance = computed<BalanceInfo | null>(() =>
|
||||
toValue(activeContext.value.balance)
|
||||
)
|
||||
|
||||
const plans = computed(() => toValue(activeContext.value.plans))
|
||||
|
||||
const currentPlanSlug = computed(() =>
|
||||
toValue(activeContext.value.currentPlanSlug)
|
||||
)
|
||||
|
||||
const isActiveSubscription = computed(() =>
|
||||
toValue(activeContext.value.isActiveSubscription)
|
||||
)
|
||||
|
||||
// Sync subscription info to workspace store for display in workspace switcher
|
||||
// A subscription is considered "subscribed" for workspace purposes if it's active AND not cancelled
|
||||
// This ensures the delete button is enabled after cancellation, even before the period ends
|
||||
watch(
|
||||
subscription,
|
||||
(sub) => {
|
||||
if (!sub || store.isInPersonalWorkspace) return
|
||||
|
||||
store.updateActiveWorkspace({
|
||||
isSubscribed: sub.isActive && !sub.isCancelled,
|
||||
subscriptionPlan: sub.planSlug
|
||||
})
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Initialize billing when workspace changes
|
||||
watch(
|
||||
() => store.activeWorkspace?.id,
|
||||
async (newWorkspaceId, oldWorkspaceId) => {
|
||||
if (!newWorkspaceId) {
|
||||
// No workspace selected - reset state
|
||||
isInitialized.value = false
|
||||
error.value = null
|
||||
return
|
||||
}
|
||||
|
||||
if (newWorkspaceId !== oldWorkspaceId) {
|
||||
// Workspace changed - reinitialize
|
||||
isInitialized.value = false
|
||||
try {
|
||||
await initialize()
|
||||
} catch (err) {
|
||||
// Error is already captured in error ref
|
||||
console.error('Failed to initialize billing context:', err)
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
async function initialize(): Promise<void> {
|
||||
if (isInitialized.value) return
|
||||
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
await activeContext.value.initialize()
|
||||
isInitialized.value = true
|
||||
} catch (err) {
|
||||
error.value =
|
||||
err instanceof Error ? err.message : 'Failed to initialize billing'
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchStatus(): Promise<void> {
|
||||
return activeContext.value.fetchStatus()
|
||||
}
|
||||
|
||||
async function fetchBalance(): Promise<void> {
|
||||
return activeContext.value.fetchBalance()
|
||||
}
|
||||
|
||||
async function subscribe(
|
||||
planSlug: string,
|
||||
returnUrl?: string,
|
||||
cancelUrl?: string
|
||||
) {
|
||||
return activeContext.value.subscribe(planSlug, returnUrl, cancelUrl)
|
||||
}
|
||||
|
||||
async function previewSubscribe(planSlug: string) {
|
||||
return activeContext.value.previewSubscribe(planSlug)
|
||||
}
|
||||
|
||||
async function manageSubscription() {
|
||||
return activeContext.value.manageSubscription()
|
||||
}
|
||||
|
||||
async function cancelSubscription() {
|
||||
return activeContext.value.cancelSubscription()
|
||||
}
|
||||
|
||||
async function fetchPlans() {
|
||||
return activeContext.value.fetchPlans()
|
||||
}
|
||||
|
||||
async function requireActiveSubscription() {
|
||||
return activeContext.value.requireActiveSubscription()
|
||||
}
|
||||
|
||||
function showSubscriptionDialog() {
|
||||
return activeContext.value.showSubscriptionDialog()
|
||||
}
|
||||
|
||||
return {
|
||||
type,
|
||||
isInitialized,
|
||||
subscription,
|
||||
balance,
|
||||
plans,
|
||||
currentPlanSlug,
|
||||
isLoading,
|
||||
error,
|
||||
isActiveSubscription,
|
||||
|
||||
initialize,
|
||||
fetchStatus,
|
||||
fetchBalance,
|
||||
subscribe,
|
||||
previewSubscribe,
|
||||
manageSubscription,
|
||||
cancelSubscription,
|
||||
fetchPlans,
|
||||
requireActiveSubscription,
|
||||
showSubscriptionDialog
|
||||
}
|
||||
}
|
||||
|
||||
export const useBillingContext = createSharedComposable(
|
||||
useBillingContextInternal
|
||||
)
|
||||
189
src/composables/billing/useLegacyBilling.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import type {
|
||||
PreviewSubscribeResponse,
|
||||
SubscribeResponse
|
||||
} from '@/platform/workspace/api/workspaceApi'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
|
||||
import type {
|
||||
BalanceInfo,
|
||||
BillingActions,
|
||||
BillingState,
|
||||
SubscriptionInfo
|
||||
} from './types'
|
||||
|
||||
/**
|
||||
* Adapter for legacy user-scoped billing via /customers/* endpoints.
|
||||
* Used for personal workspaces.
|
||||
* @internal - Use useBillingContext() instead of importing directly.
|
||||
*/
|
||||
export function useLegacyBilling(): BillingState & BillingActions {
|
||||
const {
|
||||
isActiveSubscription: legacyIsActiveSubscription,
|
||||
subscriptionTier,
|
||||
subscriptionDuration,
|
||||
formattedRenewalDate,
|
||||
formattedEndDate,
|
||||
isCancelled,
|
||||
fetchStatus: legacyFetchStatus,
|
||||
manageSubscription: legacyManageSubscription,
|
||||
subscribe: legacySubscribe,
|
||||
showSubscriptionDialog: legacyShowSubscriptionDialog
|
||||
} = useSubscription()
|
||||
|
||||
const firebaseAuthStore = useFirebaseAuthStore()
|
||||
|
||||
const isInitialized = ref(false)
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const isActiveSubscription = computed(() => legacyIsActiveSubscription.value)
|
||||
|
||||
const subscription = computed<SubscriptionInfo | null>(() => {
|
||||
if (!legacyIsActiveSubscription.value && !subscriptionTier.value) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
isActive: legacyIsActiveSubscription.value,
|
||||
tier: subscriptionTier.value,
|
||||
duration: subscriptionDuration.value,
|
||||
planSlug: null, // Legacy doesn't use plan slugs
|
||||
renewalDate: formattedRenewalDate.value || null,
|
||||
endDate: formattedEndDate.value || null,
|
||||
isCancelled: isCancelled.value,
|
||||
hasFunds: (firebaseAuthStore.balance?.amount_micros ?? 0) > 0
|
||||
}
|
||||
})
|
||||
|
||||
const balance = computed<BalanceInfo | null>(() => {
|
||||
const legacyBalance = firebaseAuthStore.balance
|
||||
if (!legacyBalance) return null
|
||||
|
||||
return {
|
||||
amountMicros: legacyBalance.amount_micros ?? 0,
|
||||
currency: legacyBalance.currency ?? 'usd',
|
||||
effectiveBalanceMicros:
|
||||
legacyBalance.effective_balance_micros ??
|
||||
legacyBalance.amount_micros ??
|
||||
0,
|
||||
prepaidBalanceMicros: legacyBalance.prepaid_balance_micros ?? 0,
|
||||
cloudCreditBalanceMicros: legacyBalance.cloud_credit_balance_micros ?? 0
|
||||
}
|
||||
})
|
||||
|
||||
// Legacy billing doesn't have workspace-style plans
|
||||
const plans = computed(() => [])
|
||||
const currentPlanSlug = computed(() => null)
|
||||
|
||||
async function initialize(): Promise<void> {
|
||||
if (isInitialized.value) return
|
||||
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
await Promise.all([fetchStatus(), fetchBalance()])
|
||||
isInitialized.value = true
|
||||
} catch (err) {
|
||||
error.value =
|
||||
err instanceof Error ? err.message : 'Failed to initialize billing'
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchStatus(): Promise<void> {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
await legacyFetchStatus()
|
||||
} catch (err) {
|
||||
error.value =
|
||||
err instanceof Error ? err.message : 'Failed to fetch subscription'
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchBalance(): Promise<void> {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
await firebaseAuthStore.fetchBalance()
|
||||
} catch (err) {
|
||||
error.value =
|
||||
err instanceof Error ? err.message : 'Failed to fetch balance'
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function subscribe(
|
||||
_planSlug: string,
|
||||
_returnUrl?: string,
|
||||
_cancelUrl?: string
|
||||
): Promise<SubscribeResponse | void> {
|
||||
// Legacy billing uses Stripe checkout flow via useSubscription
|
||||
await legacySubscribe()
|
||||
}
|
||||
|
||||
async function previewSubscribe(
|
||||
_planSlug: string
|
||||
): Promise<PreviewSubscribeResponse | null> {
|
||||
// Legacy billing doesn't support preview - returns null
|
||||
return null
|
||||
}
|
||||
|
||||
async function manageSubscription(): Promise<void> {
|
||||
await legacyManageSubscription()
|
||||
}
|
||||
|
||||
async function cancelSubscription(): Promise<void> {
|
||||
await legacyManageSubscription()
|
||||
}
|
||||
|
||||
async function fetchPlans(): Promise<void> {
|
||||
// Legacy billing doesn't have workspace-style plans
|
||||
// Plans are hardcoded in the UI for legacy subscriptions
|
||||
}
|
||||
|
||||
async function requireActiveSubscription(): Promise<void> {
|
||||
await fetchStatus()
|
||||
if (!isActiveSubscription.value) {
|
||||
legacyShowSubscriptionDialog()
|
||||
}
|
||||
}
|
||||
|
||||
function showSubscriptionDialog(): void {
|
||||
legacyShowSubscriptionDialog()
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
isInitialized,
|
||||
subscription,
|
||||
balance,
|
||||
plans,
|
||||
currentPlanSlug,
|
||||
isLoading,
|
||||
error,
|
||||
isActiveSubscription,
|
||||
|
||||
// Actions
|
||||
initialize,
|
||||
fetchStatus,
|
||||
fetchBalance,
|
||||
subscribe,
|
||||
previewSubscribe,
|
||||
manageSubscription,
|
||||
cancelSubscription,
|
||||
fetchPlans,
|
||||
requireActiveSubscription,
|
||||
showSubscriptionDialog
|
||||
}
|
||||
}
|
||||
311
src/composables/billing/useWorkspaceBilling.ts
Normal file
@@ -0,0 +1,311 @@
|
||||
import { computed, onBeforeUnmount, ref, shallowRef } from 'vue'
|
||||
|
||||
import { useBillingPlans } from '@/platform/cloud/subscription/composables/useBillingPlans'
|
||||
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
import type {
|
||||
BillingBalanceResponse,
|
||||
BillingStatusResponse,
|
||||
PreviewSubscribeResponse,
|
||||
SubscribeResponse
|
||||
} from '@/platform/workspace/api/workspaceApi'
|
||||
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
|
||||
import type {
|
||||
BalanceInfo,
|
||||
BillingActions,
|
||||
BillingState,
|
||||
SubscriptionInfo
|
||||
} from './types'
|
||||
|
||||
/**
|
||||
* Adapter for workspace-scoped billing via /billing/* endpoints.
|
||||
* Used for team workspaces.
|
||||
* @internal - Use useBillingContext() instead of importing directly.
|
||||
*/
|
||||
export function useWorkspaceBilling(): BillingState & BillingActions {
|
||||
const billingPlans = useBillingPlans()
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
|
||||
const isInitialized = ref(false)
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const statusData = shallowRef<BillingStatusResponse | null>(null)
|
||||
const balanceData = shallowRef<BillingBalanceResponse | null>(null)
|
||||
|
||||
const isActiveSubscription = computed(
|
||||
() => statusData.value?.is_active ?? false
|
||||
)
|
||||
|
||||
const subscription = computed<SubscriptionInfo | null>(() => {
|
||||
const status = statusData.value
|
||||
if (!status) return null
|
||||
|
||||
return {
|
||||
isActive: status.is_active,
|
||||
tier: status.subscription_tier ?? null,
|
||||
duration: status.subscription_duration ?? null,
|
||||
planSlug: status.plan_slug ?? null,
|
||||
renewalDate: status.renewal_date ?? null,
|
||||
endDate: status.cancel_at ?? null,
|
||||
isCancelled: status.subscription_status === 'canceled',
|
||||
hasFunds: status.has_funds
|
||||
}
|
||||
})
|
||||
|
||||
const balance = computed<BalanceInfo | null>(() => {
|
||||
const data = balanceData.value
|
||||
if (!data) return null
|
||||
|
||||
return {
|
||||
amountMicros: data.amount_micros,
|
||||
currency: data.currency,
|
||||
effectiveBalanceMicros: data.effective_balance_micros,
|
||||
prepaidBalanceMicros: data.prepaid_balance_micros,
|
||||
cloudCreditBalanceMicros: data.cloud_credit_balance_micros
|
||||
}
|
||||
})
|
||||
|
||||
const plans = computed(() => billingPlans.plans.value)
|
||||
const currentPlanSlug = computed(
|
||||
() => statusData.value?.plan_slug ?? billingPlans.currentPlanSlug.value
|
||||
)
|
||||
|
||||
const pendingCancelOpId = ref<string | null>(null)
|
||||
let cancelPollTimeout: number | null = null
|
||||
|
||||
const stopCancelPolling = () => {
|
||||
if (cancelPollTimeout !== null) {
|
||||
window.clearTimeout(cancelPollTimeout)
|
||||
cancelPollTimeout = null
|
||||
}
|
||||
}
|
||||
|
||||
async function pollCancelStatus(opId: string): Promise<void> {
|
||||
stopCancelPolling()
|
||||
|
||||
const maxAttempts = 30
|
||||
let attempt = 0
|
||||
const poll = async () => {
|
||||
if (pendingCancelOpId.value !== opId) return
|
||||
|
||||
try {
|
||||
const response = await workspaceApi.getBillingOpStatus(opId)
|
||||
if (response.status === 'succeeded') {
|
||||
pendingCancelOpId.value = null
|
||||
stopCancelPolling()
|
||||
await fetchStatus()
|
||||
workspaceStore.updateActiveWorkspace({
|
||||
isSubscribed: false
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (response.status === 'failed') {
|
||||
pendingCancelOpId.value = null
|
||||
stopCancelPolling()
|
||||
throw new Error(
|
||||
response.error_message ?? 'Failed to cancel subscription'
|
||||
)
|
||||
}
|
||||
|
||||
attempt += 1
|
||||
if (attempt >= maxAttempts) {
|
||||
pendingCancelOpId.value = null
|
||||
stopCancelPolling()
|
||||
await fetchStatus()
|
||||
return
|
||||
}
|
||||
} catch (err) {
|
||||
pendingCancelOpId.value = null
|
||||
stopCancelPolling()
|
||||
throw err
|
||||
}
|
||||
|
||||
cancelPollTimeout = window.setTimeout(
|
||||
() => {
|
||||
void poll()
|
||||
},
|
||||
Math.min(1000 * 2 ** attempt, 5000)
|
||||
)
|
||||
}
|
||||
|
||||
await poll()
|
||||
}
|
||||
|
||||
async function initialize(): Promise<void> {
|
||||
if (isInitialized.value) return
|
||||
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
await Promise.all([fetchStatus(), fetchBalance(), fetchPlans()])
|
||||
isInitialized.value = true
|
||||
} catch (err) {
|
||||
error.value =
|
||||
err instanceof Error ? err.message : 'Failed to initialize billing'
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchStatus(): Promise<void> {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
statusData.value = await workspaceApi.getBillingStatus()
|
||||
} catch (err) {
|
||||
error.value =
|
||||
err instanceof Error ? err.message : 'Failed to fetch billing status'
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchBalance(): Promise<void> {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
balanceData.value = await workspaceApi.getBillingBalance()
|
||||
} catch (err) {
|
||||
error.value =
|
||||
err instanceof Error ? err.message : 'Failed to fetch balance'
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function subscribe(
|
||||
planSlug: string,
|
||||
returnUrl?: string,
|
||||
cancelUrl?: string
|
||||
): Promise<SubscribeResponse> {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const response = await workspaceApi.subscribe(
|
||||
planSlug,
|
||||
returnUrl,
|
||||
cancelUrl
|
||||
)
|
||||
|
||||
// Refresh status and balance after subscription
|
||||
await Promise.all([fetchStatus(), fetchBalance()])
|
||||
|
||||
return response
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to subscribe'
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function previewSubscribe(
|
||||
planSlug: string
|
||||
): Promise<PreviewSubscribeResponse | null> {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
return await workspaceApi.previewSubscribe(planSlug)
|
||||
} catch (err) {
|
||||
error.value =
|
||||
err instanceof Error ? err.message : 'Failed to preview subscription'
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function manageSubscription(): Promise<void> {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const returnUrl = window.location.href
|
||||
const response = await workspaceApi.getPaymentPortalUrl(returnUrl)
|
||||
if (response.url) {
|
||||
window.open(response.url, '_blank')
|
||||
}
|
||||
} catch (err) {
|
||||
error.value =
|
||||
err instanceof Error ? err.message : 'Failed to open billing portal'
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelSubscription(): Promise<void> {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const response = await workspaceApi.cancelSubscription()
|
||||
pendingCancelOpId.value = response.billing_op_id
|
||||
await pollCancelStatus(response.billing_op_id)
|
||||
} catch (err) {
|
||||
error.value =
|
||||
err instanceof Error ? err.message : 'Failed to cancel subscription'
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchPlans(): Promise<void> {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
await billingPlans.fetchPlans()
|
||||
if (billingPlans.error.value) {
|
||||
error.value = billingPlans.error.value
|
||||
}
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const subscriptionDialog = useSubscriptionDialog()
|
||||
|
||||
async function requireActiveSubscription(): Promise<void> {
|
||||
await fetchStatus()
|
||||
if (!isActiveSubscription.value) {
|
||||
subscriptionDialog.show()
|
||||
}
|
||||
}
|
||||
|
||||
function showSubscriptionDialog(): void {
|
||||
subscriptionDialog.show()
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopCancelPolling()
|
||||
})
|
||||
|
||||
return {
|
||||
// State
|
||||
isInitialized,
|
||||
subscription,
|
||||
balance,
|
||||
plans,
|
||||
currentPlanSlug,
|
||||
isLoading,
|
||||
error,
|
||||
isActiveSubscription,
|
||||
|
||||
// Actions
|
||||
initialize,
|
||||
fetchStatus,
|
||||
fetchBalance,
|
||||
subscribe,
|
||||
previewSubscribe,
|
||||
manageSubscription,
|
||||
cancelSubscription,
|
||||
fetchPlans,
|
||||
requireActiveSubscription,
|
||||
showSubscriptionDialog
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,13 @@
|
||||
import { markRaw } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import LogsTerminal from '@/components/bottomPanel/tabs/terminal/LogsTerminal.vue'
|
||||
import CommandTerminal from '@/components/bottomPanel/tabs/terminal/CommandTerminal.vue'
|
||||
import type { BottomPanelExtension } from '@/types/extensionTypes'
|
||||
|
||||
export function useLogsTerminalTab(): BottomPanelExtension {
|
||||
const { t } = useI18n()
|
||||
return {
|
||||
id: 'logs-terminal',
|
||||
title: t('g.logs'),
|
||||
title: 'Logs',
|
||||
titleKey: 'g.logs',
|
||||
component: markRaw(LogsTerminal),
|
||||
type: 'vue'
|
||||
@@ -17,10 +15,9 @@ export function useLogsTerminalTab(): BottomPanelExtension {
|
||||
}
|
||||
|
||||
export function useCommandTerminalTab(): BottomPanelExtension {
|
||||
const { t } = useI18n()
|
||||
return {
|
||||
id: 'command-terminal',
|
||||
title: t('g.terminal'),
|
||||
title: 'Terminal',
|
||||
titleKey: 'g.terminal',
|
||||
component: markRaw(CommandTerminal),
|
||||
type: 'vue'
|
||||
|
||||
@@ -134,6 +134,25 @@ vi.mock('@/stores/executionStore', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
let jobPreviewStoreMock: {
|
||||
previewsByPromptId: Record<string, string>
|
||||
isPreviewEnabled: boolean
|
||||
}
|
||||
const ensureJobPreviewStore = () => {
|
||||
if (!jobPreviewStoreMock) {
|
||||
jobPreviewStoreMock = reactive({
|
||||
previewsByPromptId: {} as Record<string, string>,
|
||||
isPreviewEnabled: true
|
||||
})
|
||||
}
|
||||
return jobPreviewStoreMock
|
||||
}
|
||||
vi.mock('@/stores/jobPreviewStore', () => ({
|
||||
useJobPreviewStore: () => {
|
||||
return ensureJobPreviewStore()
|
||||
}
|
||||
}))
|
||||
|
||||
let workflowStoreMock: {
|
||||
activeWorkflow: null | { activeState?: { id?: string } }
|
||||
}
|
||||
@@ -186,6 +205,10 @@ const resetStores = () => {
|
||||
executionStore.activePromptId = null
|
||||
executionStore.executingNode = null
|
||||
|
||||
const jobPreviewStore = ensureJobPreviewStore()
|
||||
jobPreviewStore.previewsByPromptId = {}
|
||||
jobPreviewStore.isPreviewEnabled = true
|
||||
|
||||
const workflowStore = ensureWorkflowStore()
|
||||
workflowStore.activeWorkflow = null
|
||||
|
||||
@@ -437,6 +460,44 @@ describe('useJobList', () => {
|
||||
expect(otherJob.computeHours).toBeCloseTo(1)
|
||||
})
|
||||
|
||||
it('assigns preview urls for running jobs when previews enabled', async () => {
|
||||
queueStoreMock.runningTasks = [
|
||||
createTask({
|
||||
promptId: 'live-preview',
|
||||
queueIndex: 1,
|
||||
mockState: 'running'
|
||||
})
|
||||
]
|
||||
jobPreviewStoreMock.previewsByPromptId = {
|
||||
'live-preview': 'blob:preview-url'
|
||||
}
|
||||
jobPreviewStoreMock.isPreviewEnabled = true
|
||||
|
||||
const { jobItems } = initComposable()
|
||||
await flush()
|
||||
|
||||
expect(jobItems.value[0].iconImageUrl).toBe('blob:preview-url')
|
||||
})
|
||||
|
||||
it('omits preview urls when previews are disabled', async () => {
|
||||
queueStoreMock.runningTasks = [
|
||||
createTask({
|
||||
promptId: 'disabled-preview',
|
||||
queueIndex: 1,
|
||||
mockState: 'running'
|
||||
})
|
||||
]
|
||||
jobPreviewStoreMock.previewsByPromptId = {
|
||||
'disabled-preview': 'blob:preview-url'
|
||||
}
|
||||
jobPreviewStoreMock.isPreviewEnabled = false
|
||||
|
||||
const { jobItems } = initComposable()
|
||||
await flush()
|
||||
|
||||
expect(jobItems.value[0].iconImageUrl).toBeUndefined()
|
||||
})
|
||||
|
||||
it('derives current node name from execution store fallbacks', async () => {
|
||||
const instance = initComposable()
|
||||
await flush()
|
||||
|
||||
@@ -7,6 +7,7 @@ import { st } from '@/i18n'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useJobPreviewStore } from '@/stores/jobPreviewStore'
|
||||
import { useQueueStore } from '@/stores/queueStore'
|
||||
import type { TaskItemImpl } from '@/stores/queueStore'
|
||||
import type { JobState } from '@/types/queue'
|
||||
@@ -96,6 +97,7 @@ export function useJobList() {
|
||||
const { t, locale } = useI18n()
|
||||
const queueStore = useQueueStore()
|
||||
const executionStore = useExecutionStore()
|
||||
const jobPreviewStore = useJobPreviewStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
|
||||
const seenPendingIds = ref<Set<string>>(new Set())
|
||||
@@ -256,6 +258,11 @@ export function useJobList() {
|
||||
String(task.promptId ?? '') ===
|
||||
String(executionStore.activePromptId ?? '')
|
||||
const showAddedHint = shouldShowAddedHint(task, state)
|
||||
const promptKey = taskIdToKey(task.promptId)
|
||||
const promptPreviewUrl =
|
||||
state === 'running' && jobPreviewStore.isPreviewEnabled && promptKey
|
||||
? jobPreviewStore.previewsByPromptId[promptKey]
|
||||
: undefined
|
||||
|
||||
const display = buildJobDisplay(task, state, {
|
||||
t,
|
||||
@@ -275,7 +282,7 @@ export function useJobList() {
|
||||
meta: display.secondary,
|
||||
state,
|
||||
iconName: display.iconName,
|
||||
iconImageUrl: display.iconImageUrl,
|
||||
iconImageUrl: promptPreviewUrl ?? display.iconImageUrl,
|
||||
showClear: display.showClear,
|
||||
taskRef: task,
|
||||
progressTotalPercent:
|
||||
|
||||