diff --git a/AGENTS.md b/AGENTS.md index 0d80ec8a0..ca0985a7e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -63,6 +63,9 @@ The project uses **Nx** for build orchestration and task management - Imports: - sorted/grouped by plugin - run `pnpm format` before committing + - use separate `import type` statements, not inline `type` in mixed imports + - ✅ `import type { Foo } from './foo'` + `import { bar } from './foo'` + - ❌ `import { bar, type Foo } from './foo'` - ESLint: - Vue + TS rules - no floating promises @@ -137,7 +140,7 @@ The project uses **Nx** for build orchestration and task management 8. Implement proper error handling 9. Follow Vue 3 style guide and naming conventions 10. Use Vite for fast development and building -11. Use vue-i18n in composition API for any string literals. Place new translation entries in src/locales/en/main.json +11. Use vue-i18n in composition API for any string literals. Place new translation entries in src/locales/en/main.json. Use the plurals system in i18n instead of hardcoding pluralization in templates. 12. Avoid new usage of PrimeVue components 13. Write tests for all changes, especially bug fixes to catch future regressions 14. Write code that is expressive and self-documenting to the furthest degree possible. This reduces the need for code comments which can get out of sync with the code itself. Try to avoid comments unless absolutely necessary diff --git a/src/components/common/StatusBadge.vue b/src/components/common/StatusBadge.vue new file mode 100644 index 000000000..46ef7ac79 --- /dev/null +++ b/src/components/common/StatusBadge.vue @@ -0,0 +1,30 @@ + + + diff --git a/src/components/toast/ProgressToastItem.vue b/src/components/toast/ProgressToastItem.vue new file mode 100644 index 000000000..495af2e24 --- /dev/null +++ b/src/components/toast/ProgressToastItem.vue @@ -0,0 +1,66 @@ + + + diff --git a/src/locales/en/main.json b/src/locales/en/main.json index 4cdaf3636..babf0113c 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -2480,5 +2480,21 @@ "help": { "recentReleases": "Recent releases", "helpCenterMenu": "Help Center Menu" + }, + "progressToast": { + "importingModels": "Importing Models", + "downloadingModel": "Downloading model...", + "downloadsFailed": "{count} downloads failed | {count} download failed | {count} downloads failed", + "allDownloadsCompleted": "All downloads completed", + "noImportsInQueue": "No {filter} in queue", + "failed": "Failed", + "finished": "Finished", + "pending": "Pending", + "progressCount": "{completed} of {total}", + "filter": { + "all": "All", + "completed": "Completed", + "failed": "Failed" + } } } \ No newline at end of file diff --git a/src/platform/assets/components/ModelImportProgressDialog.vue b/src/platform/assets/components/ModelImportProgressDialog.vue new file mode 100644 index 000000000..d5f8c8666 --- /dev/null +++ b/src/platform/assets/components/ModelImportProgressDialog.vue @@ -0,0 +1,271 @@ + + + diff --git a/src/stores/assetDownloadStore.ts b/src/stores/assetDownloadStore.ts index 8efffe0ee..91bf69015 100644 --- a/src/stores/assetDownloadStore.ts +++ b/src/stores/assetDownloadStore.ts @@ -1,13 +1,10 @@ import { defineStore } from 'pinia' import { computed, ref } from 'vue' -import { useEventListener } from '@vueuse/core' -import { st } from '@/i18n' -import { useToastStore } from '@/platform/updates/common/toastStore' import type { AssetDownloadWsMessage } from '@/schemas/apiSchema' import { api } from '@/scripts/api' -interface AssetDownload { +export interface AssetDownload { taskId: string assetId: string assetName: string @@ -24,32 +21,35 @@ interface CompletedDownload { timestamp: number } -const PROGRESS_TOAST_INTERVAL_MS = 5000 const PROCESSED_TASK_CLEANUP_MS = 60000 const MAX_COMPLETED_DOWNLOADS = 10 export const useAssetDownloadStore = defineStore('assetDownload', () => { - const toastStore = useToastStore() - /** Map of task IDs to their download progress data */ - const activeDownloads = ref>(new Map()) + const downloads = ref>(new Map()) /** Map of task IDs to model types, used to track which model type to refresh after download completes */ const pendingModelTypes = new Map() - /** Map of task IDs to timestamps, used to throttle progress toast notifications */ - const lastToastTime = new Map() - /** Set of task IDs that have reached a terminal state (completed/failed), prevents duplicate processing */ const processedTaskIds = new Set() /** Reactive signal for completed downloads */ const completedDownloads = ref([]) - const hasActiveDownloads = computed(() => activeDownloads.value.size > 0) - const downloadList = computed(() => - Array.from(activeDownloads.value.values()) + const downloadList = computed(() => Array.from(downloads.value.values())) + const activeDownloads = computed(() => + downloadList.value.filter( + (d) => d.status === 'created' || d.status === 'running' + ) ) + const finishedDownloads = computed(() => + downloadList.value.filter( + (d) => d.status === 'completed' || d.status === 'failed' + ) + ) + const hasActiveDownloads = computed(() => activeDownloads.value.length > 0) + const hasDownloads = computed(() => downloads.value.size > 0) /** * Associates a download task with its model type for later use when the download completes. @@ -82,19 +82,17 @@ export const useAssetDownloadStore = defineStore('assetDownload', () => { error: data.error } + downloads.value.set(data.task_id, download) + if (data.status === 'completed') { - activeDownloads.value.delete(data.task_id) - lastToastTime.delete(data.task_id) const modelType = pendingModelTypes.get(data.task_id) if (modelType) { - // Emit completed download signal for other stores to react to const newDownload: CompletedDownload = { taskId: data.task_id, modelType, timestamp: Date.now() } - // Keep only the last MAX_COMPLETED_DOWNLOADS items (FIFO) const updated = [...completedDownloads.value, newDownload] if (updated.length > MAX_COMPLETED_DOWNLOADS) { updated.shift() @@ -107,65 +105,31 @@ export const useAssetDownloadStore = defineStore('assetDownload', () => { () => processedTaskIds.delete(data.task_id), PROCESSED_TASK_CLEANUP_MS ) - toastStore.add({ - severity: 'success', - summary: st('assetBrowser.download.complete', 'Download complete'), - detail: data.asset_name, - life: 5000 - }) } else if (data.status === 'failed') { - activeDownloads.value.delete(data.task_id) - lastToastTime.delete(data.task_id) pendingModelTypes.delete(data.task_id) setTimeout( () => processedTaskIds.delete(data.task_id), PROCESSED_TASK_CLEANUP_MS ) - toastStore.add({ - severity: 'error', - summary: st('assetBrowser.download.failed', 'Download failed'), - detail: data.error || data.asset_name, - life: 8000 - }) - } else { - activeDownloads.value.set(data.task_id, download) - - const now = Date.now() - const lastTime = lastToastTime.get(data.task_id) ?? 0 - const shouldShowToast = now - lastTime >= PROGRESS_TOAST_INTERVAL_MS - - if (shouldShowToast) { - lastToastTime.set(data.task_id, now) - const progressPercent = Math.round(data.progress * 100) - toastStore.add({ - severity: 'info', - summary: st('assetBrowser.download.inProgress', 'Downloading...'), - detail: `${data.asset_name} (${progressPercent}%)`, - life: PROGRESS_TOAST_INTERVAL_MS, - closable: true - }) - } } } - let stopListener: (() => void) | undefined + api.addEventListener('asset_download', handleAssetDownload) - function setup() { - stopListener = useEventListener(api, 'asset_download', handleAssetDownload) - } - - function teardown() { - stopListener?.() - stopListener = undefined + function clearFinishedDownloads() { + for (const download of finishedDownloads.value) { + downloads.value.delete(download.taskId) + } } return { activeDownloads, + finishedDownloads, hasActiveDownloads, + hasDownloads, downloadList, completedDownloads, trackDownload, - setup, - teardown + clearFinishedDownloads } }) diff --git a/src/views/GraphView.vue b/src/views/GraphView.vue index 1bf5b7e42..e95257083 100644 --- a/src/views/GraphView.vue +++ b/src/views/GraphView.vue @@ -17,6 +17,7 @@ + @@ -49,6 +50,7 @@ import { useErrorHandling } from '@/composables/useErrorHandling' import { useProgressFavicon } from '@/composables/useProgressFavicon' import { SERVER_CONFIG_ITEMS } from '@/constants/serverConfig' import { i18n, loadLocale } from '@/i18n' +import ModelImportProgressDialog from '@/platform/assets/components/ModelImportProgressDialog.vue' import { isCloud } from '@/platform/distribution/types' import { useSettingStore } from '@/platform/settings/settingStore' import { useTelemetry } from '@/platform/telemetry' @@ -60,7 +62,6 @@ import { api } from '@/scripts/api' import { app } from '@/scripts/app' import { setupAutoQueueHandler } from '@/services/autoQueueService' import { useKeybindingService } from '@/services/keybindingService' -import { useAssetDownloadStore } from '@/stores/assetDownloadStore' import { useAssetsStore } from '@/stores/assetsStore' import { useCommandStore } from '@/stores/commandStore' import { useExecutionStore } from '@/stores/executionStore' @@ -88,7 +89,6 @@ const { t } = useI18n() const toast = useToast() const settingStore = useSettingStore() const executionStore = useExecutionStore() -const assetDownloadStore = useAssetDownloadStore() const colorPaletteStore = useColorPaletteStore() const queueStore = useQueueStore() const assetsStore = useAssetsStore() @@ -256,7 +256,6 @@ onMounted(() => { api.addEventListener('reconnecting', onReconnecting) api.addEventListener('reconnected', onReconnected) executionStore.bindExecutionEvents() - assetDownloadStore.setup() try { init() @@ -273,7 +272,6 @@ onBeforeUnmount(() => { api.removeEventListener('reconnecting', onReconnecting) api.removeEventListener('reconnected', onReconnected) executionStore.unbindExecutionEvents() - assetDownloadStore.teardown() // Clean up page visibility listener if (visibilityListener) {