发布 v1.10 #5

Merged
czm merged 147 commits from develop into main 2026-08-20 11:36:27 +08:00
6 changed files with 182 additions and 17 deletions
Showing only changes of commit 89824b5b9b - Show all commits

View File

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

View File

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

View File

@@ -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('内部推理');
});
});

View File

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

View File

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

View File

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