perf: 模型管理界面重做

This commit is contained in:
2026-03-11 20:33:04 +08:00
parent 219fa566ef
commit 373d7f8201
37 changed files with 4120 additions and 2108 deletions

View File

@@ -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;

View File

@@ -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;

View File

@@ -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);
});
});

View File

@@ -58,7 +58,6 @@
"button": {
"management": "Management",
"test": "Test",
"addAllLlm": "Add models from the list",
"RetrieveAgain": "Retrieve the model list again"
},
"all": "All",

View File

@@ -55,7 +55,6 @@
"button": {
"management": "管理",
"test": "检测",
"addAllLlm": "添加列表中的所有模型",
"RetrieveAgain": "重新获取模型列表"
},
"all": "全部",

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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

View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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');
});
});

View File

@@ -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);
});
});

View File

@@ -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;

View File

@@ -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