Compare commits

...

2 Commits

Author SHA1 Message Date
bymyself
c3fe311a72 search playground 2025-12-11 03:08:06 -08:00
Alexander Brown
72b5444d5a Devex: Linter updates (#7309)
## Summary

Updates for the linter/formatter deps, turning on some more rules.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7309-WIP-Linter-updates-2c56d73d36508101b3ece6bcaf7e5212)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2025-12-10 11:08:47 -08:00
68 changed files with 2693 additions and 788 deletions

View File

@@ -13,14 +13,37 @@
"src/types/generatedManagerTypes.ts",
"src/types/vue-shim.d.ts"
],
"plugins": [
"eslint",
"import",
"oxc",
"typescript",
"unicorn",
"vitest",
"vue"
],
"rules": {
"no-async-promise-executor": "off",
"no-control-regex": "off",
"no-eval": "off",
"no-redeclare": "error",
"no-self-assign": "allow",
"no-unused-expressions": "off",
"no-unused-private-class-members": "off",
"no-useless-rename": "off",
"import/default": "error",
"import/export": "error",
"import/namespace": "error",
"import/no-duplicates": "error",
"import/consistent-type-specifier-style": [
"error",
"prefer-top-level"
],
"jest/expect-expect": "off",
"jest/no-conditional-expect": "off",
"jest/no-disabled-tests": "off",
"jest/no-standalone-expect": "off",
"jest/valid-title": "off",
"typescript/no-this-alias": "off",
"typescript/no-unnecessary-parameter-property-assignment": "off",
"typescript/no-unsafe-declaration-merging": "off",
@@ -39,6 +62,7 @@
"typescript/restrict-template-expressions": "off",
"typescript/unbound-method": "off",
"typescript/no-floating-promises": "error",
"vue/no-import-compiler-macros": "error"
"vue/no-import-compiler-macros": "error",
"vue/no-dupe-keys": "error"
}
}

View File

@@ -12,6 +12,9 @@
"declaration-property-value-no-unknown": [
true,
{
"typesSyntax": {
"radial-gradient()": "| <any-value>"
},
"ignoreProperties": {
"speak": ["none"],
"app-region": ["drag", "no-drag"],
@@ -56,10 +59,7 @@
"function-no-unknown": [
true,
{
"ignoreFunctions": [
"theme",
"v-bind"
]
"ignoreFunctions": ["theme", "v-bind"]
}
]
},

View File

@@ -135,7 +135,6 @@ export default defineConfig([
allowInterfaces: 'always'
}
],
'import-x/consistent-type-specifier-style': ['error', 'prefer-top-level'],
'import-x/no-useless-path-segments': 'error',
'import-x/no-relative-packages': 'error',
'unused-imports/no-unused-imports': 'error',
@@ -146,14 +145,13 @@ export default defineConfig([
'vue/multi-word-component-names': 'off', // TODO: fix
'vue/no-template-shadow': 'off', // TODO: fix
'vue/match-component-import-name': 'error',
/* Toggle on to do additional until we can clean up existing violations.
'vue/no-unused-emit-declarations': 'error',
'vue/no-unused-properties': 'error',
'vue/no-unused-refs': 'error',
'vue/no-use-v-else-with-v-for': 'error',
'vue/no-useless-mustaches': 'error',
'vue/no-useless-v-bind': 'error',
// */
'vue/one-component-per-file': 'off', // TODO: fix
'vue/no-unused-emit-declarations': 'error',
'vue/no-use-v-else-with-v-for': 'error',
'vue/one-component-per-file': 'error',
'vue/require-default-prop': 'off', // TODO: fix -- this one is very worthwhile
// Restrict deprecated PrimeVue components
'no-restricted-imports': [
@@ -297,5 +295,14 @@ export default defineConfig([
// Turn off ESLint rules that are already handled by oxlint
...oxlint.buildFromOxlintConfigFile(
path.resolve(import.meta.dirname, '.oxlintrc.json')
)
),
{
rules: {
'import-x/default': 'off',
'import-x/export': 'off',
'import-x/namespace': 'off',
'import-x/no-duplicates': 'off',
'import-x/consistent-type-specifier-style': 'off'
}
}
])

1648
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,7 @@ packages:
catalog:
'@alloc/quick-lru': ^5.2.0
'@comfyorg/comfyui-electron-types': 0.5.5
'@eslint/js': ^9.35.0
'@eslint/js': ^9.39.1
'@iconify-json/lucide': ^1.1.178
'@iconify/json': ^2.2.380
'@iconify/tailwind': ^1.1.3
@@ -17,7 +17,7 @@ catalog:
'@nx/vite': 21.4.1
'@pinia/testing': ^0.1.5
'@playwright/test': ^1.52.0
'@prettier/plugin-oxc': ^0.0.4
'@prettier/plugin-oxc': ^0.1.3
'@primeuix/forms': 0.0.2
'@primeuix/styled': 0.3.2
'@primeuix/utils': ^0.3.2
@@ -48,15 +48,15 @@ catalog:
axios: ^1.8.2
cross-env: ^10.1.0
dotenv: ^16.4.5
eslint: ^9.34.0
eslint: ^9.39.1
eslint-config-prettier: ^10.1.8
eslint-import-resolver-typescript: ^4.4.4
eslint-plugin-import-x: ^4.16.1
eslint-plugin-oxlint: 1.25.0
eslint-plugin-prettier: ^5.5.4
eslint-plugin-storybook: ^9.1.6
eslint-plugin-unused-imports: ^4.2.0
eslint-plugin-vue: ^10.4.0
eslint-plugin-storybook: ^9.1.16
eslint-plugin-unused-imports: ^4.3.0
eslint-plugin-vue: ^10.6.2
firebase: ^11.6.0
globals: ^15.9.0
happy-dom: ^15.11.0
@@ -64,29 +64,29 @@ catalog:
jiti: 2.4.2
jsdom: ^26.1.0
knip: ^5.62.0
lint-staged: ^15.2.7
lint-staged: ^15.5.2
markdown-table: ^3.0.4
mixpanel-browser: ^2.71.0
nx: 21.4.1
oxlint: ^1.25.0
oxlint-tsgolint: ^0.4.0
oxlint: ^1.32.0
oxlint-tsgolint: ^0.8.4
picocolors: ^1.1.1
pinia: ^2.1.7
postcss-html: ^1.8.0
prettier: ^3.6.2
prettier: ^3.7.4
pretty-bytes: ^7.1.0
primeicons: ^7.0.0
primevue: ^4.2.5
rollup-plugin-visualizer: ^6.0.4
storybook: ^9.1.6
stylelint: ^16.24.0
storybook: ^9.1.16
stylelint: ^16.26.1
tailwindcss: ^4.1.12
tailwindcss-primeui: ^0.6.1
tsx: ^4.15.6
tw-animate-css: ^1.3.8
typegpu: ^0.8.2
typescript: ^5.9.2
typescript-eslint: ^8.44.0
typescript: ^5.9.3
typescript-eslint: ^8.49.0
unplugin-icons: ^0.22.0
unplugin-typegpu: 0.8.0
unplugin-vue-components: ^0.28.0
@@ -100,7 +100,7 @@ catalog:
vue-eslint-parser: ^10.2.0
vue-i18n: ^9.14.3
vue-router: ^4.4.3
vue-tsc: ^3.0.7
vue-tsc: ^3.1.8
vuefire: ^3.2.1
yjs: ^13.6.27
zod: ^3.23.8

View File

@@ -55,7 +55,6 @@ import { normalizeI18nKey } from '@/utils/formatUtil'
const { t } = useI18n()
const { subcategories } = defineProps<{
commands: ComfyCommandImpl[]
subcategories: Record<string, ComfyCommandImpl[]>
}>()

View File

@@ -21,7 +21,7 @@
class="icon-[lucide--triangle-alert] text-warning-background"
/>
<span class="p-breadcrumb-item-label px-2">{{ item.label }}</span>
<Tag v-if="item.isBlueprint" :value="'Blueprint'" severity="primary" />
<Tag v-if="item.isBlueprint" value="Blueprint" severity="primary" />
<i v-if="isActive" class="pi pi-angle-down text-[10px]"></i>
</a>
<Menu

View File

@@ -41,7 +41,7 @@ const {
inputAttrs?: Record<string, string>
}>()
const emit = defineEmits(['update:modelValue', 'edit', 'cancel'])
const emit = defineEmits(['edit', 'cancel'])
const inputValue = ref<string>(modelValue)
const inputRef = ref<InstanceType<typeof InputText> | undefined>()
const isCanceling = ref(false)

View File

@@ -11,7 +11,6 @@ import InputText from 'primevue/inputtext'
const modelValue = defineModel<string>('modelValue')
defineProps<{
defaultValue?: string
label?: string
}>()

View File

@@ -5,7 +5,7 @@
icon="pi pi-exclamation-circle"
:title="title"
:message="error.exceptionMessage"
:text-class="'break-words max-w-[60vw]'"
text-class="break-words max-w-[60vw]"
/>
<template v-if="error.extensionFile">
<span>{{ t('errorDialog.extensionFileHint') }}:</span>

View File

@@ -465,9 +465,8 @@ onMounted(async () => {
await workflowPersistence.loadTemplateFromUrlIfPresent()
// Initialize release store to fetch releases from comfy-api (fire-and-forget)
const { useReleaseStore } = await import(
'@/platform/updates/common/releaseStore'
)
const { useReleaseStore } =
await import('@/platform/updates/common/releaseStore')
const releaseStore = useReleaseStore()
void releaseStore.initialize()

View File

@@ -37,7 +37,6 @@
</Button>
<Button
ref="zoomButton"
v-tooltip.top="t('zoomControls.label')"
severity="secondary"
:label="t('zoomControls.label')"
@@ -56,7 +55,6 @@
<div class="h-[27px] w-[1px] self-center bg-node-divider" />
<Button
ref="minimapButton"
v-tooltip.top="minimapTooltip"
severity="secondary"
:aria-label="minimapTooltip"

View File

@@ -2,7 +2,7 @@
<div>
<Popover
ref="popover"
:append-to="'body'"
append-to="body"
:auto-z-index="true"
:base-z-index="1000"
:dismissable="true"

View File

@@ -21,7 +21,6 @@ import { linkifyHtml, nl2br } from '@/utils/formatUtil'
const modelValue = defineModel<string>({ required: true })
const props = defineProps<{
widget?: object
nodeId: NodeId
}>()

View File

@@ -35,7 +35,6 @@
<div v-show="activeCategory" class="rounded-lg bg-smoke-700/30">
<SceneControls
v-if="showSceneControls"
ref="sceneControlsRef"
v-model:show-grid="sceneConfig!.showGrid"
v-model:background-color="sceneConfig!.backgroundColor"
v-model:background-image="sceneConfig!.backgroundImage"
@@ -46,28 +45,24 @@
<ModelControls
v-if="showModelControls"
ref="modelControlsRef"
v-model:material-mode="modelConfig!.materialMode"
v-model:up-direction="modelConfig!.upDirection"
/>
<CameraControls
v-if="showCameraControls"
ref="cameraControlsRef"
v-model:camera-type="cameraConfig!.cameraType"
v-model:fov="cameraConfig!.fov"
/>
<LightControls
v-if="showLightControls"
ref="lightControlsRef"
v-model:light-intensity="lightConfig!.intensity"
v-model:material-mode="modelConfig!.materialMode"
/>
<ExportControls
v-if="showExportControls"
ref="exportControlsRef"
@export-model="handleExportModel"
/>
</div>

View File

@@ -9,8 +9,7 @@
<span class="inline-flex items-center gap-2">
<span v-if="props.mode === 'allFailed'" class="inline-flex items-center">
<i
class="ml-1 icon-[lucide--circle-alert] block size-4 leading-none"
:class="'text-destructive-background'"
class="ml-1 icon-[lucide--circle-alert] block size-4 leading-none text-destructive-background"
/>
</span>

View File

@@ -64,8 +64,7 @@ export const RunningWithCurrent: Story = {
state: 'running',
title: 'Generating image',
progressTotalPercent: 66,
progressCurrentPercent: 10,
runningNodeName: 'KSampler'
progressCurrentPercent: 10
}
}

View File

@@ -225,7 +225,6 @@ const props = withDefaults(
showMenu?: boolean
progressTotalPercent?: number
progressCurrentPercent?: number
runningNodeName?: string
activeDetailsId?: string | null
}>(),
{

View File

@@ -7,7 +7,6 @@ import type { ClassValue } from '@/utils/tailwindUtil'
const props = defineProps<{
nodeTitle: string
widgetName: string
isShown?: boolean
isDraggable?: boolean
isPhysical?: boolean
class?: ClassValue

View File

@@ -49,7 +49,7 @@
auto-option-focus
force-selection
multiple
:option-label="'display_name'"
option-label="display_name"
@complete="search($event.query)"
@option-select="onAddNode($event.value)"
@focused-option-changed="setHoverSuggestion($event)"

View File

@@ -59,7 +59,6 @@ interface WorkflowOption {
}
const props = defineProps<{
class?: string
workflowOption: WorkflowOption
}>()

View File

@@ -14,6 +14,7 @@ import type { HTMLAttributes } from 'vue'
import { cn } from '@/utils/tailwindUtil'
const props = defineProps<
// eslint-disable-next-line vue/no-unused-properties
SliderRootProps & { class?: HTMLAttributes['class'] }
>()

View File

@@ -1,11 +1,17 @@
import { refDebounced, watchDebounced } from '@vueuse/core'
import Fuse from 'fuse.js'
import { computed, ref, watch } from 'vue'
import { computed, inject, ref, watch } from 'vue'
import type { Ref } from 'vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
import {
DEFAULT_TEMPLATE_FUSE_CONFIG,
TEMPLATE_FUSE_SETTINGS_KEY,
buildTemplateFuseOptions
} from '@/platform/workflow/templates/utils/templateFuseOptions'
import { TEMPLATE_SEARCH_QUERY_OVERRIDE_KEY } from '@/platform/workflow/templates/utils/templateSearchLabInjection'
import { debounce } from 'es-toolkit/compat'
export function useTemplateFiltering(
@@ -13,15 +19,19 @@ export function useTemplateFiltering(
) {
const settingStore = useSettingStore()
const searchQuery = ref('')
const injectedSearchQuery = inject<Ref<string> | null>(
TEMPLATE_SEARCH_QUERY_OVERRIDE_KEY,
null
)
const searchQuery = injectedSearchQuery ?? ref('')
const selectedModels = ref<string[]>(
settingStore.get('Comfy.Templates.SelectedModels')
settingStore.get('Comfy.Templates.SelectedModels') ?? []
)
const selectedUseCases = ref<string[]>(
settingStore.get('Comfy.Templates.SelectedUseCases')
settingStore.get('Comfy.Templates.SelectedUseCases') ?? []
)
const selectedRunsOn = ref<string[]>(
settingStore.get('Comfy.Templates.SelectedRunsOn')
settingStore.get('Comfy.Templates.SelectedRunsOn') ?? []
)
const sortBy = ref<
| 'default'
@@ -36,21 +46,24 @@ export function useTemplateFiltering(
return Array.isArray(templateData) ? templateData : []
})
// Fuse.js configuration for fuzzy search
const fuseOptions = {
keys: [
{ name: 'name', weight: 0.3 },
{ name: 'title', weight: 0.3 },
{ name: 'description', weight: 0.2 },
{ name: 'tags', weight: 0.1 },
{ name: 'models', weight: 0.1 }
],
threshold: 0.4,
includeScore: true,
includeMatches: true
}
const fuseConfig = computed(
() =>
settingStore.get(TEMPLATE_FUSE_SETTINGS_KEY) ??
DEFAULT_TEMPLATE_FUSE_CONFIG
)
const fuse = computed(() => new Fuse(templatesArray.value, fuseOptions))
const debouncedSearchQuery = refDebounced(searchQuery, 50)
const fuse = computed(
() =>
new Fuse(
templatesArray.value,
buildTemplateFuseOptions<TemplateInfo>({
config: fuseConfig.value,
query: debouncedSearchQuery.value.trim().toLowerCase()
})
)
)
const availableModels = computed(() => {
const modelSet = new Set<string>()
@@ -76,8 +89,6 @@ export function useTemplateFiltering(
return ['ComfyUI', 'External or Remote API']
})
const debouncedSearchQuery = refDebounced(searchQuery, 50)
const filteredBySearch = computed(() => {
if (!debouncedSearchQuery.value.trim()) {
return templatesArray.value

View File

@@ -249,9 +249,7 @@ const link_bounding = new Rectangle()
* This class is in charge of rendering one graph inside a canvas. And provides all the interaction required.
* Valid callbacks are: onNodeSelected, onNodeDeselected, onShowNodePanel, onNodeDblClicked
*/
export class LGraphCanvas
implements CustomEventDispatcher<LGraphCanvasEventMap>
{
export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap> {
static DEFAULT_BACKGROUND_IMAGE =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAIAAAD/gAIDAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAQBJREFUeNrs1rEKwjAUhlETUkj3vP9rdmr1Ysammk2w5wdxuLgcMHyptfawuZX4pJSWZTnfnu/lnIe/jNNxHHGNn//HNbbv+4dr6V+11uF527arU7+u63qfa/bnmh8sWLBgwYJlqRf8MEptXPBXJXa37BSl3ixYsGDBMliwFLyCV/DeLIMFCxYsWLBMwSt4Be/NggXLYMGCBUvBK3iNruC9WbBgwYJlsGApeAWv4L1ZBgsWLFiwYJmCV/AK3psFC5bBggULloJX8BpdwXuzYMGCBctgwVLwCl7Be7MMFixYsGDBsu8FH1FaSmExVfAxBa/gvVmwYMGCZbBg/W4vAQYA5tRF9QYlv/QAAAAASUVORK5CYII='

View File

@@ -2,9 +2,9 @@
* Temporary workaround until downstream consumers migrate to Map.
* A brittle wrapper with many flaws, but should be fine for simple maps using int indexes.
*/
export class MapProxyHandler<V>
implements ProxyHandler<Map<number | string, V>>
{
export class MapProxyHandler<V> implements ProxyHandler<
Map<number | string, V>
> {
getOwnPropertyDescriptor(
target: Map<number | string, V>,
p: string | symbol

View File

@@ -81,9 +81,9 @@ export interface CustomEventDispatcher<
* ```
*/
export class CustomEventTarget<
EventMap extends Record<Keys, unknown>,
Keys extends keyof EventMap & string = keyof EventMap & string
>
EventMap extends Record<Keys, unknown>,
Keys extends keyof EventMap & string = keyof EventMap & string
>
extends EventTarget
implements ICustomEventTarget<EventMap, Keys>
{

View File

@@ -380,8 +380,10 @@ interface IContextMenuBase {
}
/** ContextMenu */
export interface IContextMenuOptions<TValue = unknown, TExtra = unknown>
extends IContextMenuBase {
export interface IContextMenuOptions<
TValue = unknown,
TExtra = unknown
> extends IContextMenuBase {
ignore_item_callbacks?: boolean
parentMenu?: ContextMenu<TValue>
event?: MouseEvent
@@ -426,13 +428,15 @@ export interface IContextMenuValue<
): void | boolean | Promise<void | boolean>
}
interface IContextMenuSubmenu<TValue = unknown>
extends IContextMenuOptions<TValue> {
interface IContextMenuSubmenu<
TValue = unknown
> extends IContextMenuOptions<TValue> {
options: ConstructorParameters<typeof ContextMenu<TValue>>[0]
}
export interface ContextMenuDivElement<TValue = unknown>
extends HTMLDivElement {
export interface ContextMenuDivElement<
TValue = unknown
> extends HTMLDivElement {
value?: string | IContextMenuValue<TValue>
onclick_callback?: never
}

View File

@@ -30,8 +30,8 @@ import type { SubgraphInput } from './SubgraphInput'
import type { SubgraphOutput } from './SubgraphOutput'
export abstract class SubgraphIONodeBase<
TSlot extends SubgraphInput | SubgraphOutput
>
TSlot extends SubgraphInput | SubgraphOutput
>
implements Positionable, Hoverable, Serialisable<ExportedSubgraphIONode>
{
static margin = 10

View File

@@ -46,9 +46,7 @@ export interface CanvasPointerEvent extends PointerEvent, CanvasMouseEvent {}
/** MouseEvent with canvasX/Y and deltaX/Y properties */
interface CanvasMouseEvent
extends MouseEvent,
Readonly<CanvasPointerExtensions>,
LegacyMouseEvent {}
extends MouseEvent, Readonly<CanvasPointerExtensions>, LegacyMouseEvent {}
export type CanvasEventDetail =
| GenericEventDetail

View File

@@ -94,27 +94,32 @@ export interface INumericWidget extends IBaseWidget<number, 'number'> {
value: number
}
export interface ISliderWidget
extends IBaseWidget<number, 'slider', IWidgetSliderOptions> {
export interface ISliderWidget extends IBaseWidget<
number,
'slider',
IWidgetSliderOptions
> {
type: 'slider'
value: number
marker?: number
}
export interface IKnobWidget
extends IBaseWidget<number, 'knob', IWidgetKnobOptions> {
export interface IKnobWidget extends IBaseWidget<
number,
'knob',
IWidgetKnobOptions
> {
type: 'knob'
value: number
options: IWidgetKnobOptions
}
/** Avoids the type issues with the legacy IComboWidget type */
export interface IStringComboWidget
extends IBaseWidget<
string,
'combo',
RequiredProps<IWidgetOptions<string[]>, 'values'>
> {
export interface IStringComboWidget extends IBaseWidget<
string,
'combo',
RequiredProps<IWidgetOptions<string[]>, 'values'>
> {
type: 'combo'
value: string
}
@@ -125,25 +130,29 @@ type ComboWidgetValues =
| ((widget?: IComboWidget, node?: LGraphNode) => string[])
/** A combo-box widget (dropdown, select, etc) */
export interface IComboWidget
extends IBaseWidget<
string | number,
'combo',
RequiredProps<IWidgetOptions<ComboWidgetValues>, 'values'>
> {
export interface IComboWidget extends IBaseWidget<
string | number,
'combo',
RequiredProps<IWidgetOptions<ComboWidgetValues>, 'values'>
> {
type: 'combo'
value: string | number
}
/** A widget with a string value */
export interface IStringWidget
extends IBaseWidget<string, 'string' | 'text', IWidgetOptions<string[]>> {
export interface IStringWidget extends IBaseWidget<
string,
'string' | 'text',
IWidgetOptions<string[]>
> {
type: 'string' | 'text'
value: string
}
export interface IButtonWidget
extends IBaseWidget<string | undefined, 'button'> {
export interface IButtonWidget extends IBaseWidget<
string | undefined,
'button'
> {
type: 'button'
value: string | undefined
clicked: boolean
@@ -181,15 +190,19 @@ interface IImageWidget extends IBaseWidget<string, 'image'> {
}
/** Tree select widget for hierarchical selection */
export interface ITreeSelectWidget
extends IBaseWidget<string | string[], 'treeselect'> {
export interface ITreeSelectWidget extends IBaseWidget<
string | string[],
'treeselect'
> {
type: 'treeselect'
value: string | string[]
}
/** Multi-select widget for selecting multiple options */
export interface IMultiSelectWidget
extends IBaseWidget<string[], 'multiselect'> {
export interface IMultiSelectWidget extends IBaseWidget<
string[],
'multiselect'
> {
type: 'multiselect'
value: string[]
}
@@ -207,19 +220,20 @@ export interface IGalleriaWidget extends IBaseWidget<string[], 'galleria'> {
}
/** Image comparison widget for comparing two images side by side */
export interface IImageCompareWidget
extends IBaseWidget<string[], 'imagecompare'> {
export interface IImageCompareWidget extends IBaseWidget<
string[],
'imagecompare'
> {
type: 'imagecompare'
value: string[]
}
/** Select button widget for selecting from a group of buttons */
export interface ISelectButtonWidget
extends IBaseWidget<
string,
'selectbutton',
RequiredProps<IWidgetOptions<string[]>, 'values'>
> {
export interface ISelectButtonWidget extends IBaseWidget<
string,
'selectbutton',
RequiredProps<IWidgetOptions<string[]>, 'values'>
> {
type: 'selectbutton'
value: string
}
@@ -230,8 +244,11 @@ export interface ITextareaWidget extends IBaseWidget<string, 'textarea'> {
value: string
}
export interface IAssetWidget
extends IBaseWidget<string, 'asset', IWidgetOptions<string[]>> {
export interface IAssetWidget extends IBaseWidget<
string,
'asset',
IWidgetOptions<string[]>
> {
type: 'asset'
value: string
}

View File

@@ -33,9 +33,9 @@ export interface WidgetEventOptions {
canvas: LGraphCanvas
}
export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
implements IBaseWidget
{
export abstract class BaseWidget<
TWidget extends IBaseWidget = IBaseWidget
> implements IBaseWidget {
/** From node edge to widget edge */
static margin = 15
/** From widget edge to tip of arrow button */

View File

@@ -880,6 +880,153 @@
"searchPlaceholder": "Search..."
}
},
"templateSearchLab": {
"title": "Template Search Tuning Lab",
"subtitle": "Fine-tune Fuse.js options and instantly preview template results.",
"description": "Use real template data to decide how strict or fuzzy searches like \"Wan\" and \"Animate\" should feel before handing settings to engineering.",
"reset": "Reset to recommended defaults",
"configCopied": "Copied",
"configCopy": "Copy JSON",
"searchLabel": "Type a template, model, or tag",
"searchPlaceholder": "Search templates (try \"Wan\")",
"samplesLabel": "Quick queries",
"previewTitle": "Live Preview",
"previewCount": "{count} matches",
"loadingTemplates": "Loading template catalog...",
"previewEmptyState": "Start typing or tap a sample query to preview matches.",
"previewNoResults": "No templates match \"{query}\".",
"scoreLabel": "Score",
"matchLabel": "Match found in {field}",
"unknownField": "unknown field",
"matchFallback": "Enable includeMatches to view highlighted indices.",
"configHeading": "Fuse option snapshot",
"configSubheading": "Copy this block into the composable or share it with engineering.",
"additionalReadingTitle": "Helpful reading",
"additionalReadingSubtitle": "Deep dives that explain how scoring and extended syntax work.",
"previewSummaryIdle": "Waiting for a query…",
"previewSummaryEmpty": "Search ran but no templates matched.",
"previewSummaryActive": "Showing {count} of {total} templates.",
"docLinkLabel": "Docs",
"links": {
"apiDocs": "Fuse option reference",
"scoringTheory": "Scoring theory explainer",
"extendedSearch": "Extended search syntax"
},
"dialogPreviewTitle": "Live Templates Dialog",
"dialogPreviewSubtitle": "Same component designers see in production, updated in real time as you tweak settings.",
"optionCountSuffix": "settings",
"optionGroups": {
"basic": {
"title": "Basic options",
"description": "Quick toggles that control how literal or fuzzy the search feels."
},
"fuzzy": {
"title": "Fuzzy matching",
"description": "Control how tolerant we are of typos and offsets."
},
"advanced": {
"title": "Advanced",
"description": "Ranking strategies, extended syntax, and scoring heuristics."
}
},
"sortLabel": "Sort strategy",
"sortDescription": "Override Fuse's default ordering when designers want exact or prefix matches before fuzzy ones.",
"sortExample": "Use \"Exact title first\" when Wan-branded templates must show before descriptions that merely mention \"Wan\".",
"sortModes": {
"score": "Fuse score (default)",
"exact": "Exact title or name first",
"prefix": "Prefix / word-start boost"
},
"getFnLabel": "Collection accessor",
"getFnDescription": "Control how array fields such as tags or models are flattened before scoring.",
"getFnExample": "Flatten tags/models if designers want \"Wan\" to match the model list even when the title differs.",
"getFnModes": {
"default": "Fuse default accessor",
"flatten": "Flatten arrays into readable strings"
},
"keysHeading": "Keys & weights",
"keysDescription": "Pick which template fields feed the Fuse index and how much they matter.",
"keysHelper": "Weights are relative—Fuse normalizes them automatically.",
"keysAddLabel": "Field to add",
"keysWeightLabel": "Weight",
"keysAddButton": "Add field",
"keysOptions": {
"name": "Raw name",
"title": "Localized title",
"description": "Description",
"tags": "Tags",
"models": "Models",
"useCase": "Use case",
"sourceModule": "Source module"
},
"keys": {
"nameDescription": "Matches internal slugs such as wan_image_diffusion.",
"titleDescription": "Searches the display title (localized), e.g., Wan Diffusion Starter.",
"descriptionDescription": "Looks through the marketing copy for keywords like \"face retouch\".",
"tagsDescription": "Covers curated tags (portrait, anime, product, etc.).",
"modelsDescription": "Matches referenced model names such as Wan or SDXL.",
"customDescription": "Custom weighting for {field}."
},
"removeLabel": "Remove",
"options": {
"isCaseSensitive": {
"description": "Respect letter casing when matching template text.",
"example": "Turn on if \"WAN\" should not match \"Wan Diffusion\" in lowercase."
},
"ignoreDiacritics": {
"description": "Treat accents and diacritics as plain letters.",
"example": "Let \"anime\" match templates tagged \"animé\" without needing the accent."
},
"includeScore": {
"description": "Expose the Fuse score so you can see how confident each hit is.",
"example": "Helps compare how much higher \"Wan Diffusion\" scores than \"Glow Portrait\" for the same query."
},
"includeMatches": {
"description": "Return the character indices of each match for highlighting.",
"example": "Needed when you want to visually highlight \"Wan\" inside descriptions or tags."
},
"minMatchCharLength": {
"description": "Ignore matches shorter than this many characters.",
"example": "Set to 2 so a single \"w\" typed by accident does not reshuffle the template list."
},
"shouldSort": {
"description": "Let Fuse sort by score. Turn off to keep the original grouping order.",
"example": "Disable when designers want curated ordering even while filtering by text."
},
"findAllMatches": {
"description": "Continue searching after a perfect hit to surface every occurrence.",
"example": "Use when multiple models inside the description should all highlight \"Wan\" segments."
},
"location": {
"description": "Bias toward matches near this index in the text.",
"example": "Keep near 0 if template titles should matter more than long descriptions."
},
"threshold": {
"description": "Overall fuzziness. 0 is exact, 1 matches almost anything.",
"example": "0.2 is strict enough that \"Wan\" surfaces Wan-branded templates before unrelated blurbs."
},
"distance": {
"description": "How far from the expected location a match can drift before being ignored.",
"example": "Lower values keep \"Wan\" from matching a paragraph hundreds of characters away."
},
"ignoreLocation": {
"description": "When true, disable the location + distance penalty entirely.",
"example": "Great for template data where matches can live anywhere within descriptions or tags."
},
"useExtendedSearch": {
"description": "Allow ^=, =, and other extended search syntax for power users.",
"example": "Type ^=wan to show only templates whose names start with Wan."
},
"ignoreFieldNorm": {
"description": "Ignore the penalty for long fields.",
"example": "Enable when long descriptions should not count against simple titles."
},
"fieldNormWeight": {
"description": "Scale how much field length matters in scoring.",
"example": "Set to 0 when you only care that \"Wan\" exists somewhere, no matter how wordy the field is."
}
}
},
"graphCanvasMenu": {
"zoomIn": "Zoom In",
"zoomOut": "Zoom Out",
@@ -2392,4 +2539,4 @@
"recentReleases": "Recent releases",
"helpCenterMenu": "Help Center Menu"
}
}
}

View File

@@ -27,9 +27,8 @@ import { i18n } from './i18n'
import { isCloud } from '@/platform/distribution/types'
if (isCloud) {
const { loadRemoteConfig } = await import(
'@/platform/remoteConfig/remoteConfig'
)
const { loadRemoteConfig } =
await import('@/platform/remoteConfig/remoteConfig')
await loadRemoteConfig()
}

View File

@@ -85,7 +85,6 @@ import { OnCloseKey } from '@/types/widgetTypes'
const props = defineProps<{
nodeType?: string
inputName?: string
onSelect?: (asset: AssetItem) => void
onClose?: () => void
showLeftPanel?: boolean

View File

@@ -44,7 +44,6 @@ TODO: Extract checkbox pattern into reusable Checkbox component
<script setup lang="ts">
const { mediaTypeFilters } = defineProps<{
mediaTypeFilters: string[]
close: () => void
}>()
const emit = defineEmits<{

View File

@@ -6,7 +6,7 @@
<Popover
ref="popover"
:append-to="'body'"
append-to="body"
:auto-z-index="true"
:base-z-index="1000"
:dismissable="true"

View File

@@ -16,12 +16,11 @@ import { computed } from 'vue'
import { formatSize, getFilenameDetails } from '@/utils/formatUtil'
import type { AssetContext, AssetMeta } from '../schemas/mediaAssetSchema'
import type { AssetMeta } from '../schemas/mediaAssetSchema'
import MediaTitle from './MediaTitle.vue'
const { asset } = defineProps<{
asset: AssetMeta
context: AssetContext
}>()
const fileName = computed(() => {

View File

@@ -14,12 +14,11 @@ import { computed } from 'vue'
import { getFilenameDetails } from '@/utils/formatUtil'
import type { AssetContext, AssetMeta } from '../schemas/mediaAssetSchema'
import type { AssetMeta } from '../schemas/mediaAssetSchema'
import MediaTitle from './MediaTitle.vue'
const { asset } = defineProps<{
asset: AssetMeta
context: AssetContext
}>()
const fileName = computed(() => {

View File

@@ -13,12 +13,11 @@ import { computed } from 'vue'
import { formatSize, getFilenameDetails } from '@/utils/formatUtil'
import type { AssetContext, AssetMeta } from '../schemas/mediaAssetSchema'
import type { AssetMeta } from '../schemas/mediaAssetSchema'
import MediaTitle from './MediaTitle.vue'
const { asset } = defineProps<{
asset: AssetMeta
context: AssetContext
}>()
const fileName = computed(() => {

View File

@@ -5,7 +5,6 @@
@mouseleave="isHovered = false"
>
<video
ref="videoRef"
:controls="shouldShowControls"
preload="metadata"
autoplay
@@ -27,20 +26,17 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import type { AssetContext, AssetMeta } from '../schemas/mediaAssetSchema'
import type { AssetMeta } from '../schemas/mediaAssetSchema'
const { asset } = defineProps<{
asset: AssetMeta
context: AssetContext
}>()
const emit = defineEmits<{
play: [assetId: string]
videoPlayingStateChanged: [isPlaying: boolean]
videoControlsChanged: [showControls: boolean]
}>()
const videoRef = ref<HTMLVideoElement>()
const isHovered = ref(false)
const isPlaying = ref(false)

View File

@@ -14,9 +14,8 @@ export const cloudOnboardingRoutes: RouteRecordRaw[] = [
beforeEnter: async (to, _from, next) => {
// Only redirect if not explicitly switching accounts
if (!to.query.switchAccount) {
const { useCurrentUser } = await import(
'@/composables/auth/useCurrentUser'
)
const { useCurrentUser } =
await import('@/composables/auth/useCurrentUser')
const { isLoggedIn } = useCurrentUser()
if (isLoggedIn.value) {

View File

@@ -28,9 +28,7 @@ export const useSubscriptionDialog = () => {
key: DIALOG_KEY,
component: defineAsyncComponent(
() =>
import(
'@/platform/cloud/subscription/components/SubscriptionRequiredDialogContent.vue'
)
import('@/platform/cloud/subscription/components/SubscriptionRequiredDialogContent.vue')
),
props: {
onClose: hide

View File

@@ -100,9 +100,7 @@ export function useSettingUI(
},
component: defineAsyncComponent(
() =>
import(
'@/platform/cloud/subscription/components/SubscriptionPanel.vue'
)
import('@/platform/cloud/subscription/components/SubscriptionPanel.vue')
)
}

View File

@@ -0,0 +1,206 @@
import { cloneDeep } from 'es-toolkit/compat'
import type {
IFuseOptions,
FuseSortFunction,
FuseSortFunctionArg
} from 'fuse.js'
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
export const TEMPLATE_FUSE_SETTINGS_KEY = 'Comfy.Templates.FuseOverrides'
export type TemplateFuseSortMode = 'score' | 'exact' | 'prefix'
export type TemplateFuseGetMode = 'default' | 'flatten'
export interface TemplateFuseOptionState {
isCaseSensitive: boolean
ignoreDiacritics: boolean
includeScore: boolean
includeMatches: boolean
minMatchCharLength: number
shouldSort: boolean
findAllMatches: boolean
location: number
threshold: number
distance: number
ignoreLocation: boolean
useExtendedSearch: boolean
ignoreFieldNorm: boolean
fieldNormWeight: number
}
export interface TemplateFuseKeyConfig {
path: string
weight: number
}
export interface TemplateFuseConfig {
options: TemplateFuseOptionState
keys: TemplateFuseKeyConfig[]
sortMode: TemplateFuseSortMode
getFnMode: TemplateFuseGetMode
}
const DEFAULT_TEMPLATE_FUSE_OPTIONS: TemplateFuseOptionState = {
isCaseSensitive: false,
ignoreDiacritics: true,
includeScore: true,
includeMatches: true,
minMatchCharLength: 1,
shouldSort: true,
findAllMatches: false,
location: 0,
threshold: 0.4,
distance: 100,
ignoreLocation: false,
useExtendedSearch: false,
ignoreFieldNorm: false,
fieldNormWeight: 1
}
const DEFAULT_TEMPLATE_FUSE_KEYS: TemplateFuseKeyConfig[] = [
{ path: 'name', weight: 0.3 },
{ path: 'title', weight: 0.3 },
{ path: 'description', weight: 0.2 },
{ path: 'tags', weight: 0.1 },
{ path: 'models', weight: 0.1 }
]
export const DEFAULT_TEMPLATE_FUSE_CONFIG: TemplateFuseConfig = {
options: DEFAULT_TEMPLATE_FUSE_OPTIONS,
keys: DEFAULT_TEMPLATE_FUSE_KEYS,
sortMode: 'score',
getFnMode: 'default'
}
type TemplateLike = TemplateInfo & {
localizedTitle?: string
}
type BuildOptionsParams = {
config?: TemplateFuseConfig | null
query?: string
}
export function buildTemplateFuseOptions<T extends TemplateLike>(
params?: BuildOptionsParams
): IFuseOptions<T> {
const baseConfig = params?.config ?? DEFAULT_TEMPLATE_FUSE_CONFIG
const normalizedKeys =
baseConfig.keys.length > 0 ? baseConfig.keys : DEFAULT_TEMPLATE_FUSE_KEYS
return {
...cloneDeep(baseConfig.options),
keys: normalizedKeys.map((entry) => ({
name: entry.path,
weight: entry.weight
})),
getFn: buildGetFn<T>(baseConfig.getFnMode),
sortFn: buildSortFn(baseConfig.sortMode, params?.query)
}
}
function buildGetFn<T extends TemplateLike>(mode: TemplateFuseGetMode) {
if (mode !== 'flatten') {
return undefined
}
return (obj: T, path: string | string[]) => {
if (Array.isArray(path)) {
return path.map((segment) => stringifyValue(obj, segment))
}
return stringifyValue(obj, path)
}
}
function stringifyValue(object: TemplateLike, path: string) {
const raw = resolvePath(object, path)
if (raw == null) return ''
if (Array.isArray(raw)) {
return raw
.map((entry) =>
typeof entry === 'string' ? entry : JSON.stringify(entry)
)
.join(' ')
}
if (typeof raw === 'object') {
return Object.values(raw)
.map((value) => (typeof value === 'string' ? value : ''))
.join(' ')
}
return String(raw)
}
function resolvePath(object: TemplateLike, path: string) {
return path.split('.').reduce<unknown>((current, segment) => {
if (current && typeof current === 'object') {
return (current as Record<string, unknown>)[segment]
}
return undefined
}, object)
}
function buildSortFn(
mode: TemplateFuseSortMode,
query?: string
): FuseSortFunction | undefined {
if (mode === 'score') {
return undefined
}
const normalizedQuery = query?.toLowerCase().trim()
return (a: FuseSortFunctionArg, b: FuseSortFunctionArg) => {
const templateA = a.item as unknown as TemplateLike
const templateB = b.item as unknown as TemplateLike
if (!normalizedQuery) {
return compareScores(a, b)
}
if (mode === 'exact') {
const aExact = isExactMatch(templateA, normalizedQuery)
const bExact = isExactMatch(templateB, normalizedQuery)
if (aExact !== bExact) {
return aExact ? -1 : 1
}
return compareScores(a, b)
}
const aPrefix = prefixScore(templateA, normalizedQuery)
const bPrefix = prefixScore(templateB, normalizedQuery)
if (aPrefix !== bPrefix) {
return bPrefix - aPrefix
}
return compareScores(a, b)
}
}
function compareScores(a: FuseSortFunctionArg, b: FuseSortFunctionArg) {
const aScore = typeof a.score === 'number' ? a.score : 1
const bScore = typeof b.score === 'number' ? b.score : 1
return aScore - bScore
}
function isExactMatch(template: TemplateLike, query: string) {
const title = formatTitle(template)
const name = (template.name || '').toLowerCase()
return title === query || name === query
}
function prefixScore(template: TemplateLike, query: string) {
const title = formatTitle(template)
const name = (template.name || '').toLowerCase()
if (title.startsWith(query) || name.startsWith(query)) {
return 2
}
if (title.includes(` ${query}`) || name.includes(` ${query}`)) {
return 1
}
return 0
}
function formatTitle(template: TemplateLike) {
return (
template.localizedTitle ||
template.title ||
template.name ||
''
).toLowerCase()
}

View File

@@ -0,0 +1,4 @@
import type { InjectionKey, Ref } from 'vue'
export const TEMPLATE_SEARCH_QUERY_OVERRIDE_KEY: InjectionKey<Ref<string>> =
Symbol('TemplateSearchOverride')

View File

@@ -181,7 +181,6 @@ import NodeWidgets from './NodeWidgets.vue'
interface LGraphNodeProps {
nodeData: VueNodeData
error?: string | null
zoomLevel?: number
}
const { nodeData, error = null } = defineProps<LGraphNodeProps>()

View File

@@ -1,3 +1,4 @@
/* eslint-disable vue/one-component-per-file */
import { mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
import { describe, expect, it } from 'vitest'

View File

@@ -9,7 +9,6 @@ import type { SimplifiedWidget } from '@/types/simplifiedWidget'
const props = defineProps<{
widget: SimplifiedWidget<void>
nodeId: string
readonly?: boolean
}>()
const domEl = ref<HTMLElement>()

View File

@@ -14,7 +14,6 @@ import type { SimplifiedWidget } from '@/types/simplifiedWidget'
const props = defineProps<{
widget: SimplifiedWidget<void>
readonly?: boolean
}>()
const canvasEl = ref()

View File

@@ -92,7 +92,6 @@ import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { app } from '@/scripts/app'
import { useAudioService } from '@/services/audioService'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { useAudioPlayback } from '../composables/audio/useAudioPlayback'
import { useAudioRecorder } from '../composables/audio/useAudioRecorder'
@@ -100,7 +99,6 @@ import { useAudioWaveform } from '../composables/audio/useAudioWaveform'
import { formatTime } from '../utils/audioUtils'
const props = defineProps<{
widget: SimplifiedWidget<string | number | undefined>
readonly?: boolean
nodeId: string
}>()

View File

@@ -82,7 +82,6 @@
<!-- Options Button -->
<div
v-if="showOptionsButton"
ref="optionsButtonRef"
role="button"
:tabindex="0"
:aria-label="$t('g.moreOptions')"
@@ -155,10 +154,8 @@ const { t } = useI18n()
const props = withDefaults(
defineProps<{
readonly?: boolean
hideWhenEmpty?: boolean
showOptionsButton?: boolean
modelValue?: string
nodeId?: string
audioUrl?: string
}>(),
@@ -170,7 +167,6 @@ const props = withDefaults(
// Refs
const audioRef = ref<HTMLAudioElement>()
const optionsMenu = ref()
const optionsButtonRef = ref<HTMLElement>()
const isPlaying = ref(false)
const isMuted = ref(false)
const volume = ref(1)

View File

@@ -9,7 +9,6 @@ import type { DropdownItem, SelectedKey } from './types'
interface Props {
isOpen?: boolean
placeholder?: string
files: File[]
items: DropdownItem[]
selected: Set<SelectedKey>
maxSelectable: number

View File

@@ -67,6 +67,11 @@ const router = createRouter({
path: 'user-select',
name: 'UserSelectView',
component: () => import('@/views/UserSelectView.vue')
},
{
path: 'designer/search-lab',
name: 'TemplateSearchLab',
component: () => import('@/views/templates/TemplateSearchLab.vue')
}
]
}
@@ -169,9 +174,8 @@ if (isCloud) {
// For root path, check actual user status to handle waitlisted users
if (!isElectron() && isLoggedIn && to.path === '/') {
// Import auth functions dynamically to avoid circular dependency
const { getSurveyCompletedStatus } = await import(
'@/platform/cloud/onboarding/auth'
)
const { getSurveyCompletedStatus } =
await import('@/platform/cloud/onboarding/auth')
try {
// Check user's actual status
const surveyCompleted = await getSurveyCompletedStatus()

View File

@@ -369,6 +369,35 @@ const zNodeBadgeMode = z.enum(
Object.values(NodeBadgeMode) as [string, ...string[]]
)
const zTemplateFuseOptionState = z.object({
isCaseSensitive: z.boolean(),
ignoreDiacritics: z.boolean(),
includeScore: z.boolean(),
includeMatches: z.boolean(),
minMatchCharLength: z.number(),
shouldSort: z.boolean(),
findAllMatches: z.boolean(),
location: z.number(),
threshold: z.number(),
distance: z.number(),
ignoreLocation: z.boolean(),
useExtendedSearch: z.boolean(),
ignoreFieldNorm: z.boolean(),
fieldNormWeight: z.number()
})
const zTemplateFuseKeyConfig = z.object({
path: z.string(),
weight: z.number()
})
const zTemplateFuseOverrides = z.object({
options: zTemplateFuseOptionState,
keys: z.array(zTemplateFuseKeyConfig),
sortMode: z.enum(['score', 'exact', 'prefix']),
getFnMode: z.enum(['default', 'flatten'])
})
const zSettings = z.object({
'Comfy.ColorPalette': z.string(),
'Comfy.CustomColorPalettes': colorPalettesSchema,
@@ -518,6 +547,7 @@ const zSettings = z.object({
'vram-low-to-high',
'model-size-low-to-high'
]),
'Comfy.Templates.FuseOverrides': zTemplateFuseOverrides,
/** Settings used for testing */
'test.setting': z.any(),
'main.sub.setting.name': z.any(),

View File

@@ -15,8 +15,9 @@ import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
import { generateUUID } from '@/utils/formatUtil'
export interface BaseDOMWidget<V extends object | string = object | string>
extends IBaseWidget<V, string, DOMWidgetOptions<V>> {
export interface BaseDOMWidget<
V extends object | string = object | string
> extends IBaseWidget<V, string, DOMWidgetOptions<V>> {
// ICustomWidget properties
type: string
options: DOMWidgetOptions<V>
@@ -37,8 +38,10 @@ export interface BaseDOMWidget<V extends object | string = object | string>
/**
* A DOM widget that wraps a custom HTML element as a litegraph widget.
*/
export interface DOMWidget<T extends HTMLElement, V extends object | string>
extends BaseDOMWidget<V> {
export interface DOMWidget<
T extends HTMLElement,
V extends object | string
> extends BaseDOMWidget<V> {
element: T
/**
* @deprecated Legacy property used by some extensions for customtext
@@ -78,8 +81,9 @@ export interface ComponentWidget<
readonly props?: P
}
export interface DOMWidgetOptions<V extends object | string>
extends IWidgetOptions {
export interface DOMWidgetOptions<
V extends object | string
> extends IWidgetOptions {
/**
* Whether to render a placeholder rectangle when zoomed out.
*/
@@ -286,9 +290,9 @@ export class DOMWidgetImpl<T extends HTMLElement, V extends object | string>
}
export class ComponentWidgetImpl<
V extends object | string,
P extends ComponentWidgetCustomProps = ComponentWidgetCustomProps
>
V extends object | string,
P extends ComponentWidgetCustomProps = ComponentWidgetCustomProps
>
extends BaseDOMWidgetImpl<V>
implements ComponentWidget<V, P>
{

View File

@@ -535,9 +535,8 @@ export const useDialogService = () => {
return
}
const { useSubscriptionDialog } = await import(
'@/platform/cloud/subscription/composables/useSubscriptionDialog'
)
const { useSubscriptionDialog } =
await import('@/platform/cloud/subscription/composables/useSubscriptionDialog')
const { show } = useSubscriptionDialog()
show()
}

View File

@@ -5,8 +5,10 @@ import { computed, ref } from 'vue'
import { electronAPI, isElectron } from '@/utils/envUtil'
export interface ElectronDownload
extends Pick<DownloadState, 'url' | 'filename'> {
export interface ElectronDownload extends Pick<
DownloadState,
'url' | 'filename'
> {
progress?: number
savePath?: string
status?: DownloadStatus

View File

@@ -0,0 +1,937 @@
<template>
<div
class="grid min-h-screen gap-6 overflow-y-auto p-6 lg:grid-cols-[1.1fr_0.9fr]"
>
<div
class="flex max-h-[calc(100vh-3rem)] flex-col gap-6 overflow-y-auto pr-3"
>
<section
class="rounded-2xl border border-border-default bg-secondary-background p-6 shadow-sm"
>
<div
class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between"
>
<div class="space-y-2">
<p
class="text-sm font-semibold uppercase tracking-wide text-muted-foreground"
>
{{ t('templateSearchLab.title') }}
</p>
<h1 class="text-2xl font-semibold text-base-foreground">
{{ t('templateSearchLab.subtitle') }}
</h1>
<p class="text-sm text-muted-foreground">
{{ t('templateSearchLab.description') }}
</p>
<div class="flex flex-wrap gap-2">
<Button
size="small"
:label="t('templateSearchLab.reset')"
icon="icon-[lucide--refresh-cw]"
severity="secondary"
@click="resetLab"
/>
<Button
size="small"
:label="
copyStatus === 'copied'
? t('templateSearchLab.configCopied')
: t('templateSearchLab.configCopy')
"
:icon="
copyStatus === 'copied'
? 'icon-[lucide--check]'
: 'icon-[lucide--clipboard-copy]'
"
:severity="copyStatus === 'error' ? 'danger' : 'secondary'"
@click="copyOptions"
/>
</div>
</div>
<div class="flex flex-wrap gap-4 text-sm text-muted-foreground">
<a
v-for="link in resourceLinks"
:key="link.href"
class="inline-flex items-center gap-2 text-sm font-medium text-text-primary underline-offset-4 hover:underline"
:href="link.href"
target="_blank"
rel="noreferrer"
>
<i :class="link.icon" />
<span>{{ link.label }}</span>
</a>
</div>
</div>
<div class="mt-6 grid gap-6 lg:grid-cols-[1.6fr_1fr]">
<div class="space-y-4">
<div class="space-y-2">
<label class="text-sm font-semibold text-base-foreground">
{{ t('templateSearchLab.searchLabel') }}
</label>
<SearchBox
v-model="searchQuery"
:placeholder="t('templateSearchLab.searchPlaceholder')"
show-border
/>
<div class="flex flex-wrap gap-2 text-xs text-muted-foreground">
<span class="font-medium">{{
t('templateSearchLab.samplesLabel')
}}</span>
<button
v-for="sample in sampleQueries"
:key="sample"
type="button"
class="rounded-full border border-border-muted px-3 py-1 text-xs font-medium text-base-foreground transition hover:bg-base-background"
@click="searchQuery = sample"
>
{{ sample }}
</button>
</div>
</div>
<div
class="rounded-xl border border-border-default bg-secondary-background shadow-inner"
>
<div
class="flex items-center justify-between border-b border-border-muted px-4 py-3"
>
<div>
<p class="text-sm font-semibold text-base-foreground">
{{ t('templateSearchLab.previewTitle') }}
</p>
<p class="text-xs text-muted-foreground">
{{ previewSummary }}
</p>
</div>
<Tag v-if="searchQuery.trim().length" severity="secondary">
{{
t('templateSearchLab.previewCount', {
count: previewResults.length
})
}}
</Tag>
</div>
<div class="max-h-[420px] space-y-3 overflow-y-auto px-4 py-3">
<template v-if="!isLoaded">
<p class="text-sm text-muted-foreground">
{{ t('templateSearchLab.loadingTemplates') }}
</p>
</template>
<template v-else-if="!searchQuery.trim().length">
<p class="text-sm text-muted-foreground">
{{ t('templateSearchLab.previewEmptyState') }}
</p>
</template>
<template v-else-if="previewResults.length === 0">
<p class="text-sm text-danger-100">
{{
t('templateSearchLab.previewNoResults', {
query: searchQuery
})
}}
</p>
</template>
<template v-else>
<article
v-for="(result, index) in previewResults"
:key="`${result.item.name}-${index}`"
class="rounded-lg border border-border-muted bg-secondary-background px-4 py-3"
>
<div
class="flex flex-wrap items-start justify-between gap-2"
>
<div>
<p class="text-base font-semibold text-base-foreground">
{{ formatTemplateTitle(result.item) }}
</p>
<p class="text-xs text-muted-foreground">
{{ formatTemplateMeta(result.item) }}
</p>
</div>
<Tag
v-if="typeof result.score === 'number'"
severity="info"
>
{{ t('templateSearchLab.scoreLabel') }}:
{{ formatScore(result.score) }}
</Tag>
</div>
<div v-if="result.matches?.length" class="mt-3 space-y-2">
<div
v-for="match in result.matches"
:key="`${match.key}-${match.refIndex}`"
class="rounded-md border border-border-muted px-3 py-2"
>
<p
class="text-xs font-semibold uppercase text-muted-foreground"
>
{{
t('templateSearchLab.matchLabel', {
field:
match.key || t('templateSearchLab.unknownField')
})
}}
</p>
<p
v-if="typeof match.value === 'string'"
class="text-sm text-base-foreground"
>
<span
v-for="(chunk, chunkIndex) in buildHighlightChunks(
match.value,
match.indices || []
)"
:key="chunkIndex"
:class="
chunk.isHit
? 'rounded bg-base-background px-1 font-semibold text-base-foreground'
: ''
"
>
{{ chunk.text }}
</span>
</p>
<p v-else class="text-xs text-muted-foreground">
{{ formatNonStringMatch(match.value) }}
</p>
</div>
</div>
<p
v-else-if="designerOptions.includeMatches"
class="mt-3 text-xs text-muted-foreground"
>
{{ t('templateSearchLab.matchFallback') }}
</p>
</article>
</template>
</div>
</div>
</div>
<div class="space-y-4">
<div
class="rounded-xl border border-border-default bg-secondary-background p-4"
>
<div class="flex items-center justify-between">
<p class="text-sm font-semibold text-base-foreground">
{{ t('templateSearchLab.configHeading') }}
</p>
<i class="icon-[lucide--sparkles] text-muted-foreground" />
</div>
<p class="mt-1 text-xs text-muted-foreground">
{{ t('templateSearchLab.configSubheading') }}
</p>
<pre
class="mt-3 max-h-64 overflow-auto rounded-lg bg-base-background p-3 text-xs leading-relaxed text-base-foreground"
>{{ shareableConfig }}</pre
>
</div>
<div
class="rounded-xl border border-border-default bg-secondary-background p-4"
>
<p class="text-sm font-semibold text-base-foreground">
{{ t('templateSearchLab.additionalReadingTitle') }}
</p>
<p class="text-xs text-muted-foreground">
{{ t('templateSearchLab.additionalReadingSubtitle') }}
</p>
<ul class="mt-3 space-y-2 text-sm">
<li
v-for="link in deepDiveLinks"
:key="link.href"
class="flex items-center gap-2 text-text-primary"
>
<i :class="link.icon" class="text-base" />
<a
class="hover:underline"
:href="link.href"
target="_blank"
rel="noreferrer"
>
{{ link.label }}
</a>
</li>
</ul>
</div>
</div>
</div>
</section>
<section class="space-y-6">
<div
v-for="group in optionGroups"
:key="group.key"
class="rounded-2xl border border-border-default bg-secondary-background p-6"
>
<div class="flex items-center justify-between">
<div>
<p class="text-lg font-semibold text-base-foreground">
{{ group.title }}
</p>
<p class="text-sm text-muted-foreground">
{{ group.description }}
</p>
</div>
<Tag severity="secondary"
>{{ group.options.length }}
{{ t('templateSearchLab.optionCountSuffix') }}</Tag
>
</div>
<div class="mt-4 grid gap-4 md:grid-cols-2">
<div
v-for="option in group.options"
:key="option.key"
class="rounded-xl border border-border-muted bg-secondary-background p-4"
>
<div class="flex items-start justify-between gap-3">
<div>
<p class="font-semibold text-base-foreground">
{{ option.label }}
</p>
<p class="text-xs text-muted-foreground">
{{ option.description }}
</p>
<p class="mt-1 text-xs font-medium text-text-primary">
{{ option.example }}
</p>
</div>
<a
class="text-xs font-semibold text-text-primary underline-offset-4 hover:underline"
:href="docLink(option.anchor)"
target="_blank"
rel="noreferrer"
>
{{ t('templateSearchLab.docLinkLabel') }}
</a>
</div>
<div class="mt-4 space-y-3">
<ToggleSwitch
v-if="option.type === 'boolean'"
v-model="
designerOptions[option.key as keyof DesignerToggleOptions]
"
:input-id="`toggle-${option.key}`"
/>
<div v-else-if="option.type === 'number'" class="space-y-2">
<Slider
v-model.number="
designerOptions[
option.key as keyof DesignerNumericOptions
]
"
:min="option.min"
:max="option.max"
:step="option.step"
/>
<InputNumber
v-model.number="
designerOptions[
option.key as keyof DesignerNumericOptions
]
"
:min="option.min"
:max="option.max"
:step="option.step"
class="w-full"
/>
</div>
<div v-else-if="option.type === 'sort'" class="space-y-2">
<Select
v-model="sortMode"
:options="sortOptions"
option-label="label"
option-value="value"
class="w-full"
/>
<p class="text-xs text-muted-foreground">
{{ t('templateSearchLab.sortDescription') }}
</p>
</div>
<div v-else-if="option.type === 'get'" class="space-y-2">
<Select
v-model="getFnMode"
:options="getFnOptions"
option-label="label"
option-value="value"
class="w-full"
/>
<p class="text-xs text-muted-foreground">
{{ t('templateSearchLab.getFnDescription') }}
</p>
</div>
</div>
</div>
</div>
</div>
<div
class="rounded-2xl border border-border-default bg-secondary-background p-6"
>
<div class="flex items-center justify-between">
<div>
<p class="text-lg font-semibold text-base-foreground">
{{ t('templateSearchLab.keysHeading') }}
</p>
<p class="text-sm text-muted-foreground">
{{ t('templateSearchLab.keysDescription') }}
</p>
</div>
<a
class="text-xs font-semibold text-text-primary underline-offset-4 hover:underline"
:href="docLink('keys')"
target="_blank"
rel="noreferrer"
>
{{ t('templateSearchLab.docLinkLabel') }}
</a>
</div>
<p class="mt-2 text-xs text-muted-foreground">
{{ t('templateSearchLab.keysHelper') }}
</p>
<div class="mt-4 flex flex-wrap items-end gap-3">
<div class="grow">
<label class="text-xs font-semibold text-muted-foreground">
{{ t('templateSearchLab.keysAddLabel') }}
</label>
<Select
v-model="keyToAdd"
:options="keyLibrary"
option-label="label"
option-value="value"
class="w-full"
/>
</div>
<div>
<label class="text-xs font-semibold text-muted-foreground">
{{ t('templateSearchLab.keysWeightLabel') }}
</label>
<InputNumber
v-model.number="newKeyWeight"
:min="0.05"
:max="1"
:step="0.05"
/>
</div>
<Button
:label="t('templateSearchLab.keysAddButton')"
icon="icon-[lucide--plus-circle]"
:disabled="!keyToAdd"
@click="addKey"
/>
</div>
<div class="mt-6 grid gap-4 md:grid-cols-2">
<div
v-for="key in fuseKeyEntries"
:key="key.id"
class="rounded-xl border border-border-muted bg-secondary-background p-4"
>
<div class="flex items-start justify-between gap-3">
<div>
<p class="text-sm font-semibold text-base-foreground">
{{ key.path }}
</p>
<p class="text-xs text-muted-foreground">
{{ describeKey(key.path) }}
</p>
</div>
<button
type="button"
class="text-xs font-semibold text-danger-100 disabled:text-muted-foreground"
:disabled="fuseKeyEntries.length === 1"
@click="removeKey(key.id)"
>
{{ t('templateSearchLab.removeLabel') }}
</button>
</div>
<div class="mt-3 space-y-2">
<InputText v-model="key.path" class="w-full" />
<div class="flex items-center gap-3">
<Slider
v-model.number="key.weight"
:min="0.05"
:max="1"
:step="0.05"
/>
<InputNumber
v-model.number="key.weight"
:min="0.05"
:max="1"
:step="0.05"
/>
</div>
</div>
</div>
</div>
</div>
</section>
</div>
<aside
class="flex flex-col gap-4 rounded-2xl border border-border-default bg-secondary-background p-4"
>
<div>
<p class="text-sm font-semibold text-base-foreground">
{{ t('templateSearchLab.dialogPreviewTitle') }}
</p>
<p class="text-xs text-muted-foreground">
{{ t('templateSearchLab.dialogPreviewSubtitle') }}
</p>
</div>
<div
class="min-h-[70vh] flex-1 overflow-hidden rounded-xl border border-border-default bg-base-background"
>
<WorkflowTemplateSelectorDialog :on-close="noop" />
</div>
</aside>
</div>
</template>
<script setup lang="ts">
import { watchDebounced } from '@vueuse/core'
import Fuse from 'fuse.js'
import type { RangeTuple } from 'fuse.js'
import { storeToRefs } from 'pinia'
import Button from 'primevue/button'
import InputNumber from 'primevue/inputnumber'
import InputText from 'primevue/inputtext'
import Select from 'primevue/select'
import Slider from 'primevue/slider'
import Tag from 'primevue/tag'
import ToggleSwitch from 'primevue/toggleswitch'
import { computed, onMounted, provide, reactive, ref } from 'vue'
import WorkflowTemplateSelectorDialog from '@/components/custom/widget/WorkflowTemplateSelectorDialog.vue'
import SearchBox from '@/components/input/SearchBox.vue'
import { t } from '@/i18n'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useWorkflowTemplatesStore } from '@/platform/workflow/templates/repositories/workflowTemplatesStore'
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
import {
DEFAULT_TEMPLATE_FUSE_CONFIG,
TEMPLATE_FUSE_SETTINGS_KEY,
buildTemplateFuseOptions
} from '@/platform/workflow/templates/utils/templateFuseOptions'
import type {
TemplateFuseConfig,
TemplateFuseGetMode,
TemplateFuseKeyConfig,
TemplateFuseOptionState,
TemplateFuseSortMode
} from '@/platform/workflow/templates/utils/templateFuseOptions'
import { TEMPLATE_SEARCH_QUERY_OVERRIDE_KEY } from '@/platform/workflow/templates/utils/templateSearchLabInjection'
interface TemplateSearchRecord extends TemplateInfo {
localizedTitle?: string
localizedDescription?: string
tags?: string[]
models?: string[]
sourceModule?: string
searchableText?: string
}
interface HighlightChunk {
text: string
isHit: boolean
}
interface FuseKeyEntry {
id: string
path: string
weight: number
}
interface OptionDefinition {
key: keyof DesignerOptions | 'sort' | 'get'
label: string
description: string
example: string
anchor: string
type: 'boolean' | 'number' | 'sort' | 'get'
min?: number
max?: number
step?: number
}
interface OptionGroup {
key: string
title: string
description: string
options: OptionDefinition[]
}
type TemplateFuseToggleKey =
| 'isCaseSensitive'
| 'ignoreDiacritics'
| 'includeScore'
| 'includeMatches'
| 'shouldSort'
| 'findAllMatches'
| 'ignoreLocation'
| 'useExtendedSearch'
| 'ignoreFieldNorm'
type TemplateFuseNumberKey =
| 'minMatchCharLength'
| 'location'
| 'threshold'
| 'distance'
| 'fieldNormWeight'
type DesignerOptions = TemplateFuseOptionState
type DesignerToggleOptions = Pick<
TemplateFuseOptionState,
TemplateFuseToggleKey
>
type DesignerNumericOptions = Pick<
TemplateFuseOptionState,
TemplateFuseNumberKey
>
type SortMode = TemplateFuseSortMode
type GetFnMode = TemplateFuseGetMode
const DOCS_BASE_URL = 'https://www.fusejs.io/api/options.html'
let keyIdCounter = 0
const createKeyId = () => `fuse-key-${++keyIdCounter}`
const settingStore = useSettingStore()
const initialConfig =
settingStore.get(TEMPLATE_FUSE_SETTINGS_KEY) ?? DEFAULT_TEMPLATE_FUSE_CONFIG
const designerOptions = reactive<DesignerOptions>({
...initialConfig.options
})
const fuseKeyEntries = ref<FuseKeyEntry[]>(buildKeyEntries(initialConfig.keys))
const sortMode = ref<SortMode>(initialConfig.sortMode)
const getFnMode = ref<GetFnMode>(initialConfig.getFnMode)
const searchQuery = ref('wan')
const sampleQueries = ['wan', 'refiner', 'animate', 'face', 'stylized']
const keyToAdd = ref('models')
const newKeyWeight = ref(0.15)
const copyStatus = ref<'idle' | 'copied' | 'error'>('idle')
const noop = () => {}
const workflowTemplatesStore = useWorkflowTemplatesStore()
const { enhancedTemplates, isLoaded } = storeToRefs(workflowTemplatesStore)
onMounted(async () => {
if (!isLoaded.value) {
await workflowTemplatesStore.loadWorkflowTemplates()
}
})
const templateRecords = computed<TemplateSearchRecord[]>(
() => enhancedTemplates.value as TemplateSearchRecord[]
)
const normalizedQuery = computed(() => searchQuery.value.trim().toLowerCase())
provide(TEMPLATE_SEARCH_QUERY_OVERRIDE_KEY, searchQuery)
const currentConfig = computed<TemplateFuseConfig>(() => ({
options: { ...designerOptions },
keys: fuseKeyEntries.value
.filter((entry) => entry.path.trim().length)
.map((entry) => ({ path: entry.path.trim(), weight: entry.weight })),
sortMode: sortMode.value,
getFnMode: getFnMode.value
}))
watchDebounced(
() => currentConfig.value,
(config) => {
void settingStore.set(TEMPLATE_FUSE_SETTINGS_KEY, config)
},
{ debounce: 250, deep: true }
)
const fuseOptions = computed(() =>
buildTemplateFuseOptions<TemplateSearchRecord>({
config: currentConfig.value,
query: normalizedQuery.value
})
)
const previewResults = computed(() => {
const templates = templateRecords.value
if (!templates.length || !normalizedQuery.value.length) {
return []
}
const fuse = new Fuse(templates, fuseOptions.value)
return fuse.search(normalizedQuery.value, { limit: 30 })
})
const previewSummary = computed(() => {
if (!searchQuery.value.trim().length) {
return t('templateSearchLab.previewSummaryIdle')
}
if (!isLoaded.value) {
return t('templateSearchLab.loadingTemplates')
}
if (previewResults.value.length === 0) {
return t('templateSearchLab.previewSummaryEmpty')
}
return t('templateSearchLab.previewSummaryActive', {
count: previewResults.value.length,
total: templateRecords.value.length
})
})
const shareableConfig = computed(() =>
JSON.stringify(currentConfig.value, null, 2)
)
const resourceLinks = computed(() => [
{
href: docLink(''),
label: t('templateSearchLab.links.apiDocs'),
icon: 'icon-[lucide--book-open]'
},
{
href: 'https://www.fusejs.io/concepts/scoring-theory.html',
label: t('templateSearchLab.links.scoringTheory'),
icon: 'icon-[lucide--line-chart]'
},
{
href: 'https://www.fusejs.io/examples.html#extended-search',
label: t('templateSearchLab.links.extendedSearch'),
icon: 'icon-[lucide--filter]'
}
])
const deepDiveLinks = computed(() => resourceLinks.value.slice(1))
const sortOptions = computed(() => [
{ value: 'score', label: t('templateSearchLab.sortModes.score') },
{ value: 'exact', label: t('templateSearchLab.sortModes.exact') },
{ value: 'prefix', label: t('templateSearchLab.sortModes.prefix') }
])
const getFnOptions = computed(() => [
{ value: 'default', label: t('templateSearchLab.getFnModes.default') },
{ value: 'flatten', label: t('templateSearchLab.getFnModes.flatten') }
])
const keyLibrary = computed(() => [
{ value: 'name', label: t('templateSearchLab.keysOptions.name') },
{ value: 'title', label: t('templateSearchLab.keysOptions.title') },
{
value: 'description',
label: t('templateSearchLab.keysOptions.description')
},
{ value: 'tags', label: t('templateSearchLab.keysOptions.tags') },
{ value: 'models', label: t('templateSearchLab.keysOptions.models') },
{ value: 'useCase', label: t('templateSearchLab.keysOptions.useCase') },
{
value: 'sourceModule',
label: t('templateSearchLab.keysOptions.sourceModule')
}
])
const optionGroups = computed<OptionGroup[]>(() => [
{
key: 'basic',
title: t('templateSearchLab.optionGroups.basic.title'),
description: t('templateSearchLab.optionGroups.basic.description'),
options: [
createToggleOption('isCaseSensitive', 'iscasesensitive'),
createToggleOption('ignoreDiacritics', 'ignorediacritics'),
createToggleOption('includeScore', 'includescore'),
createToggleOption('includeMatches', 'includematches'),
createNumericOption('minMatchCharLength', 'minmatchcharlength', 1, 10, 1),
createToggleOption('shouldSort', 'shouldsort'),
createToggleOption('findAllMatches', 'findallmatches')
]
},
{
key: 'fuzzy',
title: t('templateSearchLab.optionGroups.fuzzy.title'),
description: t('templateSearchLab.optionGroups.fuzzy.description'),
options: [
createNumericOption('location', 'location', 0, 500, 5),
createNumericOption('threshold', 'threshold', 0, 1, 0.01),
createNumericOption('distance', 'distance', 0, 1000, 10),
createToggleOption('ignoreLocation', 'ignorelocation')
]
},
{
key: 'advanced',
title: t('templateSearchLab.optionGroups.advanced.title'),
description: t('templateSearchLab.optionGroups.advanced.description'),
options: [
createToggleOption('useExtendedSearch', 'useextendedsearch'),
{
key: 'sort',
label: t('templateSearchLab.sortLabel'),
description: t('templateSearchLab.sortDescription'),
example: t('templateSearchLab.sortExample'),
anchor: 'sortFn',
type: 'sort'
},
{
key: 'get',
label: t('templateSearchLab.getFnLabel'),
description: t('templateSearchLab.getFnDescription'),
example: t('templateSearchLab.getFnExample'),
anchor: 'getFn',
type: 'get'
},
createToggleOption('ignoreFieldNorm', 'ignorefieldnorm'),
createNumericOption('fieldNormWeight', 'fieldnormweight', 0, 2, 0.1)
]
}
])
function createToggleOption(
key: keyof DesignerToggleOptions,
anchor: string
): OptionDefinition {
return {
key,
anchor,
label: key,
description: t(`templateSearchLab.options.${String(key)}.description`),
example: t(`templateSearchLab.options.${String(key)}.example`),
type: 'boolean'
}
}
function createNumericOption(
key: keyof DesignerNumericOptions,
anchor: string,
min: number,
max: number,
step: number
): OptionDefinition {
return {
key,
anchor,
label: key,
description: t(`templateSearchLab.options.${String(key)}.description`),
example: t(`templateSearchLab.options.${String(key)}.example`),
type: 'number',
min,
max,
step
}
}
function buildHighlightChunks(
text: string,
indices: readonly RangeTuple[]
): HighlightChunk[] {
if (!indices.length) {
return [{ text, isHit: false }]
}
const chunks: HighlightChunk[] = []
let lastIndex = 0
indices.forEach(([start, end]) => {
if (start > lastIndex) {
chunks.push({ text: text.slice(lastIndex, start), isHit: false })
}
chunks.push({ text: text.slice(start, end + 1), isHit: true })
lastIndex = end + 1
})
if (lastIndex < text.length) {
chunks.push({ text: text.slice(lastIndex), isHit: false })
}
return chunks
}
function formatScore(score: number) {
return score.toFixed(3)
}
function formatTemplateTitle(template: TemplateSearchRecord) {
return template.title || template.localizedTitle || template.name
}
function formatTemplateMeta(template: TemplateSearchRecord) {
const runsOn = template.openSource === false ? 'External API' : 'ComfyUI'
const models = template.models?.slice(0, 2).join(', ')
return [runsOn, models].filter(Boolean).join(' • ')
}
function formatNonStringMatch(value: unknown) {
if (Array.isArray(value)) {
return value.join(', ')
}
return String(value)
}
function docLink(anchor: string) {
return anchor ? `${DOCS_BASE_URL}#${anchor}` : DOCS_BASE_URL
}
function describeKey(path: string) {
switch (path) {
case 'name':
return t('templateSearchLab.keys.nameDescription')
case 'title':
return t('templateSearchLab.keys.titleDescription')
case 'description':
return t('templateSearchLab.keys.descriptionDescription')
case 'tags':
return t('templateSearchLab.keys.tagsDescription')
case 'models':
return t('templateSearchLab.keys.modelsDescription')
default:
return t('templateSearchLab.keys.customDescription', { field: path })
}
}
function buildKeyEntries(keys: TemplateFuseKeyConfig[]): FuseKeyEntry[] {
if (!keys.length) {
return DEFAULT_TEMPLATE_FUSE_CONFIG.keys.map((key) => createKeyEntry(key))
}
return keys.map((key) => createKeyEntry(key))
}
function createKeyEntry(config: TemplateFuseKeyConfig): FuseKeyEntry {
return {
id: createKeyId(),
path: config.path,
weight: config.weight
}
}
function addKey() {
if (!keyToAdd.value) {
return
}
fuseKeyEntries.value.push(
createKeyEntry({ path: keyToAdd.value, weight: newKeyWeight.value })
)
}
function removeKey(id: string) {
if (fuseKeyEntries.value.length === 1) {
return
}
fuseKeyEntries.value = fuseKeyEntries.value.filter((entry) => entry.id !== id)
}
async function copyOptions() {
try {
await navigator.clipboard.writeText(shareableConfig.value)
copyStatus.value = 'copied'
window.setTimeout(() => {
copyStatus.value = 'idle'
}, 2000)
} catch (error) {
console.error(error)
copyStatus.value = 'error'
}
}
function resetLab() {
applyConfig(DEFAULT_TEMPLATE_FUSE_CONFIG)
searchQuery.value = 'wan'
}
function applyConfig(config: TemplateFuseConfig) {
Object.assign(designerOptions, config.options)
fuseKeyEntries.value = buildKeyEntries(config.keys)
sortMode.value = config.sortMode
getFnMode.value = config.getFnMode
}
</script>

View File

@@ -549,9 +549,8 @@ export function useConflictDetection() {
async function initializeConflictDetection(): Promise<void> {
try {
// Check if manager is new Manager before proceeding
const { useManagerState } = await import(
'@/workbench/extensions/manager/composables/useManagerState'
)
const { useManagerState } =
await import('@/workbench/extensions/manager/composables/useManagerState')
const managerState = useManagerState()
if (!managerState.isNewManagerUI.value) {

View File

@@ -61,13 +61,6 @@ describe('EssentialsPanel', () => {
const shortcutsList = wrapper.findComponent(ShortcutsList)
expect(shortcutsList.exists()).toBe(true)
// Should pass only essentials commands
const commands = shortcutsList.props('commands')
expect(commands).toHaveLength(3)
commands.forEach((cmd: ComfyCommandImpl) => {
expect(cmd.category).toBe('essentials')
})
})
it('should categorize commands into subcategories', () => {

View File

@@ -18,9 +18,8 @@ describe('useConflictAcknowledgment', () => {
describe('initial state loading', () => {
it('should load empty state when localStorage is empty', async () => {
vi.resetModules()
const { useConflictAcknowledgment } = await import(
'@/workbench/extensions/manager/composables/useConflictAcknowledgment'
)
const { useConflictAcknowledgment } =
await import('@/workbench/extensions/manager/composables/useConflictAcknowledgment')
const { acknowledgmentState } = useConflictAcknowledgment()
expect(acknowledgmentState.value).toEqual({
@@ -44,9 +43,8 @@ describe('useConflictAcknowledgment', () => {
// Need to import the module after localStorage is set
vi.resetModules()
const { useConflictAcknowledgment } = await import(
'@/workbench/extensions/manager/composables/useConflictAcknowledgment'
)
const { useConflictAcknowledgment } =
await import('@/workbench/extensions/manager/composables/useConflictAcknowledgment')
const { acknowledgmentState } = useConflictAcknowledgment()
expect(acknowledgmentState.value).toEqual({
@@ -60,9 +58,8 @@ describe('useConflictAcknowledgment', () => {
describe('dismissal functions', () => {
it('should mark conflicts as seen with unified function', async () => {
vi.resetModules()
const { useConflictAcknowledgment } = await import(
'@/workbench/extensions/manager/composables/useConflictAcknowledgment'
)
const { useConflictAcknowledgment } =
await import('@/workbench/extensions/manager/composables/useConflictAcknowledgment')
const { markConflictsAsSeen, acknowledgmentState } =
useConflictAcknowledgment()
@@ -73,9 +70,8 @@ describe('useConflictAcknowledgment', () => {
it('should dismiss red dot notification', async () => {
vi.resetModules()
const { useConflictAcknowledgment } = await import(
'@/workbench/extensions/manager/composables/useConflictAcknowledgment'
)
const { useConflictAcknowledgment } =
await import('@/workbench/extensions/manager/composables/useConflictAcknowledgment')
const { dismissRedDotNotification, acknowledgmentState } =
useConflictAcknowledgment()
@@ -86,9 +82,8 @@ describe('useConflictAcknowledgment', () => {
it('should dismiss warning banner', async () => {
vi.resetModules()
const { useConflictAcknowledgment } = await import(
'@/workbench/extensions/manager/composables/useConflictAcknowledgment'
)
const { useConflictAcknowledgment } =
await import('@/workbench/extensions/manager/composables/useConflictAcknowledgment')
const { dismissWarningBanner, acknowledgmentState } =
useConflictAcknowledgment()
@@ -99,9 +94,8 @@ describe('useConflictAcknowledgment', () => {
it('should mark all conflicts as seen', async () => {
vi.resetModules()
const { useConflictAcknowledgment } = await import(
'@/workbench/extensions/manager/composables/useConflictAcknowledgment'
)
const { useConflictAcknowledgment } =
await import('@/workbench/extensions/manager/composables/useConflictAcknowledgment')
const { markConflictsAsSeen, acknowledgmentState } =
useConflictAcknowledgment()
@@ -117,9 +111,8 @@ describe('useConflictAcknowledgment', () => {
it('should calculate shouldShowConflictModal correctly', async () => {
// Need fresh module import to ensure clean state
vi.resetModules()
const { useConflictAcknowledgment } = await import(
'@/workbench/extensions/manager/composables/useConflictAcknowledgment'
)
const { useConflictAcknowledgment } =
await import('@/workbench/extensions/manager/composables/useConflictAcknowledgment')
const { shouldShowConflictModal, markConflictsAsSeen } =
useConflictAcknowledgment()
@@ -131,9 +124,8 @@ describe('useConflictAcknowledgment', () => {
it('should calculate shouldShowRedDot correctly based on conflicts', async () => {
vi.resetModules()
const { useConflictAcknowledgment } = await import(
'@/workbench/extensions/manager/composables/useConflictAcknowledgment'
)
const { useConflictAcknowledgment } =
await import('@/workbench/extensions/manager/composables/useConflictAcknowledgment')
const { shouldShowRedDot, dismissRedDotNotification } =
useConflictAcknowledgment()
@@ -146,9 +138,8 @@ describe('useConflictAcknowledgment', () => {
it('should calculate shouldShowManagerBanner correctly', async () => {
vi.resetModules()
const { useConflictAcknowledgment } = await import(
'@/workbench/extensions/manager/composables/useConflictAcknowledgment'
)
const { useConflictAcknowledgment } =
await import('@/workbench/extensions/manager/composables/useConflictAcknowledgment')
const { shouldShowManagerBanner, dismissWarningBanner } =
useConflictAcknowledgment()
@@ -164,9 +155,8 @@ describe('useConflictAcknowledgment', () => {
it('should persist to localStorage automatically', async () => {
// Need fresh module import to ensure clean state
vi.resetModules()
const { useConflictAcknowledgment } = await import(
'@/workbench/extensions/manager/composables/useConflictAcknowledgment'
)
const { useConflictAcknowledgment } =
await import('@/workbench/extensions/manager/composables/useConflictAcknowledgment')
const { markConflictsAsSeen, dismissWarningBanner } =
useConflictAcknowledgment()

View File

@@ -153,9 +153,8 @@ vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
}))
}))
const { useMinimap } = await import(
'@/renderer/extensions/minimap/composables/useMinimap'
)
const { useMinimap } =
await import('@/renderer/extensions/minimap/composables/useMinimap')
const { api } = await import('@/scripts/api')
describe('useMinimap', () => {

View File

@@ -29,9 +29,8 @@ vi.mock('@/platform/telemetry', () => ({
}))
}))
const { useTemplateFiltering } = await import(
'@/composables/useTemplateFiltering'
)
const { useTemplateFiltering } =
await import('@/composables/useTemplateFiltering')
describe('useTemplateFiltering', () => {
beforeEach(() => {

View File

@@ -103,9 +103,8 @@ describe('useMinimapRenderer', () => {
})
it('should only render when redraw is needed', async () => {
const { renderMinimapToCanvas } = await import(
'@/renderer/extensions/minimap/minimapCanvasRenderer'
)
const { renderMinimapToCanvas } =
await import('@/renderer/extensions/minimap/minimapCanvasRenderer')
const canvasRef = ref(mockCanvas)
const graphRef = ref(mockGraph as any)
const boundsRef = ref({ minX: 0, minY: 0, width: 100, height: 100 })

View File

@@ -66,9 +66,8 @@ describe('useMinimapViewport', () => {
})
it('should calculate graph bounds from nodes', async () => {
const { calculateNodeBounds, enforceMinimumBounds } = await import(
'@/renderer/core/spatial/boundsCalculator'
)
const { calculateNodeBounds, enforceMinimumBounds } =
await import('@/renderer/core/spatial/boundsCalculator')
vi.mocked(calculateNodeBounds).mockReturnValue({
minX: 100,
@@ -93,9 +92,8 @@ describe('useMinimapViewport', () => {
})
it('should handle empty graph', async () => {
const { calculateNodeBounds } = await import(
'@/renderer/core/spatial/boundsCalculator'
)
const { calculateNodeBounds } =
await import('@/renderer/core/spatial/boundsCalculator')
vi.mocked(calculateNodeBounds).mockReturnValue(null)

View File

@@ -19,12 +19,10 @@ vi.mock('@/scripts/api', () => ({
}
}))
const { useWorkflowThumbnail } = await import(
'@/renderer/core/thumbnail/useWorkflowThumbnail'
)
const { createGraphThumbnail } = await import(
'@/renderer/core/thumbnail/graphThumbnailRenderer'
)
const { useWorkflowThumbnail } =
await import('@/renderer/core/thumbnail/useWorkflowThumbnail')
const { createGraphThumbnail } =
await import('@/renderer/core/thumbnail/graphThumbnailRenderer')
const { api } = await import('@/scripts/api')
describe('useWorkflowThumbnail', () => {

View File

@@ -62,9 +62,8 @@ describe('useReleaseStore', () => {
}
// Setup mock implementations
const { useReleaseService } = await import(
'@/platform/updates/common/releaseService'
)
const { useReleaseService } =
await import('@/platform/updates/common/releaseService')
const { useSettingStore } = await import('@/platform/settings/settingStore')
const { useSystemStatsStore } = await import('@/stores/systemStatsStore')
const { isElectron } = await import('@/utils/envUtil')

View File

@@ -287,9 +287,8 @@ describe('versionUtil', () => {
vi.resetModules()
// Import fresh module
const versionUtil = await import(
'@/workbench/extensions/manager/utils/versionUtil'
)
const versionUtil =
await import('@/workbench/extensions/manager/utils/versionUtil')
const version = versionUtil.getFrontendVersion()
expect(version).toBe('2.0.0')
@@ -322,9 +321,8 @@ describe('versionUtil', () => {
vi.resetModules()
// Import fresh module
const versionUtil = await import(
'@/workbench/extensions/manager/utils/versionUtil'
)
const versionUtil =
await import('@/workbench/extensions/manager/utils/versionUtil')
const version = versionUtil.getFrontendVersion()
expect(version).toBeUndefined()