feat: 增加服务商远端模型发现与一键添加

- 支持 OpenAI 兼容、Ollama 和阿里百炼模型目录适配

- 使用静态模型库识别能力并过滤未接入的生成模型

- 增加扁平模型列表、搜索筛选和幂等添加
This commit is contained in:
2026-07-21 19:01:33 +08:00
parent 9436cc5397
commit 53fb63802b
35 changed files with 15397 additions and 3 deletions

View File

@@ -3,7 +3,14 @@ import { computed, onMounted, ref } from 'vue';
import { $t } from '@easyflow/locales';
import { Delete, Edit, Plus, Select, Setting } from '@element-plus/icons-vue';
import {
Delete,
Edit,
Plus,
Refresh,
Select,
Setting,
} from '@element-plus/icons-vue';
import {
ElButton,
ElEmpty,
@@ -13,6 +20,7 @@ import {
ElMessage,
ElMessageBox,
ElTag,
ElTooltip,
} from 'element-plus';
import { getLlmProviderList } from '#/api/ai/llm.js';
@@ -31,6 +39,7 @@ import {
} from '#/views/ai/model/modelUtils/providerDraft';
import ModelVerifyConfig from '#/views/ai/model/ModelVerifyConfig.vue';
import ModelViewItemOperation from '#/views/ai/model/ModelViewItemOperation.vue';
import RemoteModelDialog from '#/views/ai/model/RemoteModelDialog.vue';
import UnifiedGatewayWorkspace from '#/views/ai/model/UnifiedGatewayWorkspace.vue';
type ModelWorkspaceView = 'active' | 'gateway' | 'provider';
@@ -61,6 +70,7 @@ const llmVerifyConfigRef = ref();
const addLlmRef = ref();
const activeWorkspaceRef = ref();
const unifiedGatewayWorkspaceRef = ref();
const remoteModelDialogRef = ref();
const selectedProvider = computed(() =>
providers.value.find((item) => item.id === selectedProviderId.value),
@@ -119,6 +129,19 @@ const isProviderDirty = computed(() =>
isProviderDraftDirty(selectedProvider.value, providerDraft.value),
);
const remoteModelsDisabledReason = computed(() => {
if (!selectedProvider.value) {
return '请先选择模型服务商';
}
if (isProviderDirty.value) {
return '请先保存服务商配置';
}
if (!selectedProvider.value.endpoint?.trim()) {
return '请先配置并保存 API 地址';
}
return '';
});
const currentProviderMetrics = computed(() =>
getProviderConfigMetrics(
{
@@ -405,6 +428,24 @@ const handleAddLlm = (modelType = activeModelType.value) => {
addLlmRef.value.openAddDialog(targetModelType);
};
const openRemoteModelDialog = () => {
if (remoteModelsDisabledReason.value) {
return;
}
remoteModelDialogRef.value?.openDialog?.(
selectedProviderId.value,
selectedProvider.value?.providerName || '',
);
};
const handleRemoteManualAdd = () => {
handleAddLlm(actionModelType.value);
};
const handleRemoteModelImported = async () => {
await loadProviderDetail(selectedProviderId.value, { keepDraft: true });
};
const handleDeleteLlm = (id: string) => {
ElMessageBox.confirm($t('message.deleteAlert'), $t('message.noticeTitle'), {
confirmButtonText: $t('message.ok'),
@@ -722,6 +763,21 @@ onMounted(() => {
<p>按模型能力分组管理已配置模型</p>
</div>
<div class="provider-card__head-actions">
<ElTooltip
:disabled="!remoteModelsDisabledReason"
:content="remoteModelsDisabledReason"
placement="top"
>
<span>
<ElButton
:icon="Refresh"
:disabled="Boolean(remoteModelsDisabledReason)"
@click="openRemoteModelDialog"
>
获取模型列表
</ElButton>
</span>
</ElTooltip>
<ElButton
type="primary"
:icon="Plus"
@@ -825,6 +881,11 @@ onMounted(() => {
@reload="handleModelDataReload"
/>
<ModelVerifyConfig ref="llmVerifyConfigRef" />
<RemoteModelDialog
ref="remoteModelDialogRef"
@imported="handleRemoteModelImported"
@manual-add="handleRemoteManualAdd"
/>
</ListPageShell>
</template>

View File

@@ -0,0 +1,192 @@
import { flushPromises, mount } from '@vue/test-utils';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import RemoteModelDialog from './RemoteModelDialog.vue';
const apiMocks = vi.hoisted(() => ({
getRemoteModels: vi.fn(),
importRemoteModel: vi.fn(),
}));
vi.mock('#/api/ai/llm', () => apiMocks);
vi.mock('@easyflow/common-ui', async () => {
const { defineComponent, h } = await import('vue');
return {
EasyFlowFormModal: defineComponent({
props: {
open: Boolean,
title: { default: '', type: String },
},
emits: ['update:open'],
setup(props, { slots }) {
return () =>
props.open
? h('section', [h('h2', props.title), slots.default?.()])
: null;
},
}),
};
});
const mountDialog = () =>
mount(RemoteModelDialog, {
global: {
stubs: {
Transition: false,
},
},
});
describe('remote model dialog', () => {
beforeEach(() => {
vi.clearAllMocks();
apiMocks.getRemoteModels.mockResolvedValue({
data: {
models: [
{
addable: true,
added: false,
capabilitySource: 'CATALOG',
displayName: 'BGE-M3',
family: 'bge',
modelId: 'BAAI/bge-m3',
modelType: 'embeddingModel',
supportImage: false,
supportThinking: false,
supportTool: false,
},
],
providerId: '100',
truncated: false,
},
errorCode: 0,
});
apiMocks.importRemoteModel.mockResolvedValue({
data: { localModelId: '200', status: 'CREATED' },
errorCode: 0,
});
});
it('loads remote models and keeps the dialog open after one-click import', async () => {
const wrapper = mountDialog();
wrapper.vm.openDialog('100', '测试服务商');
await flushPromises();
expect(apiMocks.getRemoteModels).toHaveBeenCalledWith('100');
expect(wrapper.text()).toContain('BAAI/bge-m3');
expect(wrapper.text()).not.toContain('BGE-M3');
await wrapper.get('button[aria-label="添加 BAAI/bge-m3"]').trigger('click');
await flushPromises();
expect(apiMocks.importRemoteModel).toHaveBeenCalledWith(
'100',
'BAAI/bge-m3',
);
expect(wrapper.text()).toContain('已添加');
expect(wrapper.emitted('imported')).toHaveLength(1);
expect(wrapper.get('h2').text()).toBe('测试服务商 模型');
});
it('shows a retryable error and preserves manual add', async () => {
apiMocks.getRemoteModels.mockRejectedValueOnce(
new Error('当前服务暂不支持获取模型列表'),
);
const wrapper = mountDialog();
wrapper.vm.openDialog('100', '测试服务商');
await flushPromises();
expect(wrapper.text()).toContain('获取失败');
expect(wrapper.text()).toContain('当前服务暂不支持获取模型列表');
const manualAdd = wrapper
.findAll('button')
.find((button) => button.text().trim() === '手动新增');
await manualAdd?.trigger('click');
expect(wrapper.emitted('manualAdd')).toHaveLength(1);
});
it('uses one scroll container and keeps semantic capability colors', async () => {
apiMocks.getRemoteModels.mockResolvedValueOnce({
data: {
models: [
{
addable: true,
added: false,
capabilitySource: 'CATALOG',
displayName: 'DeepSeek V4 Pro',
family: 'deepseek-thinking',
modelId: 'deepseek-v4-pro',
modelType: 'chatModel',
supportImage: false,
supportThinking: true,
supportTool: true,
},
],
providerId: '100',
truncated: false,
},
errorCode: 0,
});
const wrapper = mountDialog();
wrapper.vm.openDialog('100', '测试服务商');
await flushPromises();
expect(wrapper.find('.el-vl__wrapper').exists()).toBe(false);
expect(wrapper.find('.el-tag--info').text()).toBe('推理');
expect(wrapper.find('.el-tag--warning').text()).toBe('工具');
});
it('renders a flat list sorted by model id', async () => {
apiMocks.getRemoteModels.mockResolvedValueOnce({
data: {
models: [
{
addable: true,
added: false,
capabilitySource: 'CATALOG',
displayName: 'Zeta Model',
family: 'zeta-family',
modelId: 'zeta/model',
modelType: 'chatModel',
supportImage: false,
supportThinking: false,
supportTool: false,
},
{
addable: true,
added: false,
capabilitySource: 'CATALOG',
displayName: 'Alpha Model',
family: 'alpha-family',
modelId: 'alpha/model',
modelType: 'chatModel',
supportImage: false,
supportThinking: false,
supportTool: false,
},
],
providerId: '100',
truncated: false,
},
errorCode: 0,
});
const wrapper = mountDialog();
wrapper.vm.openDialog('100', '测试服务商');
await flushPromises();
expect(wrapper.find('.remote-model-dialog__group').exists()).toBe(false);
expect(
wrapper
.findAll('.remote-model-row__identity strong')
.map((item) => item.text()),
).toEqual(['alpha/model', 'zeta/model']);
expect(wrapper.text()).not.toContain('alpha-family');
expect(wrapper.text()).not.toContain('zeta-family');
});
});

View File

@@ -0,0 +1,494 @@
<script setup lang="ts">
import type { RemoteModelDescriptor, RemoteModelListData } from '#/api/ai/llm';
import { computed, onBeforeUnmount, ref } from 'vue';
import { EasyFlowFormModal } from '@easyflow/common-ui';
import { Check, Plus, Refresh, Search } from '@element-plus/icons-vue';
import {
ElButton,
ElEmpty,
ElIcon,
ElInput,
ElMessage,
ElSegmented,
ElSkeleton,
ElTag,
} from 'element-plus';
import { getRemoteModels, importRemoteModel } from '#/api/ai/llm';
type ModelTypeFilter =
| 'allModel'
| 'chatModel'
| 'embeddingModel'
| 'rerankModel';
const emit = defineEmits<{
imported: [];
manualAdd: [];
}>();
const visible = ref(false);
const providerId = ref('');
const providerName = ref('');
const models = ref<RemoteModelDescriptor[]>([]);
const loading = ref(false);
const errorMessage = ref('');
const truncated = ref(false);
const searchText = ref('');
const debouncedSearch = ref('');
const activeType = ref<ModelTypeFilter>('allModel');
const importingIds = ref(new Set<string>());
let searchTimer: ReturnType<typeof setTimeout> | undefined;
let requestSequence = 0;
const typeOptions = [
{ label: '全部', value: 'allModel' },
{ label: '对话', value: 'chatModel' },
{ label: '向量', value: 'embeddingModel' },
{ label: '重排', value: 'rerankModel' },
];
const typeLabelMap: Record<RemoteModelDescriptor['modelType'], string> = {
chatModel: '对话',
embeddingModel: '向量',
rerankModel: '重排',
};
const getTypeLabel = (modelType: RemoteModelDescriptor['modelType']) =>
typeLabelMap[modelType] || '对话';
const filteredModels = computed(() => {
const keyword = debouncedSearch.value.trim().toLowerCase();
return models.value
.filter((model) => {
const typeMatched =
activeType.value === 'allModel' || model.modelType === activeType.value;
if (!typeMatched || !keyword) {
return typeMatched;
}
return model.modelId.toLowerCase().includes(keyword);
})
.sort((left, right) =>
left.modelId.localeCompare(right.modelId, undefined, {
sensitivity: 'base',
}),
);
});
const hasFilters = computed(
() => Boolean(searchText.value.trim()) || activeType.value !== 'allModel',
);
const emptyDescription = computed(() =>
hasFilters.value ? '没有匹配的模型' : '远端没有返回可添加的模型',
);
const dialogTitle = computed(() =>
providerName.value ? `${providerName.value} 模型` : '获取模型列表',
);
const handleSearchInput = () => {
if (searchTimer) {
clearTimeout(searchTimer);
}
searchTimer = setTimeout(() => {
debouncedSearch.value = searchText.value;
}, 160);
};
const clearFilters = () => {
searchText.value = '';
debouncedSearch.value = '';
activeType.value = 'allModel';
};
const requestModels = async () => {
if (!providerId.value || loading.value) {
return;
}
const sequence = ++requestSequence;
loading.value = true;
errorMessage.value = '';
try {
const response = await getRemoteModels(providerId.value);
if (sequence !== requestSequence) {
return;
}
if (response?.errorCode !== 0) {
throw new Error(response?.message || '获取模型列表失败');
}
const data = (response.data || {}) as RemoteModelListData;
models.value = Array.isArray(data.models) ? data.models : [];
truncated.value = Boolean(data.truncated);
} catch (error: any) {
if (sequence === requestSequence) {
models.value = [];
truncated.value = false;
errorMessage.value =
error?.response?.data?.message || error?.message || '获取模型列表失败';
}
} finally {
if (sequence === requestSequence) {
loading.value = false;
}
}
};
const handleImport = async (model: RemoteModelDescriptor) => {
if (model.added || !model.addable || importingIds.value.has(model.modelId)) {
return;
}
importingIds.value = new Set(importingIds.value).add(model.modelId);
try {
const response = await importRemoteModel(providerId.value, model.modelId);
if (response?.errorCode !== 0) {
throw new Error(response?.message || '添加模型失败');
}
models.value = models.value.map((item) =>
item.modelId === model.modelId ? { ...item, added: true } : item,
);
ElMessage.success(
response.data?.status === 'ALREADY_EXISTS' ? '模型已添加' : '添加成功',
);
emit('imported');
} catch (error: any) {
ElMessage.error(
error?.response?.data?.message || error?.message || '添加模型失败',
);
} finally {
const nextIds = new Set(importingIds.value);
nextIds.delete(model.modelId);
importingIds.value = nextIds;
}
};
const handleManualAdd = () => {
visible.value = false;
emit('manualAdd');
};
const handleOpenChange = (open: boolean) => {
visible.value = open;
if (!open) {
requestSequence += 1;
}
};
const openDialog = (nextProviderId: string, nextProviderName = '') => {
providerId.value = nextProviderId;
providerName.value = nextProviderName;
models.value = [];
importingIds.value = new Set();
errorMessage.value = '';
truncated.value = false;
clearFilters();
visible.value = true;
void requestModels();
};
onBeforeUnmount(() => {
if (searchTimer) {
clearTimeout(searchTimer);
}
requestSequence += 1;
});
defineExpose({ openDialog });
</script>
<template>
<EasyFlowFormModal
:open="visible"
:title="dialogTitle"
width="800px"
:show-footer="false"
@update:open="handleOpenChange"
>
<div class="remote-model-dialog">
<div class="remote-model-dialog__toolbar">
<ElInput
v-model="searchText"
clearable
:disabled="loading || Boolean(errorMessage)"
placeholder="搜索模型 ID"
aria-label="搜索远端模型"
@input="handleSearchInput"
@clear="handleSearchInput"
>
<template #prefix>
<ElIcon><Search /></ElIcon>
</template>
</ElInput>
<ElButton
:icon="Refresh"
:loading="loading"
:disabled="loading"
aria-label="刷新远端模型列表"
@click="requestModels"
>
刷新
</ElButton>
</div>
<div class="remote-model-dialog__filters">
<ElSegmented
v-model="activeType"
:options="typeOptions"
aria-label="按模型类型筛选"
/>
<span
v-if="!loading && !errorMessage"
class="remote-model-dialog__count"
>
{{ filteredModels.length }} 个模型
</span>
</div>
<div
v-if="loading"
class="remote-model-dialog__loading"
aria-live="polite"
>
<ElSkeleton v-for="item in 5" :key="item" animated :rows="1" />
</div>
<div v-else-if="errorMessage" class="remote-model-dialog__state">
<strong>获取失败</strong>
<p>{{ errorMessage }}</p>
<div class="remote-model-dialog__state-actions">
<ElButton type="primary" :icon="Refresh" @click="requestModels">
重新获取
</ElButton>
<ElButton @click="handleManualAdd">手动新增</ElButton>
</div>
</div>
<div
v-else-if="filteredModels.length === 0"
class="remote-model-dialog__state"
>
<ElEmpty :description="emptyDescription">
<ElButton v-if="hasFilters" @click="clearFilters">清空筛选</ElButton>
<ElButton v-else :icon="Refresh" @click="requestModels">
重新获取
</ElButton>
</ElEmpty>
</div>
<div v-else class="remote-model-dialog__list">
<div
v-for="model in filteredModels"
:key="model.modelId"
class="remote-model-row"
>
<div class="remote-model-row__identity">
<strong :title="model.modelId">
{{ model.modelId }}
</strong>
</div>
<div class="remote-model-row__abilities">
<ElTag size="small" effect="plain">
{{ getTypeLabel(model.modelType) }}
</ElTag>
<ElTag
v-if="model.supportImage"
size="small"
effect="plain"
type="success"
>
视觉
</ElTag>
<ElTag
v-if="model.supportThinking"
size="small"
effect="plain"
type="info"
>
推理
</ElTag>
<ElTag
v-if="model.supportTool"
size="small"
effect="plain"
type="warning"
>
工具
</ElTag>
</div>
<div class="remote-model-row__action">
<span v-if="model.added" class="remote-model-row__added">
<ElIcon><Check /></ElIcon>
已添加
</span>
<ElButton
v-else
circle
text
:icon="Plus"
:loading="importingIds.has(model.modelId)"
:disabled="!model.addable || importingIds.has(model.modelId)"
:title="model.unavailableReason || '添加模型'"
:aria-label="`添加 ${model.modelId}`"
@click="handleImport(model)"
/>
</div>
</div>
</div>
<p v-if="truncated" class="remote-model-dialog__notice">
模型数量较多当前显示前 1000
</p>
</div>
</EasyFlowFormModal>
</template>
<style scoped>
.remote-model-dialog {
display: flex;
flex-direction: column;
gap: 16px;
min-height: 0;
}
.remote-model-dialog__toolbar {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
}
.remote-model-dialog__filters {
display: flex;
gap: 16px;
align-items: center;
justify-content: space-between;
}
.remote-model-dialog__count,
.remote-model-dialog__notice {
font-size: 12px;
color: hsl(var(--text-muted));
}
.remote-model-dialog__loading {
display: flex;
flex-direction: column;
gap: 16px;
padding-top: 16px;
}
.remote-model-dialog__state {
display: flex;
flex: 1;
flex-direction: column;
gap: 8px;
align-items: center;
justify-content: center;
min-height: 280px;
text-align: center;
}
.remote-model-dialog__state strong {
color: hsl(var(--text-strong));
}
.remote-model-dialog__state p {
max-width: 560px;
margin: 0;
font-size: 13px;
line-height: 1.6;
color: hsl(var(--text-muted));
}
.remote-model-dialog__state-actions {
display: flex;
gap: 8px;
margin-top: 8px;
}
.remote-model-dialog__list {
overflow: hidden;
}
.remote-model-row {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(160px, auto) 64px;
gap: 16px;
align-items: center;
min-height: 64px;
padding: 0 12px;
border-bottom: 1px solid hsl(var(--divider-faint) / 42%);
transition: background-color 140ms ease;
}
.remote-model-row:hover {
background: hsl(var(--surface-contrast-soft) / 28%);
}
.remote-model-row__identity {
min-width: 0;
}
.remote-model-row__identity strong {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
font-weight: 600;
color: hsl(var(--text-strong));
}
.remote-model-row__abilities {
display: flex;
flex-wrap: wrap;
gap: 6px;
justify-content: flex-end;
}
.remote-model-row__action {
display: flex;
justify-content: flex-end;
}
.remote-model-row__added {
display: inline-flex;
gap: 4px;
align-items: center;
font-size: 12px;
color: hsl(var(--success));
}
.remote-model-dialog__notice {
margin: -8px 0 0;
}
@media (max-width: 768px) {
.remote-model-dialog {
min-height: 0;
}
.remote-model-dialog__toolbar {
grid-template-columns: minmax(0, 1fr);
}
.remote-model-dialog__filters {
align-items: flex-start;
flex-direction: column;
gap: 8px;
}
.remote-model-row {
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
}
.remote-model-row__abilities {
display: none;
}
}
</style>