Files
admin-ui/src/views/home/components/SessionModelSetter.vue
T
2910410219andClaude e596c9b5ae 模型管理v2:系统模型填 API Key 转用户模型时透传 priceConfig 等4个业务字段
后端在返回的模型信息中携带 requestBusinessFieldMapping/responseBusinessFieldMapping/minDuration/priceConfig,
首页两处提交链路(SessionModelSetter、TemplateCompleteDialog)此前遗漏未传,现从模型对象原样透传。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-21 14:57:44 +08:00

483 lines
13 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="860px"
:close-on-click-modal="false"
@close="handleClose"
>
<!-- 当前会话模型提示 -->
<el-alert
v-if="props.currentChatModelName"
type="success"
:closable="false"
show-icon
class="current-model-alert"
>
<template #title>当前会话模型{{ props.currentChatModelName }}</template>
</el-alert>
<div class="setter-header">
<div class="search-bar">
<el-input v-model="searchKeyword" 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 && pagedModels.length === 0" description="暂无推理模型" :image-size="100" />
<div v-else class="model-grid">
<div
v-for="model in pagedModels"
:key="model.id"
class="model-card"
:class="{ selected: selectedModel?.id === model.id, active: String(model.id) === String(props.currentChatModelId) }"
@click="handleSelectModel(model)"
>
<div class="model-card-header">
<div class="model-name-row">
<span class="model-name">{{ model.modelName }}</span>
<el-tag v-if="model.systemModel" size="small" type="warning">内置</el-tag>
<el-tag v-else size="small" type="success">我的</el-tag>
</div>
<div class="model-icons">
<el-icon v-if="String(model.id) === String(props.currentChatModelId)" class="current-icon" color="#2563eb">
<CircleCheck />
</el-icon>
<el-icon v-if="selectedModel?.id === model.id" class="check-icon" color="#67c23a">
<CircleCheck />
</el-icon>
</div>
</div>
<div class="model-card-body">
<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 class="pagination-wrap">
<el-pagination
:current-page="pagination.pageNum"
:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 30, 50]"
layout="total, sizes, prev, pager, next"
background
@size-change="handleSizeChange"
@current-change="handlePageChange"
/>
</div>
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" :disabled="!selectedModel" :loading="saving" @click="handleConfirm">设为会话模型</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
class="api-key-alert"
title="该模型为系统内置模型,填写你的 API Key 后将自动转换为你的用户模型,并设为会话模型。"
/>
<el-form label-position="top" class="api-key-form">
<el-form-item label="API Key" required>
<el-input
v-model="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="saving" @click="handleApiKeyConfirm">确认并设置</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch } from 'vue';
import { ElMessage } from 'element-plus';
import { Search, CircleCheck } from '@element-plus/icons-vue';
import { listModelManage, getModelManageTypes, updateModelManage, type ModelManageItem, type ModelTypeTreeNode, type UpdateModelManageParams } from '/@/api/settings/modelConfigV2';
interface Props {
modelValue: boolean;
// 当前会话模型 id,用于高亮展示
currentChatModelId?: string | number | null;
// 当前会话模型名称,用于顶部提示
currentChatModelName?: string;
}
interface Emits {
(e: 'update:modelValue', value: boolean): void;
(e: 'saved', model: { id: string; modelName: string }): void;
}
const props = withDefaults(defineProps<Props>(), {
currentChatModelId: null,
currentChatModelName: '',
});
const emit = defineEmits<Emits>();
const visible = ref(false);
const searchKeyword = ref('');
const loading = ref(false);
const saving = ref(false);
const modelList = ref<ModelManageItem[]>([]);
const reasoningTypes = ref<Set<number>>(new Set());
const selectedModel = ref<ModelManageItem | null>(null);
const pagination = reactive({ pageNum: 1, pageSize: 10, total: 0 });
// 系统内置模型:填 API Key → updateModelManage 转用户模型
const apiKeyDialogVisible = ref(false);
const pendingSystemModel = ref<ModelManageItem | null>(null);
const apiKey = ref('');
watch(
() => props.modelValue,
(val) => {
visible.value = val;
if (val) {
selectedModel.value = null;
searchKeyword.value = '';
pagination.pageNum = 1;
fetchData();
}
}
);
watch(visible, (val) => {
if (!val) emit('update:modelValue', false);
});
// 兼容 enabled 数字(1/0) 与布尔(true/false)
const isModelEnabled = (model: ModelManageItem) => model.enabled === true || (model as any).enabled === 1;
// 将节点及其整棵子树的所有 value 加入集合
const addSubtreeValues = (node: ModelTypeTreeNode, set: Set<number>) => {
set.add(Number(node.value));
for (const child of node.children || []) {
addSubtreeValues(child, set);
}
};
// 递归遍历类型树,收集"推理"类节点的整棵子树 value 作为推理类型集合。
// 例如 推理模型(100) 下的 101 对话/补全、102 思维链、103 函数调用 都属于推理类型,
// 与 editModule 中"父路径标签含推理即算推理模型"的判断语义一致。
const collectReasoningTypes = (nodes: ModelTypeTreeNode[], set: Set<number>) => {
for (const n of nodes || []) {
const label = String(n.label || '');
if (label.includes('推理') || label.toLowerCase().includes('reasoning')) {
addSubtreeValues(n, set);
continue;
}
if (n.children) collectReasoningTypes(n.children, set);
}
};
// 拉取全部模型(分页合并),避免因分页截断导致前端过滤不完整
const fetchAllModels = async () => {
const all: ModelManageItem[] = [];
const pageSize = 100;
const first: any = await listModelManage({ pageNum: 1, pageSize });
all.push(...(first?.data?.list || []));
const total = first?.data?.total || 0;
const pages = Math.ceil(total / pageSize);
for (let p = 2; p <= pages; p++) {
const r: any = await listModelManage({ pageNum: p, pageSize });
all.push(...(r?.data?.list || []));
}
return all;
};
const fetchData = async () => {
loading.value = true;
try {
// 类型树
const typeRes: any = await getModelManageTypes();
const set = new Set<number>();
collectReasoningTypes(typeRes?.data?.list || [], set);
reasoningTypes.value = set;
// 全量模型
const all = await fetchAllModels();
modelList.value = all.filter((m) => {
// 推理类型集合非空时按类型过滤;集合为空(接口异常)则退回显示全部启用模型
if (reasoningTypes.value.size > 0 && !reasoningTypes.value.has(Number(m.modelType))) return false;
return isModelEnabled(m);
});
} catch {
modelList.value = [];
} finally {
loading.value = false;
}
};
const filteredModels = computed(() => {
const kw = searchKeyword.value.trim();
if (!kw) return modelList.value;
return modelList.value.filter((m) => (m.modelName || '').includes(kw));
});
// 本地分页切片
const pagedModels = computed(() => {
const start = (pagination.pageNum - 1) * pagination.pageSize;
return filteredModels.value.slice(start, start + pagination.pageSize);
});
watch(
filteredModels,
() => {
pagination.total = filteredModels.value.length;
},
{ immediate: true }
);
const handleSearch = () => {
pagination.pageNum = 1;
};
const handlePageChange = (page: number) => {
pagination.pageNum = page;
};
const handleSizeChange = (size: number) => {
pagination.pageSize = size;
pagination.pageNum = 1;
};
const handleSelectModel = (model: ModelManageItem) => {
// 系统内置模型:先填 API Key,经修改接口转成用户模型并设为会话模型
if (model.systemModel) {
pendingSystemModel.value = model;
apiKey.value = '';
apiKeyDialogVisible.value = true;
return;
}
selectedModel.value = model;
};
const handleConfirm = async () => {
if (!selectedModel.value) return;
await doUpdate(selectedModel.value);
};
const handleApiKeyConfirm = async () => {
if (!apiKey.value.trim()) {
ElMessage.warning('请输入 API Key');
return;
}
if (!pendingSystemModel.value) return;
await doUpdate(pendingSystemModel.value, apiKey.value.trim());
};
// 系统模型转用户模型:需把模型完整配置传给后端,由后端复制为当前用户的模型副本
const buildFullPayload = (model: ModelManageItem, apiKeyVal: string): UpdateModelManageParams => {
const safeObj = (v: unknown): Record<string, unknown> | undefined =>
v && typeof v === 'object' ? (v as Record<string, unknown>) : undefined;
return {
id: model.id,
modelSupplier: Number(model.modelSupplier),
modelName: model.modelName,
modelType: Number(model.modelType),
baseUrl: model.baseUrl,
responseType: Number(model.responseType),
apiKey: apiKeyVal,
enabled: model.enabled,
chatModel: true,
maxConcurrency: model.maxConcurrency,
maxTokens: model.maxTokens,
tokenPredictPrice: model.tokenPredictPrice,
requestHeadMapping: safeObj(model.requestHeadMapping),
requestBodyMapping: safeObj(model.requestBodyMapping),
requestBusinessFieldMapping: model.requestBusinessFieldMapping,
responseMapping: safeObj(model.responseMapping),
responseBodyMapping: (safeObj(model.responseBodyMapping) as Record<string, string>) || undefined,
responseBusinessFieldMapping: model.responseBusinessFieldMapping,
tokenMapping: model.tokenMapping,
asyncTaskMapping: model.asyncTaskMapping,
lastFrame: model.lastFrame || '',
maxDuration: model.maxDuration,
minDuration: model.minDuration,
tokenPredictPriceUnit: model.tokenPredictPriceUnit || '',
priceConfig: model.priceConfig,
};
};
const doUpdate = async (model: ModelManageItem, apiKeyVal?: string) => {
saving.value = true;
try {
// 系统模型(填 API Key)转用户模型时传完整模型数据;用户自己的模型仅传 id + chatModel
const payload: UpdateModelManageParams = apiKeyVal ? buildFullPayload(model, apiKeyVal) : { id: model.id, chatModel: true };
const res: any = await updateModelManage(payload);
// 系统模型转用户模型:后端返回新的 modelManage(含新 id
const newId = res?.data?.modelManage?.id || res?.data?.id || model.id;
emit('saved', { id: String(newId), modelName: model.modelName });
apiKeyDialogVisible.value = false;
pendingSystemModel.value = null;
selectedModel.value = null;
visible.value = false;
} catch (e: any) {
ElMessage.error(e?.message || '设置会话模型失败');
} finally {
saving.value = false;
}
};
const handleApiKeyClose = () => {
apiKeyDialogVisible.value = false;
pendingSystemModel.value = null;
};
const handleClose = () => {
visible.value = false;
selectedModel.value = null;
};
</script>
<style scoped lang="scss">
.current-model-alert {
margin-bottom: 16px;
}
.setter-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: 420px;
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;
&:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
}
&.selected {
border-color: #67c23a;
background: #f0f9ff;
}
&.active {
border-color: #2563eb;
background: #eff6ff;
}
}
.model-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.model-name-row {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.model-icons {
display: inline-flex;
align-items: center;
gap: 4px;
}
.current-icon,
.check-icon {
font-size: 20px;
}
.model-card-body {
flex: 1;
}
.model-name {
font-size: 16px;
font-weight: 600;
color: #1f2937;
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>