Migrate linear to use jobs instead of assets

This commit is contained in:
Austin
2026-04-15 17:41:50 -07:00
parent 3d87a28460
commit aa8be9d527
5 changed files with 106 additions and 123 deletions

View File

@@ -6,10 +6,9 @@ import { downloadFile } from '@/base/common/downloadUtil'
import Popover from '@/components/ui/Popover.vue'
import Button from '@/components/ui/button/Button.vue'
import { useAppMode } from '@/composables/useAppMode'
import { useMediaAssetActions } from '@/platform/assets/composables/useMediaAssetActions'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { extractWorkflowFromAsset } from '@/platform/workflow/utils/workflowExtractionUtil'
import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
import ImagePreview from '@/renderer/extensions/linearMode/ImagePreview.vue'
import LatentPreview from '@/renderer/extensions/linearMode/LatentPreview.vue'
import LinearWelcome from '@/renderer/extensions/linearMode/LinearWelcome.vue'
@@ -19,11 +18,12 @@ import MediaOutputPreview from '@/renderer/extensions/linearMode/MediaOutputPrev
import OutputHistory from '@/renderer/extensions/linearMode/OutputHistory.vue'
import { useOutputHistory } from '@/renderer/extensions/linearMode/useOutputHistory'
import type { OutputSelection } from '@/renderer/extensions/linearMode/linearModeTypes'
import { extractWorkflow } from '@/platform/remote/comfyui/jobs/fetchJobs'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import type { ResultItemImpl } from '@/stores/queueStore'
const { t } = useI18n()
const mediaActions = useMediaAssetActions()
const { isBuilderMode, isArrangeMode } = useAppMode()
const { allOutputs, isWorkflowActive, cancelActiveWorkflowJobs } =
useOutputHistory()
@@ -33,28 +33,28 @@ const { runButtonClick, mobile, typeformWidgetId } = defineProps<{
typeformWidgetId?: string
}>()
const selectedItem = ref<AssetItem>()
const selectedItem = ref<JobListItem>()
const selectedOutput = ref<ResultItemImpl>()
const canShowPreview = ref(true)
const latentPreview = ref<string>()
const showSkeleton = ref(false)
function handleSelection(sel: OutputSelection) {
selectedItem.value = sel.asset
selectedItem.value = sel.job
selectedOutput.value = sel.output
canShowPreview.value = sel.canShowPreview
latentPreview.value = sel.latentPreviewUrl
showSkeleton.value = sel.showSkeleton ?? false
}
function downloadAsset(item?: AssetItem) {
function downloadJob(item?: JobListItem) {
for (const output of allOutputs(item))
downloadFile(output.url, output.filename)
}
async function loadWorkflow(item: AssetItem | undefined) {
async function loadWorkflow(item: JobListItem | undefined) {
if (!item) return
const { workflow } = await extractWorkflowFromAsset(item)
const workflow = await extractWorkflow(item)
if (!workflow) return
if (workflow.id !== app.rootGraph.id) return app.loadGraphData(workflow)
@@ -120,7 +120,7 @@ async function rerun(e: Event) {
label: t('linearMode.downloadAll', {
count: allOutputs(selectedItem).length
}),
command: () => downloadAsset(selectedItem)
command: () => downloadJob(selectedItem)
},
{ separator: true }
]
@@ -128,7 +128,7 @@ async function rerun(e: Event) {
{
icon: 'icon-[lucide--trash-2]',
label: t('linearMode.deleteAllAssets'),
command: () => mediaActions.deleteAssets(selectedItem!)
command: () => api.deleteItem('output', selectedItem!.id)
}
]"
/>

View File

@@ -28,12 +28,13 @@ import OutputPreviewItem from '@/renderer/extensions/linearMode/OutputPreviewIte
import { useOutputHistory } from '@/renderer/extensions/linearMode/useOutputHistory'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useAppModeStore } from '@/stores/appModeStore'
import { useQueueStore } from '@/stores/queueStore'
import { useHistoryStore, useQueueStore } from '@/stores/queueStore'
import { cn } from '@/utils/tailwindUtil'
const { outputs, allOutputs, selectFirstHistory, mayBeActiveWorkflowPending } =
useOutputHistory()
const { hasOutputs } = storeToRefs(useAppModeStore())
const historyStore = useHistoryStore()
const queueStore = useQueueStore()
const store = useLinearOutputStore()
const workflowStore = useWorkflowStore()
@@ -56,7 +57,7 @@ const hasActiveContent = computed(
)
const visibleHistory = computed(() =>
outputs.media.value.filter((a) => allOutputs(a).length > 0)
outputs.value.filter((a) => allOutputs(a).length > 0)
)
const selectableItems = computed(() => {
@@ -71,7 +72,7 @@ const selectableItems = computed(() => {
itemId: item.id
})
}
for (const asset of outputs.media.value) {
for (const asset of outputs.value) {
const outs = allOutputs(asset)
for (let k = 0; k < outs.length; k++) {
items.push({
@@ -137,11 +138,11 @@ function doEmit() {
}
return
}
const asset = outputs.media.value.find((a) => a.id === sel.assetId)
const output = asset ? allOutputs(asset)[sel.key] : undefined
const isFirst = outputs.media.value[0]?.id === sel.assetId
const job = outputs.value.find((a) => a.id === sel.assetId)
const output = job ? allOutputs(job)[sel.key] : undefined
const isFirst = outputs.value[0]?.id === sel.assetId
emit('updateSelection', {
asset,
job,
output,
canShowPreview: isFirst
})
@@ -170,7 +171,7 @@ watch(
// Keep history selection stable on media changes
watch(
() => outputs.media.value,
() => outputs.value,
(newAssets, oldAssets) => {
if (
newAssets.length === oldAssets.length ||
@@ -219,8 +220,8 @@ watch(
}
)
useInfiniteScroll(outputsRef, outputs.loadMore, {
canLoadMore: () => outputs.hasMore.value
useInfiniteScroll(outputsRef, historyStore.loadMoreHistory, {
canLoadMore: () => historyStore.hasMoreHistory
})
function navigateToAdjacent(direction: 1 | -1) {

View File

@@ -1,4 +1,4 @@
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
import type { ResultItemImpl } from '@/stores/queueStore'
export interface InProgressItem {
@@ -10,7 +10,7 @@ export interface InProgressItem {
}
export interface OutputSelection {
asset?: AssetItem
job?: JobListItem
output?: ResultItemImpl
canShowPreview: boolean
latentPreviewUrl?: string

View File

@@ -2,13 +2,13 @@ import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
import type { InProgressItem } from '@/renderer/extensions/linearMode/linearModeTypes'
import { useOutputHistory } from '@/renderer/extensions/linearMode/useOutputHistory'
import { useAppModeStore } from '@/stores/appModeStore'
import { ResultItemImpl } from '@/stores/queueStore'
const mediaRef = ref<AssetItem[]>([])
const mediaRef = ref<JobListItem[]>([])
const pendingResolveRef = ref(new Set<string>())
const inProgressItemsRef = ref<InProgressItem[]>([])
const activeWorkflowInProgressItemsRef = ref<InProgressItem[]>([])
@@ -115,25 +115,29 @@ vi.mock('@/renderer/extensions/linearMode/flattenNodeOutput', () => ({
}
}))
function makeAsset(
function makeJob(
id: string,
jobId: string,
opts?: { allOutputs?: ResultItemImpl[]; outputCount?: number }
): AssetItem {
): JobListItem {
return {
id,
name: `${id}.png`,
tags: [],
preview_url: `/view?filename=${id}.png`,
user_metadata: {
jobId,
nodeId: '1',
subfolder: '',
...(opts?.allOutputs ? { allOutputs: opts.allOutputs } : {}),
...(opts?.outputCount !== undefined
? { outputCount: opts.outputCount }
: {})
}
status: 'completed',
create_time: 0,
//name: `${id}.png`,
//tags: [],
output_count: opts?.outputCount,
outputs: opts?.allOutputs && unflatResults(opts.allOutputs),
priority: 0
//preview_url: `/view?filename=${id}.png`,
//user_metadata: {
// jobId,
// nodeId: '1',
// subfolder: '',
// ...(opts?.allOutputs ? { allOutputs: opts.allOutputs } : {}),
// ...(opts?.outputCount !== undefined
// ? { outputCount: opts.outputCount }
// : {})
//}
}
}
@@ -147,6 +151,18 @@ function makeResult(filename: string, nodeId: string = '1'): ResultItemImpl {
})
}
function unflatResults(results: ResultItemImpl[]) {
const ret: Record<string, Record<string, ResultItemImpl[]>> = {}
for (const result of results) {
ret[result.nodeId] ??= {}
const nodeOutputs = ret[result.nodeId]
nodeOutputs[result.mediaType] ??= []
nodeOutputs[result.mediaType].push(result)
}
return ret
}
describe(useOutputHistory, () => {
beforeEach(() => {
setActivePinia(createPinia())
@@ -172,22 +188,22 @@ describe(useOutputHistory, () => {
['job-1', 'workflows/test.json'],
['job-2', 'workflows/other.json']
])
mediaRef.value = [makeAsset('a1', 'job-1'), makeAsset('a2', 'job-2')]
mediaRef.value = [makeJob('job-1'), makeJob('job-2')]
const { outputs } = useOutputHistory()
expect(outputs.media.value).toHaveLength(1)
expect(outputs.media.value[0].id).toBe('a1')
expect(outputs.value).toHaveLength(1)
expect(outputs.value[0].id).toBe('a1')
})
it('returns empty when no workflow is active', () => {
activeWorkflowPathRef.value = ''
jobIdToPathRef.value = new Map([['job-1', 'workflows/test.json']])
mediaRef.value = [makeAsset('a1', 'job-1')]
mediaRef.value = [makeJob('job-1')]
const { outputs } = useOutputHistory()
expect(outputs.media.value).toHaveLength(0)
expect(outputs.value).toHaveLength(0)
})
it('updates when active workflow changes', async () => {
@@ -195,19 +211,19 @@ describe(useOutputHistory, () => {
['job-1', 'workflows/a.json'],
['job-2', 'workflows/b.json']
])
mediaRef.value = [makeAsset('a1', 'job-1'), makeAsset('a2', 'job-2')]
mediaRef.value = [makeJob('job-1'), makeJob('job-2')]
activeWorkflowPathRef.value = 'workflows/a.json'
const { outputs } = useOutputHistory()
expect(outputs.media.value).toHaveLength(1)
expect(outputs.media.value[0].id).toBe('a1')
expect(outputs.value).toHaveLength(1)
expect(outputs.value[0].id).toBe('a1')
activeWorkflowPathRef.value = 'workflows/b.json'
await nextTick()
expect(outputs.media.value).toHaveLength(1)
expect(outputs.media.value[0].id).toBe('a2')
expect(outputs.value).toHaveLength(1)
expect(outputs.value[0].id).toBe('a2')
})
})
@@ -222,7 +238,7 @@ describe(useOutputHistory, () => {
it('returns outputs from metadata allOutputs when count matches', () => {
useAppModeStore().selectedOutputs.push('1')
const results = [makeResult('a.png'), makeResult('b.png')]
const asset = makeAsset('a1', 'job-1', {
const asset = makeJob('job-1', {
allOutputs: results,
outputCount: 2
})
@@ -242,7 +258,7 @@ describe(useOutputHistory, () => {
makeResult('b.png', '2'),
makeResult('c.png', '3')
]
const asset = makeAsset('a1', 'job-1', {
const asset = makeJob('job-1', {
allOutputs: results,
outputCount: 3
})
@@ -259,7 +275,7 @@ describe(useOutputHistory, () => {
it('returns empty when no output nodes are selected', () => {
const results = [makeResult('a.png', '1'), makeResult('b.png', '2')]
const asset = makeAsset('a1', 'job-1', {
const asset = makeJob('job-1', {
allOutputs: results,
outputCount: 2
})
@@ -272,7 +288,7 @@ describe(useOutputHistory, () => {
it('returns consistent filtered outputs across repeated calls', () => {
const results = [makeResult('a.png', '1'), makeResult('b.png', '2')]
const asset = makeAsset('a1', 'job-1', {
const asset = makeJob('job-1', {
allOutputs: results,
outputCount: 2
})
@@ -306,7 +322,7 @@ describe(useOutputHistory, () => {
output: makeResult('b.png')
}
]
const asset = makeAsset('a1', 'job-1')
const asset = makeJob('job-1')
const { allOutputs } = useOutputHistory()
const outputs = allOutputs(asset)
@@ -329,7 +345,7 @@ describe(useOutputHistory, () => {
}
}
})
const asset = makeAsset('a1', 'job-1')
const asset = makeJob('job-1')
const { allOutputs } = useOutputHistory()
@@ -348,7 +364,7 @@ describe(useOutputHistory, () => {
it('resolves pending jobs when history outputs load', async () => {
useAppModeStore().selectedOutputs.push('1')
const results = [makeResult('a.png')]
const asset = makeAsset('a1', 'job-1', {
const asset = makeJob('job-1', {
allOutputs: results,
outputCount: 1
})
@@ -367,7 +383,7 @@ describe(useOutputHistory, () => {
it('does not select first history when a selection exists', async () => {
useAppModeStore().selectedOutputs.push('1')
const results = [makeResult('a.png')]
const asset = makeAsset('a1', 'job-1', {
const asset = makeJob('job-1', {
allOutputs: results,
outputCount: 1
})
@@ -397,7 +413,7 @@ describe(useOutputHistory, () => {
describe('selectFirstHistory', () => {
it('selects first media item', () => {
jobIdToPathRef.value = new Map([['job-1', 'workflows/test.json']])
mediaRef.value = [makeAsset('a1', 'job-1')]
mediaRef.value = [makeJob('job-1')]
const { selectFirstHistory } = useOutputHistory()
selectFirstHistory()

View File

@@ -1,37 +1,35 @@
import { useAsyncState } from '@vueuse/core'
import type { ComputedRef } from 'vue'
import { computed, ref, watchEffect } from 'vue'
import { computed, watchEffect } from 'vue'
import type { IAssetsProvider } from '@/platform/assets/composables/media/IAssetsProvider'
import { useMediaAssets } from '@/platform/assets/composables/media/useMediaAssets'
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { flattenNodeOutput } from '@/renderer/extensions/linearMode/flattenNodeOutput'
import { useLinearOutputStore } from '@/renderer/extensions/linearMode/linearOutputStore'
import { getJobDetail } from '@/services/jobOutputCache'
import { api } from '@/scripts/api'
import { useAppModeStore } from '@/stores/appModeStore'
import { useCommandStore } from '@/stores/commandStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useQueueStore } from '@/stores/queueStore'
import {
TaskItemImpl,
useHistoryStore,
useQueueStore
} from '@/stores/queueStore'
import type { ResultItemImpl } from '@/stores/queueStore'
export function useOutputHistory(): {
outputs: IAssetsProvider
allOutputs: (item?: AssetItem) => ResultItemImpl[]
outputs: ComputedRef<JobListItem[]>
allOutputs: (item?: JobListItem) => readonly ResultItemImpl[]
selectFirstHistory: () => void
mayBeActiveWorkflowPending: ComputedRef<boolean>
isWorkflowActive: ComputedRef<boolean>
cancelActiveWorkflowJobs: () => Promise<void>
} {
const backingOutputs = useMediaAssets('output')
void backingOutputs.fetchMediaList()
const linearStore = useLinearOutputStore()
const workflowStore = useWorkflowStore()
const executionStore = useExecutionStore()
const appModeStore = useAppModeStore()
const queueStore = useQueueStore()
const historyStore = useHistoryStore()
function matchesActiveWorkflow(task: { jobId: string | number }): boolean {
const path = workflowStore.activeWorkflow?.path
@@ -60,7 +58,9 @@ export function useOutputHistory(): {
hasActiveWorkflowJobs()
)
function filterByOutputNodes(items: ResultItemImpl[]): ResultItemImpl[] {
function filterByOutputNodes(
items: readonly ResultItemImpl[]
): readonly ResultItemImpl[] {
const nodeIds = appModeStore.selectedOutputs
if (!nodeIds.length) return []
return items.filter((r) =>
@@ -74,76 +74,45 @@ export function useOutputHistory(): {
const pathMap = executionStore.jobIdToSessionWorkflowPath
return backingOutputs.media.value.filter((asset) => {
const m = getOutputAssetMetadata(asset?.user_metadata)
return m ? pathMap.get(m.jobId) === path : false
})
return historyStore.historyItems.filter(
(item) => pathMap.get(item.id) === path
)
})
const outputs: IAssetsProvider = {
...backingOutputs,
media: sessionMedia,
hasMore: ref(false),
isLoadingMore: ref(false),
loadMore: async () => {}
}
const resolvedCache = linearStore.resolvedOutputsCache
const asyncRefs = new Map<
string,
ReturnType<typeof useAsyncState<ResultItemImpl[]>>['state']
ReturnType<typeof useAsyncState<readonly ResultItemImpl[]>>['state']
>()
function allOutputs(item?: AssetItem): ResultItemImpl[] {
function allOutputs(item?: JobListItem): readonly ResultItemImpl[] {
if (!item?.id) return []
const cached = resolvedCache.get(item.id)
if (cached) return filterByOutputNodes(cached)
const user_metadata = getOutputAssetMetadata(item.user_metadata)
if (!user_metadata) return []
/*FIXME
// For recently completed jobs still pending resolve, derive order from
// the in-progress items which are in correct execution order.
if (linearStore.pendingResolve.has(user_metadata.jobId)) {
if (linearStore.pendingResolve.has(item.Id)) {
const ordered = linearStore.inProgressItems
.filter((i) => i.jobId === user_metadata.jobId && i.output)
.filter((i) => i.id === item.id && i.output)
.map((i) => i.output!)
if (ordered.length > 0) {
resolvedCache.set(item.id, ordered)
return filterByOutputNodes(ordered)
}
}
// Use metadata when all outputs are present. The /jobs list endpoint
// only returns preview_output (single item), so outputCount may exceed
// allOutputs.length for multi-output jobs.
if (
user_metadata.allOutputs?.length &&
(!user_metadata.outputCount ||
user_metadata.outputCount <= user_metadata.allOutputs.length) &&
item.preview_url
) {
const reversed = user_metadata.allOutputs.toReversed()
resolvedCache.set(item.id, reversed)
return filterByOutputNodes(reversed)
}
}*/
// Async fallback for multi-output jobs — fetch full /jobs/{id} detail.
// This can be hit if the user executes the job then switches tabs.
const existing = asyncRefs.get(item.id)
if (existing) return filterByOutputNodes(existing.value)
const itemId = item.id
const outputRef = useAsyncState(
getJobDetail(user_metadata.jobId).then((jobDetail) => {
if (!jobDetail?.outputs) return []
const results = Object.entries(jobDetail.outputs)
.flatMap(flattenNodeOutput)
.toReversed()
resolvedCache.set(itemId, results)
return results
}),
new TaskItemImpl(item)
.loadFullOutputs()
.then((item) => item.calculateFlatOutputs()),
[]
).state
asyncRefs.set(item.id, outputRef)
@@ -151,7 +120,7 @@ export function useOutputHistory(): {
}
function selectFirstHistory() {
const first = outputs.media.value[0]
const first = historyStore.historyItems[0]
if (first) {
linearStore.selectAsLatest(`history:${first.id}:0`)
} else {
@@ -163,12 +132,9 @@ export function useOutputHistory(): {
watchEffect(() => {
if (linearStore.pendingResolve.size === 0) return
for (const jobId of linearStore.pendingResolve) {
const asset = outputs.media.value.find((a) => {
const m = getOutputAssetMetadata(a?.user_metadata)
return m?.jobId === jobId
})
if (!asset) continue
const loaded = allOutputs(asset).length > 0
const job = historyStore.historyItems.find((j) => j.id === jobId)
if (!job) continue
const loaded = allOutputs(job).length > 0
if (loaded) {
linearStore.resolveIfReady(jobId, true)
if (!linearStore.selectedId) selectFirstHistory()
@@ -194,7 +160,7 @@ export function useOutputHistory(): {
}
return {
outputs,
outputs: sessionMedia,
allOutputs,
selectFirstHistory,
mayBeActiveWorkflowPending,