feat(garden): add inspiration and import polish
- Add prompt garden inspiration suggestions for image workspaces - Use public API paths and locale-aware suggestion loading - Preserve category paths when importing favorites from Prompt Garden
This commit is contained in:
1 parent
b34ca68898
commit
3f6c460154
15 files changed
+1506
-280
No files matched your search
@@ -22,7 +22,7 @@ Prompt Optimizer 在应用初始 session 恢复完成后检查当前路由 query
|
||||
- `importCode`(必填)
|
||||
- 外部提示词的唯一标识,例如 `NB-001`
|
||||
- 可在导入码后追加示例选择后缀,例如 `NB-001@ex-2`
|
||||
- 追加后缀时,Prompt Optimizer 仍请求 `GET /api/prompt-source/NB-001`,并把 `ex-2` 当作本次导入的示例选择
|
||||
- 追加后缀时,Prompt Optimizer 仍请求 `GET /api/public/prompt-source/NB-001`,并把 `ex-2` 当作本次导入的示例选择;旧路径 `/api/prompt-source/NB-001` 由 Garden 侧做 `307` 兼容跳转
|
||||
- `subModeKey`(可选)
|
||||
- 显式指定导入目标工作区
|
||||
- 若未提供,则优先使用 Garden 返回的 `optimizerTarget.subModeKey`
|
||||
@@ -75,7 +75,7 @@ Prompt Optimizer 在应用初始 session 恢复完成后检查当前路由 query
|
||||
|
||||
Prompt Optimizer 会调用:
|
||||
|
||||
`GET {gardenBaseUrl}/api/prompt-source/{encodeURIComponent(importCode)}`
|
||||
`GET {gardenBaseUrl}/api/public/prompt-source/{encodeURIComponent(importCode)}`
|
||||
|
||||
其中:
|
||||
|
||||
@@ -354,10 +354,24 @@ Prompt Optimizer 会调用:
|
||||
- 收藏描述预填充
|
||||
- `tags`
|
||||
- 收藏标签预填充
|
||||
- `categoryPath`
|
||||
- 收藏分类树导入的首选字段
|
||||
- Prompt Optimizer 会按路径逐级复用或创建分类节点,并将收藏挂到最后一个叶子节点
|
||||
- `categoryPathKey`
|
||||
- `categoryPath` 的兼容回退
|
||||
- 用法与 `categoryPath` 一致
|
||||
- `categoryKey`
|
||||
- 收藏分类预填充,优先级高于 `category`
|
||||
- 旧版单分类兼容字段
|
||||
- 当 `categoryPath/categoryPathKey` 都缺失时,退化为单节点分类
|
||||
- `category`
|
||||
- 收藏分类预填充,作为 `categoryKey` 的回退
|
||||
- 旧版单分类兼容字段,作为 `categoryKey` 的回退
|
||||
|
||||
Prompt Optimizer 当前的分类提取优先级为:
|
||||
|
||||
1. `meta.categoryPath`
|
||||
2. `meta.categoryPathKey`
|
||||
3. 旧字段组合 `[meta.category, meta.subcategory]`
|
||||
4. `meta.categoryKey`
|
||||
|
||||
如果 `meta.title` 缺失,Prompt Optimizer 会退回到 prompt 内容首行生成收藏标题。
|
||||
|
||||
@@ -392,7 +406,7 @@ Prompt Optimizer 会调用:
|
||||
- 用户会看到一个 warning toast
|
||||
- 常见原因是 Garden 静态素材的 CORS 配置不正确
|
||||
|
||||
因此,若要支持 image2image 的“可复现实例导入”,Garden 不仅要开放 `/api/prompt-source/*`,也要开放示例图片地址。
|
||||
因此,若要支持 image2image 的“可复现实例导入”,Garden 不仅要开放 `/api/public/prompt-source/*`,也要开放示例图片地址。旧路径 `/api/prompt-source/*` 只需要保留 `307` 兼容跳转即可。
|
||||
|
||||
## 8. 收藏联动(`saveToFavorites`)
|
||||
|
||||
@@ -403,6 +417,7 @@ Prompt Optimizer 会调用:
|
||||
- Prompt Optimizer 会尝试自动保存到收藏
|
||||
- 收藏内容来自 Garden 返回的 prompt
|
||||
- `meta` 和 `assets` 会作为 `gardenSnapshot` 一起写入收藏 metadata
|
||||
- 如果 `meta.categoryPath/categoryPathKey` 存在,会自动创建或复用分类树,并保存到叶子分类
|
||||
- 不会写入或覆盖当前工作区
|
||||
|
||||
### 8.2 确认保存
|
||||
@@ -411,6 +426,7 @@ Prompt Optimizer 会调用:
|
||||
|
||||
- Prompt Optimizer 会打开“保存收藏”对话框
|
||||
- 自动带入标题、描述、标签、分类、模式信息和 `gardenSnapshot`
|
||||
- 若分类树节点尚不存在,不会在弹窗打开时提前创建;只有用户真正点击保存时才会补建缺失节点
|
||||
- 不会写入或覆盖当前工作区
|
||||
|
||||
### 8.3 去重与更新规则
|
||||
@@ -495,12 +511,12 @@ Prompt Optimizer 仅支持 Mustache 风格变量占位符:
|
||||
|
||||
至少需要覆盖两类资源:
|
||||
|
||||
- `/api/prompt-source/*`
|
||||
- `/api/public/prompt-source/*`
|
||||
- `assets.cover.url`、`assets.showcases[*].url/images[*]`、`assets.examples[*].images[*]`、`assets.examples[*].inputImages[*]` 指向的静态资源地址
|
||||
|
||||
建议:
|
||||
|
||||
- `/api/prompt-source/*` 返回:
|
||||
- `/api/public/prompt-source/*` 返回:
|
||||
- `Access-Control-Allow-Origin: https://prompt.example.com`,或你的实际部署域名
|
||||
- 静态素材地址也返回相同的 `Access-Control-Allow-Origin`
|
||||
- 开发环境可临时使用 `*` 联调
|
||||
|
||||
@@ -401,6 +401,11 @@ import type { AppServices } from '../types/services'
|
||||
import { getI18nErrorMessage } from '../utils/error'
|
||||
import { buildFavoriteMediaMetadata, parseFavoriteMediaMetadata } from '../utils/favorite-media'
|
||||
import { normalizeFavoriteFunctionMode } from '../utils/favorite-mode'
|
||||
import {
|
||||
deriveFavoriteCategoryPathFromGardenMeta,
|
||||
ensureFavoriteCategoryPath,
|
||||
loadFavoriteCategoryPathLeafId,
|
||||
} from '../utils/favorite-category-path'
|
||||
import {
|
||||
getEmbeddedFavoritePromptAsset,
|
||||
promptContentToEditableText,
|
||||
@@ -475,6 +480,9 @@ type FavoriteReproducibilityExamplePreviews = {
|
||||
inputImages: Array<{ assetId: string; source: string }>
|
||||
}
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
Boolean(value) && typeof value === 'object' && !Array.isArray(value)
|
||||
|
||||
const services = inject<Ref<AppServices | null>>('services')
|
||||
const message = useToast()
|
||||
|
||||
@@ -780,14 +788,35 @@ const hydrateMediaDraft = async (
|
||||
mediaDraft.coverIndex = coverSource ? Math.max(0, sources.indexOf(coverSource)) : 0
|
||||
}
|
||||
|
||||
const resolvePrefillCategoryId = async (candidate?: string): Promise<string> => {
|
||||
const normalized = String(candidate || '').trim()
|
||||
if (!normalized) return ''
|
||||
const extractGardenCategoryPathFromMetadata = (metadata: unknown): string[] => {
|
||||
if (!isRecord(metadata)) return []
|
||||
const gardenSnapshot = isRecord(metadata.gardenSnapshot) ? metadata.gardenSnapshot : null
|
||||
if (!gardenSnapshot) return []
|
||||
return deriveFavoriteCategoryPathFromGardenMeta(gardenSnapshot.meta)
|
||||
}
|
||||
|
||||
const resolvePrefillCategoryId = async (
|
||||
candidate?: string,
|
||||
metadata?: Record<string, unknown>,
|
||||
): Promise<string> => {
|
||||
const servicesValue = services?.value
|
||||
if (!servicesValue?.favoriteManager) return ''
|
||||
|
||||
try {
|
||||
const categoryPath = extractGardenCategoryPathFromMetadata(metadata)
|
||||
if (categoryPath.length > 0) {
|
||||
const resolvedFromPath = await loadFavoriteCategoryPathLeafId(
|
||||
servicesValue.favoriteManager,
|
||||
categoryPath,
|
||||
)
|
||||
if (resolvedFromPath) {
|
||||
return resolvedFromPath
|
||||
}
|
||||
}
|
||||
|
||||
const normalized = String(candidate || '').trim()
|
||||
if (!normalized) return ''
|
||||
|
||||
const categories = await servicesValue.favoriteManager.getCategories()
|
||||
if (categories.some((category) => category.id === normalized)) {
|
||||
return normalized
|
||||
@@ -804,6 +833,23 @@ const resolvePrefillCategoryId = async (candidate?: string): Promise<string> =>
|
||||
}
|
||||
}
|
||||
|
||||
const ensureSaveModeCategoryId = async (
|
||||
metadata?: Record<string, unknown>,
|
||||
): Promise<string> => {
|
||||
const servicesValue = services?.value
|
||||
if (!servicesValue?.favoriteManager) return ''
|
||||
|
||||
const categoryPath = extractGardenCategoryPathFromMetadata(metadata)
|
||||
if (categoryPath.length === 0) return ''
|
||||
|
||||
try {
|
||||
return await ensureFavoriteCategoryPath(servicesValue.favoriteManager, categoryPath) || ''
|
||||
} catch (error) {
|
||||
console.warn('[FavoriteEditorForm] Failed to ensure category path from Garden metadata:', error)
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
const buildMediaMetadataForSave = async () => {
|
||||
const normalizedSources = dedupeStrings(
|
||||
mediaDraft.sources.map((item) => String(item || '').trim()).filter(Boolean),
|
||||
@@ -1153,11 +1199,21 @@ const handleSave = async () => {
|
||||
|
||||
const sanitizedTags = Array.from(toRaw(formData.tags || [])).map((tag) => String(tag))
|
||||
|
||||
const prefillMetadata =
|
||||
props.mode === 'save' && props.prefill?.metadata && typeof props.prefill.metadata === 'object'
|
||||
? (props.prefill.metadata as Record<string, unknown>)
|
||||
: undefined
|
||||
|
||||
const resolvedCategoryId = formData.category || await ensureSaveModeCategoryId(prefillMetadata)
|
||||
if (resolvedCategoryId && resolvedCategoryId !== formData.category) {
|
||||
formData.category = resolvedCategoryId
|
||||
}
|
||||
|
||||
const basePayload = {
|
||||
title: formData.title.trim(),
|
||||
description: formData.description.trim(),
|
||||
content: formData.content.trim(),
|
||||
category: formData.category,
|
||||
category: resolvedCategoryId,
|
||||
tags: sanitizedTags,
|
||||
functionMode: formData.functionMode,
|
||||
optimizationMode: formData.optimizationMode,
|
||||
@@ -1314,8 +1370,13 @@ watch(() => [
|
||||
}
|
||||
|
||||
const prefill = props.prefill
|
||||
const prefillMetadata =
|
||||
prefill?.metadata && typeof prefill.metadata === 'object'
|
||||
? (prefill.metadata as Record<string, unknown>)
|
||||
: undefined
|
||||
const resolvedCategory = await resolvePrefillCategoryId(
|
||||
typeof prefill?.category === 'string' ? prefill.category : '',
|
||||
prefillMetadata,
|
||||
)
|
||||
if (isStale()) return
|
||||
|
||||
@@ -1361,10 +1422,6 @@ watch(() => [
|
||||
formData.imageSubMode = undefined
|
||||
}
|
||||
|
||||
const prefillMetadata =
|
||||
prefill?.metadata && typeof prefill.metadata === 'object'
|
||||
? (prefill.metadata as Record<string, unknown>)
|
||||
: undefined
|
||||
// Save prefill is normalized by useAppFavorite so create/save and edit hydrate the same metadata source.
|
||||
if (prefill?.reproducibilityDraft && prefill.reproducibilityDraft.examples.length > 0) {
|
||||
reviewAddedExampleIds.value = prefill.reproducibilityDraft.examples
|
||||
|
||||
@@ -0,0 +1,574 @@
|
||||
<template>
|
||||
<NPopover
|
||||
v-if="isPromptGardenEnabled"
|
||||
ref="popoverInstRef"
|
||||
v-model:show="popoverVisible"
|
||||
trigger="manual"
|
||||
placement="bottom-end"
|
||||
flip
|
||||
:show-arrow="false"
|
||||
:style="{ padding: '0' }"
|
||||
:content-style="{ padding: '0' }"
|
||||
@clickoutside="handleClickOutside"
|
||||
>
|
||||
<template #trigger>
|
||||
<NButton
|
||||
type="tertiary"
|
||||
size="small"
|
||||
ghost
|
||||
round
|
||||
class="prompt-garden-inspiration-trigger"
|
||||
:disabled="disabled"
|
||||
:data-testid="`${rootTestId}-trigger`"
|
||||
@click="handleTriggerClick"
|
||||
@mouseenter="handleTriggerMouseEnter"
|
||||
@mouseleave="handleTriggerMouseLeave"
|
||||
@focus="handleTriggerFocus"
|
||||
@blur="handleTriggerBlur"
|
||||
@keydown.esc.stop.prevent="closePopover"
|
||||
>
|
||||
<template #icon>
|
||||
<NIcon>
|
||||
<Plant2 />
|
||||
</NIcon>
|
||||
</template>
|
||||
{{ t('common.promptGarden.inspirationTrigger') }}
|
||||
</NButton>
|
||||
</template>
|
||||
|
||||
<div
|
||||
ref="popoverBodyRef"
|
||||
class="prompt-garden-inspiration-popover"
|
||||
:data-testid="`${rootTestId}-popover`"
|
||||
@mouseenter="handlePopoverMouseEnter"
|
||||
@mouseleave="handlePopoverMouseLeave"
|
||||
@focusin.capture="handlePopoverFocusIn"
|
||||
@focusout.capture="handlePopoverFocusOut"
|
||||
@keydown.esc.stop.prevent="closePopover"
|
||||
>
|
||||
<div class="prompt-garden-inspiration-popover__header">
|
||||
<div class="prompt-garden-inspiration-popover__title">
|
||||
<NIcon>
|
||||
<Plant2 />
|
||||
</NIcon>
|
||||
<NText strong>
|
||||
{{ hasPrompt ? t('common.promptGarden.inspirationTitle') : t('common.promptGarden.inspirationStartTitle') }}
|
||||
</NText>
|
||||
</div>
|
||||
<NButton
|
||||
size="tiny"
|
||||
quaternary
|
||||
circle
|
||||
:disabled="isLoading"
|
||||
:title="t('common.promptGarden.refreshSuggestions')"
|
||||
:data-testid="`${rootTestId}-refresh`"
|
||||
@click="handleRefresh"
|
||||
>
|
||||
<template #icon>
|
||||
<NIcon>
|
||||
<Refresh />
|
||||
</NIcon>
|
||||
</template>
|
||||
</NButton>
|
||||
</div>
|
||||
|
||||
<div v-if="isLoading" class="prompt-garden-inspiration-popover__loading">
|
||||
<NSpin size="small" />
|
||||
<NText depth="3">{{ t('common.promptGarden.loadingSuggestions') }}</NText>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="suggestions.length > 0"
|
||||
class="prompt-garden-inspiration-list"
|
||||
>
|
||||
<div
|
||||
v-for="(item, index) in suggestions"
|
||||
:key="`${item.importCode}-${item.id}`"
|
||||
class="prompt-garden-inspiration-item"
|
||||
:data-testid="`${rootTestId}-suggestion-${index}`"
|
||||
>
|
||||
<img
|
||||
v-if="item.thumbnailUrl"
|
||||
class="prompt-garden-inspiration-item__thumb"
|
||||
:src="item.thumbnailUrl"
|
||||
:alt="item.title"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div class="prompt-garden-inspiration-item__body">
|
||||
<div class="prompt-garden-inspiration-item__title-row">
|
||||
<NText strong class="prompt-garden-inspiration-item__title">
|
||||
{{ item.title }}
|
||||
</NText>
|
||||
<NButton
|
||||
size="tiny"
|
||||
secondary
|
||||
:disabled="disabled"
|
||||
:data-testid="`${rootTestId}-suggestion-${index}-apply`"
|
||||
@click="handleApply(item)"
|
||||
>
|
||||
{{ hasPrompt ? t('common.promptGarden.replaceImportShort') : t('common.promptGarden.useShort') }}
|
||||
</NButton>
|
||||
</div>
|
||||
<NText depth="3" class="prompt-garden-inspiration-item__summary">
|
||||
{{ item.summary }}
|
||||
</NText>
|
||||
<div v-if="item.tags.length > 0" class="prompt-garden-inspiration-item__tags">
|
||||
<NTag
|
||||
v-for="tag in item.tags"
|
||||
:key="tag"
|
||||
size="small"
|
||||
:bordered="false"
|
||||
>
|
||||
{{ tag }}
|
||||
</NTag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="prompt-garden-inspiration-popover__empty">
|
||||
<NText depth="3">{{ t('common.promptGarden.noSuggestions') }}</NText>
|
||||
</div>
|
||||
|
||||
<div class="prompt-garden-inspiration-popover__footer">
|
||||
<NButton
|
||||
size="tiny"
|
||||
text
|
||||
:data-testid="`${rootTestId}-browse`"
|
||||
@click="handleBrowseMore"
|
||||
>
|
||||
<template #icon>
|
||||
<NIcon>
|
||||
<ExternalLink />
|
||||
</NIcon>
|
||||
</template>
|
||||
{{ t('common.promptGarden.browseMore') }}
|
||||
</NButton>
|
||||
<NButton
|
||||
size="tiny"
|
||||
text
|
||||
:data-testid="`${rootTestId}-import`"
|
||||
@click="handleOpenImport"
|
||||
>
|
||||
<template #icon>
|
||||
<NIcon>
|
||||
<FileImport />
|
||||
</NIcon>
|
||||
</template>
|
||||
{{ t('common.promptGarden.importShort') }}
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
</NPopover>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch, type Ref } from 'vue'
|
||||
import { NButton, NIcon, NPopover, NSpin, NTag, NText } from 'naive-ui'
|
||||
import { ExternalLink, FileImport, Plant2, Refresh } from '@vicons/tabler'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { getEnvVar } from '@prompt-optimizer/core'
|
||||
import { openExternalUrl } from '../../utils/open-external-url'
|
||||
import type { PromptGardenImportRequest } from '../../utils/prompt-garden-import'
|
||||
import {
|
||||
fetchPromptGardenSuggestions,
|
||||
type PromptGardenSuggestionItem,
|
||||
} from '../../utils/prompt-garden-suggestions'
|
||||
|
||||
type PopoverInst = {
|
||||
syncPosition?: () => void
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
mode: string
|
||||
hasPrompt?: boolean
|
||||
disabled?: boolean
|
||||
testId?: string
|
||||
}>(),
|
||||
{
|
||||
hasPrompt: false,
|
||||
disabled: false,
|
||||
testId: 'prompt-garden-inspiration',
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
apply: [request: PromptGardenImportRequest]
|
||||
'open-import': []
|
||||
}>()
|
||||
|
||||
const { t, locale } = useI18n() as unknown as {
|
||||
t: (key: string) => string
|
||||
locale: Ref<string>
|
||||
}
|
||||
|
||||
const popoverVisible = ref(false)
|
||||
const isHoveringTrigger = ref(false)
|
||||
const isHoveringPopover = ref(false)
|
||||
const isPinnedByClick = ref(false)
|
||||
const hasFocusWithinPopover = ref(false)
|
||||
const isLoading = ref(false)
|
||||
const suggestions = ref<PromptGardenSuggestionItem[]>([])
|
||||
const browseUrl = ref('')
|
||||
const shownExclude = ref<string[]>([])
|
||||
const cacheExpiresAt = ref(0)
|
||||
const popoverInstRef = ref<PopoverInst | null>(null)
|
||||
const popoverBodyRef = ref<HTMLElement | null>(null)
|
||||
const rootTestId = computed(() => props.testId)
|
||||
|
||||
let openTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let closeTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let loadSequence = 0
|
||||
|
||||
const HOVER_OPEN_DELAY = 180
|
||||
const POPOVER_CLOSE_DELAY = 220
|
||||
|
||||
const clearOpenTimer = () => {
|
||||
if (!openTimer) return
|
||||
clearTimeout(openTimer)
|
||||
openTimer = null
|
||||
}
|
||||
|
||||
const clearCloseTimer = () => {
|
||||
if (!closeTimer) return
|
||||
clearTimeout(closeTimer)
|
||||
closeTimer = null
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearOpenTimer()
|
||||
clearCloseTimer()
|
||||
})
|
||||
|
||||
const isPromptGardenEnabled = computed(() => {
|
||||
const value = getEnvVar('VITE_ENABLE_PROMPT_GARDEN_IMPORT').trim().toLowerCase()
|
||||
return value === '1' || value === 'true'
|
||||
})
|
||||
|
||||
const promptGardenBaseUrl = computed(() =>
|
||||
getEnvVar('VITE_PROMPT_GARDEN_BASE_URL').trim().replace(/\/$/, ''),
|
||||
)
|
||||
|
||||
const fallbackBrowseUrl = computed(() => {
|
||||
if (!promptGardenBaseUrl.value) return ''
|
||||
const url = new URL(`${promptGardenBaseUrl.value}/prompts`)
|
||||
url.searchParams.set('mode', props.mode)
|
||||
return url.toString()
|
||||
})
|
||||
|
||||
const promptGardenLocale = computed(() => String(locale.value || '').trim())
|
||||
|
||||
const syncPopoverPosition = () => {
|
||||
nextTick(() => {
|
||||
popoverInstRef.value?.syncPosition?.()
|
||||
if (typeof requestAnimationFrame !== 'undefined') {
|
||||
requestAnimationFrame(() => popoverInstRef.value?.syncPosition?.())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
watch(popoverVisible, (visible) => {
|
||||
if (visible) {
|
||||
syncPopoverPosition()
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.mode,
|
||||
() => {
|
||||
suggestions.value = []
|
||||
browseUrl.value = ''
|
||||
shownExclude.value = []
|
||||
cacheExpiresAt.value = 0
|
||||
},
|
||||
)
|
||||
|
||||
const dedupe = (items: string[]): string[] =>
|
||||
Array.from(new Set(items.map((item) => item.trim()).filter(Boolean)))
|
||||
|
||||
const rememberShown = (items: PromptGardenSuggestionItem[], nextExclude: string[]) => {
|
||||
shownExclude.value = dedupe([
|
||||
...shownExclude.value,
|
||||
...nextExclude,
|
||||
...items.map((item) => item.importCode),
|
||||
])
|
||||
}
|
||||
|
||||
const loadSuggestions = async (options: { force?: boolean } = {}) => {
|
||||
if (props.disabled || !isPromptGardenEnabled.value || !promptGardenBaseUrl.value) return
|
||||
|
||||
if (!options.force && suggestions.value.length > 0 && cacheExpiresAt.value > Date.now()) {
|
||||
return
|
||||
}
|
||||
|
||||
const sequence = ++loadSequence
|
||||
isLoading.value = true
|
||||
|
||||
try {
|
||||
const result = await fetchPromptGardenSuggestions({
|
||||
gardenBaseUrl: promptGardenBaseUrl.value,
|
||||
mode: props.mode,
|
||||
limit: 3,
|
||||
strategy: 'mixed',
|
||||
exclude: options.force ? shownExclude.value : [],
|
||||
locale: promptGardenLocale.value,
|
||||
})
|
||||
|
||||
if (sequence !== loadSequence) return
|
||||
|
||||
suggestions.value = result.items
|
||||
browseUrl.value = result.browseUrl
|
||||
rememberShown(result.items, result.nextExclude)
|
||||
|
||||
const ttl = result.ttlSeconds ?? 300
|
||||
cacheExpiresAt.value = Date.now() + ttl * 1000
|
||||
} catch (error) {
|
||||
if (sequence !== loadSequence) return
|
||||
console.info('[PromptGardenInspiration] Failed to load suggestions:', error)
|
||||
suggestions.value = []
|
||||
browseUrl.value = fallbackBrowseUrl.value
|
||||
cacheExpiresAt.value = Date.now() + 60 * 1000
|
||||
} finally {
|
||||
if (sequence === loadSequence) {
|
||||
isLoading.value = false
|
||||
syncPopoverPosition()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const openPopover = (options: { pinned?: boolean; immediate?: boolean } = {}) => {
|
||||
if (props.disabled) return
|
||||
|
||||
clearCloseTimer()
|
||||
clearOpenTimer()
|
||||
|
||||
const open = () => {
|
||||
popoverVisible.value = true
|
||||
if (options.pinned) {
|
||||
isPinnedByClick.value = true
|
||||
}
|
||||
void loadSuggestions()
|
||||
}
|
||||
|
||||
if (options.immediate) {
|
||||
open()
|
||||
return
|
||||
}
|
||||
|
||||
openTimer = setTimeout(open, HOVER_OPEN_DELAY)
|
||||
}
|
||||
|
||||
const closePopover = () => {
|
||||
clearOpenTimer()
|
||||
clearCloseTimer()
|
||||
popoverVisible.value = false
|
||||
isPinnedByClick.value = false
|
||||
hasFocusWithinPopover.value = false
|
||||
}
|
||||
|
||||
const scheduleClose = () => {
|
||||
if (isPinnedByClick.value || hasFocusWithinPopover.value) {
|
||||
clearCloseTimer()
|
||||
return
|
||||
}
|
||||
|
||||
clearCloseTimer()
|
||||
closeTimer = setTimeout(() => {
|
||||
if (!isHoveringTrigger.value && !isHoveringPopover.value) {
|
||||
closePopover()
|
||||
}
|
||||
}, POPOVER_CLOSE_DELAY)
|
||||
}
|
||||
|
||||
const handleTriggerMouseEnter = () => {
|
||||
isHoveringTrigger.value = true
|
||||
openPopover()
|
||||
}
|
||||
|
||||
const handleTriggerMouseLeave = () => {
|
||||
isHoveringTrigger.value = false
|
||||
scheduleClose()
|
||||
}
|
||||
|
||||
const handleTriggerClick = () => {
|
||||
if (props.disabled) return
|
||||
|
||||
if (popoverVisible.value && isPinnedByClick.value) {
|
||||
closePopover()
|
||||
return
|
||||
}
|
||||
|
||||
openPopover({ pinned: true, immediate: true })
|
||||
}
|
||||
|
||||
const handleTriggerFocus = () => {
|
||||
openPopover({ immediate: true })
|
||||
}
|
||||
|
||||
const handleTriggerBlur = () => {
|
||||
scheduleClose()
|
||||
}
|
||||
|
||||
const handlePopoverMouseEnter = () => {
|
||||
isHoveringPopover.value = true
|
||||
clearCloseTimer()
|
||||
}
|
||||
|
||||
const handlePopoverMouseLeave = () => {
|
||||
isHoveringPopover.value = false
|
||||
scheduleClose()
|
||||
}
|
||||
|
||||
const handlePopoverFocusIn = () => {
|
||||
hasFocusWithinPopover.value = true
|
||||
clearCloseTimer()
|
||||
}
|
||||
|
||||
const handlePopoverFocusOut = () => {
|
||||
if (typeof document === 'undefined') return
|
||||
|
||||
const body = popoverBodyRef.value
|
||||
const updateFocusState = () => {
|
||||
const active = document.activeElement
|
||||
if (body && active && body.contains(active)) return
|
||||
|
||||
hasFocusWithinPopover.value = false
|
||||
scheduleClose()
|
||||
}
|
||||
|
||||
if (typeof requestAnimationFrame !== 'undefined') {
|
||||
requestAnimationFrame(updateFocusState)
|
||||
return
|
||||
}
|
||||
|
||||
updateFocusState()
|
||||
}
|
||||
|
||||
const handleClickOutside = () => {
|
||||
if (isPinnedByClick.value || hasFocusWithinPopover.value) {
|
||||
closePopover()
|
||||
}
|
||||
}
|
||||
|
||||
const handleRefresh = () => {
|
||||
void loadSuggestions({ force: true })
|
||||
}
|
||||
|
||||
const handleApply = (item: PromptGardenSuggestionItem) => {
|
||||
emit('apply', {
|
||||
importCode: item.importCode,
|
||||
exampleId: null,
|
||||
subModeKey: item.mode || props.mode,
|
||||
})
|
||||
closePopover()
|
||||
}
|
||||
|
||||
const handleBrowseMore = () => {
|
||||
void openExternalUrl(browseUrl.value || fallbackBrowseUrl.value || promptGardenBaseUrl.value, {
|
||||
logPrefix: 'PromptGarden',
|
||||
})
|
||||
closePopover()
|
||||
}
|
||||
|
||||
const handleOpenImport = () => {
|
||||
emit('open-import')
|
||||
closePopover()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.prompt-garden-inspiration-trigger {
|
||||
border-radius: 999px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.prompt-garden-inspiration-popover {
|
||||
width: min(360px, calc(100vw - 32px));
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.prompt-garden-inspiration-popover__header,
|
||||
.prompt-garden-inspiration-popover__footer,
|
||||
.prompt-garden-inspiration-item__title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.prompt-garden-inspiration-popover__title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
gap: 6px;
|
||||
color: color-mix(in srgb, var(--n-success-color) 78%, var(--n-text-color-2));
|
||||
}
|
||||
|
||||
.prompt-garden-inspiration-popover__loading,
|
||||
.prompt-garden-inspiration-popover__empty {
|
||||
min-height: 70px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.prompt-garden-inspiration-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.prompt-garden-inspiration-item {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--n-border-color);
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--n-color) 92%, var(--n-success-color) 8%);
|
||||
}
|
||||
|
||||
.prompt-garden-inspiration-item__thumb {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 6px;
|
||||
object-fit: cover;
|
||||
background: var(--n-border-color);
|
||||
}
|
||||
|
||||
.prompt-garden-inspiration-item__body {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.prompt-garden-inspiration-item__title {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.prompt-garden-inspiration-item__summary {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
line-height: 1.4;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.prompt-garden-inspiration-item__tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.prompt-garden-inspiration-popover__footer {
|
||||
padding-top: 2px;
|
||||
}
|
||||
</style>
|
||||
@@ -40,22 +40,32 @@
|
||||
{{ promptSummary }}
|
||||
</NText>
|
||||
</NFlex>
|
||||
<NButton
|
||||
type="tertiary"
|
||||
size="small"
|
||||
ghost
|
||||
round
|
||||
@click="isInputPanelCollapsed = false"
|
||||
:title="t('common.expand')"
|
||||
>
|
||||
<template #icon>
|
||||
<NIcon>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</NIcon>
|
||||
</template>
|
||||
</NButton>
|
||||
<NFlex align="center" :size="8">
|
||||
<PromptGardenInspirationPopover
|
||||
mode="image-image2image"
|
||||
:has-prompt="!!originalPrompt.trim()"
|
||||
:disabled="isPromptGardenGuideDisabled"
|
||||
test-id="image-image2image-prompt-garden-inspiration"
|
||||
@apply="handlePromptGardenImportConfirm"
|
||||
@open-import="showPromptGardenImport = true"
|
||||
/>
|
||||
<NButton
|
||||
type="tertiary"
|
||||
size="small"
|
||||
ghost
|
||||
round
|
||||
@click="isInputPanelCollapsed = false"
|
||||
:title="t('common.expand')"
|
||||
>
|
||||
<template #icon>
|
||||
<NIcon>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</NIcon>
|
||||
</template>
|
||||
</NButton>
|
||||
</NFlex>
|
||||
</NFlex>
|
||||
|
||||
<!-- 展开态:完整输入面板 -->
|
||||
@@ -70,6 +80,14 @@
|
||||
}}</NText
|
||||
>
|
||||
<NFlex align="center" :size="8">
|
||||
<PromptGardenInspirationPopover
|
||||
mode="image-image2image"
|
||||
:has-prompt="!!originalPrompt.trim()"
|
||||
:disabled="isPromptGardenGuideDisabled"
|
||||
test-id="image-image2image-prompt-garden-inspiration"
|
||||
@apply="handlePromptGardenImportConfirm"
|
||||
@open-import="showPromptGardenImport = true"
|
||||
/>
|
||||
<NButton
|
||||
type="tertiary"
|
||||
size="small"
|
||||
@@ -146,56 +164,6 @@
|
||||
:disabled="isOptimizing"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="showPromptGardenEmptyGuide"
|
||||
class="prompt-garden-empty-guide"
|
||||
data-testid="image-image2image-prompt-garden-guide"
|
||||
>
|
||||
<div class="prompt-garden-empty-guide__icon">
|
||||
<NIcon>
|
||||
<Plant2 />
|
||||
</NIcon>
|
||||
</div>
|
||||
<div class="prompt-garden-empty-guide__copy">
|
||||
<NText strong>
|
||||
{{ t('common.promptGarden.image2ImageGuideTitle') }}
|
||||
</NText>
|
||||
<NText depth="3" class="prompt-garden-empty-guide__hint">
|
||||
{{ t('common.promptGarden.image2ImageGuideHint') }}
|
||||
</NText>
|
||||
</div>
|
||||
<div class="prompt-garden-empty-guide__actions">
|
||||
<NButton
|
||||
size="small"
|
||||
secondary
|
||||
:disabled="isPromptGardenGuideDisabled"
|
||||
data-testid="image-image2image-prompt-garden-discover"
|
||||
@click="handlePromptGardenDiscover"
|
||||
>
|
||||
<template #icon>
|
||||
<NIcon>
|
||||
<ExternalLink />
|
||||
</NIcon>
|
||||
</template>
|
||||
{{ t('common.promptGarden.discoverShort') }}
|
||||
</NButton>
|
||||
<NButton
|
||||
size="small"
|
||||
secondary
|
||||
:disabled="isPromptGardenGuideDisabled"
|
||||
data-testid="image-image2image-prompt-garden-import"
|
||||
@click="showPromptGardenImport = true"
|
||||
>
|
||||
<template #icon>
|
||||
<NIcon>
|
||||
<FileImport />
|
||||
</NIcon>
|
||||
</template>
|
||||
{{ t('common.promptGarden.importShort') }}
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 图片上传区域 - Image2Image 模式始终显示 -->
|
||||
<NSpace
|
||||
vertical
|
||||
@@ -843,10 +811,10 @@ import {
|
||||
NTooltip,
|
||||
type UploadFileInfo,
|
||||
} from "naive-ui";
|
||||
import { ExternalLink, FileImport, Plant2 } from '@vicons/tabler'
|
||||
import { useI18n } from "vue-i18n";
|
||||
import PromptPanelUI from "../PromptPanel.vue";
|
||||
import WorkspaceUtilityMenu from '../common/WorkspaceUtilityMenu.vue'
|
||||
import PromptGardenInspirationPopover from '../common/PromptGardenInspirationPopover.vue'
|
||||
import PromptGardenImportDialog from '../common/PromptGardenImportDialog.vue'
|
||||
import PromptPreviewPanel from "../PromptPreviewPanel.vue";
|
||||
import SelectWithConfig from "../SelectWithConfig.vue";
|
||||
@@ -863,7 +831,6 @@ import { getI18nErrorMessage } from '../../utils/error'
|
||||
import { withHistorySourceBindingMetadata } from '../../utils/history-source-binding'
|
||||
import { resolveSourceAssetRef } from '../../utils/source-asset'
|
||||
import { downloadImageSource } from '../../utils/image-download'
|
||||
import { openExternalUrl } from '../../utils/open-external-url'
|
||||
import { createImagePromptAnalysisVersion } from '../../utils/imagePromptAnalysis'
|
||||
import type { PromptGardenImportRequest } from '../../utils/prompt-garden-import'
|
||||
import { VariableAwareInput } from '../variable-extraction'
|
||||
@@ -903,7 +870,6 @@ import { useElementSize } from '@vueuse/core'
|
||||
import { runTasksWithExecutionMode } from '../../utils/runTasksSequentially'
|
||||
import {
|
||||
applyPatchOperationsToText,
|
||||
getEnvVar,
|
||||
type ContextMode,
|
||||
type ImageModelConfig,
|
||||
type Image2ImageRequest,
|
||||
@@ -1157,27 +1123,10 @@ const handleClearEvaluation = () => {
|
||||
|
||||
const showPromptGardenImport = ref(false)
|
||||
|
||||
const isPromptGardenEnabled = computed(() => {
|
||||
const value = getEnvVar('VITE_ENABLE_PROMPT_GARDEN_IMPORT').trim().toLowerCase()
|
||||
return value === '1' || value === 'true'
|
||||
})
|
||||
|
||||
const promptGardenBaseUrl = computed(() =>
|
||||
getEnvVar('VITE_PROMPT_GARDEN_BASE_URL').trim().replace(/\/$/, ''),
|
||||
)
|
||||
|
||||
const showPromptGardenEmptyGuide = computed(() =>
|
||||
isPromptGardenEnabled.value && !originalPrompt.value.trim(),
|
||||
)
|
||||
|
||||
const isPromptGardenGuideDisabled = computed(() =>
|
||||
isOptimizing.value || isIterating.value || isAnyVariantRunning.value,
|
||||
)
|
||||
|
||||
const handlePromptGardenDiscover = () => {
|
||||
void openExternalUrl(promptGardenBaseUrl.value, { logPrefix: 'PromptGarden' })
|
||||
}
|
||||
|
||||
const handlePromptGardenImportConfirm = async (request: PromptGardenImportRequest) => {
|
||||
if (!request.importCode) return false
|
||||
|
||||
@@ -2768,53 +2717,4 @@ onUnmounted(() => {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.prompt-garden-empty-guide {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid color-mix(in srgb, var(--n-success-color) 10%, var(--n-border-color));
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--n-success-color) 3%, var(--n-color));
|
||||
}
|
||||
|
||||
.prompt-garden-empty-guide__icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
color: color-mix(in srgb, var(--n-success-color) 76%, var(--n-text-color-3));
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--n-success-color) 7%, transparent);
|
||||
}
|
||||
|
||||
.prompt-garden-empty-guide__copy {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.prompt-garden-empty-guide__hint {
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.prompt-garden-empty-guide__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.prompt-garden-empty-guide {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.prompt-garden-empty-guide__actions {
|
||||
grid-column: 1 / -1;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -41,6 +41,14 @@
|
||||
</NText>
|
||||
</NFlex>
|
||||
<NFlex align="center" :size="8">
|
||||
<PromptGardenInspirationPopover
|
||||
mode="image-text2image"
|
||||
:has-prompt="!!originalPrompt.trim()"
|
||||
:disabled="isPromptGardenGuideDisabled"
|
||||
test-id="image-text2image-prompt-garden-inspiration"
|
||||
@apply="handlePromptGardenImportConfirm"
|
||||
@open-import="showPromptGardenImport = true"
|
||||
/>
|
||||
<NFlex align="center" :size="6" class="reference-action-toolbar">
|
||||
<NTooltip
|
||||
v-for="button in referenceActionButtons"
|
||||
@@ -135,6 +143,14 @@
|
||||
}}</NText
|
||||
>
|
||||
<NFlex align="center" :size="8">
|
||||
<PromptGardenInspirationPopover
|
||||
mode="image-text2image"
|
||||
:has-prompt="!!originalPrompt.trim()"
|
||||
:disabled="isPromptGardenGuideDisabled"
|
||||
test-id="image-text2image-prompt-garden-inspiration"
|
||||
@apply="handlePromptGardenImportConfirm"
|
||||
@open-import="showPromptGardenImport = true"
|
||||
/>
|
||||
<NFlex align="center" :size="6" class="reference-action-toolbar">
|
||||
<NTooltip
|
||||
v-for="button in referenceActionButtons"
|
||||
@@ -288,56 +304,6 @@
|
||||
:disabled="isOptimizing"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="showPromptGardenEmptyGuide"
|
||||
class="prompt-garden-empty-guide"
|
||||
data-testid="image-text2image-prompt-garden-guide"
|
||||
>
|
||||
<div class="prompt-garden-empty-guide__icon">
|
||||
<NIcon>
|
||||
<Plant2 />
|
||||
</NIcon>
|
||||
</div>
|
||||
<div class="prompt-garden-empty-guide__copy">
|
||||
<NText strong>
|
||||
{{ t('common.promptGarden.text2ImageGuideTitle') }}
|
||||
</NText>
|
||||
<NText depth="3" class="prompt-garden-empty-guide__hint">
|
||||
{{ t('common.promptGarden.text2ImageGuideHint') }}
|
||||
</NText>
|
||||
</div>
|
||||
<div class="prompt-garden-empty-guide__actions">
|
||||
<NButton
|
||||
size="small"
|
||||
secondary
|
||||
:disabled="isPromptGardenGuideDisabled"
|
||||
data-testid="image-text2image-prompt-garden-discover"
|
||||
@click="handlePromptGardenDiscover"
|
||||
>
|
||||
<template #icon>
|
||||
<NIcon>
|
||||
<ExternalLink />
|
||||
</NIcon>
|
||||
</template>
|
||||
{{ t('common.promptGarden.discoverShort') }}
|
||||
</NButton>
|
||||
<NButton
|
||||
size="small"
|
||||
secondary
|
||||
:disabled="isPromptGardenGuideDisabled"
|
||||
data-testid="image-text2image-prompt-garden-import"
|
||||
@click="showPromptGardenImport = true"
|
||||
>
|
||||
<template #icon>
|
||||
<NIcon>
|
||||
<FileImport />
|
||||
</NIcon>
|
||||
</template>
|
||||
{{ t('common.promptGarden.importShort') }}
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 控制面板 - 使用网格布局 -->
|
||||
<NGrid :cols="24" :x-gap="8" responsive="screen">
|
||||
<!-- 文本模型选择 -->
|
||||
@@ -944,10 +910,10 @@ import {
|
||||
NRadioButton,
|
||||
NTooltip,
|
||||
} from "naive-ui";
|
||||
import { ExternalLink, FileImport, Plant2 } from '@vicons/tabler'
|
||||
import { useI18n } from "vue-i18n";
|
||||
import PromptPanelUI from "../PromptPanel.vue";
|
||||
import WorkspaceUtilityMenu from '../common/WorkspaceUtilityMenu.vue'
|
||||
import PromptGardenInspirationPopover from '../common/PromptGardenInspirationPopover.vue'
|
||||
import PromptGardenImportDialog from '../common/PromptGardenImportDialog.vue'
|
||||
import PromptPreviewPanel from "../PromptPreviewPanel.vue";
|
||||
import SelectWithConfig from "../SelectWithConfig.vue";
|
||||
@@ -965,7 +931,6 @@ import { getI18nErrorMessage } from '../../utils/error'
|
||||
import { withHistorySourceBindingMetadata } from '../../utils/history-source-binding'
|
||||
import { resolveSourceAssetRef } from '../../utils/source-asset'
|
||||
import { downloadImageSource } from '../../utils/image-download'
|
||||
import { openExternalUrl } from '../../utils/open-external-url'
|
||||
import { createImagePromptAnalysisVersion } from '../../utils/imagePromptAnalysis'
|
||||
import type { PromptGardenImportRequest } from '../../utils/prompt-garden-import'
|
||||
import {
|
||||
@@ -1022,7 +987,6 @@ import {
|
||||
} from './imageText2ImageEvaluation'
|
||||
import {
|
||||
applyPatchOperationsToText,
|
||||
getEnvVar,
|
||||
type ContextMode,
|
||||
type ImageModelConfig,
|
||||
type Text2ImageRequest,
|
||||
@@ -1168,27 +1132,10 @@ const originalPrompt = computed<string>({
|
||||
|
||||
const showPromptGardenImport = ref(false)
|
||||
|
||||
const isPromptGardenEnabled = computed(() => {
|
||||
const value = getEnvVar('VITE_ENABLE_PROMPT_GARDEN_IMPORT').trim().toLowerCase()
|
||||
return value === '1' || value === 'true'
|
||||
})
|
||||
|
||||
const promptGardenBaseUrl = computed(() =>
|
||||
getEnvVar('VITE_PROMPT_GARDEN_BASE_URL').trim().replace(/\/$/, ''),
|
||||
)
|
||||
|
||||
const showPromptGardenEmptyGuide = computed(() =>
|
||||
isPromptGardenEnabled.value && !originalPrompt.value.trim(),
|
||||
)
|
||||
|
||||
const isPromptGardenGuideDisabled = computed(() =>
|
||||
isOptimizing.value || isIterating.value || isAnyVariantRunning.value || isExtractingFromImage.value,
|
||||
)
|
||||
|
||||
const handlePromptGardenDiscover = () => {
|
||||
void openExternalUrl(promptGardenBaseUrl.value, { logPrefix: 'PromptGarden' })
|
||||
}
|
||||
|
||||
const handlePromptGardenImportConfirm = async (request: PromptGardenImportRequest) => {
|
||||
if (!request.importCode) return false
|
||||
|
||||
@@ -3164,56 +3111,6 @@ onUnmounted(() => {
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.prompt-garden-empty-guide {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid color-mix(in srgb, var(--n-success-color) 10%, var(--n-border-color));
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--n-success-color) 3%, var(--n-color));
|
||||
}
|
||||
|
||||
.prompt-garden-empty-guide__icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
color: color-mix(in srgb, var(--n-success-color) 76%, var(--n-text-color-3));
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--n-success-color) 7%, transparent);
|
||||
}
|
||||
|
||||
.prompt-garden-empty-guide__copy {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.prompt-garden-empty-guide__hint {
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.prompt-garden-empty-guide__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.prompt-garden-empty-guide {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.prompt-garden-empty-guide__actions {
|
||||
grid-column: 1 / -1;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.split-divider {
|
||||
cursor: col-resize;
|
||||
background: var(--n-divider-color, rgba(0, 0, 0, 0.08));
|
||||
|
||||
@@ -43,6 +43,11 @@ import {
|
||||
getWorkspaceTemporaryVariablesSession,
|
||||
type WorkspaceApplyTargetKey,
|
||||
} from '../../utils/workspace-external-apply'
|
||||
import {
|
||||
deriveFavoriteCategoryPathFromGardenMeta,
|
||||
ensureFavoriteCategoryPath,
|
||||
loadFavoriteCategoryPathLeafId,
|
||||
} from '../../utils/favorite-category-path'
|
||||
|
||||
type SupportedSubModeKey = WorkspaceApplyTargetKey
|
||||
|
||||
@@ -144,7 +149,10 @@ type FetchedPrompt = {
|
||||
gardenSnapshot: GardenSnapshot
|
||||
}
|
||||
|
||||
type FavoriteManagerLike = Pick<IFavoriteManager, 'getFavorites' | 'addFavorite' | 'updateFavorite'>
|
||||
type FavoriteManagerLike = Pick<
|
||||
IFavoriteManager,
|
||||
'getFavorites' | 'addFavorite' | 'updateFavorite' | 'getCategories' | 'addCategory'
|
||||
>
|
||||
|
||||
type GardenSnapshotVariable = {
|
||||
name: string
|
||||
@@ -289,6 +297,10 @@ const deriveFavoriteTags = (fetched: FetchedPrompt): string[] => {
|
||||
return extractStringArray(snapshotMeta.tags)
|
||||
}
|
||||
|
||||
const deriveFavoriteCategoryPath = (fetched: FetchedPrompt): string[] => {
|
||||
return deriveFavoriteCategoryPathFromGardenMeta(fetched.gardenSnapshot.meta)
|
||||
}
|
||||
|
||||
const deriveFavoriteCategory = (fetched: FetchedPrompt): string | undefined => {
|
||||
const snapshotMeta = fetched.gardenSnapshot.meta
|
||||
if (!snapshotMeta) return undefined
|
||||
@@ -335,6 +347,8 @@ const saveImportedPromptToFavorites = async (opts: {
|
||||
{ allowImageFallback: true },
|
||||
)
|
||||
const media = buildFavoriteMediaFromSnapshot(snapshot)
|
||||
const categoryPath = deriveFavoriteCategoryPath(fetched)
|
||||
const categoryId = await ensureFavoriteCategoryPath(manager, categoryPath)
|
||||
const favorites = await manager.getFavorites()
|
||||
const existing = favorites.find((favorite) => isSameGardenSnapshotFavorite(favorite, snapshot))
|
||||
|
||||
@@ -342,6 +356,7 @@ const saveImportedPromptToFavorites = async (opts: {
|
||||
const metadataBase = isPlainObject(existing.metadata) ? existing.metadata : {}
|
||||
await manager.updateFavorite(existing.id, {
|
||||
content,
|
||||
...(existing.category ? {} : categoryId ? { category: categoryId } : {}),
|
||||
functionMode: modeMapping.functionMode,
|
||||
optimizationMode: modeMapping.optimizationMode,
|
||||
imageSubMode: modeMapping.imageSubMode,
|
||||
@@ -358,6 +373,7 @@ const saveImportedPromptToFavorites = async (opts: {
|
||||
title: deriveFavoriteTitle(fetched),
|
||||
description: deriveFavoriteDescription(fetched),
|
||||
content,
|
||||
...(categoryId ? { category: categoryId } : {}),
|
||||
tags: deriveFavoriteTags(fetched),
|
||||
functionMode: modeMapping.functionMode,
|
||||
optimizationMode: modeMapping.optimizationMode,
|
||||
@@ -487,7 +503,7 @@ const fetchPromptFromGarden = async (opts: {
|
||||
|
||||
const url = (() => {
|
||||
if (!normalizedGardenBaseUrl) return null
|
||||
return `${normalizedGardenBaseUrl}/api/prompt-source/${encodeURIComponent(importCode)}`
|
||||
return `${normalizedGardenBaseUrl}/api/public/prompt-source/${encodeURIComponent(importCode)}`
|
||||
})()
|
||||
|
||||
if (!url) {
|
||||
@@ -1151,6 +1167,11 @@ export function useAppPromptGardenImport(options: AppPromptGardenImportOptions)
|
||||
gardenSnapshot: snapshot,
|
||||
...(media ? { media } : {}),
|
||||
}
|
||||
const favoriteManager = getFavoriteManager?.() || null
|
||||
const categoryPath = deriveFavoriteCategoryPath(fetched)
|
||||
const resolvedCategoryFromPath = favoriteManager
|
||||
? await loadFavoriteCategoryPathLeafId(favoriteManager, categoryPath)
|
||||
: undefined
|
||||
|
||||
openSaveFavoriteDialog({
|
||||
content,
|
||||
@@ -1158,7 +1179,7 @@ export function useAppPromptGardenImport(options: AppPromptGardenImportOptions)
|
||||
prefill: {
|
||||
title: deriveFavoriteTitle(fetched),
|
||||
description: deriveFavoriteDescription(fetched),
|
||||
category: deriveFavoriteCategory(fetched),
|
||||
category: resolvedCategoryFromPath || deriveFavoriteCategory(fetched),
|
||||
tags: deriveFavoriteTags(fetched),
|
||||
functionMode: modeMapping.functionMode,
|
||||
optimizationMode: modeMapping.optimizationMode,
|
||||
|
||||
@@ -51,6 +51,15 @@ const messages = {
|
||||
"text2ImageGuideHint": "When you need a starting point, browse Garden prompts or paste an import code into this workspace.",
|
||||
"image2ImageGuideTitle": "Discover image-to-image prompts in Prompt Garden",
|
||||
"image2ImageGuideHint": "When you need an editing direction, browse Garden prompts or paste an import code into this workspace.",
|
||||
"inspirationTrigger": "Inspiration",
|
||||
"inspirationStartTitle": "Start with inspiration",
|
||||
"inspirationTitle": "Find inspiration",
|
||||
"loadingSuggestions": "Loading inspiration...",
|
||||
"refreshSuggestions": "Refresh suggestions",
|
||||
"browseMore": "Browse more",
|
||||
"noSuggestions": "No suggestions right now. Browse Garden or paste an import code.",
|
||||
"useShort": "Use",
|
||||
"replaceImportShort": "Replace import",
|
||||
"discoverShort": "Discover",
|
||||
"importShort": "Paste code",
|
||||
"importingStatus": "Importing prompt and image resources from Prompt Garden..."
|
||||
|
||||
@@ -49,6 +49,15 @@ const messages = {
|
||||
"text2ImageGuideHint": "没有灵感时,可以先去花园挑一个提示词,或粘贴导入码直接带入当前工作区。",
|
||||
"image2ImageGuideTitle": "从 Prompt Garden 发现图生图提示词",
|
||||
"image2ImageGuideHint": "没有改图思路时,可以先去花园挑一个图生图提示词,或粘贴导入码带入当前工作区。",
|
||||
"inspirationTrigger": "灵感",
|
||||
"inspirationStartTitle": "从灵感开始",
|
||||
"inspirationTitle": "找点灵感",
|
||||
"loadingSuggestions": "正在加载灵感...",
|
||||
"refreshSuggestions": "换一批",
|
||||
"browseMore": "浏览更多",
|
||||
"noSuggestions": "暂时没有推荐,可以去花园浏览或粘贴导入码。",
|
||||
"useShort": "使用",
|
||||
"replaceImportShort": "替换导入",
|
||||
"discoverShort": "去发现",
|
||||
"importShort": "粘贴导入码",
|
||||
"importingStatus": "正在从 Prompt Garden 获取提示词和图片资源..."
|
||||
|
||||
@@ -49,6 +49,15 @@ const messages = {
|
||||
"text2ImageGuideHint": "沒有靈感時,可以先去花園挑一個提示詞,或貼上匯入碼直接帶入目前工作區。",
|
||||
"image2ImageGuideTitle": "從 Prompt Garden 發現圖生圖提示詞",
|
||||
"image2ImageGuideHint": "沒有改圖思路時,可以先去花園挑一個圖生圖提示詞,或貼上匯入碼帶入目前工作區。",
|
||||
"inspirationTrigger": "靈感",
|
||||
"inspirationStartTitle": "從靈感開始",
|
||||
"inspirationTitle": "找點靈感",
|
||||
"loadingSuggestions": "正在載入靈感...",
|
||||
"refreshSuggestions": "換一批",
|
||||
"browseMore": "瀏覽更多",
|
||||
"noSuggestions": "暫時沒有推薦,可以去花園瀏覽或貼上匯入碼。",
|
||||
"useShort": "使用",
|
||||
"replaceImportShort": "替換匯入",
|
||||
"discoverShort": "去發現",
|
||||
"importShort": "貼上匯入碼",
|
||||
"importingStatus": "正在從 Prompt Garden 取得提示詞和圖片資源..."
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import type { FavoriteCategory, IFavoriteManager } from '@prompt-optimizer/core'
|
||||
|
||||
export type FavoriteCategoryPathManager = Pick<IFavoriteManager, 'getCategories' | 'addCategory'>
|
||||
|
||||
const normalizeCategorySegment = (value: unknown): string =>
|
||||
typeof value === 'string' ? value.trim() : ''
|
||||
|
||||
const normalizeCategoryKey = (value: unknown): string => normalizeCategorySegment(value).toLowerCase()
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
Boolean(value) && typeof value === 'object' && !Array.isArray(value)
|
||||
|
||||
export const normalizeFavoriteCategoryPath = (value: unknown): string[] => {
|
||||
if (!Array.isArray(value)) return []
|
||||
return value
|
||||
.map((item) => normalizeCategorySegment(item))
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
export const deriveFavoriteCategoryPathFromGardenMeta = (snapshotMeta: unknown): string[] => {
|
||||
if (!isRecord(snapshotMeta)) return []
|
||||
|
||||
const fromCategoryPath = normalizeFavoriteCategoryPath(snapshotMeta.categoryPath)
|
||||
if (fromCategoryPath.length > 0) return fromCategoryPath
|
||||
|
||||
const fromCategoryPathKey = normalizeFavoriteCategoryPath(snapshotMeta.categoryPathKey)
|
||||
if (fromCategoryPathKey.length > 0) return fromCategoryPathKey
|
||||
|
||||
const category = normalizeCategorySegment(snapshotMeta.category)
|
||||
const subcategory = normalizeCategorySegment(snapshotMeta.subcategory)
|
||||
const legacyPath = [category, subcategory].filter(Boolean)
|
||||
if (legacyPath.length > 0) return legacyPath
|
||||
|
||||
const categoryKey = normalizeCategorySegment(snapshotMeta.categoryKey)
|
||||
return categoryKey ? [categoryKey] : []
|
||||
}
|
||||
|
||||
const findCategoryInParent = (
|
||||
categories: FavoriteCategory[],
|
||||
parentId: string | undefined,
|
||||
name: string,
|
||||
): FavoriteCategory | undefined => {
|
||||
const normalizedParentId = normalizeCategorySegment(parentId)
|
||||
const normalizedName = normalizeCategoryKey(name)
|
||||
|
||||
return categories.find((category) => {
|
||||
if (normalizeCategorySegment(category.parentId) !== normalizedParentId) return false
|
||||
return normalizeCategoryKey(category.name) === normalizedName
|
||||
})
|
||||
}
|
||||
|
||||
export const resolveFavoriteCategoryPathLeafId = (
|
||||
categories: FavoriteCategory[],
|
||||
path: string[],
|
||||
): string | undefined => {
|
||||
const normalizedPath = normalizeFavoriteCategoryPath(path)
|
||||
if (normalizedPath.length === 0) return undefined
|
||||
|
||||
let parentId: string | undefined
|
||||
for (const segment of normalizedPath) {
|
||||
const matched = findCategoryInParent(categories, parentId, segment)
|
||||
if (!matched) return undefined
|
||||
parentId = matched.id
|
||||
}
|
||||
|
||||
return parentId
|
||||
}
|
||||
|
||||
export const loadFavoriteCategoryPathLeafId = async (
|
||||
manager: FavoriteCategoryPathManager,
|
||||
path: string[],
|
||||
): Promise<string | undefined> => {
|
||||
const normalizedPath = normalizeFavoriteCategoryPath(path)
|
||||
if (normalizedPath.length === 0) return undefined
|
||||
|
||||
const categories = await manager.getCategories()
|
||||
return resolveFavoriteCategoryPathLeafId(categories, normalizedPath)
|
||||
}
|
||||
|
||||
export const ensureFavoriteCategoryPath = async (
|
||||
manager: FavoriteCategoryPathManager,
|
||||
path: string[],
|
||||
): Promise<string | undefined> => {
|
||||
const normalizedPath = normalizeFavoriteCategoryPath(path)
|
||||
if (normalizedPath.length === 0) return undefined
|
||||
|
||||
let categories = await manager.getCategories()
|
||||
let parentId: string | undefined
|
||||
|
||||
for (const segment of normalizedPath) {
|
||||
const existing = findCategoryInParent(categories, parentId, segment)
|
||||
if (existing) {
|
||||
parentId = existing.id
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const newId = await manager.addCategory({
|
||||
name: segment,
|
||||
parentId,
|
||||
sortOrder: 0,
|
||||
})
|
||||
|
||||
categories = [
|
||||
...categories,
|
||||
{
|
||||
id: newId,
|
||||
name: segment,
|
||||
parentId,
|
||||
createdAt: Date.now(),
|
||||
sortOrder: 0,
|
||||
},
|
||||
]
|
||||
parentId = newId
|
||||
} catch {
|
||||
categories = await manager.getCategories()
|
||||
const retryMatch = findCategoryInParent(categories, parentId, segment)
|
||||
if (!retryMatch) return undefined
|
||||
parentId = retryMatch.id
|
||||
}
|
||||
}
|
||||
|
||||
return parentId
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
export type PromptGardenSuggestionStrategy = 'mixed' | 'latest' | 'random' | 'featured'
|
||||
export type PromptGardenSuggestionSource = 'latest' | 'random' | 'featured' | 'related'
|
||||
|
||||
export interface PromptGardenSuggestionItem {
|
||||
id: string
|
||||
title: string
|
||||
summary: string
|
||||
importCode: string
|
||||
tags: string[]
|
||||
mode: string | null
|
||||
thumbnailUrl: string | null
|
||||
updatedAt: string | null
|
||||
source: PromptGardenSuggestionSource | null
|
||||
}
|
||||
|
||||
export interface PromptGardenSuggestionResponse {
|
||||
items: PromptGardenSuggestionItem[]
|
||||
browseUrl: string
|
||||
nextExclude: string[]
|
||||
ttlSeconds: number | null
|
||||
}
|
||||
|
||||
export interface FetchPromptGardenSuggestionsOptions {
|
||||
gardenBaseUrl: string
|
||||
mode: string
|
||||
limit?: number
|
||||
strategy?: PromptGardenSuggestionStrategy
|
||||
exclude?: string[]
|
||||
locale?: string
|
||||
timeoutMs?: number
|
||||
}
|
||||
|
||||
const DEFAULT_LIMIT = 3
|
||||
const DEFAULT_TIMEOUT_MS = 2500
|
||||
const SOURCE_VALUES = new Set<PromptGardenSuggestionSource>([
|
||||
'latest',
|
||||
'random',
|
||||
'featured',
|
||||
'related',
|
||||
])
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
|
||||
const normalizeBaseUrl = (value: string): string => value.trim().replace(/\/+$/, '')
|
||||
|
||||
const normalizeString = (value: unknown): string =>
|
||||
typeof value === 'string' ? value.trim() : ''
|
||||
|
||||
const resolveGardenUrl = (gardenBaseUrl: string, value: unknown): string | null => {
|
||||
const raw = normalizeString(value)
|
||||
if (!raw) return null
|
||||
|
||||
try {
|
||||
return new URL(raw, `${gardenBaseUrl}/`).toString()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const normalizeTags = (value: unknown): string[] => {
|
||||
if (!Array.isArray(value)) return []
|
||||
return Array.from(
|
||||
new Set(
|
||||
value
|
||||
.map((item) => normalizeString(item))
|
||||
.filter(Boolean)
|
||||
.slice(0, 3),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
const normalizeNextExclude = (value: unknown): string[] => {
|
||||
if (!Array.isArray(value)) return []
|
||||
return Array.from(new Set(value.map((item) => normalizeString(item)).filter(Boolean)))
|
||||
}
|
||||
|
||||
const normalizeSuggestionItem = (
|
||||
value: unknown,
|
||||
gardenBaseUrl: string,
|
||||
): PromptGardenSuggestionItem | null => {
|
||||
if (!isRecord(value)) return null
|
||||
|
||||
const id = normalizeString(value.id)
|
||||
const title = normalizeString(value.title)
|
||||
const summary = normalizeString(value.summary)
|
||||
const importCode = normalizeString(value.importCode)
|
||||
|
||||
if (!id || !title || !summary || !importCode) return null
|
||||
|
||||
const source = normalizeString(value.source)
|
||||
const mode = normalizeString(value.mode)
|
||||
|
||||
return {
|
||||
id,
|
||||
title,
|
||||
summary,
|
||||
importCode,
|
||||
tags: normalizeTags(value.tags),
|
||||
mode: mode || null,
|
||||
thumbnailUrl: resolveGardenUrl(gardenBaseUrl, value.thumbnailUrl),
|
||||
updatedAt: normalizeString(value.updatedAt) || null,
|
||||
source: SOURCE_VALUES.has(source as PromptGardenSuggestionSource)
|
||||
? (source as PromptGardenSuggestionSource)
|
||||
: null,
|
||||
}
|
||||
}
|
||||
|
||||
const buildFallbackBrowseUrl = (gardenBaseUrl: string, mode: string): string => {
|
||||
const url = new URL(`${gardenBaseUrl}/prompts`)
|
||||
if (mode) {
|
||||
url.searchParams.set('mode', mode)
|
||||
}
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
export const buildPromptGardenSuggestionsUrl = (
|
||||
options: FetchPromptGardenSuggestionsOptions,
|
||||
): string => {
|
||||
const gardenBaseUrl = normalizeBaseUrl(options.gardenBaseUrl)
|
||||
if (!gardenBaseUrl) {
|
||||
throw new Error('Missing Prompt Garden base URL')
|
||||
}
|
||||
|
||||
const url = new URL(`${gardenBaseUrl}/api/public/prompts/suggestions`)
|
||||
url.searchParams.set('mode', options.mode)
|
||||
url.searchParams.set('limit', String(options.limit ?? DEFAULT_LIMIT))
|
||||
url.searchParams.set('strategy', options.strategy ?? 'mixed')
|
||||
|
||||
const exclude = Array.from(new Set((options.exclude ?? []).map((item) => item.trim()).filter(Boolean)))
|
||||
if (exclude.length > 0) {
|
||||
url.searchParams.set('exclude', exclude.join(','))
|
||||
}
|
||||
|
||||
const locale = normalizeString(options.locale)
|
||||
if (locale) {
|
||||
url.searchParams.set('locale', locale)
|
||||
}
|
||||
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
export const fetchPromptGardenSuggestions = async (
|
||||
options: FetchPromptGardenSuggestionsOptions,
|
||||
): Promise<PromptGardenSuggestionResponse> => {
|
||||
const gardenBaseUrl = normalizeBaseUrl(options.gardenBaseUrl)
|
||||
const url = buildPromptGardenSuggestionsUrl({
|
||||
...options,
|
||||
gardenBaseUrl,
|
||||
})
|
||||
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), options.timeoutMs ?? DEFAULT_TIMEOUT_MS)
|
||||
|
||||
try {
|
||||
const resp = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
},
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Garden suggestions request failed: ${resp.status}`)
|
||||
}
|
||||
|
||||
const data = (await resp.json()) as unknown
|
||||
if (!isRecord(data)) {
|
||||
throw new Error('Garden suggestions response must be an object')
|
||||
}
|
||||
|
||||
const items = Array.isArray(data.items)
|
||||
? data.items
|
||||
.map((item) => normalizeSuggestionItem(item, gardenBaseUrl))
|
||||
.filter((item): item is PromptGardenSuggestionItem => Boolean(item))
|
||||
: []
|
||||
|
||||
const browseUrl =
|
||||
resolveGardenUrl(gardenBaseUrl, data.browseUrl) ||
|
||||
buildFallbackBrowseUrl(gardenBaseUrl, options.mode)
|
||||
|
||||
const ttlSeconds =
|
||||
typeof data.ttlSeconds === 'number' && Number.isFinite(data.ttlSeconds) && data.ttlSeconds > 0
|
||||
? Math.floor(data.ttlSeconds)
|
||||
: null
|
||||
|
||||
return {
|
||||
items,
|
||||
browseUrl,
|
||||
nextExclude: normalizeNextExclude(data.nextExclude),
|
||||
ttlSeconds,
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
@@ -213,6 +213,68 @@ describe('FavoriteEditorForm', () => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('creates missing category path from garden metadata only when save is confirmed', async () => {
|
||||
const addFavorite = vi.fn(async () => 'fav-saved')
|
||||
const getCategories = vi.fn(async () => [])
|
||||
const addCategory = vi.fn(async ({ name }: { name: string }) => `cat-${name}`)
|
||||
|
||||
const wrapper = mount(FavoriteEditorForm, {
|
||||
props: {
|
||||
mode: 'save',
|
||||
content: 'Imported content',
|
||||
prefill: {
|
||||
title: 'Garden Favorite',
|
||||
metadata: {
|
||||
gardenSnapshot: {
|
||||
meta: {
|
||||
categoryPath: ['图像创作', '海报'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
global: {
|
||||
stubs: naiveStubs,
|
||||
provide: {
|
||||
services: ref({
|
||||
favoriteImageStorageService: {},
|
||||
imageStorageService: {},
|
||||
favoriteManager: {
|
||||
getAllTags: vi.fn(async () => []),
|
||||
addTag: vi.fn(async () => {}),
|
||||
getCategories,
|
||||
addCategory,
|
||||
addFavorite,
|
||||
},
|
||||
} as any),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(addCategory).not.toHaveBeenCalled()
|
||||
|
||||
await wrapper.find('[data-testid="favorite-editor-save"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(addCategory).toHaveBeenCalledTimes(2)
|
||||
expect(addCategory).toHaveBeenNthCalledWith(1, expect.objectContaining({
|
||||
name: '图像创作',
|
||||
parentId: undefined,
|
||||
}))
|
||||
expect(addCategory).toHaveBeenNthCalledWith(2, expect.objectContaining({
|
||||
name: '海报',
|
||||
parentId: 'cat-图像创作',
|
||||
}))
|
||||
expect(addFavorite).toHaveBeenCalledWith(expect.objectContaining({
|
||||
title: 'Garden Favorite',
|
||||
content: 'Imported content',
|
||||
category: 'cat-海报',
|
||||
}))
|
||||
expect(getCategories).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores stale media hydration after switching edited favorites', async () => {
|
||||
const firstCover = createDeferred<string | null>()
|
||||
const secondCover = createDeferred<string | null>()
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, h } from 'vue'
|
||||
import { mount } from '@vue/test-utils'
|
||||
|
||||
const fetchSuggestionsMock = vi.hoisted(() => vi.fn())
|
||||
const openExternalUrlMock = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@prompt-optimizer/core', () => ({
|
||||
getEnvVar: (key: string) => {
|
||||
if (key === 'VITE_ENABLE_PROMPT_GARDEN_IMPORT') return '1'
|
||||
if (key === 'VITE_PROMPT_GARDEN_BASE_URL') return 'https://garden.always200.com/'
|
||||
return ''
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
locale: { value: 'en-US' },
|
||||
t: (key: string) => {
|
||||
const messages: Record<string, string> = {
|
||||
'common.promptGarden.inspirationTrigger': 'Inspiration',
|
||||
'common.promptGarden.inspirationStartTitle': 'Start with inspiration',
|
||||
'common.promptGarden.inspirationTitle': 'Find inspiration',
|
||||
'common.promptGarden.loadingSuggestions': 'Loading inspiration...',
|
||||
'common.promptGarden.refreshSuggestions': 'Refresh suggestions',
|
||||
'common.promptGarden.browseMore': 'Browse more',
|
||||
'common.promptGarden.noSuggestions': 'No suggestions right now',
|
||||
'common.promptGarden.useShort': 'Use',
|
||||
'common.promptGarden.replaceImportShort': 'Replace import',
|
||||
'common.promptGarden.importShort': 'Paste code',
|
||||
}
|
||||
return messages[key] ?? key
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/utils/open-external-url', () => ({
|
||||
openExternalUrl: openExternalUrlMock,
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/utils/prompt-garden-suggestions', () => ({
|
||||
fetchPromptGardenSuggestions: fetchSuggestionsMock,
|
||||
}))
|
||||
|
||||
vi.mock('naive-ui', () => ({
|
||||
NButton: defineComponent({
|
||||
name: 'NButton',
|
||||
props: {
|
||||
disabled: Boolean,
|
||||
},
|
||||
setup(props, { slots, attrs }) {
|
||||
return () =>
|
||||
h(
|
||||
'button',
|
||||
{
|
||||
...attrs,
|
||||
disabled: props.disabled,
|
||||
},
|
||||
[slots.icon?.(), slots.default?.()],
|
||||
)
|
||||
},
|
||||
}),
|
||||
NIcon: defineComponent({
|
||||
name: 'NIcon',
|
||||
setup(_, { slots }) {
|
||||
return () => h('span', { class: 'n-icon-stub' }, slots.default?.())
|
||||
},
|
||||
}),
|
||||
NPopover: defineComponent({
|
||||
name: 'NPopover',
|
||||
props: {
|
||||
show: Boolean,
|
||||
},
|
||||
emits: ['update:show', 'clickoutside'],
|
||||
setup(props, { slots }) {
|
||||
return () =>
|
||||
h('div', { class: 'n-popover-stub' }, [
|
||||
h('div', { class: 'n-popover-trigger' }, slots.trigger?.()),
|
||||
props.show ? h('div', { class: 'n-popover-content' }, slots.default?.()) : null,
|
||||
])
|
||||
},
|
||||
}),
|
||||
NSpin: defineComponent({
|
||||
name: 'NSpin',
|
||||
setup() {
|
||||
return () => h('span', 'spin')
|
||||
},
|
||||
}),
|
||||
NTag: defineComponent({
|
||||
name: 'NTag',
|
||||
setup(_, { slots }) {
|
||||
return () => h('span', { class: 'n-tag-stub' }, slots.default?.())
|
||||
},
|
||||
}),
|
||||
NText: defineComponent({
|
||||
name: 'NText',
|
||||
setup(_, { slots }) {
|
||||
return () => h('span', slots.default?.())
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@vicons/tabler', () => ({
|
||||
ExternalLink: defineComponent({ name: 'ExternalLink', setup: () => () => h('i') }),
|
||||
FileImport: defineComponent({ name: 'FileImport', setup: () => () => h('i') }),
|
||||
Plant2: defineComponent({ name: 'Plant2', setup: () => () => h('i') }),
|
||||
Refresh: defineComponent({ name: 'Refresh', setup: () => () => h('i') }),
|
||||
}))
|
||||
|
||||
import PromptGardenInspirationPopover from '../../../src/components/common/PromptGardenInspirationPopover.vue'
|
||||
|
||||
describe('PromptGardenInspirationPopover', () => {
|
||||
beforeEach(() => {
|
||||
fetchSuggestionsMock.mockReset()
|
||||
openExternalUrlMock.mockReset()
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
it('loads lightweight Garden suggestions on hover and emits an import request', async () => {
|
||||
fetchSuggestionsMock.mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
id: 'prompt-1',
|
||||
title: 'Cinematic portrait',
|
||||
summary: 'Keep the subject and improve lighting.',
|
||||
importCode: 'NB-001',
|
||||
tags: ['portrait'],
|
||||
mode: 'image-image2image',
|
||||
thumbnailUrl: null,
|
||||
updatedAt: null,
|
||||
source: 'latest',
|
||||
},
|
||||
],
|
||||
browseUrl: 'https://garden.always200.com/prompts?mode=image-image2image',
|
||||
nextExclude: ['NB-001'],
|
||||
ttlSeconds: 300,
|
||||
})
|
||||
|
||||
const wrapper = mount(PromptGardenInspirationPopover, {
|
||||
props: {
|
||||
mode: 'image-image2image',
|
||||
hasPrompt: true,
|
||||
testId: 'garden-test',
|
||||
},
|
||||
})
|
||||
|
||||
await wrapper.find('[data-testid="garden-test-trigger"]').trigger('mouseenter')
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(fetchSuggestionsMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
gardenBaseUrl: 'https://garden.always200.com',
|
||||
mode: 'image-image2image',
|
||||
limit: 3,
|
||||
strategy: 'mixed',
|
||||
locale: 'en-US',
|
||||
}),
|
||||
)
|
||||
expect(wrapper.text()).toContain('Cinematic portrait')
|
||||
expect(wrapper.text()).toContain('Replace import')
|
||||
|
||||
await wrapper.find('[data-testid="garden-test-suggestion-0-apply"]').trigger('click')
|
||||
|
||||
expect(wrapper.emitted('apply')?.[0]).toEqual([
|
||||
{
|
||||
importCode: 'NB-001',
|
||||
exampleId: null,
|
||||
subModeKey: 'image-image2image',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('opens the Garden browse URL from the popover fallback action', async () => {
|
||||
fetchSuggestionsMock.mockResolvedValue({
|
||||
items: [],
|
||||
browseUrl: 'https://garden.always200.com/prompts?mode=image-text2image',
|
||||
nextExclude: [],
|
||||
ttlSeconds: 300,
|
||||
})
|
||||
|
||||
const wrapper = mount(PromptGardenInspirationPopover, {
|
||||
props: {
|
||||
mode: 'image-text2image',
|
||||
testId: 'garden-test',
|
||||
},
|
||||
})
|
||||
|
||||
await wrapper.find('[data-testid="garden-test-trigger"]').trigger('click')
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
await wrapper.find('[data-testid="garden-test-browse"]').trigger('click')
|
||||
|
||||
expect(openExternalUrlMock).toHaveBeenCalledWith(
|
||||
'https://garden.always200.com/prompts?mode=image-text2image',
|
||||
{ logPrefix: 'PromptGarden' },
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -217,7 +217,7 @@ describe('useAppPromptGardenImport', () => {
|
||||
await waitForCondition(() => isLoadingExternalData.value === false)
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toBe('http://garden.local/api/prompt-source/NB-001')
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toBe('http://garden.local/api/public/prompt-source/NB-001')
|
||||
|
||||
// Navigated to target workspace.
|
||||
expect(currentRoute.value.path).toBe('/basic/system')
|
||||
@@ -385,7 +385,7 @@ describe('useAppPromptGardenImport', () => {
|
||||
await waitForCondition(() => isLoadingExternalData.value === false)
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toBe('http://garden.local/api/prompt-source/NB-PRO-001')
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toBe('http://garden.local/api/public/prompt-source/NB-PRO-001')
|
||||
|
||||
// Navigated to target workspace.
|
||||
expect(currentRoute.value.path).toBe('/pro/multi')
|
||||
@@ -565,7 +565,7 @@ describe('useAppPromptGardenImport', () => {
|
||||
await waitForCondition(() => isLoadingExternalData.value === false)
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toBe('http://garden.local/api/prompt-source/NB-PVAR-001')
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toBe('http://garden.local/api/public/prompt-source/NB-PVAR-001')
|
||||
|
||||
expect(currentRoute.value.path).toBe('/pro/variable')
|
||||
|
||||
@@ -821,7 +821,7 @@ describe('useAppPromptGardenImport', () => {
|
||||
(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
|
||||
>(async (input) => {
|
||||
const url = String(input)
|
||||
if (url === 'http://garden.local/api/prompt-source/NB-MULTI-EXAMPLE-001') {
|
||||
if (url === 'http://garden.local/api/public/prompt-source/NB-MULTI-EXAMPLE-001') {
|
||||
return new Response(JSON.stringify(v1Payload), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
@@ -1109,7 +1109,7 @@ describe('useAppPromptGardenImport', () => {
|
||||
await waitForCondition(() => isLoadingExternalData.value === false)
|
||||
|
||||
expect(currentRoute.value.path).toBe('/pro/variable')
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toBe('http://garden.local/api/prompt-source/NB-PVAR-EX-002')
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toBe('http://garden.local/api/public/prompt-source/NB-PVAR-EX-002')
|
||||
expect(proVariableSession.getTemporaryVariable('name')).toBe('Charlie')
|
||||
|
||||
// Import params removed from the URL.
|
||||
@@ -1370,7 +1370,7 @@ describe('useAppPromptGardenImport', () => {
|
||||
(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
|
||||
>(async (input) => {
|
||||
const url = String(input)
|
||||
if (url === 'http://garden.local/api/prompt-source/NB-I2I-001') {
|
||||
if (url === 'http://garden.local/api/public/prompt-source/NB-I2I-001') {
|
||||
return new Response(JSON.stringify(v1Payload), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
@@ -1598,6 +1598,8 @@ describe('useAppPromptGardenImport', () => {
|
||||
|
||||
const favoriteManager = {
|
||||
getFavorites: vi.fn(async (): Promise<FavoritePrompt[]> => []),
|
||||
getCategories: vi.fn(async () => []),
|
||||
addCategory: vi.fn(async ({ name }: { name: string }) => `cat-${name}`),
|
||||
addFavorite: vi.fn(async (
|
||||
_favorite: Omit<FavoritePrompt, 'id' | 'createdAt' | 'updatedAt' | 'useCount'>
|
||||
) => 'fav-new'),
|
||||
@@ -1650,6 +1652,7 @@ describe('useAppPromptGardenImport', () => {
|
||||
title: 'Garden Prompt Title',
|
||||
description: 'Garden Prompt Description',
|
||||
tags: ['travel', 'city'],
|
||||
categoryPath: ['图像创作', '海报'],
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1696,7 +1699,17 @@ describe('useAppPromptGardenImport', () => {
|
||||
expect(savedArg.content).toBe('IMPORTED PROMPT')
|
||||
expect(savedArg.functionMode).toBe('basic')
|
||||
expect(savedArg.optimizationMode).toBe('system')
|
||||
expect(savedArg.category).toBe('cat-海报')
|
||||
expect(savedArg.tags).toEqual(['travel', 'city'])
|
||||
expect(favoriteManager.addCategory).toHaveBeenCalledTimes(2)
|
||||
expect(favoriteManager.addCategory).toHaveBeenNthCalledWith(1, expect.objectContaining({
|
||||
name: '图像创作',
|
||||
parentId: undefined,
|
||||
}))
|
||||
expect(favoriteManager.addCategory).toHaveBeenNthCalledWith(2, expect.objectContaining({
|
||||
name: '海报',
|
||||
parentId: 'cat-图像创作',
|
||||
}))
|
||||
|
||||
const metadata = savedArg.metadata as Record<string, unknown>
|
||||
const snapshot = metadata.gardenSnapshot as Record<string, unknown>
|
||||
@@ -1791,6 +1804,23 @@ describe('useAppPromptGardenImport', () => {
|
||||
|
||||
const favoriteManager = {
|
||||
getFavorites: vi.fn(async (): Promise<FavoritePrompt[]> => []),
|
||||
getCategories: vi.fn(async () => [
|
||||
{
|
||||
id: 'cat-text-root',
|
||||
name: '文本生成',
|
||||
parentId: undefined,
|
||||
createdAt: Date.now(),
|
||||
sortOrder: 0,
|
||||
},
|
||||
{
|
||||
id: 'cat-marketing-leaf',
|
||||
name: '营销文案',
|
||||
parentId: 'cat-text-root',
|
||||
createdAt: Date.now(),
|
||||
sortOrder: 0,
|
||||
},
|
||||
]),
|
||||
addCategory: vi.fn(async ({ name }: { name: string }) => `cat-${name}`),
|
||||
addFavorite: vi.fn(async (
|
||||
_favorite: Omit<FavoritePrompt, 'id' | 'createdAt' | 'updatedAt' | 'useCount'>
|
||||
) => 'fav-new'),
|
||||
@@ -1815,6 +1845,7 @@ describe('useAppPromptGardenImport', () => {
|
||||
title: 'Confirm Prompt Title',
|
||||
description: 'Confirm Prompt Description',
|
||||
tags: ['confirm', 'garden'],
|
||||
categoryPath: ['文本生成', '营销文案'],
|
||||
categoryKey: '文本生成',
|
||||
},
|
||||
}),
|
||||
@@ -1877,11 +1908,12 @@ describe('useAppPromptGardenImport', () => {
|
||||
expect(savedArg.prefill?.title).toBe('Confirm Prompt Title')
|
||||
expect(savedArg.prefill?.description).toBe('Confirm Prompt Description')
|
||||
expect(savedArg.prefill?.tags).toEqual(['confirm', 'garden'])
|
||||
expect(savedArg.prefill?.category).toBe('文本生成')
|
||||
expect(savedArg.prefill?.category).toBe('cat-marketing-leaf')
|
||||
expect(savedArg.prefill?.functionMode).toBe('basic')
|
||||
expect(savedArg.prefill?.metadata?.gardenSnapshot).toBeTruthy()
|
||||
|
||||
expect(favoriteManager.getFavorites).not.toHaveBeenCalled()
|
||||
expect(favoriteManager.getCategories).toHaveBeenCalledTimes(1)
|
||||
expect(favoriteManager.addFavorite).not.toHaveBeenCalled()
|
||||
expect(favoriteManager.updateFavorite).not.toHaveBeenCalled()
|
||||
|
||||
@@ -1948,6 +1980,8 @@ describe('useAppPromptGardenImport', () => {
|
||||
|
||||
const favoriteManager = {
|
||||
getFavorites: vi.fn(async (): Promise<FavoritePrompt[]> => []),
|
||||
getCategories: vi.fn(async () => []),
|
||||
addCategory: vi.fn(async ({ name }: { name: string }) => `cat-${name}`),
|
||||
addFavorite: vi.fn(async (
|
||||
_favorite: Omit<FavoritePrompt, 'id' | 'createdAt' | 'updatedAt' | 'useCount'>
|
||||
) => 'fav-new'),
|
||||
@@ -2227,6 +2261,8 @@ describe('useAppPromptGardenImport', () => {
|
||||
|
||||
const favoriteManager = {
|
||||
getFavorites: vi.fn(async (): Promise<FavoritePrompt[]> => []),
|
||||
getCategories: vi.fn(async () => []),
|
||||
addCategory: vi.fn(async ({ name }: { name: string }) => `cat-${name}`),
|
||||
addFavorite: vi.fn(async (
|
||||
_favorite: Omit<FavoritePrompt, 'id' | 'createdAt' | 'updatedAt' | 'useCount'>
|
||||
) => 'fav-new'),
|
||||
@@ -2359,6 +2395,8 @@ describe('useAppPromptGardenImport', () => {
|
||||
getFavorites: vi.fn(async (): Promise<FavoritePrompt[]> => [
|
||||
existingFavorite,
|
||||
]),
|
||||
getCategories: vi.fn(async () => []),
|
||||
addCategory: vi.fn(async ({ name }: { name: string }) => `cat-${name}`),
|
||||
addFavorite: vi.fn(async (
|
||||
_favorite: Omit<FavoritePrompt, 'id' | 'createdAt' | 'updatedAt' | 'useCount'>
|
||||
) => 'fav-new'),
|
||||
@@ -2505,6 +2543,8 @@ describe('useAppPromptGardenImport', () => {
|
||||
|
||||
const favoriteManager = {
|
||||
getFavorites: vi.fn(async (): Promise<FavoritePrompt[]> => []),
|
||||
getCategories: vi.fn(async () => []),
|
||||
addCategory: vi.fn(async ({ name }: { name: string }) => `cat-${name}`),
|
||||
addFavorite: vi.fn(async () => {
|
||||
throw new Error('favorites payload exceeds hard limit')
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
buildPromptGardenSuggestionsUrl,
|
||||
fetchPromptGardenSuggestions,
|
||||
} from '../../../src/utils/prompt-garden-suggestions'
|
||||
|
||||
describe('prompt-garden-suggestions', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('builds a lightweight suggestions URL with mode, strategy, exclude, and locale', () => {
|
||||
const url = buildPromptGardenSuggestionsUrl({
|
||||
gardenBaseUrl: 'https://garden.always200.com/',
|
||||
mode: 'image-image2image',
|
||||
limit: 3,
|
||||
strategy: 'mixed',
|
||||
exclude: ['NB-001', 'NB-002', 'NB-001'],
|
||||
locale: 'zh-CN',
|
||||
})
|
||||
|
||||
expect(url).toBe(
|
||||
'https://garden.always200.com/api/public/prompts/suggestions?mode=image-image2image&limit=3&strategy=mixed&exclude=NB-001%2CNB-002&locale=zh-CN',
|
||||
)
|
||||
})
|
||||
|
||||
it('normalizes valid suggestion cards and drops invalid items', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
items: [
|
||||
{
|
||||
id: 'prompt-1',
|
||||
title: 'Cinematic portrait',
|
||||
summary: 'Keep the subject and improve lighting.',
|
||||
tags: ['portrait', 'cinematic', 'portrait'],
|
||||
importCode: 'NB-001',
|
||||
mode: 'image-image2image',
|
||||
thumbnailUrl: '/prompt-assets/thumb.webp',
|
||||
source: 'latest',
|
||||
},
|
||||
{
|
||||
id: 'prompt-2',
|
||||
title: 'Curated style transfer',
|
||||
summary: 'Featured by Garden before random fill.',
|
||||
tags: ['featured'],
|
||||
importCode: 'NB-002',
|
||||
mode: 'image-image2image',
|
||||
source: 'featured',
|
||||
},
|
||||
{
|
||||
id: 'missing-code',
|
||||
title: 'Invalid',
|
||||
summary: 'No code',
|
||||
},
|
||||
],
|
||||
browseUrl: '/prompts?mode=image-image2image',
|
||||
nextExclude: ['NB-001', 'NB-002'],
|
||||
ttlSeconds: 120,
|
||||
}),
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const result = await fetchPromptGardenSuggestions({
|
||||
gardenBaseUrl: 'https://garden.always200.com',
|
||||
mode: 'image-image2image',
|
||||
timeoutMs: 1000,
|
||||
})
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'https://garden.always200.com/api/public/prompts/suggestions?mode=image-image2image&limit=3&strategy=mixed',
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
}),
|
||||
)
|
||||
expect(result.items).toHaveLength(2)
|
||||
expect(result.items[0]).toMatchObject({
|
||||
id: 'prompt-1',
|
||||
importCode: 'NB-001',
|
||||
tags: ['portrait', 'cinematic'],
|
||||
thumbnailUrl: 'https://garden.always200.com/prompt-assets/thumb.webp',
|
||||
})
|
||||
expect(result.items[1]).toMatchObject({
|
||||
id: 'prompt-2',
|
||||
importCode: 'NB-002',
|
||||
source: 'featured',
|
||||
})
|
||||
expect(result.browseUrl).toBe('https://garden.always200.com/prompts?mode=image-image2image')
|
||||
expect(result.nextExclude).toEqual(['NB-001', 'NB-002'])
|
||||
expect(result.ttlSeconds).toBe(120)
|
||||
})
|
||||
|
||||
it('falls back to a mode-scoped browse URL when Garden omits browseUrl', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ items: [] }),
|
||||
}),
|
||||
)
|
||||
|
||||
const result = await fetchPromptGardenSuggestions({
|
||||
gardenBaseUrl: 'https://garden.always200.com',
|
||||
mode: 'image-text2image',
|
||||
})
|
||||
|
||||
expect(result.items).toEqual([])
|
||||
expect(result.browseUrl).toBe('https://garden.always200.com/prompts?mode=image-text2image')
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user