feat(ui): enhance favorites with example editing, media management, and workspace apply

- Add reproducibility example editing with media support in favorite editor
- Add example apply to workspace sessions (pro-variable, image modes)
- Harden favorites page routing, guards, and garden deduplication
- Consolidate FavoriteCard into editor form with full test coverage
This commit is contained in:
linshen
2026-04-27 21:28:00 +08:00
parent 2dc0b99e78
commit f034bde2d4
38 changed files with 2770 additions and 863 deletions

View File

@@ -1,488 +0,0 @@
<template>
<NCard
hoverable
class="favorite-card"
:class="{ 'is-selected': isSelected }"
:header-style="{ padding: '12px 16px' }"
:content-style="{ padding: '14px 16px', display: 'flex', flexDirection: 'column', gap: '12px' }"
:footer-style="{ padding: '12px 16px' }"
@click="$emit('select', favorite)"
>
<template #header>
<div class="favorite-card__header">
<NEllipsis class="favorite-card__title">
{{ favorite.title }}
</NEllipsis>
<NTag
v-if="category"
:color="category.color ? { color: category.color, textColor: 'white' } : undefined"
size="small"
:bordered="false"
class="favorite-card__category"
>
<NEllipsis class="favorite-card__category-text">
{{ category.name }}
</NEllipsis>
</NTag>
</div>
</template>
<template #cover>
<div class="favorite-card__cover" data-testid="favorite-card-cover">
<AppPreviewImage
v-if="coverImageSrc"
:src="coverImageSrc"
:alt="favorite.title"
object-fit="cover"
class="favorite-card__cover-image"
preview-disabled
/>
<div
v-else
class="favorite-card__cover-placeholder"
data-testid="favorite-card-cover-placeholder"
>
<NThing>
<template #header>
<NTag
:type="getFunctionModeTagType(normalizedFunctionMode)"
size="small"
:bordered="false"
>
{{ modeLabel }}
</NTag>
</template>
<NText depth="3" class="favorite-card__cover-summary">
{{ coverSummary }}
</NText>
</NThing>
</div>
</div>
</template>
<div class="favorite-card__body">
<NSpace :size="6" :wrap="false" align="center" class="favorite-card__mode-row">
<NTag
:type="getFunctionModeTagType(normalizedFunctionMode)"
size="small"
:bordered="false"
>
{{ modeLabel }}
</NTag>
<NTag
v-if="subModeLabel"
:type="getSubModeTagType(favorite)"
size="small"
:bordered="false"
>
{{ subModeLabel }}
</NTag>
</NSpace>
<NEllipsis
:line-clamp="3"
:tooltip="false"
class="favorite-card__summary"
>
<NText depth="2">
{{ summaryText }}
</NText>
</NEllipsis>
<div class="favorite-card__tag-row">
<NSpace v-if="displayedTags.length > 0" :size="6" :wrap="false">
<NTag
v-for="tag in displayedTags"
:key="tag"
size="small"
type="info"
:bordered="false"
>
{{ tag }}
</NTag>
</NSpace>
</div>
</div>
<template #footer>
<div class="favorite-card__footer">
<NText depth="3" class="favorite-card__meta">
{{ metaText }}
</NText>
<NSpace :size="8" align="center" :wrap="false" class="favorite-card__actions">
<NButton
size="small"
type="primary"
data-testid="favorite-card-use-button"
@click.stop="$emit('use', favorite)"
>
<template #icon>
<NIcon><PlayerPlay /></NIcon>
</template>
{{ t('favorites.manager.card.useNow') }}
</NButton>
<NButton
size="small"
secondary
data-testid="favorite-card-copy-button"
@click.stop="$emit('copy', favorite)"
>
<template #icon>
<NIcon><Copy /></NIcon>
</template>
{{ t('favorites.manager.card.copyContent') }}
</NButton>
<NDropdown
trigger="click"
:options="menuOptions"
@select="handleMenuSelect"
>
<NButton
size="small"
quaternary
circle
data-testid="favorite-card-menu-button"
@click.stop
>
<template #icon>
<NIcon><DotsVertical /></NIcon>
</template>
</NButton>
</NDropdown>
</NSpace>
</div>
</template>
</NCard>
</template>
<script setup lang="ts">
import { computed, h, inject, ref, watch, type Ref } from 'vue'
import {
NButton,
NCard,
NDropdown,
NEllipsis,
NIcon,
NSpace,
NTag,
NText,
NThing,
} from 'naive-ui'
import { Copy, DotsVertical, Edit, PlayerPlay, Trash } from '@vicons/tabler'
import { useI18n } from 'vue-i18n'
import type { FavoriteCategory, FavoritePrompt } from '@prompt-optimizer/core'
import type { AppServices } from '../types/services'
import { resolveAssetIdToDataUrl } from '../utils/image-asset-storage'
import { parseFavoriteMediaMetadata } from '../utils/favorite-media'
import { normalizeFavoriteFunctionMode } from '../utils/favorite-mode'
import AppPreviewImage from './media/AppPreviewImage.vue'
const { t } = useI18n()
const services = inject<Ref<AppServices | null> | null>('services', null)
interface Props {
favorite: FavoritePrompt
category?: FavoriteCategory
isSelected?: boolean
cardHeight?: number
}
const props = defineProps<Props>()
const emit = defineEmits<{
'select': [favorite: FavoritePrompt]
'edit': [favorite: FavoritePrompt]
'copy': [favorite: FavoritePrompt]
'delete': [favorite: FavoritePrompt]
'use': [favorite: FavoritePrompt]
}>()
const coverImageSrc = ref<string | null>(null)
const displayedTags = computed(() => props.favorite.tags.slice(0, 2))
const normalizedFunctionMode = computed(() => normalizeFavoriteFunctionMode(props.favorite.functionMode))
const modeLabel = computed(() => t(`favorites.manager.card.functionMode.${normalizedFunctionMode.value}`))
const summaryText = computed(() =>
props.favorite.description?.trim()
|| props.favorite.content.trim()
)
const coverSummary = computed(() => summaryText.value)
const metaText = computed(() =>
`${formatDate(props.favorite.updatedAt)} · ${t('favorites.manager.preview.useCountInline', { count: props.favorite.useCount })}`
)
const subModeLabel = computed(() => getSubModeLabel(props.favorite))
const menuOptions = computed(() => [
{
label: t('favorites.manager.card.edit'),
key: 'edit',
icon: () => h(NIcon, null, { default: () => h(Edit) }),
},
{
label: t('favorites.manager.card.delete'),
key: 'delete',
icon: () => h(NIcon, null, { default: () => h(Trash) }),
},
])
const handleMenuSelect = (key: string) => {
if (key === 'edit') {
emit('edit', props.favorite)
return
}
if (key === 'delete') {
emit('delete', props.favorite)
}
}
const getReadStorageCandidates = () => {
const favoriteStorage = services?.value?.favoriteImageStorageService || null
const legacyStorage = services?.value?.imageStorageService || null
if (favoriteStorage && legacyStorage && favoriteStorage !== legacyStorage) {
return [favoriteStorage, legacyStorage]
}
if (favoriteStorage) return [favoriteStorage]
if (legacyStorage) return [legacyStorage]
return []
}
const resolveCoverImage = async () => {
const media = parseFavoriteMediaMetadata(props.favorite)
if (!media) {
coverImageSrc.value = null
return
}
const storageCandidates = getReadStorageCandidates()
if (storageCandidates.length === 0) {
coverImageSrc.value = media.coverUrl || null
return
}
if (media.coverAssetId) {
for (const storageService of storageCandidates) {
try {
const dataUrl = await resolveAssetIdToDataUrl(media.coverAssetId, storageService)
if (dataUrl) {
coverImageSrc.value = dataUrl
return
}
} catch (error) {
console.warn('[FavoriteCard] Failed to resolve cover asset id:', media.coverAssetId, error)
}
}
}
coverImageSrc.value = media.coverUrl || null
}
watch(
() => props.favorite,
() => {
void resolveCoverImage()
},
{ immediate: true },
)
watch(
() => [services?.value?.favoriteImageStorageService, services?.value?.imageStorageService],
() => {
void resolveCoverImage()
},
)
const getFunctionModeTagType = (mode: string): 'default' | 'info' | 'success' => {
const typeMap: Record<string, 'default' | 'info' | 'success'> = {
basic: 'default',
context: 'info',
image: 'success',
}
return typeMap[mode] || 'default'
}
const getSubModeTagType = (favorite: FavoritePrompt): 'warning' | 'error' | 'success' | 'info' | 'default' => {
if (favorite.optimizationMode) {
return favorite.optimizationMode === 'system' ? 'warning' : 'error'
}
if (favorite.imageSubMode) {
return favorite.imageSubMode === 'text2image' ? 'success' : 'info'
}
return 'default'
}
const getSubModeLabel = (favorite: FavoritePrompt): string => {
if (favorite.optimizationMode) {
const isContextMode = normalizeFavoriteFunctionMode(favorite.functionMode) === 'context'
if (isContextMode) {
return favorite.optimizationMode === 'system'
? t('contextMode.optimizationMode.message')
: t('contextMode.optimizationMode.variable')
}
return t(`favorites.manager.card.optimizationMode.${favorite.optimizationMode}`)
}
if (favorite.imageSubMode) {
return t(`favorites.manager.card.imageSubMode.${favorite.imageSubMode}`)
}
return ''
}
const formatDate = (timestamp: number) => {
const date = new Date(timestamp)
const now = new Date()
const diff = now.getTime() - date.getTime()
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
if (days === 0) {
const hours = Math.floor(diff / (1000 * 60 * 60))
if (hours === 0) {
const minutes = Math.floor(diff / (1000 * 60))
return minutes <= 1 ? t('favorites.manager.time.justNow') : t('favorites.manager.time.minutesAgo', { minutes })
}
return t('favorites.manager.time.hoursAgo', { hours })
}
if (days === 1) {
return t('favorites.manager.time.yesterday')
}
if (days < 7) {
return t('favorites.manager.time.daysAgo', { days })
}
return date.toLocaleDateString()
}
</script>
<style scoped>
.favorite-card {
height: 100%;
cursor: pointer;
}
.favorite-card.is-selected {
box-shadow: inset 0 0 0 1px rgba(var(--primary-color), 0.85);
}
.favorite-card__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
overflow: hidden;
}
.favorite-card__title {
min-width: 0;
flex: 1;
font-size: 14px;
font-weight: 600;
}
.favorite-card__category {
max-width: 112px;
flex-shrink: 0;
}
.favorite-card__category-text {
max-width: 88px;
}
.favorite-card__cover {
min-height: 168px;
background: color-mix(in srgb, var(--n-color-embedded) 84%, transparent);
}
.favorite-card__cover :deep(.n-card-cover) {
margin: 0;
}
.favorite-card__cover-image {
display: block;
width: 100%;
height: 168px;
}
.favorite-card__cover-placeholder {
display: flex;
min-height: 168px;
padding: 16px;
align-items: flex-end;
background:
linear-gradient(160deg, color-mix(in srgb, var(--n-color-embedded) 88%, white 12%), var(--n-color)),
linear-gradient(180deg, transparent, color-mix(in srgb, var(--n-color-embedded) 86%, black 14%));
}
.favorite-card__cover-summary {
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
word-break: break-word;
white-space: pre-wrap;
}
.favorite-card__body {
display: flex;
min-height: 108px;
flex-direction: column;
gap: 10px;
}
.favorite-card__mode-row {
min-height: 28px;
}
.favorite-card__summary {
min-height: 60px;
line-height: 1.55;
}
.favorite-card__tag-row {
min-height: 24px;
overflow: hidden;
}
.favorite-card__footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.favorite-card__meta {
min-width: 0;
font-size: 12px;
}
.favorite-card__actions {
flex-shrink: 0;
}
@media (max-width: 768px) {
.favorite-card__footer {
align-items: stretch;
flex-direction: column;
}
.favorite-card__actions {
justify-content: flex-end;
}
}
</style>

View File

@@ -20,6 +20,7 @@
<NSpace :size="8" align="center" wrap>
<NButton
data-testid="favorite-detail-use"
type="primary"
@click="$emit('use', favorite)"
>
@@ -29,6 +30,7 @@
{{ t('favorites.manager.card.useNow') }}
</NButton>
<NButton
data-testid="favorite-detail-copy"
secondary
@click="$emit('copy', favorite)"
>
@@ -38,6 +40,7 @@
{{ t('favorites.manager.card.copyContent') }}
</NButton>
<NButton
data-testid="favorite-detail-fullscreen"
quaternary
@click="$emit('fullscreen', favorite)"
>
@@ -47,6 +50,7 @@
{{ t('common.fullscreen') }}
</NButton>
<NButton
data-testid="favorite-detail-edit"
quaternary
@click="$emit('edit', favorite)"
>
@@ -56,6 +60,7 @@
{{ t('favorites.manager.card.edit') }}
</NButton>
<NButton
data-testid="favorite-detail-delete"
quaternary
type="error"
@click="$emit('delete', favorite)"
@@ -194,11 +199,17 @@
name="reproducibility"
:title="t('favorites.manager.preview.reproducibility.title')"
>
<FavoriteReproducibilityDisplay :reproducibility="reproducibility" />
<FavoriteReproducibilityDisplay
:reproducibility="reproducibility"
:example-previews="reproducibilityExamplePreviews"
@apply-example="handleApplyExample"
/>
</NCollapseItem>
<NCollapseItem name="extra" :title="t('favorites.manager.preview.extraTitle')">
<FavoritePreviewExtensionHost
:favorite="favorite"
:garden-snapshot-hidden-sections="promotedGardenSnapshotSections"
garden-snapshot-source-only
@favorite-updated="handleFavoriteUpdated"
/>
</NCollapseItem>
@@ -298,11 +309,17 @@
name="reproducibility"
:title="t('favorites.manager.preview.reproducibility.title')"
>
<FavoriteReproducibilityDisplay :reproducibility="reproducibility" />
<FavoriteReproducibilityDisplay
:reproducibility="reproducibility"
:example-previews="reproducibilityExamplePreviews"
@apply-example="handleApplyExample"
/>
</NCollapseItem>
<NCollapseItem name="extra" :title="t('favorites.manager.preview.extraTitle')">
<FavoritePreviewExtensionHost
:favorite="favorite"
:garden-snapshot-hidden-sections="promotedGardenSnapshotSections"
garden-snapshot-source-only
@favorite-updated="handleFavoriteUpdated"
/>
</NCollapseItem>
@@ -361,7 +378,7 @@ const props = withDefaults(defineProps<{
const emit = defineEmits<{
'back': []
'use': [favorite: FavoritePrompt]
'use': [favorite: FavoritePrompt, options?: { applyExample?: boolean; exampleId?: string; exampleIndex?: number }]
'copy': [favorite: FavoritePrompt]
'edit': [favorite: FavoritePrompt]
'delete': [favorite: FavoritePrompt]
@@ -374,8 +391,14 @@ const services = inject<Ref<AppServices | null> | null>('services', null)
const assetDataUrlCache = new Map<string, string>()
const displayImages = ref<string[]>([])
const promotedGardenSnapshotSections = ['metaInfo', 'cover', 'showcases', 'examples', 'variables']
const reproducibilityExamplePreviews = ref<Array<{
images: Array<{ assetId: string; source: string }>
inputImages: Array<{ assetId: string; source: string }>
}>>([])
const activeImageIndex = ref(0)
let resolveSequence = 0
let reproducibilityResolveSequence = 0
const detailVariant = computed(() => (displayImages.value.length > 0 ? 'image' : 'text'))
const activeImage = computed(() => displayImages.value[activeImageIndex.value] || '')
@@ -504,10 +527,51 @@ const refreshDisplayImages = async () => {
activeImageIndex.value = 0
}
const refreshReproducibilityExamplePreviews = async () => {
const currentSequence = ++reproducibilityResolveSequence
const favorite = props.favorite
if (!favorite) {
reproducibilityExamplePreviews.value = []
return
}
const parsed = parseFavoriteReproducibility(favorite)
const resolveAssetPreviews = async (assetIds: string[]) => {
const previewItems: Array<{ assetId: string; source: string }> = []
for (const assetId of assetIds) {
const source = (await resolveAssetIdsToDataUrls([assetId]))[0]
if (currentSequence !== reproducibilityResolveSequence) return []
if (source) {
previewItems.push({ assetId, source })
}
}
return previewItems
}
const previews: Array<{
images: Array<{ assetId: string; source: string }>
inputImages: Array<{ assetId: string; source: string }>
}> = []
for (const example of parsed.examples) {
const images = await resolveAssetPreviews(example.imageAssetIds)
if (currentSequence !== reproducibilityResolveSequence) return
const inputImages = await resolveAssetPreviews(example.inputImageAssetIds)
if (currentSequence !== reproducibilityResolveSequence) return
previews.push({
images,
inputImages,
})
}
if (currentSequence !== reproducibilityResolveSequence) return
reproducibilityExamplePreviews.value = previews
}
watch(
() => props.favorite,
() => {
void refreshDisplayImages()
void refreshReproducibilityExamplePreviews()
},
{ immediate: true },
)
@@ -516,6 +580,7 @@ watch(
() => [services?.value?.favoriteImageStorageService, services?.value?.imageStorageService],
() => {
void refreshDisplayImages()
void refreshReproducibilityExamplePreviews()
},
)
@@ -563,6 +628,11 @@ const formatDate = (timestamp: number) => {
const handleFavoriteUpdated = (favoriteId: string) => {
emit('favorite-updated', favoriteId)
}
const handleApplyExample = (options: { exampleId?: string; exampleIndex: number }) => {
if (!props.favorite) return
emit('use', props.favorite, { ...options, applyExample: true })
}
</script>
<style scoped>

View File

@@ -13,6 +13,7 @@
<NGridItem>
<NFormItem :label="t('favorites.dialog.titleLabel')" required>
<NInput
data-testid="favorite-editor-title"
v-model:value="formData.title"
:placeholder="t('favorites.dialog.titlePlaceholder')"
maxlength="100"
@@ -71,6 +72,7 @@
<NFormItem :label="t('favorites.dialog.descriptionLabel')">
<NInput
data-testid="favorite-editor-description"
v-model:value="formData.description"
type="textarea"
:placeholder="t('favorites.dialog.descriptionPlaceholder')"
@@ -121,6 +123,7 @@
<NText depth="3">{{ t('favorites.dialog.imagesUploadSupport') }}</NText>
</div>
<NUpload
data-testid="favorite-editor-image-upload-empty"
accept="image/*"
multiple
:default-upload="false"
@@ -128,7 +131,7 @@
:disabled="saving"
@before-upload="handleBeforeImageUpload"
>
<NButton secondary size="small">
<NButton secondary size="small" data-testid="favorite-editor-add-images-empty">
{{ t('favorites.dialog.addImages') }}
</NButton>
</NUpload>
@@ -138,6 +141,7 @@
<template v-else>
<NSpace justify="space-between" align="center" wrap>
<NUpload
data-testid="favorite-editor-image-upload"
accept="image/*"
multiple
:default-upload="false"
@@ -145,12 +149,13 @@
:disabled="saving"
@before-upload="handleBeforeImageUpload"
>
<NButton secondary>
<NButton secondary data-testid="favorite-editor-add-images">
{{ t('favorites.dialog.addImages') }}
</NButton>
</NUpload>
<NButton
data-testid="favorite-editor-clear-images"
quaternary
type="error"
size="small"
@@ -188,6 +193,7 @@
{{ t('favorites.dialog.coverTag') }}
</NTag>
<NButton
data-testid="favorite-editor-set-cover"
v-else
quaternary
size="tiny"
@@ -197,6 +203,7 @@
</NButton>
<NButton
data-testid="favorite-editor-remove-image"
quaternary
type="error"
size="tiny"
@@ -215,6 +222,7 @@
<FavoriteReproducibilityEditor
v-model:variables="reproducibilityVariables"
v-model:examples="reproducibilityExamples"
:example-previews="reproducibilityExamplePreviews"
/>
<NCard
@@ -223,6 +231,7 @@
:segmented="{ content: true }"
>
<NInput
data-testid="favorite-editor-content"
v-model:value="formData.content"
type="textarea"
:placeholder="t('favorites.dialog.contentPlaceholder')"
@@ -235,10 +244,10 @@
<div class="favorite-editor-form__actions" :class="{ 'favorite-editor-form__actions--embedded': embedded }">
<NSpace justify="end">
<NButton :disabled="saving" @click="$emit('cancel')">
<NButton data-testid="favorite-editor-cancel" :disabled="saving" @click="$emit('cancel')">
{{ t('favorites.dialog.cancel') }}
</NButton>
<NButton type="primary" :loading="saving" @click="handleSave">
<NButton data-testid="favorite-editor-save" type="primary" :loading="saving" @click="handleSave">
{{ t('favorites.dialog.save') }}
</NButton>
</NSpace>
@@ -330,6 +339,11 @@ const emit = defineEmits<{
'saved': [favoriteId: string]
}>()
type FavoriteReproducibilityExamplePreviews = {
images: Array<{ assetId: string; source: string }>
inputImages: Array<{ assetId: string; source: string }>
}
const services = inject<Ref<AppServices | null>>('services')
const message = useToast()
@@ -339,6 +353,7 @@ const mediaTouched = ref(false)
const tagInputValue = ref('')
const reproducibilityVariables = ref<FavoriteReproducibilityVariable[]>([])
const reproducibilityExamples = ref<FavoriteReproducibilityExample[]>([])
const reproducibilityExamplePreviews = ref<FavoriteReproducibilityExamplePreviews[]>([])
const isMobile = computed(() => viewportWidth.value < 768)
@@ -357,6 +372,7 @@ const mediaDraft = reactive({
sources: [] as string[],
coverIndex: -1,
})
let hydrateRequestId = 0
const tagSuggestions = computed(() => {
const suggestions = filterTags(tagInputValue.value, formData.tags)
@@ -443,6 +459,7 @@ const cloneReproducibilityExamples = (
const resetReproducibilityDraft = () => {
reproducibilityVariables.value = []
reproducibilityExamples.value = []
reproducibilityExamplePreviews.value = []
}
const hydrateReproducibilityDraft = (
@@ -455,9 +472,57 @@ const hydrateReproducibilityDraft = (
reproducibilityVariables.value = cloneReproducibilityVariables(reproducibility.variables)
reproducibilityExamples.value = cloneReproducibilityExamples(reproducibility.examples)
reproducibilityExamplePreviews.value = reproducibility.examples.map(() => ({
images: [],
inputImages: [],
}))
}
const hydrateMediaDraft = async (metadata?: Record<string, unknown>, favorite?: FavoritePrompt) => {
const hydrateReproducibilityExamplePreviews = async (
metadata?: Record<string, unknown>,
favorite?: FavoritePrompt,
isStale: () => boolean = () => false,
) => {
const reproducibility = favorite
? parseFavoriteReproducibility(favorite)
: parseFavoriteReproducibilityFromMetadata(metadata)
const previews: FavoriteReproducibilityExamplePreviews[] = []
const resolveAssetPreviews = async (assetIds: string[]) => {
const previewItems: Array<{ assetId: string; source: string }> = []
for (const assetId of assetIds) {
if (isStale()) return []
const source = (await resolveAssetIdsToDataUrls([assetId]))[0]
if (isStale()) return []
if (source) {
previewItems.push({ assetId, source })
}
}
return previewItems
}
for (const example of reproducibility.examples) {
if (isStale()) return
const images = await resolveAssetPreviews(example.imageAssetIds)
if (isStale()) return
const inputImages = await resolveAssetPreviews(example.inputImageAssetIds)
if (isStale()) return
previews.push({
images,
inputImages,
})
}
if (isStale()) return
reproducibilityExamplePreviews.value = previews
}
const hydrateMediaDraft = async (
metadata?: Record<string, unknown>,
favorite?: FavoritePrompt,
isStale: () => boolean = () => false,
) => {
if (isStale()) return
resetMediaDraft()
const media = favorite
? parseFavoriteMediaMetadata(favorite)
@@ -469,7 +534,9 @@ const hydrateMediaDraft = async (metadata?: Record<string, unknown>, favorite?:
const resolvedCover = media.coverAssetId
? (await resolveAssetIdsToDataUrls([media.coverAssetId]))[0]
: undefined
if (isStale()) return
const resolvedAssets = await resolveAssetIdsToDataUrls(media.assetIds)
if (isStale()) return
const sources = dedupeStrings([
resolvedCover || media.coverUrl || '',
@@ -564,12 +631,87 @@ const buildMediaMetadataForSave = async () => {
})
}
const persistSourcesForFavoriteAssets = async (sources: string[]) => {
const normalizedSources = dedupeStrings(
sources.map((item) => String(item || '').trim()).filter(Boolean),
)
const preferredStorage = getPreferredStorageService()
const assetIds: string[] = []
const fallbackSources: string[] = []
for (const source of normalizedSources) {
if (!preferredStorage) {
fallbackSources.push(source)
continue
}
try {
const assetId = await persistImageSourceAsAssetId({
source,
storageService: preferredStorage,
sourceType: 'uploaded',
})
if (assetId) {
assetIds.push(assetId)
} else {
fallbackSources.push(source)
}
} catch (error) {
console.warn('[FavoriteEditorForm] Failed to persist example image source:', error)
fallbackSources.push(source)
}
}
return {
assetIds: dedupeStrings(assetIds),
fallbackSources: dedupeStrings(fallbackSources),
}
}
const buildReproducibilityDraftForSave = async () => {
const examples: FavoriteReproducibilityExample[] = []
for (const example of toRaw(reproducibilityExamples.value)) {
const exampleImages = await persistSourcesForFavoriteAssets(example.images || [])
const inputImages = await persistSourcesForFavoriteAssets(example.inputImages || [])
examples.push({
...example,
parameters: { ...example.parameters },
images: exampleImages.fallbackSources,
imageAssetIds: dedupeStrings([
...(example.imageAssetIds || []),
...exampleImages.assetIds,
]),
inputImages: inputImages.fallbackSources,
inputImageAssetIds: dedupeStrings([
...(example.inputImageAssetIds || []),
...inputImages.assetIds,
]),
})
}
return {
variables: toRaw(reproducibilityVariables.value).map((variable) => ({
...variable,
options: [...variable.options],
})),
examples,
}
}
const handleBeforeImageUpload = async (options: { file: UploadFileInfo }) => {
const raw = (options.file as unknown as { file?: Blob | null }).file
if (!raw) return false
const requestId = hydrateRequestId
try {
const dataUrl = await readBlobAsDataUrl(raw)
if (requestId !== hydrateRequestId) {
return false
}
if (dataUrl) {
mediaDraft.sources = dedupeStrings([...mediaDraft.sources, dataUrl])
mediaTouched.value = true
@@ -760,13 +902,11 @@ const handleSave = async () => {
}
const currentReproducibility = parseFavoriteReproducibilityFromMetadata(existingMetadata)
const reproducibilityDraft = await buildReproducibilityDraftForSave()
const hasReproducibilityDraft =
reproducibilityVariables.value.length > 0 || reproducibilityExamples.value.length > 0
reproducibilityDraft.variables.length > 0 || reproducibilityDraft.examples.length > 0
if (currentReproducibility.hasData || hasReproducibilityDraft) {
existingMetadata = applyFavoriteReproducibilityToMetadata(existingMetadata, {
variables: toRaw(reproducibilityVariables.value),
examples: toRaw(reproducibilityExamples.value),
})
existingMetadata = applyFavoriteReproducibilityToMetadata(existingMetadata, reproducibilityDraft)
}
const metadata = Object.keys(existingMetadata).length > 0 ? existingMetadata : undefined
@@ -805,9 +945,17 @@ watch(() => [
props.currentOptimizationMode,
props.prefill,
props.favorite,
], async () => {
], async (_value, _oldValue, onCleanup) => {
const requestId = ++hydrateRequestId
let cancelled = false
const isStale = () => cancelled || requestId !== hydrateRequestId
onCleanup(() => {
cancelled = true
})
mediaTouched.value = false
await loadTags()
if (isStale()) return
if (props.mode === 'create') {
formData.title = ''
@@ -832,21 +980,26 @@ watch(() => [
formData.functionMode = normalizeFavoriteFunctionMode(props.favorite.functionMode)
formData.optimizationMode = props.favorite.optimizationMode
formData.imageSubMode = props.favorite.imageSubMode
await hydrateMediaDraft(undefined, props.favorite)
await hydrateMediaDraft(undefined, props.favorite, isStale)
if (isStale()) return
hydrateReproducibilityDraft(undefined, props.favorite)
await hydrateReproducibilityExamplePreviews(undefined, props.favorite, isStale)
return
}
const prefill = props.prefill
const resolvedCategory = await resolvePrefillCategoryId(
typeof prefill?.category === 'string' ? prefill.category : '',
)
if (isStale()) return
const titleSource = (typeof prefill?.title === 'string' && prefill.title.trim()
? prefill.title
: props.originalContent || props.content || '')
formData.title = titleSource.replace(/\r?\n/g, ' ').substring(0, 30).trim()
formData.content = props.content || ''
formData.description = typeof prefill?.description === 'string' ? prefill.description : ''
formData.category = await resolvePrefillCategoryId(
typeof prefill?.category === 'string' ? prefill.category : '',
)
formData.category = resolvedCategory
formData.tags = Array.isArray(prefill?.tags)
? dedupeStrings(prefill.tags.map((tag) => String(tag || '').trim()).filter(Boolean))
: []
@@ -886,8 +1039,10 @@ watch(() => [
prefill?.metadata && typeof prefill.metadata === 'object'
? (prefill.metadata as Record<string, unknown>)
: undefined
await hydrateMediaDraft(prefillMetadata)
await hydrateMediaDraft(prefillMetadata, undefined, isStale)
if (isStale()) return
hydrateReproducibilityDraft(prefillMetadata)
await hydrateReproducibilityExamplePreviews(prefillMetadata, undefined, isStale)
}, { immediate: true, deep: true })
const updateViewportWidth = () => {
@@ -903,6 +1058,7 @@ onMounted(() => {
})
onBeforeUnmount(() => {
hydrateRequestId += 1
if (typeof window !== 'undefined') {
window.removeEventListener('resize', updateViewportWidth)
}
@@ -926,6 +1082,10 @@ onBeforeUnmount(() => {
padding: 20px;
}
.favorite-editor-form--embedded .favorite-editor-form__content {
padding: 20px 20px 96px;
}
.favorite-editor-form__tag-field {
width: 100%;
}
@@ -999,6 +1159,10 @@ onBeforeUnmount(() => {
padding: 16px;
}
.favorite-editor-form--embedded .favorite-editor-form__content {
padding: 16px 16px 88px;
}
.favorite-editor-form__media-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}

View File

@@ -561,7 +561,7 @@ const props = withDefaults(defineProps<{
active?: boolean
layout?: LibraryLayout
initialModeFilter?: FavoriteModeFilterKey
useFavorite?: (favorite: FavoritePrompt) => boolean | Promise<boolean>
useFavorite?: (favorite: FavoritePrompt, options?: { applyExample?: boolean; exampleId?: string; exampleIndex?: number }) => boolean | Promise<boolean>
}>(), {
active: true,
layout: 'modal',
@@ -569,7 +569,7 @@ const props = withDefaults(defineProps<{
})
const emit = defineEmits<{
'use-favorite': [favorite: FavoritePrompt]
'use-favorite': [favorite: FavoritePrompt, options?: { applyExample?: boolean; exampleId?: string; exampleIndex?: number }]
}>()
const services = inject<Ref<AppServices | null> | null>('services', null)
@@ -1306,14 +1306,17 @@ const handleDeleteFavorite = (favorite: FavoritePrompt) => {
}
}
const handleUseFavorite = async (favorite: FavoritePrompt) => {
const handleUseFavorite = async (
favorite: FavoritePrompt,
options?: { applyExample?: boolean; exampleId?: string; exampleIndex?: number },
) => {
let used = true
try {
if (props.useFavorite) {
used = await props.useFavorite(favorite)
used = await props.useFavorite(favorite, options)
} else {
emit('use-favorite', favorite)
emit('use-favorite', favorite, options)
}
} catch (error) {
console.error('[FavoriteManager] Failed to use favorite:', error)

View File

@@ -38,7 +38,7 @@ const { t } = useI18n()
const props = withDefaults(defineProps<{
show?: boolean
useFavorite?: (favorite: FavoritePrompt) => boolean | Promise<boolean>
useFavorite?: (favorite: FavoritePrompt, options?: { applyExample?: boolean; exampleId?: string; exampleIndex?: number }) => boolean | Promise<boolean>
}>(), {
show: false,
})
@@ -46,7 +46,7 @@ const props = withDefaults(defineProps<{
const emit = defineEmits<{
// Legacy event kept for consumers that still bind it; the shared library no longer exposes this action.
'optimize-prompt': []
'use-favorite': [favorite: FavoritePrompt]
'use-favorite': [favorite: FavoritePrompt, options?: { applyExample?: boolean; exampleId?: string; exampleIndex?: number }]
'update:show': [value: boolean]
'close': []
}>()
@@ -56,12 +56,15 @@ const close = () => {
emit('close')
}
const handleUseFavorite = async (favorite: FavoritePrompt) => {
const handleUseFavorite = async (
favorite: FavoritePrompt,
options?: { applyExample?: boolean; exampleId?: string; exampleIndex?: number },
) => {
if (props.useFavorite) {
return props.useFavorite(favorite)
return props.useFavorite(favorite, options)
}
emit('use-favorite', favorite)
emit('use-favorite', favorite, options)
return true
}
</script>

View File

@@ -5,6 +5,8 @@
v-for="plugin in activePlugins"
:key="plugin.id"
:favorite="favorite"
:garden-snapshot-hidden-sections="gardenSnapshotHiddenSections"
:garden-snapshot-source-only="gardenSnapshotSourceOnly"
@favorite-updated="handleFavoriteUpdated"
/>
</div>
@@ -21,6 +23,8 @@ import {
const props = defineProps<{
favorite: FavoritePrompt
gardenSnapshotHiddenSections?: string[]
gardenSnapshotSourceOnly?: boolean
}>()
const emit = defineEmits<{

View File

@@ -80,6 +80,15 @@
<NText strong>
{{ example.id || t('favorites.manager.preview.reproducibility.exampleLabel', { index: index + 1 }) }}
</NText>
<NButton
size="small"
secondary
type="primary"
:data-testid="`favorite-repro-example-apply-${index}`"
@click="$emit('apply-example', { exampleId: example.id, exampleIndex: index })"
>
{{ t('favorites.manager.preview.reproducibility.applyExample') }}
</NButton>
<NText v-if="example.text">{{ example.text }}</NText>
<NText v-if="example.description" depth="3">{{ example.description }}</NText>
@@ -98,6 +107,44 @@
{{ row.value }}
</NDescriptionsItem>
</NDescriptions>
<div
v-if="getExampleImageSources(index, 'images', example).length > 0"
class="favorite-reproducibility-display__image-block"
>
<NText strong>{{ t('favorites.manager.preview.reproducibility.images') }}</NText>
<AppPreviewImageGroup>
<div class="favorite-reproducibility-display__image-grid">
<AppPreviewImage
v-for="(source, imageIndex) in getExampleImageSources(index, 'images', example)"
:key="`example-${index}-image-${imageIndex}-${source.slice(0, 24)}`"
:src="source"
:alt="t('favorites.dialog.imageAlt', { index: imageIndex + 1 })"
object-fit="cover"
class="favorite-reproducibility-display__image"
/>
</div>
</AppPreviewImageGroup>
</div>
<div
v-if="getExampleImageSources(index, 'inputImages', example).length > 0"
class="favorite-reproducibility-display__image-block"
>
<NText strong>{{ t('favorites.manager.preview.reproducibility.inputImages') }}</NText>
<AppPreviewImageGroup>
<div class="favorite-reproducibility-display__image-grid">
<AppPreviewImage
v-for="(source, imageIndex) in getExampleImageSources(index, 'inputImages', example)"
:key="`example-${index}-input-image-${imageIndex}-${source.slice(0, 24)}`"
:src="source"
:alt="t('favorites.dialog.imageAlt', { index: imageIndex + 1 })"
object-fit="cover"
class="favorite-reproducibility-display__image"
/>
</div>
</AppPreviewImageGroup>
</div>
</NSpace>
</NCard>
</NSpace>
@@ -108,6 +155,7 @@
<script setup lang="ts">
import {
NButton,
NCard,
NDescriptions,
NDescriptionsItem,
@@ -123,18 +171,42 @@ import type {
FavoriteReproducibility,
FavoriteReproducibilityExample,
} from '../utils/favorite-reproducibility'
import AppPreviewImage from './media/AppPreviewImage.vue'
import AppPreviewImageGroup from './media/AppPreviewImageGroup.vue'
defineProps<{
type FavoriteReproducibilityExamplePreviews = {
images: Array<{ assetId: string; source: string }>
inputImages: Array<{ assetId: string; source: string }>
}
const props = defineProps<{
reproducibility: FavoriteReproducibility
examplePreviews?: FavoriteReproducibilityExamplePreviews[]
}>()
defineEmits<{
'apply-example': [options: { exampleId?: string; exampleIndex: number }]
}>()
const { t } = useI18n()
const dedupeStrings = (items: string[]) => Array.from(new Set(items.filter(Boolean)))
const getExampleImageSources = (
index: number,
field: 'images' | 'inputImages',
example: FavoriteReproducibilityExample,
) => {
const resolvedSources = props.examplePreviews?.[index]?.[field] || []
return dedupeStrings([
...(example[field] || []),
...resolvedSources.map((item) => item.source),
])
}
const exampleSummaryRows = (example: FavoriteReproducibilityExample) => {
const rows: Array<{ label: string; value: string }> = []
const parameterEntries = Object.entries(example.parameters)
const imageCount = example.images.length + example.imageAssetIds.length
const inputImageCount = example.inputImages.length + example.inputImageAssetIds.length
if (parameterEntries.length > 0) {
rows.push({
@@ -143,20 +215,6 @@ const exampleSummaryRows = (example: FavoriteReproducibilityExample) => {
})
}
if (imageCount > 0) {
rows.push({
label: t('favorites.manager.preview.reproducibility.images'),
value: String(imageCount),
})
}
if (inputImageCount > 0) {
rows.push({
label: t('favorites.manager.preview.reproducibility.inputImages'),
value: String(inputImageCount),
})
}
return rows
}
</script>
@@ -197,4 +255,25 @@ const exampleSummaryRows = (example: FavoriteReproducibilityExample) => {
.favorite-reproducibility-display :deep(.n-text) {
overflow-wrap: anywhere;
}
.favorite-reproducibility-display__image-block {
display: flex;
min-width: 0;
flex-direction: column;
gap: 6px;
}
.favorite-reproducibility-display__image-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(84px, 1fr));
gap: 8px;
}
.favorite-reproducibility-display__image {
width: 100%;
aspect-ratio: 1;
border-radius: 6px;
overflow: hidden;
background: var(--n-color-embedded);
}
</style>

View File

@@ -14,10 +14,10 @@
{{ t('favorites.dialog.reproducibility.empty') }}
</NText>
<NSpace :size="8" wrap>
<NButton size="small" secondary @click="handleAddVariable">
<NButton data-testid="favorite-repro-add-variable-empty" size="small" secondary @click="handleAddVariable">
{{ t('favorites.dialog.reproducibility.addVariable') }}
</NButton>
<NButton size="small" secondary @click="handleAddExample">
<NButton data-testid="favorite-repro-add-example-empty" size="small" secondary @click="handleAddExample">
{{ t('favorites.dialog.reproducibility.addExample') }}
</NButton>
</NSpace>
@@ -27,7 +27,7 @@
<section class="favorite-reproducibility-editor__section">
<div class="favorite-reproducibility-editor__section-header">
<NText strong>{{ t('favorites.dialog.reproducibility.variables') }}</NText>
<NButton size="small" secondary @click="handleAddVariable">
<NButton data-testid="favorite-repro-add-variable" size="small" secondary @click="handleAddVariable">
{{ t('favorites.dialog.reproducibility.addVariable') }}
</NButton>
</div>
@@ -55,6 +55,7 @@
</NGridItem>
<NGridItem>
<NInput
data-testid="favorite-repro-variable-default"
:value="variable.defaultValue"
:placeholder="t('favorites.dialog.reproducibility.variableDefaultPlaceholder')"
@update:value="updateVariable(index, { defaultValue: $event })"
@@ -62,6 +63,7 @@
</NGridItem>
<NGridItem>
<NSelect
data-testid="favorite-repro-variable-type"
:value="variable.type"
clearable
:options="variableTypeOptions"
@@ -71,6 +73,7 @@
</NGridItem>
<NGridItem>
<NInput
data-testid="favorite-repro-variable-options"
:value="formatOptions(variable.options)"
:placeholder="t('favorites.dialog.reproducibility.variableOptionsPlaceholder')"
@update:value="updateVariable(index, { options: parseListText($event) })"
@@ -78,6 +81,7 @@
</NGridItem>
<NGridItem>
<NInput
data-testid="favorite-repro-variable-description"
:value="variable.description"
:placeholder="t('favorites.dialog.reproducibility.variableDescriptionPlaceholder')"
@update:value="updateVariable(index, { description: $event })"
@@ -87,12 +91,14 @@
<div class="favorite-reproducibility-editor__item-actions">
<NCheckbox
data-testid="favorite-repro-variable-required"
:checked="variable.required"
@update:checked="updateVariable(index, { required: Boolean($event) })"
>
{{ t('favorites.dialog.reproducibility.required') }}
</NCheckbox>
<NButton
data-testid="favorite-repro-remove-variable"
size="small"
quaternary
type="error"
@@ -108,7 +114,7 @@
<section class="favorite-reproducibility-editor__section">
<div class="favorite-reproducibility-editor__section-header">
<NText strong>{{ t('favorites.dialog.reproducibility.examples') }}</NText>
<NButton size="small" secondary @click="handleAddExample">
<NButton data-testid="favorite-repro-add-example" size="small" secondary @click="handleAddExample">
{{ t('favorites.dialog.reproducibility.addExample') }}
</NButton>
</div>
@@ -123,53 +129,14 @@
<div
v-for="(example, index) in examples"
:key="`${index}-${example.id || example.text || 'example'}`"
class="favorite-reproducibility-editor__item"
class="favorite-reproducibility-editor__item favorite-reproducibility-editor__example"
>
<NGrid cols="1 s:2" :x-gap="10" :y-gap="8" responsive="screen">
<NGridItem>
<NInput
:value="example.id"
:placeholder="t('favorites.dialog.reproducibility.exampleIdPlaceholder')"
@update:value="updateExample(index, { id: $event })"
/>
</NGridItem>
<NGridItem>
<NInput
:value="example.text"
:placeholder="t('favorites.dialog.reproducibility.exampleTextPlaceholder')"
@update:value="updateExample(index, { text: $event })"
/>
</NGridItem>
<NGridItem>
<NInput
:value="example.description"
:placeholder="t('favorites.dialog.reproducibility.exampleDescriptionPlaceholder')"
@update:value="updateExample(index, { description: $event })"
/>
</NGridItem>
<NGridItem>
<NInput
data-testid="favorite-repro-example-parameters"
:value="formatParameters(example.parameters)"
type="textarea"
:autosize="{ minRows: 2, maxRows: 5 }"
:placeholder="t('favorites.dialog.reproducibility.exampleParametersPlaceholder')"
@update:value="updateExample(index, { parameters: parseParametersText($event) })"
/>
</NGridItem>
<NGridItem>
<NInput
:value="formatList(example.inputImages)"
type="textarea"
:autosize="{ minRows: 2, maxRows: 5 }"
:placeholder="t('favorites.dialog.reproducibility.exampleInputImagesPlaceholder')"
@update:value="updateExample(index, { inputImages: parseListText($event) })"
/>
</NGridItem>
</NGrid>
<div class="favorite-reproducibility-editor__item-actions favorite-reproducibility-editor__item-actions--end">
<div class="favorite-reproducibility-editor__example-header">
<NText strong>
{{ example.id || t('favorites.dialog.reproducibility.exampleLabel', { index: index + 1 }) }}
</NText>
<NButton
data-testid="favorite-repro-remove-example"
size="small"
quaternary
type="error"
@@ -178,6 +145,224 @@
{{ t('favorites.dialog.reproducibility.remove') }}
</NButton>
</div>
<div class="favorite-reproducibility-editor__example-basic">
<div>
<NInput
data-testid="favorite-repro-example-id"
:value="example.id"
:placeholder="t('favorites.dialog.reproducibility.exampleIdPlaceholder')"
@update:value="updateExample(index, { id: $event })"
/>
</div>
<div>
<NInput
data-testid="favorite-repro-example-text"
:value="example.text"
:placeholder="t('favorites.dialog.reproducibility.exampleTextPlaceholder')"
@update:value="updateExample(index, { text: $event })"
/>
</div>
<div class="favorite-reproducibility-editor__example-description">
<NInput
data-testid="favorite-repro-example-description"
:value="example.description"
:placeholder="t('favorites.dialog.reproducibility.exampleDescriptionPlaceholder')"
@update:value="updateExample(index, { description: $event })"
/>
</div>
</div>
<div class="favorite-reproducibility-editor__example-section">
<div class="favorite-reproducibility-editor__parameter-field">
<NText strong>{{ t('favorites.dialog.reproducibility.exampleParametersLabel') }}</NText>
<NSpace
v-if="getParameterEntries(example.parameters).length > 0"
vertical
:size="6"
>
<div
v-for="[parameterKey, parameterValue] in getParameterEntries(example.parameters)"
:key="parameterKey"
class="favorite-reproducibility-editor__parameter-row"
>
<NText class="favorite-reproducibility-editor__parameter-key">
{{ parameterKey }}
</NText>
<NInput
data-testid="favorite-repro-example-parameter-value"
:value="parameterValue"
:placeholder="t('favorites.dialog.reproducibility.parameterValuePlaceholder')"
@update:value="handleUpdateExampleParameterValue(index, parameterKey, $event)"
/>
<NButton
data-testid="favorite-repro-example-remove-parameter"
size="small"
quaternary
type="error"
@click="handleRemoveExampleParameter(index, parameterKey)"
>
{{ t('favorites.dialog.reproducibility.remove') }}
</NButton>
</div>
</NSpace>
<div class="favorite-reproducibility-editor__parameter-row">
<NInput
data-testid="favorite-repro-example-parameter-key"
:value="getParameterDraft(index).key"
:placeholder="t('favorites.dialog.reproducibility.parameterNamePlaceholder')"
@update:value="setParameterDraft(index, 'key', $event)"
/>
<NInput
data-testid="favorite-repro-example-parameter-new-value"
:value="getParameterDraft(index).value"
:placeholder="t('favorites.dialog.reproducibility.parameterValuePlaceholder')"
@update:value="setParameterDraft(index, 'value', $event)"
@keydown.enter.prevent="handleAddExampleParameter(index)"
/>
<NButton
data-testid="favorite-repro-example-add-parameter"
size="small"
secondary
@click="handleAddExampleParameter(index)"
>
{{ t('favorites.dialog.reproducibility.addParameter') }}
</NButton>
</div>
</div>
</div>
<div class="favorite-reproducibility-editor__example-media-grid">
<div class="favorite-reproducibility-editor__image-field">
<NText strong>{{ t('favorites.dialog.reproducibility.exampleImages') }}</NText>
<NSpace :size="6" align="center" wrap class="favorite-reproducibility-editor__image-toolbar">
<NInput
data-testid="favorite-repro-example-images"
:value="getImageUrlDraft(index, 'images')"
:placeholder="t('favorites.dialog.reproducibility.exampleImagesPlaceholder')"
@update:value="setImageUrlDraft(index, 'images', $event)"
@keydown.enter.prevent="handleAddExampleImageUrl(index, 'images')"
/>
<NButton
data-testid="favorite-repro-example-add-image-url"
size="small"
secondary
class="favorite-reproducibility-editor__image-action"
@click="handleAddExampleImageUrl(index, 'images')"
>
{{ t('favorites.dialog.reproducibility.addImageUrl') }}
</NButton>
</NSpace>
<NUpload
data-testid="favorite-repro-example-image-upload"
accept="image/*"
multiple
:default-upload="false"
:show-file-list="false"
@before-upload="(options) => handleBeforeExampleImageUpload(index, 'images', options)"
>
<NButton
data-testid="favorite-repro-example-add-images"
size="small"
secondary
class="favorite-reproducibility-editor__image-action"
>
{{ t('favorites.dialog.reproducibility.addExampleImages') }}
</NButton>
</NUpload>
<AppPreviewImageGroup v-if="getExampleImageItems(index, 'images', example).length > 0">
<div class="favorite-reproducibility-editor__image-grid">
<div
v-for="item in getExampleImageItems(index, 'images', example)"
:key="item.key"
class="favorite-reproducibility-editor__image-item"
>
<AppPreviewImage
:src="item.source"
:alt="t('favorites.dialog.imageAlt', { index: item.displayIndex + 1 })"
object-fit="cover"
class="favorite-reproducibility-editor__image"
/>
<NButton
data-testid="favorite-repro-example-remove-image"
size="tiny"
type="error"
quaternary
class="favorite-reproducibility-editor__image-remove"
@click="handleRemoveExampleImage(index, 'images', item)"
>
×
</NButton>
</div>
</div>
</AppPreviewImageGroup>
</div>
<div class="favorite-reproducibility-editor__image-field">
<NText strong>{{ t('favorites.dialog.reproducibility.exampleInputImages') }}</NText>
<NSpace :size="6" align="center" wrap class="favorite-reproducibility-editor__image-toolbar">
<NInput
data-testid="favorite-repro-example-input-images"
:value="getImageUrlDraft(index, 'inputImages')"
:placeholder="t('favorites.dialog.reproducibility.exampleInputImagesPlaceholder')"
@update:value="setImageUrlDraft(index, 'inputImages', $event)"
@keydown.enter.prevent="handleAddExampleImageUrl(index, 'inputImages')"
/>
<NButton
data-testid="favorite-repro-example-add-input-image-url"
size="small"
secondary
class="favorite-reproducibility-editor__image-action"
@click="handleAddExampleImageUrl(index, 'inputImages')"
>
{{ t('favorites.dialog.reproducibility.addImageUrl') }}
</NButton>
</NSpace>
<NUpload
data-testid="favorite-repro-example-input-image-upload"
accept="image/*"
multiple
:default-upload="false"
:show-file-list="false"
@before-upload="(options) => handleBeforeExampleImageUpload(index, 'inputImages', options)"
>
<NButton
data-testid="favorite-repro-example-add-input-images"
size="small"
secondary
class="favorite-reproducibility-editor__image-action"
>
{{ t('favorites.dialog.reproducibility.addExampleInputImages') }}
</NButton>
</NUpload>
<AppPreviewImageGroup v-if="getExampleImageItems(index, 'inputImages', example).length > 0">
<div class="favorite-reproducibility-editor__image-grid">
<div
v-for="item in getExampleImageItems(index, 'inputImages', example)"
:key="item.key"
class="favorite-reproducibility-editor__image-item"
>
<AppPreviewImage
:src="item.source"
:alt="t('favorites.dialog.imageAlt', { index: item.displayIndex + 1 })"
object-fit="cover"
class="favorite-reproducibility-editor__image"
/>
<NButton
data-testid="favorite-repro-example-remove-input-image"
size="tiny"
type="error"
quaternary
class="favorite-reproducibility-editor__image-remove"
@click="handleRemoveExampleImage(index, 'inputImages', item)"
>
×
</NButton>
</div>
</div>
</AppPreviewImageGroup>
</div>
</div>
</div>
</NSpace>
</section>
@@ -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<string[]>([])
const imageUrlDrafts = reactive<Record<string, string>>({})
const parameterDrafts = reactive<Record<number, { key: string; value: string }>>({})
const formatOptions = (items: string[] | undefined) => (items || []).join(', ')
const dedupeStrings = (items: string[]) => Array.from(new Set(items.filter(Boolean)))
const assetFieldByImageField: Record<ExampleImageField, ExampleAssetField> = {
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<string, string> | undefined) => {
return Object.entries(parameters || {})
.map(([key, value]) => `${key}=${value}`)
.join('\n')
const getParameterEntries = (parameters: Record<string, string> | undefined) =>
Object.entries(parameters || {})
const getParameterDraft = (index: number) => {
parameterDrafts[index] ||= { key: '', value: '' }
return parameterDrafts[index]
}
const parseParametersText = (value: string): Record<string, string> => {
const parameters: Record<string, string> = {}
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<number, { key: string; value: string }> = {}
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<string, string> = {}
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<FavoriteReproducibilityExample>,
) => {
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<string>((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
}
</script>
@@ -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;
}
}
</style>

View File

@@ -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 @@
</NSpace>
<NDropdown trigger="click" :options="menuOptions" @select="handleMenuSelect">
<NButton quaternary circle size="small" @click.stop>
<NButton data-testid="favorite-workspace-item-menu" quaternary circle size="small" @click.stop>
<template #icon>
<NIcon><DotsVertical /></NIcon>
</template>

View File

@@ -1,14 +1,46 @@
<template>
<div data-testid="favorite-garden-snapshot-preview">
<NSpace vertical size="medium">
<NDivider style="margin: 0;" />
<NDivider v-if="!sourceOnly" style="margin: 0;" />
<NSpace vertical size="small">
<NSpace v-if="!sourceOnly" vertical size="small">
<NText strong>{{ t('favorites.manager.preview.garden.snapshotTitle') }}</NText>
<NText depth="3">{{ t('favorites.manager.preview.garden.snapshotHint') }}</NText>
</NSpace>
<div
v-if="sourceOnly && showBasicInfo"
data-testid="favorite-garden-basic-info"
>
<NDescriptions :column="1" size="small" bordered label-placement="left">
<NDescriptionsItem
v-if="snapshot.importCode"
:label="t('favorites.manager.preview.garden.importCode')"
>
{{ snapshot.importCode }}
</NDescriptionsItem>
<NDescriptionsItem
v-if="snapshot.gardenBaseUrl"
:label="t('favorites.manager.preview.garden.gardenBaseUrl')"
>
{{ snapshot.gardenBaseUrl }}
</NDescriptionsItem>
<NDescriptionsItem
v-if="snapshot.schema"
:label="t('favorites.manager.preview.garden.schema')"
>
{{ snapshot.schema }}
<NText v-if="snapshot.schemaVersion !== undefined" depth="3">
v{{ snapshot.schemaVersion }}
</NText>
</NDescriptionsItem>
</NDescriptions>
</div>
<NCollapse
v-else
:expanded-names="expandedSections"
@update:expanded-names="handleExpandedNamesUpdate"
>
@@ -205,7 +237,7 @@
</NCollapseItem>
<NCollapseItem
v-if="snapshot.examples.length > 0"
v-if="showExamplesSection"
name="examples"
:title="t('favorites.manager.preview.garden.examples')"
>
@@ -284,7 +316,7 @@
</NCollapseItem>
<NCollapseItem
v-if="snapshot.variables.length > 0"
v-if="showVariablesSection"
name="variables"
:title="t('favorites.manager.preview.garden.variables')"
>
@@ -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<SectionKey[]>([...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]> => {

View File

@@ -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<{

View File

@@ -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<string>({
@@ -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<boolean> => {
const used = await handleUseFavorite(favorite);
const handleUseFavoriteFromPage = async (
favorite: FavoritePrompt,
options?: { applyExample?: boolean; exampleId?: string; exampleIndex?: number },
): Promise<boolean> => {
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.

View File

@@ -127,9 +127,12 @@ const handleReturnToWorkspace = () => {
void routerInstance.push(DEFAULT_WORKSPACE_PATH)
}
const handleUseFavorite = async (favorite: FavoritePrompt): Promise<boolean> => {
const handleUseFavorite = async (
favorite: FavoritePrompt,
options?: { applyExample?: boolean; exampleId?: string; exampleIndex?: number },
): Promise<boolean> => {
if (actions) {
return await actions.useFavorite(favorite)
return await actions.useFavorite(favorite, options)
}
return false

View File

@@ -2,7 +2,7 @@ import type { InjectionKey } from 'vue'
import type { FavoritePrompt } from '@prompt-optimizer/core'
export interface FavoritesPageActions {
useFavorite: (favorite: FavoritePrompt) => boolean | Promise<boolean>
useFavorite: (favorite: FavoritePrompt, options?: { applyExample?: boolean; exampleId?: string; exampleIndex?: number }) => Promise<boolean>
returnToWorkspace: () => void
}

View File

@@ -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<string, unknown>
}
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<void>
navigateToSubModeKey: (toKey: string, opts?: { replace?: boolean }) => boolean | void | Promise<boolean | void>
/** 处理上下文模式变更 */
handleContextModeChange: (mode: ContextMode) => Promise<void>
/** 优化器提示词(用于设置收藏内容) */
@@ -54,6 +85,13 @@ export interface AppFavoriteOptions {
t: (key: string, params?: Record<string, unknown>) => string
/** 外部数据加载中标志(防止模式切换的自动 restore 覆盖外部数据) */
isLoadingExternalData: Ref<boolean>
/** 高级/图像模式临时变量会话,用于应用收藏示例参数 */
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<boolean>
handleUseFavorite: (favorite: FavoriteItem, options?: UseFavoriteOptions) => Promise<boolean>
}
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<boolean> => {
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<ImagePayload[]> => {
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<boolean> => {
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<boolean> => {
const handleUseFavorite = async (favorite: FavoriteItem, useOptions: UseFavoriteOptions = {}): Promise<boolean> => {
try {
// 🔧 设置外部数据加载标志,防止模式切换的自动 restore 覆盖外部数据
isLoadingExternalData.value = true
return await handleUseFavoriteImpl(favorite)
return await handleUseFavoriteImpl(favorite, useOptions)
} catch (error) {
// 捕获收藏加载过程中的所有错误
console.error('[App] Failed to load favorite:', error)

View File

@@ -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<void>
navigateToSubModeKey: (toKey: string, opts?: { replace?: boolean }) => boolean | void | Promise<boolean | void>
/** 处理上下文模式变更 */
handleContextModeChange: (mode: ContextMode) => Promise<void>
/** 处理历史记录选择 */
@@ -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()

View File

@@ -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
}

View File

@@ -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",

View File

@@ -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": "数字",

View File

@@ -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": "數字",

View File

@@ -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
}
}

View File

@@ -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 }
}
}

View File

@@ -0,0 +1,22 @@
import { computed, ref } from 'vue'
export const createExternalDataLoadingGate = () => {
const depth = ref(0)
const isLoading = computed<boolean>({
get: () => depth.value > 0,
set: (value) => {
if (value) {
depth.value += 1
return
}
depth.value = Math.max(0, depth.value - 1)
},
})
return {
isLoading,
depth,
}
}

View File

@@ -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
}

View File

@@ -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: `
<article class="n-card" @click="$emit('click')">
<header class="n-card-header"><slot name="header" /></header>
<div class="n-card-cover"><slot name="cover" /></div>
<section class="n-card-content"><slot /></section>
<footer class="n-card-footer"><slot name="footer" /></footer>
</article>
`,
emits: ['click'],
},
NSpace: {
name: 'NSpace',
template: '<div class="n-space"><slot /></div>',
props: ['vertical', 'size', 'align', 'justify', 'wrap', 'class'],
},
NTag: {
name: 'NTag',
template: '<span class="n-tag"><slot /></span>',
props: ['type', 'size', 'bordered', 'color'],
},
NText: {
name: 'NText',
template: '<span class="n-text"><slot /></span>',
props: ['depth'],
},
NIcon: {
name: 'NIcon',
template: '<i class="n-icon"><slot /></i>',
},
NButton: {
name: 'NButton',
template: '<button class="n-button" :data-testid="$attrs[\'data-testid\']" @click="$emit(\'click\', $event)"><slot name="icon" /><slot /></button>',
emits: ['click'],
},
NEllipsis: {
name: 'NEllipsis',
template: '<span class="n-ellipsis"><slot /></span>',
props: ['lineClamp', 'tooltip', 'class'],
},
NThing: {
name: 'NThing',
template: '<div class="n-thing"><div><slot name="header" /></div><div><slot /></div><div><slot name="footer" /></div></div>',
},
NDropdown: {
name: 'NDropdown',
template: `
<div class="n-dropdown">
<slot />
<button
v-for="option in normalizedOptions"
:key="option.key"
class="n-dropdown-option"
:data-key="option.key"
@click="$emit('select', option.key)"
>
{{ option.key }}
</button>
</div>
`,
props: ['options'],
emits: ['select'],
computed: {
normalizedOptions() {
return (this.options || []).filter((option: any) => !option.type)
},
},
},
AppPreviewImage: {
name: 'AppPreviewImage',
template: '<img class="app-preview-image" :src="src" :alt="alt" />',
props: ['src', 'alt', 'objectFit', 'previewDisabled', 'class'],
},
}
const createFavorite = (overrides: Partial<FavoritePrompt> = {}): 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)
})
})

View File

@@ -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',
])
})
})

View File

@@ -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<typeof import('vue-i18n')>()
return {
...actual,
useI18n: () => ({
t: (key: string) => key,
}),
}
})
import FavoriteEditorForm from '../../../src/components/FavoriteEditorForm.vue'
const naiveStubs = {
NAutoComplete: {
name: 'NAutoComplete',
template: '<input class="n-auto-complete" :value="value" />',
props: ['value', 'options', 'placeholder', 'getShow', 'clearable'],
},
NButton: {
name: 'NButton',
template: '<button class="n-button" :disabled="disabled" @click="$emit(\'click\', $event)"><slot /></button>',
props: ['disabled', 'loading', 'type', 'secondary', 'size', 'quaternary'],
emits: ['click'],
},
NCard: {
name: 'NCard',
template: '<section class="n-card"><header>{{ title }}</header><slot /><footer><slot name="footer" /></footer></section>',
props: ['title', 'size', 'segmented', 'class'],
},
NForm: {
name: 'NForm',
template: '<form class="n-form"><slot /></form>',
props: ['labelPlacement'],
},
NFormItem: {
name: 'NFormItem',
template: '<label class="n-form-item">{{ label }}<slot /></label>',
props: ['label', 'required'],
},
NGrid: {
name: 'NGrid',
template: '<div class="n-grid"><slot /></div>',
props: ['cols', 'xGap'],
},
NGridItem: {
name: 'NGridItem',
template: '<div class="n-grid-item"><slot /></div>',
},
NInput: {
name: 'NInput',
template: '<textarea v-if="type === \'textarea\'" class="n-input" :value="value" /><input v-else class="n-input" :value="value" />',
props: ['value', 'type', 'placeholder', 'autosize', 'maxlength', 'showCount'],
},
NScrollbar: {
name: 'NScrollbar',
template: '<div class="n-scrollbar"><slot /></div>',
props: ['class'],
},
NSelect: {
name: 'NSelect',
template: '<select class="n-select" :value="value"></select>',
props: ['value', 'options', 'placeholder', 'disabled'],
},
NSpace: {
name: 'NSpace',
template: '<div class="n-space"><slot /></div>',
props: ['vertical', 'size', 'justify', 'align', 'wrap', 'class'],
},
NTag: {
name: 'NTag',
template: '<span class="n-tag"><slot /></span>',
props: ['type', 'closable', 'size', 'bordered'],
},
NText: {
name: 'NText',
template: '<span class="n-text"><slot /></span>',
props: ['depth'],
},
NUpload: {
name: 'NUpload',
template: '<div class="n-upload" @click="onBeforeUpload && onBeforeUpload({ file: { file: testBlob } })"><slot /></div>',
props: ['accept', 'multiple', 'defaultUpload', 'showFileList', 'disabled', 'onBeforeUpload'],
emits: ['before-upload'],
data: () => ({
testBlob: new Blob(['upload'], { type: 'image/png' }),
}),
},
Upload: {
name: 'Upload',
template: '<div class="n-upload" @click="onBeforeUpload && onBeforeUpload({ file: { file: testBlob } })"><slot /></div>',
props: ['accept', 'multiple', 'defaultUpload', 'showFileList', 'disabled', 'onBeforeUpload'],
emits: ['before-upload'],
data: () => ({
testBlob: new Blob(['upload'], { type: 'image/png' }),
}),
},
CategoryTreeSelect: {
name: 'CategoryTreeSelect',
template: '<div class="category-tree-select"></div>',
props: ['modelValue', 'placeholder', 'showAllOption'],
},
FavoriteReproducibilityEditor: {
name: 'FavoriteReproducibilityEditor',
template: '<div class="favorite-reproducibility-editor"></div>',
props: ['variables', 'examples', 'examplePreviews'],
},
AppPreviewImage: {
name: 'AppPreviewImage',
template: '<img class="app-preview-image" :src="src" :alt="alt" />',
props: ['src', 'alt', 'objectFit', 'class'],
},
AppPreviewImageGroup: {
name: 'AppPreviewImageGroup',
template: '<div class="app-preview-image-group"><slot /></div>',
},
}
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 = <T>() => {
let resolve!: (value: T) => void
const promise = new Promise<T>((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<string | null>()
const secondCover = createDeferred<string | null>()
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<typeof vi.fn>
}> = []
class TestFileReader {
result = ''
onload: null | (() => void) = null
onerror: null | (() => void) = null
readAsDataURL = vi.fn(() => {
readers.push(this)
})
}
vi.stubGlobal('FileReader', TestFileReader)
const secondCover = createDeferred<string | null>()
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<string, any>
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' }],
},
])
})
})

View File

@@ -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<typeof import('vue-i18n')>()
return {
...actual,
useI18n: () => ({
t: (key: string, values?: Record<string, unknown>) =>
values?.index ? `${key} ${values.index}` : key,
}),
}
})
const naiveStubs = {
NCard: {
name: 'NCard',
template: '<section class="n-card"><slot /></section>',
props: ['size', 'embedded'],
},
NDescriptions: {
name: 'NDescriptions',
template: '<dl class="n-descriptions"><slot /></dl>',
props: ['column', 'size', 'bordered', 'labelPlacement'],
},
NDescriptionsItem: {
name: 'NDescriptionsItem',
template: '<div class="n-descriptions-item"><dt>{{ label }}</dt><dd><slot /></dd></div>',
props: ['label'],
},
NEmpty: {
name: 'NEmpty',
template: '<div class="n-empty">{{ description }}</div>',
props: ['description', 'size'],
},
NSpace: {
name: 'NSpace',
template: '<div class="n-space"><slot /></div>',
props: ['vertical', 'size', 'align', 'wrap'],
},
NTable: {
name: 'NTable',
template: '<table class="n-table"><slot /></table>',
props: ['size', 'striped', 'singleLine'],
},
NTag: {
name: 'NTag',
template: '<span class="n-tag"><slot /></span>',
props: ['size', 'type', 'bordered'],
},
NText: {
name: 'NText',
template: '<span class="n-text"><slot /></span>',
props: ['depth', 'strong'],
},
AppPreviewImage: {
name: 'AppPreviewImage',
template: '<img class="app-preview-image" :src="src" :alt="alt" />',
props: ['src', 'alt', 'objectFit', 'class'],
},
AppPreviewImageGroup: {
name: 'AppPreviewImageGroup',
template: '<div class="app-preview-image-group"><slot /></div>',
},
}
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',
])
})
})

View File

@@ -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: '<span class="n-text"><slot /></span>',
props: ['depth', 'strong'],
},
NUpload: {
name: 'NUpload',
template: '<div class="n-upload" @click="handleClick"><slot /></div>',
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: '<img class="app-preview-image" :src="src" :alt="alt" />',
props: ['src', 'alt', 'objectFit', 'class'],
},
AppPreviewImageGroup: {
name: 'AppPreviewImageGroup',
template: '<div class="app-preview-image-group"><slot /></div>',
},
}
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: [],
},
])
})
})

View File

@@ -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

View File

@@ -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<string, string> = { 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({

View File

@@ -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',

View File

@@ -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> = {},
): 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)

View File

@@ -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)
})
})

View File

@@ -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[]) => {}),

View File

@@ -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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
await page.evaluate(async (items) => {
const dbName = (window as unknown as { __TEST_DB_NAME__?: string }).__TEST_DB_NAME__ || 'PromptOptimizerDB';
await new Promise<void>((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<void> {
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 });
});
});

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
<rect width="64" height="64" fill="#1a73e8"/>
<path d="M18 40 L32 16 L46 40 Z" fill="#ffffff"/>
</svg>

After

Width:  |  Height:  |  Size: 191 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
<rect width="64" height="64" fill="#d93025"/>
<circle cx="32" cy="32" r="18" fill="#ffffff"/>
</svg>

After

Width:  |  Height:  |  Size: 189 B