Files
admin-ui/src/components/model/ModelSelector.vue

624 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<el-dialog v-model="visible" title="选择模型" width="1000px" :close-on-click-modal="false" @close="handleClose">
<div class="model-selector-header">
<div class="search-bar">
<el-input v-model="searchParams.modelName" placeholder="搜索模型名称" clearable @clear="handleSearch">
<template #prefix
><el-icon> <Search /> </el-icon
></template>
</el-input>
<el-button type="primary" @click="handleSearch">搜索</el-button>
</div>
<el-button type="success" @click="handleAddModel">+ 新建模型</el-button>
</div>
<div class="model-list" v-loading="loading">
<el-empty v-if="!loading && modelList.length === 0" description="暂无模型数据" :image-size="100" />
<div v-else class="model-grid">
<div
v-for="model in modelList"
:key="model.id"
class="model-card"
:class="{ selected: selectedModel?.id === model.id }"
@click="handleSelectModel(model)"
>
<div class="model-card-header">
<div class="model-type">{{ getModelTypeName(model.modelType) }}</div>
<div class="model-badges">
<el-tag v-if="model.isOwner === 0" type="warning" size="small">内置模型</el-tag>
<el-icon v-if="selectedModel?.id === model.id" class="check-icon" color="#67c23a">
<CircleCheck />
</el-icon>
</div>
</div>
<div class="model-card-body">
<h3 class="model-name">{{ model.modelName }}</h3>
<p class="model-url">{{ model.baseUrl }}</p>
<div class="model-status">
<el-tag :type="model.enabled === 1 ? 'success' : 'info'" size="small">
{{ model.enabled === 1 ? '已启用' : '已禁用' }}
</el-tag>
</div>
</div>
</div>
</div>
</div>
<div v-if="pagination.total > 0" class="pagination-wrap">
<el-pagination
v-model:current-page="pagination.pageNum"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50]"
layout="total, prev, pager, next"
small
@current-change="handlePageChange"
/>
</div>
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="handleConfirm" :disabled="!selectedModel">确定</el-button>
</template>
<!-- 新建模型弹窗 -->
<EditModule ref="editModuleRef" :model-types="modelTypes" :is-super-admin="isSuperAdmin" @refresh="handleRefresh" />
<!-- 内置模型 API Key 输入弹窗 -->
<el-dialog v-model="apiKeyDialogVisible" title="配置内置模型" width="500px" :close-on-click-modal="false" append-to-body>
<el-alert type="info" :closable="false" style="margin-bottom: 16px">
<template #title>
<div style="line-height: 1.6">
您选择的是内置模型需要配置您自己的 API Key<br />
系统将为您创建一个模型副本
</div>
</template>
</el-alert>
<el-form :model="apiKeyForm" :rules="apiKeyRules" ref="apiKeyFormRef" label-width="100px">
<el-form-item label="模型名称" prop="modelName">
<el-input v-model="apiKeyForm.modelName" placeholder="请输入模型名称" />
</el-form-item>
<el-form-item label="API Key" prop="apiKey">
<el-input v-model="apiKeyForm.apiKey" type="password" show-password placeholder="请输入您的 API Key" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="apiKeyDialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleCreatePrivateModel" :loading="creatingModel">确定</el-button>
</template>
</el-dialog>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, reactive, watch, onMounted } from 'vue';
import { ElMessage, type FormInstance, type FormRules } from 'element-plus';
import { Search, CircleCheck } from '@element-plus/icons-vue';
import {
getModelModuleList,
addModelModule,
getModelTypeList,
normalizeModelTypeOptions,
type CreateModelParams,
type ModelFormEntry,
} from '/@/api/settings/modelConfig/modelModule';
import { checkIsSuperAdmin } from '/@/api/system/user/index';
import { getApiErrorMessage } from '/@/utils/request';
import EditModule from '/@/views/settings/modelConfig/modelModule/component/editModule.vue';
interface ModelItem {
id: string;
tenantId?: number;
modelName: string;
modelType: number | string;
baseUrl: string;
route: string;
httpMethod: string;
enabled: number;
apiKey?: string;
isPrivate?: number;
isChatModel?: number;
headMsg?: string | Record<string, string>;
operatorName?: string;
responseBody?: Record<string, unknown>;
responseTokenField?: string;
tokenConfig?: Record<string, unknown> | string;
extendMapping?: Record<string, unknown> | string;
queryConfig?: Record<string, unknown>;
streamConfig?: Record<string, unknown>;
form?: ModelFormEntry[] | Record<string, unknown>;
requestMapping?: Record<string, unknown>;
requiredFields?: string[];
firstFrame?: string;
lastFrame?: string;
responseMapping?: Record<string, unknown>;
callMode?: number;
isAsync?: number;
maxConcurrency?: number;
queueLimit?: number;
timeoutSeconds?: number;
expectedSeconds?: number;
retryTimes?: number;
retryQueueMaxSeconds?: number;
autoCleanSeconds?: number;
remark?: string;
[key: string]: any;
}
interface Props {
modelValue: boolean;
defaultModel?: ModelItem | null;
modelType?: number;
}
interface Emits {
(e: 'update:modelValue', value: boolean): void;
(e: 'confirm', model: ModelItem): void;
}
const props = withDefaults(defineProps<Props>(), {
modelValue: false,
defaultModel: null,
modelType: 1,
});
const emit = defineEmits<Emits>();
const visible = ref(false);
const searchParams = reactive({ modelName: '' });
const pagination = reactive({ pageNum: 1, pageSize: 10, total: 0 });
const modelList = ref<ModelItem[]>([]);
const loading = ref(false);
const selectedModel = ref<ModelItem | null>(null);
const editModuleRef = ref();
const isSuperAdmin = ref(false);
const modelTypes = ref<Array<{ id: number | string; label: string }>>([]);
// 内置模型 API Key 配置
const apiKeyDialogVisible = ref(false);
const apiKeyFormRef = ref<FormInstance>();
const apiKeyForm = reactive({
modelName: '',
apiKey: '',
});
const apiKeyRules: FormRules = {
modelName: [{ required: true, message: '请输入模型名称', trigger: 'blur' }],
apiKey: [{ required: true, message: '请输入 API Key', trigger: 'blur' }],
};
const creatingModel = ref(false);
const builtInModelToClone = ref<ModelItem | null>(null);
// 检查是否为管理员
const checkAdminStatus = async () => {
try {
const res: any = await checkIsSuperAdmin();
isSuperAdmin.value = res.data?.isSuperAdmin || false;
} catch {
isSuperAdmin.value = false;
}
};
watch(
() => props.modelValue,
(val) => {
visible.value = val;
if (val) {
selectedModel.value = props.defaultModel || null;
fetchModelList();
}
}
);
watch(visible, (val) => {
if (!val) {
emit('update:modelValue', false);
}
});
watch(
() => props.modelType,
() => {
if (!visible.value) return;
pagination.pageNum = 1;
fetchModelList();
}
);
const getModelTypeName = (type: number | string) => {
const typeMap: Record<number, string> = {
100: '推理模型',
200: '图片模型',
201: '图片模型-文生图',
202: '图片模型-图生图',
203: '图片模型-图片编辑',
204: '图片模型-图片变体',
300: '音频模型',
301: '音频模型-文生音',
302: '音频模型-音生文',
303: '音频模型-音生音',
400: '向量化模型',
401: '向量化模型-文本嵌入',
402: '向量化模型-重排序',
500: '全模态模型',
501: '全模态模型-文图音',
502: '全模态模型-视觉理解',
600: '视频模型',
601: '视频模型-文生视频',
602: '视频模型-图生视频',
603: '视频模型-图文生视频',
604: '视频模型-视频生视频',
605: '视频模型-视频编辑',
};
return typeMap[Number(type)] || '未知类型';
};
const parseJsonObjectField = (raw: unknown): Record<string, unknown> => {
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
return raw as Record<string, unknown>;
}
if (typeof raw === 'string') {
try {
const parsed = JSON.parse(raw || '{}');
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
return parsed as Record<string, unknown>;
}
} catch {
return {};
}
}
return {};
};
const fieldsToUnknownObject = (fields: Array<{ key: string; value: string }>) => {
const obj: Record<string, unknown> = {};
fields.forEach((f) => {
const k = String(f.key || '').trim();
if (!k) return;
obj[k] = String(f.value ?? '');
});
return obj;
};
const parseFormEntries = (raw: unknown): ModelFormEntry[] => {
if (Array.isArray(raw)) {
return (raw as ModelFormEntry[])
.filter((item) => item && item.key !== undefined)
.map((item) => ({
key: String(item.key ?? '').trim(),
value: String(item.value ?? ''),
}))
.filter((item) => item.key !== '');
}
if (raw && typeof raw === 'object') {
return Object.entries(raw as Record<string, unknown>).map(([key, value]) => {
let nextValue = value;
if (nextValue && typeof nextValue === 'object' && !Array.isArray(nextValue) && 'value' in nextValue) {
nextValue = (nextValue as { value: unknown }).value;
}
return {
key: String(key).trim(),
value: String(nextValue ?? ''),
};
});
}
return [];
};
const normalizeQueryConfig = (raw: unknown): Record<string, unknown> => {
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
return raw as Record<string, unknown>;
}
return {};
};
const parseHeadMsgRecord = (raw: ModelItem['headMsg']) => {
const headMsgRecord: Record<string, string> = {};
if (typeof raw === 'string') {
try {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
Object.entries(parsed).forEach(([k, v]) => {
headMsgRecord[k] = String(v);
});
return headMsgRecord;
}
} catch {
const pairs = raw.split(',');
pairs.forEach((pair) => {
const idx = pair.indexOf(':');
if (idx === -1) return;
const key = pair.slice(0, idx).trim();
const value = pair.slice(idx + 1).trim();
if (key) {
headMsgRecord[key] = value;
}
});
return headMsgRecord;
}
}
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
Object.entries(raw).forEach(([k, v]) => {
headMsgRecord[k] = String(v);
});
}
return headMsgRecord;
};
const fetchModelList = async () => {
loading.value = true;
try {
const params = {
pageNum: pagination.pageNum,
pageSize: pagination.pageSize,
modelName: searchParams.modelName || undefined,
modelType: props.modelType,
enabled: 1,
};
const res: any = await getModelModuleList(params);
modelList.value = res.data?.list || [];
pagination.total = res.data?.total || 0;
} catch {
modelList.value = [];
pagination.total = 0;
} finally {
loading.value = false;
}
};
const handleSearch = () => {
pagination.pageNum = 1;
fetchModelList();
};
const handlePageChange = () => {
fetchModelList();
};
const handleSelectModel = (model: ModelItem) => {
if (isSuperAdmin.value) {
selectedModel.value = model;
return;
}
if (model.isOwner === 0) {
builtInModelToClone.value = model;
apiKeyForm.modelName = model.modelName;
apiKeyForm.apiKey = '';
apiKeyDialogVisible.value = true;
} else {
selectedModel.value = model;
}
};
const handleCreatePrivateModel = async () => {
if (!apiKeyFormRef.value || !builtInModelToClone.value) return;
try {
await apiKeyFormRef.value.validate();
creatingModel.value = true;
const builtInModel = builtInModelToClone.value;
const formList = parseFormEntries(builtInModel.form);
const createParams: CreateModelParams = {
modelName: apiKeyForm.modelName,
modelType: builtInModel.modelType,
operatorName: builtInModel.operatorName || '',
baseUrl: builtInModel.baseUrl,
httpMethod: builtInModel.httpMethod || 'POST',
headMsg: parseHeadMsgRecord(builtInModel.headMsg),
isPrivate: builtInModel.isPrivate ?? 1,
enabled: builtInModel.enabled ?? 1,
isChatModel: builtInModel.isChatModel || 0,
callMode: builtInModel.callMode ?? builtInModel.isAsync ?? 0,
apiKey: apiKeyForm.apiKey,
form: formList,
requestMapping: (builtInModel.requestMapping as Record<string, unknown>) || {},
requiredFields: Array.isArray(builtInModel.requiredFields) ? builtInModel.requiredFields : [],
firstFrame: String(builtInModel.firstFrame || ''),
lastFrame: String(builtInModel.lastFrame || ''),
responseMapping: (builtInModel.responseMapping as Record<string, unknown>) || {},
responseBody: builtInModel.responseBody || {},
maxConcurrency: builtInModel.maxConcurrency || 10,
queueLimit: builtInModel.queueLimit || 100,
timeoutSeconds: builtInModel.timeoutSeconds || 30,
expectedSeconds: builtInModel.expectedSeconds || 15,
retryTimes: builtInModel.retryTimes || 3,
retryQueueMaxSeconds: builtInModel.retryQueueMaxSeconds || 60,
autoCleanSeconds: builtInModel.autoCleanSeconds || 300,
extendMapping: fieldsToUnknownObject(
Object.entries(parseJsonObjectField(builtInModel.extendMapping)).map(([k, v]) => ({ key: k, value: String(v ?? '') }))
),
tokenConfig: fieldsToUnknownObject(
Object.entries(parseJsonObjectField(builtInModel.tokenConfig)).map(([k, v]) => ({ key: k, value: String(v ?? '') }))
),
queryConfig: normalizeQueryConfig(builtInModel.queryConfig),
responseTokenField: String(builtInModel.responseTokenField || ''),
streamConfig:
Number(builtInModel.callMode ?? builtInModel.isAsync ?? 0) === 2
? builtInModel.streamConfig && typeof builtInModel.streamConfig === 'object' && !Array.isArray(builtInModel.streamConfig)
? (builtInModel.streamConfig as Record<string, unknown>)
: {}
: undefined,
};
const res: any = await addModelModule(createParams);
ElMessage.success('模型创建成功');
apiKeyDialogVisible.value = false;
await fetchModelList();
const newModelId = res.data?.id || res.data;
if (newModelId) {
const newModel = modelList.value.find((m) => m.id === String(newModelId));
if (newModel) {
selectedModel.value = newModel;
}
}
} catch (error: any) {
if (error !== 'cancel') {
ElMessage.error(getApiErrorMessage(error, '创建模型失败'));
}
} finally {
creatingModel.value = false;
}
};
const handleAddModel = () => {
editModuleRef.value?.openDialog('add');
};
const handleRefresh = () => {
fetchModelList();
};
const handleConfirm = () => {
if (selectedModel.value) {
emit('confirm', selectedModel.value);
handleClose();
}
};
const handleClose = () => {
visible.value = false;
selectedModel.value = null;
apiKeyDialogVisible.value = false;
builtInModelToClone.value = null;
};
const loadModelTypes = async () => {
try {
const res: any = await getModelTypeList();
if (res.code === 0) {
modelTypes.value = normalizeModelTypeOptions(res);
}
} catch {
// 接口错误由 request 全局提示后端 message
}
};
onMounted(() => {
checkAdminStatus();
loadModelTypes();
});
</script>
<style scoped lang="scss">
.model-selector-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
gap: 12px;
}
.search-bar {
display: flex;
gap: 12px;
flex: 1;
}
.model-list {
min-height: 300px;
max-height: 400px;
overflow-y: auto;
}
.model-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
}
.model-card {
background: #f8fafc;
border-radius: 8px;
padding: 16px;
cursor: pointer;
transition: all 0.3s ease;
border: 2px solid transparent;
}
.model-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
}
.model-card.selected {
border-color: #67c23a;
background: #f0f9ff;
}
.model-card.builtin-model {
border-color: #fbbf24;
background: #fffbeb;
}
.model-card.builtin-model:hover {
border-color: #f59e0b;
}
.model-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.model-type {
display: inline-block;
padding: 2px 8px;
background: #eff6ff;
color: #3b82f6;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
}
.model-badges {
display: flex;
align-items: center;
gap: 8px;
}
.check-icon {
font-size: 20px;
}
.model-card-body {
flex: 1;
}
.model-name {
font-size: 16px;
font-weight: 600;
color: #1f2937;
margin: 0 0 8px 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.model-url {
font-size: 13px;
color: #64748b;
line-height: 1.5;
margin: 0 0 8px 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.model-status {
display: flex;
align-items: center;
gap: 8px;
}
.pagination-wrap {
display: flex;
justify-content: center;
margin-top: 20px;
}
</style>