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:
linshen committed 2026-05-10 21:23:37 +08:00
1 parent b34ca68898
commit 3f6c460154
15 files changed
+1506 -280

No files matched your search

+23 -7
View File
@@ -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')
})
})