275 lines
12 KiB
Vue
275 lines
12 KiB
Vue
|
|
<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>
|