feat(flow): 新增流式执行模式与SSE支持

新增流式执行模式,通过SSE实时推送节点执行事件与日志
重构HTTP执行器与中间件,提取通用HTTP客户端组件
优化前端测试面板,支持流式模式切换与实时日志展示
更新依赖版本并修复密码哈希的随机数生成器问题
修复前端节点类型映射问题,确保Code节点表单可用
This commit is contained in:
2025-09-21 01:48:24 +08:00
parent 296f0ae9f6
commit dd7857940f
24 changed files with 1695 additions and 885 deletions

View File

@ -3,7 +3,7 @@
* SPDX-License-Identifier: MIT
*/
import { FC, useContext, useEffect, useState } from 'react';
import { FC, useContext, useEffect, useRef, useState } from 'react';
import classnames from 'classnames';
import { useService, I18n } from '@flowgram.ai/free-layout-editor';
@ -41,6 +41,21 @@ export const TestRunSidePanel: FC<TestRunSidePanelProps> = ({ visible, onCancel
| 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));
};
// 流式渲染:实时上下文与日志
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');
@ -63,11 +78,13 @@ export const TestRunSidePanel: FC<TestRunSidePanelProps> = ({ visible, onCancel
const onTestRun = async () => {
if (isRunning) {
// 后端运行不可取消,这里直接忽略重复点击
// 运行中,忽略重复点击
return;
}
setResult(undefined);
setErrors(undefined);
setStreamCtx(undefined);
setStreamLogs([]);
setRunning(true);
try {
// 运行前保存(静默),确保后端 YAML 与编辑器一致;若保存失败则不继续运行
@ -76,29 +93,75 @@ export const TestRunSidePanel: FC<TestRunSidePanelProps> = ({ visible, onCancel
setErrors([I18n.t('Save failed, cannot run')]);
return;
}
const runRes = await customService.run(values);
if (runRes) {
// 若后端返回 ok=false则视为失败并展示失败信息与日志
if ((runRes as any).ok === false) {
setResult(runRes as any);
const err = extractErrorMsg((runRes as any).logs) || I18n.t('Run failed');
setErrors([err]);
if (streamMode) {
const { cancel, done } = 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);
},
});
cancelRef.current = cancel;
const finished = await done;
if (finished) {
setResult(finished as any);
} else {
setResult(runRes as any);
// 流结束但未收到 done 事件,给出提示
setErrors((prev) => [...(prev || []), I18n.t('Stream terminated without completion')]);
}
} else {
setErrors([I18n.t('Run failed')]);
// 普通 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();
};
@ -119,6 +182,20 @@ export const TestRunSidePanel: FC<TestRunSidePanelProps> = ({ visible, onCancel
<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>
);
@ -141,6 +218,12 @@ export const TestRunSidePanel: FC<TestRunSidePanelProps> = ({ visible, onCancel
onChange={(checked: boolean) => setInputJSONMode(checked)}
size="small"
/>
<div>{I18n.t('Streaming Mode')}</div>
<Switch
checked={streamMode}
onChange={(checked: boolean) => setStreamMode(checked)}
size="small"
/>
</div>
{renderStatus}
{errors?.map((e) => (
@ -153,6 +236,13 @@ export const TestRunSidePanel: FC<TestRunSidePanelProps> = ({ visible, onCancel
) : (
<TestRunForm values={values} setValues={setValues} />
)}
{/* 运行中(流式)时,直接在表单区域下方展示实时输出,而不是覆盖整块内容 */}
{streamMode && isRunning && (
<>
<NodeStatusGroup title={I18n.t('Context') + ' (Live)'} data={streamCtx} optional disableCollapse />
<NodeStatusGroup title={I18n.t('Logs') + ' (Live)'} data={streamLogs} optional disableCollapse />
</>
)}
{/* 展示后端返回的执行信息 */}
<NodeStatusGroup title={I18n.t('Context')} data={result?.ctx} optional disableCollapse />
<NodeStatusGroup title={I18n.t('Logs')} data={result?.logs} optional disableCollapse />
@ -161,8 +251,12 @@ export const TestRunSidePanel: FC<TestRunSidePanelProps> = ({ visible, onCancel
const renderButton = (
<Button
onClick={onTestRun}
icon={isRunning ? <IconCancel /> : <IconPlay size="small" />}
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,
@ -204,7 +298,8 @@ export const TestRunSidePanel: FC<TestRunSidePanelProps> = ({ visible, onCancel
/>
</div>
<div className={styles['testrun-panel-content']}>
{isRunning ? renderRunning : renderForm}
{/* 始终展示表单与结果区域;运行中不再使用覆盖层 */}
{renderForm}
</div>
<div className={styles['testrun-panel-footer']}>{renderButton}</div>
</div>