refactor(engine): 重构Rhai表达式错误处理为枚举类型 fix(script_rhai): 修正脚本文件读取和执行失败的错误返回 perf(testrun): 优化前端测试面板日志去重和显示逻辑
373 lines
12 KiB
TypeScript
373 lines
12 KiB
TypeScript
/**
|
||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||
* SPDX-License-Identifier: MIT
|
||
*/
|
||
|
||
import { FC, useContext, useEffect, useRef, useState } from 'react';
|
||
|
||
import classnames from 'classnames';
|
||
import { useService, I18n } from '@flowgram.ai/free-layout-editor';
|
||
import { Button, SideSheet, Switch, Tag } from '@douyinfe/semi-ui';
|
||
import { IconClose, IconPlay, IconSpin } from '@douyinfe/semi-icons';
|
||
|
||
import { TestRunJsonInput } from '../testrun-json-input';
|
||
import { TestRunForm } from '../testrun-form';
|
||
import { NodeStatusGroup } from '../node-status-bar/group';
|
||
// 改为使用后端运行服务
|
||
import { CustomService } from '../../../services';
|
||
import { SidebarContext } from '../../../context';
|
||
import { IconCancel } from '../../../assets/icon-cancel';
|
||
|
||
import styles from './index.module.less';
|
||
|
||
interface TestRunSidePanelProps {
|
||
visible: boolean;
|
||
onCancel: () => void;
|
||
}
|
||
|
||
export const TestRunSidePanel: FC<TestRunSidePanelProps> = ({ visible, onCancel }) => {
|
||
const customService = useService(CustomService);
|
||
const { nodeId: sidebarNodeId, setNodeId } = useContext(SidebarContext);
|
||
|
||
const [isRunning, setRunning] = useState(false);
|
||
const [values, setValues] = useState<Record<string, unknown>>({});
|
||
const [errors, setErrors] = useState<string[]>();
|
||
const [result, setResult] = useState<
|
||
| {
|
||
ok?: boolean;
|
||
ctx: any;
|
||
logs: string[];
|
||
}
|
||
| undefined
|
||
>();
|
||
|
||
// 模式切换:SSE 流式 or 普通 HTTP
|
||
const [streamMode, _setStreamMode] = useState<boolean>(() => {
|
||
const saved = localStorage.getItem('testrun-stream-mode');
|
||
return saved ? JSON.parse(saved) : true;
|
||
});
|
||
const setStreamMode = (checked: boolean) => {
|
||
_setStreamMode(checked);
|
||
localStorage.setItem('testrun-stream-mode', JSON.stringify(checked));
|
||
};
|
||
// 当启用流式时,选择 WS 或 SSE(默认 SSE)
|
||
const [useWS, _setUseWS] = useState<boolean>(() => {
|
||
const saved = localStorage.getItem('testrun-ws-mode');
|
||
return saved ? JSON.parse(saved) : false;
|
||
});
|
||
const setUseWS = (checked: boolean) => {
|
||
_setUseWS(checked);
|
||
localStorage.setItem('testrun-ws-mode', JSON.stringify(checked));
|
||
};
|
||
|
||
// 流式渲染:实时上下文与日志
|
||
const [streamCtx, setStreamCtx] = useState<any | undefined>();
|
||
const [streamLogs, setStreamLogs] = useState<string[]>([]);
|
||
const cancelRef = useRef<(() => void) | null>(null);
|
||
|
||
// en - Use localStorage to persist the JSON mode state
|
||
const [inputJSONMode, _setInputJSONMode] = useState(() => {
|
||
const savedMode = localStorage.getItem('testrun-input-json-mode');
|
||
return savedMode ? JSON.parse(savedMode) : false;
|
||
});
|
||
|
||
const setInputJSONMode = (checked: boolean) => {
|
||
_setInputJSONMode(checked);
|
||
localStorage.setItem('testrun-input-json-mode', JSON.stringify(checked));
|
||
};
|
||
|
||
const extractErrorMsg = (logs: string[] | undefined): string | undefined => {
|
||
if (!logs || logs.length === 0) return undefined;
|
||
const patterns = [/failed/i, /error/i, /panic/i];
|
||
for (const line of logs) {
|
||
if (patterns.some((p) => p.test(line))) return line;
|
||
}
|
||
return logs[logs.length - 1];
|
||
};
|
||
|
||
const onTestRun = async () => {
|
||
if (isRunning) {
|
||
// 运行中,忽略重复点击
|
||
return;
|
||
}
|
||
setResult(undefined);
|
||
setErrors(undefined);
|
||
setStreamCtx(undefined);
|
||
setStreamLogs([]);
|
||
setRunning(true);
|
||
try {
|
||
// 运行前保存(静默),确保后端 YAML 与编辑器一致;若保存失败则不继续运行
|
||
const saved = await customService.save({ silent: true });
|
||
if (!saved) {
|
||
setErrors([I18n.t('Save failed, cannot run')]);
|
||
return;
|
||
}
|
||
|
||
if (streamMode) {
|
||
const startStream = () => useWS
|
||
? customService.runStreamWS(values, {
|
||
onNode: (evt) => {
|
||
if (evt.ctx) setStreamCtx((prev: any) => ({ ...(prev || {}), ...(evt.ctx || {}) }));
|
||
if (evt.logs && evt.logs.length) {
|
||
const normalizeLog = (s: string) => s.replace(/\r/g, '').trim();
|
||
const dedupLogs = (arr: string[]) => {
|
||
const seen = new Set<string>();
|
||
const res: string[] = [];
|
||
for (const s of arr) {
|
||
const key = normalizeLog(s);
|
||
if (!seen.has(key)) {
|
||
seen.add(key);
|
||
res.push(s);
|
||
}
|
||
}
|
||
return res;
|
||
};
|
||
const incoming = evt.logs!;
|
||
setStreamLogs((prev) => dedupLogs([...(prev || []), ...incoming]));
|
||
}
|
||
},
|
||
onError: (evt) => {
|
||
const msg = evt.message || I18n.t('Run failed');
|
||
setErrors((prev) => [...(prev || []), msg]);
|
||
},
|
||
onDone: (evt) => {
|
||
setResult({ ok: evt.ok, ctx: evt.ctx, logs: evt.logs });
|
||
},
|
||
onFatal: (err) => {
|
||
setErrors((prev) => [...(prev || []), err.message || String(err)]);
|
||
setRunning(false);
|
||
},
|
||
})
|
||
: customService.runStream(values, {
|
||
onNode: (evt) => {
|
||
if (evt.ctx) setStreamCtx((prev: any) => ({ ...(prev || {}), ...(evt.ctx || {}) }));
|
||
if (evt.logs && evt.logs.length) setStreamLogs((prev: string[]) => [...prev, ...evt.logs!]);
|
||
},
|
||
onError: (evt) => {
|
||
const msg = evt.message || I18n.t('Run failed');
|
||
setErrors((prev) => [...(prev || []), msg]);
|
||
},
|
||
onDone: (evt) => {
|
||
setResult({ ok: evt.ok, ctx: evt.ctx, logs: evt.logs });
|
||
},
|
||
onFatal: (err) => {
|
||
setErrors((prev) => [...(prev || []), err.message || String(err)]);
|
||
setRunning(false);
|
||
},
|
||
});
|
||
|
||
const { cancel, done } = startStream();
|
||
|
||
cancelRef.current = cancel;
|
||
|
||
const finished = await done;
|
||
if (finished) {
|
||
setResult(finished as any);
|
||
} else {
|
||
// 流结束但未收到 done 事件,给出提示
|
||
setErrors((prev) => [...(prev || []), I18n.t('Stream terminated without completion')]);
|
||
}
|
||
} else {
|
||
// 普通 HTTP 一次性运行
|
||
try {
|
||
const runRes = await customService.run(values);
|
||
if (runRes) {
|
||
if ((runRes as any).ok === false) {
|
||
setResult(runRes as any);
|
||
const err = extractErrorMsg((runRes as any).logs) || I18n.t('Run failed');
|
||
setErrors([err]);
|
||
} else {
|
||
setResult(runRes as any);
|
||
}
|
||
} else {
|
||
setErrors([I18n.t('Run failed')]);
|
||
}
|
||
} catch (e: any) {
|
||
setErrors([e?.message || I18n.t('Run failed')]);
|
||
}
|
||
}
|
||
} catch (e: any) {
|
||
setErrors([e?.message || I18n.t('Run failed')]);
|
||
} finally {
|
||
setRunning(false);
|
||
cancelRef.current = null;
|
||
}
|
||
};
|
||
|
||
const onCancelRun = () => {
|
||
try { cancelRef.current?.(); } catch {}
|
||
setRunning(false);
|
||
};
|
||
|
||
const onClose = async () => {
|
||
setValues({});
|
||
if (isRunning) {
|
||
if (streamMode) onCancelRun();
|
||
}
|
||
setRunning(false);
|
||
setStreamCtx(undefined);
|
||
setStreamLogs([]);
|
||
onCancel();
|
||
};
|
||
|
||
// 当测试运行面板打开时,自动关闭右侧节点编辑侧栏,避免两个 SideSheet 重叠
|
||
useEffect(() => {
|
||
if (visible) {
|
||
setNodeId(undefined);
|
||
}
|
||
}, [visible]);
|
||
|
||
useEffect(() => {
|
||
if (sidebarNodeId) {
|
||
onCancel();
|
||
}
|
||
}, [sidebarNodeId]);
|
||
|
||
const renderRunning = (
|
||
<div className={styles['testrun-panel-running']}>
|
||
<IconSpin spin size="large" />
|
||
<div className={styles.text}>{I18n.t('Running...')}</div>
|
||
{/* 实时输出(仅流式模式显示) */}
|
||
{streamMode && (
|
||
<>
|
||
{errors?.length ? (
|
||
<div className={styles.error}>
|
||
{errors.map((e) => (
|
||
<div key={e}>{e}</div>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
<NodeStatusGroup title={I18n.t('Context') + ' (Live)'} data={streamCtx} optional disableCollapse />
|
||
<NodeStatusGroup title={I18n.t('Logs') + ' (Live)'} data={streamLogs} optional disableCollapse />
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
|
||
const renderStatus = (
|
||
<div style={{ marginBottom: 8 }}>
|
||
{result?.ok === true && <Tag color="green">{I18n.t('Success')}</Tag>}
|
||
{(errors?.length || result?.ok === false) && (
|
||
<Tag color="red">{I18n.t('Failed')}</Tag>
|
||
)}
|
||
</div>
|
||
);
|
||
|
||
const renderForm = (
|
||
<div className={styles['testrun-panel-form']}>
|
||
<div className={styles['testrun-panel-input']}>
|
||
<div className={styles.title}>{I18n.t('Input Form')}</div>
|
||
<div className={styles.toggle}>
|
||
<div>{I18n.t('JSON Mode')}</div>
|
||
<Switch
|
||
checked={inputJSONMode}
|
||
onChange={(checked: boolean) => setInputJSONMode(checked)}
|
||
size="small"
|
||
/>
|
||
</div>
|
||
<div className={styles.toggle}>
|
||
<div>{I18n.t('Streaming Mode')}</div>
|
||
<Switch
|
||
checked={streamMode}
|
||
onChange={(checked: boolean) => setStreamMode(checked)}
|
||
size="small"
|
||
/>
|
||
</div>
|
||
{streamMode && (
|
||
<div className={styles.toggle}>
|
||
<div>WS</div>
|
||
<Switch
|
||
checked={useWS}
|
||
onChange={(checked: boolean) => setUseWS(checked)}
|
||
size="small"
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
{renderStatus}
|
||
{errors?.map((e) => (
|
||
<div className={styles.error} key={e}>
|
||
{e}
|
||
</div>
|
||
))}
|
||
{inputJSONMode ? (
|
||
<TestRunJsonInput values={values} setValues={setValues} />
|
||
) : (
|
||
<TestRunForm values={values} setValues={setValues} />
|
||
)}
|
||
{/* 运行中(流式)时,直接在表单区域下方展示实时输出,而不是覆盖整块内容 */}
|
||
{streamMode && isRunning && (
|
||
<>
|
||
<NodeStatusGroup title={I18n.t('Logs') + ' (Live)'} data={streamLogs} optional disableCollapse />
|
||
<NodeStatusGroup title={I18n.t('Context') + ' (Live)'} data={streamCtx} optional disableCollapse />
|
||
</>
|
||
)}
|
||
|
||
{/* 展示后端返回的执行信息:仅在非流式或流式已结束时显示,避免与实时输出重复 */}
|
||
{(!streamMode || !isRunning) && (
|
||
<>
|
||
<NodeStatusGroup title={I18n.t('Logs')} data={result?.logs} optional disableCollapse />
|
||
<NodeStatusGroup title={I18n.t('Context')} data={result?.ctx} optional disableCollapse />
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
|
||
const renderButton = (
|
||
<Button
|
||
onClick={isRunning ? (streamMode ? onCancelRun : undefined) : onTestRun}
|
||
disabled={isRunning && !streamMode}
|
||
icon={
|
||
// 仅用按钮转圈提示运行中
|
||
isRunning ? <IconSpin spin size="small" /> : <IconPlay size="small" />
|
||
}
|
||
className={classnames(styles.button, {
|
||
[styles.running]: isRunning,
|
||
[styles.default]: !isRunning,
|
||
})}
|
||
>
|
||
{isRunning ? I18n.t('Running...') : I18n.t('Test Run')}
|
||
</Button>
|
||
);
|
||
|
||
return (
|
||
<SideSheet
|
||
title={I18n.t('Test Run')}
|
||
visible={visible}
|
||
mask={false}
|
||
motion={false}
|
||
onCancel={onClose}
|
||
width={500}
|
||
headerStyle={{
|
||
display: 'none',
|
||
}}
|
||
bodyStyle={{
|
||
padding: 0,
|
||
}}
|
||
style={{
|
||
background: 'none',
|
||
boxShadow: 'none',
|
||
}}
|
||
>
|
||
<div className={styles['testrun-panel-container']}>
|
||
<div className={styles['testrun-panel-header']}>
|
||
<div className={styles['testrun-panel-title']}>{I18n.t('Test Run')}</div>
|
||
<Button
|
||
className={styles['testrun-panel-title']}
|
||
type="tertiary"
|
||
icon={<IconClose />}
|
||
size="small"
|
||
theme="borderless"
|
||
onClick={onClose}
|
||
/>
|
||
</div>
|
||
<div className={styles['testrun-panel-content']}>
|
||
{/* 始终展示表单与结果区域;运行中不再使用覆盖层 */}
|
||
{renderForm}
|
||
</div>
|
||
<div className={styles['testrun-panel-footer']}>{renderButton}</div>
|
||
</div>
|
||
</SideSheet>
|
||
);
|
||
};
|