feat: 收口工作流对话最终结果展示
- 主时间线只展示顶级工作流最终输出和有限运行进度 - 补齐结构化结果、人工审核交互与实时详情回归 - 优化数组层级和复制操作
This commit is contained in:
@@ -44,18 +44,13 @@ import { copyTextWithFeedback } from '#/utils/clipboard-feedback';
|
||||
import { buildAbsoluteAppRouteUrl } from '#/utils/share-route-context';
|
||||
import { resolveWorkflowShareFailureReason } from '#/utils/workflow-share-context';
|
||||
|
||||
import {
|
||||
appendWorkflowStreamDelta,
|
||||
appendWorkflowThinkingDelta,
|
||||
createWorkflowStreamMessage,
|
||||
updateWorkflowStreamStatus,
|
||||
} from './workflowChatStreamMessage';
|
||||
import {
|
||||
finalizeWorkflowExecutionSteps,
|
||||
formatExecutionValue,
|
||||
hydrateWorkflowExecutionSteps,
|
||||
reduceWorkflowExecutionSteps,
|
||||
} from './workflowExecutionDetails';
|
||||
import WorkflowFinalOutput from './WorkflowFinalOutput.vue';
|
||||
import WorkflowFormItem from './WorkflowFormItem.vue';
|
||||
import { resolveWorkflowFormParameters } from './workflowFormParameters';
|
||||
import {
|
||||
@@ -63,6 +58,11 @@ import {
|
||||
buildWorkflowFormSubmissionText,
|
||||
hasRequiredWorkflowFormParameters,
|
||||
} from './workflowFormPresentation';
|
||||
import {
|
||||
formatWorkflowElapsed,
|
||||
formatWorkflowProgressLabel,
|
||||
summarizeWorkflowActiveNodes,
|
||||
} from './workflowRunProgress';
|
||||
|
||||
interface WorkflowStreamEnvelope {
|
||||
data?: Record<string, any>;
|
||||
@@ -72,17 +72,6 @@ interface WorkflowStreamEnvelope {
|
||||
type: string;
|
||||
}
|
||||
|
||||
interface PendingDelta {
|
||||
chunks: PendingDeltaChunk[];
|
||||
nodeName: string;
|
||||
streamId: string;
|
||||
}
|
||||
|
||||
interface PendingDeltaChunk {
|
||||
content: string;
|
||||
type: 'text' | 'thinking';
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
shareMode?: boolean;
|
||||
@@ -103,6 +92,7 @@ const question = ref('');
|
||||
const running = ref(false);
|
||||
const stopping = ref(false);
|
||||
const executeId = ref('');
|
||||
const runStatusKey = ref('');
|
||||
const extraReady = ref(false);
|
||||
const extraSubmitted = ref(false);
|
||||
const extraSubmitting = ref(false);
|
||||
@@ -111,6 +101,8 @@ const extraFormRef = ref<FormInstance>();
|
||||
const waitingConfirmation = ref<Record<string, any>>();
|
||||
const confirmValues = ref<Record<string, any>>({});
|
||||
const confirmFormRef = ref<FormInstance>();
|
||||
const confirmSubmittingAction = ref<'' | 'confirm' | 'reject'>('');
|
||||
const confirmError = ref('');
|
||||
const detailVisible = ref(false);
|
||||
const detailLoading = ref(false);
|
||||
const detailLoadError = ref('');
|
||||
@@ -125,9 +117,8 @@ const executionStartedAt = ref<number>();
|
||||
const executionElapsed = ref<number>();
|
||||
const sharing = ref(false);
|
||||
const manualAbort = ref(false);
|
||||
const streamMessages = new Map<string, ChatTimelineMessageItem>();
|
||||
const pendingDeltas = new Map<string, PendingDelta>();
|
||||
let deltaFlushFrame = 0;
|
||||
const lastRunningNodeName = ref('');
|
||||
let progressStatusTimer = 0;
|
||||
let userMessageSequence = 0;
|
||||
|
||||
const formParameters = computed(() =>
|
||||
@@ -215,9 +206,7 @@ onMounted(loadPage);
|
||||
onBeforeUnmount(() => {
|
||||
manualAbort.value = true;
|
||||
streamClient.abort();
|
||||
if (deltaFlushFrame) {
|
||||
cancelAnimationFrame(deltaFlushFrame);
|
||||
}
|
||||
clearProgressStatusTimer();
|
||||
});
|
||||
|
||||
async function loadPage() {
|
||||
@@ -362,41 +351,6 @@ function appendUserMessage(
|
||||
});
|
||||
}
|
||||
|
||||
function appendAssistantMessage(
|
||||
id: string,
|
||||
content: string,
|
||||
status: ChatTimelineMessageItem['status'] = 'done',
|
||||
) {
|
||||
const item: ChatTimelineMessageItem = {
|
||||
id,
|
||||
role: 'assistant',
|
||||
status,
|
||||
parts: [
|
||||
{
|
||||
content,
|
||||
id: `${id}-text`,
|
||||
type: 'text',
|
||||
},
|
||||
],
|
||||
type: 'message',
|
||||
};
|
||||
timelineItems.value.push(item);
|
||||
return item;
|
||||
}
|
||||
|
||||
function replaceStreamMessage(
|
||||
streamId: string,
|
||||
message: ChatTimelineMessageItem,
|
||||
) {
|
||||
const current = streamMessages.get(streamId);
|
||||
streamMessages.set(streamId, message);
|
||||
const messageId = current?.id || message.id;
|
||||
const index = timelineItems.value.findIndex((item) => item.id === messageId);
|
||||
if (index !== -1) {
|
||||
timelineItems.value[index] = message;
|
||||
}
|
||||
}
|
||||
|
||||
function appendError(message: string, id = `error-${Date.now()}`) {
|
||||
if (
|
||||
timelineItems.value.some((item) => item.type === 'error' && item.id === id)
|
||||
@@ -410,6 +364,69 @@ function appendError(message: string, id = `error-${Date.now()}`) {
|
||||
});
|
||||
}
|
||||
|
||||
function appendFinalOutput(output: unknown, eventId: string) {
|
||||
timelineItems.value.push({
|
||||
customType: 'workflow-final-output',
|
||||
data: output,
|
||||
id: `final-output-${eventId}`,
|
||||
type: 'custom',
|
||||
});
|
||||
}
|
||||
|
||||
function clearProgressStatusTimer() {
|
||||
if (progressStatusTimer) {
|
||||
window.clearTimeout(progressStatusTimer);
|
||||
progressStatusTimer = 0;
|
||||
}
|
||||
}
|
||||
|
||||
function activeNodeSummary() {
|
||||
return summarizeWorkflowActiveNodes(
|
||||
liveExecutionSteps.value,
|
||||
lastRunningNodeName.value,
|
||||
);
|
||||
}
|
||||
|
||||
function progressLabel(prefix: string) {
|
||||
return formatWorkflowProgressLabel(
|
||||
prefix,
|
||||
liveExecutionSteps.value,
|
||||
lastRunningNodeName.value,
|
||||
);
|
||||
}
|
||||
|
||||
function scheduleRunningStatus() {
|
||||
if (!running.value || executionState.value !== 'running') {
|
||||
return;
|
||||
}
|
||||
clearProgressStatusTimer();
|
||||
progressStatusTimer = window.setTimeout(() => {
|
||||
progressStatusTimer = 0;
|
||||
appendStatus(
|
||||
progressLabel(stopping.value ? '正在中止' : '正在运行'),
|
||||
'running',
|
||||
runStatusKey.value,
|
||||
);
|
||||
}, 140);
|
||||
}
|
||||
|
||||
function markConfirmationStep(status: 'running' | 'waiting') {
|
||||
for (
|
||||
let index = liveExecutionSteps.value.length - 1;
|
||||
index >= 0;
|
||||
index -= 1
|
||||
) {
|
||||
const step = liveExecutionSteps.value[index];
|
||||
if (!step || (step.status !== 'running' && step.status !== 'waiting')) {
|
||||
continue;
|
||||
}
|
||||
const next = [...liveExecutionSteps.value];
|
||||
next[index] = { ...step, status };
|
||||
liveExecutionSteps.value = next;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSend() {
|
||||
const content = question.value.trim();
|
||||
if (!content || composerDisabled.value || !workflowId.value) {
|
||||
@@ -425,12 +442,16 @@ async function handleSend() {
|
||||
executionStartedAt.value = Date.now();
|
||||
executionElapsed.value = undefined;
|
||||
executeId.value = '';
|
||||
runStatusKey.value = `run-${Date.now()}-${userMessageSequence}`;
|
||||
executionDetail.value = undefined;
|
||||
detailLoadError.value = '';
|
||||
liveExecutionSteps.value = [];
|
||||
expandedExecutionStepKeys.value = [];
|
||||
detailExpansionTouched.value = false;
|
||||
waitingConfirmation.value = undefined;
|
||||
confirmSubmittingAction.value = '';
|
||||
confirmError.value = '';
|
||||
lastRunningNodeName.value = '';
|
||||
manualAbort.value = false;
|
||||
|
||||
void streamClient.post(
|
||||
@@ -447,22 +468,21 @@ async function handleSend() {
|
||||
if (manualAbort.value) {
|
||||
return;
|
||||
}
|
||||
flushPendingDeltas();
|
||||
running.value = false;
|
||||
executionState.value = 'failed';
|
||||
finalizeLiveExecutionSteps('failed');
|
||||
appendError(
|
||||
finishExecution(
|
||||
'failed',
|
||||
error?.message || '工作流执行失败',
|
||||
undefined,
|
||||
`stream-error-${Date.now()}`,
|
||||
);
|
||||
},
|
||||
onFinished: () => {
|
||||
flushPendingDeltas();
|
||||
if (running.value && !manualAbort.value) {
|
||||
running.value = false;
|
||||
executionState.value = 'failed';
|
||||
finalizeLiveExecutionSteps('failed');
|
||||
appendError('运行连接已结束,请重试');
|
||||
finishExecution(
|
||||
'failed',
|
||||
'运行连接已结束,请重试',
|
||||
undefined,
|
||||
`stream-finished-${Date.now()}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
onMessage: (message) => {
|
||||
@@ -487,16 +507,11 @@ function parseStreamEvent(raw: string): null | WorkflowStreamEnvelope {
|
||||
}
|
||||
|
||||
function handleStreamEvent(event: WorkflowStreamEnvelope) {
|
||||
if (event.type === 'llm_delta' || event.type === 'llm_thinking_delta') {
|
||||
enqueueDelta(event);
|
||||
return;
|
||||
}
|
||||
flushPendingDeltas();
|
||||
updateLiveExecutionSteps(event);
|
||||
const data = event.data || {};
|
||||
switch (event.type) {
|
||||
case 'execution_cancelled': {
|
||||
finishExecution('已中止', false);
|
||||
finishExecution('cancelled');
|
||||
break;
|
||||
}
|
||||
case 'execution_error': {
|
||||
@@ -507,136 +522,87 @@ function handleStreamEvent(event: WorkflowStreamEnvelope) {
|
||||
break;
|
||||
}
|
||||
case 'execution_failed': {
|
||||
finishExecution('运行失败', true, data.message);
|
||||
finishExecution('failed', data.message);
|
||||
break;
|
||||
}
|
||||
case 'execution_finished': {
|
||||
finishExecution('运行完成', false);
|
||||
finishExecution('completed', undefined, data.output, event.eventId);
|
||||
break;
|
||||
}
|
||||
case 'execution_started': {
|
||||
executeId.value = String(event.executeId || data.executeId || '');
|
||||
executionState.value = 'running';
|
||||
appendStatus('正在运行', 'running', `run-${executeId.value}`);
|
||||
appendStatus('准备运行…', 'running', runStatusKey.value);
|
||||
break;
|
||||
}
|
||||
case 'execution_status': {
|
||||
if (data.status === 'RUNNING') {
|
||||
waitingConfirmation.value = undefined;
|
||||
confirmError.value = '';
|
||||
executionState.value = 'running';
|
||||
appendStatus('正在运行', 'running', `run-${executeId.value}`);
|
||||
markConfirmationStep('running');
|
||||
scheduleRunningStatus();
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'execution_waiting': {
|
||||
clearProgressStatusTimer();
|
||||
waitingConfirmation.value = data;
|
||||
executionState.value = 'waiting';
|
||||
confirmSubmittingAction.value = '';
|
||||
confirmError.value = '';
|
||||
initializeConfirmValues(data.parameters);
|
||||
appendStatus(
|
||||
data.message || '等待确认',
|
||||
'running',
|
||||
`run-${executeId.value}`,
|
||||
);
|
||||
markConfirmationStep('waiting');
|
||||
appendStatus(progressLabel('等待确认'), 'running', runStatusKey.value);
|
||||
break;
|
||||
}
|
||||
case 'node_finished': {
|
||||
const streamId = String(data.streamId || '');
|
||||
const streamMessage = streamMessages.get(streamId);
|
||||
if (streamMessage) {
|
||||
replaceStreamMessage(
|
||||
streamId,
|
||||
updateWorkflowStreamStatus(streamMessage, 'done'),
|
||||
);
|
||||
timelineItems.value = [...timelineItems.value];
|
||||
}
|
||||
scheduleRunningStatus();
|
||||
break;
|
||||
}
|
||||
case 'output': {
|
||||
appendAssistantMessage(
|
||||
`output-${event.eventId}`,
|
||||
formatNodeOutput(data.nodeName, data.output),
|
||||
case 'node_started': {
|
||||
lastRunningNodeName.value = String(
|
||||
data.nodeName || lastRunningNodeName.value,
|
||||
);
|
||||
scheduleRunningStatus();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function enqueueDelta(event: WorkflowStreamEnvelope) {
|
||||
const data = event.data || {};
|
||||
const streamId = String(data.streamId || '');
|
||||
const delta = String(data.delta || '');
|
||||
if (!streamId || !delta) {
|
||||
return;
|
||||
}
|
||||
const pending = pendingDeltas.get(streamId) || {
|
||||
chunks: [],
|
||||
nodeName: String(data.nodeName || '大模型'),
|
||||
streamId,
|
||||
};
|
||||
pending.chunks.push({
|
||||
content: delta,
|
||||
type: event.type === 'llm_thinking_delta' ? 'thinking' : 'text',
|
||||
});
|
||||
pendingDeltas.set(streamId, pending);
|
||||
if (!deltaFlushFrame) {
|
||||
deltaFlushFrame = requestAnimationFrame(flushPendingDeltas);
|
||||
}
|
||||
}
|
||||
|
||||
function flushPendingDeltas() {
|
||||
if (deltaFlushFrame) {
|
||||
cancelAnimationFrame(deltaFlushFrame);
|
||||
deltaFlushFrame = 0;
|
||||
}
|
||||
if (pendingDeltas.size === 0) {
|
||||
return;
|
||||
}
|
||||
for (const pending of pendingDeltas.values()) {
|
||||
let message =
|
||||
streamMessages.get(pending.streamId) ||
|
||||
createWorkflowStreamMessage(`llm-${pending.streamId}`, pending.nodeName);
|
||||
if (!streamMessages.has(pending.streamId)) {
|
||||
timelineItems.value.push(message);
|
||||
streamMessages.set(pending.streamId, message);
|
||||
}
|
||||
for (const chunk of pending.chunks) {
|
||||
message =
|
||||
chunk.type === 'thinking'
|
||||
? appendWorkflowThinkingDelta(message, chunk.content)
|
||||
: appendWorkflowStreamDelta(message, chunk.content);
|
||||
}
|
||||
replaceStreamMessage(pending.streamId, message);
|
||||
}
|
||||
pendingDeltas.clear();
|
||||
timelineItems.value = [...timelineItems.value];
|
||||
}
|
||||
|
||||
function finishExecution(label: string, failed: boolean, message?: string) {
|
||||
function finishExecution(
|
||||
terminalStatus: 'cancelled' | 'completed' | 'failed',
|
||||
message?: string,
|
||||
output?: unknown,
|
||||
eventId = executeId.value || String(Date.now()),
|
||||
) {
|
||||
const failedNodeName = activeNodeSummary();
|
||||
clearProgressStatusTimer();
|
||||
running.value = false;
|
||||
stopping.value = false;
|
||||
waitingConfirmation.value = undefined;
|
||||
let terminalStatus: 'cancelled' | 'completed' | 'failed' = 'completed';
|
||||
if (label === '已中止') {
|
||||
terminalStatus = 'cancelled';
|
||||
} else if (failed) {
|
||||
terminalStatus = 'failed';
|
||||
}
|
||||
confirmSubmittingAction.value = '';
|
||||
confirmError.value = '';
|
||||
executionState.value = terminalStatus;
|
||||
finalizeLiveExecutionSteps(terminalStatus);
|
||||
for (const [streamId, item] of streamMessages.entries()) {
|
||||
if (item.status === 'streaming') {
|
||||
replaceStreamMessage(
|
||||
streamId,
|
||||
updateWorkflowStreamStatus(item, failed ? 'error' : 'done'),
|
||||
);
|
||||
}
|
||||
const elapsedText = formatWorkflowElapsed(executionElapsed.value);
|
||||
let label = elapsedText ? `运行完成 · ${elapsedText}` : '运行完成';
|
||||
if (terminalStatus === 'cancelled') {
|
||||
label = '已中止';
|
||||
} else if (terminalStatus === 'failed') {
|
||||
label = failedNodeName ? `运行失败 · ${failedNodeName}` : '运行失败';
|
||||
}
|
||||
appendStatus(label, 'done', `run-${executeId.value}`);
|
||||
if (failed && message) {
|
||||
appendStatus(label, 'done', runStatusKey.value || `run-${eventId}`);
|
||||
if (terminalStatus === 'completed') {
|
||||
appendFinalOutput(output, eventId);
|
||||
}
|
||||
if (terminalStatus === 'failed' && message) {
|
||||
appendError(message, `terminal-error-${executeId.value}`);
|
||||
}
|
||||
timelineItems.value = [...timelineItems.value];
|
||||
window.setTimeout(() => void loadExecutionDetail(), 320);
|
||||
if (executeId.value) {
|
||||
window.setTimeout(() => void loadExecutionDetail(), 320);
|
||||
}
|
||||
}
|
||||
|
||||
function updateLiveExecutionSteps(event: WorkflowStreamEnvelope) {
|
||||
@@ -645,6 +611,11 @@ function updateLiveExecutionSteps(event: WorkflowStreamEnvelope) {
|
||||
return;
|
||||
}
|
||||
liveExecutionSteps.value = next;
|
||||
if (event.type === 'node_started') {
|
||||
const latestStep = next[next.length - 1];
|
||||
lastRunningNodeName.value =
|
||||
latestStep?.nodeName || lastRunningNodeName.value;
|
||||
}
|
||||
if (
|
||||
event.type === 'node_started' &&
|
||||
!detailExpansionTouched.value &&
|
||||
@@ -669,24 +640,6 @@ function finalizeLiveExecutionSteps(
|
||||
: Math.max(0, finishedAt - executionStartedAt.value);
|
||||
}
|
||||
|
||||
function formatNodeOutput(nodeName: unknown, output: unknown) {
|
||||
const title = String(nodeName || '输出');
|
||||
return `**${title}**\n\n${formatOutput(output)}`;
|
||||
}
|
||||
|
||||
function formatOutput(output: unknown) {
|
||||
if (typeof output === 'string') {
|
||||
return output;
|
||||
}
|
||||
if (output && typeof output === 'object' && !Array.isArray(output)) {
|
||||
const entries = Object.entries(output as Record<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) {
|
||||
const values: Record<string, any> = {};
|
||||
for (const parameter of Array.isArray(parameters) ? parameters : []) {
|
||||
@@ -699,25 +652,34 @@ function initializeConfirmValues(parameters: unknown) {
|
||||
}
|
||||
|
||||
async function resumeExecution(confirmed: boolean) {
|
||||
if (!executeId.value || !confirmKey.value) {
|
||||
if (!executeId.value || !confirmKey.value || confirmSubmittingAction.value) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
confirmed &&
|
||||
!(await confirmFormRef.value?.validate().catch(() => false))
|
||||
) {
|
||||
return;
|
||||
confirmSubmittingAction.value = confirmed ? 'confirm' : 'reject';
|
||||
confirmError.value = '';
|
||||
try {
|
||||
if (
|
||||
confirmed &&
|
||||
!(await confirmFormRef.value?.validate().catch(() => false))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await api.post('/api/v1/workflowChat/resume', {
|
||||
executeId: executeId.value,
|
||||
confirmParams: {
|
||||
[confirmKey.value]: confirmed ? 'yes' : 'no',
|
||||
...(confirmed ? confirmValues.value : {}),
|
||||
},
|
||||
});
|
||||
waitingConfirmation.value = undefined;
|
||||
executionState.value = 'running';
|
||||
markConfirmationStep('running');
|
||||
appendStatus(progressLabel('正在运行'), 'running', runStatusKey.value);
|
||||
} catch (error: any) {
|
||||
confirmError.value = error?.message || '提交失败,请重试';
|
||||
} finally {
|
||||
confirmSubmittingAction.value = '';
|
||||
}
|
||||
await api.post('/api/v1/workflowChat/resume', {
|
||||
executeId: executeId.value,
|
||||
confirmParams: {
|
||||
[confirmKey.value]: confirmed ? 'yes' : 'no',
|
||||
...(confirmed ? confirmValues.value : {}),
|
||||
},
|
||||
});
|
||||
waitingConfirmation.value = undefined;
|
||||
executionState.value = 'running';
|
||||
appendStatus('正在运行', 'running', `run-${executeId.value}`);
|
||||
}
|
||||
|
||||
async function stopExecution() {
|
||||
@@ -725,28 +687,33 @@ async function stopExecution() {
|
||||
return;
|
||||
}
|
||||
stopping.value = true;
|
||||
clearProgressStatusTimer();
|
||||
appendStatus(progressLabel('正在中止'), 'running', runStatusKey.value);
|
||||
try {
|
||||
if (executeId.value) {
|
||||
await api.post('/api/v1/workflowChat/cancel', {
|
||||
executeId: executeId.value,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
manualAbort.value = true;
|
||||
streamClient.abort();
|
||||
flushPendingDeltas();
|
||||
running.value = false;
|
||||
finishExecution('cancelled');
|
||||
} catch (error: any) {
|
||||
stopping.value = false;
|
||||
waitingConfirmation.value = undefined;
|
||||
executionState.value = 'cancelled';
|
||||
finalizeLiveExecutionSteps('cancelled');
|
||||
appendStatus('已中止', 'done', `run-${executeId.value || Date.now()}`);
|
||||
appendStatus(progressLabel('正在运行'), 'running', runStatusKey.value);
|
||||
appendError(
|
||||
error?.message || '中止失败,请重试',
|
||||
`cancel-error-${executeId.value || Date.now()}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function resetConversation() {
|
||||
if (running.value) {
|
||||
await stopExecution();
|
||||
if (running.value) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
manualAbort.value = true;
|
||||
streamClient.abort();
|
||||
@@ -754,9 +721,9 @@ async function resetConversation() {
|
||||
liveExecutionSteps.value = [];
|
||||
expandedExecutionStepKeys.value = [];
|
||||
detailExpansionTouched.value = false;
|
||||
streamMessages.clear();
|
||||
pendingDeltas.clear();
|
||||
clearProgressStatusTimer();
|
||||
executeId.value = '';
|
||||
runStatusKey.value = '';
|
||||
question.value = '';
|
||||
executionDetail.value = undefined;
|
||||
executionState.value = 'idle';
|
||||
@@ -764,6 +731,9 @@ async function resetConversation() {
|
||||
executionElapsed.value = undefined;
|
||||
detailLoadError.value = '';
|
||||
waitingConfirmation.value = undefined;
|
||||
confirmSubmittingAction.value = '';
|
||||
confirmError.value = '';
|
||||
lastRunningNodeName.value = '';
|
||||
initializeAdditionalValues();
|
||||
}
|
||||
|
||||
@@ -965,7 +935,14 @@ function executionTraceText(
|
||||
:empty-text="emptyText"
|
||||
:copy-action="copyMessage"
|
||||
:copyable="(item) => item.parts.some((part) => part.content)"
|
||||
/>
|
||||
>
|
||||
<template #custom-item="{ item }">
|
||||
<WorkflowFinalOutput
|
||||
v-if="item.customType === 'workflow-final-output'"
|
||||
:output="item.data"
|
||||
/>
|
||||
</template>
|
||||
</ChatTimeline>
|
||||
|
||||
<section
|
||||
v-if="additionalParameters.length > 0 && !extraSubmitted"
|
||||
@@ -1022,9 +999,27 @@ function executionTraceText(
|
||||
@update:run-params="confirmValues = $event"
|
||||
/>
|
||||
</ElForm>
|
||||
<p
|
||||
v-if="confirmError"
|
||||
class="workflow-chat__confirm-error"
|
||||
role="alert"
|
||||
>
|
||||
{{ confirmError }}
|
||||
</p>
|
||||
<div class="workflow-chat__form-actions">
|
||||
<ElButton @click="resumeExecution(false)">取消</ElButton>
|
||||
<ElButton type="primary" @click="resumeExecution(true)">
|
||||
<ElButton
|
||||
:loading="confirmSubmittingAction === 'reject'"
|
||||
:disabled="Boolean(confirmSubmittingAction)"
|
||||
@click="resumeExecution(false)"
|
||||
>
|
||||
拒绝
|
||||
</ElButton>
|
||||
<ElButton
|
||||
type="primary"
|
||||
:loading="confirmSubmittingAction === 'confirm'"
|
||||
:disabled="Boolean(confirmSubmittingAction)"
|
||||
@click="resumeExecution(true)"
|
||||
>
|
||||
确认
|
||||
</ElButton>
|
||||
</div>
|
||||
@@ -1338,6 +1333,16 @@ function executionTraceText(
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.workflow-chat__confirm-error {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
margin: var(--space-3) 0 0;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
color: var(--el-color-danger);
|
||||
background: var(--el-color-danger-light-9);
|
||||
border-radius: var(--radius-control);
|
||||
}
|
||||
|
||||
.workflow-chat__form-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
Reference in New Issue
Block a user