fix: 过滤工作流输出中的思考内容
- 统一清洗运行结果、详情、预览与复制文本 - 支持嵌套、混合及未闭合思考标签
This commit is contained in:
@@ -1154,7 +1154,7 @@ function executionTraceText(
|
||||
</section>
|
||||
<section v-if="step.hasOutput">
|
||||
<h3>输出</h3>
|
||||
<pre>{{ formatExecutionValue(step.output) }}</pre>
|
||||
<pre>{{ formatExecutionValue(step.output, true) }}</pre>
|
||||
</section>
|
||||
<section v-if="step.error">
|
||||
<h3>错误</h3>
|
||||
|
||||
@@ -21,6 +21,7 @@ import { copyTextWithFeedback } from '#/utils/clipboard-feedback';
|
||||
import {
|
||||
buildWorkflowFinalOutputView,
|
||||
formatWorkflowOutputPreview,
|
||||
sanitizeWorkflowOutputTextForDisplay,
|
||||
serializeWorkflowOutput,
|
||||
} from './workflowFinalOutput';
|
||||
|
||||
@@ -118,6 +119,9 @@ function cellText(value: unknown) {
|
||||
if (value === null) {
|
||||
return '空值';
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return sanitizeWorkflowOutputTextForDisplay(value);
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
finalizeWorkflowExecutionSteps,
|
||||
formatExecutionValue,
|
||||
hydrateWorkflowExecutionSteps,
|
||||
reduceWorkflowExecutionSteps,
|
||||
} from './workflowExecutionDetails';
|
||||
@@ -121,4 +122,14 @@ describe('workflowExecutionDetails', () => {
|
||||
status: 'cancelled',
|
||||
});
|
||||
});
|
||||
|
||||
it('filters thinking blocks only when formatting node outputs', () => {
|
||||
const value = {
|
||||
text: '<thinking>内部推理</thinking>最终答案',
|
||||
};
|
||||
|
||||
expect(formatExecutionValue(value)).toContain('内部推理');
|
||||
expect(formatExecutionValue(value, true)).toContain('最终答案');
|
||||
expect(formatExecutionValue(value, true)).not.toContain('内部推理');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { sanitizeWorkflowOutputForDisplay } from './workflowFinalOutput';
|
||||
|
||||
export interface WorkflowExecutionTrace {
|
||||
id: string;
|
||||
outcome: 'matched' | 'skipped';
|
||||
@@ -192,15 +194,21 @@ export function finalizeWorkflowExecutionSteps(
|
||||
|
||||
/**
|
||||
* 格式化节点输入或输出。
|
||||
*
|
||||
* @param value 节点输入或输出
|
||||
* @param sanitizeThinking 是否移除旧思考标签内容块
|
||||
*/
|
||||
export function formatExecutionValue(value: unknown) {
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
export function formatExecutionValue(value: unknown, sanitizeThinking = false) {
|
||||
const displayValue = sanitizeThinking
|
||||
? sanitizeWorkflowOutputForDisplay(value)
|
||||
: value;
|
||||
if (typeof displayValue === 'string') {
|
||||
return displayValue;
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value ?? null, null, 2);
|
||||
return JSON.stringify(displayValue ?? null, null, 2);
|
||||
} catch {
|
||||
return String(value ?? '');
|
||||
return String(displayValue ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
buildWorkflowFinalOutputView,
|
||||
formatWorkflowOutputPreview,
|
||||
sanitizeWorkflowOutputForDisplay,
|
||||
serializeWorkflowOutput,
|
||||
} from './workflowFinalOutput';
|
||||
|
||||
describe('workflowFinalOutput', () => {
|
||||
@@ -68,12 +70,12 @@ describe('workflowFinalOutput', () => {
|
||||
const view = buildWorkflowFinalOutputView({
|
||||
image: {
|
||||
contentType: 'image/png',
|
||||
fileName: 'result.png',
|
||||
fileName: '<think>隐藏图片名</think>result.png',
|
||||
filePath: '/files/result.png',
|
||||
},
|
||||
report: {
|
||||
contentType: 'application/pdf',
|
||||
fileName: 'report.pdf',
|
||||
fileName: '<thinking>隐藏文件名</thinking>report.pdf',
|
||||
filePath: '/files/report.pdf',
|
||||
},
|
||||
});
|
||||
@@ -98,4 +100,71 @@ describe('workflowFinalOutput', () => {
|
||||
sections: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('removes complete and unclosed thinking blocks from nested outputs', () => {
|
||||
const sanitized = sanitizeWorkflowOutputForDisplay({
|
||||
answer: '<think>内部推理</think>最终答案',
|
||||
items: ['前缀<THINKING>隐藏内容</THINKING>后缀', '<think>未闭合内容'],
|
||||
nested: {
|
||||
text: '可见内容</think>',
|
||||
},
|
||||
});
|
||||
|
||||
expect(sanitized).toEqual({
|
||||
answer: '最终答案',
|
||||
items: ['前缀后缀', ''],
|
||||
nested: {
|
||||
text: '可见内容',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('removes nested and mixed thinking blocks without exposing outer content', () => {
|
||||
const sanitized = sanitizeWorkflowOutputForDisplay({
|
||||
mixed: '<think>外层<thinking>内层</thinking>仍属外层</think>最终答案',
|
||||
same: '前缀<think>第一层<think>第二层</think>第一层结尾</think>后缀',
|
||||
unclosed: '<THINKING>隐藏<think>继续隐藏</think>仍未闭合',
|
||||
});
|
||||
|
||||
expect(sanitized).toEqual({
|
||||
mixed: '最终答案',
|
||||
same: '前缀后缀',
|
||||
unclosed: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('uses sanitized values for rendering previews and copy text', () => {
|
||||
const output = {
|
||||
answer: '<think>\n推理过程\n</think>\n最终答案',
|
||||
};
|
||||
const view = buildWorkflowFinalOutputView(output);
|
||||
|
||||
expect(view.sections[0]?.scalarText).toBe('\n最终答案');
|
||||
expect(formatWorkflowOutputPreview(output)).not.toContain('推理过程');
|
||||
expect(serializeWorkflowOutput(output)).not.toContain('推理过程');
|
||||
expect(serializeWorkflowOutput(output)).not.toContain('<think>');
|
||||
});
|
||||
|
||||
it('sanitizes only values reached by the bounded preview', () => {
|
||||
const nested: Record<string, unknown> = {
|
||||
visible: '<think>隐藏内容</think>展示内容',
|
||||
};
|
||||
for (let index = 1; index < 20; index += 1) {
|
||||
Object.defineProperty(nested, `field${index}`, {
|
||||
enumerable: true,
|
||||
get() {
|
||||
if (index >= 16) {
|
||||
throw new Error('不应访问预览范围外的数据');
|
||||
}
|
||||
return index;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const preview = formatWorkflowOutputPreview(nested);
|
||||
|
||||
expect(preview).toContain('展示内容');
|
||||
expect(preview).not.toContain('隐藏内容');
|
||||
expect(preview).toContain('另有 4 个字段');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,6 +41,70 @@ const IMAGE_EXTENSIONS = new Set([
|
||||
'webp',
|
||||
]);
|
||||
const TABLE_COLUMN_LIMIT = 8;
|
||||
const THINKING_TAG_PATTERN = /<\s*(?:(\/)\s*)?(?:thinking|think)\b[^>]*>/gi;
|
||||
|
||||
/**
|
||||
* 移除工作流展示文本中的旧思考标签内容块。
|
||||
*/
|
||||
export function sanitizeWorkflowOutputTextForDisplay(value: string): string {
|
||||
let cursor = 0;
|
||||
let thinkingDepth = 0;
|
||||
let sanitized = '';
|
||||
|
||||
for (const match of value.matchAll(THINKING_TAG_PATTERN)) {
|
||||
const matchIndex = match.index;
|
||||
if (thinkingDepth === 0) {
|
||||
sanitized += value.slice(cursor, matchIndex);
|
||||
}
|
||||
if (match[1]) {
|
||||
thinkingDepth = Math.max(0, thinkingDepth - 1);
|
||||
} else {
|
||||
thinkingDepth += 1;
|
||||
}
|
||||
cursor = matchIndex + match[0].length;
|
||||
}
|
||||
|
||||
if (thinkingDepth === 0) {
|
||||
sanitized += value.slice(cursor);
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归移除工作流展示值中的旧思考标签内容块。
|
||||
*/
|
||||
export function sanitizeWorkflowOutputForDisplay(
|
||||
value: unknown,
|
||||
seen = new WeakMap<object, unknown>(),
|
||||
): unknown {
|
||||
if (typeof value === 'string') {
|
||||
return sanitizeWorkflowOutputTextForDisplay(value);
|
||||
}
|
||||
if (!value || typeof value !== 'object') {
|
||||
return value;
|
||||
}
|
||||
const existing = seen.get(value);
|
||||
if (existing !== undefined) {
|
||||
return existing;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
const sanitized: unknown[] = [];
|
||||
seen.set(value, sanitized);
|
||||
value.forEach((item) => {
|
||||
sanitized.push(sanitizeWorkflowOutputForDisplay(item, seen));
|
||||
});
|
||||
return sanitized;
|
||||
}
|
||||
if (!isPlainObject(value)) {
|
||||
return value;
|
||||
}
|
||||
const sanitized: Record<string, unknown> = {};
|
||||
seen.set(value, sanitized);
|
||||
Object.entries(value).forEach(([key, item]) => {
|
||||
sanitized[key] = sanitizeWorkflowOutputForDisplay(item, seen);
|
||||
});
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将工作流顶级最终输出整理为稳定的展示分区。
|
||||
@@ -89,13 +153,14 @@ export function formatWorkflowOutputPreview(
|
||||
* 将完整输出序列化为复制文本。
|
||||
*/
|
||||
export function serializeWorkflowOutput(value: unknown): string {
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
const sanitizedValue = sanitizeWorkflowOutputForDisplay(value);
|
||||
if (typeof sanitizedValue === 'string') {
|
||||
return sanitizedValue;
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
return JSON.stringify(sanitizedValue, null, 2);
|
||||
} catch {
|
||||
return String(value ?? '');
|
||||
return String(sanitizedValue ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,7 +264,9 @@ function resolveMedia(value: unknown) {
|
||||
const source = mediaSource(candidate);
|
||||
images.push({
|
||||
mimeType: stringValue(candidate.contentType || candidate.mimeType),
|
||||
name: mediaName(candidate, source, '图片'),
|
||||
name: sanitizeWorkflowOutputTextForDisplay(
|
||||
mediaName(candidate, source, '图片'),
|
||||
),
|
||||
previewUrl: source,
|
||||
size: numberValue(candidate.size),
|
||||
status: 'ready',
|
||||
@@ -211,7 +278,9 @@ function resolveMedia(value: unknown) {
|
||||
attachmentRef: stringValue(candidate.attachmentRef) || undefined,
|
||||
downloadUrl: source,
|
||||
mimeType: stringValue(candidate.contentType || candidate.mimeType),
|
||||
name: mediaName(candidate, source, '文件'),
|
||||
name: sanitizeWorkflowOutputTextForDisplay(
|
||||
mediaName(candidate, source, '文件'),
|
||||
),
|
||||
size: numberValue(candidate.size),
|
||||
status: 'ready',
|
||||
});
|
||||
@@ -277,6 +346,9 @@ function formatScalar(value: unknown): string {
|
||||
if (value === null) {
|
||||
return '空值';
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return sanitizeWorkflowOutputTextForDisplay(value);
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
@@ -314,9 +386,10 @@ function boundedPreview(
|
||||
seen = new WeakSet<object>(),
|
||||
): unknown {
|
||||
if (typeof value === 'string') {
|
||||
return value.length <= limits.stringLimit
|
||||
? value
|
||||
: `${value.slice(0, limits.stringLimit)}…`;
|
||||
const sanitizedValue = sanitizeWorkflowOutputTextForDisplay(value);
|
||||
return sanitizedValue.length <= limits.stringLimit
|
||||
? sanitizedValue
|
||||
: `${sanitizedValue.slice(0, limits.stringLimit)}…`;
|
||||
}
|
||||
if (isScalar(value) || value === undefined) {
|
||||
return value;
|
||||
|
||||
Reference in New Issue
Block a user