forked from SmartBench/CMVR-IOT-UI
302 lines
13 KiB
Vue
302 lines
13 KiB
Vue
<!--
|
||
* 文件说明:通用图片分析节点参数编辑器,支持引用上游图片或直接填写 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="图片来源" 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">上游输出为图片数组时,将按原顺序全部分析,最多 12 张。</div>
|
||
</el-form-item>
|
||
|
||
<el-form-item v-else label="图片地址" prop="imageUrlsText">
|
||
<el-input
|
||
v-model="formData.imageUrlsText"
|
||
type="textarea"
|
||
:rows="4"
|
||
placeholder="每行一个 HTTP/HTTPS 图片地址,最多 12 张"
|
||
/>
|
||
</el-form-item>
|
||
|
||
<el-form-item label="分析提示词" prop="prompt">
|
||
<el-input
|
||
v-model="formData.prompt"
|
||
type="textarea"
|
||
:rows="5"
|
||
maxlength="4000"
|
||
show-word-limit
|
||
placeholder="例如:读取图片中的仪表盘数值,只输出数字。或:识别所有仪表,按 JSON 输出名称、数值和单位。"
|
||
/>
|
||
</el-form-item>
|
||
|
||
<el-form-item label="分析模式">
|
||
<el-segmented v-model="formData.analysisMode" :options="modeOptions" block />
|
||
</el-form-item>
|
||
|
||
<el-collapse v-model="advancedSections" class="advanced-settings">
|
||
<el-collapse-item 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 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 { reactive, ref, watch } from 'vue'
|
||
import { getInput } 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' },
|
||
]
|
||
|
||
/**
|
||
* 使用稳定的 reactive 对象承载表单状态;initData 只覆盖字段,避免 Element Plus 校验引用失效。
|
||
*/
|
||
const formData = reactive({
|
||
imageMode: 'quote',
|
||
imageQuote: [],
|
||
imageQuoteType: 'output',
|
||
imageUrlsText: '',
|
||
referenceMode: 'input',
|
||
referenceQuote: [],
|
||
referenceQuoteType: 'output',
|
||
referenceImageUrl: '',
|
||
prompt: '',
|
||
analysisMode: 'FAST',
|
||
analysisTuning: { maxWidth: 896, maxImages: 12, maxOutputTokens: 384 },
|
||
})
|
||
|
||
const outputSummary = [
|
||
{ name: 'result', desc: '按提示词返回的结果;JSON、数组和数字会自动解析' },
|
||
{ name: 'resultText', 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('请至少输入一个图片地址'))
|
||
if (urls.length > 12) return Promise.reject(new Error('最多输入 12 张图片'))
|
||
return urls.every(validUrl)
|
||
? Promise.resolve()
|
||
: Promise.reject(new Error('图片地址必须是 HTTP/HTTPS 地址'))
|
||
},
|
||
trigger: 'blur',
|
||
}],
|
||
referenceImageUrl: [{
|
||
validator: (_, value) => !value || formData.referenceMode !== 'input' || validUrl(String(value).trim())
|
||
? Promise.resolve()
|
||
: Promise.reject(new Error('请输入有效的参考图片地址')),
|
||
trigger: 'blur',
|
||
}],
|
||
prompt: [{ required: true, message: '请输入分析提示词', trigger: 'blur' }],
|
||
}
|
||
|
||
// 引用选项按需加载,上游流程较大时避免每次渲染都遍历画布。
|
||
const loadQuoteOptions = visible => { if (visible) quoteOptions.value = getInput(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 = () => {
|
||
const params = props.data?.properties?.nodeParams || []
|
||
const images = getParam(params, 'imageUrl')
|
||
const reference = getParam(params, 'referenceImageUrl')
|
||
const tuning = getParam(params, 'analysisTuning')?.input || {}
|
||
formData.imageMode = images?.type === 'input' ? 'input' : 'quote'
|
||
formData.imageQuote = Array.isArray(images?.quote) ? [...images.quote] : []
|
||
formData.imageQuoteType = images?.quoteType || 'output'
|
||
formData.imageUrlsText = Array.isArray(images?.input) ? images.input.join('\n') : (images?.input || '')
|
||
formData.referenceMode = reference?.type === 'quote' ? 'quote' : 'input'
|
||
formData.referenceQuote = Array.isArray(reference?.quote) ? [...reference.quote] : []
|
||
formData.referenceQuoteType = reference?.quoteType || 'output'
|
||
formData.referenceImageUrl = reference?.input || ''
|
||
formData.prompt = getParam(params, 'prompt')?.input || legacyPrompt(params)
|
||
const savedMode = String(getParam(params, 'analysisMode')?.input || '').toUpperCase()
|
||
formData.analysisMode = savedMode === 'ACCURATE' ? 'ACCURATE' : 'FAST'
|
||
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)
|
||
advancedSections.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: 'prompt', type: 'input', input: 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 },
|
||
]
|
||
if (hasReference()) {
|
||
// 参考图存在时插入到图片来源和提示词之间,保持执行端按固定顺序读取。
|
||
nodeParams.splice(2, 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>
|