Compare commits

..

4 Commits

Author SHA1 Message Date
jaeone94
521fed4fe9 test: assert missing media absence without errors tab 2026-05-04 20:44:16 +09:00
jaeone94
4afe0baa72 test: add public input missing media regression 2026-05-04 18:14:08 +09:00
jaeone94
f508a060a0 refactor: extract asset hash verification 2026-05-04 17:55:20 +09:00
jaeone94
5f43461623 refactor: align asset pagination schema 2026-05-04 17:42:37 +09:00
78 changed files with 1455 additions and 4026 deletions

View File

@@ -69,50 +69,6 @@ test.describe('Homepage @smoke', () => {
).toBeVisible()
})
test('CaseStudySpotlight CTA sizes to its content, not the column', async ({
page
}) => {
const contentColumn = page.getByTestId('case-study-content')
const cta = contentColumn.getByRole('link', {
name: /see all case studies/i
})
await cta.scrollIntoViewIfNeeded()
await expect(cta).toBeVisible()
const [columnBox, ctaBox] = await Promise.all([
contentColumn.boundingBox(),
cta.boundingBox()
])
expect(columnBox).not.toBeNull()
expect(ctaBox).not.toBeNull()
expect(ctaBox!.width).toBeLessThan(columnBox!.width * 0.7)
})
test('CaseStudySpotlight CTA has breathing room above it on mobile @mobile', async ({
page
}) => {
const contentColumn = page.getByTestId('case-study-content')
const subheading = contentColumn.getByText(
/Videos & case studies from teams/i
)
const cta = contentColumn.getByRole('link', {
name: /see all case studies/i
})
await cta.scrollIntoViewIfNeeded()
const [subBox, ctaBox] = await Promise.all([
subheading.boundingBox(),
cta.boundingBox()
])
expect(subBox).not.toBeNull()
expect(ctaBox).not.toBeNull()
expect(ctaBox!.y - (subBox!.y + subBox!.height)).toBeGreaterThanOrEqual(24)
})
test('BuildWhatSection is visible', async ({ page }) => {
// "DOESN'T EXIST" is the actual badge text rendered in the Build What section
await expect(page.getByText("DOESN'T EXIST")).toBeVisible()

View File

@@ -1,83 +0,0 @@
# Website Scripts
## `refresh-ashby-snapshot.ts`
Pulls the latest job postings from Ashby and writes
`src/data/ashby-roles.snapshot.json`. Invoked by the `Release: Website`
GitHub Actions workflow; also runnable locally via
`pnpm --filter @comfyorg/website ashby:refresh-snapshot`.
## `process-videos.sh`
Generates multi-resolution VP9/WebM + H.264/MP4 variants and a poster
frame for marketing videos using `ffmpeg`. Run **locally** before
uploading the outputs to `media.comfy.org`; this is not wired into CI.
```sh
apps/website/scripts/process-videos.sh \
./video-sources \
./dist/videos \
"640 960 1280 1920"
```
### Output
For each source video at `./video-sources/foo.mp4`, you get:
```text
foo-640.webm foo-640.mp4
foo-960.webm foo-960.mp4
foo-1280.webm foo-1280.mp4
foo-1920.webm foo-1920.mp4
foo-poster.jpg
```
The naming convention is enforced by `buildVideoSources()` in
`src/utils/video.ts`, which the `<SiteVideo>` Vue component uses to
emit `<source>` URLs.
### Pairing with `<SiteVideo>`
Once the assets are uploaded, render them with:
```vue
<SiteVideo
name="foo"
base-url="https://media.comfy.org/website/marketing"
:width="1280"
:formats="['webm', 'mp4']"
poster="https://media.comfy.org/website/marketing/foo-poster.jpg"
autoplay
loop
/>
```
### `<SiteVideo>` vs `<VideoPlayer>`
- **`SiteVideo`** — lightweight multi-source `<video>` for decorative or
autoplay marketing clips. No custom controls, no captions UI.
- **`VideoPlayer`** — full-featured player with custom scrubber, mute,
fullscreen, and caption toggles. Use this for content with subtitles or
user-driven playback.
If you need both responsive sources and the rich `VideoPlayer` chrome, the
two are not yet combined; either pick one or extend `VideoPlayer` to accept
a source list.
### Encoder choices
- **VP9/WebM** at CRF 32 — preferred by Chrome and Firefox; smaller files.
- **H.264/MP4** at CRF 23, High profile, `+faststart` — universal fallback,
required for Safari iOS.
- **Poster JPG** at q4 — extracted from t=1s when the clip is long enough,
otherwise t=0; scaled to 1280w. Use this as the `poster` attribute so
the video shows something while loading.
### Why a single resolution per video
`<source media="...">` inside `<video>` is unreliable across browsers
(Safari ignores it). The simplest correct strategy is to ship one
well-sized resolution and let CSS scale it down on smaller viewports.
The script generates multiple widths so you can pick a different one
per page (e.g. 1280w for a hero, 640w for a thumbnail), or wire up
JavaScript-based selection later if metrics demand it.

View File

@@ -1,110 +0,0 @@
#!/usr/bin/env bash
#
# Generate multi-resolution VP9/WebM + H.264/MP4 variants and a poster frame
# for every source video in a given directory. Intended to be run locally
# before uploading the outputs to media.comfy.org.
#
# Usage:
# apps/website/scripts/process-videos.sh <input-dir> <output-dir> [widths]
#
# Example:
# apps/website/scripts/process-videos.sh \
# ./video-sources \
# ./dist/videos \
# "640 960 1280 1920"
#
# Defaults to widths "1280" if omitted.
#
# Output naming matches buildVideoSources() in src/utils/video.ts:
# <name>-<width>.webm
# <name>-<width>.mp4
# <name>-poster.jpg (single 1280w poster, suitable for SiteVideo)
#
# Requires ffmpeg and ffprobe on PATH. Tested with ffmpeg 6.x and 7.x.
set -euo pipefail
if [[ $# -lt 2 ]]; then
cat <<USAGE >&2
Usage: $0 <input-dir> <output-dir> [widths]
widths: space-separated list, e.g. "640 1280 1920" (default: "1280")
USAGE
exit 64
fi
input_dir=$1
output_dir=$2
widths=${3:-1280}
for tool in ffmpeg ffprobe; do
if ! command -v "$tool" >/dev/null 2>&1; then
echo "error: $tool not found on PATH" >&2
exit 127
fi
done
if [[ ! -d $input_dir ]]; then
echo "error: input dir not found: $input_dir" >&2
exit 66
fi
mkdir -p "$output_dir"
shopt -s nullglob nocaseglob
sources=("$input_dir"/*.{mp4,mov,webm,mkv})
shopt -u nullglob nocaseglob
if [[ ${#sources[@]} -eq 0 ]]; then
echo "error: no source videos in $input_dir (looked for .mp4 .mov .webm .mkv)" >&2
exit 66
fi
for src in "${sources[@]}"; do
name=$(basename "$src")
name=${name%.*}
echo "==> $name"
for w in $widths; do
webm_out="$output_dir/${name}-${w}.webm"
mp4_out="$output_dir/${name}-${w}.mp4"
echo " encoding ${w}w VP9/WebM -> $webm_out"
ffmpeg -y -hide_banner -loglevel error \
-i "$src" \
-vf "scale=${w}:-2:flags=lanczos" \
-c:v libvpx-vp9 -b:v 0 -crf 32 -row-mt 1 -tile-columns 2 \
-c:a libopus -b:a 96k \
-f webm "$webm_out"
echo " encoding ${w}w H.264/MP4 -> $mp4_out"
ffmpeg -y -hide_banner -loglevel error \
-i "$src" \
-vf "scale=${w}:-2:flags=lanczos" \
-c:v libx264 -crf 23 -preset slow -profile:v high -pix_fmt yuv420p \
-c:a aac -b:a 128k \
-movflags +faststart \
"$mp4_out"
done
poster_out="$output_dir/${name}-poster.jpg"
duration_raw=$(ffprobe -v error -show_entries format=duration \
-of default=noprint_wrappers=1:nokey=1 "$src" 2>/dev/null || true)
if [[ $duration_raw =~ ^[0-9]+([.][0-9]+)?$ ]]; then
duration="$duration_raw"
else
duration=0
fi
if awk -v d="$duration" 'BEGIN { exit !(d >= 1.0) }'; then
poster_seek=1
else
poster_seek=0
fi
echo " extracting poster (t=${poster_seek}s) -> $poster_out"
ffmpeg -y -hide_banner -loglevel error \
-ss "$poster_seek" -i "$src" \
-vframes 1 -vf "scale=1280:-2:flags=lanczos" \
-q:v 4 \
"$poster_out"
done
echo "done. upload contents of $output_dir to media.comfy.org."

View File

@@ -1,51 +0,0 @@
# Marketing Assets
Source images committed here are processed by Astro at build time and emitted
as multiple formats (AVIF, WebP) at multiple widths (640w, 960w, 1280w, 1920w).
## Usage
Drop a high-resolution source image (PNG or JPG) here, then render it with
Astro's built-in `<Picture>` component plus the shared defaults:
```astro
---
import { Picture } from 'astro:assets'
import {
MARKETING_FORMATS,
MARKETING_WIDTHS
} from '../utils/marketingImage'
import hero from '../assets/marketing/hero.png'
---
<Picture
src={hero}
alt="ComfyUI workflow preview"
formats={[...MARKETING_FORMATS]}
widths={[...MARKETING_WIDTHS]}
sizes="(max-width: 768px) 100vw, 50vw"
/>
```
The component generates a `<picture>` element with `<source>` tags for AVIF
and WebP, plus an `<img>` fallback. Output files are hashed and emitted under
`dist/_website/` for long-term caching.
A custom Astro wrapper component is intentionally not provided: Astro's
discriminated union `LocalImageProps | RemoteImageProps` for `<Picture>` makes
a thin wrapper that mutates `widths` / `formats` impractical to type safely
without `as` casts. The shared constants give us the same consistency benefit
without that cost.
## When to use this vs. `media.comfy.org`
- **Use `src/assets/marketing/`** for static marketing images that are part of
page content (hero shots, product imagery, illustrations). Build-time
processing gives you AVIF/WebP variants automatically.
- **Use `media.comfy.org`** for video content, large/changing image libraries
(gallery), and anything shared across properties.
## Source image guidelines
- Provide the largest size you'll ever need (≥1920px wide).
- PNG for screenshots/illustrations with sharp edges; JPG for photographs.
- Astro will downscale; it will not upscale. Always supply at least 1920w.

View File

@@ -1,68 +0,0 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { computed } from 'vue'
import { buildVideoSources, videoKey } from '../../utils/video'
import type { VideoFormat } from '../../utils/video'
const {
name,
baseUrl,
width = 1280,
formats = ['webm', 'mp4'],
poster,
alt,
autoplay = false,
loop = false,
muted = autoplay,
controls = false,
preload = autoplay ? 'auto' : 'metadata',
containerClass,
videoClass
} = defineProps<{
name: string
baseUrl: string
width?: number
formats?: VideoFormat[]
poster?: string
alt?: string
autoplay?: boolean
loop?: boolean
muted?: boolean
controls?: boolean
preload?: 'auto' | 'metadata' | 'none'
containerClass?: string
videoClass?: string
}>()
const sources = computed(() =>
buildVideoSources({ name, baseUrl, width, formats })
)
const remountKey = computed(() => videoKey(sources.value))
const decorative = computed(() => !alt && !controls)
</script>
<template>
<div :class="cn('relative', containerClass)">
<video
:key="remountKey"
:class="cn('size-full', videoClass)"
:poster
:preload
:autoplay
:loop
:muted
:controls
:aria-label="alt"
:aria-hidden="decorative ? true : undefined"
playsinline
>
<source
v-for="source in sources"
:key="source.src"
:src="source.src"
:type="source.type"
/>
</video>
</div>
</template>

View File

@@ -35,10 +35,7 @@ const routes = getRoutes(locale)
</div>
<!-- Right: content -->
<div
data-testid="case-study-content"
class="flex flex-col justify-between p-6 lg:flex-1"
>
<div class="flex flex-col justify-between p-6 lg:flex-1">
<div class="flex flex-col gap-8">
<p
class="text-primary-comfy-yellow text-sm font-bold tracking-widest uppercase"
@@ -55,8 +52,12 @@ const routes = getRoutes(locale)
</p>
</div>
<div class="mt-8 flex flex-col items-start gap-3 sm:flex-row lg:mt-0">
<BrandButton :href="routes.customers" variant="outline">
<div class="flex flex-col gap-3 sm:flex-row">
<BrandButton
:href="routes.customers"
variant="outline"
class="flex-1 text-center"
>
{{ t('caseStudy.seeAll', locale) }}
</BrandButton>
</div>

View File

@@ -101,9 +101,17 @@ const features: IncludedFeature[] = [
class="mt-0.5 size-4 shrink-0"
aria-hidden="true"
/>
<p class="text-primary-comfy-canvas text-sm font-medium">
{{ t(feature.titleKey, locale) }}
</p>
<div>
<p class="text-primary-comfy-canvas text-sm font-medium">
{{ t(feature.titleKey, locale) }}
</p>
<span
v-if="feature.isComingSoon"
class="text-primary-comfy-yellow mt-1 inline-block text-xs"
>
{{ t('pricing.included.comingSoon', locale) }}
</span>
</div>
</div>
<!-- Description -->

View File

@@ -914,9 +914,9 @@ const translations = {
'zh-CN': '我应该选择 Comfy Cloud 还是本地 ComfyUI自托管'
},
'cloud.faq.3.a': {
en: "Comfy Cloud has zero setup, is easy to share with your team, and is faster than most GPUs you can run on a desktop workstation. You can immediately run the best models and workflows from the community on Comfy Cloud.\nLocal ComfyUI is infinitely customizable, works offline, and you don't need to worry about queue times. However, depending on what you want to create, you might need to have a good GPU and some amount of technical knowledge to install community-created custom nodes.",
en: "Comfy Cloud (beta) has zero setup, is easy to share with your team, and is faster than most GPUs you can run on a desktop workstation. You can immediately run the best models and workflows from the community on Comfy Cloud.\nLocal ComfyUI is infinitely customizable, works offline, and you don't need to worry about queue times. However, depending on what you want to create, you might need to have a good GPU and some amount of technical knowledge to install community-created custom nodes.",
'zh-CN':
'Comfy Cloud 无需任何设置,方便与团队共享,比大多数桌面工作站 GPU 更快。您可以立即在 Comfy Cloud 上运行社区中最好的模型和工作流。\n本地 ComfyUI 可以无限定制,支持离线工作,无需担心排队时间。但根据您的创作需求,可能需要一块好的 GPU 以及一定的技术知识来安装社区创建的自定义节点。'
'Comfy Cloud(测试版)无需任何设置,方便与团队共享,比大多数桌面工作站 GPU 更快。您可以立即在 Comfy Cloud 上运行社区中最好的模型和工作流。\n本地 ComfyUI 可以无限定制,支持离线工作,无需担心排队时间。但根据您的创作需求,可能需要一块好的 GPU 以及一定的技术知识来安装社区创建的自定义节点。'
},
'cloud.faq.4.q': {
en: 'Do I need a GPU or a strong computer to use Comfy Cloud?',
@@ -1280,6 +1280,10 @@ const translations = {
en: 'Run multiple workflows in parallel to speed up your pipeline.',
'zh-CN': '并行运行多个工作流,加速你的流程。'
},
'pricing.included.comingSoon': {
en: 'coming soon',
'zh-CN': '即将推出'
},
// VideoPlayer
'player.play': { en: 'Play', 'zh-CN': '播放' },

View File

@@ -1,3 +0,0 @@
export const MARKETING_FORMATS = ['avif', 'webp'] as const
export const MARKETING_WIDTHS = [640, 960, 1280, 1920] as const

View File

@@ -1,111 +0,0 @@
import { describe, expect, it } from 'vitest'
import { buildVideoSources, videoKey } from './video'
describe('buildVideoSources', () => {
it('builds a source per requested format', () => {
const sources = buildVideoSources({
name: 'hero',
baseUrl: 'https://media.comfy.org/website/marketing',
width: 1280,
formats: ['webm', 'mp4']
})
expect(sources).toEqual([
{
src: 'https://media.comfy.org/website/marketing/hero-1280.webm',
type: 'video/webm',
format: 'webm'
},
{
src: 'https://media.comfy.org/website/marketing/hero-1280.mp4',
type: 'video/mp4',
format: 'mp4'
}
])
})
it('preserves caller-supplied format order', () => {
const sources = buildVideoSources({
name: 'clip',
baseUrl: 'https://cdn.example.com/v',
width: 960,
formats: ['mp4', 'webm']
})
expect(sources.map((s) => s.format)).toEqual(['mp4', 'webm'])
})
it('strips a single trailing slash from baseUrl', () => {
const sources = buildVideoSources({
name: 'reel',
baseUrl: 'https://media.comfy.org/website/marketing/',
width: 1920,
formats: ['webm']
})
expect(sources[0]?.src).toBe(
'https://media.comfy.org/website/marketing/reel-1920.webm'
)
})
it('returns an empty list when no formats are requested', () => {
const sources = buildVideoSources({
name: 'x',
baseUrl: 'https://example.com',
width: 640,
formats: []
})
expect(sources).toEqual([])
})
})
describe('videoKey', () => {
it('changes when the source URL list changes', () => {
const at1280 = buildVideoSources({
name: 'hero',
baseUrl: 'https://media.comfy.org/m',
width: 1280,
formats: ['webm', 'mp4']
})
const at640 = buildVideoSources({
name: 'hero',
baseUrl: 'https://media.comfy.org/m',
width: 640,
formats: ['webm', 'mp4']
})
expect(videoKey(at1280)).not.toBe(videoKey(at640))
})
it('is stable across repeated calls with the same inputs', () => {
const args = {
name: 'hero',
baseUrl: 'https://media.comfy.org/m',
width: 1280,
formats: ['webm', 'mp4'] as const
}
expect(
videoKey(buildVideoSources({ ...args, formats: [...args.formats] }))
).toBe(videoKey(buildVideoSources({ ...args, formats: [...args.formats] })))
})
it('reflects format-order changes', () => {
const webmFirst = buildVideoSources({
name: 'hero',
baseUrl: 'https://media.comfy.org/m',
width: 1280,
formats: ['webm', 'mp4']
})
const mp4First = buildVideoSources({
name: 'hero',
baseUrl: 'https://media.comfy.org/m',
width: 1280,
formats: ['mp4', 'webm']
})
expect(videoKey(webmFirst)).not.toBe(videoKey(mp4First))
})
})

View File

@@ -1,49 +0,0 @@
/** @knipIgnoreUsedByStackedPR */
export type VideoFormat = 'webm' | 'mp4'
/** @knipIgnoreUsedByStackedPR */
export type VideoSource = {
src: string
type: `video/${VideoFormat}`
format: VideoFormat
}
const MIME_TYPES: Record<VideoFormat, VideoSource['type']> = {
webm: 'video/webm',
mp4: 'video/mp4'
}
type BuildArgs = {
name: string
baseUrl: string
width: number
formats: VideoFormat[]
}
/**
* Expects assets named `${name}-${width}.${format}` under `${baseUrl}/`,
* matching the output of `apps/website/scripts/process-videos.sh`.
*/
export function buildVideoSources({
name,
baseUrl,
width,
formats
}: BuildArgs): VideoSource[] {
const base = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl
return formats.map((format) => ({
src: `${base}/${name}-${width}.${format}`,
type: MIME_TYPES[format],
format
}))
}
/**
* Stable identifier for a list of video sources, suitable as a Vue `key`.
* Browsers do not reload a `<video>` when nested `<source>` children change;
* keying the parent forces a remount when the source set changes.
*/
export function videoKey(sources: VideoSource[]): string {
return sources.map((s) => s.src).join('|')
}

View File

@@ -1,9 +1,60 @@
import { expect } from '@playwright/test'
import type { Route } from '@playwright/test'
import type { Asset, ListAssetsResponse } from '@comfyorg/ingest-types'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import { STABLE_INPUT_IMAGE } from '@e2e/fixtures/data/assetFixtures'
import { TestIds } from '@e2e/fixtures/selectors'
import { loadWorkflowAndOpenErrorsTab } from '@e2e/fixtures/helpers/ErrorsTabHelper'
import { PropertiesPanelHelper } from '@e2e/tests/propertiesPanel/PropertiesPanelHelper'
const test = comfyPageFixture
const PUBLIC_INPUT_ASSET_HASH = 'nonexistent_test_image_12345.png'
const PUBLIC_INPUT_ASSET: Asset = {
...STABLE_INPUT_IMAGE,
id: 'test-public-input-001',
name: 'public_reference_photo.png',
asset_hash: PUBLIC_INPUT_ASSET_HASH
}
function makeAssetsResponse(assets: Asset[]): ListAssetsResponse {
return { assets, total: assets.length, has_more: false }
}
const cloudTest = comfyPageFixture.extend<{
cloudAssetRequests: string[]
stubCloudAssets: void
}>({
cloudAssetRequests: async ({ page: _page }, use) => {
await use([])
},
stubCloudAssets: [
async ({ cloudAssetRequests, page }, use) => {
const assetsRoutePattern = /\/api\/assets(?:\?.*)?$/
const assetsRouteHandler = (route: Route) => {
const url = new URL(route.request().url())
const includeTags = url.searchParams.get('include_tags') ?? ''
const tags = includeTags.split(',').filter(Boolean)
const includePublic = url.searchParams.get('include_public') === 'true'
const assets =
tags.includes('input') && includePublic ? [PUBLIC_INPUT_ASSET] : []
cloudAssetRequests.push(route.request().url())
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(makeAssetsResponse(assets))
})
}
await page.route(assetsRoutePattern, assetsRouteHandler)
await use()
await page.unroute(assetsRoutePattern, assetsRouteHandler)
},
{ auto: true }
]
})
async function uploadFileViaDropzone(comfyPage: ComfyPage) {
const dropzone = comfyPage.page.getByTestId(
@@ -203,3 +254,49 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
})
})
})
cloudTest.describe(
'Errors tab - Missing media cloud assets',
{
tag: '@cloud'
},
() => {
cloudTest.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.RightSidePanel.ShowErrorsTab',
true
)
})
cloudTest(
'does not report public input assets as missing',
async ({ cloudAssetRequests, comfyPage }) => {
await comfyPage.workflow.loadWorkflow('missing/missing_media_single')
await expect
.poll(() =>
cloudAssetRequests.some((requestUrl) => {
const url = new URL(requestUrl)
const includeTags = url.searchParams.get('include_tags') ?? ''
return (
includeTags.split(',').includes('input') &&
url.searchParams.get('include_public') === 'true'
)
})
)
.toBe(true)
const panel = new PropertiesPanelHelper(comfyPage.page)
await panel.open(comfyPage.actionbar.propertiesButton)
await expect(
comfyPage.page.getByTestId(TestIds.propertiesPanel.errorsTab)
).toBeHidden()
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingMediaGroup)
).toBeHidden()
}
)
}
)

View File

@@ -54,9 +54,6 @@ const config: KnipConfig = {
'.github/workflows/ci-oss-assets-validation.yaml',
// Pending integration in stacked PR
'src/components/sidebar/tabs/nodeLibrary/CustomNodesPanel.vue',
// Marketing media tooling — adopted by pages in a follow-up PR
'apps/website/src/components/common/SiteVideo.vue',
'apps/website/src/utils/marketingImage.ts',
// Agent review check config, not part of the build
'.agents/checks/eslint.strict.config.js',
// Devtools extensions, included dynamically

View File

@@ -74,7 +74,6 @@
"@primevue/themes": "catalog:",
"@sentry/vue": "catalog:",
"@sparkjsdev/spark": "catalog:",
"@tanstack/vue-query": "catalog:",
"@tanstack/vue-virtual": "catalog:",
"@tiptap/core": "catalog:",
"@tiptap/extension-link": "catalog:",

404
pnpm-lock.yaml generated
View File

@@ -108,9 +108,6 @@ catalogs:
'@tailwindcss/vite':
specifier: ^4.2.0
version: 4.2.0
'@tanstack/vue-query':
specifier: ^5.83.0
version: 5.100.9
'@tanstack/vue-virtual':
specifier: ^3.13.12
version: 3.13.12
@@ -479,9 +476,6 @@ importers:
'@sparkjsdev/spark':
specifier: 'catalog:'
version: 0.1.10
'@tanstack/vue-query':
specifier: 'catalog:'
version: 5.100.9(vue@3.5.13(typescript@5.9.3))
'@tanstack/vue-virtual':
specifier: 'catalog:'
version: 3.13.12(vue@3.5.13(typescript@5.9.3))
@@ -857,7 +851,7 @@ importers:
version: 8.0.5(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))
vitest:
specifier: 'catalog:'
version: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(msw@2.14.3(@types/node@24.10.4)(typescript@5.9.3))(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
version: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
vue-component-type-helpers:
specifier: 'catalog:'
version: 3.2.6
@@ -1003,7 +997,7 @@ importers:
version: 5.9.3
vitest:
specifier: 'catalog:'
version: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(msw@2.14.3(@types/node@25.0.3)(typescript@5.9.3))(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
version: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
packages/design-system:
dependencies:
@@ -2657,41 +2651,6 @@ packages:
cpu: [x64]
os: [win32]
'@inquirer/ansi@2.0.5':
resolution: {integrity: sha512-doc2sWgJpbFQ64UflSVd17ibMGDuxO1yKgOgLMwavzESnXjFWJqUeG8saYosqKpHp4kWiM5x1nXvEjbpx90gzw==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
'@inquirer/confirm@6.0.12':
resolution: {integrity: sha512-h9FgGun3QwVYNj5TWIZZ+slii73bMoBFjPfVIGtnFuL4t8gBiNDV9PcSfIzkuxvgquJKt9nr1QzszpBzTbH8Og==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@inquirer/core@11.1.9':
resolution: {integrity: sha512-BDE4fG22uYh1bGSifcj7JSx119TVYNViMhMu85usp4Fswrzh6M0DV3yld64jA98uOAa2GSQ4Bg4bZRm2d2cwSg==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@inquirer/figures@2.0.5':
resolution: {integrity: sha512-NsSs4kzfm12lNetHwAn3GEuH317IzpwrMCbOuMIVytpjnJ90YYHNwdRgYGuKmVxwuIqSgqk3M5qqQt1cDk0tGQ==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
'@inquirer/type@4.0.5':
resolution: {integrity: sha512-aetVUNeKNc/VriqXlw1NRSW0zhMBB0W4bNbWRJgzRl/3d0QNDQFfk0GO5SDdtjMZVg6o8ZKEiadd7SCCzoOn5Q==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@internationalized/date@3.9.0':
resolution: {integrity: sha512-yaN3brAnHRD+4KyyOsJyk49XUvj2wtbNACSqg0bz3u8t2VuzhC8Q5dfRnrSxjnnbDb+ienBnkn1TzQfE154vyg==}
@@ -2827,10 +2786,6 @@ packages:
'@mixpanel/rrweb@2.0.0-alpha.18.2':
resolution: {integrity: sha512-J3dVTEu6Z4p8di7y9KKvUooNuBjX97DdG6XGWoPEPi07A9512h9M8MEtvlY3mK0PGfuC0Mz5Pv/Ws6gjGYfKQg==}
'@mswjs/interceptors@0.41.8':
resolution: {integrity: sha512-pRLMNKTSGRoLq+KnEB/7OY5vijw1XmcheAAOiv6pj7W1FG32kAGqj1C/RK/cqxRGr1Fh+zBi8sDur8kj3EQv6A==}
engines: {node: '>=18'}
'@napi-rs/wasm-runtime@0.2.12':
resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==}
@@ -2972,18 +2927,6 @@ packages:
'@one-ini/wasm@0.1.1':
resolution: {integrity: sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==}
'@open-draft/deferred-promise@2.2.0':
resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==}
'@open-draft/deferred-promise@3.0.0':
resolution: {integrity: sha512-XW375UK8/9SqUVNVa6M0yEy8+iTi4QN5VZ7aZuRFQmy76LRwI9wy5F4YIBU6T+eTe2/DNDo8tqu8RHlwLHM6RA==}
'@open-draft/logger@0.3.0':
resolution: {integrity: sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==}
'@open-draft/until@2.1.0':
resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==}
'@opentelemetry/api-logs@0.208.0':
resolution: {integrity: sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg==}
engines: {node: '>=8.0.0'}
@@ -4254,25 +4197,9 @@ packages:
peerDependencies:
vite: ^8.0.0
'@tanstack/match-sorter-utils@8.19.4':
resolution: {integrity: sha512-Wo1iKt2b9OT7d+YGhvEPD3DXvPv2etTusIMhMUoG7fbhmxcXCtIjJDEygy91Y2JFlwGyjqiBPRozme7UD8hoqg==}
engines: {node: '>=12'}
'@tanstack/query-core@5.100.9':
resolution: {integrity: sha512-SJSFw1S8+kQ0+knv/XGfrbocWoAlT7vDKsSImtLx3ZPQmEcR46hkDjLSvynSy25N8Ms4tIEini1FuBd5k7IscQ==}
'@tanstack/virtual-core@3.13.12':
resolution: {integrity: sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA==}
'@tanstack/vue-query@5.100.9':
resolution: {integrity: sha512-wGiv/AirRuITlTDl87zdBRaZIZTejMItUswKgMzzcX/1gfn95iKw2EaCuz7qlX9ceB0DwBj9FqaroLnDoJCecg==}
peerDependencies:
'@vue/composition-api': ^1.1.2
vue: ^2.6.0 || ^3.3.0
peerDependenciesMeta:
'@vue/composition-api':
optional: true
'@tanstack/vue-virtual@3.13.12':
resolution: {integrity: sha512-vhF7kEU9EXWXh+HdAwKJ2m3xaOnTTmgcdXcF2pim8g4GvI7eRrk2YRuV5nUlZnd/NbCIX4/Ja2OZu5EjJL06Ww==}
peerDependencies:
@@ -4578,15 +4505,9 @@ packages:
'@types/semver@7.7.0':
resolution: {integrity: sha512-k107IF4+Xr7UHjwDc7Cfd6PRQfbdkiRabXGRjo07b4WyPahFBZCZ1sE+BNxYIJPPg73UkfOsVOLwqVc/6ETrIA==}
'@types/set-cookie-parser@2.4.10':
resolution: {integrity: sha512-GGmQVGpQWUe5qglJozEjZV/5dyxbOOZ0LHe/lqyWssB88Y4svNfst0uqBVscdDeIKl5Jy5+aPSvy7mI9tYRguw==}
'@types/stats.js@0.17.3':
resolution: {integrity: sha512-pXNfAD3KHOdif9EQXZ9deK82HVNaXP5ZIF5RP2QG6OQFNTaY2YIetfrE9t528vEreGQvEPRDDc8muaoYeK0SxQ==}
'@types/statuses@2.0.6':
resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==}
'@types/three@0.169.0':
resolution: {integrity: sha512-oan7qCgJBt03wIaK+4xPWclYRPG9wzcg7Z2f5T8xYTNEF95kh0t0lklxLLYBDo7gQiGLYzE6iF4ta7nXF2bcsw==}
@@ -5678,10 +5599,6 @@ packages:
resolution: {integrity: sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A==}
engines: {node: '>=20'}
cli-width@4.1.0:
resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==}
engines: {node: '>= 12'}
cliui@8.0.1:
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
engines: {node: '>=12'}
@@ -6532,12 +6449,6 @@ packages:
fast-levenshtein@2.0.6:
resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
fast-string-truncated-width@3.0.3:
resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==}
fast-string-width@3.0.2:
resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==}
fast-unique-numbers@9.0.22:
resolution: {integrity: sha512-dBR+30yHAqBGvOuxxQdnn2lTLHCO6r/9B+M4yF8mNrzr3u1yiF+YVJ6u3GTyPN/VRWqaE1FcscZDdBgVKmrmQQ==}
engines: {node: '>=18.2.0'}
@@ -6545,9 +6456,6 @@ packages:
fast-uri@3.1.0:
resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==}
fast-wrap-ansi@0.2.0:
resolution: {integrity: sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w==}
fastest-levenshtein@1.0.16:
resolution: {integrity: sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==}
engines: {node: '>= 4.9.1'}
@@ -6824,10 +6732,6 @@ packages:
graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
graphql@16.13.2:
resolution: {integrity: sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig==}
engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0}
gray-matter@4.0.3:
resolution: {integrity: sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==}
engines: {node: '>=6.0'}
@@ -6910,9 +6814,6 @@ packages:
resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==}
hasBin: true
headers-polyfill@5.0.1:
resolution: {integrity: sha512-1TJ6Fih/b8h5TIcv+1+Hw0PDQWJTKDKzFZzcKOiW1wJza3XoAQlkCuXLbymPYB8+ZQyw8mHvdw560e8zVFIWyA==}
hookable@5.5.3:
resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==}
@@ -7169,9 +7070,6 @@ packages:
resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==}
engines: {node: '>= 0.4'}
is-node-process@1.2.0:
resolution: {integrity: sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==}
is-npm@6.1.0:
resolution: {integrity: sha512-O2z4/kNgyjhQwVR1Wpkbfc19JIhggF97NZNCpWTnjH7kVcZMUrnut9XSN7txI7VdyIYk5ZatOq3zvSuWpU8hoA==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
@@ -8017,16 +7915,6 @@ packages:
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
msw@2.14.3:
resolution: {integrity: sha512-kk8G5cocVlJ4wsKMGZegn2H6XLOEKjbA+nSJE2354e/SRp4mDicCHUYnMXpymzVcVDCs+GUAsmNqSn+yHv4T2A==}
engines: {node: '>=18'}
hasBin: true
peerDependencies:
typescript: '>= 4.8.x'
peerDependenciesMeta:
typescript:
optional: true
muggle-string@0.4.1:
resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==}
@@ -8034,10 +7922,6 @@ packages:
resolution: {integrity: sha512-SsI/exkodHsh+ofCV7An2PZWRaJC7eFVl7gtHQlMWFEDmWtb7cELr/GK32Nhe/6dZQhbr81o+Moswx9aXN3RRg==}
engines: {node: '>=18.2.0'}
mute-stream@3.0.0:
resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==}
engines: {node: ^20.17.0 || >=22.9.0}
nanoid@3.3.11:
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
@@ -8222,9 +8106,6 @@ packages:
orderedmap@2.1.1:
resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==}
outvariant@1.4.3:
resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==}
own-keys@1.0.1:
resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==}
engines: {node: '>= 0.4'}
@@ -8353,9 +8234,6 @@ packages:
resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==}
engines: {node: 18 || 20 || >=22}
path-to-regexp@6.3.0:
resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==}
path-type@4.0.0:
resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==}
engines: {node: '>=8'}
@@ -8801,9 +8679,6 @@ packages:
remark-stringify@11.0.0:
resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==}
remove-accents@0.5.0:
resolution: {integrity: sha512-8g3/Otx1eJaVD12e31UbJj1YzdtVvzH85HV7t+9MJYk/u3XmkOUJ5Ys9wQrf9PCPK8+xn4ymzqYCiZl6QWKn+A==}
request-light@0.5.8:
resolution: {integrity: sha512-3Zjgh+8b5fhRJBQZoy+zbVKpAQGLyka0MPgW3zruTF4dFFJ8Fqcfu9YsAvi/rvdcaTeWG3MkbZv4WKxAn/84Lg==}
@@ -8862,9 +8737,6 @@ packages:
retext@9.0.0:
resolution: {integrity: sha512-sbMDcpHCNjvlheSgMfEcVrZko3cDzdbe1x/e7G66dFp0Ff7Mldvi2uv6JkJQzdRcvLYE8CA8Oe8siQx8ZOgTcA==}
rettime@0.11.11:
resolution: {integrity: sha512-ILJRqVWBCTlg9r42fFgwVZx1gnFAcQF8mRoMkbgQfIrjEDf9nbBFDFx00oloOa+Q869FUtaYDXZvEfnecQSCoQ==}
reusify@1.1.0:
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
@@ -8960,9 +8832,6 @@ packages:
engines: {node: '>=10'}
hasBin: true
set-cookie-parser@3.1.0:
resolution: {integrity: sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==}
set-function-length@1.2.2:
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
engines: {node: '>= 0.4'}
@@ -9096,10 +8965,6 @@ packages:
standardized-audio-context@25.3.77:
resolution: {integrity: sha512-Ki9zNz6pKcC5Pi+QPjPyVsD9GwJIJWgryji0XL9cAJXMGyn+dPOf6Qik1AHei0+UNVcc4BOCa0hWLBzlwqsW/A==}
statuses@2.0.2:
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
engines: {node: '>= 0.8'}
std-env@3.10.0:
resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
@@ -9119,9 +8984,6 @@ packages:
stream-replace-string@2.0.0:
resolution: {integrity: sha512-TlnjJ1C0QrmxRNrON00JvaFFlNh5TTG00APw23j74ET7gkQpTASi6/L2fuiav8pzK715HXtUeClpBTw2NPSn6w==}
strict-event-emitter@0.5.1:
resolution: {integrity: sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==}
string-argv@0.3.2:
resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==}
engines: {node: '>=0.6.19'}
@@ -9378,10 +9240,6 @@ packages:
resolution: {integrity: sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==}
engines: {node: '>=16'}
tough-cookie@6.0.1:
resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==}
engines: {node: '>=16'}
tr46@0.0.3:
resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
@@ -9460,10 +9318,6 @@ packages:
resolution: {integrity: sha512-JnTrzGu+zPV3aXIUhnyWJj4z/wigMsdYajGLIYakqyOW1nPllzXEJee0QQbHj+CTIQtXGlAjuK0UY+2xTyjVAw==}
engines: {node: '>=20'}
type-fest@5.6.0:
resolution: {integrity: sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==}
engines: {node: '>=20'}
typed-array-buffer@1.0.3:
resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==}
engines: {node: '>= 0.4'}
@@ -9720,9 +9574,6 @@ packages:
uploadthing:
optional: true
until-async@3.0.2:
resolution: {integrity: sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==}
update-browserslist-db@1.2.2:
resolution: {integrity: sha512-E85pfNzMQ9jpKkA7+TJAi4TJN+tBCuWh5rUcS/sv6cFi+1q9LYDwDI5dpUL0u/73EElyQ8d3TEaeW4sPedBqYA==}
hasBin: true
@@ -10032,8 +9883,8 @@ packages:
vue-component-type-helpers@3.2.6:
resolution: {integrity: sha512-O02tnvIfOQVmnvoWwuSydwRoHjZVt8UEBR+2p4rT35p8GAy5VTlWP8o5qXfJR/GWCN0nVZoYWsVUvx2jwgdBmQ==}
vue-component-type-helpers@3.2.8:
resolution: {integrity: sha512-9689efAXhN/EV86plgkL/XFiJSXhGtWPG6JDboZ+QnjlUWUUQrQ0ILKQtw4iQsuwIwu5k6Aw+JnehDe7161e7A==}
vue-component-type-helpers@3.2.7:
resolution: {integrity: sha512-+gPp5YGmhfsj1IN+xUo7y0fb4clfnOiiUA39y07yW1VzCRjzVgwLbtmdWlghh7mXrPsEaYc7rrIir/HT6C8vYQ==}
vue-demi@0.14.10:
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
@@ -12206,64 +12057,6 @@ snapshots:
'@img/sharp-win32-x64@0.34.5':
optional: true
'@inquirer/ansi@2.0.5':
optional: true
'@inquirer/confirm@6.0.12(@types/node@24.10.4)':
dependencies:
'@inquirer/core': 11.1.9(@types/node@24.10.4)
'@inquirer/type': 4.0.5(@types/node@24.10.4)
optionalDependencies:
'@types/node': 24.10.4
optional: true
'@inquirer/confirm@6.0.12(@types/node@25.0.3)':
dependencies:
'@inquirer/core': 11.1.9(@types/node@25.0.3)
'@inquirer/type': 4.0.5(@types/node@25.0.3)
optionalDependencies:
'@types/node': 25.0.3
optional: true
'@inquirer/core@11.1.9(@types/node@24.10.4)':
dependencies:
'@inquirer/ansi': 2.0.5
'@inquirer/figures': 2.0.5
'@inquirer/type': 4.0.5(@types/node@24.10.4)
cli-width: 4.1.0
fast-wrap-ansi: 0.2.0
mute-stream: 3.0.0
signal-exit: 4.1.0
optionalDependencies:
'@types/node': 24.10.4
optional: true
'@inquirer/core@11.1.9(@types/node@25.0.3)':
dependencies:
'@inquirer/ansi': 2.0.5
'@inquirer/figures': 2.0.5
'@inquirer/type': 4.0.5(@types/node@25.0.3)
cli-width: 4.1.0
fast-wrap-ansi: 0.2.0
mute-stream: 3.0.0
signal-exit: 4.1.0
optionalDependencies:
'@types/node': 25.0.3
optional: true
'@inquirer/figures@2.0.5':
optional: true
'@inquirer/type@4.0.5(@types/node@24.10.4)':
optionalDependencies:
'@types/node': 24.10.4
optional: true
'@inquirer/type@4.0.5(@types/node@25.0.3)':
optionalDependencies:
'@types/node': 25.0.3
optional: true
'@internationalized/date@3.9.0':
dependencies:
'@swc/helpers': 0.5.17
@@ -12503,16 +12296,6 @@ snapshots:
base64-arraybuffer: 1.0.2
mitt: 3.0.1
'@mswjs/interceptors@0.41.8':
dependencies:
'@open-draft/deferred-promise': 2.2.0
'@open-draft/logger': 0.3.0
'@open-draft/until': 2.1.0
is-node-process: 1.2.0
outvariant: 1.4.3
strict-event-emitter: 0.5.1
optional: true
'@napi-rs/wasm-runtime@0.2.12':
dependencies:
'@emnapi/core': 1.8.1
@@ -12719,7 +12502,7 @@ snapshots:
tsconfig-paths: 4.2.0
tslib: 2.8.1
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(msw@2.14.3(@types/node@24.10.4)(typescript@5.9.3))(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
transitivePeerDependencies:
- '@babel/traverse'
- '@swc-node/register'
@@ -12739,7 +12522,7 @@ snapshots:
tslib: 2.8.1
optionalDependencies:
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(msw@2.14.3(@types/node@24.10.4)(typescript@5.9.3))(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
transitivePeerDependencies:
- '@babel/traverse'
- '@swc-node/register'
@@ -12768,21 +12551,6 @@ snapshots:
'@one-ini/wasm@0.1.1': {}
'@open-draft/deferred-promise@2.2.0':
optional: true
'@open-draft/deferred-promise@3.0.0':
optional: true
'@open-draft/logger@0.3.0':
dependencies:
is-node-process: 1.2.0
outvariant: 1.4.3
optional: true
'@open-draft/until@2.1.0':
optional: true
'@opentelemetry/api-logs@0.208.0':
dependencies:
'@opentelemetry/api': 1.9.0
@@ -13637,7 +13405,7 @@ snapshots:
storybook: 10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
type-fest: 2.19.0
vue: 3.5.13(typescript@5.9.3)
vue-component-type-helpers: 3.2.8
vue-component-type-helpers: 3.2.7
'@swc/helpers@0.5.17':
dependencies:
@@ -13718,22 +13486,8 @@ snapshots:
tailwindcss: 4.2.0
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
'@tanstack/match-sorter-utils@8.19.4':
dependencies:
remove-accents: 0.5.0
'@tanstack/query-core@5.100.9': {}
'@tanstack/virtual-core@3.13.12': {}
'@tanstack/vue-query@5.100.9(vue@3.5.13(typescript@5.9.3))':
dependencies:
'@tanstack/match-sorter-utils': 8.19.4
'@tanstack/query-core': 5.100.9
'@vue/devtools-api': 6.6.4
vue: 3.5.13(typescript@5.9.3)
vue-demi: 0.14.10(vue@3.5.13(typescript@5.9.3))
'@tanstack/vue-virtual@3.13.12(vue@3.5.13(typescript@5.9.3))':
dependencies:
'@tanstack/virtual-core': 3.13.12
@@ -14078,16 +13832,8 @@ snapshots:
'@types/semver@7.7.0': {}
'@types/set-cookie-parser@2.4.10':
dependencies:
'@types/node': 25.0.3
optional: true
'@types/stats.js@0.17.3': {}
'@types/statuses@2.0.6':
optional: true
'@types/three@0.169.0':
dependencies:
'@tweenjs/tween.js': 23.1.3
@@ -14372,7 +14118,7 @@ snapshots:
obug: 2.1.1
std-env: 3.10.0
tinyrainbow: 3.0.3
vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(msw@2.14.3(@types/node@24.10.4)(typescript@5.9.3))(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
transitivePeerDependencies:
- supports-color
@@ -14393,22 +14139,20 @@ snapshots:
chai: 6.2.2
tinyrainbow: 3.0.3
'@vitest/mocker@4.0.16(msw@2.14.3(@types/node@24.10.4)(typescript@5.9.3))(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))':
'@vitest/mocker@4.0.16(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))':
dependencies:
'@vitest/spy': 4.0.16
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
msw: 2.14.3(@types/node@24.10.4)(typescript@5.9.3)
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
'@vitest/mocker@4.0.16(msw@2.14.3(@types/node@25.0.3)(typescript@5.9.3))(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))':
'@vitest/mocker@4.0.16(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))':
dependencies:
'@vitest/spy': 4.0.16
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
msw: 2.14.3(@types/node@25.0.3)(typescript@5.9.3)
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
'@vitest/pretty-format@3.2.4':
@@ -14445,7 +14189,7 @@ snapshots:
sirv: 3.0.2
tinyglobby: 0.2.15
tinyrainbow: 3.0.3
vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(msw@2.14.3(@types/node@25.0.3)(typescript@5.9.3))(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
'@vitest/utils@3.2.4':
dependencies:
@@ -15483,9 +15227,6 @@ snapshots:
slice-ansi: 7.1.2
string-width: 8.2.0
cli-width@4.1.0:
optional: true
cliui@8.0.1:
dependencies:
string-width: 4.2.3
@@ -16488,14 +16229,6 @@ snapshots:
fast-levenshtein@2.0.6: {}
fast-string-truncated-width@3.0.3:
optional: true
fast-string-width@3.0.2:
dependencies:
fast-string-truncated-width: 3.0.3
optional: true
fast-unique-numbers@9.0.22:
dependencies:
'@babel/runtime': 7.29.2
@@ -16503,11 +16236,6 @@ snapshots:
fast-uri@3.1.0: {}
fast-wrap-ansi@0.2.0:
dependencies:
fast-string-width: 3.0.2
optional: true
fastest-levenshtein@1.0.16: {}
fastq@1.20.1:
@@ -16824,9 +16552,6 @@ snapshots:
graceful-fs@4.2.11: {}
graphql@16.13.2:
optional: true
gray-matter@4.0.3:
dependencies:
js-yaml: 3.14.2
@@ -16972,12 +16697,6 @@ snapshots:
he@1.2.0: {}
headers-polyfill@5.0.1:
dependencies:
'@types/set-cookie-parser': 2.4.10
set-cookie-parser: 3.1.0
optional: true
hookable@5.5.3: {}
hookified@1.14.0: {}
@@ -17239,9 +16958,6 @@ snapshots:
is-negative-zero@2.0.3:
optional: true
is-node-process@1.2.0:
optional: true
is-npm@6.1.0: {}
is-number-object@1.1.1:
@@ -18238,58 +17954,6 @@ snapshots:
ms@2.1.3: {}
msw@2.14.3(@types/node@24.10.4)(typescript@5.9.3):
dependencies:
'@inquirer/confirm': 6.0.12(@types/node@24.10.4)
'@mswjs/interceptors': 0.41.8
'@open-draft/deferred-promise': 3.0.0
'@types/statuses': 2.0.6
cookie: 1.1.1
graphql: 16.13.2
headers-polyfill: 5.0.1
is-node-process: 1.2.0
outvariant: 1.4.3
path-to-regexp: 6.3.0
picocolors: 1.1.1
rettime: 0.11.11
statuses: 2.0.2
strict-event-emitter: 0.5.1
tough-cookie: 6.0.1
type-fest: 5.6.0
until-async: 3.0.2
yargs: 17.7.2
optionalDependencies:
typescript: 5.9.3
transitivePeerDependencies:
- '@types/node'
optional: true
msw@2.14.3(@types/node@25.0.3)(typescript@5.9.3):
dependencies:
'@inquirer/confirm': 6.0.12(@types/node@25.0.3)
'@mswjs/interceptors': 0.41.8
'@open-draft/deferred-promise': 3.0.0
'@types/statuses': 2.0.6
cookie: 1.1.1
graphql: 16.13.2
headers-polyfill: 5.0.1
is-node-process: 1.2.0
outvariant: 1.4.3
path-to-regexp: 6.3.0
picocolors: 1.1.1
rettime: 0.11.11
statuses: 2.0.2
strict-event-emitter: 0.5.1
tough-cookie: 6.0.1
type-fest: 5.6.0
until-async: 3.0.2
yargs: 17.7.2
optionalDependencies:
typescript: 5.9.3
transitivePeerDependencies:
- '@types/node'
optional: true
muggle-string@0.4.1: {}
multi-buffer-data-view@6.0.22:
@@ -18297,9 +17961,6 @@ snapshots:
'@babel/runtime': 7.29.2
tslib: 2.8.1
mute-stream@3.0.0:
optional: true
nanoid@3.3.11: {}
nanoid@5.1.5: {}
@@ -18545,9 +18206,6 @@ snapshots:
orderedmap@2.1.1: {}
outvariant@1.4.3:
optional: true
own-keys@1.0.1:
dependencies:
get-intrinsic: 1.3.0
@@ -18757,9 +18415,6 @@ snapshots:
lru-cache: 11.2.6
minipass: 7.1.3
path-to-regexp@6.3.0:
optional: true
path-type@4.0.0: {}
pathe@0.2.0: {}
@@ -19361,8 +19016,6 @@ snapshots:
mdast-util-to-markdown: 2.1.2
unified: 11.0.5
remove-accents@0.5.0: {}
request-light@0.5.8: {}
request-light@0.7.0: {}
@@ -19425,9 +19078,6 @@ snapshots:
retext-stringify: 4.0.0
unified: 11.0.5
rettime@0.11.11:
optional: true
reusify@1.1.0: {}
rfdc@1.4.1: {}
@@ -19550,9 +19200,6 @@ snapshots:
semver@7.7.4: {}
set-cookie-parser@3.1.0:
optional: true
set-function-length@1.2.2:
dependencies:
define-data-property: 1.1.4
@@ -19734,9 +19381,6 @@ snapshots:
automation-events: 7.1.11
tslib: 2.8.1
statuses@2.0.2:
optional: true
std-env@3.10.0: {}
stop-iteration-iterator@1.1.0:
@@ -19769,9 +19413,6 @@ snapshots:
stream-replace-string@2.0.0: {}
strict-event-emitter@0.5.1:
optional: true
string-argv@0.3.2: {}
string-width@4.2.3:
@@ -20073,11 +19714,6 @@ snapshots:
dependencies:
tldts: 7.0.19
tough-cookie@6.0.1:
dependencies:
tldts: 7.0.19
optional: true
tr46@0.0.3: {}
tr46@6.0.0:
@@ -20146,11 +19782,6 @@ snapshots:
dependencies:
tagged-tag: 1.0.0
type-fest@5.6.0:
dependencies:
tagged-tag: 1.0.0
optional: true
typed-array-buffer@1.0.3:
dependencies:
call-bound: 1.0.4
@@ -20414,9 +20045,6 @@ snapshots:
ofetch: 1.5.1
ufo: 1.6.3
until-async@3.0.2:
optional: true
update-browserslist-db@1.2.2(browserslist@4.28.1):
dependencies:
browserslist: 4.28.1
@@ -20706,10 +20334,10 @@ snapshots:
optionalDependencies:
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
vitest@4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(msw@2.14.3(@types/node@24.10.4)(typescript@5.9.3))(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2):
vitest@4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2):
dependencies:
'@vitest/expect': 4.0.16
'@vitest/mocker': 4.0.16(msw@2.14.3(@types/node@24.10.4)(typescript@5.9.3))(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
'@vitest/mocker': 4.0.16(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
'@vitest/pretty-format': 4.0.16
'@vitest/runner': 4.0.16
'@vitest/snapshot': 4.0.16
@@ -20748,10 +20376,10 @@ snapshots:
- tsx
- yaml
vitest@4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(msw@2.14.3(@types/node@25.0.3)(typescript@5.9.3))(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2):
vitest@4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2):
dependencies:
'@vitest/expect': 4.0.16
'@vitest/mocker': 4.0.16(msw@2.14.3(@types/node@25.0.3)(typescript@5.9.3))(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
'@vitest/mocker': 4.0.16(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
'@vitest/pretty-format': 4.0.16
'@vitest/runner': 4.0.16
'@vitest/snapshot': 4.0.16
@@ -20902,7 +20530,7 @@ snapshots:
vue-component-type-helpers@3.2.6: {}
vue-component-type-helpers@3.2.8: {}
vue-component-type-helpers@3.2.7: {}
vue-demi@0.14.10(vue@3.5.13(typescript@5.9.3)):
dependencies:

View File

@@ -37,7 +37,6 @@ catalog:
'@storybook/vue3': ^10.2.10
'@storybook/vue3-vite': ^10.2.10
'@tailwindcss/vite': ^4.2.0
'@tanstack/vue-query': ^5.83.0
'@tanstack/vue-virtual': ^3.13.12
'@testing-library/jest-dom': ^6.9.1
'@testing-library/user-event': ^14.6.1

View File

@@ -1,32 +0,0 @@
import axios from 'axios'
const PAYLOAD_KEY_SAMPLE = 10
export function summarizeError(err: unknown): Record<string, unknown> {
if (axios.isAxiosError(err)) {
return {
message: err.message,
code: err.code,
status: err.response?.status
}
}
if (err instanceof Error) {
return { message: err.message, name: err.name }
}
return { message: String(err) }
}
export function summarizePayload(data: unknown): Record<string, unknown> {
if (data === null) return { type: 'null' }
if (data === undefined) return { type: 'undefined' }
if (Array.isArray(data)) return { type: 'array', length: data.length }
if (typeof data === 'object') {
const keys = Object.keys(data as Record<string, unknown>)
return {
type: 'object',
keys: keys.slice(0, PAYLOAD_KEY_SAMPLE),
keyCount: keys.length
}
}
return { type: typeof data }
}

View File

@@ -1,49 +0,0 @@
import * as fc from 'fast-check'
import { describe, expect, it } from 'vitest'
import { mapToDropdownItem } from '@/base/remote/itemSchema'
describe('mapToDropdownItem property tests', () => {
it('mapping is total and stable for arbitrary string fields', () => {
fc.assert(
fc.property(
fc.record({
id: fc.string(),
name: fc.string()
}),
(raw) => {
const schema = {
value_field: 'id',
label_field: 'name',
preview_type: 'image' as const
}
const a = mapToDropdownItem(raw, schema)
const b = mapToDropdownItem(raw, schema)
expect(a).toEqual(b)
expect(typeof a.id).toBe('string')
expect(typeof a.name).toBe('string')
}
)
)
})
it('id is non-empty when value_field is present in raw', () => {
fc.assert(
fc.property(
fc.record({
id: fc.string({ minLength: 1 }),
name: fc.string()
}),
(raw) => {
const schema = {
value_field: 'id',
label_field: 'name',
preview_type: 'image' as const
}
const item = mapToDropdownItem(raw, schema)
expect(item.id.length).toBeGreaterThan(0)
}
)
)
})
})

View File

@@ -1,354 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
buildSearchText,
displayName,
extractItems,
getByPath,
mapToDropdownItem,
resolveLabel
} from '@/base/remote/itemSchema'
describe('getByPath', () => {
it('returns a top-level value for a plain key', () => {
expect(getByPath({ name: 'Alice' }, 'name')).toBe('Alice')
})
it('traverses nested objects via dot-path', () => {
expect(getByPath({ profile: { name: 'Alice' } }, 'profile.name')).toBe(
'Alice'
)
})
it('treats numeric segments as array indices', () => {
expect(getByPath({ items: ['a', 'b', 'c'] }, 'items.1')).toBe('b')
})
it('combines nested objects and array indices', () => {
const obj = { data: { results: [{ id: 'x' }, { id: 'y' }] } }
expect(getByPath(obj, 'data.results.1.id')).toBe('y')
})
it('returns undefined for a missing top-level key', () => {
expect(getByPath({ a: 1 }, 'b')).toBeUndefined()
})
it('returns undefined when traversing past a null segment', () => {
expect(getByPath({ a: null }, 'a.b')).toBeUndefined()
})
it('returns undefined when the root is null', () => {
expect(getByPath(null, 'a')).toBeUndefined()
})
it('returns undefined when the root is undefined', () => {
expect(getByPath(undefined, 'a')).toBeUndefined()
})
it('returns undefined for an out-of-bounds array index', () => {
expect(getByPath({ items: ['a'] }, 'items.5')).toBeUndefined()
})
})
describe('resolveLabel', () => {
it('resolves a plain dot-path to its value', () => {
expect(resolveLabel('name', { name: 'Alice' })).toBe('Alice')
})
it('resolves a nested dot-path without placeholders', () => {
expect(resolveLabel('profile.name', { profile: { name: 'Alice' } })).toBe(
'Alice'
)
})
it('substitutes a single {field} placeholder', () => {
expect(resolveLabel('Name: {name}', { name: 'Alice' })).toBe('Name: Alice')
})
it('substitutes multiple placeholders', () => {
expect(
resolveLabel('{first} {last}', { first: 'Alice', last: 'Liddell' })
).toBe('Alice Liddell')
})
it('substitutes placeholders with dot-paths', () => {
expect(
resolveLabel('{profile.name} ({profile.age})', {
profile: { name: 'Alice', age: 30 }
})
).toBe('Alice (30)')
})
it('replaces missing placeholder fields with an empty string', () => {
expect(resolveLabel('{name} - {missing}', { name: 'Alice' })).toBe(
'Alice - '
)
})
it('returns an empty string when a plain path resolves to undefined', () => {
expect(resolveLabel('missing', { a: 1 })).toBe('')
})
it('coerces numeric values to strings', () => {
expect(resolveLabel('{count}', { count: 5 })).toBe('5')
})
})
describe('mapToDropdownItem', () => {
it('maps required fields to id and name', () => {
const item = mapToDropdownItem(
{ voice_id: 'v1', label: 'Roger' },
{ value_field: 'voice_id', label_field: 'label', preview_type: 'image' }
)
expect(item).toEqual({
id: 'v1',
name: 'Roger',
description: undefined,
preview_url: undefined
})
})
it('includes description when description_field is set', () => {
const item = mapToDropdownItem(
{ id: 'v1', label: 'Roger', desc: 'Laid-back American male' },
{
value_field: 'id',
label_field: 'label',
description_field: 'desc',
preview_type: 'image'
}
)
expect(item.description).toBe('Laid-back American male')
})
it('includes preview_url when preview_url_field is set', () => {
const item = mapToDropdownItem(
{ id: 'v1', label: 'Roger', sample: 'https://example.com/a.mp3' },
{
value_field: 'id',
label_field: 'label',
preview_url_field: 'sample',
preview_type: 'audio'
}
)
expect(item.preview_url).toBe('https://example.com/a.mp3')
})
it('resolves label_field templates with placeholders', () => {
const item = mapToDropdownItem(
{ id: 'v1', first: 'Alice', last: 'Liddell' },
{
value_field: 'id',
label_field: '{first} {last}',
preview_type: 'image'
}
)
expect(item.name).toBe('Alice Liddell')
})
it('resolves dot-path fields for nested data', () => {
const item = mapToDropdownItem(
{ task_result: { elements: [{ element_id: 'e1', name: 'Elem' }] } },
{
value_field: 'task_result.elements.0.element_id',
label_field: 'task_result.elements.0.name',
preview_type: 'image'
}
)
expect(item.id).toBe('e1')
expect(item.name).toBe('Elem')
})
it('stringifies non-string value_field', () => {
const item = mapToDropdownItem(
{ id: 42, label: 'Answer' },
{ value_field: 'id', label_field: 'label', preview_type: 'image' }
)
expect(item.id).toBe('42')
})
it('returns an empty string id when value_field is missing', () => {
const item = mapToDropdownItem(
{ label: 'Orphan' },
{ value_field: 'id', label_field: 'label', preview_type: 'image' }
)
expect(item.id).toBe('')
})
})
describe('extractItems', () => {
it('returns the full response when responseKey is undefined', () => {
expect(extractItems([1, 2, 3])).toEqual([1, 2, 3])
})
it('extracts items from a top-level key', () => {
expect(
extractItems({ voices: [{ id: 'a' }, { id: 'b' }] }, 'voices')
).toEqual([{ id: 'a' }, { id: 'b' }])
})
it('extracts items via a dot-path', () => {
expect(extractItems({ data: { items: [1, 2] } }, 'data.items')).toEqual([
1, 2
])
})
it('returns an empty array for a valid empty list', () => {
expect(extractItems([])).toEqual([])
})
it('returns null when the path does not exist', () => {
expect(extractItems({ a: 1 }, 'nonexistent')).toBeNull()
})
it('returns null when the path resolves to a non-array', () => {
expect(
extractItems({ data: { items: 'not an array' } }, 'data.items')
).toBeNull()
})
it('returns null when the full response is not an array', () => {
expect(extractItems({ not: 'array' })).toBeNull()
})
it('returns null when response is null', () => {
expect(extractItems(null)).toBeNull()
})
})
describe('buildSearchText', () => {
it('joins multiple fields with a space', () => {
expect(buildSearchText({ a: 'Hello', b: 'World' }, ['a', 'b'])).toBe(
'hello world'
)
})
it('lowercases the result', () => {
expect(buildSearchText({ name: 'ALICE' }, ['name'])).toBe('alice')
})
it('drops missing fields', () => {
expect(buildSearchText({ name: 'Alice' }, ['name', 'missing'])).toBe(
'alice'
)
})
it('supports dot-path fields', () => {
expect(
buildSearchText({ profile: { name: 'Alice', age: 30 } }, [
'profile.name',
'profile.age'
])
).toBe('alice 30')
})
it('returns an empty string when all fields are missing', () => {
expect(buildSearchText({ name: 'Alice' }, ['missing'])).toBe('')
})
})
describe('mapToDropdownItem preview_url normalization', () => {
const baseSchema = {
value_field: 'id',
label_field: 'name',
preview_url_field: 'thumb',
preview_type: 'image' as const
}
it('preserves absolute https URLs', () => {
const item = mapToDropdownItem(
{ id: 'a', name: 'A', thumb: 'https://cdn.example.com/a.png' },
baseSchema,
{ previewBaseUrl: 'https://api.comfy.org' }
)
expect(item.preview_url).toBe('https://cdn.example.com/a.png')
})
it('preserves protocol-relative URLs', () => {
const item = mapToDropdownItem(
{ id: 'a', name: 'A', thumb: '//cdn.example.com/a.png' },
baseSchema,
{ previewBaseUrl: 'https://api.comfy.org' }
)
expect(item.preview_url).toBe('//cdn.example.com/a.png')
})
it('preserves data: URIs', () => {
const item = mapToDropdownItem(
{ id: 'a', name: 'A', thumb: 'data:image/png;base64,AAA' },
baseSchema,
{ previewBaseUrl: 'https://api.comfy.org' }
)
expect(item.preview_url).toBe('data:image/png;base64,AAA')
})
it('preserves blob: URLs', () => {
const item = mapToDropdownItem(
{ id: 'a', name: 'A', thumb: 'blob:https://app/abc' },
baseSchema,
{ previewBaseUrl: 'https://api.comfy.org' }
)
expect(item.preview_url).toBe('blob:https://app/abc')
})
it('joins relative paths against the previewBaseUrl', () => {
const item = mapToDropdownItem(
{ id: 'a', name: 'A', thumb: '/voices/1/preview.png' },
baseSchema,
{ previewBaseUrl: 'https://api.comfy.org' }
)
expect(item.preview_url).toBe('https://api.comfy.org/voices/1/preview.png')
})
it('adds a leading slash when relative path lacks one', () => {
const item = mapToDropdownItem(
{ id: 'a', name: 'A', thumb: 'voices/1/preview.png' },
baseSchema,
{ previewBaseUrl: 'https://api.comfy.org' }
)
expect(item.preview_url).toBe('https://api.comfy.org/voices/1/preview.png')
})
it('strips trailing slashes from previewBaseUrl', () => {
const item = mapToDropdownItem(
{ id: 'a', name: 'A', thumb: '/x.png' },
baseSchema,
{ previewBaseUrl: 'https://api.comfy.org/' }
)
expect(item.preview_url).toBe('https://api.comfy.org/x.png')
})
it('returns relative path unchanged when no previewBaseUrl is provided', () => {
const item = mapToDropdownItem(
{ id: 'a', name: 'A', thumb: '/x.png' },
baseSchema
)
expect(item.preview_url).toBe('/x.png')
})
it('returns undefined when preview_url_field is unset', () => {
const item = mapToDropdownItem(
{ id: 'a', name: 'A' },
{ value_field: 'id', label_field: 'name', preview_type: 'image' },
{ previewBaseUrl: 'https://api.comfy.org' }
)
expect(item.preview_url).toBeUndefined()
})
})
describe('displayName', () => {
it('returns name when present', () => {
expect(displayName({ id: 'abc', name: 'Cool Asset' })).toBe('Cool Asset')
})
it('falls back to id when name is empty string', () => {
expect(displayName({ id: 'abc-123', name: '' })).toBe('abc-123')
})
})

View File

@@ -1,91 +0,0 @@
import type { RemoteItemSchema } from '@/schemas/nodeDefSchema'
export interface DropdownItemShape {
id: string
name: string
description?: string
preview_url?: string
}
/**
* User-facing label for a dropdown item. Falls back to id when name
* is missing or empty, so trigger/list rows never render blank.
*/
export function displayName(item: DropdownItemShape): string {
return item.name || item.id
}
export function getByPath(obj: unknown, path: string): unknown {
return path.split('.').reduce((acc: unknown, key: string) => {
if (acc == null) return undefined
const idx = Number(key)
if (Number.isInteger(idx) && idx >= 0 && Array.isArray(acc)) return acc[idx]
return (acc as Record<string, unknown>)[key]
}, obj)
}
export function resolveLabel(template: string, item: unknown): string {
if (!template.includes('{')) {
return String(getByPath(item, template) ?? '')
}
return template.replace(/\{([^}]+)\}/g, (_, path: string) =>
String(getByPath(item, path) ?? '')
)
}
const ABSOLUTE_URL_REGEX = /^([a-z][a-z0-9+.-]*:)?\/\//i
const DATA_URL_PREFIX = 'data:'
const BLOB_URL_PREFIX = 'blob:'
function resolvePreviewUrl(
raw: string | undefined,
baseUrl?: string
): string | undefined {
if (!raw) return undefined
const lowered = raw.toLowerCase()
if (
ABSOLUTE_URL_REGEX.test(raw) ||
lowered.startsWith(DATA_URL_PREFIX) ||
lowered.startsWith(BLOB_URL_PREFIX)
) {
return raw
}
if (!baseUrl) return raw
const normalizedBase = baseUrl.replace(/\/+$/, '')
const normalizedPath = raw.startsWith('/') ? raw : `/${raw}`
return normalizedBase + normalizedPath
}
export function mapToDropdownItem(
raw: unknown,
schema: RemoteItemSchema,
options: { previewBaseUrl?: string } = {}
): DropdownItemShape {
const previewRaw = schema.preview_url_field
? String(getByPath(raw, schema.preview_url_field) ?? '')
: undefined
return {
id: String(getByPath(raw, schema.value_field) ?? ''),
name: resolveLabel(schema.label_field, raw),
description: schema.description_field
? resolveLabel(schema.description_field, raw)
: undefined,
preview_url: resolvePreviewUrl(previewRaw, options.previewBaseUrl)
}
}
export function extractItems(
response: unknown,
responseKey?: string
): unknown[] | null {
const data = responseKey ? getByPath(response, responseKey) : response
return Array.isArray(data) ? data : null
}
export function buildSearchText(raw: unknown, searchFields: string[]): string {
return searchFields
.map((field) => String(getByPath(raw, field) ?? ''))
.filter(Boolean)
.join(' ')
.toLowerCase()
}

View File

@@ -1,17 +0,0 @@
import axios from 'axios'
const BACKOFF_BASE_MS = 1000
const BACKOFF_CAP_MS = 16000
export function getBackoff(retryCount: number): number {
return Math.min(BACKOFF_BASE_MS * Math.pow(2, retryCount), BACKOFF_CAP_MS)
}
export function isRetriableError(err: unknown): boolean {
if (!axios.isAxiosError(err)) return true
if (err.code === 'ERR_CANCELED') return false
const status = err.response?.status
if (status == null) return true
if (status >= 500) return true
return status === 408 || status === 429
}

View File

@@ -1,187 +0,0 @@
import { AxiosError, AxiosHeaders } from 'axios'
import { describe, expect, it } from 'vitest'
import { getBackoff, isRetriableError } from '@/base/remote/retry'
import { summarizeError, summarizePayload } from '@/base/remote/diagnostics'
describe('getBackoff', () => {
it('grows exponentially from 1s', () => {
expect(getBackoff(1)).toBe(2000)
expect(getBackoff(2)).toBe(4000)
expect(getBackoff(3)).toBe(8000)
expect(getBackoff(4)).toBe(16000)
})
it('caps at 16s for higher attempt counts', () => {
expect(getBackoff(5)).toBe(16000)
expect(getBackoff(10)).toBe(16000)
expect(getBackoff(100)).toBe(16000)
})
})
describe('isRetriableError', () => {
function axiosErrorWithStatus(status: number): AxiosError {
return new AxiosError(
`HTTP ${status}`,
'ERR_BAD_RESPONSE',
undefined,
undefined,
{
status,
statusText: '',
headers: {},
config: { headers: new AxiosHeaders() },
data: null
}
)
}
it('retries non-axios errors (e.g. unexpected throws)', () => {
expect(isRetriableError(new Error('boom'))).toBe(true)
expect(isRetriableError('string error')).toBe(true)
expect(isRetriableError(undefined)).toBe(true)
})
it('retries axios errors with no response (network failures)', () => {
const err = new AxiosError('Network Error', 'ERR_NETWORK')
expect(isRetriableError(err)).toBe(true)
})
it('retries 5xx responses', () => {
expect(isRetriableError(axiosErrorWithStatus(500))).toBe(true)
expect(isRetriableError(axiosErrorWithStatus(502))).toBe(true)
expect(isRetriableError(axiosErrorWithStatus(503))).toBe(true)
})
it('retries 408 (request timeout) and 429 (too many requests)', () => {
expect(isRetriableError(axiosErrorWithStatus(408))).toBe(true)
expect(isRetriableError(axiosErrorWithStatus(429))).toBe(true)
})
it('does not retry other 4xx responses', () => {
expect(isRetriableError(axiosErrorWithStatus(400))).toBe(false)
expect(isRetriableError(axiosErrorWithStatus(401))).toBe(false)
expect(isRetriableError(axiosErrorWithStatus(403))).toBe(false)
expect(isRetriableError(axiosErrorWithStatus(404))).toBe(false)
})
})
describe('summarizeError', () => {
it('extracts message, code and status from an axios error', () => {
const err = new AxiosError(
'Request failed',
'ERR_BAD_RESPONSE',
undefined,
undefined,
{
status: 500,
statusText: '',
headers: {},
config: { headers: new AxiosHeaders() },
data: null
}
)
expect(summarizeError(err)).toEqual({
message: 'Request failed',
code: 'ERR_BAD_RESPONSE',
status: 500
})
})
it('does not include axios config, headers, request or response data', () => {
const authedConfig = {
url: '/voices',
method: 'get',
headers: new AxiosHeaders({ Authorization: 'Bearer SECRET-TOKEN-123' })
}
const err = new AxiosError(
'Request failed',
'ERR_BAD_RESPONSE',
authedConfig,
undefined,
{
status: 500,
statusText: '',
headers: { 'set-cookie': ['session=PRIVATE'] },
config: authedConfig,
data: { user_email: 'private@example.com' }
}
)
const summary = summarizeError(err)
expect(JSON.stringify(summary)).not.toContain('SECRET-TOKEN-123')
expect(JSON.stringify(summary)).not.toContain('PRIVATE')
expect(JSON.stringify(summary)).not.toContain('private@example.com')
expect(summary).not.toHaveProperty('config')
expect(summary).not.toHaveProperty('request')
expect(summary).not.toHaveProperty('response')
})
it('reports an axios network error with no response as undefined status', () => {
const err = new AxiosError('Network Error', 'ERR_NETWORK')
expect(summarizeError(err)).toEqual({
message: 'Network Error',
code: 'ERR_NETWORK',
status: undefined
})
})
it('summarizes a plain Error using its name and message', () => {
expect(summarizeError(new TypeError('boom'))).toEqual({
message: 'boom',
name: 'TypeError'
})
})
it('coerces non-Error throwables to a message string', () => {
expect(summarizeError('oops')).toEqual({ message: 'oops' })
expect(summarizeError(42)).toEqual({ message: '42' })
expect(summarizeError(null)).toEqual({ message: 'null' })
expect(summarizeError(undefined)).toEqual({ message: 'undefined' })
})
})
describe('summarizePayload', () => {
it('reports array length without exposing values', () => {
expect(
summarizePayload([{ secret: 'a' }, { secret: 'b' }, { secret: 'c' }])
).toEqual({
type: 'array',
length: 3
})
})
it('reports object keys without exposing values', () => {
expect(
summarizePayload({ user_email: 'private@example.com', voices: ['x'] })
).toEqual({
type: 'object',
keys: ['user_email', 'voices'],
keyCount: 2
})
})
it('caps the keys sample at 10 but reports the full key count', () => {
const big: Record<string, number> = {}
for (let i = 0; i < 25; i++) big[`k${i}`] = i
const summary = summarizePayload(big) as {
type: string
keys: string[]
keyCount: number
}
expect(summary.type).toBe('object')
expect(summary.keys).toHaveLength(10)
expect(summary.keyCount).toBe(25)
})
it('distinguishes null and undefined', () => {
expect(summarizePayload(null)).toEqual({ type: 'null' })
expect(summarizePayload(undefined)).toEqual({ type: 'undefined' })
})
it('reports primitive types without their value', () => {
expect(summarizePayload('hello')).toEqual({ type: 'string' })
expect(summarizePayload(123)).toEqual({ type: 'number' })
expect(summarizePayload(true)).toEqual({ type: 'boolean' })
})
})

View File

@@ -98,7 +98,6 @@ import { useQueueFeatureFlags } from '@/composables/queue/useQueueFeatureFlags'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useSurveyFeatureTracking } from '@/platform/surveys/useSurveyFeatureTracking'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
const emit = defineEmits<{
@@ -108,7 +107,6 @@ const emit = defineEmits<{
const { t } = useI18n()
const settingStore = useSettingStore()
const sidebarTabStore = useSidebarTabStore()
const { trackFeatureUsed } = useSurveyFeatureTracking('queue-progress-overlay')
const moreTooltipConfig = computed(() => buildTooltipConfig(t('g.more')))
const { isQueuePanelV2Enabled, isRunProgressBarEnabled } =
@@ -121,7 +119,6 @@ const onClearHistoryFromMenu = (close: () => void) => {
}
const onToggleDockedJobHistory = async (close: () => void) => {
trackFeatureUsed()
close()
try {
@@ -141,7 +138,6 @@ const onToggleDockedJobHistory = async (close: () => void) => {
}
const onToggleRunProgressBar = async () => {
trackFeatureUsed()
await settingStore.set(
'Comfy.Queue.ShowRunProgressBar',
!isRunProgressBarEnabled.value

View File

@@ -13,7 +13,7 @@
:selected-sort-mode="selectedSortMode"
:has-failed-jobs="hasFailedJobs"
@show-assets="$emit('showAssets')"
@update:selected-job-tab="onUpdateSelectedJobTab"
@update:selected-job-tab="$emit('update:selectedJobTab', $event)"
@update:selected-workflow-filter="
$emit('update:selectedWorkflowFilter', $event)
"
@@ -50,7 +50,6 @@ import type {
import type { MenuEntry } from '@/composables/queue/useJobMenu'
import { useJobMenu } from '@/composables/queue/useJobMenu'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { useSurveyFeatureTracking } from '@/platform/surveys/useSurveyFeatureTracking'
import QueueOverlayHeader from './QueueOverlayHeader.vue'
import JobContextMenu from './job/JobContextMenu.vue'
@@ -82,7 +81,6 @@ const emit = defineEmits<{
const currentMenuItem = ref<JobListItem | null>(null)
const jobContextMenuRef = ref<InstanceType<typeof JobContextMenu> | null>(null)
const { wrapWithErrorHandlingAsync } = useErrorHandling()
const { trackFeatureUsed } = useSurveyFeatureTracking('queue-progress-overlay')
const { jobMenuEntries } = useJobMenu(
() => currentMenuItem.value,
@@ -97,11 +95,6 @@ const onDeleteItemEvent = (item: JobListItem) => {
emit('deleteItem', item)
}
const onUpdateSelectedJobTab = (value: JobTab) => {
trackFeatureUsed()
emit('update:selectedJobTab', value)
}
const onMenuItem = (item: JobListItem, event: Event) => {
currentMenuItem.value = item
jobContextMenuRef.value?.open(event)

View File

@@ -66,7 +66,6 @@ import { useResultGallery } from '@/composables/queue/useResultGallery'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { useAssetSelectionStore } from '@/platform/assets/composables/useAssetSelectionStore'
import { isCloud } from '@/platform/distribution/types'
import { useSurveyFeatureTracking } from '@/platform/surveys/useSurveyFeatureTracking'
import { api } from '@/scripts/api'
import { useAssetsStore } from '@/stores/assetsStore'
import { useCommandStore } from '@/stores/commandStore'
@@ -94,7 +93,6 @@ const assetsStore = useAssetsStore()
const assetSelectionStore = useAssetSelectionStore()
const { showQueueClearHistoryDialog } = useQueueClearHistoryDialog()
const { wrapWithErrorHandlingAsync } = useErrorHandling()
const { trackFeatureUsed } = useSurveyFeatureTracking('queue-progress-overlay')
const {
totalPercentFormatted,
@@ -190,7 +188,6 @@ const {
const displayedJobGroups = computed(() => groupedJobItems.value)
const onCancelItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
trackFeatureUsed()
const jobId = item.taskRef?.jobId
if (!jobId) return
@@ -212,7 +209,6 @@ const onCancelItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
})
const onDeleteItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
trackFeatureUsed()
if (!item.taskRef) return
await queueStore.delete(item.taskRef)
})
@@ -228,12 +224,10 @@ const setExpanded = (expanded: boolean) => {
}
const viewAllJobs = () => {
trackFeatureUsed()
setExpanded(true)
}
const toggleAssetsSidebar = () => {
trackFeatureUsed()
sidebarTabStore.toggleSidebarTab('assets')
}
@@ -263,14 +257,12 @@ const focusAssetInSidebar = async (item: JobListItem) => {
const inspectJobAsset = wrapWithErrorHandlingAsync(
async (item: JobListItem) => {
trackFeatureUsed()
await openResultGallery(item)
await focusAssetInSidebar(item)
}
)
const cancelQueuedWorkflows = wrapWithErrorHandlingAsync(async () => {
trackFeatureUsed()
// Capture pending jobIds before clearing
const pendingJobIds = queueStore.pendingTasks
.map((task) => task.jobId)
@@ -283,7 +275,6 @@ const cancelQueuedWorkflows = wrapWithErrorHandlingAsync(async () => {
})
const interruptAll = wrapWithErrorHandlingAsync(async () => {
trackFeatureUsed()
const tasks = queueStore.runningTasks
const jobIds = tasks
.map((task) => task.jobId)
@@ -307,7 +298,6 @@ const interruptAll = wrapWithErrorHandlingAsync(async () => {
})
const onClearHistoryFromMenu = () => {
trackFeatureUsed()
showQueueClearHistoryDialog()
}
</script>

View File

@@ -122,7 +122,6 @@ import Button from '@/components/ui/button/Button.vue'
import { jobSortModes } from '@/composables/queue/useJobList'
import type { JobSortMode } from '@/composables/queue/useJobList'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { useSurveyFeatureTracking } from '@/platform/surveys/useSurveyFeatureTracking'
const {
hideShowAssetsAction = false,
@@ -148,7 +147,6 @@ const emit = defineEmits<{
}>()
const { t } = useI18n()
const { trackFeatureUsed } = useSurveyFeatureTracking('queue-progress-overlay')
const filterTooltipConfig = computed(() =>
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.filterBy'))
@@ -172,7 +170,6 @@ const onSelectWorkflowFilter = (
value: 'all' | 'current',
close: () => void
) => {
trackFeatureUsed()
selectWorkflowFilter(value)
close()
}
@@ -182,7 +179,6 @@ const selectSortMode = (value: JobSortMode) => {
}
const onSelectSortMode = (value: JobSortMode, close: () => void) => {
trackFeatureUsed()
selectSortMode(value)
close()
}

View File

@@ -2,16 +2,15 @@
<SidebarTabTemplate :title="$t('queue.jobHistory')">
<template #alt-title>
<div class="ml-auto flex shrink-0 items-center">
<JobHistoryActionsMenu @clear-history="onClearHistory" />
<JobHistoryActionsMenu @clear-history="showQueueClearHistoryDialog" />
</div>
</template>
<template #header>
<div class="flex flex-col gap-2 pb-1">
<div class="px-3 py-2">
<JobFilterTabs
:selected-job-tab="selectedJobTab"
v-model:selected-job-tab="selectedJobTab"
:has-failed-jobs="hasFailedJobs"
@update:selected-job-tab="onUpdateSelectedJobTab"
/>
</div>
<JobFilterActions
@@ -82,14 +81,13 @@ import JobHistoryActionsMenu from '@/components/queue/JobHistoryActionsMenu.vue'
import type { MenuEntry } from '@/composables/queue/useJobMenu'
import { useJobMenu } from '@/composables/queue/useJobMenu'
import { useJobList } from '@/composables/queue/useJobList'
import type { JobListItem, JobTab } from '@/composables/queue/useJobList'
import type { JobListItem } from '@/composables/queue/useJobList'
import { useQueueClearHistoryDialog } from '@/composables/queue/useQueueClearHistoryDialog'
import { useResultGallery } from '@/composables/queue/useResultGallery'
import { useErrorHandling } from '@/composables/useErrorHandling'
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
import MediaLightbox from '@/components/sidebar/tabs/queue/MediaLightbox.vue'
import Button from '@/components/ui/button/Button.vue'
import { useSurveyFeatureTracking } from '@/platform/surveys/useSurveyFeatureTracking'
import { useCommandStore } from '@/stores/commandStore'
import { useDialogStore } from '@/stores/dialogStore'
import { useExecutionStore } from '@/stores/executionStore'
@@ -106,17 +104,6 @@ const executionStore = useExecutionStore()
const queueStore = useQueueStore()
const { showQueueClearHistoryDialog } = useQueueClearHistoryDialog()
const { wrapWithErrorHandlingAsync } = useErrorHandling()
const { trackFeatureUsed } = useSurveyFeatureTracking('queue-progress-overlay')
const onClearHistory = () => {
trackFeatureUsed()
showQueueClearHistoryDialog()
}
const onUpdateSelectedJobTab = (value: JobTab) => {
trackFeatureUsed()
selectedJobTab.value = value
}
const {
selectedJobTab,
selectedWorkflowFilter,
@@ -158,7 +145,6 @@ const activeQueueSummary = computed(() => {
})
const clearQueuedWorkflows = wrapWithErrorHandlingAsync(async () => {
trackFeatureUsed()
const pendingJobIds = queueStore.pendingTasks
.map((task) => task.jobId)
.filter((id): id is string => typeof id === 'string' && id.length > 0)
@@ -174,7 +160,6 @@ const {
} = useResultGallery(() => filteredTasks.value)
const onViewItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
trackFeatureUsed()
const previewOutput = item.taskRef?.previewOutput
if (previewOutput?.is3D) {
@@ -209,12 +194,10 @@ const { jobMenuEntries, cancelJob } = useJobMenu(
)
const onCancelItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
trackFeatureUsed()
await cancelJob(item)
})
const onDeleteItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
trackFeatureUsed()
if (!item.taskRef) return
await queueStore.delete(item.taskRef)
})

View File

@@ -2700,19 +2700,6 @@
"placeholderUnknown": "Select media...",
"maxSelectionReached": "Maximum selection limit reached"
},
"remoteCombo": {
"loading": "Loading...",
"loadFailed": "Failed to load options",
"noResults": "No results found",
"refresh": "Refresh options",
"selectAriaLabel": "Select {field}",
"searchAriaLabel": "Search {field}",
"layoutSwitcherAriaLabel": "Layout switcher",
"layoutList": "List view",
"layoutGrid": "Grid view",
"playAudioPreview": "Play audio preview",
"pauseAudioPreview": "Pause audio preview"
},
"valueControl": {
"header": {
"prefix": "Automatically update the value",

View File

@@ -1,7 +1,6 @@
import { definePreset } from '@primevue/themes'
import Aura from '@primevue/themes/aura'
import * as Sentry from '@sentry/vue'
import { VueQueryPlugin } from '@tanstack/vue-query'
import { initializeApp } from 'firebase/app'
import { createPinia } from 'pinia'
import 'primeicons/primeicons.css'
@@ -12,8 +11,6 @@ import Tooltip from 'primevue/tooltip'
import { createApp } from 'vue'
import { VueFire, VueFireAuth } from 'vuefire'
import { createAppQueryClient } from '@/platform/remote/queryClient'
import { getFirebaseConfig } from '@/config/firebase'
import {
configValueOrDefault,
@@ -85,9 +82,7 @@ Sentry.init({
})
})
app.directive('tooltip', Tooltip)
const queryClient = createAppQueryClient()
app
.use(VueQueryPlugin, { queryClient })
.use(router)
.use(PrimeVue, {
theme: {

View File

@@ -1,3 +1,4 @@
import { zListAssetsResponse } from '@comfyorg/ingest-types/zod'
import { z } from 'zod'
// Zod schemas for asset API validation matching ComfyUI Assets REST API spec
@@ -21,9 +22,9 @@ const zAsset = z.object({
})
const zAssetResponse = z.object({
assets: z.array(zAsset).optional(),
total: z.number().optional(),
has_more: z.boolean().optional()
assets: z.array(zAsset),
total: zListAssetsResponse.shape.total,
has_more: zListAssetsResponse.shape.has_more
})
const zModelFolder = z.object({

View File

@@ -64,6 +64,16 @@ function buildResponse(
} as unknown as Response
}
function buildAssetListResponse(
assets: AssetItem[],
{
hasMore = false,
total = assets.length
}: { hasMore?: boolean; total?: number } = {}
): Response {
return buildResponse({ assets, total, has_more: hasMore })
}
function validAsset(overrides: Partial<AssetItem> = {}): AssetItem {
return {
id: 'asset-1',
@@ -218,7 +228,7 @@ describe(assetService.uploadAssetFromUrl, () => {
const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })]
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
fetchApiMock
.mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
.mockResolvedValueOnce(buildAssetListResponse(staleAssets))
.mockResolvedValueOnce(buildResponse({ id: 'missing-name' }))
await assetService.getInputAssetsIncludingPublic()
@@ -240,7 +250,7 @@ describe(assetService.uploadAssetFromUrl, () => {
const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })]
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
fetchApiMock
.mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
.mockResolvedValueOnce(buildAssetListResponse(staleAssets))
.mockResolvedValueOnce(
buildResponse(validAsset({ id: 'uploaded-input', tags: ['input'] }))
)
@@ -301,7 +311,7 @@ describe(assetService.uploadAssetFromBase64, () => {
.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(new Response('hello'))
fetchApiMock
.mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
.mockResolvedValueOnce(buildAssetListResponse(staleAssets))
.mockResolvedValueOnce(buildResponse({ id: 'missing-name' }))
await assetService.getInputAssetsIncludingPublic()
@@ -327,7 +337,7 @@ describe(assetService.uploadAssetFromBase64, () => {
.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(new Response('hello'))
fetchApiMock
.mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
.mockResolvedValueOnce(buildAssetListResponse(staleAssets))
.mockResolvedValueOnce(
buildResponse({
...validAsset({ id: 'uploaded-input', tags: ['input'] }),
@@ -423,15 +433,13 @@ describe(assetService.getAssetModelFolders, () => {
it('filters out missing-tagged assets and blacklisted directories, returning alphabetical unique folders without include_public', async () => {
fetchApiMock.mockResolvedValueOnce(
buildResponse({
assets: [
validAsset({ id: 'a', tags: ['models', 'loras'] }),
validAsset({ id: 'b', tags: ['models', 'checkpoints'] }),
validAsset({ id: 'c', tags: ['models', 'configs'] }),
validAsset({ id: 'd', tags: ['models', 'missing', 'controlnet'] }),
validAsset({ id: 'e', tags: ['models', 'loras'] })
]
})
buildAssetListResponse([
validAsset({ id: 'a', tags: ['models', 'loras'] }),
validAsset({ id: 'b', tags: ['models', 'checkpoints'] }),
validAsset({ id: 'c', tags: ['models', 'configs'] }),
validAsset({ id: 'd', tags: ['models', 'missing', 'controlnet'] }),
validAsset({ id: 'e', tags: ['models', 'loras'] })
])
)
const folders = await assetService.getAssetModelFolders()
@@ -492,12 +500,10 @@ describe(assetService.getAssetsByTag, () => {
it('forwards include_public=true by default and excludes missing-tagged assets', async () => {
fetchApiMock.mockResolvedValueOnce(
buildResponse({
assets: [
validAsset({ id: 'visible', tags: ['input'] }),
validAsset({ id: 'hidden', tags: ['input', 'missing'] })
]
})
buildAssetListResponse([
validAsset({ id: 'visible', tags: ['input'] }),
validAsset({ id: 'hidden', tags: ['input', 'missing'] })
])
)
const assets = await assetService.getAssetsByTag('input')
@@ -518,17 +524,16 @@ describe(assetService.getAllAssetsByTag, () => {
it('paginates tagged asset requests with include_public=true', async () => {
fetchApiMock
.mockResolvedValueOnce(
buildResponse({
assets: [
buildAssetListResponse(
[
validAsset({ id: 'a', tags: ['input'] }),
validAsset({ id: 'b', tags: ['input'] })
]
})
],
{ hasMore: true }
)
)
.mockResolvedValueOnce(
buildResponse({
assets: [validAsset({ id: 'c', tags: ['input'] })]
})
buildAssetListResponse([validAsset({ id: 'c', tags: ['input'] })])
)
const assets = await assetService.getAllAssetsByTag('input', true, {
@@ -553,17 +558,18 @@ describe(assetService.getAllAssetsByTag, () => {
it('paginates from raw response size before filtering missing-tagged assets', async () => {
fetchApiMock
.mockResolvedValueOnce(
buildResponse({
assets: [
buildAssetListResponse(
[
validAsset({ id: 'visible', tags: ['input'] }),
validAsset({ id: 'hidden', tags: ['input', MISSING_TAG] })
]
})
],
{ hasMore: true }
)
)
.mockResolvedValueOnce(
buildResponse({
assets: [validAsset({ id: 'later-public', tags: ['input'] })]
})
buildAssetListResponse([
validAsset({ id: 'later-public', tags: ['input'] })
])
)
const assets = await assetService.getAllAssetsByTag('input', true, {
@@ -584,23 +590,22 @@ describe(assetService.getAllAssetsByTag, () => {
it('honors has_more when walking tagged asset pages', async () => {
fetchApiMock
.mockResolvedValueOnce(
buildResponse({
assets: [
buildAssetListResponse(
[
validAsset({ id: 'first', tags: ['input'] }),
validAsset({ id: 'second', tags: ['input'] })
],
has_more: true
})
{ hasMore: true }
)
)
.mockResolvedValueOnce(
buildResponse({
assets: [validAsset({ id: 'later-public', tags: ['input'] })],
has_more: false
})
buildAssetListResponse([
validAsset({ id: 'later-public', tags: ['input'] })
])
)
const assets = await assetService.getAllAssetsByTag('input', true, {
limit: 3
limit: 2
})
expect(assets.map((a) => a.id)).toEqual(['first', 'second', 'later-public'])
@@ -614,12 +619,23 @@ describe(assetService.getAllAssetsByTag, () => {
expect(secondParams.get('offset')).toBe('2')
})
it('rejects tagged asset pages without has_more', async () => {
fetchApiMock.mockResolvedValueOnce(
buildResponse({
assets: [validAsset({ id: 'a', tags: ['input'] })],
total: 1
})
)
await expect(
assetService.getAllAssetsByTag('input', true, { limit: 2 })
).rejects.toThrow(/Invalid asset response/)
})
it('passes abort signals through paginated requests', async () => {
const controller = new AbortController()
fetchApiMock.mockResolvedValueOnce(
buildResponse({
assets: [validAsset({ id: 'a', tags: ['input'] })]
})
buildAssetListResponse([validAsset({ id: 'a', tags: ['input'] })])
)
await assetService.getAllAssetsByTag('input', true, {
@@ -636,12 +652,13 @@ describe(assetService.getAllAssetsByTag, () => {
const controller = new AbortController()
fetchApiMock.mockImplementationOnce(async () => {
controller.abort()
return buildResponse({
assets: [
return buildAssetListResponse(
[
validAsset({ id: 'a', tags: ['input'] }),
validAsset({ id: 'b', tags: ['input'] })
]
})
],
{ hasMore: true }
)
})
await expect(
@@ -666,7 +683,7 @@ describe(assetService.getInputAssetsIncludingPublic, () => {
validAsset({ id: 'user-input', tags: ['input'] }),
validAsset({ id: 'public-input', tags: ['input'], is_immutable: true })
]
fetchApiMock.mockResolvedValueOnce(buildResponse({ assets }))
fetchApiMock.mockResolvedValueOnce(buildAssetListResponse(assets))
const first = await assetService.getInputAssetsIncludingPublic()
const second = await assetService.getInputAssetsIncludingPublic()
@@ -685,8 +702,8 @@ describe(assetService.getInputAssetsIncludingPublic, () => {
const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })]
const freshAssets = [validAsset({ id: 'fresh-input', tags: ['input'] })]
fetchApiMock
.mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
.mockResolvedValueOnce(buildResponse({ assets: freshAssets }))
.mockResolvedValueOnce(buildAssetListResponse(staleAssets))
.mockResolvedValueOnce(buildAssetListResponse(freshAssets))
await assetService.getInputAssetsIncludingPublic()
assetService.invalidateInputAssetsIncludingPublic()
@@ -720,7 +737,7 @@ describe(assetService.getInputAssetsIncludingPublic, () => {
await expect(first).rejects.toMatchObject({ name: 'AbortError' })
expect(serviceSignal).toBeUndefined()
resolveResponse(buildResponse({ assets }))
resolveResponse(buildAssetListResponse(assets))
await expect(second).resolves.toEqual(assets)
expect(fetchApiMock).toHaveBeenCalledOnce()
@@ -750,7 +767,7 @@ describe(assetService.getInputAssetsIncludingPublic, () => {
await expect(first).rejects.toMatchObject({ name: 'AbortError' })
await expect(second).rejects.toMatchObject({ name: 'AbortError' })
resolveResponse(buildResponse({ assets }))
resolveResponse(buildAssetListResponse(assets))
await Promise.resolve()
await expect(assetService.getInputAssetsIncludingPublic()).resolves.toEqual(
@@ -770,12 +787,12 @@ describe(assetService.getInputAssetsIncludingPublic, () => {
resolveResponse = resolve
})
)
.mockResolvedValueOnce(buildResponse({ assets: freshAssets }))
.mockResolvedValueOnce(buildAssetListResponse(freshAssets))
const inFlight = assetService.getInputAssetsIncludingPublic()
assetService.invalidateInputAssetsIncludingPublic()
resolveResponse(buildResponse({ assets }))
resolveResponse(buildAssetListResponse(assets))
await expect(inFlight).resolves.toEqual(assets)
await expect(assetService.getInputAssetsIncludingPublic()).resolves.toEqual(
@@ -788,9 +805,9 @@ describe(assetService.getInputAssetsIncludingPublic, () => {
const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })]
const freshAssets = [validAsset({ id: 'fresh-input', tags: ['input'] })]
fetchApiMock
.mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
.mockResolvedValueOnce(buildAssetListResponse(staleAssets))
.mockResolvedValueOnce(buildResponse(null))
.mockResolvedValueOnce(buildResponse({ assets: freshAssets }))
.mockResolvedValueOnce(buildAssetListResponse(freshAssets))
await assetService.getInputAssetsIncludingPublic()
await assetService.deleteAsset('stale-input')
@@ -809,9 +826,9 @@ describe(assetService.getInputAssetsIncludingPublic, () => {
const uploadedAsset = validAsset({ id: 'uploaded-input', tags: ['input'] })
const freshAssets = [uploadedAsset]
fetchApiMock
.mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
.mockResolvedValueOnce(buildAssetListResponse(staleAssets))
.mockResolvedValueOnce(buildResponse(uploadedAsset))
.mockResolvedValueOnce(buildResponse({ assets: freshAssets }))
.mockResolvedValueOnce(buildAssetListResponse(freshAssets))
await assetService.getInputAssetsIncludingPublic()
await assetService.uploadAssetAsync({
@@ -827,7 +844,7 @@ describe(assetService.getInputAssetsIncludingPublic, () => {
it('does not invalidate cached input assets for pending async input uploads', async () => {
const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })]
fetchApiMock
.mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
.mockResolvedValueOnce(buildAssetListResponse(staleAssets))
.mockResolvedValueOnce(
buildResponse(
{ task_id: 'task-1', status: 'running' },
@@ -849,7 +866,7 @@ describe(assetService.getInputAssetsIncludingPublic, () => {
it('does not invalidate cached input assets for non-input uploads', async () => {
const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })]
fetchApiMock
.mockResolvedValueOnce(buildResponse({ assets: staleAssets }))
.mockResolvedValueOnce(buildAssetListResponse(staleAssets))
.mockResolvedValueOnce(buildResponse(validAsset({ tags: ['models'] })))
await assetService.getInputAssetsIncludingPublic()

View File

@@ -473,11 +473,8 @@ function createAssetService() {
}
const data = await res.json()
// Validate the single asset response against our schema
const result = assetResponseSchema.safeParse({ assets: [data] })
if (result.success && result.data.assets?.[0]) {
return result.data.assets[0]
}
const result = assetItemSchema.safeParse(data)
if (result.success) return result.data
const error = result.error
? fromZodError(result.error)
@@ -548,10 +545,7 @@ function createAssetService() {
const batch = data.assets ?? []
assets.push(...batch.filter((asset) => !asset.tags.includes(MISSING_TAG)))
const noMoreFromServer = data.has_more === false
const inferredLastPage =
data.has_more === undefined && batch.length < pageSize
if (batch.length === 0 || noMoreFromServer || inferredLastPage) {
if (batch.length === 0 || !data.has_more) {
return assets
}

View File

@@ -0,0 +1,140 @@
import { describe, expect, it, vi } from 'vitest'
import { verifyCandidatesByAssetHash } from './assetHashVerification'
interface Candidate {
id: string
hash: string | null
}
function candidate(id: string, hash: string | null): Candidate {
return { id, hash }
}
describe(verifyCandidatesByAssetHash, () => {
it('deduplicates hash checks and groups existing and missing candidates', async () => {
const existingHash = 'blake3:existing'
const missingHash = 'blake3:missing'
const candidates = [
candidate('a', existingHash),
candidate('b', existingHash),
candidate('c', missingHash)
]
const checkAssetHash = vi.fn(async (hash: string) =>
hash === existingHash ? ('exists' as const) : ('missing' as const)
)
const result = await verifyCandidatesByAssetHash({
candidates,
getAssetHash: (candidate) => candidate.hash,
checkAssetHash
})
expect(result.aborted).toBe(false)
expect(result.existing.map((candidate) => candidate.id)).toEqual(['a', 'b'])
expect(result.missing.map((candidate) => candidate.id)).toEqual(['c'])
expect(result.fallback).toEqual([])
expect(checkAssetHash).toHaveBeenCalledTimes(2)
})
it('routes candidates without hashes and invalid hashes to fallback', async () => {
const invalidHash = 'blake3:invalid'
const candidates = [candidate('a', null), candidate('b', invalidHash)]
const checkAssetHash = vi.fn(async () => 'invalid' as const)
const result = await verifyCandidatesByAssetHash({
candidates,
getAssetHash: (candidate) => candidate.hash,
checkAssetHash
})
expect(result.existing).toEqual([])
expect(result.missing).toEqual([])
expect(result.fallback.map((candidate) => candidate.id)).toEqual(['a', 'b'])
expect(checkAssetHash).toHaveBeenCalledOnce()
})
it('routes non-abort verification failures to fallback', async () => {
const candidates = [candidate('a', 'blake3:network-failure')]
const error = new Error('network failed')
const onError = vi.fn()
const result = await verifyCandidatesByAssetHash({
candidates,
getAssetHash: (candidate) => candidate.hash,
checkAssetHash: async () => {
throw error
},
onError
})
expect(result.fallback).toEqual(candidates)
expect(result.aborted).toBe(false)
expect(onError).toHaveBeenCalledWith(error)
})
it('returns aborted without resolving candidates when the signal is aborted', async () => {
const controller = new AbortController()
controller.abort()
const candidates = [candidate('a', 'blake3:aborted')]
const checkAssetHash = vi.fn(async () => 'exists' as const)
const result = await verifyCandidatesByAssetHash({
candidates,
getAssetHash: (candidate) => candidate.hash,
checkAssetHash,
signal: controller.signal
})
expect(result).toEqual({
existing: [],
missing: [],
fallback: [],
aborted: true
})
expect(checkAssetHash).not.toHaveBeenCalled()
})
it('silences abort errors from hash verification', async () => {
const candidates = [candidate('a', 'blake3:aborted')]
const onError = vi.fn()
const result = await verifyCandidatesByAssetHash({
candidates,
getAssetHash: (candidate) => candidate.hash,
checkAssetHash: async () => {
throw new DOMException('aborted', 'AbortError')
},
onError
})
expect(result.aborted).toBe(true)
expect(result.existing).toEqual([])
expect(result.missing).toEqual([])
expect(result.fallback).toEqual([])
expect(onError).not.toHaveBeenCalled()
})
it('caps concurrent hash checks', async () => {
let activeChecks = 0
let maxActiveChecks = 0
const candidates = Array.from({ length: 6 }, (_, index) =>
candidate(String(index), `blake3:${index}`)
)
await verifyCandidatesByAssetHash({
candidates,
getAssetHash: (candidate) => candidate.hash,
maxConcurrent: 2,
checkAssetHash: async () => {
activeChecks++
maxActiveChecks = Math.max(maxActiveChecks, activeChecks)
await new Promise((resolve) => setTimeout(resolve, 1))
activeChecks--
return 'missing'
}
})
expect(maxActiveChecks).toBeLessThanOrEqual(2)
})
})

View File

@@ -0,0 +1,111 @@
import type { AssetHashStatus } from '@/platform/assets/services/assetService'
import { isAbortError } from '@/utils/typeGuardUtil'
export type AssetHashVerifier = (
assetHash: string,
signal?: AbortSignal
) => Promise<AssetHashStatus>
interface AssetHashVerificationResult<T> {
existing: T[]
missing: T[]
fallback: T[]
aborted: boolean
}
interface VerifyCandidatesByAssetHashOptions<T> {
candidates: readonly T[]
getAssetHash: (candidate: T) => string | null
checkAssetHash: AssetHashVerifier
signal?: AbortSignal
maxConcurrent?: number
onError?: (error: unknown) => void
}
const DEFAULT_MAX_CONCURRENT_HASH_CHECKS = 12
export async function verifyCandidatesByAssetHash<T>({
candidates,
getAssetHash,
checkAssetHash,
signal,
maxConcurrent = DEFAULT_MAX_CONCURRENT_HASH_CHECKS,
onError
}: VerifyCandidatesByAssetHashOptions<T>): Promise<
AssetHashVerificationResult<T>
> {
const result: AssetHashVerificationResult<T> = {
existing: [],
missing: [],
fallback: [],
aborted: false
}
if (signal?.aborted) return { ...result, aborted: true }
const candidatesByHash = new Map<string, T[]>()
for (const candidate of candidates) {
const assetHash = getAssetHash(candidate)
if (!assetHash) {
result.fallback.push(candidate)
continue
}
const hashCandidates = candidatesByHash.get(assetHash)
if (hashCandidates) hashCandidates.push(candidate)
else candidatesByHash.set(assetHash, [candidate])
}
const entries = [...candidatesByHash.entries()]
let nextIndex = 0
const workerCount = Math.min(
entries.length,
Math.max(1, Math.floor(maxConcurrent))
)
async function verifyNextHash(): Promise<void> {
while (!result.aborted && nextIndex < entries.length) {
const entry = entries[nextIndex++]
if (!entry) return
const [assetHash, hashCandidates] = entry
if (signal?.aborted) {
result.aborted = true
return
}
let status: AssetHashStatus
try {
status = await checkAssetHash(assetHash, signal)
} catch (error) {
if (signal?.aborted || isAbortError(error)) {
result.aborted = true
return
}
onError?.(error)
result.fallback.push(...hashCandidates)
continue
}
if (signal?.aborted) {
result.aborted = true
return
}
if (status === 'exists') {
result.existing.push(...hashCandidates)
} else if (status === 'missing') {
result.missing.push(...hashCandidates)
} else {
result.fallback.push(...hashCandidates)
}
}
}
await Promise.all(
Array.from({ length: workerCount }, async () => await verifyNextHash())
)
return result
}

View File

@@ -403,8 +403,7 @@ describe('verifyCloudMediaCandidates', () => {
})
it('silences aborts while loading legacy fallback input assets', async () => {
const abortError = new Error('aborted')
abortError.name = 'AbortError'
const abortError = new DOMException('aborted', 'AbortError')
const controller = new AbortController()
const candidates = [
makeCandidate('1', 'photo.png', { isMissing: undefined })
@@ -427,8 +426,7 @@ describe('verifyCloudMediaCandidates', () => {
})
it('silences aborts from the default legacy fallback input asset store path', async () => {
const abortError = new Error('aborted')
abortError.name = 'AbortError'
const abortError = new DOMException('aborted', 'AbortError')
const controller = new AbortController()
const candidates = [
makeCandidate('1', 'photo.png', { isMissing: undefined })

View File

@@ -19,11 +19,13 @@ import {
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
import { resolveComboValues } from '@/utils/litegraphUtil'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import type { AssetHashStatus } from '@/platform/assets/services/assetService'
import {
assetService,
isBlake3AssetHash
} from '@/platform/assets/services/assetService'
import { verifyCandidatesByAssetHash } from '@/platform/assets/utils/assetHashVerification'
import type { AssetHashVerifier } from '@/platform/assets/utils/assetHashVerification'
import { isAbortError } from '@/utils/typeGuardUtil'
/** Map of node types to their media widget name and media type. */
const MEDIA_NODE_WIDGETS: Record<
@@ -112,70 +114,8 @@ export function scanNodeMediaCandidates(
return candidates
}
type AssetHashVerifier = (
assetHash: string,
signal?: AbortSignal
) => Promise<AssetHashStatus>
type InputAssetFetcher = (signal?: AbortSignal) => Promise<AssetItem[]>
function groupCandidatesForHashLookup(candidates: MissingMediaCandidate[]): {
candidatesByHash: Map<string, MissingMediaCandidate[]>
legacyCandidates: MissingMediaCandidate[]
} {
const candidatesByHash = new Map<string, MissingMediaCandidate[]>()
const legacyCandidates: MissingMediaCandidate[] = []
for (const candidate of candidates) {
if (!isBlake3AssetHash(candidate.name)) {
legacyCandidates.push(candidate)
continue
}
const hashCandidates = candidatesByHash.get(candidate.name)
if (hashCandidates) hashCandidates.push(candidate)
else candidatesByHash.set(candidate.name, [candidate])
}
return { candidatesByHash, legacyCandidates }
}
async function verifyCandidatesByHash(
candidatesByHash: Map<string, MissingMediaCandidate[]>,
legacyCandidates: MissingMediaCandidate[],
signal: AbortSignal | undefined,
checkAssetHash: AssetHashVerifier
): Promise<void> {
await Promise.all(
Array.from(candidatesByHash, async ([assetHash, hashCandidates]) => {
if (signal?.aborted) return
let status: AssetHashStatus
try {
status = await checkAssetHash(assetHash, signal)
if (signal?.aborted) return
} catch (err) {
if (signal?.aborted || isAbortError(err)) return
console.warn(
'[Missing Media Pipeline] Failed to verify asset hash:',
err
)
legacyCandidates.push(...hashCandidates)
return
}
if (status === 'invalid') {
legacyCandidates.push(...hashCandidates)
return
}
for (const candidate of hashCandidates) {
candidate.isMissing = status === 'missing'
}
})
)
}
/**
* Verify cloud media candidates by probing the asset hash endpoint first.
* Invalid hash values fall back to the legacy input asset list check.
@@ -191,16 +131,26 @@ export async function verifyCloudMediaCandidates(
const pending = candidates.filter((c) => c.isMissing === undefined)
if (pending.length === 0) return
const { candidatesByHash, legacyCandidates } =
groupCandidatesForHashLookup(pending)
await verifyCandidatesByHash(
candidatesByHash,
legacyCandidates,
const verification = await verifyCandidatesByAssetHash({
candidates: pending,
getAssetHash: (candidate) =>
isBlake3AssetHash(candidate.name) ? candidate.name : null,
signal,
checkAssetHash
)
checkAssetHash,
onError: (err) => {
console.warn('[Missing Media Pipeline] Failed to verify asset hash:', err)
}
})
if (verification.aborted) return
if (signal?.aborted || legacyCandidates.length === 0) return
for (const candidate of verification.existing) {
candidate.isMissing = false
}
for (const candidate of verification.missing) {
candidate.isMissing = true
}
if (signal?.aborted || verification.fallback.length === 0) return
let inputAssets: AssetItem[]
try {
@@ -216,7 +166,7 @@ export async function verifyCloudMediaCandidates(
inputAssets.map((a) => a.asset_hash).filter((h): h is string => !!h)
)
for (const candidate of legacyCandidates) {
for (const candidate of verification.fallback) {
candidate.isMissing = !assetHashes.has(candidate.name)
}
}
@@ -227,15 +177,6 @@ async function fetchMissingInputAssets(
return await assetService.getInputAssetsIncludingPublic(signal)
}
function isAbortError(err: unknown): boolean {
return (
typeof err === 'object' &&
err !== null &&
'name' in err &&
err.name === 'AbortError'
)
}
/** Group confirmed-missing candidates by file name into view models. */
export function groupCandidatesByName(
candidates: MissingMediaCandidate[]

View File

@@ -1557,8 +1557,7 @@ describe('verifyAssetSupportedCandidates', () => {
it('should not warn or fall back when hash verification is aborted', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const abortError = new Error('aborted')
abortError.name = 'AbortError'
const abortError = new DOMException('aborted', 'AbortError')
const hash =
'4444444444444444444444444444444444444444444444444444444444444444'
const candidates = [

View File

@@ -24,11 +24,12 @@ import {
} from '@/utils/graphTraversalUtil'
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
import { resolveComboValues } from '@/utils/litegraphUtil'
import type { AssetHashStatus } from '@/platform/assets/services/assetService'
import {
assetService,
toBlake3AssetHash
} from '@/platform/assets/services/assetService'
import { verifyCandidatesByAssetHash } from '@/platform/assets/utils/assetHashVerification'
import type { AssetHashVerifier } from '@/platform/assets/utils/assetHashVerification'
export type MissingModelWorkflowData = FlattenableWorkflowGraph & {
models?: ModelFile[]
@@ -450,11 +451,6 @@ interface AssetVerifier {
getAssets: (nodeType: string) => AssetItem[] | undefined
}
type AssetHashVerifier = (
assetHash: string,
signal?: AbortSignal
) => Promise<AssetHashStatus>
export async function verifyAssetSupportedCandidates(
candidates: MissingModelCandidate[],
signal?: AbortSignal,
@@ -468,47 +464,25 @@ export async function verifyAssetSupportedCandidates(
)
if (pendingCandidates.length === 0) return
const pendingNodeTypes = new Set<string>()
const candidatesByHash = new Map<string, MissingModelCandidate[]>()
for (const candidate of pendingCandidates) {
const assetHash = getBlake3AssetHash(candidate)
if (!assetHash) {
pendingNodeTypes.add(candidate.nodeType)
continue
const verification = await verifyCandidatesByAssetHash({
candidates: pendingCandidates,
getAssetHash: getBlake3AssetHash,
signal,
checkAssetHash,
onError: (err) => {
console.warn('[Missing Model Pipeline] Failed to verify asset hash:', err)
}
})
if (verification.aborted) return
const hashCandidates = candidatesByHash.get(assetHash)
if (hashCandidates) hashCandidates.push(candidate)
else candidatesByHash.set(assetHash, [candidate])
for (const candidate of verification.existing) {
candidate.isMissing = false
}
await Promise.all(
Array.from(candidatesByHash, async ([assetHash, hashCandidates]) => {
if (signal?.aborted) return
try {
const status = await checkAssetHash(assetHash, signal)
if (signal?.aborted) return
if (status === 'exists') {
for (const candidate of hashCandidates) {
candidate.isMissing = false
}
return
}
} catch (err) {
if (signal?.aborted || isAbortError(err)) return
console.warn(
'[Missing Model Pipeline] Failed to verify asset hash:',
err
)
}
for (const candidate of hashCandidates) {
pendingNodeTypes.add(candidate.nodeType)
}
})
const pendingNodeTypes = new Set(
[...verification.missing, ...verification.fallback].map(
(candidate) => candidate.nodeType
)
)
if (signal?.aborted) return
@@ -549,15 +523,6 @@ function getBlake3AssetHash(candidate: MissingModelCandidate): string | null {
return toBlake3AssetHash(candidate.hash)
}
function isAbortError(err: unknown): boolean {
return (
typeof err === 'object' &&
err !== null &&
'name' in err &&
err.name === 'AbortError'
)
}
function normalizePath(path: string): string {
return path.replace(/\\/g, '/')
}

View File

@@ -6,6 +6,7 @@ import { t } from '@/i18n'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { app } from '@/scripts/app'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { isAbortError } from '@/utils/typeGuardUtil'
import type { MissingModelCandidate } from '@/platform/missingModel/types'
import type { AssetMetadata } from '@/platform/assets/schemas/assetSchema'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
@@ -273,10 +274,6 @@ export const useMissingModelStore = defineStore('missingModel', () => {
fileSizes.value = {}
}
function isAbortError(error: unknown) {
return error instanceof Error && error.name === 'AbortError'
}
async function refreshMissingModels() {
if (isRefreshingMissingModels.value) return

View File

@@ -1,128 +0,0 @@
import { createTestingPinia } from '@pinia/testing'
import { QueryClient, VueQueryPlugin } from '@tanstack/vue-query'
import type * as AxiosModule from 'axios'
import { describe, expect, it, vi } from 'vitest'
import { createApp, effectScope, h } from 'vue'
import { useRemoteOptions } from '@/platform/remote/composables/useRemoteOptions'
import { remoteOptionKeys } from '@/platform/remote/queryKeys'
import type { RemoteRequestDescriptor } from '@/platform/remote/schema/remoteRequestSchema'
vi.mock('axios', async (importOriginal) => {
const actual = await importOriginal<typeof AxiosModule>()
return {
...actual,
default: { ...actual.default, get: vi.fn() }
}
})
vi.mock('@/platform/workspace/stores/workspaceAuthStore', () => ({
useWorkspaceAuthStore: () => ({ currentWorkspace: null })
}))
vi.mock('@/stores/authStore', () => ({
useAuthStore: () => ({
userId: 'u1',
getAuthHeader: vi.fn(() => Promise.resolve(null))
})
}))
function createTestQueryClient() {
return new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: 0 } }
})
}
function withSetup<T>(setup: () => T): { result: T; cleanup: () => void } {
let result!: T
const queryClient = createTestQueryClient()
const app = createApp({
setup() {
result = setup()
return () => h('div')
}
})
app.use(createTestingPinia({ createSpy: vi.fn }))
app.use(VueQueryPlugin, { queryClient })
const container = document.createElement('div')
app.mount(container)
return {
result,
cleanup: () => {
app.unmount()
}
}
}
const desc: RemoteRequestDescriptor = {
client: 'comfyApi',
route: '/test'
}
describe('useRemoteOptions', () => {
it('builds a stable, scope-aware query key', () => {
const key = remoteOptionKeys.byRoute(desc, {
userId: 'u1',
workspaceId: 'w1'
})
expect(key).toContain('comfyApi')
expect(key).toContain('/test')
expect(key).toContain('u1')
expect(key).toContain('w1')
})
it('partitions by route', () => {
const a = remoteOptionKeys.byRoute(
{ client: 'comfyApi', route: '/a' },
{ userId: 'u1', workspaceId: null }
)
const b = remoteOptionKeys.byRoute(
{ client: 'comfyApi', route: '/b' },
{ userId: 'u1', workspaceId: null }
)
expect(JSON.stringify(a)).not.toBe(JSON.stringify(b))
})
it('partitions by workspaceId', () => {
const a = remoteOptionKeys.byRoute(desc, {
userId: 'u1',
workspaceId: 'w1'
})
const b = remoteOptionKeys.byRoute(desc, {
userId: 'u1',
workspaceId: 'w2'
})
expect(JSON.stringify(a)).not.toBe(JSON.stringify(b))
})
it('partitions anonymous from api-key sessions even when userId/workspaceId match', () => {
const anon = remoteOptionKeys.byRoute(desc, {
userId: null,
workspaceId: null,
apiKeyBucket: 'anon'
})
const apikey = remoteOptionKeys.byRoute(desc, {
userId: null,
workspaceId: null,
apiKeyBucket: 'apikey'
})
expect(JSON.stringify(anon)).not.toBe(JSON.stringify(apikey))
})
it('returns disabled state when descriptor is null', async () => {
const scope = effectScope()
let result!: ReturnType<typeof useRemoteOptions>
let cleanup = () => {}
scope.run(() => {
const mounted = withSetup(() =>
useRemoteOptions({
descriptor: null
})
)
result = mounted.result
cleanup = mounted.cleanup
})
expect(result.isLoading.value).toBe(false)
cleanup()
scope.stop()
})
})

View File

@@ -1,132 +0,0 @@
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import axios from 'axios'
import { computed, toValue } from 'vue'
import type { ComputedRef, MaybeRefOrGetter } from 'vue'
import { isRetriableError } from '@/base/remote/retry'
import { getComfyApiBaseUrl } from '@/config/comfyApi'
import { remoteOptionKeys } from '@/platform/remote/queryKeys'
import type {
RemoteAuthScope,
RemoteRequestDescriptor
} from '@/platform/remote/schema/remoteRequestSchema'
import { useWorkspaceAuthStore } from '@/platform/workspace/stores/workspaceAuthStore'
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
import { useAuthStore } from '@/stores/authStore'
const DEFAULT_TIMEOUT_MS = 30_000
const DEFAULT_MAX_RETRIES = 3
function resolveUrl(
descriptor: RemoteRequestDescriptor,
baseUrl: string
): string {
if (descriptor.client === 'comfyApi') {
return baseUrl + descriptor.route
}
return descriptor.route
}
async function executeRemoteRequest(
descriptor: RemoteRequestDescriptor,
signal: AbortSignal
): Promise<unknown> {
let headers: Record<string, string> | undefined
if (descriptor.client === 'comfyApi') {
const authStore = useAuthStore()
const authHeader = await authStore.getAuthHeader()
headers = authHeader ? { ...authHeader } : undefined
}
const url = resolveUrl(descriptor, getComfyApiBaseUrl())
const response = await axios.get(url, {
params: descriptor.params,
timeout: descriptor.timeout ?? DEFAULT_TIMEOUT_MS,
signal,
...(headers ? { headers } : {})
})
return response.data
}
interface UseRemoteOptionsResult<T> {
data: ComputedRef<T | undefined>
rawData: ComputedRef<unknown>
isLoading: ComputedRef<boolean>
isFetching: ComputedRef<boolean>
error: ComputedRef<Error | null>
refetch: () => Promise<unknown>
invalidate: () => Promise<void>
}
interface UseRemoteOptionsArgs<T> {
descriptor: MaybeRefOrGetter<RemoteRequestDescriptor | null | undefined>
enabled?: MaybeRefOrGetter<boolean>
select?: (raw: unknown) => T
}
export function useRemoteOptions<T = unknown>(
args: UseRemoteOptionsArgs<T>
): UseRemoteOptionsResult<T> {
const queryClient = useQueryClient()
const authStore = useAuthStore()
const workspaceStore = useWorkspaceAuthStore()
const apiKeyStore = useApiKeyAuthStore()
const scope = computed<RemoteAuthScope>(() => ({
userId: authStore.userId ?? null,
workspaceId: workspaceStore.currentWorkspace?.id ?? null,
apiKeyBucket: apiKeyStore.getApiKey() ? 'apikey' : 'anon'
}))
const queryKey = computed(() => {
const descriptor = toValue(args.descriptor)
if (!descriptor) {
return [...remoteOptionKeys.all(), 'disabled'] as const
}
return remoteOptionKeys.byRoute(descriptor, scope.value)
})
const enabled = computed(() => {
const userEnabled = toValue(args.enabled)
const hasDescriptor = !!toValue(args.descriptor)
return hasDescriptor && (userEnabled === undefined || userEnabled)
})
const query = useQuery({
queryKey,
enabled,
queryFn: async ({ signal }) => {
const descriptor = toValue(args.descriptor)
if (!descriptor) {
throw new Error('useRemoteOptions: descriptor is required')
}
return executeRemoteRequest(descriptor, signal)
},
retry: (failureCount, error) => {
const descriptor = toValue(args.descriptor)
const max = descriptor?.maxRetries ?? DEFAULT_MAX_RETRIES
return failureCount < max && isRetriableError(error)
},
staleTime: computed(() => toValue(args.descriptor)?.ttl ?? 0)
})
const data = computed<T | undefined>(() => {
const raw = query.data.value
if (raw === undefined) return undefined
if (args.select) return args.select(raw)
return raw as T
})
const invalidate = async () => {
await queryClient.invalidateQueries({ queryKey: queryKey.value })
}
return {
data,
rawData: computed(() => query.data.value),
isLoading: computed(() => query.isLoading.value),
isFetching: computed(() => query.isFetching.value),
error: computed(() => query.error.value),
refetch: () => query.refetch(),
invalidate
}
}

View File

@@ -1,43 +0,0 @@
import { QueryClient } from '@tanstack/vue-query'
import { isRetriableError } from '@/base/remote/retry'
const DEFAULT_GC_TIME_MS = 5 * 60_000
const DEFAULT_RETRY_COUNT = 3
let appQueryClient: QueryClient | undefined
/**
* Create the application-wide TanStack Query client.
*
* Defaults are tuned for remote-option dropdowns and similar widget data:
* - `staleTime: 0` so refresh buttons always re-fetch
* - `gcTime` bounded so a session's footprint stays small (no LRU yet)
* - `retry` driven by {@link isRetriableError} from `base/remote/retry`
* - `refetchOnWindowFocus: false` to avoid surprise re-fetches mid-edit
*
* QueryClient lifetime is bound to the Vue app instance; auth-state changes
* tear down the authenticated layout subtree (see master plan §8), so the
* cache is naturally evicted without manual `queryClient.clear()` calls.
*/
export function createAppQueryClient(): QueryClient {
appQueryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 0,
gcTime: DEFAULT_GC_TIME_MS,
retry: (failureCount, error) =>
failureCount < DEFAULT_RETRY_COUNT && isRetriableError(error),
refetchOnWindowFocus: false
}
}
})
return appQueryClient
}
export function getAppQueryClient(): QueryClient {
if (!appQueryClient) {
appQueryClient = createAppQueryClient()
}
return appQueryClient
}

View File

@@ -1,26 +0,0 @@
import type {
RemoteAuthScope,
RemoteRequestDescriptor
} from '@/platform/remote/schema/remoteRequestSchema'
function sortedParams(
params?: Record<string, string>
): Array<[string, string]> {
if (!params) return []
return Object.entries(params).sort(([a], [b]) => a.localeCompare(b))
}
export const remoteOptionKeys = {
all: () => ['remote-options'] as const,
byRoute: (descriptor: RemoteRequestDescriptor, scope: RemoteAuthScope) =>
[
...remoteOptionKeys.all(),
descriptor.client,
descriptor.route,
descriptor.responseKey ?? '',
sortedParams(descriptor.params),
scope.workspaceId ?? null,
scope.userId ?? null,
scope.apiKeyBucket ?? null
] as const
}

View File

@@ -1,19 +0,0 @@
export type RemoteRequestClient = 'comfyApi'
export interface RemoteRequestDescriptor {
client: RemoteRequestClient
route: string
params?: Record<string, string>
responseKey?: string
ttl?: number
timeout?: number
maxRetries?: number
}
export type RemoteAuthBucket = 'apikey' | 'anon'
export interface RemoteAuthScope {
userId?: string | null
workspaceId?: string | null
apiKeyBucket?: RemoteAuthBucket | null
}

View File

@@ -11,12 +11,6 @@ export const FEATURE_SURVEYS: Record<string, FeatureSurveyConfig> = {
triggerThreshold: 3,
delayMs: 5000
},
'queue-progress-overlay': {
featureId: 'queue-progress-overlay',
typeformId: 'HZ5saxry',
triggerThreshold: 16,
delayMs: 5000
},
'error-panel': {
featureId: 'error-panel',
typeformId: 'iFp4p4mV',

View File

@@ -1,70 +0,0 @@
import { describe, expect, it } from 'vitest'
import { comboAdapter } from '@/renderer/extensions/vueNodes/widgets/adapters/comboAdapter'
import type { ComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
function makeSpec(overrides: Partial<ComboInputSpec> = {}): ComboInputSpec {
return {
name: 'field',
type: 'COMBO',
isOptional: false,
...overrides
} as ComboInputSpec
}
describe('comboAdapter.canHandle', () => {
it('returns true for combo input specs', () => {
expect(comboAdapter.canHandle(makeSpec())).toBe(true)
})
})
describe('comboAdapter.extractProps', () => {
it('returns kind=unknown when no upload flags set', () => {
expect(comboAdapter.extractProps(makeSpec()).assetKind).toBe('unknown')
})
it('detects video', () => {
expect(
comboAdapter.extractProps(makeSpec({ video_upload: true })).assetKind
).toBe('video')
})
it('detects image (image_upload)', () => {
expect(
comboAdapter.extractProps(makeSpec({ image_upload: true })).assetKind
).toBe('image')
})
it('detects image (animated_image_upload)', () => {
expect(
comboAdapter.extractProps(makeSpec({ animated_image_upload: true }))
.assetKind
).toBe('image')
})
it('detects audio', () => {
expect(
comboAdapter.extractProps(makeSpec({ audio_upload: true })).assetKind
).toBe('audio')
})
it('detects mesh and forces uploadFolder=input', () => {
const props = comboAdapter.extractProps(makeSpec({ mesh_upload: true }))
expect(props.assetKind).toBe('mesh')
expect(props.uploadFolder).toBe('input')
})
it('respects image_folder for non-mesh', () => {
const props = comboAdapter.extractProps(
makeSpec({ image_upload: true, image_folder: 'output' })
)
expect(props.uploadFolder).toBe('output')
})
it('flags allowUpload when any *_upload is true', () => {
expect(
comboAdapter.extractProps(makeSpec({ image_upload: true })).allowUpload
).toBe(true)
expect(comboAdapter.extractProps(makeSpec()).allowUpload).toBe(false)
})
})

View File

@@ -1,31 +0,0 @@
import type { ComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { isComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { AssetKind } from '@/types/widgetTypes'
import type { SpecAdapter, SpecAdapterProps } from './specAdapter'
function deriveAssetKind(spec: ComboInputSpec): AssetKind {
if (spec.video_upload) return 'video'
if (spec.image_upload || spec.animated_image_upload) return 'image'
if (spec.audio_upload) return 'audio'
if (spec.mesh_upload) return 'mesh'
return 'unknown'
}
export const comboAdapter: SpecAdapter<ComboInputSpec> = {
canHandle: isComboInputSpec,
extractProps: (spec): SpecAdapterProps => {
const allowUpload =
spec.image_upload === true ||
spec.animated_image_upload === true ||
spec.video_upload === true ||
spec.audio_upload === true ||
spec.mesh_upload === true
return {
assetKind: deriveAssetKind(spec),
allowUpload,
uploadFolder: spec.mesh_upload ? 'input' : spec.image_folder,
uploadSubfolder: spec.upload_subfolder
}
}
}

View File

@@ -1,18 +0,0 @@
import type { Component } from 'vue'
import type { ResultItemType } from '@/schemas/apiSchema'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { AssetKind } from '@/types/widgetTypes'
export interface SpecAdapterProps {
assetKind?: AssetKind
allowUpload?: boolean
uploadFolder?: ResultItemType
uploadSubfolder?: string
}
export interface SpecAdapter<T extends InputSpec> {
canHandle: (spec: InputSpec) => spec is T
extractProps: (spec: T) => SpecAdapterProps
component?: Component
}

View File

@@ -1,25 +0,0 @@
<script setup lang="ts">
import { ComboboxContent, ComboboxPortal } from 'reka-ui'
import { cn } from '@comfyorg/tailwind-utils'
import { contentVariants } from './remoteCombo.variants'
defineProps<{
class?: string
}>()
</script>
<template>
<ComboboxPortal>
<ComboboxContent
position="popper"
:side-offset="4"
align="start"
:class="cn(contentVariants(), $props.class)"
data-testid="remote-combo-content"
>
<slot />
</ComboboxContent>
</ComboboxPortal>
</template>

View File

@@ -1,18 +0,0 @@
<script setup lang="ts">
import { ComboboxEmpty } from 'reka-ui'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
</script>
<template>
<ComboboxEmpty
class="p-3 text-center text-xs text-muted-foreground"
aria-live="polite"
data-testid="remote-combo-empty"
>
<slot>
{{ t('widgets.remoteCombo.noResults') }}
</slot>
</ComboboxEmpty>
</template>

View File

@@ -1,29 +0,0 @@
<script setup lang="ts">
import { inject } from 'vue'
defineProps<{
message?: string
}>()
import { RemoteComboKey } from './state'
const ctx = inject(RemoteComboKey)
if (!ctx) {
throw new Error('RemoteCombo.Error must be used inside RemoteCombo.Root')
}
</script>
<template>
<div
class="flex items-center gap-2 rounded-sm bg-destructive-background/10 px-3 py-2 text-xs text-base-foreground"
role="alert"
aria-live="assertive"
data-testid="remote-combo-error"
>
<i
class="icon-[lucide--alert-circle] size-4 shrink-0 text-destructive-background"
aria-hidden="true"
/>
<span class="flex-1">{{ message ?? ctx.errorMessage.value }}</span>
</div>
</template>

View File

@@ -1,93 +0,0 @@
import { render, screen } from '@testing-library/vue'
import { describe, expect, it } from 'vitest'
import { ComboboxRoot } from 'reka-ui'
import { computed, defineComponent, h, provide, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import type { DropdownItemShape } from '@/base/remote/itemSchema'
import Item from './Item.vue'
import { RemoteComboKey } from './state'
import type { RemoteComboContext, RemoteComboPreviewType } from './state'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
widgets: {
remoteCombo: {
playAudioPreview: 'Play audio preview',
pauseAudioPreview: 'Pause audio preview'
}
}
}
}
})
function makeCtx(previewType: RemoteComboPreviewType): RemoteComboContext {
return {
isOpen: ref(true),
searchQuery: ref(''),
selectedValue: ref<string | undefined>(undefined),
items: computed(() => []),
filteredItems: computed(() => []),
isLoading: computed(() => false),
isFetching: computed(() => false),
errorMessage: computed(() => null),
refresh: async () => {},
select: () => {},
fieldLabel: computed(() => 'field'),
previewType: computed(() => previewType)
}
}
function renderItemInOpenCombobox(
item: DropdownItemShape,
previewType: RemoteComboPreviewType
) {
const Host = defineComponent({
setup() {
provide(RemoteComboKey, makeCtx(previewType))
return () =>
h(
ComboboxRoot,
{ open: true, modelValue: undefined },
{
default: () => h(Item, { item, index: 0 })
}
)
}
})
return render(Host, { global: { plugins: [i18n] } })
}
describe('RemoteCombo.Item preview rendering', () => {
it('renders an <img> for image preview_type with preview_url', () => {
renderItemInOpenCombobox(
{
id: '1',
name: 'Picture',
preview_url: 'https://cdn.example.com/p.png'
},
'image'
)
const img = screen.getByRole('img', { name: /picture/i })
expect(img).toHaveAttribute('src', 'https://cdn.example.com/p.png')
})
it('renders an audio play button for audio preview_type with preview_url', () => {
renderItemInOpenCombobox(
{ id: '1', name: 'Voice', preview_url: 'https://cdn.example.com/a.mp3' },
'audio'
)
expect(
screen.getByRole('button', { name: /play audio preview/i })
).toBeInTheDocument()
})
it('omits preview element when preview_url is missing', () => {
renderItemInOpenCombobox({ id: '1', name: 'NoPreview' }, 'image')
expect(screen.queryByRole('img')).toBeNull()
})
})

View File

@@ -1,129 +0,0 @@
<script setup lang="ts">
import { ComboboxItem, ComboboxItemIndicator } from 'reka-ui'
import { computed, inject, ref, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { cn } from '@comfyorg/tailwind-utils'
import { displayName } from '@/base/remote/itemSchema'
import type { DropdownItemShape } from '@/base/remote/itemSchema'
import { itemVariants } from './remoteCombo.variants'
import type { ItemVariants } from './remoteCombo.variants'
import { RemoteComboKey } from './state'
const props = defineProps<{
item: DropdownItemShape
index: number
layout?: ItemVariants['layout']
class?: string
}>()
const ctx = inject(RemoteComboKey)
if (!ctx) {
throw new Error('RemoteCombo.Item must be used inside RemoteCombo.Root')
}
const { t } = useI18n()
const isSelected = computed(() => ctx.selectedValue.value === props.item.id)
const hasPreview = computed(() => !!props.item.preview_url)
const label = computed(() => displayName(props.item))
const audioEl = useTemplateRef<HTMLAudioElement>('audioEl')
const isPlaying = ref(false)
function toggleAudio() {
const el = audioEl.value
if (!el) return
if (el.paused) {
void el.play().then(() => {
isPlaying.value = true
})
} else {
el.pause()
isPlaying.value = false
}
}
function handleAudioEnded() {
isPlaying.value = false
}
</script>
<template>
<ComboboxItem
:value="item.id"
:class="cn(itemVariants({ layout: props.layout }), props.class)"
:data-testid="`remote-combo-item-${index}`"
@select="ctx.select(item.id)"
>
<slot :item="item" :index="index" :is-selected="isSelected">
<template v-if="hasPreview && ctx.previewType.value === 'image'">
<img
:src="item.preview_url"
:alt="label"
class="size-10 shrink-0 rounded-sm object-cover"
loading="lazy"
decoding="async"
/>
</template>
<template v-else-if="hasPreview && ctx.previewType.value === 'video'">
<video
:src="item.preview_url"
:aria-label="label"
class="size-10 shrink-0 rounded-sm object-cover"
preload="metadata"
muted
playsinline
/>
</template>
<template v-else-if="hasPreview && ctx.previewType.value === 'audio'">
<button
type="button"
class="focus-visible:ring-ring flex size-8 shrink-0 items-center justify-center rounded-full bg-secondary-background-hover text-base-foreground hover:bg-secondary-background-selected focus-visible:ring-1 focus-visible:outline-none"
:aria-label="
isPlaying
? t('widgets.remoteCombo.pauseAudioPreview')
: t('widgets.remoteCombo.playAudioPreview')
"
:aria-pressed="isPlaying"
@click.stop="toggleAudio"
@pointerdown.stop
>
<i
:class="
cn(
'size-4',
isPlaying ? 'icon-[lucide--pause]' : 'icon-[lucide--play]'
)
"
aria-hidden="true"
/>
<audio
ref="audioEl"
:src="item.preview_url"
preload="none"
class="sr-only"
@ended="handleAudioEnded"
/>
</button>
</template>
<div class="flex flex-1 flex-col gap-0.5 overflow-hidden">
<span class="truncate">{{ label }}</span>
<span
v-if="item.description"
class="truncate text-[10px] text-muted-foreground"
>
{{ item.description }}
</span>
</div>
</slot>
<ComboboxItemIndicator>
<i
class="icon-[lucide--check] size-4 text-primary-background"
aria-hidden="true"
/>
</ComboboxItemIndicator>
</ComboboxItem>
</template>

View File

@@ -1,54 +0,0 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { cn } from '@comfyorg/tailwind-utils'
import Button from '@/components/ui/button/Button.vue'
import type { LayoutMode } from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/types'
const props = defineProps<{
layout?: LayoutMode
}>()
const layoutMode = defineModel<LayoutMode>('layout', { default: 'list' })
void props
const { t } = useI18n()
function setLayout(mode: LayoutMode) {
layoutMode.value = mode
}
</script>
<template>
<div
class="flex items-center gap-1"
role="group"
:aria-label="t('widgets.remoteCombo.layoutSwitcherAriaLabel')"
data-testid="remote-combo-layout-switcher"
>
<Button
variant="textonly"
size="icon-sm"
type="button"
:aria-label="t('widgets.remoteCombo.layoutList')"
:aria-pressed="layoutMode === 'list'"
:class="cn(layoutMode === 'list' && 'bg-secondary-background-selected')"
@click.stop="setLayout('list')"
>
<i class="icon-[lucide--list] size-4" aria-hidden="true" />
</Button>
<Button
variant="textonly"
size="icon-sm"
type="button"
:aria-label="t('widgets.remoteCombo.layoutGrid')"
:aria-pressed="layoutMode === 'grid'"
:class="cn(layoutMode === 'grid' && 'bg-secondary-background-selected')"
@click.stop="setLayout('grid')"
>
<i class="icon-[lucide--grid-2x2] size-4" aria-hidden="true" />
</Button>
</div>
</template>

View File

@@ -1,20 +0,0 @@
<script setup lang="ts">
import { ComboboxViewport } from 'reka-ui'
import { cn } from '@comfyorg/tailwind-utils'
import { listVariants } from './remoteCombo.variants'
defineProps<{
class?: string
}>()
</script>
<template>
<ComboboxViewport
:class="cn(listVariants(), $props.class)"
data-testid="remote-combo-list"
>
<slot />
</ComboboxViewport>
</template>

View File

@@ -1,21 +0,0 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
</script>
<template>
<div
class="flex items-center justify-center gap-2 p-3 text-xs text-muted-foreground"
role="status"
aria-live="polite"
aria-busy="true"
data-testid="remote-combo-loading"
>
<i
class="icon-[lucide--loader-circle] size-4 animate-spin text-primary-background"
aria-hidden="true"
/>
<span>{{ t('widgets.remoteCombo.loading') }}</span>
</div>
</template>

View File

@@ -1,56 +0,0 @@
<script setup lang="ts">
import { inject } from 'vue'
import { useI18n } from 'vue-i18n'
import { cn } from '@comfyorg/tailwind-utils'
import Button from '@/components/ui/button/Button.vue'
import { RemoteComboKey } from './state'
import type { RemoteComboContext } from './state'
const props = defineProps<{
class?: string
context?: RemoteComboContext
disabled?: boolean
}>()
const injected = inject(RemoteComboKey, null)
const resolved = props.context ?? injected
if (!resolved) {
throw new Error(
'RemoteCombo.Refresh requires a RemoteComboContext (provide via Root or pass as prop)'
)
}
const ctx = resolved
const { t } = useI18n()
async function handleClick() {
await ctx.refresh()
}
</script>
<template>
<Button
variant="textonly"
size="icon"
type="button"
:disabled="props.disabled"
:aria-label="t('widgets.remoteCombo.refresh')"
:title="t('widgets.remoteCombo.refresh')"
:class="cn('shrink-0', props.class)"
data-testid="remote-combo-refresh"
@click.stop="handleClick"
>
<i
:class="
cn(
'icon-[lucide--rotate-cw] size-4',
ctx.isFetching.value && 'animate-spin'
)
"
aria-hidden="true"
/>
</Button>
</template>

View File

@@ -1,164 +0,0 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref, computed } from 'vue'
import type { Ref } from 'vue'
import type { DropdownItemShape } from '@/base/remote/itemSchema'
import Content from './Content.vue'
import Empty from './Empty.vue'
import ErrorAtom from './Error.vue'
import Item from './Item.vue'
import LayoutSwitcher from './LayoutSwitcher.vue'
import List from './List.vue'
import Loading from './Loading.vue'
import Refresh from './Refresh.vue'
import Root from './Root.vue'
import Search from './Search.vue'
import Trigger from './Trigger.vue'
import type { RemoteComboContext } from './state'
const sampleItems: DropdownItemShape[] = [
{ id: 'voice-1', name: 'Aria', description: 'Soft, warm female voice' },
{ id: 'voice-2', name: 'Roger', description: 'Deep, narrator male voice' },
{ id: 'voice-3', name: 'Sarah', description: 'Bright, youthful' },
{ id: 'voice-4', name: 'Charlie', description: 'Calm, professional' },
{ id: 'voice-5', name: 'George', description: 'Casual, friendly' }
]
interface StoryArgs {
isLoading: boolean
hasError: boolean
items: DropdownItemShape[]
selected?: string
}
function makeContext(args: StoryArgs): RemoteComboContext {
const isOpen = ref(false)
const searchQuery = ref('')
const selectedValue = ref(args.selected) as Ref<string | undefined>
const items = computed(() => args.items)
const filteredItems = computed(() =>
searchQuery.value
? items.value.filter((it) =>
it.name.toLowerCase().includes(searchQuery.value.toLowerCase())
)
: items.value
)
return {
isOpen,
searchQuery,
selectedValue,
items,
filteredItems,
isLoading: computed(() => args.isLoading),
isFetching: computed(() => args.isLoading),
errorMessage: computed(() =>
args.hasError ? 'Failed to load options' : null
),
refresh: async () => {},
select: (id) => {
selectedValue.value = id
isOpen.value = false
},
fieldLabel: computed(() => 'voice'),
previewType: computed(() => 'image' as const)
}
}
const meta: Meta<StoryArgs> = {
title: 'Widgets/RemoteCombo',
argTypes: {
isLoading: { control: 'boolean' },
hasError: { control: 'boolean' }
},
args: {
isLoading: false,
hasError: false,
items: sampleItems,
selected: undefined
},
parameters: {
docs: {
description: {
component:
'Atomized remote-populated combo widget. Compose Root → Trigger + Content (Search, List/Item, Loading, Empty, Error) and an optional Refresh sibling.'
}
}
}
}
export default meta
type Story = StoryObj<StoryArgs>
const renderTemplate = (args: StoryArgs) => ({
components: {
Root,
Trigger,
Content,
Search,
List,
Item,
Empty,
Loading,
ErrorAtom,
Refresh,
LayoutSwitcher
},
setup() {
const ctx = makeContext(args)
return { ctx, args }
},
template: `
<div class="flex w-72 items-center gap-1">
<Root :context="ctx" class="min-w-0 flex-1">
<Trigger class="min-w-0 flex-1" />
<Content>
<Search />
<Loading v-if="args.isLoading" />
<ErrorAtom v-else-if="args.hasError" />
<List v-else>
<Item v-for="(item, index) in ctx.filteredItems.value" :key="item.id" :item="item" :index="index" />
<Empty v-if="ctx.filteredItems.value.length === 0" />
</List>
</Content>
</Root>
<Refresh :context="ctx" />
</div>
`
})
export const Default: Story = {
render: renderTemplate
}
export const LoadingState: Story = {
args: { isLoading: true, items: [] },
render: renderTemplate
}
export const ErrorState: Story = {
args: { hasError: true, items: [] },
render: renderTemplate
}
export const EmptyState: Story = {
args: { items: [] },
render: renderTemplate
}
export const WithSelection: Story = {
args: { selected: 'voice-2' },
render: renderTemplate
}
export const KeyboardA11y: Story = {
parameters: {
docs: {
description: {
story:
'Tab to focus trigger; Enter/Space opens; Arrow keys navigate; Enter selects; Escape closes. Demonstrates the reka-ui Combobox keyboard contract.'
}
}
},
render: renderTemplate
}

View File

@@ -1,40 +0,0 @@
<script setup lang="ts">
import { ComboboxRoot } from 'reka-ui'
import { provide } from 'vue'
import { RemoteComboKey } from './state'
import type { RemoteComboContext } from './state'
const props = defineProps<{
context: RemoteComboContext
multiple?: boolean
disabled?: boolean
}>()
const ctx = props.context
provide(RemoteComboKey, ctx)
function onOpenChange(value: boolean) {
ctx.isOpen.value = value
}
function onSearchChange(value: string) {
ctx.searchQuery.value = value
}
</script>
<template>
<ComboboxRoot
:open="ctx.isOpen.value"
:search-term="ctx.searchQuery.value"
:multiple="multiple"
:disabled="disabled"
ignore-filter
:reset-search-term-on-select="false"
data-testid="remote-combo-root"
@update:open="onOpenChange"
@update:search-term="onSearchChange"
>
<slot />
</ComboboxRoot>
</template>

View File

@@ -1,44 +0,0 @@
<script setup lang="ts">
import { ComboboxInput } from 'reka-ui'
import { inject } from 'vue'
import { useI18n } from 'vue-i18n'
import { cn } from '@comfyorg/tailwind-utils'
import { searchVariants } from './remoteCombo.variants'
import { RemoteComboKey } from './state'
defineProps<{
placeholder?: string
}>()
const ctx = inject(RemoteComboKey)
if (!ctx) {
throw new Error('RemoteCombo.Search must be used inside RemoteCombo.Root')
}
const { t } = useI18n()
const emptyDisplayValue = () => ''
</script>
<template>
<div :class="cn(searchVariants())">
<i
class="icon-[lucide--search] size-4 shrink-0 text-muted-foreground"
aria-hidden="true"
/>
<ComboboxInput
v-model="ctx.searchQuery.value"
:display-value="emptyDisplayValue"
:placeholder="placeholder ?? t('g.search')"
class="w-full border-none bg-transparent text-xs text-base-foreground outline-none placeholder:text-muted-foreground"
:aria-label="
t('widgets.remoteCombo.searchAriaLabel', {
field: ctx.fieldLabel.value
})
"
data-testid="remote-combo-search-input"
/>
</div>
</template>

View File

@@ -1,80 +0,0 @@
<script setup lang="ts">
import { ComboboxAnchor, ComboboxTrigger } from 'reka-ui'
import { computed, inject } from 'vue'
import { useI18n } from 'vue-i18n'
import { cn } from '@comfyorg/tailwind-utils'
import { displayName } from '@/base/remote/itemSchema'
import { triggerVariants } from './remoteCombo.variants'
import type { TriggerVariants } from './remoteCombo.variants'
import { RemoteComboKey } from './state'
const props = defineProps<{
size?: TriggerVariants['size']
variant?: TriggerVariants['variant']
border?: TriggerVariants['border']
class?: string
placeholder?: string
disabled?: boolean
}>()
const ctx = inject(RemoteComboKey)
if (!ctx) {
throw new Error('RemoteCombo.Trigger must be used inside RemoteCombo.Root')
}
const { t } = useI18n()
const displayLabel = computed(() => {
if (ctx.isLoading.value) return t('widgets.remoteCombo.loading')
if (ctx.errorMessage.value) return ctx.errorMessage.value
const id = ctx.selectedValue.value
if (!id) return props.placeholder ?? t('widgets.uploadSelect.placeholder')
const item = ctx.items.value.find((i) => i.id === id)
return item ? displayName(item) : id
})
const computedBorder = computed<TriggerVariants['border']>(() => {
if (props.border) return props.border
if (ctx.errorMessage.value) return 'invalid'
if (ctx.isOpen.value) return 'active'
return 'none'
})
</script>
<template>
<ComboboxAnchor as-child>
<ComboboxTrigger
:class="
cn(
triggerVariants({
size: props.size,
variant: props.variant,
border: computedBorder
}),
props.class
)
"
:aria-label="
t('widgets.remoteCombo.selectAriaLabel', {
field: ctx.fieldLabel.value
})
"
:disabled="
props.disabled || ctx.isLoading.value || !!ctx.errorMessage.value
"
:aria-disabled="
props.disabled || ctx.isLoading.value || !!ctx.errorMessage.value
"
data-testid="remote-combo-trigger"
>
<span class="truncate">{{ displayLabel }}</span>
<i
class="icon-[lucide--chevron-down] size-4 shrink-0 text-muted-foreground"
aria-hidden="true"
/>
</ComboboxTrigger>
</ComboboxAnchor>
</template>

View File

@@ -1,61 +0,0 @@
import type { VariantProps } from 'cva'
import { cva } from 'cva'
export const triggerVariants = cva({
base: 'relative inline-flex w-full items-center justify-between gap-2 cursor-pointer select-none rounded-md border border-border-default bg-secondary-background text-base-foreground transition-colors hover:bg-secondary-background-hover focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50 disabled:pointer-events-none',
variants: {
size: {
sm: 'h-6 px-2 text-xs',
md: 'h-8 px-3 text-xs',
lg: 'h-10 px-4 text-sm'
},
variant: {
secondary: 'bg-secondary-background hover:bg-secondary-background-hover',
primary:
'bg-primary-background text-base-foreground hover:bg-primary-background-hover',
destructive:
'bg-destructive-background text-base-foreground hover:bg-destructive-background-hover',
textonly:
'border-transparent bg-transparent hover:bg-secondary-background-hover'
},
border: {
none: '',
active: 'border-node-component-border',
invalid: 'border-destructive-background'
}
},
defaultVariants: {
size: 'md',
variant: 'secondary',
border: 'none'
}
})
export type TriggerVariants = VariantProps<typeof triggerVariants>
export const contentVariants = cva({
base: 'z-50 min-w-(--reka-combobox-trigger-width) overflow-hidden rounded-md border border-border-default bg-base-background text-base-foreground shadow-md data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=top]:slide-in-from-bottom-2'
})
export const itemVariants = cva({
base: 'relative flex cursor-pointer select-none items-center gap-2 px-2 py-1.5 text-xs text-base-foreground outline-none transition-colors hover:bg-secondary-background-hover data-highlighted:bg-secondary-background-selected data-[state=checked]:bg-secondary-background-selected data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
variants: {
layout: {
single: 'rounded-sm',
multi: 'gap-2 rounded-sm'
}
},
defaultVariants: {
layout: 'single'
}
})
export type ItemVariants = VariantProps<typeof itemVariants>
export const searchVariants = cva({
base: 'flex w-full items-center gap-2 border-b border-border-default px-3 py-1.5'
})
export const listVariants = cva({
base: 'flex max-h-[16rem] flex-col gap-0 overflow-y-auto p-1 text-xs scrollbar-custom'
})

View File

@@ -1,23 +0,0 @@
import type { ComputedRef, InjectionKey, Ref } from 'vue'
import type { DropdownItemShape } from '@/base/remote/itemSchema'
export type RemoteComboPreviewType = 'image' | 'video' | 'audio'
export interface RemoteComboContext {
isOpen: Ref<boolean>
searchQuery: Ref<string>
selectedValue: Ref<string | undefined>
items: ComputedRef<DropdownItemShape[]>
filteredItems: ComputedRef<DropdownItemShape[]>
isLoading: ComputedRef<boolean>
isFetching: ComputedRef<boolean>
errorMessage: ComputedRef<string | null>
refresh: () => Promise<void>
select: (id: string) => void
fieldLabel: ComputedRef<string>
previewType: ComputedRef<RemoteComboPreviewType>
}
export const RemoteComboKey: InjectionKey<RemoteComboContext> =
Symbol('RemoteComboContext')

View File

@@ -1,187 +0,0 @@
import { createTestingPinia } from '@pinia/testing'
import { QueryClient, VueQueryPlugin } from '@tanstack/vue-query'
import { render, screen, waitFor } from '@testing-library/vue'
import axios from 'axios'
import type * as AxiosModule from 'axios'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import RichComboWidget from '@/renderer/extensions/vueNodes/widgets/components/RichComboWidget.vue'
import type { ComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type {
RemoteComboConfig,
RemoteItemSchema
} from '@/schemas/nodeDefSchema'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { createMockWidget } from './widgetTestUtils'
vi.mock('axios', async (importOriginal) => {
const actual = await importOriginal<typeof AxiosModule>()
return {
...actual,
default: { ...actual.default, get: vi.fn() }
}
})
vi.mock('@/platform/workspace/stores/workspaceAuthStore', () => ({
useWorkspaceAuthStore: () => ({ currentWorkspace: null })
}))
vi.mock('@/stores/authStore', () => ({
useAuthStore: () => ({
userId: undefined,
getAuthHeader: vi.fn(() => Promise.resolve(null))
})
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
widgets: {
remoteCombo: {
loading: 'Loading...',
loadFailed: 'Failed to load options',
noResults: 'No results found',
refresh: 'Refresh options',
selectAriaLabel: 'Select {field}',
searchAriaLabel: 'Search {field}',
layoutSwitcherAriaLabel: 'Layout switcher',
layoutList: 'List view',
layoutGrid: 'Grid view'
},
uploadSelect: { placeholder: 'Select...' }
},
g: { search: 'Search' }
}
}
})
function makeWidget(
spec: ComboInputSpec,
value: string | undefined = undefined
): SimplifiedWidget<string | undefined> {
return createMockWidget({
name: 'remote_field',
type: 'combo',
value,
spec
}) as SimplifiedWidget<string | undefined>
}
const itemSchema: RemoteItemSchema = {
value_field: 'id',
label_field: 'name',
preview_type: 'image'
}
function makeRemoteCombo(
overrides: Partial<RemoteComboConfig> = {}
): ComboInputSpec {
return {
name: 'remote_field',
type: 'COMBO',
isOptional: false,
remote_combo: {
route: '/test/options',
item_schema: itemSchema,
...overrides
}
}
}
function renderWithProviders(
component: typeof RichComboWidget,
props: { widget: SimplifiedWidget<string | undefined> }
) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } }
})
return render(component, {
global: {
plugins: [
i18n,
createTestingPinia({ createSpy: vi.fn }),
[VueQueryPlugin, { queryClient }]
]
},
props
})
}
beforeEach(() => {
vi.mocked(axios.get).mockReset()
})
describe('RichComboWidget', () => {
it('renders trigger with placeholder when no selection and no items loaded', async () => {
vi.mocked(axios.get).mockResolvedValueOnce({ data: [], status: 200 })
const widget = makeWidget(makeRemoteCombo())
renderWithProviders(RichComboWidget, { widget })
expect(screen.getByTestId('remote-combo-trigger')).toBeInTheDocument()
})
it('shows loading state while fetching', async () => {
let resolveResp: (value: unknown) => void = () => {}
vi.mocked(axios.get).mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveResp = (data) => resolve({ data, status: 200 } as never)
})
)
const widget = makeWidget(makeRemoteCombo())
renderWithProviders(RichComboWidget, { widget })
const trigger = await screen.findByTestId('remote-combo-trigger')
expect(trigger).toHaveTextContent(/loading/i)
expect(trigger).toHaveAttribute('aria-disabled', 'true')
resolveResp([])
})
it('auto_select="first" selects first item when value is empty', async () => {
vi.mocked(axios.get).mockResolvedValueOnce({
data: [
{ id: 'one', name: 'One' },
{ id: 'two', name: 'Two' }
],
status: 200
})
const widget = makeWidget(makeRemoteCombo({ auto_select: 'first' }))
const { emitted } = renderWithProviders(RichComboWidget, { widget })
await waitFor(() => {
const events = emitted<unknown[]>('update:modelValue')
expect(events?.[0]?.[0]).toBe('one')
})
})
it('auto_select="last" selects last item when value is empty', async () => {
vi.mocked(axios.get).mockResolvedValueOnce({
data: [
{ id: 'a', name: 'A' },
{ id: 'b', name: 'B' },
{ id: 'c', name: 'C' }
],
status: 200
})
const widget = makeWidget(makeRemoteCombo({ auto_select: 'last' }))
const { emitted } = renderWithProviders(RichComboWidget, { widget })
await waitFor(() => {
const events = emitted<unknown[]>('update:modelValue')
expect(events?.[0]?.[0]).toBe('c')
})
})
it('renders refresh button when refresh_button is undefined', () => {
vi.mocked(axios.get).mockResolvedValueOnce({ data: [], status: 200 })
const widget = makeWidget(makeRemoteCombo())
renderWithProviders(RichComboWidget, { widget })
expect(screen.getByTestId('remote-combo-refresh')).toBeInTheDocument()
})
it('hides refresh button when refresh_button is false', () => {
vi.mocked(axios.get).mockResolvedValueOnce({ data: [], status: 200 })
const widget = makeWidget(makeRemoteCombo({ refresh_button: false }))
renderWithProviders(RichComboWidget, { widget })
expect(screen.queryByTestId('remote-combo-refresh')).toBeNull()
})
})

View File

@@ -1,102 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import { isComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { RemoteComboConfig } from '@/schemas/nodeDefSchema'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import RemoteComboContent from './RemoteCombo/Content.vue'
import RemoteComboEmpty from './RemoteCombo/Empty.vue'
import RemoteComboError from './RemoteCombo/Error.vue'
import RemoteComboItem from './RemoteCombo/Item.vue'
import RemoteComboList from './RemoteCombo/List.vue'
import RemoteComboLoading from './RemoteCombo/Loading.vue'
import RemoteComboRefresh from './RemoteCombo/Refresh.vue'
import RemoteComboRoot from './RemoteCombo/Root.vue'
import RemoteComboSearch from './RemoteCombo/Search.vue'
import RemoteComboTrigger from './RemoteCombo/Trigger.vue'
import type { RemoteComboContext } from './RemoteCombo/state'
import { useRemoteCombo } from '../composables/useRemoteCombo'
const { widget } = defineProps<{
widget: SimplifiedWidget<string | undefined>
}>()
const modelValue = defineModel<string | undefined>()
const comboSpec = computed(() => {
if (widget.spec && isComboInputSpec(widget.spec)) {
return widget.spec
}
return undefined
})
const remoteConfig = computed<RemoteComboConfig | undefined>(
() => comboSpec.value?.remote_combo
)
const fieldLabel = computed(() => widget.label ?? widget.name)
const combo = useRemoteCombo({
config: remoteConfig,
modelValue,
fieldLabel
})
const context: RemoteComboContext = {
isOpen: combo.isOpen,
searchQuery: combo.searchQuery,
selectedValue: combo.selectedValue,
items: combo.items,
filteredItems: combo.filteredItems,
isLoading: combo.isLoading,
isFetching: combo.isFetching,
errorMessage: combo.errorMessage,
refresh: combo.refresh,
select: combo.select,
fieldLabel: combo.fieldLabel,
previewType: combo.previewType
}
const showRefreshButton = computed(
() => !!remoteConfig.value && remoteConfig.value.refresh_button !== false
)
const isDisabled = computed(() => widget.options?.disabled === true)
</script>
<template>
<div
class="flex w-full min-w-0 items-center gap-1"
@pointerdown.stop
@pointermove.stop
@pointerup.stop
>
<RemoteComboRoot
:context="context"
:disabled="isDisabled"
class="min-w-0 flex-1"
>
<RemoteComboTrigger :disabled="isDisabled" class="min-w-0 flex-1" />
<RemoteComboContent>
<RemoteComboSearch />
<RemoteComboLoading v-if="combo.isLoading.value" />
<RemoteComboError v-else-if="combo.errorMessage.value" />
<RemoteComboList v-else>
<RemoteComboItem
v-for="(item, index) in combo.filteredItems.value"
:key="item.id"
:item
:index
/>
<RemoteComboEmpty v-if="combo.filteredItems.value.length === 0" />
</RemoteComboList>
</RemoteComboContent>
</RemoteComboRoot>
<RemoteComboRefresh
v-if="showRefreshButton"
:context="context"
:disabled="isDisabled"
/>
</div>
</template>

View File

@@ -1,7 +1,6 @@
<template>
<RichComboWidget v-if="hasRemoteCombo" v-model="modelValue" :widget />
<WidgetSelectDropdown
v-else-if="isDropdownUIWidget"
v-if="isDropdownUIWidget"
v-model="modelValue"
:widget
:node-type="widget.nodeType ?? nodeType"
@@ -25,7 +24,6 @@
import { computed } from 'vue'
import { assetService } from '@/platform/assets/services/assetService'
import RichComboWidget from '@/renderer/extensions/vueNodes/widgets/components/RichComboWidget.vue'
import WidgetSelectDefault from '@/renderer/extensions/vueNodes/widgets/components/WidgetSelectDefault.vue'
import WidgetSelectDropdown from '@/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue'
import WidgetWithControl from '@/renderer/extensions/vueNodes/widgets/components/WidgetWithControl.vue'
@@ -55,8 +53,6 @@ const comboSpec = computed<ComboInputSpec | undefined>(() => {
return undefined
})
const hasRemoteCombo = computed(() => !!comboSpec.value?.remote_combo)
const specDescriptor = computed<{
kind: AssetKind
allowUpload: boolean

View File

@@ -33,8 +33,6 @@ interface Props {
accept?: string
filterOptions?: FilterOption[]
sortOptions?: SortOption[]
showSort?: boolean
showLayoutSwitcher?: boolean
showOwnershipFilter?: boolean
ownershipOptions?: OwnershipFilterOption[]
showBaseModelFilter?: boolean
@@ -61,8 +59,6 @@ const {
accept,
filterOptions = [],
sortOptions = getDefaultSortOptions(),
showSort = true,
showLayoutSwitcher = true,
showOwnershipFilter,
ownershipOptions,
showBaseModelFilter,
@@ -233,8 +229,6 @@ function handleSelection(item: FormDropdownItem, index: number) {
v-model:base-model-selected="baseModelSelected"
:filter-options
:sort-options
:show-sort
:show-layout-switcher
:show-ownership-filter
:ownership-options
:show-base-model-filter

View File

@@ -68,11 +68,7 @@ const theButtonStyle = computed(() =>
{{ placeholder }}
</span>
<span v-else>
{{
selectedItems
.map((item) => item.label || item.name || item.id)
.join(', ')
}}
{{ selectedItems.map((item) => item.label ?? item.name).join(', ') }}
</span>
</span>
<i

View File

@@ -20,8 +20,6 @@ interface Props {
isSelected: (item: FormDropdownItem, index: number) => boolean
filterOptions: FilterOption[]
sortOptions: SortOption[]
showSort?: boolean
showLayoutSwitcher?: boolean
showOwnershipFilter?: boolean
ownershipOptions?: OwnershipFilterOption[]
showBaseModelFilter?: boolean
@@ -33,8 +31,6 @@ const {
isSelected,
filterOptions,
sortOptions,
showSort = true,
showLayoutSwitcher = true,
showOwnershipFilter,
ownershipOptions,
showBaseModelFilter,
@@ -116,8 +112,6 @@ const virtualItems = computed<VirtualDropdownItem[]>(() =>
v-model:ownership-selected="ownershipSelected"
v-model:base-model-selected="baseModelSelected"
:sort-options
:show-sort
:show-layout-switcher
:show-ownership-filter
:ownership-options
:show-base-model-filter
@@ -151,7 +145,6 @@ const virtualItems = computed<VirtualDropdownItem[]>(() =>
:preview-url="item.preview_url ?? ''"
:name="item.name"
:label="item.label"
:description="item.description"
:layout="layoutMode"
@click="emit('item-click', item, index)"
/>

View File

@@ -16,10 +16,8 @@ import type { LayoutMode, SortOption } from './types'
const { t } = useI18n()
const { showSort = true, showLayoutSwitcher = true } = defineProps<{
defineProps<{
sortOptions: SortOption[]
showSort?: boolean
showLayoutSwitcher?: boolean
showOwnershipFilter?: boolean
ownershipOptions?: OwnershipFilterOption[]
showBaseModelFilter?: boolean
@@ -114,7 +112,6 @@ function toggleBaseModelSelection(item: FilterOption) {
/>
<Button
v-if="showSort"
ref="sortTriggerRef"
:aria-label="t('assetBrowser.sortBy')"
:title="t('assetBrowser.sortBy')"
@@ -135,7 +132,6 @@ function toggleBaseModelSelection(item: FilterOption) {
<i class="icon-[lucide--arrow-up-down] size-4" />
</Button>
<Popover
v-if="showSort"
ref="sortPopoverRef"
:dismissable="true"
:close-on-escape="true"
@@ -310,7 +306,6 @@ function toggleBaseModelSelection(item: FilterOption) {
</Popover>
<div
v-if="showLayoutSwitcher"
:class="
cn(
actionButtonStyle,

View File

@@ -28,15 +28,11 @@ const assetKind = inject(AssetKindKey)
const isVideo = computed(() => assetKind?.value === 'video')
const isMesh = computed(() => assetKind?.value === 'mesh')
const isAudio = computed(() => assetKind?.value === 'audio')
const mediaContainerRef = ref<HTMLElement>()
const resolvedMeshPreview = ref<string | null>(null)
const meshPreviewAttempted = ref(false)
const audioRef = ref<HTMLAudioElement | null>(null)
const isPlayingAudio = ref(false)
function toLookupName(name: string): string {
const stripped = name.replace(/ \[output\]$/, '')
const slash = stripped.lastIndexOf('/')
@@ -72,17 +68,6 @@ function handleClick() {
emit('click', props.index)
}
function toggleAudioPreview(event: Event) {
event.stopPropagation()
const audio = audioRef.value
if (!audio) return
if (audio.paused) {
void audio.play().catch(() => {})
} else {
audio.pause()
}
}
function handleImageLoad(event: Event) {
emit('mediaLoad', event)
if (!event.target || !(event.target instanceof HTMLImageElement)) return
@@ -163,35 +148,6 @@ function handleVideoLoad(event: Event) {
muted
@loadeddata="handleVideoLoad"
/>
<button
v-else-if="previewUrl && isAudio"
type="button"
:aria-label="
isPlayingAudio
? t('widgets.remoteCombo.pauseAudioPreview')
: t('widgets.remoteCombo.playAudioPreview')
"
:aria-pressed="isPlayingAudio"
class="flex size-full cursor-pointer items-center justify-center bg-component-node-widget-background hover:bg-component-node-widget-background-hovered"
@click.stop="toggleAudioPreview"
>
<audio
ref="audioRef"
:src="previewUrl"
preload="none"
@play="isPlayingAudio = true"
@pause="isPlayingAudio = false"
@ended="isPlayingAudio = false"
/>
<i
:class="
cn(
'text-secondary size-5',
isPlayingAudio ? 'icon-[lucide--pause]' : 'icon-[lucide--play]'
)
"
/>
</button>
<img
v-else-if="displayedPreviewUrl"
:src="displayedPreviewUrl"
@@ -237,13 +193,6 @@ function handleVideoLoad(event: Event) {
>
{{ label ?? name }}
</span>
<!-- Description -->
<span
v-if="description && layout !== 'grid'"
class="text-secondary line-clamp-1 block overflow-hidden text-xs"
>
{{ description }}
</span>
<!-- Meta Data -->
<span v-if="actualDimensions" class="text-secondary block text-xs">
{{ actualDimensions }}

View File

@@ -12,9 +12,7 @@ export interface FormDropdownItem {
name: string
/** Original/alternate label (e.g., original filename) */
label?: string
/** Short description shown below the name in list view */
description?: string
/** Preview image/video/audio URL */
/** Preview image/video URL */
preview_url?: string
/** Whether the item is immutable (public model) - used for ownership filtering */
is_immutable?: boolean
@@ -49,7 +47,6 @@ export interface FormDropdownMenuItemProps {
previewUrl: string
name: string
label?: string
description?: string
layout?: LayoutMode
}

View File

@@ -1,163 +0,0 @@
import { computed, ref, toValue, watch } from 'vue'
import type { ComputedRef, MaybeRefOrGetter, Ref } from 'vue'
import { useI18n } from 'vue-i18n'
import {
buildSearchText,
extractItems,
getByPath,
mapToDropdownItem
} from '@/base/remote/itemSchema'
import type { DropdownItemShape } from '@/base/remote/itemSchema'
import { getComfyApiBaseUrl } from '@/config/comfyApi'
import { useRemoteOptions } from '@/platform/remote/composables/useRemoteOptions'
import type { RemoteRequestDescriptor } from '@/platform/remote/schema/remoteRequestSchema'
import type { RemoteComboConfig } from '@/schemas/nodeDefSchema'
import type { RemoteComboPreviewType } from '../components/RemoteCombo/state'
interface UseRemoteComboArgs {
config: MaybeRefOrGetter<RemoteComboConfig | undefined | null>
modelValue: Ref<string | undefined>
fieldLabel?: MaybeRefOrGetter<string>
enabled?: MaybeRefOrGetter<boolean>
}
interface UseRemoteComboResult {
isOpen: Ref<boolean>
searchQuery: Ref<string>
items: ComputedRef<DropdownItemShape[]>
filteredItems: ComputedRef<DropdownItemShape[]>
isLoading: ComputedRef<boolean>
isFetching: ComputedRef<boolean>
errorMessage: ComputedRef<string | null>
refresh: () => Promise<void>
select: (id: string) => void
selectedValue: Ref<string | undefined>
fieldLabel: ComputedRef<string>
previewType: ComputedRef<RemoteComboPreviewType>
}
export function useRemoteCombo(args: UseRemoteComboArgs): UseRemoteComboResult {
const { t } = useI18n()
const isOpen = ref(false)
const searchQuery = ref('')
const descriptor = computed<RemoteRequestDescriptor | null>(() => {
const config = toValue(args.config)
if (!config) return null
return {
client: 'comfyApi',
route: config.route,
responseKey: config.response_key,
ttl: config.refresh,
timeout: config.timeout,
maxRetries: config.max_retries
}
})
const { rawData, isLoading, isFetching, error, refetch } = useRemoteOptions({
descriptor,
enabled: args.enabled
})
const rawItems = computed<unknown[]>(() => {
const data = rawData.value
const config = toValue(args.config)
if (data === undefined) return []
const items = extractItems(data, config?.response_key)
return items ?? []
})
const items = computed<DropdownItemShape[]>(() => {
const config = toValue(args.config)
const schema = config?.item_schema
if (schema) {
const previewBaseUrl = getComfyApiBaseUrl()
return rawItems.value.map((raw) =>
mapToDropdownItem(raw, schema, { previewBaseUrl })
)
}
return rawItems.value.map((raw) => {
const val = String(raw ?? '')
return { id: val, name: val }
})
})
const searchIndex = computed(() => {
const config = toValue(args.config)
const schema = config?.item_schema
const fields = schema?.search_fields
if (!schema || !fields?.length) return new Map<string, string>()
const index = new Map<string, string>()
for (const raw of rawItems.value) {
const id = String(getByPath(raw, schema.value_field) ?? '')
const text = buildSearchText(raw, fields)
if (text) index.set(id, text)
}
return index
})
const filteredItems = computed<DropdownItemShape[]>(() => {
const q = searchQuery.value.trim().toLowerCase()
if (!q) return items.value
return items.value.filter((item) => {
const text = searchIndex.value.get(item.id) ?? item.name.toLowerCase()
return text.includes(q)
})
})
const errorMessage = computed<string | null>(() => {
if (!error.value) return null
return t('widgets.remoteCombo.loadFailed')
})
const fieldLabel = computed(() => toValue(args.fieldLabel) ?? '')
const previewType = computed<RemoteComboPreviewType>(
() => toValue(args.config)?.item_schema?.preview_type ?? 'image'
)
function applyAutoSelect(config: RemoteComboConfig) {
if (args.modelValue.value) return
const list = items.value
if (list.length === 0) return
if (config.auto_select === 'first') {
args.modelValue.value = list[0].id
} else if (config.auto_select === 'last') {
args.modelValue.value = list[list.length - 1].id
}
}
watch(
items,
() => {
const config = toValue(args.config)
if (config) applyAutoSelect(config)
},
{ immediate: true }
)
async function refresh() {
await refetch()
}
function select(id: string) {
args.modelValue.value = id
isOpen.value = false
}
return {
isOpen,
searchQuery,
items,
filteredItems,
isLoading,
isFetching,
errorMessage,
refresh,
select,
selectedValue: args.modelValue,
fieldLabel,
previewType
}
}

View File

@@ -0,0 +1,753 @@
import axios from 'axios'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { IWidget } from '@/lib/litegraph/src/litegraph'
import { api } from '@/scripts/api'
import { useRemoteWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useRemoteWidget'
import type { RemoteWidgetConfig } from '@/schemas/nodeDefSchema'
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
function createMockWidget(overrides: Partial<IWidget> = {}): IWidget {
return {
name: 'test_widget',
type: 'text',
value: '',
options: {},
...overrides
} as Partial<IWidget> as IWidget
}
const mockCloudAuth = vi.hoisted(() => ({
isCloud: false,
authHeader: null as { Authorization: string } | null
}))
vi.mock('axios', async (importOriginal) => {
const actual = await importOriginal<typeof axios>()
return {
default: {
...actual,
get: vi.fn()
}
}
})
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
return mockCloudAuth.isCloud
}
}))
vi.mock('@/stores/authStore', async () => {
return {
useAuthStore: vi.fn(() => ({
getAuthHeader: vi.fn(() => Promise.resolve(mockCloudAuth.authHeader))
}))
}
})
vi.mock('@/platform/settings/settingStore', async () => {
return {
useSettingStore: () => ({
settings: {}
})
}
})
const FIRST_BACKOFF = 1000 // backoff is 1s on first retry
const DEFAULT_VALUE = 'Loading...'
function createMockConfig(overrides = {}): RemoteWidgetConfig {
return {
route: `/api/test/${Date.now()}${Math.random().toString(36).substring(2, 15)}`,
refresh: 0,
...overrides
}
}
const createMockOptions = (inputOverrides = {}) => ({
remoteConfig: createMockConfig(inputOverrides),
defaultValue: DEFAULT_VALUE,
node: createMockLGraphNode({
addWidget: vi.fn(() => createMockWidget()),
onRemoved: undefined
}),
widget: createMockWidget()
})
function mockAxiosResponse(data: unknown, status = 200) {
vi.mocked(axios.get).mockResolvedValueOnce({ data, status })
}
function mockAxiosError(error: Error | string) {
const err = error instanceof Error ? error : new Error(error)
vi.mocked(axios.get).mockRejectedValueOnce(err)
}
function createHookWithData(data: unknown, inputOverrides = {}) {
mockAxiosResponse(data)
const hook = useRemoteWidget(createMockOptions(inputOverrides))
return hook
}
async function setupHookWithResponse(data: unknown, inputOverrides = {}) {
const hook = createHookWithData(data, inputOverrides)
const result = await getResolvedValue(hook)
return { hook, result }
}
async function getResolvedValue(hook: ReturnType<typeof useRemoteWidget>) {
// Create a promise that resolves when the fetch is complete
const responsePromise = new Promise<void>((resolve) => {
hook.getValue(() => resolve())
})
await responsePromise
return hook.getCachedValue()
}
describe('useRemoteWidget', () => {
beforeEach(() => {
vi.clearAllMocks()
// Reset mocks
vi.mocked(axios.get).mockReset()
// Reset cache between tests
vi.spyOn(Map.prototype, 'get').mockClear()
vi.spyOn(Map.prototype, 'set').mockClear()
vi.spyOn(Map.prototype, 'delete').mockClear()
})
afterEach(() => {
vi.clearAllMocks()
})
describe('initialization', () => {
it('should create hook with default values', () => {
const hook = useRemoteWidget(createMockOptions())
expect(hook.getCachedValue()).toBeUndefined()
expect(hook.getValue()).toBe('Loading...')
})
it('should generate consistent cache keys', () => {
const options = createMockOptions()
const hook1 = useRemoteWidget(options)
const hook2 = useRemoteWidget(options)
expect(hook1.cacheKey).toBe(hook2.cacheKey)
})
it('should handle query params in cache key', () => {
const hook1 = useRemoteWidget(
createMockOptions({ query_params: { a: 1 } })
)
const hook2 = useRemoteWidget(
createMockOptions({ query_params: { a: 2 } })
)
expect(hook1.cacheKey).not.toBe(hook2.cacheKey)
})
})
describe('fetchOptions', () => {
it('should fetch data successfully', async () => {
const mockData = ['optionA', 'optionB']
const { hook, result } = await setupHookWithResponse(mockData)
expect(result).toEqual(mockData)
expect(vi.mocked(axios.get)).toHaveBeenCalledWith(
hook.cacheKey.split(';')[0], // Get the route part from cache key
expect.any(Object)
)
})
it('should use response_key if provided', async () => {
const mockResponse = { items: ['optionB', 'optionA', 'optionC'] }
const { result } = await setupHookWithResponse(mockResponse, {
response_key: 'items'
})
expect(result).toEqual(mockResponse.items)
})
it('should cache successful responses', async () => {
const mockData = ['optionA', 'optionB', 'optionC', 'optionD']
const { hook } = await setupHookWithResponse(mockData)
const entry = hook.getCacheEntry()
expect(entry?.data).toEqual(mockData)
expect(entry?.error).toBeNull()
})
it('should handle fetch errors', async () => {
const error = new Error('Network error')
mockAxiosError(error)
const { hook } = await setupHookWithResponse([])
const entry = hook.getCacheEntry()
expect(entry?.error).toBeTruthy()
expect(entry?.lastErrorTime).toBeDefined()
})
it('should handle empty array responses', async () => {
const { result } = await setupHookWithResponse([])
expect(result).toEqual([])
})
it('should handle malformed response data', async () => {
const hook = useRemoteWidget(createMockOptions())
mockAxiosResponse(null)
const data1 = hook.getValue()
mockAxiosResponse(undefined)
const data2 = hook.getValue()
expect(data1).toBe(DEFAULT_VALUE)
expect(data2).toBe(DEFAULT_VALUE)
})
it('should handle non-200 status codes', async () => {
mockAxiosError('Request failed with status code 404')
const { hook } = await setupHookWithResponse([])
const entry = hook.getCacheEntry()
expect(entry?.error?.message).toBe('Request failed with status code 404')
})
})
describe('refresh behavior', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
vi.clearAllMocks()
})
describe('permanent widgets (no refresh)', () => {
it('permanent widgets should not attempt fetch after initialization', async () => {
const mockData = ['data that is permanent after initialization']
const { hook } = await setupHookWithResponse(mockData)
await getResolvedValue(hook)
await getResolvedValue(hook)
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(1)
})
it('permanent widgets should re-fetch if refreshValue is called', async () => {
const mockData = ['data that is permanent after initialization']
const { hook } = await setupHookWithResponse(mockData)
await getResolvedValue(hook)
expect(hook.getCachedValue()).toEqual(mockData)
const refreshedData = ['data that user forced to be fetched']
mockAxiosResponse(refreshedData)
hook.refreshValue()
// Wait for cache to update with refreshed data
await vi.waitFor(() => {
expect(hook.getCachedValue()).toEqual(refreshedData)
})
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(2)
})
it('permanent widgets should still retry if request fails', async () => {
mockAxiosError('Network error')
const hook = useRemoteWidget(createMockOptions())
await getResolvedValue(hook)
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(1)
vi.setSystemTime(Date.now() + FIRST_BACKOFF)
const secondData = await getResolvedValue(hook)
expect(secondData).toBe('Loading...')
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(2)
})
it('should treat empty refresh field as permanent', async () => {
const { hook } = await setupHookWithResponse(['data that is permanent'])
await getResolvedValue(hook)
await getResolvedValue(hook)
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(1)
})
})
it('should refresh when data is stale', async () => {
const refresh = 256
const mockData1 = ['option1']
const mockData2 = ['option2']
const { hook } = await setupHookWithResponse(mockData1, { refresh })
mockAxiosResponse(mockData2)
vi.setSystemTime(Date.now() + refresh)
const newData = await getResolvedValue(hook)
expect(newData).toEqual(mockData2)
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(2)
})
it('should not refresh when data is not stale', async () => {
const { hook } = await setupHookWithResponse(['option1'], {
refresh: 512
})
vi.setSystemTime(Date.now() + 128)
await getResolvedValue(hook)
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(1)
})
it('should use backoff instead of refresh after error', async () => {
const refresh = 4096
const { hook } = await setupHookWithResponse(['first success'], {
refresh
})
mockAxiosError('Network error')
vi.setSystemTime(Date.now() + refresh)
await getResolvedValue(hook)
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(2)
mockAxiosResponse(['second success'])
vi.setSystemTime(Date.now() + FIRST_BACKOFF)
const thirdData = await getResolvedValue(hook)
expect(thirdData).toEqual(['second success'])
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(3)
})
it('should use last valid value after error', async () => {
const refresh = 4096
const { hook } = await setupHookWithResponse(['a valid value'], {
refresh
})
mockAxiosError('Network error')
vi.setSystemTime(Date.now() + refresh)
const secondData = await getResolvedValue(hook)
expect(secondData).toEqual(['a valid value'])
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(2)
})
})
describe('error handling and backoff', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('should implement exponential backoff on errors', async () => {
mockAxiosError('Network error')
const hook = useRemoteWidget(createMockOptions())
await getResolvedValue(hook)
const entry1 = hook.getCacheEntry()
expect(entry1?.error).toBeTruthy()
await getResolvedValue(hook)
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(1)
vi.setSystemTime(Date.now() + 500)
await getResolvedValue(hook)
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(1) // Still backing off
vi.setSystemTime(Date.now() + 3000)
await getResolvedValue(hook)
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(2)
expect(entry1?.data).toBeDefined()
})
it('should reset error state on successful fetch', async () => {
mockAxiosError('Network error')
const hook = useRemoteWidget(createMockOptions())
const firstData = await getResolvedValue(hook)
expect(firstData).toBe('Loading...')
vi.setSystemTime(Date.now() + 3000)
mockAxiosResponse(['option1'])
const secondData = await getResolvedValue(hook)
expect(secondData).toEqual(['option1'])
const entry = hook.getCacheEntry()
expect(entry?.error).toBeNull()
expect(entry?.retryCount).toBe(0)
})
it('should save successful data after backoff', async () => {
mockAxiosError('Network error')
const hook = useRemoteWidget(createMockOptions())
await getResolvedValue(hook)
const entry1 = hook.getCacheEntry()
expect(entry1?.error).toBeTruthy()
vi.setSystemTime(Date.now() + 3000)
mockAxiosResponse(['success after backoff'])
const secondData = await getResolvedValue(hook)
expect(secondData).toEqual(['success after backoff'])
const entry2 = hook.getCacheEntry()
expect(entry2?.error).toBeNull()
expect(entry2?.retryCount).toBe(0)
})
it('should save successful data after multiple backoffs', async () => {
mockAxiosError('Network error')
mockAxiosError('Network error')
mockAxiosError('Network error')
const hook = useRemoteWidget(createMockOptions())
await getResolvedValue(hook)
const entry1 = hook.getCacheEntry()
expect(entry1?.error).toBeTruthy()
vi.setSystemTime(Date.now() + 3000)
const secondData = await getResolvedValue(hook)
expect(secondData).toBe('Loading...')
expect(entry1?.error).toBeDefined()
vi.setSystemTime(Date.now() + 9000)
const thirdData = await getResolvedValue(hook)
expect(thirdData).toBe('Loading...')
expect(entry1?.error).toBeDefined()
vi.setSystemTime(Date.now() + 120_000)
mockAxiosResponse(['success after multiple backoffs'])
const fourthData = await getResolvedValue(hook)
expect(fourthData).toEqual(['success after multiple backoffs'])
const entry2 = hook.getCacheEntry()
expect(entry2?.error).toBeNull()
expect(entry2?.retryCount).toBe(0)
})
})
describe('cache management', () => {
it('should clear cache entries', async () => {
const { hook } = await setupHookWithResponse(['to be cleared'])
expect(hook.getCachedValue()).toBeDefined()
hook.refreshValue()
expect(hook.getCachedValue()).toBe(DEFAULT_VALUE)
})
it('should prevent duplicate in-flight requests', async () => {
const mockData = ['non-duplicate']
mockAxiosResponse(mockData)
const hook = useRemoteWidget(createMockOptions())
// Start two concurrent getValue calls
const promise1 = new Promise<void>((resolve) => {
hook.getValue(() => resolve())
})
const promise2 = new Promise<void>((resolve) => {
hook.getValue(() => resolve())
})
// Wait for both e
await Promise.all([promise1, promise2])
// Both should see the same cached data
expect(hook.getCachedValue()).toEqual(mockData)
// Only one axios call should have been made
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(1)
})
})
describe('concurrent access and multiple instances', () => {
it('should handle concurrent hook instances with same route', async () => {
mockAxiosResponse(['shared data'])
const options = createMockOptions()
const hook1 = useRemoteWidget(options)
const hook2 = useRemoteWidget(options)
// Since they have the same route, only one request will be made
await Promise.race([getResolvedValue(hook1), getResolvedValue(hook2)])
const data1 = hook1.getValue()
const data2 = hook2.getValue()
expect(data1).toEqual(['shared data'])
expect(data2).toEqual(['shared data'])
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(1)
expect(hook1.getCachedValue()).toBe(hook2.getCachedValue())
})
it('should use shared cache across multiple hooks', async () => {
mockAxiosResponse(['shared data'])
const options = createMockOptions()
const hook1 = useRemoteWidget(options)
const hook2 = useRemoteWidget(options)
const hook3 = useRemoteWidget(options)
const hook4 = useRemoteWidget(options)
const data1 = await getResolvedValue(hook1)
const data2 = await getResolvedValue(hook2)
const data3 = await getResolvedValue(hook3)
const data4 = await getResolvedValue(hook4)
expect(data1).toEqual(['shared data'])
expect(data2).toBe(data1)
expect(data3).toBe(data1)
expect(data4).toBe(data1)
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(1)
expect(hook1.getCachedValue()).toBe(hook2.getCachedValue())
expect(hook2.getCachedValue()).toBe(hook3.getCachedValue())
expect(hook3.getCachedValue()).toBe(hook4.getCachedValue())
})
it('should handle rapid cache clearing during fetch', async () => {
let resolvePromise: (value: { data: unknown; status?: number }) => void
const delayedPromise = new Promise<{ data: unknown; status?: number }>(
(resolve) => {
resolvePromise = resolve
}
)
vi.mocked(axios.get).mockImplementationOnce(() => delayedPromise)
const hook = useRemoteWidget(createMockOptions())
hook.getValue()
hook.refreshValue()
resolvePromise!({ data: ['delayed data'] })
const data = await getResolvedValue(hook)
// The value should be the default value because the refreshValue
// clears the cache and the fetch is aborted
expect(data).toEqual(DEFAULT_VALUE)
expect(hook.getCachedValue()).toBe(DEFAULT_VALUE)
})
it('should handle widget destroyed during fetch', async () => {
let resolvePromise: (value: { data: unknown; status?: number }) => void
const delayedPromise = new Promise<{ data: unknown; status?: number }>(
(resolve) => {
resolvePromise = resolve
}
)
vi.mocked(axios.get).mockImplementationOnce(() => delayedPromise)
let hook: ReturnType<typeof useRemoteWidget> | null =
useRemoteWidget(createMockOptions())
const fetchPromise = hook.getValue()
hook = null
resolvePromise!({ data: ['delayed data'] })
await fetchPromise
expect(hook).toBeNull()
hook = useRemoteWidget(createMockOptions())
const data2 = await getResolvedValue(hook)
expect(data2).toEqual(DEFAULT_VALUE)
})
})
describe('cloud distribution authentication', () => {
describe('when distribution is cloud', () => {
describe('when authenticated', () => {
it('passes Firebase authentication token in request headers', async () => {
const mockData = ['authenticated data']
mockCloudAuth.authHeader = null
mockCloudAuth.isCloud = true
mockCloudAuth.authHeader = { Authorization: 'Bearer test-token' }
mockAxiosResponse(mockData)
const hook = useRemoteWidget(createMockOptions())
await getResolvedValue(hook)
expect(vi.mocked(axios.get)).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
headers: { Authorization: 'Bearer test-token' }
})
)
})
})
})
describe('when distribution is not cloud', () => {
it('bypasses authentication for non-cloud environments', async () => {
const mockData = ['non-cloud data']
mockCloudAuth.isCloud = false
mockAxiosResponse(mockData)
const hook = useRemoteWidget(createMockOptions())
await getResolvedValue(hook)
const axiosCall = vi.mocked(axios.get).mock.calls[0][1]
expect(axiosCall).not.toHaveProperty('headers')
})
})
})
describe('auto-refresh on task completion', () => {
it('should add auto-refresh toggle widget', () => {
const mockNode = createMockLGraphNode({
addWidget: vi.fn(),
widgets: []
})
const mockWidget = createMockWidget({
refresh: vi.fn()
})
useRemoteWidget({
remoteConfig: createMockConfig(),
defaultValue: DEFAULT_VALUE,
node: mockNode,
widget: mockWidget
})
// Should add auto-refresh toggle widget
expect(mockNode.addWidget).toHaveBeenCalledWith(
'toggle',
'Auto-refresh after generation',
false,
expect.any(Function),
{
serialize: false
}
)
})
it('should register event listener when enabled', async () => {
const addEventListenerSpy = vi.spyOn(api, 'addEventListener')
const mockNode = createMockLGraphNode({
addWidget: vi.fn(),
widgets: []
})
const mockWidget = createMockWidget({
refresh: vi.fn()
})
useRemoteWidget({
remoteConfig: createMockConfig(),
defaultValue: DEFAULT_VALUE,
node: mockNode,
widget: mockWidget
})
// Event listener should be registered immediately
expect(addEventListenerSpy).toHaveBeenCalledWith(
'execution_success',
expect.any(Function)
)
})
it('should refresh widget when workflow completes successfully', async () => {
let executionSuccessHandler: (() => void) | undefined
vi.spyOn(api, 'addEventListener').mockImplementation((event, handler) => {
if (event === 'execution_success') {
executionSuccessHandler = handler as () => void
}
})
const mockNode = createMockLGraphNode({
addWidget: vi.fn(),
widgets: []
})
const mockWidget = createMockWidget({})
useRemoteWidget({
remoteConfig: createMockConfig(),
defaultValue: DEFAULT_VALUE,
node: mockNode,
widget: mockWidget
})
// Spy on the refresh function that was added by useRemoteWidget
const refreshSpy = vi.spyOn(mockWidget, 'refresh')
// Get the toggle callback and enable auto-refresh
const addWidgetMock = mockNode.addWidget as ReturnType<typeof vi.fn>
const toggleCallback = addWidgetMock.mock.calls.find(
(call: unknown[]) => call[0] === 'toggle'
)?.[3]
toggleCallback?.(true)
// Simulate workflow completion
executionSuccessHandler?.()
expect(refreshSpy).toHaveBeenCalled()
})
it('should not refresh when toggle is disabled', async () => {
let executionSuccessHandler: (() => void) | undefined
vi.spyOn(api, 'addEventListener').mockImplementation((event, handler) => {
if (event === 'execution_success') {
executionSuccessHandler = handler as () => void
}
})
const mockNode = createMockLGraphNode({
addWidget: vi.fn(),
widgets: []
})
const mockWidget = createMockWidget({})
useRemoteWidget({
remoteConfig: createMockConfig(),
defaultValue: DEFAULT_VALUE,
node: mockNode,
widget: mockWidget
})
// Spy on the refresh function that was added by useRemoteWidget
const refreshSpy = vi.spyOn(mockWidget, 'refresh')
// Toggle is disabled by default
// Simulate workflow completion
executionSuccessHandler?.()
expect(refreshSpy).not.toHaveBeenCalled()
})
it('should cleanup event listener on node removal', async () => {
let executionSuccessHandler: (() => void) | undefined
vi.spyOn(api, 'addEventListener').mockImplementation((event, handler) => {
if (event === 'execution_success') {
executionSuccessHandler = handler as () => void
}
})
const removeEventListenerSpy = vi.spyOn(api, 'removeEventListener')
const mockNode = createMockLGraphNode({
addWidget: vi.fn(),
widgets: [],
onRemoved: undefined
})
const mockWidget = createMockWidget({
refresh: vi.fn()
})
useRemoteWidget({
remoteConfig: createMockConfig(),
defaultValue: DEFAULT_VALUE,
node: mockNode,
widget: mockWidget
})
// Simulate node removal
mockNode.onRemoved?.()
expect(removeEventListenerSpy).toHaveBeenCalledWith(
'execution_success',
executionSuccessHandler
)
})
})
})

View File

@@ -1,20 +1,26 @@
import axios from 'axios'
import { isRetriableError } from '@/base/remote/retry'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import type { IWidget, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { isCloud } from '@/platform/distribution/types'
import { getAppQueryClient } from '@/platform/remote/queryClient'
import { remoteOptionKeys } from '@/platform/remote/queryKeys'
import type { RemoteRequestDescriptor } from '@/platform/remote/schema/remoteRequestSchema'
import type { RemoteWidgetConfig } from '@/schemas/nodeDefSchema'
import { api } from '@/scripts/api'
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
import { useAuthStore } from '@/stores/authStore'
const MAX_RETRIES = 5
const TIMEOUT = 4096
interface CacheEntry<T> {
data: T
timestamp?: number
error?: Error | null
fetchPromise?: Promise<T>
controller?: AbortController
lastErrorTime?: number
retryCount?: number
failed?: boolean
}
async function getAuthHeaders() {
if (isCloud) {
const authStore = useAuthStore()
@@ -26,32 +32,57 @@ async function getAuthHeaders() {
return {}
}
const createDescriptor = (
config: RemoteWidgetConfig
): RemoteRequestDescriptor => ({
client: 'comfyApi',
route: config.route,
params: config.query_params,
responseKey: config.response_key,
ttl: config.refresh,
timeout: config.timeout ?? TIMEOUT,
maxRetries: config.max_retries ?? MAX_RETRIES
})
const dataCache = new Map<string, CacheEntry<unknown>>()
const createCacheKey = (config: RemoteWidgetConfig): string => {
const { route, query_params = {}, refresh = 0 } = config
const paramsKey = Object.entries(query_params)
.sort(([a], [b]) => a.localeCompare(b))
.map(([k, v]) => `${k}=${v}`)
.join('&')
return [route, `r=${refresh}`, paramsKey].join(';')
}
const getBackoff = (retryCount: number) =>
Math.min(1000 * Math.pow(2, retryCount), 512)
const isInitialized = (entry: CacheEntry<unknown> | undefined) =>
entry?.data !== undefined &&
entry?.timestamp !== undefined &&
entry.timestamp > 0
const isStale = (entry: CacheEntry<unknown> | undefined, ttl: number) =>
entry?.timestamp && Date.now() - entry.timestamp >= ttl
const isFetching = (entry: CacheEntry<unknown> | undefined) =>
entry?.fetchPromise !== undefined
const isFailed = (entry: CacheEntry<unknown> | undefined) =>
entry?.failed === true
const isBackingOff = (entry: CacheEntry<unknown> | undefined) =>
entry?.error &&
entry?.lastErrorTime &&
Date.now() - entry.lastErrorTime < getBackoff(entry.retryCount || 0)
const fetchData = async (
config: RemoteWidgetConfig,
controller: AbortController
) => {
const { route, response_key, query_params, timeout = TIMEOUT } = config
async function fetchRemoteWidgetData(
descriptor: RemoteRequestDescriptor,
signal: AbortSignal
): Promise<unknown> {
const authHeaders = await getAuthHeaders()
const res = await axios.get(descriptor.route, {
params: descriptor.params,
signal,
timeout: descriptor.timeout,
const res = await axios.get(route, {
params: query_params,
signal: controller.signal,
timeout,
...authHeaders
})
return descriptor.responseKey
? (res.data as Record<string, unknown>)[descriptor.responseKey]
: res.data
return response_key ? res.data[response_key] : res.data
}
export function useRemoteWidget<
@@ -63,39 +94,42 @@ export function useRemoteWidget<
widget: IWidget
}) {
const { remoteConfig, defaultValue, node, widget } = options
const descriptor = createDescriptor(remoteConfig)
const queryClient = getAppQueryClient()
const getQueryKey = () =>
remoteOptionKeys.byRoute(descriptor, {
userId: useAuthStore().userId ?? null,
workspaceId: null,
apiKeyBucket: useApiKeyAuthStore().getApiKey() ? 'apikey' : 'anon'
})
const { refresh = 0, max_retries = MAX_RETRIES } = remoteConfig
const isPermanent = refresh <= 0
const cacheKey = createCacheKey(remoteConfig)
let isLoaded = false
let refreshQueued = false
let cachedValue: T | undefined
const fetchValue = async (): Promise<T> => {
try {
const data = await queryClient.fetchQuery({
queryKey: getQueryKey(),
queryFn: ({ signal }) => fetchRemoteWidgetData(descriptor, signal),
staleTime: remoteConfig.refresh,
retry: (failureCount, error) =>
failureCount < (remoteConfig.max_retries ?? MAX_RETRIES) &&
isRetriableError(error)
})
cachedValue = (data ?? defaultValue) as T
return cachedValue
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error'
console.warn('Remote widget fetch failed:', message)
cachedValue = (cachedValue ?? defaultValue) as T
return cachedValue
const setSuccess = (entry: CacheEntry<T>, data: T) => {
entry.retryCount = 0
entry.lastErrorTime = 0
entry.error = null
entry.timestamp = Date.now()
entry.data = data ?? defaultValue
}
const setError = (entry: CacheEntry<T>, error: Error | unknown) => {
entry.retryCount = (entry.retryCount || 0) + 1
entry.lastErrorTime = Date.now()
entry.error = error instanceof Error ? error : new Error(String(error))
entry.data ??= defaultValue
entry.fetchPromise = undefined
if (entry.retryCount >= max_retries) {
setFailed(entry)
}
}
const setFailed = (entry: CacheEntry<T>) => {
dataCache.set(cacheKey, {
data: entry.data ?? defaultValue,
failed: true
})
}
const isFirstLoad = () => {
return !isLoaded && isInitialized(dataCache.get(cacheKey))
}
const onFirstLoad = (data: T | T[]) => {
isLoaded = true
const nextValue =
@@ -105,37 +139,85 @@ export function useRemoteWidget<
node.graph?.setDirtyCanvas(true)
}
const fetchValue = async () => {
const entry = dataCache.get(cacheKey)
if (isFailed(entry)) return entry!.data as T
const isValid =
isInitialized(entry) && (isPermanent || !isStale(entry, refresh))
if (isValid || isBackingOff(entry) || isFetching(entry))
return entry!.data as T
const currentEntry: CacheEntry<T> = (entry as
| CacheEntry<T>
| undefined) || { data: defaultValue }
dataCache.set(cacheKey, currentEntry)
try {
currentEntry.controller = new AbortController()
currentEntry.fetchPromise = fetchData(
remoteConfig,
currentEntry.controller
)
const data = await currentEntry.fetchPromise
setSuccess(currentEntry, data)
return currentEntry.data
} catch (err) {
setError(currentEntry, err)
return currentEntry.data
} finally {
currentEntry.fetchPromise = undefined
currentEntry.controller = undefined
}
}
const onRefresh = () => {
if (!remoteConfig.control_after_refresh) return
const data = cachedValue
if (!Array.isArray(data)) return
if (remoteConfig.control_after_refresh) {
const data = getCachedValue()
if (!Array.isArray(data)) return // control_after_refresh is only supported for array values
switch (remoteConfig.control_after_refresh) {
case 'first':
widget.value = data[0] ?? defaultValue
break
case 'last':
widget.value = data.at(-1) ?? defaultValue
break
switch (remoteConfig.control_after_refresh) {
case 'first':
widget.value = data[0] ?? defaultValue
break
case 'last':
widget.value = data.at(-1) ?? defaultValue
break
}
widget.callback?.(widget.value)
node.graph?.setDirtyCanvas(true)
}
widget.callback?.(widget.value)
node.graph?.setDirtyCanvas(true)
}
function getCachedValue(): T {
if (cachedValue !== undefined) return cachedValue
const fromQuery = queryClient.getQueryData<T>(getQueryKey())
if (fromQuery !== undefined) {
cachedValue = fromQuery
return fromQuery
}
return defaultValue
/**
* Clear the widget's cached value, forcing a refresh on next access (e.g., a new render)
*/
const clearCachedValue = () => {
const entry = dataCache.get(cacheKey)
if (!entry) return
if (entry.fetchPromise) entry.controller?.abort() // Abort in-flight request
dataCache.delete(cacheKey)
}
/**
* Get the cached value of the widget without starting a new fetch.
* @returns the most recently computed value of the widget.
*/
function getCachedValue() {
return dataCache.get(cacheKey)?.data as T
}
/**
* Getter of the remote property of the widget (e.g., options.values, value, etc.).
* Starts the fetch process then returns the cached value immediately.
* @returns the most recent value of the widget.
*/
function getValue(onFulfilled?: () => void) {
void fetchValue()
.then((data) => {
if (!isLoaded) onFirstLoad(data)
if (isFirstLoad()) onFirstLoad(data)
if (refreshQueued && data !== defaultValue) {
onRefresh()
refreshQueued = false
@@ -148,26 +230,36 @@ export function useRemoteWidget<
return getCachedValue() ?? defaultValue
}
/**
* Force the widget to refresh its value
*/
widget.refresh = function () {
refreshQueued = true
void queryClient.invalidateQueries({ queryKey: getQueryKey() }).then(() => {
getValue()
})
clearCachedValue()
getValue()
}
/**
* Add a refresh button to the node that, when clicked, will force the widget to refresh
*/
function addRefreshButton() {
node.addWidget('button', 'refresh', 'refresh', widget.refresh)
}
/**
* Add auto-refresh toggle widget and execution success listener
*/
function addAutoRefreshToggle() {
let autoRefreshEnabled = false
// Handler for execution success
const handleExecutionSuccess = () => {
if (autoRefreshEnabled && widget.refresh) {
widget.refresh()
}
}
// Add toggle widget
const autoRefreshWidget = node.addWidget(
'toggle',
'Auto-refresh after generation',
@@ -180,8 +272,10 @@ export function useRemoteWidget<
}
)
// Register event listener
api.addEventListener('execution_success', handleExecutionSuccess)
// Cleanup on node removal
node.onRemoved = useChainCallback(node.onRemoved, function () {
api.removeEventListener('execution_success', handleExecutionSuccess)
})
@@ -189,6 +283,7 @@ export function useRemoteWidget<
return autoRefreshWidget
}
// Always add auto-refresh toggle for remote widgets
addAutoRefreshToggle()
return {
@@ -196,6 +291,8 @@ export function useRemoteWidget<
getValue,
refreshValue: widget.refresh,
addRefreshButton,
getQueryKey
getCacheEntry: () => dataCache.get(cacheKey),
cacheKey
}
}

View File

@@ -1,41 +0,0 @@
import { describe, expect, it } from 'vitest'
import { zComboInputOptionsValidated } from '@/schemas/nodeDefSchema'
describe('zComboInputOptionsValidated XOR enforcement', () => {
const remote = {
route: '/legacy'
}
const remote_combo = {
route: '/rich',
item_schema: { value_field: 'id', label_field: 'name' }
}
it('accepts options without remote or remote_combo', () => {
const result = zComboInputOptionsValidated.safeParse({})
expect(result.success).toBe(true)
})
it('accepts options with only remote', () => {
const result = zComboInputOptionsValidated.safeParse({ remote })
expect(result.success).toBe(true)
})
it('accepts options with only remote_combo', () => {
const result = zComboInputOptionsValidated.safeParse({ remote_combo })
expect(result.success).toBe(true)
})
it('rejects options with both remote and remote_combo', () => {
const result = zComboInputOptionsValidated.safeParse({
remote,
remote_combo
})
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error.issues[0]?.message).toContain(
'Combo input cannot specify both'
)
}
})
})

View File

@@ -5,11 +5,6 @@ import { resultItemType } from '@/schemas/apiSchema'
import { CONTROL_OPTIONS } from '@/types/simplifiedWidget'
const zComboOption = z.union([z.string(), z.number()])
/**
* Plain remote combo config — feeds a standard combo dropdown from a remote endpoint.
* Handled by `useRemoteWidget` + `WidgetSelectDropdown`.
*/
const zRemoteWidgetConfig = z.object({
route: z.string().url().or(z.string().startsWith('/')),
refresh: z.number().gte(128).safe().or(z.number().lte(0).safe()).optional(),
@@ -20,32 +15,6 @@ const zRemoteWidgetConfig = z.object({
timeout: z.number().gte(0).optional(),
max_retries: z.number().gte(0).optional()
})
const zRemoteItemSchema = z.object({
value_field: z.string(),
label_field: z.string(),
preview_url_field: z.string().optional(),
preview_type: z.enum(['image', 'video', 'audio']).default('image'),
description_field: z.string().optional(),
search_fields: z.array(z.string()).optional()
})
/**
* Rich remote combo config — feeds `RichComboWidget` with item previews, search, and filtering.
* Requires `item_schema`. Vue-nodes only. Routes are always relative paths and resolve against
* the comfy-api base URL with auth headers injected. The endpoint returns the full items array
* in a single response.
*/
const zRemoteComboConfig = z.object({
route: z.string().startsWith('/'),
item_schema: zRemoteItemSchema,
refresh_button: z.boolean().optional(),
auto_select: z.enum(['first', 'last']).optional(),
refresh: z.number().gte(128).safe().or(z.number().lte(0).safe()).optional(),
response_key: z.string().optional(),
timeout: z.number().gte(0).optional(),
max_retries: z.number().gte(0).optional()
})
const zMultiSelectOption = z.object({
placeholder: z.string().optional(),
chip: z.boolean().optional()
@@ -127,20 +96,10 @@ export const zComboInputOptions = zBaseInputOptions.extend({
animated_image_upload: z.boolean().optional(),
options: z.array(zComboOption).optional(),
remote: zRemoteWidgetConfig.optional(),
remote_combo: zRemoteComboConfig.optional(),
/** Whether the widget is a multi-select widget. */
multi_select: zMultiSelectOption.optional()
})
export const zComboInputOptionsValidated = zComboInputOptions.refine(
(opts) => !(opts.remote && opts.remote_combo),
{
message:
'Combo input cannot specify both `remote` and `remote_combo`; pick one.',
path: ['remote_combo']
}
)
const zIntInputSpec = z.tuple([z.literal('INT'), zIntInputOptions.optional()])
const zFloatInputSpec = z.tuple([
z.literal('FLOAT'),
@@ -393,9 +352,7 @@ export const zMatchTypeOptions = z.object({
export type ComfyInputsSpec = z.infer<typeof zComfyInputsSpec>
export type ComfyOutputTypesSpec = z.infer<typeof zComfyOutputTypesSpec>
export type ComfyNodeDef = z.infer<typeof zComfyNodeDef>
export type RemoteItemSchema = z.infer<typeof zRemoteItemSchema>
export type RemoteWidgetConfig = z.infer<typeof zRemoteWidgetConfig>
export type RemoteComboConfig = z.infer<typeof zRemoteComboConfig>
export type ComboInputOptions = z.infer<typeof zComboInputOptions>
export type NumericInputOptions = z.infer<typeof zNumericInputOptions>

View File

@@ -71,66 +71,4 @@ describe('validateNodeDef', () => {
})
}
)
describe('remote_combo route validation', () => {
const buildNodeDef = (remoteCombo: object): unknown => ({
...EXAMPLE_NODE_DEF,
input: {
required: {
voice: ['COMBO', { remote_combo: remoteCombo }]
}
}
})
const baseRemoteCombo = {
item_schema: { value_field: 'id', label_field: 'name' }
}
it('accepts a relative route', () => {
expect(
validateComfyNodeDef(
buildNodeDef({
...baseRemoteCombo,
route: '/voices'
})
)
).not.toBeNull()
})
it('rejects an absolute http URL', () => {
expect(
validateComfyNodeDef(
buildNodeDef({
...baseRemoteCombo,
route: 'http://api.example.com/voices'
}),
() => {}
)
).toBeNull()
})
it('rejects an absolute https URL', () => {
expect(
validateComfyNodeDef(
buildNodeDef({
...baseRemoteCombo,
route: 'https://api.example.com/voices'
}),
() => {}
)
).toBeNull()
})
it('rejects a route with no leading slash', () => {
expect(
validateComfyNodeDef(
buildNodeDef({
...baseRemoteCombo,
route: 'voices'
}),
() => {}
)
).toBeNull()
})
})
})