From b034b56ff62eca423717bcdf4118ca8d94f6ae27 Mon Sep 17 00:00:00 2001 From: linshen <32978552+linshenkx@users.noreply.github.com> Date: Sat, 31 Jan 2026 23:14:01 +0800 Subject: [PATCH] feat(integrations): import prompt-garden examples --- .../app/useAppPromptGardenImport.ts | 193 ++++++++- .../useAppPromptGardenImport.spec.ts | 373 ++++++++++++++++++ 2 files changed, 551 insertions(+), 15 deletions(-) diff --git a/packages/ui/src/composables/app/useAppPromptGardenImport.ts b/packages/ui/src/composables/app/useAppPromptGardenImport.ts index 9a843043..7ffc89b7 100644 --- a/packages/ui/src/composables/app/useAppPromptGardenImport.ts +++ b/packages/ui/src/composables/app/useAppPromptGardenImport.ts @@ -104,6 +104,11 @@ type FetchedPrompt = { promptText?: string promptMessages?: ConversationMessage[] variables: Array<{ name: string; defaultValue?: string }> + examples: Array<{ + id?: string + parameters?: Record + inputImages?: string[] + }> } type ImportedVariable = { @@ -117,6 +122,29 @@ type TemporaryVariablesSessionApi = { clearTemporaryVariables: () => void } +const getTemporaryVariablesSession = ( + targetKey: SupportedSubModeKey, + api: { + proMultiMessageSession: ProMultiMessageSessionApi + proVariableSession: ProVariableSessionApi + imageText2ImageSession: ImageText2ImageSessionApi + imageImage2ImageSession: ImageImage2ImageSessionApi + }, +): TemporaryVariablesSessionApi | null => { + switch (targetKey) { + case 'pro-multi': + return api.proMultiMessageSession + case 'pro-variable': + return api.proVariableSession + case 'image-text2image': + return api.imageText2ImageSession + case 'image-image2image': + return api.imageImage2ImageSession + default: + return null + } +} + const ensureImportedTemporaryVariables = ( targetKey: SupportedSubModeKey, api: { @@ -129,20 +157,7 @@ const ensureImportedTemporaryVariables = ( variables: ImportedVariable[] }, ) => { - const session: TemporaryVariablesSessionApi | null = (() => { - switch (targetKey) { - case 'pro-multi': - return api.proMultiMessageSession - case 'pro-variable': - return api.proVariableSession - case 'image-text2image': - return api.imageText2ImageSession - case 'image-image2image': - return api.imageImage2ImageSession - default: - return null - } - })() + const session = getTemporaryVariablesSession(targetKey, api) if (!session) return @@ -306,12 +321,48 @@ const fetchPromptFromGarden = async (opts: { }) .filter((v) => isValidVariableName(v.name)) + const assets = isPlainObject((data as any).assets) ? ((data as any).assets as Record) : null + const rawExamples = assets && Array.isArray((assets as any).examples) ? ((assets as any).examples as unknown[]) : [] + const examples = rawExamples + .map((ex): { id?: string; parameters?: Record; inputImages?: string[] } => { + if (!isPlainObject(ex)) return {} + + const id = typeof ex.id === 'string' ? ex.id.trim() : undefined + + const parameters = (() => { + const p = (ex as any).parameters + if (!isPlainObject(p)) return undefined + const out: Record = {} + for (const [k, v] of Object.entries(p)) { + const key = String(k || '').trim() + if (!isValidVariableName(key)) continue + if (v === undefined || v === null) continue + out[key] = String(v) + } + return Object.keys(out).length ? out : undefined + })() + + const inputImages = Array.isArray((ex as any).inputImages) + ? ((ex as any).inputImages as unknown[]) + .map((u) => (typeof u === 'string' ? u.trim() : '')) + .filter(Boolean) + : undefined + + return { + id, + parameters, + inputImages, + } + }) + .filter((ex) => Boolean(ex.parameters) || (Array.isArray(ex.inputImages) && ex.inputImages.length > 0)) + return { optimizerTargetKey, promptFormat: format, promptText, promptMessages, variables, + examples, } } @@ -325,6 +376,72 @@ const fetchPromptFromGarden = async (opts: { return parseV1(data) } +const resolveGardenUrl = (opts: { gardenBaseUrl: string | null; url: string }): string | null => { + const raw = String(opts.url || '').trim() + if (!raw) return null + if (/^https?:\/\//u.test(raw)) return raw + + const base = opts.gardenBaseUrl ? normalizeBaseUrl(opts.gardenBaseUrl) : null + if (!base) return null + + try { + return new URL(raw, `${base}/`).toString() + } catch { + return null + } +} + +const fetchImageAsBase64 = async (absoluteUrl: string): Promise<{ b64: string; mimeType: string } | null> => { + const resp = await fetch(absoluteUrl, { method: 'GET' }) + if (!resp.ok) { + throw new Error(`Example image request failed: ${resp.status}`) + } + + const headerType = resp.headers.get('content-type') + const mimeType = typeof headerType === 'string' ? headerType.split(';')[0].trim() : '' + + // Tests run in Node where Buffer is available; browsers use FileReader. + const maybeBuffer = (globalThis as any).Buffer as any + if (maybeBuffer && typeof maybeBuffer.from === 'function') { + const ab = await resp.arrayBuffer() + const b64 = maybeBuffer.from(ab).toString('base64') + return { b64, mimeType } + } + + if (typeof FileReader === 'undefined') { + throw new Error('FileReader is not available to decode images') + } + + const blob = await resp.blob() + const actualMime = blob.type || mimeType + const dataUrl = await new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onerror = () => reject(new Error('Failed to read image blob')) + reader.onload = () => resolve(String(reader.result || '')) + reader.readAsDataURL(blob) + }) + + const match = dataUrl.match(/^data:.*?;base64,(.*)$/u) + const b64 = match ? match[1] : '' + if (!b64) { + throw new Error('Failed to decode image data URL') + } + return { b64, mimeType: actualMime } +} + +const pickImportedExample = ( + examples: FetchedPrompt['examples'], + exampleId: string | null, +): FetchedPrompt['examples'][number] | null => { + if (!Array.isArray(examples) || examples.length === 0) return null + const id = (exampleId || '').trim() + if (id) { + const found = examples.find((ex) => (ex.id || '').trim() === id) + if (found) return found + } + return examples[0] || null +} + const clearSessionForExternalImport = (targetKey: SupportedSubModeKey, api: { basicSystemSession: BasicSystemSessionApi basicUserSession: BasicUserSessionApi @@ -453,6 +570,8 @@ export function useAppPromptGardenImport(options: AppPromptGardenImportOptions) const importCode = getQueryString(query, 'importCode') if (!importCode) return + const exampleId = getQueryString(query, 'exampleId') + inFlight.value = true isLoadingExternalData.value = true try { @@ -461,6 +580,8 @@ export function useAppPromptGardenImport(options: AppPromptGardenImportOptions) importCode, }) + const importedExample = pickImportedExample(fetched.examples, exampleId) + const targetKey = resolveTargetKey(query, currentRoute.path, fetched.optimizerTargetKey) // If caller opened the wrong workspace, navigate first. @@ -539,6 +660,48 @@ export function useAppPromptGardenImport(options: AppPromptGardenImportOptions) } ) + // If the imported prompt provides a full example, apply the example's parameter values + // (and input image for image2image) so the user can reproduce the result directly. + if (importedExample) { + const session = getTemporaryVariablesSession(targetKey, { + proMultiMessageSession, + proVariableSession, + imageText2ImageSession, + imageImage2ImageSession, + }) + + const importedVariableNames = new Set( + fetched.variables + .map((v) => String(v?.name || '').trim()) + .filter((name) => isValidVariableName(name)) + ) + + if (session && importedExample.parameters) { + for (const [key, value] of Object.entries(importedExample.parameters)) { + if (!importedVariableNames.has(key)) continue + session.setTemporaryVariable(key, String(value)) + } + } + + if (targetKey === 'image-image2image' && Array.isArray(importedExample.inputImages) && importedExample.inputImages.length > 0) { + const inputUrl = resolveGardenUrl({ + gardenBaseUrl, + url: importedExample.inputImages[0], + }) + if (inputUrl) { + try { + const img = await fetchImageAsBase64(inputUrl) + if (img?.b64) { + imageImage2ImageSession.updateInputImage(img.b64, img.mimeType) + } + } catch (e) { + console.warn('[PromptGardenImport] Failed to load example input image:', e) + toast.warning('示例输入图加载失败(请检查 Prompt Garden /prompt-assets 的 CORS 配置)') + } + } + } + } + // Best-effort persist. try { if (targetKey === 'basic-system') await basicSystemSession.saveSession() @@ -552,7 +715,7 @@ export function useAppPromptGardenImport(options: AppPromptGardenImportOptions) } // Remove import params to avoid re-import on refresh. - const cleanedQuery = omitKeys(query, ['importCode', 'subModeKey']) + const cleanedQuery = omitKeys(query, ['importCode', 'subModeKey', 'exampleId']) await router.replace({ path: router.currentRoute.value.path, query: cleanedQuery }) await nextTick() diff --git a/packages/ui/tests/unit/composables/useAppPromptGardenImport.spec.ts b/packages/ui/tests/unit/composables/useAppPromptGardenImport.spec.ts index afa36938..0cb11218 100644 --- a/packages/ui/tests/unit/composables/useAppPromptGardenImport.spec.ts +++ b/packages/ui/tests/unit/composables/useAppPromptGardenImport.spec.ts @@ -596,6 +596,243 @@ describe('useAppPromptGardenImport', () => { } }) + it('applies default example parameters into pro-variable temporary variables', async () => { + const { pinia } = createTestPinia() + + // Avoid console.warn from useToast (tests fail on console.warn). + const createReactive = (): MessageReactive => ({ + destroy: () => {}, + } as unknown as MessageReactive) + setGlobalMessageApi({ + success: vi.fn(() => createReactive()), + error: vi.fn(() => createReactive()), + warning: vi.fn(() => createReactive()), + info: vi.fn(() => createReactive()), + }) + + const basicSystemSession = useBasicSystemSession(pinia) + const basicUserSession = useBasicUserSession(pinia) + const proMultiMessageSession = useProMultiMessageSession(pinia) + const proVariableSession = useProVariableSession(pinia) + const imageText2ImageSession = useImageText2ImageSession(pinia) + const imageImage2ImageSession = useImageImage2ImageSession(pinia) + + // Seed existing values; example parameters should override them. + proVariableSession.setTemporaryVariable('name', 'Bob') + + const optimizerCurrentVersions = ref([makeDummyRecord()]) + const hasRestoredInitialState = ref(false) + const isLoadingExternalData = ref(false) + + const query: LocationQuery = { + importCode: 'NB-PVAR-EX-001', + } + + const currentRoute = ref(makeRoute('/basic/system', query)) + + let replaceResolve: (() => void) | undefined + const replaceDone = new Promise((resolve) => { + replaceResolve = resolve + }) + + const push: Router['push'] = vi.fn(async (to) => { + applyNavigation(currentRoute, to) + return undefined + }) + + const replace: Router['replace'] = vi.fn(async (to) => { + applyNavigation(currentRoute, to) + replaceResolve?.() + return undefined + }) + + const router: Pick = { + currentRoute, + push, + replace, + } + + const v1Payload = { + schema: 'prompt-garden.prompt.v1', + schemaVersion: 1, + optimizerTarget: { subModeKey: 'pro-variable' }, + prompt: { + format: 'text', + text: 'Hello {{name}}', + }, + variables: [{ name: 'name' }, { name: 'tone' }], + assets: { + examples: [ + { + id: 'ex-001', + parameters: { + name: 'Alice', + tone: 'friendly', + }, + }, + ], + }, + } + + const fetchMock = vi.fn< + (input: RequestInfo | URL, init?: RequestInit) => Promise + >(async () => { + return new Response(JSON.stringify(v1Payload), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + }) + vi.stubGlobal('fetch', fetchMock) + + const scope = effectScope() + try { + scope.run(() => { + useAppPromptGardenImport({ + router, + hasRestoredInitialState, + isLoadingExternalData, + gardenBaseUrl: 'http://garden.local', + basicSystemSession, + basicUserSession, + proMultiMessageSession, + proVariableSession, + imageText2ImageSession, + imageImage2ImageSession, + optimizerCurrentVersions, + }) + }) + + hasRestoredInitialState.value = true + + await replaceDone + await waitForCondition(() => isLoadingExternalData.value === false) + + expect(currentRoute.value.path).toBe('/pro/variable') + expect(proVariableSession.prompt).toBe('Hello {{name}}') + + // Example parameters override existing values. + expect(proVariableSession.getTemporaryVariable('name')).toBe('Alice') + expect(proVariableSession.getTemporaryVariable('tone')).toBe('friendly') + } finally { + scope.stop() + } + }) + + it('applies selected exampleId parameters when provided', async () => { + const { pinia } = createTestPinia() + + // Avoid console.warn from useToast (tests fail on console.warn). + const createReactive = (): MessageReactive => ({ + destroy: () => {}, + } as unknown as MessageReactive) + setGlobalMessageApi({ + success: vi.fn(() => createReactive()), + error: vi.fn(() => createReactive()), + warning: vi.fn(() => createReactive()), + info: vi.fn(() => createReactive()), + }) + + const basicSystemSession = useBasicSystemSession(pinia) + const basicUserSession = useBasicUserSession(pinia) + const proMultiMessageSession = useProMultiMessageSession(pinia) + const proVariableSession = useProVariableSession(pinia) + const imageText2ImageSession = useImageText2ImageSession(pinia) + const imageImage2ImageSession = useImageImage2ImageSession(pinia) + + const optimizerCurrentVersions = ref([makeDummyRecord()]) + const hasRestoredInitialState = ref(false) + const isLoadingExternalData = ref(false) + + const query: LocationQuery = { + importCode: 'NB-PVAR-EX-002', + exampleId: 'ex-b', + } + + const currentRoute = ref(makeRoute('/basic/system', query)) + + let replaceResolve: (() => void) | undefined + const replaceDone = new Promise((resolve) => { + replaceResolve = resolve + }) + + const push: Router['push'] = vi.fn(async (to) => { + applyNavigation(currentRoute, to) + return undefined + }) + + const replace: Router['replace'] = vi.fn(async (to) => { + applyNavigation(currentRoute, to) + replaceResolve?.() + return undefined + }) + + const router: Pick = { + currentRoute, + push, + replace, + } + + const v1Payload = { + schema: 'prompt-garden.prompt.v1', + schemaVersion: 1, + optimizerTarget: { subModeKey: 'pro-variable' }, + prompt: { + format: 'text', + text: 'Hello {{name}}', + }, + variables: [{ name: 'name' }], + assets: { + examples: [ + { id: 'ex-a', parameters: { name: 'Alice' } }, + { id: 'ex-b', parameters: { name: 'Charlie' } }, + ], + }, + } + + const fetchMock = vi.fn< + (input: RequestInfo | URL, init?: RequestInit) => Promise + >(async () => { + return new Response(JSON.stringify(v1Payload), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + }) + vi.stubGlobal('fetch', fetchMock) + + const scope = effectScope() + try { + scope.run(() => { + useAppPromptGardenImport({ + router, + hasRestoredInitialState, + isLoadingExternalData, + gardenBaseUrl: 'http://garden.local', + basicSystemSession, + basicUserSession, + proMultiMessageSession, + proVariableSession, + imageText2ImageSession, + imageImage2ImageSession, + optimizerCurrentVersions, + }) + }) + + hasRestoredInitialState.value = true + + await replaceDone + await waitForCondition(() => isLoadingExternalData.value === false) + + expect(currentRoute.value.path).toBe('/pro/variable') + expect(proVariableSession.getTemporaryVariable('name')).toBe('Charlie') + + // Import params removed from the URL. + expect(currentRoute.value.query.importCode).toBeUndefined() + expect(currentRoute.value.query.exampleId).toBeUndefined() + } finally { + scope.stop() + } + }) + it('injects {{var}} placeholders into image temporary variables', async () => { const { pinia } = createTestPinia({ // Image sessions require ImageStorageService to persist. @@ -725,6 +962,142 @@ describe('useAppPromptGardenImport', () => { } }) + it('loads image2image example input image when present', async () => { + const { pinia } = createTestPinia({ + imageStorageService: { + getMetadata: async () => null, + saveImage: async () => {}, + listAllMetadata: async () => [], + deleteImages: async () => {}, + } as unknown as never, + }) + + // Avoid console.warn from useToast (tests fail on console.warn). + const createReactive = (): MessageReactive => ({ + destroy: () => {}, + } as unknown as MessageReactive) + setGlobalMessageApi({ + success: vi.fn(() => createReactive()), + error: vi.fn(() => createReactive()), + warning: vi.fn(() => createReactive()), + info: vi.fn(() => createReactive()), + }) + + const basicSystemSession = useBasicSystemSession(pinia) + const basicUserSession = useBasicUserSession(pinia) + const proMultiMessageSession = useProMultiMessageSession(pinia) + const proVariableSession = useProVariableSession(pinia) + const imageText2ImageSession = useImageText2ImageSession(pinia) + const imageImage2ImageSession = useImageImage2ImageSession(pinia) + + const optimizerCurrentVersions = ref([makeDummyRecord()]) + const hasRestoredInitialState = ref(false) + const isLoadingExternalData = ref(false) + + const query: LocationQuery = { + importCode: 'NB-I2I-001', + subModeKey: 'image-image2image', + } + + const currentRoute = ref(makeRoute('/basic/system', query)) + + let replaceResolve: (() => void) | undefined + const replaceDone = new Promise((resolve) => { + replaceResolve = resolve + }) + + const push: Router['push'] = vi.fn(async (to) => { + applyNavigation(currentRoute, to) + return undefined + }) + + const replace: Router['replace'] = vi.fn(async (to) => { + applyNavigation(currentRoute, to) + replaceResolve?.() + return undefined + }) + + const router: Pick = { + currentRoute, + push, + replace, + } + + const inputAssetPath = '/prompt-assets/NB-I2I-001/examples/ex-001/01.png' + const inputAssetUrl = `http://garden.local${inputAssetPath}` + + const v1Payload = { + schema: 'prompt-garden.prompt.v1', + schemaVersion: 1, + optimizerTarget: { subModeKey: 'image-image2image' }, + prompt: { format: 'text', text: 'Transform the image' }, + variables: [], + assets: { + examples: [ + { + id: 'ex-001', + inputImages: [inputAssetPath], + }, + ], + }, + } + + const fetchMock = vi.fn< + (input: RequestInfo | URL, init?: RequestInit) => Promise + >(async (input) => { + const url = String(input) + if (url === 'http://garden.local/api/prompt-source/NB-I2I-001') { + return new Response(JSON.stringify(v1Payload), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + } + if (url === inputAssetUrl) { + return new Response(new Uint8Array([0, 1, 2, 3]), { + status: 200, + headers: { 'content-type': 'image/png' }, + }) + } + throw new Error(`Unexpected fetch URL: ${url}`) + }) + vi.stubGlobal('fetch', fetchMock) + + const scope = effectScope() + try { + scope.run(() => { + useAppPromptGardenImport({ + router, + hasRestoredInitialState, + isLoadingExternalData, + gardenBaseUrl: 'http://garden.local', + basicSystemSession, + basicUserSession, + proMultiMessageSession, + proVariableSession, + imageText2ImageSession, + imageImage2ImageSession, + optimizerCurrentVersions, + }) + }) + + hasRestoredInitialState.value = true + + await replaceDone + await waitForCondition(() => isLoadingExternalData.value === false) + + expect(currentRoute.value.path).toBe('/image/image2image') + expect(imageImage2ImageSession.originalPrompt).toBe('Transform the image') + + // [0,1,2,3] -> AAECAw== + expect(imageImage2ImageSession.inputImageB64).toBe('AAECAw==') + expect(imageImage2ImageSession.inputImageMime).toBe('image/png') + + expect(fetchMock).toHaveBeenCalledTimes(2) + } finally { + scope.stop() + } + }) + it('clears temporary variables when importing an empty variable list into pro-variable', async () => { const { pinia } = createTestPinia()