feat: 接入本地执行内核与运行面板
This commit is contained in:
parent
32ca0a1af8
commit
6a2cc24898
315
src/main/browser/session.ts
Normal file
315
src/main/browser/session.ts
Normal file
@ -0,0 +1,315 @@
|
||||
import { BrowserWindow } from "electron";
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { RuntimeError, type BrowserLike } from "../engine/types";
|
||||
|
||||
const DEFAULT_DOMAINS = ["127.0.0.1", "localhost", "example.com"];
|
||||
|
||||
function allowedDomains(): string[] {
|
||||
const extra = (process.env.RPA_ALLOWED_DOMAINS || "")
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
return [...DEFAULT_DOMAINS, ...extra];
|
||||
}
|
||||
|
||||
function assertUrl(raw: string): URL {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(raw);
|
||||
} catch {
|
||||
throw new RuntimeError("blocked_url", `无效的 URL:${raw}`, { recommendation: "填写完整的 http/https 地址" });
|
||||
}
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
||||
throw new RuntimeError("blocked_url", `只允许 http/https,当前为 ${url.protocol}`);
|
||||
}
|
||||
const host = url.hostname;
|
||||
const allowed = allowedDomains().some((domain) => host === domain || host.endsWith(`.${domain}`));
|
||||
if (!allowed) {
|
||||
throw new RuntimeError("blocked_url", `域名不在白名单:${host}`, {
|
||||
recommendation: `在设置中把 ${host} 加入白名单(或设置 RPA_ALLOWED_DOMAINS)`
|
||||
});
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
interface ElementInfo {
|
||||
tag: string;
|
||||
text: string;
|
||||
value: string;
|
||||
visible: boolean;
|
||||
disabled: boolean;
|
||||
rect: { x: number; y: number; width: number; height: number };
|
||||
}
|
||||
|
||||
function selectorSpec(selector: unknown): { kind: string; value: string; role?: string; name?: string; attr?: string } {
|
||||
if (selector && typeof selector === "object") {
|
||||
const record = selector as Record<string, unknown>;
|
||||
if (record.kind && record.value) {
|
||||
return { kind: String(record.kind), value: String(record.value), role: record.role as string, name: record.name as string };
|
||||
}
|
||||
const common = record.commonProps as Record<string, unknown> | undefined;
|
||||
if (common?.cssSelector) {
|
||||
return { kind: "css", value: String(common.cssSelector) };
|
||||
}
|
||||
if (record.selector && Array.isArray(record.selector)) {
|
||||
const last = [...record.selector].reverse().find((item) => (item as Record<string, unknown>)?.name);
|
||||
if (last) {
|
||||
const item = last as Record<string, unknown>;
|
||||
const props = (item.props as Array<Record<string, unknown>>) || [];
|
||||
const id = props.find((prop) => prop.name === "id")?.textValue;
|
||||
const cls = props.find((prop) => prop.name === "class")?.textValue;
|
||||
const tag = String(item.name);
|
||||
if (id) return { kind: "css", value: `${tag}#${id}` };
|
||||
if (cls) return { kind: "css", value: `${tag}.${String(cls).split(/\s+/).join(".")}` };
|
||||
return { kind: "css", value: tag };
|
||||
}
|
||||
}
|
||||
}
|
||||
const text = String(selector ?? "").trim();
|
||||
if (!text) throw new RuntimeError("validation_error", "元素选择器为空", { recommendation: "使用 css:#id、text:提交 或 aria:button=提交" });
|
||||
if (text.startsWith("css:")) return { kind: "css", value: text.slice(4) };
|
||||
if (text.startsWith("testid:")) return { kind: "css", value: `[data-testid="${text.slice(7).replace(/"/g, '\\"')}"]` };
|
||||
if (text.startsWith("text:")) return { kind: "text", value: text.slice(5) };
|
||||
if (text.startsWith("aria:")) {
|
||||
const rest = text.slice(5);
|
||||
const [role, name] = rest.split("=");
|
||||
return { kind: "aria", value: role, role, name: name || "" };
|
||||
}
|
||||
return { kind: "css", value: text };
|
||||
}
|
||||
|
||||
export class BrowserSession implements BrowserLike {
|
||||
private win: BrowserWindow;
|
||||
|
||||
private constructor(win: BrowserWindow) {
|
||||
this.win = win;
|
||||
}
|
||||
|
||||
static async create(): Promise<BrowserSession> {
|
||||
if (globalThis.__rpaBrowserWindow && !globalThis.__rpaBrowserWindow.isDestroyed()) {
|
||||
return new BrowserSession(globalThis.__rpaBrowserWindow);
|
||||
}
|
||||
const win = new BrowserWindow({
|
||||
width: 1200,
|
||||
height: 820,
|
||||
show: true,
|
||||
title: "RPA 运行浏览器",
|
||||
backgroundColor: "#ffffff",
|
||||
webPreferences: {
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true
|
||||
}
|
||||
});
|
||||
globalThis.__rpaBrowserWindow = win;
|
||||
win.on("closed", () => {
|
||||
globalThis.__rpaBrowserWindow = null;
|
||||
});
|
||||
await win.loadURL("about:blank");
|
||||
if (!win.webContents.debugger.isAttached()) {
|
||||
win.webContents.debugger.attach("1.3");
|
||||
}
|
||||
return new BrowserSession(win);
|
||||
}
|
||||
|
||||
private async cdp<T = unknown>(method: string, params: Record<string, unknown> = {}): Promise<T> {
|
||||
if (this.win.isDestroyed()) {
|
||||
throw new RuntimeError("tab_unavailable", "运行浏览器已关闭", { recommendation: "重新运行流程" });
|
||||
}
|
||||
try {
|
||||
if (!this.win.webContents.debugger.isAttached()) this.win.webContents.debugger.attach("1.3");
|
||||
return (await this.win.webContents.debugger.sendCommand(method, params)) as T;
|
||||
} catch (error) {
|
||||
if (/destroyed|Target closed|not attached/i.test((error as Error).message)) {
|
||||
throw new RuntimeError("tab_unavailable", "浏览器连接已失效", { detail: (error as Error).message });
|
||||
}
|
||||
throw new RuntimeError("browser_error", (error as Error).message, { recommendation: "重试或检查页面状态" });
|
||||
}
|
||||
}
|
||||
|
||||
private async evaluate<T = unknown>(expression: string): Promise<T> {
|
||||
const result = await this.cdp<{ result?: { value?: T }; exceptionDetails?: { exception?: { description?: string }; text?: string } }>(
|
||||
"Runtime.evaluate",
|
||||
{ expression, returnByValue: true, awaitPromise: true }
|
||||
);
|
||||
if (result.exceptionDetails) {
|
||||
throw new RuntimeError(
|
||||
"evaluate_error",
|
||||
result.exceptionDetails.exception?.description || result.exceptionDetails.text || "页面脚本执行失败"
|
||||
);
|
||||
}
|
||||
return result.result?.value as T;
|
||||
}
|
||||
|
||||
async navigate(url: string): Promise<{ url: string; title: string }> {
|
||||
const target = assertUrl(url);
|
||||
await this.cdp("Page.enable");
|
||||
await this.win.loadURL(target.toString());
|
||||
return { url: this.win.webContents.getURL(), title: this.win.webContents.getTitle() };
|
||||
}
|
||||
|
||||
async locate(selector: unknown): Promise<ElementInfo & { selector: string }> {
|
||||
const spec = selectorSpec(selector);
|
||||
const expression = `(() => {
|
||||
const spec = ${JSON.stringify(spec)};
|
||||
const pick = (nodes) => {
|
||||
for (const el of nodes) {
|
||||
const style = getComputedStyle(el);
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.width <= 0 || rect.height <= 0) continue;
|
||||
if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0) continue;
|
||||
return el;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
let el = null;
|
||||
if (spec.kind === 'text') {
|
||||
const target = spec.value;
|
||||
const all = [...document.querySelectorAll('*')].filter((node) => {
|
||||
const text = (node.innerText || node.textContent || '').trim();
|
||||
return text === target || (text.includes(target) && text.length <= target.length + 20);
|
||||
});
|
||||
el = pick(all.sort((a, b) => (a.innerText || '').length - (b.innerText || '').length));
|
||||
} else if (spec.kind === 'aria') {
|
||||
const role = spec.role || spec.value;
|
||||
const name = spec.name || '';
|
||||
const all = [...document.querySelectorAll('[role="' + role + '"], ' + role)];
|
||||
el = pick(all.filter((node) => {
|
||||
const acc = node.getAttribute('aria-label') || node.innerText || '';
|
||||
return !name || acc.includes(name);
|
||||
}));
|
||||
} else {
|
||||
el = pick([...document.querySelectorAll(spec.value)]);
|
||||
}
|
||||
if (!el) return null;
|
||||
el.scrollIntoView({ block: 'center', inline: 'center' });
|
||||
const rect = el.getBoundingClientRect();
|
||||
return {
|
||||
tag: el.tagName.toLowerCase(),
|
||||
text: (el.innerText || el.textContent || '').trim().slice(0, 200),
|
||||
value: el.value ?? '',
|
||||
visible: rect.width > 0 && rect.height > 0,
|
||||
disabled: Boolean(el.disabled),
|
||||
rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height }
|
||||
};
|
||||
})()`;
|
||||
const info = await this.evaluate<ElementInfo | null>(expression);
|
||||
if (!info) {
|
||||
throw new RuntimeError("not_found", `未找到元素:${spec.kind}:${spec.value}`, {
|
||||
recommendation: "检查选择器,或先用拾取器重新采集"
|
||||
});
|
||||
}
|
||||
return { ...info, selector: `${spec.kind}:${spec.value}` };
|
||||
}
|
||||
|
||||
private async mouseClick(x: number, y: number): Promise<void> {
|
||||
const point = { x: Math.round(x), y: Math.round(y) };
|
||||
await this.cdp("Input.dispatchMouseEvent", { type: "mouseMoved", ...point, button: "none" });
|
||||
await this.cdp("Input.dispatchMouseEvent", { type: "mousePressed", ...point, button: "left", clickCount: 1 });
|
||||
await this.cdp("Input.dispatchMouseEvent", { type: "mouseReleased", ...point, button: "left", clickCount: 1 });
|
||||
}
|
||||
|
||||
async click(selector: unknown): Promise<unknown> {
|
||||
const info = await this.locate(selector);
|
||||
if (!info.visible) {
|
||||
throw new RuntimeError("not_found", `元素不可见:${info.selector}`, { recommendation: "等待元素出现或检查页面状态" });
|
||||
}
|
||||
if (info.disabled) {
|
||||
throw new RuntimeError("not_found", `元素已禁用:${info.selector}`);
|
||||
}
|
||||
await this.mouseClick(info.rect.x + info.rect.width / 2, info.rect.y + info.rect.height / 2);
|
||||
await new Promise((resolve) => setTimeout(resolve, 180));
|
||||
return { selector: info.selector, tag: info.tag, text: info.text };
|
||||
}
|
||||
|
||||
async type(selector: unknown, text: string, clearFirst = true): Promise<unknown> {
|
||||
const info = await this.locate(selector);
|
||||
await this.mouseClick(info.rect.x + info.rect.width / 2, info.rect.y + info.rect.height / 2);
|
||||
if (clearFirst) {
|
||||
await this.evaluate(`(() => {
|
||||
const el = document.activeElement;
|
||||
if (el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA')) { el.select(); return true; }
|
||||
return false;
|
||||
})()`);
|
||||
await this.cdp("Input.dispatchKeyEvent", { type: "keyDown", key: "Backspace", code: "Backspace", windowsVirtualKeyCode: 8 });
|
||||
await this.cdp("Input.dispatchKeyEvent", { type: "keyUp", key: "Backspace", code: "Backspace", windowsVirtualKeyCode: 8 });
|
||||
}
|
||||
await this.cdp("Input.insertText", { text });
|
||||
await new Promise((resolve) => setTimeout(resolve, 120));
|
||||
const actual = await this.evaluate<string>(`(() => { const el = document.activeElement; return el ? (el.value ?? '') : ''; })()`);
|
||||
return { selector: info.selector, value: actual };
|
||||
}
|
||||
|
||||
async getText(selector: unknown, attribute?: string): Promise<string> {
|
||||
const info = await this.locate(selector);
|
||||
if (attribute) {
|
||||
const value = await this.evaluate<string | null>(`(() => {
|
||||
const spec = ${JSON.stringify(selectorSpec(selector))};
|
||||
const el = document.querySelector(spec.value);
|
||||
return el ? el.getAttribute(${JSON.stringify(attribute)}) : null;
|
||||
})()`);
|
||||
return value ?? "";
|
||||
}
|
||||
return info.value || info.text;
|
||||
}
|
||||
|
||||
async exists(selector: unknown): Promise<boolean> {
|
||||
try {
|
||||
await this.locate(selector);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async select(selector: unknown, value: string): Promise<unknown> {
|
||||
const info = await this.locate(selector);
|
||||
const applied = await this.evaluate<boolean>(`(() => {
|
||||
const spec = ${JSON.stringify(selectorSpec(selector))};
|
||||
const el = document.querySelector(spec.value);
|
||||
if (!el) return false;
|
||||
el.focus();
|
||||
el.value = ${JSON.stringify(value)};
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
return true;
|
||||
})()`);
|
||||
if (!applied) throw new RuntimeError("not_found", `下拉框未找到:${info.selector}`);
|
||||
return { selector: info.selector, value };
|
||||
}
|
||||
|
||||
async runJs(script: string): Promise<unknown> {
|
||||
return this.evaluate(script);
|
||||
}
|
||||
|
||||
async screenshot(filePath: string): Promise<string> {
|
||||
const result = await this.cdp<{ data: string }>("Page.captureScreenshot", { format: "png" });
|
||||
const target = path.resolve(filePath);
|
||||
await fs.mkdir(path.dirname(target), { recursive: true });
|
||||
await fs.writeFile(target, Buffer.from(result.data, "base64"));
|
||||
return target;
|
||||
}
|
||||
|
||||
async currentUrl(): Promise<string> {
|
||||
return this.win.webContents.getURL();
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
if (!this.win.isDestroyed()) this.win.close();
|
||||
globalThis.__rpaBrowserWindow = null;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
var __rpaBrowserWindow: BrowserWindow | null | undefined;
|
||||
}
|
||||
|
||||
export async function browserSession(): Promise<BrowserSession> {
|
||||
return BrowserSession.create();
|
||||
}
|
||||
|
||||
export function assertAllowedUrl(url: string): URL {
|
||||
return assertUrl(url);
|
||||
}
|
||||
134
src/main/capabilities/browser.ts
Normal file
134
src/main/capabilities/browser.ts
Normal file
@ -0,0 +1,134 @@
|
||||
import { register } from "../engine/registry";
|
||||
import { RuntimeError } from "../engine/types";
|
||||
import { browserSession } from "../browser/session";
|
||||
import type { BrowserLike, RunContext } from "../engine/types";
|
||||
|
||||
async function session(ctx: RunContext): Promise<BrowserLike> {
|
||||
if (!ctx.browser) ctx.browser = await browserSession();
|
||||
return ctx.browser;
|
||||
}
|
||||
|
||||
function selectorOf(args: Record<string, unknown>): unknown {
|
||||
const selector = args.uiSelector ?? args.selector ?? args.target ?? args.uiWindowSelector;
|
||||
if (selector === undefined || selector === null || selector === "") {
|
||||
throw new RuntimeError("validation_error", "元素选择器为空", {
|
||||
recommendation: "填写 css:#id、text:提交、aria:button=提交 或使用拾取器"
|
||||
});
|
||||
}
|
||||
return selector;
|
||||
}
|
||||
|
||||
register(["WebBrowser_CreateV3", "WebBrowser_Create", "WebBrowser_CreateBackground"], async (ctx, node, args) => {
|
||||
const browser = await session(ctx);
|
||||
const url = String(args.url ?? "");
|
||||
if (url) {
|
||||
const result = await browser.navigate(url);
|
||||
ctx.log(`已打开 ${result.url}`, "info", node.id);
|
||||
return result;
|
||||
}
|
||||
return { url: await browser.currentUrl(), title: "" };
|
||||
});
|
||||
|
||||
register(["WebBrowser_GoUrl", "WebBrowser_Navigate"], async (ctx, node, args) => {
|
||||
const browser = await session(ctx);
|
||||
const url = String(args.url ?? "");
|
||||
if (!url) {
|
||||
throw new RuntimeError("validation_error", "跳转地址为空", {
|
||||
nodeId: node.id,
|
||||
componentId: node.componentId,
|
||||
recommendation: "填写要访问的 URL"
|
||||
});
|
||||
}
|
||||
const result = await browser.navigate(url);
|
||||
ctx.log(`已跳转 ${result.url}`, "info", node.id);
|
||||
return result;
|
||||
});
|
||||
|
||||
register("WebBrowser_Switch", async (ctx, node) => {
|
||||
await session(ctx);
|
||||
ctx.log("网页切换已由统一运行浏览器接管", "info", node.id);
|
||||
return true;
|
||||
});
|
||||
|
||||
register("WebBrowser_Close", async (ctx) => {
|
||||
if (ctx.browser) await ctx.browser.close();
|
||||
ctx.browser = undefined;
|
||||
return true;
|
||||
});
|
||||
|
||||
register(["Element_Click_V6", "Element_Click_V3", "Element_Click"], async (ctx, node, args) => {
|
||||
const browser = await session(ctx);
|
||||
const result = await browser.click(selectorOf(args));
|
||||
ctx.log(`已点击 ${(result as { selector?: string }).selector || ""}`, "info", node.id);
|
||||
return result;
|
||||
});
|
||||
|
||||
register(["Element_SetValue_V6", "Element_SetValue_V4", "Element_SetValue"], async (ctx, node, args) => {
|
||||
const browser = await session(ctx);
|
||||
const text = String(args.text ?? args.value ?? "");
|
||||
const result = await browser.type(selectorOf(args), text, true);
|
||||
ctx.log(`已输入 ${text.length > 40 ? `${text.slice(0, 40)}…` : text}`, "info", node.id);
|
||||
if (String(args.isEnter ?? "0") === "1" || String(args.isEnter) === "true") {
|
||||
await browser.runJs("document.activeElement && document.activeElement.blur()");
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
register(["Element_GetText_V5", "Element_GetText"], async (ctx, node, args) => {
|
||||
const browser = await session(ctx);
|
||||
const attrType = String(args.attr_type ?? "");
|
||||
const attrName = String(args.attr_name ?? "");
|
||||
const attribute = attrType && attrType !== "default" ? attrName || attrType : undefined;
|
||||
const text = await browser.getText(selectorOf(args), attribute);
|
||||
ctx.log(`读取到 ${text.length > 60 ? `${text.slice(0, 60)}…` : text}`, "info", node.id);
|
||||
return text;
|
||||
});
|
||||
|
||||
register(["Element_Exists", "Element_ElementExists"], async (ctx, node, args) => {
|
||||
const browser = await session(ctx);
|
||||
const exists = await browser.exists(selectorOf(args));
|
||||
ctx.log(exists ? "元素存在" : "元素不存在", "info", node.id);
|
||||
return exists;
|
||||
});
|
||||
|
||||
register(["Element_SelectOperation_v1", "Element_SelectOperation"], async (ctx, node, args) => {
|
||||
const browser = await session(ctx);
|
||||
const value = String(args.itemsToSelect ?? args.choose ?? args.value ?? "");
|
||||
const result = await browser.select(selectorOf(args), value);
|
||||
ctx.log(`已选择 ${value}`, "info", node.id);
|
||||
return result;
|
||||
});
|
||||
|
||||
register(["WebBrowser_RunJs", "WebBrowser_RunJsInIeByTitle"], async (ctx, node, args) => {
|
||||
const browser = await session(ctx);
|
||||
const script = String(args.jsCode ?? args.script ?? args.code ?? "");
|
||||
if (!script) throw new RuntimeError("validation_error", "JS 代码为空", { nodeId: node.id, componentId: node.componentId });
|
||||
return browser.runJs(script);
|
||||
});
|
||||
|
||||
register("WebBrowser_Screenshot", async (ctx, node, args) => {
|
||||
const browser = await session(ctx);
|
||||
const target = String(args.file_path ?? args.image_path ?? `${ctx.evidenceDir}/${node.id}.png`);
|
||||
const file = await browser.screenshot(target);
|
||||
ctx.log(`截图已保存 ${file}`, "info", node.id);
|
||||
return file;
|
||||
});
|
||||
|
||||
register("Image_Screenshot", async (ctx, node, args) => {
|
||||
const { desktopCapturer, screen } = await import("electron");
|
||||
const folder = String(args.outImagePath ?? args.image_path ?? ctx.evidenceDir);
|
||||
const name = String(args.outImageName ?? `${node.id}.png`);
|
||||
const { promises: fs } = await import("node:fs");
|
||||
const path = await import("node:path");
|
||||
const target = path.resolve(folder.endsWith(".png") ? folder : path.join(folder, name));
|
||||
const primary = screen.getPrimaryDisplay();
|
||||
const sources = await desktopCapturer.getSources({
|
||||
types: ["screen"],
|
||||
thumbnailSize: { width: primary.size.width, height: primary.size.height }
|
||||
});
|
||||
const source = sources[0];
|
||||
if (!source) throw new RuntimeError("desktop_error", "无法获取屏幕截图");
|
||||
await fs.mkdir(path.dirname(target), { recursive: true });
|
||||
await fs.writeFile(target, source.thumbnail.toPNG());
|
||||
return target;
|
||||
});
|
||||
84
src/main/capabilities/control.ts
Normal file
84
src/main/capabilities/control.ts
Normal file
@ -0,0 +1,84 @@
|
||||
import { register } from "../engine/registry";
|
||||
import { BreakSignal, ContinueSignal, RuntimeError, StopSignal } from "../engine/types";
|
||||
import { interpolate, toNumber } from "../engine/expression";
|
||||
|
||||
function sleep(ms: number, signal: AbortSignal): Promise<void> {
|
||||
if (ms <= 0) return Promise.resolve();
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
resolve();
|
||||
}, ms);
|
||||
const onAbort = () => {
|
||||
clearTimeout(timer);
|
||||
reject(new RuntimeError("cancelled", "运行已取消"));
|
||||
};
|
||||
if (signal.aborted) return onAbort();
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
register(["Trigger.Manual", "Trigger.Schedule", "Trigger.FileArrival"], () => null);
|
||||
|
||||
register("Basic_Delay_V2", async (ctx, node, args) => {
|
||||
const random = String(args.is_random ?? "0") === "1" || String(args.is_random) === "true";
|
||||
const seconds = random
|
||||
? toNumber(args.min_second, 1) + Math.random() * Math.max(0, toNumber(args.max_second, 5) - toNumber(args.min_second, 1))
|
||||
: toNumber(args.second, 1);
|
||||
ctx.log(`等待 ${seconds.toFixed(2)} 秒`, "info", node.id);
|
||||
await sleep(Math.max(0, seconds) * 1000, ctx.signal);
|
||||
return seconds;
|
||||
});
|
||||
|
||||
register(["Basic_Delay_V1", "Basic_Delay"], async (ctx, node, args) => {
|
||||
const seconds = toNumber(args.second ?? args.time ?? 1, 1);
|
||||
await sleep(Math.max(0, seconds) * 1000, ctx.signal);
|
||||
return seconds;
|
||||
});
|
||||
|
||||
register("Basic_DebugOutput", (ctx, node, args) => {
|
||||
const message = String(args.msg ?? args.message ?? "");
|
||||
ctx.log(message || "(空)", "info", node.id);
|
||||
return message;
|
||||
});
|
||||
|
||||
register("Basic_SetVariable", (ctx, node, args) => {
|
||||
const name = String(node.options?.outputVar || "").trim();
|
||||
const value = args.value;
|
||||
if (!name) {
|
||||
throw new RuntimeError("validation_error", "「设置变量」未配置输出变量", {
|
||||
nodeId: node.id,
|
||||
componentId: node.componentId,
|
||||
recommendation: "在属性面板的输出变量里填写变量名"
|
||||
});
|
||||
}
|
||||
ctx.vars[name] = value;
|
||||
ctx.log(`${name} = ${typeof value === "object" ? JSON.stringify(value) : String(value)}`, "info", node.id);
|
||||
return value;
|
||||
});
|
||||
|
||||
register("logic_control_var", (ctx, node, args) => {
|
||||
const name = String(interpolate(args.var_name ?? node.options?.outputVar ?? "", ctx.vars) || "").trim();
|
||||
if (!name) {
|
||||
throw new RuntimeError("validation_error", "「设置变量」未填写变量名称", {
|
||||
nodeId: node.id,
|
||||
componentId: node.componentId
|
||||
});
|
||||
}
|
||||
const value = interpolate(args.var_value ?? args.value, ctx.vars);
|
||||
ctx.vars[name] = value;
|
||||
ctx.log(`${name} = ${typeof value === "object" ? JSON.stringify(value) : String(value)}`, "info", node.id);
|
||||
return value;
|
||||
});
|
||||
|
||||
register("logic_control_break_for", () => {
|
||||
throw new BreakSignal();
|
||||
});
|
||||
|
||||
register("logic_control_next_for", () => {
|
||||
throw new ContinueSignal();
|
||||
});
|
||||
|
||||
register(["logic_control_stop", "logic_control_stop_return"], () => {
|
||||
throw new StopSignal();
|
||||
});
|
||||
515
src/main/capabilities/data.ts
Normal file
515
src/main/capabilities/data.ts
Normal file
@ -0,0 +1,515 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { register } from "../engine/registry";
|
||||
import { RuntimeError } from "../engine/types";
|
||||
|
||||
const str = (value: unknown): string => (value == null ? "" : String(value));
|
||||
const num = (value: unknown, fallback = 0): number => {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
};
|
||||
const bool = (value: unknown): boolean =>
|
||||
value === true || value === "true" || value === "1" || value === "True" || value === "是" || value === "开启";
|
||||
|
||||
function first(args: Record<string, unknown>, keys: string[]): unknown {
|
||||
for (const key of keys) {
|
||||
if (Object.prototype.hasOwnProperty.call(args, key)) return args[key];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function asArray(value: unknown): unknown[] {
|
||||
if (Array.isArray(value)) return value;
|
||||
if (value == null || value === "") return [];
|
||||
if (typeof value === "string") {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return Array.isArray(parsed) ? parsed : [parsed];
|
||||
} catch {
|
||||
return value.split(/[,,\n]/).map((item) => item.trim()).filter(Boolean);
|
||||
}
|
||||
}
|
||||
return [value];
|
||||
}
|
||||
|
||||
function asObject(value: unknown): Record<string, unknown> {
|
||||
if (value && typeof value === "object" && !Array.isArray(value)) return value as Record<string, unknown>;
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed as Record<string, unknown>;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function toDate(value: unknown): Date {
|
||||
if (value instanceof Date) return value;
|
||||
if (typeof value === "number") return new Date(value < 1e12 ? value * 1000 : value);
|
||||
const text = str(value).trim();
|
||||
if (!text) return new Date();
|
||||
if (/^\d+$/.test(text)) {
|
||||
const numeric = Number(text);
|
||||
return new Date(numeric < 1e12 ? numeric * 1000 : numeric);
|
||||
}
|
||||
const normalized = text.replace(/\//g, "-").replace(" ", "T");
|
||||
const date = new Date(normalized);
|
||||
return Number.isNaN(date.getTime()) ? new Date(text) : date;
|
||||
}
|
||||
|
||||
function formatDate(value: unknown, pattern = "YYYY-MM-DD HH:mm:ss"): string {
|
||||
const date = toDate(value);
|
||||
if (Number.isNaN(date.getTime())) return "";
|
||||
const pad = (input: number) => String(input).padStart(2, "0");
|
||||
const fmt = pattern
|
||||
.replace(/yyyy/g, "YYYY")
|
||||
.replace(/yy/g, "YY")
|
||||
.replace(/dd/g, "DD");
|
||||
return fmt
|
||||
.replace(/YYYY/g, String(date.getFullYear()))
|
||||
.replace(/YY/g, String(date.getFullYear()).slice(-2))
|
||||
.replace(/MM/g, pad(date.getMonth() + 1))
|
||||
.replace(/DD/g, pad(date.getDate()))
|
||||
.replace(/HH/g, pad(date.getHours()))
|
||||
.replace(/mm/g, pad(date.getMinutes()))
|
||||
.replace(/ss/g, pad(date.getSeconds()));
|
||||
}
|
||||
|
||||
function cutText(text: string, args: Record<string, unknown>): string {
|
||||
const startType = str(first(args, ["start_cut_type"]));
|
||||
const endType = str(first(args, ["end_cut_type"]));
|
||||
let start = 0;
|
||||
if (startType.includes("文本")) {
|
||||
const token = str(first(args, ["start_cut_str", "start_cut_pos"]));
|
||||
const index = text.indexOf(token);
|
||||
start = index >= 0 ? index + token.length : 0;
|
||||
} else {
|
||||
start = Math.max(0, num(first(args, ["start_cut_pos", "start"]), 0));
|
||||
}
|
||||
if (endType.includes("长度")) {
|
||||
const length = Math.max(0, num(first(args, ["end_cut_length", "length"]), text.length));
|
||||
return text.slice(start, start + length);
|
||||
}
|
||||
if (endType.includes("文本")) {
|
||||
const token = str(first(args, ["end_cut_txt", "end_cut_str"]));
|
||||
const index = text.indexOf(token, start);
|
||||
return text.slice(start, index >= 0 ? index : text.length);
|
||||
}
|
||||
const end = num(first(args, ["end_cut_pos", "end"]), text.length);
|
||||
return text.slice(start, end);
|
||||
}
|
||||
|
||||
/* ---------------- StrUtil ---------------- */
|
||||
register("StrUtil_Len", (_ctx, _node, args) => str(first(args, ["string"])).length);
|
||||
register("StrUtil_InStr", (_ctx, _node, args) => {
|
||||
const text = str(first(args, ["string"]));
|
||||
const search = str(first(args, ["search"]));
|
||||
const start = Math.max(0, num(first(args, ["startIndex"]), 0));
|
||||
return text.indexOf(search, start);
|
||||
});
|
||||
register("StrUtil_Replace", (_ctx, _node, args) => {
|
||||
const text = str(first(args, ["string"]));
|
||||
const search = str(first(args, ["search", "Config"]));
|
||||
const replacement = str(first(args, ["replace"]));
|
||||
return text.split(search).join(replacement);
|
||||
});
|
||||
register(["StrUtil_LowerCase"], (_ctx, _node, args) => str(first(args, ["string"])).toLowerCase());
|
||||
register(["StrUtil_UpperCase"], (_ctx, _node, args) => str(first(args, ["string"])).toUpperCase());
|
||||
register("StrUtil_CaseChange", (_ctx, _node, args) => {
|
||||
const text = str(first(args, ["string"]));
|
||||
const mode = str(first(args, ["case"]));
|
||||
if (mode.includes("小写")) return text.toLowerCase();
|
||||
if (mode.includes("大写")) return text.toUpperCase();
|
||||
if (mode.includes("首")) return text ? text[0].toUpperCase() + text.slice(1) : "";
|
||||
return text;
|
||||
});
|
||||
register("StrUtil_Trim", (_ctx, _node, args) => str(first(args, ["string"])).trim());
|
||||
register("StrUtil_Ltrim", (_ctx, _node, args) => str(first(args, ["string"])).replace(/^\s+/, ""));
|
||||
register("StrUtil_Rtrim", (_ctx, _node, args) => str(first(args, ["string"])).replace(/\s+$/, ""));
|
||||
register("StrUtil_DelTrim", (_ctx, _node, args) => str(first(args, ["string"])).trim());
|
||||
register("StrUtil_Left", (_ctx, _node, args) => str(first(args, ["string"])).slice(0, Math.max(0, num(first(args, ["length"]), 0))));
|
||||
register("StrUtil_Right", (_ctx, _node, args) => {
|
||||
const text = str(first(args, ["string"]));
|
||||
const length = Math.max(0, num(first(args, ["length"]), 0));
|
||||
return length ? text.slice(-length) : "";
|
||||
});
|
||||
register("StrUtil_Mid", (_ctx, _node, args) => {
|
||||
const text = str(first(args, ["string"]));
|
||||
const start = Math.max(0, num(first(args, ["start"]), 0));
|
||||
const length = Math.max(0, num(first(args, ["length"]), text.length));
|
||||
return text.slice(start, start + length);
|
||||
});
|
||||
register(["StrUtil_CutOutStr", "StrUtil_CutOutStrV1"], (_ctx, _node, args) => cutText(str(first(args, ["string"])), args));
|
||||
register("StrUtil_Split", (_ctx, _node, args) =>
|
||||
str(first(args, ["string"])).split(str(first(args, ["spliter"])))
|
||||
);
|
||||
register(["StrUtil_SplitStr"], (_ctx, _node, args) => {
|
||||
const text = str(first(args, ["string"]));
|
||||
const custom = str(first(args, ["diy_symbol"]));
|
||||
const symbol = custom || str(first(args, ["split_symbol"])) || ",";
|
||||
const regex = bool(first(args, ["is_re"]));
|
||||
let parts: string[];
|
||||
if (regex) {
|
||||
try {
|
||||
parts = text.split(new RegExp(symbol));
|
||||
} catch {
|
||||
parts = text.split(symbol);
|
||||
}
|
||||
} else {
|
||||
parts = text.split(symbol);
|
||||
}
|
||||
const trim = args.del_trim === undefined ? true : bool(args.del_trim);
|
||||
if (trim) parts = parts.map((item) => item.trim());
|
||||
return parts.filter((item) => item !== "" || !trim);
|
||||
});
|
||||
register("StrUtil_AddToStr", (_ctx, _node, args) => {
|
||||
const text = str(first(args, ["string"]));
|
||||
const addition = str(first(args, ["add_str"]));
|
||||
const type = str(first(args, ["add_type"]));
|
||||
const symbol = str(first(args, ["symbol"]));
|
||||
if (type.includes("前")) return addition + symbol + text;
|
||||
return text + symbol + addition;
|
||||
});
|
||||
register("StrUtil_ReverseStr", (_ctx, _node, args) => {
|
||||
const text = str(first(args, ["string"]));
|
||||
return bool(first(args, ["letter_reverse"])) ? [...text].reverse().join("") : text.split(/\s+/).reverse().join(" ");
|
||||
});
|
||||
register("StrUtil_ListToString", (_ctx, _node, args) => {
|
||||
const list = asArray(first(args, ["str_list"]));
|
||||
const connector = str(first(args, ["connectors_custom", "standard"])) || ",";
|
||||
return list.map(str).join(connector);
|
||||
});
|
||||
register("StrUtil_CompleteStr", (_ctx, _node, args) => {
|
||||
const text = str(first(args, ["string"]));
|
||||
const total = Math.max(0, num(first(args, ["sum_length"]), text.length));
|
||||
const pad = str(first(args, ["com_string"])) || " ";
|
||||
const mode = str(first(args, ["com_type"]));
|
||||
const fill = pad.repeat(Math.max(0, total - text.length)).slice(0, Math.max(0, total - text.length));
|
||||
return mode.includes("右") ? text + fill : fill + text;
|
||||
});
|
||||
register("StrUtil_CharCode", (_ctx, _node, args) => String.fromCharCode(num(first(args, ["code"]), 0)));
|
||||
register("StrUtil_CharAsc", (_ctx, _node, args) => str(first(args, ["char"])).charCodeAt(0));
|
||||
register("StrUtil_CreateRandomStr", (_ctx, _node, args) => {
|
||||
const length = Math.max(0, num(first(args, ["string_length"]), 8));
|
||||
const alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
||||
return Array.from({ length }, () => alphabet[Math.floor(Math.random() * alphabet.length)]).join("");
|
||||
});
|
||||
register("StrUtil_LookUpStr", (_ctx, _node, args) => {
|
||||
const text = str(first(args, ["string"]));
|
||||
const search = str(first(args, ["search"]));
|
||||
const start = Math.max(0, num(first(args, ["startIndex"]), 0));
|
||||
return text.indexOf(search, start);
|
||||
});
|
||||
|
||||
/* ---------------- Time ---------------- */
|
||||
register("Time_Now", () => formatDate(new Date()));
|
||||
register(["Time_GetDate", "Time_GetDateV1", "Time_GetDateV2"], (_ctx, _node, args) => {
|
||||
let date = toDate(first(args, ["date_start", "time_data"]));
|
||||
const offset = num(first(args, ["offset_unit_num"]), 0);
|
||||
const mode = str(first(args, ["offset_status"]));
|
||||
if (offset) date = new Date(date.getTime() + (mode.includes("减") ? -offset : offset) * 86400000);
|
||||
const type = str(first(args, ["get_type"]));
|
||||
if (type.includes("时间戳")) return Math.floor(date.getTime() / 1000);
|
||||
if (type.includes("日期") && !type.includes("时间")) return formatDate(date, "YYYY-MM-DD");
|
||||
return formatDate(date, type || "YYYY-MM-DD HH:mm:ss");
|
||||
});
|
||||
register("Time_Date", () => formatDate(new Date(), "YYYY-MM-DD"));
|
||||
register("Time_Time", () => formatDate(new Date(), "HH:mm:ss"));
|
||||
register("Time_TimeStamp", () => Math.floor(Date.now() / 1000));
|
||||
register(["Time_Format", "Time_FormatNew"], (_ctx, _node, args) =>
|
||||
formatDate(first(args, ["timestamp", "date_input"]), str(first(args, ["format"])) || "YYYY-MM-DD HH:mm:ss")
|
||||
);
|
||||
register("Time_DatetimeToTimestamp", (_ctx, _node, args) => Math.floor(toDate(first(args, ["datetime"])).getTime() / 1000));
|
||||
register("Time_DateToTimestamp", (_ctx, _node, args) => Math.floor(toDate(first(args, ["datetime"])).getTime() / 1000));
|
||||
register(["Time_DateOffset", "Time_DateOffset_V1"], (_ctx, _node, args) => {
|
||||
const date = toDate(first(args, ["date_input"]));
|
||||
const amount = num(first(args, ["offset_unit_num"]), 0);
|
||||
const mode = str(first(args, ["offset_status"]));
|
||||
const delta = mode.includes("减") ? -amount : amount;
|
||||
const pattern = str(first(args, ["get_type"])) || str(first(args, ["is_fmt"])) || "YYYY-MM-DD HH:mm:ss";
|
||||
return formatDate(new Date(date.getTime() + delta * 86400000), pattern.includes("YYYY") ? pattern : "YYYY-MM-DD HH:mm:ss");
|
||||
});
|
||||
for (const [id, getter] of [
|
||||
["Time_Year", (date: Date) => date.getFullYear()],
|
||||
["Time_Month", (date: Date) => date.getMonth() + 1],
|
||||
["Time_Day", (date: Date) => date.getDate()],
|
||||
["Time_Hour", (date: Date) => date.getHours()],
|
||||
["Time_Minute", (date: Date) => date.getMinutes()],
|
||||
["Time_Second", (date: Date) => date.getSeconds()],
|
||||
["Time_WeekDay", (date: Date) => date.getDay()]
|
||||
] as Array<[string, (date: Date) => number]>) {
|
||||
register(id, (_ctx, _node, args) => getter(toDate(first(args, ["timestamp", "datetime"]))));
|
||||
}
|
||||
register("Time_CalDate", (_ctx, _node, args) => {
|
||||
const start = toDate(first(args, ["date_start"]));
|
||||
const end = toDate(first(args, ["date_end"]));
|
||||
const unit = str(first(args, ["time_type"]));
|
||||
const ms = end.getTime() - start.getTime();
|
||||
if (unit.includes("时")) return ms / 3600000;
|
||||
if (unit.includes("分")) return ms / 60000;
|
||||
if (unit.includes("秒")) return ms / 1000;
|
||||
return ms / 86400000;
|
||||
});
|
||||
register("Time_TimeTypeConversion", (_ctx, _node, args) =>
|
||||
formatDate(first(args, ["datetime"]), str(first(args, ["format"])) || "YYYY-MM-DD HH:mm:ss")
|
||||
);
|
||||
|
||||
/* ---------------- List ---------------- */
|
||||
register("List_CreateList", (_ctx, _node, args) => asArray(first(args, ["data"])));
|
||||
register("List_ListLength", (_ctx, _node, args) => asArray(first(args, ["arrayRet"])).length);
|
||||
register("List_InsertList", (_ctx, _node, args) => {
|
||||
const list = asArray(first(args, ["arrayRet"]));
|
||||
const item = first(args, ["data"]);
|
||||
const type = str(first(args, ["insertType"]));
|
||||
if (type.includes("位置") || type.includes("指定")) {
|
||||
const position = Math.max(0, Math.min(list.length, num(first(args, ["position"]), 0)));
|
||||
return [...list.slice(0, position), item, ...list.slice(position)];
|
||||
}
|
||||
return [...list, item];
|
||||
});
|
||||
register("List_DeleteList", (_ctx, _node, args) => {
|
||||
const list = asArray(first(args, ["arrayRet"]));
|
||||
const type = str(first(args, ["deleteType"]));
|
||||
if (type.includes("位置")) {
|
||||
const position = Math.max(0, num(first(args, ["position"]), 0));
|
||||
return list.filter((_, index) => index !== position);
|
||||
}
|
||||
const target = first(args, ["data"]);
|
||||
return list.filter((item) => item !== target);
|
||||
});
|
||||
register("List_GetListItem", (_ctx, _node, args) => asArray(first(args, ["arrayRet"]))[num(first(args, ["position"]), 0)]);
|
||||
register("List_SetListItem", (_ctx, _node, args) => {
|
||||
const list = [...asArray(first(args, ["arrayRet"]))];
|
||||
const position = Math.max(0, num(first(args, ["position"]), 0));
|
||||
list[position] = first(args, ["data"]);
|
||||
return list;
|
||||
});
|
||||
register("List_GetIndex", (_ctx, _node, args) => asArray(first(args, ["arrayRet"])).indexOf(first(args, ["data"])));
|
||||
register("List_ListMerge", (_ctx, _node, args) => [
|
||||
...asArray(first(args, ["arrayRet1"])),
|
||||
...asArray(first(args, ["arrayRet2"]))
|
||||
]);
|
||||
register("List_ListDuplication", (_ctx, _node, args) => [...new Set(asArray(first(args, ["arrayRet"])))]);
|
||||
register("List_ListSorted", (_ctx, _node, args) => {
|
||||
const list = [...asArray(first(args, ["arrayRet"]))];
|
||||
const mode = str(first(args, ["sortedType"]));
|
||||
return list.sort((a, b) => {
|
||||
const result = String(a).localeCompare(String(b), "zh-CN", { numeric: true });
|
||||
return mode.includes("降") ? -result : result;
|
||||
});
|
||||
});
|
||||
register("List_ListIntersection", (_ctx, _node, args) => {
|
||||
const second = new Set(asArray(first(args, ["arrayRet2"])));
|
||||
return asArray(first(args, ["arrayRet1"])).filter((item) => second.has(item));
|
||||
});
|
||||
register("List_ClearList", () => []);
|
||||
|
||||
/* ---------------- Dict ---------------- */
|
||||
register("Dict_Create", (_ctx, _node, args) => asObject(first(args, ["value"])));
|
||||
register("Dict_GetItem", (_ctx, _node, args) => {
|
||||
const object = asObject(first(args, ["dictObj"]));
|
||||
const key = str(first(args, ["key"]));
|
||||
if (key in object) return object[key];
|
||||
return first(args, ["default_value"]);
|
||||
});
|
||||
register("Dict_SetItem", (_ctx, _node, args) => {
|
||||
const object = asObject(first(args, ["dictObj"]));
|
||||
const value = first(args, ["value"]);
|
||||
if (value && typeof value === "object") {
|
||||
const record = value as Record<string, unknown>;
|
||||
if ("key" in record) return { ...object, [str(record.key)]: record.value };
|
||||
return { ...object, ...record };
|
||||
}
|
||||
return { ...object, [str(first(args, ["key"]))]: value };
|
||||
});
|
||||
register("Dict_Keys", (_ctx, _node, args) => Object.keys(asObject(first(args, ["dictObj"]))));
|
||||
register("Dict_Values", (_ctx, _node, args) => Object.values(asObject(first(args, ["dictObj"]))));
|
||||
register("Dict_Items", (_ctx, _node, args) => Object.entries(asObject(first(args, ["dictObj"]))));
|
||||
register("Dict_Delete", (_ctx, _node, args) => {
|
||||
const object = { ...asObject(first(args, ["dictObj"])) };
|
||||
delete object[str(first(args, ["key"]))];
|
||||
return object;
|
||||
});
|
||||
register("Dict_Clear", () => ({}));
|
||||
register("Dict_Update", (_ctx, _node, args) => ({
|
||||
...asObject(first(args, ["dictObj"])),
|
||||
...asObject(first(args, ["targetDict"]))
|
||||
}));
|
||||
|
||||
/* ---------------- Regex ---------------- */
|
||||
register(["Regex_Test", "Regex_TestNew"], (_ctx, _node, args) => {
|
||||
try {
|
||||
return new RegExp(str(first(args, ["pattern", "Config"]))).test(str(first(args, ["text"])));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
register(["Regex_FindAll", "Regex_FindAllNew"], (_ctx, _node, args) => {
|
||||
try {
|
||||
const regex = new RegExp(str(first(args, ["pattern", "Config"])), "g");
|
||||
return [...str(first(args, ["text"])).matchAll(regex)].map((match) => match[0]);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
register(["Regex_Find", "Regex_FindNew", "Regex_FindStr", "Regex_FindStrV1", "Regex_FindNew_1"], (_ctx, _node, args) => {
|
||||
try {
|
||||
const pattern = str(first(args, ["pattern", "Config", "expression"]));
|
||||
const regex = new RegExp(pattern);
|
||||
const match = regex.exec(str(first(args, ["text", "string"])));
|
||||
if (!match) return "";
|
||||
const index = Math.max(0, num(first(args, ["index", "extract_pos"]), 0));
|
||||
return match[index] ?? match[0];
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
});
|
||||
|
||||
/* ---------------- Math ---------------- */
|
||||
register("Math_Round", (_ctx, _node, args) => {
|
||||
const digit = num(first(args, ["digit"]), 0);
|
||||
const factor = 10 ** digit;
|
||||
return Math.round(num(first(args, ["data"])) * factor) / factor;
|
||||
});
|
||||
register("Math_Abs", (_ctx, _node, args) => Math.abs(num(first(args, ["data"]))));
|
||||
register("Math_Int", (_ctx, _node, args) => {
|
||||
const value = num(first(args, ["data"]));
|
||||
const position = str(first(args, ["position"]));
|
||||
return position.includes("上") ? Math.ceil(value) : Math.floor(value);
|
||||
});
|
||||
register("Math_Sqrt", (_ctx, _node, args) => Math.sqrt(num(first(args, ["data"]))));
|
||||
register("Math_Pow", (_ctx, _node, args) => Math.pow(num(first(args, ["x"])), num(first(args, ["y"]))));
|
||||
register("Math_Exp", (_ctx, _node, args) => Math.exp(num(first(args, ["data"]))));
|
||||
register("Math_Sgn", (_ctx, _node, args) => Math.sign(num(first(args, ["data"]))));
|
||||
register("Math_Hex", (_ctx, _node, args) => num(first(args, ["num"])).toString(16).toUpperCase());
|
||||
register("Math_Oct", (_ctx, _node, args) => num(first(args, ["num"])).toString(8));
|
||||
register("Math_Scale", (_ctx, _node, args) => {
|
||||
const source = str(first(args, ["sourceNum"]));
|
||||
const from = num(first(args, ["sourceNumScale"]), 10);
|
||||
const to = num(first(args, ["targetNumScale"]), 10);
|
||||
return parseInt(source, from).toString(to).toUpperCase();
|
||||
});
|
||||
for (const [id, fn] of [
|
||||
["Math_Sin", (value: number, angle: boolean) => Math.sin(angle ? (value * Math.PI) / 180 : value)],
|
||||
["Math_Cos", (value: number, angle: boolean) => Math.cos(angle ? (value * Math.PI) / 180 : value)],
|
||||
["Math_Tan", (value: number, angle: boolean) => Math.tan(angle ? (value * Math.PI) / 180 : value)]
|
||||
] as Array<[string, (value: number, angle: boolean) => number]>) {
|
||||
register(id, (_ctx, _node, args) => {
|
||||
const unit = str(first(args, ["unit"]));
|
||||
return fn(num(first(args, ["data"])), unit.includes("角度"));
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------------- JSON / CSV ---------------- */
|
||||
register("JSON_Stringify", (_ctx, _node, args) => JSON.stringify(first(args, ["string"])));
|
||||
register("JSON_Parse", (_ctx, _node, args) => {
|
||||
try {
|
||||
return JSON.parse(str(first(args, ["string"])));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
function parseCsv(text: string): string[][] {
|
||||
const rows: string[][] = [];
|
||||
let row: string[] = [];
|
||||
let cell = "";
|
||||
let quoted = false;
|
||||
for (let i = 0; i < text.length; i += 1) {
|
||||
const char = text[i];
|
||||
if (quoted) {
|
||||
if (char === '"' && text[i + 1] === '"') {
|
||||
cell += '"';
|
||||
i += 1;
|
||||
} else if (char === '"') {
|
||||
quoted = false;
|
||||
} else {
|
||||
cell += char;
|
||||
}
|
||||
} else if (char === '"') {
|
||||
quoted = true;
|
||||
} else if (char === ",") {
|
||||
row.push(cell);
|
||||
cell = "";
|
||||
} else if (char === "\n") {
|
||||
row.push(cell);
|
||||
rows.push(row);
|
||||
row = [];
|
||||
cell = "";
|
||||
} else if (char !== "\r") {
|
||||
cell += char;
|
||||
}
|
||||
}
|
||||
if (cell || row.length) {
|
||||
row.push(cell);
|
||||
rows.push(row);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function toCsv(rows: unknown[][]): string {
|
||||
return rows
|
||||
.map((row) =>
|
||||
row
|
||||
.map((cell) => {
|
||||
const text = str(cell);
|
||||
return /[",\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
|
||||
})
|
||||
.join(",")
|
||||
)
|
||||
.join("\r\n");
|
||||
}
|
||||
|
||||
register(["CSV_Read", "CSV_ReadNew"], async (_ctx, node, args) => {
|
||||
const filePath = path.resolve(str(first(args, ["path"])));
|
||||
if (!filePath) throw new RuntimeError("validation_error", "CSV 路径为空", { nodeId: node.id, componentId: node.componentId });
|
||||
const text = await fs.readFile(filePath, "utf8");
|
||||
return parseCsv(text);
|
||||
});
|
||||
register(["CSV_Write", "CSV_WriteNew"], async (_ctx, node, args) => {
|
||||
const filePath = path.resolve(str(first(args, ["path"])));
|
||||
if (!filePath) throw new RuntimeError("validation_error", "CSV 路径为空", { nodeId: node.id, componentId: node.componentId });
|
||||
const table = asArray(first(args, ["table"])).map((row) => asArray(row));
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
await fs.writeFile(filePath, toCsv(table), "utf8");
|
||||
return filePath;
|
||||
});
|
||||
|
||||
/* ---------------- Basic ---------------- */
|
||||
register(["Basic_ConvertNumber", "Basic_StringToNum"], (_ctx, _node, args) => num(first(args, ["val"])));
|
||||
register("Basic_ConvertString", (_ctx, _node, args) => str(first(args, ["val"])));
|
||||
register("Basic_ConvertInt", (_ctx, _node, args) => Math.trunc(num(first(args, ["val"]))));
|
||||
register("Basic_ConvertBool", (_ctx, _node, args) => bool(first(args, ["val"])));
|
||||
register("Basic_IsNull", (_ctx, _node, args) => {
|
||||
const value = first(args, ["val"]);
|
||||
return value === null || value === undefined || value === "";
|
||||
});
|
||||
register("Basic_IsArray", (_ctx, _node, args) => Array.isArray(first(args, ["val"])));
|
||||
register("Basic_IsDictionary", (_ctx, _node, args) => {
|
||||
const value = first(args, ["val"]);
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
});
|
||||
register("Basic_IsNumber", (_ctx, _node, args) => Number.isFinite(Number(first(args, ["val"]))));
|
||||
register("Basic_Type", (_ctx, _node, args) => {
|
||||
const value = first(args, ["val"]);
|
||||
if (value === null) return "null";
|
||||
if (Array.isArray(value)) return "list";
|
||||
return typeof value;
|
||||
});
|
||||
register("Basic_JudgeDataType", (_ctx, _node, args) => {
|
||||
const value = first(args, ["val"]);
|
||||
if (value === null || value === undefined) return "null";
|
||||
if (Array.isArray(value)) return "list";
|
||||
if (typeof value === "object") return "dict";
|
||||
return typeof value;
|
||||
});
|
||||
register("Basic_Clone", (_ctx, _node, args) => structuredClone(first(args, ["val"])));
|
||||
register("Basic_RandomInt", (_ctx, _node, args) => {
|
||||
const min = num(first(args, ["min", "start"]), 0);
|
||||
const max = num(first(args, ["max", "end"]), 100);
|
||||
return Math.floor(min + Math.random() * (max - min + 1));
|
||||
});
|
||||
register("Basic_Rnd", () => Math.random());
|
||||
register("Basic_DataTypeConversion", (_ctx, _node, args) => first(args, ["val"]));
|
||||
213
src/main/capabilities/files.ts
Normal file
213
src/main/capabilities/files.ts
Normal file
@ -0,0 +1,213 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import AdmZip from "adm-zip";
|
||||
import { register } from "../engine/registry";
|
||||
import { RuntimeError } from "../engine/types";
|
||||
|
||||
function pick(args: Record<string, unknown>, keys: string[]): string {
|
||||
for (const key of keys) {
|
||||
const value = args[key];
|
||||
if (value !== undefined && value !== null && value !== "") return String(value);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function truthy(value: unknown): boolean {
|
||||
return value === true || value === "true" || value === "1" || value === "True" || value === "是" || value === "开启";
|
||||
}
|
||||
|
||||
async function readText(filePath: string, encode = "utf-8"): Promise<string> {
|
||||
const buffer = await fs.readFile(filePath);
|
||||
const encoding = (encode || "utf-8").toLowerCase().replace("_", "-");
|
||||
if (encoding === "utf-8" || encoding === "utf8" || encoding === "auto") return buffer.toString("utf8");
|
||||
try {
|
||||
return new TextDecoder(encoding).decode(buffer);
|
||||
} catch {
|
||||
return buffer.toString("utf8");
|
||||
}
|
||||
}
|
||||
|
||||
async function writeText(filePath: string, content: string, append: boolean, encode = "utf-8"): Promise<number> {
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
if (append) await fs.appendFile(filePath, content, "utf8");
|
||||
else await fs.writeFile(filePath, content, "utf8");
|
||||
const stat = await fs.stat(filePath);
|
||||
return stat.size;
|
||||
}
|
||||
|
||||
function requirePath(value: string, label: string, nodeId: string, componentId: string): string {
|
||||
if (!value.trim()) {
|
||||
throw new RuntimeError("validation_error", `${label}为空`, {
|
||||
nodeId,
|
||||
componentId,
|
||||
recommendation: "在属性面板选择文件或目录"
|
||||
});
|
||||
}
|
||||
return path.resolve(value);
|
||||
}
|
||||
|
||||
register("File_Read", async (_ctx, node, args) => {
|
||||
const filePath = requirePath(pick(args, ["path", "file", "file_path"]), "文件路径", node.id, node.componentId);
|
||||
return readText(filePath, pick(args, ["encode"]) || "utf-8");
|
||||
});
|
||||
|
||||
register("File_Write", async (_ctx, node, args) => {
|
||||
const filePath = requirePath(pick(args, ["path", "file", "file_path"]), "文件路径", node.id, node.componentId);
|
||||
const content = String(args.content ?? args.value ?? "");
|
||||
const append = truthy(args.append);
|
||||
return writeText(filePath, content, append, pick(args, ["encode"]) || "utf-8");
|
||||
});
|
||||
|
||||
register(["File_CreateFile"], async (_ctx, node, args) => {
|
||||
const filePath = requirePath(pick(args, ["path", "file", "file_path"]), "文件路径", node.id, node.componentId);
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
await fs.writeFile(filePath, "", { flag: "a" });
|
||||
return filePath;
|
||||
});
|
||||
|
||||
register("File_Copy", async (_ctx, node, args) => {
|
||||
const source = requirePath(pick(args, ["pathSrc", "src", "source"]), "源路径", node.id, node.componentId);
|
||||
const target = requirePath(pick(args, ["pathDest", "dest", "target"]), "目标路径", node.id, node.componentId);
|
||||
const overwrite = truthy(args.cover ?? args.overwrite);
|
||||
const stat = await fs.stat(source);
|
||||
if (stat.isDirectory()) {
|
||||
await fs.cp(source, target, { recursive: true, force: overwrite });
|
||||
} else {
|
||||
await fs.mkdir(path.dirname(target), { recursive: true });
|
||||
await fs.copyFile(source, target);
|
||||
}
|
||||
return target;
|
||||
});
|
||||
|
||||
register(["File_File_Move", "File_Move"], async (_ctx, node, args) => {
|
||||
const source = requirePath(pick(args, ["pathSrc", "src", "source"]), "源路径", node.id, node.componentId);
|
||||
const target = requirePath(pick(args, ["pathDest", "dest", "target"]), "目标路径", node.id, node.componentId);
|
||||
await fs.mkdir(path.dirname(target), { recursive: true });
|
||||
try {
|
||||
await fs.rename(source, target);
|
||||
} catch {
|
||||
await fs.cp(source, target, { recursive: true, force: true });
|
||||
await fs.rm(source, { recursive: true, force: true });
|
||||
}
|
||||
return target;
|
||||
});
|
||||
|
||||
register(["File_DeleteFile", "File_Delete"], async (_ctx, node, args) => {
|
||||
const type = String(args.file_type ?? "file");
|
||||
const target = type === "folder"
|
||||
? pick(args, ["folder_path", "path"])
|
||||
: pick(args, ["file_path", "path"]);
|
||||
const resolved = requirePath(target, "待删除路径", node.id, node.componentId);
|
||||
await fs.rm(resolved, { recursive: true, force: true });
|
||||
return resolved;
|
||||
});
|
||||
|
||||
register(["File_FileExists", "File_PathExists"], async (_ctx, node, args) => {
|
||||
const target = requirePath(pick(args, ["path", "file", "file_path"]), "文件路径", node.id, node.componentId);
|
||||
try {
|
||||
await fs.access(target);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
register("File_GetFileList", async (_ctx, node, args) => {
|
||||
const folder = requirePath(pick(args, ["folder_path", "folder", "path"]), "文件夹", node.id, node.componentId);
|
||||
const keyword = pick(args, ["key_word"]);
|
||||
const ignore = pick(args, ["ignore"]);
|
||||
const recursive = truthy(args.recursive);
|
||||
const fullPath = truthy(args.is_full_path ?? "1");
|
||||
const results: string[] = [];
|
||||
async function walk(dir: string): Promise<void> {
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const absolute = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (recursive) await walk(absolute);
|
||||
continue;
|
||||
}
|
||||
if (keyword && !entry.name.includes(keyword)) continue;
|
||||
if (ignore && entry.name.includes(ignore)) continue;
|
||||
results.push(fullPath ? absolute : entry.name);
|
||||
}
|
||||
}
|
||||
await walk(folder);
|
||||
results.sort((a, b) => a.localeCompare(b, "zh-CN"));
|
||||
return results;
|
||||
});
|
||||
|
||||
register(["File_MakeDir", "File_MakeFolder", "File_CreateFolder"], async (_ctx, node, args) => {
|
||||
const base = pick(args, ["path", "folder_path", "folder"]);
|
||||
const name = pick(args, ["name"]);
|
||||
const target = requirePath(name ? path.join(base, name) : base, "文件夹路径", node.id, node.componentId);
|
||||
await fs.mkdir(target, { recursive: true });
|
||||
return target;
|
||||
});
|
||||
|
||||
register(["File_PathSize", "File_PathSizeNew"], async (_ctx, node, args) => {
|
||||
const target = requirePath(pick(args, ["path", "file"]), "路径", node.id, node.componentId);
|
||||
const stat = await fs.stat(target);
|
||||
return stat.size;
|
||||
});
|
||||
|
||||
register("File_GetFileInfo", async (_ctx, node, args) => {
|
||||
const target = requirePath(pick(args, ["path", "file"]), "路径", node.id, node.componentId);
|
||||
const stat = await fs.stat(target);
|
||||
return {
|
||||
path: target,
|
||||
name: path.basename(target),
|
||||
size: stat.size,
|
||||
isDirectory: stat.isDirectory(),
|
||||
createdAt: stat.birthtime.toISOString(),
|
||||
modifiedAt: stat.mtime.toISOString()
|
||||
};
|
||||
});
|
||||
|
||||
register("File_WaitFile", async (ctx, node, args) => {
|
||||
const target = requirePath(pick(args, ["path", "file"]), "文件路径", node.id, node.componentId);
|
||||
const timeout = Math.max(1, Number(args.time_out ?? args.timeout ?? 30)) * 1000;
|
||||
const started = Date.now();
|
||||
while (Date.now() - started < timeout) {
|
||||
if (ctx.signal.aborted) throw new RuntimeError("cancelled", "运行已取消");
|
||||
try {
|
||||
await fs.access(target);
|
||||
return true;
|
||||
} catch {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
}
|
||||
throw new RuntimeError("timeout", `等待文件超时:${target}`, {
|
||||
nodeId: node.id,
|
||||
componentId: node.componentId,
|
||||
recommendation: "检查文件是否生成,或调大超时时间"
|
||||
});
|
||||
});
|
||||
|
||||
register("File_CompressFile", async (_ctx, node, args) => {
|
||||
const source = requirePath(pick(args, ["pathSrc", "path", "folder_path"]), "源路径", node.id, node.componentId);
|
||||
const target = requirePath(pick(args, ["pathDest", "zip_path", "out_path"]), "压缩包路径", node.id, node.componentId);
|
||||
const zip = new AdmZip();
|
||||
const stat = await fs.stat(source);
|
||||
if (stat.isDirectory()) zip.addLocalFolder(source);
|
||||
else zip.addLocalFile(source);
|
||||
await fs.mkdir(path.dirname(target), { recursive: true });
|
||||
zip.writeZip(target);
|
||||
return target;
|
||||
});
|
||||
|
||||
register("File_DecompressFile", async (_ctx, node, args) => {
|
||||
const source = requirePath(pick(args, ["folder_path", "path", "zip_path"]), "压缩包路径", node.id, node.componentId);
|
||||
const target = requirePath(pick(args, ["out_path", "pathDest", "target"]), "解压目录", node.id, node.componentId);
|
||||
await fs.mkdir(target, { recursive: true });
|
||||
new AdmZip(source).extractAllTo(target, true);
|
||||
return target;
|
||||
});
|
||||
|
||||
register("File_Rename", async (_ctx, node, args) => {
|
||||
const source = requirePath(pick(args, ["pathSrc", "path"]), "源路径", node.id, node.componentId);
|
||||
const newName = pick(args, ["reName", "name"]);
|
||||
const target = path.join(path.dirname(source), newName);
|
||||
await fs.rename(source, target);
|
||||
return target;
|
||||
});
|
||||
143
src/main/capabilities/http.ts
Normal file
143
src/main/capabilities/http.ts
Normal file
@ -0,0 +1,143 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { register } from "../engine/registry";
|
||||
import { RuntimeError } from "../engine/types";
|
||||
|
||||
function parseObject(value: unknown): Record<string, string> {
|
||||
if (!value) return {};
|
||||
if (typeof value === "object") {
|
||||
const result: Record<string, string> = {};
|
||||
for (const [key, item] of Object.entries(value as Record<string, unknown>)) result[key] = String(item ?? "");
|
||||
return result;
|
||||
}
|
||||
const text = String(value).trim();
|
||||
if (!text) return {};
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
const result: Record<string, string> = {};
|
||||
for (const [key, item] of Object.entries(parsed as Record<string, unknown>)) result[key] = String(item ?? "");
|
||||
return result;
|
||||
}
|
||||
} catch {
|
||||
// fall through to query string
|
||||
}
|
||||
const result: Record<string, string> = {};
|
||||
for (const pair of text.split("&")) {
|
||||
if (!pair) continue;
|
||||
const [key, ...rest] = pair.split("=");
|
||||
result[decodeURIComponent(key)] = decodeURIComponent(rest.join("="));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function withQuery(url: string, params: Record<string, string>): string {
|
||||
if (!Object.keys(params).length) return url;
|
||||
const target = new URL(url);
|
||||
for (const [key, value] of Object.entries(params)) target.searchParams.set(key, value);
|
||||
return target.toString();
|
||||
}
|
||||
|
||||
async function request(
|
||||
nodeId: string,
|
||||
componentId: string,
|
||||
method: string,
|
||||
args: Record<string, unknown>,
|
||||
signal: AbortSignal
|
||||
): Promise<unknown> {
|
||||
const rawUrl = String(args.url ?? args.web_page ?? "").trim();
|
||||
if (!rawUrl) {
|
||||
throw new RuntimeError("validation_error", "URL 为空", {
|
||||
nodeId,
|
||||
componentId,
|
||||
recommendation: "在属性面板填写请求地址"
|
||||
});
|
||||
}
|
||||
const timeout = Math.max(1000, Number(args.timeout ?? args.time_out ?? 60000));
|
||||
const headers = parseObject(args.headers);
|
||||
const cookies = parseObject(args.cookies);
|
||||
if (Object.keys(cookies).length) {
|
||||
headers.Cookie = Object.entries(cookies).map(([key, value]) => `${key}=${value}`).join("; ");
|
||||
}
|
||||
const params = parseObject(args.param);
|
||||
const body = String(args.body ?? args.data ?? "");
|
||||
|
||||
let url = rawUrl;
|
||||
let payload: string | undefined;
|
||||
if (method === "GET") {
|
||||
url = withQuery(url, params);
|
||||
} else if (body) {
|
||||
payload = body;
|
||||
} else if (Object.keys(params).length) {
|
||||
const asJson = /json/i.test(headers["Content-Type"] || headers["content-type"] || "");
|
||||
if (asJson) {
|
||||
payload = JSON.stringify(params);
|
||||
headers["Content-Type"] = headers["Content-Type"] || "application/json";
|
||||
} else {
|
||||
payload = new URLSearchParams(params).toString();
|
||||
headers["Content-Type"] = headers["Content-Type"] || "application/x-www-form-urlencoded";
|
||||
}
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeout);
|
||||
const onAbort = () => controller.abort();
|
||||
if (signal.aborted) controller.abort();
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
|
||||
try {
|
||||
const response = await fetch(url, { method, headers, body: payload, signal: controller.signal });
|
||||
const contentType = response.headers.get("content-type") || "";
|
||||
const text = await response.text();
|
||||
if (!response.ok && !/Http_Http_Request/i.test(componentId)) {
|
||||
throw new RuntimeError("network_error", `HTTP ${response.status} ${response.statusText}`, {
|
||||
nodeId,
|
||||
componentId,
|
||||
recommendation: "检查地址、参数与鉴权信息"
|
||||
});
|
||||
}
|
||||
const savePath = String(args.file_path ?? "").trim();
|
||||
if (savePath) {
|
||||
await fs.mkdir(path.dirname(path.resolve(savePath)), { recursive: true });
|
||||
await fs.writeFile(path.resolve(savePath), text, "utf8");
|
||||
}
|
||||
if (/json/i.test(contentType) || (text.trim().startsWith("{") || text.trim().startsWith("["))) {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
return text;
|
||||
} catch (error) {
|
||||
if (error instanceof RuntimeError) throw error;
|
||||
if ((error as Error).name === "AbortError") {
|
||||
throw new RuntimeError("timeout", `请求超时(${timeout}ms)`, {
|
||||
nodeId,
|
||||
componentId,
|
||||
recommendation: "增加超时时间或检查网络"
|
||||
});
|
||||
}
|
||||
throw new RuntimeError("network_error", (error as Error).message, {
|
||||
nodeId,
|
||||
componentId,
|
||||
recommendation: "检查 URL、代理与网络连接"
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
}
|
||||
}
|
||||
|
||||
register(["Http_Get", "Http_Get_V1", "Http_Get_V2"], (ctx, node, args) =>
|
||||
request(node.id, node.componentId, "GET", args, ctx.signal)
|
||||
);
|
||||
|
||||
register(["Http_Post", "Http_Post_V2", "Http_Post_V3"], (ctx, node, args) =>
|
||||
request(node.id, node.componentId, "POST", args, ctx.signal)
|
||||
);
|
||||
|
||||
register("Http_Http_Request", (ctx, node, args) => {
|
||||
const method = String(args.method ?? "GET").toUpperCase();
|
||||
return request(node.id, node.componentId, method, args, ctx.signal);
|
||||
});
|
||||
7
src/main/capabilities/index.ts
Normal file
7
src/main/capabilities/index.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import "./control";
|
||||
import "./files";
|
||||
import "./http";
|
||||
import "./data";
|
||||
import "./browser";
|
||||
|
||||
export { capabilityCount, capabilityIds, getHandler, hasCapability, register } from "../engine/registry";
|
||||
@ -2,6 +2,7 @@ import { app } from "electron";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { Catalog, CatalogComponent } from "../shared/types";
|
||||
import { hasCapability } from "./engine/registry";
|
||||
|
||||
let cached: Catalog | null = null;
|
||||
|
||||
@ -18,28 +19,6 @@ function resolveCatalogPath(): string {
|
||||
throw new Error(`catalog.json not found; tried: ${candidates.join(", ")}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 已注册执行器的函数名。Day 2 起由 capabilities 注册表汇总;
|
||||
* Day 1 先标出控制/触发与少量基础组件,保证目录状态可演示。
|
||||
*/
|
||||
const EXECUTABLE_FUNCTIONS = new Set<string>([
|
||||
"Trigger.Manual",
|
||||
"Trigger.Schedule",
|
||||
"Trigger.FileArrival",
|
||||
"logic_control_if_else",
|
||||
"logic_control_count_for",
|
||||
"logic_control_array_for_v3",
|
||||
"logic_control_while",
|
||||
"logic_control_try_catch",
|
||||
"logic_control_var",
|
||||
"logic_control_break_for",
|
||||
"logic_control_next_for",
|
||||
"logic_control_stop",
|
||||
"Basic_Delay_V2",
|
||||
"Basic_SetVariable",
|
||||
"Basic_DebugOutput"
|
||||
]);
|
||||
|
||||
export function catalogPath(): string {
|
||||
return resolveCatalogPath();
|
||||
}
|
||||
@ -50,13 +29,12 @@ export function loadCatalog(): Catalog {
|
||||
const catalog = JSON.parse(raw) as Catalog;
|
||||
catalog.components = catalog.components.map((component: CatalogComponent) => ({
|
||||
...component,
|
||||
implemented: EXECUTABLE_FUNCTIONS.has(component.functionName) || EXECUTABLE_FUNCTIONS.has(component.id)
|
||||
implemented: hasCapability(component.id) || hasCapability(component.functionName)
|
||||
}));
|
||||
cached = catalog;
|
||||
return catalog;
|
||||
}
|
||||
|
||||
export function markImplemented(functionNames: Iterable<string>): void {
|
||||
for (const name of functionNames) EXECUTABLE_FUNCTIONS.add(name);
|
||||
export function invalidateCatalog(): void {
|
||||
cached = null;
|
||||
}
|
||||
|
||||
464
src/main/engine/expression.ts
Normal file
464
src/main/engine/expression.ts
Normal file
@ -0,0 +1,464 @@
|
||||
/**
|
||||
* 轻量表达式求值器:支持 ${expr} 插值、变量、成员/下标、算术/比较/逻辑/三元与白名单函数。
|
||||
* 不使用 eval / new Function;错误带字符位置,便于在节点日志里定位。
|
||||
*/
|
||||
|
||||
type TokenType = "num" | "str" | "ident" | "op" | "eof";
|
||||
|
||||
interface Token {
|
||||
type: TokenType;
|
||||
value: string;
|
||||
pos: number;
|
||||
}
|
||||
|
||||
export class ExpressionError extends Error {
|
||||
position: number;
|
||||
constructor(message: string, position = 0) {
|
||||
super(message);
|
||||
this.name = "ExpressionError";
|
||||
this.position = position;
|
||||
}
|
||||
}
|
||||
|
||||
const OPERATORS = [
|
||||
"===",
|
||||
"!==",
|
||||
"==",
|
||||
"!=",
|
||||
">=",
|
||||
"<=",
|
||||
"&&",
|
||||
"||",
|
||||
"??",
|
||||
"+",
|
||||
"-",
|
||||
"*",
|
||||
"/",
|
||||
"%",
|
||||
">",
|
||||
"<",
|
||||
"!",
|
||||
"?",
|
||||
":",
|
||||
"(",
|
||||
")",
|
||||
"[",
|
||||
"]",
|
||||
".",
|
||||
","
|
||||
];
|
||||
|
||||
function tokenize(source: string): Token[] {
|
||||
const tokens: Token[] = [];
|
||||
let i = 0;
|
||||
while (i < source.length) {
|
||||
const ch = source[i];
|
||||
if (/\s/.test(ch)) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (/[0-9]/.test(ch) || (ch === "." && /[0-9]/.test(source[i + 1] || ""))) {
|
||||
const start = i;
|
||||
while (i < source.length && /[0-9.]/.test(source[i])) i += 1;
|
||||
tokens.push({ type: "num", value: source.slice(start, i), pos: start });
|
||||
continue;
|
||||
}
|
||||
if (ch === '"' || ch === "'") {
|
||||
const quote = ch;
|
||||
const start = i;
|
||||
i += 1;
|
||||
let value = "";
|
||||
while (i < source.length && source[i] !== quote) {
|
||||
if (source[i] === "\\") {
|
||||
i += 1;
|
||||
const next = source[i];
|
||||
if (next === "n") value += "\n";
|
||||
else if (next === "t") value += "\t";
|
||||
else if (next === "r") value += "\r";
|
||||
else value += next ?? "";
|
||||
i += 1;
|
||||
} else {
|
||||
value += source[i];
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
if (source[i] !== quote) throw new ExpressionError("字符串未闭合", start);
|
||||
i += 1;
|
||||
tokens.push({ type: "str", value, pos: start });
|
||||
continue;
|
||||
}
|
||||
if (/[A-Za-z_\u4e00-\u9fa5$]/.test(ch)) {
|
||||
const start = i;
|
||||
while (i < source.length && /[A-Za-z0-9_\u4e00-\u9fa5$]/.test(source[i])) i += 1;
|
||||
tokens.push({ type: "ident", value: source.slice(start, i), pos: start });
|
||||
continue;
|
||||
}
|
||||
const op = OPERATORS.find((candidate) => source.startsWith(candidate, i));
|
||||
if (op) {
|
||||
tokens.push({ type: "op", value: op, pos: i });
|
||||
i += op.length;
|
||||
continue;
|
||||
}
|
||||
throw new ExpressionError(`无法识别的字符「${ch}」`, i);
|
||||
}
|
||||
tokens.push({ type: "eof", value: "", pos: source.length });
|
||||
return tokens;
|
||||
}
|
||||
|
||||
type Ast =
|
||||
| { type: "literal"; value: unknown }
|
||||
| { type: "identifier"; name: string; pos: number }
|
||||
| { type: "member"; object: Ast; property: Ast; computed: boolean; pos: number }
|
||||
| { type: "call"; callee: Ast; args: Ast[]; pos: number }
|
||||
| { type: "unary"; op: string; argument: Ast; pos: number }
|
||||
| { type: "binary"; op: string; left: Ast; right: Ast; pos: number }
|
||||
| { type: "ternary"; test: Ast; consequent: Ast; alternate: Ast; pos: number }
|
||||
| { type: "array"; items: Ast[]; pos: number };
|
||||
|
||||
const PRECEDENCE: Record<string, number> = {
|
||||
"||": 1,
|
||||
"??": 1,
|
||||
"&&": 2,
|
||||
"==": 3,
|
||||
"!=": 3,
|
||||
"===": 3,
|
||||
"!==": 3,
|
||||
">": 4,
|
||||
">=": 4,
|
||||
"<": 4,
|
||||
"<=": 4,
|
||||
"+": 5,
|
||||
"-": 5,
|
||||
"*": 6,
|
||||
"/": 6,
|
||||
"%": 6
|
||||
};
|
||||
|
||||
class Parser {
|
||||
private tokens: Token[];
|
||||
private index = 0;
|
||||
|
||||
constructor(source: string) {
|
||||
this.tokens = tokenize(source);
|
||||
}
|
||||
|
||||
parse(): Ast {
|
||||
const expression = this.parseExpression(0);
|
||||
const token = this.peek();
|
||||
if (token.type !== "eof") throw new ExpressionError(`多余的内容「${token.value}」`, token.pos);
|
||||
return expression;
|
||||
}
|
||||
|
||||
private peek(): Token {
|
||||
return this.tokens[this.index];
|
||||
}
|
||||
|
||||
private next(): Token {
|
||||
return this.tokens[this.index++];
|
||||
}
|
||||
|
||||
private expect(value: string): Token {
|
||||
const token = this.next();
|
||||
if (token.value !== value) throw new ExpressionError(`期望「${value}」,实际「${token.value || "结束"}」`, token.pos);
|
||||
return token;
|
||||
}
|
||||
|
||||
private parseExpression(minPrecedence: number): Ast {
|
||||
let left = this.parsePrefix();
|
||||
for (;;) {
|
||||
const token = this.peek();
|
||||
if (token.type === "op" && token.value === "?") {
|
||||
if (minPrecedence > 0) break;
|
||||
this.next();
|
||||
const consequent = this.parseExpression(0);
|
||||
this.expect(":");
|
||||
const alternate = this.parseExpression(0);
|
||||
left = { type: "ternary", test: left, consequent, alternate, pos: token.pos };
|
||||
continue;
|
||||
}
|
||||
const precedence = token.type === "op" ? PRECEDENCE[token.value] : undefined;
|
||||
if (precedence === undefined || precedence < minPrecedence) break;
|
||||
this.next();
|
||||
const right = this.parseExpression(precedence + 1);
|
||||
left = { type: "binary", op: token.value, left, right, pos: token.pos };
|
||||
}
|
||||
return left;
|
||||
}
|
||||
|
||||
private parsePrefix(): Ast {
|
||||
const token = this.next();
|
||||
if (token.type === "num") return { type: "literal", value: Number(token.value) };
|
||||
if (token.type === "str") return { type: "literal", value: token.value };
|
||||
if (token.type === "ident") {
|
||||
if (token.value === "true") return { type: "literal", value: true };
|
||||
if (token.value === "false") return { type: "literal", value: false };
|
||||
if (token.value === "null") return { type: "literal", value: null };
|
||||
if (token.value === "undefined") return { type: "literal", value: undefined };
|
||||
return this.parsePostfix({ type: "identifier", name: token.value, pos: token.pos });
|
||||
}
|
||||
if (token.type === "op") {
|
||||
if (token.value === "(") {
|
||||
const expression = this.parseExpression(0);
|
||||
this.expect(")");
|
||||
return this.parsePostfix(expression);
|
||||
}
|
||||
if (token.value === "[" ) {
|
||||
const items: Ast[] = [];
|
||||
if (this.peek().value !== "]") {
|
||||
for (;;) {
|
||||
items.push(this.parseExpression(0));
|
||||
if (this.peek().value === ",") {
|
||||
this.next();
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
this.expect("]");
|
||||
return { type: "array", items, pos: token.pos };
|
||||
}
|
||||
if (token.value === "!" || token.value === "-") {
|
||||
return { type: "unary", op: token.value, argument: this.parseExpression(7), pos: token.pos };
|
||||
}
|
||||
}
|
||||
throw new ExpressionError(`意外的「${token.value || "结束"}」`, token.pos);
|
||||
}
|
||||
|
||||
private parsePostfix(base: Ast): Ast {
|
||||
let expression = base;
|
||||
for (;;) {
|
||||
const token = this.peek();
|
||||
if (token.value === ".") {
|
||||
this.next();
|
||||
const property = this.next();
|
||||
if (property.type !== "ident") throw new ExpressionError("属性名缺失", property.pos);
|
||||
expression = {
|
||||
type: "member",
|
||||
object: expression,
|
||||
property: { type: "literal", value: property.value },
|
||||
computed: false,
|
||||
pos: token.pos
|
||||
};
|
||||
} else if (token.value === "[") {
|
||||
this.next();
|
||||
const property = this.parseExpression(0);
|
||||
this.expect("]");
|
||||
expression = { type: "member", object: expression, property, computed: true, pos: token.pos };
|
||||
} else if (token.value === "(") {
|
||||
this.next();
|
||||
const args: Ast[] = [];
|
||||
if (this.peek().value !== ")") {
|
||||
for (;;) {
|
||||
args.push(this.parseExpression(0));
|
||||
if (this.peek().value === ",") {
|
||||
this.next();
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
this.expect(")");
|
||||
expression = { type: "call", callee: expression, args, pos: token.pos };
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return expression;
|
||||
}
|
||||
}
|
||||
|
||||
const astCache = new Map<string, Ast>();
|
||||
|
||||
function parse(source: string): Ast {
|
||||
const cached = astCache.get(source);
|
||||
if (cached) return cached;
|
||||
const ast = new Parser(source).parse();
|
||||
if (astCache.size > 500) astCache.clear();
|
||||
astCache.set(source, ast);
|
||||
return ast;
|
||||
}
|
||||
|
||||
type Scope = Record<string, unknown>;
|
||||
|
||||
function hasOwn(scope: Scope, name: string): boolean {
|
||||
return Object.prototype.hasOwnProperty.call(scope, name);
|
||||
}
|
||||
|
||||
function formatDate(value: unknown, pattern = "YYYY-MM-DD HH:mm:ss"): string {
|
||||
const date = value instanceof Date ? value : new Date(value as string | number);
|
||||
if (Number.isNaN(date.getTime())) return "";
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return pattern
|
||||
.replace(/YYYY/g, String(date.getFullYear()))
|
||||
.replace(/MM/g, pad(date.getMonth() + 1))
|
||||
.replace(/DD/g, pad(date.getDate()))
|
||||
.replace(/HH/g, pad(date.getHours()))
|
||||
.replace(/mm/g, pad(date.getMinutes()))
|
||||
.replace(/ss/g, pad(date.getSeconds()));
|
||||
}
|
||||
|
||||
const FUNCTIONS: Record<string, (...args: unknown[]) => unknown> = {
|
||||
len: (value) => (value == null ? 0 : (value as string | unknown[]).length ?? 0),
|
||||
upper: (value) => String(value ?? "").toUpperCase(),
|
||||
lower: (value) => String(value ?? "").toLowerCase(),
|
||||
trim: (value) => String(value ?? "").trim(),
|
||||
replace: (value, from, to) => String(value ?? "").split(String(from ?? "")).join(String(to ?? "")),
|
||||
split: (value, separator) => String(value ?? "").split(String(separator ?? "")),
|
||||
join: (value, separator = ",") => (Array.isArray(value) ? value.join(String(separator)) : ""),
|
||||
contains: (value, needle) => String(value ?? "").includes(String(needle ?? "")),
|
||||
startsWith: (value, prefix) => String(value ?? "").startsWith(String(prefix ?? "")),
|
||||
endsWith: (value, suffix) => String(value ?? "").endsWith(String(suffix ?? "")),
|
||||
matches: (value, pattern) => {
|
||||
try {
|
||||
return new RegExp(String(pattern ?? "")).test(String(value ?? ""));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
number: (value) => {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
},
|
||||
string: (value) => (value == null ? "" : String(value)),
|
||||
round: (value, digits = 0) => {
|
||||
const factor = 10 ** Number(digits || 0);
|
||||
return Math.round(Number(value) * factor) / factor;
|
||||
},
|
||||
abs: (value) => Math.abs(Number(value)),
|
||||
min: (...args) => Math.min(...args.map(Number)),
|
||||
max: (...args) => Math.max(...args.map(Number)),
|
||||
random: () => Math.random(),
|
||||
now: () => new Date().toISOString(),
|
||||
today: () => formatDate(new Date(), "YYYY-MM-DD"),
|
||||
formatDate: (value, pattern) => formatDate(value, pattern == null ? undefined : String(pattern)),
|
||||
json: (value) => JSON.stringify(value),
|
||||
parseJson: (value) => {
|
||||
try {
|
||||
return JSON.parse(String(value ?? ""));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
sum: (value) => (Array.isArray(value) ? value.reduce((sum, item) => sum + Number(item || 0), 0) : 0),
|
||||
avg: (value) => (Array.isArray(value) && value.length ? value.reduce((sum, item) => sum + Number(item || 0), 0) / value.length : 0),
|
||||
coalesce: (...args) => args.find((item) => item !== undefined && item !== null && item !== "") ?? ""
|
||||
};
|
||||
|
||||
function evaluateAst(ast: Ast, scope: Scope): unknown {
|
||||
switch (ast.type) {
|
||||
case "literal":
|
||||
return ast.value;
|
||||
case "identifier": {
|
||||
if (hasOwn(scope, ast.name)) return scope[ast.name];
|
||||
if (ast.name in FUNCTIONS) return FUNCTIONS[ast.name];
|
||||
return undefined;
|
||||
}
|
||||
case "array":
|
||||
return ast.items.map((item) => evaluateAst(item, scope));
|
||||
case "member": {
|
||||
const object = evaluateAst(ast.object, scope);
|
||||
const property = evaluateAst(ast.property, scope);
|
||||
if (object == null) return undefined;
|
||||
if (Array.isArray(object) && property === "length") return object.length;
|
||||
if (typeof object === "string" && property === "length") return object.length;
|
||||
return (object as Record<string | number, unknown>)[property as string | number];
|
||||
}
|
||||
case "call": {
|
||||
if (ast.callee.type !== "identifier") throw new ExpressionError("只允许调用白名单函数", ast.pos);
|
||||
const fn = FUNCTIONS[ast.callee.name];
|
||||
if (!fn) throw new ExpressionError(`未知函数「${ast.callee.name}」`, ast.pos);
|
||||
return fn(...ast.args.map((arg) => evaluateAst(arg, scope)));
|
||||
}
|
||||
case "unary": {
|
||||
const value = evaluateAst(ast.argument, scope);
|
||||
if (ast.op === "!") return !value;
|
||||
return -Number(value);
|
||||
}
|
||||
case "ternary": {
|
||||
return evaluateAst(ast.test, scope) ? evaluateAst(ast.consequent, scope) : evaluateAst(ast.alternate, scope);
|
||||
}
|
||||
case "binary": {
|
||||
if (ast.op === "&&") {
|
||||
const left = evaluateAst(ast.left, scope);
|
||||
return left ? evaluateAst(ast.right, scope) : left;
|
||||
}
|
||||
if (ast.op === "||") {
|
||||
const left = evaluateAst(ast.left, scope);
|
||||
return left ? left : evaluateAst(ast.right, scope);
|
||||
}
|
||||
if (ast.op === "??") {
|
||||
const left = evaluateAst(ast.left, scope);
|
||||
return left ?? evaluateAst(ast.right, scope);
|
||||
}
|
||||
const left = evaluateAst(ast.left, scope);
|
||||
const right = evaluateAst(ast.right, scope);
|
||||
switch (ast.op) {
|
||||
case "+":
|
||||
return typeof left === "string" || typeof right === "string"
|
||||
? String(left ?? "") + String(right ?? "")
|
||||
: Number(left) + Number(right);
|
||||
case "-":
|
||||
return Number(left) - Number(right);
|
||||
case "*":
|
||||
return Number(left) * Number(right);
|
||||
case "/":
|
||||
return Number(left) / Number(right);
|
||||
case "%":
|
||||
return Number(left) % Number(right);
|
||||
case ">":
|
||||
return left! > right!;
|
||||
case ">=":
|
||||
return left! >= right!;
|
||||
case "<":
|
||||
return left! < right!;
|
||||
case "<=":
|
||||
return left! <= right!;
|
||||
case "==":
|
||||
return left == right;
|
||||
case "!=":
|
||||
return left != right;
|
||||
case "===":
|
||||
return left === right;
|
||||
case "!==":
|
||||
return left !== right;
|
||||
default:
|
||||
throw new ExpressionError(`不支持的运算符「${ast.op}」`, ast.pos);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function evaluate(expression: string, scope: Scope = {}): unknown {
|
||||
const source = expression.trim();
|
||||
if (!source) return "";
|
||||
return evaluateAst(parse(source), scope);
|
||||
}
|
||||
|
||||
/** 整个字符串是 ${expr} 时保留类型;否则按字符串插值。 */
|
||||
export function interpolate(value: unknown, scope: Scope): unknown {
|
||||
if (typeof value !== "string") return value;
|
||||
const whole = value.match(/^\s*\$\{([\s\S]*)\}\s*$/);
|
||||
if (whole) return evaluate(whole[1], scope);
|
||||
if (!value.includes("${")) return value;
|
||||
return value.replace(/\$\{([\s\S]*?)\}/g, (_match, expression: string) => {
|
||||
const result = evaluate(expression, scope);
|
||||
return result == null ? "" : typeof result === "object" ? JSON.stringify(result) : String(result);
|
||||
});
|
||||
}
|
||||
|
||||
/** 条件表达式允许直接写变量名或 ${...} 包裹。 */
|
||||
export function evaluateCondition(value: unknown, scope: Scope): boolean {
|
||||
if (typeof value === "boolean") return value;
|
||||
if (typeof value === "number") return value !== 0;
|
||||
if (typeof value !== "string") return Boolean(value);
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return false;
|
||||
const inner = trimmed.match(/^\$\{([\s\S]*)\}$/);
|
||||
const result = interpolate(inner ? trimmed : `\${${trimmed}}`, scope);
|
||||
return Boolean(result);
|
||||
}
|
||||
|
||||
export function toNumber(value: unknown, fallback = 0): number {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
24
src/main/engine/registry.ts
Normal file
24
src/main/engine/registry.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import type { CapabilityHandler } from "./types";
|
||||
|
||||
const handlers = new Map<string, CapabilityHandler>();
|
||||
|
||||
export function register(ids: string | string[], handler: CapabilityHandler): void {
|
||||
const list = Array.isArray(ids) ? ids : [ids];
|
||||
for (const id of list) handlers.set(id, handler);
|
||||
}
|
||||
|
||||
export function getHandler(id: string): CapabilityHandler | undefined {
|
||||
return handlers.get(id);
|
||||
}
|
||||
|
||||
export function hasCapability(id: string): boolean {
|
||||
return handlers.has(id);
|
||||
}
|
||||
|
||||
export function capabilityIds(): string[] {
|
||||
return [...handlers.keys()];
|
||||
}
|
||||
|
||||
export function capabilityCount(): number {
|
||||
return handlers.size;
|
||||
}
|
||||
467
src/main/engine/runtime.ts
Normal file
467
src/main/engine/runtime.ts
Normal file
@ -0,0 +1,467 @@
|
||||
import type { FlowDefinition, FlowNode, RunError } from "../../shared/types";
|
||||
import { evaluate, evaluateCondition, interpolate, toNumber } from "./expression";
|
||||
import { getHandler } from "./registry";
|
||||
import {
|
||||
asRuntimeError,
|
||||
BreakSignal,
|
||||
ContinueSignal,
|
||||
RuntimeError,
|
||||
StopSignal,
|
||||
type NodeState,
|
||||
type RunContext,
|
||||
type RunResult
|
||||
} from "./types";
|
||||
|
||||
const MAX_LOOP = 10000;
|
||||
|
||||
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
if (ms <= 0) return Promise.resolve();
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
resolve();
|
||||
}, ms);
|
||||
const onAbort = () => {
|
||||
clearTimeout(timer);
|
||||
reject(new RuntimeError("cancelled", "运行已取消"));
|
||||
};
|
||||
if (signal?.aborted) return onAbort();
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, signal: AbortSignal, label: string): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
let settled = false;
|
||||
const finish = (fn: () => void) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
fn();
|
||||
};
|
||||
const timer = setTimeout(() => {
|
||||
finish(() => reject(new RuntimeError("timeout", `${label} 执行超时(${timeoutMs}ms)`, {
|
||||
recommendation: "检查目标是否可达,或调大该节点的超时时间"
|
||||
})));
|
||||
}, timeoutMs > 0 ? timeoutMs : 30000);
|
||||
const onAbort = () => finish(() => reject(new RuntimeError("cancelled", "运行已取消")));
|
||||
if (signal.aborted) return onAbort();
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
promise.then(
|
||||
(value) => finish(() => resolve(value)),
|
||||
(error) => finish(() => reject(error))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function isRetryable(code: string): boolean {
|
||||
return !["validation_error", "capability_not_implemented", "blocked_url", "cancelled", "expression_error"].includes(code);
|
||||
}
|
||||
|
||||
function resolveArgs(node: FlowNode, component: { fields: Array<{ key: string; defaultValue: string }> }, vars: Record<string, unknown>): Record<string, unknown> {
|
||||
const args: Record<string, unknown> = {};
|
||||
for (const field of component.fields) {
|
||||
const raw = Object.prototype.hasOwnProperty.call(node.values, field.key) ? node.values[field.key] : field.defaultValue;
|
||||
try {
|
||||
args[field.key] = interpolate(raw, vars);
|
||||
} catch (error) {
|
||||
throw new RuntimeError("expression_error", `参数 ${field.key} 求值失败:${(error as Error).message}`, {
|
||||
arg: field.key,
|
||||
recommendation: "检查 ${...} 中的变量名与表达式语法"
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const [key, value] of Object.entries(node.values || {})) {
|
||||
if (key in args) continue;
|
||||
try {
|
||||
args[key] = interpolate(value, vars);
|
||||
} catch (error) {
|
||||
throw new RuntimeError("expression_error", `参数 ${key} 求值失败:${(error as Error).message}`, { arg: key });
|
||||
}
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
function childChain(flow: FlowDefinition, parentId: string, slot: string): FlowNode[] {
|
||||
return flow.nodes.filter((node) => node.parentId === parentId && node.slot === slot);
|
||||
}
|
||||
|
||||
function asArray(value: unknown): unknown[] {
|
||||
if (Array.isArray(value)) return value;
|
||||
if (value == null || value === "") return [];
|
||||
if (typeof value === "string") {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return Array.isArray(parsed) ? parsed : [parsed];
|
||||
} catch {
|
||||
return value.split(/[,,\n]/).map((item) => item.trim()).filter(Boolean);
|
||||
}
|
||||
}
|
||||
if (typeof value === "object") return Object.values(value as Record<string, unknown>);
|
||||
return [value];
|
||||
}
|
||||
|
||||
export async function runFlow(
|
||||
flow: FlowDefinition,
|
||||
options: {
|
||||
runId: string;
|
||||
evidenceDir: string;
|
||||
signal: AbortSignal;
|
||||
emit: RunContext["emit"];
|
||||
log: RunContext["log"];
|
||||
componentOf: RunContext["componentOf"];
|
||||
browser?: RunContext["browser"];
|
||||
variables?: Record<string, unknown>;
|
||||
}
|
||||
): Promise<RunResult> {
|
||||
const startedAt = new Date().toISOString();
|
||||
const startMs = Date.now();
|
||||
const vars: Record<string, unknown> = { ...(options.variables || {}) };
|
||||
const ctx: RunContext = {
|
||||
runId: options.runId,
|
||||
flowId: flow.id,
|
||||
flow,
|
||||
vars,
|
||||
secrets: {},
|
||||
signal: options.signal,
|
||||
evidenceDir: options.evidenceDir,
|
||||
browser: options.browser,
|
||||
emit: options.emit,
|
||||
log: options.log,
|
||||
componentOf: options.componentOf
|
||||
};
|
||||
const states = new Map<string, NodeState>();
|
||||
let status: RunResult["status"] = "succeeded";
|
||||
let failure: RunError | undefined;
|
||||
|
||||
options.emit({ type: "run.started", startedAt });
|
||||
|
||||
const order = topLevelOrder(flow);
|
||||
try {
|
||||
for (const node of order) {
|
||||
if (options.signal.aborted) throw new RuntimeError("cancelled", "运行已取消");
|
||||
await executeNode(node, ctx, states);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof StopSignal) {
|
||||
options.log("流程被「退出流程」节点终止", "warn");
|
||||
status = "succeeded";
|
||||
} else if (error instanceof BreakSignal || error instanceof ContinueSignal) {
|
||||
failure = { code: "validation_error", reason: "跳出/继续语句不在循环体内" };
|
||||
status = "failed";
|
||||
} else {
|
||||
const runtimeError = asRuntimeError(error);
|
||||
failure = runtimeError;
|
||||
status = runtimeError.code === "cancelled" ? "cancelled" : "failed";
|
||||
}
|
||||
}
|
||||
|
||||
const finishedAt = new Date().toISOString();
|
||||
const durationMs = Date.now() - startMs;
|
||||
options.emit({
|
||||
type: "run.finished",
|
||||
status,
|
||||
durationMs,
|
||||
error: failure,
|
||||
startedAt
|
||||
});
|
||||
|
||||
return {
|
||||
runId: options.runId,
|
||||
status,
|
||||
startedAt,
|
||||
finishedAt,
|
||||
durationMs,
|
||||
nodes: [...states.values()],
|
||||
variables: vars,
|
||||
error: failure
|
||||
};
|
||||
}
|
||||
|
||||
function topLevelOrder(flow: FlowDefinition): FlowNode[] {
|
||||
const top = flow.nodes.filter((node) => !node.parentId);
|
||||
if (top.length <= 1) return top;
|
||||
const topIds = new Set(top.map((node) => node.id));
|
||||
const incoming = new Map<string, number>();
|
||||
for (const edge of flow.edges) {
|
||||
if (topIds.has(edge.source) && topIds.has(edge.target)) {
|
||||
incoming.set(edge.target, (incoming.get(edge.target) || 0) + 1);
|
||||
}
|
||||
}
|
||||
const start =
|
||||
top.find((node) => !incoming.get(node.id) && node.componentId.startsWith("Trigger.")) ||
|
||||
top.find((node) => !incoming.get(node.id)) ||
|
||||
top[0];
|
||||
const ordered: FlowNode[] = [];
|
||||
const visited = new Set<string>();
|
||||
let current: FlowNode | undefined = start;
|
||||
while (current && !visited.has(current.id)) {
|
||||
ordered.push(current);
|
||||
visited.add(current.id);
|
||||
const edge = flow.edges.find((item) => item.source === current!.id && topIds.has(item.target));
|
||||
current = edge ? top.find((node) => node.id === edge.target) : undefined;
|
||||
}
|
||||
for (const node of [...top].sort((a, b) => a.position.y - b.position.y)) {
|
||||
if (!visited.has(node.id)) ordered.push(node);
|
||||
}
|
||||
return ordered;
|
||||
}
|
||||
|
||||
async function executeNode(node: FlowNode, ctx: RunContext, states: Map<string, NodeState>): Promise<unknown> {
|
||||
const component = ctx.componentOf(node);
|
||||
const state: NodeState = {
|
||||
nodeId: node.id,
|
||||
componentId: node.componentId,
|
||||
status: "running",
|
||||
startedAt: new Date().toISOString(),
|
||||
evidence: [],
|
||||
attempt: 1
|
||||
};
|
||||
states.set(node.id, state);
|
||||
ctx.emit({ type: "node.started", nodeId: node.id, attempt: 1 });
|
||||
|
||||
if (!component) {
|
||||
const error = new RuntimeError("validation_error", `组件 ${node.componentId} 不在目录中`, {
|
||||
nodeId: node.id,
|
||||
componentId: node.componentId,
|
||||
recommendation: "重新添加该节点"
|
||||
});
|
||||
state.status = "failed";
|
||||
state.error = error.toRunError();
|
||||
state.finishedAt = new Date().toISOString();
|
||||
ctx.emit({ type: "node.finished", nodeId: node.id, status: "failed", error: state.error, durationMs: 0, evidence: [] });
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (node.enabled === false) {
|
||||
state.status = "skipped";
|
||||
state.finishedAt = new Date().toISOString();
|
||||
ctx.emit({ type: "node.finished", nodeId: node.id, status: "skipped", durationMs: 0, evidence: [] });
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const startMs = Date.now();
|
||||
try {
|
||||
let result: unknown;
|
||||
if (component.slots.length > 0) {
|
||||
result = await executeContainer(node, component.id, ctx, states);
|
||||
} else {
|
||||
const handler = getHandler(node.componentId) || getHandler(component.functionName);
|
||||
if (!handler) {
|
||||
throw new RuntimeError(
|
||||
"capability_not_implemented",
|
||||
`组件「${component.name}」当前版本未接入执行器`,
|
||||
{
|
||||
nodeId: node.id,
|
||||
componentId: node.componentId,
|
||||
recommendation: "换用已接入组件;该能力已列入后续接入清单"
|
||||
}
|
||||
);
|
||||
}
|
||||
const args = resolveArgs(node, component, ctx.vars);
|
||||
const delayBefore = Math.max(0, Number(node.options?.delayBeforeMs || 0));
|
||||
const delayAfter = Math.max(0, Number(node.options?.delayAfterMs || 0));
|
||||
const timeoutMs = Math.max(0, Number(node.options?.timeoutMs || 30000));
|
||||
const retries = Math.max(0, Math.min(100, Number(node.options?.retryCount || 0)));
|
||||
const interval = Math.max(1000, Number(node.options?.retryIntervalMs || 1000));
|
||||
|
||||
await sleep(delayBefore, ctx.signal);
|
||||
for (let attempt = 1; attempt <= retries + 1; attempt += 1) {
|
||||
state.attempt = attempt;
|
||||
try {
|
||||
result = await withTimeout(
|
||||
Promise.resolve(handler(ctx, node, args, component)),
|
||||
timeoutMs,
|
||||
ctx.signal,
|
||||
component.name
|
||||
);
|
||||
break;
|
||||
} catch (error) {
|
||||
if (error instanceof BreakSignal || error instanceof ContinueSignal || error instanceof StopSignal) throw error;
|
||||
const runtimeError = asRuntimeError(error, node, component.id);
|
||||
if (attempt > retries || !isRetryable(runtimeError.code)) {
|
||||
throw new RuntimeError(runtimeError.code, runtimeError.reason, {
|
||||
recommendation: runtimeError.recommendation,
|
||||
nodeId: node.id,
|
||||
componentId: component.id,
|
||||
detail: runtimeError.detail
|
||||
});
|
||||
}
|
||||
ctx.log(
|
||||
`第 ${attempt} 次执行失败:${runtimeError.reason},${Math.round(interval / 1000)} 秒后重试`,
|
||||
"warn",
|
||||
node.id
|
||||
);
|
||||
await sleep(interval, ctx.signal);
|
||||
}
|
||||
}
|
||||
if (node.options?.outputVar) ctx.vars[node.options.outputVar] = result;
|
||||
await sleep(delayAfter, ctx.signal);
|
||||
}
|
||||
|
||||
state.status = "succeeded";
|
||||
state.output = result;
|
||||
state.finishedAt = new Date().toISOString();
|
||||
state.durationMs = Date.now() - startMs;
|
||||
ctx.emit({
|
||||
type: "node.finished",
|
||||
nodeId: node.id,
|
||||
status: "succeeded",
|
||||
output: result,
|
||||
durationMs: state.durationMs,
|
||||
evidence: state.evidence
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (error instanceof BreakSignal || error instanceof ContinueSignal || error instanceof StopSignal) {
|
||||
state.status = "skipped";
|
||||
state.finishedAt = new Date().toISOString();
|
||||
state.durationMs = Date.now() - startMs;
|
||||
ctx.emit({ type: "node.finished", nodeId: node.id, status: "skipped", durationMs: state.durationMs, evidence: [] });
|
||||
throw error;
|
||||
}
|
||||
const runtimeError = asRuntimeError(error, node, component.id);
|
||||
state.error = runtimeError;
|
||||
state.finishedAt = new Date().toISOString();
|
||||
state.durationMs = Date.now() - startMs;
|
||||
const skip = Boolean(node.options?.skipOnError);
|
||||
state.status = skip ? "skipped" : "failed";
|
||||
ctx.log(`${component.name} ${skip ? "失败但已跳过" : "失败"}:${runtimeError.reason}`, "error", node.id);
|
||||
if (node.options?.logException && runtimeError.detail) ctx.log(runtimeError.detail, "error", node.id);
|
||||
ctx.emit({
|
||||
type: "node.finished",
|
||||
nodeId: node.id,
|
||||
status: state.status,
|
||||
error: runtimeError,
|
||||
durationMs: state.durationMs,
|
||||
evidence: []
|
||||
});
|
||||
if (skip) return undefined;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function executeContainer(
|
||||
node: FlowNode,
|
||||
componentId: string,
|
||||
ctx: RunContext,
|
||||
states: Map<string, NodeState>
|
||||
): Promise<unknown> {
|
||||
const runSlot = async (slot: string) => {
|
||||
for (const child of childChain(ctx.flow, node.id, slot)) {
|
||||
if (ctx.signal.aborted) throw new RuntimeError("cancelled", "运行已取消");
|
||||
await executeNode(child, ctx, states);
|
||||
}
|
||||
};
|
||||
|
||||
switch (componentId) {
|
||||
case "logic_control_if_else": {
|
||||
const condition = evaluateCondition(node.values?.condition, ctx.vars);
|
||||
ctx.log(`条件 ${condition ? "成立" : "不成立"}:${String(node.values?.condition ?? "")}`, "info", node.id);
|
||||
await runSlot(condition ? "statement_if" : "statement_else");
|
||||
return condition;
|
||||
}
|
||||
case "logic_control_count_for": {
|
||||
const start = toNumber(interpolate(node.values?.count_start_value, ctx.vars), 1);
|
||||
const end = toNumber(interpolate(node.values?.count_end_value, ctx.vars), 1);
|
||||
const step = toNumber(interpolate(node.values?.count_step, ctx.vars), 1) || 1;
|
||||
const indexName = String(interpolate(node.values?.count_index, ctx.vars) || "index");
|
||||
let iterations = 0;
|
||||
for (let value = start; step > 0 ? value <= end : value >= end; value += step) {
|
||||
if (iterations++ > MAX_LOOP) throw new RuntimeError("validation_error", "循环次数超过上限 10000");
|
||||
ctx.vars[indexName] = value;
|
||||
try {
|
||||
await runSlot("count_for_fn");
|
||||
} catch (error) {
|
||||
if (error instanceof BreakSignal) break;
|
||||
if (error instanceof ContinueSignal) continue;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
return iterations;
|
||||
}
|
||||
case "logic_control_array_for_v3":
|
||||
case "logic_control_dictionary_for_v1": {
|
||||
const raw = node.values?.array_name;
|
||||
let value = interpolate(raw, ctx.vars);
|
||||
if (typeof value === "string") {
|
||||
const name = value.trim();
|
||||
if (name in ctx.vars) value = ctx.vars[name];
|
||||
else {
|
||||
try {
|
||||
value = JSON.parse(name);
|
||||
} catch {
|
||||
value = asArray(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
const list = asArray(value);
|
||||
const reverse = String(node.values?.reverse ?? "") === "true" || String(node.values?.reverse ?? "") === "1";
|
||||
const start = Math.max(0, toNumber(node.values?.start_index, 0));
|
||||
const end = node.values?.end_index === "" || node.values?.end_index == null ? list.length : toNumber(node.values?.end_index, list.length);
|
||||
const slice = (reverse ? [...list].reverse() : list).slice(start, end);
|
||||
let iterations = 0;
|
||||
for (let index = 0; index < slice.length; index += 1) {
|
||||
if (iterations++ > MAX_LOOP) throw new RuntimeError("validation_error", "循环次数超过上限 10000");
|
||||
ctx.vars.item = slice[index];
|
||||
ctx.vars.index = index;
|
||||
ctx.vars.value = slice[index];
|
||||
try {
|
||||
await runSlot("array_for_fn");
|
||||
} catch (error) {
|
||||
if (error instanceof BreakSignal) break;
|
||||
if (error instanceof ContinueSignal) continue;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
return iterations;
|
||||
}
|
||||
case "logic_control_while": {
|
||||
let iterations = 0;
|
||||
while (evaluateCondition(node.values?.if_for_condition, ctx.vars)) {
|
||||
if (iterations++ > MAX_LOOP) throw new RuntimeError("validation_error", "循环次数超过上限 10000");
|
||||
try {
|
||||
await runSlot("if_for_fn");
|
||||
} catch (error) {
|
||||
if (error instanceof BreakSignal) break;
|
||||
if (error instanceof ContinueSignal) continue;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
return iterations;
|
||||
}
|
||||
case "infinite_loop": {
|
||||
for (let iterations = 0; ; iterations += 1) {
|
||||
if (iterations > MAX_LOOP) throw new RuntimeError("validation_error", "循环次数超过上限 10000");
|
||||
try {
|
||||
await runSlot("loop_body");
|
||||
} catch (error) {
|
||||
if (error instanceof BreakSignal) break;
|
||||
if (error instanceof ContinueSignal) continue;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
case "logic_control_try_catch": {
|
||||
try {
|
||||
await runSlot("try_fn");
|
||||
await runSlot("else_fn");
|
||||
return "else";
|
||||
} catch (error) {
|
||||
if (error instanceof BreakSignal || error instanceof ContinueSignal || error instanceof StopSignal) throw error;
|
||||
ctx.vars.error = asRuntimeError(error, node, componentId);
|
||||
await runSlot("except_fn");
|
||||
return "catch";
|
||||
}
|
||||
}
|
||||
default: {
|
||||
const slot = componentId === "Element_exception_monitor_v2" ? "exception_monitor_params" : "body";
|
||||
await runSlot(slot);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { evaluate, evaluateCondition, interpolate };
|
||||
137
src/main/engine/types.ts
Normal file
137
src/main/engine/types.ts
Normal file
@ -0,0 +1,137 @@
|
||||
import type { CatalogComponent, FlowDefinition, FlowNode, RunError, RunEvent } from "../../shared/types";
|
||||
|
||||
export interface BrowserLike {
|
||||
navigate(url: string): Promise<{ url: string; title: string }>;
|
||||
click(selector: unknown, timeoutMs?: number): Promise<unknown>;
|
||||
type(selector: unknown, text: string, clearFirst?: boolean): Promise<unknown>;
|
||||
getText(selector: unknown, attribute?: string): Promise<string>;
|
||||
exists(selector: unknown, timeoutMs?: number): Promise<boolean>;
|
||||
select(selector: unknown, value: string): Promise<unknown>;
|
||||
runJs(script: string): Promise<unknown>;
|
||||
screenshot(path: string): Promise<string>;
|
||||
currentUrl(): Promise<string>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface RunContext {
|
||||
runId: string;
|
||||
flowId: string;
|
||||
flow: FlowDefinition;
|
||||
vars: Record<string, unknown>;
|
||||
secrets: Record<string, string>;
|
||||
signal: AbortSignal;
|
||||
evidenceDir: string;
|
||||
browser?: BrowserLike;
|
||||
emit: (event: Omit<RunEvent, "runId">) => void;
|
||||
log: (message: string, level?: "info" | "warn" | "error", nodeId?: string) => void;
|
||||
componentOf: (node: FlowNode) => CatalogComponent | undefined;
|
||||
}
|
||||
|
||||
export type CapabilityHandler = (
|
||||
ctx: RunContext,
|
||||
node: FlowNode,
|
||||
args: Record<string, unknown>,
|
||||
component: CatalogComponent
|
||||
) => Promise<unknown> | unknown;
|
||||
|
||||
export interface NodeState {
|
||||
nodeId: string;
|
||||
componentId: string;
|
||||
status: "idle" | "running" | "succeeded" | "failed" | "skipped" | "cancelled";
|
||||
startedAt?: string;
|
||||
finishedAt?: string;
|
||||
durationMs?: number;
|
||||
output?: unknown;
|
||||
error?: RunError;
|
||||
evidence: string[];
|
||||
attempt: number;
|
||||
}
|
||||
|
||||
export interface RunResult {
|
||||
runId: string;
|
||||
status: "succeeded" | "failed" | "cancelled";
|
||||
startedAt: string;
|
||||
finishedAt: string;
|
||||
durationMs: number;
|
||||
nodes: NodeState[];
|
||||
variables: Record<string, unknown>;
|
||||
error?: RunError;
|
||||
}
|
||||
|
||||
export class RuntimeError extends Error {
|
||||
code: string;
|
||||
recommendation?: string;
|
||||
nodeId?: string;
|
||||
componentId?: string;
|
||||
arg?: string;
|
||||
detail?: string;
|
||||
|
||||
constructor(
|
||||
code: string,
|
||||
message: string,
|
||||
options: { recommendation?: string; nodeId?: string; componentId?: string; arg?: string; detail?: string } = {}
|
||||
) {
|
||||
super(message);
|
||||
this.name = "RuntimeError";
|
||||
this.code = code;
|
||||
this.recommendation = options.recommendation;
|
||||
this.nodeId = options.nodeId;
|
||||
this.componentId = options.componentId;
|
||||
this.arg = options.arg;
|
||||
this.detail = options.detail;
|
||||
}
|
||||
|
||||
toRunError(): RunError {
|
||||
return {
|
||||
code: this.code,
|
||||
reason: this.message,
|
||||
recommendation: this.recommendation,
|
||||
location: {
|
||||
nodeId: this.nodeId,
|
||||
componentId: this.componentId,
|
||||
arg: this.arg
|
||||
},
|
||||
detail: this.detail
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class BreakSignal extends Error {
|
||||
constructor() {
|
||||
super("break");
|
||||
this.name = "BreakSignal";
|
||||
}
|
||||
}
|
||||
|
||||
export class ContinueSignal extends Error {
|
||||
constructor() {
|
||||
super("continue");
|
||||
this.name = "ContinueSignal";
|
||||
}
|
||||
}
|
||||
|
||||
export class StopSignal extends Error {
|
||||
constructor() {
|
||||
super("stop");
|
||||
this.name = "StopSignal";
|
||||
}
|
||||
}
|
||||
|
||||
export function asRuntimeError(error: unknown, node?: FlowNode, componentId?: string): RunError {
|
||||
if (error instanceof RuntimeError) {
|
||||
const result = error.toRunError();
|
||||
if (!result.location?.nodeId && node) {
|
||||
result.location = { ...(result.location || {}), nodeId: node.id, componentId: componentId || node.componentId };
|
||||
}
|
||||
return result;
|
||||
}
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
return { code: "cancelled", reason: "运行已取消" };
|
||||
}
|
||||
const code = (error as { code?: string })?.code;
|
||||
return {
|
||||
code: typeof code === "string" ? code : "unknown",
|
||||
reason: error instanceof Error ? error.message : String(error),
|
||||
location: node ? { nodeId: node.id, componentId: componentId || node.componentId } : undefined
|
||||
};
|
||||
}
|
||||
@ -1,8 +1,11 @@
|
||||
import { app, BrowserWindow, dialog, ipcMain, shell } from "electron";
|
||||
import path from "node:path";
|
||||
import type { AppInfo, FlowDefinition } from "../shared/types";
|
||||
import "./capabilities";
|
||||
import { catalogPath, loadCatalog } from "./catalog";
|
||||
import { createFlow, deleteFlow, getFlow, listFlows, saveFlow } from "./flows";
|
||||
import { capabilityCount, capabilityIds } from "./engine/registry";
|
||||
import { closeBrowserSession, startRun, stopRun } from "./run/manager";
|
||||
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
|
||||
@ -58,6 +61,9 @@ function registerIpc(): void {
|
||||
}));
|
||||
|
||||
ipcMain.handle("catalog:get", () => loadCatalog());
|
||||
ipcMain.handle("run:start", (event, flow: FlowDefinition) => startRun(flow, event.sender));
|
||||
ipcMain.handle("run:stop", (_event, runId?: string) => stopRun(runId));
|
||||
ipcMain.handle("run:capabilities", () => ({ count: capabilityCount(), ids: capabilityIds() }));
|
||||
ipcMain.handle("flows:list", () => listFlows());
|
||||
ipcMain.handle("flows:create", (_event, name?: string) => {
|
||||
const flow = createFlow(name);
|
||||
@ -112,4 +118,8 @@ if (!gotLock) {
|
||||
app.on("window-all-closed", () => {
|
||||
if (process.platform !== "darwin") app.quit();
|
||||
});
|
||||
|
||||
app.on("before-quit", () => {
|
||||
void closeBrowserSession();
|
||||
});
|
||||
}
|
||||
|
||||
86
src/main/run/manager.ts
Normal file
86
src/main/run/manager.ts
Normal file
@ -0,0 +1,86 @@
|
||||
import { app, type WebContents } from "electron";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { FlowDefinition, RunEvent } from "../../shared/types";
|
||||
import { loadCatalog } from "../catalog";
|
||||
import { runFlow } from "../engine/runtime";
|
||||
import { BrowserSession } from "../browser/session";
|
||||
|
||||
const active = new Map<string, AbortController>();
|
||||
let currentRunId: string | null = null;
|
||||
|
||||
function newRunId(): string {
|
||||
return `run_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
|
||||
}
|
||||
|
||||
export function startRun(flow: FlowDefinition, sender: WebContents): { runId: string } {
|
||||
if (currentRunId) active.get(currentRunId)?.abort();
|
||||
|
||||
const runId = newRunId();
|
||||
const root = path.join(app.getPath("userData"), "runs", runId);
|
||||
const evidenceDir = path.join(root, "evidence");
|
||||
fs.mkdirSync(evidenceDir, { recursive: true });
|
||||
|
||||
const controller = new AbortController();
|
||||
active.set(runId, controller);
|
||||
currentRunId = runId;
|
||||
|
||||
const catalog = loadCatalog();
|
||||
const components = new Map(catalog.components.map((item) => [item.id, item]));
|
||||
|
||||
const emit = (event: Omit<RunEvent, "runId">) => {
|
||||
if (!sender.isDestroyed()) sender.send("run:event", { ...event, runId });
|
||||
};
|
||||
const log: (message: string, level?: "info" | "warn" | "error", nodeId?: string) => void = (
|
||||
message,
|
||||
level = "info",
|
||||
nodeId
|
||||
) => emit({ type: "node.log", nodeId, level, message });
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const result = await runFlow(flow, {
|
||||
runId,
|
||||
evidenceDir,
|
||||
signal: controller.signal,
|
||||
emit,
|
||||
log,
|
||||
componentOf: (node) => components.get(node.componentId)
|
||||
});
|
||||
fs.writeFileSync(path.join(root, "run.json"), JSON.stringify(result, null, 2), "utf8");
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
emit({ type: "run.finished", status: "failed", durationMs: 0, error: { code: "unknown", reason: message } });
|
||||
fs.writeFileSync(
|
||||
path.join(root, "run.json"),
|
||||
JSON.stringify({ runId, status: "failed", error: message }, null, 2),
|
||||
"utf8"
|
||||
);
|
||||
} finally {
|
||||
active.delete(runId);
|
||||
if (currentRunId === runId) currentRunId = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return { runId };
|
||||
}
|
||||
|
||||
export function stopRun(runId?: string): boolean {
|
||||
const id = runId || currentRunId;
|
||||
if (!id) return false;
|
||||
const controller = active.get(id);
|
||||
if (!controller) return false;
|
||||
controller.abort();
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function closeBrowserSession(): Promise<void> {
|
||||
if (globalThis.__rpaBrowserWindow && !globalThis.__rpaBrowserWindow.isDestroyed()) {
|
||||
const session = await BrowserSession.create();
|
||||
await session.close();
|
||||
}
|
||||
}
|
||||
|
||||
export function isRunning(): boolean {
|
||||
return currentRunId !== null;
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
import { contextBridge, ipcRenderer } from "electron";
|
||||
import type { AppInfo, Catalog, FlowDefinition, FlowSummary } from "../shared/types";
|
||||
import type { AppInfo, Catalog, FlowDefinition, FlowSummary, RunEvent } from "../shared/types";
|
||||
|
||||
const api = {
|
||||
app: {
|
||||
@ -20,6 +20,16 @@ const api = {
|
||||
ipcRenderer.invoke("dialog:open", options || {}),
|
||||
save: (options?: { title?: string; defaultPath?: string; filters?: Array<{ name: string; extensions: string[] }> }): Promise<string | null> =>
|
||||
ipcRenderer.invoke("dialog:save", options || {})
|
||||
},
|
||||
run: {
|
||||
start: (flow: FlowDefinition): Promise<{ runId: string }> => ipcRenderer.invoke("run:start", flow),
|
||||
stop: (runId?: string): Promise<boolean> => ipcRenderer.invoke("run:stop", runId),
|
||||
capabilities: (): Promise<{ count: number; ids: string[] }> => ipcRenderer.invoke("run:capabilities"),
|
||||
onEvent: (callback: (event: RunEvent) => void): (() => void) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent, payload: RunEvent) => callback(payload);
|
||||
ipcRenderer.on("run:event", listener);
|
||||
return () => ipcRenderer.removeListener("run:event", listener);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from "react";
|
||||
import { Bot, CircleAlert, Loader2, Sparkles } from "lucide-react";
|
||||
import type { Catalog } from "@shared/types";
|
||||
import { useEditorStore } from "./store/editorStore";
|
||||
import { useRunStore } from "./store/runStore";
|
||||
import { EditorPage } from "./pages/EditorPage";
|
||||
import { RunsPage } from "./pages/RunsPage";
|
||||
import { SettingsPage } from "./pages/SettingsPage";
|
||||
@ -28,6 +29,12 @@ export function App() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const setCatalogStore = useEditorStore((state) => state.setCatalog);
|
||||
const loadFlow = useEditorStore((state) => state.loadFlow);
|
||||
const handleRunEvent = useRunStore((state) => state.handle);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = window.rpa.run.onEvent(handleRunEvent);
|
||||
return () => unsubscribe();
|
||||
}, [handleRunEvent]);
|
||||
|
||||
const showToast = useCallback((message: string, kind: "ok" | "error" = "ok") => {
|
||||
setToast({ id: Date.now(), message, kind });
|
||||
|
||||
@ -17,6 +17,7 @@ export interface ContainerNodeData extends Record<string, unknown> {
|
||||
childrenBySlot: Record<string, FlowNode[]>;
|
||||
components: Map<string, CatalogComponent>;
|
||||
selectedChildId?: string | null;
|
||||
status?: "idle" | "running" | "succeeded" | "failed" | "skipped" | "cancelled";
|
||||
}
|
||||
|
||||
export function containerSlots(component: CatalogComponent): ContainerSlot[] {
|
||||
@ -67,7 +68,8 @@ export function ContainerNode({ data, selected }: NodeProps) {
|
||||
"container-node",
|
||||
`tone-${component.group}`,
|
||||
selected ? "selected" : "",
|
||||
pending ? "pending" : ""
|
||||
pending ? "pending" : "",
|
||||
nodeData.status && nodeData.status !== "idle" ? nodeData.status : ""
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
|
||||
@ -16,6 +16,7 @@ import {
|
||||
} from "@xyflow/react";
|
||||
import type { Catalog, CatalogComponent } from "@shared/types";
|
||||
import { useEditorStore } from "../store/editorStore";
|
||||
import { useRunStore } from "../store/runStore";
|
||||
import { RpaNode, type RpaNodeData } from "./RpaNode";
|
||||
import { ContainerNode, containerSlots, type ContainerNodeData } from "./ContainerNode";
|
||||
import { MousePointerClick } from "lucide-react";
|
||||
@ -54,6 +55,7 @@ function CanvasInner({ catalog, onAddAt }: { catalog: Catalog; onAddAt: (compone
|
||||
const removeNode = useEditorStore((state) => state.removeNode);
|
||||
const addEdge = useEditorStore((state) => state.addEdge);
|
||||
const removeEdge = useEditorStore((state) => state.removeEdge);
|
||||
const runNodes = useRunStore((state) => state.nodes);
|
||||
const { screenToFlowPosition, fitView } = useReactFlow();
|
||||
|
||||
const componentMap = useMemo(() => new Map(catalog.components.map((item) => [item.id, item])), [catalog]);
|
||||
@ -81,7 +83,8 @@ function CanvasInner({ catalog, onAddAt }: { catalog: Catalog; onAddAt: (compone
|
||||
slots,
|
||||
childrenBySlot,
|
||||
components: componentMap,
|
||||
selectedChildId: selectedNodeId
|
||||
selectedChildId: selectedNodeId,
|
||||
status: runNodes[node.id]?.status
|
||||
};
|
||||
return {
|
||||
id: node.id,
|
||||
@ -127,11 +130,12 @@ function CanvasInner({ catalog, onAddAt }: { catalog: Catalog; onAddAt: (compone
|
||||
component: component || fallback,
|
||||
label: node.label,
|
||||
subtitle: component ? subtitleFor(component, node.values) : undefined,
|
||||
outputVar: node.options?.outputVar
|
||||
outputVar: node.options?.outputVar,
|
||||
status: runNodes[node.id]?.status
|
||||
} as RpaNodeData as unknown as Record<string, unknown>
|
||||
} as Node;
|
||||
});
|
||||
}, [flow, componentMap, selectedNodeId]);
|
||||
}, [flow, componentMap, selectedNodeId, runNodes]);
|
||||
|
||||
const edges = useMemo<Edge[]>(() => {
|
||||
if (!flow) return [];
|
||||
|
||||
@ -7,7 +7,7 @@ export interface RpaNodeData extends Record<string, unknown> {
|
||||
label?: string;
|
||||
subtitle?: string;
|
||||
outputVar?: string;
|
||||
status?: "idle" | "running" | "succeeded" | "failed";
|
||||
status?: "idle" | "running" | "succeeded" | "failed" | "skipped" | "cancelled";
|
||||
}
|
||||
|
||||
export function RpaNode({ data, selected }: NodeProps) {
|
||||
|
||||
@ -5,8 +5,35 @@ import "./styles/tokens.css";
|
||||
import "./styles/app.css";
|
||||
import "@xyflow/react/dist/style.css";
|
||||
|
||||
class RenderErrorBoundary extends React.Component<React.PropsWithChildren, { error: Error | null }> {
|
||||
state: { error: Error | null } = { error: null };
|
||||
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: React.ErrorInfo) {
|
||||
console.error("RPA Studio renderer error", error, info.componentStack);
|
||||
(window as Window & { __RPA_RENDER_ERROR?: string }).__RPA_RENDER_ERROR = `${error.stack || error.message}\n${info.componentStack || ""}`;
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.error) {
|
||||
return (
|
||||
<div style={{ padding: 28, fontFamily: "Microsoft YaHei", color: "#172033", background: "#e7edf3", height: "100vh" }}>
|
||||
<h2>RPA Studio 渲染错误</h2>
|
||||
<pre style={{ whiteSpace: "pre-wrap", marginTop: 12, color: "#912018" }}>{this.state.error.stack || this.state.error.message}</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
<RenderErrorBoundary>
|
||||
<App />
|
||||
</RenderErrorBoundary>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Check, CirclePlay, Loader2, Redo2, Save, Undo2 } from "lucide-react";
|
||||
import { Check, CirclePlay, Loader2, Redo2, Save, Square, Undo2 } from "lucide-react";
|
||||
import type { Catalog } from "@shared/types";
|
||||
import { useEditorStore } from "../store/editorStore";
|
||||
import { useRunStore } from "../store/runStore";
|
||||
import { Palette } from "../panels/Palette";
|
||||
import { Properties } from "../panels/Properties";
|
||||
import { RunDrawer } from "../panels/RunDrawer";
|
||||
import { FlowCanvas } from "../canvas/FlowCanvas";
|
||||
import { containerSlots } from "../canvas/ContainerNode";
|
||||
|
||||
@ -23,6 +25,9 @@ export function EditorPage({ catalog, onToast }: EditorPageProps) {
|
||||
const markSaved = useEditorStore((state) => state.markSaved);
|
||||
const canUndo = useEditorStore((state) => state.past.length > 0);
|
||||
const canRedo = useEditorStore((state) => state.future.length > 0);
|
||||
const running = useRunStore((state) => state.running);
|
||||
const startRun = useRunStore((state) => state.start);
|
||||
const stopRun = useRunStore((state) => state.stop);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [savedAt, setSavedAt] = useState<string | null>(null);
|
||||
|
||||
@ -66,6 +71,18 @@ export function EditorPage({ catalog, onToast }: EditorPageProps) {
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [redo, save, undo]);
|
||||
|
||||
const runCurrentFlow = useCallback(async () => {
|
||||
const current = useEditorStore.getState().flow;
|
||||
if (!current) return;
|
||||
try {
|
||||
const saved = await window.rpa.flows.save(current);
|
||||
markSaved();
|
||||
await startRun(saved);
|
||||
} catch (error) {
|
||||
onToast(error instanceof Error ? error.message : String(error), "error");
|
||||
}
|
||||
}, [markSaved, onToast, startRun]);
|
||||
|
||||
if (!flow) {
|
||||
return (
|
||||
<div className="loading">
|
||||
@ -130,14 +147,15 @@ export function EditorPage({ catalog, onToast }: EditorPageProps) {
|
||||
<button type="button" className="icon-btn" title="保存 (Ctrl+S)" onClick={() => void save()} disabled={saving}>
|
||||
{saving ? <Loader2 className="icon spin" /> : <Save className="icon" />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled
|
||||
title="Day 2 接入执行内核后开放"
|
||||
>
|
||||
<CirclePlay className="icon" /> 运行
|
||||
</button>
|
||||
{running ? (
|
||||
<button type="button" className="btn btn-danger" onClick={() => void stopRun()}>
|
||||
<Square className="icon" /> 停止
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" className="btn btn-primary" onClick={() => void runCurrentFlow()}>
|
||||
<CirclePlay className="icon" /> 运行
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -147,6 +165,8 @@ export function EditorPage({ catalog, onToast }: EditorPageProps) {
|
||||
<Check className="icon" />
|
||||
可执行 {catalog.components.filter((item) => item.implemented).length} / {catalog.counts.total} 项
|
||||
</div>
|
||||
|
||||
<RunDrawer />
|
||||
</section>
|
||||
|
||||
<Properties catalog={catalog} onToast={onToast} />
|
||||
|
||||
103
src/renderer/src/panels/RunDrawer.tsx
Normal file
103
src/renderer/src/panels/RunDrawer.tsx
Normal file
@ -0,0 +1,103 @@
|
||||
import { useMemo } from "react";
|
||||
import { CircleAlert, CircleCheck, Loader2, Square, Timer, X } from "lucide-react";
|
||||
import { useEditorStore } from "../store/editorStore";
|
||||
import { useRunStore } from "../store/runStore";
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
running: "运行中",
|
||||
succeeded: "成功",
|
||||
failed: "失败",
|
||||
skipped: "跳过",
|
||||
cancelled: "已取消",
|
||||
idle: "待运行"
|
||||
};
|
||||
|
||||
export function RunDrawer() {
|
||||
const running = useRunStore((state) => state.running);
|
||||
const stopping = useRunStore((state) => state.stopping);
|
||||
const nodes = useRunStore((state) => state.nodes);
|
||||
const logs = useRunStore((state) => state.logs);
|
||||
const status = useRunStore((state) => state.status);
|
||||
const durationMs = useRunStore((state) => state.durationMs);
|
||||
const error = useRunStore((state) => state.error);
|
||||
const stop = useRunStore((state) => state.stop);
|
||||
const clear = useRunStore((state) => state.clear);
|
||||
const flow = useEditorStore((state) => state.flow);
|
||||
const catalog = useEditorStore((state) => state.catalog);
|
||||
|
||||
const nameOf = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
if (flow && catalog) {
|
||||
for (const node of flow.nodes) {
|
||||
const component = catalog.components.find((item) => item.id === node.componentId);
|
||||
map.set(node.id, node.label || component?.name || node.componentId);
|
||||
}
|
||||
}
|
||||
return (nodeId?: string) => (nodeId ? map.get(nodeId) || nodeId : "流程");
|
||||
}, [flow, catalog]);
|
||||
|
||||
if (!running && !status && logs.length === 0) return null;
|
||||
|
||||
const finished = Object.entries(nodes).filter(([, value]) => value.status !== "running" && value.status !== "idle");
|
||||
const statusClass = status === "succeeded" ? "done" : status === "failed" ? "failed" : status === "cancelled" ? "paused" : "running";
|
||||
|
||||
return (
|
||||
<div className="run-drawer">
|
||||
<div className="run-drawer-head">
|
||||
<span className={`status-pill ${statusClass}`}>
|
||||
{running ? <Loader2 className="icon spin" /> : status === "succeeded" ? <CircleCheck className="icon" /> : <CircleAlert className="icon" />}
|
||||
{running ? (stopping ? "停止中" : "运行中") : STATUS_LABEL[status || ""] || "运行"}
|
||||
</span>
|
||||
<span className="run-drawer-meta">
|
||||
<Timer className="icon" />
|
||||
{durationMs == null ? "—" : `${(durationMs / 1000).toFixed(2)}s`}
|
||||
<span className="dot" />
|
||||
已完成 {finished.length}/{Object.keys(nodes).length}
|
||||
</span>
|
||||
<span className="spacer" />
|
||||
{running ? (
|
||||
<button type="button" className="btn" onClick={() => void stop()} disabled={stopping}>
|
||||
<Square className="icon" /> 停止
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" className="icon-btn" title="关闭运行面板" onClick={clear}>
|
||||
<X className="icon" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="run-error">
|
||||
<CircleAlert className="icon" />
|
||||
<div>
|
||||
<strong>{error.reason}</strong>
|
||||
{error.recommendation && <span>{error.recommendation}</span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="run-drawer-body">
|
||||
<div className="run-node-list">
|
||||
{Object.entries(nodes).length === 0 && <div className="field-hint">等待节点启动…</div>}
|
||||
{Object.entries(nodes).map(([nodeId, value]) => (
|
||||
<div className={`run-node-item ${value.status}`} key={nodeId}>
|
||||
<span className="run-node-name">{nameOf(nodeId)}</span>
|
||||
<span className="run-node-status">{STATUS_LABEL[value.status] || value.status}</span>
|
||||
<span className="run-node-time">{value.durationMs == null ? "" : `${value.durationMs}ms`}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="run-logs">
|
||||
{logs.length === 0 && <div className="field-hint">运行日志会显示在这里…</div>}
|
||||
{logs.map((log) => (
|
||||
<div className={`run-log ${log.level}`} key={log.id}>
|
||||
<span className="run-log-time">{log.time}</span>
|
||||
<span className="run-log-node">{nameOf(log.nodeId)}</span>
|
||||
<span className="run-log-message">{log.message}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
146
src/renderer/src/store/runStore.ts
Normal file
146
src/renderer/src/store/runStore.ts
Normal file
@ -0,0 +1,146 @@
|
||||
import { create } from "zustand";
|
||||
import type { FlowDefinition, RunError, RunEvent } from "@shared/types";
|
||||
|
||||
export interface NodeRunState {
|
||||
status: "idle" | "running" | "succeeded" | "failed" | "skipped" | "cancelled";
|
||||
durationMs?: number;
|
||||
error?: RunError;
|
||||
output?: unknown;
|
||||
attempt?: number;
|
||||
}
|
||||
|
||||
export interface RunLog {
|
||||
id: number;
|
||||
time: string;
|
||||
nodeId?: string;
|
||||
level: "info" | "warn" | "error";
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface RunStore {
|
||||
running: boolean;
|
||||
stopping: boolean;
|
||||
runId: string | null;
|
||||
nodes: Record<string, NodeRunState>;
|
||||
logs: RunLog[];
|
||||
status: "succeeded" | "failed" | "cancelled" | null;
|
||||
durationMs: number | null;
|
||||
error: RunError | null;
|
||||
start: (flow: FlowDefinition) => Promise<void>;
|
||||
stop: () => Promise<void>;
|
||||
handle: (event: RunEvent) => void;
|
||||
clear: () => void;
|
||||
}
|
||||
|
||||
let logSeq = 0;
|
||||
|
||||
export const useRunStore = create<RunStore>((set, get) => ({
|
||||
running: false,
|
||||
stopping: false,
|
||||
runId: null,
|
||||
nodes: {},
|
||||
logs: [],
|
||||
status: null,
|
||||
durationMs: null,
|
||||
error: null,
|
||||
|
||||
start: async (flow) => {
|
||||
set({
|
||||
running: true,
|
||||
stopping: false,
|
||||
runId: null,
|
||||
nodes: {},
|
||||
logs: [],
|
||||
status: null,
|
||||
durationMs: null,
|
||||
error: null
|
||||
});
|
||||
try {
|
||||
const { runId } = await window.rpa.run.start(flow);
|
||||
set({ runId });
|
||||
} catch (error) {
|
||||
set({
|
||||
running: false,
|
||||
status: "failed",
|
||||
error: { code: "unknown", reason: error instanceof Error ? error.message : String(error) }
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
stop: async () => {
|
||||
const runId = get().runId;
|
||||
set({ stopping: true });
|
||||
try {
|
||||
await window.rpa.run.stop(runId || undefined);
|
||||
} catch {
|
||||
set({ stopping: false });
|
||||
}
|
||||
},
|
||||
|
||||
handle: (event) => {
|
||||
const time = new Date().toLocaleTimeString("zh-CN", { hour12: false });
|
||||
switch (event.type) {
|
||||
case "run.started":
|
||||
set({ running: true, status: null, error: null, durationMs: null });
|
||||
break;
|
||||
case "node.started":
|
||||
set((state) => ({
|
||||
nodes: {
|
||||
...state.nodes,
|
||||
[event.nodeId || ""]: { status: "running", attempt: event.attempt }
|
||||
}
|
||||
}));
|
||||
break;
|
||||
case "node.log":
|
||||
logSeq += 1;
|
||||
set((state) => ({
|
||||
logs: [
|
||||
...state.logs.slice(-499),
|
||||
{
|
||||
id: logSeq,
|
||||
time,
|
||||
nodeId: event.nodeId,
|
||||
level: (event.level || "info") as RunLog["level"],
|
||||
message: event.message || ""
|
||||
}
|
||||
]
|
||||
}));
|
||||
break;
|
||||
case "node.finished":
|
||||
set((state) => ({
|
||||
nodes: {
|
||||
...state.nodes,
|
||||
[event.nodeId || ""]: {
|
||||
status: (event.status as NodeRunState["status"]) || "succeeded",
|
||||
durationMs: event.durationMs,
|
||||
error: event.error,
|
||||
output: event.output,
|
||||
attempt: event.attempt
|
||||
}
|
||||
}
|
||||
}));
|
||||
break;
|
||||
case "run.finished":
|
||||
set({
|
||||
running: false,
|
||||
stopping: false,
|
||||
status: (event.status as RunStore["status"]) || "succeeded",
|
||||
durationMs: event.durationMs || 0,
|
||||
error: event.error || null
|
||||
});
|
||||
break;
|
||||
}
|
||||
},
|
||||
|
||||
clear: () =>
|
||||
set({
|
||||
running: false,
|
||||
stopping: false,
|
||||
runId: null,
|
||||
nodes: {},
|
||||
logs: [],
|
||||
status: null,
|
||||
durationMs: null,
|
||||
error: null
|
||||
})
|
||||
}));
|
||||
Loading…
Reference in New Issue
Block a user