From 3f6c460154d23d3a3162b2347e799987c9c8be98 Mon Sep 17 00:00:00 2001 From: linshen <32978552+linshenkx@users.noreply.github.com> Date: Sun, 10 May 2026 21:23:37 +0800 Subject: [PATCH] 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 --- docs/developer/prompt-garden-integration.md | 30 +- .../ui/src/components/FavoriteEditorForm.vue | 73 ++- .../common/PromptGardenInspirationPopover.vue | 574 ++++++++++++++++++ .../image-mode/ImageImage2ImageWorkspace.vue | 170 ++---- .../image-mode/ImageText2ImageWorkspace.vue | 137 +---- .../app/useAppPromptGardenImport.ts | 27 +- packages/ui/src/i18n/locales/en-US/core.ts | 9 + packages/ui/src/i18n/locales/zh-CN/core.ts | 9 + packages/ui/src/i18n/locales/zh-TW/core.ts | 9 + .../ui/src/utils/favorite-category-path.ts | 124 ++++ .../ui/src/utils/prompt-garden-suggestions.ts | 197 ++++++ .../components/FavoriteEditorForm.spec.ts | 62 ++ .../PromptGardenInspirationPopover.spec.ts | 200 ++++++ .../useAppPromptGardenImport.spec.ts | 54 +- .../utils/prompt-garden-suggestions.spec.ts | 111 ++++ 15 files changed, 1506 insertions(+), 280 deletions(-) create mode 100644 packages/ui/src/components/common/PromptGardenInspirationPopover.vue create mode 100644 packages/ui/src/utils/favorite-category-path.ts create mode 100644 packages/ui/src/utils/prompt-garden-suggestions.ts create mode 100644 packages/ui/tests/unit/components/PromptGardenInspirationPopover.spec.ts create mode 100644 packages/ui/tests/unit/utils/prompt-garden-suggestions.spec.ts diff --git a/docs/developer/prompt-garden-integration.md b/docs/developer/prompt-garden-integration.md index d4240b47..d4ef29bf 100644 --- a/docs/developer/prompt-garden-integration.md +++ b/docs/developer/prompt-garden-integration.md @@ -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` - 开发环境可临时使用 `*` 联调 diff --git a/packages/ui/src/components/FavoriteEditorForm.vue b/packages/ui/src/components/FavoriteEditorForm.vue index 55b868e9..6362f760 100644 --- a/packages/ui/src/components/FavoriteEditorForm.vue +++ b/packages/ui/src/components/FavoriteEditorForm.vue @@ -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 => + Boolean(value) && typeof value === 'object' && !Array.isArray(value) + const services = inject>('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 => { - 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, +): Promise => { 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 => } } +const ensureSaveModeCategoryId = async ( + metadata?: Record, +): Promise => { + 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) + : 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) + : 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) - : 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 diff --git a/packages/ui/src/components/common/PromptGardenInspirationPopover.vue b/packages/ui/src/components/common/PromptGardenInspirationPopover.vue new file mode 100644 index 00000000..ac91b742 --- /dev/null +++ b/packages/ui/src/components/common/PromptGardenInspirationPopover.vue @@ -0,0 +1,574 @@ + + + + + diff --git a/packages/ui/src/components/image-mode/ImageImage2ImageWorkspace.vue b/packages/ui/src/components/image-mode/ImageImage2ImageWorkspace.vue index 0f640962..17b4b71d 100644 --- a/packages/ui/src/components/image-mode/ImageImage2ImageWorkspace.vue +++ b/packages/ui/src/components/image-mode/ImageImage2ImageWorkspace.vue @@ -40,22 +40,32 @@ {{ promptSummary }} - - - + + + + + + @@ -70,6 +80,14 @@ }} + -
-
- - - -
-
- - {{ t('common.promptGarden.image2ImageGuideTitle') }} - - - {{ t('common.promptGarden.image2ImageGuideHint') }} - -
-
- - - {{ t('common.promptGarden.discoverShort') }} - - - - {{ t('common.promptGarden.importShort') }} - -
-
- { 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; - } -} diff --git a/packages/ui/src/components/image-mode/ImageText2ImageWorkspace.vue b/packages/ui/src/components/image-mode/ImageText2ImageWorkspace.vue index 0394bd4b..b8949d0d 100644 --- a/packages/ui/src/components/image-mode/ImageText2ImageWorkspace.vue +++ b/packages/ui/src/components/image-mode/ImageText2ImageWorkspace.vue @@ -41,6 +41,14 @@
+ + -
-
- - - -
-
- - {{ t('common.promptGarden.text2ImageGuideTitle') }} - - - {{ t('common.promptGarden.text2ImageGuideHint') }} - -
-
- - - {{ t('common.promptGarden.discoverShort') }} - - - - {{ t('common.promptGarden.importShort') }} - -
-
- @@ -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({ 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)); diff --git a/packages/ui/src/composables/app/useAppPromptGardenImport.ts b/packages/ui/src/composables/app/useAppPromptGardenImport.ts index 5c4e9708..4452eab4 100644 --- a/packages/ui/src/composables/app/useAppPromptGardenImport.ts +++ b/packages/ui/src/composables/app/useAppPromptGardenImport.ts @@ -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 +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, diff --git a/packages/ui/src/i18n/locales/en-US/core.ts b/packages/ui/src/i18n/locales/en-US/core.ts index 29750c05..31246f69 100644 --- a/packages/ui/src/i18n/locales/en-US/core.ts +++ b/packages/ui/src/i18n/locales/en-US/core.ts @@ -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..." diff --git a/packages/ui/src/i18n/locales/zh-CN/core.ts b/packages/ui/src/i18n/locales/zh-CN/core.ts index 8c90232b..4cab0ac4 100644 --- a/packages/ui/src/i18n/locales/zh-CN/core.ts +++ b/packages/ui/src/i18n/locales/zh-CN/core.ts @@ -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 获取提示词和图片资源..." diff --git a/packages/ui/src/i18n/locales/zh-TW/core.ts b/packages/ui/src/i18n/locales/zh-TW/core.ts index 8d5fabb0..f0c2de10 100644 --- a/packages/ui/src/i18n/locales/zh-TW/core.ts +++ b/packages/ui/src/i18n/locales/zh-TW/core.ts @@ -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 取得提示詞和圖片資源..." diff --git a/packages/ui/src/utils/favorite-category-path.ts b/packages/ui/src/utils/favorite-category-path.ts new file mode 100644 index 00000000..10b8f27d --- /dev/null +++ b/packages/ui/src/utils/favorite-category-path.ts @@ -0,0 +1,124 @@ +import type { FavoriteCategory, IFavoriteManager } from '@prompt-optimizer/core' + +export type FavoriteCategoryPathManager = Pick + +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 => + 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 => { + 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 => { + 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 +} diff --git a/packages/ui/src/utils/prompt-garden-suggestions.ts b/packages/ui/src/utils/prompt-garden-suggestions.ts new file mode 100644 index 00000000..66997f18 --- /dev/null +++ b/packages/ui/src/utils/prompt-garden-suggestions.ts @@ -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([ + 'latest', + 'random', + 'featured', + 'related', +]) + +const isRecord = (value: unknown): value is Record => + 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 => { + 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) + } +} diff --git a/packages/ui/tests/unit/components/FavoriteEditorForm.spec.ts b/packages/ui/tests/unit/components/FavoriteEditorForm.spec.ts index 01bf6f28..19f1ce3e 100644 --- a/packages/ui/tests/unit/components/FavoriteEditorForm.spec.ts +++ b/packages/ui/tests/unit/components/FavoriteEditorForm.spec.ts @@ -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() const secondCover = createDeferred() diff --git a/packages/ui/tests/unit/components/PromptGardenInspirationPopover.spec.ts b/packages/ui/tests/unit/components/PromptGardenInspirationPopover.spec.ts new file mode 100644 index 00000000..1b279e84 --- /dev/null +++ b/packages/ui/tests/unit/components/PromptGardenInspirationPopover.spec.ts @@ -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 = { + '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' }, + ) + }) +}) diff --git a/packages/ui/tests/unit/composables/useAppPromptGardenImport.spec.ts b/packages/ui/tests/unit/composables/useAppPromptGardenImport.spec.ts index b93fd41d..60d02226 100644 --- a/packages/ui/tests/unit/composables/useAppPromptGardenImport.spec.ts +++ b/packages/ui/tests/unit/composables/useAppPromptGardenImport.spec.ts @@ -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 >(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 >(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 => []), + getCategories: vi.fn(async () => []), + addCategory: vi.fn(async ({ name }: { name: string }) => `cat-${name}`), addFavorite: vi.fn(async ( _favorite: Omit ) => '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 const snapshot = metadata.gardenSnapshot as Record @@ -1791,6 +1804,23 @@ describe('useAppPromptGardenImport', () => { const favoriteManager = { getFavorites: vi.fn(async (): Promise => []), + 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 ) => '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 => []), + getCategories: vi.fn(async () => []), + addCategory: vi.fn(async ({ name }: { name: string }) => `cat-${name}`), addFavorite: vi.fn(async ( _favorite: Omit ) => 'fav-new'), @@ -2227,6 +2261,8 @@ describe('useAppPromptGardenImport', () => { const favoriteManager = { getFavorites: vi.fn(async (): Promise => []), + getCategories: vi.fn(async () => []), + addCategory: vi.fn(async ({ name }: { name: string }) => `cat-${name}`), addFavorite: vi.fn(async ( _favorite: Omit ) => 'fav-new'), @@ -2359,6 +2395,8 @@ describe('useAppPromptGardenImport', () => { getFavorites: vi.fn(async (): Promise => [ existingFavorite, ]), + getCategories: vi.fn(async () => []), + addCategory: vi.fn(async ({ name }: { name: string }) => `cat-${name}`), addFavorite: vi.fn(async ( _favorite: Omit ) => 'fav-new'), @@ -2505,6 +2543,8 @@ describe('useAppPromptGardenImport', () => { const favoriteManager = { getFavorites: vi.fn(async (): Promise => []), + 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') }), diff --git a/packages/ui/tests/unit/utils/prompt-garden-suggestions.spec.ts b/packages/ui/tests/unit/utils/prompt-garden-suggestions.spec.ts new file mode 100644 index 00000000..3f0598ac --- /dev/null +++ b/packages/ui/tests/unit/utils/prompt-garden-suggestions.spec.ts @@ -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') + }) +})