sub_flow 节点引入工作流修复与首页执行工作流重构
- 引入工作流列表字段兼容 flowName,选择弹窗卡片正常展示数据
- 生成次数 maxConcurrency 同步保存到 sub_flow 节点,回显兜底恢复
- valueSource 按后端契约统一 {nodeId, fieldName},表单展示/引用正确保存与回显
- 首页执行工作流 DSL 字段路径改为 .enumValues[i],支持引用上游输出与上传文件名
This commit is contained in:
@@ -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