-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
{{ t('favorites.dialog.reproducibility.exampleParametersLabel') }}
+
+
+
+ {{ parameterKey }}
+
+
+
+ {{ t('favorites.dialog.reproducibility.remove') }}
+
+
+
+
+
+
+
+ {{ t('favorites.dialog.reproducibility.addParameter') }}
+
+
+
+
+
+
@@ -198,18 +383,44 @@ import {
NSelect,
NSpace,
NText,
+ NUpload,
+ type UploadFileInfo,
} from 'naive-ui'
-import { computed } from 'vue'
+import { computed, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import type {
FavoriteReproducibilityExample,
FavoriteReproducibilityVariable,
} from '../utils/favorite-reproducibility'
+import AppPreviewImage from './media/AppPreviewImage.vue'
+import AppPreviewImageGroup from './media/AppPreviewImageGroup.vue'
+
+type FavoriteReproducibilityImagePreview = {
+ assetId: string
+ source: string
+}
+
+type FavoriteReproducibilityExamplePreviews = {
+ images: FavoriteReproducibilityImagePreview[]
+ inputImages: FavoriteReproducibilityImagePreview[]
+}
+
+type ExampleImageField = 'images' | 'inputImages'
+type ExampleAssetField = 'imageAssetIds' | 'inputImageAssetIds'
+type ExampleImageItem = {
+ key: string
+ source: string
+ displayIndex: number
+ kind: 'source' | 'asset'
+ sourceIndex?: number
+ assetId?: string
+}
const props = defineProps<{
variables: FavoriteReproducibilityVariable[]
examples: FavoriteReproducibilityExample[]
+ examplePreviews?: FavoriteReproducibilityExamplePreviews[]
}>()
const emit = defineEmits<{
@@ -230,8 +441,19 @@ const hasReproducibilityData = computed(
() => props.variables.length > 0 || props.examples.length > 0,
)
-const formatList = (items: string[] | undefined) => (items || []).join('\n')
+let uploadSequence = 0
+let exampleKeySequence = 0
+let lastEmittedExamples: FavoriteReproducibilityExample[] | null = null
+const exampleDraftKeys = ref
([])
+const imageUrlDrafts = reactive>({})
+const parameterDrafts = reactive>({})
+
const formatOptions = (items: string[] | undefined) => (items || []).join(', ')
+const dedupeStrings = (items: string[]) => Array.from(new Set(items.filter(Boolean)))
+const assetFieldByImageField: Record = {
+ images: 'imageAssetIds',
+ inputImages: 'inputImageAssetIds',
+}
const parseListText = (value: string): string[] => {
return String(value || '')
@@ -240,29 +462,129 @@ const parseListText = (value: string): string[] => {
.filter(Boolean)
}
-const formatParameters = (parameters: Record | undefined) => {
- return Object.entries(parameters || {})
- .map(([key, value]) => `${key}=${value}`)
- .join('\n')
+const getParameterEntries = (parameters: Record | undefined) =>
+ Object.entries(parameters || {})
+
+const getParameterDraft = (index: number) => {
+ parameterDrafts[index] ||= { key: '', value: '' }
+ return parameterDrafts[index]
}
-const parseParametersText = (value: string): Record => {
- const parameters: Record = {}
- String(value || '')
- .split(/\r?\n/u)
- .forEach((line) => {
- const trimmed = line.trim()
- if (!trimmed) return
- const separatorIndex = trimmed.indexOf('=')
- if (separatorIndex < 0) {
- parameters[trimmed] = ''
- return
+const setParameterDraft = (
+ index: number,
+ field: 'key' | 'value',
+ value: string,
+) => {
+ const draft = getParameterDraft(index)
+ draft[field] = value
+}
+
+const getImageUrlDraftKey = (index: number, field: ExampleImageField) => `${index}:${field}`
+
+const createExampleDraftKey = () => `example-${++exampleKeySequence}`
+
+const clearExampleDrafts = () => {
+ Object.keys(parameterDrafts).forEach((key) => {
+ delete parameterDrafts[Number(key)]
+ })
+ Object.keys(imageUrlDrafts).forEach((key) => {
+ delete imageUrlDrafts[key]
+ })
+}
+
+const emitExamples = (
+ examples: FavoriteReproducibilityExample[],
+ draftKeys = exampleDraftKeys.value,
+) => {
+ exampleDraftKeys.value = draftKeys
+ lastEmittedExamples = examples
+ emit('update:examples', examples)
+}
+
+watch(
+ () => props.examples,
+ (examples) => {
+ if (examples === lastEmittedExamples) {
+ lastEmittedExamples = null
+ if (exampleDraftKeys.value.length !== examples.length) {
+ exampleDraftKeys.value = examples.map(() => createExampleDraftKey())
}
- const key = trimmed.slice(0, separatorIndex).trim()
- if (!key) return
- parameters[key] = trimmed.slice(separatorIndex + 1).trim()
- })
- return parameters
+ return
+ }
+
+ uploadSequence += 1
+ exampleDraftKeys.value = examples.map(() => createExampleDraftKey())
+ clearExampleDrafts()
+ },
+ { immediate: true },
+)
+
+const handleAddExampleParameter = (index: number) => {
+ const draft = getParameterDraft(index)
+ const key = draft.key.trim()
+ if (!key) return
+
+ const example = props.examples[index]
+ if (!example) return
+
+ updateExample(index, {
+ parameters: {
+ ...example.parameters,
+ [key]: draft.value,
+ },
+ })
+ parameterDrafts[index] = { key: '', value: '' }
+}
+
+const handleUpdateExampleParameterValue = (
+ index: number,
+ key: string,
+ value: string,
+) => {
+ const example = props.examples[index]
+ if (!example) return
+
+ updateExample(index, {
+ parameters: {
+ ...example.parameters,
+ [key]: value,
+ },
+ })
+}
+
+const handleRemoveExampleParameter = (index: number, key: string) => {
+ const example = props.examples[index]
+ if (!example) return
+
+ const nextParameters = { ...example.parameters }
+ delete nextParameters[key]
+ updateExample(index, { parameters: nextParameters })
+}
+
+const removeExampleDrafts = (removedIndex: number) => {
+ const nextParameterDrafts: Record = {}
+ Object.entries(parameterDrafts).forEach(([indexText, draft]) => {
+ const index = Number(indexText)
+ if (!Number.isInteger(index) || index === removedIndex) return
+ nextParameterDrafts[index > removedIndex ? index - 1 : index] = draft
+ })
+ Object.keys(parameterDrafts).forEach((key) => {
+ delete parameterDrafts[Number(key)]
+ })
+ Object.assign(parameterDrafts, nextParameterDrafts)
+
+ const nextImageUrlDrafts: Record = {}
+ Object.entries(imageUrlDrafts).forEach(([key, value]) => {
+ const match = key.match(/^(\d+):(images|inputImages)$/u)
+ if (!match) return
+ const index = Number(match[1])
+ if (index === removedIndex) return
+ nextImageUrlDrafts[getImageUrlDraftKey(index > removedIndex ? index - 1 : index, match[2] as ExampleImageField)] = value
+ })
+ Object.keys(imageUrlDrafts).forEach((key) => {
+ delete imageUrlDrafts[key]
+ })
+ Object.assign(imageUrlDrafts, nextImageUrlDrafts)
}
const handleAddVariable = () => {
@@ -293,7 +615,7 @@ const handleRemoveVariable = (index: number) => {
}
const handleAddExample = () => {
- emit('update:examples', [
+ emitExamples([
...props.examples,
{
parameters: {},
@@ -302,6 +624,9 @@ const handleAddExample = () => {
inputImages: [],
inputImageAssetIds: [],
},
+ ], [
+ ...exampleDraftKeys.value,
+ createExampleDraftKey(),
])
}
@@ -309,16 +634,123 @@ const updateExample = (
index: number,
patch: Partial,
) => {
- emit(
- 'update:examples',
+ emitExamples(
props.examples.map((example, currentIndex) =>
currentIndex === index ? { ...example, ...patch } : example,
),
)
}
+const getImageUrlDraft = (index: number, field: ExampleImageField) =>
+ imageUrlDrafts[getImageUrlDraftKey(index, field)] || ''
+
+const setImageUrlDraft = (index: number, field: ExampleImageField, value: string) => {
+ imageUrlDrafts[getImageUrlDraftKey(index, field)] = value
+}
+
+const handleAddExampleImageUrl = (index: number, field: ExampleImageField) => {
+ const value = getImageUrlDraft(index, field).trim()
+ const example = props.examples[index]
+ if (!example || !value) return
+
+ updateExample(index, {
+ [field]: dedupeStrings([...(example[field] || []), value]),
+ })
+ imageUrlDrafts[getImageUrlDraftKey(index, field)] = ''
+}
+
+const getExampleImageItems = (
+ index: number,
+ field: ExampleImageField,
+ example: FavoriteReproducibilityExample,
+) => {
+ const assetField = assetFieldByImageField[field]
+ const existingAssetIds = new Set(example[assetField] || [])
+ const sourceItems: ExampleImageItem[] = (example[field] || []).map((source, sourceIndex) => ({
+ key: `${field}-source-${sourceIndex}-${source.slice(0, 24)}`,
+ source,
+ displayIndex: sourceIndex,
+ kind: 'source',
+ sourceIndex,
+ }))
+ const assetItems: ExampleImageItem[] = (props.examplePreviews?.[index]?.[field] || [])
+ .filter((item) => existingAssetIds.has(item.assetId))
+ .map((item, previewIndex) => ({
+ key: `${field}-asset-${item.assetId}`,
+ source: item.source,
+ displayIndex: sourceItems.length + previewIndex,
+ kind: 'asset',
+ assetId: item.assetId,
+ }))
+
+ return [...sourceItems, ...assetItems]
+}
+
+const handleRemoveExampleImage = (
+ index: number,
+ field: ExampleImageField,
+ item: ExampleImageItem,
+) => {
+ const example = props.examples[index]
+ if (!example) return
+
+ if (item.kind === 'asset' && item.assetId) {
+ const assetField = assetFieldByImageField[field]
+ updateExample(index, {
+ [assetField]: (example[assetField] || []).filter((assetId) => assetId !== item.assetId),
+ })
+ return
+ }
+
+ if (typeof item.sourceIndex === 'number') {
+ updateExample(index, {
+ [field]: (example[field] || []).filter((_, currentIndex) => currentIndex !== item.sourceIndex),
+ })
+ }
+}
+
const handleRemoveExample = (index: number) => {
- emit('update:examples', props.examples.filter((_, currentIndex) => currentIndex !== index))
+ removeExampleDrafts(index)
+ emitExamples(
+ props.examples.filter((_, currentIndex) => currentIndex !== index),
+ exampleDraftKeys.value.filter((_, currentIndex) => currentIndex !== index),
+ )
+}
+
+const readBlobAsDataUrl = (blob: Blob) =>
+ new Promise((resolve, reject) => {
+ const reader = new FileReader()
+ reader.onerror = () => reject(new Error('Failed to read image file'))
+ reader.onload = () => resolve(String(reader.result || ''))
+ reader.readAsDataURL(blob)
+ })
+
+const handleBeforeExampleImageUpload = async (
+ index: number,
+ field: ExampleImageField,
+ options: { file: UploadFileInfo },
+) => {
+ const raw = (options.file as unknown as { file?: Blob | null }).file
+ if (!raw) return false
+
+ const requestId = uploadSequence
+ const targetDraftKey = exampleDraftKeys.value[index]
+ try {
+ const dataUrl = await readBlobAsDataUrl(raw)
+ if (requestId !== uploadSequence) return false
+ if (targetDraftKey !== exampleDraftKeys.value[index]) return false
+
+ const example = props.examples[index]
+ if (!example || !dataUrl) return false
+
+ updateExample(index, {
+ [field]: dedupeStrings([...(example[field] || []), dataUrl]),
+ })
+ } catch (error) {
+ console.error('[FavoriteReproducibilityEditor] Failed to read selected example image:', error)
+ }
+
+ return false
}
@@ -358,6 +790,36 @@ const handleRemoveExample = (index: number) => {
background: var(--n-color);
}
+.favorite-reproducibility-editor__example {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.favorite-reproducibility-editor__example-header {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.favorite-reproducibility-editor__example-basic {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 8px;
+}
+
+.favorite-reproducibility-editor__example-description,
+.favorite-reproducibility-editor__example-section {
+ grid-column: 1 / -1;
+}
+
+.favorite-reproducibility-editor__example-media-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 12px;
+}
+
.favorite-reproducibility-editor__item-actions {
margin-top: 10px;
}
@@ -365,4 +827,80 @@ const handleRemoveExample = (index: number) => {
.favorite-reproducibility-editor__item-actions--end {
justify-content: flex-end;
}
+
+.favorite-reproducibility-editor__parameter-field,
+.favorite-reproducibility-editor__image-field {
+ display: flex;
+ min-width: 0;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.favorite-reproducibility-editor__parameter-row {
+ display: grid;
+ min-width: 0;
+ grid-template-columns: minmax(92px, 0.8fr) minmax(0, 1.2fr) auto;
+ gap: 6px;
+ align-items: center;
+}
+
+.favorite-reproducibility-editor__parameter-key {
+ min-width: 0;
+ padding: 5px 8px;
+ border: 1px solid var(--n-border-color);
+ border-radius: 6px;
+ background: var(--n-color-embedded);
+ overflow-wrap: anywhere;
+}
+
+.favorite-reproducibility-editor__image-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(76px, 1fr));
+ gap: 6px;
+}
+
+.favorite-reproducibility-editor__image-toolbar :deep(.n-input) {
+ min-width: 160px;
+ flex: 1 1 180px;
+}
+
+.favorite-reproducibility-editor__image-action {
+ flex: 0 0 auto;
+}
+
+.favorite-reproducibility-editor__image-item {
+ position: relative;
+ min-width: 0;
+}
+
+.favorite-reproducibility-editor__image {
+ width: 100%;
+ aspect-ratio: 1;
+ border-radius: 6px;
+ overflow: hidden;
+ background: var(--n-color-embedded);
+}
+
+.favorite-reproducibility-editor__image-remove {
+ position: absolute;
+ top: 4px;
+ right: 4px;
+ width: 22px;
+ height: 22px;
+ min-width: 22px;
+ background: var(--n-color);
+ border-radius: 999px;
+ opacity: 0.92;
+}
+
+@media (max-width: 767px) {
+ .favorite-reproducibility-editor__example-basic,
+ .favorite-reproducibility-editor__example-media-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .favorite-reproducibility-editor__parameter-row {
+ grid-template-columns: 1fr;
+ }
+}
diff --git a/packages/ui/src/components/FavoriteWorkspaceListItem.vue b/packages/ui/src/components/FavoriteWorkspaceListItem.vue
index d35db31c..4d290fd0 100644
--- a/packages/ui/src/components/FavoriteWorkspaceListItem.vue
+++ b/packages/ui/src/components/FavoriteWorkspaceListItem.vue
@@ -6,6 +6,7 @@
'favorite-workspace-list-item--with-actions': showQuickActions,
'favorite-workspace-list-item--card': variant === 'card',
}"
+ data-testid="favorite-workspace-list-item"
role="button"
tabindex="0"
@click="handleItemClick"
@@ -80,7 +81,7 @@
-
+
diff --git a/packages/ui/src/components/GardenSnapshotPreview.vue b/packages/ui/src/components/GardenSnapshotPreview.vue
index b7ffb84f..37141e7a 100644
--- a/packages/ui/src/components/GardenSnapshotPreview.vue
+++ b/packages/ui/src/components/GardenSnapshotPreview.vue
@@ -1,14 +1,46 @@
-
+
-
+
{{ t('favorites.manager.preview.garden.snapshotTitle') }}
{{ t('favorites.manager.preview.garden.snapshotHint') }}
+
+
+
+ {{ snapshot.importCode }}
+
+
+
+ {{ snapshot.gardenBaseUrl }}
+
+
+
+ {{ snapshot.schema }}
+
+ v{{ snapshot.schemaVersion }}
+
+
+
+
+
@@ -205,7 +237,7 @@
@@ -284,7 +316,7 @@
@@ -369,9 +401,13 @@ const props = withDefaults(defineProps<{
snapshot: GardenSnapshotPreview
editable?: boolean
busy?: boolean
+ hiddenSections?: SectionKey[]
+ sourceOnly?: boolean
}>(), {
editable: false,
busy: false,
+ hiddenSections: () => [],
+ sourceOnly: false,
})
const emit = defineEmits<{
@@ -382,13 +418,16 @@ const emit = defineEmits<{
const { t } = useI18n()
const expandedSections = ref([...SECTION_KEYS])
+const hiddenSectionSet = computed(() => new Set(props.hiddenSections))
+
+const isSectionHidden = (section: SectionKey) => hiddenSectionSet.value.has(section)
const showBasicInfo = computed(() => {
- return Boolean(props.snapshot.importCode || props.snapshot.gardenBaseUrl || props.snapshot.schema)
+ return !isSectionHidden('basicInfo') && Boolean(props.snapshot.importCode || props.snapshot.gardenBaseUrl || props.snapshot.schema)
})
const showMeta = computed(() => {
- return Boolean(
+ return !isSectionHidden('metaInfo') && Boolean(
props.snapshot.meta.title ||
props.snapshot.meta.description ||
props.snapshot.meta.tags.length > 0,
@@ -396,11 +435,19 @@ const showMeta = computed(() => {
})
const showCoverSection = computed(() => {
- return Boolean(props.snapshot.coverUrl || props.editable)
+ return !isSectionHidden('cover') && Boolean(props.snapshot.coverUrl || props.editable)
})
const showShowcasesSection = computed(() => {
- return Boolean(props.snapshot.showcases.length > 0 || props.editable)
+ return !isSectionHidden('showcases') && Boolean(props.snapshot.showcases.length > 0 || props.editable)
+})
+
+const showExamplesSection = computed(() => {
+ return !isSectionHidden('examples') && props.snapshot.examples.length > 0
+})
+
+const showVariablesSection = computed(() => {
+ return !isSectionHidden('variables') && props.snapshot.variables.length > 0
})
const parameterEntries = (asset: GardenSnapshotPreviewAsset): Array<[string, string]> => {
diff --git a/packages/ui/src/components/PromptGardenFavoritePreviewPanel.vue b/packages/ui/src/components/PromptGardenFavoritePreviewPanel.vue
index c1abc8f2..408f49f6 100644
--- a/packages/ui/src/components/PromptGardenFavoritePreviewPanel.vue
+++ b/packages/ui/src/components/PromptGardenFavoritePreviewPanel.vue
@@ -4,6 +4,8 @@
:snapshot="snapshot"
editable
:busy="isSaving"
+ :hidden-sections="gardenSnapshotHiddenSections"
+ :source-only="gardenSnapshotSourceOnly"
@upload-cover="handleGardenCoverUpload"
@append-showcase-images="handleGardenShowcaseUpload"
/>
@@ -30,6 +32,8 @@ import GardenSnapshotPreview from './GardenSnapshotPreview.vue'
const props = defineProps<{
favorite: FavoritePrompt
+ gardenSnapshotHiddenSections?: Array<'basicInfo' | 'metaInfo' | 'cover' | 'showcases' | 'examples' | 'variables'>
+ gardenSnapshotSourceOnly?: boolean
}>()
const emit = defineEmits<{
diff --git a/packages/ui/src/components/app-layout/PromptOptimizerApp.vue b/packages/ui/src/components/app-layout/PromptOptimizerApp.vue
index afa4da4a..a9801df8 100644
--- a/packages/ui/src/components/app-layout/PromptOptimizerApp.vue
+++ b/packages/ui/src/components/app-layout/PromptOptimizerApp.vue
@@ -243,6 +243,7 @@ import {
normalizeWorkspacePath,
parseWorkspaceRoutePath,
} from '../../router/workspaceRoutes';
+import { createExternalDataLoadingGate } from '../../utils/external-data-loading'
import { registerOptionalIntegrations } from '../../integrations/registerOptionalIntegrations';
import { useI18n } from "vue-i18n";
import {
@@ -614,7 +615,8 @@ const hasRestoredInitialState = ref(false);
// ✅ 外部数据加载中标志(防止模式切换的自动 restore 覆盖外部数据)
// 适用场景:历史记录恢复、收藏加载、模板导入等任何外部数据加载导致模式切换的情况
-const isLoadingExternalData = ref(false);
+const externalDataLoadingGate = createExternalDataLoadingGate();
+const isLoadingExternalData = externalDataLoadingGate.isLoading;
// 5. 控制主UI渲染的标志
// 🔧 必须等待路由初始化完成,避免短暂显示根路径的空白页
@@ -1585,8 +1587,11 @@ const navigateToSubModeKeyCompat = (
toKey: string,
opts?: { replace?: boolean },
) => {
- if (!WORKSPACE_SUB_MODE_KEYS.includes(toKey as SubModeKey)) return Promise.resolve();
- return navigateToSubModeKey(toKey as SubModeKey, opts).then(() => undefined);
+ if (!WORKSPACE_SUB_MODE_KEYS.includes(toKey as SubModeKey)) {
+ console.warn(`[PromptOptimizerApp] Invalid workspace sub mode key: ${toKey}`);
+ return Promise.resolve(false);
+ }
+ return navigateToSubModeKey(toKey as SubModeKey, opts).then(() => true);
};
const optimizerPrompt = computed({
@@ -1623,6 +1628,13 @@ const {
optimizerPrompt,
t,
isLoadingExternalData,
+ proMultiMessageSession,
+ proVariableSession,
+ imageText2ImageSession,
+ imageImage2ImageSession,
+ imageMultiImageSession,
+ getFavoriteImageStorageService:
+ () => services.value?.favoriteImageStorageService || services.value?.imageStorageService || null,
});
const resolveFavoritesReturnPath = () =>
@@ -1653,8 +1665,11 @@ const returnToWorkspace = () => {
void routerInstance.push(resolveFavoritesReturnPath());
};
-const handleUseFavoriteFromPage = async (favorite: FavoritePrompt): Promise => {
- const used = await handleUseFavorite(favorite);
+const handleUseFavoriteFromPage = async (
+ favorite: FavoritePrompt,
+ options?: { applyExample?: boolean; exampleId?: string; exampleIndex?: number },
+): Promise => {
+ const used = await handleUseFavorite(favorite, options);
// handleUseFavorite awaits target workspace navigation. This fallback only covers
// legacy/non-navigating favorite payloads that still leave the page route active.
diff --git a/packages/ui/src/components/favorites/FavoritesPage.vue b/packages/ui/src/components/favorites/FavoritesPage.vue
index bcb3f933..796667f2 100644
--- a/packages/ui/src/components/favorites/FavoritesPage.vue
+++ b/packages/ui/src/components/favorites/FavoritesPage.vue
@@ -127,9 +127,12 @@ const handleReturnToWorkspace = () => {
void routerInstance.push(DEFAULT_WORKSPACE_PATH)
}
-const handleUseFavorite = async (favorite: FavoritePrompt): Promise => {
+const handleUseFavorite = async (
+ favorite: FavoritePrompt,
+ options?: { applyExample?: boolean; exampleId?: string; exampleIndex?: number },
+): Promise => {
if (actions) {
- return await actions.useFavorite(favorite)
+ return await actions.useFavorite(favorite, options)
}
return false
diff --git a/packages/ui/src/components/favorites/favorites-page-context.ts b/packages/ui/src/components/favorites/favorites-page-context.ts
index 7c9f18e2..e45112ee 100644
--- a/packages/ui/src/components/favorites/favorites-page-context.ts
+++ b/packages/ui/src/components/favorites/favorites-page-context.ts
@@ -2,7 +2,7 @@ import type { InjectionKey } from 'vue'
import type { FavoritePrompt } from '@prompt-optimizer/core'
export interface FavoritesPageActions {
- useFavorite: (favorite: FavoritePrompt) => boolean | Promise
+ useFavorite: (favorite: FavoritePrompt, options?: { applyExample?: boolean; exampleId?: string; exampleIndex?: number }) => Promise
returnToWorkspace: () => void
}
diff --git a/packages/ui/src/composables/app/useAppFavorite.ts b/packages/ui/src/composables/app/useAppFavorite.ts
index 9a3cf68d..b77c4566 100644
--- a/packages/ui/src/composables/app/useAppFavorite.ts
+++ b/packages/ui/src/composables/app/useAppFavorite.ts
@@ -9,7 +9,17 @@
import { ref, nextTick, type Ref } from 'vue'
import { useToast } from '../ui/useToast'
-import type { BasicSubMode, ProSubMode, ContextMode, OptimizationMode } from '@prompt-optimizer/core'
+import type { BasicSubMode, ProSubMode, ContextMode, IImageStorageService, OptimizationMode } from '@prompt-optimizer/core'
+import { isValidVariableName } from '../../types/variable'
+import {
+ parseFavoriteReproducibility,
+ type FavoriteReproducibilityExample,
+} from '../../utils/favorite-reproducibility'
+import {
+ normalizeImageSourceToPayload,
+ resolveAssetIdToDataUrl,
+ type ImagePayload,
+} from '../../utils/image-asset-storage'
/**
* 保存收藏的数据结构
@@ -40,12 +50,33 @@ export interface FavoriteItem {
metadata?: Record
}
+export interface UseFavoriteOptions {
+ applyExample?: boolean
+ exampleId?: string
+ exampleIndex?: number
+}
+
+type TemporaryVariablesSessionApi = {
+ getTemporaryVariable: (name: string) => string | undefined
+ setTemporaryVariable: (name: string, value: string) => void
+ clearTemporaryVariables: () => void
+ updatePrompt?: (prompt: string) => void
+}
+
+type Image2ImageExampleSessionApi = TemporaryVariablesSessionApi & {
+ updateInputImage: (b64: string | null, mimeType?: string) => void
+}
+
+type MultiImageExampleSessionApi = TemporaryVariablesSessionApi & {
+ replaceInputImages: (images: ImagePayload[]) => void
+}
+
/**
* useAppFavorite 的配置选项
*/
export interface AppFavoriteOptions {
/** 🔧 Step D: 路由导航函数(替代 setFunctionMode/set*SubMode) */
- navigateToSubModeKey: (toKey: string, opts?: { replace?: boolean }) => void | Promise
+ navigateToSubModeKey: (toKey: string, opts?: { replace?: boolean }) => boolean | void | Promise
/** 处理上下文模式变更 */
handleContextModeChange: (mode: ContextMode) => Promise
/** 优化器提示词(用于设置收藏内容) */
@@ -54,6 +85,13 @@ export interface AppFavoriteOptions {
t: (key: string, params?: Record) => string
/** 外部数据加载中标志(防止模式切换的自动 restore 覆盖外部数据) */
isLoadingExternalData: Ref
+ /** 高级/图像模式临时变量会话,用于应用收藏示例参数 */
+ proMultiMessageSession?: TemporaryVariablesSessionApi
+ proVariableSession?: TemporaryVariablesSessionApi
+ imageText2ImageSession?: TemporaryVariablesSessionApi
+ imageImage2ImageSession?: Image2ImageExampleSessionApi
+ imageMultiImageSession?: MultiImageExampleSessionApi
+ getFavoriteImageStorageService?: () => IImageStorageService | null
}
/**
@@ -73,7 +111,45 @@ export interface AppFavoriteReturn {
/** 处理收藏优化提示词 */
handleFavoriteOptimizePrompt: () => void
/** 处理使用收藏 */
- handleUseFavorite: (favorite: FavoriteItem) => Promise
+ handleUseFavorite: (favorite: FavoriteItem, options?: UseFavoriteOptions) => Promise
+}
+
+const getFavoriteTargetKey = (favorite: FavoriteItem): string | null => {
+ const favFunctionMode = favorite.functionMode
+ if (favFunctionMode === 'image') {
+ return `image-${favorite.imageSubMode || 'text2image'}`
+ }
+
+ if (favFunctionMode === 'basic' || favFunctionMode === 'context' || favFunctionMode === 'pro') {
+ const targetFunctionMode = (favFunctionMode === 'context' || favFunctionMode === 'pro') ? 'pro' : 'basic'
+ if (targetFunctionMode === 'pro') {
+ const mode = favorite.optimizationMode ?? 'user'
+ return mode === 'system' ? 'pro-multi' : 'pro-variable'
+ }
+
+ return `basic-${favorite.optimizationMode ?? 'system'}`
+ }
+
+ return null
+}
+
+const pickFavoriteExample = (
+ examples: FavoriteReproducibilityExample[],
+ options: UseFavoriteOptions,
+): FavoriteReproducibilityExample | null => {
+ if (!options.applyExample || examples.length === 0) return null
+
+ const id = String(options.exampleId || '').trim()
+ if (id) {
+ const found = examples.find((example) => (example.id || '').trim() === id)
+ if (found) return found
+ }
+
+ if (typeof options.exampleIndex === 'number' && Number.isInteger(options.exampleIndex)) {
+ return examples[options.exampleIndex] || null
+ }
+
+ return examples[0] || null
}
/**
@@ -86,6 +162,12 @@ export function useAppFavorite(options: AppFavoriteOptions): AppFavoriteReturn {
optimizerPrompt,
t,
isLoadingExternalData,
+ proMultiMessageSession,
+ proVariableSession,
+ imageText2ImageSession,
+ imageImage2ImageSession,
+ imageMultiImageSession,
+ getFavoriteImageStorageService,
} = options
const toast = useToast()
@@ -132,7 +214,102 @@ export function useAppFavorite(options: AppFavoriteOptions): AppFavoriteReturn {
/**
* 处理使用收藏 - 智能模式切换(内部实现)
*/
- const handleUseFavoriteImpl = async (favorite: FavoriteItem): Promise => {
+ const getTemporaryVariablesSession = (targetKey: string | null): TemporaryVariablesSessionApi | null => {
+ switch (targetKey) {
+ case 'pro-multi':
+ return proMultiMessageSession || null
+ case 'pro-variable':
+ return proVariableSession || null
+ case 'image-text2image':
+ return imageText2ImageSession || null
+ case 'image-image2image':
+ return imageImage2ImageSession || null
+ case 'image-multiimage':
+ return imageMultiImageSession || null
+ default:
+ return null
+ }
+ }
+
+ const resolveExampleInputImages = async (example: FavoriteReproducibilityExample): Promise => {
+ const storageService = getFavoriteImageStorageService?.() || null
+ const sources = [...example.inputImages]
+
+ if (storageService) {
+ for (const assetId of example.inputImageAssetIds) {
+ try {
+ const dataUrl = await resolveAssetIdToDataUrl(assetId, storageService)
+ if (dataUrl) sources.push(dataUrl)
+ } catch (error) {
+ console.warn('[App] Failed to resolve favorite example input image:', error)
+ }
+ }
+ }
+
+ const images: ImagePayload[] = []
+ for (const source of sources) {
+ try {
+ const payload = await normalizeImageSourceToPayload(source)
+ if (payload) images.push(payload)
+ } catch (error) {
+ console.warn('[App] Failed to load favorite example input image:', error)
+ }
+ }
+
+ return images
+ }
+
+ const applyFavoriteExample = async (
+ favorite: FavoriteItem,
+ targetKey: string | null,
+ useOptions: UseFavoriteOptions,
+ ) => {
+ if (!useOptions.applyExample) return
+
+ const reproducibility = parseFavoriteReproducibility(favorite)
+ const example = pickFavoriteExample(reproducibility.examples, useOptions)
+ if (!example) return
+
+ const session = getTemporaryVariablesSession(targetKey)
+ if (session) {
+ const variableEntries = reproducibility.variables
+ .map((variable) => ({
+ name: variable.name.trim(),
+ value: variable.defaultValue !== undefined ? String(variable.defaultValue) : '',
+ }))
+ .filter((variable) => isValidVariableName(variable.name))
+
+ const variableNames = new Set(variableEntries.map((variable) => variable.name))
+ session.clearTemporaryVariables()
+
+ for (const { name, value } of variableEntries) {
+ session.setTemporaryVariable(name, value)
+ }
+
+ for (const [key, value] of Object.entries(example.parameters)) {
+ const name = key.trim()
+ if (!isValidVariableName(name)) continue
+ if (variableNames.size > 0 && !variableNames.has(name)) continue
+ session.setTemporaryVariable(name, String(value))
+ }
+ }
+
+ if (targetKey === 'image-image2image' && imageImage2ImageSession) {
+ const [firstImage] = await resolveExampleInputImages(example)
+ if (firstImage) {
+ imageImage2ImageSession.updateInputImage(firstImage.b64, firstImage.mimeType)
+ }
+ }
+
+ if (targetKey === 'image-multiimage' && imageMultiImageSession) {
+ const images = await resolveExampleInputImages(example)
+ if (images.length > 0) {
+ imageMultiImageSession.replaceInputImages(images)
+ }
+ }
+ }
+
+ const handleUseFavoriteImpl = async (favorite: FavoriteItem, useOptions: UseFavoriteOptions = {}): Promise => {
const {
functionMode: favFunctionMode,
optimizationMode: favOptimizationMode,
@@ -147,7 +324,8 @@ export function useAppFavorite(options: AppFavoriteOptions): AppFavoriteReturn {
const targetSubMode = favImageSubMode || 'text2image'
const targetKey = `image-${targetSubMode}`
- await navigateToSubModeKey(targetKey)
+ const didNavigate = await navigateToSubModeKey(targetKey)
+ if (didNavigate === false) return false
toast.info(t('toast.info.switchedToImageMode'))
await nextTick()
@@ -165,6 +343,8 @@ export function useAppFavorite(options: AppFavoriteOptions): AppFavoriteReturn {
)
}
+ await applyFavoriteExample(favorite, targetKey, useOptions)
+
toast.success(t('toast.success.imageFavoriteLoaded'))
} else if (favFunctionMode === 'basic' || favFunctionMode === 'context' || favFunctionMode === 'pro') {
// 基础模式或上下文模式
@@ -186,7 +366,8 @@ export function useAppFavorite(options: AppFavoriteOptions): AppFavoriteReturn {
// 3. 一次性导航到目标路由
const targetKey = `${targetFunctionMode}-${targetSubMode}`
- await navigateToSubModeKey(targetKey)
+ const didNavigate = await navigateToSubModeKey(targetKey)
+ if (didNavigate === false) return false
await nextTick()
@@ -214,9 +395,15 @@ export function useAppFavorite(options: AppFavoriteOptions): AppFavoriteReturn {
// 5. 将收藏的提示词内容设置到输入框
optimizerPrompt.value = favorite.content
+ if (targetKey === 'pro-variable') {
+ proVariableSession?.updatePrompt?.(favorite.content)
+ }
+ await applyFavoriteExample(favorite, targetKey, useOptions)
} else {
// 其他情况:直接设置内容,不切换模式
optimizerPrompt.value = favorite.content
+ // 未知模式无法可靠定位目标 session,仅保留正文应用行为。
+ await applyFavoriteExample(favorite, null, useOptions)
}
// 关闭收藏管理对话框
@@ -231,12 +418,12 @@ export function useAppFavorite(options: AppFavoriteOptions): AppFavoriteReturn {
/**
* 收藏加载的错误处理包装器
*/
- const handleUseFavorite = async (favorite: FavoriteItem): Promise => {
+ const handleUseFavorite = async (favorite: FavoriteItem, useOptions: UseFavoriteOptions = {}): Promise => {
try {
// 🔧 设置外部数据加载标志,防止模式切换的自动 restore 覆盖外部数据
isLoadingExternalData.value = true
- return await handleUseFavoriteImpl(favorite)
+ return await handleUseFavoriteImpl(favorite, useOptions)
} catch (error) {
// 捕获收藏加载过程中的所有错误
console.error('[App] Failed to load favorite:', error)
diff --git a/packages/ui/src/composables/app/useAppHistoryRestore.ts b/packages/ui/src/composables/app/useAppHistoryRestore.ts
index a9ac84bc..7af10b3f 100644
--- a/packages/ui/src/composables/app/useAppHistoryRestore.ts
+++ b/packages/ui/src/composables/app/useAppHistoryRestore.ts
@@ -47,7 +47,7 @@ export interface AppHistoryRestoreOptions {
/** 服务实例 */
services: Ref<{ historyManager: IHistoryManager } | null>
/** 🔧 Step D: 路由导航函数(替代 setFunctionMode/set*SubMode) */
- navigateToSubModeKey: (toKey: string, opts?: { replace?: boolean }) => void | Promise
+ navigateToSubModeKey: (toKey: string, opts?: { replace?: boolean }) => boolean | void | Promise
/** 处理上下文模式变更 */
handleContextModeChange: (mode: ContextMode) => Promise
/** 处理历史记录选择 */
@@ -133,7 +133,10 @@ export function useAppHistoryRestore(options: AppHistoryRestoreOptions): AppHist
: 'text2image' // 默认为文生图模式
// 🔧 Step D: 使用 navigateToSubModeKey 替代 setImageSubMode
- await navigateToSubModeKey(`image-${imageMode}`)
+ const didNavigate = await navigateToSubModeKey(`image-${imageMode}`)
+ if (didNavigate === false) {
+ throw new Error(`Invalid image workspace target: image-${imageMode}`)
+ }
toast.info(t('toast.info.switchedToImageMode'))
// 🆕 图像模式专用数据回填逻辑
@@ -189,7 +192,10 @@ export function useAppHistoryRestore(options: AppHistoryRestoreOptions): AppHist
targetFunctionMode === 'pro'
? `pro-${targetMode === 'system' ? 'multi' : 'variable'}`
: `basic-${targetMode}`
- await navigateToSubModeKey(targetKey)
+ const didNavigate = await navigateToSubModeKey(targetKey)
+ if (didNavigate === false) {
+ throw new Error(`Invalid workspace target: ${targetKey}`)
+ }
// 等待路由切换完成
await nextTick()
diff --git a/packages/ui/src/composables/ui/useToast.ts b/packages/ui/src/composables/ui/useToast.ts
index 6851da43..1e5e92c7 100644
--- a/packages/ui/src/composables/ui/useToast.ts
+++ b/packages/ui/src/composables/ui/useToast.ts
@@ -26,9 +26,6 @@ type ToastOptions = number | MessageOptions
export function useToast() {
const getMessageApi = (): MessageApi | null => {
- if (!globalMessageApi) {
- console.warn('[useToast] NMessageProvider context not available yet.')
- }
return globalMessageApi
}
diff --git a/packages/ui/src/i18n/locales/en-US/favorites.ts b/packages/ui/src/i18n/locales/en-US/favorites.ts
index 244e7de8..2d6ad608 100644
--- a/packages/ui/src/i18n/locales/en-US/favorites.ts
+++ b/packages/ui/src/i18n/locales/en-US/favorites.ts
@@ -152,7 +152,8 @@ const messages = {
"exampleLabel": "Example #{index}",
"parameters": "Parameters",
"images": "Images",
- "inputImages": "Input images"
+ "inputImages": "Input images",
+ "applyExample": "Use this example"
}
},
"messages": {
@@ -313,10 +314,20 @@ const messages = {
"required": "Required",
"remove": "Remove",
"exampleIdPlaceholder": "Example ID (optional)",
+ "exampleLabel": "Example #{index}",
"exampleTextPlaceholder": "Example name or note (optional)",
"exampleDescriptionPlaceholder": "Example description (optional)",
- "exampleParametersPlaceholder": "Parameters, one key=value per line",
- "exampleInputImagesPlaceholder": "Input image URLs, one per line",
+ "exampleParametersLabel": "Example variable values",
+ "parameterNamePlaceholder": "Variable name",
+ "parameterValuePlaceholder": "Variable value",
+ "addParameter": "Add value",
+ "exampleImages": "Example/output images",
+ "exampleInputImages": "Input images",
+ "exampleImagesPlaceholder": "Paste an image link",
+ "exampleInputImagesPlaceholder": "Paste an image link",
+ "addImageUrl": "Add link",
+ "addExampleImages": "Upload example images",
+ "addExampleInputImages": "Upload input images",
"variableType": {
"string": "Text",
"number": "Number",
diff --git a/packages/ui/src/i18n/locales/zh-CN/favorites.ts b/packages/ui/src/i18n/locales/zh-CN/favorites.ts
index 810ec122..efab493e 100644
--- a/packages/ui/src/i18n/locales/zh-CN/favorites.ts
+++ b/packages/ui/src/i18n/locales/zh-CN/favorites.ts
@@ -152,7 +152,8 @@ const messages = {
"exampleLabel": "示例 #{index}",
"parameters": "参数",
"images": "图片",
- "inputImages": "输入图"
+ "inputImages": "输入图",
+ "applyExample": "应用此示例"
}
},
"messages": {
@@ -313,10 +314,20 @@ const messages = {
"required": "必填",
"remove": "移除",
"exampleIdPlaceholder": "示例 ID(可选)",
+ "exampleLabel": "示例 #{index}",
"exampleTextPlaceholder": "示例名称或说明(可选)",
"exampleDescriptionPlaceholder": "示例描述(可选)",
- "exampleParametersPlaceholder": "参数,每行 key=value",
- "exampleInputImagesPlaceholder": "输入图片 URL,每行一个",
+ "exampleParametersLabel": "示例变量取值",
+ "parameterNamePlaceholder": "变量名",
+ "parameterValuePlaceholder": "变量取值",
+ "addParameter": "添加取值",
+ "exampleImages": "示例/输出图片",
+ "exampleInputImages": "输入图片",
+ "exampleImagesPlaceholder": "粘贴图片链接",
+ "exampleInputImagesPlaceholder": "粘贴图片链接",
+ "addImageUrl": "添加链接",
+ "addExampleImages": "上传示例图片",
+ "addExampleInputImages": "上传输入图片",
"variableType": {
"string": "文本",
"number": "数字",
diff --git a/packages/ui/src/i18n/locales/zh-TW/favorites.ts b/packages/ui/src/i18n/locales/zh-TW/favorites.ts
index ddf71550..d20b142d 100644
--- a/packages/ui/src/i18n/locales/zh-TW/favorites.ts
+++ b/packages/ui/src/i18n/locales/zh-TW/favorites.ts
@@ -152,7 +152,8 @@ const messages = {
"exampleLabel": "範例 #{index}",
"parameters": "參數",
"images": "圖片",
- "inputImages": "輸入圖"
+ "inputImages": "輸入圖",
+ "applyExample": "套用此範例"
}
},
"messages": {
@@ -313,10 +314,20 @@ const messages = {
"required": "必填",
"remove": "移除",
"exampleIdPlaceholder": "範例 ID(可選)",
+ "exampleLabel": "範例 #{index}",
"exampleTextPlaceholder": "範例名稱或說明(可選)",
"exampleDescriptionPlaceholder": "範例描述(可選)",
- "exampleParametersPlaceholder": "參數,每行 key=value",
- "exampleInputImagesPlaceholder": "輸入圖片 URL,每行一個",
+ "exampleParametersLabel": "範例變數取值",
+ "parameterNamePlaceholder": "變數名",
+ "parameterValuePlaceholder": "變數取值",
+ "addParameter": "新增取值",
+ "exampleImages": "範例/輸出圖片",
+ "exampleInputImages": "輸入圖片",
+ "exampleImagesPlaceholder": "貼上圖片連結",
+ "exampleInputImagesPlaceholder": "貼上圖片連結",
+ "addImageUrl": "新增連結",
+ "addExampleImages": "上傳範例圖片",
+ "addExampleInputImages": "上傳輸入圖片",
"variableType": {
"string": "文字",
"number": "數字",
diff --git a/packages/ui/src/router/RootBootstrapRoute.ts b/packages/ui/src/router/RootBootstrapRoute.ts
index b6c59ceb..7efb761b 100644
--- a/packages/ui/src/router/RootBootstrapRoute.ts
+++ b/packages/ui/src/router/RootBootstrapRoute.ts
@@ -1,6 +1,7 @@
import { defineComponent, h, watchEffect } from 'vue'
import { useRouter } from 'vue-router'
import { useGlobalSettings, type GlobalSettingsApi } from '../stores/settings/useGlobalSettings'
+import { DEFAULT_WORKSPACE_PATH } from './workspaceRoutes'
export const getInitialRouteFromGlobalSettings = (globalSettings: GlobalSettingsApi) => {
const { functionMode, basicSubMode, proSubMode, imageSubMode } = globalSettings.state
@@ -13,7 +14,7 @@ export const getInitialRouteFromGlobalSettings = (globalSettings: GlobalSettings
case 'image':
return `/image/${imageSubMode}`
default:
- return '/basic/system'
+ return DEFAULT_WORKSPACE_PATH
}
}
diff --git a/packages/ui/src/router/guards.ts b/packages/ui/src/router/guards.ts
index 2dd9f35b..2c51bd22 100644
--- a/packages/ui/src/router/guards.ts
+++ b/packages/ui/src/router/guards.ts
@@ -26,23 +26,23 @@ export const parseSubModeKey = (path: string): SubModeKey | null => {
export const beforeRouteSwitch: NavigationGuard = (to) => {
// ✅ 兼容旧 pro 路由(/pro/system|/pro/user -> /pro/multi|/pro/variable)
if (to.path === '/pro/system') {
- return '/pro/multi'
+ return { path: '/pro/multi', query: to.query, hash: to.hash }
}
if (to.path === '/pro/user') {
- return '/pro/variable'
+ return { path: '/pro/variable', query: to.query, hash: to.hash }
}
const subModeKey = parseSubModeKey(to.path)
if (subModeKey === null && to.path !== '/') {
- const match = to.path.match(/^\/(basic|pro|image)/)
+ const match = to.path.match(/^\/(basic|pro|image)(\/|$)/)
if (match) {
const mode = match[1] as WorkspaceMode
const defaultSubMode = getDefaultSubModeForWorkspaceMode(mode)
console.warn(`[Router] Invalid subMode: ${to.path}. Redirecting to /${mode}/${defaultSubMode}`)
- return `/${mode}/${defaultSubMode}`
+ return { path: `/${mode}/${defaultSubMode}`, query: to.query, hash: to.hash }
}
}
diff --git a/packages/ui/src/utils/external-data-loading.ts b/packages/ui/src/utils/external-data-loading.ts
new file mode 100644
index 00000000..7323c4eb
--- /dev/null
+++ b/packages/ui/src/utils/external-data-loading.ts
@@ -0,0 +1,22 @@
+import { computed, ref } from 'vue'
+
+export const createExternalDataLoadingGate = () => {
+ const depth = ref(0)
+
+ const isLoading = computed({
+ get: () => depth.value > 0,
+ set: (value) => {
+ if (value) {
+ depth.value += 1
+ return
+ }
+
+ depth.value = Math.max(0, depth.value - 1)
+ },
+ })
+
+ return {
+ isLoading,
+ depth,
+ }
+}
diff --git a/packages/ui/src/utils/favorite-asset-maintenance.ts b/packages/ui/src/utils/favorite-asset-maintenance.ts
index a957d164..c717ee84 100644
--- a/packages/ui/src/utils/favorite-asset-maintenance.ts
+++ b/packages/ui/src/utils/favorite-asset-maintenance.ts
@@ -46,19 +46,8 @@ const collectFavoriteAssetIds = (
const gardenSnapshot = isRecord(favorite.metadata.gardenSnapshot)
? favorite.metadata.gardenSnapshot
: null
- if (!gardenSnapshot || !isRecord(gardenSnapshot.assets)) {
- return assetIds
- }
- const cover = isRecord(gardenSnapshot.assets.cover) ? gardenSnapshot.assets.cover : null
- if (cover) {
- const coverAssetId = typeof cover.assetId === 'string' ? cover.assetId.trim() : ''
- if (coverAssetId) {
- assetIds.add(coverAssetId)
- }
- }
-
- const collectFromSnapshotItems = (items: unknown) => {
+ const collectFromExampleItems = (items: unknown) => {
if (!Array.isArray(items)) return
items.forEach((item) => {
@@ -69,8 +58,27 @@ const collectFavoriteAssetIds = (
})
}
- collectFromSnapshotItems(gardenSnapshot.assets.showcases)
- collectFromSnapshotItems(gardenSnapshot.assets.examples)
+ if (gardenSnapshot && isRecord(gardenSnapshot.assets)) {
+ const cover = isRecord(gardenSnapshot.assets.cover) ? gardenSnapshot.assets.cover : null
+ if (cover) {
+ const coverAssetId = typeof cover.assetId === 'string' ? cover.assetId.trim() : ''
+ if (coverAssetId) {
+ assetIds.add(coverAssetId)
+ }
+ }
+
+ collectFromExampleItems(gardenSnapshot.assets.showcases)
+ collectFromExampleItems(gardenSnapshot.assets.examples)
+ }
+
+ const reproducibility = isRecord(favorite.metadata.reproducibility)
+ ? favorite.metadata.reproducibility
+ : null
+ if (reproducibility) {
+ collectFromExampleItems(reproducibility.examples)
+ }
+
+ collectFromExampleItems(favorite.metadata.examples)
return assetIds
}
diff --git a/packages/ui/tests/unit/components/FavoriteCard.spec.ts b/packages/ui/tests/unit/components/FavoriteCard.spec.ts
deleted file mode 100644
index 94883295..00000000
--- a/packages/ui/tests/unit/components/FavoriteCard.spec.ts
+++ /dev/null
@@ -1,185 +0,0 @@
-import { beforeEach, describe, expect, it, vi } from 'vitest'
-import { flushPromises, mount } from '@vue/test-utils'
-import { ref } from 'vue'
-
-import type { FavoriteCategory, FavoritePrompt } from '@prompt-optimizer/core'
-
-import FavoriteCard from '../../../src/components/FavoriteCard.vue'
-
-const parseFavoriteMediaMetadataMock = vi.fn()
-const resolveAssetIdToDataUrlMock = vi.fn()
-
-vi.mock('../../../src/utils/favorite-media', () => ({
- parseFavoriteMediaMetadata: (...args: unknown[]) => parseFavoriteMediaMetadataMock(...args),
-}))
-
-vi.mock('../../../src/utils/image-asset-storage', () => ({
- resolveAssetIdToDataUrl: (...args: unknown[]) => resolveAssetIdToDataUrlMock(...args),
-}))
-
-const naiveStubs = {
- NCard: {
- name: 'NCard',
- template: `
-
-
-
-
-
-
- `,
- emits: ['click'],
- },
- NSpace: {
- name: 'NSpace',
- template: '
',
- props: ['vertical', 'size', 'align', 'justify', 'wrap', 'class'],
- },
- NTag: {
- name: 'NTag',
- template: '',
- props: ['type', 'size', 'bordered', 'color'],
- },
- NText: {
- name: 'NText',
- template: '',
- props: ['depth'],
- },
- NIcon: {
- name: 'NIcon',
- template: '',
- },
- NButton: {
- name: 'NButton',
- template: '',
- emits: ['click'],
- },
- NEllipsis: {
- name: 'NEllipsis',
- template: '',
- props: ['lineClamp', 'tooltip', 'class'],
- },
- NThing: {
- name: 'NThing',
- template: '',
- },
- NDropdown: {
- name: 'NDropdown',
- template: `
-
-
-
-
- `,
- props: ['options'],
- emits: ['select'],
- computed: {
- normalizedOptions() {
- return (this.options || []).filter((option: any) => !option.type)
- },
- },
- },
- AppPreviewImage: {
- name: 'AppPreviewImage',
- template: '
',
- props: ['src', 'alt', 'objectFit', 'previewDisabled', 'class'],
- },
-}
-
-const createFavorite = (overrides: Partial = {}): FavoritePrompt => ({
- id: 'favorite-1',
- title: 'Prompt title',
- content: 'A concise favorite content block for testing.',
- description: 'A reusable favorite description.',
- createdAt: Date.now(),
- updatedAt: Date.now(),
- tags: ['tag-a', 'tag-b', 'tag-c'],
- category: 'category-1',
- useCount: 7,
- functionMode: 'image',
- imageSubMode: 'text2image',
- ...overrides,
-})
-
-const category: FavoriteCategory = {
- id: 'category-1',
- name: 'Visual',
- color: '#3366ff',
- createdAt: Date.now(),
- sortOrder: 1,
-}
-
-const mountComponent = (favorite: FavoritePrompt) =>
- mount(FavoriteCard, {
- props: {
- favorite,
- category,
- },
- global: {
- stubs: naiveStubs,
- provide: {
- services: ref({
- favoriteImageStorageService: {},
- imageStorageService: {},
- } as any),
- },
- },
- })
-
-describe('FavoriteCard', () => {
- beforeEach(() => {
- parseFavoriteMediaMetadataMock.mockReset()
- resolveAssetIdToDataUrlMock.mockReset()
- parseFavoriteMediaMetadataMock.mockReturnValue(null)
- resolveAssetIdToDataUrlMock.mockResolvedValue(null)
- })
-
- it('renders a unified placeholder cover for text-only favorites', async () => {
- const wrapper = mountComponent(createFavorite({ functionMode: 'basic', optimizationMode: 'system' }))
-
- await flushPromises()
-
- expect(wrapper.find('[data-testid="favorite-card-cover"]').exists()).toBe(true)
- expect(wrapper.find('[data-testid="favorite-card-cover-placeholder"]').exists()).toBe(true)
- expect(wrapper.find('.app-preview-image').exists()).toBe(false)
- expect(wrapper.text()).toContain('Use now')
- expect(wrapper.text()).toContain('Copy content')
- expect(wrapper.text()).toContain('System')
- })
-
- it('renders cover media and emits select plus action events', async () => {
- parseFavoriteMediaMetadataMock.mockReturnValue({
- coverAssetId: 'asset-cover-1',
- coverUrl: 'https://example.com/fallback.png',
- })
- resolveAssetIdToDataUrlMock.mockResolvedValue('data:image/png;base64,cover')
-
- const wrapper = mountComponent(createFavorite())
-
- await flushPromises()
-
- expect(wrapper.find('.app-preview-image').exists()).toBe(true)
- expect(wrapper.find('[data-testid="favorite-card-cover-placeholder"]').exists()).toBe(false)
-
- await wrapper.find('.n-card').trigger('click')
- await wrapper.find('[data-testid="favorite-card-use-button"]').trigger('click')
- await wrapper.find('[data-testid="favorite-card-copy-button"]').trigger('click')
- const vm = wrapper.vm as unknown as { handleMenuSelect: (key: string) => void }
- vm.handleMenuSelect('edit')
- vm.handleMenuSelect('delete')
-
- expect(wrapper.emitted('select')).toHaveLength(1)
- expect(wrapper.emitted('use')).toHaveLength(1)
- expect(wrapper.emitted('copy')).toHaveLength(1)
- expect(wrapper.emitted('edit')).toHaveLength(1)
- expect(wrapper.emitted('delete')).toHaveLength(1)
- })
-})
diff --git a/packages/ui/tests/unit/components/FavoriteDetailPanel.spec.ts b/packages/ui/tests/unit/components/FavoriteDetailPanel.spec.ts
index b5374f9a..af49c7cd 100644
--- a/packages/ui/tests/unit/components/FavoriteDetailPanel.spec.ts
+++ b/packages/ui/tests/unit/components/FavoriteDetailPanel.spec.ts
@@ -349,4 +349,39 @@ describe('FavoriteDetailPanel', () => {
expect(wrapper.text()).toContain('example-1')
expect(wrapper.text()).toContain('style=ink')
})
+
+ it('resolves and displays example asset images in the detail panel', async () => {
+ resolveAssetIdToDataUrlMock.mockImplementation(async (assetId: string) => {
+ if (assetId === 'asset-output') return 'data:image/png;base64,output-preview'
+ if (assetId === 'asset-input') return 'data:image/png;base64,input-preview'
+ return null
+ })
+
+ const wrapper = mountComponent({
+ ...favorite,
+ functionMode: 'basic',
+ optimizationMode: 'system',
+ imageSubMode: undefined,
+ metadata: {
+ reproducibility: {
+ variables: [],
+ examples: [
+ {
+ id: 'example-assets',
+ parameters: {},
+ imageAssetIds: ['asset-output'],
+ inputImageAssetIds: ['asset-input'],
+ },
+ ],
+ },
+ },
+ })
+
+ await flushPromises()
+
+ expect(wrapper.findAll('.app-preview-image').map((image) => image.attributes('src'))).toEqual([
+ 'data:image/png;base64,output-preview',
+ 'data:image/png;base64,input-preview',
+ ])
+ })
})
diff --git a/packages/ui/tests/unit/components/FavoriteEditorForm.spec.ts b/packages/ui/tests/unit/components/FavoriteEditorForm.spec.ts
new file mode 100644
index 00000000..7c50fad0
--- /dev/null
+++ b/packages/ui/tests/unit/components/FavoriteEditorForm.spec.ts
@@ -0,0 +1,473 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { flushPromises, mount } from '@vue/test-utils'
+import { ref } from 'vue'
+import type { FavoritePrompt } from '@prompt-optimizer/core'
+
+const resolveAssetIdToDataUrlMock = vi.fn()
+const persistImageSourceAsAssetIdMock = vi.fn()
+
+vi.mock('../../../src/utils/image-asset-storage', () => ({
+ resolveAssetIdToDataUrl: (...args: unknown[]) => resolveAssetIdToDataUrlMock(...args),
+ persistImageSourceAsAssetId: (...args: unknown[]) => persistImageSourceAsAssetIdMock(...args),
+}))
+
+vi.mock('../../../src/composables/ui/useTagSuggestions', () => ({
+ useTagSuggestions: () => ({
+ filterTags: () => [],
+ loadTags: vi.fn(async () => {}),
+ }),
+}))
+
+vi.mock('../../../src/composables/ui/useToast', () => ({
+ useToast: () => ({
+ success: vi.fn(),
+ error: vi.fn(),
+ warning: vi.fn(),
+ }),
+}))
+
+vi.mock('vue-i18n', async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ useI18n: () => ({
+ t: (key: string) => key,
+ }),
+ }
+})
+
+import FavoriteEditorForm from '../../../src/components/FavoriteEditorForm.vue'
+
+const naiveStubs = {
+ NAutoComplete: {
+ name: 'NAutoComplete',
+ template: '',
+ props: ['value', 'options', 'placeholder', 'getShow', 'clearable'],
+ },
+ NButton: {
+ name: 'NButton',
+ template: '',
+ props: ['disabled', 'loading', 'type', 'secondary', 'size', 'quaternary'],
+ emits: ['click'],
+ },
+ NCard: {
+ name: 'NCard',
+ template: '',
+ props: ['title', 'size', 'segmented', 'class'],
+ },
+ NForm: {
+ name: 'NForm',
+ template: '',
+ props: ['labelPlacement'],
+ },
+ NFormItem: {
+ name: 'NFormItem',
+ template: '',
+ props: ['label', 'required'],
+ },
+ NGrid: {
+ name: 'NGrid',
+ template: '
',
+ props: ['cols', 'xGap'],
+ },
+ NGridItem: {
+ name: 'NGridItem',
+ template: '
',
+ },
+ NInput: {
+ name: 'NInput',
+ template: '',
+ props: ['value', 'type', 'placeholder', 'autosize', 'maxlength', 'showCount'],
+ },
+ NScrollbar: {
+ name: 'NScrollbar',
+ template: '
',
+ props: ['class'],
+ },
+ NSelect: {
+ name: 'NSelect',
+ template: '',
+ props: ['value', 'options', 'placeholder', 'disabled'],
+ },
+ NSpace: {
+ name: 'NSpace',
+ template: '
',
+ props: ['vertical', 'size', 'justify', 'align', 'wrap', 'class'],
+ },
+ NTag: {
+ name: 'NTag',
+ template: '',
+ props: ['type', 'closable', 'size', 'bordered'],
+ },
+ NText: {
+ name: 'NText',
+ template: '',
+ props: ['depth'],
+ },
+ NUpload: {
+ name: 'NUpload',
+ template: '
',
+ props: ['accept', 'multiple', 'defaultUpload', 'showFileList', 'disabled', 'onBeforeUpload'],
+ emits: ['before-upload'],
+ data: () => ({
+ testBlob: new Blob(['upload'], { type: 'image/png' }),
+ }),
+ },
+ Upload: {
+ name: 'Upload',
+ template: '
',
+ props: ['accept', 'multiple', 'defaultUpload', 'showFileList', 'disabled', 'onBeforeUpload'],
+ emits: ['before-upload'],
+ data: () => ({
+ testBlob: new Blob(['upload'], { type: 'image/png' }),
+ }),
+ },
+ CategoryTreeSelect: {
+ name: 'CategoryTreeSelect',
+ template: '',
+ props: ['modelValue', 'placeholder', 'showAllOption'],
+ },
+ FavoriteReproducibilityEditor: {
+ name: 'FavoriteReproducibilityEditor',
+ template: '',
+ props: ['variables', 'examples', 'examplePreviews'],
+ },
+ AppPreviewImage: {
+ name: 'AppPreviewImage',
+ template: '
',
+ props: ['src', 'alt', 'objectFit', 'class'],
+ },
+ AppPreviewImageGroup: {
+ name: 'AppPreviewImageGroup',
+ template: '
',
+ },
+}
+
+const createFavorite = (id: string, coverAssetId: string): FavoritePrompt => ({
+ id,
+ title: `Favorite ${id}`,
+ content: `Content ${id}`,
+ description: '',
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ tags: [],
+ category: '',
+ useCount: 0,
+ functionMode: 'image',
+ imageSubMode: 'text2image',
+ metadata: {
+ media: {
+ coverAssetId,
+ assetIds: [],
+ urls: [],
+ },
+ },
+})
+
+const createFavoriteWithoutMedia = (id: string): FavoritePrompt => ({
+ id,
+ title: `Favorite ${id}`,
+ content: `Content ${id}`,
+ description: '',
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ tags: [],
+ category: '',
+ useCount: 0,
+ functionMode: 'image',
+ imageSubMode: 'text2image',
+ metadata: {},
+})
+
+const createDeferred = () => {
+ let resolve!: (value: T) => void
+ const promise = new Promise((promiseResolve) => {
+ resolve = promiseResolve
+ })
+ return { promise, resolve }
+}
+
+describe('FavoriteEditorForm', () => {
+ beforeEach(() => {
+ resolveAssetIdToDataUrlMock.mockReset()
+ persistImageSourceAsAssetIdMock.mockReset()
+ })
+
+ afterEach(() => {
+ vi.unstubAllGlobals()
+ })
+
+ it('ignores stale media hydration after switching edited favorites', async () => {
+ const firstCover = createDeferred()
+ const secondCover = createDeferred()
+ const firstFavorite = createFavorite('first', 'asset-first')
+ const secondFavorite = createFavorite('second', 'asset-second')
+ const updateFavorite = vi.fn(async () => {})
+
+ resolveAssetIdToDataUrlMock.mockImplementation((assetId: string) => {
+ if (assetId === 'asset-first') return firstCover.promise
+ if (assetId === 'asset-second') return secondCover.promise
+ return Promise.resolve(null)
+ })
+ persistImageSourceAsAssetIdMock.mockImplementation(({ source }: { source: string }) =>
+ source.includes('second') ? 'persisted-second' : 'persisted-first',
+ )
+
+ const wrapper = mount(FavoriteEditorForm, {
+ props: {
+ mode: 'edit',
+ favorite: firstFavorite,
+ },
+ global: {
+ stubs: naiveStubs,
+ provide: {
+ services: ref({
+ favoriteImageStorageService: {},
+ imageStorageService: {},
+ favoriteManager: {
+ getAllTags: vi.fn(async () => []),
+ addTag: vi.fn(async () => {}),
+ updateFavorite,
+ },
+ } as any),
+ },
+ },
+ })
+
+ await flushPromises()
+ await wrapper.setProps({ favorite: secondFavorite })
+ await flushPromises()
+
+ secondCover.resolve('data:image/png;base64,second')
+ await flushPromises()
+ firstCover.resolve('data:image/png;base64,first')
+ await flushPromises()
+
+ await wrapper.findAll('button').at(-1)?.trigger('click')
+ await flushPromises()
+
+ expect(updateFavorite).toHaveBeenCalledWith('second', expect.objectContaining({
+ metadata: expect.objectContaining({
+ media: expect.objectContaining({
+ coverAssetId: 'persisted-second',
+ }),
+ }),
+ }))
+ expect(updateFavorite).not.toHaveBeenCalledWith('second', expect.objectContaining({
+ metadata: expect.objectContaining({
+ media: expect.objectContaining({
+ coverAssetId: 'persisted-first',
+ }),
+ }),
+ }))
+ })
+
+ it('ignores stale image uploads after switching edited favorites', async () => {
+ const readers: Array<{
+ result: string
+ onload: null | (() => void)
+ onerror: null | (() => void)
+ readAsDataURL: ReturnType
+ }> = []
+ class TestFileReader {
+ result = ''
+ onload: null | (() => void) = null
+ onerror: null | (() => void) = null
+ readAsDataURL = vi.fn(() => {
+ readers.push(this)
+ })
+ }
+ vi.stubGlobal('FileReader', TestFileReader)
+
+ const secondCover = createDeferred()
+ const firstFavorite = createFavoriteWithoutMedia('first')
+ const secondFavorite = createFavorite('second', 'asset-second')
+ const updateFavorite = vi.fn(async () => {})
+
+ resolveAssetIdToDataUrlMock.mockImplementation((assetId: string) => {
+ if (assetId === 'asset-second') return secondCover.promise
+ return Promise.resolve(null)
+ })
+ persistImageSourceAsAssetIdMock.mockImplementation(({ source }: { source: string }) =>
+ source.includes('stale-upload') ? 'persisted-stale-upload' : 'persisted-second',
+ )
+
+ const wrapper = mount(FavoriteEditorForm, {
+ props: {
+ mode: 'edit',
+ favorite: firstFavorite,
+ },
+ global: {
+ stubs: naiveStubs,
+ provide: {
+ services: ref({
+ favoriteImageStorageService: {},
+ imageStorageService: {},
+ favoriteManager: {
+ getAllTags: vi.fn(async () => []),
+ addTag: vi.fn(async () => {}),
+ updateFavorite,
+ },
+ } as any),
+ },
+ },
+ })
+
+ await flushPromises()
+
+ await wrapper.find('.n-upload').trigger('click')
+ await flushPromises()
+ expect(readers).toHaveLength(1)
+
+ await wrapper.setProps({ favorite: secondFavorite })
+ await flushPromises()
+
+ secondCover.resolve('data:image/png;base64,second')
+ await flushPromises()
+
+ readers[0].result = 'data:image/png;base64,stale-upload'
+ readers[0].onload?.()
+ await flushPromises()
+
+ await wrapper.findAll('button').at(-1)?.trigger('click')
+ await flushPromises()
+
+ expect(persistImageSourceAsAssetIdMock).not.toHaveBeenCalledWith(expect.objectContaining({
+ source: 'data:image/png;base64,stale-upload',
+ }))
+ expect(updateFavorite).toHaveBeenCalledWith('second', expect.objectContaining({
+ metadata: expect.objectContaining({
+ media: expect.objectContaining({
+ coverAssetId: 'persisted-second',
+ assetIds: ['persisted-second'],
+ }),
+ }),
+ }))
+ })
+
+ it('persists reproducibility example images as favorite asset ids on save', async () => {
+ const favorite: FavoritePrompt = {
+ ...createFavoriteWithoutMedia('manual-examples'),
+ metadata: {
+ reproducibility: {
+ variables: [],
+ examples: [
+ {
+ id: 'case-1',
+ parameters: { style: 'ink' },
+ images: ['data:image/png;base64,output-image'],
+ imageAssetIds: ['existing-output-asset'],
+ inputImages: ['data:image/png;base64,input-image'],
+ inputImageAssetIds: ['existing-input-asset'],
+ },
+ ],
+ },
+ },
+ }
+ const updateFavorite = vi.fn(async () => {})
+
+ persistImageSourceAsAssetIdMock.mockImplementation(({ source }: { source: string }) => {
+ if (source.includes('output-image')) return 'persisted-output-asset'
+ if (source.includes('input-image')) return 'persisted-input-asset'
+ return null
+ })
+
+ const wrapper = mount(FavoriteEditorForm, {
+ props: {
+ mode: 'edit',
+ favorite,
+ },
+ global: {
+ stubs: naiveStubs,
+ provide: {
+ services: ref({
+ favoriteImageStorageService: {},
+ imageStorageService: {},
+ favoriteManager: {
+ getAllTags: vi.fn(async () => []),
+ addTag: vi.fn(async () => {}),
+ updateFavorite,
+ },
+ } as any),
+ },
+ },
+ })
+
+ await flushPromises()
+ await wrapper.findAll('button').at(-1)?.trigger('click')
+ await flushPromises()
+
+ const savedMetadata = updateFavorite.mock.calls[0]?.[1]?.metadata as Record
+ const savedExample = savedMetadata.reproducibility.examples[0]
+
+ expect(savedExample.imageAssetIds).toEqual(['existing-output-asset', 'persisted-output-asset'])
+ expect(savedExample.inputImageAssetIds).toEqual(['existing-input-asset', 'persisted-input-asset'])
+ expect(JSON.stringify(savedExample)).not.toContain('data:image/png;base64')
+ })
+
+ it('hydrates reproducibility example asset previews without writing them into editable url fields', async () => {
+ const favorite: FavoritePrompt = {
+ ...createFavoriteWithoutMedia('preview-examples'),
+ metadata: {
+ reproducibility: {
+ variables: [],
+ examples: [
+ {
+ id: 'case-preview',
+ parameters: {},
+ images: [],
+ imageAssetIds: ['asset-output'],
+ inputImages: [],
+ inputImageAssetIds: ['asset-input'],
+ },
+ ],
+ },
+ },
+ }
+
+ resolveAssetIdToDataUrlMock.mockImplementation((assetId: string) => {
+ if (assetId === 'asset-output') return Promise.resolve('data:image/png;base64,output-preview')
+ if (assetId === 'asset-input') return Promise.resolve('data:image/png;base64,input-preview')
+ return Promise.resolve(null)
+ })
+
+ const wrapper = mount(FavoriteEditorForm, {
+ props: {
+ mode: 'edit',
+ favorite,
+ },
+ global: {
+ stubs: naiveStubs,
+ provide: {
+ services: ref({
+ favoriteImageStorageService: {},
+ imageStorageService: {},
+ favoriteManager: {
+ getAllTags: vi.fn(async () => []),
+ addTag: vi.fn(async () => {}),
+ updateFavorite: vi.fn(async () => {}),
+ },
+ } as any),
+ },
+ },
+ })
+
+ await flushPromises()
+
+ const editor = wrapper.findComponent({ name: 'FavoriteReproducibilityEditor' })
+ expect(editor.props('examples')).toEqual([
+ expect.objectContaining({
+ images: [],
+ imageAssetIds: ['asset-output'],
+ inputImages: [],
+ inputImageAssetIds: ['asset-input'],
+ }),
+ ])
+ expect(editor.props('examplePreviews')).toEqual([
+ {
+ images: [{ assetId: 'asset-output', source: 'data:image/png;base64,output-preview' }],
+ inputImages: [{ assetId: 'asset-input', source: 'data:image/png;base64,input-preview' }],
+ },
+ ])
+ })
+})
diff --git a/packages/ui/tests/unit/components/FavoriteReproducibilityDisplay.spec.ts b/packages/ui/tests/unit/components/FavoriteReproducibilityDisplay.spec.ts
new file mode 100644
index 00000000..a30596ef
--- /dev/null
+++ b/packages/ui/tests/unit/components/FavoriteReproducibilityDisplay.spec.ts
@@ -0,0 +1,108 @@
+import { describe, expect, it, vi } from 'vitest'
+import { mount } from '@vue/test-utils'
+
+import FavoriteReproducibilityDisplay from '../../../src/components/FavoriteReproducibilityDisplay.vue'
+
+vi.mock('vue-i18n', async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ useI18n: () => ({
+ t: (key: string, values?: Record) =>
+ values?.index ? `${key} ${values.index}` : key,
+ }),
+ }
+})
+
+const naiveStubs = {
+ NCard: {
+ name: 'NCard',
+ template: '',
+ props: ['size', 'embedded'],
+ },
+ NDescriptions: {
+ name: 'NDescriptions',
+ template: '
',
+ props: ['column', 'size', 'bordered', 'labelPlacement'],
+ },
+ NDescriptionsItem: {
+ name: 'NDescriptionsItem',
+ template: '{{ label }}',
+ props: ['label'],
+ },
+ NEmpty: {
+ name: 'NEmpty',
+ template: '{{ description }}
',
+ props: ['description', 'size'],
+ },
+ NSpace: {
+ name: 'NSpace',
+ template: '
',
+ props: ['vertical', 'size', 'align', 'wrap'],
+ },
+ NTable: {
+ name: 'NTable',
+ template: '',
+ props: ['size', 'striped', 'singleLine'],
+ },
+ NTag: {
+ name: 'NTag',
+ template: '',
+ props: ['size', 'type', 'bordered'],
+ },
+ NText: {
+ name: 'NText',
+ template: '',
+ props: ['depth', 'strong'],
+ },
+ AppPreviewImage: {
+ name: 'AppPreviewImage',
+ template: '
',
+ props: ['src', 'alt', 'objectFit', 'class'],
+ },
+ AppPreviewImageGroup: {
+ name: 'AppPreviewImageGroup',
+ template: '
',
+ },
+}
+
+describe('FavoriteReproducibilityDisplay', () => {
+ it('renders example images and input images from resolved asset previews', () => {
+ const wrapper = mount(FavoriteReproducibilityDisplay, {
+ props: {
+ reproducibility: {
+ source: 'reproducibility',
+ variables: [],
+ examples: [
+ {
+ id: 'case-1',
+ parameters: {},
+ images: [],
+ imageAssetIds: ['asset-output'],
+ inputImages: [],
+ inputImageAssetIds: ['asset-input'],
+ },
+ ],
+ variableCount: 0,
+ exampleCount: 1,
+ hasInputImages: true,
+ hasData: true,
+ },
+ examplePreviews: [
+ {
+ images: [{ assetId: 'asset-output', source: 'data:image/png;base64,output-preview' }],
+ inputImages: [{ assetId: 'asset-input', source: 'data:image/png;base64,input-preview' }],
+ },
+ ],
+ },
+ global: {
+ stubs: naiveStubs,
+ },
+ })
+
+ expect(wrapper.findAll('.app-preview-image').map((image) => image.attributes('src'))).toEqual([
+ 'data:image/png;base64,output-preview',
+ 'data:image/png;base64,input-preview',
+ ])
+ })
+})
diff --git a/packages/ui/tests/unit/components/FavoriteReproducibilityEditor.spec.ts b/packages/ui/tests/unit/components/FavoriteReproducibilityEditor.spec.ts
index d7fac160..4ff48937 100644
--- a/packages/ui/tests/unit/components/FavoriteReproducibilityEditor.spec.ts
+++ b/packages/ui/tests/unit/components/FavoriteReproducibilityEditor.spec.ts
@@ -1,4 +1,4 @@
-import { describe, expect, it, vi } from 'vitest'
+import { afterEach, describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import FavoriteReproducibilityEditor from '../../../src/components/FavoriteReproducibilityEditor.vue'
@@ -70,8 +70,43 @@ const naiveStubs = {
template: '',
props: ['depth', 'strong'],
},
+ NUpload: {
+ name: 'NUpload',
+ template: '
',
+ props: ['accept', 'multiple', 'defaultUpload', 'showFileList', 'onBeforeUpload'],
+ emits: ['before-upload'],
+ data: () => ({
+ testBlob: new Blob(['upload'], { type: 'image/png' }),
+ }),
+ methods: {
+ handleClick() {
+ const payload = { file: { file: this.testBlob } }
+ if (typeof this.onBeforeUpload === 'function') {
+ this.onBeforeUpload(payload)
+ }
+ this.$emit('before-upload', payload)
+ },
+ },
+ },
+ AppPreviewImage: {
+ name: 'AppPreviewImage',
+ template: '
',
+ props: ['src', 'alt', 'objectFit', 'class'],
+ },
+ AppPreviewImageGroup: {
+ name: 'AppPreviewImageGroup',
+ template: '
',
+ },
}
+const findField = (wrapper: any, testId: string) =>
+ wrapper.find(
+ `[data-testid="${testId}"] textarea, ` +
+ `[data-testid="${testId}"] input, ` +
+ `textarea[data-testid="${testId}"], ` +
+ `input[data-testid="${testId}"]`,
+ )
+
const mountComponent = () =>
mount(FavoriteReproducibilityEditor, {
props: {
@@ -84,6 +119,10 @@ const mountComponent = () =>
})
describe('FavoriteReproducibilityEditor', () => {
+ afterEach(() => {
+ vi.unstubAllGlobals()
+ })
+
it('lets users add and edit variable configuration', async () => {
const wrapper = mountComponent()
@@ -127,9 +166,15 @@ describe('FavoriteReproducibilityEditor', () => {
])
await wrapper.setProps({ examples: nextExamples })
- await wrapper
- .find('[data-testid="favorite-repro-example-parameters"] textarea, [data-testid="favorite-repro-example-parameters"] input')
- .setValue('style=ink\nsize=large')
+ await findField(wrapper, 'favorite-repro-example-parameter-key').setValue('style')
+ await findField(wrapper, 'favorite-repro-example-parameter-new-value').setValue('ink')
+ await wrapper.find('[data-testid="favorite-repro-example-add-parameter"]').trigger('click')
+
+ const examplesWithStyle = wrapper.emitted('update:examples')?.at(-1)?.[0]
+ await wrapper.setProps({ examples: examplesWithStyle })
+ await findField(wrapper, 'favorite-repro-example-parameter-key').setValue('size')
+ await findField(wrapper, 'favorite-repro-example-parameter-new-value').setValue('large')
+ await wrapper.find('[data-testid="favorite-repro-example-add-parameter"]').trigger('click')
expect(wrapper.emitted('update:examples')?.at(-1)?.[0]).toEqual([
{
@@ -144,4 +189,143 @@ describe('FavoriteReproducibilityEditor', () => {
},
])
})
+
+ it('lets users edit example output and input image urls', async () => {
+ const wrapper = mountComponent()
+
+ await wrapper.findAll('.n-button')[1].trigger('click')
+ const nextExamples = wrapper.emitted('update:examples')?.[0]?.[0]
+ await wrapper.setProps({ examples: nextExamples })
+
+ await findField(wrapper, 'favorite-repro-example-images').setValue('https://example.com/output.png')
+ await wrapper.find('[data-testid="favorite-repro-example-add-image-url"]').trigger('click')
+ const examplesWithOutputImages = wrapper.emitted('update:examples')?.at(-1)?.[0]
+ await wrapper.setProps({ examples: examplesWithOutputImages })
+
+ await findField(wrapper, 'favorite-repro-example-input-images').setValue('https://example.com/input.png')
+ await wrapper.find('[data-testid="favorite-repro-example-add-input-image-url"]').trigger('click')
+
+ expect(wrapper.emitted('update:examples')?.at(-1)?.[0]).toEqual([
+ {
+ parameters: {},
+ images: ['https://example.com/output.png'],
+ imageAssetIds: [],
+ inputImages: ['https://example.com/input.png'],
+ inputImageAssetIds: [],
+ },
+ ])
+ })
+
+ it('does not carry unsaved example drafts onto the next example after removal', async () => {
+ const wrapper = mount(FavoriteReproducibilityEditor, {
+ props: {
+ variables: [],
+ examples: [
+ {
+ id: 'ex-1',
+ parameters: {},
+ images: [],
+ imageAssetIds: [],
+ inputImages: [],
+ inputImageAssetIds: [],
+ },
+ {
+ id: 'ex-2',
+ parameters: {},
+ images: [],
+ imageAssetIds: [],
+ inputImages: [],
+ inputImageAssetIds: [],
+ },
+ ],
+ },
+ global: {
+ stubs: naiveStubs,
+ },
+ })
+
+ await findField(wrapper, 'favorite-repro-example-parameter-key').setValue('stale')
+ await findField(wrapper, 'favorite-repro-example-parameter-new-value').setValue('draft')
+ await findField(wrapper, 'favorite-repro-example-images').setValue('https://example.com/stale.png')
+ await wrapper.findAll('[data-testid="favorite-repro-remove-example"]')[0].trigger('click')
+
+ const remainingExamples = wrapper.emitted('update:examples')?.at(-1)?.[0]
+ await wrapper.setProps({ examples: remainingExamples })
+
+ expect((findField(wrapper, 'favorite-repro-example-parameter-key').element as HTMLInputElement).value).toBe('')
+ expect((findField(wrapper, 'favorite-repro-example-parameter-new-value').element as HTMLInputElement).value).toBe('')
+ expect((findField(wrapper, 'favorite-repro-example-images').element as HTMLInputElement).value).toBe('')
+ })
+
+ it('shows persisted example asset previews separately from editable urls', () => {
+ const wrapper = mount(FavoriteReproducibilityEditor, {
+ props: {
+ variables: [],
+ examples: [
+ {
+ parameters: {},
+ images: [],
+ imageAssetIds: ['asset-output'],
+ inputImages: [],
+ inputImageAssetIds: ['asset-input'],
+ },
+ ],
+ examplePreviews: [
+ {
+ images: [{ assetId: 'asset-output', source: 'data:image/png;base64,output-preview' }],
+ inputImages: [{ assetId: 'asset-input', source: 'data:image/png;base64,input-preview' }],
+ },
+ ],
+ },
+ global: {
+ stubs: naiveStubs,
+ },
+ })
+
+ const previewImages = wrapper.findAll('.app-preview-image')
+ expect(previewImages.map((image) => image.attributes('src'))).toEqual([
+ 'data:image/png;base64,output-preview',
+ 'data:image/png;base64,input-preview',
+ ])
+ expect((findField(wrapper, 'favorite-repro-example-images').element as HTMLInputElement).value).toBe('')
+ expect((findField(wrapper, 'favorite-repro-example-input-images').element as HTMLInputElement).value).toBe('')
+ })
+
+ it('removes persisted example asset previews from the matching asset field', async () => {
+ const wrapper = mount(FavoriteReproducibilityEditor, {
+ props: {
+ variables: [],
+ examples: [
+ {
+ parameters: {},
+ images: [],
+ imageAssetIds: ['asset-output'],
+ inputImages: [],
+ inputImageAssetIds: ['asset-input'],
+ },
+ ],
+ examplePreviews: [
+ {
+ images: [{ assetId: 'asset-output', source: 'data:image/png;base64,output-preview' }],
+ inputImages: [{ assetId: 'asset-input', source: 'data:image/png;base64,input-preview' }],
+ },
+ ],
+ },
+ global: {
+ stubs: naiveStubs,
+ },
+ })
+
+ await wrapper.find('[data-testid="favorite-repro-example-remove-input-image"]').trigger('click')
+
+ expect(wrapper.emitted('update:examples')?.at(-1)?.[0]).toEqual([
+ {
+ parameters: {},
+ images: [],
+ imageAssetIds: ['asset-output'],
+ inputImages: [],
+ inputImageAssetIds: [],
+ },
+ ])
+ })
})
diff --git a/packages/ui/tests/unit/components/GardenSnapshotPreview.spec.ts b/packages/ui/tests/unit/components/GardenSnapshotPreview.spec.ts
index cada7a00..05bcdd41 100644
--- a/packages/ui/tests/unit/components/GardenSnapshotPreview.spec.ts
+++ b/packages/ui/tests/unit/components/GardenSnapshotPreview.spec.ts
@@ -112,6 +112,39 @@ describe('GardenSnapshotPreview', () => {
expect(wrapper.find('[data-testid="favorite-garden-variables"]').exists()).toBe(false)
})
+ it('can hide sections already promoted by the favorite detail panel', () => {
+ const wrapper = mount(GardenSnapshotPreview, {
+ props: {
+ snapshot: createSnapshot(),
+ hiddenSections: ['metaInfo', 'cover', 'showcases', 'examples', 'variables'],
+ },
+ })
+
+ expect(wrapper.text()).toContain('IMP-100')
+ expect(wrapper.find('[data-testid="favorite-garden-meta"]').exists()).toBe(false)
+ expect(wrapper.find('[data-testid="favorite-garden-cover"]').exists()).toBe(false)
+ expect(wrapper.find('[data-testid="favorite-garden-showcases"]').exists()).toBe(false)
+ expect(wrapper.find('[data-testid="favorite-garden-examples"]').exists()).toBe(false)
+ expect(wrapper.find('[data-testid="favorite-garden-variables"]').exists()).toBe(false)
+ })
+
+ it('renders source-only mode without the nested snapshot header or collapse sections', () => {
+ const wrapper = mount(GardenSnapshotPreview, {
+ props: {
+ snapshot: createSnapshot(),
+ sourceOnly: true,
+ },
+ })
+
+ expect(wrapper.find('[data-testid="favorite-garden-basic-info"]').exists()).toBe(true)
+ expect(wrapper.text()).toContain('IMP-100')
+ expect(wrapper.text()).toContain('https://garden.example.com')
+ expect(wrapper.text()).not.toContain('favorites.manager.preview.garden.snapshotTitle')
+ expect(wrapper.find('.n-collapse').exists()).toBe(false)
+ expect(wrapper.find('[data-testid="favorite-garden-examples"]').exists()).toBe(false)
+ expect(wrapper.find('[data-testid="favorite-garden-variables"]').exists()).toBe(false)
+ })
+
it('supports image zoom and local upload actions in editable mode', async () => {
class MockFileReader {
public result: string | null = null
diff --git a/packages/ui/tests/unit/composables/useAppFavorite.spec.ts b/packages/ui/tests/unit/composables/useAppFavorite.spec.ts
index e04e6b88..2c760e23 100644
--- a/packages/ui/tests/unit/composables/useAppFavorite.spec.ts
+++ b/packages/ui/tests/unit/composables/useAppFavorite.spec.ts
@@ -82,6 +82,143 @@ describe('useAppFavorite', () => {
dispatchSpy.mockRestore()
})
+ it('applies selected example parameters to pro-variable temporary variables', async () => {
+ const success = vi.fn(() => createReactive())
+ setGlobalMessageApi({
+ success,
+ error: vi.fn(() => createReactive()),
+ warning: vi.fn(() => createReactive()),
+ info: vi.fn(() => createReactive()),
+ })
+
+ const optimizerPrompt = ref('')
+ const temporaryVariables: Record = { topic: 'old' }
+ const proVariableSession = {
+ getTemporaryVariable: vi.fn((name: string) => temporaryVariables[name]),
+ setTemporaryVariable: vi.fn((name: string, value: string) => {
+ temporaryVariables[name] = value
+ }),
+ clearTemporaryVariables: vi.fn(() => {
+ for (const key of Object.keys(temporaryVariables)) delete temporaryVariables[key]
+ }),
+ }
+
+ const { handleUseFavorite } = useAppFavorite({
+ navigateToSubModeKey: vi.fn(async () => {}),
+ handleContextModeChange: vi.fn(async () => {}),
+ optimizerPrompt,
+ t: (key: string) => key,
+ isLoadingExternalData: ref(false),
+ proVariableSession,
+ })
+
+ const used = await handleUseFavorite({
+ content: 'Write about {{topic}}',
+ functionMode: 'context',
+ optimizationMode: 'user',
+ metadata: {
+ reproducibility: {
+ variables: [{ name: 'topic', defaultValue: 'default' }],
+ examples: [
+ { id: 'a', parameters: { topic: 'alpha' } },
+ { id: 'b', parameters: { topic: 'beta' } },
+ ],
+ },
+ },
+ }, { applyExample: true, exampleId: 'b' })
+
+ expect(used).toBe(true)
+ expect(optimizerPrompt.value).toBe('Write about {{topic}}')
+ expect(proVariableSession.clearTemporaryVariables).toHaveBeenCalled()
+ expect(temporaryVariables.topic).toBe('beta')
+ expect(success).toHaveBeenCalled()
+ })
+
+ it('applies image example input images without changing template content', async () => {
+ const success = vi.fn(() => createReactive())
+ setGlobalMessageApi({
+ success,
+ error: vi.fn(() => createReactive()),
+ warning: vi.fn(() => createReactive()),
+ info: vi.fn(() => createReactive()),
+ })
+
+ const dispatchSpy = vi.spyOn(window, 'dispatchEvent')
+ const replaceInputImages = vi.fn()
+ const imageMultiImageSession = {
+ getTemporaryVariable: vi.fn(() => undefined),
+ setTemporaryVariable: vi.fn(),
+ clearTemporaryVariables: vi.fn(),
+ replaceInputImages,
+ }
+
+ const { handleUseFavorite } = useAppFavorite({
+ navigateToSubModeKey: vi.fn(async () => {}),
+ handleContextModeChange: vi.fn(async () => {}),
+ optimizerPrompt: ref('unchanged'),
+ t: (key: string) => key,
+ isLoadingExternalData: ref(false),
+ imageMultiImageSession,
+ })
+
+ const used = await handleUseFavorite({
+ content: 'Image prompt {{scene}}',
+ functionMode: 'image',
+ imageSubMode: 'multiimage',
+ metadata: {
+ reproducibility: {
+ variables: [{ name: 'scene' }],
+ examples: [{ id: 'img', parameters: { scene: 'city' }, inputImages: ['data:image/png;base64,AAECAw=='] }],
+ },
+ },
+ }, { applyExample: true, exampleId: 'img' })
+
+ expect(used).toBe(true)
+ expect(dispatchSpy).toHaveBeenCalledWith(expect.objectContaining({
+ type: 'image-workspace-restore-favorite',
+ detail: expect.objectContaining({ content: 'Image prompt {{scene}}' }),
+ }))
+ expect(imageMultiImageSession.setTemporaryVariable).toHaveBeenCalledWith('scene', 'city')
+ expect(replaceInputImages).toHaveBeenCalledWith([{ b64: 'AAECAw==', mimeType: 'image/png' }])
+
+ dispatchSpy.mockRestore()
+ })
+
+ it('stops loading favorite data when workspace navigation rejects the target key', async () => {
+ const success = vi.fn(() => createReactive())
+ setGlobalMessageApi({
+ success,
+ error: vi.fn(() => createReactive()),
+ warning: vi.fn(() => createReactive()),
+ info: vi.fn(() => createReactive()),
+ })
+
+ const optimizerPrompt = ref('unchanged')
+ const dispatchSpy = vi.spyOn(window, 'dispatchEvent')
+ const { handleUseFavorite } = useAppFavorite({
+ navigateToSubModeKey: vi.fn(async () => false),
+ handleContextModeChange: vi.fn(async () => {}),
+ optimizerPrompt,
+ t: (key: string) => key,
+ isLoadingExternalData: ref(false),
+ })
+
+ const used = await handleUseFavorite({
+ content: 'image favorite prompt',
+ functionMode: 'image',
+ imageSubMode: 'text2image',
+ })
+
+ expect(used).toBe(false)
+ expect(optimizerPrompt.value).toBe('unchanged')
+ expect(dispatchSpy).not.toHaveBeenCalledWith(expect.objectContaining({
+ type: 'image-workspace-restore-favorite',
+ }))
+ expect(success).not.toHaveBeenCalled()
+
+ dispatchSpy.mockRestore()
+ })
+
it('logs favorite restore failures with an English runtime message', async () => {
const error = vi.fn(() => createReactive())
setGlobalMessageApi({
diff --git a/packages/ui/tests/unit/image/app-preview-image-adoption.spec.ts b/packages/ui/tests/unit/image/app-preview-image-adoption.spec.ts
index 14121f69..65331851 100644
--- a/packages/ui/tests/unit/image/app-preview-image-adoption.spec.ts
+++ b/packages/ui/tests/unit/image/app-preview-image-adoption.spec.ts
@@ -8,7 +8,6 @@ const readSource = (relativePath: string) =>
describe('app preview image adoption guards', () => {
it('replaces direct preview image usage with AppPreviewImage in key UI surfaces', () => {
const files = [
- 'src/components/FavoriteCard.vue',
'src/components/FavoriteMediaPreviewPanel.vue',
'src/components/GardenSnapshotPreview.vue',
'src/components/ImageModelEditModal.vue',
diff --git a/packages/ui/tests/unit/router/guards.spec.ts b/packages/ui/tests/unit/router/guards.spec.ts
index cb72d744..c982118b 100644
--- a/packages/ui/tests/unit/router/guards.spec.ts
+++ b/packages/ui/tests/unit/router/guards.spec.ts
@@ -3,9 +3,15 @@ import type { RouteLocationNormalized } from 'vue-router'
import { beforeRouteSwitch, parseSubModeKey } from '../../../src/router/guards'
import { normalizeWorkspacePath, parseWorkspaceRoutePath } from '../../../src/router/workspaceRoutes'
-function createRoute(path: string): RouteLocationNormalized {
+function createRoute(
+ path: string,
+ overrides: Partial = {},
+): RouteLocationNormalized {
return {
path,
+ query: {},
+ hash: '',
+ ...overrides,
} as RouteLocationNormalized
}
@@ -42,15 +48,23 @@ describe('router guards', () => {
describe('beforeRouteSwitch', () => {
it('redirects legacy pro system route to multi mode', () => {
- const result = beforeRouteSwitch(createRoute('/pro/system'), createRoute('/'), undefined as never)
+ const result = beforeRouteSwitch(
+ createRoute('/pro/system', { query: { from: '/favorites' }, hash: '#section' }),
+ createRoute('/'),
+ undefined as never,
+ )
- expect(result).toBe('/pro/multi')
+ expect(result).toEqual({
+ path: '/pro/multi',
+ query: { from: '/favorites' },
+ hash: '#section',
+ })
})
it('redirects legacy pro user route to variable mode', () => {
const result = beforeRouteSwitch(createRoute('/pro/user'), createRoute('/'), undefined as never)
- expect(result).toBe('/pro/variable')
+ expect(result).toEqual({ path: '/pro/variable', query: {}, hash: '' })
})
it('redirects invalid image sub mode to the default image route', () => {
@@ -58,10 +72,16 @@ describe('router guards', () => {
const result = beforeRouteSwitch(createRoute('/image/unknown'), createRoute('/'), undefined as never)
- expect(result).toBe('/image/text2image')
+ expect(result).toEqual({ path: '/image/text2image', query: {}, hash: '' })
expect(warnSpy).toHaveBeenCalledOnce()
})
+ it('does not redirect non-workspace routes that only share a prefix', () => {
+ expect(beforeRouteSwitch(createRoute('/profile'), createRoute('/'), undefined as never)).toBe(true)
+ expect(beforeRouteSwitch(createRoute('/project'), createRoute('/'), undefined as never)).toBe(true)
+ expect(beforeRouteSwitch(createRoute('/process'), createRoute('/'), undefined as never)).toBe(true)
+ })
+
it('allows valid routes to continue', () => {
const result = beforeRouteSwitch(createRoute('/basic/user'), createRoute('/'), undefined as never)
diff --git a/packages/ui/tests/unit/utils/external-data-loading.spec.ts b/packages/ui/tests/unit/utils/external-data-loading.spec.ts
new file mode 100644
index 00000000..a3f54fd5
--- /dev/null
+++ b/packages/ui/tests/unit/utils/external-data-loading.spec.ts
@@ -0,0 +1,34 @@
+import { describe, expect, it } from 'vitest'
+
+import { createExternalDataLoadingGate } from '../../../src/utils/external-data-loading'
+
+describe('createExternalDataLoadingGate', () => {
+ it('keeps loading true until every concurrent owner leaves', () => {
+ const gate = createExternalDataLoadingGate()
+
+ gate.isLoading.value = true
+ gate.isLoading.value = true
+
+ expect(gate.isLoading.value).toBe(true)
+ expect(gate.depth.value).toBe(2)
+
+ gate.isLoading.value = false
+
+ expect(gate.isLoading.value).toBe(true)
+ expect(gate.depth.value).toBe(1)
+
+ gate.isLoading.value = false
+
+ expect(gate.isLoading.value).toBe(false)
+ expect(gate.depth.value).toBe(0)
+ })
+
+ it('does not underflow when a caller leaves too many times', () => {
+ const gate = createExternalDataLoadingGate()
+
+ gate.isLoading.value = false
+
+ expect(gate.isLoading.value).toBe(false)
+ expect(gate.depth.value).toBe(0)
+ })
+})
diff --git a/packages/ui/tests/unit/utils/favorite-asset-maintenance.spec.ts b/packages/ui/tests/unit/utils/favorite-asset-maintenance.spec.ts
index ef2c274c..140ec023 100644
--- a/packages/ui/tests/unit/utils/favorite-asset-maintenance.spec.ts
+++ b/packages/ui/tests/unit/utils/favorite-asset-maintenance.spec.ts
@@ -22,6 +22,20 @@ describe('favoriteAssetMaintenance', () => {
examples: [{ inputImageAssetIds: ['input-1'] }],
},
},
+ reproducibility: {
+ examples: [
+ {
+ imageAssetIds: ['manual-output-1'],
+ inputImageAssetIds: ['manual-input-1'],
+ },
+ ],
+ },
+ examples: [
+ {
+ imageAssetIds: ['legacy-output-1'],
+ inputImageAssetIds: ['legacy-input-1'],
+ },
+ ],
},
},
]),
@@ -33,6 +47,10 @@ describe('favoriteAssetMaintenance', () => {
{ id: 'asset-1' },
{ id: 'gallery-1' },
{ id: 'input-1' },
+ { id: 'manual-output-1' },
+ { id: 'manual-input-1' },
+ { id: 'legacy-output-1' },
+ { id: 'legacy-input-1' },
{ id: 'orphan-1' },
]),
deleteImages: vi.fn(async (_ids: string[]) => {}),
diff --git a/tests/e2e/favorite-management.spec.ts b/tests/e2e/favorite-management.spec.ts
index e141d2eb..3e3ef676 100644
--- a/tests/e2e/favorite-management.spec.ts
+++ b/tests/e2e/favorite-management.spec.ts
@@ -1,4 +1,188 @@
+import path from 'node:path';
+import type { Page } from '@playwright/test';
+
import { test, expect } from './fixtures';
+import { waitForAppReady } from './helpers/common';
+
+const imageFixturePath = (fileName: string) =>
+ path.resolve(process.cwd(), 'tests/e2e/fixtures/images', fileName);
+const inlineExampleImage =
+ 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAFgwJ/l9v7rwAAAABJRU5ErkJggg==';
+
+async function fillFieldByTestId(page: Page, testId: string, value: string): Promise {
+ const field = page
+ .locator(
+ `[data-testid="${testId}"] input, ` +
+ `[data-testid="${testId}"] textarea, ` +
+ `input[data-testid="${testId}"], ` +
+ `textarea[data-testid="${testId}"]`
+ )
+ .first();
+
+ await expect(field).toBeVisible({ timeout: 5000 });
+ await field.fill(value);
+}
+
+async function openFavoritesPage(page: Page): Promise {
+ await page.goto('/#/favorites', { waitUntil: 'domcontentloaded' });
+ await waitForAppReady(page);
+ await expect(page.getByTestId('favorites-manager-add')).toBeVisible({ timeout: 20000 });
+}
+
+async function uploadFavoriteImage(page: Page, fileName: string): Promise {
+ const uploadInput = page
+ .locator(
+ '[data-testid="favorite-editor-image-upload-empty"] input[type="file"], ' +
+ '[data-testid="favorite-editor-image-upload"] input[type="file"]'
+ )
+ .first();
+
+ await uploadInput.setInputFiles(imageFixturePath(fileName));
+ await expect(page.getByTestId('favorite-editor-remove-image')).toBeVisible({ timeout: 10000 });
+}
+
+async function uploadExampleImage(
+ page: Page,
+ testId: 'favorite-repro-example-image-upload' | 'favorite-repro-example-input-image-upload',
+ removeTestId: 'favorite-repro-example-remove-image' | 'favorite-repro-example-remove-input-image',
+ fileName: string
+): Promise {
+ const uploadInput = page
+ .locator(`[data-testid="${testId}"] input[type="file"]`)
+ .first();
+ const previousCount = await page.getByTestId(removeTestId).count();
+
+ await uploadInput.setInputFiles(imageFixturePath(fileName));
+ await expect.poll(async () => page.getByTestId(removeTestId).count(), { timeout: 10000 }).toBeGreaterThan(previousCount);
+}
+
+async function addExampleImageUrl(
+ page: Page,
+ fieldTestId: 'favorite-repro-example-images' | 'favorite-repro-example-input-images',
+ buttonTestId: 'favorite-repro-example-add-image-url' | 'favorite-repro-example-add-input-image-url',
+ removeTestId: 'favorite-repro-example-remove-image' | 'favorite-repro-example-remove-input-image',
+ value: string
+): Promise {
+ const previousCount = await page.getByTestId(removeTestId).count();
+ await fillFieldByTestId(page, fieldTestId, value);
+ await page.getByTestId(buttonTestId).click();
+ await expect.poll(async () => page.getByTestId(removeTestId).count(), { timeout: 10000 }).toBeGreaterThan(previousCount);
+}
+
+async function addExampleParameter(page: Page, key: string, value: string): Promise {
+ await fillFieldByTestId(page, 'favorite-repro-example-parameter-key', key);
+ await fillFieldByTestId(page, 'favorite-repro-example-parameter-new-value', value);
+ await page.getByTestId('favorite-repro-example-add-parameter').click();
+}
+
+async function closeFavoritesDrawerIfOpen(page: Page): Promise {
+ const closeButton = page.locator('.n-drawer .n-base-close:visible').last();
+ if (await closeButton.count()) {
+ await closeButton.dispatchEvent('click');
+ await expect(page.locator('.n-drawer-mask')).toBeHidden({ timeout: 10000 });
+ }
+}
+
+async function createFavoriteWithReproData(
+ page: Page,
+ data: {
+ title: string;
+ description: string;
+ content: string;
+ image: string;
+ variableName: string;
+ variableDefault: string;
+ variableDescription: string;
+ exampleId: string;
+ exampleText: string;
+ exampleDescription: string;
+ exampleParameterKey: string;
+ exampleParameterValue: string;
+ exampleImages: string;
+ exampleInputImages: string;
+ exampleImageUpload: string;
+ exampleInputImageUpload: string;
+ }
+): Promise {
+ await closeFavoritesDrawerIfOpen(page);
+ await page.getByTestId('favorites-manager-add').click();
+ await expect(page.getByTestId('favorite-editor-title')).toBeVisible({ timeout: 10000 });
+
+ await fillFieldByTestId(page, 'favorite-editor-title', data.title);
+ await fillFieldByTestId(page, 'favorite-editor-description', data.description);
+ await fillFieldByTestId(page, 'favorite-editor-content', data.content);
+ await uploadFavoriteImage(page, data.image);
+
+ await page.getByTestId('favorite-repro-add-variable-empty').click();
+ await fillFieldByTestId(page, 'favorite-repro-variable-name', data.variableName);
+ await fillFieldByTestId(page, 'favorite-repro-variable-default', data.variableDefault);
+ await fillFieldByTestId(page, 'favorite-repro-variable-description', data.variableDescription);
+
+ await page.getByTestId('favorite-repro-add-example').click();
+ await fillFieldByTestId(page, 'favorite-repro-example-id', data.exampleId);
+ await fillFieldByTestId(page, 'favorite-repro-example-text', data.exampleText);
+ await fillFieldByTestId(page, 'favorite-repro-example-description', data.exampleDescription);
+ await addExampleParameter(page, data.exampleParameterKey, data.exampleParameterValue);
+ await addExampleImageUrl(page, 'favorite-repro-example-images', 'favorite-repro-example-add-image-url', 'favorite-repro-example-remove-image', data.exampleImages);
+ await addExampleImageUrl(page, 'favorite-repro-example-input-images', 'favorite-repro-example-add-input-image-url', 'favorite-repro-example-remove-input-image', data.exampleInputImages);
+ await uploadExampleImage(page, 'favorite-repro-example-image-upload', 'favorite-repro-example-remove-image', data.exampleImageUpload);
+ await uploadExampleImage(page, 'favorite-repro-example-input-image-upload', 'favorite-repro-example-remove-input-image', data.exampleInputImageUpload);
+
+ await page.getByTestId('favorite-editor-save').click();
+ const detailPanel = page.getByTestId('favorite-detail-panel');
+ await expect(detailPanel).toBeVisible({ timeout: 15000 });
+ await expect(detailPanel).toContainText(data.title);
+ await expect(detailPanel).toContainText(data.variableName);
+ await expect(detailPanel).toContainText(data.exampleId);
+}
+
+async function selectFavoriteByTitle(page: Page, title: string): Promise {
+ await closeFavoritesDrawerIfOpen(page);
+ await page.locator('.favorites-manager-search input').fill(title);
+ const listItem = page.getByTestId('favorite-workspace-list-item').filter({ hasText: title }).first();
+ await expect(listItem).toBeVisible({ timeout: 10000 });
+ await listItem.click();
+ await expect(page.getByTestId('favorite-detail-panel')).toContainText(title, { timeout: 10000 });
+}
+
+async function seedFavorites(page: Page, favorites: unknown[]): Promise {
+ await page.evaluate(async (items) => {
+ const dbName = (window as unknown as { __TEST_DB_NAME__?: string }).__TEST_DB_NAME__ || 'PromptOptimizerDB';
+ await new Promise((resolve, reject) => {
+ const request = indexedDB.open(dbName);
+ request.onupgradeneeded = () => {
+ const db = request.result;
+ if (!db.objectStoreNames.contains('storage')) {
+ db.createObjectStore('storage', { keyPath: 'key' });
+ }
+ };
+ request.onerror = () => reject(request.error || new Error('Failed to open test database'));
+ request.onsuccess = () => {
+ const db = request.result;
+ const transaction = db.transaction('storage', 'readwrite');
+ const store = transaction.objectStore('storage');
+ store.put({ key: 'favorites', value: JSON.stringify(items), timestamp: Date.now() });
+ transaction.oncomplete = () => {
+ db.close();
+ resolve();
+ };
+ transaction.onerror = () => {
+ db.close();
+ reject(transaction.error || new Error('Failed to seed favorites'));
+ };
+ };
+ });
+ }, favorites);
+}
+
+async function expectAnyTextareaValue(page: Page, value: string): Promise {
+ await expect.poll(async () => {
+ const values = await page.locator('textarea').evaluateAll((nodes) =>
+ nodes.map((node) => (node as HTMLTextAreaElement).value)
+ );
+ return values.includes(value);
+ }, { timeout: 10000 }).toBe(true);
+}
/**
* 收藏管理基础 E2E 测试
@@ -419,3 +603,165 @@ test.describe('收藏数据持久化', () => {
expect(localStorageAvailable).toBe(true);
});
});
+
+test.describe('收藏夹图片、变量和示例流程', () => {
+ test('能够创建、编辑切换并使用带图片/变量/示例的收藏', async ({ page }) => {
+ test.setTimeout(90000);
+
+ const firstFavorite = {
+ title: 'E2E 图片收藏 A',
+ description: '红色图片收藏,包含变量和示例',
+ content: '使用 {{topic}} 生成一段正式中文系统提示词。',
+ image: 'favorite-red.svg',
+ variableName: 'topic',
+ variableDefault: '产品发布',
+ variableDescription: '需要生成提示词的主题',
+ exampleId: 'case-red',
+ exampleText: '围绕新品发布生成语气稳重的系统提示词',
+ exampleDescription: '红色图片收藏的示例说明',
+ exampleParameterKey: 'topic',
+ exampleParameterValue: '新品发布',
+ exampleImages: inlineExampleImage,
+ exampleInputImages: inlineExampleImage,
+ exampleImageUpload: 'favorite-red.svg',
+ exampleInputImageUpload: 'favorite-red.svg',
+ };
+ const secondFavorite = {
+ title: 'E2E 图片收藏 B',
+ description: '蓝色图片收藏,验证编辑态不会串数据',
+ content: '请根据 {{audience}} 输出精简版图像提示词。',
+ image: 'favorite-blue.svg',
+ variableName: 'audience',
+ variableDefault: '开发者',
+ variableDescription: '目标读者或使用者',
+ exampleId: 'case-blue',
+ exampleText: '为开发者输出一段精简提示词',
+ exampleDescription: '蓝色图片收藏的示例说明',
+ exampleParameterKey: 'audience',
+ exampleParameterValue: '开发者',
+ exampleImages: inlineExampleImage,
+ exampleInputImages: inlineExampleImage,
+ exampleImageUpload: 'favorite-blue.svg',
+ exampleInputImageUpload: 'favorite-blue.svg',
+ };
+
+ await openFavoritesPage(page);
+
+ await createFavoriteWithReproData(page, firstFavorite);
+ await createFavoriteWithReproData(page, secondFavorite);
+
+ await selectFavoriteByTitle(page, firstFavorite.title);
+ await expect(page.getByTestId('favorite-detail-panel')).toContainText(firstFavorite.variableName);
+ await expect(page.getByTestId('favorite-detail-panel')).toContainText(firstFavorite.exampleId);
+
+ await page.getByTestId('favorite-detail-edit').click();
+ await expect(page.getByTestId('favorite-editor-title')).toBeVisible({ timeout: 10000 });
+ await expect(page.locator('[data-testid="favorite-editor-title"] input')).toHaveValue(firstFavorite.title);
+
+ await page.getByTestId('favorite-editor-cancel').click();
+ await closeFavoritesDrawerIfOpen(page);
+ await selectFavoriteByTitle(page, secondFavorite.title);
+ await page.getByTestId('favorite-detail-edit').click();
+ await expect(page.locator('[data-testid="favorite-editor-title"] input')).toHaveValue(secondFavorite.title);
+ await expect(page.locator('[data-testid="favorite-editor-content"] textarea')).toHaveValue(secondFavorite.content);
+ await expect(page.locator('[data-testid="favorite-repro-variable-name"] input')).toHaveValue(secondFavorite.variableName);
+ await expect(page.locator('[data-testid="favorite-repro-example-id"] input')).toHaveValue(secondFavorite.exampleId);
+
+ await fillFieldByTestId(page, 'favorite-editor-description', '蓝色图片收藏已通过 E2E 编辑验证');
+ await page.getByTestId('favorite-editor-save').click();
+ const detailPanel = page.getByTestId('favorite-detail-panel');
+ await expect(detailPanel).toBeVisible({ timeout: 15000 });
+ await expect(detailPanel).toContainText('蓝色图片收藏已通过 E2E 编辑验证');
+ await expect(detailPanel).toContainText(secondFavorite.variableName);
+ await expect(detailPanel).toContainText(secondFavorite.exampleId);
+
+ await page.getByTestId('favorite-detail-use').click();
+ await expect(page).toHaveURL(/\/#\/basic\/system$/, { timeout: 20000 });
+ await expect(page.locator('[data-testid="basic-system-input"] textarea')).toHaveValue(secondFavorite.content, {
+ timeout: 10000,
+ });
+
+ await openFavoritesPage(page);
+ await selectFavoriteByTitle(page, secondFavorite.title);
+ page.once('dialog', async (dialog) => {
+ await dialog.accept();
+ });
+ await page.getByTestId('favorite-detail-delete').click();
+ await expect(page.getByTestId('favorite-workspace-list-item').filter({ hasText: secondFavorite.title })).toHaveCount(0, {
+ timeout: 10000,
+ });
+ });
+});
+
+test.describe('收藏示例应用流程', () => {
+ test('能够从收藏示例应用非图像变量提示词和图像提示词,不调用 LLM', async ({ page }) => {
+ test.setTimeout(60000);
+
+ const now = Date.now();
+ const nonImageFavorite = {
+ id: 'e2e-fav-pro-variable-example',
+ title: 'E2E 非图像示例收藏',
+ description: '验证收藏示例参数进入上下文变量模式',
+ content: '请围绕 {{topic}} 写一个结构化提示词。',
+ createdAt: now,
+ updatedAt: now,
+ tags: [],
+ useCount: 0,
+ functionMode: 'context',
+ optimizationMode: 'user',
+ metadata: {
+ reproducibility: {
+ variables: [{ name: 'topic', defaultValue: '默认主题', required: true }],
+ examples: [{ id: 'case-topic', parameters: { topic: '收藏示例主题' } }],
+ },
+ },
+ };
+ const imageFavorite = {
+ id: 'e2e-fav-image-example',
+ title: 'E2E 图像示例收藏',
+ description: '验证收藏示例可应用图像模式输入图和变量',
+ content: '生成一张 {{scene}} 的参考图。',
+ createdAt: now + 1,
+ updatedAt: now + 1,
+ tags: [],
+ useCount: 0,
+ functionMode: 'image',
+ imageSubMode: 'multiimage',
+ metadata: {
+ reproducibility: {
+ variables: [{ name: 'scene', defaultValue: '默认场景' }],
+ examples: [{ id: 'case-image', parameters: { scene: '夜晚花园' }, inputImages: ['https://favorite-example.local/input.png'] }],
+ },
+ },
+ };
+
+ await openFavoritesPage(page);
+ await seedFavorites(page, [nonImageFavorite, imageFavorite]);
+ await page.route('https://favorite-example.local/input.png', async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: 'image/png',
+ body: Buffer.from(inlineExampleImage.split(',')[1] || '', 'base64'),
+ });
+ });
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitForAppReady(page);
+ await expect(page.getByTestId('favorites-manager-add')).toBeVisible({ timeout: 20000 });
+ await selectFavoriteByTitle(page, nonImageFavorite.title);
+ await page.getByTestId('favorite-repro-example-apply-0').click();
+ await expect(page).toHaveURL(/\/#\/pro\/variable$/, { timeout: 20000 });
+ await expect(page.getByTestId('workspace')).toHaveAttribute('data-mode', 'pro-variable');
+ await expect(page.getByTestId('pro-variable-input')).toContainText(nonImageFavorite.content, { timeout: 10000 });
+ await expect(page.locator('[data-testid="workspace"][data-mode="pro-variable"]')).toContainText('topic', { timeout: 10000 });
+ await expectAnyTextareaValue(page, '收藏示例主题');
+
+ await openFavoritesPage(page);
+ await selectFavoriteByTitle(page, imageFavorite.title);
+ await page.getByTestId('favorite-repro-example-apply-0').click();
+ await expect(page).toHaveURL(/\/#\/image\/multiimage$/, { timeout: 20000 });
+ await expect(page.getByTestId('workspace')).toHaveAttribute('data-mode', 'image-multiimage');
+ await expect(page.getByTestId('workspace')).toContainText(imageFavorite.content, { timeout: 10000 });
+ await expectAnyTextareaValue(page, '夜晚花园');
+ await expect(page.locator('[data-testid="workspace"][data-mode="image-multiimage"] img[src^="data:image/"]')).toHaveCount(1, { timeout: 10000 });
+ });
+});
diff --git a/tests/e2e/fixtures/images/favorite-blue.svg b/tests/e2e/fixtures/images/favorite-blue.svg
new file mode 100644
index 00000000..ff4fb4c0
--- /dev/null
+++ b/tests/e2e/fixtures/images/favorite-blue.svg
@@ -0,0 +1,4 @@
+
diff --git a/tests/e2e/fixtures/images/favorite-red.svg b/tests/e2e/fixtures/images/favorite-red.svg
new file mode 100644
index 00000000..47de18c0
--- /dev/null
+++ b/tests/e2e/fixtures/images/favorite-red.svg
@@ -0,0 +1,4 @@
+