@qwen-code/arena-agent 0.0.9-dev.8 → 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 (87) hide show
  1. package/.env.example +54 -19
  2. package/README.md +218 -45
  3. package/dist/agent.d.ts +4 -2
  4. package/dist/agent.js +167 -21
  5. package/dist/agent.js.map +1 -1
  6. package/dist/anthropic.d.ts +1 -0
  7. package/dist/anthropic.js +90 -23
  8. package/dist/anthropic.js.map +1 -1
  9. package/dist/attachments.d.ts +1 -1
  10. package/dist/attachments.js +16 -4
  11. package/dist/attachments.js.map +1 -1
  12. package/dist/brave-search.d.ts +10 -0
  13. package/dist/brave-search.js +238 -0
  14. package/dist/brave-search.js.map +1 -0
  15. package/dist/canonical-data.js +2 -2
  16. package/dist/canonical-data.js.map +1 -1
  17. package/dist/cli.js +64 -18
  18. package/dist/cli.js.map +1 -1
  19. package/dist/code-tools.d.ts +0 -5
  20. package/dist/code-tools.js +29 -353
  21. package/dist/code-tools.js.map +1 -1
  22. package/dist/compaction.js +8 -2
  23. package/dist/compaction.js.map +1 -1
  24. package/dist/config.d.ts +30 -0
  25. package/dist/config.js +57 -3
  26. package/dist/config.js.map +1 -1
  27. package/dist/fetch-page.d.ts +1 -0
  28. package/dist/fetch-page.js +1 -1
  29. package/dist/fetch-page.js.map +1 -1
  30. package/dist/history.d.ts +3 -2
  31. package/dist/history.js +7 -2
  32. package/dist/history.js.map +1 -1
  33. package/dist/image-input-fallback.d.ts +9 -0
  34. package/dist/image-input-fallback.js +47 -0
  35. package/dist/image-input-fallback.js.map +1 -0
  36. package/dist/image.d.ts +16 -0
  37. package/dist/image.js +380 -0
  38. package/dist/image.js.map +1 -0
  39. package/dist/mock.d.ts +1 -0
  40. package/dist/mock.js +38 -0
  41. package/dist/mock.js.map +1 -1
  42. package/dist/model-capabilities.js +12 -2
  43. package/dist/model-capabilities.js.map +1 -1
  44. package/dist/model-diagnostics.js +16 -5
  45. package/dist/model-diagnostics.js.map +1 -1
  46. package/dist/model-router-media.d.ts +26 -0
  47. package/dist/model-router-media.js +274 -0
  48. package/dist/model-router-media.js.map +1 -0
  49. package/dist/openai-responses.d.ts +28 -0
  50. package/dist/openai-responses.js +473 -0
  51. package/dist/openai-responses.js.map +1 -0
  52. package/dist/process-manager.d.ts +38 -0
  53. package/dist/process-manager.js +486 -0
  54. package/dist/process-manager.js.map +1 -0
  55. package/dist/prompts.js +3 -1
  56. package/dist/prompts.js.map +1 -1
  57. package/dist/provider.d.ts +7 -0
  58. package/dist/provider.js +291 -27
  59. package/dist/provider.js.map +1 -1
  60. package/dist/routify-web-fetch.d.ts +17 -0
  61. package/dist/routify-web-fetch.js +188 -0
  62. package/dist/routify-web-fetch.js.map +1 -0
  63. package/dist/sdk.d.ts +9 -2
  64. package/dist/sdk.js +5 -2
  65. package/dist/sdk.js.map +1 -1
  66. package/dist/server.d.ts +3 -0
  67. package/dist/server.js +112 -21
  68. package/dist/server.js.map +1 -1
  69. package/dist/spec.js +15 -1
  70. package/dist/spec.js.map +1 -1
  71. package/dist/tools.d.ts +2 -1
  72. package/dist/tools.js +250 -340
  73. package/dist/tools.js.map +1 -1
  74. package/dist/tui.d.ts +3 -0
  75. package/dist/tui.js +18 -11
  76. package/dist/tui.js.map +1 -1
  77. package/dist/types.d.ts +17 -6
  78. package/dist/voice-registry.d.ts +30 -0
  79. package/dist/voice-registry.js +82 -0
  80. package/dist/voice-registry.js.map +1 -0
  81. package/dist/web/app.js +67 -6
  82. package/dist/web/app.js.map +1 -1
  83. package/docs/BINARY_DISTRIBUTION.md +25 -18
  84. package/docs/MODES_AND_RELEASES.md +8 -4
  85. package/docs/SDK.md +28 -4
  86. package/package.json +21 -3
  87. package/web/styles.css +28 -7
package/dist/tools.js CHANGED
@@ -2,19 +2,26 @@ 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";
9
8
  import iconv from "iconv-lite";
10
9
  import mime from "mime/lite";
11
10
  import { AskUserHandlerError } from "./ask-user.js";
11
+ import { executeBraveImageSearch, executeBraveWebSearch, } from "./brave-search.js";
12
12
  import { CodeToolExecutor } from "./code-tools.js";
13
13
  import { supportsImageInput } from "./config.js";
14
14
  import { extractStructuredDocument, isStructuredDocument, } from "./document-reader.js";
15
15
  import { FetchPageReader } from "./fetch-page.js";
16
+ import { generatedImageBytesForExtension, prepareImageForModel, } from "./image.js";
17
+ import { imageTextFromFallbackModel } from "./image-input-fallback.js";
16
18
  import { isCodeArenaMode } from "./mode.js";
19
+ import { generateImageWithModelRouter, generateSpeechWithModelRouter, } from "./model-router-media.js";
20
+ import { processManagerForWorkspace, } from "./process-manager.js";
21
+ import { HttpModelClient } from "./provider.js";
22
+ import { RoutifyFetchPageReader, routifyWebFetchEnabled, } from "./routify-web-fetch.js";
17
23
  import { toolDefinitions } from "./spec.js";
24
+ import { voiceRegistryForWorkspace, } from "./voice-registry.js";
18
25
  const knownTextApplicationMimeTypes = new Set([
19
26
  "application/ecmascript",
20
27
  "application/javascript",
@@ -356,58 +363,6 @@ function record(value) {
356
363
  function stringValue(value) {
357
364
  return typeof value === "string" ? value : "";
358
365
  }
359
- function dashScopeApiKey() {
360
- const apiKey = process.env.DASHSCOPE_API_KEY;
361
- if (!apiKey) {
362
- throw new Error("DashScope tools require DASHSCOPE_API_KEY");
363
- }
364
- return apiKey;
365
- }
366
- function dashScopeRoot() {
367
- return (process.env.DASHSCOPE_API_BASE ||
368
- process.env.DASHSCOPE_BASE_URL ||
369
- "https://dashscope.aliyuncs.com")
370
- .replace(/\/+$/, "")
371
- .replace(/\/(?:api\/v1|compatible-mode\/v1)$/i, "");
372
- }
373
- function dashScopeUrl(protocol, pathname) {
374
- const prefix = protocol === "api" ? "/api/v1" : "/compatible-mode/v1";
375
- const normalized = pathname.startsWith("/") ? pathname : `/${pathname}`;
376
- return `${dashScopeRoot()}${prefix}${normalized}`;
377
- }
378
- async function dashScopeFetch(protocol, pathname, init, label) {
379
- const headers = new Headers(init.headers);
380
- headers.set("authorization", `Bearer ${dashScopeApiKey()}`);
381
- const response = await fetch(dashScopeUrl(protocol, pathname), {
382
- ...init,
383
- headers,
384
- });
385
- if (response.ok)
386
- return response;
387
- const body = await response.text();
388
- let detail = body;
389
- try {
390
- const payload = record(JSON.parse(body));
391
- detail =
392
- stringValue(payload.message) ||
393
- stringValue(record(payload.error).message) ||
394
- body;
395
- }
396
- catch {
397
- // Keep the response text as the diagnostic.
398
- }
399
- throw new Error(`${label} failed (${response.status} ${response.statusText}): ${detail.slice(0, 2000)}`);
400
- }
401
- async function dashScopeJson(protocol, pathname, init, label) {
402
- const response = await dashScopeFetch(protocol, pathname, init, label);
403
- return record(await response.json());
404
- }
405
- function responseMessage(payload) {
406
- return (stringValue(payload.message) ||
407
- stringValue(record(payload.output).message) ||
408
- stringValue(payload.code) ||
409
- "unknown provider error");
410
- }
411
366
  async function responseBytes(response, limit = 4 * 1024 * 1024) {
412
367
  const bytes = Buffer.from(await response.arrayBuffer());
413
368
  if (bytes.length > limit) {
@@ -422,24 +377,37 @@ class ArenaToolExecutor {
422
377
  validators = new Map();
423
378
  pendingVisualContent = new Map();
424
379
  askUserHandler;
380
+ imageInputFallbackClient;
425
381
  signal;
426
382
  workspaceContext;
427
383
  pageReader;
384
+ routifyPageReader;
385
+ processManager;
386
+ voiceRegistry;
428
387
  ajv = new Ajv({ allErrors: true, strict: false });
429
388
  // ask_user needs a handler: TUI/WebUI collect real user input, while
430
- // headless `-p` may delegate to a configured remote User Agent. Without a
431
- // 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.
432
391
  askUserEnabled;
433
392
  constructor(config, options = {}) {
434
393
  this.config = config;
435
394
  this.workspace = path.resolve(config.workspace);
436
395
  mkdirSync(this.workspace, { recursive: true });
437
396
  this.workspaceReal = realpathSync(this.workspace);
397
+ this.processManager = processManagerForWorkspace(this.workspaceReal);
398
+ this.voiceRegistry = voiceRegistryForWorkspace(this.workspaceReal);
438
399
  this.askUserHandler = options.askUserHandler;
439
400
  this.askUserEnabled = Boolean(options.askUserHandler);
401
+ if (config.imageInputFallbackModel?.trim()) {
402
+ this.imageInputFallbackClient =
403
+ options.modelClient || new HttpModelClient(config);
404
+ }
440
405
  this.signal = options.signal;
441
406
  this.workspaceContext = options.workspaceContext;
442
407
  this.pageReader = new FetchPageReader({ signal: options.signal });
408
+ this.routifyPageReader = new RoutifyFetchPageReader({
409
+ signal: options.signal,
410
+ });
443
411
  addFormats(this.ajv);
444
412
  for (const definition of toolDefinitions(this.workspaceContext)) {
445
413
  this.validators.set(definition.function.name, this.ajv.compile(definition.function.parameters));
@@ -447,7 +415,7 @@ class ArenaToolExecutor {
447
415
  }
448
416
  // The tool schemas offered to the model for this session. Identical to the
449
417
  // canonical trace set, adjusted for the selected workspace runtime and minus
450
- // ask_user when no handler is wired.
418
+ // ask_user when no handler is wired. add_voice stays available headlessly.
451
419
  availableTools() {
452
420
  let definitions = toolDefinitions(this.workspaceContext);
453
421
  if (!this.askUserEnabled) {
@@ -543,12 +511,16 @@ class ArenaToolExecutor {
543
511
  : "/home/user")), Number(values.timeout || 30));
544
512
  case "ask_user":
545
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);
546
516
  case "edit_file":
547
517
  return this.editFile(String(values.path), String(values.old_text), String(values.new_text));
548
518
  case "read_file":
549
519
  return this.readWorkspaceFile(String(values.path), toolCallId);
550
520
  case "fetch_page":
551
- return this.pageReader.read(String(values.url), Number(values.chunkIndex || 0));
521
+ return routifyWebFetchEnabled()
522
+ ? this.routifyPageReader.read(String(values.url), Number(values.chunkIndex || 0))
523
+ : this.pageReader.read(String(values.url), Number(values.chunkIndex || 0));
552
524
  case "web_search":
553
525
  return this.webSearch(String(values.query), String(values.depth));
554
526
  case "write_file":
@@ -557,10 +529,28 @@ class ArenaToolExecutor {
557
529
  return this.imageSearch(String(values.query), Number(values.count));
558
530
  case "present_file":
559
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));
560
539
  case "generate_image":
561
540
  return this.generateImage(String(values.file_path), String(values.prompt), values.images);
562
541
  case "generate_speech":
563
- 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
+ });
564
554
  default:
565
555
  throw new Error(`unknown tool: ${name}`);
566
556
  }
@@ -569,12 +559,15 @@ class ArenaToolExecutor {
569
559
  if (!raw)
570
560
  throw new Error("path must not be empty");
571
561
  let candidate;
572
- if (!this.workspaceContext && (raw === "/home/user" || raw === "~")) {
562
+ if (raw === "/home/user") {
573
563
  candidate = this.workspace;
574
564
  }
575
- else if (!this.workspaceContext && raw.startsWith("/home/user/")) {
565
+ else if (raw.startsWith("/home/user/")) {
576
566
  candidate = path.join(this.workspace, raw.slice("/home/user/".length));
577
567
  }
568
+ else if (!this.workspaceContext && raw === "~") {
569
+ candidate = this.workspace;
570
+ }
578
571
  else if (!this.workspaceContext && raw.startsWith("~/")) {
579
572
  candidate = path.join(this.workspace, raw.slice(2));
580
573
  }
@@ -620,6 +613,17 @@ class ArenaToolExecutor {
620
613
  }
621
614
  return virtualized;
622
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
+ }
623
627
  async bash(command, cwd, timeoutSeconds) {
624
628
  const workdir = await this.confinedPath(cwd);
625
629
  if (!(await stat(workdir)).isDirectory()) {
@@ -631,19 +635,7 @@ class ArenaToolExecutor {
631
635
  ? command
632
636
  : command.replaceAll("/home/user", "\"$ARENA_WORKSPACE\"");
633
637
  return new Promise((resolve, reject) => {
634
- const env = {
635
- ...process.env,
636
- HOME: this.workspace,
637
- ARENA_WORKSPACE: this.workspace,
638
- // 生产 sandbox 是 C collation(参考 find|sort 为逐字节序),宿主
639
- // 常见 en_US.UTF-8 会翻转大小写次序;统一成 C.UTF-8 对齐生产。
640
- LANG: "C.UTF-8",
641
- LC_ALL: "C.UTF-8",
642
- };
643
- // 交互终端(node --test、npm 等)会向子进程注入强制着色变量,
644
- // 管道输出被 ANSI 色码污染;生产轨迹无色码,剥离之。
645
- delete env.FORCE_COLOR;
646
- delete env.CLICOLOR_FORCE;
638
+ const env = this.sandboxEnvironment();
647
639
  // detached:管道成员(npm、tail…)与 bash 同进程组。只杀 bash 会留下
648
640
  // 孤儿持有 stdout/stderr 管道,close 永不触发,整个 turn 挂死;
649
641
  // 必须杀整组,并在宽限期后强制结算兜底。
@@ -658,7 +650,6 @@ class ArenaToolExecutor {
658
650
  let stderr = "";
659
651
  let timedOut = false;
660
652
  let settled = false;
661
- let killRequested = false;
662
653
  let forceSettle;
663
654
  const killTree = (signal) => {
664
655
  try {
@@ -677,33 +668,26 @@ class ArenaToolExecutor {
677
668
  throw error;
678
669
  }
679
670
  };
680
- const finish = (code) => {
671
+ const finish = (code, signal) => {
681
672
  if (settled)
682
673
  return;
683
674
  settled = true;
684
675
  clearTimeout(timeout);
685
676
  if (forceSettle)
686
677
  clearTimeout(forceSettle);
687
- // 组长退出会立刻触发 close 并取消宽限定时器;取消/超时一旦请求过
688
- // 击杀,就必须在结算时补一发组级 SIGKILL,否则忽略 TERM 且关闭了
689
- // 继承管道的后台子进程会活过取消。正常退出不清场——模型可能有意
690
- // 留下后台服务供后续调用使用。
691
- if (killRequested)
692
- 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");
693
682
  this.signal?.removeEventListener("abort", abort);
694
683
  if (this.signal?.aborted) {
695
684
  reject(this.signal.reason || new Error("operation cancelled"));
696
685
  return;
697
686
  }
698
- // 生产契约:status 恒为 "completed"(包括非零退出码),超时无参考样本,
699
- // 按最小猜测同样返回 completed + exit_code null,stderr 附超时说明。
700
- if (timedOut) {
701
- stderr += (stderr ? "\n" : "") + `command exceeded ${timeoutSeconds}s`;
702
- }
703
687
  const out = capBashOutput(this.virtualizeBashOutput(stdout));
704
688
  const err = capBashOutput(this.virtualizeBashOutput(stderr));
705
689
  resolve({
706
- status: "completed",
690
+ status: timedOut ? "timeout" : signal ? "killed" : "completed",
707
691
  stderr: err.text,
708
692
  stdout: out.text,
709
693
  exit_code: timedOut ? null : code,
@@ -717,17 +701,15 @@ class ArenaToolExecutor {
717
701
  const armForceSettle = () => {
718
702
  forceSettle ??= setTimeout(() => {
719
703
  killTree("SIGKILL");
720
- finish(null);
704
+ finish(null, "SIGKILL");
721
705
  }, 2000);
722
706
  };
723
707
  const timeout = setTimeout(() => {
724
708
  timedOut = true;
725
- killRequested = true;
726
709
  killTree("SIGKILL");
727
710
  armForceSettle();
728
711
  }, timeoutSeconds * 1000);
729
712
  const abort = () => {
730
- killRequested = true;
731
713
  killTree("SIGTERM");
732
714
  armForceSettle();
733
715
  };
@@ -748,7 +730,24 @@ class ArenaToolExecutor {
748
730
  this.signal?.removeEventListener("abort", abort);
749
731
  reject(error);
750
732
  });
751
- 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,
752
751
  });
753
752
  }
754
753
  async askUser(questions, toolCallId, context) {
@@ -768,6 +767,58 @@ class ArenaToolExecutor {
768
767
  "Proceed with reasonable defaults and state any assumptions you make.",
769
768
  };
770
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
+ }
771
822
  async editFile(rawPath, oldText, newText) {
772
823
  const target = await this.confinedPath(rawPath);
773
824
  const source = await readFile(target, "utf8");
@@ -827,7 +878,11 @@ class ArenaToolExecutor {
827
878
  if (kind === "image" && fileSize > 20 * 1024 * 1024) {
828
879
  throw new Error(`image exceeds 20 MB: ${rawPath}`);
829
880
  }
830
- if (kind === "image" && !supportsImageInput(this.config)) {
881
+ const mainModelSupportsImages = kind === "image" ? supportsImageInput(this.config) : false;
882
+ const fallbackModel = mainModelSupportsImages
883
+ ? undefined
884
+ : this.config.imageInputFallbackModel?.trim();
885
+ if (kind === "image" && !mainModelSupportsImages && !fallbackModel) {
831
886
  return unsupportedImageResult(path.basename(target));
832
887
  }
833
888
  let data;
@@ -877,9 +932,37 @@ class ArenaToolExecutor {
877
932
  mimeTypes[extension] ||
878
933
  "application/octet-stream";
879
934
  if (kind === "image") {
935
+ const prepared = await prepareImageForModel(data, mimeType);
936
+ if (!prepared.dataUri) {
937
+ return {
938
+ status: "error",
939
+ message: `Image could not be provided to the model: ${rawPath} ` +
940
+ `(${prepared.omittedReason || "exceeds the inline image limits"}).`,
941
+ };
942
+ }
943
+ if (fallbackModel) {
944
+ if (!this.imageInputFallbackClient) {
945
+ throw new Error("fallback image model client is unavailable");
946
+ }
947
+ const content = await imageTextFromFallbackModel(this.imageInputFallbackClient, {
948
+ model: fallbackModel,
949
+ fileName: this.relativePath(target),
950
+ mimeType: prepared.mimeType || mimeType,
951
+ dataUri: prepared.dataUri,
952
+ signal: this.signal,
953
+ });
954
+ return {
955
+ kind: "text",
956
+ source_kind: "image",
957
+ path: this.relativePath(target),
958
+ lines: content.split("\n").length,
959
+ size: Buffer.byteLength(content, "utf8"),
960
+ content,
961
+ };
962
+ }
880
963
  this.pendingVisualContent.set(toolCallId, [
881
964
  {
882
- image: `data:${mimeType};base64,${data.toString("base64")}`,
965
+ image: prepared.dataUri,
883
966
  },
884
967
  ]);
885
968
  return [{ type: "text", text: imageResultPlaceholder }];
@@ -898,82 +981,13 @@ class ArenaToolExecutor {
898
981
  return { status: "success" };
899
982
  }
900
983
  async webSearch(query, depth) {
901
- const model = process.env.ARENA_WEB_SEARCH_MODEL || "qwen-plus";
902
- const searchStrategy = depth === "1" ? "turbo" : "max";
903
- const payload = await dashScopeJson("api", "/services/aigc/text-generation/generation", {
904
- method: "POST",
905
- headers: { "content-type": "application/json" },
906
- body: JSON.stringify({
907
- model,
908
- input: {
909
- messages: [{ role: "user", content: query }],
910
- },
911
- parameters: {
912
- enable_search: true,
913
- result_format: "message",
914
- search_options: {
915
- forced_search: true,
916
- enable_source: true,
917
- enable_citation: true,
918
- citation_format: "[<number>]",
919
- search_strategy: searchStrategy,
920
- },
921
- },
922
- }),
923
- signal: this.signal,
924
- }, "web_search");
925
- const output = record(payload.output);
926
- const searchInfo = record(output.search_info);
927
- const searchResults = Array.isArray(searchInfo.search_results)
928
- ? searchInfo.search_results
929
- : [];
930
- const limit = { "1": 3, "2": 5, "3": 8 }[depth] || 5;
931
- // 参考轨迹条目:{id, url, title, description[, pageAge]};
932
- // DashScope 无 pageAge,按可选键省略。
933
- return {
934
- status: "success",
935
- results: searchResults.slice(0, limit).map((value, index) => {
936
- const item = record(value);
937
- return {
938
- id: Number(item.index) || index + 1,
939
- url: stringValue(item.url),
940
- title: stringValue(item.title),
941
- description: stringValue(item.content) ||
942
- stringValue(item.snippet) ||
943
- stringValue(item.summary),
944
- };
945
- }),
946
- };
984
+ return executeBraveWebSearch(query, depth, { signal: this.signal });
947
985
  }
948
986
  async imageSearch(query, count) {
949
- const model = process.env.ARENA_IMAGE_SEARCH_MODEL || "qwen3.7-plus";
950
- const payload = await dashScopeJson("compatible", "/responses", {
951
- method: "POST",
952
- headers: { "content-type": "application/json" },
953
- body: JSON.stringify({
954
- model,
955
- input: query,
956
- tools: [{ type: "web_search_image" }],
957
- }),
987
+ const payload = await executeBraveImageSearch(query, count, {
958
988
  signal: this.signal,
959
- }, "image_search");
960
- const output = Array.isArray(payload.output) ? payload.output : [];
961
- const toolCall = output
962
- .map(record)
963
- .find((item) => item.type === "web_search_image_call");
964
- let results = [];
965
- if (toolCall?.output) {
966
- try {
967
- const parsed = typeof toolCall.output === "string"
968
- ? JSON.parse(toolCall.output)
969
- : toolCall.output;
970
- if (Array.isArray(parsed))
971
- results = parsed;
972
- }
973
- catch {
974
- throw new Error("image_search returned malformed image results");
975
- }
976
- }
989
+ });
990
+ const results = Array.isArray(payload.results) ? payload.results : [];
977
991
  const saved = [];
978
992
  // 参考轨迹命名:image-search/<query slug 前 40 字符>-<序号>.<扩展名>。
979
993
  const slug = query
@@ -984,35 +998,44 @@ class ArenaToolExecutor {
984
998
  if (saved.length >= count)
985
999
  break;
986
1000
  const item = record(value);
987
- const sourceUrl = stringValue(item.url) ||
988
- stringValue(item.original_url) ||
989
- stringValue(item.image_url) ||
990
- stringValue(item.thumbnail_url);
1001
+ const properties = record(item.properties);
1002
+ const thumbnail = record(item.thumbnail);
1003
+ const sourceUrl = stringValue(properties.url) || stringValue(thumbnail.src);
1004
+ const thumbnailUrl = stringValue(thumbnail.src) || sourceUrl;
991
1005
  if (!sourceUrl)
992
1006
  continue;
993
- try {
994
- const imageResponse = await fetch(sourceUrl, { signal: this.signal });
995
- if (!imageResponse.ok)
996
- continue;
997
- const contentType = imageResponse.headers.get("content-type") || "image/jpeg";
998
- if (!contentType.toLowerCase().startsWith("image/"))
999
- continue;
1000
- const normalizedType = contentType.split(";")[0]?.trim().toLowerCase();
1001
- const extension = Object.entries(mimeTypes).find(([, mime]) => mime === normalizedType)?.[0] || ".jpg";
1002
- const target = await this.confinedPath(`image-search/${slug}-${saved.length + 1}${extension}`);
1003
- mkdirSync(path.dirname(target), { recursive: true });
1004
- await writeFile(target, await responseBytes(imageResponse));
1005
- saved.push({
1006
- title: stringValue(item.title),
1007
- file_path: this.relativePath(target),
1008
- source_url: sourceUrl,
1009
- thumbnail_url: stringValue(item.thumbnail_url) || sourceUrl,
1010
- });
1011
- }
1012
- catch (error) {
1013
- if (this.signal?.aborted)
1014
- throw error;
1015
- // A single inaccessible search result should not fail the whole search.
1007
+ const downloadUrls = [...new Set([sourceUrl, thumbnailUrl])];
1008
+ for (const downloadUrl of downloadUrls) {
1009
+ try {
1010
+ const imageResponse = await fetch(downloadUrl, {
1011
+ signal: this.signal,
1012
+ });
1013
+ if (!imageResponse.ok)
1014
+ continue;
1015
+ const contentType = imageResponse.headers.get("content-type") || "image/jpeg";
1016
+ if (!contentType.toLowerCase().startsWith("image/"))
1017
+ continue;
1018
+ const normalizedType = contentType
1019
+ .split(";")[0]
1020
+ ?.trim()
1021
+ .toLowerCase();
1022
+ const extension = Object.entries(mimeTypes).find(([, mime]) => mime === normalizedType)?.[0] || ".jpg";
1023
+ const target = await this.confinedPath(`image-search/${slug}-${saved.length + 1}${extension}`);
1024
+ mkdirSync(path.dirname(target), { recursive: true });
1025
+ await writeFile(target, await responseBytes(imageResponse));
1026
+ saved.push({
1027
+ title: stringValue(item.title),
1028
+ file_path: this.relativePath(target),
1029
+ source_url: sourceUrl,
1030
+ thumbnail_url: thumbnailUrl,
1031
+ });
1032
+ break;
1033
+ }
1034
+ catch (error) {
1035
+ if (this.signal?.aborted)
1036
+ throw error;
1037
+ // Try the Brave proxy thumbnail when the original is inaccessible.
1038
+ }
1016
1039
  }
1017
1040
  }
1018
1041
  return { status: "success", results: saved };
@@ -1025,13 +1048,13 @@ class ArenaToolExecutor {
1025
1048
  }
1026
1049
  async generateImage(rawPath, prompt, images) {
1027
1050
  const target = await this.confinedPath(rawPath);
1028
- if (![".jpg", ".jpeg", ".png"].includes(path.extname(target).toLowerCase())) {
1051
+ const extension = path.extname(target).toLowerCase();
1052
+ if (![".jpg", ".jpeg", ".png"].includes(extension)) {
1029
1053
  throw new Error("file_path must end in .jpg, .jpeg, or .png");
1030
1054
  }
1031
1055
  mkdirSync(path.dirname(target), { recursive: true });
1032
- const model = process.env.ARENA_IMAGE_MODEL || "qwen-image-2.0-pro";
1033
- const isQwenImage2 = model.startsWith("qwen-image-2.0");
1034
- const content = [];
1056
+ const model = process.env.ARENA_IMAGE_MODEL || "gemini-2.5-flash-image";
1057
+ const references = [];
1035
1058
  for (const imagePath of images || []) {
1036
1059
  const source = await this.confinedPath(imagePath);
1037
1060
  const extension = path.extname(source).toLowerCase();
@@ -1043,169 +1066,56 @@ class ArenaToolExecutor {
1043
1066
  if (bytes.length > 20 * 1024 * 1024) {
1044
1067
  throw new Error(`reference image exceeds 20 MB: ${imagePath}`);
1045
1068
  }
1046
- content.push({
1047
- image: `data:${mimeType};base64,${bytes.toString("base64")}`,
1069
+ references.push({
1070
+ bytes,
1071
+ fileName: path.basename(source),
1072
+ mimeType,
1048
1073
  });
1049
1074
  }
1050
- content.push({ text: prompt });
1051
- const parameters = {
1052
- size: process.env.ARENA_IMAGE_SIZE ||
1053
- (isQwenImage2 ? "2048*2048" : "2K"),
1054
- watermark: false,
1055
- };
1056
- if (isQwenImage2) {
1057
- parameters.prompt_extend = true;
1058
- }
1059
- else {
1060
- parameters.n = 1;
1061
- if (!images?.length)
1062
- parameters.thinking_mode = true;
1063
- }
1064
- const headers = {
1065
- "content-type": "application/json",
1066
- };
1067
- if (!isQwenImage2)
1068
- headers["x-dashscope-async"] = "enable";
1069
- const submitted = await dashScopeJson("api", isQwenImage2
1070
- ? "/services/aigc/multimodal-generation/generation"
1071
- : "/services/aigc/image-generation/generation", {
1072
- method: "POST",
1073
- headers,
1074
- body: JSON.stringify({
1075
- model,
1076
- input: {
1077
- messages: [{ role: "user", content }],
1078
- },
1079
- parameters,
1080
- }),
1075
+ const generated = await generateImageWithModelRouter({
1076
+ images: references,
1077
+ model,
1078
+ prompt,
1081
1079
  signal: this.signal,
1082
- }, "generate_image");
1083
- let completed = isQwenImage2
1084
- ? submitted
1085
- : undefined;
1086
- if (!isQwenImage2) {
1087
- const taskId = stringValue(record(submitted.output).task_id);
1088
- if (!taskId) {
1089
- throw new Error(`generate_image returned no task_id: ${responseMessage(submitted)}`);
1090
- }
1091
- const timeoutMs = Number(process.env.DASHSCOPE_TASK_TIMEOUT_MS || 5 * 60 * 1000);
1092
- const pollIntervalMs = Number(process.env.DASHSCOPE_TASK_POLL_INTERVAL_MS || 1000);
1093
- const deadline = Date.now() + timeoutMs;
1094
- while (Date.now() <= deadline) {
1095
- const task = await dashScopeJson("api", `/tasks/${encodeURIComponent(taskId)}`, { method: "GET", signal: this.signal }, "generate_image status");
1096
- const output = record(task.output);
1097
- const taskStatus = stringValue(output.task_status).toUpperCase();
1098
- if (taskStatus === "SUCCEEDED") {
1099
- completed = task;
1100
- break;
1101
- }
1102
- if (["FAILED", "CANCELED", "UNKNOWN"].includes(taskStatus)) {
1103
- throw new Error(`generate_image task ${taskStatus}: ${responseMessage(task)}`);
1104
- }
1105
- if (Date.now() + pollIntervalMs > deadline)
1106
- break;
1107
- await delay(pollIntervalMs, undefined, { signal: this.signal });
1108
- }
1109
- if (!completed) {
1110
- throw new Error(`generate_image timed out waiting for task ${taskId}`);
1111
- }
1112
- }
1113
- if (!completed) {
1114
- throw new Error("generate_image returned no response");
1115
- }
1116
- const output = record(completed.output);
1117
- const choices = Array.isArray(output.choices) ? output.choices : [];
1118
- const message = record(record(choices[0]).message);
1119
- const generatedContent = Array.isArray(message.content)
1120
- ? message.content
1121
- : [];
1122
- const generated = generatedContent
1123
- .map(record)
1124
- .find((item) => typeof item.image === "string");
1125
- const legacyResults = Array.isArray(output.results) ? output.results : [];
1126
- const imageUrl = stringValue(generated?.image) ||
1127
- stringValue(record(legacyResults[0]).url);
1128
- if (!imageUrl) {
1129
- throw new Error("generate_image response did not include an image URL");
1130
- }
1131
- const imageResponse = await fetch(imageUrl, { signal: this.signal });
1132
- if (!imageResponse.ok) {
1133
- throw new Error(`image download failed (${imageResponse.status})`);
1134
- }
1135
- 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));
1136
1083
  return {
1137
1084
  status: "success",
1138
1085
  file_path: this.relativePath(target),
1139
1086
  };
1140
1087
  }
1141
- async generateSpeech(rawPath, text, language, voiceIdentity = {}) {
1088
+ async generateSpeech(rawPath, text, voiceId, language) {
1142
1089
  const target = await this.confinedPath(rawPath);
1143
1090
  const extension = path.extname(target).toLowerCase();
1144
1091
  const format = {
1145
1092
  ".mp3": "mp3",
1146
1093
  ".wav": "wav",
1094
+ ".ogg": "opus",
1147
1095
  ".opus": "opus",
1148
1096
  }[extension];
1149
1097
  if (!format) {
1150
- 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");
1151
1099
  }
1152
1100
  mkdirSync(path.dirname(target), { recursive: true });
1153
- const model = process.env.ARENA_SPEECH_MODEL || "qwen-audio-3.0-tts-flash";
1154
- const feminine = voiceIdentity.gender !== "masculine";
1155
- const primaryLanguage = language.toLowerCase().split(/[-_]/)[0] || "";
1156
- const voices = primaryLanguage === "en"
1157
- ? feminine
1158
- ? ["loongeva_v3.6", "longanhuan_v3.6"]
1159
- : ["loongjohn", "longjielidou_v3.6"]
1160
- : feminine
1161
- ? ["longanhuan_v3.6"]
1162
- : ["longjielidou_v3.6"];
1163
- const requestedIndex = Math.max(0, Math.trunc(Number(voiceIdentity.index || 0)));
1164
- const voice = process.env.DASHSCOPE_SPEECH_VOICE ||
1165
- voices[requestedIndex % voices.length] ||
1166
- voices[0];
1167
- const supportedHints = new Set([
1168
- "ar",
1169
- "de",
1170
- "en",
1171
- "es",
1172
- "fil",
1173
- "fr",
1174
- "id",
1175
- "it",
1176
- "ja",
1177
- "ko",
1178
- "ms",
1179
- "pt",
1180
- "ru",
1181
- "th",
1182
- "vi",
1183
- "zh",
1184
- ]);
1185
- const input = {
1186
- text,
1187
- voice,
1188
- format,
1189
- sample_rate: 24000,
1190
- };
1191
- if (supportedHints.has(primaryLanguage)) {
1192
- input.language_hints = [primaryLanguage];
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.`);
1193
1104
  }
1194
- const payload = await dashScopeJson("api", "/services/audio/tts/SpeechSynthesizer", {
1195
- method: "POST",
1196
- headers: { "content-type": "application/json" },
1197
- body: JSON.stringify({ model, input }),
1198
- signal: this.signal,
1199
- }, "generate_speech");
1200
- const audioUrl = stringValue(record(record(payload.output).audio).url);
1201
- if (!audioUrl) {
1202
- throw new Error(`generate_speech returned no audio URL: ${responseMessage(payload)}`);
1203
- }
1204
- const audioResponse = await fetch(audioUrl, { signal: this.signal });
1205
- if (!audioResponse.ok) {
1206
- 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`);
1207
1110
  }
1208
- 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
+ }));
1209
1119
  return {
1210
1120
  status: "success",
1211
1121
  file_path: this.relativePath(target),