yuanflow-cli 0.1.44 → 0.1.45

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.
package/README.md CHANGED
@@ -6,6 +6,7 @@ YuanFlow 的 npm 包,包含两个命令入口:
6
6
  - `yuanflow-skill`:把 `YuanFlow-skill` 注入到本机支持的 AI Agent skills 目录。
7
7
  - `飞书官方技能`:YuanFlow-main 内置环境通过受控工具托管安装并调用飞书官方 `@larksuite/cli`,npm 包只提供 Skill 说明,不复制官方 CLI 源码。
8
8
  - `视觉卡片生成`:作为 Skill、HTML 模板、参考文档和检查脚本随包分发,不新增独立 `yuanflow-cli visual-card` 命令。
9
+ - `声音克隆 / 声音复刻`:通过 `yuanflow-cli voice` 命令创建、查询、激活 `voice_xxx`,并把文本复刻生成音频文件。
9
10
 
10
11
  ## 安装
11
12
 
@@ -53,6 +54,10 @@ yuanflow-cli video plan --project "D:\素材\yuanflow-video-edit" --timeline-pla
53
54
  yuanflow-cli ai qwen3-vl-plus --prompt "描述这张图" --image-url "https://example.com/image.png" --format agent-json
54
55
  yuanflow-cli ai qwen3-vl-plus --prompt "总结这个视频画面" --video-url "https://example.com/video.mp4" --format agent-json
55
56
  yuanflow-cli ai qwen3-vl-plus --prompt "描述本地视频" --video-file "D:\素材\demo.mp4" --format agent-json
57
+ yuanflow-cli voice clone --file-transfer "D:\voice\sample.wav" --name "我的声音" --activate --format agent-json
58
+ yuanflow-cli voice list --format agent-json
59
+ yuanflow-cli voice activate --voice voice_xxx --format agent-json
60
+ yuanflow-cli voice replicate --text "你好,这是声音复刻测试。" --voice voice_xxx --output "D:\voice\replicate.mp3" --format agent-json
56
61
  yuanflow-cli ai doubao-tts voices --format agent-json
57
62
  yuanflow-cli ai doubao-tts voice --voice zh_female_xiaohe_uranus_bigtts --format agent-json
58
63
  yuanflow-cli ai doubao-tts voice-download --voice zh_female_xiaohe_uranus_bigtts --output "D:\voice\preview.mp3" --format agent-json
@@ -69,6 +74,33 @@ YUANCHUANG_API_TOKEN=<你的令牌>
69
74
 
70
75
  token 优先级:`--token` > `YUANCHUANG_API_TOKEN` > 本地 `config.token`。独立 CLI 用户可以使用环境变量或 `config set-token`;在 YuanFlow-main 内置环境使用时,token 由 YuanFlow-main 内置环境注入,不需要手动配置。本地图片/视频上传统一使用 YuanFlow 文件中转,不需要用户配置第三方平台 Key。
71
76
 
77
+ ### 声音克隆与声音复刻
78
+
79
+ `voice` 命令组用于创建声音克隆、查询已有克隆音色 ID、激活默认音色,并使用 `voice_xxx` 把文本复刻为音频文件。声音复刻前必须先有 `voice_xxx`,或已激活默认音色。
80
+
81
+ ```bash
82
+ # 创建声音克隆:本地音频先走 YuanFlow 文件中转
83
+ yuanflow-cli voice clone --file-transfer "D:\voice\sample.wav" --name "我的声音" --activate --format agent-json
84
+
85
+ # 查询已有声音克隆 ID
86
+ yuanflow-cli voice list --format agent-json
87
+
88
+ # 激活某个克隆音色为默认音色
89
+ yuanflow-cli voice activate --voice voice_xxx --format agent-json
90
+
91
+ # 使用克隆音色生成复刻音频
92
+ yuanflow-cli voice replicate --text "你好,这是声音复刻测试。" --voice voice_xxx --output "D:\voice\replicate.mp3" --format agent-json
93
+ ```
94
+
95
+ 命令清单:
96
+
97
+ - `voice clone`:`POST /v1/audio/voices`,创建声音克隆,常用参数 `--file`、`--file-transfer`、`--audio-url`、`--name`、`--activate`。
98
+ - `voice list`:`GET /v1/audio/voices`,查询当前用户已有声音克隆音色 ID。
99
+ - `voice activate`:`POST /v1/audio/voices/{voice_xxx}/activate`,把已有声音克隆设为默认音色。
100
+ - `voice replicate`:`POST /v1/audio/speech`,使用 `voice_xxx` 或 `default` 生成复刻音频,真实调用时必须传 `--output`。
101
+
102
+ `--file-transfer` 会先通过 YuanFlow 文件中转上传本地音频,再把临时访问链接提交给 YuanFlow API。`--file` 保留为直接提交本地音频的兼容方式。`--file`、`--file-transfer` 和 `--audio-url` 只能选择一个。
103
+
72
104
  ### AI 模型命令
73
105
 
74
106
  `ai` 命令用于调用 YuanFlow API 的 OpenAI 兼容端点。CLI 对外只使用 YuanFlow API 模型参数,不暴露底层供应商内部模型名。
@@ -79,8 +111,6 @@ yuanflow-cli ai qwen3-vl-plus --prompt "描述这张图" --image-url "https://ex
79
111
  yuanflow-cli ai qwen3-vl-plus --prompt "总结这个视频画面" --video-url "https://example.com/video.mp4" --format agent-json
80
112
  yuanflow-cli ai qwen3-vl-plus --prompt "描述本地图片" --image-file "D:\素材\cover.png" --format agent-json
81
113
  yuanflow-cli ai qwen3-vl-plus --prompt "描述本地视频" --video-file "D:\素材\demo.mp4" --format agent-json
82
- yuanflow-cli ai qwen-voice-enrollment --file "D:\voice\sample.wav" --name demo --activate --format agent-json
83
- yuanflow-cli ai qwen3-tts-vc-realtime-2026-01-15 --text "你好" --voice voice_xxx --output "D:\voice\qwen.mp3" --format agent-json
84
114
  yuanflow-cli ai fun-asr --audio-url "https://example.com/audio.wav" --response-format verbose_json --format agent-json
85
115
  yuanflow-cli ai doubao-tts voices --format agent-json
86
116
  yuanflow-cli ai doubao-tts voice --voice zh_female_xiaohe_uranus_bigtts --format agent-json
@@ -122,8 +152,6 @@ yuanflow-cli ai qwen3-vl-plus --prompt "描述本地视频" --video-file "D:\素
122
152
  命令清单:
123
153
 
124
154
  - `ai qwen3-vl-plus`:`POST /v1/chat/completions`,文本/图片/视频理解,常用参数 `--prompt`、`--image-url`、`--video-url`、`--image-file`、`--video-file`。
125
- - `ai qwen-voice-enrollment`:`POST /v1/audio/voices`,音色复刻,常用参数 `--file` 或 `--audio-url`、`--name`、`--activate`。
126
- - `ai qwen3-tts-vc-realtime-2026-01-15`:`POST /v1/audio/speech`,复刻音色合成,`--voice` 使用 `voice_xxx` 或 `default`。
127
155
  - `ai fun-asr`:`POST /v1/audio/transcriptions`,语音识别,`--audio-url` 适合远程音频,`--file` 适合本地音频直传。
128
156
  - `ai doubao-tts`:`POST /v1/audio/speech`,豆包语音合成,`--voice` 直接传豆包官方音色 ID。
129
157
  - `ai doubao-tts voices`:`GET /api/voice-assets/doubao/voices`,查询 doubao-tts 可用音色列表。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yuanflow-cli",
3
- "version": "0.1.44",
3
+ "version": "0.1.45",
4
4
  "description": "YuanFlow 自媒体 API CLI 与 Skill 安装器。",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1,146 @@
1
+ ---
2
+ name: 声音克隆
3
+ description: 当用户需要创建声音克隆、查询已有声音克隆音色 ID、激活默认克隆音色,或为后续声音复刻准备 voice_xxx 时使用。本 Skill 通过 YuanFlow API 和 yuanflow-cli voice 命令完成。
4
+ emoji: 🎙️
5
+ ---
6
+
7
+ # 声音克隆
8
+
9
+ 本 Skill 用于把参考音频注册成可复用的声音克隆音色,并返回 `voice_xxx` 音色 ID。后续要生成复刻音频时,交给 `声音复刻` Skill。
10
+
11
+ ## 外部 CLI 主流程
12
+
13
+ 外部 Agent 或用户直接使用时,优先使用 `yuanflow-cli voice ...` 命令。
14
+
15
+ 1. 先确认本机可执行 `yuanflow-cli --help`。
16
+ 2. 外部 CLI 使用 `YUANCHUANG_API_TOKEN` 或 `yuanflow-cli config set-token <你的令牌>` 完成鉴权。
17
+ 3. 选择音频输入方式:
18
+ - 本地音频直接提交:`--file`
19
+ - 本地音频先走 YuanFlow 文件中转:`--file-transfer`
20
+ - 已有公网音频链接:`--audio-url`
21
+ 4. 创建成功后,保存返回的 `voice_xxx`。这是后续声音复刻必须使用的音色 ID。
22
+
23
+ 不要在回复、日志或文件中暴露 token。用户主流程统一称为 YuanFlow API 和 YuanFlow 文件中转,不要求用户配置第三方平台 Key。
24
+
25
+ ## 创建声音克隆
26
+
27
+ 推荐本地文件使用 YuanFlow 文件中转:
28
+
29
+ ```powershell
30
+ yuanflow-cli voice clone --file-transfer "D:\voice\sample.wav" --name "我的声音" --activate --format agent-json
31
+ ```
32
+
33
+ 如果用户明确希望直接提交本地音频:
34
+
35
+ ```powershell
36
+ yuanflow-cli voice clone --file "D:\voice\sample.wav" --name "我的声音" --activate --format agent-json
37
+ ```
38
+
39
+ 如果已经有可访问音频 URL:
40
+
41
+ ```powershell
42
+ yuanflow-cli voice clone --audio-url "https://example.com/sample.wav" --name "我的声音" --activate --format agent-json
43
+ ```
44
+
45
+ 返回里重点读取:
46
+
47
+ ```text
48
+ data.response.id
49
+ ```
50
+
51
+ 如果响应结构被 Agent JSON 包裹,优先找 `voice_xxx` 格式的 `id` 字段。不要把内部原始音色参数展示给用户。
52
+
53
+ ## 查询已有声音克隆 ID
54
+
55
+ 当用户已经做过声音克隆但忘记 ID,先查询:
56
+
57
+ ```powershell
58
+ yuanflow-cli voice list --format agent-json
59
+ ```
60
+
61
+ 输出时只需要整理这些字段:
62
+
63
+ ```text
64
+ 声音名称:
65
+ 音色 ID:voice_xxx
66
+ 状态:
67
+ 是否默认:
68
+ 创建时间:
69
+ ```
70
+
71
+ 如果没有可用音色,提示用户先提供参考音频创建声音克隆。
72
+
73
+ ## 激活默认音色
74
+
75
+ 如果用户希望后续用 `default` 复刻,或指定某个克隆音色为默认:
76
+
77
+ ```powershell
78
+ yuanflow-cli voice activate --voice voice_xxx --format agent-json
79
+ ```
80
+
81
+ ## 音频要求
82
+
83
+ - 优先使用清晰、无背景音乐、无明显混响的人声音频。
84
+ - 建议使用 wav、mp3、m4a、flac 等常见格式。
85
+ - 文件过大、噪音明显或多人混说时,先让用户换音频或裁剪。
86
+ - 不要上传身份证、合同、私密通话、未授权人物声音等敏感内容,除非用户明确确认且任务确实需要。
87
+
88
+ ## YuanFlow-main 内置环境
89
+
90
+ 只有在 YuanFlow-main 内置环境中,才使用受控工具 `yuanflow_cli_call`。token、受管包路径和输出目录由 YuanFlow-main 管理,不写成外部用户必备步骤。
91
+
92
+ 创建声音克隆:
93
+
94
+ ```json
95
+ {
96
+ "args": [
97
+ "voice",
98
+ "clone",
99
+ "--file-transfer",
100
+ "D:\\voice\\sample.wav",
101
+ "--name",
102
+ "我的声音",
103
+ "--activate",
104
+ "--format",
105
+ "agent-json"
106
+ ],
107
+ "timeout": 300
108
+ }
109
+ ```
110
+
111
+ 查询已有声音克隆:
112
+
113
+ ```json
114
+ {
115
+ "args": [
116
+ "voice",
117
+ "list",
118
+ "--format",
119
+ "agent-json"
120
+ ],
121
+ "timeout": 120
122
+ }
123
+ ```
124
+
125
+ 激活默认音色:
126
+
127
+ ```json
128
+ {
129
+ "args": [
130
+ "voice",
131
+ "activate",
132
+ "--voice",
133
+ "voice_xxx",
134
+ "--format",
135
+ "agent-json"
136
+ ],
137
+ "timeout": 120
138
+ }
139
+ ```
140
+
141
+ ## 失败处理
142
+
143
+ - token 缺失:说明需要配置 YuanFlow API token,或在 YuanFlow-main 受控环境中运行。
144
+ - 没有返回 `voice_xxx`:说明声音克隆未完成,不要继续声音复刻。
145
+ - 已存在克隆限制:先用 `voice list` 查询已有音色,必要时让用户选择已有 `voice_xxx` 继续。
146
+ - 音频不可访问:如果使用 URL,让用户换成可访问链接;如果是本地文件,改用 `--file-transfer`。
@@ -0,0 +1,103 @@
1
+ ---
2
+ name: 声音复刻
3
+ description: 当用户需要使用已有声音克隆 ID 生成复刻语音、把文字合成为用户克隆声音,或要求根据 voice_xxx 输出音频文件时使用。使用前必须先完成声音克隆或查询到已有 voice_xxx。
4
+ emoji: 🔊
5
+ ---
6
+
7
+ # 声音复刻
8
+
9
+ 本 Skill 用于把文本合成为已有克隆音色的音频。使用前必须满足其中一个条件:
10
+
11
+ - 已通过 `声音克隆` Skill 创建过声音克隆,并拿到 `voice_xxx`。
12
+ - 已通过 `yuanflow-cli voice list` 查询到可用 `voice_xxx`。
13
+ - 用户明确指定使用已激活默认音色 `default`。
14
+
15
+ 如果没有声音克隆 ID,先调用 `声音克隆`,不要直接复刻。
16
+
17
+ ## 外部 CLI 主流程
18
+
19
+ 外部 Agent 或用户直接使用时,优先使用 `yuanflow-cli voice replicate`。
20
+
21
+ 1. 先确认本机可执行 `yuanflow-cli --help`。
22
+ 2. 外部 CLI 使用 `YUANCHUANG_API_TOKEN` 或 `yuanflow-cli config set-token <你的令牌>` 完成鉴权。
23
+ 3. 确认 `--voice` 是 `voice_xxx` 或 `default`。
24
+ 4. 用 `--output` 指定音频保存路径。
25
+
26
+ 不要在回复、日志或文件中暴露 token。用户主流程统一称为 YuanFlow API,不要求用户配置第三方平台 Key。
27
+
28
+ ## 查询或确认音色 ID
29
+
30
+ 如果用户没有提供 `voice_xxx`,先查询:
31
+
32
+ ```powershell
33
+ yuanflow-cli voice list --format agent-json
34
+ ```
35
+
36
+ 如果列表中没有可用音色,转到 `声音克隆` Skill,先创建声音克隆。
37
+
38
+ ## 生成复刻音频
39
+
40
+ ```powershell
41
+ yuanflow-cli voice replicate --text "你好,这是声音复刻测试。" --voice voice_xxx --output "D:\voice\replicate.mp3" --format agent-json
42
+ ```
43
+
44
+ 如果用户要求使用默认音色:
45
+
46
+ ```powershell
47
+ yuanflow-cli voice replicate --text "你好,这是声音复刻测试。" --voice default --output "D:\voice\replicate.mp3" --format agent-json
48
+ ```
49
+
50
+ 默认输出 `mp3`。用户要求 wav 或 pcm 时,增加 `--response-format`:
51
+
52
+ ```powershell
53
+ yuanflow-cli voice replicate --text "测试内容" --voice voice_xxx --response-format wav --output "D:\voice\replicate.wav" --format agent-json
54
+ ```
55
+
56
+ ## 输出要求
57
+
58
+ 最终回复给用户时说明:
59
+
60
+ ```text
61
+ 复刻音频已生成:
62
+ 文件路径:D:\voice\replicate.mp3
63
+ 使用音色:voice_xxx
64
+ ```
65
+
66
+ 如果命令返回 agent-json,优先读取:
67
+
68
+ ```text
69
+ data.response.output
70
+ data.response.bytes
71
+ data.response.content_type
72
+ ```
73
+
74
+ ## YuanFlow-main 内置环境
75
+
76
+ 只有在 YuanFlow-main 内置环境中,才使用受控工具 `yuanflow_cli_call`。token、受管包路径和输出目录由 YuanFlow-main 管理,不写成外部用户必备步骤。
77
+
78
+ ```json
79
+ {
80
+ "args": [
81
+ "voice",
82
+ "replicate",
83
+ "--text",
84
+ "你好,这是声音复刻测试。",
85
+ "--voice",
86
+ "voice_xxx",
87
+ "--output",
88
+ "replicate.mp3",
89
+ "--format",
90
+ "agent-json"
91
+ ],
92
+ "timeout": 300
93
+ }
94
+ ```
95
+
96
+ 在 YuanFlow-main 内置环境里,`--output` 会被限制到受控输出目录。不要要求用户手动传程序数据目录,也不要绕过 `yuanflow_cli_call` 直接写本地文件。
97
+
98
+ ## 失败处理
99
+
100
+ - 缺少 `voice_xxx`:先调用 `声音克隆` 的查询流程或创建流程。
101
+ - 音色不存在或无权限:提示用户重新查询 `voice list`,不要猜测 ID。
102
+ - 输出路径被拒绝:在 YuanFlow-main 内置环境中改用相对文件名,让受控工具自动放入输出目录。
103
+ - 生成失败:报告 YuanFlow API 返回的简短错误,不暴露 token、Authorization header 或完整敏感链接。
@@ -8,6 +8,7 @@ import { listSearchCommands, listWorkCommands } from './work-tools.js';
8
8
  import { listVideoCommands } from './video-tools.js';
9
9
  import { listTrendingCommands } from './trending-tools.js';
10
10
  import { listAiCommands } from './ai-tools.js';
11
+ import { listVoiceCommands } from './voice-tools.js';
11
12
 
12
13
  const ERROR_MAP = [
13
14
  {
@@ -113,6 +114,7 @@ export function buildCommandRegistry() {
113
114
  const videoCommands = listVideoCommands();
114
115
  const trendingCommands = listTrendingCommands();
115
116
  const aiCommands = listAiCommands();
117
+ const voiceCommands = listVoiceCommands();
116
118
  return [
117
119
  ...shortcuts,
118
120
  ...endpoints,
@@ -125,6 +127,7 @@ export function buildCommandRegistry() {
125
127
  ...videoCommands,
126
128
  ...trendingCommands,
127
129
  ...aiCommands,
130
+ ...voiceCommands,
128
131
  ].sort((left, right) => left.key.localeCompare(right.key));
129
132
  }
130
133
 
package/src/cli.js CHANGED
@@ -29,6 +29,7 @@ import {
29
29
  } from './work-tools.js';
30
30
  import { fetchVideoHotList } from './trending-tools.js';
31
31
  import { formatAiHelp, runAiCommand } from './ai-tools.js';
32
+ import { formatVoiceHelp, runVoiceCommand } from './voice-tools.js';
32
33
 
33
34
  export async function main(argv) {
34
35
  const args = argv.slice(2);
@@ -104,6 +105,11 @@ export async function main(argv) {
104
105
  return;
105
106
  }
106
107
 
108
+ if (command === 'voice') {
109
+ await handleVoice(rest);
110
+ return;
111
+ }
112
+
107
113
  if (command === 'schema') {
108
114
  await handleSchema(rest);
109
115
  return;
@@ -451,6 +457,20 @@ async function handleAi(args) {
451
457
  });
452
458
  }
453
459
 
460
+ async function handleVoice(args) {
461
+ const { positionals, options } = parseOptions(args);
462
+ const [action = 'help'] = positionals;
463
+ if (action === 'help' && !isAgentJsonFormat(options)) {
464
+ console.log(formatVoiceHelp());
465
+ return;
466
+ }
467
+ const result = await runVoiceCommand({ action, options });
468
+ await outputResult(result, { ...options, output: undefined }, {
469
+ command: `voice ${action}`,
470
+ meta: { endpoint: result.endpoint?.path, kind: result.endpoint?.kind || 'voice' },
471
+ });
472
+ }
473
+
454
474
  async function handleGeneratedCommand(platform, args) {
455
475
  if (!getPlatforms().includes(platform)) {
456
476
  throw new Error(`未知平台:${platform}。可用平台:${getPlatforms().join(', ')}`);
@@ -633,6 +653,10 @@ function printHelp() {
633
653
  yuanflow-cli ai qwen3-vl-plus --prompt "总结这个视频画面" --video-url "https://example.com/video.mp4" --dry-run
634
654
  yuanflow-cli ai qwen3-vl-plus --prompt "描述本地图片" --image-file "D:\\素材\\cover.png" --dry-run
635
655
  yuanflow-cli ai qwen3-vl-plus --prompt "描述本地视频" --video-file "D:\\素材\\demo.mp4" --dry-run
656
+ yuanflow-cli voice clone --file-transfer "D:\\voice\\sample.wav" --name demo --activate --dry-run
657
+ yuanflow-cli voice list --dry-run
658
+ yuanflow-cli voice activate --voice voice_xxx --dry-run
659
+ yuanflow-cli voice replicate --text "你好" --voice voice_xxx --output "D:\\voice\\replicate.mp3" --dry-run
636
660
  yuanflow-cli ai qwen-voice-enrollment --file "D:\\voice\\sample.wav" --name demo --activate --dry-run
637
661
  yuanflow-cli ai qwen3-tts-vc-realtime-2026-01-15 --text "你好" --voice voice_xxx --output "D:\\voice\\qwen.mp3" --dry-run
638
662
  yuanflow-cli ai fun-asr --audio-url "https://example.com/audio.wav" --response-format verbose_json --dry-run
@@ -0,0 +1,471 @@
1
+ import { readFile, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { readConfig } from './config.js';
4
+ import { callAtomic, cleanBaseUrl } from './atomic-request.js';
5
+
6
+ const AUDIO_SPEECH_PATH = '/v1/audio/speech';
7
+ const AUDIO_VOICES_PATH = '/v1/audio/voices';
8
+ const YUANFLOW_FILE_TRANSFER_PATH = '/atomic/oss/temp-upload';
9
+
10
+ const MODEL_VOICE_CLONE = 'qwen-voice-enrollment';
11
+ const MODEL_VOICE_REPLICATE = 'qwen3-tts-vc-realtime-2026-01-15';
12
+
13
+ export function listVoiceCommands() {
14
+ return [
15
+ voiceCommand({
16
+ key: 'voice.clone',
17
+ command: 'voice clone',
18
+ description: '通过 YuanFlow API 创建声音克隆,返回可复用的 voice_xxx 音色 ID。',
19
+ method: 'POST',
20
+ apiPath: AUDIO_VOICES_PATH,
21
+ options: [
22
+ option('--file', 'file', false, '本地音频文件;与 --file-transfer、--audio-url 三选一。'),
23
+ option('--file-transfer', 'fileTransfer', false, '本地音频文件;先通过 YuanFlow 文件中转生成临时 URL,再创建声音克隆。'),
24
+ option('--audio-url', 'audioUrl', false, '公网可访问音频 URL;与 --file、--file-transfer 三选一。'),
25
+ option('--name', 'name', false, '声音克隆展示名。'),
26
+ option('--preferred-name', 'preferredName', false, '偏好音色名,默认跟随 --name。'),
27
+ option('--text', 'text', false, '参考音频对应文本,可选。'),
28
+ option('--language', 'language', false, '语言代码,可选。'),
29
+ option('--activate', 'activate', false, '创建后设为当前默认音色。'),
30
+ ...commonOptions(),
31
+ ],
32
+ requestBody: {
33
+ model: MODEL_VOICE_CLONE,
34
+ audio: '<本地音频 data URI,或通过 audio_url 传入 YuanFlow 文件中转 URL>',
35
+ },
36
+ returns: '返回 voice_xxx 音色对象;后续 voice replicate 可通过 --voice voice_xxx 复刻声音。',
37
+ }),
38
+ voiceCommand({
39
+ key: 'voice.list',
40
+ command: 'voice list',
41
+ description: '查询当前 YuanFlow API 令牌下已有的声音克隆音色 ID。',
42
+ method: 'GET',
43
+ apiPath: AUDIO_VOICES_PATH,
44
+ options: commonOptions(),
45
+ requestBody: null,
46
+ returns: '返回当前用户可用的 voice_xxx 音色列表、名称、状态和是否默认激活。',
47
+ }),
48
+ voiceCommand({
49
+ key: 'voice.activate',
50
+ command: 'voice activate',
51
+ description: '把已有声音克隆音色设为默认音色,方便 voice replicate 使用 default 复刻。',
52
+ method: 'POST',
53
+ apiPath: `${AUDIO_VOICES_PATH}/{voice_xxx}/activate`,
54
+ options: [
55
+ option('--voice', 'voice', true, '声音克隆 ID,例如 voice_xxx。'),
56
+ ...commonOptions(),
57
+ ],
58
+ requestBody: null,
59
+ returns: '返回已激活的 voice_xxx 音色对象。',
60
+ }),
61
+ voiceCommand({
62
+ key: 'voice.replicate',
63
+ command: 'voice replicate',
64
+ description: '使用已有声音克隆音色 ID 复刻生成音频文件。',
65
+ method: 'POST',
66
+ apiPath: AUDIO_SPEECH_PATH,
67
+ options: [
68
+ option('--text', 'text', true, '待复刻合成文本。'),
69
+ option('--voice', 'voice', true, '声音克隆 ID:voice_xxx;也可传 default 使用已激活默认音色。'),
70
+ option('--output', 'output', true, '音频保存路径;dry-run 时可不传。'),
71
+ option('--response-format', 'responseFormat', false, 'mp3、wav、pcm 等,默认 mp3。'),
72
+ option('--speed', 'speed', false, '语速控制。'),
73
+ option('--sample-rate', 'sampleRate', false, '采样率。'),
74
+ option('--metadata', 'metadata', false, '透传给 YuanFlow API 的 metadata JSON。'),
75
+ ...commonOptions(),
76
+ ],
77
+ requestBody: {
78
+ model: MODEL_VOICE_REPLICATE,
79
+ input: '<text>',
80
+ voice: '<voice_xxx|default>',
81
+ },
82
+ returns: '返回音频二进制;CLI 通过 --output 保存到本地文件。',
83
+ }),
84
+ ];
85
+ }
86
+
87
+ export function formatVoiceHelp() {
88
+ return listVoiceCommands()
89
+ .map((command) => {
90
+ const options = command.options.map((item) => ` ${item.flag} ${item.label}`).join('\n');
91
+ return `${command.command}\n ${command.description}\n 接口:${command.method} ${command.apiPath}\n 参数:\n${options}\n 返回:${command.returns}`;
92
+ })
93
+ .join('\n\n');
94
+ }
95
+
96
+ export async function runVoiceCommand({ action = 'help', options }) {
97
+ switch (action) {
98
+ case 'help':
99
+ case 'list-commands':
100
+ return { ok: true, commands: listVoiceCommands() };
101
+ case 'clone':
102
+ return cloneVoice(options);
103
+ case 'list':
104
+ return listVoices(options);
105
+ case 'activate':
106
+ return activateVoice(options);
107
+ case 'replicate':
108
+ return replicateVoice(options);
109
+ default:
110
+ throw new Error(`未知 voice 命令:${action}。可执行 yuanflow-cli voice help 查看用法。`);
111
+ }
112
+ }
113
+
114
+ async function cloneVoice(options) {
115
+ const body = await buildVoiceCloneBody(options);
116
+ const response = await callJson(AUDIO_VOICES_PATH, options, body);
117
+ return result('voice clone', AUDIO_VOICES_PATH, body, response, { kind: 'voice-clone' });
118
+ }
119
+
120
+ async function listVoices(options) {
121
+ const response = await callGetJson(AUDIO_VOICES_PATH, options);
122
+ return result('voice list', AUDIO_VOICES_PATH, undefined, response, {
123
+ method: 'GET',
124
+ kind: 'voice-clone',
125
+ });
126
+ }
127
+
128
+ async function activateVoice(options) {
129
+ const voice = requiredVoice(options);
130
+ const endpointPath = `${AUDIO_VOICES_PATH}/${encodeURIComponent(voice)}/activate`;
131
+ const response = await callJson(endpointPath, options, {});
132
+ return result('voice activate', endpointPath, undefined, response, { kind: 'voice-clone' });
133
+ }
134
+
135
+ async function replicateVoice(options) {
136
+ const body = buildVoiceReplicateBody(options);
137
+ const response = await callBinary(AUDIO_SPEECH_PATH, options, body);
138
+ return result('voice replicate', AUDIO_SPEECH_PATH, body, response, { kind: 'voice-replicate' });
139
+ }
140
+
141
+ async function buildVoiceCloneBody(options) {
142
+ if (options.json) {
143
+ return JSON.parse(options.json);
144
+ }
145
+ const filePath = cleanOptional(options.file);
146
+ const fileTransferPath = cleanOptional(options.named?.['file-transfer']);
147
+ const audioUrl = cleanOptional(options.named?.['audio-url']);
148
+ const sources = [filePath, fileTransferPath, audioUrl].filter(Boolean);
149
+ if (sources.length === 0) {
150
+ throw new Error('缺少 --file、--file-transfer 或 --audio-url。');
151
+ }
152
+ if (sources.length > 1) {
153
+ throw new Error('--file、--file-transfer 和 --audio-url 只能选择一个。');
154
+ }
155
+
156
+ const body = {
157
+ model: MODEL_VOICE_CLONE,
158
+ ...optionalField('name', options.named?.name),
159
+ ...optionalField('preferred_name', options.named?.['preferred-name']),
160
+ ...optionalField('text', options.named?.text),
161
+ ...optionalField('language', options.named?.language),
162
+ ...optionalBooleanField('activate', options.named?.activate),
163
+ };
164
+ if (audioUrl) {
165
+ body.audio_url = audioUrl;
166
+ } else if (fileTransferPath) {
167
+ body.audio_url = await resolveYuanFlowAudioFile(fileTransferPath, options);
168
+ } else {
169
+ body.audio = options.dryRun ? '<data URI omitted in dry-run>' : await fileToDataUri(filePath);
170
+ }
171
+ return body;
172
+ }
173
+
174
+ function buildVoiceReplicateBody(options) {
175
+ if (options.json) {
176
+ return JSON.parse(options.json);
177
+ }
178
+ const text = cleanOptional(options.named?.text || options.named?.input);
179
+ if (!text) {
180
+ throw new Error('缺少 --text。');
181
+ }
182
+ const voice = requiredVoice(options);
183
+ const body = {
184
+ model: MODEL_VOICE_REPLICATE,
185
+ input: text,
186
+ voice,
187
+ response_format: cleanOptional(options.named?.['response-format']) || 'mp3',
188
+ ...optionalField('instructions', options.named?.instructions),
189
+ };
190
+ addNumber(body, 'speed', options.named?.speed);
191
+ const metadata = parseJsonObject(options.named?.metadata);
192
+ addNumber(metadata, 'sample_rate', options.named?.['sample-rate']);
193
+ if (Object.keys(metadata).length > 0) {
194
+ body.metadata = metadata;
195
+ }
196
+ return body;
197
+ }
198
+
199
+ async function resolveYuanFlowAudioFile(filePath, options) {
200
+ const filename = path.basename(filePath);
201
+ if (options.dryRun) {
202
+ return `<YuanFlow 文件中转 signed_url:${filename}>`;
203
+ }
204
+ const response = await callAtomic(YUANFLOW_FILE_TRANSFER_PATH, {
205
+ ...options,
206
+ json: undefined,
207
+ method: 'POST',
208
+ body: {
209
+ filename,
210
+ content_base64: (await readFile(filePath)).toString('base64'),
211
+ content_type: inferAudioMimeType(filePath),
212
+ },
213
+ });
214
+ const data = response?.data && typeof response.data === 'object' ? response.data : response;
215
+ const url = cleanOptional(data?.signed_url) || cleanOptional(data?.url);
216
+ if (!url) {
217
+ throw new Error('YuanFlow 文件中转未返回 signed_url 或 url。');
218
+ }
219
+ return url;
220
+ }
221
+
222
+ async function callJson(apiPath, options, body) {
223
+ const request = await buildRequest(apiPath, options, 'POST', body);
224
+ if (request.dryRun) {
225
+ return request;
226
+ }
227
+ const response = await fetch(request.url, {
228
+ method: 'POST',
229
+ headers: {
230
+ ...request.headers,
231
+ Accept: 'application/json',
232
+ 'Content-Type': 'application/json',
233
+ },
234
+ body: JSON.stringify(body || {}),
235
+ });
236
+ return readJsonResponse(response);
237
+ }
238
+
239
+ async function callGetJson(apiPath, options) {
240
+ const request = await buildRequest(apiPath, options, 'GET');
241
+ if (request.dryRun) {
242
+ return request;
243
+ }
244
+ const response = await fetch(request.url, {
245
+ method: 'GET',
246
+ headers: {
247
+ ...request.headers,
248
+ Accept: 'application/json',
249
+ },
250
+ });
251
+ return readJsonResponse(response);
252
+ }
253
+
254
+ async function callBinary(apiPath, options, body) {
255
+ const request = await buildRequest(apiPath, options, 'POST', body);
256
+ if (request.dryRun) {
257
+ return request;
258
+ }
259
+ if (!options.output) {
260
+ throw new Error('声音复刻需要 --output 指定保存路径。');
261
+ }
262
+ const response = await fetch(request.url, {
263
+ method: 'POST',
264
+ headers: {
265
+ ...request.headers,
266
+ Accept: '*/*',
267
+ 'Content-Type': 'application/json',
268
+ },
269
+ body: JSON.stringify(body || {}),
270
+ });
271
+ if (!response.ok) {
272
+ const text = await response.text();
273
+ throw new Error(`请求失败:HTTP ${response.status} ${text}`);
274
+ }
275
+ const bytes = Buffer.from(await response.arrayBuffer());
276
+ await writeFile(options.output, bytes);
277
+ return {
278
+ ok: true,
279
+ output: options.output,
280
+ bytes: bytes.length,
281
+ content_type: response.headers.get('content-type') || '',
282
+ };
283
+ }
284
+
285
+ async function buildRequest(apiPath, options, method, body) {
286
+ const config = await readConfig();
287
+ const baseUrl = cleanBaseUrl(options.baseUrl || config.baseUrl);
288
+ const token = options.token || process.env.YUANCHUANG_API_TOKEN || config.token || '';
289
+ const url = new URL(apiPath, baseUrl);
290
+ if (options.dryRun) {
291
+ return {
292
+ dryRun: true,
293
+ method,
294
+ url: url.toString(),
295
+ headers: token ? { Authorization: `Bearer ${maskToken(token)}` } : {},
296
+ body: redactBody(body),
297
+ };
298
+ }
299
+ if (!token) {
300
+ throw new Error('缺少 token。请设置 YUANCHUANG_API_TOKEN,或执行 yuanflow-cli config set-token <你的令牌>');
301
+ }
302
+ return {
303
+ method,
304
+ url: url.toString(),
305
+ headers: { Authorization: `Bearer ${token}` },
306
+ body,
307
+ };
308
+ }
309
+
310
+ async function readJsonResponse(response) {
311
+ const text = await response.text();
312
+ const payload = parseMaybeJson(text);
313
+ if (!response.ok) {
314
+ const message = typeof payload === 'object' ? JSON.stringify(payload) : text;
315
+ throw new Error(`请求失败:HTTP ${response.status} ${message}`);
316
+ }
317
+ return payload;
318
+ }
319
+
320
+ function result(action, endpointPath, body, response, endpoint = {}) {
321
+ return {
322
+ ok: true,
323
+ action,
324
+ endpoint: { method: endpoint.method || 'POST', path: endpointPath, kind: endpoint.kind || 'voice' },
325
+ request: { body: redactBody(body) },
326
+ response,
327
+ };
328
+ }
329
+
330
+ function voiceCommand({ key, command, description, method, apiPath, options, requestBody, returns }) {
331
+ return {
332
+ key,
333
+ command,
334
+ kind: 'voice',
335
+ description,
336
+ method,
337
+ apiPath,
338
+ positionals: [],
339
+ options,
340
+ requestBody,
341
+ returns,
342
+ };
343
+ }
344
+
345
+ function requiredVoice(options) {
346
+ const voice = cleanOptional(options.named?.voice || options.named?.['voice-id']);
347
+ if (!voice) {
348
+ throw new Error('缺少 --voice。');
349
+ }
350
+ return voice;
351
+ }
352
+
353
+ function commonOptions() {
354
+ return [
355
+ option('--json', 'json', false, '直接传完整 YuanFlow API 请求 JSON。'),
356
+ option('--token', 'token', false, '临时 token。'),
357
+ option('--base-url', 'baseUrl', false, 'YuanFlow API 地址。'),
358
+ option('--format', 'format', false, 'Agent 调用时建议使用 agent-json。'),
359
+ option('--dry-run', 'dryRun', false, '仅预览请求映射,不发起真实请求,也不要求 token。'),
360
+ ];
361
+ }
362
+
363
+ function option(flag, name, required, label) {
364
+ return { flag, name, required, label };
365
+ }
366
+
367
+ async function fileToDataUri(filePath) {
368
+ const data = await readFile(filePath);
369
+ return `data:${inferAudioMimeType(filePath)};base64,${data.toString('base64')}`;
370
+ }
371
+
372
+ function inferAudioMimeType(filePath) {
373
+ switch (path.extname(filePath).toLowerCase()) {
374
+ case '.mp3':
375
+ return 'audio/mpeg';
376
+ case '.wav':
377
+ return 'audio/wav';
378
+ case '.m4a':
379
+ return 'audio/mp4';
380
+ case '.ogg':
381
+ return 'audio/ogg';
382
+ case '.flac':
383
+ return 'audio/flac';
384
+ case '.pcm':
385
+ return 'audio/pcm';
386
+ default:
387
+ return 'application/octet-stream';
388
+ }
389
+ }
390
+
391
+ function parseJsonObject(value) {
392
+ const cleaned = cleanOptional(value);
393
+ if (!cleaned) {
394
+ return {};
395
+ }
396
+ const parsed = JSON.parse(cleaned);
397
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
398
+ throw new Error('--metadata 必须是 JSON 对象。');
399
+ }
400
+ return parsed;
401
+ }
402
+
403
+ function optionalField(name, value) {
404
+ const cleaned = cleanOptional(value);
405
+ return cleaned === undefined ? {} : { [name]: cleaned };
406
+ }
407
+
408
+ function optionalBooleanField(name, value) {
409
+ const parsed = parseBoolean(value);
410
+ return parsed === undefined ? {} : { [name]: parsed };
411
+ }
412
+
413
+ function addNumber(target, name, value) {
414
+ const cleaned = cleanOptional(value);
415
+ if (cleaned !== undefined) {
416
+ const number = Number(cleaned);
417
+ target[name] = Number.isFinite(number) ? number : cleaned;
418
+ }
419
+ }
420
+
421
+ function parseBoolean(value) {
422
+ const cleaned = cleanOptional(value);
423
+ if (cleaned === undefined) {
424
+ return undefined;
425
+ }
426
+ if (typeof cleaned === 'boolean') {
427
+ return cleaned;
428
+ }
429
+ return ['1', 'true', 'yes', 'on'].includes(String(cleaned).toLowerCase());
430
+ }
431
+
432
+ function cleanOptional(value) {
433
+ if (value === undefined || value === null) return undefined;
434
+ if (typeof value === 'string') {
435
+ const trimmed = value.trim();
436
+ return trimmed ? trimmed : undefined;
437
+ }
438
+ return value;
439
+ }
440
+
441
+ function redactBody(body) {
442
+ if (!body || typeof body !== 'object' || Array.isArray(body)) {
443
+ return body;
444
+ }
445
+ const redacted = { ...body };
446
+ if ('audio' in redacted) {
447
+ redacted.audio = '<data URI omitted>';
448
+ }
449
+ return redacted;
450
+ }
451
+
452
+ function parseMaybeJson(text) {
453
+ if (!text) {
454
+ return null;
455
+ }
456
+ try {
457
+ return JSON.parse(text);
458
+ } catch {
459
+ return text;
460
+ }
461
+ }
462
+
463
+ function maskToken(token) {
464
+ if (!token) {
465
+ return '';
466
+ }
467
+ if (token.length <= 10) {
468
+ return '***';
469
+ }
470
+ return `${token.slice(0, 6)}...${token.slice(-4)}`;
471
+ }