mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-24 06:35:10 +00:00
Compare commits
3 Commits
version-bu
...
glary/post
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2990331e1 | ||
|
|
0647d7eba9 | ||
|
|
c0611876d0 |
@@ -73,6 +73,22 @@ export interface RunButtonProperties {
|
||||
is_app_mode?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Classifies how a template workflow has been edited relative to its
|
||||
* original baseline at the time of execution. Used to distinguish runs
|
||||
* that explore variations (seed/prompt only) from runs that materially
|
||||
* change the workflow graph.
|
||||
*
|
||||
* Only meaningful when `is_template = true` and a baseline was captured
|
||||
* when the template was loaded. Omitted otherwise.
|
||||
*/
|
||||
export type TemplateChangeType =
|
||||
| 'unchanged'
|
||||
| 'seed_only'
|
||||
| 'prompt_only'
|
||||
| 'seed_and_prompt'
|
||||
| 'structural'
|
||||
|
||||
/**
|
||||
* Execution context for workflow tracking
|
||||
*/
|
||||
@@ -86,6 +102,7 @@ export interface ExecutionContext {
|
||||
template_models?: string[]
|
||||
template_use_case?: string
|
||||
template_license?: string
|
||||
template_change_type?: TemplateChangeType
|
||||
// Node composition metrics
|
||||
custom_node_count: number
|
||||
api_node_count: number
|
||||
|
||||
444
src/platform/telemetry/utils/classifyTemplateChange.test.ts
Normal file
444
src/platform/telemetry/utils/classifyTemplateChange.test.ts
Normal file
@@ -0,0 +1,444 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
|
||||
import { classifyTemplateChange } from './classifyTemplateChange'
|
||||
import type { LiveNodeLookup } from './classifyTemplateChange'
|
||||
|
||||
function makeWorkflow(
|
||||
nodes: Array<{
|
||||
id: number | string
|
||||
type: string
|
||||
widgets_values?: unknown[]
|
||||
inputs?: unknown[]
|
||||
outputs?: unknown[]
|
||||
}>,
|
||||
links: Array<[number, number, number, number, number, string]> = []
|
||||
): ComfyWorkflowJSON {
|
||||
return {
|
||||
nodes,
|
||||
links,
|
||||
groups: [],
|
||||
config: {},
|
||||
extra: {},
|
||||
version: 0.4
|
||||
} as unknown as ComfyWorkflowJSON
|
||||
}
|
||||
|
||||
function liveNodes(
|
||||
entries: Array<[number | string, Array<{ name?: string; type?: string }>]>
|
||||
): LiveNodeLookup {
|
||||
const map: LiveNodeLookup = new Map()
|
||||
for (const [id, widgets] of entries) {
|
||||
map.set(id, { widgets })
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
describe('classifyTemplateChange', () => {
|
||||
it('returns unchanged when baseline and current are identical', () => {
|
||||
const baseline = makeWorkflow([
|
||||
{ id: 1, type: 'KSampler', widgets_values: [42, 20, 7.5] }
|
||||
])
|
||||
const current = makeWorkflow([
|
||||
{ id: 1, type: 'KSampler', widgets_values: [42, 20, 7.5] }
|
||||
])
|
||||
|
||||
const result = classifyTemplateChange(
|
||||
baseline,
|
||||
current,
|
||||
liveNodes([[1, [{ name: 'seed' }, { name: 'steps' }, { name: 'cfg' }]]])
|
||||
)
|
||||
|
||||
expect(result).toBe('unchanged')
|
||||
})
|
||||
|
||||
it('returns seed_only when only a seed-named widget changed', () => {
|
||||
const baseline = makeWorkflow([
|
||||
{ id: 1, type: 'KSampler', widgets_values: [42, 20, 7.5] }
|
||||
])
|
||||
const current = makeWorkflow([
|
||||
{ id: 1, type: 'KSampler', widgets_values: [99, 20, 7.5] }
|
||||
])
|
||||
|
||||
const result = classifyTemplateChange(
|
||||
baseline,
|
||||
current,
|
||||
liveNodes([[1, [{ name: 'seed' }, { name: 'steps' }, { name: 'cfg' }]]])
|
||||
)
|
||||
|
||||
expect(result).toBe('seed_only')
|
||||
})
|
||||
|
||||
it('returns seed_only for a widget named noise_seed', () => {
|
||||
const baseline = makeWorkflow([
|
||||
{ id: 7, type: 'SamplerCustom', widgets_values: [123] }
|
||||
])
|
||||
const current = makeWorkflow([
|
||||
{ id: 7, type: 'SamplerCustom', widgets_values: [456] }
|
||||
])
|
||||
|
||||
const result = classifyTemplateChange(
|
||||
baseline,
|
||||
current,
|
||||
liveNodes([[7, [{ name: 'noise_seed' }]]])
|
||||
)
|
||||
|
||||
expect(result).toBe('seed_only')
|
||||
})
|
||||
|
||||
it('returns prompt_only when only a text-named widget changed', () => {
|
||||
const baseline = makeWorkflow([
|
||||
{ id: 2, type: 'CLIPTextEncode', widgets_values: ['a cat'] }
|
||||
])
|
||||
const current = makeWorkflow([
|
||||
{ id: 2, type: 'CLIPTextEncode', widgets_values: ['a dog'] }
|
||||
])
|
||||
|
||||
const result = classifyTemplateChange(
|
||||
baseline,
|
||||
current,
|
||||
liveNodes([[2, [{ name: 'text' }]]])
|
||||
)
|
||||
|
||||
expect(result).toBe('prompt_only')
|
||||
})
|
||||
|
||||
it('detects positive_prompt and negative_prompt as prompt widgets', () => {
|
||||
const baseline = makeWorkflow([
|
||||
{ id: 3, type: 'PromptNode', widgets_values: ['cat', 'blurry'] }
|
||||
])
|
||||
const current = makeWorkflow([
|
||||
{ id: 3, type: 'PromptNode', widgets_values: ['cat', 'low quality'] }
|
||||
])
|
||||
|
||||
const result = classifyTemplateChange(
|
||||
baseline,
|
||||
current,
|
||||
liveNodes([
|
||||
[3, [{ name: 'positive_prompt' }, { name: 'negative_prompt' }]]
|
||||
])
|
||||
)
|
||||
|
||||
expect(result).toBe('prompt_only')
|
||||
})
|
||||
|
||||
it('returns seed_and_prompt when both seed and prompt changed', () => {
|
||||
const baseline = makeWorkflow([
|
||||
{ id: 1, type: 'KSampler', widgets_values: [42] },
|
||||
{ id: 2, type: 'CLIPTextEncode', widgets_values: ['a cat'] }
|
||||
])
|
||||
const current = makeWorkflow([
|
||||
{ id: 1, type: 'KSampler', widgets_values: [99] },
|
||||
{ id: 2, type: 'CLIPTextEncode', widgets_values: ['a dog'] }
|
||||
])
|
||||
|
||||
const result = classifyTemplateChange(
|
||||
baseline,
|
||||
current,
|
||||
liveNodes([
|
||||
[1, [{ name: 'seed' }]],
|
||||
[2, [{ name: 'text' }]]
|
||||
])
|
||||
)
|
||||
|
||||
expect(result).toBe('seed_and_prompt')
|
||||
})
|
||||
|
||||
it('returns structural when a non-seed/non-prompt widget changed', () => {
|
||||
const baseline = makeWorkflow([
|
||||
{ id: 1, type: 'KSampler', widgets_values: [42, 20, 7.5] }
|
||||
])
|
||||
const current = makeWorkflow([
|
||||
{ id: 1, type: 'KSampler', widgets_values: [42, 30, 7.5] }
|
||||
])
|
||||
|
||||
const result = classifyTemplateChange(
|
||||
baseline,
|
||||
current,
|
||||
liveNodes([[1, [{ name: 'seed' }, { name: 'steps' }, { name: 'cfg' }]]])
|
||||
)
|
||||
|
||||
expect(result).toBe('structural')
|
||||
})
|
||||
|
||||
it('returns structural when a node is added', () => {
|
||||
const baseline = makeWorkflow([{ id: 1, type: 'KSampler' }])
|
||||
const current = makeWorkflow([
|
||||
{ id: 1, type: 'KSampler' },
|
||||
{ id: 2, type: 'LoadImage' }
|
||||
])
|
||||
|
||||
const result = classifyTemplateChange(
|
||||
baseline,
|
||||
current,
|
||||
liveNodes([
|
||||
[1, []],
|
||||
[2, []]
|
||||
])
|
||||
)
|
||||
|
||||
expect(result).toBe('structural')
|
||||
})
|
||||
|
||||
it('returns structural when a node is removed', () => {
|
||||
const baseline = makeWorkflow([
|
||||
{ id: 1, type: 'KSampler' },
|
||||
{ id: 2, type: 'LoadImage' }
|
||||
])
|
||||
const current = makeWorkflow([{ id: 1, type: 'KSampler' }])
|
||||
|
||||
const result = classifyTemplateChange(
|
||||
baseline,
|
||||
current,
|
||||
liveNodes([[1, []]])
|
||||
)
|
||||
|
||||
expect(result).toBe('structural')
|
||||
})
|
||||
|
||||
it('returns structural when a node type changes for the same id', () => {
|
||||
const baseline = makeWorkflow([{ id: 1, type: 'KSampler' }])
|
||||
const current = makeWorkflow([{ id: 1, type: 'KSamplerAdvanced' }])
|
||||
|
||||
const result = classifyTemplateChange(
|
||||
baseline,
|
||||
current,
|
||||
liveNodes([[1, []]])
|
||||
)
|
||||
|
||||
expect(result).toBe('structural')
|
||||
})
|
||||
|
||||
it('returns structural when links change', () => {
|
||||
const baseline = makeWorkflow(
|
||||
[
|
||||
{ id: 1, type: 'A' },
|
||||
{ id: 2, type: 'B' }
|
||||
],
|
||||
[[1, 1, 0, 2, 0, 'IMAGE']]
|
||||
)
|
||||
const current = makeWorkflow(
|
||||
[
|
||||
{ id: 1, type: 'A' },
|
||||
{ id: 2, type: 'B' }
|
||||
],
|
||||
[]
|
||||
)
|
||||
|
||||
const result = classifyTemplateChange(
|
||||
baseline,
|
||||
current,
|
||||
liveNodes([
|
||||
[1, []],
|
||||
[2, []]
|
||||
])
|
||||
)
|
||||
|
||||
expect(result).toBe('structural')
|
||||
})
|
||||
|
||||
it('returns structural when widget values array length differs', () => {
|
||||
const baseline = makeWorkflow([
|
||||
{ id: 1, type: 'KSampler', widgets_values: [42, 20] }
|
||||
])
|
||||
const current = makeWorkflow([
|
||||
{ id: 1, type: 'KSampler', widgets_values: [42, 20, 7.5] }
|
||||
])
|
||||
|
||||
const result = classifyTemplateChange(
|
||||
baseline,
|
||||
current,
|
||||
liveNodes([[1, [{ name: 'seed' }, { name: 'steps' }, { name: 'cfg' }]]])
|
||||
)
|
||||
|
||||
expect(result).toBe('structural')
|
||||
})
|
||||
|
||||
it('returns structural when widget name cannot be classified', () => {
|
||||
const baseline = makeWorkflow([
|
||||
{ id: 1, type: 'CustomNode', widgets_values: [1] }
|
||||
])
|
||||
const current = makeWorkflow([
|
||||
{ id: 1, type: 'CustomNode', widgets_values: [2] }
|
||||
])
|
||||
|
||||
const result = classifyTemplateChange(
|
||||
baseline,
|
||||
current,
|
||||
liveNodes([[1, [{ name: 'mystery_param' }]]])
|
||||
)
|
||||
|
||||
expect(result).toBe('structural')
|
||||
})
|
||||
|
||||
it('treats link reordering as unchanged when the link set is equal', () => {
|
||||
const baseline = makeWorkflow(
|
||||
[
|
||||
{ id: 1, type: 'A' },
|
||||
{ id: 2, type: 'B' }
|
||||
],
|
||||
[
|
||||
[1, 1, 0, 2, 0, 'IMAGE'],
|
||||
[2, 1, 1, 2, 1, 'MASK']
|
||||
]
|
||||
)
|
||||
const current = makeWorkflow(
|
||||
[
|
||||
{ id: 1, type: 'A' },
|
||||
{ id: 2, type: 'B' }
|
||||
],
|
||||
[
|
||||
[2, 1, 1, 2, 1, 'MASK'],
|
||||
[1, 1, 0, 2, 0, 'IMAGE']
|
||||
]
|
||||
)
|
||||
|
||||
const result = classifyTemplateChange(
|
||||
baseline,
|
||||
current,
|
||||
liveNodes([
|
||||
[1, []],
|
||||
[2, []]
|
||||
])
|
||||
)
|
||||
|
||||
expect(result).toBe('unchanged')
|
||||
})
|
||||
|
||||
it('handles v1 object-shaped links without throwing', () => {
|
||||
const baseline = {
|
||||
nodes: [
|
||||
{ id: 1, type: 'A' },
|
||||
{ id: 2, type: 'B' }
|
||||
],
|
||||
links: [
|
||||
{
|
||||
id: 1,
|
||||
origin_id: 1,
|
||||
origin_slot: 0,
|
||||
target_id: 2,
|
||||
target_slot: 0,
|
||||
type: 'IMAGE'
|
||||
}
|
||||
],
|
||||
groups: [],
|
||||
version: 1
|
||||
} as unknown as ComfyWorkflowJSON
|
||||
|
||||
const current = {
|
||||
nodes: [
|
||||
{ id: 1, type: 'A' },
|
||||
{ id: 2, type: 'B' }
|
||||
],
|
||||
links: [
|
||||
{
|
||||
id: 1,
|
||||
origin_id: 1,
|
||||
origin_slot: 0,
|
||||
target_id: 2,
|
||||
target_slot: 0,
|
||||
type: 'IMAGE'
|
||||
}
|
||||
],
|
||||
groups: [],
|
||||
version: 1
|
||||
} as unknown as ComfyWorkflowJSON
|
||||
|
||||
const result = classifyTemplateChange(
|
||||
baseline,
|
||||
current,
|
||||
liveNodes([
|
||||
[1, []],
|
||||
[2, []]
|
||||
])
|
||||
)
|
||||
|
||||
expect(result).toBe('unchanged')
|
||||
})
|
||||
|
||||
it('detects link removal across the v1 object shape as structural', () => {
|
||||
const baseline = {
|
||||
nodes: [
|
||||
{ id: 1, type: 'A' },
|
||||
{ id: 2, type: 'B' }
|
||||
],
|
||||
links: [
|
||||
{
|
||||
id: 1,
|
||||
origin_id: 1,
|
||||
origin_slot: 0,
|
||||
target_id: 2,
|
||||
target_slot: 0,
|
||||
type: 'IMAGE'
|
||||
}
|
||||
],
|
||||
groups: [],
|
||||
version: 1
|
||||
} as unknown as ComfyWorkflowJSON
|
||||
|
||||
const current = {
|
||||
nodes: [
|
||||
{ id: 1, type: 'A' },
|
||||
{ id: 2, type: 'B' }
|
||||
],
|
||||
links: [],
|
||||
groups: [],
|
||||
version: 1
|
||||
} as unknown as ComfyWorkflowJSON
|
||||
|
||||
const result = classifyTemplateChange(
|
||||
baseline,
|
||||
current,
|
||||
liveNodes([
|
||||
[1, []],
|
||||
[2, []]
|
||||
])
|
||||
)
|
||||
|
||||
expect(result).toBe('structural')
|
||||
})
|
||||
|
||||
it('classifies edits inside subgraph definitions as structural', () => {
|
||||
const baseline = {
|
||||
nodes: [{ id: 1, type: 'Subgraph' }],
|
||||
links: [],
|
||||
groups: [],
|
||||
version: 1,
|
||||
definitions: {
|
||||
subgraphs: [
|
||||
{
|
||||
id: 'sg-1',
|
||||
nodes: [{ id: 10, type: 'KSampler', widgets_values: [42] }]
|
||||
}
|
||||
]
|
||||
}
|
||||
} as unknown as ComfyWorkflowJSON
|
||||
|
||||
const current = {
|
||||
nodes: [{ id: 1, type: 'Subgraph' }],
|
||||
links: [],
|
||||
groups: [],
|
||||
version: 1,
|
||||
definitions: {
|
||||
subgraphs: [
|
||||
{
|
||||
id: 'sg-1',
|
||||
nodes: [
|
||||
{ id: 10, type: 'KSampler', widgets_values: [42] },
|
||||
{ id: 11, type: 'LoadImage', widgets_values: [] }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
} as unknown as ComfyWorkflowJSON
|
||||
|
||||
const result = classifyTemplateChange(
|
||||
baseline,
|
||||
current,
|
||||
liveNodes([[1, []]])
|
||||
)
|
||||
|
||||
expect(result).toBe('structural')
|
||||
})
|
||||
})
|
||||
181
src/platform/telemetry/utils/classifyTemplateChange.ts
Normal file
181
src/platform/telemetry/utils/classifyTemplateChange.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import { isEqual } from 'es-toolkit/compat'
|
||||
|
||||
import type {
|
||||
ComfyNode,
|
||||
ComfyWorkflowJSON
|
||||
} from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
|
||||
import type { TemplateChangeType } from '../types'
|
||||
|
||||
const SEED_WIDGET_NAME_PATTERN = /(^|_)seed($|_)|noise_seed/i
|
||||
const PROMPT_WIDGET_NAME_PATTERN = /(^|_)(prompt|text|positive|negative)($|_)/i
|
||||
|
||||
export type LiveWidgetInfo = {
|
||||
name?: string
|
||||
type?: string
|
||||
}
|
||||
|
||||
export type LiveNodeInfo = {
|
||||
widgets?: LiveWidgetInfo[]
|
||||
}
|
||||
|
||||
export type LiveNodeLookup = Map<string | number, LiveNodeInfo>
|
||||
|
||||
type WidgetKind = 'seed' | 'prompt' | 'other'
|
||||
|
||||
type NormalizedLink = readonly [
|
||||
src: string | number | undefined,
|
||||
srcSlot: number | undefined,
|
||||
dst: string | number | undefined,
|
||||
dstSlot: number | undefined,
|
||||
dataType: string | undefined
|
||||
]
|
||||
|
||||
type LinkObjectShape = {
|
||||
origin_id?: string | number
|
||||
origin_slot?: number
|
||||
target_id?: string | number
|
||||
target_slot?: number
|
||||
type?: string
|
||||
}
|
||||
|
||||
function widgetKind(widget: LiveWidgetInfo | undefined): WidgetKind {
|
||||
const name = widget?.name
|
||||
if (!name) return 'other'
|
||||
if (SEED_WIDGET_NAME_PATTERN.test(name)) return 'seed'
|
||||
if (PROMPT_WIDGET_NAME_PATTERN.test(name)) return 'prompt'
|
||||
return 'other'
|
||||
}
|
||||
|
||||
function nodesById(
|
||||
workflow: ComfyWorkflowJSON
|
||||
): Map<string | number, ComfyNode> {
|
||||
const map = new Map<string | number, ComfyNode>()
|
||||
for (const node of workflow.nodes ?? []) {
|
||||
if (node?.id !== undefined) map.set(node.id, node as ComfyNode)
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
function normalizeLink(link: unknown): NormalizedLink | undefined {
|
||||
if (Array.isArray(link)) {
|
||||
const [, src, srcSlot, dst, dstSlot, dataType] = link as [
|
||||
unknown,
|
||||
string | number | undefined,
|
||||
number | undefined,
|
||||
string | number | undefined,
|
||||
number | undefined,
|
||||
string | undefined
|
||||
]
|
||||
return [src, srcSlot, dst, dstSlot, dataType]
|
||||
}
|
||||
if (link && typeof link === 'object') {
|
||||
const obj = link as LinkObjectShape
|
||||
return [
|
||||
obj.origin_id,
|
||||
obj.origin_slot,
|
||||
obj.target_id,
|
||||
obj.target_slot,
|
||||
obj.type
|
||||
]
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function normalizeLinks(
|
||||
links: ComfyWorkflowJSON['links'] | undefined
|
||||
): string[] {
|
||||
const result: string[] = []
|
||||
for (const link of links ?? []) {
|
||||
const normalized = normalizeLink(link)
|
||||
if (normalized) result.push(JSON.stringify(normalized))
|
||||
}
|
||||
return result.sort()
|
||||
}
|
||||
|
||||
function linksDiffer(
|
||||
baseline: ComfyWorkflowJSON,
|
||||
current: ComfyWorkflowJSON
|
||||
): boolean {
|
||||
return !isEqual(normalizeLinks(baseline.links), normalizeLinks(current.links))
|
||||
}
|
||||
|
||||
function subgraphDefinitionsDiffer(
|
||||
baseline: ComfyWorkflowJSON,
|
||||
current: ComfyWorkflowJSON
|
||||
): boolean {
|
||||
const a = (baseline as { definitions?: unknown }).definitions
|
||||
const b = (current as { definitions?: unknown }).definitions
|
||||
if (a === undefined && b === undefined) return false
|
||||
return !isEqual(a, b)
|
||||
}
|
||||
|
||||
function structurallyDifferent(
|
||||
baselineNode: ComfyNode,
|
||||
currentNode: ComfyNode
|
||||
): boolean {
|
||||
if (baselineNode.type !== currentNode.type) return true
|
||||
if (!isEqual(baselineNode.inputs ?? [], currentNode.inputs ?? [])) return true
|
||||
if (!isEqual(baselineNode.outputs ?? [], currentNode.outputs ?? []))
|
||||
return true
|
||||
return false
|
||||
}
|
||||
|
||||
function combine(
|
||||
hasSeedChange: boolean,
|
||||
hasPromptChange: boolean
|
||||
): TemplateChangeType {
|
||||
if (hasSeedChange && hasPromptChange) return 'seed_and_prompt'
|
||||
if (hasSeedChange) return 'seed_only'
|
||||
if (hasPromptChange) return 'prompt_only'
|
||||
return 'unchanged'
|
||||
}
|
||||
|
||||
export function classifyTemplateChange(
|
||||
baseline: ComfyWorkflowJSON,
|
||||
current: ComfyWorkflowJSON,
|
||||
liveNodes: LiveNodeLookup
|
||||
): TemplateChangeType {
|
||||
if (subgraphDefinitionsDiffer(baseline, current)) return 'structural'
|
||||
|
||||
const baselineNodes = nodesById(baseline)
|
||||
const currentNodes = nodesById(current)
|
||||
|
||||
if (baselineNodes.size !== currentNodes.size) return 'structural'
|
||||
for (const id of baselineNodes.keys()) {
|
||||
if (!currentNodes.has(id)) return 'structural'
|
||||
}
|
||||
|
||||
if (linksDiffer(baseline, current)) return 'structural'
|
||||
|
||||
let hasSeedChange = false
|
||||
let hasPromptChange = false
|
||||
|
||||
for (const [id, baselineNode] of baselineNodes) {
|
||||
const currentNode = currentNodes.get(id)!
|
||||
|
||||
if (structurallyDifferent(baselineNode, currentNode)) return 'structural'
|
||||
|
||||
const baselineValues = baselineNode.widgets_values ?? []
|
||||
const currentValues = currentNode.widgets_values ?? []
|
||||
if (!Array.isArray(baselineValues) || !Array.isArray(currentValues)) {
|
||||
if (!isEqual(baselineValues, currentValues)) return 'structural'
|
||||
continue
|
||||
}
|
||||
|
||||
if (baselineValues.length !== currentValues.length) return 'structural'
|
||||
|
||||
const widgets = liveNodes.get(id)?.widgets ?? []
|
||||
|
||||
for (let i = 0; i < baselineValues.length; i++) {
|
||||
if (isEqual(baselineValues[i], currentValues[i])) continue
|
||||
|
||||
const kind = widgetKind(widgets[i])
|
||||
if (kind === 'seed') hasSeedChange = true
|
||||
else if (kind === 'prompt') hasPromptChange = true
|
||||
else return 'structural'
|
||||
}
|
||||
}
|
||||
|
||||
return combine(hasSeedChange, hasPromptChange)
|
||||
}
|
||||
@@ -4,10 +4,16 @@ import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
mockNodeDefsByName: {} as Record<string, unknown>,
|
||||
mockNodes: [] as Pick<LGraphNode, 'type' | 'isSubgraphNode'>[],
|
||||
mockNodes: [] as Array<
|
||||
Pick<LGraphNode, 'type' | 'isSubgraphNode'> & {
|
||||
id?: number | string
|
||||
widgets?: Array<{ name?: string; type?: string }>
|
||||
}
|
||||
>,
|
||||
mockActiveWorkflow: null as null | {
|
||||
filename: string
|
||||
fullFilename: string
|
||||
changeTracker?: { activeState?: unknown }
|
||||
},
|
||||
mockKnownTemplateNames: new Set<string>(),
|
||||
mockTemplateByName: null as null | { sourceModule?: string }
|
||||
@@ -65,6 +71,12 @@ vi.mock('@/scripts/app', () => ({
|
||||
}))
|
||||
|
||||
import { getExecutionContext } from './getExecutionContext'
|
||||
import {
|
||||
clearTemplateBaselines,
|
||||
setTemplateBaseline
|
||||
} from './templateBaselineStore'
|
||||
|
||||
type WorkflowJSON = Parameters<typeof setTemplateBaseline>[1]
|
||||
|
||||
describe('getExecutionContext', () => {
|
||||
beforeEach(() => {
|
||||
@@ -76,6 +88,7 @@ describe('getExecutionContext', () => {
|
||||
hoisted.mockActiveWorkflow = null
|
||||
hoisted.mockKnownTemplateNames = new Set()
|
||||
hoisted.mockTemplateByName = null
|
||||
clearTemplateBaselines()
|
||||
})
|
||||
|
||||
it('returns has_toolkit_nodes false when no toolkit nodes are present', () => {
|
||||
@@ -212,4 +225,112 @@ describe('getExecutionContext', () => {
|
||||
expect(context.is_template).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('template_change_type', () => {
|
||||
function setUpTemplateWorkflow(
|
||||
name: string,
|
||||
activeState: WorkflowJSON,
|
||||
liveNodes: Array<{
|
||||
id: number
|
||||
type: string
|
||||
widgets?: Array<{ name?: string; type?: string }>
|
||||
}>
|
||||
) {
|
||||
hoisted.mockKnownTemplateNames = new Set([name])
|
||||
hoisted.mockTemplateByName = { sourceModule: 'default' }
|
||||
hoisted.mockActiveWorkflow = {
|
||||
filename: name,
|
||||
fullFilename: `${name}.json`,
|
||||
changeTracker: { activeState }
|
||||
}
|
||||
for (const node of liveNodes) {
|
||||
hoisted.mockNodes.push({
|
||||
...node,
|
||||
isSubgraphNode: (() => false) as LGraphNode['isSubgraphNode']
|
||||
} as never)
|
||||
}
|
||||
}
|
||||
|
||||
it('is omitted when no baseline was captured for the template', () => {
|
||||
setUpTemplateWorkflow(
|
||||
'flux-dev',
|
||||
{
|
||||
nodes: [{ id: 1, type: 'KSampler', widgets_values: [42] }],
|
||||
links: []
|
||||
} as unknown as WorkflowJSON,
|
||||
[{ id: 1, type: 'KSampler', widgets: [{ name: 'seed' }] }]
|
||||
)
|
||||
|
||||
const context = getExecutionContext()
|
||||
|
||||
expect(context.is_template).toBe(true)
|
||||
expect(context.template_change_type).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reports unchanged when baseline matches current state', () => {
|
||||
const baseline = {
|
||||
nodes: [{ id: 1, type: 'KSampler', widgets_values: [42] }],
|
||||
links: []
|
||||
} as unknown as WorkflowJSON
|
||||
setTemplateBaseline('flux-dev', baseline)
|
||||
setUpTemplateWorkflow('flux-dev', baseline, [
|
||||
{ id: 1, type: 'KSampler', widgets: [{ name: 'seed' }] }
|
||||
])
|
||||
|
||||
const context = getExecutionContext()
|
||||
|
||||
expect(context.template_change_type).toBe('unchanged')
|
||||
})
|
||||
|
||||
it('reports seed_only when only the seed widget value differs', () => {
|
||||
const baseline = {
|
||||
nodes: [{ id: 1, type: 'KSampler', widgets_values: [42, 20] }],
|
||||
links: []
|
||||
} as unknown as WorkflowJSON
|
||||
setTemplateBaseline('flux-dev', baseline)
|
||||
setUpTemplateWorkflow(
|
||||
'flux-dev',
|
||||
{
|
||||
nodes: [{ id: 1, type: 'KSampler', widgets_values: [99, 20] }],
|
||||
links: []
|
||||
} as unknown as WorkflowJSON,
|
||||
[
|
||||
{
|
||||
id: 1,
|
||||
type: 'KSampler',
|
||||
widgets: [{ name: 'seed' }, { name: 'steps' }]
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
const context = getExecutionContext()
|
||||
|
||||
expect(context.template_change_type).toBe('seed_only')
|
||||
})
|
||||
|
||||
it('reports structural when nodes were added', () => {
|
||||
setTemplateBaseline('flux-dev', {
|
||||
nodes: [{ id: 1, type: 'KSampler', widgets_values: [42] }],
|
||||
links: []
|
||||
} as unknown as WorkflowJSON)
|
||||
setUpTemplateWorkflow(
|
||||
'flux-dev',
|
||||
{
|
||||
nodes: [
|
||||
{ id: 1, type: 'KSampler', widgets_values: [42] },
|
||||
{ id: 2, type: 'LoadImage', widgets_values: [] }
|
||||
],
|
||||
links: []
|
||||
} as unknown as WorkflowJSON,
|
||||
[
|
||||
{ id: 1, type: 'KSampler', widgets: [{ name: 'seed' }] },
|
||||
{ id: 2, type: 'LoadImage', widgets: [] }
|
||||
]
|
||||
)
|
||||
|
||||
const context = getExecutionContext()
|
||||
|
||||
expect(context.template_change_type).toBe('structural')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,7 +9,10 @@ import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { NodeSourceType } from '@/types/nodeSource'
|
||||
import { reduceAllNodes } from '@/utils/graphTraversalUtil'
|
||||
|
||||
import type { ExecutionContext } from '../types'
|
||||
import type { ExecutionContext, TemplateChangeType } from '../types'
|
||||
import { classifyTemplateChange } from './classifyTemplateChange'
|
||||
import type { LiveNodeLookup } from './classifyTemplateChange'
|
||||
import { getTemplateBaseline } from './templateBaselineStore'
|
||||
|
||||
type NodeMetrics = {
|
||||
custom_node_count: number
|
||||
@@ -23,6 +26,41 @@ type NodeMetrics = {
|
||||
toolkit_node_names: string[]
|
||||
}
|
||||
|
||||
function buildLiveNodeLookup(): LiveNodeLookup {
|
||||
return reduceAllNodes<LiveNodeLookup>(
|
||||
app.rootGraph,
|
||||
(acc, node) => {
|
||||
if (node?.id !== undefined) {
|
||||
acc.set(node.id, {
|
||||
widgets: node.widgets?.map((w) => ({
|
||||
name: w?.name,
|
||||
type: w?.type
|
||||
}))
|
||||
})
|
||||
}
|
||||
return acc
|
||||
},
|
||||
new Map()
|
||||
)
|
||||
}
|
||||
|
||||
function getTemplateChangeType(
|
||||
templateName: string
|
||||
): TemplateChangeType | undefined {
|
||||
const baseline = getTemplateBaseline(templateName)
|
||||
if (!baseline) return undefined
|
||||
|
||||
const currentState =
|
||||
useWorkflowStore().activeWorkflow?.changeTracker?.activeState
|
||||
if (!currentState) return undefined
|
||||
|
||||
try {
|
||||
return classifyTemplateChange(baseline, currentState, buildLiveNodeLookup())
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export function getExecutionContext(): ExecutionContext {
|
||||
const workflowStore = useWorkflowStore()
|
||||
const templatesStore = useWorkflowTemplatesStore()
|
||||
@@ -101,6 +139,7 @@ export function getExecutionContext(): ExecutionContext {
|
||||
template_models: englishMetadata?.models ?? template?.models,
|
||||
template_use_case: englishMetadata?.useCase ?? template?.useCase,
|
||||
template_license: englishMetadata?.license ?? template?.license,
|
||||
template_change_type: getTemplateChangeType(templateName),
|
||||
...nodeCounts
|
||||
}
|
||||
}
|
||||
|
||||
109
src/platform/telemetry/utils/templateBaselineStore.test.ts
Normal file
109
src/platform/telemetry/utils/templateBaselineStore.test.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
|
||||
import {
|
||||
MAX_BASELINES,
|
||||
clearTemplateBaselines,
|
||||
getTemplateBaseline,
|
||||
setTemplateBaseline
|
||||
} from './templateBaselineStore'
|
||||
|
||||
function makeWorkflow(seed: number): ComfyWorkflowJSON {
|
||||
return {
|
||||
nodes: [{ id: 1, type: 'KSampler', widgets_values: [seed] }],
|
||||
links: [],
|
||||
groups: [],
|
||||
config: {},
|
||||
extra: {},
|
||||
version: 0.4
|
||||
} as unknown as ComfyWorkflowJSON
|
||||
}
|
||||
|
||||
describe('templateBaselineStore', () => {
|
||||
beforeEach(() => {
|
||||
clearTemplateBaselines()
|
||||
})
|
||||
|
||||
it('stores and retrieves a baseline by workflow name', () => {
|
||||
const workflow = makeWorkflow(42)
|
||||
setTemplateBaseline('flux-dev', workflow)
|
||||
|
||||
const retrieved = getTemplateBaseline('flux-dev')
|
||||
|
||||
expect(retrieved).toEqual(workflow)
|
||||
})
|
||||
|
||||
it('returns undefined for unknown workflow names', () => {
|
||||
expect(getTemplateBaseline('does-not-exist')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('deep-clones stored baselines so mutations to the source do not leak', () => {
|
||||
const workflow = makeWorkflow(42)
|
||||
setTemplateBaseline('flux-dev', workflow)
|
||||
|
||||
workflow.nodes[0].widgets_values = [999]
|
||||
|
||||
const retrieved = getTemplateBaseline('flux-dev')
|
||||
|
||||
expect(retrieved?.nodes[0].widgets_values).toEqual([42])
|
||||
})
|
||||
|
||||
it('replaces an existing baseline when set again with the same name', () => {
|
||||
setTemplateBaseline('flux-dev', makeWorkflow(1))
|
||||
setTemplateBaseline('flux-dev', makeWorkflow(2))
|
||||
|
||||
expect(getTemplateBaseline('flux-dev')?.nodes[0].widgets_values).toEqual([
|
||||
2
|
||||
])
|
||||
})
|
||||
|
||||
it('ignores empty workflow names', () => {
|
||||
setTemplateBaseline('', makeWorkflow(1))
|
||||
expect(getTemplateBaseline('')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns a defensive clone so callers cannot mutate the stored baseline', () => {
|
||||
setTemplateBaseline('flux-dev', makeWorkflow(42))
|
||||
|
||||
const first = getTemplateBaseline('flux-dev')
|
||||
expect(first).toBeDefined()
|
||||
first!.nodes[0].widgets_values = [999]
|
||||
|
||||
const second = getTemplateBaseline('flux-dev')
|
||||
expect(second?.nodes[0].widgets_values).toEqual([42])
|
||||
})
|
||||
|
||||
it('evicts the oldest baseline when MAX_BASELINES is exceeded', () => {
|
||||
for (let i = 0; i < MAX_BASELINES; i++) {
|
||||
setTemplateBaseline(`template-${i}`, makeWorkflow(i))
|
||||
}
|
||||
|
||||
setTemplateBaseline('overflow', makeWorkflow(999))
|
||||
|
||||
expect(getTemplateBaseline('template-0')).toBeUndefined()
|
||||
expect(getTemplateBaseline('template-1')?.nodes[0].widgets_values).toEqual([
|
||||
1
|
||||
])
|
||||
expect(getTemplateBaseline('overflow')?.nodes[0].widgets_values).toEqual([
|
||||
999
|
||||
])
|
||||
})
|
||||
|
||||
it('refreshes recency when re-setting an existing key', () => {
|
||||
for (let i = 0; i < MAX_BASELINES; i++) {
|
||||
setTemplateBaseline(`template-${i}`, makeWorkflow(i))
|
||||
}
|
||||
|
||||
setTemplateBaseline('template-0', makeWorkflow(1000))
|
||||
setTemplateBaseline('overflow', makeWorkflow(999))
|
||||
|
||||
expect(getTemplateBaseline('template-0')?.nodes[0].widgets_values).toEqual([
|
||||
1000
|
||||
])
|
||||
expect(getTemplateBaseline('template-1')).toBeUndefined()
|
||||
expect(getTemplateBaseline('overflow')?.nodes[0].widgets_values).toEqual([
|
||||
999
|
||||
])
|
||||
})
|
||||
})
|
||||
36
src/platform/telemetry/utils/templateBaselineStore.ts
Normal file
36
src/platform/telemetry/utils/templateBaselineStore.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
|
||||
const baselineByWorkflowName = new Map<string, ComfyWorkflowJSON>()
|
||||
|
||||
export const MAX_BASELINES = 32
|
||||
|
||||
function clone(workflow: ComfyWorkflowJSON): ComfyWorkflowJSON {
|
||||
return JSON.parse(JSON.stringify(workflow)) as ComfyWorkflowJSON
|
||||
}
|
||||
|
||||
export function setTemplateBaseline(
|
||||
workflowName: string,
|
||||
workflow: ComfyWorkflowJSON
|
||||
): void {
|
||||
if (!workflowName) return
|
||||
|
||||
if (baselineByWorkflowName.has(workflowName)) {
|
||||
baselineByWorkflowName.delete(workflowName)
|
||||
} else if (baselineByWorkflowName.size >= MAX_BASELINES) {
|
||||
const oldestKey = baselineByWorkflowName.keys().next().value
|
||||
if (oldestKey !== undefined) baselineByWorkflowName.delete(oldestKey)
|
||||
}
|
||||
|
||||
baselineByWorkflowName.set(workflowName, clone(workflow))
|
||||
}
|
||||
|
||||
export function getTemplateBaseline(
|
||||
workflowName: string
|
||||
): ComfyWorkflowJSON | undefined {
|
||||
const baseline = baselineByWorkflowName.get(workflowName)
|
||||
return baseline ? clone(baseline) : undefined
|
||||
}
|
||||
|
||||
export function clearTemplateBaselines(): void {
|
||||
baselineByWorkflowName.clear()
|
||||
}
|
||||
@@ -1,8 +1,18 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { setTemplateBaseline } from '@/platform/telemetry/utils/templateBaselineStore'
|
||||
import { useTemplateWorkflows } from '@/platform/workflow/templates/composables/useTemplateWorkflows'
|
||||
import { useWorkflowTemplatesStore } from '@/platform/workflow/templates/repositories/workflowTemplatesStore'
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
mockActiveWorkflow: {
|
||||
changeTracker: { activeState: { nodes: [], links: [] } as unknown }
|
||||
} as
|
||||
| { changeTracker?: { activeState?: unknown } | undefined }
|
||||
| null
|
||||
| undefined
|
||||
}))
|
||||
|
||||
async function flushPromises() {
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
}
|
||||
@@ -49,6 +59,18 @@ vi.mock('@/stores/dialogStore', () => ({
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
get activeWorkflow() {
|
||||
return hoisted.mockActiveWorkflow
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry/utils/templateBaselineStore', () => ({
|
||||
setTemplateBaseline: vi.fn()
|
||||
}))
|
||||
|
||||
// Mock fetch
|
||||
global.fetch = vi.fn()
|
||||
|
||||
@@ -115,6 +137,11 @@ describe('useTemplateWorkflows', () => {
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
json: vi.fn().mockResolvedValue({ workflow: 'data' })
|
||||
} as Partial<Response> as Response)
|
||||
|
||||
hoisted.mockActiveWorkflow = {
|
||||
changeTracker: { activeState: { nodes: [], links: [] } as unknown }
|
||||
}
|
||||
vi.mocked(setTemplateBaseline).mockClear()
|
||||
})
|
||||
|
||||
it('should load templates from store', async () => {
|
||||
@@ -285,6 +312,40 @@ describe('useTemplateWorkflows', () => {
|
||||
expect(fetch).toHaveBeenCalledWith('mock-file-url/templates/template1.json')
|
||||
})
|
||||
|
||||
it('captures the normalized active state as the template baseline', async () => {
|
||||
const { loadWorkflowTemplate } = useTemplateWorkflows()
|
||||
mockWorkflowTemplatesStore.isLoaded = true
|
||||
|
||||
const normalizedState = {
|
||||
nodes: [{ id: 1, type: 'KSampler', widgets_values: [42] }],
|
||||
links: []
|
||||
}
|
||||
hoisted.mockActiveWorkflow = {
|
||||
changeTracker: { activeState: normalizedState }
|
||||
}
|
||||
|
||||
await loadWorkflowTemplate('template1', 'default')
|
||||
await flushPromises()
|
||||
|
||||
expect(setTemplateBaseline).toHaveBeenCalledWith(
|
||||
'template1',
|
||||
normalizedState
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to fetched JSON when no active state is available', async () => {
|
||||
const { loadWorkflowTemplate } = useTemplateWorkflows()
|
||||
mockWorkflowTemplatesStore.isLoaded = true
|
||||
hoisted.mockActiveWorkflow = undefined
|
||||
|
||||
await loadWorkflowTemplate('template1', 'default')
|
||||
await flushPromises()
|
||||
|
||||
expect(setTemplateBaseline).toHaveBeenCalledWith('template1', {
|
||||
workflow: 'data'
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle errors when loading templates', async () => {
|
||||
const { loadWorkflowTemplate, loadingTemplateId } = useTemplateWorkflows()
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { setTemplateBaseline } from '@/platform/telemetry/utils/templateBaselineStore'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useWorkflowTemplatesStore } from '@/platform/workflow/templates/repositories/workflowTemplatesStore'
|
||||
import type {
|
||||
TemplateGroup,
|
||||
@@ -144,6 +146,10 @@ export function useTemplateWorkflows() {
|
||||
openSource: 'template'
|
||||
})
|
||||
|
||||
const loadedBaseline =
|
||||
useWorkflowStore().activeWorkflow?.changeTracker?.activeState ?? json
|
||||
setTemplateBaseline(id, loadedBaseline)
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Error loading workflow template:', error)
|
||||
|
||||
Reference in New Issue
Block a user