feat(prompt-garden): integrate import and favorite flow

Wire Prompt Garden imports into workspace and favorite flows, add the import dialog/panel affordances, persist Garden metadata/media into favorites, and document the external import contract.
This commit is contained in:
linshen committed 2026-05-03 13:15:16 +08:00
1 parent 98c85c64bc
commit 6d75528793
17 files changed
+499 -71

No files matched your search

+17 -7
View File
@@ -6,7 +6,7 @@
- URL 只携带少量路由参数,由 Prompt Optimizer 自己去 Garden 拉完整内容
- Prompt Optimizer 固定从 `VITE_PROMPT_GARDEN_BASE_URL` 拉取内容,不接受 URL 参数覆盖
- Garden API 返回统一的 v1 schema,由 Prompt Optimizer 按子模式写入不同 session store
- Garden API 返回统一的 v1 schema;普通导入会由 Prompt Optimizer 按子模式写入不同 session store
- Garden 的扩展元数据和素材快照可以跟随收藏一起保存,便于后续预览和复用
## 1. Prompt Optimizer 侧:导入触发与 URL 参数
@@ -21,19 +21,23 @@ Prompt Optimizer 在应用初始 session 恢复完成后检查当前路由 query
- `importCode`(必填)
- 外部提示词的唯一标识,例如 `NB-001`
- 可在导入码后追加示例选择后缀,例如 `NB-001@ex-2`
- 追加后缀时,Prompt Optimizer 仍请求 `GET /api/prompt-source/NB-001`,并把 `ex-2` 当作本次导入的示例选择
- `subModeKey`(可选)
- 显式指定导入目标工作区
- 若未提供,则优先使用 Garden 返回的 `optimizerTarget.subModeKey`
- 若 Garden 返回值无效,则退回当前路由;当前路由也无效时默认落到 `basic-system`
- `exampleId`(可选)
- 指定使用哪一个示例
- 若 `importCode` 同时带 `@exampleId` 后缀,显式 URL 参数 `exampleId` 优先
- 若未提供,则默认使用 `assets.examples[0]`
- 仅用于示例参数和 image2image 输入图回填,不改变 prompt 主体
- `saveToFavorites`(可选)
- 控制导入后是否联动收藏
- 控制本次导入是否进入收藏流程
- `1` / `true` / `auto` -> 自动保存到收藏
- `confirm` / `dialog` / `manual` -> 打开“保存收藏”对话框,并带预填数据
- 其它值或省略 -> 不触发收藏保存
- 只要启用 `saveToFavorites`,本次导入只处理收藏,不写入或覆盖当前工作区
- 其它值或省略 -> 作为“使用”导入,写入目标工作区,不触发收藏保存
### 1.2 `subModeKey` 支持值
@@ -52,7 +56,10 @@ Prompt Optimizer 在应用初始 session 恢复完成后检查当前路由 query
- 导入到 `pro-multi`,并显式指定示例:
- `https://prompt.example.com/#/pro/multi?importCode=NB-001&exampleId=ex-2`
- 导入到 `image-image2image`,并在导入后弹出保存收藏对话框:
- 导入到 `pro-multi`,并通过导入码后缀指定示例:
- `https://prompt.example.com/#/pro/multi?importCode=NB-001@ex-2`
- 从 `image-image2image` 类型的 Garden 提示词导入为收藏,并弹出保存收藏对话框:
- `https://prompt.example.com/#/image/image2image?importCode=NB-001&saveToFavorites=confirm`
- 若希望 query 明确覆盖目标工作区:
@@ -61,7 +68,8 @@ Prompt Optimizer 在应用初始 session 恢复完成后检查当前路由 query
说明:
- 推荐 Garden 直接打开目标工作区路由,而不是总是打开根路由再依赖 `subModeKey`
- `subModeKey` 只用于决定写入哪个工作区,不决定 API 返回结构
- 不带 `saveToFavorites` 时,`subModeKey` 用于决定写入哪个工作区
- 带 `saveToFavorites` 时,`subModeKey` 只用于收藏的模式预填充,不会触发工作区写入
## 2. Prompt Garden 侧:必须提供的 API
@@ -393,8 +401,9 @@ Prompt Optimizer 会调用:
当 `saveToFavorites=1|true|auto` 时:
- Prompt Optimizer 会尝试自动保存到收藏
- 收藏内容来自导入后的 prompt
- 收藏内容来自 Garden 返回的 prompt
- `meta` 和 `assets` 会作为 `gardenSnapshot` 一起写入收藏 metadata
- 不会写入或覆盖当前工作区
### 8.2 确认保存
@@ -402,6 +411,7 @@ Prompt Optimizer 会调用:
- Prompt Optimizer 会打开“保存收藏”对话框
- 自动带入标题、描述、标签、分类、模式信息和 `gardenSnapshot`
- 不会写入或覆盖当前工作区
### 8.3 去重与更新规则
@@ -417,7 +427,7 @@ Garden 联动保存收藏时,不按收藏内容去重,而按下面的组合
## 9. 工作区写入时会被重置的状态
导入成功后,Prompt Optimizer 不只是更新 prompt,还会清理与旧工作区状态绑定的内容。
当 URL 不带 `saveToFavorites` 时,导入成功后 Prompt Optimizer 不只是更新 prompt,还会清理与旧工作区状态绑定的内容。
例如:
@@ -14,6 +14,7 @@ import {
} from './errors';
import { FAVORITE_ERROR_CODES } from '../../constants/error-codes'
import { toErrorWithCode } from '../../utils/error'
import { safeSerializeArgs } from '../../utils/ipc-serialization'
declare const window: {
electronAPI: {
@@ -39,7 +40,8 @@ export class FavoriteManagerElectronProxy implements IFavoriteManager {
private async invokeMethod<T>(method: string, ...args: any[]): Promise<T> {
this.ensureApiAvailable();
try {
return await (window.electronAPI.favoriteManager as any)[method](...args);
const safeArgs = safeSerializeArgs(...args);
return await (window.electronAPI.favoriteManager as any)[method](...safeArgs);
} catch (error: any) {
// New i18n-style structured errors: pass through as-is so UI can translate via `code + params`.
if (typeof error?.code === 'string' && error.code.startsWith('error.')) {
@@ -0,0 +1,55 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { FavoriteManagerElectronProxy } from '../../../src/services/favorite/electron-proxy'
describe('FavoriteManagerElectronProxy', () => {
afterEach(() => {
vi.restoreAllMocks()
delete (globalThis as typeof globalThis & { window?: unknown }).window
})
it('serializes favorite payloads before sending them over Electron IPC', async () => {
const addFavorite = vi.fn().mockResolvedValue('favorite-1')
;(globalThis as typeof globalThis & { window?: unknown }).window = {
electronAPI: {
favoriteManager: {
addFavorite,
},
},
}
const nonCloneableMetadata = {
media: {
coverUrl: 'data:image/png;base64,abc',
assetIds: ['asset-1'],
},
droppedCallback: () => 'not cloneable',
}
const proxiedMetadata = new Proxy(nonCloneableMetadata, {})
const manager = new FavoriteManagerElectronProxy()
const id = await manager.addFavorite({
title: 'Garden image prompt',
content: 'A quiet botanical workspace',
tags: ['garden'],
functionMode: 'image',
imageSubMode: 'text2image',
metadata: proxiedMetadata,
})
expect(id).toBe('favorite-1')
expect(addFavorite).toHaveBeenCalledOnce()
expect(addFavorite).toHaveBeenCalledWith({
title: 'Garden image prompt',
content: 'A quiet botanical workspace',
tags: ['garden'],
functionMode: 'image',
imageSubMode: 'text2image',
metadata: {
media: {
coverUrl: 'data:image/png;base64,abc',
assetIds: ['asset-1'],
},
},
})
})
})
@@ -13,6 +13,9 @@
<NSpace :size="16" align="center" wrap>
<NRadio value="file">{{ t('favorites.manager.importDialog.sourceFile') }}</NRadio>
<NRadio value="paste">{{ t('favorites.manager.importDialog.sourcePaste') }}</NRadio>
<NRadio v-if="isPromptGardenEnabled" value="garden">
{{ t('favorites.manager.importDialog.sourceGarden') }}
</NRadio>
</NSpace>
</NRadioGroup>
@@ -65,6 +68,32 @@
</NUpload>
</div>
<div v-else-if="source === 'garden'" class="favorite-import-panel__garden">
<div class="favorite-import-panel__garden-guide">
<NIcon size="22">
<Plant2 />
</NIcon>
<div class="favorite-import-panel__garden-copy">
<NText strong>{{ t('favorites.manager.importDialog.gardenTitle') }}</NText>
<NText depth="3">{{ t('favorites.manager.importDialog.gardenHint') }}</NText>
</div>
<NButton secondary size="small" @click="handlePromptGardenDiscover">
<template #icon>
<NIcon>
<ExternalLink />
</NIcon>
</template>
{{ t('favorites.manager.importDialog.gardenDiscover') }}
</NButton>
</div>
<NInput
v-model:value="gardenImportInput"
:placeholder="t('favorites.manager.importDialog.gardenPlaceholder')"
clearable
/>
</div>
<NInput
v-else
v-model:value="rawJson"
@@ -76,6 +105,7 @@
</NCard>
<NCard
v-if="source !== 'garden'"
size="small"
:title="t('favorites.manager.importDialog.mergeStrategy')"
:segmented="{ content: true }"
@@ -119,6 +149,7 @@
<script setup lang="ts">
import { computed, inject, ref, type Ref } from 'vue'
import { useRouter } from 'vue-router'
import {
NButton,
@@ -135,12 +166,14 @@ import {
NUploadDragger,
type UploadFileInfo,
} from 'naive-ui'
import { Upload } from '@vicons/tabler'
import { ExternalLink, Plant2, Upload } from '@vicons/tabler'
import { useI18n } from 'vue-i18n'
import { getEnvVar } from '@prompt-optimizer/core'
import { useToast } from '../composables/ui/useToast'
import type { AppServices } from '../types/services'
import { getI18nErrorMessage } from '../utils/error'
import { normalizePromptGardenImportCode } from '../utils/prompt-garden-import'
import {
importFavoriteResourcePackage,
looksLikeFavoriteZipPackage,
@@ -148,6 +181,7 @@ import {
} from '../utils/favorite-resource-package'
const { t } = useI18n()
const router = useRouter()
const message = useToast()
const services = inject<Ref<AppServices | null> | null>('services', null)
@@ -156,13 +190,24 @@ const emit = defineEmits<{
'imported': []
}>()
const source = ref<'file' | 'paste'>('file')
const source = ref<'file' | 'paste' | 'garden'>('file')
const rawJson = ref('')
const gardenImportInput = ref('')
const mergeStrategy = ref<'skip' | 'overwrite' | 'merge'>('skip')
const fileList = ref<UploadFileInfo[]>([])
const importing = ref(false)
const selectedFile = computed(() => fileList.value[0] || null)
const normalizedGardenImportCode = computed(() => normalizePromptGardenImportCode(gardenImportInput.value))
const isPromptGardenEnabled = computed(() => {
const value = getEnvVar('VITE_ENABLE_PROMPT_GARDEN_IMPORT').trim().toLowerCase()
return value === '1' || value === 'true'
})
const promptGardenBaseUrl = computed(() => {
return getEnvVar('VITE_PROMPT_GARDEN_BASE_URL').trim().replace(/\/$/, '')
})
const mergeStrategyHint = computed(() => {
if (mergeStrategy.value === 'overwrite') {
@@ -184,6 +229,27 @@ const handleFileChange = (options: UploadChangeParam) => {
fileList.value = options.fileList.slice(0, 1)
}
const openExternalUrl = async (url: string) => {
if (!url) return
if (typeof window !== 'undefined' && window.electronAPI?.shell) {
try {
await window.electronAPI.shell.openExternal(url)
return
} catch (error) {
console.error('[PromptGarden] Failed to open external URL in Electron:', error)
}
}
if (typeof window !== 'undefined') {
window.open(url, '_blank')
}
}
const handlePromptGardenDiscover = () => {
void openExternalUrl(promptGardenBaseUrl.value)
}
const readFileAsArrayBuffer = (file: File) =>
new Promise<ArrayBuffer>((resolve, reject) => {
const reader = new FileReader()
@@ -235,6 +301,33 @@ const buildPackageImportWarning = (result: FavoriteResourcePackageImportResult):
}
const handleImportConfirm = async () => {
if (source.value === 'garden') {
const importCode = normalizedGardenImportCode.value
if (!importCode) {
message.warning(t('favorites.manager.importDialog.gardenCodeRequired'))
return
}
importing.value = true
try {
const currentRoute = router.currentRoute.value
await router.push({
path: currentRoute.path,
query: {
...currentRoute.query,
importCode,
saveToFavorites: 'confirm',
},
})
emit('cancel')
} catch (error) {
message.error(buildErrorMessage(t('favorites.manager.importDialog.importFailed'), error))
} finally {
importing.value = false
}
return
}
const servicesValue = services?.value
if (!servicesValue?.favoriteManager) {
message.warning(t('favorites.manager.messages.unavailable'))
@@ -330,10 +423,33 @@ const handleImportConfirm = async () => {
}
.favorite-import-panel__section,
.favorite-import-panel__garden,
.favorite-import-panel__strategy-list {
width: 100%;
}
.favorite-import-panel__garden {
display: grid;
gap: 12px;
}
.favorite-import-panel__garden-guide {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 10px;
align-items: center;
padding: 12px;
border: 1px solid var(--n-border-color);
border-radius: 8px;
background: color-mix(in srgb, var(--n-success-color) 5%, transparent);
}
.favorite-import-panel__garden-copy {
display: grid;
gap: 2px;
min-width: 0;
}
.favorite-import-panel__upload {
padding: 18px 12px;
}
@@ -376,5 +492,14 @@ const handleImportConfirm = async () => {
.favorite-import-panel__actions {
padding: 14px 16px;
}
.favorite-import-panel__garden-guide {
grid-template-columns: auto 1fr;
}
.favorite-import-panel__garden-guide .n-button {
grid-column: 1 / -1;
justify-self: start;
}
}
</style>
@@ -2,8 +2,8 @@
<NModal
:show="show"
preset="dialog"
:title="t('common.promptGarden.importTitle')"
:positive-text="t('common.import')"
:title="title || t('common.promptGarden.importTitle')"
:positive-text="positiveText || t('common.import')"
:negative-text="t('common.cancel')"
:positive-button-props="{ disabled: !normalizedImportCode }"
:show-icon="false"
@@ -15,7 +15,7 @@
>
<div class="prompt-garden-import-dialog">
<p class="prompt-garden-import-hint">
{{ t('common.promptGarden.importHint') }}
{{ hint || t('common.promptGarden.importHint') }}
</p>
<NInput
v-model:value="inputValue"
@@ -33,9 +33,13 @@
import { computed, ref } from 'vue'
import { NInput, NModal } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { normalizePromptGardenImportCode } from '../../utils/prompt-garden-import'
defineProps<{
show: boolean
title?: string
hint?: string
positiveText?: string
}>()
const emit = defineEmits<{
@@ -46,27 +50,6 @@ const emit = defineEmits<{
const { t } = useI18n()
const inputValue = ref('')
const normalizePromptGardenImportCode = (value: string): string => {
const trimmed = value.trim()
if (!trimmed) return ''
try {
const url = new URL(trimmed)
const directImportCode = url.searchParams.get('importCode')?.trim()
if (directImportCode) return directImportCode
const hashQueryIndex = url.hash.indexOf('?')
if (hashQueryIndex >= 0) {
const hashQuery = new URLSearchParams(url.hash.slice(hashQueryIndex + 1))
return hashQuery.get('importCode')?.trim() || trimmed
}
} catch {
// Plain import codes are expected; non-URL values are used as-is.
}
return trimmed
}
const normalizedImportCode = computed(() =>
normalizePromptGardenImportCode(inputValue.value)
)
@@ -97,6 +97,8 @@
<PromptGardenImportDialog
v-model:show="showPromptGardenImport"
:title="promptGardenImportDialogTitle"
:hint="promptGardenImportDialogHint"
@confirm="handleConfirmPromptGardenImport"
/>
</template>
@@ -104,8 +106,9 @@
<script setup lang="ts">
import { computed, h, inject, nextTick, onMounted, onUnmounted, ref, type CSSProperties } from 'vue'
import { routerKey } from 'vue-router'
import type { LocationQueryRaw } from 'vue-router'
import { NButton, NDropdown, NIcon, NModal, type DropdownOption } from 'naive-ui'
import { ClearAll, DotsVertical, ExternalLink, Plant2, FileImport } from '@vicons/tabler'
import { Bookmark, ClearAll, DotsVertical, ExternalLink, Plant2, FileImport } from '@vicons/tabler'
import { useI18n } from 'vue-i18n'
import { getEnvVar } from '@prompt-optimizer/core'
import SourceAssetBadge from '../source/SourceAssetBadge.vue'
@@ -131,6 +134,7 @@ const { t } = useI18n()
const router = inject(routerKey, null)
const showClearConfirm = ref(false)
const showPromptGardenImport = ref(false)
const promptGardenImportIntent = ref<'use' | 'favorite'>('use')
const triggerStyle = ref<CSSProperties>({})
let placementResizeObserver: ResizeObserver | null = null
@@ -221,8 +225,25 @@ const gardenMenuOptions = computed<DropdownOption[]>(() => [
label: t('common.promptGarden.importPrompt'),
icon: () => h(NIcon, null, { default: () => h(FileImport) }),
},
{
key: 'import-favorite',
label: t('common.promptGarden.importFavorite'),
icon: () => h(NIcon, null, { default: () => h(Bookmark) }),
},
])
const promptGardenImportDialogTitle = computed(() =>
promptGardenImportIntent.value === 'favorite'
? t('common.promptGarden.importFavoriteTitle')
: t('common.promptGarden.importTitle'),
)
const promptGardenImportDialogHint = computed(() =>
promptGardenImportIntent.value === 'favorite'
? t('common.promptGarden.importFavoriteHint')
: t('common.promptGarden.importHint'),
)
const handleSelect = (key: string) => {
if (key === 'clear-content') {
showClearConfirm.value = true
@@ -236,6 +257,12 @@ const handleGardenSelect = (key: string) => {
}
if (key === 'import-code') {
promptGardenImportIntent.value = 'use'
showPromptGardenImport.value = true
}
if (key === 'import-favorite') {
promptGardenImportIntent.value = 'favorite'
showPromptGardenImport.value = true
}
}
@@ -248,12 +275,20 @@ const handleConfirmPromptGardenImport = async (importCode: string) => {
if (!importCode || !router) return false
const currentRoute = router.currentRoute.value
const query: LocationQueryRaw = {
...currentRoute.query,
importCode,
}
if (promptGardenImportIntent.value === 'favorite') {
query.saveToFavorites = 'confirm'
} else {
delete query.saveToFavorites
}
await router.push({
path: currentRoute.path,
query: {
...currentRoute.query,
importCode,
},
query,
})
showPromptGardenImport.value = false
@@ -60,6 +60,31 @@ const getQueryString = (query: LocationQuery, key: string): string | null => {
return null
}
const parseImportCodeSelector = (value: string): { importCode: string; exampleId: string | null } => {
const trimmed = value.trim()
const separatorIndex = trimmed.lastIndexOf('@')
if (separatorIndex <= 0 || separatorIndex === trimmed.length - 1) {
return {
importCode: trimmed,
exampleId: null,
}
}
const importCode = trimmed.slice(0, separatorIndex).trim()
const exampleId = trimmed.slice(separatorIndex + 1).trim()
if (!importCode || !exampleId) {
return {
importCode: trimmed,
exampleId: null,
}
}
return {
importCode,
exampleId,
}
}
const omitKeys = (query: LocationQuery, keys: string[]): LocationQuery => {
const next: Record<string, unknown> = { ...query }
for (const k of keys) {
@@ -1053,10 +1078,13 @@ export function useAppPromptGardenImport(options: AppPromptGardenImportOptions)
const currentRoute = router.currentRoute.value
const query = currentRoute.query
const importCode = getQueryString(query, 'importCode')
const rawImportCode = getQueryString(query, 'importCode')
const parsedImportCode = rawImportCode ? parseImportCodeSelector(rawImportCode) : null
const importCode = parsedImportCode?.importCode
if (!importCode) return
const exampleId = getQueryString(query, 'exampleId')
const explicitExampleId = getQueryString(query, 'exampleId')?.trim() || null
const exampleId = explicitExampleId ?? parsedImportCode.exampleId
const saveToFavoritesMode = parseSaveToFavoritesMode(getQueryString(query, 'saveToFavorites'))
inFlight.value = true
@@ -1071,6 +1099,76 @@ export function useAppPromptGardenImport(options: AppPromptGardenImportOptions)
const targetKey = resolveTargetKey(query, currentRoute.path, fetched.optimizerTargetKey)
if (saveToFavoritesMode !== 'none') {
if (saveToFavoritesMode === 'auto') {
const favoriteManager = getFavoriteManager?.() || null
const imageStorageService =
getFavoriteImageStorageService?.() || getImageStorageService?.() || null
if (favoriteManager) {
try {
await saveImportedPromptToFavorites({
manager: favoriteManager,
imageStorageService,
fetched,
targetKey,
})
} catch (error) {
toast.warning(String(i18n.global.t('toast.warning.promptGardenFavoriteSaveFailed')))
}
} else {
console.warn('[PromptGardenImport] Favorite manager unavailable, skip auto-save')
}
} else {
const content = buildFavoriteContentFromFetchedPrompt(fetched)
if (!content) {
console.warn('[PromptGardenImport] Skip favorite dialog: imported content is empty')
} else if (!openSaveFavoriteDialog) {
console.warn('[PromptGardenImport] Favorite dialog callback unavailable, skip confirm flow')
} else {
const modeMapping = toFavoriteModeMapping(targetKey)
const imageStorageService =
getFavoriteImageStorageService?.() || getImageStorageService?.() || null
let snapshot = fetched.gardenSnapshot
try {
snapshot = await buildStorableGardenSnapshot(snapshot, imageStorageService, {
allowImageFallback: true,
})
} catch (error) {
console.info('[PromptGardenImport] Failed to persist snapshot assets for favorite dialog:', error)
}
const media = buildFavoriteMediaFromSnapshot(snapshot)
const metadata: Record<string, unknown> = {
gardenSnapshot: snapshot,
...(media ? { media } : {}),
}
openSaveFavoriteDialog({
content,
originalContent: content,
prefill: {
title: deriveFavoriteTitle(fetched),
description: deriveFavoriteDescription(fetched),
category: deriveFavoriteCategory(fetched),
tags: deriveFavoriteTags(fetched),
functionMode: modeMapping.functionMode,
optimizationMode: modeMapping.optimizationMode,
imageSubMode: modeMapping.imageSubMode,
metadata,
},
})
}
}
const cleanedQuery = omitKeys(query, ['importCode', 'subModeKey', 'exampleId', 'saveToFavorites'])
await router.replace({ path: router.currentRoute.value.path, query: cleanedQuery })
await nextTick()
toast.success(String(i18n.global.t('toast.success.promptGardenImportSuccess')))
return
}
// If caller opened the wrong workspace, navigate first.
const targetPath = keyToPath(targetKey)
if (router.currentRoute.value.path !== targetPath) {
@@ -41,8 +41,11 @@ const messages = {
"title": "Prompt Garden",
"discover": "Discover Garden Prompts",
"importPrompt": "Import Garden Prompt",
"importFavorite": "Import as Favorite",
"importTitle": "Import Prompt Garden Prompt",
"importHint": "Paste the import code copied from Prompt Garden, or paste a link that contains importCode.",
"importFavoriteTitle": "Import Prompt Garden Favorite",
"importFavoriteHint": "Paste a Prompt Garden import code or link. A save-favorite confirmation will open after import.",
"importPlaceholder": "Enter import code",
"text2ImageGuideTitle": "Discover text-to-image prompts in Prompt Garden",
"text2ImageGuideHint": "When you need a starting point, browse Garden prompts or paste an import code into this workspace.",
@@ -64,15 +64,21 @@ const messages = {
"emptyDescription": "No favorites yet",
"importDialog": {
"title": "Import Favorites",
"sourceLabel": "Data Source",
"sourceFile": "Upload File",
"sourcePaste": "Paste JSON",
"selectFile": "Select package or JSON file",
"uploadHint": "Click or drag file to this area",
"supportFormat": "Supports .po-favorites.zip packages and legacy .json files",
"orPasteJson": "Or paste exported favorites JSON",
"pastePlaceholder": "Paste favorites data...",
"mergeStrategy": "Merge Strategy",
"sourceLabel": "Data Source",
"sourceFile": "Upload File",
"sourcePaste": "Paste JSON",
"sourceGarden": "Prompt Garden",
"selectFile": "Select package or JSON file",
"uploadHint": "Click or drag file to this area",
"supportFormat": "Supports .po-favorites.zip packages and legacy .json files",
"orPasteJson": "Or paste exported favorites JSON",
"pastePlaceholder": "Paste favorites data...",
"gardenTitle": "Import from Prompt Garden",
"gardenHint": "Browse Prompt Garden for text-to-image prompts, then paste an import code or importCode link to save it as a favorite.",
"gardenDiscover": "Discover Garden",
"gardenPlaceholder": "Enter an import code, e.g. ZH-NB-001@ex-001",
"gardenCodeRequired": "Paste a Prompt Garden import code first",
"mergeStrategy": "Merge Strategy",
"skipDuplicate": "Skip Duplicates",
"overwriteDuplicate": "Overwrite Duplicates",
"createCopy": "Create Copy",
@@ -39,8 +39,11 @@ const messages = {
"title": "Prompt Garden",
"discover": "发现花园提示词",
"importPrompt": "导入花园提示词",
"importFavorite": "导入为收藏",
"importTitle": "导入 Prompt Garden 提示词",
"importHint": "粘贴从 Prompt Garden 复制的导入码,也可以粘贴包含 importCode 的链接。",
"importFavoriteTitle": "从 Prompt Garden 导入收藏",
"importFavoriteHint": "粘贴 Prompt Garden 导入码或链接,导入后会打开收藏保存确认。",
"importPlaceholder": "输入导入码",
"text2ImageGuideTitle": "从 Prompt Garden 发现文生图提示词",
"text2ImageGuideHint": "没有灵感时,可以先去花园挑一个提示词,或粘贴导入码直接带入当前工作区。",
@@ -64,15 +64,21 @@ const messages = {
"emptyDescription": "还没有收藏任何提示词",
"importDialog": {
"title": "导入收藏",
"sourceLabel": "数据来源",
"sourceFile": "上传文件",
"sourcePaste": "粘贴 JSON",
"selectFile": "选择资源包或 JSON 文件",
"uploadHint": "点击或拖拽文件到此区域",
"supportFormat": "支持 .po-favorites.zip 资源包和历史 .json 文件",
"orPasteJson": "或粘贴导出的收藏 JSON",
"pastePlaceholder": "粘贴收藏数据...",
"mergeStrategy": "合并策略",
"sourceLabel": "数据来源",
"sourceFile": "上传文件",
"sourcePaste": "粘贴 JSON",
"sourceGarden": "Prompt Garden",
"selectFile": "选择资源包或 JSON 文件",
"uploadHint": "点击或拖拽文件到此区域",
"supportFormat": "支持 .po-favorites.zip 资源包和历史 .json 文件",
"orPasteJson": "或粘贴导出的收藏 JSON",
"pastePlaceholder": "粘贴收藏数据...",
"gardenTitle": "从 Prompt Garden 导入收藏",
"gardenHint": "可以先去 Garden 发现文生图提示词,再粘贴导入码或包含 importCode 的链接保存到收藏。",
"gardenDiscover": "去 Garden 发现",
"gardenPlaceholder": "输入导入码,例如 ZH-NB-001@ex-001",
"gardenCodeRequired": "请先粘贴 Prompt Garden 导入码",
"mergeStrategy": "合并策略",
"skipDuplicate": "跳过重复",
"overwriteDuplicate": "覆盖重复",
"createCopy": "创建副本",
@@ -39,8 +39,11 @@ const messages = {
"title": "Prompt Garden",
"discover": "發現花園提示詞",
"importPrompt": "匯入花園提示詞",
"importFavorite": "匯入為收藏",
"importTitle": "匯入 Prompt Garden 提示詞",
"importHint": "貼上從 Prompt Garden 複製的匯入碼,也可以貼上包含 importCode 的連結。",
"importFavoriteTitle": "從 Prompt Garden 匯入收藏",
"importFavoriteHint": "貼上 Prompt Garden 匯入碼或連結,匯入後會開啟收藏儲存確認。",
"importPlaceholder": "輸入匯入碼",
"text2ImageGuideTitle": "從 Prompt Garden 發現文生圖提示詞",
"text2ImageGuideHint": "沒有靈感時,可以先去花園挑一個提示詞,或貼上匯入碼直接帶入目前工作區。",
@@ -64,15 +64,21 @@ const messages = {
"emptyDescription": "還沒有收藏任何提示詞",
"importDialog": {
"title": "匯入收藏",
"sourceLabel": "資料來源",
"sourceFile": "上傳檔案",
"sourcePaste": "貼上 JSON",
"selectFile": "選擇資源包或 JSON 檔案",
"uploadHint": "點選或拖曳檔案到此區域",
"supportFormat": "支援 .po-favorites.zip 資源包與歷史 .json 檔案",
"orPasteJson": "或貼上匯出的收藏 JSON",
"pastePlaceholder": "貼上收藏資料...",
"mergeStrategy": "合併策略",
"sourceLabel": "資料來源",
"sourceFile": "上傳檔案",
"sourcePaste": "貼上 JSON",
"sourceGarden": "Prompt Garden",
"selectFile": "選擇資源包或 JSON 檔案",
"uploadHint": "點選或拖曳檔案到此區域",
"supportFormat": "支援 .po-favorites.zip 資源包與歷史 .json 檔案",
"orPasteJson": "或貼上匯出的收藏 JSON",
"pastePlaceholder": "貼上收藏資料...",
"gardenTitle": "從 Prompt Garden 匯入收藏",
"gardenHint": "可以先去 Garden 發現文生圖提示詞,再貼上匯入碼或包含 importCode 的連結儲存到收藏。",
"gardenDiscover": "去 Garden 發現",
"gardenPlaceholder": "輸入匯入碼,例如 ZH-NB-001@ex-001",
"gardenCodeRequired": "請先貼上 Prompt Garden 匯入碼",
"mergeStrategy": "合併策略",
"skipDuplicate": "跳過重複",
"overwriteDuplicate": "覆蓋重複",
"createCopy": "建立副本",
@@ -0,0 +1,20 @@
export const normalizePromptGardenImportCode = (value: string): string => {
const trimmed = value.trim()
if (!trimmed) return ''
try {
const url = new URL(trimmed)
const directImportCode = url.searchParams.get('importCode')?.trim()
if (directImportCode) return directImportCode
const hashQueryIndex = url.hash.indexOf('?')
if (hashQueryIndex >= 0) {
const hashQuery = new URLSearchParams(url.hash.slice(hashQueryIndex + 1))
return hashQuery.get('importCode')?.trim() || trimmed
}
} catch {
// Plain import codes are expected; non-URL values are used as-is.
}
return trimmed
}
@@ -13,10 +13,28 @@ const toastMock = vi.hoisted(() => ({
info: vi.fn(),
}))
const { pushMock, currentRoute } = vi.hoisted(() => {
const pushMock = vi.fn()
const currentRoute = {
value: {
path: '/favorites',
query: { keep: '1' },
},
}
return { pushMock, currentRoute }
})
vi.mock('../../../src/composables/ui/useToast', () => ({
useToast: () => toastMock,
}))
vi.mock('vue-router', () => ({
useRouter: () => ({
currentRoute,
push: pushMock,
}),
}))
const naiveStubs = {
NButton: {
name: 'NButton',
@@ -110,6 +128,11 @@ describe('FavoriteImportPanel', () => {
toastMock.error.mockReset()
toastMock.warning.mockReset()
toastMock.info.mockReset()
pushMock.mockReset()
currentRoute.value = {
path: '/favorites',
query: { keep: '1' },
}
})
it('keeps legacy JSON file import on the file path', async () => {
@@ -193,4 +216,25 @@ describe('FavoriteImportPanel', () => {
expect(order).toEqual(['save:cover-asset', 'import:favorites'])
expect(wrapper.emitted('imported')).toHaveLength(1)
})
it('routes Prompt Garden import codes to the save-favorite flow', async () => {
const wrapper = mountPanel({})
;(wrapper.vm as unknown as { source: string; gardenImportInput: string }).source = 'garden'
;(wrapper.vm as unknown as { source: string; gardenImportInput: string }).gardenImportInput =
'https://prompt.local/#/image/text2image?importCode=ZH-NB-001@ex-001'
await (wrapper.vm as unknown as { handleImportConfirm: () => Promise<void> }).handleImportConfirm()
await flushPromises()
expect(pushMock).toHaveBeenCalledWith({
path: '/favorites',
query: {
keep: '1',
importCode: 'ZH-NB-001@ex-001',
saveToFavorites: 'confirm',
},
})
expect(wrapper.emitted('cancel')).toHaveLength(1)
})
})
@@ -36,8 +36,11 @@ vi.mock('vue-i18n', () => ({
'common.promptGarden.title': 'Prompt Garden',
'common.promptGarden.discover': 'Discover Garden Prompts',
'common.promptGarden.importPrompt': 'Import Garden Prompt',
'common.promptGarden.importFavorite': 'Import as Favorite',
'common.promptGarden.importTitle': 'Import Prompt Garden Prompt',
'common.promptGarden.importHint': 'Paste import code',
'common.promptGarden.importFavoriteTitle': 'Import Prompt Garden Favorite',
'common.promptGarden.importFavoriteHint': 'Paste import code and save favorite',
'common.promptGarden.importPlaceholder': 'Enter import code',
'common.workspaceTools': 'Workspace Tools',
'common.clearContent': 'Clear Content',
@@ -211,6 +214,7 @@ describe('WorkspaceUtilityMenu Prompt Garden actions', () => {
expect(wrapper.find('[data-testid="workspace-prompt-garden-menu"]').exists()).toBe(true)
expect(wrapper.text()).toContain('Discover Garden Prompts')
expect(wrapper.text()).toContain('Import Garden Prompt')
expect(wrapper.text()).toContain('Import as Favorite')
await wrapper.find('[data-dropdown-key="discover"]').trigger('click')
@@ -234,4 +238,25 @@ describe('WorkspaceUtilityMenu Prompt Garden actions', () => {
},
})
})
it('can import a pasted Garden link as a favorite through the route query', async () => {
const wrapper = mountComponent()
await wrapper.find('[data-dropdown-key="import-favorite"]').trigger('click')
expect(wrapper.text()).toContain('Import Prompt Garden Favorite')
await wrapper.find('[data-testid="workspace-prompt-garden-import-code"]').setValue(
'https://prompt.always200.com/#/image/text2image?importCode=garden_fav',
)
await wrapper.find('[data-testid="modal-positive"]').trigger('click')
expect(pushMock).toHaveBeenCalledWith({
path: '/image/text2image',
query: {
keep: '1',
importCode: 'garden_fav',
saveToFavorites: 'confirm',
},
})
})
})
@@ -1005,7 +1005,7 @@ describe('useAppPromptGardenImport', () => {
}
})
it('applies selected exampleId parameters when provided', async () => {
it('applies selected inline example suffix parameters when provided', async () => {
const { pinia } = createTestPinia()
// Avoid console.warn from useToast (tests fail on console.warn).
@@ -1031,8 +1031,7 @@ describe('useAppPromptGardenImport', () => {
const isLoadingExternalData = ref(false)
const query: LocationQuery = {
importCode: 'NB-PVAR-EX-002',
exampleId: 'ex-b',
importCode: 'NB-PVAR-EX-002@ex-b',
}
const currentRoute = ref<RouteLocationNormalizedLoaded>(makeRoute('/basic/system', query))
@@ -1110,6 +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(proVariableSession.getTemporaryVariable('name')).toBe('Charlie')
// Import params removed from the URL.
@@ -1747,6 +1747,7 @@ describe('useAppPromptGardenImport', () => {
})
const basicSystemSession = useBasicSystemSession(pinia)
basicSystemSession.updatePrompt('KEEP WORKSPACE')
const basicUserSession = useBasicUserSession(pinia)
const proMultiMessageSession = useProMultiMessageSession(pinia)
const proVariableSession = useProVariableSession(pinia)
@@ -1849,6 +1850,7 @@ describe('useAppPromptGardenImport', () => {
await waitForCondition(() => isLoadingExternalData.value === false)
expect(openSaveFavoriteDialog).toHaveBeenCalledTimes(1)
expect(basicSystemSession.prompt).toBe('KEEP WORKSPACE')
const savedArg = openSaveFavoriteDialog.mock.calls[0]?.[0] as {
content: string
prefill?: {
@@ -1894,6 +1896,7 @@ describe('useAppPromptGardenImport', () => {
})
const basicSystemSession = useBasicSystemSession(pinia)
basicSystemSession.updatePrompt('KEEP WORKSPACE')
const basicUserSession = useBasicUserSession(pinia)
const proMultiMessageSession = useProMultiMessageSession(pinia)
const proVariableSession = useProVariableSession(pinia)
@@ -2006,6 +2009,7 @@ describe('useAppPromptGardenImport', () => {
await waitForCondition(() => isLoadingExternalData.value === false)
expect(favoriteManager.getFavorites).toHaveBeenCalledTimes(1)
expect(basicSystemSession.prompt).toBe('KEEP WORKSPACE')
expect(favoriteManager.addFavorite).toHaveBeenCalledTimes(1)
expect(favoriteManager.updateFavorite).not.toHaveBeenCalled()
const favoriteArg = favoriteManager.addFavorite.mock.calls[0]?.[0]