发布 v1.10 #5

Merged
czm merged 147 commits from develop into main 2026-08-20 11:36:27 +08:00
13 changed files with 1531 additions and 330 deletions
Showing only changes of commit 0754ad0792 - Show all commits

View File

@@ -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;
}
} }
} }

View File

@@ -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()
);
}
}

View File

@@ -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,136 +522,87 @@ 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', `run-${executeId.value}`); appendStatus(label, 'done', runStatusKey.value || `run-${eventId}`);
if (failed && message) { if (terminalStatus === 'completed') {
appendFinalOutput(output, eventId);
}
if (terminalStatus === 'failed' && message) {
appendError(message, `terminal-error-${executeId.value}`); appendError(message, `terminal-error-${executeId.value}`);
} }
timelineItems.value = [...timelineItems.value]; timelineItems.value = [...timelineItems.value];
window.setTimeout(() => void loadExecutionDetail(), 320); if (executeId.value) {
window.setTimeout(() => void loadExecutionDetail(), 320);
}
} }
function updateLiveExecutionSteps(event: WorkflowStreamEnvelope) { function updateLiveExecutionSteps(event: WorkflowStreamEnvelope) {
@@ -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,25 +652,34 @@ 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;
} }
if ( confirmSubmittingAction.value = confirmed ? 'confirm' : 'reject';
confirmed && confirmError.value = '';
!(await confirmFormRef.value?.validate().catch(() => false)) try {
) { if (
return; 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() { 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;

View File

@@ -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);
});
});

View File

@@ -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>

View File

@@ -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: [],
});
});
});

View File

@@ -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);
}

View File

@@ -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');
});
});

View File

@@ -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`;
}

View File

@@ -149,27 +149,33 @@ watch(
</div> </div>
</div> </div>
<template v-else> <template v-else>
<ChatTimelineItem <template v-for="item in items" :key="item.id">
v-for="item in items" <slot
:key="item.id" v-if="item.type === 'custom'"
:assistant-actions-visible="isAssistantActionAnchor(item)" name="custom-item"
:item="item" :item="item"
:document-loader="documentLoader" ></slot>
:image-loader="imageLoader" <ChatTimelineItem
:approval-loading="approvalLoading" v-else
:copy-action="copyAction" :assistant-actions-visible="isAssistantActionAnchor(item)"
:copyable="canCopyMessage(item)" :item="item"
:regenerable="canRegenerateMessage(item)" :document-loader="documentLoader"
:regenerate-disabled="regenerateDisabled" :image-loader="imageLoader"
:variant-loading="isVariantLoading(item)" :approval-loading="approvalLoading"
@approve="emit('approve', $event)" :copy-action="copyAction"
@copy-message="emit('copyMessage', $event)" :copyable="canCopyMessage(item)"
@regenerate-message="emit('regenerateMessage', $event)" :regenerable="canRegenerateMessage(item)"
@reject="emit('reject', $event)" :regenerate-disabled="regenerateDisabled"
@select-next-variant="emit('selectNextVariant', $event)" :variant-loading="isVariantLoading(item)"
@select-previous-variant="emit('selectPreviousVariant', $event)" @approve="emit('approve', $event)"
@thinking-toggle="handleThinkingToggle" @copy-message="emit('copyMessage', $event)"
/> @regenerate-message="emit('regenerateMessage', $event)"
@reject="emit('reject', $event)"
@select-next-variant="emit('selectNextVariant', $event)"
@select-previous-variant="emit('selectPreviousVariant', $event)"
@thinking-toggle="handleThinkingToggle"
/>
</template>
</template> </template>
</div> </div>
</template> </template>

View File

@@ -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');
});
});

View File

@@ -16,6 +16,7 @@ export type {
ChatDocumentLoader, ChatDocumentLoader,
ChatImageAttachment, ChatImageAttachment,
ChatImageLoader, ChatImageLoader,
ChatTimelineCustomItem,
ChatTimelineErrorItem, ChatTimelineErrorItem,
ChatTimelineItem, ChatTimelineItem,
ChatTimelineItemStatus, ChatTimelineItemStatus,

View File

@@ -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