@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.
Files changed (4) hide show
  1. package/README.md +188 -10
  2. package/deyo.mjs +1648 -154
  3. package/package.json +7 -4
  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 ReportedCliError extends Error {}
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
- if (args.url) {
141
- fail('只能提供一个内容链接')
142
- }
233
+ args.positional.push(current)
234
+ }
235
+
236
+ if (args.positional.length > 1) {
237
+ fail('v1 只支持提交一个链接或一个本地文件,不支持批量转写')
238
+ }
143
239
 
144
- args.url = current
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
- const response = await fetch(`${baseUrl}${path}`, {
412
- method: options.method || 'GET',
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
- return response.json()
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 requestResult(baseUrl, apiKey, taskId, format) {
429
- const response = await fetch(`${baseUrl}/api/cli/transcriptions/${encodeURIComponent(taskId)}/result?format=${encodeURIComponent(format)}`, {
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 (!response.ok) {
437
- throw new Error(await readErrorMessage(response))
864
+ if (response.status === 204) {
865
+ return {
866
+ available: true,
867
+ text: null,
868
+ }
438
869
  }
439
870
 
440
- if (format === 'json' || format === 'verbose_json') {
441
- return JSON.stringify(await response.json(), null, 2)
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
- return response.text()
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: `任务已创建:${task.taskId}`,
603
- taskId: task.taskId,
604
- title: task.title,
605
- sourceType: task.sourceType,
606
- status: task.status,
607
- statusLabel: formatStatus(task.status),
608
- consumedMinutes: task.consumedMinutes,
609
- remainingMinutes: task.remainingMinutes,
610
- mode: task.mode,
611
- resultReady: task.resultReady,
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(`任务已创建:${task.taskId}`)
617
- writeTextMessage(`标题:${task.title}`)
618
- writeTextMessage(`来源:${task.sourceType}`)
619
- writeTextMessage(`状态:${formatStatus(task.status)}`)
620
- writeTextMessage(`本次消耗分钟:${task.consumedMinutes}`)
621
- writeTextMessage(`剩余分钟:${task.remainingMinutes}`)
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
- reportProgress(input = {}) {
631
- const finishedChunks = Number(input.finishedChunks)
632
- const totalChunks = Number(input.totalChunks)
633
- const normalizedProgress = normalizeProgressValue(
634
- Number(input.progress),
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: 'task.progress',
1151
+ event: 'upload.hashing',
650
1152
  message,
651
- status: 'transcribing',
652
- progress: normalizedProgress,
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
- async function consumeTaskEvents(baseUrl, apiKey, taskId, initialTask, reporter) {
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 Error(await readErrorMessage(response))
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
- while (true) {
790
- const { value, done } = await reader.read()
1582
+ function processSseBlock(block) {
1583
+ const normalizedBlock = block.replace(/\r\n?/g, '\n').trim()
791
1584
 
792
- if (done) {
793
- break
1585
+ if (!normalizedBlock) {
1586
+ return false
794
1587
  }
795
1588
 
796
- buffer += decoder.decode(value, { stream: true })
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
- while (separatorIndex >= 0) {
802
- const block = buffer.slice(0, separatorIndex).trim()
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 (!block) {
807
- continue
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
- const { event, data } = parseSseBlock(block)
1604
+ if (event === 'job.status_changed') {
1605
+ applyStatus(data?.status)
1606
+ return TERMINAL_STATUSES.has(data?.status)
1607
+ }
811
1608
 
812
- if (event === 'job.snapshot') {
813
- if (data?.status === 'transcribing') {
814
- renderProgress(data)
815
- } else if (TERMINAL_STATUSES.has(data?.status)) {
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
- if (event === 'job.status_changed') {
824
- applyStatus(data?.status)
825
- continue
826
- }
1614
+ if (event === 'transcript.delta') {
1615
+ transcriptState?.applySseSnapshot(data?.committedText)
1616
+ return false
1617
+ }
827
1618
 
828
- if (event === 'job.progress') {
829
- renderProgress(data)
830
- continue
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
- if (event === 'job.completed') {
834
- applyTerminal('completed', null)
835
- return task
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
- if (event === 'job.failed') {
839
- applyTerminal('failed', data?.error || data?.message || '转写失败')
840
- return task
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
- if (event === 'job.cancelled') {
844
- applyTerminal('cancelled', '转写任务已取消')
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
- async function handleTranscription(argv) {
934
- const args = parseTranscriptionArgs(argv)
935
- const reporter = createProgressReporter(args.progressFormat)
936
- try {
937
- const storedConfig = await readStoredConfig()
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
- if (!resolvedApiKey) {
941
- printUsage()
942
- fail('缺少 API key。请传 --api-key、设置 DEYO_API_KEY,或先执行 deyo auth login')
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 (!args.url) {
946
- printUsage()
947
- fail('缺少内容链接')
2245
+ if (runContext.state.interrupting) {
2246
+ throw new ReportedCliError('已中断', 130)
948
2247
  }
949
2248
 
950
- const { baseUrl } = resolveBaseUrl(args.baseUrl, storedConfig)
951
- const format = inferFormat(args.output, args.format)
952
- const createPayload = {
953
- url: args.url,
954
- language: args.language || DEFAULT_LANGUAGE,
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
- if (args.source) {
958
- createPayload.source = args.source
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
- const created = await requestJson(baseUrl, resolvedApiKey.apiKey, '/api/cli/transcriptions', {
962
- method: 'POST',
963
- body: createPayload,
964
- })
965
- reporter.reportTaskCreated(created)
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
- let task = created
968
- let lastStatus = created.status
2307
+ if (!isRetryableRequestError(error)) {
2308
+ throw error
2309
+ }
969
2310
 
970
- if (task.mode === 'transcription' && !TERMINAL_STATUSES.has(task.status)) {
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
- task = await consumeTaskEvents(baseUrl, resolvedApiKey.apiKey, task.taskId, task, reporter)
973
- lastStatus = task.status
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
- reporter.reportNotice(`事件流中断,回退到轮询状态:${error instanceof Error ? error.message : String(error)}`)
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
- while (!TERMINAL_STATUSES.has(task.status)) {
980
- await sleep(POLL_INTERVAL_MS)
981
- task = await requestJson(baseUrl, resolvedApiKey.apiKey, `/api/cli/transcriptions/${encodeURIComponent(task.taskId)}`)
2343
+ if (Date.now() - lastSseRecoveryAt < SSE_RECOVERY_INTERVAL_MS) {
2344
+ continue
2345
+ }
982
2346
 
983
- if (task.status !== lastStatus) {
984
- if (TERMINAL_STATUSES.has(task.status)) {
985
- reporter.reportTerminal(task.status, task.error || null)
986
- } else {
987
- reporter.reportStatusChanged(task.status)
988
- }
989
- lastStatus = task.status
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
- reporter.reportTerminal(task.status, task.error || null)
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 (task.status !== 'completed') {
996
- throw new ReportedCliError(task.error || `任务未成功完成:${formatStatus(task.status)}`)
2437
+ if (!resolvedApiKey) {
2438
+ printUsage()
2439
+ fail('缺少 API key。请传 --api-key、设置 DEYO_API_KEY,或先执行 deyo auth login')
997
2440
  }
998
2441
 
999
- const result = await requestResult(baseUrl, resolvedApiKey.apiKey, task.taskId, format)
2442
+ if (!args.url && !args.file) {
2443
+ printUsage()
2444
+ fail('缺少内容链接或本地文件')
2445
+ }
1000
2446
 
1001
- if (args.output) {
1002
- const outputPath = await writeOutput(args.output, result)
1003
- reporter.reportResultWritten(outputPath, format)
1004
- return
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.close()
1008
- process.stdout.write(result)
2455
+ runContext = createRunContext(baseUrl, resolvedApiKey.apiKey, reporter)
2456
+ let createPayload
2457
+ let created
1009
2458
 
1010
- if (!result.endsWith('\n')) {
1011
- process.stdout.write('\n')
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)