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

275 lines
12 KiB
Vue
Raw Normal View History

<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>