Merge remote-tracking branch 'origin/main' into pysssss/asset-delete-progress
@@ -21,7 +21,6 @@ import {
|
|||||||
import { Topbar } from './components/Topbar'
|
import { Topbar } from './components/Topbar'
|
||||||
import type { Position, Size } from './types'
|
import type { Position, Size } from './types'
|
||||||
import { NodeReference, SubgraphSlotReference } from './utils/litegraphUtils'
|
import { NodeReference, SubgraphSlotReference } from './utils/litegraphUtils'
|
||||||
import TaskHistory from './utils/taskHistory'
|
|
||||||
|
|
||||||
dotenv.config()
|
dotenv.config()
|
||||||
|
|
||||||
@@ -146,8 +145,6 @@ class ConfirmDialog {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class ComfyPage {
|
export class ComfyPage {
|
||||||
private _history: TaskHistory | null = null
|
|
||||||
|
|
||||||
public readonly url: string
|
public readonly url: string
|
||||||
// All canvas position operations are based on default view of canvas.
|
// All canvas position operations are based on default view of canvas.
|
||||||
public readonly canvas: Locator
|
public readonly canvas: Locator
|
||||||
@@ -301,11 +298,6 @@ export class ComfyPage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setupHistory(): TaskHistory {
|
|
||||||
this._history ??= new TaskHistory(this)
|
|
||||||
return this._history
|
|
||||||
}
|
|
||||||
|
|
||||||
async setup({
|
async setup({
|
||||||
clearStorage = true,
|
clearStorage = true,
|
||||||
mockReleases = true
|
mockReleases = true
|
||||||
|
|||||||
@@ -1,164 +0,0 @@
|
|||||||
import type { Request, Route } from '@playwright/test'
|
|
||||||
import _ from 'es-toolkit/compat'
|
|
||||||
import fs from 'fs'
|
|
||||||
import path from 'path'
|
|
||||||
import { v4 as uuidv4 } from 'uuid'
|
|
||||||
|
|
||||||
import type {
|
|
||||||
HistoryTaskItem,
|
|
||||||
TaskItem,
|
|
||||||
TaskOutput
|
|
||||||
} from '../../../src/schemas/apiSchema'
|
|
||||||
import type { ComfyPage } from '../ComfyPage'
|
|
||||||
|
|
||||||
/** keyof TaskOutput[string] */
|
|
||||||
type OutputFileType = 'images' | 'audio' | 'animated'
|
|
||||||
|
|
||||||
const DEFAULT_IMAGE = 'example.webp'
|
|
||||||
|
|
||||||
const getFilenameParam = (request: Request) => {
|
|
||||||
const url = new URL(request.url())
|
|
||||||
return url.searchParams.get('filename') || DEFAULT_IMAGE
|
|
||||||
}
|
|
||||||
|
|
||||||
const getContentType = (filename: string, fileType: OutputFileType) => {
|
|
||||||
const subtype = path.extname(filename).slice(1)
|
|
||||||
switch (fileType) {
|
|
||||||
case 'images':
|
|
||||||
return `image/${subtype}`
|
|
||||||
case 'audio':
|
|
||||||
return `audio/${subtype}`
|
|
||||||
case 'animated':
|
|
||||||
return `video/${subtype}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const setQueueIndex = (task: TaskItem) => {
|
|
||||||
task.prompt[0] = TaskHistory.queueIndex++
|
|
||||||
}
|
|
||||||
|
|
||||||
const setPromptId = (task: TaskItem) => {
|
|
||||||
task.prompt[1] = uuidv4()
|
|
||||||
}
|
|
||||||
|
|
||||||
export default class TaskHistory {
|
|
||||||
static queueIndex = 0
|
|
||||||
static readonly defaultTask: Readonly<HistoryTaskItem> = {
|
|
||||||
prompt: [0, 'prompt-id', {}, { client_id: uuidv4() }, []],
|
|
||||||
outputs: {},
|
|
||||||
status: {
|
|
||||||
status_str: 'success',
|
|
||||||
completed: true,
|
|
||||||
messages: []
|
|
||||||
},
|
|
||||||
taskType: 'History'
|
|
||||||
}
|
|
||||||
private tasks: HistoryTaskItem[] = []
|
|
||||||
private outputContentTypes: Map<string, string> = new Map()
|
|
||||||
|
|
||||||
constructor(readonly comfyPage: ComfyPage) {}
|
|
||||||
|
|
||||||
private loadAsset: (filename: string) => Buffer = _.memoize(
|
|
||||||
(filename: string) => {
|
|
||||||
const filePath = this.comfyPage.assetPath(filename)
|
|
||||||
return fs.readFileSync(filePath)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
private async handleGetHistory(route: Route) {
|
|
||||||
return route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: 'application/json',
|
|
||||||
body: JSON.stringify(this.tasks)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleGetView(route: Route) {
|
|
||||||
const fileName = getFilenameParam(route.request())
|
|
||||||
if (!this.outputContentTypes.has(fileName)) {
|
|
||||||
return route.continue()
|
|
||||||
}
|
|
||||||
|
|
||||||
const asset = this.loadAsset(fileName)
|
|
||||||
return route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: this.outputContentTypes.get(fileName),
|
|
||||||
body: asset,
|
|
||||||
headers: {
|
|
||||||
'Cache-Control': 'public, max-age=31536000',
|
|
||||||
'Content-Length': asset.byteLength.toString()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async setupRoutes() {
|
|
||||||
return this.comfyPage.page.route(
|
|
||||||
/.*\/api\/(view|history)(\?.*)?$/,
|
|
||||||
async (route) => {
|
|
||||||
const request = route.request()
|
|
||||||
const method = request.method()
|
|
||||||
|
|
||||||
const isViewReq = request.url().includes('view') && method === 'GET'
|
|
||||||
if (isViewReq) return this.handleGetView(route)
|
|
||||||
|
|
||||||
const isHistoryPath = request.url().includes('history')
|
|
||||||
const isGetHistoryReq = isHistoryPath && method === 'GET'
|
|
||||||
if (isGetHistoryReq) return this.handleGetHistory(route)
|
|
||||||
|
|
||||||
const isClearReq =
|
|
||||||
method === 'POST' &&
|
|
||||||
isHistoryPath &&
|
|
||||||
request.postDataJSON()?.clear === true
|
|
||||||
if (isClearReq) return this.clearTasks()
|
|
||||||
|
|
||||||
return route.continue()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private createOutputs(
|
|
||||||
filenames: string[],
|
|
||||||
filetype: OutputFileType
|
|
||||||
): TaskOutput {
|
|
||||||
return filenames.reduce((outputs, filename, i) => {
|
|
||||||
const nodeId = `${i + 1}`
|
|
||||||
outputs[nodeId] = {
|
|
||||||
[filetype]: [{ filename, subfolder: '', type: 'output' }]
|
|
||||||
}
|
|
||||||
const contentType = getContentType(filename, filetype)
|
|
||||||
this.outputContentTypes.set(filename, contentType)
|
|
||||||
return outputs
|
|
||||||
}, {})
|
|
||||||
}
|
|
||||||
|
|
||||||
private addTask(task: HistoryTaskItem) {
|
|
||||||
setPromptId(task)
|
|
||||||
setQueueIndex(task)
|
|
||||||
this.tasks.unshift(task) // Tasks are added to the front of the queue
|
|
||||||
}
|
|
||||||
|
|
||||||
clearTasks(): this {
|
|
||||||
this.tasks = []
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
withTask(
|
|
||||||
outputFilenames: string[],
|
|
||||||
outputFiletype: OutputFileType = 'images',
|
|
||||||
overrides: Partial<HistoryTaskItem> = {}
|
|
||||||
): this {
|
|
||||||
this.addTask({
|
|
||||||
...TaskHistory.defaultTask,
|
|
||||||
outputs: this.createOutputs(outputFilenames, outputFiletype),
|
|
||||||
...overrides
|
|
||||||
})
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Repeats the last task in the task history a specified number of times. */
|
|
||||||
repeat(n: number): this {
|
|
||||||
for (let i = 0; i < n; i++)
|
|
||||||
this.addTask(structuredClone(this.tasks.at(0)) as HistoryTaskItem)
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 138 KiB After Width: | Height: | Size: 138 KiB |
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@comfyorg/comfyui-frontend",
|
"name": "@comfyorg/comfyui-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.38.2",
|
"version": "1.38.3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
|
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
|
||||||
"homepage": "https://comfy.org",
|
"homepage": "https://comfy.org",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex h-full items-center">
|
<div class="flex h-full items-center" :class="cn(!isDocked && '-ml-2')">
|
||||||
<div
|
<div
|
||||||
v-if="isDragging && !isDocked"
|
v-if="isDragging && !isDocked"
|
||||||
:class="actionbarClass"
|
:class="actionbarClass"
|
||||||
@@ -77,7 +77,6 @@ const { isIdle: isExecutionIdle } = storeToRefs(useExecutionStore())
|
|||||||
const position = computed(() => settingsStore.get('Comfy.UseNewMenu'))
|
const position = computed(() => settingsStore.get('Comfy.UseNewMenu'))
|
||||||
const visible = computed(() => position.value !== 'Disabled')
|
const visible = computed(() => position.value !== 'Disabled')
|
||||||
|
|
||||||
const tabContainer = document.querySelector('.workflow-tabs-container')
|
|
||||||
const panelRef = ref<HTMLElement | null>(null)
|
const panelRef = ref<HTMLElement | null>(null)
|
||||||
const dragHandleRef = ref<HTMLElement | null>(null)
|
const dragHandleRef = ref<HTMLElement | null>(null)
|
||||||
const isDocked = useLocalStorage('Comfy.MenuPosition.Docked', true)
|
const isDocked = useLocalStorage('Comfy.MenuPosition.Docked', true)
|
||||||
@@ -88,14 +87,7 @@ const storedPosition = useLocalStorage('Comfy.MenuPosition.Floating', {
|
|||||||
const { x, y, style, isDragging } = useDraggable(panelRef, {
|
const { x, y, style, isDragging } = useDraggable(panelRef, {
|
||||||
initialValue: { x: 0, y: 0 },
|
initialValue: { x: 0, y: 0 },
|
||||||
handle: dragHandleRef,
|
handle: dragHandleRef,
|
||||||
containerElement: document.body,
|
containerElement: document.body
|
||||||
onMove: (event) => {
|
|
||||||
// Prevent dragging the menu over the top of the tabs
|
|
||||||
const minY = tabContainer?.getBoundingClientRect().bottom ?? 40
|
|
||||||
if (event.y < minY) {
|
|
||||||
event.y = minY
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Update storedPosition when x or y changes
|
// Update storedPosition when x or y changes
|
||||||
|
|||||||
@@ -55,17 +55,4 @@ const dialogStore = useDialogStore()
|
|||||||
@apply p-2 2xl:p-[var(--p-dialog-content-padding)];
|
@apply p-2 2xl:p-[var(--p-dialog-content-padding)];
|
||||||
@apply pt-0;
|
@apply pt-0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.manager-dialog {
|
|
||||||
height: 80vh;
|
|
||||||
max-width: 1724px;
|
|
||||||
max-height: 1026px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 3000px) {
|
|
||||||
.manager-dialog {
|
|
||||||
max-width: 2200px;
|
|
||||||
max-height: 1320px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -262,7 +262,7 @@ const focusAssetInSidebar = async (item: JobListItem) => {
|
|||||||
|
|
||||||
const inspectJobAsset = wrapWithErrorHandlingAsync(
|
const inspectJobAsset = wrapWithErrorHandlingAsync(
|
||||||
async (item: JobListItem) => {
|
async (item: JobListItem) => {
|
||||||
openResultGallery(item)
|
await openResultGallery(item)
|
||||||
await focusAssetInSidebar(item)
|
await focusAssetInSidebar(item)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||||
|
|
||||||
import type { TaskStatus } from '@/schemas/apiSchema'
|
import type {
|
||||||
|
JobListItem,
|
||||||
|
JobStatus
|
||||||
|
} from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||||
import { useExecutionStore } from '@/stores/executionStore'
|
import { useExecutionStore } from '@/stores/executionStore'
|
||||||
import { TaskItemImpl, useQueueStore } from '@/stores/queueStore'
|
import { TaskItemImpl, useQueueStore } from '@/stores/queueStore'
|
||||||
|
|
||||||
@@ -37,91 +40,86 @@ function resetStores() {
|
|||||||
exec.nodeProgressStatesByPrompt = {}
|
exec.nodeProgressStatesByPrompt = {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function makeTask(
|
||||||
|
id: string,
|
||||||
|
priority: number,
|
||||||
|
fields: Partial<JobListItem> & { status: JobStatus; create_time: number }
|
||||||
|
): TaskItemImpl {
|
||||||
|
const job: JobListItem = {
|
||||||
|
id,
|
||||||
|
priority,
|
||||||
|
last_state_update: null,
|
||||||
|
update_time: fields.create_time,
|
||||||
|
...fields
|
||||||
|
}
|
||||||
|
return new TaskItemImpl(job)
|
||||||
|
}
|
||||||
|
|
||||||
function makePendingTask(
|
function makePendingTask(
|
||||||
id: string,
|
id: string,
|
||||||
index: number,
|
priority: number,
|
||||||
createTimeMs?: number
|
createTimeMs: number
|
||||||
): TaskItemImpl {
|
): TaskItemImpl {
|
||||||
const extraData = {
|
return makeTask(id, priority, {
|
||||||
client_id: 'c1',
|
status: 'pending',
|
||||||
...(typeof createTimeMs === 'number' ? { create_time: createTimeMs } : {})
|
create_time: createTimeMs
|
||||||
}
|
})
|
||||||
return new TaskItemImpl('Pending', [index, id, {}, extraData, []])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeRunningTask(
|
function makeRunningTask(
|
||||||
id: string,
|
id: string,
|
||||||
index: number,
|
priority: number,
|
||||||
createTimeMs?: number
|
createTimeMs: number
|
||||||
): TaskItemImpl {
|
): TaskItemImpl {
|
||||||
const extraData = {
|
return makeTask(id, priority, {
|
||||||
client_id: 'c1',
|
status: 'in_progress',
|
||||||
...(typeof createTimeMs === 'number' ? { create_time: createTimeMs } : {})
|
create_time: createTimeMs
|
||||||
}
|
})
|
||||||
return new TaskItemImpl('Running', [index, id, {}, extraData, []])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeRunningTaskWithStart(
|
function makeRunningTaskWithStart(
|
||||||
id: string,
|
id: string,
|
||||||
index: number,
|
priority: number,
|
||||||
startedSecondsAgo: number
|
startedSecondsAgo: number
|
||||||
): TaskItemImpl {
|
): TaskItemImpl {
|
||||||
const start = Date.now() - startedSecondsAgo * 1000
|
const start = Date.now() - startedSecondsAgo * 1000
|
||||||
const status: TaskStatus = {
|
return makeTask(id, priority, {
|
||||||
status_str: 'success',
|
status: 'in_progress',
|
||||||
completed: false,
|
create_time: start - 5000,
|
||||||
messages: [['execution_start', { prompt_id: id, timestamp: start } as any]]
|
update_time: start
|
||||||
}
|
})
|
||||||
return new TaskItemImpl(
|
|
||||||
'Running',
|
|
||||||
[index, id, {}, { client_id: 'c1', create_time: start - 5000 }, []],
|
|
||||||
status
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeHistoryTask(
|
function makeHistoryTask(
|
||||||
id: string,
|
id: string,
|
||||||
index: number,
|
priority: number,
|
||||||
durationSec: number,
|
durationSec: number,
|
||||||
ok: boolean,
|
ok: boolean,
|
||||||
errorMessage?: string
|
errorMessage?: string
|
||||||
): TaskItemImpl {
|
): TaskItemImpl {
|
||||||
const start = Date.now() - durationSec * 1000 - 1000
|
const now = Date.now()
|
||||||
const end = start + durationSec * 1000
|
const executionEndTime = now
|
||||||
const messages: TaskStatus['messages'] = ok
|
const executionStartTime = now - durationSec * 1000
|
||||||
? [
|
return makeTask(id, priority, {
|
||||||
['execution_start', { prompt_id: id, timestamp: start } as any],
|
status: ok ? 'completed' : 'failed',
|
||||||
['execution_success', { prompt_id: id, timestamp: end } as any]
|
create_time: executionStartTime - 5000,
|
||||||
]
|
update_time: now,
|
||||||
: [
|
execution_start_time: executionStartTime,
|
||||||
['execution_start', { prompt_id: id, timestamp: start } as any],
|
execution_end_time: executionEndTime,
|
||||||
[
|
execution_error: errorMessage
|
||||||
'execution_error',
|
? {
|
||||||
{
|
prompt_id: id,
|
||||||
prompt_id: id,
|
timestamp: now,
|
||||||
timestamp: end,
|
node_id: '1',
|
||||||
node_id: '1',
|
node_type: 'ExampleNode',
|
||||||
node_type: 'Node',
|
exception_message: errorMessage,
|
||||||
executed: [],
|
exception_type: 'RuntimeError',
|
||||||
exception_message:
|
traceback: [],
|
||||||
errorMessage || 'Demo error: Node failed during execution',
|
current_inputs: {},
|
||||||
exception_type: 'RuntimeError',
|
current_outputs: {}
|
||||||
traceback: [],
|
}
|
||||||
current_inputs: {},
|
: undefined
|
||||||
current_outputs: {}
|
})
|
||||||
} as any
|
|
||||||
]
|
|
||||||
]
|
|
||||||
const status: TaskStatus = {
|
|
||||||
status_str: ok ? 'success' : 'error',
|
|
||||||
completed: true,
|
|
||||||
messages
|
|
||||||
}
|
|
||||||
return new TaskItemImpl(
|
|
||||||
'History',
|
|
||||||
[index, id, {}, { client_id: 'c1', create_time: start }, []],
|
|
||||||
status
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Queued: Story = {
|
export const Queued: Story = {
|
||||||
@@ -140,8 +138,12 @@ export const Queued: Story = {
|
|||||||
makePendingTask(jobId, queueIndex, Date.now() - 90_000)
|
makePendingTask(jobId, queueIndex, Date.now() - 90_000)
|
||||||
]
|
]
|
||||||
// Add some other pending jobs to give context
|
// Add some other pending jobs to give context
|
||||||
queue.pendingTasks.push(makePendingTask('job-older-1', 100))
|
queue.pendingTasks.push(
|
||||||
queue.pendingTasks.push(makePendingTask('job-older-2', 101))
|
makePendingTask('job-older-1', 100, Date.now() - 60_000)
|
||||||
|
)
|
||||||
|
queue.pendingTasks.push(
|
||||||
|
makePendingTask('job-older-2', 101, Date.now() - 30_000)
|
||||||
|
)
|
||||||
|
|
||||||
// Queued at (in metadata on prompt[4])
|
// Queued at (in metadata on prompt[4])
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
v-for="ji in group.items"
|
v-for="ji in group.items"
|
||||||
:key="ji.id"
|
:key="ji.id"
|
||||||
:job-id="ji.id"
|
:job-id="ji.id"
|
||||||
:workflow-id="ji.taskRef?.workflow?.id"
|
:workflow-id="ji.taskRef?.workflowId"
|
||||||
:state="ji.state"
|
:state="ji.state"
|
||||||
:title="ji.title"
|
:title="ji.title"
|
||||||
:right-text="ji.meta"
|
:right-text="ji.meta"
|
||||||
|
|||||||
@@ -2,116 +2,49 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import type { ComputedRef } from 'vue'
|
import type { ComputedRef } from 'vue'
|
||||||
|
|
||||||
import type { ExecutionErrorWsMessage } from '@/schemas/apiSchema'
|
|
||||||
import type { TaskItemImpl } from '@/stores/queueStore'
|
import type { TaskItemImpl } from '@/stores/queueStore'
|
||||||
import type {
|
import type { JobErrorDialogService } from '@/components/queue/job/useJobErrorReporting'
|
||||||
JobErrorDialogService,
|
import { useJobErrorReporting } from '@/components/queue/job/useJobErrorReporting'
|
||||||
UseJobErrorReportingOptions
|
import type { ExecutionError } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||||
} from '@/components/queue/job/useJobErrorReporting'
|
|
||||||
import * as jobErrorReporting from '@/components/queue/job/useJobErrorReporting'
|
|
||||||
|
|
||||||
const createExecutionErrorMessage = (
|
const createTaskWithError = (
|
||||||
overrides: Partial<ExecutionErrorWsMessage> = {}
|
promptId: string,
|
||||||
): ExecutionErrorWsMessage => ({
|
errorMessage?: string,
|
||||||
prompt_id: 'prompt',
|
executionError?: ExecutionError,
|
||||||
timestamp: 100,
|
createTime?: number
|
||||||
node_id: 'node-1',
|
|
||||||
node_type: 'KSampler',
|
|
||||||
executed: [],
|
|
||||||
exception_message: 'default failure',
|
|
||||||
exception_type: 'RuntimeError',
|
|
||||||
traceback: ['Trace line'],
|
|
||||||
current_inputs: {},
|
|
||||||
current_outputs: {},
|
|
||||||
...overrides
|
|
||||||
})
|
|
||||||
|
|
||||||
const createTaskWithMessages = (
|
|
||||||
messages: Array<[string, unknown]> | undefined = []
|
|
||||||
): TaskItemImpl =>
|
): TaskItemImpl =>
|
||||||
({
|
({
|
||||||
status: {
|
promptId,
|
||||||
status_str: 'error',
|
errorMessage,
|
||||||
completed: false,
|
executionError,
|
||||||
messages
|
createTime: createTime ?? Date.now()
|
||||||
}
|
}) as Partial<TaskItemImpl> as TaskItemImpl
|
||||||
}) as TaskItemImpl
|
|
||||||
|
|
||||||
describe('extractExecutionError', () => {
|
|
||||||
it('returns null when task has no execution error messages', () => {
|
|
||||||
expect(jobErrorReporting.extractExecutionError(null)).toBeNull()
|
|
||||||
expect(
|
|
||||||
jobErrorReporting.extractExecutionError({
|
|
||||||
status: undefined
|
|
||||||
} as TaskItemImpl)
|
|
||||||
).toBeNull()
|
|
||||||
expect(
|
|
||||||
jobErrorReporting.extractExecutionError({
|
|
||||||
status: {
|
|
||||||
status_str: 'error',
|
|
||||||
completed: false,
|
|
||||||
messages: {} as unknown as Array<[string, unknown]>
|
|
||||||
}
|
|
||||||
} as TaskItemImpl)
|
|
||||||
).toBeNull()
|
|
||||||
expect(
|
|
||||||
jobErrorReporting.extractExecutionError(createTaskWithMessages([]))
|
|
||||||
).toBeNull()
|
|
||||||
expect(
|
|
||||||
jobErrorReporting.extractExecutionError(
|
|
||||||
createTaskWithMessages([
|
|
||||||
['execution_start', { prompt_id: 'prompt', timestamp: 1 }]
|
|
||||||
] as Array<[string, unknown]>)
|
|
||||||
)
|
|
||||||
).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns detail and message for execution_error entries', () => {
|
|
||||||
const detail = createExecutionErrorMessage({ exception_message: 'Kaboom' })
|
|
||||||
const result = jobErrorReporting.extractExecutionError(
|
|
||||||
createTaskWithMessages([
|
|
||||||
['execution_success', { prompt_id: 'prompt', timestamp: 2 }],
|
|
||||||
['execution_error', detail]
|
|
||||||
] as Array<[string, unknown]>)
|
|
||||||
)
|
|
||||||
expect(result).toEqual({
|
|
||||||
detail,
|
|
||||||
message: 'Kaboom'
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('falls back to an empty message when the tuple lacks detail', () => {
|
|
||||||
const result = jobErrorReporting.extractExecutionError(
|
|
||||||
createTaskWithMessages([
|
|
||||||
['execution_error'] as unknown as [string, ExecutionErrorWsMessage]
|
|
||||||
])
|
|
||||||
)
|
|
||||||
expect(result).toEqual({ detail: undefined, message: '' })
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('useJobErrorReporting', () => {
|
describe('useJobErrorReporting', () => {
|
||||||
let taskState = ref<TaskItemImpl | null>(null)
|
let taskState = ref<TaskItemImpl | null>(null)
|
||||||
let taskForJob: ComputedRef<TaskItemImpl | null>
|
let taskForJob: ComputedRef<TaskItemImpl | null>
|
||||||
let copyToClipboard: UseJobErrorReportingOptions['copyToClipboard']
|
let copyToClipboard: ReturnType<typeof vi.fn>
|
||||||
let showExecutionErrorDialog: JobErrorDialogService['showExecutionErrorDialog']
|
let showErrorDialog: ReturnType<typeof vi.fn>
|
||||||
let showErrorDialog: JobErrorDialogService['showErrorDialog']
|
let showExecutionErrorDialog: ReturnType<typeof vi.fn>
|
||||||
let dialog: JobErrorDialogService
|
let dialog: JobErrorDialogService
|
||||||
let composable: ReturnType<typeof jobErrorReporting.useJobErrorReporting>
|
let composable: ReturnType<typeof useJobErrorReporting>
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
taskState = ref<TaskItemImpl | null>(null)
|
taskState = ref<TaskItemImpl | null>(null)
|
||||||
taskForJob = computed(() => taskState.value)
|
taskForJob = computed(() => taskState.value)
|
||||||
copyToClipboard = vi.fn()
|
copyToClipboard = vi.fn()
|
||||||
showExecutionErrorDialog = vi.fn()
|
|
||||||
showErrorDialog = vi.fn()
|
showErrorDialog = vi.fn()
|
||||||
|
showExecutionErrorDialog = vi.fn()
|
||||||
dialog = {
|
dialog = {
|
||||||
showExecutionErrorDialog,
|
showErrorDialog,
|
||||||
showErrorDialog
|
showExecutionErrorDialog
|
||||||
}
|
} as unknown as JobErrorDialogService
|
||||||
composable = jobErrorReporting.useJobErrorReporting({
|
composable = useJobErrorReporting({
|
||||||
taskForJob,
|
taskForJob,
|
||||||
copyToClipboard,
|
copyToClipboard: copyToClipboard as (
|
||||||
|
value: string
|
||||||
|
) => void | Promise<void>,
|
||||||
dialog
|
dialog
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -121,73 +54,87 @@ describe('useJobErrorReporting', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('exposes a computed message that reflects the current task error', () => {
|
it('exposes a computed message that reflects the current task error', () => {
|
||||||
taskState.value = createTaskWithMessages([
|
taskState.value = createTaskWithError('job-1', 'First failure')
|
||||||
[
|
|
||||||
'execution_error',
|
|
||||||
createExecutionErrorMessage({ exception_message: 'First failure' })
|
|
||||||
]
|
|
||||||
])
|
|
||||||
expect(composable.errorMessageValue.value).toBe('First failure')
|
expect(composable.errorMessageValue.value).toBe('First failure')
|
||||||
|
|
||||||
taskState.value = createTaskWithMessages([
|
taskState.value = createTaskWithError('job-2', 'Second failure')
|
||||||
[
|
|
||||||
'execution_error',
|
|
||||||
createExecutionErrorMessage({ exception_message: 'Second failure' })
|
|
||||||
]
|
|
||||||
])
|
|
||||||
expect(composable.errorMessageValue.value).toBe('Second failure')
|
expect(composable.errorMessageValue.value).toBe('Second failure')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('returns empty string when no error message', () => {
|
||||||
|
taskState.value = createTaskWithError('job-1')
|
||||||
|
expect(composable.errorMessageValue.value).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns empty string when task is null', () => {
|
||||||
|
taskState.value = null
|
||||||
|
expect(composable.errorMessageValue.value).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
it('only calls the copy handler when a message exists', () => {
|
it('only calls the copy handler when a message exists', () => {
|
||||||
taskState.value = createTaskWithMessages([
|
taskState.value = createTaskWithError('job-1', 'Clipboard failure')
|
||||||
[
|
|
||||||
'execution_error',
|
|
||||||
createExecutionErrorMessage({ exception_message: 'Clipboard failure' })
|
|
||||||
]
|
|
||||||
])
|
|
||||||
composable.copyErrorMessage()
|
composable.copyErrorMessage()
|
||||||
expect(copyToClipboard).toHaveBeenCalledTimes(1)
|
expect(copyToClipboard).toHaveBeenCalledTimes(1)
|
||||||
expect(copyToClipboard).toHaveBeenCalledWith('Clipboard failure')
|
expect(copyToClipboard).toHaveBeenCalledWith('Clipboard failure')
|
||||||
|
|
||||||
vi.mocked(copyToClipboard).mockClear()
|
copyToClipboard.mockClear()
|
||||||
taskState.value = createTaskWithMessages([])
|
taskState.value = createTaskWithError('job-2')
|
||||||
composable.copyErrorMessage()
|
composable.copyErrorMessage()
|
||||||
expect(copyToClipboard).not.toHaveBeenCalled()
|
expect(copyToClipboard).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('prefers the detailed execution dialog when detail is available', () => {
|
it('shows simple error dialog when only errorMessage present', () => {
|
||||||
const detail = createExecutionErrorMessage({
|
taskState.value = createTaskWithError('job-1', 'Queue job error')
|
||||||
exception_message: 'Detailed failure'
|
|
||||||
})
|
|
||||||
taskState.value = createTaskWithMessages([['execution_error', detail]])
|
|
||||||
composable.reportJobError()
|
composable.reportJobError()
|
||||||
expect(showExecutionErrorDialog).toHaveBeenCalledTimes(1)
|
|
||||||
expect(showExecutionErrorDialog).toHaveBeenCalledWith(detail)
|
|
||||||
expect(showErrorDialog).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows a fallback dialog when only a message is available', () => {
|
|
||||||
const message = 'Queue job error'
|
|
||||||
taskState.value = createTaskWithMessages([])
|
|
||||||
const valueSpy = vi
|
|
||||||
.spyOn(composable.errorMessageValue, 'value', 'get')
|
|
||||||
.mockReturnValue(message)
|
|
||||||
|
|
||||||
expect(composable.errorMessageValue.value).toBe(message)
|
|
||||||
composable.reportJobError()
|
|
||||||
expect(showExecutionErrorDialog).not.toHaveBeenCalled()
|
|
||||||
expect(showErrorDialog).toHaveBeenCalledTimes(1)
|
expect(showErrorDialog).toHaveBeenCalledTimes(1)
|
||||||
const [errorArg, optionsArg] = vi.mocked(showErrorDialog).mock.calls[0]
|
const [errorArg, optionsArg] = showErrorDialog.mock.calls[0]
|
||||||
expect(errorArg).toBeInstanceOf(Error)
|
expect(errorArg).toBeInstanceOf(Error)
|
||||||
expect(errorArg.message).toBe(message)
|
expect(errorArg.message).toBe('Queue job error')
|
||||||
expect(optionsArg).toEqual({ reportType: 'queueJobError' })
|
expect(optionsArg).toEqual({ reportType: 'queueJobError' })
|
||||||
valueSpy.mockRestore()
|
expect(showExecutionErrorDialog).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('does nothing when no error could be extracted', () => {
|
it('does nothing when no task exists', () => {
|
||||||
taskState.value = createTaskWithMessages([])
|
taskState.value = null
|
||||||
composable.reportJobError()
|
composable.reportJobError()
|
||||||
expect(showExecutionErrorDialog).not.toHaveBeenCalled()
|
|
||||||
expect(showErrorDialog).not.toHaveBeenCalled()
|
expect(showErrorDialog).not.toHaveBeenCalled()
|
||||||
|
expect(showExecutionErrorDialog).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows rich error dialog when execution_error available on task', () => {
|
||||||
|
const executionError: ExecutionError = {
|
||||||
|
prompt_id: 'job-1',
|
||||||
|
timestamp: 12345,
|
||||||
|
node_id: '5',
|
||||||
|
node_type: 'KSampler',
|
||||||
|
executed: ['1', '2'],
|
||||||
|
exception_message: 'CUDA out of memory',
|
||||||
|
exception_type: 'RuntimeError',
|
||||||
|
traceback: ['line 1', 'line 2'],
|
||||||
|
current_inputs: {},
|
||||||
|
current_outputs: {}
|
||||||
|
}
|
||||||
|
taskState.value = createTaskWithError(
|
||||||
|
'job-1',
|
||||||
|
'CUDA out of memory',
|
||||||
|
executionError,
|
||||||
|
12345
|
||||||
|
)
|
||||||
|
|
||||||
|
composable.reportJobError()
|
||||||
|
|
||||||
|
expect(showExecutionErrorDialog).toHaveBeenCalledTimes(1)
|
||||||
|
expect(showExecutionErrorDialog).toHaveBeenCalledWith(executionError)
|
||||||
|
expect(showErrorDialog).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does nothing when no error message and no execution_error', () => {
|
||||||
|
taskState.value = createTaskWithError('job-1')
|
||||||
|
|
||||||
|
composable.reportJobError()
|
||||||
|
|
||||||
|
expect(showErrorDialog).not.toHaveBeenCalled()
|
||||||
|
expect(showExecutionErrorDialog).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import type { ComputedRef } from 'vue'
|
import type { ComputedRef } from 'vue'
|
||||||
|
|
||||||
import type { ExecutionErrorWsMessage } from '@/schemas/apiSchema'
|
import type { ExecutionErrorDialogInput } from '@/services/dialogService'
|
||||||
import type { TaskItemImpl } from '@/stores/queueStore'
|
import type { TaskItemImpl } from '@/stores/queueStore'
|
||||||
|
|
||||||
type CopyHandler = (value: string) => void | Promise<void>
|
type CopyHandler = (value: string) => void | Promise<void>
|
||||||
|
|
||||||
export type JobErrorDialogService = {
|
export type JobErrorDialogService = {
|
||||||
showExecutionErrorDialog: (error: ExecutionErrorWsMessage) => void
|
showExecutionErrorDialog: (executionError: ExecutionErrorDialogInput) => void
|
||||||
showErrorDialog: (
|
showErrorDialog: (
|
||||||
error: Error,
|
error: Error,
|
||||||
options?: {
|
options?: {
|
||||||
@@ -17,30 +17,7 @@ export type JobErrorDialogService = {
|
|||||||
) => void
|
) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
type JobExecutionError = {
|
type UseJobErrorReportingOptions = {
|
||||||
detail?: ExecutionErrorWsMessage
|
|
||||||
message: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const extractExecutionError = (
|
|
||||||
task: TaskItemImpl | null
|
|
||||||
): JobExecutionError | null => {
|
|
||||||
const status = (task as TaskItemImpl | null)?.status
|
|
||||||
const messages = (status as { messages?: unknown[] } | undefined)?.messages
|
|
||||||
if (!Array.isArray(messages) || !messages.length) return null
|
|
||||||
const record = messages.find((entry: unknown) => {
|
|
||||||
return Array.isArray(entry) && entry[0] === 'execution_error'
|
|
||||||
}) as [string, ExecutionErrorWsMessage?] | undefined
|
|
||||||
if (!record) return null
|
|
||||||
const detail = record[1]
|
|
||||||
const message = String(detail?.exception_message ?? '')
|
|
||||||
return {
|
|
||||||
detail,
|
|
||||||
message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type UseJobErrorReportingOptions = {
|
|
||||||
taskForJob: ComputedRef<TaskItemImpl | null>
|
taskForJob: ComputedRef<TaskItemImpl | null>
|
||||||
copyToClipboard: CopyHandler
|
copyToClipboard: CopyHandler
|
||||||
dialog: JobErrorDialogService
|
dialog: JobErrorDialogService
|
||||||
@@ -51,10 +28,7 @@ export const useJobErrorReporting = ({
|
|||||||
copyToClipboard,
|
copyToClipboard,
|
||||||
dialog
|
dialog
|
||||||
}: UseJobErrorReportingOptions) => {
|
}: UseJobErrorReportingOptions) => {
|
||||||
const errorMessageValue = computed(() => {
|
const errorMessageValue = computed(() => taskForJob.value?.errorMessage ?? '')
|
||||||
const error = extractExecutionError(taskForJob.value)
|
|
||||||
return error?.message ?? ''
|
|
||||||
})
|
|
||||||
|
|
||||||
const copyErrorMessage = () => {
|
const copyErrorMessage = () => {
|
||||||
if (errorMessageValue.value) {
|
if (errorMessageValue.value) {
|
||||||
@@ -63,11 +37,12 @@ export const useJobErrorReporting = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const reportJobError = () => {
|
const reportJobError = () => {
|
||||||
const error = extractExecutionError(taskForJob.value)
|
const executionError = taskForJob.value?.executionError
|
||||||
if (error?.detail) {
|
if (executionError) {
|
||||||
dialog.showExecutionErrorDialog(error.detail)
|
dialog.showExecutionErrorDialog(executionError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (errorMessageValue.value) {
|
if (errorMessageValue.value) {
|
||||||
dialog.showErrorDialog(new Error(errorMessageValue.value), {
|
dialog.showErrorDialog(new Error(errorMessageValue.value), {
|
||||||
reportType: 'queueJobError'
|
reportType: 'queueJobError'
|
||||||
|
|||||||
@@ -240,12 +240,19 @@ import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
|||||||
import type { MediaKind } from '@/platform/assets/schemas/mediaAssetSchema'
|
import type { MediaKind } from '@/platform/assets/schemas/mediaAssetSchema'
|
||||||
import { isCloud } from '@/platform/distribution/types'
|
import { isCloud } from '@/platform/distribution/types'
|
||||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||||
|
import { getJobDetail } from '@/services/jobOutputCache'
|
||||||
import { useCommandStore } from '@/stores/commandStore'
|
import { useCommandStore } from '@/stores/commandStore'
|
||||||
import { useDialogStore } from '@/stores/dialogStore'
|
import { useDialogStore } from '@/stores/dialogStore'
|
||||||
import { ResultItemImpl, useQueueStore } from '@/stores/queueStore'
|
import { ResultItemImpl, useQueueStore } from '@/stores/queueStore'
|
||||||
import { formatDuration, getMediaTypeFromFilename } from '@/utils/formatUtil'
|
import { formatDuration, getMediaTypeFromFilename } from '@/utils/formatUtil'
|
||||||
import { cn } from '@/utils/tailwindUtil'
|
import { cn } from '@/utils/tailwindUtil'
|
||||||
|
|
||||||
|
interface JobOutputItem {
|
||||||
|
filename: string
|
||||||
|
subfolder: string
|
||||||
|
type: string
|
||||||
|
}
|
||||||
|
|
||||||
const { t, n } = useI18n()
|
const { t, n } = useI18n()
|
||||||
const commandStore = useCommandStore()
|
const commandStore = useCommandStore()
|
||||||
const queueStore = useQueueStore()
|
const queueStore = useQueueStore()
|
||||||
@@ -492,6 +499,47 @@ function handleContextMenuHide() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleBulkDownload = (assets: AssetItem[]) => {
|
||||||
|
downloadMultipleAssets(assets)
|
||||||
|
clearSelection()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBulkDelete = async (assets: AssetItem[]) => {
|
||||||
|
if (await deleteAssets(assets)) {
|
||||||
|
clearSelection()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClearQueue = async () => {
|
||||||
|
await commandStore.execute('Comfy.ClearPendingTasks')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBulkAddToWorkflow = async (assets: AssetItem[]) => {
|
||||||
|
await addMultipleToWorkflow(assets)
|
||||||
|
clearSelection()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBulkOpenWorkflow = async (assets: AssetItem[]) => {
|
||||||
|
await openMultipleWorkflows(assets)
|
||||||
|
clearSelection()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBulkExportWorkflow = async (assets: AssetItem[]) => {
|
||||||
|
await exportMultipleWorkflows(assets)
|
||||||
|
clearSelection()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDownloadSelected = () => {
|
||||||
|
downloadMultipleAssets(selectedAssets.value)
|
||||||
|
clearSelection()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteSelected = async () => {
|
||||||
|
if (await deleteAssets(selectedAssets.value)) {
|
||||||
|
clearSelection()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleZoomClick = (asset: AssetItem) => {
|
const handleZoomClick = (asset: AssetItem) => {
|
||||||
const mediaType = getMediaTypeFromFilename(asset.name)
|
const mediaType = getMediaTypeFromFilename(asset.name)
|
||||||
|
|
||||||
@@ -519,16 +567,16 @@ const handleZoomClick = (asset: AssetItem) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const enterFolderView = (asset: AssetItem) => {
|
const enterFolderView = async (asset: AssetItem) => {
|
||||||
const metadata = getOutputAssetMetadata(asset.user_metadata)
|
const metadata = getOutputAssetMetadata(asset.user_metadata)
|
||||||
if (!metadata) {
|
if (!metadata) {
|
||||||
console.warn('Invalid output asset metadata')
|
console.warn('Invalid output asset metadata')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const { promptId, allOutputs, executionTimeInSeconds } = metadata
|
const { promptId, allOutputs, executionTimeInSeconds, outputCount } = metadata
|
||||||
|
|
||||||
if (!promptId || !Array.isArray(allOutputs) || allOutputs.length === 0) {
|
if (!promptId) {
|
||||||
console.warn('Missing required folder view data')
|
console.warn('Missing required folder view data')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -536,7 +584,48 @@ const enterFolderView = (asset: AssetItem) => {
|
|||||||
folderPromptId.value = promptId
|
folderPromptId.value = promptId
|
||||||
folderExecutionTime.value = executionTimeInSeconds
|
folderExecutionTime.value = executionTimeInSeconds
|
||||||
|
|
||||||
folderAssets.value = allOutputs.map((output) => ({
|
// Determine which outputs to display
|
||||||
|
let outputsToDisplay = allOutputs ?? []
|
||||||
|
|
||||||
|
// If outputCount indicates more outputs than we have, fetch full outputs
|
||||||
|
const needsFullOutputs =
|
||||||
|
typeof outputCount === 'number' &&
|
||||||
|
outputCount > 1 &&
|
||||||
|
outputsToDisplay.length < outputCount
|
||||||
|
|
||||||
|
if (needsFullOutputs) {
|
||||||
|
try {
|
||||||
|
const jobDetail = await getJobDetail(promptId)
|
||||||
|
if (jobDetail?.outputs) {
|
||||||
|
// Convert job outputs to ResultItemImpl array
|
||||||
|
outputsToDisplay = Object.entries(jobDetail.outputs).flatMap(
|
||||||
|
([nodeId, nodeOutputs]) =>
|
||||||
|
Object.entries(nodeOutputs).flatMap(([mediaType, items]) =>
|
||||||
|
(items as JobOutputItem[])
|
||||||
|
.map(
|
||||||
|
(item) =>
|
||||||
|
new ResultItemImpl({
|
||||||
|
...item,
|
||||||
|
nodeId,
|
||||||
|
mediaType
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.filter((r) => r.supportsPreview)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch job detail for folder view:', error)
|
||||||
|
outputsToDisplay = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (outputsToDisplay.length === 0) {
|
||||||
|
console.warn('No outputs available for folder view')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
folderAssets.value = outputsToDisplay.map((output) => ({
|
||||||
id: `${output.nodeId}-${output.filename}`,
|
id: `${output.nodeId}-${output.filename}`,
|
||||||
name: output.filename,
|
name: output.filename,
|
||||||
size: 0,
|
size: 0,
|
||||||
@@ -599,49 +688,6 @@ const copyJobId = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDownloadSelected = () => {
|
|
||||||
downloadMultipleAssets(selectedAssets.value)
|
|
||||||
clearSelection()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDeleteSelected = async () => {
|
|
||||||
const confirmed = await deleteAssets(selectedAssets.value)
|
|
||||||
if (confirmed) {
|
|
||||||
clearSelection()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleBulkDownload = (assets: AssetItem[]) => {
|
|
||||||
downloadMultipleAssets(assets)
|
|
||||||
clearSelection()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleBulkDelete = async (assets: AssetItem[]) => {
|
|
||||||
const confirmed = await deleteAssets(assets)
|
|
||||||
if (confirmed) {
|
|
||||||
clearSelection()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleBulkAddToWorkflow = async (assets: AssetItem[]) => {
|
|
||||||
await addMultipleToWorkflow(assets)
|
|
||||||
clearSelection()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleBulkOpenWorkflow = async (assets: AssetItem[]) => {
|
|
||||||
await openMultipleWorkflows(assets)
|
|
||||||
clearSelection()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleBulkExportWorkflow = async (assets: AssetItem[]) => {
|
|
||||||
await exportMultipleWorkflows(assets)
|
|
||||||
clearSelection()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleClearQueue = async () => {
|
|
||||||
await commandStore.execute('Comfy.ClearPendingTasks')
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleApproachEnd = useDebounceFn(async () => {
|
const handleApproachEnd = useDebounceFn(async () => {
|
||||||
if (
|
if (
|
||||||
activeTab.value === 'output' &&
|
activeTab.value === 'output' &&
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex h-full shrink-0 items-center gap-1">
|
<div class="flex h-full shrink-0 items-center gap-1 empty:hidden">
|
||||||
<Button
|
<Button
|
||||||
v-for="(button, index) in actionBarButtonStore.buttons"
|
v-for="(button, index) in actionBarButtonStore.buttons"
|
||||||
:key="index"
|
:key="index"
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ const transform = computed(() => {
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
ref="zoomPane"
|
ref="zoomPane"
|
||||||
class="contain-size flex place-content-center"
|
class="contain-size place-content-center"
|
||||||
@wheel="handleWheel"
|
@wheel="handleWheel"
|
||||||
@pointerdown.prevent="handleDown"
|
@pointerdown.prevent="handleDown"
|
||||||
@pointermove="handleMove"
|
@pointermove="handleMove"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<div class="base-widget-layout rounded-2xl overflow-hidden relative">
|
<div class="base-widget-layout rounded-2xl overflow-hidden relative">
|
||||||
<Button
|
<Button
|
||||||
v-show="!isRightPanelOpen && hasRightPanel"
|
v-show="!isRightPanelOpen && hasRightPanel"
|
||||||
size="icon"
|
size="lg"
|
||||||
:class="
|
:class="
|
||||||
cn('absolute top-4 right-18 z-10', 'transition-opacity duration-200', {
|
cn('absolute top-4 right-18 z-10', 'transition-opacity duration-200', {
|
||||||
'opacity-0 pointer-events-none': isRightPanelOpen || !hasRightPanel
|
'opacity-0 pointer-events-none': isRightPanelOpen || !hasRightPanel
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
"
|
"
|
||||||
@click="toggleRightPanel"
|
@click="toggleRightPanel"
|
||||||
>
|
>
|
||||||
<i class="icon-[lucide--panel-right] text-sm" />
|
<i class="icon-[lucide--panel-right]" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="lg"
|
size="lg"
|
||||||
@@ -64,7 +64,7 @@
|
|||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
v-if="isRightPanelOpen && hasRightPanel"
|
v-if="isRightPanelOpen && hasRightPanel"
|
||||||
size="icon"
|
size="lg"
|
||||||
@click="toggleRightPanel"
|
@click="toggleRightPanel"
|
||||||
>
|
>
|
||||||
<i class="icon-[lucide--panel-right-close]" />
|
<i class="icon-[lucide--panel-right-close]" />
|
||||||
@@ -90,7 +90,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<aside
|
<aside
|
||||||
v-if="hasRightPanel && isRightPanelOpen"
|
v-if="hasRightPanel && isRightPanelOpen"
|
||||||
class="w-1/4 min-w-40 max-w-80"
|
class="w-1/4 min-w-40 max-w-80 pt-16 pb-8"
|
||||||
>
|
>
|
||||||
<slot name="rightPanel"></slot>
|
<slot name="rightPanel"></slot>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -111,6 +111,10 @@ const { contentTitle } = defineProps<{
|
|||||||
contentTitle: string
|
contentTitle: string
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const isRightPanelOpen = defineModel<boolean>('rightPanelOpen', {
|
||||||
|
default: false
|
||||||
|
})
|
||||||
|
|
||||||
const BREAKPOINTS = { md: 880 }
|
const BREAKPOINTS = { md: 880 }
|
||||||
const PANEL_SIZES = {
|
const PANEL_SIZES = {
|
||||||
width: 'w-1/3',
|
width: 'w-1/3',
|
||||||
@@ -125,7 +129,6 @@ const breakpoints = useBreakpoints(BREAKPOINTS)
|
|||||||
const notMobile = breakpoints.greater('md')
|
const notMobile = breakpoints.greater('md')
|
||||||
|
|
||||||
const isLeftPanelOpen = ref<boolean>(true)
|
const isLeftPanelOpen = ref<boolean>(true)
|
||||||
const isRightPanelOpen = ref<boolean>(false)
|
|
||||||
const mobileMenuOpen = ref<boolean>(false)
|
const mobileMenuOpen = ref<boolean>(false)
|
||||||
|
|
||||||
const hasRightPanel = computed(() => !!slots.rightPanel)
|
const hasRightPanel = computed(() => !!slots.rightPanel)
|
||||||
|
|||||||
@@ -41,8 +41,8 @@ export const useCurrentUser = () => {
|
|||||||
whenever(() => authStore.tokenRefreshTrigger, callback)
|
whenever(() => authStore.tokenRefreshTrigger, callback)
|
||||||
|
|
||||||
const onUserLogout = (callback: () => void) => {
|
const onUserLogout = (callback: () => void) => {
|
||||||
watch(resolvedUserInfo, (user) => {
|
watch(resolvedUserInfo, (user, prevUser) => {
|
||||||
if (!user) callback()
|
if (prevUser && !user) callback()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
|
|
||||||
import { ref, watch } from 'vue'
|
|
||||||
|
|
||||||
type BreakpointKey = keyof typeof breakpointsTailwind
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Composable for element with responsive collapsed state
|
|
||||||
* @param breakpointThreshold - Breakpoint at which the element should become collapsible
|
|
||||||
*/
|
|
||||||
export const useResponsiveCollapse = (
|
|
||||||
breakpointThreshold: BreakpointKey = 'lg'
|
|
||||||
) => {
|
|
||||||
const breakpoints = useBreakpoints(breakpointsTailwind)
|
|
||||||
|
|
||||||
const isSmallScreen = breakpoints.smallerOrEqual(breakpointThreshold)
|
|
||||||
const isOpen = ref(!isSmallScreen.value)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles screen size changes to automatically open/close the element
|
|
||||||
* when crossing the breakpoint threshold
|
|
||||||
*/
|
|
||||||
const onIsSmallScreenChange = () => {
|
|
||||||
if (isSmallScreen.value && isOpen.value) {
|
|
||||||
isOpen.value = false
|
|
||||||
} else if (!isSmallScreen.value && !isOpen.value) {
|
|
||||||
isOpen.value = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(isSmallScreen, onIsSmallScreenChange)
|
|
||||||
|
|
||||||
return {
|
|
||||||
breakpoints,
|
|
||||||
isOpen,
|
|
||||||
isSmallScreen,
|
|
||||||
|
|
||||||
open: () => (isOpen.value = true),
|
|
||||||
close: () => (isOpen.value = false),
|
|
||||||
toggle: () => (isOpen.value = !isOpen.value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -16,7 +16,7 @@ type TestTask = {
|
|||||||
executionTime?: number
|
executionTime?: number
|
||||||
executionEndTimestamp?: number
|
executionEndTimestamp?: number
|
||||||
createTime?: number
|
createTime?: number
|
||||||
workflow?: { id?: string }
|
workflowId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const translations: Record<string, string> = {
|
const translations: Record<string, string> = {
|
||||||
@@ -161,7 +161,7 @@ const createTask = (
|
|||||||
executionTime: overrides.executionTime,
|
executionTime: overrides.executionTime,
|
||||||
executionEndTimestamp: overrides.executionEndTimestamp,
|
executionEndTimestamp: overrides.executionEndTimestamp,
|
||||||
createTime: overrides.createTime,
|
createTime: overrides.createTime,
|
||||||
workflow: overrides.workflow
|
workflowId: overrides.workflowId
|
||||||
})
|
})
|
||||||
|
|
||||||
const mountUseJobList = () => {
|
const mountUseJobList = () => {
|
||||||
@@ -305,7 +305,7 @@ describe('useJobList', () => {
|
|||||||
expect(vi.getTimerCount()).toBe(0)
|
expect(vi.getTimerCount()).toBe(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('sorts all tasks by queue index descending', async () => {
|
it('sorts all tasks by priority descending', async () => {
|
||||||
queueStoreMock.pendingTasks = [
|
queueStoreMock.pendingTasks = [
|
||||||
createTask({ promptId: 'p', queueIndex: 1, mockState: 'pending' })
|
createTask({ promptId: 'p', queueIndex: 1, mockState: 'pending' })
|
||||||
]
|
]
|
||||||
@@ -360,13 +360,13 @@ describe('useJobList', () => {
|
|||||||
promptId: 'wf-1',
|
promptId: 'wf-1',
|
||||||
queueIndex: 2,
|
queueIndex: 2,
|
||||||
mockState: 'pending',
|
mockState: 'pending',
|
||||||
workflow: { id: 'workflow-1' }
|
workflowId: 'workflow-1'
|
||||||
}),
|
}),
|
||||||
createTask({
|
createTask({
|
||||||
promptId: 'wf-2',
|
promptId: 'wf-2',
|
||||||
queueIndex: 1,
|
queueIndex: 1,
|
||||||
mockState: 'pending',
|
mockState: 'pending',
|
||||||
workflow: { id: 'workflow-2' }
|
workflowId: 'workflow-2'
|
||||||
})
|
})
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -238,7 +238,7 @@ export function useJobList() {
|
|||||||
const activeId = workflowStore.activeWorkflow?.activeState?.id
|
const activeId = workflowStore.activeWorkflow?.activeState?.id
|
||||||
if (!activeId) return []
|
if (!activeId) return []
|
||||||
entries = entries.filter(({ task }) => {
|
entries = entries.filter(({ task }) => {
|
||||||
const wid = task.workflow?.id
|
const wid = task.workflowId
|
||||||
return !!wid && wid === activeId
|
return !!wid && wid === activeId
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ vi.mock('@/scripts/utils', () => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
const dialogServiceMock = {
|
const dialogServiceMock = {
|
||||||
|
showErrorDialog: vi.fn(),
|
||||||
showExecutionErrorDialog: vi.fn(),
|
showExecutionErrorDialog: vi.fn(),
|
||||||
prompt: vi.fn()
|
prompt: vi.fn()
|
||||||
}
|
}
|
||||||
@@ -103,6 +104,11 @@ vi.mock('@/stores/queueStore', () => ({
|
|||||||
useQueueStore: () => queueStoreMock
|
useQueueStore: () => queueStoreMock
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
const getJobWorkflowMock = vi.fn()
|
||||||
|
vi.mock('@/services/jobOutputCache', () => ({
|
||||||
|
getJobWorkflow: (...args: any[]) => getJobWorkflowMock(...args)
|
||||||
|
}))
|
||||||
|
|
||||||
const createAnnotatedPathMock = vi.fn()
|
const createAnnotatedPathMock = vi.fn()
|
||||||
vi.mock('@/utils/createAnnotatedPath', () => ({
|
vi.mock('@/utils/createAnnotatedPath', () => ({
|
||||||
createAnnotatedPath: (...args: any[]) => createAnnotatedPathMock(...args)
|
createAnnotatedPath: (...args: any[]) => createAnnotatedPathMock(...args)
|
||||||
@@ -132,9 +138,7 @@ const createJobItem = (
|
|||||||
title: overrides.title ?? 'Test job',
|
title: overrides.title ?? 'Test job',
|
||||||
meta: overrides.meta ?? 'meta',
|
meta: overrides.meta ?? 'meta',
|
||||||
state: overrides.state ?? 'completed',
|
state: overrides.state ?? 'completed',
|
||||||
taskRef: overrides.taskRef as Partial<TaskItemImpl> | undefined as
|
taskRef: overrides.taskRef as TaskItemImpl | undefined,
|
||||||
| TaskItemImpl
|
|
||||||
| undefined,
|
|
||||||
iconName: overrides.iconName,
|
iconName: overrides.iconName,
|
||||||
iconImageUrl: overrides.iconImageUrl,
|
iconImageUrl: overrides.iconImageUrl,
|
||||||
showClear: overrides.showClear,
|
showClear: overrides.showClear,
|
||||||
@@ -181,6 +185,8 @@ describe('useJobMenu', () => {
|
|||||||
LoadVideo: { id: 'LoadVideo' },
|
LoadVideo: { id: 'LoadVideo' },
|
||||||
LoadAudio: { id: 'LoadAudio' }
|
LoadAudio: { id: 'LoadAudio' }
|
||||||
}
|
}
|
||||||
|
// Default: no workflow available via lazy loading
|
||||||
|
getJobWorkflowMock.mockResolvedValue(undefined)
|
||||||
})
|
})
|
||||||
|
|
||||||
const setCurrentItem = (item: JobListItem | null) => {
|
const setCurrentItem = (item: JobListItem | null) => {
|
||||||
@@ -190,10 +196,13 @@ describe('useJobMenu', () => {
|
|||||||
it('opens workflow when workflow data exists', async () => {
|
it('opens workflow when workflow data exists', async () => {
|
||||||
const { openJobWorkflow } = mountJobMenu()
|
const { openJobWorkflow } = mountJobMenu()
|
||||||
const workflow = { nodes: [] }
|
const workflow = { nodes: [] }
|
||||||
setCurrentItem(createJobItem({ id: '55', taskRef: { workflow } }))
|
// Mock lazy loading via fetchJobDetail + extractWorkflow
|
||||||
|
getJobWorkflowMock.mockResolvedValue(workflow)
|
||||||
|
setCurrentItem(createJobItem({ id: '55' }))
|
||||||
|
|
||||||
await openJobWorkflow()
|
await openJobWorkflow()
|
||||||
|
|
||||||
|
expect(getJobWorkflowMock).toHaveBeenCalledWith('55')
|
||||||
expect(workflowStoreMock.createTemporary).toHaveBeenCalledWith(
|
expect(workflowStoreMock.createTemporary).toHaveBeenCalledWith(
|
||||||
'Job 55.json',
|
'Job 55.json',
|
||||||
workflow
|
workflow
|
||||||
@@ -268,11 +277,10 @@ describe('useJobMenu', () => {
|
|||||||
|
|
||||||
it('copies error message from failed job entry', async () => {
|
it('copies error message from failed job entry', async () => {
|
||||||
const { jobMenuEntries } = mountJobMenu()
|
const { jobMenuEntries } = mountJobMenu()
|
||||||
const error = { exception_message: 'boom' }
|
|
||||||
setCurrentItem(
|
setCurrentItem(
|
||||||
createJobItem({
|
createJobItem({
|
||||||
state: 'failed',
|
state: 'failed',
|
||||||
taskRef: { status: { messages: [['execution_error', error]] } } as any
|
taskRef: { errorMessage: 'Something went wrong' } as any
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -280,31 +288,75 @@ describe('useJobMenu', () => {
|
|||||||
const entry = findActionEntry(jobMenuEntries.value, 'copy-error')
|
const entry = findActionEntry(jobMenuEntries.value, 'copy-error')
|
||||||
await entry?.onClick?.()
|
await entry?.onClick?.()
|
||||||
|
|
||||||
expect(copyToClipboardMock).toHaveBeenCalledWith('boom')
|
expect(copyToClipboardMock).toHaveBeenCalledWith('Something went wrong')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('reports error via dialog when entry triggered', async () => {
|
it('reports error via rich dialog when execution_error available', async () => {
|
||||||
|
const executionError = {
|
||||||
|
prompt_id: 'job-1',
|
||||||
|
timestamp: 12345,
|
||||||
|
node_id: '5',
|
||||||
|
node_type: 'KSampler',
|
||||||
|
executed: ['1', '2'],
|
||||||
|
exception_message: 'CUDA out of memory',
|
||||||
|
exception_type: 'RuntimeError',
|
||||||
|
traceback: ['line 1', 'line 2'],
|
||||||
|
current_inputs: {},
|
||||||
|
current_outputs: {}
|
||||||
|
}
|
||||||
const { jobMenuEntries } = mountJobMenu()
|
const { jobMenuEntries } = mountJobMenu()
|
||||||
const error = { exception_message: 'bad', extra: 1 }
|
|
||||||
setCurrentItem(
|
setCurrentItem(
|
||||||
createJobItem({
|
createJobItem({
|
||||||
state: 'failed',
|
state: 'failed',
|
||||||
taskRef: { status: { messages: [['execution_error', error]] } } as any
|
taskRef: {
|
||||||
|
errorMessage: 'CUDA out of memory',
|
||||||
|
executionError,
|
||||||
|
createTime: 12345
|
||||||
|
} as any
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
await nextTick()
|
await nextTick()
|
||||||
const entry = findActionEntry(jobMenuEntries.value, 'report-error')
|
const entry = findActionEntry(jobMenuEntries.value, 'report-error')
|
||||||
void entry?.onClick?.()
|
await entry?.onClick?.()
|
||||||
|
|
||||||
|
expect(dialogServiceMock.showExecutionErrorDialog).toHaveBeenCalledTimes(1)
|
||||||
expect(dialogServiceMock.showExecutionErrorDialog).toHaveBeenCalledWith(
|
expect(dialogServiceMock.showExecutionErrorDialog).toHaveBeenCalledWith(
|
||||||
error
|
executionError
|
||||||
)
|
)
|
||||||
|
expect(dialogServiceMock.showErrorDialog).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to simple error dialog when no execution_error', async () => {
|
||||||
|
const { jobMenuEntries } = mountJobMenu()
|
||||||
|
setCurrentItem(
|
||||||
|
createJobItem({
|
||||||
|
state: 'failed',
|
||||||
|
taskRef: { errorMessage: 'Job failed with error' } as any
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
await nextTick()
|
||||||
|
const entry = findActionEntry(jobMenuEntries.value, 'report-error')
|
||||||
|
await entry?.onClick?.()
|
||||||
|
|
||||||
|
expect(dialogServiceMock.showExecutionErrorDialog).not.toHaveBeenCalled()
|
||||||
|
expect(dialogServiceMock.showErrorDialog).toHaveBeenCalledTimes(1)
|
||||||
|
const [errorArg, optionsArg] =
|
||||||
|
dialogServiceMock.showErrorDialog.mock.calls[0]
|
||||||
|
expect(errorArg).toBeInstanceOf(Error)
|
||||||
|
expect(errorArg.message).toBe('Job failed with error')
|
||||||
|
expect(optionsArg).toEqual({ reportType: 'queueJobError' })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('ignores error actions when message missing', async () => {
|
it('ignores error actions when message missing', async () => {
|
||||||
const { jobMenuEntries } = mountJobMenu()
|
const { jobMenuEntries } = mountJobMenu()
|
||||||
setCurrentItem(createJobItem({ state: 'failed', taskRef: { status: {} } }))
|
setCurrentItem(
|
||||||
|
createJobItem({
|
||||||
|
state: 'failed',
|
||||||
|
taskRef: { errorMessage: undefined } as any
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
await nextTick()
|
await nextTick()
|
||||||
const copyEntry = findActionEntry(jobMenuEntries.value, 'copy-error')
|
const copyEntry = findActionEntry(jobMenuEntries.value, 'copy-error')
|
||||||
@@ -313,6 +365,7 @@ describe('useJobMenu', () => {
|
|||||||
await reportEntry?.onClick?.()
|
await reportEntry?.onClick?.()
|
||||||
|
|
||||||
expect(copyToClipboardMock).not.toHaveBeenCalled()
|
expect(copyToClipboardMock).not.toHaveBeenCalled()
|
||||||
|
expect(dialogServiceMock.showErrorDialog).not.toHaveBeenCalled()
|
||||||
expect(dialogServiceMock.showExecutionErrorDialog).not.toHaveBeenCalled()
|
expect(dialogServiceMock.showExecutionErrorDialog).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -488,12 +541,13 @@ describe('useJobMenu', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('exports workflow with default filename when prompting disabled', async () => {
|
it('exports workflow with default filename when prompting disabled', async () => {
|
||||||
|
const workflow = { foo: 'bar' }
|
||||||
|
getJobWorkflowMock.mockResolvedValue(workflow)
|
||||||
const { jobMenuEntries } = mountJobMenu()
|
const { jobMenuEntries } = mountJobMenu()
|
||||||
setCurrentItem(
|
setCurrentItem(
|
||||||
createJobItem({
|
createJobItem({
|
||||||
id: '7',
|
id: '7',
|
||||||
state: 'completed',
|
state: 'completed'
|
||||||
taskRef: { workflow: { foo: 'bar' } }
|
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -513,11 +567,11 @@ describe('useJobMenu', () => {
|
|||||||
it('prompts for filename when setting enabled', async () => {
|
it('prompts for filename when setting enabled', async () => {
|
||||||
settingStoreMock.get.mockReturnValue(true)
|
settingStoreMock.get.mockReturnValue(true)
|
||||||
dialogServiceMock.prompt.mockResolvedValue('custom-name')
|
dialogServiceMock.prompt.mockResolvedValue('custom-name')
|
||||||
|
getJobWorkflowMock.mockResolvedValue({})
|
||||||
const { jobMenuEntries } = mountJobMenu()
|
const { jobMenuEntries } = mountJobMenu()
|
||||||
setCurrentItem(
|
setCurrentItem(
|
||||||
createJobItem({
|
createJobItem({
|
||||||
state: 'completed',
|
state: 'completed'
|
||||||
taskRef: { workflow: {} }
|
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -537,12 +591,12 @@ describe('useJobMenu', () => {
|
|||||||
it('keeps existing json extension when exporting workflow', async () => {
|
it('keeps existing json extension when exporting workflow', async () => {
|
||||||
settingStoreMock.get.mockReturnValue(true)
|
settingStoreMock.get.mockReturnValue(true)
|
||||||
dialogServiceMock.prompt.mockResolvedValue('existing.json')
|
dialogServiceMock.prompt.mockResolvedValue('existing.json')
|
||||||
|
getJobWorkflowMock.mockResolvedValue({ foo: 'bar' })
|
||||||
const { jobMenuEntries } = mountJobMenu()
|
const { jobMenuEntries } = mountJobMenu()
|
||||||
setCurrentItem(
|
setCurrentItem(
|
||||||
createJobItem({
|
createJobItem({
|
||||||
id: '42',
|
id: '42',
|
||||||
state: 'completed',
|
state: 'completed'
|
||||||
taskRef: { workflow: { foo: 'bar' } }
|
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -558,11 +612,11 @@ describe('useJobMenu', () => {
|
|||||||
it('abandons export when prompt cancelled', async () => {
|
it('abandons export when prompt cancelled', async () => {
|
||||||
settingStoreMock.get.mockReturnValue(true)
|
settingStoreMock.get.mockReturnValue(true)
|
||||||
dialogServiceMock.prompt.mockResolvedValue('')
|
dialogServiceMock.prompt.mockResolvedValue('')
|
||||||
|
getJobWorkflowMock.mockResolvedValue({})
|
||||||
const { jobMenuEntries } = mountJobMenu()
|
const { jobMenuEntries } = mountJobMenu()
|
||||||
setCurrentItem(
|
setCurrentItem(
|
||||||
createJobItem({
|
createJobItem({
|
||||||
state: 'completed',
|
state: 'completed'
|
||||||
taskRef: { workflow: {} }
|
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -682,7 +736,12 @@ describe('useJobMenu', () => {
|
|||||||
|
|
||||||
it('returns failed menu entries with error actions', async () => {
|
it('returns failed menu entries with error actions', async () => {
|
||||||
const { jobMenuEntries } = mountJobMenu()
|
const { jobMenuEntries } = mountJobMenu()
|
||||||
setCurrentItem(createJobItem({ state: 'failed', taskRef: { status: {} } }))
|
setCurrentItem(
|
||||||
|
createJobItem({
|
||||||
|
state: 'failed',
|
||||||
|
taskRef: { errorMessage: 'Some error' } as any
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
await nextTick()
|
await nextTick()
|
||||||
expect(jobMenuEntries.value.map((entry) => entry.key)).toEqual([
|
expect(jobMenuEntries.value.map((entry) => entry.key)).toEqual([
|
||||||
|
|||||||
@@ -9,15 +9,11 @@ import { useMediaAssetActions } from '@/platform/assets/composables/useMediaAsse
|
|||||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||||
import type {
|
import type { ResultItem, ResultItemType } from '@/schemas/apiSchema'
|
||||||
ExecutionErrorWsMessage,
|
|
||||||
ResultItem,
|
|
||||||
ResultItemType,
|
|
||||||
TaskStatus
|
|
||||||
} from '@/schemas/apiSchema'
|
|
||||||
import { api } from '@/scripts/api'
|
import { api } from '@/scripts/api'
|
||||||
import { downloadBlob } from '@/scripts/utils'
|
import { downloadBlob } from '@/scripts/utils'
|
||||||
import { useDialogService } from '@/services/dialogService'
|
import { useDialogService } from '@/services/dialogService'
|
||||||
|
import { getJobWorkflow } from '@/services/jobOutputCache'
|
||||||
import { useLitegraphService } from '@/services/litegraphService'
|
import { useLitegraphService } from '@/services/litegraphService'
|
||||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||||
import { useQueueStore } from '@/stores/queueStore'
|
import { useQueueStore } from '@/stores/queueStore'
|
||||||
@@ -59,7 +55,7 @@ export function useJobMenu(
|
|||||||
const openJobWorkflow = async (item?: JobListItem | null) => {
|
const openJobWorkflow = async (item?: JobListItem | null) => {
|
||||||
const target = resolveItem(item)
|
const target = resolveItem(item)
|
||||||
if (!target) return
|
if (!target) return
|
||||||
const data = target.taskRef?.workflow
|
const data = await getJobWorkflow(target.id)
|
||||||
if (!data) return
|
if (!data) return
|
||||||
const filename = `Job ${target.id}.json`
|
const filename = `Job ${target.id}.json`
|
||||||
const temp = workflowStore.createTemporary(filename, data)
|
const temp = workflowStore.createTemporary(filename, data)
|
||||||
@@ -83,37 +79,39 @@ export function useJobMenu(
|
|||||||
await queueStore.update()
|
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 copyErrorMessage = async (item?: JobListItem | null) => {
|
||||||
const target = resolveItem(item)
|
const target = resolveItem(item)
|
||||||
if (!target) return
|
const message = target?.taskRef?.errorMessage
|
||||||
const err = findExecutionError(target.taskRef?.status?.messages)
|
if (message) await copyToClipboard(message)
|
||||||
const message = err?.exception_message
|
|
||||||
if (message) await copyToClipboard(String(message))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const reportError = (item?: JobListItem | null) => {
|
const reportError = (item?: JobListItem | null) => {
|
||||||
const target = resolveItem(item)
|
const target = resolveItem(item)
|
||||||
if (!target) return
|
if (!target) return
|
||||||
const err = findExecutionError(target.taskRef?.status?.messages)
|
|
||||||
if (err) useDialogService().showExecutionErrorDialog(err)
|
// Use execution_error from list response if available
|
||||||
|
const executionError = target.taskRef?.executionError
|
||||||
|
|
||||||
|
if (executionError) {
|
||||||
|
useDialogService().showExecutionErrorDialog(executionError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to simple error dialog
|
||||||
|
const message = target.taskRef?.errorMessage
|
||||||
|
if (message) {
|
||||||
|
useDialogService().showErrorDialog(new Error(message), {
|
||||||
|
reportType: 'queueJobError'
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// This is very magical only because it matches the respective backend implementation
|
// This is very magical only because it matches the respective backend implementation
|
||||||
// There is or will be a better way to do this
|
// There is or will be a better way to do this
|
||||||
const addOutputLoaderNode = async (item?: JobListItem | null) => {
|
const addOutputLoaderNode = async () => {
|
||||||
const target = resolveItem(item)
|
const item = currentMenuItem()
|
||||||
if (!target) return
|
if (!item) return
|
||||||
const result: ResultItemImpl | undefined = target.taskRef?.previewOutput
|
const result: ResultItemImpl | undefined = item.taskRef?.previewOutput
|
||||||
if (!result) return
|
if (!result) return
|
||||||
|
|
||||||
let nodeType: 'LoadImage' | 'LoadVideo' | 'LoadAudio' | null = null
|
let nodeType: 'LoadImage' | 'LoadVideo' | 'LoadAudio' | null = null
|
||||||
@@ -161,10 +159,10 @@ export function useJobMenu(
|
|||||||
/**
|
/**
|
||||||
* Trigger a download of the job's previewable output asset.
|
* Trigger a download of the job's previewable output asset.
|
||||||
*/
|
*/
|
||||||
const downloadPreviewAsset = (item?: JobListItem | null) => {
|
const downloadPreviewAsset = () => {
|
||||||
const target = resolveItem(item)
|
const item = currentMenuItem()
|
||||||
if (!target) return
|
if (!item) return
|
||||||
const result: ResultItemImpl | undefined = target.taskRef?.previewOutput
|
const result: ResultItemImpl | undefined = item.taskRef?.previewOutput
|
||||||
if (!result) return
|
if (!result) return
|
||||||
downloadFile(result.url)
|
downloadFile(result.url)
|
||||||
}
|
}
|
||||||
@@ -172,14 +170,14 @@ export function useJobMenu(
|
|||||||
/**
|
/**
|
||||||
* Export the workflow JSON attached to the job.
|
* Export the workflow JSON attached to the job.
|
||||||
*/
|
*/
|
||||||
const exportJobWorkflow = async (item?: JobListItem | null) => {
|
const exportJobWorkflow = async () => {
|
||||||
const target = resolveItem(item)
|
const item = currentMenuItem()
|
||||||
if (!target) return
|
if (!item) return
|
||||||
const data = target.taskRef?.workflow
|
const data = await getJobWorkflow(item.id)
|
||||||
if (!data) return
|
if (!data) return
|
||||||
|
|
||||||
const settingStore = useSettingStore()
|
const settingStore = useSettingStore()
|
||||||
let filename = `Job ${target.id}.json`
|
let filename = `Job ${item.id}.json`
|
||||||
|
|
||||||
if (settingStore.get('Comfy.PromptFilename')) {
|
if (settingStore.get('Comfy.PromptFilename')) {
|
||||||
const input = await useDialogService().prompt({
|
const input = await useDialogService().prompt({
|
||||||
@@ -196,10 +194,10 @@ export function useJobMenu(
|
|||||||
downloadBlob(filename, blob)
|
downloadBlob(filename, blob)
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteJobAsset = async (item?: JobListItem | null) => {
|
const deleteJobAsset = async () => {
|
||||||
const target = resolveItem(item)
|
const item = currentMenuItem()
|
||||||
if (!target) return
|
if (!item) return
|
||||||
const task = target.taskRef as TaskItemImpl | undefined
|
const task = item.taskRef as TaskItemImpl | undefined
|
||||||
const preview = task?.previewOutput
|
const preview = task?.previewOutput
|
||||||
if (!task || !preview) return
|
if (!task || !preview) return
|
||||||
|
|
||||||
@@ -210,8 +208,8 @@ export function useJobMenu(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const removeFailedJob = async (item?: JobListItem | null) => {
|
const removeFailedJob = async () => {
|
||||||
const task = resolveItem(item)?.taskRef as TaskItemImpl | undefined
|
const task = currentMenuItem()?.taskRef as TaskItemImpl | undefined
|
||||||
if (!task) return
|
if (!task) return
|
||||||
await queueStore.delete(task)
|
await queueStore.delete(task)
|
||||||
}
|
}
|
||||||
@@ -242,8 +240,8 @@ export function useJobMenu(
|
|||||||
icon: 'icon-[lucide--zoom-in]',
|
icon: 'icon-[lucide--zoom-in]',
|
||||||
onClick: onInspectAsset
|
onClick: onInspectAsset
|
||||||
? () => {
|
? () => {
|
||||||
const current = resolveItem()
|
const item = currentMenuItem()
|
||||||
if (current) onInspectAsset(current)
|
if (item) onInspectAsset(item)
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
},
|
},
|
||||||
@@ -254,33 +252,33 @@ export function useJobMenu(
|
|||||||
'Add to current workflow'
|
'Add to current workflow'
|
||||||
),
|
),
|
||||||
icon: 'icon-[comfy--node]',
|
icon: 'icon-[comfy--node]',
|
||||||
onClick: () => addOutputLoaderNode(resolveItem())
|
onClick: addOutputLoaderNode
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'download',
|
key: 'download',
|
||||||
label: st('queue.jobMenu.download', 'Download'),
|
label: st('queue.jobMenu.download', 'Download'),
|
||||||
icon: 'icon-[lucide--download]',
|
icon: 'icon-[lucide--download]',
|
||||||
onClick: () => downloadPreviewAsset(resolveItem())
|
onClick: downloadPreviewAsset
|
||||||
},
|
},
|
||||||
{ kind: 'divider', key: 'd1' },
|
{ kind: 'divider', key: 'd1' },
|
||||||
{
|
{
|
||||||
key: 'open-workflow',
|
key: 'open-workflow',
|
||||||
label: jobMenuOpenWorkflowLabel.value,
|
label: jobMenuOpenWorkflowLabel.value,
|
||||||
icon: 'icon-[comfy--workflow]',
|
icon: 'icon-[comfy--workflow]',
|
||||||
onClick: () => openJobWorkflow(resolveItem())
|
onClick: openJobWorkflow
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'export-workflow',
|
key: 'export-workflow',
|
||||||
label: st('queue.jobMenu.exportWorkflow', 'Export workflow'),
|
label: st('queue.jobMenu.exportWorkflow', 'Export workflow'),
|
||||||
icon: 'icon-[comfy--file-output]',
|
icon: 'icon-[comfy--file-output]',
|
||||||
onClick: () => exportJobWorkflow(resolveItem())
|
onClick: exportJobWorkflow
|
||||||
},
|
},
|
||||||
{ kind: 'divider', key: 'd2' },
|
{ kind: 'divider', key: 'd2' },
|
||||||
{
|
{
|
||||||
key: 'copy-id',
|
key: 'copy-id',
|
||||||
label: jobMenuCopyJobIdLabel.value,
|
label: jobMenuCopyJobIdLabel.value,
|
||||||
icon: 'icon-[lucide--copy]',
|
icon: 'icon-[lucide--copy]',
|
||||||
onClick: () => copyJobId(resolveItem())
|
onClick: copyJobId
|
||||||
},
|
},
|
||||||
{ kind: 'divider', key: 'd3' },
|
{ kind: 'divider', key: 'd3' },
|
||||||
...(hasDeletableAsset
|
...(hasDeletableAsset
|
||||||
@@ -289,7 +287,7 @@ export function useJobMenu(
|
|||||||
key: 'delete',
|
key: 'delete',
|
||||||
label: st('queue.jobMenu.deleteAsset', 'Delete asset'),
|
label: st('queue.jobMenu.deleteAsset', 'Delete asset'),
|
||||||
icon: 'icon-[lucide--trash-2]',
|
icon: 'icon-[lucide--trash-2]',
|
||||||
onClick: () => deleteJobAsset(resolveItem())
|
onClick: deleteJobAsset
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
: [])
|
: [])
|
||||||
@@ -301,33 +299,33 @@ export function useJobMenu(
|
|||||||
key: 'open-workflow',
|
key: 'open-workflow',
|
||||||
label: jobMenuOpenWorkflowFailedLabel.value,
|
label: jobMenuOpenWorkflowFailedLabel.value,
|
||||||
icon: 'icon-[comfy--workflow]',
|
icon: 'icon-[comfy--workflow]',
|
||||||
onClick: () => openJobWorkflow(resolveItem())
|
onClick: openJobWorkflow
|
||||||
},
|
},
|
||||||
{ kind: 'divider', key: 'd1' },
|
{ kind: 'divider', key: 'd1' },
|
||||||
{
|
{
|
||||||
key: 'copy-id',
|
key: 'copy-id',
|
||||||
label: jobMenuCopyJobIdLabel.value,
|
label: jobMenuCopyJobIdLabel.value,
|
||||||
icon: 'icon-[lucide--copy]',
|
icon: 'icon-[lucide--copy]',
|
||||||
onClick: () => copyJobId(resolveItem())
|
onClick: copyJobId
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'copy-error',
|
key: 'copy-error',
|
||||||
label: st('queue.jobMenu.copyErrorMessage', 'Copy error message'),
|
label: st('queue.jobMenu.copyErrorMessage', 'Copy error message'),
|
||||||
icon: 'icon-[lucide--copy]',
|
icon: 'icon-[lucide--copy]',
|
||||||
onClick: () => copyErrorMessage(resolveItem())
|
onClick: copyErrorMessage
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'report-error',
|
key: 'report-error',
|
||||||
label: st('queue.jobMenu.reportError', 'Report error'),
|
label: st('queue.jobMenu.reportError', 'Report error'),
|
||||||
icon: 'icon-[lucide--message-circle-warning]',
|
icon: 'icon-[lucide--message-circle-warning]',
|
||||||
onClick: () => reportError(resolveItem())
|
onClick: reportError
|
||||||
},
|
},
|
||||||
{ kind: 'divider', key: 'd2' },
|
{ kind: 'divider', key: 'd2' },
|
||||||
{
|
{
|
||||||
key: 'delete',
|
key: 'delete',
|
||||||
label: st('queue.jobMenu.removeJob', 'Remove job'),
|
label: st('queue.jobMenu.removeJob', 'Remove job'),
|
||||||
icon: 'icon-[lucide--circle-minus]',
|
icon: 'icon-[lucide--circle-minus]',
|
||||||
onClick: () => removeFailedJob(resolveItem())
|
onClick: removeFailedJob
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -336,21 +334,21 @@ export function useJobMenu(
|
|||||||
key: 'open-workflow',
|
key: 'open-workflow',
|
||||||
label: jobMenuOpenWorkflowLabel.value,
|
label: jobMenuOpenWorkflowLabel.value,
|
||||||
icon: 'icon-[comfy--workflow]',
|
icon: 'icon-[comfy--workflow]',
|
||||||
onClick: () => openJobWorkflow(resolveItem())
|
onClick: openJobWorkflow
|
||||||
},
|
},
|
||||||
{ kind: 'divider', key: 'd1' },
|
{ kind: 'divider', key: 'd1' },
|
||||||
{
|
{
|
||||||
key: 'copy-id',
|
key: 'copy-id',
|
||||||
label: jobMenuCopyJobIdLabel.value,
|
label: jobMenuCopyJobIdLabel.value,
|
||||||
icon: 'icon-[lucide--copy]',
|
icon: 'icon-[lucide--copy]',
|
||||||
onClick: () => copyJobId(resolveItem())
|
onClick: copyJobId
|
||||||
},
|
},
|
||||||
{ kind: 'divider', key: 'd2' },
|
{ kind: 'divider', key: 'd2' },
|
||||||
{
|
{
|
||||||
key: 'cancel-job',
|
key: 'cancel-job',
|
||||||
label: jobMenuCancelLabel.value,
|
label: jobMenuCancelLabel.value,
|
||||||
icon: 'icon-[lucide--x]',
|
icon: 'icon-[lucide--x]',
|
||||||
onClick: () => cancelJob(resolveItem())
|
onClick: cancelJob
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,35 +1,74 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
|
import { beforeEach, describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
import { useResultGallery } from '@/composables/queue/useResultGallery'
|
import { useResultGallery } from '@/composables/queue/useResultGallery'
|
||||||
import type { JobListItem } from '@/composables/queue/useJobList'
|
import type { JobListItem as JobListViewItem } from '@/composables/queue/useJobList'
|
||||||
|
import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||||
|
import { ResultItemImpl, TaskItemImpl } from '@/stores/queueStore'
|
||||||
|
|
||||||
type PreviewLike = { url: string; supportsPreview: boolean }
|
const createResultItem = (
|
||||||
|
url: string,
|
||||||
|
supportsPreview = true
|
||||||
|
): ResultItemImpl => {
|
||||||
|
const item = new ResultItemImpl({
|
||||||
|
filename: url,
|
||||||
|
subfolder: '',
|
||||||
|
type: 'output',
|
||||||
|
nodeId: 'node-1',
|
||||||
|
mediaType: supportsPreview ? 'images' : 'unknown'
|
||||||
|
})
|
||||||
|
// Override url getter for test matching
|
||||||
|
Object.defineProperty(item, 'url', { get: () => url })
|
||||||
|
Object.defineProperty(item, 'supportsPreview', { get: () => supportsPreview })
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
|
||||||
const createPreview = (url: string, supportsPreview = true): PreviewLike => ({
|
const createMockJob = (id: string, outputsCount = 1): JobListItem => ({
|
||||||
url,
|
id,
|
||||||
supportsPreview
|
status: 'completed',
|
||||||
|
create_time: Date.now(),
|
||||||
|
preview_output: null,
|
||||||
|
outputs_count: outputsCount,
|
||||||
|
priority: 0
|
||||||
})
|
})
|
||||||
|
|
||||||
const createTask = (preview?: PreviewLike) => ({
|
const createTask = (
|
||||||
previewOutput: preview
|
preview?: ResultItemImpl,
|
||||||
})
|
allOutputs?: ResultItemImpl[],
|
||||||
|
outputsCount = 1
|
||||||
|
): TaskItemImpl => {
|
||||||
|
const job = createMockJob(
|
||||||
|
`task-${Math.random().toString(36).slice(2)}`,
|
||||||
|
outputsCount
|
||||||
|
)
|
||||||
|
const flatOutputs = allOutputs ?? (preview ? [preview] : [])
|
||||||
|
return new TaskItemImpl(job, {}, flatOutputs)
|
||||||
|
}
|
||||||
|
|
||||||
const createJobItem = (id: string, preview?: PreviewLike): JobListItem =>
|
const createJobViewItem = (
|
||||||
|
id: string,
|
||||||
|
taskRef?: TaskItemImpl
|
||||||
|
): JobListViewItem =>
|
||||||
({
|
({
|
||||||
id,
|
id,
|
||||||
title: `Job ${id}`,
|
title: `Job ${id}`,
|
||||||
meta: '',
|
meta: '',
|
||||||
state: 'completed',
|
state: 'completed',
|
||||||
showClear: false,
|
showClear: false,
|
||||||
taskRef: preview ? { previewOutput: preview } : undefined
|
taskRef
|
||||||
}) as JobListItem
|
}) as JobListViewItem
|
||||||
|
|
||||||
describe('useResultGallery', () => {
|
describe('useResultGallery', () => {
|
||||||
it('collects only previewable outputs and preserves their order', () => {
|
beforeEach(() => {
|
||||||
const previewable = [createPreview('p-1'), createPreview('p-2')]
|
setActivePinia(createPinia())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('collects only previewable outputs and preserves their order', async () => {
|
||||||
|
const previewable = [createResultItem('p-1'), createResultItem('p-2')]
|
||||||
|
const nonPreviewable = createResultItem('skip-me', false)
|
||||||
const tasks = [
|
const tasks = [
|
||||||
createTask(previewable[0]),
|
createTask(previewable[0]),
|
||||||
createTask({ url: 'skip-me', supportsPreview: false }),
|
createTask(nonPreviewable),
|
||||||
createTask(previewable[1]),
|
createTask(previewable[1]),
|
||||||
createTask()
|
createTask()
|
||||||
]
|
]
|
||||||
@@ -38,28 +77,28 @@ describe('useResultGallery', () => {
|
|||||||
() => tasks
|
() => tasks
|
||||||
)
|
)
|
||||||
|
|
||||||
onViewItem(createJobItem('job-1', previewable[0]))
|
await onViewItem(createJobViewItem('job-1', tasks[0]))
|
||||||
|
|
||||||
expect(galleryItems.value).toEqual(previewable)
|
expect(galleryItems.value).toEqual([previewable[0]])
|
||||||
expect(galleryActiveIndex.value).toBe(0)
|
expect(galleryActiveIndex.value).toBe(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('does not change state when there are no previewable tasks', () => {
|
it('does not change state when there are no previewable tasks', async () => {
|
||||||
const { galleryItems, galleryActiveIndex, onViewItem } = useResultGallery(
|
const { galleryItems, galleryActiveIndex, onViewItem } = useResultGallery(
|
||||||
() => []
|
() => []
|
||||||
)
|
)
|
||||||
|
|
||||||
onViewItem(createJobItem('job-missing'))
|
await onViewItem(createJobViewItem('job-missing'))
|
||||||
|
|
||||||
expect(galleryItems.value).toEqual([])
|
expect(galleryItems.value).toEqual([])
|
||||||
expect(galleryActiveIndex.value).toBe(-1)
|
expect(galleryActiveIndex.value).toBe(-1)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('activates the index that matches the viewed preview URL', () => {
|
it('activates the index that matches the viewed preview URL', async () => {
|
||||||
const previewable = [
|
const previewable = [
|
||||||
createPreview('p-1'),
|
createResultItem('p-1'),
|
||||||
createPreview('p-2'),
|
createResultItem('p-2'),
|
||||||
createPreview('p-3')
|
createResultItem('p-3')
|
||||||
]
|
]
|
||||||
const tasks = previewable.map((preview) => createTask(preview))
|
const tasks = previewable.map((preview) => createTask(preview))
|
||||||
|
|
||||||
@@ -67,37 +106,66 @@ describe('useResultGallery', () => {
|
|||||||
() => tasks
|
() => tasks
|
||||||
)
|
)
|
||||||
|
|
||||||
onViewItem(createJobItem('job-2', createPreview('p-2')))
|
await onViewItem(createJobViewItem('job-2', tasks[1]))
|
||||||
|
|
||||||
expect(galleryItems.value).toEqual(previewable)
|
expect(galleryItems.value).toEqual([previewable[1]])
|
||||||
expect(galleryActiveIndex.value).toBe(1)
|
expect(galleryActiveIndex.value).toBe(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('defaults to the first entry when the clicked job lacks a preview', () => {
|
it('defaults to the first entry when the clicked job lacks a preview', async () => {
|
||||||
const previewable = [createPreview('p-1'), createPreview('p-2')]
|
const previewable = [createResultItem('p-1'), createResultItem('p-2')]
|
||||||
const tasks = previewable.map((preview) => createTask(preview))
|
const tasks = previewable.map((preview) => createTask(preview))
|
||||||
|
|
||||||
const { galleryItems, galleryActiveIndex, onViewItem } = useResultGallery(
|
const { galleryItems, galleryActiveIndex, onViewItem } = useResultGallery(
|
||||||
() => tasks
|
() => tasks
|
||||||
)
|
)
|
||||||
|
|
||||||
onViewItem(createJobItem('job-no-preview'))
|
await onViewItem(createJobViewItem('job-no-preview'))
|
||||||
|
|
||||||
expect(galleryItems.value).toEqual(previewable)
|
expect(galleryItems.value).toEqual(previewable)
|
||||||
expect(galleryActiveIndex.value).toBe(0)
|
expect(galleryActiveIndex.value).toBe(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('defaults to the first entry when no gallery item matches the preview URL', () => {
|
it('defaults to the first entry when no gallery item matches the preview URL', async () => {
|
||||||
const previewable = [createPreview('p-1'), createPreview('p-2')]
|
const previewable = [createResultItem('p-1'), createResultItem('p-2')]
|
||||||
const tasks = previewable.map((preview) => createTask(preview))
|
const tasks = previewable.map((preview) => createTask(preview))
|
||||||
|
|
||||||
const { galleryItems, galleryActiveIndex, onViewItem } = useResultGallery(
|
const { galleryItems, galleryActiveIndex, onViewItem } = useResultGallery(
|
||||||
() => tasks
|
() => tasks
|
||||||
)
|
)
|
||||||
|
|
||||||
onViewItem(createJobItem('job-mismatch', createPreview('missing')))
|
const taskWithMismatchedPreview = createTask(createResultItem('missing'))
|
||||||
|
await onViewItem(
|
||||||
|
createJobViewItem('job-mismatch', taskWithMismatchedPreview)
|
||||||
|
)
|
||||||
|
|
||||||
expect(galleryItems.value).toEqual(previewable)
|
expect(galleryItems.value).toEqual([createResultItem('missing')])
|
||||||
|
expect(galleryActiveIndex.value).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loads full outputs when task has only preview outputs', async () => {
|
||||||
|
const previewOutput = createResultItem('preview-1')
|
||||||
|
const fullOutputs = [
|
||||||
|
createResultItem('full-1'),
|
||||||
|
createResultItem('full-2'),
|
||||||
|
createResultItem('full-3')
|
||||||
|
]
|
||||||
|
|
||||||
|
// Create a task with outputsCount > 1 to trigger lazy loading
|
||||||
|
const job = createMockJob('task-1', 3)
|
||||||
|
const task = new TaskItemImpl(job, {}, [previewOutput])
|
||||||
|
|
||||||
|
// Mock loadFullOutputs to return full outputs
|
||||||
|
const loadedTask = new TaskItemImpl(job, {}, fullOutputs)
|
||||||
|
task.loadFullOutputs = async () => loadedTask
|
||||||
|
|
||||||
|
const { galleryItems, galleryActiveIndex, onViewItem } = useResultGallery(
|
||||||
|
() => [task]
|
||||||
|
)
|
||||||
|
|
||||||
|
await onViewItem(createJobViewItem('job-1', task))
|
||||||
|
|
||||||
|
expect(galleryItems.value).toEqual(fullOutputs)
|
||||||
expect(galleryActiveIndex.value).toBe(0)
|
expect(galleryActiveIndex.value).toBe(0)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,39 +1,42 @@
|
|||||||
import { ref, shallowRef } from 'vue'
|
import { ref, shallowRef } from 'vue'
|
||||||
|
|
||||||
import type { JobListItem } from '@/composables/queue/useJobList'
|
import type { JobListItem } from '@/composables/queue/useJobList'
|
||||||
|
import { findActiveIndex, getOutputsForTask } from '@/services/jobOutputCache'
|
||||||
/** Minimal preview item interface for gallery filtering. */
|
import type { ResultItemImpl, TaskItemImpl } from '@/stores/queueStore'
|
||||||
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.
|
* Manages result gallery state and activation for queue items.
|
||||||
*/
|
*/
|
||||||
export function useResultGallery<T extends PreviewItem>(
|
export function useResultGallery(getFilteredTasks: () => TaskItemImpl[]) {
|
||||||
getFilteredTasks: () => TaskWithPreview<T>[]
|
|
||||||
) {
|
|
||||||
const galleryActiveIndex = ref(-1)
|
const galleryActiveIndex = ref(-1)
|
||||||
const galleryItems = shallowRef<T[]>([])
|
const galleryItems = shallowRef<ResultItemImpl[]>([])
|
||||||
|
|
||||||
const onViewItem = (item: JobListItem) => {
|
async function onViewItem(item: JobListItem) {
|
||||||
const items: T[] = getFilteredTasks().flatMap((t) => {
|
const tasks = getFilteredTasks()
|
||||||
const preview = t.previewOutput
|
if (!tasks.length) return
|
||||||
return preview && preview.supportsPreview ? [preview] : []
|
|
||||||
})
|
const targetTask = item.taskRef
|
||||||
|
const targetOutputs = targetTask
|
||||||
|
? await getOutputsForTask(targetTask)
|
||||||
|
: null
|
||||||
|
|
||||||
|
// Request was superseded by a newer one
|
||||||
|
if (targetOutputs === null && targetTask) return
|
||||||
|
|
||||||
|
// Use target's outputs if available, otherwise fall back to all previews
|
||||||
|
const items = targetOutputs?.length
|
||||||
|
? targetOutputs
|
||||||
|
: tasks
|
||||||
|
.map((t) => t.previewOutput)
|
||||||
|
.filter((o): o is ResultItemImpl => !!o)
|
||||||
|
|
||||||
if (!items.length) return
|
if (!items.length) return
|
||||||
|
|
||||||
galleryItems.value = items
|
galleryItems.value = items
|
||||||
const activeUrl: string | undefined = item.taskRef?.previewOutput?.url
|
galleryActiveIndex.value = findActiveIndex(
|
||||||
const idx = activeUrl ? items.findIndex((o) => o.url === activeUrl) : 0
|
items,
|
||||||
galleryActiveIndex.value = idx >= 0 ? idx : 0
|
item.taskRef?.previewOutput?.url
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ export enum ServerFeatureFlag {
|
|||||||
ONBOARDING_SURVEY_ENABLED = 'onboarding_survey_enabled',
|
ONBOARDING_SURVEY_ENABLED = 'onboarding_survey_enabled',
|
||||||
HUGGINGFACE_MODEL_IMPORT_ENABLED = 'huggingface_model_import_enabled',
|
HUGGINGFACE_MODEL_IMPORT_ENABLED = 'huggingface_model_import_enabled',
|
||||||
LINEAR_TOGGLE_ENABLED = 'linear_toggle_enabled',
|
LINEAR_TOGGLE_ENABLED = 'linear_toggle_enabled',
|
||||||
ASYNC_MODEL_UPLOAD_ENABLED = 'async_model_upload_enabled'
|
ASYNC_MODEL_UPLOAD_ENABLED = 'async_model_upload_enabled',
|
||||||
|
TEAM_WORKSPACES_ENABLED = 'team_workspaces_enabled'
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -92,6 +93,12 @@ export function useFeatureFlags() {
|
|||||||
false
|
false
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
},
|
||||||
|
get teamWorkspacesEnabled() {
|
||||||
|
return (
|
||||||
|
remoteConfig.value.team_workspaces_enabled ??
|
||||||
|
api.getServerFeature(ServerFeatureFlag.TEAM_WORKSPACES_ENABLED, false)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMuta
|
|||||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||||
import { removeNodeTitleHeight } from '@/renderer/core/layout/utils/nodeSizeUtil'
|
import { removeNodeTitleHeight } from '@/renderer/core/layout/utils/nodeSizeUtil'
|
||||||
|
import { forEachNode } from '@/utils/graphTraversalUtil'
|
||||||
|
|
||||||
import { CanvasPointer } from './CanvasPointer'
|
import { CanvasPointer } from './CanvasPointer'
|
||||||
import type { ContextMenu } from './ContextMenu'
|
import type { ContextMenu } from './ContextMenu'
|
||||||
@@ -4057,6 +4058,8 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
|||||||
layoutStore.batchUpdateNodeBounds(newPositions)
|
layoutStore.batchUpdateNodeBounds(newPositions)
|
||||||
|
|
||||||
this.selectItems(created)
|
this.selectItems(created)
|
||||||
|
forEachNode(graph, (n) => n.onGraphConfigured?.())
|
||||||
|
forEachNode(graph, (n) => n.onAfterGraphConfigured?.())
|
||||||
|
|
||||||
graph.afterChange()
|
graph.afterChange()
|
||||||
this.emitAfterChange()
|
this.emitAfterChange()
|
||||||
|
|||||||
@@ -848,6 +848,7 @@
|
|||||||
"releaseTitle": "إصدار {package} {version}",
|
"releaseTitle": "إصدار {package} {version}",
|
||||||
"reloadToApplyChanges": "أعد التحميل لتطبيق التغييرات",
|
"reloadToApplyChanges": "أعد التحميل لتطبيق التغييرات",
|
||||||
"removeImage": "إزالة الصورة",
|
"removeImage": "إزالة الصورة",
|
||||||
|
"removeTag": "إزالة الوسم",
|
||||||
"removeVideo": "إزالة الفيديو",
|
"removeVideo": "إزالة الفيديو",
|
||||||
"rename": "إعادة تسمية",
|
"rename": "إعادة تسمية",
|
||||||
"reportIssue": "إرسال تقرير",
|
"reportIssue": "إرسال تقرير",
|
||||||
@@ -2575,6 +2576,21 @@
|
|||||||
"exportWorkflow": "تصدير سير العمل",
|
"exportWorkflow": "تصدير سير العمل",
|
||||||
"saveWorkflow": "حفظ سير العمل"
|
"saveWorkflow": "حفظ سير العمل"
|
||||||
},
|
},
|
||||||
|
"workspace": {
|
||||||
|
"unsavedChanges": {
|
||||||
|
"message": "لديك تغييرات غير محفوظة. هل تريد تجاهلها والانتقال إلى مساحة عمل أخرى؟",
|
||||||
|
"title": "تغييرات غير محفوظة"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"workspaceAuth": {
|
||||||
|
"errors": {
|
||||||
|
"accessDenied": "ليس لديك صلاحية الوصول إلى هذه مساحة العمل",
|
||||||
|
"invalidFirebaseToken": "فشل التحقق من الهوية. يرجى محاولة تسجيل الدخول مرة أخرى.",
|
||||||
|
"notAuthenticated": "يجب تسجيل الدخول للوصول إلى مساحات العمل",
|
||||||
|
"tokenExchangeFailed": "فشل التحقق من مساحة العمل: {error}",
|
||||||
|
"workspaceNotFound": "لم يتم العثور على مساحة العمل"
|
||||||
|
}
|
||||||
|
},
|
||||||
"zoomControls": {
|
"zoomControls": {
|
||||||
"hideMinimap": "إخفاء الخريطة المصغرة",
|
"hideMinimap": "إخفاء الخريطة المصغرة",
|
||||||
"label": "عناصر التحكم في التكبير",
|
"label": "عناصر التحكم في التكبير",
|
||||||
|
|||||||
@@ -273,7 +273,7 @@
|
|||||||
"noItems": "No items"
|
"noItems": "No items"
|
||||||
},
|
},
|
||||||
"manager": {
|
"manager": {
|
||||||
"title": "Custom Nodes Manager",
|
"title": "Nodes Manager",
|
||||||
"legacyMenuNotAvailable": "Legacy manager menu is not available, defaulting to the new manager menu.",
|
"legacyMenuNotAvailable": "Legacy manager menu is not available, defaulting to the new manager menu.",
|
||||||
"legacyManagerUI": "Use Legacy UI",
|
"legacyManagerUI": "Use Legacy UI",
|
||||||
"legacyManagerUIDescription": "To use the legacy Manager UI, start ComfyUI with --enable-manager-legacy-ui",
|
"legacyManagerUIDescription": "To use the legacy Manager UI, start ComfyUI with --enable-manager-legacy-ui",
|
||||||
@@ -2496,7 +2496,7 @@
|
|||||||
},
|
},
|
||||||
"linearMode": {
|
"linearMode": {
|
||||||
"linearMode": "Simple Mode",
|
"linearMode": "Simple Mode",
|
||||||
"beta": "Beta - Give Feedback",
|
"beta": "Simple Mode in Beta - Feedback",
|
||||||
"graphMode": "Graph Mode",
|
"graphMode": "Graph Mode",
|
||||||
"dragAndDropImage": "Click to browse or drag an image",
|
"dragAndDropImage": "Click to browse or drag an image",
|
||||||
"runCount": "Run count:",
|
"runCount": "Run count:",
|
||||||
@@ -2592,5 +2592,20 @@
|
|||||||
"completed": "Completed",
|
"completed": "Completed",
|
||||||
"failed": "Failed"
|
"failed": "Failed"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"workspace": {
|
||||||
|
"unsavedChanges": {
|
||||||
|
"title": "Unsaved Changes",
|
||||||
|
"message": "You have unsaved changes. Do you want to discard them and switch workspaces?"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"workspaceAuth": {
|
||||||
|
"errors": {
|
||||||
|
"notAuthenticated": "You must be logged in to access workspaces",
|
||||||
|
"invalidFirebaseToken": "Authentication failed. Please try logging in again.",
|
||||||
|
"accessDenied": "You do not have access to this workspace",
|
||||||
|
"workspaceNotFound": "Workspace not found",
|
||||||
|
"tokenExchangeFailed": "Failed to authenticate with workspace: {error}"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -848,6 +848,7 @@
|
|||||||
"releaseTitle": "Lanzamiento de {package} {version}",
|
"releaseTitle": "Lanzamiento de {package} {version}",
|
||||||
"reloadToApplyChanges": "Recargar para aplicar cambios",
|
"reloadToApplyChanges": "Recargar para aplicar cambios",
|
||||||
"removeImage": "Eliminar imagen",
|
"removeImage": "Eliminar imagen",
|
||||||
|
"removeTag": "Eliminar etiqueta",
|
||||||
"removeVideo": "Eliminar video",
|
"removeVideo": "Eliminar video",
|
||||||
"rename": "Renombrar",
|
"rename": "Renombrar",
|
||||||
"reportIssue": "Enviar informe",
|
"reportIssue": "Enviar informe",
|
||||||
@@ -2575,6 +2576,21 @@
|
|||||||
"exportWorkflow": "Exportar flujo de trabajo",
|
"exportWorkflow": "Exportar flujo de trabajo",
|
||||||
"saveWorkflow": "Guardar flujo de trabajo"
|
"saveWorkflow": "Guardar flujo de trabajo"
|
||||||
},
|
},
|
||||||
|
"workspace": {
|
||||||
|
"unsavedChanges": {
|
||||||
|
"message": "Tienes cambios no guardados. ¿Quieres descartarlos y cambiar de espacio de trabajo?",
|
||||||
|
"title": "Cambios no guardados"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"workspaceAuth": {
|
||||||
|
"errors": {
|
||||||
|
"accessDenied": "No tienes acceso a este espacio de trabajo",
|
||||||
|
"invalidFirebaseToken": "La autenticación ha fallado. Por favor, intenta iniciar sesión de nuevo.",
|
||||||
|
"notAuthenticated": "Debes iniciar sesión para acceder a los espacios de trabajo",
|
||||||
|
"tokenExchangeFailed": "No se pudo autenticar con el espacio de trabajo: {error}",
|
||||||
|
"workspaceNotFound": "Espacio de trabajo no encontrado"
|
||||||
|
}
|
||||||
|
},
|
||||||
"zoomControls": {
|
"zoomControls": {
|
||||||
"hideMinimap": "Ocultar minimapa",
|
"hideMinimap": "Ocultar minimapa",
|
||||||
"label": "Controles de zoom",
|
"label": "Controles de zoom",
|
||||||
|
|||||||
@@ -848,6 +848,7 @@
|
|||||||
"releaseTitle": "انتشار {package} نسخه {version}",
|
"releaseTitle": "انتشار {package} نسخه {version}",
|
||||||
"reloadToApplyChanges": "برای اعمال تغییرات بارگذاری مجدد کنید",
|
"reloadToApplyChanges": "برای اعمال تغییرات بارگذاری مجدد کنید",
|
||||||
"removeImage": "حذف تصویر",
|
"removeImage": "حذف تصویر",
|
||||||
|
"removeTag": "حذف برچسب",
|
||||||
"removeVideo": "حذف ویدیو",
|
"removeVideo": "حذف ویدیو",
|
||||||
"rename": "تغییر نام",
|
"rename": "تغییر نام",
|
||||||
"reportIssue": "ارسال گزارش",
|
"reportIssue": "ارسال گزارش",
|
||||||
@@ -2586,6 +2587,21 @@
|
|||||||
"exportWorkflow": "خروجی گرفتن از workflow",
|
"exportWorkflow": "خروجی گرفتن از workflow",
|
||||||
"saveWorkflow": "ذخیره workflow"
|
"saveWorkflow": "ذخیره workflow"
|
||||||
},
|
},
|
||||||
|
"workspace": {
|
||||||
|
"unsavedChanges": {
|
||||||
|
"message": "شما تغییرات ذخیرهنشده دارید. آیا میخواهید آنها را رها کرده و فضای کاری را تغییر دهید؟",
|
||||||
|
"title": "تغییرات ذخیرهنشده"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"workspaceAuth": {
|
||||||
|
"errors": {
|
||||||
|
"accessDenied": "شما به این فضای کاری دسترسی ندارید.",
|
||||||
|
"invalidFirebaseToken": "احراز هویت ناموفق بود. لطفاً دوباره وارد شوید.",
|
||||||
|
"notAuthenticated": "برای دسترسی به فضاهای کاری باید وارد شوید.",
|
||||||
|
"tokenExchangeFailed": "احراز هویت با فضای کاری ناموفق بود: {error}",
|
||||||
|
"workspaceNotFound": "فضای کاری پیدا نشد."
|
||||||
|
}
|
||||||
|
},
|
||||||
"zoomControls": {
|
"zoomControls": {
|
||||||
"hideMinimap": "مخفیسازی نقشه کوچک",
|
"hideMinimap": "مخفیسازی نقشه کوچک",
|
||||||
"label": "کنترلهای بزرگنمایی",
|
"label": "کنترلهای بزرگنمایی",
|
||||||
|
|||||||
@@ -848,6 +848,7 @@
|
|||||||
"releaseTitle": "Publication de {package} {version}",
|
"releaseTitle": "Publication de {package} {version}",
|
||||||
"reloadToApplyChanges": "Recharger pour appliquer les modifications",
|
"reloadToApplyChanges": "Recharger pour appliquer les modifications",
|
||||||
"removeImage": "Supprimer l'image",
|
"removeImage": "Supprimer l'image",
|
||||||
|
"removeTag": "Supprimer le tag",
|
||||||
"removeVideo": "Supprimer la vidéo",
|
"removeVideo": "Supprimer la vidéo",
|
||||||
"rename": "Renommer",
|
"rename": "Renommer",
|
||||||
"reportIssue": "Envoyer le rapport",
|
"reportIssue": "Envoyer le rapport",
|
||||||
@@ -2575,6 +2576,21 @@
|
|||||||
"exportWorkflow": "Exporter le flux de travail",
|
"exportWorkflow": "Exporter le flux de travail",
|
||||||
"saveWorkflow": "Enregistrer le flux de travail"
|
"saveWorkflow": "Enregistrer le flux de travail"
|
||||||
},
|
},
|
||||||
|
"workspace": {
|
||||||
|
"unsavedChanges": {
|
||||||
|
"message": "Vous avez des modifications non enregistrées. Voulez-vous les abandonner et changer d’espace de travail ?",
|
||||||
|
"title": "Modifications non enregistrées"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"workspaceAuth": {
|
||||||
|
"errors": {
|
||||||
|
"accessDenied": "Vous n’avez pas accès à cet espace de travail",
|
||||||
|
"invalidFirebaseToken": "Échec de l’authentification. Veuillez vous reconnecter.",
|
||||||
|
"notAuthenticated": "Vous devez être connecté pour accéder aux espaces de travail",
|
||||||
|
"tokenExchangeFailed": "Échec de l’authentification avec l’espace de travail : {error}",
|
||||||
|
"workspaceNotFound": "Espace de travail introuvable"
|
||||||
|
}
|
||||||
|
},
|
||||||
"zoomControls": {
|
"zoomControls": {
|
||||||
"hideMinimap": "Masquer la mini-carte",
|
"hideMinimap": "Masquer la mini-carte",
|
||||||
"label": "Contrôles de zoom",
|
"label": "Contrôles de zoom",
|
||||||
|
|||||||
@@ -848,6 +848,7 @@
|
|||||||
"releaseTitle": "{package} {version} リリース",
|
"releaseTitle": "{package} {version} リリース",
|
||||||
"reloadToApplyChanges": "変更を適用するには再読み込みしてください",
|
"reloadToApplyChanges": "変更を適用するには再読み込みしてください",
|
||||||
"removeImage": "画像を削除",
|
"removeImage": "画像を削除",
|
||||||
|
"removeTag": "タグを削除",
|
||||||
"removeVideo": "ビデオを削除",
|
"removeVideo": "ビデオを削除",
|
||||||
"rename": "名前を変更",
|
"rename": "名前を変更",
|
||||||
"reportIssue": "報告する",
|
"reportIssue": "報告する",
|
||||||
@@ -2575,6 +2576,21 @@
|
|||||||
"exportWorkflow": "ワークフローをエクスポート",
|
"exportWorkflow": "ワークフローをエクスポート",
|
||||||
"saveWorkflow": "ワークフローを保存"
|
"saveWorkflow": "ワークフローを保存"
|
||||||
},
|
},
|
||||||
|
"workspace": {
|
||||||
|
"unsavedChanges": {
|
||||||
|
"message": "未保存の変更があります。破棄してワークスペースを切り替えますか?",
|
||||||
|
"title": "未保存の変更"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"workspaceAuth": {
|
||||||
|
"errors": {
|
||||||
|
"accessDenied": "このワークスペースへのアクセス権がありません",
|
||||||
|
"invalidFirebaseToken": "認証に失敗しました。もう一度ログインしてください。",
|
||||||
|
"notAuthenticated": "ワークスペースにアクセスするにはログインが必要です",
|
||||||
|
"tokenExchangeFailed": "ワークスペースの認証に失敗しました: {error}",
|
||||||
|
"workspaceNotFound": "ワークスペースが見つかりません"
|
||||||
|
}
|
||||||
|
},
|
||||||
"zoomControls": {
|
"zoomControls": {
|
||||||
"hideMinimap": "ミニマップを非表示",
|
"hideMinimap": "ミニマップを非表示",
|
||||||
"label": "ズームコントロール",
|
"label": "ズームコントロール",
|
||||||
|
|||||||
@@ -848,6 +848,7 @@
|
|||||||
"releaseTitle": "{package} {version} 릴리스",
|
"releaseTitle": "{package} {version} 릴리스",
|
||||||
"reloadToApplyChanges": "변경 사항을 적용하려면 새로 고침하세요.",
|
"reloadToApplyChanges": "변경 사항을 적용하려면 새로 고침하세요.",
|
||||||
"removeImage": "이미지 제거",
|
"removeImage": "이미지 제거",
|
||||||
|
"removeTag": "태그 제거",
|
||||||
"removeVideo": "비디오 제거",
|
"removeVideo": "비디오 제거",
|
||||||
"rename": "이름 바꾸기",
|
"rename": "이름 바꾸기",
|
||||||
"reportIssue": "보고서 보내기",
|
"reportIssue": "보고서 보내기",
|
||||||
@@ -2575,6 +2576,21 @@
|
|||||||
"exportWorkflow": "워크플로 내보내기",
|
"exportWorkflow": "워크플로 내보내기",
|
||||||
"saveWorkflow": "워크플로 저장"
|
"saveWorkflow": "워크플로 저장"
|
||||||
},
|
},
|
||||||
|
"workspace": {
|
||||||
|
"unsavedChanges": {
|
||||||
|
"message": "저장되지 않은 변경 사항이 있습니다. 변경 사항을 취소하고 워크스페이스를 전환하시겠습니까?",
|
||||||
|
"title": "저장되지 않은 변경 사항"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"workspaceAuth": {
|
||||||
|
"errors": {
|
||||||
|
"accessDenied": "이 워크스페이스에 접근할 수 없습니다.",
|
||||||
|
"invalidFirebaseToken": "인증에 실패했습니다. 다시 로그인해 주세요.",
|
||||||
|
"notAuthenticated": "워크스페이스에 접근하려면 로그인해야 합니다.",
|
||||||
|
"tokenExchangeFailed": "워크스페이스 인증에 실패했습니다: {error}",
|
||||||
|
"workspaceNotFound": "워크스페이스를 찾을 수 없습니다."
|
||||||
|
}
|
||||||
|
},
|
||||||
"zoomControls": {
|
"zoomControls": {
|
||||||
"hideMinimap": "미니맵 숨기기",
|
"hideMinimap": "미니맵 숨기기",
|
||||||
"label": "줌 컨트롤",
|
"label": "줌 컨트롤",
|
||||||
|
|||||||
@@ -848,6 +848,7 @@
|
|||||||
"releaseTitle": "Lançamento {package} {version}",
|
"releaseTitle": "Lançamento {package} {version}",
|
||||||
"reloadToApplyChanges": "Recarregue para aplicar as alterações",
|
"reloadToApplyChanges": "Recarregue para aplicar as alterações",
|
||||||
"removeImage": "Remover imagem",
|
"removeImage": "Remover imagem",
|
||||||
|
"removeTag": "Remover tag",
|
||||||
"removeVideo": "Remover vídeo",
|
"removeVideo": "Remover vídeo",
|
||||||
"rename": "Renomear",
|
"rename": "Renomear",
|
||||||
"reportIssue": "Enviar relatório",
|
"reportIssue": "Enviar relatório",
|
||||||
@@ -2586,6 +2587,21 @@
|
|||||||
"exportWorkflow": "Exportar Fluxo de Trabalho",
|
"exportWorkflow": "Exportar Fluxo de Trabalho",
|
||||||
"saveWorkflow": "Salvar fluxo de trabalho"
|
"saveWorkflow": "Salvar fluxo de trabalho"
|
||||||
},
|
},
|
||||||
|
"workspace": {
|
||||||
|
"unsavedChanges": {
|
||||||
|
"message": "Você tem alterações não salvas. Deseja descartá-las e trocar de espaço de trabalho?",
|
||||||
|
"title": "Alterações não salvas"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"workspaceAuth": {
|
||||||
|
"errors": {
|
||||||
|
"accessDenied": "Você não tem acesso a este espaço de trabalho",
|
||||||
|
"invalidFirebaseToken": "Falha na autenticação. Por favor, tente fazer login novamente.",
|
||||||
|
"notAuthenticated": "Você precisa estar logado para acessar os espaços de trabalho",
|
||||||
|
"tokenExchangeFailed": "Falha ao autenticar com o espaço de trabalho: {error}",
|
||||||
|
"workspaceNotFound": "Espaço de trabalho não encontrado"
|
||||||
|
}
|
||||||
|
},
|
||||||
"zoomControls": {
|
"zoomControls": {
|
||||||
"hideMinimap": "Ocultar Minimapa",
|
"hideMinimap": "Ocultar Minimapa",
|
||||||
"label": "Controles de Zoom",
|
"label": "Controles de Zoom",
|
||||||
|
|||||||
@@ -848,6 +848,7 @@
|
|||||||
"releaseTitle": "Релиз {package} {version}",
|
"releaseTitle": "Релиз {package} {version}",
|
||||||
"reloadToApplyChanges": "Перезагрузите, чтобы применить изменения",
|
"reloadToApplyChanges": "Перезагрузите, чтобы применить изменения",
|
||||||
"removeImage": "Удалить изображение",
|
"removeImage": "Удалить изображение",
|
||||||
|
"removeTag": "Удалить тег",
|
||||||
"removeVideo": "Удалить видео",
|
"removeVideo": "Удалить видео",
|
||||||
"rename": "Переименовать",
|
"rename": "Переименовать",
|
||||||
"reportIssue": "Отправить отчёт",
|
"reportIssue": "Отправить отчёт",
|
||||||
@@ -2575,6 +2576,21 @@
|
|||||||
"exportWorkflow": "Экспорт рабочего процесса",
|
"exportWorkflow": "Экспорт рабочего процесса",
|
||||||
"saveWorkflow": "Сохранить рабочий процесс"
|
"saveWorkflow": "Сохранить рабочий процесс"
|
||||||
},
|
},
|
||||||
|
"workspace": {
|
||||||
|
"unsavedChanges": {
|
||||||
|
"message": "У вас есть несохранённые изменения. Хотите их отменить и переключиться на другое рабочее пространство?",
|
||||||
|
"title": "Несохранённые изменения"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"workspaceAuth": {
|
||||||
|
"errors": {
|
||||||
|
"accessDenied": "У вас нет доступа к этому рабочему пространству",
|
||||||
|
"invalidFirebaseToken": "Ошибка аутентификации. Пожалуйста, попробуйте войти снова.",
|
||||||
|
"notAuthenticated": "Вы должны войти в систему, чтобы получить доступ к рабочим пространствам",
|
||||||
|
"tokenExchangeFailed": "Не удалось выполнить аутентификацию с рабочим пространством: {error}",
|
||||||
|
"workspaceNotFound": "Рабочее пространство не найдено"
|
||||||
|
}
|
||||||
|
},
|
||||||
"zoomControls": {
|
"zoomControls": {
|
||||||
"hideMinimap": "Скрыть миникарту",
|
"hideMinimap": "Скрыть миникарту",
|
||||||
"label": "Управление масштабом",
|
"label": "Управление масштабом",
|
||||||
|
|||||||
@@ -848,6 +848,7 @@
|
|||||||
"releaseTitle": "{package} {version} Sürümü",
|
"releaseTitle": "{package} {version} Sürümü",
|
||||||
"reloadToApplyChanges": "Değişiklikleri uygulamak için yeniden yükleyin",
|
"reloadToApplyChanges": "Değişiklikleri uygulamak için yeniden yükleyin",
|
||||||
"removeImage": "Görüntüyü kaldır",
|
"removeImage": "Görüntüyü kaldır",
|
||||||
|
"removeTag": "Etiketi kaldır",
|
||||||
"removeVideo": "Videoyu kaldır",
|
"removeVideo": "Videoyu kaldır",
|
||||||
"rename": "Yeniden Adlandır",
|
"rename": "Yeniden Adlandır",
|
||||||
"reportIssue": "Rapor Gönder",
|
"reportIssue": "Rapor Gönder",
|
||||||
@@ -2575,6 +2576,21 @@
|
|||||||
"exportWorkflow": "İş Akışını Dışa Aktar",
|
"exportWorkflow": "İş Akışını Dışa Aktar",
|
||||||
"saveWorkflow": "İş akışını kaydet"
|
"saveWorkflow": "İş akışını kaydet"
|
||||||
},
|
},
|
||||||
|
"workspace": {
|
||||||
|
"unsavedChanges": {
|
||||||
|
"message": "Kaydedilmemiş değişiklikleriniz var. Bunları iptal edip çalışma alanlarını değiştirmek istiyor musunuz?",
|
||||||
|
"title": "Kaydedilmemiş Değişiklikler"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"workspaceAuth": {
|
||||||
|
"errors": {
|
||||||
|
"accessDenied": "Bu çalışma alanına erişiminiz yok",
|
||||||
|
"invalidFirebaseToken": "Kimlik doğrulama başarısız oldu. Lütfen tekrar giriş yapmayı deneyin.",
|
||||||
|
"notAuthenticated": "Çalışma alanlarına erişmek için giriş yapmalısınız",
|
||||||
|
"tokenExchangeFailed": "Çalışma alanı ile kimlik doğrulama başarısız oldu: {error}",
|
||||||
|
"workspaceNotFound": "Çalışma alanı bulunamadı"
|
||||||
|
}
|
||||||
|
},
|
||||||
"zoomControls": {
|
"zoomControls": {
|
||||||
"hideMinimap": "Mini Haritayı Gizle",
|
"hideMinimap": "Mini Haritayı Gizle",
|
||||||
"label": "Yakınlaştırma Kontrolleri",
|
"label": "Yakınlaştırma Kontrolleri",
|
||||||
|
|||||||
@@ -848,6 +848,7 @@
|
|||||||
"releaseTitle": "{package} {version} 版本發佈",
|
"releaseTitle": "{package} {version} 版本發佈",
|
||||||
"reloadToApplyChanges": "重新載入以套用變更",
|
"reloadToApplyChanges": "重新載入以套用變更",
|
||||||
"removeImage": "移除圖片",
|
"removeImage": "移除圖片",
|
||||||
|
"removeTag": "移除標籤",
|
||||||
"removeVideo": "移除影片",
|
"removeVideo": "移除影片",
|
||||||
"rename": "重新命名",
|
"rename": "重新命名",
|
||||||
"reportIssue": "送出回報",
|
"reportIssue": "送出回報",
|
||||||
@@ -2575,6 +2576,21 @@
|
|||||||
"exportWorkflow": "匯出工作流程",
|
"exportWorkflow": "匯出工作流程",
|
||||||
"saveWorkflow": "儲存工作流程"
|
"saveWorkflow": "儲存工作流程"
|
||||||
},
|
},
|
||||||
|
"workspace": {
|
||||||
|
"unsavedChanges": {
|
||||||
|
"message": "您有未儲存的變更。是否要捨棄這些變更並切換工作區?",
|
||||||
|
"title": "未儲存的變更"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"workspaceAuth": {
|
||||||
|
"errors": {
|
||||||
|
"accessDenied": "您沒有存取此工作區的權限",
|
||||||
|
"invalidFirebaseToken": "驗證失敗。請重新登入。",
|
||||||
|
"notAuthenticated": "您必須登入才能存取工作區",
|
||||||
|
"tokenExchangeFailed": "與工作區驗證失敗:{error}",
|
||||||
|
"workspaceNotFound": "找不到工作區"
|
||||||
|
}
|
||||||
|
},
|
||||||
"zoomControls": {
|
"zoomControls": {
|
||||||
"hideMinimap": "隱藏小地圖",
|
"hideMinimap": "隱藏小地圖",
|
||||||
"label": "縮放控制",
|
"label": "縮放控制",
|
||||||
|
|||||||
@@ -848,6 +848,7 @@
|
|||||||
"releaseTitle": "{package} {version} 发布",
|
"releaseTitle": "{package} {version} 发布",
|
||||||
"reloadToApplyChanges": "重新加载以应用更改",
|
"reloadToApplyChanges": "重新加载以应用更改",
|
||||||
"removeImage": "移除图片",
|
"removeImage": "移除图片",
|
||||||
|
"removeTag": "移除标签",
|
||||||
"removeVideo": "移除视频",
|
"removeVideo": "移除视频",
|
||||||
"rename": "重命名",
|
"rename": "重命名",
|
||||||
"reportIssue": "发送报告",
|
"reportIssue": "发送报告",
|
||||||
@@ -2586,6 +2587,21 @@
|
|||||||
"exportWorkflow": "导出工作流",
|
"exportWorkflow": "导出工作流",
|
||||||
"saveWorkflow": "保存工作流"
|
"saveWorkflow": "保存工作流"
|
||||||
},
|
},
|
||||||
|
"workspace": {
|
||||||
|
"unsavedChanges": {
|
||||||
|
"message": "您有未保存的更改。是否要放弃这些更改并切换工作区?",
|
||||||
|
"title": "未保存的更改"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"workspaceAuth": {
|
||||||
|
"errors": {
|
||||||
|
"accessDenied": "您无权访问此工作区",
|
||||||
|
"invalidFirebaseToken": "身份验证失败。请重新登录。",
|
||||||
|
"notAuthenticated": "您必须登录才能访问工作区",
|
||||||
|
"tokenExchangeFailed": "与工作区认证失败:{error}",
|
||||||
|
"workspaceNotFound": "未找到工作区"
|
||||||
|
}
|
||||||
|
},
|
||||||
"zoomControls": {
|
"zoomControls": {
|
||||||
"hideMinimap": "隐藏小地图",
|
"hideMinimap": "隐藏小地图",
|
||||||
"label": "缩放控制",
|
"label": "缩放控制",
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ export function mapTaskOutputToAssetItem(
|
|||||||
subfolder: output.subfolder,
|
subfolder: output.subfolder,
|
||||||
executionTimeInSeconds: taskItem.executionTimeInSeconds,
|
executionTimeInSeconds: taskItem.executionTimeInSeconds,
|
||||||
format: output.format,
|
format: output.format,
|
||||||
workflow: taskItem.workflow,
|
|
||||||
create_time: taskItem.createTime
|
create_time: taskItem.createTime
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { api } from '@/scripts/api'
|
|
||||||
import { isCloud } from '@/platform/distribution/types'
|
import { isCloud } from '@/platform/distribution/types'
|
||||||
|
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
|
||||||
|
import { api } from '@/scripts/api'
|
||||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -10,31 +11,59 @@ export const useSessionCookie = () => {
|
|||||||
/**
|
/**
|
||||||
* Creates or refreshes the session cookie.
|
* Creates or refreshes the session cookie.
|
||||||
* Called after login and on token refresh.
|
* Called after login and on token refresh.
|
||||||
|
*
|
||||||
|
* When team_workspaces_enabled is true, uses Firebase token directly
|
||||||
|
* (since getAuthHeader() returns workspace token which shouldn't be used for session creation).
|
||||||
|
* When disabled, uses getAuthHeader() for backward compatibility.
|
||||||
*/
|
*/
|
||||||
const createSession = async (): Promise<void> => {
|
const createSession = async (): Promise<void> => {
|
||||||
if (!isCloud) return
|
if (!isCloud) return
|
||||||
|
|
||||||
const authStore = useFirebaseAuthStore()
|
try {
|
||||||
const authHeader = await authStore.getAuthHeader()
|
const authStore = useFirebaseAuthStore()
|
||||||
|
|
||||||
if (!authHeader) {
|
let authHeader: Record<string, string>
|
||||||
throw new Error('No auth header available for session creation')
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(api.apiURL('/auth/session'), {
|
if (remoteConfig.value.team_workspaces_enabled) {
|
||||||
method: 'POST',
|
const firebaseToken = await authStore.getIdToken()
|
||||||
credentials: 'include',
|
if (!firebaseToken) {
|
||||||
headers: {
|
console.warn(
|
||||||
...authHeader,
|
'Failed to create session cookie:',
|
||||||
'Content-Type': 'application/json'
|
'No Firebase token available for session creation'
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
authHeader = { Authorization: `Bearer ${firebaseToken}` }
|
||||||
|
} else {
|
||||||
|
const header = await authStore.getAuthHeader()
|
||||||
|
if (!header) {
|
||||||
|
console.warn(
|
||||||
|
'Failed to create session cookie:',
|
||||||
|
'No auth header available for session creation'
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
authHeader = header
|
||||||
}
|
}
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
const response = await fetch(api.apiURL('/auth/session'), {
|
||||||
const errorData = await response.json().catch(() => ({}))
|
method: 'POST',
|
||||||
throw new Error(
|
credentials: 'include',
|
||||||
`Failed to create session: ${errorData.message || response.statusText}`
|
headers: {
|
||||||
)
|
...authHeader,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}))
|
||||||
|
console.warn(
|
||||||
|
'Failed to create session cookie:',
|
||||||
|
errorData.message || response.statusText
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to create session cookie:', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,16 +74,21 @@ export const useSessionCookie = () => {
|
|||||||
const deleteSession = async (): Promise<void> => {
|
const deleteSession = async (): Promise<void> => {
|
||||||
if (!isCloud) return
|
if (!isCloud) return
|
||||||
|
|
||||||
const response = await fetch(api.apiURL('/auth/session'), {
|
try {
|
||||||
method: 'DELETE',
|
const response = await fetch(api.apiURL('/auth/session'), {
|
||||||
credentials: 'include'
|
method: 'DELETE',
|
||||||
})
|
credentials: 'include'
|
||||||
|
})
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorData = await response.json().catch(() => ({}))
|
const errorData = await response.json().catch(() => ({}))
|
||||||
throw new Error(
|
console.warn(
|
||||||
`Failed to delete session: ${errorData.message || response.statusText}`
|
'Failed to delete session cookie:',
|
||||||
)
|
errorData.message || response.statusText
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to delete session cookie:', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
670
src/platform/auth/workspace/useWorkspaceAuth.test.ts
Normal file
@@ -0,0 +1,670 @@
|
|||||||
|
import { createPinia, setActivePinia, storeToRefs } from 'pinia'
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import {
|
||||||
|
useWorkspaceAuthStore,
|
||||||
|
WorkspaceAuthError
|
||||||
|
} from '@/stores/workspaceAuthStore'
|
||||||
|
|
||||||
|
import { WORKSPACE_STORAGE_KEYS } from './workspaceConstants'
|
||||||
|
|
||||||
|
const mockGetIdToken = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('@/stores/firebaseAuthStore', () => ({
|
||||||
|
useFirebaseAuthStore: () => ({
|
||||||
|
getIdToken: mockGetIdToken
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/scripts/api', () => ({
|
||||||
|
api: {
|
||||||
|
apiURL: (route: string) => `https://api.example.com/api${route}`
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/i18n', () => ({
|
||||||
|
t: (key: string) => key
|
||||||
|
}))
|
||||||
|
|
||||||
|
const mockRemoteConfig = vi.hoisted(() => ({
|
||||||
|
value: {
|
||||||
|
team_workspaces_enabled: true
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/platform/remoteConfig/remoteConfig', () => ({
|
||||||
|
remoteConfig: mockRemoteConfig
|
||||||
|
}))
|
||||||
|
|
||||||
|
const mockWorkspace = {
|
||||||
|
id: 'workspace-123',
|
||||||
|
name: 'Test Workspace',
|
||||||
|
type: 'team' as const
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockWorkspaceWithRole = {
|
||||||
|
...mockWorkspace,
|
||||||
|
role: 'owner' as const
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockTokenResponse = {
|
||||||
|
token: 'workspace-token-abc',
|
||||||
|
expires_at: new Date(Date.now() + 3600 * 1000).toISOString(),
|
||||||
|
workspace: mockWorkspace,
|
||||||
|
role: 'owner' as const,
|
||||||
|
permissions: ['owner:*']
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useWorkspaceAuthStore', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
vi.clearAllMocks()
|
||||||
|
vi.useFakeTimers()
|
||||||
|
sessionStorage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('initial state', () => {
|
||||||
|
it('has correct initial state values', () => {
|
||||||
|
const store = useWorkspaceAuthStore()
|
||||||
|
const {
|
||||||
|
currentWorkspace,
|
||||||
|
workspaceToken,
|
||||||
|
isAuthenticated,
|
||||||
|
isLoading,
|
||||||
|
error
|
||||||
|
} = storeToRefs(store)
|
||||||
|
|
||||||
|
expect(currentWorkspace.value).toBeNull()
|
||||||
|
expect(workspaceToken.value).toBeNull()
|
||||||
|
expect(isAuthenticated.value).toBe(false)
|
||||||
|
expect(isLoading.value).toBe(false)
|
||||||
|
expect(error.value).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('initializeFromSession', () => {
|
||||||
|
it('returns true and populates state when valid session data exists', () => {
|
||||||
|
const futureExpiry = Date.now() + 3600 * 1000
|
||||||
|
sessionStorage.setItem(
|
||||||
|
WORKSPACE_STORAGE_KEYS.CURRENT_WORKSPACE,
|
||||||
|
JSON.stringify(mockWorkspaceWithRole)
|
||||||
|
)
|
||||||
|
sessionStorage.setItem(WORKSPACE_STORAGE_KEYS.TOKEN, 'valid-token')
|
||||||
|
sessionStorage.setItem(
|
||||||
|
WORKSPACE_STORAGE_KEYS.EXPIRES_AT,
|
||||||
|
futureExpiry.toString()
|
||||||
|
)
|
||||||
|
|
||||||
|
const store = useWorkspaceAuthStore()
|
||||||
|
const { currentWorkspace, workspaceToken } = storeToRefs(store)
|
||||||
|
|
||||||
|
const result = store.initializeFromSession()
|
||||||
|
|
||||||
|
expect(result).toBe(true)
|
||||||
|
expect(currentWorkspace.value).toEqual(mockWorkspaceWithRole)
|
||||||
|
expect(workspaceToken.value).toBe('valid-token')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns false when sessionStorage is empty', () => {
|
||||||
|
const store = useWorkspaceAuthStore()
|
||||||
|
|
||||||
|
const result = store.initializeFromSession()
|
||||||
|
|
||||||
|
expect(result).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns false and clears storage when token is expired', () => {
|
||||||
|
const pastExpiry = Date.now() - 1000
|
||||||
|
sessionStorage.setItem(
|
||||||
|
WORKSPACE_STORAGE_KEYS.CURRENT_WORKSPACE,
|
||||||
|
JSON.stringify(mockWorkspaceWithRole)
|
||||||
|
)
|
||||||
|
sessionStorage.setItem(WORKSPACE_STORAGE_KEYS.TOKEN, 'expired-token')
|
||||||
|
sessionStorage.setItem(
|
||||||
|
WORKSPACE_STORAGE_KEYS.EXPIRES_AT,
|
||||||
|
pastExpiry.toString()
|
||||||
|
)
|
||||||
|
|
||||||
|
const store = useWorkspaceAuthStore()
|
||||||
|
|
||||||
|
const result = store.initializeFromSession()
|
||||||
|
|
||||||
|
expect(result).toBe(false)
|
||||||
|
expect(
|
||||||
|
sessionStorage.getItem(WORKSPACE_STORAGE_KEYS.CURRENT_WORKSPACE)
|
||||||
|
).toBeNull()
|
||||||
|
expect(sessionStorage.getItem(WORKSPACE_STORAGE_KEYS.TOKEN)).toBeNull()
|
||||||
|
expect(
|
||||||
|
sessionStorage.getItem(WORKSPACE_STORAGE_KEYS.EXPIRES_AT)
|
||||||
|
).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns false and clears storage when data is malformed', () => {
|
||||||
|
sessionStorage.setItem(
|
||||||
|
WORKSPACE_STORAGE_KEYS.CURRENT_WORKSPACE,
|
||||||
|
'invalid-json{'
|
||||||
|
)
|
||||||
|
sessionStorage.setItem(WORKSPACE_STORAGE_KEYS.TOKEN, 'some-token')
|
||||||
|
sessionStorage.setItem(WORKSPACE_STORAGE_KEYS.EXPIRES_AT, 'not-a-number')
|
||||||
|
|
||||||
|
const store = useWorkspaceAuthStore()
|
||||||
|
|
||||||
|
const result = store.initializeFromSession()
|
||||||
|
|
||||||
|
expect(result).toBe(false)
|
||||||
|
expect(
|
||||||
|
sessionStorage.getItem(WORKSPACE_STORAGE_KEYS.CURRENT_WORKSPACE)
|
||||||
|
).toBeNull()
|
||||||
|
expect(sessionStorage.getItem(WORKSPACE_STORAGE_KEYS.TOKEN)).toBeNull()
|
||||||
|
expect(
|
||||||
|
sessionStorage.getItem(WORKSPACE_STORAGE_KEYS.EXPIRES_AT)
|
||||||
|
).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns false when partial session data exists (missing token)', () => {
|
||||||
|
sessionStorage.setItem(
|
||||||
|
WORKSPACE_STORAGE_KEYS.CURRENT_WORKSPACE,
|
||||||
|
JSON.stringify(mockWorkspaceWithRole)
|
||||||
|
)
|
||||||
|
sessionStorage.setItem(
|
||||||
|
WORKSPACE_STORAGE_KEYS.EXPIRES_AT,
|
||||||
|
(Date.now() + 3600 * 1000).toString()
|
||||||
|
)
|
||||||
|
|
||||||
|
const store = useWorkspaceAuthStore()
|
||||||
|
|
||||||
|
const result = store.initializeFromSession()
|
||||||
|
|
||||||
|
expect(result).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('switchWorkspace', () => {
|
||||||
|
it('successfully exchanges Firebase token for workspace token', async () => {
|
||||||
|
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve(mockTokenResponse)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const store = useWorkspaceAuthStore()
|
||||||
|
const { currentWorkspace, workspaceToken, isAuthenticated } =
|
||||||
|
storeToRefs(store)
|
||||||
|
|
||||||
|
await store.switchWorkspace('workspace-123')
|
||||||
|
|
||||||
|
expect(currentWorkspace.value).toEqual(mockWorkspaceWithRole)
|
||||||
|
expect(workspaceToken.value).toBe('workspace-token-abc')
|
||||||
|
expect(isAuthenticated.value).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stores workspace data in sessionStorage', async () => {
|
||||||
|
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve(mockTokenResponse)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const store = useWorkspaceAuthStore()
|
||||||
|
|
||||||
|
await store.switchWorkspace('workspace-123')
|
||||||
|
|
||||||
|
expect(
|
||||||
|
sessionStorage.getItem(WORKSPACE_STORAGE_KEYS.CURRENT_WORKSPACE)
|
||||||
|
).toBe(JSON.stringify(mockWorkspaceWithRole))
|
||||||
|
expect(sessionStorage.getItem(WORKSPACE_STORAGE_KEYS.TOKEN)).toBe(
|
||||||
|
'workspace-token-abc'
|
||||||
|
)
|
||||||
|
expect(
|
||||||
|
sessionStorage.getItem(WORKSPACE_STORAGE_KEYS.EXPIRES_AT)
|
||||||
|
).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sets isLoading to true during operation', async () => {
|
||||||
|
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
|
||||||
|
let resolveResponse: (value: unknown) => void
|
||||||
|
const responsePromise = new Promise((resolve) => {
|
||||||
|
resolveResponse = resolve
|
||||||
|
})
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockReturnValue(responsePromise))
|
||||||
|
|
||||||
|
const store = useWorkspaceAuthStore()
|
||||||
|
const { isLoading } = storeToRefs(store)
|
||||||
|
|
||||||
|
const switchPromise = store.switchWorkspace('workspace-123')
|
||||||
|
expect(isLoading.value).toBe(true)
|
||||||
|
|
||||||
|
resolveResponse!({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve(mockTokenResponse)
|
||||||
|
})
|
||||||
|
await switchPromise
|
||||||
|
|
||||||
|
expect(isLoading.value).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws WorkspaceAuthError with code NOT_AUTHENTICATED when Firebase token unavailable', async () => {
|
||||||
|
mockGetIdToken.mockResolvedValue(undefined)
|
||||||
|
|
||||||
|
const store = useWorkspaceAuthStore()
|
||||||
|
const { error } = storeToRefs(store)
|
||||||
|
|
||||||
|
await expect(store.switchWorkspace('workspace-123')).rejects.toThrow(
|
||||||
|
WorkspaceAuthError
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(error.value).toBeInstanceOf(WorkspaceAuthError)
|
||||||
|
expect((error.value as WorkspaceAuthError).code).toBe('NOT_AUTHENTICATED')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws WorkspaceAuthError with code ACCESS_DENIED on 403 response', async () => {
|
||||||
|
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn().mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
status: 403,
|
||||||
|
statusText: 'Forbidden',
|
||||||
|
json: () => Promise.resolve({ message: 'Access denied' })
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const store = useWorkspaceAuthStore()
|
||||||
|
const { error } = storeToRefs(store)
|
||||||
|
|
||||||
|
await expect(store.switchWorkspace('workspace-123')).rejects.toThrow(
|
||||||
|
WorkspaceAuthError
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(error.value).toBeInstanceOf(WorkspaceAuthError)
|
||||||
|
expect((error.value as WorkspaceAuthError).code).toBe('ACCESS_DENIED')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws WorkspaceAuthError with code WORKSPACE_NOT_FOUND on 404 response', async () => {
|
||||||
|
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn().mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
status: 404,
|
||||||
|
statusText: 'Not Found',
|
||||||
|
json: () => Promise.resolve({ message: 'Workspace not found' })
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const store = useWorkspaceAuthStore()
|
||||||
|
const { error } = storeToRefs(store)
|
||||||
|
|
||||||
|
await expect(store.switchWorkspace('workspace-123')).rejects.toThrow(
|
||||||
|
WorkspaceAuthError
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(error.value).toBeInstanceOf(WorkspaceAuthError)
|
||||||
|
expect((error.value as WorkspaceAuthError).code).toBe(
|
||||||
|
'WORKSPACE_NOT_FOUND'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws WorkspaceAuthError with code INVALID_FIREBASE_TOKEN on 401 response', async () => {
|
||||||
|
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn().mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
status: 401,
|
||||||
|
statusText: 'Unauthorized',
|
||||||
|
json: () => Promise.resolve({ message: 'Invalid token' })
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const store = useWorkspaceAuthStore()
|
||||||
|
const { error } = storeToRefs(store)
|
||||||
|
|
||||||
|
await expect(store.switchWorkspace('workspace-123')).rejects.toThrow(
|
||||||
|
WorkspaceAuthError
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(error.value).toBeInstanceOf(WorkspaceAuthError)
|
||||||
|
expect((error.value as WorkspaceAuthError).code).toBe(
|
||||||
|
'INVALID_FIREBASE_TOKEN'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws WorkspaceAuthError with code TOKEN_EXCHANGE_FAILED on other errors', async () => {
|
||||||
|
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn().mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
status: 500,
|
||||||
|
statusText: 'Internal Server Error',
|
||||||
|
json: () => Promise.resolve({ message: 'Server error' })
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const store = useWorkspaceAuthStore()
|
||||||
|
const { error } = storeToRefs(store)
|
||||||
|
|
||||||
|
await expect(store.switchWorkspace('workspace-123')).rejects.toThrow(
|
||||||
|
WorkspaceAuthError
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(error.value).toBeInstanceOf(WorkspaceAuthError)
|
||||||
|
expect((error.value as WorkspaceAuthError).code).toBe(
|
||||||
|
'TOKEN_EXCHANGE_FAILED'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sends correct request to API', async () => {
|
||||||
|
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
|
||||||
|
const mockFetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve(mockTokenResponse)
|
||||||
|
})
|
||||||
|
vi.stubGlobal('fetch', mockFetch)
|
||||||
|
|
||||||
|
const store = useWorkspaceAuthStore()
|
||||||
|
|
||||||
|
await store.switchWorkspace('workspace-123')
|
||||||
|
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith(
|
||||||
|
'https://api.example.com/api/auth/token',
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: 'Bearer firebase-token-xyz',
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ workspace_id: 'workspace-123' })
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('clearWorkspaceContext', () => {
|
||||||
|
it('clears all state refs', async () => {
|
||||||
|
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve(mockTokenResponse)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const store = useWorkspaceAuthStore()
|
||||||
|
const { currentWorkspace, workspaceToken, error, isAuthenticated } =
|
||||||
|
storeToRefs(store)
|
||||||
|
|
||||||
|
await store.switchWorkspace('workspace-123')
|
||||||
|
expect(isAuthenticated.value).toBe(true)
|
||||||
|
|
||||||
|
store.clearWorkspaceContext()
|
||||||
|
|
||||||
|
expect(currentWorkspace.value).toBeNull()
|
||||||
|
expect(workspaceToken.value).toBeNull()
|
||||||
|
expect(error.value).toBeNull()
|
||||||
|
expect(isAuthenticated.value).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clears sessionStorage', async () => {
|
||||||
|
sessionStorage.setItem(
|
||||||
|
WORKSPACE_STORAGE_KEYS.CURRENT_WORKSPACE,
|
||||||
|
JSON.stringify(mockWorkspaceWithRole)
|
||||||
|
)
|
||||||
|
sessionStorage.setItem(WORKSPACE_STORAGE_KEYS.TOKEN, 'some-token')
|
||||||
|
sessionStorage.setItem(WORKSPACE_STORAGE_KEYS.EXPIRES_AT, '12345')
|
||||||
|
|
||||||
|
const store = useWorkspaceAuthStore()
|
||||||
|
|
||||||
|
store.clearWorkspaceContext()
|
||||||
|
|
||||||
|
expect(
|
||||||
|
sessionStorage.getItem(WORKSPACE_STORAGE_KEYS.CURRENT_WORKSPACE)
|
||||||
|
).toBeNull()
|
||||||
|
expect(sessionStorage.getItem(WORKSPACE_STORAGE_KEYS.TOKEN)).toBeNull()
|
||||||
|
expect(
|
||||||
|
sessionStorage.getItem(WORKSPACE_STORAGE_KEYS.EXPIRES_AT)
|
||||||
|
).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getWorkspaceAuthHeader', () => {
|
||||||
|
it('returns null when no workspace token', () => {
|
||||||
|
const store = useWorkspaceAuthStore()
|
||||||
|
|
||||||
|
const header = store.getWorkspaceAuthHeader()
|
||||||
|
|
||||||
|
expect(header).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns proper Authorization header when workspace token exists', async () => {
|
||||||
|
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve(mockTokenResponse)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const store = useWorkspaceAuthStore()
|
||||||
|
|
||||||
|
await store.switchWorkspace('workspace-123')
|
||||||
|
const header = store.getWorkspaceAuthHeader()
|
||||||
|
|
||||||
|
expect(header).toEqual({
|
||||||
|
Authorization: 'Bearer workspace-token-abc'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('token refresh scheduling', () => {
|
||||||
|
it('schedules token refresh 5 minutes before expiry', async () => {
|
||||||
|
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
|
||||||
|
const expiresInMs = 3600 * 1000
|
||||||
|
const tokenResponseWithFutureExpiry = {
|
||||||
|
...mockTokenResponse,
|
||||||
|
expires_at: new Date(Date.now() + expiresInMs).toISOString()
|
||||||
|
}
|
||||||
|
const mockFetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve(tokenResponseWithFutureExpiry)
|
||||||
|
})
|
||||||
|
vi.stubGlobal('fetch', mockFetch)
|
||||||
|
|
||||||
|
const store = useWorkspaceAuthStore()
|
||||||
|
|
||||||
|
await store.switchWorkspace('workspace-123')
|
||||||
|
|
||||||
|
expect(mockFetch).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
const refreshBufferMs = 5 * 60 * 1000
|
||||||
|
const refreshDelay = expiresInMs - refreshBufferMs
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(refreshDelay - 1)
|
||||||
|
expect(mockFetch).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(1)
|
||||||
|
|
||||||
|
expect(mockFetch).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clears context when refresh fails with ACCESS_DENIED', async () => {
|
||||||
|
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
|
||||||
|
const expiresInMs = 3600 * 1000
|
||||||
|
const tokenResponseWithFutureExpiry = {
|
||||||
|
...mockTokenResponse,
|
||||||
|
expires_at: new Date(Date.now() + expiresInMs).toISOString()
|
||||||
|
}
|
||||||
|
const mockFetch = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve(tokenResponseWithFutureExpiry)
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
status: 403,
|
||||||
|
statusText: 'Forbidden',
|
||||||
|
json: () => Promise.resolve({ message: 'Access denied' })
|
||||||
|
})
|
||||||
|
vi.stubGlobal('fetch', mockFetch)
|
||||||
|
|
||||||
|
const store = useWorkspaceAuthStore()
|
||||||
|
const { currentWorkspace, workspaceToken } = storeToRefs(store)
|
||||||
|
|
||||||
|
await store.switchWorkspace('workspace-123')
|
||||||
|
expect(workspaceToken.value).toBe('workspace-token-abc')
|
||||||
|
|
||||||
|
const refreshBufferMs = 5 * 60 * 1000
|
||||||
|
const refreshDelay = expiresInMs - refreshBufferMs
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(refreshDelay)
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(currentWorkspace.value).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(workspaceToken.value).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('refreshToken', () => {
|
||||||
|
it('does nothing when no current workspace', async () => {
|
||||||
|
const mockFetch = vi.fn()
|
||||||
|
vi.stubGlobal('fetch', mockFetch)
|
||||||
|
|
||||||
|
const store = useWorkspaceAuthStore()
|
||||||
|
|
||||||
|
await store.refreshToken()
|
||||||
|
|
||||||
|
expect(mockFetch).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('refreshes token for current workspace', async () => {
|
||||||
|
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
|
||||||
|
const mockFetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve(mockTokenResponse)
|
||||||
|
})
|
||||||
|
vi.stubGlobal('fetch', mockFetch)
|
||||||
|
|
||||||
|
const store = useWorkspaceAuthStore()
|
||||||
|
const { workspaceToken } = storeToRefs(store)
|
||||||
|
|
||||||
|
await store.switchWorkspace('workspace-123')
|
||||||
|
expect(mockFetch).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
mockFetch.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: () =>
|
||||||
|
Promise.resolve({
|
||||||
|
...mockTokenResponse,
|
||||||
|
token: 'refreshed-token'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
await store.refreshToken()
|
||||||
|
expect(mockFetch).toHaveBeenCalledTimes(2)
|
||||||
|
expect(workspaceToken.value).toBe('refreshed-token')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('isAuthenticated computed', () => {
|
||||||
|
it('returns true when both workspace and token are present', async () => {
|
||||||
|
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve(mockTokenResponse)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const store = useWorkspaceAuthStore()
|
||||||
|
const { isAuthenticated } = storeToRefs(store)
|
||||||
|
|
||||||
|
await store.switchWorkspace('workspace-123')
|
||||||
|
|
||||||
|
expect(isAuthenticated.value).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns false when workspace is null', () => {
|
||||||
|
const store = useWorkspaceAuthStore()
|
||||||
|
const { isAuthenticated } = storeToRefs(store)
|
||||||
|
|
||||||
|
expect(isAuthenticated.value).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns false when currentWorkspace is set but workspaceToken is null', async () => {
|
||||||
|
mockGetIdToken.mockResolvedValue(null)
|
||||||
|
|
||||||
|
const store = useWorkspaceAuthStore()
|
||||||
|
const { currentWorkspace, workspaceToken, isAuthenticated } =
|
||||||
|
storeToRefs(store)
|
||||||
|
|
||||||
|
currentWorkspace.value = mockWorkspaceWithRole
|
||||||
|
workspaceToken.value = null
|
||||||
|
|
||||||
|
expect(isAuthenticated.value).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('feature flag disabled', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockRemoteConfig.value.team_workspaces_enabled = false
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
mockRemoteConfig.value.team_workspaces_enabled = true
|
||||||
|
})
|
||||||
|
|
||||||
|
it('initializeFromSession returns false when flag disabled', () => {
|
||||||
|
const futureExpiry = Date.now() + 3600 * 1000
|
||||||
|
sessionStorage.setItem(
|
||||||
|
WORKSPACE_STORAGE_KEYS.CURRENT_WORKSPACE,
|
||||||
|
JSON.stringify(mockWorkspaceWithRole)
|
||||||
|
)
|
||||||
|
sessionStorage.setItem(WORKSPACE_STORAGE_KEYS.TOKEN, 'valid-token')
|
||||||
|
sessionStorage.setItem(
|
||||||
|
WORKSPACE_STORAGE_KEYS.EXPIRES_AT,
|
||||||
|
futureExpiry.toString()
|
||||||
|
)
|
||||||
|
|
||||||
|
const store = useWorkspaceAuthStore()
|
||||||
|
const { currentWorkspace, workspaceToken } = storeToRefs(store)
|
||||||
|
|
||||||
|
const result = store.initializeFromSession()
|
||||||
|
|
||||||
|
expect(result).toBe(false)
|
||||||
|
expect(currentWorkspace.value).toBeNull()
|
||||||
|
expect(workspaceToken.value).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('switchWorkspace is a no-op when flag disabled', async () => {
|
||||||
|
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
|
||||||
|
const mockFetch = vi.fn()
|
||||||
|
vi.stubGlobal('fetch', mockFetch)
|
||||||
|
|
||||||
|
const store = useWorkspaceAuthStore()
|
||||||
|
const { currentWorkspace, workspaceToken, isLoading } = storeToRefs(store)
|
||||||
|
|
||||||
|
await store.switchWorkspace('workspace-123')
|
||||||
|
|
||||||
|
expect(mockFetch).not.toHaveBeenCalled()
|
||||||
|
expect(currentWorkspace.value).toBeNull()
|
||||||
|
expect(workspaceToken.value).toBeNull()
|
||||||
|
expect(isLoading.value).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
166
src/platform/auth/workspace/useWorkspaceSwitch.test.ts
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import { useWorkspaceSwitch } from '@/platform/auth/workspace/useWorkspaceSwitch'
|
||||||
|
import type { WorkspaceWithRole } from '@/platform/auth/workspace/workspaceTypes'
|
||||||
|
|
||||||
|
const mockSwitchWorkspace = vi.hoisted(() => vi.fn())
|
||||||
|
const mockCurrentWorkspace = vi.hoisted(() => ({
|
||||||
|
value: null as WorkspaceWithRole | null
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/stores/workspaceAuthStore', () => ({
|
||||||
|
useWorkspaceAuthStore: () => ({
|
||||||
|
switchWorkspace: mockSwitchWorkspace
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('pinia', () => ({
|
||||||
|
storeToRefs: () => ({
|
||||||
|
currentWorkspace: mockCurrentWorkspace
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
|
const mockModifiedWorkflows = vi.hoisted(
|
||||||
|
() => [] as Array<{ isModified: boolean }>
|
||||||
|
)
|
||||||
|
|
||||||
|
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||||
|
useWorkflowStore: () => ({
|
||||||
|
get modifiedWorkflows() {
|
||||||
|
return mockModifiedWorkflows
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
|
const mockConfirm = vi.hoisted(() => vi.fn())
|
||||||
|
|
||||||
|
vi.mock('@/services/dialogService', () => ({
|
||||||
|
useDialogService: () => ({
|
||||||
|
confirm: mockConfirm
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('vue-i18n', () => ({
|
||||||
|
useI18n: () => ({
|
||||||
|
t: (key: string) => key
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
|
const mockReload = vi.fn()
|
||||||
|
|
||||||
|
describe('useWorkspaceSwitch', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
mockCurrentWorkspace.value = {
|
||||||
|
id: 'workspace-1',
|
||||||
|
name: 'Test Workspace',
|
||||||
|
type: 'personal',
|
||||||
|
role: 'owner'
|
||||||
|
}
|
||||||
|
mockModifiedWorkflows.length = 0
|
||||||
|
vi.stubGlobal('location', { reload: mockReload })
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('hasUnsavedChanges', () => {
|
||||||
|
it('returns true when there are modified workflows', () => {
|
||||||
|
mockModifiedWorkflows.push({ isModified: true })
|
||||||
|
const { hasUnsavedChanges } = useWorkspaceSwitch()
|
||||||
|
|
||||||
|
expect(hasUnsavedChanges()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns true when multiple workflows are modified', () => {
|
||||||
|
mockModifiedWorkflows.push({ isModified: true }, { isModified: true })
|
||||||
|
const { hasUnsavedChanges } = useWorkspaceSwitch()
|
||||||
|
|
||||||
|
expect(hasUnsavedChanges()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns false when no workflows are modified', () => {
|
||||||
|
mockModifiedWorkflows.length = 0
|
||||||
|
const { hasUnsavedChanges } = useWorkspaceSwitch()
|
||||||
|
|
||||||
|
expect(hasUnsavedChanges()).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('switchWithConfirmation', () => {
|
||||||
|
it('returns true immediately if switching to the same workspace', async () => {
|
||||||
|
const { switchWithConfirmation } = useWorkspaceSwitch()
|
||||||
|
|
||||||
|
const result = await switchWithConfirmation('workspace-1')
|
||||||
|
|
||||||
|
expect(result).toBe(true)
|
||||||
|
expect(mockSwitchWorkspace).not.toHaveBeenCalled()
|
||||||
|
expect(mockConfirm).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('switches directly without dialog when no unsaved changes', async () => {
|
||||||
|
mockModifiedWorkflows.length = 0
|
||||||
|
mockSwitchWorkspace.mockResolvedValue(undefined)
|
||||||
|
const { switchWithConfirmation } = useWorkspaceSwitch()
|
||||||
|
|
||||||
|
const result = await switchWithConfirmation('workspace-2')
|
||||||
|
|
||||||
|
expect(result).toBe(true)
|
||||||
|
expect(mockConfirm).not.toHaveBeenCalled()
|
||||||
|
expect(mockSwitchWorkspace).toHaveBeenCalledWith('workspace-2')
|
||||||
|
expect(mockReload).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows confirmation dialog when there are unsaved changes', async () => {
|
||||||
|
mockModifiedWorkflows.push({ isModified: true })
|
||||||
|
mockConfirm.mockResolvedValue(true)
|
||||||
|
mockSwitchWorkspace.mockResolvedValue(undefined)
|
||||||
|
const { switchWithConfirmation } = useWorkspaceSwitch()
|
||||||
|
|
||||||
|
await switchWithConfirmation('workspace-2')
|
||||||
|
|
||||||
|
expect(mockConfirm).toHaveBeenCalledWith({
|
||||||
|
title: 'workspace.unsavedChanges.title',
|
||||||
|
message: 'workspace.unsavedChanges.message',
|
||||||
|
type: 'dirtyClose'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns false if user cancels the confirmation dialog', async () => {
|
||||||
|
mockModifiedWorkflows.push({ isModified: true })
|
||||||
|
mockConfirm.mockResolvedValue(false)
|
||||||
|
const { switchWithConfirmation } = useWorkspaceSwitch()
|
||||||
|
|
||||||
|
const result = await switchWithConfirmation('workspace-2')
|
||||||
|
|
||||||
|
expect(result).toBe(false)
|
||||||
|
expect(mockSwitchWorkspace).not.toHaveBeenCalled()
|
||||||
|
expect(mockReload).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls switchWorkspace and reloads page after user confirms', async () => {
|
||||||
|
mockModifiedWorkflows.push({ isModified: true })
|
||||||
|
mockConfirm.mockResolvedValue(true)
|
||||||
|
mockSwitchWorkspace.mockResolvedValue(undefined)
|
||||||
|
const { switchWithConfirmation } = useWorkspaceSwitch()
|
||||||
|
|
||||||
|
const result = await switchWithConfirmation('workspace-2')
|
||||||
|
|
||||||
|
expect(result).toBe(true)
|
||||||
|
expect(mockSwitchWorkspace).toHaveBeenCalledWith('workspace-2')
|
||||||
|
expect(mockReload).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns false if switchWorkspace throws an error', async () => {
|
||||||
|
mockModifiedWorkflows.length = 0
|
||||||
|
mockSwitchWorkspace.mockRejectedValue(new Error('Switch failed'))
|
||||||
|
const { switchWithConfirmation } = useWorkspaceSwitch()
|
||||||
|
|
||||||
|
const result = await switchWithConfirmation('workspace-2')
|
||||||
|
|
||||||
|
expect(result).toBe(false)
|
||||||
|
expect(mockReload).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
49
src/platform/auth/workspace/useWorkspaceSwitch.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { storeToRefs } from 'pinia'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||||
|
import { useDialogService } from '@/services/dialogService'
|
||||||
|
import { useWorkspaceAuthStore } from '@/stores/workspaceAuthStore'
|
||||||
|
|
||||||
|
export function useWorkspaceSwitch() {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const workspaceAuthStore = useWorkspaceAuthStore()
|
||||||
|
const { currentWorkspace } = storeToRefs(workspaceAuthStore)
|
||||||
|
const workflowStore = useWorkflowStore()
|
||||||
|
const dialogService = useDialogService()
|
||||||
|
|
||||||
|
function hasUnsavedChanges(): boolean {
|
||||||
|
return workflowStore.modifiedWorkflows.length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
async function switchWithConfirmation(workspaceId: string): Promise<boolean> {
|
||||||
|
if (currentWorkspace.value?.id === workspaceId) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasUnsavedChanges()) {
|
||||||
|
const confirmed = await dialogService.confirm({
|
||||||
|
title: t('workspace.unsavedChanges.title'),
|
||||||
|
message: t('workspace.unsavedChanges.message'),
|
||||||
|
type: 'dirtyClose'
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!confirmed) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await workspaceAuthStore.switchWorkspace(workspaceId)
|
||||||
|
window.location.reload()
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
hasUnsavedChanges,
|
||||||
|
switchWithConfirmation
|
||||||
|
}
|
||||||
|
}
|
||||||
7
src/platform/auth/workspace/workspaceConstants.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export const WORKSPACE_STORAGE_KEYS = {
|
||||||
|
CURRENT_WORKSPACE: 'Comfy.Workspace.Current',
|
||||||
|
TOKEN: 'Comfy.Workspace.Token',
|
||||||
|
EXPIRES_AT: 'Comfy.Workspace.ExpiresAt'
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export const TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1000
|
||||||
6
src/platform/auth/workspace/workspaceTypes.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export interface WorkspaceWithRole {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
type: 'personal' | 'team'
|
||||||
|
role: 'owner' | 'member'
|
||||||
|
}
|
||||||
@@ -1,380 +0,0 @@
|
|||||||
/**
|
|
||||||
* @fileoverview Test fixtures for history tests.
|
|
||||||
*/
|
|
||||||
import type { HistoryResponseV2 } from '@/platform/remote/comfyui/history/types/historyV2Types'
|
|
||||||
import type { HistoryTaskItem } from '@/schemas/apiSchema'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* V1 API raw response format (object with prompt IDs as keys)
|
|
||||||
*/
|
|
||||||
export const historyV1RawResponse: Record<
|
|
||||||
string,
|
|
||||||
Omit<HistoryTaskItem, 'taskType'>
|
|
||||||
> = {
|
|
||||||
'complete-item-id': {
|
|
||||||
prompt: [
|
|
||||||
24,
|
|
||||||
'complete-item-id',
|
|
||||||
{},
|
|
||||||
{
|
|
||||||
client_id: 'test-client',
|
|
||||||
extra_pnginfo: {
|
|
||||||
workflow: {
|
|
||||||
id: '44f0c9f9-b5a7-48de-99fc-7e80c1570241',
|
|
||||||
revision: 0,
|
|
||||||
last_node_id: 9,
|
|
||||||
last_link_id: 9,
|
|
||||||
nodes: [],
|
|
||||||
links: [],
|
|
||||||
groups: [],
|
|
||||||
config: {},
|
|
||||||
extra: {},
|
|
||||||
version: 0.4
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
['9']
|
|
||||||
],
|
|
||||||
outputs: {
|
|
||||||
'9': {
|
|
||||||
images: [
|
|
||||||
{
|
|
||||||
filename: 'test.png',
|
|
||||||
subfolder: '',
|
|
||||||
type: 'output'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
status: {
|
|
||||||
status_str: 'success',
|
|
||||||
completed: true,
|
|
||||||
messages: [
|
|
||||||
[
|
|
||||||
'execution_start',
|
|
||||||
{ prompt_id: 'complete-item-id', timestamp: 1234567890 }
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'execution_success',
|
|
||||||
{ prompt_id: 'complete-item-id', timestamp: 1234567900 }
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
meta: {
|
|
||||||
'9': {
|
|
||||||
node_id: '9',
|
|
||||||
display_node: '9'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
'no-status-id': {
|
|
||||||
prompt: [
|
|
||||||
23,
|
|
||||||
'no-status-id',
|
|
||||||
{},
|
|
||||||
{
|
|
||||||
client_id: 'inference'
|
|
||||||
},
|
|
||||||
['10']
|
|
||||||
],
|
|
||||||
outputs: {
|
|
||||||
'10': {
|
|
||||||
images: []
|
|
||||||
}
|
|
||||||
},
|
|
||||||
status: undefined,
|
|
||||||
meta: {
|
|
||||||
'10': {
|
|
||||||
node_id: '10',
|
|
||||||
display_node: '10'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* V2 response with multiple edge cases:
|
|
||||||
* - Item 0: Complete with all fields
|
|
||||||
* - Item 1: Missing optional status field
|
|
||||||
* - Item 2: Missing optional meta field
|
|
||||||
* - Item 3: Multiple output nodes
|
|
||||||
*/
|
|
||||||
export const historyV2Fixture: HistoryResponseV2 = {
|
|
||||||
history: [
|
|
||||||
{
|
|
||||||
prompt_id: 'complete-item-id',
|
|
||||||
prompt: {
|
|
||||||
priority: 24,
|
|
||||||
prompt_id: 'complete-item-id',
|
|
||||||
extra_data: {
|
|
||||||
client_id: 'test-client',
|
|
||||||
extra_pnginfo: {
|
|
||||||
workflow: {
|
|
||||||
id: '44f0c9f9-b5a7-48de-99fc-7e80c1570241',
|
|
||||||
revision: 0,
|
|
||||||
last_node_id: 9,
|
|
||||||
last_link_id: 9,
|
|
||||||
nodes: [],
|
|
||||||
links: [],
|
|
||||||
groups: [],
|
|
||||||
config: {},
|
|
||||||
extra: {},
|
|
||||||
version: 0.4
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
outputs: {
|
|
||||||
'9': {
|
|
||||||
images: [
|
|
||||||
{
|
|
||||||
filename: 'test.png',
|
|
||||||
subfolder: '',
|
|
||||||
type: 'output'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
status: {
|
|
||||||
status_str: 'success',
|
|
||||||
completed: true,
|
|
||||||
messages: [
|
|
||||||
[
|
|
||||||
'execution_start',
|
|
||||||
{ prompt_id: 'complete-item-id', timestamp: 1234567890 }
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'execution_success',
|
|
||||||
{ prompt_id: 'complete-item-id', timestamp: 1234567900 }
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
meta: {
|
|
||||||
'9': {
|
|
||||||
node_id: '9',
|
|
||||||
display_node: '9'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
prompt_id: 'no-status-id',
|
|
||||||
prompt: {
|
|
||||||
priority: 23,
|
|
||||||
prompt_id: 'no-status-id',
|
|
||||||
extra_data: {
|
|
||||||
client_id: 'inference'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
outputs: {
|
|
||||||
'10': {
|
|
||||||
images: []
|
|
||||||
}
|
|
||||||
},
|
|
||||||
meta: {
|
|
||||||
'10': {
|
|
||||||
node_id: '10',
|
|
||||||
display_node: '10'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
prompt_id: 'no-meta-id',
|
|
||||||
prompt: {
|
|
||||||
priority: 22,
|
|
||||||
prompt_id: 'no-meta-id',
|
|
||||||
extra_data: {
|
|
||||||
client_id: 'web-ui'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
outputs: {
|
|
||||||
'11': {
|
|
||||||
audio: []
|
|
||||||
}
|
|
||||||
},
|
|
||||||
status: {
|
|
||||||
status_str: 'error',
|
|
||||||
completed: false,
|
|
||||||
messages: []
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
prompt_id: 'multi-output-id',
|
|
||||||
prompt: {
|
|
||||||
priority: 21,
|
|
||||||
prompt_id: 'multi-output-id',
|
|
||||||
extra_data: {
|
|
||||||
client_id: 'batch-processor'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
outputs: {
|
|
||||||
'3': {
|
|
||||||
images: [{ filename: 'img1.png', type: 'output', subfolder: '' }]
|
|
||||||
},
|
|
||||||
'9': {
|
|
||||||
images: [{ filename: 'img2.png', type: 'output', subfolder: '' }]
|
|
||||||
},
|
|
||||||
'12': {
|
|
||||||
video: [{ filename: 'video.mp4', type: 'output', subfolder: '' }]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
status: {
|
|
||||||
status_str: 'success',
|
|
||||||
completed: true,
|
|
||||||
messages: []
|
|
||||||
},
|
|
||||||
meta: {
|
|
||||||
'3': { node_id: '3', display_node: '3' },
|
|
||||||
'9': { node_id: '9', display_node: '9' },
|
|
||||||
'12': { node_id: '12', display_node: '12' }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Expected V1 transformation of historyV2Fixture
|
|
||||||
* Priority is now synthetic based on execution_success timestamp:
|
|
||||||
* - complete-item-id: has timestamp → priority 1 (only one with timestamp)
|
|
||||||
* - no-status-id: no status → priority 0
|
|
||||||
* - no-meta-id: empty messages → priority 0
|
|
||||||
* - multi-output-id: empty messages → priority 0
|
|
||||||
*/
|
|
||||||
export const expectedV1Fixture: HistoryTaskItem[] = [
|
|
||||||
{
|
|
||||||
taskType: 'History',
|
|
||||||
prompt: [
|
|
||||||
1,
|
|
||||||
'complete-item-id',
|
|
||||||
{},
|
|
||||||
{
|
|
||||||
client_id: 'test-client',
|
|
||||||
extra_pnginfo: {
|
|
||||||
workflow: {
|
|
||||||
id: '44f0c9f9-b5a7-48de-99fc-7e80c1570241',
|
|
||||||
revision: 0,
|
|
||||||
last_node_id: 9,
|
|
||||||
last_link_id: 9,
|
|
||||||
nodes: [],
|
|
||||||
links: [],
|
|
||||||
groups: [],
|
|
||||||
config: {},
|
|
||||||
extra: {},
|
|
||||||
version: 0.4
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
['9']
|
|
||||||
],
|
|
||||||
outputs: {
|
|
||||||
'9': {
|
|
||||||
images: [
|
|
||||||
{
|
|
||||||
filename: 'test.png',
|
|
||||||
subfolder: '',
|
|
||||||
type: 'output'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
status: {
|
|
||||||
status_str: 'success',
|
|
||||||
completed: true,
|
|
||||||
messages: [
|
|
||||||
[
|
|
||||||
'execution_start',
|
|
||||||
{ prompt_id: 'complete-item-id', timestamp: 1234567890 }
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'execution_success',
|
|
||||||
{ prompt_id: 'complete-item-id', timestamp: 1234567900 }
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
meta: {
|
|
||||||
'9': {
|
|
||||||
node_id: '9',
|
|
||||||
display_node: '9'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
taskType: 'History',
|
|
||||||
prompt: [
|
|
||||||
0,
|
|
||||||
'no-status-id',
|
|
||||||
{},
|
|
||||||
{
|
|
||||||
client_id: 'inference'
|
|
||||||
},
|
|
||||||
['10']
|
|
||||||
],
|
|
||||||
outputs: {
|
|
||||||
'10': {
|
|
||||||
images: []
|
|
||||||
}
|
|
||||||
},
|
|
||||||
status: undefined,
|
|
||||||
meta: {
|
|
||||||
'10': {
|
|
||||||
node_id: '10',
|
|
||||||
display_node: '10'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
taskType: 'History',
|
|
||||||
prompt: [
|
|
||||||
0,
|
|
||||||
'no-meta-id',
|
|
||||||
{},
|
|
||||||
{
|
|
||||||
client_id: 'web-ui'
|
|
||||||
},
|
|
||||||
['11']
|
|
||||||
],
|
|
||||||
outputs: {
|
|
||||||
'11': {
|
|
||||||
audio: []
|
|
||||||
}
|
|
||||||
},
|
|
||||||
status: {
|
|
||||||
status_str: 'error',
|
|
||||||
completed: false,
|
|
||||||
messages: []
|
|
||||||
},
|
|
||||||
meta: undefined
|
|
||||||
},
|
|
||||||
{
|
|
||||||
taskType: 'History',
|
|
||||||
prompt: [
|
|
||||||
0,
|
|
||||||
'multi-output-id',
|
|
||||||
{},
|
|
||||||
{
|
|
||||||
client_id: 'batch-processor'
|
|
||||||
},
|
|
||||||
['3', '9', '12']
|
|
||||||
],
|
|
||||||
outputs: {
|
|
||||||
'3': {
|
|
||||||
images: [{ filename: 'img1.png', type: 'output', subfolder: '' }]
|
|
||||||
},
|
|
||||||
'9': {
|
|
||||||
images: [{ filename: 'img2.png', type: 'output', subfolder: '' }]
|
|
||||||
},
|
|
||||||
'12': {
|
|
||||||
video: [{ filename: 'video.mp4', type: 'output', subfolder: '' }]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
status: {
|
|
||||||
status_str: 'success',
|
|
||||||
completed: true,
|
|
||||||
messages: []
|
|
||||||
},
|
|
||||||
meta: {
|
|
||||||
'3': { node_id: '3', display_node: '3' },
|
|
||||||
'9': { node_id: '9', display_node: '9' },
|
|
||||||
'12': { node_id: '12', display_node: '12' }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
@@ -1,434 +0,0 @@
|
|||||||
/**
|
|
||||||
* @fileoverview Unit tests for V2 to V1 history adapter.
|
|
||||||
*/
|
|
||||||
import { describe, expect, it } from 'vitest'
|
|
||||||
|
|
||||||
import { mapHistoryV2toHistory } from '@/platform/remote/comfyui/history/adapters/v2ToV1Adapter'
|
|
||||||
import { zRawHistoryItemV2 } from '@/platform/remote/comfyui/history/types/historyV2Types'
|
|
||||||
import type { HistoryResponseV2 } from '@/platform/remote/comfyui/history/types/historyV2Types'
|
|
||||||
|
|
||||||
import {
|
|
||||||
expectedV1Fixture,
|
|
||||||
historyV2Fixture
|
|
||||||
} from '@/platform/remote/comfyui/history/__fixtures__/historyFixtures'
|
|
||||||
import type { HistoryTaskItem } from '@/platform/remote/comfyui/history/types/historyV1Types'
|
|
||||||
|
|
||||||
const historyV2WithMissingTimestamp: HistoryResponseV2 = {
|
|
||||||
history: [
|
|
||||||
{
|
|
||||||
prompt_id: 'item-timestamp-1000',
|
|
||||||
prompt: {
|
|
||||||
priority: 0,
|
|
||||||
prompt_id: 'item-timestamp-1000',
|
|
||||||
extra_data: {
|
|
||||||
client_id: 'test-client'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
outputs: {
|
|
||||||
'1': {
|
|
||||||
images: [{ filename: 'test1.png', type: 'output', subfolder: '' }]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
status: {
|
|
||||||
status_str: 'success',
|
|
||||||
completed: true,
|
|
||||||
messages: [
|
|
||||||
[
|
|
||||||
'execution_success',
|
|
||||||
{ prompt_id: 'item-timestamp-1000', timestamp: 1000 }
|
|
||||||
]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
prompt_id: 'item-timestamp-2000',
|
|
||||||
prompt: {
|
|
||||||
priority: 0,
|
|
||||||
prompt_id: 'item-timestamp-2000',
|
|
||||||
extra_data: {
|
|
||||||
client_id: 'test-client'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
outputs: {
|
|
||||||
'2': {
|
|
||||||
images: [{ filename: 'test2.png', type: 'output', subfolder: '' }]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
status: {
|
|
||||||
status_str: 'success',
|
|
||||||
completed: true,
|
|
||||||
messages: [
|
|
||||||
[
|
|
||||||
'execution_success',
|
|
||||||
{ prompt_id: 'item-timestamp-2000', timestamp: 2000 }
|
|
||||||
]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
prompt_id: 'item-no-timestamp',
|
|
||||||
prompt: {
|
|
||||||
priority: 0,
|
|
||||||
prompt_id: 'item-no-timestamp',
|
|
||||||
extra_data: {
|
|
||||||
client_id: 'test-client'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
outputs: {
|
|
||||||
'3': {
|
|
||||||
images: [{ filename: 'test3.png', type: 'output', subfolder: '' }]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
status: {
|
|
||||||
status_str: 'success',
|
|
||||||
completed: true,
|
|
||||||
messages: []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
const historyV2FiveItemsSorting: HistoryResponseV2 = {
|
|
||||||
history: [
|
|
||||||
{
|
|
||||||
prompt_id: 'item-timestamp-3000',
|
|
||||||
prompt: {
|
|
||||||
priority: 0,
|
|
||||||
prompt_id: 'item-timestamp-3000',
|
|
||||||
extra_data: { client_id: 'test-client' }
|
|
||||||
},
|
|
||||||
outputs: {
|
|
||||||
'1': {
|
|
||||||
images: [{ filename: 'test1.png', type: 'output', subfolder: '' }]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
status: {
|
|
||||||
status_str: 'success',
|
|
||||||
completed: true,
|
|
||||||
messages: [
|
|
||||||
[
|
|
||||||
'execution_success',
|
|
||||||
{ prompt_id: 'item-timestamp-3000', timestamp: 3000 }
|
|
||||||
]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
prompt_id: 'item-timestamp-1000',
|
|
||||||
prompt: {
|
|
||||||
priority: 0,
|
|
||||||
prompt_id: 'item-timestamp-1000',
|
|
||||||
extra_data: { client_id: 'test-client' }
|
|
||||||
},
|
|
||||||
outputs: {
|
|
||||||
'2': {
|
|
||||||
images: [{ filename: 'test2.png', type: 'output', subfolder: '' }]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
status: {
|
|
||||||
status_str: 'success',
|
|
||||||
completed: true,
|
|
||||||
messages: [
|
|
||||||
[
|
|
||||||
'execution_success',
|
|
||||||
{ prompt_id: 'item-timestamp-1000', timestamp: 1000 }
|
|
||||||
]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
prompt_id: 'item-timestamp-5000',
|
|
||||||
prompt: {
|
|
||||||
priority: 0,
|
|
||||||
prompt_id: 'item-timestamp-5000',
|
|
||||||
extra_data: { client_id: 'test-client' }
|
|
||||||
},
|
|
||||||
outputs: {
|
|
||||||
'3': {
|
|
||||||
images: [{ filename: 'test3.png', type: 'output', subfolder: '' }]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
status: {
|
|
||||||
status_str: 'success',
|
|
||||||
completed: true,
|
|
||||||
messages: [
|
|
||||||
[
|
|
||||||
'execution_success',
|
|
||||||
{ prompt_id: 'item-timestamp-5000', timestamp: 5000 }
|
|
||||||
]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
prompt_id: 'item-timestamp-2000',
|
|
||||||
prompt: {
|
|
||||||
priority: 0,
|
|
||||||
prompt_id: 'item-timestamp-2000',
|
|
||||||
extra_data: { client_id: 'test-client' }
|
|
||||||
},
|
|
||||||
outputs: {
|
|
||||||
'4': {
|
|
||||||
images: [{ filename: 'test4.png', type: 'output', subfolder: '' }]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
status: {
|
|
||||||
status_str: 'success',
|
|
||||||
completed: true,
|
|
||||||
messages: [
|
|
||||||
[
|
|
||||||
'execution_success',
|
|
||||||
{ prompt_id: 'item-timestamp-2000', timestamp: 2000 }
|
|
||||||
]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
prompt_id: 'item-timestamp-4000',
|
|
||||||
prompt: {
|
|
||||||
priority: 0,
|
|
||||||
prompt_id: 'item-timestamp-4000',
|
|
||||||
extra_data: { client_id: 'test-client' }
|
|
||||||
},
|
|
||||||
outputs: {
|
|
||||||
'5': {
|
|
||||||
images: [{ filename: 'test5.png', type: 'output', subfolder: '' }]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
status: {
|
|
||||||
status_str: 'success',
|
|
||||||
completed: true,
|
|
||||||
messages: [
|
|
||||||
[
|
|
||||||
'execution_success',
|
|
||||||
{ prompt_id: 'item-timestamp-4000', timestamp: 4000 }
|
|
||||||
]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
const historyV2MultipleNoTimestamp: HistoryResponseV2 = {
|
|
||||||
history: [
|
|
||||||
{
|
|
||||||
prompt_id: 'item-no-timestamp-1',
|
|
||||||
prompt: {
|
|
||||||
priority: 0,
|
|
||||||
prompt_id: 'item-no-timestamp-1',
|
|
||||||
extra_data: { client_id: 'test-client' }
|
|
||||||
},
|
|
||||||
outputs: {
|
|
||||||
'1': {
|
|
||||||
images: [{ filename: 'test1.png', type: 'output', subfolder: '' }]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
status: {
|
|
||||||
status_str: 'success',
|
|
||||||
completed: true,
|
|
||||||
messages: []
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
prompt_id: 'item-no-timestamp-2',
|
|
||||||
prompt: {
|
|
||||||
priority: 0,
|
|
||||||
prompt_id: 'item-no-timestamp-2',
|
|
||||||
extra_data: { client_id: 'test-client' }
|
|
||||||
},
|
|
||||||
outputs: {
|
|
||||||
'2': {
|
|
||||||
images: [{ filename: 'test2.png', type: 'output', subfolder: '' }]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
status: {
|
|
||||||
status_str: 'success',
|
|
||||||
completed: true,
|
|
||||||
messages: []
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
prompt_id: 'item-no-timestamp-3',
|
|
||||||
prompt: {
|
|
||||||
priority: 0,
|
|
||||||
prompt_id: 'item-no-timestamp-3',
|
|
||||||
extra_data: { client_id: 'test-client' }
|
|
||||||
},
|
|
||||||
outputs: {
|
|
||||||
'3': {
|
|
||||||
images: [{ filename: 'test3.png', type: 'output', subfolder: '' }]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
status: {
|
|
||||||
status_str: 'success',
|
|
||||||
completed: true,
|
|
||||||
messages: []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
function findResultByPromptId(
|
|
||||||
result: HistoryTaskItem[],
|
|
||||||
promptId: string
|
|
||||||
): HistoryTaskItem {
|
|
||||||
const item = result.find((item) => item.prompt[1] === promptId)
|
|
||||||
if (!item) {
|
|
||||||
throw new Error(`Expected item with promptId ${promptId} not found`)
|
|
||||||
}
|
|
||||||
return item
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('mapHistoryV2toHistory', () => {
|
|
||||||
describe('fixture validation', () => {
|
|
||||||
it('should have valid fixture data', () => {
|
|
||||||
// Validate all items in the fixture to ensure test data is correct
|
|
||||||
historyV2Fixture.history.forEach((item: unknown) => {
|
|
||||||
expect(() => zRawHistoryItemV2.parse(item)).not.toThrow()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('given a complete V2 history response with edge cases', () => {
|
|
||||||
const history = mapHistoryV2toHistory(historyV2Fixture)
|
|
||||||
|
|
||||||
it('should transform all items to V1 format with correct structure', () => {
|
|
||||||
expect(history).toEqual(expectedV1Fixture)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should add taskType "History" to all items', () => {
|
|
||||||
history.forEach((item) => {
|
|
||||||
expect(item.taskType).toBe('History')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should transform prompt to V1 tuple [priority, id, {}, extra_data, outputNodeIds]', () => {
|
|
||||||
const firstItem = history[0]
|
|
||||||
|
|
||||||
expect(firstItem.prompt[0]).toBe(1) // Synthetic priority based on timestamp
|
|
||||||
expect(firstItem.prompt[1]).toBe('complete-item-id')
|
|
||||||
expect(firstItem.prompt[2]).toEqual({}) // history v2 does not return this data
|
|
||||||
expect(firstItem.prompt[3]).toMatchObject({ client_id: 'test-client' })
|
|
||||||
expect(firstItem.prompt[4]).toEqual(['9'])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should handle missing optional status field', () => {
|
|
||||||
expect(history[1].prompt[1]).toBe('no-status-id')
|
|
||||||
expect(history[1].status).toBeUndefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should handle missing optional meta field', () => {
|
|
||||||
expect(history[2].prompt[1]).toBe('no-meta-id')
|
|
||||||
expect(history[2].meta).toBeUndefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should derive output node IDs from outputs object keys', () => {
|
|
||||||
const multiOutputItem = history[3]
|
|
||||||
|
|
||||||
expect(multiOutputItem.prompt[4]).toEqual(
|
|
||||||
expect.arrayContaining(['3', '9', '12'])
|
|
||||||
)
|
|
||||||
expect(multiOutputItem.prompt[4]).toHaveLength(3)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('given empty history array', () => {
|
|
||||||
it('should return empty array', () => {
|
|
||||||
const emptyResponse: HistoryResponseV2 = { history: [] }
|
|
||||||
const history = mapHistoryV2toHistory(emptyResponse)
|
|
||||||
|
|
||||||
expect(history).toEqual([])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('given empty outputs object', () => {
|
|
||||||
it('should return empty array for output node IDs', () => {
|
|
||||||
const v2Response: HistoryResponseV2 = {
|
|
||||||
history: [
|
|
||||||
{
|
|
||||||
prompt_id: 'test-id',
|
|
||||||
prompt: {
|
|
||||||
priority: 0,
|
|
||||||
prompt_id: 'test-id',
|
|
||||||
extra_data: { client_id: 'test' }
|
|
||||||
},
|
|
||||||
outputs: {}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
const history = mapHistoryV2toHistory(v2Response)
|
|
||||||
|
|
||||||
expect(history[0].prompt[4]).toEqual([])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('given missing client_id', () => {
|
|
||||||
it('should accept history items without client_id', () => {
|
|
||||||
const v2Response: HistoryResponseV2 = {
|
|
||||||
history: [
|
|
||||||
{
|
|
||||||
prompt_id: 'test-id',
|
|
||||||
prompt: {
|
|
||||||
priority: 0,
|
|
||||||
prompt_id: 'test-id',
|
|
||||||
extra_data: {}
|
|
||||||
},
|
|
||||||
outputs: {}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
const history = mapHistoryV2toHistory(v2Response)
|
|
||||||
|
|
||||||
expect(history[0].prompt[3].client_id).toBeUndefined()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('timestamp-based priority assignment', () => {
|
|
||||||
it('assigns priority 0 to items without execution_success timestamp', () => {
|
|
||||||
const result = mapHistoryV2toHistory(historyV2WithMissingTimestamp)
|
|
||||||
|
|
||||||
expect(result).toHaveLength(3)
|
|
||||||
|
|
||||||
const item1000 = findResultByPromptId(result, 'item-timestamp-1000')
|
|
||||||
const item2000 = findResultByPromptId(result, 'item-timestamp-2000')
|
|
||||||
const itemNoTimestamp = findResultByPromptId(result, 'item-no-timestamp')
|
|
||||||
|
|
||||||
expect(item2000.prompt[0]).toBe(2)
|
|
||||||
expect(item1000.prompt[0]).toBe(1)
|
|
||||||
expect(itemNoTimestamp.prompt[0]).toBe(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('correctly sorts and assigns priorities for multiple items', () => {
|
|
||||||
const result = mapHistoryV2toHistory(historyV2FiveItemsSorting)
|
|
||||||
|
|
||||||
expect(result).toHaveLength(5)
|
|
||||||
|
|
||||||
const item1000 = findResultByPromptId(result, 'item-timestamp-1000')
|
|
||||||
const item2000 = findResultByPromptId(result, 'item-timestamp-2000')
|
|
||||||
const item3000 = findResultByPromptId(result, 'item-timestamp-3000')
|
|
||||||
const item4000 = findResultByPromptId(result, 'item-timestamp-4000')
|
|
||||||
const item5000 = findResultByPromptId(result, 'item-timestamp-5000')
|
|
||||||
|
|
||||||
expect(item5000.prompt[0]).toBe(5)
|
|
||||||
expect(item4000.prompt[0]).toBe(4)
|
|
||||||
expect(item3000.prompt[0]).toBe(3)
|
|
||||||
expect(item2000.prompt[0]).toBe(2)
|
|
||||||
expect(item1000.prompt[0]).toBe(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('assigns priority 0 to all items when multiple items lack timestamps', () => {
|
|
||||||
const result = mapHistoryV2toHistory(historyV2MultipleNoTimestamp)
|
|
||||||
|
|
||||||
expect(result).toHaveLength(3)
|
|
||||||
|
|
||||||
const item1 = findResultByPromptId(result, 'item-no-timestamp-1')
|
|
||||||
const item2 = findResultByPromptId(result, 'item-no-timestamp-2')
|
|
||||||
const item3 = findResultByPromptId(result, 'item-no-timestamp-3')
|
|
||||||
|
|
||||||
expect(item1.prompt[0]).toBe(0)
|
|
||||||
expect(item2.prompt[0]).toBe(0)
|
|
||||||
expect(item3.prompt[0]).toBe(0)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
/**
|
|
||||||
* @fileoverview Adapter to convert V2 history format to V1 format
|
|
||||||
* @module platform/remote/comfyui/history/adapters/v2ToV1Adapter
|
|
||||||
*/
|
|
||||||
import type { HistoryTaskItem, TaskPrompt } from '../types/historyV1Types'
|
|
||||||
import type {
|
|
||||||
HistoryResponseV2,
|
|
||||||
RawHistoryItemV2,
|
|
||||||
TaskOutput,
|
|
||||||
TaskPromptV2
|
|
||||||
} from '../types/historyV2Types'
|
|
||||||
|
|
||||||
function mapPromptV2toV1(
|
|
||||||
promptV2: TaskPromptV2,
|
|
||||||
outputs: TaskOutput,
|
|
||||||
syntheticPriority: number,
|
|
||||||
createTime?: number
|
|
||||||
): TaskPrompt {
|
|
||||||
const extraData = {
|
|
||||||
...(promptV2.extra_data ?? {}),
|
|
||||||
...(typeof createTime === 'number' ? { create_time: createTime } : {})
|
|
||||||
}
|
|
||||||
return [
|
|
||||||
syntheticPriority,
|
|
||||||
promptV2.prompt_id,
|
|
||||||
{},
|
|
||||||
extraData,
|
|
||||||
Object.keys(outputs)
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
function getExecutionSuccessTimestamp(item: RawHistoryItemV2): number {
|
|
||||||
return (
|
|
||||||
item.status?.messages?.find((m) => m[0] === 'execution_success')?.[1]
|
|
||||||
?.timestamp ?? 0
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mapHistoryV2toHistory(
|
|
||||||
historyV2Response: HistoryResponseV2
|
|
||||||
): HistoryTaskItem[] {
|
|
||||||
const { history } = historyV2Response
|
|
||||||
|
|
||||||
// Sort by execution_success timestamp, descending (newest first)
|
|
||||||
history.sort((a, b) => {
|
|
||||||
return getExecutionSuccessTimestamp(b) - getExecutionSuccessTimestamp(a)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Count items with valid timestamps for synthetic priority calculation
|
|
||||||
const countWithTimestamps = history.filter(
|
|
||||||
(item) => getExecutionSuccessTimestamp(item) > 0
|
|
||||||
).length
|
|
||||||
|
|
||||||
return history.map((item, index): HistoryTaskItem => {
|
|
||||||
const { prompt, outputs, status, meta } = item
|
|
||||||
const timestamp = getExecutionSuccessTimestamp(item)
|
|
||||||
|
|
||||||
// Items with timestamps get priority based on sorted position (highest first)
|
|
||||||
const syntheticPriority = timestamp > 0 ? countWithTimestamps - index : 0
|
|
||||||
|
|
||||||
return {
|
|
||||||
taskType: 'History' as const,
|
|
||||||
prompt: mapPromptV2toV1(
|
|
||||||
prompt,
|
|
||||||
outputs,
|
|
||||||
syntheticPriority,
|
|
||||||
item.create_time
|
|
||||||
),
|
|
||||||
status,
|
|
||||||
outputs,
|
|
||||||
meta
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
/**
|
|
||||||
* @fileoverview Unit tests for V1 history fetcher.
|
|
||||||
*/
|
|
||||||
import { describe, expect, it, vi } from 'vitest'
|
|
||||||
|
|
||||||
import { fetchHistoryV1 } from '@/platform/remote/comfyui/history/fetchers/fetchHistoryV1'
|
|
||||||
|
|
||||||
import { historyV1RawResponse } from '@/platform/remote/comfyui/history/__fixtures__/historyFixtures'
|
|
||||||
|
|
||||||
describe('fetchHistoryV1', () => {
|
|
||||||
const mockFetchApi = vi.fn().mockResolvedValue({
|
|
||||||
json: async () => historyV1RawResponse
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should fetch from /history endpoint with default max_items', async () => {
|
|
||||||
await fetchHistoryV1(mockFetchApi)
|
|
||||||
|
|
||||||
expect(mockFetchApi).toHaveBeenCalledWith('/history?max_items=200')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should fetch with custom max_items parameter', async () => {
|
|
||||||
await fetchHistoryV1(mockFetchApi, 50)
|
|
||||||
|
|
||||||
expect(mockFetchApi).toHaveBeenCalledWith('/history?max_items=50')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should transform object response to array with taskType and preserve fields', async () => {
|
|
||||||
const result = await fetchHistoryV1(mockFetchApi)
|
|
||||||
|
|
||||||
expect(result.History).toHaveLength(2)
|
|
||||||
result.History.forEach((item) => {
|
|
||||||
expect(item.taskType).toBe('History')
|
|
||||||
})
|
|
||||||
expect(result.History[0]).toMatchObject({
|
|
||||||
taskType: 'History',
|
|
||||||
prompt: [24, 'complete-item-id', {}, expect.any(Object), ['9']],
|
|
||||||
outputs: expect.any(Object),
|
|
||||||
status: expect.any(Object),
|
|
||||||
meta: expect.any(Object)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should handle empty response object', async () => {
|
|
||||||
const emptyMock = vi.fn().mockResolvedValue({
|
|
||||||
json: async () => ({})
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = await fetchHistoryV1(emptyMock)
|
|
||||||
|
|
||||||
expect(result.History).toEqual([])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
/**
|
|
||||||
* @fileoverview V1 History Fetcher - Desktop/localhost API
|
|
||||||
* @module platform/remote/comfyui/history/fetchers/fetchHistoryV1
|
|
||||||
*
|
|
||||||
* Fetches history directly from V1 API endpoint.
|
|
||||||
* Used by desktop and localhost distributions.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type {
|
|
||||||
HistoryTaskItem,
|
|
||||||
HistoryV1Response
|
|
||||||
} from '../types/historyV1Types'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches history from V1 API endpoint
|
|
||||||
* @param api - API instance with fetchApi method
|
|
||||||
* @param maxItems - Maximum number of history items to fetch
|
|
||||||
* @param offset - Offset for pagination (must be non-negative integer)
|
|
||||||
* @returns Promise resolving to V1 history response
|
|
||||||
* @throws Error if offset is invalid (negative or non-integer)
|
|
||||||
*/
|
|
||||||
export async function fetchHistoryV1(
|
|
||||||
fetchApi: (url: string) => Promise<Response>,
|
|
||||||
maxItems: number = 200,
|
|
||||||
offset?: number
|
|
||||||
): Promise<HistoryV1Response> {
|
|
||||||
// Validate offset parameter
|
|
||||||
if (offset !== undefined && (offset < 0 || !Number.isInteger(offset))) {
|
|
||||||
throw new Error(
|
|
||||||
`Invalid offset parameter: ${offset}. Must be a non-negative integer.`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const params = new URLSearchParams({ max_items: maxItems.toString() })
|
|
||||||
if (offset !== undefined) {
|
|
||||||
params.set('offset', offset.toString())
|
|
||||||
}
|
|
||||||
const url = `/history?${params.toString()}`
|
|
||||||
const res = await fetchApi(url)
|
|
||||||
const json: Record<
|
|
||||||
string,
|
|
||||||
Omit<HistoryTaskItem, 'taskType'>
|
|
||||||
> = await res.json()
|
|
||||||
|
|
||||||
return {
|
|
||||||
History: Object.values(json).map((item) => ({
|
|
||||||
...item,
|
|
||||||
taskType: 'History'
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
/**
|
|
||||||
* @fileoverview Unit tests for V2 history fetcher.
|
|
||||||
*/
|
|
||||||
import { describe, expect, it, vi } from 'vitest'
|
|
||||||
|
|
||||||
import { fetchHistoryV2 } from '@/platform/remote/comfyui/history/fetchers/fetchHistoryV2'
|
|
||||||
|
|
||||||
import {
|
|
||||||
expectedV1Fixture,
|
|
||||||
historyV2Fixture
|
|
||||||
} from '@/platform/remote/comfyui/history/__fixtures__/historyFixtures'
|
|
||||||
|
|
||||||
describe('fetchHistoryV2', () => {
|
|
||||||
const mockFetchApi = vi.fn().mockResolvedValue({
|
|
||||||
json: async () => historyV2Fixture
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should fetch from /history_v2 endpoint with default max_items', async () => {
|
|
||||||
await fetchHistoryV2(mockFetchApi)
|
|
||||||
|
|
||||||
expect(mockFetchApi).toHaveBeenCalledWith('/history_v2?max_items=200')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should fetch with custom max_items parameter', async () => {
|
|
||||||
await fetchHistoryV2(mockFetchApi, 50)
|
|
||||||
|
|
||||||
expect(mockFetchApi).toHaveBeenCalledWith('/history_v2?max_items=50')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should adapt V2 response to V1-compatible format', async () => {
|
|
||||||
const result = await fetchHistoryV2(mockFetchApi)
|
|
||||||
|
|
||||||
expect(result.History).toEqual(expectedV1Fixture)
|
|
||||||
expect(result).toHaveProperty('History')
|
|
||||||
expect(Array.isArray(result.History)).toBe(true)
|
|
||||||
result.History.forEach((item) => {
|
|
||||||
expect(item.taskType).toBe('History')
|
|
||||||
expect(item.prompt).toHaveLength(5)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
/**
|
|
||||||
* @fileoverview V2 History Fetcher - Cloud API with adapter
|
|
||||||
* @module platform/remote/comfyui/history/fetchers/fetchHistoryV2
|
|
||||||
*
|
|
||||||
* Fetches history from V2 API endpoint and converts to V1 format.
|
|
||||||
* Used exclusively by cloud distribution.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { mapHistoryV2toHistory } from '../adapters/v2ToV1Adapter'
|
|
||||||
import type { HistoryV1Response } from '../types/historyV1Types'
|
|
||||||
import type { HistoryResponseV2 } from '../types/historyV2Types'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches history from V2 API endpoint and adapts to V1 format
|
|
||||||
* @param fetchApi - API instance with fetchApi method
|
|
||||||
* @param maxItems - Maximum number of history items to fetch
|
|
||||||
* @param offset - Offset for pagination (must be non-negative integer)
|
|
||||||
* @returns Promise resolving to V1 history response (adapted from V2)
|
|
||||||
* @throws Error if offset is invalid (negative or non-integer)
|
|
||||||
*/
|
|
||||||
export async function fetchHistoryV2(
|
|
||||||
fetchApi: (url: string) => Promise<Response>,
|
|
||||||
maxItems: number = 200,
|
|
||||||
offset?: number
|
|
||||||
): Promise<HistoryV1Response> {
|
|
||||||
// Validate offset parameter
|
|
||||||
if (offset !== undefined && (offset < 0 || !Number.isInteger(offset))) {
|
|
||||||
throw new Error(
|
|
||||||
`Invalid offset parameter: ${offset}. Must be a non-negative integer.`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const params = new URLSearchParams({ max_items: maxItems.toString() })
|
|
||||||
if (offset !== undefined) {
|
|
||||||
params.set('offset', offset.toString())
|
|
||||||
}
|
|
||||||
const url = `/history_v2?${params.toString()}`
|
|
||||||
const res = await fetchApi(url)
|
|
||||||
const rawData: HistoryResponseV2 = await res.json()
|
|
||||||
const adaptedHistory = mapHistoryV2toHistory(rawData)
|
|
||||||
return { History: adaptedHistory }
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
/**
|
|
||||||
* @fileoverview History API module - Distribution-aware exports
|
|
||||||
* @module platform/remote/comfyui/history
|
|
||||||
*
|
|
||||||
* This module provides a unified history fetching interface that automatically
|
|
||||||
* uses the correct implementation based on build-time distribution constant.
|
|
||||||
*
|
|
||||||
* - Cloud builds: Uses V2 API with adapter (tree-shakes V1 fetcher)
|
|
||||||
* - Desktop/localhost builds: Uses V1 API directly (tree-shakes V2 fetcher + adapter)
|
|
||||||
*
|
|
||||||
* The rest of the application only needs to import from this module and use
|
|
||||||
* V1 types - all distribution-specific details are encapsulated here.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { isCloud } from '@/platform/distribution/types'
|
|
||||||
import { fetchHistoryV1 } from './fetchers/fetchHistoryV1'
|
|
||||||
import { fetchHistoryV2 } from './fetchers/fetchHistoryV2'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches history using the appropriate API for the current distribution.
|
|
||||||
* Build-time constant enables dead code elimination - only one implementation
|
|
||||||
* will be included in the final bundle.
|
|
||||||
*/
|
|
||||||
export const fetchHistory = isCloud ? fetchHistoryV2 : fetchHistoryV1
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Export only V1 types publicly - consumers don't need to know about V2
|
|
||||||
*/
|
|
||||||
export type * from './types'
|
|
||||||
@@ -1,335 +0,0 @@
|
|||||||
/**
|
|
||||||
* @fileoverview Tests for history reconciliation (V1 and V2)
|
|
||||||
*/
|
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
||||||
|
|
||||||
import { reconcileHistory } from '@/platform/remote/comfyui/history/reconciliation'
|
|
||||||
import type { TaskItem } from '@/schemas/apiSchema'
|
|
||||||
|
|
||||||
// Mock distribution types
|
|
||||||
vi.mock('@/platform/distribution/types', () => ({
|
|
||||||
isCloud: false,
|
|
||||||
isDesktop: true
|
|
||||||
}))
|
|
||||||
|
|
||||||
function createHistoryItem(promptId: string, queueIndex = 0): TaskItem {
|
|
||||||
return {
|
|
||||||
taskType: 'History',
|
|
||||||
prompt: [queueIndex, promptId, {}, {}, []],
|
|
||||||
status: { status_str: 'success', completed: true, messages: [] },
|
|
||||||
outputs: {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getAllPromptIds(result: TaskItem[]): string[] {
|
|
||||||
return result.map((item) => item.prompt[1])
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('reconcileHistory (V1)', () => {
|
|
||||||
beforeEach(async () => {
|
|
||||||
const distTypes = await import('@/platform/distribution/types')
|
|
||||||
vi.mocked(distTypes).isCloud = false
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('when filtering by queueIndex', () => {
|
|
||||||
it('should retain items with queueIndex greater than lastKnownQueueIndex', () => {
|
|
||||||
const serverHistory = [
|
|
||||||
createHistoryItem('new-1', 11),
|
|
||||||
createHistoryItem('new-2', 10),
|
|
||||||
createHistoryItem('old', 5)
|
|
||||||
]
|
|
||||||
const clientHistory = [createHistoryItem('old', 5)]
|
|
||||||
|
|
||||||
const result = reconcileHistory(serverHistory, clientHistory, 10, 9)
|
|
||||||
|
|
||||||
const promptIds = getAllPromptIds(result)
|
|
||||||
expect(promptIds).toHaveLength(3)
|
|
||||||
expect(promptIds).toContain('new-1')
|
|
||||||
expect(promptIds).toContain('new-2')
|
|
||||||
expect(promptIds).toContain('old')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should evict items with queueIndex less than or equal to lastKnownQueueIndex', () => {
|
|
||||||
const serverHistory = [
|
|
||||||
createHistoryItem('new', 11),
|
|
||||||
createHistoryItem('existing', 10),
|
|
||||||
createHistoryItem('old-should-not-appear', 5)
|
|
||||||
]
|
|
||||||
const clientHistory = [createHistoryItem('existing', 10)]
|
|
||||||
|
|
||||||
const result = reconcileHistory(serverHistory, clientHistory, 10, 10)
|
|
||||||
|
|
||||||
const promptIds = getAllPromptIds(result)
|
|
||||||
expect(promptIds).toHaveLength(2)
|
|
||||||
expect(promptIds).toContain('new')
|
|
||||||
expect(promptIds).toContain('existing')
|
|
||||||
expect(promptIds).not.toContain('old-should-not-appear')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should retain all server items when lastKnownQueueIndex is undefined', () => {
|
|
||||||
const serverHistory = [
|
|
||||||
createHistoryItem('item-1', 5),
|
|
||||||
createHistoryItem('item-2', 4)
|
|
||||||
]
|
|
||||||
|
|
||||||
const result = reconcileHistory(serverHistory, [], 10, undefined)
|
|
||||||
|
|
||||||
expect(result).toHaveLength(2)
|
|
||||||
expect(result[0].prompt[1]).toBe('item-1')
|
|
||||||
expect(result[1].prompt[1]).toBe('item-2')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('when reconciling with existing client items', () => {
|
|
||||||
it('should retain client items that still exist on server', () => {
|
|
||||||
const serverHistory = [
|
|
||||||
createHistoryItem('new', 11),
|
|
||||||
createHistoryItem('existing-1', 9),
|
|
||||||
createHistoryItem('existing-2', 8)
|
|
||||||
]
|
|
||||||
const clientHistory = [
|
|
||||||
createHistoryItem('existing-1', 9),
|
|
||||||
createHistoryItem('existing-2', 8)
|
|
||||||
]
|
|
||||||
|
|
||||||
const result = reconcileHistory(serverHistory, clientHistory, 10, 10)
|
|
||||||
|
|
||||||
const promptIds = getAllPromptIds(result)
|
|
||||||
expect(promptIds).toHaveLength(3)
|
|
||||||
expect(promptIds).toContain('new')
|
|
||||||
expect(promptIds).toContain('existing-1')
|
|
||||||
expect(promptIds).toContain('existing-2')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should evict client items that no longer exist on server', () => {
|
|
||||||
const serverHistory = [
|
|
||||||
createHistoryItem('new', 11),
|
|
||||||
createHistoryItem('keep', 9)
|
|
||||||
]
|
|
||||||
const clientHistory = [
|
|
||||||
createHistoryItem('keep', 9),
|
|
||||||
createHistoryItem('removed-from-server', 8)
|
|
||||||
]
|
|
||||||
|
|
||||||
const result = reconcileHistory(serverHistory, clientHistory, 10, 10)
|
|
||||||
|
|
||||||
const promptIds = getAllPromptIds(result)
|
|
||||||
expect(promptIds).toHaveLength(2)
|
|
||||||
expect(promptIds).toContain('new')
|
|
||||||
expect(promptIds).toContain('keep')
|
|
||||||
expect(promptIds).not.toContain('removed-from-server')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('when limiting the result count', () => {
|
|
||||||
it('should respect the maxItems constraint', () => {
|
|
||||||
const serverHistory = Array.from({ length: 10 }, (_, i) =>
|
|
||||||
createHistoryItem(`item-${i}`, 20 + i)
|
|
||||||
)
|
|
||||||
|
|
||||||
const result = reconcileHistory(serverHistory, [], 5, 15)
|
|
||||||
|
|
||||||
const promptIds = getAllPromptIds(result)
|
|
||||||
expect(promptIds).toHaveLength(5)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should evict lowest priority items when exceeding capacity', () => {
|
|
||||||
const serverHistory = [
|
|
||||||
createHistoryItem('new-1', 13),
|
|
||||||
createHistoryItem('new-2', 12),
|
|
||||||
createHistoryItem('new-3', 11),
|
|
||||||
createHistoryItem('existing', 9)
|
|
||||||
]
|
|
||||||
const clientHistory = [createHistoryItem('existing', 9)]
|
|
||||||
|
|
||||||
const result = reconcileHistory(serverHistory, clientHistory, 2, 10)
|
|
||||||
|
|
||||||
expect(result).toHaveLength(2)
|
|
||||||
expect(result[0].prompt[1]).toBe('new-1')
|
|
||||||
expect(result[1].prompt[1]).toBe('new-2')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('when handling empty collections', () => {
|
|
||||||
it('should return all server items when client history is empty', () => {
|
|
||||||
const serverHistory = [
|
|
||||||
createHistoryItem('item-1', 10),
|
|
||||||
createHistoryItem('item-2', 9)
|
|
||||||
]
|
|
||||||
|
|
||||||
const result = reconcileHistory(serverHistory, [], 10, 8)
|
|
||||||
|
|
||||||
const promptIds = getAllPromptIds(result)
|
|
||||||
expect(promptIds).toHaveLength(2)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should return empty result when server history is empty', () => {
|
|
||||||
const clientHistory = [createHistoryItem('item-1', 5)]
|
|
||||||
|
|
||||||
const result = reconcileHistory([], clientHistory, 10, 5)
|
|
||||||
|
|
||||||
expect(result).toHaveLength(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should return empty result when both collections are empty', () => {
|
|
||||||
const result = reconcileHistory([], [], 10, undefined)
|
|
||||||
|
|
||||||
expect(result).toHaveLength(0)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('reconcileHistory (V2/Cloud)', () => {
|
|
||||||
beforeEach(async () => {
|
|
||||||
const distTypes = await import('@/platform/distribution/types')
|
|
||||||
vi.mocked(distTypes).isCloud = true
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('when adding new items from server', () => {
|
|
||||||
it('should retain items with promptIds not present in client history', () => {
|
|
||||||
const serverHistory = [
|
|
||||||
createHistoryItem('new-item'),
|
|
||||||
createHistoryItem('existing-item')
|
|
||||||
]
|
|
||||||
const clientHistory = [createHistoryItem('existing-item')]
|
|
||||||
|
|
||||||
const result = reconcileHistory(serverHistory, clientHistory, 10)
|
|
||||||
|
|
||||||
const promptIds = getAllPromptIds(result)
|
|
||||||
expect(promptIds).toHaveLength(2)
|
|
||||||
expect(promptIds).toContain('new-item')
|
|
||||||
expect(promptIds).toContain('existing-item')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should respect priority ordering when retaining multiple new items', () => {
|
|
||||||
const serverHistory = [
|
|
||||||
createHistoryItem('new-1'),
|
|
||||||
createHistoryItem('new-2'),
|
|
||||||
createHistoryItem('existing')
|
|
||||||
]
|
|
||||||
const clientHistory = [createHistoryItem('existing')]
|
|
||||||
|
|
||||||
const result = reconcileHistory(serverHistory, clientHistory, 10)
|
|
||||||
|
|
||||||
const promptIds = getAllPromptIds(result)
|
|
||||||
expect(promptIds).toHaveLength(3)
|
|
||||||
expect(promptIds).toContain('new-1')
|
|
||||||
expect(promptIds).toContain('new-2')
|
|
||||||
expect(promptIds).toContain('existing')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('when reconciling with existing client items', () => {
|
|
||||||
it('should retain client items that still exist on server', () => {
|
|
||||||
const serverHistory = [
|
|
||||||
createHistoryItem('item-1'),
|
|
||||||
createHistoryItem('item-2')
|
|
||||||
]
|
|
||||||
const clientHistory = [
|
|
||||||
createHistoryItem('item-1'),
|
|
||||||
createHistoryItem('item-2')
|
|
||||||
]
|
|
||||||
|
|
||||||
const result = reconcileHistory(serverHistory, clientHistory, 10)
|
|
||||||
|
|
||||||
const promptIds = getAllPromptIds(result)
|
|
||||||
expect(promptIds).toHaveLength(2)
|
|
||||||
expect(promptIds).toContain('item-1')
|
|
||||||
expect(promptIds).toContain('item-2')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should evict client items that no longer exist on server', () => {
|
|
||||||
const serverHistory = [createHistoryItem('item-1')]
|
|
||||||
const clientHistory = [
|
|
||||||
createHistoryItem('item-1'),
|
|
||||||
createHistoryItem('old-item')
|
|
||||||
]
|
|
||||||
|
|
||||||
const result = reconcileHistory(serverHistory, clientHistory, 10)
|
|
||||||
|
|
||||||
const promptIds = getAllPromptIds(result)
|
|
||||||
expect(promptIds).toHaveLength(1)
|
|
||||||
expect(promptIds).toContain('item-1')
|
|
||||||
expect(promptIds).not.toContain('old-item')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('when detecting new items by promptId', () => {
|
|
||||||
it('should retain new items regardless of queueIndex values', () => {
|
|
||||||
const serverHistory = [
|
|
||||||
createHistoryItem('existing', 100),
|
|
||||||
createHistoryItem('new-item', 50)
|
|
||||||
]
|
|
||||||
const clientHistory = [createHistoryItem('existing', 100)]
|
|
||||||
|
|
||||||
const result = reconcileHistory(serverHistory, clientHistory, 10)
|
|
||||||
|
|
||||||
const promptIds = getAllPromptIds(result)
|
|
||||||
expect(promptIds).toContain('new-item')
|
|
||||||
expect(promptIds).toContain('existing')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('when limiting the result count', () => {
|
|
||||||
it('should respect the maxItems constraint', () => {
|
|
||||||
const serverHistory = Array.from({ length: 10 }, (_, i) =>
|
|
||||||
createHistoryItem(`server-${i}`)
|
|
||||||
)
|
|
||||||
const clientHistory = Array.from({ length: 5 }, (_, i) =>
|
|
||||||
createHistoryItem(`client-${i}`)
|
|
||||||
)
|
|
||||||
|
|
||||||
const result = reconcileHistory(serverHistory, clientHistory, 5)
|
|
||||||
|
|
||||||
const promptIds = getAllPromptIds(result)
|
|
||||||
expect(promptIds).toHaveLength(5)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should evict lowest priority items when exceeding capacity', () => {
|
|
||||||
const serverHistory = [
|
|
||||||
createHistoryItem('new-1'),
|
|
||||||
createHistoryItem('new-2'),
|
|
||||||
createHistoryItem('existing')
|
|
||||||
]
|
|
||||||
const clientHistory = [createHistoryItem('existing')]
|
|
||||||
|
|
||||||
const result = reconcileHistory(serverHistory, clientHistory, 2)
|
|
||||||
|
|
||||||
expect(result).toHaveLength(2)
|
|
||||||
expect(result[0].prompt[1]).toBe('new-1')
|
|
||||||
expect(result[1].prompt[1]).toBe('new-2')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('when handling empty collections', () => {
|
|
||||||
it('should return all server items when client history is empty', () => {
|
|
||||||
const serverHistory = [
|
|
||||||
createHistoryItem('item-1'),
|
|
||||||
createHistoryItem('item-2')
|
|
||||||
]
|
|
||||||
|
|
||||||
const result = reconcileHistory(serverHistory, [], 10)
|
|
||||||
|
|
||||||
expect(result).toHaveLength(2)
|
|
||||||
expect(result[0].prompt[1]).toBe('item-1')
|
|
||||||
expect(result[1].prompt[1]).toBe('item-2')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should return empty result when server history is empty', () => {
|
|
||||||
const clientHistory = [
|
|
||||||
createHistoryItem('item-1'),
|
|
||||||
createHistoryItem('item-2')
|
|
||||||
]
|
|
||||||
|
|
||||||
const result = reconcileHistory([], clientHistory, 10)
|
|
||||||
|
|
||||||
expect(result).toHaveLength(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should return empty result when both collections are empty', () => {
|
|
||||||
const result = reconcileHistory([], [], 10)
|
|
||||||
|
|
||||||
expect(result).toHaveLength(0)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
/**
|
|
||||||
* @fileoverview History reconciliation for V1 and V2 APIs
|
|
||||||
* @module platform/remote/comfyui/history/reconciliation
|
|
||||||
*
|
|
||||||
* Returns list of items that should be displayed, sorted by queueIndex (newest first).
|
|
||||||
* Caller is responsible for mapping to their own class instances.
|
|
||||||
*
|
|
||||||
* V1: QueueIndex-based filtering for stable monotonic indices
|
|
||||||
* V2: PromptId-based merging for synthetic priorities (V2 assigns synthetic
|
|
||||||
* priorities after timestamp sorting, so new items may have lower priority
|
|
||||||
* than existing items)
|
|
||||||
*/
|
|
||||||
import { isCloud } from '@/platform/distribution/types'
|
|
||||||
import type { TaskItem } from '@/schemas/apiSchema'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* V1 reconciliation: QueueIndex-based filtering works because V1 has stable,
|
|
||||||
* monotonically increasing queue indices.
|
|
||||||
*
|
|
||||||
* Sort order: Sorts serverHistory by queueIndex descending (newest first) to ensure
|
|
||||||
* consistent ordering. JavaScript .filter() maintains iteration order, so filtered
|
|
||||||
* results remain sorted. clientHistory is assumed already sorted from previous update.
|
|
||||||
*
|
|
||||||
* @returns All items to display, sorted by queueIndex descending (newest first)
|
|
||||||
*/
|
|
||||||
function reconcileHistoryV1(
|
|
||||||
serverHistory: TaskItem[],
|
|
||||||
clientHistory: TaskItem[],
|
|
||||||
maxItems: number,
|
|
||||||
lastKnownQueueIndex: number | undefined
|
|
||||||
): TaskItem[] {
|
|
||||||
const sortedServerHistory = serverHistory.sort(
|
|
||||||
(a, b) => b.prompt[0] - a.prompt[0]
|
|
||||||
)
|
|
||||||
|
|
||||||
const serverPromptIds = new Set(
|
|
||||||
sortedServerHistory.map((item) => item.prompt[1])
|
|
||||||
)
|
|
||||||
|
|
||||||
// If undefined, treat as initial sync (all items are new)
|
|
||||||
const itemsAddedSinceLastSync =
|
|
||||||
lastKnownQueueIndex === undefined
|
|
||||||
? sortedServerHistory
|
|
||||||
: sortedServerHistory.filter(
|
|
||||||
(item) => item.prompt[0] > lastKnownQueueIndex
|
|
||||||
)
|
|
||||||
|
|
||||||
const clientItemsStillOnServer = clientHistory.filter((item) =>
|
|
||||||
serverPromptIds.has(item.prompt[1])
|
|
||||||
)
|
|
||||||
|
|
||||||
// Merge new and reused items, sort by queueIndex descending, limit to maxItems
|
|
||||||
return [...itemsAddedSinceLastSync, ...clientItemsStillOnServer]
|
|
||||||
.sort((a, b) => b.prompt[0] - a.prompt[0])
|
|
||||||
.slice(0, maxItems)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* V2 reconciliation: PromptId-based merging because V2 assigns synthetic
|
|
||||||
* priorities after sorting by timestamp.
|
|
||||||
*
|
|
||||||
* Sort order: Sorts serverHistory by queueIndex descending (newest first) to ensure
|
|
||||||
* consistent ordering. JavaScript .filter() maintains iteration order, so filtered
|
|
||||||
* results remain sorted. clientHistory is assumed already sorted from previous update.
|
|
||||||
*
|
|
||||||
* @returns All items to display, sorted by queueIndex descending (newest first)
|
|
||||||
*/
|
|
||||||
function reconcileHistoryV2(
|
|
||||||
serverHistory: TaskItem[],
|
|
||||||
clientHistory: TaskItem[],
|
|
||||||
maxItems: number
|
|
||||||
): TaskItem[] {
|
|
||||||
const sortedServerHistory = serverHistory.sort(
|
|
||||||
(a, b) => b.prompt[0] - a.prompt[0]
|
|
||||||
)
|
|
||||||
|
|
||||||
const serverPromptIds = new Set(
|
|
||||||
sortedServerHistory.map((item) => item.prompt[1])
|
|
||||||
)
|
|
||||||
const clientPromptIds = new Set(clientHistory.map((item) => item.prompt[1]))
|
|
||||||
|
|
||||||
const newItems = sortedServerHistory.filter(
|
|
||||||
(item) => !clientPromptIds.has(item.prompt[1])
|
|
||||||
)
|
|
||||||
|
|
||||||
const clientItemsStillOnServer = clientHistory.filter((item) =>
|
|
||||||
serverPromptIds.has(item.prompt[1])
|
|
||||||
)
|
|
||||||
|
|
||||||
// Merge new and reused items, sort by queueIndex descending, limit to maxItems
|
|
||||||
return [...newItems, ...clientItemsStillOnServer]
|
|
||||||
.sort((a, b) => b.prompt[0] - a.prompt[0])
|
|
||||||
.slice(0, maxItems)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reconciles server history with client history.
|
|
||||||
* Automatically uses V1 (queueIndex-based) or V2 (promptId-based) algorithm based on
|
|
||||||
* distribution type.
|
|
||||||
*
|
|
||||||
* @param serverHistory - Server's current history items
|
|
||||||
* @param clientHistory - Client's existing history items
|
|
||||||
* @param maxItems - Maximum number of items to return
|
|
||||||
* @param lastKnownQueueIndex - Last queue index seen (V1 only, optional for V2)
|
|
||||||
* @returns All items that should be displayed, sorted by queueIndex descending
|
|
||||||
*/
|
|
||||||
export function reconcileHistory(
|
|
||||||
serverHistory: TaskItem[],
|
|
||||||
clientHistory: TaskItem[],
|
|
||||||
maxItems: number,
|
|
||||||
lastKnownQueueIndex?: number
|
|
||||||
): TaskItem[] {
|
|
||||||
if (isCloud) {
|
|
||||||
return reconcileHistoryV2(serverHistory, clientHistory, maxItems)
|
|
||||||
}
|
|
||||||
return reconcileHistoryV1(
|
|
||||||
serverHistory,
|
|
||||||
clientHistory,
|
|
||||||
maxItems,
|
|
||||||
lastKnownQueueIndex
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
/**
|
|
||||||
* @fileoverview History V1 types - Public interface used throughout the app
|
|
||||||
* @module platform/remote/comfyui/history/types/historyV1Types
|
|
||||||
*
|
|
||||||
* These types represent the V1 history format that the application expects.
|
|
||||||
* Both desktop (direct V1 API) and cloud (V2 API + adapter) return data in this format.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { HistoryTaskItem, TaskPrompt } from '@/schemas/apiSchema'
|
|
||||||
|
|
||||||
export interface HistoryV1Response {
|
|
||||||
History: HistoryTaskItem[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export type { HistoryTaskItem, TaskPrompt }
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
/**
|
|
||||||
* @fileoverview History V2 types and schemas - Internal cloud API format
|
|
||||||
* @module platform/remote/comfyui/history/types/historyV2Types
|
|
||||||
*
|
|
||||||
* These types and schemas represent the V2 history format returned by the cloud API.
|
|
||||||
* They are only used internally and are converted to V1 format via adapter.
|
|
||||||
*
|
|
||||||
* IMPORTANT: These types should NOT be used outside this history module.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { z } from 'zod'
|
|
||||||
|
|
||||||
import {
|
|
||||||
zExtraData,
|
|
||||||
zPromptId,
|
|
||||||
zQueueIndex,
|
|
||||||
zStatus,
|
|
||||||
zTaskMeta,
|
|
||||||
zTaskOutput
|
|
||||||
} from '@/schemas/apiSchema'
|
|
||||||
|
|
||||||
const zTaskPromptV2 = z.object({
|
|
||||||
priority: zQueueIndex,
|
|
||||||
prompt_id: zPromptId,
|
|
||||||
extra_data: zExtraData
|
|
||||||
})
|
|
||||||
|
|
||||||
const zRawHistoryItemV2 = z.object({
|
|
||||||
prompt_id: zPromptId,
|
|
||||||
prompt: zTaskPromptV2,
|
|
||||||
status: zStatus.optional(),
|
|
||||||
outputs: zTaskOutput,
|
|
||||||
meta: zTaskMeta.optional(),
|
|
||||||
create_time: z.number().int().optional()
|
|
||||||
})
|
|
||||||
|
|
||||||
const zHistoryResponseV2 = z.object({
|
|
||||||
history: z.array(zRawHistoryItemV2)
|
|
||||||
})
|
|
||||||
|
|
||||||
export type TaskPromptV2 = z.infer<typeof zTaskPromptV2>
|
|
||||||
export type RawHistoryItemV2 = z.infer<typeof zRawHistoryItemV2>
|
|
||||||
export type HistoryResponseV2 = z.infer<typeof zHistoryResponseV2>
|
|
||||||
export type TaskOutput = z.infer<typeof zTaskOutput>
|
|
||||||
|
|
||||||
export { zRawHistoryItemV2 }
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
/**
|
|
||||||
* @fileoverview Public history types export
|
|
||||||
* @module platform/remote/comfyui/history/types
|
|
||||||
*
|
|
||||||
* Only V1 types are exported publicly - the rest of the app
|
|
||||||
* should never need to know about V2 types or implementation details.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export type * from './historyV1Types'
|
|
||||||
@@ -16,17 +16,13 @@ type JobsListResponse = z.infer<typeof zJobsListResponse>
|
|||||||
|
|
||||||
function createMockJob(
|
function createMockJob(
|
||||||
id: string,
|
id: string,
|
||||||
status: 'pending' | 'in_progress' | 'completed' = 'completed',
|
status: 'pending' | 'in_progress' | 'completed' | 'failed' = 'completed',
|
||||||
overrides: Partial<RawJobListItem> = {}
|
overrides: Partial<RawJobListItem> = {}
|
||||||
): RawJobListItem {
|
): RawJobListItem {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
status,
|
status,
|
||||||
create_time: Date.now(),
|
create_time: Date.now(),
|
||||||
execution_start_time: null,
|
|
||||||
execution_end_time: null,
|
|
||||||
preview_output: null,
|
|
||||||
outputs_count: 0,
|
|
||||||
...overrides
|
...overrides
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -63,7 +59,7 @@ describe('fetchJobs', () => {
|
|||||||
const result = await fetchHistory(mockFetch)
|
const result = await fetchHistory(mockFetch)
|
||||||
|
|
||||||
expect(mockFetch).toHaveBeenCalledWith(
|
expect(mockFetch).toHaveBeenCalledWith(
|
||||||
'/jobs?status=completed&limit=200&offset=0'
|
'/jobs?status=completed,failed,cancelled&limit=200&offset=0'
|
||||||
)
|
)
|
||||||
expect(result).toHaveLength(2)
|
expect(result).toHaveLength(2)
|
||||||
expect(result[0].id).toBe('job1')
|
expect(result[0].id).toBe('job1')
|
||||||
@@ -113,7 +109,7 @@ describe('fetchJobs', () => {
|
|||||||
const result = await fetchHistory(mockFetch, 200, 5)
|
const result = await fetchHistory(mockFetch, 200, 5)
|
||||||
|
|
||||||
expect(mockFetch).toHaveBeenCalledWith(
|
expect(mockFetch).toHaveBeenCalledWith(
|
||||||
'/jobs?status=completed&limit=200&offset=5'
|
'/jobs?status=completed,failed,cancelled&limit=200&offset=5'
|
||||||
)
|
)
|
||||||
// Priority base is total - offset = 10 - 5 = 5
|
// Priority base is total - offset = 10 - 5 = 5
|
||||||
expect(result[0].priority).toBe(5) // (total - offset) - 0
|
expect(result[0].priority).toBe(5) // (total - offset) - 0
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ function assignPriority(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches history (completed jobs)
|
* Fetches history (terminal state jobs: completed, failed, cancelled)
|
||||||
* Assigns synthetic priority starting from total (lower than queue jobs).
|
* Assigns synthetic priority starting from total (lower than queue jobs).
|
||||||
*/
|
*/
|
||||||
export async function fetchHistory(
|
export async function fetchHistory(
|
||||||
@@ -78,7 +78,7 @@ export async function fetchHistory(
|
|||||||
): Promise<JobListItem[]> {
|
): Promise<JobListItem[]> {
|
||||||
const { jobs, total } = await fetchJobsRaw(
|
const { jobs, total } = await fetchJobsRaw(
|
||||||
fetchApi,
|
fetchApi,
|
||||||
['completed'],
|
['completed', 'failed', 'cancelled'],
|
||||||
maxItems,
|
maxItems,
|
||||||
offset
|
offset
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -21,12 +21,15 @@ const zJobStatus = z.enum([
|
|||||||
const zPreviewOutput = z.object({
|
const zPreviewOutput = z.object({
|
||||||
filename: z.string(),
|
filename: z.string(),
|
||||||
subfolder: z.string(),
|
subfolder: z.string(),
|
||||||
type: resultItemType
|
type: resultItemType,
|
||||||
|
nodeId: z.string(),
|
||||||
|
mediaType: z.string()
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Execution error details for error jobs.
|
* Execution error from Jobs API.
|
||||||
* Contains the same structure as ExecutionErrorWsMessage from WebSocket.
|
* Similar to ExecutionErrorWsMessage but with optional prompt_id/timestamp/executed
|
||||||
|
* since these may not be present in stored errors or infrastructure-generated errors.
|
||||||
*/
|
*/
|
||||||
const zExecutionError = z
|
const zExecutionError = z
|
||||||
.object({
|
.object({
|
||||||
@@ -43,6 +46,8 @@ const zExecutionError = z
|
|||||||
})
|
})
|
||||||
.passthrough()
|
.passthrough()
|
||||||
|
|
||||||
|
export type ExecutionError = z.infer<typeof zExecutionError>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Raw job from API - uses passthrough to allow extra fields
|
* Raw job from API - uses passthrough to allow extra fields
|
||||||
*/
|
*/
|
||||||
@@ -105,3 +110,9 @@ export type RawJobListItem = z.infer<typeof zRawJobListItem>
|
|||||||
/** Job list item with priority always set (server-provided or synthetic) */
|
/** Job list item with priority always set (server-provided or synthetic) */
|
||||||
export type JobListItem = RawJobListItem & { priority: number }
|
export type JobListItem = RawJobListItem & { priority: number }
|
||||||
export type JobDetail = z.infer<typeof zJobDetail>
|
export type JobDetail = z.infer<typeof zJobDetail>
|
||||||
|
|
||||||
|
/** Task type used in the API (queue vs history endpoints) */
|
||||||
|
export type APITaskType = 'queue' | 'history'
|
||||||
|
|
||||||
|
/** Internal task type derived from job status for UI display */
|
||||||
|
export type TaskType = 'Running' | 'Pending' | 'History'
|
||||||
|
|||||||
@@ -42,4 +42,5 @@ export type RemoteConfig = {
|
|||||||
huggingface_model_import_enabled?: boolean
|
huggingface_model_import_enabled?: boolean
|
||||||
linear_toggle_enabled?: boolean
|
linear_toggle_enabled?: boolean
|
||||||
async_model_upload_enabled?: boolean
|
async_model_upload_enabled?: boolean
|
||||||
|
team_workspaces_enabled?: boolean
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -413,6 +413,7 @@ export type ExecutionTriggerSource =
|
|||||||
| 'keybinding'
|
| 'keybinding'
|
||||||
| 'legacy_ui'
|
| 'legacy_ui'
|
||||||
| 'unknown'
|
| 'unknown'
|
||||||
|
| 'linear'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Union type for all possible telemetry event properties
|
* Union type for all possible telemetry event properties
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest'
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import type { JobDetail } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||||
import { getWorkflowFromHistory } from '@/platform/workflow/cloud/getWorkflowFromHistory'
|
import {
|
||||||
|
extractWorkflow,
|
||||||
|
fetchJobDetail
|
||||||
|
} from '@/platform/remote/comfyui/jobs/fetchJobs'
|
||||||
|
|
||||||
const mockWorkflow: ComfyWorkflowJSON = {
|
const mockWorkflow: ComfyWorkflowJSON = {
|
||||||
id: 'test-workflow-id',
|
|
||||||
revision: 0,
|
|
||||||
last_node_id: 5,
|
last_node_id: 5,
|
||||||
last_link_id: 3,
|
last_link_id: 3,
|
||||||
nodes: [],
|
nodes: [],
|
||||||
@@ -16,75 +18,63 @@ const mockWorkflow: ComfyWorkflowJSON = {
|
|||||||
version: 0.4
|
version: 0.4
|
||||||
}
|
}
|
||||||
|
|
||||||
const mockHistoryResponse = {
|
// Jobs API detail response structure (matches actual /jobs/{id} response)
|
||||||
'test-prompt-id': {
|
// workflow is nested at: workflow.extra_data.extra_pnginfo.workflow
|
||||||
prompt: {
|
const mockJobDetailResponse: JobDetail = {
|
||||||
priority: 1,
|
id: 'test-prompt-id',
|
||||||
prompt_id: 'test-prompt-id',
|
status: 'completed',
|
||||||
extra_data: {
|
create_time: 1234567890,
|
||||||
client_id: 'test-client',
|
update_time: 1234567900,
|
||||||
extra_pnginfo: {
|
workflow: {
|
||||||
workflow: mockWorkflow
|
extra_data: {
|
||||||
}
|
extra_pnginfo: {
|
||||||
|
workflow: mockWorkflow
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
outputs: {},
|
},
|
||||||
status: {
|
outputs: {
|
||||||
status_str: 'success',
|
'20': {
|
||||||
completed: true,
|
images: [
|
||||||
messages: []
|
{ filename: 'test.png', subfolder: '', type: 'output' },
|
||||||
|
{ filename: 'test2.png', subfolder: '', type: 'output' }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('getWorkflowFromHistory', () => {
|
describe('fetchJobDetail', () => {
|
||||||
it('should fetch workflow from /history_v2/{prompt_id} endpoint', async () => {
|
it('should fetch job detail from /jobs/{prompt_id} endpoint', async () => {
|
||||||
const mockFetchApi = vi.fn().mockResolvedValue({
|
const mockFetchApi = vi.fn().mockResolvedValue({
|
||||||
json: async () => mockHistoryResponse
|
ok: true,
|
||||||
|
json: async () => mockJobDetailResponse
|
||||||
})
|
})
|
||||||
|
|
||||||
await getWorkflowFromHistory(mockFetchApi, 'test-prompt-id')
|
await fetchJobDetail(mockFetchApi, 'test-prompt-id')
|
||||||
|
|
||||||
expect(mockFetchApi).toHaveBeenCalledWith('/history_v2/test-prompt-id')
|
expect(mockFetchApi).toHaveBeenCalledWith('/jobs/test-prompt-id')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should extract and return workflow from response', async () => {
|
it('should return job detail with workflow and outputs', async () => {
|
||||||
const mockFetchApi = vi.fn().mockResolvedValue({
|
const mockFetchApi = vi.fn().mockResolvedValue({
|
||||||
json: async () => mockHistoryResponse
|
ok: true,
|
||||||
|
json: async () => mockJobDetailResponse
|
||||||
})
|
})
|
||||||
|
|
||||||
const result = await getWorkflowFromHistory(mockFetchApi, 'test-prompt-id')
|
const result = await fetchJobDetail(mockFetchApi, 'test-prompt-id')
|
||||||
|
|
||||||
expect(result).toEqual(mockWorkflow)
|
expect(result).toBeDefined()
|
||||||
|
expect(result?.id).toBe('test-prompt-id')
|
||||||
|
expect(result?.outputs).toEqual(mockJobDetailResponse.outputs)
|
||||||
|
expect(result?.workflow).toBeDefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should return undefined when prompt_id not found in response', async () => {
|
it('should return undefined when job not found (non-OK response)', async () => {
|
||||||
const mockFetchApi = vi.fn().mockResolvedValue({
|
const mockFetchApi = vi.fn().mockResolvedValue({
|
||||||
json: async () => ({})
|
ok: false,
|
||||||
|
status: 404
|
||||||
})
|
})
|
||||||
|
|
||||||
const result = await getWorkflowFromHistory(mockFetchApi, 'nonexistent-id')
|
const result = await fetchJobDetail(mockFetchApi, 'nonexistent-id')
|
||||||
|
|
||||||
expect(result).toBeUndefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should return undefined when workflow is missing from extra_pnginfo', async () => {
|
|
||||||
const mockFetchApi = vi.fn().mockResolvedValue({
|
|
||||||
json: async () => ({
|
|
||||||
'test-prompt-id': {
|
|
||||||
prompt: {
|
|
||||||
priority: 1,
|
|
||||||
prompt_id: 'test-prompt-id',
|
|
||||||
extra_data: {
|
|
||||||
client_id: 'test-client'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
outputs: {}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = await getWorkflowFromHistory(mockFetchApi, 'test-prompt-id')
|
|
||||||
|
|
||||||
expect(result).toBeUndefined()
|
expect(result).toBeUndefined()
|
||||||
})
|
})
|
||||||
@@ -92,19 +82,45 @@ describe('getWorkflowFromHistory', () => {
|
|||||||
it('should handle fetch errors gracefully', async () => {
|
it('should handle fetch errors gracefully', async () => {
|
||||||
const mockFetchApi = vi.fn().mockRejectedValue(new Error('Network error'))
|
const mockFetchApi = vi.fn().mockRejectedValue(new Error('Network error'))
|
||||||
|
|
||||||
const result = await getWorkflowFromHistory(mockFetchApi, 'test-prompt-id')
|
const result = await fetchJobDetail(mockFetchApi, 'test-prompt-id')
|
||||||
|
|
||||||
expect(result).toBeUndefined()
|
expect(result).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should handle malformed JSON responses', async () => {
|
it('should handle malformed JSON responses', async () => {
|
||||||
const mockFetchApi = vi.fn().mockResolvedValue({
|
const mockFetchApi = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
json: async () => {
|
json: async () => {
|
||||||
throw new Error('Invalid JSON')
|
throw new Error('Invalid JSON')
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const result = await getWorkflowFromHistory(mockFetchApi, 'test-prompt-id')
|
const result = await fetchJobDetail(mockFetchApi, 'test-prompt-id')
|
||||||
|
|
||||||
|
expect(result).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('extractWorkflow', () => {
|
||||||
|
it('should extract workflow from job detail', async () => {
|
||||||
|
const result = await extractWorkflow(mockJobDetailResponse)
|
||||||
|
|
||||||
|
expect(result).toEqual(mockWorkflow)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return undefined when job is undefined', async () => {
|
||||||
|
const result = await extractWorkflow(undefined)
|
||||||
|
|
||||||
|
expect(result).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return undefined when workflow is missing', async () => {
|
||||||
|
const jobWithoutWorkflow: JobDetail = {
|
||||||
|
...mockJobDetailResponse,
|
||||||
|
workflow: {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await extractWorkflow(jobWithoutWorkflow)
|
||||||
|
|
||||||
expect(result).toBeUndefined()
|
expect(result).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
|
||||||
import type { PromptId } from '@/schemas/apiSchema'
|
|
||||||
|
|
||||||
export async function getWorkflowFromHistory(
|
|
||||||
fetchApi: (url: string) => Promise<Response>,
|
|
||||||
promptId: PromptId
|
|
||||||
): Promise<ComfyWorkflowJSON | undefined> {
|
|
||||||
try {
|
|
||||||
const res = await fetchApi(`/history_v2/${promptId}`)
|
|
||||||
const json = await res.json()
|
|
||||||
|
|
||||||
const historyItem = json[promptId]
|
|
||||||
if (!historyItem) return undefined
|
|
||||||
|
|
||||||
const workflow = historyItem.prompt?.extra_data?.extra_pnginfo?.workflow
|
|
||||||
return workflow ?? undefined
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Failed to fetch workflow for prompt ${promptId}:`, error)
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
/**
|
|
||||||
* Cloud: Fetches workflow by prompt_id. Desktop: Returns undefined (workflows already in history).
|
|
||||||
*/
|
|
||||||
import { isCloud } from '@/platform/distribution/types'
|
|
||||||
|
|
||||||
import { getWorkflowFromHistory as cloudImpl } from './getWorkflowFromHistory'
|
|
||||||
|
|
||||||
export const getWorkflowFromHistory = isCloud
|
|
||||||
? cloudImpl
|
|
||||||
: async () => undefined
|
|
||||||
@@ -8,17 +8,18 @@ import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
|||||||
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
|
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
|
||||||
import { getAssetUrl } from '@/platform/assets/utils/assetUrlUtil'
|
import { getAssetUrl } from '@/platform/assets/utils/assetUrlUtil'
|
||||||
import { getWorkflowDataFromFile } from '@/scripts/metadata/parser'
|
import { getWorkflowDataFromFile } from '@/scripts/metadata/parser'
|
||||||
|
import { getJobWorkflow } from '@/services/jobOutputCache'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract workflow from AssetItem (async - may need file fetch)
|
* Extract workflow from AssetItem using jobs API
|
||||||
* Tries metadata first (for output assets), then falls back to extracting from file
|
* For output assets: uses jobs API (getJobWorkflow)
|
||||||
* This supports both output assets (with embedded metadata) and input assets (PNG with workflow)
|
* For input assets: extracts from file metadata
|
||||||
*
|
*
|
||||||
* @param asset The asset item to extract workflow from
|
* @param asset The asset item to extract workflow from
|
||||||
* @returns WorkflowSource with workflow and generated filename
|
* @returns WorkflowSource with workflow and generated filename
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* const asset = { name: 'output.png', user_metadata: { workflow: {...} } }
|
* const asset = { name: 'output.png', user_metadata: { promptId: '123' } }
|
||||||
* const { workflow, filename } = await extractWorkflowFromAsset(asset)
|
* const { workflow, filename } = await extractWorkflowFromAsset(asset)
|
||||||
*/
|
*/
|
||||||
export async function extractWorkflowFromAsset(asset: AssetItem): Promise<{
|
export async function extractWorkflowFromAsset(asset: AssetItem): Promise<{
|
||||||
@@ -27,17 +28,14 @@ export async function extractWorkflowFromAsset(asset: AssetItem): Promise<{
|
|||||||
}> {
|
}> {
|
||||||
const baseFilename = asset.name.replace(/\.[^/.]+$/, '.json')
|
const baseFilename = asset.name.replace(/\.[^/.]+$/, '.json')
|
||||||
|
|
||||||
// Strategy 1: Try metadata first (for output assets)
|
// For output assets: use jobs API (with caching and validation)
|
||||||
const metadata = getOutputAssetMetadata(asset.user_metadata)
|
const metadata = getOutputAssetMetadata(asset.user_metadata)
|
||||||
if (metadata?.workflow) {
|
if (metadata?.promptId) {
|
||||||
return {
|
const workflow = await getJobWorkflow(metadata.promptId)
|
||||||
workflow: metadata.workflow as ComfyWorkflowJSON,
|
return { workflow: workflow ?? null, filename: baseFilename }
|
||||||
filename: baseFilename
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strategy 2: Try extracting from file (for input assets with embedded workflow)
|
// For input assets: extract from file metadata (PNG/WEBP/FLAC with embedded workflow)
|
||||||
// This supports PNG, WEBP, FLAC, and other formats with metadata
|
|
||||||
try {
|
try {
|
||||||
const fileUrl = getAssetUrl(asset)
|
const fileUrl = getAssetUrl(asset)
|
||||||
const response = await fetch(fileUrl)
|
const response = await fetch(fileUrl)
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const height = ref('')
|
|||||||
ref="imageRef"
|
ref="imageRef"
|
||||||
:src
|
:src
|
||||||
v-bind="slotProps"
|
v-bind="slotProps"
|
||||||
|
class="h-full object-contain w-full"
|
||||||
@load="
|
@load="
|
||||||
() => {
|
() => {
|
||||||
if (!imageRef) return
|
if (!imageRef) return
|
||||||
|
|||||||
@@ -118,9 +118,6 @@ async function runButtonClick(e: Event) {
|
|||||||
? 'Comfy.QueuePromptFront'
|
? 'Comfy.QueuePromptFront'
|
||||||
: 'Comfy.QueuePrompt'
|
: 'Comfy.QueuePrompt'
|
||||||
|
|
||||||
useTelemetry()?.trackUiButtonClicked({
|
|
||||||
button_id: props.mobile ? 'queue_run_linear_mobile' : 'queue_run_linear'
|
|
||||||
})
|
|
||||||
if (batchCount.value > 1) {
|
if (batchCount.value > 1) {
|
||||||
useTelemetry()?.trackUiButtonClicked({
|
useTelemetry()?.trackUiButtonClicked({
|
||||||
button_id: 'queue_run_multiple_batches_submitted'
|
button_id: 'queue_run_multiple_batches_submitted'
|
||||||
@@ -129,7 +126,7 @@ async function runButtonClick(e: Event) {
|
|||||||
await commandStore.execute(commandId, {
|
await commandStore.execute(commandId, {
|
||||||
metadata: {
|
metadata: {
|
||||||
subscribe_to_run: false,
|
subscribe_to_run: false,
|
||||||
trigger_source: 'button'
|
trigger_source: 'linear'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -178,7 +178,7 @@ async function rerun(e: Event) {
|
|||||||
/>
|
/>
|
||||||
<img
|
<img
|
||||||
v-else
|
v-else
|
||||||
class="pointer-events-none object-contain flex-1 max-h-full md:contain-size brightness-50 opacity-10"
|
class="pointer-events-none flex-1 max-h-full md:contain-size brightness-50 opacity-10"
|
||||||
src="/assets/images/comfy-logo-mono.svg"
|
src="/assets/images/comfy-logo-mono.svg"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -247,18 +247,16 @@ useEventListener(document.body, 'keydown', (e: KeyboardEvent) => {
|
|||||||
"
|
"
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
<section
|
<template v-for="(item, index) in outputs.media.value" :key="index">
|
||||||
v-for="(item, index) in outputs.media.value"
|
<div
|
||||||
:key="index"
|
class="border-border-subtle not-md:border-l md:border-t first:border-none not-md:h-21 md:w-full m-3"
|
||||||
data-testid="linear-job"
|
/>
|
||||||
class="py-3 not-md:h-24 border-border-subtle flex md:flex-col md:w-full px-1 first:border-t-0 first:border-l-0 md:border-t-2 not-md:border-l-2"
|
|
||||||
>
|
|
||||||
<template v-for="(output, key) in allOutputs(item)" :key>
|
<template v-for="(output, key) in allOutputs(item)" :key>
|
||||||
<img
|
<img
|
||||||
v-if="getMediaType(output) === 'images'"
|
v-if="getMediaType(output) === 'images'"
|
||||||
:class="
|
:class="
|
||||||
cn(
|
cn(
|
||||||
'p-1 rounded-lg aspect-square object-cover',
|
'p-1 rounded-lg aspect-square object-cover not-md:h-20 md:w-full',
|
||||||
index === selectedIndex[0] &&
|
index === selectedIndex[0] &&
|
||||||
key === selectedIndex[1] &&
|
key === selectedIndex[1] &&
|
||||||
'border-2'
|
'border-2'
|
||||||
@@ -286,7 +284,7 @@ useEventListener(document.body, 'keydown', (e: KeyboardEvent) => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</section>
|
</template>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
<Teleport
|
<Teleport
|
||||||
|
|||||||
@@ -1,18 +1,14 @@
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
|
||||||
import { LinkMarkerShape } from '@/lib/litegraph/src/litegraph'
|
import { LinkMarkerShape } from '@/lib/litegraph/src/litegraph'
|
||||||
import {
|
import { zNodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||||
zComfyWorkflow,
|
|
||||||
zNodeId
|
|
||||||
} from '@/platform/workflow/validation/schemas/workflowSchema'
|
|
||||||
import { colorPalettesSchema } from '@/schemas/colorPaletteSchema'
|
import { colorPalettesSchema } from '@/schemas/colorPaletteSchema'
|
||||||
import { zKeybinding } from '@/schemas/keyBindingSchema'
|
import { zKeybinding } from '@/schemas/keyBindingSchema'
|
||||||
import { NodeBadgeMode } from '@/types/nodeSource'
|
import { NodeBadgeMode } from '@/types/nodeSource'
|
||||||
import { LinkReleaseTriggerAction } from '@/types/searchBoxTypes'
|
import { LinkReleaseTriggerAction } from '@/types/searchBoxTypes'
|
||||||
|
|
||||||
const zNodeType = z.string()
|
const zNodeType = z.string()
|
||||||
export const zQueueIndex = z.number()
|
const zPromptId = z.string()
|
||||||
export const zPromptId = z.string()
|
|
||||||
export type PromptId = z.infer<typeof zPromptId>
|
export type PromptId = z.infer<typeof zPromptId>
|
||||||
export const resultItemType = z.enum(['input', 'output', 'temp'])
|
export const resultItemType = z.enum(['input', 'output', 'temp'])
|
||||||
export type ResultItemType = z.infer<typeof resultItemType>
|
export type ResultItemType = z.infer<typeof resultItemType>
|
||||||
@@ -173,136 +169,9 @@ export type AssetDownloadWsMessage = z.infer<typeof zAssetDownloadWsMessage>
|
|||||||
|
|
||||||
export type NotificationWsMessage = z.infer<typeof zNotificationWsMessage>
|
export type NotificationWsMessage = z.infer<typeof zNotificationWsMessage>
|
||||||
|
|
||||||
const zPromptInputItem = z.object({
|
|
||||||
inputs: z.record(z.string(), z.any()),
|
|
||||||
class_type: zNodeType
|
|
||||||
})
|
|
||||||
|
|
||||||
const zPromptInputs = z.record(zPromptInputItem)
|
|
||||||
|
|
||||||
const zExtraPngInfo = z
|
|
||||||
.object({
|
|
||||||
workflow: zComfyWorkflow
|
|
||||||
})
|
|
||||||
.passthrough()
|
|
||||||
|
|
||||||
export const zExtraData = z
|
|
||||||
.object({
|
|
||||||
/** extra_pnginfo can be missing is backend execution gets a validation error. */
|
|
||||||
extra_pnginfo: zExtraPngInfo.optional(),
|
|
||||||
client_id: z.string().optional(),
|
|
||||||
// Cloud/Adapters: creation time in milliseconds when available
|
|
||||||
create_time: z.number().int().optional()
|
|
||||||
})
|
|
||||||
// Allow backend/adapters/extensions to add arbitrary metadata
|
|
||||||
.passthrough()
|
|
||||||
const zOutputsToExecute = z.array(zNodeId)
|
|
||||||
|
|
||||||
const zExecutionStartMessage = z.tuple([
|
|
||||||
z.literal('execution_start'),
|
|
||||||
zExecutionStartWsMessage
|
|
||||||
])
|
|
||||||
|
|
||||||
const zExecutionSuccessMessage = z.tuple([
|
|
||||||
z.literal('execution_success'),
|
|
||||||
zExecutionSuccessWsMessage
|
|
||||||
])
|
|
||||||
|
|
||||||
const zExecutionCachedMessage = z.tuple([
|
|
||||||
z.literal('execution_cached'),
|
|
||||||
zExecutionCachedWsMessage
|
|
||||||
])
|
|
||||||
|
|
||||||
const zExecutionInterruptedMessage = z.tuple([
|
|
||||||
z.literal('execution_interrupted'),
|
|
||||||
zExecutionInterruptedWsMessage
|
|
||||||
])
|
|
||||||
|
|
||||||
const zExecutionErrorMessage = z.tuple([
|
|
||||||
z.literal('execution_error'),
|
|
||||||
zExecutionErrorWsMessage
|
|
||||||
])
|
|
||||||
|
|
||||||
const zStatusMessage = z.union([
|
|
||||||
zExecutionStartMessage,
|
|
||||||
zExecutionSuccessMessage,
|
|
||||||
zExecutionCachedMessage,
|
|
||||||
zExecutionInterruptedMessage,
|
|
||||||
zExecutionErrorMessage
|
|
||||||
])
|
|
||||||
|
|
||||||
export const zStatus = z.object({
|
|
||||||
status_str: z.enum(['success', 'error']),
|
|
||||||
completed: z.boolean(),
|
|
||||||
messages: z.array(zStatusMessage)
|
|
||||||
})
|
|
||||||
|
|
||||||
const zTaskPrompt = z.tuple([
|
|
||||||
zQueueIndex,
|
|
||||||
zPromptId,
|
|
||||||
zPromptInputs,
|
|
||||||
zExtraData,
|
|
||||||
zOutputsToExecute
|
|
||||||
])
|
|
||||||
|
|
||||||
const zRunningTaskItem = z.object({
|
|
||||||
taskType: z.literal('Running'),
|
|
||||||
prompt: zTaskPrompt,
|
|
||||||
// @Deprecated
|
|
||||||
remove: z.object({
|
|
||||||
name: z.literal('Cancel'),
|
|
||||||
cb: z.function()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const zPendingTaskItem = z.object({
|
|
||||||
taskType: z.literal('Pending'),
|
|
||||||
prompt: zTaskPrompt
|
|
||||||
})
|
|
||||||
|
|
||||||
export const zTaskOutput = z.record(zNodeId, zOutputs)
|
export const zTaskOutput = z.record(zNodeId, zOutputs)
|
||||||
|
|
||||||
const zNodeOutputsMeta = z.object({
|
|
||||||
node_id: zNodeId,
|
|
||||||
display_node: zNodeId,
|
|
||||||
prompt_id: zPromptId.optional(),
|
|
||||||
read_node_id: zNodeId.optional()
|
|
||||||
})
|
|
||||||
|
|
||||||
export const zTaskMeta = z.record(zNodeId, zNodeOutputsMeta)
|
|
||||||
|
|
||||||
const zHistoryTaskItem = z.object({
|
|
||||||
taskType: z.literal('History'),
|
|
||||||
prompt: zTaskPrompt,
|
|
||||||
status: zStatus.optional(),
|
|
||||||
outputs: zTaskOutput,
|
|
||||||
meta: zTaskMeta.optional()
|
|
||||||
})
|
|
||||||
|
|
||||||
const zTaskItem = z.union([
|
|
||||||
zRunningTaskItem,
|
|
||||||
zPendingTaskItem,
|
|
||||||
zHistoryTaskItem
|
|
||||||
])
|
|
||||||
|
|
||||||
const zTaskType = z.union([
|
|
||||||
z.literal('Running'),
|
|
||||||
z.literal('Pending'),
|
|
||||||
z.literal('History')
|
|
||||||
])
|
|
||||||
|
|
||||||
export type TaskType = z.infer<typeof zTaskType>
|
|
||||||
export type TaskPrompt = z.infer<typeof zTaskPrompt>
|
|
||||||
export type TaskStatus = z.infer<typeof zStatus>
|
|
||||||
export type TaskOutput = z.infer<typeof zTaskOutput>
|
export type TaskOutput = z.infer<typeof zTaskOutput>
|
||||||
|
|
||||||
// `/queue`
|
|
||||||
export type RunningTaskItem = z.infer<typeof zRunningTaskItem>
|
|
||||||
export type PendingTaskItem = z.infer<typeof zPendingTaskItem>
|
|
||||||
// `/history`
|
|
||||||
export type HistoryTaskItem = z.infer<typeof zHistoryTaskItem>
|
|
||||||
export type TaskItem = z.infer<typeof zTaskItem>
|
|
||||||
|
|
||||||
const zEmbeddingsResponse = z.array(z.string())
|
const zEmbeddingsResponse = z.array(z.string())
|
||||||
const zExtensionsResponse = z.array(z.string())
|
const zExtensionsResponse = z.array(z.string())
|
||||||
const zError = z.object({
|
const zError = z.object({
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type {
|
|||||||
} from '@/platform/assets/schemas/assetSchema'
|
} from '@/platform/assets/schemas/assetSchema'
|
||||||
import { isCloud } from '@/platform/distribution/types'
|
import { isCloud } from '@/platform/distribution/types'
|
||||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||||
|
import type { IFuseOptions } from 'fuse.js'
|
||||||
import {
|
import {
|
||||||
type TemplateInfo,
|
type TemplateInfo,
|
||||||
type WorkflowTemplates
|
type WorkflowTemplates
|
||||||
@@ -31,16 +32,13 @@ import type {
|
|||||||
ExecutionSuccessWsMessage,
|
ExecutionSuccessWsMessage,
|
||||||
ExtensionsResponse,
|
ExtensionsResponse,
|
||||||
FeatureFlagsWsMessage,
|
FeatureFlagsWsMessage,
|
||||||
HistoryTaskItem,
|
|
||||||
LogsRawResponse,
|
LogsRawResponse,
|
||||||
LogsWsMessage,
|
LogsWsMessage,
|
||||||
NotificationWsMessage,
|
NotificationWsMessage,
|
||||||
PendingTaskItem,
|
|
||||||
ProgressStateWsMessage,
|
ProgressStateWsMessage,
|
||||||
ProgressTextWsMessage,
|
ProgressTextWsMessage,
|
||||||
ProgressWsMessage,
|
ProgressWsMessage,
|
||||||
PromptResponse,
|
PromptResponse,
|
||||||
RunningTaskItem,
|
|
||||||
Settings,
|
Settings,
|
||||||
StatusWsMessage,
|
StatusWsMessage,
|
||||||
StatusWsMessageStatus,
|
StatusWsMessageStatus,
|
||||||
@@ -49,12 +47,19 @@ import type {
|
|||||||
UserDataFullInfo,
|
UserDataFullInfo,
|
||||||
PreviewMethod
|
PreviewMethod
|
||||||
} from '@/schemas/apiSchema'
|
} from '@/schemas/apiSchema'
|
||||||
|
import type {
|
||||||
|
JobDetail,
|
||||||
|
JobListItem
|
||||||
|
} from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||||
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||||
import type { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
import type { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||||
import type { AuthHeader } from '@/types/authTypes'
|
import type { AuthHeader } from '@/types/authTypes'
|
||||||
import type { NodeExecutionId } from '@/types/nodeIdentification'
|
import type { NodeExecutionId } from '@/types/nodeIdentification'
|
||||||
import { fetchHistory } from '@/platform/remote/comfyui/history'
|
import {
|
||||||
import type { IFuseOptions } from 'fuse.js'
|
fetchHistory,
|
||||||
|
fetchJobDetail,
|
||||||
|
fetchQueue
|
||||||
|
} from '@/platform/remote/comfyui/jobs/fetchJobs'
|
||||||
|
|
||||||
interface QueuePromptRequestBody {
|
interface QueuePromptRequestBody {
|
||||||
client_id: string
|
client_id: string
|
||||||
@@ -670,7 +675,6 @@ export class ComfyApi extends EventTarget {
|
|||||||
case 'logs':
|
case 'logs':
|
||||||
case 'b_preview':
|
case 'b_preview':
|
||||||
case 'notification':
|
case 'notification':
|
||||||
case 'asset_download':
|
|
||||||
this.dispatchCustomEvent(msg.type, msg.data)
|
this.dispatchCustomEvent(msg.type, msg.data)
|
||||||
break
|
break
|
||||||
case 'feature_flags':
|
case 'feature_flags':
|
||||||
@@ -893,53 +897,13 @@ export class ComfyApi extends EventTarget {
|
|||||||
* @returns The currently running and queued items
|
* @returns The currently running and queued items
|
||||||
*/
|
*/
|
||||||
async getQueue(): Promise<{
|
async getQueue(): Promise<{
|
||||||
Running: RunningTaskItem[]
|
Running: JobListItem[]
|
||||||
Pending: PendingTaskItem[]
|
Pending: JobListItem[]
|
||||||
}> {
|
}> {
|
||||||
try {
|
try {
|
||||||
const res = await this.fetchApi('/queue')
|
return await fetchQueue(this.fetchApi.bind(this))
|
||||||
const data = await res.json()
|
|
||||||
// Normalize queue tuple shape across backends:
|
|
||||||
// - Backend (V1): [idx, prompt_id, inputs, extra_data(object), outputs_to_execute(array)]
|
|
||||||
// - Cloud: [idx, prompt_id, inputs, outputs_to_execute(array), metadata(object{create_time})]
|
|
||||||
const normalizeQueuePrompt = (prompt: any): any => {
|
|
||||||
if (!Array.isArray(prompt)) return prompt
|
|
||||||
// Ensure 5-tuple
|
|
||||||
const p = prompt.slice(0, 5)
|
|
||||||
const fourth = p[3]
|
|
||||||
const fifth = p[4]
|
|
||||||
// Cloud shape: 4th is array, 5th is metadata object
|
|
||||||
if (
|
|
||||||
Array.isArray(fourth) &&
|
|
||||||
fifth &&
|
|
||||||
typeof fifth === 'object' &&
|
|
||||||
!Array.isArray(fifth)
|
|
||||||
) {
|
|
||||||
const meta: any = fifth
|
|
||||||
const extraData = { ...meta }
|
|
||||||
return [p[0], p[1], p[2], extraData, fourth]
|
|
||||||
}
|
|
||||||
// V1 shape already: return as-is
|
|
||||||
return p
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
// Running action uses a different endpoint for cancelling
|
|
||||||
Running: data.queue_running.map((prompt: any) => {
|
|
||||||
const np = normalizeQueuePrompt(prompt)
|
|
||||||
return {
|
|
||||||
taskType: 'Running',
|
|
||||||
prompt: np,
|
|
||||||
// prompt[1] is the prompt id
|
|
||||||
remove: { name: 'Cancel', cb: () => api.interrupt(np[1]) }
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
Pending: data.queue_pending.map((prompt: any) => ({
|
|
||||||
taskType: 'Pending',
|
|
||||||
prompt: normalizeQueuePrompt(prompt)
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error('Failed to fetch queue:', error)
|
||||||
return { Running: [], Pending: [] }
|
return { Running: [], Pending: [] }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -951,7 +915,7 @@ export class ComfyApi extends EventTarget {
|
|||||||
async getHistory(
|
async getHistory(
|
||||||
max_items: number = 200,
|
max_items: number = 200,
|
||||||
options?: { offset?: number }
|
options?: { offset?: number }
|
||||||
): Promise<{ History: HistoryTaskItem[] }> {
|
): Promise<JobListItem[]> {
|
||||||
try {
|
try {
|
||||||
return await fetchHistory(
|
return await fetchHistory(
|
||||||
this.fetchApi.bind(this),
|
this.fetchApi.bind(this),
|
||||||
@@ -960,10 +924,19 @@ export class ComfyApi extends EventTarget {
|
|||||||
)
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
return { History: [] }
|
return []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets detailed job info including outputs and workflow
|
||||||
|
* @param jobId The job/prompt ID
|
||||||
|
* @returns Full job details or undefined if not found
|
||||||
|
*/
|
||||||
|
async getJobDetail(jobId: string): Promise<JobDetail | undefined> {
|
||||||
|
return fetchJobDetail(this.fetchApi.bind(this), jobId)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets system & device stats
|
* Gets system & device stats
|
||||||
* @returns System stats such as python version, OS, per device info
|
* @returns System stats such as python version, OS, per device info
|
||||||
@@ -1273,29 +1246,6 @@ export class ComfyApi extends EventTarget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets the Fuse options from the server.
|
|
||||||
*
|
|
||||||
* @returns The Fuse options, or null if not found or invalid
|
|
||||||
*/
|
|
||||||
async getFuseOptions(): Promise<IFuseOptions<TemplateInfo> | null> {
|
|
||||||
try {
|
|
||||||
const res = await axios.get(
|
|
||||||
this.fileURL('/templates/fuse_options.json'),
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
const contentType = res.headers['content-type']
|
|
||||||
return contentType?.includes('application/json') ? res.data : null
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading fuse options:', error)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the custom nodes i18n data from the server.
|
* Gets the custom nodes i18n data from the server.
|
||||||
*
|
*
|
||||||
@@ -1331,6 +1281,24 @@ export class ComfyApi extends EventTarget {
|
|||||||
getServerFeatures(): Record<string, unknown> {
|
getServerFeatures(): Record<string, unknown> {
|
||||||
return { ...this.serverFeatureFlags }
|
return { ...this.serverFeatureFlags }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getFuseOptions(): Promise<IFuseOptions<TemplateInfo> | null> {
|
||||||
|
try {
|
||||||
|
const res = await axios.get(
|
||||||
|
this.fileURL('/templates/fuse_options.json'),
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
const contentType = res.headers['content-type']
|
||||||
|
return contentType?.includes('application/json') ? res.data : null
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading fuse options:', error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const api = new ComfyApi()
|
export const api = new ComfyApi()
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||||
import { WORKFLOW_ACCEPT_STRING } from '@/platform/workflow/core/types/formats'
|
import { WORKFLOW_ACCEPT_STRING } from '@/platform/workflow/core/types/formats'
|
||||||
import { type StatusWsMessageStatus, type TaskItem } from '@/schemas/apiSchema'
|
import { type StatusWsMessageStatus } from '@/schemas/apiSchema'
|
||||||
import { useDialogService } from '@/services/dialogService'
|
import { useDialogService } from '@/services/dialogService'
|
||||||
import { isCloud } from '@/platform/distribution/types'
|
import { isCloud } from '@/platform/distribution/types'
|
||||||
import { useTelemetry } from '@/platform/telemetry'
|
import { useTelemetry } from '@/platform/telemetry'
|
||||||
@@ -33,6 +33,17 @@ type Props = {
|
|||||||
|
|
||||||
type Children = Element[] | Element | string | string[]
|
type Children = Element[] | Element | string | string[]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Legacy queue item structure from old history API.
|
||||||
|
* Will be removed when ComfyList is migrated to Jobs API.
|
||||||
|
*/
|
||||||
|
interface LegacyQueueItem {
|
||||||
|
prompt: [unknown, string, unknown, { extra_pnginfo: { workflow: unknown } }]
|
||||||
|
outputs?: Record<string, unknown>
|
||||||
|
meta?: Record<string, { display_node?: string }>
|
||||||
|
remove?: { name: string; cb: () => Promise<void> | void }
|
||||||
|
}
|
||||||
|
|
||||||
type ElementType<K extends string> = K extends keyof HTMLElementTagNameMap
|
type ElementType<K extends string> = K extends keyof HTMLElementTagNameMap
|
||||||
? HTMLElementTagNameMap[K]
|
? HTMLElementTagNameMap[K]
|
||||||
: HTMLElement
|
: HTMLElement
|
||||||
@@ -259,29 +270,28 @@ class ComfyList {
|
|||||||
$el('div.comfy-list-items', [
|
$el('div.comfy-list-items', [
|
||||||
// @ts-expect-error fixme ts strict error
|
// @ts-expect-error fixme ts strict error
|
||||||
...(this.#reverse ? items[section].reverse() : items[section]).map(
|
...(this.#reverse ? items[section].reverse() : items[section]).map(
|
||||||
(item: TaskItem) => {
|
(item: LegacyQueueItem) => {
|
||||||
// Allow items to specify a custom remove action (e.g. for interrupt current prompt)
|
// Allow items to specify a custom remove action (e.g. for interrupt current prompt)
|
||||||
const removeAction =
|
const removeAction = item.remove ?? {
|
||||||
'remove' in item
|
name: 'Delete',
|
||||||
? item.remove
|
cb: () => api.deleteItem(this.#type, item.prompt[1])
|
||||||
: {
|
}
|
||||||
name: 'Delete',
|
|
||||||
cb: () => api.deleteItem(this.#type, item.prompt[1])
|
|
||||||
}
|
|
||||||
return $el('div', { textContent: item.prompt[0] + ': ' }, [
|
return $el('div', { textContent: item.prompt[0] + ': ' }, [
|
||||||
$el('button', {
|
$el('button', {
|
||||||
textContent: 'Load',
|
textContent: 'Load',
|
||||||
onclick: async () => {
|
onclick: async () => {
|
||||||
await app.loadGraphData(
|
await app.loadGraphData(
|
||||||
// @ts-expect-error fixme ts strict error
|
item.prompt[3].extra_pnginfo.workflow as Parameters<
|
||||||
item.prompt[3].extra_pnginfo.workflow,
|
typeof app.loadGraphData
|
||||||
|
>[0],
|
||||||
true,
|
true,
|
||||||
false
|
false
|
||||||
)
|
)
|
||||||
if ('outputs' in item) {
|
if ('outputs' in item && item.outputs) {
|
||||||
app.nodeOutputs = {}
|
app.nodeOutputs = {}
|
||||||
for (const [key, value] of Object.entries(item.outputs)) {
|
for (const [key, value] of Object.entries(item.outputs)) {
|
||||||
const realKey = item['meta']?.[key]?.display_node ?? key
|
const realKey = item['meta']?.[key]?.display_node ?? key
|
||||||
|
// @ts-expect-error fixme ts strict error
|
||||||
app.nodeOutputs[realKey] = value
|
app.nodeOutputs[realKey] = value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,15 +19,12 @@ import { useTelemetry } from '@/platform/telemetry'
|
|||||||
import { isCloud } from '@/platform/distribution/types'
|
import { isCloud } from '@/platform/distribution/types'
|
||||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||||
import SettingDialogContent from '@/platform/settings/components/SettingDialogContent.vue'
|
import SettingDialogContent from '@/platform/settings/components/SettingDialogContent.vue'
|
||||||
import type { ExecutionErrorWsMessage } from '@/schemas/apiSchema'
|
|
||||||
import { useDialogStore } from '@/stores/dialogStore'
|
import { useDialogStore } from '@/stores/dialogStore'
|
||||||
import type {
|
import type {
|
||||||
DialogComponentProps,
|
DialogComponentProps,
|
||||||
ShowDialogOptions
|
ShowDialogOptions
|
||||||
} from '@/stores/dialogStore'
|
} from '@/stores/dialogStore'
|
||||||
|
|
||||||
import ManagerDialogContent from '@/workbench/extensions/manager/components/manager/ManagerDialogContent.vue'
|
|
||||||
import ManagerHeader from '@/workbench/extensions/manager/components/manager/ManagerHeader.vue'
|
|
||||||
import ImportFailedNodeContent from '@/workbench/extensions/manager/components/manager/ImportFailedNodeContent.vue'
|
import ImportFailedNodeContent from '@/workbench/extensions/manager/components/manager/ImportFailedNodeContent.vue'
|
||||||
import ImportFailedNodeFooter from '@/workbench/extensions/manager/components/manager/ImportFailedNodeFooter.vue'
|
import ImportFailedNodeFooter from '@/workbench/extensions/manager/components/manager/ImportFailedNodeFooter.vue'
|
||||||
import ImportFailedNodeHeader from '@/workbench/extensions/manager/components/manager/ImportFailedNodeHeader.vue'
|
import ImportFailedNodeHeader from '@/workbench/extensions/manager/components/manager/ImportFailedNodeHeader.vue'
|
||||||
@@ -45,6 +42,18 @@ export type ConfirmationDialogType =
|
|||||||
| 'dirtyClose'
|
| 'dirtyClose'
|
||||||
| 'reinstall'
|
| 'reinstall'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal interface for execution error dialogs.
|
||||||
|
* Satisfied by both ExecutionErrorWsMessage (WebSocket) and ExecutionError (Jobs API).
|
||||||
|
*/
|
||||||
|
export interface ExecutionErrorDialogInput {
|
||||||
|
exception_type: string
|
||||||
|
exception_message: string
|
||||||
|
node_id: string | number
|
||||||
|
node_type: string
|
||||||
|
traceback: string[]
|
||||||
|
}
|
||||||
|
|
||||||
export const useDialogService = () => {
|
export const useDialogService = () => {
|
||||||
const dialogStore = useDialogStore()
|
const dialogStore = useDialogStore()
|
||||||
|
|
||||||
@@ -115,7 +124,7 @@ export const useDialogService = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function showExecutionErrorDialog(executionError: ExecutionErrorWsMessage) {
|
function showExecutionErrorDialog(executionError: ExecutionErrorDialogInput) {
|
||||||
const props: ComponentAttrs<typeof ErrorDialogContent> = {
|
const props: ComponentAttrs<typeof ErrorDialogContent> = {
|
||||||
error: {
|
error: {
|
||||||
exceptionType: executionError.exception_type,
|
exceptionType: executionError.exception_type,
|
||||||
@@ -141,32 +150,6 @@ export const useDialogService = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function showManagerDialog(
|
|
||||||
props: ComponentAttrs<typeof ManagerDialogContent> = {}
|
|
||||||
) {
|
|
||||||
dialogStore.showDialog({
|
|
||||||
key: 'global-manager',
|
|
||||||
component: ManagerDialogContent,
|
|
||||||
headerComponent: ManagerHeader,
|
|
||||||
dialogComponentProps: {
|
|
||||||
closable: true,
|
|
||||||
pt: {
|
|
||||||
pcCloseButton: {
|
|
||||||
root: {
|
|
||||||
class: 'bg-dialog-surface w-9 h-9 p-1.5 rounded-full text-white'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
header: { class: 'py-0! px-6 m-0! h-[68px]' },
|
|
||||||
content: {
|
|
||||||
class: 'p-0! h-full w-[90vw] max-w-full flex-1 overflow-hidden'
|
|
||||||
},
|
|
||||||
root: { class: 'manager-dialog' }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
props
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseError(error: Error) {
|
function parseError(error: Error) {
|
||||||
const filename =
|
const filename =
|
||||||
'fileName' in error
|
'fileName' in error
|
||||||
@@ -408,20 +391,10 @@ export const useDialogService = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleManagerDialog(
|
|
||||||
props?: ComponentAttrs<typeof ManagerDialogContent>
|
|
||||||
) {
|
|
||||||
if (dialogStore.isDialogOpen('global-manager')) {
|
|
||||||
dialogStore.closeDialog({ key: 'global-manager' })
|
|
||||||
} else {
|
|
||||||
showManagerDialog(props)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function showLayoutDialog(options: {
|
function showLayoutDialog(options: {
|
||||||
key: string
|
key: string
|
||||||
component: Component
|
component: Component
|
||||||
props: { onClose: () => void }
|
props: { onClose: () => void } & Record<string, unknown>
|
||||||
dialogComponentProps?: DialogComponentProps
|
dialogComponentProps?: DialogComponentProps
|
||||||
}) {
|
}) {
|
||||||
const layoutDefaultProps: DialogComponentProps = {
|
const layoutDefaultProps: DialogComponentProps = {
|
||||||
@@ -552,7 +525,6 @@ export const useDialogService = () => {
|
|||||||
showSettingsDialog,
|
showSettingsDialog,
|
||||||
showAboutDialog,
|
showAboutDialog,
|
||||||
showExecutionErrorDialog,
|
showExecutionErrorDialog,
|
||||||
showManagerDialog,
|
|
||||||
showApiNodesSignInDialog,
|
showApiNodesSignInDialog,
|
||||||
showSignInDialog,
|
showSignInDialog,
|
||||||
showSubscriptionRequiredDialog,
|
showSubscriptionRequiredDialog,
|
||||||
@@ -562,7 +534,6 @@ export const useDialogService = () => {
|
|||||||
prompt,
|
prompt,
|
||||||
showErrorDialog,
|
showErrorDialog,
|
||||||
confirm,
|
confirm,
|
||||||
toggleManagerDialog,
|
|
||||||
showLayoutDialog,
|
showLayoutDialog,
|
||||||
showImportFailedNodeDialog,
|
showImportFailedNodeDialog,
|
||||||
showNodeConflictDialog
|
showNodeConflictDialog
|
||||||
|
|||||||
278
src/services/jobOutputCache.test.ts
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import type {
|
||||||
|
JobDetail,
|
||||||
|
JobListItem
|
||||||
|
} from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||||
|
import { ResultItemImpl, TaskItemImpl } from '@/stores/queueStore'
|
||||||
|
|
||||||
|
vi.mock('@/platform/remote/comfyui/jobs/fetchJobs', () => ({
|
||||||
|
fetchJobDetail: vi.fn(),
|
||||||
|
extractWorkflow: vi.fn()
|
||||||
|
}))
|
||||||
|
|
||||||
|
function createResultItem(url: string, supportsPreview = true): ResultItemImpl {
|
||||||
|
const item = new ResultItemImpl({
|
||||||
|
filename: url,
|
||||||
|
subfolder: '',
|
||||||
|
type: 'output',
|
||||||
|
nodeId: 'node-1',
|
||||||
|
mediaType: supportsPreview ? 'images' : 'unknown'
|
||||||
|
})
|
||||||
|
Object.defineProperty(item, 'url', { get: () => url })
|
||||||
|
Object.defineProperty(item, 'supportsPreview', { get: () => supportsPreview })
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMockJob(id: string, outputsCount = 1): JobListItem {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
status: 'completed',
|
||||||
|
create_time: Date.now(),
|
||||||
|
preview_output: null,
|
||||||
|
outputs_count: outputsCount,
|
||||||
|
priority: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTask(
|
||||||
|
preview?: ResultItemImpl,
|
||||||
|
allOutputs?: ResultItemImpl[],
|
||||||
|
outputsCount = 1
|
||||||
|
): TaskItemImpl {
|
||||||
|
const job = createMockJob(
|
||||||
|
`task-${Math.random().toString(36).slice(2)}`,
|
||||||
|
outputsCount
|
||||||
|
)
|
||||||
|
const flatOutputs = allOutputs ?? (preview ? [preview] : [])
|
||||||
|
return new TaskItemImpl(job, {}, flatOutputs)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('jobOutputCache', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetModules()
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('findActiveIndex', () => {
|
||||||
|
it('returns index of matching URL', async () => {
|
||||||
|
const { findActiveIndex } = await import('@/services/jobOutputCache')
|
||||||
|
const items = [
|
||||||
|
createResultItem('a'),
|
||||||
|
createResultItem('b'),
|
||||||
|
createResultItem('c')
|
||||||
|
]
|
||||||
|
|
||||||
|
expect(findActiveIndex(items, 'b')).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns 0 when URL not found', async () => {
|
||||||
|
const { findActiveIndex } = await import('@/services/jobOutputCache')
|
||||||
|
const items = [createResultItem('a'), createResultItem('b')]
|
||||||
|
|
||||||
|
expect(findActiveIndex(items, 'missing')).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns 0 when URL is undefined', async () => {
|
||||||
|
const { findActiveIndex } = await import('@/services/jobOutputCache')
|
||||||
|
const items = [createResultItem('a'), createResultItem('b')]
|
||||||
|
|
||||||
|
expect(findActiveIndex(items, undefined)).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getOutputsForTask', () => {
|
||||||
|
it('returns previewable outputs directly when no lazy load needed', async () => {
|
||||||
|
const { getOutputsForTask } = await import('@/services/jobOutputCache')
|
||||||
|
const outputs = [createResultItem('p-1'), createResultItem('p-2')]
|
||||||
|
const task = createTask(undefined, outputs, 1)
|
||||||
|
|
||||||
|
const result = await getOutputsForTask(task)
|
||||||
|
|
||||||
|
expect(result).toEqual(outputs)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lazy loads when outputsCount > 1', async () => {
|
||||||
|
const { getOutputsForTask } = await import('@/services/jobOutputCache')
|
||||||
|
const previewOutput = createResultItem('preview')
|
||||||
|
const fullOutputs = [
|
||||||
|
createResultItem('full-1'),
|
||||||
|
createResultItem('full-2')
|
||||||
|
]
|
||||||
|
|
||||||
|
const job = createMockJob('task-1', 3)
|
||||||
|
const task = new TaskItemImpl(job, {}, [previewOutput])
|
||||||
|
const loadedTask = new TaskItemImpl(job, {}, fullOutputs)
|
||||||
|
task.loadFullOutputs = vi.fn().mockResolvedValue(loadedTask)
|
||||||
|
|
||||||
|
const result = await getOutputsForTask(task)
|
||||||
|
|
||||||
|
expect(result).toEqual(fullOutputs)
|
||||||
|
expect(task.loadFullOutputs).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('caches loaded tasks', async () => {
|
||||||
|
const { getOutputsForTask } = await import('@/services/jobOutputCache')
|
||||||
|
const fullOutputs = [createResultItem('full-1')]
|
||||||
|
|
||||||
|
const job = createMockJob('task-1', 3)
|
||||||
|
const task = new TaskItemImpl(job, {}, [createResultItem('preview')])
|
||||||
|
const loadedTask = new TaskItemImpl(job, {}, fullOutputs)
|
||||||
|
task.loadFullOutputs = vi.fn().mockResolvedValue(loadedTask)
|
||||||
|
|
||||||
|
// First call should load
|
||||||
|
await getOutputsForTask(task)
|
||||||
|
expect(task.loadFullOutputs).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
// Second call should use cache
|
||||||
|
await getOutputsForTask(task)
|
||||||
|
expect(task.loadFullOutputs).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to preview outputs on load error', async () => {
|
||||||
|
const { getOutputsForTask } = await import('@/services/jobOutputCache')
|
||||||
|
const previewOutput = createResultItem('preview')
|
||||||
|
|
||||||
|
const job = createMockJob('task-1', 3)
|
||||||
|
const task = new TaskItemImpl(job, {}, [previewOutput])
|
||||||
|
task.loadFullOutputs = vi
|
||||||
|
.fn()
|
||||||
|
.mockRejectedValue(new Error('Network error'))
|
||||||
|
|
||||||
|
const result = await getOutputsForTask(task)
|
||||||
|
|
||||||
|
expect(result).toEqual([previewOutput])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null when request is superseded', async () => {
|
||||||
|
const { getOutputsForTask } = await import('@/services/jobOutputCache')
|
||||||
|
const job1 = createMockJob('task-1', 3)
|
||||||
|
const job2 = createMockJob('task-2', 3)
|
||||||
|
|
||||||
|
const task1 = new TaskItemImpl(job1, {}, [createResultItem('preview-1')])
|
||||||
|
const task2 = new TaskItemImpl(job2, {}, [createResultItem('preview-2')])
|
||||||
|
|
||||||
|
const loadedTask1 = new TaskItemImpl(job1, {}, [
|
||||||
|
createResultItem('full-1')
|
||||||
|
])
|
||||||
|
const loadedTask2 = new TaskItemImpl(job2, {}, [
|
||||||
|
createResultItem('full-2')
|
||||||
|
])
|
||||||
|
|
||||||
|
// Task1 loads slowly, task2 loads quickly
|
||||||
|
task1.loadFullOutputs = vi.fn().mockImplementation(
|
||||||
|
() =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
setTimeout(() => resolve(loadedTask1), 50)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
task2.loadFullOutputs = vi.fn().mockResolvedValue(loadedTask2)
|
||||||
|
|
||||||
|
// Start task1, then immediately start task2
|
||||||
|
const promise1 = getOutputsForTask(task1)
|
||||||
|
const promise2 = getOutputsForTask(task2)
|
||||||
|
|
||||||
|
const [result1, result2] = await Promise.all([promise1, promise2])
|
||||||
|
|
||||||
|
// Task2 should succeed, task1 should return null (superseded)
|
||||||
|
expect(result1).toBeNull()
|
||||||
|
expect(result2).toEqual([createResultItem('full-2')])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getJobDetail', () => {
|
||||||
|
it('fetches and caches job detail', async () => {
|
||||||
|
const { getJobDetail } = await import('@/services/jobOutputCache')
|
||||||
|
const { fetchJobDetail } =
|
||||||
|
await import('@/platform/remote/comfyui/jobs/fetchJobs')
|
||||||
|
|
||||||
|
const mockDetail: JobDetail = {
|
||||||
|
id: 'job-1',
|
||||||
|
status: 'completed',
|
||||||
|
create_time: Date.now(),
|
||||||
|
priority: 0,
|
||||||
|
outputs: {}
|
||||||
|
}
|
||||||
|
vi.mocked(fetchJobDetail).mockResolvedValue(mockDetail)
|
||||||
|
|
||||||
|
const result = await getJobDetail('job-1')
|
||||||
|
|
||||||
|
expect(result).toEqual(mockDetail)
|
||||||
|
expect(fetchJobDetail).toHaveBeenCalledWith(expect.any(Function), 'job-1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns cached job detail on subsequent calls', async () => {
|
||||||
|
const { getJobDetail } = await import('@/services/jobOutputCache')
|
||||||
|
const { fetchJobDetail } =
|
||||||
|
await import('@/platform/remote/comfyui/jobs/fetchJobs')
|
||||||
|
|
||||||
|
const mockDetail: JobDetail = {
|
||||||
|
id: 'job-2',
|
||||||
|
status: 'completed',
|
||||||
|
create_time: Date.now(),
|
||||||
|
priority: 0,
|
||||||
|
outputs: {}
|
||||||
|
}
|
||||||
|
vi.mocked(fetchJobDetail).mockResolvedValue(mockDetail)
|
||||||
|
|
||||||
|
// First call
|
||||||
|
await getJobDetail('job-2')
|
||||||
|
expect(fetchJobDetail).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
// Second call should use cache
|
||||||
|
const result = await getJobDetail('job-2')
|
||||||
|
expect(result).toEqual(mockDetail)
|
||||||
|
expect(fetchJobDetail).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns undefined on fetch error', async () => {
|
||||||
|
const { getJobDetail } = await import('@/services/jobOutputCache')
|
||||||
|
const { fetchJobDetail } =
|
||||||
|
await import('@/platform/remote/comfyui/jobs/fetchJobs')
|
||||||
|
|
||||||
|
vi.mocked(fetchJobDetail).mockRejectedValue(new Error('Network error'))
|
||||||
|
|
||||||
|
const result = await getJobDetail('job-error')
|
||||||
|
|
||||||
|
expect(result).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getJobWorkflow', () => {
|
||||||
|
it('fetches job detail and extracts workflow', async () => {
|
||||||
|
const { getJobWorkflow } = await import('@/services/jobOutputCache')
|
||||||
|
const { fetchJobDetail, extractWorkflow } =
|
||||||
|
await import('@/platform/remote/comfyui/jobs/fetchJobs')
|
||||||
|
|
||||||
|
const mockDetail: JobDetail = {
|
||||||
|
id: 'job-wf',
|
||||||
|
status: 'completed',
|
||||||
|
create_time: Date.now(),
|
||||||
|
priority: 0,
|
||||||
|
outputs: {}
|
||||||
|
}
|
||||||
|
const mockWorkflow = { version: 1 }
|
||||||
|
|
||||||
|
vi.mocked(fetchJobDetail).mockResolvedValue(mockDetail)
|
||||||
|
vi.mocked(extractWorkflow).mockResolvedValue(mockWorkflow as any)
|
||||||
|
|
||||||
|
const result = await getJobWorkflow('job-wf')
|
||||||
|
|
||||||
|
expect(result).toEqual(mockWorkflow)
|
||||||
|
expect(extractWorkflow).toHaveBeenCalledWith(mockDetail)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns undefined when job detail not found', async () => {
|
||||||
|
const { getJobWorkflow } = await import('@/services/jobOutputCache')
|
||||||
|
const { fetchJobDetail, extractWorkflow } =
|
||||||
|
await import('@/platform/remote/comfyui/jobs/fetchJobs')
|
||||||
|
|
||||||
|
vi.mocked(fetchJobDetail).mockResolvedValue(undefined)
|
||||||
|
vi.mocked(extractWorkflow).mockResolvedValue(undefined)
|
||||||
|
|
||||||
|
const result = await getJobWorkflow('missing')
|
||||||
|
|
||||||
|
expect(result).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
103
src/services/jobOutputCache.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Job output cache for caching and managing job data
|
||||||
|
* @module services/jobOutputCache
|
||||||
|
*
|
||||||
|
* Centralizes job output and detail caching with LRU eviction.
|
||||||
|
* Provides helpers for working with previewable outputs and workflows.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import QuickLRU from '@alloc/quick-lru'
|
||||||
|
|
||||||
|
import type { JobDetail } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||||
|
import { extractWorkflow } from '@/platform/remote/comfyui/jobs/fetchJobs'
|
||||||
|
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||||
|
import { api } from '@/scripts/api'
|
||||||
|
import { ResultItemImpl } from '@/stores/queueStore'
|
||||||
|
import type { TaskItemImpl } from '@/stores/queueStore'
|
||||||
|
|
||||||
|
const MAX_TASK_CACHE_SIZE = 50
|
||||||
|
const MAX_JOB_DETAIL_CACHE_SIZE = 50
|
||||||
|
|
||||||
|
const taskCache = new QuickLRU<string, TaskItemImpl>({
|
||||||
|
maxSize: MAX_TASK_CACHE_SIZE
|
||||||
|
})
|
||||||
|
const jobDetailCache = new QuickLRU<string, JobDetail>({
|
||||||
|
maxSize: MAX_JOB_DETAIL_CACHE_SIZE
|
||||||
|
})
|
||||||
|
|
||||||
|
// Track latest request to dedupe stale responses
|
||||||
|
let latestTaskRequestId: string | null = null
|
||||||
|
|
||||||
|
// ===== Task Output Caching =====
|
||||||
|
|
||||||
|
export function findActiveIndex(
|
||||||
|
items: readonly ResultItemImpl[],
|
||||||
|
url?: string
|
||||||
|
): number {
|
||||||
|
return ResultItemImpl.findByUrl(items, url)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets previewable outputs for a task, with lazy loading, caching, and request deduping.
|
||||||
|
* Returns null if a newer request superseded this one while loading.
|
||||||
|
*/
|
||||||
|
export async function getOutputsForTask(
|
||||||
|
task: TaskItemImpl
|
||||||
|
): Promise<ResultItemImpl[] | null> {
|
||||||
|
const requestId = String(task.promptId)
|
||||||
|
latestTaskRequestId = requestId
|
||||||
|
|
||||||
|
const outputsCount = task.outputsCount ?? 0
|
||||||
|
const needsLazyLoad = outputsCount > 1
|
||||||
|
|
||||||
|
if (!needsLazyLoad) {
|
||||||
|
return [...task.previewableOutputs]
|
||||||
|
}
|
||||||
|
|
||||||
|
const cached = taskCache.get(requestId)
|
||||||
|
if (cached) {
|
||||||
|
return [...cached.previewableOutputs]
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const loadedTask = await task.loadFullOutputs()
|
||||||
|
|
||||||
|
// Check if request was superseded while loading
|
||||||
|
if (latestTaskRequestId !== requestId) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
taskCache.set(requestId, loadedTask)
|
||||||
|
return [...loadedTask.previewableOutputs]
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to load full outputs, using preview:', error)
|
||||||
|
return [...task.previewableOutputs]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Job Detail Caching =====
|
||||||
|
|
||||||
|
export async function getJobDetail(
|
||||||
|
jobId: string
|
||||||
|
): Promise<JobDetail | undefined> {
|
||||||
|
const cached = jobDetailCache.get(jobId)
|
||||||
|
if (cached) return cached
|
||||||
|
|
||||||
|
try {
|
||||||
|
const detail = await api.getJobDetail(jobId)
|
||||||
|
if (detail) {
|
||||||
|
jobDetailCache.set(jobId, detail)
|
||||||
|
}
|
||||||
|
return detail
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to fetch job detail:', error)
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getJobWorkflow(
|
||||||
|
jobId: string
|
||||||
|
): Promise<ComfyWorkflowJSON | undefined> {
|
||||||
|
const detail = await getJobDetail(jobId)
|
||||||
|
return await extractWorkflow(detail)
|
||||||
|
}
|
||||||
@@ -3,12 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|||||||
|
|
||||||
import { useAssetsStore } from '@/stores/assetsStore'
|
import { useAssetsStore } from '@/stores/assetsStore'
|
||||||
import { api } from '@/scripts/api'
|
import { api } from '@/scripts/api'
|
||||||
import type {
|
import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||||
HistoryTaskItem,
|
|
||||||
TaskPrompt,
|
|
||||||
TaskStatus,
|
|
||||||
TaskOutput
|
|
||||||
} from '@/schemas/apiSchema'
|
|
||||||
|
|
||||||
// Mock the api module
|
// Mock the api module
|
||||||
vi.mock('@/scripts/api', () => ({
|
vi.mock('@/scripts/api', () => ({
|
||||||
@@ -53,26 +48,25 @@ vi.mock('@/stores/queueStore', () => ({
|
|||||||
url: string
|
url: string
|
||||||
}
|
}
|
||||||
| undefined
|
| undefined
|
||||||
|
public promptId: string
|
||||||
|
|
||||||
constructor(
|
constructor(public job: JobListItem) {
|
||||||
public taskType: string,
|
this.promptId = job.id
|
||||||
public prompt: TaskPrompt,
|
this.flatOutputs = [
|
||||||
public status: TaskStatus | undefined,
|
{
|
||||||
public outputs: TaskOutput
|
supportsPreview: true,
|
||||||
) {
|
filename: 'test.png',
|
||||||
this.flatOutputs = this.outputs
|
subfolder: '',
|
||||||
? [
|
type: 'output',
|
||||||
{
|
url: 'http://test.com/test.png'
|
||||||
supportsPreview: true,
|
}
|
||||||
filename: 'test.png',
|
]
|
||||||
subfolder: '',
|
|
||||||
type: 'output',
|
|
||||||
url: 'http://test.com/test.png'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
: []
|
|
||||||
this.previewOutput = this.flatOutputs[0]
|
this.previewOutput = this.flatOutputs[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get previewableOutputs() {
|
||||||
|
return this.flatOutputs.filter((o) => o.supportsPreview)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -82,17 +76,17 @@ vi.mock('@/platform/assets/composables/media/assetMappers', () => ({
|
|||||||
id: `${type}-${index}`,
|
id: `${type}-${index}`,
|
||||||
name,
|
name,
|
||||||
size: 0,
|
size: 0,
|
||||||
created_at: new Date(Date.now() - index * 1000).toISOString(), // Unique timestamps
|
created_at: new Date(Date.now() - index * 1000).toISOString(),
|
||||||
tags: [type],
|
tags: [type],
|
||||||
preview_url: `http://test.com/${name}`
|
preview_url: `http://test.com/${name}`
|
||||||
})),
|
})),
|
||||||
mapTaskOutputToAssetItem: vi.fn((task, output) => {
|
mapTaskOutputToAssetItem: vi.fn((task, output) => {
|
||||||
const index = parseInt(task.prompt[1].split('_')[1]) || 0
|
const index = parseInt(task.promptId.split('_')[1]) || 0
|
||||||
return {
|
return {
|
||||||
id: task.prompt[1], // Use promptId as asset ID
|
id: task.promptId,
|
||||||
name: output.filename,
|
name: output.filename,
|
||||||
size: 0,
|
size: 0,
|
||||||
created_at: new Date(Date.now() - index * 1000).toISOString(), // Unique timestamps
|
created_at: new Date(Date.now() - index * 1000).toISOString(),
|
||||||
tags: ['output'],
|
tags: ['output'],
|
||||||
preview_url: output.url,
|
preview_url: output.url,
|
||||||
user_metadata: {}
|
user_metadata: {}
|
||||||
@@ -103,43 +97,20 @@ vi.mock('@/platform/assets/composables/media/assetMappers', () => ({
|
|||||||
describe('assetsStore - Refactored (Option A)', () => {
|
describe('assetsStore - Refactored (Option A)', () => {
|
||||||
let store: ReturnType<typeof useAssetsStore>
|
let store: ReturnType<typeof useAssetsStore>
|
||||||
|
|
||||||
// Helper function to create mock history items
|
// Helper function to create mock job items
|
||||||
const createMockHistoryItem = (index: number): HistoryTaskItem => ({
|
const createMockJobItem = (index: number): JobListItem => ({
|
||||||
taskType: 'History' as const,
|
id: `prompt_${index}`,
|
||||||
prompt: [
|
status: 'completed',
|
||||||
1000 + index, // queueIndex
|
create_time: 1000 + index,
|
||||||
`prompt_${index}`, // promptId
|
update_time: 1000 + index,
|
||||||
{}, // promptInputs
|
last_state_update: 1000 + index,
|
||||||
{
|
priority: 1000 + index,
|
||||||
extra_pnginfo: {
|
preview_output: {
|
||||||
workflow: {
|
filename: `output_${index}.png`,
|
||||||
last_node_id: 1,
|
subfolder: '',
|
||||||
last_link_id: 1,
|
type: 'output',
|
||||||
nodes: [],
|
nodeId: 'node_1',
|
||||||
links: [],
|
mediaType: 'images'
|
||||||
groups: [],
|
|
||||||
config: {},
|
|
||||||
version: 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, // extraData
|
|
||||||
[] // outputsToExecute
|
|
||||||
],
|
|
||||||
status: {
|
|
||||||
status_str: 'success' as const,
|
|
||||||
completed: true,
|
|
||||||
messages: []
|
|
||||||
},
|
|
||||||
outputs: {
|
|
||||||
'1': {
|
|
||||||
images: [
|
|
||||||
{
|
|
||||||
filename: `output_${index}.png`,
|
|
||||||
subfolder: '',
|
|
||||||
type: 'output' as const
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -152,11 +123,9 @@ describe('assetsStore - Refactored (Option A)', () => {
|
|||||||
describe('Initial Load', () => {
|
describe('Initial Load', () => {
|
||||||
it('should load initial history items', async () => {
|
it('should load initial history items', async () => {
|
||||||
const mockHistory = Array.from({ length: 10 }, (_, i) =>
|
const mockHistory = Array.from({ length: 10 }, (_, i) =>
|
||||||
createMockHistoryItem(i)
|
createMockJobItem(i)
|
||||||
)
|
)
|
||||||
vi.mocked(api.getHistory).mockResolvedValue({
|
vi.mocked(api.getHistory).mockResolvedValue(mockHistory)
|
||||||
History: mockHistory
|
|
||||||
})
|
|
||||||
|
|
||||||
await store.updateHistory()
|
await store.updateHistory()
|
||||||
|
|
||||||
@@ -169,11 +138,9 @@ describe('assetsStore - Refactored (Option A)', () => {
|
|||||||
|
|
||||||
it('should set hasMoreHistory to true when batch is full', async () => {
|
it('should set hasMoreHistory to true when batch is full', async () => {
|
||||||
const mockHistory = Array.from({ length: 200 }, (_, i) =>
|
const mockHistory = Array.from({ length: 200 }, (_, i) =>
|
||||||
createMockHistoryItem(i)
|
createMockJobItem(i)
|
||||||
)
|
)
|
||||||
vi.mocked(api.getHistory).mockResolvedValue({
|
vi.mocked(api.getHistory).mockResolvedValue(mockHistory)
|
||||||
History: mockHistory
|
|
||||||
})
|
|
||||||
|
|
||||||
await store.updateHistory()
|
await store.updateHistory()
|
||||||
|
|
||||||
@@ -197,11 +164,9 @@ describe('assetsStore - Refactored (Option A)', () => {
|
|||||||
it('should accumulate items when loading more', async () => {
|
it('should accumulate items when loading more', async () => {
|
||||||
// First batch - full BATCH_SIZE
|
// First batch - full BATCH_SIZE
|
||||||
const firstBatch = Array.from({ length: 200 }, (_, i) =>
|
const firstBatch = Array.from({ length: 200 }, (_, i) =>
|
||||||
createMockHistoryItem(i)
|
createMockJobItem(i)
|
||||||
)
|
)
|
||||||
vi.mocked(api.getHistory).mockResolvedValueOnce({
|
vi.mocked(api.getHistory).mockResolvedValueOnce(firstBatch)
|
||||||
History: firstBatch
|
|
||||||
})
|
|
||||||
|
|
||||||
await store.updateHistory()
|
await store.updateHistory()
|
||||||
expect(store.historyAssets).toHaveLength(200)
|
expect(store.historyAssets).toHaveLength(200)
|
||||||
@@ -209,11 +174,9 @@ describe('assetsStore - Refactored (Option A)', () => {
|
|||||||
|
|
||||||
// Second batch - different items
|
// Second batch - different items
|
||||||
const secondBatch = Array.from({ length: 200 }, (_, i) =>
|
const secondBatch = Array.from({ length: 200 }, (_, i) =>
|
||||||
createMockHistoryItem(200 + i)
|
createMockJobItem(200 + i)
|
||||||
)
|
)
|
||||||
vi.mocked(api.getHistory).mockResolvedValueOnce({
|
vi.mocked(api.getHistory).mockResolvedValueOnce(secondBatch)
|
||||||
History: secondBatch
|
|
||||||
})
|
|
||||||
|
|
||||||
await store.loadMoreHistory()
|
await store.loadMoreHistory()
|
||||||
|
|
||||||
@@ -225,24 +188,20 @@ describe('assetsStore - Refactored (Option A)', () => {
|
|||||||
it('should prevent duplicate items during pagination', async () => {
|
it('should prevent duplicate items during pagination', async () => {
|
||||||
// First batch - full BATCH_SIZE
|
// First batch - full BATCH_SIZE
|
||||||
const firstBatch = Array.from({ length: 200 }, (_, i) =>
|
const firstBatch = Array.from({ length: 200 }, (_, i) =>
|
||||||
createMockHistoryItem(i)
|
createMockJobItem(i)
|
||||||
)
|
)
|
||||||
vi.mocked(api.getHistory).mockResolvedValueOnce({
|
vi.mocked(api.getHistory).mockResolvedValueOnce(firstBatch)
|
||||||
History: firstBatch
|
|
||||||
})
|
|
||||||
|
|
||||||
await store.updateHistory()
|
await store.updateHistory()
|
||||||
expect(store.historyAssets).toHaveLength(200)
|
expect(store.historyAssets).toHaveLength(200)
|
||||||
|
|
||||||
// Second batch with some duplicates
|
// Second batch with some duplicates
|
||||||
const secondBatch = [
|
const secondBatch = [
|
||||||
createMockHistoryItem(2), // Duplicate
|
createMockJobItem(2), // Duplicate
|
||||||
createMockHistoryItem(5), // Duplicate
|
createMockJobItem(5), // Duplicate
|
||||||
...Array.from({ length: 198 }, (_, i) => createMockHistoryItem(200 + i)) // New
|
...Array.from({ length: 198 }, (_, i) => createMockJobItem(200 + i)) // New
|
||||||
]
|
]
|
||||||
vi.mocked(api.getHistory).mockResolvedValueOnce({
|
vi.mocked(api.getHistory).mockResolvedValueOnce(secondBatch)
|
||||||
History: secondBatch
|
|
||||||
})
|
|
||||||
|
|
||||||
await store.loadMoreHistory()
|
await store.loadMoreHistory()
|
||||||
|
|
||||||
@@ -258,11 +217,9 @@ describe('assetsStore - Refactored (Option A)', () => {
|
|||||||
it('should stop loading when no more items', async () => {
|
it('should stop loading when no more items', async () => {
|
||||||
// First batch - less than BATCH_SIZE
|
// First batch - less than BATCH_SIZE
|
||||||
const firstBatch = Array.from({ length: 50 }, (_, i) =>
|
const firstBatch = Array.from({ length: 50 }, (_, i) =>
|
||||||
createMockHistoryItem(i)
|
createMockJobItem(i)
|
||||||
)
|
)
|
||||||
vi.mocked(api.getHistory).mockResolvedValueOnce({
|
vi.mocked(api.getHistory).mockResolvedValueOnce(firstBatch)
|
||||||
History: firstBatch
|
|
||||||
})
|
|
||||||
|
|
||||||
await store.updateHistory()
|
await store.updateHistory()
|
||||||
expect(store.hasMoreHistory).toBe(false)
|
expect(store.hasMoreHistory).toBe(false)
|
||||||
@@ -277,11 +234,9 @@ describe('assetsStore - Refactored (Option A)', () => {
|
|||||||
it('should handle race conditions with concurrent loads', async () => {
|
it('should handle race conditions with concurrent loads', async () => {
|
||||||
// Setup initial state with full batch
|
// Setup initial state with full batch
|
||||||
const initialBatch = Array.from({ length: 200 }, (_, i) =>
|
const initialBatch = Array.from({ length: 200 }, (_, i) =>
|
||||||
createMockHistoryItem(i)
|
createMockJobItem(i)
|
||||||
)
|
)
|
||||||
vi.mocked(api.getHistory).mockResolvedValueOnce({
|
vi.mocked(api.getHistory).mockResolvedValueOnce(initialBatch)
|
||||||
History: initialBatch
|
|
||||||
})
|
|
||||||
await store.updateHistory()
|
await store.updateHistory()
|
||||||
expect(store.hasMoreHistory).toBe(true)
|
expect(store.hasMoreHistory).toBe(true)
|
||||||
|
|
||||||
@@ -289,12 +244,10 @@ describe('assetsStore - Refactored (Option A)', () => {
|
|||||||
vi.mocked(api.getHistory).mockClear()
|
vi.mocked(api.getHistory).mockClear()
|
||||||
|
|
||||||
// Setup slow API response
|
// Setup slow API response
|
||||||
let resolveLoadMore: (value: { History: HistoryTaskItem[] }) => void
|
let resolveLoadMore: (value: JobListItem[]) => void
|
||||||
const loadMorePromise = new Promise<{ History: HistoryTaskItem[] }>(
|
const loadMorePromise = new Promise<JobListItem[]>((resolve) => {
|
||||||
(resolve) => {
|
resolveLoadMore = resolve
|
||||||
resolveLoadMore = resolve
|
})
|
||||||
}
|
|
||||||
)
|
|
||||||
vi.mocked(api.getHistory).mockReturnValueOnce(loadMorePromise)
|
vi.mocked(api.getHistory).mockReturnValueOnce(loadMorePromise)
|
||||||
|
|
||||||
// Start first loadMore
|
// Start first loadMore
|
||||||
@@ -305,9 +258,9 @@ describe('assetsStore - Refactored (Option A)', () => {
|
|||||||
|
|
||||||
// Resolve
|
// Resolve
|
||||||
const secondBatch = Array.from({ length: 200 }, (_, i) =>
|
const secondBatch = Array.from({ length: 200 }, (_, i) =>
|
||||||
createMockHistoryItem(200 + i)
|
createMockJobItem(200 + i)
|
||||||
)
|
)
|
||||||
resolveLoadMore!({ History: secondBatch })
|
resolveLoadMore!(secondBatch)
|
||||||
|
|
||||||
await Promise.all([firstLoad, secondLoad])
|
await Promise.all([firstLoad, secondLoad])
|
||||||
|
|
||||||
@@ -320,21 +273,17 @@ describe('assetsStore - Refactored (Option A)', () => {
|
|||||||
|
|
||||||
// Initial load
|
// Initial load
|
||||||
const firstBatch = Array.from({ length: 200 }, (_, i) =>
|
const firstBatch = Array.from({ length: 200 }, (_, i) =>
|
||||||
createMockHistoryItem(i)
|
createMockJobItem(i)
|
||||||
)
|
)
|
||||||
vi.mocked(api.getHistory).mockResolvedValueOnce({
|
vi.mocked(api.getHistory).mockResolvedValueOnce(firstBatch)
|
||||||
History: firstBatch
|
|
||||||
})
|
|
||||||
await store.updateHistory()
|
await store.updateHistory()
|
||||||
|
|
||||||
// Load additional batches
|
// Load additional batches
|
||||||
for (let batch = 1; batch < BATCH_COUNT; batch++) {
|
for (let batch = 1; batch < BATCH_COUNT; batch++) {
|
||||||
const items = Array.from({ length: 200 }, (_, i) =>
|
const items = Array.from({ length: 200 }, (_, i) =>
|
||||||
createMockHistoryItem(batch * 200 + i)
|
createMockJobItem(batch * 200 + i)
|
||||||
)
|
)
|
||||||
vi.mocked(api.getHistory).mockResolvedValueOnce({
|
vi.mocked(api.getHistory).mockResolvedValueOnce(items)
|
||||||
History: items
|
|
||||||
})
|
|
||||||
await store.loadMoreHistory()
|
await store.loadMoreHistory()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -347,21 +296,17 @@ describe('assetsStore - Refactored (Option A)', () => {
|
|||||||
it('should maintain date sorting after pagination', async () => {
|
it('should maintain date sorting after pagination', async () => {
|
||||||
// First batch
|
// First batch
|
||||||
const firstBatch = Array.from({ length: 200 }, (_, i) =>
|
const firstBatch = Array.from({ length: 200 }, (_, i) =>
|
||||||
createMockHistoryItem(i)
|
createMockJobItem(i)
|
||||||
)
|
)
|
||||||
vi.mocked(api.getHistory).mockResolvedValueOnce({
|
vi.mocked(api.getHistory).mockResolvedValueOnce(firstBatch)
|
||||||
History: firstBatch
|
|
||||||
})
|
|
||||||
|
|
||||||
await store.updateHistory()
|
await store.updateHistory()
|
||||||
|
|
||||||
// Second batch
|
// Second batch
|
||||||
const secondBatch = Array.from({ length: 200 }, (_, i) =>
|
const secondBatch = Array.from({ length: 200 }, (_, i) =>
|
||||||
createMockHistoryItem(200 + i)
|
createMockJobItem(200 + i)
|
||||||
)
|
)
|
||||||
vi.mocked(api.getHistory).mockResolvedValueOnce({
|
vi.mocked(api.getHistory).mockResolvedValueOnce(secondBatch)
|
||||||
History: secondBatch
|
|
||||||
})
|
|
||||||
|
|
||||||
await store.loadMoreHistory()
|
await store.loadMoreHistory()
|
||||||
|
|
||||||
@@ -378,11 +323,9 @@ describe('assetsStore - Refactored (Option A)', () => {
|
|||||||
it('should preserve existing data when loadMore fails', async () => {
|
it('should preserve existing data when loadMore fails', async () => {
|
||||||
// First successful load - full batch
|
// First successful load - full batch
|
||||||
const firstBatch = Array.from({ length: 200 }, (_, i) =>
|
const firstBatch = Array.from({ length: 200 }, (_, i) =>
|
||||||
createMockHistoryItem(i)
|
createMockJobItem(i)
|
||||||
)
|
)
|
||||||
vi.mocked(api.getHistory).mockResolvedValueOnce({
|
vi.mocked(api.getHistory).mockResolvedValueOnce(firstBatch)
|
||||||
History: firstBatch
|
|
||||||
})
|
|
||||||
|
|
||||||
await store.updateHistory()
|
await store.updateHistory()
|
||||||
expect(store.historyAssets).toHaveLength(200)
|
expect(store.historyAssets).toHaveLength(200)
|
||||||
@@ -402,11 +345,9 @@ describe('assetsStore - Refactored (Option A)', () => {
|
|||||||
it('should clear error state on successful retry', async () => {
|
it('should clear error state on successful retry', async () => {
|
||||||
// First load succeeds
|
// First load succeeds
|
||||||
const firstBatch = Array.from({ length: 200 }, (_, i) =>
|
const firstBatch = Array.from({ length: 200 }, (_, i) =>
|
||||||
createMockHistoryItem(i)
|
createMockJobItem(i)
|
||||||
)
|
)
|
||||||
vi.mocked(api.getHistory).mockResolvedValueOnce({
|
vi.mocked(api.getHistory).mockResolvedValueOnce(firstBatch)
|
||||||
History: firstBatch
|
|
||||||
})
|
|
||||||
|
|
||||||
await store.updateHistory()
|
await store.updateHistory()
|
||||||
|
|
||||||
@@ -419,11 +360,9 @@ describe('assetsStore - Refactored (Option A)', () => {
|
|||||||
|
|
||||||
// Third load succeeds
|
// Third load succeeds
|
||||||
const thirdBatch = Array.from({ length: 200 }, (_, i) =>
|
const thirdBatch = Array.from({ length: 200 }, (_, i) =>
|
||||||
createMockHistoryItem(200 + i)
|
createMockJobItem(200 + i)
|
||||||
)
|
)
|
||||||
vi.mocked(api.getHistory).mockResolvedValueOnce({
|
vi.mocked(api.getHistory).mockResolvedValueOnce(thirdBatch)
|
||||||
History: thirdBatch
|
|
||||||
})
|
|
||||||
|
|
||||||
await store.loadMoreHistory()
|
await store.loadMoreHistory()
|
||||||
|
|
||||||
@@ -450,11 +389,9 @@ describe('assetsStore - Refactored (Option A)', () => {
|
|||||||
|
|
||||||
for (let batch = 0; batch < batches; batch++) {
|
for (let batch = 0; batch < batches; batch++) {
|
||||||
const items = Array.from({ length: 200 }, (_, i) =>
|
const items = Array.from({ length: 200 }, (_, i) =>
|
||||||
createMockHistoryItem(batch * 200 + i)
|
createMockJobItem(batch * 200 + i)
|
||||||
)
|
)
|
||||||
vi.mocked(api.getHistory).mockResolvedValueOnce({
|
vi.mocked(api.getHistory).mockResolvedValueOnce(items)
|
||||||
History: items
|
|
||||||
})
|
|
||||||
|
|
||||||
if (batch === 0) {
|
if (batch === 0) {
|
||||||
await store.updateHistory()
|
await store.updateHistory()
|
||||||
@@ -476,11 +413,9 @@ describe('assetsStore - Refactored (Option A)', () => {
|
|||||||
// Load items beyond limit
|
// Load items beyond limit
|
||||||
for (let batch = 0; batch < 6; batch++) {
|
for (let batch = 0; batch < 6; batch++) {
|
||||||
const items = Array.from({ length: 200 }, (_, i) =>
|
const items = Array.from({ length: 200 }, (_, i) =>
|
||||||
createMockHistoryItem(batch * 200 + i)
|
createMockJobItem(batch * 200 + i)
|
||||||
)
|
)
|
||||||
vi.mocked(api.getHistory).mockResolvedValueOnce({
|
vi.mocked(api.getHistory).mockResolvedValueOnce(items)
|
||||||
History: items
|
|
||||||
})
|
|
||||||
|
|
||||||
if (batch === 0) {
|
if (batch === 0) {
|
||||||
await store.updateHistory()
|
await store.updateHistory()
|
||||||
@@ -503,11 +438,9 @@ describe('assetsStore - Refactored (Option A)', () => {
|
|||||||
describe('jobDetailView Support', () => {
|
describe('jobDetailView Support', () => {
|
||||||
it('should include outputCount and allOutputs in user_metadata', async () => {
|
it('should include outputCount and allOutputs in user_metadata', async () => {
|
||||||
const mockHistory = Array.from({ length: 5 }, (_, i) =>
|
const mockHistory = Array.from({ length: 5 }, (_, i) =>
|
||||||
createMockHistoryItem(i)
|
createMockJobItem(i)
|
||||||
)
|
)
|
||||||
vi.mocked(api.getHistory).mockResolvedValue({
|
vi.mocked(api.getHistory).mockResolvedValue(mockHistory)
|
||||||
History: mockHistory
|
|
||||||
})
|
|
||||||
|
|
||||||
await store.updateHistory()
|
await store.updateHistory()
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||||
import { assetService } from '@/platform/assets/services/assetService'
|
import { assetService } from '@/platform/assets/services/assetService'
|
||||||
import { isCloud } from '@/platform/distribution/types'
|
import { isCloud } from '@/platform/distribution/types'
|
||||||
import type { TaskItem } from '@/schemas/apiSchema'
|
import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||||
import { api } from '@/scripts/api'
|
import { api } from '@/scripts/api'
|
||||||
|
|
||||||
import { TaskItemImpl } from './queueStore'
|
import { TaskItemImpl } from './queueStore'
|
||||||
@@ -48,27 +48,18 @@ async function fetchInputFilesFromCloud(): Promise<AssetItem[]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert history task items to asset items
|
* Convert history job items to asset items
|
||||||
*/
|
*/
|
||||||
function mapHistoryToAssets(historyItems: TaskItem[]): AssetItem[] {
|
function mapHistoryToAssets(historyItems: JobListItem[]): AssetItem[] {
|
||||||
const assetItems: AssetItem[] = []
|
const assetItems: AssetItem[] = []
|
||||||
|
|
||||||
for (const item of historyItems) {
|
for (const job of historyItems) {
|
||||||
// Type guard for HistoryTaskItem which has status and outputs
|
// Only process completed jobs with preview output
|
||||||
if (item.taskType !== 'History') {
|
if (job.status !== 'completed' || !job.preview_output) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!item.outputs || !item.status || item.status?.status_str === 'error') {
|
const task = new TaskItemImpl(job)
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
const task = new TaskItemImpl(
|
|
||||||
'History',
|
|
||||||
item.prompt,
|
|
||||||
item.status,
|
|
||||||
item.outputs
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!task.previewOutput) {
|
if (!task.previewOutput) {
|
||||||
continue
|
continue
|
||||||
@@ -76,11 +67,10 @@ function mapHistoryToAssets(historyItems: TaskItem[]): AssetItem[] {
|
|||||||
|
|
||||||
const assetItem = mapTaskOutputToAssetItem(task, task.previewOutput)
|
const assetItem = mapTaskOutputToAssetItem(task, task.previewOutput)
|
||||||
|
|
||||||
const supportedOutputs = task.flatOutputs.filter((o) => o.supportsPreview)
|
|
||||||
assetItem.user_metadata = {
|
assetItem.user_metadata = {
|
||||||
...assetItem.user_metadata,
|
...assetItem.user_metadata,
|
||||||
outputCount: supportedOutputs.length,
|
outputCount: job.outputs_count,
|
||||||
allOutputs: supportedOutputs
|
allOutputs: task.previewableOutputs
|
||||||
}
|
}
|
||||||
|
|
||||||
assetItems.push(assetItem)
|
assetItems.push(assetItem)
|
||||||
@@ -158,8 +148,8 @@ export const useAssetsStore = defineStore('assets', () => {
|
|||||||
offset: historyOffset.value
|
offset: historyOffset.value
|
||||||
})
|
})
|
||||||
|
|
||||||
// Convert TaskItems to AssetItems
|
// Convert JobListItems to AssetItems
|
||||||
const newAssets = mapHistoryToAssets(history.History)
|
const newAssets = mapHistoryToAssets(history)
|
||||||
|
|
||||||
if (loadMore) {
|
if (loadMore) {
|
||||||
// Filter out duplicates and insert in sorted order
|
// Filter out duplicates and insert in sorted order
|
||||||
@@ -191,7 +181,7 @@ export const useAssetsStore = defineStore('assets', () => {
|
|||||||
|
|
||||||
// Update pagination state
|
// Update pagination state
|
||||||
historyOffset.value += BATCH_SIZE
|
historyOffset.value += BATCH_SIZE
|
||||||
hasMoreHistory.value = history.History.length === BATCH_SIZE
|
hasMoreHistory.value = history.length === BATCH_SIZE
|
||||||
|
|
||||||
if (allHistoryItems.value.length > MAX_HISTORY_ITEMS) {
|
if (allHistoryItems.value.length > MAX_HISTORY_ITEMS) {
|
||||||
const removed = allHistoryItems.value.slice(MAX_HISTORY_ITEMS)
|
const removed = allHistoryItems.value.slice(MAX_HISTORY_ITEMS)
|
||||||
|
|||||||
@@ -396,10 +396,8 @@ export const useExecutionStore = defineStore('execution', () => {
|
|||||||
error: e.detail.exception_message
|
error: e.detail.exception_message
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
const pid = e.detail?.prompt_id
|
clearInitializationByPromptId(e.detail.prompt_id)
|
||||||
// Clear initialization for errored prompt if present
|
resetExecutionState(e.detail.prompt_id)
|
||||||
if (e.detail?.prompt_id) clearInitializationByPromptId(e.detail.prompt_id)
|
|
||||||
resetExecutionState(pid)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -23,7 +23,9 @@ import { useFirebaseAuth } from 'vuefire'
|
|||||||
|
|
||||||
import { getComfyApiBaseUrl } from '@/config/comfyApi'
|
import { getComfyApiBaseUrl } from '@/config/comfyApi'
|
||||||
import { t } from '@/i18n'
|
import { t } from '@/i18n'
|
||||||
|
import { WORKSPACE_STORAGE_KEYS } from '@/platform/auth/workspace/workspaceConstants'
|
||||||
import { isCloud } from '@/platform/distribution/types'
|
import { isCloud } from '@/platform/distribution/types'
|
||||||
|
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
|
||||||
import { useTelemetry } from '@/platform/telemetry'
|
import { useTelemetry } from '@/platform/telemetry'
|
||||||
import { useDialogService } from '@/services/dialogService'
|
import { useDialogService } from '@/services/dialogService'
|
||||||
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
|
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
|
||||||
@@ -107,6 +109,15 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
|
|||||||
isInitialized.value = true
|
isInitialized.value = true
|
||||||
if (user === null) {
|
if (user === null) {
|
||||||
lastTokenUserId.value = null
|
lastTokenUserId.value = null
|
||||||
|
|
||||||
|
// Clear workspace sessionStorage on logout to prevent stale tokens
|
||||||
|
try {
|
||||||
|
sessionStorage.removeItem(WORKSPACE_STORAGE_KEYS.CURRENT_WORKSPACE)
|
||||||
|
sessionStorage.removeItem(WORKSPACE_STORAGE_KEYS.TOKEN)
|
||||||
|
sessionStorage.removeItem(WORKSPACE_STORAGE_KEYS.EXPIRES_AT)
|
||||||
|
} catch {
|
||||||
|
// Ignore sessionStorage errors (e.g., in private browsing mode)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset balance when auth state changes
|
// Reset balance when auth state changes
|
||||||
@@ -152,16 +163,34 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
|
|||||||
/**
|
/**
|
||||||
* Retrieves the appropriate authentication header for API requests.
|
* Retrieves the appropriate authentication header for API requests.
|
||||||
* Checks for authentication in the following order:
|
* Checks for authentication in the following order:
|
||||||
* 1. Firebase authentication token (if user is logged in)
|
* 1. Workspace token (if team_workspaces_enabled and user has active workspace context)
|
||||||
* 2. API key (if stored in the browser's credential manager)
|
* 2. Firebase authentication token (if user is logged in)
|
||||||
|
* 3. API key (if stored in the browser's credential manager)
|
||||||
*
|
*
|
||||||
* @returns {Promise<AuthHeader | null>}
|
* @returns {Promise<AuthHeader | null>}
|
||||||
* - A LoggedInAuthHeader with Bearer token if Firebase authenticated
|
* - A LoggedInAuthHeader with Bearer token (workspace or Firebase)
|
||||||
* - An ApiKeyAuthHeader with X-API-KEY if API key exists
|
* - An ApiKeyAuthHeader with X-API-KEY if API key exists
|
||||||
* - null if neither authentication method is available
|
* - null if no authentication method is available
|
||||||
*/
|
*/
|
||||||
const getAuthHeader = async (): Promise<AuthHeader | null> => {
|
const getAuthHeader = async (): Promise<AuthHeader | null> => {
|
||||||
// If available, set header with JWT used to identify the user to Firebase service
|
if (remoteConfig.value.team_workspaces_enabled) {
|
||||||
|
const workspaceToken = sessionStorage.getItem(
|
||||||
|
WORKSPACE_STORAGE_KEYS.TOKEN
|
||||||
|
)
|
||||||
|
const expiresAt = sessionStorage.getItem(
|
||||||
|
WORKSPACE_STORAGE_KEYS.EXPIRES_AT
|
||||||
|
)
|
||||||
|
|
||||||
|
if (workspaceToken && expiresAt) {
|
||||||
|
const expiryTime = parseInt(expiresAt, 10)
|
||||||
|
if (Date.now() < expiryTime) {
|
||||||
|
return {
|
||||||
|
Authorization: `Bearer ${workspaceToken}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const token = await getIdToken()
|
const token = await getIdToken()
|
||||||
if (token) {
|
if (token) {
|
||||||
return {
|
return {
|
||||||
@@ -169,7 +198,6 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If not authenticated with Firebase, try falling back to API key if available
|
|
||||||
return useApiKeyAuthStore().getAuthHeader()
|
return useApiKeyAuthStore().getAuthHeader()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import { createPinia, setActivePinia } from 'pinia'
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
import type { ComfyApp } from '@/scripts/app'
|
import type {
|
||||||
|
JobDetail,
|
||||||
|
JobListItem
|
||||||
|
} from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||||
|
import type { ComfyApp } from '@/scripts/app'
|
||||||
import { TaskItemImpl } from '@/stores/queueStore'
|
import { TaskItemImpl } from '@/stores/queueStore'
|
||||||
import * as getWorkflowModule from '@/platform/workflow/cloud'
|
import * as jobOutputCache from '@/services/jobOutputCache'
|
||||||
|
|
||||||
vi.mock('@/platform/distribution/types', () => ({
|
|
||||||
isCloud: true
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('@/services/extensionService', () => ({
|
vi.mock('@/services/extensionService', () => ({
|
||||||
useExtensionService: vi.fn(() => ({
|
useExtensionService: vi.fn(() => ({
|
||||||
@@ -17,8 +17,6 @@ vi.mock('@/services/extensionService', () => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
const mockWorkflow: ComfyWorkflowJSON = {
|
const mockWorkflow: ComfyWorkflowJSON = {
|
||||||
id: 'test-workflow-id',
|
|
||||||
revision: 0,
|
|
||||||
last_node_id: 5,
|
last_node_id: 5,
|
||||||
last_link_id: 3,
|
last_link_id: 3,
|
||||||
nodes: [],
|
nodes: [],
|
||||||
@@ -29,53 +27,46 @@ const mockWorkflow: ComfyWorkflowJSON = {
|
|||||||
version: 0.4
|
version: 0.4
|
||||||
}
|
}
|
||||||
|
|
||||||
const createHistoryTaskWithWorkflow = (): TaskItemImpl => {
|
// Mock job detail response (matches actual /jobs/{id} API response structure)
|
||||||
return new TaskItemImpl(
|
// workflow is nested at: workflow.extra_data.extra_pnginfo.workflow
|
||||||
'History',
|
const mockJobDetail = {
|
||||||
[
|
id: 'test-prompt-id',
|
||||||
0, // queueIndex
|
status: 'completed' as const,
|
||||||
'test-prompt-id', // promptId
|
create_time: Date.now(),
|
||||||
{}, // promptInputs
|
update_time: Date.now(),
|
||||||
{
|
workflow: {
|
||||||
client_id: 'test-client',
|
extra_data: {
|
||||||
extra_pnginfo: {
|
extra_pnginfo: {
|
||||||
workflow: mockWorkflow
|
workflow: mockWorkflow
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
[] // outputsToExecute
|
},
|
||||||
],
|
outputs: {
|
||||||
{
|
'1': { images: [{ filename: 'test.png', subfolder: '', type: 'output' }] }
|
||||||
status_str: 'success',
|
}
|
||||||
completed: true,
|
|
||||||
messages: []
|
|
||||||
},
|
|
||||||
{} // outputs
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const createHistoryTaskWithoutWorkflow = (): TaskItemImpl => {
|
function createHistoryJob(id: string): JobListItem {
|
||||||
return new TaskItemImpl(
|
const now = Date.now()
|
||||||
'History',
|
return {
|
||||||
[
|
id,
|
||||||
0,
|
status: 'completed',
|
||||||
'test-prompt-id',
|
create_time: now,
|
||||||
{},
|
priority: now
|
||||||
{
|
}
|
||||||
client_id: 'test-client'
|
|
||||||
// No extra_pnginfo.workflow
|
|
||||||
},
|
|
||||||
[]
|
|
||||||
],
|
|
||||||
{
|
|
||||||
status_str: 'success',
|
|
||||||
completed: true,
|
|
||||||
messages: []
|
|
||||||
},
|
|
||||||
{}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('TaskItemImpl.loadWorkflow - cloud history workflow fetching', () => {
|
function createRunningJob(id: string): JobListItem {
|
||||||
|
const now = Date.now()
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
status: 'in_progress',
|
||||||
|
create_time: now,
|
||||||
|
priority: now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('TaskItemImpl.loadWorkflow - workflow fetching', () => {
|
||||||
let mockApp: ComfyApp
|
let mockApp: ComfyApp
|
||||||
let mockFetchApi: ReturnType<typeof vi.fn>
|
let mockFetchApi: ReturnType<typeof vi.fn>
|
||||||
|
|
||||||
@@ -91,85 +82,57 @@ describe('TaskItemImpl.loadWorkflow - cloud history workflow fetching', () => {
|
|||||||
fetchApi: mockFetchApi
|
fetchApi: mockFetchApi
|
||||||
}
|
}
|
||||||
} as unknown as ComfyApp
|
} as unknown as ComfyApp
|
||||||
|
|
||||||
vi.spyOn(getWorkflowModule, 'getWorkflowFromHistory')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should load workflow directly when workflow is in extra_pnginfo', async () => {
|
it('should fetch workflow from API for history tasks', async () => {
|
||||||
const task = createHistoryTaskWithWorkflow()
|
const job = createHistoryJob('test-prompt-id')
|
||||||
|
const task = new TaskItemImpl(job)
|
||||||
|
|
||||||
await task.loadWorkflow(mockApp)
|
vi.spyOn(jobOutputCache, 'getJobDetail').mockResolvedValue(
|
||||||
|
mockJobDetail as JobDetail
|
||||||
expect(mockApp.loadGraphData).toHaveBeenCalledWith(mockWorkflow)
|
|
||||||
expect(mockFetchApi).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should fetch workflow from cloud when workflow is missing from history task', async () => {
|
|
||||||
const task = createHistoryTaskWithoutWorkflow()
|
|
||||||
|
|
||||||
// Mock getWorkflowFromHistory to return workflow
|
|
||||||
vi.spyOn(getWorkflowModule, 'getWorkflowFromHistory').mockResolvedValue(
|
|
||||||
mockWorkflow
|
|
||||||
)
|
)
|
||||||
|
|
||||||
await task.loadWorkflow(mockApp)
|
await task.loadWorkflow(mockApp)
|
||||||
|
|
||||||
expect(getWorkflowModule.getWorkflowFromHistory).toHaveBeenCalledWith(
|
expect(jobOutputCache.getJobDetail).toHaveBeenCalledWith('test-prompt-id')
|
||||||
expect.any(Function),
|
|
||||||
'test-prompt-id'
|
|
||||||
)
|
|
||||||
expect(mockApp.loadGraphData).toHaveBeenCalledWith(mockWorkflow)
|
expect(mockApp.loadGraphData).toHaveBeenCalledWith(mockWorkflow)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should not load workflow when fetch returns undefined', async () => {
|
it('should not load workflow when fetch returns undefined', async () => {
|
||||||
const task = createHistoryTaskWithoutWorkflow()
|
const job = createHistoryJob('test-prompt-id')
|
||||||
|
const task = new TaskItemImpl(job)
|
||||||
|
|
||||||
vi.spyOn(getWorkflowModule, 'getWorkflowFromHistory').mockResolvedValue(
|
vi.spyOn(jobOutputCache, 'getJobDetail').mockResolvedValue(undefined)
|
||||||
undefined
|
|
||||||
)
|
|
||||||
|
|
||||||
await task.loadWorkflow(mockApp)
|
await task.loadWorkflow(mockApp)
|
||||||
|
|
||||||
expect(getWorkflowModule.getWorkflowFromHistory).toHaveBeenCalled()
|
expect(jobOutputCache.getJobDetail).toHaveBeenCalled()
|
||||||
expect(mockApp.loadGraphData).not.toHaveBeenCalled()
|
expect(mockApp.loadGraphData).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should only fetch for history tasks, not running tasks', async () => {
|
it('should only fetch for history tasks, not running tasks', async () => {
|
||||||
const runningTask = new TaskItemImpl(
|
const job = createRunningJob('test-prompt-id')
|
||||||
'Running',
|
const runningTask = new TaskItemImpl(job)
|
||||||
[
|
|
||||||
0,
|
|
||||||
'test-prompt-id',
|
|
||||||
{},
|
|
||||||
{
|
|
||||||
client_id: 'test-client'
|
|
||||||
},
|
|
||||||
[]
|
|
||||||
],
|
|
||||||
undefined,
|
|
||||||
{}
|
|
||||||
)
|
|
||||||
|
|
||||||
vi.spyOn(getWorkflowModule, 'getWorkflowFromHistory').mockResolvedValue(
|
vi.spyOn(jobOutputCache, 'getJobDetail').mockResolvedValue(
|
||||||
mockWorkflow
|
mockJobDetail as JobDetail
|
||||||
)
|
)
|
||||||
|
|
||||||
await runningTask.loadWorkflow(mockApp)
|
await runningTask.loadWorkflow(mockApp)
|
||||||
|
|
||||||
expect(getWorkflowModule.getWorkflowFromHistory).not.toHaveBeenCalled()
|
expect(jobOutputCache.getJobDetail).not.toHaveBeenCalled()
|
||||||
expect(mockApp.loadGraphData).not.toHaveBeenCalled()
|
expect(mockApp.loadGraphData).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should handle fetch errors gracefully by returning undefined', async () => {
|
it('should handle fetch errors gracefully by returning undefined', async () => {
|
||||||
const task = createHistoryTaskWithoutWorkflow()
|
const job = createHistoryJob('test-prompt-id')
|
||||||
|
const task = new TaskItemImpl(job)
|
||||||
|
|
||||||
vi.spyOn(getWorkflowModule, 'getWorkflowFromHistory').mockResolvedValue(
|
vi.spyOn(jobOutputCache, 'getJobDetail').mockResolvedValue(undefined)
|
||||||
undefined
|
|
||||||
)
|
|
||||||
|
|
||||||
await task.loadWorkflow(mockApp)
|
await task.loadWorkflow(mockApp)
|
||||||
|
|
||||||
expect(getWorkflowModule.getWorkflowFromHistory).toHaveBeenCalled()
|
expect(jobOutputCache.getJobDetail).toHaveBeenCalled()
|
||||||
expect(mockApp.loadGraphData).not.toHaveBeenCalled()
|
expect(mockApp.loadGraphData).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,34 +1,39 @@
|
|||||||
import { createPinia, setActivePinia } from 'pinia'
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
import type {
|
import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||||
HistoryTaskItem,
|
import type { TaskOutput } from '@/schemas/apiSchema'
|
||||||
PendingTaskItem,
|
|
||||||
RunningTaskItem,
|
|
||||||
TaskOutput,
|
|
||||||
TaskPrompt,
|
|
||||||
TaskStatus
|
|
||||||
} from '@/schemas/apiSchema'
|
|
||||||
import { api } from '@/scripts/api'
|
import { api } from '@/scripts/api'
|
||||||
import { TaskItemImpl, useQueueStore } from '@/stores/queueStore'
|
import { TaskItemImpl, useQueueStore } from '@/stores/queueStore'
|
||||||
|
|
||||||
// Fixture factories
|
// Fixture factory for JobListItem
|
||||||
const createTaskPrompt = (
|
function createJob(
|
||||||
queueIndex: number,
|
id: string,
|
||||||
promptId: string,
|
status: JobListItem['status'],
|
||||||
inputs: Record<string, any> = {},
|
createTime: number = Date.now(),
|
||||||
extraData: Record<string, any> = {},
|
priority?: number
|
||||||
outputsToExecute: any[] = []
|
): JobListItem {
|
||||||
): TaskPrompt => [queueIndex, promptId, inputs, extraData, outputsToExecute]
|
return {
|
||||||
|
id,
|
||||||
|
status,
|
||||||
|
create_time: createTime,
|
||||||
|
update_time: createTime,
|
||||||
|
last_state_update: createTime,
|
||||||
|
priority: priority ?? createTime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const createTaskStatus = (
|
function createRunningJob(createTime: number, id: string): JobListItem {
|
||||||
statusStr: 'success' | 'error' = 'success',
|
return createJob(id, 'in_progress', createTime)
|
||||||
messages: any[] = []
|
}
|
||||||
): TaskStatus => ({
|
|
||||||
status_str: statusStr,
|
function createPendingJob(createTime: number, id: string): JobListItem {
|
||||||
completed: true,
|
return createJob(id, 'pending', createTime)
|
||||||
messages
|
}
|
||||||
})
|
|
||||||
|
function createHistoryJob(createTime: number, id: string): JobListItem {
|
||||||
|
return createJob(id, 'completed', createTime)
|
||||||
|
}
|
||||||
|
|
||||||
const createTaskOutput = (
|
const createTaskOutput = (
|
||||||
nodeId: string = 'node-1',
|
nodeId: string = 'node-1',
|
||||||
@@ -39,35 +44,6 @@ const createTaskOutput = (
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const createRunningTask = (
|
|
||||||
queueIndex: number,
|
|
||||||
promptId: string
|
|
||||||
): RunningTaskItem => ({
|
|
||||||
taskType: 'Running',
|
|
||||||
prompt: createTaskPrompt(queueIndex, promptId),
|
|
||||||
remove: { name: 'Cancel', cb: () => {} }
|
|
||||||
})
|
|
||||||
|
|
||||||
const createPendingTask = (
|
|
||||||
queueIndex: number,
|
|
||||||
promptId: string
|
|
||||||
): PendingTaskItem => ({
|
|
||||||
taskType: 'Pending',
|
|
||||||
prompt: createTaskPrompt(queueIndex, promptId)
|
|
||||||
})
|
|
||||||
|
|
||||||
const createHistoryTask = (
|
|
||||||
queueIndex: number,
|
|
||||||
promptId: string,
|
|
||||||
outputs: TaskOutput = createTaskOutput(),
|
|
||||||
status: TaskStatus = createTaskStatus()
|
|
||||||
): HistoryTaskItem => ({
|
|
||||||
taskType: 'History',
|
|
||||||
prompt: createTaskPrompt(queueIndex, promptId),
|
|
||||||
status,
|
|
||||||
outputs
|
|
||||||
})
|
|
||||||
|
|
||||||
// Mock API
|
// Mock API
|
||||||
vi.mock('@/scripts/api', () => ({
|
vi.mock('@/scripts/api', () => ({
|
||||||
api: {
|
api: {
|
||||||
@@ -83,17 +59,13 @@ vi.mock('@/scripts/api', () => ({
|
|||||||
|
|
||||||
describe('TaskItemImpl', () => {
|
describe('TaskItemImpl', () => {
|
||||||
it('should remove animated property from outputs during construction', () => {
|
it('should remove animated property from outputs during construction', () => {
|
||||||
const taskItem = new TaskItemImpl(
|
const job = createHistoryJob(0, 'prompt-id')
|
||||||
'History',
|
const taskItem = new TaskItemImpl(job, {
|
||||||
[0, 'prompt-id', {}, { client_id: 'client-id' }, []],
|
'node-1': {
|
||||||
{ status_str: 'success', messages: [], completed: true },
|
images: [{ filename: 'test.png', type: 'output', subfolder: '' }],
|
||||||
{
|
animated: [false]
|
||||||
'node-1': {
|
|
||||||
images: [{ filename: 'test.png', type: 'output', subfolder: '' }],
|
|
||||||
animated: [false]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
})
|
||||||
|
|
||||||
// Check that animated property was removed
|
// Check that animated property was removed
|
||||||
expect('animated' in taskItem.outputs['node-1']).toBe(false)
|
expect('animated' in taskItem.outputs['node-1']).toBe(false)
|
||||||
@@ -103,90 +75,72 @@ describe('TaskItemImpl', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should handle outputs without animated property', () => {
|
it('should handle outputs without animated property', () => {
|
||||||
const taskItem = new TaskItemImpl(
|
const job = createHistoryJob(0, 'prompt-id')
|
||||||
'History',
|
const taskItem = new TaskItemImpl(job, {
|
||||||
[0, 'prompt-id', {}, { client_id: 'client-id' }, []],
|
'node-1': {
|
||||||
{ status_str: 'success', messages: [], completed: true },
|
images: [{ filename: 'test.png', type: 'output', subfolder: '' }]
|
||||||
{
|
|
||||||
'node-1': {
|
|
||||||
images: [{ filename: 'test.png', type: 'output', subfolder: '' }]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
})
|
||||||
|
|
||||||
expect(taskItem.outputs['node-1'].images).toBeDefined()
|
expect(taskItem.outputs['node-1'].images).toBeDefined()
|
||||||
expect(taskItem.outputs['node-1'].images?.[0]?.filename).toBe('test.png')
|
expect(taskItem.outputs['node-1'].images?.[0]?.filename).toBe('test.png')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should recognize webm video from core', () => {
|
it('should recognize webm video from core', () => {
|
||||||
const taskItem = new TaskItemImpl(
|
const job = createHistoryJob(0, 'prompt-id')
|
||||||
'History',
|
const taskItem = new TaskItemImpl(job, {
|
||||||
[0, 'prompt-id', {}, { client_id: 'client-id' }, []],
|
'node-1': {
|
||||||
{ status_str: 'success', messages: [], completed: true },
|
video: [{ filename: 'test.webm', type: 'output', subfolder: '' }]
|
||||||
{
|
|
||||||
'node-1': {
|
|
||||||
video: [{ filename: 'test.webm', type: 'output', subfolder: '' }]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
})
|
||||||
|
|
||||||
const output = taskItem.flatOutputs[0]
|
const output = taskItem.flatOutputs[0]
|
||||||
|
|
||||||
expect(output.htmlVideoType).toBe('video/webm')
|
expect(output.htmlVideoType).toBe('video/webm')
|
||||||
expect(output.isVideo).toBe(true)
|
expect(output.isVideo).toBe(true)
|
||||||
expect(output.isWebm).toBe(true)
|
|
||||||
expect(output.isVhsFormat).toBe(false)
|
expect(output.isVhsFormat).toBe(false)
|
||||||
expect(output.isImage).toBe(false)
|
expect(output.isImage).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
// https://github.com/Kosinkadink/ComfyUI-VideoHelperSuite/blob/0a75c7958fe320efcb052f1d9f8451fd20c730a8/videohelpersuite/nodes.py#L578-L590
|
// https://github.com/Kosinkadink/ComfyUI-VideoHelperSuite/blob/0a75c7958fe320efcb052f1d9f8451fd20c730a8/videohelpersuite/nodes.py#L578-L590
|
||||||
it('should recognize webm video from VHS', () => {
|
it('should recognize webm video from VHS', () => {
|
||||||
const taskItem = new TaskItemImpl(
|
const job = createHistoryJob(0, 'prompt-id')
|
||||||
'History',
|
const taskItem = new TaskItemImpl(job, {
|
||||||
[0, 'prompt-id', {}, { client_id: 'client-id' }, []],
|
'node-1': {
|
||||||
{ status_str: 'success', messages: [], completed: true },
|
gifs: [
|
||||||
{
|
{
|
||||||
'node-1': {
|
filename: 'test.webm',
|
||||||
gifs: [
|
type: 'output',
|
||||||
{
|
subfolder: '',
|
||||||
filename: 'test.webm',
|
format: 'video/webm',
|
||||||
type: 'output',
|
frame_rate: 30
|
||||||
subfolder: '',
|
}
|
||||||
format: 'video/webm',
|
]
|
||||||
frame_rate: 30
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
})
|
||||||
|
|
||||||
const output = taskItem.flatOutputs[0]
|
const output = taskItem.flatOutputs[0]
|
||||||
|
|
||||||
expect(output.htmlVideoType).toBe('video/webm')
|
expect(output.htmlVideoType).toBe('video/webm')
|
||||||
expect(output.isVideo).toBe(true)
|
expect(output.isVideo).toBe(true)
|
||||||
expect(output.isWebm).toBe(true)
|
|
||||||
expect(output.isVhsFormat).toBe(true)
|
expect(output.isVhsFormat).toBe(true)
|
||||||
expect(output.isImage).toBe(false)
|
expect(output.isImage).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should recognize mp4 video from core', () => {
|
it('should recognize mp4 video from core', () => {
|
||||||
const taskItem = new TaskItemImpl(
|
const job = createHistoryJob(0, 'prompt-id')
|
||||||
'History',
|
const taskItem = new TaskItemImpl(job, {
|
||||||
[0, 'prompt-id', {}, { client_id: 'client-id' }, []],
|
'node-1': {
|
||||||
{ status_str: 'success', messages: [], completed: true },
|
images: [
|
||||||
{
|
{
|
||||||
'node-1': {
|
filename: 'test.mp4',
|
||||||
images: [
|
type: 'output',
|
||||||
{
|
subfolder: ''
|
||||||
filename: 'test.mp4',
|
}
|
||||||
type: 'output',
|
],
|
||||||
subfolder: ''
|
animated: [true]
|
||||||
}
|
|
||||||
],
|
|
||||||
animated: [true]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
})
|
||||||
|
|
||||||
const output = taskItem.flatOutputs[0]
|
const output = taskItem.flatOutputs[0]
|
||||||
|
|
||||||
@@ -205,22 +159,18 @@ describe('TaskItemImpl', () => {
|
|||||||
|
|
||||||
audioFormats.forEach(({ extension, mimeType }) => {
|
audioFormats.forEach(({ extension, mimeType }) => {
|
||||||
it(`should recognize ${extension} audio`, () => {
|
it(`should recognize ${extension} audio`, () => {
|
||||||
const taskItem = new TaskItemImpl(
|
const job = createHistoryJob(0, 'prompt-id')
|
||||||
'History',
|
const taskItem = new TaskItemImpl(job, {
|
||||||
[0, 'prompt-id', {}, { client_id: 'client-id' }, []],
|
'node-1': {
|
||||||
{ status_str: 'success', messages: [], completed: true },
|
audio: [
|
||||||
{
|
{
|
||||||
'node-1': {
|
filename: `test.${extension}`,
|
||||||
audio: [
|
type: 'output',
|
||||||
{
|
subfolder: ''
|
||||||
filename: `test.${extension}`,
|
}
|
||||||
type: 'output',
|
]
|
||||||
subfolder: ''
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
})
|
||||||
|
|
||||||
const output = taskItem.flatOutputs[0]
|
const output = taskItem.flatOutputs[0]
|
||||||
|
|
||||||
@@ -232,6 +182,58 @@ describe('TaskItemImpl', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('error extraction getters', () => {
|
||||||
|
it('errorMessage returns undefined when no execution_error', () => {
|
||||||
|
const job = createHistoryJob(0, 'prompt-id')
|
||||||
|
const taskItem = new TaskItemImpl(job)
|
||||||
|
expect(taskItem.errorMessage).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('errorMessage returns the exception_message from execution_error', () => {
|
||||||
|
const job: JobListItem = {
|
||||||
|
...createHistoryJob(0, 'prompt-id'),
|
||||||
|
status: 'failed',
|
||||||
|
execution_error: {
|
||||||
|
node_id: 'node-1',
|
||||||
|
node_type: 'KSampler',
|
||||||
|
exception_message: 'GPU out of memory',
|
||||||
|
exception_type: 'RuntimeError',
|
||||||
|
traceback: ['line 1', 'line 2'],
|
||||||
|
current_inputs: {},
|
||||||
|
current_outputs: {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const taskItem = new TaskItemImpl(job)
|
||||||
|
expect(taskItem.errorMessage).toBe('GPU out of memory')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('executionError returns undefined when no execution_error', () => {
|
||||||
|
const job = createHistoryJob(0, 'prompt-id')
|
||||||
|
const taskItem = new TaskItemImpl(job)
|
||||||
|
expect(taskItem.executionError).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('executionError returns the full error object from execution_error', () => {
|
||||||
|
const errorDetail = {
|
||||||
|
node_id: 'node-1',
|
||||||
|
node_type: 'KSampler',
|
||||||
|
executed: ['node-0'],
|
||||||
|
exception_message: 'Invalid dimensions',
|
||||||
|
exception_type: 'ValueError',
|
||||||
|
traceback: ['traceback line'],
|
||||||
|
current_inputs: { input1: 'value' },
|
||||||
|
current_outputs: {}
|
||||||
|
}
|
||||||
|
const job: JobListItem = {
|
||||||
|
...createHistoryJob(0, 'prompt-id'),
|
||||||
|
status: 'failed',
|
||||||
|
execution_error: errorDetail
|
||||||
|
}
|
||||||
|
const taskItem = new TaskItemImpl(job)
|
||||||
|
expect(taskItem.executionError).toEqual(errorDetail)
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('useQueueStore', () => {
|
describe('useQueueStore', () => {
|
||||||
@@ -267,15 +269,16 @@ describe('useQueueStore', () => {
|
|||||||
|
|
||||||
describe('update() - basic functionality', () => {
|
describe('update() - basic functionality', () => {
|
||||||
it('should load running and pending tasks from API', async () => {
|
it('should load running and pending tasks from API', async () => {
|
||||||
const runningTask = createRunningTask(1, 'run-1')
|
const runningJob = createRunningJob(1, 'run-1')
|
||||||
const pendingTask1 = createPendingTask(2, 'pend-1')
|
const pendingJob1 = createPendingJob(2, 'pend-1')
|
||||||
const pendingTask2 = createPendingTask(3, 'pend-2')
|
const pendingJob2 = createPendingJob(3, 'pend-2')
|
||||||
|
|
||||||
|
// API returns pre-sorted data (newest first)
|
||||||
mockGetQueue.mockResolvedValue({
|
mockGetQueue.mockResolvedValue({
|
||||||
Running: [runningTask],
|
Running: [runningJob],
|
||||||
Pending: [pendingTask1, pendingTask2]
|
Pending: [pendingJob2, pendingJob1] // Pre-sorted by create_time desc
|
||||||
})
|
})
|
||||||
mockGetHistory.mockResolvedValue({ History: [] })
|
mockGetHistory.mockResolvedValue([])
|
||||||
|
|
||||||
await store.update()
|
await store.update()
|
||||||
|
|
||||||
@@ -287,13 +290,11 @@ describe('useQueueStore', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should load history tasks from API', async () => {
|
it('should load history tasks from API', async () => {
|
||||||
const historyTask1 = createHistoryTask(5, 'hist-1')
|
const historyJob1 = createHistoryJob(5, 'hist-1')
|
||||||
const historyTask2 = createHistoryTask(4, 'hist-2')
|
const historyJob2 = createHistoryJob(4, 'hist-2')
|
||||||
|
|
||||||
mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
|
mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
|
||||||
mockGetHistory.mockResolvedValue({
|
mockGetHistory.mockResolvedValue([historyJob1, historyJob2])
|
||||||
History: [historyTask1, historyTask2]
|
|
||||||
})
|
|
||||||
|
|
||||||
await store.update()
|
await store.update()
|
||||||
|
|
||||||
@@ -304,7 +305,7 @@ describe('useQueueStore', () => {
|
|||||||
|
|
||||||
it('should set loading state correctly', async () => {
|
it('should set loading state correctly', async () => {
|
||||||
mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
|
mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
|
||||||
mockGetHistory.mockResolvedValue({ History: [] })
|
mockGetHistory.mockResolvedValue([])
|
||||||
|
|
||||||
expect(store.isLoading).toBe(false)
|
expect(store.isLoading).toBe(false)
|
||||||
|
|
||||||
@@ -317,7 +318,7 @@ describe('useQueueStore', () => {
|
|||||||
|
|
||||||
it('should clear loading state even if API fails', async () => {
|
it('should clear loading state even if API fails', async () => {
|
||||||
mockGetQueue.mockRejectedValue(new Error('API error'))
|
mockGetQueue.mockRejectedValue(new Error('API error'))
|
||||||
mockGetHistory.mockResolvedValue({ History: [] })
|
mockGetHistory.mockResolvedValue([])
|
||||||
|
|
||||||
await expect(store.update()).rejects.toThrow('API error')
|
await expect(store.update()).rejects.toThrow('API error')
|
||||||
expect(store.isLoading).toBe(false)
|
expect(store.isLoading).toBe(false)
|
||||||
@@ -326,14 +327,12 @@ describe('useQueueStore', () => {
|
|||||||
|
|
||||||
describe('update() - sorting', () => {
|
describe('update() - sorting', () => {
|
||||||
it('should sort tasks by queueIndex descending', async () => {
|
it('should sort tasks by queueIndex descending', async () => {
|
||||||
const task1 = createHistoryTask(1, 'hist-1')
|
const job1 = createHistoryJob(1, 'hist-1')
|
||||||
const task2 = createHistoryTask(5, 'hist-2')
|
const job2 = createHistoryJob(5, 'hist-2')
|
||||||
const task3 = createHistoryTask(3, 'hist-3')
|
const job3 = createHistoryJob(3, 'hist-3')
|
||||||
|
|
||||||
mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
|
mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
|
||||||
mockGetHistory.mockResolvedValue({
|
mockGetHistory.mockResolvedValue([job1, job2, job3])
|
||||||
History: [task1, task2, task3]
|
|
||||||
})
|
|
||||||
|
|
||||||
await store.update()
|
await store.update()
|
||||||
|
|
||||||
@@ -342,16 +341,17 @@ describe('useQueueStore', () => {
|
|||||||
expect(store.historyTasks[2].queueIndex).toBe(1)
|
expect(store.historyTasks[2].queueIndex).toBe(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should sort pending tasks by queueIndex descending', async () => {
|
it('should preserve API sort order for pending tasks', async () => {
|
||||||
const pend1 = createPendingTask(10, 'pend-1')
|
const pend1 = createPendingJob(10, 'pend-1')
|
||||||
const pend2 = createPendingTask(15, 'pend-2')
|
const pend2 = createPendingJob(15, 'pend-2')
|
||||||
const pend3 = createPendingTask(12, 'pend-3')
|
const pend3 = createPendingJob(12, 'pend-3')
|
||||||
|
|
||||||
|
// API returns pre-sorted data (newest first)
|
||||||
mockGetQueue.mockResolvedValue({
|
mockGetQueue.mockResolvedValue({
|
||||||
Running: [],
|
Running: [],
|
||||||
Pending: [pend1, pend2, pend3]
|
Pending: [pend2, pend3, pend1] // Pre-sorted by create_time desc
|
||||||
})
|
})
|
||||||
mockGetHistory.mockResolvedValue({ History: [] })
|
mockGetHistory.mockResolvedValue([])
|
||||||
|
|
||||||
await store.update()
|
await store.update()
|
||||||
|
|
||||||
@@ -363,19 +363,17 @@ describe('useQueueStore', () => {
|
|||||||
|
|
||||||
describe('update() - queue index collision (THE BUG FIX)', () => {
|
describe('update() - queue index collision (THE BUG FIX)', () => {
|
||||||
it('should NOT confuse different prompts with same queueIndex', async () => {
|
it('should NOT confuse different prompts with same queueIndex', async () => {
|
||||||
const hist1 = createHistoryTask(50, 'prompt-uuid-aaa')
|
const hist1 = createHistoryJob(50, 'prompt-uuid-aaa')
|
||||||
|
|
||||||
mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
|
mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
|
||||||
mockGetHistory.mockResolvedValue({ History: [hist1] })
|
mockGetHistory.mockResolvedValue([hist1])
|
||||||
|
|
||||||
await store.update()
|
await store.update()
|
||||||
expect(store.historyTasks).toHaveLength(1)
|
expect(store.historyTasks).toHaveLength(1)
|
||||||
expect(store.historyTasks[0].promptId).toBe('prompt-uuid-aaa')
|
expect(store.historyTasks[0].promptId).toBe('prompt-uuid-aaa')
|
||||||
|
|
||||||
const hist2 = createHistoryTask(51, 'prompt-uuid-bbb')
|
const hist2 = createHistoryJob(51, 'prompt-uuid-bbb')
|
||||||
mockGetHistory.mockResolvedValue({
|
mockGetHistory.mockResolvedValue([hist2])
|
||||||
History: [hist2]
|
|
||||||
})
|
|
||||||
|
|
||||||
await store.update()
|
await store.update()
|
||||||
|
|
||||||
@@ -385,19 +383,17 @@ describe('useQueueStore', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should correctly reconcile when queueIndex is reused', async () => {
|
it('should correctly reconcile when queueIndex is reused', async () => {
|
||||||
const hist1 = createHistoryTask(100, 'first-prompt-at-100')
|
const hist1 = createHistoryJob(100, 'first-prompt-at-100')
|
||||||
const hist2 = createHistoryTask(99, 'prompt-at-99')
|
const hist2 = createHistoryJob(99, 'prompt-at-99')
|
||||||
|
|
||||||
mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
|
mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
|
||||||
mockGetHistory.mockResolvedValue({ History: [hist1, hist2] })
|
mockGetHistory.mockResolvedValue([hist1, hist2])
|
||||||
|
|
||||||
await store.update()
|
await store.update()
|
||||||
expect(store.historyTasks).toHaveLength(2)
|
expect(store.historyTasks).toHaveLength(2)
|
||||||
|
|
||||||
const hist3 = createHistoryTask(101, 'second-prompt-at-101')
|
const hist3 = createHistoryJob(101, 'second-prompt-at-101')
|
||||||
mockGetHistory.mockResolvedValue({
|
mockGetHistory.mockResolvedValue([hist3, hist2])
|
||||||
History: [hist3, hist2]
|
|
||||||
})
|
|
||||||
|
|
||||||
await store.update()
|
await store.update()
|
||||||
|
|
||||||
@@ -409,23 +405,19 @@ describe('useQueueStore', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should handle multiple queueIndex collisions simultaneously', async () => {
|
it('should handle multiple queueIndex collisions simultaneously', async () => {
|
||||||
const hist1 = createHistoryTask(10, 'old-at-10')
|
const hist1 = createHistoryJob(10, 'old-at-10')
|
||||||
const hist2 = createHistoryTask(20, 'old-at-20')
|
const hist2 = createHistoryJob(20, 'old-at-20')
|
||||||
const hist3 = createHistoryTask(30, 'keep-at-30')
|
const hist3 = createHistoryJob(30, 'keep-at-30')
|
||||||
|
|
||||||
mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
|
mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
|
||||||
mockGetHistory.mockResolvedValue({
|
mockGetHistory.mockResolvedValue([hist3, hist2, hist1])
|
||||||
History: [hist3, hist2, hist1]
|
|
||||||
})
|
|
||||||
|
|
||||||
await store.update()
|
await store.update()
|
||||||
expect(store.historyTasks).toHaveLength(3)
|
expect(store.historyTasks).toHaveLength(3)
|
||||||
|
|
||||||
const newHist1 = createHistoryTask(31, 'new-at-31')
|
const newHist1 = createHistoryJob(31, 'new-at-31')
|
||||||
const newHist2 = createHistoryTask(32, 'new-at-32')
|
const newHist2 = createHistoryJob(32, 'new-at-32')
|
||||||
mockGetHistory.mockResolvedValue({
|
mockGetHistory.mockResolvedValue([newHist2, newHist1, hist3])
|
||||||
History: [newHist2, newHist1, hist3]
|
|
||||||
})
|
|
||||||
|
|
||||||
await store.update()
|
await store.update()
|
||||||
|
|
||||||
@@ -437,19 +429,17 @@ describe('useQueueStore', () => {
|
|||||||
|
|
||||||
describe('update() - history reconciliation', () => {
|
describe('update() - history reconciliation', () => {
|
||||||
it('should keep existing items still on server (by promptId)', async () => {
|
it('should keep existing items still on server (by promptId)', async () => {
|
||||||
const hist1 = createHistoryTask(10, 'existing-1')
|
const hist1 = createHistoryJob(10, 'existing-1')
|
||||||
const hist2 = createHistoryTask(9, 'existing-2')
|
const hist2 = createHistoryJob(9, 'existing-2')
|
||||||
|
|
||||||
mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
|
mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
|
||||||
mockGetHistory.mockResolvedValue({ History: [hist1, hist2] })
|
mockGetHistory.mockResolvedValue([hist1, hist2])
|
||||||
|
|
||||||
await store.update()
|
await store.update()
|
||||||
expect(store.historyTasks).toHaveLength(2)
|
expect(store.historyTasks).toHaveLength(2)
|
||||||
|
|
||||||
const hist3 = createHistoryTask(11, 'new-1')
|
const hist3 = createHistoryJob(11, 'new-1')
|
||||||
mockGetHistory.mockResolvedValue({
|
mockGetHistory.mockResolvedValue([hist3, hist1, hist2])
|
||||||
History: [hist3, hist1, hist2]
|
|
||||||
})
|
|
||||||
|
|
||||||
await store.update()
|
await store.update()
|
||||||
|
|
||||||
@@ -460,16 +450,16 @@ describe('useQueueStore', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should remove items no longer on server', async () => {
|
it('should remove items no longer on server', async () => {
|
||||||
const hist1 = createHistoryTask(10, 'remove-me')
|
const hist1 = createHistoryJob(10, 'remove-me')
|
||||||
const hist2 = createHistoryTask(9, 'keep-me')
|
const hist2 = createHistoryJob(9, 'keep-me')
|
||||||
|
|
||||||
mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
|
mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
|
||||||
mockGetHistory.mockResolvedValue({ History: [hist1, hist2] })
|
mockGetHistory.mockResolvedValue([hist1, hist2])
|
||||||
|
|
||||||
await store.update()
|
await store.update()
|
||||||
expect(store.historyTasks).toHaveLength(2)
|
expect(store.historyTasks).toHaveLength(2)
|
||||||
|
|
||||||
mockGetHistory.mockResolvedValue({ History: [hist2] })
|
mockGetHistory.mockResolvedValue([hist2])
|
||||||
|
|
||||||
await store.update()
|
await store.update()
|
||||||
|
|
||||||
@@ -478,18 +468,16 @@ describe('useQueueStore', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should add new items from server', async () => {
|
it('should add new items from server', async () => {
|
||||||
const hist1 = createHistoryTask(5, 'old-1')
|
const hist1 = createHistoryJob(5, 'old-1')
|
||||||
|
|
||||||
mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
|
mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
|
||||||
mockGetHistory.mockResolvedValue({ History: [hist1] })
|
mockGetHistory.mockResolvedValue([hist1])
|
||||||
|
|
||||||
await store.update()
|
await store.update()
|
||||||
|
|
||||||
const hist2 = createHistoryTask(6, 'new-1')
|
const hist2 = createHistoryJob(6, 'new-1')
|
||||||
const hist3 = createHistoryTask(7, 'new-2')
|
const hist3 = createHistoryJob(7, 'new-2')
|
||||||
mockGetHistory.mockResolvedValue({
|
mockGetHistory.mockResolvedValue([hist3, hist2, hist1])
|
||||||
History: [hist3, hist2, hist1]
|
|
||||||
})
|
|
||||||
|
|
||||||
await store.update()
|
await store.update()
|
||||||
|
|
||||||
@@ -497,18 +485,69 @@ describe('useQueueStore', () => {
|
|||||||
expect(store.historyTasks.map((t) => t.promptId)).toContain('new-1')
|
expect(store.historyTasks.map((t) => t.promptId)).toContain('new-1')
|
||||||
expect(store.historyTasks.map((t) => t.promptId)).toContain('new-2')
|
expect(store.historyTasks.map((t) => t.promptId)).toContain('new-2')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should recreate TaskItemImpl when outputs_count changes', async () => {
|
||||||
|
// Initial load without outputs_count
|
||||||
|
const jobWithoutOutputsCount = createHistoryJob(10, 'job-1')
|
||||||
|
delete (jobWithoutOutputsCount as any).outputs_count
|
||||||
|
|
||||||
|
mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
|
||||||
|
mockGetHistory.mockResolvedValue([jobWithoutOutputsCount])
|
||||||
|
|
||||||
|
await store.update()
|
||||||
|
expect(store.historyTasks).toHaveLength(1)
|
||||||
|
const initialTask = store.historyTasks[0]
|
||||||
|
expect(initialTask.outputsCount).toBeUndefined()
|
||||||
|
|
||||||
|
// Second load with outputs_count now populated
|
||||||
|
const jobWithOutputsCount = {
|
||||||
|
...createHistoryJob(10, 'job-1'),
|
||||||
|
outputs_count: 2
|
||||||
|
}
|
||||||
|
mockGetHistory.mockResolvedValue([jobWithOutputsCount])
|
||||||
|
|
||||||
|
await store.update()
|
||||||
|
|
||||||
|
// Should have recreated the TaskItemImpl with new outputs_count
|
||||||
|
expect(store.historyTasks).toHaveLength(1)
|
||||||
|
const updatedTask = store.historyTasks[0]
|
||||||
|
expect(updatedTask.outputsCount).toBe(2)
|
||||||
|
// Should be a different instance
|
||||||
|
expect(updatedTask).not.toBe(initialTask)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should reuse TaskItemImpl when outputs_count unchanged', async () => {
|
||||||
|
const job = {
|
||||||
|
...createHistoryJob(10, 'job-1'),
|
||||||
|
outputs_count: 2
|
||||||
|
}
|
||||||
|
|
||||||
|
mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
|
||||||
|
mockGetHistory.mockResolvedValue([job])
|
||||||
|
|
||||||
|
await store.update()
|
||||||
|
const initialTask = store.historyTasks[0]
|
||||||
|
|
||||||
|
// Same job with same outputs_count
|
||||||
|
mockGetHistory.mockResolvedValue([{ ...job }])
|
||||||
|
|
||||||
|
await store.update()
|
||||||
|
|
||||||
|
// Should reuse the same instance
|
||||||
|
expect(store.historyTasks[0]).toBe(initialTask)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('update() - maxHistoryItems limit', () => {
|
describe('update() - maxHistoryItems limit', () => {
|
||||||
it('should enforce maxHistoryItems limit', async () => {
|
it('should enforce maxHistoryItems limit', async () => {
|
||||||
store.maxHistoryItems = 3
|
store.maxHistoryItems = 3
|
||||||
|
|
||||||
const tasks = Array.from({ length: 5 }, (_, i) =>
|
const jobs = Array.from({ length: 5 }, (_, i) =>
|
||||||
createHistoryTask(10 - i, `hist-${i}`)
|
createHistoryJob(10 - i, `hist-${i}`)
|
||||||
)
|
)
|
||||||
|
|
||||||
mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
|
mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
|
||||||
mockGetHistory.mockResolvedValue({ History: tasks })
|
mockGetHistory.mockResolvedValue(jobs)
|
||||||
|
|
||||||
await store.update()
|
await store.update()
|
||||||
|
|
||||||
@@ -522,21 +561,19 @@ describe('useQueueStore', () => {
|
|||||||
store.maxHistoryItems = 5
|
store.maxHistoryItems = 5
|
||||||
|
|
||||||
const initial = Array.from({ length: 3 }, (_, i) =>
|
const initial = Array.from({ length: 3 }, (_, i) =>
|
||||||
createHistoryTask(10 + i, `existing-${i}`)
|
createHistoryJob(10 + i, `existing-${i}`)
|
||||||
)
|
)
|
||||||
|
|
||||||
mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
|
mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
|
||||||
mockGetHistory.mockResolvedValue({ History: initial })
|
mockGetHistory.mockResolvedValue(initial)
|
||||||
|
|
||||||
await store.update()
|
await store.update()
|
||||||
expect(store.historyTasks).toHaveLength(3)
|
expect(store.historyTasks).toHaveLength(3)
|
||||||
|
|
||||||
const newTasks = Array.from({ length: 4 }, (_, i) =>
|
const newJobs = Array.from({ length: 4 }, (_, i) =>
|
||||||
createHistoryTask(20 + i, `new-${i}`)
|
createHistoryJob(20 + i, `new-${i}`)
|
||||||
)
|
)
|
||||||
mockGetHistory.mockResolvedValue({
|
mockGetHistory.mockResolvedValue([...newJobs, ...initial])
|
||||||
History: [...newTasks, ...initial]
|
|
||||||
})
|
|
||||||
|
|
||||||
await store.update()
|
await store.update()
|
||||||
|
|
||||||
@@ -547,10 +584,10 @@ describe('useQueueStore', () => {
|
|||||||
it('should handle maxHistoryItems = 0', async () => {
|
it('should handle maxHistoryItems = 0', async () => {
|
||||||
store.maxHistoryItems = 0
|
store.maxHistoryItems = 0
|
||||||
|
|
||||||
const tasks = [createHistoryTask(10, 'hist-1')]
|
const jobs = [createHistoryJob(10, 'hist-1')]
|
||||||
|
|
||||||
mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
|
mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
|
||||||
mockGetHistory.mockResolvedValue({ History: tasks })
|
mockGetHistory.mockResolvedValue(jobs)
|
||||||
|
|
||||||
await store.update()
|
await store.update()
|
||||||
|
|
||||||
@@ -560,13 +597,13 @@ describe('useQueueStore', () => {
|
|||||||
it('should handle maxHistoryItems = 1', async () => {
|
it('should handle maxHistoryItems = 1', async () => {
|
||||||
store.maxHistoryItems = 1
|
store.maxHistoryItems = 1
|
||||||
|
|
||||||
const tasks = [
|
const jobs = [
|
||||||
createHistoryTask(10, 'hist-1'),
|
createHistoryJob(10, 'hist-1'),
|
||||||
createHistoryTask(9, 'hist-2')
|
createHistoryJob(9, 'hist-2')
|
||||||
]
|
]
|
||||||
|
|
||||||
mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
|
mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
|
||||||
mockGetHistory.mockResolvedValue({ History: tasks })
|
mockGetHistory.mockResolvedValue(jobs)
|
||||||
|
|
||||||
await store.update()
|
await store.update()
|
||||||
|
|
||||||
@@ -577,18 +614,18 @@ describe('useQueueStore', () => {
|
|||||||
it('should dynamically adjust when maxHistoryItems changes', async () => {
|
it('should dynamically adjust when maxHistoryItems changes', async () => {
|
||||||
store.maxHistoryItems = 10
|
store.maxHistoryItems = 10
|
||||||
|
|
||||||
const tasks = Array.from({ length: 15 }, (_, i) =>
|
const jobs = Array.from({ length: 15 }, (_, i) =>
|
||||||
createHistoryTask(20 - i, `hist-${i}`)
|
createHistoryJob(20 - i, `hist-${i}`)
|
||||||
)
|
)
|
||||||
|
|
||||||
mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
|
mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
|
||||||
mockGetHistory.mockResolvedValue({ History: tasks })
|
mockGetHistory.mockResolvedValue(jobs)
|
||||||
|
|
||||||
await store.update()
|
await store.update()
|
||||||
expect(store.historyTasks).toHaveLength(10)
|
expect(store.historyTasks).toHaveLength(10)
|
||||||
|
|
||||||
store.maxHistoryItems = 5
|
store.maxHistoryItems = 5
|
||||||
mockGetHistory.mockResolvedValue({ History: tasks })
|
mockGetHistory.mockResolvedValue(jobs)
|
||||||
|
|
||||||
await store.update()
|
await store.update()
|
||||||
expect(store.historyTasks).toHaveLength(5)
|
expect(store.historyTasks).toHaveLength(5)
|
||||||
@@ -597,19 +634,17 @@ describe('useQueueStore', () => {
|
|||||||
|
|
||||||
describe('computed properties', () => {
|
describe('computed properties', () => {
|
||||||
it('tasks should combine pending, running, and history in correct order', async () => {
|
it('tasks should combine pending, running, and history in correct order', async () => {
|
||||||
const running = createRunningTask(5, 'run-1')
|
const running = createRunningJob(5, 'run-1')
|
||||||
const pending1 = createPendingTask(6, 'pend-1')
|
const pending1 = createPendingJob(6, 'pend-1')
|
||||||
const pending2 = createPendingTask(7, 'pend-2')
|
const pending2 = createPendingJob(7, 'pend-2')
|
||||||
const hist1 = createHistoryTask(3, 'hist-1')
|
const hist1 = createHistoryJob(3, 'hist-1')
|
||||||
const hist2 = createHistoryTask(4, 'hist-2')
|
const hist2 = createHistoryJob(4, 'hist-2')
|
||||||
|
|
||||||
mockGetQueue.mockResolvedValue({
|
mockGetQueue.mockResolvedValue({
|
||||||
Running: [running],
|
Running: [running],
|
||||||
Pending: [pending1, pending2]
|
Pending: [pending1, pending2]
|
||||||
})
|
})
|
||||||
mockGetHistory.mockResolvedValue({
|
mockGetHistory.mockResolvedValue([hist2, hist1])
|
||||||
History: [hist2, hist1]
|
|
||||||
})
|
|
||||||
|
|
||||||
await store.update()
|
await store.update()
|
||||||
|
|
||||||
@@ -624,9 +659,9 @@ describe('useQueueStore', () => {
|
|||||||
it('hasPendingTasks should be true when pending tasks exist', async () => {
|
it('hasPendingTasks should be true when pending tasks exist', async () => {
|
||||||
mockGetQueue.mockResolvedValue({
|
mockGetQueue.mockResolvedValue({
|
||||||
Running: [],
|
Running: [],
|
||||||
Pending: [createPendingTask(1, 'pend-1')]
|
Pending: [createPendingJob(1, 'pend-1')]
|
||||||
})
|
})
|
||||||
mockGetHistory.mockResolvedValue({ History: [] })
|
mockGetHistory.mockResolvedValue([])
|
||||||
|
|
||||||
await store.update()
|
await store.update()
|
||||||
expect(store.hasPendingTasks).toBe(true)
|
expect(store.hasPendingTasks).toBe(true)
|
||||||
@@ -634,21 +669,19 @@ describe('useQueueStore', () => {
|
|||||||
|
|
||||||
it('hasPendingTasks should be false when no pending tasks', async () => {
|
it('hasPendingTasks should be false when no pending tasks', async () => {
|
||||||
mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
|
mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
|
||||||
mockGetHistory.mockResolvedValue({ History: [] })
|
mockGetHistory.mockResolvedValue([])
|
||||||
|
|
||||||
await store.update()
|
await store.update()
|
||||||
expect(store.hasPendingTasks).toBe(false)
|
expect(store.hasPendingTasks).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('lastHistoryQueueIndex should return highest queue index', async () => {
|
it('lastHistoryQueueIndex should return highest queue index', async () => {
|
||||||
const hist1 = createHistoryTask(10, 'hist-1')
|
const hist1 = createHistoryJob(10, 'hist-1')
|
||||||
const hist2 = createHistoryTask(25, 'hist-2')
|
const hist2 = createHistoryJob(25, 'hist-2')
|
||||||
const hist3 = createHistoryTask(15, 'hist-3')
|
const hist3 = createHistoryJob(15, 'hist-3')
|
||||||
|
|
||||||
mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
|
mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
|
||||||
mockGetHistory.mockResolvedValue({
|
mockGetHistory.mockResolvedValue([hist1, hist2, hist3])
|
||||||
History: [hist1, hist2, hist3]
|
|
||||||
})
|
|
||||||
|
|
||||||
await store.update()
|
await store.update()
|
||||||
expect(store.lastHistoryQueueIndex).toBe(25)
|
expect(store.lastHistoryQueueIndex).toBe(25)
|
||||||
@@ -656,7 +689,7 @@ describe('useQueueStore', () => {
|
|||||||
|
|
||||||
it('lastHistoryQueueIndex should be -1 when no history', async () => {
|
it('lastHistoryQueueIndex should be -1 when no history', async () => {
|
||||||
mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
|
mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
|
||||||
mockGetHistory.mockResolvedValue({ History: [] })
|
mockGetHistory.mockResolvedValue([])
|
||||||
|
|
||||||
await store.update()
|
await store.update()
|
||||||
expect(store.lastHistoryQueueIndex).toBe(-1)
|
expect(store.lastHistoryQueueIndex).toBe(-1)
|
||||||
@@ -666,19 +699,17 @@ describe('useQueueStore', () => {
|
|||||||
describe('clear()', () => {
|
describe('clear()', () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
mockGetQueue.mockResolvedValue({
|
mockGetQueue.mockResolvedValue({
|
||||||
Running: [createRunningTask(1, 'run-1')],
|
Running: [createRunningJob(1, 'run-1')],
|
||||||
Pending: [createPendingTask(2, 'pend-1')]
|
Pending: [createPendingJob(2, 'pend-1')]
|
||||||
})
|
|
||||||
mockGetHistory.mockResolvedValue({
|
|
||||||
History: [createHistoryTask(3, 'hist-1')]
|
|
||||||
})
|
})
|
||||||
|
mockGetHistory.mockResolvedValue([createHistoryJob(3, 'hist-1')])
|
||||||
await store.update()
|
await store.update()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should clear both queue and history by default', async () => {
|
it('should clear both queue and history by default', async () => {
|
||||||
mockClearItems.mockResolvedValue(undefined)
|
mockClearItems.mockResolvedValue(undefined)
|
||||||
mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
|
mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
|
||||||
mockGetHistory.mockResolvedValue({ History: [] })
|
mockGetHistory.mockResolvedValue([])
|
||||||
|
|
||||||
await store.clear()
|
await store.clear()
|
||||||
|
|
||||||
@@ -693,9 +724,7 @@ describe('useQueueStore', () => {
|
|||||||
it('should clear only queue when specified', async () => {
|
it('should clear only queue when specified', async () => {
|
||||||
mockClearItems.mockResolvedValue(undefined)
|
mockClearItems.mockResolvedValue(undefined)
|
||||||
mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
|
mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
|
||||||
mockGetHistory.mockResolvedValue({
|
mockGetHistory.mockResolvedValue([createHistoryJob(3, 'hist-1')])
|
||||||
History: [createHistoryTask(3, 'hist-1')]
|
|
||||||
})
|
|
||||||
|
|
||||||
await store.clear(['queue'])
|
await store.clear(['queue'])
|
||||||
|
|
||||||
@@ -707,10 +736,10 @@ describe('useQueueStore', () => {
|
|||||||
it('should clear only history when specified', async () => {
|
it('should clear only history when specified', async () => {
|
||||||
mockClearItems.mockResolvedValue(undefined)
|
mockClearItems.mockResolvedValue(undefined)
|
||||||
mockGetQueue.mockResolvedValue({
|
mockGetQueue.mockResolvedValue({
|
||||||
Running: [createRunningTask(1, 'run-1')],
|
Running: [createRunningJob(1, 'run-1')],
|
||||||
Pending: [createPendingTask(2, 'pend-1')]
|
Pending: [createPendingJob(2, 'pend-1')]
|
||||||
})
|
})
|
||||||
mockGetHistory.mockResolvedValue({ History: [] })
|
mockGetHistory.mockResolvedValue([])
|
||||||
|
|
||||||
await store.clear(['history'])
|
await store.clear(['history'])
|
||||||
|
|
||||||
@@ -729,11 +758,12 @@ describe('useQueueStore', () => {
|
|||||||
|
|
||||||
describe('delete()', () => {
|
describe('delete()', () => {
|
||||||
it('should delete task from queue', async () => {
|
it('should delete task from queue', async () => {
|
||||||
const task = new TaskItemImpl('Pending', createTaskPrompt(1, 'pend-1'))
|
const job = createPendingJob(1, 'pend-1')
|
||||||
|
const task = new TaskItemImpl(job)
|
||||||
|
|
||||||
mockDeleteItem.mockResolvedValue(undefined)
|
mockDeleteItem.mockResolvedValue(undefined)
|
||||||
mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
|
mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
|
||||||
mockGetHistory.mockResolvedValue({ History: [] })
|
mockGetHistory.mockResolvedValue([])
|
||||||
|
|
||||||
await store.delete(task)
|
await store.delete(task)
|
||||||
|
|
||||||
@@ -741,16 +771,12 @@ describe('useQueueStore', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should delete task from history', async () => {
|
it('should delete task from history', async () => {
|
||||||
const task = new TaskItemImpl(
|
const job = createHistoryJob(1, 'hist-1')
|
||||||
'History',
|
const task = new TaskItemImpl(job, createTaskOutput())
|
||||||
createTaskPrompt(1, 'hist-1'),
|
|
||||||
createTaskStatus(),
|
|
||||||
createTaskOutput()
|
|
||||||
)
|
|
||||||
|
|
||||||
mockDeleteItem.mockResolvedValue(undefined)
|
mockDeleteItem.mockResolvedValue(undefined)
|
||||||
mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
|
mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
|
||||||
mockGetHistory.mockResolvedValue({ History: [] })
|
mockGetHistory.mockResolvedValue([])
|
||||||
|
|
||||||
await store.delete(task)
|
await store.delete(task)
|
||||||
|
|
||||||
@@ -758,11 +784,12 @@ describe('useQueueStore', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should refresh store after deletion', async () => {
|
it('should refresh store after deletion', async () => {
|
||||||
const task = new TaskItemImpl('Pending', createTaskPrompt(1, 'pend-1'))
|
const job = createPendingJob(1, 'pend-1')
|
||||||
|
const task = new TaskItemImpl(job)
|
||||||
|
|
||||||
mockDeleteItem.mockResolvedValue(undefined)
|
mockDeleteItem.mockResolvedValue(undefined)
|
||||||
mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
|
mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
|
||||||
mockGetHistory.mockResolvedValue({ History: [] })
|
mockGetHistory.mockResolvedValue([])
|
||||||
|
|
||||||
await store.delete(task)
|
await store.delete(task)
|
||||||
|
|
||||||
|
|||||||
@@ -2,34 +2,27 @@ import _ from 'es-toolkit/compat'
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { computed, ref, shallowRef, toRaw, toValue } from 'vue'
|
import { computed, ref, shallowRef, toRaw, toValue } from 'vue'
|
||||||
|
|
||||||
import { isCloud } from '@/platform/distribution/types'
|
import { extractWorkflow } from '@/platform/remote/comfyui/jobs/fetchJobs'
|
||||||
import { reconcileHistory } from '@/platform/remote/comfyui/history/reconciliation'
|
|
||||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
|
||||||
import { getWorkflowFromHistory } from '@/platform/workflow/cloud'
|
|
||||||
import type {
|
import type {
|
||||||
ComfyWorkflowJSON,
|
APITaskType,
|
||||||
NodeId
|
JobListItem,
|
||||||
} from '@/platform/workflow/validation/schemas/workflowSchema'
|
TaskType
|
||||||
|
} from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||||
|
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||||
import type {
|
import type {
|
||||||
HistoryTaskItem,
|
|
||||||
ResultItem,
|
ResultItem,
|
||||||
StatusWsMessageStatus,
|
StatusWsMessageStatus,
|
||||||
TaskItem,
|
TaskOutput
|
||||||
TaskOutput,
|
|
||||||
TaskPrompt,
|
|
||||||
TaskStatus,
|
|
||||||
TaskType
|
|
||||||
} from '@/schemas/apiSchema'
|
} from '@/schemas/apiSchema'
|
||||||
import { api } from '@/scripts/api'
|
import { api } from '@/scripts/api'
|
||||||
import type { ComfyApp } from '@/scripts/app'
|
import type { ComfyApp } from '@/scripts/app'
|
||||||
import { useExtensionService } from '@/services/extensionService'
|
import { useExtensionService } from '@/services/extensionService'
|
||||||
|
import { getJobDetail } from '@/services/jobOutputCache'
|
||||||
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
||||||
import { useExecutionStore } from '@/stores/executionStore'
|
import { useExecutionStore } from '@/stores/executionStore'
|
||||||
|
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||||
import { getMediaTypeFromFilename } from '@/utils/formatUtil'
|
import { getMediaTypeFromFilename } from '@/utils/formatUtil'
|
||||||
|
|
||||||
// Task type used in the API.
|
|
||||||
type APITaskType = 'queue' | 'history'
|
|
||||||
|
|
||||||
enum TaskItemDisplayStatus {
|
enum TaskItemDisplayStatus {
|
||||||
Running = 'Running',
|
Running = 'Running',
|
||||||
Pending = 'Pending',
|
Pending = 'Pending',
|
||||||
@@ -212,32 +205,44 @@ export class ResultItemImpl {
|
|||||||
get supportsPreview(): boolean {
|
get supportsPreview(): boolean {
|
||||||
return this.isImage || this.isVideo || this.isAudio || this.is3D
|
return this.isImage || this.isVideo || this.isAudio || this.is3D
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static filterPreviewable(
|
||||||
|
outputs: readonly ResultItemImpl[]
|
||||||
|
): ResultItemImpl[] {
|
||||||
|
return outputs.filter((o) => o.supportsPreview)
|
||||||
|
}
|
||||||
|
|
||||||
|
static findByUrl(items: readonly ResultItemImpl[], url?: string): number {
|
||||||
|
if (!url) return 0
|
||||||
|
const idx = items.findIndex((o) => o.url === url)
|
||||||
|
return idx >= 0 ? idx : 0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class TaskItemImpl {
|
export class TaskItemImpl {
|
||||||
readonly taskType: TaskType
|
readonly job: JobListItem
|
||||||
readonly prompt: TaskPrompt
|
|
||||||
readonly status?: TaskStatus
|
|
||||||
readonly outputs: TaskOutput
|
readonly outputs: TaskOutput
|
||||||
readonly flatOutputs: ReadonlyArray<ResultItemImpl>
|
readonly flatOutputs: ReadonlyArray<ResultItemImpl>
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
taskType: TaskType,
|
job: JobListItem,
|
||||||
prompt: TaskPrompt,
|
|
||||||
status?: TaskStatus,
|
|
||||||
outputs?: TaskOutput,
|
outputs?: TaskOutput,
|
||||||
flatOutputs?: ReadonlyArray<ResultItemImpl>
|
flatOutputs?: ReadonlyArray<ResultItemImpl>
|
||||||
) {
|
) {
|
||||||
this.taskType = taskType
|
this.job = job
|
||||||
this.prompt = prompt
|
// If no outputs provided but job has preview_output, create synthetic outputs
|
||||||
this.status = status
|
// using the real nodeId and mediaType from the backend response
|
||||||
|
const effectiveOutputs =
|
||||||
|
outputs ??
|
||||||
|
(job.preview_output
|
||||||
|
? {
|
||||||
|
[job.preview_output.nodeId]: {
|
||||||
|
[job.preview_output.mediaType]: [job.preview_output]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: {})
|
||||||
// Remove animated outputs from the outputs object
|
// Remove animated outputs from the outputs object
|
||||||
// outputs.animated is an array of boolean values that indicates if the images
|
this.outputs = _.mapValues(effectiveOutputs, (nodeOutputs) =>
|
||||||
// array in the result are animated or not.
|
|
||||||
// The queueStore does not use this information.
|
|
||||||
// It is part of the legacy API response. We should redesign the backend API.
|
|
||||||
// https://github.com/Comfy-Org/ComfyUI_frontend/issues/2739
|
|
||||||
this.outputs = _.mapValues(outputs ?? {}, (nodeOutputs) =>
|
|
||||||
_.omit(nodeOutputs, 'animated')
|
_.omit(nodeOutputs, 'animated')
|
||||||
)
|
)
|
||||||
this.flatOutputs = flatOutputs ?? this.calculateFlatOutputs()
|
this.flatOutputs = flatOutputs ?? this.calculateFlatOutputs()
|
||||||
@@ -261,15 +266,31 @@ export class TaskItemImpl {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** All outputs that support preview (images, videos, audio, 3D) */
|
||||||
|
get previewableOutputs(): readonly ResultItemImpl[] {
|
||||||
|
return ResultItemImpl.filterPreviewable(this.flatOutputs)
|
||||||
|
}
|
||||||
|
|
||||||
get previewOutput(): ResultItemImpl | undefined {
|
get previewOutput(): ResultItemImpl | undefined {
|
||||||
|
const previewable = this.previewableOutputs
|
||||||
|
// Prefer saved media files over the temp previews
|
||||||
return (
|
return (
|
||||||
this.flatOutputs.find(
|
previewable.find((output) => output.type === 'output') ?? previewable[0]
|
||||||
// Prefer saved media files over the temp previews
|
|
||||||
(output) => output.type === 'output' && output.supportsPreview
|
|
||||||
) ?? this.flatOutputs.find((output) => output.supportsPreview)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Derive taskType from job status
|
||||||
|
get taskType(): TaskType {
|
||||||
|
switch (this.job.status) {
|
||||||
|
case 'in_progress':
|
||||||
|
return 'Running'
|
||||||
|
case 'pending':
|
||||||
|
return 'Pending'
|
||||||
|
default:
|
||||||
|
return 'History'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
get apiTaskType(): APITaskType {
|
get apiTaskType(): APITaskType {
|
||||||
switch (this.taskType) {
|
switch (this.taskType) {
|
||||||
case 'Running':
|
case 'Running':
|
||||||
@@ -285,61 +306,42 @@ export class TaskItemImpl {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get queueIndex() {
|
get queueIndex() {
|
||||||
return this.prompt[0]
|
return this.job.priority
|
||||||
}
|
}
|
||||||
|
|
||||||
get promptId() {
|
get promptId() {
|
||||||
return this.prompt[1]
|
return this.job.id
|
||||||
}
|
}
|
||||||
|
|
||||||
get promptInputs() {
|
get outputsCount(): number | undefined {
|
||||||
return this.prompt[2]
|
return this.job.outputs_count ?? undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
get extraData() {
|
get status() {
|
||||||
return this.prompt[3]
|
return this.job.status
|
||||||
}
|
}
|
||||||
|
|
||||||
get outputsToExecute() {
|
get errorMessage(): string | undefined {
|
||||||
return this.prompt[4]
|
return this.job.execution_error?.exception_message ?? undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
get extraPngInfo() {
|
get executionError() {
|
||||||
return this.extraData.extra_pnginfo
|
return this.job.execution_error ?? undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
get clientId() {
|
get workflowId(): string | undefined {
|
||||||
return this.extraData.client_id
|
return this.job.workflow_id ?? undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
get workflow(): ComfyWorkflowJSON | undefined {
|
get createTime(): number {
|
||||||
return this.extraPngInfo?.workflow
|
return this.job.create_time
|
||||||
}
|
}
|
||||||
|
|
||||||
get messages() {
|
get interrupted(): boolean {
|
||||||
return this.status?.messages || []
|
return (
|
||||||
}
|
this.job.status === 'failed' &&
|
||||||
|
this.job.execution_error?.exception_type ===
|
||||||
/**
|
'InterruptProcessingException'
|
||||||
* Server-provided creation time in milliseconds, when available.
|
|
||||||
*
|
|
||||||
* Sources:
|
|
||||||
* - Queue: 5th tuple element may be a metadata object with { create_time }.
|
|
||||||
* - History (Cloud V2): Adapter injects create_time into prompt[3].extra_data.
|
|
||||||
*/
|
|
||||||
get createTime(): number | undefined {
|
|
||||||
const extra = (this.extraData as any) || {}
|
|
||||||
const fromExtra =
|
|
||||||
typeof extra.create_time === 'number' ? extra.create_time : undefined
|
|
||||||
if (typeof fromExtra === 'number') return fromExtra
|
|
||||||
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
get interrupted() {
|
|
||||||
return _.some(
|
|
||||||
this.messages,
|
|
||||||
(message) => message[0] === 'execution_interrupted'
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -352,42 +354,26 @@ export class TaskItemImpl {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get displayStatus(): TaskItemDisplayStatus {
|
get displayStatus(): TaskItemDisplayStatus {
|
||||||
switch (this.taskType) {
|
switch (this.job.status) {
|
||||||
case 'Running':
|
case 'in_progress':
|
||||||
return TaskItemDisplayStatus.Running
|
return TaskItemDisplayStatus.Running
|
||||||
case 'Pending':
|
case 'pending':
|
||||||
return TaskItemDisplayStatus.Pending
|
return TaskItemDisplayStatus.Pending
|
||||||
case 'History':
|
case 'completed':
|
||||||
if (this.interrupted) return TaskItemDisplayStatus.Cancelled
|
return TaskItemDisplayStatus.Completed
|
||||||
|
case 'failed':
|
||||||
switch (this.status!.status_str) {
|
return TaskItemDisplayStatus.Failed
|
||||||
case 'success':
|
case 'cancelled':
|
||||||
return TaskItemDisplayStatus.Completed
|
return TaskItemDisplayStatus.Cancelled
|
||||||
case 'error':
|
|
||||||
return TaskItemDisplayStatus.Failed
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
get executionStartTimestamp() {
|
get executionStartTimestamp() {
|
||||||
const message = this.messages.find(
|
return this.job.execution_start_time ?? undefined
|
||||||
(message) => message[0] === 'execution_start'
|
|
||||||
)
|
|
||||||
return message ? message[1].timestamp : undefined
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get executionEndTimestamp() {
|
get executionEndTimestamp() {
|
||||||
const messages = this.messages.filter((message) =>
|
return this.job.execution_end_time ?? undefined
|
||||||
[
|
|
||||||
'execution_success',
|
|
||||||
'execution_interrupted',
|
|
||||||
'execution_error'
|
|
||||||
].includes(message[0])
|
|
||||||
)
|
|
||||||
if (!messages.length) {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
return _.max(messages.map((message) => message[1].timestamp))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get executionTime() {
|
get executionTime() {
|
||||||
@@ -403,28 +389,48 @@ export class TaskItemImpl {
|
|||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
public async loadWorkflow(app: ComfyApp) {
|
/**
|
||||||
let workflowData = this.workflow
|
* Loads full outputs for tasks that only have preview data
|
||||||
|
* Returns a new TaskItemImpl with full outputs and execution status
|
||||||
|
*/
|
||||||
|
public async loadFullOutputs(): Promise<TaskItemImpl> {
|
||||||
|
// Only load for history tasks (caller checks outputsCount > 1)
|
||||||
|
if (!this.isHistory) {
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
const jobDetail = await getJobDetail(this.promptId)
|
||||||
|
|
||||||
if (isCloud && !workflowData && this.isHistory) {
|
if (!jobDetail?.outputs) {
|
||||||
workflowData = await getWorkflowFromHistory(
|
return this
|
||||||
(url) => app.api.fetchApi(url),
|
|
||||||
this.promptId
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create new TaskItemImpl with full outputs
|
||||||
|
return new TaskItemImpl(this.job, jobDetail.outputs)
|
||||||
|
}
|
||||||
|
|
||||||
|
public async loadWorkflow(app: ComfyApp) {
|
||||||
|
if (!this.isHistory) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single fetch for both workflow and outputs (with caching)
|
||||||
|
const jobDetail = await getJobDetail(this.promptId)
|
||||||
|
|
||||||
|
const workflowData = await extractWorkflow(jobDetail)
|
||||||
if (!workflowData) {
|
if (!workflowData) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await app.loadGraphData(toRaw(workflowData))
|
await app.loadGraphData(toRaw(workflowData))
|
||||||
|
|
||||||
if (!this.outputs) {
|
// Use full outputs from job detail, or fall back to existing outputs
|
||||||
|
const outputsToLoad = jobDetail?.outputs ?? this.outputs
|
||||||
|
if (!outputsToLoad) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const nodeOutputsStore = useNodeOutputStore()
|
const nodeOutputsStore = useNodeOutputStore()
|
||||||
const rawOutputs = toRaw(this.outputs)
|
const rawOutputs = toRaw(outputsToLoad)
|
||||||
for (const nodeExecutionId in rawOutputs) {
|
for (const nodeExecutionId in rawOutputs) {
|
||||||
nodeOutputsStore.setNodeOutputsByExecutionId(
|
nodeOutputsStore.setNodeOutputsByExecutionId(
|
||||||
nodeExecutionId,
|
nodeExecutionId,
|
||||||
@@ -445,15 +451,10 @@ export class TaskItemImpl {
|
|||||||
return this.flatOutputs.map(
|
return this.flatOutputs.map(
|
||||||
(output: ResultItemImpl, i: number) =>
|
(output: ResultItemImpl, i: number) =>
|
||||||
new TaskItemImpl(
|
new TaskItemImpl(
|
||||||
this.taskType,
|
{
|
||||||
[
|
...this.job,
|
||||||
this.queueIndex,
|
id: `${this.promptId}-${i}`
|
||||||
`${this.promptId}-${i}`,
|
},
|
||||||
this.promptInputs,
|
|
||||||
this.extraData,
|
|
||||||
this.outputsToExecute
|
|
||||||
],
|
|
||||||
this.status,
|
|
||||||
{
|
{
|
||||||
[output.nodeId]: {
|
[output.nodeId]: {
|
||||||
[output.mediaType]: [output]
|
[output.mediaType]: [output]
|
||||||
@@ -463,32 +464,8 @@ export class TaskItemImpl {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
public toTaskItem(): TaskItem {
|
|
||||||
const item: HistoryTaskItem = {
|
|
||||||
taskType: 'History',
|
|
||||||
prompt: this.prompt,
|
|
||||||
status: this.status!,
|
|
||||||
outputs: this.outputs
|
|
||||||
}
|
|
||||||
return item
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const sortNewestFirst = (a: TaskItemImpl, b: TaskItemImpl) =>
|
|
||||||
b.queueIndex - a.queueIndex
|
|
||||||
|
|
||||||
const toTaskItemImpls = (tasks: TaskItem[]): TaskItemImpl[] =>
|
|
||||||
tasks.map(
|
|
||||||
(task) =>
|
|
||||||
new TaskItemImpl(
|
|
||||||
task.taskType,
|
|
||||||
task.prompt,
|
|
||||||
'status' in task ? task.status : undefined,
|
|
||||||
'outputs' in task ? task.outputs : undefined
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
export const useQueueStore = defineStore('queue', () => {
|
export const useQueueStore = defineStore('queue', () => {
|
||||||
// Use shallowRef because TaskItemImpl instances are immutable and arrays are
|
// Use shallowRef because TaskItemImpl instances are immutable and arrays are
|
||||||
// replaced entirely (not mutated), so deep reactivity would waste performance
|
// replaced entirely (not mutated), so deep reactivity would waste performance
|
||||||
@@ -525,8 +502,9 @@ export const useQueueStore = defineStore('queue', () => {
|
|||||||
api.getHistory(maxHistoryItems.value)
|
api.getHistory(maxHistoryItems.value)
|
||||||
])
|
])
|
||||||
|
|
||||||
runningTasks.value = toTaskItemImpls(queue.Running).sort(sortNewestFirst)
|
// API returns pre-sorted data (sort_by=create_time&order=desc)
|
||||||
pendingTasks.value = toTaskItemImpls(queue.Pending).sort(sortNewestFirst)
|
runningTasks.value = queue.Running.map((job) => new TaskItemImpl(job))
|
||||||
|
pendingTasks.value = queue.Pending.map((job) => new TaskItemImpl(job))
|
||||||
|
|
||||||
const currentHistory = toValue(historyTasks)
|
const currentHistory = toValue(historyTasks)
|
||||||
|
|
||||||
@@ -534,7 +512,7 @@ export const useQueueStore = defineStore('queue', () => {
|
|||||||
const executionStore = useExecutionStore()
|
const executionStore = useExecutionStore()
|
||||||
appearedTasks.forEach((task) => {
|
appearedTasks.forEach((task) => {
|
||||||
const promptIdString = String(task.promptId)
|
const promptIdString = String(task.promptId)
|
||||||
const workflowId = task.workflow?.id
|
const workflowId = task.workflowId
|
||||||
if (workflowId && promptIdString) {
|
if (workflowId && promptIdString) {
|
||||||
executionStore.registerPromptWorkflowIdMapping(
|
executionStore.registerPromptWorkflowIdMapping(
|
||||||
promptIdString,
|
promptIdString,
|
||||||
@@ -543,22 +521,26 @@ export const useQueueStore = defineStore('queue', () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const items = reconcileHistory(
|
// Sort by create_time descending and limit to maxItems
|
||||||
history.History,
|
const sortedHistory = [...history]
|
||||||
currentHistory.map((impl) => impl.toTaskItem()),
|
.sort((a, b) => b.create_time - a.create_time)
|
||||||
toValue(maxHistoryItems),
|
.slice(0, toValue(maxHistoryItems))
|
||||||
toValue(lastHistoryQueueIndex)
|
|
||||||
)
|
|
||||||
|
|
||||||
// Reuse existing TaskItemImpl instances or create new
|
// Reuse existing TaskItemImpl instances or create new
|
||||||
|
// Must recreate if outputs_count changed (e.g., API started returning it)
|
||||||
const existingByPromptId = new Map(
|
const existingByPromptId = new Map(
|
||||||
currentHistory.map((impl) => [impl.promptId, impl])
|
currentHistory.map((impl) => [impl.promptId, impl])
|
||||||
)
|
)
|
||||||
|
|
||||||
historyTasks.value = items.map(
|
historyTasks.value = sortedHistory.map((job) => {
|
||||||
(item) =>
|
const existing = existingByPromptId.get(job.id)
|
||||||
existingByPromptId.get(item.prompt[1]) ?? toTaskItemImpls([item])[0]
|
if (!existing) return new TaskItemImpl(job)
|
||||||
)
|
// Recreate if outputs_count changed to ensure lazy loading works
|
||||||
|
if (existing.outputsCount !== (job.outputs_count ?? undefined)) {
|
||||||
|
return new TaskItemImpl(job)
|
||||||
|
}
|
||||||
|
return existing
|
||||||
|
})
|
||||||
} finally {
|
} finally {
|
||||||
isLoading.value = false
|
isLoading.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
373
src/stores/workspaceAuthStore.ts
Normal file
@@ -0,0 +1,373 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { computed, ref, shallowRef } from 'vue'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { fromZodError } from 'zod-validation-error'
|
||||||
|
|
||||||
|
import { t } from '@/i18n'
|
||||||
|
import {
|
||||||
|
TOKEN_REFRESH_BUFFER_MS,
|
||||||
|
WORKSPACE_STORAGE_KEYS
|
||||||
|
} from '@/platform/auth/workspace/workspaceConstants'
|
||||||
|
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
|
||||||
|
import { api } from '@/scripts/api'
|
||||||
|
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||||
|
import type { AuthHeader } from '@/types/authTypes'
|
||||||
|
import type { WorkspaceWithRole } from '@/platform/auth/workspace/workspaceTypes'
|
||||||
|
|
||||||
|
const WorkspaceWithRoleSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
type: z.enum(['personal', 'team']),
|
||||||
|
role: z.enum(['owner', 'member'])
|
||||||
|
})
|
||||||
|
|
||||||
|
const WorkspaceTokenResponseSchema = z.object({
|
||||||
|
token: z.string(),
|
||||||
|
expires_at: z.string(),
|
||||||
|
workspace: z.object({
|
||||||
|
id: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
type: z.enum(['personal', 'team'])
|
||||||
|
}),
|
||||||
|
role: z.enum(['owner', 'member']),
|
||||||
|
permissions: z.array(z.string())
|
||||||
|
})
|
||||||
|
|
||||||
|
export class WorkspaceAuthError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
public readonly code?: string
|
||||||
|
) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'WorkspaceAuthError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useWorkspaceAuthStore = defineStore('workspaceAuth', () => {
|
||||||
|
// State
|
||||||
|
const currentWorkspace = shallowRef<WorkspaceWithRole | null>(null)
|
||||||
|
const workspaceToken = ref<string | null>(null)
|
||||||
|
const isLoading = ref(false)
|
||||||
|
const error = ref<Error | null>(null)
|
||||||
|
|
||||||
|
// Timer state
|
||||||
|
let refreshTimerId: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
// Request ID to prevent stale refresh operations from overwriting newer workspace contexts
|
||||||
|
let refreshRequestId = 0
|
||||||
|
|
||||||
|
// Getters
|
||||||
|
const isAuthenticated = computed(
|
||||||
|
() => currentWorkspace.value !== null && workspaceToken.value !== null
|
||||||
|
)
|
||||||
|
|
||||||
|
// Private helpers
|
||||||
|
function stopRefreshTimer(): void {
|
||||||
|
if (refreshTimerId !== null) {
|
||||||
|
clearTimeout(refreshTimerId)
|
||||||
|
refreshTimerId = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleTokenRefresh(expiresAt: number): void {
|
||||||
|
stopRefreshTimer()
|
||||||
|
const now = Date.now()
|
||||||
|
const refreshAt = expiresAt - TOKEN_REFRESH_BUFFER_MS
|
||||||
|
const delay = Math.max(0, refreshAt - now)
|
||||||
|
|
||||||
|
refreshTimerId = setTimeout(() => {
|
||||||
|
void refreshToken()
|
||||||
|
}, delay)
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistToSession(
|
||||||
|
workspace: WorkspaceWithRole,
|
||||||
|
token: string,
|
||||||
|
expiresAt: number
|
||||||
|
): void {
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(
|
||||||
|
WORKSPACE_STORAGE_KEYS.CURRENT_WORKSPACE,
|
||||||
|
JSON.stringify(workspace)
|
||||||
|
)
|
||||||
|
sessionStorage.setItem(WORKSPACE_STORAGE_KEYS.TOKEN, token)
|
||||||
|
sessionStorage.setItem(
|
||||||
|
WORKSPACE_STORAGE_KEYS.EXPIRES_AT,
|
||||||
|
expiresAt.toString()
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
console.warn('Failed to persist workspace context to sessionStorage')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSessionStorage(): void {
|
||||||
|
try {
|
||||||
|
sessionStorage.removeItem(WORKSPACE_STORAGE_KEYS.CURRENT_WORKSPACE)
|
||||||
|
sessionStorage.removeItem(WORKSPACE_STORAGE_KEYS.TOKEN)
|
||||||
|
sessionStorage.removeItem(WORKSPACE_STORAGE_KEYS.EXPIRES_AT)
|
||||||
|
} catch {
|
||||||
|
console.warn('Failed to clear workspace context from sessionStorage')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
function init(): void {
|
||||||
|
initializeFromSession()
|
||||||
|
}
|
||||||
|
|
||||||
|
function destroy(): void {
|
||||||
|
stopRefreshTimer()
|
||||||
|
}
|
||||||
|
|
||||||
|
function initializeFromSession(): boolean {
|
||||||
|
if (!remoteConfig.value.team_workspaces_enabled) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const workspaceJson = sessionStorage.getItem(
|
||||||
|
WORKSPACE_STORAGE_KEYS.CURRENT_WORKSPACE
|
||||||
|
)
|
||||||
|
const token = sessionStorage.getItem(WORKSPACE_STORAGE_KEYS.TOKEN)
|
||||||
|
const expiresAtStr = sessionStorage.getItem(
|
||||||
|
WORKSPACE_STORAGE_KEYS.EXPIRES_AT
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!workspaceJson || !token || !expiresAtStr) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const expiresAt = parseInt(expiresAtStr, 10)
|
||||||
|
if (isNaN(expiresAt) || expiresAt <= Date.now()) {
|
||||||
|
clearSessionStorage()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedWorkspace = JSON.parse(workspaceJson)
|
||||||
|
const parseResult = WorkspaceWithRoleSchema.safeParse(parsedWorkspace)
|
||||||
|
|
||||||
|
if (!parseResult.success) {
|
||||||
|
clearSessionStorage()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
currentWorkspace.value = parseResult.data
|
||||||
|
workspaceToken.value = token
|
||||||
|
error.value = null
|
||||||
|
|
||||||
|
scheduleTokenRefresh(expiresAt)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
clearSessionStorage()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function switchWorkspace(workspaceId: string): Promise<void> {
|
||||||
|
if (!remoteConfig.value.team_workspaces_enabled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only increment request ID when switching to a different workspace
|
||||||
|
// This invalidates stale refresh operations for the old workspace
|
||||||
|
// but allows refresh operations for the same workspace to complete
|
||||||
|
if (currentWorkspace.value?.id !== workspaceId) {
|
||||||
|
refreshRequestId++
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading.value = true
|
||||||
|
error.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const firebaseAuthStore = useFirebaseAuthStore()
|
||||||
|
const firebaseToken = await firebaseAuthStore.getIdToken()
|
||||||
|
if (!firebaseToken) {
|
||||||
|
throw new WorkspaceAuthError(
|
||||||
|
t('workspaceAuth.errors.notAuthenticated'),
|
||||||
|
'NOT_AUTHENTICATED'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(api.apiURL('/auth/token'), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${firebaseToken}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ workspace_id: workspaceId })
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}))
|
||||||
|
const message = errorData.message || response.statusText
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
throw new WorkspaceAuthError(
|
||||||
|
t('workspaceAuth.errors.invalidFirebaseToken'),
|
||||||
|
'INVALID_FIREBASE_TOKEN'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (response.status === 403) {
|
||||||
|
throw new WorkspaceAuthError(
|
||||||
|
t('workspaceAuth.errors.accessDenied'),
|
||||||
|
'ACCESS_DENIED'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (response.status === 404) {
|
||||||
|
throw new WorkspaceAuthError(
|
||||||
|
t('workspaceAuth.errors.workspaceNotFound'),
|
||||||
|
'WORKSPACE_NOT_FOUND'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new WorkspaceAuthError(
|
||||||
|
t('workspaceAuth.errors.tokenExchangeFailed', { error: message }),
|
||||||
|
'TOKEN_EXCHANGE_FAILED'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawData = await response.json()
|
||||||
|
const parseResult = WorkspaceTokenResponseSchema.safeParse(rawData)
|
||||||
|
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new WorkspaceAuthError(
|
||||||
|
t('workspaceAuth.errors.tokenExchangeFailed', {
|
||||||
|
error: fromZodError(parseResult.error).message
|
||||||
|
}),
|
||||||
|
'TOKEN_EXCHANGE_FAILED'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = parseResult.data
|
||||||
|
const expiresAt = new Date(data.expires_at).getTime()
|
||||||
|
|
||||||
|
if (isNaN(expiresAt)) {
|
||||||
|
throw new WorkspaceAuthError(
|
||||||
|
t('workspaceAuth.errors.tokenExchangeFailed', {
|
||||||
|
error: 'Invalid expiry timestamp'
|
||||||
|
}),
|
||||||
|
'TOKEN_EXCHANGE_FAILED'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspaceWithRole: WorkspaceWithRole = {
|
||||||
|
...data.workspace,
|
||||||
|
role: data.role
|
||||||
|
}
|
||||||
|
|
||||||
|
currentWorkspace.value = workspaceWithRole
|
||||||
|
workspaceToken.value = data.token
|
||||||
|
|
||||||
|
persistToSession(workspaceWithRole, data.token, expiresAt)
|
||||||
|
scheduleTokenRefresh(expiresAt)
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err : new Error(String(err))
|
||||||
|
throw error.value
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshToken(): Promise<void> {
|
||||||
|
if (!currentWorkspace.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspaceId = currentWorkspace.value.id
|
||||||
|
// Capture the current request ID to detect if workspace context changed during refresh
|
||||||
|
const capturedRequestId = refreshRequestId
|
||||||
|
const maxRetries = 3
|
||||||
|
const baseDelayMs = 1000
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||||
|
// Check if workspace context changed since refresh started (user switched workspaces)
|
||||||
|
if (capturedRequestId !== refreshRequestId) {
|
||||||
|
console.warn(
|
||||||
|
'Aborting stale token refresh: workspace context changed during refresh'
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await switchWorkspace(workspaceId)
|
||||||
|
return
|
||||||
|
} catch (err) {
|
||||||
|
const isAuthError = err instanceof WorkspaceAuthError
|
||||||
|
|
||||||
|
const isPermanentError =
|
||||||
|
isAuthError &&
|
||||||
|
(err.code === 'ACCESS_DENIED' ||
|
||||||
|
err.code === 'WORKSPACE_NOT_FOUND' ||
|
||||||
|
err.code === 'INVALID_FIREBASE_TOKEN' ||
|
||||||
|
err.code === 'NOT_AUTHENTICATED')
|
||||||
|
|
||||||
|
if (isPermanentError) {
|
||||||
|
// Only clear context if this refresh is still for the current workspace
|
||||||
|
if (capturedRequestId === refreshRequestId) {
|
||||||
|
console.error('Workspace access revoked or auth invalid:', err)
|
||||||
|
clearWorkspaceContext()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const isTransientError =
|
||||||
|
isAuthError && err.code === 'TOKEN_EXCHANGE_FAILED'
|
||||||
|
|
||||||
|
if (isTransientError && attempt < maxRetries) {
|
||||||
|
const delay = baseDelayMs * Math.pow(2, attempt)
|
||||||
|
console.warn(
|
||||||
|
`Token refresh failed (attempt ${attempt + 1}/${maxRetries + 1}), retrying in ${delay}ms:`,
|
||||||
|
err
|
||||||
|
)
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, delay))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only clear context if this refresh is still for the current workspace
|
||||||
|
if (capturedRequestId === refreshRequestId) {
|
||||||
|
console.error('Failed to refresh workspace token after retries:', err)
|
||||||
|
clearWorkspaceContext()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWorkspaceAuthHeader(): AuthHeader | null {
|
||||||
|
if (!workspaceToken.value) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
Authorization: `Bearer ${workspaceToken.value}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearWorkspaceContext(): void {
|
||||||
|
// Increment request ID to invalidate any in-flight stale refresh operations
|
||||||
|
refreshRequestId++
|
||||||
|
stopRefreshTimer()
|
||||||
|
currentWorkspace.value = null
|
||||||
|
workspaceToken.value = null
|
||||||
|
error.value = null
|
||||||
|
clearSessionStorage()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
// State
|
||||||
|
currentWorkspace,
|
||||||
|
workspaceToken,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
|
||||||
|
// Getters
|
||||||
|
isAuthenticated,
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
init,
|
||||||
|
destroy,
|
||||||
|
initializeFromSession,
|
||||||
|
switchWorkspace,
|
||||||
|
refreshToken,
|
||||||
|
getWorkspaceAuthHeader,
|
||||||
|
clearWorkspaceContext
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -1,180 +1,218 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<BaseModalLayout
|
||||||
class="mx-auto flex h-full flex-col overflow-hidden"
|
v-model:right-panel-open="isRightPanelOpen"
|
||||||
:aria-label="$t('manager.title')"
|
:content-title="$t('manager.discoverCommunityContent')"
|
||||||
|
class="manager-dialog"
|
||||||
>
|
>
|
||||||
<ContentDivider :width="0.3" />
|
<template #leftPanel>
|
||||||
<Button
|
<LeftSidePanel v-model="selectedNavId" :nav-items="navItems">
|
||||||
v-if="isSmallScreen"
|
<template #header-icon>
|
||||||
variant="secondary"
|
<i class="icon-[lucide--puzzle]" />
|
||||||
size="icon"
|
</template>
|
||||||
:class="
|
<template #header-title>
|
||||||
cn(
|
<span class="text-neutral text-base">{{ $t('manager.title') }}</span>
|
||||||
'absolute top-1/2 z-10 -translate-y-1/2',
|
</template>
|
||||||
isSideNavOpen ? 'left-[12rem]' : 'left-2'
|
</LeftSidePanel>
|
||||||
)
|
</template>
|
||||||
"
|
|
||||||
@click="toggleSideNav"
|
<template #header>
|
||||||
>
|
<div class="flex items-center gap-2">
|
||||||
<i
|
<SingleSelect
|
||||||
:class="isSideNavOpen ? 'pi pi-chevron-left' : 'pi pi-chevron-right'"
|
v-model="searchMode"
|
||||||
/>
|
class="min-w-34"
|
||||||
</Button>
|
:options="filterOptions"
|
||||||
<div class="relative flex flex-1 overflow-hidden">
|
/>
|
||||||
<ManagerNavSidebar
|
<AutoCompletePlus
|
||||||
v-if="isSideNavOpen"
|
v-model.lazy="searchQuery"
|
||||||
v-model:selected-tab="selectedTab"
|
:suggestions="suggestions"
|
||||||
:tabs="tabs"
|
:placeholder="$t('manager.searchPlaceholder')"
|
||||||
/>
|
:complete-on-focus="false"
|
||||||
|
:delay="8"
|
||||||
|
option-label="query"
|
||||||
|
class="w-full min-w-md max-w-lg"
|
||||||
|
:pt="{
|
||||||
|
pcInputText: {
|
||||||
|
root: {
|
||||||
|
autofocus: true,
|
||||||
|
class: 'w-full rounded-lg h-10'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
loader: { style: 'display: none' }
|
||||||
|
}"
|
||||||
|
:show-empty-message="false"
|
||||||
|
@complete="stubTrue"
|
||||||
|
@option-select="onOptionSelect"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #contentFilter>
|
||||||
|
<!-- Conflict Warning Banner -->
|
||||||
<div
|
<div
|
||||||
class="flex-1 overflow-auto bg-base-background"
|
v-if="shouldShowManagerBanner"
|
||||||
:class="{
|
class="relative mx-6 mt-3 mb-4 flex items-center gap-6 rounded-lg bg-yellow-500/20 p-4"
|
||||||
'transition-all duration-300': isSmallScreen
|
|
||||||
}"
|
|
||||||
>
|
>
|
||||||
<div class="flex h-full flex-col px-6">
|
<i
|
||||||
<!-- Conflict Warning Banner -->
|
class="icon-[lucide--triangle-alert] text-lg text-warning-background"
|
||||||
<div
|
/>
|
||||||
v-if="shouldShowManagerBanner"
|
<div class="flex flex-1 flex-col gap-2">
|
||||||
class="relative mt-3 mb-4 flex items-center gap-6 rounded-lg bg-yellow-500/20 p-4"
|
<p class="m-0 text-sm font-bold">
|
||||||
|
{{ $t('manager.conflicts.warningBanner.title') }}
|
||||||
|
</p>
|
||||||
|
<p class="m-0 text-xs">
|
||||||
|
{{ $t('manager.conflicts.warningBanner.message') }}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
class="m-0 cursor-pointer text-sm font-bold"
|
||||||
|
@click="onClickWarningLink"
|
||||||
>
|
>
|
||||||
<i
|
{{ $t('manager.conflicts.warningBanner.button') }}
|
||||||
class="icon-[lucide--triangle-alert] text-lg text-warning-background"
|
</p>
|
||||||
/>
|
</div>
|
||||||
<div class="flex flex-1 flex-col gap-2">
|
<Button
|
||||||
<p class="m-0 text-sm font-bold">
|
class="absolute top-0 right-0"
|
||||||
{{ $t('manager.conflicts.warningBanner.title') }}
|
variant="textonly"
|
||||||
</p>
|
size="icon"
|
||||||
<p class="m-0 text-xs">
|
@click="dismissWarningBanner"
|
||||||
{{ $t('manager.conflicts.warningBanner.message') }}
|
>
|
||||||
</p>
|
<i class="pi pi-times text-xs text-base-foreground"></i>
|
||||||
<p
|
</Button>
|
||||||
class="m-0 cursor-pointer text-sm font-bold"
|
</div>
|
||||||
@click="onClickWarningLink"
|
|
||||||
>
|
<!-- Filters Row -->
|
||||||
{{ $t('manager.conflicts.warningBanner.button') }}
|
<div class="relative flex flex-wrap justify-between gap-2 px-6 pb-4">
|
||||||
</p>
|
<div>
|
||||||
</div>
|
<PackInstallButton
|
||||||
<Button
|
v-if="isMissingTab && missingNodePacks.length > 0"
|
||||||
class="absolute top-0 right-0"
|
:disabled="isMissingLoading || !!missingError"
|
||||||
variant="textonly"
|
:node-packs="missingNodePacks"
|
||||||
size="icon"
|
size="lg"
|
||||||
@click="dismissWarningBanner"
|
:label="$t('manager.installAllMissingNodes')"
|
||||||
>
|
|
||||||
<i class="pi pi-times text-xs text-base-foreground"></i>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<RegistrySearchBar
|
|
||||||
v-model:search-query="searchQuery"
|
|
||||||
v-model:search-mode="searchMode"
|
|
||||||
v-model:sort-field="sortField"
|
|
||||||
:search-results="searchResults"
|
|
||||||
:suggestions="suggestions"
|
|
||||||
:is-missing-tab="isMissingTab"
|
|
||||||
:sort-options="sortOptions"
|
|
||||||
:is-update-available-tab="isUpdateAvailableTab"
|
|
||||||
/>
|
/>
|
||||||
<div class="flex-1 overflow-auto">
|
<PackUpdateButton
|
||||||
<div
|
v-if="isUpdateAvailableTab && hasUpdateAvailable"
|
||||||
v-if="isLoading"
|
:node-packs="enabledUpdateAvailableNodePacks"
|
||||||
class="h-full scrollbar-hide w-full overflow-auto"
|
:has-disabled-update-packs="hasDisabledUpdatePacks"
|
||||||
>
|
size="lg"
|
||||||
<GridSkeleton :grid-style="GRID_STYLE" :skeleton-card-count />
|
/>
|
||||||
</div>
|
</div>
|
||||||
<NoResultsPlaceholder
|
|
||||||
v-else-if="searchResults.length === 0"
|
<!-- Sort Options on right -->
|
||||||
:title="
|
<div>
|
||||||
comfyManagerStore.error
|
<SingleSelect
|
||||||
? $t('manager.errorConnecting')
|
v-model="sortField"
|
||||||
: $t('manager.noResultsFound')
|
:label="$t('g.sort')"
|
||||||
"
|
:options="availableSortOptions"
|
||||||
:message="
|
class="w-48"
|
||||||
comfyManagerStore.error
|
>
|
||||||
? $t('manager.tryAgainLater')
|
<template #icon>
|
||||||
: $t('manager.tryDifferentSearch')
|
<i class="icon-[lucide--arrow-up-down] text-muted-foreground" />
|
||||||
"
|
</template>
|
||||||
/>
|
</SingleSelect>
|
||||||
<div v-else class="h-full" @click="handleGridContainerClick">
|
|
||||||
<VirtualGrid
|
|
||||||
id="results-grid"
|
|
||||||
:items="resultsWithKeys"
|
|
||||||
:buffer-rows="4"
|
|
||||||
:grid-style="GRID_STYLE"
|
|
||||||
@approach-end="onApproachEnd"
|
|
||||||
>
|
|
||||||
<template #item="{ item }">
|
|
||||||
<PackCard
|
|
||||||
:node-pack="item"
|
|
||||||
:is-selected="
|
|
||||||
selectedNodePacks.some((pack) => pack.id === item.id)
|
|
||||||
"
|
|
||||||
@click.stop="
|
|
||||||
(event: MouseEvent) => selectNodePack(item, event)
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</VirtualGrid>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="z-20 flex w-[clamp(250px,33%,306px)] border-l-0">
|
</template>
|
||||||
<ContentDivider orientation="vertical" :width="0.2" />
|
|
||||||
<div class="isolate flex w-full flex-col">
|
<template #content>
|
||||||
<InfoPanel
|
<div v-if="isLoading" class="scrollbar-hide h-full w-full overflow-auto">
|
||||||
v-if="!hasMultipleSelections && selectedNodePack"
|
<GridSkeleton :grid-style="GRID_STYLE" :skeleton-card-count />
|
||||||
:node-pack="selectedNodePack"
|
|
||||||
/>
|
|
||||||
<InfoPanelMultiItem v-else :node-packs="selectedNodePacks" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<NoResultsPlaceholder
|
||||||
</div>
|
v-else-if="searchResults.length === 0"
|
||||||
|
:title="
|
||||||
|
comfyManagerStore.error
|
||||||
|
? $t('manager.errorConnecting')
|
||||||
|
: $t('manager.noResultsFound')
|
||||||
|
"
|
||||||
|
:message="
|
||||||
|
comfyManagerStore.error
|
||||||
|
? $t('manager.tryAgainLater')
|
||||||
|
: $t('manager.tryDifferentSearch')
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<div v-else class="h-full" @click="handleGridContainerClick">
|
||||||
|
<VirtualGrid
|
||||||
|
id="results-grid"
|
||||||
|
:items="resultsWithKeys"
|
||||||
|
:buffer-rows="4"
|
||||||
|
:grid-style="GRID_STYLE"
|
||||||
|
@approach-end="onApproachEnd"
|
||||||
|
>
|
||||||
|
<template #item="{ item }">
|
||||||
|
<PackCard
|
||||||
|
:node-pack="item"
|
||||||
|
:is-selected="
|
||||||
|
selectedNodePacks.some((pack) => pack.id === item.id)
|
||||||
|
"
|
||||||
|
@click.stop="(event: MouseEvent) => selectNodePack(item, event)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</VirtualGrid>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #rightPanel>
|
||||||
|
<InfoPanel
|
||||||
|
v-if="!hasMultipleSelections && selectedNodePack"
|
||||||
|
:node-pack="selectedNodePack"
|
||||||
|
/>
|
||||||
|
<InfoPanelMultiItem v-else :node-packs="selectedNodePacks" />
|
||||||
|
</template>
|
||||||
|
</BaseModalLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { whenever } from '@vueuse/core'
|
import { whenever } from '@vueuse/core'
|
||||||
import { merge } from 'es-toolkit/compat'
|
import { merge, stubTrue } from 'es-toolkit/compat'
|
||||||
|
import type { AutoCompleteOptionSelectEvent } from 'primevue/autocomplete'
|
||||||
import {
|
import {
|
||||||
computed,
|
computed,
|
||||||
onBeforeUnmount,
|
onBeforeUnmount,
|
||||||
onMounted,
|
onMounted,
|
||||||
onUnmounted,
|
onUnmounted,
|
||||||
|
provide,
|
||||||
ref,
|
ref,
|
||||||
watch,
|
watch,
|
||||||
watchEffect
|
watchEffect
|
||||||
} from 'vue'
|
} from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
import ContentDivider from '@/components/common/ContentDivider.vue'
|
|
||||||
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
|
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
|
||||||
import VirtualGrid from '@/components/common/VirtualGrid.vue'
|
import VirtualGrid from '@/components/common/VirtualGrid.vue'
|
||||||
|
import SingleSelect from '@/components/input/SingleSelect.vue'
|
||||||
|
import AutoCompletePlus from '@/components/primevueOverride/AutoCompletePlus.vue'
|
||||||
import Button from '@/components/ui/button/Button.vue'
|
import Button from '@/components/ui/button/Button.vue'
|
||||||
import { useResponsiveCollapse } from '@/composables/element/useResponsiveCollapse'
|
import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
|
||||||
|
import LeftSidePanel from '@/components/widget/panel/LeftSidePanel.vue'
|
||||||
import { useExternalLink } from '@/composables/useExternalLink'
|
import { useExternalLink } from '@/composables/useExternalLink'
|
||||||
import { useComfyRegistryStore } from '@/stores/comfyRegistryStore'
|
import { useComfyRegistryStore } from '@/stores/comfyRegistryStore'
|
||||||
import type { components } from '@/types/comfyRegistryTypes'
|
import type { components } from '@/types/comfyRegistryTypes'
|
||||||
import { cn } from '@/utils/tailwindUtil'
|
import type { NavItemData } from '@/types/navTypes'
|
||||||
import ManagerNavSidebar from '@/workbench/extensions/manager/components/manager/ManagerNavSidebar.vue'
|
import { OnCloseKey } from '@/types/widgetTypes'
|
||||||
|
import PackInstallButton from '@/workbench/extensions/manager/components/manager/button/PackInstallButton.vue'
|
||||||
|
import PackUpdateButton from '@/workbench/extensions/manager/components/manager/button/PackUpdateButton.vue'
|
||||||
import InfoPanel from '@/workbench/extensions/manager/components/manager/infoPanel/InfoPanel.vue'
|
import InfoPanel from '@/workbench/extensions/manager/components/manager/infoPanel/InfoPanel.vue'
|
||||||
import InfoPanelMultiItem from '@/workbench/extensions/manager/components/manager/infoPanel/InfoPanelMultiItem.vue'
|
import InfoPanelMultiItem from '@/workbench/extensions/manager/components/manager/infoPanel/InfoPanelMultiItem.vue'
|
||||||
import PackCard from '@/workbench/extensions/manager/components/manager/packCard/PackCard.vue'
|
import PackCard from '@/workbench/extensions/manager/components/manager/packCard/PackCard.vue'
|
||||||
import RegistrySearchBar from '@/workbench/extensions/manager/components/manager/registrySearchBar/RegistrySearchBar.vue'
|
|
||||||
import GridSkeleton from '@/workbench/extensions/manager/components/manager/skeleton/GridSkeleton.vue'
|
import GridSkeleton from '@/workbench/extensions/manager/components/manager/skeleton/GridSkeleton.vue'
|
||||||
import { useInstalledPacks } from '@/workbench/extensions/manager/composables/nodePack/useInstalledPacks'
|
import { useInstalledPacks } from '@/workbench/extensions/manager/composables/nodePack/useInstalledPacks'
|
||||||
|
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
|
||||||
import { usePackUpdateStatus } from '@/workbench/extensions/manager/composables/nodePack/usePackUpdateStatus'
|
import { usePackUpdateStatus } from '@/workbench/extensions/manager/composables/nodePack/usePackUpdateStatus'
|
||||||
|
import { useUpdateAvailableNodes } from '@/workbench/extensions/manager/composables/nodePack/useUpdateAvailableNodes'
|
||||||
import { useWorkflowPacks } from '@/workbench/extensions/manager/composables/nodePack/useWorkflowPacks'
|
import { useWorkflowPacks } from '@/workbench/extensions/manager/composables/nodePack/useWorkflowPacks'
|
||||||
import { useConflictAcknowledgment } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment'
|
import { useConflictAcknowledgment } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment'
|
||||||
import { useManagerStatePersistence } from '@/workbench/extensions/manager/composables/useManagerStatePersistence'
|
import { useManagerStatePersistence } from '@/workbench/extensions/manager/composables/useManagerStatePersistence'
|
||||||
import { useRegistrySearch } from '@/workbench/extensions/manager/composables/useRegistrySearch'
|
import { useRegistrySearch } from '@/workbench/extensions/manager/composables/useRegistrySearch'
|
||||||
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
|
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
|
||||||
import type { TabItem } from '@/workbench/extensions/manager/types/comfyManagerTypes'
|
|
||||||
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
|
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
|
||||||
|
|
||||||
const { initialTab } = defineProps<{
|
const { initialTab, onClose } = defineProps<{
|
||||||
initialTab?: ManagerTab
|
initialTab?: ManagerTab
|
||||||
|
onClose: () => void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
provide(OnCloseKey, onClose)
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const { buildDocsUrl } = useExternalLink()
|
const { buildDocsUrl } = useExternalLink()
|
||||||
const comfyManagerStore = useComfyManagerStore()
|
const comfyManagerStore = useComfyManagerStore()
|
||||||
@@ -186,46 +224,56 @@ const initialState = persistedState.loadStoredState()
|
|||||||
const GRID_STYLE = {
|
const GRID_STYLE = {
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
gridTemplateColumns: 'repeat(auto-fill, minmax(17rem, 1fr))',
|
gridTemplateColumns: 'repeat(auto-fill, minmax(17rem, 1fr))',
|
||||||
padding: '0.5rem',
|
gap: '1.5rem',
|
||||||
gap: '1.5rem'
|
padding: '0'
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
const {
|
|
||||||
isSmallScreen,
|
|
||||||
isOpen: isSideNavOpen,
|
|
||||||
toggle: toggleSideNav
|
|
||||||
} = useResponsiveCollapse()
|
|
||||||
|
|
||||||
// Use conflict acknowledgment state from composable
|
|
||||||
const {
|
const {
|
||||||
shouldShowManagerBanner,
|
shouldShowManagerBanner,
|
||||||
dismissWarningBanner,
|
dismissWarningBanner,
|
||||||
dismissRedDotNotification
|
dismissRedDotNotification
|
||||||
} = conflictAcknowledgment
|
} = conflictAcknowledgment
|
||||||
|
|
||||||
const tabs = ref<TabItem[]>([
|
// Missing nodes composable
|
||||||
{ id: ManagerTab.All, label: t('g.all'), icon: 'pi-list' },
|
const {
|
||||||
{ id: ManagerTab.Installed, label: t('g.installed'), icon: 'pi-box' },
|
missingNodePacks,
|
||||||
|
isLoading: isMissingLoading,
|
||||||
|
error: missingError
|
||||||
|
} = useMissingNodes()
|
||||||
|
|
||||||
|
// Update available nodes composable
|
||||||
|
const {
|
||||||
|
hasUpdateAvailable,
|
||||||
|
enabledUpdateAvailableNodePacks,
|
||||||
|
hasDisabledUpdatePacks
|
||||||
|
} = useUpdateAvailableNodes()
|
||||||
|
|
||||||
|
// Navigation items for LeftSidePanel
|
||||||
|
const navItems = computed<NavItemData[]>(() => [
|
||||||
|
{ id: ManagerTab.All, label: t('g.all'), icon: 'pi pi-list' },
|
||||||
|
{ id: ManagerTab.Installed, label: t('g.installed'), icon: 'pi pi-box' },
|
||||||
{
|
{
|
||||||
id: ManagerTab.Workflow,
|
id: ManagerTab.Workflow,
|
||||||
label: t('manager.inWorkflow'),
|
label: t('manager.inWorkflow'),
|
||||||
icon: 'pi-folder'
|
icon: 'pi pi-folder'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: ManagerTab.Missing,
|
id: ManagerTab.Missing,
|
||||||
label: t('g.missing'),
|
label: t('g.missing'),
|
||||||
icon: 'pi-exclamation-circle'
|
icon: 'pi pi-exclamation-circle'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: ManagerTab.UpdateAvailable,
|
id: ManagerTab.UpdateAvailable,
|
||||||
label: t('g.updateAvailable'),
|
label: t('g.updateAvailable'),
|
||||||
icon: 'pi-sync'
|
icon: 'pi pi-sync'
|
||||||
}
|
}
|
||||||
])
|
])
|
||||||
|
|
||||||
const initialTabId = initialTab ?? initialState.selectedTabId
|
const initialTabId = initialTab ?? initialState.selectedTabId ?? ManagerTab.All
|
||||||
const selectedTab = ref<TabItem>(
|
const selectedNavId = ref<string | null>(initialTabId)
|
||||||
tabs.value.find((tab) => tab.id === initialTabId) || tabs.value[0]
|
|
||||||
|
const selectedTab = computed(() =>
|
||||||
|
navItems.value.find((item) => item.id === selectedNavId.value)
|
||||||
)
|
)
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -243,6 +291,25 @@ const {
|
|||||||
initialSearchQuery: initialState.searchQuery
|
initialSearchQuery: initialState.searchQuery
|
||||||
})
|
})
|
||||||
pageNumber.value = 0
|
pageNumber.value = 0
|
||||||
|
|
||||||
|
// Filter and sort options for SingleSelect
|
||||||
|
const filterOptions = computed(() => [
|
||||||
|
{ name: t('manager.filter.nodePack'), value: 'packs' },
|
||||||
|
{ name: t('g.nodes'), value: 'nodes' }
|
||||||
|
])
|
||||||
|
|
||||||
|
const availableSortOptions = computed(() => {
|
||||||
|
if (!sortOptions.value) return []
|
||||||
|
return sortOptions.value.map((field) => ({
|
||||||
|
name: field.label,
|
||||||
|
value: field.id
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
const onOptionSelect = (event: AutoCompleteOptionSelectEvent) => {
|
||||||
|
searchQuery.value = event.value.query
|
||||||
|
}
|
||||||
|
|
||||||
const onApproachEnd = () => {
|
const onApproachEnd = () => {
|
||||||
pageNumber.value++
|
pageNumber.value++
|
||||||
}
|
}
|
||||||
@@ -433,6 +500,14 @@ const selectedNodePacks = ref<components['schemas']['Node'][]>([])
|
|||||||
const selectedNodePack = computed<components['schemas']['Node'] | null>(() =>
|
const selectedNodePack = computed<components['schemas']['Node'] | null>(() =>
|
||||||
selectedNodePacks.value.length === 1 ? selectedNodePacks.value[0] : null
|
selectedNodePacks.value.length === 1 ? selectedNodePacks.value[0] : null
|
||||||
)
|
)
|
||||||
|
const isRightPanelOpen = ref(false)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => selectedNodePacks.value.length,
|
||||||
|
(length) => {
|
||||||
|
isRightPanelOpen.value = length > 0
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
const getLoadingCount = () => {
|
const getLoadingCount = () => {
|
||||||
switch (selectedTab.value?.id) {
|
switch (selectedTab.value?.id) {
|
||||||
@@ -452,28 +527,26 @@ const getLoadingCount = () => {
|
|||||||
const skeletonCardCount = computed(() => {
|
const skeletonCardCount = computed(() => {
|
||||||
const loadingCount = getLoadingCount()
|
const loadingCount = getLoadingCount()
|
||||||
if (loadingCount) return loadingCount
|
if (loadingCount) return loadingCount
|
||||||
return isSmallScreen.value ? 12 : 16
|
return 16
|
||||||
})
|
})
|
||||||
|
|
||||||
const selectNodePack = (
|
const selectNodePack = (
|
||||||
nodePack: components['schemas']['Node'],
|
nodePack: components['schemas']['Node'],
|
||||||
event: MouseEvent
|
event: MouseEvent
|
||||||
) => {
|
) => {
|
||||||
// Handle multi-select with Shift or Ctrl/Cmd key
|
|
||||||
if (event.shiftKey || event.ctrlKey || event.metaKey) {
|
if (event.shiftKey || event.ctrlKey || event.metaKey) {
|
||||||
const index = selectedNodePacks.value.findIndex(
|
const index = selectedNodePacks.value.findIndex(
|
||||||
(pack) => pack.id === nodePack.id
|
(pack) => pack.id === nodePack.id
|
||||||
)
|
)
|
||||||
|
|
||||||
if (index === -1) {
|
if (index === -1) {
|
||||||
// Add to selection if not already selected
|
selectedNodePacks.value = [...selectedNodePacks.value, nodePack]
|
||||||
selectedNodePacks.value.push(nodePack)
|
|
||||||
} else {
|
} else {
|
||||||
// Remove from selection if already selected
|
selectedNodePacks.value = selectedNodePacks.value.filter(
|
||||||
selectedNodePacks.value.splice(index, 1)
|
(pack) => pack.id !== nodePack.id
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Single select behavior
|
|
||||||
selectedNodePacks.value = [nodePack]
|
selectedNodePacks.value = [nodePack]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -490,32 +563,24 @@ const handleGridContainerClick = (event: MouseEvent) => {
|
|||||||
|
|
||||||
const hasMultipleSelections = computed(() => selectedNodePacks.value.length > 1)
|
const hasMultipleSelections = computed(() => selectedNodePacks.value.length > 1)
|
||||||
|
|
||||||
// Track the last pack ID for which we've fetched full registry data
|
|
||||||
const lastFetchedPackId = ref<string | null>(null)
|
const lastFetchedPackId = ref<string | null>(null)
|
||||||
|
|
||||||
// Whenever a single pack is selected, fetch its full info once
|
|
||||||
whenever(selectedNodePack, async () => {
|
whenever(selectedNodePack, async () => {
|
||||||
// Cancel any in-flight requests from previously selected node pack
|
|
||||||
getPackById.cancel()
|
getPackById.cancel()
|
||||||
// If only a single node pack is selected, fetch full node pack info from registry
|
|
||||||
const pack = selectedNodePack.value
|
const pack = selectedNodePack.value
|
||||||
if (!pack?.id) return
|
if (!pack?.id) return
|
||||||
if (hasMultipleSelections.value) return
|
if (hasMultipleSelections.value) return
|
||||||
// Only fetch if we haven't already for this pack
|
|
||||||
if (lastFetchedPackId.value === pack.id) return
|
if (lastFetchedPackId.value === pack.id) return
|
||||||
const data = await getPackById.call(pack.id)
|
const data = await getPackById.call(pack.id)
|
||||||
// If selected node hasn't changed since request, merge registry & Algolia data
|
|
||||||
if (data?.id === pack.id) {
|
if (data?.id === pack.id) {
|
||||||
lastFetchedPackId.value = pack.id
|
lastFetchedPackId.value = pack.id
|
||||||
const mergedPack = merge({}, pack, data)
|
const mergedPack = merge({}, pack, data)
|
||||||
// Update the pack in current selection without changing selection state
|
|
||||||
const packIndex = selectedNodePacks.value.findIndex(
|
const packIndex = selectedNodePacks.value.findIndex(
|
||||||
(p) => p.id === mergedPack.id
|
(p) => p.id === mergedPack.id
|
||||||
)
|
)
|
||||||
if (packIndex !== -1) {
|
if (packIndex !== -1) {
|
||||||
selectedNodePacks.value.splice(packIndex, 1, mergedPack)
|
selectedNodePacks.value.splice(packIndex, 1, mergedPack)
|
||||||
}
|
}
|
||||||
// Replace pack in displayPacks so that children receive a fresh prop reference
|
|
||||||
const idx = displayPacks.value.findIndex((p) => p.id === mergedPack.id)
|
const idx = displayPacks.value.findIndex((p) => p.id === mergedPack.id)
|
||||||
if (idx !== -1) {
|
if (idx !== -1) {
|
||||||
displayPacks.value.splice(idx, 1, mergedPack)
|
displayPacks.value.splice(idx, 1, mergedPack)
|
||||||
@@ -527,7 +592,7 @@ let gridContainer: HTMLElement | null = null
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
gridContainer = document.getElementById('results-grid')
|
gridContainer = document.getElementById('results-grid')
|
||||||
})
|
})
|
||||||
watch([searchQuery, selectedTab], () => {
|
watch([searchQuery, selectedNavId], () => {
|
||||||
gridContainer ??= document.getElementById('results-grid')
|
gridContainer ??= document.getElementById('results-grid')
|
||||||
if (gridContainer) {
|
if (gridContainer) {
|
||||||
pageNumber.value = 0
|
pageNumber.value = 0
|
||||||
@@ -541,7 +606,7 @@ watchEffect(() => {
|
|||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
persistedState.persistState({
|
persistedState.persistState({
|
||||||
selectedTabId: selectedTab.value?.id,
|
selectedTabId: (selectedTab.value?.id as ManagerTab) ?? ManagerTab.All,
|
||||||
searchQuery: searchQuery.value,
|
searchQuery: searchQuery.value,
|
||||||
searchMode: searchMode.value,
|
searchMode: searchMode.value,
|
||||||
sortField: sortField.value
|
sortField: sortField.value
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
import { mount } from '@vue/test-utils'
|
|
||||||
import { createPinia } from 'pinia'
|
|
||||||
import PrimeVue from 'primevue/config'
|
|
||||||
import { describe, expect, it } from 'vitest'
|
|
||||||
import { createI18n } from 'vue-i18n'
|
|
||||||
|
|
||||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
|
||||||
|
|
||||||
import ManagerHeader from './ManagerHeader.vue'
|
|
||||||
|
|
||||||
const i18n = createI18n({
|
|
||||||
legacy: false,
|
|
||||||
locale: 'en',
|
|
||||||
messages: {
|
|
||||||
en: enMessages
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('ManagerHeader', () => {
|
|
||||||
const createWrapper = () => {
|
|
||||||
return mount(ManagerHeader, {
|
|
||||||
global: {
|
|
||||||
plugins: [createPinia(), PrimeVue, i18n]
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
it('renders the component title', () => {
|
|
||||||
const wrapper = createWrapper()
|
|
||||||
|
|
||||||
expect(wrapper.find('h2').text()).toBe(
|
|
||||||
enMessages.manager.discoverCommunityContent
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('has proper structure with flex container', () => {
|
|
||||||
const wrapper = createWrapper()
|
|
||||||
|
|
||||||
const flexContainer = wrapper.find('.flex.items-center')
|
|
||||||
expect(flexContainer.exists()).toBe(true)
|
|
||||||
|
|
||||||
const title = flexContainer.find('h2')
|
|
||||||
expect(title.exists()).toBe(true)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="w-full">
|
|
||||||
<div class="flex items-center">
|
|
||||||
<h2 class="text-left text-lg font-normal">
|
|
||||||
{{ $t('manager.discoverCommunityContent') }}
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts"></script>
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
<template>
|
|
||||||
<aside
|
|
||||||
class="z-5 flex w-3/12 max-w-[250px] translate-x-0 transition-transform duration-300 ease-in-out"
|
|
||||||
>
|
|
||||||
<ScrollPanel class="flex-1">
|
|
||||||
<Listbox
|
|
||||||
v-model="selectedTab"
|
|
||||||
:options="tabs"
|
|
||||||
option-label="label"
|
|
||||||
list-style="max-height:unset"
|
|
||||||
class="w-full border-0 bg-transparent shadow-none"
|
|
||||||
:pt="{
|
|
||||||
list: { class: 'p-3 gap-2' },
|
|
||||||
option: { class: 'px-4 py-2 text-lg rounded-lg' },
|
|
||||||
optionGroup: { class: 'p-0 text-left text-inherit' }
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<template #option="slotProps">
|
|
||||||
<div class="flex items-center text-left">
|
|
||||||
<i :class="['pi', slotProps.option.icon, 'mr-2 text-sm']" />
|
|
||||||
<span class="text-sm">{{ slotProps.option.label }}</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</Listbox>
|
|
||||||
</ScrollPanel>
|
|
||||||
<ContentDivider orientation="vertical" :width="0.3" />
|
|
||||||
</aside>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import Listbox from 'primevue/listbox'
|
|
||||||
import ScrollPanel from 'primevue/scrollpanel'
|
|
||||||
|
|
||||||
import ContentDivider from '@/components/common/ContentDivider.vue'
|
|
||||||
import type { TabItem } from '@/workbench/extensions/manager/types/comfyManagerTypes'
|
|
||||||
|
|
||||||
defineProps<{
|
|
||||||
tabs: TabItem[]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const selectedTab = defineModel<TabItem>('selectedTab')
|
|
||||||
</script>
|
|
||||||
@@ -3,9 +3,8 @@
|
|||||||
v-tooltip.top="
|
v-tooltip.top="
|
||||||
hasDisabledUpdatePacks ? $t('manager.disabledNodesWontUpdate') : null
|
hasDisabledUpdatePacks ? $t('manager.disabledNodesWontUpdate') : null
|
||||||
"
|
"
|
||||||
variant="textonly"
|
|
||||||
class="border"
|
class="border"
|
||||||
size="sm"
|
:size
|
||||||
:disabled="isUpdating"
|
:disabled="isUpdating"
|
||||||
@click="updateAllPacks"
|
@click="updateAllPacks"
|
||||||
>
|
>
|
||||||
@@ -19,14 +18,20 @@ import { ref } from 'vue'
|
|||||||
|
|
||||||
import DotSpinner from '@/components/common/DotSpinner.vue'
|
import DotSpinner from '@/components/common/DotSpinner.vue'
|
||||||
import Button from '@/components/ui/button/Button.vue'
|
import Button from '@/components/ui/button/Button.vue'
|
||||||
|
import type { ButtonVariants } from '@/components/ui/button/button.variants'
|
||||||
import type { components } from '@/types/comfyRegistryTypes'
|
import type { components } from '@/types/comfyRegistryTypes'
|
||||||
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
|
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
|
||||||
|
|
||||||
type NodePack = components['schemas']['Node']
|
type NodePack = components['schemas']['Node']
|
||||||
|
|
||||||
const { nodePacks, hasDisabledUpdatePacks } = defineProps<{
|
const {
|
||||||
|
nodePacks,
|
||||||
|
hasDisabledUpdatePacks,
|
||||||
|
size = 'sm'
|
||||||
|
} = defineProps<{
|
||||||
nodePacks: NodePack[]
|
nodePacks: NodePack[]
|
||||||
hasDisabledUpdatePacks?: boolean
|
hasDisabledUpdatePacks?: boolean
|
||||||
|
size?: ButtonVariants['size']
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const isUpdating = ref<boolean>(false)
|
const isUpdating = ref<boolean>(false)
|
||||||
|
|||||||
@@ -1,130 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="relative w-full p-6">
|
|
||||||
<div class="flex h-12 items-center justify-between gap-1">
|
|
||||||
<div class="flex w-5/12 items-center">
|
|
||||||
<AutoComplete
|
|
||||||
v-model.lazy="searchQuery"
|
|
||||||
:suggestions="suggestions || []"
|
|
||||||
:placeholder="$t('manager.searchPlaceholder')"
|
|
||||||
:complete-on-focus="false"
|
|
||||||
:delay="8"
|
|
||||||
option-label="query"
|
|
||||||
class="w-full"
|
|
||||||
:pt="{
|
|
||||||
pcInputText: {
|
|
||||||
root: {
|
|
||||||
autofocus: true,
|
|
||||||
class: 'w-full rounded-2xl'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
loader: {
|
|
||||||
style: 'display: none'
|
|
||||||
}
|
|
||||||
}"
|
|
||||||
:show-empty-message="false"
|
|
||||||
@complete="stubTrue"
|
|
||||||
@option-select="onOptionSelect"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<PackInstallButton
|
|
||||||
v-if="isMissingTab && missingNodePacks.length > 0"
|
|
||||||
:disabled="isLoading || !!error"
|
|
||||||
:node-packs="missingNodePacks"
|
|
||||||
:label="$t('manager.installAllMissingNodes')"
|
|
||||||
/>
|
|
||||||
<PackUpdateButton
|
|
||||||
v-if="isUpdateAvailableTab && hasUpdateAvailable"
|
|
||||||
:node-packs="enabledUpdateAvailableNodePacks"
|
|
||||||
:has-disabled-update-packs="hasDisabledUpdatePacks"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="mt-3 flex text-sm">
|
|
||||||
<div class="ml-1 flex gap-6">
|
|
||||||
<SearchFilterDropdown
|
|
||||||
v-model:model-value="searchMode"
|
|
||||||
:options="filterOptions"
|
|
||||||
:label="$t('g.filter')"
|
|
||||||
/>
|
|
||||||
<SearchFilterDropdown
|
|
||||||
v-model:model-value="sortField"
|
|
||||||
:options="availableSortOptions"
|
|
||||||
:label="$t('g.sort')"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="ml-6 flex items-center gap-4">
|
|
||||||
<small v-if="hasResults" class="text-color-secondary">
|
|
||||||
{{ $t('g.resultsCount', { count: searchResults?.length || 0 }) }}
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { stubTrue } from 'es-toolkit/compat'
|
|
||||||
import type { AutoCompleteOptionSelectEvent } from 'primevue/autocomplete'
|
|
||||||
import AutoComplete from 'primevue/autocomplete'
|
|
||||||
import { computed } from 'vue'
|
|
||||||
import { useI18n } from 'vue-i18n'
|
|
||||||
|
|
||||||
import type { components } from '@/types/comfyRegistryTypes'
|
|
||||||
import type {
|
|
||||||
QuerySuggestion,
|
|
||||||
SearchMode,
|
|
||||||
SortableField
|
|
||||||
} from '@/types/searchServiceTypes'
|
|
||||||
import PackInstallButton from '@/workbench/extensions/manager/components/manager/button/PackInstallButton.vue'
|
|
||||||
import PackUpdateButton from '@/workbench/extensions/manager/components/manager/button/PackUpdateButton.vue'
|
|
||||||
import SearchFilterDropdown from '@/workbench/extensions/manager/components/manager/registrySearchBar/SearchFilterDropdown.vue'
|
|
||||||
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
|
|
||||||
import { useUpdateAvailableNodes } from '@/workbench/extensions/manager/composables/nodePack/useUpdateAvailableNodes'
|
|
||||||
import { SortableAlgoliaField } from '@/workbench/extensions/manager/types/comfyManagerTypes'
|
|
||||||
import type { SearchOption } from '@/workbench/extensions/manager/types/comfyManagerTypes'
|
|
||||||
|
|
||||||
const { searchResults, sortOptions } = defineProps<{
|
|
||||||
searchResults?: components['schemas']['Node'][]
|
|
||||||
suggestions?: QuerySuggestion[]
|
|
||||||
sortOptions?: SortableField[]
|
|
||||||
isMissingTab?: boolean
|
|
||||||
isUpdateAvailableTab?: boolean
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const searchQuery = defineModel<string>('searchQuery')
|
|
||||||
const searchMode = defineModel<SearchMode>('searchMode', { default: 'packs' })
|
|
||||||
const sortField = defineModel<string>('sortField', {
|
|
||||||
default: SortableAlgoliaField.Downloads
|
|
||||||
})
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
|
||||||
|
|
||||||
// Get missing node packs from workflow with loading and error states
|
|
||||||
const { missingNodePacks, isLoading, error } = useMissingNodes()
|
|
||||||
|
|
||||||
// Use the composable to get update available nodes
|
|
||||||
const {
|
|
||||||
hasUpdateAvailable,
|
|
||||||
enabledUpdateAvailableNodePacks,
|
|
||||||
hasDisabledUpdatePacks
|
|
||||||
} = useUpdateAvailableNodes()
|
|
||||||
|
|
||||||
const hasResults = computed(
|
|
||||||
() => searchQuery.value?.trim() && searchResults?.length
|
|
||||||
)
|
|
||||||
|
|
||||||
const availableSortOptions = computed<SearchOption<string>[]>(() => {
|
|
||||||
if (!sortOptions) return []
|
|
||||||
return sortOptions.map((field) => ({
|
|
||||||
id: field.id,
|
|
||||||
label: field.label
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
const filterOptions: SearchOption<SearchMode>[] = [
|
|
||||||
{ id: 'packs', label: t('manager.filter.nodePack') },
|
|
||||||
{ id: 'nodes', label: t('g.nodes') }
|
|
||||||
]
|
|
||||||
|
|
||||||
// When a dropdown query suggestion is selected, update the search query
|
|
||||||
const onOptionSelect = (event: AutoCompleteOptionSelectEvent) => {
|
|
||||||
searchQuery.value = event.value.query
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
<span class="text-muted">{{ label }}:</span>
|
|
||||||
<Dropdown
|
|
||||||
:model-value="modelValue"
|
|
||||||
:options="options"
|
|
||||||
option-label="label"
|
|
||||||
option-value="id"
|
|
||||||
class="min-w-[6rem] border-none bg-transparent shadow-none"
|
|
||||||
:pt="{
|
|
||||||
input: { class: 'py-0 px-1 border-none' },
|
|
||||||
trigger: { class: 'hidden' },
|
|
||||||
panel: { class: 'shadow-md' },
|
|
||||||
item: { class: 'py-2 px-3 text-sm' }
|
|
||||||
}"
|
|
||||||
@update:model-value="$emit('update:modelValue', $event)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts" generic="T">
|
|
||||||
// oxlint-disable-next-line no-restricted-imports -- TODO: Migrate to Select component
|
|
||||||
import Dropdown from 'primevue/dropdown'
|
|
||||||
|
|
||||||
import type { SearchOption } from '@/workbench/extensions/manager/types/comfyManagerTypes'
|
|
||||||
|
|
||||||
defineProps<{
|
|
||||||
options: SearchOption<T>[]
|
|
||||||
label: string
|
|
||||||
modelValue: T
|
|
||||||
}>()
|
|
||||||
|
|
||||||
defineEmits<{
|
|
||||||
'update:modelValue': [value: T]
|
|
||||||
}>()
|
|
||||||
</script>
|
|
||||||
@@ -14,7 +14,6 @@ import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comf
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Composable to find missing NodePacks from workflow
|
* Composable to find missing NodePacks from workflow
|
||||||
* Uses the same filtering approach as ManagerDialogContent.vue
|
|
||||||
* Automatically fetches workflow pack data when initialized
|
* Automatically fetches workflow pack data when initialized
|
||||||
* This is a shared singleton composable - all components use the same instance
|
* This is a shared singleton composable - all components use the same instance
|
||||||
*/
|
*/
|
||||||
@@ -25,7 +24,6 @@ export const useMissingNodes = createSharedComposable(() => {
|
|||||||
const { workflowPacks, isLoading, error, startFetchWorkflowPacks } =
|
const { workflowPacks, isLoading, error, startFetchWorkflowPacks } =
|
||||||
useWorkflowPacks()
|
useWorkflowPacks()
|
||||||
|
|
||||||
// Same filtering logic as ManagerDialogContent.vue
|
|
||||||
const filterMissingPacks = (packs: components['schemas']['Node'][]) =>
|
const filterMissingPacks = (packs: components['schemas']['Node'][]) =>
|
||||||
packs.filter((pack) => !comfyManagerStore.isPackInstalled(pack.id))
|
packs.filter((pack) => !comfyManagerStore.isPackInstalled(pack.id))
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comf
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Composable to find NodePacks that have updates available
|
* Composable to find NodePacks that have updates available
|
||||||
* Uses the same filtering approach as ManagerDialogContent.vue
|
|
||||||
* Automatically fetches installed pack data when initialized
|
* Automatically fetches installed pack data when initialized
|
||||||
*/
|
*/
|
||||||
export const useUpdateAvailableNodes = () => {
|
export const useUpdateAvailableNodes = () => {
|
||||||
@@ -34,7 +33,6 @@ export const useUpdateAvailableNodes = () => {
|
|||||||
return compare(latestVersion, installedVersion) > 0
|
return compare(latestVersion, installedVersion) > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// Same filtering logic as ManagerDialogContent.vue
|
|
||||||
const filterOutdatedPacks = (packs: components['schemas']['Node'][]) =>
|
const filterOutdatedPacks = (packs: components['schemas']['Node'][]) =>
|
||||||
packs.filter(isOutdatedPack)
|
packs.filter(isOutdatedPack)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { useDialogService } from '@/services/dialogService'
|
||||||
|
import { useDialogStore } from '@/stores/dialogStore'
|
||||||
|
import type { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
|
||||||
|
import ManagerDialog from '@/workbench/extensions/manager/components/manager/ManagerDialog.vue'
|
||||||
|
|
||||||
|
const DIALOG_KEY = 'global-manager'
|
||||||
|
|
||||||
|
export function useManagerDialog() {
|
||||||
|
const dialogService = useDialogService()
|
||||||
|
const dialogStore = useDialogStore()
|
||||||
|
|
||||||
|
function hide() {
|
||||||
|
dialogStore.closeDialog({ key: DIALOG_KEY })
|
||||||
|
}
|
||||||
|
|
||||||
|
function show(initialTab?: ManagerTab) {
|
||||||
|
dialogService.showLayoutDialog({
|
||||||
|
key: DIALOG_KEY,
|
||||||
|
component: ManagerDialog,
|
||||||
|
props: {
|
||||||
|
onClose: hide,
|
||||||
|
initialTab
|
||||||
|
},
|
||||||
|
dialogComponentProps: {
|
||||||
|
pt: {
|
||||||
|
content: { class: '!px-0 overflow-hidden h-full !py-0' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
show,
|
||||||
|
hide
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -53,6 +53,13 @@ vi.mock('@/stores/toastStore', () => ({
|
|||||||
}))
|
}))
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/workbench/extensions/manager/composables/useManagerDialog', () => ({
|
||||||
|
useManagerDialog: vi.fn(() => ({
|
||||||
|
show: vi.fn(),
|
||||||
|
hide: vi.fn()
|
||||||
|
}))
|
||||||
|
}))
|
||||||
|
|
||||||
describe('useManagerState', () => {
|
describe('useManagerState', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
|
|||||||