Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -81,17 +81,25 @@
>
<span v-else>报告已生成完毕,查看下方报告卡片</span>
</div>
<!-- Pure code block (all items share same code type) -->
<div
v-else-if="isPureCodeBlock(step.contentBlock)"
v-html="renderCode(step.contentBlock)"
/>
<!-- Mixed content: text with possible embedded JSON/code -->
<div
v-else-if="step.contentBlock.length"
class="text-body"
v-html="renderTextWithJsonDetection(step.contentBlock)"
/>
<!-- Preserve typed code inside mixed status/code output. -->
<div v-else-if="step.contentBlock.length" class="timeline-content">
<template
v-for="(segment, index) in segmentWorkflowContent(
step.contentBlock,
)"
:key="`${segment.kind}-${index}`"
>
<div
v-if="segment.kind === 'code'"
v-html="renderCode(segment.items)"
/>
<div
v-else
class="text-body"
v-html="renderTextWithJsonDetection(segment.items)"
/>
</template>
</div>
<!-- RESULT_SET arrives as another block from the same node. -->
<ChatResultSet
v-if="step.resultSetText && store.requestOptions.showSqlResults"
Expand All @@ -113,7 +121,10 @@ import { TextType, type GraphNodeResponse } from '~/services/graph/index';
import type { ResultData } from '~/services/resultSet/index';
import ChatResultSet from './ChatResultSet.vue';
import { useChatStore } from '~/stores/chat';
import { groupWorkflowTimeline } from '~/utils/workflowTimeline';
import {
groupWorkflowTimeline,
segmentWorkflowContent,
} from '~/utils/workflowTimeline';

const store = useChatStore();

Expand Down Expand Up @@ -324,14 +335,6 @@ function escapeHtml(text: string): string {
const timelineRef = ref<HTMLElement | null>(null);
const { renderECharts } = useEchartsRenderer();

const CODE_TEXT_TYPES = new Set(['SQL', 'PYTHON', 'JSON']);

function isPureCodeBlock(block: GraphNodeResponse[]): boolean {
return (
block.length > 0 && block.every((n) => CODE_TEXT_TYPES.has(n.textType))
);
}

const SANITIZE_OPTIONS = {
ADD_TAGS: ['pre', 'code'],
ADD_ATTR: ['class'],
Expand Down
56 changes: 52 additions & 4 deletions data-agent-frontend-nuxt/app/utils/workflowTimeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,11 @@
*/

import { describe, expect, it } from 'vitest';
import { TextType, type GraphNodeResponse } from '../services/graph/index';
import {
TextType,
type GraphNodeResponse,
} from '../services/graph/index';
import { groupWorkflowTimeline } from './workflowTimeline';
groupWorkflowTimeline,
segmentWorkflowContent,
} from './workflowTimeline';

function event(
nodeName: string,
Expand Down Expand Up @@ -88,3 +88,51 @@ describe('groupWorkflowTimeline', () => {
expect(groups[0]?.items).toHaveLength(2);
});
});

describe('segmentWorkflowContent', () => {
it('keeps streamed SQL as a code segment between status messages', () => {
const segments = segmentWorkflowContent([
event('SqlGenerateNode', '开始生成SQL...\n'),
event('SqlGenerateNode', 'select', { textType: TextType.SQL }),
event('SqlGenerateNode', ' * from users', {
textType: TextType.SQL,
}),
event('SqlGenerateNode', 'SQL生成完成\n'),
]);

expect(segments.map((segment) => segment.kind)).toEqual([
'text',
'code',
'text',
]);
expect(segments[1]?.items.map((item) => item.text).join('')).toBe(
'select * from users',
);
});

it('keeps executed SQL separate from surrounding execution text', () => {
const segments = segmentWorkflowContent([
event('SqlExecuteNode', '开始执行SQL...\n'),
event('SqlExecuteNode', '执行SQL查询:\n'),
event('SqlExecuteNode', 'select 1\n', { textType: TextType.SQL }),
event('SqlExecuteNode', '执行SQL完成\n'),
event('SqlExecuteNode', 'SQL查询结果:\n'),
]);

expect(segments).toHaveLength(3);
expect(segments[0]?.items).toHaveLength(2);
expect(segments[1]?.kind).toBe('code');
expect(segments[1]?.items[0]?.textType).toBe(TextType.SQL);
expect(segments[2]?.items).toHaveLength(2);
});

it('does not merge adjacent code written in different languages', () => {
const segments = segmentWorkflowContent([
event('MixedNode', 'select 1', { textType: TextType.SQL }),
event('MixedNode', 'print(1)', { textType: TextType.PYTHON }),
]);

expect(segments).toHaveLength(2);
expect(segments.every((segment) => segment.kind === 'code')).toBe(true);
});
});
38 changes: 34 additions & 4 deletions data-agent-frontend-nuxt/app/utils/workflowTimeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,7 @@
* limitations under the License.
*/

import {
TextType,
type GraphNodeResponse,
} from '../services/graph/index';
import { TextType, type GraphNodeResponse } from '../services/graph/index';

export interface WorkflowStepGroup {
stepId: string;
Expand All @@ -26,6 +23,39 @@ export interface WorkflowStepGroup {
items: GraphNodeResponse[];
}

export interface WorkflowContentSegment {
kind: 'code' | 'text';
items: GraphNodeResponse[];
}

const CODE_TEXT_TYPES = new Set([TextType.SQL, TextType.PYTHON, TextType.JSON]);

/**
* Splits one node execution into renderable text and code segments. A workflow
* step normally contains status text before and after streamed code, so treating
* the whole step as plain text would discard the backend's SQL/Python/JSON type.
*/
export function segmentWorkflowContent(
items: GraphNodeResponse[],
): WorkflowContentSegment[] {
const segments: WorkflowContentSegment[] = [];

for (const item of items) {
const kind = CODE_TEXT_TYPES.has(item.textType) ? 'code' : 'text';
const previous = segments.at(-1);
const sameCodeType =
kind !== 'code' || previous?.items[0]?.textType === item.textType;

if (previous?.kind === kind && sameCodeType) {
previous.items.push(item);
} else {
segments.push({ kind, items: [item] });
}
}

return segments;
}

/**
* Groups workflow events by backend step identity. The fallback branch keeps persisted
* timelines from older versions readable by attaching a result-only block to the
Expand Down
Loading