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>

View File

@ -73,6 +73,8 @@ export function Editor() {
const parsed = parseFlowYaml(payload?.yaml || '')
nextDoc = parsed?.doc as any
}
// 新增:将后端的 design_json 节点类型从 javascript 还原为 code确保前端能够使用 Code 节点表单
nextDoc = transformDesignJsonFromBackend(nextDoc)
// 兜底:如果后端没有任何流程数据(空 YAML/空 design_json使用最小流程包含开始与结束
if (!nextDoc || !Array.isArray((nextDoc as any).nodes) || (nextDoc as any).nodes.length === 0) {
if (mounted) setDoc(MINIMAL_DOC)
@ -110,3 +112,21 @@ export function Editor() {
}
export default Editor;
// 新增:将后端存储的 javascript 类型还原为前端 UI 的 code 类型
function transformDesignJsonFromBackend(json: any): any {
try {
const clone = JSON.parse(JSON.stringify(json));
if (Array.isArray(clone?.nodes)) {
clone.nodes = clone.nodes.map((n: any) => {
if (n && n.type === 'javascript') {
return { ...n, type: 'code' };
}
return n;
});
}
return clone;
} catch {
return json;
}
}

View File

@ -366,6 +366,7 @@ export function useEditorProps(
'Running...': '运行中...',
'Input Form': '输入表单',
'JSON Mode': 'JSON 模式',
'Streaming Mode': '流式模式',
'Context': '上下文',
'Logs': '日志',
'Please input integer': '请输入整数',
@ -427,6 +428,7 @@ export function useEditorProps(
'Rows': 'Rows',
'First Row': 'First Row',
'Affected Rows': 'Affected Rows',
'Streaming Mode': 'Streaming Mode',
},
},
},

View File

@ -14,9 +14,16 @@ import { Toast } from '@douyinfe/semi-ui';
import { I18n } from '@flowgram.ai/free-layout-editor';
import api, { type ApiResp } from '../../utils/axios';
import { stringifyFlowDoc } from '../utils/yaml';
import { postSSE } from '../../utils/sse';
interface RunResult { ok: boolean; ctx: any; logs: string[] }
// 与后端 StreamEvent 保持一致serde(tag = "type")
export type StreamEvent =
| { type: 'node'; node_id?: string; ctx?: any; logs?: string[] }
| { type: 'done'; ok: boolean; ctx: any; logs: string[] }
| { type: 'error'; message: string };
// 兼容 BrowserRouter 与 HashRouter优先从 search 获取,若无则从 hash 的查询串中获取
function getFlowIdFromUrl(): string {
const searchId = new URLSearchParams(window.location.search).get('id');
@ -116,4 +123,40 @@ export class CustomService {
return null;
}
}
// 新增SSE 流式运行,返回取消函数与完成 Promise
runStream(input: any = {}, handlers?: { onNode?: (e: StreamEvent & { type: 'node' }) => void; onDone?: (e: StreamEvent & { type: 'done' }) => void; onError?: (e: StreamEvent & { type: 'error' }) => void; onFatal?: (err: Error) => void; }) {
const id = getFlowIdFromUrl();
if (!id) {
const err = new Error(I18n.t('Flow ID is missing, cannot run'));
handlers?.onFatal?.(err);
return { cancel: () => {}, done: Promise.resolve<RunResult | null>(null) } as const;
}
const base = (api.defaults.baseURL || '') as string;
const url = base ? `${base}/flows/${id}/run/stream` : `/flows/${id}/run/stream`;
const { cancel, done } = postSSE<RunResult | null>(url, { input }, {
onMessage: (json: any) => {
try {
const evt = json as StreamEvent
if (evt.type === 'node') {
handlers?.onNode?.(evt as any)
return undefined
}
if (evt.type === 'error') {
handlers?.onError?.(evt as any)
return undefined
}
if (evt.type === 'done') {
handlers?.onDone?.(evt as any)
return { ok: evt.ok, ctx: evt.ctx, logs: evt.logs }
}
} catch (_) {}
return undefined
},
onFatal: (e) => handlers?.onFatal?.(e),
})
return { cancel, done } as const;
}
}

114
frontend/src/utils/sse.ts Normal file
View File

@ -0,0 +1,114 @@
import api, { type ApiResp } from './axios'
import { getToken } from './token'
export interface PostSSEHandlers<T> {
onMessage: (json: any) => T | void
onFatal?: (err: Error) => void
headers?: Record<string, string>
}
// 通用:带鉴权与一次性 refresh 的 POST SSE 工具
export function postSSE<T = unknown>(url: string, body: unknown, handlers: PostSSEHandlers<T>) {
const controller = new AbortController()
let aborted = false
const doFetch = async (): Promise<Response> => {
const token = getToken()
const baseHeaders: Record<string, string> = { 'Content-Type': 'application/json' }
if (token) baseHeaders['Authorization'] = `Bearer ${token}`
const headers = { ...baseHeaders, ...(handlers.headers || {}) }
const resp = await fetch(url, {
method: 'POST',
headers,
body: JSON.stringify(body ?? {}),
signal: controller.signal,
credentials: 'include',
})
if (resp.status === 401) {
try {
const { data } = await api.get<ApiResp<{ access_token: string }>>('/auth/refresh')
if (data?.code === 0) {
const token2 = getToken()
if (token2) headers['Authorization'] = `Bearer ${token2}`
return await fetch(url, {
method: 'POST',
headers,
body: JSON.stringify(body ?? {}),
signal: controller.signal,
credentials: 'include',
})
}
} catch {}
}
return resp
}
const done = (async (): Promise<T | null> => {
try {
const resp = await doFetch()
if (!resp.ok || !resp.body) {
try {
const data = await resp.json()
const msg = (data && (data.message || data.msg)) || `SSE request failed: ${resp.status}`
throw new Error(msg)
} catch {
throw new Error(`SSE request failed: ${resp.status}`)
}
}
const reader = resp.body.getReader()
const decoder = new TextDecoder('utf-8')
let buffer = ''
const flush = (chunk: string): T | null => {
buffer += chunk
const parts = buffer.split(/\n\n/)
buffer = parts.pop() || ''
for (const part of parts) {
const dataLines = part
.split(/\n/)
.filter((l) => l.startsWith('data:'))
.map((l) => l.slice(5).trimStart())
if (!dataLines.length) continue
// 兼容 CRLF去除行尾的 \r整体 trim 以防止 JSON.parse 失败
const payloadRaw = dataLines.join('\n')
const payload = payloadRaw.replace(/\r+$/g, '').trim()
try {
const json = JSON.parse(payload)
const ret = handlers.onMessage(json)
if (typeof ret !== 'undefined') {
// 收到终止信号:主动中止连接,避免悬挂
aborted = true
try { controller.abort() } catch {}
return ret as T
}
} catch (_) {
// 单条事件解析失败:忽略
}
}
return null
}
while (!aborted) {
const { value, done } = await reader.read()
if (done) break
const text = decoder.decode(value, { stream: true })
const ret = flush(text)
if (ret !== null) return ret
}
return null
} catch (e: any) {
// 发生致命错误:通知回调并确保中止连接
try { controller.abort() } catch {}
aborted = true
handlers.onFatal?.(e instanceof Error ? e : new Error(String(e)))
return null
}
})()
const cancel = () => {
aborted = true
try { controller.abort() } catch {}
}
return { cancel, done } as const
}

File diff suppressed because one or more lines are too long

View File

@ -8,7 +8,7 @@ export default defineConfig(({ mode }) => {
const env = loadEnv(mode, '.', '')
const port = Number(env.VITE_PORT || 5173)
const open = String(env.VITE_OPEN ?? 'true').toLowerCase() === 'true' || env.VITE_OPEN === '1'
const proxyTarget = env.VITE_ADMIN_PROXY_PATH || 'http://127.0.0.1:8080'
const proxyTarget = env.VITE_ADMIN_PROXY_PATH || 'http://127.0.0.1:9898'
return {
plugins: [
@ -30,7 +30,50 @@ export default defineConfig(({ mode }) => {
proxy: {
'/api': {
target: proxyTarget,
changeOrigin: true
changeOrigin: true,
// 为 SSE 透传加固:禁用超时并保持连接
proxyTimeout: 0,
timeout: 0,
headers: { 'Connection': 'keep-alive' },
// 关键:在 dev 代理层面禁止缓冲/缓存,强制以 chunk 方式向浏览器侧回传,避免一次性聚合
configure: (proxy: any) => {
// 移除 Accept-Encoding避免后端压缩导致中间件缓冲
proxy.on('proxyReq', (proxyReq: any, req: any) => {
const url: string = req?.url || ''
if (url.includes('/run/stream')) {
try {
if (typeof proxyReq.removeHeader === 'function') proxyReq.removeHeader('accept-encoding')
proxyReq.setHeader('accept', 'text/event-stream')
proxyReq.setHeader('connection', 'keep-alive')
} catch {}
}
})
proxy.on('proxyRes', (proxyRes: any, req: any, res: any) => {
const url: string = req?.url || ''
const ct: string = String(proxyRes.headers?.['content-type'] || '')
const isSse = url.includes('/run/stream') || ct.includes('text/event-stream')
if (!isSse) return
try {
// 直接改写后端返回头,确保为 SSE 且无长度/压缩
proxyRes.headers['content-type'] = 'text/event-stream; charset=utf-8'
proxyRes.headers['cache-control'] = 'no-cache'
proxyRes.headers['pragma'] = 'no-cache'
proxyRes.headers['x-accel-buffering'] = 'no'
delete proxyRes.headers['content-length']
delete proxyRes.headers['content-encoding']
// 同步确保 devServer 给浏览器的头一致,并尽早发送
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8')
res.setHeader('Cache-Control', 'no-cache')
res.setHeader('Pragma', 'no-cache')
res.setHeader('X-Accel-Buffering', 'no')
if (typeof res.removeHeader === 'function') res.removeHeader('Content-Length')
if (typeof res.removeHeader === 'function') res.removeHeader('Content-Encoding')
if (typeof res.flushHeaders === 'function') res.flushHeaders()
} catch {}
})
}
}
}
}