Road to No Explicit Any Part 6: Composables and Extensions (#8083)

## Summary
- Type `onExecuted` callbacks with `NodeExecutionOutput` in saveMesh.ts
and uploadAudio.ts
- Type composable parameters and return values properly
(useLoad3dViewer, useImageMenuOptions, useJobMenu, useResultGallery,
useContextMenuTranslation)
- Type `taskRef` as `TaskItemImpl` with updated test mocks
- Fix error catch and index signature patterns without `any`
- Add `NodeOutputWith<T>` generic helper for typed access to passthrough
properties on `NodeExecutionOutput`

## Test plan
- [x] `pnpm typecheck` passes
- [x] `pnpm lint` passes
- [x] Unit tests pass for affected files
- [x] Sourcegraph checks confirm no external usage of modified types

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8083-Road-to-No-Explicit-Any-Part-6-Composables-and-Extensions-2e96d73d3650810fb033d745bf88a22b)
by [Unito](https://www.unito.io)
This commit is contained in:
Johnpaul Chiwetelu
2026-01-16 00:27:28 +01:00
committed by GitHub
parent 0d5ca96a2b
commit c56e8425d4
16 changed files with 159 additions and 87 deletions

View File

@@ -4,6 +4,7 @@ import { defineComponent, nextTick } from 'vue'
import JobGroupsList from '@/components/queue/job/JobGroupsList.vue'
import type { JobGroup, JobListItem } from '@/composables/queue/useJobList'
import type { TaskItemImpl } from '@/stores/queueStore'
const QueueJobItemStub = defineComponent({
name: 'QueueJobItemStub',
@@ -25,20 +26,25 @@ const QueueJobItemStub = defineComponent({
template: '<div class="queue-job-item-stub"></div>'
})
const createJobItem = (overrides: Partial<JobListItem> = {}): JobListItem => ({
id: 'job-id',
title: 'Example job',
meta: 'Meta text',
state: 'running',
iconName: 'icon',
iconImageUrl: 'https://example.com/icon.png',
showClear: true,
taskRef: { workflow: { id: 'workflow-id' } },
progressTotalPercent: 60,
progressCurrentPercent: 30,
runningNodeName: 'Node A',
...overrides
})
const createJobItem = (overrides: Partial<JobListItem> = {}): JobListItem => {
const { taskRef, ...rest } = overrides
return {
id: 'job-id',
title: 'Example job',
meta: 'Meta text',
state: 'running',
iconName: 'icon',
iconImageUrl: 'https://example.com/icon.png',
showClear: true,
taskRef: (taskRef ?? {
workflow: { id: 'workflow-id' }
}) as TaskItemImpl,
progressTotalPercent: 60,
progressCurrentPercent: 30,
runningNodeName: 'Node A',
...rest
}
}
const mountComponent = (groups: JobGroup[]) =>
mount(JobGroupsList, {

View File

@@ -1,6 +1,7 @@
import { useI18n } from 'vue-i18n'
import { downloadFile } from '@/base/common/downloadUtil'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { useCommandStore } from '@/stores/commandStore'
import type { MenuOption } from './useMoreOptionsMenu'
@@ -16,7 +17,7 @@ export function useImageMenuOptions() {
void commandStore.execute('Comfy.MaskEditor.OpenMaskEditor')
}
const openImage = (node: any) => {
const openImage = (node: LGraphNode) => {
if (!node?.imgs?.length) return
const img = node.imgs[node.imageIndex ?? 0]
if (!img) return
@@ -25,7 +26,7 @@ export function useImageMenuOptions() {
window.open(url.toString(), '_blank')
}
const copyImage = async (node: any) => {
const copyImage = async (node: LGraphNode) => {
if (!node?.imgs?.length) return
const img = node.imgs[node.imageIndex ?? 0]
if (!img) return
@@ -62,7 +63,7 @@ export function useImageMenuOptions() {
}
}
const saveImage = (node: any) => {
const saveImage = (node: LGraphNode) => {
if (!node?.imgs?.length) return
const img = node.imgs[node.imageIndex ?? 0]
if (!img) return
@@ -76,7 +77,7 @@ export function useImageMenuOptions() {
}
}
const getImageMenuOptions = (node: any): MenuOption[] => {
const getImageMenuOptions = (node: LGraphNode): MenuOption[] => {
if (!node?.imgs?.length) return []
return [

View File

@@ -404,8 +404,9 @@ export function useBrushDrawing(initialSettings?: {
device = root.device
console.warn('✅ TypeGPU initialized! Root:', root)
console.warn('Device info:', root.device.limits)
} catch (error: any) {
console.warn('Failed to initialize TypeGPU:', error.message)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
console.warn('Failed to initialize TypeGPU:', message)
}
}

View File

@@ -57,8 +57,8 @@ export const useCompletionSummary = () => {
}
if (prev && !active) {
const start = lastActiveStartTs.value ?? 0
const finished = queueStore.historyTasks.filter((t: any) => {
const ts: number | undefined = t.executionEndTimestamp
const finished = queueStore.historyTasks.filter((t) => {
const ts = t.executionEndTimestamp
return typeof ts === 'number' && ts >= start
})

View File

@@ -38,7 +38,7 @@ export type JobListItem = {
iconName?: string
iconImageUrl?: string
showClear?: boolean
taskRef?: any
taskRef?: TaskItemImpl
progressTotalPercent?: number
progressCurrentPercent?: number
runningNodeName?: string

View File

@@ -117,13 +117,24 @@ vi.mock('@/utils/formatUtil', () => ({
}))
import { useJobMenu } from '@/composables/queue/useJobMenu'
import type { TaskItemImpl } from '@/stores/queueStore'
const createJobItem = (overrides: Partial<JobListItem> = {}): JobListItem => ({
type MockTaskRef = Record<string, unknown>
type TestJobListItem = Omit<JobListItem, 'taskRef'> & {
taskRef?: MockTaskRef
}
const createJobItem = (
overrides: Partial<TestJobListItem> = {}
): JobListItem => ({
id: overrides.id ?? 'job-1',
title: overrides.title ?? 'Test job',
meta: overrides.meta ?? 'meta',
state: overrides.state ?? 'completed',
taskRef: overrides.taskRef,
taskRef: overrides.taskRef as Partial<TaskItemImpl> | undefined as
| TaskItemImpl
| undefined,
iconName: overrides.iconName,
iconImageUrl: overrides.iconImageUrl,
showClear: overrides.showClear,

View File

@@ -12,7 +12,8 @@ import { useWorkflowStore } from '@/platform/workflow/management/stores/workflow
import type {
ExecutionErrorWsMessage,
ResultItem,
ResultItemType
ResultItemType,
TaskStatus
} from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import { downloadBlob } from '@/scripts/utils'
@@ -82,13 +83,20 @@ export function useJobMenu(
await queueStore.update()
}
const findExecutionError = (
messages: TaskStatus['messages'] | undefined
): ExecutionErrorWsMessage | undefined => {
const errMessage = messages?.find((m) => m[0] === 'execution_error')
if (errMessage && errMessage[0] === 'execution_error') {
return errMessage[1]
}
return undefined
}
const copyErrorMessage = async (item?: JobListItem | null) => {
const target = resolveItem(item)
if (!target) return
const msgs = target.taskRef?.status?.messages as any[] | undefined
const err = msgs?.find((m: any) => m?.[0] === 'execution_error')?.[1] as
| ExecutionErrorWsMessage
| undefined
const err = findExecutionError(target.taskRef?.status?.messages)
const message = err?.exception_message
if (message) await copyToClipboard(String(message))
}
@@ -96,10 +104,7 @@ export function useJobMenu(
const reportError = (item?: JobListItem | null) => {
const target = resolveItem(item)
if (!target) return
const msgs = target.taskRef?.status?.messages as any[] | undefined
const err = msgs?.find((m: any) => m?.[0] === 'execution_error')?.[1] as
| ExecutionErrorWsMessage
| undefined
const err = findExecutionError(target.taskRef?.status?.messages)
if (err) useDialogService().showExecutionErrorDialog(err)
}

View File

@@ -1,17 +1,29 @@
import { ref, shallowRef } from 'vue'
import type { JobListItem } from '@/composables/queue/useJobList'
import type { ResultItemImpl } from '@/stores/queueStore'
/** Minimal preview item interface for gallery filtering. */
interface PreviewItem {
url: string
supportsPreview: boolean
}
/** Minimal task interface for gallery preview. */
interface TaskWithPreview<T extends PreviewItem = PreviewItem> {
previewOutput?: T
}
/**
* Manages result gallery state and activation for queue items.
*/
export function useResultGallery(getFilteredTasks: () => any[]) {
export function useResultGallery<T extends PreviewItem>(
getFilteredTasks: () => TaskWithPreview<T>[]
) {
const galleryActiveIndex = ref(-1)
const galleryItems = shallowRef<ResultItemImpl[]>([])
const galleryItems = shallowRef<T[]>([])
const onViewItem = (item: JobListItem) => {
const items: ResultItemImpl[] = getFilteredTasks().flatMap((t: any) => {
const items: T[] = getFilteredTasks().flatMap((t) => {
const preview = t.previewOutput
return preview && preview.supportsPreview ? [preview] : []
})

View File

@@ -36,7 +36,7 @@ interface CivitaiModelVersionResponse {
model: CivitaiModel
modelId: number
files: CivitaiModelFile[]
[key: string]: any
[key: string]: unknown
}
/**

View File

@@ -123,7 +123,10 @@ export const useContextMenuTranslation = () => {
}
// for capture translation text of input and widget
const extraInfo: any = options.extra || options.parentMenu?.options?.extra
const extraInfo = (options.extra ||
options.parentMenu?.options?.extra) as
| { inputs?: INodeInputSlot[]; widgets?: IWidget[] }
| undefined
// widgets and inputs
const matchInput = value.content?.match(reInput)
if (matchInput) {

View File

@@ -5,9 +5,13 @@ import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import type {
AnimationItem,
BackgroundRenderModeType,
CameraConfig,
CameraState,
CameraType,
LightConfig,
MaterialMode,
ModelConfig,
SceneConfig,
UpDirection
} from '@/extensions/core/load3d/interfaces'
import { t } from '@/i18n'
@@ -271,10 +275,18 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
const sourceCameraState = source.getCameraState()
const sceneConfig = node.properties['Scene Config'] as any
const modelConfig = node.properties['Model Config'] as any
const cameraConfig = node.properties['Camera Config'] as any
const lightConfig = node.properties['Light Config'] as any
const sceneConfig = node.properties['Scene Config'] as
| SceneConfig
| undefined
const modelConfig = node.properties['Model Config'] as
| ModelConfig
| undefined
const cameraConfig = node.properties['Camera Config'] as
| CameraConfig
| undefined
const lightConfig = node.properties['Light Config'] as
| LightConfig
| undefined
isPreview.value = node.type === 'Preview3D'
@@ -438,7 +450,9 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
materialMode: initialState.value.materialMode
}
const currentCameraConfig = nodeValue.properties['Camera Config'] as any
const currentCameraConfig = nodeValue.properties['Camera Config'] as
| CameraConfig
| undefined
nodeValue.properties['Camera Config'] = {
...currentCameraConfig,
state: initialState.value.cameraState

View File

@@ -1,8 +1,13 @@
import type { NodeExecutionOutput } from '@/schemas/apiSchema'
import type { NodeOutputWith } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useExtensionService } from '@/services/extensionService'
type ImageCompareOutput = NodeOutputWith<{
a_images?: Record<string, string>[]
b_images?: Record<string, string>[]
}>
useExtensionService().registerExtension({
name: 'Comfy.ImageCompare',
@@ -14,15 +19,10 @@ useExtensionService().registerExtension({
const onExecuted = node.onExecuted
node.onExecuted = function (output: NodeExecutionOutput) {
node.onExecuted = function (output: ImageCompareOutput) {
onExecuted?.call(this, output)
const aImages = (output as Record<string, unknown>).a_images as
| Record<string, string>[]
| undefined
const bImages = (output as Record<string, unknown>).b_images as
| Record<string, string>[]
| undefined
const { a_images: aImages, b_images: bImages } = output
const rand = app.getRandParam()
const beforeUrl =

View File

@@ -15,7 +15,11 @@ import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { IContextMenuValue } from '@/lib/litegraph/src/interfaces'
import type { IStringWidget } from '@/lib/litegraph/src/types/widgets'
import { useToastStore } from '@/platform/updates/common/toastStore'
import type { NodeExecutionOutput } from '@/schemas/apiSchema'
import type { NodeOutputWith } from '@/schemas/apiSchema'
type Load3dPreviewOutput = NodeOutputWith<{
result?: [string?, CameraState?, string?]
}>
import type { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { api } from '@/scripts/api'
import { ComfyApp, app } from '@/scripts/app'
@@ -496,13 +500,11 @@ useExtensionService().registerExtension({
config.configure(settings)
}
node.onExecuted = function (output: NodeExecutionOutput) {
node.onExecuted = function (output: Load3dPreviewOutput) {
onExecuted?.call(this, output)
const result = (output as Record<string, unknown>).result as
| unknown[]
| undefined
const filePath = result?.[0] as string | undefined
const result = output.result
const filePath = result?.[0]
if (!filePath) {
const msg = t('toastMessages.unableToGetModelFilePath')
@@ -510,8 +512,8 @@ useExtensionService().registerExtension({
useToastStore().addAlert(msg)
}
const cameraState = result?.[1] as CameraState | undefined
const bgImagePath = result?.[2] as string | undefined
const cameraState = result?.[1]
const bgImagePath = result?.[2]
modelWidget.value = filePath?.replaceAll('\\', '/')

View File

@@ -6,7 +6,12 @@ import { createExportMenuItems } from '@/extensions/core/load3d/exportMenuHelper
import Load3DConfiguration from '@/extensions/core/load3d/Load3DConfiguration'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { IContextMenuValue } from '@/lib/litegraph/src/interfaces'
import { type CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { NodeOutputWith, ResultItem } from '@/schemas/apiSchema'
type SaveMeshOutput = NodeOutputWith<{
'3d'?: ResultItem[]
}>
import type { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
import { useExtensionService } from '@/services/extensionService'
import { useLoad3dService } from '@/services/load3dService'
@@ -70,22 +75,26 @@ useExtensionService().registerExtension({
const onExecuted = node.onExecuted
node.onExecuted = function (message: any) {
onExecuted?.apply(this, arguments as any)
node.onExecuted = function (output: SaveMeshOutput) {
onExecuted?.call(this, output)
const fileInfo = message['3d'][0]
const fileInfo = output['3d']?.[0]
if (!fileInfo) return
useLoad3d(node).waitForLoad3d((load3d) => {
const modelWidget = node.widgets?.find((w) => w.name === 'image')
if (load3d && modelWidget) {
const filePath = fileInfo['subfolder'] + '/' + fileInfo['filename']
const filePath =
(fileInfo.subfolder ?? '') + '/' + (fileInfo.filename ?? '')
modelWidget.value = filePath
const config = new Load3DConfiguration(load3d, node.properties)
config.configureForSaveMesh(fileInfo['type'], filePath)
const loadFolder = fileInfo.type as 'input' | 'output'
config.configureForSaveMesh(loadFolder, filePath)
}
})
}

View File

@@ -15,6 +15,7 @@ import {
getResourceURL,
splitFilePath
} from '@/renderer/extensions/vueNodes/widgets/utils/audioUtils'
import type { NodeExecutionOutput } from '@/schemas/apiSchema'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import type { DOMWidget } from '@/scripts/domWidget'
import { useAudioService } from '@/services/audioService'
@@ -112,14 +113,17 @@ app.registerExtension({
audioUIWidget.element.classList.add('empty-audio-widget')
// Populate the audio widget UI on node execution.
const onExecuted = node.onExecuted
node.onExecuted = function (message: any) {
// @ts-expect-error fixme ts strict error
onExecuted?.apply(this, arguments)
const audios = message.audio
if (!audios) return
node.onExecuted = function (output: NodeExecutionOutput) {
onExecuted?.call(this, output)
const audios = output.audio
if (!audios?.length) return
const audio = audios[0]
audioUIWidget.element.src = api.apiURL(
getResourceURL(audio.subfolder, audio.filename, audio.type)
getResourceURL(
audio.subfolder ?? '',
audio.filename ?? '',
audio.type
)
)
audioUIWidget.element.classList.remove('empty-audio-widget')
}
@@ -139,22 +143,23 @@ app.registerExtension({
}
}
},
onNodeOutputsUpdated(nodeOutputs: Record<NodeLocatorId, any>) {
onNodeOutputsUpdated(
nodeOutputs: Record<NodeLocatorId, NodeExecutionOutput>
) {
for (const [nodeLocatorId, output] of Object.entries(nodeOutputs)) {
if ('audio' in output) {
const node = getNodeByLocatorId(app.rootGraph, nodeLocatorId)
if (!node) continue
if (!output.audio?.length) continue
// @ts-expect-error fixme ts strict error
const audioUIWidget = node.widgets.find(
(w) => w.name === 'audioUI'
) as unknown as DOMWidget<HTMLAudioElement, string>
const audio = output.audio[0]
audioUIWidget.element.src = api.apiURL(
getResourceURL(audio.subfolder, audio.filename, audio.type)
)
audioUIWidget.element.classList.remove('empty-audio-widget')
}
const node = getNodeByLocatorId(app.rootGraph, nodeLocatorId)
if (!node) continue
const audioUIWidget = node.widgets?.find(
(w) => w.name === 'audioUI'
) as unknown as DOMWidget<HTMLAudioElement, string>
const audio = output.audio[0]
audioUIWidget.element.src = api.apiURL(
getResourceURL(audio.subfolder ?? '', audio.filename ?? '', audio.type)
)
audioUIWidget.element.classList.remove('empty-audio-widget')
}
}
})

View File

@@ -35,6 +35,9 @@ const zOutputs = z
export type NodeExecutionOutput = z.infer<typeof zOutputs>
export type NodeOutputWith<T extends Record<string, unknown>> =
NodeExecutionOutput & T
// WS messages
const zStatusWsMessageStatus = z.object({
exec_info: z.object({