发布 v1.10 #5
@@ -1154,7 +1154,7 @@ function executionTraceText(
|
|||||||
</section>
|
</section>
|
||||||
<section v-if="step.hasOutput">
|
<section v-if="step.hasOutput">
|
||||||
<h3>输出</h3>
|
<h3>输出</h3>
|
||||||
<pre>{{ formatExecutionValue(step.output) }}</pre>
|
<pre>{{ formatExecutionValue(step.output, true) }}</pre>
|
||||||
</section>
|
</section>
|
||||||
<section v-if="step.error">
|
<section v-if="step.error">
|
||||||
<h3>错误</h3>
|
<h3>错误</h3>
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { copyTextWithFeedback } from '#/utils/clipboard-feedback';
|
|||||||
import {
|
import {
|
||||||
buildWorkflowFinalOutputView,
|
buildWorkflowFinalOutputView,
|
||||||
formatWorkflowOutputPreview,
|
formatWorkflowOutputPreview,
|
||||||
|
sanitizeWorkflowOutputTextForDisplay,
|
||||||
serializeWorkflowOutput,
|
serializeWorkflowOutput,
|
||||||
} from './workflowFinalOutput';
|
} from './workflowFinalOutput';
|
||||||
|
|
||||||
@@ -118,6 +119,9 @@ function cellText(value: unknown) {
|
|||||||
if (value === null) {
|
if (value === null) {
|
||||||
return '空值';
|
return '空值';
|
||||||
}
|
}
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return sanitizeWorkflowOutputTextForDisplay(value);
|
||||||
|
}
|
||||||
return String(value);
|
return String(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
finalizeWorkflowExecutionSteps,
|
finalizeWorkflowExecutionSteps,
|
||||||
|
formatExecutionValue,
|
||||||
hydrateWorkflowExecutionSteps,
|
hydrateWorkflowExecutionSteps,
|
||||||
reduceWorkflowExecutionSteps,
|
reduceWorkflowExecutionSteps,
|
||||||
} from './workflowExecutionDetails';
|
} from './workflowExecutionDetails';
|
||||||
@@ -121,4 +122,14 @@ describe('workflowExecutionDetails', () => {
|
|||||||
status: 'cancelled',
|
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 {
|
export interface WorkflowExecutionTrace {
|
||||||
id: string;
|
id: string;
|
||||||
outcome: 'matched' | 'skipped';
|
outcome: 'matched' | 'skipped';
|
||||||
@@ -192,15 +194,21 @@ export function finalizeWorkflowExecutionSteps(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 格式化节点输入或输出。
|
* 格式化节点输入或输出。
|
||||||
|
*
|
||||||
|
* @param value 节点输入或输出
|
||||||
|
* @param sanitizeThinking 是否移除旧思考标签内容块
|
||||||
*/
|
*/
|
||||||
export function formatExecutionValue(value: unknown) {
|
export function formatExecutionValue(value: unknown, sanitizeThinking = false) {
|
||||||
if (typeof value === 'string') {
|
const displayValue = sanitizeThinking
|
||||||
return value;
|
? sanitizeWorkflowOutputForDisplay(value)
|
||||||
|
: value;
|
||||||
|
if (typeof displayValue === 'string') {
|
||||||
|
return displayValue;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
return JSON.stringify(value ?? null, null, 2);
|
return JSON.stringify(displayValue ?? null, null, 2);
|
||||||
} catch {
|
} catch {
|
||||||
return String(value ?? '');
|
return String(displayValue ?? '');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { describe, expect, it } from 'vitest';
|
|||||||
import {
|
import {
|
||||||
buildWorkflowFinalOutputView,
|
buildWorkflowFinalOutputView,
|
||||||
formatWorkflowOutputPreview,
|
formatWorkflowOutputPreview,
|
||||||
|
sanitizeWorkflowOutputForDisplay,
|
||||||
|
serializeWorkflowOutput,
|
||||||
} from './workflowFinalOutput';
|
} from './workflowFinalOutput';
|
||||||
|
|
||||||
describe('workflowFinalOutput', () => {
|
describe('workflowFinalOutput', () => {
|
||||||
@@ -68,12 +70,12 @@ describe('workflowFinalOutput', () => {
|
|||||||
const view = buildWorkflowFinalOutputView({
|
const view = buildWorkflowFinalOutputView({
|
||||||
image: {
|
image: {
|
||||||
contentType: 'image/png',
|
contentType: 'image/png',
|
||||||
fileName: 'result.png',
|
fileName: '<think>隐藏图片名</think>result.png',
|
||||||
filePath: '/files/result.png',
|
filePath: '/files/result.png',
|
||||||
},
|
},
|
||||||
report: {
|
report: {
|
||||||
contentType: 'application/pdf',
|
contentType: 'application/pdf',
|
||||||
fileName: 'report.pdf',
|
fileName: '<thinking>隐藏文件名</thinking>report.pdf',
|
||||||
filePath: '/files/report.pdf',
|
filePath: '/files/report.pdf',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -98,4 +100,71 @@ describe('workflowFinalOutput', () => {
|
|||||||
sections: [],
|
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',
|
'webp',
|
||||||
]);
|
]);
|
||||||
const TABLE_COLUMN_LIMIT = 8;
|
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 {
|
export function serializeWorkflowOutput(value: unknown): string {
|
||||||
if (typeof value === 'string') {
|
const sanitizedValue = sanitizeWorkflowOutputForDisplay(value);
|
||||||
return value;
|
if (typeof sanitizedValue === 'string') {
|
||||||
|
return sanitizedValue;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
return JSON.stringify(value, null, 2);
|
return JSON.stringify(sanitizedValue, null, 2);
|
||||||
} catch {
|
} catch {
|
||||||
return String(value ?? '');
|
return String(sanitizedValue ?? '');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,7 +264,9 @@ function resolveMedia(value: unknown) {
|
|||||||
const source = mediaSource(candidate);
|
const source = mediaSource(candidate);
|
||||||
images.push({
|
images.push({
|
||||||
mimeType: stringValue(candidate.contentType || candidate.mimeType),
|
mimeType: stringValue(candidate.contentType || candidate.mimeType),
|
||||||
name: mediaName(candidate, source, '图片'),
|
name: sanitizeWorkflowOutputTextForDisplay(
|
||||||
|
mediaName(candidate, source, '图片'),
|
||||||
|
),
|
||||||
previewUrl: source,
|
previewUrl: source,
|
||||||
size: numberValue(candidate.size),
|
size: numberValue(candidate.size),
|
||||||
status: 'ready',
|
status: 'ready',
|
||||||
@@ -211,7 +278,9 @@ function resolveMedia(value: unknown) {
|
|||||||
attachmentRef: stringValue(candidate.attachmentRef) || undefined,
|
attachmentRef: stringValue(candidate.attachmentRef) || undefined,
|
||||||
downloadUrl: source,
|
downloadUrl: source,
|
||||||
mimeType: stringValue(candidate.contentType || candidate.mimeType),
|
mimeType: stringValue(candidate.contentType || candidate.mimeType),
|
||||||
name: mediaName(candidate, source, '文件'),
|
name: sanitizeWorkflowOutputTextForDisplay(
|
||||||
|
mediaName(candidate, source, '文件'),
|
||||||
|
),
|
||||||
size: numberValue(candidate.size),
|
size: numberValue(candidate.size),
|
||||||
status: 'ready',
|
status: 'ready',
|
||||||
});
|
});
|
||||||
@@ -277,6 +346,9 @@ function formatScalar(value: unknown): string {
|
|||||||
if (value === null) {
|
if (value === null) {
|
||||||
return '空值';
|
return '空值';
|
||||||
}
|
}
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return sanitizeWorkflowOutputTextForDisplay(value);
|
||||||
|
}
|
||||||
return String(value);
|
return String(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -314,9 +386,10 @@ function boundedPreview(
|
|||||||
seen = new WeakSet<object>(),
|
seen = new WeakSet<object>(),
|
||||||
): unknown {
|
): unknown {
|
||||||
if (typeof value === 'string') {
|
if (typeof value === 'string') {
|
||||||
return value.length <= limits.stringLimit
|
const sanitizedValue = sanitizeWorkflowOutputTextForDisplay(value);
|
||||||
? value
|
return sanitizedValue.length <= limits.stringLimit
|
||||||
: `${value.slice(0, limits.stringLimit)}…`;
|
? sanitizedValue
|
||||||
|
: `${sanitizedValue.slice(0, limits.stringLimit)}…`;
|
||||||
}
|
}
|
||||||
if (isScalar(value) || value === undefined) {
|
if (isScalar(value) || value === undefined) {
|
||||||
return value;
|
return value;
|
||||||
|
|||||||
Reference in New Issue
Block a user