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

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 (91) hide show
  1. package/.env.example +34 -13
  2. package/README.md +168 -60
  3. package/dist/agent.d.ts +6 -2
  4. package/dist/agent.js +155 -21
  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 +65 -19
  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 +32 -9
  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 +24 -2
  21. package/dist/history.js +306 -15
  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 +492 -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/sdk.js +9 -1
  45. package/dist/sdk.js.map +1 -1
  46. package/dist/server.d.ts +2 -0
  47. package/dist/server.js +149 -59
  48. package/dist/server.js.map +1 -1
  49. package/dist/spec.js +15 -1
  50. package/dist/spec.js.map +1 -1
  51. package/dist/tools.js +568 -268
  52. package/dist/tools.js.map +1 -1
  53. package/dist/tui.d.ts +2 -0
  54. package/dist/tui.js +22 -13
  55. package/dist/tui.js.map +1 -1
  56. package/dist/types.d.ts +7 -0
  57. package/dist/voice-registry.d.ts +30 -0
  58. package/dist/voice-registry.js +82 -0
  59. package/dist/voice-registry.js.map +1 -0
  60. package/dist/web/app.js +25 -3
  61. package/dist/web/app.js.map +1 -1
  62. package/docs/SDK.md +9 -7
  63. package/package.json +8 -2
  64. package/web/styles.css +6 -0
  65. package/dist/audit.d.ts +0 -14
  66. package/dist/audit.js +0 -31
  67. package/dist/audit.js.map +0 -1
  68. package/dist/dashscope.d.ts +0 -5
  69. package/dist/dashscope.js +0 -55
  70. package/dist/dashscope.js.map +0 -1
  71. package/dist/fetch-page/aparat.d.ts +0 -30
  72. package/dist/fetch-page/aparat.js +0 -209
  73. package/dist/fetch-page/aparat.js.map +0 -1
  74. package/dist/fetch-page/json-path.d.ts +0 -12
  75. package/dist/fetch-page/json-path.js +0 -111
  76. package/dist/fetch-page/json-path.js.map +0 -1
  77. package/dist/fetch-page/sites.d.ts +0 -26
  78. package/dist/fetch-page/sites.js +0 -39
  79. package/dist/fetch-page/sites.js.map +0 -1
  80. package/dist/fetch-page/text.d.ts +0 -5
  81. package/dist/fetch-page/text.js +0 -38
  82. package/dist/fetch-page/text.js.map +0 -1
  83. package/dist/fetch-page/types.d.ts +0 -17
  84. package/dist/fetch-page/types.js +0 -3
  85. package/dist/fetch-page/types.js.map +0 -1
  86. package/dist/fetch-page/youtube.d.ts +0 -35
  87. package/dist/fetch-page/youtube.js +0 -282
  88. package/dist/fetch-page/youtube.js.map +0 -1
  89. package/dist/web-search.d.ts +0 -5
  90. package/dist/web-search.js +0 -465
  91. 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",
@@ -347,6 +349,180 @@ function json(value) {
347
349
  function errorMessage(error) {
348
350
  return error instanceof Error ? error.message : String(error);
349
351
  }
352
+ function jsonPointerPath(instancePath) {
353
+ if (!instancePath)
354
+ return [];
355
+ return instancePath
356
+ .slice(1)
357
+ .split("/")
358
+ .map((part) => part.replace(/~1/g, "/").replace(/~0/g, "~"))
359
+ .map((part) => (/^(0|[1-9]\d*)$/.test(part) ? Number(part) : part));
360
+ }
361
+ function valueAtPath(value, pathParts) {
362
+ let current = value;
363
+ for (const part of pathParts) {
364
+ if (current === null || typeof current !== "object")
365
+ return undefined;
366
+ current = current[part];
367
+ }
368
+ return current;
369
+ }
370
+ function zodTypeName(type) {
371
+ return type === "integer" ? "number" : type;
372
+ }
373
+ function receivedType(value) {
374
+ if (value === null)
375
+ return "null";
376
+ if (Array.isArray(value))
377
+ return "array";
378
+ return typeof value;
379
+ }
380
+ function arenaValidationIssue(error, value) {
381
+ const pathParts = jsonPointerPath(error.instancePath);
382
+ if (error.keyword === "required") {
383
+ const missingProperty = String(error.params.missingProperty);
384
+ const parentSchema = error.parentSchema;
385
+ const expected = zodTypeName(parentSchema?.properties?.[missingProperty]?.type || "unknown");
386
+ return {
387
+ expected,
388
+ code: "invalid_type",
389
+ path: [...pathParts, missingProperty],
390
+ message: `Invalid input: expected ${expected}, received undefined`,
391
+ };
392
+ }
393
+ if (error.keyword === "type") {
394
+ const expected = zodTypeName(String(error.params.type));
395
+ return {
396
+ expected,
397
+ code: "invalid_type",
398
+ path: pathParts,
399
+ message: `Invalid input: expected ${expected}, received ` +
400
+ receivedType(valueAtPath(value, pathParts)),
401
+ };
402
+ }
403
+ if (error.keyword === "maxLength" || error.keyword === "minLength") {
404
+ const tooBig = error.keyword === "maxLength";
405
+ const limitKey = tooBig ? "maximum" : "minimum";
406
+ const limit = Number(error.params.limit);
407
+ return {
408
+ origin: "string",
409
+ code: tooBig ? "too_big" : "too_small",
410
+ [limitKey]: limit,
411
+ inclusive: true,
412
+ path: pathParts,
413
+ message: `${tooBig ? "Too big" : "Too small"}: expected string to have ` +
414
+ `${tooBig ? "<=" : ">="}${limit} characters`,
415
+ };
416
+ }
417
+ if (error.keyword === "maximum" || error.keyword === "minimum") {
418
+ const tooBig = error.keyword === "maximum";
419
+ const limitKey = tooBig ? "maximum" : "minimum";
420
+ const limit = Number(error.params.limit);
421
+ return {
422
+ origin: "number",
423
+ code: tooBig ? "too_big" : "too_small",
424
+ [limitKey]: limit,
425
+ inclusive: true,
426
+ path: pathParts,
427
+ message: `${tooBig ? "Too big" : "Too small"}: expected number to be ` +
428
+ `${tooBig ? "<=" : ">="}${limit}`,
429
+ };
430
+ }
431
+ if (error.keyword === "maxItems" || error.keyword === "minItems") {
432
+ const tooBig = error.keyword === "maxItems";
433
+ const limitKey = tooBig ? "maximum" : "minimum";
434
+ const limit = Number(error.params.limit);
435
+ return {
436
+ origin: "array",
437
+ code: tooBig ? "too_big" : "too_small",
438
+ [limitKey]: limit,
439
+ inclusive: true,
440
+ path: pathParts,
441
+ message: `${tooBig ? "Too big" : "Too small"}: expected array to have ` +
442
+ `${tooBig ? "<=" : ">="}${limit} items`,
443
+ };
444
+ }
445
+ if (error.keyword === "enum") {
446
+ const values = error.params.allowedValues;
447
+ return {
448
+ code: "invalid_value",
449
+ values,
450
+ path: pathParts,
451
+ message: `Invalid option: expected one of ${values
452
+ .map((item) => JSON.stringify(item))
453
+ .join("|")}`,
454
+ };
455
+ }
456
+ if (error.keyword === "format") {
457
+ return {
458
+ origin: "string",
459
+ code: "invalid_format",
460
+ format: error.params.format === "uri" ? "url" : error.params.format,
461
+ path: pathParts,
462
+ message: error.params.format === "uri" ? "Invalid URL" : "Invalid string",
463
+ };
464
+ }
465
+ return {
466
+ code: "custom",
467
+ path: pathParts,
468
+ message: error.message || "Invalid input",
469
+ };
470
+ }
471
+ function arenaValidationIssues(error, value) {
472
+ const issues = [arenaValidationIssue(error, value)];
473
+ if (error.keyword !== "type" || error.params.type !== "array")
474
+ return issues;
475
+ const pathParts = jsonPointerPath(error.instancePath);
476
+ const received = valueAtPath(value, pathParts);
477
+ if (typeof received !== "string")
478
+ return issues;
479
+ const parentSchema = error.parentSchema;
480
+ const maximum = parentSchema?.maxItems;
481
+ if (typeof maximum === "number" && received.length > maximum) {
482
+ issues.push({
483
+ origin: "string",
484
+ code: "too_big",
485
+ maximum,
486
+ inclusive: true,
487
+ path: pathParts,
488
+ message: `Too big: expected string to have <=${maximum} characters`,
489
+ });
490
+ }
491
+ const minimum = parentSchema?.minItems;
492
+ if (typeof minimum === "number" && received.length < minimum) {
493
+ issues.push({
494
+ origin: "string",
495
+ code: "too_small",
496
+ minimum,
497
+ inclusive: true,
498
+ path: pathParts,
499
+ message: `Too small: expected string to have >=${minimum} characters`,
500
+ });
501
+ }
502
+ return issues;
503
+ }
504
+ function arenaValidationError(toolName, value, errors) {
505
+ const issues = (errors || []).flatMap((error) => arenaValidationIssues(error, value));
506
+ return (`Invalid input for tool ${toolName}: Type validation failed: Value: ` +
507
+ `${JSON.stringify(value)}.\nError message: ${JSON.stringify(issues, null, 2)}`);
508
+ }
509
+ function arenaJsonParsingError(toolName, text, error) {
510
+ return (`Invalid input for tool ${toolName}: JSON parsing failed: Text: ${text}.\n` +
511
+ `Error message: ${errorMessage(error)}`);
512
+ }
513
+ function unavailableToolError(toolName, availableToolNames) {
514
+ return (`Model tried to call unavailable tool '${toolName}'. Available tools: ` +
515
+ `${[...availableToolNames, "compact"].join(", ")}.`);
516
+ }
517
+ function prefixedFailure(prefix, message) {
518
+ return message.startsWith(prefix) ? message : `${prefix}${message}`;
519
+ }
520
+ function fetchPageFailure(message) {
521
+ const status = message.match(/(?:fetch_page|Firecrawl|Jina Reader) failed \((\d{3})\b/i)?.[1];
522
+ return status
523
+ ? `Failed to fetch page (HTTP ${status})`
524
+ : "Failed to fetch page";
525
+ }
350
526
  function displayByteSize(value) {
351
527
  if (value >= 1024 * 1024) {
352
528
  return `${(value / (1024 * 1024)).toFixed(1)} MB`;
@@ -361,58 +537,6 @@ function record(value) {
361
537
  function stringValue(value) {
362
538
  return typeof value === "string" ? value : "";
363
539
  }
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
540
  async function responseBytes(response, limit = 4 * 1024 * 1024) {
417
541
  const bytes = Buffer.from(await response.arrayBuffer());
418
542
  if (bytes.length > limit) {
@@ -432,16 +556,27 @@ class ArenaToolExecutor {
432
556
  workspaceContext;
433
557
  pageReader;
434
558
  routifyPageReader;
435
- ajv = new Ajv({ allErrors: true, strict: false });
559
+ processManager;
560
+ voiceRegistry;
561
+ ajv = new Ajv({
562
+ allErrors: true,
563
+ strict: false,
564
+ verbose: true,
565
+ // The captured runtime uses Zod object parsing: unknown object keys are
566
+ // stripped before dispatch instead of being emitted as validation issues.
567
+ removeAdditional: "all",
568
+ });
436
569
  // 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.
570
+ // headless `-p` may delegate to a configured remote User Agent. add_voice
571
+ // remains available without one and deterministically registers a fallback.
439
572
  askUserEnabled;
440
573
  constructor(config, options = {}) {
441
574
  this.config = config;
442
575
  this.workspace = path.resolve(config.workspace);
443
576
  mkdirSync(this.workspace, { recursive: true });
444
577
  this.workspaceReal = realpathSync(this.workspace);
578
+ this.processManager = processManagerForWorkspace(this.workspaceReal);
579
+ this.voiceRegistry = voiceRegistryForWorkspace(this.workspaceReal);
445
580
  this.askUserHandler = options.askUserHandler;
446
581
  this.askUserEnabled = Boolean(options.askUserHandler);
447
582
  if (config.imageInputFallbackModel?.trim()) {
@@ -455,13 +590,20 @@ class ArenaToolExecutor {
455
590
  signal: options.signal,
456
591
  });
457
592
  addFormats(this.ajv);
593
+ // WHATWG URL parsing matches the captured fetch_page behavior. ajv-formats'
594
+ // RFC URI checker rejects a recorded URL containing `|` in its query even
595
+ // though Arena accepted and fetched it successfully.
596
+ this.ajv.addFormat("uri", {
597
+ type: "string",
598
+ validate: (value) => URL.canParse(value),
599
+ });
458
600
  for (const definition of toolDefinitions(this.workspaceContext)) {
459
601
  this.validators.set(definition.function.name, this.ajv.compile(definition.function.parameters));
460
602
  }
461
603
  }
462
604
  // The tool schemas offered to the model for this session. Identical to the
463
605
  // canonical trace set, adjusted for the selected workspace runtime and minus
464
- // ask_user when no handler is wired.
606
+ // ask_user when no handler is wired. add_voice stays available headlessly.
465
607
  availableTools() {
466
608
  let definitions = toolDefinitions(this.workspaceContext);
467
609
  if (!this.askUserEnabled) {
@@ -477,23 +619,82 @@ class ArenaToolExecutor {
477
619
  try {
478
620
  // 监听器不会补发已发生的 abort:已取消的 signal 必须在产生任何副作用
479
621
  // 之前短路,否则命令会照常 spawn 直到自然结束。
480
- this.signal?.throwIfAborted();
622
+ if (this.signal?.aborted) {
623
+ return {
624
+ role: "tool",
625
+ tool_call_id: callId,
626
+ content: json({ status: "aborted" }),
627
+ };
628
+ }
481
629
  if (this.config.disallowedTools?.includes(name)) {
482
630
  throw new Error(`tool is disallowed: ${name}`);
483
631
  }
484
- const argumentsValue = JSON.parse(typeof call.function?.arguments === "string"
485
- ? call.function.arguments
486
- : "{}");
487
632
  const validate = this.validators.get(name);
488
- if (!validate)
489
- throw new Error(`unknown tool: ${name}`);
490
- if (!validate(argumentsValue)) {
491
- const errors = (validate.errors || [])
492
- .map((error) => `${error.instancePath || "/"} ${error.message}`)
493
- .join("; ");
494
- throw new Error(`invalid arguments: ${errors}`);
633
+ if (!validate) {
634
+ return {
635
+ role: "tool",
636
+ tool_call_id: callId,
637
+ content: unavailableToolError(name, toolDefinitions(this.workspaceContext).map((definition) => definition.function.name)),
638
+ };
639
+ }
640
+ const argumentsText = typeof call.function?.arguments === "string"
641
+ ? call.function.arguments
642
+ : "{}";
643
+ let argumentsValue;
644
+ try {
645
+ argumentsValue = JSON.parse(argumentsText);
646
+ }
647
+ catch (error) {
648
+ return {
649
+ role: "tool",
650
+ tool_call_id: callId,
651
+ content: arenaJsonParsingError(name, argumentsText, error),
652
+ };
653
+ }
654
+ const malformedArguments = argumentsValue !== null &&
655
+ typeof argumentsValue === "object" &&
656
+ !Array.isArray(argumentsValue)
657
+ ? argumentsValue.__malformedToolArguments
658
+ : undefined;
659
+ if (typeof malformedArguments === "string") {
660
+ try {
661
+ JSON.parse(malformedArguments);
662
+ }
663
+ catch (error) {
664
+ return {
665
+ role: "tool",
666
+ tool_call_id: callId,
667
+ content: arenaJsonParsingError(name, malformedArguments, error),
668
+ };
669
+ }
670
+ }
671
+ const validatedArguments = structuredClone(argumentsValue);
672
+ if (!validate(validatedArguments)) {
673
+ return {
674
+ role: "tool",
675
+ tool_call_id: callId,
676
+ content: arenaValidationError(name, argumentsValue, validate.errors),
677
+ };
678
+ }
679
+ const startedAt = Date.now();
680
+ let result;
681
+ try {
682
+ result = await this.dispatch(name, validatedArguments, callId, context);
683
+ }
684
+ catch (error) {
685
+ if (error instanceof AskUserHandlerError) {
686
+ return {
687
+ role: "tool",
688
+ tool_call_id: callId,
689
+ content: json({ status: "error", message: error.message }),
690
+ };
691
+ }
692
+ return {
693
+ role: "tool",
694
+ tool_call_id: callId,
695
+ content: json(this.runtimeFailureResult(name, validatedArguments, error, startedAt)),
696
+ };
495
697
  }
496
- const result = await this.dispatch(name, argumentsValue, callId, context);
497
698
  const content = typeof result === "string" ? result : json(result);
498
699
  if (content.length > toolResultCharacterLimit) {
499
700
  return {
@@ -514,13 +715,6 @@ class ArenaToolExecutor {
514
715
  };
515
716
  }
516
717
  catch (error) {
517
- if (error instanceof AskUserHandlerError) {
518
- return {
519
- role: "tool",
520
- tool_call_id: callId,
521
- content: json({ status: "error", message: error.message }),
522
- };
523
- }
524
718
  return {
525
719
  role: "tool",
526
720
  tool_call_id: callId,
@@ -531,6 +725,83 @@ class ArenaToolExecutor {
531
725
  };
532
726
  }
533
727
  }
728
+ runtimeFailureResult(name, values, error, startedAt) {
729
+ if (this.signal?.aborted)
730
+ return { status: "aborted" };
731
+ const message = errorMessage(error);
732
+ const durationMs = Date.now() - startedAt;
733
+ if (name === "bash") {
734
+ const stderr = this.virtualizeBashOutput(message);
735
+ return {
736
+ status: "shell_error",
737
+ stderr,
738
+ stdout: "",
739
+ exit_code: null,
740
+ duration_ms: durationMs,
741
+ stderr_truncated: false,
742
+ stdout_truncated: false,
743
+ };
744
+ }
745
+ if (name === "start_process") {
746
+ const cwd = String(values.cwd || "/home/user");
747
+ const code = error?.code;
748
+ const logTail = code === "ENOENT"
749
+ ? `[invalid_argument] cwd '${cwd}' does not exist`
750
+ : message;
751
+ return {
752
+ status: "shell_error",
753
+ process_id: null,
754
+ pid: null,
755
+ exit_code: null,
756
+ log_tail: logTail,
757
+ listening_ports: [],
758
+ new_ports: [],
759
+ duration_ms: durationMs,
760
+ };
761
+ }
762
+ if (name === "get_process_output") {
763
+ return {
764
+ status: "shell_error",
765
+ log_tail: message,
766
+ exit_code: null,
767
+ listening_ports: [],
768
+ };
769
+ }
770
+ if (name === "stop_process") {
771
+ return { status: "shell_error", log_tail: message };
772
+ }
773
+ if (name === "fetch_page") {
774
+ return { status: "error", error: fetchPageFailure(message) };
775
+ }
776
+ if (name === "add_voice") {
777
+ return {
778
+ status: "error",
779
+ message: prefixedFailure("Failed to audition voices: ", message),
780
+ };
781
+ }
782
+ if (name === "generate_image") {
783
+ return {
784
+ status: "error",
785
+ message: prefixedFailure("Failed to generate image: ", message),
786
+ };
787
+ }
788
+ if (name === "generate_speech") {
789
+ return {
790
+ status: "error",
791
+ message: prefixedFailure("Failed to generate speech: ", message),
792
+ };
793
+ }
794
+ if (name === "image_search") {
795
+ return {
796
+ status: "error",
797
+ message: prefixedFailure("Image search failed: ", message),
798
+ };
799
+ }
800
+ if (name === "present_file") {
801
+ return { status: "error", message: "Error calling present file tool" };
802
+ }
803
+ return { status: "error", message };
804
+ }
534
805
  takeVisualMessage(toolCallIds) {
535
806
  // 生产契约:同一组 tool call 的全部图片汇入一条 user message,
536
807
  // 由一个 arena-system-message 文本 part 领头。
@@ -557,6 +828,8 @@ class ArenaToolExecutor {
557
828
  : "/home/user")), Number(values.timeout || 30));
558
829
  case "ask_user":
559
830
  return this.askUser(values.questions, toolCallId, context);
831
+ case "add_voice":
832
+ return this.addVoice(String(values.text), String(values.language), values.voice_identity, toolCallId, context);
560
833
  case "edit_file":
561
834
  return this.editFile(String(values.path), String(values.old_text), String(values.new_text));
562
835
  case "read_file":
@@ -573,10 +846,28 @@ class ArenaToolExecutor {
573
846
  return this.imageSearch(String(values.query), Number(values.count));
574
847
  case "present_file":
575
848
  return this.presentFile(String(values.path));
849
+ case "stop_process":
850
+ return this.processManager.stop(String(values.process_id));
851
+ case "start_process":
852
+ return this.startProcess(String(values.name || ""), String(values.command), String(values.cwd ||
853
+ (this.workspaceContext
854
+ ? path.resolve(this.workspaceContext.cwd)
855
+ : "/home/user")), Number(values.startup_wait || 5));
576
856
  case "generate_image":
577
857
  return this.generateImage(String(values.file_path), String(values.prompt), values.images);
578
858
  case "generate_speech":
579
- return this.generateSpeech(String(values.file_path), String(values.text), String(values.language), values.voice_identity);
859
+ return this.generateSpeech(String(values.file_path), String(values.text), String(values.voice_id), values.language === undefined ? undefined : String(values.language));
860
+ case "get_process_output":
861
+ return this.processManager.get({
862
+ processId: String(values.process_id),
863
+ tailLines: Number(values.tail_lines || 200),
864
+ waitFor: values.wait_for,
865
+ waitPattern: values.wait_pattern === undefined
866
+ ? undefined
867
+ : String(values.wait_pattern),
868
+ waitTimeoutSeconds: Number(values.wait_timeout || 60),
869
+ signal: this.signal,
870
+ });
580
871
  default:
581
872
  throw new Error(`unknown tool: ${name}`);
582
873
  }
@@ -585,12 +876,15 @@ class ArenaToolExecutor {
585
876
  if (!raw)
586
877
  throw new Error("path must not be empty");
587
878
  let candidate;
588
- if (!this.workspaceContext && (raw === "/home/user" || raw === "~")) {
879
+ if (raw === "/home/user") {
589
880
  candidate = this.workspace;
590
881
  }
591
- else if (!this.workspaceContext && raw.startsWith("/home/user/")) {
882
+ else if (raw.startsWith("/home/user/")) {
592
883
  candidate = path.join(this.workspace, raw.slice("/home/user/".length));
593
884
  }
885
+ else if (!this.workspaceContext && raw === "~") {
886
+ candidate = this.workspace;
887
+ }
594
888
  else if (!this.workspaceContext && raw.startsWith("~/")) {
595
889
  candidate = path.join(this.workspace, raw.slice(2));
596
890
  }
@@ -636,6 +930,17 @@ class ArenaToolExecutor {
636
930
  }
637
931
  return virtualized;
638
932
  }
933
+ sandboxEnvironment() {
934
+ const env = {
935
+ ...process.env,
936
+ ARENA_WORKSPACE: this.workspace,
937
+ LANG: "C.UTF-8",
938
+ LC_ALL: "C.UTF-8",
939
+ };
940
+ delete env.FORCE_COLOR;
941
+ delete env.CLICOLOR_FORCE;
942
+ return env;
943
+ }
639
944
  async bash(command, cwd, timeoutSeconds) {
640
945
  const workdir = await this.confinedPath(cwd);
641
946
  if (!(await stat(workdir)).isDirectory()) {
@@ -647,18 +952,7 @@ class ArenaToolExecutor {
647
952
  ? command
648
953
  : command.replaceAll("/home/user", "\"$ARENA_WORKSPACE\"");
649
954
  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;
955
+ const env = this.sandboxEnvironment();
662
956
  // detached:管道成员(npm、tail…)与 bash 同进程组。只杀 bash 会留下
663
957
  // 孤儿持有 stdout/stderr 管道,close 永不触发,整个 turn 挂死;
664
958
  // 必须杀整组,并在宽限期后强制结算兜底。
@@ -673,7 +967,6 @@ class ArenaToolExecutor {
673
967
  let stderr = "";
674
968
  let timedOut = false;
675
969
  let settled = false;
676
- let killRequested = false;
677
970
  let forceSettle;
678
971
  const killTree = (signal) => {
679
972
  try {
@@ -692,33 +985,26 @@ class ArenaToolExecutor {
692
985
  throw error;
693
986
  }
694
987
  };
695
- const finish = (code) => {
988
+ const finish = (code, signal) => {
696
989
  if (settled)
697
990
  return;
698
991
  settled = true;
699
992
  clearTimeout(timeout);
700
993
  if (forceSettle)
701
994
  clearTimeout(forceSettle);
702
- // 组长退出会立刻触发 close 并取消宽限定时器;取消/超时一旦请求过
703
- // 击杀,就必须在结算时补一发组级 SIGKILL,否则忽略 TERM 且关闭了
704
- // 继承管道的后台子进程会活过取消。正常退出不清场——模型可能有意
705
- // 留下后台服务供后续调用使用。
706
- if (killRequested)
707
- killTree("SIGKILL");
995
+ // bash is one-shot in v2. Any descendant left in the process group is
996
+ // cleaned up even when the shell itself exited normally; persistent
997
+ // servers belong in start_process.
998
+ killTree("SIGKILL");
708
999
  this.signal?.removeEventListener("abort", abort);
709
1000
  if (this.signal?.aborted) {
710
1001
  reject(this.signal.reason || new Error("operation cancelled"));
711
1002
  return;
712
1003
  }
713
- // 生产契约:status 恒为 "completed"(包括非零退出码),超时无参考样本,
714
- // 按最小猜测同样返回 completed + exit_code null,stderr 附超时说明。
715
- if (timedOut) {
716
- stderr += (stderr ? "\n" : "") + `command exceeded ${timeoutSeconds}s`;
717
- }
718
1004
  const out = capBashOutput(this.virtualizeBashOutput(stdout));
719
1005
  const err = capBashOutput(this.virtualizeBashOutput(stderr));
720
1006
  resolve({
721
- status: "completed",
1007
+ status: timedOut ? "timeout" : signal ? "killed" : "completed",
722
1008
  stderr: err.text,
723
1009
  stdout: out.text,
724
1010
  exit_code: timedOut ? null : code,
@@ -732,17 +1018,15 @@ class ArenaToolExecutor {
732
1018
  const armForceSettle = () => {
733
1019
  forceSettle ??= setTimeout(() => {
734
1020
  killTree("SIGKILL");
735
- finish(null);
1021
+ finish(null, "SIGKILL");
736
1022
  }, 2000);
737
1023
  };
738
1024
  const timeout = setTimeout(() => {
739
1025
  timedOut = true;
740
- killRequested = true;
741
1026
  killTree("SIGKILL");
742
1027
  armForceSettle();
743
1028
  }, timeoutSeconds * 1000);
744
1029
  const abort = () => {
745
- killRequested = true;
746
1030
  killTree("SIGTERM");
747
1031
  armForceSettle();
748
1032
  };
@@ -763,7 +1047,24 @@ class ArenaToolExecutor {
763
1047
  this.signal?.removeEventListener("abort", abort);
764
1048
  reject(error);
765
1049
  });
766
- child.once("close", (code) => finish(code));
1050
+ child.once("close", (code, signal) => finish(code, signal));
1051
+ });
1052
+ }
1053
+ async startProcess(name, command, cwd, startupWaitSeconds) {
1054
+ const workdir = await this.confinedPath(cwd);
1055
+ if (!(await stat(workdir)).isDirectory()) {
1056
+ throw new Error(`cwd is not a directory: ${cwd}`);
1057
+ }
1058
+ const translatedCommand = this.workspaceContext
1059
+ ? command
1060
+ : command.replaceAll("/home/user", '"$ARENA_WORKSPACE"');
1061
+ return this.processManager.start({
1062
+ name,
1063
+ command: translatedCommand,
1064
+ cwd: workdir,
1065
+ startupWaitSeconds,
1066
+ env: this.sandboxEnvironment(),
1067
+ signal: this.signal,
767
1068
  });
768
1069
  }
769
1070
  async askUser(questions, toolCallId, context) {
@@ -783,9 +1084,77 @@ class ArenaToolExecutor {
783
1084
  "Proceed with reasonable defaults and state any assumptions you make.",
784
1085
  };
785
1086
  }
1087
+ async addVoice(text, language, voiceIdentity = {}, toolCallId, context) {
1088
+ if (!this.askUserHandler) {
1089
+ const selection = this.voiceRegistry.registerVirtual(language, voiceIdentity);
1090
+ return {
1091
+ status: "success",
1092
+ message: `Battle "${selection.battleId}": no interactive voice picker was ` +
1093
+ `available, so a default voice was selected. Pass voice_id ` +
1094
+ `"${selection.voiceId}" to generate_speech to speak with it.`,
1095
+ voice_id: selection.voiceId,
1096
+ battle_id: selection.battleId,
1097
+ virtual: true,
1098
+ };
1099
+ }
1100
+ const battle = await this.voiceRegistry.audition(text, language, voiceIdentity, this.signal);
1101
+ const response = await this.askUserHandler(toolCallId, [
1102
+ {
1103
+ id: `voice-battle-${battle.battleId}`,
1104
+ question: "Which voice should be used for this speaker?",
1105
+ options: battle.candidates.map((candidate) => ({
1106
+ id: candidate.id,
1107
+ label: candidate.label,
1108
+ description: "Play the audition, then select this voice.",
1109
+ audioDataUrl: candidate.audioDataUrl,
1110
+ })),
1111
+ allowCustomResponse: false,
1112
+ },
1113
+ ], context
1114
+ ? {
1115
+ ...context,
1116
+ signal: this.signal || context.signal,
1117
+ }
1118
+ : undefined);
1119
+ if (response.status === "needs_input")
1120
+ return response;
1121
+ if (response.skipped === true) {
1122
+ return { status: "error", message: "The user skipped voice selection." };
1123
+ }
1124
+ const answers = Array.isArray(response.answers) ? response.answers : [];
1125
+ const selectedOptionId = record(answers[0]).selectedOptionId;
1126
+ if (typeof selectedOptionId !== "string") {
1127
+ throw new Error("voice selection did not choose a candidate");
1128
+ }
1129
+ const voiceId = this.voiceRegistry.register(battle, selectedOptionId);
1130
+ return {
1131
+ status: "success",
1132
+ message: `Battle "${battle.battleId}": the user selected a voice. ` +
1133
+ `Pass voice_id "${voiceId}" to generate_speech to speak with it. ` +
1134
+ "Continue the task now — if you need another voice, call add_voice again immediately.",
1135
+ voice_id: voiceId,
1136
+ battle_id: battle.battleId,
1137
+ };
1138
+ }
786
1139
  async editFile(rawPath, oldText, newText) {
787
- const target = await this.confinedPath(rawPath);
788
- const source = await readFile(target, "utf8");
1140
+ let target;
1141
+ let source;
1142
+ try {
1143
+ target = await this.confinedPath(rawPath);
1144
+ if (!(await stat(target)).isFile()) {
1145
+ return {
1146
+ status: "error",
1147
+ message: `File not found: ${rawPath}`,
1148
+ };
1149
+ }
1150
+ source = await readFile(target, "utf8");
1151
+ }
1152
+ catch {
1153
+ return {
1154
+ status: "error",
1155
+ message: `Failed to read workspace file: ${rawPath}`,
1156
+ };
1157
+ }
789
1158
  // 仅精确匹配:曾有空白容错回退,但 T1(b) 回放证实生产没有——case-003
790
1159
  // 四个 old_text 精确缺失的 edit 生产全部返回 Context not found,而回退
791
1160
  // 会静默改错文件;全部参考成功样本也都能走精确路径复现。
@@ -798,7 +1167,15 @@ class ArenaToolExecutor {
798
1167
  message: "Context not found. Read the file to verify the text exists.",
799
1168
  };
800
1169
  }
801
- await writeFile(target, source.replace(oldText, newText), "utf8");
1170
+ try {
1171
+ await writeFile(target, source.replace(oldText, newText), "utf8");
1172
+ }
1173
+ catch {
1174
+ return {
1175
+ status: "error",
1176
+ message: `Failed to write workspace file: ${rawPath}`,
1177
+ };
1178
+ }
802
1179
  return { status: "success", message: `Edited ${rawPath}.` };
803
1180
  }
804
1181
  async readWorkspaceFile(rawPath, toolCallId) {
@@ -842,6 +1219,15 @@ class ArenaToolExecutor {
842
1219
  if (kind === "image" && fileSize > 20 * 1024 * 1024) {
843
1220
  throw new Error(`image exceeds 20 MB: ${rawPath}`);
844
1221
  }
1222
+ // Current Arena v2 captures consistently expose local PDFs as binary
1223
+ // metadata. PDF text extraction belongs to fetch_page, not read_file.
1224
+ if (extension === ".pdf") {
1225
+ return {
1226
+ kind: "unsupported",
1227
+ mediaType: "application/pdf",
1228
+ size: fileSize,
1229
+ };
1230
+ }
845
1231
  const mainModelSupportsImages = kind === "image" ? supportsImageInput(this.config) : false;
846
1232
  const fallbackModel = mainModelSupportsImages
847
1233
  ? undefined
@@ -939,9 +1325,18 @@ class ArenaToolExecutor {
939
1325
  };
940
1326
  }
941
1327
  async writeWorkspaceFile(rawPath, content) {
942
- const target = await this.confinedPath(rawPath);
943
- mkdirSync(path.dirname(target), { recursive: true });
944
- await writeFile(target, content, "utf8");
1328
+ let target;
1329
+ try {
1330
+ target = await this.confinedPath(rawPath);
1331
+ mkdirSync(path.dirname(target), { recursive: true });
1332
+ await writeFile(target, content, "utf8");
1333
+ }
1334
+ catch {
1335
+ return {
1336
+ status: "error",
1337
+ message: `Failed to write workspace file: ${rawPath}`,
1338
+ };
1339
+ }
945
1340
  return { status: "success" };
946
1341
  }
947
1342
  async webSearch(query, depth) {
@@ -1002,23 +1397,35 @@ class ArenaToolExecutor {
1002
1397
  }
1003
1398
  }
1004
1399
  }
1400
+ if (!saved.length) {
1401
+ return {
1402
+ status: "error",
1403
+ message: "Image search failed: Strict safe search filtered all results for this query. " +
1404
+ "Try a different or more specific query.",
1405
+ };
1406
+ }
1005
1407
  return { status: "success", results: saved };
1006
1408
  }
1007
1409
  async presentFile(rawPath) {
1008
- const target = await this.confinedPath(rawPath);
1009
- if (!(await stat(target)).isFile())
1010
- throw new Error(rawPath);
1011
- return { path: rawPath, status: "success" };
1410
+ try {
1411
+ const target = await this.confinedPath(rawPath);
1412
+ if (!(await stat(target)).isFile())
1413
+ throw new Error(rawPath);
1414
+ return { path: rawPath, status: "success" };
1415
+ }
1416
+ catch {
1417
+ return { status: "error", message: "Error calling present file tool" };
1418
+ }
1012
1419
  }
1013
1420
  async generateImage(rawPath, prompt, images) {
1014
1421
  const target = await this.confinedPath(rawPath);
1015
- if (![".jpg", ".jpeg", ".png"].includes(path.extname(target).toLowerCase())) {
1422
+ const extension = path.extname(target).toLowerCase();
1423
+ if (![".jpg", ".jpeg", ".png"].includes(extension)) {
1016
1424
  throw new Error("file_path must end in .jpg, .jpeg, or .png");
1017
1425
  }
1018
1426
  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 = [];
1427
+ const model = process.env.ARENA_IMAGE_MODEL || "gemini-2.5-flash-image";
1428
+ const references = [];
1022
1429
  for (const imagePath of images || []) {
1023
1430
  const source = await this.confinedPath(imagePath);
1024
1431
  const extension = path.extname(source).toLowerCase();
@@ -1026,173 +1433,66 @@ class ArenaToolExecutor {
1026
1433
  if (!mimeType?.startsWith("image/")) {
1027
1434
  throw new Error(`unsupported reference image format: ${imagePath}`);
1028
1435
  }
1029
- const bytes = await readFile(source);
1436
+ let bytes;
1437
+ try {
1438
+ bytes = await readFile(source);
1439
+ }
1440
+ catch {
1441
+ throw new Error(`Failed to read workspace file: ${imagePath}`);
1442
+ }
1030
1443
  if (bytes.length > 20 * 1024 * 1024) {
1031
1444
  throw new Error(`reference image exceeds 20 MB: ${imagePath}`);
1032
1445
  }
1033
- content.push({
1034
- image: `data:${mimeType};base64,${bytes.toString("base64")}`,
1446
+ references.push({
1447
+ bytes,
1448
+ fileName: path.basename(source),
1449
+ mimeType,
1035
1450
  });
1036
1451
  }
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
- }),
1452
+ const generated = await generateImageWithModelRouter({
1453
+ images: references,
1454
+ model,
1455
+ prompt,
1068
1456
  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));
1457
+ size: process.env.ARENA_IMAGE_SIZE || "1024*1024",
1458
+ });
1459
+ await writeFile(target, await generatedImageBytesForExtension(generated.bytes, generated.mimeType, extension));
1123
1460
  return {
1124
1461
  status: "success",
1125
1462
  file_path: this.relativePath(target),
1126
1463
  };
1127
1464
  }
1128
- async generateSpeech(rawPath, text, language, voiceIdentity = {}) {
1465
+ async generateSpeech(rawPath, text, voiceId, language) {
1129
1466
  const target = await this.confinedPath(rawPath);
1130
1467
  const extension = path.extname(target).toLowerCase();
1131
1468
  const format = {
1132
1469
  ".mp3": "mp3",
1133
1470
  ".wav": "wav",
1471
+ ".ogg": "opus",
1134
1472
  ".opus": "opus",
1135
1473
  }[extension];
1136
1474
  if (!format) {
1137
- throw new Error("DashScope speech output supports .mp3, .wav, or .opus");
1475
+ throw new Error("ModelRouter Doubao TTS output supports .mp3, .wav, .ogg, or .opus");
1138
1476
  }
1139
1477
  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)}`);
1478
+ const registered = this.voiceRegistry.resolve(voiceId);
1479
+ if (!registered) {
1480
+ throw new Error(`unknown voice_id: ${voiceId}. Call add_voice and use the returned voice_id.`);
1190
1481
  }
1191
- const audioResponse = await fetch(audioUrl, { signal: this.signal });
1192
- if (!audioResponse.ok) {
1193
- throw new Error(`audio download failed (${audioResponse.status})`);
1482
+ if (language &&
1483
+ language.toLowerCase().split(/[-_]/)[0] !==
1484
+ registered.language.toLowerCase().split(/[-_]/)[0]) {
1485
+ throw new Error(`voice ${voiceId} was registered for ${registered.language}; ` +
1486
+ `register a voice for ${language} with add_voice`);
1194
1487
  }
1195
- await writeFile(target, await responseBytes(audioResponse, 25 * 1024 * 1024));
1488
+ const voice = process.env.ARENA_SPEECH_VOICE || registered.providerVoice;
1489
+ await writeFile(target, await generateSpeechWithModelRouter({
1490
+ format,
1491
+ model: process.env.ARENA_SPEECH_MODEL || "doubao-tts",
1492
+ signal: this.signal,
1493
+ text,
1494
+ voice,
1495
+ }));
1196
1496
  return {
1197
1497
  status: "success",
1198
1498
  file_path: this.relativePath(target),