perf: 模型管理界面重做
This commit is contained in:
@@ -1,10 +1,19 @@
|
||||
import { api } from '#/api/request.js';
|
||||
import {api} from '#/api/request.js';
|
||||
|
||||
// 获取LLM供应商
|
||||
export async function getLlmProviderList() {
|
||||
return api.get('/api/v1/modelProvider/list');
|
||||
}
|
||||
|
||||
export interface ModelListQuery {
|
||||
modelType?: string;
|
||||
providerId?: string;
|
||||
}
|
||||
|
||||
export async function getModelList(params: ModelListQuery = {}) {
|
||||
return api.get('/api/v1/model/list', { params });
|
||||
}
|
||||
|
||||
// 保存LLM
|
||||
export async function saveLlm(data: string) {
|
||||
return api.post('/api/v1/model/save', data);
|
||||
@@ -20,6 +29,10 @@ export async function updateLlm(data: any) {
|
||||
return api.post(`/api/v1/model/update`, data);
|
||||
}
|
||||
|
||||
export async function verifyModelConfig(id: string) {
|
||||
return api.get('/api/v1/model/verifyLlmConfig', { params: { id } });
|
||||
}
|
||||
|
||||
// 一键添加LLM
|
||||
export async function quickAddLlm(data: any) {
|
||||
return api.post(`/api/v1/model/quickAdd`, data);
|
||||
@@ -33,7 +46,6 @@ export interface llmType {
|
||||
providerName: string;
|
||||
providerType: string;
|
||||
};
|
||||
withUsed: boolean;
|
||||
llmModel: string;
|
||||
icon: string;
|
||||
description: string;
|
||||
|
||||
@@ -6,7 +6,7 @@ interface Props {
|
||||
contentPadding?: number | string;
|
||||
dense?: boolean;
|
||||
stickyToolbar?: boolean;
|
||||
surface?: 'panel' | 'subtle';
|
||||
surface?: 'panel' | 'plain' | 'subtle';
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
@@ -141,6 +141,18 @@ const contentStyle = computed((): CSSProperties => {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.list-page-shell.is-plain .list-page-shell__content {
|
||||
background: hsl(var(--surface-panel) / 0.96);
|
||||
border: 1px solid hsl(var(--divider-faint) / 0.58);
|
||||
border-radius: 20px;
|
||||
box-shadow: none;
|
||||
backdrop-filter: none;
|
||||
}
|
||||
|
||||
.list-page-shell.is-plain .list-page-shell__content::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.list-page-shell__content::before {
|
||||
position: absolute;
|
||||
inset: 0 0 auto;
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import {mount} from '@vue/test-utils';
|
||||
|
||||
import {describe, expect, it, vi} from 'vitest';
|
||||
import CardList from '../CardList.vue';
|
||||
|
||||
const { hasAccessByCodes } = vi.hoisted(() => ({
|
||||
hasAccessByCodes: vi.fn((codes: string[]) => codes[0] !== 'blocked'),
|
||||
}));
|
||||
|
||||
vi.mock('@easyflow/access', () => ({
|
||||
useAccess: () => ({
|
||||
hasAccessByCodes,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('CardList', () => {
|
||||
function mountCardList(props: Record<string, unknown>) {
|
||||
return mount(CardList, {
|
||||
props: {
|
||||
data: [
|
||||
{
|
||||
id: 'bot-1',
|
||||
title: '演示卡片',
|
||||
description: '用于验证主次交互是否正常工作',
|
||||
},
|
||||
],
|
||||
defaultIcon: '/favicon.svg',
|
||||
...props,
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
IconifyIcon: {
|
||||
props: ['icon'],
|
||||
template: '<span class="iconify-icon">{{ icon }}</span>',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
it('点击卡片空白区域时触发主动作', async () => {
|
||||
const primaryAction = vi.fn();
|
||||
const wrapper = mountCardList({
|
||||
primaryAction: {
|
||||
text: '进入设置',
|
||||
onClick: primaryAction,
|
||||
},
|
||||
});
|
||||
|
||||
await wrapper.get('.card-item').trigger('click');
|
||||
|
||||
expect(primaryAction).toHaveBeenCalledTimes(1);
|
||||
expect(primaryAction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'bot-1' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('点击次级按钮时不会冒泡触发主动作', async () => {
|
||||
const primaryAction = vi.fn();
|
||||
const inlineAction = vi.fn();
|
||||
const wrapper = mountCardList({
|
||||
primaryAction: {
|
||||
text: '进入设置',
|
||||
onClick: primaryAction,
|
||||
},
|
||||
actions: [
|
||||
{
|
||||
text: '编辑',
|
||||
placement: 'inline',
|
||||
onClick: inlineAction,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await wrapper.get('.card-action-btn').trigger('click');
|
||||
|
||||
expect(inlineAction).toHaveBeenCalledTimes(1);
|
||||
expect(primaryAction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('键盘 Enter 可以触发主动作', async () => {
|
||||
const primaryAction = vi.fn();
|
||||
const wrapper = mountCardList({
|
||||
primaryAction: {
|
||||
text: '进入设计',
|
||||
onClick: primaryAction,
|
||||
},
|
||||
});
|
||||
|
||||
await wrapper.get('.card-item').trigger('keydown.enter');
|
||||
|
||||
expect(primaryAction).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('未提供主动作时保持旧卡片模式,不会把卡片变成可点击入口', async () => {
|
||||
const legacyAction = vi.fn();
|
||||
const wrapper = mountCardList({
|
||||
actions: [
|
||||
{
|
||||
text: '编辑',
|
||||
onClick: legacyAction,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await wrapper.get('.card-item').trigger('click');
|
||||
await wrapper.get('.card-action-btn').trigger('click');
|
||||
|
||||
expect(wrapper.get('.card-item').attributes('role')).toBeUndefined();
|
||||
expect(legacyAction).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -58,7 +58,6 @@
|
||||
"button": {
|
||||
"management": "Management",
|
||||
"test": "Test",
|
||||
"addAllLlm": "Add models from the list",
|
||||
"RetrieveAgain": "Retrieve the model list again"
|
||||
},
|
||||
"all": "All",
|
||||
|
||||
@@ -55,7 +55,6 @@
|
||||
"button": {
|
||||
"management": "管理",
|
||||
"test": "检测",
|
||||
"addAllLlm": "添加列表中的所有模型",
|
||||
"RetrieveAgain": "重新获取模型列表"
|
||||
},
|
||||
"all": "全部",
|
||||
|
||||
@@ -0,0 +1,774 @@
|
||||
<script setup lang="ts">
|
||||
import type {llmType} from '#/api';
|
||||
import type {ModelAbilityItem} from '#/views/ai/model/modelUtils/model-ability';
|
||||
import {getDefaultModelAbility} from '#/views/ai/model/modelUtils/model-ability';
|
||||
|
||||
import {computed, onMounted, reactive, ref} from 'vue';
|
||||
|
||||
import {CircleCheck, CircleClose, Delete, Edit, Loading, Select,} from '@element-plus/icons-vue';
|
||||
import {
|
||||
ElButton,
|
||||
ElEmpty,
|
||||
ElIcon,
|
||||
ElInput,
|
||||
ElMessage,
|
||||
ElMessageBox,
|
||||
ElOption,
|
||||
ElSelect,
|
||||
ElTable,
|
||||
ElTableColumn,
|
||||
ElTag,
|
||||
} from 'element-plus';
|
||||
|
||||
import {deleteLlm, getModelList, verifyModelConfig,} from '#/api/ai/llm';
|
||||
import {$t} from '#/locales';
|
||||
import ModelProviderBadge from '#/views/ai/model/ModelProviderBadge.vue';
|
||||
import {mapLlmToModelAbility} from '#/views/ai/model/modelUtils/model-ability-utils';
|
||||
|
||||
interface ProviderOption {
|
||||
id: string;
|
||||
providerName: string;
|
||||
}
|
||||
|
||||
interface FilterState {
|
||||
keyword: string;
|
||||
modelType: string;
|
||||
providerId: string;
|
||||
}
|
||||
|
||||
interface BatchDeleteResult {
|
||||
failed: Array<{ id: string; message: string }>;
|
||||
successIds: string[];
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
providers: ProviderOption[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'createModel', modelType?: string): void;
|
||||
(e: 'editModel', id: string): void;
|
||||
(e: 'refreshProviderStats'): void;
|
||||
}>();
|
||||
|
||||
const isLoading = ref(false);
|
||||
const isActionLoading = ref(false);
|
||||
const modelRows = ref<llmType[]>([]);
|
||||
const selectedRows = ref<llmType[]>([]);
|
||||
const lastErrorMessage = ref('');
|
||||
type VerifyButtonStatus = 'error' | 'idle' | 'loading' | 'success';
|
||||
const verifyStatusMap = ref<Record<string, VerifyButtonStatus>>({});
|
||||
|
||||
const filterState = reactive<FilterState>({
|
||||
keyword: '',
|
||||
modelType: '',
|
||||
providerId: '',
|
||||
});
|
||||
|
||||
const modelTypeOptions = [
|
||||
{
|
||||
label: $t('llmProvider.chatModel'),
|
||||
value: 'chatModel',
|
||||
},
|
||||
{
|
||||
label: $t('llmProvider.embeddingModel'),
|
||||
value: 'embeddingModel',
|
||||
},
|
||||
{
|
||||
label: $t('llmProvider.rerankModel'),
|
||||
value: 'rerankModel',
|
||||
},
|
||||
];
|
||||
|
||||
const modelTypeLabelMap: Record<string, string> = {
|
||||
chatModel: $t('llmProvider.chatModel'),
|
||||
embeddingModel: $t('llmProvider.embeddingModel'),
|
||||
rerankModel: $t('llmProvider.rerankModel'),
|
||||
};
|
||||
|
||||
const getProviderName = (row: llmType) =>
|
||||
row.modelProvider?.providerName || row.aiLlmProvider?.providerName || '-';
|
||||
|
||||
const getProviderType = (row: llmType) =>
|
||||
row.modelProvider?.providerType || row.aiLlmProvider?.providerType || '';
|
||||
|
||||
const getProviderIcon = (row: llmType) =>
|
||||
row.modelProvider?.icon || row.aiLlmProvider?.icon || '';
|
||||
|
||||
const getModelName = (row: llmType) =>
|
||||
row.llmModel || (row as any).modelName || '-';
|
||||
|
||||
const getModelTypeLabel = (row: llmType) =>
|
||||
modelTypeLabelMap[row.modelType] || row.modelType || '-';
|
||||
|
||||
const canVerify = (row: llmType) => row.modelType !== 'rerankModel';
|
||||
const getModelId = (row: llmType) =>
|
||||
String((row as any).id || (row as any).llmId || (row as any).modelId || '');
|
||||
const getVerifyStatus = (row: llmType): VerifyButtonStatus =>
|
||||
verifyStatusMap.value[getModelId(row)] || 'idle';
|
||||
const setVerifyStatus = (id: string, status: VerifyButtonStatus) => {
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
verifyStatusMap.value[id] = status;
|
||||
};
|
||||
const isVerifying = (row: llmType) => getVerifyStatus(row) === 'loading';
|
||||
const getVerifyButtonText = (row: llmType) => {
|
||||
const status = getVerifyStatus(row);
|
||||
if (status === 'loading') {
|
||||
return '验证中';
|
||||
}
|
||||
if (status === 'success') {
|
||||
return '验证成功';
|
||||
}
|
||||
if (status === 'error') {
|
||||
return '验证失败';
|
||||
}
|
||||
return '验证配置';
|
||||
};
|
||||
const getVerifyButtonIcon = (row: llmType) => {
|
||||
const status = getVerifyStatus(row);
|
||||
if (status === 'loading') {
|
||||
return Loading;
|
||||
}
|
||||
if (status === 'success') {
|
||||
return CircleCheck;
|
||||
}
|
||||
if (status === 'error') {
|
||||
return CircleClose;
|
||||
}
|
||||
return Select;
|
||||
};
|
||||
|
||||
const getAbilityTags = (row: llmType): ModelAbilityItem[] =>
|
||||
mapLlmToModelAbility(row, getDefaultModelAbility()).filter(
|
||||
(tag) => tag.selected,
|
||||
);
|
||||
|
||||
const totalCount = computed(() => modelRows.value.length);
|
||||
|
||||
const filteredRows = computed(() => {
|
||||
const keyword = filterState.keyword.trim().toLowerCase();
|
||||
|
||||
return modelRows.value.filter((item) => {
|
||||
if (
|
||||
filterState.providerId &&
|
||||
String((item as any).providerId) !== filterState.providerId
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (filterState.modelType && item.modelType !== filterState.modelType) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!keyword) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const searchTargets = [
|
||||
item.title,
|
||||
getModelName(item),
|
||||
item.groupName,
|
||||
getProviderName(item),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.map((text) => String(text).toLowerCase());
|
||||
|
||||
return searchTargets.some((text) => text.includes(keyword));
|
||||
});
|
||||
});
|
||||
|
||||
const selectableRowCount = computed(() => selectedRows.value.length);
|
||||
|
||||
const loadModels = async () => {
|
||||
isLoading.value = true;
|
||||
lastErrorMessage.value = '';
|
||||
|
||||
try {
|
||||
const res = await getModelList();
|
||||
|
||||
if (res.errorCode === 0) {
|
||||
modelRows.value = res.data || [];
|
||||
verifyStatusMap.value = {};
|
||||
} else {
|
||||
modelRows.value = [];
|
||||
verifyStatusMap.value = {};
|
||||
lastErrorMessage.value =
|
||||
res.message || $t('ui.actionMessage.operationFailed');
|
||||
}
|
||||
} catch (error) {
|
||||
modelRows.value = [];
|
||||
verifyStatusMap.value = {};
|
||||
lastErrorMessage.value =
|
||||
(error as Error)?.message || $t('ui.actionMessage.operationFailed');
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const reloadAndNotify = async () => {
|
||||
await loadModels();
|
||||
emit('refreshProviderStats');
|
||||
selectedRows.value = [];
|
||||
};
|
||||
|
||||
const handleSelectionChange = (rows: llmType[]) => {
|
||||
selectedRows.value = rows;
|
||||
};
|
||||
|
||||
const handleEdit = (row: llmType) => {
|
||||
const modelId = getModelId(row);
|
||||
if (!modelId) {
|
||||
ElMessage.warning('当前模型缺少ID,无法编辑');
|
||||
return;
|
||||
}
|
||||
emit('editModel', modelId);
|
||||
};
|
||||
|
||||
const handleDelete = async (row: llmType) => {
|
||||
const modelId = getModelId(row);
|
||||
if (!modelId) {
|
||||
ElMessage.warning('当前模型缺少ID,无法删除');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确认删除模型「${row.title}」吗?该操作不可恢复。`,
|
||||
$t('message.noticeTitle'),
|
||||
{
|
||||
cancelButtonText: $t('message.cancel'),
|
||||
confirmButtonText: $t('message.ok'),
|
||||
type: 'warning',
|
||||
},
|
||||
);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
isActionLoading.value = true;
|
||||
|
||||
try {
|
||||
const res = await deleteLlm({ id: modelId });
|
||||
|
||||
if (res.errorCode === 0) {
|
||||
ElMessage.success(res.message || '模型已删除');
|
||||
await reloadAndNotify();
|
||||
} else {
|
||||
ElMessage.error(res.message || $t('ui.actionMessage.operationFailed'));
|
||||
}
|
||||
} finally {
|
||||
isActionLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleVerify = async (row: llmType) => {
|
||||
const modelId = getModelId(row);
|
||||
|
||||
if (!canVerify(row) || !modelId || isVerifying(row)) {
|
||||
if (!modelId) {
|
||||
ElMessage.warning('当前模型缺少ID,无法验证配置');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setVerifyStatus(modelId, 'loading');
|
||||
|
||||
try {
|
||||
const res = await verifyModelConfig(modelId);
|
||||
|
||||
if (res.errorCode === 0) {
|
||||
setVerifyStatus(modelId, 'success');
|
||||
if (row.modelType === 'embeddingModel' && res?.data?.dimension) {
|
||||
ElMessage.success(`验证成功,向量维度:${res.data.dimension}`);
|
||||
} else {
|
||||
ElMessage.success('验证成功');
|
||||
}
|
||||
} else {
|
||||
setVerifyStatus(modelId, 'error');
|
||||
if (!res.message) {
|
||||
ElMessage.error($t('ui.actionMessage.operationFailed'));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
setVerifyStatus(modelId, 'error');
|
||||
// error toast is already handled by global response interceptors
|
||||
} finally {
|
||||
// keep final status to show explicit success/failure state
|
||||
}
|
||||
};
|
||||
|
||||
const runBatchDelete = async (
|
||||
ids: string[],
|
||||
concurrency = 5,
|
||||
): Promise<BatchDeleteResult> => {
|
||||
const queue = [...ids];
|
||||
const successIds: string[] = [];
|
||||
const failed: Array<{ id: string; message: string }> = [];
|
||||
|
||||
const worker = async () => {
|
||||
while (queue.length > 0) {
|
||||
const id = queue.shift();
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await deleteLlm({ id });
|
||||
if (res.errorCode === 0) {
|
||||
successIds.push(id);
|
||||
} else {
|
||||
failed.push({ id, message: res.message || '删除失败' });
|
||||
}
|
||||
} catch (error) {
|
||||
failed.push({ id, message: (error as Error)?.message || '网络错误' });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const workerCount = Math.max(1, Math.min(concurrency, ids.length || 1));
|
||||
await Promise.all(Array.from({ length: workerCount }, () => worker()));
|
||||
|
||||
return {
|
||||
failed,
|
||||
successIds,
|
||||
};
|
||||
};
|
||||
|
||||
const handleBatchDelete = async () => {
|
||||
const ids = selectedRows.value
|
||||
.map((item) => getModelId(item))
|
||||
.filter(Boolean);
|
||||
|
||||
if (ids.length === 0) {
|
||||
ElMessage.warning('请先选择要删除的模型');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确认批量删除 ${ids.length} 个模型吗?该操作不可恢复。`,
|
||||
$t('message.noticeTitle'),
|
||||
{
|
||||
cancelButtonText: $t('message.cancel'),
|
||||
confirmButtonText: $t('message.ok'),
|
||||
type: 'warning',
|
||||
},
|
||||
);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
isActionLoading.value = true;
|
||||
|
||||
try {
|
||||
const result = await runBatchDelete(ids, 5);
|
||||
const successCount = result.successIds.length;
|
||||
const failCount = result.failed.length;
|
||||
|
||||
if (failCount === 0) {
|
||||
ElMessage.success(`批量删除完成,共 ${successCount} 个模型`);
|
||||
} else {
|
||||
ElMessage.warning(
|
||||
`批量删除完成,成功 ${successCount} 个,失败 ${failCount} 个`,
|
||||
);
|
||||
}
|
||||
|
||||
await reloadAndNotify();
|
||||
} finally {
|
||||
isActionLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
filterState.keyword = '';
|
||||
filterState.providerId = '';
|
||||
filterState.modelType = '';
|
||||
};
|
||||
|
||||
onMounted(loadModels);
|
||||
|
||||
defineExpose({
|
||||
async reloadData() {
|
||||
await loadModels();
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="active-workspace">
|
||||
<header class="active-workspace__header">
|
||||
<div class="active-workspace__summary">
|
||||
<h3>已配置模型</h3>
|
||||
<p>共 {{ totalCount }} 个模型</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="active-workspace__filters">
|
||||
<ElInput
|
||||
v-model.trim="filterState.keyword"
|
||||
clearable
|
||||
placeholder="搜索模型名、模型ID、服务商、分组"
|
||||
/>
|
||||
<ElSelect
|
||||
v-model="filterState.providerId"
|
||||
clearable
|
||||
placeholder="全部服务商"
|
||||
>
|
||||
<ElOption
|
||||
v-for="provider in props.providers"
|
||||
:key="provider.id"
|
||||
:label="provider.providerName"
|
||||
:value="provider.id"
|
||||
/>
|
||||
</ElSelect>
|
||||
<ElSelect
|
||||
v-model="filterState.modelType"
|
||||
clearable
|
||||
placeholder="全部模型类型"
|
||||
>
|
||||
<ElOption
|
||||
v-for="type in modelTypeOptions"
|
||||
:key="type.value"
|
||||
:label="type.label"
|
||||
:value="type.value"
|
||||
/>
|
||||
</ElSelect>
|
||||
<ElButton @click="resetFilters">重置</ElButton>
|
||||
</div>
|
||||
|
||||
<div v-if="selectableRowCount > 0" class="active-workspace__batch-bar">
|
||||
<span>已选 {{ selectableRowCount }} 项</span>
|
||||
<div class="active-workspace__batch-actions">
|
||||
<ElButton
|
||||
class="is-danger"
|
||||
:disabled="isActionLoading"
|
||||
@click="handleBatchDelete"
|
||||
>
|
||||
批量删除
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isLoading" class="active-workspace__state">
|
||||
正在加载模型数据...
|
||||
</div>
|
||||
|
||||
<div v-else-if="lastErrorMessage" class="active-workspace__state is-error">
|
||||
{{ lastErrorMessage }}
|
||||
</div>
|
||||
|
||||
<div v-else-if="modelRows.length === 0" class="active-workspace__empty">
|
||||
<ElEmpty description="还没有模型,先添加一个模型开始使用。">
|
||||
<ElButton type="primary" @click="emit('createModel', 'chatModel')">
|
||||
添加模型
|
||||
</ElButton>
|
||||
</ElEmpty>
|
||||
</div>
|
||||
|
||||
<div v-else-if="filteredRows.length === 0" class="active-workspace__empty">
|
||||
<ElEmpty description="没有符合筛选条件的模型,试试调整筛选项。" />
|
||||
</div>
|
||||
|
||||
<ElTable
|
||||
v-else
|
||||
row-key="id"
|
||||
:data="filteredRows"
|
||||
class="active-workspace__table"
|
||||
@selection-change="handleSelectionChange"
|
||||
>
|
||||
<ElTableColumn type="selection" width="48" />
|
||||
|
||||
<ElTableColumn label="模型名称" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<div class="active-workspace__name-with-logo">
|
||||
<ModelProviderBadge
|
||||
:icon="getProviderIcon(row)"
|
||||
:provider-name="getProviderName(row)"
|
||||
:provider-type="getProviderType(row)"
|
||||
:size="30"
|
||||
/>
|
||||
<div class="active-workspace__name-cell">
|
||||
<strong>{{ row.title }}</strong>
|
||||
<span>{{ getModelName(row) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
|
||||
<ElTableColumn label="服务商" min-width="140">
|
||||
<template #default="{ row }">
|
||||
{{ getProviderName(row) }}
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
|
||||
<ElTableColumn label="类型" width="110">
|
||||
<template #default="{ row }">
|
||||
{{ getModelTypeLabel(row) }}
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
|
||||
<ElTableColumn label="分组" min-width="120">
|
||||
<template #default="{ row }">
|
||||
{{ row.groupName || '-' }}
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
|
||||
<ElTableColumn label="能力" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<div class="active-workspace__ability">
|
||||
<ElTag
|
||||
v-for="tag in getAbilityTags(row)"
|
||||
:key="tag.value"
|
||||
effect="plain"
|
||||
size="small"
|
||||
>
|
||||
{{ tag.label }}
|
||||
</ElTag>
|
||||
<span v-if="getAbilityTags(row).length === 0">-</span>
|
||||
</div>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
|
||||
<ElTableColumn label="操作" min-width="290" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<div class="active-workspace__actions">
|
||||
<ElButton
|
||||
v-if="canVerify(row)"
|
||||
text
|
||||
size="small"
|
||||
class="active-workspace__verify-btn"
|
||||
:class="`is-${getVerifyStatus(row)}`"
|
||||
:disabled="
|
||||
isVerifying(row) || isActionLoading || !getModelId(row)
|
||||
"
|
||||
@click="handleVerify(row)"
|
||||
>
|
||||
<template #icon>
|
||||
<ElIcon
|
||||
class="active-workspace__verify-icon"
|
||||
:class="`is-${getVerifyStatus(row)}`"
|
||||
>
|
||||
<component :is="getVerifyButtonIcon(row)" />
|
||||
</ElIcon>
|
||||
</template>
|
||||
{{ getVerifyButtonText(row) }}
|
||||
</ElButton>
|
||||
<ElButton text size="small" :icon="Edit" @click="handleEdit(row)">
|
||||
编辑
|
||||
</ElButton>
|
||||
<ElButton
|
||||
text
|
||||
size="small"
|
||||
class="is-danger"
|
||||
:icon="Delete"
|
||||
@click="handleDelete(row)"
|
||||
>
|
||||
删除
|
||||
</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.active-workspace {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.active-workspace__header {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid hsl(var(--divider-faint) / 58%);
|
||||
}
|
||||
|
||||
.active-workspace__summary h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--text-strong));
|
||||
}
|
||||
|
||||
.active-workspace__summary p,
|
||||
.active-workspace__state {
|
||||
margin: 6px 0 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: hsl(var(--text-muted));
|
||||
}
|
||||
|
||||
.active-workspace__filters {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 1.6fr) repeat(2, minmax(0, 1fr)) auto;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.active-workspace__batch-bar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 12px;
|
||||
background: hsl(var(--surface-contrast-soft) / 68%);
|
||||
border: 1px solid hsl(var(--divider-faint) / 58%);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.active-workspace__batch-bar span {
|
||||
font-size: 13px;
|
||||
color: hsl(var(--text-muted));
|
||||
}
|
||||
|
||||
.active-workspace__batch-actions {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.active-workspace__batch-actions .is-danger,
|
||||
.active-workspace__actions .is-danger {
|
||||
color: hsl(var(--destructive));
|
||||
}
|
||||
|
||||
.active-workspace__state {
|
||||
padding: 14px 0;
|
||||
}
|
||||
|
||||
.active-workspace__state.is-error {
|
||||
color: hsl(var(--destructive));
|
||||
}
|
||||
|
||||
.active-workspace__empty {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.active-workspace__empty :deep(.el-empty) {
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.active-workspace__table {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.active-workspace__name-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.active-workspace__name-with-logo {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.active-workspace__name-cell strong {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--text-strong));
|
||||
}
|
||||
|
||||
.active-workspace__name-cell span {
|
||||
font-size: 12px;
|
||||
color: hsl(var(--text-muted));
|
||||
}
|
||||
|
||||
.active-workspace__ability {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.active-workspace__actions {
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.active-workspace__verify-btn.is-idle {
|
||||
color: hsl(var(--text-muted));
|
||||
}
|
||||
|
||||
.active-workspace__verify-btn.is-loading {
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
.active-workspace__verify-btn.is-success {
|
||||
color: hsl(var(--success));
|
||||
}
|
||||
|
||||
.active-workspace__verify-btn.is-error {
|
||||
color: hsl(var(--destructive));
|
||||
}
|
||||
|
||||
.active-workspace__verify-icon {
|
||||
transition:
|
||||
color 0.24s ease,
|
||||
transform 0.24s ease;
|
||||
}
|
||||
|
||||
.active-workspace__verify-icon.is-loading {
|
||||
animation: active-workspace-verify-spin 0.9s linear infinite;
|
||||
}
|
||||
|
||||
.active-workspace__verify-icon.is-success,
|
||||
.active-workspace__verify-icon.is-error {
|
||||
animation: active-workspace-verify-pop 0.32s ease;
|
||||
}
|
||||
|
||||
@keyframes active-workspace-verify-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes active-workspace-verify-pop {
|
||||
0% {
|
||||
opacity: 0.72;
|
||||
transform: scale(0.82);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.active-workspace__filters {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.active-workspace__header {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.active-workspace__filters {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.active-workspace__batch-bar {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -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>
|
||||
|
||||
@@ -1,41 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue';
|
||||
import {computed, reactive, ref} from 'vue';
|
||||
|
||||
import { EasyFlowFormModal } from '@easyflow/common-ui';
|
||||
import {EasyFlowFormModal} from '@easyflow/common-ui';
|
||||
|
||||
import {
|
||||
ElForm,
|
||||
ElFormItem,
|
||||
ElInput,
|
||||
ElMessage,
|
||||
ElOption,
|
||||
ElSelect,
|
||||
} from 'element-plus';
|
||||
import {ArrowDown, ArrowUp} from '@element-plus/icons-vue';
|
||||
import {ElForm, ElFormItem, ElIcon, ElInput, ElMessage, ElOption, ElSelect,} from 'element-plus';
|
||||
|
||||
import { api } from '#/api/request';
|
||||
import {api} from '#/api/request';
|
||||
import UploadAvatar from '#/components/upload/UploadAvatar.vue';
|
||||
import { $t } from '#/locales';
|
||||
import providerList from '#/views/ai/model/modelUtils/providerList.json';
|
||||
import {$t} from '#/locales';
|
||||
import ModelProviderBadge from '#/views/ai/model/ModelProviderBadge.vue';
|
||||
import {getProviderPresetByValue, providerPresets,} from '#/views/ai/model/modelUtils/defaultIcon';
|
||||
|
||||
const emit = defineEmits(['reload']);
|
||||
|
||||
const formDataRef = ref();
|
||||
|
||||
defineExpose({
|
||||
openAddDialog() {
|
||||
formDataRef.value?.resetFields();
|
||||
dialogVisible.value = true;
|
||||
},
|
||||
openEditDialog(item: any) {
|
||||
dialogVisible.value = true;
|
||||
isAdd.value = false;
|
||||
Object.assign(formData, item);
|
||||
},
|
||||
});
|
||||
const providerOptions =
|
||||
ref<Array<{ label: string; options: any; value: string }>>(providerList);
|
||||
const isAdd = ref(true);
|
||||
const dialogVisible = ref(false);
|
||||
const btnLoading = ref(false);
|
||||
const isAdd = ref(true);
|
||||
const showAdvanced = ref(false);
|
||||
|
||||
const formData = reactive({
|
||||
id: '',
|
||||
icon: '',
|
||||
@@ -47,9 +31,64 @@ const formData = reactive({
|
||||
embedPath: '',
|
||||
rerankPath: '',
|
||||
});
|
||||
|
||||
const selectedPreset = computed(() =>
|
||||
getProviderPresetByValue(formData.providerType),
|
||||
);
|
||||
|
||||
const hasLegacyProvider = computed(
|
||||
() => !selectedPreset.value && !!formData.providerType,
|
||||
);
|
||||
|
||||
const getModeLabel = (mode?: 'hosted' | 'self-hosted') =>
|
||||
mode === 'self-hosted' ? '自部署' : '云服务';
|
||||
|
||||
const resetFormData = () => {
|
||||
Object.assign(formData, {
|
||||
id: '',
|
||||
icon: '',
|
||||
providerName: '',
|
||||
providerType: '',
|
||||
apiKey: '',
|
||||
endpoint: '',
|
||||
chatPath: '',
|
||||
embedPath: '',
|
||||
rerankPath: '',
|
||||
});
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
openAddDialog() {
|
||||
isAdd.value = true;
|
||||
dialogVisible.value = true;
|
||||
showAdvanced.value = false;
|
||||
formDataRef.value?.resetFields();
|
||||
resetFormData();
|
||||
},
|
||||
openEditDialog(item: any) {
|
||||
isAdd.value = false;
|
||||
dialogVisible.value = true;
|
||||
showAdvanced.value = false;
|
||||
formDataRef.value?.clearValidate();
|
||||
resetFormData();
|
||||
Object.assign(formData, {
|
||||
...item,
|
||||
icon: item.icon || '',
|
||||
providerName: item.providerName || '',
|
||||
providerType: item.providerType || '',
|
||||
apiKey: item.apiKey || '',
|
||||
endpoint: item.endpoint || '',
|
||||
chatPath: item.chatPath || '',
|
||||
embedPath: item.embedPath || '',
|
||||
rerankPath: item.rerankPath || '',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const closeDialog = () => {
|
||||
dialogVisible.value = false;
|
||||
};
|
||||
|
||||
const rules = {
|
||||
providerName: [
|
||||
{
|
||||
@@ -59,6 +98,20 @@ const rules = {
|
||||
},
|
||||
],
|
||||
providerType: [
|
||||
{
|
||||
required: true,
|
||||
message: $t('message.required'),
|
||||
trigger: 'change',
|
||||
},
|
||||
],
|
||||
endpoint: [
|
||||
{
|
||||
required: true,
|
||||
message: $t('message.required'),
|
||||
trigger: 'blur',
|
||||
},
|
||||
],
|
||||
chatPath: [
|
||||
{
|
||||
required: true,
|
||||
message: $t('message.required'),
|
||||
@@ -66,48 +119,47 @@ const rules = {
|
||||
},
|
||||
],
|
||||
};
|
||||
const btnLoading = ref(false);
|
||||
|
||||
const applyPreset = (value: string) => {
|
||||
const preset = getProviderPresetByValue(value);
|
||||
if (!preset) {
|
||||
return;
|
||||
}
|
||||
|
||||
formData.providerType = preset.value;
|
||||
formData.providerName = preset.label;
|
||||
formData.endpoint = preset.options.llmEndpoint || '';
|
||||
formData.chatPath = preset.options.chatPath || '';
|
||||
formData.embedPath = preset.options.embedPath || '';
|
||||
formData.rerankPath = preset.options.rerankPath || '';
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
btnLoading.value = true;
|
||||
|
||||
try {
|
||||
if (!isAdd.value) {
|
||||
api.post('/api/v1/modelProvider/update', formData).then((res) => {
|
||||
if (res.errorCode === 0) {
|
||||
ElMessage.success(res.message);
|
||||
emit('reload');
|
||||
closeDialog();
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
await formDataRef.value.validate();
|
||||
api.post('/api/v1/modelProvider/save', formData).then((res) => {
|
||||
if (res.errorCode === 0) {
|
||||
ElMessage.success(res.message);
|
||||
emit('reload');
|
||||
closeDialog();
|
||||
}
|
||||
});
|
||||
|
||||
const url = isAdd.value
|
||||
? '/api/v1/modelProvider/save'
|
||||
: '/api/v1/modelProvider/update';
|
||||
const res = await api.post(url, formData);
|
||||
|
||||
if (res.errorCode === 0) {
|
||||
ElMessage.success(res.message || '服务商已保存');
|
||||
emit('reload');
|
||||
closeDialog();
|
||||
} else {
|
||||
ElMessage.error(res.message || $t('ui.actionMessage.operationFailed'));
|
||||
}
|
||||
} catch (error) {
|
||||
if (!(error as any)?.fields) {
|
||||
ElMessage.error($t('ui.actionMessage.operationFailed'));
|
||||
}
|
||||
} finally {
|
||||
btnLoading.value = false;
|
||||
}
|
||||
};
|
||||
const handleChangeProvider = (val: string) => {
|
||||
const tempProvider = providerList.find((item) => item.value === val);
|
||||
if (!tempProvider) {
|
||||
return;
|
||||
}
|
||||
formData.providerName = tempProvider.label;
|
||||
formData.endpoint = providerOptions.value.find(
|
||||
(item) => item.value === val,
|
||||
)?.options.llmEndpoint;
|
||||
formData.chatPath = providerOptions.value.find(
|
||||
(item) => item.value === val,
|
||||
)?.options.chatPath;
|
||||
formData.embedPath = providerOptions.value.find(
|
||||
(item) => item.value === val,
|
||||
)?.options.embedPath;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -115,9 +167,9 @@ const handleChangeProvider = (val: string) => {
|
||||
v-model:open="dialogVisible"
|
||||
:centered="true"
|
||||
:closable="!btnLoading"
|
||||
:title="isAdd ? $t('button.add') : $t('button.edit')"
|
||||
:title="isAdd ? '添加服务商' : '编辑服务商'"
|
||||
:before-close="closeDialog"
|
||||
width="482"
|
||||
width="680"
|
||||
:confirm-loading="btnLoading"
|
||||
:confirm-text="$t('button.save')"
|
||||
:submitting="btnLoading"
|
||||
@@ -129,63 +181,259 @@ const handleChangeProvider = (val: string) => {
|
||||
status-icon
|
||||
:rules="rules"
|
||||
label-position="top"
|
||||
class="easyflow-modal-form easyflow-modal-form--compact"
|
||||
class="provider-modal"
|
||||
>
|
||||
<ElFormItem
|
||||
prop="icon"
|
||||
style="display: flex; align-items: center"
|
||||
:label="$t('llmProvider.icon')"
|
||||
>
|
||||
<UploadAvatar v-model="formData.icon" />
|
||||
</ElFormItem>
|
||||
<ElFormItem prop="providerName" :label="$t('llmProvider.providerName')">
|
||||
<ElInput v-model.trim="formData.providerName" />
|
||||
</ElFormItem>
|
||||
<ElFormItem prop="provider" :label="$t('llmProvider.apiType')">
|
||||
<ElSelect
|
||||
v-model="formData.providerType"
|
||||
@change="handleChangeProvider"
|
||||
>
|
||||
<ElOption
|
||||
v-for="item in providerOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value || ''"
|
||||
<section class="provider-modal__section">
|
||||
<div class="provider-modal__section-head">
|
||||
<h3>服务商预设</h3>
|
||||
<p>先选择预设,系统会自动回填推荐网关与默认路径。</p>
|
||||
</div>
|
||||
|
||||
<ElFormItem prop="providerType" :label="$t('llmProvider.apiType')">
|
||||
<ElSelect
|
||||
v-model="formData.providerType"
|
||||
filterable
|
||||
placeholder="选择一个服务商预设"
|
||||
popper-class="provider-preset-select-dropdown"
|
||||
@change="applyPreset"
|
||||
>
|
||||
<template #prefix>
|
||||
<ModelProviderBadge
|
||||
v-if="selectedPreset"
|
||||
:provider-name="selectedPreset.label"
|
||||
:provider-type="selectedPreset.value"
|
||||
:size="20"
|
||||
/>
|
||||
</template>
|
||||
<ElOption
|
||||
v-for="item in providerPresets"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
>
|
||||
<div class="provider-preset-option">
|
||||
<div class="provider-preset-option__meta">
|
||||
<ModelProviderBadge
|
||||
:provider-name="item.label"
|
||||
:provider-type="item.value"
|
||||
:size="24"
|
||||
/>
|
||||
<strong>{{ item.label }}</strong>
|
||||
</div>
|
||||
<span>{{ getModeLabel(item.mode) }}</span>
|
||||
</div>
|
||||
</ElOption>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
|
||||
<div v-if="hasLegacyProvider" class="provider-modal__legacy">
|
||||
当前类型为
|
||||
{{
|
||||
formData.providerType
|
||||
}},该类型不在最新预设列表中,但仍可继续编辑。
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="provider-modal__section">
|
||||
<div class="provider-modal__section-head">
|
||||
<h3>基础接入</h3>
|
||||
<p>先完成主流程字段:名称、密钥、Endpoint、对话路径。</p>
|
||||
</div>
|
||||
|
||||
<div class="provider-modal__grid">
|
||||
<ElFormItem
|
||||
prop="providerName"
|
||||
:label="$t('llmProvider.providerName')"
|
||||
>
|
||||
<ElInput
|
||||
v-model.trim="formData.providerName"
|
||||
placeholder="例如:OpenAI 生产环境"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem prop="apiKey" :label="$t('llmProvider.apiKey')">
|
||||
<ElInput
|
||||
v-model.trim="formData.apiKey"
|
||||
type="password"
|
||||
show-password
|
||||
placeholder="填写服务商密钥"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</div>
|
||||
|
||||
<ElFormItem prop="endpoint" :label="$t('llmProvider.endpoint')">
|
||||
<ElInput
|
||||
v-model.trim="formData.endpoint"
|
||||
placeholder="填写网关地址"
|
||||
/>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
<ElFormItem prop="apiKey" :label="$t('llmProvider.apiKey')">
|
||||
<ElInput v-model.trim="formData.apiKey" />
|
||||
</ElFormItem>
|
||||
<ElFormItem prop="endpoint" :label="$t('llmProvider.endpoint')">
|
||||
<ElInput v-model.trim="formData.endpoint" />
|
||||
</ElFormItem>
|
||||
<ElFormItem prop="chatPath" :label="$t('llmProvider.chatPath')">
|
||||
<ElInput v-model.trim="formData.chatPath" />
|
||||
</ElFormItem>
|
||||
<ElFormItem prop="rerankPath" :label="$t('llmProvider.rerankPath')">
|
||||
<ElInput v-model.trim="formData.rerankPath" />
|
||||
</ElFormItem>
|
||||
<ElFormItem prop="embedPath" :label="$t('llmProvider.embedPath')">
|
||||
<ElInput v-model.trim="formData.embedPath" />
|
||||
</ElFormItem>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem prop="chatPath" :label="$t('llmProvider.chatPath')">
|
||||
<ElInput
|
||||
v-model.trim="formData.chatPath"
|
||||
placeholder="对话模型请求路径"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</section>
|
||||
|
||||
<section class="provider-modal__section">
|
||||
<button
|
||||
type="button"
|
||||
class="provider-modal__advanced-toggle"
|
||||
@click="showAdvanced = !showAdvanced"
|
||||
>
|
||||
<div>
|
||||
<h3>高级设置</h3>
|
||||
<p>包含向量/重排路径、图标上传和预设说明,默认收起。</p>
|
||||
</div>
|
||||
<ElIcon>
|
||||
<ArrowUp v-if="showAdvanced" />
|
||||
<ArrowDown v-else />
|
||||
</ElIcon>
|
||||
</button>
|
||||
|
||||
<div v-if="showAdvanced" class="provider-modal__advanced">
|
||||
<div class="provider-modal__grid">
|
||||
<ElFormItem prop="embedPath" :label="$t('llmProvider.embedPath')">
|
||||
<ElInput
|
||||
v-model.trim="formData.embedPath"
|
||||
placeholder="向量模型请求路径"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem prop="rerankPath" :label="$t('llmProvider.rerankPath')">
|
||||
<ElInput
|
||||
v-model.trim="formData.rerankPath"
|
||||
placeholder="Rerank 请求路径"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</div>
|
||||
|
||||
<div class="provider-modal__advanced-grid">
|
||||
<div class="provider-modal__icon-panel">
|
||||
<h4>图标上传</h4>
|
||||
<p>用于区分同一厂商在测试、预发、生产等不同环境。</p>
|
||||
<ElFormItem prop="icon" :label="$t('llmProvider.icon')">
|
||||
<UploadAvatar v-model="formData.icon" />
|
||||
</ElFormItem>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</ElForm>
|
||||
</EasyFlowFormModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.headers-container-reduce {
|
||||
.provider-modal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.provider-modal__section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: hsl(var(--surface-panel) / 92%);
|
||||
border: 1px solid hsl(var(--divider-faint) / 54%);
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.provider-modal__section-head h3,
|
||||
.provider-modal__advanced-toggle h3,
|
||||
.provider-modal__preset-head h4,
|
||||
.provider-modal__icon-panel h4 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--text-strong));
|
||||
}
|
||||
|
||||
.provider-modal__section-head p,
|
||||
.provider-modal__advanced-toggle p,
|
||||
.provider-modal__legacy,
|
||||
.provider-modal__preset-head p,
|
||||
.provider-modal__icon-panel p {
|
||||
margin: 6px 0 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: hsl(var(--text-muted));
|
||||
}
|
||||
|
||||
.provider-preset-option {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.provider-preset-option__meta {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
.addHeadersBtn {
|
||||
|
||||
.provider-preset-option strong {
|
||||
font-size: 14px;
|
||||
color: hsl(var(--text-strong));
|
||||
}
|
||||
|
||||
.provider-preset-option span {
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
color: hsl(var(--text-muted));
|
||||
background: hsl(var(--surface-contrast-soft));
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.provider-modal__legacy {
|
||||
padding: 10px 12px;
|
||||
background: hsl(var(--warning) / 7%);
|
||||
border: 1px solid hsl(var(--warning) / 26%);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.provider-modal__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.provider-modal__advanced-toggle {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
border-style: dashed;
|
||||
border-color: var(--el-color-primary);
|
||||
border-radius: 8px;
|
||||
margin-top: 8px;
|
||||
padding: 0;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
.head-con-content {
|
||||
margin-bottom: 8px;
|
||||
align-items: center;
|
||||
|
||||
.provider-modal__advanced {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.provider-modal__advanced-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.provider-modal__icon-panel {
|
||||
padding: 12px;
|
||||
background: hsl(var(--surface-contrast-soft) / 70%);
|
||||
border: 1px solid hsl(var(--divider-faint) / 54%);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.provider-modal__grid,
|
||||
.provider-modal__advanced-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,350 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { nextTick, reactive, ref } from 'vue';
|
||||
|
||||
import { EasyFlowPanelModal } from '@easyflow/common-ui';
|
||||
|
||||
import {
|
||||
CirclePlus,
|
||||
Loading,
|
||||
Minus,
|
||||
RefreshRight,
|
||||
} from '@element-plus/icons-vue';
|
||||
import {
|
||||
ElCollapse,
|
||||
ElCollapseItem,
|
||||
ElForm,
|
||||
ElFormItem,
|
||||
ElIcon,
|
||||
ElInput,
|
||||
ElMessageBox,
|
||||
ElTabPane,
|
||||
ElTabs,
|
||||
ElTooltip,
|
||||
} from 'element-plus';
|
||||
|
||||
import { api } from '#/api/request';
|
||||
import { $t } from '#/locales';
|
||||
import ModelViewItemOperation from '#/views/ai/model/ModelViewItemOperation.vue';
|
||||
|
||||
const emit = defineEmits(['reload']);
|
||||
const tabList = ref<any>([]);
|
||||
const isLoading = ref(false);
|
||||
const chatModelTabList = [
|
||||
// {
|
||||
// label: $t('llm.all'),
|
||||
// name: 'all',
|
||||
// },
|
||||
{
|
||||
label: $t('llmProvider.chatModel'),
|
||||
name: 'chatModel',
|
||||
},
|
||||
// {
|
||||
// label: $t('llm.modelAbility.free'),
|
||||
// name: 'supportFree',
|
||||
// },
|
||||
];
|
||||
const embeddingModelTabList = [
|
||||
{
|
||||
label: $t('llmProvider.embeddingModel'),
|
||||
name: 'embeddingModel',
|
||||
},
|
||||
];
|
||||
|
||||
const rerankModelTabList = [
|
||||
{
|
||||
label: $t('llmProvider.rerankModel'),
|
||||
name: 'rerankModel',
|
||||
},
|
||||
];
|
||||
const formDataRef = ref();
|
||||
const providerInfo = ref<any>();
|
||||
const getProviderInfo = (id: string) => {
|
||||
api.get(`/api/v1/modelProvider/detail?id=${id}`).then((res) => {
|
||||
if (res.errorCode === 0) {
|
||||
providerInfo.value = res.data;
|
||||
}
|
||||
});
|
||||
};
|
||||
const modelList = ref<any>([]);
|
||||
const getLlmList = (providerId: string, modelType: string) => {
|
||||
isLoading.value = true;
|
||||
const url =
|
||||
modelType === ''
|
||||
? `/api/v1/model/selectLlmByProviderAndModelType?providerId=${providerId}&modelType=${modelType}&supportFree=true`
|
||||
: `/api/v1/model/selectLlmByProviderAndModelType?providerId=${providerId}&modelType=${modelType}&selectText=${searchFormDada.searchText}`;
|
||||
api.get(url).then((res) => {
|
||||
if (res.errorCode === 0) {
|
||||
const chatModelMap = res.data || {};
|
||||
modelList.value = Object.entries(chatModelMap).map(
|
||||
([groupName, llmList]) => ({
|
||||
groupName,
|
||||
llmList,
|
||||
}),
|
||||
);
|
||||
}
|
||||
isLoading.value = false;
|
||||
});
|
||||
};
|
||||
const selectedProviderId = ref('');
|
||||
defineExpose({
|
||||
// providerId: 供应商id, clickModelType 父组件点击的是什么类型的模型 可以是chatModel or embeddingModel
|
||||
openDialog(providerId: string, clickModelType: string) {
|
||||
switch (clickModelType) {
|
||||
case 'chatModel': {
|
||||
tabList.value = [...chatModelTabList];
|
||||
break;
|
||||
}
|
||||
case 'embeddingModel': {
|
||||
tabList.value = [...embeddingModelTabList];
|
||||
break;
|
||||
}
|
||||
case 'rerankModel': {
|
||||
tabList.value = [...rerankModelTabList];
|
||||
break;
|
||||
}
|
||||
// No default
|
||||
}
|
||||
selectedProviderId.value = providerId;
|
||||
formDataRef.value?.resetFields();
|
||||
modelList.value = [];
|
||||
activeName.value = tabList.value[0]?.name;
|
||||
getProviderInfo(providerId);
|
||||
getLlmList(providerId, clickModelType);
|
||||
dialogVisible.value = true;
|
||||
},
|
||||
openEditDialog(item: any) {
|
||||
dialogVisible.value = true;
|
||||
isAdd.value = false;
|
||||
formData.icon = item.icon;
|
||||
formData.providerName = item.providerName;
|
||||
formData.provider = item.provider;
|
||||
},
|
||||
});
|
||||
const isAdd = ref(true);
|
||||
const dialogVisible = ref(false);
|
||||
const formData = reactive({
|
||||
icon: '',
|
||||
providerName: '',
|
||||
provider: '',
|
||||
apiKey: '',
|
||||
endPoint: '',
|
||||
chatPath: '',
|
||||
embedPath: '',
|
||||
});
|
||||
const closeDialog = () => {
|
||||
dialogVisible.value = false;
|
||||
};
|
||||
const handleTabClick = async () => {
|
||||
await nextTick();
|
||||
getLlmList(providerInfo.value.id, activeName.value);
|
||||
};
|
||||
const activeName = ref('all');
|
||||
const handleGroupNameDelete = (groupName: string) => {
|
||||
ElMessageBox.confirm(
|
||||
$t('message.deleteModelGroupAlert'),
|
||||
$t('message.noticeTitle'),
|
||||
{
|
||||
confirmButtonText: $t('message.ok'),
|
||||
cancelButtonText: $t('message.cancel'),
|
||||
type: 'warning',
|
||||
},
|
||||
).then(() => {
|
||||
api
|
||||
.post(`/api/v1/model/removeByEntity`, {
|
||||
groupName,
|
||||
providerId: selectedProviderId.value,
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.errorCode === 0) {
|
||||
getLlmList(providerInfo.value.id, activeName.value);
|
||||
emit('reload');
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
const handleDeleteLlm = (id: any) => {
|
||||
ElMessageBox.confirm(
|
||||
$t('message.deleteModelAlert'),
|
||||
$t('message.noticeTitle'),
|
||||
{
|
||||
confirmButtonText: $t('message.ok'),
|
||||
cancelButtonText: $t('message.cancel'),
|
||||
type: 'warning',
|
||||
},
|
||||
).then(() => {
|
||||
api.post(`/api/v1/model/removeLlmByIds`, { id }).then((res) => {
|
||||
if (res.errorCode === 0) {
|
||||
getLlmList(providerInfo.value.id, activeName.value);
|
||||
emit('reload');
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
const handleAddLlm = (id: string) => {
|
||||
api
|
||||
.post(`/api/v1/model/update`, {
|
||||
id,
|
||||
withUsed: true,
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.errorCode === 0) {
|
||||
getLlmList(providerInfo.value.id, activeName.value);
|
||||
emit('reload');
|
||||
}
|
||||
});
|
||||
};
|
||||
const searchFormDada = reactive({
|
||||
searchText: '',
|
||||
});
|
||||
const handleAddAllLlm = () => {
|
||||
api
|
||||
.post(`/api/v1/model/addAllLlm`, {
|
||||
providerId: selectedProviderId.value,
|
||||
withUsed: true,
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.errorCode === 0) {
|
||||
getLlmList(providerInfo.value.id, activeName.value);
|
||||
emit('reload');
|
||||
}
|
||||
});
|
||||
};
|
||||
const handleRefresh = () => {
|
||||
if (isLoading.value) return;
|
||||
getLlmList(providerInfo.value.id, activeName.value);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<EasyFlowPanelModal
|
||||
v-model:open="dialogVisible"
|
||||
:centered="true"
|
||||
:title="`${providerInfo?.providerName}${$t('llmProvider.model')}`"
|
||||
:before-close="closeDialog"
|
||||
width="762"
|
||||
:show-footer="false"
|
||||
>
|
||||
<div class="manage-llm-container">
|
||||
<div class="form-container">
|
||||
<ElForm ref="formDataRef" :model="searchFormDada" status-icon>
|
||||
<ElFormItem prop="searchText">
|
||||
<div class="search-container">
|
||||
<ElInput
|
||||
v-model.trim="searchFormDada.searchText"
|
||||
@input="handleRefresh"
|
||||
:placeholder="$t('llm.searchTextPlaceholder')"
|
||||
/>
|
||||
<ElTooltip
|
||||
:content="$t('llm.button.addAllLlm')"
|
||||
placement="top"
|
||||
effect="dark"
|
||||
>
|
||||
<ElIcon
|
||||
size="20"
|
||||
@click="handleAddAllLlm"
|
||||
class="cursor-pointer"
|
||||
>
|
||||
<CirclePlus />
|
||||
</ElIcon>
|
||||
</ElTooltip>
|
||||
<ElTooltip
|
||||
:content="$t('llm.button.RetrieveAgain')"
|
||||
placement="top"
|
||||
effect="dark"
|
||||
>
|
||||
<ElIcon size="20" @click="handleRefresh" class="cursor-pointer">
|
||||
<RefreshRight />
|
||||
</ElIcon>
|
||||
</ElTooltip>
|
||||
</div>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
</div>
|
||||
<div class="llm-table-container">
|
||||
<ElTabs v-model="activeName" @tab-click="handleTabClick">
|
||||
<ElTabPane
|
||||
:label="item.label"
|
||||
:name="item.name"
|
||||
v-for="item in tabList"
|
||||
default-active="all"
|
||||
:key="item.name"
|
||||
>
|
||||
<div v-if="isLoading" class="collapse-loading">
|
||||
<ElIcon class="is-loading" size="24">
|
||||
<Loading />
|
||||
</ElIcon>
|
||||
</div>
|
||||
<div v-else>
|
||||
<ElCollapse
|
||||
expand-icon-position="left"
|
||||
v-if="modelList.length > 0"
|
||||
>
|
||||
<ElCollapseItem
|
||||
v-for="group in modelList"
|
||||
:key="group.groupName"
|
||||
:title="group.groupName"
|
||||
:name="group.groupName"
|
||||
>
|
||||
<template #title>
|
||||
<div class="flex items-center justify-between pr-2">
|
||||
<span>{{ group.groupName }}</span>
|
||||
<span>
|
||||
<ElIcon
|
||||
@click.stop="handleGroupNameDelete(group.groupName)"
|
||||
>
|
||||
<Minus />
|
||||
</ElIcon>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<ModelViewItemOperation
|
||||
:need-hidden-setting-icon="true"
|
||||
:llm-list="group.llmList"
|
||||
@delete-llm="handleDeleteLlm"
|
||||
@add-llm="handleAddLlm"
|
||||
:is-management="true"
|
||||
/>
|
||||
</ElCollapseItem>
|
||||
</ElCollapse>
|
||||
</div>
|
||||
</ElTabPane>
|
||||
</ElTabs>
|
||||
</div>
|
||||
</div>
|
||||
</EasyFlowPanelModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.manage-llm-container {
|
||||
height: 540px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.form-container {
|
||||
height: 30px;
|
||||
}
|
||||
.search-container {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.llm-table-container {
|
||||
flex: 1;
|
||||
}
|
||||
.collapse-loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 300px;
|
||||
gap: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
:deep(.el-tabs__nav-wrap::after) {
|
||||
height: 1px !important;
|
||||
background-color: #e4e7ed !important;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
155
easyflow-ui-admin/app/src/views/ai/model/ModelProviderBadge.vue
Normal file
155
easyflow-ui-admin/app/src/views/ai/model/ModelProviderBadge.vue
Normal file
@@ -0,0 +1,155 @@
|
||||
<script setup lang="ts">
|
||||
import {computed} from 'vue';
|
||||
|
||||
import {ElImage} from 'element-plus';
|
||||
|
||||
import {
|
||||
getIconByValue,
|
||||
getProviderBadgeText,
|
||||
isSvgString,
|
||||
} from '#/views/ai/model/modelUtils/defaultIcon';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
icon?: string;
|
||||
providerName?: string;
|
||||
providerType?: string;
|
||||
size?: number;
|
||||
}>(),
|
||||
{
|
||||
icon: '',
|
||||
providerName: '',
|
||||
providerType: '',
|
||||
size: 40,
|
||||
},
|
||||
);
|
||||
|
||||
const presetIcon = computed(() => getIconByValue(props.providerType));
|
||||
|
||||
const resolvedSvg = computed(() => {
|
||||
if (presetIcon.value && isSvgString(presetIcon.value)) {
|
||||
return presetIcon.value;
|
||||
}
|
||||
|
||||
return isSvgString(props.icon) ? props.icon : '';
|
||||
});
|
||||
|
||||
const resolvedImage = computed(() => {
|
||||
if (props.icon && !isSvgString(props.icon)) {
|
||||
return props.icon;
|
||||
}
|
||||
|
||||
return presetIcon.value && !isSvgString(presetIcon.value)
|
||||
? presetIcon.value
|
||||
: '';
|
||||
});
|
||||
|
||||
const badgeText = computed(() =>
|
||||
getProviderBadgeText(props.providerName, props.providerType),
|
||||
);
|
||||
|
||||
const badgeStyle = computed(() => ({
|
||||
width: `${props.size}px`,
|
||||
height: `${props.size}px`,
|
||||
}));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="provider-badge" :style="badgeStyle" :aria-hidden="true">
|
||||
<ElImage
|
||||
v-if="resolvedImage"
|
||||
:src="resolvedImage"
|
||||
fit="contain"
|
||||
class="provider-badge__image"
|
||||
/>
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<div
|
||||
v-else-if="resolvedSvg"
|
||||
class="provider-badge__svg"
|
||||
v-html="resolvedSvg"
|
||||
></div>
|
||||
<!-- eslint-enable vue/no-v-html -->
|
||||
<div v-else class="provider-badge__fallback" :title="badgeText">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle cx="7" cy="7" r="2.5" />
|
||||
<circle cx="17" cy="7" r="2.5" />
|
||||
<circle cx="12" cy="17" r="2.5" />
|
||||
<path d="M8.9 8.4L10.6 12" />
|
||||
<path d="M15.1 8.4L13.4 12" />
|
||||
<path d="M9.2 16h5.6" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.provider-badge {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(
|
||||
145deg,
|
||||
hsl(var(--surface-contrast-soft) / 96%) 0%,
|
||||
hsl(var(--surface-panel) / 98%) 100%
|
||||
);
|
||||
border: 1px solid hsl(var(--glass-border) / 58%);
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 12px 26px -22px hsl(var(--foreground) / 30%);
|
||||
}
|
||||
|
||||
.provider-badge__image,
|
||||
.provider-badge__svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.provider-badge__image {
|
||||
padding: 10%;
|
||||
}
|
||||
|
||||
.provider-badge__image :deep(img) {
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.provider-badge__svg {
|
||||
padding: 16%;
|
||||
}
|
||||
|
||||
.provider-badge__svg :deep(svg) {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.provider-badge__fallback {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
color: hsl(var(--text-strong));
|
||||
}
|
||||
|
||||
.provider-badge__fallback svg {
|
||||
width: 58%;
|
||||
height: 58%;
|
||||
}
|
||||
|
||||
.provider-badge__fallback circle,
|
||||
.provider-badge__fallback path {
|
||||
stroke: currentcolor;
|
||||
stroke-width: 1.8;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.provider-badge__fallback circle {
|
||||
fill: hsl(var(--surface-panel));
|
||||
}
|
||||
|
||||
.provider-badge__fallback path {
|
||||
fill: none;
|
||||
}
|
||||
</style>
|
||||
@@ -1,44 +1,61 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue';
|
||||
import {computed, reactive, ref} from 'vue';
|
||||
|
||||
import { EasyFlowFormModal } from '@easyflow/common-ui';
|
||||
import {EasyFlowFormModal} from '@easyflow/common-ui';
|
||||
|
||||
import {
|
||||
ElForm,
|
||||
ElFormItem,
|
||||
ElMessage,
|
||||
ElOption,
|
||||
ElSelect,
|
||||
} from 'element-plus';
|
||||
import {CircleCheckFilled, WarningFilled} from '@element-plus/icons-vue';
|
||||
import {ElForm, ElFormItem, ElMessage, ElOption, ElSelect,} from 'element-plus';
|
||||
|
||||
import { api } from '#/api/request';
|
||||
import { $t } from '#/locales';
|
||||
import {api} from '#/api/request';
|
||||
import {$t} from '#/locales';
|
||||
|
||||
type VerifyStatus = 'error' | 'idle' | 'success';
|
||||
|
||||
const options = ref<any[]>([]);
|
||||
const getLlmList = (providerId: string) => {
|
||||
api.get(`/api/v1/model/list?providerId=${providerId}`, {}).then((res) => {
|
||||
if (res.errorCode === 0) {
|
||||
options.value = res.data;
|
||||
}
|
||||
});
|
||||
};
|
||||
const modelType = ref('');
|
||||
const vectorDimension = ref('');
|
||||
const verifyStatus = ref<VerifyStatus>('idle');
|
||||
const verifyMessage = ref('');
|
||||
const formDataRef = ref();
|
||||
const dialogVisible = ref(false);
|
||||
defineExpose({
|
||||
openDialog(providerId: string) {
|
||||
formDataRef.value?.resetFields();
|
||||
modelType.value = '';
|
||||
vectorDimension.value = '';
|
||||
getLlmList(providerId);
|
||||
dialogVisible.value = true;
|
||||
},
|
||||
});
|
||||
const btnLoading = ref(false);
|
||||
|
||||
const formData = reactive({
|
||||
llmId: '',
|
||||
});
|
||||
|
||||
const resultTitle = computed(() => {
|
||||
if (verifyStatus.value === 'success') {
|
||||
return '验证成功';
|
||||
}
|
||||
|
||||
if (verifyStatus.value === 'error') {
|
||||
return '验证失败';
|
||||
}
|
||||
|
||||
return '等待验证';
|
||||
});
|
||||
|
||||
const getLlmList = async (providerId: string) => {
|
||||
const res = await api.get(`/api/v1/model/list?providerId=${providerId}`, {});
|
||||
if (res.errorCode === 0) {
|
||||
options.value = res.data;
|
||||
}
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
async openDialog(providerId: string) {
|
||||
formDataRef.value?.resetFields();
|
||||
formData.llmId = '';
|
||||
modelType.value = '';
|
||||
vectorDimension.value = '';
|
||||
verifyStatus.value = 'idle';
|
||||
verifyMessage.value = '请选择一个模型并开始验证。';
|
||||
await getLlmList(providerId);
|
||||
dialogVisible.value = true;
|
||||
},
|
||||
});
|
||||
|
||||
const rules = {
|
||||
llmId: [
|
||||
{
|
||||
@@ -49,28 +66,51 @@ const rules = {
|
||||
],
|
||||
};
|
||||
|
||||
const getModelInfo = (id: string) => {
|
||||
const current = options.value.find((item: any) => item.id === id);
|
||||
modelType.value = current?.modelType || '';
|
||||
vectorDimension.value = '';
|
||||
verifyStatus.value = 'idle';
|
||||
verifyMessage.value = '已更新待验证模型,点击确认开始检测配置。';
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
btnLoading.value = true;
|
||||
await formDataRef.value.validate();
|
||||
api
|
||||
.get(`/api/v1/model/verifyLlmConfig?id=${formData.llmId}`, {})
|
||||
.then((res) => {
|
||||
if (res.errorCode === 0) {
|
||||
ElMessage.success($t('llm.testSuccess'));
|
||||
if (modelType.value === 'embeddingModel' && res?.data?.dimension) {
|
||||
vectorDimension.value = res?.data?.dimension;
|
||||
}
|
||||
verifyStatus.value = 'idle';
|
||||
verifyMessage.value = '正在连接模型服务,请稍候...';
|
||||
|
||||
try {
|
||||
await formDataRef.value.validate();
|
||||
const res = await api.get(
|
||||
`/api/v1/model/verifyLlmConfig?id=${formData.llmId}`,
|
||||
{},
|
||||
);
|
||||
|
||||
if (res.errorCode === 0) {
|
||||
verifyStatus.value = 'success';
|
||||
verifyMessage.value = $t('llm.testSuccess');
|
||||
ElMessage.success($t('llm.testSuccess'));
|
||||
if (modelType.value === 'embeddingModel' && res?.data?.dimension) {
|
||||
vectorDimension.value = res.data.dimension;
|
||||
}
|
||||
btnLoading.value = false;
|
||||
});
|
||||
};
|
||||
const btnLoading = ref(false);
|
||||
const getModelInfo = (id: string) => {
|
||||
options.value.forEach((item: any) => {
|
||||
if (item.id === id) {
|
||||
modelType.value = item.modelType;
|
||||
} else {
|
||||
verifyStatus.value = 'error';
|
||||
verifyMessage.value =
|
||||
res.message || $t('ui.actionMessage.operationFailed');
|
||||
ElMessage.error(verifyMessage.value);
|
||||
}
|
||||
});
|
||||
} catch (error: any) {
|
||||
if (error?.fields) {
|
||||
verifyMessage.value = '请选择一个模型并开始验证。';
|
||||
return;
|
||||
}
|
||||
|
||||
verifyStatus.value = 'error';
|
||||
verifyMessage.value =
|
||||
error?.message || $t('ui.actionMessage.operationFailed');
|
||||
} finally {
|
||||
btnLoading.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -80,54 +120,147 @@ const getModelInfo = (id: string) => {
|
||||
:centered="true"
|
||||
:closable="!btnLoading"
|
||||
:title="$t('llm.verifyLlmTitle')"
|
||||
width="482"
|
||||
width="560"
|
||||
:confirm-loading="btnLoading"
|
||||
:confirm-text="$t('button.confirm')"
|
||||
: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="llmId" :label="$t('llm.modelToBeTested')">
|
||||
<ElSelect v-model="formData.llmId" @change="getModelInfo">
|
||||
<ElOption
|
||||
v-for="item in options"
|
||||
:key="item.id"
|
||||
:label="item.title"
|
||||
:value="item.id || ''"
|
||||
/>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
<ElFormItem
|
||||
v-if="modelType === 'embeddingModel' && vectorDimension"
|
||||
:label="$t('documentCollection.dimensionOfVectorModel')"
|
||||
label-width="100px"
|
||||
>
|
||||
{{ vectorDimension }}
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
<div class="verify-modal">
|
||||
<section class="verify-modal__section">
|
||||
<div class="verify-modal__section-head">
|
||||
<h3>1. 选择待验证模型</h3>
|
||||
<p>
|
||||
会用当前保存的服务商配置发起一次真实请求,帮助你确认密钥和路径是否正确。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ElForm
|
||||
ref="formDataRef"
|
||||
:model="formData"
|
||||
status-icon
|
||||
:rules="rules"
|
||||
label-position="top"
|
||||
class="verify-modal__form"
|
||||
>
|
||||
<ElFormItem prop="llmId" :label="$t('llm.modelToBeTested')">
|
||||
<ElSelect
|
||||
v-model="formData.llmId"
|
||||
filterable
|
||||
placeholder="选择一个模型"
|
||||
@change="getModelInfo"
|
||||
>
|
||||
<ElOption
|
||||
v-for="item in options"
|
||||
:key="item.id"
|
||||
:label="item.title"
|
||||
:value="item.id || ''"
|
||||
/>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
</section>
|
||||
|
||||
<section class="verify-modal__section verify-modal__section--result">
|
||||
<div class="verify-modal__section-head">
|
||||
<h3>2. 查看验证结果</h3>
|
||||
<p>成功后会返回可用状态;如果是向量模型,还会展示向量维度。</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="verify-result-card"
|
||||
:class="{
|
||||
'is-success': verifyStatus === 'success',
|
||||
'is-error': verifyStatus === 'error',
|
||||
}"
|
||||
>
|
||||
<div class="verify-result-card__icon">
|
||||
<CircleCheckFilled v-if="verifyStatus === 'success'" />
|
||||
<WarningFilled v-else />
|
||||
</div>
|
||||
<div class="verify-result-card__content">
|
||||
<h4>{{ resultTitle }}</h4>
|
||||
<p>{{ verifyMessage }}</p>
|
||||
<div
|
||||
v-if="modelType === 'embeddingModel' && vectorDimension"
|
||||
class="verify-result-card__meta"
|
||||
>
|
||||
向量维度:{{ vectorDimension }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</EasyFlowFormModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.headers-container-reduce {
|
||||
align-items: center;
|
||||
.verify-modal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
.addHeadersBtn {
|
||||
width: 100%;
|
||||
border-style: dashed;
|
||||
border-color: var(--el-color-primary);
|
||||
border-radius: 8px;
|
||||
margin-top: 8px;
|
||||
|
||||
.verify-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;
|
||||
}
|
||||
.head-con-content {
|
||||
margin-bottom: 8px;
|
||||
|
||||
.verify-modal__section-head h3,
|
||||
.verify-result-card__content h4 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--text-strong));
|
||||
}
|
||||
|
||||
.verify-modal__section-head p,
|
||||
.verify-result-card__content p,
|
||||
.verify-result-card__meta {
|
||||
margin: 6px 0 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: hsl(var(--text-muted));
|
||||
}
|
||||
|
||||
.verify-result-card {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: flex-start;
|
||||
padding: 18px;
|
||||
background: hsl(var(--surface-panel) / 90%);
|
||||
border: 1px solid hsl(var(--divider-faint) / 56%);
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.verify-result-card.is-success {
|
||||
background: hsl(var(--success) / 6%);
|
||||
border-color: hsl(var(--success) / 36%);
|
||||
}
|
||||
|
||||
.verify-result-card.is-error {
|
||||
background: hsl(var(--destructive) / 5%);
|
||||
border-color: hsl(var(--destructive) / 36%);
|
||||
}
|
||||
|
||||
.verify-result-card__icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
font-size: 18px;
|
||||
color: hsl(var(--text-strong));
|
||||
background: hsl(var(--surface-contrast-soft));
|
||||
border-radius: 999px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,201 +1,377 @@
|
||||
<script setup lang="ts">
|
||||
import type { PropType } from 'vue';
|
||||
import type {PropType} from 'vue';
|
||||
import {ref} from 'vue';
|
||||
|
||||
import type { llmType } from '#/api';
|
||||
import type { ModelAbilityItem } from '#/views/ai/model/modelUtils/model-ability';
|
||||
import type {llmType} from '#/api';
|
||||
import type {ModelAbilityItem} from '#/views/ai/model/modelUtils/model-ability';
|
||||
import {getDefaultModelAbility} from '#/views/ai/model/modelUtils/model-ability';
|
||||
|
||||
import { Minus, Plus, Setting } from '@element-plus/icons-vue';
|
||||
import { ElIcon, ElImage, ElTag } from 'element-plus';
|
||||
import {CircleCheck, CircleClose, Delete, Edit, Loading, Select,} from '@element-plus/icons-vue';
|
||||
import {ElButton, ElIcon, ElMessage, ElTag} from 'element-plus';
|
||||
|
||||
import { getIconByValue } from '#/views/ai/model/modelUtils/defaultIcon';
|
||||
import { getDefaultModelAbility } from '#/views/ai/model/modelUtils/model-ability';
|
||||
import { mapLlmToModelAbility } from '#/views/ai/model/modelUtils/model-ability-utils';
|
||||
import {verifyModelConfig} from '#/api/ai/llm';
|
||||
import ModelProviderBadge from '#/views/ai/model/ModelProviderBadge.vue';
|
||||
import {mapLlmToModelAbility} from '#/views/ai/model/modelUtils/model-ability-utils';
|
||||
|
||||
defineProps({
|
||||
const props = defineProps({
|
||||
llmList: {
|
||||
type: Array as PropType<llmType[]>,
|
||||
default: () => [],
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
needHiddenSettingIcon: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isManagement: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['deleteLlm', 'editLlm', 'addLlm', 'updateWithUsed']);
|
||||
const emit = defineEmits(['deleteLlm', 'editLlm']);
|
||||
type VerifyButtonStatus = 'error' | 'idle' | 'loading' | 'success';
|
||||
const verifyStatusMap = ref<Record<string, VerifyButtonStatus>>({});
|
||||
const getModelId = (llm: llmType) =>
|
||||
String((llm as any).id || (llm as any).llmId || (llm as any).modelId || '');
|
||||
const getVerifyStatus = (llm: llmType): VerifyButtonStatus =>
|
||||
verifyStatusMap.value[getModelId(llm)] || 'idle';
|
||||
const isVerifying = (llm: llmType) => getVerifyStatus(llm) === 'loading';
|
||||
const setVerifyStatus = (id: string, status: VerifyButtonStatus) => {
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
verifyStatusMap.value[id] = status;
|
||||
};
|
||||
const getVerifyButtonText = (llm: llmType) => {
|
||||
const status = getVerifyStatus(llm);
|
||||
if (status === 'loading') {
|
||||
return '验证中';
|
||||
}
|
||||
if (status === 'success') {
|
||||
return '验证成功';
|
||||
}
|
||||
if (status === 'error') {
|
||||
return '验证失败';
|
||||
}
|
||||
return '验证配置';
|
||||
};
|
||||
const getVerifyButtonIcon = (llm: llmType) => {
|
||||
const status = getVerifyStatus(llm);
|
||||
if (status === 'loading') {
|
||||
return Loading;
|
||||
}
|
||||
if (status === 'success') {
|
||||
return CircleCheck;
|
||||
}
|
||||
if (status === 'error') {
|
||||
return CircleClose;
|
||||
}
|
||||
return Select;
|
||||
};
|
||||
|
||||
const handleDeleteLlm = (id: string) => {
|
||||
emit('deleteLlm', id);
|
||||
};
|
||||
|
||||
const handleAddLlm = (id: string) => {
|
||||
emit('addLlm', id);
|
||||
};
|
||||
|
||||
const handleEditLlm = (id: string) => {
|
||||
emit('editLlm', id);
|
||||
};
|
||||
|
||||
// 修改该模型为未使用状态,修改数据库的with_used字段为false
|
||||
const handleUpdateWithUsedLlm = (id: string) => {
|
||||
emit('updateWithUsed', id);
|
||||
const canVerify = (llm: llmType) => llm.modelType !== 'rerankModel';
|
||||
|
||||
const handleVerifyLlm = async (llm: llmType) => {
|
||||
const modelId = getModelId(llm);
|
||||
if (!modelId || isVerifying(llm)) {
|
||||
if (!modelId) {
|
||||
ElMessage.warning('当前模型缺少ID,无法验证配置');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setVerifyStatus(modelId, 'loading');
|
||||
|
||||
try {
|
||||
const res = await verifyModelConfig(modelId);
|
||||
|
||||
if (res.errorCode === 0) {
|
||||
setVerifyStatus(modelId, 'success');
|
||||
if (llm.modelType === 'embeddingModel' && res?.data?.dimension) {
|
||||
ElMessage.success(`验证成功,向量维度:${res.data.dimension}`);
|
||||
} else {
|
||||
ElMessage.success('验证成功');
|
||||
}
|
||||
} else {
|
||||
setVerifyStatus(modelId, 'error');
|
||||
if (!res.message) {
|
||||
ElMessage.error('验证失败');
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
setVerifyStatus(modelId, 'error');
|
||||
// error toast is already handled by global response interceptors
|
||||
} finally {
|
||||
// keep final status for explicit visual feedback
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取LLM支持的选中的能力标签
|
||||
* 只返回 selected 为 true 的标签
|
||||
*/
|
||||
const getSelectedAbilityTagsForLlm = (llm: llmType): ModelAbilityItem[] => {
|
||||
const defaultAbility = getDefaultModelAbility();
|
||||
const allTags = mapLlmToModelAbility(llm, defaultAbility);
|
||||
return allTags.filter((tag) => tag.selected);
|
||||
};
|
||||
|
||||
const getModelMeta = (llm: llmType) => {
|
||||
const providerName =
|
||||
llm?.modelProvider?.providerName || llm?.aiLlmProvider?.providerName || '';
|
||||
const modelName = llm.llmModel || llm.title;
|
||||
|
||||
return `${providerName} · ${modelName}`;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-for="llm in llmList" :key="llm.id" class="container">
|
||||
<div class="llm-item">
|
||||
<div class="start">
|
||||
<ElImage
|
||||
v-if="llm.modelProvider.icon"
|
||||
:src="llm.modelProvider.icon"
|
||||
style="width: 21px; height: 21px"
|
||||
<div class="llm-list">
|
||||
<article v-for="llm in props.llmList" :key="llm.id" class="llm-item">
|
||||
<div class="llm-item__main">
|
||||
<ModelProviderBadge
|
||||
:icon="llm.modelProvider?.icon"
|
||||
:provider-name="llm.modelProvider?.providerName"
|
||||
:provider-type="llm.modelProvider?.providerType"
|
||||
:size="40"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-else
|
||||
v-html="getIconByValue(llm.modelProvider.providerType)"
|
||||
:style="{
|
||||
width: '21px',
|
||||
height: '21px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
}"
|
||||
class="svg-container"
|
||||
></div>
|
||||
|
||||
<div>{{ llm?.modelProvider?.providerName }}/{{ llm.title }}</div>
|
||||
|
||||
<!-- 模型能力 -->
|
||||
<div
|
||||
v-if="getSelectedAbilityTagsForLlm(llm).length > 0"
|
||||
class="ability-tags"
|
||||
>
|
||||
<ElTag
|
||||
v-for="tag in getSelectedAbilityTagsForLlm(llm)"
|
||||
:key="tag.value"
|
||||
class="ability-tag"
|
||||
:type="tag.activeType"
|
||||
size="small"
|
||||
>
|
||||
{{ tag.label }}
|
||||
</ElTag>
|
||||
<div class="llm-item__content">
|
||||
<div class="llm-item__headline">
|
||||
<h4 class="llm-item__title">{{ llm.title }}</h4>
|
||||
<div
|
||||
v-if="getSelectedAbilityTagsForLlm(llm).length > 0"
|
||||
class="llm-item__tags"
|
||||
>
|
||||
<ElTag
|
||||
v-for="tag in getSelectedAbilityTagsForLlm(llm)"
|
||||
:key="tag.value"
|
||||
effect="plain"
|
||||
size="small"
|
||||
class="llm-item__tag"
|
||||
>
|
||||
{{ tag.label }}
|
||||
</ElTag>
|
||||
</div>
|
||||
</div>
|
||||
<p class="llm-item__meta">{{ getModelMeta(llm) }}</p>
|
||||
<p v-if="llm.description" class="llm-item__description">
|
||||
{{ llm.description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="end">
|
||||
<ElIcon
|
||||
v-if="!needHiddenSettingIcon"
|
||||
size="16"
|
||||
@click="handleEditLlm(llm.id)"
|
||||
style="cursor: pointer"
|
||||
>
|
||||
<Setting />
|
||||
</ElIcon>
|
||||
<template v-if="!isManagement">
|
||||
<ElIcon
|
||||
size="16"
|
||||
@click="handleUpdateWithUsedLlm(llm.id)"
|
||||
style="cursor: pointer"
|
||||
>
|
||||
<Minus />
|
||||
</ElIcon>
|
||||
</template>
|
||||
|
||||
<template v-if="isManagement">
|
||||
<ElIcon
|
||||
v-if="llm.withUsed"
|
||||
size="16"
|
||||
@click="handleDeleteLlm(llm.id)"
|
||||
style="cursor: pointer"
|
||||
>
|
||||
<Minus />
|
||||
</ElIcon>
|
||||
<ElIcon
|
||||
v-else
|
||||
size="16"
|
||||
@click="handleAddLlm(llm.id)"
|
||||
style="cursor: pointer"
|
||||
>
|
||||
<Plus />
|
||||
</ElIcon>
|
||||
</template>
|
||||
<div class="llm-item__actions">
|
||||
<ElButton
|
||||
v-if="canVerify(llm)"
|
||||
text
|
||||
size="small"
|
||||
class="llm-item__verify-btn"
|
||||
:class="`is-${getVerifyStatus(llm)}`"
|
||||
:disabled="isVerifying(llm) || !getModelId(llm)"
|
||||
@click="handleVerifyLlm(llm)"
|
||||
>
|
||||
<template #icon>
|
||||
<ElIcon
|
||||
class="llm-item__verify-icon"
|
||||
:class="`is-${getVerifyStatus(llm)}`"
|
||||
>
|
||||
<component :is="getVerifyButtonIcon(llm)" />
|
||||
</ElIcon>
|
||||
</template>
|
||||
{{ getVerifyButtonText(llm) }}
|
||||
</ElButton>
|
||||
|
||||
<ElButton
|
||||
v-if="!needHiddenSettingIcon"
|
||||
text
|
||||
size="small"
|
||||
:icon="Edit"
|
||||
@click="handleEditLlm(llm.id)"
|
||||
>
|
||||
编辑
|
||||
</ElButton>
|
||||
|
||||
<ElButton
|
||||
text
|
||||
size="small"
|
||||
:icon="Delete"
|
||||
class="llm-item__danger"
|
||||
@click="handleDeleteLlm(llm.id)"
|
||||
>
|
||||
删除
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.llm-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.container {
|
||||
.llm-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.llm-item {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
padding: 16px 18px;
|
||||
background: hsl(var(--surface-panel) / 90%);
|
||||
border: 1px solid hsl(var(--divider-faint) / 54%);
|
||||
border-radius: 18px;
|
||||
transition:
|
||||
border-color 0.2s ease,
|
||||
transform 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.llm-item:hover {
|
||||
border-color: hsl(var(--primary) / 28%);
|
||||
box-shadow: 0 18px 30px -28px hsl(var(--foreground) / 34%);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.llm-item__main {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
gap: 14px;
|
||||
align-items: flex-start;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.llm-item__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.llm-item__headline {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 12px 18px;
|
||||
border-bottom: 1px solid #e4e7ed;
|
||||
}
|
||||
|
||||
.container:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.start {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.end {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
.llm-item__title {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
color: hsl(var(--text-strong));
|
||||
}
|
||||
|
||||
.ability-tags {
|
||||
.llm-item__meta,
|
||||
.llm-item__description {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: hsl(var(--text-muted));
|
||||
}
|
||||
|
||||
.llm-item__description {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.llm-item__tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.ability-tag {
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
.llm-item__tag {
|
||||
color: hsl(var(--text-strong));
|
||||
background: hsl(var(--primary) / 8%);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.svg-container {
|
||||
display: flex;
|
||||
.llm-item__actions {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.svg-container :deep(svg) {
|
||||
width: 21px;
|
||||
height: 21px;
|
||||
.llm-item__actions :deep(.el-button) {
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.llm-item__verify-btn.is-idle {
|
||||
color: hsl(var(--text-muted));
|
||||
}
|
||||
|
||||
.llm-item__verify-btn.is-loading {
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
.llm-item__verify-btn.is-success {
|
||||
color: hsl(var(--success));
|
||||
}
|
||||
|
||||
.llm-item__verify-btn.is-error {
|
||||
color: hsl(var(--destructive));
|
||||
}
|
||||
|
||||
.llm-item__verify-icon {
|
||||
transition:
|
||||
color 0.24s ease,
|
||||
transform 0.24s ease;
|
||||
}
|
||||
|
||||
.llm-item__verify-icon.is-loading {
|
||||
animation: llm-item-verify-spin 0.9s linear infinite;
|
||||
}
|
||||
|
||||
.llm-item__verify-icon.is-success,
|
||||
.llm-item__verify-icon.is-error {
|
||||
animation: llm-item-verify-pop 0.32s ease;
|
||||
}
|
||||
|
||||
.llm-item__danger {
|
||||
color: hsl(var(--danger) / 90%);
|
||||
}
|
||||
|
||||
.llm-item__danger:hover,
|
||||
.llm-item__danger:focus-visible {
|
||||
color: hsl(var(--danger));
|
||||
}
|
||||
|
||||
@keyframes llm-item-verify-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes llm-item-verify-pop {
|
||||
0% {
|
||||
opacity: 0.72;
|
||||
transform: scale(0.82);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.llm-item {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.llm-item__actions {
|
||||
justify-content: flex-start;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
import {getProviderBadgeText, getProviderPresetByValue} from '../defaultIcon';
|
||||
|
||||
describe('defaultIcon helpers', () => {
|
||||
it('可以读取新的服务商预设', () => {
|
||||
const preset = getProviderPresetByValue('self-hosted');
|
||||
|
||||
expect(preset?.label).toBe('自部署');
|
||||
expect(preset?.mode).toBe('self-hosted');
|
||||
});
|
||||
|
||||
it('未知服务商类型会回退到 providerName 文本', () => {
|
||||
expect(getProviderBadgeText('历史服务商', 'legacy')).toBe('历史');
|
||||
expect(getProviderBadgeText('', 'legacy-provider')).toBe('LE');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
import {
|
||||
createProviderDraft,
|
||||
getProviderConfigMetrics,
|
||||
isProviderDraftDirty,
|
||||
} from '../providerDraft';
|
||||
|
||||
describe('providerDraft helpers', () => {
|
||||
it('相同配置不会被识别为脏数据', () => {
|
||||
const provider = {
|
||||
apiKey: 'key',
|
||||
endpoint: 'https://api.example.com',
|
||||
chatPath: '/v1/chat/completions',
|
||||
embedPath: '/v1/embeddings',
|
||||
rerankPath: '',
|
||||
};
|
||||
|
||||
expect(isProviderDraftDirty(provider, createProviderDraft(provider))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('修改任一关键字段后会被识别为脏数据', () => {
|
||||
const provider = {
|
||||
apiKey: 'key',
|
||||
endpoint: 'https://api.example.com',
|
||||
chatPath: '/v1/chat/completions',
|
||||
embedPath: '',
|
||||
rerankPath: '',
|
||||
};
|
||||
|
||||
expect(
|
||||
isProviderDraftDirty(provider, {
|
||||
...createProviderDraft(provider),
|
||||
apiKey: 'next-key',
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('会根据预设路径计算配置完成度', () => {
|
||||
const metrics = getProviderConfigMetrics(
|
||||
{
|
||||
apiKey: 'key',
|
||||
endpoint: 'https://api.example.com',
|
||||
chatPath: '/v1/chat/completions',
|
||||
embedPath: '/v1/embeddings',
|
||||
rerankPath: '',
|
||||
},
|
||||
{
|
||||
label: '示例',
|
||||
value: 'demo',
|
||||
description: '',
|
||||
icon: '',
|
||||
mode: 'hosted',
|
||||
options: {
|
||||
chatPath: '/v1/chat/completions',
|
||||
embedPath: '/v1/embeddings',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(metrics.completed).toBe(4);
|
||||
expect(metrics.total).toBe(4);
|
||||
expect(metrics.complete).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,27 +1,66 @@
|
||||
import { ref } from 'vue';
|
||||
|
||||
import providerList from './providerList.json';
|
||||
|
||||
const providerOptions =
|
||||
ref<Array<{ icon: string; label: string; options: any; value: string }>>(
|
||||
providerList,
|
||||
);
|
||||
export interface ProviderModelPreset {
|
||||
description: string;
|
||||
label: string;
|
||||
llmModel: string;
|
||||
supportChat?: boolean;
|
||||
supportEmbed?: boolean;
|
||||
supportFunctionCalling?: boolean;
|
||||
supportRerank?: boolean;
|
||||
}
|
||||
|
||||
export interface ProviderPreset {
|
||||
description: string;
|
||||
icon: string;
|
||||
label: string;
|
||||
mode: 'hosted' | 'self-hosted';
|
||||
options: {
|
||||
chatPath?: string;
|
||||
embedPath?: string;
|
||||
llmEndpoint?: string;
|
||||
modelList?: ProviderModelPreset[];
|
||||
rerankPath?: string;
|
||||
};
|
||||
tags?: string[];
|
||||
value: string;
|
||||
}
|
||||
|
||||
const providerOptions = providerList as ProviderPreset[];
|
||||
|
||||
export const getProviderPresetByValue = (targetValue?: string) =>
|
||||
providerOptions.find((item) => item.value === targetValue);
|
||||
|
||||
/**
|
||||
* 根据传入的value,返回对应的icon属性
|
||||
* @param targetValue 要匹配的value值
|
||||
* @returns 匹配到的icon字符串,未匹配到返回空字符串
|
||||
*/
|
||||
export const getIconByValue = (targetValue: string): string => {
|
||||
const matchItem = providerOptions.value.find(
|
||||
(item) => item.value === targetValue,
|
||||
);
|
||||
|
||||
return matchItem?.icon || '';
|
||||
};
|
||||
export const getIconByValue = (targetValue: string): string =>
|
||||
getProviderPresetByValue(targetValue)?.icon || '';
|
||||
|
||||
export const isSvgString = (icon: any) => {
|
||||
if (typeof icon !== 'string') return false;
|
||||
// 简单判断:是否包含 SVG 根标签
|
||||
return icon.trim().startsWith('<svg') && icon.trim().endsWith('</svg>');
|
||||
};
|
||||
|
||||
export const getProviderBadgeText = (
|
||||
providerName?: string,
|
||||
providerType?: string,
|
||||
) => {
|
||||
const preset = getProviderPresetByValue(providerType);
|
||||
const source = providerName || preset?.label || providerType || 'AI';
|
||||
const ascii = source
|
||||
.replaceAll(/[^a-z]/gi, '')
|
||||
.slice(0, 2)
|
||||
.toUpperCase();
|
||||
|
||||
if (ascii) {
|
||||
return ascii;
|
||||
}
|
||||
|
||||
return source.replaceAll(/\s+/g, '').slice(0, 2).toUpperCase();
|
||||
};
|
||||
|
||||
export const providerPresets = providerOptions;
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import type {ProviderPreset} from './defaultIcon';
|
||||
|
||||
export const PROVIDER_EDITABLE_FIELDS = [
|
||||
'apiKey',
|
||||
'endpoint',
|
||||
'chatPath',
|
||||
'embedPath',
|
||||
'rerankPath',
|
||||
] as const;
|
||||
|
||||
export type ProviderEditableField = (typeof PROVIDER_EDITABLE_FIELDS)[number];
|
||||
|
||||
export interface ProviderDraft {
|
||||
apiKey: string;
|
||||
endpoint: string;
|
||||
chatPath: string;
|
||||
embedPath: string;
|
||||
rerankPath: string;
|
||||
}
|
||||
|
||||
type ProviderLike = Partial<Record<ProviderEditableField, string>> & {
|
||||
providerType?: string;
|
||||
};
|
||||
|
||||
export const createProviderDraft = (
|
||||
provider?: null | ProviderLike,
|
||||
): ProviderDraft => ({
|
||||
apiKey: provider?.apiKey || '',
|
||||
endpoint: provider?.endpoint || '',
|
||||
chatPath: provider?.chatPath || '',
|
||||
embedPath: provider?.embedPath || '',
|
||||
rerankPath: provider?.rerankPath || '',
|
||||
});
|
||||
|
||||
export const isProviderDraftDirty = (
|
||||
provider?: null | ProviderLike,
|
||||
draft?: null | ProviderLike,
|
||||
) =>
|
||||
PROVIDER_EDITABLE_FIELDS.some(
|
||||
(field) => (provider?.[field] || '') !== (draft?.[field] || ''),
|
||||
);
|
||||
|
||||
export const getProviderConfigMetrics = (
|
||||
provider?: null | ProviderLike,
|
||||
preset?: ProviderPreset,
|
||||
) => {
|
||||
const requiredFields: ProviderEditableField[] = [
|
||||
'apiKey',
|
||||
'endpoint',
|
||||
'chatPath',
|
||||
];
|
||||
|
||||
if (preset?.options?.embedPath || provider?.embedPath) {
|
||||
requiredFields.push('embedPath');
|
||||
}
|
||||
|
||||
if (preset?.options?.rerankPath || provider?.rerankPath) {
|
||||
requiredFields.push('rerankPath');
|
||||
}
|
||||
|
||||
const completed = requiredFields.filter((field) =>
|
||||
Boolean((provider?.[field] || '').trim()),
|
||||
).length;
|
||||
const total = requiredFields.length;
|
||||
|
||||
return {
|
||||
completed,
|
||||
total,
|
||||
ratio: total === 0 ? 0 : Math.round((completed / total) * 100),
|
||||
complete: total > 0 && completed === total,
|
||||
};
|
||||
};
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user