<!-- Source: https://deepseek-browser-use.io.nexus/26 -->

# 源码教程：批量、配方、后台作业与维护

这些能力不增加新的浏览器原语，而是组合和管理已有命令。重点是避免 HTTP 超时被误认为执行失败，以及区分“取消后续步骤”和“撤销已执行操作”。

## 1. commands 批量入口

`commands` 是 `ActionService.execute` 特殊处理的入口，不包含在 122 个注册方法之中。同步批次逐项解析单命令对象，按顺序调用同一执行层，因此单条与批量应具有一致的截图和错误处理语义。

```json
{
  "id":"1001",
  "method":"commands",
  "params":{
    "stopOnError":true,
    "commands":[
      {"go_to_url":{"url":"https://example.com"}},
      {"get_title":{}},
      {"get_browser_state":{"highlight":false}}
    ]
  }
}
```

批次不允许嵌套 commands，每项只有一个命令键，有最大步骤数限制。stopOnError 控制遇到失败是否停止，expect 则用来判断业务观察条件；条件失败不能被一次浏览器 API 返回成功掩盖。

页面变化不会自动终止整个批次。依赖新快照索引的点击应放到下一次请求中，不能在同一批次预先猜好未来页面索引。

## 2. 后台作业

params 加 async=true 时，`JobRegistry.submit` 将批次交给后台执行器，立即返回 jobId。注册表保存状态、耗时、步骤数、取消标志和最终结果。

| 命令 | 实现 |
| --- | --- |
| `list_jobs` | 返回最近作业摘要，可通过 limit 限制数量 |
| `get_job` | 按 jobId 查内存记录，includeResult 默认包含已有结果；运行中返回状态提示 |
| `cancel_job` | 标记 cancelRequested，在后续步骤边界协作停止，不打断已发送的浏览器动作 |

状态为 running、done、failed 或 cancelled。记录保存在内存，服务重启后丢失；注册表会清理较旧的已结束作业，不能当成持久化消息队列。

当前 get_job 注册只读取 jobId 与 includeResult。不能因为某条提示提到长轮询，就自行增加未实现的参数并假定服务真的等待；客户端按实际接口查询即可。HTTP 客户端超时后先查询已存在作业，不能重发整个批次。

## 3. 配方复用

`list_recipes` 调用 `RecipeStore.list`，返回可用配方、数量和目录。`run_recipe` 按 name 读取配方，合并变量并生成 commands，随后仍由公共执行链执行。配方格式与示例见[第 14 篇](<https://deepseek-browser-use.io.nexus/10>)。

配方必须显式点名，不从网站地址猜测执行哪套流程。把稳定步骤放入配方，把需要新快照判断、短信验证或人工确认的分支保留在上层控制逻辑。变量是参数化工具，不是隐含扩大操作权限的机制。

## 4. 自省与清理

| 命令 | 实现与返回 |
| --- | --- |
| `list_methods` | 从 CommandTable 的键生成方法名列表，可按 filter 筛选，返回 count/methods/total |
| `get_config` | 返回服务端解析后的生效配置和路径，不能只看配置文件推断当前进程状态 |
| `list_tasks` | 汇总活跃任务与共享浏览器状态，用于诊断启动中、页签和资源归属 |
| `cleanup` | 根据 scope、olderThanHours、keepLatest 选择产物；默认 dryRun，只预演 |

cleanup 真正删除前先核对候选和保留策略。截图、trace 与上传文件可能仍被任务引用，不应为了节省磁盘盲目清空。命令清理运行产物，与关闭浏览器、删除账号或注销商户无关。

## 5. 验证方法

使用本地页面构造成功、失败、条件不满足的三个步骤，检查 stopOnError 和 expect 的计数及结果。异步测试在第一步与第二步之间发出取消，确认已执行动作保留、后续动作不执行。配方测试对比变量替换结果，不访问真实业务站点。cleanup 先测试 dryRun 无副作用，再在专用临时目录验证筛选条件，禁止将用户真实产物作为清理测试样本。

## 注册参数与 Java 入口

以下按 `CommandTable` 实际读取参数整理。`*` 表示注册层使用必填读取器；其余字段省略后由服务决定默认行为。带条件的入口仍需满足正文说明，例如上传文件来源、元素定位二选一。外层 `id` 不重复列出。

| 命令 | params 字段 | Java 入口 |
| --- | --- | --- |
| `list_methods` | `filter` | `CommandTable.names` |
| `get_config` | 无 | `getConfig` |
| `list_tasks` | 无 | `listTasks` |
| `cleanup` | `scope`、`olderThanHours`、`keepLatest`、`dryRun` | `cleanup` |
| `list_recipes` | 无 | `RecipeStore.list` |
| `run_recipe` | `name`*、`vars`、`stopOnError` | `CommandTable.runRecipe` |
| `list_jobs` | `limit` | `JobRegistry.recent` |
| `get_job` | `jobId`*、`includeResult` | `JobRegistry.get` / `Job.describe` |
| `cancel_job` | `jobId`* | `JobRegistry.cancel` |

<!-- current-source-tutorial -->
## 当前源码：命令注册与执行

先在本章上半部分理解行为，再按命令展开实现。注册代码说明 JSON 参数如何传给 Java；服务方法展示实际浏览器操作。方法依赖共享类中的字段和辅助函数，不应脱离原类直接粘贴编译。

### `list_methods`

::: details 展开参数注册

源码：`playwright-server/src/main/java/nexus/io/ai/browser/actions/registry/CommandTable.java`。以下为当前实现，可放回原类中阅读；依赖同类字段和辅助方法，并非独立编译单元。

```java
    put("list_methods", (svc, id, a) -> {
      String filter = optStr(a, "filter");
      List<String> names = new java.util.ArrayList<>();
      for (String name : names()) {
        if (filter == null || name.contains(filter.toLowerCase(Locale.ROOT))) {
          names.add(name);
        }
      }
      Kv data = Kv.by("count", names.size()).set("methods", names).set("total", names().size());
      if (filter != null) {
        data.set("filter", filter);
      }
      return RespBodyVo.ok(data);
    });
```

:::

本命令的主要处理直接在上述注册函数中完成；其中的作业或配方依赖保持与当前项目一致。

### `get_config`

::: details 展开参数注册

源码：`playwright-server/src/main/java/nexus/io/ai/browser/actions/registry/CommandTable.java`。以下为当前实现，可放回原类中阅读；依赖同类字段和辅助方法，并非独立编译单元。

```java
    put("get_config", (svc, id, a) -> svc.getConfig(id));
```

:::

::: details 展开 getConfig 实现

源码：`playwright-server/src/main/java/nexus/io/ai/browser/service/PlaywrightService.java`。以下为当前实现，可放回原类中阅读；依赖同类字段和辅助方法，并非独立编译单元。

```java
  public RespBodyVo getConfig(Long browserId) {
    Kv data = new Kv();
    data.set("workDir", Paths.get("").toAbsolutePath().normalize().toString());
    data.set("jsDir", scriptDir().toString());
    // 引擎与类型:engine 只分 chromium/firefox 两档,type 才是 auto/chromium/chrome/edge/firefox
    data.set("engine", BrowserEngine.current().id());
    data.set("configuredType", BrowserChoice.configured().id());
    data.set("typeChoices", BrowserChoice.CHOICES);
    data.set("profileDir", Kv.by("resolved", ChromeBrowser.managedProfileDir().toString())
        .set("perPort", ChromeBrowser.perPortProfileDir())
        .set("configured", ChromeBrowser.config(ChromeBrowser.KEY_PROFILE_DIR))
        .set("note", "没显式配 browser.profileDir 且 perPort 开着时,目录按服务端口派生(shared-<端口>),"
            + "多个服务实例同时跑不会抢同一份 profile 锁"));
    data.set("action", Kv.by("timeoutMs", actionTimeoutMs()).set("jsFallback", jsFallbackEnabled())
        .set("mouseFallback", mouseFallbackEnabled()));
    data.set("launchTimeoutMs", launchTimeoutMs());
    data.set("trace", Kv.by("dir", CommandTraceLog.currentDir().toString())
        .set("enabled", Boolean.parseBoolean(String.valueOf(ChromeBrowser.config(CommandTraceLog.KEY_ENABLED) == null
            ? "true" : ChromeBrowser.config(CommandTraceLog.KEY_ENABLED))))
        .set("redact", CommandTraceLog.redactEnabled()));
    data.set("upload", Kv.by("dir", UploadStore.dir().toString()).set("enabled", UploadStore.enabled())
        .set("maxBytes", UploadStore.maxBytes()));
    data.set("tasks", INSTANCES.size());
    data.set("commands", CommandTableNamesHolder.names());
    data.set("java", System.getProperty("java.version"));
    Package playwrightPackage = Playwright.class.getPackage();
    data.set("playwright", playwrightPackage == null ? null : playwrightPackage.getImplementationVersion());
    if (browserId != null) {
      data.set("browser", browserInfo(browserId));
    }
    return RespBodyVo.ok(data);
  }
```

:::

### `list_tasks`

::: details 展开参数注册

源码：`playwright-server/src/main/java/nexus/io/ai/browser/actions/registry/CommandTable.java`。以下为当前实现，可放回原类中阅读；依赖同类字段和辅助方法，并非独立编译单元。

```java
    put("list_tasks", (svc, id, a) -> svc.listTasks());
```

:::

::: details 展开 listTasks 实现

源码：`playwright-server/src/main/java/nexus/io/ai/browser/service/PlaywrightService.java`。以下为当前实现，可放回原类中阅读；依赖同类字段和辅助方法，并非独立编译单元。

```java
  public RespBodyVo listTasks() {
    List<Kv> tasks = new ArrayList<>();
    for (BrowserInstance inst : INSTANCES.values()) {
      tasks.add(taskInfo(inst));
    }
    Kv data = Kv.by("count", tasks.size()).set("tasks", tasks);
    SharedBrowser browser = sharedBrowser;
    if (browser != null) {
      data.set("browser", Kv.by("type", browser.resolvedType.id()).set("engine", browser.engine.id())
          .set("profileDir", browser.profileDir.toAbsolutePath().toString()).set("headless", browser.headless)
          .set("mode", browser.browser != null ? "cdp" : "managed"));
    } else {
      data.set("browser", null);
    }
    Kv launching = launchState;
    if (launching != null) {
      Long started = launching.getLong("startedAt");
      long startedAt = started == null ? System.currentTimeMillis() : started;
      data.set("launching", Kv.by("startedAt", startedAt).set("elapsedMs", System.currentTimeMillis() - startedAt)
          .set("browser", launching.get("browser")).set("headless", launching.get("headless"))
          .set("note", launching.get("note")))
          .set("note", "浏览器正在启动中,所以 count=0 是正常的(任务要等浏览器起来才注册):"
              + "现在这一步可能是在下载/升级 Playwright 的浏览器,先别重复 start;详情见 launching 字段");
    } else {
      data.set("launching", null);
    }
    return RespBodyVo.ok(data);
  }
```

:::

### `cleanup`

::: details 展开参数注册

源码：`playwright-server/src/main/java/nexus/io/ai/browser/actions/registry/CommandTable.java`。以下为当前实现，可放回原类中阅读；依赖同类字段和辅助方法，并非独立编译单元。

```java
    put("cleanup", (svc, id, a) -> svc.cleanup(optStr(a, "scope"), a.getInteger("olderThanHours"),
        a.getInteger("keepLatest"), a.getBoolean("dryRun")));
```

:::

::: details 展开 cleanup 实现

源码：`playwright-server/src/main/java/nexus/io/ai/browser/service/PlaywrightService.java`。以下为当前实现，可放回原类中阅读；依赖同类字段和辅助方法，并非独立编译单元。

```java
  public RespBodyVo cleanup(String scope, Integer olderThanHours, Integer keepLatest, Boolean dryRun) {
    String target = scope == null || scope.isBlank() ? "all" : scope.trim().toLowerCase(java.util.Locale.ROOT);
    long cutoff = System.currentTimeMillis()
        - (olderThanHours == null ? 24L : Math.max(0, olderThanHours)) * 3600_000L;
    int keep = keepLatest == null ? 0 : Math.max(0, keepLatest);
    // 删文件是不可逆的:默认只预演,要真删必须显式关掉
    boolean preview = dryRun == null || dryRun;
    Kv report = new Kv();
    long[] totals = new long[] {0, 0};
    List<String> notes = new ArrayList<>();
    if ("all".equals(target) || "data".equals(target)) {
      Path data = Paths.get(DATA_DIR).toAbsolutePath().normalize();
      report.set("data", sweep(data, cutoff, keep, preview, totals, notes));
    }
    if ("all".equals(target) || "trace".equals(target)) {
      report.set("trace", sweep(CommandTraceLog.currentDir(), cutoff, keep, preview, totals, notes));
    }
    if ("all".equals(target) || "upload".equals(target)) {
      // 暂存文件是「等会儿要交给页面」的,按时间清会把正在用的文件删掉,所以只在显式点名 upload 时才动
      if ("upload".equals(target)) {
        report.set("upload", sweep(UploadStore.dir(), cutoff, keep, preview, totals, notes));
      } else {
        notes.add("upload 目录没动:暂存文件可能正在被某个任务使用,要清请显式指定 scope:\"upload\"");
      }
    }
    report.set("scope", target).set("olderThanHours", olderThanHours == null ? 24 : olderThanHours)
        .set("keepLatest", keep).set("dryRun", preview).set("deletedFiles", totals[0])
        .set("freedBytes", totals[1]);
    if (!notes.isEmpty()) {
      report.set("notes", notes);
    }
    return RespBodyVo.ok(report);
  }
```

:::

### `list_recipes`

::: details 展开参数注册

源码：`playwright-server/src/main/java/nexus/io/ai/browser/actions/registry/CommandTable.java`。以下为当前实现，可放回原类中阅读；依赖同类字段和辅助方法，并非独立编译单元。

```java
    put("list_recipes", (svc, id, a) -> {
      List<Kv> recipes = RecipeStore.list();
      return RespBodyVo.ok(Kv.by("count", recipes.size()).set("recipes", recipes)
          .set("dir", RecipeStore.dir().toString()));
    });
```

:::

本命令的主要处理直接在上述注册函数中完成；其中的作业或配方依赖保持与当前项目一致。

### `run_recipe`

::: details 展开参数注册

源码：`playwright-server/src/main/java/nexus/io/ai/browser/actions/registry/CommandTable.java`。以下为当前实现，可放回原类中阅读；依赖同类字段和辅助方法，并非独立编译单元。

```java
    put("run_recipe", (svc, id, a) -> runRecipe(id, reqStr(a, "name"), a.getJSONObject("vars"),
        a.getBoolean("stopOnError")));
```

:::

本命令的主要处理直接在上述注册函数中完成；其中的作业或配方依赖保持与当前项目一致。

### `list_jobs`

::: details 展开参数注册

源码：`playwright-server/src/main/java/nexus/io/ai/browser/actions/registry/CommandTable.java`。以下为当前实现，可放回原类中阅读；依赖同类字段和辅助方法，并非独立编译单元。

```java
    put("list_jobs", (svc, id, a) -> {
      Integer limit = a.getInteger("limit");
      List<Kv> jobs = new java.util.ArrayList<>();
      for (nexus.io.ai.browser.service.JobRegistry.Job job : nexus.io.ai.browser.service.JobRegistry
          .recent(limit == null ? 20 : limit)) {
        jobs.add(job.describe(false));
      }
      return RespBodyVo.ok(Kv.by("count", jobs.size()).set("jobs", jobs));
    });
```

:::

本命令的主要处理直接在上述注册函数中完成；其中的作业或配方依赖保持与当前项目一致。

### `get_job`

::: details 展开参数注册

源码：`playwright-server/src/main/java/nexus/io/ai/browser/actions/registry/CommandTable.java`。以下为当前实现，可放回原类中阅读；依赖同类字段和辅助方法，并非独立编译单元。

```java
    put("get_job", (svc, id, a) -> {
      String jobId = reqStr(a, "jobId");
      nexus.io.ai.browser.service.JobRegistry.Job job = nexus.io.ai.browser.service.JobRegistry.get(jobId);
      if (job == null) {
        return RespBodyVo.fail("没有这个任务：" + jobId + "（任务只保留最近 50 个,且服务重启即丢;"
            + "用 list_jobs 看现存任务）");
      }
      boolean withResult = a.getBoolean("includeResult") == null || a.getBoolean("includeResult");
      Kv data = job.describe(withResult);
      if ("running".equals(job.status)) {
        data.set("hint", "还在跑:等一会儿再查,或用 waitForSeconds 做长轮询");
      }
      return RespBodyVo.ok(data);
    });
```

:::

本命令的主要处理直接在上述注册函数中完成；其中的作业或配方依赖保持与当前项目一致。

### `cancel_job`

::: details 展开参数注册

源码：`playwright-server/src/main/java/nexus/io/ai/browser/actions/registry/CommandTable.java`。以下为当前实现，可放回原类中阅读；依赖同类字段和辅助方法，并非独立编译单元。

```java
    put("cancel_job", (svc, id, a) -> {
      String jobId = reqStr(a, "jobId");
      nexus.io.ai.browser.service.JobRegistry.Job job = nexus.io.ai.browser.service.JobRegistry.get(jobId);
      if (job == null) {
        return RespBodyVo.fail("没有这个任务：" + jobId);
      }
      boolean accepted = nexus.io.ai.browser.service.JobRegistry.cancel(jobId);
      return RespBodyVo.ok(Kv.by("jobId", jobId).set("cancelRequested", accepted).set("status", job.status)
          .set("note", "取消是协作式的:批次会在下一步之前停下来,当前这一步不会被中断"));
    });
```

:::

本命令的主要处理直接在上述注册函数中完成；其中的作业或配方依赖保持与当前项目一致。

### `commands` 批量入口

批量入口由 ActionService 直接处理，不计入注册表的 120 条命令。逐条执行、停止策略和断言统计见下面的当前实现。

::: details 展开批量执行

源码：`playwright-server/src/main/java/nexus/io/ai/browser/service/ActionService.java`。以下为当前实现，可放回原类中阅读；依赖同类字段和辅助方法，并非独立编译单元。

```java
  public RespBodyVo batchExecute(Long id, JSONObject params, String jobId) {
    Long browserId = id;
    boolean stopOnError = true;
    if (params == null) {
      return RespBodyVo.fail("commands 需要 params.commands 命令数组");
    }
    if (params.getBoolean("stopOnError") != null) {
      stopOnError = params.getBoolean("stopOnError");
    }
    Long maxDurationMs = params.getLong("maxDurationMs");
    long batchStartedAt = System.currentTimeMillis();
    JSONArray commands = params.getJSONArray("commands");
    if (commands == null && params.size() == 1 && !params.containsKey("stopOnError")) {
      // 宽容处理:只写一条命令时,params 本身就可以是那一条
      commands = new JSONArray();
      commands.add(params);
    }
    if (commands == null) {
      return RespBodyVo.fail("commands 需要 params.commands 命令数组");
    }
    if (commands.isEmpty()) {
      return RespBodyVo.fail("命令数组为空");
    }
    if (commands.size() > MAX_COMMANDS) {
      return RespBodyVo.fail("命令数组最多 " + MAX_COMMANDS + " 条,当前 " + commands.size() + " 条");
    }
    if (browserId == null) {
      return RespBodyVo.fail("缺少参数 id");
    }

    List<Object> results = new ArrayList<>(commands.size());
    int succeeded = 0;
    int failed = 0;
    int expectFailed = 0;
    String firstFailure = null;
    String firstExpectFailure = null;
    String stopReason = null;
    boolean stopOnExpectFailure = Boolean.TRUE.equals(params.getBoolean("stopOnExpectFailure"));

    for (int i = 0; i < commands.size(); i++) {
      // 异步任务的取消是协作式的:在每一步之间生效,不会把已经发出的那一步打断
      if (jobId != null && JobRegistry.cancelRequested(jobId)) {
        stopReason = "cancelled";
        break;
      }
      if (maxDurationMs != null && maxDurationMs > 0 && System.currentTimeMillis() - batchStartedAt > maxDurationMs) {
        stopReason = "maxDurationMs(" + maxDurationMs + "ms)";
        break;
      }
      String cmdName = "?";
      RespBodyVo result;
      JSONObject expectation = null;
      try {
        JSONObject entry = commands.getJSONObject(i);
        if (entry == null) {
          result = RespBodyVo.fail("每条命令对象只能包含一个键");
        } else {
          // 允许「一条命令 + 一个 expect 断言」的两键写法:{命令:{...}, expect:{...}}
          expectation = entry.getJSONObject("expect");
          JSONObject only = new JSONObject(entry);
          only.remove("expect");
          if (only.size() != 1) {
            result = RespBodyVo.fail("每条命令对象只能包含一个键（外加可选的 expect）");
          } else {
            cmdName = only.keySet().iterator().next();
            if ("commands".equals(cmdName)) {
              result = RespBodyVo.fail("批量接口不支持嵌套调用 commands");
            } else {
              JSONObject args = only.getJSONObject(cmdName);
              result = execute(browserId, cmdName, args == null ? new JSONObject() : args);
            }
          }
        }
      } catch (Exception e) {
        result = RespBodyVo.fail(PlaywrightService.briefMessage(e.getMessage()));
      }

      Kv step = Kv.by("index", i).set("command", cmdName).set("ok", result.isOk()).set("data", result.getData())
          .set("msg", result.getMsg());
      if (!result.isOk()) {
        // 失败步给一个统一的错误对象:调用方不必去解析中文 msg,也不必自己判断能不能重试
        step.set("error", describeError(result));
      }
      // 断言在命令之后求值:动作说成功、状态其实没变的情况,靠它暴露出来
      if (expectation != null && result.isOk()) {
        Kv checked = checkExpectation(browserId, expectation);
        step.set("expectResult", checked);
        if (!Boolean.TRUE.equals(checked.getBoolean("passed"))) {
          expectFailed++;
          if (firstExpectFailure == null) {
            firstExpectFailure = "第 " + i + " 条命令 " + cmdName + " 的断言没通过："
                + (checked.get("error") != null ? checked.get("error")
                    : "实际值 " + checked.get("actual") + " 不满足 " + checked.get("matcher") + " " + checked.get("expected"));
          }
        }
      }
      results.add(step);

      if (result.isOk()) {
        succeeded++;
      } else {
        failed++;
        if (firstFailure == null) {
          firstFailure = "第 " + i + " 条命令 " + cmdName + " 失败：" + result.getMsg();
        }
        if (stopOnError) {
          break;
        }
      }
      if (stopOnExpectFailure && expectFailed > 0) {
        stopReason = "stopOnExpectFailure";
        break;
      }
      if (jobId != null) {
        JobRegistry.Job job = JobRegistry.get(jobId);
        if (job != null) {
          job.steps = i + 1;
        }
      }
    }

    Kv data = Kv.by("count", results.size()).set("succeeded", succeeded).set("failed", failed)
        .set("expectFailed", expectFailed)
        .set("stopped", stopReason != null || (stopOnError && failed > 0)
            || (stopOnExpectFailure && expectFailed > 0))
        .set("results", results);
    if (stopReason != null) {
      data.set("stopReason", stopReason);
    }
    if (jobId != null) {
      data.set("jobId", jobId);
    }
    if (failed == 0 && expectFailed == 0) {
      return RespBodyVo.ok(data);
    }
    RespBodyVo resp = RespBodyVo.fail(failed > 0 ? firstFailure : firstExpectFailure);
    if (failed == 0) {
      // 命令都执行了,只是断言没通过:回执要如实说明,别让调用方以为动作失败
      data.set("note", "命令本身都执行成功,是 expect 断言没通过(动作发了、页面状态没变成期望的样子)");
    }
    resp.setData(data);
    return resp;
  }
```

:::

::: details 展开配方执行

源码：`playwright-server/src/main/java/nexus/io/ai/browser/actions/registry/CommandTable.java`。以下为当前实现，可放回原类中阅读；依赖同类字段和辅助方法，并非独立编译单元。

```java
  private static RespBodyVo runRecipe(Long id, String name, JSONObject vars, Boolean stopOnError) {
    Kv recipe = RecipeStore.load(name);
    if (recipe == null) {
      List<Kv> available = RecipeStore.list();
      StringBuilder hint = new StringBuilder();
      for (Kv item : available) {
        if (hint.length() > 0) {
          hint.append(" / ");
        }
        hint.append(item.getStr("name"));
      }
      return RespBodyVo.fail("没有这个配方：" + name + (hint.length() == 0
          ? "（配方目录里没有可用配方,见 get_config 的 recipesDir / list_recipes）"
          : "，可用配方：" + hint));
    }
    JSONArray commands = RecipeStore.applyVars((JSONArray) recipe.get("commands"),
        RecipeStore.mergeVars((JSONObject) recipe.get("defaults"), vars));
    JSONObject params = new JSONObject();
    params.put("commands", commands);
    params.put("stopOnError", stopOnError == null || stopOnError);
    RespBodyVo result = nexus.io.jfinal.aop.Aop.get(nexus.io.ai.browser.service.ActionService.class)
        .batchExecute(id, params);
    Object data = result.getData();
    Kv merged = data instanceof Kv ? (Kv) data : Kv.by("result", data);
    merged.set("recipe", recipe.getStr("name"));
    if (recipe.get("description") != null) {
      merged.set("recipeDescription", recipe.get("description"));
    }
    result.setData(merged);
    return result;
  }
```

:::
