@casatwy/deyo 0.1.7 → 0.1.9
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 +75 -8
- package/deyo.mjs +1182 -93
- package/package.json +1 -1
package/deyo.mjs
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
import { chmod, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { chmod, mkdir, open, readFile, rm, stat, writeFile } from 'node:fs/promises'
|
|
4
|
+
import { createHash } from 'node:crypto'
|
|
4
5
|
import { createRequire } from 'node:module'
|
|
5
|
-
import { dirname, extname, resolve } from 'node:path'
|
|
6
|
+
import { basename, dirname, extname, isAbsolute, resolve } from 'node:path'
|
|
6
7
|
import { homedir } from 'node:os'
|
|
7
8
|
import process from 'node:process'
|
|
8
9
|
|
|
@@ -14,8 +15,52 @@ const POLL_INTERVAL_MS = 1500
|
|
|
14
15
|
const PROGRESS_FORMATS = new Set(['auto', 'text', 'jsonl'])
|
|
15
16
|
const RESULT_FORMATS = new Set(['text', 'srt', 'vtt', 'json', 'verbose_json'])
|
|
16
17
|
const TERMINAL_STATUSES = new Set(['completed', 'failed', 'cancelled'])
|
|
18
|
+
const MAX_UPLOAD_CONCURRENCY = 3
|
|
19
|
+
const HASH_CHUNK_SIZE = 4 * 1024 * 1024
|
|
20
|
+
const UPLOAD_REDACTED_SOURCE_LABEL = 'upload:file'
|
|
21
|
+
const RETRYABLE_STATUS_CODES = new Set([429, 500, 502, 503, 504])
|
|
22
|
+
const URL_SCHEME_PATTERN = /^[a-z][a-z\d+\-.]*:\/\//i
|
|
23
|
+
const DOMAIN_LIKE_PATTERN = /^(?:[a-z0-9-]+\.)+[a-z]{2,}(?::\d+)?(?:[/?#].*)?$/i
|
|
24
|
+
const SENSITIVE_UPLOAD_RESULT_KEY_PATTERN = /(?:^|_)(?:objectKey|object_key|audioUrl|audio_url|downloadUrl|download_url|signedUrl|signed_url|uploadUrl|upload_url|sourceUrl|source_url|url)$/i
|
|
25
|
+
const SIGNED_URL_PARAM_PATTERN = /(?:OSSAccessKeyId|Signature|Expires|x-oss-signature|x-oss-credential|x-oss-expires|x-amz-signature)=/i
|
|
26
|
+
const MIME_TYPES_BY_EXTENSION = new Map([
|
|
27
|
+
['.aac', 'audio/aac'],
|
|
28
|
+
['.aif', 'audio/aiff'],
|
|
29
|
+
['.aiff', 'audio/aiff'],
|
|
30
|
+
['.amr', 'audio/amr'],
|
|
31
|
+
['.flac', 'audio/flac'],
|
|
32
|
+
['.m4a', 'audio/mp4'],
|
|
33
|
+
['.mp3', 'audio/mpeg'],
|
|
34
|
+
['.oga', 'audio/ogg'],
|
|
35
|
+
['.ogg', 'audio/ogg'],
|
|
36
|
+
['.opus', 'audio/opus'],
|
|
37
|
+
['.wav', 'audio/wav'],
|
|
38
|
+
['.weba', 'audio/webm'],
|
|
39
|
+
['.wma', 'audio/x-ms-wma'],
|
|
40
|
+
['.3gp', 'video/3gpp'],
|
|
41
|
+
['.avi', 'video/x-msvideo'],
|
|
42
|
+
['.m4v', 'video/x-m4v'],
|
|
43
|
+
['.mkv', 'video/x-matroska'],
|
|
44
|
+
['.mov', 'video/quicktime'],
|
|
45
|
+
['.mp4', 'video/mp4'],
|
|
46
|
+
['.mpeg', 'video/mpeg'],
|
|
47
|
+
['.mpg', 'video/mpeg'],
|
|
48
|
+
['.webm', 'video/webm'],
|
|
49
|
+
])
|
|
50
|
+
|
|
51
|
+
class ReportedCliError extends Error {
|
|
52
|
+
constructor(message = '', exitCode = 1) {
|
|
53
|
+
super(message)
|
|
54
|
+
this.exitCode = exitCode
|
|
55
|
+
}
|
|
56
|
+
}
|
|
17
57
|
|
|
18
|
-
class
|
|
58
|
+
class HttpCliError extends Error {
|
|
59
|
+
constructor(message, status) {
|
|
60
|
+
super(message)
|
|
61
|
+
this.status = status
|
|
62
|
+
}
|
|
63
|
+
}
|
|
19
64
|
|
|
20
65
|
function safeJsonParse(value) {
|
|
21
66
|
try {
|
|
@@ -32,13 +77,16 @@ function fail(message, exitCode = 1) {
|
|
|
32
77
|
|
|
33
78
|
function printUsage() {
|
|
34
79
|
process.stderr.write(`用法:
|
|
35
|
-
deyo [选项] <url>
|
|
80
|
+
deyo [选项] <url|file>
|
|
81
|
+
deyo [选项] --file <file>
|
|
36
82
|
deyo auth <login|logout|status> [选项]
|
|
37
83
|
|
|
38
84
|
转写选项:
|
|
39
85
|
--api-key <key> 可选,Deyo API key
|
|
40
86
|
-O, --output <path> 可选,输出文件路径
|
|
41
87
|
--source <name> 可选,显式指定来源:xiaoyuzhou | ximalaya | bilibili | douyin | xiaohongshu | youtube | apple-podcasts | twitter
|
|
88
|
+
--file <path> 可选,显式指定本地单个音视频文件
|
|
89
|
+
--mime-type <type> 可选,上传本地文件时显式指定 MIME,例如 audio/mpeg 或 video/mp4
|
|
42
90
|
--language <value> 可选,转写语言,默认 ${DEFAULT_LANGUAGE}
|
|
43
91
|
--format <value> 可选,text | srt | vtt | json | verbose_json
|
|
44
92
|
--progress-format <v> 可选,auto | text | jsonl,默认 auto
|
|
@@ -55,6 +103,10 @@ API key 读取优先级:
|
|
|
55
103
|
1. --api-key
|
|
56
104
|
2. 环境变量 DEYO_API_KEY
|
|
57
105
|
3. 本地配置文件(先执行 deyo auth login)
|
|
106
|
+
|
|
107
|
+
本地文件说明:
|
|
108
|
+
v1 只支持单文件上传;不支持目录、glob、stdin、批量和断点续传。
|
|
109
|
+
可以用 deyo -- ./audio.mp3 明确把 -- 后的单个参数当作路径或链接。
|
|
58
110
|
`)
|
|
59
111
|
}
|
|
60
112
|
|
|
@@ -76,11 +128,14 @@ function parseTranscriptionArgs(argv) {
|
|
|
76
128
|
apiKey: '',
|
|
77
129
|
output: '',
|
|
78
130
|
source: '',
|
|
131
|
+
file: '',
|
|
132
|
+
mimeType: '',
|
|
79
133
|
language: '',
|
|
80
134
|
format: '',
|
|
81
135
|
progressFormat: 'auto',
|
|
82
136
|
baseUrl: '',
|
|
83
137
|
url: '',
|
|
138
|
+
positional: [],
|
|
84
139
|
}
|
|
85
140
|
|
|
86
141
|
for (let index = 0; index < argv.length; index += 1) {
|
|
@@ -91,6 +146,17 @@ function parseTranscriptionArgs(argv) {
|
|
|
91
146
|
process.exit(0)
|
|
92
147
|
}
|
|
93
148
|
|
|
149
|
+
if (current === '--') {
|
|
150
|
+
const rest = argv.slice(index + 1)
|
|
151
|
+
|
|
152
|
+
if (rest.length !== 1) {
|
|
153
|
+
fail('-- 后只能提供一个本地文件路径或内容链接')
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
args.positional.push(rest[0])
|
|
157
|
+
break
|
|
158
|
+
}
|
|
159
|
+
|
|
94
160
|
if (current === '-O' || current === '--output') {
|
|
95
161
|
args.output = argv[index + 1] || ''
|
|
96
162
|
index += 1
|
|
@@ -109,6 +175,18 @@ function parseTranscriptionArgs(argv) {
|
|
|
109
175
|
continue
|
|
110
176
|
}
|
|
111
177
|
|
|
178
|
+
if (current === '--file') {
|
|
179
|
+
args.file = argv[index + 1] || ''
|
|
180
|
+
index += 1
|
|
181
|
+
continue
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (current === '--mime-type') {
|
|
185
|
+
args.mimeType = argv[index + 1] || ''
|
|
186
|
+
index += 1
|
|
187
|
+
continue
|
|
188
|
+
}
|
|
189
|
+
|
|
112
190
|
if (current === '--language') {
|
|
113
191
|
args.language = argv[index + 1] || ''
|
|
114
192
|
index += 1
|
|
@@ -137,13 +215,18 @@ function parseTranscriptionArgs(argv) {
|
|
|
137
215
|
fail(`未知参数:${current}`)
|
|
138
216
|
}
|
|
139
217
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
218
|
+
args.positional.push(current)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (args.positional.length > 1) {
|
|
222
|
+
fail('v1 只支持提交一个链接或一个本地文件,不支持批量转写')
|
|
223
|
+
}
|
|
143
224
|
|
|
144
|
-
|
|
225
|
+
if (args.file && args.positional.length) {
|
|
226
|
+
fail('--file 不能和额外的路径或 URL 同时使用')
|
|
145
227
|
}
|
|
146
228
|
|
|
229
|
+
args.url = args.positional[0] || ''
|
|
147
230
|
return args
|
|
148
231
|
}
|
|
149
232
|
|
|
@@ -216,6 +299,295 @@ function inferFormat(outputPath, explicitFormat) {
|
|
|
216
299
|
return 'text'
|
|
217
300
|
}
|
|
218
301
|
|
|
302
|
+
function isAbortError(error) {
|
|
303
|
+
return error?.name === 'AbortError' || error?.code === 'ABORT_ERR'
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function createAbortError() {
|
|
307
|
+
const error = new Error('操作已中断')
|
|
308
|
+
error.name = 'AbortError'
|
|
309
|
+
return error
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function isRetryableRequestError(error) {
|
|
313
|
+
if (isAbortError(error)) {
|
|
314
|
+
return false
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (error instanceof HttpCliError) {
|
|
318
|
+
return RETRYABLE_STATUS_CODES.has(error.status) || error.status >= 500
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return true
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function getRetryDelayMs(attempt) {
|
|
325
|
+
return Math.min(4000, 350 * (2 ** Math.max(0, attempt - 1)))
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async function withRetry(operation, options = {}) {
|
|
329
|
+
const maxAttempts = options.maxAttempts || 4
|
|
330
|
+
let lastError = null
|
|
331
|
+
|
|
332
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
333
|
+
try {
|
|
334
|
+
return await operation(attempt)
|
|
335
|
+
} catch (error) {
|
|
336
|
+
lastError = error
|
|
337
|
+
|
|
338
|
+
if (options.signal?.aborted || isAbortError(error)) {
|
|
339
|
+
throw error
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const shouldRetry = options.shouldRetry
|
|
343
|
+
? options.shouldRetry(error)
|
|
344
|
+
: isRetryableRequestError(error)
|
|
345
|
+
|
|
346
|
+
if (!shouldRetry || attempt >= maxAttempts) {
|
|
347
|
+
throw error
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
await sleep(getRetryDelayMs(attempt))
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
throw lastError || new Error('请求失败')
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function isLikelyUrlInput(value) {
|
|
358
|
+
const normalized = String(value || '').trim()
|
|
359
|
+
|
|
360
|
+
return URL_SCHEME_PATTERN.test(normalized) || DOMAIN_LIKE_PATTERN.test(normalized)
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function isStdinInput(value) {
|
|
364
|
+
return String(value || '').trim() === '-'
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function hasGlobPattern(value) {
|
|
368
|
+
const normalized = String(value || '')
|
|
369
|
+
return /[*[\]{}]/.test(normalized) || (!isLikelyUrlInput(normalized) && normalized.includes('?'))
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function expandHomePath(value) {
|
|
373
|
+
const normalized = String(value || '').trim()
|
|
374
|
+
|
|
375
|
+
if (normalized === '~') {
|
|
376
|
+
return homedir() || normalized
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
if (normalized.startsWith('~/')) {
|
|
380
|
+
const home = homedir()
|
|
381
|
+
return home ? resolve(home, normalized.slice(2)) : normalized
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return normalized
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function resolveLocalPath(value) {
|
|
388
|
+
const expanded = expandHomePath(value)
|
|
389
|
+
return isAbsolute(expanded) ? expanded : resolve(process.cwd(), expanded)
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function isAllowedLocalMimeType(value) {
|
|
393
|
+
const normalized = String(value || '').trim().toLowerCase()
|
|
394
|
+
return normalized.startsWith('audio/') || normalized.startsWith('video/')
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function normalizeExplicitMimeType(value) {
|
|
398
|
+
const normalized = String(value || '').trim().toLowerCase()
|
|
399
|
+
|
|
400
|
+
if (!normalized) {
|
|
401
|
+
return ''
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
if (!/^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*(?:\s*;.*)?$/i.test(normalized)) {
|
|
405
|
+
fail(`--mime-type 不是合法 MIME:${value}`)
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
if (!isAllowedLocalMimeType(normalized)) {
|
|
409
|
+
fail('--mime-type 只支持 audio/* 或 video/*')
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
return normalized.split(';')[0].trim()
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function inferMimeTypeFromPath(filePath) {
|
|
416
|
+
return MIME_TYPES_BY_EXTENSION.get(extname(filePath).toLowerCase()) || ''
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function normalizeEtag(value) {
|
|
420
|
+
return String(value || '').trim().replace(/^"+|"+$/g, '')
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function formatBytes(value) {
|
|
424
|
+
const bytes = Number(value)
|
|
425
|
+
|
|
426
|
+
if (!Number.isFinite(bytes) || bytes <= 0) {
|
|
427
|
+
return '0 B'
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
|
431
|
+
let size = bytes
|
|
432
|
+
let unitIndex = 0
|
|
433
|
+
|
|
434
|
+
while (size >= 1024 && unitIndex < units.length - 1) {
|
|
435
|
+
size /= 1024
|
|
436
|
+
unitIndex += 1
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const precision = size >= 10 || unitIndex === 0 ? 0 : 1
|
|
440
|
+
return `${size.toFixed(precision)} ${units[unitIndex]}`
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function redactUploadSourceUrl(sourceType, sourceUrl) {
|
|
444
|
+
if (sourceType === 'upload' || String(sourceUrl || '').startsWith('upload:sha256:')) {
|
|
445
|
+
return UPLOAD_REDACTED_SOURCE_LABEL
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
return sourceUrl || null
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function isSensitiveUploadResultString(key, value, sourceType = null) {
|
|
452
|
+
if (value.startsWith('upload:sha256:')) {
|
|
453
|
+
return true
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
if (SIGNED_URL_PARAM_PATTERN.test(value)) {
|
|
457
|
+
return true
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
return sourceType === 'upload' && !!key && SENSITIVE_UPLOAD_RESULT_KEY_PATTERN.test(key)
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function sanitizeTaskForReporting(task) {
|
|
464
|
+
if (!task || typeof task !== 'object') {
|
|
465
|
+
return task
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
return {
|
|
469
|
+
...task,
|
|
470
|
+
sourceUrl: redactUploadSourceUrl(task.sourceType, task.sourceUrl),
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function sanitizeResultPayload(value, inheritedSourceType = null, inheritedKey = null) {
|
|
475
|
+
if (Array.isArray(value)) {
|
|
476
|
+
return value.map(item => sanitizeResultPayload(item, inheritedSourceType, inheritedKey))
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
if (!value || typeof value !== 'object') {
|
|
480
|
+
if (typeof value === 'string' && isSensitiveUploadResultString(inheritedKey, value, inheritedSourceType)) {
|
|
481
|
+
return UPLOAD_REDACTED_SOURCE_LABEL
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
return value
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const sourceType = typeof value.sourceType === 'string' ? value.sourceType : inheritedSourceType
|
|
488
|
+
const next = {}
|
|
489
|
+
|
|
490
|
+
for (const [key, item] of Object.entries(value)) {
|
|
491
|
+
if (key === 'sourceUrl') {
|
|
492
|
+
next[key] = redactUploadSourceUrl(sourceType, item)
|
|
493
|
+
continue
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
next[key] = sanitizeResultPayload(item, sourceType, key)
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
return next
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
async function inspectLocalFile(filePath, explicitMimeType) {
|
|
503
|
+
const trimmedPath = String(filePath || '').trim()
|
|
504
|
+
|
|
505
|
+
if (!trimmedPath) {
|
|
506
|
+
fail('缺少本地文件路径')
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
if (isStdinInput(trimmedPath)) {
|
|
510
|
+
fail('v1 不支持从 stdin 读取,请提供本地单个音视频文件路径')
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
if (hasGlobPattern(trimmedPath)) {
|
|
514
|
+
fail('v1 不支持 glob,请提供一个明确的本地文件路径')
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
const resolvedPath = resolveLocalPath(trimmedPath)
|
|
518
|
+
let fileStat
|
|
519
|
+
|
|
520
|
+
try {
|
|
521
|
+
fileStat = await stat(resolvedPath)
|
|
522
|
+
} catch (error) {
|
|
523
|
+
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
|
|
524
|
+
fail(`本地文件不存在:${trimmedPath}`)
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
throw error
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
if (fileStat.isDirectory()) {
|
|
531
|
+
fail('v1 不支持目录,请提供一个本地音视频文件')
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
if (!fileStat.isFile()) {
|
|
535
|
+
fail('v1 只支持普通本地文件,不支持管道、设备或其他特殊文件')
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
if (fileStat.size <= 0) {
|
|
539
|
+
fail('本地文件为空,无法转写')
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
const explicit = normalizeExplicitMimeType(explicitMimeType)
|
|
543
|
+
const mimeType = explicit || inferMimeTypeFromPath(resolvedPath)
|
|
544
|
+
|
|
545
|
+
if (!mimeType || !isAllowedLocalMimeType(mimeType)) {
|
|
546
|
+
fail('无法识别本地文件 MIME,请使用 --mime-type 指定 audio/* 或 video/*')
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
return {
|
|
550
|
+
kind: 'file',
|
|
551
|
+
path: resolvedPath,
|
|
552
|
+
filename: basename(resolvedPath),
|
|
553
|
+
sizeBytes: fileStat.size,
|
|
554
|
+
mimeType,
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
async function resolveTranscriptionInput(args) {
|
|
559
|
+
if (args.file) {
|
|
560
|
+
return await inspectLocalFile(args.file, args.mimeType)
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
const rawInput = String(args.url || '').trim()
|
|
564
|
+
|
|
565
|
+
if (!rawInput) {
|
|
566
|
+
return null
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
if (isStdinInput(rawInput)) {
|
|
570
|
+
fail('v1 不支持从 stdin 读取,请提供内容链接或本地单个音视频文件')
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
if (!isLikelyUrlInput(rawInput) && hasGlobPattern(rawInput)) {
|
|
574
|
+
fail('v1 不支持 glob,请提供一个明确的本地文件路径或内容链接')
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
if (!isLikelyUrlInput(rawInput)) {
|
|
578
|
+
return await inspectLocalFile(rawInput, args.mimeType)
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
if (args.mimeType) {
|
|
582
|
+
fail('--mime-type 只能用于本地文件上传')
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
return {
|
|
586
|
+
kind: 'url',
|
|
587
|
+
url: rawInput,
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
219
591
|
function getConfigFileCandidates() {
|
|
220
592
|
const home = homedir()
|
|
221
593
|
const xdgConfigHome = String(process.env.XDG_CONFIG_HOME || '').trim()
|
|
@@ -408,26 +780,33 @@ async function readErrorMessage(response) {
|
|
|
408
780
|
}
|
|
409
781
|
|
|
410
782
|
async function requestJson(baseUrl, apiKey, path, options = {}) {
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
783
|
+
return await withRetry(async () => {
|
|
784
|
+
const response = await fetch(`${baseUrl}${path}`, {
|
|
785
|
+
method: options.method || 'GET',
|
|
786
|
+
signal: options.signal,
|
|
787
|
+
headers: {
|
|
788
|
+
Authorization: `Bearer ${apiKey}`,
|
|
789
|
+
'Content-Type': 'application/json',
|
|
790
|
+
...(options.headers || {}),
|
|
791
|
+
},
|
|
792
|
+
body: typeof options.body === 'undefined' ? undefined : JSON.stringify(options.body),
|
|
793
|
+
})
|
|
420
794
|
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
795
|
+
if (!response.ok) {
|
|
796
|
+
throw new HttpCliError(await readErrorMessage(response), response.status)
|
|
797
|
+
}
|
|
424
798
|
|
|
425
|
-
|
|
799
|
+
return response.json()
|
|
800
|
+
}, {
|
|
801
|
+
signal: options.signal,
|
|
802
|
+
maxAttempts: options.retry ? 4 : 1,
|
|
803
|
+
})
|
|
426
804
|
}
|
|
427
805
|
|
|
428
|
-
async function requestResult(baseUrl, apiKey, taskId, format) {
|
|
806
|
+
async function requestResult(baseUrl, apiKey, taskId, format, options = {}) {
|
|
429
807
|
const response = await fetch(`${baseUrl}/api/cli/transcriptions/${encodeURIComponent(taskId)}/result?format=${encodeURIComponent(format)}`, {
|
|
430
808
|
method: 'GET',
|
|
809
|
+
signal: options.signal,
|
|
431
810
|
headers: {
|
|
432
811
|
Authorization: `Bearer ${apiKey}`,
|
|
433
812
|
},
|
|
@@ -438,7 +817,7 @@ async function requestResult(baseUrl, apiKey, taskId, format) {
|
|
|
438
817
|
}
|
|
439
818
|
|
|
440
819
|
if (format === 'json' || format === 'verbose_json') {
|
|
441
|
-
return JSON.stringify(await response.json(), null, 2)
|
|
820
|
+
return JSON.stringify(sanitizeResultPayload(await response.json()), null, 2)
|
|
442
821
|
}
|
|
443
822
|
|
|
444
823
|
return response.text()
|
|
@@ -566,6 +945,77 @@ function buildTranscribingProgressMessage(input = {}) {
|
|
|
566
945
|
return `转写中 ${percent}%${chunksPart}`
|
|
567
946
|
}
|
|
568
947
|
|
|
948
|
+
function normalizeUploadPercent(uploadedBytes, totalBytes) {
|
|
949
|
+
if (!Number.isFinite(uploadedBytes) || !Number.isFinite(totalBytes) || totalBytes <= 0) {
|
|
950
|
+
return 0
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
return Math.max(0, Math.min(100, Math.round((uploadedBytes / totalBytes) * 100)))
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
function buildUploadProgressMessage(input = {}) {
|
|
957
|
+
const uploadedBytes = Number(input.uploadedBytes) || 0
|
|
958
|
+
const totalBytes = Number(input.totalBytes) || 0
|
|
959
|
+
const percent = normalizeUploadPercent(uploadedBytes, totalBytes)
|
|
960
|
+
|
|
961
|
+
return `上传中 ${formatBytes(uploadedBytes)} / ${formatBytes(totalBytes)} (${percent}%)`
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
function buildHashingMessage(input = {}) {
|
|
965
|
+
const hashedBytes = Number(input.hashedBytes) || 0
|
|
966
|
+
const totalBytes = Number(input.totalBytes) || 0
|
|
967
|
+
const percent = normalizeUploadPercent(hashedBytes, totalBytes)
|
|
968
|
+
|
|
969
|
+
return `正在计算文件哈希 ${percent}%`
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
function getUploadReadyMessage(input = {}) {
|
|
973
|
+
const title = input.title || input.filename || '上传文件'
|
|
974
|
+
const duration = Number(input.durationSeconds)
|
|
975
|
+
const durationPart = Number.isFinite(duration) && duration > 0
|
|
976
|
+
? `,时长约 ${Math.ceil(duration / 60)} 分钟`
|
|
977
|
+
: ''
|
|
978
|
+
|
|
979
|
+
return `媒体检查通过:${title}${durationPart}`
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
function getUploadTaskFromCheckResult(checkResult) {
|
|
983
|
+
if (checkResult?.task?.taskId) {
|
|
984
|
+
return checkResult.task
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
if (checkResult?.upload?.task?.taskId) {
|
|
988
|
+
return checkResult.upload.task
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
return null
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
function getUploadReadySummary(checkResult, fileInput, uploadId) {
|
|
995
|
+
const task = getUploadTaskFromCheckResult(checkResult)
|
|
996
|
+
const upload = checkResult?.upload || {}
|
|
997
|
+
const durationMs = Number(upload.ffprobeDurationMs || upload.clientDurationMs)
|
|
998
|
+
const durationSeconds = Number(checkResult?.durationSeconds)
|
|
999
|
+
const normalizedDurationSeconds = Number.isFinite(durationSeconds) && durationSeconds > 0
|
|
1000
|
+
? durationSeconds
|
|
1001
|
+
: Number.isFinite(durationMs) && durationMs > 0
|
|
1002
|
+
? Math.round(durationMs / 1000)
|
|
1003
|
+
: null
|
|
1004
|
+
const requiredMinutes = Number(checkResult?.requiredMinutes)
|
|
1005
|
+
|
|
1006
|
+
return {
|
|
1007
|
+
uploadId,
|
|
1008
|
+
filename: upload.filename || fileInput.filename,
|
|
1009
|
+
title: task?.title || checkResult?.title || upload.filename || fileInput.filename,
|
|
1010
|
+
durationSeconds: normalizedDurationSeconds,
|
|
1011
|
+
requiredMinutes: Number.isFinite(requiredMinutes) && requiredMinutes > 0
|
|
1012
|
+
? requiredMinutes
|
|
1013
|
+
: normalizedDurationSeconds
|
|
1014
|
+
? Math.max(1, Math.floor(normalizedDurationSeconds / 60))
|
|
1015
|
+
: null,
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
|
|
569
1019
|
function createProgressReporter(progressFormat) {
|
|
570
1020
|
const resolvedMode = resolveProgressFormat(progressFormat)
|
|
571
1021
|
const outputState = createOutputState()
|
|
@@ -596,29 +1046,157 @@ function createProgressReporter(progressFormat) {
|
|
|
596
1046
|
return {
|
|
597
1047
|
mode: resolvedMode,
|
|
598
1048
|
reportTaskCreated(task) {
|
|
1049
|
+
const safeTask = sanitizeTaskForReporting(task)
|
|
1050
|
+
|
|
599
1051
|
if (resolvedMode === 'jsonl') {
|
|
600
1052
|
writeJsonEvent({
|
|
601
1053
|
event: 'task.created',
|
|
602
|
-
message: `任务已创建:${
|
|
603
|
-
taskId:
|
|
604
|
-
title:
|
|
605
|
-
sourceType:
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
1054
|
+
message: `任务已创建:${safeTask.taskId}`,
|
|
1055
|
+
taskId: safeTask.taskId,
|
|
1056
|
+
title: safeTask.title,
|
|
1057
|
+
sourceType: safeTask.sourceType,
|
|
1058
|
+
sourceUrl: safeTask.sourceUrl || null,
|
|
1059
|
+
status: safeTask.status,
|
|
1060
|
+
statusLabel: formatStatus(safeTask.status),
|
|
1061
|
+
consumedMinutes: safeTask.consumedMinutes,
|
|
1062
|
+
remainingMinutes: safeTask.remainingMinutes,
|
|
1063
|
+
mode: safeTask.mode,
|
|
1064
|
+
resultReady: safeTask.resultReady,
|
|
612
1065
|
})
|
|
613
1066
|
return
|
|
614
1067
|
}
|
|
615
1068
|
|
|
616
|
-
writeTextMessage(`任务已创建:${
|
|
617
|
-
writeTextMessage(`标题:${
|
|
618
|
-
writeTextMessage(`来源:${
|
|
619
|
-
writeTextMessage(`状态:${formatStatus(
|
|
620
|
-
writeTextMessage(`本次消耗分钟:${
|
|
621
|
-
writeTextMessage(`剩余分钟:${
|
|
1069
|
+
writeTextMessage(`任务已创建:${safeTask.taskId}`)
|
|
1070
|
+
writeTextMessage(`标题:${safeTask.title}`)
|
|
1071
|
+
writeTextMessage(`来源:${safeTask.sourceType}`)
|
|
1072
|
+
writeTextMessage(`状态:${formatStatus(safeTask.status)}`)
|
|
1073
|
+
writeTextMessage(`本次消耗分钟:${safeTask.consumedMinutes}`)
|
|
1074
|
+
writeTextMessage(`剩余分钟:${safeTask.remainingMinutes}`)
|
|
1075
|
+
},
|
|
1076
|
+
reportUploadHashing(input = {}) {
|
|
1077
|
+
const message = buildHashingMessage(input)
|
|
1078
|
+
const hashedBytes = Number(input.hashedBytes) || 0
|
|
1079
|
+
const totalBytes = Number(input.totalBytes) || 0
|
|
1080
|
+
const percent = normalizeUploadPercent(hashedBytes, totalBytes)
|
|
1081
|
+
|
|
1082
|
+
if (resolvedMode === 'interactive') {
|
|
1083
|
+
writeInline(outputState, message)
|
|
1084
|
+
return
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
if (resolvedMode === 'jsonl') {
|
|
1088
|
+
writeJsonEvent({
|
|
1089
|
+
event: 'upload.hashing',
|
|
1090
|
+
message,
|
|
1091
|
+
filename: input.filename || null,
|
|
1092
|
+
hashedBytes,
|
|
1093
|
+
totalBytes,
|
|
1094
|
+
percent,
|
|
1095
|
+
})
|
|
1096
|
+
return
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
if (message === lastTextProgressMessage) {
|
|
1100
|
+
return
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
lastTextProgressMessage = message
|
|
1104
|
+
writeTextMessage(message)
|
|
1105
|
+
},
|
|
1106
|
+
reportUploadStarted(input = {}) {
|
|
1107
|
+
lastTextProgressMessage = ''
|
|
1108
|
+
const message = `上传已开始:${input.filename || '上传文件'}(${formatBytes(input.totalBytes)})`
|
|
1109
|
+
|
|
1110
|
+
emitTextOrJson(message, {
|
|
1111
|
+
event: 'upload.started',
|
|
1112
|
+
filename: input.filename || null,
|
|
1113
|
+
mimeType: input.mimeType || null,
|
|
1114
|
+
totalBytes: Number(input.totalBytes) || 0,
|
|
1115
|
+
uploadId: input.uploadId || null,
|
|
1116
|
+
partSize: Number(input.partSize) || null,
|
|
1117
|
+
totalParts: Number(input.totalParts) || null,
|
|
1118
|
+
})
|
|
1119
|
+
},
|
|
1120
|
+
reportUploadProgress(input = {}) {
|
|
1121
|
+
const uploadedBytes = Number(input.uploadedBytes) || 0
|
|
1122
|
+
const totalBytes = Number(input.totalBytes) || 0
|
|
1123
|
+
const percent = normalizeUploadPercent(uploadedBytes, totalBytes)
|
|
1124
|
+
const message = buildUploadProgressMessage({ uploadedBytes, totalBytes })
|
|
1125
|
+
|
|
1126
|
+
if (resolvedMode === 'interactive') {
|
|
1127
|
+
writeInline(outputState, message)
|
|
1128
|
+
return
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
if (resolvedMode === 'jsonl') {
|
|
1132
|
+
writeJsonEvent({
|
|
1133
|
+
event: 'upload.progress',
|
|
1134
|
+
message,
|
|
1135
|
+
uploadedBytes,
|
|
1136
|
+
totalBytes,
|
|
1137
|
+
percent,
|
|
1138
|
+
completedParts: Number(input.completedParts) || null,
|
|
1139
|
+
totalParts: Number(input.totalParts) || null,
|
|
1140
|
+
})
|
|
1141
|
+
return
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
if (message === lastTextProgressMessage) {
|
|
1145
|
+
return
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
lastTextProgressMessage = message
|
|
1149
|
+
writeTextMessage(message)
|
|
1150
|
+
},
|
|
1151
|
+
reportUploadCompleted(input = {}) {
|
|
1152
|
+
finishInline(outputState)
|
|
1153
|
+
lastTextProgressMessage = ''
|
|
1154
|
+
const message = '上传完成,正在合并并准备媒体检查'
|
|
1155
|
+
|
|
1156
|
+
emitTextOrJson(message, {
|
|
1157
|
+
event: 'upload.completed',
|
|
1158
|
+
uploadId: input.uploadId || null,
|
|
1159
|
+
filename: input.filename || null,
|
|
1160
|
+
totalBytes: Number(input.totalBytes) || 0,
|
|
1161
|
+
})
|
|
1162
|
+
},
|
|
1163
|
+
reportUploadChecking(input = {}) {
|
|
1164
|
+
const message = '正在检查媒体信息'
|
|
1165
|
+
|
|
1166
|
+
emitTextOrJson(message, {
|
|
1167
|
+
event: 'upload.checking',
|
|
1168
|
+
uploadId: input.uploadId || null,
|
|
1169
|
+
})
|
|
1170
|
+
},
|
|
1171
|
+
reportUploadReady(input = {}) {
|
|
1172
|
+
const message = getUploadReadyMessage(input)
|
|
1173
|
+
|
|
1174
|
+
emitTextOrJson(message, {
|
|
1175
|
+
event: 'upload.ready',
|
|
1176
|
+
uploadId: input.uploadId || null,
|
|
1177
|
+
sourceType: 'upload',
|
|
1178
|
+
sourceUrl: UPLOAD_REDACTED_SOURCE_LABEL,
|
|
1179
|
+
title: input.title || input.filename || null,
|
|
1180
|
+
durationSeconds: Number(input.durationSeconds) || null,
|
|
1181
|
+
requiredMinutes: Number(input.requiredMinutes) || null,
|
|
1182
|
+
})
|
|
1183
|
+
},
|
|
1184
|
+
reportUploadAborted(input = {}) {
|
|
1185
|
+
finishInline(outputState)
|
|
1186
|
+
const message = '上传已取消'
|
|
1187
|
+
|
|
1188
|
+
emitTextOrJson(message, {
|
|
1189
|
+
event: 'upload.aborted',
|
|
1190
|
+
uploadId: input.uploadId || null,
|
|
1191
|
+
})
|
|
1192
|
+
},
|
|
1193
|
+
reportUploadFailed(message, input = {}) {
|
|
1194
|
+
finishInline(outputState)
|
|
1195
|
+
emitTextOrJson(message, {
|
|
1196
|
+
event: 'upload.failed',
|
|
1197
|
+
uploadId: input.uploadId || null,
|
|
1198
|
+
error: message,
|
|
1199
|
+
})
|
|
622
1200
|
},
|
|
623
1201
|
reportStatusChanged(status) {
|
|
624
1202
|
emitTextOrJson(buildStatusChangedMessage(status), {
|
|
@@ -728,9 +1306,10 @@ function parseSseBlock(block) {
|
|
|
728
1306
|
}
|
|
729
1307
|
}
|
|
730
1308
|
|
|
731
|
-
async function consumeTaskEvents(baseUrl, apiKey, taskId, initialTask, reporter) {
|
|
1309
|
+
async function consumeTaskEvents(baseUrl, apiKey, taskId, initialTask, reporter, signal) {
|
|
732
1310
|
const response = await fetch(`${baseUrl}/api/cli/transcriptions/${encodeURIComponent(taskId)}/events`, {
|
|
733
1311
|
method: 'GET',
|
|
1312
|
+
signal,
|
|
734
1313
|
headers: {
|
|
735
1314
|
Authorization: `Bearer ${apiKey}`,
|
|
736
1315
|
},
|
|
@@ -858,6 +1437,423 @@ async function writeOutput(outputPath, content) {
|
|
|
858
1437
|
return resolvedPath
|
|
859
1438
|
}
|
|
860
1439
|
|
|
1440
|
+
function assertNotAborted(signal) {
|
|
1441
|
+
if (signal?.aborted) {
|
|
1442
|
+
throw createAbortError()
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
async function calculateFileSha256(fileInput, reporter, signal) {
|
|
1447
|
+
const hash = createHash('sha256')
|
|
1448
|
+
const fileHandle = await open(fileInput.path, 'r')
|
|
1449
|
+
let hashedBytes = 0
|
|
1450
|
+
|
|
1451
|
+
reporter.reportUploadHashing({
|
|
1452
|
+
filename: fileInput.filename,
|
|
1453
|
+
hashedBytes,
|
|
1454
|
+
totalBytes: fileInput.sizeBytes,
|
|
1455
|
+
})
|
|
1456
|
+
|
|
1457
|
+
try {
|
|
1458
|
+
while (hashedBytes < fileInput.sizeBytes) {
|
|
1459
|
+
assertNotAborted(signal)
|
|
1460
|
+
|
|
1461
|
+
const length = Math.min(HASH_CHUNK_SIZE, fileInput.sizeBytes - hashedBytes)
|
|
1462
|
+
const buffer = Buffer.allocUnsafe(length)
|
|
1463
|
+
const { bytesRead } = await fileHandle.read(buffer, 0, length, hashedBytes)
|
|
1464
|
+
|
|
1465
|
+
if (bytesRead <= 0) {
|
|
1466
|
+
break
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
hash.update(bytesRead === buffer.length ? buffer : buffer.subarray(0, bytesRead))
|
|
1470
|
+
hashedBytes += bytesRead
|
|
1471
|
+
reporter.reportUploadHashing({
|
|
1472
|
+
filename: fileInput.filename,
|
|
1473
|
+
hashedBytes,
|
|
1474
|
+
totalBytes: fileInput.sizeBytes,
|
|
1475
|
+
})
|
|
1476
|
+
}
|
|
1477
|
+
} finally {
|
|
1478
|
+
await fileHandle.close().catch(() => {})
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
if (hashedBytes !== fileInput.sizeBytes) {
|
|
1482
|
+
throw new Error('读取本地文件计算哈希时大小不一致,请重新选择文件')
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
return hash.digest('hex')
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
async function readFileChunk(filePath, start, length, signal) {
|
|
1489
|
+
assertNotAborted(signal)
|
|
1490
|
+
|
|
1491
|
+
const fileHandle = await open(filePath, 'r')
|
|
1492
|
+
const buffer = Buffer.allocUnsafe(length)
|
|
1493
|
+
let offset = 0
|
|
1494
|
+
|
|
1495
|
+
try {
|
|
1496
|
+
while (offset < length) {
|
|
1497
|
+
assertNotAborted(signal)
|
|
1498
|
+
|
|
1499
|
+
const { bytesRead } = await fileHandle.read(
|
|
1500
|
+
buffer,
|
|
1501
|
+
offset,
|
|
1502
|
+
length - offset,
|
|
1503
|
+
start + offset,
|
|
1504
|
+
)
|
|
1505
|
+
|
|
1506
|
+
if (bytesRead <= 0) {
|
|
1507
|
+
break
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
offset += bytesRead
|
|
1511
|
+
}
|
|
1512
|
+
} finally {
|
|
1513
|
+
await fileHandle.close().catch(() => {})
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
if (offset !== length) {
|
|
1517
|
+
throw new Error('读取本地文件分片时大小不一致,请重新选择文件')
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
return buffer
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
async function abortUploadOnServer(baseUrl, apiKey, uploadId) {
|
|
1524
|
+
if (!uploadId) {
|
|
1525
|
+
return null
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
return await requestJson(baseUrl, apiKey, '/api/cli/uploads/abort', {
|
|
1529
|
+
method: 'POST',
|
|
1530
|
+
retry: true,
|
|
1531
|
+
body: {
|
|
1532
|
+
uploadId,
|
|
1533
|
+
},
|
|
1534
|
+
})
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
function createRunContext(baseUrl, apiKey, reporter) {
|
|
1538
|
+
const abortController = new AbortController()
|
|
1539
|
+
const state = {
|
|
1540
|
+
uploadId: '',
|
|
1541
|
+
taskId: '',
|
|
1542
|
+
taskCreated: false,
|
|
1543
|
+
uploadAbortAllowed: true,
|
|
1544
|
+
interrupting: false,
|
|
1545
|
+
interruptPromise: null,
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
const handleSigint = () => {
|
|
1549
|
+
if (state.interrupting) {
|
|
1550
|
+
return
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
state.interrupting = true
|
|
1554
|
+
abortController.abort()
|
|
1555
|
+
|
|
1556
|
+
state.interruptPromise = (async () => {
|
|
1557
|
+
if (state.taskCreated && state.taskId) {
|
|
1558
|
+
reporter.reportNotice(`已停止本地等待,服务端转写仍在继续:${state.taskId}`)
|
|
1559
|
+
reporter.close()
|
|
1560
|
+
return
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
if (!state.uploadAbortAllowed && state.uploadId) {
|
|
1564
|
+
const taskPart = state.taskId ? `,taskId=${state.taskId}` : ''
|
|
1565
|
+
reporter.reportNotice(`已停止本地等待,服务端正在处理上传:uploadId=${state.uploadId}${taskPart}`)
|
|
1566
|
+
reporter.close()
|
|
1567
|
+
return
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
if (state.uploadId) {
|
|
1571
|
+
try {
|
|
1572
|
+
await abortUploadOnServer(baseUrl, apiKey, state.uploadId)
|
|
1573
|
+
reporter.reportUploadAborted({ uploadId: state.uploadId })
|
|
1574
|
+
} catch (error) {
|
|
1575
|
+
reporter.reportUploadFailed(
|
|
1576
|
+
`已中断本地上传,但服务端上传清理失败:${error instanceof Error ? error.message : String(error)}`,
|
|
1577
|
+
{ uploadId: state.uploadId },
|
|
1578
|
+
)
|
|
1579
|
+
}
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
reporter.close()
|
|
1583
|
+
})()
|
|
1584
|
+
|
|
1585
|
+
void state.interruptPromise.finally(() => {
|
|
1586
|
+
process.exit(130)
|
|
1587
|
+
})
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
process.once('SIGINT', handleSigint)
|
|
1591
|
+
|
|
1592
|
+
return {
|
|
1593
|
+
signal: abortController.signal,
|
|
1594
|
+
state,
|
|
1595
|
+
dispose() {
|
|
1596
|
+
process.off('SIGINT', handleSigint)
|
|
1597
|
+
},
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
async function signUploadPart(baseUrl, apiKey, uploadId, partNumber, signal) {
|
|
1602
|
+
const response = await requestJson(baseUrl, apiKey, '/api/cli/uploads/parts', {
|
|
1603
|
+
method: 'POST',
|
|
1604
|
+
signal,
|
|
1605
|
+
retry: true,
|
|
1606
|
+
body: {
|
|
1607
|
+
uploadId,
|
|
1608
|
+
partNumbers: [partNumber],
|
|
1609
|
+
},
|
|
1610
|
+
})
|
|
1611
|
+
const signedPart = Array.isArray(response?.parts)
|
|
1612
|
+
? response.parts.find(part => Number(part?.partNumber) === partNumber)
|
|
1613
|
+
: null
|
|
1614
|
+
|
|
1615
|
+
if (!signedPart?.url) {
|
|
1616
|
+
throw new Error(`第 ${partNumber} 个分片上传地址生成失败`)
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
return signedPart
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
async function putSignedPartBuffer(signedUrl, buffer, signal) {
|
|
1623
|
+
const response = await fetch(signedUrl, {
|
|
1624
|
+
method: 'PUT',
|
|
1625
|
+
signal,
|
|
1626
|
+
body: buffer,
|
|
1627
|
+
})
|
|
1628
|
+
|
|
1629
|
+
if (!response.ok) {
|
|
1630
|
+
const message = response.status === 403
|
|
1631
|
+
? '分片上传签名已过期'
|
|
1632
|
+
: `分片上传失败:${response.status} ${response.statusText}`
|
|
1633
|
+
throw new HttpCliError(message, response.status)
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
const etag = normalizeEtag(response.headers.get('ETag') || response.headers.get('etag'))
|
|
1637
|
+
|
|
1638
|
+
if (!etag) {
|
|
1639
|
+
throw new Error('OSS 未返回分片 ETag,请检查服务端签名与跨域响应头配置')
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
return etag
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
async function uploadSinglePart(input) {
|
|
1646
|
+
const {
|
|
1647
|
+
baseUrl,
|
|
1648
|
+
apiKey,
|
|
1649
|
+
uploadId,
|
|
1650
|
+
fileInput,
|
|
1651
|
+
partNumber,
|
|
1652
|
+
partSize,
|
|
1653
|
+
totalParts,
|
|
1654
|
+
signal,
|
|
1655
|
+
} = input
|
|
1656
|
+
const start = (partNumber - 1) * partSize
|
|
1657
|
+
const length = Math.min(fileInput.sizeBytes - start, partSize)
|
|
1658
|
+
const buffer = await readFileChunk(fileInput.path, start, length, signal)
|
|
1659
|
+
let signedPart = await signUploadPart(baseUrl, apiKey, uploadId, partNumber, signal)
|
|
1660
|
+
|
|
1661
|
+
for (let attempt = 1; attempt <= 4; attempt += 1) {
|
|
1662
|
+
assertNotAborted(signal)
|
|
1663
|
+
|
|
1664
|
+
try {
|
|
1665
|
+
const etag = await putSignedPartBuffer(signedPart.url, buffer, signal)
|
|
1666
|
+
|
|
1667
|
+
return {
|
|
1668
|
+
partNumber,
|
|
1669
|
+
etag,
|
|
1670
|
+
bytes: length,
|
|
1671
|
+
}
|
|
1672
|
+
} catch (error) {
|
|
1673
|
+
if (signal?.aborted || isAbortError(error)) {
|
|
1674
|
+
throw error
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
if (error instanceof HttpCliError && error.status === 403 && attempt < 4) {
|
|
1678
|
+
signedPart = await signUploadPart(baseUrl, apiKey, uploadId, partNumber, signal)
|
|
1679
|
+
await sleep(getRetryDelayMs(attempt))
|
|
1680
|
+
continue
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
if (isRetryableRequestError(error) && attempt < 4) {
|
|
1684
|
+
await sleep(getRetryDelayMs(attempt))
|
|
1685
|
+
continue
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
1689
|
+
throw new Error(`第 ${partNumber}/${totalParts} 个分片上传失败:${message}`)
|
|
1690
|
+
}
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
throw new Error(`第 ${partNumber}/${totalParts} 个分片上传失败`)
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
async function uploadFileParts(input) {
|
|
1697
|
+
const totalParts = Math.ceil(input.fileInput.sizeBytes / input.partSize)
|
|
1698
|
+
const workerCount = Math.min(MAX_UPLOAD_CONCURRENCY, totalParts)
|
|
1699
|
+
const completedParts = []
|
|
1700
|
+
let nextPartNumber = 1
|
|
1701
|
+
let completedPartCount = 0
|
|
1702
|
+
let uploadedBytes = 0
|
|
1703
|
+
let firstError = null
|
|
1704
|
+
|
|
1705
|
+
input.reporter.reportUploadProgress({
|
|
1706
|
+
uploadedBytes,
|
|
1707
|
+
totalBytes: input.fileInput.sizeBytes,
|
|
1708
|
+
completedParts: completedPartCount,
|
|
1709
|
+
totalParts,
|
|
1710
|
+
})
|
|
1711
|
+
|
|
1712
|
+
async function uploadNextPart() {
|
|
1713
|
+
while (nextPartNumber <= totalParts) {
|
|
1714
|
+
assertNotAborted(input.signal)
|
|
1715
|
+
|
|
1716
|
+
if (firstError) {
|
|
1717
|
+
throw firstError
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1720
|
+
const partNumber = nextPartNumber
|
|
1721
|
+
nextPartNumber += 1
|
|
1722
|
+
|
|
1723
|
+
try {
|
|
1724
|
+
const completed = await uploadSinglePart({
|
|
1725
|
+
...input,
|
|
1726
|
+
partNumber,
|
|
1727
|
+
totalParts,
|
|
1728
|
+
})
|
|
1729
|
+
|
|
1730
|
+
completedParts.push({
|
|
1731
|
+
partNumber: completed.partNumber,
|
|
1732
|
+
etag: completed.etag,
|
|
1733
|
+
})
|
|
1734
|
+
uploadedBytes += completed.bytes
|
|
1735
|
+
completedPartCount += 1
|
|
1736
|
+
input.reporter.reportUploadProgress({
|
|
1737
|
+
uploadedBytes,
|
|
1738
|
+
totalBytes: input.fileInput.sizeBytes,
|
|
1739
|
+
completedParts: completedPartCount,
|
|
1740
|
+
totalParts,
|
|
1741
|
+
})
|
|
1742
|
+
} catch (error) {
|
|
1743
|
+
firstError = error
|
|
1744
|
+
throw error
|
|
1745
|
+
}
|
|
1746
|
+
}
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1749
|
+
await Promise.all(Array.from({ length: workerCount }, () => uploadNextPart()))
|
|
1750
|
+
|
|
1751
|
+
return completedParts.sort((a, b) => a.partNumber - b.partNumber)
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1754
|
+
async function uploadLocalFile(fileInput, args, baseUrl, apiKey, reporter, runContext) {
|
|
1755
|
+
let uploadId = ''
|
|
1756
|
+
|
|
1757
|
+
try {
|
|
1758
|
+
const fileHash = await calculateFileSha256(fileInput, reporter, runContext.signal)
|
|
1759
|
+
const startResponse = await requestJson(baseUrl, apiKey, '/api/cli/uploads/start', {
|
|
1760
|
+
method: 'POST',
|
|
1761
|
+
signal: runContext.signal,
|
|
1762
|
+
retry: true,
|
|
1763
|
+
body: {
|
|
1764
|
+
filename: fileInput.filename,
|
|
1765
|
+
mimeType: fileInput.mimeType,
|
|
1766
|
+
sizeBytes: fileInput.sizeBytes,
|
|
1767
|
+
fileHash,
|
|
1768
|
+
},
|
|
1769
|
+
})
|
|
1770
|
+
|
|
1771
|
+
uploadId = String(startResponse?.uploadId || '')
|
|
1772
|
+
runContext.state.uploadId = uploadId
|
|
1773
|
+
|
|
1774
|
+
const partSize = Number(startResponse?.partSize)
|
|
1775
|
+
|
|
1776
|
+
if (!uploadId || !Number.isFinite(partSize) || partSize <= 0) {
|
|
1777
|
+
throw new Error('上传初始化失败,请稍后重试')
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
const totalParts = Math.ceil(fileInput.sizeBytes / partSize)
|
|
1781
|
+
|
|
1782
|
+
reporter.reportUploadStarted({
|
|
1783
|
+
uploadId,
|
|
1784
|
+
filename: fileInput.filename,
|
|
1785
|
+
mimeType: fileInput.mimeType,
|
|
1786
|
+
totalBytes: fileInput.sizeBytes,
|
|
1787
|
+
partSize,
|
|
1788
|
+
totalParts,
|
|
1789
|
+
})
|
|
1790
|
+
|
|
1791
|
+
const parts = await uploadFileParts({
|
|
1792
|
+
baseUrl,
|
|
1793
|
+
apiKey,
|
|
1794
|
+
uploadId,
|
|
1795
|
+
fileInput,
|
|
1796
|
+
partSize,
|
|
1797
|
+
signal: runContext.signal,
|
|
1798
|
+
reporter,
|
|
1799
|
+
})
|
|
1800
|
+
|
|
1801
|
+
await requestJson(baseUrl, apiKey, '/api/cli/uploads/complete', {
|
|
1802
|
+
method: 'POST',
|
|
1803
|
+
signal: runContext.signal,
|
|
1804
|
+
retry: true,
|
|
1805
|
+
body: {
|
|
1806
|
+
uploadId,
|
|
1807
|
+
parts,
|
|
1808
|
+
},
|
|
1809
|
+
})
|
|
1810
|
+
|
|
1811
|
+
reporter.reportUploadCompleted({
|
|
1812
|
+
uploadId,
|
|
1813
|
+
filename: fileInput.filename,
|
|
1814
|
+
totalBytes: fileInput.sizeBytes,
|
|
1815
|
+
})
|
|
1816
|
+
runContext.state.uploadAbortAllowed = false
|
|
1817
|
+
reporter.reportUploadChecking({ uploadId })
|
|
1818
|
+
|
|
1819
|
+
const checkResult = await requestJson(baseUrl, apiKey, '/api/cli/uploads/check', {
|
|
1820
|
+
method: 'POST',
|
|
1821
|
+
signal: runContext.signal,
|
|
1822
|
+
retry: true,
|
|
1823
|
+
body: {
|
|
1824
|
+
uploadId,
|
|
1825
|
+
language: args.language || DEFAULT_LANGUAGE,
|
|
1826
|
+
},
|
|
1827
|
+
})
|
|
1828
|
+
const checkedTask = getUploadTaskFromCheckResult(checkResult)
|
|
1829
|
+
|
|
1830
|
+
if (checkedTask) {
|
|
1831
|
+
runContext.state.taskId = checkedTask.taskId || ''
|
|
1832
|
+
runContext.state.taskCreated = true
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1835
|
+
reporter.reportUploadReady(getUploadReadySummary(checkResult, fileInput, uploadId))
|
|
1836
|
+
|
|
1837
|
+
return {
|
|
1838
|
+
...checkResult,
|
|
1839
|
+
uploadId,
|
|
1840
|
+
}
|
|
1841
|
+
} catch (error) {
|
|
1842
|
+
if (runContext.state.interrupting || isAbortError(error)) {
|
|
1843
|
+
throw new ReportedCliError('已中断', 130)
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1846
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
1847
|
+
reporter.reportUploadFailed(`上传失败:${message}`, { uploadId })
|
|
1848
|
+
|
|
1849
|
+
if (uploadId && runContext.state.uploadAbortAllowed) {
|
|
1850
|
+
await abortUploadOnServer(baseUrl, apiKey, uploadId).catch(() => null)
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1853
|
+
throw error
|
|
1854
|
+
}
|
|
1855
|
+
}
|
|
1856
|
+
|
|
861
1857
|
async function handleAuthLogin(args) {
|
|
862
1858
|
if (!args.apiKey) {
|
|
863
1859
|
printAuthUsage()
|
|
@@ -930,9 +1926,113 @@ async function handleAuthCommand(argv) {
|
|
|
930
1926
|
fail(`不支持的 auth 子命令:${args.action}`)
|
|
931
1927
|
}
|
|
932
1928
|
|
|
1929
|
+
function buildUrlTaskCreatePayload(args, input) {
|
|
1930
|
+
const createPayload = {
|
|
1931
|
+
url: input.url,
|
|
1932
|
+
language: args.language || DEFAULT_LANGUAGE,
|
|
1933
|
+
}
|
|
1934
|
+
|
|
1935
|
+
if (args.source) {
|
|
1936
|
+
createPayload.source = args.source
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1939
|
+
return createPayload
|
|
1940
|
+
}
|
|
1941
|
+
|
|
1942
|
+
async function createCliTask(baseUrl, apiKey, createPayload, reporter, runContext) {
|
|
1943
|
+
const created = await requestJson(baseUrl, apiKey, '/api/cli/transcriptions', {
|
|
1944
|
+
method: 'POST',
|
|
1945
|
+
signal: runContext.signal,
|
|
1946
|
+
body: createPayload,
|
|
1947
|
+
})
|
|
1948
|
+
|
|
1949
|
+
runContext.state.taskId = created.taskId || ''
|
|
1950
|
+
runContext.state.taskCreated = true
|
|
1951
|
+
reporter.reportTaskCreated(created)
|
|
1952
|
+
|
|
1953
|
+
return created
|
|
1954
|
+
}
|
|
1955
|
+
|
|
1956
|
+
async function waitForTaskAndOutput(baseUrl, apiKey, task, args, format, reporter, runContext) {
|
|
1957
|
+
let currentTask = task
|
|
1958
|
+
let lastStatus = currentTask.status
|
|
1959
|
+
|
|
1960
|
+
if (currentTask.mode === 'transcription' && !TERMINAL_STATUSES.has(currentTask.status)) {
|
|
1961
|
+
try {
|
|
1962
|
+
currentTask = await consumeTaskEvents(
|
|
1963
|
+
baseUrl,
|
|
1964
|
+
apiKey,
|
|
1965
|
+
currentTask.taskId,
|
|
1966
|
+
currentTask,
|
|
1967
|
+
reporter,
|
|
1968
|
+
runContext.signal,
|
|
1969
|
+
)
|
|
1970
|
+
lastStatus = currentTask.status
|
|
1971
|
+
} catch (error) {
|
|
1972
|
+
if (runContext.state.interrupting || isAbortError(error)) {
|
|
1973
|
+
throw new ReportedCliError('已中断', 130)
|
|
1974
|
+
}
|
|
1975
|
+
|
|
1976
|
+
reporter.reportNotice(`事件流中断,回退到轮询状态:${error instanceof Error ? error.message : String(error)}`)
|
|
1977
|
+
}
|
|
1978
|
+
}
|
|
1979
|
+
|
|
1980
|
+
while (!TERMINAL_STATUSES.has(currentTask.status)) {
|
|
1981
|
+
await sleep(POLL_INTERVAL_MS)
|
|
1982
|
+
|
|
1983
|
+
if (runContext.state.interrupting) {
|
|
1984
|
+
throw new ReportedCliError('已中断', 130)
|
|
1985
|
+
}
|
|
1986
|
+
|
|
1987
|
+
currentTask = await requestJson(
|
|
1988
|
+
baseUrl,
|
|
1989
|
+
apiKey,
|
|
1990
|
+
`/api/cli/transcriptions/${encodeURIComponent(currentTask.taskId)}`,
|
|
1991
|
+
{
|
|
1992
|
+
signal: runContext.signal,
|
|
1993
|
+
retry: true,
|
|
1994
|
+
},
|
|
1995
|
+
)
|
|
1996
|
+
|
|
1997
|
+
if (currentTask.status !== lastStatus) {
|
|
1998
|
+
if (TERMINAL_STATUSES.has(currentTask.status)) {
|
|
1999
|
+
reporter.reportTerminal(currentTask.status, currentTask.error || null)
|
|
2000
|
+
} else {
|
|
2001
|
+
reporter.reportStatusChanged(currentTask.status)
|
|
2002
|
+
}
|
|
2003
|
+
lastStatus = currentTask.status
|
|
2004
|
+
}
|
|
2005
|
+
}
|
|
2006
|
+
|
|
2007
|
+
reporter.reportTerminal(currentTask.status, currentTask.error || null)
|
|
2008
|
+
|
|
2009
|
+
if (currentTask.status !== 'completed') {
|
|
2010
|
+
throw new ReportedCliError(currentTask.error || `任务未成功完成:${formatStatus(currentTask.status)}`)
|
|
2011
|
+
}
|
|
2012
|
+
|
|
2013
|
+
const result = await requestResult(baseUrl, apiKey, currentTask.taskId, format, {
|
|
2014
|
+
signal: runContext.signal,
|
|
2015
|
+
})
|
|
2016
|
+
|
|
2017
|
+
if (args.output) {
|
|
2018
|
+
const outputPath = await writeOutput(args.output, result)
|
|
2019
|
+
reporter.reportResultWritten(outputPath, format)
|
|
2020
|
+
return
|
|
2021
|
+
}
|
|
2022
|
+
|
|
2023
|
+
reporter.close()
|
|
2024
|
+
process.stdout.write(result)
|
|
2025
|
+
|
|
2026
|
+
if (!result.endsWith('\n')) {
|
|
2027
|
+
process.stdout.write('\n')
|
|
2028
|
+
}
|
|
2029
|
+
}
|
|
2030
|
+
|
|
933
2031
|
async function handleTranscription(argv) {
|
|
934
2032
|
const args = parseTranscriptionArgs(argv)
|
|
935
2033
|
const reporter = createProgressReporter(args.progressFormat)
|
|
2034
|
+
let runContext = null
|
|
2035
|
+
|
|
936
2036
|
try {
|
|
937
2037
|
const storedConfig = await readStoredConfig()
|
|
938
2038
|
const resolvedApiKey = resolveApiKey(args.apiKey, storedConfig)
|
|
@@ -942,75 +2042,62 @@ async function handleTranscription(argv) {
|
|
|
942
2042
|
fail('缺少 API key。请传 --api-key、设置 DEYO_API_KEY,或先执行 deyo auth login')
|
|
943
2043
|
}
|
|
944
2044
|
|
|
945
|
-
if (!args.url) {
|
|
2045
|
+
if (!args.url && !args.file) {
|
|
946
2046
|
printUsage()
|
|
947
|
-
fail('
|
|
2047
|
+
fail('缺少内容链接或本地文件')
|
|
948
2048
|
}
|
|
949
2049
|
|
|
950
2050
|
const { baseUrl } = resolveBaseUrl(args.baseUrl, storedConfig)
|
|
951
|
-
const
|
|
952
|
-
const createPayload = {
|
|
953
|
-
url: args.url,
|
|
954
|
-
language: args.language || DEFAULT_LANGUAGE,
|
|
955
|
-
}
|
|
2051
|
+
const input = await resolveTranscriptionInput(args)
|
|
956
2052
|
|
|
957
|
-
if (
|
|
958
|
-
|
|
2053
|
+
if (!input) {
|
|
2054
|
+
printUsage()
|
|
2055
|
+
fail('缺少内容链接或本地文件')
|
|
959
2056
|
}
|
|
960
2057
|
|
|
961
|
-
const
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
reporter.reportTaskCreated(created)
|
|
966
|
-
|
|
967
|
-
let task = created
|
|
968
|
-
let lastStatus = created.status
|
|
2058
|
+
const format = inferFormat(args.output, args.format)
|
|
2059
|
+
runContext = createRunContext(baseUrl, resolvedApiKey.apiKey, reporter)
|
|
2060
|
+
let createPayload
|
|
2061
|
+
let created
|
|
969
2062
|
|
|
970
|
-
if (
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
lastStatus = task.status
|
|
974
|
-
} catch (error) {
|
|
975
|
-
reporter.reportNotice(`事件流中断,回退到轮询状态:${error instanceof Error ? error.message : String(error)}`)
|
|
2063
|
+
if (input.kind === 'file') {
|
|
2064
|
+
if (args.source && args.source !== 'upload') {
|
|
2065
|
+
fail('本地文件转写不支持 --source;上传来源会固定为 upload')
|
|
976
2066
|
}
|
|
977
|
-
}
|
|
978
2067
|
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
2068
|
+
const checkResult = await uploadLocalFile(
|
|
2069
|
+
input,
|
|
2070
|
+
args,
|
|
2071
|
+
baseUrl,
|
|
2072
|
+
resolvedApiKey.apiKey,
|
|
2073
|
+
reporter,
|
|
2074
|
+
runContext,
|
|
2075
|
+
)
|
|
2076
|
+
const checkedTask = getUploadTaskFromCheckResult(checkResult)
|
|
2077
|
+
|
|
2078
|
+
if (checkedTask) {
|
|
2079
|
+
runContext.state.taskId = checkedTask.taskId || ''
|
|
2080
|
+
runContext.state.taskCreated = true
|
|
2081
|
+
reporter.reportTaskCreated(checkedTask)
|
|
2082
|
+
created = checkedTask
|
|
2083
|
+
} else {
|
|
2084
|
+
throw new Error('媒体检查通过但服务端没有返回上传转写任务')
|
|
990
2085
|
}
|
|
2086
|
+
} else {
|
|
2087
|
+
createPayload = buildUrlTaskCreatePayload(args, input)
|
|
991
2088
|
}
|
|
992
2089
|
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
if (task.status !== 'completed') {
|
|
996
|
-
throw new ReportedCliError(task.error || `任务未成功完成:${formatStatus(task.status)}`)
|
|
2090
|
+
if (!created) {
|
|
2091
|
+
created = await createCliTask(baseUrl, resolvedApiKey.apiKey, createPayload, reporter, runContext)
|
|
997
2092
|
}
|
|
998
2093
|
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
if (
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
return
|
|
2094
|
+
await waitForTaskAndOutput(baseUrl, resolvedApiKey.apiKey, created, args, format, reporter, runContext)
|
|
2095
|
+
} catch (error) {
|
|
2096
|
+
if (runContext?.state.interrupting || isAbortError(error)) {
|
|
2097
|
+
await runContext?.state.interruptPromise?.catch(() => {})
|
|
2098
|
+
throw new ReportedCliError('已中断', 130)
|
|
1005
2099
|
}
|
|
1006
2100
|
|
|
1007
|
-
reporter.close()
|
|
1008
|
-
process.stdout.write(result)
|
|
1009
|
-
|
|
1010
|
-
if (!result.endsWith('\n')) {
|
|
1011
|
-
process.stdout.write('\n')
|
|
1012
|
-
}
|
|
1013
|
-
} catch (error) {
|
|
1014
2101
|
if (error instanceof ReportedCliError) {
|
|
1015
2102
|
throw error
|
|
1016
2103
|
}
|
|
@@ -1021,6 +2108,8 @@ async function handleTranscription(argv) {
|
|
|
1021
2108
|
}
|
|
1022
2109
|
|
|
1023
2110
|
throw error
|
|
2111
|
+
} finally {
|
|
2112
|
+
runContext?.dispose()
|
|
1024
2113
|
}
|
|
1025
2114
|
}
|
|
1026
2115
|
|
|
@@ -1048,7 +2137,7 @@ async function main() {
|
|
|
1048
2137
|
|
|
1049
2138
|
main().catch((error) => {
|
|
1050
2139
|
if (error instanceof ReportedCliError) {
|
|
1051
|
-
process.exit(1)
|
|
2140
|
+
process.exit(error.exitCode || 1)
|
|
1052
2141
|
}
|
|
1053
2142
|
|
|
1054
2143
|
const message = error instanceof Error ? error.message : String(error)
|