refactor(ui): extract temporary variables panel

Reuse a shared temp-variable editor in test panels and show it above image test variants for parity with Pro.
This commit is contained in:
linshen committed 2026-01-27 21:39:56 +08:00
1 parent 84b1751cc7
commit 0f526630a2
6 files changed
+450 -714

No files matched your search

@@ -1,159 +1,12 @@
<template>
<NFlex vertical :style="{ height: mode === 'full' ? '100%' : 'auto', gap: '12px' }">
<!-- 变量值输入表单 -->
<NCard
:title="t('test.variables.formTitle')"
size="small"
:bordered="true"
:style="{ flexShrink: 0 }"
>
<template #header-extra>
<NSpace :size="8">
<NTag :bordered="false" type="info" size="small">
{{ t("test.variables.tempCount", { count: displayVariables.length }) }}
</NTag>
<NButton
size="small"
quaternary
:loading="isGenerating"
:disabled="displayVariables.length === 0 || isGenerating"
@click="handleGenerateValues"
>
{{ isGenerating
? t('test.variableValueGeneration.generating')
: t('test.variableValueGeneration.generateButton')
}}
</NButton>
<NButton
size="small"
quaternary
@click="handleClearAllVariables"
>
{{ t("test.variables.clearAll") }}
</NButton>
<NButton
size="small"
quaternary
@click="emit('open-global-variables')"
>
{{ t("contextMode.actions.globalVariables") }}
</NButton>
</NSpace>
</template>
<NSpace vertical :size="12">
<!-- 变量输入项 -->
<div
v-for="varName in displayVariables"
:key="varName"
:style="{
display: 'flex',
alignItems: 'center',
gap: '8px',
}"
>
<NTag
size="small"
:bordered="false"
:type="
getVariableSource(varName) === 'predefined'
? 'success'
: getVariableSource(varName) === 'test'
? 'warning'
: 'default'
"
:style="{ minWidth: '120px', flexShrink: 0 }"
>
<span v-text="`{{${varName}}}`"></span>
</NTag>
<NInput
:value="getVariableDisplayValue(varName)"
:placeholder="getVariablePlaceholder(varName)"
size="small"
:style="{ flex: 1 }"
@update:value="
handleVariableValueChange(varName, $event)
"
/>
<!-- 删除按钮 (仅临时变量显示) -->
<NButton
v-if="getVariableSource(varName) === 'test'"
size="small"
quaternary
@click="handleDeleteVariable(varName)"
:title="t('test.variables.delete')"
>
🗑️
</NButton>
<!-- 保存到全局按钮 (仅测试变量显示) -->
<NButton
v-if="getVariableSource(varName) === 'test'"
size="small"
quaternary
@click="handleSaveToGlobal(varName)"
:title="t('test.variables.saveToGlobal')"
>
💾
</NButton>
</div>
<!-- 无变量提示 -->
<NEmpty
v-if="displayVariables.length === 0"
:description="t('test.variables.noVariables')"
size="small"
/>
<!-- 操作按钮 -->
<NSpace :size="8" justify="end">
<!-- 添加变量按钮 -->
<NButton
size="small"
@click="showAddVariableDialog = true"
>
{{ t("test.variables.addVariable") }}
</NButton>
</NSpace>
</NSpace>
</NCard>
<!-- 添加变量对话框 -->
<NModal
v-model:show="showAddVariableDialog"
preset="dialog"
:title="t('test.variables.addVariable')"
:positive-text="t('common.confirm')"
:negative-text="t('common.cancel')"
:on-positive-click="handleAddVariable"
:mask-closable="false"
>
<NSpace vertical :size="12" style="margin-top: 16px;">
<NFormItem
:label="t('variableExtraction.variableName')"
:validation-status="
newVariableNameError ? 'error' : undefined
"
:feedback="newVariableNameError"
>
<NInput
v-model:value="newVariableName"
:placeholder="
t('variableExtraction.variableNamePlaceholder')
"
@input="validateNewVariableName"
/>
</NFormItem>
<NFormItem :label="t('variableExtraction.variableValue')">
<NInput
v-model:value="newVariableValue"
:placeholder="
t('variableExtraction.variableValuePlaceholder')
"
/>
</NFormItem>
</NSpace>
</NModal>
<TemporaryVariablesPanel
:manager="variableManager"
:show-generate-values="true"
:is-generating="isGenerating"
@generate-values="handleGenerateValues"
@open-global-variables="emit('open-global-variables')"
/>
<template v-if="mode === 'full'">
<!-- 控制工具栏 -->
@@ -244,13 +97,6 @@ import { useI18n } from "vue-i18n";
import {
NFlex,
NCard,
NButton,
NTag,
NSpace,
NInput,
NEmpty,
NModal,
NFormItem,
} from "naive-ui";
import { useResponsive } from '../../composables/ui/useResponsive';
import { usePerformanceMonitor } from "../../composables/performance/usePerformanceMonitor";
@@ -260,6 +106,7 @@ import { useVariableValueGeneration } from "../../composables/variable/useVariab
import { useToast } from "../../composables/ui/useToast";
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, VariableToGenerate } from '@prompt-optimizer/core';
import type { ScoreLevel } from '../../composables/prompt/useEvaluation';
@@ -455,20 +302,10 @@ const variableManager = useTestVariableManager({
});
const {
showAddVariableDialog,
newVariableName,
newVariableValue,
newVariableNameError,
sortedVariables: displayVariables,
getVariableSource,
getVariableDisplayValue,
getVariablePlaceholder,
validateNewVariableName,
handleVariableValueChange,
handleAddVariable,
handleDeleteVariable,
handleClearAllVariables,
handleSaveToGlobal,
getVariableValues,
setVariableValues,
} = variableManager;
@@ -1,147 +1,9 @@
<template>
<NFlex vertical :style="{ height: mode === 'full' ? '100%' : 'auto', gap: '12px' }">
<!-- 变量值输入表单 -->
<NCard
:title="t('test.variables.formTitle')"
size="small"
:bordered="true"
:style="{ flexShrink: 0 }"
>
<template #header-extra>
<NSpace :size="8">
<NTag :bordered="false" type="info" size="small">
{{ t("test.variables.tempCount", { count: displayVariables.length }) }}
</NTag>
<NButton
size="small"
quaternary
@click="handleClearAllVariables"
>
{{ t("test.variables.clearAll") }}
</NButton>
<NButton
size="small"
quaternary
@click="emit('open-global-variables')"
>
{{ t("contextMode.actions.globalVariables") }}
</NButton>
</NSpace>
</template>
<NSpace vertical :size="12">
<!-- 变量输入项 -->
<div
v-for="varName in displayVariables"
:key="varName"
:style="{
display: 'flex',
alignItems: 'center',
gap: '8px',
}"
>
<NTag
size="small"
:bordered="false"
:type="
getVariableSource(varName) === 'predefined'
? 'success'
: getVariableSource(varName) === 'test'
? 'warning'
: 'default'
"
:style="{ minWidth: '120px', flexShrink: 0 }"
>
<span v-text="`{{${varName}}}`"></span>
</NTag>
<NInput
:value="getVariableDisplayValue(varName)"
:placeholder="getVariablePlaceholder(varName)"
size="small"
:style="{ flex: 1 }"
@update:value="
handleVariableValueChange(varName, $event)
"
/>
<!-- 删除按钮 (仅临时变量显示) -->
<NButton
v-if="getVariableSource(varName) === 'test'"
size="small"
quaternary
@click="handleDeleteVariable(varName)"
:title="t('test.variables.delete')"
>
🗑️
</NButton>
<!-- 保存到全局按钮 (仅测试变量显示) -->
<NButton
v-if="getVariableSource(varName) === 'test'"
size="small"
quaternary
@click="handleSaveToGlobal(varName)"
:title="t('test.variables.saveToGlobal')"
>
💾
</NButton>
</div>
<!-- 无变量提示 -->
<NEmpty
v-if="displayVariables.length === 0"
:description="t('test.variables.noVariables')"
size="small"
/>
<!-- 操作按钮 -->
<NSpace :size="8" justify="end">
<!-- 添加变量按钮 -->
<NButton
size="small"
@click="showAddVariableDialog = true"
>
{{ t("test.variables.addVariable") }}
</NButton>
</NSpace>
</NSpace>
</NCard>
<!-- 添加变量对话框 -->
<NModal
v-model:show="showAddVariableDialog"
preset="dialog"
:title="t('test.variables.addVariable')"
:positive-text="t('common.confirm')"
:negative-text="t('common.cancel')"
:on-positive-click="handleAddVariable"
:mask-closable="false"
>
<NSpace vertical :size="12" style="margin-top: 16px;">
<NFormItem
:label="t('variableExtraction.variableName')"
:validation-status="
newVariableNameError ? 'error' : undefined
"
:feedback="newVariableNameError"
>
<NInput
v-model:value="newVariableName"
:placeholder="
t('variableExtraction.variableNamePlaceholder')
"
@input="validateNewVariableName"
/>
</NFormItem>
<NFormItem :label="t('variableExtraction.variableValue')">
<NInput
v-model:value="newVariableValue"
:placeholder="
t('variableExtraction.variableValuePlaceholder')
"
/>
</NFormItem>
</NSpace>
</NModal>
<TemporaryVariablesPanel
:manager="variableManager"
@open-global-variables="emit('open-global-variables')"
/>
<template v-if="mode === 'full'">
<!-- 控制工具栏 -->
@@ -264,20 +126,12 @@
</template>
<script setup lang="ts">
import { computed, ref, watch, onUnmounted } from 'vue'
import { computed, ref, onUnmounted, toRef } from 'vue'
import { useI18n } from "vue-i18n";
import {
useMessage,
NFlex,
NCard,
NButton,
NTag,
NSpace,
NInput,
NEmpty,
NModal,
NFormItem,
} from "naive-ui";
import type {
OptimizationMode,
@@ -293,9 +147,10 @@ import { useDebounceThrottle } from "../../composables/performance/useDebounceTh
import TestControlBar from "../TestControlBar.vue";
import TestResultSection from "../TestResultSection.vue";
import ToolCallDisplay from "../ToolCallDisplay.vue";
import TemporaryVariablesPanel from "../variable/TemporaryVariablesPanel.vue";
import { useTestVariableManager } from "../../composables/variable/useTestVariableManager";
const { t } = useI18n();
const message = useMessage();
// 性能监控
const { recordUpdate, getPerformanceReport } = usePerformanceMonitor("ConversationTestPanel");
@@ -494,216 +349,35 @@ const handleTest = throttle(
// ========== 变量管理 ==========
// 添加变量对话框状态
const showAddVariableDialog = ref(false);
const newVariableName = ref("");
const newVariableValue = ref("");
const newVariableNameError = ref("");
const variableManager = useTestVariableManager({
globalVariables: toRef(props, 'globalVariables'),
predefinedVariables: toRef(props, 'predefinedVariables'),
temporaryVariables: toRef(props, 'temporaryVariables'),
onVariableChange: (name, value) => {
emit('variable-change', name, value)
recordUpdate()
},
onSaveToGlobal: (name, value) => {
emit('save-to-global', name, value)
recordUpdate()
},
onVariableRemove: (name) => {
emit('temporary-variable-remove', name)
recordUpdate()
},
onVariablesClear: () => {
emit('temporary-variables-clear')
recordUpdate()
},
})
// 测试区临时变量
interface TestVariable {
value: string;
timestamp: number;
const getVariableValues = () => {
return variableManager.getVariableValues()
}
const testVariables = ref<Record<string, TestVariable>>({});
// 监听 props.temporaryVariables 变化
watch(
() => props.temporaryVariables,
(newVars) => {
const newVarNames = new Set(Object.keys(newVars));
for (const name of Object.keys(testVariables.value)) {
if (!newVarNames.has(name)) {
delete testVariables.value[name];
}
}
for (const [name, value] of Object.entries(newVars)) {
if (!testVariables.value[name]) {
testVariables.value[name] = {
value,
timestamp: Date.now(),
};
} else {
testVariables.value[name].value = value;
}
}
},
{ deep: true, immediate: true }
);
// 三层变量合并
const mergedVariables = computed(() => {
const testVarsFlat: Record<string, string> = {};
for (const [name, data] of Object.entries(testVariables.value)) {
testVarsFlat[name] = data.value;
}
return {
...props.globalVariables,
...testVarsFlat,
...props.predefinedVariables,
};
});
// 按时间排序的临时变量列表
const sortedTestVariables = computed(() => {
const entries = Object.entries(testVariables.value);
return entries
.sort((a, b) => b[1].timestamp - a[1].timestamp)
.map(([name]) => name);
});
// 实际显示的变量列表
const displayVariables = computed(() => {
return sortedTestVariables.value;
});
// 获取变量的显示值
const getVariableDisplayValue = (varName: string): string => {
return mergedVariables.value[varName] || "";
};
// 获取变量的占位符提示
const getVariablePlaceholder = (varName: string): string => {
if (props.predefinedVariables?.[varName]) {
return (
t("test.variables.inputPlaceholder") +
` (${t("variables.source.predefined")})`
);
}
if (props.globalVariables?.[varName]) {
return (
t("test.variables.inputPlaceholder") +
` (${t("variables.source.global")})`
);
}
return t("test.variables.inputPlaceholder");
};
// 事件处理函数
const handleVariableValueChange = (varName: string, value: string) => {
if (testVariables.value[varName]) {
testVariables.value[varName].value = value;
} else {
testVariables.value[varName] = {
value,
timestamp: Date.now(),
};
}
emit("variable-change", varName, value);
recordUpdate();
};
const handleClearAllVariables = () => {
testVariables.value = {};
emit("temporary-variables-clear");
message.success(t("test.variables.clearSuccess"));
recordUpdate();
};
// 保存测试变量到全局
const handleSaveToGlobal = (varName: string) => {
const varData = testVariables.value[varName];
if (!varData || !varData.value.trim()) {
message.warning(t("test.variables.emptyValueWarning"));
return;
}
emit("save-to-global", varName, varData.value);
message.success(t("test.variables.savedToGlobal"));
recordUpdate();
};
// 验证新变量名
const validateNewVariableName = () => {
const name = newVariableName.value.trim();
if (!name) {
newVariableNameError.value = "";
return false;
}
if (/^\d/.test(name)) {
newVariableNameError.value = t(
"variableExtraction.validation.noNumberStart"
);
return false;
}
if (!/^[\u4e00-\u9fa5a-zA-Z_][\u4e00-\u9fa5a-zA-Z0-9_]*$/.test(name)) {
newVariableNameError.value = t(
"variableExtraction.validation.invalidCharacters"
);
return false;
}
if (testVariables.value[name]) {
newVariableNameError.value = t(
"variableExtraction.validation.duplicateVariable"
);
return false;
}
newVariableNameError.value = "";
return true;
};
// 添加新变量
const handleAddVariable = () => {
if (!validateNewVariableName()) {
if (!newVariableName.value.trim()) {
message.warning(t("test.variables.nameRequired"));
}
return false;
}
const name = newVariableName.value.trim();
handleVariableValueChange(name, newVariableValue.value);
if (testVariables.value[name]) {
testVariables.value[name].timestamp = Date.now();
}
message.success(t("test.variables.addSuccess"));
newVariableName.value = "";
newVariableValue.value = "";
newVariableNameError.value = "";
showAddVariableDialog.value = false;
return true;
};
// 删除变量
const handleDeleteVariable = (varName: string) => {
delete testVariables.value[varName];
emit("temporary-variable-remove", varName);
emit("variable-change", varName, "");
message.success(
t("test.variables.deleteSuccess", { name: varName })
);
recordUpdate();
};
// 暴露变量值供外部访问
const getVariableValues = () => {
return { ...mergedVariables.value };
};
// 设置变量值
const setVariableValues = (values: Record<string, string>) => {
for (const [name, value] of Object.entries(values)) {
emit("variable-change", name, value);
}
};
// 获取变量来源
const getVariableSource = (varName: string): "predefined" | "test" | "global" | "empty" => {
if (props.predefinedVariables?.[varName]) return "predefined";
if (testVariables.value[varName]) return "test";
if (props.globalVariables?.[varName]) return "global";
return "empty";
};
variableManager.setVariableValues(values)
}
// 开发环境下的性能调试
if (import.meta.env.DEV) {
@@ -402,6 +402,11 @@
<!-- 右侧:图像生成测试区域(图像模型,多列 variants) -->
<div ref="testPaneRef" class="split-pane" style="min-width: 0; height: 100%; overflow: hidden;">
<NFlex vertical :style="{ height: '100%', gap: '12px' }">
<TemporaryVariablesPanel
:manager="temporaryVariablePanelManager"
:disabled="isOptimizing"
:show-open-global-variables="false"
/>
<!-- 顶部:列数与全局操作 -->
<NCard size="small" :style="{ flexShrink: 0 }">
<div class="test-area-top">
@@ -742,10 +747,16 @@ import type { SelectOption } from "../../types/select-options";
import { useToast } from "../../composables/ui/useToast";
import { getI18nErrorMessage } from '../../utils/error'
import { VariableAwareInput } from '../variable-extraction'
import TemporaryVariablesPanel from '../variable/TemporaryVariablesPanel.vue'
import { useTemporaryVariables } from '../../composables/variable/useTemporaryVariables'
import { useVariableAwareInputBridge } from '../../composables/variable/useVariableAwareInputBridge'
import { useTestVariableManager } from '../../composables/variable/useTestVariableManager'
import type { VariableManagerHooks } from '../../composables/prompt/useVariableManager'
import { platform } from '../../utils/platform'
import { findMissingVariables, replaceVariablesInContent } from '../../utils/prompt-variables'
import {
buildPromptExecutionContext,
hashString,
hashVariables,
} from '../../utils/prompt-variables'
import {
useImageImage2ImageSession,
type TestColumnCount,
@@ -788,75 +799,48 @@ const services = inject<Ref<AppServices | null>>("services", ref(null));
const variableManager = inject<VariableManagerHooks | null>('variableManager', null)
const tempVarsManager = useTemporaryVariables()
// VariableAwareInput 需要的变量数据
const purePredefinedVariables = computed(() => {
const all = variableManager?.allVariables.value || {}
const custom = variableManager?.customVariables.value || {}
const predefined: Record<string, string> = {}
for (const [key, value] of Object.entries(all)) {
if (!(key in custom)) predefined[key] = value
}
return predefined
const {
variableInputData,
predefinedVariableValues: purePredefinedVariables,
handleVariableExtracted,
handleAddMissingVariable,
} = useVariableAwareInputBridge({
enabled: computed(() => true),
isReady: computed(() => variableManager?.isReady.value ?? false),
globalVariables: computed(() => variableManager?.customVariables.value || {}),
temporaryVariables: tempVarsManager.temporaryVariables,
allVariables: computed(() => variableManager?.allVariables.value || {}),
saveGlobalVariable: (name, value) => variableManager?.addVariable(name, value),
saveTemporaryVariable: (name, value) => tempVarsManager.setVariable(name, value),
logPrefix: 'ImageImage2ImageWorkspace',
})
const variableInputData = computed(() => {
if (!variableManager?.isReady.value) return null
return {
existingGlobalVariables: Object.keys(variableManager.customVariables.value || {}),
existingTemporaryVariables: Object.keys(tempVarsManager.temporaryVariables.value || {}),
predefinedVariables: Object.keys(purePredefinedVariables.value || {}),
globalVariableValues: variableManager.customVariables.value || {},
temporaryVariableValues: tempVarsManager.temporaryVariables.value || {},
predefinedVariableValues: purePredefinedVariables.value || {},
}
const temporaryVariablePanelManager = useTestVariableManager({
globalVariables: computed(() => variableManager?.customVariables.value || {}),
predefinedVariables: purePredefinedVariables,
temporaryVariables: computed(() => tempVarsManager.temporaryVariables.value),
onVariableChange: (name, value) => {
tempVarsManager.setVariable(name, value)
},
onVariableRemove: (name) => {
tempVarsManager.deleteVariable(name)
},
onVariablesClear: () => {
tempVarsManager.clearAll()
},
onSaveToGlobal: (name, value) => {
if (!variableManager || !variableManager.isReady.value) {
throw new Error('variable manager not ready')
}
variableManager.addVariable(name, value)
},
})
const handleOriginalPromptInput = (value: string) => {
originalPrompt.value = value
}
const handleVariableExtracted = (data: {
variableName: string
variableValue: string
variableType: 'global' | 'temporary'
}) => {
if (data.variableType === 'global') {
try {
variableManager?.addVariable(data.variableName, data.variableValue)
toast.success(t('variableExtraction.savedToGlobal', { name: data.variableName }))
} catch (error) {
console.error('[ImageImage2ImageWorkspace] Failed to save global variable:', error)
toast.error(
t('variableExtraction.saveFailedWithUndo', {
name: data.variableName,
undo: platform.getUndoKey(),
}),
{ duration: 8000, closable: true },
)
}
return
}
try {
tempVarsManager.setVariable(data.variableName, data.variableValue)
toast.success(t('variableExtraction.savedToTemporary', { name: data.variableName }))
} catch (error) {
console.error('[ImageImage2ImageWorkspace] Failed to save temporary variable:', error)
toast.error(
t('variableExtraction.saveFailedWithUndo', {
name: data.variableName,
undo: platform.getUndoKey(),
}),
{ duration: 8000, closable: true },
)
}
}
const handleAddMissingVariable = (varName: string) => {
tempVarsManager.setVariable(varName, '')
toast.success(t('variableDetection.addSuccess', { name: varName }))
}
// handleVariableExtracted / handleAddMissingVariable are provided by useVariableAwareInputBridge
// Session store(单一真源)
const session = useImageImage2ImageSession()
@@ -1267,14 +1251,6 @@ const getVariantImageTestId = (id: TestVariantId) => {
const getVariantResult = (id: TestVariantId) => variantResults.value[id]
const hasVariantResult = (id: TestVariantId) => !!(variantResults.value[id]?.images?.length)
const hashString = (input: string): string => {
let hash = 5381
for (let i = 0; i < input.length; i++) {
hash = ((hash << 5) + hash) ^ input.charCodeAt(i)
}
return (hash >>> 0).toString(36)
}
// image 模式变量优先级:global < temporary < predefined
const mergedGenerationVariables = computed<Record<string, string>>(() => ({
...(variableManager?.customVariables.value || {}),
@@ -1292,15 +1268,6 @@ const buildRuntimePredefinedVariables = (resolved: ResolvedPrompt): Record<strin
}
}
const stableStringifyVars = (vars: Record<string, string>): string => {
const keys = Object.keys(vars).sort()
let out = ''
for (const k of keys) {
out += `${k}=${vars[k] ?? ''}\n`
}
return out
}
// 仅用于 stale 检测:避免对完整 base64 扫描(可能很大)
const getInputImageSignature = (): string => {
const b64 = inputImageB64.value
@@ -1327,7 +1294,7 @@ const getVariantFingerprint = (id: TestVariantId) => {
...mergedGenerationVariables.value,
...buildRuntimePredefinedVariables(resolved),
}
const varsHash = hashString(stableStringifyVars(varsForFingerprint))
const varsHash = hashVariables(varsForFingerprint)
return `${String(selection)}:${resolved.resolvedVersion}:${modelKey}:${promptHash}:${varsHash}:${imgSig}`
}
@@ -1356,13 +1323,17 @@ const getVariantRequest = (id: TestVariantId): Image2ImageRequest | null => {
...buildRuntimePredefinedVariables(resolved),
}
const missing = findMissingVariables(resolved.text, varsForRequest)
if (missing.length > 0) {
toast.error(t('imageWorkspace.generation.missingVariables', { vars: missing.join(', ') }))
const ctx = buildPromptExecutionContext(resolved.text, varsForRequest)
if (ctx.forbiddenTemplateSyntax.length > 0) {
toast.error(t('imageWorkspace.generation.forbiddenTemplateSyntax'))
return null
}
if (ctx.missingVariables.length > 0) {
toast.error(t('imageWorkspace.generation.missingVariables', { vars: ctx.missingVariables.join(', ') }))
return null
}
const prompt = replaceVariablesInContent(resolved.text, varsForRequest)
const prompt = ctx.renderedContent
if (!prompt.trim()) {
toast.error(t('imageWorkspace.generation.missingRequiredFields'))
return null
@@ -342,6 +342,11 @@
<!-- 右侧:图像生成测试区域(图像模型,多列 variants) -->
<div ref="testPaneRef" class="split-pane" style="min-width: 0; height: 100%; overflow: hidden;">
<NFlex vertical :style="{ height: '100%', gap: '12px' }">
<TemporaryVariablesPanel
:manager="temporaryVariablePanelManager"
:disabled="isOptimizing"
:show-open-global-variables="false"
/>
<!-- 顶部:列数与全局操作 -->
<NCard size="small" :style="{ flexShrink: 0 }">
<div class="test-area-top">
@@ -619,10 +624,16 @@ import type { SelectOption } from "../../types/select-options";
import { useToast } from "../../composables/ui/useToast";
import { getI18nErrorMessage } from '../../utils/error'
import { VariableAwareInput } from '../variable-extraction'
import TemporaryVariablesPanel from '../variable/TemporaryVariablesPanel.vue'
import { useTemporaryVariables } from '../../composables/variable/useTemporaryVariables'
import { useVariableAwareInputBridge } from '../../composables/variable/useVariableAwareInputBridge'
import { useTestVariableManager } from '../../composables/variable/useTestVariableManager'
import type { VariableManagerHooks } from '../../composables/prompt/useVariableManager'
import { platform } from '../../utils/platform'
import { findMissingVariables, replaceVariablesInContent } from '../../utils/prompt-variables'
import {
buildPromptExecutionContext,
hashString,
hashVariables,
} from '../../utils/prompt-variables'
import {
useImageText2ImageSession,
type TestColumnCount,
@@ -665,76 +676,49 @@ const services = inject<Ref<AppServices | null>>("services", ref(null));
const variableManager = inject<VariableManagerHooks | null>('variableManager', null)
const tempVarsManager = useTemporaryVariables()
// VariableAwareInput 需要的变量数据
const purePredefinedVariables = computed(() => {
const all = variableManager?.allVariables.value || {}
const custom = variableManager?.customVariables.value || {}
const predefined: Record<string, string> = {}
for (const [key, value] of Object.entries(all)) {
if (!(key in custom)) predefined[key] = value
}
return predefined
const {
variableInputData,
predefinedVariableValues: purePredefinedVariables,
handleVariableExtracted,
handleAddMissingVariable,
} = useVariableAwareInputBridge({
enabled: computed(() => true),
// 仅在全局变量管理器就绪时启用变量感知输入(避免“变量未加载但编辑器已替换/提取”)
isReady: computed(() => variableManager?.isReady.value ?? false),
globalVariables: computed(() => variableManager?.customVariables.value || {}),
temporaryVariables: tempVarsManager.temporaryVariables,
allVariables: computed(() => variableManager?.allVariables.value || {}),
saveGlobalVariable: (name, value) => variableManager?.addVariable(name, value),
saveTemporaryVariable: (name, value) => tempVarsManager.setVariable(name, value),
logPrefix: 'ImageText2ImageWorkspace',
})
const variableInputData = computed(() => {
// 仅在全局变量管理器就绪时启用变量感知输入(避免“变量未加载但编辑器已替换/提取”)
if (!variableManager?.isReady.value) return null
return {
existingGlobalVariables: Object.keys(variableManager.customVariables.value || {}),
existingTemporaryVariables: Object.keys(tempVarsManager.temporaryVariables.value || {}),
predefinedVariables: Object.keys(purePredefinedVariables.value || {}),
globalVariableValues: variableManager.customVariables.value || {},
temporaryVariableValues: tempVarsManager.temporaryVariables.value || {},
predefinedVariableValues: purePredefinedVariables.value || {},
}
const temporaryVariablePanelManager = useTestVariableManager({
globalVariables: computed(() => variableManager?.customVariables.value || {}),
predefinedVariables: purePredefinedVariables,
temporaryVariables: computed(() => tempVarsManager.temporaryVariables.value),
onVariableChange: (name, value) => {
tempVarsManager.setVariable(name, value)
},
onVariableRemove: (name) => {
tempVarsManager.deleteVariable(name)
},
onVariablesClear: () => {
tempVarsManager.clearAll()
},
onSaveToGlobal: (name, value) => {
if (!variableManager || !variableManager.isReady.value) {
throw new Error('variable manager not ready')
}
variableManager.addVariable(name, value)
},
})
const handleOriginalPromptInput = (value: string) => {
originalPrompt.value = value
}
const handleVariableExtracted = (data: {
variableName: string
variableValue: string
variableType: 'global' | 'temporary'
}) => {
if (data.variableType === 'global') {
try {
variableManager?.addVariable(data.variableName, data.variableValue)
toast.success(t('variableExtraction.savedToGlobal', { name: data.variableName }))
} catch (error) {
console.error('[ImageText2ImageWorkspace] Failed to save global variable:', error)
toast.error(
t('variableExtraction.saveFailedWithUndo', {
name: data.variableName,
undo: platform.getUndoKey(),
}),
{ duration: 8000, closable: true },
)
}
return
}
try {
tempVarsManager.setVariable(data.variableName, data.variableValue)
toast.success(t('variableExtraction.savedToTemporary', { name: data.variableName }))
} catch (error) {
console.error('[ImageText2ImageWorkspace] Failed to save temporary variable:', error)
toast.error(
t('variableExtraction.saveFailedWithUndo', {
name: data.variableName,
undo: platform.getUndoKey(),
}),
{ duration: 8000, closable: true },
)
}
}
const handleAddMissingVariable = (varName: string) => {
tempVarsManager.setVariable(varName, '')
toast.success(t('variableDetection.addSuccess', { name: varName }))
}
// handleVariableExtracted / handleAddMissingVariable are provided by useVariableAwareInputBridge
// Session store(单一真源)
const session = useImageText2ImageSession()
@@ -1122,14 +1106,6 @@ const getVariantImageTestId = (id: TestVariantId) => {
const getVariantResult = (id: TestVariantId) => variantResults.value[id]
const hasVariantResult = (id: TestVariantId) => !!(variantResults.value[id]?.images?.length)
const hashString = (input: string): string => {
let hash = 5381
for (let i = 0; i < input.length; i++) {
hash = ((hash << 5) + hash) ^ input.charCodeAt(i)
}
return (hash >>> 0).toString(36)
}
// image 模式变量优先级:global < temporary < predefined
// - predefined 作为只读“系统变量”,在替换阶段优先级最高
const mergedGenerationVariables = computed<Record<string, string>>(() => ({
@@ -1149,15 +1125,6 @@ const buildRuntimePredefinedVariables = (resolved: ResolvedPrompt): Record<strin
}
}
const stableStringifyVars = (vars: Record<string, string>): string => {
const keys = Object.keys(vars).sort()
let out = ''
for (const k of keys) {
out += `${k}=${vars[k] ?? ''}\n`
}
return out
}
const getVariantFingerprint = (id: TestVariantId) => {
const selection = variantVersionModels[id].value
const resolved = resolvePromptForSelection(selection)
@@ -1167,7 +1134,7 @@ const getVariantFingerprint = (id: TestVariantId) => {
...mergedGenerationVariables.value,
...buildRuntimePredefinedVariables(resolved),
}
const varsHash = hashString(stableStringifyVars(varsForFingerprint))
const varsHash = hashVariables(varsForFingerprint)
return `${String(selection)}:${resolved.resolvedVersion}:${modelKey}:${promptHash}:${varsHash}`
}
@@ -1196,13 +1163,17 @@ const getVariantRequest = (id: TestVariantId): Text2ImageRequest | null => {
...buildRuntimePredefinedVariables(resolved),
}
const missing = findMissingVariables(resolved.text, varsForRequest)
if (missing.length > 0) {
toast.error(t('imageWorkspace.generation.missingVariables', { vars: missing.join(', ') }))
const ctx = buildPromptExecutionContext(resolved.text, varsForRequest)
if (ctx.forbiddenTemplateSyntax.length > 0) {
toast.error(t('imageWorkspace.generation.forbiddenTemplateSyntax'))
return null
}
if (ctx.missingVariables.length > 0) {
toast.error(t('imageWorkspace.generation.missingVariables', { vars: ctx.missingVariables.join(', ') }))
return null
}
const prompt = replaceVariablesInContent(resolved.text, varsForRequest)
const prompt = ctx.renderedContent
if (!prompt.trim()) {
toast.error(t('imageWorkspace.generation.missingRequiredFields'))
return null
@@ -0,0 +1,228 @@
<template>
<!-- 变量值输入表单(临时变量编辑区) -->
<NCard
:title="t('test.variables.formTitle')"
size="small"
:bordered="true"
:style="{ flexShrink: 0 }"
>
<template #header-extra>
<NSpace :size="8">
<NTag :bordered="false" type="info" size="small">
{{
t('test.variables.tempCount', {
count: displayVariables.length,
})
}}
</NTag>
<NButton
v-if="props.showGenerateValues"
size="small"
quaternary
:loading="props.isGenerating"
:disabled="
props.disabled ||
props.isGenerating ||
displayVariables.length === 0
"
@click="emit('generate-values')"
>
{{
props.isGenerating
? t('test.variableValueGeneration.generating')
: t('test.variableValueGeneration.generateButton')
}}
</NButton>
<NButton
size="small"
quaternary
:disabled="props.disabled"
@click="handleClearAllVariables"
>
{{ t('test.variables.clearAll') }}
</NButton>
<NButton
v-if="props.showOpenGlobalVariables"
size="small"
quaternary
:disabled="props.disabled"
@click="emit('open-global-variables')"
>
{{ t('contextMode.actions.globalVariables') }}
</NButton>
</NSpace>
</template>
<NSpace vertical :size="12">
<!-- 变量输入项 -->
<div
v-for="varName in displayVariables"
:key="varName"
:style="{
display: 'flex',
alignItems: 'center',
gap: '8px',
}"
>
<NTag
size="small"
:bordered="false"
:type="
getVariableSource(varName) === 'predefined'
? 'success'
: getVariableSource(varName) === 'test'
? 'warning'
: 'default'
"
:style="{ minWidth: '120px', flexShrink: 0 }"
>
<span v-text="`{{${varName}}}`"></span>
</NTag>
<NInput
:value="getVariableDisplayValue(varName)"
:placeholder="getVariablePlaceholder(varName)"
size="small"
:disabled="props.disabled"
:style="{ flex: 1 }"
@update:value="handleVariableValueChange(varName, $event)"
/>
<!-- 删除按钮 (仅临时变量显示) -->
<NButton
v-if="getVariableSource(varName) === 'test'"
size="small"
quaternary
:disabled="props.disabled"
@click="handleDeleteVariable(varName)"
:title="t('test.variables.delete')"
>
🗑️
</NButton>
<!-- 保存到全局按钮 (仅测试变量显示) -->
<NButton
v-if="getVariableSource(varName) === 'test'"
size="small"
quaternary
:disabled="props.disabled"
@click="handleSaveToGlobal(varName)"
:title="t('test.variables.saveToGlobal')"
>
💾
</NButton>
</div>
<!-- 无变量提示 -->
<NEmpty
v-if="displayVariables.length === 0"
:description="t('test.variables.noVariables')"
size="small"
/>
<!-- 操作按钮 -->
<NSpace :size="8" justify="end">
<!-- 添加变量按钮 -->
<NButton
size="small"
:disabled="props.disabled"
@click="showAddVariableDialog = true"
>
{{ t('test.variables.addVariable') }}
</NButton>
</NSpace>
</NSpace>
</NCard>
<!-- 添加变量对话框 -->
<NModal
v-model:show="showAddVariableDialog"
preset="dialog"
:title="t('test.variables.addVariable')"
:positive-text="t('common.confirm')"
:negative-text="t('common.cancel')"
:on-positive-click="handleAddVariable"
:mask-closable="false"
>
<NSpace vertical :size="12" style="margin-top: 16px">
<NFormItem
:label="t('variableExtraction.variableName')"
:validation-status="newVariableNameError ? 'error' : undefined"
:feedback="newVariableNameError"
>
<NInput
v-model:value="newVariableName"
:placeholder="t('variableExtraction.variableNamePlaceholder')"
:disabled="props.disabled"
@input="validateNewVariableName"
/>
</NFormItem>
<NFormItem :label="t('variableExtraction.variableValue')">
<NInput
v-model:value="newVariableValue"
:placeholder="t('variableExtraction.variableValuePlaceholder')"
:disabled="props.disabled"
/>
</NFormItem>
</NSpace>
</NModal>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import {
NCard,
NSpace,
NTag,
NButton,
NInput,
NEmpty,
NModal,
NFormItem,
} from 'naive-ui'
import type { TestVariableManager } from '../../composables/variable/useTestVariableManager'
interface Props {
manager: TestVariableManager
disabled?: boolean
// Optional actions
showOpenGlobalVariables?: boolean
showGenerateValues?: boolean
isGenerating?: boolean
}
const props = withDefaults(defineProps<Props>(), {
disabled: false,
showOpenGlobalVariables: true,
showGenerateValues: false,
isGenerating: false,
})
const emit = defineEmits<{
(e: 'open-global-variables'): void
(e: 'generate-values'): void
}>()
const { t } = useI18n()
const {
showAddVariableDialog,
newVariableName,
newVariableValue,
newVariableNameError,
sortedVariables,
getVariableSource,
getVariableDisplayValue,
getVariablePlaceholder,
validateNewVariableName,
handleVariableValueChange,
handleAddVariable,
handleDeleteVariable,
handleClearAllVariables,
handleSaveToGlobal,
} = props.manager
const displayVariables = computed(() => sortedVariables.value)
</script>
@@ -8,6 +8,8 @@ import { ref, computed, watch, type Ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useMessage } from 'naive-ui'
import { VARIABLE_VALIDATION } from '../../types/variable'
interface TestVariable {
value: string
timestamp: number
@@ -34,6 +36,9 @@ export function useTestVariableManager(options: TestVariableManagerOptions) {
const { t } = useI18n()
const message = useMessage()
const hasOwn = (obj: Record<string, unknown>, key: string) =>
Object.prototype.hasOwnProperty.call(obj, key)
// 添加变量对话框状态
const showAddVariableDialog = ref(false)
const newVariableName = ref('')
@@ -87,10 +92,15 @@ export function useTestVariableManager(options: TestVariableManagerOptions) {
})
// 获取变量来源
const getVariableSource = (varName: string): 'predefined' | 'test' | 'global' | 'empty' => {
if (options.predefinedVariables.value[varName]) return 'predefined'
if (testVariables.value[varName]) return 'test'
if (options.globalVariables.value[varName]) return 'global'
const getVariableSource = (
varName: string
): 'predefined' | 'test' | 'global' | 'empty' => {
// Use key existence (not truthiness) because empty-string values are valid.
if (hasOwn(options.predefinedVariables.value, varName)) return 'predefined'
if (hasOwn(testVariables.value as unknown as Record<string, unknown>, varName)) {
return 'test'
}
if (hasOwn(options.globalVariables.value, varName)) return 'global'
return 'empty'
}
@@ -101,10 +111,10 @@ export function useTestVariableManager(options: TestVariableManagerOptions) {
// 获取变量占位符
const getVariablePlaceholder = (varName: string): string => {
if (options.predefinedVariables.value[varName]) {
if (hasOwn(options.predefinedVariables.value, varName)) {
return t('test.variables.inputPlaceholder') + ` (${t('variables.source.predefined')})`
}
if (options.globalVariables.value[varName]) {
if (hasOwn(options.globalVariables.value, varName)) {
return t('test.variables.inputPlaceholder') + ` (${t('variables.source.global')})`
}
return t('test.variables.inputPlaceholder')
@@ -113,11 +123,24 @@ export function useTestVariableManager(options: TestVariableManagerOptions) {
// 验证变量名
const validateVariableName = (name: string): string => {
if (!name) return ''
if (/^\d/.test(name)) return t('variableExtraction.validation.noNumberStart')
if (!/^[\u4e00-\u9fa5a-zA-Z_][\u4e00-\u9fa5a-zA-Z0-9_]*$/.test(name)) {
const trimmedName = name.trim()
if (!trimmedName) return ''
// Align with shared variable rules used by scanning/replacement.
if (!VARIABLE_VALIDATION.NAME_PATTERN.test(trimmedName)) {
return t('variableExtraction.validation.invalidCharacters')
}
if (testVariables.value[name]) return t('variableExtraction.validation.duplicateVariable')
// Prevent creating a temporary variable that would be shadowed by predefined variables.
if (hasOwn(options.predefinedVariables.value, trimmedName)) {
return t('variableExtraction.validation.predefinedVariable')
}
if (testVariables.value[trimmedName]) {
return t('variableExtraction.validation.duplicateVariable')
}
return ''
}
@@ -161,15 +184,31 @@ export function useTestVariableManager(options: TestVariableManagerOptions) {
// 删除变量
const handleDeleteVariable = (varName: string) => {
delete testVariables.value[varName]
options.onVariableRemove?.(varName)
options.onVariableChange?.(varName, '')
// Prefer explicit remove callback; fallback to onVariableChange(name, '') for legacy callers.
if (options.onVariableRemove) {
options.onVariableRemove(varName)
} else {
options.onVariableChange?.(varName, '')
}
message.success(t('test.variables.deleteSuccess', { name: varName }))
}
// 清空所有变量
const handleClearAllVariables = () => {
const removedNames = Object.keys(testVariables.value)
testVariables.value = {}
options.onVariablesClear?.()
// Prefer explicit clear callback; otherwise best-effort remove for callers.
if (options.onVariablesClear) {
options.onVariablesClear()
} else if (options.onVariableRemove) {
removedNames.forEach((name) => options.onVariableRemove?.(name))
} else {
removedNames.forEach((name) => options.onVariableChange?.(name, ''))
}
message.success(t('test.variables.clearSuccess'))
}
@@ -180,8 +219,22 @@ export function useTestVariableManager(options: TestVariableManagerOptions) {
message.warning(t('test.variables.emptyValueWarning'))
return
}
options.onSaveToGlobal?.(varName, varData.value)
message.success(t('test.variables.savedToGlobal'))
if (!options.onSaveToGlobal) return
try {
options.onSaveToGlobal(varName, varData.value)
message.success(t('test.variables.savedToGlobal'))
} catch (err) {
console.warn('[useTestVariableManager] onSaveToGlobal failed:', err)
const errMessage = err instanceof Error ? err.message : ''
if (/not\s+ready/i.test(errMessage)) {
message.error(t('variableExtraction.managerNotReady'))
} else {
message.error(t('variableExtraction.saveFailed', { name: varName }))
}
}
}
// 获取所有变量值
@@ -219,3 +272,5 @@ export function useTestVariableManager(options: TestVariableManagerOptions) {
setVariableValues,
}
}
export type TestVariableManager = ReturnType<typeof useTestVariableManager>