/** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { injectable, inject } from '@flowgram.ai/free-layout-editor'; import { FreeLayoutPluginContext, SelectionService, Playground, 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'; import { postSSE } from '../../utils/sse'; import { getToken } from '../../utils/token' 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'); if (searchId) return searchId; const hash = window.location.hash || ''; const qIndex = hash.indexOf('?'); if (qIndex >= 0) { const qs = hash.substring(qIndex + 1); const hashId = new URLSearchParams(qs).get('id'); if (hashId) return hashId; } return ''; } // 新增:针对后端的 design_json 兼容性转换 // - 将前端 UI 的 type: 'code' 按语言映射:javascript -> 'javascript',rhai -> 'script'(后端映射到 script_rhai) // - 其余字段保持不变 function transformDesignJsonForBackend(json: any): any { try { const clone = JSON.parse(JSON.stringify(json)); clone.nodes = (clone.nodes || []).map((n: any) => { if (n && n.type === 'code') { const lang = n?.data?.script?.language; if (lang === 'rhai') { return { ...n, type: 'script' }; } // 默认或显式 javascript return { ...n, type: 'javascript' }; } return n; }); return clone; } catch { return json; } } @injectable() export class CustomService { @inject(FreeLayoutPluginContext) ctx!: FreeLayoutPluginContext; @inject(SelectionService) selectionService!: SelectionService; @inject(Playground) playground!: Playground; @inject(WorkflowDocument) document!: WorkflowDocument; // 新增可选参数,用于静默保存时不弹出 Toast,并返回是否保存成功 async save(opts?: { silent?: boolean; executionMode?: 'sync'|'async'|'queued'|'bounded'; concurrencyLimit?: number }): Promise { const silent = !!opts?.silent; try { const id = getFlowIdFromUrl(); if (!id) { 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>(`/flows/${id}`, { yaml, design_json }); if (data?.code === 0) { if (!silent) Toast.success(tr('Saved')); try { const key = (() => { const hash = window.location.hash || ''; if (hash.startsWith('#/')) { return hash.slice(1); } return window.location.pathname + (window.location.search || ''); })(); window.dispatchEvent(new CustomEvent('flows:doc-dirty', { detail: { key, dirty: false } })); } catch {} return true; } else { const msg = data?.message || tr('Save failed'); if (!silent) Toast.error(msg); return false; } } catch (e: any) { const msg = e?.message || tr('Save failed'); if (!silent) Toast.error(msg); return false; } } async run(input: any = {}) { try { const id = getFlowIdFromUrl(); if (!id) { Toast.error(tr('Flow ID is missing, cannot run')); return null; } const { data } = await api.post>(`/flows/${id}/run`, { input }); if (data?.code === 0) { return data.data; } throw new Error(data?.message || tr('Run failed')); } catch (e: any) { Toast.error(e?.message || tr('Run failed')); return null; } } // 新增:WebSocket 流式运行 runStreamWS( 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(tr('Flow ID is missing, cannot run')); handlers?.onFatal?.(err); return { cancel: () => {}, done: Promise.resolve(null) } as const; } // 构造 WS URL const base = (api.defaults.baseURL || '') as string; // 可能是 /api 或 http(s)://host/api // 解析出 API 的路径前缀(用于生产环境相对路径),默认 /api let apiPathPrefix = '/api'; if (base.startsWith('http://') || base.startsWith('https://')) { try { const u = new URL(base); apiPathPrefix = (u.pathname.replace(/\/$/, '') || '/api'); } catch {} } else if (base.startsWith('/')) { apiPathPrefix = (base.replace(/\/$/, '') || '/api'); } const path = `/flows/${id}/run/ws`; // 取 token 放到查询参数(WS 握手无法自定义 Authorization 头部) const token = getToken(); const isDev = !!((import.meta as any).env?.DEV); // 开发:走 /ws 前缀(由 Vite 代理到 8855 并 rewrite 到 /api) // 生产:走同域 /api 前缀,由 Nginx/网关反代,不显式暴露端口 const prefix = isDev ? '/ws' : apiPathPrefix; const qs = token ? `?access_token=${encodeURIComponent(token)}` : ''; // 始终构造绝对 WS URL,避免浏览器兼容性问题 const loc = window.location; const proto = loc.protocol === 'https:' ? 'wss:' : 'ws:'; const origin = `${proto}//${loc.host}`; // host 里已包含端口(开发时 5173),线上通常无端口 const wsUrl = `${origin}${prefix}${path}${qs}`; let ws: WebSocket | null = null; let resolveDone: (v: RunResult | null) => void; let rejectDone: (e: any) => void; const done = new Promise((resolve, reject) => { resolveDone = resolve; rejectDone = reject; }); let finished = false; try { ws = new WebSocket(wsUrl); } catch (e: any) { handlers?.onFatal?.(e); return { cancel: () => {}, done: Promise.resolve(null) } as const; } ws.onopen = () => { try { ws?.send(JSON.stringify({ input })); } catch (e: any) { handlers?.onFatal?.(e); } }; ws.onmessage = (ev: MessageEvent) => { try { const data = typeof ev.data === 'string' ? ev.data : '' + ev.data; const evt = JSON.parse(data) as StreamEvent; if (evt.type === 'node') { handlers?.onNode?.(evt as any); return; } if (evt.type === 'error') { handlers?.onError?.(evt as any); return; } if (evt.type === 'done') { finished = true; handlers?.onDone?.(evt as any); resolveDone({ ok: evt.ok, ctx: (evt as any).ctx, logs: (evt as any).logs }); ws?.close(); return; } } catch (e: any) { // 忽略解析错误为致命,仅记录 console.warn('WS message parse error', e); } }; ws.onerror = (ev: Event) => { if (!finished) { handlers?.onFatal?.(new Error('WebSocket error')); rejectDone(new Error('WebSocket error')); } }; ws.onclose = () => { if (!finished) { handlers?.onFatal?.(new Error('WebSocket closed')); rejectDone(new Error('WebSocket closed')); } }; return { cancel: () => { try { ws?.close(); } catch {} }, done, } as const; } // SSE 方案保留 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(null) } as const; } const res = postSSE(`/flows/${id}/run/stream`, { input }, { onMessage: (evt: StreamEvent) => { try { const e = evt; if (e.type === 'node') { handlers?.onNode?.(e as any); return; } if (e.type === 'error') { handlers?.onError?.(e as any); return; } if (e.type === 'done') { handlers?.onDone?.(e as any); return; } } catch (err: unknown) { // ignore } }, onFatal: (err: Error) => handlers?.onFatal?.(err), }); return res; } }