feat: 增加服务商远端模型发现与一键添加
- 支持 OpenAI 兼容、Ollama 和阿里百炼模型目录适配 - 使用静态模型库识别能力并过滤未接入的生成模型 - 增加扁平模型列表、搜索筛选和幂等添加
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
494
easyflow-ui-admin/app/src/views/ai/model/RemoteModelDialog.vue
Normal file
494
easyflow-ui-admin/app/src/views/ai/model/RemoteModelDialog.vue
Normal 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>
|
||||
Reference in New Issue
Block a user