CMVR-IOT-UI/src/views/flow/components/params/ImageAnalysisParams.vue

432 lines
20 KiB
Vue
Raw Normal View History

<!--
* 文件说明:通用图片分析节点参数编辑器,支持引用上游图片或直接填写 HTTP/HTTPS 图片地址。
* 数据流向:从 nodeParams 回填表单,保存时写回 IMAGE_ANALYZE 执行协议和统一输出字段。
-->
<template>
<div class="image-analysis-params">
<div class="intro">
<div class="intro__title">通用图片分析</div>
<div class="intro__desc">根据提示词分析一张或多张图片,模型结果保持提示词要求的内容和格式。</div>
</div>
<el-form ref="formRef" :model="formData" :rules="rules" label-position="top">
<el-form-item label="分析方式">
<el-segmented v-model="formData.analysisMethod" :options="methodOptions" block />
</el-form-item>
<el-form-item :label="isReferenceMatch ? '大图来源' : (isTextLocate ? '目标图片来源' : '图片来源')" prop="imageMode">
<el-segmented v-model="formData.imageMode" :options="sourceOptions" />
</el-form-item>
<el-form-item v-if="formData.imageMode === 'quote'" label="引用上游图片" prop="imageQuote">
<el-cascader
ref="imageCascaderRef"
v-model="formData.imageQuote"
:options="quoteOptions"
:props="{ checkStrictly: true }"
placeholder="选择图片节点的 imageUrl 输出"
style="width: 100%"
@visible-change="loadQuoteOptions"
@change="handleQuoteChange"
/>
<div class="field-hint" v-if="isVisionAnalysis">上游输出为图片数组时,将按原顺序全部分析,最多 12 张。</div>
</el-form-item>
<el-form-item v-else label="图片地址" prop="imageUrlsText">
<el-input
v-model="formData.imageUrlsText"
type="textarea"
:rows="4"
:placeholder="isLocationMethod ? '输入一张目标图片的 HTTP/HTTPS 地址' : '每行一个 HTTP/HTTPS 图片地址,最多 12 张'"
/>
</el-form-item>
<el-form-item v-if="!isReferenceMatch" :label="isTextLocate ? '目标描述' : '分析提示词'" prop="prompt">
<el-input
v-model="formData.prompt"
type="textarea"
:rows="5"
maxlength="4000"
show-word-limit
:placeholder="isTextLocate ? '例如:底部应用栏中的设置按钮' : '例如:读取图片中的仪表盘数值,只输出数字。或:识别所有仪表,按 JSON 输出名称、数值和单位。'"
/>
</el-form-item>
<el-form-item v-if="!isReferenceMatch" label="分析模式">
<el-segmented v-model="formData.analysisMode" :options="modeOptions" block />
</el-form-item>
<el-form-item label="异步执行">
<el-switch v-model="formData.asynchronous" />
<div class="field-hint">开启后分析在后台并行执行,流程结束前统一等待分析结果。</div>
</el-form-item>
<el-form-item v-if="isTextLocate" label="最低定位置信度">
<el-slider v-model="formData.analysisTuning.locateConfidenceThreshold" :min="0.2" :max="0.95" :step="0.01" show-input />
</el-form-item>
<template v-if="isReferenceMatch">
<el-form-item label="小图来源">
<el-segmented v-model="formData.referenceMode" :options="sourceOptions" />
</el-form-item>
<el-form-item v-if="formData.referenceMode === 'quote'" label="引用待查小图" prop="referenceQuote">
<el-cascader
ref="referenceCascaderRef"
v-model="formData.referenceQuote"
:options="quoteOptions"
:props="{ checkStrictly: true }"
placeholder="选择待查小图输出"
style="width: 100%"
@visible-change="loadQuoteOptions"
@change="handleReferenceQuoteChange"
/>
</el-form-item>
<el-form-item v-else label="小图地址" prop="referenceImageUrl">
<el-input v-model="formData.referenceImageUrl" clearable placeholder="输入待查小图的 HTTP/HTTPS 地址" />
</el-form-item>
<el-form-item label="最低匹配分数">
<el-slider v-model="formData.analysisTuning.matchThreshold" :min="0.2" :max="0.95" :step="0.01" show-input />
</el-form-item>
<el-form-item label="选择匹配结果">
<el-select v-model="formData.analysisTuning.matchSelector" style="width: 100%">
<el-option v-for="item in matchSelectorOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<template v-if="formData.analysisTuning.matchSelector === 'INDEX'">
<el-form-item label="排序方式">
<el-segmented v-model="formData.analysisTuning.matchSort" :options="matchSortOptions" block />
</el-form-item>
<el-form-item label="选择第几个">
<el-input-number v-model="formData.analysisTuning.matchIndex" :min="1" :max="20" />
</el-form-item>
</template>
</template>
<el-collapse v-if="!isReferenceMatch" v-model="advancedSections" class="advanced-settings">
<el-collapse-item v-if="isVisionAnalysis" title="参考图片(可选)" name="reference">
<el-form-item label="参考图片来源">
<el-segmented v-model="formData.referenceMode" :options="sourceOptions" />
</el-form-item>
<el-form-item v-if="formData.referenceMode === 'quote'" label="引用参考图片" prop="referenceQuote">
<el-cascader
ref="referenceCascaderRef"
v-model="formData.referenceQuote"
:options="quoteOptions"
:props="{ checkStrictly: true }"
placeholder="选择参考图片输出"
style="width: 100%"
clearable
@visible-change="loadQuoteOptions"
@change="handleReferenceQuoteChange"
/>
</el-form-item>
<el-form-item v-else label="参考图片地址" prop="referenceImageUrl">
<el-input v-model="formData.referenceImageUrl" clearable placeholder="HTTP/HTTPS 图片地址" />
</el-form-item>
<div class="field-hint">参考图片会作为最后一张图片传入;请在提示词中说明它的用途。</div>
</el-collapse-item>
<el-collapse-item title="高级设置" name="advanced">
<el-form-item label="图片最大宽度">
<el-slider v-model="formData.analysisTuning.maxWidth" :min="640" :max="1600" :step="64" show-input />
</el-form-item>
<el-form-item v-if="isVisionAnalysis" label="最多分析图片">
<el-input-number v-model="formData.analysisTuning.maxImages" :min="1" :max="12" />
</el-form-item>
<el-form-item label="最大输出长度">
<el-input-number v-model="formData.analysisTuning.maxOutputTokens" :min="32" :max="4096" :step="32" />
</el-form-item>
</el-collapse-item>
</el-collapse>
</el-form>
<el-divider content-position="left">节点输出</el-divider>
<div class="output-list">
<div v-for="item in outputSummary" :key="item.name" class="output-item">
<code>{{ item.name }}</code><span>{{ item.desc }}</span>
</div>
</div>
</div>
</template>
<script setup>
import { computed, reactive, ref, watch } from 'vue'
import { getUpstreamInput } from '@/utils/flow'
import { IMAGE_ANALYSIS_OUTPUT_PARAMS } from '@/utils/flowResources'
const props = defineProps({ data: Object })
const emit = defineEmits(['save-success', 'save-error'])
const formRef = ref()
const imageCascaderRef = ref()
const referenceCascaderRef = ref()
const quoteOptions = ref([])
const advancedSections = ref([])
// 这里的 value 会直接写入 nodeParams.type,需与流程参数协议中的 quote/input 保持一致。
const sourceOptions = [
{ label: '引用节点输出', value: 'quote' },
{ label: '输入图片地址', value: 'input' },
]
const modeOptions = [
{ label: '快速', value: 'FAST' },
{ label: '精准', value: 'ACCURATE' },
]
const methodOptions = [
{ label: '智能分析', value: 'VISION_MODEL' },
{ label: '描述找图', value: 'TEXT_LOCATE' },
{ label: '精确找图', value: 'REFERENCE_MATCH' },
]
const matchSelectorOptions = [
{ label: '最佳匹配', value: 'BEST' },
{ label: '按序号选择', value: 'INDEX' },
{ label: '左上', value: 'TOP_LEFT' },
{ label: '右上', value: 'TOP_RIGHT' },
{ label: '左下', value: 'BOTTOM_LEFT' },
{ label: '右下', value: 'BOTTOM_RIGHT' },
]
const matchSortOptions = [
{ label: '先行后列', value: 'ROW_MAJOR' },
{ label: '先列后行', value: 'COLUMN_MAJOR' },
]
/**
* 使用稳定的 reactive 对象承载表单状态;initData 只覆盖字段,避免 Element Plus 校验引用失效。
*/
const formData = reactive({
analysisMethod: 'VISION_MODEL',
imageMode: 'quote',
imageQuote: [],
imageQuoteType: 'output',
imageUrlsText: '',
referenceMode: 'input',
referenceQuote: [],
referenceQuoteType: 'output',
referenceImageUrl: '',
prompt: '',
analysisMode: 'FAST',
asynchronous: false,
analysisTuning: {
maxWidth: 896,
maxImages: 12,
maxOutputTokens: 384,
matchThreshold: 0.48,
matchSelector: 'BEST',
matchIndex: 1,
matchSort: 'ROW_MAJOR',
locateConfidenceThreshold: 0.45,
},
})
const isReferenceMatch = computed(() => formData.analysisMethod === 'REFERENCE_MATCH')
const isTextLocate = computed(() => formData.analysisMethod === 'TEXT_LOCATE')
const isVisionAnalysis = computed(() => formData.analysisMethod === 'VISION_MODEL')
const isLocationMethod = computed(() => isReferenceMatch.value || isTextLocate.value)
const outputSummary = [
{ name: 'result', desc: '按提示词返回的结果;JSON、数组和数字会自动解析' },
{ name: 'resultText', desc: '模型返回的原始文本' },
{ name: 'matched', desc: '找图结果是否达到最低分数' },
{ name: 'matchScore', desc: '找图的匹配分数或定位置信度' },
{ name: 'coordinates', desc: '大图原始尺寸坐标 [x1,y1,x2,y2]' },
{ name: 'center', desc: '目标中心点坐标 [x,y]' },
{ name: 'matchedDescription', desc: '模型实际定位的目标描述' },
{ name: 'refinementMethod', desc: '坐标精修方式' },
{ name: 'matchCount', desc: '找到的匹配项数量' },
{ name: 'matches', desc: '全部匹配项及其序号、坐标和分数' },
{ name: 'selectedMatchIndex', desc: '当前选择的匹配序号' },
]
// 文本输入允许换行或逗号分隔,保存前统一转成执行端需要的 URL 数组。
const parseUrls = value => String(value || '').split(/\r?\n|,/).map(item => item.trim()).filter(Boolean)
const validUrl = value => /^https?:\/\//i.test(value)
const hasReference = () => formData.referenceMode === 'quote'
? formData.referenceQuote.length > 0
: Boolean(formData.referenceImageUrl.trim())
/**
* 条件校验只约束当前可见的图片来源,隐藏模式不阻塞保存;直接输入仅接受 HTTP/HTTPS。
*/
const rules = {
imageQuote: [{
validator: (_, value) => formData.imageMode !== 'quote' || value?.length
? Promise.resolve()
: Promise.reject(new Error('请选择图片输出')),
trigger: 'change',
}],
imageUrlsText: [{
validator: (_, value) => {
if (formData.imageMode !== 'input') return Promise.resolve()
const urls = parseUrls(value)
if (!urls.length) return Promise.reject(new Error('请至少输入一个图片地址'))
const maximum = isLocationMethod.value ? 1 : 12
if (urls.length > maximum) return Promise.reject(new Error(isLocationMethod.value ? '找图只能输入一张目标图片' : '最多输入 12 张图片'))
return urls.every(validUrl)
? Promise.resolve()
: Promise.reject(new Error('图片地址必须是 HTTP/HTTPS 地址'))
},
trigger: 'blur',
}],
referenceImageUrl: [{
validator: (_, value) => {
if (formData.referenceMode !== 'input') return Promise.resolve()
if (isReferenceMatch.value && !String(value || '').trim()) return Promise.reject(new Error('请输入待查小图地址'))
return !value || validUrl(String(value).trim())
? Promise.resolve()
: Promise.reject(new Error('请输入有效的参考图片地址'))
},
trigger: 'blur',
}],
referenceQuote: [{
validator: (_, value) => !isReferenceMatch.value || formData.referenceMode !== 'quote' || value?.length
? Promise.resolve()
: Promise.reject(new Error('请选择待查小图输出')),
trigger: 'change',
}],
prompt: [{
validator: (_, value) => isReferenceMatch.value || String(value || '').trim()
? Promise.resolve()
: Promise.reject(new Error('请输入分析提示词')),
trigger: 'blur',
}],
}
// 引用选项按需加载,上游流程较大时避免每次渲染都遍历画布。
const loadQuoteOptions = visible => { if (visible) quoteOptions.value = getUpstreamInput(props.data.id) || [] }
const handleQuoteChange = () => {
formData.imageQuoteType = imageCascaderRef.value?.getCheckedNodes?.(true)?.[0]?.data?.type || 'output'
}
const handleReferenceQuoteChange = () => {
formData.referenceQuoteType = referenceCascaderRef.value?.getCheckedNodes?.(true)?.[0]?.data?.type || 'output'
}
const getParam = (params, name) => params.find(item => item.name === name)
/**
* 兼容旧版图片识别节点:把 taskType、targetDescription、instruction 合成新的自由提示词。
*/
const legacyPrompt = (params) => {
const target = String(getParam(params, 'targetDescription')?.input || '').trim()
const instruction = String(getParam(params, 'instruction')?.input || '').trim()
const taskType = String(getParam(params, 'taskType')?.input || '').toUpperCase()
const parts = [target || instruction]
if (taskType === 'ICON_TEMPLATE_LOCATE') {
parts.push('最后一张图片是参考图片,请在其他图片中查找对应目标并返回坐标。')
}
parts.push('只返回分析结果,不输出通过、未通过或置信度。')
return parts.filter(Boolean).join('\n')
}
/**
* 将持久化 nodeParams 回填到表单,并为缺少新字段的旧节点补默认值。
*/
const initData = () => {
loadQuoteOptions(true)
const params = props.data?.properties?.nodeParams || []
const images = getParam(params, 'imageUrl')
const reference = getParam(params, 'referenceImageUrl')
const tuning = getParam(params, 'analysisTuning')?.input || {}
const asynchronous = getParam(params, 'asynchronous')
formData.imageMode = images?.type === 'input' ? 'input' : 'quote'
formData.imageQuote = Array.isArray(images?.quote) ? [...images.quote] : []
formData.imageQuoteType = images?.quoteType || 'output'
const staticImages = Array.isArray(images?.input) ? [...images.input] : parseUrls(images?.input || '')
const savedMethod = String(getParam(params, 'analysisMethod')?.input || '').toUpperCase()
const savedPrompt = getParam(params, 'prompt')?.input || legacyPrompt(params)
const coordinatePrompt = /(坐标|位置|小图|参考图|图标|coordinate|position|bbox|template)/i.test(savedPrompt)
const legacyReferenceMatch = !savedMethod && coordinatePrompt
&& ((reference && staticImages.length === 1) || (!reference && staticImages.length === 2))
const legacyTextLocate = !savedMethod && coordinatePrompt && !reference && staticImages.length === 1
formData.analysisMethod = savedMethod === 'REFERENCE_MATCH' || legacyReferenceMatch
? 'REFERENCE_MATCH'
: (savedMethod === 'TEXT_LOCATE' || legacyTextLocate ? 'TEXT_LOCATE' : 'VISION_MODEL')
formData.imageUrlsText = (legacyReferenceMatch ? staticImages.slice(0, 1) : staticImages).join('\n')
formData.referenceMode = reference?.type === 'quote' ? 'quote' : 'input'
formData.referenceQuote = Array.isArray(reference?.quote) ? [...reference.quote] : []
formData.referenceQuoteType = reference?.quoteType || 'output'
formData.referenceImageUrl = reference?.input || (legacyReferenceMatch ? staticImages[1] : '')
formData.prompt = savedPrompt
const savedMode = String(getParam(params, 'analysisMode')?.input || '').toUpperCase()
formData.analysisMode = savedMode === 'ACCURATE' ? 'ACCURATE' : 'FAST'
formData.asynchronous = asynchronous?.input === true || asynchronous?.input === 'true'
formData.analysisTuning.maxWidth = Number(tuning.maxWidth) || (formData.analysisMode === 'FAST' ? 896 : 1280)
formData.analysisTuning.maxImages = Number(tuning.maxImages) || 12
formData.analysisTuning.maxOutputTokens = Number(tuning.maxOutputTokens)
|| (formData.analysisMode === 'FAST' ? 384 : 512)
formData.analysisTuning.matchThreshold = Number(tuning.matchThreshold) || 0.48
formData.analysisTuning.matchSelector = matchSelectorOptions.some(item => item.value === tuning.matchSelector)
? tuning.matchSelector
: 'BEST'
formData.analysisTuning.matchIndex = Math.max(1, Math.min(20, Number(tuning.matchIndex) || 1))
formData.analysisTuning.matchSort = tuning.matchSort === 'COLUMN_MAJOR' ? 'COLUMN_MAJOR' : 'ROW_MAJOR'
formData.analysisTuning.locateConfidenceThreshold = Number(tuning.locateConfidenceThreshold) || 0.45
advancedSections.value = isVisionAnalysis.value && hasReference() ? ['reference'] : []
}
watch(() => props.data, initData, { immediate: true, deep: true })
/**
* 媒体参数统一保存为 quote/input 二选一结构;未使用的一侧清空,避免执行端误读。
*/
const buildMediaParam = (name, mode, input, quote, quoteType, required = true) => ({
name,
type: mode,
input: mode === 'input' ? input : '',
quote: mode === 'quote' ? [...quote] : '',
quoteType,
required,
disabled: true,
})
/**
* 校验通过后重建 IMAGE_ANALYZE 节点属性,确保 action、类型和输出契约同步升级。
*/
const validateAndSave = async () => {
try {
await formRef.value.validate()
const nodeParams = [
{ name: 'profileCode', type: 'input', input: 'common.image_analysis.v1', required: true, disabled: true },
buildMediaParam('imageUrl', formData.imageMode, parseUrls(formData.imageUrlsText), formData.imageQuote, formData.imageQuoteType),
{ name: 'analysisMethod', type: 'input', input: formData.analysisMethod, required: false, disabled: true },
{ name: 'prompt', type: 'input', input: isReferenceMatch.value ? '在大图中查找小图,返回原图像素坐标 [x1,y1,x2,y2]' : formData.prompt.trim(), required: true, disabled: true, componentType: 'textarea' },
{ name: 'analysisMode', type: 'input', input: formData.analysisMode, required: false, disabled: true, componentType: 'select', selectOptions: modeOptions },
{ name: 'analysisTuning', type: 'input', input: { ...formData.analysisTuning }, required: false, disabled: true },
{ name: 'asynchronous', type: 'input', input: formData.asynchronous, required: false, disabled: true, componentType: 'switch' },
]
if (!isTextLocate.value && hasReference()) {
// 参考图存在时插入到图片来源和提示词之间,保持执行端按固定顺序读取。
nodeParams.splice(3, 0, buildMediaParam(
'referenceImageUrl', formData.referenceMode, formData.referenceImageUrl.trim(),
formData.referenceQuote, formData.referenceQuoteType, false,
))
}
const properties = {
...props.data.properties,
name: '通用图片分析',
action: 'IMAGE_ANALYZE',
nodeType: 'LLM',
nodeParams,
outputType: 'json',
outputParams: JSON.parse(JSON.stringify(IMAGE_ANALYSIS_OUTPUT_PARAMS)),
}
// 图片分析由 LLM 服务执行,不绑定机器人或设备资源。
delete properties.executionTarget
lf.setProperties(props.data.id, properties)
emit('save-success')
} catch {
emit('save-error')
}
}
defineExpose({ validateAndSave })
</script>
<style scoped lang="scss">
.image-analysis-params { padding-top: 16px; }
.intro { margin-bottom: 22px; padding: 14px 16px; border-left: 3px solid var(--el-color-primary); background: var(--el-fill-color-light); }
.intro__title { color: var(--el-text-color-primary); font-weight: 650; }
.intro__desc, .field-hint { margin-top: 5px; color: var(--el-text-color-secondary); font-size: 12px; line-height: 18px; }
.advanced-settings { margin-bottom: 18px; }
.output-list { display: grid; gap: 8px; }
.output-item { display: flex; align-items: baseline; gap: 12px; color: var(--el-text-color-regular); font-size: 13px; }
.output-item code { min-width: 92px; color: var(--el-color-primary); }
</style>