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:
1 parent
aef1f1425b
commit
4664cc2a21
18 files changed
+2311
-49
No files matched your search
@@ -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()
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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: "收藏功能暂不可用,请稍后再试",
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user