@casatwy/deyo 0.1.7 → 0.2.0
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 +188 -10
- package/deyo.mjs +1648 -154
- package/package.json +7 -4
- package/skill-manager.mjs +1344 -0
package/deyo.mjs
CHANGED
|
@@ -1,21 +1,69 @@
|
|
|
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'
|
|
9
|
+
import { handleSkillCommand } from './skill-manager.mjs'
|
|
8
10
|
|
|
9
11
|
const CLI_VERSION = createRequire(import.meta.url)('./package.json').version
|
|
10
12
|
|
|
11
13
|
const DEFAULT_BASE_URL = 'https://deyo.miaobi.fun'
|
|
12
14
|
const DEFAULT_LANGUAGE = 'zh'
|
|
13
15
|
const POLL_INTERVAL_MS = 1500
|
|
16
|
+
const SSE_RECONNECT_DELAYS_MS = [1000, 2000, 4000]
|
|
17
|
+
const SSE_RECOVERY_INTERVAL_MS = 15000
|
|
14
18
|
const PROGRESS_FORMATS = new Set(['auto', 'text', 'jsonl'])
|
|
15
19
|
const RESULT_FORMATS = new Set(['text', 'srt', 'vtt', 'json', 'verbose_json'])
|
|
16
20
|
const TERMINAL_STATUSES = new Set(['completed', 'failed', 'cancelled'])
|
|
21
|
+
const MAX_UPLOAD_CONCURRENCY = 3
|
|
22
|
+
const HASH_CHUNK_SIZE = 4 * 1024 * 1024
|
|
23
|
+
const UPLOAD_REDACTED_SOURCE_LABEL = 'upload:file'
|
|
24
|
+
const RETRYABLE_STATUS_CODES = new Set([429, 500, 502, 503, 504])
|
|
25
|
+
const URL_SCHEME_PATTERN = /^[a-z][a-z\d+\-.]*:\/\//i
|
|
26
|
+
const DOMAIN_LIKE_PATTERN = /^(?:[a-z0-9-]+\.)+[a-z]{2,}(?::\d+)?(?:[/?#].*)?$/i
|
|
27
|
+
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
|
|
28
|
+
const SIGNED_URL_PARAM_PATTERN = /(?:OSSAccessKeyId|Signature|Expires|x-oss-signature|x-oss-credential|x-oss-expires|x-amz-signature)=/i
|
|
29
|
+
const MIME_TYPES_BY_EXTENSION = new Map([
|
|
30
|
+
['.aac', 'audio/aac'],
|
|
31
|
+
['.aif', 'audio/aiff'],
|
|
32
|
+
['.aiff', 'audio/aiff'],
|
|
33
|
+
['.amr', 'audio/amr'],
|
|
34
|
+
['.flac', 'audio/flac'],
|
|
35
|
+
['.m4a', 'audio/mp4'],
|
|
36
|
+
['.mp3', 'audio/mpeg'],
|
|
37
|
+
['.oga', 'audio/ogg'],
|
|
38
|
+
['.ogg', 'audio/ogg'],
|
|
39
|
+
['.opus', 'audio/opus'],
|
|
40
|
+
['.wav', 'audio/wav'],
|
|
41
|
+
['.weba', 'audio/webm'],
|
|
42
|
+
['.wma', 'audio/x-ms-wma'],
|
|
43
|
+
['.3gp', 'video/3gpp'],
|
|
44
|
+
['.avi', 'video/x-msvideo'],
|
|
45
|
+
['.m4v', 'video/x-m4v'],
|
|
46
|
+
['.mkv', 'video/x-matroska'],
|
|
47
|
+
['.mov', 'video/quicktime'],
|
|
48
|
+
['.mp4', 'video/mp4'],
|
|
49
|
+
['.mpeg', 'video/mpeg'],
|
|
50
|
+
['.mpg', 'video/mpeg'],
|
|
51
|
+
['.webm', 'video/webm'],
|
|
52
|
+
])
|
|
53
|
+
|
|
54
|
+
class ReportedCliError extends Error {
|
|
55
|
+
constructor(message = '', exitCode = 1) {
|
|
56
|
+
super(message)
|
|
57
|
+
this.exitCode = exitCode
|
|
58
|
+
}
|
|
59
|
+
}
|
|
17
60
|
|
|
18
|
-
class
|
|
61
|
+
class HttpCliError extends Error {
|
|
62
|
+
constructor(message, status) {
|
|
63
|
+
super(message)
|
|
64
|
+
this.status = status
|
|
65
|
+
}
|
|
66
|
+
}
|
|
19
67
|
|
|
20
68
|
function safeJsonParse(value) {
|
|
21
69
|
try {
|
|
@@ -32,16 +80,21 @@ function fail(message, exitCode = 1) {
|
|
|
32
80
|
|
|
33
81
|
function printUsage() {
|
|
34
82
|
process.stderr.write(`用法:
|
|
35
|
-
deyo [选项] <url>
|
|
83
|
+
deyo [选项] <url|file>
|
|
84
|
+
deyo [选项] --file <file>
|
|
36
85
|
deyo auth <login|logout|status> [选项]
|
|
86
|
+
deyo skill <install|status> [选项]
|
|
37
87
|
|
|
38
88
|
转写选项:
|
|
39
89
|
--api-key <key> 可选,Deyo API key
|
|
40
90
|
-O, --output <path> 可选,输出文件路径
|
|
41
91
|
--source <name> 可选,显式指定来源:xiaoyuzhou | ximalaya | bilibili | douyin | xiaohongshu | youtube | apple-podcasts | twitter
|
|
92
|
+
--file <path> 可选,显式指定本地单个音视频文件
|
|
93
|
+
--mime-type <type> 可选,上传本地文件时显式指定 MIME,例如 audio/mpeg 或 video/mp4
|
|
42
94
|
--language <value> 可选,转写语言,默认 ${DEFAULT_LANGUAGE}
|
|
43
95
|
--format <value> 可选,text | srt | vtt | json | verbose_json
|
|
44
96
|
--progress-format <v> 可选,auto | text | jsonl,默认 auto
|
|
97
|
+
--stream-transcript 可选,通过 stderr JSONL 输出稳定正文;仅支持 text 且必须搭配 --progress-format jsonl
|
|
45
98
|
--base-url <url> 可选,默认 ${DEFAULT_BASE_URL}
|
|
46
99
|
-h, --help 显示帮助
|
|
47
100
|
-v, --version 显示 CLI 版本
|
|
@@ -51,10 +104,18 @@ auth 子命令:
|
|
|
51
104
|
deyo auth logout
|
|
52
105
|
deyo auth status
|
|
53
106
|
|
|
107
|
+
Skill 管理:
|
|
108
|
+
deyo skill install --platform codex|claude|gemini|openclaw [--scope user|workspace|global]
|
|
109
|
+
deyo skill status [--platform codex|claude|gemini|openclaw] [--json]
|
|
110
|
+
|
|
54
111
|
API key 读取优先级:
|
|
55
112
|
1. --api-key
|
|
56
113
|
2. 环境变量 DEYO_API_KEY
|
|
57
114
|
3. 本地配置文件(先执行 deyo auth login)
|
|
115
|
+
|
|
116
|
+
本地文件说明:
|
|
117
|
+
v1 只支持单文件上传;不支持目录、glob、stdin、批量和断点续传。
|
|
118
|
+
可以用 deyo -- ./audio.mp3 明确把 -- 后的单个参数当作路径或链接。
|
|
58
119
|
`)
|
|
59
120
|
}
|
|
60
121
|
|
|
@@ -76,11 +137,15 @@ function parseTranscriptionArgs(argv) {
|
|
|
76
137
|
apiKey: '',
|
|
77
138
|
output: '',
|
|
78
139
|
source: '',
|
|
140
|
+
file: '',
|
|
141
|
+
mimeType: '',
|
|
79
142
|
language: '',
|
|
80
143
|
format: '',
|
|
81
144
|
progressFormat: 'auto',
|
|
145
|
+
streamTranscript: false,
|
|
82
146
|
baseUrl: '',
|
|
83
147
|
url: '',
|
|
148
|
+
positional: [],
|
|
84
149
|
}
|
|
85
150
|
|
|
86
151
|
for (let index = 0; index < argv.length; index += 1) {
|
|
@@ -91,6 +156,17 @@ function parseTranscriptionArgs(argv) {
|
|
|
91
156
|
process.exit(0)
|
|
92
157
|
}
|
|
93
158
|
|
|
159
|
+
if (current === '--') {
|
|
160
|
+
const rest = argv.slice(index + 1)
|
|
161
|
+
|
|
162
|
+
if (rest.length !== 1) {
|
|
163
|
+
fail('-- 后只能提供一个本地文件路径或内容链接')
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
args.positional.push(rest[0])
|
|
167
|
+
break
|
|
168
|
+
}
|
|
169
|
+
|
|
94
170
|
if (current === '-O' || current === '--output') {
|
|
95
171
|
args.output = argv[index + 1] || ''
|
|
96
172
|
index += 1
|
|
@@ -109,6 +185,18 @@ function parseTranscriptionArgs(argv) {
|
|
|
109
185
|
continue
|
|
110
186
|
}
|
|
111
187
|
|
|
188
|
+
if (current === '--file') {
|
|
189
|
+
args.file = argv[index + 1] || ''
|
|
190
|
+
index += 1
|
|
191
|
+
continue
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (current === '--mime-type') {
|
|
195
|
+
args.mimeType = argv[index + 1] || ''
|
|
196
|
+
index += 1
|
|
197
|
+
continue
|
|
198
|
+
}
|
|
199
|
+
|
|
112
200
|
if (current === '--language') {
|
|
113
201
|
args.language = argv[index + 1] || ''
|
|
114
202
|
index += 1
|
|
@@ -127,6 +215,11 @@ function parseTranscriptionArgs(argv) {
|
|
|
127
215
|
continue
|
|
128
216
|
}
|
|
129
217
|
|
|
218
|
+
if (current === '--stream-transcript') {
|
|
219
|
+
args.streamTranscript = true
|
|
220
|
+
continue
|
|
221
|
+
}
|
|
222
|
+
|
|
130
223
|
if (current === '--base-url') {
|
|
131
224
|
args.baseUrl = argv[index + 1] || ''
|
|
132
225
|
index += 1
|
|
@@ -137,13 +230,18 @@ function parseTranscriptionArgs(argv) {
|
|
|
137
230
|
fail(`未知参数:${current}`)
|
|
138
231
|
}
|
|
139
232
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
233
|
+
args.positional.push(current)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (args.positional.length > 1) {
|
|
237
|
+
fail('v1 只支持提交一个链接或一个本地文件,不支持批量转写')
|
|
238
|
+
}
|
|
143
239
|
|
|
144
|
-
|
|
240
|
+
if (args.file && args.positional.length) {
|
|
241
|
+
fail('--file 不能和额外的路径或 URL 同时使用')
|
|
145
242
|
}
|
|
146
243
|
|
|
244
|
+
args.url = args.positional[0] || ''
|
|
147
245
|
return args
|
|
148
246
|
}
|
|
149
247
|
|
|
@@ -216,6 +314,309 @@ function inferFormat(outputPath, explicitFormat) {
|
|
|
216
314
|
return 'text'
|
|
217
315
|
}
|
|
218
316
|
|
|
317
|
+
function validateTranscriptStreaming(args, format) {
|
|
318
|
+
if (!args.streamTranscript) {
|
|
319
|
+
return
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (format !== 'text') {
|
|
323
|
+
fail('--stream-transcript 仅支持 text 结果格式')
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (args.progressFormat !== 'jsonl') {
|
|
327
|
+
fail('--stream-transcript 必须搭配 --progress-format jsonl')
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function isAbortError(error) {
|
|
332
|
+
return error?.name === 'AbortError' || error?.code === 'ABORT_ERR'
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function createAbortError() {
|
|
336
|
+
const error = new Error('操作已中断')
|
|
337
|
+
error.name = 'AbortError'
|
|
338
|
+
return error
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function isRetryableRequestError(error) {
|
|
342
|
+
if (isAbortError(error)) {
|
|
343
|
+
return false
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (error instanceof HttpCliError) {
|
|
347
|
+
return RETRYABLE_STATUS_CODES.has(error.status) || error.status >= 500
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
return true
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function getRetryDelayMs(attempt) {
|
|
354
|
+
return Math.min(4000, 350 * (2 ** Math.max(0, attempt - 1)))
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
async function withRetry(operation, options = {}) {
|
|
358
|
+
const maxAttempts = options.maxAttempts || 4
|
|
359
|
+
let lastError = null
|
|
360
|
+
|
|
361
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
362
|
+
try {
|
|
363
|
+
return await operation(attempt)
|
|
364
|
+
} catch (error) {
|
|
365
|
+
lastError = error
|
|
366
|
+
|
|
367
|
+
if (options.signal?.aborted || isAbortError(error)) {
|
|
368
|
+
throw error
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const shouldRetry = options.shouldRetry
|
|
372
|
+
? options.shouldRetry(error)
|
|
373
|
+
: isRetryableRequestError(error)
|
|
374
|
+
|
|
375
|
+
if (!shouldRetry || attempt >= maxAttempts) {
|
|
376
|
+
throw error
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
await sleep(getRetryDelayMs(attempt))
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
throw lastError || new Error('请求失败')
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function isLikelyUrlInput(value) {
|
|
387
|
+
const normalized = String(value || '').trim()
|
|
388
|
+
|
|
389
|
+
return URL_SCHEME_PATTERN.test(normalized) || DOMAIN_LIKE_PATTERN.test(normalized)
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function isStdinInput(value) {
|
|
393
|
+
return String(value || '').trim() === '-'
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function hasGlobPattern(value) {
|
|
397
|
+
const normalized = String(value || '')
|
|
398
|
+
return /[*[\]{}]/.test(normalized) || (!isLikelyUrlInput(normalized) && normalized.includes('?'))
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function expandHomePath(value) {
|
|
402
|
+
const normalized = String(value || '').trim()
|
|
403
|
+
|
|
404
|
+
if (normalized === '~') {
|
|
405
|
+
return homedir() || normalized
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
if (normalized.startsWith('~/')) {
|
|
409
|
+
const home = homedir()
|
|
410
|
+
return home ? resolve(home, normalized.slice(2)) : normalized
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
return normalized
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function resolveLocalPath(value) {
|
|
417
|
+
const expanded = expandHomePath(value)
|
|
418
|
+
return isAbsolute(expanded) ? expanded : resolve(process.cwd(), expanded)
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function isAllowedLocalMimeType(value) {
|
|
422
|
+
const normalized = String(value || '').trim().toLowerCase()
|
|
423
|
+
return normalized.startsWith('audio/') || normalized.startsWith('video/')
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function normalizeExplicitMimeType(value) {
|
|
427
|
+
const normalized = String(value || '').trim().toLowerCase()
|
|
428
|
+
|
|
429
|
+
if (!normalized) {
|
|
430
|
+
return ''
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
if (!/^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*(?:\s*;.*)?$/i.test(normalized)) {
|
|
434
|
+
fail(`--mime-type 不是合法 MIME:${value}`)
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
if (!isAllowedLocalMimeType(normalized)) {
|
|
438
|
+
fail('--mime-type 只支持 audio/* 或 video/*')
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
return normalized.split(';')[0].trim()
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function inferMimeTypeFromPath(filePath) {
|
|
445
|
+
return MIME_TYPES_BY_EXTENSION.get(extname(filePath).toLowerCase()) || ''
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function normalizeEtag(value) {
|
|
449
|
+
return String(value || '').trim().replace(/^"+|"+$/g, '')
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function formatBytes(value) {
|
|
453
|
+
const bytes = Number(value)
|
|
454
|
+
|
|
455
|
+
if (!Number.isFinite(bytes) || bytes <= 0) {
|
|
456
|
+
return '0 B'
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
|
460
|
+
let size = bytes
|
|
461
|
+
let unitIndex = 0
|
|
462
|
+
|
|
463
|
+
while (size >= 1024 && unitIndex < units.length - 1) {
|
|
464
|
+
size /= 1024
|
|
465
|
+
unitIndex += 1
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const precision = size >= 10 || unitIndex === 0 ? 0 : 1
|
|
469
|
+
return `${size.toFixed(precision)} ${units[unitIndex]}`
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function redactUploadSourceUrl(sourceType, sourceUrl) {
|
|
473
|
+
if (sourceType === 'upload' || String(sourceUrl || '').startsWith('upload:sha256:')) {
|
|
474
|
+
return UPLOAD_REDACTED_SOURCE_LABEL
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
return sourceUrl || null
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function isSensitiveUploadResultString(key, value, sourceType = null) {
|
|
481
|
+
if (value.startsWith('upload:sha256:')) {
|
|
482
|
+
return true
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
if (SIGNED_URL_PARAM_PATTERN.test(value)) {
|
|
486
|
+
return true
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
return sourceType === 'upload' && !!key && SENSITIVE_UPLOAD_RESULT_KEY_PATTERN.test(key)
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function sanitizeTaskForReporting(task) {
|
|
493
|
+
if (!task || typeof task !== 'object') {
|
|
494
|
+
return task
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
return {
|
|
498
|
+
...task,
|
|
499
|
+
sourceUrl: redactUploadSourceUrl(task.sourceType, task.sourceUrl),
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function sanitizeResultPayload(value, inheritedSourceType = null, inheritedKey = null) {
|
|
504
|
+
if (Array.isArray(value)) {
|
|
505
|
+
return value.map(item => sanitizeResultPayload(item, inheritedSourceType, inheritedKey))
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
if (!value || typeof value !== 'object') {
|
|
509
|
+
if (typeof value === 'string' && isSensitiveUploadResultString(inheritedKey, value, inheritedSourceType)) {
|
|
510
|
+
return UPLOAD_REDACTED_SOURCE_LABEL
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
return value
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const sourceType = typeof value.sourceType === 'string' ? value.sourceType : inheritedSourceType
|
|
517
|
+
const next = {}
|
|
518
|
+
|
|
519
|
+
for (const [key, item] of Object.entries(value)) {
|
|
520
|
+
if (key === 'sourceUrl') {
|
|
521
|
+
next[key] = redactUploadSourceUrl(sourceType, item)
|
|
522
|
+
continue
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
next[key] = sanitizeResultPayload(item, sourceType, key)
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
return next
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
async function inspectLocalFile(filePath, explicitMimeType) {
|
|
532
|
+
const trimmedPath = String(filePath || '').trim()
|
|
533
|
+
|
|
534
|
+
if (!trimmedPath) {
|
|
535
|
+
fail('缺少本地文件路径')
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
if (isStdinInput(trimmedPath)) {
|
|
539
|
+
fail('v1 不支持从 stdin 读取,请提供本地单个音视频文件路径')
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
if (hasGlobPattern(trimmedPath)) {
|
|
543
|
+
fail('v1 不支持 glob,请提供一个明确的本地文件路径')
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
const resolvedPath = resolveLocalPath(trimmedPath)
|
|
547
|
+
let fileStat
|
|
548
|
+
|
|
549
|
+
try {
|
|
550
|
+
fileStat = await stat(resolvedPath)
|
|
551
|
+
} catch (error) {
|
|
552
|
+
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
|
|
553
|
+
fail(`本地文件不存在:${trimmedPath}`)
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
throw error
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
if (fileStat.isDirectory()) {
|
|
560
|
+
fail('v1 不支持目录,请提供一个本地音视频文件')
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
if (!fileStat.isFile()) {
|
|
564
|
+
fail('v1 只支持普通本地文件,不支持管道、设备或其他特殊文件')
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
if (fileStat.size <= 0) {
|
|
568
|
+
fail('本地文件为空,无法转写')
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
const explicit = normalizeExplicitMimeType(explicitMimeType)
|
|
572
|
+
const mimeType = explicit || inferMimeTypeFromPath(resolvedPath)
|
|
573
|
+
|
|
574
|
+
if (!mimeType || !isAllowedLocalMimeType(mimeType)) {
|
|
575
|
+
fail('无法识别本地文件 MIME,请使用 --mime-type 指定 audio/* 或 video/*')
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
return {
|
|
579
|
+
kind: 'file',
|
|
580
|
+
path: resolvedPath,
|
|
581
|
+
filename: basename(resolvedPath),
|
|
582
|
+
sizeBytes: fileStat.size,
|
|
583
|
+
mimeType,
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
async function resolveTranscriptionInput(args) {
|
|
588
|
+
if (args.file) {
|
|
589
|
+
return await inspectLocalFile(args.file, args.mimeType)
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
const rawInput = String(args.url || '').trim()
|
|
593
|
+
|
|
594
|
+
if (!rawInput) {
|
|
595
|
+
return null
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
if (isStdinInput(rawInput)) {
|
|
599
|
+
fail('v1 不支持从 stdin 读取,请提供内容链接或本地单个音视频文件')
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
if (!isLikelyUrlInput(rawInput) && hasGlobPattern(rawInput)) {
|
|
603
|
+
fail('v1 不支持 glob,请提供一个明确的本地文件路径或内容链接')
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
if (!isLikelyUrlInput(rawInput)) {
|
|
607
|
+
return await inspectLocalFile(rawInput, args.mimeType)
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
if (args.mimeType) {
|
|
611
|
+
fail('--mime-type 只能用于本地文件上传')
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
return {
|
|
615
|
+
kind: 'url',
|
|
616
|
+
url: rawInput,
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
219
620
|
function getConfigFileCandidates() {
|
|
220
621
|
const home = homedir()
|
|
221
622
|
const xdgConfigHome = String(process.env.XDG_CONFIG_HOME || '').trim()
|
|
@@ -408,40 +809,80 @@ async function readErrorMessage(response) {
|
|
|
408
809
|
}
|
|
409
810
|
|
|
410
811
|
async function requestJson(baseUrl, apiKey, path, options = {}) {
|
|
411
|
-
|
|
412
|
-
|
|
812
|
+
return await withRetry(async () => {
|
|
813
|
+
const response = await fetch(`${baseUrl}${path}`, {
|
|
814
|
+
method: options.method || 'GET',
|
|
815
|
+
signal: options.signal,
|
|
816
|
+
headers: {
|
|
817
|
+
Authorization: `Bearer ${apiKey}`,
|
|
818
|
+
'Content-Type': 'application/json',
|
|
819
|
+
...(options.headers || {}),
|
|
820
|
+
},
|
|
821
|
+
body: typeof options.body === 'undefined' ? undefined : JSON.stringify(options.body),
|
|
822
|
+
})
|
|
823
|
+
|
|
824
|
+
if (!response.ok) {
|
|
825
|
+
throw new HttpCliError(await readErrorMessage(response), response.status)
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
return response.json()
|
|
829
|
+
}, {
|
|
830
|
+
signal: options.signal,
|
|
831
|
+
maxAttempts: options.retry ? 4 : 1,
|
|
832
|
+
})
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
async function requestResult(baseUrl, apiKey, taskId, format, options = {}) {
|
|
836
|
+
const response = await fetch(`${baseUrl}/api/cli/transcriptions/${encodeURIComponent(taskId)}/result?format=${encodeURIComponent(format)}`, {
|
|
837
|
+
method: 'GET',
|
|
838
|
+
signal: options.signal,
|
|
413
839
|
headers: {
|
|
414
840
|
Authorization: `Bearer ${apiKey}`,
|
|
415
|
-
'Content-Type': 'application/json',
|
|
416
|
-
...(options.headers || {}),
|
|
417
841
|
},
|
|
418
|
-
body: typeof options.body === 'undefined' ? undefined : JSON.stringify(options.body),
|
|
419
842
|
})
|
|
420
843
|
|
|
421
844
|
if (!response.ok) {
|
|
422
845
|
throw new Error(await readErrorMessage(response))
|
|
423
846
|
}
|
|
424
847
|
|
|
425
|
-
|
|
848
|
+
if (format === 'json' || format === 'verbose_json') {
|
|
849
|
+
return JSON.stringify(sanitizeResultPayload(await response.json()), null, 2)
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
return response.text()
|
|
426
853
|
}
|
|
427
854
|
|
|
428
|
-
async function
|
|
429
|
-
const response = await fetch(`${baseUrl}/api/cli/transcriptions/${encodeURIComponent(taskId)}/
|
|
855
|
+
async function requestTranscriptPreview(baseUrl, apiKey, taskId, options = {}) {
|
|
856
|
+
const response = await fetch(`${baseUrl}/api/cli/transcriptions/${encodeURIComponent(taskId)}/preview`, {
|
|
430
857
|
method: 'GET',
|
|
858
|
+
signal: options.signal,
|
|
431
859
|
headers: {
|
|
432
860
|
Authorization: `Bearer ${apiKey}`,
|
|
433
861
|
},
|
|
434
862
|
})
|
|
435
863
|
|
|
436
|
-
if (
|
|
437
|
-
|
|
864
|
+
if (response.status === 204) {
|
|
865
|
+
return {
|
|
866
|
+
available: true,
|
|
867
|
+
text: null,
|
|
868
|
+
}
|
|
438
869
|
}
|
|
439
870
|
|
|
440
|
-
if (
|
|
441
|
-
return
|
|
871
|
+
if (response.status === 404 || response.status === 405 || response.status === 501) {
|
|
872
|
+
return {
|
|
873
|
+
available: false,
|
|
874
|
+
text: null,
|
|
875
|
+
}
|
|
442
876
|
}
|
|
443
877
|
|
|
444
|
-
|
|
878
|
+
if (!response.ok) {
|
|
879
|
+
throw new HttpCliError(await readErrorMessage(response), response.status)
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
return {
|
|
883
|
+
available: true,
|
|
884
|
+
text: await response.text(),
|
|
885
|
+
}
|
|
445
886
|
}
|
|
446
887
|
|
|
447
888
|
function sleep(ms) {
|
|
@@ -566,6 +1007,77 @@ function buildTranscribingProgressMessage(input = {}) {
|
|
|
566
1007
|
return `转写中 ${percent}%${chunksPart}`
|
|
567
1008
|
}
|
|
568
1009
|
|
|
1010
|
+
function normalizeUploadPercent(uploadedBytes, totalBytes) {
|
|
1011
|
+
if (!Number.isFinite(uploadedBytes) || !Number.isFinite(totalBytes) || totalBytes <= 0) {
|
|
1012
|
+
return 0
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
return Math.max(0, Math.min(100, Math.round((uploadedBytes / totalBytes) * 100)))
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
function buildUploadProgressMessage(input = {}) {
|
|
1019
|
+
const uploadedBytes = Number(input.uploadedBytes) || 0
|
|
1020
|
+
const totalBytes = Number(input.totalBytes) || 0
|
|
1021
|
+
const percent = normalizeUploadPercent(uploadedBytes, totalBytes)
|
|
1022
|
+
|
|
1023
|
+
return `上传中 ${formatBytes(uploadedBytes)} / ${formatBytes(totalBytes)} (${percent}%)`
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
function buildHashingMessage(input = {}) {
|
|
1027
|
+
const hashedBytes = Number(input.hashedBytes) || 0
|
|
1028
|
+
const totalBytes = Number(input.totalBytes) || 0
|
|
1029
|
+
const percent = normalizeUploadPercent(hashedBytes, totalBytes)
|
|
1030
|
+
|
|
1031
|
+
return `正在计算文件哈希 ${percent}%`
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
function getUploadReadyMessage(input = {}) {
|
|
1035
|
+
const title = input.title || input.filename || '上传文件'
|
|
1036
|
+
const duration = Number(input.durationSeconds)
|
|
1037
|
+
const durationPart = Number.isFinite(duration) && duration > 0
|
|
1038
|
+
? `,时长约 ${Math.ceil(duration / 60)} 分钟`
|
|
1039
|
+
: ''
|
|
1040
|
+
|
|
1041
|
+
return `媒体检查通过:${title}${durationPart}`
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
function getUploadTaskFromCheckResult(checkResult) {
|
|
1045
|
+
if (checkResult?.task?.taskId) {
|
|
1046
|
+
return checkResult.task
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
if (checkResult?.upload?.task?.taskId) {
|
|
1050
|
+
return checkResult.upload.task
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
return null
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
function getUploadReadySummary(checkResult, fileInput, uploadId) {
|
|
1057
|
+
const task = getUploadTaskFromCheckResult(checkResult)
|
|
1058
|
+
const upload = checkResult?.upload || {}
|
|
1059
|
+
const durationMs = Number(upload.ffprobeDurationMs || upload.clientDurationMs)
|
|
1060
|
+
const durationSeconds = Number(checkResult?.durationSeconds)
|
|
1061
|
+
const normalizedDurationSeconds = Number.isFinite(durationSeconds) && durationSeconds > 0
|
|
1062
|
+
? durationSeconds
|
|
1063
|
+
: Number.isFinite(durationMs) && durationMs > 0
|
|
1064
|
+
? Math.round(durationMs / 1000)
|
|
1065
|
+
: null
|
|
1066
|
+
const requiredMinutes = Number(checkResult?.requiredMinutes)
|
|
1067
|
+
|
|
1068
|
+
return {
|
|
1069
|
+
uploadId,
|
|
1070
|
+
filename: upload.filename || fileInput.filename,
|
|
1071
|
+
title: task?.title || checkResult?.title || upload.filename || fileInput.filename,
|
|
1072
|
+
durationSeconds: normalizedDurationSeconds,
|
|
1073
|
+
requiredMinutes: Number.isFinite(requiredMinutes) && requiredMinutes > 0
|
|
1074
|
+
? requiredMinutes
|
|
1075
|
+
: normalizedDurationSeconds
|
|
1076
|
+
? Math.max(1, Math.floor(normalizedDurationSeconds / 60))
|
|
1077
|
+
: null,
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
|
|
569
1081
|
function createProgressReporter(progressFormat) {
|
|
570
1082
|
const resolvedMode = resolveProgressFormat(progressFormat)
|
|
571
1083
|
const outputState = createOutputState()
|
|
@@ -596,48 +1108,38 @@ function createProgressReporter(progressFormat) {
|
|
|
596
1108
|
return {
|
|
597
1109
|
mode: resolvedMode,
|
|
598
1110
|
reportTaskCreated(task) {
|
|
1111
|
+
const safeTask = sanitizeTaskForReporting(task)
|
|
1112
|
+
|
|
599
1113
|
if (resolvedMode === 'jsonl') {
|
|
600
1114
|
writeJsonEvent({
|
|
601
1115
|
event: 'task.created',
|
|
602
|
-
message: `任务已创建:${
|
|
603
|
-
taskId:
|
|
604
|
-
title:
|
|
605
|
-
sourceType:
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
1116
|
+
message: `任务已创建:${safeTask.taskId}`,
|
|
1117
|
+
taskId: safeTask.taskId,
|
|
1118
|
+
title: safeTask.title,
|
|
1119
|
+
sourceType: safeTask.sourceType,
|
|
1120
|
+
sourceUrl: safeTask.sourceUrl || null,
|
|
1121
|
+
status: safeTask.status,
|
|
1122
|
+
statusLabel: formatStatus(safeTask.status),
|
|
1123
|
+
consumedMinutes: safeTask.consumedMinutes,
|
|
1124
|
+
remainingMinutes: safeTask.remainingMinutes,
|
|
1125
|
+
mode: safeTask.mode,
|
|
1126
|
+
resultReady: safeTask.resultReady,
|
|
612
1127
|
})
|
|
613
1128
|
return
|
|
614
1129
|
}
|
|
615
1130
|
|
|
616
|
-
writeTextMessage(`任务已创建:${
|
|
617
|
-
writeTextMessage(`标题:${
|
|
618
|
-
writeTextMessage(`来源:${
|
|
619
|
-
writeTextMessage(`状态:${formatStatus(
|
|
620
|
-
writeTextMessage(`本次消耗分钟:${
|
|
621
|
-
writeTextMessage(`剩余分钟:${
|
|
622
|
-
},
|
|
623
|
-
reportStatusChanged(status) {
|
|
624
|
-
emitTextOrJson(buildStatusChangedMessage(status), {
|
|
625
|
-
event: 'task.status_changed',
|
|
626
|
-
status,
|
|
627
|
-
statusLabel: formatStatus(status),
|
|
628
|
-
})
|
|
1131
|
+
writeTextMessage(`任务已创建:${safeTask.taskId}`)
|
|
1132
|
+
writeTextMessage(`标题:${safeTask.title}`)
|
|
1133
|
+
writeTextMessage(`来源:${safeTask.sourceType}`)
|
|
1134
|
+
writeTextMessage(`状态:${formatStatus(safeTask.status)}`)
|
|
1135
|
+
writeTextMessage(`本次消耗分钟:${safeTask.consumedMinutes}`)
|
|
1136
|
+
writeTextMessage(`剩余分钟:${safeTask.remainingMinutes}`)
|
|
629
1137
|
},
|
|
630
|
-
|
|
631
|
-
const
|
|
632
|
-
const
|
|
633
|
-
const
|
|
634
|
-
|
|
635
|
-
finishedChunks,
|
|
636
|
-
totalChunks,
|
|
637
|
-
)
|
|
638
|
-
const message = buildTranscribingProgressMessage(input)
|
|
639
|
-
|
|
640
|
-
lastTerminalSignature = ''
|
|
1138
|
+
reportUploadHashing(input = {}) {
|
|
1139
|
+
const message = buildHashingMessage(input)
|
|
1140
|
+
const hashedBytes = Number(input.hashedBytes) || 0
|
|
1141
|
+
const totalBytes = Number(input.totalBytes) || 0
|
|
1142
|
+
const percent = normalizeUploadPercent(hashedBytes, totalBytes)
|
|
641
1143
|
|
|
642
1144
|
if (resolvedMode === 'interactive') {
|
|
643
1145
|
writeInline(outputState, message)
|
|
@@ -646,10 +1148,148 @@ function createProgressReporter(progressFormat) {
|
|
|
646
1148
|
|
|
647
1149
|
if (resolvedMode === 'jsonl') {
|
|
648
1150
|
writeJsonEvent({
|
|
649
|
-
event: '
|
|
1151
|
+
event: 'upload.hashing',
|
|
650
1152
|
message,
|
|
651
|
-
|
|
652
|
-
|
|
1153
|
+
filename: input.filename || null,
|
|
1154
|
+
hashedBytes,
|
|
1155
|
+
totalBytes,
|
|
1156
|
+
percent,
|
|
1157
|
+
})
|
|
1158
|
+
return
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
if (message === lastTextProgressMessage) {
|
|
1162
|
+
return
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
lastTextProgressMessage = message
|
|
1166
|
+
writeTextMessage(message)
|
|
1167
|
+
},
|
|
1168
|
+
reportUploadStarted(input = {}) {
|
|
1169
|
+
lastTextProgressMessage = ''
|
|
1170
|
+
const message = `上传已开始:${input.filename || '上传文件'}(${formatBytes(input.totalBytes)})`
|
|
1171
|
+
|
|
1172
|
+
emitTextOrJson(message, {
|
|
1173
|
+
event: 'upload.started',
|
|
1174
|
+
filename: input.filename || null,
|
|
1175
|
+
mimeType: input.mimeType || null,
|
|
1176
|
+
totalBytes: Number(input.totalBytes) || 0,
|
|
1177
|
+
uploadId: input.uploadId || null,
|
|
1178
|
+
partSize: Number(input.partSize) || null,
|
|
1179
|
+
totalParts: Number(input.totalParts) || null,
|
|
1180
|
+
})
|
|
1181
|
+
},
|
|
1182
|
+
reportUploadProgress(input = {}) {
|
|
1183
|
+
const uploadedBytes = Number(input.uploadedBytes) || 0
|
|
1184
|
+
const totalBytes = Number(input.totalBytes) || 0
|
|
1185
|
+
const percent = normalizeUploadPercent(uploadedBytes, totalBytes)
|
|
1186
|
+
const message = buildUploadProgressMessage({ uploadedBytes, totalBytes })
|
|
1187
|
+
|
|
1188
|
+
if (resolvedMode === 'interactive') {
|
|
1189
|
+
writeInline(outputState, message)
|
|
1190
|
+
return
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
if (resolvedMode === 'jsonl') {
|
|
1194
|
+
writeJsonEvent({
|
|
1195
|
+
event: 'upload.progress',
|
|
1196
|
+
message,
|
|
1197
|
+
uploadedBytes,
|
|
1198
|
+
totalBytes,
|
|
1199
|
+
percent,
|
|
1200
|
+
completedParts: Number(input.completedParts) || null,
|
|
1201
|
+
totalParts: Number(input.totalParts) || null,
|
|
1202
|
+
})
|
|
1203
|
+
return
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
if (message === lastTextProgressMessage) {
|
|
1207
|
+
return
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
lastTextProgressMessage = message
|
|
1211
|
+
writeTextMessage(message)
|
|
1212
|
+
},
|
|
1213
|
+
reportUploadCompleted(input = {}) {
|
|
1214
|
+
finishInline(outputState)
|
|
1215
|
+
lastTextProgressMessage = ''
|
|
1216
|
+
const message = '上传完成,正在合并并准备媒体检查'
|
|
1217
|
+
|
|
1218
|
+
emitTextOrJson(message, {
|
|
1219
|
+
event: 'upload.completed',
|
|
1220
|
+
uploadId: input.uploadId || null,
|
|
1221
|
+
filename: input.filename || null,
|
|
1222
|
+
totalBytes: Number(input.totalBytes) || 0,
|
|
1223
|
+
})
|
|
1224
|
+
},
|
|
1225
|
+
reportUploadChecking(input = {}) {
|
|
1226
|
+
const message = '正在检查媒体信息'
|
|
1227
|
+
|
|
1228
|
+
emitTextOrJson(message, {
|
|
1229
|
+
event: 'upload.checking',
|
|
1230
|
+
uploadId: input.uploadId || null,
|
|
1231
|
+
})
|
|
1232
|
+
},
|
|
1233
|
+
reportUploadReady(input = {}) {
|
|
1234
|
+
const message = getUploadReadyMessage(input)
|
|
1235
|
+
|
|
1236
|
+
emitTextOrJson(message, {
|
|
1237
|
+
event: 'upload.ready',
|
|
1238
|
+
uploadId: input.uploadId || null,
|
|
1239
|
+
sourceType: 'upload',
|
|
1240
|
+
sourceUrl: UPLOAD_REDACTED_SOURCE_LABEL,
|
|
1241
|
+
title: input.title || input.filename || null,
|
|
1242
|
+
durationSeconds: Number(input.durationSeconds) || null,
|
|
1243
|
+
requiredMinutes: Number(input.requiredMinutes) || null,
|
|
1244
|
+
})
|
|
1245
|
+
},
|
|
1246
|
+
reportUploadAborted(input = {}) {
|
|
1247
|
+
finishInline(outputState)
|
|
1248
|
+
const message = '上传已取消'
|
|
1249
|
+
|
|
1250
|
+
emitTextOrJson(message, {
|
|
1251
|
+
event: 'upload.aborted',
|
|
1252
|
+
uploadId: input.uploadId || null,
|
|
1253
|
+
})
|
|
1254
|
+
},
|
|
1255
|
+
reportUploadFailed(message, input = {}) {
|
|
1256
|
+
finishInline(outputState)
|
|
1257
|
+
emitTextOrJson(message, {
|
|
1258
|
+
event: 'upload.failed',
|
|
1259
|
+
uploadId: input.uploadId || null,
|
|
1260
|
+
error: message,
|
|
1261
|
+
})
|
|
1262
|
+
},
|
|
1263
|
+
reportStatusChanged(status) {
|
|
1264
|
+
emitTextOrJson(buildStatusChangedMessage(status), {
|
|
1265
|
+
event: 'task.status_changed',
|
|
1266
|
+
status,
|
|
1267
|
+
statusLabel: formatStatus(status),
|
|
1268
|
+
})
|
|
1269
|
+
},
|
|
1270
|
+
reportProgress(input = {}) {
|
|
1271
|
+
const finishedChunks = Number(input.finishedChunks)
|
|
1272
|
+
const totalChunks = Number(input.totalChunks)
|
|
1273
|
+
const normalizedProgress = normalizeProgressValue(
|
|
1274
|
+
Number(input.progress),
|
|
1275
|
+
finishedChunks,
|
|
1276
|
+
totalChunks,
|
|
1277
|
+
)
|
|
1278
|
+
const message = buildTranscribingProgressMessage(input)
|
|
1279
|
+
|
|
1280
|
+
lastTerminalSignature = ''
|
|
1281
|
+
|
|
1282
|
+
if (resolvedMode === 'interactive') {
|
|
1283
|
+
writeInline(outputState, message)
|
|
1284
|
+
return
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
if (resolvedMode === 'jsonl') {
|
|
1288
|
+
writeJsonEvent({
|
|
1289
|
+
event: 'task.progress',
|
|
1290
|
+
message,
|
|
1291
|
+
status: 'transcribing',
|
|
1292
|
+
progress: normalizedProgress,
|
|
653
1293
|
percent: normalizedProgress === null ? null : Math.round(normalizedProgress * 100),
|
|
654
1294
|
finishedChunks: Number.isFinite(finishedChunks) ? Math.max(0, finishedChunks) : null,
|
|
655
1295
|
totalChunks: Number.isFinite(totalChunks) && totalChunks > 0 ? totalChunks : null,
|
|
@@ -684,6 +1324,50 @@ function createProgressReporter(progressFormat) {
|
|
|
684
1324
|
error: errorMessage || null,
|
|
685
1325
|
})
|
|
686
1326
|
},
|
|
1327
|
+
reportTranscriptDelta(input) {
|
|
1328
|
+
if (resolvedMode !== 'jsonl') {
|
|
1329
|
+
return
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
writeJsonEvent({
|
|
1333
|
+
event: 'task.transcript.delta',
|
|
1334
|
+
taskId: input.taskId,
|
|
1335
|
+
sequence: input.sequence,
|
|
1336
|
+
source: input.source,
|
|
1337
|
+
startOffset: input.startOffset,
|
|
1338
|
+
endOffset: input.endOffset,
|
|
1339
|
+
text: input.text,
|
|
1340
|
+
})
|
|
1341
|
+
},
|
|
1342
|
+
reportTranscriptReset(input) {
|
|
1343
|
+
if (resolvedMode !== 'jsonl') {
|
|
1344
|
+
return
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
writeJsonEvent({
|
|
1348
|
+
event: 'task.transcript.reset',
|
|
1349
|
+
taskId: input.taskId,
|
|
1350
|
+
sequence: input.sequence,
|
|
1351
|
+
source: input.source,
|
|
1352
|
+
reason: input.reason,
|
|
1353
|
+
text: input.text,
|
|
1354
|
+
characterCount: input.characterCount,
|
|
1355
|
+
})
|
|
1356
|
+
},
|
|
1357
|
+
reportTranscriptCompleted(input) {
|
|
1358
|
+
if (resolvedMode !== 'jsonl') {
|
|
1359
|
+
return
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
writeJsonEvent({
|
|
1363
|
+
event: 'task.transcript.completed',
|
|
1364
|
+
taskId: input.taskId,
|
|
1365
|
+
sequence: input.sequence,
|
|
1366
|
+
source: input.source,
|
|
1367
|
+
characterCount: input.characterCount,
|
|
1368
|
+
sha256: input.sha256,
|
|
1369
|
+
})
|
|
1370
|
+
},
|
|
687
1371
|
reportResultWritten(outputPath, format) {
|
|
688
1372
|
emitTextOrJson(`结果已写入:${outputPath}`, {
|
|
689
1373
|
event: 'task.result_written',
|
|
@@ -728,21 +1412,130 @@ function parseSseBlock(block) {
|
|
|
728
1412
|
}
|
|
729
1413
|
}
|
|
730
1414
|
|
|
731
|
-
|
|
1415
|
+
function countCodePoints(value) {
|
|
1416
|
+
return Array.from(String(value || '')).length
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
function longestCommonCodePointPrefix(first, second) {
|
|
1420
|
+
const firstCodePoints = Array.from(String(first || ''))
|
|
1421
|
+
const secondCodePoints = Array.from(String(second || ''))
|
|
1422
|
+
const length = Math.min(firstCodePoints.length, secondCodePoints.length)
|
|
1423
|
+
let index = 0
|
|
1424
|
+
|
|
1425
|
+
while (index < length && firstCodePoints[index] === secondCodePoints[index]) {
|
|
1426
|
+
index += 1
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
return firstCodePoints.slice(0, index).join('')
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
function createTranscriptStreamState(taskId, reporter) {
|
|
1433
|
+
let stableText = ''
|
|
1434
|
+
let stableCharacterCount = 0
|
|
1435
|
+
let previousPreviewSnapshot = null
|
|
1436
|
+
let sequence = 0
|
|
1437
|
+
|
|
1438
|
+
function nextSequence() {
|
|
1439
|
+
sequence += 1
|
|
1440
|
+
return sequence
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
function applyStableSnapshot(snapshot, source, resetReason = 'non_prefix_snapshot') {
|
|
1444
|
+
if (typeof snapshot !== 'string' || snapshot === stableText) {
|
|
1445
|
+
return
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
if (snapshot.startsWith(stableText)) {
|
|
1449
|
+
const appendedText = snapshot.slice(stableText.length)
|
|
1450
|
+
const startOffset = stableCharacterCount
|
|
1451
|
+
|
|
1452
|
+
stableText = snapshot
|
|
1453
|
+
stableCharacterCount += countCodePoints(appendedText)
|
|
1454
|
+
|
|
1455
|
+
if (appendedText) {
|
|
1456
|
+
reporter.reportTranscriptDelta({
|
|
1457
|
+
taskId,
|
|
1458
|
+
sequence: nextSequence(),
|
|
1459
|
+
source,
|
|
1460
|
+
startOffset,
|
|
1461
|
+
endOffset: stableCharacterCount,
|
|
1462
|
+
text: appendedText,
|
|
1463
|
+
})
|
|
1464
|
+
}
|
|
1465
|
+
return
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
stableText = snapshot
|
|
1469
|
+
stableCharacterCount = countCodePoints(stableText)
|
|
1470
|
+
reporter.reportTranscriptReset({
|
|
1471
|
+
taskId,
|
|
1472
|
+
sequence: nextSequence(),
|
|
1473
|
+
source,
|
|
1474
|
+
reason: resetReason,
|
|
1475
|
+
text: stableText,
|
|
1476
|
+
characterCount: stableCharacterCount,
|
|
1477
|
+
})
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
return {
|
|
1481
|
+
applySseSnapshot(snapshot) {
|
|
1482
|
+
if (typeof snapshot === 'string') {
|
|
1483
|
+
previousPreviewSnapshot = null
|
|
1484
|
+
}
|
|
1485
|
+
applyStableSnapshot(snapshot, 'sse')
|
|
1486
|
+
},
|
|
1487
|
+
applyPreviewSnapshot(snapshot) {
|
|
1488
|
+
if (typeof snapshot !== 'string') {
|
|
1489
|
+
return
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
if (previousPreviewSnapshot === null) {
|
|
1493
|
+
previousPreviewSnapshot = snapshot
|
|
1494
|
+
return
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
const stablePreviewText = longestCommonCodePointPrefix(previousPreviewSnapshot, snapshot)
|
|
1498
|
+
previousPreviewSnapshot = snapshot
|
|
1499
|
+
|
|
1500
|
+
if (
|
|
1501
|
+
stablePreviewText.length < stableText.length
|
|
1502
|
+
&& stableText.startsWith(stablePreviewText)
|
|
1503
|
+
) {
|
|
1504
|
+
return
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
applyStableSnapshot(stablePreviewText, 'preview')
|
|
1508
|
+
},
|
|
1509
|
+
complete(authoritativeText) {
|
|
1510
|
+
applyStableSnapshot(authoritativeText, 'result', 'final_result_mismatch')
|
|
1511
|
+
reporter.reportTranscriptCompleted({
|
|
1512
|
+
taskId,
|
|
1513
|
+
sequence: nextSequence(),
|
|
1514
|
+
source: 'result',
|
|
1515
|
+
characterCount: stableCharacterCount,
|
|
1516
|
+
sha256: createHash('sha256').update(authoritativeText, 'utf8').digest('hex'),
|
|
1517
|
+
})
|
|
1518
|
+
},
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
async function consumeTaskEvents(baseUrl, apiKey, taskId, initialTask, reporter, signal, transcriptState = null) {
|
|
732
1523
|
const response = await fetch(`${baseUrl}/api/cli/transcriptions/${encodeURIComponent(taskId)}/events`, {
|
|
733
1524
|
method: 'GET',
|
|
1525
|
+
signal,
|
|
734
1526
|
headers: {
|
|
735
1527
|
Authorization: `Bearer ${apiKey}`,
|
|
736
1528
|
},
|
|
737
1529
|
})
|
|
738
1530
|
|
|
739
1531
|
if (!response.ok || !response.body) {
|
|
740
|
-
throw new
|
|
1532
|
+
throw new HttpCliError(await readErrorMessage(response), response.status)
|
|
741
1533
|
}
|
|
742
1534
|
|
|
743
1535
|
const reader = response.body.getReader()
|
|
744
1536
|
const decoder = new TextDecoder()
|
|
745
1537
|
let buffer = ''
|
|
1538
|
+
let streamDone = false
|
|
746
1539
|
let task = {
|
|
747
1540
|
...initialTask,
|
|
748
1541
|
}
|
|
@@ -786,68 +1579,117 @@ async function consumeTaskEvents(baseUrl, apiKey, taskId, initialTask, reporter)
|
|
|
786
1579
|
reporter.reportProgress(data)
|
|
787
1580
|
}
|
|
788
1581
|
|
|
789
|
-
|
|
790
|
-
const
|
|
1582
|
+
function processSseBlock(block) {
|
|
1583
|
+
const normalizedBlock = block.replace(/\r\n?/g, '\n').trim()
|
|
791
1584
|
|
|
792
|
-
if (
|
|
793
|
-
|
|
1585
|
+
if (!normalizedBlock) {
|
|
1586
|
+
return false
|
|
794
1587
|
}
|
|
795
1588
|
|
|
796
|
-
|
|
797
|
-
buffer = buffer.replace(/\r\n/g, '\n')
|
|
798
|
-
|
|
799
|
-
let separatorIndex = buffer.indexOf('\n\n')
|
|
1589
|
+
const { event, data } = parseSseBlock(normalizedBlock)
|
|
800
1590
|
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
buffer = buffer.slice(separatorIndex + 2)
|
|
804
|
-
separatorIndex = buffer.indexOf('\n\n')
|
|
1591
|
+
if (event === 'job.snapshot') {
|
|
1592
|
+
transcriptState?.applySseSnapshot(data?.committedText)
|
|
805
1593
|
|
|
806
|
-
if (
|
|
807
|
-
|
|
1594
|
+
if (data?.status === 'transcribing') {
|
|
1595
|
+
renderProgress(data)
|
|
1596
|
+
} else if (TERMINAL_STATUSES.has(data?.status)) {
|
|
1597
|
+
applyTerminal(data.status, data?.error || data?.message || null)
|
|
1598
|
+
} else {
|
|
1599
|
+
applyStatus(data?.status)
|
|
808
1600
|
}
|
|
1601
|
+
return TERMINAL_STATUSES.has(data?.status)
|
|
1602
|
+
}
|
|
809
1603
|
|
|
810
|
-
|
|
1604
|
+
if (event === 'job.status_changed') {
|
|
1605
|
+
applyStatus(data?.status)
|
|
1606
|
+
return TERMINAL_STATUSES.has(data?.status)
|
|
1607
|
+
}
|
|
811
1608
|
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
applyTerminal(data.status, data?.error || data?.message || null)
|
|
817
|
-
} else {
|
|
818
|
-
applyStatus(data?.status)
|
|
819
|
-
}
|
|
820
|
-
continue
|
|
821
|
-
}
|
|
1609
|
+
if (event === 'job.progress') {
|
|
1610
|
+
renderProgress(data)
|
|
1611
|
+
return false
|
|
1612
|
+
}
|
|
822
1613
|
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
1614
|
+
if (event === 'transcript.delta') {
|
|
1615
|
+
transcriptState?.applySseSnapshot(data?.committedText)
|
|
1616
|
+
return false
|
|
1617
|
+
}
|
|
827
1618
|
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
1619
|
+
if (event === 'job.completed') {
|
|
1620
|
+
applyTerminal('completed', null)
|
|
1621
|
+
return true
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
if (event === 'job.failed') {
|
|
1625
|
+
applyTerminal('failed', data?.error || data?.message || '转写失败')
|
|
1626
|
+
return true
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
if (event === 'job.cancelled') {
|
|
1630
|
+
applyTerminal('cancelled', '转写任务已取消')
|
|
1631
|
+
return true
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
return false
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
function drainSseBuffer(flush = false) {
|
|
1638
|
+
while (true) {
|
|
1639
|
+
const separator = /\r\n\r\n|\n\n|\r\r/.exec(buffer)
|
|
1640
|
+
|
|
1641
|
+
if (!separator || typeof separator.index !== 'number') {
|
|
1642
|
+
break
|
|
831
1643
|
}
|
|
832
1644
|
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
1645
|
+
const block = buffer.slice(0, separator.index)
|
|
1646
|
+
buffer = buffer.slice(separator.index + separator[0].length)
|
|
1647
|
+
|
|
1648
|
+
if (processSseBlock(block)) {
|
|
1649
|
+
return true
|
|
836
1650
|
}
|
|
1651
|
+
}
|
|
837
1652
|
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
1653
|
+
if (flush && buffer.trim()) {
|
|
1654
|
+
const tail = buffer
|
|
1655
|
+
buffer = ''
|
|
1656
|
+
return processSseBlock(tail)
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
return false
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
try {
|
|
1663
|
+
while (true) {
|
|
1664
|
+
const { value, done } = await reader.read()
|
|
1665
|
+
|
|
1666
|
+
if (done) {
|
|
1667
|
+
streamDone = true
|
|
1668
|
+
buffer += decoder.decode()
|
|
1669
|
+
if (drainSseBuffer(true)) {
|
|
1670
|
+
return task
|
|
1671
|
+
}
|
|
1672
|
+
break
|
|
841
1673
|
}
|
|
842
1674
|
|
|
843
|
-
|
|
844
|
-
|
|
1675
|
+
buffer += decoder.decode(value, { stream: true })
|
|
1676
|
+
|
|
1677
|
+
if (drainSseBuffer()) {
|
|
845
1678
|
return task
|
|
846
1679
|
}
|
|
847
1680
|
}
|
|
1681
|
+
} finally {
|
|
1682
|
+
if (!streamDone) {
|
|
1683
|
+
await reader.cancel().catch(() => {})
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
try {
|
|
1687
|
+
reader.releaseLock()
|
|
1688
|
+
} catch {
|
|
1689
|
+
// The stream may already have released its lock while aborting.
|
|
1690
|
+
}
|
|
848
1691
|
}
|
|
849
1692
|
|
|
850
|
-
reporter.close()
|
|
851
1693
|
return task
|
|
852
1694
|
}
|
|
853
1695
|
|
|
@@ -858,6 +1700,423 @@ async function writeOutput(outputPath, content) {
|
|
|
858
1700
|
return resolvedPath
|
|
859
1701
|
}
|
|
860
1702
|
|
|
1703
|
+
function assertNotAborted(signal) {
|
|
1704
|
+
if (signal?.aborted) {
|
|
1705
|
+
throw createAbortError()
|
|
1706
|
+
}
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
async function calculateFileSha256(fileInput, reporter, signal) {
|
|
1710
|
+
const hash = createHash('sha256')
|
|
1711
|
+
const fileHandle = await open(fileInput.path, 'r')
|
|
1712
|
+
let hashedBytes = 0
|
|
1713
|
+
|
|
1714
|
+
reporter.reportUploadHashing({
|
|
1715
|
+
filename: fileInput.filename,
|
|
1716
|
+
hashedBytes,
|
|
1717
|
+
totalBytes: fileInput.sizeBytes,
|
|
1718
|
+
})
|
|
1719
|
+
|
|
1720
|
+
try {
|
|
1721
|
+
while (hashedBytes < fileInput.sizeBytes) {
|
|
1722
|
+
assertNotAborted(signal)
|
|
1723
|
+
|
|
1724
|
+
const length = Math.min(HASH_CHUNK_SIZE, fileInput.sizeBytes - hashedBytes)
|
|
1725
|
+
const buffer = Buffer.allocUnsafe(length)
|
|
1726
|
+
const { bytesRead } = await fileHandle.read(buffer, 0, length, hashedBytes)
|
|
1727
|
+
|
|
1728
|
+
if (bytesRead <= 0) {
|
|
1729
|
+
break
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
hash.update(bytesRead === buffer.length ? buffer : buffer.subarray(0, bytesRead))
|
|
1733
|
+
hashedBytes += bytesRead
|
|
1734
|
+
reporter.reportUploadHashing({
|
|
1735
|
+
filename: fileInput.filename,
|
|
1736
|
+
hashedBytes,
|
|
1737
|
+
totalBytes: fileInput.sizeBytes,
|
|
1738
|
+
})
|
|
1739
|
+
}
|
|
1740
|
+
} finally {
|
|
1741
|
+
await fileHandle.close().catch(() => {})
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1744
|
+
if (hashedBytes !== fileInput.sizeBytes) {
|
|
1745
|
+
throw new Error('读取本地文件计算哈希时大小不一致,请重新选择文件')
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
return hash.digest('hex')
|
|
1749
|
+
}
|
|
1750
|
+
|
|
1751
|
+
async function readFileChunk(filePath, start, length, signal) {
|
|
1752
|
+
assertNotAborted(signal)
|
|
1753
|
+
|
|
1754
|
+
const fileHandle = await open(filePath, 'r')
|
|
1755
|
+
const buffer = Buffer.allocUnsafe(length)
|
|
1756
|
+
let offset = 0
|
|
1757
|
+
|
|
1758
|
+
try {
|
|
1759
|
+
while (offset < length) {
|
|
1760
|
+
assertNotAborted(signal)
|
|
1761
|
+
|
|
1762
|
+
const { bytesRead } = await fileHandle.read(
|
|
1763
|
+
buffer,
|
|
1764
|
+
offset,
|
|
1765
|
+
length - offset,
|
|
1766
|
+
start + offset,
|
|
1767
|
+
)
|
|
1768
|
+
|
|
1769
|
+
if (bytesRead <= 0) {
|
|
1770
|
+
break
|
|
1771
|
+
}
|
|
1772
|
+
|
|
1773
|
+
offset += bytesRead
|
|
1774
|
+
}
|
|
1775
|
+
} finally {
|
|
1776
|
+
await fileHandle.close().catch(() => {})
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1779
|
+
if (offset !== length) {
|
|
1780
|
+
throw new Error('读取本地文件分片时大小不一致,请重新选择文件')
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
return buffer
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1786
|
+
async function abortUploadOnServer(baseUrl, apiKey, uploadId) {
|
|
1787
|
+
if (!uploadId) {
|
|
1788
|
+
return null
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
return await requestJson(baseUrl, apiKey, '/api/cli/uploads/abort', {
|
|
1792
|
+
method: 'POST',
|
|
1793
|
+
retry: true,
|
|
1794
|
+
body: {
|
|
1795
|
+
uploadId,
|
|
1796
|
+
},
|
|
1797
|
+
})
|
|
1798
|
+
}
|
|
1799
|
+
|
|
1800
|
+
function createRunContext(baseUrl, apiKey, reporter) {
|
|
1801
|
+
const abortController = new AbortController()
|
|
1802
|
+
const state = {
|
|
1803
|
+
uploadId: '',
|
|
1804
|
+
taskId: '',
|
|
1805
|
+
taskCreated: false,
|
|
1806
|
+
uploadAbortAllowed: true,
|
|
1807
|
+
interrupting: false,
|
|
1808
|
+
interruptPromise: null,
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1811
|
+
const handleSigint = () => {
|
|
1812
|
+
if (state.interrupting) {
|
|
1813
|
+
return
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
state.interrupting = true
|
|
1817
|
+
abortController.abort()
|
|
1818
|
+
|
|
1819
|
+
state.interruptPromise = (async () => {
|
|
1820
|
+
if (state.taskCreated && state.taskId) {
|
|
1821
|
+
reporter.reportNotice(`已停止本地等待,服务端转写仍在继续:${state.taskId}`)
|
|
1822
|
+
reporter.close()
|
|
1823
|
+
return
|
|
1824
|
+
}
|
|
1825
|
+
|
|
1826
|
+
if (!state.uploadAbortAllowed && state.uploadId) {
|
|
1827
|
+
const taskPart = state.taskId ? `,taskId=${state.taskId}` : ''
|
|
1828
|
+
reporter.reportNotice(`已停止本地等待,服务端正在处理上传:uploadId=${state.uploadId}${taskPart}`)
|
|
1829
|
+
reporter.close()
|
|
1830
|
+
return
|
|
1831
|
+
}
|
|
1832
|
+
|
|
1833
|
+
if (state.uploadId) {
|
|
1834
|
+
try {
|
|
1835
|
+
await abortUploadOnServer(baseUrl, apiKey, state.uploadId)
|
|
1836
|
+
reporter.reportUploadAborted({ uploadId: state.uploadId })
|
|
1837
|
+
} catch (error) {
|
|
1838
|
+
reporter.reportUploadFailed(
|
|
1839
|
+
`已中断本地上传,但服务端上传清理失败:${error instanceof Error ? error.message : String(error)}`,
|
|
1840
|
+
{ uploadId: state.uploadId },
|
|
1841
|
+
)
|
|
1842
|
+
}
|
|
1843
|
+
}
|
|
1844
|
+
|
|
1845
|
+
reporter.close()
|
|
1846
|
+
})()
|
|
1847
|
+
|
|
1848
|
+
void state.interruptPromise.finally(() => {
|
|
1849
|
+
process.exit(130)
|
|
1850
|
+
})
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1853
|
+
process.once('SIGINT', handleSigint)
|
|
1854
|
+
|
|
1855
|
+
return {
|
|
1856
|
+
signal: abortController.signal,
|
|
1857
|
+
state,
|
|
1858
|
+
dispose() {
|
|
1859
|
+
process.off('SIGINT', handleSigint)
|
|
1860
|
+
},
|
|
1861
|
+
}
|
|
1862
|
+
}
|
|
1863
|
+
|
|
1864
|
+
async function signUploadPart(baseUrl, apiKey, uploadId, partNumber, signal) {
|
|
1865
|
+
const response = await requestJson(baseUrl, apiKey, '/api/cli/uploads/parts', {
|
|
1866
|
+
method: 'POST',
|
|
1867
|
+
signal,
|
|
1868
|
+
retry: true,
|
|
1869
|
+
body: {
|
|
1870
|
+
uploadId,
|
|
1871
|
+
partNumbers: [partNumber],
|
|
1872
|
+
},
|
|
1873
|
+
})
|
|
1874
|
+
const signedPart = Array.isArray(response?.parts)
|
|
1875
|
+
? response.parts.find(part => Number(part?.partNumber) === partNumber)
|
|
1876
|
+
: null
|
|
1877
|
+
|
|
1878
|
+
if (!signedPart?.url) {
|
|
1879
|
+
throw new Error(`第 ${partNumber} 个分片上传地址生成失败`)
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1882
|
+
return signedPart
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1885
|
+
async function putSignedPartBuffer(signedUrl, buffer, signal) {
|
|
1886
|
+
const response = await fetch(signedUrl, {
|
|
1887
|
+
method: 'PUT',
|
|
1888
|
+
signal,
|
|
1889
|
+
body: buffer,
|
|
1890
|
+
})
|
|
1891
|
+
|
|
1892
|
+
if (!response.ok) {
|
|
1893
|
+
const message = response.status === 403
|
|
1894
|
+
? '分片上传签名已过期'
|
|
1895
|
+
: `分片上传失败:${response.status} ${response.statusText}`
|
|
1896
|
+
throw new HttpCliError(message, response.status)
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1899
|
+
const etag = normalizeEtag(response.headers.get('ETag') || response.headers.get('etag'))
|
|
1900
|
+
|
|
1901
|
+
if (!etag) {
|
|
1902
|
+
throw new Error('OSS 未返回分片 ETag,请检查服务端签名与跨域响应头配置')
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1905
|
+
return etag
|
|
1906
|
+
}
|
|
1907
|
+
|
|
1908
|
+
async function uploadSinglePart(input) {
|
|
1909
|
+
const {
|
|
1910
|
+
baseUrl,
|
|
1911
|
+
apiKey,
|
|
1912
|
+
uploadId,
|
|
1913
|
+
fileInput,
|
|
1914
|
+
partNumber,
|
|
1915
|
+
partSize,
|
|
1916
|
+
totalParts,
|
|
1917
|
+
signal,
|
|
1918
|
+
} = input
|
|
1919
|
+
const start = (partNumber - 1) * partSize
|
|
1920
|
+
const length = Math.min(fileInput.sizeBytes - start, partSize)
|
|
1921
|
+
const buffer = await readFileChunk(fileInput.path, start, length, signal)
|
|
1922
|
+
let signedPart = await signUploadPart(baseUrl, apiKey, uploadId, partNumber, signal)
|
|
1923
|
+
|
|
1924
|
+
for (let attempt = 1; attempt <= 4; attempt += 1) {
|
|
1925
|
+
assertNotAborted(signal)
|
|
1926
|
+
|
|
1927
|
+
try {
|
|
1928
|
+
const etag = await putSignedPartBuffer(signedPart.url, buffer, signal)
|
|
1929
|
+
|
|
1930
|
+
return {
|
|
1931
|
+
partNumber,
|
|
1932
|
+
etag,
|
|
1933
|
+
bytes: length,
|
|
1934
|
+
}
|
|
1935
|
+
} catch (error) {
|
|
1936
|
+
if (signal?.aborted || isAbortError(error)) {
|
|
1937
|
+
throw error
|
|
1938
|
+
}
|
|
1939
|
+
|
|
1940
|
+
if (error instanceof HttpCliError && error.status === 403 && attempt < 4) {
|
|
1941
|
+
signedPart = await signUploadPart(baseUrl, apiKey, uploadId, partNumber, signal)
|
|
1942
|
+
await sleep(getRetryDelayMs(attempt))
|
|
1943
|
+
continue
|
|
1944
|
+
}
|
|
1945
|
+
|
|
1946
|
+
if (isRetryableRequestError(error) && attempt < 4) {
|
|
1947
|
+
await sleep(getRetryDelayMs(attempt))
|
|
1948
|
+
continue
|
|
1949
|
+
}
|
|
1950
|
+
|
|
1951
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
1952
|
+
throw new Error(`第 ${partNumber}/${totalParts} 个分片上传失败:${message}`)
|
|
1953
|
+
}
|
|
1954
|
+
}
|
|
1955
|
+
|
|
1956
|
+
throw new Error(`第 ${partNumber}/${totalParts} 个分片上传失败`)
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1959
|
+
async function uploadFileParts(input) {
|
|
1960
|
+
const totalParts = Math.ceil(input.fileInput.sizeBytes / input.partSize)
|
|
1961
|
+
const workerCount = Math.min(MAX_UPLOAD_CONCURRENCY, totalParts)
|
|
1962
|
+
const completedParts = []
|
|
1963
|
+
let nextPartNumber = 1
|
|
1964
|
+
let completedPartCount = 0
|
|
1965
|
+
let uploadedBytes = 0
|
|
1966
|
+
let firstError = null
|
|
1967
|
+
|
|
1968
|
+
input.reporter.reportUploadProgress({
|
|
1969
|
+
uploadedBytes,
|
|
1970
|
+
totalBytes: input.fileInput.sizeBytes,
|
|
1971
|
+
completedParts: completedPartCount,
|
|
1972
|
+
totalParts,
|
|
1973
|
+
})
|
|
1974
|
+
|
|
1975
|
+
async function uploadNextPart() {
|
|
1976
|
+
while (nextPartNumber <= totalParts) {
|
|
1977
|
+
assertNotAborted(input.signal)
|
|
1978
|
+
|
|
1979
|
+
if (firstError) {
|
|
1980
|
+
throw firstError
|
|
1981
|
+
}
|
|
1982
|
+
|
|
1983
|
+
const partNumber = nextPartNumber
|
|
1984
|
+
nextPartNumber += 1
|
|
1985
|
+
|
|
1986
|
+
try {
|
|
1987
|
+
const completed = await uploadSinglePart({
|
|
1988
|
+
...input,
|
|
1989
|
+
partNumber,
|
|
1990
|
+
totalParts,
|
|
1991
|
+
})
|
|
1992
|
+
|
|
1993
|
+
completedParts.push({
|
|
1994
|
+
partNumber: completed.partNumber,
|
|
1995
|
+
etag: completed.etag,
|
|
1996
|
+
})
|
|
1997
|
+
uploadedBytes += completed.bytes
|
|
1998
|
+
completedPartCount += 1
|
|
1999
|
+
input.reporter.reportUploadProgress({
|
|
2000
|
+
uploadedBytes,
|
|
2001
|
+
totalBytes: input.fileInput.sizeBytes,
|
|
2002
|
+
completedParts: completedPartCount,
|
|
2003
|
+
totalParts,
|
|
2004
|
+
})
|
|
2005
|
+
} catch (error) {
|
|
2006
|
+
firstError = error
|
|
2007
|
+
throw error
|
|
2008
|
+
}
|
|
2009
|
+
}
|
|
2010
|
+
}
|
|
2011
|
+
|
|
2012
|
+
await Promise.all(Array.from({ length: workerCount }, () => uploadNextPart()))
|
|
2013
|
+
|
|
2014
|
+
return completedParts.sort((a, b) => a.partNumber - b.partNumber)
|
|
2015
|
+
}
|
|
2016
|
+
|
|
2017
|
+
async function uploadLocalFile(fileInput, args, baseUrl, apiKey, reporter, runContext) {
|
|
2018
|
+
let uploadId = ''
|
|
2019
|
+
|
|
2020
|
+
try {
|
|
2021
|
+
const fileHash = await calculateFileSha256(fileInput, reporter, runContext.signal)
|
|
2022
|
+
const startResponse = await requestJson(baseUrl, apiKey, '/api/cli/uploads/start', {
|
|
2023
|
+
method: 'POST',
|
|
2024
|
+
signal: runContext.signal,
|
|
2025
|
+
retry: true,
|
|
2026
|
+
body: {
|
|
2027
|
+
filename: fileInput.filename,
|
|
2028
|
+
mimeType: fileInput.mimeType,
|
|
2029
|
+
sizeBytes: fileInput.sizeBytes,
|
|
2030
|
+
fileHash,
|
|
2031
|
+
},
|
|
2032
|
+
})
|
|
2033
|
+
|
|
2034
|
+
uploadId = String(startResponse?.uploadId || '')
|
|
2035
|
+
runContext.state.uploadId = uploadId
|
|
2036
|
+
|
|
2037
|
+
const partSize = Number(startResponse?.partSize)
|
|
2038
|
+
|
|
2039
|
+
if (!uploadId || !Number.isFinite(partSize) || partSize <= 0) {
|
|
2040
|
+
throw new Error('上传初始化失败,请稍后重试')
|
|
2041
|
+
}
|
|
2042
|
+
|
|
2043
|
+
const totalParts = Math.ceil(fileInput.sizeBytes / partSize)
|
|
2044
|
+
|
|
2045
|
+
reporter.reportUploadStarted({
|
|
2046
|
+
uploadId,
|
|
2047
|
+
filename: fileInput.filename,
|
|
2048
|
+
mimeType: fileInput.mimeType,
|
|
2049
|
+
totalBytes: fileInput.sizeBytes,
|
|
2050
|
+
partSize,
|
|
2051
|
+
totalParts,
|
|
2052
|
+
})
|
|
2053
|
+
|
|
2054
|
+
const parts = await uploadFileParts({
|
|
2055
|
+
baseUrl,
|
|
2056
|
+
apiKey,
|
|
2057
|
+
uploadId,
|
|
2058
|
+
fileInput,
|
|
2059
|
+
partSize,
|
|
2060
|
+
signal: runContext.signal,
|
|
2061
|
+
reporter,
|
|
2062
|
+
})
|
|
2063
|
+
|
|
2064
|
+
await requestJson(baseUrl, apiKey, '/api/cli/uploads/complete', {
|
|
2065
|
+
method: 'POST',
|
|
2066
|
+
signal: runContext.signal,
|
|
2067
|
+
retry: true,
|
|
2068
|
+
body: {
|
|
2069
|
+
uploadId,
|
|
2070
|
+
parts,
|
|
2071
|
+
},
|
|
2072
|
+
})
|
|
2073
|
+
|
|
2074
|
+
reporter.reportUploadCompleted({
|
|
2075
|
+
uploadId,
|
|
2076
|
+
filename: fileInput.filename,
|
|
2077
|
+
totalBytes: fileInput.sizeBytes,
|
|
2078
|
+
})
|
|
2079
|
+
runContext.state.uploadAbortAllowed = false
|
|
2080
|
+
reporter.reportUploadChecking({ uploadId })
|
|
2081
|
+
|
|
2082
|
+
const checkResult = await requestJson(baseUrl, apiKey, '/api/cli/uploads/check', {
|
|
2083
|
+
method: 'POST',
|
|
2084
|
+
signal: runContext.signal,
|
|
2085
|
+
retry: true,
|
|
2086
|
+
body: {
|
|
2087
|
+
uploadId,
|
|
2088
|
+
language: args.language || DEFAULT_LANGUAGE,
|
|
2089
|
+
},
|
|
2090
|
+
})
|
|
2091
|
+
const checkedTask = getUploadTaskFromCheckResult(checkResult)
|
|
2092
|
+
|
|
2093
|
+
if (checkedTask) {
|
|
2094
|
+
runContext.state.taskId = checkedTask.taskId || ''
|
|
2095
|
+
runContext.state.taskCreated = true
|
|
2096
|
+
}
|
|
2097
|
+
|
|
2098
|
+
reporter.reportUploadReady(getUploadReadySummary(checkResult, fileInput, uploadId))
|
|
2099
|
+
|
|
2100
|
+
return {
|
|
2101
|
+
...checkResult,
|
|
2102
|
+
uploadId,
|
|
2103
|
+
}
|
|
2104
|
+
} catch (error) {
|
|
2105
|
+
if (runContext.state.interrupting || isAbortError(error)) {
|
|
2106
|
+
throw new ReportedCliError('已中断', 130)
|
|
2107
|
+
}
|
|
2108
|
+
|
|
2109
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
2110
|
+
reporter.reportUploadFailed(`上传失败:${message}`, { uploadId })
|
|
2111
|
+
|
|
2112
|
+
if (uploadId && runContext.state.uploadAbortAllowed) {
|
|
2113
|
+
await abortUploadOnServer(baseUrl, apiKey, uploadId).catch(() => null)
|
|
2114
|
+
}
|
|
2115
|
+
|
|
2116
|
+
throw error
|
|
2117
|
+
}
|
|
2118
|
+
}
|
|
2119
|
+
|
|
861
2120
|
async function handleAuthLogin(args) {
|
|
862
2121
|
if (!args.apiKey) {
|
|
863
2122
|
printAuthUsage()
|
|
@@ -930,87 +2189,311 @@ async function handleAuthCommand(argv) {
|
|
|
930
2189
|
fail(`不支持的 auth 子命令:${args.action}`)
|
|
931
2190
|
}
|
|
932
2191
|
|
|
933
|
-
|
|
934
|
-
const
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
const resolvedApiKey = resolveApiKey(args.apiKey, storedConfig)
|
|
2192
|
+
function buildUrlTaskCreatePayload(args, input) {
|
|
2193
|
+
const createPayload = {
|
|
2194
|
+
url: input.url,
|
|
2195
|
+
language: args.language || DEFAULT_LANGUAGE,
|
|
2196
|
+
}
|
|
939
2197
|
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
2198
|
+
if (args.source) {
|
|
2199
|
+
createPayload.source = args.source
|
|
2200
|
+
}
|
|
2201
|
+
|
|
2202
|
+
return createPayload
|
|
2203
|
+
}
|
|
2204
|
+
|
|
2205
|
+
async function createCliTask(baseUrl, apiKey, createPayload, reporter, runContext) {
|
|
2206
|
+
const created = await requestJson(baseUrl, apiKey, '/api/cli/transcriptions', {
|
|
2207
|
+
method: 'POST',
|
|
2208
|
+
signal: runContext.signal,
|
|
2209
|
+
body: createPayload,
|
|
2210
|
+
})
|
|
2211
|
+
|
|
2212
|
+
runContext.state.taskId = created.taskId || ''
|
|
2213
|
+
runContext.state.taskCreated = true
|
|
2214
|
+
reporter.reportTaskCreated(created)
|
|
2215
|
+
|
|
2216
|
+
return created
|
|
2217
|
+
}
|
|
2218
|
+
|
|
2219
|
+
function reportTaskStatusTransition(reporter, previousStatus, task) {
|
|
2220
|
+
if (task.status === previousStatus) {
|
|
2221
|
+
return task.status
|
|
2222
|
+
}
|
|
2223
|
+
|
|
2224
|
+
if (TERMINAL_STATUSES.has(task.status)) {
|
|
2225
|
+
reporter.reportTerminal(task.status, task.error || null)
|
|
2226
|
+
} else {
|
|
2227
|
+
reporter.reportStatusChanged(task.status)
|
|
2228
|
+
}
|
|
2229
|
+
|
|
2230
|
+
return task.status
|
|
2231
|
+
}
|
|
2232
|
+
|
|
2233
|
+
async function waitForTranscriptionTask(baseUrl, apiKey, initialTask, reporter, runContext, transcriptState) {
|
|
2234
|
+
let currentTask = initialTask
|
|
2235
|
+
let lastStatus = currentTask.status
|
|
2236
|
+
let lastSseError = null
|
|
2237
|
+
|
|
2238
|
+
for (let attempt = 0; attempt <= SSE_RECONNECT_DELAYS_MS.length; attempt += 1) {
|
|
2239
|
+
if (attempt > 0) {
|
|
2240
|
+
const delayMs = SSE_RECONNECT_DELAYS_MS[attempt - 1]
|
|
2241
|
+
reporter.reportNotice(`事件流中断,${delayMs / 1000} 秒后重连${lastSseError ? `:${lastSseError}` : ''}`)
|
|
2242
|
+
await sleep(delayMs)
|
|
943
2243
|
}
|
|
944
2244
|
|
|
945
|
-
if (
|
|
946
|
-
|
|
947
|
-
fail('缺少内容链接')
|
|
2245
|
+
if (runContext.state.interrupting) {
|
|
2246
|
+
throw new ReportedCliError('已中断', 130)
|
|
948
2247
|
}
|
|
949
2248
|
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
2249
|
+
try {
|
|
2250
|
+
currentTask = await consumeTaskEvents(
|
|
2251
|
+
baseUrl,
|
|
2252
|
+
apiKey,
|
|
2253
|
+
currentTask.taskId,
|
|
2254
|
+
currentTask,
|
|
2255
|
+
reporter,
|
|
2256
|
+
runContext.signal,
|
|
2257
|
+
transcriptState,
|
|
2258
|
+
)
|
|
2259
|
+
lastStatus = currentTask.status
|
|
2260
|
+
|
|
2261
|
+
if (TERMINAL_STATUSES.has(currentTask.status)) {
|
|
2262
|
+
return currentTask
|
|
2263
|
+
}
|
|
2264
|
+
|
|
2265
|
+
lastSseError = '服务端在任务结束前关闭了事件流'
|
|
2266
|
+
} catch (error) {
|
|
2267
|
+
if (runContext.state.interrupting || isAbortError(error)) {
|
|
2268
|
+
throw new ReportedCliError('已中断', 130)
|
|
2269
|
+
}
|
|
2270
|
+
|
|
2271
|
+
if (!isRetryableRequestError(error)) {
|
|
2272
|
+
throw error
|
|
2273
|
+
}
|
|
2274
|
+
|
|
2275
|
+
lastSseError = error instanceof Error ? error.message : String(error)
|
|
955
2276
|
}
|
|
2277
|
+
}
|
|
2278
|
+
|
|
2279
|
+
reporter.reportNotice(`事件流连续中断,回退到状态轮询${transcriptState ? '和正文预览' : ''}`)
|
|
2280
|
+
|
|
2281
|
+
let previewAvailable = !!transcriptState
|
|
2282
|
+
let lastSseRecoveryAt = Date.now()
|
|
956
2283
|
|
|
957
|
-
|
|
958
|
-
|
|
2284
|
+
while (!TERMINAL_STATUSES.has(currentTask.status)) {
|
|
2285
|
+
await sleep(POLL_INTERVAL_MS)
|
|
2286
|
+
|
|
2287
|
+
if (runContext.state.interrupting) {
|
|
2288
|
+
throw new ReportedCliError('已中断', 130)
|
|
959
2289
|
}
|
|
960
2290
|
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
2291
|
+
try {
|
|
2292
|
+
currentTask = await requestJson(
|
|
2293
|
+
baseUrl,
|
|
2294
|
+
apiKey,
|
|
2295
|
+
`/api/cli/transcriptions/${encodeURIComponent(currentTask.taskId)}`,
|
|
2296
|
+
{
|
|
2297
|
+
signal: runContext.signal,
|
|
2298
|
+
retry: true,
|
|
2299
|
+
},
|
|
2300
|
+
)
|
|
2301
|
+
lastStatus = reportTaskStatusTransition(reporter, lastStatus, currentTask)
|
|
2302
|
+
} catch (error) {
|
|
2303
|
+
if (runContext.state.interrupting || isAbortError(error)) {
|
|
2304
|
+
throw new ReportedCliError('已中断', 130)
|
|
2305
|
+
}
|
|
966
2306
|
|
|
967
|
-
|
|
968
|
-
|
|
2307
|
+
if (!isRetryableRequestError(error)) {
|
|
2308
|
+
throw error
|
|
2309
|
+
}
|
|
969
2310
|
|
|
970
|
-
|
|
2311
|
+
reporter.reportNotice(`状态轮询失败,将继续重试:${error instanceof Error ? error.message : String(error)}`)
|
|
2312
|
+
}
|
|
2313
|
+
|
|
2314
|
+
if (TERMINAL_STATUSES.has(currentTask.status)) {
|
|
2315
|
+
break
|
|
2316
|
+
}
|
|
2317
|
+
|
|
2318
|
+
if (previewAvailable && transcriptState) {
|
|
971
2319
|
try {
|
|
972
|
-
|
|
973
|
-
|
|
2320
|
+
const preview = await requestTranscriptPreview(baseUrl, apiKey, currentTask.taskId, {
|
|
2321
|
+
signal: runContext.signal,
|
|
2322
|
+
})
|
|
2323
|
+
|
|
2324
|
+
if (!preview.available) {
|
|
2325
|
+
previewAvailable = false
|
|
2326
|
+
reporter.reportNotice('当前服务端不支持 CLI 正文预览,将继续等待最终结果')
|
|
2327
|
+
} else if (preview.text !== null) {
|
|
2328
|
+
transcriptState.applyPreviewSnapshot(preview.text)
|
|
2329
|
+
}
|
|
974
2330
|
} catch (error) {
|
|
975
|
-
|
|
2331
|
+
if (runContext.state.interrupting || isAbortError(error)) {
|
|
2332
|
+
throw new ReportedCliError('已中断', 130)
|
|
2333
|
+
}
|
|
2334
|
+
|
|
2335
|
+
if (!isRetryableRequestError(error)) {
|
|
2336
|
+
throw error
|
|
2337
|
+
}
|
|
2338
|
+
|
|
2339
|
+
reporter.reportNotice(`正文预览读取失败,将继续重试:${error instanceof Error ? error.message : String(error)}`)
|
|
976
2340
|
}
|
|
977
2341
|
}
|
|
978
2342
|
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
2343
|
+
if (Date.now() - lastSseRecoveryAt < SSE_RECOVERY_INTERVAL_MS) {
|
|
2344
|
+
continue
|
|
2345
|
+
}
|
|
982
2346
|
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
2347
|
+
lastSseRecoveryAt = Date.now()
|
|
2348
|
+
|
|
2349
|
+
try {
|
|
2350
|
+
reporter.reportNotice('正在尝试恢复事件流')
|
|
2351
|
+
currentTask = await consumeTaskEvents(
|
|
2352
|
+
baseUrl,
|
|
2353
|
+
apiKey,
|
|
2354
|
+
currentTask.taskId,
|
|
2355
|
+
currentTask,
|
|
2356
|
+
reporter,
|
|
2357
|
+
runContext.signal,
|
|
2358
|
+
transcriptState,
|
|
2359
|
+
)
|
|
2360
|
+
lastStatus = currentTask.status
|
|
2361
|
+
|
|
2362
|
+
if (TERMINAL_STATUSES.has(currentTask.status)) {
|
|
2363
|
+
return currentTask
|
|
2364
|
+
}
|
|
2365
|
+
|
|
2366
|
+
reporter.reportNotice('事件流再次提前结束,将继续轮询')
|
|
2367
|
+
} catch (error) {
|
|
2368
|
+
if (runContext.state.interrupting || isAbortError(error)) {
|
|
2369
|
+
throw new ReportedCliError('已中断', 130)
|
|
2370
|
+
}
|
|
2371
|
+
|
|
2372
|
+
if (!isRetryableRequestError(error)) {
|
|
2373
|
+
throw error
|
|
990
2374
|
}
|
|
2375
|
+
|
|
2376
|
+
reporter.reportNotice(`事件流恢复失败,将继续轮询:${error instanceof Error ? error.message : String(error)}`)
|
|
991
2377
|
}
|
|
2378
|
+
}
|
|
992
2379
|
|
|
993
|
-
|
|
2380
|
+
return currentTask
|
|
2381
|
+
}
|
|
2382
|
+
|
|
2383
|
+
async function waitForTaskAndOutput(baseUrl, apiKey, task, args, format, reporter, runContext) {
|
|
2384
|
+
let currentTask = task
|
|
2385
|
+
const transcriptState = args.streamTranscript
|
|
2386
|
+
? createTranscriptStreamState(currentTask.taskId, reporter)
|
|
2387
|
+
: null
|
|
2388
|
+
|
|
2389
|
+
if (currentTask.mode === 'transcription' && !TERMINAL_STATUSES.has(currentTask.status)) {
|
|
2390
|
+
currentTask = await waitForTranscriptionTask(
|
|
2391
|
+
baseUrl,
|
|
2392
|
+
apiKey,
|
|
2393
|
+
currentTask,
|
|
2394
|
+
reporter,
|
|
2395
|
+
runContext,
|
|
2396
|
+
transcriptState,
|
|
2397
|
+
)
|
|
2398
|
+
}
|
|
2399
|
+
|
|
2400
|
+
reporter.reportTerminal(currentTask.status, currentTask.error || null)
|
|
2401
|
+
|
|
2402
|
+
if (currentTask.status !== 'completed') {
|
|
2403
|
+
throw new ReportedCliError(currentTask.error || `任务未成功完成:${formatStatus(currentTask.status)}`)
|
|
2404
|
+
}
|
|
2405
|
+
|
|
2406
|
+
const result = await requestResult(baseUrl, apiKey, currentTask.taskId, format, {
|
|
2407
|
+
signal: runContext.signal,
|
|
2408
|
+
})
|
|
2409
|
+
|
|
2410
|
+
if (args.output) {
|
|
2411
|
+
const outputPath = await writeOutput(args.output, result)
|
|
2412
|
+
transcriptState?.complete(result)
|
|
2413
|
+
reporter.reportResultWritten(outputPath, format)
|
|
2414
|
+
return
|
|
2415
|
+
}
|
|
2416
|
+
|
|
2417
|
+
transcriptState?.complete(result)
|
|
2418
|
+
reporter.close()
|
|
2419
|
+
process.stdout.write(result)
|
|
2420
|
+
|
|
2421
|
+
if (!result.endsWith('\n')) {
|
|
2422
|
+
process.stdout.write('\n')
|
|
2423
|
+
}
|
|
2424
|
+
}
|
|
2425
|
+
|
|
2426
|
+
async function handleTranscription(argv) {
|
|
2427
|
+
const args = parseTranscriptionArgs(argv)
|
|
2428
|
+
const format = inferFormat(args.output, args.format)
|
|
2429
|
+
validateTranscriptStreaming(args, format)
|
|
2430
|
+
const reporter = createProgressReporter(args.progressFormat)
|
|
2431
|
+
let runContext = null
|
|
2432
|
+
|
|
2433
|
+
try {
|
|
2434
|
+
const storedConfig = await readStoredConfig()
|
|
2435
|
+
const resolvedApiKey = resolveApiKey(args.apiKey, storedConfig)
|
|
994
2436
|
|
|
995
|
-
if (
|
|
996
|
-
|
|
2437
|
+
if (!resolvedApiKey) {
|
|
2438
|
+
printUsage()
|
|
2439
|
+
fail('缺少 API key。请传 --api-key、设置 DEYO_API_KEY,或先执行 deyo auth login')
|
|
997
2440
|
}
|
|
998
2441
|
|
|
999
|
-
|
|
2442
|
+
if (!args.url && !args.file) {
|
|
2443
|
+
printUsage()
|
|
2444
|
+
fail('缺少内容链接或本地文件')
|
|
2445
|
+
}
|
|
1000
2446
|
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
2447
|
+
const { baseUrl } = resolveBaseUrl(args.baseUrl, storedConfig)
|
|
2448
|
+
const input = await resolveTranscriptionInput(args)
|
|
2449
|
+
|
|
2450
|
+
if (!input) {
|
|
2451
|
+
printUsage()
|
|
2452
|
+
fail('缺少内容链接或本地文件')
|
|
1005
2453
|
}
|
|
1006
2454
|
|
|
1007
|
-
reporter
|
|
1008
|
-
|
|
2455
|
+
runContext = createRunContext(baseUrl, resolvedApiKey.apiKey, reporter)
|
|
2456
|
+
let createPayload
|
|
2457
|
+
let created
|
|
1009
2458
|
|
|
1010
|
-
if (
|
|
1011
|
-
|
|
2459
|
+
if (input.kind === 'file') {
|
|
2460
|
+
if (args.source && args.source !== 'upload') {
|
|
2461
|
+
fail('本地文件转写不支持 --source;上传来源会固定为 upload')
|
|
2462
|
+
}
|
|
2463
|
+
|
|
2464
|
+
const checkResult = await uploadLocalFile(
|
|
2465
|
+
input,
|
|
2466
|
+
args,
|
|
2467
|
+
baseUrl,
|
|
2468
|
+
resolvedApiKey.apiKey,
|
|
2469
|
+
reporter,
|
|
2470
|
+
runContext,
|
|
2471
|
+
)
|
|
2472
|
+
const checkedTask = getUploadTaskFromCheckResult(checkResult)
|
|
2473
|
+
|
|
2474
|
+
if (checkedTask) {
|
|
2475
|
+
runContext.state.taskId = checkedTask.taskId || ''
|
|
2476
|
+
runContext.state.taskCreated = true
|
|
2477
|
+
reporter.reportTaskCreated(checkedTask)
|
|
2478
|
+
created = checkedTask
|
|
2479
|
+
} else {
|
|
2480
|
+
throw new Error('媒体检查通过但服务端没有返回上传转写任务')
|
|
2481
|
+
}
|
|
2482
|
+
} else {
|
|
2483
|
+
createPayload = buildUrlTaskCreatePayload(args, input)
|
|
1012
2484
|
}
|
|
2485
|
+
|
|
2486
|
+
if (!created) {
|
|
2487
|
+
created = await createCliTask(baseUrl, resolvedApiKey.apiKey, createPayload, reporter, runContext)
|
|
2488
|
+
}
|
|
2489
|
+
|
|
2490
|
+
await waitForTaskAndOutput(baseUrl, resolvedApiKey.apiKey, created, args, format, reporter, runContext)
|
|
1013
2491
|
} catch (error) {
|
|
2492
|
+
if (runContext?.state.interrupting || isAbortError(error)) {
|
|
2493
|
+
await runContext?.state.interruptPromise?.catch(() => {})
|
|
2494
|
+
throw new ReportedCliError('已中断', 130)
|
|
2495
|
+
}
|
|
2496
|
+
|
|
1014
2497
|
if (error instanceof ReportedCliError) {
|
|
1015
2498
|
throw error
|
|
1016
2499
|
}
|
|
@@ -1021,6 +2504,8 @@ async function handleTranscription(argv) {
|
|
|
1021
2504
|
}
|
|
1022
2505
|
|
|
1023
2506
|
throw error
|
|
2507
|
+
} finally {
|
|
2508
|
+
runContext?.dispose()
|
|
1024
2509
|
}
|
|
1025
2510
|
}
|
|
1026
2511
|
|
|
@@ -1043,12 +2528,21 @@ async function main() {
|
|
|
1043
2528
|
return
|
|
1044
2529
|
}
|
|
1045
2530
|
|
|
2531
|
+
if (primaryCommand === 'skill') {
|
|
2532
|
+
await handleSkillCommand(argv, CLI_VERSION)
|
|
2533
|
+
return
|
|
2534
|
+
}
|
|
2535
|
+
|
|
1046
2536
|
await handleTranscription(argv)
|
|
1047
2537
|
}
|
|
1048
2538
|
|
|
1049
2539
|
main().catch((error) => {
|
|
2540
|
+
if (error?.name === 'SkillCommandReportedError') {
|
|
2541
|
+
process.exit(error.exitCode || 1)
|
|
2542
|
+
}
|
|
2543
|
+
|
|
1050
2544
|
if (error instanceof ReportedCliError) {
|
|
1051
|
-
process.exit(1)
|
|
2545
|
+
process.exit(error.exitCode || 1)
|
|
1052
2546
|
}
|
|
1053
2547
|
|
|
1054
2548
|
const message = error instanceof Error ? error.message : String(error)
|