yuanflow-cli 0.1.39 → 0.1.41
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 +149 -12
- package/generated/registry.json +985 -984
- package/package.json +1 -1
- package/scripts/generate-registry.js +1 -1
- package/skills/yuanflow-skill/IP/350/277/220/350/220/245/SKILL.md +1 -0
- package/skills/yuanflow-skill/README.md +18 -10
- package/skills/yuanflow-skill/SKILL.md +46 -11
- package/skills/yuanflow-skill/{OSS → YuanFlow}/346/226/207/344/273/266/344/270/255/350/275/254/345/267/245/345/205/267/SKILL.md +8 -8
- package/skills/yuanflow-skill/yuanflow-cli/SKILL.md +3 -3
- package/skills/yuanflow-skill//344/275/234/345/223/201/344/270/213/350/275/275/347/273/274/345/220/210/345/267/245/345/205/267/SKILL.md +1 -1
- package/skills/yuanflow-skill//344/275/234/345/223/201/350/257/204/350/256/272/351/207/207/351/233/206/SKILL.md +1 -1
- package/skills/yuanflow-skill//345/205/254/344/274/227/345/217/267/347/224/237/346/210/220/344/270/216/345/217/221/345/270/203/SKILL.md +1 -1
- package/skills/yuanflow-skill//345/210/233/344/275/234/346/200/273/347/233/221/SKILL.md +94 -0
- package/skills/yuanflow-skill//345/260/217/347/272/242/344/271/246/350/277/220/350/220/245/{344/270/216/345/217/221/345/270/203/SKILL.md → SKILL.md} +10 -5
- package/skills/yuanflow-skill//345/270/220/345/217/267/345/256/232/344/275/215/SKILL.md +1 -0
- package/skills/yuanflow-skill//346/226/207/346/241/210/345/210/233/344/275/234/SKILL.md +1 -0
- package/skills/yuanflow-skill//347/203/255/351/227/250/345/206/205/345/256/271/346/225/264/347/220/206/SKILL.md +83 -0
- package/skills/yuanflow-skill//347/233/264/346/222/255/346/212/225/346/265/201/347/255/226/347/225/245/SKILL.md +1 -0
- package/skills/yuanflow-skill//347/233/264/346/222/255/350/257/235/346/234/257/347/224/237/346/210/220/SKILL.md +1 -0
- package/skills/yuanflow-skill//350/207/252/345/252/222/344/275/223/347/237/245/350/257/206/345/272/223/SKILL.md +2 -0
- package/skills/yuanflow-skill//350/247/206/350/247/211/347/220/206/350/247/243/SKILL.md +174 -0
- package/skills/yuanflow-skill//350/247/206/351/242/221/346/212/225/346/265/201/347/255/226/347/225/245/SKILL.md +1 -0
- package/skills/yuanflow-skill//350/247/206/351/242/221/346/213/206/350/247/243/SKILL.md +245 -0
- package/skills/yuanflow-skill//350/247/206/351/242/221/346/231/272/350/203/275/345/211/252/350/276/221/SKILL.md +60 -12
- package/skills/yuanflow-skill//351/200/211/351/242/230/347/255/226/345/210/222/SKILL.md +1 -0
- package/skills/yuanflow-skill//351/237/263/350/247/206/351/242/221/345/234/250/347/272/277/350/275/254/346/226/207/345/255/227/SKILL.md +10 -10
- package/src/agent-protocol.js +11 -5
- package/src/ai-tools.js +835 -0
- package/src/cli.js +58 -2
- package/src/comment-collector.js +1 -1
- package/src/oss-tools.js +11 -11
- package/src/shortcuts.js +3 -3
- package/src/trending-tools.js +117 -0
- package/src/video-tools.js +182 -3
- package/src/work-tools.js +4 -4
- /package/skills/yuanflow-skill//345/260/217/347/272/242/344/271/246/350/277/220/350/220/245/{344/270/216/345/217/221/345/270/203/references → references}/commands.md" +0 -0
- /package/skills/yuanflow-skill//345/260/217/347/272/242/344/271/246/350/277/220/350/220/245/{344/270/216/345/217/221/345/270/203/references → references}/interaction-policy.md" +0 -0
- /package/skills/yuanflow-skill//345/260/217/347/272/242/344/271/246/350/277/220/350/220/245/{344/270/216/345/217/221/345/270/203/references → references}/publishing-policy.md" +0 -0
package/src/ai-tools.js
ADDED
|
@@ -0,0 +1,835 @@
|
|
|
1
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { readConfig } from './config.js';
|
|
4
|
+
import { callAtomic } from './atomic-request.js';
|
|
5
|
+
|
|
6
|
+
const CHAT_COMPLETIONS_PATH = '/v1/chat/completions';
|
|
7
|
+
const AUDIO_SPEECH_PATH = '/v1/audio/speech';
|
|
8
|
+
const AUDIO_TRANSCRIPTIONS_PATH = '/v1/audio/transcriptions';
|
|
9
|
+
const AUDIO_VOICES_PATH = '/v1/audio/voices';
|
|
10
|
+
const DOUBAO_TTS_VOICE_ASSETS_PATH = '/api/voice-assets/doubao/voices';
|
|
11
|
+
const YUANFLOW_FILE_TRANSFER_PATH = '/atomic/oss/temp-upload';
|
|
12
|
+
|
|
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';
|
|
16
|
+
const MODEL_FUN_ASR = 'fun-asr';
|
|
17
|
+
const MODEL_DOUBAO_TTS = 'doubao-tts';
|
|
18
|
+
|
|
19
|
+
export function listAiCommands() {
|
|
20
|
+
return [
|
|
21
|
+
aiCommand({
|
|
22
|
+
key: 'ai.qwen3-vl-plus',
|
|
23
|
+
command: 'ai qwen3-vl-plus',
|
|
24
|
+
description: '调用 YuanFlow API 对外模型 qwen3-vl-plus,支持图片/视频 URL 与本地图片/视频经 YuanFlow 文件中转后的视觉理解。',
|
|
25
|
+
apiPath: CHAT_COMPLETIONS_PATH,
|
|
26
|
+
options: [
|
|
27
|
+
option('--prompt', 'prompt', true, '用户文本提示词。'),
|
|
28
|
+
option('--image-url', 'imageUrl', false, '图片 URL,支持 http(s) 可访问地址。'),
|
|
29
|
+
option('--video-url', 'videoUrl', false, '视频 URL,支持 http(s) 可访问地址;YuanFlow 视觉理解建议单个视频最大 2GB,时长 2 秒至 1 小时。'),
|
|
30
|
+
option('--image-file', 'imageFile', false, '本地图片文件;CLI 会先上传到 YuanFlow 文件中转,再调用 qwen3-vl-plus。'),
|
|
31
|
+
option('--video-file', 'videoFile', false, '本地视频文件;CLI 会先上传到 YuanFlow 文件中转,再调用 qwen3-vl-plus。'),
|
|
32
|
+
option('--system', 'system', false, '可选系统提示词。'),
|
|
33
|
+
option('--temperature', 'temperature', false, '采样温度。'),
|
|
34
|
+
option('--max-tokens', 'maxTokens', false, '最大输出 token 数。'),
|
|
35
|
+
...commonOptions(),
|
|
36
|
+
],
|
|
37
|
+
requestBody: {
|
|
38
|
+
model: MODEL_QWEN_VL,
|
|
39
|
+
messages: '<由 --prompt 和 --image-url/--video-url/--image-file/--video-file 组装>',
|
|
40
|
+
},
|
|
41
|
+
returns: '返回 OpenAI chat.completion 兼容 JSON。',
|
|
42
|
+
}),
|
|
43
|
+
aiCommand({
|
|
44
|
+
key: 'ai.qwen-voice-enrollment',
|
|
45
|
+
command: 'ai qwen-voice-enrollment',
|
|
46
|
+
description: '调用 YuanFlow API 对外模型 qwen-voice-enrollment,创建音色复刻记录。',
|
|
47
|
+
apiPath: AUDIO_VOICES_PATH,
|
|
48
|
+
options: [
|
|
49
|
+
option('--file', 'file', false, '本地音频文件;与 --audio-url 二选一。'),
|
|
50
|
+
option('--audio-url', 'audioUrl', false, '公网可访问音频 URL;与 --file 二选一。'),
|
|
51
|
+
option('--name', 'name', false, '音色展示名。'),
|
|
52
|
+
option('--preferred-name', 'preferredName', false, '偏好音色名,默认跟随 --name。'),
|
|
53
|
+
option('--text', 'text', false, '参考音频对应文本,可选。'),
|
|
54
|
+
option('--language', 'language', false, '语言代码,可选。'),
|
|
55
|
+
option('--activate', 'activate', false, '创建后设为当前默认音色。'),
|
|
56
|
+
...commonOptions(),
|
|
57
|
+
],
|
|
58
|
+
requestBody: {
|
|
59
|
+
model: MODEL_QWEN_VOICE,
|
|
60
|
+
audio: '<本地文件 data URI 或 audio_url>',
|
|
61
|
+
},
|
|
62
|
+
returns: '返回 voice_xxx 音色对象;后续 qwen3-tts-vc-realtime-2026-01-15 可用 --voice voice_xxx 调用。',
|
|
63
|
+
}),
|
|
64
|
+
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 合成音频。',
|
|
68
|
+
apiPath: AUDIO_SPEECH_PATH,
|
|
69
|
+
options: speechOptions('音色 ID:voice_xxx 或 default。', false),
|
|
70
|
+
requestBody: {
|
|
71
|
+
model: MODEL_QWEN_TTS_VC,
|
|
72
|
+
input: '<text>',
|
|
73
|
+
voice: '<voice_xxx|default>',
|
|
74
|
+
},
|
|
75
|
+
returns: '返回音频二进制;CLI 通过 --output 保存到本地文件。',
|
|
76
|
+
}),
|
|
77
|
+
aiCommand({
|
|
78
|
+
key: 'ai.fun-asr',
|
|
79
|
+
command: 'ai fun-asr',
|
|
80
|
+
description: '调用 YuanFlow API 对外模型 fun-asr,提交音频转写。',
|
|
81
|
+
apiPath: AUDIO_TRANSCRIPTIONS_PATH,
|
|
82
|
+
options: [
|
|
83
|
+
option('--audio-url', 'audioUrl', false, '公网可访问音频 URL;适合异步 ASR。'),
|
|
84
|
+
option('--file', 'file', false, '本地音频文件;适合直传 ASR。'),
|
|
85
|
+
option('--response-format', 'responseFormat', false, 'json 或 verbose_json,默认 json。'),
|
|
86
|
+
option('--language', 'language', false, '语言提示,例如 zh、en。'),
|
|
87
|
+
option('--language-hints', 'languageHints', false, '逗号分隔语言提示。'),
|
|
88
|
+
option('--timestamps', 'timestamps', false, '返回时间戳。'),
|
|
89
|
+
option('--diarization-enabled', 'diarizationEnabled', false, '开启说话人分离。'),
|
|
90
|
+
option('--speaker-count', 'speakerCount', false, '说话人数。'),
|
|
91
|
+
...commonOptions(),
|
|
92
|
+
],
|
|
93
|
+
requestBody: {
|
|
94
|
+
model: MODEL_FUN_ASR,
|
|
95
|
+
response_format: 'json',
|
|
96
|
+
metadata: '<audio_url/language/timestamps 等>',
|
|
97
|
+
},
|
|
98
|
+
returns: '返回 OpenAI audio transcription 兼容 JSON。',
|
|
99
|
+
}),
|
|
100
|
+
aiCommand({
|
|
101
|
+
key: 'ai.doubao-tts',
|
|
102
|
+
command: 'ai doubao-tts',
|
|
103
|
+
description: '调用 YuanFlow API 对外模型 doubao-tts,voice 直接传豆包音色参数。',
|
|
104
|
+
apiPath: AUDIO_SPEECH_PATH,
|
|
105
|
+
options: speechOptions('豆包音色 ID,例如 zh_female_xiaohe_uranus_bigtts。', true),
|
|
106
|
+
requestBody: {
|
|
107
|
+
model: MODEL_DOUBAO_TTS,
|
|
108
|
+
input: '<text>',
|
|
109
|
+
voice: '<豆包音色 ID>',
|
|
110
|
+
},
|
|
111
|
+
returns: '返回音频二进制;CLI 通过 --output 保存到本地文件。',
|
|
112
|
+
}),
|
|
113
|
+
aiCommand({
|
|
114
|
+
key: 'ai.doubao-tts.voices',
|
|
115
|
+
command: 'ai doubao-tts voices',
|
|
116
|
+
description: '查询 doubao-tts 可用于合成的全部豆包官方音色清单。',
|
|
117
|
+
method: 'GET',
|
|
118
|
+
apiPath: DOUBAO_TTS_VOICE_ASSETS_PATH,
|
|
119
|
+
options: commonOptions(),
|
|
120
|
+
requestBody: null,
|
|
121
|
+
returns: '返回音色列表;合成时使用每条记录的 voice_type 作为 --voice。',
|
|
122
|
+
}),
|
|
123
|
+
aiCommand({
|
|
124
|
+
key: 'ai.doubao-tts.voice',
|
|
125
|
+
command: 'ai doubao-tts voice',
|
|
126
|
+
description: '查询单个 doubao-tts 音色详情。',
|
|
127
|
+
method: 'GET',
|
|
128
|
+
apiPath: `${DOUBAO_TTS_VOICE_ASSETS_PATH}/{voice_type}`,
|
|
129
|
+
options: [
|
|
130
|
+
option('--voice', 'voice', true, '豆包音色 ID,也就是 voice_type。'),
|
|
131
|
+
...commonOptions(),
|
|
132
|
+
],
|
|
133
|
+
requestBody: null,
|
|
134
|
+
returns: '返回单个音色的展示名、分类、语言、能力标签和试听资源 key。',
|
|
135
|
+
}),
|
|
136
|
+
aiCommand({
|
|
137
|
+
key: 'ai.doubao-tts.voice-download',
|
|
138
|
+
command: 'ai doubao-tts voice-download',
|
|
139
|
+
description: '获取 doubao-tts 单个音色试听音频下载地址,并可保存到本地。',
|
|
140
|
+
method: 'GET',
|
|
141
|
+
apiPath: `${DOUBAO_TTS_VOICE_ASSETS_PATH}/{voice_type}/download`,
|
|
142
|
+
options: [
|
|
143
|
+
option('--voice', 'voice', true, '豆包音色 ID,也就是 voice_type。'),
|
|
144
|
+
option('--output', 'output', false, '可选本地保存路径;不传时只返回签名下载 URL。'),
|
|
145
|
+
...commonOptions(),
|
|
146
|
+
],
|
|
147
|
+
requestBody: null,
|
|
148
|
+
returns: '返回签名下载 URL;传 --output 时 CLI 会下载试听 mp3 到本地。',
|
|
149
|
+
}),
|
|
150
|
+
];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function formatAiHelp() {
|
|
154
|
+
return listAiCommands()
|
|
155
|
+
.map((command) => {
|
|
156
|
+
const options = command.options.map((item) => ` ${item.flag} ${item.label}`).join('\n');
|
|
157
|
+
return `${command.command}\n ${command.description}\n 接口:${command.method} ${command.apiPath}\n 参数:\n${options}\n 返回:${command.returns}`;
|
|
158
|
+
})
|
|
159
|
+
.join('\n\n');
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export async function runAiCommand({ action = 'help', rest = [], options }) {
|
|
163
|
+
switch (action) {
|
|
164
|
+
case 'help':
|
|
165
|
+
case 'list':
|
|
166
|
+
return { ok: true, commands: listAiCommands() };
|
|
167
|
+
case MODEL_QWEN_VL:
|
|
168
|
+
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);
|
|
173
|
+
case MODEL_FUN_ASR:
|
|
174
|
+
return callFunASR(options);
|
|
175
|
+
case MODEL_DOUBAO_TTS:
|
|
176
|
+
return runDoubaoTTSCommand(rest, options);
|
|
177
|
+
default:
|
|
178
|
+
throw new Error(`未知 ai 命令:${action}。可执行 yuanflow-cli ai help 查看用法。`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function runDoubaoTTSCommand(rest, options) {
|
|
183
|
+
const [subCommand] = rest;
|
|
184
|
+
if (!subCommand) {
|
|
185
|
+
return callSpeech(MODEL_DOUBAO_TTS, options, true);
|
|
186
|
+
}
|
|
187
|
+
switch (subCommand) {
|
|
188
|
+
case 'voices':
|
|
189
|
+
case 'list':
|
|
190
|
+
return listDoubaoTTSVoices(options);
|
|
191
|
+
case 'voice':
|
|
192
|
+
case 'detail':
|
|
193
|
+
return getDoubaoTTSVoice(options);
|
|
194
|
+
case 'voice-download':
|
|
195
|
+
case 'download':
|
|
196
|
+
return downloadDoubaoTTSVoice(options);
|
|
197
|
+
default:
|
|
198
|
+
throw new Error(`未知 doubao-tts 子命令:${subCommand}。可用:voices、voice、voice-download。`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function buildQwenVLBody(options) {
|
|
203
|
+
if (options.json) {
|
|
204
|
+
return JSON.parse(options.json);
|
|
205
|
+
}
|
|
206
|
+
const prompt = cleanOptional(options.named?.prompt);
|
|
207
|
+
if (!prompt) {
|
|
208
|
+
throw new Error('缺少 --prompt。');
|
|
209
|
+
}
|
|
210
|
+
const messages = [];
|
|
211
|
+
const system = cleanOptional(options.named?.system);
|
|
212
|
+
if (system) {
|
|
213
|
+
messages.push({ role: 'system', content: system });
|
|
214
|
+
}
|
|
215
|
+
const media = await resolveQwenVLMedia(options);
|
|
216
|
+
const content = media
|
|
217
|
+
? [
|
|
218
|
+
{ type: 'text', text: prompt },
|
|
219
|
+
mediaPart(media),
|
|
220
|
+
]
|
|
221
|
+
: prompt;
|
|
222
|
+
const body = {
|
|
223
|
+
model: MODEL_QWEN_VL,
|
|
224
|
+
messages: [...messages, { role: 'user', content }],
|
|
225
|
+
};
|
|
226
|
+
addNumber(body, 'temperature', options.named?.temperature);
|
|
227
|
+
addNumber(body, 'max_tokens', options.named?.['max-tokens']);
|
|
228
|
+
return body;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function resolveQwenVLMedia(options) {
|
|
232
|
+
const inputs = [
|
|
233
|
+
{ source: 'image-url', kind: 'image', value: cleanOptional(options.named?.['image-url']) },
|
|
234
|
+
{ source: 'video-url', kind: 'video', value: cleanOptional(options.named?.['video-url']) },
|
|
235
|
+
{ source: 'image-file', kind: 'image', value: cleanOptional(options.named?.['image-file']) },
|
|
236
|
+
{ source: 'video-file', kind: 'video', value: cleanOptional(options.named?.['video-file']) },
|
|
237
|
+
].filter((item) => item.value !== undefined);
|
|
238
|
+
|
|
239
|
+
if (inputs.length === 0) {
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
if (inputs.length > 1) {
|
|
243
|
+
throw new Error('qwen3-vl-plus 快捷参数一次只支持一个媒体输入,请在 --image-url、--video-url、--image-file、--video-file 中选择一个。');
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const [input] = inputs;
|
|
247
|
+
if (input.source.endsWith('-url')) {
|
|
248
|
+
return { kind: input.kind, url: input.value };
|
|
249
|
+
}
|
|
250
|
+
const url = await resolveQwenVLLocalFile(input.value, input.kind, options);
|
|
251
|
+
return { kind: input.kind, url };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async function resolveQwenVLLocalFile(filePath, mediaKind, options) {
|
|
255
|
+
const filename = path.basename(filePath);
|
|
256
|
+
if (options.dryRun) {
|
|
257
|
+
return `<YuanFlow 文件中转 signed_url:${filename}>`;
|
|
258
|
+
}
|
|
259
|
+
const response = await uploadYuanFlowVisionFile({
|
|
260
|
+
filePath,
|
|
261
|
+
filename,
|
|
262
|
+
mediaKind,
|
|
263
|
+
options,
|
|
264
|
+
});
|
|
265
|
+
return extractYuanFlowFileURL(response);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async function uploadYuanFlowVisionFile({ filePath, filename, mediaKind, options }) {
|
|
269
|
+
const body = {
|
|
270
|
+
filename,
|
|
271
|
+
content_base64: (await readFile(filePath)).toString('base64'),
|
|
272
|
+
content_type: inferMediaMimeType(filePath, mediaKind),
|
|
273
|
+
};
|
|
274
|
+
return callAtomic(YUANFLOW_FILE_TRANSFER_PATH, {
|
|
275
|
+
...options,
|
|
276
|
+
json: undefined,
|
|
277
|
+
method: 'POST',
|
|
278
|
+
body,
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function extractYuanFlowFileURL(response) {
|
|
283
|
+
const data = response?.data && typeof response.data === 'object' ? response.data : response;
|
|
284
|
+
const url = cleanOptional(data?.signed_url) || cleanOptional(data?.url);
|
|
285
|
+
if (!url) {
|
|
286
|
+
throw new Error('YuanFlow 文件中转未返回 signed_url 或 url。');
|
|
287
|
+
}
|
|
288
|
+
return url;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function mediaPart(media) {
|
|
292
|
+
if (media.kind === 'video') {
|
|
293
|
+
return { type: 'video_url', video_url: { url: media.url } };
|
|
294
|
+
}
|
|
295
|
+
return { type: 'image_url', image_url: { url: media.url } };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async function buildVoiceEnrollmentBody(options) {
|
|
299
|
+
if (options.json) {
|
|
300
|
+
return JSON.parse(options.json);
|
|
301
|
+
}
|
|
302
|
+
const filePath = cleanOptional(options.file);
|
|
303
|
+
const audioUrl = cleanOptional(options.named?.['audio-url']);
|
|
304
|
+
if (!filePath && !audioUrl) {
|
|
305
|
+
throw new Error('缺少 --file 或 --audio-url。');
|
|
306
|
+
}
|
|
307
|
+
if (filePath && audioUrl) {
|
|
308
|
+
throw new Error('--file 和 --audio-url 不能同时使用。');
|
|
309
|
+
}
|
|
310
|
+
const body = {
|
|
311
|
+
model: MODEL_QWEN_VOICE,
|
|
312
|
+
...optionalField('name', options.named?.name),
|
|
313
|
+
...optionalField('preferred_name', options.named?.['preferred-name']),
|
|
314
|
+
...optionalField('text', options.named?.text),
|
|
315
|
+
...optionalField('language', options.named?.language),
|
|
316
|
+
...optionalBooleanField('activate', options.named?.activate),
|
|
317
|
+
};
|
|
318
|
+
if (audioUrl) {
|
|
319
|
+
body.audio_url = audioUrl;
|
|
320
|
+
} else {
|
|
321
|
+
body.audio = options.dryRun ? '<data URI omitted in dry-run>' : await fileToDataUri(filePath);
|
|
322
|
+
}
|
|
323
|
+
return body;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async function callSpeech(model, options, requiresVoice) {
|
|
327
|
+
const body = buildSpeechBody(model, options, requiresVoice);
|
|
328
|
+
const response = await callBinary(AUDIO_SPEECH_PATH, options, body);
|
|
329
|
+
return result(model, AUDIO_SPEECH_PATH, body, response);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function buildSpeechBody(model, options, requiresVoice) {
|
|
333
|
+
if (options.json) {
|
|
334
|
+
return JSON.parse(options.json);
|
|
335
|
+
}
|
|
336
|
+
const text = cleanOptional(options.named?.text || options.named?.input);
|
|
337
|
+
if (!text) {
|
|
338
|
+
throw new Error('缺少 --text。');
|
|
339
|
+
}
|
|
340
|
+
const voice = cleanOptional(options.named?.voice) || (requiresVoice ? undefined : 'default');
|
|
341
|
+
if (!voice) {
|
|
342
|
+
throw new Error('缺少 --voice。');
|
|
343
|
+
}
|
|
344
|
+
const body = {
|
|
345
|
+
model,
|
|
346
|
+
input: text,
|
|
347
|
+
voice,
|
|
348
|
+
response_format: cleanOptional(options.named?.['response-format']) || 'mp3',
|
|
349
|
+
...optionalField('instructions', options.named?.instructions),
|
|
350
|
+
};
|
|
351
|
+
addNumber(body, 'speed', options.named?.speed);
|
|
352
|
+
const metadata = parseJsonObject(options.named?.metadata);
|
|
353
|
+
addNumber(metadata, 'sample_rate', options.named?.['sample-rate']);
|
|
354
|
+
addNumber(metadata, 'volume', options.named?.volume);
|
|
355
|
+
addNumber(metadata, 'pitch_rate', options.named?.['pitch-rate']);
|
|
356
|
+
addNumber(metadata, 'bit_rate', options.named?.['bit-rate']);
|
|
357
|
+
addString(metadata, 'mode', options.named?.mode);
|
|
358
|
+
addString(metadata, 'language', options.named?.language);
|
|
359
|
+
if (Object.keys(metadata).length > 0) {
|
|
360
|
+
body.metadata = metadata;
|
|
361
|
+
}
|
|
362
|
+
return body;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
async function callFunASR(options) {
|
|
366
|
+
if (options.json) {
|
|
367
|
+
return callJson(AUDIO_TRANSCRIPTIONS_PATH, options, JSON.parse(options.json));
|
|
368
|
+
}
|
|
369
|
+
const metadata = buildASRMetadata(options);
|
|
370
|
+
const responseFormat = cleanOptional(options.named?.['response-format']) || 'json';
|
|
371
|
+
const filePath = cleanOptional(options.file);
|
|
372
|
+
if (filePath) {
|
|
373
|
+
const response = await callMultipartJson(AUDIO_TRANSCRIPTIONS_PATH, options, {
|
|
374
|
+
model: MODEL_FUN_ASR,
|
|
375
|
+
response_format: responseFormat,
|
|
376
|
+
metadata,
|
|
377
|
+
filePath,
|
|
378
|
+
});
|
|
379
|
+
return result(MODEL_FUN_ASR, AUDIO_TRANSCRIPTIONS_PATH, { model: MODEL_FUN_ASR, response_format: responseFormat, metadata, file: filePath }, response);
|
|
380
|
+
}
|
|
381
|
+
if (!metadata.audio_url) {
|
|
382
|
+
throw new Error('缺少 --audio-url 或 --file。');
|
|
383
|
+
}
|
|
384
|
+
const body = {
|
|
385
|
+
model: MODEL_FUN_ASR,
|
|
386
|
+
response_format: responseFormat,
|
|
387
|
+
metadata,
|
|
388
|
+
};
|
|
389
|
+
const response = await callJson(AUDIO_TRANSCRIPTIONS_PATH, options, body);
|
|
390
|
+
return result(MODEL_FUN_ASR, AUDIO_TRANSCRIPTIONS_PATH, body, response);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
async function listDoubaoTTSVoices(options) {
|
|
394
|
+
const response = await callGetJson(DOUBAO_TTS_VOICE_ASSETS_PATH, options);
|
|
395
|
+
return result('doubao-tts voices', DOUBAO_TTS_VOICE_ASSETS_PATH, undefined, response, {
|
|
396
|
+
method: 'GET',
|
|
397
|
+
kind: 'ai-voice-assets',
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
async function getDoubaoTTSVoice(options) {
|
|
402
|
+
const voiceType = requiredVoiceType(options);
|
|
403
|
+
const endpointPath = `${DOUBAO_TTS_VOICE_ASSETS_PATH}/${encodeURIComponent(voiceType)}`;
|
|
404
|
+
const response = await callGetJson(endpointPath, options);
|
|
405
|
+
return result('doubao-tts voice', endpointPath, undefined, response, {
|
|
406
|
+
method: 'GET',
|
|
407
|
+
kind: 'ai-voice-assets',
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
async function downloadDoubaoTTSVoice(options) {
|
|
412
|
+
const voiceType = requiredVoiceType(options);
|
|
413
|
+
const endpointPath = `${DOUBAO_TTS_VOICE_ASSETS_PATH}/${encodeURIComponent(voiceType)}/download`;
|
|
414
|
+
const signed = await callGetJson(endpointPath, options);
|
|
415
|
+
if (options.dryRun || !options.output) {
|
|
416
|
+
return result('doubao-tts voice-download', endpointPath, undefined, signed, {
|
|
417
|
+
method: 'GET',
|
|
418
|
+
kind: 'ai-voice-assets',
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
const downloadUrl = signed?.data?.url || signed?.url;
|
|
422
|
+
if (!downloadUrl) {
|
|
423
|
+
throw new Error('下载接口未返回 data.url。');
|
|
424
|
+
}
|
|
425
|
+
const response = await fetch(downloadUrl);
|
|
426
|
+
if (!response.ok) {
|
|
427
|
+
const text = await response.text();
|
|
428
|
+
throw new Error(`试听音频下载失败:HTTP ${response.status} ${text}`);
|
|
429
|
+
}
|
|
430
|
+
const bytes = Buffer.from(await response.arrayBuffer());
|
|
431
|
+
await writeFile(options.output, bytes);
|
|
432
|
+
return result('doubao-tts voice-download', endpointPath, undefined, {
|
|
433
|
+
ok: true,
|
|
434
|
+
output: options.output,
|
|
435
|
+
bytes: bytes.length,
|
|
436
|
+
voice_type: voiceType,
|
|
437
|
+
content_type: response.headers.get('content-type') || signed?.data?.preview_audio_content_type || '',
|
|
438
|
+
signed_url_expires_at: signed?.data?.expires_at,
|
|
439
|
+
}, {
|
|
440
|
+
method: 'GET',
|
|
441
|
+
kind: 'ai-voice-assets',
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function buildASRMetadata(options) {
|
|
446
|
+
const metadata = parseJsonObject(options.named?.metadata);
|
|
447
|
+
addString(metadata, 'audio_url', options.named?.['audio-url']);
|
|
448
|
+
addString(metadata, 'language', options.named?.language);
|
|
449
|
+
const hints = splitList(options.named?.['language-hints']);
|
|
450
|
+
if (hints.length > 0) {
|
|
451
|
+
metadata.language_hints = hints;
|
|
452
|
+
}
|
|
453
|
+
addBoolean(metadata, 'timestamps', options.named?.timestamps);
|
|
454
|
+
addBoolean(metadata, 'diarization_enabled', options.named?.['diarization-enabled']);
|
|
455
|
+
addNumber(metadata, 'speaker_count', options.named?.['speaker-count']);
|
|
456
|
+
return metadata;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
async function callJson(apiPath, options, body) {
|
|
460
|
+
const request = await buildRequest(apiPath, options, 'POST', body);
|
|
461
|
+
if (request.dryRun) {
|
|
462
|
+
return request;
|
|
463
|
+
}
|
|
464
|
+
const response = await fetch(request.url, {
|
|
465
|
+
method: 'POST',
|
|
466
|
+
headers: {
|
|
467
|
+
...request.headers,
|
|
468
|
+
Accept: 'application/json',
|
|
469
|
+
'Content-Type': 'application/json',
|
|
470
|
+
},
|
|
471
|
+
body: JSON.stringify(body || {}),
|
|
472
|
+
});
|
|
473
|
+
return readJsonResponse(response);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
async function callGetJson(apiPath, options) {
|
|
477
|
+
const request = await buildRequest(apiPath, options, 'GET');
|
|
478
|
+
if (request.dryRun) {
|
|
479
|
+
return request;
|
|
480
|
+
}
|
|
481
|
+
const response = await fetch(request.url, {
|
|
482
|
+
method: 'GET',
|
|
483
|
+
headers: {
|
|
484
|
+
...request.headers,
|
|
485
|
+
Accept: 'application/json',
|
|
486
|
+
},
|
|
487
|
+
});
|
|
488
|
+
return readJsonResponse(response);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
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
|
+
});
|
|
498
|
+
if (request.dryRun) {
|
|
499
|
+
return request;
|
|
500
|
+
}
|
|
501
|
+
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 || {}));
|
|
505
|
+
const file = new Blob([await readFile(payload.filePath)], { type: inferAudioMimeType(payload.filePath) });
|
|
506
|
+
form.set('file', file, path.basename(payload.filePath));
|
|
507
|
+
const response = await fetch(request.url, {
|
|
508
|
+
method: 'POST',
|
|
509
|
+
headers: request.headers,
|
|
510
|
+
body: form,
|
|
511
|
+
});
|
|
512
|
+
return readJsonResponse(response);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
async function callBinary(apiPath, options, body) {
|
|
516
|
+
const request = await buildRequest(apiPath, options, 'POST', body);
|
|
517
|
+
if (request.dryRun) {
|
|
518
|
+
return request;
|
|
519
|
+
}
|
|
520
|
+
if (!options.output) {
|
|
521
|
+
throw new Error('音频生成需要 --output 指定保存路径。');
|
|
522
|
+
}
|
|
523
|
+
const response = await fetch(request.url, {
|
|
524
|
+
method: 'POST',
|
|
525
|
+
headers: {
|
|
526
|
+
...request.headers,
|
|
527
|
+
Accept: '*/*',
|
|
528
|
+
'Content-Type': 'application/json',
|
|
529
|
+
},
|
|
530
|
+
body: JSON.stringify(body || {}),
|
|
531
|
+
});
|
|
532
|
+
if (!response.ok) {
|
|
533
|
+
const text = await response.text();
|
|
534
|
+
throw new Error(`请求失败:HTTP ${response.status} ${text}`);
|
|
535
|
+
}
|
|
536
|
+
const bytes = Buffer.from(await response.arrayBuffer());
|
|
537
|
+
await writeFile(options.output, bytes);
|
|
538
|
+
return {
|
|
539
|
+
ok: true,
|
|
540
|
+
output: options.output,
|
|
541
|
+
bytes: bytes.length,
|
|
542
|
+
content_type: response.headers.get('content-type') || '',
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
async function buildRequest(apiPath, options, method, body) {
|
|
547
|
+
const config = await readConfig();
|
|
548
|
+
const baseUrl = cleanBaseUrl(options.baseUrl || config.baseUrl);
|
|
549
|
+
const token = options.token || process.env.YUANCHUANG_API_TOKEN || config.token || '';
|
|
550
|
+
const url = new URL(apiPath, baseUrl);
|
|
551
|
+
if (options.dryRun) {
|
|
552
|
+
return {
|
|
553
|
+
dryRun: true,
|
|
554
|
+
method,
|
|
555
|
+
url: url.toString(),
|
|
556
|
+
headers: token ? { Authorization: `Bearer ${maskToken(token)}` } : {},
|
|
557
|
+
body: redactBody(body),
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
if (!token) {
|
|
561
|
+
throw new Error('缺少 token。请设置 YUANCHUANG_API_TOKEN,或执行 yuanflow-cli config set-token <你的令牌>');
|
|
562
|
+
}
|
|
563
|
+
return {
|
|
564
|
+
method,
|
|
565
|
+
url: url.toString(),
|
|
566
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
567
|
+
body,
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
async function readJsonResponse(response) {
|
|
572
|
+
const text = await response.text();
|
|
573
|
+
const payload = parseMaybeJson(text);
|
|
574
|
+
if (!response.ok) {
|
|
575
|
+
const message = typeof payload === 'object' ? JSON.stringify(payload) : text;
|
|
576
|
+
throw new Error(`请求失败:HTTP ${response.status} ${message}`);
|
|
577
|
+
}
|
|
578
|
+
return payload;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function result(action, endpointPath, body, response, endpoint = {}) {
|
|
582
|
+
return {
|
|
583
|
+
ok: true,
|
|
584
|
+
action,
|
|
585
|
+
endpoint: { method: endpoint.method || 'POST', path: endpointPath, kind: endpoint.kind || 'ai-model' },
|
|
586
|
+
request: { body: redactBody(body) },
|
|
587
|
+
response,
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function aiCommand({ key, command, description, method = 'POST', apiPath, options, requestBody, returns }) {
|
|
592
|
+
return {
|
|
593
|
+
key,
|
|
594
|
+
command,
|
|
595
|
+
kind: 'ai-model',
|
|
596
|
+
description,
|
|
597
|
+
method,
|
|
598
|
+
apiPath,
|
|
599
|
+
positionals: [],
|
|
600
|
+
options,
|
|
601
|
+
requestBody,
|
|
602
|
+
returns,
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function requiredVoiceType(options) {
|
|
607
|
+
const voiceType = cleanOptional(options.named?.voice || options.named?.['voice-type']);
|
|
608
|
+
if (!voiceType) {
|
|
609
|
+
throw new Error('缺少 --voice。');
|
|
610
|
+
}
|
|
611
|
+
return voiceType;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function speechOptions(voiceLabel, voiceRequired) {
|
|
615
|
+
return [
|
|
616
|
+
option('--text', 'text', true, '待合成文本。'),
|
|
617
|
+
option('--voice', 'voice', voiceRequired, voiceLabel),
|
|
618
|
+
option('--output', 'output', true, '音频保存路径;dry-run 时可不传。'),
|
|
619
|
+
option('--response-format', 'responseFormat', false, 'mp3、wav、pcm 等,默认 mp3。'),
|
|
620
|
+
option('--speed', 'speed', false, '语速控制。'),
|
|
621
|
+
option('--sample-rate', 'sampleRate', false, '采样率。'),
|
|
622
|
+
option('--metadata', 'metadata', false, '透传给 YuanFlow API 的 metadata JSON。'),
|
|
623
|
+
...commonOptions(),
|
|
624
|
+
];
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
function commonOptions() {
|
|
628
|
+
return [
|
|
629
|
+
option('--json', 'json', false, '直接传完整 YuanFlow API 请求 JSON。'),
|
|
630
|
+
option('--token', 'token', false, '临时 token。'),
|
|
631
|
+
option('--base-url', 'baseUrl', false, 'YuanFlow API 地址。'),
|
|
632
|
+
option('--format', 'format', false, 'Agent 调用时建议使用 agent-json。'),
|
|
633
|
+
option('--dry-run', 'dryRun', false, '仅预览请求映射,不发起真实请求,也不要求 token。'),
|
|
634
|
+
];
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function option(flag, name, required, label) {
|
|
638
|
+
return { flag, name, required, label };
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
async function fileToDataUri(filePath) {
|
|
642
|
+
const data = await readFile(filePath);
|
|
643
|
+
return `data:${inferAudioMimeType(filePath)};base64,${data.toString('base64')}`;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function inferAudioMimeType(filePath) {
|
|
647
|
+
switch (path.extname(filePath).toLowerCase()) {
|
|
648
|
+
case '.mp3':
|
|
649
|
+
return 'audio/mpeg';
|
|
650
|
+
case '.wav':
|
|
651
|
+
return 'audio/wav';
|
|
652
|
+
case '.m4a':
|
|
653
|
+
return 'audio/mp4';
|
|
654
|
+
case '.ogg':
|
|
655
|
+
return 'audio/ogg';
|
|
656
|
+
case '.flac':
|
|
657
|
+
return 'audio/flac';
|
|
658
|
+
case '.pcm':
|
|
659
|
+
return 'audio/pcm';
|
|
660
|
+
default:
|
|
661
|
+
return 'application/octet-stream';
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
function inferMediaMimeType(filePath, mediaKind) {
|
|
666
|
+
if (mediaKind === 'image') {
|
|
667
|
+
return inferImageMimeType(filePath);
|
|
668
|
+
}
|
|
669
|
+
return inferVideoMimeType(filePath);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
function inferImageMimeType(filePath) {
|
|
673
|
+
switch (path.extname(filePath).toLowerCase()) {
|
|
674
|
+
case '.jpg':
|
|
675
|
+
case '.jpeg':
|
|
676
|
+
return 'image/jpeg';
|
|
677
|
+
case '.png':
|
|
678
|
+
return 'image/png';
|
|
679
|
+
case '.webp':
|
|
680
|
+
return 'image/webp';
|
|
681
|
+
case '.gif':
|
|
682
|
+
return 'image/gif';
|
|
683
|
+
case '.bmp':
|
|
684
|
+
return 'image/bmp';
|
|
685
|
+
case '.tif':
|
|
686
|
+
case '.tiff':
|
|
687
|
+
return 'image/tiff';
|
|
688
|
+
case '.heic':
|
|
689
|
+
return 'image/heic';
|
|
690
|
+
default:
|
|
691
|
+
return 'application/octet-stream';
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
function inferVideoMimeType(filePath) {
|
|
696
|
+
switch (path.extname(filePath).toLowerCase()) {
|
|
697
|
+
case '.mp4':
|
|
698
|
+
return 'video/mp4';
|
|
699
|
+
case '.mov':
|
|
700
|
+
return 'video/quicktime';
|
|
701
|
+
case '.avi':
|
|
702
|
+
return 'video/x-msvideo';
|
|
703
|
+
case '.mkv':
|
|
704
|
+
return 'video/x-matroska';
|
|
705
|
+
case '.flv':
|
|
706
|
+
return 'video/x-flv';
|
|
707
|
+
case '.wmv':
|
|
708
|
+
return 'video/x-ms-wmv';
|
|
709
|
+
case '.webm':
|
|
710
|
+
return 'video/webm';
|
|
711
|
+
default:
|
|
712
|
+
return 'application/octet-stream';
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
function parseJsonObject(value) {
|
|
717
|
+
const cleaned = cleanOptional(value);
|
|
718
|
+
if (!cleaned) {
|
|
719
|
+
return {};
|
|
720
|
+
}
|
|
721
|
+
const parsed = JSON.parse(cleaned);
|
|
722
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
723
|
+
throw new Error('--metadata 必须是 JSON 对象。');
|
|
724
|
+
}
|
|
725
|
+
return parsed;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
function optionalField(name, value) {
|
|
729
|
+
const cleaned = cleanOptional(value);
|
|
730
|
+
return cleaned === undefined ? {} : { [name]: cleaned };
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
function optionalBooleanField(name, value) {
|
|
734
|
+
const parsed = parseBoolean(value);
|
|
735
|
+
return parsed === undefined ? {} : { [name]: parsed };
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
function addString(target, name, value) {
|
|
739
|
+
const cleaned = cleanOptional(value);
|
|
740
|
+
if (cleaned !== undefined) {
|
|
741
|
+
target[name] = cleaned;
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
function addNumber(target, name, value) {
|
|
746
|
+
const cleaned = cleanOptional(value);
|
|
747
|
+
if (cleaned !== undefined) {
|
|
748
|
+
const number = Number(cleaned);
|
|
749
|
+
target[name] = Number.isFinite(number) ? number : cleaned;
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
function addBoolean(target, name, value) {
|
|
754
|
+
const parsed = parseBoolean(value);
|
|
755
|
+
if (parsed !== undefined) {
|
|
756
|
+
target[name] = parsed;
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
function parseBoolean(value) {
|
|
761
|
+
const cleaned = cleanOptional(value);
|
|
762
|
+
if (cleaned === undefined) {
|
|
763
|
+
return undefined;
|
|
764
|
+
}
|
|
765
|
+
if (typeof cleaned === 'boolean') {
|
|
766
|
+
return cleaned;
|
|
767
|
+
}
|
|
768
|
+
return ['1', 'true', 'yes', 'on'].includes(String(cleaned).toLowerCase());
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
function splitList(value) {
|
|
772
|
+
const cleaned = cleanOptional(value);
|
|
773
|
+
if (!cleaned) {
|
|
774
|
+
return [];
|
|
775
|
+
}
|
|
776
|
+
return String(cleaned)
|
|
777
|
+
.split(',')
|
|
778
|
+
.map((item) => item.trim())
|
|
779
|
+
.filter(Boolean);
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
function cleanOptional(value) {
|
|
783
|
+
if (value === undefined || value === null) return undefined;
|
|
784
|
+
if (typeof value === 'string') {
|
|
785
|
+
const trimmed = value.trim();
|
|
786
|
+
return trimmed ? trimmed : undefined;
|
|
787
|
+
}
|
|
788
|
+
return value;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
function redactBody(body) {
|
|
792
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) {
|
|
793
|
+
return body;
|
|
794
|
+
}
|
|
795
|
+
const redacted = { ...body };
|
|
796
|
+
if ('audio' in redacted) {
|
|
797
|
+
redacted.audio = '<data URI omitted>';
|
|
798
|
+
}
|
|
799
|
+
if ('file' in redacted) {
|
|
800
|
+
redacted.file = '<file omitted>';
|
|
801
|
+
}
|
|
802
|
+
return redacted;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
function cleanBaseUrl(value) {
|
|
806
|
+
return (value || 'https://open.yuanchuangai.com').replace(/\/+$/, '');
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
function parseMaybeJson(text) {
|
|
810
|
+
if (!text) {
|
|
811
|
+
return null;
|
|
812
|
+
}
|
|
813
|
+
try {
|
|
814
|
+
return JSON.parse(text);
|
|
815
|
+
} catch {
|
|
816
|
+
return text;
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
function maskToken(token) {
|
|
821
|
+
if (!token) {
|
|
822
|
+
return '';
|
|
823
|
+
}
|
|
824
|
+
if (token.length <= 10) {
|
|
825
|
+
return '***';
|
|
826
|
+
}
|
|
827
|
+
return `${token.slice(0, 6)}...${token.slice(-4)}`;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
function formatBytes(bytes) {
|
|
831
|
+
if (bytes < 1024 * 1024) {
|
|
832
|
+
return `${Math.ceil(bytes / 1024)}KB`;
|
|
833
|
+
}
|
|
834
|
+
return `${(bytes / 1024 / 1024).toFixed(2)}MB`;
|
|
835
|
+
}
|