feat(flow): 新增分组执行与异步模式支持

refactor(executors): 将 Rhai 引擎评估逻辑迁移至 script_rhai 模块
docs: 添加 Flow 架构文档与示例 JSON
feat(i18n): 新增前端多语言支持
perf(axios): 优化 token 刷新与 401 处理逻辑
style: 统一代码格式化与简化条件判断
This commit is contained in:
2025-12-03 20:51:22 +08:00
parent a1b21e87b3
commit 75c6974a35
20 changed files with 1830 additions and 299 deletions

View File

@ -7,7 +7,7 @@ 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 { Button, SideSheet, Switch, Tag, Select, InputNumber } from '@douyinfe/semi-ui';
import { IconClose, IconPlay, IconSpin } from '@douyinfe/semi-icons';
import { TestRunJsonInput } from '../testrun-json-input';
@ -15,6 +15,7 @@ import { TestRunForm } from '../testrun-form';
import { NodeStatusGroup } from '../node-status-bar/group';
// 改为使用后端运行服务
import { CustomService } from '../../../services';
import { tr } from '../../../../utils/i18n';
import { SidebarContext } from '../../../context';
import { IconCancel } from '../../../assets/icon-cancel';
@ -76,6 +77,17 @@ export const TestRunSidePanel: FC<TestRunSidePanelProps> = ({ visible, onCancel
localStorage.setItem('testrun-input-json-mode', JSON.stringify(checked));
};
// 执行模式与并发上限(仅保存到设计 JSON不直接随运行请求发送
const [executionMode, setExecutionMode] = useState<'sync'|'async'|'queued'|'bounded'>(() => {
const saved = localStorage.getItem('testrun-execution-mode');
return (saved === 'async' || saved === 'queued' || saved === 'bounded') ? (saved as any) : 'sync';
});
const [concurrencyLimit, setConcurrencyLimit] = useState<number | undefined>(() => {
const saved = localStorage.getItem('testrun-concurrency-limit');
const num = saved ? Number(saved) : NaN;
return Number.isFinite(num) && num > 0 ? num : undefined;
});
const extractErrorMsg = (logs: string[] | undefined): string | undefined => {
if (!logs || logs.length === 0) return undefined;
const patterns = [/failed/i, /error/i, /panic/i];
@ -97,9 +109,9 @@ export const TestRunSidePanel: FC<TestRunSidePanelProps> = ({ visible, onCancel
setRunning(true);
try {
// 运行前保存(静默),确保后端 YAML 与编辑器一致;若保存失败则不继续运行
const saved = await customService.save({ silent: true });
const saved = await customService.save({ silent: true, executionMode, concurrencyLimit });
if (!saved) {
setErrors([I18n.t('Save failed, cannot run')]);
setErrors([tr('Save failed, cannot run')]);
return;
}
@ -127,7 +139,7 @@ export const TestRunSidePanel: FC<TestRunSidePanelProps> = ({ visible, onCancel
}
},
onError: (evt) => {
const msg = evt.message || I18n.t('Run failed');
const msg = evt.message || tr('Run failed');
setErrors((prev) => [...(prev || []), msg]);
},
onDone: (evt) => {
@ -144,7 +156,7 @@ export const TestRunSidePanel: FC<TestRunSidePanelProps> = ({ visible, onCancel
if (evt.logs && evt.logs.length) setStreamLogs((prev: string[]) => [...prev, ...evt.logs!]);
},
onError: (evt) => {
const msg = evt.message || I18n.t('Run failed');
const msg = evt.message || tr('Run failed');
setErrors((prev) => [...(prev || []), msg]);
},
onDone: (evt) => {
@ -165,7 +177,7 @@ export const TestRunSidePanel: FC<TestRunSidePanelProps> = ({ visible, onCancel
setResult(finished as any);
} else {
// 流结束但未收到 done 事件,给出提示
setErrors((prev) => [...(prev || []), I18n.t('Stream terminated without completion')]);
setErrors((prev) => [...(prev || []), tr('Stream terminated without completion')]);
}
} else {
// 普通 HTTP 一次性运行
@ -174,20 +186,20 @@ export const TestRunSidePanel: FC<TestRunSidePanelProps> = ({ visible, onCancel
if (runRes) {
if ((runRes as any).ok === false) {
setResult(runRes as any);
const err = extractErrorMsg((runRes as any).logs) || I18n.t('Run failed');
const err = extractErrorMsg((runRes as any).logs) || tr('Run failed');
setErrors([err]);
} else {
setResult(runRes as any);
}
} else {
setErrors([I18n.t('Run failed')]);
setErrors([tr('Run failed')]);
}
} catch (e: any) {
setErrors([e?.message || I18n.t('Run failed')]);
setErrors([e?.message || tr('Run failed')]);
}
}
} catch (e: any) {
setErrors([e?.message || I18n.t('Run failed')]);
setErrors([e?.message || tr('Run failed')]);
} finally {
setRunning(false);
cancelRef.current = null;
@ -226,7 +238,7 @@ export const TestRunSidePanel: FC<TestRunSidePanelProps> = ({ visible, onCancel
const renderRunning = (
<div className={styles['testrun-panel-running']}>
<IconSpin spin size="large" />
<div className={styles.text}>{I18n.t('Running...')}</div>
<div className={styles.text}>{tr('Running...')}</div>
{/* 实时输出(仅流式模式显示) */}
{streamMode && (
<>
@ -246,9 +258,9 @@ export const TestRunSidePanel: FC<TestRunSidePanelProps> = ({ visible, onCancel
const renderStatus = (
<div style={{ marginBottom: 8 }}>
{result?.ok === true && <Tag color="green">{I18n.t('Success')}</Tag>}
{result?.ok === true && <Tag color="green">{tr('Success')}</Tag>}
{(errors?.length || result?.ok === false) && (
<Tag color="red">{I18n.t('Failed')}</Tag>
<Tag color="red">{tr('Failed')}</Tag>
)}
</div>
);
@ -256,9 +268,9 @@ export const TestRunSidePanel: FC<TestRunSidePanelProps> = ({ visible, onCancel
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.title}>{tr('Input Form')}</div>
<div className={styles.toggle}>
<div>{I18n.t('JSON Mode')}</div>
<div>{tr('JSON Mode')}</div>
<Switch
checked={inputJSONMode}
onChange={(checked: boolean) => setInputJSONMode(checked)}
@ -266,16 +278,44 @@ export const TestRunSidePanel: FC<TestRunSidePanelProps> = ({ visible, onCancel
/>
</div>
<div className={styles.toggle}>
<div>{I18n.t('Streaming Mode')}</div>
<div>{tr('Streaming Mode')}</div>
<Switch
checked={streamMode}
onChange={(checked: boolean) => setStreamMode(checked)}
size="small"
/>
</div>
<div className={styles.toggle}>
<div>{tr('Execution Mode')}</div>
<Select
value={executionMode}
onChange={(v) => { const val = String(v) as any; setExecutionMode(val); localStorage.setItem('testrun-execution-mode', val); }}
size="small"
style={{ width: 160 }}
renderSelectedItem={(option: any) => option?.label ?? tr(String(option?.value || ''))}
>
<Select.Option value="sync">{tr('sync')}</Select.Option>
<Select.Option value="async">{tr('async')}</Select.Option>
<Select.Option value="queued">{tr('queued')}</Select.Option>
<Select.Option value="bounded">{tr('bounded')}</Select.Option>
</Select>
</div>
{executionMode === 'bounded' && (
<div className={styles.toggle}>
<div>{tr('Concurrency Limit')}</div>
<InputNumber
value={typeof concurrencyLimit === 'number' ? concurrencyLimit : undefined}
onChange={(v) => { const num = Number(v); const next = Number.isFinite(num) && num > 0 ? num : undefined; setConcurrencyLimit(next); localStorage.setItem('testrun-concurrency-limit', String(next ?? '')); }}
size="small"
min={1}
max={128}
style={{ width: 120 }}
/>
</div>
)}
{streamMode && (
<div className={styles.toggle}>
<div>WS</div>
<div>{tr('WS')}</div>
<Switch
checked={useWS}
onChange={(checked: boolean) => setUseWS(checked)}
@ -298,16 +338,16 @@ export const TestRunSidePanel: FC<TestRunSidePanelProps> = ({ visible, onCancel
{/* 运行中(流式)时,直接在表单区域下方展示实时输出,而不是覆盖整块内容 */}
{streamMode && isRunning && (
<>
<NodeStatusGroup title={I18n.t('Logs') + ' (Live)'} data={streamLogs} optional disableCollapse />
<NodeStatusGroup title={I18n.t('Context') + ' (Live)'} data={streamCtx} optional disableCollapse />
<NodeStatusGroup title={tr('Logs') + ' (Live)'} data={streamLogs} optional disableCollapse />
<NodeStatusGroup title={tr('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 />
<NodeStatusGroup title={tr('Logs')} data={result?.logs} optional disableCollapse />
<NodeStatusGroup title={tr('Context')} data={result?.ctx} optional disableCollapse />
</>
)}
</div>
@ -326,13 +366,13 @@ export const TestRunSidePanel: FC<TestRunSidePanelProps> = ({ visible, onCancel
[styles.default]: !isRunning,
})}
>
{isRunning ? I18n.t('Running...') : I18n.t('Test Run')}
{isRunning ? tr('Running...') : tr('Test Run')}
</Button>
);
return (
<SideSheet
title={I18n.t('Test Run')}
title={tr('Test Run')}
visible={visible}
mask={false}
motion={false}
@ -351,7 +391,7 @@ export const TestRunSidePanel: FC<TestRunSidePanelProps> = ({ visible, onCancel
>
<div className={styles['testrun-panel-container']}>
<div className={styles['testrun-panel-header']}>
<div className={styles['testrun-panel-title']}>{I18n.t('Test Run')}</div>
<div className={styles['testrun-panel-title']}>{tr('Test Run')}</div>
<Button
className={styles['testrun-panel-title']}
type="tertiary"

View File

@ -8,6 +8,7 @@ import { useState, useEffect } from 'react';
import { useRefresh } from '@flowgram.ai/free-layout-editor';
import { useClientContext } from '@flowgram.ai/free-layout-editor';
import { Tooltip, IconButton, Divider } from '@douyinfe/semi-ui';
import { tr } from '../../../utils/i18n';
import { IconUndo, IconRedo, IconChevronLeft, IconEdit } from '@douyinfe/semi-icons';
import { TestRunButton } from '../testrun/testrun-button';
@ -172,7 +173,7 @@ export const FlowTools = () => {
{flowCode ? <span className="code">{flowCode}</span> : null}
{flowName ? <span className="name" title={flowName}>{flowName}</span> : null}
<span className="actions">
<Tooltip content={I18n.t('Edit Base Info')}>
<Tooltip content={tr('Edit Base Info')}>
<IconButton type="tertiary" theme="borderless" icon={<IconEdit />} onClick={openBaseInfo} />
</Tooltip>
</span>
@ -182,7 +183,7 @@ export const FlowTools = () => {
<ToolContainer className="flow-tools">
<ToolSection>
{/* 返回列表 */}
<Tooltip content={I18n.t('Back to List')}>
<Tooltip content={tr('Back to List')}>
<IconButton
type="tertiary"
theme="borderless"
@ -200,7 +201,7 @@ export const FlowTools = () => {
<Minimap visible={minimapVisible} />
<Readonly />
<Comment />
<Tooltip content={I18n.t('Undo')}>
<Tooltip content={tr('Undo')}>
<IconButton
type="tertiary"
theme="borderless"
@ -209,7 +210,7 @@ export const FlowTools = () => {
onClick={() => history.undo()}
/>
</Tooltip>
<Tooltip content={I18n.t('Redo')}>
<Tooltip content={tr('Redo')}>
<IconButton
type="tertiary"
theme="borderless"
@ -230,7 +231,7 @@ export const FlowTools = () => {
{/* 基础信息弹窗(对齐新建流程表单) */}
<AModal
title={`${I18n.t('Edit Base Info')}${baseForm.getFieldValue('name') ? ' - ' + baseForm.getFieldValue('name') : ''}`}
title={`${tr('Edit Base Info')}${baseForm.getFieldValue('name') ? ' - ' + baseForm.getFieldValue('name') : ''}`}
open={baseOpen}
onOk={handleBaseOk}
confirmLoading={baseLoading}
@ -240,14 +241,14 @@ export const FlowTools = () => {
destroyOnHidden
>
<AForm form={baseForm} layout="vertical" preserve={false}>
<AForm.Item name="name" label={I18n.t('Flow Name')} rules={[{ required: true, message: I18n.t('Please input flow name') }, { max: 50, message: I18n.t('Max 50 characters') }]}>
<AInput placeholder={I18n.t('Please input flow name')} allowClear />
<AForm.Item name="name" label={tr('Flow Name')} rules={[{ required: true, message: tr('Please input flow name') }, { max: 50, message: tr('Max 50 characters') }]}>
<AInput placeholder={tr('Please input flow name')} allowClear />
</AForm.Item>
<AForm.Item name="code" label={I18n.t('Flow Code')} rules={[{ required: true, message: I18n.t('Please input flow code') }, { max: 50, message: I18n.t('Max 50 characters') }]}>
<AInput placeholder={I18n.t('Required, recommend letters/numbers/-/_')} allowClear />
<AForm.Item name="code" label={tr('Flow Code')} rules={[{ required: true, message: tr('Please input flow code') }, { max: 50, message: tr('Max 50 characters') }]}>
<AInput placeholder={tr('Required, recommend letters/numbers/-/_')} allowClear />
</AForm.Item>
<AForm.Item name="remark" label={I18n.t('Remark')} rules={[{ max: 255, message: I18n.t('Max 255 characters') }]}>
<AInput.TextArea rows={3} placeholder={I18n.t('Optional, remark info')} allowClear />
<AForm.Item name="remark" label={tr('Remark')} rules={[{ max: 255, message: tr('Max 255 characters') }]}>
<AInput.TextArea rows={3} placeholder={tr('Optional, remark info')} allowClear />
</AForm.Item>
</AForm>
</AModal>

View File

@ -11,6 +11,7 @@ import {
WorkflowDocument,
} from '@flowgram.ai/free-layout-editor';
import { Toast } from '@douyinfe/semi-ui';
import { tr } from '../../utils/i18n';
import { I18n } from '@flowgram.ai/free-layout-editor';
import api, { type ApiResp } from '../../utils/axios';
import { stringifyFlowDoc } from '../utils/yaml';
@ -73,22 +74,29 @@ export class CustomService {
@inject(WorkflowDocument) document!: WorkflowDocument;
// 新增可选参数,用于静默保存时不弹出 Toast并返回是否保存成功
async save(opts?: { silent?: boolean }): Promise<boolean> {
async save(opts?: { silent?: boolean; executionMode?: 'sync'|'async'|'queued'|'bounded'; concurrencyLimit?: number }): Promise<boolean> {
const silent = !!opts?.silent;
try {
const id = getFlowIdFromUrl();
if (!id) {
if (!silent) Toast.error(I18n.t('Flow ID is missing, cannot save'));
if (!silent) Toast.error(tr('Flow ID is missing, cannot save'));
return false;
}
const json = this.document.toJSON() as any;
// 在根级写入执行配置与后端契约保持一致executionMode/concurrencyLimit
if (opts?.executionMode) {
try { json.executionMode = opts.executionMode; } catch {}
}
if (typeof opts?.concurrencyLimit === 'number') {
try { json.concurrencyLimit = opts.concurrencyLimit; } catch {}
}
const yaml = stringifyFlowDoc(json);
// 使用转换后的 design_json以便后端根据语言选择正确的执行器
const designForBackend = transformDesignJsonForBackend(json);
const design_json = JSON.stringify(designForBackend);
const { data } = await api.put<ApiResp<{ saved: boolean }>>(`/flows/${id}`, { yaml, design_json });
if (data?.code === 0) {
if (!silent) Toast.success(I18n.t('Saved'));
if (!silent) Toast.success(tr('Saved'));
try {
const key = (() => {
const hash = window.location.hash || '';
@ -101,12 +109,12 @@ export class CustomService {
} catch {}
return true;
} else {
const msg = data?.message || I18n.t('Save failed');
const msg = data?.message || tr('Save failed');
if (!silent) Toast.error(msg);
return false;
}
} catch (e: any) {
const msg = e?.message || I18n.t('Save failed');
const msg = e?.message || tr('Save failed');
if (!silent) Toast.error(msg);
return false;
}
@ -116,16 +124,16 @@ export class CustomService {
try {
const id = getFlowIdFromUrl();
if (!id) {
Toast.error(I18n.t('Flow ID is missing, cannot run'));
Toast.error(tr('Flow ID is missing, cannot run'));
return null;
}
const { data } = await api.post<ApiResp<RunResult>>(`/flows/${id}/run`, { input });
if (data?.code === 0) {
return data.data;
}
throw new Error(data?.message || I18n.t('Run failed'));
throw new Error(data?.message || tr('Run failed'));
} catch (e: any) {
Toast.error(e?.message || I18n.t('Run failed'));
Toast.error(e?.message || tr('Run failed'));
return null;
}
}
@ -142,7 +150,7 @@ export class CustomService {
) {
const id = getFlowIdFromUrl();
if (!id) {
const err = new Error(I18n.t('Flow ID is missing, cannot run'));
const err = new Error(tr('Flow ID is missing, cannot run'));
handlers?.onFatal?.(err);
return { cancel: () => {}, done: Promise.resolve<RunResult | null>(null) } as const;
}

View File

@ -14,6 +14,58 @@ const api: AxiosInstance = axios.create({ baseURL: configuredBase ? `${configure
let isRefreshing = false
let pendingQueue: { resolve: () => void; reject: (e: unknown) => void; config: RetryConfig }[] = []
function redirectToLogin(msg?: string): Promise<never> {
clearToken()
if (typeof window !== 'undefined' && window.location.pathname !== '/login') {
window.location.href = '/login'
}
return Promise.reject(new Error(msg || '未登录或登录已过期'))
}
async function tryRefreshAndRetry(original: RetryConfig, message?: string): Promise<AxiosResponse<any>> {
// 已重试过,直接拒绝
if (original._retry) {
return Promise.reject(new Error(message || '未授权'))
}
original._retry = true
const hasToken = !!getToken()
if (!hasToken) {
return redirectToLogin(message)
}
if (!isRefreshing) {
isRefreshing = true
try {
const { data } = await api.get<ApiResp<{ access_token: string }>>('/auth/refresh')
if (data?.code === 0) {
const access = data.data?.access_token
if (access) setToken(access)
pendingQueue.forEach(p => p.resolve())
pendingQueue = []
return api(original)
}
// 刷新失败,走登录
pendingQueue.forEach(p => p.reject(new Error(data?.message || 'refresh failed')))
pendingQueue = []
return redirectToLogin(data?.message)
} catch (e: any) {
pendingQueue.forEach(p => p.reject(e))
pendingQueue = []
clearToken()
if (typeof window !== 'undefined' && window.location.pathname !== '/login') {
window.location.href = '/login'
}
return Promise.reject(e)
} finally {
isRefreshing = false
}
}
return new Promise<void>((resolve, reject) => {
pendingQueue.push({ resolve: () => resolve(), reject: (e: unknown) => reject(e as unknown), config: original })
}).then(() => api(original))
}
api.interceptors.request.use((config: RetryConfig) => {
const token = getToken()
if (token) {
@ -30,7 +82,21 @@ api.interceptors.request.use((config: RetryConfig) => {
})
api.interceptors.response.use(
(r: AxiosResponse) => r,
async (r: AxiosResponse<ApiResp<unknown>>) => {
// 业务层 401HTTP 200, body.code=401统一处理为未授权
const body = r?.data as ApiResp<unknown>
if (body && typeof body.code === 'number' && body.code === 401) {
const original = (r.config || {}) as RetryConfig
const reqUrl = (original?.url || '').toString()
// 登录接口返回 401不做 refresh不跳转
if (reqUrl.includes('/auth/login')) {
const msg = body?.message || '未登录或登录已过期'
return Promise.reject(new Error(msg))
}
return tryRefreshAndRetry(original, body?.message)
}
return r
},
async (error: AxiosError<ApiResp<unknown>>) => {
const original = (error.config || {}) as RetryConfig
const status = error.response?.status
@ -52,12 +118,7 @@ api.interceptors.response.use(
const hasToken = !!getToken()
if (!hasToken) {
// 没有 token 的 401如果不在登录页则跳转到登录页否则仅抛错以便界面提示
if (typeof window !== 'undefined' && window.location.pathname !== '/login') {
window.location.href = '/login'
}
const msg = resp?.message || '未登录或登录已过期'
return Promise.reject(new Error(msg))
return redirectToLogin(resp?.message)
}
// 有 token 的 401尝试刷新
@ -99,4 +160,4 @@ api.interceptors.response.use(
}
)
export default api
export default api

View File

@ -0,0 +1,44 @@
export function tr(s: string): string {
const lang = (localStorage.getItem('lang') === 'en') ? 'en' : 'zh';
if (lang === 'en') return s;
const map: Record<string, string> = {
'Test Run': '测试运行',
'Running...': '运行中...',
'JSON Mode': 'JSON 模式',
'Streaming Mode': '流式模式',
'WS': 'WS',
'Execution Mode': '执行模式',
'Concurrency Limit': '并发上限',
'Success': '成功',
'Failed': '失败',
'Context': '上下文',
'Logs': '日志',
'Input Form': '输入表单',
'Back to List': '返回列表',
'Edit Base Info': '编辑基础信息',
'Please input flow name': '请输入流程名称',
'Flow Name': '流程名称',
'Flow Code': '流程编号',
'Remark': '备注',
'Max 50 characters': '最多50个字符',
'Max 255 characters': '最多255个字符',
'Required, recommend letters/numbers/-/_': '必填,建议使用字母/数字/-/_',
'Optional, remark info': '可选,备注信息',
'Save': '保存',
'Undo': '撤销',
'Redo': '重做',
'Saved': '已保存',
'Save failed': '保存失败',
'Save failed, cannot run': '保存失败,无法运行',
'Run failed': '运行失败',
'Flow ID is missing, cannot save': '缺少流程 ID无法保存',
'Flow ID is missing, cannot run': '缺少流程 ID无法运行',
'Stream terminated without completion': '流结束但未完成',
// Execution modes
'sync': '同步',
'async': '异步',
'queued': '队列',
'bounded': '限并发',
};
return map[s] ?? s;
}

File diff suppressed because one or more lines are too long