yuanflow-cli 0.1.43 → 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.
Files changed (23) hide show
  1. package/README.md +34 -4
  2. package/package.json +1 -1
  3. package/skills/yuanflow-skill/README.md +7 -0
  4. package/skills/yuanflow-skill/SKILL.md +24 -9
  5. package/skills/yuanflow-skill//345/243/260/351/237/263/345/205/213/351/232/206/SKILL.md +146 -0
  6. package/skills/yuanflow-skill//345/243/260/351/237/263/345/244/215/345/210/273/SKILL.md +103 -0
  7. package/skills/yuanflow-skill//350/247/206/350/247/211/345/215/241/347/211/207/347/224/237/346/210/220/SKILL.md +77 -0
  8. package/skills/yuanflow-skill//350/247/206/350/247/211/345/215/241/347/211/207/347/224/237/346/210/220/agents/openai.yaml +8 -0
  9. package/skills/yuanflow-skill//350/247/206/350/247/211/345/215/241/347/211/207/347/224/237/346/210/220/assets/templates/editorial-publishing.html +65 -0
  10. package/skills/yuanflow-skill//350/247/206/350/247/211/345/215/241/347/211/207/347/224/237/346/210/220/assets/templates/lifestyle-collage.html +49 -0
  11. package/skills/yuanflow-skill//350/247/206/350/247/211/345/215/241/347/211/207/347/224/237/346/210/220/assets/templates/product-brand.html +50 -0
  12. package/skills/yuanflow-skill//350/247/206/350/247/211/345/215/241/347/211/207/347/224/237/346/210/220/assets/templates/social-campaign.html +49 -0
  13. package/skills/yuanflow-skill//350/247/206/350/247/211/345/215/241/347/211/207/347/224/237/346/210/220/assets/templates/technical-diagram.html +45 -0
  14. package/skills/yuanflow-skill//350/247/206/350/247/211/345/215/241/347/211/207/347/224/237/346/210/220/overview.html +272 -0
  15. package/skills/yuanflow-skill//350/247/206/350/247/211/345/215/241/347/211/207/347/224/237/346/210/220/references/commercial-system.md +56 -0
  16. package/skills/yuanflow-skill//350/247/206/350/247/211/345/215/241/347/211/207/347/224/237/346/210/220/references/layout-playbook.md +98 -0
  17. package/skills/yuanflow-skill//350/247/206/350/247/211/345/215/241/347/211/207/347/224/237/346/210/220/references/quality-check.md +58 -0
  18. package/skills/yuanflow-skill//350/247/206/350/247/211/345/215/241/347/211/207/347/224/237/346/210/220/references/style-atlas.md +728 -0
  19. package/skills/yuanflow-skill//350/247/206/350/247/211/345/215/241/347/211/207/347/224/237/346/210/220/references/use-case-recipes.md +116 -0
  20. package/skills/yuanflow-skill//350/247/206/350/247/211/345/215/241/347/211/207/347/224/237/346/210/220/scripts/check_visual_card.py +92 -0
  21. package/src/agent-protocol.js +3 -0
  22. package/src/cli.js +24 -0
  23. package/src/voice-tools.js +471 -0
@@ -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
+ }