@casatwy/deyo 0.1.3 → 0.1.4

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 (3) hide show
  1. package/README.md +22 -1
  2. package/deyo.mjs +270 -67
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -93,6 +93,12 @@ deyo -O ./transcript.txt 'https://www.bilibili.com/video/BVxxxx'
93
93
  deyo --format srt -O ./transcript.srt 'https://www.youtube.com/watch?v=xxxx'
94
94
  ```
95
95
 
96
+ 为 AI 或日志系统输出结构化进度:
97
+
98
+ ```bash
99
+ deyo --progress-format jsonl -O ./transcript.txt 'https://www.youtube.com/watch?v=xxxx'
100
+ ```
101
+
96
102
  如果输出文件名本身就是 `.srt`,也可以省略 `--format srt`,CLI 会自动识别格式。
97
103
 
98
104
  指定输出英文转写结果:
@@ -131,6 +137,21 @@ deyo --api-key 'deyo_sk_other' 'https://www.youtube.com/watch?v=xxxx'
131
137
  - `.vtt` 会自动使用 `vtt`
132
138
  - 其他情况默认使用 `text`
133
139
 
140
+ `--progress-format <value>`
141
+
142
+ 指定进度输出格式,支持:
143
+
144
+ - `auto`
145
+ - `text`
146
+ - `jsonl`
147
+
148
+ 默认值是 `auto`:
149
+
150
+ - 当 stderr 是 TTY 时,会保留当前的单行原地刷新进度体验
151
+ - 当 stderr 不是 TTY 时,会退化成逐行文本进度,避免控制字符污染日志或代理输出
152
+
153
+ 如果你希望 AI 或脚本稳定读取进度事件,建议使用 `--progress-format jsonl`。此时 CLI 会继续把最终结果输出到 stdout 或文件,只把进度事件按 JSON Lines 写到 stderr。
154
+
134
155
  `--api-key <key>`
135
156
 
136
157
  为当前这一次命令临时指定 API Key。
@@ -154,7 +175,7 @@ deyo --api-key 'deyo_sk_other' 'https://www.youtube.com/watch?v=xxxx'
154
175
  2. 如何向你索取一次 API Key
155
176
  3. 如何在你的本地环境执行 `deyo auth login`
156
177
 
157
- 完成后,你就可以让 AI 直接帮你调用 `deyo` 提交转写任务。
178
+ 完成后,你就可以让 AI 直接帮你调用 `deyo` 提交转写任务。对于需要在对话里持续同步进度的场景,AI 应优先使用 `--progress-format jsonl`,读取 stderr 里的结构化事件,再把任务创建、状态切换、关键百分比和最终结果转述给你。
158
179
 
159
180
  ## 更多帮助
160
181
 
package/deyo.mjs CHANGED
@@ -11,9 +11,12 @@ const CLI_VERSION = createRequire(import.meta.url)('./package.json').version
11
11
  const DEFAULT_BASE_URL = 'https://deyo.miaobi.fun'
12
12
  const DEFAULT_LANGUAGE = 'zh'
13
13
  const POLL_INTERVAL_MS = 1500
14
+ const PROGRESS_FORMATS = new Set(['auto', 'text', 'jsonl'])
14
15
  const RESULT_FORMATS = new Set(['text', 'srt', 'vtt', 'json', 'verbose_json'])
15
16
  const TERMINAL_STATUSES = new Set(['completed', 'failed', 'cancelled'])
16
17
 
18
+ class ReportedCliError extends Error {}
19
+
17
20
  function safeJsonParse(value) {
18
21
  try {
19
22
  return JSON.parse(value)
@@ -38,6 +41,7 @@ function printUsage() {
38
41
  --source <name> 可选,显式指定来源
39
42
  --language <value> 可选,转写语言,默认 ${DEFAULT_LANGUAGE}
40
43
  --format <value> 可选,text | srt | vtt | json | verbose_json
44
+ --progress-format <v> 可选,auto | text | jsonl,默认 auto
41
45
  --base-url <url> 可选,默认 ${DEFAULT_BASE_URL}
42
46
  -h, --help 显示帮助
43
47
  -v, --version 显示 CLI 版本
@@ -74,6 +78,7 @@ function parseTranscriptionArgs(argv) {
74
78
  source: '',
75
79
  language: '',
76
80
  format: '',
81
+ progressFormat: 'auto',
77
82
  baseUrl: '',
78
83
  url: '',
79
84
  }
@@ -116,6 +121,12 @@ function parseTranscriptionArgs(argv) {
116
121
  continue
117
122
  }
118
123
 
124
+ if (current === '--progress-format') {
125
+ args.progressFormat = argv[index + 1] || ''
126
+ index += 1
127
+ continue
128
+ }
129
+
119
130
  if (current === '--base-url') {
120
131
  args.baseUrl = argv[index + 1] || ''
121
132
  index += 1
@@ -456,6 +467,20 @@ function createOutputState() {
456
467
  }
457
468
  }
458
469
 
470
+ function resolveProgressFormat(progressFormat) {
471
+ const normalized = String(progressFormat || 'auto').trim() || 'auto'
472
+
473
+ if (!PROGRESS_FORMATS.has(normalized)) {
474
+ fail(`--progress-format 不支持:${normalized}`)
475
+ }
476
+
477
+ if (normalized === 'auto') {
478
+ return process.stderr.isTTY ? 'interactive' : 'text'
479
+ }
480
+
481
+ return normalized
482
+ }
483
+
459
484
  function clearInline(outputState) {
460
485
  if (!outputState.inlineActive) {
461
486
  return
@@ -484,6 +509,26 @@ function finishInline(outputState) {
484
509
  outputState.inlineActive = false
485
510
  }
486
511
 
512
+ function buildStatusChangedMessage(status) {
513
+ return `状态更新:${formatStatus(status)}`
514
+ }
515
+
516
+ function buildTerminalMessage(status, errorMessage) {
517
+ if (status === 'completed') {
518
+ return '任务已完成'
519
+ }
520
+
521
+ if (status === 'failed') {
522
+ return errorMessage ? `任务失败:${errorMessage}` : '任务失败'
523
+ }
524
+
525
+ if (status === 'cancelled') {
526
+ return errorMessage ? `任务已取消:${errorMessage}` : '任务已取消'
527
+ }
528
+
529
+ return errorMessage ? `任务结束:${errorMessage}` : `任务结束:${formatStatus(status)}`
530
+ }
531
+
487
532
  function normalizeProgressValue(progress, finishedChunks, totalChunks) {
488
533
  if (Number.isFinite(progress) && progress >= 0) {
489
534
  return Math.max(0, Math.min(1, progress))
@@ -521,6 +566,142 @@ function buildTranscribingProgressMessage(input = {}) {
521
566
  return `转写中 ${percent}%${chunksPart}`
522
567
  }
523
568
 
569
+ function createProgressReporter(progressFormat) {
570
+ const resolvedMode = resolveProgressFormat(progressFormat)
571
+ const outputState = createOutputState()
572
+ let lastTextProgressMessage = ''
573
+ let lastTerminalSignature = ''
574
+
575
+ function writeTextMessage(message) {
576
+ writeStatusLine(outputState, message)
577
+ }
578
+
579
+ function writeJsonEvent(payload) {
580
+ clearInline(outputState)
581
+ process.stderr.write(`${JSON.stringify(payload)}\n`)
582
+ }
583
+
584
+ function emitTextOrJson(message, payload) {
585
+ if (resolvedMode === 'jsonl') {
586
+ writeJsonEvent({
587
+ ...payload,
588
+ message,
589
+ })
590
+ return
591
+ }
592
+
593
+ writeTextMessage(message)
594
+ }
595
+
596
+ return {
597
+ mode: resolvedMode,
598
+ reportTaskCreated(task) {
599
+ if (resolvedMode === 'jsonl') {
600
+ writeJsonEvent({
601
+ 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,
612
+ })
613
+ return
614
+ }
615
+
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
+ })
629
+ },
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 = ''
641
+
642
+ if (resolvedMode === 'interactive') {
643
+ writeInline(outputState, message)
644
+ return
645
+ }
646
+
647
+ if (resolvedMode === 'jsonl') {
648
+ writeJsonEvent({
649
+ event: 'task.progress',
650
+ message,
651
+ status: 'transcribing',
652
+ progress: normalizedProgress,
653
+ percent: normalizedProgress === null ? null : Math.round(normalizedProgress * 100),
654
+ finishedChunks: Number.isFinite(finishedChunks) ? Math.max(0, finishedChunks) : null,
655
+ totalChunks: Number.isFinite(totalChunks) && totalChunks > 0 ? totalChunks : null,
656
+ })
657
+ return
658
+ }
659
+
660
+ if (message === lastTextProgressMessage) {
661
+ return
662
+ }
663
+
664
+ lastTextProgressMessage = message
665
+ writeTextMessage(message)
666
+ },
667
+ reportTerminal(status, errorMessage) {
668
+ if (!status) {
669
+ return
670
+ }
671
+
672
+ finishInline(outputState)
673
+ const signature = `${status}:${errorMessage || ''}`
674
+
675
+ if (lastTerminalSignature === signature) {
676
+ return
677
+ }
678
+
679
+ lastTerminalSignature = signature
680
+ emitTextOrJson(buildTerminalMessage(status, errorMessage), {
681
+ event: `task.${status}`,
682
+ status,
683
+ statusLabel: formatStatus(status),
684
+ error: errorMessage || null,
685
+ })
686
+ },
687
+ reportResultWritten(outputPath, format) {
688
+ emitTextOrJson(`结果已写入:${outputPath}`, {
689
+ event: 'task.result_written',
690
+ outputPath,
691
+ format,
692
+ })
693
+ },
694
+ reportNotice(message) {
695
+ emitTextOrJson(message, {
696
+ event: 'task.notice',
697
+ })
698
+ },
699
+ close() {
700
+ finishInline(outputState)
701
+ },
702
+ }
703
+ }
704
+
524
705
  function parseSseBlock(block) {
525
706
  const lines = block.split('\n')
526
707
  let event = 'message'
@@ -547,7 +728,7 @@ function parseSseBlock(block) {
547
728
  }
548
729
  }
549
730
 
550
- async function consumeTaskEvents(baseUrl, apiKey, taskId, initialTask, outputState) {
731
+ async function consumeTaskEvents(baseUrl, apiKey, taskId, initialTask, reporter) {
551
732
  const response = await fetch(`${baseUrl}/api/cli/transcriptions/${encodeURIComponent(taskId)}/events`, {
552
733
  method: 'GET',
553
734
  headers: {
@@ -577,7 +758,13 @@ async function consumeTaskEvents(baseUrl, apiKey, taskId, initialTask, outputSta
577
758
  ...task,
578
759
  status: nextStatus,
579
760
  }
580
- writeStatusLine(outputState, `状态更新:${formatStatus(nextStatus)}`)
761
+
762
+ if (TERMINAL_STATUSES.has(nextStatus)) {
763
+ reporter.reportTerminal(nextStatus, task.error || null)
764
+ return
765
+ }
766
+
767
+ reporter.reportStatusChanged(nextStatus)
581
768
  }
582
769
 
583
770
  function applyTerminal(nextStatus, errorMessage) {
@@ -587,7 +774,7 @@ async function consumeTaskEvents(baseUrl, apiKey, taskId, initialTask, outputSta
587
774
  status: nextStatus,
588
775
  error: errorMessage || task.error || null,
589
776
  }
590
- finishInline(outputState)
777
+ reporter.reportTerminal(nextStatus, task.error || null)
591
778
  }
592
779
 
593
780
  function renderProgress(data) {
@@ -596,7 +783,7 @@ async function consumeTaskEvents(baseUrl, apiKey, taskId, initialTask, outputSta
596
783
  ...task,
597
784
  status: 'transcribing',
598
785
  }
599
- writeInline(outputState, buildTranscribingProgressMessage(data))
786
+ reporter.reportProgress(data)
600
787
  }
601
788
 
602
789
  while (true) {
@@ -625,6 +812,8 @@ async function consumeTaskEvents(baseUrl, apiKey, taskId, initialTask, outputSta
625
812
  if (event === 'job.snapshot') {
626
813
  if (data?.status === 'transcribing') {
627
814
  renderProgress(data)
815
+ } else if (TERMINAL_STATUSES.has(data?.status)) {
816
+ applyTerminal(data.status, data?.error || data?.message || null)
628
817
  } else {
629
818
  applyStatus(data?.status)
630
819
  }
@@ -658,7 +847,7 @@ async function consumeTaskEvents(baseUrl, apiKey, taskId, initialTask, outputSta
658
847
  }
659
848
  }
660
849
 
661
- finishInline(outputState)
850
+ reporter.close()
662
851
  return task
663
852
  }
664
853
 
@@ -743,85 +932,95 @@ async function handleAuthCommand(argv) {
743
932
 
744
933
  async function handleTranscription(argv) {
745
934
  const args = parseTranscriptionArgs(argv)
746
- const storedConfig = await readStoredConfig()
747
- const resolvedApiKey = resolveApiKey(args.apiKey, storedConfig)
748
-
749
- if (!resolvedApiKey) {
750
- printUsage()
751
- fail('缺少 API key。请传 --api-key、设置 DEYO_API_KEY,或先执行 deyo auth login')
752
- }
935
+ const reporter = createProgressReporter(args.progressFormat)
936
+ try {
937
+ const storedConfig = await readStoredConfig()
938
+ const resolvedApiKey = resolveApiKey(args.apiKey, storedConfig)
753
939
 
754
- if (!args.url) {
755
- printUsage()
756
- fail('缺少内容链接')
757
- }
940
+ if (!resolvedApiKey) {
941
+ printUsage()
942
+ fail('缺少 API key。请传 --api-key、设置 DEYO_API_KEY,或先执行 deyo auth login')
943
+ }
758
944
 
759
- const { baseUrl } = resolveBaseUrl(args.baseUrl, storedConfig)
760
- const format = inferFormat(args.output, args.format)
761
- const createPayload = {
762
- url: args.url,
763
- language: args.language || DEFAULT_LANGUAGE,
764
- }
945
+ if (!args.url) {
946
+ printUsage()
947
+ fail('缺少内容链接')
948
+ }
765
949
 
766
- if (args.source) {
767
- createPayload.source = args.source
768
- }
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,
955
+ }
769
956
 
770
- const created = await requestJson(baseUrl, resolvedApiKey.apiKey, '/api/cli/transcriptions', {
771
- method: 'POST',
772
- body: createPayload,
773
- })
774
- const outputState = createOutputState()
957
+ if (args.source) {
958
+ createPayload.source = args.source
959
+ }
775
960
 
776
- process.stderr.write(`任务已创建:${created.taskId}\n`)
777
- process.stderr.write(`标题:${created.title}\n`)
778
- process.stderr.write(`来源:${created.sourceType}\n`)
779
- process.stderr.write(`状态:${formatStatus(created.status)}\n`)
780
- process.stderr.write(`本次消耗分钟:${created.consumedMinutes}\n`)
781
- process.stderr.write(`剩余分钟:${created.remainingMinutes}\n`)
961
+ const created = await requestJson(baseUrl, resolvedApiKey.apiKey, '/api/cli/transcriptions', {
962
+ method: 'POST',
963
+ body: createPayload,
964
+ })
965
+ reporter.reportTaskCreated(created)
966
+
967
+ let task = created
968
+ let lastStatus = created.status
969
+
970
+ if (task.mode === 'transcription' && !TERMINAL_STATUSES.has(task.status)) {
971
+ try {
972
+ task = await consumeTaskEvents(baseUrl, resolvedApiKey.apiKey, task.taskId, task, reporter)
973
+ lastStatus = task.status
974
+ } catch (error) {
975
+ reporter.reportNotice(`事件流中断,回退到轮询状态:${error instanceof Error ? error.message : String(error)}`)
976
+ }
977
+ }
782
978
 
783
- let task = created
784
- let lastStatus = created.status
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)}`)
785
982
 
786
- if (task.mode === 'transcription' && !TERMINAL_STATUSES.has(task.status)) {
787
- try {
788
- task = await consumeTaskEvents(baseUrl, resolvedApiKey.apiKey, task.taskId, task, outputState)
789
- lastStatus = task.status
790
- } catch (error) {
791
- finishInline(outputState)
792
- process.stderr.write(`事件流中断,回退到轮询状态:${error instanceof Error ? error.message : String(error)}\n`)
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
990
+ }
793
991
  }
794
- }
795
992
 
796
- while (!TERMINAL_STATUSES.has(task.status)) {
797
- await sleep(POLL_INTERVAL_MS)
798
- task = await requestJson(baseUrl, resolvedApiKey.apiKey, `/api/cli/transcriptions/${encodeURIComponent(task.taskId)}`)
993
+ reporter.reportTerminal(task.status, task.error || null)
799
994
 
800
- if (task.status !== lastStatus) {
801
- finishInline(outputState)
802
- process.stderr.write(`状态更新:${formatStatus(task.status)}\n`)
803
- lastStatus = task.status
995
+ if (task.status !== 'completed') {
996
+ throw new ReportedCliError(task.error || `任务未成功完成:${formatStatus(task.status)}`)
804
997
  }
805
- }
806
998
 
807
- finishInline(outputState)
999
+ const result = await requestResult(baseUrl, resolvedApiKey.apiKey, task.taskId, format)
808
1000
 
809
- if (task.status !== 'completed') {
810
- throw new Error(task.error || `任务未成功完成:${formatStatus(task.status)}`)
811
- }
1001
+ if (args.output) {
1002
+ const outputPath = await writeOutput(args.output, result)
1003
+ reporter.reportResultWritten(outputPath, format)
1004
+ return
1005
+ }
812
1006
 
813
- const result = await requestResult(baseUrl, resolvedApiKey.apiKey, task.taskId, format)
1007
+ reporter.close()
1008
+ process.stdout.write(result)
814
1009
 
815
- if (args.output) {
816
- const outputPath = await writeOutput(args.output, result)
817
- process.stderr.write(`结果已写入:${outputPath}\n`)
818
- return
819
- }
1010
+ if (!result.endsWith('\n')) {
1011
+ process.stdout.write('\n')
1012
+ }
1013
+ } catch (error) {
1014
+ if (error instanceof ReportedCliError) {
1015
+ throw error
1016
+ }
820
1017
 
821
- process.stdout.write(result)
1018
+ if (reporter.mode === 'jsonl') {
1019
+ reporter.reportNotice(`执行失败:${error instanceof Error ? error.message : String(error)}`)
1020
+ throw new ReportedCliError('执行失败')
1021
+ }
822
1022
 
823
- if (!result.endsWith('\n')) {
824
- process.stdout.write('\n')
1023
+ throw error
825
1024
  }
826
1025
  }
827
1026
 
@@ -848,6 +1047,10 @@ async function main() {
848
1047
  }
849
1048
 
850
1049
  main().catch((error) => {
1050
+ if (error instanceof ReportedCliError) {
1051
+ process.exit(1)
1052
+ }
1053
+
851
1054
  const message = error instanceof Error ? error.message : String(error)
852
1055
  fail(`执行失败:${message}`)
853
1056
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@casatwy/deyo",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "CLI for submitting Deyo transcription jobs from the terminal",
5
5
  "homepage": "https://deyo.miaobi.fun/docs",
6
6
  "license": "UNLICENSED",