import fs from "node:fs"; import path from "node:path"; import { getHandler, register } from "../engine/registry"; import { runFlow } from "../engine/runtime"; import { RuntimeError, type CapabilityHandler, type RunContext } from "../engine/types"; import { powershell, psString } from "./desktop"; import { findImage } from "./vision"; function text(args: Record, keys: string[], fallback = ""): string { for (const key of keys) { const value = args[key]; if (value !== undefined && value !== null && value !== "") return String(value); } return fallback; } function num(args: Record, keys: string[], fallback = 0): number { const value = Number(text(args, keys, String(fallback))); return Number.isFinite(value) ? value : fallback; } function bool(value: unknown, fallback = false): boolean { if (value === undefined || value === null || value === "") return fallback; return [true, 1, "1", "true", "True", "是", "开启"].includes(value as never); } function list(value: unknown): string[] { if (Array.isArray(value)) return value.map(String); const raw = String(value ?? "").trim(); if (!raw) return []; try { const parsed = JSON.parse(raw); return Array.isArray(parsed) ? parsed.map(String) : [String(parsed)]; } catch { return raw.split(/[,,;;\n]+/).map((item) => item.trim()).filter(Boolean); } } function record(value: unknown): Record { return value && typeof value === "object" ? value as Record : (() => { try { return JSON.parse(String(value || "{}")); } catch { return {}; } })(); } function required(value: string, label: string, node: { id: string; componentId: string }): string { if (!value.trim()) throw new RuntimeError("validation_error", label + "为空", { nodeId: node.id, componentId: node.componentId }); return value; } const dbConnections = new Map(); function dbConfig(args: Record): Record { const inline = record(args.DatabaseConfig || args.config || args.connectionID); const merged = { ...inline, ...args }; return { ...inline, type: text(merged, ["dtype", "type"], "mysql"), host: text(merged, ["host", "server", "server_address"], "127.0.0.1"), port: num(merged, ["port"], 0), user: text(merged, ["user", "username", "account"], ""), password: text(merged, ["password", "pass"], ""), database: text(merged, ["database", "db", "catalog"], "") }; } async function databaseClient(config: Record) { const type = String(config.type || "mysql").toLowerCase(); if (type.includes("mysql")) { const mysql = await import("mysql2/promise"); return await mysql.createConnection(config as never); } if (type.includes("postgres") || type.includes("pgsql")) { const { Client } = await import("pg"); const client = new Client({ host: String(config.host), port: Number(config.port || 5432), user: String(config.user), password: String(config.password), database: String(config.database) }); await client.connect(); return client; } if (type.includes("sqlserver") || type.includes("mssql")) { const sql = await import("mssql"); return await sql.connect(config as never); } throw new RuntimeError("validation_error", "暂不支持的数据库类型:" + type); } async function rowsOf(client: any, sql: string, params: string[]) { const result = await client.query(sql, params); return result.rows || result.recordset || []; } async function runDb(args: Record, sql: string) { const key = String(args.connectionID || "inline"); let client = dbConnections.get(key); if (!client) { client = await databaseClient(dbConfig(args)); dbConnections.set(key, client); } return await rowsOf(client, sql, list(args.paras)); } register("Database_DBConnect", async (_ctx, _node, args) => { const id = "db_" + Date.now().toString(36); const client = await databaseClient(dbConfig(args)); dbConnections.set(id, client); return { connectionID: id }; }); register(["Database_SingleSQLQuery", "Database_AllSQLQuery", "Database_AllSQLQuery_V1"], async (_ctx, node, args) => runDb(args, required(text(args, ["sql"]), "SQL", node))); register(["Database_SQLExecute", "Database_BatchSQLExecute"], async (_ctx, node, args) => { const statements = list(args.sql).length ? list(args.sql) : [required(text(args, ["sql"]), "SQL", node)]; for (const statement of statements) await runDb(args, statement); return { executed: statements.length }; }); register("Database_BatchInsertExecute", async (_ctx, node, args) => { const parsed = record(args.data); const rows = Array.isArray(parsed) ? parsed : Array.isArray(parsed.value) ? parsed.value : []; for (const row of rows) await runDb(args, required(text(args, ["sql"]), "SQL", node)); return { inserted: rows.length }; }); register("Database_CloseDBConnect", async (_ctx, _node, args) => { const key = String(args.connectionID || ""); const client = dbConnections.get(key); if (client) { if (client.close) await client.close(); if (client.end) await client.end(); } dbConnections.delete(key); return { closed: true }; }); async function callSubflow(ctx: RunContext, node: { id: string; componentId: string }, args: Record) { const { listFlows, getFlow } = await import("../flows"); const wanted = text(args, ["module_name", "module", "flow"]); const rows = await listFlows(); const found = rows.find((row) => row.id === wanted || row.name === wanted); if (!found) throw new RuntimeError("validation_error", "子流程不存在:" + wanted, { nodeId: node.id, componentId: node.componentId }); const { randomUUID } = await import("node:crypto"); const child = await getFlow(found.id); const inputs = record(args.args || args.input); return await runFlow(child, { runId: randomUUID(), evidenceDir: ctx.evidenceDir, signal: ctx.signal, emit: ctx.emit, log: ctx.log, componentOf: ctx.componentOf, browser: ctx.browser, variables: { ...ctx.vars, ...inputs } }); } register("child_modules_v1", async (ctx, node, args) => callSubflow(ctx, node, args)); register("concurrent_child_process_v1", async (ctx, node, args) => { const tasks = Array.from({ length: Math.max(1, num(args, ["count"], 2)) }, () => callSubflow(ctx, node, args)); return bool(args.is_async) ? { started: true } : await Promise.all(tasks); }); register("Element_exception_monitor_v2", async (ctx, node, args) => { ctx.log("异常监控已启用:" + text(args, ["event_type", "monitor_type"], "element"), bool(args.is_exits) ? "info" : "warn", node.id); return { monitoring: true }; }); register("FileTypeConversion_Office2pdf", async (_ctx, node, args) => { const input = path.resolve(text(args, ["fileInPath", "path"])); const folder = path.resolve(text(args, ["fileOutPath", "folder"], path.dirname(input))); fs.mkdirSync(folder, { recursive: true }); const output = path.join(folder, path.basename(input, path.extname(input)) + ".pdf"); const ext = path.extname(input).toLowerCase(); if ([".doc", ".docx", ".rtf"].includes(ext)) await powershell(`$w=New-Object -ComObject Word.Application;$w.Visible=$false;$d=$w.Documents.Open(${psString(input)});$d.SaveAs([ref]${psString(output)},[ref]17);$d.Close($false);$w.Quit()`); else if ([".xls", ".xlsx", ".csv"].includes(ext)) await powershell(`$e=New-Object -ComObject Excel.Application;$e.Visible=$false;$w=$e.Workbooks.Open(${psString(input)});$w.ExportAsFixedFormat(0,${psString(output)});$w.Close($false);$e.Quit()`); else if ([".ppt", ".pptx"].includes(ext)) await powershell(`$p=New-Object -ComObject PowerPoint.Application;$pre=$p.Presentations.Open(${psString(input)},$true,$false,$false);$pre.SaveAs(${psString(output)},32);$pre.Close()`); else throw new RuntimeError("validation_error", "不支持的 Office 文件:" + ext); return output; }); register("FileTypeConversion_Pdf2image2", async (ctx, node, args) => { const pdf = path.resolve(text(args, ["pdfPath", "path"])); const folder = path.resolve(text(args, ["imageDir", "folder"], path.dirname(pdf))); const handler = getHandler("PDF_PdfToImg"); if (!handler) throw new RuntimeError("capability_not_implemented", "PDF 渲染能力不可用"); return await handler(ctx, node, { ...args, fileInPath: pdf, pdfSavePath: folder, imageName: path.basename(pdf, path.extname(pdf)), imageType: "png" }, {} as never); }); function sheetId(args: Record): string { const url = text(args, ["url", "spreadsheetUrl"]); const match = url.match(/\/spreadsheets\/d\/([a-zA-Z0-9-_]+)/); if (!match) throw new RuntimeError("validation_error", "无效 Google Sheets URL:" + url); return match[1]; } function sheetRange(args: Record): string { const sheet = encodeURIComponent(text(args, ["sheetName"], "Sheet1")); const start = text(args, ["startPosition", "start"], "A1"); const end = text(args, ["endPosition", "end"], "Z1000"); return `${sheet}!${start}:${end}`; } async function googleRequest(args: Record, pathUrl: string, init: RequestInit = {}) { const id = sheetId(args); const token = text(args, ["accessToken", "access_token", "token"]); const key = text(args, ["apiKey", "api_key", "key"]); const headers: Record = {}; if (token) headers.Authorization = "Bearer " + token; const separator = pathUrl.includes("?") ? "&" : "?"; const response = await fetch(`https://sheets.googleapis.com/v4/spreadsheets/${id}${pathUrl}${key ? separator + "key=" + key : ""}`, { ...init, headers: { ...headers, ...(init.headers || {}) } }); if (!response.ok) throw new RuntimeError("external_service_failed", "Google Sheets API " + response.status + ": " + await response.text()); return await response.json(); } register("GoogleTable_GetAllData", async (_ctx, node, args) => { const out = await googleRequest(args, `/values/${sheetRange(args)}?majorDimension=ROWS`); return out.values || []; }); register("GoogleTable_GetAllSheetsName", async (_ctx, _node, args) => { const out = await googleRequest(args, "?fields=sheets.properties.title"); return (out.sheets || []).map((sheet: any) => sheet.properties.title); }); async function googleWrite(args: Record) { if (!text(args, ["accessToken", "access_token", "token"])) throw new RuntimeError("validation_error", "Google Sheets 写入需要 accessToken"); return await googleRequest(args, `/values/${sheetRange(args)}?valueInputOption=USER_ENTERED`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ values: (() => { const value = args.Values || args.values; if (Array.isArray(value)) return value.map((row) => Array.isArray(row) ? row : [row]); try { const parsed = JSON.parse(String(value || "[]")); return Array.isArray(parsed) ? parsed.map((row: any) => Array.isArray(row) ? row : [row]) : [[parsed]]; } catch { return [[value]]; } })() }) }); } for (const id of ["GoogleTable_WriteToSheetRegionColumns", "GoogleTable_WriteToSheetRegionRows", "GoogleTable_WriteToSheetRegionregion"]) register(id, async (_ctx, _node, args) => googleWrite(args)); function googleCell(args: Record) { return `${encodeURIComponent(text(args, ["sheetName"], "Sheet1"))}!${text(args, ["cellName", "cell"], "A1")}`; } register("GoogleTable_WriteToSheetCell", async (_ctx, _node, args) => googleRequest(args, `/values/${googleCell(args)}?valueInputOption=RAW`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ values: [[args.value]] }) })); register("GoogleTable_WriteToSheetCellFormula", async (_ctx, _node, args) => { const value = String(args.value || ""); return googleRequest(args, `/values/${googleCell(args)}?valueInputOption=USER_ENTERED`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ values: [[value.startsWith("=") ? value : "=" + value]] }) }); }); register("GoogleTable_GetSheetRowCount", async (_ctx, _node, args) => { const out = await googleRequest(args, `/values/${encodeURIComponent(text(args, ["sheetName"], "Sheet1"))}`); return (out.values || []).length; }); register("GoogleTable_DownloadSheets", async (_ctx, _node, args) => { const format = /xlsx/i.test(text(args, ["format"], "csv")) ? "xlsx" : "csv"; const response = await fetch(`https://docs.google.com/spreadsheets/d/${sheetId(args)}/export?format=${format}`); if (!response.ok) throw new RuntimeError("external_service_failed", "Google 导出失败:" + response.status); const file = path.resolve(process.cwd(), `google-sheet-${Date.now()}.${format}`); fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, Buffer.from(await response.arrayBuffer())); return file; }); register("GoogleTable_CopyPasteSheet", async (_ctx, _node, args) => { const source = `${encodeURIComponent(text(args, ["sourceSheetName"], "Sheet1"))}!${text(args, ["sourceStart"], "A1")}:${text(args, ["sourceEnd"], "Z1000")}`; const target = `${encodeURIComponent(text(args, ["destSheetName"], "Copy"))}!${text(args, ["destStart"], "A1")}:${text(args, ["destEnd"], "Z1000")}`; const values = await googleRequest(args, `/values/${source}`); return await googleRequest(args, `/values/${target}?valueInputOption=USER_ENTERED`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ values: values.values || [] }) }); }); register("GoogleTable_GetColumnsByTableTitle", async (_ctx, _node, args) => { const titles = list(args.tableTitles); const values = await googleRequest(args, `/values/${encodeURIComponent(text(args, ["sheetName"], "Sheet1"))}`); const row = (values.values || [])[num(args, ["rowNum"], 0)] || []; return titles.map((title) => ({ title, value: row[Number(title)] ?? "" })); }); register("GoogleTable_ChangeSheet", async (_ctx, _node, args) => { const out = await googleRequest(args, "?fields=sheets.properties"); const wanted = text(args, ["sheetName"], "Sheet1"); const sheet = (out.sheets || []).find((item: any) => item.properties.title === wanted); if (!sheet) throw new RuntimeError("not_found", "Google Sheet 不存在:" + wanted); return sheet.properties; }); register("GoogleTable_RenameSheet", async (_ctx, _node, args) => { const out = await googleRequest(args, "?fields=sheets.properties.sheetId"); const oldName = text(args, ["originalSheetName"]); const sheet = (out.sheets || []).find((item: any) => item.properties.title === oldName); if (!sheet) throw new RuntimeError("not_found", "Google Sheet 不存在:" + oldName); return await googleRequest(args, ":batchUpdate", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ requests: [{ updateSheet: { properties: { sheetId: sheet.properties.sheetId, title: text(args, ["modifySheetName"]) }, fields: "title" } }] }) }); }); // Image_Exists / Image_Location / Image_Wait / Image_Move 统一由 vision.ts 注册到新查找管线 // (虚拟桌面多屏拼接 + 相似度语义 + JPG 模板)。此处旧实现用 GDI CopyFromScreen 只截主屏 // 且 accuracy 语义颠倒, 已整体迁出删除。 async function captureScreen(file: string) { fs.mkdirSync(path.dirname(file), { recursive: true }); await powershell(`Add-Type -AssemblyName System.Drawing;Add-Type -AssemblyName System.Windows.Forms;$bounds=[System.Windows.Forms.Screen]::PrimaryScreen.Bounds;$bitmap=New-Object System.Drawing.Bitmap $bounds.Width,$bounds.Height;$graphics=[System.Drawing.Graphics]::FromImage($bitmap);$graphics.CopyFromScreen($bounds.Location,[System.Drawing.Point]::Empty,$bounds.Size);$bitmap.Save(${psString(file)});$graphics.Dispose();$bitmap.Dispose()`); } // 图片Base64编解码: 统一组件按 mode 分流(encode=图片→Base64, decode=Base64→图片)。 // Image_Base64Decode 已隐藏进该组件,但保留注册以防旧流程直接引用旧 id。 register("Image_Base64Encode", async (_ctx, node, args) => { const mode = text(args, ["mode"], "encode"); if (mode === "decode") { const value = text(args, ["val", "value"]).replace(/^data:[^;]+;base64,/, ""); if (!value.trim()) throw new RuntimeError("validation_error", "Base64编码不能为空", { nodeId: node.id, componentId: node.componentId, recommendation: "填写 base64 字符串,或用 ${变量} 引用上游编码输出" }); const custom = text(args, ["output"]).trim(); const file = custom ? path.resolve(custom) : path.resolve(process.cwd(), `image-${Date.now()}.png`); fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, Buffer.from(value, "base64")); return file; } const imageArg = text(args, ["path", "image"]); if (!imageArg.trim()) throw new RuntimeError("validation_error", "图片对象不能为空", { nodeId: node.id, componentId: node.componentId, recommendation: "填写源图片路径,或用 ${变量} 引用上游截图输出" }); const source = path.resolve(imageArg); if (!fs.existsSync(source)) throw new RuntimeError("validation_error", `找不到图片文件:${source}`, { nodeId: node.id, componentId: node.componentId, recommendation: "确认路径正确,或先用「屏幕截图」组件生成图片" }); return fs.readFileSync(source).toString("base64"); }); register("Image_Base64Decode", async (_ctx, _node, args) => { const value = text(args, ["val", "value"]).replace(/^data:[^;]+;base64,/, ""); const file = path.resolve(process.cwd(), `image-${Date.now()}.png`); fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, Buffer.from(value, "base64")); return file; }); register("Image_CropImg", async (_ctx, node, args) => { const rawInput = text(args, ["file_path", "imagePath", "path"]); if (!rawInput.trim()) throw new RuntimeError("validation_error", "图片地址不能为空", { nodeId: node.id, componentId: node.componentId, recommendation: "填写要裁剪的源图片完整路径(如 D:\\images\\shot.png),该组件是从已有图片文件里裁剪区域,不是截屏" }); const input = path.resolve(rawInput); if (!fs.existsSync(input)) throw new RuntimeError("validation_error", `找不到图片文件:${input}`, { nodeId: node.id, componentId: node.componentId, recommendation: "确认路径正确;若要截取屏幕内容,请改用「截图图像 (Image_Screenshot)」组件" }); const output = path.resolve(text(args, ["output"], input.replace(/(\.[^.]+)$/, "-crop$1"))); const x = num(args, ["xstart"], 0), y = num(args, ["ystart"], 0), width = num(args, ["xend"], 100) - x, height = num(args, ["yend"], 100) - y; if (width <= 0 || height <= 0) throw new RuntimeError("validation_error", `裁剪区域无效(宽 ${width} × 高 ${height})`, { nodeId: node.id, componentId: node.componentId, recommendation: "终点坐标需要大于起点坐标(单位是图片上的像素)" }); fs.mkdirSync(path.dirname(output), { recursive: true }); // 注意: .NET 方法的参数表里只能放表达式, 不能放 New-Object 语句—— // DrawImage($img, New-Object Rectangle(...), ...) 会解析报错"','后面缺少表达式"(组件此前从未跑通的原因); // 统一改用 [Type]::new() 表达式写法 await powershell(`Add-Type -AssemblyName System.Drawing;$image=[System.Drawing.Image]::FromFile(${psString(input)});$rect=[System.Drawing.Rectangle]::new(${x},${y},${width},${height});$bitmap=[System.Drawing.Bitmap]::new($rect.Width,$rect.Height);$graphics=[System.Drawing.Graphics]::FromImage($bitmap);$graphics.DrawImage($image,[System.Drawing.Rectangle]::new(0,0,$rect.Width,$rect.Height),$rect,[System.Drawing.GraphicsUnit]::Pixel);$bitmap.Save(${psString(output)});$graphics.Dispose();$bitmap.Dispose();$image.Dispose()`); return output; }); register("Image_RelativePosition", async (ctx, node, args) => { const source = path.resolve(text(args, ["source", "image"])); const template = path.resolve(text(args, ["template", "template_path"])); if (!source.trim() || !template.trim()) throw new RuntimeError("validation_error", "被搜索图像与查找目标都需填写图片路径", { nodeId: node.id, componentId: node.componentId }); if (!fs.existsSync(source)) throw new RuntimeError("validation_error", `找不到被搜索图像:${source}`, { nodeId: node.id, componentId: node.componentId }); if (!fs.existsSync(template)) throw new RuntimeError("validation_error", `找不到查找目标图片:${template}`, { nodeId: node.id, componentId: node.componentId }); // 兼容填反:模板比源图大时(首次查找直接抛"还大"错误)自动互换, // 大图当被搜索图、小图当查找目标,语义仍是「小图在大图中的位置」 const accuracy = num(args, ["accuracy"], 0.2); let hit: Awaited> = null; let firstError: unknown = null; try { hit = await findImage(template, source, accuracy); } catch (error) { firstError = error; try { hit = await findImage(source, template, accuracy); if (hit) ctx.log("两张图疑似填反,已自动按「小图在大图中」查找并命中", "warn", node.id); } catch { // 互换也失败则回落到原始方向的错误信息 } } if (!hit) { if (firstError) throw firstError; throw new RuntimeError("not_found", "查找目标未出现在被搜索图像中", { nodeId: node.id, componentId: node.componentId, recommendation: "「查找目标」应是被搜索图里出现的小图案(按钮/图标/文字块);两张图互不包含时无法匹配" }); } return hit; }); register("logic_control_if_ai_vision", async (ctx, node, args) => { const screenshot = path.join(ctx.evidenceDir, `ai-vision-${Date.now()}.png`); await captureScreen(screenshot); return { image: screenshot, prompt: text(args, ["input_data", "prompt", "condition"], "描述屏幕内容并判断条件是否成立"), matched: true }; }); // CV_GetText / CV_SetText / CV_MouseHover / CV_MouseClick 统一由 cv-vision.ts 注册: // 此处曾重复注册并覆盖 cv-vision 的实现,其中内联 here-string(@'...'@ 单行)是 // 非法 PowerShell 语法,导致 CV_MouseClick/CV_MouseHover 必然解析失败(报 CLIXML 错误)。 // 如需调整 CV 行为请修改 cv-vision.ts,不要在此重复注册。 async function xunfeiRequest(args: Record, operation: string, payload: Record) { const endpoint = text(args, ["endpoint"]); if (!endpoint) throw new RuntimeError("validation_error", "需要配置讯飞 API endpoint"); const response = await fetch(endpoint, { method: "POST", headers: { "Content-Type": "application/json", "X-Appid": text(args, ["APPID", "appId"]), Authorization: "Bearer " + text(args, ["APIKey", "apiKey"]) }, body: JSON.stringify({ operation, ...payload }) }); if (!response.ok) throw new RuntimeError("external_service_failed", "讯飞 API " + response.status + ": " + await response.text()); return await response.json(); } register("NLP_XunFei_MachineTranslation", async (_ctx, _node, args) => xunfeiRequest(args, "translation", { text: text(args, ["text"]), from: text(args, ["BeforLanguage"], "auto"), to: text(args, ["AfterLanguage"], "en") })); register("NLP_XunFei_FeelingAnalysis", async (_ctx, _node, args) => xunfeiRequest(args, "sentiment", { text: text(args, ["text"]) })); register("NLP_XunFei_KeywordExtraction", async (_ctx, _node, args) => xunfeiRequest(args, "keyword", { text: text(args, ["text"]) })); async function ocrImage(args: Record, node: { id: string; componentId: string }) { const image = path.resolve(text(args, ["path", "image_url", "data", "pictureSource", "image_path", "image"])); if (!fs.existsSync(image)) throw new RuntimeError("validation_error", "OCR 图片不存在", { nodeId: node.id, componentId: node.componentId, detail: image }); const module = await import("@gutenye/ocr-node"); const ocr = await module.default.create(); const result = await ocr.detect(image); return (result.texts || []).map((line: any) => line.text).join("\n"); } const ocrIds = ["OCR_MergeCardOCR", "OCR_CalculationCode", "OCR_IdCardV2", "OCR_BusinessCardV2", "OCR_VehicleInvoiceV2", "OCR_MergeOtherOCR", "OCR_BusinessLicenseV3", "OCR_PassportV2", "OCR_MergeGeneralOCR", "OCR_KeyValueIdentification", "OCR_AmountIdentification", "OCR_GeneralTextV2", "OCR_VatInvoiceV2", "OCR_DrivingLicenseV2", "OCR_AccountPageV2", "OCR_PlateLicenseV2", "OCR_BankCardV2", "OCR_BankCardV3", "OCR_IdCardV3", "OCR_VatInvoiceV3", "OCR_MergePaperOCR", "OCR_DriverLicenseV2", "OCR_RealEstateCertV2", "OCR_TrainTicketV2", "OCR_TaxiInvoiceV2", "OCR_OfficialSealV2", "OCR_MergeCarOCR", "OCR_MergeEducationOCR", "OCR_HouseCertV2", "OCR_CommonEnglishNumbersCode", "OCR_BusinessLicenseV2", "OCR_VinV2"]; for (const id of ocrIds) register(id, async (ctx, node, args) => { const value = await ocrImage(args, node); ctx.log(id + " OCR 完成:" + value.length + " 字符", "info", node.id); return value; });