Files
EasyFlow/easyflow-ui-admin/app/src/views/ai/model/AddModelModal.vue
陈子默 567fd12706 feat: 完善模型能力识别与验证
- 自动识别模型类型、视觉、推理和工具能力并保留手动覆盖

- 使用 AgentScope 工具与视觉探测并统一管理端配置反馈
2026-07-27 19:40:23 +08:00

823 lines
22 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.
<script setup lang="ts">
import type { ModelAbilityItem } from '#/views/ai/model/modelUtils/model-ability';
import { computed, reactive, ref, watch } from 'vue';
import { EasyFlowFormModal } from '@easyflow/common-ui';
import { IconifyIcon } from '@easyflow/icons';
import {
ArrowDown,
ArrowUp,
InfoFilled,
Loading,
} from '@element-plus/icons-vue';
import {
ElForm,
ElFormItem,
ElIcon,
ElInput,
ElMessage,
ElOption,
ElSelect,
ElTooltip,
} from 'element-plus';
import { resolveModelCapabilities } from '#/api/ai/llm';
import { api } from '#/api/request';
import { $t } from '#/locales';
import {
getDefaultModelAbility,
handleTagClick as handleTagClickUtil,
syncTagSelectedStatus as syncTagSelectedStatusUtil,
} from '#/views/ai/model/modelUtils/model-ability';
import { resetModelAbility } from '#/views/ai/model/modelUtils/model-ability-utils';
type AgentHttpVersionPolicy = 'AUTO' | 'HTTP_1_1' | 'HTTP_2_PREFERRED';
type AgentSystemContentFormat = 'STRING' | 'TEXT_PARTS';
interface ModelOptions {
agentHttpVersionPolicy: AgentHttpVersionPolicy;
agentSystemContentFormat: AgentSystemContentFormat;
[key: string]: unknown;
}
interface FormData {
id?: string;
modelType: string;
title: string;
modelName: string;
groupName: string;
providerId: string;
invokeCode: string;
publishEnabled: boolean;
apiKey: string;
endpoint: string;
requestPath: string;
supportThinking: boolean | null;
supportTool: boolean | null;
supportImage: boolean | null;
supportAudio: boolean;
supportFree: boolean;
supportVideo: boolean;
supportImageB64Only: boolean;
supportToolMessage: boolean | null;
options: ModelOptions;
}
const props = defineProps({
providerId: {
type: String,
default: '',
},
});
const emit = defineEmits(['reload']);
const selectedProviderId = ref<string>(props.providerId ?? '');
watch(
() => props.providerId,
(newVal) => {
if (newVal) {
selectedProviderId.value = newVal;
}
},
{ immediate: true },
);
const formDataRef = ref();
const isAdd = ref(true);
const dialogVisible = ref(false);
const btnLoading = ref(false);
const showAdvanced = ref(false);
const capabilityLoading = ref(false);
const autoDetectedModelType = ref(false);
let capabilityRequestSequence = 0;
let manuallyEditedModelName = '';
const formData = reactive<FormData>({
modelType: '',
title: '',
modelName: '',
groupName: '',
providerId: '',
invokeCode: '',
publishEnabled: false,
apiKey: '',
endpoint: '',
requestPath: '',
supportThinking: null,
supportTool: null,
supportImage: null,
supportAudio: false,
supportFree: false,
supportVideo: false,
supportImageB64Only: false,
supportToolMessage: null,
options: {
agentHttpVersionPolicy: 'AUTO',
agentSystemContentFormat: 'STRING',
},
});
const agentHttpVersionOptions = [
{ label: '自动', value: 'AUTO' },
{ label: 'HTTP/1.1 兼容', value: 'HTTP_1_1' },
{ label: 'HTTP/2 优先', value: 'HTTP_2_PREFERRED' },
] as const;
const agentSystemContentFormatOptions = [
{ label: '字符串(默认)', value: 'STRING' },
{ label: '内容块数组', value: 'TEXT_PARTS' },
] as const;
const normalizeAgentHttpVersionPolicy = (
value?: unknown,
): AgentHttpVersionPolicy => {
if (value === 'HTTP_1_1' || value === 'HTTP_2_PREFERRED') {
return value;
}
return 'AUTO';
};
const normalizeAgentSystemContentFormat = (
value?: unknown,
): AgentSystemContentFormat => {
return value === 'TEXT_PARTS' ? 'TEXT_PARTS' : 'STRING';
};
const normalizeModelOptions = (options?: unknown): ModelOptions => {
const source =
options && typeof options === 'object' && !Array.isArray(options)
? (options as Record<string, unknown>)
: {};
return {
...source,
agentHttpVersionPolicy: normalizeAgentHttpVersionPolicy(
source.agentHttpVersionPolicy,
),
agentSystemContentFormat: normalizeAgentSystemContentFormat(
source.agentSystemContentFormat,
),
};
};
const modelAbility = ref<ModelAbilityItem[]>(getDefaultModelAbility());
const visibleModelAbility = computed(() => modelAbility.value);
type SelectableModelType = '' | 'embeddingModel' | 'rerankModel';
const selectedModelType = ref<SelectableModelType>('');
const modelTypeAbilityOptions = [
{
label: $t('llmProvider.embeddingModel'),
value: 'embeddingModel',
},
{
label: $t('llmProvider.rerankModel'),
value: 'rerankModel',
},
] as const;
const hasSpecialModelType = computed(() => Boolean(selectedModelType.value));
const abilityIconMap: Record<string, string> = {
embeddingModel: 'svg:knowledge',
rerankModel: 'svg:data-center',
thinking: 'svg:llm',
tool: 'svg:wrench',
image: 'mdi:image-outline',
};
const syncTagSelectedStatus = () => {
syncTagSelectedStatusUtil(modelAbility.value, formData);
};
const resetAbilitySelection = () => {
resetModelAbility(modelAbility.value);
formData.supportThinking = false;
formData.supportTool = false;
formData.supportImage = false;
formData.supportToolMessage = false;
syncTagSelectedStatus();
};
const markCapabilitiesAsManuallyEdited = () => {
manuallyEditedModelName = formData.modelName.trim();
capabilityRequestSequence += 1;
capabilityLoading.value = false;
};
const handleAbilityChipClick = (item: ModelAbilityItem) => {
if (hasSpecialModelType.value) {
return;
}
markCapabilitiesAsManuallyEdited();
handleTagClickUtil(item, formData);
if (item.field === 'supportTool') {
formData.supportToolMessage = formData.supportTool;
}
};
const handleModelNameInput = () => {
manuallyEditedModelName = '';
capabilityRequestSequence += 1;
capabilityLoading.value = false;
autoDetectedModelType.value = false;
selectedModelType.value = '';
formData.supportThinking = null;
formData.supportTool = null;
formData.supportImage = null;
formData.supportToolMessage = null;
syncTagSelectedStatus();
};
const handleModelTypeChipClick = (
modelType: Exclude<SelectableModelType, ''>,
) => {
const nextType = selectedModelType.value === modelType ? '' : modelType;
markCapabilitiesAsManuallyEdited();
autoDetectedModelType.value = false;
selectedModelType.value = nextType;
if (nextType) {
resetAbilitySelection();
} else {
formData.supportThinking = null;
formData.supportTool = null;
formData.supportImage = null;
formData.supportToolMessage = null;
syncTagSelectedStatus();
}
};
const getAbilityIcon = (value: string) => abilityIconMap[value] || 'svg:llm';
const resolveModelType = (): FormData['modelType'] => {
return selectedModelType.value || 'chatModel';
};
const normalizeSelectableModelType = (
modelType?: string,
): SelectableModelType => {
return modelType === 'embeddingModel' || modelType === 'rerankModel'
? modelType
: '';
};
const resetFormData = () => {
Object.assign(formData, {
id: '',
modelType: '',
title: '',
modelName: '',
groupName: '',
providerId: '',
invokeCode: '',
publishEnabled: false,
apiKey: '',
endpoint: '',
requestPath: '',
supportThinking: null,
supportTool: null,
supportAudio: false,
supportVideo: false,
supportImage: null,
supportImageB64Only: false,
supportFree: false,
supportToolMessage: null,
options: normalizeModelOptions(),
});
};
defineExpose({
openAddDialog(modelType?: string) {
isAdd.value = true;
formDataRef.value?.resetFields();
resetFormData();
showAdvanced.value = false;
autoDetectedModelType.value = false;
manuallyEditedModelName = '';
selectedModelType.value = normalizeSelectableModelType(modelType);
if (selectedModelType.value) {
resetAbilitySelection();
} else {
syncTagSelectedStatus();
}
dialogVisible.value = true;
},
openEditDialog(item: any) {
dialogVisible.value = true;
isAdd.value = false;
resetFormData();
showAdvanced.value = false;
autoDetectedModelType.value = false;
manuallyEditedModelName = '';
Object.assign(formData, {
id: item.id,
modelType: item.modelType || '',
title: item.title || '',
modelName: item.modelName || '',
groupName: item.groupName || '',
providerId: item.providerId || '',
invokeCode: item.invokeCode || '',
publishEnabled: item.publishEnabled || false,
endpoint: item.endpoint || '',
requestPath: item.requestPath || '',
apiKey: item.apiKey || '',
supportThinking: item.supportThinking ?? null,
supportAudio: item.supportAudio || false,
supportImage: item.supportImage ?? null,
supportImageB64Only: item.supportImageB64Only || false,
supportVideo: item.supportVideo || false,
supportTool: item.supportTool ?? null,
supportFree: item.supportFree || false,
supportToolMessage: item.supportToolMessage ?? null,
options: normalizeModelOptions(item.options),
});
selectedModelType.value = normalizeSelectableModelType(item.modelType);
if (selectedModelType.value) {
resetAbilitySelection();
} else {
syncTagSelectedStatus();
}
},
});
const closeDialog = () => {
capabilityRequestSequence += 1;
dialogVisible.value = false;
};
const detectModelCapabilities = async () => {
const modelName = formData.modelName.trim();
if (!modelName || manuallyEditedModelName === modelName) {
return;
}
const requestSequence = ++capabilityRequestSequence;
capabilityLoading.value = true;
try {
const providerId = isAdd.value
? selectedProviderId.value
: formData.providerId;
const res = await resolveModelCapabilities({ modelName, providerId });
if (
requestSequence !== capabilityRequestSequence ||
res.errorCode !== 0 ||
!res.data
) {
return;
}
const capability = res.data;
manuallyEditedModelName = '';
if (!capability.detected) {
if (autoDetectedModelType.value) {
selectedModelType.value = '';
}
autoDetectedModelType.value = false;
formData.supportThinking = null;
formData.supportTool = null;
formData.supportImage = null;
formData.supportToolMessage = null;
syncTagSelectedStatus();
return;
}
autoDetectedModelType.value = true;
formData.modelType = capability.modelType;
selectedModelType.value = normalizeSelectableModelType(
capability.modelType,
);
formData.supportThinking = capability.supportThinking ?? null;
formData.supportTool = capability.supportTool ?? null;
formData.supportImage = capability.supportImage ?? null;
formData.supportToolMessage = capability.supportTool ?? null;
syncTagSelectedStatus();
} catch {
// 自动识别失败不阻塞表单,保存时后端仍会再次解析能力。
} finally {
if (requestSequence === capabilityRequestSequence) {
capabilityLoading.value = false;
}
}
};
const rules = {
title: [{ required: true, message: $t('message.required'), trigger: 'blur' }],
modelName: [
{ required: true, message: $t('message.required'), trigger: 'blur' },
],
groupName: [
{ required: true, message: $t('message.required'), trigger: 'blur' },
],
};
const save = async () => {
btnLoading.value = true;
const modelType = resolveModelType();
try {
await formDataRef.value.validate();
const submitData = {
...formData,
modelType,
providerId: isAdd.value ? selectedProviderId.value : formData.providerId,
};
const url = isAdd.value ? '/api/v1/model/save' : '/api/v1/model/update';
const res = await api.post(url, submitData);
if (res.errorCode === 0) {
ElMessage.success(res.message);
emit('reload');
closeDialog();
} else {
ElMessage.error(res.message || $t('ui.actionMessage.operationFailed'));
}
} catch (error) {
if (!(error as any)?.fields) {
ElMessage.error($t('ui.actionMessage.operationFailed'));
}
} finally {
btnLoading.value = false;
}
};
</script>
<template>
<EasyFlowFormModal
v-model:open="dialogVisible"
:centered="true"
:closable="!btnLoading"
:title="isAdd ? '添加模型' : '编辑模型'"
:before-close="closeDialog"
width="640"
:confirm-loading="btnLoading"
:confirm-text="$t('button.save')"
:submitting="btnLoading"
@confirm="save"
>
<div class="model-modal">
<ElForm
ref="formDataRef"
:model="formData"
status-icon
:rules="rules"
label-position="top"
class="model-modal__form"
>
<div class="model-modal__section">
<ElFormItem prop="title" :label="$t('llm.title')">
<ElInput
v-model.trim="formData.title"
placeholder="例如:生产主模型"
/>
</ElFormItem>
<ElFormItem prop="modelName" :label="$t('llm.llmModel')">
<ElInput
v-model.trim="formData.modelName"
placeholder="例如gpt-4.1 / glm-4.5 / qwen3:8b"
@blur="detectModelCapabilities"
@input="handleModelNameInput"
/>
</ElFormItem>
<ElFormItem prop="groupName" :label="$t('llm.groupName')">
<ElInput
v-model.trim="formData.groupName"
placeholder="例如:默认组"
/>
</ElFormItem>
<ElFormItem class="model-modal__ability-item">
<template #label>
<span class="model-modal__ability-label">
{{ $t('llm.ability') }}
<ElTooltip
:content="$t('llm.abilityChangeTip')"
effect="light"
placement="top"
>
<button
type="button"
class="model-modal__ability-info"
:aria-label="$t('llm.abilityChangeTip')"
>
<ElIcon><InfoFilled /></ElIcon>
</button>
</ElTooltip>
</span>
</template>
<div
class="model-modal__ability-panel"
:aria-busy="capabilityLoading"
>
<div class="model-modal__ability-toolbar">
<button
v-for="item in modelTypeAbilityOptions"
:key="item.value"
type="button"
class="model-modal__ability-chip is-interactive"
:class="[
`is-tone-${item.value}`,
{ 'is-active': selectedModelType === item.value },
]"
:aria-pressed="selectedModelType === item.value"
@click="handleModelTypeChipClick(item.value)"
>
<IconifyIcon
:icon="getAbilityIcon(item.value)"
class="model-modal__ability-icon"
/>
{{ item.label }}
</button>
<span
class="model-modal__ability-separator"
aria-hidden="true"
></span>
<button
v-for="item in visibleModelAbility"
:key="item.value"
type="button"
class="model-modal__ability-chip is-interactive"
:class="{
'is-active': item.selected,
'is-disabled': hasSpecialModelType,
[`is-tone-${item.value}`]: true,
}"
:aria-pressed="item.selected"
:disabled="hasSpecialModelType"
@click="handleAbilityChipClick(item)"
>
<IconifyIcon
:icon="getAbilityIcon(item.value)"
class="model-modal__ability-icon"
/>
{{ item.label }}
</button>
<ElIcon
v-if="capabilityLoading"
class="model-modal__ability-loading is-loading"
aria-label="正在识别模型能力"
>
<Loading />
</ElIcon>
</div>
</div>
</ElFormItem>
<div v-if="!hasSpecialModelType" class="model-modal__advanced">
<button
type="button"
class="model-modal__advanced-toggle"
:aria-expanded="showAdvanced"
@click="showAdvanced = !showAdvanced"
>
<span>高级设置</span>
<ElIcon>
<ArrowUp v-if="showAdvanced" />
<ArrowDown v-else />
</ElIcon>
</button>
<div v-if="showAdvanced" class="model-modal__advanced-body">
<ElFormItem label="Agent HTTP 传输">
<ElSelect
v-model="formData.options.agentHttpVersionPolicy"
aria-label="Agent HTTP 传输"
>
<ElOption
v-for="item in agentHttpVersionOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</ElSelect>
</ElFormItem>
<ElFormItem label="System 消息格式">
<ElSelect
v-model="formData.options.agentSystemContentFormat"
aria-label="System 消息格式"
>
<ElOption
v-for="item in agentSystemContentFormatOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</ElSelect>
</ElFormItem>
</div>
</div>
</div>
</ElForm>
</div>
</EasyFlowFormModal>
</template>
<style scoped>
.model-modal {
display: flex;
flex-direction: column;
gap: 16px;
}
.model-modal__section {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px 18px;
background: hsl(var(--surface-panel) / 96%);
border: 1px solid hsl(var(--divider-faint) / 42%);
border-radius: 20px;
}
.model-modal__form {
display: flex;
flex-direction: column;
gap: 12px;
}
.model-modal__ability-item {
margin-top: 4px;
}
.model-modal__ability-label {
display: inline-flex;
gap: var(--space-1);
align-items: center;
}
.model-modal__ability-info {
display: inline-flex;
padding: 0;
color: hsl(var(--text-muted));
cursor: help;
background: transparent;
border: 0;
border-radius: var(--radius-control);
transition: color var(--motion-duration-base) var(--motion-ease-standard);
}
.model-modal__ability-info:hover,
.model-modal__ability-info:focus-visible {
color: hsl(var(--primary));
}
.model-modal__ability-info:focus-visible {
outline: 2px solid hsl(var(--primary) / 24%);
outline-offset: 2px;
}
.model-modal__ability-panel {
padding: 2px;
overflow: hidden;
border-radius: 18px;
}
.model-modal__ability-toolbar {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
}
.model-modal__ability-separator {
align-self: stretch;
width: 1px;
min-height: 28px;
background: hsl(var(--divider-faint) / 62%);
border-radius: 999px;
}
.model-modal__ability-chip {
display: inline-flex;
gap: 8px;
align-items: center;
justify-content: center;
min-height: 40px;
padding: 9px 18px;
font-size: 13px;
font-weight: 600;
line-height: 1;
color: hsl(var(--text-muted));
cursor: default;
background: hsl(var(--surface-contrast-soft) / 86%);
border: 1px solid transparent;
border-radius: 999px;
transition:
transform 0.2s ease,
border-color 0.2s ease,
color 0.2s ease,
background 0.2s ease,
box-shadow 0.2s ease;
}
.model-modal__ability-chip.is-interactive {
cursor: pointer;
}
.model-modal__ability-chip.is-interactive:hover,
.model-modal__ability-chip.is-interactive:focus-visible {
color: hsl(var(--text-strong));
box-shadow: 0 10px 18px -14px hsl(var(--foreground) / 28%);
transform: translateY(-1px);
}
.model-modal__ability-chip.is-active {
color: hsl(var(--text-strong));
background: hsl(var(--primary) / 10%);
border-color: hsl(var(--primary) / 18%);
box-shadow: inset 0 0 0 1px hsl(var(--primary) / 14%);
}
.model-modal__ability-chip.is-disabled {
cursor: default;
box-shadow: none;
opacity: 0.56;
transform: none;
}
.model-modal__ability-icon {
font-size: 15px;
opacity: 0.88;
}
.model-modal__ability-loading {
margin-inline-start: var(--space-1);
color: hsl(var(--text-muted));
}
.model-modal__ability-chip.is-active.is-tone-embeddingModel,
.model-modal__ability-chip.is-active.is-tone-thinking {
color: hsl(var(--primary));
background: hsl(var(--primary) / 10%);
border-color: hsl(var(--primary) / 18%);
box-shadow: inset 0 0 0 1px hsl(var(--primary) / 14%);
}
.model-modal__ability-chip.is-active.is-tone-rerankModel,
.model-modal__ability-chip.is-active.is-tone-tool {
color: hsl(var(--warning));
background: hsl(var(--warning) / 12%);
border-color: hsl(var(--warning) / 18%);
box-shadow: inset 0 0 0 1px hsl(var(--warning) / 14%);
}
.model-modal__ability-chip.is-active.is-tone-image {
color: hsl(var(--success));
background: hsl(var(--success) / 12%);
border-color: hsl(var(--success) / 18%);
box-shadow: inset 0 0 0 1px hsl(var(--success) / 14%);
}
.model-modal__advanced {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.model-modal__advanced-toggle {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: var(--space-2) var(--space-3);
font-size: 13px;
color: hsl(var(--text-muted));
cursor: pointer;
background: hsl(var(--surface-contrast-soft) / 68%);
border: 1px solid hsl(var(--divider-faint) / 56%);
border-radius: var(--radius-control);
transition:
color var(--motion-duration-base) var(--motion-ease-standard),
border-color var(--motion-duration-base) var(--motion-ease-standard),
background var(--motion-duration-base) var(--motion-ease-standard);
}
.model-modal__advanced-toggle:hover,
.model-modal__advanced-toggle:focus-visible {
color: hsl(var(--text-strong));
background: hsl(var(--surface-contrast-soft));
border-color: hsl(var(--divider-faint));
}
.model-modal__advanced-body :deep(.el-form-item) {
margin-bottom: 0;
}
.model-modal__advanced-body {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.model-modal__advanced-body :deep(.el-select) {
width: 100%;
}
@media (max-width: 640px) {
.model-modal__ability-separator {
display: none;
}
}
</style>