feat: 新增统一模型网关与模型管理工作区

- 新增 OpenAI 兼容统一模型调用链路、模型发布配置与批量发布能力

- 重构模型管理页面入口与统一网关工作区,更新服务商 logo 资源与模型 ID 文案

- 收口全新库初始化脚本,仅保留服务商种子并整理统一网关 migration
This commit is contained in:
2026-03-26 20:48:18 +08:00
parent b777cb3641
commit aaf4c61ff8
80 changed files with 4786 additions and 362 deletions

View File

@@ -1,19 +1,19 @@
<script setup lang="ts">
import type {ModelAbilityItem} from '#/views/ai/model/modelUtils/model-ability';
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 { ElForm, ElFormItem, ElInput, ElMessage } from 'element-plus';
import { api } from '#/api/request';
import { $t } from '#/locales';
import {
getDefaultModelAbility,
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,
@@ -26,6 +26,8 @@ interface FormData {
modelName: string;
groupName: string;
providerId: string;
invokeCode: string;
publishEnabled: boolean;
apiKey: string;
endpoint: string;
requestPath: string;
@@ -63,7 +65,6 @@ const formDataRef = ref();
const isAdd = ref(true);
const dialogVisible = ref(false);
const btnLoading = ref(false);
const showAdvanced = ref(false);
const formData = reactive<FormData>({
modelType: '',
@@ -71,6 +72,8 @@ const formData = reactive<FormData>({
modelName: '',
groupName: '',
providerId: '',
invokeCode: '',
publishEnabled: false,
apiKey: '',
endpoint: '',
requestPath: '',
@@ -99,6 +102,17 @@ const modelTypeAbilityOptions = [
},
] 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',
video: 'mdi:video-outline',
image: 'mdi:image-outline',
audio: 'mdi:microphone-outline',
imageB64: 'mdi:file-image-outline',
toolMessage: 'mdi:hammer',
};
const syncTagSelectedStatus = () => {
syncTagSelectedStatusUtil(modelAbility.value, formData);
@@ -128,11 +142,20 @@ const handleModelTypeChipClick = (
};
const isAbilityChipDisabled = () => hasSpecialModelType.value;
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: '',
@@ -141,6 +164,8 @@ const resetFormData = () => {
modelName: '',
groupName: '',
providerId: '',
invokeCode: '',
publishEnabled: false,
apiKey: '',
endpoint: '',
requestPath: '',
@@ -156,13 +181,16 @@ const resetFormData = () => {
};
defineExpose({
openAddDialog() {
openAddDialog(modelType?: string) {
isAdd.value = true;
formDataRef.value?.resetFields();
resetFormData();
showAdvanced.value = false;
selectedModelType.value = '';
resetAbilitySelection();
selectedModelType.value = normalizeSelectableModelType(modelType);
if (selectedModelType.value) {
resetAbilitySelection();
} else {
syncTagSelectedStatus();
}
dialogVisible.value = true;
},
@@ -176,6 +204,9 @@ defineExpose({
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 || '',
@@ -189,13 +220,7 @@ defineExpose({
supportToolMessage:
item.supportToolMessage === undefined ? true : item.supportToolMessage,
});
selectedModelType.value =
item.modelType === 'embeddingModel' || item.modelType === 'rerankModel'
? item.modelType
: '';
showAdvanced.value = Boolean(
formData.apiKey || formData.endpoint || formData.requestPath,
);
selectedModelType.value = normalizeSelectableModelType(item.modelType);
if (selectedModelType.value) {
resetAbilitySelection();
} else {
@@ -281,11 +306,6 @@ const save = async () => {
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"
@@ -304,81 +324,56 @@ const save = async () => {
placeholder="例如:默认组"
/>
</ElFormItem>
</div>
<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"
<ElFormItem
:label="$t('llm.ability')"
class="model-modal__ability-item"
>
<div>
<h4>高级设置</h4>
<p>仅在需要覆写服务商默认配置时填写</p>
<div class="model-modal__ability-panel">
<div class="model-modal__ability-toolbar">
<button
v-for="item in modelTypeAbilityOptions"
:key="item.value"
type="button"
class="model-modal__ability-chip"
:class="[
`is-tone-${item.value}`,
{ 'is-active': 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 modelAbility"
:key="item.value"
type="button"
class="model-modal__ability-chip"
:class="{
'is-active': item.selected,
'is-disabled': isAbilityChipDisabled(),
[`is-tone-${item.value}`]: true,
}"
:disabled="isAbilityChipDisabled()"
@click="handleTagClick(item)"
>
<IconifyIcon
:icon="getAbilityIcon(item.value)"
class="model-modal__ability-icon"
/>
{{ item.label }}
</button>
</div>
</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>
</ElFormItem>
</div>
</ElForm>
</div>
@@ -389,114 +384,136 @@ const save = async () => {
.model-modal {
display: flex;
flex-direction: column;
gap: 20px;
gap: 16px;
}
.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));
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: 18px;
gap: 12px;
}
.model-modal__ability {
.model-modal__ability-item {
margin-top: 4px;
}
.model-modal__ability-panel {
overflow: hidden;
padding: 2px;
border-radius: 18px;
}
.model-modal__ability-toolbar {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
}
.model-modal__ability-separator {
width: 1px;
align-self: stretch;
min-height: 28px;
background: hsl(var(--divider-faint) / 62%);
border-radius: 999px;
}
.model-modal__ability-chip {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 8px 14px;
gap: 8px;
min-height: 40px;
padding: 9px 18px;
font-size: 13px;
font-weight: 600;
line-height: 1;
color: hsl(var(--text-muted));
cursor: pointer;
background: hsl(var(--surface-panel));
border: 1px solid hsl(var(--divider-faint) / 68%);
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;
background 0.2s ease,
box-shadow 0.2s ease;
}
.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));
transform: translateY(-1px);
box-shadow: 0 10px 18px -14px hsl(var(--foreground) / 28%);
}
.model-modal__ability-chip.is-active {
color: hsl(var(--text-strong));
background: hsl(var(--primary) / 8%);
border-color: hsl(var(--primary) / 38%);
border-color: hsl(var(--primary) / 18%);
background: hsl(var(--primary) / 10%);
box-shadow: inset 0 0 0 1px hsl(var(--primary) / 14%);
}
.model-modal__ability-chip.is-disabled {
cursor: not-allowed;
opacity: 0.56;
box-shadow: none;
transform: none;
}
.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__ability-icon {
font-size: 15px;
opacity: 0.88;
}
.model-modal__advanced-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
.model-modal__ability-chip.is-active.is-tone-embeddingModel,
.model-modal__ability-chip.is-active.is-tone-thinking,
.model-modal__ability-chip.is-active.is-tone-toolMessage {
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__advanced-grid :deep(.el-form-item:last-child) {
grid-column: 1 / -1;
.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,
.model-modal__ability-chip.is-active.is-tone-imageB64 {
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__ability-chip.is-active.is-tone-audio,
.model-modal__ability-chip.is-active.is-tone-video,
.model-modal__ability-chip.is-active.is-tone-free {
color: hsl(var(--danger));
background: hsl(var(--danger) / 10%);
border-color: hsl(var(--danger) / 16%);
box-shadow: inset 0 0 0 1px hsl(var(--danger) / 12%);
}
@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;
.model-modal__ability-separator {
display: none;
}
}
</style>