diff --git a/.claude/commands/setup_repo.md b/.claude/commands/setup_repo.md index d82e22ec6..71dee96a5 100644 --- a/.claude/commands/setup_repo.md +++ b/.claude/commands/setup_repo.md @@ -122,7 +122,7 @@ echo " pnpm build - Build for production" echo " pnpm test:unit - Run unit tests" echo " pnpm typecheck - Run TypeScript checks" echo " pnpm lint - Run ESLint" -echo " pnpm format - Format code with Prettier" +echo " pnpm format - Format code with oxfmt" echo "" echo "Next steps:" echo "1. Run 'pnpm dev' to start developing" diff --git a/.github/workflows/ci-lint-format.yaml b/.github/workflows/ci-lint-format.yaml index 3ce6d6aa9..c97f6255c 100644 --- a/.github/workflows/ci-lint-format.yaml +++ b/.github/workflows/ci-lint-format.yaml @@ -42,7 +42,7 @@ jobs: - name: Run Stylelint with auto-fix run: pnpm stylelint:fix - - name: Run Prettier with auto-format + - name: Run oxfmt with auto-format run: pnpm format - name: Check for changes @@ -60,7 +60,7 @@ jobs: git config --local user.email "action@github.com" git config --local user.name "GitHub Action" git add . - git commit -m "[automated] Apply ESLint and Prettier fixes" + git commit -m "[automated] Apply ESLint and Oxfmt fixes" git push - name: Final validation @@ -80,7 +80,7 @@ jobs: issue_number: context.issue.number, owner: context.repo.owner, repo: context.repo.repo, - body: '## 🔧 Auto-fixes Applied\n\nThis PR has been automatically updated to fix linting and formatting issues.\n\n**⚠️ Important**: Your local branch is now behind. Run `git pull` before making additional changes to avoid conflicts.\n\n### Changes made:\n- ESLint auto-fixes\n- Prettier formatting' + body: '## 🔧 Auto-fixes Applied\n\nThis PR has been automatically updated to fix linting and formatting issues.\n\n**⚠️ Important**: Your local branch is now behind. Run `git pull` before making additional changes to avoid conflicts.\n\n### Changes made:\n- ESLint auto-fixes\n- Oxfmt formatting' }) - name: Comment on PR about manual fix needed diff --git a/.i18nrc.cjs b/.i18nrc.cjs index 86ce06eaa..4369f0a70 100644 --- a/.i18nrc.cjs +++ b/.i18nrc.cjs @@ -1,7 +1,7 @@ // This file is intentionally kept in CommonJS format (.cjs) // to resolve compatibility issues with dependencies that require CommonJS. // Do not convert this file to ESModule format unless all dependencies support it. -const { defineConfig } = require('@lobehub/i18n-cli'); +const { defineConfig } = require('@lobehub/i18n-cli') module.exports = defineConfig({ modelName: 'gpt-4.1', @@ -10,7 +10,19 @@ module.exports = defineConfig({ entry: 'src/locales/en', entryLocale: 'en', output: 'src/locales', - outputLocales: ['zh', 'zh-TW', 'ru', 'ja', 'ko', 'fr', 'es', 'ar', 'tr', 'pt-BR', 'fa'], + outputLocales: [ + 'zh', + 'zh-TW', + 'ru', + 'ja', + 'ko', + 'fr', + 'es', + 'ar', + 'tr', + 'pt-BR', + 'fa' + ], reference: `Special names to keep untranslated: flux, photomaker, clip, vae, cfg, stable audio, stable cascade, stable zero, controlnet, lora, HiDream, Civitai, Hugging Face. 'latent' is the short form of 'latent space'. 'mask' is in the context of image processing. @@ -26,4 +38,4 @@ module.exports = defineConfig({ - Use Arabic-Indic numerals (۰-۹) for numbers where appropriate. - Maintain consistency with terminology used in Persian software and design applications. ` -}); +}) diff --git a/.oxfmtrc.json b/.oxfmtrc.json new file mode 100644 index 000000000..5da4febe2 --- /dev/null +++ b/.oxfmtrc.json @@ -0,0 +1,20 @@ +{ + "$schema": "./node_modules/oxfmt/configuration_schema.json", + "singleQuote": true, + "tabWidth": 2, + "semi": false, + "trailingComma": "none", + "printWidth": 80, + "ignorePatterns": [ + "packages/registry-types/src/comfyRegistryTypes.ts", + "src/types/generatedManagerTypes.ts", + "**/*.md", + "**/*.json", + "**/*.css", + "**/*.yaml", + "**/*.yml", + "**/*.html", + "**/*.svg", + "**/*.xml" + ] +} diff --git a/.prettierignore b/.prettierignore deleted file mode 100644 index 4403edd8e..000000000 --- a/.prettierignore +++ /dev/null @@ -1,2 +0,0 @@ -packages/registry-types/src/comfyRegistryTypes.ts -src/types/generatedManagerTypes.ts diff --git a/.prettierrc b/.prettierrc deleted file mode 100644 index aa43a43ac..000000000 --- a/.prettierrc +++ /dev/null @@ -1,11 +0,0 @@ -{ - "singleQuote": true, - "tabWidth": 2, - "semi": false, - "trailingComma": "none", - "printWidth": 80, - "importOrder": ["^@core/(.*)$", "", "^@/(.*)$", "^[./]"], - "importOrderSeparation": true, - "importOrderSortSpecifiers": true, - "plugins": ["@prettier/plugin-oxc", "@trivago/prettier-plugin-sort-imports"] -} diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 54f28d400..9cbac42d7 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,25 +1,22 @@ { "recommendations": [ + "antfu.vite", "austenc.tailwind-docs", "bradlc.vscode-tailwindcss", "davidanson.vscode-markdownlint", "dbaeumer.vscode-eslint", + "donjayamanne.githistory", "eamodio.gitlens", - "esbenp.prettier-vscode", - "figma.figma-vscode-extension", "github.vscode-github-actions", "github.vscode-pull-request-github", "hbenl.vscode-test-explorer", + "kisstkondoros.vscode-codemetrics", "lokalise.i18n-ally", "ms-playwright.playwright", + "oxc.oxc-vscode", + "sonarsource.sonarlint-vscode", "vitest.explorer", "vue.volar", - "sonarsource.sonarlint-vscode", - "deque-systems.vscode-axe-linter", - "kisstkondoros.vscode-codemetrics", - "donjayamanne.githistory", - "wix.vscode-import-cost", - "prograhammer.tslint-vue", - "antfu.vite" + "wix.vscode-import-cost" ] } diff --git a/AGENTS.md b/AGENTS.md index da2953783..9938865a9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,10 +27,10 @@ See @docs/guidance/*.md for file-type-specific conventions (auto-loaded by glob) - Build output: `dist/` - Configs - `vite.config.mts` - - `vitest.config.ts` - `playwright.config.ts` - `eslint.config.ts` - - `.prettierrc` + - `.oxfmtrc.json` + - `.oxlintrc.json` - etc. ## Monorepo Architecture @@ -46,7 +46,7 @@ The project uses **Nx** for build orchestration and task management - `pnpm test:unit`: Run Vitest unit tests - `pnpm test:browser`: Run Playwright E2E tests (`browser_tests/`) - `pnpm lint` / `pnpm lint:fix`: Lint (ESLint) -- `pnpm format` / `pnpm format:check`: Prettier +- `pnpm format` / `pnpm format:check`: oxfmt - `pnpm typecheck`: Vue TSC type checking - `pnpm storybook`: Start Storybook development server @@ -72,7 +72,7 @@ The project uses **Nx** for build orchestration and task management - Composition API only - Tailwind 4 styling - Avoid ` diff --git a/src/components/common/WorkspaceProfilePic.vue b/src/components/common/WorkspaceProfilePic.vue new file mode 100644 index 000000000..642317267 --- /dev/null +++ b/src/components/common/WorkspaceProfilePic.vue @@ -0,0 +1,43 @@ + + + diff --git a/src/components/common/statusBadge.variants.ts b/src/components/common/statusBadge.variants.ts new file mode 100644 index 000000000..479a0dda8 --- /dev/null +++ b/src/components/common/statusBadge.variants.ts @@ -0,0 +1,26 @@ +import type { VariantProps } from 'cva' +import { cva } from 'cva' + +export const statusBadgeVariants = cva({ + base: 'inline-flex items-center justify-center rounded-full', + variants: { + severity: { + default: 'bg-primary-background text-base-foreground', + secondary: 'bg-secondary-background text-base-foreground', + warn: 'bg-warning-background text-base-background', + danger: 'bg-destructive-background text-white', + contrast: 'bg-base-foreground text-base-background' + }, + variant: { + label: 'h-3.5 px-1 text-xxxs font-semibold uppercase', + dot: 'size-2', + circle: 'size-3.5 text-xxxs font-semibold' + } + }, + defaultVariants: { + severity: 'default', + variant: 'label' + } +}) + +export type StatusBadgeVariants = VariantProps diff --git a/src/components/dialog/GlobalDialog.vue b/src/components/dialog/GlobalDialog.vue index afc056d61..2a1f0ef3d 100644 --- a/src/components/dialog/GlobalDialog.vue +++ b/src/components/dialog/GlobalDialog.vue @@ -4,7 +4,12 @@ v-for="item in dialogStore.dialogStack" :key="item.key" v-model:visible="item.visible" - class="global-dialog" + :class="[ + 'global-dialog', + item.key === 'global-settings' && teamWorkspacesEnabled + ? 'settings-dialog-workspace' + : '' + ]" v-bind="item.dialogComponentProps" :pt="item.dialogComponentProps.pt" :aria-labelledby="item.key" @@ -38,7 +43,15 @@ @@ -55,4 +68,27 @@ const dialogStore = useDialogStore() @apply p-2 2xl:p-[var(--p-dialog-content-padding)]; @apply pt-0; } + +/* Workspace mode: wider settings dialog */ +.settings-dialog-workspace { + width: 100%; + max-width: 1440px; +} + +.settings-dialog-workspace .p-dialog-content { + width: 100%; +} + +.manager-dialog { + height: 80vh; + max-width: 1724px; + max-height: 1026px; +} + +@media (min-width: 3000px) { + .manager-dialog { + max-width: 2200px; + max-height: 1320px; + } +} diff --git a/src/components/dialog/content/setting/SettingItem.test.ts b/src/components/dialog/content/setting/SettingItem.test.ts index 17e3bab06..673fe5894 100644 --- a/src/components/dialog/content/setting/SettingItem.test.ts +++ b/src/components/dialog/content/setting/SettingItem.test.ts @@ -18,7 +18,7 @@ vi.mock('@/utils/formatUtil', () => ({ })) describe('SettingItem', () => { - const mountComponent = (props: any, options = {}): any => { + const mountComponent = (props: Record, options = {}) => { return mount(SettingItem, { global: { plugins: [PrimeVue, i18n, createPinia()], @@ -32,6 +32,7 @@ describe('SettingItem', () => { 'i-material-symbols:experiment-outline': true } }, + // @ts-expect-error - Test utility accepts flexible props for testing edge cases props, ...options }) @@ -48,8 +49,9 @@ describe('SettingItem', () => { } }) - // Get the options property of the FormItem - const options = wrapper.vm.formItem.options + // Check the FormItem component's item prop for the options + const formItem = wrapper.findComponent({ name: 'FormItem' }) + const options = formItem.props('item').options expect(options).toEqual([ { text: 'Correctly Translated', value: 'Correctly Translated' } ]) @@ -67,7 +69,8 @@ describe('SettingItem', () => { }) // Should not throw an error and tooltip should be preserved as-is - expect(wrapper.vm.formItem.tooltip).toBe( + const formItem = wrapper.findComponent({ name: 'FormItem' }) + expect(formItem.props('item').tooltip).toBe( 'This will load a larger version of @mtb/markdown-parser that bundles shiki' ) }) diff --git a/src/components/dialog/content/setting/UsageLogsTable.test.ts b/src/components/dialog/content/setting/UsageLogsTable.test.ts index b98664668..72a4fbdba 100644 --- a/src/components/dialog/content/setting/UsageLogsTable.test.ts +++ b/src/components/dialog/content/setting/UsageLogsTable.test.ts @@ -12,6 +12,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { nextTick } from 'vue' import { createI18n } from 'vue-i18n' +import type { AuditLog } from '@/services/customerEventsService' import { EventType } from '@/services/customerEventsService' import UsageLogsTable from './UsageLogsTable.vue' @@ -19,7 +20,7 @@ import UsageLogsTable from './UsageLogsTable.vue' type ComponentInstance = InstanceType & { loading: boolean error: string | null - events: any[] + events: Partial[] pagination: { page: number limit: number diff --git a/src/components/dialog/content/setting/WorkspacePanel.vue b/src/components/dialog/content/setting/WorkspacePanel.vue new file mode 100644 index 000000000..aff8f3733 --- /dev/null +++ b/src/components/dialog/content/setting/WorkspacePanel.vue @@ -0,0 +1,11 @@ + + + diff --git a/src/components/dialog/content/setting/WorkspacePanelContent.vue b/src/components/dialog/content/setting/WorkspacePanelContent.vue new file mode 100644 index 000000000..9366a573f --- /dev/null +++ b/src/components/dialog/content/setting/WorkspacePanelContent.vue @@ -0,0 +1,163 @@ + + + diff --git a/src/components/dialog/content/setting/WorkspaceSidebarItem.vue b/src/components/dialog/content/setting/WorkspaceSidebarItem.vue new file mode 100644 index 000000000..cab92c7a8 --- /dev/null +++ b/src/components/dialog/content/setting/WorkspaceSidebarItem.vue @@ -0,0 +1,19 @@ + + + diff --git a/src/components/dialog/content/signin/ApiKeyForm.test.ts b/src/components/dialog/content/signin/ApiKeyForm.test.ts index 5d6a726b8..4d073cb08 100644 --- a/src/components/dialog/content/signin/ApiKeyForm.test.ts +++ b/src/components/dialog/content/signin/ApiKeyForm.test.ts @@ -1,3 +1,5 @@ +import type { ComponentProps } from 'vue-component-type-helpers' + import { Form } from '@primevue/forms' import { mount } from '@vue/test-utils' import { createPinia } from 'pinia' @@ -63,7 +65,7 @@ describe('ApiKeyForm', () => { mockLoading.mockReset() }) - const mountComponent = (props: any = {}) => { + const mountComponent = (props: ComponentProps = {}) => { return mount(ApiKeyForm, { global: { plugins: [PrimeVue, createPinia(), i18n], diff --git a/src/components/dialog/content/signin/SignInForm.test.ts b/src/components/dialog/content/signin/SignInForm.test.ts index da898532a..c27d15929 100644 --- a/src/components/dialog/content/signin/SignInForm.test.ts +++ b/src/components/dialog/content/signin/SignInForm.test.ts @@ -112,8 +112,10 @@ describe('SignInForm', () => { // Mock getElementById to track focus const mockFocus = vi.fn() - const mockElement = { focus: mockFocus } - vi.spyOn(document, 'getElementById').mockReturnValue(mockElement as any) + const mockElement: Partial = { focus: mockFocus } + vi.spyOn(document, 'getElementById').mockReturnValue( + mockElement as HTMLElement + ) // Click forgot password link while email is empty await forgotPasswordSpan.trigger('click') @@ -138,7 +140,10 @@ describe('SignInForm', () => { it('calls handleForgotPassword with email when link is clicked', async () => { const wrapper = mountComponent() - const component = wrapper.vm as any + const component = wrapper.vm as typeof wrapper.vm & { + handleForgotPassword: (email: string, valid: boolean) => void + onSubmit: (data: { valid: boolean; values: unknown }) => void + } // Spy on handleForgotPassword const handleForgotPasswordSpy = vi.spyOn( @@ -161,7 +166,10 @@ describe('SignInForm', () => { describe('Form Submission', () => { it('emits submit event when onSubmit is called with valid data', async () => { const wrapper = mountComponent() - const component = wrapper.vm as any + const component = wrapper.vm as typeof wrapper.vm & { + handleForgotPassword: (email: string, valid: boolean) => void + onSubmit: (data: { valid: boolean; values: unknown }) => void + } // Call onSubmit directly with valid data component.onSubmit({ @@ -181,7 +189,10 @@ describe('SignInForm', () => { it('does not emit submit event when form is invalid', async () => { const wrapper = mountComponent() - const component = wrapper.vm as any + const component = wrapper.vm as typeof wrapper.vm & { + handleForgotPassword: (email: string, valid: boolean) => void + onSubmit: (data: { valid: boolean; values: unknown }) => void + } // Call onSubmit with invalid form component.onSubmit({ valid: false, values: {} }) @@ -254,12 +265,17 @@ describe('SignInForm', () => { describe('Focus Behavior', () => { it('focuses email input when handleForgotPassword is called with invalid email', async () => { const wrapper = mountComponent() - const component = wrapper.vm as any + const component = wrapper.vm as typeof wrapper.vm & { + handleForgotPassword: (email: string, valid: boolean) => void + onSubmit: (data: { valid: boolean; values: unknown }) => void + } // Mock getElementById to track focus const mockFocus = vi.fn() - const mockElement = { focus: mockFocus } - vi.spyOn(document, 'getElementById').mockReturnValue(mockElement as any) + const mockElement: Partial = { focus: mockFocus } + vi.spyOn(document, 'getElementById').mockReturnValue( + mockElement as HTMLElement + ) // Call handleForgotPassword with no email await component.handleForgotPassword('', false) @@ -273,12 +289,17 @@ describe('SignInForm', () => { it('does not focus email input when valid email is provided', async () => { const wrapper = mountComponent() - const component = wrapper.vm as any + const component = wrapper.vm as typeof wrapper.vm & { + handleForgotPassword: (email: string, valid: boolean) => void + onSubmit: (data: { valid: boolean; values: unknown }) => void + } // Mock getElementById const mockFocus = vi.fn() - const mockElement = { focus: mockFocus } - vi.spyOn(document, 'getElementById').mockReturnValue(mockElement as any) + const mockElement: Partial = { focus: mockFocus } + vi.spyOn(document, 'getElementById').mockReturnValue( + mockElement as HTMLElement + ) // Call handleForgotPassword with valid email await component.handleForgotPassword('test@example.com', true) diff --git a/src/components/dialog/content/workspace/CreateWorkspaceDialogContent.vue b/src/components/dialog/content/workspace/CreateWorkspaceDialogContent.vue new file mode 100644 index 000000000..b9444ce58 --- /dev/null +++ b/src/components/dialog/content/workspace/CreateWorkspaceDialogContent.vue @@ -0,0 +1,113 @@ + + + diff --git a/src/components/dialog/content/workspace/DeleteWorkspaceDialogContent.vue b/src/components/dialog/content/workspace/DeleteWorkspaceDialogContent.vue new file mode 100644 index 000000000..dea2da18d --- /dev/null +++ b/src/components/dialog/content/workspace/DeleteWorkspaceDialogContent.vue @@ -0,0 +1,89 @@ + + + diff --git a/src/components/dialog/content/workspace/EditWorkspaceDialogContent.vue b/src/components/dialog/content/workspace/EditWorkspaceDialogContent.vue new file mode 100644 index 000000000..62b650a4e --- /dev/null +++ b/src/components/dialog/content/workspace/EditWorkspaceDialogContent.vue @@ -0,0 +1,104 @@ + + + diff --git a/src/components/dialog/content/workspace/LeaveWorkspaceDialogContent.vue b/src/components/dialog/content/workspace/LeaveWorkspaceDialogContent.vue new file mode 100644 index 000000000..6a3d16c36 --- /dev/null +++ b/src/components/dialog/content/workspace/LeaveWorkspaceDialogContent.vue @@ -0,0 +1,78 @@ + + + diff --git a/src/components/queue/QueueProgressOverlay.vue b/src/components/queue/QueueProgressOverlay.vue index d6b3edcd8..626709593 100644 --- a/src/components/queue/QueueProgressOverlay.vue +++ b/src/components/queue/QueueProgressOverlay.vue @@ -200,7 +200,13 @@ const onCancelItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => { if (item.state === 'running' || item.state === 'initialization') { // Running/initializing jobs: interrupt execution - await api.interrupt(promptId) + // Cloud backend uses deleteItem, local uses interrupt + if (isCloud) { + await api.deleteItem('queue', promptId) + } else { + await api.interrupt(promptId) + } + executionStore.clearInitializationByPromptId(promptId) await queueStore.update() } else if (item.state === 'pending') { // Pending jobs: remove from queue @@ -268,7 +274,15 @@ const inspectJobAsset = wrapWithErrorHandlingAsync( ) const cancelQueuedWorkflows = wrapWithErrorHandlingAsync(async () => { + // Capture pending promptIds before clearing + const pendingPromptIds = queueStore.pendingTasks + .map((task) => task.promptId) + .filter((id): id is string => typeof id === 'string' && id.length > 0) + await commandStore.execute('Comfy.ClearPendingTasks') + + // Clear initialization state for removed prompts + executionStore.clearInitializationByPromptIds(pendingPromptIds) }) const interruptAll = wrapWithErrorHandlingAsync(async () => { @@ -284,10 +298,14 @@ const interruptAll = wrapWithErrorHandlingAsync(async () => { // on cloud to ensure we cancel the workflow the user clicked. if (isCloud) { await Promise.all(promptIds.map((id) => api.deleteItem('queue', id))) + executionStore.clearInitializationByPromptIds(promptIds) + await queueStore.update() return } await Promise.all(promptIds.map((id) => api.interrupt(id))) + executionStore.clearInitializationByPromptIds(promptIds) + await queueStore.update() }) const showClearHistoryDialog = () => { diff --git a/src/components/rightSidePanel/layout/PropertiesAccordionItem.vue b/src/components/rightSidePanel/layout/PropertiesAccordionItem.vue index d3ee425dd..0b1b88820 100644 --- a/src/components/rightSidePanel/layout/PropertiesAccordionItem.vue +++ b/src/components/rightSidePanel/layout/PropertiesAccordionItem.vue @@ -5,25 +5,32 @@ import { cn } from '@/utils/tailwindUtil' import TransitionCollapse from './TransitionCollapse.vue' -const props = defineProps<{ +const { + disabled, + label, + enableEmptyState, + tooltip, + class: className +} = defineProps<{ disabled?: boolean label?: string enableEmptyState?: boolean tooltip?: string + class?: string }>() const isCollapse = defineModel('collapse', { default: false }) -const isExpanded = computed(() => !isCollapse.value && !props.disabled) +const isExpanded = computed(() => !isCollapse.value && !disabled) const tooltipConfig = computed(() => { - if (!props.tooltip) return undefined - return { value: props.tooltip, showDelay: 1000 } + if (!tooltip) return undefined + return { value: tooltip, showDelay: 1000 } }) diff --git a/src/components/ui/tags-input/TagsInputInput.vue b/src/components/ui/tags-input/TagsInputInput.vue index 62d3f01e8..320b89f97 100644 --- a/src/components/ui/tags-input/TagsInputInput.vue +++ b/src/components/ui/tags-input/TagsInputInput.vue @@ -23,6 +23,11 @@ const showInput = computed(() => isEditing.value || isEmpty) const { forwardRef, currentElement } = useForwardExpose() const registerFocus = inject(tagsInputFocusKey, undefined) +function handleEscape() { + currentElement.value?.blur() + isEditing.value = false +} + onMounted(() => { registerFocus?.(() => currentElement.value?.focus()) }) @@ -44,5 +49,6 @@ onUnmounted(() => { className ) " + @keydown.escape.stop="handleEscape" /> diff --git a/src/components/widget/layout/BaseModalLayout.vue b/src/components/widget/layout/BaseModalLayout.vue index 0da7755af..148ea3ccd 100644 --- a/src/components/widget/layout/BaseModalLayout.vue +++ b/src/components/widget/layout/BaseModalLayout.vue @@ -1,100 +1,128 @@ @@ -102,27 +130,29 @@ diff --git a/src/components/widget/nav/NavItem.vue b/src/components/widget/nav/NavItem.vue index 8c20ad929..eb20b9852 100644 --- a/src/components/widget/nav/NavItem.vue +++ b/src/components/widget/nav/NavItem.vue @@ -5,7 +5,7 @@ disabled: !isOverflowing, pt: { text: { class: 'whitespace-nowrap' } } }" - class="flex cursor-pointer items-start gap-2 rounded-md px-4 py-3 text-sm transition-colors text-base-foreground" + class="flex cursor-pointer items-center-safe gap-2 rounded-md px-4 py-3 text-sm transition-colors text-base-foreground" :class=" active ? 'bg-interface-menu-component-surface-selected' @@ -15,25 +15,32 @@ @mouseenter="checkOverflow" @click="onClick" > -
- -
+ - + + diff --git a/src/platform/assets/components/AssetBrowserModal.test.ts b/src/platform/assets/components/AssetBrowserModal.test.ts index 87135b468..6fb027519 100644 --- a/src/platform/assets/components/AssetBrowserModal.test.ts +++ b/src/platform/assets/components/AssetBrowserModal.test.ts @@ -6,6 +6,9 @@ import AssetBrowserModal from '@/platform/assets/components/AssetBrowserModal.vu import type { AssetItem } from '@/platform/assets/schemas/assetSchema' import { useAssetsStore } from '@/stores/assetsStore' +const mockAssetsByKey = vi.hoisted(() => new Map()) +const mockLoadingByKey = vi.hoisted(() => new Map()) + vi.mock('@/i18n', () => ({ t: (key: string, params?: Record) => params ? `${key}:${JSON.stringify(params)}` : key, @@ -13,13 +16,20 @@ vi.mock('@/i18n', () => ({ })) vi.mock('@/stores/assetsStore', () => { - const store = { - modelAssetsByNodeType: new Map(), - modelLoadingByNodeType: new Map(), - updateModelsForNodeType: vi.fn(), - updateModelsForTag: vi.fn() + const getAssets = vi.fn((key: string) => mockAssetsByKey.get(key) ?? []) + const isModelLoading = vi.fn( + (key: string) => mockLoadingByKey.get(key) ?? false + ) + const updateModelsForNodeType = vi.fn() + const updateModelsForTag = vi.fn() + return { + useAssetsStore: () => ({ + getAssets, + isModelLoading, + updateModelsForNodeType, + updateModelsForTag + }) } - return { useAssetsStore: () => store } }) vi.mock('@/stores/modelToNodeStore', () => ({ @@ -183,12 +193,10 @@ describe('AssetBrowserModal', () => { }) } - const mockStore = useAssetsStore() - beforeEach(() => { vi.resetAllMocks() - mockStore.modelAssetsByNodeType.clear() - mockStore.modelLoadingByNodeType.clear() + mockAssetsByKey.clear() + mockLoadingByKey.clear() }) describe('Integration with useAssetBrowser', () => { @@ -197,7 +205,7 @@ describe('AssetBrowserModal', () => { createTestAsset('asset1', 'Model A', 'checkpoints'), createTestAsset('asset2', 'Model B', 'loras') ] - mockStore.modelAssetsByNodeType.set('CheckpointLoaderSimple', assets) + mockAssetsByKey.set('CheckpointLoaderSimple', assets) const wrapper = createWrapper({ nodeType: 'CheckpointLoaderSimple' }) await flushPromises() @@ -214,7 +222,7 @@ describe('AssetBrowserModal', () => { createTestAsset('c1', 'model.safetensors', 'checkpoints'), createTestAsset('l1', 'lora.pt', 'loras') ] - mockStore.modelAssetsByNodeType.set('CheckpointLoaderSimple', assets) + mockAssetsByKey.set('CheckpointLoaderSimple', assets) const wrapper = createWrapper({ nodeType: 'CheckpointLoaderSimple', @@ -231,17 +239,18 @@ describe('AssetBrowserModal', () => { describe('Data fetching', () => { it('triggers store refresh for node type on mount', async () => { + const store = useAssetsStore() createWrapper({ nodeType: 'CheckpointLoaderSimple' }) await flushPromises() - expect(mockStore.updateModelsForNodeType).toHaveBeenCalledWith( + expect(store.updateModelsForNodeType).toHaveBeenCalledWith( 'CheckpointLoaderSimple' ) }) it('displays cached assets immediately from store', async () => { const assets = [createTestAsset('asset1', 'Cached Model', 'checkpoints')] - mockStore.modelAssetsByNodeType.set('CheckpointLoaderSimple', assets) + mockAssetsByKey.set('CheckpointLoaderSimple', assets) const wrapper = createWrapper({ nodeType: 'CheckpointLoaderSimple' }) @@ -253,15 +262,16 @@ describe('AssetBrowserModal', () => { }) it('triggers store refresh for asset type (tag) on mount', async () => { + const store = useAssetsStore() createWrapper({ assetType: 'models' }) await flushPromises() - expect(mockStore.updateModelsForTag).toHaveBeenCalledWith('models') + expect(store.updateModelsForTag).toHaveBeenCalledWith('models') }) it('uses tag: prefix for cache key when assetType is provided', async () => { const assets = [createTestAsset('asset1', 'Tagged Model', 'models')] - mockStore.modelAssetsByNodeType.set('tag:models', assets) + mockAssetsByKey.set('tag:models', assets) const wrapper = createWrapper({ assetType: 'models' }) await flushPromises() @@ -277,7 +287,7 @@ describe('AssetBrowserModal', () => { describe('Asset Selection', () => { it('emits asset-select event when asset is selected', async () => { const assets = [createTestAsset('asset1', 'Model A', 'checkpoints')] - mockStore.modelAssetsByNodeType.set('CheckpointLoaderSimple', assets) + mockAssetsByKey.set('CheckpointLoaderSimple', assets) const wrapper = createWrapper({ nodeType: 'CheckpointLoaderSimple' }) await flushPromises() @@ -290,7 +300,7 @@ describe('AssetBrowserModal', () => { it('executes onSelect callback when provided', async () => { const assets = [createTestAsset('asset1', 'Model A', 'checkpoints')] - mockStore.modelAssetsByNodeType.set('CheckpointLoaderSimple', assets) + mockAssetsByKey.set('CheckpointLoaderSimple', assets) const onSelect = vi.fn() const wrapper = createWrapper({ @@ -333,7 +343,7 @@ describe('AssetBrowserModal', () => { createTestAsset('asset1', 'Model A', 'checkpoints'), createTestAsset('asset2', 'Model B', 'loras') ] - mockStore.modelAssetsByNodeType.set('CheckpointLoaderSimple', assets) + mockAssetsByKey.set('CheckpointLoaderSimple', assets) const wrapper = createWrapper({ nodeType: 'CheckpointLoaderSimple', @@ -366,7 +376,7 @@ describe('AssetBrowserModal', () => { it('passes computed contentTitle to BaseModalLayout when no title prop', async () => { const assets = [createTestAsset('asset1', 'Model A', 'checkpoints')] - mockStore.modelAssetsByNodeType.set('CheckpointLoaderSimple', assets) + mockAssetsByKey.set('CheckpointLoaderSimple', assets) const wrapper = createWrapper({ nodeType: 'CheckpointLoaderSimple' }) await flushPromises() diff --git a/src/platform/assets/components/AssetBrowserModal.vue b/src/platform/assets/components/AssetBrowserModal.vue index b7db7494c..2b8529333 100644 --- a/src/platform/assets/components/AssetBrowserModal.vue +++ b/src/platform/assets/components/AssetBrowserModal.vue @@ -1,18 +1,20 @@ diff --git a/src/platform/assets/components/AssetCard.vue b/src/platform/assets/components/AssetCard.vue index 11e404d41..ccaf546c1 100644 --- a/src/platform/assets/components/AssetCard.vue +++ b/src/platform/assets/components/AssetCard.vue @@ -9,30 +9,28 @@ cn( 'rounded-2xl overflow-hidden transition-all duration-200 bg-modal-card-background p-2 gap-2 flex flex-col h-full', interactive && - 'group appearance-none bg-transparent m-0 outline-none text-left hover:bg-secondary-background focus:bg-secondary-background border-none focus:outline-solid outline-base-foreground outline-4' + 'group appearance-none bg-transparent m-0 outline-none text-left hover:bg-secondary-background focus:bg-secondary-background border-none focus:outline-solid outline-base-foreground outline-4', + focused && 'bg-secondary-background outline-solid' ) " + @click.stop="interactive && $emit('focus', asset)" + @focus="interactive && $emit('focus', asset)" @keydown.enter.self="interactive && $emit('select', asset)" >
- + + diff --git a/src/platform/assets/components/modelInfo/ModelInfoPanel.test.ts b/src/platform/assets/components/modelInfo/ModelInfoPanel.test.ts new file mode 100644 index 000000000..981c1a01e --- /dev/null +++ b/src/platform/assets/components/modelInfo/ModelInfoPanel.test.ts @@ -0,0 +1,171 @@ +import { mount } from '@vue/test-utils' +import { createTestingPinia } from '@pinia/testing' +import { describe, expect, it, vi } from 'vitest' +import { createI18n } from 'vue-i18n' + +import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser' + +import ModelInfoPanel from './ModelInfoPanel.vue' + +vi.mock('@/composables/useCopyToClipboard', () => ({ + useCopyToClipboard: () => ({ + copyToClipboard: vi.fn() + }) +})) + +const i18n = createI18n({ + legacy: false, + locale: 'en', + messages: { en: {} }, + missingWarn: false, + fallbackWarn: false +}) + +describe('ModelInfoPanel', () => { + const createMockAsset = ( + overrides: Partial = {} + ): AssetDisplayItem => ({ + id: 'test-id', + name: 'test-model.safetensors', + asset_hash: 'hash123', + size: 1024, + mime_type: 'application/octet-stream', + tags: ['models', 'checkpoints'], + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + last_access_time: '2024-01-01T00:00:00Z', + description: 'A test model description', + badges: [], + stats: {}, + ...overrides + }) + + const mountPanel = (asset: AssetDisplayItem) => { + return mount(ModelInfoPanel, { + props: { asset }, + global: { + plugins: [createTestingPinia({ stubActions: false }), i18n] + } + }) + } + + describe('Basic Info Section', () => { + it('renders basic info section', () => { + const wrapper = mountPanel(createMockAsset()) + expect(wrapper.text()).toContain('assetBrowser.modelInfo.basicInfo') + }) + + it('displays asset filename', () => { + const asset = createMockAsset({ name: 'my-model.safetensors' }) + const wrapper = mountPanel(asset) + expect(wrapper.text()).toContain('my-model.safetensors') + }) + + it('displays name from user_metadata when present', () => { + const asset = createMockAsset({ + user_metadata: { name: 'My Custom Model' } + }) + const wrapper = mountPanel(asset) + expect(wrapper.text()).toContain('My Custom Model') + }) + + it('falls back to asset name when user_metadata.name not present', () => { + const asset = createMockAsset({ name: 'fallback-model.safetensors' }) + const wrapper = mountPanel(asset) + expect(wrapper.text()).toContain('fallback-model.safetensors') + }) + + it('renders source link when source_arn is present', () => { + const asset = createMockAsset({ + user_metadata: { source_arn: 'civitai:model:123:version:456' } + }) + const wrapper = mountPanel(asset) + const link = wrapper.find( + 'a[href="https://civitai.com/models/123?modelVersionId=456"]' + ) + expect(link.exists()).toBe(true) + expect(link.attributes('target')).toBe('_blank') + }) + + it('displays Civitai icon for Civitai source', () => { + const asset = createMockAsset({ + user_metadata: { source_arn: 'civitai:model:123:version:456' } + }) + const wrapper = mountPanel(asset) + expect( + wrapper.find('img[src="/assets/images/civitai.svg"]').exists() + ).toBe(true) + }) + + it('does not render source field when source_arn is absent', () => { + const asset = createMockAsset() + const wrapper = mountPanel(asset) + const links = wrapper.findAll('a') + expect(links).toHaveLength(0) + }) + }) + + describe('Model Tagging Section', () => { + it('renders model tagging section', () => { + const wrapper = mountPanel(createMockAsset()) + expect(wrapper.text()).toContain('assetBrowser.modelInfo.modelTagging') + }) + + it('renders model type field', () => { + const wrapper = mountPanel(createMockAsset()) + expect(wrapper.text()).toContain('assetBrowser.modelInfo.modelType') + }) + + it('renders base models field', () => { + const asset = createMockAsset({ + user_metadata: { base_model: ['SDXL'] } + }) + const wrapper = mountPanel(asset) + expect(wrapper.text()).toContain( + 'assetBrowser.modelInfo.compatibleBaseModels' + ) + }) + + it('renders additional tags field', () => { + const wrapper = mountPanel(createMockAsset()) + expect(wrapper.text()).toContain('assetBrowser.modelInfo.additionalTags') + }) + }) + + describe('Model Description Section', () => { + it('renders trigger phrases when present', () => { + const asset = createMockAsset({ + user_metadata: { trained_words: ['trigger1', 'trigger2'] } + }) + const wrapper = mountPanel(asset) + expect(wrapper.text()).toContain('trigger1') + expect(wrapper.text()).toContain('trigger2') + }) + + it('renders description section', () => { + const wrapper = mountPanel(createMockAsset()) + expect(wrapper.text()).toContain( + 'assetBrowser.modelInfo.modelDescription' + ) + }) + + it('does not render trigger phrases field when empty', () => { + const asset = createMockAsset() + const wrapper = mountPanel(asset) + expect(wrapper.text()).not.toContain( + 'assetBrowser.modelInfo.triggerPhrases' + ) + }) + }) + + describe('Accordion Structure', () => { + it('renders all three section labels', () => { + const wrapper = mountPanel(createMockAsset()) + expect(wrapper.text()).toContain('assetBrowser.modelInfo.basicInfo') + expect(wrapper.text()).toContain('assetBrowser.modelInfo.modelTagging') + expect(wrapper.text()).toContain( + 'assetBrowser.modelInfo.modelDescription' + ) + }) + }) +}) diff --git a/src/platform/assets/components/modelInfo/ModelInfoPanel.vue b/src/platform/assets/components/modelInfo/ModelInfoPanel.vue new file mode 100644 index 000000000..834f33afa --- /dev/null +++ b/src/platform/assets/components/modelInfo/ModelInfoPanel.vue @@ -0,0 +1,344 @@ +