From c33f5973a3dab23dad49d221e26f3d26ff39d12d Mon Sep 17 00:00:00 2001 From: linshen <32978552+linshenkx@users.noreply.github.com> Date: Mon, 30 Mar 2026 22:08:41 +0800 Subject: [PATCH] refactor(image): rebuild reference prompt pipeline - Introduce the new reference prompt composition and migration template pipeline - Replace the legacy image prompt extraction path and align template loading contracts - Update text2image workspace integration and add reference pipeline coverage --- ...6-03-31-reference-image-dialog-redesign.md | 519 +++++++++++++ ...-reference-image-dialog-redesign-design.md | 434 +++++++++++ .../image-prompt-composition/composition.ts | 44 ++ .../composition_en.ts | 44 ++ .../image-prompt-composition/index.ts | 2 + .../image-prompt-extraction/extraction.ts | 58 -- .../image-prompt-extraction/extraction_en.ts | 58 -- .../image-prompt-extraction/index.ts | 2 - .../image-prompt-migration/index.ts | 2 + .../image-prompt-migration/migration.ts | 48 ++ .../image-prompt-migration/migration_en.ts | 48 ++ .../extraction.ts | 46 ++ .../extraction_en.ts | 46 ++ .../image-reference-spec-extraction/index.ts | 2 + .../template/default-templates/index.ts | 20 +- .../variable-extraction/extraction.ts | 4 +- .../variable-extraction/extraction_en.ts | 4 +- .../src/services/template/static-loader.ts | 22 +- packages/core/src/services/template/types.ts | 10 +- .../image-reference-template-types.test.ts | 87 +++ .../image-mode/ImageImage2ImageWorkspace.vue | 29 +- .../image-mode/ImageText2ImageWorkspace.vue | 36 +- .../composables/prompt/useTemplateManager.ts | 8 +- .../ui/src/services/ImageStyleExtractor.ts | 685 +++++++++++------- ...StyleExtractor.reference-migration.spec.ts | 340 +++++++++ 25 files changed, 2196 insertions(+), 402 deletions(-) create mode 100644 docs/superpowers/plans/2026-03-31-reference-image-dialog-redesign.md create mode 100644 docs/superpowers/specs/2026-03-31-reference-image-dialog-redesign-design.md create mode 100644 packages/core/src/services/template/default-templates/image-prompt-composition/composition.ts create mode 100644 packages/core/src/services/template/default-templates/image-prompt-composition/composition_en.ts create mode 100644 packages/core/src/services/template/default-templates/image-prompt-composition/index.ts delete mode 100644 packages/core/src/services/template/default-templates/image-prompt-extraction/extraction.ts delete mode 100644 packages/core/src/services/template/default-templates/image-prompt-extraction/extraction_en.ts delete mode 100644 packages/core/src/services/template/default-templates/image-prompt-extraction/index.ts create mode 100644 packages/core/src/services/template/default-templates/image-prompt-migration/index.ts create mode 100644 packages/core/src/services/template/default-templates/image-prompt-migration/migration.ts create mode 100644 packages/core/src/services/template/default-templates/image-prompt-migration/migration_en.ts create mode 100644 packages/core/src/services/template/default-templates/image-reference-spec-extraction/extraction.ts create mode 100644 packages/core/src/services/template/default-templates/image-reference-spec-extraction/extraction_en.ts create mode 100644 packages/core/src/services/template/default-templates/image-reference-spec-extraction/index.ts create mode 100644 packages/core/tests/unit/template/image-reference-template-types.test.ts create mode 100644 packages/ui/tests/unit/services/ImageStyleExtractor.reference-migration.spec.ts diff --git a/docs/superpowers/plans/2026-03-31-reference-image-dialog-redesign.md b/docs/superpowers/plans/2026-03-31-reference-image-dialog-redesign.md new file mode 100644 index 00000000..247232c3 --- /dev/null +++ b/docs/superpowers/plans/2026-03-31-reference-image-dialog-redesign.md @@ -0,0 +1,519 @@ +# Reference Image Dialog Redesign Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Rework the text-to-image reference-image dialog into a simpler, clearer flow with thumbnail-first context, user-facing mode names, no undo complexity, and a hard 5-variable cap. + +**Architecture:** Keep the existing three-stage reference pipeline intact (`ReferenceSpec -> PromptDraft -> VariableizedPrompt`) and only reshape the dialog state, layout, copy, and variable extraction policy around it. Treat the modal as a confirmation-and-light-edit surface rather than a strongly synchronized editor, so prompt text and variables can diverge temporarily without triggering re-extraction logic. + +**Tech Stack:** Vue 3, Naive UI, TypeScript, Vitest, existing core template system + +--- + +## File Map + +### Core files to modify + +- `packages/ui/src/components/image-mode/ImageText2ImageWorkspace.vue` + - Reference-image modal structure, layout, user-facing wording hooks, thumbnail presentation, and removal of undo banner UI. +- `packages/ui/src/composables/image/useReferencePromptDialog.ts` + - Dialog state model, removal of apply snapshot/undo, addition of current-prompt preview state if needed. +- `packages/ui/src/services/ImageStyleExtractor.ts` + - Service-layer variable cap and any helper for trimming extracted variables to 5 high-priority entries. +- `packages/ui/src/i18n/locales/zh-CN.ts` + - Main Chinese copy changes for the dialog. +- `packages/ui/src/i18n/locales/zh-TW.ts` + - Traditional Chinese parity for the same copy. +- `packages/ui/src/i18n/locales/en-US.ts` + - English parity for the same copy. +- `packages/core/src/services/template/default-templates/variable-extraction/extraction.ts` + - Tighten extraction policy and cap guidance to 5 variables in Chinese. +- `packages/core/src/services/template/default-templates/variable-extraction/extraction_en.ts` + - Tighten extraction policy and cap guidance to 5 variables in English. + +### Test files to modify + +- `packages/ui/tests/unit/composables/useReferencePromptDialog.spec.ts` + - Update expectations away from undo and toward simplified apply behavior. +- `packages/ui/tests/unit/services/ImageStyleExtractor.reference-migration.spec.ts` + - Add assertions for the 5-variable hard cap and final-prompt-only retention. +- `packages/ui/tests/unit/image/reference-image-theme-guards.spec.ts` + - Update source guard assertions to match thumbnail layout, no undo banner, and controlled modal sizing. + +### Optional new tests + +- `packages/ui/tests/unit/image/reference-image-dialog-layout.spec.ts` + - Add only if the existing theme guard becomes too brittle to express the new structure cleanly. + +--- + +### Task 1: Simplify Dialog State and Remove Undo + +**Files:** +- Modify: `packages/ui/src/composables/image/useReferencePromptDialog.ts` +- Test: `packages/ui/tests/unit/composables/useReferencePromptDialog.spec.ts` + +- [ ] **Step 1: Write the failing composable test for “apply without undo”** + +```ts +it('applies prompt and variables without exposing undo state', () => { + const dialog = useReferencePromptDialog(/* ... */) + dialog.openDialog() + dialog.setGeneratedPreview(createPreview()) + + dialog.applyToCurrentPrompt() + + expect(dialog).not.toHaveProperty('canUndoLastApply') + expect(dialog).not.toHaveProperty('undoLastApply') +}) +``` + +- [ ] **Step 2: Add a failing test for current-prompt preview behavior** + +```ts +it('stores detected current prompt for preview while keeping full prompt available for migration decisions', () => { + const dialog = useReferencePromptDialog(/* original prompt is long text */) + dialog.openDialog() + + expect(dialog.detectedOriginalPrompt.value).toContain('当前提示词') +}) +``` + +Note: +- If the implementation chooses a computed preview string instead of a second ref, assert the chosen public API instead of inventing a new property. + +- [ ] **Step 3: Run the composable test file** + +Run: + +```powershell +pnpm -F @prompt-optimizer/ui test -- tests/unit/composables/useReferencePromptDialog.spec.ts +``` + +Expected: +- FAIL because undo APIs still exist and the new preview expectation is not implemented yet. + +- [ ] **Step 4: Remove undo state from the composable** + +Implementation direction: + +```ts +// Remove: +// - lastApplySnapshot +// - canUndoLastApply +// - undoLastApply() + +const applyToCurrentPrompt = () => { + if (!canApply.value) return false + + options.applyPrompt(workingPrompt.value) + options.applyVariables(cloneVariables(workingVariables.value)) + options.resetPromptArtifacts?.() + closeDialog() + return true +} +``` + +- [ ] **Step 5: Add or expose a small current-prompt preview value** + +Implementation direction: + +```ts +const detectedOriginalPromptPreview = computed(() => + detectedOriginalPrompt.value.trim(), +) +``` + +Rules: +- Keep the full original prompt for mode-switch logic. +- Let the workspace decide how many lines to show visually. + +- [ ] **Step 6: Re-run the composable tests** + +Run: + +```powershell +pnpm -F @prompt-optimizer/ui test -- tests/unit/composables/useReferencePromptDialog.spec.ts +``` + +Expected: +- PASS + +- [ ] **Step 7: Commit** + +```powershell +git add packages/ui/src/composables/image/useReferencePromptDialog.ts packages/ui/tests/unit/composables/useReferencePromptDialog.spec.ts +git commit -m "refactor(ui): simplify reference dialog state" +``` + +--- + +### Task 2: Rebuild the Modal Information Architecture and Layout + +**Files:** +- Modify: `packages/ui/src/components/image-mode/ImageText2ImageWorkspace.vue` +- Test: `packages/ui/tests/unit/image/reference-image-theme-guards.spec.ts` + +- [ ] **Step 1: Write the failing UI source guard updates** + +Add expectations for: +- thumbnail-oriented reference image block +- separate detected-current-prompt section +- `风格迁移 / 复刻图片` copy hooks +- no undo banner markup + +Example: + +```ts +expect(source).toMatch(/reference-dialog-context-grid/) +expect(source).toMatch(/reference-dialog-thumbnail/) +expect(source).toMatch(/detectedCurrentPromptTitle/) +expect(source).not.toMatch(/reference-image-undo-button/) +expect(source).not.toMatch(/undoBanner/) +``` + +- [ ] **Step 2: Run the UI source guard test** + +Run: + +```powershell +pnpm -F @prompt-optimizer/ui test -- tests/unit/image/reference-image-theme-guards.spec.ts +``` + +Expected: +- FAIL because the current modal still uses the old arrangement and undo markup. + +- [ ] **Step 3: Replace the large reference preview with a compact thumbnail layout** + +Implementation direction inside `ImageText2ImageWorkspace.vue`: + +```vue +
+ +
+ {{ referenceDialog.sourceImageName || t(...) }} + {{ t('imageWorkspace.referenceImage.sourceImageHint') }} + {{ ... }} +
+
+``` + +Rules: +- Thumbnail should not dominate vertical space. +- Keep Naive UI theming and avoid hardcoded light-only backgrounds. + +- [ ] **Step 4: Split “detected current prompt” into its own section** + +Implementation direction: + +```vue + + {{ t('imageWorkspace.referenceImage.detectedCurrentPromptTitle') }} + + {{ referenceDialog.detectedOriginalPromptPreview }} + + +``` + +Rules: +- Show only preview text visually via CSS line clamp. +- Do not place it under the usage-mode heading. + +- [ ] **Step 5: Convert the mode selector into user-facing option cards** + +Implementation direction: + +```vue + +
+ +
+
+``` + +Rules: +- Preserve the underlying radio semantics. +- Increase selection clarity with border/background states derived from theme tokens. + +- [ ] **Step 6: Move the result area to a mixed layout** + +Implementation direction: + +```vue +
+ ... + ... +
+``` + +Rules: +- Desktop: `2fr / 1fr` +- Narrow widths: stack vertically +- Prompt textarea gets a stable minimum height +- Variable list gets a stable min/max height with scrolling + +- [ ] **Step 7: Remove the undo banner from the workspace** + +Delete: +- success alert block above the template/model controls +- `handleReferenceDialogUndo` wiring +- any `referenceDialog.canUndoLastApply` template branches + +- [ ] **Step 8: Re-run the UI source guard test** + +Run: + +```powershell +pnpm -F @prompt-optimizer/ui test -- tests/unit/image/reference-image-theme-guards.spec.ts +``` + +Expected: +- PASS + +- [ ] **Step 9: Commit** + +```powershell +git add packages/ui/src/components/image-mode/ImageText2ImageWorkspace.vue packages/ui/tests/unit/image/reference-image-theme-guards.spec.ts +git commit -m "feat(ui): redesign reference image dialog layout" +``` + +--- + +### Task 3: Update Dialog Copy Across Locales + +**Files:** +- Modify: `packages/ui/src/i18n/locales/zh-CN.ts` +- Modify: `packages/ui/src/i18n/locales/zh-TW.ts` +- Modify: `packages/ui/src/i18n/locales/en-US.ts` + +- [ ] **Step 1: Add a failing check in the UI source guard or locale-aware test** + +If no locale test exists, extend the existing source guard to assert the new translation keys are referenced and old ones are gone. + +Example: + +```ts +expect(source).toMatch(/styleTransfer/) +expect(source).toMatch(/replicateImage/) +expect(source).not.toMatch(/migrateToCurrentPrompt/) +expect(source).not.toMatch(/replicateOnly/) +``` + +- [ ] **Step 2: Run the relevant test file** + +Run: + +```powershell +pnpm -F @prompt-optimizer/ui test -- tests/unit/image/reference-image-theme-guards.spec.ts +``` + +Expected: +- FAIL until the component and locale keys agree. + +- [ ] **Step 3: Update the locale entries** + +Chinese target copy: + +```ts +styleTransfer: "风格迁移", +styleTransferDescription: "在原提示词的基础上学习图片风格进行改造", +replicateImage: "复刻图片", +replicateImageDescription: "丢弃原始提示词内容,反推图片对应提示词", +detectedCurrentPromptTitle: "检测到当前提示词", +``` + +Also remove: +- `undoBanner` +- `undo` +- `undoSuccess` + +- [ ] **Step 4: Keep cross-locale parity** + +Do not leave `zh-TW` and `en-US` behind even if the main product language is Chinese. + +- [ ] **Step 5: Re-run the UI test** + +Run: + +```powershell +pnpm -F @prompt-optimizer/ui test -- tests/unit/image/reference-image-theme-guards.spec.ts +``` + +Expected: +- PASS + +- [ ] **Step 6: Commit** + +```powershell +git add packages/ui/src/i18n/locales/zh-CN.ts packages/ui/src/i18n/locales/zh-TW.ts packages/ui/src/i18n/locales/en-US.ts packages/ui/src/components/image-mode/ImageText2ImageWorkspace.vue packages/ui/tests/unit/image/reference-image-theme-guards.spec.ts +git commit -m "refactor(ui): update reference dialog copy" +``` + +--- + +### Task 4: Tighten Variable Extraction to 5 High-Value Variables + +**Files:** +- Modify: `packages/core/src/services/template/default-templates/variable-extraction/extraction.ts` +- Modify: `packages/core/src/services/template/default-templates/variable-extraction/extraction_en.ts` +- Modify: `packages/ui/src/services/ImageStyleExtractor.ts` +- Test: `packages/ui/tests/unit/services/ImageStyleExtractor.reference-migration.spec.ts` + +- [ ] **Step 1: Add a failing service test for the 5-variable cap** + +Example: + +```ts +it('caps extracted variables at 5 entries', async () => { + const result = await extractPromptVariables({ + prompt: SOME_PROMPT, + modelKey: 'text-model', + variableExtractionService: { + extract: vi.fn().mockResolvedValue({ + variables: Array.from({ length: 7 }, (_, index) => ({ + name: `变量${index + 1}`, + value: `值${index + 1}`, + position: { originalText: `词${index + 1}`, occurrence: 1 }, + reason: 'test', + })), + }), + } as any, + }) + + expect(Object.keys(result.variableDefaults)).toHaveLength(5) +}) +``` + +- [ ] **Step 2: Run the reference-migration service tests** + +Run: + +```powershell +pnpm -F @prompt-optimizer/ui test -- tests/unit/services/ImageStyleExtractor.reference-migration.spec.ts +``` + +Expected: +- FAIL because no hard cap exists yet. + +- [ ] **Step 3: Tighten the variable-extraction prompt templates** + +Chinese and English template changes should: +- reduce max variable count from 20 to 5 +- instruct the model to prioritize subject, count, color, key action, and key scene/style anchor +- avoid low-value decorative fragments + +Example rule block: + +```ts +- 最多返回5个变量,按重要性排序 +- 优先保留主体、数量、颜色、关键动作、关键场景或核心风格锚点 +- 避免提取低价值修饰词、重复限定词和局部装饰 +``` + +- [ ] **Step 4: Add a hard-cap safeguard in `ImageStyleExtractor.ts`** + +Implementation direction: + +```ts +const MAX_REFERENCE_DIALOG_VARIABLES = 5 + +const limitedVariables = result.variables.slice(0, MAX_REFERENCE_DIALOG_VARIABLES) +const variableizedPrompt = replaceExtractedVariablesInPrompt(prompt, limitedVariables) +const keptVariableNames = scanVariablesFromValue(variableizedPrompt) + +return { + prompt: variableizedPrompt, + variableDefaults: buildFilteredDefaultsFromExtractedVariables(keptVariableNames, limitedVariables), + rawText, +} +``` + +- [ ] **Step 5: Re-run the service tests** + +Run: + +```powershell +pnpm -F @prompt-optimizer/ui test -- tests/unit/services/ImageStyleExtractor.reference-migration.spec.ts +``` + +Expected: +- PASS + +- [ ] **Step 6: Commit** + +```powershell +git add packages/core/src/services/template/default-templates/variable-extraction/extraction.ts packages/core/src/services/template/default-templates/variable-extraction/extraction_en.ts packages/ui/src/services/ImageStyleExtractor.ts packages/ui/tests/unit/services/ImageStyleExtractor.reference-migration.spec.ts +git commit -m "refactor(image): cap reference dialog variables" +``` + +--- + +### Task 5: Final Verification and Manual Smoke Check + +**Files:** +- Verify all files from Tasks 1-4 +- Optional notes update in: `docs/superpowers/specs/2026-03-31-reference-image-dialog-redesign-design.md` + +- [ ] **Step 1: Run the targeted UI and service tests together** + +Run: + +```powershell +pnpm -F @prompt-optimizer/ui test -- tests/unit/composables/useReferencePromptDialog.spec.ts tests/unit/image/reference-image-theme-guards.spec.ts tests/unit/services/ImageStyleExtractor.reference-migration.spec.ts +``` + +Expected: +- PASS + +- [ ] **Step 2: Run UI typecheck** + +Run: + +```powershell +pnpm -F @prompt-optimizer/ui typecheck +``` + +Expected: +- PASS + +- [ ] **Step 3: Run a local manual smoke test** + +Run: + +```powershell +pnpm dev:fresh +``` + +Manual checklist: +- Open `http://127.0.0.1:18181/#/image/text2image` +- Click `参考图` +- Confirm there is no undo banner in the workspace after apply +- Confirm the reference image is shown as a thumbnail, not a large preview +- Confirm `检测到当前提示词` is separate from the mode area +- Confirm `风格迁移 / 复刻图片` are clearly distinguishable +- Confirm the prompt editor and variables panel both have stable visible height + +If model/auth config blocks full end-to-end generation, still verify the static layout and state behavior around modal opening, mode switching, and absence of undo affordances. + +- [ ] **Step 4: Commit the verified final state** + +```powershell +git add packages/ui/src/components/image-mode/ImageText2ImageWorkspace.vue packages/ui/src/composables/image/useReferencePromptDialog.ts packages/ui/src/services/ImageStyleExtractor.ts packages/ui/src/i18n/locales/zh-CN.ts packages/ui/src/i18n/locales/zh-TW.ts packages/ui/src/i18n/locales/en-US.ts packages/core/src/services/template/default-templates/variable-extraction/extraction.ts packages/core/src/services/template/default-templates/variable-extraction/extraction_en.ts packages/ui/tests/unit/composables/useReferencePromptDialog.spec.ts packages/ui/tests/unit/image/reference-image-theme-guards.spec.ts packages/ui/tests/unit/services/ImageStyleExtractor.reference-migration.spec.ts +git commit -m "feat(ui): simplify reference image migration dialog" +``` + +--- + +## Notes for the Implementer + +- Do not reintroduce “sync variables” or “undo once” under new names. +- Keep the modal built on Naive UI primitives and theme tokens. +- Prefer CSS line-clamp or constrained container height for current-prompt preview rather than trimming the underlying source text aggressively. +- Keep the three-stage service architecture intact; this plan is a UI and policy refinement, not a pipeline rewrite. +- If the workspace file becomes too hard to reason about, it is acceptable to extract a small presentational subcomponent for the mode cards or result grid, but only if it reduces complexity instead of spreading it. diff --git a/docs/superpowers/specs/2026-03-31-reference-image-dialog-redesign-design.md b/docs/superpowers/specs/2026-03-31-reference-image-dialog-redesign-design.md new file mode 100644 index 00000000..6740a9af --- /dev/null +++ b/docs/superpowers/specs/2026-03-31-reference-image-dialog-redesign-design.md @@ -0,0 +1,434 @@ +# 文生图参考图弹窗重构设计 + +## 背景 + +当前文生图中的“参考图”弹窗已经接入三段式内部链路: + +1. `ReferenceSpec` 提取 +2. `PromptDraft` 生成或迁移 +3. `VariableizedPrompt` 抽取 + +但当前交互仍存在几个明显问题: + +- 弹窗承担了过多“临时补救”职责,例如应用后撤销,导致状态复杂度偏高。 +- “检测到当前提示词”被放在“怎么使用这张参考图”内部,信息层级不清晰。 +- 模式命名偏实现视角,用户第一眼难以理解差异。 +- 参考图预览区域占用空间过大,挤压了真正重要的结果编辑区。 +- 变量区缺少明确产品边界,当前结果更像调试信息而不是可确认的产物。 +- 当前变量提取策略过于宽松,不适合参考图迁移场景,容易抽出过多变量。 + +本次重构目标不是新增更强的自动能力,而是把参考图弹窗整理成一个更清晰、更可理解、更可确认的前置改造流程。 + +## 目标 + +### 产品目标 + +- 让用户能快速理解“这张参考图会怎么影响当前提示词”。 +- 保持现有 `文生图 / 图生图` 一级入口和模板下拉不变。 +- 让“参考图”成为清晰的前置改造工具,而不是半自动编辑器。 +- 保留 prompt 与变量的人工微调能力,但删除不必要的状态补丁和联动复杂度。 + +### 交互目标 + +- 让弹窗第一眼就能看懂当前上下文、可选模式和最终结果。 +- 把视觉重心从“参考图展示”转移到“生成后的提示词”和“变量预览”。 +- 让模式命名直接对应用户心智,而不是内部实现术语。 + +### 技术目标 + +- 删除撤销链路及其相关状态。 +- 保持三段式架构不变,只收敛 UI 结构、文案与变量策略。 +- 把变量数量稳定控制在 5 个以内,并保证优先级一致。 + +## 非目标 + +- 不修改图生图工作区。 +- 不新增收藏、URL、Garden 导入等参考来源。 +- 不引入 prompt 与变量的实时双向同步。 +- 不把参考图弹窗演化为 JSON 树编辑器或多步向导。 +- 不改变现有优化按钮、模板选择、主工作区链路。 + +## 设计原则 + +### 1. 参考图是来源,不是主角 + +参考图用于确认风格来源是否正确,不应该占据大块主编辑空间。默认展示应为缩略图,而不是大图卡片。 + +### 2. 先交代上下文,再让用户做选择 + +“检测到当前提示词”属于系统当前上下文,应先独立告知用户,再让用户选择如何使用参考图。 + +### 3. 模式命名必须是用户语言 + +模式名称应直接表达结果差异,而不是实现逻辑。用户要看懂“风格迁移”和“复刻图片”,而不是推断“保留当前内容”和“按参考图生成”的含义。 + +### 4. 结果区是弹窗的视觉中心 + +弹窗应把主要空间分配给最终会被应用的两类结果: + +- 生成后的提示词 +- 提取到的变量 + +### 5. 简化优先于强一致 + +本期保留“初次生成时自动抽变量,之后允许手动改 prompt 和变量值”的弱一致策略,不引入自动重算或手动同步动作。 + +## 方案比较 + +### 方案 A:纯纵向堆叠 + +按顺序纵向排列所有内容块: + +- 参考图 +- 检测到当前提示词 +- 使用方式 +- 生成后的提示词 +- 变量预览 + +优点: + +- 实现简单 +- 信息顺序线性 + +缺点: + +- 弹窗过长 +- 重点区域不够突出 +- prompt 与变量缺少工作台感 + +### 方案 B:完全左右双栏 + +整个弹窗采用左右布局,把上下文和结果一起放入双栏。 + +优点: + +- 可在桌面端压缩高度 +- 对比感更强 + +缺点: + +- 信息关系变复杂 +- 辅助信息和核心结果抢空间 +- 响应式和主题适配更容易出问题 + +### 方案 C:上轻下重的混合布局 + +顶部为轻量上下文区,中部为模式选择,底部为结果工作区双栏。 + +结构为: + +- 顶部:`参考图` 缩略图卡 + `检测到当前提示词` 摘要卡 +- 中部:`风格迁移 / 复刻图片` 两张模式卡 +- 底部:左侧 `生成后的提示词`,右侧 `提取到的变量` + +优点: + +- 信息优先级清晰 +- 结果区获得足够空间 +- 比纯双栏更稳,比纯纵向更紧凑 + +缺点: + +- 需要对弹窗结构和样式做一次性整理 + +### 推荐方案 + +采用方案 C。它最符合当前产品定位,也最容易沿用 Naive UI 和现有弹窗结构做稳定落地。 + +## 信息架构 + +### 1. 参考图 + +作为第一块区域展示,采用缩略图样式: + +- 左侧固定尺寸缩略图,建议 `96x96` 或 `112x112` +- 右侧展示文件名、说明文字和“上传/更换图片”按钮 +- 默认不展示大图,不占用额外垂直空间 +- 点击缩略图时可沿用现有图片预览能力,但不作为默认状态 + +说明文案继续保留: + +- `仅支持本地 PNG/JPEG,最大 10MB` + +### 2. 检测到当前提示词 + +仅在当前原始提示词非空时显示,作为独立信息卡片放在参考图区域之后。 + +展示规则: + +- 标题:`检测到当前提示词` +- 只展示前 2 到 3 行摘要 +- 超出内容省略,不展示完整正文 +- 目的仅为帮助用户判断是否保留当前内容,避免把遗留文本误当成当前意图 + +该区域不承担编辑功能。 + +### 3. 怎么使用这张参考图 + +作为单独区域展示,不再混入当前提示词信息。 + +模式命名改为: + +- `风格迁移` +- `复刻图片` + +模式说明改为: + +- `风格迁移`:在原提示词的基础上学习图片风格进行改造 +- `复刻图片`:丢弃原始提示词内容,反推图片对应提示词 + +展示方式: + +- 使用可点击卡片承载单选逻辑,而不是仅显示普通文字型 radio +- 卡片需有明显选中态,便于第一眼区分 + +规则: + +- 当前提示词为空时,不显示模式切换,默认只走 `复刻图片` +- 当前提示词非空时,默认推荐 `风格迁移` + +## 弹窗布局 + +### 桌面端 + +采用混合布局: + +- 顶部上下文区:纵向排列两个轻量卡片 + - `参考图` + - `检测到当前提示词` +- 中部模式区:两张模式卡横向排布 +- 底部结果区:左右布局 + - 左:`生成后的提示词`,宽度约 `2fr` + - 右:`提取到的变量`,宽度约 `1fr` + +### 窄屏 + +底部结果区自动回退为纵向堆叠: + +- 先显示 `生成后的提示词` +- 再显示 `提取到的变量` + +### 弹窗尺寸 + +- 继续基于 Naive UI `NModal + NCard` +- 控制整体宽度,不允许回退为异常全屏 +- 建议桌面宽度保持在 `min(880px, calc(100vw - 32px))` 左右的级别 +- 保持主题跟随,避免硬编码浅色背景或阴影逻辑 + +## 结果编辑区设计 + +### 生成后的提示词 + +这是弹窗的主内容区,应具备稳定高度。 + +规则: + +- 使用单个可编辑 textarea +- 不再展示“请先上传参考图生成预览”之类的说明文案 +- 未生成前允许为空,但界面仍保留固定编辑区域 +- 建议最小高度约 `260px ~ 320px` +- 加载中只显示轻量状态提示,不挤压编辑区高度 + +### 提取到的变量 + +作为结果工作区的一部分展示,而不是附属信息。 + +规则: + +- 变量名只读 +- 变量值可编辑 +- 区域需具备明确最小高度和滚动能力 +- 变量行之间保持稳定间距,避免像表单碎片一样堆在底部 + +## 变量策略 + +### 产品规则 + +- 变量只在首次生成预览时自动提取一次 +- 用户编辑 prompt 后,不自动重算变量 +- 不再提供“同步变量”或“重新提取变量”按钮 +- 点击应用时: + - 直接采用当前 prompt 文本 + - 直接采用当前变量区值 + - 不再做额外重算 + +这意味着 prompt 与变量允许短暂不完全一致,这是有意的简化,不视为缺陷。 + +### 数量上限 + +变量总数必须控制在 5 个以内。 + +采用三层约束: + +1. 参考图生成与迁移模板中,要求输出更适合少量高价值变量抽取的结构 +2. 变量提取模板中明确要求最多返回 5 个变量,并按重要性排序 +3. UI 服务层增加硬上限兜底,只保留前 5 个变量 + +### 优先级 + +优先保留以下变量类型: + +- 主体 +- 数量 +- 颜色 +- 关键动作 +- 关键场景或核心风格锚点 + +低优先级项应避免被抽成变量: + +- 细碎修饰词 +- 重复限定词 +- 对最终复用价值不高的局部装饰 + +## 状态与数据流 + +### 打开弹窗 + +1. 读取当前原始提示词 +2. 若非空,生成摘要用于“检测到当前提示词” +3. 初始化模式: + - 有当前提示词时默认 `风格迁移` + - 无当前提示词时默认 `复刻图片` +4. 清空本次参考图预览结果 + +### 上传参考图后生成预览 + +#### 复刻图片 + +1. `extractReferenceSpecFromImage` +2. `composePromptFromReferenceSpec` +3. `extractPromptVariables` +4. 将结果填入弹窗中的 prompt 和变量区 + +#### 风格迁移 + +1. `extractReferenceSpecFromImage` +2. `migratePromptWithReferenceSpec` +3. `extractPromptVariables` +4. 将结果填入弹窗中的 prompt 和变量区 + +### 应用到当前提示词 + +1. 使用当前编辑框中的 prompt 作为最终文本 +2. 使用当前变量区中的值覆盖当前临时变量 +3. 关闭弹窗 +4. 不再保留撤销快照 + +## 需要删除的复杂度 + +### 删除撤销链路 + +移除以下能力: + +- 弹窗应用后的撤销提示条 +- `lastApplySnapshot` +- `canUndoLastApply` +- `undoLastApply()` +- 相关 UI 按钮与成功提示 + +原因: + +- 弹窗本身已承担最终确认职责 +- 应用后撤销会引入额外状态与心智负担 +- 不符合本功能“确认后替换”的简单模型 + +### 删除变量同步链路 + +移除或保持不存在以下能力: + +- 手动同步变量 +- 自动重算变量 +- prompt 变更后的失焦刷新 +- 变量与 prompt 的双向联动逻辑 + +原因: + +- 这会把弹窗变成编辑器,不再是前置参考改造工具 +- 当前 MVP 的价值在于快速得到一份可用起点,而不是维护强一致的数据模型 + +## 文案调整 + +### 主要文案 + +- `识图提取` 保持为 `参考图` +- `检测到当前提示词:{prompt}` 改为分离式标题和摘要内容,不再拼成一整句 +- `保留当前内容` 改为 `风格迁移` +- `按参考图生成` 改为 `复刻图片` + +### 模式说明 + +- `风格迁移`:在原提示词的基础上学习图片风格进行改造 +- `复刻图片`:丢弃原始提示词内容,反推图片对应提示词 + +## 影响范围 + +### UI + +- `packages/ui/src/components/image-mode/ImageText2ImageWorkspace.vue` +- `packages/ui/src/composables/image/useReferencePromptDialog.ts` +- `packages/ui/src/i18n/locales/zh-CN.ts` +- `packages/ui/src/i18n/locales/zh-TW.ts` +- `packages/ui/src/i18n/locales/en-US.ts` + +### 服务与模板 + +- `packages/ui/src/services/ImageStyleExtractor.ts` +- `packages/core/src/services/template/default-templates/image-prompt-composition/` +- `packages/core/src/services/template/default-templates/image-prompt-migration/` +- `packages/core/src/services/template/default-templates/variable-extraction/` + +## 错误处理 + +- 上传失败时,维持当前 prompt 和变量不变 +- 参考图解析失败时,仅提示本次生成失败,不关闭弹窗 +- 变量提取失败时,允许 prompt 继续展示,但变量区为空 +- 当前提示词为空时,不展示模式选择,也不报错 + +## 测试要点 + +### 交互测试 + +- 弹窗不再显示应用后撤销提示 +- 当前提示词非空时,先显示摘要,再显示模式选择 +- 模式名称和说明文案已切换为 `风格迁移 / 复刻图片` +- 参考图区域使用缩略图,不再占用大图高度 +- prompt 区和变量区有稳定高度 + +### 逻辑测试 + +- `风格迁移` 默认选中,仅在当前提示词非空时出现 +- `复刻图片` 在当前提示词为空时自动生效 +- 应用时不再保存撤销快照 +- 编辑 prompt 不触发变量自动刷新 +- 变量数量始终不超过 5 个 + +### 回归测试 + +- 未使用参考图时,文生图现有流程不变 +- 图生图工作区不受影响 +- 三段式内部服务链路保持不变 + +## 验收标准 + +- 用户能在首次打开弹窗时看懂“当前看到了什么”和“可以怎么用这张图” +- 参考图不再抢占主编辑区空间 +- `风格迁移 / 复刻图片` 的语义差异无需额外解释即可理解 +- 弹窗应用链路不再包含撤销和同步变量等补救型复杂度 +- 变量数量被稳定控制在 5 个以内 + +## 实施建议 + +建议按以下顺序实施: + +1. 先删状态复杂度 + - 移除撤销状态与相关文案 +2. 再改 UI 信息架构 + - 缩略图参考图 + - 当前提示词摘要卡 + - 模式卡 + - 底部混合布局 +3. 最后收变量策略 + - 提取模板收紧 + - 服务层硬上限兜底 + +这样可以先把交互心智收干净,再保证变量结果稳定。 diff --git a/packages/core/src/services/template/default-templates/image-prompt-composition/composition.ts b/packages/core/src/services/template/default-templates/image-prompt-composition/composition.ts new file mode 100644 index 00000000..ae92f1b2 --- /dev/null +++ b/packages/core/src/services/template/default-templates/image-prompt-composition/composition.ts @@ -0,0 +1,44 @@ +import type { MessageTemplate, Template } from '../../types' + +export const template: Template = { + id: 'image-prompt-from-reference-spec', + name: '根据 ReferenceSpec 生成结构化提示词', + content: [ + { + role: 'system', + content: `你是“ReferenceSpec 到结构化生图提示词编排器(JSON 输出)”。 + +你的任务是把参考图规格编排成一份可直接给用户编辑和使用的结构化 JSON 生图提示词。 + +只输出一个 JSON 对象(必须能被 JSON.parse 解析),不要输出解释、标题、Markdown、代码块、前后缀。 +顶层必须是 object,禁止数组包裹,必须使用双引号、无注释、无尾随逗号。 + +强约束: +1. 直接输出最终 prompt JSON,不要包裹在 { "prompt": ... } 或 { "defaults": ... } 里。 +2. 不要输出变量占位符,先给出具体、完整、可直接生成的文字。 +3. 重点体现风格、光线、色调、构图、镜头、媒介、材质、氛围与复现重点。 +4. 如果是图生图模式,可以在 JSON 中加入“保留/改变/参考图指导”等字段,但不要虚构原图没有的核心内容。 +5. 字段结构可以自由发挥,但必须具体、可视、可控,适合后续再抽取变量。 + +当前工作模式:{{referenceMode}} + +参考规格(ReferenceSpec JSON): +{{referenceSpecJson}}` + }, + { + role: 'user', + content: '请输出最终的结构化 JSON 提示词。' + } + ] as MessageTemplate[], + metadata: { + version: '1.0.0', + lastModified: Date.now(), + author: 'System', + description: '把 ReferenceSpec 组合成结构化 JSON 提示词', + templateType: 'image-prompt-composition', + language: 'zh', + tags: ['image', 'json', 'prompt', 'composition', 'internal'], + internalOnly: true, + }, + isBuiltin: true +} diff --git a/packages/core/src/services/template/default-templates/image-prompt-composition/composition_en.ts b/packages/core/src/services/template/default-templates/image-prompt-composition/composition_en.ts new file mode 100644 index 00000000..aaea0b67 --- /dev/null +++ b/packages/core/src/services/template/default-templates/image-prompt-composition/composition_en.ts @@ -0,0 +1,44 @@ +import type { MessageTemplate, Template } from '../../types' + +export const template: Template = { + id: 'image-prompt-from-reference-spec', + name: 'Compose Prompt From ReferenceSpec', + content: [ + { + role: 'system', + content: `You are a "ReferenceSpec-to-structured image prompt composer (JSON output)". + +Your task is to turn the reference spec into a structured JSON prompt that the user can edit and use directly. + +Output exactly one JSON object that can be parsed by JSON.parse. Do not output explanations, headings, Markdown, code fences, or extra text. +The top level must be an object, never an array. Use double quotes only, with no comments and no trailing commas. + +Hard constraints: +1. Output the final prompt JSON directly. Do not wrap it in { "prompt": ... } or { "defaults": ... }. +2. Do not output variable placeholders. Produce a concrete, complete prompt draft first. +3. Emphasize style, lighting, color, composition, camera language, medium, material, atmosphere, and reconstruction priorities. +4. In image-to-image mode, you may include preserve/change/reference-guidance fields, but do not invent core content that is not visually grounded. +5. The structure can stay flexible, but it must remain concrete, visual, and controllable so variables can be extracted later. + +Current workflow mode: {{referenceMode}} + +ReferenceSpec JSON: +{{referenceSpecJson}}` + }, + { + role: 'user', + content: 'Please output the final structured JSON prompt.' + } + ] as MessageTemplate[], + metadata: { + version: '1.0.0', + lastModified: Date.now(), + author: 'System', + description: 'Compose a structured JSON prompt from a ReferenceSpec', + templateType: 'image-prompt-composition', + language: 'en', + tags: ['image', 'json', 'prompt', 'composition', 'internal'], + internalOnly: true, + }, + isBuiltin: true +} diff --git a/packages/core/src/services/template/default-templates/image-prompt-composition/index.ts b/packages/core/src/services/template/default-templates/image-prompt-composition/index.ts new file mode 100644 index 00000000..ad943d4d --- /dev/null +++ b/packages/core/src/services/template/default-templates/image-prompt-composition/index.ts @@ -0,0 +1,2 @@ +export { template as imagePromptCompositionTemplate } from './composition' +export { template as imagePromptCompositionTemplateEn } from './composition_en' diff --git a/packages/core/src/services/template/default-templates/image-prompt-extraction/extraction.ts b/packages/core/src/services/template/default-templates/image-prompt-extraction/extraction.ts deleted file mode 100644 index 93cdd952..00000000 --- a/packages/core/src/services/template/default-templates/image-prompt-extraction/extraction.ts +++ /dev/null @@ -1,58 +0,0 @@ -import type { MessageTemplate, Template } from '../../types'; - -export const template: Template = { - id: 'image-prompt-extraction', - name: '从图片提取 JSON 提示词', - content: [ - { - role: 'system', - content: `你是“图像提示词结构化编排器(JSON 输出)”。 - -请根据参考图片,提取一份可直接用于{{modeGoal}}的结构化 JSON 提示词。 - -只输出一个 JSON 对象(必须能被 JSON.parse 解析),不要输出解释、标题、Markdown、代码块、前后缀。 -顶层必须是 object,禁止数组包裹,必须使用双引号、无注释、无尾随逗号。 - -这个 JSON 对象必须严格使用以下格式: -{ - "prompt": { - "这里放可直接用于生图的结构化 JSON 提示词对象": true - }, - "defaults": { - "snake_case_variable": "该变量基于图片推断出的初始值" - } -} - -其中 prompt 是最终要给用户展示和使用的结构化 JSON 提示词,defaults 只是给变量面板提供默认值。 -prompt 的键名与字段值都使用中文,结构可以自由发挥,但要更具体、更可视、更可控。 - -请尽量提取颜色、光线、构图、镜头、材质、版式、氛围、风格标签、主体特征、细节约束等关键信息。 -若某些风格维度适合复用,请直接在 prompt 的字段值里改写成 \`{{variablePlaceholderExample}}\` 变量占位符,并在 defaults 中给出对应初始值。 -变量最多只能保留 {{maxExtractedVariables}} 个,而且必须是最核心、最影响复现效果的可复用特征。 -优先考虑主体核心材质、主色/色调、关键灯光或关键风格特征,不要把次要细节也参数化。 -defaults 中的每个值都必须来自对图片的视觉推断,目的是让用户不改变量时也能尽量快速复现原图。 -不要输出空的 defaults,也不要给没有在 prompt 中出现的变量提供 defaults。 -如果图片中有明显的文字、排版、UI、边框、颗粒、滤镜、材质、景深、镜头语言,也请体现在 JSON 中。 -如信息无法完全确定,请根据图像做最合理的视觉推断,但保持结果可直接用于生图。` - }, - { - role: 'user', - content: `请基于这张参考图片完成提取。 - -{{modeSpecificRequirement}} - -你可以参考以下结构,但不强制: -{{recommendedStructure}}` - } - ] as MessageTemplate[], - metadata: { - version: '1.0.0', - lastModified: Date.now(), - author: 'System', - description: '根据参考图片提取可直接用于生图的结构化 JSON 提示词', - templateType: 'image-prompt-extraction', - language: 'zh', - tags: ['image', 'json', 'prompt', 'extraction'] - }, - isBuiltin: true -}; diff --git a/packages/core/src/services/template/default-templates/image-prompt-extraction/extraction_en.ts b/packages/core/src/services/template/default-templates/image-prompt-extraction/extraction_en.ts deleted file mode 100644 index 5fa3e665..00000000 --- a/packages/core/src/services/template/default-templates/image-prompt-extraction/extraction_en.ts +++ /dev/null @@ -1,58 +0,0 @@ -import type { MessageTemplate, Template } from '../../types'; - -export const template: Template = { - id: 'image-prompt-extraction', - name: 'Extract JSON Prompt From Image', - content: [ - { - role: 'system', - content: `You are an "image prompt structuring orchestrator (JSON output)". - -Please analyze the reference image and extract a structured JSON prompt that can be used directly for {{modeGoal}}. - -Output exactly one JSON object that can be parsed by JSON.parse. Do not output explanations, titles, Markdown, code fences, or any extra prefix/suffix. -The top level must be an object, never an array. Use double quotes only, with no comments and no trailing commas. - -The JSON object must strictly use this envelope: -{ - "prompt": { - "put the final structured image-generation JSON prompt object here": true - }, - "defaults": { - "snake_case_variable": "the initial value inferred from the image" - } -} - -The \`prompt\` field is the final structured JSON prompt shown to the user and used for generation. The \`defaults\` field is only for prefilling the variable panel. -Use English for field names and field values inside \`prompt\`. The internal structure can be flexible, but it should be concrete, visual, and controllable. - -Extract key information such as color, lighting, composition, camera language, material, layout, mood, style tags, subject traits, and detail constraints whenever possible. -If some stylistic dimensions are worth reusing, rewrite those values inside \`prompt\` as \`{{variablePlaceholderExample}}\` placeholders and provide their initial values in \`defaults\`. -Keep at most {{maxExtractedVariables}} variables, and only keep the few most critical reusable features that have the greatest impact on reconstruction quality. -Prioritize core subject material, dominant color or tone, key lighting, and signature style traits. Do not parameterize minor details. -Every value in \`defaults\` must come from visual inference of the image, so users can quickly reproduce the image even without editing variables. -Do not output an empty \`defaults\` object, and do not include defaults for variables that do not appear in \`prompt\`. -If the image clearly contains text, typography, UI, borders, grain, filters, material texture, depth of field, or cinematic lens language, reflect that in the JSON. -If some details are uncertain, make the most reasonable visual inference while keeping the result directly usable for image generation.` - }, - { - role: 'user', - content: `Please complete the extraction based on this reference image. - -{{modeSpecificRequirement}} - -You may use the following structure as inspiration, but you do not have to follow it exactly: -{{recommendedStructure}}` - } - ] as MessageTemplate[], - metadata: { - version: '1.0.0', - lastModified: Date.now(), - author: 'System', - description: 'Extract a structured JSON image-generation prompt from a reference image', - templateType: 'image-prompt-extraction', - language: 'en', - tags: ['image', 'json', 'prompt', 'extraction'] - }, - isBuiltin: true -}; diff --git a/packages/core/src/services/template/default-templates/image-prompt-extraction/index.ts b/packages/core/src/services/template/default-templates/image-prompt-extraction/index.ts deleted file mode 100644 index c1a691fe..00000000 --- a/packages/core/src/services/template/default-templates/image-prompt-extraction/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { template as imagePromptExtractionTemplate } from './extraction'; -export { template as imagePromptExtractionTemplateEn } from './extraction_en'; diff --git a/packages/core/src/services/template/default-templates/image-prompt-migration/index.ts b/packages/core/src/services/template/default-templates/image-prompt-migration/index.ts new file mode 100644 index 00000000..c0b9326a --- /dev/null +++ b/packages/core/src/services/template/default-templates/image-prompt-migration/index.ts @@ -0,0 +1,2 @@ +export { template as imagePromptMigrationTemplate } from './migration'; +export { template as imagePromptMigrationTemplateEn } from './migration_en'; diff --git a/packages/core/src/services/template/default-templates/image-prompt-migration/migration.ts b/packages/core/src/services/template/default-templates/image-prompt-migration/migration.ts new file mode 100644 index 00000000..e017ab28 --- /dev/null +++ b/packages/core/src/services/template/default-templates/image-prompt-migration/migration.ts @@ -0,0 +1,48 @@ +import type { MessageTemplate, Template } from '../../types'; + +export const template: Template = { + id: 'image-prompt-migration', + name: '参考图迁移为当前提示词', + content: [ + { + role: 'system', + content: `你是“ReferenceSpec 迁移结构化编排器(JSON 输出)”。 + +你的任务是把“当前原始提示词”作为主体语义来源,把“参考图规格”作为视觉语言来源,合成为一份新的结构化 JSON 生图提示词。 + +只输出一个 JSON 对象(必须能被 JSON.parse 解析),不要输出解释、标题、Markdown、代码块、前后缀。 +顶层必须是 object,禁止数组包裹,必须使用双引号、无注释、无尾随逗号。 + +强约束: +1. 原始提示词语义优先于参考图中的主体内容。不要把参考图里的主体、数量、关系直接搬进最终结果。 +2. 参考图主要贡献风格、光线、色调、氛围、构图倾向、镜头语言、媒介感、材质感和版式倾向。 +3. 如果原始提示词中已经明确主体、数量、颜色、核心对象或关键动作,这些信息必须保留。 +4. 若参考图与原始提示词冲突,保留原始提示词主体语义,只迁移视觉语言。 +5. 直接输出最终 prompt JSON,不要包裹在 { "prompt": ... } 或 { "defaults": ... } 里。 +6. 不要输出变量占位符,先给出完整、具体、可直接生成的文字。 +7. prompt 的键名与字段值都使用中文,结构可以自由发挥,但必须具体、可视、可控。 + +当前工作模式:{{referenceMode}} + +参考图规格(ReferenceSpec JSON): +{{referenceSpecJson}}` + }, + { + role: 'user', + content: `请将下面这段原始提示词迁移为新的结构化 JSON 生图提示词,同时吸收参考图的视觉语言: + +{{originalPrompt}}` + } + ] as MessageTemplate[], + metadata: { + version: '1.0.0', + lastModified: Date.now(), + author: 'System', + description: '将参考图规格迁移到当前原始提示词,输出结构化 JSON 提示词', + templateType: 'image-prompt-migration', + language: 'zh', + tags: ['image', 'json', 'prompt', 'migration', 'internal'], + internalOnly: true, + }, + isBuiltin: true +} diff --git a/packages/core/src/services/template/default-templates/image-prompt-migration/migration_en.ts b/packages/core/src/services/template/default-templates/image-prompt-migration/migration_en.ts new file mode 100644 index 00000000..3be28dd5 --- /dev/null +++ b/packages/core/src/services/template/default-templates/image-prompt-migration/migration_en.ts @@ -0,0 +1,48 @@ +import type { MessageTemplate, Template } from '../../types'; + +export const template: Template = { + id: 'image-prompt-migration', + name: 'Migrate Reference Style Into Prompt', + content: [ + { + role: 'system', + content: `You are a "ReferenceSpec migration composer (JSON output)". + +Your job is to use the current original prompt as the source of subject semantics, and use the reference spec as the source of visual language, then merge them into a new structured JSON prompt for image generation. + +Output exactly one JSON object that can be parsed by JSON.parse. Do not output explanations, headings, Markdown, code fences, or extra text. +The top level must be an object, not an array. Use double quotes only, with no comments and no trailing commas. + +Hard constraints: +1. The original prompt has priority over the subject content from the reference image. Do not copy the reference subject into the final result. +2. The reference spec should mainly contribute style, lighting, color palette, atmosphere, composition bias, camera language, medium feel, material feel, and layout tendencies. +3. If the original prompt already specifies subject, quantity, color, key objects, or important actions, preserve them. +4. If the reference image conflicts with the original prompt, preserve the original subject semantics and only transfer the visual language. +5. Output the final prompt JSON directly. Do not wrap it in { "prompt": ... } or { "defaults": ... }. +6. Do not output variable placeholders. Produce a concrete, directly usable draft first. +7. The JSON structure should stay concrete, visual, and controllable. + +Reference workflow mode: {{referenceMode}} + +ReferenceSpec JSON: +{{referenceSpecJson}}` + }, + { + role: 'user', + content: `Please migrate the following original prompt into a new structured JSON image prompt while absorbing the reference image's visual language: + +{{originalPrompt}}` + } + ] as MessageTemplate[], + metadata: { + version: '1.0.0', + lastModified: Date.now(), + author: 'System', + description: 'Migrate a reference spec into the current original prompt and output structured JSON', + templateType: 'image-prompt-migration', + language: 'en', + tags: ['image', 'json', 'prompt', 'migration', 'internal'], + internalOnly: true, + }, + isBuiltin: true +} diff --git a/packages/core/src/services/template/default-templates/image-reference-spec-extraction/extraction.ts b/packages/core/src/services/template/default-templates/image-reference-spec-extraction/extraction.ts new file mode 100644 index 00000000..a1faacaa --- /dev/null +++ b/packages/core/src/services/template/default-templates/image-reference-spec-extraction/extraction.ts @@ -0,0 +1,46 @@ +import type { MessageTemplate, Template } from '../../types' + +export const template: Template = { + id: 'image-reference-spec-extraction', + name: '从参考图提取 ReferenceSpec', + content: [ + { + role: 'system', + content: `你是“参考图规格提取器(ReferenceSpec JSON 输出)”。 + +请根据参考图片,提取一份统一的 ReferenceSpec JSON,用来描述这张图的视觉规格,而不是直接输出最终生图 prompt。 + +只输出一个 JSON 对象(必须能被 JSON.parse 解析),不要输出解释、标题、Markdown、代码块、前后缀。 +顶层必须是 object,禁止数组包裹,必须使用双引号、无注释、无尾随逗号。 + +输出目标: +1. 聚焦这张图的风格语言、光线色调、构图镜头、媒介材质、主体观察、氛围节奏、版式信息与复现重点。 +2. 不要输出最终给用户使用的 prompt,不要输出 defaults,不要输出变量占位符。 +3. 参考规格应尽量稳定、抽象、可迁移,让后续步骤可以把它组合到文生图或图生图 prompt 中。 +4. 如果图片里有明显的文字、UI、边框、排版、颗粒、滤镜、材质、镜头语言,也要写入规格。 +5. 字段结构可以灵活,但必须具体、可理解、可供后续迁移使用。 + +当前工作模式:{{modeGoal}}` + }, + { + role: 'user', + content: `请基于这张参考图片提取 ReferenceSpec。 + +{{modeSpecificRequirement}} + +你可以参考以下结构,但不强制: +{{recommendedStructure}}` + } + ] as MessageTemplate[], + metadata: { + version: '1.0.0', + lastModified: Date.now(), + author: 'System', + description: '根据参考图片提取统一 ReferenceSpec JSON', + templateType: 'image-reference-spec-extraction', + language: 'zh', + tags: ['image', 'reference', 'spec', 'internal'], + internalOnly: true, + }, + isBuiltin: true +} diff --git a/packages/core/src/services/template/default-templates/image-reference-spec-extraction/extraction_en.ts b/packages/core/src/services/template/default-templates/image-reference-spec-extraction/extraction_en.ts new file mode 100644 index 00000000..44d202ae --- /dev/null +++ b/packages/core/src/services/template/default-templates/image-reference-spec-extraction/extraction_en.ts @@ -0,0 +1,46 @@ +import type { MessageTemplate, Template } from '../../types' + +export const template: Template = { + id: 'image-reference-spec-extraction', + name: 'Extract ReferenceSpec From Image', + content: [ + { + role: 'system', + content: `You are a "reference image spec extractor (ReferenceSpec JSON output)". + +Analyze the reference image and extract a unified ReferenceSpec JSON that describes the image's visual specification. Do not output the final generation prompt. + +Output exactly one JSON object that can be parsed by JSON.parse. Do not output explanations, headings, Markdown, code fences, or extra text. +The top level must be an object, never an array. Use double quotes only, with no comments and no trailing commas. + +Goals: +1. Focus on visual language, lighting and color, composition and camera, medium and material, subject observations, atmosphere, layout signals, and reconstruction priorities. +2. Do not output the final user-facing prompt, defaults, or variable placeholders. +3. The spec should stay stable, reusable, and transferable so later steps can compose it into text-to-image or image-to-image prompts. +4. If the image contains visible text, UI, borders, grain, filters, materials, or cinematic lens language, capture them in the spec. +5. The structure can stay flexible, but it must remain concrete and useful for later migration. + +Current workflow mode: {{modeGoal}}` + }, + { + role: 'user', + content: `Please extract a ReferenceSpec from this reference image. + +{{modeSpecificRequirement}} + +You may use the following structure as inspiration, but you do not have to follow it exactly: +{{recommendedStructure}}` + } + ] as MessageTemplate[], + metadata: { + version: '1.0.0', + lastModified: Date.now(), + author: 'System', + description: 'Extract a reusable ReferenceSpec JSON from a reference image', + templateType: 'image-reference-spec-extraction', + language: 'en', + tags: ['image', 'reference', 'spec', 'internal'], + internalOnly: true, + }, + isBuiltin: true +} diff --git a/packages/core/src/services/template/default-templates/image-reference-spec-extraction/index.ts b/packages/core/src/services/template/default-templates/image-reference-spec-extraction/index.ts new file mode 100644 index 00000000..f60e344a --- /dev/null +++ b/packages/core/src/services/template/default-templates/image-reference-spec-extraction/index.ts @@ -0,0 +1,2 @@ +export { template as imageReferenceSpecExtractionTemplate } from './extraction' +export { template as imageReferenceSpecExtractionTemplateEn } from './extraction_en' diff --git a/packages/core/src/services/template/default-templates/index.ts b/packages/core/src/services/template/default-templates/index.ts index 2fb91ea8..3d9b3197 100644 --- a/packages/core/src/services/template/default-templates/index.ts +++ b/packages/core/src/services/template/default-templates/index.ts @@ -133,9 +133,13 @@ import { template as evaluation_structured_compare_pair_judge_en } from './evalu import { template as evaluation_structured_compare_synthesis } from './evaluation-structured-compare/synthesis'; import { template as evaluation_structured_compare_synthesis_en } from './evaluation-structured-compare/synthesis_en'; -// 图片 JSON 提取模板 -import { imagePromptExtractionTemplate } from './image-prompt-extraction'; -import { imagePromptExtractionTemplateEn } from './image-prompt-extraction'; +// 参考图内部模板 +import { imageReferenceSpecExtractionTemplate } from './image-reference-spec-extraction'; +import { imageReferenceSpecExtractionTemplateEn } from './image-reference-spec-extraction'; +import { imagePromptCompositionTemplate } from './image-prompt-composition'; +import { imagePromptCompositionTemplateEn } from './image-prompt-composition'; +import { imagePromptMigrationTemplate } from './image-prompt-migration'; +import { imagePromptMigrationTemplateEn } from './image-prompt-migration'; // 简单的模板集合 - 模板自身已包含完整信息(id、name、language、type等) export const ALL_TEMPLATES = { @@ -256,7 +260,11 @@ export const ALL_TEMPLATES = { evaluation_structured_compare_pair_judge_en, evaluation_structured_compare_synthesis, evaluation_structured_compare_synthesis_en, - // 图片 JSON 提取模板 - imagePromptExtractionTemplate, - imagePromptExtractionTemplateEn, + // 参考图内部模板 + imageReferenceSpecExtractionTemplate, + imageReferenceSpecExtractionTemplateEn, + imagePromptCompositionTemplate, + imagePromptCompositionTemplateEn, + imagePromptMigrationTemplate, + imagePromptMigrationTemplateEn, }; diff --git a/packages/core/src/services/template/default-templates/variable-extraction/extraction.ts b/packages/core/src/services/template/default-templates/variable-extraction/extraction.ts index 5b34518b..98d48549 100644 --- a/packages/core/src/services/template/default-templates/variable-extraction/extraction.ts +++ b/packages/core/src/services/template/default-templates/variable-extraction/extraction.ts @@ -57,7 +57,9 @@ export const template: Template = { # 重要规则 -- 最多返回20个变量,按重要性排序 +- 最多返回5个变量,按重要性排序 +- 优先保留主体、数量、颜色、关键动作、关键场景或核心风格锚点 +- 避免提取低价值修饰词、重复限定词和局部装饰 - position.originalText 必须能在原文中精确找到 - position.occurrence 表示第几次出现(从1开始) - 如果原文中已有 {{变量}},不要重复提取 diff --git a/packages/core/src/services/template/default-templates/variable-extraction/extraction_en.ts b/packages/core/src/services/template/default-templates/variable-extraction/extraction_en.ts index ceb89481..de37af7e 100644 --- a/packages/core/src/services/template/default-templates/variable-extraction/extraction_en.ts +++ b/packages/core/src/services/template/default-templates/variable-extraction/extraction_en.ts @@ -57,7 +57,9 @@ Strictly use JSON format, wrapped in a \`\`\`json code block: # Important Rules -- Return at most 20 variables, sorted by importance +- Return at most 5 variables, sorted by importance +- Prioritize subject, count, color, key action, and key scene or core style anchor +- Avoid low-value decorative fragments, repeated modifiers, and minor embellishments - position.originalText must be precisely findable in the original text - position.occurrence indicates which occurrence (starting from 1) - If the original text already has {{variable}}, do not extract it again diff --git a/packages/core/src/services/template/static-loader.ts b/packages/core/src/services/template/static-loader.ts index 022c2103..e7090a1e 100644 --- a/packages/core/src/services/template/static-loader.ts +++ b/packages/core/src/services/template/static-loader.ts @@ -21,7 +21,9 @@ export type TemplateType = | 'context-user-optimize' | 'context-iterate' | 'evaluation' - | 'image-prompt-extraction'; + | 'image-reference-spec-extraction' + | 'image-prompt-composition' + | 'image-prompt-migration'; export type Language = 'zh' | 'en'; export interface StaticTemplateCollection { @@ -81,7 +83,9 @@ export class StaticLoader { 'context-user-optimize': { zh: {}, en: {} }, 'context-iterate': { zh: {}, en: {} }, 'evaluation': { zh: {}, en: {} }, - 'image-prompt-extraction': { zh: {}, en: {} } + 'image-reference-spec-extraction': { zh: {}, en: {} }, + 'image-prompt-composition': { zh: {}, en: {} }, + 'image-prompt-migration': { zh: {}, en: {} } }; // 处理每个模板 @@ -124,8 +128,14 @@ export class StaticLoader { case 'evaluation': normalizedType = 'evaluation'; break; - case 'image-prompt-extraction': - normalizedType = 'image-prompt-extraction'; + case 'image-reference-spec-extraction': + normalizedType = 'image-reference-spec-extraction'; + break; + case 'image-prompt-composition': + normalizedType = 'image-prompt-composition'; + break; + case 'image-prompt-migration': + normalizedType = 'image-prompt-migration'; break; case 'iterate': case 'optimize': @@ -161,7 +171,9 @@ export class StaticLoader { 'context-user-optimize': Object.keys(byType['context-user-optimize'].zh).length + Object.keys(byType['context-user-optimize'].en).length, 'context-iterate': Object.keys(byType['context-iterate'].zh).length + Object.keys(byType['context-iterate'].en).length, evaluation: Object.keys(byType.evaluation.zh).length + Object.keys(byType.evaluation.en).length, - 'image-prompt-extraction': Object.keys(byType['image-prompt-extraction'].zh).length + Object.keys(byType['image-prompt-extraction'].en).length + 'image-reference-spec-extraction': Object.keys(byType['image-reference-spec-extraction'].zh).length + Object.keys(byType['image-reference-spec-extraction'].en).length, + 'image-prompt-composition': Object.keys(byType['image-prompt-composition'].zh).length + Object.keys(byType['image-prompt-composition'].en).length, + 'image-prompt-migration': Object.keys(byType['image-prompt-migration'].zh).length + Object.keys(byType['image-prompt-migration'].en).length }); StaticLoader.templateCache = result; diff --git a/packages/core/src/services/template/types.ts b/packages/core/src/services/template/types.ts index 2ea722c6..6d365a43 100644 --- a/packages/core/src/services/template/types.ts +++ b/packages/core/src/services/template/types.ts @@ -25,7 +25,9 @@ export interface TemplateMetadata { | 'evaluation' | 'variable-extraction' | 'variable-value-generation' - | 'image-prompt-extraction'; // 模板类型标识(包含向后兼容的旧值) + | 'image-reference-spec-extraction' + | 'image-prompt-composition' + | 'image-prompt-migration'; // 模板类型标识 language?: 'zh' | 'en'; // 模板语言(可选,主要用于内置模板语言切换) [key: string]: any; // 允许任意额外字段 } @@ -153,8 +155,10 @@ export const templateSchema = z.object({ 'evaluation', 'variable-extraction', 'variable-value-generation', - 'image-prompt-extraction', - ]), // 🔧 向后兼容:保留旧枚举值 + 'image-reference-spec-extraction', + 'image-prompt-composition', + 'image-prompt-migration', + ]), language: z.enum(['zh', 'en']).optional() }).passthrough(), // 允许额外字段通过验证 isBuiltin: z.boolean().optional() diff --git a/packages/core/tests/unit/template/image-reference-template-types.test.ts b/packages/core/tests/unit/template/image-reference-template-types.test.ts new file mode 100644 index 00000000..9ca1d1ec --- /dev/null +++ b/packages/core/tests/unit/template/image-reference-template-types.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest' + +import { ALL_TEMPLATES } from '../../../src/services/template/default-templates' +import { StaticLoader } from '../../../src/services/template/static-loader' +import { templateSchema } from '../../../src/services/template/types' + +describe('image reference template types', () => { + it('accepts the new internal image reference template types', () => { + const base = { + id: 'test-template', + name: 'test template', + content: 'test content', + metadata: { + version: '1.0.0', + lastModified: Date.now(), + }, + } + + expect( + templateSchema.safeParse({ + ...base, + metadata: { + ...base.metadata, + templateType: 'image-reference-spec-extraction', + }, + }).success, + ).toBe(true) + + expect( + templateSchema.safeParse({ + ...base, + metadata: { + ...base.metadata, + templateType: 'image-prompt-composition', + }, + }).success, + ).toBe(true) + + expect( + templateSchema.safeParse({ + ...base, + metadata: { + ...base.metadata, + templateType: 'image-prompt-migration', + }, + }).success, + ).toBe(true) + }) + + it('rejects the removed image-prompt-extraction template type', () => { + const result = templateSchema.safeParse({ + id: 'legacy-template', + name: 'legacy template', + content: 'legacy content', + metadata: { + version: '1.0.0', + lastModified: Date.now(), + templateType: 'image-prompt-extraction', + }, + }) + + expect(result.success).toBe(false) + }) + + it('registers the new internal template ids and removes the legacy extractor id', () => { + const loader = new StaticLoader() + const templates = loader.loadTemplates() + const templateIds = Object.values(ALL_TEMPLATES).map((template) => template.id) + + expect(templateIds).toContain('image-reference-spec-extraction') + expect(templateIds).toContain('image-prompt-from-reference-spec') + expect(templateIds).toContain('image-prompt-migration') + expect(templateIds).not.toContain('image-prompt-extraction') + + expect(templates.all['image-reference-spec-extraction']).toBeDefined() + expect(templates.all['image-prompt-from-reference-spec']).toBeDefined() + expect(templates.all['image-prompt-migration']).toBeDefined() + expect(templates.all['image-prompt-extraction']).toBeUndefined() + expect( + templates.byType['image-reference-spec-extraction'].zh['image-reference-spec-extraction'], + ).toBeDefined() + expect( + templates.byType['image-prompt-composition'].zh['image-prompt-from-reference-spec'], + ).toBeDefined() + expect(templates.byType['image-prompt-migration'].zh['image-prompt-migration']).toBeDefined() + }) +}) diff --git a/packages/ui/src/components/image-mode/ImageImage2ImageWorkspace.vue b/packages/ui/src/components/image-mode/ImageImage2ImageWorkspace.vue index 3a2ae7db..b9e5ad0a 100644 --- a/packages/ui/src/components/image-mode/ImageImage2ImageWorkspace.vue +++ b/packages/ui/src/components/image-mode/ImageImage2ImageWorkspace.vue @@ -815,7 +815,7 @@ import type { SelectOption } from "../../types/select-options"; import { useToast } from "../../composables/ui/useToast"; import { getI18nErrorMessage } from '../../utils/error' import { - extractImageStyle, + resolveReferencePromptPreview, } from '../../services/ImageStyleExtractor' import { replaceTemporaryVariablesForPrompt } from '../../utils/image-prompt-extraction' import { VariableAwareInput } from '../variable-extraction' @@ -1818,14 +1818,29 @@ const extractPromptFromReferenceImage = async (imageB64: string, mimeType: strin return } + if (!selectedTextModelKey.value) { + toast.error(t('toast.error.noOptimizeModel')) + return + } + + if (!promptService.value) { + toast.error(t('toast.error.serviceInit')) + return + } + const modelConfig = await getImageRecognitionModelConfig() - const result = await extractImageStyle( - modelConfig, + const result = await resolveReferencePromptPreview({ + mode: 'replicate', + originalPrompt: '', imageB64, - mimeType || 'image/png', - 'image2image', - services.value?.templateManager, - ) + mimeType: mimeType || 'image/png', + modelConfig, + templateManager: services.value?.templateManager, + promptService: promptService.value, + variableExtractionService: services.value?.variableExtractionService, + modelKey: selectedTextModelKey.value, + referenceMode: 'image2image', + }) const variableCount = replaceTemporaryVariablesForPrompt( result.prompt, diff --git a/packages/ui/src/components/image-mode/ImageText2ImageWorkspace.vue b/packages/ui/src/components/image-mode/ImageText2ImageWorkspace.vue index f07d5514..9d946ead 100644 --- a/packages/ui/src/components/image-mode/ImageText2ImageWorkspace.vue +++ b/packages/ui/src/components/image-mode/ImageText2ImageWorkspace.vue @@ -1045,6 +1045,8 @@ const isIterating = ref(false) const isExtractingFromImage = ref(false) const extractImageInputRef = ref(null) const referenceBaseExtraction = ref(null) +const referenceDialogPendingCount = ref(0) +const referenceDialogPreviewToken = ref(0) // 历史管理专用 ref(不写入 session store) const currentChainId = ref('') @@ -1809,6 +1811,7 @@ const extractReferencePromptFromImage = async ( const resolveReferenceDialogPreview = async ( nextMode: ReferenceApplicationMode = referenceDialog.mode, + previewToken: number = referenceDialogPreviewToken.value, ) => { if (!referenceBaseExtraction.value) { return @@ -1832,9 +1835,28 @@ const resolveReferenceDialogPreview = async ( referenceMode: 'text2image', }) + if (previewToken !== referenceDialogPreviewToken.value) { + return + } + referenceDialog.setGeneratedPreview(preview) } +const beginReferenceDialogTask = () => { + referenceDialogPendingCount.value += 1 + isExtractingFromImage.value = true +} + +const endReferenceDialogTask = () => { + referenceDialogPendingCount.value = Math.max(0, referenceDialogPendingCount.value - 1) + isExtractingFromImage.value = referenceDialogPendingCount.value > 0 +} + +const createReferenceDialogPreviewToken = () => { + referenceDialogPreviewToken.value += 1 + return referenceDialogPreviewToken.value +} + const openReferenceImageDialog = () => { referenceBaseExtraction.value = null referenceDialog.openDialog() @@ -1885,7 +1907,8 @@ const handleReferenceImageFileChange = async (event: Event) => { return } - isExtractingFromImage.value = true + const previewToken = createReferenceDialogPreviewToken() + beginReferenceDialogTask() try { const { base64, mimeType } = await readImageFileAsBase64(file) @@ -1895,13 +1918,13 @@ const handleReferenceImageFileChange = async (event: Event) => { previewUrl: `data:${mimeType || 'image/png'};base64,${base64}`, }) referenceBaseExtraction.value = await extractReferencePromptFromImage(base64, mimeType) - await resolveReferenceDialogPreview(referenceDialog.mode) + await resolveReferenceDialogPreview(referenceDialog.mode, previewToken) } catch (error) { toast.error( getI18nErrorMessage(error, t('imageWorkspace.input.extractFailed')), ) } finally { - isExtractingFromImage.value = false + endReferenceDialogTask() } } @@ -1913,15 +1936,16 @@ const handleReferenceDialogModeChange = async (value: string | number | boolean) return } - isExtractingFromImage.value = true + const previewToken = createReferenceDialogPreviewToken() + beginReferenceDialogTask() try { - await resolveReferenceDialogPreview(nextMode) + await resolveReferenceDialogPreview(nextMode, previewToken) } catch (error) { toast.error( getI18nErrorMessage(error, t('imageWorkspace.input.extractFailed')), ) } finally { - isExtractingFromImage.value = false + endReferenceDialogTask() } } diff --git a/packages/ui/src/composables/prompt/useTemplateManager.ts b/packages/ui/src/composables/prompt/useTemplateManager.ts index 3141508c..8614c016 100644 --- a/packages/ui/src/composables/prompt/useTemplateManager.ts +++ b/packages/ui/src/composables/prompt/useTemplateManager.ts @@ -3,7 +3,13 @@ import type { TemplateMetadata } from '@prompt-optimizer/core' export type TemplateManagerTemplateType = Exclude< TemplateMetadata['templateType'], - 'contextSystemOptimize' | 'evaluation' | 'variable-extraction' | 'variable-value-generation' | 'image-prompt-extraction' + | 'contextSystemOptimize' + | 'evaluation' + | 'variable-extraction' + | 'variable-value-generation' + | 'image-reference-spec-extraction' + | 'image-prompt-composition' + | 'image-prompt-migration' > export interface TemplateManagerHooks { diff --git a/packages/ui/src/services/ImageStyleExtractor.ts b/packages/ui/src/services/ImageStyleExtractor.ts index 1e92465a..0650c77f 100644 --- a/packages/ui/src/services/ImageStyleExtractor.ts +++ b/packages/ui/src/services/ImageStyleExtractor.ts @@ -1,7 +1,10 @@ import { createImageUnderstandingService, + type ExtractedVariable, + type IPromptService, TemplateProcessor, type ITemplateManager, + type IVariableExtractionService, type Message, type Template, type TextModelConfig, @@ -9,30 +12,67 @@ import { import { VARIABLE_VALIDATION, isValidVariableName } from '../types/variable' export type ImagePromptExtractionMode = 'text2image' | 'image2image' +export type ReferenceApplicationMode = 'replicate' | 'migrate' -export interface ExtractImageStyleResult { +export interface ReferenceSpec { + spec: Record + rawText: string +} + +export interface PromptDraft { + prompt: string + rawText: string +} + +export interface VariableizedPrompt { prompt: string variableDefaults: Record rawText: string } +export type ReferencePromptPreview = VariableizedPrompt + +const MAX_REFERENCE_DIALOG_VARIABLES = 5 + interface ExtractionPrompts { systemPrompt: string userPrompt: string } -const MAX_EXTRACTED_VARIABLES = 3 -const IMAGE_PROMPT_EXTRACTION_TEMPLATE_ID = 'image-prompt-extraction' +export interface ResolveReferencePromptPreviewOptions { + mode: ReferenceApplicationMode + originalPrompt: string + modelKey?: string + promptService?: IPromptService | null + variableExtractionService?: IVariableExtractionService | null + referenceMode?: ImagePromptExtractionMode + referenceSpec?: ReferenceSpec | null + modelConfig?: TextModelConfig + imageB64?: string + mimeType?: string + templateManager?: ITemplateManager | null +} + +export interface ExtractPromptVariablesOptions { + prompt: string + rawText?: string + modelKey?: string + variableExtractionService?: IVariableExtractionService | null +} + +const IMAGE_REFERENCE_SPEC_EXTRACTION_TEMPLATE_ID = 'image-reference-spec-extraction' +const IMAGE_PROMPT_COMPOSITION_TEMPLATE_ID = 'image-prompt-from-reference-spec' +const IMAGE_PROMPT_MIGRATION_TEMPLATE_ID = 'image-prompt-migration' const imageUnderstandingService = createImageUnderstandingService() -export async function extractImageStyle( +export async function extractReferenceSpecFromImage( modelConfig: TextModelConfig, imageB64: string, mimeType: string, mode: ImagePromptExtractionMode, - templateManager: ITemplateManager | null | undefined -): Promise { - const prompts = await buildExtractionPrompts(templateManager, mode) + templateManager: ITemplateManager | null | undefined, +): Promise { + const prompts = await buildReferenceSpecPrompts(templateManager, mode) const response = await imageUnderstandingService.understand({ modelConfig, systemPrompt: prompts.systemPrompt || undefined, @@ -40,57 +80,248 @@ export async function extractImageStyle( images: [ { b64: imageB64, - mimeType - } + mimeType, + }, ], paramOverrides: { - temperature: 0.2 + temperature: 0.2, }, - responseMimeType: 'application/json' + responseMimeType: 'application/json', }) const rawText = typeof response.content === 'string' ? response.content.trim() : '' if (!rawText) { - throw new Error('Model did not return a valid extraction result') + throw new Error('Model did not return a valid reference spec') } - return normalizeExtractionResult(rawText) + return normalizeReferenceSpecResult(rawText) } -async function buildExtractionPrompts( +export async function composePromptFromReferenceSpec(options: { + promptService?: IPromptService | null + modelKey?: string + referenceSpec: ReferenceSpec + mode?: ImagePromptExtractionMode +}): Promise { + const { + promptService, + modelKey, + referenceSpec, + mode = 'text2image', + } = options + + if (!promptService) { + throw new Error('Prompt service is not initialized') + } + + if (!modelKey?.trim()) { + throw new Error('Text model is required for reference prompt composition') + } + + const rawText = await promptService.optimizePrompt({ + optimizationMode: 'user', + targetPrompt: '', + templateId: IMAGE_PROMPT_COMPOSITION_TEMPLATE_ID, + modelKey: modelKey.trim(), + advancedContext: { + variables: { + referenceSpecJson: JSON.stringify(referenceSpec.spec, null, 2), + referenceMode: mode, + }, + }, + }) + + return normalizePromptDraftResult(rawText) +} + +export async function migratePromptWithReferenceSpec(options: { + promptService?: IPromptService | null + modelKey?: string + originalPrompt: string + referenceSpec: ReferenceSpec + mode?: ImagePromptExtractionMode +}): Promise { + const { + promptService, + modelKey, + originalPrompt, + referenceSpec, + mode = 'text2image', + } = options + + if (!promptService) { + throw new Error('Prompt service is not initialized') + } + + if (!modelKey?.trim()) { + throw new Error('Text model is required for reference prompt migration') + } + + if (!originalPrompt.trim()) { + throw new Error('Original prompt is required for reference prompt migration') + } + + const rawText = await promptService.optimizePrompt({ + optimizationMode: 'user', + targetPrompt: originalPrompt, + templateId: IMAGE_PROMPT_MIGRATION_TEMPLATE_ID, + modelKey: modelKey.trim(), + advancedContext: { + variables: { + referenceSpecJson: JSON.stringify(referenceSpec.spec, null, 2), + referenceMode: mode, + }, + }, + }) + + return normalizePromptDraftResult(rawText) +} + +export async function extractPromptVariables( + options: ExtractPromptVariablesOptions, +): Promise { + const { + prompt, + rawText = prompt, + modelKey, + variableExtractionService, + } = options + + if (!prompt.trim()) { + return { + prompt, + variableDefaults: {}, + rawText, + } + } + + if (!variableExtractionService || !modelKey?.trim()) { + return { + prompt, + variableDefaults: {}, + rawText, + } + } + + const result = await variableExtractionService.extract({ + promptContent: prompt, + extractionModelKey: modelKey.trim(), + }) + + const limitedVariables = result.variables.slice(0, MAX_REFERENCE_DIALOG_VARIABLES) + const variableizedPrompt = replaceExtractedVariablesInPrompt(prompt, limitedVariables) + const keptVariableNames = scanVariablesFromValue(variableizedPrompt) + + return { + prompt: variableizedPrompt, + variableDefaults: buildFilteredDefaultsFromExtractedVariables( + keptVariableNames, + limitedVariables, + ), + rawText, + } +} + +export async function resolveReferencePromptPreview( + options: ResolveReferencePromptPreviewOptions, +): Promise { + const { + mode, + originalPrompt, + modelKey, + promptService, + variableExtractionService, + referenceMode = 'text2image', + } = options + + const referenceSpec = + options.referenceSpec ?? + (await extractReferenceSpecFromResolveOptions(options, referenceMode)) + + const promptDraft = + mode === 'migrate' && originalPrompt.trim() + ? await migratePromptWithReferenceSpec({ + promptService, + modelKey, + originalPrompt, + referenceSpec, + mode: referenceMode, + }) + : await composePromptFromReferenceSpec({ + promptService, + modelKey, + referenceSpec, + mode: referenceMode, + }) + + return extractPromptVariables({ + prompt: promptDraft.prompt, + rawText: promptDraft.rawText, + modelKey, + variableExtractionService, + }) +} + +async function extractReferenceSpecFromResolveOptions( + options: ResolveReferencePromptPreviewOptions, + referenceMode: ImagePromptExtractionMode, +): Promise { + const { modelConfig, imageB64, mimeType, templateManager } = options + + if (!modelConfig || !imageB64 || !mimeType) { + throw new Error('Reference spec or image extraction inputs are required') + } + + return extractReferenceSpecFromImage( + modelConfig, + imageB64, + mimeType, + referenceMode, + templateManager, + ) +} + +async function buildReferenceSpecPrompts( templateManager: ITemplateManager | null | undefined, - mode: ImagePromptExtractionMode + mode: ImagePromptExtractionMode, ): Promise { if (!templateManager) { throw new Error('Template manager is not initialized') } - const template = await templateManager.getTemplate(IMAGE_PROMPT_EXTRACTION_TEMPLATE_ID) + const template = await templateManager.getTemplate(IMAGE_REFERENCE_SPEC_EXTRACTION_TEMPLATE_ID) const messages = TemplateProcessor.processTemplate( template, - createExtractionTemplateContext(mode, template) + createReferenceSpecTemplateContext(mode, template), ) const systemPrompt = collectPromptByRole(messages, 'system') const userPrompt = collectPromptByRole(messages, 'user') if (!userPrompt) { - throw new Error('Image extraction template is missing a user prompt') + throw new Error('Reference spec extraction template is missing a user prompt') } return { systemPrompt, - userPrompt + userPrompt, } } -function normalizeExtractionResult(rawText: string): ExtractImageStyleResult { +function normalizeReferenceSpecResult(rawText: string): ReferenceSpec { const parsed = parseJsonObject(rawText) - const { promptObject, variableDefaults } = parseExtractionEnvelope(parsed) - const constrainedPromptObject = constrainPromptVariables(promptObject, variableDefaults) - const keptVariableNames = scanVariablesFromValue(constrainedPromptObject) - const filteredDefaults = buildFilteredDefaults(keptVariableNames, variableDefaults) - const formattedPrompt = JSON.stringify(constrainedPromptObject, null, 2) + const spec = isRecord(parsed.referenceSpec) ? parsed.referenceSpec : parsed + + return { + spec, + rawText, + } +} + +function normalizePromptDraftResult(rawText: string): PromptDraft { + const parsed = parseJsonObject(rawText) + const promptObject = resolvePromptObject(parsed.prompt ?? parsed) + const formattedPrompt = JSON.stringify(promptObject, null, 2) if (!formattedPrompt || formattedPrompt === 'null') { throw new Error('Model response is not a valid JSON object') @@ -98,24 +329,7 @@ function normalizeExtractionResult(rawText: string): ExtractImageStyleResult { return { prompt: formattedPrompt, - variableDefaults: filteredDefaults, - rawText - } -} - -function parseExtractionEnvelope(parsed: Record): { - promptObject: Record - variableDefaults: Record -} { - const promptCandidate = parsed.prompt - const defaultsCandidate = parsed.defaults - - const promptObject = resolvePromptObject(promptCandidate ?? parsed) - const variableDefaults = normalizeVariableDefaults(defaultsCandidate) - - return { - promptObject, - variableDefaults + rawText, } } @@ -135,83 +349,187 @@ function resolvePromptObject(value: unknown): Record { } } - throw new Error('Extraction result is missing a usable JSON prompt object') + throw new Error('Model response is missing a usable JSON prompt object') } -function normalizeVariableDefaults(value: unknown): Record { - if (!isRecord(value)) return {} +function buildFilteredDefaultsFromExtractedVariables( + variableNames: string[], + variables: ExtractedVariable[], +): Record { + const keptNames = new Set(variableNames) - const defaults: Record = {} - for (const [name, rawValue] of Object.entries(value)) { - const variableName = name.trim() - if (!isValidVariableName(variableName)) continue - if (typeof rawValue !== 'string') continue + return variables.reduce>((acc, variable) => { + const name = variable.name.trim() + const value = variable.value.trim() - const normalizedValue = rawValue.trim() - if (!normalizedValue) continue - - defaults[variableName] = normalizedValue - } - - return defaults -} - -function constrainPromptVariables( - promptObject: Record, - defaults: Record -): Record { - const variableNames = scanVariablesFromValue(promptObject) - const missingDefaults = variableNames.filter((name) => !defaults[name]) - - if (missingDefaults.length > 0) { - throw new Error(`Extraction result is missing variable default values: ${missingDefaults.join(', ')}`) - } - - const keptNames = variableNames.slice(0, MAX_EXTRACTED_VARIABLES) - const keptSet = new Set(keptNames) - - return replacePlaceholdersInValue(promptObject, keptSet, defaults) as Record -} - -function replacePlaceholdersInValue( - value: unknown, - keptNames: Set, - defaults: Record -): unknown { - if (typeof value === 'string') { - VARIABLE_VALIDATION.VARIABLE_SCAN_PATTERN.lastIndex = 0 - - return value.replace( - VARIABLE_VALIDATION.VARIABLE_SCAN_PATTERN, - (match, rawVariableName: string) => { - const variableName = rawVariableName.trim() - - if (!isValidVariableName(variableName)) { - return match - } - - if (keptNames.has(variableName)) { - return `{{${variableName}}}` - } - - return defaults[variableName] ?? match - } - ) - } - - if (Array.isArray(value)) { - return value.map((item) => replacePlaceholdersInValue(item, keptNames, defaults)) - } - - if (isRecord(value)) { - const next: Record = {} - for (const [key, child] of Object.entries(value)) { - next[key] = replacePlaceholdersInValue(child, keptNames, defaults) + if (!name || !value || !keptNames.has(name) || !isValidVariableName(name)) { + return acc } - return next + + acc[name] = value + return acc + }, {}) +} + +function findExtractedVariableOccurrenceIndex( + text: string, + searchText: string, + occurrence: number, +): number { + let count = 0 + let index = -1 + + while (count < occurrence) { + index = text.indexOf(searchText, index + 1) + if (index === -1) { + return -1 + } + count += 1 } - return value + return index +} + +export function replaceExtractedVariablesInPrompt( + prompt: string, + variables: ExtractedVariable[], +): string { + let nextPrompt = prompt + + const sortedVariables = [...variables].sort((a, b) => { + const indexA = findExtractedVariableOccurrenceIndex( + prompt, + a.position.originalText, + a.position.occurrence, + ) + const indexB = findExtractedVariableOccurrenceIndex( + prompt, + b.position.originalText, + b.position.occurrence, + ) + return indexB - indexA + }) + + for (const variable of sortedVariables) { + const index = findExtractedVariableOccurrenceIndex( + nextPrompt, + variable.position.originalText, + variable.position.occurrence, + ) + + if (index === -1) { + continue + } + + nextPrompt = + nextPrompt.slice(0, index) + + `{{${variable.name}}}` + + nextPrompt.slice(index + variable.position.originalText.length) + } + + return nextPrompt +} + +function parseJsonObject(rawText: string): Record { + const trimmed = rawText.trim() + const fencedMatch = trimmed.match(/```(?:json)?\s*([\s\S]*?)\s*```/i) + const candidate = fencedMatch?.[1]?.trim() || trimmed + const start = candidate.indexOf('{') + const end = candidate.lastIndexOf('}') + const jsonText = + start >= 0 && end >= start + ? candidate.slice(start, end + 1) + : candidate + + try { + const parsed = JSON.parse(jsonText) + if (!isRecord(parsed)) { + throw new Error('Model response is not a valid JSON object') + } + return parsed + } catch (error) { + if (error instanceof Error && error.message === 'Model response is not a valid JSON object') { + throw error + } + throw new Error('Model response is not valid JSON', { cause: error }) + } +} + +function createReferenceSpecTemplateContext( + mode: ImagePromptExtractionMode, + template: Template, +): Record { + const isEnglish = template.metadata.language === 'en' + + if (mode === 'image2image') { + return { + modeGoal: isEnglish ? 'image-to-image reference analysis' : '图生图参考分析', + modeSpecificRequirement: isEnglish + ? 'Focus on what the image is visually doing and what should be preserved or changed when used as a reference for image-to-image editing.' + : '重点说明这张图的视觉语言,以及在图生图参考场景下适合保留或改变的部分。', + recommendedStructure: isEnglish + ? [ + '{', + ' "subject_observation": { },', + ' "visual_language": { },', + ' "composition_camera": { },', + ' "lighting_color": { },', + ' "material_medium": { },', + ' "reference_guidance": { "preserve": ["..."], "change": ["..."] },', + ' "reconstruction_priorities": ["..."]', + '}', + ].join('\n') + : [ + '{', + ' "主体观察": { },', + ' "视觉语言": { },', + ' "构图镜头": { },', + ' "光线色彩": { },', + ' "材质媒介": { },', + ' "参考图指导": { "保留": ["..."], "改变": ["..."] },', + ' "复现重点": ["..."]', + '}', + ].join('\n'), + } + } + + return { + modeGoal: isEnglish ? 'text-to-image reference analysis' : '文生图参考分析', + modeSpecificRequirement: isEnglish + ? 'Focus on the image as a reusable visual direction, so later steps can transfer its style into a new generation prompt.' + : '把这张图视为可迁移的视觉方向,为后续生成新的文生图提示词提供参考规格。', + recommendedStructure: isEnglish + ? [ + '{', + ' "subject_observation": { },', + ' "visual_language": { },', + ' "composition_camera": { },', + ' "lighting_color": { },', + ' "material_medium": { },', + ' "layout_details": { },', + ' "reconstruction_priorities": ["..."]', + '}', + ].join('\n') + : [ + '{', + ' "主体观察": { },', + ' "视觉语言": { },', + ' "构图镜头": { },', + ' "光线色彩": { },', + ' "材质媒介": { },', + ' "版式细节": { },', + ' "复现重点": ["..."]', + '}', + ].join('\n'), + } +} + +function collectPromptByRole(messages: Message[], role: Message['role']): string { + return messages + .filter((message) => message.role === role && typeof message.content === 'string') + .map((message) => message.content.trim()) + .filter(Boolean) + .join('\n\n') } function scanVariablesFromValue(value: unknown): string[] { @@ -254,147 +572,6 @@ function scanVariablesFromValue(value: unknown): string[] { return found } -function buildFilteredDefaults( - variableNames: string[], - defaults: Record -): Record { - const next: Record = {} - for (const name of variableNames) { - const value = defaults[name] - if (value) { - next[name] = value - } - } - return next -} - -function parseJsonObject(rawText: string): Record { - const trimmed = rawText.trim() - const fencedMatch = trimmed.match(/```(?:json)?\s*([\s\S]*?)\s*```/i) - const candidate = fencedMatch?.[1]?.trim() || trimmed - const start = candidate.indexOf('{') - const end = candidate.lastIndexOf('}') - const jsonText = - start >= 0 && end >= start - ? candidate.slice(start, end + 1) - : candidate - - try { - const parsed = JSON.parse(jsonText) - if (!isRecord(parsed)) { - throw new Error('Model response is not a valid JSON object') - } - return parsed - } catch (error) { - if (error instanceof Error && error.message === 'Model response is not a valid JSON object') { - throw error - } - throw new Error('Model response is not valid JSON', { cause: error }) - } -} - -function createExtractionTemplateContext( - mode: ImagePromptExtractionMode, - template: Template -): Record { - const isEnglish = template.metadata.language === 'en' - const variablePlaceholderExample = '{{snake_case}}' - - if (mode === 'image2image') { - return { - modeGoal: isEnglish ? 'image-to-image generation' : '图生图', - modeSpecificRequirement: isEnglish - ? 'For image-to-image workflows, you may explicitly describe what to preserve and what to change relative to the reference image, but do not invent content that is not visually present.' - : '图生图场景下,可以在 JSON 中明确“保留/改变”的参考图指导,但不要虚构输入图中不存在的内容。', - recommendedStructure: isEnglish - ? [ - '{', - ' "scene": { },', - ' "reference_guidance": {', - ' "use_input_image_as_reference": true,', - ' "preserve": ["..."],', - ' "change": ["..."]', - ' },', - ' "constraints": { "must_keep": ["..."], "avoid": ["..."] },', - ' "negative_prompt": ["..."]', - '}' - ].join('\n') - : [ - '{', - ' "场景": { },', - ' "参考图指导": {', - ' "使用输入图作为参考": true,', - ' "保留": ["..."],', - ' "改变": ["..."]', - ' },', - ' "约束": { "必须保留": ["..."], "避免": ["..."] },', - ' "负面提示词": ["..."]', - '}' - ].join('\n'), - maxExtractedVariables: MAX_EXTRACTED_VARIABLES, - variablePlaceholderExample - } - } - - return { - modeGoal: isEnglish ? 'text-to-image generation' : '文生图', - modeSpecificRequirement: isEnglish - ? 'For text-to-image workflows, organize the subject, environment, camera, lighting, and style into a JSON structure that can be used directly for generation.' - : '文生图场景下,请把画面主体、环境、镜头、光影和风格描述整理成便于直接生图的 JSON 结构。', - recommendedStructure: isEnglish - ? [ - '{', - ' "scene": {', - ' "description": "...",', - ' "subjects": [', - ' { "type": "...", "description": "...", "attributes": { } }', - ' ],', - ' "environment": { },', - ' "action": { },', - ' "composition": { },', - ' "camera": { },', - ' "lighting": { },', - ' "color": { },', - ' "style": { },', - ' "details": ["..."]', - ' },', - ' "constraints": { "must_keep": ["..."], "avoid": ["..."] },', - ' "negative_prompt": ["..."]', - '}' - ].join('\n') - : [ - '{', - ' "场景": {', - ' "描述": "...",', - ' "主体": [', - ' { "类型": "...", "描述": "...", "属性": { } }', - ' ],', - ' "环境": { },', - ' "动作": { },', - ' "构图": { },', - ' "相机": { },', - ' "光照": { },', - ' "色彩": { },', - ' "风格": { },', - ' "细节": ["..."]', - ' },', - ' "约束": { "必须保留": ["..."], "避免": ["..."] },', - ' "负面提示词": ["..."]', - '}' - ].join('\n'), - maxExtractedVariables: MAX_EXTRACTED_VARIABLES, - variablePlaceholderExample - } -} - -function collectPromptByRole(messages: Message[], role: Message['role']): string { - return messages - .filter((message) => message.role === role && typeof message.content === 'string') - .map((message) => message.content.trim()) - .filter(Boolean) - .join('\n\n') -} - function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } diff --git a/packages/ui/tests/unit/services/ImageStyleExtractor.reference-migration.spec.ts b/packages/ui/tests/unit/services/ImageStyleExtractor.reference-migration.spec.ts new file mode 100644 index 00000000..2ac756b8 --- /dev/null +++ b/packages/ui/tests/unit/services/ImageStyleExtractor.reference-migration.spec.ts @@ -0,0 +1,340 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockUnderstand, mockProcessTemplate } = vi.hoisted(() => ({ + mockUnderstand: vi.fn(), + mockProcessTemplate: vi.fn(), +})) + +vi.mock('@prompt-optimizer/core', async () => { + const actual = await vi.importActual( + '@prompt-optimizer/core', + ) + + return { + ...actual, + createImageUnderstandingService: () => ({ + understand: mockUnderstand, + }), + TemplateProcessor: { + processTemplate: mockProcessTemplate, + }, + } +}) + +import { + extractPromptVariables, + resolveReferencePromptPreview, +} from '../../../src/services/ImageStyleExtractor' + +describe('ImageStyleExtractor reference migration pipeline', () => { + const modelConfig = { + provider: 'mock', + model: 'mock-image-understanding', + apiKey: 'test-key', + } as any + + const referenceSpec = { + 风格: { + 媒介: '胶片插画', + 光线: '傍晚逆光', + }, + 主体观察: { + 当前主体: '一只金毛犬', + }, + } + + beforeEach(() => { + vi.clearAllMocks() + + mockProcessTemplate.mockReturnValue([ + { role: 'system', content: 'extract reference spec' }, + { role: 'user', content: 'describe reference image' }, + ]) + + mockUnderstand.mockResolvedValue({ + content: JSON.stringify(referenceSpec), + }) + }) + + it('按参考图生成时,走 extract spec -> compose -> variables', async () => { + const templateManager = { + getTemplate: vi.fn().mockResolvedValue({ + id: 'image-reference-spec-extraction', + name: 'Extract Reference Spec', + content: 'unused', + metadata: { + version: '1.0.0', + lastModified: Date.now(), + templateType: 'image-reference-spec-extraction', + language: 'zh', + }, + }), + } + + const promptService = { + optimizePrompt: vi.fn().mockResolvedValue( + JSON.stringify({ + 场景: { + 主体: '一只棕色的猫', + 风格: '胶片感插画', + }, + }), + ), + } + + const variableExtractionService = { + extract: vi.fn().mockResolvedValue({ + variables: [ + { + name: '主体颜色', + value: '棕色', + position: { + originalText: '棕色', + occurrence: 1, + }, + reason: '颜色是主要可调参数', + }, + ], + summary: '提取到 1 个变量', + }), + } + + const preview = await resolveReferencePromptPreview({ + mode: 'replicate', + originalPrompt: '', + imageB64: 'ZmFrZS1pbWFnZQ==', + mimeType: 'image/png', + modelConfig, + templateManager: templateManager as any, + promptService: promptService as any, + variableExtractionService: variableExtractionService as any, + modelKey: 'text-model', + referenceMode: 'text2image', + }) + + expect(templateManager.getTemplate).toHaveBeenCalledWith('image-reference-spec-extraction') + expect(mockUnderstand).toHaveBeenCalledWith( + expect.objectContaining({ + modelConfig, + systemPrompt: 'extract reference spec', + userPrompt: 'describe reference image', + responseMimeType: 'application/json', + }), + ) + expect(promptService.optimizePrompt).toHaveBeenCalledWith( + expect.objectContaining({ + templateId: 'image-prompt-from-reference-spec', + modelKey: 'text-model', + targetPrompt: '', + advancedContext: { + variables: expect.objectContaining({ + referenceMode: 'text2image', + referenceSpecJson: JSON.stringify(referenceSpec, null, 2), + }), + }, + }), + ) + expect(variableExtractionService.extract).toHaveBeenCalledWith({ + promptContent: JSON.stringify( + { + 场景: { + 主体: '一只棕色的猫', + 风格: '胶片感插画', + }, + }, + null, + 2, + ), + extractionModelKey: 'text-model', + }) + expect(preview.prompt).toContain('{{主体颜色}}') + expect(preview.variableDefaults).toEqual({ + 主体颜色: '棕色', + }) + }) + + it('保留当前内容时,走 extract spec -> migrate -> variables,且保留原始主体语义', async () => { + const promptService = { + optimizePrompt: vi.fn().mockResolvedValue( + JSON.stringify({ + 场景: { + 主体: '两只棕色的猫', + 气氛: '胶片感、傍晚逆光', + }, + }), + ), + } + + const variableExtractionService = { + extract: vi.fn().mockResolvedValue({ + variables: [ + { + name: '主体颜色', + value: '棕色', + position: { + originalText: '棕色', + occurrence: 1, + }, + reason: '颜色是主要可调参数', + }, + ], + summary: '提取到 1 个变量', + }), + } + + const preview = await resolveReferencePromptPreview({ + mode: 'migrate', + originalPrompt: '两只棕色的猫', + imageB64: 'ZmFrZS1pbWFnZQ==', + mimeType: 'image/png', + modelConfig, + templateManager: { + getTemplate: vi.fn().mockResolvedValue({ + id: 'image-reference-spec-extraction', + name: 'Extract Reference Spec', + content: 'unused', + metadata: { + version: '1.0.0', + lastModified: Date.now(), + templateType: 'image-reference-spec-extraction', + language: 'zh', + }, + }), + } as any, + promptService: promptService as any, + variableExtractionService: variableExtractionService as any, + modelKey: 'text-model', + referenceMode: 'text2image', + }) + + expect(promptService.optimizePrompt).toHaveBeenCalledWith( + expect.objectContaining({ + templateId: 'image-prompt-migration', + targetPrompt: '两只棕色的猫', + advancedContext: { + variables: expect.objectContaining({ + referenceSpecJson: JSON.stringify(referenceSpec, null, 2), + }), + }, + }), + ) + expect(variableExtractionService.extract).toHaveBeenCalledTimes(1) + expect(preview.prompt).toContain('两只{{主体颜色}}的猫') + expect(preview.prompt).toContain('胶片感、傍晚逆光') + expect(preview.prompt).not.toContain('金毛犬') + expect(preview.variableDefaults).toEqual({ + 主体颜色: '棕色', + }) + }) + + it('变量抽取只保留最终 prompt 中实际出现的变量', async () => { + const variableExtractionService = { + extract: vi.fn().mockResolvedValue({ + variables: [ + { + name: '主体颜色', + value: '棕色', + position: { + originalText: '棕色', + occurrence: 1, + }, + reason: '主体颜色可复用', + }, + { + name: '背景场景', + value: '森林', + position: { + originalText: '森林', + occurrence: 1, + }, + reason: '错误提取,不应保留', + }, + ], + summary: '提取到 2 个变量', + }), + } + + const result = await extractPromptVariables({ + prompt: JSON.stringify( + { + 场景: { + 主体: '一只棕色的猫', + 背景: '白墙', + }, + }, + null, + 2, + ), + rawText: '{"场景":{"主体":"一只棕色的猫","背景":"白墙"}}', + modelKey: 'text-model', + variableExtractionService: variableExtractionService as any, + }) + + expect(result.prompt).toContain('一只{{主体颜色}}的猫') + expect(result.prompt).toContain('白墙') + expect(result.variableDefaults).toEqual({ + 主体颜色: '棕色', + }) + }) + + it('变量抽取最多只保留 5 个高优先级变量', async () => { + const variableExtractionService = { + extract: vi.fn().mockResolvedValue({ + variables: [ + { + name: '主体', + value: '猫', + position: { originalText: '猫', occurrence: 1 }, + reason: '主体可复用', + }, + { + name: '数量', + value: '两只', + position: { originalText: '两只', occurrence: 1 }, + reason: '数量可复用', + }, + { + name: '颜色', + value: '棕色', + position: { originalText: '棕色', occurrence: 1 }, + reason: '颜色可复用', + }, + { + name: '动作', + value: '奔跑', + position: { originalText: '奔跑', occurrence: 1 }, + reason: '动作可复用', + }, + { + name: '场景', + value: '草地', + position: { originalText: '草地', occurrence: 1 }, + reason: '场景可复用', + }, + { + name: '光线', + value: '黄昏逆光', + position: { originalText: '黄昏逆光', occurrence: 1 }, + reason: '超过上限后不应保留', + }, + ], + summary: '提取到 6 个变量', + }), + } + + const result = await extractPromptVariables({ + prompt: '两只棕色的猫在草地上奔跑,黄昏逆光', + modelKey: 'text-model', + variableExtractionService: variableExtractionService as any, + }) + + expect(Object.keys(result.variableDefaults)).toHaveLength(5) + expect(result.prompt).toContain('{{主体}}') + expect(result.prompt).toContain('{{数量}}') + expect(result.prompt).toContain('{{颜色}}') + expect(result.prompt).toContain('{{动作}}') + expect(result.prompt).toContain('{{场景}}') + expect(result.prompt).toContain('黄昏逆光') + expect(result.variableDefaults).not.toHaveProperty('光线') + }) +})