feat: 收口工作流对话最终结果展示
- 主时间线只展示顶级工作流最终输出和有限运行进度 - 补齐结构化结果、人工审核交互与实时详情回归 - 优化数组层级和复制操作
This commit is contained in:
@@ -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.ChainStatusChangeEvent;
|
||||||
import com.easyagents.flow.core.chain.event.EdgeConditionCheckFailedEvent;
|
import com.easyagents.flow.core.chain.event.EdgeConditionCheckFailedEvent;
|
||||||
import com.easyagents.flow.core.chain.event.EdgeTriggerEvent;
|
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.NodeEndEvent;
|
||||||
import com.easyagents.flow.core.chain.event.NodeStartEvent;
|
import com.easyagents.flow.core.chain.event.NodeStartEvent;
|
||||||
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
|
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.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
@@ -59,7 +56,6 @@ public class WorkflowChatEventStream {
|
|||||||
@PostConstruct
|
@PostConstruct
|
||||||
public void registerListeners() {
|
public void registerListeners() {
|
||||||
chainExecutor.addEventListener(this::onEvent);
|
chainExecutor.addEventListener(this::onEvent);
|
||||||
chainExecutor.addOutputListener(this::onExplicitOutput);
|
|
||||||
chainExecutor.addErrorListener(this::onChainError);
|
chainExecutor.addErrorListener(this::onChainError);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,10 +103,6 @@ public class WorkflowChatEventStream {
|
|||||||
if (session == null) {
|
if (session == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (event instanceof LlmStreamEvent streamEvent) {
|
|
||||||
session.onLlmDelta(chain, streamEvent);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (event instanceof NodeStartEvent nodeStartEvent) {
|
if (event instanceof NodeStartEvent nodeStartEvent) {
|
||||||
session.onNodeStarted(chain, nodeStartEvent);
|
session.onNodeStarted(chain, nodeStartEvent);
|
||||||
return;
|
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();
|
return error.getMessage();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 去掉顶级工作流结果中的内部状态控制字段。
|
||||||
|
*
|
||||||
|
* @param result 顶级工作流执行结果
|
||||||
|
* @return 可直接交给会话结果区展示的最终输出
|
||||||
|
*/
|
||||||
|
static Map<String, Object> visibleFinalOutput(
|
||||||
|
Map<String, Object> result
|
||||||
|
) {
|
||||||
|
Map<String, Object> 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 会话。
|
* 单次工作流执行的 SSE 会话。
|
||||||
*/
|
*/
|
||||||
@@ -237,8 +231,6 @@ public class WorkflowChatEventStream {
|
|||||||
private final SseEmitter emitter;
|
private final SseEmitter emitter;
|
||||||
private final AtomicLong sequence = new AtomicLong();
|
private final AtomicLong sequence = new AtomicLong();
|
||||||
private final AtomicBoolean terminal = new AtomicBoolean(false);
|
private final AtomicBoolean terminal = new AtomicBoolean(false);
|
||||||
private final Map<String, String> activeLlmStreams =
|
|
||||||
new ConcurrentHashMap<>();
|
|
||||||
private volatile String executeId;
|
private volatile String executeId;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -259,24 +251,6 @@ public class WorkflowChatEventStream {
|
|||||||
this.executeId = executeId;
|
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 chain 当前工作流
|
||||||
* @param event 节点完成事件
|
* @param event 节点完成事件
|
||||||
*/
|
*/
|
||||||
private void onNodeFinished(Chain chain, NodeEndEvent event) {
|
private void onNodeFinished(Chain chain, NodeEndEvent event) {
|
||||||
Node node = event.getNode();
|
Node node = event.getNode();
|
||||||
String streamId = activeLlmStreams.remove(nodeRunKey(chain, node));
|
|
||||||
Map<String, Object> data = new LinkedHashMap<>();
|
Map<String, Object> data = new LinkedHashMap<>();
|
||||||
data.put("attemptKey", event.getExecutionAttemptKey());
|
data.put("attemptKey", event.getExecutionAttemptKey());
|
||||||
data.put("status", event.getStatus() == null
|
data.put("status", event.getStatus() == null
|
||||||
@@ -316,17 +289,7 @@ public class WorkflowChatEventStream {
|
|||||||
if (event.getError() != null) {
|
if (event.getError() != null) {
|
||||||
data.put("error", safeErrorMessage(event.getError()));
|
data.put("error", safeErrorMessage(event.getError()));
|
||||||
}
|
}
|
||||||
if (streamId != null) {
|
|
||||||
data.put("streamId", streamId);
|
|
||||||
}
|
|
||||||
send("node_finished", nodePayload(node, data));
|
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<String, Object> data = new LinkedHashMap<>();
|
Map<String, Object> data = new LinkedHashMap<>();
|
||||||
data.put("status", status.name());
|
data.put("status", status.name());
|
||||||
data.put("message", chain.getState().getMessage());
|
data.put("message", chain.getState().getMessage());
|
||||||
|
if (status == ChainStatus.SUCCEEDED) {
|
||||||
|
data.put(
|
||||||
|
"output",
|
||||||
|
visibleFinalOutput(chain.getState().getExecuteResult())
|
||||||
|
);
|
||||||
|
}
|
||||||
send(eventType, data);
|
send(eventType, data);
|
||||||
removeSession(this);
|
removeSession(this);
|
||||||
emitter.complete();
|
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 事件。
|
* 发送 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;
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 去掉结束节点内部状态控制字段。
|
|
||||||
*
|
|
||||||
* @param result 节点结果
|
|
||||||
* @return 用户可见输出
|
|
||||||
*/
|
|
||||||
private Map<String, Object> visibleEndOutput(
|
|
||||||
Map<String, Object> result
|
|
||||||
) {
|
|
||||||
Map<String, Object> 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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<String, Object> 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<String, Object> 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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,18 +44,13 @@ import { copyTextWithFeedback } from '#/utils/clipboard-feedback';
|
|||||||
import { buildAbsoluteAppRouteUrl } from '#/utils/share-route-context';
|
import { buildAbsoluteAppRouteUrl } from '#/utils/share-route-context';
|
||||||
import { resolveWorkflowShareFailureReason } from '#/utils/workflow-share-context';
|
import { resolveWorkflowShareFailureReason } from '#/utils/workflow-share-context';
|
||||||
|
|
||||||
import {
|
|
||||||
appendWorkflowStreamDelta,
|
|
||||||
appendWorkflowThinkingDelta,
|
|
||||||
createWorkflowStreamMessage,
|
|
||||||
updateWorkflowStreamStatus,
|
|
||||||
} from './workflowChatStreamMessage';
|
|
||||||
import {
|
import {
|
||||||
finalizeWorkflowExecutionSteps,
|
finalizeWorkflowExecutionSteps,
|
||||||
formatExecutionValue,
|
formatExecutionValue,
|
||||||
hydrateWorkflowExecutionSteps,
|
hydrateWorkflowExecutionSteps,
|
||||||
reduceWorkflowExecutionSteps,
|
reduceWorkflowExecutionSteps,
|
||||||
} from './workflowExecutionDetails';
|
} from './workflowExecutionDetails';
|
||||||
|
import WorkflowFinalOutput from './WorkflowFinalOutput.vue';
|
||||||
import WorkflowFormItem from './WorkflowFormItem.vue';
|
import WorkflowFormItem from './WorkflowFormItem.vue';
|
||||||
import { resolveWorkflowFormParameters } from './workflowFormParameters';
|
import { resolveWorkflowFormParameters } from './workflowFormParameters';
|
||||||
import {
|
import {
|
||||||
@@ -63,6 +58,11 @@ import {
|
|||||||
buildWorkflowFormSubmissionText,
|
buildWorkflowFormSubmissionText,
|
||||||
hasRequiredWorkflowFormParameters,
|
hasRequiredWorkflowFormParameters,
|
||||||
} from './workflowFormPresentation';
|
} from './workflowFormPresentation';
|
||||||
|
import {
|
||||||
|
formatWorkflowElapsed,
|
||||||
|
formatWorkflowProgressLabel,
|
||||||
|
summarizeWorkflowActiveNodes,
|
||||||
|
} from './workflowRunProgress';
|
||||||
|
|
||||||
interface WorkflowStreamEnvelope {
|
interface WorkflowStreamEnvelope {
|
||||||
data?: Record<string, any>;
|
data?: Record<string, any>;
|
||||||
@@ -72,17 +72,6 @@ interface WorkflowStreamEnvelope {
|
|||||||
type: string;
|
type: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PendingDelta {
|
|
||||||
chunks: PendingDeltaChunk[];
|
|
||||||
nodeName: string;
|
|
||||||
streamId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PendingDeltaChunk {
|
|
||||||
content: string;
|
|
||||||
type: 'text' | 'thinking';
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
shareMode?: boolean;
|
shareMode?: boolean;
|
||||||
@@ -103,6 +92,7 @@ const question = ref('');
|
|||||||
const running = ref(false);
|
const running = ref(false);
|
||||||
const stopping = ref(false);
|
const stopping = ref(false);
|
||||||
const executeId = ref('');
|
const executeId = ref('');
|
||||||
|
const runStatusKey = ref('');
|
||||||
const extraReady = ref(false);
|
const extraReady = ref(false);
|
||||||
const extraSubmitted = ref(false);
|
const extraSubmitted = ref(false);
|
||||||
const extraSubmitting = ref(false);
|
const extraSubmitting = ref(false);
|
||||||
@@ -111,6 +101,8 @@ const extraFormRef = ref<FormInstance>();
|
|||||||
const waitingConfirmation = ref<Record<string, any>>();
|
const waitingConfirmation = ref<Record<string, any>>();
|
||||||
const confirmValues = ref<Record<string, any>>({});
|
const confirmValues = ref<Record<string, any>>({});
|
||||||
const confirmFormRef = ref<FormInstance>();
|
const confirmFormRef = ref<FormInstance>();
|
||||||
|
const confirmSubmittingAction = ref<'' | 'confirm' | 'reject'>('');
|
||||||
|
const confirmError = ref('');
|
||||||
const detailVisible = ref(false);
|
const detailVisible = ref(false);
|
||||||
const detailLoading = ref(false);
|
const detailLoading = ref(false);
|
||||||
const detailLoadError = ref('');
|
const detailLoadError = ref('');
|
||||||
@@ -125,9 +117,8 @@ const executionStartedAt = ref<number>();
|
|||||||
const executionElapsed = ref<number>();
|
const executionElapsed = ref<number>();
|
||||||
const sharing = ref(false);
|
const sharing = ref(false);
|
||||||
const manualAbort = ref(false);
|
const manualAbort = ref(false);
|
||||||
const streamMessages = new Map<string, ChatTimelineMessageItem>();
|
const lastRunningNodeName = ref('');
|
||||||
const pendingDeltas = new Map<string, PendingDelta>();
|
let progressStatusTimer = 0;
|
||||||
let deltaFlushFrame = 0;
|
|
||||||
let userMessageSequence = 0;
|
let userMessageSequence = 0;
|
||||||
|
|
||||||
const formParameters = computed(() =>
|
const formParameters = computed(() =>
|
||||||
@@ -215,9 +206,7 @@ onMounted(loadPage);
|
|||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
manualAbort.value = true;
|
manualAbort.value = true;
|
||||||
streamClient.abort();
|
streamClient.abort();
|
||||||
if (deltaFlushFrame) {
|
clearProgressStatusTimer();
|
||||||
cancelAnimationFrame(deltaFlushFrame);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
async function loadPage() {
|
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()}`) {
|
function appendError(message: string, id = `error-${Date.now()}`) {
|
||||||
if (
|
if (
|
||||||
timelineItems.value.some((item) => item.type === 'error' && item.id === id)
|
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() {
|
async function handleSend() {
|
||||||
const content = question.value.trim();
|
const content = question.value.trim();
|
||||||
if (!content || composerDisabled.value || !workflowId.value) {
|
if (!content || composerDisabled.value || !workflowId.value) {
|
||||||
@@ -425,12 +442,16 @@ async function handleSend() {
|
|||||||
executionStartedAt.value = Date.now();
|
executionStartedAt.value = Date.now();
|
||||||
executionElapsed.value = undefined;
|
executionElapsed.value = undefined;
|
||||||
executeId.value = '';
|
executeId.value = '';
|
||||||
|
runStatusKey.value = `run-${Date.now()}-${userMessageSequence}`;
|
||||||
executionDetail.value = undefined;
|
executionDetail.value = undefined;
|
||||||
detailLoadError.value = '';
|
detailLoadError.value = '';
|
||||||
liveExecutionSteps.value = [];
|
liveExecutionSteps.value = [];
|
||||||
expandedExecutionStepKeys.value = [];
|
expandedExecutionStepKeys.value = [];
|
||||||
detailExpansionTouched.value = false;
|
detailExpansionTouched.value = false;
|
||||||
waitingConfirmation.value = undefined;
|
waitingConfirmation.value = undefined;
|
||||||
|
confirmSubmittingAction.value = '';
|
||||||
|
confirmError.value = '';
|
||||||
|
lastRunningNodeName.value = '';
|
||||||
manualAbort.value = false;
|
manualAbort.value = false;
|
||||||
|
|
||||||
void streamClient.post(
|
void streamClient.post(
|
||||||
@@ -447,22 +468,21 @@ async function handleSend() {
|
|||||||
if (manualAbort.value) {
|
if (manualAbort.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
flushPendingDeltas();
|
finishExecution(
|
||||||
running.value = false;
|
'failed',
|
||||||
executionState.value = 'failed';
|
|
||||||
finalizeLiveExecutionSteps('failed');
|
|
||||||
appendError(
|
|
||||||
error?.message || '工作流执行失败',
|
error?.message || '工作流执行失败',
|
||||||
|
undefined,
|
||||||
`stream-error-${Date.now()}`,
|
`stream-error-${Date.now()}`,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
onFinished: () => {
|
onFinished: () => {
|
||||||
flushPendingDeltas();
|
|
||||||
if (running.value && !manualAbort.value) {
|
if (running.value && !manualAbort.value) {
|
||||||
running.value = false;
|
finishExecution(
|
||||||
executionState.value = 'failed';
|
'failed',
|
||||||
finalizeLiveExecutionSteps('failed');
|
'运行连接已结束,请重试',
|
||||||
appendError('运行连接已结束,请重试');
|
undefined,
|
||||||
|
`stream-finished-${Date.now()}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onMessage: (message) => {
|
onMessage: (message) => {
|
||||||
@@ -487,16 +507,11 @@ function parseStreamEvent(raw: string): null | WorkflowStreamEnvelope {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleStreamEvent(event: WorkflowStreamEnvelope) {
|
function handleStreamEvent(event: WorkflowStreamEnvelope) {
|
||||||
if (event.type === 'llm_delta' || event.type === 'llm_thinking_delta') {
|
|
||||||
enqueueDelta(event);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
flushPendingDeltas();
|
|
||||||
updateLiveExecutionSteps(event);
|
updateLiveExecutionSteps(event);
|
||||||
const data = event.data || {};
|
const data = event.data || {};
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case 'execution_cancelled': {
|
case 'execution_cancelled': {
|
||||||
finishExecution('已中止', false);
|
finishExecution('cancelled');
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'execution_error': {
|
case 'execution_error': {
|
||||||
@@ -507,137 +522,88 @@ function handleStreamEvent(event: WorkflowStreamEnvelope) {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'execution_failed': {
|
case 'execution_failed': {
|
||||||
finishExecution('运行失败', true, data.message);
|
finishExecution('failed', data.message);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'execution_finished': {
|
case 'execution_finished': {
|
||||||
finishExecution('运行完成', false);
|
finishExecution('completed', undefined, data.output, event.eventId);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'execution_started': {
|
case 'execution_started': {
|
||||||
executeId.value = String(event.executeId || data.executeId || '');
|
executeId.value = String(event.executeId || data.executeId || '');
|
||||||
executionState.value = 'running';
|
executionState.value = 'running';
|
||||||
appendStatus('正在运行', 'running', `run-${executeId.value}`);
|
appendStatus('准备运行…', 'running', runStatusKey.value);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'execution_status': {
|
case 'execution_status': {
|
||||||
if (data.status === 'RUNNING') {
|
if (data.status === 'RUNNING') {
|
||||||
waitingConfirmation.value = undefined;
|
waitingConfirmation.value = undefined;
|
||||||
|
confirmError.value = '';
|
||||||
executionState.value = 'running';
|
executionState.value = 'running';
|
||||||
appendStatus('正在运行', 'running', `run-${executeId.value}`);
|
markConfirmationStep('running');
|
||||||
|
scheduleRunningStatus();
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'execution_waiting': {
|
case 'execution_waiting': {
|
||||||
|
clearProgressStatusTimer();
|
||||||
waitingConfirmation.value = data;
|
waitingConfirmation.value = data;
|
||||||
executionState.value = 'waiting';
|
executionState.value = 'waiting';
|
||||||
|
confirmSubmittingAction.value = '';
|
||||||
|
confirmError.value = '';
|
||||||
initializeConfirmValues(data.parameters);
|
initializeConfirmValues(data.parameters);
|
||||||
appendStatus(
|
markConfirmationStep('waiting');
|
||||||
data.message || '等待确认',
|
appendStatus(progressLabel('等待确认'), 'running', runStatusKey.value);
|
||||||
'running',
|
|
||||||
`run-${executeId.value}`,
|
|
||||||
);
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'node_finished': {
|
case 'node_finished': {
|
||||||
const streamId = String(data.streamId || '');
|
scheduleRunningStatus();
|
||||||
const streamMessage = streamMessages.get(streamId);
|
|
||||||
if (streamMessage) {
|
|
||||||
replaceStreamMessage(
|
|
||||||
streamId,
|
|
||||||
updateWorkflowStreamStatus(streamMessage, 'done'),
|
|
||||||
);
|
|
||||||
timelineItems.value = [...timelineItems.value];
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'output': {
|
case 'node_started': {
|
||||||
appendAssistantMessage(
|
lastRunningNodeName.value = String(
|
||||||
`output-${event.eventId}`,
|
data.nodeName || lastRunningNodeName.value,
|
||||||
formatNodeOutput(data.nodeName, data.output),
|
|
||||||
);
|
);
|
||||||
|
scheduleRunningStatus();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function enqueueDelta(event: WorkflowStreamEnvelope) {
|
function finishExecution(
|
||||||
const data = event.data || {};
|
terminalStatus: 'cancelled' | 'completed' | 'failed',
|
||||||
const streamId = String(data.streamId || '');
|
message?: string,
|
||||||
const delta = String(data.delta || '');
|
output?: unknown,
|
||||||
if (!streamId || !delta) {
|
eventId = executeId.value || String(Date.now()),
|
||||||
return;
|
) {
|
||||||
}
|
const failedNodeName = activeNodeSummary();
|
||||||
const pending = pendingDeltas.get(streamId) || {
|
clearProgressStatusTimer();
|
||||||
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) {
|
|
||||||
running.value = false;
|
running.value = false;
|
||||||
stopping.value = false;
|
stopping.value = false;
|
||||||
waitingConfirmation.value = undefined;
|
waitingConfirmation.value = undefined;
|
||||||
let terminalStatus: 'cancelled' | 'completed' | 'failed' = 'completed';
|
confirmSubmittingAction.value = '';
|
||||||
if (label === '已中止') {
|
confirmError.value = '';
|
||||||
terminalStatus = 'cancelled';
|
|
||||||
} else if (failed) {
|
|
||||||
terminalStatus = 'failed';
|
|
||||||
}
|
|
||||||
executionState.value = terminalStatus;
|
executionState.value = terminalStatus;
|
||||||
finalizeLiveExecutionSteps(terminalStatus);
|
finalizeLiveExecutionSteps(terminalStatus);
|
||||||
for (const [streamId, item] of streamMessages.entries()) {
|
const elapsedText = formatWorkflowElapsed(executionElapsed.value);
|
||||||
if (item.status === 'streaming') {
|
let label = elapsedText ? `运行完成 · ${elapsedText}` : '运行完成';
|
||||||
replaceStreamMessage(
|
if (terminalStatus === 'cancelled') {
|
||||||
streamId,
|
label = '已中止';
|
||||||
updateWorkflowStreamStatus(item, failed ? 'error' : 'done'),
|
} else if (terminalStatus === 'failed') {
|
||||||
);
|
label = failedNodeName ? `运行失败 · ${failedNodeName}` : '运行失败';
|
||||||
}
|
}
|
||||||
|
appendStatus(label, 'done', runStatusKey.value || `run-${eventId}`);
|
||||||
|
if (terminalStatus === 'completed') {
|
||||||
|
appendFinalOutput(output, eventId);
|
||||||
}
|
}
|
||||||
appendStatus(label, 'done', `run-${executeId.value}`);
|
if (terminalStatus === 'failed' && message) {
|
||||||
if (failed && message) {
|
|
||||||
appendError(message, `terminal-error-${executeId.value}`);
|
appendError(message, `terminal-error-${executeId.value}`);
|
||||||
}
|
}
|
||||||
timelineItems.value = [...timelineItems.value];
|
timelineItems.value = [...timelineItems.value];
|
||||||
|
if (executeId.value) {
|
||||||
window.setTimeout(() => void loadExecutionDetail(), 320);
|
window.setTimeout(() => void loadExecutionDetail(), 320);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function updateLiveExecutionSteps(event: WorkflowStreamEnvelope) {
|
function updateLiveExecutionSteps(event: WorkflowStreamEnvelope) {
|
||||||
const next = reduceWorkflowExecutionSteps(liveExecutionSteps.value, event);
|
const next = reduceWorkflowExecutionSteps(liveExecutionSteps.value, event);
|
||||||
@@ -645,6 +611,11 @@ function updateLiveExecutionSteps(event: WorkflowStreamEnvelope) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
liveExecutionSteps.value = next;
|
liveExecutionSteps.value = next;
|
||||||
|
if (event.type === 'node_started') {
|
||||||
|
const latestStep = next[next.length - 1];
|
||||||
|
lastRunningNodeName.value =
|
||||||
|
latestStep?.nodeName || lastRunningNodeName.value;
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
event.type === 'node_started' &&
|
event.type === 'node_started' &&
|
||||||
!detailExpansionTouched.value &&
|
!detailExpansionTouched.value &&
|
||||||
@@ -669,24 +640,6 @@ function finalizeLiveExecutionSteps(
|
|||||||
: Math.max(0, finishedAt - executionStartedAt.value);
|
: 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<string, unknown>);
|
|
||||||
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) {
|
function initializeConfirmValues(parameters: unknown) {
|
||||||
const values: Record<string, any> = {};
|
const values: Record<string, any> = {};
|
||||||
for (const parameter of Array.isArray(parameters) ? parameters : []) {
|
for (const parameter of Array.isArray(parameters) ? parameters : []) {
|
||||||
@@ -699,9 +652,12 @@ function initializeConfirmValues(parameters: unknown) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function resumeExecution(confirmed: boolean) {
|
async function resumeExecution(confirmed: boolean) {
|
||||||
if (!executeId.value || !confirmKey.value) {
|
if (!executeId.value || !confirmKey.value || confirmSubmittingAction.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
confirmSubmittingAction.value = confirmed ? 'confirm' : 'reject';
|
||||||
|
confirmError.value = '';
|
||||||
|
try {
|
||||||
if (
|
if (
|
||||||
confirmed &&
|
confirmed &&
|
||||||
!(await confirmFormRef.value?.validate().catch(() => false))
|
!(await confirmFormRef.value?.validate().catch(() => false))
|
||||||
@@ -717,7 +673,13 @@ async function resumeExecution(confirmed: boolean) {
|
|||||||
});
|
});
|
||||||
waitingConfirmation.value = undefined;
|
waitingConfirmation.value = undefined;
|
||||||
executionState.value = 'running';
|
executionState.value = 'running';
|
||||||
appendStatus('正在运行', 'running', `run-${executeId.value}`);
|
markConfirmationStep('running');
|
||||||
|
appendStatus(progressLabel('正在运行'), 'running', runStatusKey.value);
|
||||||
|
} catch (error: any) {
|
||||||
|
confirmError.value = error?.message || '提交失败,请重试';
|
||||||
|
} finally {
|
||||||
|
confirmSubmittingAction.value = '';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function stopExecution() {
|
async function stopExecution() {
|
||||||
@@ -725,28 +687,33 @@ async function stopExecution() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
stopping.value = true;
|
stopping.value = true;
|
||||||
|
clearProgressStatusTimer();
|
||||||
|
appendStatus(progressLabel('正在中止'), 'running', runStatusKey.value);
|
||||||
try {
|
try {
|
||||||
if (executeId.value) {
|
if (executeId.value) {
|
||||||
await api.post('/api/v1/workflowChat/cancel', {
|
await api.post('/api/v1/workflowChat/cancel', {
|
||||||
executeId: executeId.value,
|
executeId: executeId.value,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} finally {
|
|
||||||
manualAbort.value = true;
|
manualAbort.value = true;
|
||||||
streamClient.abort();
|
streamClient.abort();
|
||||||
flushPendingDeltas();
|
finishExecution('cancelled');
|
||||||
running.value = false;
|
} catch (error: any) {
|
||||||
stopping.value = false;
|
stopping.value = false;
|
||||||
waitingConfirmation.value = undefined;
|
appendStatus(progressLabel('正在运行'), 'running', runStatusKey.value);
|
||||||
executionState.value = 'cancelled';
|
appendError(
|
||||||
finalizeLiveExecutionSteps('cancelled');
|
error?.message || '中止失败,请重试',
|
||||||
appendStatus('已中止', 'done', `run-${executeId.value || Date.now()}`);
|
`cancel-error-${executeId.value || Date.now()}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resetConversation() {
|
async function resetConversation() {
|
||||||
if (running.value) {
|
if (running.value) {
|
||||||
await stopExecution();
|
await stopExecution();
|
||||||
|
if (running.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
manualAbort.value = true;
|
manualAbort.value = true;
|
||||||
streamClient.abort();
|
streamClient.abort();
|
||||||
@@ -754,9 +721,9 @@ async function resetConversation() {
|
|||||||
liveExecutionSteps.value = [];
|
liveExecutionSteps.value = [];
|
||||||
expandedExecutionStepKeys.value = [];
|
expandedExecutionStepKeys.value = [];
|
||||||
detailExpansionTouched.value = false;
|
detailExpansionTouched.value = false;
|
||||||
streamMessages.clear();
|
clearProgressStatusTimer();
|
||||||
pendingDeltas.clear();
|
|
||||||
executeId.value = '';
|
executeId.value = '';
|
||||||
|
runStatusKey.value = '';
|
||||||
question.value = '';
|
question.value = '';
|
||||||
executionDetail.value = undefined;
|
executionDetail.value = undefined;
|
||||||
executionState.value = 'idle';
|
executionState.value = 'idle';
|
||||||
@@ -764,6 +731,9 @@ async function resetConversation() {
|
|||||||
executionElapsed.value = undefined;
|
executionElapsed.value = undefined;
|
||||||
detailLoadError.value = '';
|
detailLoadError.value = '';
|
||||||
waitingConfirmation.value = undefined;
|
waitingConfirmation.value = undefined;
|
||||||
|
confirmSubmittingAction.value = '';
|
||||||
|
confirmError.value = '';
|
||||||
|
lastRunningNodeName.value = '';
|
||||||
initializeAdditionalValues();
|
initializeAdditionalValues();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -965,7 +935,14 @@ function executionTraceText(
|
|||||||
:empty-text="emptyText"
|
:empty-text="emptyText"
|
||||||
:copy-action="copyMessage"
|
:copy-action="copyMessage"
|
||||||
:copyable="(item) => item.parts.some((part) => part.content)"
|
:copyable="(item) => item.parts.some((part) => part.content)"
|
||||||
|
>
|
||||||
|
<template #custom-item="{ item }">
|
||||||
|
<WorkflowFinalOutput
|
||||||
|
v-if="item.customType === 'workflow-final-output'"
|
||||||
|
:output="item.data"
|
||||||
/>
|
/>
|
||||||
|
</template>
|
||||||
|
</ChatTimeline>
|
||||||
|
|
||||||
<section
|
<section
|
||||||
v-if="additionalParameters.length > 0 && !extraSubmitted"
|
v-if="additionalParameters.length > 0 && !extraSubmitted"
|
||||||
@@ -1022,9 +999,27 @@ function executionTraceText(
|
|||||||
@update:run-params="confirmValues = $event"
|
@update:run-params="confirmValues = $event"
|
||||||
/>
|
/>
|
||||||
</ElForm>
|
</ElForm>
|
||||||
|
<p
|
||||||
|
v-if="confirmError"
|
||||||
|
class="workflow-chat__confirm-error"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
{{ confirmError }}
|
||||||
|
</p>
|
||||||
<div class="workflow-chat__form-actions">
|
<div class="workflow-chat__form-actions">
|
||||||
<ElButton @click="resumeExecution(false)">取消</ElButton>
|
<ElButton
|
||||||
<ElButton type="primary" @click="resumeExecution(true)">
|
:loading="confirmSubmittingAction === 'reject'"
|
||||||
|
:disabled="Boolean(confirmSubmittingAction)"
|
||||||
|
@click="resumeExecution(false)"
|
||||||
|
>
|
||||||
|
拒绝
|
||||||
|
</ElButton>
|
||||||
|
<ElButton
|
||||||
|
type="primary"
|
||||||
|
:loading="confirmSubmittingAction === 'confirm'"
|
||||||
|
:disabled="Boolean(confirmSubmittingAction)"
|
||||||
|
@click="resumeExecution(true)"
|
||||||
|
>
|
||||||
确认
|
确认
|
||||||
</ElButton>
|
</ElButton>
|
||||||
</div>
|
</div>
|
||||||
@@ -1338,6 +1333,16 @@ function executionTraceText(
|
|||||||
line-height: 20px;
|
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 {
|
.workflow-chat__form-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,539 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type {
|
||||||
|
ChatDocumentAttachment,
|
||||||
|
ChatDocumentLoader,
|
||||||
|
} from '@easyflow/common-ui';
|
||||||
|
|
||||||
|
import type { WorkflowFinalOutputSection } from './workflowFinalOutput';
|
||||||
|
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
|
||||||
|
import {
|
||||||
|
ChatDocumentAttachments,
|
||||||
|
ChatImageAttachments,
|
||||||
|
ChatTextBlock,
|
||||||
|
} from '@easyflow/common-ui';
|
||||||
|
|
||||||
|
import { ElButton } from 'element-plus';
|
||||||
|
|
||||||
|
import { copyTextWithFeedback } from '#/utils/clipboard-feedback';
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildWorkflowFinalOutputView,
|
||||||
|
formatWorkflowOutputPreview,
|
||||||
|
serializeWorkflowOutput,
|
||||||
|
} from './workflowFinalOutput';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
output: unknown;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const view = computed(() => buildWorkflowFinalOutputView(props.output));
|
||||||
|
const hasMultipleSections = computed(() => view.value.sections.length > 1);
|
||||||
|
const expandedKeys = ref<Set<string>>(new Set());
|
||||||
|
const copyingKey = ref('');
|
||||||
|
const COLLAPSED_ITEM_LIMIT = 12;
|
||||||
|
const EXPANDED_ITEM_LIMIT = 100;
|
||||||
|
const COLLAPSED_TEXT_LIMIT = 4000;
|
||||||
|
|
||||||
|
function isExpanded(key: string) {
|
||||||
|
return expandedKeys.value.has(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleExpanded(key: string) {
|
||||||
|
const next = new Set(expandedKeys.value);
|
||||||
|
if (next.has(key)) {
|
||||||
|
next.delete(key);
|
||||||
|
} else {
|
||||||
|
next.add(key);
|
||||||
|
}
|
||||||
|
expandedKeys.value = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function visibleLimit(section: WorkflowFinalOutputSection) {
|
||||||
|
return isExpanded(section.key) ? EXPANDED_ITEM_LIMIT : COLLAPSED_ITEM_LIMIT;
|
||||||
|
}
|
||||||
|
|
||||||
|
function visibleListItems(section: WorkflowFinalOutputSection) {
|
||||||
|
return section.listItems
|
||||||
|
.slice(0, visibleLimit(section))
|
||||||
|
.map((text, index) => ({ index, text }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function visibleTableRows(section: WorkflowFinalOutputSection) {
|
||||||
|
return section.tableRows.slice(0, visibleLimit(section));
|
||||||
|
}
|
||||||
|
|
||||||
|
function visibleObjectEntries(section: WorkflowFinalOutputSection) {
|
||||||
|
if (
|
||||||
|
!section.rawValue ||
|
||||||
|
typeof section.rawValue !== 'object' ||
|
||||||
|
Array.isArray(section.rawValue)
|
||||||
|
) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return Object.entries(section.rawValue).slice(0, visibleLimit(section));
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayedScalar(section: WorkflowFinalOutputSection) {
|
||||||
|
if (
|
||||||
|
typeof section.rawValue !== 'string' ||
|
||||||
|
isExpanded(section.key) ||
|
||||||
|
section.scalarText.length <= COLLAPSED_TEXT_LIMIT
|
||||||
|
) {
|
||||||
|
return section.scalarText;
|
||||||
|
}
|
||||||
|
return `${section.scalarText.slice(0, COLLAPSED_TEXT_LIMIT)}…`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasMore(section: WorkflowFinalOutputSection) {
|
||||||
|
if (section.kind === 'scalar') {
|
||||||
|
return (
|
||||||
|
typeof section.rawValue === 'string' &&
|
||||||
|
section.scalarText.length > COLLAPSED_TEXT_LIMIT
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (section.kind === 'list') {
|
||||||
|
return section.listItems.length > COLLAPSED_ITEM_LIMIT;
|
||||||
|
}
|
||||||
|
if (section.kind === 'table') {
|
||||||
|
return section.tableRows.length > COLLAPSED_ITEM_LIMIT;
|
||||||
|
}
|
||||||
|
if (section.kind === 'key-value') {
|
||||||
|
return (
|
||||||
|
section.rawValue !== null &&
|
||||||
|
typeof section.rawValue === 'object' &&
|
||||||
|
!Array.isArray(section.rawValue) &&
|
||||||
|
Object.keys(section.rawValue).length > COLLAPSED_ITEM_LIMIT
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
section.kind === 'json' &&
|
||||||
|
formatWorkflowOutputPreview(section.rawValue, false) !==
|
||||||
|
formatWorkflowOutputPreview(section.rawValue, true)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cellText(value: unknown) {
|
||||||
|
if (value === null) {
|
||||||
|
return '空值';
|
||||||
|
}
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sectionMeta(section: WorkflowFinalOutputSection) {
|
||||||
|
if (section.kind === 'images') {
|
||||||
|
return `图片 · ${section.images.length} 项`;
|
||||||
|
}
|
||||||
|
if (section.kind === 'documents') {
|
||||||
|
return `文件 · ${section.documents.length} 项`;
|
||||||
|
}
|
||||||
|
if (section.kind === 'media') {
|
||||||
|
return `附件 · ${section.images.length + section.documents.length} 项`;
|
||||||
|
}
|
||||||
|
if (Array.isArray(section.rawValue)) {
|
||||||
|
return `数组 · ${section.rawValue.length} 项`;
|
||||||
|
}
|
||||||
|
if (section.rawValue !== null && typeof section.rawValue === 'object') {
|
||||||
|
return `对象 · ${Object.keys(section.rawValue).length} 项`;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyOutput(key: string, value: unknown) {
|
||||||
|
if (copyingKey.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
copyingKey.value = key;
|
||||||
|
try {
|
||||||
|
await copyTextWithFeedback(
|
||||||
|
serializeWorkflowOutput(value),
|
||||||
|
'已复制',
|
||||||
|
'复制失败',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
copyingKey.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadDocument: ChatDocumentLoader = async (
|
||||||
|
document: ChatDocumentAttachment,
|
||||||
|
) => {
|
||||||
|
const downloadUrl = String(document.downloadUrl || '').trim();
|
||||||
|
if (!downloadUrl) {
|
||||||
|
throw new Error('文件缺少下载地址');
|
||||||
|
}
|
||||||
|
const opened = window.open(downloadUrl, '_blank', 'noopener,noreferrer');
|
||||||
|
if (!opened) {
|
||||||
|
throw new Error('浏览器阻止了文件下载,请允许打开新窗口后重试');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<article class="workflow-final-output" aria-label="工作流运行结果">
|
||||||
|
<header class="workflow-final-output__header">
|
||||||
|
<h2>运行结果</h2>
|
||||||
|
<ElButton
|
||||||
|
v-if="!view.empty"
|
||||||
|
text
|
||||||
|
size="small"
|
||||||
|
:loading="copyingKey === '__all__'"
|
||||||
|
:disabled="Boolean(copyingKey)"
|
||||||
|
@click="copyOutput('__all__', output)"
|
||||||
|
>
|
||||||
|
{{ hasMultipleSections ? '复制全部' : '复制结果' }}
|
||||||
|
</ElButton>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<p v-if="view.empty" class="workflow-final-output__empty">
|
||||||
|
运行完成,暂无输出
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<section
|
||||||
|
v-for="section in view.sections"
|
||||||
|
v-else
|
||||||
|
:key="section.key"
|
||||||
|
class="workflow-final-output__section"
|
||||||
|
>
|
||||||
|
<div class="workflow-final-output__section-heading">
|
||||||
|
<div class="workflow-final-output__section-title">
|
||||||
|
<h3>{{ section.label }}</h3>
|
||||||
|
<span
|
||||||
|
v-if="sectionMeta(section)"
|
||||||
|
class="workflow-final-output__section-meta"
|
||||||
|
>
|
||||||
|
{{ sectionMeta(section) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ElButton
|
||||||
|
v-if="hasMultipleSections"
|
||||||
|
text
|
||||||
|
size="small"
|
||||||
|
:aria-label="`复制 ${section.label}`"
|
||||||
|
:loading="copyingKey === section.key"
|
||||||
|
:disabled="Boolean(copyingKey)"
|
||||||
|
@click="copyOutput(section.key, section.rawValue)"
|
||||||
|
>
|
||||||
|
复制
|
||||||
|
</ElButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ChatImageAttachments
|
||||||
|
v-if="section.images.length > 0"
|
||||||
|
:items="section.images"
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
<ChatDocumentAttachments
|
||||||
|
v-if="section.documents.length > 0"
|
||||||
|
:items="section.documents"
|
||||||
|
:document-loader="loadDocument"
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ChatTextBlock
|
||||||
|
v-if="section.kind === 'scalar'"
|
||||||
|
class="workflow-final-output__text"
|
||||||
|
:content="displayedScalar(section)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ol
|
||||||
|
v-else-if="section.kind === 'list'"
|
||||||
|
class="workflow-final-output__list"
|
||||||
|
:aria-label="`${section.label} 数组,共 ${section.listItems.length} 项`"
|
||||||
|
>
|
||||||
|
<li
|
||||||
|
v-for="item in visibleListItems(section)"
|
||||||
|
:key="`${section.key}-${item.index}`"
|
||||||
|
class="workflow-final-output__list-item"
|
||||||
|
>
|
||||||
|
<span class="workflow-final-output__list-index" aria-hidden="true">
|
||||||
|
{{ String(item.index + 1).padStart(2, '0') }}
|
||||||
|
</span>
|
||||||
|
<span class="workflow-final-output__list-content">
|
||||||
|
{{ item.text }}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li
|
||||||
|
v-if="section.listItems.length === 0"
|
||||||
|
class="workflow-final-output__list-empty"
|
||||||
|
>
|
||||||
|
空数组
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else-if="section.kind === 'table'"
|
||||||
|
class="workflow-final-output__table-wrap"
|
||||||
|
>
|
||||||
|
<table class="workflow-final-output__table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th v-for="column in section.tableColumns" :key="column">
|
||||||
|
{{ column }}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
|
v-for="(row, rowIndex) in visibleTableRows(section)"
|
||||||
|
:key="`${section.key}-row-${rowIndex}`"
|
||||||
|
>
|
||||||
|
<td v-for="column in section.tableColumns" :key="column">
|
||||||
|
{{ cellText(row[column]) }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl
|
||||||
|
v-else-if="section.kind === 'key-value'"
|
||||||
|
class="workflow-final-output__key-values"
|
||||||
|
>
|
||||||
|
<template
|
||||||
|
v-for="entry in visibleObjectEntries(section)"
|
||||||
|
:key="entry[0]"
|
||||||
|
>
|
||||||
|
<dt>{{ entry[0] }}</dt>
|
||||||
|
<dd>{{ cellText(entry[1]) }}</dd>
|
||||||
|
</template>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<pre
|
||||||
|
v-else-if="section.kind === 'json'"
|
||||||
|
class="workflow-final-output__json"
|
||||||
|
><code>{{ formatWorkflowOutputPreview(section.rawValue, isExpanded(section.key)) }}</code></pre>
|
||||||
|
|
||||||
|
<div v-if="hasMore(section)" class="workflow-final-output__expand">
|
||||||
|
<ElButton text type="primary" @click="toggleExpanded(section.key)">
|
||||||
|
{{ isExpanded(section.key) ? '收起' : '展开更多' }}
|
||||||
|
</ElButton>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</article>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.workflow-final-output {
|
||||||
|
width: min(100%, 760px);
|
||||||
|
padding: var(--space-4) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-final-output__header,
|
||||||
|
.workflow-final-output__section-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-final-output__header {
|
||||||
|
padding-bottom: var(--space-2);
|
||||||
|
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-final-output__header h2,
|
||||||
|
.workflow-final-output__section-heading h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-final-output__header h2 {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-final-output__section {
|
||||||
|
padding-top: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-final-output__section + .workflow-final-output__section {
|
||||||
|
margin-top: var(--space-2);
|
||||||
|
border-top: 1px solid var(--el-border-color-lighter);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-final-output__section-heading {
|
||||||
|
min-height: 32px;
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-final-output__section-title {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-final-output__section-heading h3 {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 22px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-final-output__section-meta {
|
||||||
|
flex: none;
|
||||||
|
padding: 0 var(--space-2);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 24px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
background: var(--el-fill-color-light);
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-final-output__empty {
|
||||||
|
margin: var(--space-4) 0 0;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 22px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-final-output__text {
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-final-output__list {
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
list-style: none;
|
||||||
|
background: var(--el-bg-color);
|
||||||
|
border: 1px solid var(--el-border-color-lighter);
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-final-output__list-item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: var(--space-8) minmax(0, 1fr);
|
||||||
|
gap: var(--space-3);
|
||||||
|
align-items: start;
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-final-output__list-item + .workflow-final-output__list-item {
|
||||||
|
border-top: 1px solid var(--el-border-color-lighter);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-final-output__list-index {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: var(--space-8);
|
||||||
|
height: var(--space-6);
|
||||||
|
font-size: 12px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
background: var(--el-fill-color-light);
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-final-output__list-content {
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 24px;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-final-output__list-empty {
|
||||||
|
padding: var(--space-4);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 20px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-final-output__table-wrap,
|
||||||
|
.workflow-final-output__json {
|
||||||
|
max-height: 360px;
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid var(--el-border-color-lighter);
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-final-output__table {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 480px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 20px;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-final-output__table th,
|
||||||
|
.workflow-final-output__table td {
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
vertical-align: top;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-final-output__table th {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
background: var(--el-fill-color-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-final-output__table tbody tr:last-child td {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-final-output__key-values {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(96px, 0.32fr) minmax(0, 1fr);
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-final-output__key-values dt,
|
||||||
|
.workflow-final-output__key-values dd {
|
||||||
|
padding: var(--space-2) 0;
|
||||||
|
margin: 0;
|
||||||
|
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-final-output__key-values dt {
|
||||||
|
padding-right: var(--space-4);
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-final-output__key-values dd {
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-final-output__json {
|
||||||
|
padding: var(--space-3);
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--el-font-family-monospace, monospace);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 20px;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
background: var(--el-fill-color-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-final-output__expand {
|
||||||
|
margin-top: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.workflow-final-output__key-values {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-final-output__key-values dt {
|
||||||
|
padding-bottom: 0;
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -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: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, unknown>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, unknown>[],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
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<string, unknown>);
|
||||||
|
if (
|
||||||
|
firstKeys.length === 0 ||
|
||||||
|
firstKeys.length > TABLE_COLUMN_LIMIT ||
|
||||||
|
!value.every((row) => {
|
||||||
|
const keys = Object.keys(row as Record<string, unknown>);
|
||||||
|
return (
|
||||||
|
keys.length === firstKeys.length &&
|
||||||
|
keys.every((key, index) => key === firstKeys[index]) &&
|
||||||
|
Object.values(row as Record<string, unknown>).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<string, unknown> {
|
||||||
|
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, unknown>): string {
|
||||||
|
return stringValue(
|
||||||
|
value.downloadUrl || value.url || value.filePath || value.previewUrl,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mediaName(
|
||||||
|
value: Record<string, unknown>,
|
||||||
|
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<string, unknown> {
|
||||||
|
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<object>(),
|
||||||
|
): 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<string, unknown> = {};
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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`;
|
||||||
|
}
|
||||||
@@ -149,9 +149,14 @@ watch(
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
|
<template v-for="item in items" :key="item.id">
|
||||||
|
<slot
|
||||||
|
v-if="item.type === 'custom'"
|
||||||
|
name="custom-item"
|
||||||
|
:item="item"
|
||||||
|
></slot>
|
||||||
<ChatTimelineItem
|
<ChatTimelineItem
|
||||||
v-for="item in items"
|
v-else
|
||||||
:key="item.id"
|
|
||||||
:assistant-actions-visible="isAssistantActionAnchor(item)"
|
:assistant-actions-visible="isAssistantActionAnchor(item)"
|
||||||
:item="item"
|
:item="item"
|
||||||
:document-loader="documentLoader"
|
:document-loader="documentLoader"
|
||||||
@@ -171,6 +176,7 @@ watch(
|
|||||||
@thinking-toggle="handleThinkingToggle"
|
@thinking-toggle="handleThinkingToggle"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -16,6 +16,7 @@ export type {
|
|||||||
ChatDocumentLoader,
|
ChatDocumentLoader,
|
||||||
ChatImageAttachment,
|
ChatImageAttachment,
|
||||||
ChatImageLoader,
|
ChatImageLoader,
|
||||||
|
ChatTimelineCustomItem,
|
||||||
ChatTimelineErrorItem,
|
ChatTimelineErrorItem,
|
||||||
ChatTimelineItem,
|
ChatTimelineItem,
|
||||||
ChatTimelineItemStatus,
|
ChatTimelineItemStatus,
|
||||||
|
|||||||
@@ -142,7 +142,14 @@ export interface ChatTimelineErrorItem extends ChatTimelineItemBase {
|
|||||||
type: 'error';
|
type: 'error';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ChatTimelineCustomItem extends ChatTimelineItemBase {
|
||||||
|
customType: string;
|
||||||
|
data: unknown;
|
||||||
|
type: 'custom';
|
||||||
|
}
|
||||||
|
|
||||||
export type ChatTimelineItem =
|
export type ChatTimelineItem =
|
||||||
|
| ChatTimelineCustomItem
|
||||||
| ChatTimelineErrorItem
|
| ChatTimelineErrorItem
|
||||||
| ChatTimelineKnowledgeItem
|
| ChatTimelineKnowledgeItem
|
||||||
| ChatTimelineMessageItem
|
| ChatTimelineMessageItem
|
||||||
|
|||||||
Reference in New Issue
Block a user