diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/service/ai/WorkflowChatEventStream.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/service/ai/WorkflowChatEventStream.java index e5ba2740..88b4ee0c 100644 --- a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/service/ai/WorkflowChatEventStream.java +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/service/ai/WorkflowChatEventStream.java @@ -10,12 +10,9 @@ import com.easyagents.flow.core.chain.Node; import com.easyagents.flow.core.chain.event.ChainStatusChangeEvent; import com.easyagents.flow.core.chain.event.EdgeConditionCheckFailedEvent; import com.easyagents.flow.core.chain.event.EdgeTriggerEvent; -import com.easyagents.flow.core.chain.event.LlmStreamEvent; import com.easyagents.flow.core.chain.event.NodeEndEvent; import com.easyagents.flow.core.chain.event.NodeStartEvent; import com.easyagents.flow.core.chain.runtime.ChainExecutor; -import com.easyagents.flow.core.node.EndNode; -import com.easyagents.flow.core.node.LlmNode; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; @@ -59,7 +56,6 @@ public class WorkflowChatEventStream { @PostConstruct public void registerListeners() { chainExecutor.addEventListener(this::onEvent); - chainExecutor.addOutputListener(this::onExplicitOutput); chainExecutor.addErrorListener(this::onChainError); } @@ -107,10 +103,6 @@ public class WorkflowChatEventStream { if (session == null) { return; } - if (event instanceof LlmStreamEvent streamEvent) { - session.onLlmDelta(chain, streamEvent); - return; - } if (event instanceof NodeStartEvent nodeStartEvent) { session.onNodeStarted(chain, nodeStartEvent); return; @@ -133,24 +125,6 @@ public class WorkflowChatEventStream { } } - /** - * 转发节点显式发布的每一条输出。 - * - * @param chain 当前工作流 - * @param node 输出节点 - * @param outputMessage 输出内容 - */ - private void onExplicitOutput( - Chain chain, - Node node, - Object outputMessage - ) { - StreamSession session = findSession(chain); - if (session != null) { - session.sendNodeOutput(node, outputMessage); - } - } - /** * 将链级异常发送到客户端。 * @@ -229,6 +203,26 @@ public class WorkflowChatEventStream { return error.getMessage(); } + /** + * 去掉顶级工作流结果中的内部状态控制字段。 + * + * @param result 顶级工作流执行结果 + * @return 可直接交给会话结果区展示的最终输出 + */ + static Map visibleFinalOutput( + Map result + ) { + Map visible = new LinkedHashMap<>(); + if (result != null) { + visible.putAll(result); + } + visible.remove(ChainConsts.CHAIN_STATE_STATUS_KEY); + visible.remove(ChainConsts.CHAIN_STATE_MESSAGE_KEY); + visible.remove(ChainConsts.NODE_STATE_STATUS_KEY); + visible.remove(ChainConsts.SCHEDULE_NEXT_NODE_DISABLED_KEY); + return visible; + } + /** * 单次工作流执行的 SSE 会话。 */ @@ -237,8 +231,6 @@ public class WorkflowChatEventStream { private final SseEmitter emitter; private final AtomicLong sequence = new AtomicLong(); private final AtomicBoolean terminal = new AtomicBoolean(false); - private final Map activeLlmStreams = - new ConcurrentHashMap<>(); private volatile String executeId; /** @@ -259,24 +251,6 @@ public class WorkflowChatEventStream { this.executeId = executeId; } - /** - * 处理 LLM 文本增量。 - * - * @param chain 当前工作流 - * @param event LLM 增量事件 - */ - private void onLlmDelta(Chain chain, LlmStreamEvent event) { - String nodeRunKey = nodeRunKey(chain, event.getNode()); - activeLlmStreams.put(nodeRunKey, event.getStreamId()); - String eventType = event.isReasoning() - ? "llm_thinking_delta" - : "llm_delta"; - send(eventType, nodePayload(event.getNode(), Map.of( - "streamId", event.getStreamId(), - "delta", event.getDelta() - ))); - } - /** * 处理节点开始事件。 * @@ -295,14 +269,13 @@ public class WorkflowChatEventStream { } /** - * 处理节点完成事件并输出所有可见结果。 + * 处理节点完成事件并更新运行详情。 * * @param chain 当前工作流 * @param event 节点完成事件 */ private void onNodeFinished(Chain chain, NodeEndEvent event) { Node node = event.getNode(); - String streamId = activeLlmStreams.remove(nodeRunKey(chain, node)); Map data = new LinkedHashMap<>(); data.put("attemptKey", event.getExecutionAttemptKey()); data.put("status", event.getStatus() == null @@ -316,17 +289,7 @@ public class WorkflowChatEventStream { if (event.getError() != null) { data.put("error", safeErrorMessage(event.getError())); } - if (streamId != null) { - data.put("streamId", streamId); - } send("node_finished", nodePayload(node, data)); - - if (node instanceof EndNode) { - sendNodeOutput(node, visibleEndOutput(event.getResult())); - } else if (node instanceof LlmNode && streamId == null) { - // 少数模型只在流结束时返回完整文本,仍需向对话区展示结果。 - sendNodeOutput(node, event.getResult()); - } } /** @@ -453,23 +416,17 @@ public class WorkflowChatEventStream { Map data = new LinkedHashMap<>(); data.put("status", status.name()); data.put("message", chain.getState().getMessage()); + if (status == ChainStatus.SUCCEEDED) { + data.put( + "output", + visibleFinalOutput(chain.getState().getExecuteResult()) + ); + } send(eventType, data); removeSession(this); emitter.complete(); } - /** - * 发送一条用户可见节点输出。 - * - * @param node 输出节点 - * @param outputMessage 输出内容 - */ - private void sendNodeOutput(Node node, Object outputMessage) { - send("output", nodePayload(node, Map.of( - "output", outputMessage == null ? Map.of() : outputMessage - ))); - } - /** * 发送 SSE 事件。 * @@ -515,17 +472,6 @@ public class WorkflowChatEventStream { } } - /** - * 构建节点本次执行的关联键。 - * - * @param chain 当前工作流 - * @param node 当前节点 - * @return 节点运行键 - */ - private String nodeRunKey(Chain chain, Node node) { - return chain.getStateInstanceId() + ":" + node.getId(); - } - /** * 构建带节点信息的事件数据。 * @@ -546,24 +492,5 @@ public class WorkflowChatEventStream { return payload; } - /** - * 去掉结束节点内部状态控制字段。 - * - * @param result 节点结果 - * @return 用户可见输出 - */ - private Map visibleEndOutput( - Map result - ) { - Map visible = new LinkedHashMap<>(); - if (result != null) { - visible.putAll(result); - } - visible.remove(ChainConsts.CHAIN_STATE_STATUS_KEY); - visible.remove(ChainConsts.CHAIN_STATE_MESSAGE_KEY); - visible.remove(ChainConsts.NODE_STATE_STATUS_KEY); - visible.remove(ChainConsts.SCHEDULE_NEXT_NODE_DISABLED_KEY); - return visible; - } } } diff --git a/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/service/ai/WorkflowChatEventStreamTest.java b/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/service/ai/WorkflowChatEventStreamTest.java new file mode 100644 index 00000000..f29a8541 --- /dev/null +++ b/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/service/ai/WorkflowChatEventStreamTest.java @@ -0,0 +1,72 @@ +package tech.easyflow.admin.service.ai; + +import com.easyagents.flow.core.chain.ChainConsts; +import com.easyagents.flow.core.chain.runtime.ChainExecutor; +import org.testng.Assert; +import org.testng.annotations.Test; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +/** + * {@link WorkflowChatEventStream} 最终输出协议测试。 + */ +public class WorkflowChatEventStreamTest { + + /** + * 验证会话事件流不再订阅节点显式输出。 + */ + @Test + public void shouldOnlyRegisterLifecycleAndErrorListeners() { + ChainExecutor chainExecutor = mock(ChainExecutor.class); + WorkflowChatEventStream eventStream = + new WorkflowChatEventStream(chainExecutor); + + eventStream.registerListeners(); + + verify(chainExecutor).addEventListener(any()); + verify(chainExecutor).addErrorListener(any()); + verify(chainExecutor, never()).addOutputListener(any()); + } + + /** + * 验证顶级工作流最终输出会保留业务结构并移除内部控制字段。 + */ + @Test + public void shouldKeepStructuredFinalOutputAndRemoveInternalFields() { + Map result = new LinkedHashMap<>(); + result.put("summary", "执行完成"); + result.put("items", List.of( + Map.of("name", "A", "score", 90), + Map.of("name", "B", "score", 85) + )); + result.put(ChainConsts.CHAIN_STATE_STATUS_KEY, "SUCCEEDED"); + result.put(ChainConsts.CHAIN_STATE_MESSAGE_KEY, "internal"); + result.put(ChainConsts.NODE_STATE_STATUS_KEY, "SUCCESS"); + result.put(ChainConsts.SCHEDULE_NEXT_NODE_DISABLED_KEY, true); + + Map visible = + WorkflowChatEventStream.visibleFinalOutput(result); + + Assert.assertEquals(visible.get("summary"), "执行完成"); + Assert.assertEquals(visible.get("items"), result.get("items")); + Assert.assertEquals(visible.size(), 2); + Assert.assertEquals(result.size(), 6); + } + + /** + * 验证空执行结果被规范化为空对象。 + */ + @Test + public void shouldNormalizeMissingFinalOutputToEmptyMap() { + Assert.assertTrue( + WorkflowChatEventStream.visibleFinalOutput(null).isEmpty() + ); + } +} diff --git a/easyflow-ui-admin/app/src/views/ai/workflow/components/WorkflowChatPage.vue b/easyflow-ui-admin/app/src/views/ai/workflow/components/WorkflowChatPage.vue index e4024a7a..3197deb5 100644 --- a/easyflow-ui-admin/app/src/views/ai/workflow/components/WorkflowChatPage.vue +++ b/easyflow-ui-admin/app/src/views/ai/workflow/components/WorkflowChatPage.vue @@ -44,18 +44,13 @@ import { copyTextWithFeedback } from '#/utils/clipboard-feedback'; import { buildAbsoluteAppRouteUrl } from '#/utils/share-route-context'; import { resolveWorkflowShareFailureReason } from '#/utils/workflow-share-context'; -import { - appendWorkflowStreamDelta, - appendWorkflowThinkingDelta, - createWorkflowStreamMessage, - updateWorkflowStreamStatus, -} from './workflowChatStreamMessage'; import { finalizeWorkflowExecutionSteps, formatExecutionValue, hydrateWorkflowExecutionSteps, reduceWorkflowExecutionSteps, } from './workflowExecutionDetails'; +import WorkflowFinalOutput from './WorkflowFinalOutput.vue'; import WorkflowFormItem from './WorkflowFormItem.vue'; import { resolveWorkflowFormParameters } from './workflowFormParameters'; import { @@ -63,6 +58,11 @@ import { buildWorkflowFormSubmissionText, hasRequiredWorkflowFormParameters, } from './workflowFormPresentation'; +import { + formatWorkflowElapsed, + formatWorkflowProgressLabel, + summarizeWorkflowActiveNodes, +} from './workflowRunProgress'; interface WorkflowStreamEnvelope { data?: Record; @@ -72,17 +72,6 @@ interface WorkflowStreamEnvelope { type: string; } -interface PendingDelta { - chunks: PendingDeltaChunk[]; - nodeName: string; - streamId: string; -} - -interface PendingDeltaChunk { - content: string; - type: 'text' | 'thinking'; -} - const props = withDefaults( defineProps<{ shareMode?: boolean; @@ -103,6 +92,7 @@ const question = ref(''); const running = ref(false); const stopping = ref(false); const executeId = ref(''); +const runStatusKey = ref(''); const extraReady = ref(false); const extraSubmitted = ref(false); const extraSubmitting = ref(false); @@ -111,6 +101,8 @@ const extraFormRef = ref(); const waitingConfirmation = ref>(); const confirmValues = ref>({}); const confirmFormRef = ref(); +const confirmSubmittingAction = ref<'' | 'confirm' | 'reject'>(''); +const confirmError = ref(''); const detailVisible = ref(false); const detailLoading = ref(false); const detailLoadError = ref(''); @@ -125,9 +117,8 @@ const executionStartedAt = ref(); const executionElapsed = ref(); const sharing = ref(false); const manualAbort = ref(false); -const streamMessages = new Map(); -const pendingDeltas = new Map(); -let deltaFlushFrame = 0; +const lastRunningNodeName = ref(''); +let progressStatusTimer = 0; let userMessageSequence = 0; const formParameters = computed(() => @@ -215,9 +206,7 @@ onMounted(loadPage); onBeforeUnmount(() => { manualAbort.value = true; streamClient.abort(); - if (deltaFlushFrame) { - cancelAnimationFrame(deltaFlushFrame); - } + clearProgressStatusTimer(); }); async function loadPage() { @@ -362,41 +351,6 @@ function appendUserMessage( }); } -function appendAssistantMessage( - id: string, - content: string, - status: ChatTimelineMessageItem['status'] = 'done', -) { - const item: ChatTimelineMessageItem = { - id, - role: 'assistant', - status, - parts: [ - { - content, - id: `${id}-text`, - type: 'text', - }, - ], - type: 'message', - }; - timelineItems.value.push(item); - return item; -} - -function replaceStreamMessage( - streamId: string, - message: ChatTimelineMessageItem, -) { - const current = streamMessages.get(streamId); - streamMessages.set(streamId, message); - const messageId = current?.id || message.id; - const index = timelineItems.value.findIndex((item) => item.id === messageId); - if (index !== -1) { - timelineItems.value[index] = message; - } -} - function appendError(message: string, id = `error-${Date.now()}`) { if ( timelineItems.value.some((item) => item.type === 'error' && item.id === id) @@ -410,6 +364,69 @@ function appendError(message: string, id = `error-${Date.now()}`) { }); } +function appendFinalOutput(output: unknown, eventId: string) { + timelineItems.value.push({ + customType: 'workflow-final-output', + data: output, + id: `final-output-${eventId}`, + type: 'custom', + }); +} + +function clearProgressStatusTimer() { + if (progressStatusTimer) { + window.clearTimeout(progressStatusTimer); + progressStatusTimer = 0; + } +} + +function activeNodeSummary() { + return summarizeWorkflowActiveNodes( + liveExecutionSteps.value, + lastRunningNodeName.value, + ); +} + +function progressLabel(prefix: string) { + return formatWorkflowProgressLabel( + prefix, + liveExecutionSteps.value, + lastRunningNodeName.value, + ); +} + +function scheduleRunningStatus() { + if (!running.value || executionState.value !== 'running') { + return; + } + clearProgressStatusTimer(); + progressStatusTimer = window.setTimeout(() => { + progressStatusTimer = 0; + appendStatus( + progressLabel(stopping.value ? '正在中止' : '正在运行'), + 'running', + runStatusKey.value, + ); + }, 140); +} + +function markConfirmationStep(status: 'running' | 'waiting') { + for ( + let index = liveExecutionSteps.value.length - 1; + index >= 0; + index -= 1 + ) { + const step = liveExecutionSteps.value[index]; + if (!step || (step.status !== 'running' && step.status !== 'waiting')) { + continue; + } + const next = [...liveExecutionSteps.value]; + next[index] = { ...step, status }; + liveExecutionSteps.value = next; + return; + } +} + async function handleSend() { const content = question.value.trim(); if (!content || composerDisabled.value || !workflowId.value) { @@ -425,12 +442,16 @@ async function handleSend() { executionStartedAt.value = Date.now(); executionElapsed.value = undefined; executeId.value = ''; + runStatusKey.value = `run-${Date.now()}-${userMessageSequence}`; executionDetail.value = undefined; detailLoadError.value = ''; liveExecutionSteps.value = []; expandedExecutionStepKeys.value = []; detailExpansionTouched.value = false; waitingConfirmation.value = undefined; + confirmSubmittingAction.value = ''; + confirmError.value = ''; + lastRunningNodeName.value = ''; manualAbort.value = false; void streamClient.post( @@ -447,22 +468,21 @@ async function handleSend() { if (manualAbort.value) { return; } - flushPendingDeltas(); - running.value = false; - executionState.value = 'failed'; - finalizeLiveExecutionSteps('failed'); - appendError( + finishExecution( + 'failed', error?.message || '工作流执行失败', + undefined, `stream-error-${Date.now()}`, ); }, onFinished: () => { - flushPendingDeltas(); if (running.value && !manualAbort.value) { - running.value = false; - executionState.value = 'failed'; - finalizeLiveExecutionSteps('failed'); - appendError('运行连接已结束,请重试'); + finishExecution( + 'failed', + '运行连接已结束,请重试', + undefined, + `stream-finished-${Date.now()}`, + ); } }, onMessage: (message) => { @@ -487,16 +507,11 @@ function parseStreamEvent(raw: string): null | WorkflowStreamEnvelope { } function handleStreamEvent(event: WorkflowStreamEnvelope) { - if (event.type === 'llm_delta' || event.type === 'llm_thinking_delta') { - enqueueDelta(event); - return; - } - flushPendingDeltas(); updateLiveExecutionSteps(event); const data = event.data || {}; switch (event.type) { case 'execution_cancelled': { - finishExecution('已中止', false); + finishExecution('cancelled'); break; } case 'execution_error': { @@ -507,136 +522,87 @@ function handleStreamEvent(event: WorkflowStreamEnvelope) { break; } case 'execution_failed': { - finishExecution('运行失败', true, data.message); + finishExecution('failed', data.message); break; } case 'execution_finished': { - finishExecution('运行完成', false); + finishExecution('completed', undefined, data.output, event.eventId); break; } case 'execution_started': { executeId.value = String(event.executeId || data.executeId || ''); executionState.value = 'running'; - appendStatus('正在运行', 'running', `run-${executeId.value}`); + appendStatus('准备运行…', 'running', runStatusKey.value); break; } case 'execution_status': { if (data.status === 'RUNNING') { waitingConfirmation.value = undefined; + confirmError.value = ''; executionState.value = 'running'; - appendStatus('正在运行', 'running', `run-${executeId.value}`); + markConfirmationStep('running'); + scheduleRunningStatus(); } break; } case 'execution_waiting': { + clearProgressStatusTimer(); waitingConfirmation.value = data; executionState.value = 'waiting'; + confirmSubmittingAction.value = ''; + confirmError.value = ''; initializeConfirmValues(data.parameters); - appendStatus( - data.message || '等待确认', - 'running', - `run-${executeId.value}`, - ); + markConfirmationStep('waiting'); + appendStatus(progressLabel('等待确认'), 'running', runStatusKey.value); break; } case 'node_finished': { - const streamId = String(data.streamId || ''); - const streamMessage = streamMessages.get(streamId); - if (streamMessage) { - replaceStreamMessage( - streamId, - updateWorkflowStreamStatus(streamMessage, 'done'), - ); - timelineItems.value = [...timelineItems.value]; - } + scheduleRunningStatus(); break; } - case 'output': { - appendAssistantMessage( - `output-${event.eventId}`, - formatNodeOutput(data.nodeName, data.output), + case 'node_started': { + lastRunningNodeName.value = String( + data.nodeName || lastRunningNodeName.value, ); + scheduleRunningStatus(); break; } } } -function enqueueDelta(event: WorkflowStreamEnvelope) { - const data = event.data || {}; - const streamId = String(data.streamId || ''); - const delta = String(data.delta || ''); - if (!streamId || !delta) { - return; - } - const pending = pendingDeltas.get(streamId) || { - chunks: [], - nodeName: String(data.nodeName || '大模型'), - streamId, - }; - pending.chunks.push({ - content: delta, - type: event.type === 'llm_thinking_delta' ? 'thinking' : 'text', - }); - pendingDeltas.set(streamId, pending); - if (!deltaFlushFrame) { - deltaFlushFrame = requestAnimationFrame(flushPendingDeltas); - } -} - -function flushPendingDeltas() { - if (deltaFlushFrame) { - cancelAnimationFrame(deltaFlushFrame); - deltaFlushFrame = 0; - } - if (pendingDeltas.size === 0) { - return; - } - for (const pending of pendingDeltas.values()) { - let message = - streamMessages.get(pending.streamId) || - createWorkflowStreamMessage(`llm-${pending.streamId}`, pending.nodeName); - if (!streamMessages.has(pending.streamId)) { - timelineItems.value.push(message); - streamMessages.set(pending.streamId, message); - } - for (const chunk of pending.chunks) { - message = - chunk.type === 'thinking' - ? appendWorkflowThinkingDelta(message, chunk.content) - : appendWorkflowStreamDelta(message, chunk.content); - } - replaceStreamMessage(pending.streamId, message); - } - pendingDeltas.clear(); - timelineItems.value = [...timelineItems.value]; -} - -function finishExecution(label: string, failed: boolean, message?: string) { +function finishExecution( + terminalStatus: 'cancelled' | 'completed' | 'failed', + message?: string, + output?: unknown, + eventId = executeId.value || String(Date.now()), +) { + const failedNodeName = activeNodeSummary(); + clearProgressStatusTimer(); running.value = false; stopping.value = false; waitingConfirmation.value = undefined; - let terminalStatus: 'cancelled' | 'completed' | 'failed' = 'completed'; - if (label === '已中止') { - terminalStatus = 'cancelled'; - } else if (failed) { - terminalStatus = 'failed'; - } + confirmSubmittingAction.value = ''; + confirmError.value = ''; executionState.value = terminalStatus; finalizeLiveExecutionSteps(terminalStatus); - for (const [streamId, item] of streamMessages.entries()) { - if (item.status === 'streaming') { - replaceStreamMessage( - streamId, - updateWorkflowStreamStatus(item, failed ? 'error' : 'done'), - ); - } + const elapsedText = formatWorkflowElapsed(executionElapsed.value); + let label = elapsedText ? `运行完成 · ${elapsedText}` : '运行完成'; + if (terminalStatus === 'cancelled') { + label = '已中止'; + } else if (terminalStatus === 'failed') { + label = failedNodeName ? `运行失败 · ${failedNodeName}` : '运行失败'; } - appendStatus(label, 'done', `run-${executeId.value}`); - if (failed && message) { + appendStatus(label, 'done', runStatusKey.value || `run-${eventId}`); + if (terminalStatus === 'completed') { + appendFinalOutput(output, eventId); + } + if (terminalStatus === 'failed' && message) { appendError(message, `terminal-error-${executeId.value}`); } timelineItems.value = [...timelineItems.value]; - window.setTimeout(() => void loadExecutionDetail(), 320); + if (executeId.value) { + window.setTimeout(() => void loadExecutionDetail(), 320); + } } function updateLiveExecutionSteps(event: WorkflowStreamEnvelope) { @@ -645,6 +611,11 @@ function updateLiveExecutionSteps(event: WorkflowStreamEnvelope) { return; } liveExecutionSteps.value = next; + if (event.type === 'node_started') { + const latestStep = next[next.length - 1]; + lastRunningNodeName.value = + latestStep?.nodeName || lastRunningNodeName.value; + } if ( event.type === 'node_started' && !detailExpansionTouched.value && @@ -669,24 +640,6 @@ function finalizeLiveExecutionSteps( : Math.max(0, finishedAt - executionStartedAt.value); } -function formatNodeOutput(nodeName: unknown, output: unknown) { - const title = String(nodeName || '输出'); - return `**${title}**\n\n${formatOutput(output)}`; -} - -function formatOutput(output: unknown) { - if (typeof output === 'string') { - return output; - } - if (output && typeof output === 'object' && !Array.isArray(output)) { - const entries = Object.entries(output as Record); - if (entries.length === 1 && typeof entries[0]?.[1] === 'string') { - return String(entries[0][1]); - } - } - return `\`\`\`json\n${JSON.stringify(output ?? {}, null, 2)}\n\`\`\``; -} - function initializeConfirmValues(parameters: unknown) { const values: Record = {}; for (const parameter of Array.isArray(parameters) ? parameters : []) { @@ -699,25 +652,34 @@ function initializeConfirmValues(parameters: unknown) { } async function resumeExecution(confirmed: boolean) { - if (!executeId.value || !confirmKey.value) { + if (!executeId.value || !confirmKey.value || confirmSubmittingAction.value) { return; } - if ( - confirmed && - !(await confirmFormRef.value?.validate().catch(() => false)) - ) { - return; + confirmSubmittingAction.value = confirmed ? 'confirm' : 'reject'; + confirmError.value = ''; + try { + if ( + confirmed && + !(await confirmFormRef.value?.validate().catch(() => false)) + ) { + return; + } + await api.post('/api/v1/workflowChat/resume', { + executeId: executeId.value, + confirmParams: { + [confirmKey.value]: confirmed ? 'yes' : 'no', + ...(confirmed ? confirmValues.value : {}), + }, + }); + waitingConfirmation.value = undefined; + executionState.value = 'running'; + markConfirmationStep('running'); + appendStatus(progressLabel('正在运行'), 'running', runStatusKey.value); + } catch (error: any) { + confirmError.value = error?.message || '提交失败,请重试'; + } finally { + confirmSubmittingAction.value = ''; } - await api.post('/api/v1/workflowChat/resume', { - executeId: executeId.value, - confirmParams: { - [confirmKey.value]: confirmed ? 'yes' : 'no', - ...(confirmed ? confirmValues.value : {}), - }, - }); - waitingConfirmation.value = undefined; - executionState.value = 'running'; - appendStatus('正在运行', 'running', `run-${executeId.value}`); } async function stopExecution() { @@ -725,28 +687,33 @@ async function stopExecution() { return; } stopping.value = true; + clearProgressStatusTimer(); + appendStatus(progressLabel('正在中止'), 'running', runStatusKey.value); try { if (executeId.value) { await api.post('/api/v1/workflowChat/cancel', { executeId: executeId.value, }); } - } finally { manualAbort.value = true; streamClient.abort(); - flushPendingDeltas(); - running.value = false; + finishExecution('cancelled'); + } catch (error: any) { stopping.value = false; - waitingConfirmation.value = undefined; - executionState.value = 'cancelled'; - finalizeLiveExecutionSteps('cancelled'); - appendStatus('已中止', 'done', `run-${executeId.value || Date.now()}`); + appendStatus(progressLabel('正在运行'), 'running', runStatusKey.value); + appendError( + error?.message || '中止失败,请重试', + `cancel-error-${executeId.value || Date.now()}`, + ); } } async function resetConversation() { if (running.value) { await stopExecution(); + if (running.value) { + return; + } } manualAbort.value = true; streamClient.abort(); @@ -754,9 +721,9 @@ async function resetConversation() { liveExecutionSteps.value = []; expandedExecutionStepKeys.value = []; detailExpansionTouched.value = false; - streamMessages.clear(); - pendingDeltas.clear(); + clearProgressStatusTimer(); executeId.value = ''; + runStatusKey.value = ''; question.value = ''; executionDetail.value = undefined; executionState.value = 'idle'; @@ -764,6 +731,9 @@ async function resetConversation() { executionElapsed.value = undefined; detailLoadError.value = ''; waitingConfirmation.value = undefined; + confirmSubmittingAction.value = ''; + confirmError.value = ''; + lastRunningNodeName.value = ''; initializeAdditionalValues(); } @@ -965,7 +935,14 @@ function executionTraceText( :empty-text="emptyText" :copy-action="copyMessage" :copyable="(item) => item.parts.some((part) => part.content)" - /> + > + +
+
- 取消 - + + 拒绝 + + 确认
@@ -1338,6 +1333,16 @@ function executionTraceText( line-height: 20px; } +.workflow-chat__confirm-error { + padding: var(--space-2) var(--space-3); + margin: var(--space-3) 0 0; + font-size: 12px; + line-height: 20px; + color: var(--el-color-danger); + background: var(--el-color-danger-light-9); + border-radius: var(--radius-control); +} + .workflow-chat__form-actions { display: flex; gap: 8px; diff --git a/easyflow-ui-admin/app/src/views/ai/workflow/components/WorkflowFinalOutput.component.test.ts b/easyflow-ui-admin/app/src/views/ai/workflow/components/WorkflowFinalOutput.component.test.ts new file mode 100644 index 00000000..d6c98d5c --- /dev/null +++ b/easyflow-ui-admin/app/src/views/ai/workflow/components/WorkflowFinalOutput.component.test.ts @@ -0,0 +1,47 @@ +import { mount } from '@vue/test-utils'; + +import { describe, expect, it, vi } from 'vitest'; + +import WorkflowFinalOutput from './WorkflowFinalOutput.vue'; + +vi.mock('#/utils/clipboard-feedback', () => ({ + copyTextWithFeedback: vi.fn(), +})); + +describe('workflow final output', () => { + it('uses one copy action and exposes array item hierarchy for a single field', () => { + const wrapper = mount(WorkflowFinalOutput, { + props: { + output: { + output: ['第一项', '第二项'], + }, + }, + }); + + expect(wrapper.text()).toContain('复制结果'); + expect(wrapper.text()).not.toContain('复制全部'); + expect(wrapper.findAll('button')).toHaveLength(1); + expect(wrapper.get('.workflow-final-output__section-meta').text()).toBe( + '数组 · 2 项', + ); + expect( + wrapper + .findAll('.workflow-final-output__list-index') + .map((item) => item.text()), + ).toEqual(['01', '02']); + }); + + it('keeps scoped copy actions when the result contains multiple fields', () => { + const wrapper = mount(WorkflowFinalOutput, { + props: { + output: { + answer: '完成', + items: ['A', 'B'], + }, + }, + }); + + expect(wrapper.text()).toContain('复制全部'); + expect(wrapper.findAll('button')).toHaveLength(3); + }); +}); diff --git a/easyflow-ui-admin/app/src/views/ai/workflow/components/WorkflowFinalOutput.vue b/easyflow-ui-admin/app/src/views/ai/workflow/components/WorkflowFinalOutput.vue new file mode 100644 index 00000000..2522d96a --- /dev/null +++ b/easyflow-ui-admin/app/src/views/ai/workflow/components/WorkflowFinalOutput.vue @@ -0,0 +1,539 @@ + + + + + diff --git a/easyflow-ui-admin/app/src/views/ai/workflow/components/workflowFinalOutput.test.ts b/easyflow-ui-admin/app/src/views/ai/workflow/components/workflowFinalOutput.test.ts new file mode 100644 index 00000000..01c368d7 --- /dev/null +++ b/easyflow-ui-admin/app/src/views/ai/workflow/components/workflowFinalOutput.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildWorkflowFinalOutputView, + formatWorkflowOutputPreview, +} from './workflowFinalOutput'; + +describe('workflowFinalOutput', () => { + it('keeps output parameter names and classifies scalar fields', () => { + const view = buildWorkflowFinalOutputView({ + answer: '你好', + count: 2, + enabled: true, + nullable: null, + }); + + expect(view.sections.map((section) => section.label)).toEqual([ + 'answer', + 'count', + 'enabled', + 'nullable', + ]); + expect(view.sections.map((section) => section.kind)).toEqual([ + 'scalar', + 'scalar', + 'scalar', + 'scalar', + ]); + expect(view.sections[3]?.scalarText).toBe('空值'); + }); + + it('classifies primitive arrays and homogeneous object arrays', () => { + const view = buildWorkflowFinalOutputView({ + labels: ['A', 'B'], + rows: [ + { name: 'A', score: 90 }, + { name: 'B', score: 85 }, + ], + }); + + expect(view.sections[0]).toMatchObject({ + kind: 'list', + listItems: ['A', 'B'], + }); + expect(view.sections[1]).toMatchObject({ + kind: 'table', + tableColumns: ['name', 'score'], + }); + }); + + it('uses key-value and bounded JSON views for objects', () => { + const view = buildWorkflowFinalOutputView({ + metadata: { id: 1, ok: true }, + nested: { page: { items: [{ id: 1 }] } }, + }); + + expect(view.sections[0]?.kind).toBe('key-value'); + expect(view.sections[1]?.kind).toBe('json'); + expect( + formatWorkflowOutputPreview( + Array.from({ length: 20 }, (_, index) => index), + false, + ), + ).toContain('另有 8 项'); + }); + + it('separates image and document outputs from text bubbles', () => { + const view = buildWorkflowFinalOutputView({ + image: { + contentType: 'image/png', + fileName: 'result.png', + filePath: '/files/result.png', + }, + report: { + contentType: 'application/pdf', + fileName: 'report.pdf', + filePath: '/files/report.pdf', + }, + }); + + expect(view.sections[0]).toMatchObject({ + kind: 'images', + images: [{ name: 'result.png', previewUrl: '/files/result.png' }], + }); + expect(view.sections[1]).toMatchObject({ + documents: [{ downloadUrl: '/files/report.pdf', name: 'report.pdf' }], + kind: 'documents', + }); + }); + + it('represents an empty result explicitly', () => { + expect(buildWorkflowFinalOutputView({})).toEqual({ + empty: true, + sections: [], + }); + expect(buildWorkflowFinalOutputView(undefined)).toEqual({ + empty: true, + sections: [], + }); + }); +}); diff --git a/easyflow-ui-admin/app/src/views/ai/workflow/components/workflowFinalOutput.ts b/easyflow-ui-admin/app/src/views/ai/workflow/components/workflowFinalOutput.ts new file mode 100644 index 00000000..325fed2e --- /dev/null +++ b/easyflow-ui-admin/app/src/views/ai/workflow/components/workflowFinalOutput.ts @@ -0,0 +1,354 @@ +import type { + ChatDocumentAttachment, + ChatImageAttachment, +} from '@easyflow/common-ui'; + +export type WorkflowFinalOutputKind = + | 'documents' + | 'images' + | 'json' + | 'key-value' + | 'list' + | 'media' + | 'scalar' + | 'table'; + +export interface WorkflowFinalOutputSection { + documents: ChatDocumentAttachment[]; + images: ChatImageAttachment[]; + key: string; + kind: WorkflowFinalOutputKind; + label: string; + listItems: string[]; + rawValue: unknown; + scalarText: string; + tableColumns: string[]; + tableRows: Record[]; +} + +export interface WorkflowFinalOutputView { + empty: boolean; + sections: WorkflowFinalOutputSection[]; +} + +const IMAGE_EXTENSIONS = new Set([ + 'bmp', + 'gif', + 'jpeg', + 'jpg', + 'png', + 'svg', + 'webp', +]); +const TABLE_COLUMN_LIMIT = 8; + +/** + * 将工作流顶级最终输出整理为稳定的展示分区。 + */ +export function buildWorkflowFinalOutputView( + output: unknown, +): WorkflowFinalOutputView { + if (isPlainObject(output) && Object.keys(output).length === 0) { + return { empty: true, sections: [] }; + } + if (output === undefined) { + return { empty: true, sections: [] }; + } + + const entries = + isPlainObject(output) && !isMediaDescriptor(output) + ? Object.entries(output) + : [['output', output] as const]; + return { + empty: entries.length === 0, + sections: entries.map(([key, value]) => buildSection(key, value)), + }; +} + +/** + * 创建有界 JSON 预览,避免超大结果一次性挂载到 DOM。 + */ +export function formatWorkflowOutputPreview( + value: unknown, + expanded = false, +): string { + const preview = boundedPreview(value, { + arrayLimit: expanded ? 100 : 12, + depthLimit: expanded ? 6 : 3, + keyLimit: expanded ? 100 : 16, + stringLimit: expanded ? 20_000 : 2000, + }); + try { + return JSON.stringify(preview, null, 2); + } catch { + return String(value ?? ''); + } +} + +/** + * 将完整输出序列化为复制文本。 + */ +export function serializeWorkflowOutput(value: unknown): string { + if (typeof value === 'string') { + return value; + } + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value ?? ''); + } +} + +function buildSection(key: string, value: unknown): WorkflowFinalOutputSection { + const media = resolveMedia(value); + const base: WorkflowFinalOutputSection = { + documents: media.documents, + images: media.images, + key, + kind: 'json', + label: key, + listItems: [], + rawValue: value, + scalarText: '', + tableColumns: [], + tableRows: [], + }; + if (media.images.length > 0 && media.documents.length === 0) { + return { ...base, kind: 'images' }; + } + if (media.documents.length > 0 && media.images.length === 0) { + return { ...base, kind: 'documents' }; + } + if (media.images.length > 0 && media.documents.length > 0) { + return { ...base, kind: 'media' }; + } + if (isScalar(value)) { + return { + ...base, + kind: 'scalar', + scalarText: formatScalar(value), + }; + } + if (Array.isArray(value)) { + if (value.every((item) => isScalar(item))) { + return { + ...base, + kind: 'list', + listItems: value.map((item) => formatScalar(item)), + }; + } + const columns = homogeneousTableColumns(value); + if (columns.length > 0) { + return { + ...base, + kind: 'table', + tableColumns: columns, + tableRows: value as Record[], + }; + } + return base; + } + if ( + isPlainObject(value) && + Object.values(value).every((item) => isScalar(item)) + ) { + return { ...base, kind: 'key-value' }; + } + return base; +} + +function homogeneousTableColumns(value: unknown[]): string[] { + if (value.length === 0 || !value.every((item) => isPlainObject(item))) { + return []; + } + const firstKeys = Object.keys(value[0] as Record); + if ( + firstKeys.length === 0 || + firstKeys.length > TABLE_COLUMN_LIMIT || + !value.every((row) => { + const keys = Object.keys(row as Record); + return ( + keys.length === firstKeys.length && + keys.every((key, index) => key === firstKeys[index]) && + Object.values(row as Record).every((item) => + isScalar(item), + ) + ); + }) + ) { + return []; + } + return firstKeys; +} + +function resolveMedia(value: unknown) { + const candidates = Array.isArray(value) ? value : [value]; + if ( + candidates.length === 0 || + !candidates.every((candidate) => isMediaDescriptor(candidate)) + ) { + return { + documents: [] as ChatDocumentAttachment[], + images: [] as ChatImageAttachment[], + }; + } + const documents: ChatDocumentAttachment[] = []; + const images: ChatImageAttachment[] = []; + for (const candidate of candidates) { + if (isImageDescriptor(candidate)) { + const source = mediaSource(candidate); + images.push({ + mimeType: stringValue(candidate.contentType || candidate.mimeType), + name: mediaName(candidate, source, '图片'), + previewUrl: source, + size: numberValue(candidate.size), + status: 'ready', + }); + continue; + } + const source = mediaSource(candidate); + documents.push({ + attachmentRef: stringValue(candidate.attachmentRef) || undefined, + downloadUrl: source, + mimeType: stringValue(candidate.contentType || candidate.mimeType), + name: mediaName(candidate, source, '文件'), + size: numberValue(candidate.size), + status: 'ready', + }); + } + return { documents, images }; +} + +function isMediaDescriptor(value: unknown): value is Record { + return isImageDescriptor(value) || isDocumentDescriptor(value); +} + +function isImageDescriptor(value: unknown): boolean { + if (!isPlainObject(value)) { + return false; + } + const source = mediaSource(value); + if (!source) { + return false; + } + const mimeType = stringValue( + value.contentType || value.mimeType, + ).toLowerCase(); + if (mimeType.startsWith('image/')) { + return true; + } + const name = mediaName(value, source, ''); + return IMAGE_EXTENSIONS.has(fileExtension(name || source)); +} + +function isDocumentDescriptor(value: unknown): boolean { + if (!isPlainObject(value) || isImageDescriptor(value)) { + return false; + } + const source = mediaSource(value); + const name = stringValue(value.fileName || value.name); + return Boolean(source && (name || value.attachmentRef)); +} + +function mediaSource(value: Record): string { + return stringValue( + value.downloadUrl || value.url || value.filePath || value.previewUrl, + ); +} + +function mediaName( + value: Record, + source: string, + fallback: string, +): string { + const explicit = stringValue(value.fileName || value.name); + if (explicit) { + return explicit; + } + const fromUrl = source.split('/').pop()?.split('?')[0] || ''; + return fromUrl || fallback; +} + +function fileExtension(value: string): string { + return value.toLowerCase().split('.').pop()?.split('?')[0] || ''; +} + +function formatScalar(value: unknown): string { + if (value === null) { + return '空值'; + } + return String(value); +} + +function isScalar(value: unknown): boolean { + return ( + value === null || + typeof value === 'boolean' || + typeof value === 'number' || + typeof value === 'string' + ); +} + +function isPlainObject(value: unknown): value is Record { + return Boolean(value && typeof value === 'object' && !Array.isArray(value)); +} + +function stringValue(value: unknown): string { + return typeof value === 'string' ? value.trim() : ''; +} + +function numberValue(value: unknown): number | undefined { + const parsed = Number(value); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined; +} + +function boundedPreview( + value: unknown, + limits: { + arrayLimit: number; + depthLimit: number; + keyLimit: number; + stringLimit: number; + }, + depth = 0, + seen = new WeakSet(), +): unknown { + if (typeof value === 'string') { + return value.length <= limits.stringLimit + ? value + : `${value.slice(0, limits.stringLimit)}…`; + } + if (isScalar(value) || value === undefined) { + return value; + } + if (depth >= limits.depthLimit) { + return Array.isArray(value) ? '[数组已折叠]' : '{对象已折叠}'; + } + if (value && typeof value === 'object') { + if (seen.has(value)) { + return '[循环引用]'; + } + seen.add(value); + } + if (Array.isArray(value)) { + const items = value + .slice(0, limits.arrayLimit) + .map((item) => boundedPreview(item, limits, depth + 1, seen)); + if (value.length > limits.arrayLimit) { + items.push(`… 另有 ${value.length - limits.arrayLimit} 项`); + } + return items; + } + if (isPlainObject(value)) { + const keys = Object.keys(value); + const preview: Record = {}; + for (const key of keys.slice(0, limits.keyLimit)) { + preview[key] = boundedPreview(value[key], limits, depth + 1, seen); + } + if (keys.length > limits.keyLimit) { + preview['…'] = `另有 ${keys.length - limits.keyLimit} 个字段`; + } + return preview; + } + return String(value); +} diff --git a/easyflow-ui-admin/app/src/views/ai/workflow/components/workflowRunProgress.test.ts b/easyflow-ui-admin/app/src/views/ai/workflow/components/workflowRunProgress.test.ts new file mode 100644 index 00000000..4e857ccc --- /dev/null +++ b/easyflow-ui-admin/app/src/views/ai/workflow/components/workflowRunProgress.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; + +import { + formatWorkflowElapsed, + formatWorkflowProgressLabel, + summarizeWorkflowActiveNodes, +} from './workflowRunProgress'; + +describe('workflowRunProgress', () => { + it('shows the current active node', () => { + const steps = [ + { + hasInput: false, + hasOutput: false, + key: 'start', + nodeId: 'start', + nodeName: '开始节点', + status: 'completed' as const, + traces: [], + }, + { + hasInput: true, + hasOutput: false, + key: 'llm', + nodeId: 'llm', + nodeName: '内容生成', + status: 'running' as const, + traces: [], + }, + ]; + + expect(summarizeWorkflowActiveNodes(steps)).toBe('内容生成'); + expect(formatWorkflowProgressLabel('正在运行', steps)).toBe( + '正在运行 · 内容生成', + ); + }); + + it('shows the latest node and active count for parallel execution', () => { + const steps = ['检索资料', '生成摘要'].map((nodeName, index) => ({ + hasInput: true, + hasOutput: false, + key: String(index), + nodeId: String(index), + nodeName, + status: 'running' as const, + traces: [], + })); + + expect(summarizeWorkflowActiveNodes(steps)).toBe('生成摘要 等 2 个节点'); + }); + + it('keeps the latest node during short lifecycle gaps', () => { + expect(summarizeWorkflowActiveNodes([], '条件判断')).toBe('条件判断'); + }); + + it('formats short and long durations compactly', () => { + expect(formatWorkflowElapsed(320)).toBe('320 ms'); + expect(formatWorkflowElapsed(1280)).toBe('1.3 s'); + expect(formatWorkflowElapsed(12_800)).toBe('13 s'); + }); +}); diff --git a/easyflow-ui-admin/app/src/views/ai/workflow/components/workflowRunProgress.ts b/easyflow-ui-admin/app/src/views/ai/workflow/components/workflowRunProgress.ts new file mode 100644 index 00000000..ea07d2ca --- /dev/null +++ b/easyflow-ui-admin/app/src/views/ai/workflow/components/workflowRunProgress.ts @@ -0,0 +1,52 @@ +import type { WorkflowExecutionStepView } from './workflowExecutionDetails'; + +/** + * 汇总当前活动节点名称。 + */ +export function summarizeWorkflowActiveNodes( + steps: WorkflowExecutionStepView[], + fallbackName = '', +): string { + const names: string[] = []; + for (const step of steps) { + if (step.status !== 'running' && step.status !== 'waiting') { + continue; + } + if (step.nodeName && !names.includes(step.nodeName)) { + names.push(step.nodeName); + } + } + if (names.length === 0) { + return fallbackName; + } + const latestName = names[names.length - 1] || fallbackName; + return names.length > 1 + ? `${latestName} 等 ${names.length} 个节点` + : latestName; +} + +/** + * 生成包含当前节点的运行状态文案。 + */ +export function formatWorkflowProgressLabel( + prefix: string, + steps: WorkflowExecutionStepView[], + fallbackName = '', +): string { + const nodeSummary = summarizeWorkflowActiveNodes(steps, fallbackName); + return nodeSummary ? `${prefix} · ${nodeSummary}` : prefix; +} + +/** + * 将执行耗时格式化为紧凑文案。 + */ +export function formatWorkflowElapsed(value?: number): string { + if (value === undefined) { + return ''; + } + if (value < 1000) { + return `${value} ms`; + } + const seconds = value / 1000; + return `${seconds < 10 ? seconds.toFixed(1) : Math.round(seconds)} s`; +} diff --git a/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/ChatTimeline.vue b/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/ChatTimeline.vue index 8fbc84cb..df05ec22 100644 --- a/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/ChatTimeline.vue +++ b/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/ChatTimeline.vue @@ -149,27 +149,33 @@ watch( diff --git a/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/__tests__/ChatTimelineCustomItem.test.ts b/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/__tests__/ChatTimelineCustomItem.test.ts new file mode 100644 index 00000000..2d501fe2 --- /dev/null +++ b/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/__tests__/ChatTimelineCustomItem.test.ts @@ -0,0 +1,29 @@ +import { mount } from '@vue/test-utils'; +import { h } from 'vue'; + +import { describe, expect, it } from 'vitest'; + +import ChatTimeline from '../ChatTimeline.vue'; + +describe('chat timeline custom item', () => { + it('renders business content through the custom item slot', () => { + const wrapper = mount(ChatTimeline, { + props: { + items: [ + { + customType: 'workflow-final-output', + data: { answer: '完成' }, + id: 'final-result', + type: 'custom', + }, + ], + }, + slots: { + 'custom-item': ({ item }: any) => + h('div', { class: 'custom-result' }, item.customType), + }, + }); + + expect(wrapper.get('.custom-result').text()).toBe('workflow-final-output'); + }); +}); diff --git a/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/index.ts b/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/index.ts index 8a3cd72a..1a996e34 100644 --- a/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/index.ts +++ b/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/index.ts @@ -16,6 +16,7 @@ export type { ChatDocumentLoader, ChatImageAttachment, ChatImageLoader, + ChatTimelineCustomItem, ChatTimelineErrorItem, ChatTimelineItem, ChatTimelineItemStatus, diff --git a/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/types.ts b/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/types.ts index 3302a5bf..7a7fb0dd 100644 --- a/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/types.ts +++ b/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/types.ts @@ -142,7 +142,14 @@ export interface ChatTimelineErrorItem extends ChatTimelineItemBase { type: 'error'; } +export interface ChatTimelineCustomItem extends ChatTimelineItemBase { + customType: string; + data: unknown; + type: 'custom'; +} + export type ChatTimelineItem = + | ChatTimelineCustomItem | ChatTimelineErrorItem | ChatTimelineKnowledgeItem | ChatTimelineMessageItem