1164 lines
30 KiB
Vue
1164 lines
30 KiB
Vue
<script setup lang="ts">
|
||
import {computed, onMounted, ref} from 'vue';
|
||
|
||
import {$t} from '@easyflow/locales';
|
||
|
||
import {Delete, Edit, Plus, Select, Setting} from '@element-plus/icons-vue';
|
||
import {
|
||
ElButton,
|
||
ElEmpty,
|
||
ElForm,
|
||
ElFormItem,
|
||
ElInput,
|
||
ElMessage,
|
||
ElMessageBox,
|
||
ElTag,
|
||
} from 'element-plus';
|
||
|
||
import {getLlmProviderList} from '#/api/ai/llm.js';
|
||
import {api} from '#/api/request.js';
|
||
import ListPageShell from '#/components/page/ListPageShell.vue';
|
||
import ActiveModelWorkspace from '#/views/ai/model/ActiveModelWorkspace.vue';
|
||
import AddModelModal from '#/views/ai/model/AddModelModal.vue';
|
||
import AddModelProviderModal from '#/views/ai/model/AddModelProviderModal.vue';
|
||
import ModelProviderBadge from '#/views/ai/model/ModelProviderBadge.vue';
|
||
import {getProviderPresetByValue} from '#/views/ai/model/modelUtils/defaultIcon';
|
||
import {modelTypes} from '#/views/ai/model/modelUtils/modelTypes';
|
||
import {
|
||
createProviderDraft,
|
||
getProviderConfigMetrics,
|
||
isProviderDraftDirty,
|
||
} from '#/views/ai/model/modelUtils/providerDraft';
|
||
import ModelVerifyConfig from '#/views/ai/model/ModelVerifyConfig.vue';
|
||
import ModelViewItemOperation from '#/views/ai/model/ModelViewItemOperation.vue';
|
||
|
||
type ModelWorkspaceView = 'active' | 'provider';
|
||
|
||
interface ModelGroup {
|
||
groupName: string;
|
||
llmList: any[];
|
||
}
|
||
|
||
const providers = ref<any[]>([]);
|
||
const providerCounts = ref<Record<string, number>>({});
|
||
const selectedProviderId = ref('');
|
||
const activeModelType = ref('allModel');
|
||
const workspaceView = ref<ModelWorkspaceView>('provider');
|
||
const showProviderAdvanced = ref(false);
|
||
const providerDraft = ref(createProviderDraft());
|
||
const groupedModels = ref<Record<string, ModelGroup[]>>({
|
||
chatModel: [],
|
||
embeddingModel: [],
|
||
rerankModel: [],
|
||
});
|
||
const isPageLoading = ref(false);
|
||
const isDetailLoading = ref(false);
|
||
const isSaveLoading = ref(false);
|
||
|
||
const addLlmProviderRef = ref();
|
||
const llmVerifyConfigRef = ref();
|
||
const addLlmRef = ref();
|
||
const activeWorkspaceRef = ref();
|
||
|
||
const selectedProvider = computed(() =>
|
||
providers.value.find((item) => item.id === selectedProviderId.value),
|
||
);
|
||
|
||
const selectedPreset = computed(() =>
|
||
getProviderPresetByValue(selectedProvider.value?.providerType),
|
||
);
|
||
|
||
const workspaceModelTypes = [
|
||
{
|
||
label: '全部模型',
|
||
value: 'allModel',
|
||
},
|
||
...modelTypes,
|
||
];
|
||
|
||
const currentModelGroups = computed(() => {
|
||
if (activeModelType.value !== 'allModel') {
|
||
return groupedModels.value[activeModelType.value] || [];
|
||
}
|
||
|
||
const mergedGroups = new Map<string, any[]>();
|
||
for (const modelType of ['chatModel', 'embeddingModel', 'rerankModel']) {
|
||
for (const group of groupedModels.value[modelType] || []) {
|
||
if (!mergedGroups.has(group.groupName)) {
|
||
mergedGroups.set(group.groupName, []);
|
||
}
|
||
mergedGroups.get(group.groupName)?.push(...group.llmList);
|
||
}
|
||
}
|
||
|
||
return [...mergedGroups.entries()].map(([groupName, llmList]) => ({
|
||
groupName,
|
||
llmList,
|
||
}));
|
||
});
|
||
|
||
const currentModelTypeLabel = computed(() =>
|
||
activeModelType.value === 'allModel'
|
||
? '全部模型'
|
||
: modelTypes.find((item) => item.value === activeModelType.value)?.label ||
|
||
$t('llmProvider.chatModel'),
|
||
);
|
||
|
||
const actionModelType = computed(() =>
|
||
activeModelType.value === 'allModel' ? 'chatModel' : activeModelType.value,
|
||
);
|
||
|
||
const selectedProviderTypeLabel = computed(
|
||
() =>
|
||
selectedPreset.value?.label || selectedProvider.value?.providerType || '-',
|
||
);
|
||
|
||
const isProviderDirty = computed(() =>
|
||
isProviderDraftDirty(selectedProvider.value, providerDraft.value),
|
||
);
|
||
|
||
const currentProviderMetrics = computed(() =>
|
||
getProviderConfigMetrics(
|
||
{
|
||
...selectedProvider.value,
|
||
...providerDraft.value,
|
||
},
|
||
selectedPreset.value,
|
||
),
|
||
);
|
||
|
||
const toGroupList = (modelMap: Record<string, any[]>) =>
|
||
Object.entries(modelMap || {}).map(([groupName, llmList]) => ({
|
||
groupName,
|
||
llmList,
|
||
}));
|
||
|
||
const countModels = (data: any) => {
|
||
let total = 0;
|
||
|
||
for (const key of ['chatModel', 'embeddingModel', 'rerankModel']) {
|
||
const groups = Object.values(data?.[key] || {}) as any[][];
|
||
for (const items of groups) {
|
||
total += items.length;
|
||
}
|
||
}
|
||
|
||
return total;
|
||
};
|
||
|
||
const syncProviderDraft = (provider?: any) => {
|
||
providerDraft.value = createProviderDraft(provider);
|
||
};
|
||
|
||
const syncGroupedModels = (data: any) => {
|
||
groupedModels.value = {
|
||
chatModel: toGroupList(data?.chatModel || {}),
|
||
embeddingModel: toGroupList(data?.embeddingModel || {}),
|
||
rerankModel: toGroupList(data?.rerankModel || {}),
|
||
};
|
||
};
|
||
|
||
const confirmDiscardProviderDraft = async () => {
|
||
if (!isProviderDirty.value) {
|
||
return true;
|
||
}
|
||
|
||
try {
|
||
await ElMessageBox.confirm(
|
||
'当前服务商配置还没有保存,离开后未保存修改会丢失。',
|
||
'未保存的更改',
|
||
{
|
||
confirmButtonText: '继续',
|
||
cancelButtonText: $t('message.cancel'),
|
||
type: 'warning',
|
||
},
|
||
);
|
||
return true;
|
||
} catch {
|
||
return false;
|
||
}
|
||
};
|
||
|
||
const loadProviderDetail = async (
|
||
providerId: string,
|
||
options: { keepDraft?: boolean } = {},
|
||
) => {
|
||
if (!providerId) {
|
||
syncGroupedModels({});
|
||
if (!options.keepDraft) {
|
||
syncProviderDraft();
|
||
}
|
||
return;
|
||
}
|
||
|
||
isDetailLoading.value = true;
|
||
|
||
try {
|
||
const res = await api.get(`/api/v1/model/getList?providerId=${providerId}`, {});
|
||
|
||
if (res.errorCode === 0) {
|
||
syncGroupedModels(res.data);
|
||
providerCounts.value = {
|
||
...providerCounts.value,
|
||
[providerId]: countModels(res.data),
|
||
};
|
||
|
||
if (!options.keepDraft) {
|
||
syncProviderDraft(selectedProvider.value);
|
||
}
|
||
} else {
|
||
ElMessage.error(res.message || $t('ui.actionMessage.operationFailed'));
|
||
}
|
||
} finally {
|
||
isDetailLoading.value = false;
|
||
}
|
||
};
|
||
|
||
const refreshProviderCounts = async (list: any[]) => {
|
||
const entries = await Promise.all(
|
||
list.map(async (provider) => {
|
||
try {
|
||
const res = await api.get(
|
||
`/api/v1/model/getList?providerId=${provider.id}`,
|
||
{},
|
||
);
|
||
return [provider.id, res.errorCode === 0 ? countModels(res.data) : 0];
|
||
} catch {
|
||
return [provider.id, 0];
|
||
}
|
||
}),
|
||
);
|
||
|
||
providerCounts.value = Object.fromEntries(entries);
|
||
};
|
||
|
||
const loadProviders = async (
|
||
preferredId?: string,
|
||
refreshCounts = false,
|
||
keepDraft = false,
|
||
) => {
|
||
isPageLoading.value = true;
|
||
const previousId = selectedProviderId.value;
|
||
|
||
try {
|
||
const res = await getLlmProviderList();
|
||
const list = res?.data || [];
|
||
providers.value = list;
|
||
|
||
if (refreshCounts) {
|
||
await refreshProviderCounts(list);
|
||
}
|
||
|
||
if (list.length === 0) {
|
||
selectedProviderId.value = '';
|
||
syncProviderDraft();
|
||
syncGroupedModels({});
|
||
return;
|
||
}
|
||
|
||
let nextProviderId = list[0].id;
|
||
|
||
if (preferredId && list.some((item: any) => item.id === preferredId)) {
|
||
nextProviderId = preferredId;
|
||
} else if (
|
||
selectedProviderId.value &&
|
||
list.some((item: any) => item.id === selectedProviderId.value)
|
||
) {
|
||
nextProviderId = selectedProviderId.value;
|
||
}
|
||
|
||
selectedProviderId.value = nextProviderId;
|
||
showProviderAdvanced.value = false;
|
||
await loadProviderDetail(nextProviderId, {
|
||
keepDraft: keepDraft && previousId === nextProviderId,
|
||
});
|
||
} finally {
|
||
isPageLoading.value = false;
|
||
}
|
||
};
|
||
|
||
const switchWorkspaceView = async (target: ModelWorkspaceView) => {
|
||
if (workspaceView.value === target) {
|
||
return;
|
||
}
|
||
|
||
if (!(await confirmDiscardProviderDraft())) {
|
||
return;
|
||
}
|
||
|
||
workspaceView.value = target;
|
||
|
||
if (target === 'active') {
|
||
await activeWorkspaceRef.value?.reloadData?.();
|
||
}
|
||
};
|
||
|
||
const selectProvider = async (provider: any) => {
|
||
if (!provider?.id || provider.id === selectedProviderId.value) {
|
||
return;
|
||
}
|
||
|
||
if (!(await confirmDiscardProviderDraft())) {
|
||
return;
|
||
}
|
||
|
||
selectedProviderId.value = provider.id;
|
||
activeModelType.value = 'allModel';
|
||
showProviderAdvanced.value = false;
|
||
syncProviderDraft(provider);
|
||
await loadProviderDetail(provider.id);
|
||
};
|
||
|
||
const saveProviderDraft = async () => {
|
||
if (!selectedProviderId.value || !isProviderDirty.value) {
|
||
return;
|
||
}
|
||
|
||
isSaveLoading.value = true;
|
||
|
||
try {
|
||
const res = await api.post('/api/v1/modelProvider/update', {
|
||
id: selectedProviderId.value,
|
||
...providerDraft.value,
|
||
});
|
||
|
||
if (res.errorCode === 0) {
|
||
ElMessage.success(res.message || '配置已保存');
|
||
await loadProviders(selectedProviderId.value, true);
|
||
} else {
|
||
ElMessage.error(res.message || $t('ui.actionMessage.operationFailed'));
|
||
}
|
||
} finally {
|
||
isSaveLoading.value = false;
|
||
}
|
||
};
|
||
|
||
const handleDeleteProvider = async (provider: any) => {
|
||
try {
|
||
await ElMessageBox.confirm(
|
||
$t('message.deleteAlert'),
|
||
$t('message.noticeTitle'),
|
||
{
|
||
confirmButtonText: $t('message.ok'),
|
||
cancelButtonText: $t('message.cancel'),
|
||
type: 'warning',
|
||
},
|
||
);
|
||
} catch {
|
||
return;
|
||
}
|
||
|
||
const res = await api.post('/api/v1/modelProvider/remove', {
|
||
id: provider.id,
|
||
});
|
||
|
||
if (res.errorCode === 0) {
|
||
ElMessage.success(res.message);
|
||
const remaining = providers.value.find((item) => item.id !== provider.id);
|
||
await loadProviders(remaining?.id, true);
|
||
await activeWorkspaceRef.value?.reloadData?.();
|
||
}
|
||
};
|
||
|
||
const openAddProviderDialog = () => {
|
||
addLlmProviderRef.value.openAddDialog();
|
||
};
|
||
|
||
const openEditProviderDialog = () => {
|
||
if (!selectedProvider.value) {
|
||
return;
|
||
}
|
||
|
||
addLlmProviderRef.value.openEditDialog(selectedProvider.value);
|
||
};
|
||
|
||
const handleProviderModalReload = async () => {
|
||
await loadProviders(selectedProviderId.value, true);
|
||
await activeWorkspaceRef.value?.reloadData?.();
|
||
};
|
||
|
||
const handleAddLlm = (modelType = activeModelType.value) => {
|
||
const targetModelType =
|
||
modelType === 'allModel' || !modelType ? 'chatModel' : modelType;
|
||
addLlmRef.value.openAddDialog(targetModelType);
|
||
};
|
||
|
||
const handleDeleteLlm = (id: string) => {
|
||
ElMessageBox.confirm($t('message.deleteAlert'), $t('message.noticeTitle'), {
|
||
confirmButtonText: $t('message.ok'),
|
||
cancelButtonText: $t('message.cancel'),
|
||
type: 'warning',
|
||
}).then(async () => {
|
||
const res = await api.post('/api/v1/model/remove', { id });
|
||
if (res.errorCode === 0) {
|
||
ElMessage.success($t('message.deleteOkMessage'));
|
||
await loadProviderDetail(selectedProviderId.value, {
|
||
keepDraft: isProviderDirty.value,
|
||
});
|
||
await activeWorkspaceRef.value?.reloadData?.();
|
||
await refreshProviderCounts(providers.value);
|
||
}
|
||
});
|
||
};
|
||
|
||
const handleEditLlm = async (id: string) => {
|
||
const res = await api.get(`/api/v1/model/detail?id=${id}`);
|
||
if (res.errorCode === 0) {
|
||
addLlmRef.value.openEditDialog(res.data);
|
||
}
|
||
};
|
||
|
||
const handleGroupNameUpdateModel = (groupName: string) => {
|
||
ElMessageBox.confirm(
|
||
`确认删除分组「${groupName}」下的所有模型吗?该操作不可恢复。`,
|
||
$t('message.noticeTitle'),
|
||
{
|
||
confirmButtonText: $t('message.ok'),
|
||
cancelButtonText: $t('message.cancel'),
|
||
type: 'warning',
|
||
},
|
||
).then(async () => {
|
||
const res = await api.post('/api/v1/model/removeByEntity', {
|
||
providerId: selectedProviderId.value,
|
||
groupName,
|
||
});
|
||
if (res.errorCode === 0) {
|
||
await loadProviderDetail(selectedProviderId.value, {
|
||
keepDraft: isProviderDirty.value,
|
||
});
|
||
await activeWorkspaceRef.value?.reloadData?.();
|
||
await refreshProviderCounts(providers.value);
|
||
}
|
||
});
|
||
};
|
||
|
||
const handleTest = () => {
|
||
if (!selectedProviderId.value) {
|
||
return;
|
||
}
|
||
llmVerifyConfigRef.value.openDialog(selectedProviderId.value);
|
||
};
|
||
|
||
const handleModelDataReload = async () => {
|
||
await loadProviderDetail(selectedProviderId.value, {
|
||
keepDraft: isProviderDirty.value,
|
||
});
|
||
await activeWorkspaceRef.value?.reloadData?.();
|
||
await refreshProviderCounts(providers.value);
|
||
};
|
||
|
||
const handleActiveProviderStatsRefresh = async () => {
|
||
if (isProviderDirty.value) {
|
||
await refreshProviderCounts(providers.value);
|
||
return;
|
||
}
|
||
|
||
await loadProviders(selectedProviderId.value, true, true);
|
||
};
|
||
|
||
onMounted(() => {
|
||
loadProviders(undefined, true);
|
||
});
|
||
</script>
|
||
|
||
<template>
|
||
<ListPageShell class="model-page-shell" surface="plain" :content-padding="20">
|
||
<template #filters>
|
||
<div class="workspace-switch">
|
||
<button
|
||
type="button"
|
||
class="workspace-switch__item"
|
||
:class="{ 'is-active': workspaceView === 'provider' }"
|
||
@click="switchWorkspaceView('provider')"
|
||
>
|
||
服务商配置
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="workspace-switch__item"
|
||
:class="{ 'is-active': workspaceView === 'active' }"
|
||
@click="switchWorkspaceView('active')"
|
||
>
|
||
已配置模型
|
||
</button>
|
||
</div>
|
||
</template>
|
||
|
||
<section v-if="workspaceView === 'provider'" class="provider-workspace">
|
||
<aside class="provider-list">
|
||
<div class="provider-list__head">
|
||
<h3>服务商</h3>
|
||
<ElButton circle :icon="Plus" @click="openAddProviderDialog" />
|
||
</div>
|
||
|
||
<div
|
||
v-if="providers.length === 0 && !isPageLoading"
|
||
class="provider-list__empty"
|
||
>
|
||
<p>还没有服务商,先添加一个开始配置。</p>
|
||
<ElButton type="primary" @click="openAddProviderDialog">
|
||
添加服务商
|
||
</ElButton>
|
||
</div>
|
||
|
||
<div v-else class="provider-list__body">
|
||
<button
|
||
v-for="provider in providers"
|
||
:key="provider.id"
|
||
type="button"
|
||
class="provider-item"
|
||
:class="{ 'is-active': provider.id === selectedProviderId }"
|
||
@click="selectProvider(provider)"
|
||
>
|
||
<ModelProviderBadge
|
||
:icon="provider.icon"
|
||
:provider-name="provider.providerName"
|
||
:provider-type="provider.providerType"
|
||
:size="40"
|
||
/>
|
||
<div class="provider-item__content">
|
||
<strong>{{ provider.providerName }}</strong>
|
||
<span>{{ providerCounts[provider.id] || 0 }} 个模型</span>
|
||
</div>
|
||
</button>
|
||
</div>
|
||
</aside>
|
||
|
||
<section class="provider-detail">
|
||
<div v-if="selectedProvider" class="provider-detail__content">
|
||
<header class="provider-detail__header">
|
||
<div class="provider-detail__identity">
|
||
<ModelProviderBadge
|
||
:icon="selectedProvider.icon"
|
||
:provider-name="selectedProvider.providerName"
|
||
:provider-type="selectedProvider.providerType"
|
||
:size="56"
|
||
/>
|
||
<div>
|
||
<h2>{{ selectedProvider.providerName }}</h2>
|
||
<p>
|
||
{{
|
||
selectedPreset?.description ||
|
||
'当前服务商使用自定义配置接入。'
|
||
}}
|
||
</p>
|
||
<div class="provider-detail__meta">
|
||
<ElTag effect="plain">
|
||
{{
|
||
selectedPreset?.mode === 'self-hosted'
|
||
? '自部署'
|
||
: '云服务'
|
||
}}
|
||
</ElTag>
|
||
<span>配置完成度 {{ currentProviderMetrics.ratio }}%</span>
|
||
<span>
|
||
{{ providerCounts[selectedProvider.id] || 0 }} 个模型
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="provider-detail__actions">
|
||
<ElButton :icon="Select" @click="handleTest">验证配置</ElButton>
|
||
<ElButton :icon="Edit" @click="openEditProviderDialog">
|
||
编辑服务商
|
||
</ElButton>
|
||
<ElButton
|
||
text
|
||
class="is-danger"
|
||
:icon="Delete"
|
||
@click="handleDeleteProvider(selectedProvider)"
|
||
>
|
||
删除
|
||
</ElButton>
|
||
</div>
|
||
</header>
|
||
|
||
<section class="provider-card">
|
||
<div class="provider-card__head">
|
||
<div>
|
||
<h3>默认连接配置</h3>
|
||
<p>主流程优先配置 API 密钥、Endpoint 和对话路径。</p>
|
||
</div>
|
||
<span
|
||
class="provider-card__status"
|
||
:class="{ 'is-complete': currentProviderMetrics.complete }"
|
||
>
|
||
{{
|
||
currentProviderMetrics.complete
|
||
? '配置完整'
|
||
: `还差 ${currentProviderMetrics.total - currentProviderMetrics.completed} 项`
|
||
}}
|
||
</span>
|
||
</div>
|
||
|
||
<ElForm
|
||
label-position="top"
|
||
class="provider-config-form"
|
||
@submit.prevent
|
||
>
|
||
<ElFormItem label="服务商类型">
|
||
<ElInput :model-value="selectedProviderTypeLabel" disabled />
|
||
</ElFormItem>
|
||
<ElFormItem :label="$t('llmProvider.providerName')">
|
||
<ElInput
|
||
:model-value="selectedProvider.providerName"
|
||
disabled
|
||
/>
|
||
</ElFormItem>
|
||
<ElFormItem :label="$t('llmProvider.apiKey')">
|
||
<ElInput
|
||
v-model="providerDraft.apiKey"
|
||
type="password"
|
||
show-password
|
||
placeholder="填写服务商密钥"
|
||
/>
|
||
</ElFormItem>
|
||
<ElFormItem :label="$t('llmProvider.endpoint')">
|
||
<ElInput
|
||
v-model.trim="providerDraft.endpoint"
|
||
placeholder="填写网关地址"
|
||
/>
|
||
</ElFormItem>
|
||
<ElFormItem :label="$t('llmProvider.chatPath')">
|
||
<ElInput
|
||
v-model.trim="providerDraft.chatPath"
|
||
placeholder="对话模型请求路径"
|
||
/>
|
||
</ElFormItem>
|
||
|
||
<div class="provider-config-form__advanced">
|
||
<button
|
||
type="button"
|
||
class="provider-config-form__advanced-toggle"
|
||
@click="showProviderAdvanced = !showProviderAdvanced"
|
||
>
|
||
<span>高级设置</span>
|
||
<span>{{ showProviderAdvanced ? '收起' : '展开' }}</span>
|
||
</button>
|
||
|
||
<div
|
||
v-if="showProviderAdvanced"
|
||
class="provider-config-form__advanced-body"
|
||
>
|
||
<ElFormItem :label="$t('llmProvider.embedPath')">
|
||
<ElInput
|
||
v-model.trim="providerDraft.embedPath"
|
||
placeholder="向量模型请求路径"
|
||
/>
|
||
</ElFormItem>
|
||
<ElFormItem :label="$t('llmProvider.rerankPath')">
|
||
<ElInput
|
||
v-model.trim="providerDraft.rerankPath"
|
||
placeholder="Rerank 请求路径"
|
||
/>
|
||
</ElFormItem>
|
||
<p class="provider-config-form__hint">
|
||
{{
|
||
selectedPreset?.description ||
|
||
'该服务商为自定义接入,建议在编辑弹窗中维护图标和更多信息。'
|
||
}}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</ElForm>
|
||
|
||
<div class="provider-card__footer">
|
||
<p v-if="isProviderDirty" class="provider-card__tip">
|
||
你有未保存的改动,点击保存后才会生效。
|
||
</p>
|
||
<div class="provider-card__actions">
|
||
<ElButton @click="syncProviderDraft(selectedProvider)">
|
||
重置改动
|
||
</ElButton>
|
||
<ElButton
|
||
type="primary"
|
||
:loading="isSaveLoading"
|
||
:disabled="!isProviderDirty"
|
||
:icon="Setting"
|
||
@click="saveProviderDraft"
|
||
>
|
||
保存配置
|
||
</ElButton>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="provider-card">
|
||
<div class="provider-card__head">
|
||
<div>
|
||
<h3>服务商模型工作区</h3>
|
||
<p>按模型能力分组管理已配置模型。</p>
|
||
</div>
|
||
<div class="provider-card__head-actions">
|
||
<ElButton
|
||
type="primary"
|
||
:icon="Plus"
|
||
@click="handleAddLlm(actionModelType)"
|
||
>
|
||
新增模型
|
||
</ElButton>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="provider-card__segment">
|
||
<button
|
||
v-for="item in workspaceModelTypes"
|
||
:key="item.value"
|
||
type="button"
|
||
class="provider-card__segment-item"
|
||
:class="{ 'is-active': activeModelType === item.value }"
|
||
@click="activeModelType = item.value"
|
||
>
|
||
{{ item.label }}
|
||
</button>
|
||
</div>
|
||
|
||
<div v-if="isDetailLoading" class="provider-card__state">
|
||
正在载入 {{ currentModelTypeLabel }}...
|
||
</div>
|
||
|
||
<div
|
||
v-else-if="currentModelGroups.length === 0"
|
||
class="provider-card__empty"
|
||
>
|
||
<ElEmpty :description="`当前还没有${currentModelTypeLabel}`">
|
||
<ElButton type="primary" @click="handleAddLlm(actionModelType)">
|
||
添加第一个模型
|
||
</ElButton>
|
||
</ElEmpty>
|
||
</div>
|
||
|
||
<div v-else class="provider-card__groups">
|
||
<section
|
||
v-for="group in currentModelGroups"
|
||
:key="group.groupName"
|
||
class="provider-group"
|
||
>
|
||
<div class="provider-group__head">
|
||
<div>
|
||
<h4>{{ group.groupName }}</h4>
|
||
<p>{{ group.llmList.length }} 个模型</p>
|
||
</div>
|
||
<ElButton
|
||
text
|
||
class="is-danger"
|
||
@click="handleGroupNameUpdateModel(group.groupName)"
|
||
>
|
||
删除分组
|
||
</ElButton>
|
||
</div>
|
||
|
||
<ModelViewItemOperation
|
||
:llm-list="group.llmList"
|
||
@delete-llm="handleDeleteLlm"
|
||
@edit-llm="handleEditLlm"
|
||
/>
|
||
</section>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
|
||
<div v-else class="provider-detail__empty">
|
||
<ElEmpty description="还没有可用服务商,先添加一个开始配置模型。">
|
||
<ElButton type="primary" @click="openAddProviderDialog">
|
||
添加服务商
|
||
</ElButton>
|
||
</ElEmpty>
|
||
</div>
|
||
</section>
|
||
</section>
|
||
|
||
<ActiveModelWorkspace
|
||
v-else
|
||
ref="activeWorkspaceRef"
|
||
:providers="providers"
|
||
@create-model="handleAddLlm"
|
||
@edit-model="handleEditLlm"
|
||
@refresh-provider-stats="handleActiveProviderStatsRefresh"
|
||
/>
|
||
|
||
<AddModelProviderModal
|
||
ref="addLlmProviderRef"
|
||
@reload="handleProviderModalReload"
|
||
/>
|
||
<AddModelModal
|
||
ref="addLlmRef"
|
||
:provider-id="selectedProviderId"
|
||
@reload="handleModelDataReload"
|
||
/>
|
||
<ModelVerifyConfig ref="llmVerifyConfigRef" />
|
||
</ListPageShell>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.model-page-shell {
|
||
min-height: calc(100vh - 112px);
|
||
}
|
||
|
||
.workspace-switch {
|
||
display: inline-flex;
|
||
gap: 4px;
|
||
padding: 4px;
|
||
background: hsl(var(--surface-contrast-soft) / 66%);
|
||
border: 1px solid hsl(var(--divider-faint) / 58%);
|
||
border-radius: 12px;
|
||
}
|
||
|
||
.workspace-switch__item {
|
||
min-width: 104px;
|
||
padding: 8px 12px;
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
color: hsl(var(--text-muted));
|
||
background: transparent;
|
||
border: none;
|
||
border-radius: 8px;
|
||
}
|
||
|
||
.workspace-switch__item.is-active {
|
||
color: hsl(var(--text-strong));
|
||
background: hsl(var(--surface-panel));
|
||
box-shadow: 0 1px 2px hsl(var(--foreground) / 8%);
|
||
}
|
||
|
||
.provider-workspace {
|
||
display: grid;
|
||
grid-template-columns: 280px minmax(0, 1fr);
|
||
gap: 16px;
|
||
height: 100%;
|
||
min-height: 0;
|
||
}
|
||
|
||
.provider-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-height: 0;
|
||
padding: 14px;
|
||
background: hsl(var(--surface-panel) / 90%);
|
||
border: 1px solid hsl(var(--divider-faint) / 52%);
|
||
border-radius: 14px;
|
||
}
|
||
|
||
.provider-list__head {
|
||
display: flex;
|
||
gap: 8px;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding-bottom: 10px;
|
||
border-bottom: 1px solid hsl(var(--divider-faint) / 42%);
|
||
}
|
||
|
||
.provider-list__head h3,
|
||
.provider-detail__identity h2,
|
||
.provider-card__head h3,
|
||
.provider-group__head h4 {
|
||
margin: 0;
|
||
font-size: 16px;
|
||
font-weight: 600;
|
||
color: hsl(var(--text-strong));
|
||
}
|
||
|
||
.provider-list__body {
|
||
display: flex;
|
||
flex: 1;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
min-height: 0;
|
||
padding-top: 10px;
|
||
overflow: auto;
|
||
}
|
||
|
||
.provider-item {
|
||
display: flex;
|
||
gap: 10px;
|
||
align-items: flex-start;
|
||
width: 100%;
|
||
padding: 10px;
|
||
text-align: left;
|
||
background: hsl(var(--surface-panel));
|
||
border: 1px solid hsl(var(--divider-faint) / 52%);
|
||
border-radius: 10px;
|
||
}
|
||
|
||
.provider-item.is-active {
|
||
background: hsl(var(--primary) / 6%);
|
||
border-color: hsl(var(--primary) / 40%);
|
||
}
|
||
|
||
.provider-item__content {
|
||
display: flex;
|
||
flex: 1;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
min-width: 0;
|
||
}
|
||
|
||
.provider-item__content strong {
|
||
font-size: 14px;
|
||
color: hsl(var(--text-strong));
|
||
}
|
||
|
||
.provider-item__content span,
|
||
.provider-list__empty p,
|
||
.provider-detail__identity p,
|
||
.provider-card__head p,
|
||
.provider-card__tip,
|
||
.provider-group__head p,
|
||
.provider-card__state,
|
||
.provider-config-form__hint {
|
||
font-size: 12px;
|
||
line-height: 1.6;
|
||
color: hsl(var(--text-muted));
|
||
}
|
||
|
||
.provider-list__empty {
|
||
display: flex;
|
||
flex: 1;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
align-items: flex-start;
|
||
justify-content: center;
|
||
}
|
||
|
||
.provider-detail {
|
||
min-height: 0;
|
||
overflow: auto;
|
||
}
|
||
|
||
.provider-detail__content {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 14px;
|
||
}
|
||
|
||
.provider-detail__header,
|
||
.provider-detail__identity,
|
||
.provider-card__head,
|
||
.provider-card__footer,
|
||
.provider-group__head {
|
||
display: flex;
|
||
gap: 12px;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
}
|
||
|
||
.provider-detail__header,
|
||
.provider-card {
|
||
padding: 16px;
|
||
background: hsl(var(--surface-panel) / 92%);
|
||
border: 1px solid hsl(var(--divider-faint) / 54%);
|
||
border-radius: 14px;
|
||
}
|
||
|
||
.provider-detail__identity {
|
||
flex: 1;
|
||
align-items: flex-start;
|
||
justify-content: flex-start;
|
||
}
|
||
|
||
.provider-detail__meta {
|
||
display: inline-flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
align-items: center;
|
||
margin-top: 8px;
|
||
}
|
||
|
||
.provider-detail__meta span {
|
||
font-size: 12px;
|
||
color: hsl(var(--text-muted));
|
||
}
|
||
|
||
.provider-detail__actions {
|
||
display: inline-flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
align-items: center;
|
||
}
|
||
|
||
.provider-card {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
}
|
||
|
||
.provider-card__head {
|
||
align-items: flex-start;
|
||
}
|
||
|
||
.provider-card__head-actions {
|
||
display: inline-flex;
|
||
gap: 8px;
|
||
align-items: center;
|
||
}
|
||
|
||
.provider-card__status {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
padding: 4px 10px;
|
||
font-size: 12px;
|
||
color: hsl(var(--destructive));
|
||
border: 1px solid hsl(var(--destructive) / 22%);
|
||
border-radius: 999px;
|
||
}
|
||
|
||
.provider-card__status.is-complete {
|
||
color: hsl(var(--success));
|
||
border-color: hsl(var(--success) / 32%);
|
||
}
|
||
|
||
.provider-config-form {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
gap: 12px;
|
||
}
|
||
|
||
.provider-config-form :deep(.el-form-item:last-child) {
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
.provider-config-form__advanced {
|
||
display: flex;
|
||
flex-direction: column;
|
||
grid-column: 1 / -1;
|
||
gap: 10px;
|
||
}
|
||
|
||
.provider-config-form__advanced-toggle {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 9px 10px;
|
||
font-size: 13px;
|
||
color: hsl(var(--text-muted));
|
||
background: hsl(var(--surface-contrast-soft) / 68%);
|
||
border: 1px solid hsl(var(--divider-faint) / 56%);
|
||
border-radius: 10px;
|
||
}
|
||
|
||
.provider-config-form__advanced-body {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
gap: 12px;
|
||
}
|
||
|
||
.provider-config-form__hint {
|
||
grid-column: 1 / -1;
|
||
margin: 0;
|
||
}
|
||
|
||
.provider-card__actions {
|
||
display: inline-flex;
|
||
gap: 8px;
|
||
align-items: center;
|
||
}
|
||
|
||
.provider-card__footer {
|
||
justify-content: flex-end;
|
||
}
|
||
|
||
.provider-card__footer .provider-card__actions {
|
||
margin-left: auto;
|
||
}
|
||
|
||
.provider-card__tip {
|
||
margin: 0;
|
||
margin-right: auto;
|
||
}
|
||
|
||
.provider-card__segment {
|
||
display: inline-flex;
|
||
gap: 6px;
|
||
padding: 4px;
|
||
background: hsl(var(--surface-contrast-soft) / 66%);
|
||
border: 1px solid hsl(var(--divider-faint) / 56%);
|
||
border-radius: 10px;
|
||
}
|
||
|
||
.provider-card__segment-item {
|
||
padding: 7px 12px;
|
||
font-size: 13px;
|
||
color: hsl(var(--text-muted));
|
||
background: transparent;
|
||
border: none;
|
||
border-radius: 8px;
|
||
}
|
||
|
||
.provider-card__segment-item.is-active {
|
||
color: hsl(var(--text-strong));
|
||
background: hsl(var(--surface-panel));
|
||
}
|
||
|
||
.provider-card__state {
|
||
padding: 6px 0;
|
||
}
|
||
|
||
.provider-card__empty,
|
||
.provider-detail__empty {
|
||
display: flex;
|
||
min-height: 300px;
|
||
}
|
||
|
||
.provider-card__empty :deep(.el-empty),
|
||
.provider-detail__empty :deep(.el-empty) {
|
||
margin: auto;
|
||
}
|
||
|
||
.provider-card__groups {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
}
|
||
|
||
.provider-group {
|
||
padding: 12px;
|
||
background: hsl(var(--surface-panel));
|
||
border: 1px solid hsl(var(--divider-faint) / 52%);
|
||
border-radius: 10px;
|
||
}
|
||
|
||
.provider-group__head {
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.is-danger {
|
||
color: hsl(var(--destructive));
|
||
}
|
||
|
||
@media (max-width: 1024px) {
|
||
.provider-workspace {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
|
||
@media (max-width: 768px) {
|
||
.provider-detail__header,
|
||
.provider-card__head,
|
||
.provider-card__footer {
|
||
flex-direction: column;
|
||
align-items: flex-start;
|
||
}
|
||
|
||
.provider-card__footer {
|
||
width: 100%;
|
||
}
|
||
|
||
.provider-card__footer .provider-card__actions {
|
||
justify-content: flex-end;
|
||
width: 100%;
|
||
}
|
||
|
||
.provider-config-form,
|
||
.provider-config-form__advanced-body {
|
||
grid-template-columns: minmax(0, 1fr);
|
||
}
|
||
|
||
.provider-card__head-actions,
|
||
.provider-detail__actions {
|
||
flex-wrap: wrap;
|
||
width: 100%;
|
||
}
|
||
}
|
||
</style>
|