feat(ui): add pluggable prompt garden favorite previews

Move Garden snapshot rendering and editing into optional preview plugins so favorites stay decoupled while preserving import-time snapshot persistence and asset updates.
This commit is contained in:
linshen committed 2026-02-10 23:22:03 +08:00
1 parent aef1f1425b
commit 4664cc2a21
18 files changed
+2311 -49

No files matched your search

+15 -1
View File
@@ -144,7 +144,15 @@
mode="readonly"
:enabled-actions="['copy', 'diff']"
@copy="handlePreviewCopy"
/>
>
<template #extra-content>
<FavoritePreviewExtensionHost
v-if="previewFavorite"
:favorite="previewFavorite"
@favorite-updated="handleFavoritePreviewUpdated"
/>
</template>
</OutputDisplayFullscreen>
<!-- 收藏导入 -->
<n-modal
@@ -273,6 +281,7 @@ import ToastUI from './Toast.vue';
const { t } = useI18n();
import FavoriteCard from './FavoriteCard.vue';
import OutputDisplayFullscreen from './OutputDisplayFullscreen.vue';
import FavoritePreviewExtensionHost from './FavoritePreviewExtensionHost.vue';
import CategoryManager from './CategoryManager.vue';
import CategoryTreeSelect from './CategoryTreeSelect.vue';
import SaveFavoriteDialog from './SaveFavoriteDialog.vue';
@@ -439,6 +448,11 @@ const previewOriginalContent = computed(() => {
return previewFavorite.value.metadata?.originalContent ?? '';
});
const handleFavoritePreviewUpdated = async (favoriteId: string) => {
await loadFavorites();
previewFavorite.value = favorites.value.find((favorite) => favorite.id === favoriteId) || null;
};
// 网格布局配置:根据视口宽度自适应列数
// 移动端 (< 768px): 1 列
// 平板 (768-1023px): 2 列
@@ -0,0 +1,50 @@
<template>
<div v-if="activePlugins.length > 0" class="favorite-preview-extension-host">
<component
:is="plugin.component"
v-for="plugin in activePlugins"
:key="plugin.id"
:favorite="favorite"
@favorite-updated="handleFavoriteUpdated"
/>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import type { FavoritePrompt } from '@prompt-optimizer/core'
import {
loadEnabledFavoritePreviewPlugins,
type FavoritePreviewPlugin,
} from '../integrations/favoritePreviewPlugins'
const props = defineProps<{
favorite: FavoritePrompt
}>()
const emit = defineEmits<{
'favorite-updated': [favoriteId: string]
}>()
const plugins = ref<FavoritePreviewPlugin[]>([])
const activePlugins = computed(() => {
return plugins.value.filter((plugin) => plugin.match(props.favorite))
})
const handleFavoriteUpdated = (favoriteId: string) => {
emit('favorite-updated', favoriteId)
}
onMounted(async () => {
plugins.value = await loadEnabledFavoritePreviewPlugins()
})
</script>
<style scoped>
.favorite-preview-extension-host {
display: grid;
gap: 12px;
}
</style>
@@ -0,0 +1,470 @@
<template>
<div data-testid="favorite-garden-snapshot-preview">
<NSpace vertical size="medium">
<NDivider style="margin: 0;" />
<NSpace vertical size="small">
<NText strong>{{ t('favorites.manager.preview.garden.snapshotTitle') }}</NText>
<NText depth="3">{{ t('favorites.manager.preview.garden.snapshotHint') }}</NText>
</NSpace>
<NCollapse
:expanded-names="expandedSections"
@update:expanded-names="handleExpandedNamesUpdate"
>
<NCollapseItem
v-if="showBasicInfo"
name="basicInfo"
:title="t('favorites.manager.preview.garden.basicInfo')"
>
<div 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>
</NCollapseItem>
<NCollapseItem
v-if="showMeta"
name="metaInfo"
:title="t('favorites.manager.preview.garden.metaInfo')"
>
<div data-testid="favorite-garden-meta">
<NSpace vertical size="small">
<NDescriptions :column="1" size="small" bordered label-placement="left">
<NDescriptionsItem
v-if="snapshot.meta.title"
:label="t('favorites.manager.preview.garden.title')"
>
{{ snapshot.meta.title }}
</NDescriptionsItem>
<NDescriptionsItem
v-if="snapshot.meta.description"
:label="t('favorites.manager.preview.garden.description')"
>
{{ snapshot.meta.description }}
</NDescriptionsItem>
</NDescriptions>
<NSpace v-if="snapshot.meta.tags.length > 0" size="small" wrap>
<NTag
v-for="tag in snapshot.meta.tags"
:key="`meta-tag-${tag}`"
size="small"
:bordered="false"
type="info"
>
{{ tag }}
</NTag>
</NSpace>
</NSpace>
</div>
</NCollapseItem>
<NCollapseItem
v-if="showCoverSection"
name="cover"
:title="t('favorites.manager.preview.garden.cover')"
>
<div data-testid="favorite-garden-cover">
<NSpace vertical size="small">
<NImage
v-if="snapshot.coverUrl"
:src="snapshot.coverUrl"
:alt="t('favorites.manager.preview.garden.cover')"
object-fit="cover"
width="100%"
style="max-height: 220px;"
/>
<NEmpty
v-else
size="small"
:description="t('favorites.manager.preview.garden.noCover')"
/>
<NSpace v-if="editable" size="small">
<NUpload
data-testid="favorite-garden-cover-uploader"
accept="image/*"
:max="1"
:show-file-list="false"
:default-upload="false"
:custom-request="noopUploadRequest"
:disabled="busy"
@change="handleCoverUploadChange"
>
<NButton
size="small"
secondary
:loading="busy"
:disabled="busy"
>
{{ t('favorites.manager.preview.garden.uploadCover') }}
</NButton>
</NUpload>
</NSpace>
</NSpace>
</div>
</NCollapseItem>
<NCollapseItem
v-if="showShowcasesSection"
name="showcases"
:title="t('favorites.manager.preview.garden.showcases')"
>
<div data-testid="favorite-garden-showcases">
<NSpace vertical size="small">
<template v-if="snapshot.showcases.length > 0">
<NCard
v-for="(showcase, index) in snapshot.showcases"
:key="showcase.id || `showcase-${index}`"
size="small"
embedded
>
<NSpace vertical size="small">
<NText strong>
{{ t('favorites.manager.preview.garden.showcaseLabel', { index: index + 1 }) }}
</NText>
<NText v-if="showcase.text">{{ showcase.text }}</NText>
<NText v-if="showcase.description" depth="3">{{ showcase.description }}</NText>
<NImageGroup v-if="showcase.images.length > 0">
<NGrid cols="2 s:3 m:4" responsive="screen" :x-gap="8" :y-gap="8">
<NGridItem
v-for="(url, imageIndex) in showcase.images"
:key="`${showcase.id || index}-image-${imageIndex}`"
>
<NImage
:src="url"
:alt="t('favorites.manager.preview.garden.showcaseLabel', { index: index + 1 })"
object-fit="cover"
width="100%"
style="max-height: 220px;"
/>
</NGridItem>
</NGrid>
</NImageGroup>
</NSpace>
</NCard>
</template>
<NEmpty
v-else
size="small"
:description="t('favorites.manager.preview.garden.noShowcases')"
/>
<NSpace v-if="editable" size="small">
<NUpload
data-testid="favorite-garden-showcase-uploader"
accept="image/*"
multiple
:show-file-list="false"
:default-upload="false"
:custom-request="noopUploadRequest"
:disabled="busy"
@change="handleShowcaseUploadChange"
>
<NButton
size="small"
secondary
:loading="busy"
:disabled="busy"
>
{{ t('favorites.manager.preview.garden.uploadShowcaseImages') }}
</NButton>
</NUpload>
</NSpace>
</NSpace>
</div>
</NCollapseItem>
<NCollapseItem
v-if="snapshot.examples.length > 0"
name="examples"
:title="t('favorites.manager.preview.garden.examples')"
>
<div data-testid="favorite-garden-examples">
<NSpace vertical size="small">
<NCard
v-for="(example, index) in snapshot.examples"
:key="example.id || `example-${index}`"
size="small"
embedded
>
<NSpace vertical size="small">
<NText strong>
{{ t('favorites.manager.preview.garden.exampleLabel', { index: index + 1 }) }}
</NText>
<NText v-if="example.text">{{ example.text }}</NText>
<NText v-if="example.description" depth="3">{{ example.description }}</NText>
<template v-if="example.images.length > 0">
<NText depth="3">{{ t('favorites.manager.preview.garden.exampleImages') }}</NText>
<NImageGroup>
<NGrid cols="2 s:3 m:4" responsive="screen" :x-gap="8" :y-gap="8">
<NGridItem
v-for="(url, imageIndex) in example.images"
:key="`${example.id || index}-example-image-${imageIndex}`"
>
<NImage
:src="url"
:alt="t('favorites.manager.preview.garden.exampleLabel', { index: index + 1 })"
object-fit="cover"
width="100%"
style="max-height: 220px;"
/>
</NGridItem>
</NGrid>
</NImageGroup>
</template>
<template v-if="parameterEntries(example).length > 0">
<NText depth="3">{{ t('favorites.manager.preview.garden.parameters') }}</NText>
<NDescriptions :column="1" size="small" bordered label-placement="left">
<NDescriptionsItem
v-for="([key, value]) in parameterEntries(example)"
:key="`${example.id || index}-param-${key}`"
:label="key"
>
{{ value }}
</NDescriptionsItem>
</NDescriptions>
</template>
<template v-if="example.inputImages.length > 0">
<NText depth="3">{{ t('favorites.manager.preview.garden.inputImages') }}</NText>
<NImageGroup>
<NGrid cols="2 s:3 m:4" responsive="screen" :x-gap="8" :y-gap="8">
<NGridItem
v-for="(url, imageIndex) in example.inputImages"
:key="`${example.id || index}-input-image-${imageIndex}`"
>
<NImage
:src="url"
:alt="t('favorites.manager.preview.garden.inputImages')"
object-fit="cover"
width="100%"
style="max-height: 220px;"
/>
</NGridItem>
</NGrid>
</NImageGroup>
</template>
</NSpace>
</NCard>
</NSpace>
</div>
</NCollapseItem>
<NCollapseItem
v-if="snapshot.variables.length > 0"
name="variables"
:title="t('favorites.manager.preview.garden.variables')"
>
<div data-testid="favorite-garden-variables">
<NTable size="small" striped :single-line="false">
<thead>
<tr>
<th>{{ t('favorites.manager.preview.garden.variableName') }}</th>
<th>{{ t('favorites.manager.preview.garden.variableType') }}</th>
<th>{{ t('favorites.manager.preview.garden.variableRequired') }}</th>
<th>{{ t('favorites.manager.preview.garden.variableDefault') }}</th>
<th>{{ t('favorites.manager.preview.garden.variableOptions') }}</th>
<th>{{ t('favorites.manager.preview.garden.variableDescription') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="variable in snapshot.variables" :key="variable.name">
<td>{{ variable.name }}</td>
<td>{{ variable.type || '-' }}</td>
<td>
{{
variable.required
? t('favorites.manager.preview.garden.requiredYes')
: t('favorites.manager.preview.garden.requiredNo')
}}
</td>
<td>{{ variable.defaultValue || '-' }}</td>
<td>{{ variable.options.length ? variable.options.join(', ') : '-' }}</td>
<td>{{ variable.description || '-' }}</td>
</tr>
</tbody>
</NTable>
</div>
</NCollapseItem>
</NCollapse>
</NSpace>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import {
NButton,
NCard,
NCollapse,
NCollapseItem,
NDescriptions,
NDescriptionsItem,
NDivider,
NEmpty,
NGrid,
NGridItem,
NImage,
NImageGroup,
NSpace,
NUpload,
NTag,
NTable,
NText,
type UploadFileInfo,
} from 'naive-ui'
import { useI18n } from 'vue-i18n'
import type {
GardenSnapshotPreview,
GardenSnapshotPreviewAsset,
} from '../utils/garden-snapshot-preview'
type SectionKey = 'basicInfo' | 'metaInfo' | 'cover' | 'showcases' | 'examples' | 'variables'
const SECTION_KEYS: SectionKey[] = [
'basicInfo',
'metaInfo',
'cover',
'showcases',
'examples',
'variables',
]
const SECTION_KEY_SET = new Set<SectionKey>(SECTION_KEYS)
const props = withDefaults(defineProps<{
snapshot: GardenSnapshotPreview
editable?: boolean
busy?: boolean
}>(), {
editable: false,
busy: false,
})
const emit = defineEmits<{
'upload-cover': [dataUrl: string]
'append-showcase-images': [dataUrls: string[]]
}>()
const { t } = useI18n()
const expandedSections = ref<SectionKey[]>([...SECTION_KEYS])
const showBasicInfo = computed(() => {
return Boolean(props.snapshot.importCode || props.snapshot.gardenBaseUrl || props.snapshot.schema)
})
const showMeta = computed(() => {
return Boolean(
props.snapshot.meta.title ||
props.snapshot.meta.description ||
props.snapshot.meta.tags.length > 0,
)
})
const showCoverSection = computed(() => {
return Boolean(props.snapshot.coverUrl || props.editable)
})
const showShowcasesSection = computed(() => {
return Boolean(props.snapshot.showcases.length > 0 || props.editable)
})
const parameterEntries = (asset: GardenSnapshotPreviewAsset): Array<[string, string]> => {
return Object.entries(asset.parameters)
}
type UploadChangeParam = {
file: UploadFileInfo | null
fileList: UploadFileInfo[]
event?: Event
}
const noopUploadRequest = () => {
return undefined
}
const handleExpandedNamesUpdate = (names: Array<string | number>) => {
const parsed = names.filter((item): item is SectionKey => {
return typeof item === 'string' && SECTION_KEY_SET.has(item as SectionKey)
})
expandedSections.value = parsed
}
const toImageFiles = (items: Array<UploadFileInfo | null | undefined>): File[] => {
return items
.map((item) => item?.file)
.filter((file): file is File => file instanceof File && file.type.startsWith('image/'))
}
const dedupeStrings = (items: string[]): string[] => {
return Array.from(new Set(items.filter(Boolean)))
}
const readFileAsDataUrl = (file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onerror = () => reject(new Error('Failed to read image file'))
reader.onload = () => resolve(String(reader.result || ''))
reader.readAsDataURL(file)
})
}
const handleCoverUploadChange = async (options: UploadChangeParam) => {
if (props.busy) return
const files = toImageFiles([options.file])
if (files.length === 0) return
const dataUrl = await readFileAsDataUrl(files[0])
if (dataUrl) {
emit('upload-cover', dataUrl)
}
}
const handleShowcaseUploadChange = async (options: UploadChangeParam) => {
if (props.busy) return
const files = toImageFiles((options.fileList || []).length > 0 ? options.fileList : [options.file])
if (files.length === 0) return
const dataUrls = await Promise.all(files.map((file) => readFileAsDataUrl(file)))
const validUrls = dedupeStrings(dataUrls.filter((item) => Boolean(item)))
if (validUrls.length > 0) {
emit('append-showcase-images', validUrls)
}
}
</script>
@@ -1,22 +1,33 @@
<template>
<FullscreenDialog v-model="internalVisible" :title="title || t('common.content')">
<NFlex vertical style="flex: 1; min-height: 0; overflow: hidden;">
<OutputDisplayCore
ref="coreDisplayRef"
:content="internalContent"
:originalContent="originalContent"
:reasoning="reasoning"
:mode="mode"
:reasoningMode="reasoningMode"
:enabledActions="coreEnabledActions"
height="100%"
:placeholder="placeholder"
:loading="loading"
:streaming="streaming"
:compareService="compareService"
@update:content="handleContentUpdate"
@copy="handleCopy"
/>
<NFlex vertical :wrap="false" style="flex: 1; min-height: 0; overflow: hidden;">
<NFlex vertical :wrap="false" style="flex: 1; min-height: 0; overflow: hidden;">
<OutputDisplayCore
ref="coreDisplayRef"
:content="internalContent"
:originalContent="originalContent"
:reasoning="reasoning"
:mode="mode"
:reasoningMode="reasoningMode"
:enabledActions="coreEnabledActions"
height="100%"
:placeholder="placeholder"
:loading="loading"
:streaming="streaming"
:compareService="compareService"
@update:content="handleContentUpdate"
@copy="handleCopy"
/>
</NFlex>
<NDivider v-if="$slots['extra-content']" style="margin: 12px 0 8px;" />
<NScrollbar
v-if="$slots['extra-content']"
style="flex: 0 0 auto; max-height: 42%;"
>
<slot name="extra-content" />
</NScrollbar>
</NFlex>
</FullscreenDialog>
</template>
@@ -25,7 +36,7 @@
import { computed, ref, watch, inject, nextTick, type Ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { NFlex } from 'naive-ui'
import { NDivider, NFlex, NScrollbar } from 'naive-ui'
import FullscreenDialog from './FullscreenDialog.vue'
import OutputDisplayCore from './OutputDisplayCore.vue'
import type { AppServices } from '../types/services';
@@ -0,0 +1,127 @@
<template>
<GardenSnapshotPreview
v-if="snapshot"
:snapshot="snapshot"
editable
:busy="isSaving"
@upload-cover="handleGardenCoverUpload"
@append-showcase-images="handleGardenShowcaseUpload"
/>
</template>
<script setup lang="ts">
import { computed, inject, ref, type Ref } from 'vue'
import { useI18n } from 'vue-i18n'
import type { FavoritePrompt } from '@prompt-optimizer/core'
import { useToast } from '../composables/ui/useToast'
import type { AppServices } from '../types/services'
import { parseFavoriteGardenSnapshotPreview } from '../utils/garden-snapshot-preview'
import GardenSnapshotPreview from './GardenSnapshotPreview.vue'
const props = defineProps<{
favorite: FavoritePrompt
}>()
const emit = defineEmits<{
'favorite-updated': [favoriteId: string]
}>()
const { t } = useI18n()
const message = useToast()
const services = inject<Ref<AppServices | null> | null>('services', null)
const isSaving = ref(false)
const snapshot = computed(() => {
return parseFavoriteGardenSnapshotPreview(props.favorite)
})
const isRecord = (value: unknown): value is Record<string, unknown> => {
return !!value && typeof value === 'object' && !Array.isArray(value)
}
const asStringArray = (value: unknown): string[] => {
if (!Array.isArray(value)) return []
return value
.map((item) => (typeof item === 'string' ? item.trim() : ''))
.filter(Boolean)
}
const dedupeStrings = (items: string[]): string[] => {
return Array.from(new Set(items.filter(Boolean)))
}
const updateGardenSnapshot = async (
updater: (snapshotRecord: Record<string, unknown>) => void,
) => {
if (isSaving.value) return
const favoriteManager = services?.value?.favoriteManager
if (!favoriteManager) {
message.warning(t('favorites.manager.messages.unavailable'))
return
}
const metadata = isRecord(props.favorite.metadata) ? { ...props.favorite.metadata } : {}
const snapshotRecord = isRecord(metadata.gardenSnapshot) ? { ...metadata.gardenSnapshot } : {}
updater(snapshotRecord)
metadata.gardenSnapshot = snapshotRecord
isSaving.value = true
try {
await favoriteManager.updateFavorite(props.favorite.id, { metadata })
message.success(t('favorites.manager.preview.garden.saveSnapshotSuccess'))
emit('favorite-updated', props.favorite.id)
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '未知错误'
message.error(`${t('favorites.manager.preview.garden.saveSnapshotFailed')}: ${errorMessage}`)
} finally {
isSaving.value = false
}
}
const handleGardenCoverUpload = async (coverDataUrl: string) => {
if (!coverDataUrl) return
await updateGardenSnapshot((snapshotRecord) => {
const assets = isRecord(snapshotRecord.assets) ? { ...snapshotRecord.assets } : {}
const cover = isRecord(assets.cover) ? { ...assets.cover } : {}
cover.url = coverDataUrl
assets.cover = cover
snapshotRecord.assets = assets
})
}
const handleGardenShowcaseUpload = async (showcaseImages: string[]) => {
const imageList = dedupeStrings(showcaseImages || [])
if (imageList.length === 0) return
await updateGardenSnapshot((snapshotRecord) => {
const assets = isRecord(snapshotRecord.assets) ? { ...snapshotRecord.assets } : {}
const showcasesRaw = Array.isArray(assets.showcases) ? [...assets.showcases] : []
const firstShowcase: Record<string, unknown> = isRecord(showcasesRaw[0])
? { ...showcasesRaw[0] }
: {
id: `local-showcase-${Date.now()}`,
images: [] as string[],
}
const mergedImages = dedupeStrings([
...asStringArray(firstShowcase.images),
...imageList,
])
firstShowcase.images = mergedImages
if (!firstShowcase.url && mergedImages.length > 0) {
firstShowcase.url = mergedImages[0]
}
showcasesRaw[0] = firstShowcase
assets.showcases = showcasesRaw
snapshotRecord.assets = assets
})
}
</script>
@@ -1655,6 +1655,7 @@ void registerOptionalIntegrations({
proVariableSession,
imageText2ImageSession,
imageImage2ImageSession,
getFavoriteManager: () => services.value?.favoriteManager || null,
optimizerCurrentVersions,
});
provide("handleSaveFavorite", handleSaveFavorite);
@@ -12,7 +12,12 @@
import { watch, nextTick, type Ref } from 'vue'
import type { LocationQuery, Router } from 'vue-router'
import type { ConversationMessage, PromptRecordChain } from '@prompt-optimizer/core'
import type {
ConversationMessage,
FavoritePrompt,
IFavoriteManager,
PromptRecordChain,
} from '@prompt-optimizer/core'
import { useToast } from '../ui/useToast'
import { createDefaultEvaluationResults } from '../../types/evaluation'
@@ -99,6 +104,7 @@ const resolveTargetKey = (
}
type FetchedPrompt = {
importCode: string
optimizerTargetKey: string
promptFormat: 'text' | 'messages'
promptText?: string
@@ -109,6 +115,196 @@ type FetchedPrompt = {
parameters?: Record<string, string>
inputImages?: string[]
}>
gardenSnapshot: GardenSnapshot
}
type FavoriteManagerLike = Pick<IFavoriteManager, 'getFavorites' | 'addFavorite' | 'updateFavorite'>
type GardenSnapshotVariable = {
name: string
description?: string
type?: 'string' | 'number' | 'boolean' | 'enum'
required?: boolean
defaultValue?: string
options?: string[]
source?: string
}
type GardenSnapshotAssetItem = {
id?: string
url?: string
images?: string[]
inputImages?: string[]
text?: string
description?: string
parameters?: Record<string, string>
[key: string]: unknown
}
type GardenSnapshot = {
schema: 'prompt-garden.prompt.v1'
schemaVersion: 1
importCode: string
gardenBaseUrl: string | null
id?: string
optimizerTarget: {
subModeKey: string
}
prompt: {
format: 'text' | 'messages'
text?: string
messages?: ConversationMessage[]
}
variables: GardenSnapshotVariable[]
assets: {
cover?: {
url?: string
[key: string]: unknown
}
showcases?: GardenSnapshotAssetItem[]
examples?: GardenSnapshotAssetItem[]
}
meta?: Record<string, unknown>
importedAt: string
}
type FavoriteModeMapping =
| { functionMode: 'basic'; optimizationMode: 'system' | 'user'; imageSubMode?: never }
| { functionMode: 'context'; optimizationMode: 'system' | 'user'; imageSubMode?: never }
| { functionMode: 'image'; imageSubMode: 'text2image' | 'image2image'; optimizationMode?: never }
const isTruthyQueryFlag = (value: string | null): boolean => {
if (!value) return false
const normalized = value.trim().toLowerCase()
return normalized === '1' || normalized === 'true'
}
const extractStringArray = (value: unknown): string[] => {
if (!Array.isArray(value)) return []
return value
.map((item) => (typeof item === 'string' ? item.trim() : ''))
.filter(Boolean)
}
const toFavoriteModeMapping = (targetKey: SupportedSubModeKey): FavoriteModeMapping => {
switch (targetKey) {
case 'basic-user':
return { functionMode: 'basic', optimizationMode: 'user' }
case 'pro-multi':
return { functionMode: 'context', optimizationMode: 'system' }
case 'pro-variable':
return { functionMode: 'context', optimizationMode: 'user' }
case 'image-text2image':
return { functionMode: 'image', imageSubMode: 'text2image' }
case 'image-image2image':
return { functionMode: 'image', imageSubMode: 'image2image' }
case 'basic-system':
default:
return { functionMode: 'basic', optimizationMode: 'system' }
}
}
const buildFavoriteContentFromFetchedPrompt = (fetched: FetchedPrompt): string => {
if (fetched.promptFormat === 'text') {
return String(fetched.promptText || '').trim()
}
const rows = (fetched.promptMessages || [])
.map((msg) => {
const role = String(msg.role || '').trim() || 'system'
const content = String(msg.content || '').trim()
if (!content) return ''
return `[${role}] ${content}`
})
.filter(Boolean)
return rows.join('\n\n').trim()
}
const deriveFavoriteTitle = (fetched: FetchedPrompt): string => {
const snapshotMeta = fetched.gardenSnapshot.meta
const metaTitle = snapshotMeta && typeof snapshotMeta.title === 'string'
? snapshotMeta.title.trim()
: ''
if (metaTitle) return metaTitle
const content = buildFavoriteContentFromFetchedPrompt(fetched)
if (!content) return `Prompt Garden ${fetched.importCode}`
const firstLine = content.replace(/\r?\n/g, ' ').trim()
if (firstLine.length <= 60) return firstLine
return `${firstLine.slice(0, 60)}...`
}
const deriveFavoriteDescription = (fetched: FetchedPrompt): string | undefined => {
const snapshotMeta = fetched.gardenSnapshot.meta
const metaDescription = snapshotMeta && typeof snapshotMeta.description === 'string'
? snapshotMeta.description.trim()
: ''
return metaDescription || undefined
}
const deriveFavoriteTags = (fetched: FetchedPrompt): string[] => {
const snapshotMeta = fetched.gardenSnapshot.meta
if (!snapshotMeta) return []
return extractStringArray(snapshotMeta.tags)
}
const isSameGardenSnapshotFavorite = (favorite: FavoritePrompt, snapshot: GardenSnapshot): boolean => {
const metadata = favorite.metadata
if (!isPlainObject(metadata)) return false
const gardenSnapshot = isPlainObject(metadata.gardenSnapshot) ? metadata.gardenSnapshot : null
if (!gardenSnapshot) return false
const importCode = typeof gardenSnapshot.importCode === 'string' ? gardenSnapshot.importCode.trim() : ''
const gardenBaseUrl = typeof gardenSnapshot.gardenBaseUrl === 'string' ? gardenSnapshot.gardenBaseUrl.trim() : ''
return importCode === snapshot.importCode && gardenBaseUrl === (snapshot.gardenBaseUrl || '')
}
const saveImportedPromptToFavorites = async (opts: {
manager: FavoriteManagerLike
fetched: FetchedPrompt
targetKey: SupportedSubModeKey
}): Promise<void> => {
const { manager, fetched, targetKey } = opts
const content = buildFavoriteContentFromFetchedPrompt(fetched)
if (!content) {
throw new Error('Cannot save imported prompt with empty content')
}
const modeMapping = toFavoriteModeMapping(targetKey)
const snapshot = await buildStorableGardenSnapshot(fetched.gardenSnapshot)
const favorites = await manager.getFavorites()
const existing = favorites.find((favorite) => isSameGardenSnapshotFavorite(favorite, snapshot))
if (existing) {
const metadataBase = isPlainObject(existing.metadata) ? existing.metadata : {}
await manager.updateFavorite(existing.id, {
content,
functionMode: modeMapping.functionMode,
optimizationMode: modeMapping.optimizationMode,
imageSubMode: modeMapping.imageSubMode,
metadata: {
...metadataBase,
gardenSnapshot: snapshot,
},
})
return
}
await manager.addFavorite({
title: deriveFavoriteTitle(fetched),
description: deriveFavoriteDescription(fetched),
content,
tags: deriveFavoriteTags(fetched),
functionMode: modeMapping.functionMode,
optimizationMode: modeMapping.optimizationMode,
imageSubMode: modeMapping.imageSubMode,
metadata: {
gardenSnapshot: snapshot,
},
})
}
type ImportedVariable = {
@@ -236,17 +432,88 @@ const buildConversationFromPromptText = (content: string): ConversationMessage[]
return [{ id, role: 'system', content: text, originalContent: text }]
}
const normalizeSnapshotUrl = (opts: {
gardenBaseUrl: string | null
rawUrl: string
}): string => {
const raw = String(opts.rawUrl || '').trim()
if (!raw) return ''
const resolved = resolveGardenUrl({ gardenBaseUrl: opts.gardenBaseUrl, url: raw })
return resolved || raw
}
const normalizeSnapshotUrlList = (opts: {
gardenBaseUrl: string | null
urls: unknown
}): string[] => {
if (!Array.isArray(opts.urls)) return []
return opts.urls
.map((item) => (typeof item === 'string' ? normalizeSnapshotUrl({ gardenBaseUrl: opts.gardenBaseUrl, rawUrl: item }) : ''))
.filter(Boolean)
}
const normalizeSnapshotParameters = (value: unknown): Record<string, string> | undefined => {
if (!isPlainObject(value)) return undefined
const out: Record<string, string> = {}
for (const [k, v] of Object.entries(value)) {
const key = String(k || '').trim()
if (!key) continue
if (v === undefined || v === null) continue
out[key] = String(v)
}
return Object.keys(out).length ? out : undefined
}
const normalizeSnapshotAssetItem = (opts: {
gardenBaseUrl: string | null
item: unknown
}): GardenSnapshotAssetItem | null => {
if (!isPlainObject(opts.item)) return null
const raw = { ...opts.item } as GardenSnapshotAssetItem
if (typeof raw.url === 'string') {
const next = normalizeSnapshotUrl({ gardenBaseUrl: opts.gardenBaseUrl, rawUrl: raw.url })
raw.url = next || undefined
}
const images = normalizeSnapshotUrlList({ gardenBaseUrl: opts.gardenBaseUrl, urls: raw.images })
if (images.length) {
raw.images = images
} else if (Array.isArray(raw.images)) {
raw.images = []
}
const inputImages = normalizeSnapshotUrlList({
gardenBaseUrl: opts.gardenBaseUrl,
urls: raw.inputImages,
})
if (inputImages.length) {
raw.inputImages = inputImages
} else if (Array.isArray(raw.inputImages)) {
raw.inputImages = []
}
const parameters = normalizeSnapshotParameters(raw.parameters)
if (parameters) {
raw.parameters = parameters
} else {
delete raw.parameters
}
return raw
}
const fetchPromptFromGarden = async (opts: {
gardenBaseUrl: string | null
importCode: string
}): Promise<FetchedPrompt> => {
const { gardenBaseUrl, importCode } = opts
const normalizedGardenBaseUrl = gardenBaseUrl ? normalizeBaseUrl(gardenBaseUrl) : null
const url = (() => {
if (!gardenBaseUrl) return null
const base = normalizeBaseUrl(gardenBaseUrl)
if (!base) return null
return `${base}/api/prompt-source/${encodeURIComponent(importCode)}`
if (!normalizedGardenBaseUrl) return null
return `${normalizedGardenBaseUrl}/api/prompt-source/${encodeURIComponent(importCode)}`
})()
if (!url) {
@@ -312,7 +579,7 @@ const fetchPromptFromGarden = async (opts: {
if (!Array.isArray(data.variables)) {
throw new Error('Missing variables')
}
const variables = data.variables
const variablesForImport = data.variables
.map((v): { name: string; defaultValue?: string } => {
if (!isPlainObject(v)) return { name: '' }
const name = typeof v.name === 'string' ? v.name.trim() : ''
@@ -321,29 +588,72 @@ const fetchPromptFromGarden = async (opts: {
})
.filter((v) => isValidVariableName(v.name))
const assets = isPlainObject((data as any).assets) ? ((data as any).assets as Record<string, unknown>) : null
const rawExamples = assets && Array.isArray((assets as any).examples) ? ((assets as any).examples as unknown[]) : []
const examples = rawExamples
.map((ex): { id?: string; parameters?: Record<string, string>; inputImages?: string[] } => {
if (!isPlainObject(ex)) return {}
const snapshotVariables = data.variables
.map((v): GardenSnapshotVariable | null => {
if (!isPlainObject(v)) return null
const name = typeof v.name === 'string' ? v.name.trim() : ''
if (!isValidVariableName(name)) return null
const type =
v.type === 'string' || v.type === 'number' || v.type === 'boolean' || v.type === 'enum'
? v.type
: undefined
const options = extractStringArray(v.options)
const source = typeof v.source === 'string' && v.source.trim() ? v.source.trim() : undefined
return {
name,
description: typeof v.description === 'string' ? v.description : undefined,
type,
required: typeof v.required === 'boolean' ? v.required : undefined,
defaultValue: typeof v.defaultValue === 'string' ? v.defaultValue : undefined,
options: options.length ? options : undefined,
source,
}
})
.filter((v): v is GardenSnapshotVariable => Boolean(v))
const assets = isPlainObject(data.assets) ? data.assets : null
const cover = (() => {
if (!assets || !isPlainObject(assets.cover)) return undefined
const out = { ...assets.cover } as { url?: string; [key: string]: unknown }
if (typeof out.url === 'string') {
out.url = normalizeSnapshotUrl({
gardenBaseUrl: normalizedGardenBaseUrl,
rawUrl: out.url,
}) || undefined
}
return out
})()
const showcases = assets && Array.isArray(assets.showcases)
? assets.showcases
.map((item) => normalizeSnapshotAssetItem({
gardenBaseUrl: normalizedGardenBaseUrl,
item,
}))
.filter((item): item is GardenSnapshotAssetItem => Boolean(item))
: []
const snapshotExamples = assets && Array.isArray(assets.examples)
? assets.examples
.map((item) => normalizeSnapshotAssetItem({
gardenBaseUrl: normalizedGardenBaseUrl,
item,
}))
.filter((item): item is GardenSnapshotAssetItem => Boolean(item))
: []
const examples = snapshotExamples
.map((ex): { id?: string; parameters?: Record<string, string>; inputImages?: string[] } => {
const id = typeof ex.id === 'string' ? ex.id.trim() : undefined
const parameters = (() => {
const p = (ex as any).parameters
if (!isPlainObject(p)) return undefined
const out: Record<string, string> = {}
for (const [k, v] of Object.entries(p)) {
const key = String(k || '').trim()
if (!isValidVariableName(key)) continue
if (v === undefined || v === null) continue
out[key] = String(v)
}
return Object.keys(out).length ? out : undefined
})()
const parameters = normalizeSnapshotParameters(ex.parameters)
const inputImages = Array.isArray((ex as any).inputImages)
? ((ex as any).inputImages as unknown[])
const inputImages = Array.isArray(ex.inputImages)
? ex.inputImages
.map((u) => (typeof u === 'string' ? u.trim() : ''))
.filter(Boolean)
: undefined
@@ -356,13 +666,49 @@ const fetchPromptFromGarden = async (opts: {
})
.filter((ex) => Boolean(ex.parameters) || (Array.isArray(ex.inputImages) && ex.inputImages.length > 0))
const meta = isPlainObject(data.meta) ? { ...data.meta } : undefined
const snapshot: GardenSnapshot = {
schema: 'prompt-garden.prompt.v1',
schemaVersion: 1,
importCode:
typeof data.importCode === 'string' && data.importCode.trim()
? data.importCode.trim()
: importCode,
gardenBaseUrl: normalizedGardenBaseUrl,
id: typeof data.id === 'string' ? data.id : undefined,
optimizerTarget: {
subModeKey: optimizerTargetKey,
},
prompt:
format === 'text'
? {
format,
text: promptText,
}
: {
format,
messages: promptMessages,
},
variables: snapshotVariables,
assets: {
cover,
showcases: showcases.length ? showcases : undefined,
examples: snapshotExamples.length ? snapshotExamples : undefined,
},
meta,
importedAt: new Date().toISOString(),
}
return {
importCode: snapshot.importCode,
optimizerTargetKey,
promptFormat: format,
promptText,
promptMessages,
variables,
variables: variablesForImport,
examples,
gardenSnapshot: snapshot,
}
}
@@ -400,8 +746,12 @@ const fetchImageAsBase64 = async (absoluteUrl: string): Promise<{ b64: string; m
const headerType = resp.headers.get('content-type')
const mimeType = typeof headerType === 'string' ? headerType.split(';')[0].trim() : ''
type BufferLike = {
from: (data: ArrayBuffer) => { toString: (encoding: 'base64') => string }
}
// Tests run in Node where Buffer is available; browsers use FileReader.
const maybeBuffer = (globalThis as any).Buffer as any
const maybeBuffer = (globalThis as unknown as { Buffer?: BufferLike }).Buffer
if (maybeBuffer && typeof maybeBuffer.from === 'function') {
const ab = await resp.arrayBuffer()
const b64 = maybeBuffer.from(ab).toString('base64')
@@ -429,6 +779,88 @@ const fetchImageAsBase64 = async (absoluteUrl: string): Promise<{ b64: string; m
return { b64, mimeType: actualMime }
}
const isDataUrl = (value: string): boolean => /^data:/iu.test(value.trim())
const toDataUrlOrOriginal = async (value: string): Promise<string> => {
const raw = String(value || '').trim()
if (!raw) return ''
if (isDataUrl(raw)) return raw
if (!/^https?:\/\//u.test(raw)) return raw
try {
const encoded = await fetchImageAsBase64(raw)
if (!encoded?.b64) return raw
const mimeType = encoded.mimeType || 'application/octet-stream'
return `data:${mimeType};base64,${encoded.b64}`
} catch (error) {
console.warn('[PromptGardenImport] Failed to convert image URL to base64, keep original URL:', raw, error)
return raw
}
}
const toDataUrlListOrOriginal = async (urls: unknown): Promise<string[] | undefined> => {
if (!Array.isArray(urls)) return undefined
const converted = await Promise.all(
urls.map(async (item) => {
if (typeof item !== 'string') return ''
return toDataUrlOrOriginal(item)
})
)
return converted.filter(Boolean)
}
const toBase64AssetItem = async (item: GardenSnapshotAssetItem): Promise<GardenSnapshotAssetItem> => {
const next: GardenSnapshotAssetItem = { ...item }
if (typeof next.url === 'string') {
next.url = await toDataUrlOrOriginal(next.url)
}
const images = await toDataUrlListOrOriginal(next.images)
if (images !== undefined) {
next.images = images
}
const inputImages = await toDataUrlListOrOriginal(next.inputImages)
if (inputImages !== undefined) {
next.inputImages = inputImages
}
return next
}
async function buildStorableGardenSnapshot(snapshot: GardenSnapshot): Promise<GardenSnapshot> {
const assets = snapshot.assets || {}
let cover = assets.cover
if (cover && typeof cover.url === 'string') {
cover = {
...cover,
url: await toDataUrlOrOriginal(cover.url),
}
}
const showcases = Array.isArray(assets.showcases)
? await Promise.all(assets.showcases.map((item) => toBase64AssetItem(item)))
: undefined
const examples = Array.isArray(assets.examples)
? await Promise.all(assets.examples.map((item) => toBase64AssetItem(item)))
: undefined
return {
...snapshot,
assets: {
...assets,
cover,
showcases,
examples,
},
}
}
const pickImportedExample = (
examples: FetchedPrompt['examples'],
exampleId: string | null,
@@ -533,6 +965,9 @@ export interface AppPromptGardenImportOptions {
imageText2ImageSession: ImageText2ImageSessionApi
imageImage2ImageSession: ImageImage2ImageSessionApi
/** Optional getter for auto-save-to-favorites flow. */
getFavoriteManager?: () => FavoriteManagerLike | null
/** UI-only current versions list for history drawer; safe to clear for basic imports */
optimizerCurrentVersions: Ref<PromptRecordChain['versions']>
}
@@ -550,6 +985,7 @@ export function useAppPromptGardenImport(options: AppPromptGardenImportOptions)
proVariableSession,
imageText2ImageSession,
imageImage2ImageSession,
getFavoriteManager,
optimizerCurrentVersions,
} = options
@@ -571,6 +1007,7 @@ export function useAppPromptGardenImport(options: AppPromptGardenImportOptions)
if (!importCode) return
const exampleId = getQueryString(query, 'exampleId')
const shouldSaveToFavorites = isTruthyQueryFlag(getQueryString(query, 'saveToFavorites'))
inFlight.value = true
isLoadingExternalData.value = true
@@ -702,6 +1139,23 @@ export function useAppPromptGardenImport(options: AppPromptGardenImportOptions)
}
}
if (shouldSaveToFavorites) {
const favoriteManager = getFavoriteManager?.() || null
if (favoriteManager) {
try {
await saveImportedPromptToFavorites({
manager: favoriteManager,
fetched,
targetKey,
})
} catch (error) {
console.warn('[PromptGardenImport] Auto-save to favorites failed:', error)
}
} else {
console.warn('[PromptGardenImport] Favorite manager unavailable, skip auto-save')
}
}
// Best-effort persist.
try {
if (targetKey === 'basic-system') await basicSystemSession.saveSession()
@@ -715,7 +1169,7 @@ export function useAppPromptGardenImport(options: AppPromptGardenImportOptions)
}
// Remove import params to avoid re-import on refresh.
const cleanedQuery = omitKeys(query, ['importCode', 'subModeKey', 'exampleId'])
const cleanedQuery = omitKeys(query, ['importCode', 'subModeKey', 'exampleId', 'saveToFavorites'])
await router.replace({ path: router.currentRoute.value.path, query: cleanedQuery })
await nextTick()
+35
View File
@@ -228,6 +228,41 @@ export default {
preview: {
title: "Favorite Details",
updatedAt: "Updated {time}",
garden: {
snapshotTitle: "Prompt Garden Snapshot",
snapshotHint: "Imported visual assets and variable settings from Prompt Garden",
basicInfo: "Basic Info",
metaInfo: "Meta",
importCode: "Import Code",
gardenBaseUrl: "Garden Base URL",
schema: "Schema",
title: "Title",
description: "Description",
cover: "Cover",
showcases: "Showcases",
showcaseLabel: "Showcase #{index}",
examples: "Examples",
exampleLabel: "Example #{index}",
exampleImages: "Example Images",
parameters: "Parameters",
inputImages: "Input Images",
variables: "Variables",
variableName: "Name",
variableType: "Type",
variableRequired: "Required",
variableDefault: "Default",
variableOptions: "Options",
variableDescription: "Description",
requiredYes: "Yes",
requiredNo: "No",
uploadCover: "Upload Cover",
uploadShowcaseImages: "Upload Showcase Images",
noCover: "No cover image yet",
noShowcases: "No showcase images yet",
noExamples: "No examples yet",
saveSnapshotSuccess: "Snapshot updated",
saveSnapshotFailed: "Failed to update snapshot",
},
},
messages: {
unavailable:
+35
View File
@@ -220,6 +220,41 @@ export default {
preview: {
title: "收藏详情",
updatedAt: "更新于 {time}",
garden: {
snapshotTitle: "Prompt Garden 快照",
snapshotHint: "展示从 Prompt Garden 导入的可视化资源和变量配置",
basicInfo: "基础信息",
metaInfo: "元信息",
importCode: "导入码",
gardenBaseUrl: "Garden 地址",
schema: "Schema",
title: "标题",
description: "描述",
cover: "封面",
showcases: "展示集",
showcaseLabel: "展示 #{index}",
examples: "示例",
exampleLabel: "示例 #{index}",
exampleImages: "示例图片",
parameters: "参数",
inputImages: "输入图片",
variables: "变量定义",
variableName: "名称",
variableType: "类型",
variableRequired: "必填",
variableDefault: "默认值",
variableOptions: "可选项",
variableDescription: "说明",
requiredYes: "是",
requiredNo: "否",
uploadCover: "上传封面图",
uploadShowcaseImages: "上传效果图",
noCover: "暂无封面图",
noShowcases: "暂无效果图",
noExamples: "暂无示例",
saveSnapshotSuccess: "快照已更新",
saveSnapshotFailed: "快照更新失败",
},
},
messages: {
unavailable: "收藏功能暂不可用,请稍后再试",
+35
View File
@@ -220,6 +220,41 @@ export default {
preview: {
title: "收藏詳情",
updatedAt: "更新於 {time}",
garden: {
snapshotTitle: "Prompt Garden 快照",
snapshotHint: "展示從 Prompt Garden 匯入的可視化資源和變數設定",
basicInfo: "基礎資訊",
metaInfo: "中繼資料",
importCode: "匯入碼",
gardenBaseUrl: "Garden 位址",
schema: "Schema",
title: "標題",
description: "描述",
cover: "封面",
showcases: "展示集",
showcaseLabel: "展示 #{index}",
examples: "範例",
exampleLabel: "範例 #{index}",
exampleImages: "範例圖片",
parameters: "參數",
inputImages: "輸入圖片",
variables: "變數定義",
variableName: "名稱",
variableType: "類型",
variableRequired: "必填",
variableDefault: "預設值",
variableOptions: "可選項",
variableDescription: "說明",
requiredYes: "是",
requiredNo: "否",
uploadCover: "上傳封面圖",
uploadShowcaseImages: "上傳效果圖",
noCover: "暫無封面圖",
noShowcases: "暫無效果圖",
noExamples: "暫無範例",
saveSnapshotSuccess: "快照已更新",
saveSnapshotFailed: "快照更新失敗",
},
},
messages: {
unavailable: "收藏功能暫不可用,請稍後再試",
@@ -0,0 +1,56 @@
import { getEnvVar, type FavoritePrompt } from '@prompt-optimizer/core'
import type { Component } from 'vue'
export interface FavoritePreviewPlugin {
id: string
envFlag: string
order?: number
match: (favorite: FavoritePrompt) => boolean
component: Component
}
type FavoritePreviewModule = {
favoritePreviewPlugin?: FavoritePreviewPlugin
}
const discoveredFavoritePreviewModules = import.meta.glob<FavoritePreviewModule>(
'./*.favorite-preview.ts'
)
const isEnvEnabled = (value: unknown): boolean => {
return value === '1' || value === 'true'
}
let favoritePreviewPluginsCache: FavoritePreviewPlugin[] | null = null
/**
* Loads favorite preview plugins lazily and filters by env flag.
*/
export const loadEnabledFavoritePreviewPlugins = async (): Promise<FavoritePreviewPlugin[]> => {
if (favoritePreviewPluginsCache) {
return favoritePreviewPluginsCache
}
const modules = Object.values(discoveredFavoritePreviewModules)
const loaded = await Promise.all(
modules.map(async (loader) => {
try {
return await loader()
} catch (error) {
console.error('[FavoritePreviewPlugins] Failed to load plugin module', error)
return null
}
}),
)
const plugins = loaded
.map((mod) => mod?.favoritePreviewPlugin)
.filter((plugin): plugin is FavoritePreviewPlugin => {
if (!plugin) return false
return isEnvEnabled(getEnvVar(plugin.envFlag))
})
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
favoritePreviewPluginsCache = plugins
return plugins
}
@@ -0,0 +1,15 @@
import type { FavoritePrompt } from '@prompt-optimizer/core'
import PromptGardenFavoritePreviewPanel from '../components/PromptGardenFavoritePreviewPanel.vue'
import { parseFavoriteGardenSnapshotPreview } from '../utils/garden-snapshot-preview'
import type { FavoritePreviewPlugin } from './favoritePreviewPlugins'
export const favoritePreviewPlugin: FavoritePreviewPlugin = {
id: 'prompt-garden-preview',
envFlag: 'VITE_ENABLE_PROMPT_GARDEN_IMPORT',
order: 100,
match: (favorite: FavoritePrompt) => {
return Boolean(parseFavoriteGardenSnapshotPreview(favorite))
},
component: PromptGardenFavoritePreviewPanel,
}
@@ -23,6 +23,7 @@ export const integration: OptionalIntegration = {
proVariableSession: ctx.proVariableSession,
imageText2ImageSession: ctx.imageText2ImageSession,
imageImage2ImageSession: ctx.imageImage2ImageSession,
getFavoriteManager: () => ctx.getFavoriteManager(),
optimizerCurrentVersions: ctx.optimizerCurrentVersions,
})
},
+4 -1
View File
@@ -1,6 +1,6 @@
import type { Ref } from 'vue'
import type { Router } from 'vue-router'
import type { ConversationMessage, PromptRecordChain } from '@prompt-optimizer/core'
import type { ConversationMessage, IFavoriteManager, PromptRecordChain } from '@prompt-optimizer/core'
import type { BasicSystemSessionApi } from '../stores/session/useBasicSystemSession'
import type { BasicUserSessionApi } from '../stores/session/useBasicUserSession'
@@ -27,6 +27,9 @@ export interface OptionalIntegrationsContext {
imageText2ImageSession: ImageText2ImageSessionApi
imageImage2ImageSession: ImageImage2ImageSessionApi
optimizerCurrentVersions: Ref<PromptRecordChain['versions']>
/** Lazy getter to avoid hard coupling optional integrations to app service lifecycle. */
getFavoriteManager: () => IFavoriteManager | null
}
export interface OptionalIntegration {
@@ -0,0 +1,200 @@
import type { FavoritePrompt } from '@prompt-optimizer/core'
export type GardenSnapshotPreviewVariable = {
name: string
description?: string
type?: 'string' | 'number' | 'boolean' | 'enum'
required: boolean
defaultValue?: string
options: string[]
source?: string
}
export type GardenSnapshotPreviewAsset = {
id?: string
text?: string
description?: string
url?: string
images: string[]
inputImages: string[]
parameters: Record<string, string>
}
export type GardenSnapshotPreviewMeta = {
title?: string
description?: string
tags: string[]
}
export type GardenSnapshotPreview = {
schema?: string
schemaVersion?: number
importCode?: string
gardenBaseUrl?: string
importedAt?: string
meta: GardenSnapshotPreviewMeta
variables: GardenSnapshotPreviewVariable[]
coverUrl?: string
showcases: GardenSnapshotPreviewAsset[]
examples: GardenSnapshotPreviewAsset[]
}
const isPlainObject = (value: unknown): value is Record<string, unknown> => {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
}
const asTrimmedString = (value: unknown): string | undefined => {
if (typeof value !== 'string') return undefined
const trimmed = value.trim()
return trimmed ? trimmed : undefined
}
const asStringArray = (value: unknown): string[] => {
if (!Array.isArray(value)) return []
return value
.map((item) => asTrimmedString(item))
.filter((item): item is string => Boolean(item))
}
const parseParameters = (value: unknown): Record<string, string> => {
if (!isPlainObject(value)) return {}
const out: Record<string, string> = {}
for (const [key, raw] of Object.entries(value)) {
const normalizedKey = key.trim()
if (!normalizedKey) continue
if (raw === undefined || raw === null) continue
out[normalizedKey] = String(raw)
}
return out
}
const dedupeStrings = (list: string[]): string[] => {
const out: string[] = []
const seen = new Set<string>()
for (const item of list) {
if (!item || seen.has(item)) continue
seen.add(item)
out.push(item)
}
return out
}
const parseAsset = (value: unknown): GardenSnapshotPreviewAsset | null => {
if (!isPlainObject(value)) return null
const url = asTrimmedString(value.url)
const images = dedupeStrings([
...(url ? [url] : []),
...asStringArray(value.images),
])
const asset: GardenSnapshotPreviewAsset = {
id: asTrimmedString(value.id),
text: asTrimmedString(value.text),
description: asTrimmedString(value.description),
url,
images,
inputImages: dedupeStrings(asStringArray(value.inputImages)),
parameters: parseParameters(value.parameters),
}
const hasData = Boolean(
asset.id ||
asset.text ||
asset.description ||
asset.url ||
asset.images.length > 0 ||
asset.inputImages.length > 0 ||
Object.keys(asset.parameters).length > 0,
)
return hasData ? asset : null
}
const parseAssets = (value: unknown): GardenSnapshotPreviewAsset[] => {
if (!Array.isArray(value)) return []
return value
.map((item) => parseAsset(item))
.filter((item): item is GardenSnapshotPreviewAsset => Boolean(item))
}
const parseVariables = (value: unknown): GardenSnapshotPreviewVariable[] => {
if (!Array.isArray(value)) return []
return value
.map((item): GardenSnapshotPreviewVariable | null => {
if (!isPlainObject(item)) return null
const name = asTrimmedString(item.name)
if (!name) return null
const type =
item.type === 'string' ||
item.type === 'number' ||
item.type === 'boolean' ||
item.type === 'enum'
? item.type
: undefined
return {
name,
description: asTrimmedString(item.description),
type,
required: item.required === true,
defaultValue: asTrimmedString(item.defaultValue),
options: dedupeStrings(asStringArray(item.options)),
source: asTrimmedString(item.source),
}
})
.filter((item): item is GardenSnapshotPreviewVariable => Boolean(item))
}
export const parseGardenSnapshotPreview = (value: unknown): GardenSnapshotPreview | null => {
if (!isPlainObject(value)) return null
const assets = isPlainObject(value.assets) ? value.assets : {}
const meta = isPlainObject(value.meta) ? value.meta : {}
const cover = isPlainObject(assets.cover) ? assets.cover : {}
const snapshot: GardenSnapshotPreview = {
schema: asTrimmedString(value.schema),
schemaVersion: typeof value.schemaVersion === 'number' ? value.schemaVersion : undefined,
importCode: asTrimmedString(value.importCode),
gardenBaseUrl: asTrimmedString(value.gardenBaseUrl),
importedAt: asTrimmedString(value.importedAt),
meta: {
title: asTrimmedString(meta.title),
description: asTrimmedString(meta.description),
tags: dedupeStrings(asStringArray(meta.tags)),
},
variables: parseVariables(value.variables),
coverUrl: asTrimmedString(cover.url),
showcases: parseAssets(assets.showcases),
examples: parseAssets(assets.examples),
}
const hasMeaningfulContent = Boolean(
snapshot.importCode ||
snapshot.gardenBaseUrl ||
snapshot.meta.title ||
snapshot.meta.description ||
snapshot.meta.tags.length > 0 ||
snapshot.coverUrl ||
snapshot.variables.length > 0 ||
snapshot.showcases.length > 0 ||
snapshot.examples.length > 0,
)
return hasMeaningfulContent ? snapshot : null
}
export const parseFavoriteGardenSnapshotPreview = (
favorite: FavoritePrompt | null | undefined,
): GardenSnapshotPreview | null => {
if (!favorite) return null
if (!isPlainObject(favorite.metadata)) return null
return parseGardenSnapshotPreview(favorite.metadata.gardenSnapshot)
}
@@ -0,0 +1,167 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import type { UploadFileInfo } from 'naive-ui'
import GardenSnapshotPreview from '../../../src/components/GardenSnapshotPreview.vue'
import type { GardenSnapshotPreview as GardenSnapshotPreviewData } from '../../../src/utils/garden-snapshot-preview'
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string, params?: Record<string, unknown>) => {
if (params && typeof params.index === 'number') {
return `${key}:${params.index}`
}
return key
},
}),
}))
const createSnapshot = (): GardenSnapshotPreviewData => ({
schema: 'prompt-garden.prompt.v1',
schemaVersion: 1,
importCode: 'IMP-100',
gardenBaseUrl: 'https://garden.example.com',
importedAt: '2026-01-01T00:00:00.000Z',
meta: {
title: 'Snapshot Title',
description: 'Snapshot Description',
tags: ['portrait', 'lighting'],
},
coverUrl: 'https://cdn.example.com/cover.png',
variables: [
{
name: 'style',
type: 'enum',
required: true,
defaultValue: 'cinematic',
options: ['cinematic', 'anime'],
description: 'Output style',
},
],
showcases: [
{
id: 'showcase-1',
text: 'Showcase text',
description: 'Showcase description',
images: ['https://cdn.example.com/showcase.png'],
inputImages: [],
parameters: {},
},
],
examples: [
{
id: 'example-1',
text: 'Example text',
description: 'Example description',
images: ['https://cdn.example.com/example.png'],
inputImages: ['https://cdn.example.com/input.png'],
parameters: {
width: '1024',
},
},
],
})
describe('GardenSnapshotPreview', () => {
afterEach(() => {
vi.unstubAllGlobals()
})
it('renders snapshot sections with assets, variables and example details', () => {
const wrapper = mount(GardenSnapshotPreview, {
props: {
snapshot: createSnapshot(),
},
})
expect(wrapper.find('[data-testid="favorite-garden-snapshot-preview"]').exists()).toBe(true)
expect(wrapper.text()).toContain('IMP-100')
expect(wrapper.text()).toContain('https://garden.example.com')
expect(wrapper.text()).toContain('Snapshot Title')
expect(wrapper.text()).toContain('Snapshot Description')
const images = wrapper.findAll('img')
expect(images.length).toBe(4)
expect(wrapper.find('[data-testid="favorite-garden-showcases"]').exists()).toBe(true)
expect(wrapper.find('[data-testid="favorite-garden-examples"]').exists()).toBe(true)
expect(wrapper.find('[data-testid="favorite-garden-variables"]').exists()).toBe(true)
expect(wrapper.text()).toContain('width')
expect(wrapper.text()).toContain('1024')
})
it('keeps optional sections hidden when snapshot data is minimal', () => {
const wrapper = mount(GardenSnapshotPreview, {
props: {
snapshot: {
meta: {
tags: [],
},
variables: [],
showcases: [],
examples: [],
importCode: 'IMP-MIN',
},
},
})
expect(wrapper.text()).toContain('IMP-MIN')
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('supports image zoom and local upload actions in editable mode', async () => {
class MockFileReader {
public result: string | null = null
public onload: ((event: Event) => void) | null = null
public onerror: ((event: Event) => void) | null = null
readAsDataURL() {
this.result = 'data:image/png;base64,dGVzdA=='
this.onload?.(new Event('load'))
}
}
vi.stubGlobal('FileReader', MockFileReader)
const wrapper = mount(GardenSnapshotPreview, {
props: {
snapshot: createSnapshot(),
editable: true,
},
})
expect(wrapper.findAll('.n-image').length).toBeGreaterThan(0)
const coverFile = new File(['cover'], 'cover.png', { type: 'image/png' })
const showcaseFile = new File(['shot'], 'shot.png', { type: 'image/png' })
await (wrapper.vm as unknown as {
handleCoverUploadChange: (arg: { file: UploadFileInfo | null; fileList: UploadFileInfo[] }) => Promise<void>
handleShowcaseUploadChange: (arg: { file: UploadFileInfo | null; fileList: UploadFileInfo[] }) => Promise<void>
}).handleCoverUploadChange({
file: { file: coverFile } as UploadFileInfo,
fileList: [{ file: coverFile } as UploadFileInfo],
})
await (wrapper.vm as unknown as {
handleShowcaseUploadChange: (arg: { file: UploadFileInfo | null; fileList: UploadFileInfo[] }) => Promise<void>
}).handleShowcaseUploadChange({
file: { file: showcaseFile } as UploadFileInfo,
fileList: [{ file: showcaseFile } as UploadFileInfo],
})
const coverEvent = wrapper.emitted('upload-cover')
expect(coverEvent).toBeTruthy()
const coverPayload = coverEvent?.[0]?.[0] as string | undefined
expect(String(coverPayload || '')).toMatch(/^data:image\/png;base64,/)
const showcaseEvent = wrapper.emitted('append-showcase-images')
expect(showcaseEvent).toBeTruthy()
const showcasePayload = showcaseEvent?.[0]?.[0] as string[] | undefined
expect(Array.isArray(showcasePayload)).toBe(true)
expect(String(showcasePayload?.[0] || '')).toMatch(/^data:image\/png;base64,/)
})
})
@@ -6,7 +6,7 @@ import type {
RouteLocationRaw,
Router,
} from 'vue-router'
import type { ConversationMessage, PromptRecord, PromptRecordChain } from '@prompt-optimizer/core'
import type { ConversationMessage, FavoritePrompt, PromptRecord, PromptRecordChain } from '@prompt-optimizer/core'
import type { MessageReactive } from 'naive-ui'
import { createTestPinia } from '../../utils/pinia-test-helpers'
@@ -1211,4 +1211,454 @@ describe('useAppPromptGardenImport', () => {
scope.stop()
}
})
it('auto-saves imported prompt into favorites when saveToFavorites=1', async () => {
const { pinia } = createTestPinia()
const createReactive = (): MessageReactive => ({
destroy: () => {},
} as unknown as MessageReactive)
setGlobalMessageApi({
success: vi.fn(() => createReactive()),
error: vi.fn(() => createReactive()),
warning: vi.fn(() => createReactive()),
info: vi.fn(() => createReactive()),
})
const basicSystemSession = useBasicSystemSession(pinia)
const basicUserSession = useBasicUserSession(pinia)
const proMultiMessageSession = useProMultiMessageSession(pinia)
const proVariableSession = useProVariableSession(pinia)
const imageText2ImageSession = useImageText2ImageSession(pinia)
const imageImage2ImageSession = useImageImage2ImageSession(pinia)
const optimizerCurrentVersions = ref<PromptRecordChain['versions']>([])
const hasRestoredInitialState = ref(false)
const isLoadingExternalData = ref(false)
const query: LocationQuery = {
importCode: 'NB-SAVE-001',
saveToFavorites: '1',
}
const currentRoute = ref<RouteLocationNormalizedLoaded>(makeRoute('/basic/system', query))
let replaceResolve: (() => void) | undefined
const replaceDone = new Promise<void>((resolve) => {
replaceResolve = resolve
})
const push: Router['push'] = vi.fn(async (to) => {
applyNavigation(currentRoute, to)
return undefined
})
const replace: Router['replace'] = vi.fn(async (to) => {
applyNavigation(currentRoute, to)
replaceResolve?.()
return undefined
})
const router: Pick<Router, 'currentRoute' | 'push' | 'replace'> = {
currentRoute,
push,
replace,
}
const favoriteManager = {
getFavorites: vi.fn(async (): Promise<FavoritePrompt[]> => []),
addFavorite: vi.fn(async (
_favorite: Omit<FavoritePrompt, 'id' | 'createdAt' | 'updatedAt' | 'useCount'>
) => 'fav-new'),
updateFavorite: vi.fn(async (_id: string, _updates: Partial<FavoritePrompt>) => {}),
}
const v1Payload = {
id: 'prompt-001',
importCode: 'NB-SAVE-001',
schema: 'prompt-garden.prompt.v1',
schemaVersion: 1,
optimizerTarget: { subModeKey: 'basic-system' },
prompt: { format: 'text', text: 'IMPORTED PROMPT' },
variables: [
{
name: 'topic',
description: 'topic desc',
type: 'string',
required: true,
defaultValue: 'travel',
source: 'frontmatter',
},
],
assets: {
cover: {
url: '/prompt-assets/cover.png',
},
showcases: [
{
id: 'show-1',
images: ['/prompt-assets/show-1.png'],
},
],
examples: [
{
id: 'ex-1',
inputImages: ['/prompt-assets/in-1.png'],
parameters: {
topic: 'city',
},
},
],
},
meta: {
title: 'Garden Prompt Title',
description: 'Garden Prompt Description',
tags: ['travel', 'city'],
},
}
const fetchMock = vi.fn<
(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
>(async () => {
return new Response(JSON.stringify(v1Payload), {
status: 200,
headers: { 'content-type': 'application/json' },
})
})
vi.stubGlobal('fetch', fetchMock)
const scope = effectScope()
try {
scope.run(() => {
useAppPromptGardenImport({
router,
hasRestoredInitialState,
isLoadingExternalData,
gardenBaseUrl: 'http://garden.local',
basicSystemSession,
basicUserSession,
proMultiMessageSession,
proVariableSession,
imageText2ImageSession,
imageImage2ImageSession,
getFavoriteManager: () => favoriteManager,
optimizerCurrentVersions,
})
})
hasRestoredInitialState.value = true
await replaceDone
await waitForCondition(() => isLoadingExternalData.value === false)
expect(favoriteManager.getFavorites).toHaveBeenCalledTimes(1)
expect(favoriteManager.updateFavorite).not.toHaveBeenCalled()
expect(favoriteManager.addFavorite).toHaveBeenCalledTimes(1)
const savedArg = favoriteManager.addFavorite.mock.calls[0]![0] as Record<string, unknown>
expect(savedArg.content).toBe('IMPORTED PROMPT')
expect(savedArg.functionMode).toBe('basic')
expect(savedArg.optimizationMode).toBe('system')
expect(savedArg.tags).toEqual(['travel', 'city'])
const metadata = savedArg.metadata as Record<string, unknown>
const snapshot = metadata.gardenSnapshot as Record<string, unknown>
expect(snapshot.importCode).toBe('NB-SAVE-001')
expect(snapshot.gardenBaseUrl).toBe('http://garden.local')
const snapshotAssets = snapshot.assets as Record<string, unknown>
const cover = snapshotAssets.cover as Record<string, unknown>
expect(String(cover.url)).toMatch(/^data:/)
const showcases = snapshotAssets.showcases as Array<Record<string, unknown>>
const showcaseImages = showcases[0]?.images as unknown[]
expect(showcaseImages).toHaveLength(1)
expect(String(showcaseImages[0])).toMatch(/^data:/)
const examples = snapshotAssets.examples as Array<Record<string, unknown>>
const inputImages = examples[0]?.inputImages as unknown[]
expect(inputImages).toHaveLength(1)
expect(String(inputImages[0])).toMatch(/^data:/)
// saveToFavorites 查询参数会和 import 参数一起清理。
expect(currentRoute.value.query.importCode).toBeUndefined()
expect(currentRoute.value.query.saveToFavorites).toBeUndefined()
} finally {
scope.stop()
}
})
it('does not auto-save when saveToFavorites flag is absent', async () => {
const { pinia } = createTestPinia()
const createReactive = (): MessageReactive => ({
destroy: () => {},
} as unknown as MessageReactive)
setGlobalMessageApi({
success: vi.fn(() => createReactive()),
error: vi.fn(() => createReactive()),
warning: vi.fn(() => createReactive()),
info: vi.fn(() => createReactive()),
})
const basicSystemSession = useBasicSystemSession(pinia)
const basicUserSession = useBasicUserSession(pinia)
const proMultiMessageSession = useProMultiMessageSession(pinia)
const proVariableSession = useProVariableSession(pinia)
const imageText2ImageSession = useImageText2ImageSession(pinia)
const imageImage2ImageSession = useImageImage2ImageSession(pinia)
const optimizerCurrentVersions = ref<PromptRecordChain['versions']>([])
const hasRestoredInitialState = ref(false)
const isLoadingExternalData = ref(false)
const query: LocationQuery = {
importCode: 'NB-NO-SAVE-001',
}
const currentRoute = ref<RouteLocationNormalizedLoaded>(makeRoute('/basic/system', query))
let replaceResolve: (() => void) | undefined
const replaceDone = new Promise<void>((resolve) => {
replaceResolve = resolve
})
const push: Router['push'] = vi.fn(async (to) => {
applyNavigation(currentRoute, to)
return undefined
})
const replace: Router['replace'] = vi.fn(async (to) => {
applyNavigation(currentRoute, to)
replaceResolve?.()
return undefined
})
const router: Pick<Router, 'currentRoute' | 'push' | 'replace'> = {
currentRoute,
push,
replace,
}
const favoriteManager = {
getFavorites: vi.fn(async (): Promise<FavoritePrompt[]> => []),
addFavorite: vi.fn(async (
_favorite: Omit<FavoritePrompt, 'id' | 'createdAt' | 'updatedAt' | 'useCount'>
) => 'fav-new'),
updateFavorite: vi.fn(async (_id: string, _updates: Partial<FavoritePrompt>) => {}),
}
const fetchMock = vi.fn<
(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
>(async () => {
return new Response(
JSON.stringify({
schema: 'prompt-garden.prompt.v1',
schemaVersion: 1,
optimizerTarget: { subModeKey: 'basic-system' },
prompt: { format: 'text', text: 'NO SAVE PROMPT' },
variables: [],
}),
{
status: 200,
headers: { 'content-type': 'application/json' },
}
)
})
vi.stubGlobal('fetch', fetchMock)
const scope = effectScope()
try {
scope.run(() => {
useAppPromptGardenImport({
router,
hasRestoredInitialState,
isLoadingExternalData,
gardenBaseUrl: 'http://garden.local',
basicSystemSession,
basicUserSession,
proMultiMessageSession,
proVariableSession,
imageText2ImageSession,
imageImage2ImageSession,
getFavoriteManager: () => favoriteManager,
optimizerCurrentVersions,
})
})
hasRestoredInitialState.value = true
await replaceDone
await waitForCondition(() => isLoadingExternalData.value === false)
expect(favoriteManager.getFavorites).not.toHaveBeenCalled()
expect(favoriteManager.addFavorite).not.toHaveBeenCalled()
expect(favoriteManager.updateFavorite).not.toHaveBeenCalled()
} finally {
scope.stop()
}
})
it('upserts existing favorite by gardenSnapshot importCode + gardenBaseUrl', async () => {
const { pinia } = createTestPinia()
const createReactive = (): MessageReactive => ({
destroy: () => {},
} as unknown as MessageReactive)
setGlobalMessageApi({
success: vi.fn(() => createReactive()),
error: vi.fn(() => createReactive()),
warning: vi.fn(() => createReactive()),
info: vi.fn(() => createReactive()),
})
const basicSystemSession = useBasicSystemSession(pinia)
const basicUserSession = useBasicUserSession(pinia)
const proMultiMessageSession = useProMultiMessageSession(pinia)
const proVariableSession = useProVariableSession(pinia)
const imageText2ImageSession = useImageText2ImageSession(pinia)
const imageImage2ImageSession = useImageImage2ImageSession(pinia)
const optimizerCurrentVersions = ref<PromptRecordChain['versions']>([])
const hasRestoredInitialState = ref(false)
const isLoadingExternalData = ref(false)
const query: LocationQuery = {
importCode: 'NB-SAVE-UPSERT-001',
saveToFavorites: 'true',
}
const currentRoute = ref<RouteLocationNormalizedLoaded>(makeRoute('/pro/multi', query))
let replaceResolve: (() => void) | undefined
const replaceDone = new Promise<void>((resolve) => {
replaceResolve = resolve
})
const push: Router['push'] = vi.fn(async (to) => {
applyNavigation(currentRoute, to)
return undefined
})
const replace: Router['replace'] = vi.fn(async (to) => {
applyNavigation(currentRoute, to)
replaceResolve?.()
return undefined
})
const router: Pick<Router, 'currentRoute' | 'push' | 'replace'> = {
currentRoute,
push,
replace,
}
const existingFavorite: FavoritePrompt = {
id: 'fav-existing',
title: 'old',
content: 'old',
createdAt: 1,
updatedAt: 1,
tags: [],
useCount: 0,
functionMode: 'basic',
optimizationMode: 'system',
metadata: {
gardenSnapshot: {
importCode: 'NB-SAVE-UPSERT-001',
gardenBaseUrl: 'http://garden.local',
},
},
}
const favoriteManager = {
getFavorites: vi.fn(async (): Promise<FavoritePrompt[]> => [
existingFavorite,
]),
addFavorite: vi.fn(async (
_favorite: Omit<FavoritePrompt, 'id' | 'createdAt' | 'updatedAt' | 'useCount'>
) => 'fav-new'),
updateFavorite: vi.fn(async (_id: string, _updates: Partial<FavoritePrompt>) => {}),
}
const fetchMock = vi.fn<
(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
>(async () => {
return new Response(
JSON.stringify({
schema: 'prompt-garden.prompt.v1',
schemaVersion: 1,
optimizerTarget: { subModeKey: 'pro-multi' },
prompt: {
format: 'messages',
messages: [
{
id: 'm1',
role: 'system',
content: 'System content',
},
{
id: 'm2',
role: 'user',
content: 'User content',
},
],
},
variables: [],
meta: {
title: 'new title',
},
}),
{
status: 200,
headers: { 'content-type': 'application/json' },
}
)
})
vi.stubGlobal('fetch', fetchMock)
const scope = effectScope()
try {
scope.run(() => {
useAppPromptGardenImport({
router,
hasRestoredInitialState,
isLoadingExternalData,
gardenBaseUrl: 'http://garden.local',
basicSystemSession,
basicUserSession,
proMultiMessageSession,
proVariableSession,
imageText2ImageSession,
imageImage2ImageSession,
getFavoriteManager: () => favoriteManager,
optimizerCurrentVersions,
})
})
hasRestoredInitialState.value = true
await replaceDone
await waitForCondition(() => isLoadingExternalData.value === false)
expect(favoriteManager.getFavorites).toHaveBeenCalledTimes(1)
expect(favoriteManager.addFavorite).not.toHaveBeenCalled()
expect(favoriteManager.updateFavorite).toHaveBeenCalledTimes(1)
expect(favoriteManager.updateFavorite).toHaveBeenCalledWith(
'fav-existing',
expect.objectContaining({
content: '[system] System content\n\n[user] User content',
functionMode: 'context',
optimizationMode: 'system',
metadata: expect.objectContaining({
gardenSnapshot: expect.objectContaining({
importCode: 'NB-SAVE-UPSERT-001',
gardenBaseUrl: 'http://garden.local',
}),
}),
})
)
} finally {
scope.stop()
}
})
})
@@ -0,0 +1,138 @@
import { describe, expect, it } from 'vitest'
import type { FavoritePrompt } from '@prompt-optimizer/core'
import {
parseFavoriteGardenSnapshotPreview,
parseGardenSnapshotPreview,
} from '../../../src/utils/garden-snapshot-preview'
describe('garden-snapshot-preview utils', () => {
it('normalizes a garden snapshot payload for preview rendering', () => {
const parsed = parseGardenSnapshotPreview({
schema: 'prompt-garden.prompt.v1',
schemaVersion: 1,
importCode: ' ABC-123 ',
gardenBaseUrl: ' https://garden.example.com ',
meta: {
title: ' Demo Title ',
description: ' Demo Description ',
tags: ['tag-a', 'tag-a', 'tag-b', ''],
},
variables: [
{
name: 'style',
type: 'enum',
required: true,
options: ['cinematic', 'cinematic', 'anime'],
defaultValue: 'cinematic',
description: 'Output style',
},
{
name: ' ',
type: 'string',
},
],
assets: {
cover: {
url: 'https://cdn.example.com/cover.png',
},
showcases: [
{
id: 'showcase-1',
url: 'https://cdn.example.com/showcase-main.png',
images: [
'https://cdn.example.com/showcase-main.png',
'https://cdn.example.com/showcase-2.png',
],
},
],
examples: [
{
id: 'example-1',
text: 'Prompt text',
parameters: {
width: 1024,
quality: 'high',
},
inputImages: ['https://cdn.example.com/input-a.png', 'https://cdn.example.com/input-a.png'],
},
],
},
})
expect(parsed).not.toBeNull()
expect(parsed?.importCode).toBe('ABC-123')
expect(parsed?.gardenBaseUrl).toBe('https://garden.example.com')
expect(parsed?.meta.title).toBe('Demo Title')
expect(parsed?.meta.description).toBe('Demo Description')
expect(parsed?.meta.tags).toEqual(['tag-a', 'tag-b'])
expect(parsed?.variables).toHaveLength(1)
expect(parsed?.variables[0]).toMatchObject({
name: 'style',
type: 'enum',
required: true,
options: ['cinematic', 'anime'],
defaultValue: 'cinematic',
})
expect(parsed?.coverUrl).toBe('https://cdn.example.com/cover.png')
expect(parsed?.showcases).toHaveLength(1)
expect(parsed?.showcases[0]?.images).toEqual([
'https://cdn.example.com/showcase-main.png',
'https://cdn.example.com/showcase-2.png',
])
expect(parsed?.examples).toHaveLength(1)
expect(parsed?.examples[0]?.parameters).toEqual({
width: '1024',
quality: 'high',
})
expect(parsed?.examples[0]?.inputImages).toEqual(['https://cdn.example.com/input-a.png'])
})
it('returns null for invalid or empty snapshot payloads', () => {
expect(parseGardenSnapshotPreview(null)).toBeNull()
expect(parseGardenSnapshotPreview(undefined)).toBeNull()
expect(parseGardenSnapshotPreview('invalid')).toBeNull()
expect(parseGardenSnapshotPreview({ assets: { examples: [] } })).toBeNull()
})
it('extracts garden snapshot only when favorite metadata contains valid payload', () => {
const baseFavorite: FavoritePrompt = {
id: 'fav-1',
title: 'Favorite',
content: 'Prompt content',
createdAt: Date.now(),
updatedAt: Date.now(),
tags: [],
useCount: 0,
functionMode: 'basic',
optimizationMode: 'system',
}
expect(parseFavoriteGardenSnapshotPreview(baseFavorite)).toBeNull()
const withSnapshot: FavoritePrompt = {
...baseFavorite,
metadata: {
gardenSnapshot: {
importCode: 'X1',
assets: {
examples: [
{
id: 'example',
inputImages: ['https://cdn.example.com/input.png'],
},
],
},
},
},
}
const parsed = parseFavoriteGardenSnapshotPreview(withSnapshot)
expect(parsed).not.toBeNull()
expect(parsed?.importCode).toBe('X1')
expect(parsed?.examples).toHaveLength(1)
})
})