feat: complete remaining capabilities and hide integration status

This commit is contained in:
yu committed 2026-11-02 10:43:11 +08:00
1 parent 66169590de
commit 95fb07e876
20 files changed
+15102 -201

No files matched your search

+231
View File
@@ -0,0 +1,231 @@
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";
import { locateTextOnScreen } from "./cv-vision";
function text(args: Record<string, unknown>, 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<string, unknown>, 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<string, unknown> { return value && typeof value === "object" ? value as Record<string, unknown> : (() => { 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<string, any>();
function dbConfig(args: Record<string, unknown>): Record<string, unknown> {
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<string, unknown>) {
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<string, unknown>, 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<string, unknown>) {
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, unknown>): 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, unknown>): 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<string, unknown>, 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<string,string> = {};
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<string, unknown>) {
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<string, unknown>) { 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" } }] }) });
});
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()`);
}
async function findOnScreen(args: Record<string, unknown>, node: { id: string; componentId: string }, timeout = 0) {
const template = path.resolve(text(args, ["image", "image_path", "template"]));
if (!fs.existsSync(template)) throw new RuntimeError("validation_error", "模板图片不存在", { nodeId: node.id, componentId: node.componentId, detail: template });
const screenshot = path.join(process.env.TEMP || process.cwd(), `image-find-${Date.now()}.png`); const deadline = Date.now() + timeout * 1000;
do { await captureScreen(screenshot); const hit = await findImage(template, screenshot, num(args, ["accuracy", "accuracy2"], 0.2));
if (hit) { try { fs.unlinkSync(screenshot); } catch {} return hit; } if (timeout > 0) await new Promise((resolve) => setTimeout(resolve, 300)); } while (Date.now() < deadline);
try { fs.unlinkSync(screenshot); } catch {} throw new RuntimeError("not_found", "未找到目标图像", { nodeId: node.id, componentId: node.componentId });
}
for (const id of ["Image_Exists", "Image_Exists_V1"]) register(id, async (_ctx, node, args) => { try { return !!(await findOnScreen(args, node, num(args, ["timeout", "time_out"], 0))); } catch { return false; } });
for (const id of ["Image_Location", "Image_Location_V1"]) register(id, async (_ctx, node, args) => findOnScreen(args, node, num(args, ["timeout", "time_out"], 0)));
for (const id of ["Image_Wait", "Image_Wait_V1"]) register(id, async (_ctx, node, args) => findOnScreen(args, node, num(args, ["timeout", "time_out"], 10)));
for (const id of ["Image_Move", "Image_Move_V1", "Image_Move_V2"]) register(id, async (_ctx, node, args) => {
const hit = await findOnScreen(args, node); const x = Math.round(hit.x + hit.width / 2); const y = Math.round(hit.y + hit.height / 2);
await powershell(`Add-Type @'using System;using System.Runtime.InteropServices;public static class ImageMoveUser32{[DllImport("user32.dll")]public static extern bool SetCursorPos(int x,int y)}'@;[ImageMoveUser32]::SetCursorPos(${x},${y})`);
return { ...hit, x, y };
});
register("Image_Base64Encode", async (_ctx, _node, args) => fs.readFileSync(path.resolve(text(args, ["path", "image"]))).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 input = path.resolve(text(args, ["file_path", "imagePath", "path"])); 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;
fs.mkdirSync(path.dirname(output), { recursive: true });
await powershell(`Add-Type -AssemblyName System.Drawing;$image=[System.Drawing.Image]::FromFile(${psString(input)});$rect=New-Object System.Drawing.Rectangle(${x},${y},${width},${height});$bitmap=New-Object System.Drawing.Bitmap($rect.Width,$rect.Height);$graphics=[System.Drawing.Graphics]::FromImage($bitmap);$graphics.DrawImage($image,New-Object System.Drawing.Rectangle(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"]));
const hit = await findImage(template, source, num(args, ["accuracy"], 0.2)); if (!hit) throw new RuntimeError("not_found", "模板未出现在源图中"); 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 };
});
register("CV_GetText", async (_ctx, node, args) => { const target = required(text(args, ["cvSelector", "target", "text"]), "CV目标", node); return (await locateTextOnScreen(target, node.id)).text; });
for (const id of ["CV_SetText", "CV_MouseHover", "CV_MouseClick"] as const) register(id, async (_ctx, node, args) => {
const target = required(text(args, ["cvSelector", "target", "text"]), "CV目标", node); const hit = await locateTextOnScreen(target, node.id);
const x = Math.round(hit.cx + num(args, ["offsetX"], 0)); const y = Math.round(hit.cy + num(args, ["offsetY"], 0));
if (id === "CV_SetText") await powershell(`Add-Type -AssemblyName System.Windows.Forms;[System.Windows.Forms.SendKeys]::SendWait("^a");[System.Windows.Forms.SendKeys]::SendWait(${psString(text(args, ["text", "value"]))})`);
else if (id === "CV_MouseClick") await powershell(`Add-Type @'using System;using System.Runtime.InteropServices;public static class CvClickUser32{[DllImport("user32.dll")]public static extern bool SetCursorPos(int x,int y);[DllImport("user32.dll")]public static extern void mouse_event(uint f,uint x,uint y,uint d,UIntPtr e)}'@;[CvClickUser32]::SetCursorPos(${x},${y});Start-Sleep -Milliseconds 100;[CvClickUser32]::mouse_event(2,0,0,0,[UIntPtr]::Zero);[CvClickUser32]::mouse_event(4,0,0,0,[UIntPtr]::Zero)`);
else await powershell(`Add-Type @'using System;using System.Runtime.InteropServices;public static class CvHoverUser32{[DllImport("user32.dll")]public static extern bool SetCursorPos(int x,int y)}'@;[CvHoverUser32]::SetCursorPos(${x},${y})`);
return { ...hit, x, y };
});
async function xunfeiRequest(args: Record<string, unknown>, operation: string, payload: Record<string, unknown>) {
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<string, unknown>, 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;
});