feat(flow): 新增流式执行模式与SSE支持
新增流式执行模式,通过SSE实时推送节点执行事件与日志 重构HTTP执行器与中间件,提取通用HTTP客户端组件 优化前端测试面板,支持流式模式切换与实时日志展示 更新依赖版本并修复密码哈希的随机数生成器问题 修复前端节点类型映射问题,确保Code节点表单可用
This commit is contained in:
@ -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>
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@ -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
114
frontend/src/utils/sse.ts
Normal 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
@ -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 {}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user