CMVR-IOT-UI/src/views/flow/components/params/ImageAnalysisParams.vue
lixiaolong b3dae8db39 feat(flow): 添加子流程功能并优化参数引用逻辑
- 新增子流程节点类型,支持在同一项目中执行已发布工作流
- 添加子流程节点UI组件和相关配置选项
- 重构参数引用逻辑,使用getUpstreamInput替代getInput方法
- 优化cascader组件属性设置,统一使用props配置checkStrictly
- 修正多个参数组件中的handleTypeChange和cascaderChange函数参数传递
- 添加流程输出参数管理功能,支持引用来源选择和类型识别
- 实现子流程运行时状态管理和迭代输出处理
- 增加循环引用检测和资源验证功能
- 更新API接口,添加子流程选项获取方法
- 修复流程引用和依赖关系处理逻辑
2026-08-21 11:48:10 +08:00

432 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!--
* 文件说明:通用图片分析节点参数编辑器,支持引用上游图片或直接填写 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>