From 6a2cc24898ee567aba230b0f938e689a88ef9c98 Mon Sep 17 00:00:00 2001 From: yu <191972707@qq.com> Date: Mon, 26 Oct 2026 17:41:39 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8E=A5=E5=85=A5=E6=9C=AC=E5=9C=B0?= =?UTF-8?q?=E6=89=A7=E8=A1=8C=E5=86=85=E6=A0=B8=E4=B8=8E=E8=BF=90=E8=A1=8C?= =?UTF-8?q?=E9=9D=A2=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/browser/session.ts | 315 +++++++++++++ src/main/capabilities/browser.ts | 134 ++++++ src/main/capabilities/control.ts | 84 ++++ src/main/capabilities/data.ts | 515 ++++++++++++++++++++++ src/main/capabilities/files.ts | 213 +++++++++ src/main/capabilities/http.ts | 143 ++++++ src/main/capabilities/index.ts | 7 + src/main/catalog.ts | 28 +- src/main/engine/expression.ts | 464 +++++++++++++++++++ src/main/engine/registry.ts | 24 + src/main/engine/runtime.ts | 467 ++++++++++++++++++++ src/main/engine/types.ts | 137 ++++++ src/main/index.ts | 10 + src/main/run/manager.ts | 86 ++++ src/preload/index.ts | 12 +- src/renderer/src/App.tsx | 7 + src/renderer/src/canvas/ContainerNode.tsx | 4 +- src/renderer/src/canvas/FlowCanvas.tsx | 10 +- src/renderer/src/canvas/RpaNode.tsx | 2 +- src/renderer/src/main.tsx | 29 +- src/renderer/src/pages/EditorPage.tsx | 38 +- src/renderer/src/panels/RunDrawer.tsx | 103 +++++ src/renderer/src/store/runStore.ts | 146 ++++++ 23 files changed, 2937 insertions(+), 41 deletions(-) create mode 100644 src/main/browser/session.ts create mode 100644 src/main/capabilities/browser.ts create mode 100644 src/main/capabilities/control.ts create mode 100644 src/main/capabilities/data.ts create mode 100644 src/main/capabilities/files.ts create mode 100644 src/main/capabilities/http.ts create mode 100644 src/main/capabilities/index.ts create mode 100644 src/main/engine/expression.ts create mode 100644 src/main/engine/registry.ts create mode 100644 src/main/engine/runtime.ts create mode 100644 src/main/engine/types.ts create mode 100644 src/main/run/manager.ts create mode 100644 src/renderer/src/panels/RunDrawer.tsx create mode 100644 src/renderer/src/store/runStore.ts diff --git a/src/main/browser/session.ts b/src/main/browser/session.ts new file mode 100644 index 0000000..78121f3 --- /dev/null +++ b/src/main/browser/session.ts @@ -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; + 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 | 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)?.name); + if (last) { + const item = last as Record; + const props = (item.props as Array>) || []; + 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 { + 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(method: string, params: Record = {}): Promise { + 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(expression: string): Promise { + 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 { + 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(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 { + 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 { + 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 { + 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(`(() => { const el = document.activeElement; return el ? (el.value ?? '') : ''; })()`); + return { selector: info.selector, value: actual }; + } + + async getText(selector: unknown, attribute?: string): Promise { + const info = await this.locate(selector); + if (attribute) { + const value = await this.evaluate(`(() => { + 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 { + try { + await this.locate(selector); + return true; + } catch { + return false; + } + } + + async select(selector: unknown, value: string): Promise { + const info = await this.locate(selector); + const applied = await this.evaluate(`(() => { + 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 { + return this.evaluate(script); + } + + async screenshot(filePath: string): Promise { + 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 { + return this.win.webContents.getURL(); + } + + async close(): Promise { + 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 { + return BrowserSession.create(); +} + +export function assertAllowedUrl(url: string): URL { + return assertUrl(url); +} diff --git a/src/main/capabilities/browser.ts b/src/main/capabilities/browser.ts new file mode 100644 index 0000000..c3f50dc --- /dev/null +++ b/src/main/capabilities/browser.ts @@ -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 { + if (!ctx.browser) ctx.browser = await browserSession(); + return ctx.browser; +} + +function selectorOf(args: Record): 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; +}); diff --git a/src/main/capabilities/control.ts b/src/main/capabilities/control.ts new file mode 100644 index 0000000..7f69df0 --- /dev/null +++ b/src/main/capabilities/control.ts @@ -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 { + 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(); +}); diff --git a/src/main/capabilities/data.ts b/src/main/capabilities/data.ts new file mode 100644 index 0000000..a4307fd --- /dev/null +++ b/src/main/capabilities/data.ts @@ -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, 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 { + if (value && typeof value === "object" && !Array.isArray(value)) return value as Record; + if (typeof value === "string" && value.trim()) { + try { + const parsed = JSON.parse(value); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed as Record; + } 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 { + 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; + 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"])); diff --git a/src/main/capabilities/files.ts b/src/main/capabilities/files.ts new file mode 100644 index 0000000..6ca2641 --- /dev/null +++ b/src/main/capabilities/files.ts @@ -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, 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 { + 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 { + 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 { + 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; +}); diff --git a/src/main/capabilities/http.ts b/src/main/capabilities/http.ts new file mode 100644 index 0000000..e868527 --- /dev/null +++ b/src/main/capabilities/http.ts @@ -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 { + if (!value) return {}; + if (typeof value === "object") { + const result: Record = {}; + for (const [key, item] of Object.entries(value as Record)) 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 = {}; + for (const [key, item] of Object.entries(parsed as Record)) result[key] = String(item ?? ""); + return result; + } + } catch { + // fall through to query string + } + const result: Record = {}; + 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 { + 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, + signal: AbortSignal +): Promise { + 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); +}); diff --git a/src/main/capabilities/index.ts b/src/main/capabilities/index.ts new file mode 100644 index 0000000..78b02ed --- /dev/null +++ b/src/main/capabilities/index.ts @@ -0,0 +1,7 @@ +import "./control"; +import "./files"; +import "./http"; +import "./data"; +import "./browser"; + +export { capabilityCount, capabilityIds, getHandler, hasCapability, register } from "../engine/registry"; diff --git a/src/main/catalog.ts b/src/main/catalog.ts index 2726aa5..1a7579c 100644 --- a/src/main/catalog.ts +++ b/src/main/catalog.ts @@ -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([ - "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): void { - for (const name of functionNames) EXECUTABLE_FUNCTIONS.add(name); +export function invalidateCatalog(): void { cached = null; } diff --git a/src/main/engine/expression.ts b/src/main/engine/expression.ts new file mode 100644 index 0000000..3628376 --- /dev/null +++ b/src/main/engine/expression.ts @@ -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 = { + "||": 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(); + +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; + +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 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)[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; +} diff --git a/src/main/engine/registry.ts b/src/main/engine/registry.ts new file mode 100644 index 0000000..d36526b --- /dev/null +++ b/src/main/engine/registry.ts @@ -0,0 +1,24 @@ +import type { CapabilityHandler } from "./types"; + +const handlers = new Map(); + +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; +} diff --git a/src/main/engine/runtime.ts b/src/main/engine/runtime.ts new file mode 100644 index 0000000..86e9d56 --- /dev/null +++ b/src/main/engine/runtime.ts @@ -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 { + 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(promise: Promise, timeoutMs: number, signal: AbortSignal, label: string): Promise { + return new Promise((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): Record { + const args: Record = {}; + 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); + 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; + } +): Promise { + const startedAt = new Date().toISOString(); + const startMs = Date.now(); + const vars: Record = { ...(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(); + 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(); + 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(); + 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): Promise { + 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 +): Promise { + 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 }; diff --git a/src/main/engine/types.ts b/src/main/engine/types.ts new file mode 100644 index 0000000..f32865d --- /dev/null +++ b/src/main/engine/types.ts @@ -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; + type(selector: unknown, text: string, clearFirst?: boolean): Promise; + getText(selector: unknown, attribute?: string): Promise; + exists(selector: unknown, timeoutMs?: number): Promise; + select(selector: unknown, value: string): Promise; + runJs(script: string): Promise; + screenshot(path: string): Promise; + currentUrl(): Promise; + close(): Promise; +} + +export interface RunContext { + runId: string; + flowId: string; + flow: FlowDefinition; + vars: Record; + secrets: Record; + signal: AbortSignal; + evidenceDir: string; + browser?: BrowserLike; + emit: (event: Omit) => 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, + component: CatalogComponent +) => Promise | 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; + 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 + }; +} diff --git a/src/main/index.ts b/src/main/index.ts index 034d128..8952465 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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(); + }); } diff --git a/src/main/run/manager.ts b/src/main/run/manager.ts new file mode 100644 index 0000000..4aab5f5 --- /dev/null +++ b/src/main/run/manager.ts @@ -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(); +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) => { + 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 { + if (globalThis.__rpaBrowserWindow && !globalThis.__rpaBrowserWindow.isDestroyed()) { + const session = await BrowserSession.create(); + await session.close(); + } +} + +export function isRunning(): boolean { + return currentRunId !== null; +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 0079219..70ca23d 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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 => ipcRenderer.invoke("dialog:save", options || {}) + }, + run: { + start: (flow: FlowDefinition): Promise<{ runId: string }> => ipcRenderer.invoke("run:start", flow), + stop: (runId?: string): Promise => 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); + } } }; diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index f41f870..0e8ef5b 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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(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 }); diff --git a/src/renderer/src/canvas/ContainerNode.tsx b/src/renderer/src/canvas/ContainerNode.tsx index 76b4861..de9ad6e 100644 --- a/src/renderer/src/canvas/ContainerNode.tsx +++ b/src/renderer/src/canvas/ContainerNode.tsx @@ -17,6 +17,7 @@ export interface ContainerNodeData extends Record { childrenBySlot: Record; components: Map; 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(" ")} diff --git a/src/renderer/src/canvas/FlowCanvas.tsx b/src/renderer/src/canvas/FlowCanvas.tsx index 5d7315d..d1d8ecb 100644 --- a/src/renderer/src/canvas/FlowCanvas.tsx +++ b/src/renderer/src/canvas/FlowCanvas.tsx @@ -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 } as Node; }); - }, [flow, componentMap, selectedNodeId]); + }, [flow, componentMap, selectedNodeId, runNodes]); const edges = useMemo(() => { if (!flow) return []; diff --git a/src/renderer/src/canvas/RpaNode.tsx b/src/renderer/src/canvas/RpaNode.tsx index 600d96e..f540aa1 100644 --- a/src/renderer/src/canvas/RpaNode.tsx +++ b/src/renderer/src/canvas/RpaNode.tsx @@ -7,7 +7,7 @@ export interface RpaNodeData extends Record { label?: string; subtitle?: string; outputVar?: string; - status?: "idle" | "running" | "succeeded" | "failed"; + status?: "idle" | "running" | "succeeded" | "failed" | "skipped" | "cancelled"; } export function RpaNode({ data, selected }: NodeProps) { diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index a1bd04a..a941bcb 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -5,8 +5,35 @@ import "./styles/tokens.css"; import "./styles/app.css"; import "@xyflow/react/dist/style.css"; +class RenderErrorBoundary extends React.Component { + 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 ( +
+

RPA Studio 渲染错误

+
{this.state.error.stack || this.state.error.message}
+
+ ); + } + return this.props.children; + } +} + ReactDOM.createRoot(document.getElementById("root")!).render( - + + + ); diff --git a/src/renderer/src/pages/EditorPage.tsx b/src/renderer/src/pages/EditorPage.tsx index 7ed4b15..f3791ec 100644 --- a/src/renderer/src/pages/EditorPage.tsx +++ b/src/renderer/src/pages/EditorPage.tsx @@ -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(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 (
@@ -130,14 +147,15 @@ export function EditorPage({ catalog, onToast }: EditorPageProps) { - + {running ? ( + + ) : ( + + )}
@@ -147,6 +165,8 @@ export function EditorPage({ catalog, onToast }: EditorPageProps) { 可执行 {catalog.components.filter((item) => item.implemented).length} / {catalog.counts.total} 项 + + diff --git a/src/renderer/src/panels/RunDrawer.tsx b/src/renderer/src/panels/RunDrawer.tsx new file mode 100644 index 0000000..893cb42 --- /dev/null +++ b/src/renderer/src/panels/RunDrawer.tsx @@ -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 = { + 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(); + 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 ( +
+
+ + {running ? : status === "succeeded" ? : } + {running ? (stopping ? "停止中" : "运行中") : STATUS_LABEL[status || ""] || "运行"} + + + + {durationMs == null ? "—" : `${(durationMs / 1000).toFixed(2)}s`} + + 已完成 {finished.length}/{Object.keys(nodes).length} + + + {running ? ( + + ) : ( + + )} +
+ + {error && ( +
+ +
+ {error.reason} + {error.recommendation && {error.recommendation}} +
+
+ )} + +
+
+ {Object.entries(nodes).length === 0 &&
等待节点启动…
} + {Object.entries(nodes).map(([nodeId, value]) => ( +
+ {nameOf(nodeId)} + {STATUS_LABEL[value.status] || value.status} + {value.durationMs == null ? "" : `${value.durationMs}ms`} +
+ ))} +
+
+ {logs.length === 0 &&
运行日志会显示在这里…
} + {logs.map((log) => ( +
+ {log.time} + {nameOf(log.nodeId)} + {log.message} +
+ ))} +
+
+
+ ); +} diff --git a/src/renderer/src/store/runStore.ts b/src/renderer/src/store/runStore.ts new file mode 100644 index 0000000..f8e5d8f --- /dev/null +++ b/src/renderer/src/store/runStore.ts @@ -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; + logs: RunLog[]; + status: "succeeded" | "failed" | "cancelled" | null; + durationMs: number | null; + error: RunError | null; + start: (flow: FlowDefinition) => Promise; + stop: () => Promise; + handle: (event: RunEvent) => void; + clear: () => void; +} + +let logSeq = 0; + +export const useRunStore = create((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 + }) +}));