diff --git a/src/views/home/index.vue b/src/views/home/index.vue index b0db6d4..a9951fd 100644 --- a/src/views/home/index.vue +++ b/src/views/home/index.vue @@ -380,8 +380,8 @@ const handleSessionModelSaved = async (model: { id: string; modelName: string }) const selectedWorkflowDetail = ref(null); const sendingSessions = reactive>({}); -// ===== 会话级长连接(单活跃连接)===== -// 连接状态/helper 见 formatTime/addMessage 之后统一定义;这里声明本轮 handler 与当前连接路由 +// ===== 会话级长连接(每会话独立连接,支持多会话并发执行)===== +// 连接状态/helper 见 formatTime/addMessage 之后统一定义;这里声明每会话 handler 与连接路由 interface RoundHandler { onMessage: (raw: any) => void; onError: (ev: Event) => void; @@ -389,18 +389,18 @@ interface RoundHandler { // 终止本轮:保留已生成内容 + flush 打字机 + markStopped + 清 sendingSessions abort: () => void; } -// 当前进行中的一轮(连接级 onMessage 只路由给 activeHandler;sendingSessions 防重入保证任意时刻最多一轮在生成) -let activeHandler: RoundHandler | null = null; -// 当前活跃的会话级连接(单活跃:切会话时关旧开新) -let wsState: { ws: WebSocket; sid: string; sessionId: string; ready: Promise } | null = null; -// 当前进行中的一轮类型:true=工作流执行,false=普通对话(sendCancel 协议判断用) -// ref 响应式:InputBar 依据它区分「工作流执行中」与「普通对话生成中」,决定禁用/停止按钮展示 -const activeRunIsWorkflow = ref(false); +// 每会话一个进行中的轮(连接级 onMessage 按 sid 路由;sendingSessions 防重入保证同一会话任意时刻最多一轮在生成) +const roundHandlers = new Map(); +// 每会话一个会话级连接(切会话不再关旧连接,后台执行中的会话连接保留;空闲会话释放) +const wsConnMap: Record }> = {}; +// 每会话进行中的一轮类型:true=工作流执行,false=普通对话(sendCancel 协议判断用) +// 非响应式对象:InputBar 依据它区分「工作流执行中」与「普通对话生成中」,由 isWorkflowRunning 按活跃会话取值 +const runTypeMap: Record = {}; // 生成中:有活跃会话且该会话正处于发送状态(发送按钮切换为停止按钮) const isGenerating = computed(() => !!activeHistoryId.value && !!sendingSessions[activeHistoryId.value]); -// 工作流执行中:isGenerating 且本轮为工作流 → 输入框整体禁用,停止按钮转移到表单卡片 footer -const isWorkflowRunning = computed(() => isGenerating.value && activeRunIsWorkflow.value); +// 工作流执行中:isGenerating 且当前活跃会话本轮为工作流 → 输入框整体禁用,停止按钮转移到表单卡片 footer +const isWorkflowRunning = computed(() => isGenerating.value && runTypeMap[activeHistoryId.value || ''] === true); const inputBarRef = ref(null); const templateDialogVisible = ref(false); const pendingTemplate = ref(null); @@ -503,8 +503,20 @@ const claimSessionId = (oldId: string, newId: string) => { delete sendingSessions[oldId]; } if (activeHistoryId.value === oldId) activeHistoryId.value = newId; - // 连接状态对齐 - if (wsState && wsState.sid === oldId) wsState.sid = newId; + // 连接 / handler / 运行类型 map 对齐(认领后所有以旧 id 为 key 的执行态随迁到新 key) + if (wsConnMap[oldId]) { + wsConnMap[newId] = wsConnMap[oldId]; + wsConnMap[newId].sid = newId; + delete wsConnMap[oldId]; + } + if (roundHandlers.has(oldId)) { + roundHandlers.set(newId, roundHandlers.get(oldId)!); + roundHandlers.delete(oldId); + } + if (runTypeMap[oldId] !== undefined) { + runTypeMap[newId] = runTypeMap[oldId]; + delete runTypeMap[oldId]; + } }; // 认领后刷新会话标题:用后端正式号查一次会话列表,把「新会话 N」更新为后端返回的正式名(fire-and-forget) @@ -645,64 +657,79 @@ const addMessage = (msg: ChatMessage) => { else sessionMessages.value.set(id, [msg]); }; -// ===== 会话级长连接管理 ===== -// 关闭当前连接:先置空 wsState,旧 socket 的 onclose 路由被「陈旧守卫」(wsState?.ws !== 旧 ws)丢弃 -const closeCurrentSocket = () => { - const st = wsState; - if (!st) return; - wsState = null; +// 按 sid 定向写入消息流(多会话并发:后台会话的完成/失败消息落到其自身列表,不依赖 activeHistoryId) +const pushToSession = (sid: string, msg: ChatMessage) => { + const list = sessionMessages.value.get(sid); + if (list) list.push(msg); + else sessionMessages.value.set(sid, [msg]); +}; + +// ===== 会话级长连接管理(每会话独立连接)===== +// 关闭指定会话的连接:先从 map 移除,旧 socket 的 onclose 路由被「陈旧守卫」(wsConnMap[sid].ws !== 旧 ws)丢弃 +const closeSocket = (sid: string) => { + const conn = wsConnMap[sid]; + if (!conn) return; + delete wsConnMap[sid]; try { - if (st.ws.readyState === WebSocket.OPEN || st.ws.readyState === WebSocket.CONNECTING) st.ws.close(); + if (conn.ws.readyState === WebSocket.OPEN || conn.ws.readyState === WebSocket.CONNECTING) conn.ws.close(); } catch { /* 忽略关闭异常 */ } }; -// 确保当前会话有可用连接:sid 匹配且 OPEN 则复用;否则关旧建新(单活跃连接,惰性重连) +// 确保指定会话有可用连接:sid 已有连接且 OPEN 则复用;CONNECTING 等待;否则为该会话新建(惰性重连,不影响其他会话连接) const ensureSocket = async (sid: string, sessionId: string): Promise => { - const st = wsState; - if (st && st.sid === sid) { - if (st.ws.readyState === WebSocket.OPEN) return st.ws; - if (st.ws.readyState === WebSocket.CONNECTING) { + const existing = wsConnMap[sid]; + if (existing) { + if (existing.ws.readyState === WebSocket.OPEN) return existing.ws; + if (existing.ws.readyState === WebSocket.CONNECTING) { try { - return await st.ready; + return await existing.ready; } catch { /* 建连失败,走重建 */ } } } - closeCurrentSocket(); + // 会话号可随 ack 认领变化(local → 后端 UUID),holder.sid 跟随认领保持路由一致 + const holder = { sid }; let session: ReturnType = null; session = connectSessionSocket({ sessionId, onMessage: (raw) => { - if (wsState?.ws === session?.ws) activeHandler?.onMessage(raw); + const conn = wsConnMap[holder.sid]; + if (conn && conn.ws === session?.ws) roundHandlers.get(holder.sid)?.onMessage(raw); }, onClaim: (uuid) => { // 首次建连:后端在 ack 里返回正式会话号 → 认领本地临时会话,后续所有操作以正式号为 key - if (wsState?.ws === session?.ws) { - claimSessionId(sid, uuid); + const conn = wsConnMap[holder.sid]; + if (conn && conn.ws === session?.ws) { + claimSessionId(holder.sid, uuid); + holder.sid = uuid; void refreshSessionTitle(uuid); } }, onError: (ev) => { - if (wsState?.ws === session?.ws) activeHandler?.onError(ev); + const conn = wsConnMap[holder.sid]; + if (conn && conn.ws === session?.ws) roundHandlers.get(holder.sid)?.onError(ev); }, onClose: (ev) => { - // 陈旧连接 / 主动关闭 → 忽略 - if (wsState?.ws !== session?.ws) return; - wsState = null; - const h = activeHandler; - activeHandler = null; - if (h) h.onClose(ev); + // 陈旧连接 / 主动关闭 → 忽略;仅真正断开的当前连接触发会话内 onClose + const conn = wsConnMap[holder.sid]; + if (!conn || conn.ws !== session?.ws) return; + delete wsConnMap[holder.sid]; + const h = roundHandlers.get(holder.sid); + if (h) { + roundHandlers.delete(holder.sid); + h.onClose(ev); + } }, }); if (!session) return null; - wsState = { ws: session.ws, sid, sessionId, ready: session.ready }; + wsConnMap[holder.sid] = { ws: session.ws, sid: holder.sid, sessionId, ready: session.ready }; try { return await session.ready; } catch { - closeCurrentSocket(); + delete wsConnMap[holder.sid]; return null; } }; @@ -725,26 +752,29 @@ const markStopped = (sid: string) => { } }; -// 终止当前轮(停止 / 切会话 / 删除 / 卸载共用):发 cancel 帧(不关闭连接)→ 本轮 abort 定格 → 清空 handler -const teardownActiveRound = () => { - const h = activeHandler; +// 终止指定会话的轮(停止 / 删除 / 卸载共用):发 cancel 帧(不关闭连接)→ 本轮 abort 定格 → 清空 handler +// 切会话不再触发终止——多会话并发,后台会话的轮继续执行 +const teardownRound = (sid: string) => { + const h = roundHandlers.get(sid); if (!h) return; - const st = wsState; - if (st && st.ws.readyState === WebSocket.OPEN) { + roundHandlers.delete(sid); + const isWorkflow = runTypeMap[sid] === true; + delete runTypeMap[sid]; + const conn = wsConnMap[sid]; + if (conn && conn.ws.readyState === WebSocket.OPEN) { try { - sendCancel(st.ws, activeRunIsWorkflow.value); + sendCancel(conn.ws, isWorkflow); } catch { /* 连接异常时忽略,仅做本地定格 */ } } h.abort(); - activeHandler = null; }; -// 终止当前会话:终止进行中的轮 + 关闭连接 -const teardownSession = () => { - teardownActiveRound(); - closeCurrentSocket(); +// 清理全部会话的轮与连接(组件卸载共用) +const teardownAll = () => { + for (const sid of [...roundHandlers.keys()]) teardownRound(sid); + for (const sid of Object.keys(wsConnMap)) closeSocket(sid); }; const handleSend = async (message: string) => { @@ -883,13 +913,13 @@ const handleDeleteMessage = async (msg: ChatMessage) => { // ===== 停止生成:按执行类型发送对应取消消息并关闭连接,保留已生成内容 ===== // 普通对话走 agent 协议(启动为 {type:'agent'},取消为 {type:'agent_cancel'}); // 工作流走 workflow 协议(取消为 {type:'workflow_cancel'})。 -// 判断依据:activeRunIsWorkflow 标记当前轮类型(runWorkflow/runChat 各自设置)。 +// 判断依据:runTypeMap[sid] 标记当前会话轮类型(runWorkflow/runChat 各自设置)。 const handleStopGenerate = () => { const sid = activeHistoryId.value; if (!sid || !sendingSessions[sid]) return; - // 会话级长连接:只发 cancel 帧终止本轮(不关闭连接,连接保持复用); + // 会话级长连接:只发 cancel 帧终止当前活跃会话本轮(不关闭连接,连接保持复用); // 「已停止生成」标记与 sendingSessions 清理由本轮 handler 的 abort 统一负责 - teardownActiveRound(); + teardownRound(sid); }; // ===== 工作流执行:选中工作流 → 表单页 WS 执行 → 完成后切回对话页 ===== @@ -912,6 +942,11 @@ const runWorkflow = async ( const finishExec = (success: boolean, errorMsg?: string, outputs?: WorkflowOutput[]) => { if (finished) return; finished = true; + // 本轮结束:清 handler 与运行类型(正常完成路径不走 teardownRound,主动清理避免残留路由) + roundHandlers.delete(sid); + delete runTypeMap[sid]; + // 后台会话(非当前活跃)执行完成 → 释放空闲连接;活跃会话保留连接供继续交互 + if (activeHistoryId.value !== sid) closeSocket(sid); // 定格表单卡片:done/failed + 失败信息;执行结束清进度(避免残留影响重新编辑) formMsg.formStatus = success ? 'done' : 'failed'; formMsg.formError = success ? undefined : errorMsg || '执行失败'; @@ -930,7 +965,7 @@ const runWorkflow = async ( recordType: roundRecordId ? 'workflow' : undefined, outputs: outputs || [], }; - addMessage(resultMsg); + pushToSession(sid, resultMsg); if (success) { ElMessage.success('✅ 执行完成'); // 刷新工作空间树(查询所有结果) @@ -943,8 +978,9 @@ const runWorkflow = async ( // 会话在建连时已认领为后端正式号(UUID),执行完成/失败后直接刷新会话结果 loadSessionResults(sid); delete sendingSessions[sid]; - // 执行完成后切回对话页:清空工作流选择,主区域展示消息流 - if (success) { + // 执行完成后切回对话页:清空工作流选择,主区域展示消息流; + // 仅当当前活跃会话即本轮会话时重置(多会话并发:后台会话完成不干扰当前会话的输入状态) + if (success && activeHistoryId.value === sid) { inputBarRef.value?.resetAll?.(); } }; @@ -1008,11 +1044,11 @@ const runWorkflow = async ( onClose: () => { if (!finished) finishExec(false, '连接已断开,请重试'); }, - // 停止 / 切会话 / 卸载共用:工作流无 AI 气泡,独立定格(不能复用 finishExec——它会追加 ✅/❌ 消息) + // 停止 / 删除 / 卸载共用:工作流无 AI 气泡,独立定格(不能复用 finishExec——它会追加 ✅/❌ 消息) abort: () => { if (finished) return; finished = true; - // 取消/切会话:表单卡片定格为已取消(只读、保留已填值),不再追加汇总消息 + // 取消/删除:表单卡片定格为已取消(只读、保留已填值),不再追加汇总消息 formMsg.formStatus = 'failed'; formMsg.formError = '执行已取消'; formMsg.formProgress = undefined; @@ -1020,8 +1056,8 @@ const runWorkflow = async ( delete sendingSessions[sid]; }, }; - // 先挂 handler 再等连接,覆盖建连等待窗口内的停止/切会话 - activeHandler = handler; + // 先挂 handler 再等连接,覆盖建连等待窗口内的停止 + roundHandlers.set(sid, handler); const ws = await ensureSocket(sid, sessionId); if (!ws) { finishExec(false, 'WebSocket 初始化失败,请检查服务地址'); @@ -1030,8 +1066,8 @@ const runWorkflow = async ( // 首次建连已认领后端正式号(ensureSocket 就绪前 ack 已处理):本轮 sid 更新为正式号, // 后续状态操作(消息/分页/结果)都落到新 key sid = currentIdOf(sid); - if (activeHandler !== handler) return; // 等待期被终止/切走 → 不再发送启动帧 - activeRunIsWorkflow.value = true; + if (roundHandlers.get(sid) !== handler) return; // 等待期被终止 → 不再发送启动帧 + runTypeMap[sid] = true; sendWorkflowStart(ws, { flowId: detail.id, flowContent: updatedFlowContent, @@ -1076,10 +1112,18 @@ const handleFormReEdit = (msg: ChatMessage) => { msg.formError = undefined; }; -// 工作流执行中取消(卡片 footer「取消执行」):复用 teardownActiveRound —— 发 cancel 帧 + 本轮 abort, +// 工作流执行中取消(卡片 footer「取消执行」):复用 teardownRound —— 发 cancel 帧 + 本轮 abort, // abort 把卡片定格为「执行已取消」(保留已填值,可重新编辑并执行) -const handleFormCancel = (_msg: ChatMessage) => { - teardownActiveRound(); +const handleFormCancel = (msg: ChatMessage) => { + // 定位卡片所在会话:优先活跃会话,其次按消息列表反查(卡片必在当前活跃会话渲染) + let sid = activeHistoryId.value || ''; + for (const [k, list] of sessionMessages.value) { + if (list.includes(msg)) { + sid = k; + break; + } + } + if (sid) teardownRound(sid); }; // ===== 会话内产出文件卡片操作:预览 / 下载 / 删除 ===== @@ -1126,10 +1170,10 @@ const handleOutputDelete = async (msg: ChatMessage, output: WorkflowOutput) => { // ===== 普通对话:未选工作流 → 纯问答,AI 回复进消息流 ===== const runChat = async (sid: string, sessionId: string, message: string) => { - activeRunIsWorkflow.value = false; - // AI loading 占位气泡 + runTypeMap[sid] = false; + // AI loading 占位气泡(按 sid 定向写入,后台会话的 loading 落在其自身列表) const aiMsgId = 'msg-' + Date.now() + '-ai'; - addMessage({ id: aiMsgId, content: '', time: formatTime(new Date()), isUser: false, loading: true }); + pushToSession(sid, { id: aiMsgId, content: '', time: formatTime(new Date()), isUser: false, loading: true }); let done = false; // 思考/生成过程计时起点(收到首帧时开始) @@ -1197,6 +1241,11 @@ const runChat = async (sid: string, sessionId: string, message: string) => { const finishChat = (success: boolean, errorMsg?: string) => { if (done) return; done = true; + // 本轮结束:清 handler 与运行类型(正常完成路径不走 teardownRound,主动清理避免残留路由) + roundHandlers.delete(sid); + delete runTypeMap[sid]; + // 后台会话(非当前活跃)对话完成 → 释放空闲连接;活跃会话保留连接供继续交互 + if (activeHistoryId.value !== sid) closeSocket(sid); // 结束前清理打字机:成功则把队列剩余思考一次性补全,失败则丢弃未渲染内容 stopTypewriter(); if (success) { @@ -1214,7 +1263,7 @@ const runChat = async (sid: string, sessionId: string, message: string) => { list[aiIdx].thinking = ''; list[aiIdx].retryQuestion = message; } else { - addMessage({ id: 'msg-' + Date.now() + '-fail', content, time: formatTime(new Date()), isUser: false }); + pushToSession(sid, { id: 'msg-' + Date.now() + '-fail', content, time: formatTime(new Date()), isUser: false }); } } else if (list && aiIdx >= 0) { // 成功兜底:后端未推任何回复内容时,给出明确提示并允许重新生成(避免空白气泡) @@ -1254,7 +1303,7 @@ const runChat = async (sid: string, sessionId: string, message: string) => { return; } - // 会话级长连接:本轮消息处理整体进 handler,连接只路由到 activeHandler(打字机/thinking 闭包保留在上面) + // 会话级长连接:本轮消息处理整体进 handler,连接按 sid 路由到对应会话 handler(打字机/thinking 闭包保留在上面) const handler: RoundHandler = { onMessage: (raw) => { const msg = parseWsMessage(raw); @@ -1353,14 +1402,14 @@ const runChat = async (sid: string, sessionId: string, message: string) => { onClose: () => { if (!done) finishChat(false, '连接已断开,请重试'); }, - // 停止 / 切会话 / 卸载共用:保留已生成内容(finishChat 内部 flush 打字机并清 sendingSessions),无内容则标记已停止 + // 停止 / 删除 / 卸载共用:保留已生成内容(finishChat 内部 flush 打字机并清 sendingSessions),无内容则标记已停止 abort: () => { finishChat(true); markStopped(sid); }, }; - // 先挂 handler 再等连接,覆盖建连等待窗口内的停止/切会话 - activeHandler = handler; + // 先挂 handler 再等连接,覆盖建连等待窗口内的停止 + roundHandlers.set(sid, handler); const ws = await ensureSocket(sid, sessionId); if (!ws) { finishChat(false, 'WebSocket 初始化失败,请检查服务地址'); @@ -1368,17 +1417,19 @@ const runChat = async (sid: string, sessionId: string, message: string) => { } // 首次建连已认领后端正式号(ensureSocket 就绪前 ack 已处理):本轮 sid 更新为正式号 sid = currentIdOf(sid); - if (activeHandler !== handler) return; // 等待期被终止/切走 → 不再发送启动帧 + if (roundHandlers.get(sid) !== handler) return; // 等待期被终止 → 不再发送启动帧 sendAgentStart(ws, { modelId: chatModelId, question: message }); }; const handleSelectHistory = async (id: string) => { // 防重复点击同一条,避免误杀正在生成的轮 if (id === activeHistoryId.value) return; - // 会话级长连接(单活跃):切会话先终止旧轮(保留已生成内容)+ 关闭旧连接 - teardownSession(); + // 多会话并发:切会话不再终止旧轮/关闭旧连接,后台执行继续; + // 仅当旧会话当前无执行任务时释放其空闲连接(切回时惰性重连) + const prevId = activeHistoryId.value; activeHistoryId.value = id; activeMenu.value = 'chat'; + if (prevId && !roundHandlers.has(prevId)) closeSocket(prevId); // 切换会话不继承工作流选择(历史工作流会话回显时预选上次执行的工作流,可自由切换) selectedWorkflowDetail.value = null; const session = historyList.value.find((h) => h.id === id); @@ -1561,8 +1612,8 @@ const handleLoadMore = async (sid: string | null | undefined) => { const createNewSession = () => { - // 新建会话:终止当前会话的轮与连接(单活跃连接) - teardownSession(); + // 新建会话:不终止旧会话的执行(多会话并发,后台继续);旧会话无执行任务时释放空闲连接 + const prevId = activeHistoryId.value; const sessionId = `virtual_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`; historyList.value.unshift({ id: sessionId, @@ -1575,6 +1626,7 @@ const createNewSession = () => { selectedWorkflowDetail.value = null; inputBarRef.value?.resetAll(); sessionMessages.value.set(sessionId, []); // 清空消息 + if (prevId && !roundHandlers.has(prevId)) closeSocket(prevId); }; const handleCreateHistory = () => { @@ -1592,12 +1644,9 @@ const handleDeleteHistory = async (id: string) => { } catch { return; } - // 删除的是当前活跃/生成中会话 → 终止该轮并关闭其连接;否则仅关闭对应连接 - if (activeHistoryId.value === id) { - teardownSession(); - } else if (wsState?.sid === id) { - closeCurrentSocket(); - } + // 删除会话:终止其进行中的轮(若有)并关闭其连接(后台执行一并放弃) + teardownRound(id); + closeSocket(id); // 已认领后端正式号的会话才调后端删除接口;未认领的本地临时会话后端无记录,跳过删除 if (hasBackendSessionId(id)) { try { @@ -1631,9 +1680,9 @@ onMounted(() => { // 不自动创建 / 选中会话,默认显示占位引导页 }); -// 组件卸载:关闭当前会话的长连接,避免泄漏 +// 组件卸载:清理所有会话的轮与长连接,避免泄漏 onBeforeUnmount(() => { - teardownSession(); + teardownAll(); });