414 lines
12 KiB
Vue
414 lines
12 KiB
Vue
<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>
|
||
</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) }}
|
||
<el-tag v-if="model.systemModel" size="small" type="warning" class="model-owner-tag">内置</el-tag>
|
||
<el-tag v-else size="small" type="success" class="model-owner-tag">我的</el-tag>
|
||
</div>
|
||
<el-icon v-if="selectedModel?.id === model.id" class="check-icon" color="#67c23a">
|
||
<CircleCheck />
|
||
</el-icon>
|
||
</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="isModelEnabled(model) ? 'success' : 'info'" size="small">
|
||
{{ isModelEnabled(model) ? '已启用' : '已禁用' }}
|
||
</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"
|
||
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>
|
||
</el-dialog>
|
||
|
||
<!-- 系统内置模型:填写 API Key,经修改接口转换为用户模型后自动绑定 -->
|
||
<el-dialog
|
||
v-model="apiKeyDialogVisible"
|
||
title="填写 API Key"
|
||
width="480px"
|
||
append-to-body
|
||
:close-on-click-modal="false"
|
||
@close="handleApiKeyClose"
|
||
>
|
||
<el-alert
|
||
type="info"
|
||
:closable="false"
|
||
show-icon
|
||
title="该模型为系统内置模型,填写你的 API Key 后将创建一条用户模型并自动绑定到当前节点。"
|
||
class="api-key-alert"
|
||
/>
|
||
<el-form label-position="top" class="api-key-form">
|
||
<el-form-item label="API Key" required>
|
||
<el-input
|
||
v-model="apiKeyForm.apiKey"
|
||
type="password"
|
||
show-password
|
||
placeholder="请输入你的 API Key"
|
||
@keyup.enter="handleApiKeyConfirm"
|
||
/>
|
||
</el-form-item>
|
||
</el-form>
|
||
<template #footer>
|
||
<el-button @click="handleApiKeyClose">取消</el-button>
|
||
<el-button type="primary" :loading="converting" @click="handleApiKeyConfirm">确认并转换</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, reactive, watch } from 'vue';
|
||
import { ElMessage } from 'element-plus';
|
||
import { Search, CircleCheck } from '@element-plus/icons-vue';
|
||
import { getWorkflowModelList, type WorkflowModelItem } from '/@/api/settings/workflow';
|
||
import { updateModelManage } from '/@/api/settings/modelConfigV2';
|
||
|
||
interface Props {
|
||
modelValue: boolean;
|
||
defaultModel?: WorkflowModelItem | null;
|
||
// 非空时仅列出同类型模型(首页「重新选择模型」场景);工作流管理不传,行为不变
|
||
sameTypeModelType?: string | number | null;
|
||
// 系统模型是否必须填写 API Key(默认 true)。
|
||
// 超级管理员绘制/编辑模板工作流时传 false:选系统模型直接选中,不再弹 API Key。
|
||
systemModelRequireKey?: boolean;
|
||
}
|
||
|
||
interface Emits {
|
||
(e: 'update:modelValue', value: boolean): void;
|
||
(e: 'confirm', model: WorkflowModelItem): void;
|
||
}
|
||
|
||
const props = withDefaults(defineProps<Props>(), {
|
||
modelValue: false,
|
||
defaultModel: null,
|
||
sameTypeModelType: null,
|
||
systemModelRequireKey: true,
|
||
});
|
||
|
||
const emit = defineEmits<Emits>();
|
||
|
||
const visible = ref(false);
|
||
const searchParams = reactive({ modelName: '' });
|
||
const pagination = reactive({ pageNum: 1, pageSize: 10, total: 0 });
|
||
const modelList = ref<WorkflowModelItem[]>([]);
|
||
const loading = ref(false);
|
||
const selectedModel = ref<WorkflowModelItem | null>(null);
|
||
// 系统内置模型(systemModel === true)转用户模型:填 API Key → 调修改接口,后端自动克隆出新用户模型
|
||
const apiKeyDialogVisible = ref(false);
|
||
const converting = ref(false);
|
||
const pendingCloneModel = ref<WorkflowModelItem | null>(null);
|
||
const apiKeyForm = reactive({ apiKey: '' });
|
||
|
||
watch(
|
||
() => props.modelValue,
|
||
(val) => {
|
||
visible.value = val;
|
||
if (val) {
|
||
selectedModel.value = props.defaultModel || null;
|
||
pagination.pageNum = 1;
|
||
fetchModelList();
|
||
}
|
||
}
|
||
);
|
||
|
||
watch(visible, (val) => {
|
||
if (!val) {
|
||
emit('update:modelValue', false);
|
||
}
|
||
});
|
||
|
||
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)] || '未知类型';
|
||
};
|
||
|
||
// 兼容 enabled 数字(1/0) 与布尔(true/false) 两种返回
|
||
const isModelEnabled = (model: WorkflowModelItem) => model.enabled === 1 || model.enabled === true;
|
||
|
||
const fetchModelList = async () => {
|
||
loading.value = true;
|
||
try {
|
||
const params = {
|
||
pageNum: pagination.pageNum,
|
||
pageSize: pagination.pageSize,
|
||
modelName: searchParams.modelName || undefined,
|
||
...(props.sameTypeModelType !== undefined && props.sameTypeModelType !== null && props.sameTypeModelType !== ''
|
||
? { isSameType: true, modelType: props.sameTypeModelType }
|
||
: {}),
|
||
};
|
||
const res = await getWorkflowModelList(params);
|
||
modelList.value = res.data?.list || [];
|
||
pagination.total = res.data?.total || 0;
|
||
// 预选项仅为部分信息(切换节点后无 id)时,按名称匹配当前页以高亮
|
||
if (selectedModel.value && !selectedModel.value.id && selectedModel.value.modelName) {
|
||
const matched = modelList.value.find((m) => m.modelName === selectedModel.value!.modelName);
|
||
if (matched) selectedModel.value = matched;
|
||
}
|
||
} catch {
|
||
modelList.value = [];
|
||
pagination.total = 0;
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
};
|
||
|
||
const handleSearch = () => {
|
||
pagination.pageNum = 1;
|
||
fetchModelList();
|
||
};
|
||
|
||
const handlePageChange = () => {
|
||
fetchModelList();
|
||
};
|
||
|
||
const handleSelectModel = (model: WorkflowModelItem) => {
|
||
// 系统内置模型:默认需填写 API Key,经修改接口转成用户模型后再绑定;
|
||
// 超级管理员绘制模板(systemModelRequireKey=false)时直接选中,模板保留系统模型供用户使用时再补 Key
|
||
if (model.systemModel && props.systemModelRequireKey) {
|
||
pendingCloneModel.value = model;
|
||
apiKeyForm.apiKey = '';
|
||
apiKeyDialogVisible.value = true;
|
||
return;
|
||
}
|
||
selectedModel.value = model;
|
||
};
|
||
|
||
const handleConfirm = () => {
|
||
if (selectedModel.value) {
|
||
emit('confirm', selectedModel.value);
|
||
handleClose();
|
||
}
|
||
};
|
||
|
||
// 确认 API Key:调用修改接口,后端克隆出用户模型并直接返回,前端立即绑定
|
||
const handleApiKeyConfirm = async () => {
|
||
if (!apiKeyForm.apiKey.trim()) {
|
||
ElMessage.warning('请输入 API Key');
|
||
return;
|
||
}
|
||
if (!pendingCloneModel.value?.id) return;
|
||
converting.value = true;
|
||
try {
|
||
const src = pendingCloneModel.value;
|
||
const res = await updateModelManage({
|
||
id: src.id,
|
||
// 传递系统模型全部配置,供后端克隆用户模型时继承
|
||
modelName: src.modelName,
|
||
modelType: src.modelType,
|
||
modelSupplier: src.modelSupplier,
|
||
baseUrl: src.baseUrl,
|
||
responseType: src.responseType ?? src.invokeType,
|
||
apiKey: apiKeyForm.apiKey.trim(),
|
||
enabled: src.enabled,
|
||
chatModel: src.ChatModel ?? src.chatModel,
|
||
maxConcurrency: src.maxConcurrency,
|
||
maxTokens: src.maxTokens,
|
||
tokenPredictPrice: src.tokenPredictPrice,
|
||
requestHeadMapping: src.requestHeadMapping ?? src.requestMapping,
|
||
requestBodyMapping: src.requestBodyMapping,
|
||
responseMapping: src.responseMapping,
|
||
responseBodyMapping: src.responseBodyMapping,
|
||
...(src.tokenMapping ? { tokenMapping: src.tokenMapping } : {}),
|
||
...(src.asyncTaskMapping ? { asyncTaskMapping: src.asyncTaskMapping } : {}),
|
||
...(src.lastFrame ? { lastFrame: src.lastFrame } : {}),
|
||
...(src.maxDuration ? { maxDuration: src.maxDuration } : {}),
|
||
...(src.tokenPredictPriceUnit ? { tokenPredictPriceUnit: src.tokenPredictPriceUnit } : {}),
|
||
} as any);
|
||
// 返回结构为 { data: { modelManage: {...} } },新用户模型 id 在 modelManage 中
|
||
const newModelManage = res?.data?.modelManage || res?.data;
|
||
if (!newModelManage?.id) throw new Error('接口未返回新的用户模型');
|
||
// 后端返回的 modelManage 仅带新 id/apiKey,modelName/requestBodyMapping 等为空。
|
||
// 用原系统模型完整配置 + 新 id 合并,保证绑定后模型名、参数表单、下游引用、isSameType 过滤都正常。
|
||
const newModel: WorkflowModelItem = {
|
||
...(pendingCloneModel.value || {}),
|
||
id: newModelManage.id,
|
||
systemModel: false,
|
||
apiKey: apiKeyForm.apiKey.trim(),
|
||
};
|
||
apiKeyDialogVisible.value = false;
|
||
pendingCloneModel.value = null;
|
||
emit('confirm', newModel);
|
||
handleClose();
|
||
ElMessage.success('已创建用户模型并绑定');
|
||
} catch (e: any) {
|
||
ElMessage.error(e?.message || '模型转换失败,请重试');
|
||
} finally {
|
||
converting.value = false;
|
||
}
|
||
};
|
||
|
||
const handleApiKeyClose = () => {
|
||
apiKeyDialogVisible.value = false;
|
||
pendingCloneModel.value = null;
|
||
};
|
||
|
||
const handleClose = () => {
|
||
visible.value = false;
|
||
selectedModel.value = null;
|
||
};
|
||
</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-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 12px;
|
||
}
|
||
.model-type {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 2px 8px;
|
||
background: #eff6ff;
|
||
color: #3b82f6;
|
||
border-radius: 4px;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
|
||
.model-owner-tag {
|
||
margin-left: 2px;
|
||
}
|
||
}
|
||
.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: 12px;
|
||
color: #94a3b8;
|
||
margin: 0 0 8px 0;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.model-status {
|
||
display: flex;
|
||
align-items: center;
|
||
}
|
||
.pagination-wrap {
|
||
display: flex;
|
||
justify-content: center;
|
||
margin-top: 20px;
|
||
}
|
||
|
||
.api-key-alert {
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.api-key-form {
|
||
margin-bottom: 4px;
|
||
}
|
||
</style>
|