websocket相关

This commit is contained in:
2026-08-13 20:03:14 +08:00
parent 8e7948cff5
commit c088894546
9 changed files with 621 additions and 225 deletions
+11
View File
@@ -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',
});
}
+13
View File
@@ -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 },
});
}
+70 -4
View File
@@ -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 {
+2 -2
View File
@@ -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 = '';
+24
View File
@@ -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
View File
@@ -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 → modelRequestParamsform → 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;
+60
View File
@@ -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.valueform → 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;
+68
View File
@@ -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_URLhttp(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;
}