From 4664cc2a211cb1deeaecfe6289213b8765d9f101 Mon Sep 17 00:00:00 2001 From: linshen <32978552+linshenkx@users.noreply.github.com> Date: Tue, 10 Feb 2026 23:22:03 +0800 Subject: [PATCH] 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. --- .../ui/src/components/FavoriteManager.vue | 16 +- .../FavoritePreviewExtensionHost.vue | 50 ++ .../src/components/GardenSnapshotPreview.vue | 470 ++++++++++++++++ .../components/OutputDisplayFullscreen.vue | 47 +- .../PromptGardenFavoritePreviewPanel.vue | 127 +++++ .../app-layout/PromptOptimizerApp.vue | 1 + .../app/useAppPromptGardenImport.ts | 510 +++++++++++++++++- packages/ui/src/i18n/locales/en-US.ts | 35 ++ packages/ui/src/i18n/locales/zh-CN.ts | 35 ++ packages/ui/src/i18n/locales/zh-TW.ts | 35 ++ .../integrations/favoritePreviewPlugins.ts | 56 ++ .../prompt-garden.favorite-preview.ts | 15 + .../integrations/prompt-garden.integration.ts | 1 + packages/ui/src/integrations/types.ts | 5 +- .../ui/src/utils/garden-snapshot-preview.ts | 200 +++++++ .../components/GardenSnapshotPreview.spec.ts | 167 ++++++ .../useAppPromptGardenImport.spec.ts | 452 +++++++++++++++- .../utils/garden-snapshot-preview.spec.ts | 138 +++++ 18 files changed, 2311 insertions(+), 49 deletions(-) create mode 100644 packages/ui/src/components/FavoritePreviewExtensionHost.vue create mode 100644 packages/ui/src/components/GardenSnapshotPreview.vue create mode 100644 packages/ui/src/components/PromptGardenFavoritePreviewPanel.vue create mode 100644 packages/ui/src/integrations/favoritePreviewPlugins.ts create mode 100644 packages/ui/src/integrations/prompt-garden.favorite-preview.ts create mode 100644 packages/ui/src/utils/garden-snapshot-preview.ts create mode 100644 packages/ui/tests/unit/components/GardenSnapshotPreview.spec.ts create mode 100644 packages/ui/tests/unit/utils/garden-snapshot-preview.spec.ts diff --git a/packages/ui/src/components/FavoriteManager.vue b/packages/ui/src/components/FavoriteManager.vue index f56022ad..a1741a6a 100644 --- a/packages/ui/src/components/FavoriteManager.vue +++ b/packages/ui/src/components/FavoriteManager.vue @@ -144,7 +144,15 @@ mode="readonly" :enabled-actions="['copy', 'diff']" @copy="handlePreviewCopy" - /> + > + + { 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 列 diff --git a/packages/ui/src/components/FavoritePreviewExtensionHost.vue b/packages/ui/src/components/FavoritePreviewExtensionHost.vue new file mode 100644 index 00000000..0e90a22b --- /dev/null +++ b/packages/ui/src/components/FavoritePreviewExtensionHost.vue @@ -0,0 +1,50 @@ + + + + + diff --git a/packages/ui/src/components/GardenSnapshotPreview.vue b/packages/ui/src/components/GardenSnapshotPreview.vue new file mode 100644 index 00000000..624360d0 --- /dev/null +++ b/packages/ui/src/components/GardenSnapshotPreview.vue @@ -0,0 +1,470 @@ + + + diff --git a/packages/ui/src/components/OutputDisplayFullscreen.vue b/packages/ui/src/components/OutputDisplayFullscreen.vue index aa676bef..2e361843 100644 --- a/packages/ui/src/components/OutputDisplayFullscreen.vue +++ b/packages/ui/src/components/OutputDisplayFullscreen.vue @@ -1,22 +1,33 @@ @@ -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'; diff --git a/packages/ui/src/components/PromptGardenFavoritePreviewPanel.vue b/packages/ui/src/components/PromptGardenFavoritePreviewPanel.vue new file mode 100644 index 00000000..428e9e75 --- /dev/null +++ b/packages/ui/src/components/PromptGardenFavoritePreviewPanel.vue @@ -0,0 +1,127 @@ + + + diff --git a/packages/ui/src/components/app-layout/PromptOptimizerApp.vue b/packages/ui/src/components/app-layout/PromptOptimizerApp.vue index 8c7f278a..4c48db77 100644 --- a/packages/ui/src/components/app-layout/PromptOptimizerApp.vue +++ b/packages/ui/src/components/app-layout/PromptOptimizerApp.vue @@ -1655,6 +1655,7 @@ void registerOptionalIntegrations({ proVariableSession, imageText2ImageSession, imageImage2ImageSession, + getFavoriteManager: () => services.value?.favoriteManager || null, optimizerCurrentVersions, }); provide("handleSaveFavorite", handleSaveFavorite); diff --git a/packages/ui/src/composables/app/useAppPromptGardenImport.ts b/packages/ui/src/composables/app/useAppPromptGardenImport.ts index 7ffc89b7..1392f992 100644 --- a/packages/ui/src/composables/app/useAppPromptGardenImport.ts +++ b/packages/ui/src/composables/app/useAppPromptGardenImport.ts @@ -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 inputImages?: string[] }> + gardenSnapshot: GardenSnapshot +} + +type FavoriteManagerLike = Pick + +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 + [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 + 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 => { + 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 | undefined => { + if (!isPlainObject(value)) return undefined + const out: Record = {} + 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 => { 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) : 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; 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; 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 = {} - 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 => { + 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 => { + 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 => { + 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 { + 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 } @@ -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() diff --git a/packages/ui/src/i18n/locales/en-US.ts b/packages/ui/src/i18n/locales/en-US.ts index 764fa826..8d047b06 100644 --- a/packages/ui/src/i18n/locales/en-US.ts +++ b/packages/ui/src/i18n/locales/en-US.ts @@ -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: diff --git a/packages/ui/src/i18n/locales/zh-CN.ts b/packages/ui/src/i18n/locales/zh-CN.ts index cd6dcd3a..499bc17e 100644 --- a/packages/ui/src/i18n/locales/zh-CN.ts +++ b/packages/ui/src/i18n/locales/zh-CN.ts @@ -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: "收藏功能暂不可用,请稍后再试", diff --git a/packages/ui/src/i18n/locales/zh-TW.ts b/packages/ui/src/i18n/locales/zh-TW.ts index ecd403e6..6b641df4 100644 --- a/packages/ui/src/i18n/locales/zh-TW.ts +++ b/packages/ui/src/i18n/locales/zh-TW.ts @@ -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: "收藏功能暫不可用,請稍後再試", diff --git a/packages/ui/src/integrations/favoritePreviewPlugins.ts b/packages/ui/src/integrations/favoritePreviewPlugins.ts new file mode 100644 index 00000000..31209870 --- /dev/null +++ b/packages/ui/src/integrations/favoritePreviewPlugins.ts @@ -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( + './*.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 => { + 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 +} diff --git a/packages/ui/src/integrations/prompt-garden.favorite-preview.ts b/packages/ui/src/integrations/prompt-garden.favorite-preview.ts new file mode 100644 index 00000000..834e32f2 --- /dev/null +++ b/packages/ui/src/integrations/prompt-garden.favorite-preview.ts @@ -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, +} diff --git a/packages/ui/src/integrations/prompt-garden.integration.ts b/packages/ui/src/integrations/prompt-garden.integration.ts index 5c283f7d..a8cedfaf 100644 --- a/packages/ui/src/integrations/prompt-garden.integration.ts +++ b/packages/ui/src/integrations/prompt-garden.integration.ts @@ -23,6 +23,7 @@ export const integration: OptionalIntegration = { proVariableSession: ctx.proVariableSession, imageText2ImageSession: ctx.imageText2ImageSession, imageImage2ImageSession: ctx.imageImage2ImageSession, + getFavoriteManager: () => ctx.getFavoriteManager(), optimizerCurrentVersions: ctx.optimizerCurrentVersions, }) }, diff --git a/packages/ui/src/integrations/types.ts b/packages/ui/src/integrations/types.ts index 9d1bd99e..85b2b9d2 100644 --- a/packages/ui/src/integrations/types.ts +++ b/packages/ui/src/integrations/types.ts @@ -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 + + /** Lazy getter to avoid hard coupling optional integrations to app service lifecycle. */ + getFavoriteManager: () => IFavoriteManager | null } export interface OptionalIntegration { diff --git a/packages/ui/src/utils/garden-snapshot-preview.ts b/packages/ui/src/utils/garden-snapshot-preview.ts new file mode 100644 index 00000000..cf0dd47c --- /dev/null +++ b/packages/ui/src/utils/garden-snapshot-preview.ts @@ -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 +} + +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 => { + 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 => { + if (!isPlainObject(value)) return {} + + const out: Record = {} + 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() + + 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) +} diff --git a/packages/ui/tests/unit/components/GardenSnapshotPreview.spec.ts b/packages/ui/tests/unit/components/GardenSnapshotPreview.spec.ts new file mode 100644 index 00000000..cada7a00 --- /dev/null +++ b/packages/ui/tests/unit/components/GardenSnapshotPreview.spec.ts @@ -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) => { + 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 + handleShowcaseUploadChange: (arg: { file: UploadFileInfo | null; fileList: UploadFileInfo[] }) => Promise + }).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 + }).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,/) + }) +}) diff --git a/packages/ui/tests/unit/composables/useAppPromptGardenImport.spec.ts b/packages/ui/tests/unit/composables/useAppPromptGardenImport.spec.ts index 0cb11218..2ed3bfed 100644 --- a/packages/ui/tests/unit/composables/useAppPromptGardenImport.spec.ts +++ b/packages/ui/tests/unit/composables/useAppPromptGardenImport.spec.ts @@ -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([]) + const hasRestoredInitialState = ref(false) + const isLoadingExternalData = ref(false) + + const query: LocationQuery = { + importCode: 'NB-SAVE-001', + saveToFavorites: '1', + } + + const currentRoute = ref(makeRoute('/basic/system', query)) + + let replaceResolve: (() => void) | undefined + const replaceDone = new Promise((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 = { + currentRoute, + push, + replace, + } + + const favoriteManager = { + getFavorites: vi.fn(async (): Promise => []), + addFavorite: vi.fn(async ( + _favorite: Omit + ) => 'fav-new'), + updateFavorite: vi.fn(async (_id: string, _updates: Partial) => {}), + } + + 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 + >(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 + 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 + const snapshot = metadata.gardenSnapshot as Record + expect(snapshot.importCode).toBe('NB-SAVE-001') + expect(snapshot.gardenBaseUrl).toBe('http://garden.local') + + const snapshotAssets = snapshot.assets as Record + const cover = snapshotAssets.cover as Record + expect(String(cover.url)).toMatch(/^data:/) + + const showcases = snapshotAssets.showcases as Array> + const showcaseImages = showcases[0]?.images as unknown[] + expect(showcaseImages).toHaveLength(1) + expect(String(showcaseImages[0])).toMatch(/^data:/) + + const examples = snapshotAssets.examples as Array> + 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([]) + const hasRestoredInitialState = ref(false) + const isLoadingExternalData = ref(false) + + const query: LocationQuery = { + importCode: 'NB-NO-SAVE-001', + } + + const currentRoute = ref(makeRoute('/basic/system', query)) + + let replaceResolve: (() => void) | undefined + const replaceDone = new Promise((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 = { + currentRoute, + push, + replace, + } + + const favoriteManager = { + getFavorites: vi.fn(async (): Promise => []), + addFavorite: vi.fn(async ( + _favorite: Omit + ) => 'fav-new'), + updateFavorite: vi.fn(async (_id: string, _updates: Partial) => {}), + } + + const fetchMock = vi.fn< + (input: RequestInfo | URL, init?: RequestInit) => Promise + >(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([]) + const hasRestoredInitialState = ref(false) + const isLoadingExternalData = ref(false) + + const query: LocationQuery = { + importCode: 'NB-SAVE-UPSERT-001', + saveToFavorites: 'true', + } + + const currentRoute = ref(makeRoute('/pro/multi', query)) + + let replaceResolve: (() => void) | undefined + const replaceDone = new Promise((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 = { + 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 => [ + existingFavorite, + ]), + addFavorite: vi.fn(async ( + _favorite: Omit + ) => 'fav-new'), + updateFavorite: vi.fn(async (_id: string, _updates: Partial) => {}), + } + + const fetchMock = vi.fn< + (input: RequestInfo | URL, init?: RequestInit) => Promise + >(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() + } + }) }) diff --git a/packages/ui/tests/unit/utils/garden-snapshot-preview.spec.ts b/packages/ui/tests/unit/utils/garden-snapshot-preview.spec.ts new file mode 100644 index 00000000..86821734 --- /dev/null +++ b/packages/ui/tests/unit/utils/garden-snapshot-preview.spec.ts @@ -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) + }) +})