后端在返回的模型信息中携带 requestBusinessFieldMapping/responseBusinessFieldMapping/minDuration/priceConfig, 首页两处提交链路(SessionModelSetter、TemplateCompleteDialog)此前遗漏未传,现从模型对象原样透传。 Co-Authored-By: Claude <noreply@anthropic.com>
483 lines
13 KiB
Vue
483 lines
13 KiB
Vue
<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>
|