CMVR-IOT-UI/src/views/flow/components/params/ImageAnalysisParams.vue
lixiaolong 71feae4bbd feat(flow): 添加通用图片分析功能并优化媒体分析节点
- 新增通用图片分析节点类型,支持按提示词分析图片并返回指定内容
- 添加详细的图片分析参数配置界面,包括图片源选择、提示词输入等功能
- 实现图片分析节点的输出参数定义和处理逻辑
- 更新视频分析节点,增加详细输出选项和结果字段
- 重构错误状态恢复功能,更名为运行状态恢复并更新相关UI
- 添加图片分析结果的日志展示支持
- 优化流程资源配置,增加图片分析相关的常量定义
- 更新单节点执行参数构建逻辑,适配新的图片分析功能
2026-08-19 15:00:17 +08:00

275 lines
12 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.

<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([])
const sourceOptions = [
{ label: '引用节点输出', value: 'quote' },
{ label: '输入图片地址', value: 'input' },
]
const modeOptions = [
{ label: '快速', value: 'FAST' },
{ label: '精准', value: 'ACCURATE' },
]
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: '模型返回的原始文本' },
]
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())
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)
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')
}
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 })
const buildMediaParam = (name, mode, input, quote, quoteType, required = true) => ({
name,
type: mode,
input: mode === 'input' ? input : '',
quote: mode === 'quote' ? [...quote] : '',
quoteType,
required,
disabled: true,
})
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)),
}
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>