From bb15c053e6d5804c925a474d2b79f793b50ab994 Mon Sep 17 00:00:00 2001 From: 2910410219 <2910410219@qq.com> Date: Sat, 15 Aug 2026 21:36:33 +0800 Subject: [PATCH] =?UTF-8?q?=E9=A6=96=E9=A1=B5=E5=B7=A5=E4=BD=9C=E6=B5=81?= =?UTF-8?q?=E8=A1=A8=E5=8D=95=E5=8D=A1=E7=89=87=E5=8C=96=E9=97=AD=E7=8E=AF?= =?UTF-8?q?=EF=BC=9A=E6=89=A7=E8=A1=8C=E8=BF=9B=E5=BA=A6=E3=80=81=E8=BE=93?= =?UTF-8?q?=E5=85=A5=E7=A6=81=E7=94=A8=E4=B8=8E=E5=8F=96=E6=B6=88=E5=85=A5?= =?UTF-8?q?=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - socket 节点事件推进表单卡片执行进度,失败详情展示详细原因 - 工作流执行时禁用输入框,取消入口移到卡片 footer,卡片定格可重试 - 工作流 ModelRequestParams 按详情原样透传后端 - 会话内产出文件卡片展示(预览/下载/删除),移除顶部结果卡片区 Co-Authored-By: Claude --- src/views/home/components/ChatList.vue | 22 ++ src/views/home/components/InputBar.vue | 30 +- src/views/home/components/MainContent.vue | 34 +-- src/views/home/components/SessionResults.vue | 288 ------------------ .../home/components/WorkflowFormCard.vue | 68 ++++- .../home/components/WorkflowOutputCard.vue | 148 +++++++++ src/views/home/index.vue | 203 ++++++++---- src/views/home/utils/flowDsl.ts | 51 ++++ src/views/home/utils/wsMessage.ts | 27 +- 9 files changed, 495 insertions(+), 376 deletions(-) delete mode 100644 src/views/home/components/SessionResults.vue create mode 100644 src/views/home/components/WorkflowOutputCard.vue diff --git a/src/views/home/components/ChatList.vue b/src/views/home/components/ChatList.vue index 88b6fd6..4a83a8a 100644 --- a/src/views/home/components/ChatList.vue +++ b/src/views/home/components/ChatList.vue @@ -19,7 +19,10 @@ :readonly="msg.formStatus === 'done' || msg.formStatus === 'failed'" :submitting="msg.formStatus === 'running'" :form-error="msg.formError" + :progress="msg.formProgress" @submit="emit('form-submit', msg, $event)" + @re-edit="emit('form-re-edit', msg)" + @cancel="emit('form-cancel', msg)" /> + +
@@ -17,7 +17,8 @@ v-model="message" type="textarea" :autosize="{ minRows: 1, maxRows: 6 }" - placeholder="有什么想聊的?按 Enter 发送,Shift+Enter 换行" + :disabled="generating || isWorkflowRunning" + :placeholder="inputPlaceholder" class="message-input" @focus="isFocused = true" @blur="isFocused = false" @@ -49,11 +50,11 @@ Shift+Enter 换行 @@ -92,6 +93,8 @@ interface Props { generating?: boolean; // 当前会话模型名称,用于输入栏切换入口展示 currentModelName?: string; + // 工作流正在执行中:输入框/发送按钮整体禁用,停止按钮转移到表单卡片 footer + isWorkflowRunning?: boolean; } interface Emits { @@ -115,6 +118,7 @@ const props = withDefaults(defineProps(), { hideShortcuts: false, generating: false, currentModelName: '', + isWorkflowRunning: false, }); const emit = defineEmits(); const message = ref(''); @@ -129,6 +133,14 @@ const visibleWorkflows = computed(() => { return commonWorkflows.value; }); +// 输入框整体锁定:选择了工作流(不可取消)或正处于生成/工作流执行中 +const lockAll = computed(() => props.workflowLocked || props.isWorkflowRunning || props.generating); + +// 输入框占位符:工作流执行中给出明确提示 +const inputPlaceholder = computed(() => + props.isWorkflowRunning ? '工作流执行中,请稍候...' : '有什么想聊的?按 Enter 发送,Shift+Enter 换行', +); + const fetchWorkflows = async () => { try { const res = await getWorkflowList(); @@ -163,7 +175,7 @@ const handleAttachment = () => { }; const toggleWorkflow = (id: string) => { - if (props.workflowLocked) return; + if (lockAll.value) return; const item = commonWorkflows.value.find((w) => w.id === id); const newId = selectedWorkflowId.value === id ? null : id; selectedWorkflowId.value = newId; @@ -177,13 +189,13 @@ const resetAll = () => { }; const clearWorkflow = () => { - if (props.workflowLocked) return; + if (lockAll.value) return; selectedWorkflowId.value = null; emit('workflow-select', null); }; const selectWorkflow = (id: string | null) => { - if (props.workflowLocked) return; + if (lockAll.value) return; selectedWorkflowId.value = id; emit('workflow-select', id, false); }; diff --git a/src/views/home/components/MainContent.vue b/src/views/home/components/MainContent.vue index 63326b9..07b52ec 100644 --- a/src/views/home/components/MainContent.vue +++ b/src/views/home/components/MainContent.vue @@ -1,17 +1,9 @@ @@ -153,6 +165,7 @@ + + diff --git a/src/views/home/index.vue b/src/views/home/index.vue index 4ec7eeb..e09ab6b 100644 --- a/src/views/home/index.vue +++ b/src/views/home/index.vue @@ -20,13 +20,17 @@ :active-history-id="activeHistoryId" :messages="currentMessages" :results="currentSessionResults" - :results-loading="currentSessionResultsLoading" :has-more="currentSessionHasMore" :loading-more="currentSessionLoadingMore" @retry="handleRetry" @regenerate="handleRetry" @delete="handleDeleteMessage" @form-submit="handleFormSubmit" + @form-re-edit="handleFormReEdit" + @form-cancel="handleFormCancel" + @output-preview="handleOutputPreview" + @output-download="handleOutputDownload" + @output-delete="handleOutputDelete" @workflow-select="handlePlaceWorkflowSelect" @load-more="handleLoadMore" /> @@ -36,6 +40,7 @@ :workflow-locked="isHistoryWorkflow" :hide-shortcuts="isPlaceholder" :generating="isGenerating" + :is-workflow-running="isWorkflowRunning" :current-model-name="currentChatModelName" @send="handleSend" @workflow-select="handleWorkflowSelect" @@ -85,14 +90,16 @@ import MainContent from './components/MainContent.vue'; import InputBar from './components/InputBar.vue'; import TemplateCompleteDialog from './components/TemplateCompleteDialog.vue'; import SessionModelSetter from './components/SessionModelSetter.vue'; -import { applyHomeFormValues } from './utils/flowDsl'; +import { applyHomeFormValues, extractWorkflowOutputs } from './utils/flowDsl'; +import type { WorkflowOutput } from './utils/flowDsl'; import { getChatModel, listModelManage } from '/@/api/settings/modelConfigV2'; import { connectSessionSocket, sendAgentStart, sendWorkflowStart, sendCancel } from './utils/wsExecute'; -import { parseWsMessage, getDelta, getAnswer, getErrorText, getToolCallName, getToolResultText } from './utils/wsMessage'; +import { parseWsMessage, getDelta, getAnswer, getErrorText, getNodeProgress, getToolCallName, getToolResultText } from './utils/wsMessage'; import type { ExecutionTreeItem } from '/@/api/settings/creation'; import { getExecutionList, getWorkflowDetail, + getExecutionDetail, deleteExecutionResult, downloadToFile, } from '/@/api/settings/creation'; @@ -124,6 +131,10 @@ interface ChatMessage { form?: any; formStatus?: 'editing' | 'running' | 'done' | 'failed'; formError?: string; + // 工作流执行节点进度(后端 node_start/node_complete 事件推进,运行中显示) + formProgress?: { current: number; total: number; nodeName: string }; + // 工作流执行产出文件列表(挂在结果消息上,渲染产出卡片) + outputs?: WorkflowOutput[]; } interface TreeNode { @@ -376,11 +387,14 @@ let activeHandler: RoundHandler | null = null; // 当前活跃的会话级连接(单活跃:切会话时关旧开新) let wsState: { ws: WebSocket; sid: string; sessionId: string; ready: Promise } | null = null; // 当前进行中的一轮类型:true=工作流执行,false=普通对话(sendCancel 协议判断用) -let activeRunIsWorkflow = false; +// ref 响应式:InputBar 依据它区分「工作流执行中」与「普通对话生成中」,决定禁用/停止按钮展示 +const activeRunIsWorkflow = ref(false); const isHistoryWorkflow = ref(false); // 生成中:有活跃会话且该会话正处于发送状态(发送按钮切换为停止按钮) const isGenerating = computed(() => !!activeHistoryId.value && !!sendingSessions[activeHistoryId.value]); +// 工作流执行中:isGenerating 且本轮为工作流 → 输入框整体禁用,停止按钮转移到表单卡片 footer +const isWorkflowRunning = computed(() => isGenerating.value && activeRunIsWorkflow.value); const inputBarRef = ref(null); const templateDialogVisible = ref(false); const pendingTemplate = ref(null); @@ -413,10 +427,6 @@ const currentSessionResults = computed(() => { const id = activeHistoryId.value; return id ? sessionResultsMap[id] || [] : []; }); -const currentSessionResultsLoading = computed(() => { - const id = activeHistoryId.value; - return !!id && !!sessionResultsLoading[id]; -}); // session/get 分页:打开会话详情默认只拿最近 10 条对话(约一屏),更早的通过滚动加载更多(下一页更早的记录) const SESSION_PAGE_SIZE = 10; @@ -440,7 +450,21 @@ const getSessionId = () => { const handleWorkflowSelect = async (workflowId: string | null, isTemplate?: boolean) => { if (workflowId === null) { - // 取消选择:移除最新未提交(editing)的表单卡片草稿,已提交/定格的保留 + // 取消选择:若存在未提交(editing)的表单草稿先确认是否放弃,已提交/定格的保留 + const id = activeHistoryId.value; + const list = id ? sessionMessages.value.get(id) : undefined; + const hasDraft = !!list && list.some((m) => m.type === 'form' && m.formStatus === 'editing'); + if (hasDraft) { + try { + await ElMessageBox.confirm('放弃当前未提交的工作流表单?', '提示', { + confirmButtonText: '放弃', + cancelButtonText: '继续编辑', + type: 'warning', + }); + } catch { + return; // 用户选择继续编辑,不清除草稿与选择 + } + } removeDraftFormCard(); selectedWorkflowDetail.value = null; return; @@ -457,6 +481,8 @@ const handleWorkflowSelect = async (workflowId: string | null, isTemplate?: bool return; } try { + // 切换工作流:先移除当前会话未提交(editing)的旧草稿卡片,已提交/定格的保留 + removeDraftFormCard(); const res = await getWorkflowDetail(workflowId); selectedWorkflowDetail.value = res.data || null; // 对话卡片化:选中工作流即推送一张可填表单卡片进入消息流 @@ -625,7 +651,7 @@ const teardownActiveRound = () => { const st = wsState; if (st && st.ws.readyState === WebSocket.OPEN) { try { - sendCancel(st.ws, activeRunIsWorkflow); + sendCancel(st.ws, activeRunIsWorkflow.value); } catch { /* 连接异常时忽略,仅做本地定格 */ } @@ -797,16 +823,20 @@ const runWorkflow = async ( const finishExec = (success: boolean, errorMsg?: string) => { if (finished) return; finished = true; - // 定格表单卡片:done/failed + 失败信息 + // 定格表单卡片:done/failed + 失败信息;执行结束清进度(避免残留影响重新编辑) formMsg.formStatus = success ? 'done' : 'failed'; formMsg.formError = success ? undefined : errorMsg || '执行失败'; + formMsg.formProgress = undefined; curSession.status = success ? 'completed' : 'failed'; - addMessage({ + // 结果消息:成功记录后续异步附加本次执行产出(outputs) + const resultMsg: ChatMessage = { id: 'msg-' + Date.now() + (success ? '-done' : '-fail'), content: success ? '✅ 执行完成,可前往工作空间查看产出' : `❌ ${errorMsg || '执行失败,请重试或联系管理员'}`, time: formatTime(new Date()), isUser: false, - }); + outputs: [], + }; + addMessage(resultMsg); if (success) { ElMessage.success('✅ 执行完成,可前往工作空间查看'); // 刷新工作空间树(查询所有结果) @@ -814,6 +844,19 @@ const runWorkflow = async ( imgAddressPrefix.value = res.data?.imgAddressPrefix || ''; treeNodes.value = buildTreeNodes(res.data?.tree || []); }); + // 会话内渲染本次产出:拉最新 session/get 记录(时间倒序首条)→ getExecutionDetail → 附加到结果消息 + // 时序降级:记录未及时写入则静默跳过(用户重进会话可见回显产出) + loadSessionResults(sid).then((results) => { + const latest = Array.isArray(results) && results.length ? results[0] : undefined; + if (latest && latest.type === 'workflow' && !latest.errorMsg) { + getExecutionDetail(String(latest.id)) + .then((res) => { + const outputs = extractWorkflowOutputs(res?.data, String(latest.id)); + if (outputs.length) resultMsg.outputs = outputs; + }) + .catch(() => {}); + } + }); } else { ElMessage.error(errorMsg || '执行失败,请重试'); } @@ -856,19 +899,10 @@ const runWorkflow = async ( try { // 1. 构建节点输入参数:深拷贝 DSL,把表单卡片值写回开始节点(唯一表单源)运行字段 + // 其余节点结构原样透传后端——后端执行依赖 model 节点的 modelConfig(含 modelRequestParams 等), + // 从详情拿到的工作流是什么样就给后端什么样,不做任何字段删除。 const nodeInputParams = JSON.parse(JSON.stringify(detail.nodeInputParams || [])); applyHomeFormValues(nodeInputParams, opts.formValues, opts.formFileNames); - // 开始节点为唯一表单源:执行时 model 节点不带参数结构、form 节点不带自定义字段 - // (值已汇总进开始节点 outputConfig 一并提交,避免重复/冗余参数) - nodeInputParams.forEach((n: any) => { - const code = String(n?.nodeCode || '').toLowerCase(); - if (code === 'model' && n?.modelConfig && typeof n.modelConfig === 'object') { - delete n.modelConfig.modelRequestParams; - delete n.modelConfig.modelFormFields; - } else if (code === 'form') { - delete n.outputConfig; - } - }); // 2. 构建 flowContent const updatedFlowContent = { @@ -888,6 +922,10 @@ const runWorkflow = async ( finishExec(false, getErrorText(msg)); } else if (msg.type === 'answer') { finishExec(true); + } else { + // 工作流节点进度推进:node_start / node_complete → 更新表单卡片运行态进度文本 + const prog = getNodeProgress(msg); + if (prog) formMsg.formProgress = prog; } return; } @@ -908,11 +946,11 @@ const runWorkflow = async ( abort: () => { if (finished) return; finished = true; - // 停止/切会话:表单卡片定格为已停止(只读),不再追加汇总消息 + // 取消/切会话:表单卡片定格为已取消(只读、保留已填值),不再追加汇总消息 formMsg.formStatus = 'failed'; - formMsg.formError = '执行已停止'; + formMsg.formError = '执行已取消'; + formMsg.formProgress = undefined; curSession.status = 'completed'; - markStopped(sid); delete sendingSessions[sid]; }, }; @@ -924,7 +962,7 @@ const runWorkflow = async ( return; } if (activeHandler !== handler) return; // 等待期被终止/切走 → 不再发送启动帧 - activeRunIsWorkflow = true; + activeRunIsWorkflow.value = true; sendWorkflowStart(ws, { flowId: detail.id, flowContent: updatedFlowContent, @@ -951,26 +989,75 @@ const handleFormSubmit = async ( const session = historyList.value.find((h) => h.id === sid); if (!session || !msg.form) return; - // 卡片定格执行中,禁用重复提交 + // 标记本轮发送中:工作流执行期间 InputBar 显示停止按钮(isGenerating 依赖 sendingSessions) + sendingSessions[sid] = true; + // 卡片定格执行中,禁用重复提交;表单卡片 running 态即提交意图,不追加用户消息 msg.formStatus = 'running'; msg.formError = undefined; - // 追加用户消息「执行工作流」 - addMessage({ - id: 'msg-' + Date.now() + '-wfuser', - content: '执行工作流', - time: formatTime(new Date()), - isUser: true, - }); - session.status = 'executing'; const sessionId = session.sessionId || getSessionId(); await runWorkflow(sid, sessionId, msg.form, payload, msg); }; +// 失败卡片「重新编辑并执行」:切回 editing 保留已填值(组件内 formValues 随消息保留, +// detail 未变不触发重置),改完重新提交 +const handleFormReEdit = (msg: ChatMessage) => { + msg.formStatus = 'editing'; + msg.formError = undefined; +}; + +// 工作流执行中取消(卡片 footer「取消执行」):复用 teardownActiveRound —— 发 cancel 帧 + 本轮 abort, +// abort 把卡片定格为「执行已取消」(保留已填值,可重新编辑并执行) +const handleFormCancel = (_msg: ChatMessage) => { + teardownActiveRound(); +}; + +// ===== 会话内产出文件卡片操作:预览 / 下载 / 删除 ===== +const handleOutputPreview = (msg: ChatMessage, output: WorkflowOutput) => { + if (!output.url) return; + previewUrl.value = buildAssetUrl(output.url); + previewMode.value = getPreviewMode(output.type); + previewDialogVisible.value = true; +}; + +const handleOutputDownload = async (msg: ChatMessage, output: WorkflowOutput) => { + if (!output.url) return; + await downloadAsset(output.url, output.name); +}; + +const handleOutputDelete = async (msg: ChatMessage, output: WorkflowOutput) => { + try { + await ElMessageBox.confirm(`确定删除产出「${output.name}」?此操作不可恢复。`, '删除产出', { + confirmButtonText: '删除', + cancelButtonText: '取消', + type: 'warning', + }); + } catch { + return; + } + try { + if (output.backendId && output.url) { + await deleteExecutionResult({ id: output.backendId, content: output.url }); + } + // 从消息产出列表移除,并刷新工作空间树 + if (Array.isArray(msg.outputs)) { + const idx = msg.outputs.findIndex((o) => o.url === output.url); + if (idx >= 0) msg.outputs.splice(idx, 1); + } + getExecutionList().then((res) => { + imgAddressPrefix.value = res.data?.imgAddressPrefix || ''; + treeNodes.value = buildTreeNodes(res.data?.tree || []); + }); + ElMessage.success('已删除'); + } catch (e: any) { + ElMessage.error(e?.message || '删除失败'); + } +}; + // ===== 普通对话:未选工作流 → 纯问答,AI 回复进消息流 ===== const runChat = async (sid: string, sessionId: string, message: string) => { - activeRunIsWorkflow = false; + activeRunIsWorkflow.value = false; // AI loading 占位气泡 const aiMsgId = 'msg-' + Date.now() + '-ai'; addMessage({ id: aiMsgId, content: '', time: formatTime(new Date()), isUser: false, loading: true }); @@ -1253,18 +1340,20 @@ const handleSelectHistory = async (id: string) => { // 表单卡片直接用结果记录的 requestParams(完整 flowContent,含 __start__ outputConfig 值快照)构造, // 不再依赖 execution/get;chat 结果按原逻辑转消息流 const loadWorkflowSessionMessages = async (sid: string, results: VOSessionInfoResult[]) => { - // 最近一次 workflow 结果记录(results 时间倒序,第一条即最新):它的 requestParams 即回显表单值来源 + // 最近一次 workflow 结果记录(results 时间倒序,第一条即最新):用于同步 InputBar 锁定标签展示 const latestWf = Array.isArray(results) ? results.find((r) => r.type === 'workflow') : undefined; - // 同步回显 InputBar 的工作流选择(锁定标签展示):按 flowId 匹配(commonWorkflows 的 id 即工作流 id) - let flowName = ''; + // 按 flowId 查工作流名(commonWorkflows 的 id 即工作流 id),每条记录用各自名称 + const flowNameOf = (flowId?: string): string => { + if (!flowId) return ''; + const ib = inputBarRef.value as any; + const match = ib?.commonWorkflows?.find((w: any) => String(w.id) === String(flowId)); + return match ? String(match.name || '') : ''; + }; if (latestWf?.flowId) { const ib = inputBarRef.value as any; if (ib?.commonWorkflows) { const match = ib.commonWorkflows.find((w: any) => String(w.id) === String(latestWf.flowId)); - if (match) { - ib.selectedWorkflowId = match.id; - flowName = String(match.name || ''); - } + if (match) ib.selectedWorkflowId = match.id; } } const msgs: ChatMessage[] = []; @@ -1277,8 +1366,9 @@ const loadWorkflowSessionMessages = async (sid: string, results: VOSessionInfoRe } else if (r.type === 'workflow') { // 失败判断:以 errorMsg 为准(部分失败记录 status 仍是 1,仅凭 status 会误判为成功) const failed = !!r.errorMsg; - // 表单卡片:仅最近一次 workflow 结果渲染带值表单卡片(requestParams.nodes 即 nodeInputParams 结构) - if (r === latestWf && Array.isArray(r.requestParams?.nodes)) { + // 每次执行都渲染带值表单卡片(requestParams.nodes 即该次提交的表单值快照); + // form 补 id=flowId,供失败卡片「重新编辑并执行」时作为启动 flowId + if (Array.isArray(r.requestParams?.nodes)) { msgs.push({ id: 'form-' + r.id, content: '', @@ -1286,7 +1376,8 @@ const loadWorkflowSessionMessages = async (sid: string, results: VOSessionInfoRe isUser: false, type: 'form', form: { - flowName, + id: r.flowId, + flowName: flowNameOf(r.flowId) || flowNameOf(latestWf?.flowId), nodeInputParams: r.requestParams.nodes, flowContent: r.requestParams, }, @@ -1294,15 +1385,25 @@ const loadWorkflowSessionMessages = async (sid: string, results: VOSessionInfoRe formError: failed ? r.errorMsg || '执行失败' : '', }); } - // 结果消息 - msgs.push({ + // 结果消息:成功记录异步拉取产出(getExecutionDetail → outputParams/fileUrls)附加到 outputs + const resultMsg: ChatMessage = { id: failed ? 'wfail-' + r.id : 'wok-' + r.id, content: failed ? '❌ ' + (r.errorMsg || '执行失败,请重试或联系管理员') : '✅ 执行完成,可前往工作空间查看产出', time: r.createdAt || '', isUser: false, recordId: String(r.id), recordType: r.type, - }); + outputs: [], + }; + msgs.push(resultMsg); + if (!failed) { + getExecutionDetail(String(r.id)) + .then((res) => { + const outputs = extractWorkflowOutputs(res?.data, String(r.id)); + if (outputs.length) resultMsg.outputs = outputs; + }) + .catch(() => {}); + } } }); sessionMessages.value.set(sid, msgs); diff --git a/src/views/home/utils/flowDsl.ts b/src/views/home/utils/flowDsl.ts index 7ef83cc..5d2e105 100644 --- a/src/views/home/utils/flowDsl.ts +++ b/src/views/home/utils/flowDsl.ts @@ -143,3 +143,54 @@ export function checkTemplateMissing(flowContent: any): { nodeId: string; nodeNa } return missing; } + +// ===== 执行产出提取 ===== +// 会话内展示工作流执行产出(文件卡片):来自 getExecutionDetail 返回的 WorkflowItem, +// 合并 fileUrls / outputParams 各 value / resultUrl,去重后归一为 { url, name, type, backendId }。 +// type 用于预览模式判定(image/video/audio/text/file),backendId 为执行记录 id(删除产出用)。 + +export interface WorkflowOutput { + url: string; + name: string; + type: string; + backendId: string; +} + +// 按文件扩展名推断预览类型 +function guessFileType(url: string): string { + const clean = String(url).split('?')[0] || ''; + const ext = (clean.split('.').pop() || '').toLowerCase(); + if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg', 'ico'].includes(ext)) return 'image'; + if (['mp4', 'webm', 'mov', 'avi', 'mkv'].includes(ext)) return 'video'; + if (['mp3', 'wav', 'ogg', 'flac', 'm4a', 'aac'].includes(ext)) return 'audio'; + if (['txt', 'md', 'json', 'log', 'csv'].includes(ext)) return 'text'; + return 'file'; +} + +export function extractWorkflowOutputs(data: any, backendId?: string): WorkflowOutput[] { + if (!data) return []; + const seen = new Set(); + const outputs: WorkflowOutput[] = []; + const push = (rawUrl: any) => { + if (typeof rawUrl !== 'string') return; + const url = rawUrl.trim(); + if (!url || seen.has(url)) return; + seen.add(url); + const name = String(url).split('?')[0].split('/').pop() || '产出文件'; + outputs.push({ + url, + name, + type: guessFileType(url), + backendId: backendId || data.id || '', + }); + }; + if (Array.isArray(data.fileUrls)) data.fileUrls.forEach(push); + if (Array.isArray(data.outputParams)) { + data.outputParams.forEach((o: any) => { + if (!o || typeof o !== 'object') return; + Object.values(o).forEach((v) => push(v)); + }); + } + if (data.resultUrl) push(data.resultUrl); + return outputs; +} diff --git a/src/views/home/utils/wsMessage.ts b/src/views/home/utils/wsMessage.ts index 74470a4..f862105 100644 --- a/src/views/home/utils/wsMessage.ts +++ b/src/views/home/utils/wsMessage.ts @@ -6,7 +6,9 @@ // {"type":"reasoning_chunk","message":"思考中","data":{"delta":"你"}} 思考内容增量(逐 chunk) // {"type":"answer_chunk","message":"回答中","data":{"delta":"你"}} 回答内容增量(逐 chunk) // {"type":"answer","message":"作答完成","data":{"answer":"..."}} 最终回答 -// {"type":"error","message":"..."} 执行失败 +// {"type":"node_start","message":"开始执行(2/4): xxx","data":{"nodeCount":4,"nodeId":"..","nodeIndex":2,"nodeName":".."}} 工作流节点开始(进度推进) +// {"type":"node_complete","message":"执行完成(2/4): xxx","data":同上} 工作流节点完成(进度推进) +// {"type":"error","message":"...","error":"节点失败详情"} 执行失败(error 字段含详细原因) // 完成/失败判定均以 type 为准,不再用正则猜测文本。 export interface WsStreamMessage { @@ -14,6 +16,8 @@ export interface WsStreamMessage { message?: string; data?: any; payload?: any; + // 失败详情:error 事件携带的详细原因(含节点名与具体失败信息),比 message 更具体 + error?: string; } /** 后端 ReAct WebSocket 事件类型常量 */ @@ -24,6 +28,8 @@ export const WsEventType = { Answer: 'answer', // 最终回答 AnswerChunk: 'answer_chunk', // 回答内容增量(逐 chunk) ReasoningChunk: 'reasoning_chunk', // 思考内容增量(逐 chunk) + NodeStart: 'node_start', // 工作流节点开始(进度推进) + NodeComplete: 'node_complete', // 工作流节点完成(进度推进) Error: 'error', // 出错 } as const; @@ -62,6 +68,21 @@ export function getStepInfo(msg: WsStreamMessage): { step: number; maxStep: numb return { step, maxStep: typeof maxStep === 'number' ? maxStep : 0 }; } +/** 工作流节点进度:node_start / node_complete → { current, total, nodeName },非节点事件返回 null */ +export function getNodeProgress(msg: WsStreamMessage): { current: number; total: number; nodeName: string } | null { + const t = msg?.type; + if (t !== WsEventType.NodeStart && t !== WsEventType.NodeComplete) return null; + const d = msg?.data; + const nodeIndex = d?.nodeIndex; + const nodeCount = d?.nodeCount; + if (typeof nodeIndex !== 'number' || typeof nodeCount !== 'number') return null; + return { + current: nodeIndex, + total: nodeCount, + nodeName: typeof d?.nodeName === 'string' ? d.nodeName : '', + }; +} + /** tool_call → 工具名称(tool / toolName / name 依次尝试,缺失返回空串) */ export function getToolCallName(msg: WsStreamMessage): string { const d = msg?.data; @@ -87,8 +108,10 @@ export function getToolResultText(msg: WsStreamMessage): string { return ''; } -/** error → 错误文案(message 优先,其次 data.message / data.error / data.msg) */ +/** error → 错误文案(error 字段为详细原因优先;其次 message;再 data.message / data.error / data.msg) */ export function getErrorText(msg: WsStreamMessage): string { + // 详细失败原因(含节点名/具体错误),优先展示 + if (typeof msg?.error === 'string' && msg.error) return msg.error; if (typeof msg?.message === 'string' && msg.message) return msg.message; const d = msg?.data; if (typeof d === 'string') return d;