@qwen-code/arena-agent 0.0.9-image-input-fallback.0 → 0.0.10-dev.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/.env.example +34 -13
  2. package/README.md +156 -53
  3. package/dist/agent.d.ts +4 -2
  4. package/dist/agent.js +142 -20
  5. package/dist/agent.js.map +1 -1
  6. package/dist/anthropic.d.ts +1 -0
  7. package/dist/anthropic.js +78 -19
  8. package/dist/anthropic.js.map +1 -1
  9. package/dist/canonical-data.js +2 -2
  10. package/dist/canonical-data.js.map +1 -1
  11. package/dist/cli.js +44 -13
  12. package/dist/cli.js.map +1 -1
  13. package/dist/code-tools.js +18 -161
  14. package/dist/code-tools.js.map +1 -1
  15. package/dist/compaction.js +8 -2
  16. package/dist/compaction.js.map +1 -1
  17. package/dist/config.d.ts +22 -0
  18. package/dist/config.js +53 -1
  19. package/dist/config.js.map +1 -1
  20. package/dist/history.d.ts +3 -2
  21. package/dist/history.js +7 -2
  22. package/dist/history.js.map +1 -1
  23. package/dist/image.d.ts +2 -0
  24. package/dist/image.js +88 -7
  25. package/dist/image.js.map +1 -1
  26. package/dist/model-capabilities.js +12 -2
  27. package/dist/model-capabilities.js.map +1 -1
  28. package/dist/model-diagnostics.js +11 -3
  29. package/dist/model-diagnostics.js.map +1 -1
  30. package/dist/model-router-media.d.ts +26 -0
  31. package/dist/model-router-media.js +274 -0
  32. package/dist/model-router-media.js.map +1 -0
  33. package/dist/openai-responses.d.ts +28 -0
  34. package/dist/openai-responses.js +473 -0
  35. package/dist/openai-responses.js.map +1 -0
  36. package/dist/process-manager.d.ts +38 -0
  37. package/dist/process-manager.js +486 -0
  38. package/dist/process-manager.js.map +1 -0
  39. package/dist/prompts.js +3 -1
  40. package/dist/prompts.js.map +1 -1
  41. package/dist/provider.d.ts +7 -0
  42. package/dist/provider.js +259 -14
  43. package/dist/provider.js.map +1 -1
  44. package/dist/server.d.ts +2 -0
  45. package/dist/server.js +37 -18
  46. package/dist/server.js.map +1 -1
  47. package/dist/spec.js +15 -1
  48. package/dist/spec.js.map +1 -1
  49. package/dist/tools.js +160 -237
  50. package/dist/tools.js.map +1 -1
  51. package/dist/tui.d.ts +2 -0
  52. package/dist/tui.js +15 -11
  53. package/dist/tui.js.map +1 -1
  54. package/dist/types.d.ts +7 -0
  55. package/dist/voice-registry.d.ts +30 -0
  56. package/dist/voice-registry.js +82 -0
  57. package/dist/voice-registry.js.map +1 -0
  58. package/dist/web/app.js +8 -0
  59. package/dist/web/app.js.map +1 -1
  60. package/docs/SDK.md +4 -3
  61. package/package.json +7 -2
  62. package/web/styles.css +6 -0
  63. package/dist/audit.d.ts +0 -14
  64. package/dist/audit.js +0 -31
  65. package/dist/audit.js.map +0 -1
  66. package/dist/dashscope.d.ts +0 -5
  67. package/dist/dashscope.js +0 -55
  68. package/dist/dashscope.js.map +0 -1
  69. package/dist/fetch-page/aparat.d.ts +0 -30
  70. package/dist/fetch-page/aparat.js +0 -209
  71. package/dist/fetch-page/aparat.js.map +0 -1
  72. package/dist/fetch-page/json-path.d.ts +0 -12
  73. package/dist/fetch-page/json-path.js +0 -111
  74. package/dist/fetch-page/json-path.js.map +0 -1
  75. package/dist/fetch-page/sites.d.ts +0 -26
  76. package/dist/fetch-page/sites.js +0 -39
  77. package/dist/fetch-page/sites.js.map +0 -1
  78. package/dist/fetch-page/text.d.ts +0 -5
  79. package/dist/fetch-page/text.js +0 -38
  80. package/dist/fetch-page/text.js.map +0 -1
  81. package/dist/fetch-page/types.d.ts +0 -17
  82. package/dist/fetch-page/types.js +0 -3
  83. package/dist/fetch-page/types.js.map +0 -1
  84. package/dist/fetch-page/youtube.d.ts +0 -35
  85. package/dist/fetch-page/youtube.js +0 -282
  86. package/dist/fetch-page/youtube.js.map +0 -1
  87. package/dist/web-search.d.ts +0 -5
  88. package/dist/web-search.js +0 -465
  89. package/dist/web-search.js.map +0 -1
package/dist/tools.js CHANGED
@@ -2,7 +2,6 @@ import { spawn } from "node:child_process";
2
2
  import { existsSync, mkdirSync, realpathSync, } from "node:fs";
3
3
  import { open, readFile, realpath, stat, writeFile, } from "node:fs/promises";
4
4
  import path from "node:path";
5
- import { setTimeout as delay } from "node:timers/promises";
6
5
  import { Ajv } from "ajv";
7
6
  import addFormats from "ajv-formats";
8
7
  import { detect as detectEncoding } from "chardet";
@@ -14,12 +13,15 @@ import { CodeToolExecutor } from "./code-tools.js";
14
13
  import { supportsImageInput } from "./config.js";
15
14
  import { extractStructuredDocument, isStructuredDocument, } from "./document-reader.js";
16
15
  import { FetchPageReader } from "./fetch-page.js";
17
- import { prepareImageForModel } from "./image.js";
16
+ import { generatedImageBytesForExtension, prepareImageForModel, } from "./image.js";
18
17
  import { imageTextFromFallbackModel } from "./image-input-fallback.js";
19
18
  import { isCodeArenaMode } from "./mode.js";
19
+ import { generateImageWithModelRouter, generateSpeechWithModelRouter, } from "./model-router-media.js";
20
+ import { processManagerForWorkspace, } from "./process-manager.js";
20
21
  import { HttpModelClient } from "./provider.js";
21
22
  import { RoutifyFetchPageReader, routifyWebFetchEnabled, } from "./routify-web-fetch.js";
22
23
  import { toolDefinitions } from "./spec.js";
24
+ import { voiceRegistryForWorkspace, } from "./voice-registry.js";
23
25
  const knownTextApplicationMimeTypes = new Set([
24
26
  "application/ecmascript",
25
27
  "application/javascript",
@@ -361,58 +363,6 @@ function record(value) {
361
363
  function stringValue(value) {
362
364
  return typeof value === "string" ? value : "";
363
365
  }
364
- function dashScopeApiKey() {
365
- const apiKey = process.env.DASHSCOPE_API_KEY;
366
- if (!apiKey) {
367
- throw new Error("DashScope tools require DASHSCOPE_API_KEY");
368
- }
369
- return apiKey;
370
- }
371
- function dashScopeRoot() {
372
- return (process.env.DASHSCOPE_API_BASE ||
373
- process.env.DASHSCOPE_BASE_URL ||
374
- "https://dashscope.aliyuncs.com")
375
- .replace(/\/+$/, "")
376
- .replace(/\/(?:api\/v1|compatible-mode\/v1)$/i, "");
377
- }
378
- function dashScopeUrl(protocol, pathname) {
379
- const prefix = protocol === "api" ? "/api/v1" : "/compatible-mode/v1";
380
- const normalized = pathname.startsWith("/") ? pathname : `/${pathname}`;
381
- return `${dashScopeRoot()}${prefix}${normalized}`;
382
- }
383
- async function dashScopeFetch(protocol, pathname, init, label) {
384
- const headers = new Headers(init.headers);
385
- headers.set("authorization", `Bearer ${dashScopeApiKey()}`);
386
- const response = await fetch(dashScopeUrl(protocol, pathname), {
387
- ...init,
388
- headers,
389
- });
390
- if (response.ok)
391
- return response;
392
- const body = await response.text();
393
- let detail = body;
394
- try {
395
- const payload = record(JSON.parse(body));
396
- detail =
397
- stringValue(payload.message) ||
398
- stringValue(record(payload.error).message) ||
399
- body;
400
- }
401
- catch {
402
- // Keep the response text as the diagnostic.
403
- }
404
- throw new Error(`${label} failed (${response.status} ${response.statusText}): ${detail.slice(0, 2000)}`);
405
- }
406
- async function dashScopeJson(protocol, pathname, init, label) {
407
- const response = await dashScopeFetch(protocol, pathname, init, label);
408
- return record(await response.json());
409
- }
410
- function responseMessage(payload) {
411
- return (stringValue(payload.message) ||
412
- stringValue(record(payload.output).message) ||
413
- stringValue(payload.code) ||
414
- "unknown provider error");
415
- }
416
366
  async function responseBytes(response, limit = 4 * 1024 * 1024) {
417
367
  const bytes = Buffer.from(await response.arrayBuffer());
418
368
  if (bytes.length > limit) {
@@ -432,16 +382,20 @@ class ArenaToolExecutor {
432
382
  workspaceContext;
433
383
  pageReader;
434
384
  routifyPageReader;
385
+ processManager;
386
+ voiceRegistry;
435
387
  ajv = new Ajv({ allErrors: true, strict: false });
436
388
  // ask_user needs a handler: TUI/WebUI collect real user input, while
437
- // headless `-p` may delegate to a configured remote User Agent. Without a
438
- // handler the tool is withheld rather than dead-ending on needs_input.
389
+ // headless `-p` may delegate to a configured remote User Agent. add_voice
390
+ // remains available without one and deterministically registers a fallback.
439
391
  askUserEnabled;
440
392
  constructor(config, options = {}) {
441
393
  this.config = config;
442
394
  this.workspace = path.resolve(config.workspace);
443
395
  mkdirSync(this.workspace, { recursive: true });
444
396
  this.workspaceReal = realpathSync(this.workspace);
397
+ this.processManager = processManagerForWorkspace(this.workspaceReal);
398
+ this.voiceRegistry = voiceRegistryForWorkspace(this.workspaceReal);
445
399
  this.askUserHandler = options.askUserHandler;
446
400
  this.askUserEnabled = Boolean(options.askUserHandler);
447
401
  if (config.imageInputFallbackModel?.trim()) {
@@ -461,7 +415,7 @@ class ArenaToolExecutor {
461
415
  }
462
416
  // The tool schemas offered to the model for this session. Identical to the
463
417
  // canonical trace set, adjusted for the selected workspace runtime and minus
464
- // ask_user when no handler is wired.
418
+ // ask_user when no handler is wired. add_voice stays available headlessly.
465
419
  availableTools() {
466
420
  let definitions = toolDefinitions(this.workspaceContext);
467
421
  if (!this.askUserEnabled) {
@@ -557,6 +511,8 @@ class ArenaToolExecutor {
557
511
  : "/home/user")), Number(values.timeout || 30));
558
512
  case "ask_user":
559
513
  return this.askUser(values.questions, toolCallId, context);
514
+ case "add_voice":
515
+ return this.addVoice(String(values.text), String(values.language), values.voice_identity, toolCallId, context);
560
516
  case "edit_file":
561
517
  return this.editFile(String(values.path), String(values.old_text), String(values.new_text));
562
518
  case "read_file":
@@ -573,10 +529,28 @@ class ArenaToolExecutor {
573
529
  return this.imageSearch(String(values.query), Number(values.count));
574
530
  case "present_file":
575
531
  return this.presentFile(String(values.path));
532
+ case "stop_process":
533
+ return this.processManager.stop(String(values.process_id));
534
+ case "start_process":
535
+ return this.startProcess(String(values.name || ""), String(values.command), String(values.cwd ||
536
+ (this.workspaceContext
537
+ ? path.resolve(this.workspaceContext.cwd)
538
+ : "/home/user")), Number(values.startup_wait || 5));
576
539
  case "generate_image":
577
540
  return this.generateImage(String(values.file_path), String(values.prompt), values.images);
578
541
  case "generate_speech":
579
- return this.generateSpeech(String(values.file_path), String(values.text), String(values.language), values.voice_identity);
542
+ return this.generateSpeech(String(values.file_path), String(values.text), String(values.voice_id), values.language === undefined ? undefined : String(values.language));
543
+ case "get_process_output":
544
+ return this.processManager.get({
545
+ processId: String(values.process_id),
546
+ tailLines: Number(values.tail_lines || 200),
547
+ waitFor: values.wait_for,
548
+ waitPattern: values.wait_pattern === undefined
549
+ ? undefined
550
+ : String(values.wait_pattern),
551
+ waitTimeoutSeconds: Number(values.wait_timeout || 60),
552
+ signal: this.signal,
553
+ });
580
554
  default:
581
555
  throw new Error(`unknown tool: ${name}`);
582
556
  }
@@ -585,12 +559,15 @@ class ArenaToolExecutor {
585
559
  if (!raw)
586
560
  throw new Error("path must not be empty");
587
561
  let candidate;
588
- if (!this.workspaceContext && (raw === "/home/user" || raw === "~")) {
562
+ if (raw === "/home/user") {
589
563
  candidate = this.workspace;
590
564
  }
591
- else if (!this.workspaceContext && raw.startsWith("/home/user/")) {
565
+ else if (raw.startsWith("/home/user/")) {
592
566
  candidate = path.join(this.workspace, raw.slice("/home/user/".length));
593
567
  }
568
+ else if (!this.workspaceContext && raw === "~") {
569
+ candidate = this.workspace;
570
+ }
594
571
  else if (!this.workspaceContext && raw.startsWith("~/")) {
595
572
  candidate = path.join(this.workspace, raw.slice(2));
596
573
  }
@@ -636,6 +613,17 @@ class ArenaToolExecutor {
636
613
  }
637
614
  return virtualized;
638
615
  }
616
+ sandboxEnvironment() {
617
+ const env = {
618
+ ...process.env,
619
+ ARENA_WORKSPACE: this.workspace,
620
+ LANG: "C.UTF-8",
621
+ LC_ALL: "C.UTF-8",
622
+ };
623
+ delete env.FORCE_COLOR;
624
+ delete env.CLICOLOR_FORCE;
625
+ return env;
626
+ }
639
627
  async bash(command, cwd, timeoutSeconds) {
640
628
  const workdir = await this.confinedPath(cwd);
641
629
  if (!(await stat(workdir)).isDirectory()) {
@@ -647,18 +635,7 @@ class ArenaToolExecutor {
647
635
  ? command
648
636
  : command.replaceAll("/home/user", "\"$ARENA_WORKSPACE\"");
649
637
  return new Promise((resolve, reject) => {
650
- const env = {
651
- ...process.env,
652
- ARENA_WORKSPACE: this.workspace,
653
- // 生产 sandbox 是 C collation(参考 find|sort 为逐字节序),宿主
654
- // 常见 en_US.UTF-8 会翻转大小写次序;统一成 C.UTF-8 对齐生产。
655
- LANG: "C.UTF-8",
656
- LC_ALL: "C.UTF-8",
657
- };
658
- // 交互终端(node --test、npm 等)会向子进程注入强制着色变量,
659
- // 管道输出被 ANSI 色码污染;生产轨迹无色码,剥离之。
660
- delete env.FORCE_COLOR;
661
- delete env.CLICOLOR_FORCE;
638
+ const env = this.sandboxEnvironment();
662
639
  // detached:管道成员(npm、tail…)与 bash 同进程组。只杀 bash 会留下
663
640
  // 孤儿持有 stdout/stderr 管道,close 永不触发,整个 turn 挂死;
664
641
  // 必须杀整组,并在宽限期后强制结算兜底。
@@ -673,7 +650,6 @@ class ArenaToolExecutor {
673
650
  let stderr = "";
674
651
  let timedOut = false;
675
652
  let settled = false;
676
- let killRequested = false;
677
653
  let forceSettle;
678
654
  const killTree = (signal) => {
679
655
  try {
@@ -692,33 +668,26 @@ class ArenaToolExecutor {
692
668
  throw error;
693
669
  }
694
670
  };
695
- const finish = (code) => {
671
+ const finish = (code, signal) => {
696
672
  if (settled)
697
673
  return;
698
674
  settled = true;
699
675
  clearTimeout(timeout);
700
676
  if (forceSettle)
701
677
  clearTimeout(forceSettle);
702
- // 组长退出会立刻触发 close 并取消宽限定时器;取消/超时一旦请求过
703
- // 击杀,就必须在结算时补一发组级 SIGKILL,否则忽略 TERM 且关闭了
704
- // 继承管道的后台子进程会活过取消。正常退出不清场——模型可能有意
705
- // 留下后台服务供后续调用使用。
706
- if (killRequested)
707
- killTree("SIGKILL");
678
+ // bash is one-shot in v2. Any descendant left in the process group is
679
+ // cleaned up even when the shell itself exited normally; persistent
680
+ // servers belong in start_process.
681
+ killTree("SIGKILL");
708
682
  this.signal?.removeEventListener("abort", abort);
709
683
  if (this.signal?.aborted) {
710
684
  reject(this.signal.reason || new Error("operation cancelled"));
711
685
  return;
712
686
  }
713
- // 生产契约:status 恒为 "completed"(包括非零退出码),超时无参考样本,
714
- // 按最小猜测同样返回 completed + exit_code null,stderr 附超时说明。
715
- if (timedOut) {
716
- stderr += (stderr ? "\n" : "") + `command exceeded ${timeoutSeconds}s`;
717
- }
718
687
  const out = capBashOutput(this.virtualizeBashOutput(stdout));
719
688
  const err = capBashOutput(this.virtualizeBashOutput(stderr));
720
689
  resolve({
721
- status: "completed",
690
+ status: timedOut ? "timeout" : signal ? "killed" : "completed",
722
691
  stderr: err.text,
723
692
  stdout: out.text,
724
693
  exit_code: timedOut ? null : code,
@@ -732,17 +701,15 @@ class ArenaToolExecutor {
732
701
  const armForceSettle = () => {
733
702
  forceSettle ??= setTimeout(() => {
734
703
  killTree("SIGKILL");
735
- finish(null);
704
+ finish(null, "SIGKILL");
736
705
  }, 2000);
737
706
  };
738
707
  const timeout = setTimeout(() => {
739
708
  timedOut = true;
740
- killRequested = true;
741
709
  killTree("SIGKILL");
742
710
  armForceSettle();
743
711
  }, timeoutSeconds * 1000);
744
712
  const abort = () => {
745
- killRequested = true;
746
713
  killTree("SIGTERM");
747
714
  armForceSettle();
748
715
  };
@@ -763,7 +730,24 @@ class ArenaToolExecutor {
763
730
  this.signal?.removeEventListener("abort", abort);
764
731
  reject(error);
765
732
  });
766
- child.once("close", (code) => finish(code));
733
+ child.once("close", (code, signal) => finish(code, signal));
734
+ });
735
+ }
736
+ async startProcess(name, command, cwd, startupWaitSeconds) {
737
+ const workdir = await this.confinedPath(cwd);
738
+ if (!(await stat(workdir)).isDirectory()) {
739
+ throw new Error(`cwd is not a directory: ${cwd}`);
740
+ }
741
+ const translatedCommand = this.workspaceContext
742
+ ? command
743
+ : command.replaceAll("/home/user", '"$ARENA_WORKSPACE"');
744
+ return this.processManager.start({
745
+ name,
746
+ command: translatedCommand,
747
+ cwd: workdir,
748
+ startupWaitSeconds,
749
+ env: this.sandboxEnvironment(),
750
+ signal: this.signal,
767
751
  });
768
752
  }
769
753
  async askUser(questions, toolCallId, context) {
@@ -783,6 +767,58 @@ class ArenaToolExecutor {
783
767
  "Proceed with reasonable defaults and state any assumptions you make.",
784
768
  };
785
769
  }
770
+ async addVoice(text, language, voiceIdentity = {}, toolCallId, context) {
771
+ if (!this.askUserHandler) {
772
+ const selection = this.voiceRegistry.registerVirtual(language, voiceIdentity);
773
+ return {
774
+ status: "success",
775
+ message: `Battle "${selection.battleId}": no interactive voice picker was ` +
776
+ `available, so a default voice was selected. Pass voice_id ` +
777
+ `"${selection.voiceId}" to generate_speech to speak with it.`,
778
+ voice_id: selection.voiceId,
779
+ battle_id: selection.battleId,
780
+ virtual: true,
781
+ };
782
+ }
783
+ const battle = await this.voiceRegistry.audition(text, language, voiceIdentity, this.signal);
784
+ const response = await this.askUserHandler(toolCallId, [
785
+ {
786
+ id: `voice-battle-${battle.battleId}`,
787
+ question: "Which voice should be used for this speaker?",
788
+ options: battle.candidates.map((candidate) => ({
789
+ id: candidate.id,
790
+ label: candidate.label,
791
+ description: "Play the audition, then select this voice.",
792
+ audioDataUrl: candidate.audioDataUrl,
793
+ })),
794
+ allowCustomResponse: false,
795
+ },
796
+ ], context
797
+ ? {
798
+ ...context,
799
+ signal: this.signal || context.signal,
800
+ }
801
+ : undefined);
802
+ if (response.status === "needs_input")
803
+ return response;
804
+ if (response.skipped === true) {
805
+ return { status: "error", message: "The user skipped voice selection." };
806
+ }
807
+ const answers = Array.isArray(response.answers) ? response.answers : [];
808
+ const selectedOptionId = record(answers[0]).selectedOptionId;
809
+ if (typeof selectedOptionId !== "string") {
810
+ throw new Error("voice selection did not choose a candidate");
811
+ }
812
+ const voiceId = this.voiceRegistry.register(battle, selectedOptionId);
813
+ return {
814
+ status: "success",
815
+ message: `Battle "${battle.battleId}": the user selected a voice. ` +
816
+ `Pass voice_id "${voiceId}" to generate_speech to speak with it. ` +
817
+ "Continue the task now — if you need another voice, call add_voice again immediately.",
818
+ voice_id: voiceId,
819
+ battle_id: battle.battleId,
820
+ };
821
+ }
786
822
  async editFile(rawPath, oldText, newText) {
787
823
  const target = await this.confinedPath(rawPath);
788
824
  const source = await readFile(target, "utf8");
@@ -1012,13 +1048,13 @@ class ArenaToolExecutor {
1012
1048
  }
1013
1049
  async generateImage(rawPath, prompt, images) {
1014
1050
  const target = await this.confinedPath(rawPath);
1015
- if (![".jpg", ".jpeg", ".png"].includes(path.extname(target).toLowerCase())) {
1051
+ const extension = path.extname(target).toLowerCase();
1052
+ if (![".jpg", ".jpeg", ".png"].includes(extension)) {
1016
1053
  throw new Error("file_path must end in .jpg, .jpeg, or .png");
1017
1054
  }
1018
1055
  mkdirSync(path.dirname(target), { recursive: true });
1019
- const model = process.env.ARENA_IMAGE_MODEL || "qwen-image-2.0-pro";
1020
- const isQwenImage2 = model.startsWith("qwen-image-2.0");
1021
- const content = [];
1056
+ const model = process.env.ARENA_IMAGE_MODEL || "gemini-2.5-flash-image";
1057
+ const references = [];
1022
1058
  for (const imagePath of images || []) {
1023
1059
  const source = await this.confinedPath(imagePath);
1024
1060
  const extension = path.extname(source).toLowerCase();
@@ -1030,169 +1066,56 @@ class ArenaToolExecutor {
1030
1066
  if (bytes.length > 20 * 1024 * 1024) {
1031
1067
  throw new Error(`reference image exceeds 20 MB: ${imagePath}`);
1032
1068
  }
1033
- content.push({
1034
- image: `data:${mimeType};base64,${bytes.toString("base64")}`,
1069
+ references.push({
1070
+ bytes,
1071
+ fileName: path.basename(source),
1072
+ mimeType,
1035
1073
  });
1036
1074
  }
1037
- content.push({ text: prompt });
1038
- const parameters = {
1039
- size: process.env.ARENA_IMAGE_SIZE ||
1040
- (isQwenImage2 ? "2048*2048" : "2K"),
1041
- watermark: false,
1042
- };
1043
- if (isQwenImage2) {
1044
- parameters.prompt_extend = true;
1045
- }
1046
- else {
1047
- parameters.n = 1;
1048
- if (!images?.length)
1049
- parameters.thinking_mode = true;
1050
- }
1051
- const headers = {
1052
- "content-type": "application/json",
1053
- };
1054
- if (!isQwenImage2)
1055
- headers["x-dashscope-async"] = "enable";
1056
- const submitted = await dashScopeJson("api", isQwenImage2
1057
- ? "/services/aigc/multimodal-generation/generation"
1058
- : "/services/aigc/image-generation/generation", {
1059
- method: "POST",
1060
- headers,
1061
- body: JSON.stringify({
1062
- model,
1063
- input: {
1064
- messages: [{ role: "user", content }],
1065
- },
1066
- parameters,
1067
- }),
1075
+ const generated = await generateImageWithModelRouter({
1076
+ images: references,
1077
+ model,
1078
+ prompt,
1068
1079
  signal: this.signal,
1069
- }, "generate_image");
1070
- let completed = isQwenImage2
1071
- ? submitted
1072
- : undefined;
1073
- if (!isQwenImage2) {
1074
- const taskId = stringValue(record(submitted.output).task_id);
1075
- if (!taskId) {
1076
- throw new Error(`generate_image returned no task_id: ${responseMessage(submitted)}`);
1077
- }
1078
- const timeoutMs = Number(process.env.DASHSCOPE_TASK_TIMEOUT_MS || 5 * 60 * 1000);
1079
- const pollIntervalMs = Number(process.env.DASHSCOPE_TASK_POLL_INTERVAL_MS || 1000);
1080
- const deadline = Date.now() + timeoutMs;
1081
- while (Date.now() <= deadline) {
1082
- const task = await dashScopeJson("api", `/tasks/${encodeURIComponent(taskId)}`, { method: "GET", signal: this.signal }, "generate_image status");
1083
- const output = record(task.output);
1084
- const taskStatus = stringValue(output.task_status).toUpperCase();
1085
- if (taskStatus === "SUCCEEDED") {
1086
- completed = task;
1087
- break;
1088
- }
1089
- if (["FAILED", "CANCELED", "UNKNOWN"].includes(taskStatus)) {
1090
- throw new Error(`generate_image task ${taskStatus}: ${responseMessage(task)}`);
1091
- }
1092
- if (Date.now() + pollIntervalMs > deadline)
1093
- break;
1094
- await delay(pollIntervalMs, undefined, { signal: this.signal });
1095
- }
1096
- if (!completed) {
1097
- throw new Error(`generate_image timed out waiting for task ${taskId}`);
1098
- }
1099
- }
1100
- if (!completed) {
1101
- throw new Error("generate_image returned no response");
1102
- }
1103
- const output = record(completed.output);
1104
- const choices = Array.isArray(output.choices) ? output.choices : [];
1105
- const message = record(record(choices[0]).message);
1106
- const generatedContent = Array.isArray(message.content)
1107
- ? message.content
1108
- : [];
1109
- const generated = generatedContent
1110
- .map(record)
1111
- .find((item) => typeof item.image === "string");
1112
- const legacyResults = Array.isArray(output.results) ? output.results : [];
1113
- const imageUrl = stringValue(generated?.image) ||
1114
- stringValue(record(legacyResults[0]).url);
1115
- if (!imageUrl) {
1116
- throw new Error("generate_image response did not include an image URL");
1117
- }
1118
- const imageResponse = await fetch(imageUrl, { signal: this.signal });
1119
- if (!imageResponse.ok) {
1120
- throw new Error(`image download failed (${imageResponse.status})`);
1121
- }
1122
- await writeFile(target, await responseBytes(imageResponse, 25 * 1024 * 1024));
1080
+ size: process.env.ARENA_IMAGE_SIZE || "1024*1024",
1081
+ });
1082
+ await writeFile(target, await generatedImageBytesForExtension(generated.bytes, generated.mimeType, extension));
1123
1083
  return {
1124
1084
  status: "success",
1125
1085
  file_path: this.relativePath(target),
1126
1086
  };
1127
1087
  }
1128
- async generateSpeech(rawPath, text, language, voiceIdentity = {}) {
1088
+ async generateSpeech(rawPath, text, voiceId, language) {
1129
1089
  const target = await this.confinedPath(rawPath);
1130
1090
  const extension = path.extname(target).toLowerCase();
1131
1091
  const format = {
1132
1092
  ".mp3": "mp3",
1133
1093
  ".wav": "wav",
1094
+ ".ogg": "opus",
1134
1095
  ".opus": "opus",
1135
1096
  }[extension];
1136
1097
  if (!format) {
1137
- throw new Error("DashScope speech output supports .mp3, .wav, or .opus");
1098
+ throw new Error("ModelRouter Doubao TTS output supports .mp3, .wav, .ogg, or .opus");
1138
1099
  }
1139
1100
  mkdirSync(path.dirname(target), { recursive: true });
1140
- const model = process.env.ARENA_SPEECH_MODEL || "qwen-audio-3.0-tts-flash";
1141
- const feminine = voiceIdentity.gender !== "masculine";
1142
- const primaryLanguage = language.toLowerCase().split(/[-_]/)[0] || "";
1143
- const voices = primaryLanguage === "en"
1144
- ? feminine
1145
- ? ["loongeva_v3.6", "longanhuan_v3.6"]
1146
- : ["loongjohn", "longjielidou_v3.6"]
1147
- : feminine
1148
- ? ["longanhuan_v3.6"]
1149
- : ["longjielidou_v3.6"];
1150
- const requestedIndex = Math.max(0, Math.trunc(Number(voiceIdentity.index || 0)));
1151
- const voice = process.env.DASHSCOPE_SPEECH_VOICE ||
1152
- voices[requestedIndex % voices.length] ||
1153
- voices[0];
1154
- const supportedHints = new Set([
1155
- "ar",
1156
- "de",
1157
- "en",
1158
- "es",
1159
- "fil",
1160
- "fr",
1161
- "id",
1162
- "it",
1163
- "ja",
1164
- "ko",
1165
- "ms",
1166
- "pt",
1167
- "ru",
1168
- "th",
1169
- "vi",
1170
- "zh",
1171
- ]);
1172
- const input = {
1173
- text,
1174
- voice,
1175
- format,
1176
- sample_rate: 24000,
1177
- };
1178
- if (supportedHints.has(primaryLanguage)) {
1179
- input.language_hints = [primaryLanguage];
1180
- }
1181
- const payload = await dashScopeJson("api", "/services/audio/tts/SpeechSynthesizer", {
1182
- method: "POST",
1183
- headers: { "content-type": "application/json" },
1184
- body: JSON.stringify({ model, input }),
1185
- signal: this.signal,
1186
- }, "generate_speech");
1187
- const audioUrl = stringValue(record(record(payload.output).audio).url);
1188
- if (!audioUrl) {
1189
- throw new Error(`generate_speech returned no audio URL: ${responseMessage(payload)}`);
1101
+ const registered = this.voiceRegistry.resolve(voiceId);
1102
+ if (!registered) {
1103
+ throw new Error(`unknown voice_id: ${voiceId}. Call add_voice and use the returned voice_id.`);
1190
1104
  }
1191
- const audioResponse = await fetch(audioUrl, { signal: this.signal });
1192
- if (!audioResponse.ok) {
1193
- throw new Error(`audio download failed (${audioResponse.status})`);
1105
+ if (language &&
1106
+ language.toLowerCase().split(/[-_]/)[0] !==
1107
+ registered.language.toLowerCase().split(/[-_]/)[0]) {
1108
+ throw new Error(`voice ${voiceId} was registered for ${registered.language}; ` +
1109
+ `register a voice for ${language} with add_voice`);
1194
1110
  }
1195
- await writeFile(target, await responseBytes(audioResponse, 25 * 1024 * 1024));
1111
+ const voice = process.env.ARENA_SPEECH_VOICE || registered.providerVoice;
1112
+ await writeFile(target, await generateSpeechWithModelRouter({
1113
+ format,
1114
+ model: process.env.ARENA_SPEECH_MODEL || "doubao-tts",
1115
+ signal: this.signal,
1116
+ text,
1117
+ voice,
1118
+ }));
1196
1119
  return {
1197
1120
  status: "success",
1198
1121
  file_path: this.relativePath(target),