sub_flow 节点引入工作流修复与首页执行工作流重构
- 引入工作流列表字段兼容 flowName,选择弹窗卡片正常展示数据
- 生成次数 maxConcurrency 同步保存到 sub_flow 节点,回显兜底恢复
- valueSource 按后端契约统一 {nodeId, fieldName},表单展示/引用正确保存与回显
- 首页执行工作流 DSL 字段路径改为 .enumValues[i],支持引用上游输出与上传文件名
This commit is contained in:
@@ -37,7 +37,13 @@
|
||||
<el-icon><MagicStick /></el-icon>
|
||||
<span class="tool-label">技能</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 切换会话模型入口 -->
|
||||
<button class="tool-icon-btn" :class="{ active: !!currentModelName }" title="切换会话模型" @click="emit('select-model')">
|
||||
<el-icon><Cpu /></el-icon>
|
||||
<span class="tool-label">{{ currentModelName || '设置模型' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="toolbar-right">
|
||||
<span class="hint-text">Shift+Enter 换行</span>
|
||||
@@ -75,7 +81,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { Top, MagicStick, Promotion, Close, Paperclip, VideoPause } from '@element-plus/icons-vue';
|
||||
import { Top, MagicStick, Promotion, Close, Paperclip, VideoPause, Cpu } from '@element-plus/icons-vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { getWorkflowList } from '/@/api/settings/creation';
|
||||
|
||||
@@ -84,12 +90,15 @@ interface Props {
|
||||
workflowLocked?: boolean;
|
||||
hideShortcuts?: boolean;
|
||||
generating?: boolean;
|
||||
// 当前会话模型名称,用于输入栏切换入口展示
|
||||
currentModelName?: string;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'send', message: string): void;
|
||||
(e: 'workflow-select', workflowId: string | null, isTemplate?: boolean): void;
|
||||
(e: 'stop'): void;
|
||||
(e: 'select-model'): void;
|
||||
}
|
||||
|
||||
interface Workflow {
|
||||
@@ -105,6 +114,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
workflowLocked: false,
|
||||
hideShortcuts: false,
|
||||
generating: false,
|
||||
currentModelName: '',
|
||||
});
|
||||
const emit = defineEmits<Emits>();
|
||||
const message = ref('');
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<el-form label-position="top" class="workflow-form">
|
||||
<template v-if="workflowDetail.nodeInputParams">
|
||||
<template v-for="node in workflowDetail.nodeInputParams" :key="node.id || node.nodeCode">
|
||||
<template v-if="node.nodeCode !== '__start__' && hasFormConfig(node)">
|
||||
<template v-if="hasFormConfig(node)">
|
||||
|
||||
<el-form-item
|
||||
v-for="field in getVisibleFields(node)"
|
||||
@@ -22,6 +22,13 @@
|
||||
:label="field.label"
|
||||
:required="field.required"
|
||||
>
|
||||
<!-- 引用其他节点输出:只读展示,值由上游节点运行时提供 -->
|
||||
<div v-if="field.valueSource" class="field-source-readonly">
|
||||
<el-tag type="info" size="small" class="source-tag">引用</el-tag>
|
||||
<span class="source-text">由「{{ getSourceDisplay(field) }}」自动提供</span>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- 文本输入 -->
|
||||
<el-input
|
||||
v-if="field.type === 'input' || field.type === 'string'"
|
||||
@@ -114,6 +121,7 @@
|
||||
v-model="formValues[getFieldKey(node, field)]"
|
||||
:placeholder="field.required ? '必填' : '选填'"
|
||||
/>
|
||||
</template>
|
||||
</el-form-item>
|
||||
</template>
|
||||
</template>
|
||||
@@ -225,6 +233,8 @@ const hasResults = computed(() => Array.isArray(props.results) && props.results.
|
||||
const hasWorkflowResults = computed(() => Array.isArray(props.results) && props.results.some((r) => r.type === 'workflow'));
|
||||
const formValues = reactive<Record<string, any>>({});
|
||||
const fieldFiles = reactive<Record<string, { name: string; url: string }[]>>({});
|
||||
// 上传字段的文件名(与 formValues 平行;单文件为字符串、多文件为数组),执行时随 value 一并写回开始节点传给后端
|
||||
const formFileNames = reactive<Record<string, string | string[]>>({});
|
||||
const uploadingFields = reactive<Record<string, boolean>>({});
|
||||
const templates = ref<any[]>([]);
|
||||
const getFieldKey = (node: any, field: any): string => {
|
||||
@@ -236,6 +246,16 @@ const getVisibleFields = (node: any): any[] => {
|
||||
return collectHomeFormFields(node);
|
||||
};
|
||||
|
||||
// 引用来源展示:valueSource.nodeId 定位上游节点名 + 字段
|
||||
const getSourceDisplay = (field: any): string => {
|
||||
const vs = field?.valueSource;
|
||||
if (!vs || typeof vs !== 'object') return '';
|
||||
const nodes = props.workflowDetail?.nodeInputParams || [];
|
||||
const srcNode = nodes.find((n: any) => String(n?.id) === String(vs.nodeId));
|
||||
const nodeName = srcNode?.name || srcNode?.nodeName || (vs.nodeId ? `节点 ${vs.nodeId}` : '上游节点');
|
||||
return vs.field ? `${nodeName} · ${vs.field}` : nodeName;
|
||||
};
|
||||
|
||||
const isFileField = (field: any): boolean => {
|
||||
return field.type === 'upload' || field.type === 'uploadMultiple' || field.type === 'fileUpload';
|
||||
};
|
||||
@@ -317,12 +337,15 @@ const handleFileUpload = async (node: any, field: any, file: any) => {
|
||||
: uploadRes.data.fileURL;
|
||||
|
||||
if (!fieldFiles[key]) fieldFiles[key] = [];
|
||||
fieldFiles[key].push({ name: raw.name, url: fileUrl });
|
||||
// 文件名取服务器返回 fileName(OSS 存储名,已确认),随 value 一并写回开始节点传给后端
|
||||
fieldFiles[key].push({ name: uploadRes.data.fileName || raw.name, url: fileUrl });
|
||||
|
||||
if (field.type === 'upload') {
|
||||
formValues[key] = fileUrl;
|
||||
formFileNames[key] = fieldFiles[key][0]?.name;
|
||||
} else {
|
||||
formValues[key] = fieldFiles[key].map((f: any) => f.url);
|
||||
formFileNames[key] = fieldFiles[key].map((f: any) => f.name);
|
||||
}
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.message || '文件上传失败');
|
||||
@@ -337,8 +360,10 @@ const removeFieldFile = (node: any, field: any, fileIdx: number) => {
|
||||
fieldFiles[key].splice(fileIdx, 1);
|
||||
if (field.type === 'upload') {
|
||||
formValues[key] = '';
|
||||
formFileNames[key] = '';
|
||||
} else {
|
||||
formValues[key] = fieldFiles[key].map((f) => f.url);
|
||||
formFileNames[key] = fieldFiles[key].map((f) => f.name);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -348,23 +373,24 @@ const currentWorkflowHasPatchLayout = computed(() => {
|
||||
});
|
||||
|
||||
const hasFormConfig = (node: any): boolean => {
|
||||
return node.nodeCode !== '__start__' && collectHomeFormFields(node).length > 0;
|
||||
// 开始节点为唯一表单源(runFormFields 汇总各 model 勾选字段 + form 自定义字段)
|
||||
return String(node?.nodeCode || '').toLowerCase() === '__start__' && collectHomeFormFields(node).length > 0;
|
||||
};
|
||||
|
||||
const hasFormFields = computed(() => {
|
||||
if (!props.workflowDetail?.nodeInputParams) return false;
|
||||
return props.workflowDetail.nodeInputParams.some(
|
||||
(node: any) => node.nodeCode !== '__start__' && collectHomeFormFields(node).length > 0
|
||||
);
|
||||
return props.workflowDetail.nodeInputParams.some((node: any) => hasFormConfig(node));
|
||||
});
|
||||
|
||||
const validateFormFields = (): boolean => {
|
||||
if (!props.workflowDetail?.nodeInputParams) return true;
|
||||
for (const node of props.workflowDetail.nodeInputParams as any[]) {
|
||||
if (node.nodeCode === '__start__') continue;
|
||||
if (String(node?.nodeCode || '').toLowerCase() !== '__start__') continue;
|
||||
const fields = getVisibleFields(node);
|
||||
for (const field of fields) {
|
||||
if (!field.required) continue;
|
||||
// 引用其他节点输出的字段:值由上游节点运行时提供,非用户填写项,跳过必填校验
|
||||
if (field.valueSource) continue;
|
||||
const key = getFieldKey(node, field);
|
||||
const value = formValues[key];
|
||||
if (value === undefined || value === null || value === '' || (Array.isArray(value) && value.length === 0)) {
|
||||
@@ -382,6 +408,7 @@ watch(
|
||||
(detail) => {
|
||||
Object.keys(formValues).forEach((key) => delete formValues[key]);
|
||||
Object.keys(fieldFiles).forEach((key) => delete fieldFiles[key]);
|
||||
Object.keys(formFileNames).forEach((key) => delete formFileNames[key]);
|
||||
Object.keys(uploadingFields).forEach((key) => delete uploadingFields[key]);
|
||||
// 尝试从执行详情恢复贴片模板
|
||||
const ext = (detail as any)?.extension;
|
||||
@@ -430,10 +457,19 @@ watch(
|
||||
const rawValue = formValues[key];
|
||||
const urls = Array.isArray(rawValue) ? rawValue : rawValue ? [rawValue] : [];
|
||||
if (urls.length === 0) return;
|
||||
fieldFiles[key] = urls.map((url: string) => ({
|
||||
name: String(url || '').split('/').pop() || 'file-' + Math.random().toString(36).slice(2, 8),
|
||||
// 文件名优先取运行字段携带的服务器名(新数据),旧数据从 url 提取兜底
|
||||
const rawFn = (field as any).fileName;
|
||||
fieldFiles[key] = urls.map((url: string, i: number) => ({
|
||||
name:
|
||||
(Array.isArray(rawFn) ? rawFn[i] : i === 0 ? rawFn : undefined) ||
|
||||
String(url || '').split('/').pop() ||
|
||||
'file-' + Math.random().toString(36).slice(2, 8),
|
||||
url,
|
||||
}));
|
||||
formFileNames[key] =
|
||||
field.type === 'upload' && fieldFiles[key].length === 1
|
||||
? fieldFiles[key][0].name
|
||||
: fieldFiles[key].map((f) => f.name);
|
||||
});
|
||||
});
|
||||
},
|
||||
@@ -461,7 +497,7 @@ onMounted(() => {
|
||||
loadPlaceWorkflows();
|
||||
});
|
||||
|
||||
defineExpose({ formValues, fieldFiles, templates, validateFormFields });
|
||||
defineExpose({ formValues, fieldFiles, formFileNames, templates, validateFormFields });
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -614,6 +650,21 @@ defineExpose({ formValues, fieldFiles, templates, validateFormFields });
|
||||
}
|
||||
}
|
||||
|
||||
/* 引用字段只读展示 */
|
||||
.field-source-readonly {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
.source-tag { flex-shrink: 0; }
|
||||
.source-text { line-height: 1.5; }
|
||||
}
|
||||
|
||||
.w100 { width: 100%; }
|
||||
.chat-container { width: min(1060px, 82%); margin: 0 auto; padding: 20px 0 130px; }
|
||||
|
||||
|
||||
@@ -0,0 +1,478 @@
|
||||
<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),
|
||||
responseMapping: safeObj(model.responseMapping),
|
||||
responseBodyMapping: (safeObj(model.responseBodyMapping) as Record<string, string>) || undefined,
|
||||
tokenMapping: model.tokenMapping,
|
||||
asyncTaskMapping: model.asyncTaskMapping,
|
||||
lastFrame: model.lastFrame || '',
|
||||
maxDuration: model.maxDuration,
|
||||
tokenPredictPriceUnit: model.tokenPredictPriceUnit || '',
|
||||
};
|
||||
};
|
||||
|
||||
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>
|
||||
Reference in New Issue
Block a user