yuanflow-cli 0.1.47 → 0.1.49

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.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: 视频拆解
3
- description: 当用户提交对标视频、本地视频、视频 URL,要求做自媒体创作方向的视频拆解、爆款结构分析、内容复盘、脚本拆解、镜头节奏拆解、账号/竞品内容学习时使用。开始前必须先使用“自媒体知识库”查询与对标拆解相关的规则,再通过 yuanflow-cli qwen3-vl-plus 上传/理解视频,并把知识库规则放入提示词中指导拆解。
3
+ description: 当用户提交对标视频、本地视频、视频 URL,要求做自媒体创作方向的视频拆解、爆款结构分析、内容复盘、脚本拆解、镜头节奏拆解、视频风格解析、账号/竞品内容学习时使用。开始前必须先查自媒体知识库;平台 URL 还要尽量获取作品详情和视频文件,再用 qwen3-vl-plus 规则化拆解。
4
4
  emoji: 🎬
5
5
  ---
6
6
 
@@ -11,19 +11,20 @@ emoji: 🎬
11
11
  核心链路:
12
12
 
13
13
  1. 先查 `自媒体知识库`,获取与“对标拆解、视频拆解、内容创作、脚本结构、镜头节奏、爆款复盘”相关的规则摘要。
14
- 2. 如果用户给的是视频 URL,先用相关工具解析并保存到本地。
15
- 3. 使用 `yuanflow-cli ai qwen3-vl-plus --video-file` 提交本地视频。
16
- 4. 把知识库拆解规则写进 prompt,让模型按规则拆解视频。
17
- 5. 整理成适合自媒体创作复用的详细拆解报告。
14
+ 2. 如果用户给的是平台视频 URL,先获取作品详情,尽量拿到标题、文案、封面、作者、发布时间、点赞/评论/转发/收藏等互动数据。
15
+ 3. 如果需要分析视频画面,再用下载/播放相关工具解析并保存到本地。
16
+ 4. 使用 `yuanflow-cli ai qwen3-vl-plus --video-file` 提交本地视频。
17
+ 5. 把知识库规则、作品详情摘要和用户目标写进 prompt,让模型按规则拆解视频。
18
+ 6. 合并“平台元数据 + 视觉理解结果 + 知识库规则”,整理成适合自媒体创作复用的详细拆解报告。
18
19
 
19
20
  ## 外部 CLI 主流程
20
21
 
21
- 外部 Agent 或用户直接使用时,先用 `yuanflow-cli knowledge ...` 查询拆解规则,再用 `yuanflow-cli ai qwen3-vl-plus` 处理视频。
22
+ 外部 Agent 或用户直接使用时,先用 `yuanflow-cli knowledge ...` 查询拆解规则;如果输入是平台作品 URL,再用 `yuanflow-cli works detail` 获取作品详情,用 `yuanflow-cli works download` 获取可播放/下载地址;最后用 `yuanflow-cli ai qwen3-vl-plus` 处理本地视频。
22
23
 
23
24
  1. 先确认本机可执行 `yuanflow-cli --help`。
24
25
  2. 外部 CLI 使用 `YUANCHUANG_API_TOKEN` 或 `yuanflow-cli config set-token <你的令牌>` 完成鉴权。
25
26
  3. 本地视频会先经过 YuanFlow 文件中转,再提交给 YuanFlow API。
26
- 4. 如果是平台视频 URL,先解析并在用户授权后保存到本地文件。
27
+ 4. 如果是平台视频 URL,先获取作品详情;需要画面拆解时,再解析并在用户授权后保存到本地文件。
27
28
 
28
29
  本地视频上传依赖 qwen3-vl-plus 的 YuanFlow 文件中转能力。外部 Agent 只需要配置 YuanFlow API token;不要要求用户提供第三方平台 Key。
29
30
 
@@ -74,7 +75,42 @@ YuanFlow-main 内置环境调用见后文专属小节。
74
75
  - 账号定位
75
76
  - 带货/转化结构
76
77
 
77
- ### 3. 视频 URL 先解析并保存本地
78
+ ### 3. 平台视频 URL 先获取作品详情
79
+
80
+ 如果用户提交的是平台视频 URL,先用 `作品详情获取工具` 或 `yuanflow-cli works detail` 获取平台元数据。它负责标题、文案/描述、封面、作者、发布时间、互动统计和媒体信息;这些内容不要让视觉模型凭画面猜。
81
+
82
+ 先按链接判断平台:
83
+
84
+ - `douyin.com`、`v.douyin.com`:`--platform douyin`。
85
+ - `xiaohongshu.com`、`xhslink.com`:`--platform xiaohongshu`,如详情接口要求 `xsec_token`,按返回或用户提供信息补充 `--xsec-token`。
86
+ - `bilibili.com`、`b23.tv`、`BV`:`--platform bilibili`。
87
+ - `youtube.com`、`youtu.be`:`--platform youtube`。
88
+ - `tiktok.com`、`vm.tiktok.com`:`--platform tiktok`。
89
+ - `kuaishou.com`:`--platform kuaishou`。
90
+ - `xigua.com`:`--platform xigua`。
91
+
92
+ 外部 CLI 示例:
93
+
94
+ ```powershell
95
+ yuanflow-cli works detail --platform douyin --target "https://v.douyin.com/xxx/" --format agent-json
96
+ ```
97
+
98
+ 拿到详情后,先提炼成“作品详情摘要”,供后续 prompt 使用:
99
+
100
+ ```text
101
+ 【作品详情摘要】
102
+ - 标题:
103
+ - 文案/描述:
104
+ - 作者:
105
+ - 发布时间:
106
+ - 封面:
107
+ - 点赞/评论/转发/收藏/播放:
108
+ - 其它可确认媒体信息:
109
+ ```
110
+
111
+ 如果详情接口没有返回某些字段,最终报告里写“未返回/未确认”,不要编造。
112
+
113
+ ### 4. 视频 URL 再解析并保存本地
78
114
 
79
115
  如果用户提交的是平台视频 URL,先用 `作品下载综合工具` 或 `yuanflow-cli works download` 解析可播放/下载地址候选。
80
116
 
@@ -85,6 +121,8 @@ YuanFlow-main 内置环境调用见后文专属小节。
85
121
  - `youtube.com`、`youtu.be`:`--platform youtube`。
86
122
  - `ixigua.com`:`--platform xigua`。
87
123
 
124
+ 注意:`works detail` 支持的平台更多,`works download` 当前只覆盖可播放/下载地址已接入的平台。详情能拿到不代表一定能直接下载视频;下载失败时,让用户提供本地视频或可直接访问的视频文件。
125
+
88
126
  外部 CLI 示例:
89
127
 
90
128
  ```powershell
@@ -121,21 +159,66 @@ ffmpeg -y -i "<解析得到的 m3u8 地址>" -c copy "<视频拆解工作目录>
121
159
 
122
160
  如果平台解析只返回播放候选而没有稳定下载 URL,说明当前链接需要用户提供可下载文件,或先让用户使用其它授权方式保存视频。
123
161
 
124
- ### 4. 调用 qwen3-vl-plus 做规则化拆解
162
+ ### 5. 调用 qwen3-vl-plus 做规则化拆解
125
163
 
126
- 把知识库查询到的规则摘要压缩进 prompt。不要只让模型“分析视频”,必须告诉它按规则拆解。
164
+ 把知识库查询到的规则摘要、作品详情摘要和用户目标压缩进 prompt。不要只让模型“分析视频”,必须告诉它按规则拆解,也要明确哪些字段来自平台详情、哪些内容来自视频画面理解。
127
165
 
128
166
  外部 CLI 示例:
129
167
 
130
168
  ```powershell
131
- yuanflow-cli ai qwen3-vl-plus --prompt "你是自媒体视频拆解助手。请按知识库规则拆解这个视频:..." --video-file "<本地视频路径>" --format agent-json
169
+ yuanflow-cli ai qwen3-vl-plus --prompt "你是自媒体视频拆解助手。请按知识库规则和作品详情摘要拆解这个视频:..." --video-file "<本地视频路径>" --format agent-json
132
170
  ```
133
171
 
134
172
  ## YuanFlow-main 内置环境
135
173
 
136
174
  在 YuanFlow-main 内置环境,优先调用受控工具 `yuanflow_cli_call`。token、受管包路径和输出目录由 YuanFlow-main 管理,不要要求用户手动提供 YuanFlow token。
137
175
 
138
- YuanFlow-main 内置工具示例:
176
+ 内置环境应按阶段调用:
177
+
178
+ 1. `knowledge entry/packs/rules`:查询拆解规则。
179
+ 2. `works detail`:平台 URL 获取标题、文案、封面和互动数据。
180
+ 3. `works download`:需要画面拆解时获取可播放/下载地址并保存本地。
181
+ 4. `ai qwen3-vl-plus`:提交本地视频做视觉与风格拆解。
182
+
183
+ YuanFlow-main 作品详情调用示例:
184
+
185
+ ```json
186
+ {
187
+ "args": [
188
+ "works",
189
+ "detail",
190
+ "--platform",
191
+ "douyin",
192
+ "--target",
193
+ "https://v.douyin.com/xxx/",
194
+ "--format",
195
+ "agent-json"
196
+ ],
197
+ "timeout": 180
198
+ }
199
+ ```
200
+
201
+ YuanFlow-main 下载/播放地址调用示例:
202
+
203
+ ```json
204
+ {
205
+ "args": [
206
+ "works",
207
+ "download",
208
+ "--platform",
209
+ "douyin",
210
+ "--target",
211
+ "https://v.douyin.com/xxx/",
212
+ "--region",
213
+ "CN",
214
+ "--format",
215
+ "agent-json"
216
+ ],
217
+ "timeout": 300
218
+ }
219
+ ```
220
+
221
+ YuanFlow-main 视频拆解调用示例:
139
222
 
140
223
  ```json
141
224
  {
@@ -143,7 +226,7 @@ YuanFlow-main 内置工具示例:
143
226
  "ai",
144
227
  "qwen3-vl-plus",
145
228
  "--prompt",
146
- "你是自媒体视频拆解助手。请严格按以下知识库规则拆解这个对标视频:\\n【知识库规则摘要】...\\n【用户目标】学习该视频的选题、开头、脚本结构、镜头节奏和可复用创作方法。\\n请输出:1. 一句话总结;2. 视频定位;3. 开头钩子;4. 内容结构;5. 镜头/画面节奏;6. 情绪和转化设计;7. 可复用模板;8. 不建议照搬的风险。",
229
+ "你是自媒体视频拆解助手。请严格按以下知识库规则和作品详情摘要拆解这个对标视频:\\n【知识库规则摘要】...\\n【作品详情摘要】标题、文案、封面、作者、发布时间、互动数据...\\n【用户目标】学习该视频的选题、开头、脚本结构、镜头节奏、视频风格和可复用创作方法。\\n请区分平台详情可确认字段和视频画面分析结论,不要编造未返回的数据。",
147
230
  "--video-file",
148
231
  "<本地视频路径>",
149
232
  "--format",
@@ -169,43 +252,71 @@ YuanFlow-main 内置工具示例:
169
252
 
170
253
  ## 1. 一句话结论
171
254
 
172
- ## 2. 视频基础判断
255
+ ## 2. 视频基础信息与数据表现
256
+ - 标题:
257
+ - 文案/描述:
258
+ - 作者/账号:
259
+ - 发布时间:
260
+ - 封面:
261
+ - 点赞/评论/转发/收藏/播放:
262
+ - 数据初步判断:
263
+ - 未返回或不确定字段:
264
+
265
+ ## 3. 视频基础判断
173
266
  - 内容类型:
174
267
  - 目标受众:
175
268
  - 核心卖点/观点:
176
269
  - 适用平台:
177
270
 
178
- ## 3. 开头钩子拆解
271
+ ## 4. 封面、标题与文案拆解
272
+ - 封面吸引点:
273
+ - 标题钩子:
274
+ - 文案结构:
275
+ - 标题/文案/封面的匹配度:
276
+
277
+ ## 5. 开头钩子拆解
179
278
  - 前 3 秒:
180
279
  - 冲突/利益点:
181
280
  - 留人方式:
182
281
 
183
- ## 4. 内容结构拆解
282
+ ## 6. 内容结构拆解
184
283
  - 段落 1:
185
284
  - 段落 2:
186
285
  - 段落 3:
187
286
  - 结尾:
188
287
 
189
- ## 5. 画面与镜头节奏
288
+ ## 7. 画面与镜头节奏
190
289
  - 场景变化:
191
290
  - 人物/产品/字幕:
192
291
  - 节奏特点:
193
292
 
194
- ## 6. 情绪、信任和转化设计
293
+ ## 8. 视频风格解析拆解
294
+ - 整体风格标签:
295
+ - 镜头语言:
296
+ - 剪辑节奏:
297
+ - 字幕与包装:
298
+ - 音乐/音效:
299
+ - 叙事语气:
300
+ - 人设或账号气质:
301
+ - 平台适配特点:
302
+ - 可复用风格公式:
303
+
304
+ ## 9. 情绪、信任和转化设计
195
305
 
196
- ## 7. 可复用创作模板
306
+ ## 10. 可复用创作模板
197
307
 
198
- ## 8. 可借鉴点与风险
308
+ ## 11. 可借鉴点与风险
199
309
  - 可借鉴:
200
310
  - 不建议照搬:
201
311
  - 需要二次原创:
202
312
 
203
- ## 9. 给用户的下一步建议
313
+ ## 12. 给用户的下一步建议
204
314
  ```
205
315
 
206
316
  ## 失败处理
207
317
 
208
318
  - 知识库查询失败:说明无法取得拆解规则,不要跳过规则直接当普通视觉理解;可询问用户是否改为通用视觉理解。
319
+ - 作品详情获取失败:说明无法补齐标题、文案、封面和互动数据;可继续做视频画面拆解,但最终报告必须标注平台元数据缺失。
209
320
  - 视频 URL 解析失败:说明需要用户提供本地视频或可直接访问的下载链接。
210
321
  - YuanFlow token 缺失:说明需要在受管环境或 CLI 配置 YuanFlow API token,不要让用户在聊天里粘贴敏感 Key。
211
322
  - 视频超限:提示最大 2GB、2 秒到 1 小时,让用户裁剪或压缩。
@@ -0,0 +1,181 @@
1
+ ---
2
+ name: 语音合成
3
+ description: 当用户需要使用 YuanFlow API 的 doubao-tts 预置音色把文本合成为音频,查询可用音色、确认单个音色详情,或下载音色试听文件后选择 voice_type 时使用。本 Skill 只使用预置音色 ID,不用于声音克隆或自行设计音色。
4
+ emoji: 🔈
5
+ ---
6
+
7
+ # 语音合成
8
+
9
+ 本 Skill 用于把文本合成为语音文件。它依赖 YuanFlow API 已准备好的预置音色列表,合成时必须使用列表里的 `voice_type` 作为音色 ID。
10
+
11
+ 重要边界:
12
+
13
+ - 这是“预置音色 + 文本合成语音”,不是声音克隆。
14
+ - 不能自行设计音色,也不能通过提示词创造一个新音色。
15
+ - 如果用户要克隆本人或指定人物声音,转到 `声音克隆` 和 `声音复刻` Skill。
16
+ - 如果用户不确定选哪个音色,先查询音色列表,再下载试听音频给用户确认。
17
+
18
+ ## 外部 CLI 主流程
19
+
20
+ 外部 Agent 或用户直接使用时,优先使用 `yuanflow-cli ai doubao-tts ...` 命令。
21
+
22
+ 1. 先确认本机可执行 `yuanflow-cli --help`。
23
+ 2. 外部 CLI 使用 `YUANCHUANG_API_TOKEN` 或 `yuanflow-cli config set-token <你的令牌>` 完成鉴权。
24
+ 3. 先查可用音色列表,读取 `voice_type`。
25
+ 4. 用户不确定音色时,下载试听音频到本地,让用户听完再选。
26
+ 5. 使用确定的 `voice_type` 执行文本转语音。
27
+
28
+ 不要在回复、日志或文件中暴露 token。用户主流程统一称为 YuanFlow API,不要求用户配置第三方平台 Key。
29
+
30
+ ## 查询全部音色
31
+
32
+ ```powershell
33
+ yuanflow-cli ai doubao-tts voices --format agent-json
34
+ ```
35
+
36
+ 返回里重点读取:
37
+
38
+ ```text
39
+ data.response.data.data[].display_name
40
+ data.response.data.data[].voice_type
41
+ data.response.data.data[].category
42
+ data.response.data.data[].language
43
+ data.response.data.data[].capabilities
44
+ ```
45
+
46
+ 给用户展示时用 `display_name`、分类、语言和能力描述;真正合成时只使用 `voice_type`。
47
+
48
+ ## 查询单个音色
49
+
50
+ 当用户指定了某个 `voice_type`,或需要确认某个音色是否存在:
51
+
52
+ ```powershell
53
+ yuanflow-cli ai doubao-tts voice --voice "<voice_type>" --format agent-json
54
+ ```
55
+
56
+ 如果查询不到,不要猜测相近 ID,重新查询音色列表。
57
+
58
+ ## 下载音色试听
59
+
60
+ 用户不确定使用哪个音色时,先下载试听文件到本地:
61
+
62
+ ```powershell
63
+ yuanflow-cli ai doubao-tts voice-download --voice "<voice_type>" --output "<试听音频输出路径>" --format agent-json
64
+ ```
65
+
66
+ 注意:
67
+
68
+ - 试听下载接口返回的是临时下载 URL,可能过期。
69
+ - 不要把临时 URL 写进长期文档或公开回复。
70
+ - 如果只需要拿到临时 URL,可以不传 `--output`;但用户要试听时优先传 `--output` 保存成本地音频文件。
71
+ - 试听资源查询和下载不做正式合成扣费,但仍需要有效 token 和可用余额。
72
+
73
+ ## 合成语音
74
+
75
+ 确认 `voice_type` 后,把文本合成为音频:
76
+
77
+ ```powershell
78
+ yuanflow-cli ai doubao-tts --text "你好,这是语音合成测试。" --voice "<voice_type>" --output "<输出音频路径>" --format agent-json
79
+ ```
80
+
81
+ 默认输出 `mp3`。用户要求其它格式时,增加 `--response-format`:
82
+
83
+ ```powershell
84
+ yuanflow-cli ai doubao-tts --text "这是一段 WAV 格式的合成测试。" --voice "<voice_type>" --response-format wav --output "<输出音频路径>" --format agent-json
85
+ ```
86
+
87
+ 常用参数:
88
+
89
+ - `--text`:要合成的文本。
90
+ - `--voice`:音色列表返回的 `voice_type`。
91
+ - `--output`:合成音频保存路径。
92
+ - `--response-format`:`mp3`、`wav`、`opus`、`pcm` 等,默认 `mp3`。
93
+ - `--speed`:语速;不传时使用 YuanFlow API 默认语速。
94
+ - `--metadata`:高级透传参数。普通用户不要主动使用,除非明确知道要覆盖什么参数。
95
+
96
+ ## 输出要求
97
+
98
+ 最终回复给用户时说明:
99
+
100
+ ```text
101
+ 语音已生成:
102
+ 文件路径:`<输出音频路径>`
103
+ 使用音色:<display_name> / <voice_type>
104
+ 输出格式:
105
+ ```
106
+
107
+ 如果命令返回 agent-json,优先读取:
108
+
109
+ ```text
110
+ data.response.output
111
+ data.response.bytes
112
+ data.response.content_type
113
+ ```
114
+
115
+ ## YuanFlow-main 内置环境
116
+
117
+ 只有在 YuanFlow-main 内置环境中,才使用受控工具 `yuanflow_cli_call`。token、受管包路径和输出目录由 YuanFlow-main 管理,不写成外部用户必备步骤。
118
+
119
+ 查询音色列表:
120
+
121
+ ```json
122
+ {
123
+ "args": [
124
+ "ai",
125
+ "doubao-tts",
126
+ "voices",
127
+ "--format",
128
+ "agent-json"
129
+ ],
130
+ "timeout": 120
131
+ }
132
+ ```
133
+
134
+ 下载试听音频:
135
+
136
+ ```json
137
+ {
138
+ "args": [
139
+ "ai",
140
+ "doubao-tts",
141
+ "voice-download",
142
+ "--voice",
143
+ "<voice_type>",
144
+ "--output",
145
+ "preview.mp3",
146
+ "--format",
147
+ "agent-json"
148
+ ],
149
+ "timeout": 180
150
+ }
151
+ ```
152
+
153
+ 合成正式音频:
154
+
155
+ ```json
156
+ {
157
+ "args": [
158
+ "ai",
159
+ "doubao-tts",
160
+ "--text",
161
+ "你好,这是语音合成测试。",
162
+ "--voice",
163
+ "<voice_type>",
164
+ "--output",
165
+ "tts.mp3",
166
+ "--format",
167
+ "agent-json"
168
+ ],
169
+ "timeout": 300
170
+ }
171
+ ```
172
+
173
+ 在 YuanFlow-main 内置环境里,`--output` 会被限制到受控输出目录。不要要求用户手动传程序数据目录,也不要绕过 `yuanflow_cli_call` 直接写本地文件。
174
+
175
+ ## 失败处理
176
+
177
+ - 没有 `voice_type`:先查询 `ai doubao-tts voices`,不要随便编造音色 ID。
178
+ - 用户想“设计一个声音”或“克隆我的声音”:说明本 Skill 不能做到,转到 `声音克隆` / `声音复刻`。
179
+ - 试听 URL 过期:重新执行 `voice-download` 获取新的临时下载地址。
180
+ - 输出路径被拒绝:在 YuanFlow-main 内置环境中改用相对文件名,让受控工具自动放入输出目录。
181
+ - 合成失败:报告 YuanFlow API 返回的简短错误,不暴露 token、Authorization header 或完整敏感链接。
@@ -25,7 +25,8 @@ const ERROR_MAP = [
25
25
  message.includes('未知平台') ||
26
26
  message.includes('未找到命令') ||
27
27
  message.includes('未知命令') ||
28
- message.includes('不支持'),
28
+ message.includes('不支持') ||
29
+ message.includes('已有未归档音色'),
29
30
  },
30
31
  {
31
32
  code: 'AUTH_INVALID',
@@ -78,7 +79,7 @@ export function createAgentSuccess(command, data, meta = {}) {
78
79
  }
79
80
 
80
81
  export function createAgentError(command, error) {
81
- const message = error?.message || String(error);
82
+ const message = sanitizeErrorMessage(error?.message || String(error));
82
83
  const mapped = mapError(message);
83
84
  return {
84
85
  payload: {
@@ -97,6 +98,18 @@ export function createAgentError(command, error) {
97
98
  };
98
99
  }
99
100
 
101
+ function sanitizeErrorMessage(message) {
102
+ const text = String(message || '');
103
+ if (text.includes('one username can only have one voice id')) {
104
+ return '请求失败:HTTP 409 当前用户已有未归档音色。请先使用 voice list 查询已有 voice_xxx,或使用已有音色复刻。';
105
+ }
106
+ const internalErrorType = ['new', 'api', 'error'].join('_');
107
+ const internalProductName = ['New', 'Api'].join('');
108
+ return text
109
+ .replace(new RegExp(internalErrorType, 'gi'), 'platform_service_error')
110
+ .replace(new RegExp(internalProductName, 'g'), 'YuanFlow API');
111
+ }
112
+
100
113
  export function getCommandName(platform, command) {
101
114
  return [platform, command].filter(Boolean).join(' ') || 'unknown';
102
115
  }
package/src/ai-tools.js CHANGED
@@ -11,8 +11,8 @@ const DOUBAO_TTS_VOICE_ASSETS_PATH = '/api/voice-assets/doubao/voices';
11
11
  const YUANFLOW_FILE_TRANSFER_PATH = '/atomic/oss/temp-upload';
12
12
 
13
13
  const MODEL_QWEN_VL = 'qwen3-vl-plus';
14
- const MODEL_QWEN_VOICE = 'qwen-voice-enrollment';
15
- const MODEL_QWEN_TTS_VC = 'qwen3-tts-vc-realtime-2026-01-15';
14
+ const MODEL_VOICE_ENROLLMENT = 'voice-enrollment';
15
+ const MODEL_COSYVOICE_FLASH = 'cosyvoice-v3-flash';
16
16
  const MODEL_FUN_ASR = 'fun-asr';
17
17
  const MODEL_DOUBAO_TTS = 'doubao-tts';
18
18
 
@@ -41,34 +41,36 @@ export function listAiCommands() {
41
41
  returns: '返回 OpenAI chat.completion 兼容 JSON。',
42
42
  }),
43
43
  aiCommand({
44
- key: 'ai.qwen-voice-enrollment',
45
- command: 'ai qwen-voice-enrollment',
46
- description: '调用 YuanFlow API 对外模型 qwen-voice-enrollment,创建音色复刻记录。',
44
+ key: 'ai.voice-enrollment',
45
+ command: 'ai voice-enrollment',
46
+ description: '调用 YuanFlow API 对外模型 voice-enrollment,创建音色复刻记录。',
47
47
  apiPath: AUDIO_VOICES_PATH,
48
48
  options: [
49
- option('--file', 'file', false, '本地音频文件;与 --audio-url 二选一。'),
49
+ option('--file', 'file', false, '本地音频文件;通过 multipart 直接提交给 YuanFlow API,与 --audio-url 二选一。'),
50
50
  option('--audio-url', 'audioUrl', false, '公网可访问音频 URL;与 --file 二选一。'),
51
51
  option('--name', 'name', false, '音色展示名。'),
52
52
  option('--preferred-name', 'preferredName', false, '偏好音色名,默认跟随 --name。'),
53
- option('--text', 'text', false, '参考音频对应文本,可选。'),
54
- option('--language', 'language', false, '语言代码,可选。'),
53
+ option('--target-model', 'targetModel', false, `后续合成模型,默认 ${MODEL_COSYVOICE_FLASH}。`),
54
+ option('--language-hints', 'languageHints', false, '逗号分隔的样本音频语种提示,例如 zh。'),
55
+ option('--language', 'language', false, '兼容别名;会映射为 language_hints。'),
55
56
  option('--activate', 'activate', false, '创建后设为当前默认音色。'),
56
57
  ...commonOptions(),
57
58
  ],
58
59
  requestBody: {
59
- model: MODEL_QWEN_VOICE,
60
- audio: '<本地文件 data URI 或 audio_url>',
60
+ model: MODEL_VOICE_ENROLLMENT,
61
+ target_model: MODEL_COSYVOICE_FLASH,
62
+ file: '<multipart 本地音频,或通过 audio_url 传入公网音频 URL>',
61
63
  },
62
- returns: '返回 voice_xxx 音色对象;后续 qwen3-tts-vc-realtime-2026-01-15 可用 --voice voice_xxx 调用。',
64
+ returns: '返回 voice_xxx 音色对象;后续 cosyvoice-v3-flash 可用 --voice voice_xxx 调用。',
63
65
  }),
64
66
  aiCommand({
65
- key: 'ai.qwen3-tts-vc-realtime-2026-01-15',
66
- command: 'ai qwen3-tts-vc-realtime-2026-01-15',
67
- description: '调用 YuanFlow API 对外模型 qwen3-tts-vc-realtime-2026-01-15,使用 voice_xxx 或 default 合成音频。',
67
+ key: 'ai.cosyvoice-v3-flash',
68
+ command: 'ai cosyvoice-v3-flash',
69
+ description: '调用 YuanFlow API 对外模型 cosyvoice-v3-flash,使用 voice_xxx 或 default 合成音频。',
68
70
  apiPath: AUDIO_SPEECH_PATH,
69
71
  options: speechOptions('音色 ID:voice_xxx 或 default。', false),
70
72
  requestBody: {
71
- model: MODEL_QWEN_TTS_VC,
73
+ model: MODEL_COSYVOICE_FLASH,
72
74
  input: '<text>',
73
75
  voice: '<voice_xxx|default>',
74
76
  },
@@ -166,10 +168,10 @@ export async function runAiCommand({ action = 'help', rest = [], options }) {
166
168
  return { ok: true, commands: listAiCommands() };
167
169
  case MODEL_QWEN_VL:
168
170
  return callJson(CHAT_COMPLETIONS_PATH, options, await buildQwenVLBody(options));
169
- case MODEL_QWEN_VOICE:
170
- return callJson(AUDIO_VOICES_PATH, options, await buildVoiceEnrollmentBody(options));
171
- case MODEL_QWEN_TTS_VC:
172
- return callSpeech(MODEL_QWEN_TTS_VC, options, false);
171
+ case MODEL_VOICE_ENROLLMENT:
172
+ return callVoiceEnrollment(options);
173
+ case MODEL_COSYVOICE_FLASH:
174
+ return callSpeech(MODEL_COSYVOICE_FLASH, options, false);
173
175
  case MODEL_FUN_ASR:
174
176
  return callFunASR(options);
175
177
  case MODEL_DOUBAO_TTS:
@@ -308,21 +310,38 @@ async function buildVoiceEnrollmentBody(options) {
308
310
  throw new Error('--file 和 --audio-url 不能同时使用。');
309
311
  }
310
312
  const body = {
311
- model: MODEL_QWEN_VOICE,
313
+ model: MODEL_VOICE_ENROLLMENT,
314
+ target_model: cleanOptional(options.named?.['target-model']) || MODEL_COSYVOICE_FLASH,
312
315
  ...optionalField('name', options.named?.name),
313
316
  ...optionalField('preferred_name', options.named?.['preferred-name']),
314
- ...optionalField('text', options.named?.text),
315
- ...optionalField('language', options.named?.language),
316
317
  ...optionalBooleanField('activate', options.named?.activate),
317
318
  };
319
+ const languageHints = splitList(options.named?.['language-hints'] || options.named?.language);
320
+ if (languageHints.length > 0) {
321
+ body.language_hints = languageHints;
322
+ }
318
323
  if (audioUrl) {
319
324
  body.audio_url = audioUrl;
320
325
  } else {
321
- body.audio = options.dryRun ? '<data URI omitted in dry-run>' : await fileToDataUri(filePath);
326
+ body.file = '<file omitted>';
322
327
  }
323
328
  return body;
324
329
  }
325
330
 
331
+ async function callVoiceEnrollment(options) {
332
+ const body = await buildVoiceEnrollmentBody(options);
333
+ const filePath = cleanOptional(options.file);
334
+ const audioUrl = cleanOptional(options.named?.['audio-url']);
335
+ if (filePath && !audioUrl && !options.json) {
336
+ const response = await callMultipartJson(AUDIO_VOICES_PATH, options, {
337
+ ...body,
338
+ filePath,
339
+ });
340
+ return result(MODEL_VOICE_ENROLLMENT, AUDIO_VOICES_PATH, body, response);
341
+ }
342
+ return callJson(AUDIO_VOICES_PATH, options, body);
343
+ }
344
+
326
345
  async function callSpeech(model, options, requiresVoice) {
327
346
  const body = buildSpeechBody(model, options, requiresVoice);
328
347
  const response = await callBinary(AUDIO_SPEECH_PATH, options, body);
@@ -352,10 +371,18 @@ function buildSpeechBody(model, options, requiresVoice) {
352
371
  const metadata = parseJsonObject(options.named?.metadata);
353
372
  addNumber(metadata, 'sample_rate', options.named?.['sample-rate']);
354
373
  addNumber(metadata, 'volume', options.named?.volume);
355
- addNumber(metadata, 'pitch_rate', options.named?.['pitch-rate']);
374
+ addNumber(metadata, 'rate', options.named?.rate || options.named?.speed);
375
+ addNumber(metadata, 'pitch', options.named?.pitch || options.named?.['pitch-rate']);
356
376
  addNumber(metadata, 'bit_rate', options.named?.['bit-rate']);
357
377
  addString(metadata, 'mode', options.named?.mode);
358
378
  addString(metadata, 'language', options.named?.language);
379
+ addNumber(metadata, 'seed', options.named?.seed);
380
+ addBoolean(metadata, 'enable_ssml', options.named?.['enable-ssml']);
381
+ addBoolean(metadata, 'word_timestamp_enabled', options.named?.['word-timestamp-enabled']);
382
+ const hints = splitList(options.named?.['language-hints']);
383
+ if (hints.length > 0) {
384
+ metadata.language_hints = hints;
385
+ }
359
386
  if (Object.keys(metadata).length > 0) {
360
387
  body.metadata = metadata;
361
388
  }
@@ -489,19 +516,25 @@ async function callGetJson(apiPath, options) {
489
516
  }
490
517
 
491
518
  async function callMultipartJson(apiPath, options, payload) {
492
- const request = await buildRequest(apiPath, options, 'POST', {
493
- model: payload.model,
494
- response_format: payload.response_format,
495
- metadata: payload.metadata,
496
- file: '<file omitted>',
497
- });
519
+ const requestBody = { ...payload, file: '<file omitted>' };
520
+ delete requestBody.filePath;
521
+ const request = await buildRequest(apiPath, options, 'POST', requestBody);
498
522
  if (request.dryRun) {
499
523
  return request;
500
524
  }
501
525
  const form = new FormData();
502
- form.set('model', payload.model);
503
- form.set('response_format', payload.response_format);
504
- form.set('metadata', JSON.stringify(payload.metadata || {}));
526
+ for (const [key, value] of Object.entries(payload || {})) {
527
+ if (key === 'filePath' || key === 'file' || value === undefined || value === null) {
528
+ continue;
529
+ }
530
+ if (Array.isArray(value)) {
531
+ form.set(key, value.join(','));
532
+ } else if (typeof value === 'object') {
533
+ form.set(key, JSON.stringify(value));
534
+ } else {
535
+ form.set(key, String(value));
536
+ }
537
+ }
505
538
  const file = new Blob([await readFile(payload.filePath)], { type: inferAudioMimeType(payload.filePath) });
506
539
  form.set('file', file, path.basename(payload.filePath));
507
540
  const response = await fetch(request.url, {
@@ -617,8 +650,16 @@ function speechOptions(voiceLabel, voiceRequired) {
617
650
  option('--voice', 'voice', voiceRequired, voiceLabel),
618
651
  option('--output', 'output', true, '音频保存路径;dry-run 时可不传。'),
619
652
  option('--response-format', 'responseFormat', false, 'mp3、wav、pcm 等,默认 mp3。'),
620
- option('--speed', 'speed', false, '语速控制。'),
653
+ option('--rate', 'rate', false, '语速控制,会写入 metadata.rate。'),
654
+ option('--speed', 'speed', false, '兼容别名;会写入 metadata.rate。'),
655
+ option('--volume', 'volume', false, '音量控制,会写入 metadata.volume。'),
656
+ option('--pitch', 'pitch', false, '音调控制,会写入 metadata.pitch。'),
621
657
  option('--sample-rate', 'sampleRate', false, '采样率。'),
658
+ option('--language', 'language', false, '目标合成语言,例如 zh。'),
659
+ option('--language-hints', 'languageHints', false, '逗号分隔的目标合成语种提示,例如 zh。'),
660
+ option('--enable-ssml', 'enableSsml', false, '是否开启 SSML。'),
661
+ option('--word-timestamp-enabled', 'wordTimestampEnabled', false, '是否开启字级时间戳。'),
662
+ option('--seed', 'seed', false, '随机种子。'),
622
663
  option('--metadata', 'metadata', false, '透传给 YuanFlow API 的 metadata JSON。'),
623
664
  ...commonOptions(),
624
665
  ];
@@ -638,11 +679,6 @@ function option(flag, name, required, label) {
638
679
  return { flag, name, required, label };
639
680
  }
640
681
 
641
- async function fileToDataUri(filePath) {
642
- const data = await readFile(filePath);
643
- return `data:${inferAudioMimeType(filePath)};base64,${data.toString('base64')}`;
644
- }
645
-
646
682
  function inferAudioMimeType(filePath) {
647
683
  switch (path.extname(filePath).toLowerCase()) {
648
684
  case '.mp3':