fix(ui): wire image workspace analysis actions

This commit is contained in:
linshen committed 2026-05-10 15:20:00 +08:00
1 parent 68adf69036
commit efda7828b8
20 files changed
+837 -12

No files matched your search

@@ -0,0 +1,67 @@
import { createImageAnalysisTemplate } from '../builders';
export const template = createImageAnalysisTemplate(
{
id: 'evaluation-image-multiimage-prompt-only',
name: '多图生图提示词直接评估',
description: '直接评估多图生图提示词质量(多图模式)',
language: 'zh',
tags: ['evaluation', 'prompt-only', 'scoring', 'image', 'multiimage'],
},
{
subjectLabel: {
zh: '多图生图提示词',
en: 'multi-image generation prompt',
},
roleName: {
zh: '多图生图提示词分析专家',
en: 'Multi_Image_Prompt_Analysis_Expert',
},
dimensions: [
{
key: 'referenceIntegration',
label: {
zh: '参考图融合明确性',
en: 'Reference Integration',
},
description: {
zh: '是否清晰说明多张参考图各自的作用、取舍和融合关系?',
en: 'Does it clearly state each reference image role, priority, and integration relationship?',
},
},
{
key: 'generationIntent',
label: {
zh: '生成目标清晰度',
en: 'Generation Intent',
},
description: {
zh: '是否明确描述最终画面的主体、场景、构图和目标效果?',
en: 'Does it clearly describe the final subject, scene, composition, and target effect?',
},
},
{
key: 'constraintClarity',
label: {
zh: '保留与修改约束',
en: 'Preservation and Edit Constraints',
},
description: {
zh: '是否准确说明哪些元素应保留、替换、强化或弱化?',
en: 'Does it accurately specify which elements should be preserved, replaced, emphasized, or reduced?',
},
},
{
key: 'styleConsistency',
label: {
zh: '风格一致性',
en: 'Style Consistency',
},
description: {
zh: '是否给出足够的风格、光影、色彩和质量约束来统一多图来源?',
en: 'Does it provide enough style, lighting, color, and quality constraints to unify multiple image sources?',
},
},
],
},
);
@@ -0,0 +1,67 @@
import { createImageAnalysisTemplate } from '../builders';
export const template = createImageAnalysisTemplate(
{
id: 'evaluation-image-multiimage-prompt-only',
name: 'Multi-Image Prompt Direct Evaluation',
description: 'Direct evaluation of multi-image generation prompt quality',
language: 'en',
tags: ['evaluation', 'prompt-only', 'scoring', 'image', 'multiimage'],
},
{
subjectLabel: {
zh: '多图生图提示词',
en: 'multi-image generation prompt',
},
roleName: {
zh: '多图生图提示词分析专家',
en: 'Multi_Image_Prompt_Analysis_Expert',
},
dimensions: [
{
key: 'referenceIntegration',
label: {
zh: '参考图融合明确性',
en: 'Reference Integration',
},
description: {
zh: '是否清晰说明多张参考图各自的作用、取舍和融合关系?',
en: 'Does it clearly state each reference image role, priority, and integration relationship?',
},
},
{
key: 'generationIntent',
label: {
zh: '生成目标清晰度',
en: 'Generation Intent',
},
description: {
zh: '是否明确描述最终画面的主体、场景、构图和目标效果?',
en: 'Does it clearly describe the final subject, scene, composition, and target effect?',
},
},
{
key: 'constraintClarity',
label: {
zh: '保留与修改约束',
en: 'Preservation and Edit Constraints',
},
description: {
zh: '是否准确说明哪些元素应保留、替换、强化或弱化?',
en: 'Does it accurately specify which elements should be preserved, replaced, emphasized, or reduced?',
},
},
{
key: 'styleConsistency',
label: {
zh: '风格一致性',
en: 'Style Consistency',
},
description: {
zh: '是否给出足够的风格、光影、色彩和质量约束来统一多图来源?',
en: 'Does it provide enough style, lighting, color, and quality constraints to unify multiple image sources?',
},
},
],
},
);
@@ -0,0 +1,6 @@
/**
* 评估模板 - 图像模式/多图生图
*/
export { template as evaluationImageMultiImagePromptOnly } from './evaluation-prompt-only';
export { template as evaluationImageMultiImagePromptOnlyEn } from './evaluation-prompt-only_en';
@@ -63,3 +63,9 @@ export {
evaluationImageImage2ImagePromptOnly,
evaluationImageImage2ImagePromptOnlyEn,
} from './image/image2image';
// 图像模式 - 多图生图评估
export {
evaluationImageMultiImagePromptOnly,
evaluationImageMultiImagePromptOnlyEn,
} from './image/multiimage';
@@ -120,6 +120,9 @@ import { template as evaluation_image_text2image_compare_en } from './evaluation
// 评估模板 - 图像模式/图生图
import { template as evaluation_image_image2image_prompt_only } from './evaluation/image/image2image/evaluation-prompt-only';
import { template as evaluation_image_image2image_prompt_only_en } from './evaluation/image/image2image/evaluation-prompt-only_en';
// 评估模板 - 图像模式/多图生图
import { template as evaluation_image_multiimage_prompt_only } from './evaluation/image/multiimage/evaluation-prompt-only';
import { template as evaluation_image_multiimage_prompt_only_en } from './evaluation/image/multiimage/evaluation-prompt-only_en';
// 变量提取模板
import { variableExtractionTemplate } from './variable-extraction';
@@ -257,6 +260,9 @@ export const ALL_TEMPLATES = {
// 评估模板 - 图像模式/图生图
evaluation_image_image2image_prompt_only,
evaluation_image_image2image_prompt_only_en,
// 评估模板 - 图像模式/多图生图
evaluation_image_multiimage_prompt_only,
evaluation_image_multiimage_prompt_only_en,
// 变量提取模板
variableExtractionTemplate,
variableExtractionTemplateEn,
@@ -4,6 +4,8 @@ import { template as text2imagePromptOnlyZh } from '../../../src/services/templa
import { template as text2imagePromptOnlyEn } from '../../../src/services/template/default-templates/evaluation/image/text2image/evaluation-prompt-only_en';
import { template as image2imagePromptOnlyZh } from '../../../src/services/template/default-templates/evaluation/image/image2image/evaluation-prompt-only';
import { template as image2imagePromptOnlyEn } from '../../../src/services/template/default-templates/evaluation/image/image2image/evaluation-prompt-only_en';
import { template as multiimagePromptOnlyZh } from '../../../src/services/template/default-templates/evaluation/image/multiimage/evaluation-prompt-only';
import { template as multiimagePromptOnlyEn } from '../../../src/services/template/default-templates/evaluation/image/multiimage/evaluation-prompt-only_en';
describe('image evaluation prompt-only templates JSON evidence injection', () => {
const baseContext: TemplateContext = {
@@ -23,6 +25,8 @@ describe('image evaluation prompt-only templates JSON evidence injection', () =>
['en-text2image', text2imagePromptOnlyEn],
['zh-image2image', image2imagePromptOnlyZh],
['en-image2image', image2imagePromptOnlyEn],
['zh-multiimage', multiimagePromptOnlyZh],
['en-multiimage', multiimagePromptOnlyEn],
])('should render %s image evaluation prompt-only template with JSON-wrapped evidence', (_label, template) => {
const messages = TemplateProcessor.processTemplate(template, baseContext);
@@ -66,6 +66,18 @@ describe('image text2image evaluation templates', () => {
expect(promptOnlyTemplate?.content[0]?.content).toContain('只输出合法 JSON,禁止 Markdown')
})
it('registers the zh-CN multiimage prompt-only template', async () => {
vi.spyOn(languageService, 'getCurrentLanguage').mockReturnValue('zh-CN')
const promptOnlyTemplate = await templateManager.getTemplate('evaluation-image-multiimage-prompt-only')
expect(promptOnlyTemplate).toBeDefined()
expect(promptOnlyTemplate?.content[0]?.content).toContain('当前工作区多图生图提示词')
expect(promptOnlyTemplate?.content[0]?.content).toContain('参考图融合明确性')
expect(promptOnlyTemplate?.content[1]?.content).toContain('"workspacePrompt":')
expect(promptOnlyTemplate?.content[1]?.content).toContain('"analysisStage":')
})
it('registers the zh-CN image prompt-iterate template with iteration evidence', async () => {
vi.spyOn(languageService, 'getCurrentLanguage').mockReturnValue('zh-CN')
@@ -124,6 +136,18 @@ describe('image text2image evaluation templates', () => {
expect(promptOnlyTemplate?.content[0]?.content).toContain('No Markdown, no code fences')
})
it('registers the en-US multiimage prompt-only template', async () => {
vi.spyOn(languageService, 'getCurrentLanguage').mockReturnValue('en-US')
const promptOnlyTemplate = await templateManager.getTemplate('evaluation-image-multiimage-prompt-only')
expect(promptOnlyTemplate).toBeDefined()
expect(promptOnlyTemplate?.content[0]?.content).toContain('current workspace multi-image generation prompt')
expect(promptOnlyTemplate?.content[0]?.content).toContain('Reference Integration')
expect(promptOnlyTemplate?.content[1]?.content).toContain('"workspacePrompt":')
expect(promptOnlyTemplate?.content[1]?.content).toContain('"analysisStage":')
})
it('registers the en-US image prompt-iterate template with iteration evidence', async () => {
vi.spyOn(languageService, 'getCurrentLanguage').mockReturnValue('en-US')
@@ -135,6 +135,7 @@
@open-preview="handleOpenPromptPreview"
@apply-to-conversation="handleApplyToConversation"
@apply-improvement="handleApplyImprovement"
@apply-patch="handleApplyLocalPatch"
@save-local-edit="handleSaveLocalEdit"
/>
</template>
@@ -63,6 +63,7 @@
@show-primary-detail="emit('show-primary-detail')"
@show-secondary-detail="emit('show-secondary-detail')"
@apply-improvement="emit('apply-improvement', $event)"
@apply-patch="emit('apply-patch', $event)"
>
<template #primary-result>
<slot name="primary-result"></slot>
@@ -105,7 +106,7 @@ import TestControlBar from "../TestControlBar.vue";
import TestResultSection from "../TestResultSection.vue";
import TemporaryVariablesPanel from "../variable/TemporaryVariablesPanel.vue";
import VariableValuePreviewDialog from "../variable/VariableValuePreviewDialog.vue";
import type { EvaluationResponse, EvaluationType } from '@prompt-optimizer/core';
import type { EvaluationResponse, EvaluationType, PatchOperation } from '@prompt-optimizer/core';
import type { ScoreLevel } from '../../composables/prompt/useEvaluation';
import type { AppServices } from '../../types/services';
@@ -229,6 +230,7 @@ const emit = defineEmits<{
"show-primary-detail": [];
"show-secondary-detail": [];
"apply-improvement": [payload: { improvement: string; type: EvaluationType }];
"apply-patch": [payload: { operation: PatchOperation }];
}>();
// 处理对比模式切换
@@ -185,9 +185,10 @@
@openTemplateManager="handleOpenTemplateManager"
@switchVersion="handleSwitchVersion"
@switchToV0="handleSwitchToV0"
@save-favorite="handleSaveFavorite"
@save-favorite="handleSaveFavorite"
@open-preview="handleOpenPromptPreview"
@apply-improvement="handleApplyImprovement"
@apply-patch="handleApplyLocalPatch"
@save-local-edit="handleSaveLocalEdit"
/>
</NCard>
@@ -63,6 +63,7 @@
@show-primary-detail="emit('show-primary-detail')"
@show-secondary-detail="emit('show-secondary-detail')"
@apply-improvement="emit('apply-improvement', $event)"
@apply-patch="emit('apply-patch', $event)"
>
<template #primary-result>
<div class="result-container">
@@ -135,6 +136,7 @@ import type {
ToolCallResult,
EvaluationResponse,
EvaluationType,
PatchOperation,
} from "@prompt-optimizer/core";
import type { ScoreLevel } from '../../composables/prompt/useEvaluation';
import { useResponsive } from '../../composables/ui/useResponsive';
@@ -269,6 +271,7 @@ const emit = defineEmits<{
"show-primary-detail": [];
"show-secondary-detail": [];
"apply-improvement": [payload: { improvement: string; type: EvaluationType }];
"apply-patch": [payload: { operation: PatchOperation }];
}>();
// 🆕 工具调用状态管理(按 variantId 分桶)
@@ -373,6 +373,24 @@
<!-- 优化按钮 -->
<NGridItem :span="6" :xs="24" :sm="6" class="flex items-end justify-end">
<NSpace :size="8">
<NButton
type="default"
size="medium"
data-testid="image-image2image-analyze-button"
:loading="isAnalyzing"
@click="handleAnalyzePrompt"
:disabled="
isAnalyzing ||
isOptimizing ||
!originalPrompt.trim()
"
>
{{
isAnalyzing
? t("promptOptimizer.analyzing")
: t("promptOptimizer.analyze")
}}
</NButton>
<NButton
type="primary"
size="medium"
@@ -380,6 +398,7 @@
:loading="isOptimizing"
@click="handleOptimizePrompt"
:disabled="
isAnalyzing ||
isOptimizing ||
!originalPrompt.trim() ||
!inputImageB64 ||
@@ -419,12 +438,15 @@
:optimization-mode="optimizationMode"
:advanced-mode-enabled="advancedModeEnabled"
:show-preview="true"
evaluation-type-override="prompt-only"
iterate-template-type="imageIterate"
@iterate="handleIteratePrompt"
@openTemplateManager="onOpenTemplateManager"
@switchVersion="handleSwitchVersion"
@save-favorite="handleSaveFavorite"
@save-local-edit="handleSaveLocalEdit"
@apply-improvement="handleApplyImprovement"
@apply-patch="handleApplyPatch"
@open-preview="handleOpenPromptPreview"
/>
</NCard>
@@ -672,6 +694,29 @@
</div>
</div>
<EvaluationPanel
v-model:show="evaluation.isPanelVisible.value"
:is-evaluating="panelProps.isEvaluating"
:result="panelProps.result"
:stream-content="panelProps.streamContent"
:error="panelProps.error"
:current-type="panelProps.currentType"
:score-level="panelProps.scoreLevel"
:rewrite-recommendation="panelProps.rewriteRecommendation"
:rewrite-reasons="panelProps.rewriteReasons"
:stale="activeEvaluationStale"
:stale-message="activeEvaluationStaleMessage"
:disable-evaluate="activeEvaluationDisableEvaluate"
:disable-evaluate-reason="activeEvaluationDisableReason"
:can-rewrite-from-evaluation="false"
@apply-local-patch="handleApplyPatch"
@apply-improvement="handleApplyImprovement"
@re-evaluate="handleReEvaluateActive"
@evaluate-with-feedback="handleEvaluateActiveWithFeedback"
@clear="handleClearEvaluation"
@retry="handleReEvaluateActive"
/>
<!-- 原始提示词 - 全屏编辑器 -->
<FullscreenDialog
v-model="isFullscreen"
@@ -772,7 +817,7 @@
</template>
<script setup lang="ts">
import { onMounted, onUnmounted, inject, ref, reactive, computed, watch, nextTick, type Ref } from 'vue'
import { onMounted, onUnmounted, inject, ref, reactive, computed, watch, nextTick, toRef, type Ref } from 'vue'
import { useRouter, type LocationQueryRaw } from 'vue-router'
import {
@@ -806,6 +851,7 @@ import PromptGardenImportDialog from '../common/PromptGardenImportDialog.vue'
import PromptPreviewPanel from "../PromptPreviewPanel.vue";
import SelectWithConfig from "../SelectWithConfig.vue";
import TestPanelVersionSelect from '../TestPanelVersionSelect.vue'
import { EvaluationPanel } from '../evaluation'
import { useLocalPromptPreviewPanel } from '../../composables/prompt/useLocalPromptPreviewPanel'
import { OptionAccessors } from "../../utils/data-transformer";
import type { AppServices } from "../../types/services";
@@ -818,6 +864,7 @@ import { withHistorySourceBindingMetadata } from '../../utils/history-source-bin
import { resolveSourceAssetRef } from '../../utils/source-asset'
import { downloadImageSource } from '../../utils/image-download'
import { openExternalUrl } from '../../utils/open-external-url'
import { createImagePromptAnalysisVersion } from '../../utils/imagePromptAnalysis'
import type { PromptGardenImportRequest } from '../../utils/prompt-garden-import'
import { VariableAwareInput } from '../variable-extraction'
import TemporaryVariablesPanel from '../variable/TemporaryVariablesPanel.vue'
@@ -828,6 +875,8 @@ import { useTemporaryVariables } from '../../composables/variable/useTemporaryVa
import { useVariableAwareInputBridge } from '../../composables/variable/useVariableAwareInputBridge'
import { useTestVariableManager } from '../../composables/variable/useTestVariableManager'
import { useSmartVariableValueGeneration } from '../../composables/variable/useSmartVariableValueGeneration'
import { useEvaluationHandler } from '../../composables/prompt/useEvaluationHandler'
import { provideEvaluation } from '../../composables/prompt/useEvaluationContext'
import type { VariableManagerHooks } from '../../composables/prompt/useVariableManager'
import {
buildPromptExecutionContext,
@@ -847,11 +896,13 @@ import {
} from '../../stores/session/useImageImage2ImageSession'
import { useImageGeneration } from '../../composables/image/useImageGeneration'
import ImageTokenUsage from './ImageTokenUsage.vue'
import { useFunctionModelManager } from '../../composables/model'
import { useWorkspaceTemplateSelection } from '../../composables/workspaces/useWorkspaceTemplateSelection'
import { useWorkspaceTextModelSelection } from '../../composables/workspaces/useWorkspaceTextModelSelection'
import { useElementSize } from '@vueuse/core'
import { runTasksWithExecutionMode } from '../../utils/runTasksSequentially'
import {
applyPatchOperationsToText,
getEnvVar,
type ContextMode,
type ImageModelConfig,
@@ -860,6 +911,7 @@ import {
type ImageResultItem,
type OptimizationMode,
type OptimizationRequest,
type PatchOperation,
type PromptRecordChain,
type PromptRecordType,
type Template,
@@ -965,6 +1017,7 @@ const promptService = computed(() => services.value?.promptService)
// 过程态(本地,不持久化)
const isOptimizing = ref(false)
const isAnalyzing = ref(false)
const isIterating = ref(false)
const uploadStatus = ref<'idle' | 'uploading' | 'success' | 'error'>('idle')
const uploadProgress = ref(0)
@@ -1007,6 +1060,7 @@ const optimizedReasoning = computed<string>({
// Text 模型选择(与模板选择对齐:自动刷新 + 兜底写回 session store)
const modelSelection = useWorkspaceTextModelSelection(services, session)
const selectedTextModelKey = modelSelection.selectedTextModelKey
const functionModelManager = useFunctionModelManager(services)
const selectedImageModelKey = computed<string>({
get: () => session.selectedImageModelKey || '',
@@ -1023,6 +1077,84 @@ const templateSelection = useWorkspaceTemplateSelection(
const selectedTemplateId = templateSelection.selectedTemplateId
const templateOptions = templateSelection.templateOptions
const evaluationHandler = useEvaluationHandler({
services,
analysisOptimizedPrompt: computed(() => optimizedPrompt.value || ''),
analysisTargetResolver: (defaultTarget) => ({
...defaultTarget,
referencePrompt: (originalPrompt.value || '').trim() || undefined,
}),
evaluationModelKey: computed(() => selectedTextModelKey.value || ''),
resolveEvaluationModelKey: async () => {
await functionModelManager.initialize()
return (
functionModelManager.evaluationModel.value ||
selectedTextModelKey.value ||
functionModelManager.effectiveEvaluationModel.value ||
''
)
},
functionMode: computed(() => 'image'),
subMode: computed(() => 'image2image'),
persistedResults: toRef(session, 'evaluationResults'),
})
provideEvaluation(evaluationHandler.evaluation)
const { evaluation, handleEvaluate: handleEvaluateInternal } = evaluationHandler
const panelProps = evaluationHandler.panelProps
const activeEvaluationStale = computed(() => false)
const activeEvaluationStaleMessage = computed(() => t('evaluation.stale.promptOnly'))
const activeEvaluationDisableEvaluate = computed(() =>
panelProps.value.currentType === 'prompt-only' &&
!optimizedPrompt.value.trim(),
)
const activeEvaluationDisableReason = computed(() => '')
const handleAnalyzePrompt = async () => {
const prompt = originalPrompt.value.trim()
if (!prompt || isAnalyzing.value) return
isAnalyzing.value = true
try {
const virtualV0 = createImagePromptAnalysisVersion(
prompt,
'image2imageOptimize' as PromptRecordType,
)
currentChainId.value = ''
currentVersions.value = [virtualV0]
currentVersionId.value = virtualV0.id
session.updateOptimizedResult({
optimizedPrompt: prompt,
reasoning: '',
chainId: '',
versionId: '',
})
evaluation.clearResult('prompt-only')
evaluation.clearResult('prompt-iterate')
await nextTick()
await handleEvaluateInternal('prompt-only')
} finally {
isAnalyzing.value = false
}
}
const handleReEvaluateActive = async () => {
if (!evaluation.state.activeDetail) return
await evaluationHandler.handleReEvaluate()
}
const handleEvaluateActiveWithFeedback = async (payload: { feedback: string }) => {
if (!evaluation.state.activeDetail) return
await evaluationHandler.handleEvaluateActiveWithFeedback(payload.feedback)
}
const handleClearEvaluation = () => {
evaluation.closePanel()
evaluation.clearAllResults()
}
const showPromptGardenImport = ref(false)
const isPromptGardenEnabled = computed(() => {
@@ -1770,6 +1902,20 @@ const handleSaveLocalEdit = async (payload: { note?: string }) => {
// PromptPanel 引用,用于在语言切换后刷新迭代模板选择
const promptPanelRef = ref<InstanceType<typeof PromptPanelUI> | null>(null);
const handleApplyImprovement = evaluationHandler.createApplyImprovementHandler(promptPanelRef)
const handleApplyPatch = (payload: { operation: PatchOperation }) => {
if (!payload.operation) return
const current = optimizedPrompt.value || ''
const result = applyPatchOperationsToText(current, payload.operation)
if (!result.ok) {
toast.warning(t('toast.warning.patchApplyFailed'))
return
}
optimizedPrompt.value = result.text
toast.success(t('evaluation.diagnose.applyFix'))
}
// 输入区折叠状态(初始展开)
const isInputPanelCollapsed = ref(false);
@@ -262,12 +262,22 @@
<NGridItem :span="6" :xs="24" :sm="6" class="flex items-end justify-end">
<NSpace :size="8">
<NButton
type="default"
size="medium"
data-testid="image-multiimage-analyze-button"
:loading="isAnalyzing"
:disabled="isAnalyzing || optimizing || isIterating || !originalPrompt.trim()"
@click="handleAnalyzePrompt"
>
{{ isAnalyzing ? t('promptOptimizer.analyzing') : t('promptOptimizer.analyze') }}
</NButton>
<NButton
type="primary"
size="medium"
data-testid="image-multiimage-optimize"
:loading="optimizing"
:disabled="optimizing || isIterating || !canOptimize"
:disabled="isAnalyzing || optimizing || isIterating || !canOptimize"
@click="optimizePrompt"
>
{{ optimizing ? t('imageWorkspace.input.optimizing') : t('common.optimize') }}
@@ -297,12 +307,15 @@
:optimization-mode="optimizationMode"
:advanced-mode-enabled="advancedModeEnabled"
:show-preview="true"
evaluation-type-override="prompt-only"
iterate-template-type="imageIterate"
@iterate="handleIteratePrompt"
@openTemplateManager="onOpenTemplateManager"
@switchVersion="handleSwitchVersion"
@save-favorite="handleSaveFavorite"
@save-local-edit="handleSaveLocalEdit"
@apply-improvement="handleApplyImprovement"
@apply-patch="handleApplyPatch"
@open-preview="handleOpenPromptPreview"
/>
</NCard>
@@ -517,6 +530,29 @@
</div>
</div>
<EvaluationPanel
v-model:show="evaluation.isPanelVisible.value"
:is-evaluating="panelProps.isEvaluating"
:result="panelProps.result"
:stream-content="panelProps.streamContent"
:error="panelProps.error"
:current-type="panelProps.currentType"
:score-level="panelProps.scoreLevel"
:rewrite-recommendation="panelProps.rewriteRecommendation"
:rewrite-reasons="panelProps.rewriteReasons"
:stale="activeEvaluationStale"
:stale-message="activeEvaluationStaleMessage"
:disable-evaluate="activeEvaluationDisableEvaluate"
:disable-evaluate-reason="activeEvaluationDisableReason"
:can-rewrite-from-evaluation="false"
@apply-local-patch="handleApplyPatch"
@apply-improvement="handleApplyImprovement"
@re-evaluate="handleReEvaluateActive"
@evaluate-with-feedback="handleEvaluateActiveWithFeedback"
@clear="handleClearEvaluation"
@retry="handleReEvaluateActive"
/>
<FullscreenDialog
v-model="isFullscreen"
:title="t('imageWorkspace.input.originalPrompt')"
@@ -552,10 +588,23 @@
</template>
<script setup lang="ts">
import { computed, inject, nextTick, onMounted, onUnmounted, reactive, ref, watch, type Ref } from 'vue'
import { computed, inject, nextTick, onMounted, onUnmounted, reactive, ref, watch, toRef, type Ref } from 'vue'
import { NButton, NCard, NEmpty, NFlex, NGrid, NGridItem, NIcon, NInput, NRadioButton, NRadioGroup, NSpace, NTag, NText, NTooltip } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import type { ContextMode, ImageInputRef, ImageModelConfig, ImageResult, ImageResultItem, MultiImageGenerationRequest, OptimizationMode, PromptRecordChain, PromptRecordType, Template } from '@prompt-optimizer/core'
import {
applyPatchOperationsToText,
type ContextMode,
type ImageInputRef,
type ImageModelConfig,
type ImageResult,
type ImageResultItem,
type MultiImageGenerationRequest,
type OptimizationMode,
type PatchOperation,
type PromptRecordChain,
type PromptRecordType,
type Template,
} from '@prompt-optimizer/core'
import type { AppServices } from '../../types/services'
import { useImageMultiImageSession, type TestColumnCount, type TestPanelVersionValue, type TestVariantId } from '../../stores/session/useImageMultiImageSession'
import { useImageGeneration } from '../../composables/image/useImageGeneration'
@@ -565,6 +614,9 @@ import { useVariableAwareInputBridge } from '../../composables/variable/useVaria
import { useSmartVariableValueGeneration } from '../../composables/variable/useSmartVariableValueGeneration'
import { useToast } from '../../composables/ui/useToast'
import { useFullscreen } from '../../composables/ui/useFullscreen'
import { useEvaluationHandler } from '../../composables/prompt/useEvaluationHandler'
import { provideEvaluation } from '../../composables/prompt/useEvaluationContext'
import { useFunctionModelManager } from '../../composables/model'
import { useWorkspaceTextModelSelection } from '../../composables/workspaces/useWorkspaceTextModelSelection'
import { useWorkspaceTemplateSelection } from '../../composables/workspaces/useWorkspaceTemplateSelection'
import { useElementSize } from '@vueuse/core'
@@ -576,6 +628,7 @@ import { buildMultiImageVariantFingerprint } from '../../utils/multiimage-worksp
import { downloadImageSource } from '../../utils/image-download'
import { getI18nErrorMessage } from '../../utils/error'
import { withHistorySourceBindingMetadata } from '../../utils/history-source-binding'
import { createImagePromptAnalysisVersion } from '../../utils/imagePromptAnalysis'
import { resolveSourceAssetRef } from '../../utils/source-asset'
import { OptionAccessors } from '../../utils/data-transformer'
import type { VariableManagerHooks } from '../../composables/prompt/useVariableManager'
@@ -591,6 +644,7 @@ import VariableValuePreviewDialog from '../variable/VariableValuePreviewDialog.v
import TestPanelVersionSelect from '../TestPanelVersionSelect.vue'
import ImageTokenUsage from './ImageTokenUsage.vue'
import SaveTestResultExampleButton from '../SaveTestResultExampleButton.vue'
import { EvaluationPanel } from '../evaluation'
const { t } = useI18n()
const toast = useToast()
@@ -624,6 +678,7 @@ const {
const fileInputRef = ref<HTMLInputElement | null>(null)
const optimizing = ref(false)
const isAnalyzing = ref(false)
const isIterating = ref(false)
const splitRootRef = ref<HTMLElement | null>(null)
const testPaneRef = ref<HTMLElement | null>(null)
@@ -700,6 +755,7 @@ const optimizedReasoning = computed<string>({
const modelSelection = useWorkspaceTextModelSelection(services, session)
const selectedTextModelKey = modelSelection.selectedTextModelKey
const textModelOptions = modelSelection.textModelOptions
const functionModelManager = useFunctionModelManager(services)
const templateSelection = useWorkspaceTemplateSelection(
services,
@@ -711,6 +767,98 @@ const selectedTemplate = templateSelection.selectedTemplate
const templateOptions = templateSelection.templateOptions
const selectedTemplateId = templateSelection.selectedTemplateId
const evaluationHandler = useEvaluationHandler({
services,
analysisOptimizedPrompt: computed(() => optimizedPrompt.value || ''),
analysisTargetResolver: (defaultTarget) => ({
...defaultTarget,
referencePrompt: (originalPrompt.value || '').trim() || undefined,
}),
evaluationModelKey: computed(() => selectedTextModelKey.value || ''),
resolveEvaluationModelKey: async () => {
await functionModelManager.initialize()
return (
functionModelManager.evaluationModel.value ||
selectedTextModelKey.value ||
functionModelManager.effectiveEvaluationModel.value ||
''
)
},
functionMode: computed(() => 'image'),
subMode: computed(() => 'multiimage'),
persistedResults: toRef(session, 'evaluationResults'),
})
provideEvaluation(evaluationHandler.evaluation)
const { evaluation, handleEvaluate: handleEvaluateInternal } = evaluationHandler
const panelProps = evaluationHandler.panelProps
const activeEvaluationStale = computed(() => false)
const activeEvaluationStaleMessage = computed(() => t('evaluation.stale.promptOnly'))
const activeEvaluationDisableEvaluate = computed(() =>
panelProps.value.currentType === 'prompt-only' &&
!optimizedPrompt.value.trim(),
)
const activeEvaluationDisableReason = computed(() => '')
const handleAnalyzePrompt = async () => {
const prompt = originalPrompt.value.trim()
if (!prompt || isAnalyzing.value) return
isAnalyzing.value = true
try {
const virtualV0 = createImagePromptAnalysisVersion(
prompt,
'multiimageOptimize' as PromptRecordType,
)
currentChainId.value = ''
currentVersions.value = [virtualV0]
currentVersionId.value = virtualV0.id
session.updateOptimizedResult({
optimizedPrompt: prompt,
reasoning: '',
chainId: '',
versionId: '',
})
evaluation.clearResult('prompt-only')
evaluation.clearResult('prompt-iterate')
await nextTick()
await handleEvaluateInternal('prompt-only')
} finally {
isAnalyzing.value = false
}
}
const handleReEvaluateActive = async () => {
if (!evaluation.state.activeDetail) return
await evaluationHandler.handleReEvaluate()
}
const handleEvaluateActiveWithFeedback = async (payload: { feedback: string }) => {
if (!evaluation.state.activeDetail) return
await evaluationHandler.handleEvaluateActiveWithFeedback(payload.feedback)
}
const handleClearEvaluation = () => {
evaluation.closePanel()
evaluation.clearAllResults()
}
const handleApplyImprovement = evaluationHandler.createApplyImprovementHandler(promptPanelRef)
const handleApplyPatch = (payload: { operation: PatchOperation }) => {
if (!payload.operation) return
const current = optimizedPrompt.value || ''
const result = applyPatchOperationsToText(current, payload.operation)
if (!result.ok) {
toast.warning(t('toast.warning.patchApplyFailed'))
return
}
optimizedPrompt.value = result.text
toast.success(t('evaluation.diagnose.applyFix'))
}
const selectedIterateTemplate = computed<Template | null>({
get: () => templateSelection.selectedIterateTemplate.value,
set: (template) => {
@@ -455,6 +455,25 @@
<!-- 优化按钮 -->
<NGridItem :span="6" :xs="24" :sm="6" class="flex items-end justify-end">
<NSpace :size="8">
<NButton
type="default"
size="medium"
data-testid="image-text2image-analyze-button"
:loading="isAnalyzing"
@click="handleAnalyzePrompt"
:disabled="
isAnalyzing ||
isExtractingFromImage ||
isOptimizing ||
!originalPrompt.trim()
"
>
{{
isAnalyzing
? t("promptOptimizer.analyzing")
: t("promptOptimizer.analyze")
}}
</NButton>
<NButton
type="primary"
size="medium"
@@ -462,6 +481,7 @@
:loading="isOptimizing"
@click="handleOptimizePrompt"
:disabled="
isAnalyzing ||
isExtractingFromImage ||
isOptimizing ||
!originalPrompt.trim() ||
@@ -516,6 +536,8 @@
@switchVersion="handleSwitchVersion"
@save-favorite="handleSaveFavorite"
@save-local-edit="handleSaveLocalEdit"
@apply-improvement="handleApplyImprovement"
@apply-patch="handleApplyPatch"
@open-preview="handleOpenPromptPreview"
/>
</NCard>
@@ -589,6 +611,8 @@
@show-detail="showCompareDetail"
@evaluate="handleEvaluateCompare"
@evaluate-with-feedback="handleCompareEvaluateWithFeedback"
@apply-improvement="handleApplyImprovement"
@apply-patch="handleApplyPatch"
/>
<FocusAnalyzeButton
v-else
@@ -724,6 +748,8 @@
@show-detail="() => showResultDetail(id)"
@evaluate="() => handleEvaluateResult(id)"
@evaluate-with-feedback="handleResultEvaluateWithFeedbackEvent(id, $event)"
@apply-improvement="handleApplyImprovement"
@apply-patch="handleApplyPatch"
/>
<FocusAnalyzeButton
v-else
@@ -847,6 +873,8 @@
:disable-evaluate="activeEvaluationDisableEvaluate"
:disable-evaluate-reason="activeEvaluationDisableReason"
:can-rewrite-from-evaluation="false"
@apply-local-patch="handleApplyPatch"
@apply-improvement="handleApplyImprovement"
@re-evaluate="handleReEvaluateActive"
@evaluate-with-feedback="handleEvaluateActiveWithFeedback"
@clear="handleClearEvaluation"
@@ -938,6 +966,7 @@ import { withHistorySourceBindingMetadata } from '../../utils/history-source-bin
import { resolveSourceAssetRef } from '../../utils/source-asset'
import { downloadImageSource } from '../../utils/image-download'
import { openExternalUrl } from '../../utils/open-external-url'
import { createImagePromptAnalysisVersion } from '../../utils/imagePromptAnalysis'
import type { PromptGardenImportRequest } from '../../utils/prompt-garden-import'
import {
resolveReferencePromptPreview,
@@ -992,6 +1021,7 @@ import {
shouldShowImageText2ImageResultAction,
} from './imageText2ImageEvaluation'
import {
applyPatchOperationsToText,
getEnvVar,
type ContextMode,
type ImageModelConfig,
@@ -1000,6 +1030,7 @@ import {
type ImageResultItem,
type OptimizationMode,
type OptimizationRequest,
type PatchOperation,
type PromptRecordChain,
type PromptRecordType,
type Template,
@@ -1111,6 +1142,7 @@ const promptService = computed(() => services.value?.promptService)
// 过程态(本地,不持久化)
const isOptimizing = ref(false)
const isAnalyzing = ref(false)
const isIterating = ref(false)
const isExtractingFromImage = ref(false)
const extractImageInputRef = ref<HTMLInputElement | null>(null)
@@ -1730,6 +1762,34 @@ const panelProps = evaluationHandler.panelProps
const getResultEvaluationProps = (variantId: string) =>
evaluationHandler.getResultEvaluationProps(variantId)
const handleAnalyzePrompt = async () => {
const prompt = originalPrompt.value.trim()
if (!prompt || isAnalyzing.value) return
isAnalyzing.value = true
try {
const virtualV0 = createImagePromptAnalysisVersion(
prompt,
'text2imageOptimize' as PromptRecordType,
)
currentChainId.value = ''
currentVersions.value = [virtualV0]
currentVersionId.value = virtualV0.id
session.updateOptimizedResult({
optimizedPrompt: prompt,
reasoning: '',
chainId: '',
versionId: '',
})
evaluation.clearResult('prompt-only')
evaluation.clearResult('prompt-iterate')
await nextTick()
await handleEvaluateInternal('prompt-only')
} finally {
isAnalyzing.value = false
}
}
const isEvaluatingCompare = evaluationHandler.compareEvaluation.isEvaluatingCompare
const compareScore = computed(
() => evaluationHandler.compareEvaluation.compareScore.value ?? 0,
@@ -2254,6 +2314,20 @@ const handleSaveLocalEdit = async (payload: { note?: string }) => {
// PromptPanel 引用,用于在语言切换后刷新迭代模板选择
const promptPanelRef = ref<InstanceType<typeof PromptPanelUI> | null>(null);
const handleApplyImprovement = evaluationHandler.createApplyImprovementHandler(promptPanelRef)
const handleApplyPatch = (payload: { operation: PatchOperation }) => {
if (!payload.operation) return
const current = optimizedPrompt.value || ''
const result = applyPatchOperationsToText(current, payload.operation)
if (!result.ok) {
toast.warning(t('toast.warning.patchApplyFailed'))
return
}
optimizedPrompt.value = result.text
toast.success(t('evaluation.diagnose.applyFix'))
}
// 输入区折叠状态(初始展开)
const isInputPanelCollapsed = ref(false);
@@ -0,0 +1,21 @@
import type { PromptRecordChain, PromptRecordType } from '@prompt-optimizer/core'
import { v4 as uuidv4 } from 'uuid'
export const createImagePromptAnalysisVersion = (
prompt: string,
type: PromptRecordType,
): PromptRecordChain['versions'][number] => {
const id = uuidv4()
return {
id,
chainId: '',
version: 0,
originalPrompt: prompt,
optimizedPrompt: prompt,
type,
timestamp: Date.now(),
modelKey: '',
templateId: '',
}
}
@@ -0,0 +1,201 @@
import { describe, expect, it } from 'vitest'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
type ComponentRequirement = {
component: string
listeners: string[]
}
type FileComponentRequirement = ComponentRequirement & {
file: string
requirePresence?: boolean
}
const sourceRoot = join(process.cwd(), 'src')
const filesToCheck = [
'components/PromptPanel.vue',
'components/TestAreaPanel.vue',
'components/TestResultSection.vue',
'components/context-mode/ContextUserTestPanel.vue',
'components/context-mode/ConversationTestPanel.vue',
'components/basic-mode/BasicUserWorkspace.vue',
'components/basic-mode/BasicSystemWorkspace.vue',
'components/context-mode/ContextUserWorkspace.vue',
'components/context-mode/ContextSystemWorkspace.vue',
'components/image-mode/ImageText2ImageWorkspace.vue',
'components/image-mode/ImageImage2ImageWorkspace.vue',
'components/image-mode/ImageMultiImageWorkspace.vue',
]
const requirements: ComponentRequirement[] = [
{
component: 'EvaluationPanel',
listeners: ['@apply-local-patch', '@apply-improvement'],
},
{
component: 'EvaluationScoreBadge',
listeners: ['@apply-improvement', '@apply-patch'],
},
{
component: 'PromptPanelUI',
listeners: ['@apply-improvement', '@apply-patch'],
},
{
component: 'TestResultSection',
listeners: [
'@evaluate-with-feedback',
'@show-primary-detail',
'@show-secondary-detail',
'@apply-improvement',
'@apply-patch',
],
},
]
const workspaceFiles = [
'components/basic-mode/BasicUserWorkspace.vue',
'components/basic-mode/BasicSystemWorkspace.vue',
'components/context-mode/ContextUserWorkspace.vue',
'components/context-mode/ContextSystemWorkspace.vue',
'components/image-mode/ImageText2ImageWorkspace.vue',
'components/image-mode/ImageImage2ImageWorkspace.vue',
'components/image-mode/ImageMultiImageWorkspace.vue',
]
const promptPanelWorkspaceFiles = workspaceFiles
const textInputWorkspaceFiles = [
'components/basic-mode/BasicUserWorkspace.vue',
'components/basic-mode/BasicSystemWorkspace.vue',
'components/context-mode/ContextUserWorkspace.vue',
]
const promptPanelPreviewWorkspaceFiles = [
'components/context-mode/ContextUserWorkspace.vue',
'components/context-mode/ContextSystemWorkspace.vue',
'components/image-mode/ImageText2ImageWorkspace.vue',
'components/image-mode/ImageImage2ImageWorkspace.vue',
'components/image-mode/ImageMultiImageWorkspace.vue',
]
const promptPanelOriginalVersionWorkspaceFiles = [
'components/basic-mode/BasicUserWorkspace.vue',
'components/basic-mode/BasicSystemWorkspace.vue',
'components/context-mode/ContextUserWorkspace.vue',
'components/context-mode/ContextSystemWorkspace.vue',
]
const buildFileRequirements = (
files: string[],
component: string,
listeners: string[],
): FileComponentRequirement[] =>
files.map((file) => ({
file,
component,
listeners,
requirePresence: true,
}))
const workspaceRequirements: FileComponentRequirement[] = [
...buildFileRequirements(workspaceFiles, 'WorkspaceUtilityMenu', ['@clear']),
...buildFileRequirements(textInputWorkspaceFiles, 'InputPanelUI', [
'@submit',
'@analyze',
]),
...buildFileRequirements(promptPanelWorkspaceFiles, 'PromptPanelUI', [
'@iterate',
'@openTemplateManager',
'@switchVersion',
'@save-favorite',
'@apply-improvement',
'@apply-patch',
'@save-local-edit',
]),
...buildFileRequirements(promptPanelPreviewWorkspaceFiles, 'PromptPanelUI', [
'@open-preview',
]),
...buildFileRequirements(promptPanelOriginalVersionWorkspaceFiles, 'PromptPanelUI', [
'@switchToV0',
]),
]
const extractComponentBlocks = (source: string, component: string) => {
const lines = source.split(/\r?\n/)
const blocks: Array<{ line: number; text: string }> = []
for (let index = 0; index < lines.length; index += 1) {
if (!lines[index]?.includes(`<${component}`)) continue
const start = index
const collected: string[] = []
for (let cursor = index; cursor < lines.length; cursor += 1) {
const line = lines[cursor] || ''
collected.push(line)
if (line.includes('/>') || line.includes(`</${component}>`)) {
index = cursor
break
}
}
blocks.push({
line: start + 1,
text: collected.join('\n'),
})
}
return blocks
}
const collectMissingListeners = (requirementsToCheck: FileComponentRequirement[]) => {
const missing: string[] = []
for (const requirement of requirementsToCheck) {
const source = readFileSync(join(sourceRoot, requirement.file), 'utf8')
const blocks = extractComponentBlocks(source, requirement.component)
if (blocks.length === 0) {
if (requirement.requirePresence) {
missing.push(`${requirement.file} missing <${requirement.component}>`)
}
continue
}
for (const block of blocks) {
const missingListeners = requirement.listeners.filter(
(listener) => !block.text.includes(listener),
)
if (missingListeners.length) {
missing.push(
`${requirement.file}:${block.line} <${requirement.component}> missing ${missingListeners.join(', ')}`,
)
}
}
}
return missing
}
describe('evaluation action wiring', () => {
it('keeps apply action events connected at workspace boundaries', () => {
const requirementsToCheck = filesToCheck.flatMap((file) =>
requirements.map((requirement) => ({
...requirement,
file,
})),
)
const missing = collectMissingListeners(requirementsToCheck)
expect(missing).toEqual([])
})
it('keeps shared workspace action controls wired consistently across modes', () => {
const missing = collectMissingListeners(workspaceRequirements)
expect(missing).toEqual([])
})
})
+14 -3
View File
@@ -1,14 +1,25 @@
import { test, expect } from '../fixtures'
import { navigateToMode } from '../helpers/common'
import {
fillOriginalPrompt,
verifyAnalyzeButtonDisabledWhenEmpty,
} from '../helpers/analysis'
const MODE = 'image-image2image' as const
test.describe('Image Image2Image - 工作区入口', () => {
test('单图生图工作区不提供单独的提示词分析按钮', async ({ page }) => {
test.describe('Image Image2Image - 提示词分析入口', () => {
test('图生图工作区提供输入区提示词分析按钮', async ({ page }) => {
await navigateToMode(page, 'image', 'image2image')
const workspace = page.locator(`[data-testid="workspace"][data-mode="${MODE}"]`).first()
await expect(workspace).toBeVisible()
await expect(workspace.getByTestId(`${MODE}-analyze-button`)).toHaveCount(0)
await verifyAnalyzeButtonDisabledWhenEmpty(page, MODE)
await fillOriginalPrompt(
page,
MODE,
'Convert to watercolor painting style, soft colors, artistic brush strokes',
)
await expect(workspace.getByTestId(`${MODE}-analyze-button`)).toBeEnabled()
})
})
@@ -0,0 +1,25 @@
import { test, expect } from '../fixtures'
import { navigateToMode } from '../helpers/common'
import {
fillOriginalPrompt,
verifyAnalyzeButtonDisabledWhenEmpty,
} from '../helpers/analysis'
const MODE = 'image-multiimage' as const
test.describe('Image MultiImage - 提示词分析入口', () => {
test('多图生图工作区提供输入区提示词分析按钮', async ({ page }) => {
await navigateToMode(page, 'image', 'multiimage')
const workspace = page.locator(`[data-testid="workspace"][data-mode="${MODE}"]`).first()
await expect(workspace).toBeVisible()
await verifyAnalyzeButtonDisabledWhenEmpty(page, MODE)
await fillOriginalPrompt(
page,
MODE,
'Combine the reference images into one cohesive editorial poster with consistent lighting',
)
await expect(workspace.getByTestId(`${MODE}-analyze-button`)).toBeEnabled()
})
})
+14 -3
View File
@@ -1,14 +1,25 @@
import { test, expect } from '../fixtures'
import { navigateToMode } from '../helpers/common'
import {
fillOriginalPrompt,
verifyAnalyzeButtonDisabledWhenEmpty,
} from '../helpers/analysis'
const MODE = 'image-text2image' as const
test.describe('Image Text2Image - 工作区入口', () => {
test('文生图工作区不提供单独的提示词分析按钮', async ({ page }) => {
test.describe('Image Text2Image - 提示词分析入口', () => {
test('文生图工作区提供输入区提示词分析按钮', async ({ page }) => {
await navigateToMode(page, 'image', 'text2image')
const workspace = page.locator(`[data-testid="workspace"][data-mode="${MODE}"]`).first()
await expect(workspace).toBeVisible()
await expect(workspace.getByTestId(`${MODE}-analyze-button`)).toHaveCount(0)
await verifyAnalyzeButtonDisabledWhenEmpty(page, MODE)
await fillOriginalPrompt(
page,
MODE,
'A beautiful sunset over the ocean with palm trees, golden hour lighting, photorealistic',
)
await expect(workspace.getByTestId(`${MODE}-analyze-button`)).toBeEnabled()
})
})
+1
View File
@@ -9,6 +9,7 @@ export type WorkspaceMode =
| 'basic-user'
| 'image-text2image'
| 'image-image2image'
| 'image-multiimage'
| 'pro-multi'
| 'pro-variable'