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;
}