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

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