perf: 模型管理界面重做

This commit is contained in:
2026-03-11 20:33:04 +08:00
parent 219fa566ef
commit 373d7f8201
37 changed files with 4120 additions and 2108 deletions

View File

@@ -1,31 +1,31 @@
<script setup lang="ts">
import type { ModelAbilityItem } from '#/views/ai/model/modelUtils/model-ability';
import { reactive, ref, watch } from 'vue';
import { EasyFlowFormModal } from '@easyflow/common-ui';
import { ElForm, ElFormItem, ElInput, ElMessage, ElTag } from 'element-plus';
import { api } from '#/api/request';
import { $t } from '#/locales';
import type {ModelAbilityItem} from '#/views/ai/model/modelUtils/model-ability';
import {
getDefaultModelAbility,
handleTagClick as handleTagClickUtil,
syncTagSelectedStatus as syncTagSelectedStatusUtil,
} from '#/views/ai/model/modelUtils/model-ability';
import {computed, reactive, ref, watch} from 'vue';
import {EasyFlowFormModal} from '@easyflow/common-ui';
import {ArrowDown, ArrowUp} from '@element-plus/icons-vue';
import {ElForm, ElFormItem, ElIcon, ElInput, ElMessage} from 'element-plus';
import {api} from '#/api/request';
import {$t} from '#/locales';
import {
generateFeaturesFromModelAbility,
resetModelAbility,
} from '#/views/ai/model/modelUtils/model-ability-utils';
interface FormData {
id?: string;
modelType: string;
title: string;
modelName: string;
groupName: string;
providerId: string;
provider: string;
apiKey: string;
endpoint: string;
requestPath: string;
@@ -37,12 +37,6 @@ interface FormData {
supportVideo: boolean;
supportImageB64Only: boolean;
supportToolMessage: boolean;
options: {
chatPath: string;
embedPath: string;
llmEndpoint: string;
rerankPath: string;
};
}
const props = defineProps({
@@ -55,7 +49,6 @@ const props = defineProps({
const emit = defineEmits(['reload']);
const selectedProviderId = ref<string>(props.providerId ?? '');
// 监听 providerId 的变化
watch(
() => props.providerId,
(newVal) => {
@@ -69,15 +62,15 @@ watch(
const formDataRef = ref();
const isAdd = ref(true);
const dialogVisible = ref(false);
const btnLoading = ref(false);
const showAdvanced = ref(false);
// 表单数据
const formData = reactive<FormData>({
modelType: '',
title: '',
modelName: '',
groupName: '',
providerId: '',
provider: '',
apiKey: '',
endpoint: '',
requestPath: '',
@@ -88,87 +81,104 @@ const formData = reactive<FormData>({
supportFree: false,
supportVideo: false,
supportImageB64Only: false,
supportToolMessage: false,
options: {
llmEndpoint: '',
chatPath: '',
embedPath: '',
rerankPath: '',
},
supportToolMessage: true,
});
// 使用抽取的函数获取模型能力配置
const modelAbility = ref<ModelAbilityItem[]>(getDefaultModelAbility());
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));
/**
* 同步标签选中状态与formData中的布尔字段
*/
const syncTagSelectedStatus = () => {
syncTagSelectedStatusUtil(modelAbility.value, formData);
};
/**
* 处理标签点击事件
*/
const handleTagClick = (item: ModelAbilityItem) => {
// handleTagClickUtil(modelAbility.value, item, formData);
handleTagClickUtil(item, formData);
const resetAbilitySelection = () => {
resetModelAbility(modelAbility.value);
syncTagSelectedStatus();
};
// 打开新增弹窗
defineExpose({
openAddDialog(modelType: string) {
isAdd.value = true;
if (formDataRef.value) {
formDataRef.value.resetFields();
}
const handleTagClick = (item: ModelAbilityItem) => {
if (hasSpecialModelType.value) {
return;
}
item.selected = !item.selected;
formData[item.field] = item.selected;
};
// 重置表单数据
Object.assign(formData, {
id: '',
modelType,
title: '',
modelName: '',
groupName: '',
provider: '',
endPoint: '',
providerId: '',
supportThinking: false,
supportTool: false,
supportAudio: false,
supportVideo: false,
supportImage: false,
supportImageB64Only: false,
supportFree: false,
supportToolMessage: true,
options: {
llmEndpoint: '',
chatPath: '',
embedPath: '',
rerankPath: '',
},
});
showMoreFields.value = false;
// 重置标签状态
resetModelAbility(modelAbility.value);
syncTagSelectedStatus();
const handleModelTypeChipClick = (
modelType: Exclude<SelectableModelType, ''>,
) => {
const nextType = selectedModelType.value === modelType ? '' : modelType;
selectedModelType.value = nextType;
if (nextType) {
resetAbilitySelection();
}
};
const isAbilityChipDisabled = () => hasSpecialModelType.value;
const resolveModelType = (): FormData['modelType'] => {
return selectedModelType.value || 'chatModel';
};
const resetFormData = () => {
Object.assign(formData, {
id: '',
modelType: '',
title: '',
modelName: '',
groupName: '',
providerId: '',
apiKey: '',
endpoint: '',
requestPath: '',
supportThinking: false,
supportTool: false,
supportAudio: false,
supportVideo: false,
supportImage: false,
supportImageB64Only: false,
supportFree: false,
supportToolMessage: true,
});
};
defineExpose({
openAddDialog() {
isAdd.value = true;
formDataRef.value?.resetFields();
resetFormData();
showAdvanced.value = false;
selectedModelType.value = '';
resetAbilitySelection();
dialogVisible.value = true;
},
openEditDialog(item: any) {
dialogVisible.value = true;
isAdd.value = false;
// 填充表单数据
resetFormData();
Object.assign(formData, {
id: item.id,
modelType: item.modelType || '',
title: item.title || '',
modelName: item.modelName || '',
groupName: item.groupName || '',
provider: item.provider || '',
endpoint: item.endpoint || '',
requestPath: item.requestPath || '',
apiKey: item.apiKey || '',
supportThinking: item.supportThinking || false,
supportAudio: item.supportAudio || false,
supportImage: item.supportImage || false,
@@ -176,17 +186,21 @@ defineExpose({
supportVideo: item.supportVideo || false,
supportTool: item.supportTool || false,
supportFree: item.supportFree || false,
supportToolMessage: item.supportToolMessage || false,
options: {
llmEndpoint: item.options?.llmEndpoint || '',
chatPath: item.options?.chatPath || '',
embedPath: item.options?.embedPath || '',
rerankPath: item.options?.rerankPath || '',
},
supportToolMessage:
item.supportToolMessage === undefined ? true : item.supportToolMessage,
});
showMoreFields.value = false;
// 同步标签状态
syncTagSelectedStatus();
selectedModelType.value =
item.modelType === 'embeddingModel' || item.modelType === 'rerankModel'
? item.modelType
: '';
showAdvanced.value = Boolean(
formData.apiKey || formData.endpoint || formData.requestPath,
);
if (selectedModelType.value) {
resetAbilitySelection();
} else {
syncTagSelectedStatus();
}
},
});
@@ -195,76 +209,53 @@ const closeDialog = () => {
};
const rules = {
title: [
{
required: true,
message: $t('message.required'),
trigger: 'blur',
},
],
title: [{ required: true, message: $t('message.required'), trigger: 'blur' }],
modelName: [
{
required: true,
message: $t('message.required'),
trigger: 'blur',
},
{ required: true, message: $t('message.required'), trigger: 'blur' },
],
groupName: [
{
required: true,
message: $t('message.required'),
trigger: 'blur',
},
],
provider: [
{
required: true,
message: $t('message.required'),
trigger: 'blur',
},
{ required: true, message: $t('message.required'), trigger: 'blur' },
],
};
const btnLoading = ref(false);
const save = async () => {
btnLoading.value = true;
// 使用工具函数从模型能力生成features
const modelType = resolveModelType();
const features = generateFeaturesFromModelAbility(modelAbility.value);
if (modelType !== 'chatModel') {
for (const key of Object.keys(features) as Array<keyof typeof features>) {
features[key] = false;
}
}
try {
await formDataRef.value.validate();
const submitData = { ...formData, ...features };
const submitData = {
...formData,
...features,
modelType,
providerId: isAdd.value ? selectedProviderId.value : formData.providerId,
};
if (isAdd.value) {
submitData.providerId = selectedProviderId.value;
const res = await api.post('/api/v1/model/save', submitData);
if (res.errorCode === 0) {
ElMessage.success(res.message);
emit('reload');
closeDialog();
} else {
ElMessage.error(res.message || $t('ui.actionMessage.operationFailed'));
}
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 {
const res = await api.post('/api/v1/model/update', submitData);
if (res.errorCode === 0) {
ElMessage.success(res.message);
emit('reload');
closeDialog();
} else {
ElMessage.error(res.message || $t('ui.actionMessage.operationFailed'));
}
ElMessage.error(res.message || $t('ui.actionMessage.operationFailed'));
}
} catch (error) {
console.error('Save model error:', error);
ElMessage.error($t('ui.actionMessage.operationFailed'));
if (!(error as any)?.fields) {
ElMessage.error($t('ui.actionMessage.operationFailed'));
}
} finally {
btnLoading.value = false;
}
};
const showMoreFields = ref(false);
</script>
<template>
@@ -272,91 +263,240 @@ const showMoreFields = ref(false);
v-model:open="dialogVisible"
:centered="true"
:closable="!btnLoading"
:title="isAdd ? $t('button.add') : $t('button.edit')"
:title="isAdd ? '添加模型' : '编辑模型'"
:before-close="closeDialog"
width="482"
width="640"
:confirm-loading="btnLoading"
:confirm-text="$t('button.save')"
:submitting="btnLoading"
@confirm="save"
>
<ElForm
ref="formDataRef"
:model="formData"
status-icon
:rules="rules"
label-position="top"
class="easyflow-modal-form easyflow-modal-form--compact"
>
<ElFormItem prop="title" :label="$t('llm.title')">
<ElInput v-model.trim="formData.title" />
</ElFormItem>
<ElFormItem prop="modelName" :label="$t('llm.llmModel')">
<ElInput v-model.trim="formData.modelName" />
</ElFormItem>
<ElFormItem prop="groupName" :label="$t('llm.groupName')">
<ElInput v-model.trim="formData.groupName" />
</ElFormItem>
<ElFormItem prop="ability" :label="$t('llm.ability')">
<div class="model-ability">
<ElTag
class="model-ability-tag"
v-for="item in modelAbility"
:key="item.value"
:type="item.selected ? item.activeType : item.defaultType"
@click="handleTagClick(item)"
:class="{ 'tag-selected': item.selected }"
>
{{ item.label }}
</ElTag>
<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">
<div class="model-modal__section-head">
<h4>基础信息</h4>
<p>这些字段决定模型在列表里的展示与组织方式</p>
</div>
<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"
/>
</ElFormItem>
<ElFormItem prop="groupName" :label="$t('llm.groupName')">
<ElInput
v-model.trim="formData.groupName"
placeholder="例如:默认组"
/>
</ElFormItem>
</div>
</ElFormItem>
<ElFormItem label=" " v-if="!showMoreFields">
<ElButton @click="showMoreFields = !showMoreFields" type="primary">
{{ showMoreFields ? $t('button.hide') : $t('button.more') }}
</ElButton>
</ElFormItem>
<ElFormItem
prop="apiKey"
:label="$t('llmProvider.apiKey')"
v-show="showMoreFields"
>
<ElInput v-model.trim="formData.apiKey" />
</ElFormItem>
<ElFormItem
prop="endpoint"
:label="$t('llmProvider.endpoint')"
v-show="showMoreFields"
>
<ElInput v-model.trim="formData.endpoint" />
</ElFormItem>
<ElFormItem
prop="requestPath"
:label="$t('llm.requestPath')"
v-show="showMoreFields"
>
<ElInput v-model.trim="formData.requestPath" />
</ElFormItem>
</ElForm>
<div class="model-modal__section">
<div class="model-modal__section-head">
<h4>{{ $t('llm.ability') }}</h4>
<p>可选嵌入或重排模型类型选择后其余能力会自动锁定</p>
</div>
<div class="model-modal__ability">
<button
v-for="item in modelTypeAbilityOptions"
:key="item.value"
type="button"
class="model-modal__ability-chip"
:class="{ 'is-active': selectedModelType === item.value }"
@click="handleModelTypeChipClick(item.value)"
>
{{ item.label }}
</button>
</div>
<div class="model-modal__ability">
<button
v-for="item in modelAbility"
:key="item.value"
type="button"
class="model-modal__ability-chip"
:class="{
'is-active': item.selected,
'is-disabled': isAbilityChipDisabled(),
}"
:disabled="isAbilityChipDisabled()"
@click="handleTagClick(item)"
>
{{ item.label }}
</button>
</div>
</div>
<div class="model-modal__section">
<button
type="button"
class="model-modal__advanced-toggle"
@click="showAdvanced = !showAdvanced"
>
<div>
<h4>高级设置</h4>
<p>仅在需要覆写服务商默认配置时填写</p>
</div>
<ElIcon>
<ArrowUp v-if="showAdvanced" />
<ArrowDown v-else />
</ElIcon>
</button>
<div v-if="showAdvanced" class="model-modal__advanced-grid">
<ElFormItem prop="apiKey" :label="$t('llmProvider.apiKey')">
<ElInput
v-model.trim="formData.apiKey"
type="password"
show-password
placeholder="可选,单独覆写模型密钥"
/>
</ElFormItem>
<ElFormItem prop="endpoint" :label="$t('llmProvider.endpoint')">
<ElInput
v-model.trim="formData.endpoint"
placeholder="可选,单独覆写 endpoint"
/>
</ElFormItem>
<ElFormItem prop="requestPath" :label="$t('llm.requestPath')">
<ElInput
v-model.trim="formData.requestPath"
placeholder="可选,单独覆写 requestPath"
/>
</ElFormItem>
</div>
</div>
</ElForm>
</div>
</EasyFlowFormModal>
</template>
<style scoped>
.model-ability {
.model-modal {
display: flex;
flex-direction: column;
gap: 20px;
}
.model-modal__section {
display: flex;
flex-direction: column;
gap: 14px;
padding: 20px;
background: linear-gradient(
180deg,
hsl(var(--surface-panel) / 98%) 0%,
hsl(var(--surface-contrast-soft) / 94%) 100%
);
border: 1px solid hsl(var(--divider-faint) / 50%);
border-radius: 24px;
}
.model-modal__section-head h4,
.model-modal__advanced-toggle h4 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: hsl(var(--text-strong));
}
.model-modal__section-head p,
.model-modal__advanced-toggle p {
margin: 6px 0 0;
font-size: 13px;
line-height: 1.6;
color: hsl(var(--text-muted));
}
.model-modal__form {
display: flex;
flex-direction: column;
gap: 18px;
}
.model-modal__ability {
display: flex;
flex-wrap: wrap;
gap: 8px;
gap: 10px;
}
.model-modal__ability-chip {
display: inline-flex;
align-items: center;
margin-top: 4px;
}
.model-ability-tag {
justify-content: center;
padding: 8px 14px;
font-size: 13px;
color: hsl(var(--text-muted));
cursor: pointer;
transition: all 0.2s;
background: hsl(var(--surface-panel));
border: 1px solid hsl(var(--divider-faint) / 68%);
border-radius: 999px;
transition:
border-color 0.2s ease,
color 0.2s ease,
background 0.2s ease;
}
.tag-selected {
font-weight: bold;
transform: scale(1.05);
.model-modal__ability-chip:hover:not(:disabled),
.model-modal__ability-chip:focus-visible:not(:disabled) {
color: hsl(var(--text-strong));
border-color: hsl(var(--divider-faint));
}
.model-modal__ability-chip.is-active {
color: hsl(var(--text-strong));
background: hsl(var(--primary) / 8%);
border-color: hsl(var(--primary) / 38%);
}
.model-modal__ability-chip.is-disabled {
cursor: not-allowed;
opacity: 0.56;
}
.model-modal__advanced-toggle {
display: flex;
gap: 16px;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 0;
text-align: left;
background: transparent;
border: none;
}
.model-modal__advanced-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.model-modal__advanced-grid :deep(.el-form-item:last-child) {
grid-column: 1 / -1;
}
@media (max-width: 640px) {
.model-modal__advanced-grid {
grid-template-columns: minmax(0, 1fr);
}
.model-modal__advanced-grid :deep(.el-form-item:last-child) {
grid-column: auto;
}
}
</style>