diff --git a/README.md b/README.md index 248f3fe..0cecf19 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,6 @@ docs 现状分析、实施方案 ## 边界 -- 1016 项目录中当前有 316 项通过能力注册表或运行时容器标记为可执行,覆盖控制/触发、数据与二维表格、文件、浏览器元素常用变体、HTTP、Excel/CSV与表格辅助、系统路径与环境变量、进程管理、桌面键鼠/窗口/剪贴板/对话框、PDF、Word、邮件发送、屏幕截图、离线图像查找与点击;其余灰标「待接入」。运行前检查会提前定位待接入组件、必填参数、多出口与断链节点。 +- 1016 项目录中当前有 363 项通过能力注册表或运行时容器标记为可执行,覆盖控制/触发、字符串/集合/INI/二维表格、文件、浏览器元素常用变体、HTTP、Excel/CSV与表格辅助、系统路径与环境变量、进程管理、桌面键鼠/窗口对象/剪贴板/对话框、PDF、Word、邮件发送、屏幕截图、离线图像查找与点击;其余灰标「待接入」。运行前检查会提前定位待接入组件、必填参数、多出口与断链节点。 - 不包含厂商代码、二进制、模型或前端资产;目录是从厂商元数据派生的静态定义。 - 运行期不访问云服务;HTTP/邮件属于用户显式配置的外部系统。 diff --git a/docs/02-实施方案.md b/docs/02-实施方案.md index 39f7b25..eb7ba94 100644 --- a/docs/02-实施方案.md +++ b/docs/02-实施方案.md @@ -4,7 +4,7 @@ > 依据:`D:\code\Agent-Employee`(只读)、`C:\rpa-demo`(只读)、2026-10-26 需求确认 > 本方案是执行基线:范围、契约、排期、验收、降级顺序都在这里;实施中变更必须回写本文件。 -> **2026-10-27 当前状态**:计划点名的代表能力已全部进入注册表或运行时容器,覆盖控制/触发、数据/文本与二维表格、文件、浏览器元素常用变体、HTTP、Excel/CSV与表格辅助、系统路径与环境变量、进程管理、桌面键鼠/窗口/剪贴板/对话框、PDF、Word、邮件发送、屏幕截图、离线图像查找与点击;启动后目录显示 **316 项可执行 / 1016 项总目录**。当前 11 个测试文件共 79 条测试,覆盖计划能力审计、浏览器参数路由与变体、Excel/PDF/Word/SMTP/模板匹配、文件与二维表格扩展、系统与进程、直线/IF/循环/重试/跳过、主出口与异常出口语义、运行记录回写和运行前检查。未通过实际场景验证的变体仍不计入最终交付数量。 +> **2026-10-27 当前状态**:计划点名的代表能力已全部进入注册表或运行时容器,覆盖控制/触发、字符串/集合/INI/二维表格、文件、浏览器元素常用变体、HTTP、Excel/CSV与表格辅助、系统路径与环境变量、进程管理、桌面键鼠/窗口对象/剪贴板/对话框、PDF、Word、邮件发送、屏幕截图、离线图像查找与点击;启动后目录显示 **363 项可执行 / 1016 项总目录**。当前 13 个测试文件共 89 条测试,覆盖计划能力审计、浏览器参数路由与变体、Excel/PDF/Word/SMTP/模板匹配、文件与数据扩展、系统/进程/窗口解析、直线/IF/循环/重试/跳过、主出口与异常出口语义、运行记录回写和运行前检查。设置页可查看分类覆盖和处理器 ID 数量。未通过实际场景验证的变体仍不计入最终交付数量。 --- diff --git a/src/main/capabilities/data-extended.test.ts b/src/main/capabilities/data-extended.test.ts new file mode 100644 index 0000000..ecd4cf8 --- /dev/null +++ b/src/main/capabilities/data-extended.test.ts @@ -0,0 +1,132 @@ +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { Catalog, CatalogComponent, FlowNode } from "../../shared/types"; +import type { RunContext } from "../engine/types"; +import { getHandler } from "../engine/registry"; +import "./data-extended"; + +const ids = [ + "Collection_Create", "Collection_HasItem", "Collection_Count", "Collection_Remove", "Collection_Add", + "Ini_Read", "Ini_Write", "Ini_ListSection", "Ini_ListKey", "Ini_DeleteKey", "Ini_DeleteSection", + "Log_Info", "Log_Error", "ExceptionUtil_Throws", + "Bivartable_CreateDf", "Bivartable_CreateDfV1", "Bivartable_DoCreate", + "Bivartable_GetMaxValue", "Bivartable_GetMinValue", "Bivartable_GetSumValue", "Bivartable_GetAvgValue", + "Bivartable_SummaryCalculationDf", "Bivartable_DeleteDuplicateRow", "Bivartable_MergeDf", + "Bivartable_DoMerge", "Bivartable_FilterDf", "Bivartable_DoFilter" +] as const; + +const temporaryDirectories: string[] = []; +const log = vi.fn(); +const context = { log } as unknown as RunContext; +const component = {} as CatalogComponent; +const node = (componentId: string): FlowNode => ({ + id: `data_${componentId}`, + componentId, + position: { x: 0, y: 0 }, + values: {}, + options: {} +}); + +async function temporaryDirectory(): Promise { + const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-data-extended-")); + temporaryDirectories.push(directory); + return directory; +} + +async function run(componentId: string, args: Record = {}): Promise { + const handler = getHandler(componentId); + expect(handler, `${componentId} should be registered`).toBeTruthy(); + return handler!(context, node(componentId), args, component); +} + +afterEach(async () => { + log.mockReset(); + await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))); +}); + +describe("extended local data capabilities", () => { + it("registers only real catalog component ids", async () => { + const catalog = JSON.parse(await readFile("resources/catalog.json", "utf8")) as Catalog; + const catalogIds = new Set(catalog.components.map((item) => item.id)); + expect(ids.length).toBeGreaterThanOrEqual(15); + for (const id of ids) { + expect(catalogIds.has(id), `${id} should exist in catalog`).toBe(true); + expect(getHandler(id), `${id} should be registered`).toBeTruthy(); + } + }); + + it("creates and mutates unique local collections", async () => { + const collection = await run("Collection_Create") as unknown[]; + await run("Collection_Add", { collection, value: "A" }); + await run("Collection_Add", { collection, value: "A" }); + await run("Collection_Add", { collection, value: "B" }); + await expect(run("Collection_HasItem", { collection, item: "A" })).resolves.toBe(true); + await expect(run("Collection_Count", { collection })).resolves.toBe(2); + await run("Collection_Remove", { collection, value: "A" }); + expect(collection).toEqual(["B"]); + }); + + it("persists, lists, reads, and deletes INI values", async () => { + const directory = await temporaryDirectory(); + const file = path.join(directory, "nested", "settings.ini"); + await run("Ini_Write", { file, section: "App", key: "name", value: "RPA Studio" }); + await run("Ini_Write", { file, section: "App", key: "workers", value: 3 }); + await run("Ini_Write", { file, section: "Network", key: "timeout", value: 30 }); + await expect(run("Ini_Read", { file, section: "App", key: "name" })).resolves.toBe("RPA Studio"); + await expect(run("Ini_Read", { file, section: "Missing", key: "key", defaultValue: "fallback" })).resolves.toBe("fallback"); + await expect(run("Ini_ListSection", { file })).resolves.toEqual(["App", "Network"]); + await expect(run("Ini_ListKey", { file, section: "App" })).resolves.toEqual(["name", "workers"]); + await run("Ini_DeleteKey", { file, section: "App", key: "workers" }); + await run("Ini_DeleteSection", { file, section: "Network" }); + await expect(run("Ini_ListKey", { file, section: "App" })).resolves.toEqual(["name"]); + await expect(run("Ini_ListSection", { file })).resolves.toEqual(["App"]); + await expect(readFile(file, "utf8")).resolves.toContain("name=RPA Studio"); + }); + + it("writes runtime logs and raises a located user exception", async () => { + await run("Log_Info", { msg: "started" }); + await run("Log_Error", { msg: "failed" }); + expect(log).toHaveBeenNthCalledWith(1, "started", "info", "data_Log_Info"); + expect(log).toHaveBeenNthCalledWith(2, "failed", "error", "data_Log_Error"); + await expect(run("ExceptionUtil_Throws", { msg: "stop now" })).rejects.toMatchObject({ + name: "RuntimeError", code: "user_exception", message: "stop now", nodeId: "data_ExceptionUtil_Throws" + }); + }); + + it("creates tables from arrays, records, and column dictionaries", async () => { + await expect(run("Bivartable_CreateDf", { data: [[1, 2], [3, 4]], columnList: ["A", "B"] })) + .resolves.toEqual([["A", "B"], [1, 2], [3, 4]]); + await expect(run("Bivartable_CreateDfV1", { data: [{ name: "A", score: 2 }, { name: "B", score: 4 }] })) + .resolves.toEqual([["name", "score"], ["A", 2], ["B", 4]]); + await expect(run("Bivartable_DoCreate", { data: { name: ["A", "B"], score: [2, 4] } })) + .resolves.toEqual([["name", "score"], ["A", 2], ["B", 4]]); + }); + + it("calculates scalar and grouped table aggregates", async () => { + const input = [["team", "score"], ["A", 2], ["A", 4], ["B", 3]]; + await expect(run("Bivartable_GetMaxValue", { df: input, maxvaluecols: "score" })).resolves.toBe(4); + await expect(run("Bivartable_GetMinValue", { df: input, minvaluecols: "score" })).resolves.toBe(2); + await expect(run("Bivartable_GetSumValue", { df: input, sumvaluecols: "score" })).resolves.toBe(9); + await expect(run("Bivartable_GetAvgValue", { df: input, avgvaluecols: "score", groupby: "team" })) + .resolves.toEqual([["team", "score"], ["A", 3], ["B", 3]]); + await expect(run("Bivartable_SummaryCalculationDf", { df: input, name: "score", method: "sum" })).resolves.toBe(9); + }); + + it("deduplicates, merges, and filters local tables", async () => { + const duplicated = [["id", "name"], [1, "A"], [1, "A2"], [2, "B"]]; + await expect(run("Bivartable_DeleteDuplicateRow", { df: duplicated, change_type: "name", col_name: ["id"], mode: "first" })) + .resolves.toEqual([["id", "name"], [1, "A"], [2, "B"]]); + const left = [["id", "name"], [1, "A"], [2, "B"]]; + const right = [["id", "score"], [1, 90], [3, 70]]; + await expect(run("Bivartable_MergeDf", { df1: left, df2: right, on_name: "id", merge_type: "left" })) + .resolves.toEqual([["id", "name", "score"], [1, "A", 90], [2, "B", null]]); + await expect(run("Bivartable_DoMerge", { df1: left, df2: right, onx: "id", howx: "inner" })) + .resolves.toEqual([["id", "name", "score"], [1, "A", 90]]); + await expect(run("Bivartable_FilterDf", { df: left, col_name: "id", choice: ">", criteria: 1 })) + .resolves.toEqual([["id", "name"], [2, "B"]]); + await expect(run("Bivartable_DoFilter", { df: [["id", "score"], [1, 70], [2, 90], [3, 95]], filter: "(df['id'] > 1) & (df['score'] >= 90)" })) + .resolves.toEqual([["id", "score"], [2, 90], [3, 95]]); + }); +}); diff --git a/src/main/capabilities/data-extended.ts b/src/main/capabilities/data-extended.ts new file mode 100644 index 0000000..eec8ca6 --- /dev/null +++ b/src/main/capabilities/data-extended.ts @@ -0,0 +1,392 @@ +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { register } from "../engine/registry"; +import { RuntimeError, type CapabilityHandler } from "../engine/types"; + +type Table = unknown[][]; +type IniDocument = Map>; + +function value(args: Record, keys: string[], fallback?: unknown): unknown { + for (const key of keys) { + const candidate = args[key]; + if (candidate !== undefined && candidate !== null && candidate !== "") return candidate; + } + return fallback; +} + +function text(candidate: unknown): string { + return candidate == null ? "" : String(candidate); +} + +function parse(candidate: unknown): unknown { + if (typeof candidate !== "string") return candidate; + const source = candidate.trim(); + if (!source) return candidate; + try { return JSON.parse(source); } catch { return candidate; } +} + +function list(candidate: unknown): unknown[] { + const parsed = parse(candidate); + if (Array.isArray(parsed)) return parsed; + if (parsed === undefined || parsed === null || parsed === "") return []; + if (typeof parsed === "string") return parsed.split(/[,\uFF0C\n]/).map((item) => item.trim()).filter(Boolean); + return [parsed]; +} + +function table(candidate: unknown): Table { + const parsed = parse(candidate); + if (Array.isArray(parsed)) { + if (!parsed.length) return []; + if (parsed.every((row) => Array.isArray(row))) return parsed.map((row) => [...row as unknown[]]); + if (parsed.every((row) => row && typeof row === "object" && !Array.isArray(row))) { + const headers = [...new Set(parsed.flatMap((row) => Object.keys(row as Record)))]; + return [headers, ...parsed.map((row) => headers.map((header) => (row as Record)[header]))]; + } + return parsed.map((item) => [item]); + } + if (parsed && typeof parsed === "object") { + const source = parsed as Record; + const headers = Object.keys(source); + const columns = headers.map((header) => list(source[header])); + const rowCount = Math.max(0, ...columns.map((column) => column.length)); + return [headers, ...Array.from({ length: rowCount }, (_, row) => columns.map((column) => column[row]))]; + } + return parsed === undefined || parsed === null || parsed === "" ? [] : [[parsed]]; +} + +function columnNames(candidate: unknown): string[] { + return list(candidate).map((item) => text(item)).filter(Boolean); +} + +function columnIndex(headers: unknown[], name: unknown): number { + const source = text(name).trim(); + const byName = headers.findIndex((header) => text(header) === source); + if (byName >= 0) return byName; + if (/^\d+$/.test(source)) return Math.max(0, Number(source) - 1); + if (/^[A-Za-z]+$/.test(source)) { + let result = 0; + for (const character of source.toUpperCase()) result = result * 26 + character.charCodeAt(0) - 64; + return Math.max(0, result - 1); + } + return -1; +} + +function selectedIndexes(rows: Table, candidate: unknown): number[] { + const headers = rows[0] ?? []; + const requested = columnNames(candidate); + return requested.map((name) => columnIndex(headers, name)).filter((index) => index >= 0); +} + +function comparable(candidate: unknown): string | number | boolean | null { + if (candidate === null || candidate === undefined) return null; + if (typeof candidate === "number" || typeof candidate === "boolean") return candidate; + const source = text(candidate).trim().replace(/^\(+|\)+$/g, "").trim(); + if (/^-?\d+(\.\d+)?$/.test(source)) return Number(source); + if (source === "true" || source === "false") return source === "true"; + return source.replace(/^['"]|['"]$/g, ""); +} + +function equal(left: unknown, right: unknown): boolean { + return comparable(left) === comparable(right); +} + +function collection(candidate: unknown): Set | unknown[] { + if (candidate instanceof Set || Array.isArray(candidate)) return candidate; + return list(candidate); +} + +register("Collection_Create", () => []); +register("Collection_HasItem", (_ctx, _node, args) => { + const source = collection(args.collection); + return source instanceof Set ? source.has(args.item) : source.some((item) => equal(item, args.item)); +}); +register("Collection_Count", (_ctx, _node, args) => { + const source = collection(args.collection); + return source instanceof Set ? source.size : source.length; +}); +register("Collection_Add", (_ctx, _node, args) => { + const source = collection(args.collection); + if (source instanceof Set) source.add(args.value); + else if (!source.some((item) => equal(item, args.value))) source.push(args.value); + return source; +}); +register("Collection_Remove", (_ctx, _node, args) => { + const source = collection(args.collection); + if (source instanceof Set) source.delete(args.value); + else { + const index = source.findIndex((item) => equal(item, args.value)); + if (index >= 0) source.splice(index, 1); + } + return source; +}); + +function requiredFile(args: Record, nodeId: string, componentId: string): string { + const candidate = text(args.file).trim(); + if (!candidate) { + throw new RuntimeError("validation_error", "INI file path is empty", { + nodeId, componentId, arg: "file", recommendation: "Select an INI file" + }); + } + return path.resolve(candidate); +} + +function parseIni(source: string): IniDocument { + const document: IniDocument = new Map([["", new Map()]]); + let section = ""; + for (const rawLine of source.replace(/^\uFEFF/, "").split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith(";") || line.startsWith("#")) continue; + const sectionMatch = line.match(/^\[([^\]]+)]$/); + if (sectionMatch) { + section = sectionMatch[1].trim(); + if (!document.has(section)) document.set(section, new Map()); + continue; + } + const separator = rawLine.indexOf("="); + if (separator < 0) continue; + document.get(section)!.set(rawLine.slice(0, separator).trim(), rawLine.slice(separator + 1).trim()); + } + return document; +} + +function serializeIni(document: IniDocument): string { + const lines: string[] = []; + for (const [section, entries] of document) { + if (section) { + if (lines.length) lines.push(""); + lines.push(`[${section}]`); + } + for (const [key, entryValue] of entries) lines.push(`${key}=${entryValue}`); + } + return `${lines.join("\r\n")}\r\n`; +} + +async function loadIni(filePath: string): Promise { + try { return parseIni(await fs.readFile(filePath, "utf8")); } + catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return parseIni(""); + throw error; + } +} + +async function saveIni(filePath: string, document: IniDocument): Promise { + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, serializeIni(document), "utf8"); +} + +register("Ini_Read", async (_ctx, node, args) => { + const document = await loadIni(requiredFile(args, node.id, node.componentId)); + return document.get(text(args.section))?.get(text(args.key)) ?? text(args.defaultValue); +}); +register("Ini_Write", async (_ctx, node, args) => { + const filePath = requiredFile(args, node.id, node.componentId); + const document = await loadIni(filePath); + const section = text(args.section); + if (!document.has(section)) document.set(section, new Map()); + document.get(section)!.set(text(args.key), text(args.value)); + await saveIni(filePath, document); + return filePath; +}); +register("Ini_ListSection", async (_ctx, node, args) => { + const document = await loadIni(requiredFile(args, node.id, node.componentId)); + return [...document.keys()].filter(Boolean); +}); +register("Ini_ListKey", async (_ctx, node, args) => { + const document = await loadIni(requiredFile(args, node.id, node.componentId)); + return [...(document.get(text(args.section))?.keys() ?? [])]; +}); +register("Ini_DeleteKey", async (_ctx, node, args) => { + const filePath = requiredFile(args, node.id, node.componentId); + const document = await loadIni(filePath); + const removed = document.get(text(args.section))?.delete(text(args.key)) ?? false; + await saveIni(filePath, document); + return removed; +}); +register("Ini_DeleteSection", async (_ctx, node, args) => { + const filePath = requiredFile(args, node.id, node.componentId); + const document = await loadIni(filePath); + const removed = document.delete(text(args.section)); + await saveIni(filePath, document); + return removed; +}); + +register("Log_Info", (ctx, node, args) => { + const message = text(args.msg); + ctx.log(message, "info", node.id); + return message; +}); +register("Log_Error", (ctx, node, args) => { + const message = text(args.msg); + ctx.log(message, "error", node.id); + return message; +}); +register("ExceptionUtil_Throws", (_ctx, node, args) => { + throw new RuntimeError("user_exception", text(args.msg) || "User exception", { + nodeId: node.id, componentId: node.componentId + }); +}); + +const createTable: CapabilityHandler = (_ctx, _node, args) => { + const rows = table(value(args, ["data"])); + const columns = columnNames(args.columnList); + if (!columns.length) return rows; + if (!rows.length) return [columns]; + if (rows[0].length !== columns.length) throw new RuntimeError("validation_error", "Column count does not match data"); + return [columns, ...rows]; +}; +register(["Bivartable_CreateDf", "Bivartable_CreateDfV1", "Bivartable_DoCreate"], createTable); + +function aggregate(args: Record, operation: "max" | "min" | "sum" | "avg"): unknown { + const rows = table(args.df); + if (rows.length < 2) return 0; + const field = operation === "max" ? "maxvaluecols" : operation === "min" ? "minvaluecols" : operation === "sum" ? "sumvaluecols" : "avgvaluecols"; + const indexes = selectedIndexes(rows, args[field]); + if (!indexes.length) throw new RuntimeError("validation_error", "No aggregate column was found"); + const groups = selectedIndexes(rows, args.groupby); + const calculate = (body: unknown[][], index: number): number => { + const numbers = body.map((row) => Number(row[index])).filter(Number.isFinite); + if (!numbers.length) return 0; + if (operation === "max") return Math.max(...numbers); + if (operation === "min") return Math.min(...numbers); + const sum = numbers.reduce((total, current) => total + current, 0); + return operation === "avg" ? sum / numbers.length : sum; + }; + if (!groups.length) { + const results = indexes.map((index) => calculate(rows.slice(1), index)); + return results.length === 1 ? results[0] : Object.fromEntries(indexes.map((index, position) => [text(rows[0][index]), results[position]])); + } + const buckets = new Map(); + for (const row of rows.slice(1)) { + const key = JSON.stringify(groups.map((index) => row[index])); + if (!buckets.has(key)) buckets.set(key, []); + buckets.get(key)!.push(row); + } + return [ + [...groups.map((index) => rows[0][index]), ...indexes.map((index) => rows[0][index])], + ...[...buckets.entries()].map(([key, body]) => [...JSON.parse(key), ...indexes.map((index) => calculate(body, index))]) + ]; +} + +register("Bivartable_GetMaxValue", (_ctx, _node, args) => aggregate(args, "max")); +register("Bivartable_GetMinValue", (_ctx, _node, args) => aggregate(args, "min")); +register("Bivartable_GetSumValue", (_ctx, _node, args) => aggregate(args, "sum")); +register("Bivartable_GetAvgValue", (_ctx, _node, args) => aggregate(args, "avg")); +register("Bivartable_SummaryCalculationDf", (_ctx, _node, args) => { + const method = text(args.method).toLowerCase(); + const operation = method.includes("max") ? "max" : method.includes("min") ? "min" : method.includes("avg") || method.includes("mean") ? "avg" : "sum"; + const field = operation === "max" ? "maxvaluecols" : operation === "min" ? "minvaluecols" : operation === "avg" ? "avgvaluecols" : "sumvaluecols"; + return aggregate({ ...args, [field]: args.name }, operation); +}); + +register("Bivartable_DeleteDuplicateRow", (_ctx, _node, args) => { + const rows = table(args.df); + if (rows.length < 2) return rows; + const requested = text(args.change_type) === "name" ? args.col_name : args.col_num; + const indexes = selectedIndexes(rows, requested); + const compared = indexes.length ? indexes : rows[0].map((_item, index) => index); + const keys = rows.slice(1).map((row) => JSON.stringify(compared.map((index) => row[index]))); + const counts = new Map(); + for (const key of keys) counts.set(key, (counts.get(key) ?? 0) + 1); + const mode = text(args.mode).toLowerCase(); + const seen = new Set(); + const body = rows.slice(1).filter((_row, index) => { + const key = keys[index]; + if (mode === "false" || mode === "none" || mode.includes("all")) return counts.get(key) === 1; + if (mode === "last") return keys.lastIndexOf(key) === index; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + return [rows[0], ...body]; +}); + +function mergeTables(args: Record): Table { + const left = table(args.df1); + const right = table(args.df2); + if (!left.length) return right; + if (!right.length) return left; + const mode = text(value(args, ["merge_type", "howx"], "inner")).toLowerCase(); + const common = left[0].filter((header) => right[0].some((candidate) => text(candidate) === text(header))).map(text); + const sameKeys = columnNames(value(args, ["on_name", "onx"])); + const leftKeys = columnNames(value(args, ["on_left", "left_onx"], sameKeys.length ? sameKeys : common)); + const rightKeys = columnNames(value(args, ["on_right", "right_onx"], sameKeys.length ? sameKeys : common)); + if (!leftKeys.length || leftKeys.length !== rightKeys.length) throw new RuntimeError("validation_error", "Invalid table join keys"); + const leftIndexes = leftKeys.map((key) => columnIndex(left[0], key)); + const rightIndexes = rightKeys.map((key) => columnIndex(right[0], key)); + if ([...leftIndexes, ...rightIndexes].some((index) => index < 0)) throw new RuntimeError("validation_error", "Table join key does not exist"); + const rightDataIndexes = right[0].map((_header, index) => index).filter((index) => !rightIndexes.includes(index)); + const outputHeaders = [...left[0], ...rightDataIndexes.map((index) => { + const header = text(right[0][index]); + return left[0].some((candidate) => text(candidate) === header) ? `${header}_right` : right[0][index]; + })]; + const rightBuckets = new Map(); + for (const row of right.slice(1)) { + const key = JSON.stringify(rightIndexes.map((index) => row[index])); + if (!rightBuckets.has(key)) rightBuckets.set(key, []); + rightBuckets.get(key)!.push(row); + } + const matchedRight = new Set(); + const output: Table = [outputHeaders]; + for (const row of left.slice(1)) { + const matches = rightBuckets.get(JSON.stringify(leftIndexes.map((index) => row[index]))) ?? []; + if (matches.length) { + for (const match of matches) { + matchedRight.add(match); + output.push([...row, ...rightDataIndexes.map((index) => match[index])]); + } + } else if (mode === "left" || mode === "outer") { + output.push([...row, ...rightDataIndexes.map(() => null)]); + } + } + if (mode === "right" || mode === "outer") { + for (const row of right.slice(1)) { + if (matchedRight.has(row)) continue; + const leftPart = left[0].map((header) => { + const keyPosition = leftKeys.indexOf(text(header)); + return keyPosition >= 0 ? row[rightIndexes[keyPosition]] : null; + }); + output.push([...leftPart, ...rightDataIndexes.map((index) => row[index])]); + } + } + return output; +} +register(["Bivartable_MergeDf", "Bivartable_DoMerge"], (_ctx, _node, args) => mergeTables(args)); + +function predicate(operator: string, expected: unknown): (actual: unknown) => boolean { + const normalized = operator.trim().toLowerCase(); + if (["2", "!=", "<>", "ne"].includes(normalized)) return (actual) => !equal(actual, expected); + if (["3", ">", "gt"].includes(normalized)) return (actual) => Number(actual) > Number(expected); + if (["4", ">=", "gte"].includes(normalized)) return (actual) => Number(actual) >= Number(expected); + if (["5", "<", "lt"].includes(normalized)) return (actual) => Number(actual) < Number(expected); + if (["6", "<=", "lte"].includes(normalized)) return (actual) => Number(actual) <= Number(expected); + if (["7", "contains"].includes(normalized)) return (actual) => text(actual).includes(text(expected)); + if (["8", "not contains"].includes(normalized)) return (actual) => !text(actual).includes(text(expected)); + if (["9", "starts", "startswith"].includes(normalized)) return (actual) => text(actual).startsWith(text(expected)); + if (["10", "ends", "endswith"].includes(normalized)) return (actual) => text(actual).endsWith(text(expected)); + return (actual) => equal(actual, expected); +} + +function expressionFilter(rows: Table, expression: string): unknown[][] { + const conditions = [...expression.matchAll(/(?:\w+\s*)?\[\s*['"]([^'"]+)['"]\s*]\s*(==|!=|>=|<=|>|<)\s*([^&|]+)/g)]; + if (!conditions.length) throw new RuntimeError("validation_error", "Unsupported filter expression"); + const operators = [...expression.matchAll(/\s*([&|])\s*/g)].map((match) => match[1]); + return rows.slice(1).filter((row) => { + const results = conditions.map((match) => { + const index = columnIndex(rows[0], match[1]); + return index >= 0 && predicate(match[2], comparable(match[3]))(row[index]); + }); + return results.slice(1).reduce((result, current, index) => operators[index] === "|" ? result || current : result && current, results[0]); + }); +} + +function filterTable(args: Record): Table { + const rows = table(args.df); + if (rows.length < 2) return rows; + const expression = text(value(args, ["rule", "filter"])).trim(); + if (expression || text(args.filter_mode).toLowerCase().includes("advanced")) return [rows[0], ...expressionFilter(rows, expression)]; + const index = columnIndex(rows[0], args.col_name); + if (index < 0) throw new RuntimeError("validation_error", `Filter column does not exist: ${text(args.col_name)}`); + const matches = predicate(text(args.choice || "1"), parse(args.criteria)); + return [rows[0], ...rows.slice(1).filter((row) => matches(row[index]))]; +} +register(["Bivartable_FilterDf", "Bivartable_DoFilter"], (_ctx, _node, args) => filterTable(args)); diff --git a/src/main/capabilities/index.ts b/src/main/capabilities/index.ts index 8cf0a9a..22a492f 100644 --- a/src/main/capabilities/index.ts +++ b/src/main/capabilities/index.ts @@ -13,5 +13,7 @@ import "./vision"; import "./extended"; import "./system-extended"; import "./office-file-extended"; +import "./window-extended"; +import "./data-extended"; export { capabilityCount, capabilityIds, getHandler, hasCapability, register } from "../engine/registry"; diff --git a/src/main/capabilities/window-extended.test.ts b/src/main/capabilities/window-extended.test.ts new file mode 100644 index 0000000..f50411a --- /dev/null +++ b/src/main/capabilities/window-extended.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import type { CatalogComponent, FlowNode } from "../../shared/types"; +import type { RunContext } from "../engine/types"; +import { getHandler } from "../engine/registry"; +import "./window-extended"; + +const context = {} as RunContext; +const component = {} as CatalogComponent; +const node = (id: string): FlowNode => ({ id, componentId: id, position: { x: 0, y: 0 }, values: {}, options: {} }); +const ids = [ + "Window_ActiveWindow_v1", "Window_Get", "Window_GetWindow", "Window_GetWindow_v1", "Window_GetWindowMessage", + "Window_GetWindowMessage_v1", "Window_SetPos", "Window_MoveWindow", "Window_MoveWindow_v1", "Window_SizeWindow", + "Mouse_MoveToV1", "Mouse_MoveToV2", "Mouse_DragToV1", "Mouse_DragToV2", "Mouse_DragToNew", + "Mouse_ClickCombine", "Mouse_ClickCombine_v1", "Mouse_ClickCombine_v2", "Mouse_ClickCombine_v3", "Mouse_ClickCombine_v4" +]; + +describe("window extended capabilities", () => { + it("registers audited window and mouse variants", () => { + for (const id of ids) expect(getHandler(id), id).toBeTruthy(); + }); + + it("rejects missing window selectors before touching the desktop", async () => { + for (const id of ["Window_GetWindow", "Window_ActiveWindow_v1", "Window_MoveWindow", "Window_SizeWindow"]) { + await expect(getHandler(id)!(context, node(id), {}, component)).rejects.toMatchObject({ code: "validation_error" }); + } + }); + + it("rejects malformed mouse coordinates before injecting input", async () => { + await expect(getHandler("Mouse_MoveToV1")!(context, node("Mouse_MoveToV1"), { location: "bad" }, component)).rejects.toMatchObject({ code: "validation_error" }); + await expect(getHandler("Mouse_DragToV1")!(context, node("Mouse_DragToV1"), { Start: "bad", End: "[1,2]" }, component)).rejects.toMatchObject({ code: "validation_error" }); + await expect(getHandler("Mouse_ClickCombine")!(context, node("Mouse_ClickCombine"), { target: "1", location: "[1,2]" }, component)).rejects.toMatchObject({ code: "validation_error" }); + }); +}); diff --git a/src/main/capabilities/window-extended.ts b/src/main/capabilities/window-extended.ts new file mode 100644 index 0000000..a74084a --- /dev/null +++ b/src/main/capabilities/window-extended.ts @@ -0,0 +1,133 @@ +import { register } from "../engine/registry"; +import { RuntimeError, type CapabilityHandler } from "../engine/types"; +import { powershell, psString } from "./desktop"; + +const WINDOW_API = `Add-Type @' +using System; +using System.Runtime.InteropServices; +public static class RpaWindowExtended { + [StructLayout(LayoutKind.Sequential)] public struct RECT { public int Left; public int Top; public int Right; public int Bottom; } + [DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr hWnd, out RECT rect); + [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd); + [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int command); + [DllImport("user32.dll")] public static extern bool SetWindowPos(IntPtr hWnd, IntPtr after, int x, int y, int width, int height, uint flags); + [DllImport("user32.dll")] public static extern bool SetCursorPos(int x, int y); + [DllImport("user32.dll")] public static extern void mouse_event(uint flags, uint dx, uint dy, uint data, UIntPtr extra); +} +'@`; + +function value(args: Record, keys: string[], fallback = ""): string { + for (const key of keys) { + const current = args[key]; + if (current !== undefined && current !== null && current !== "") return String(current); + } + return fallback; +} + +function parseObject(candidate: unknown): Record { + if (candidate && typeof candidate === "object") return candidate as Record; + if (typeof candidate === "string" && candidate.trim().startsWith("{")) { + try { return JSON.parse(candidate); } catch { return {}; } + } + return {}; +} + +function titleOf(args: Record): string { + const objectCandidate = args.window_obj ?? args["窗口对象"] ?? args.uiWindowSelector ?? args.target_window; + const object = parseObject(objectCandidate); + return String(object.title ?? object.windowTitle ?? args.title ?? args.match_title ?? args.select_title ?? objectCandidate ?? "").trim(); +} + +function requiredTitle(args: Record, nodeId: string, componentId: string): string { + const title = titleOf(args); + if (!title || title === "{}" || title === "窗口对象") throw new RuntimeError("validation_error", "窗口标题为空", { nodeId, componentId, recommendation: "填写窗口标题或传入窗口对象" }); + return title; +} + +function findWindow(title: string): string { + return `$p = Get-Process | Where-Object { $_.MainWindowHandle -ne 0 -and $_.MainWindowTitle -like ${psString(`*${title}*`)} } | Select-Object -First 1; if (-not $p) { throw '找不到窗口:${title.replace(/'/g, "''")}' }`; +} + +function parsePoint(candidate: unknown, label: string, nodeId: string, componentId: string): [number, number] { + let parsed: unknown = candidate; + if (typeof parsed === "string") { + const source = parsed; + try { parsed = JSON.parse(source); } catch { parsed = source.split(/[,,xX ]+/).map(Number); } + } + if (!Array.isArray(parsed) || parsed.length < 2 || parsed.slice(0, 2).some((item) => !Number.isFinite(Number(item)))) { + throw new RuntimeError("validation_error", `${label}无效`, { nodeId, componentId }); + } + return [Number(parsed[0]), Number(parsed[1])]; +} + +async function windowObject(title: string): Promise> { + const output = await powershell(`${WINDOW_API}; ${findWindow(title)}; $r = New-Object RpaWindowExtended+RECT; [RpaWindowExtended]::GetWindowRect($p.MainWindowHandle,[ref]$r) | Out-Null; @{pid=$p.Id;title=$p.MainWindowTitle;hwnd=$p.MainWindowHandle.ToInt64();x=$r.Left;y=$r.Top;width=$r.Right-$r.Left;height=$r.Bottom-$r.Top} | ConvertTo-Json -Compress`); + return JSON.parse(output); +} + +const getWindow: CapabilityHandler = async (_ctx, node, args) => windowObject(requiredTitle(args, node.id, node.componentId)); +register(["Window_Get", "Window_GetWindow", "Window_GetWindow_v1"], getWindow); + +register("Window_ActiveWindow_v1", async (_ctx, node, args) => { + const title = requiredTitle(args, node.id, node.componentId); + const maximize = [true, 1, "1", "true", "True"].includes(args.isMax as never); + await powershell(`${WINDOW_API}; ${findWindow(title)}; [RpaWindowExtended]::SetForegroundWindow($p.MainWindowHandle) | Out-Null; ${maximize ? "[RpaWindowExtended]::ShowWindow($p.MainWindowHandle,3) | Out-Null" : ""}`); + return windowObject(title); +}); + +register(["Window_GetWindowMessage", "Window_GetWindowMessage_v1"], async (_ctx, node, args) => { + const object = await windowObject(requiredTitle(args, node.id, node.componentId)); + const type = value(args, ["switchType"], "all").toLowerCase(); + if (type === "title") return object.title; + if (type === "pid") return object.pid; + if (type === "position" || type === "location") return [object.x, object.y]; + if (type === "size") return [object.width, object.height]; + return object; +}); + +register(["Window_SetPos", "Window_MoveWindow", "Window_MoveWindow_v1", "Window_SizeWindow"], async (_ctx, node, args) => { + const title = requiredTitle(args, node.id, node.componentId); + const current = await windowObject(title); + const moveOnly = node.componentId.includes("MoveWindow"); + const sizeOnly = node.componentId === "Window_SizeWindow"; + const position = args.location !== undefined ? parsePoint(args.location, "窗口位置", node.id, node.componentId) : [Number(args.x ?? current.x), Number(args.y ?? current.y)] as [number, number]; + const sizeCandidate = args.size ?? args.window_size; + const size = sizeCandidate !== undefined ? parsePoint(sizeCandidate, "窗口尺寸", node.id, node.componentId) : [Number(args.width ?? current.width), Number(args.height ?? current.height)] as [number, number]; + const x = sizeOnly ? Number(current.x) : position[0]; + const y = sizeOnly ? Number(current.y) : position[1]; + const width = moveOnly ? Number(current.width) : size[0]; + const height = moveOnly ? Number(current.height) : size[1]; + if (!(width > 0 && height > 0)) throw new RuntimeError("validation_error", "窗口尺寸必须大于 0", { nodeId: node.id, componentId: node.componentId }); + await powershell(`${WINDOW_API}; ${findWindow(title)}; [RpaWindowExtended]::SetWindowPos($p.MainWindowHandle,[IntPtr]::Zero,${x},${y},${width},${height},4) | Out-Null`); + return { ...(await windowObject(title)), x, y, width, height }; +}); + +const moveMouse: CapabilityHandler = async (_ctx, node, args) => { + const [x, y] = parsePoint(args.location ?? args.position, "鼠标坐标", node.id, node.componentId); + await powershell(`${WINDOW_API}; [RpaWindowExtended]::SetCursorPos(${x},${y}) | Out-Null`); + return { x, y }; +}; +register(["Mouse_MoveToV1", "Mouse_MoveToV2"], moveMouse); + +const dragMouse: CapabilityHandler = async (_ctx, node, args) => { + const [sx, sy] = parsePoint(args.Start ?? args.start, "起始坐标", node.id, node.componentId); + const [ex, ey] = parsePoint(args.End ?? args.end, "结束坐标", node.id, node.componentId); + const right = ["1", "right"].includes(value(args, ["side"], "0").toLowerCase()); + const down = right ? 8 : 2; + const up = right ? 16 : 4; + await powershell(`${WINDOW_API}; [RpaWindowExtended]::SetCursorPos(${sx},${sy}) | Out-Null; [RpaWindowExtended]::mouse_event(${down},0,0,0,[UIntPtr]::Zero); Start-Sleep -Milliseconds 80; [RpaWindowExtended]::SetCursorPos(${ex},${ey}) | Out-Null; [RpaWindowExtended]::mouse_event(${up},0,0,0,[UIntPtr]::Zero)`); + return { start: [sx, sy], end: [ex, ey] }; +}; +register(["Mouse_DragToV1", "Mouse_DragToV2", "Mouse_DragToNew"], dragMouse); + +register(["Mouse_ClickCombine", "Mouse_ClickCombine_v1", "Mouse_ClickCombine_v2", "Mouse_ClickCombine_v3", "Mouse_ClickCombine_v4"], async (_ctx, node, args) => { + if (value(args, ["target"], "0") !== "0") throw new RuntimeError("validation_error", "组合点击当前仅支持坐标目标", { nodeId: node.id, componentId: node.componentId }); + const [x, y] = parsePoint(args.location, "点击坐标", node.id, node.componentId); + const right = value(args, ["click_key"], "left").toLowerCase() === "right"; + const doubleClick = value(args, ["click_time"], "click").toLowerCase().includes("double"); + const down = right ? 8 : 2; + const up = right ? 16 : 4; + const repeats = doubleClick ? 2 : 1; + await powershell(`${WINDOW_API}; [RpaWindowExtended]::SetCursorPos(${x},${y}) | Out-Null; 1..${repeats} | ForEach-Object { [RpaWindowExtended]::mouse_event(${down},0,0,0,[UIntPtr]::Zero); [RpaWindowExtended]::mouse_event(${up},0,0,0,[UIntPtr]::Zero); Start-Sleep -Milliseconds 80 }`); + return { x, y, button: right ? "right" : "left", clicks: repeats }; +}); diff --git a/src/renderer/src/pages/SettingsPage.tsx b/src/renderer/src/pages/SettingsPage.tsx index 0e0db91..4e60c9e 100644 --- a/src/renderer/src/pages/SettingsPage.tsx +++ b/src/renderer/src/pages/SettingsPage.tsx @@ -1,112 +1,75 @@ -import { useEffect, useState } from "react"; -import { Cpu, Database, FolderCog, HardDrive, Info } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; +import { Cpu, Database, FolderCog, Globe2, HardDrive, Info, ShieldCheck } from "lucide-react"; +import type { LucideIcon } from "lucide-react"; +import type { ReactNode } from "react"; import type { AppInfo, Catalog } from "@shared/types"; +type SettingsTab = "about" | "catalog" | "execution" | "browser"; + export function SettingsPage({ catalog }: { catalog: Catalog }) { + const [tab, setTab] = useState("about"); const [info, setInfo] = useState(null); + const [handlers, setHandlers] = useState<{ count: number; ids: string[] } | null>(null); useEffect(() => { void window.rpa.app.info().then(setInfo).catch(() => setInfo(null)); + void window.rpa.run.capabilities().then(setHandlers).catch(() => setHandlers(null)); }, []); const executable = catalog.components.filter((item) => item.implemented).length; + const groups = useMemo(() => catalog.groups.map((group) => { + const components = catalog.components.filter((component) => component.group === group.id); + const connected = components.filter((component) => component.implemented).length; + return { ...group, connected, pending: components.length - connected }; + }), [catalog]); return (
-
-

- - 版本与运行环境 -

-
-
应用版本
-
{info?.version || "0.1.0"}
-
Electron / Chromium
-
- {info?.electron || "-"} / {info?.chrome || "-"} -
-
Node
-
{info?.node || "-"}
-
运行模式
-
{info?.packaged ? "打包运行(离线)" : "开发模式"}
-
数据目录
-
{info?.userData || "-"}
-
目录文件
-
{info?.catalogPath || "-"}
-
-
+ {tab === "about" && ( + <> + +
应用版本
{info?.version || "0.1.0"}
Electron / Chromium
{info?.electron || "-"} / {info?.chrome || "-"}
Node
{info?.node || "-"}
运行模式
{info?.packaged ? "打包运行(离线)" : "开发模式"}
数据目录
{info?.userData || "-"}
目录文件
{info?.catalogPath || "-"}
+
+

组件元数据来自项目内 1016 项目录。移动端、机械臂、U 盾、远程桌面、SAP/IE 专用通道、云服务、数据库和 FTP 保持待接入,不随当前版本承诺执行。

+ + )} -
-

- - 组件目录 -

-
-
目录总数
-
{catalog.counts.total} 项
-
厂商组件 / 控制 / 触发
-
- {catalog.counts.vendor} / {catalog.counts.native} / {catalog.counts.triggers} -
-
字段总数
-
{catalog.counts.fields}
-
已接入执行器
-
- {executable} 项({((executable / catalog.counts.total) * 100).toFixed(1)}%) -
-
待接入
-
{catalog.counts.total - executable} 项(灰标展示)
-
-
+ {tab === "catalog" && ( + <> + +
目录总数
{catalog.counts.total} 项
已接入执行器
{executable} 项({((executable / catalog.counts.total) * 100).toFixed(1)}%)
待接入
{catalog.counts.total - executable} 项
注册处理器 ID
{handlers?.count ?? "—"}
+
+

分类覆盖

{groups.map((group) =>
{group.label}{group.connected} 已接入{group.pending} 待接入
)}
+ + )} -
-

- - 执行方式 -

-
-
执行位置
-
本机主进程(无云端依赖)
-
浏览器
-
内嵌 WebContentsView + CDP(Day 2)
-
桌面
-
PowerShell 桥:UIA + SendInput(Day 2)
-
Office
-
exceljs / COM(Day 3)
-
-
+ {tab === "execution" && ( + <> +
执行位置
本机主进程
浏览器
独立 Electron 运行窗口 + CDP
桌面
PowerShell / user32 本地桥
Office
ExcelJS / PDF.js / pdf-lib / Word COM
运行前检查
待接入、必填、多出口、断链
+

流程保存到数据目录的 flows/;运行结果、错误和截图写入 runs/<runId>/。任务列表会读取最近一次运行的状态、时间、耗时和失败原因。

+

运行期默认离线。桌面动作只在流程显式运行时执行;未接入能力不会静默成功,而是在运行前检查或节点执行时返回明确错误。

+ + )} -
-

- - 数据与证据 -

-

- 流程以 JSON 原子写入数据目录;运行记录与截图将写入 runs/<runId>/。运行期不加载 CDN、字体或云服务。 -

-
- -
-

- - 参考与边界 -

-

- 组件元数据来自项目内 1016 项目录(997 厂商 + 16 控制 + 3 触发)。厂商代码、二进制与模型不随产品分发;未接入执行器的组件 - 以灰标展示,运行到该节点会明确报错。 -

-
+ {tab === "browser" && ( + <> +
默认允许
127.0.0.1、localhost、example.com
扩展方式
环境变量 RPA_ALLOWED_DOMAINS
格式
逗号分隔域名,不含协议和路径
+

导航只允许 HTTP/HTTPS。未列入白名单的域名会返回 blocked_url;文件协议、脚本协议和任意本地路径不会由浏览器导航组件打开。

+ + )}
); } + +function SettingCard({ icon: Icon, title, children }: { icon: LucideIcon; title: string; children: ReactNode }) { + return

{title}

{children}
; +} diff --git a/src/renderer/src/styles/app.css b/src/renderer/src/styles/app.css index f0dd47e..83cbe04 100644 --- a/src/renderer/src/styles/app.css +++ b/src/renderer/src/styles/app.css @@ -1653,6 +1653,39 @@ textarea:focus-visible { margin-bottom: 9px; } +.setting-card h3 .icon { + width: 14px; + height: 14px; + display: inline-block; + margin-right: 6px; + vertical-align: -2px; +} + +.setting-copy { + line-height: 19px; + max-width: 880px; +} + +.setting-group-table { + display: grid; + gap: 2px; +} + +.setting-group-table > div { + min-height: 42px; + display: grid; + grid-template-columns: minmax(150px, 1fr) 90px 90px minmax(120px, 1fr); + gap: 12px; + align-items: center; + border-bottom: 1px solid var(--line); + font-size: 10.5px; +} + +.setting-group-table > div:last-child { border-bottom: 0; } +.setting-group-table strong { font-size: 11px; } +.setting-group-table span { color: var(--muted); } +.setting-group-table progress { width: 100%; height: 7px; accent-color: var(--blue); } + .kv { display: grid; grid-template-columns: 150px minmax(0, 1fr);