yichael 2 місяців тому
батько
коміт
aecfabd816

+ 32 - 39
doc/ef-compiler新增结点说明.md

@@ -31,61 +31,54 @@ module.exports = { types, parse, execute }
 
 ## 二、fun 下添加结点
 
-以添加 `test-fun` 为例,共 5 步(都在 `fun-parser.js` 里改,除了第 1 步)。
 
-**1. 实现**  
-在 `nodejs/ef-compiler/actions/fun/` 下新建 `test-fun.js`:
+---
+
+### 1. 新建脚本
+
+在 `actions/fun/` 下新建 `xxx.js`:
 
 ```js
-async function executeTestFun() {
-  return { success: true, value: 'ok' }
+async function executeTestFun({ num, str, folderPath }) {  // 函数名与注册表 execute 一致;入参 key 与注册表 in 对应,folderPath 由框架注入
+  const n = Number(num ?? 0)   // 第 1 个入参,转成 number
+  const s = String(str ?? '')  // 第 2 个入参,转成 string
+  return { success: true, value: String(n) + s }  // 成功:返回 value,框架会写入出参变量并打日志;失败可返回 { success: false, error: '...' }
 }
-module.exports = { executeTestFun }
+module.exports = { executeTestFun }  // 导出与注册表 execute 同名的函数
 ```
 
-**2. 登记**  
-脚本:`nodejs/ef-compiler/actions/fun-parser.js`  
-约第 6~14 行,在 `FUN_REGISTRY_TYPES` 数组里加 `'test-fun'`(可加在 `'string-press',` 后)。
-
-**3. 解析**  
-脚本:`nodejs/ef-compiler/actions/fun-parser.js`  
-在 `parse()` 里找到 `switch (action.type)`(约第 34 行),在**最后一个 `case`(如 `case 'img-cropping':`)的 `break` 之后、`default:` 之前**插入新分支,用于解析该 fun 结点的输出变量到 `parsed.variable`(工作流里通过 `variable` 或 `outVars[0]` 指定)。  
+---
 
-插入位置示例:第 113 行 `break` 与第 115 行 `default:` 之间。  
+### 2. 注册表一条
 
-插入内容
+在 `nodejs/ef-compiler/actions/fun-node-registry.js` 里添加:
 
 ```js
-case 'test-fun':
-  parsed.variable = action.outVars?.[0] ? extractVarName(action.outVars[0]) : extractVarName(action.variable)
-  break
+{
+  type: 'test-fun',        // 工作流里的 type
+  category: 'io',          // 与 get() 分类一致:io / img / chat
+  in: ['num', 'str'],      // 入参名,与上面 executeTestFun 的 input 的 key 一致;对应 inVars[0]、inVars[1] 或 action.num、action.str
+  out: 'variable',         // 出参变量名来自 action.variable 或 action.outVars[0]
+  execute: 'executeTestFun',
+  script: 'test-fun.js',
+  displayName: 'test-fun', // 可选
+}
 ```
 
-若结点需要输入参数(如模板、路径),可仿照 `img-cropping` 等 case,给 `parsed` 增加 `parsed.inVars`、`parsed.template` 等字段。
+工作流示例:`{ "type": "test-fun", "inVars": ["myNum", "myStr"], "variable": "result" }`
 
-**4. 挂载**  
-脚本:`fun-parser.js`  
-约第 183~189 行,在 `get()` 的 `case 'io':` 的 mod 对象里加一行(例如在 `executeSaveTxt` 那行后面):  
-`executeTestFun: require(path.join(funcDir, 'test-fun.js')).executeTestFun`。(funcDir 已指向 `actions/fun`)
+---
+
+### 可选:字段别名
 
-**5. 执行**  
-脚本:`fun-parser.js`  
-约第 229 行起,在 `run()` 的 `switch (actionType)` 里加:
+在注册表加 `inAlt`:
 
 ```js
-case 'test-fun': {
-  const { executeTestFun } = get(funcDir, 'io')
-  const result = await executeTestFun()
-  if (!result.success) return result
-  const varName = action.outVars?.[0] ? extractVarName(action.outVars[0]) : extractVarName(action.variable)
-  if (varName && result.value != null) variableContext[varName] = String(result.value)
-  await logOutVars(action, variableContext, folderPath)
-  return { success: true }
-}
+inAlt: { savePath: 'save-path' }
 ```
 
-**显示名(可选)**  
-脚本:`nodejs/ef-compiler/workflow-json-parser.js`  
-约第 48~84 行,在 `getActionName` 的 `typeNames` 对象里加 `'test-fun': '测试'`。
+---
+
+### 可选:完全自定义
 
-工作流里写:`{ "type": "test-fun", "variable": "out" }` 即可
+注册表配 `customParse`、`customRun`;脚本导出 `parseNode`、`runNode`。

+ 64 - 0
nodejs/adb/send-img-to-device.js

@@ -0,0 +1,64 @@
+#!/usr/bin/env node
+/**
+ * 通过 ADB 将本地图片推送到手机并加入相册(1+ 等 Android 通用)
+ * 用法: node send-img-to-device.js <本地图片路径> [deviceId]
+ * 或 require 后调用: sendImageToDevice(localPath, deviceId) => { success, error }
+ */
+
+const { spawnSync } = require('child_process')
+const path = require('path')
+const fs = require('fs')
+
+const configPath = process.env.STATIC_ROOT
+  ? path.join(path.dirname(process.env.STATIC_ROOT), 'configs', 'config.js')
+  : path.join(__dirname, '..', '..', 'configs', 'config.js')
+const projectRoot = path.dirname(path.dirname(path.resolve(configPath)))
+const config = fs.existsSync(configPath) ? require(configPath) : {}
+const adbPath = config.adbPath?.path
+  ? (path.isAbsolute(config.adbPath.path) ? config.adbPath.path : path.resolve(projectRoot, config.adbPath.path))
+  : path.join(projectRoot, 'lib', 'scrcpy-adb', process.platform === 'win32' ? 'adb.exe' : 'adb')
+
+/** 设备 DCIM 目录,相册会扫描此处 */
+const DEVICE_DCIM = '/sdcard/DCIM/'
+
+/**
+ * 将本地图片推送到设备相册
+ * @param {string} localPath - 本地图片绝对或相对路径
+ * @param {string} [deviceId] - 设备 ID,如 192.168.42.129 或 192.168.42.129:5555
+ * @returns {{ success: boolean, error?: string, devicePath?: string }}
+ */
+function sendImageToDevice(localPath, deviceId = '') {
+  const resolved = path.resolve(localPath)
+  if (!fs.existsSync(resolved)) {
+    return { success: false, error: `本地文件不存在: ${resolved}` }
+  }
+  const basename = path.basename(resolved)
+  const deviceFile = DEVICE_DCIM + basename
+  const args = deviceId ? ['-s', deviceId, 'push', resolved, deviceFile] : ['push', resolved, deviceFile]
+  const push = spawnSync(adbPath, args, { encoding: 'utf-8', timeout: 30000 })
+  if (push.status !== 0) {
+    const err = (push.stderr || push.stdout || '').trim() || `adb push 退出码 ${push.status}`
+    return { success: false, error: err }
+  }
+  const scanArgs = deviceId ? ['-s', deviceId, 'shell', 'am', 'broadcast', '-a', 'android.intent.action.MEDIA_SCANNER_SCAN_FILE', '-d', `file://${deviceFile}`] : ['shell', 'am', 'broadcast', '-a', 'android.intent.action.MEDIA_SCANNER_SCAN_FILE', '-d', `file://${deviceFile}`]
+  spawnSync(adbPath, scanArgs, { encoding: 'utf-8', timeout: 5000 })
+  return { success: true, devicePath: deviceFile }
+}
+
+if (require.main === module) {
+  const localPath = process.argv[2]
+  const deviceId = process.argv[3] || ''
+  if (!localPath) {
+    console.error('用法: node send-img-to-device.js <本地图片路径> [deviceId]')
+    process.exit(1)
+  }
+  const result = sendImageToDevice(localPath, deviceId)
+  if (result.success) {
+    console.log(result.devicePath || 'ok')
+  } else {
+    console.error(result.error)
+    process.exit(1)
+  }
+}
+
+module.exports = { sendImageToDevice }

+ 106 - 0
nodejs/ai/ai.js

@@ -0,0 +1,106 @@
+const config = require('./config');
+
+const REQUEST_TIMEOUT_MS = 120000;
+const REQUEST_TIMEOUT_IMG_MS = 180000;
+
+module.exports.REQUEST_TIMEOUT_MS = REQUEST_TIMEOUT_MS;
+module.exports.REQUEST_TIMEOUT_IMG_MS = REQUEST_TIMEOUT_IMG_MS;
+
+function request(path, body, timeoutMs) {
+  const baseUrl = (config.BASE_URL || '').replace(/\/$/, '');
+  const url = path.startsWith('http') ? path : `${baseUrl}/${path.replace(/^\//, '')}`;
+  const controller = new AbortController();
+  const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
+  return fetch(url, {
+    method: 'POST',
+    headers: {
+      'Content-Type': 'application/json',
+      'Authorization': `Bearer ${config.API_KEY || ''}`
+    },
+    body: JSON.stringify(body),
+    signal: controller.signal
+  })
+    .then((res) => {
+      clearTimeout(timeoutId);
+      return res.json().catch(() => ({})).then((data) => {
+        if (!res.ok) throw new Error(data.error?.message || data.error || `HTTP ${res.status}`);
+        return data;
+      });
+    })
+    .catch((e) => {
+      clearTimeout(timeoutId);
+      if (e.name === 'AbortError') throw new Error(`请求超时 (${timeoutMs / 1000} 秒)`);
+      throw e;
+    });
+}
+
+function doubaoRequest(path, body, timeoutMs) {
+  const baseUrl = (config.DOUBAO_BASE_URL || '').replace(/\/$/, '');
+  const url = path.startsWith('http') ? path : `${baseUrl}/${path.replace(/^\//, '')}`;
+  const controller = new AbortController();
+  const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
+  return fetch(url, {
+    method: 'POST',
+    headers: {
+      'Content-Type': 'application/json',
+      'Authorization': `Bearer ${config.DOUBAO_API_KEY || ''}`
+    },
+    body: JSON.stringify(body),
+    signal: controller.signal
+  })
+    .then((res) => {
+      clearTimeout(timeoutId);
+      return res.json().catch(() => ({})).then((data) => {
+        if (!res.ok) throw new Error(data.error?.message || data.error || `HTTP ${res.status}`);
+        return data;
+      });
+    })
+    .catch((e) => {
+      clearTimeout(timeoutId);
+      if (e.name === 'AbortError') throw new Error(`请求超时 (${timeoutMs / 1000} 秒)`);
+      throw e;
+    });
+}
+
+const text2text = require('./request/text2text');
+const img2text = require('./request/img2text');
+const text2img = require('./request/text2img');
+const img2img = require('./request/img2img');
+
+const REQ = { text2text, img2text, text2img, img2img };
+
+async function run(action, ...args) {
+  if (action.startsWith('doubao_')) {
+    return await doRequest(action.substring(7), true, args);
+  }
+  return await doRequest(action, false, args);
+}
+
+/** 从 request 模块取参数,在 ai.js 内发请求 */
+async function doRequest(action, isDoubao, args) {
+  const req = REQ[action];
+  if (!req) return { success: false, error: 'Unknown action: ' + action };
+  if (isDoubao && (!config.DOUBAO_MODEL || !config.DOUBAO_MODEL.trim())) {
+    return { success: false, error: '豆包未配置:请在 nodejs/ai/config.js 中设置 DOUBAO_MODEL 为你在火山引擎控制台创建的模型接入点 ID(endpoint ID),或设置环境变量 DOUBAO_MODEL' };
+  }
+
+  const path = req.path;
+  const timeoutMs = req.timeoutMs;
+  const body = action === 'text2text'
+    ? (isDoubao ? req.getDoubaoBody(args[0]) : req.getBody(args[0]))
+    : (isDoubao ? req.getDoubaoBody(args[0], args[1]) : req.getBody(args[0], args[1]));
+
+  try {
+    const data = isDoubao
+      ? await doubaoRequest(path, body, timeoutMs)
+      : await request(path, body, timeoutMs);
+    return { success: true, data };
+  } catch (e) {
+    return { success: false, error: e && (e.message || String(e)) };
+  }
+}
+
+module.exports.run = run;
+module.exports.request = request;
+module.exports.REQUEST_TIMEOUT_MS = REQUEST_TIMEOUT_MS;
+module.exports.REQUEST_TIMEOUT_IMG_MS = REQUEST_TIMEOUT_IMG_MS;

+ 23 - 0
nodejs/ai/config.js

@@ -0,0 +1,23 @@
+// ---------- 一般配置(OpenAI 兼容) ----------
+const API_KEY = process.env.API_KEY || 'sk-j32LgDixK6pfESYGfJtgc2Tzlmszx5NZhSH0sOzpLQkYuKek';
+const BASE_URL = process.env.BASE_URL || 'https://api.chatanywhere.tech/v1';
+const MODEL_NAME = process.env.MODEL_NAME || 'gpt-4.1';
+
+// ---------- 豆包配置(火山引擎) ----------
+// 需在 火山引擎控制台 → 模型推理 → 模型接入 创建接入点,将 endpoint ID 填到 DOUBAO_MODEL(或设置环境变量 DOUBAO_MODEL)
+const DOUBAO_BASE_URL = process.env.DOUBAO_BASE_URL || 'https://ark.cn-beijing.volces.com/api/v3';
+const DOUBAO_API_KEY = process.env.DOUBAO_API_KEY || 'f13f97be-c990-4a43-8d17-4816357f2e47'; // id: api-key-yichael
+// 模型名称(官方示例):doubao-seed-2-0-pro-260215 对应控制台「Doubao-Seed-2.0-pro」已开通
+const DOUBAO_MODEL = process.env.DOUBAO_MODEL || 'doubao-seed-2-0-pro-260215';
+// 豆包文生图模型:需在火山引擎控制台开通「图像生成」类模型并创建接入点,将 endpoint ID 填于此(或环境变量 DOUBAO_IMAGE_MODEL)
+const DOUBAO_IMAGE_MODEL = process.env.DOUBAO_IMAGE_MODEL || '';
+
+module.exports = {
+  API_KEY,
+  BASE_URL,
+  MODEL_NAME,
+  DOUBAO_BASE_URL,
+  DOUBAO_API_KEY,
+  DOUBAO_MODEL,
+  DOUBAO_IMAGE_MODEL
+};

+ 26 - 0
nodejs/ai/request/img2img.js

@@ -0,0 +1,26 @@
+const config = require('../config');
+
+const PATH = 'images/edits';
+const TIMEOUT_MS = 180000;
+
+// 普通 AI 请求参数
+function getBody(prompt, imageUrl) {
+  return {
+    prompt,
+    image: imageUrl,
+    n: 1,
+    size: '1024x1024'
+  };
+}
+
+// 豆包请求参数(与普通共用结构,model 若豆包需要可再扩展)
+function getDoubaoBody(prompt, imageUrl) {
+  return {
+    prompt,
+    image: imageUrl,
+    n: 1,
+    size: '1024x1024'
+  };
+}
+
+module.exports = { path: PATH, getBody, getDoubaoBody, timeoutMs: TIMEOUT_MS };

+ 38 - 0
nodejs/ai/request/img2text.js

@@ -0,0 +1,38 @@
+const config = require('../config');
+
+const PATH = 'chat/completions';
+const TIMEOUT_MS = 120000;
+
+// 普通 AI 请求参数
+function getBody(prompt, imageUrl) {
+  return {
+    model: config.MODEL_NAME || 'gpt-4o',
+    messages: [{
+      role: 'user',
+      content: [
+        { type: 'text', text: prompt },
+        { type: 'image_url', image_url: { url: imageUrl, detail: 'high' } }
+      ]
+    }],
+    max_tokens: 300,
+    stream: false
+  };
+}
+
+// 豆包请求参数
+function getDoubaoBody(prompt, imageUrl) {
+  return {
+    model: config.DOUBAO_MODEL,
+    messages: [{
+      role: 'user',
+      content: [
+        { type: 'text', text: prompt },
+        { type: 'image_url', image_url: { url: imageUrl, detail: 'high' } }
+      ]
+    }],
+    max_tokens: 300,
+    stream: false
+  };
+}
+
+module.exports = { path: PATH, getBody, getDoubaoBody, timeoutMs: TIMEOUT_MS };

+ 29 - 0
nodejs/ai/request/text2img.js

@@ -0,0 +1,29 @@
+const config = require('../config');
+
+const PATH = 'images/generations';
+const TIMEOUT_MS = 180000;
+
+// 普通 AI 请求参数
+function getBody(prompt, outputPath) {
+  return {
+    model: 'dall-e-2',
+    prompt,
+    n: 1,
+    size: '1024x1024',
+    response_format: outputPath ? 'b64_json' : 'url'
+  };
+}
+
+// 豆包文生图请求参数(优先用 DOUBAO_IMAGE_MODEL,未配置则用 DOUBAO_MODEL)
+function getDoubaoBody(prompt, outputPath) {
+  const model = (config.DOUBAO_IMAGE_MODEL && config.DOUBAO_IMAGE_MODEL.trim()) ? config.DOUBAO_IMAGE_MODEL.trim() : config.DOUBAO_MODEL;
+  return {
+    model,
+    prompt,
+    n: 1,
+    size: '1024x1024',
+    response_format: outputPath ? 'b64_json' : 'url'
+  };
+}
+
+module.exports = { path: PATH, getBody, getDoubaoBody, timeoutMs: TIMEOUT_MS };

+ 24 - 0
nodejs/ai/request/text2text.js

@@ -0,0 +1,24 @@
+const config = require('../config');
+
+const PATH = 'chat/completions';
+const TIMEOUT_MS = 120000;
+
+// 普通 AI 请求参数
+function getBody(prompt) {
+  return {
+    model: config.MODEL_NAME || 'gpt-4.1',
+    messages: [{ role: 'user', content: prompt }],
+    stream: false
+  };
+}
+
+// 豆包请求参数
+function getDoubaoBody(prompt) {
+  return {
+    model: config.DOUBAO_MODEL,
+    messages: [{ role: 'user', content: prompt }],
+    stream: false
+  };
+}
+
+module.exports = { path: PATH, getBody, getDoubaoBody, timeoutMs: TIMEOUT_MS };

+ 2 - 10
nodejs/ef-compiler/actions/echo-parser.js

@@ -12,18 +12,10 @@ function parse(action, parseContext) {
 }
 
 async function execute(action, ctx) {
-  const { folderPath, variableContext, extractVarName, replaceVariablesInString, logMessage } = ctx
+  const { folderPath, variableContext, replaceVariablesInString, logMessage } = ctx
   let message = ''
   if (action.inVars && action.inVars.length > 0) {
-    const messages = action.inVars.map(varWithBraces => {
-      const varName = extractVarName(varWithBraces)
-      const varValue = variableContext[varName]
-      if (varWithBraces.startsWith('{') && varWithBraces.endsWith('}')) {
-        return varValue !== undefined ? String(varValue) : varWithBraces
-      }
-      return varWithBraces
-    })
-    message = messages.join(' ')
+    message = action.inVars.map((v) => (v != null ? String(v) : '')).join(' ')
   } else if (action.value) {
     message = replaceVariablesInString(action.value, variableContext)
     const doubleBracePattern = /\{\{([\w-]+)\}\}/g

+ 20 - 0
nodejs/ef-compiler/actions/fun-node-registry.js

@@ -0,0 +1,20 @@
+/**
+ * fun 结点注册表:新增结点只需 1)在 actions/fun/ 新建脚本(只实现 executeXxx(input),入参由 in 规定、出参由 out 规定)2)在此添加一条配置。
+ * 通用逻辑(解析 inVars、取值、写回 variableContext、logOutVars)由 fun-parser 统一处理,脚本不写 parseNode/runNode。
+ * 配置项:type, category, in(入参名数组), inAlt?(字段别名), execute(脚本导出的函数名), script?(默认 type+'.js'), displayName?。出参由工作流 action.outVars[0] 指定。
+ * 仅当需要完全自定义解析或执行时,才配 customParse/customRun 并在脚本中导出 parseNode、runNode。
+ */
+module.exports = [
+  {
+    type: 'download',
+    category: 'io',
+    in: ['url', 'savePath'],
+    inAlt: { savePath: 'save-path' },
+    execute: 'executeDownload',
+    script: 'download.js',
+  },
+  { type: 'text2text', category: 'io', in: ['prompt', 'model'], execute: 'executeText2text', script: 'ai/text2text.js', displayName: 'ai text2text' },
+  { type: 'img2text', category: 'io', in: ['prompt', 'model', 'imageUrl'], execute: 'executeImg2text', script: 'ai/img2text.js', displayName: 'ai img2text' },
+  { type: 'text2img', category: 'io', in: ['prompt', 'model', 'savePath'], execute: 'executeText2img', script: 'ai/text2img.js', displayName: 'ai text2img' },
+  { type: 'img2img', category: 'io', in: ['prompt', 'model', 'imageUrl', 'savePath'], execute: 'executeImg2img', script: 'ai/img2img.js', displayName: 'ai img2img' },
+]

+ 140 - 68
nodejs/ef-compiler/actions/fun-parser.js

@@ -1,20 +1,40 @@
 /**
  * fun 解析与执行 + 执行入口:registry/executeAction 由 ctx 传入(来自 workflow-json-parser),本模块负责 parse/runAction/run/supports
+ * 简易结点由 fun-node-registry.js 配置,只需新建脚本 + 在注册表加一条即可。
  */
 const path = require('path')
 const variableParser = require('../variable-parser.js')
+const FUN_NODE_REGISTRY = require('./fun-node-registry.js')
 
-const FUN_REGISTRY_TYPES = [
-  'fun',
+const LEGACY_FUN_TYPES = [
+  'fun', 'ai',
   'read-txt', 'read-text', 'save-txt', 'save-text',
   'img-bounding-box-location', 'img-center-point-location', 'img-cropping',
   'read-last-message', 'smart-chat-append',
   'extract-messages', 'ocr-chat', 'ocr-chat-history', 'extract-chat-history',
   'save-messages', 'generate-summary', 'generate-history-summary',
-  'ai-generate', 'string-press','download',
+  'ai-generate', 'string-press',
 ]
-
+const REGISTERED_TYPES = (FUN_NODE_REGISTRY && Array.isArray(FUN_NODE_REGISTRY)) ? FUN_NODE_REGISTRY.map((r) => r.type) : []
+const FUN_REGISTRY_TYPES = LEGACY_FUN_TYPES.concat(REGISTERED_TYPES)
 const types = FUN_REGISTRY_TYPES
+const REGISTRY_BY_TYPE = new Map((FUN_NODE_REGISTRY || []).map((r) => [r.type, r]))
+const scriptCache = new Map()
+
+function getRegistryScript(funcDir, type) {
+  const def = REGISTRY_BY_TYPE.get(type)
+  if (!def) return null
+  const key = `${funcDir}:${type}`
+  if (!scriptCache.has(key)) {
+    try {
+      const scriptPath = path.join(funcDir, def.script || def.type + '.js')
+      scriptCache.set(key, require(scriptPath))
+    } catch (e) {
+      scriptCache.set(key, null)
+    }
+  }
+  return scriptCache.get(key)
+}
 
 function parse(action, parseContext) {
   const { extractVarName, resolveValue } = parseContext
@@ -32,6 +52,24 @@ function parse(action, parseContext) {
   }
   Object.assign(parsed, action)
 
+  const regDef = REGISTRY_BY_TYPE.get(action.type)
+  if (regDef) {
+    const funcDir = (parseContext.compilerConfig && parseContext.compilerConfig.funcDir) || path.join(__dirname, 'fun')
+    if (regDef.customParse) {
+      const script = getRegistryScript(funcDir, action.type)
+      if (script && typeof script.parseNode === 'function') return script.parseNode(action, parseContext)
+    }
+    parsed.inVars = action.inVars && Array.isArray(action.inVars) ? action.inVars.map((v) => extractVarName(v)) : []
+    const inKeys = regDef.in || []
+    const inAlt = regDef.inAlt || {}
+    inKeys.forEach((key, i) => {
+      const altKey = inAlt[key]
+      parsed[key] = action.inVars?.[i] ?? action[key] ?? (altKey ? action[altKey] : undefined)
+    })
+    parsed.variable = action.outVars?.[0] ? extractVarName(action.outVars[0]) : undefined
+    return parsed
+  }
+
   switch (action.type) {
     case 'fun':
       parsed.method = action.method
@@ -113,9 +151,6 @@ function parse(action, parseContext) {
       parsed.savePath = action.inVars?.[1] ?? action.savePath
       if (action.outVars && action.outVars.length > 0) parsed.variable = extractVarName(action.outVars[0])
       break
-    case 'download':
-      parsed.variable = action.outVars?.[0] ? extractVarName(action.outVars[0]) : extractVarName(action.variable)
-      break
     default:
       parsed.inVars = action.inVars && Array.isArray(action.inVars) ? action.inVars.map(v => extractVarName(v)) : []
       parsed.outVars = action.outVars && Array.isArray(action.outVars) ? action.outVars.map(v => extractVarName(v)) : []
@@ -139,6 +174,9 @@ async function runAction(action, device, folderPath, resolution, ctx) {
   if (resolvedAction.type === 'fun' && resolvedAction.method) {
     return run(resolvedAction.method, resolvedAction, ctx, device, folderPath)
   }
+  if (resolvedAction.type === 'ai' && resolvedAction.method) {
+    return run(resolvedAction.method, resolvedAction, ctx, device, folderPath)
+  }
 
   if (supports(resolvedAction.type)) {
     return run(resolvedAction.type, resolvedAction, ctx, device, folderPath)
@@ -192,6 +230,13 @@ function get(funcDir, category) {
         executeSmartChatAppend: require(path.join(funcDir, 'chat', 'smart-chat-append.js')).executeSmartChatAppend,
         executeSaveTxt: require(path.join(funcDir, 'save-txt.js')).executeSaveTxt,
       }
+      ;(FUN_NODE_REGISTRY || []).filter((r) => r.category === 'io').forEach((def) => {
+        const scriptPath = path.join(funcDir, def.script || def.type + '.js')
+        try {
+          const m = require(scriptPath)
+          if (m[def.execute]) mod[def.execute] = m[def.execute]
+        } catch (e) { /* skip missing script */ }
+      })
       break
     case 'chat':
       mod = (() => {
@@ -227,7 +272,7 @@ function parseRegion(regionArea) {
 }
 
 async function run(actionType, action, ctx, device, folderPath) {
-  const { variableContext, extractVarName, resolveValue, replaceVariablesInString, logOutVars } = ctx
+  const { variableContext, extractVarName, resolveValue, replaceVariablesInString, logOutVars, logMessage } = ctx
   const funcDir = ctx.compilerConfig && ctx.compilerConfig.funcDir
   if (!funcDir) return { success: false, error: 'compilerConfig.funcDir 未提供' }
 
@@ -238,13 +283,13 @@ async function run(actionType, action, ctx, device, folderPath) {
       let regionPath = action.region
       if (action.inVars && Array.isArray(action.inVars)) {
         if (action.inVars.length === 1) {
-          const firstValue = variableContext[extractVarName(action.inVars[0])] ?? resolveValue(action.inVars[0], variableContext)
+          const firstValue = action.inVars[0]
           regionPath = firstValue != null && typeof firstValue === 'string' && !String(firstValue).includes('{') ? firstValue : action.inVars[0]
           screenshotPath = null
         } else if (action.inVars.length >= 2) {
-          const sv = variableContext[extractVarName(action.inVars[0])] ?? resolveValue(action.inVars[0], variableContext)
+          const sv = action.inVars[0]
           screenshotPath = sv != null && typeof sv === 'string' && !sv.includes('{') ? sv : action.inVars[0]
-          const rv = variableContext[extractVarName(action.inVars[1])] ?? resolveValue(action.inVars[1], variableContext)
+          const rv = action.inVars[1]
           regionPath = rv != null && typeof rv === 'string' && !rv.includes('{') ? rv : action.inVars[1]
         }
       }
@@ -254,7 +299,7 @@ async function run(actionType, action, ctx, device, folderPath) {
       if (screenshotPath === null && !device) return { success: false, error: '缺少完整截图路径,且无法自动获取设备截图(缺少设备ID)' }
       const result = await executeImgBoundingBoxLocation({ device, screenshot: screenshotPath, region: regionPath, folderPath })
       if (!result.success) return { success: false, error: `图像区域定位失败: ${result.error}` }
-      const outputVarName = action.outVars?.length > 0 ? extractVarName(action.outVars[0]) : (action.variable ? extractVarName(action.variable) : null)
+      const outputVarName = action.outVars?.[0] != null ? String(action.outVars[0]).trim() : (action.variable ? extractVarName(action.variable) : null)
       if (outputVarName) {
         variableContext[outputVarName] = result.corners && typeof result.corners === 'object' ? JSON.stringify(result.corners) : ''
         await logOutVars(action, variableContext, folderPath)
@@ -266,7 +311,7 @@ async function run(actionType, action, ctx, device, folderPath) {
       const { executeImgCenterPointLocation } = get(funcDir, 'img')
       let templatePath = action.template
       if (action.inVars?.length > 0) {
-        const templateValue = variableContext[extractVarName(action.inVars[0])] ?? resolveValue(action.inVars[0], variableContext)
+        const templateValue = action.inVars[0]
         templatePath = templateValue != null && typeof templateValue === 'string' && !String(templateValue).includes('{') ? templateValue : action.inVars[0]
       }
       if (!templatePath) templatePath = action.template
@@ -274,7 +319,7 @@ async function run(actionType, action, ctx, device, folderPath) {
       if (!device) return { success: false, error: '缺少设备 ID,无法自动获取截图' }
       const result = await executeImgCenterPointLocation({ device, template: templatePath, folderPath })
       if (!result.success) return { success: false, error: `图像中心点定位失败: ${result.error}` }
-      const outputVarName = action.outVars?.length > 0 ? extractVarName(action.outVars[0]) : (action.variable ? extractVarName(action.variable) : null)
+      const outputVarName = action.outVars?.[0] != null ? String(action.outVars[0]).trim() : (action.variable ? extractVarName(action.variable) : null)
       if (outputVarName) {
         variableContext[outputVarName] = result.center && typeof result.center === 'object' && result.center.x !== undefined && result.center.y !== undefined
           ? JSON.stringify({ x: result.center.x, y: result.center.y }) : ''
@@ -288,15 +333,15 @@ async function run(actionType, action, ctx, device, folderPath) {
       let area = action.area
       let savePath = action.savePath
       if (action.inVars && Array.isArray(action.inVars)) {
-        if (action.inVars.length > 0) area = variableContext[extractVarName(action.inVars[0])] ?? resolveValue(action.inVars[0], variableContext)
-        if (action.inVars.length > 1) savePath = variableContext[extractVarName(action.inVars[1])] ?? resolveValue(action.inVars[1], variableContext)
+        if (action.inVars.length > 0) area = action.inVars[0]
+        if (action.inVars.length > 1) savePath = action.inVars[1]
       }
       if (!area) return { success: false, error: 'img-cropping 缺少 area 参数' }
       if (!savePath) return { success: false, error: 'img-cropping 缺少 savePath 参数' }
       const result = await executeImgCropping({ area, savePath, folderPath, device })
       if (!result.success) return { success: false, error: result.error }
-      if (action.outVars?.length > 0) {
-        const outputVarName = extractVarName(action.outVars[0])
+      if (action.outVars?.[0] != null) {
+        const outputVarName = String(action.outVars[0]).trim()
         if (outputVarName) variableContext[outputVarName] = result.success ? '1' : '0'
       }
       await logOutVars(action, variableContext, folderPath)
@@ -307,17 +352,10 @@ async function run(actionType, action, ctx, device, folderPath) {
       const { executeReadLastMessage } = get(funcDir, 'io')
       const inputVars = action.inVars || action.inputVars || []
       const outputVars = action.outVars || action.outputVars || []
-      let textVar = outputVars.length > 0 ? extractVarName(outputVars[0]) : action.textVariable
-      let senderVar = outputVars.length > 1 ? extractVarName(outputVars[1]) : action.senderVariable
-      const inputVar = inputVars.length > 0 ? extractVarName(inputVars[0]) : null
+      let textVar = outputVars.length > 0 ? String(outputVars[0]).trim() : action.textVariable
+      let senderVar = outputVars.length > 1 ? String(outputVars[1]).trim() : action.senderVariable
+      let inputDataString = inputVars.length > 0 ? (inputVars[0] != null ? (typeof inputVars[0] === 'string' ? inputVars[0] : (Array.isArray(inputVars[0]) || typeof inputVars[0] === 'object' ? JSON.stringify(inputVars[0]) : String(inputVars[0]))) : null) : null
       if (!textVar && !senderVar) return { success: false, error: 'read-last-message 缺少 textVariable 或 senderVariable 参数' }
-      let inputDataString = null
-      if (inputVar && variableContext[inputVar] !== undefined) {
-        const inputData = variableContext[inputVar]
-        if (typeof inputData === 'string') inputDataString = inputData
-        else if (Array.isArray(inputData) || typeof inputData === 'object') inputDataString = JSON.stringify(inputData)
-        else inputDataString = String(inputData)
-      }
       const result = await executeReadLastMessage({ folderPath, inputData: inputDataString, textVariable: textVar, senderVariable: senderVar })
       if (!result.success) return { success: false, error: result.error }
       if (textVar) variableContext[textVar] = result.text
@@ -331,9 +369,8 @@ async function run(actionType, action, ctx, device, folderPath) {
       const { executeReadTxt } = get(funcDir, 'io')
       let filePath = action.filePath
       let varName = action.variable
-      if (action.inVars?.length > 0) filePath = variableContext[extractVarName(action.inVars[0])] ?? resolveValue(action.inVars[0], variableContext)
-      if (action.outVars?.length > 0) varName = extractVarName(action.outVars[0])
-      else if (action.variable) varName = extractVarName(action.variable)
+      if (action.inVars?.length > 0) filePath = action.inVars[0]
+      if (action.outVars?.length > 0) varName = String(action.outVars[0]).trim()
       else if (action.variable) varName = extractVarName(action.variable)
       if (!filePath) return { success: false, error: 'read-txt 缺少 filePath 参数' }
       if (!varName) return { success: false, error: 'read-txt 缺少 variable 参数' }
@@ -351,8 +388,8 @@ async function run(actionType, action, ctx, device, folderPath) {
       let history = action.history
       let current = action.current
       if (action.inVars && Array.isArray(action.inVars)) {
-        if (action.inVars.length > 0) history = variableContext[extractVarName(action.inVars[0])] ?? resolveValue(action.inVars[0], variableContext)
-        if (action.inVars.length > 1) current = variableContext[extractVarName(action.inVars[1])] ?? resolveValue(action.inVars[1], variableContext)
+        if (action.inVars.length > 0) history = action.inVars[0]
+        if (action.inVars.length > 1) current = action.inVars[1]
       }
       if (history === undefined || history === null) history = ''
       if (current === undefined || current === null) current = ''
@@ -361,7 +398,7 @@ async function run(actionType, action, ctx, device, folderPath) {
         current: typeof current === 'string' ? current : String(current),
       })
       if (!result.success) return { success: false, error: result.error }
-      const outputVarName = action.outVars?.length > 0 ? extractVarName(action.outVars[0]) : (action.variable ? extractVarName(action.variable) : null)
+      const outputVarName = action.outVars?.[0] != null ? String(action.outVars[0]).trim() : (action.variable ? extractVarName(action.variable) : null)
       if (outputVarName && result.result) variableContext[outputVarName] = result.result
       return { success: true, result: result.result }
     }
@@ -372,15 +409,15 @@ async function run(actionType, action, ctx, device, folderPath) {
       let filePath = action.filePath
       let content = action.content
       if (action.inVars && Array.isArray(action.inVars)) {
-        if (action.inVars.length > 0) content = variableContext[extractVarName(action.inVars[0])] ?? resolveValue(action.inVars[0], variableContext)
-        if (action.inVars.length > 1) filePath = variableContext[extractVarName(action.inVars[1])] ?? resolveValue(action.inVars[1], variableContext)
+        if (action.inVars.length > 0) content = action.inVars[0]
+        if (action.inVars.length > 1) filePath = action.inVars[1]
       }
       if (!filePath) return { success: false, error: 'save-txt 缺少 filePath 参数' }
       if (content === undefined || content === null) return { success: false, error: 'save-txt 缺少 content 参数' }
       const result = await executeSaveTxt({ filePath, content, folderPath })
       if (!result.success) return { success: false, error: result.error }
-      if (action.outVars?.length > 0) {
-        const outputVarName = extractVarName(action.outVars[0])
+      if (action.outVars?.[0] != null) {
+        const outputVarName = String(action.outVars[0]).trim()
         if (outputVarName) variableContext[outputVarName] = result.success ? '1' : '0'
       }
       await logOutVars(action, variableContext, folderPath)
@@ -400,21 +437,21 @@ async function run(actionType, action, ctx, device, folderPath) {
 
       if (action.inVars && Array.isArray(action.inVars)) {
         if (action.inVars.length >= 3) {
-          const param1 = resolveValue(action.inVars[0], variableContext)
-          const param2 = resolveValue(action.inVars[1], variableContext)
+          const param1 = action.inVars[0]
+          const param2 = action.inVars[1]
           if (typeof param1 === 'string' && rgbPattern.test(param1.trim()) && typeof param2 === 'string' && rgbPattern.test(param2.trim())) {
             friendRgb = param1.trim()
             myRgb = param2.trim()
-            regionArea = variableContext[extractVarName(action.inVars[2])] ?? resolveValue(action.inVars[2], variableContext)
+            regionArea = action.inVars[2]
           } else {
             avatar1Name = action.inVars[0]
             avatar2Name = action.inVars[1]
-            regionArea = variableContext[extractVarName(action.inVars[2])] ?? resolveValue(action.inVars[2], variableContext)
+            regionArea = action.inVars[2]
           }
           regionArea = parseRegion(regionArea)
         } else if (action.inVars.length >= 2) {
-          const param1 = resolveValue(action.inVars[0], variableContext)
-          const param2 = resolveValue(action.inVars[1], variableContext)
+          const param1 = action.inVars[0]
+          const param2 = action.inVars[1]
           if (typeof param1 === 'string' && rgbPattern.test(param1.trim()) && typeof param2 === 'string' && rgbPattern.test(param2.trim())) {
             friendRgb = param1.trim()
             myRgb = param2.trim()
@@ -444,7 +481,7 @@ async function run(actionType, action, ctx, device, folderPath) {
       const chatResult = await executeOcrChat({ device, avatar1: avatar1Path, avatar2: avatar2Path, folderPath, region: regionParam, friendRgb, myRgb })
       if (!chatResult.success) return { success: false, error: `提取消息记录失败: ${chatResult.error}` }
 
-      const outputVarName = action.outVars?.length > 0 ? extractVarName(action.outVars[0]) : (action.variable ? extractVarName(action.variable) : null)
+      const outputVarName = action.outVars?.[0] != null ? String(action.outVars[0]).trim() : (action.variable ? extractVarName(action.variable) : null)
       if (outputVarName) {
         variableContext[outputVarName] = chatResult.messagesJson || JSON.stringify(chatResult.messages || [])
         await logOutVars(action, variableContext, folderPath)
@@ -474,22 +511,6 @@ async function run(actionType, action, ctx, device, folderPath) {
     case 'ai-generate': {
       const { getHistorySummary } = get(funcDir, 'chat')
       let prompt = resolveValue(action.prompt, variableContext)
-      if (action.inVars && Array.isArray(action.inVars)) {
-        for (let i = 0; i < action.inVars.length; i++) {
-          const varName = extractVarName(action.inVars[i])
-          const varValue = variableContext[varName]
-          if (varValue !== undefined && varValue !== null) {
-            let replaceValue = String(varValue)
-            if (typeof varValue === 'string' && varValue.trim() === '[]') {
-              try {
-                const parsed = JSON.parse(varValue)
-                if (Array.isArray(parsed) && parsed.length === 0) replaceValue = ''
-              } catch (e) {}
-            }
-            prompt = (prompt || '').replace(new RegExp(`\\{${varName.replace(/[{}]/g, '\\$&')}\\}`, 'g'), replaceValue)
-          }
-        }
-      }
       if (prompt && prompt.includes('{historySummary}') && getHistorySummary) {
         let historySummary = variableContext['historySummary'] || ''
         if (!historySummary) {
@@ -549,11 +570,11 @@ async function run(actionType, action, ctx, device, folderPath) {
 
         if (action.outVars?.length > 0) {
           if (action.outVars.length > 0) {
-            const outputVarName = extractVarName(action.outVars[0])
+            const outputVarName = action.outVars?.[0] != null ? String(action.outVars[0]).trim() : null
             if (outputVarName) variableContext[outputVarName] = result
           }
           if (action.outVars.length > 1) {
-            const callbackVarName = extractVarName(action.outVars[1])
+            const callbackVarName = action.outVars?.[1] != null ? String(action.outVars[1]).trim() : null
             if (callbackVarName) variableContext[callbackVarName] = 1
           }
           await logOutVars(action, variableContext, folderPath)
@@ -562,8 +583,8 @@ async function run(actionType, action, ctx, device, folderPath) {
           if (outputVarName) variableContext[outputVarName] = result
         }
         if (!action.outVars || action.outVars.length <= 1) {
-          if (action.inVars?.length > 1) {
-            const callbackVarName = extractVarName(action.inVars[1])
+          if (action.inVars?.length > 1 && action.inVars[1] != null) {
+            const callbackVarName = String(action.inVars[1]).trim()
             if (callbackVarName) variableContext[callbackVarName] = 1
           }
         }
@@ -576,7 +597,7 @@ async function run(actionType, action, ctx, device, folderPath) {
     case 'string-press': {
       const api = ctx.electronAPI
       const inVars = action.inVars || []
-      const targetText = inVars.length > 0 ? (variableContext[extractVarName(inVars[0])] ?? resolveValue(inVars[0], variableContext) ?? action.value) : (action.value ?? '')
+      const targetText = inVars.length > 0 ? (inVars[0] ?? action.value) : (action.value ?? '')
       if (!targetText) return { success: false, error: 'string-press 操作缺少文字内容' }
       if (!api?.findTextAndGetCoordinate) return { success: false, error: '文字识别 API 不可用' }
       const matchResult = await api.findTextAndGetCoordinate(device, targetText)
@@ -588,19 +609,70 @@ async function run(actionType, action, ctx, device, folderPath) {
       return { success: true }
     }
 
-    default:
+    default: {
+      const regDef = REGISTRY_BY_TYPE.get(actionType)
+      if (regDef) {
+        if (regDef.customRun) {
+          const script = getRegistryScript(funcDir, actionType)
+          if (script && typeof script.runNode === 'function') {
+            const runCtx = { ...ctx, get, funcDir, folderPath, device }
+            const result = await script.runNode(action, runCtx)
+            if (result && result.success !== false) await logOutVars(action, variableContext, folderPath)
+            return result != null ? result : { success: true }
+          }
+        }
+        const inKeys = regDef.in || []
+        const inAlt = regDef.inAlt || {}
+        const input = {}
+        inKeys.forEach((key, i) => {
+          let val = action[key]
+          if (action.inVars && action.inVars[i] !== undefined) val = action.inVars[i]
+          if (val === undefined && inAlt[key]) val = action[inAlt[key]]
+          input[key] = val != null ? String(val).trim() : val
+        })
+        input.folderPath = folderPath
+        const mod = get(funcDir, regDef.category)
+        const fn = mod[regDef.execute]
+        if (!fn) {
+          const errMsg = `fun-parser: ${regDef.execute} not found`
+          if (logMessage) await logMessage(`[${actionType}] failed: ${errMsg}`, folderPath).catch(() => {})
+          return { success: false, error: errMsg }
+        }
+        let result
+        try {
+          result = await fn(input)
+        } catch (e) {
+          const errMsg = (e && (e.message || String(e))) || 'execute threw'
+          if (logMessage) await logMessage(`[${actionType}] failed: ${errMsg}`, folderPath).catch(() => {})
+          return { success: false, error: errMsg }
+        }
+        if (!result || !result.success) {
+          const errMsg = (result && result.error) || 'execute failed'
+          if (logMessage) await logMessage(`[${actionType}] failed: ${errMsg}`, folderPath).catch(() => {})
+          return { success: false, error: errMsg }
+        }
+        const outputVarName = action.outVars?.[0] != null ? String(action.outVars[0]).trim() : null
+        if (outputVarName && result != null) {
+          const outVal = result.path ?? result.value ?? result.result
+          if (outVal !== undefined && outVal !== null) variableContext[outputVarName] = typeof outVal === 'string' ? outVal : String(outVal)
+        }
+        await logOutVars(action, variableContext, folderPath)
+        return { success: true, ...result }
+      }
       return { success: false, error: `fun-parser 不支持的 type: ${actionType}` }
+    }
   }
 }
 
 const FUN_TYPES = new Set([
+  'ai',
   'img-bounding-box-location', 'img-center-point-location', 'img-cropping',
   'read-last-message', 'read-txt', 'read-text', 'smart-chat-append', 'save-txt', 'save-text',
   'extract-messages', 'ocr-chat', 'ocr-chat-history', 'extract-chat-history',
   'save-messages', 'generate-summary', 'generate-history-summary',
   'ai-generate',
   'string-press',
-])
+].concat(REGISTERED_TYPES))
 
 function supports(type) {
   return FUN_TYPES.has(type)

+ 39 - 0
nodejs/ef-compiler/actions/fun/ai/img2img.js

@@ -0,0 +1,39 @@
+const path = require('path')
+const fs = require('fs')
+const aiModule = require(path.join(__dirname, '../../../../ai/ai.js'))
+
+function resolveSavePath(savePath, folderPath) {
+  if (!savePath || typeof savePath !== 'string') return null
+  const trimmed = savePath.trim()
+  if (path.isAbsolute(trimmed) || /^[A-Za-z]:/.test(trimmed)) return trimmed
+  return folderPath ? path.join(folderPath, trimmed) : path.resolve(trimmed)
+}
+
+/** 入参:prompt, model, imageUrl(参考图地址), savePath(可选,生成图保存路径) */
+async function executeImg2img({ prompt, model, imageUrl, savePath, folderPath }) {
+  const p = prompt != null ? String(prompt).trim() : ''
+  const url = imageUrl != null ? String(imageUrl).trim() : ''
+  if (!url) return { success: false, error: 'img2img 缺少 imageUrl' }
+  const m = model != null ? String(model).trim().toLowerCase() : ''
+  const action = m === 'doubao' ? 'doubao_img2img' : 'img2img'
+  const outPath = savePath ? resolveSavePath(savePath, folderPath) : null
+  try {
+    const result = await aiModule.run(action, p, url)
+    if (!result.success) return { success: false, error: result.error || 'img2img 失败' }
+    const data = result.data
+    const item = data?.data?.[0]
+    if (!item) return { success: false, error: 'img2img 无返回数据' }
+    if (item.b64_json && outPath) {
+      const dir = path.dirname(outPath)
+      if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
+      fs.writeFileSync(outPath, Buffer.from(item.b64_json, 'base64'))
+      return { success: true, value: outPath, path: outPath }
+    }
+    const outUrl = item.url || ''
+    return { success: true, value: outUrl, path: outUrl || undefined }
+  } catch (e) {
+    return { success: false, error: (e && (e.message || String(e))) || 'img2img 异常' }
+  }
+}
+
+module.exports = { executeImg2img }

+ 22 - 0
nodejs/ef-compiler/actions/fun/ai/img2text.js

@@ -0,0 +1,22 @@
+const path = require('path')
+const aiModule = require(path.join(__dirname, '../../../../ai/ai.js'))
+
+/** 入参:prompt, model, imageUrl(参考图地址,如 data URL 或 http URL) */
+async function executeImg2text({ prompt, model, imageUrl, folderPath }) {
+  const p = prompt != null ? String(prompt).trim() : ''
+  const url = imageUrl != null ? String(imageUrl).trim() : ''
+  if (!url) return { success: false, error: 'img2text 缺少 imageUrl' }
+  const m = model != null ? String(model).trim().toLowerCase() : ''
+  const action = m === 'doubao' ? 'doubao_img2text' : 'img2text'
+  try {
+    const result = await aiModule.run(action, p, url)
+    if (!result.success) return { success: false, error: result.error || 'img2text 失败' }
+    const data = result.data
+    const text = data?.choices?.[0]?.message?.content ?? data?.choices?.[0]?.text ?? ''
+    return { success: true, value: typeof text === 'string' ? text : String(text) }
+  } catch (e) {
+    return { success: false, error: (e && (e.message || String(e))) || 'img2text 异常' }
+  }
+}
+
+module.exports = { executeImg2text }

+ 37 - 0
nodejs/ef-compiler/actions/fun/ai/text2img.js

@@ -0,0 +1,37 @@
+const path = require('path')
+const fs = require('fs')
+const aiModule = require(path.join(__dirname, '../../../../ai/ai.js'))
+
+function resolveSavePath(savePath, folderPath) {
+  if (!savePath || typeof savePath !== 'string') return null
+  const trimmed = savePath.trim()
+  if (path.isAbsolute(trimmed) || /^[A-Za-z]:/.test(trimmed)) return trimmed
+  return folderPath ? path.join(folderPath, trimmed) : path.resolve(trimmed)
+}
+
+/** 入参:prompt, model, savePath(可选,图片保存路径;有则返回 b64 并写入文件,否则返回 url) */
+async function executeText2img({ prompt, model, savePath, folderPath }) {
+  const p = prompt != null ? String(prompt).trim() : ''
+  const m = model != null ? String(model).trim().toLowerCase() : ''
+  const action = m === 'doubao' ? 'doubao_text2img' : 'text2img'
+  const outPath = savePath ? resolveSavePath(savePath, folderPath) : null
+  try {
+    const result = await aiModule.run(action, p, outPath)
+    if (!result.success) return { success: false, error: result.error || 'text2img 失败' }
+    const data = result.data
+    const item = data?.data?.[0]
+    if (!item) return { success: false, error: 'text2img 无返回数据' }
+    if (item.b64_json && outPath) {
+      const dir = path.dirname(outPath)
+      if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
+      fs.writeFileSync(outPath, Buffer.from(item.b64_json, 'base64'))
+      return { success: true, value: outPath, path: outPath }
+    }
+    const url = item.url || ''
+    return { success: true, value: url, path: url || undefined }
+  } catch (e) {
+    return { success: false, error: (e && (e.message || String(e))) || 'text2img 异常' }
+  }
+}
+
+module.exports = { executeText2img }

+ 19 - 0
nodejs/ef-compiler/actions/fun/ai/text2text.js

@@ -0,0 +1,19 @@
+const path = require('path')
+const aiModule = require(path.join(__dirname, '../../../../ai/ai.js'))
+
+async function executeText2text({ prompt, model, folderPath }) {
+  const p = prompt != null ? String(prompt).trim() : ''
+  const m = model != null ? String(model).trim().toLowerCase() : ''
+  const action = m === 'doubao' ? 'doubao_text2text' : 'text2text'
+  try {
+    const result = await aiModule.run(action, p)
+    if (!result.success) return { success: false, error: result.error || 'text2text 失败' }
+    const data = result.data
+    const text = data?.choices?.[0]?.message?.content ?? data?.choices?.[0]?.text ?? ''
+    return { success: true, value: typeof text === 'string' ? text : String(text) }
+  } catch (e) {
+    return { success: false, error: (e && (e.message || String(e))) || 'text2text 异常' }
+  }
+}
+
+module.exports = { executeText2text }

+ 53 - 3
nodejs/ef-compiler/actions/fun/download.js

@@ -1,4 +1,54 @@
-async function executeDownload() {
-    return { success: true, value: 'ok' }
+/**
+ * 根据 url 下载文件到 savePath(支持 http/https)
+ * 入参:url, savePath(相对路径时基于 folderPath)
+ */
+const path = require('path')
+const fs = require('fs')
+const https = require('https')
+const http = require('http')
+
+function buildSavePath(savePath, folderPath) {
+  if (!savePath || typeof savePath !== 'string') return null
+  const trimmed = savePath.trim()
+  if (path.isAbsolute(trimmed) || trimmed.match(/^[A-Za-z]:/)) return trimmed
+  return folderPath ? path.join(folderPath, trimmed) : path.resolve(trimmed)
+}
+
+function downloadToFile(url, filePath) {
+  return new Promise((resolve, reject) => {
+    const protocol = url.startsWith('https') ? https : http
+    const request = protocol.get(url, { timeout: 60000 }, (response) => {
+      if (response.statusCode === 301 || response.statusCode === 302) {
+        const redirect = response.headers.location
+        if (redirect) return downloadToFile(redirect, filePath).then(resolve).catch(reject)
+      }
+      if (response.statusCode !== 200) {
+        reject(new Error(`HTTP ${response.statusCode}`))
+        return
+      }
+      const dir = path.dirname(filePath)
+      if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
+      const file = fs.createWriteStream(filePath)
+      response.pipe(file)
+      file.on('finish', () => { file.close(); resolve({ success: true, path: filePath }) })
+      file.on('error', (err) => { fs.unlink(filePath, () => {}); reject(err) })
+    })
+    request.on('error', reject)
+    request.setTimeout(60000, () => { request.destroy(); reject(new Error('timeout')) })
+  })
+}
+
+async function executeDownload({ url, savePath, folderPath }) {
+  if (!url || typeof url !== 'string' || !url.trim()) return { success: false, error: 'download 缺少 url 参数' }
+  if (!savePath && savePath !== '') return { success: false, error: 'download 缺少 savePath 参数' }
+  const absolutePath = buildSavePath(String(savePath).trim(), folderPath)
+  if (!absolutePath) return { success: false, error: 'download 缺少 savePath 参数' }
+  try {
+    await downloadToFile(url.trim(), absolutePath)
+    return { success: true, path: absolutePath }
+  } catch (e) {
+    return { success: false, error: e && (e.message || String(e)) || 'download failed' }
   }
-  module.exports = { executeDownload }
+}
+
+module.exports = { executeDownload }

+ 0 - 33
nodejs/ef-compiler/actions/log-parser.js

@@ -1,33 +0,0 @@
-/** 语句:log 打印信息(仅 console/UI,不写 log.txt) */
-const types = ['log']
-
-function parse(action, parseContext) {
-  const { extractVarName } = parseContext
-  const parsed = { type: 'log' }
-  if (action.inVars && Array.isArray(action.inVars)) {
-    parsed.inVars = action.inVars.map(v => extractVarName(v))
-  } else parsed.inVars = []
-  if (action.value) parsed.value = action.value
-  return Object.assign({}, action, parsed)
-}
-
-async function execute(action, ctx) {
-  const { variableContext, extractVarName, replaceVariablesInString } = ctx
-  let message = ''
-  if (action.inVars && action.inVars.length > 0) {
-    const messages = action.inVars.map(varWithBraces => {
-      const varName = extractVarName(varWithBraces)
-      const v = variableContext[varName]
-      return v !== undefined ? String(v) : varWithBraces
-    })
-    message = messages.join(' ')
-  } else if (action.value) {
-    message = replaceVariablesInString(action.value, variableContext)
-  }
-  if (typeof window !== 'undefined') {
-    window.dispatchEvent(new CustomEvent('log-message', { detail: { message } }))
-  }
-  return { success: true }
-}
-
-module.exports = { types, parse, execute }

+ 5 - 3
nodejs/ef-compiler/actions/set-parser.js

@@ -12,8 +12,7 @@ function extractVarName(varName) {
 
 function replaceVariablesInString(str, context) {
   if (typeof str !== 'string') return str
-  const doubleBracePattern = /\{\{([\w-]+)\}\}/g
-  return str.replace(doubleBracePattern, (match, varName) => {
+  const replacer = (match, varName) => {
     const varValue = context[varName]
     if (varValue === undefined || varValue === null || varValue === '') return ''
     if (varValue === 'undefined' || varValue === 'null') return ''
@@ -31,7 +30,10 @@ function replaceVariablesInString(str, context) {
       }
     }
     return String(varValue)
-  })
+  }
+  const doubleBracePattern = /\{\{([\w-]+)\}\}/g
+  const singleBracePattern = /\{([\w-]+)\}/g
+  return str.replace(doubleBracePattern, replacer).replace(singleBracePattern, replacer)
 }
 
 function resolveValue(value, context) {

+ 15 - 5
nodejs/ef-compiler/variable-parser.js

@@ -1,19 +1,20 @@
 /**
- * 统一解析结点入参:将 action 中的变量引用({var}、{{var}})用 variableContext 解析为实际值,
- * 再传给各 action 的 parse/execute,避免各结点脚本重复写解析逻辑
+ * 统一解析结点入参、出参:将 action 中的变量引用用 variableContext 解析为实际值。
+ * 规则:{var}、{{var}} 为变量(替换为变量值),"hello" 为字符串字面量,"hello{var}" 为字符串+变量拼接
  */
 const setParser = require('./actions/set-parser.js')
 const resolveValue = setParser.resolveValue
 const replaceVariablesInString = setParser.replaceVariablesInString
+const extractVarName = setParser.extractVarName
 
-/** 视为入参的字段(会被解析);outVars/variable 为输出,不在此解析。inVars 由各结点按需解析(因部分结点将 inVars 某项作为输出变量名)。 */
+/** 视为入参的字段(会被解析);inVars 由各结点按需解析(因部分结点将 inVars 某项作为输出变量名)。 */
 const INPUT_KEYS = [
   'value', 'target', 'template', 'area', 'savePath', 'condition', 'delay', 'interval',
   'items', 'screenshot', 'region', 'method', 'clear', 'timeout', 'retry',
   'min', 'max', 'avatar1', 'avatar2', 'friendAvatar', 'avatar', 'path', 'filePath',
   'inputDataString', 'textVariable', 'senderVariable', 'appendMode',
   'summaryPrompt', 'historyPrompt', 'model', 'prompt', 'systemPrompt',
-  'regionArea', 'saveDir', 'url', 'filename',
+  'regionArea', 'saveDir', 'url', 'filename', 'imageUrl',
 ]
 
 /**
@@ -23,7 +24,9 @@ function resolveInputValue(val, variableContext) {
   if (variableContext == null) return val
   if (typeof val === 'string') {
     const replaced = replaceVariablesInString(val, variableContext)
-    return resolveValue(replaced, variableContext)
+    let result = resolveValue(replaced, variableContext)
+    if (result === val && /^[\w-]+$/.test(val) && variableContext[val] !== undefined) result = variableContext[val]
+    return result
   }
   if (Array.isArray(val)) return val.map(item => resolveInputValue(item, variableContext))
   if (typeof val === 'object' && val !== null) {
@@ -52,6 +55,13 @@ function resolveActionInputs(action, variableContext) {
     }
   }
 
+  if (resolved.inVars && Array.isArray(resolved.inVars)) {
+    resolved.inVars = resolved.inVars.map((v) => resolveInputValue(v, variableContext))
+  }
+  if (resolved.outVars && Array.isArray(resolved.outVars)) {
+    resolved.outVars = resolved.outVars.map((v) => (typeof v === 'string' ? extractVarName(v) : v))
+  }
+
   if (resolved.condition && typeof resolved.condition === 'object' && !Array.isArray(resolved.condition)) {
     const c = resolved.condition
     if (c.interval != null) resolved.condition = Object.assign({}, c, { interval: resolveInputValue(c.interval, variableContext) })

+ 4 - 4
nodejs/ef-compiler/workflow-json-parser.js

@@ -5,13 +5,13 @@
 const setParser = require('./actions/set-parser.js')
 const extractVarName = setParser.extractVarName
 const resolveValue = setParser.resolveValue
+const funNodeRegistry = require('./actions/fun-node-registry.js')
 
 const actionModules = [
   require('./actions/delay-parser.js'),
   setParser,
   require('./actions/adb-parser.js'),
   require('./actions/echo-parser.js'),
-  require('./actions/log-parser.js'),
   require('./actions/random-parser.js'),
   require('./actions/schedule-parser.js'),
   require('./actions/if-parser.js'),
@@ -81,9 +81,9 @@ function getActionName(action) {
     'set': 'set variable',
     'random': 'random',
     'echo': 'echo',
-    'log': 'log',
   }
-  const typeName = action.type === 'fun' ? (typeNames[action.method] || action.method || 'fun') : (typeNames[action.type] || action.type)
+  ;(funNodeRegistry || []).forEach((def) => { typeNames[def.type] = def.displayName || def.type })
+  const typeName = (action.type === 'fun' || action.type === 'ai') ? (typeNames[action.method] || action.method || action.type) : (typeNames[action.type] || action.type)
   const value = action.value || action.target || ''
   const displayValue = typeof value === 'string' ? value : JSON.stringify(value)
   if (action.type === 'schedule') {
@@ -103,7 +103,7 @@ function getActionName(action) {
   if (action.type === 'for') return `${typeName}: ${action.variable || ''}`
   if (action.type === 'try') return typeName
   if (action.type === 'set') return `${typeName}: ${action.variable || ''}`
-  if (action.type === 'fun') return `${typeName}: ${displayValue}`
+  if (action.type === 'fun' || action.type === 'ai') return `${typeName}: ${displayValue}`
   return `${typeName}: ${displayValue}`
 }
 

+ 200 - 0
static/process/CreateNote/bp.json

@@ -0,0 +1,200 @@
+{
+	"nodePositions": {
+		"node_begin_1768681618551": {
+			"x": 150,
+			"y": 100
+		},
+		"node_0": {
+			"x": 150,
+			"y": 300
+		},
+		"node_1": {
+			"x": 470,
+			"y": 100
+		},
+		"node_2": {
+			"x": 470,
+			"y": 300
+		},
+		"node_3": {
+			"x": 470,
+			"y": 500
+		},
+		"node_4": {
+			"x": 470,
+			"y": 700
+		},
+		"node_5": {
+			"x": 470,
+			"y": 900
+		},
+		"node_6": {
+			"x": 470,
+			"y": 1100
+		},
+		"node_7": {
+			"x": 470,
+			"y": 1300
+		},
+		"node_8": {
+			"x": 470,
+			"y": 1500
+		},
+		"node_9": {
+			"x": 470,
+			"y": 1700
+		},
+		"node_10": {
+			"x": 150,
+			"y": 1900
+		},
+		"node_11": {
+			"x": 790,
+			"y": 100
+		},
+		"node_12": {
+			"x": 790,
+			"y": 300
+		},
+		"node_13": {
+			"x": 150,
+			"y": 2100
+		},
+		"node_14": {
+			"x": 150,
+			"y": 2300
+		},
+		"node_15": {
+			"x": 150,
+			"y": 2500
+		},
+		"node_16": {
+			"x": 150,
+			"y": 2700
+		},
+		"node_17": {
+			"x": 150,
+			"y": 2900
+		},
+		"node_18": {
+			"x": 150,
+			"y": 3100
+		},
+		"node_19": {
+			"x": 150,
+			"y": 3300
+		},
+		"node_20": {
+			"x": 150,
+			"y": 500
+		},
+		"node_21": {
+			"x": 150,
+			"y": 700
+		},
+		"node_22": {
+			"x": 150,
+			"y": 900
+		},
+		"node_23": {
+			"x": 1110,
+			"y": 100
+		},
+		"node_24": {
+			"x": 1110,
+			"y": 300
+		},
+		"node_25": {
+			"x": 1430,
+			"y": 100
+		},
+		"node_26": {
+			"x": 1430,
+			"y": 300
+		},
+		"node_27": {
+			"x": 790,
+			"y": 500
+		},
+		"node_28": {
+			"x": 790,
+			"y": 700
+		},
+		"node_29": {
+			"x": 790,
+			"y": 900
+		},
+		"node_30": {
+			"x": 150,
+			"y": 1100
+		},
+		"node_31": {
+			"x": 150,
+			"y": 1300
+		},
+		"node_32": {
+			"x": 150,
+			"y": 1500
+		},
+		"node_33": {
+			"x": 150,
+			"y": 1700
+		},
+		"var_turn_1768681618551": {
+			"x": 50,
+			"y": 200
+		},
+		"var_relationBg_1768681618551": {
+			"x": 50,
+			"y": 320
+		},
+		"var_chatArea_1768681618551": {
+			"x": 50,
+			"y": 440
+		},
+		"var_chatHistoryMessage_1768681618551": {
+			"x": 50,
+			"y": 560
+		},
+		"var_currentChatMessage_1768681618551": {
+			"x": 50,
+			"y": 680
+		},
+		"var_lastHistoryMessage_1768681618551": {
+			"x": 50,
+			"y": 800
+		},
+		"var_lastChatMessage_1768681618551": {
+			"x": 50,
+			"y": 920
+		},
+		"var_lastChatRole_1768681618551": {
+			"x": 50,
+			"y": 1040
+		},
+		"var_lastHistoryChatMessage_1768681618551": {
+			"x": 50,
+			"y": 1160
+		},
+		"var_lastHistoryChatRole_1768681618551": {
+			"x": 50,
+			"y": 1280
+		},
+		"var_aiReply_1768681618551": {
+			"x": 50,
+			"y": 1400
+		},
+		"var_aiCallBack_1768681618551": {
+			"x": 50,
+			"y": 1520
+		},
+		"var_sendBtnPos_1768681618551": {
+			"x": 50,
+			"y": 1640
+		},
+		"var_newChatMessage_1768681618551": {
+			"x": 50,
+			"y": 1760
+		}
+	}
+}

+ 19 - 0
static/process/CreateNote/process.json

@@ -0,0 +1,19 @@
+{
+  "name": "CreateNote",
+  "description": "创建小红书笔记",
+  "variables": {
+    "model": "doubao",
+    "imgPrompt": "一张健身主题的图片,阳光下的运动场景,励志风格",
+    "imgPath": "./tmp/fitness.png"
+  },
+  "execute": 
+  [
+    
+    {
+      "type": "ai",
+      "method": "text2img",
+      "inVars": ["{imgPrompt}", "{model}", "{imgPath}"],
+      "outVars": []
+    }
+  ]
+}

+ 3 - 0
static/process/CreateNote/readme.md

@@ -0,0 +1,3 @@
+# WeChatChating
+
+用于微信聊天机器人

BIN
static/process/CreateNote/resources/图文点赞.png


BIN
static/process/CreateNote/resources/视频点赞.png


+ 6 - 6
static/process/WeChatAIChating/process.json

@@ -60,7 +60,7 @@
 					"outVars": ["{currentChatMessage}"]
 				},
 				{
-					"type": "log",
+					"type": "echo",
 					"value": "当前聊天内容:{{currentChatMessage}}"
 				},
 				{
@@ -76,7 +76,7 @@
 					"outVars": ["{relationBg}"]
 				},
 				{
-					"type": "log",
+					"type": "echo",
 					"value": "背景内容:{{relationBg}}"
 				},
 				{
@@ -123,7 +123,7 @@
 							"value": "0"
 						},
 						{
-							"type": "log",
+							"type": "echo",
 							"value": "==AI生成关系背景==:{{relationBg}}"
 						},
 						{
@@ -141,7 +141,7 @@
 					"outVars": ["{lastChatMessage}","{lastChatRole}"]
 				},
 				{
-					"type": "log",
+					"type": "echo",
 					"value": "当前最后一条消息:{{lastChatMessage}}({{lastChatRole}})"
 				},
 				{
@@ -151,7 +151,7 @@
 					"outVars": ["{lastHistoryChatMessage}","{lastHistoryChatRole}"]
 				},
 				{
-					"type": "log",
+					"type": "echo",
 					"value": "==历史最后一条消息:{{lastHistoryChatMessage}}({{lastHistoryChatRole}})=="
 				},
 				{
@@ -160,7 +160,7 @@
 					"ture": 
 					[
 						{
-							"type": "log",
+							"type": "echo",
 							"value":"AI自动回复流程开始=="
 						},
 						{