feat(frontend): 新增模型平台管理页面并接入RAG模型配置
This commit is contained in:
62
frontend/src/api/__tests__/modelProvider.spec.ts
Normal file
62
frontend/src/api/__tests__/modelProvider.spec.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
checkModelProviderHealth,
|
||||||
|
deleteModelConfig,
|
||||||
|
deleteModelProvider,
|
||||||
|
deleteModelRouteRule,
|
||||||
|
getRagStoreModelConfig,
|
||||||
|
queryModelCallLogs,
|
||||||
|
queryModelConfigs,
|
||||||
|
queryModelProviders,
|
||||||
|
queryModelRouteRules,
|
||||||
|
rebuildRagStoreIndex,
|
||||||
|
saveModelConfig,
|
||||||
|
saveModelProvider,
|
||||||
|
saveModelRouteRule,
|
||||||
|
saveRagStoreModelConfig,
|
||||||
|
} from '../modelProvider';
|
||||||
|
import { get, post } from '../request';
|
||||||
|
|
||||||
|
vi.mock('../request', () => ({
|
||||||
|
get: vi.fn(),
|
||||||
|
post: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('model provider api', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls model platform endpoints with expected params', () => {
|
||||||
|
queryModelProviders();
|
||||||
|
saveModelProvider({ providerCode: 'OPENAI_MAIN' } as never);
|
||||||
|
deleteModelProvider('1');
|
||||||
|
checkModelProviderHealth('1');
|
||||||
|
queryModelConfigs();
|
||||||
|
saveModelConfig({ modelCode: 'EMB_1' } as never);
|
||||||
|
deleteModelConfig('2');
|
||||||
|
queryModelRouteRules();
|
||||||
|
saveModelRouteRule({ routeCode: 'RAG_GLOBAL' } as never);
|
||||||
|
deleteModelRouteRule('3');
|
||||||
|
queryModelCallLogs({ taskType: 'RAG_EMBEDDING' });
|
||||||
|
getRagStoreModelConfig('10');
|
||||||
|
saveRagStoreModelConfig({ storeId: '10', embeddingModelId: '2' } as never);
|
||||||
|
rebuildRagStoreIndex('10');
|
||||||
|
|
||||||
|
expect(post).toHaveBeenCalledWith('/model/providers/query');
|
||||||
|
expect(post).toHaveBeenCalledWith('/model/providers/save', { providerCode: 'OPENAI_MAIN' });
|
||||||
|
expect(post).toHaveBeenCalledWith('/model/providers/delete', undefined, { params: { id: '1' } });
|
||||||
|
expect(post).toHaveBeenCalledWith('/model/providers/checkHealth', undefined, { params: { id: '1' } });
|
||||||
|
expect(post).toHaveBeenCalledWith('/model/configs/query');
|
||||||
|
expect(post).toHaveBeenCalledWith('/model/configs/save', { modelCode: 'EMB_1' });
|
||||||
|
expect(post).toHaveBeenCalledWith('/model/configs/delete', undefined, { params: { id: '2' } });
|
||||||
|
expect(post).toHaveBeenCalledWith('/model/routes/query');
|
||||||
|
expect(post).toHaveBeenCalledWith('/model/routes/save', { routeCode: 'RAG_GLOBAL' });
|
||||||
|
expect(post).toHaveBeenCalledWith('/model/routes/delete', undefined, { params: { id: '3' } });
|
||||||
|
expect(post).toHaveBeenCalledWith('/model/call-logs/query', { taskType: 'RAG_EMBEDDING' });
|
||||||
|
expect(get).toHaveBeenCalledWith('/rag/store/modelConfig', { params: { storeId: '10' } });
|
||||||
|
expect(post).toHaveBeenCalledWith('/rag/store/modelConfig/save', { storeId: '10', embeddingModelId: '2' });
|
||||||
|
expect(post).toHaveBeenCalledWith('/rag/store/rebuildIndex', undefined, { params: { storeId: '10' } });
|
||||||
|
});
|
||||||
|
});
|
||||||
52
frontend/src/api/modelEnums.ts
Normal file
52
frontend/src/api/modelEnums.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { listForManagement, type SysEnum } from './sysEnums';
|
||||||
|
|
||||||
|
export interface EnumOption {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const enumCache = new Map<string, EnumOption[]>();
|
||||||
|
|
||||||
|
function buildCacheKey(catalog: string, type: string) {
|
||||||
|
return `${catalog}::${type}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapOption(item: SysEnum): EnumOption {
|
||||||
|
const value = item.strvalue && item.strvalue.trim()
|
||||||
|
? item.strvalue
|
||||||
|
: item.name;
|
||||||
|
return {
|
||||||
|
label: item.name,
|
||||||
|
value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadEnumOptions(catalog: string, type: string, forceRefresh = false) {
|
||||||
|
const cacheKey = buildCacheKey(catalog, type);
|
||||||
|
if (!forceRefresh && enumCache.has(cacheKey)) {
|
||||||
|
return enumCache.get(cacheKey) ?? [];
|
||||||
|
}
|
||||||
|
const response = await listForManagement({ catalog, type, keyword: '' });
|
||||||
|
const options = (response.data ?? []).map(mapOption);
|
||||||
|
enumCache.set(cacheKey, options);
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadModelProviderEnumOptions(forceRefresh = false) {
|
||||||
|
const catalog = 'model_provider';
|
||||||
|
const types = [
|
||||||
|
'provider_type',
|
||||||
|
'protocol_type',
|
||||||
|
'auth_type',
|
||||||
|
'model_type',
|
||||||
|
'task_type',
|
||||||
|
'route_strategy',
|
||||||
|
'health_status',
|
||||||
|
'call_status',
|
||||||
|
'match_scope',
|
||||||
|
];
|
||||||
|
const entries = await Promise.all(
|
||||||
|
types.map(async (type) => [type, await loadEnumOptions(catalog, type, forceRefresh)] as const),
|
||||||
|
);
|
||||||
|
return Object.fromEntries(entries) as Record<string, EnumOption[]>;
|
||||||
|
}
|
||||||
141
frontend/src/api/modelProvider.ts
Normal file
141
frontend/src/api/modelProvider.ts
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import { get, post } from './request';
|
||||||
|
|
||||||
|
export interface ModelProvider {
|
||||||
|
id?: string;
|
||||||
|
providerCode: string;
|
||||||
|
providerName: string;
|
||||||
|
providerType: string;
|
||||||
|
protocolType: string;
|
||||||
|
baseUrl: string;
|
||||||
|
authType: string;
|
||||||
|
secretRef?: string;
|
||||||
|
hasApiKey?: boolean;
|
||||||
|
timeoutMs: number;
|
||||||
|
priority: number;
|
||||||
|
enabled: boolean;
|
||||||
|
healthStatus?: string;
|
||||||
|
remark?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModelConfig {
|
||||||
|
id?: string;
|
||||||
|
providerId: string;
|
||||||
|
modelCode: string;
|
||||||
|
modelName: string;
|
||||||
|
upstreamModel: string;
|
||||||
|
modelType: string;
|
||||||
|
embeddingDimension?: number;
|
||||||
|
localModel: boolean;
|
||||||
|
defaultModel: boolean;
|
||||||
|
optionsJson?: string;
|
||||||
|
enabled: boolean;
|
||||||
|
remark?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModelRouteRule {
|
||||||
|
id?: string;
|
||||||
|
routeCode: string;
|
||||||
|
routeName: string;
|
||||||
|
taskType: string;
|
||||||
|
matchScope: string;
|
||||||
|
scopeId?: string;
|
||||||
|
primaryModelId: string;
|
||||||
|
fallbackModelIdsJson?: string;
|
||||||
|
routeStrategy: string;
|
||||||
|
maxLatencyMs?: number;
|
||||||
|
enabled: boolean;
|
||||||
|
remark?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModelCallLog {
|
||||||
|
id?: string;
|
||||||
|
requestId: string;
|
||||||
|
providerId?: string;
|
||||||
|
modelId?: string;
|
||||||
|
taskType: string;
|
||||||
|
bizType?: string;
|
||||||
|
bizId?: string;
|
||||||
|
callType: string;
|
||||||
|
status: string;
|
||||||
|
durationMs?: number;
|
||||||
|
errorCode?: string;
|
||||||
|
errorMessage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModelCallLogQueryRequest {
|
||||||
|
taskType?: string;
|
||||||
|
providerId?: string;
|
||||||
|
modelId?: string;
|
||||||
|
status?: string;
|
||||||
|
bizType?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RagStoreModelConfig {
|
||||||
|
id?: string;
|
||||||
|
storeId: string;
|
||||||
|
embeddingModelId: string;
|
||||||
|
embeddingDimension: number;
|
||||||
|
chunkStrategy?: number;
|
||||||
|
chunkSize?: number;
|
||||||
|
chunkOverlap?: number;
|
||||||
|
delimiter?: string;
|
||||||
|
active?: boolean;
|
||||||
|
indexVersion?: number;
|
||||||
|
remark?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function queryModelProviders() {
|
||||||
|
return post<ModelProvider[]>('/model/providers/query');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveModelProvider(data: Partial<ModelProvider> & { id?: string }) {
|
||||||
|
return post<boolean>('/model/providers/save', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteModelProvider(id: string) {
|
||||||
|
return post<boolean>('/model/providers/delete', undefined, { params: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkModelProviderHealth(id: string) {
|
||||||
|
return post<boolean>('/model/providers/checkHealth', undefined, { params: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function queryModelConfigs() {
|
||||||
|
return post<ModelConfig[]>('/model/configs/query');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveModelConfig(data: Partial<ModelConfig> & { id?: string }) {
|
||||||
|
return post<boolean>('/model/configs/save', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteModelConfig(id: string) {
|
||||||
|
return post<boolean>('/model/configs/delete', undefined, { params: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function queryModelRouteRules() {
|
||||||
|
return post<ModelRouteRule[]>('/model/routes/query');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveModelRouteRule(data: Partial<ModelRouteRule> & { id?: string }) {
|
||||||
|
return post<boolean>('/model/routes/save', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteModelRouteRule(id: string) {
|
||||||
|
return post<boolean>('/model/routes/delete', undefined, { params: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function queryModelCallLogs(query?: ModelCallLogQueryRequest) {
|
||||||
|
return post<ModelCallLog[], ModelCallLogQueryRequest | undefined>('/model/call-logs/query', query);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRagStoreModelConfig(storeId: string) {
|
||||||
|
return get<RagStoreModelConfig>('/rag/store/modelConfig', { params: { storeId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveRagStoreModelConfig(data: Partial<RagStoreModelConfig> & { storeId: string }) {
|
||||||
|
return post<boolean>('/rag/store/modelConfig/save', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rebuildRagStoreIndex(storeId: string) {
|
||||||
|
return post<boolean>('/rag/store/rebuildIndex', undefined, { params: { storeId } });
|
||||||
|
}
|
||||||
@@ -6,10 +6,18 @@ import {
|
|||||||
Grid,
|
Grid,
|
||||||
Histogram,
|
Histogram,
|
||||||
List,
|
List,
|
||||||
|
Setting,
|
||||||
} from '@element-plus/icons-vue';
|
} from '@element-plus/icons-vue';
|
||||||
|
|
||||||
const menuItems = [
|
const systemMenuItems = [
|
||||||
{ path: '/system/enums', label: '系统枚举', icon: Grid },
|
{ path: '/system/enums', label: '系统枚举', icon: Grid },
|
||||||
|
{ path: '/system/model/providers', label: '模型服务商', icon: Setting },
|
||||||
|
{ path: '/system/model/configs', label: '模型配置', icon: Setting },
|
||||||
|
{ path: '/system/model/routes', label: '路由规则', icon: Setting },
|
||||||
|
{ path: '/system/model/call-logs', label: '调用日志', icon: Setting },
|
||||||
|
];
|
||||||
|
|
||||||
|
const ragMenuItems = [
|
||||||
{ path: '/rag/stores', label: '知识库', icon: Collection },
|
{ path: '/rag/stores', label: '知识库', icon: Collection },
|
||||||
{ path: '/rag/workbench', label: 'RAG工作台', icon: Histogram },
|
{ path: '/rag/workbench', label: 'RAG工作台', icon: Histogram },
|
||||||
{ path: '/rag/documents', label: '知识文档', icon: Document },
|
{ path: '/rag/documents', label: '知识文档', icon: Document },
|
||||||
@@ -28,12 +36,24 @@ const menuItems = [
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-menu class="side-menu" :default-active="$route.path" router>
|
<el-menu class="side-menu" :default-active="$route.path" router>
|
||||||
<el-menu-item v-for="item in menuItems" :key="item.path" :index="item.path">
|
<el-sub-menu index="system">
|
||||||
|
<template #title>系统管理</template>
|
||||||
|
<el-menu-item v-for="item in systemMenuItems" :key="item.path" :index="item.path">
|
||||||
<el-icon>
|
<el-icon>
|
||||||
<component :is="item.icon" />
|
<component :is="item.icon" />
|
||||||
</el-icon>
|
</el-icon>
|
||||||
<span>{{ item.label }}</span>
|
<span>{{ item.label }}</span>
|
||||||
</el-menu-item>
|
</el-menu-item>
|
||||||
|
</el-sub-menu>
|
||||||
|
<el-sub-menu index="rag">
|
||||||
|
<template #title>RAG</template>
|
||||||
|
<el-menu-item v-for="item in ragMenuItems" :key="item.path" :index="item.path">
|
||||||
|
<el-icon>
|
||||||
|
<component :is="item.icon" />
|
||||||
|
</el-icon>
|
||||||
|
<span>{{ item.label }}</span>
|
||||||
|
</el-menu-item>
|
||||||
|
</el-sub-menu>
|
||||||
</el-menu>
|
</el-menu>
|
||||||
</el-aside>
|
</el-aside>
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,14 @@ import { CirclePlus, Delete, Edit, FolderAdd, Refresh, Search, UploadFilled } fr
|
|||||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||||
import { computed, onMounted, reactive, ref } from 'vue';
|
import { computed, onMounted, reactive, ref } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
|
import { loadModelProviderEnumOptions, type EnumOption } from '@/api/modelEnums';
|
||||||
|
import {
|
||||||
|
getRagStoreModelConfig,
|
||||||
|
queryModelConfigs,
|
||||||
|
rebuildRagStoreIndex,
|
||||||
|
saveRagStoreModelConfig,
|
||||||
|
type ModelConfig,
|
||||||
|
} from '@/api/modelProvider';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
deleteRagStore,
|
deleteRagStore,
|
||||||
@@ -28,6 +36,20 @@ const activeStoreId = ref<string | null>(null);
|
|||||||
const activeStore = ref<RagStore | null>(null);
|
const activeStore = ref<RagStore | null>(null);
|
||||||
const pageOverview = ref<RagStoreOverview | null>(null);
|
const pageOverview = ref<RagStoreOverview | null>(null);
|
||||||
const activeStoreDocumentOverview = ref<RagStoreDocumentOverview | null>(null);
|
const activeStoreDocumentOverview = ref<RagStoreDocumentOverview | null>(null);
|
||||||
|
const embeddingModels = ref<ModelConfig[]>([]);
|
||||||
|
const ragConfigLoading = ref(false);
|
||||||
|
const ragConfigSaving = ref(false);
|
||||||
|
const ragConfig = reactive({
|
||||||
|
id: '',
|
||||||
|
embeddingModelId: '',
|
||||||
|
embeddingDimension: 1024,
|
||||||
|
chunkStrategy: null as number | null,
|
||||||
|
chunkSize: null as number | null,
|
||||||
|
chunkOverlap: null as number | null,
|
||||||
|
delimiter: '',
|
||||||
|
remark: '',
|
||||||
|
});
|
||||||
|
const chunkStrategyOptions = ref<EnumOption[]>([]);
|
||||||
|
|
||||||
const queryForm = reactive({
|
const queryForm = reactive({
|
||||||
storeName: '',
|
storeName: '',
|
||||||
@@ -91,6 +113,8 @@ async function loadStores(preferredStoreId?: string | null) {
|
|||||||
activeStoreId.value = null;
|
activeStoreId.value = null;
|
||||||
activeStore.value = null;
|
activeStore.value = null;
|
||||||
activeStoreDocumentOverview.value = null;
|
activeStoreDocumentOverview.value = null;
|
||||||
|
ragConfig.id = '';
|
||||||
|
ragConfig.embeddingModelId = '';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,6 +147,7 @@ async function selectStore(storeId: string) {
|
|||||||
]);
|
]);
|
||||||
activeStore.value = storeResponse.data ?? null;
|
activeStore.value = storeResponse.data ?? null;
|
||||||
activeStoreDocumentOverview.value = documentOverviewResponse.data ?? null;
|
activeStoreDocumentOverview.value = documentOverviewResponse.data ?? null;
|
||||||
|
await loadRagModelConfig(storeId);
|
||||||
} finally {
|
} finally {
|
||||||
detailLoading.value = false;
|
detailLoading.value = false;
|
||||||
}
|
}
|
||||||
@@ -223,10 +248,6 @@ async function removeStore() {
|
|||||||
await loadStores();
|
await loadStores();
|
||||||
}
|
}
|
||||||
|
|
||||||
function showFutureMessage(actionName: string) {
|
|
||||||
ElMessage.info(`${actionName} 会在下一批接口里补齐`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function openBatchUploadDialog() {
|
function openBatchUploadDialog() {
|
||||||
if (!activeStore.value?.id) {
|
if (!activeStore.value?.id) {
|
||||||
ElMessage.warning('请选择知识库');
|
ElMessage.warning('请选择知识库');
|
||||||
@@ -252,13 +273,85 @@ async function refreshAfterUpload() {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadRagModelConfig(storeId: string) {
|
||||||
|
ragConfigLoading.value = true;
|
||||||
|
try {
|
||||||
|
const response = await getRagStoreModelConfig(storeId);
|
||||||
|
const data = response.data;
|
||||||
|
ragConfig.id = data?.id ?? '';
|
||||||
|
ragConfig.embeddingModelId = data?.embeddingModelId ?? '';
|
||||||
|
ragConfig.embeddingDimension = data?.embeddingDimension ?? 1024;
|
||||||
|
ragConfig.chunkStrategy = data?.chunkStrategy ?? null;
|
||||||
|
ragConfig.chunkSize = data?.chunkSize ?? null;
|
||||||
|
ragConfig.chunkOverlap = data?.chunkOverlap ?? null;
|
||||||
|
ragConfig.delimiter = data?.delimiter ?? '';
|
||||||
|
ragConfig.remark = data?.remark ?? '';
|
||||||
|
} finally {
|
||||||
|
ragConfigLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveStoreModelConfig() {
|
||||||
|
if (!activeStoreId.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!ragConfig.embeddingModelId) {
|
||||||
|
ElMessage.warning('请选择 Embedding 模型');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ragConfigSaving.value = true;
|
||||||
|
try {
|
||||||
|
await saveRagStoreModelConfig({
|
||||||
|
id: ragConfig.id || undefined,
|
||||||
|
storeId: activeStoreId.value,
|
||||||
|
embeddingModelId: ragConfig.embeddingModelId,
|
||||||
|
embeddingDimension: ragConfig.embeddingDimension,
|
||||||
|
chunkStrategy: ragConfig.chunkStrategy ?? undefined,
|
||||||
|
chunkSize: ragConfig.chunkSize ?? undefined,
|
||||||
|
chunkOverlap: ragConfig.chunkOverlap ?? undefined,
|
||||||
|
delimiter: ragConfig.delimiter || undefined,
|
||||||
|
remark: ragConfig.remark || undefined,
|
||||||
|
});
|
||||||
|
ElMessage.success('知识库模型配置已保存');
|
||||||
|
await loadRagModelConfig(activeStoreId.value);
|
||||||
|
} finally {
|
||||||
|
ragConfigSaving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function triggerRebuildIndex() {
|
||||||
|
if (!activeStoreId.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await rebuildRagStoreIndex(activeStoreId.value);
|
||||||
|
ElMessage.success('已触发重建索引');
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncEmbeddingDimensionFromModel() {
|
||||||
|
const model = embeddingModels.value.find((item) => item.id === ragConfig.embeddingModelId);
|
||||||
|
if (!model) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (model.embeddingDimension) {
|
||||||
|
ragConfig.embeddingDimension = model.embeddingDimension;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function getStatusTagType(status?: string | null) {
|
function getStatusTagType(status?: string | null) {
|
||||||
return status === '启用' ? 'success' : 'info';
|
return status === '启用' ? 'success' : 'info';
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadOverview();
|
Promise.all([
|
||||||
loadStores();
|
loadOverview(),
|
||||||
|
loadStores(),
|
||||||
|
queryModelConfigs().then((response) => {
|
||||||
|
embeddingModels.value = (response.data ?? []).filter((item) => item.modelType === 'EMBEDDING');
|
||||||
|
}),
|
||||||
|
loadModelProviderEnumOptions().then((enums) => {
|
||||||
|
chunkStrategyOptions.value = enums.chunk_strategy ?? [];
|
||||||
|
}),
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -351,7 +444,7 @@ onMounted(() => {
|
|||||||
>
|
>
|
||||||
批量导入文件
|
批量导入文件
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button :icon="FolderAdd" @click="showFutureMessage('重建索引')">重建索引</el-button>
|
<el-button :icon="FolderAdd" @click="triggerRebuildIndex">重建索引</el-button>
|
||||||
<el-button type="danger" :icon="Delete" @click="removeStore">删除</el-button>
|
<el-button type="danger" :icon="Delete" @click="removeStore">删除</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -415,12 +508,56 @@ onMounted(() => {
|
|||||||
</el-descriptions>
|
</el-descriptions>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<article class="detail-card detail-card--placeholder">
|
<article class="detail-card">
|
||||||
<div class="detail-card__header">
|
<div class="detail-card__header">
|
||||||
<h4>检索配置</h4>
|
<h4>检索配置</h4>
|
||||||
<span>下一批接口补充</span>
|
<el-button type="primary" link :loading="ragConfigSaving" @click="saveStoreModelConfig">
|
||||||
|
保存配置
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
<div v-loading="ragConfigLoading" class="rag-config-form">
|
||||||
|
<el-form :model="ragConfig" label-width="112px">
|
||||||
|
<el-form-item label="Embedding 模型" required>
|
||||||
|
<el-select
|
||||||
|
v-model="ragConfig.embeddingModelId"
|
||||||
|
placeholder="请选择模型"
|
||||||
|
@change="syncEmbeddingDimensionFromModel"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="item in embeddingModels"
|
||||||
|
:key="item.id"
|
||||||
|
:label="`${item.modelName}(${item.modelCode})`"
|
||||||
|
:value="item.id"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="向量维度">
|
||||||
|
<el-input-number v-model="ragConfig.embeddingDimension" :min="1" controls-position="right" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="切片策略">
|
||||||
|
<el-select v-model="ragConfig.chunkStrategy" clearable placeholder="可选">
|
||||||
|
<el-option
|
||||||
|
v-for="item in chunkStrategyOptions"
|
||||||
|
:key="item.value"
|
||||||
|
:label="item.label"
|
||||||
|
:value="Number(item.value)"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="切片大小">
|
||||||
|
<el-input-number v-model="ragConfig.chunkSize" :min="1" controls-position="right" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="切片重叠">
|
||||||
|
<el-input-number v-model="ragConfig.chunkOverlap" :min="0" controls-position="right" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="分隔符">
|
||||||
|
<el-input v-model="ragConfig.delimiter" placeholder="可选,如 \\n\\n" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="备注">
|
||||||
|
<el-input v-model="ragConfig.remark" type="textarea" :rows="2" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
</div>
|
</div>
|
||||||
<el-empty description="检索模式、Embedding 模型、Chunk 参数待后端补充" />
|
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<article class="detail-card detail-card--placeholder">
|
<article class="detail-card detail-card--placeholder">
|
||||||
@@ -721,6 +858,11 @@ onMounted(() => {
|
|||||||
padding: 12px 0;
|
padding: 12px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.rag-config-form :deep(.el-select),
|
||||||
|
.rag-config-form :deep(.el-input-number) {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 1280px) {
|
@media (max-width: 1280px) {
|
||||||
.overview-grid {
|
.overview-grid {
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
|||||||
@@ -10,6 +10,12 @@ import {
|
|||||||
queryRagStores,
|
queryRagStores,
|
||||||
saveRagStore,
|
saveRagStore,
|
||||||
} from '@/api/ragStores';
|
} from '@/api/ragStores';
|
||||||
|
import {
|
||||||
|
getRagStoreModelConfig,
|
||||||
|
queryModelConfigs,
|
||||||
|
rebuildRagStoreIndex,
|
||||||
|
saveRagStoreModelConfig,
|
||||||
|
} from '@/api/modelProvider';
|
||||||
|
|
||||||
const routerPush = vi.hoisted(() => vi.fn());
|
const routerPush = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
@@ -24,6 +30,52 @@ vi.mock('@/api/ragDocuments', () => ({
|
|||||||
batchUploadRagDocuments: vi.fn(() => Promise.resolve({ resultcode: '0', message: null, data: [] })),
|
batchUploadRagDocuments: vi.fn(() => Promise.resolve({ resultcode: '0', message: null, data: [] })),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/api/modelEnums', () => ({
|
||||||
|
loadModelProviderEnumOptions: vi.fn(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
chunk_strategy: [
|
||||||
|
{ label: '固定长度', value: '1' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/api/modelProvider', () => ({
|
||||||
|
queryModelConfigs: vi.fn(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
resultcode: '0',
|
||||||
|
message: null,
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: '88',
|
||||||
|
providerId: '1',
|
||||||
|
modelCode: 'TEXT_EMBED_3_LARGE',
|
||||||
|
modelName: 'text-embedding-3-large',
|
||||||
|
modelType: 'EMBEDDING',
|
||||||
|
embeddingDimension: 1024,
|
||||||
|
localModel: false,
|
||||||
|
defaultModel: true,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
getRagStoreModelConfig: vi.fn(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
resultcode: '0',
|
||||||
|
message: null,
|
||||||
|
data: {
|
||||||
|
id: '9',
|
||||||
|
storeId: '1',
|
||||||
|
embeddingModelId: '88',
|
||||||
|
embeddingDimension: 1024,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
saveRagStoreModelConfig: vi.fn(() => Promise.resolve({ resultcode: '0', message: null, data: true })),
|
||||||
|
rebuildRagStoreIndex: vi.fn(() => Promise.resolve({ resultcode: '0', message: null, data: true })),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('@/api/ragStores', () => ({
|
vi.mock('@/api/ragStores', () => ({
|
||||||
getRagStoreOverview: vi.fn(() =>
|
getRagStoreOverview: vi.fn(() =>
|
||||||
Promise.resolve({
|
Promise.resolve({
|
||||||
@@ -142,6 +194,8 @@ describe('RagStoresPage', () => {
|
|||||||
expect(queryRagStores).toHaveBeenCalled();
|
expect(queryRagStores).toHaveBeenCalled();
|
||||||
expect(getRagStoreById).toHaveBeenCalledWith('1');
|
expect(getRagStoreById).toHaveBeenCalledWith('1');
|
||||||
expect(getRagStoreDocumentOverview).toHaveBeenCalledWith('1');
|
expect(getRagStoreDocumentOverview).toHaveBeenCalledWith('1');
|
||||||
|
expect(queryModelConfigs).toHaveBeenCalled();
|
||||||
|
expect(getRagStoreModelConfig).toHaveBeenCalledWith('1');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('filters stores by name and updates detail when a store is selected', async () => {
|
it('filters stores by name and updates detail when a store is selected', async () => {
|
||||||
@@ -225,4 +279,23 @@ describe('RagStoresPage', () => {
|
|||||||
expect(wrapper.text()).toContain('拖拽文件到此处');
|
expect(wrapper.text()).toContain('拖拽文件到此处');
|
||||||
expect(wrapper.text()).toContain('产品制度库');
|
expect(wrapper.text()).toContain('产品制度库');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('saves store model config and triggers rebuild index', async () => {
|
||||||
|
const wrapper = mount(RagStoresPage, {
|
||||||
|
global: {
|
||||||
|
plugins: [ElementPlus],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await flushPromises();
|
||||||
|
const saveButtons = wrapper.findAll('button').filter((button) => button.text().includes('保存配置'));
|
||||||
|
await saveButtons[0]?.trigger('click');
|
||||||
|
await flushPromises();
|
||||||
|
expect(saveRagStoreModelConfig).toHaveBeenCalled();
|
||||||
|
|
||||||
|
const rebuildButtons = wrapper.findAll('button').filter((button) => button.text().includes('重建索引'));
|
||||||
|
await rebuildButtons[0]?.trigger('click');
|
||||||
|
await flushPromises();
|
||||||
|
expect(rebuildRagStoreIndex).toHaveBeenCalledWith('1');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
160
frontend/src/pages/system/ModelCallLogsPage.vue
Normal file
160
frontend/src/pages/system/ModelCallLogsPage.vue
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { RefreshRight, Search } from '@element-plus/icons-vue';
|
||||||
|
import { onMounted, reactive, ref } from 'vue';
|
||||||
|
|
||||||
|
import { loadModelProviderEnumOptions, type EnumOption } from '@/api/modelEnums';
|
||||||
|
import {
|
||||||
|
queryModelCallLogs,
|
||||||
|
queryModelConfigs,
|
||||||
|
queryModelProviders,
|
||||||
|
type ModelCallLog,
|
||||||
|
type ModelConfig,
|
||||||
|
type ModelProvider,
|
||||||
|
} from '@/api/modelProvider';
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const logs = ref<ModelCallLog[]>([]);
|
||||||
|
const providers = ref<ModelProvider[]>([]);
|
||||||
|
const models = ref<ModelConfig[]>([]);
|
||||||
|
const taskTypeOptions = ref<EnumOption[]>([]);
|
||||||
|
const statusOptions = ref<EnumOption[]>([]);
|
||||||
|
const enumError = ref('');
|
||||||
|
|
||||||
|
const queryForm = reactive({
|
||||||
|
taskType: '',
|
||||||
|
providerId: '',
|
||||||
|
modelId: '',
|
||||||
|
status: '',
|
||||||
|
bizType: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadBaseData(forceRefresh = false) {
|
||||||
|
enumError.value = '';
|
||||||
|
try {
|
||||||
|
const [enumResult, providerResult, modelResult] = await Promise.all([
|
||||||
|
loadModelProviderEnumOptions(forceRefresh),
|
||||||
|
queryModelProviders(),
|
||||||
|
queryModelConfigs(),
|
||||||
|
]);
|
||||||
|
taskTypeOptions.value = enumResult.task_type ?? [];
|
||||||
|
statusOptions.value = enumResult.call_status ?? [];
|
||||||
|
providers.value = providerResult.data ?? [];
|
||||||
|
models.value = modelResult.data ?? [];
|
||||||
|
} catch (error) {
|
||||||
|
enumError.value = error instanceof Error ? error.message : '基础数据加载失败';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadLogs() {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const response = await queryModelCallLogs({
|
||||||
|
taskType: queryForm.taskType || undefined,
|
||||||
|
providerId: queryForm.providerId || undefined,
|
||||||
|
modelId: queryForm.modelId || undefined,
|
||||||
|
status: queryForm.status || undefined,
|
||||||
|
bizType: queryForm.bizType || undefined,
|
||||||
|
});
|
||||||
|
logs.value = response.data ?? [];
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetQuery() {
|
||||||
|
queryForm.taskType = '';
|
||||||
|
queryForm.providerId = '';
|
||||||
|
queryForm.modelId = '';
|
||||||
|
queryForm.status = '';
|
||||||
|
queryForm.bizType = '';
|
||||||
|
loadLogs();
|
||||||
|
}
|
||||||
|
|
||||||
|
function providerName(providerId?: string) {
|
||||||
|
return providers.value.find((item) => item.id === providerId)?.providerName ?? providerId ?? '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
function modelName(modelId?: string) {
|
||||||
|
const model = models.value.find((item) => item.id === modelId);
|
||||||
|
return model?.modelName ?? model?.modelCode ?? modelId ?? '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadBaseData();
|
||||||
|
await loadLogs();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="page-panel">
|
||||||
|
<div class="page-panel__header">
|
||||||
|
<h2>调用日志</h2>
|
||||||
|
<span>Call Logs</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toolbar">
|
||||||
|
<el-alert v-if="enumError" type="error" :closable="false" :title="`基础数据加载失败:${enumError}`" show-icon />
|
||||||
|
<el-form :model="queryForm" inline>
|
||||||
|
<el-form-item label="任务类型">
|
||||||
|
<el-select v-model="queryForm.taskType" clearable placeholder="全部">
|
||||||
|
<el-option v-for="item in taskTypeOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="服务商">
|
||||||
|
<el-select v-model="queryForm.providerId" clearable placeholder="全部">
|
||||||
|
<el-option
|
||||||
|
v-for="provider in providers"
|
||||||
|
:key="provider.id"
|
||||||
|
:label="provider.providerName"
|
||||||
|
:value="provider.id"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="模型">
|
||||||
|
<el-select v-model="queryForm.modelId" clearable placeholder="全部">
|
||||||
|
<el-option v-for="model in models" :key="model.id" :label="model.modelName" :value="model.id" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="状态">
|
||||||
|
<el-select v-model="queryForm.status" clearable placeholder="全部">
|
||||||
|
<el-option v-for="item in statusOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="业务类型">
|
||||||
|
<el-input v-model="queryForm.bizType" clearable placeholder="如 RAG" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" :icon="Search" @click="loadLogs">查询</el-button>
|
||||||
|
<el-button @click="resetQuery">重置</el-button>
|
||||||
|
<el-button :icon="RefreshRight" @click="loadBaseData(true)">重试基础数据</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-table v-loading="loading" :data="logs" row-key="id">
|
||||||
|
<el-table-column prop="requestId" label="请求ID" min-width="150" />
|
||||||
|
<el-table-column label="服务商" min-width="120">
|
||||||
|
<template #default="{ row }">{{ providerName(row.providerId) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="模型" min-width="120">
|
||||||
|
<template #default="{ row }">{{ modelName(row.modelId) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="taskType" label="任务类型" min-width="120" />
|
||||||
|
<el-table-column prop="bizType" label="业务类型" min-width="100" />
|
||||||
|
<el-table-column prop="bizId" label="业务ID" min-width="120" />
|
||||||
|
<el-table-column prop="callType" label="调用类型" min-width="100" />
|
||||||
|
<el-table-column prop="status" label="状态" min-width="90" />
|
||||||
|
<el-table-column prop="durationMs" label="耗时(ms)" width="100" />
|
||||||
|
<el-table-column prop="errorCode" label="错误码" min-width="100" />
|
||||||
|
<el-table-column prop="errorMessage" label="错误摘要" min-width="180" show-overflow-tooltip />
|
||||||
|
</el-table>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.toolbar {
|
||||||
|
padding: 16px 22px;
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
243
frontend/src/pages/system/ModelConfigsPage.vue
Normal file
243
frontend/src/pages/system/ModelConfigsPage.vue
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Delete, Edit, Plus, RefreshRight } from '@element-plus/icons-vue';
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||||
|
import { computed, onMounted, reactive, ref } from 'vue';
|
||||||
|
|
||||||
|
import { loadModelProviderEnumOptions, type EnumOption } from '@/api/modelEnums';
|
||||||
|
import {
|
||||||
|
deleteModelConfig,
|
||||||
|
queryModelConfigs,
|
||||||
|
queryModelProviders,
|
||||||
|
saveModelConfig,
|
||||||
|
type ModelConfig,
|
||||||
|
type ModelProvider,
|
||||||
|
} from '@/api/modelProvider';
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const saving = ref(false);
|
||||||
|
const dialogVisible = ref(false);
|
||||||
|
const models = ref<ModelConfig[]>([]);
|
||||||
|
const providers = ref<ModelProvider[]>([]);
|
||||||
|
const modelTypeOptions = ref<EnumOption[]>([]);
|
||||||
|
const enumError = ref('');
|
||||||
|
|
||||||
|
const editForm = reactive<ModelConfig>({
|
||||||
|
providerId: '',
|
||||||
|
modelCode: '',
|
||||||
|
modelName: '',
|
||||||
|
upstreamModel: '',
|
||||||
|
modelType: '',
|
||||||
|
embeddingDimension: 1024,
|
||||||
|
localModel: false,
|
||||||
|
defaultModel: false,
|
||||||
|
optionsJson: '{}',
|
||||||
|
enabled: true,
|
||||||
|
remark: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const dialogTitle = computed(() => (editForm.id ? '编辑模型' : '新增模型'));
|
||||||
|
|
||||||
|
function resetForm(row?: ModelConfig) {
|
||||||
|
editForm.id = row?.id;
|
||||||
|
editForm.providerId = row?.providerId ?? providers.value[0]?.id ?? '';
|
||||||
|
editForm.modelCode = row?.modelCode ?? '';
|
||||||
|
editForm.modelName = row?.modelName ?? '';
|
||||||
|
editForm.upstreamModel = row?.upstreamModel ?? '';
|
||||||
|
editForm.modelType = row?.modelType ?? modelTypeOptions.value[0]?.value ?? '';
|
||||||
|
editForm.embeddingDimension = row?.embeddingDimension ?? 1024;
|
||||||
|
editForm.localModel = row?.localModel ?? false;
|
||||||
|
editForm.defaultModel = row?.defaultModel ?? false;
|
||||||
|
editForm.optionsJson = row?.optionsJson ?? '{}';
|
||||||
|
editForm.enabled = row?.enabled ?? true;
|
||||||
|
editForm.remark = row?.remark ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadBaseData(forceRefresh = false) {
|
||||||
|
enumError.value = '';
|
||||||
|
try {
|
||||||
|
const [enumResult, providerResult] = await Promise.all([
|
||||||
|
loadModelProviderEnumOptions(forceRefresh),
|
||||||
|
queryModelProviders(),
|
||||||
|
]);
|
||||||
|
modelTypeOptions.value = enumResult.model_type ?? [];
|
||||||
|
providers.value = providerResult.data ?? [];
|
||||||
|
} catch (error) {
|
||||||
|
enumError.value = error instanceof Error ? error.message : '基础数据加载失败';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadModels() {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const response = await queryModelConfigs();
|
||||||
|
models.value = response.data ?? [];
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreateDialog() {
|
||||||
|
resetForm();
|
||||||
|
dialogVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditDialog(row: ModelConfig) {
|
||||||
|
resetForm(row);
|
||||||
|
dialogVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitModel() {
|
||||||
|
if (!editForm.providerId || !editForm.modelCode || !editForm.modelName || !editForm.modelType) {
|
||||||
|
ElMessage.warning('请填写服务商、模型编码、模型名称和模型类型');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
saving.value = true;
|
||||||
|
try {
|
||||||
|
await saveModelConfig({ ...editForm });
|
||||||
|
ElMessage.success('保存成功');
|
||||||
|
dialogVisible.value = false;
|
||||||
|
await loadModels();
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeModel(row: ModelConfig) {
|
||||||
|
if (!row.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await ElMessageBox.confirm(`确认删除模型「${row.modelName}」?`, '删除确认', {
|
||||||
|
type: 'warning',
|
||||||
|
confirmButtonText: '删除',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
});
|
||||||
|
await deleteModelConfig(row.id);
|
||||||
|
ElMessage.success('已删除');
|
||||||
|
await loadModels();
|
||||||
|
}
|
||||||
|
|
||||||
|
function providerName(providerId?: string) {
|
||||||
|
const provider = providers.value.find((item) => item.id === providerId);
|
||||||
|
return provider?.providerName ?? providerId ?? '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadBaseData();
|
||||||
|
resetForm();
|
||||||
|
await loadModels();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="page-panel">
|
||||||
|
<div class="page-panel__header">
|
||||||
|
<h2>模型配置</h2>
|
||||||
|
<span>Models</span>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar">
|
||||||
|
<el-alert v-if="enumError" type="error" :closable="false" :title="`基础数据加载失败:${enumError}`" show-icon />
|
||||||
|
<div class="toolbar__actions">
|
||||||
|
<el-button @click="loadBaseData(true)">重试基础数据</el-button>
|
||||||
|
<el-button :icon="RefreshRight" @click="loadModels">刷新</el-button>
|
||||||
|
<el-button type="primary" :icon="Plus" @click="openCreateDialog">新增模型</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-table v-loading="loading" :data="models" row-key="id">
|
||||||
|
<el-table-column prop="modelCode" label="模型编码" min-width="140" />
|
||||||
|
<el-table-column prop="modelName" label="模型名称" min-width="140" />
|
||||||
|
<el-table-column label="服务商" min-width="130">
|
||||||
|
<template #default="{ row }">{{ providerName(row.providerId) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="upstreamModel" label="上游模型" min-width="140" />
|
||||||
|
<el-table-column prop="modelType" label="模型类型" min-width="110" />
|
||||||
|
<el-table-column prop="embeddingDimension" label="向量维度" width="100" />
|
||||||
|
<el-table-column label="本地模型" width="90">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.localModel ? 'success' : 'info'">{{ row.localModel ? '是' : '否' }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="默认模型" width="90">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.defaultModel ? 'success' : 'info'">{{ row.defaultModel ? '是' : '否' }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="启用" width="80">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.enabled ? 'success' : 'info'">{{ row.enabled ? '是' : '否' }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="160" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button link type="primary" :icon="Edit" @click="openEditDialog(row)">编辑</el-button>
|
||||||
|
<el-button link type="danger" :icon="Delete" @click="removeModel(row)">删除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="680px">
|
||||||
|
<el-form :model="editForm" label-width="108px">
|
||||||
|
<el-form-item label="服务商" required>
|
||||||
|
<el-select v-model="editForm.providerId" placeholder="请选择服务商">
|
||||||
|
<el-option
|
||||||
|
v-for="provider in providers"
|
||||||
|
:key="provider.id"
|
||||||
|
:label="provider.providerName"
|
||||||
|
:value="provider.id"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="模型编码" required>
|
||||||
|
<el-input v-model="editForm.modelCode" placeholder="如 TEXT_EMBED_3L" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="模型名称" required>
|
||||||
|
<el-input v-model="editForm.modelName" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="上游模型">
|
||||||
|
<el-input v-model="editForm.upstreamModel" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="模型类型" required>
|
||||||
|
<el-select v-model="editForm.modelType" placeholder="请选择">
|
||||||
|
<el-option v-for="item in modelTypeOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="向量维度">
|
||||||
|
<el-input-number v-model="editForm.embeddingDimension" :min="1" controls-position="right" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="选项JSON">
|
||||||
|
<el-input v-model="editForm.optionsJson" type="textarea" :rows="3" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="本地模型">
|
||||||
|
<el-switch v-model="editForm.localModel" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="默认模型">
|
||||||
|
<el-switch v-model="editForm.defaultModel" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="启用">
|
||||||
|
<el-switch v-model="editForm.enabled" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="备注">
|
||||||
|
<el-input v-model="editForm.remark" type="textarea" :rows="2" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="dialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="saving" @click="submitModel">保存</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
278
frontend/src/pages/system/ModelProvidersPage.vue
Normal file
278
frontend/src/pages/system/ModelProvidersPage.vue
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Delete, Edit, Plus, RefreshRight } from '@element-plus/icons-vue';
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||||
|
import { computed, onMounted, reactive, ref } from 'vue';
|
||||||
|
|
||||||
|
import { loadModelProviderEnumOptions, type EnumOption } from '@/api/modelEnums';
|
||||||
|
import {
|
||||||
|
checkModelProviderHealth,
|
||||||
|
deleteModelProvider,
|
||||||
|
queryModelProviders,
|
||||||
|
saveModelProvider,
|
||||||
|
type ModelProvider,
|
||||||
|
} from '@/api/modelProvider';
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const saving = ref(false);
|
||||||
|
const checkingId = ref<string | null>(null);
|
||||||
|
const dialogVisible = ref(false);
|
||||||
|
const providers = ref<ModelProvider[]>([]);
|
||||||
|
const enumLoading = ref(false);
|
||||||
|
const enumError = ref('');
|
||||||
|
const providerTypeOptions = ref<EnumOption[]>([]);
|
||||||
|
const protocolTypeOptions = ref<EnumOption[]>([]);
|
||||||
|
const authTypeOptions = ref<EnumOption[]>([]);
|
||||||
|
const healthStatusOptions = ref<EnumOption[]>([]);
|
||||||
|
|
||||||
|
const editForm = reactive<ModelProvider>({
|
||||||
|
providerCode: '',
|
||||||
|
providerName: '',
|
||||||
|
providerType: '',
|
||||||
|
protocolType: '',
|
||||||
|
baseUrl: '',
|
||||||
|
authType: '',
|
||||||
|
secretRef: '',
|
||||||
|
timeoutMs: 60000,
|
||||||
|
priority: 100,
|
||||||
|
enabled: true,
|
||||||
|
remark: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const dialogTitle = computed(() => (editForm.id ? '编辑服务商' : '新增服务商'));
|
||||||
|
|
||||||
|
function resetForm(row?: ModelProvider) {
|
||||||
|
editForm.id = row?.id;
|
||||||
|
editForm.providerCode = row?.providerCode ?? '';
|
||||||
|
editForm.providerName = row?.providerName ?? '';
|
||||||
|
editForm.providerType = row?.providerType ?? providerTypeOptions.value[0]?.value ?? '';
|
||||||
|
editForm.protocolType = row?.protocolType ?? protocolTypeOptions.value[0]?.value ?? '';
|
||||||
|
editForm.baseUrl = row?.baseUrl ?? '';
|
||||||
|
editForm.authType = row?.authType ?? authTypeOptions.value[0]?.value ?? '';
|
||||||
|
editForm.secretRef = row?.secretRef ?? '';
|
||||||
|
editForm.timeoutMs = row?.timeoutMs ?? 60000;
|
||||||
|
editForm.priority = row?.priority ?? 100;
|
||||||
|
editForm.enabled = row?.enabled ?? true;
|
||||||
|
editForm.remark = row?.remark ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadEnums(forceRefresh = false) {
|
||||||
|
enumLoading.value = true;
|
||||||
|
enumError.value = '';
|
||||||
|
try {
|
||||||
|
const result = await loadModelProviderEnumOptions(forceRefresh);
|
||||||
|
providerTypeOptions.value = result.provider_type ?? [];
|
||||||
|
protocolTypeOptions.value = result.protocol_type ?? [];
|
||||||
|
authTypeOptions.value = result.auth_type ?? [];
|
||||||
|
healthStatusOptions.value = result.health_status ?? [];
|
||||||
|
} catch (error) {
|
||||||
|
enumError.value = error instanceof Error ? error.message : '枚举加载失败';
|
||||||
|
} finally {
|
||||||
|
enumLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadProviders() {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const response = await queryModelProviders();
|
||||||
|
providers.value = response.data ?? [];
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreateDialog() {
|
||||||
|
resetForm();
|
||||||
|
dialogVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditDialog(row: ModelProvider) {
|
||||||
|
resetForm(row);
|
||||||
|
dialogVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitProvider() {
|
||||||
|
if (!editForm.providerCode || !editForm.providerName || !editForm.baseUrl) {
|
||||||
|
ElMessage.warning('请填写服务商编码、服务商名称和基础地址');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
saving.value = true;
|
||||||
|
try {
|
||||||
|
await saveModelProvider({ ...editForm });
|
||||||
|
ElMessage.success('保存成功');
|
||||||
|
dialogVisible.value = false;
|
||||||
|
await loadProviders();
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeProvider(row: ModelProvider) {
|
||||||
|
if (!row.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await ElMessageBox.confirm(`确认删除服务商「${row.providerName}」?`, '删除确认', {
|
||||||
|
type: 'warning',
|
||||||
|
confirmButtonText: '删除',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
});
|
||||||
|
await deleteModelProvider(row.id);
|
||||||
|
ElMessage.success('已删除');
|
||||||
|
await loadProviders();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkHealth(row: ModelProvider) {
|
||||||
|
if (!row.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
checkingId.value = row.id;
|
||||||
|
try {
|
||||||
|
await checkModelProviderHealth(row.id);
|
||||||
|
ElMessage.success('健康检查已完成');
|
||||||
|
await loadProviders();
|
||||||
|
} finally {
|
||||||
|
checkingId.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function healthLabel(value?: string) {
|
||||||
|
const option = healthStatusOptions.value.find((item) => item.value === value);
|
||||||
|
return option?.label ?? value ?? '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadEnums();
|
||||||
|
resetForm();
|
||||||
|
await loadProviders();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="page-panel">
|
||||||
|
<div class="page-panel__header">
|
||||||
|
<h2>模型服务商</h2>
|
||||||
|
<span>Providers</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="toolbar__left">
|
||||||
|
<el-alert
|
||||||
|
v-if="enumError"
|
||||||
|
type="error"
|
||||||
|
:closable="false"
|
||||||
|
:title="`枚举加载失败:${enumError}`"
|
||||||
|
show-icon
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar__actions">
|
||||||
|
<el-button :loading="enumLoading" @click="loadEnums(true)">重试枚举</el-button>
|
||||||
|
<el-button :icon="RefreshRight" @click="loadProviders">刷新</el-button>
|
||||||
|
<el-button type="primary" :icon="Plus" @click="openCreateDialog">新增服务商</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-table v-loading="loading" :data="providers" row-key="id">
|
||||||
|
<el-table-column prop="providerCode" label="服务商编码" min-width="140" />
|
||||||
|
<el-table-column prop="providerName" label="服务商名称" min-width="140" />
|
||||||
|
<el-table-column prop="providerType" label="服务商类型" min-width="120" />
|
||||||
|
<el-table-column prop="protocolType" label="协议类型" min-width="130" />
|
||||||
|
<el-table-column prop="authType" label="鉴权类型" min-width="120" />
|
||||||
|
<el-table-column prop="secretRef" label="密钥引用" min-width="140" />
|
||||||
|
<el-table-column label="已配置密钥" width="110">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.hasApiKey ? 'success' : 'info'">{{ row.hasApiKey ? '是' : '否' }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="健康状态" width="110">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag>{{ healthLabel(row.healthStatus) }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="启用" width="90">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.enabled ? 'success' : 'info'">{{ row.enabled ? '是' : '否' }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="280" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button link type="primary" :icon="Edit" @click="openEditDialog(row)">编辑</el-button>
|
||||||
|
<el-button
|
||||||
|
link
|
||||||
|
type="primary"
|
||||||
|
:loading="checkingId === row.id"
|
||||||
|
@click="checkHealth(row)"
|
||||||
|
>
|
||||||
|
健康检查
|
||||||
|
</el-button>
|
||||||
|
<el-button link type="danger" :icon="Delete" @click="removeProvider(row)">删除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="640px">
|
||||||
|
<el-form :model="editForm" label-width="108px">
|
||||||
|
<el-form-item label="服务商编码" required>
|
||||||
|
<el-input v-model="editForm.providerCode" placeholder="如 OPENAI_MAIN" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="服务商名称" required>
|
||||||
|
<el-input v-model="editForm.providerName" placeholder="如 OpenAI 主账号" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="服务商类型">
|
||||||
|
<el-select v-model="editForm.providerType" placeholder="请选择">
|
||||||
|
<el-option v-for="item in providerTypeOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="协议类型">
|
||||||
|
<el-select v-model="editForm.protocolType" placeholder="请选择">
|
||||||
|
<el-option v-for="item in protocolTypeOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="基础地址" required>
|
||||||
|
<el-input v-model="editForm.baseUrl" placeholder="https://api.example.com" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="鉴权类型">
|
||||||
|
<el-select v-model="editForm.authType" placeholder="请选择">
|
||||||
|
<el-option v-for="item in authTypeOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="密钥引用">
|
||||||
|
<el-input v-model="editForm.secretRef" placeholder="如 OPENAI_API_KEY" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="超时(毫秒)">
|
||||||
|
<el-input-number v-model="editForm.timeoutMs" :min="1000" :step="1000" controls-position="right" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="优先级">
|
||||||
|
<el-input-number v-model="editForm.priority" :min="1" controls-position="right" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="启用">
|
||||||
|
<el-switch v-model="editForm.enabled" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="备注">
|
||||||
|
<el-input v-model="editForm.remark" type="textarea" :rows="2" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="dialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="saving" @click="submitProvider">保存</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar__left {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
245
frontend/src/pages/system/ModelRouteRulesPage.vue
Normal file
245
frontend/src/pages/system/ModelRouteRulesPage.vue
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Delete, Edit, Plus, RefreshRight } from '@element-plus/icons-vue';
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||||
|
import { computed, onMounted, reactive, ref } from 'vue';
|
||||||
|
|
||||||
|
import { loadModelProviderEnumOptions, type EnumOption } from '@/api/modelEnums';
|
||||||
|
import {
|
||||||
|
deleteModelRouteRule,
|
||||||
|
queryModelConfigs,
|
||||||
|
queryModelRouteRules,
|
||||||
|
saveModelRouteRule,
|
||||||
|
type ModelConfig,
|
||||||
|
type ModelRouteRule,
|
||||||
|
} from '@/api/modelProvider';
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const saving = ref(false);
|
||||||
|
const dialogVisible = ref(false);
|
||||||
|
const routes = ref<ModelRouteRule[]>([]);
|
||||||
|
const models = ref<ModelConfig[]>([]);
|
||||||
|
const taskTypeOptions = ref<EnumOption[]>([]);
|
||||||
|
const routeStrategyOptions = ref<EnumOption[]>([]);
|
||||||
|
const matchScopeOptions = ref<EnumOption[]>([]);
|
||||||
|
const enumError = ref('');
|
||||||
|
|
||||||
|
const editForm = reactive<ModelRouteRule>({
|
||||||
|
routeCode: '',
|
||||||
|
routeName: '',
|
||||||
|
taskType: '',
|
||||||
|
matchScope: '',
|
||||||
|
scopeId: '',
|
||||||
|
primaryModelId: '',
|
||||||
|
fallbackModelIdsJson: '[]',
|
||||||
|
routeStrategy: '',
|
||||||
|
maxLatencyMs: 0,
|
||||||
|
enabled: true,
|
||||||
|
remark: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const dialogTitle = computed(() => (editForm.id ? '编辑路由规则' : '新增路由规则'));
|
||||||
|
|
||||||
|
function resetForm(row?: ModelRouteRule) {
|
||||||
|
editForm.id = row?.id;
|
||||||
|
editForm.routeCode = row?.routeCode ?? '';
|
||||||
|
editForm.routeName = row?.routeName ?? '';
|
||||||
|
editForm.taskType = row?.taskType ?? taskTypeOptions.value[0]?.value ?? '';
|
||||||
|
editForm.matchScope = row?.matchScope ?? matchScopeOptions.value[0]?.value ?? '';
|
||||||
|
editForm.scopeId = row?.scopeId ?? '';
|
||||||
|
editForm.primaryModelId = row?.primaryModelId ?? models.value[0]?.id ?? '';
|
||||||
|
editForm.fallbackModelIdsJson = row?.fallbackModelIdsJson ?? '[]';
|
||||||
|
editForm.routeStrategy = row?.routeStrategy ?? routeStrategyOptions.value[0]?.value ?? '';
|
||||||
|
editForm.maxLatencyMs = row?.maxLatencyMs ?? 0;
|
||||||
|
editForm.enabled = row?.enabled ?? true;
|
||||||
|
editForm.remark = row?.remark ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadBaseData(forceRefresh = false) {
|
||||||
|
enumError.value = '';
|
||||||
|
try {
|
||||||
|
const [enumResult, modelResult] = await Promise.all([
|
||||||
|
loadModelProviderEnumOptions(forceRefresh),
|
||||||
|
queryModelConfigs(),
|
||||||
|
]);
|
||||||
|
taskTypeOptions.value = enumResult.task_type ?? [];
|
||||||
|
routeStrategyOptions.value = enumResult.route_strategy ?? [];
|
||||||
|
matchScopeOptions.value = enumResult.match_scope ?? [];
|
||||||
|
models.value = modelResult.data ?? [];
|
||||||
|
} catch (error) {
|
||||||
|
enumError.value = error instanceof Error ? error.message : '基础数据加载失败';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRouteRules() {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const response = await queryModelRouteRules();
|
||||||
|
routes.value = response.data ?? [];
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreateDialog() {
|
||||||
|
resetForm();
|
||||||
|
dialogVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditDialog(row: ModelRouteRule) {
|
||||||
|
resetForm(row);
|
||||||
|
dialogVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitRouteRule() {
|
||||||
|
if (!editForm.routeCode || !editForm.taskType || !editForm.primaryModelId) {
|
||||||
|
ElMessage.warning('请填写路由编码、任务类型和主模型');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
JSON.parse(editForm.fallbackModelIdsJson ?? '[]');
|
||||||
|
} catch {
|
||||||
|
ElMessage.warning('降级模型JSON格式不正确');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
saving.value = true;
|
||||||
|
try {
|
||||||
|
await saveModelRouteRule({ ...editForm });
|
||||||
|
ElMessage.success('保存成功');
|
||||||
|
dialogVisible.value = false;
|
||||||
|
await loadRouteRules();
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeRouteRule(row: ModelRouteRule) {
|
||||||
|
if (!row.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await ElMessageBox.confirm(`确认删除路由规则「${row.routeName || row.routeCode}」?`, '删除确认', {
|
||||||
|
type: 'warning',
|
||||||
|
confirmButtonText: '删除',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
});
|
||||||
|
await deleteModelRouteRule(row.id);
|
||||||
|
ElMessage.success('已删除');
|
||||||
|
await loadRouteRules();
|
||||||
|
}
|
||||||
|
|
||||||
|
function modelLabel(modelId?: string) {
|
||||||
|
const model = models.value.find((item) => item.id === modelId);
|
||||||
|
return model?.modelName ?? model?.modelCode ?? modelId ?? '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadBaseData();
|
||||||
|
resetForm();
|
||||||
|
await loadRouteRules();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="page-panel">
|
||||||
|
<div class="page-panel__header">
|
||||||
|
<h2>路由规则</h2>
|
||||||
|
<span>Routes</span>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar">
|
||||||
|
<el-alert v-if="enumError" type="error" :closable="false" :title="`基础数据加载失败:${enumError}`" show-icon />
|
||||||
|
<div class="toolbar__actions">
|
||||||
|
<el-button @click="loadBaseData(true)">重试基础数据</el-button>
|
||||||
|
<el-button :icon="RefreshRight" @click="loadRouteRules">刷新</el-button>
|
||||||
|
<el-button type="primary" :icon="Plus" @click="openCreateDialog">新增规则</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-table v-loading="loading" :data="routes" row-key="id">
|
||||||
|
<el-table-column prop="routeCode" label="规则编码" min-width="140" />
|
||||||
|
<el-table-column prop="routeName" label="规则名称" min-width="140" />
|
||||||
|
<el-table-column prop="taskType" label="任务类型" min-width="120" />
|
||||||
|
<el-table-column prop="matchScope" label="匹配范围" min-width="100" />
|
||||||
|
<el-table-column label="主模型" min-width="140">
|
||||||
|
<template #default="{ row }">{{ modelLabel(row.primaryModelId) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="fallbackModelIdsJson" label="降级模型JSON" min-width="180" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="routeStrategy" label="策略" min-width="100" />
|
||||||
|
<el-table-column prop="maxLatencyMs" label="最大延迟(ms)" width="120" />
|
||||||
|
<el-table-column label="启用" width="80">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.enabled ? 'success' : 'info'">{{ row.enabled ? '是' : '否' }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="160" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button link type="primary" :icon="Edit" @click="openEditDialog(row)">编辑</el-button>
|
||||||
|
<el-button link type="danger" :icon="Delete" @click="removeRouteRule(row)">删除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="680px">
|
||||||
|
<el-form :model="editForm" label-width="120px">
|
||||||
|
<el-form-item label="规则编码" required>
|
||||||
|
<el-input v-model="editForm.routeCode" placeholder="如 RAG_EMBEDDING_GLOBAL" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="规则名称">
|
||||||
|
<el-input v-model="editForm.routeName" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="任务类型" required>
|
||||||
|
<el-select v-model="editForm.taskType">
|
||||||
|
<el-option v-for="item in taskTypeOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="匹配范围">
|
||||||
|
<el-select v-model="editForm.matchScope">
|
||||||
|
<el-option v-for="item in matchScopeOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="范围业务ID">
|
||||||
|
<el-input v-model="editForm.scopeId" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="主模型" required>
|
||||||
|
<el-select v-model="editForm.primaryModelId">
|
||||||
|
<el-option v-for="item in models" :key="item.id" :label="`${item.modelName}(${item.modelCode})`" :value="item.id" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="降级模型JSON">
|
||||||
|
<el-input v-model="editForm.fallbackModelIdsJson" type="textarea" :rows="3" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="路由策略">
|
||||||
|
<el-select v-model="editForm.routeStrategy">
|
||||||
|
<el-option v-for="item in routeStrategyOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="最大延迟(ms)">
|
||||||
|
<el-input-number v-model="editForm.maxLatencyMs" :min="0" controls-position="right" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="启用">
|
||||||
|
<el-switch v-model="editForm.enabled" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="备注">
|
||||||
|
<el-input v-model="editForm.remark" type="textarea" :rows="2" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="dialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="saving" @click="submitRouteRule">保存</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -12,6 +12,10 @@ describe('router', () => {
|
|||||||
expect(paths).toContain('/rag/documents');
|
expect(paths).toContain('/rag/documents');
|
||||||
expect(paths).toContain('/rag/tasks/chunk');
|
expect(paths).toContain('/rag/tasks/chunk');
|
||||||
expect(paths).toContain('/system/enums');
|
expect(paths).toContain('/system/enums');
|
||||||
|
expect(paths).toContain('/system/model/providers');
|
||||||
|
expect(paths).toContain('/system/model/configs');
|
||||||
|
expect(paths).toContain('/system/model/routes');
|
||||||
|
expect(paths).toContain('/system/model/call-logs');
|
||||||
expect(paths).toContain('/:pathMatch(.*)*');
|
expect(paths).toContain('/:pathMatch(.*)*');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ import RagStoresPage from '@/pages/rag/RagStoresPage.vue';
|
|||||||
import RagChunkTasksPage from '@/pages/rag/tasks/RagChunkTasksPage.vue';
|
import RagChunkTasksPage from '@/pages/rag/tasks/RagChunkTasksPage.vue';
|
||||||
import RagWorkbenchPage from '@/pages/rag/workbench/RagWorkbenchPage.vue';
|
import RagWorkbenchPage from '@/pages/rag/workbench/RagWorkbenchPage.vue';
|
||||||
import SystemEnumsPage from '@/pages/system/SystemEnumsPage.vue';
|
import SystemEnumsPage from '@/pages/system/SystemEnumsPage.vue';
|
||||||
|
import ModelProvidersPage from '@/pages/system/ModelProvidersPage.vue';
|
||||||
|
import ModelConfigsPage from '@/pages/system/ModelConfigsPage.vue';
|
||||||
|
import ModelRouteRulesPage from '@/pages/system/ModelRouteRulesPage.vue';
|
||||||
|
import ModelCallLogsPage from '@/pages/system/ModelCallLogsPage.vue';
|
||||||
import AdminLayout from '@/layouts/AdminLayout.vue';
|
import AdminLayout from '@/layouts/AdminLayout.vue';
|
||||||
|
|
||||||
export const routes: RouteRecordRaw[] = [
|
export const routes: RouteRecordRaw[] = [
|
||||||
@@ -20,6 +24,30 @@ export const routes: RouteRecordRaw[] = [
|
|||||||
component: SystemEnumsPage,
|
component: SystemEnumsPage,
|
||||||
meta: { title: '系统枚举' },
|
meta: { title: '系统枚举' },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/system/model/providers',
|
||||||
|
name: 'system-model-providers',
|
||||||
|
component: ModelProvidersPage,
|
||||||
|
meta: { title: '模型服务商' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/system/model/configs',
|
||||||
|
name: 'system-model-configs',
|
||||||
|
component: ModelConfigsPage,
|
||||||
|
meta: { title: '模型配置' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/system/model/routes',
|
||||||
|
name: 'system-model-routes',
|
||||||
|
component: ModelRouteRulesPage,
|
||||||
|
meta: { title: '路由规则' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/system/model/call-logs',
|
||||||
|
name: 'system-model-call-logs',
|
||||||
|
component: ModelCallLogsPage,
|
||||||
|
meta: { title: '调用日志' },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/rag/stores',
|
path: '/rag/stores',
|
||||||
name: 'rag-stores',
|
name: 'rag-stores',
|
||||||
@@ -67,6 +95,30 @@ const routerRoutes: RouteRecordRaw[] = [
|
|||||||
component: SystemEnumsPage,
|
component: SystemEnumsPage,
|
||||||
meta: { title: '系统枚举' },
|
meta: { title: '系统枚举' },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'system/model/providers',
|
||||||
|
name: 'system-model-providers',
|
||||||
|
component: ModelProvidersPage,
|
||||||
|
meta: { title: '模型服务商' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'system/model/configs',
|
||||||
|
name: 'system-model-configs',
|
||||||
|
component: ModelConfigsPage,
|
||||||
|
meta: { title: '模型配置' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'system/model/routes',
|
||||||
|
name: 'system-model-routes',
|
||||||
|
component: ModelRouteRulesPage,
|
||||||
|
meta: { title: '路由规则' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'system/model/call-logs',
|
||||||
|
name: 'system-model-call-logs',
|
||||||
|
component: ModelCallLogsPage,
|
||||||
|
meta: { title: '调用日志' },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'rag/stores',
|
path: 'rag/stores',
|
||||||
name: 'rag-stores',
|
name: 'rag-stores',
|
||||||
|
|||||||
Reference in New Issue
Block a user