Files
admin-ui/src/views/settings/workflow/component/ModelSelector.vue
T

414 lines
12 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>
</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/apiKeymodelName/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>