websocket相关
This commit is contained in:
@@ -219,3 +219,14 @@ export function updateModelManage(data: UpdateModelManageParams) {
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前会话模型(普通对话执行时使用的模型 id)
|
||||
* 返回兼容 v2 惯例:data.modelManage.id(亦兼容 data.id / data.modelId)
|
||||
*/
|
||||
export function getChatModel() {
|
||||
return request({
|
||||
url: '/model-gateway/model/manage/getChatModel',
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import request from '/@/utils/request';
|
||||
|
||||
/**
|
||||
* 查询会话问答消息记录(普通对话历史)。
|
||||
* 依赖后端接口,URL/返回结构待后端提供;接口未就绪前由调用方以 localStorage 兜底。
|
||||
*/
|
||||
export function getSessionMessages(sessionId: string) {
|
||||
return request({
|
||||
url: '/ai-agent/session/getMessages',
|
||||
method: 'get',
|
||||
params: { sessionId },
|
||||
});
|
||||
}
|
||||
@@ -1,8 +1,18 @@
|
||||
<template>
|
||||
<div class="chat-list">
|
||||
<div v-for="msg in messages" :key="msg.id" class="message-row" :class="{ 'is-user': msg.isUser }">
|
||||
<div ref="chatListRef" class="chat-list">
|
||||
<div
|
||||
v-for="msg in messages"
|
||||
:key="msg.id"
|
||||
class="message-row"
|
||||
:class="{ 'is-user': msg.isUser, 'is-error': !msg.isUser && isErrorMsg(msg) }"
|
||||
>
|
||||
<div class="bubble-wrap">
|
||||
<div class="bubble">{{ msg.content }}</div>
|
||||
<div class="bubble">
|
||||
<template v-if="msg.loading">
|
||||
<span class="loading-text">思考中</span><span class="loading-dots"><i></i><i></i><i></i></span>
|
||||
</template>
|
||||
<template v-else>{{ msg.content }}</template>
|
||||
</div>
|
||||
<div class="time">{{ msg.time }}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -10,28 +20,48 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { nextTick, ref, watch } from 'vue';
|
||||
|
||||
interface ChatMessage {
|
||||
id: string;
|
||||
content: string;
|
||||
time: string;
|
||||
isUser: boolean;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
messages: ChatMessage[];
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
const props = defineProps<Props>();
|
||||
const chatListRef = ref<HTMLElement | null>(null);
|
||||
|
||||
const isErrorMsg = (msg: ChatMessage): boolean => !msg.isUser && String(msg.content || '').startsWith('❌');
|
||||
|
||||
// 消息新增或内容变化时自动滚动到底部
|
||||
watch(
|
||||
() => props.messages.map((m) => `${m.id}|${m.content}|${m.loading ? 1 : 0}`).join('\n'),
|
||||
() => {
|
||||
nextTick(() => {
|
||||
const el = chatListRef.value;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
});
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.chat-list {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
gap: 16px;
|
||||
padding: 28px 0 10px;
|
||||
scrollbar-width: none;
|
||||
&::-webkit-scrollbar { display: none; }
|
||||
}
|
||||
|
||||
.message-row {
|
||||
@@ -70,6 +100,42 @@ defineProps<Props>();
|
||||
color: #fff;
|
||||
box-shadow: 0 8px 24px rgba(37, 99, 235, 0.35);
|
||||
}
|
||||
|
||||
.message-row.is-error & {
|
||||
background: #fef2f2;
|
||||
border-color: rgba(254, 226, 226, 0.95);
|
||||
color: #dc2626;
|
||||
}
|
||||
}
|
||||
|
||||
/* 执行中占位 */
|
||||
.loading-text {
|
||||
color: #94a3b8;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.loading-dots {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
margin-left: 6px;
|
||||
|
||||
i {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
background: #94a3b8;
|
||||
animation: dot-blink 1.2s infinite ease-in-out both;
|
||||
|
||||
&:nth-child(1) { animation-delay: 0s; }
|
||||
&:nth-child(2) { animation-delay: 0.15s; }
|
||||
&:nth-child(3) { animation-delay: 0.3s; }
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes dot-blink {
|
||||
0%, 80%, 100% { opacity: 0.2; }
|
||||
40% { opacity: 1; }
|
||||
}
|
||||
|
||||
.time {
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
|
||||
<div class="toolbar-right">
|
||||
<span class="hint-text">Shift+Enter 换行</span>
|
||||
<button class="send-btn" :disabled="selectedWorkflowId === null || sendDisabled" @click="handleSend">
|
||||
<button class="send-btn" :disabled="sendDisabled" @click="handleSend">
|
||||
<el-icon><Top /></el-icon>
|
||||
</button>
|
||||
</div>
|
||||
@@ -126,7 +126,7 @@ const fetchWorkflows = async () => {
|
||||
};
|
||||
|
||||
const handleSend = () => {
|
||||
if (selectedWorkflowId.value === null || props.sendDisabled) return;
|
||||
if (props.sendDisabled) return;
|
||||
const msg = message.value.trim();
|
||||
emit('send', msg || '');
|
||||
message.value = '';
|
||||
|
||||
@@ -129,6 +129,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 对话页 — 无工作流但有消息时展示消息流 -->
|
||||
<div v-else-if="hasMessages" class="content-body chat-body">
|
||||
<ChatList :messages="messages" />
|
||||
</div>
|
||||
|
||||
<!-- 默认占位 — 无工作流时显示引导 -->
|
||||
<div v-else class="content-body placeholder-body">
|
||||
<div class="placeholder-content">
|
||||
@@ -191,16 +196,21 @@ import { ElMessage } from 'element-plus';
|
||||
import PatchTemplateEditor from '/@/components/patchTemplate/PatchTemplateEditor.vue';
|
||||
import { uploadFile } from '/@/api/common/upload';
|
||||
import { collectHomeFormFields } from '../utils/flowDsl';
|
||||
import ChatList from './ChatList.vue';
|
||||
|
||||
interface Props {
|
||||
activeMenu: string;
|
||||
workflowDetail: any;
|
||||
activeHistoryId?: string | null;
|
||||
messages?: any[];
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
activeHistoryId: null,
|
||||
messages: () => [],
|
||||
});
|
||||
|
||||
const hasMessages = computed(() => Array.isArray(props.messages) && props.messages.length > 0);
|
||||
const formValues = reactive<Record<string, any>>({});
|
||||
const fieldFiles = reactive<Record<string, { name: string; url: string }[]>>({});
|
||||
const uploadingFields = reactive<Record<string, boolean>>({});
|
||||
@@ -558,6 +568,20 @@ defineExpose({ formValues, fieldFiles, templates, validateFormFields });
|
||||
.w100 { width: 100%; }
|
||||
.chat-container { width: min(1060px, 82%); margin: 0 auto; padding: 20px 0 130px; }
|
||||
|
||||
/* ===== 对话页 ===== */
|
||||
.chat-body {
|
||||
width: min(1060px, 82%);
|
||||
margin: 0 auto;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0 20px;
|
||||
box-sizing: border-box;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
&::-webkit-scrollbar { display: none; }
|
||||
}
|
||||
|
||||
.placeholder-body { display: flex; align-items: center; justify-content: center; }
|
||||
.placeholder-content { text-align: center; padding: 20px; max-width: 480px; }
|
||||
|
||||
|
||||
@@ -35,19 +35,21 @@
|
||||
</div>
|
||||
|
||||
<div class="tc-node-model">
|
||||
<el-button
|
||||
v-if="node.modelConfig?.modelId"
|
||||
type="warning"
|
||||
plain
|
||||
size="small"
|
||||
:loading="converting"
|
||||
@click="openApiKeyDialog(node)"
|
||||
>
|
||||
补全 API Key
|
||||
</el-button>
|
||||
<el-button type="primary" plain size="small" @click="openModelSelector(node)">
|
||||
{{ node.modelConfig?.modelId ? '重新选择模型' : '选择模型' }}
|
||||
</el-button>
|
||||
<template v-if="node.modelConfig?.modelId">
|
||||
<!-- 系统模型:直接内联显示 API Key 输入框,保存时统一转换 -->
|
||||
<div v-if="systemModelNodeIds[node.id]" class="tc-api-key-inline">
|
||||
<el-input
|
||||
v-model="apiKeyInputs[node.id]"
|
||||
type="password"
|
||||
show-password
|
||||
placeholder="该模型为系统内置模型,请输入你的 API Key"
|
||||
size="small"
|
||||
clearable
|
||||
/>
|
||||
</div>
|
||||
<el-button type="primary" plain size="small" @click="openModelSelector(node)">重新选择模型</el-button>
|
||||
</template>
|
||||
<el-button v-else type="primary" plain size="small" @click="openModelSelector(node)">选择模型</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -66,44 +68,12 @@
|
||||
@confirm="handleModelConfirm"
|
||||
/>
|
||||
|
||||
<!-- 基于模板已有模型补全 API Key:系统模型 → 填 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>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, watch } from 'vue';
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import ModelSelector from '/@/views/settings/workflow/component/ModelSelector.vue';
|
||||
import { stripReadonlyFields, deepClone } from '/@/views/settings/workflow/component/modelParamUtils';
|
||||
@@ -133,18 +103,19 @@ const modelSelectorVisible = ref(false);
|
||||
const selectorTargetNode = ref<any>(null);
|
||||
const selectorDefaultModel = ref<any>(null);
|
||||
|
||||
// 补全 API Key:基于模板已有模型(系统模型)填 Key 转用户模型
|
||||
const apiKeyDialogVisible = ref(false);
|
||||
const converting = ref(false);
|
||||
const apiKeyForm = reactive({ apiKey: '' });
|
||||
const apiKeyTargetNode = ref<any>(null);
|
||||
const apiKeySourceModel = ref<any>(null);
|
||||
// 补全 API Key:内联输入框暂存 Key,保存时统一转换为用户模型
|
||||
const apiKeyInputs = ref<Record<string, string>>({});
|
||||
const systemModelNodeIds = ref<Record<string, boolean>>({});
|
||||
|
||||
const modelNodes = computed(() => nodes.value.filter((n) => String(n?.nodeCode || '').toLowerCase() === 'model'));
|
||||
|
||||
const canSave = computed(() => {
|
||||
if (!flowName.value.trim()) return false;
|
||||
return modelNodes.value.every((n) => !!n.modelConfig?.modelId);
|
||||
// 所有模型节点都必须已绑定模型
|
||||
if (modelNodes.value.some((n) => !n.modelConfig?.modelId)) return false;
|
||||
// 系统模型节点必须已填写 API Key(未补全不可保存)
|
||||
if (modelNodes.value.some((n) => systemModelNodeIds.value[n.id] && !(apiKeyInputs.value[n.id] || '').trim())) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
watch(
|
||||
@@ -156,6 +127,8 @@ watch(
|
||||
flowName.value = '我的-' + (props.template?.flowName || '系统模板');
|
||||
nodes.value = [];
|
||||
templateFlowContent.value = null;
|
||||
apiKeyInputs.value = {};
|
||||
systemModelNodeIds.value = {};
|
||||
try {
|
||||
// 优先拉取模板完整 DSL(get 接口),失败则回退列表项 flowContent
|
||||
const res = await getWorkflowDetail(props.template?.id);
|
||||
@@ -168,6 +141,8 @@ watch(
|
||||
templateFlowContent.value = fc || null;
|
||||
nodes.value = JSON.parse(JSON.stringify(fc?.nodes || []));
|
||||
} finally {
|
||||
// 先判断各已绑定模型节点是否需要补 API Key(系统模型),完成后再结束 loading,避免竞态
|
||||
await checkModelApiKey();
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
@@ -190,97 +165,71 @@ const handleModelConfirm = (model: any) => {
|
||||
// 深拷贝模型 requestBodyMapping 作为参数模板,剔除只读字段
|
||||
node.modelConfig.modelRequestParams = stripReadonlyFields(deepClone(model.requestBodyMapping ?? null));
|
||||
node.modelConfig.modelResponseBodyMapping = model.responseBodyMapping ?? null;
|
||||
// 选中为用户模型,无需再补 API Key
|
||||
systemModelNodeIds.value[node.id] = false;
|
||||
apiKeyInputs.value[node.id] = '';
|
||||
}
|
||||
modelSelectorVisible.value = false;
|
||||
selectorTargetNode.value = null;
|
||||
};
|
||||
|
||||
// 补全 API Key:拉取当前节点绑定模型详情,仅系统模型可转换
|
||||
const openApiKeyDialog = async (node: any) => {
|
||||
const modelId = node?.modelConfig?.modelId;
|
||||
if (!modelId) {
|
||||
ElMessage.warning('请先选择模型');
|
||||
return;
|
||||
}
|
||||
converting.value = true;
|
||||
try {
|
||||
const res = await getModelManage(modelId);
|
||||
const model = res?.data;
|
||||
if (!model?.id) throw new Error('模型详情获取失败');
|
||||
// 已是用户模型则无需补全
|
||||
if (model.systemModel !== true) {
|
||||
ElMessage.info('该模型已是用户模型,无需补全 API Key');
|
||||
return;
|
||||
}
|
||||
apiKeyTargetNode.value = node;
|
||||
apiKeySourceModel.value = model;
|
||||
apiKeyForm.apiKey = '';
|
||||
apiKeyDialogVisible.value = true;
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '获取模型详情失败,请重试');
|
||||
} finally {
|
||||
converting.value = false;
|
||||
}
|
||||
// 打开弹窗后:判断每个已绑定模型节点是否为系统模型(需补 API Key)
|
||||
const checkModelApiKey = async () => {
|
||||
const targets = modelNodes.value.filter((n) => n.modelConfig?.modelId);
|
||||
await Promise.all(
|
||||
targets.map(async (node) => {
|
||||
try {
|
||||
const res = await getModelManage(node.modelConfig.modelId);
|
||||
const model = res?.data?.modelManage || res?.data;
|
||||
if (model?.systemModel === true) {
|
||||
systemModelNodeIds.value[node.id] = true;
|
||||
} else {
|
||||
systemModelNodeIds.value[node.id] = false;
|
||||
apiKeyInputs.value[node.id] = '';
|
||||
}
|
||||
} catch {
|
||||
// 详情获取失败:不强制补 Key,避免阻塞保存
|
||||
systemModelNodeIds.value[node.id] = false;
|
||||
}
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
// 确认补全:调修改接口,后端克隆出用户模型并返回新 id,前端立即绑定
|
||||
const handleApiKeyConfirm = async () => {
|
||||
if (!apiKeyForm.apiKey.trim()) {
|
||||
ElMessage.warning('请输入 API Key');
|
||||
return;
|
||||
}
|
||||
const node = apiKeyTargetNode.value;
|
||||
const src = apiKeySourceModel.value;
|
||||
if (!node || !src?.id) return;
|
||||
converting.value = true;
|
||||
try {
|
||||
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('接口未返回新的用户模型');
|
||||
// 更新节点模型配置:替换为新用户模型 id,其余沿用原系统模型信息
|
||||
node.modelConfig = node.modelConfig || {};
|
||||
node.modelConfig.modelId = newModelManage.id;
|
||||
node.modelConfig.modelName = src.modelName;
|
||||
node.modelConfig.modelType = src.modelType;
|
||||
apiKeyDialogVisible.value = false;
|
||||
apiKeyTargetNode.value = null;
|
||||
apiKeySourceModel.value = null;
|
||||
ElMessage.success('已创建用户模型并绑定');
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '模型转换失败,请重试');
|
||||
} finally {
|
||||
converting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleApiKeyClose = () => {
|
||||
apiKeyDialogVisible.value = false;
|
||||
apiKeyTargetNode.value = null;
|
||||
apiKeySourceModel.value = null;
|
||||
// 将系统模型克隆为用户模型并绑定到节点(保存时统一调用)
|
||||
const convertSystemModel = async (node: any) => {
|
||||
const res = await getModelManage(node.modelConfig.modelId);
|
||||
const src = res?.data?.modelManage || res?.data;
|
||||
if (!src?.id) throw new Error('模型详情获取失败');
|
||||
const upRes = await updateModelManage({
|
||||
id: src.id,
|
||||
// 传递系统模型全部配置,供后端克隆用户模型时继承
|
||||
modelName: src.modelName,
|
||||
modelType: src.modelType,
|
||||
modelSupplier: src.modelSupplier,
|
||||
baseUrl: src.baseUrl,
|
||||
responseType: src.responseType ?? src.invokeType,
|
||||
apiKey: (apiKeyInputs.value[node.id] || '').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);
|
||||
const newModelManage = upRes?.data?.modelManage || upRes?.data;
|
||||
if (!newModelManage?.id) throw new Error('接口未返回新的用户模型');
|
||||
// 更新节点绑定为新用户模型,不再需要补 Key
|
||||
node.modelConfig.modelId = newModelManage.id;
|
||||
systemModelNodeIds.value[node.id] = false;
|
||||
apiKeyInputs.value[node.id] = '';
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
@@ -296,6 +245,10 @@ const handleSave = async () => {
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
// 保存前统一转换:系统模型节点先克隆为用户模型并绑定
|
||||
for (const node of modelNodes.value.filter((n) => systemModelNodeIds.value[n.id])) {
|
||||
await convertSystemModel(node);
|
||||
}
|
||||
const flowContent = {
|
||||
...(templateFlowContent.value || {}),
|
||||
nodes: nodes.value,
|
||||
@@ -374,11 +327,14 @@ const handleClose = () => {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.api-key-alert {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.tc-api-key-inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
|
||||
.api-key-form {
|
||||
margin-bottom: 4px;
|
||||
.el-input {
|
||||
max-width: 340px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
+275
-77
@@ -20,6 +20,7 @@
|
||||
:active-menu="activeMenu"
|
||||
:workflow-detail="selectedWorkflowDetail"
|
||||
:active-history-id="activeHistoryId"
|
||||
:messages="currentMessages"
|
||||
/>
|
||||
<InputBar ref="inputBarRef" :send-disabled="isSendDisabled" :workflow-locked="isHistoryWorkflow" @send="handleSend" @workflow-select="handleWorkflowSelect" />
|
||||
</div>
|
||||
@@ -57,6 +58,9 @@ import MainContent from './components/MainContent.vue';
|
||||
import InputBar from './components/InputBar.vue';
|
||||
import TemplateCompleteDialog from './components/TemplateCompleteDialog.vue';
|
||||
import { applyHomeFormValues } from './utils/flowDsl';
|
||||
import { getChatModel } from '/@/api/settings/modelConfigV2';
|
||||
import { openWsExecute } from './utils/wsExecute';
|
||||
import { getSessionMessages } from '/@/api/settings/session';
|
||||
import type { ExecutionTreeItem } from '/@/api/settings/creation';
|
||||
import {
|
||||
getExecutionList,
|
||||
@@ -66,7 +70,6 @@ import {
|
||||
deleteExecutionSession,
|
||||
getSessionList,
|
||||
downloadToFile,
|
||||
executeFlow,
|
||||
} from '/@/api/settings/creation';
|
||||
|
||||
interface HistoryItem {
|
||||
@@ -82,6 +85,7 @@ interface ChatMessage {
|
||||
content: string;
|
||||
time: string;
|
||||
isUser: boolean;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
interface TreeNode {
|
||||
@@ -283,6 +287,13 @@ const isSendDisabled = computed(() => {
|
||||
return session?.status === 'executing';
|
||||
});
|
||||
|
||||
// 当前会话消息流(对话页渲染)
|
||||
const currentMessages = computed(() => {
|
||||
const id = activeHistoryId.value;
|
||||
if (!id) return [];
|
||||
return sessionMessages.value.get(id) || [];
|
||||
});
|
||||
|
||||
const getSessionId = () => {
|
||||
return `session_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;
|
||||
};
|
||||
@@ -326,12 +337,39 @@ const handleMenuChange = (menu: string) => {
|
||||
|
||||
const formatTime = (d: Date) => String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0');
|
||||
|
||||
// ===== 消息缓存(普通对话历史;后端接口就绪前 localStorage 兜底)=====
|
||||
const CHAT_CACHE_KEY = 'home_chat_msgs_';
|
||||
const chatCacheKey = (sid: string) => CHAT_CACHE_KEY + sid;
|
||||
const loadChatCache = (sid: string): ChatMessage[] => {
|
||||
try {
|
||||
const raw = localStorage.getItem(chatCacheKey(sid));
|
||||
return raw ? (JSON.parse(raw) as ChatMessage[]) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
const saveChatCache = (sid: string, msgs: ChatMessage[]) => {
|
||||
try {
|
||||
localStorage.setItem(chatCacheKey(sid), JSON.stringify(msgs));
|
||||
} catch {
|
||||
/* 存储不可用时忽略 */
|
||||
}
|
||||
};
|
||||
const clearChatCache = (sid: string) => {
|
||||
try {
|
||||
localStorage.removeItem(chatCacheKey(sid));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
|
||||
const addMessage = (msg: ChatMessage) => {
|
||||
const id = activeHistoryId.value;
|
||||
if (!id) return;
|
||||
const list = sessionMessages.value.get(id);
|
||||
if (list) list.push(msg);
|
||||
else sessionMessages.value.set(id, [msg]);
|
||||
saveChatCache(id, sessionMessages.value.get(id) || []);
|
||||
};
|
||||
|
||||
const handleSend = async (message: string) => {
|
||||
@@ -350,18 +388,14 @@ const handleSend = async (message: string) => {
|
||||
const sid = activeHistoryId.value;
|
||||
if (sendingSessions[sid]) return;
|
||||
sendingSessions[sid] = true;
|
||||
if (!selectedWorkflowDetail.value) {
|
||||
ElMessage.warning('请先选择一个工作流');
|
||||
delete sendingSessions[sid];
|
||||
return;
|
||||
}
|
||||
const mc = mainContentRef.value;
|
||||
if (!mc) {
|
||||
delete sendingSessions[sid];
|
||||
return;
|
||||
}
|
||||
|
||||
if (!mc.validateFormFields()) {
|
||||
// 工作流模式:发送前校验必填表单字段;普通对话无需表单
|
||||
if (selectedWorkflowDetail.value && !mc.validateFormFields()) {
|
||||
delete sendingSessions[sid];
|
||||
return;
|
||||
}
|
||||
@@ -376,7 +410,7 @@ const handleSend = async (message: string) => {
|
||||
// 添加用户消息
|
||||
addMessage({
|
||||
id: 'msg-' + Date.now() + '-user',
|
||||
content: message || '执行工作流',
|
||||
content: message || (selectedWorkflowDetail.value ? '执行工作流' : ''),
|
||||
time: formatTime(new Date()),
|
||||
isUser: true,
|
||||
});
|
||||
@@ -385,6 +419,75 @@ const handleSend = async (message: string) => {
|
||||
|
||||
const sessionId = curSession.sessionId || getSessionId();
|
||||
|
||||
// 分支:有工作流 → 执行工作流;无工作流 → 普通对话
|
||||
if (selectedWorkflowDetail.value) {
|
||||
await runWorkflow(sid, sessionId, message, mc);
|
||||
} else {
|
||||
await runChat(sid, sessionId, message);
|
||||
}
|
||||
};
|
||||
|
||||
// ===== 工作流执行:选中工作流 → 表单页 WS 执行 → 完成后切回对话页 =====
|
||||
const runWorkflow = async (sid: string, sessionId: string, message: string, mc: any) => {
|
||||
const curSession = historyList.value.find((h) => h.id === sid);
|
||||
if (!curSession) {
|
||||
delete sendingSessions[sid];
|
||||
return;
|
||||
}
|
||||
|
||||
let finished = false;
|
||||
let wsFailed = false;
|
||||
const finishExec = (success: boolean, errorMsg?: string) => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
curSession.status = success ? 'completed' : 'failed';
|
||||
addMessage({
|
||||
id: 'msg-' + Date.now() + (success ? '-done' : '-fail'),
|
||||
content: success ? '✅ 执行完成,可前往工作空间查看产出' : `❌ ${errorMsg || '执行失败,请重试或联系管理员'}`,
|
||||
time: formatTime(new Date()),
|
||||
isUser: false,
|
||||
});
|
||||
if (success) {
|
||||
ElMessage.success('✅ 执行完成,可前往工作空间查看');
|
||||
// 刷新工作空间树(查询所有结果)
|
||||
getExecutionList().then((res) => {
|
||||
imgAddressPrefix.value = res.data?.imgAddressPrefix || '';
|
||||
treeNodes.value = buildTreeNodes(res.data?.tree || []);
|
||||
});
|
||||
} else {
|
||||
ElMessage.error(errorMsg || '执行失败,请重试');
|
||||
}
|
||||
// 虚拟会话执行后刷新会话列表替换真实条目
|
||||
if (curSession.id.startsWith('virtual_')) {
|
||||
getSessionList().then((sessionRes) => {
|
||||
const freshList = getSessionData(sessionRes).map((s: any) => ({
|
||||
id: s.id,
|
||||
sessionId: s.sessionId,
|
||||
title: s.flowName || '未命名会话',
|
||||
time: s.createDate?.substring(0, 10) || '',
|
||||
}));
|
||||
const match = freshList.find((s: any) => s.sessionId === curSession.sessionId);
|
||||
if (match) {
|
||||
const idx = historyList.value.findIndex((h) => h.id === curSession.id);
|
||||
if (idx >= 0) {
|
||||
const msgs = sessionMessages.value.get(curSession.id);
|
||||
sessionMessages.value.delete(curSession.id);
|
||||
sessionMessages.value.set(match.id, msgs || []);
|
||||
historyList.value[idx] = { ...match, status: curSession.status };
|
||||
if (activeHistoryId.value === curSession.id) {
|
||||
activeHistoryId.value = match.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
delete sendingSessions[sid];
|
||||
// 执行完成后切回对话页:清空工作流选择,主区域展示消息流
|
||||
if (success) {
|
||||
inputBarRef.value?.resetAll?.();
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
// 1. 构建节点输入参数:深拷贝 DSL,把首页表单值写回对应字段(model → modelRequestParams;form → outputConfig)
|
||||
const nodeInputParams = JSON.parse(JSON.stringify(selectedWorkflowDetail.value.nodeInputParams || []));
|
||||
@@ -396,71 +499,77 @@ const handleSend = async (message: string) => {
|
||||
nodes: nodeInputParams,
|
||||
};
|
||||
|
||||
// 3. 获取当前会话模型(普通对话用用户设置的会话模型 id)
|
||||
let chatModelId: string | number | undefined;
|
||||
try {
|
||||
const chatRes: any = await getChatModel();
|
||||
chatModelId = chatRes?.data?.modelManage?.id || chatRes?.data?.id || chatRes?.data?.modelId;
|
||||
} catch {
|
||||
chatModelId = undefined;
|
||||
}
|
||||
|
||||
// 3. 构建请求参数(外层 fileUrl 留给输入框附件,当前已禁用故保持空数组)
|
||||
const params = {
|
||||
// 4. 打开 WebSocket 执行(消息格式待定:收到完成标志或连接关闭视为完成)
|
||||
const ws = openWsExecute({
|
||||
sessionId,
|
||||
flowId: selectedWorkflowDetail.value.id,
|
||||
modelId: chatModelId,
|
||||
question: message || '执行工作流',
|
||||
systemPrompt: '',
|
||||
flowContent: updatedFlowContent,
|
||||
nodeInputParams: nodeInputParams,
|
||||
sessionId: sessionId,
|
||||
desc: message,
|
||||
flowName: selectedWorkflowDetail.value.flowName || '',
|
||||
fileUrl: [],
|
||||
resultUrl: selectedWorkflowDetail.value.resultUrl || '',
|
||||
templates: mc.templates || [],
|
||||
};
|
||||
|
||||
// 4. 执行
|
||||
await executeFlow(params);
|
||||
// 标记完成(直接用 curSession 引用,避免切换会话后状态错乱)
|
||||
curSession.status = 'completed';
|
||||
addMessage({
|
||||
id: 'msg-' + Date.now() + '-done',
|
||||
content: '✅ 执行完成,可前往工作空间查看产出',
|
||||
time: formatTime(new Date()),
|
||||
isUser: false,
|
||||
});
|
||||
ElMessage.success('✅ 执行完成,可前往工作空间查看');
|
||||
// 刷新工作空间树(查询所有结果)
|
||||
getExecutionList().then((res) => {
|
||||
imgAddressPrefix.value = res.data?.imgAddressPrefix || '';
|
||||
treeNodes.value = buildTreeNodes(res.data?.tree || []);
|
||||
});
|
||||
// 虚拟会话执行成功后,刷新会话列表替换真实条目
|
||||
if (curSession.id.startsWith('virtual_')) {
|
||||
getSessionList().then((sessionRes) => {
|
||||
const freshList = getSessionData(sessionRes).map((s: any) => ({
|
||||
id: s.id,
|
||||
sessionId: s.sessionId,
|
||||
title: s.flowName || '未命名会话',
|
||||
time: s.createDate?.substring(0, 10) || '',
|
||||
}));
|
||||
const match = freshList.find((s: any) => s.sessionId === curSession.sessionId);
|
||||
if (match) {
|
||||
const idx = historyList.value.findIndex((h) => h.id === curSession.id);
|
||||
if (idx >= 0) {
|
||||
const msgs = sessionMessages.value.get(curSession.id);
|
||||
sessionMessages.value.delete(curSession.id);
|
||||
sessionMessages.value.set(match.id, msgs || []);
|
||||
historyList.value[idx] = { ...match, status: curSession.status };
|
||||
if (activeHistoryId.value === curSession.id) {
|
||||
activeHistoryId.value = match.id;
|
||||
}
|
||||
}
|
||||
onMessage: (raw) => {
|
||||
let data: any = raw;
|
||||
if (typeof raw === 'string') {
|
||||
try { data = JSON.parse(raw); } catch { data = raw; }
|
||||
}
|
||||
});
|
||||
const str = typeof data === 'string' ? data : JSON.stringify(data || '');
|
||||
if (/done|finish|complete|end|success/i.test(str)) {
|
||||
finishExec(true);
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
wsFailed = true;
|
||||
finishExec(false, '执行连接失败,请重试');
|
||||
},
|
||||
onClose: () => {
|
||||
// 连接关闭兜底视为完成;若已触发 onError 则按失败处理
|
||||
if (!finished) finishExec(!wsFailed);
|
||||
},
|
||||
});
|
||||
if (!ws) {
|
||||
finishExec(false, 'WebSocket 初始化失败,请检查服务地址');
|
||||
}
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || "执行失败,请重试");
|
||||
curSession.status = 'failed';
|
||||
addMessage({
|
||||
id: 'msg-' + Date.now() + '-fail',
|
||||
content: '❌ 执行失败,请重试或联系管理员',
|
||||
time: formatTime(new Date()),
|
||||
isUser: false,
|
||||
});
|
||||
// 虚拟会话执行失败也刷新列表(后端会创建 status=3 的记录)
|
||||
if (curSession.id.startsWith('virtual_')) {
|
||||
finishExec(false, e?.message || '执行失败,请重试');
|
||||
}
|
||||
};
|
||||
|
||||
// ===== 普通对话:未选工作流 → 纯问答,AI 回复进消息流 =====
|
||||
const runChat = async (sid: string, sessionId: string, message: string) => {
|
||||
// AI loading 占位气泡
|
||||
const aiMsgId = 'msg-' + Date.now() + '-ai';
|
||||
addMessage({ id: aiMsgId, content: '', time: formatTime(new Date()), isUser: false, loading: true });
|
||||
|
||||
let done = false;
|
||||
let failed = false;
|
||||
const finishChat = (success: boolean, errorMsg?: string) => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
const list = sessionMessages.value.get(sid);
|
||||
const aiIdx = list ? list.findIndex((m) => m.id === aiMsgId) : -1;
|
||||
if (!success) {
|
||||
const content = `❌ ${errorMsg || '对话失败,请重试'}`;
|
||||
if (list && aiIdx >= 0) {
|
||||
list[aiIdx].loading = false;
|
||||
list[aiIdx].content = content;
|
||||
} else {
|
||||
addMessage({ id: 'msg-' + Date.now() + '-fail', content, time: formatTime(new Date()), isUser: false });
|
||||
}
|
||||
}
|
||||
// 更新会话状态
|
||||
const session = historyList.value.find((h) => h.id === sid);
|
||||
if (session) session.status = success ? 'completed' : 'failed';
|
||||
// 虚拟会话对话后刷新会话列表替换真实条目
|
||||
if (session && session.id.startsWith('virtual_')) {
|
||||
getSessionList().then((sessionRes) => {
|
||||
const freshList = getSessionData(sessionRes).map((s: any) => ({
|
||||
id: s.id,
|
||||
@@ -468,37 +577,91 @@ const handleSend = async (message: string) => {
|
||||
title: s.flowName || '未命名会话',
|
||||
time: s.createDate?.substring(0, 10) || '',
|
||||
}));
|
||||
const match = freshList.find((s: any) => s.sessionId === curSession.sessionId);
|
||||
const match = freshList.find((s: any) => s.sessionId === session.sessionId);
|
||||
if (match) {
|
||||
const idx = historyList.value.findIndex((h) => h.id === curSession.id);
|
||||
const idx = historyList.value.findIndex((h) => h.id === session.id);
|
||||
if (idx >= 0) {
|
||||
const msgs = sessionMessages.value.get(curSession.id);
|
||||
sessionMessages.value.delete(curSession.id);
|
||||
const msgs = sessionMessages.value.get(session.id);
|
||||
sessionMessages.value.delete(session.id);
|
||||
sessionMessages.value.set(match.id, msgs || []);
|
||||
historyList.value[idx] = { ...match, status: curSession.status };
|
||||
if (activeHistoryId.value === curSession.id) {
|
||||
historyList.value[idx] = { ...match, status: session.status };
|
||||
saveChatCache(match.id, msgs || []);
|
||||
if (activeHistoryId.value === session.id) {
|
||||
activeHistoryId.value = match.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
saveChatCache(sid, sessionMessages.value.get(sid) || []);
|
||||
delete sendingSessions[sid];
|
||||
};
|
||||
|
||||
// 获取会话模型
|
||||
let chatModelId: string | number | undefined;
|
||||
try {
|
||||
const chatRes: any = await getChatModel();
|
||||
chatModelId = chatRes?.data?.modelManage?.id || chatRes?.data?.id || chatRes?.data?.modelId;
|
||||
} catch {
|
||||
chatModelId = undefined;
|
||||
}
|
||||
|
||||
const ws = openWsExecute({
|
||||
sessionId,
|
||||
modelId: chatModelId,
|
||||
question: message || '',
|
||||
systemPrompt: '',
|
||||
onMessage: (raw) => {
|
||||
let data: any = raw;
|
||||
if (typeof raw === 'string') {
|
||||
try { data = JSON.parse(raw); } catch { data = raw; }
|
||||
}
|
||||
// 提取回复文本(兼容常见字段)
|
||||
let text = '';
|
||||
if (typeof data === 'string') text = data;
|
||||
else if (data && typeof data === 'object') {
|
||||
text = data.content ?? data.message ?? data.reply ?? data.text ?? data.answer ?? '';
|
||||
}
|
||||
const str = text ? text : typeof data === 'string' ? data : JSON.stringify(data || '');
|
||||
const list = sessionMessages.value.get(sid);
|
||||
const aiIdx = list ? list.findIndex((m) => m.id === aiMsgId) : -1;
|
||||
if (aiIdx >= 0 && str) {
|
||||
const ai = list[aiIdx];
|
||||
if (ai.loading) ai.loading = false;
|
||||
ai.content = ai.content ? ai.content + (str.startsWith(' ') ? str : '\n' + str) : str;
|
||||
saveChatCache(sid, list || []);
|
||||
}
|
||||
if (/done|finish|complete|end|success/i.test(str)) {
|
||||
finishChat(true);
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
failed = true;
|
||||
finishChat(false, '对话连接失败,请重试');
|
||||
},
|
||||
onClose: () => {
|
||||
// 连接关闭兜底完成
|
||||
if (!done) finishChat(!failed);
|
||||
},
|
||||
});
|
||||
if (!ws) {
|
||||
finishChat(false, 'WebSocket 初始化失败,请检查服务地址');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectHistory = async (id: string) => {
|
||||
activeHistoryId.value = id;
|
||||
activeMenu.value = 'chat';
|
||||
// 加载该会话的工作流详情,渲染可编辑表单
|
||||
const session = historyList.value.find((h) => h.id === id);
|
||||
let asForm = false;
|
||||
if (session && !session.id.startsWith('virtual_')) {
|
||||
try {
|
||||
const res = await getExecutionDetail(session.id);
|
||||
if (res.data) {
|
||||
// 工作流会话:回显表单页(可改参数重跑),并锁定工作流选择
|
||||
selectedWorkflowDetail.value = res.data;
|
||||
isHistoryWorkflow.value = true;
|
||||
asForm = true;
|
||||
// 同步回显 InputBar 的工作流选择
|
||||
const ib = inputBarRef.value as any;
|
||||
if (ib?.commonWorkflows && res.data.flowName) {
|
||||
@@ -507,10 +670,15 @@ const handleSelectHistory = async (id: string) => {
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 无关联执行数据,仅展示消息
|
||||
// 无执行详情 → 按普通对话会话处理
|
||||
}
|
||||
} else {
|
||||
}
|
||||
if (!asForm) {
|
||||
// 普通对话会话 / 虚拟会话:切回对话页,加载消息记录
|
||||
selectedWorkflowDetail.value = null;
|
||||
isHistoryWorkflow.value = false;
|
||||
inputBarRef.value?.clearWorkflow?.();
|
||||
await loadSessionMessages(id);
|
||||
}
|
||||
// 清除可重新执行的标记
|
||||
if (session?.status === 'completed' || session?.status === 'failed' || (session?.status === 'executing' && !sendingSessions[session.id])) {
|
||||
@@ -518,6 +686,35 @@ const handleSelectHistory = async (id: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
// 加载会话消息:优先后端接口,接口未就绪时读本地缓存兜底
|
||||
const loadSessionMessages = async (sid: string) => {
|
||||
const existing = sessionMessages.value.get(sid);
|
||||
if (existing && existing.length > 0) return; // 内存已有消息直接保留
|
||||
try {
|
||||
const res: any = await getSessionMessages(sid);
|
||||
const list = Array.isArray(res?.data) ? res.data : res?.data?.list || res?.data?.messages;
|
||||
if (Array.isArray(list) && list.length > 0) {
|
||||
const msgs: ChatMessage[] = list.map((m: any, i: number) => ({
|
||||
id: m.id || 'msg-' + Date.now() + '-' + i,
|
||||
content: m.content ?? m.message ?? m.reply ?? '',
|
||||
time: m.time || m.createDate || '',
|
||||
isUser: m.isUser === true || m.role === 'user',
|
||||
}));
|
||||
sessionMessages.value.set(sid, msgs);
|
||||
saveChatCache(sid, msgs);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// 接口不可用 → 兜底本地缓存
|
||||
}
|
||||
const cached = loadChatCache(sid);
|
||||
if (cached.length > 0) {
|
||||
sessionMessages.value.set(sid, cached);
|
||||
} else if (!sessionMessages.value.has(sid)) {
|
||||
sessionMessages.value.set(sid, []);
|
||||
}
|
||||
};
|
||||
|
||||
const createNewSession = () => {
|
||||
const sessionId = `virtual_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;
|
||||
historyList.value.unshift({
|
||||
@@ -561,6 +758,7 @@ const handleDeleteHistory = async (id: string) => {
|
||||
if (idx < 0) return;
|
||||
historyList.value.splice(idx, 1);
|
||||
sessionMessages.value.delete(id);
|
||||
clearChatCache(id);
|
||||
if (activeHistoryId.value === id) {
|
||||
if (historyList.value.length > 0) {
|
||||
activeHistoryId.value = historyList.value[0].id;
|
||||
|
||||
@@ -200,6 +200,61 @@ export function collectHomeFormFields(node: any): HomeFormField[] {
|
||||
return collectLegacyFormConfig(node);
|
||||
}
|
||||
|
||||
// 上传类型字段判定(与 MainContent isFileField 一致)
|
||||
const isUploadType = (f: HomeFormField): boolean =>
|
||||
f.type === 'upload' || f.type === 'uploadMultiple' || f.type === 'fileUpload';
|
||||
|
||||
// 上传类型空值:未选文件(空字符串 / 空数组 / null)
|
||||
const isEmptyUploadValue = (val: any): boolean => val === '' || val === null || (Array.isArray(val) && val.length === 0);
|
||||
|
||||
// 按路径定位父级后删除末段字段(modelRequestParams 字段 / outputConfig 条目 / http body 子字段)
|
||||
function deleteResolvedPath(params: any, path: string): boolean {
|
||||
if (!path) return false;
|
||||
const segments = path.split('.');
|
||||
const last = segments[segments.length - 1];
|
||||
let cur = params;
|
||||
for (const seg of segments.slice(0, -1)) {
|
||||
if (cur === undefined || cur === null || typeof cur !== 'object') return false;
|
||||
if (seg === 'attrs') {
|
||||
cur = cur.attrs;
|
||||
if (!cur || typeof cur !== 'object') return false;
|
||||
} else {
|
||||
const m = seg.match(/^value\[(\d+)\]$/);
|
||||
if (m) {
|
||||
if (!Array.isArray(cur.value)) return false;
|
||||
cur = cur.value[Number(m[1])];
|
||||
} else {
|
||||
cur = cur[seg];
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!cur || typeof cur !== 'object') return false;
|
||||
const lm = last.match(/^value\[(\d+)\]$/);
|
||||
if (lm) {
|
||||
if (!Array.isArray(cur.value)) return false;
|
||||
cur.value.splice(Number(lm[1]), 1);
|
||||
} else {
|
||||
delete cur[last];
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// 从 DSL 移除空上传字段(对应 applyHomeFormValues 中的写回分支)
|
||||
function removeUploadField(node: any, code: string, f: HomeFormField): void {
|
||||
if (code === 'model') {
|
||||
deleteResolvedPath(node?.modelConfig?.modelRequestParams, f.path);
|
||||
} else if (code === 'form') {
|
||||
const list = Array.isArray(node?.outputConfig) ? node.outputConfig : [];
|
||||
const idx = list.findIndex((o: any) => o && o.field === f.path);
|
||||
if (idx >= 0) list.splice(idx, 1);
|
||||
} else if (f.__isHttpBodyChild && f.bodyKey && Array.isArray(node?.formConfig)) {
|
||||
const bodyField = node.formConfig.find((x: any) => x && x.field === 'body');
|
||||
if (bodyField?.value && typeof bodyField.value === 'object' && !Array.isArray(bodyField.value)) {
|
||||
delete bodyField.value[f.bodyKey];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 首页表单值写回 DSL 对应字段(model → modelRequestParams.path.value;form → outputConfig[].value)
|
||||
export function applyHomeFormValues(nodes: any[], formValues: Record<string, any>): void {
|
||||
if (!Array.isArray(nodes)) return;
|
||||
@@ -210,6 +265,11 @@ export function applyHomeFormValues(nodes: any[], formValues: Record<string, any
|
||||
const val = formValues[key];
|
||||
// 仅跳过未初始化的 key;null(如数字清空)也要写回
|
||||
if (val === undefined) continue;
|
||||
// 上传类型字段未选文件 → 不提交,从 DSL 移除该字段
|
||||
if (isUploadType(f) && isEmptyUploadValue(val)) {
|
||||
removeUploadField(node, code, f);
|
||||
continue;
|
||||
}
|
||||
if (code === 'model') {
|
||||
const target = resolvePath(node?.modelConfig?.modelRequestParams, f.path);
|
||||
if (target && typeof target === 'object' && !Array.isArray(target)) target.value = val;
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
// ===== 首页 WebSocket 执行工作流(/ai-agent/session/wsExecute)=====
|
||||
// 后端示例:
|
||||
// WebSocketConnectReq{ SessionId, FlowId, FlowContent, ModelId, Question, SystemPrompt }
|
||||
// 约定:握手 URL query 带 token/sessionId/flowId/modelId/question/systemPrompt,
|
||||
// 复杂对象 flowContent 在握手后通过 ws.send(JSON) 发送。
|
||||
// 服务端推送消息格式待定:本工具只做连接/发送,消息解析交给调用方。
|
||||
|
||||
import { Session } from '/@/utils/storage';
|
||||
|
||||
// VITE_API_URL(http(s)://host)→ ws(s)://host
|
||||
const getWsBase = (): string => {
|
||||
const raw = import.meta.env.VITE_API_URL || '';
|
||||
return raw.replace(/^http/, 'ws');
|
||||
};
|
||||
|
||||
export interface WsExecuteOptions {
|
||||
sessionId: string;
|
||||
flowId?: string | number;
|
||||
modelId?: string | number;
|
||||
question?: string;
|
||||
systemPrompt?: string;
|
||||
flowContent?: any;
|
||||
onOpen?: (ws: WebSocket) => void;
|
||||
onMessage?: (raw: any) => void;
|
||||
onClose?: (ev: CloseEvent) => void;
|
||||
onError?: (ev: Event) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开 WebSocket 执行工作流。
|
||||
* 返回 WebSocket 实例(若 URL 构造失败返回 null)。
|
||||
* 连接成功后自动发送 flowContent(握手后 send JSON)。
|
||||
*/
|
||||
export function openWsExecute(opts: WsExecuteOptions): WebSocket | null {
|
||||
const base = getWsBase();
|
||||
const params: Record<string, string> = { sessionId: opts.sessionId };
|
||||
if (opts.flowId) params.flowId = String(opts.flowId);
|
||||
if (opts.modelId) params.modelId = String(opts.modelId);
|
||||
if (opts.question) params.question = opts.question;
|
||||
if (opts.systemPrompt) params.systemPrompt = opts.systemPrompt;
|
||||
const token = Session.get('token');
|
||||
if (token) params.token = String(token);
|
||||
|
||||
const query = Object.keys(params)
|
||||
.map((k) => `${encodeURIComponent(k)}=${encodeURIComponent(params[k])}`)
|
||||
.join('&');
|
||||
const url = `${base}/ai-agent/session/wsExecute?${query}`;
|
||||
|
||||
let ws: WebSocket;
|
||||
try {
|
||||
ws = new WebSocket(url);
|
||||
} catch (e) {
|
||||
console.error('[wsExecute] WebSocket 创建失败', e);
|
||||
return null;
|
||||
}
|
||||
|
||||
ws.onopen = () => {
|
||||
if (opts.flowContent) {
|
||||
ws.send(JSON.stringify({ flowContent: opts.flowContent, systemPrompt: opts.systemPrompt || '' }));
|
||||
}
|
||||
opts.onOpen?.(ws);
|
||||
};
|
||||
ws.onmessage = (ev) => opts.onMessage?.(ev.data);
|
||||
ws.onclose = (ev) => opts.onClose?.(ev);
|
||||
ws.onerror = (ev) => opts.onError?.(ev);
|
||||
|
||||
return ws;
|
||||
}
|
||||
Reference in New Issue
Block a user