@casatwy/deyo 0.1.1 → 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 +26 -1
  2. package/deyo.mjs +279 -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,12 +137,31 @@ 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。
137
158
  如果你已经执行过 `deyo auth login`,平时通常不需要再传。
138
159
  当它和本地已保存配置同时存在时,会优先使用这次命令里传入的 `--api-key`。
139
160
 
161
+ `-v, --version`
162
+
163
+ 打印当前 CLI 版本号并退出,适合快速确认本机装的是哪个版本。
164
+
140
165
  ## 在 AI 工具中使用
141
166
 
142
167
  如果你希望让 AI 助手在你的本机环境里调用 `deyo`,可以参考官方安装页:
@@ -150,7 +175,7 @@ deyo --api-key 'deyo_sk_other' 'https://www.youtube.com/watch?v=xxxx'
150
175
  2. 如何向你索取一次 API Key
151
176
  3. 如何在你的本地环境执行 `deyo auth login`
152
177
 
153
- 完成后,你就可以让 AI 直接帮你调用 `deyo` 提交转写任务。
178
+ 完成后,你就可以让 AI 直接帮你调用 `deyo` 提交转写任务。对于需要在对话里持续同步进度的场景,AI 应优先使用 `--progress-format jsonl`,读取 stderr 里的结构化事件,再把任务创建、状态切换、关键百分比和最终结果转述给你。
154
179
 
155
180
  ## 更多帮助
156
181
 
package/deyo.mjs CHANGED
@@ -1,16 +1,22 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { chmod, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
4
+ import { createRequire } from 'node:module'
4
5
  import { dirname, extname, resolve } from 'node:path'
5
6
  import { homedir } from 'node:os'
6
7
  import process from 'node:process'
7
8
 
9
+ const CLI_VERSION = createRequire(import.meta.url)('./package.json').version
10
+
8
11
  const DEFAULT_BASE_URL = 'https://deyo.miaobi.fun'
9
12
  const DEFAULT_LANGUAGE = 'zh'
10
13
  const POLL_INTERVAL_MS = 1500
14
+ const PROGRESS_FORMATS = new Set(['auto', 'text', 'jsonl'])
11
15
  const RESULT_FORMATS = new Set(['text', 'srt', 'vtt', 'json', 'verbose_json'])
12
16
  const TERMINAL_STATUSES = new Set(['completed', 'failed', 'cancelled'])
13
17
 
18
+ class ReportedCliError extends Error {}
19
+
14
20
  function safeJsonParse(value) {
15
21
  try {
16
22
  return JSON.parse(value)
@@ -35,8 +41,10 @@ function printUsage() {
35
41
  --source <name> 可选,显式指定来源
36
42
  --language <value> 可选,转写语言,默认 ${DEFAULT_LANGUAGE}
37
43
  --format <value> 可选,text | srt | vtt | json | verbose_json
44
+ --progress-format <v> 可选,auto | text | jsonl,默认 auto
38
45
  --base-url <url> 可选,默认 ${DEFAULT_BASE_URL}
39
46
  -h, --help 显示帮助
47
+ -v, --version 显示 CLI 版本
40
48
 
41
49
  auth 子命令:
42
50
  deyo auth login --api-key <key> [--base-url <url>]
@@ -70,6 +78,7 @@ function parseTranscriptionArgs(argv) {
70
78
  source: '',
71
79
  language: '',
72
80
  format: '',
81
+ progressFormat: 'auto',
73
82
  baseUrl: '',
74
83
  url: '',
75
84
  }
@@ -112,6 +121,12 @@ function parseTranscriptionArgs(argv) {
112
121
  continue
113
122
  }
114
123
 
124
+ if (current === '--progress-format') {
125
+ args.progressFormat = argv[index + 1] || ''
126
+ index += 1
127
+ continue
128
+ }
129
+
115
130
  if (current === '--base-url') {
116
131
  args.baseUrl = argv[index + 1] || ''
117
132
  index += 1
@@ -452,6 +467,20 @@ function createOutputState() {
452
467
  }
453
468
  }
454
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
+
455
484
  function clearInline(outputState) {
456
485
  if (!outputState.inlineActive) {
457
486
  return
@@ -480,6 +509,26 @@ function finishInline(outputState) {
480
509
  outputState.inlineActive = false
481
510
  }
482
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
+
483
532
  function normalizeProgressValue(progress, finishedChunks, totalChunks) {
484
533
  if (Number.isFinite(progress) && progress >= 0) {
485
534
  return Math.max(0, Math.min(1, progress))
@@ -517,6 +566,142 @@ function buildTranscribingProgressMessage(input = {}) {
517
566
  return `转写中 ${percent}%${chunksPart}`
518
567
  }
519
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
+
520
705
  function parseSseBlock(block) {
521
706
  const lines = block.split('\n')
522
707
  let event = 'message'
@@ -543,7 +728,7 @@ function parseSseBlock(block) {
543
728
  }
544
729
  }
545
730
 
546
- async function consumeTaskEvents(baseUrl, apiKey, taskId, initialTask, outputState) {
731
+ async function consumeTaskEvents(baseUrl, apiKey, taskId, initialTask, reporter) {
547
732
  const response = await fetch(`${baseUrl}/api/cli/transcriptions/${encodeURIComponent(taskId)}/events`, {
548
733
  method: 'GET',
549
734
  headers: {
@@ -573,7 +758,13 @@ async function consumeTaskEvents(baseUrl, apiKey, taskId, initialTask, outputSta
573
758
  ...task,
574
759
  status: nextStatus,
575
760
  }
576
- 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)
577
768
  }
578
769
 
579
770
  function applyTerminal(nextStatus, errorMessage) {
@@ -583,7 +774,7 @@ async function consumeTaskEvents(baseUrl, apiKey, taskId, initialTask, outputSta
583
774
  status: nextStatus,
584
775
  error: errorMessage || task.error || null,
585
776
  }
586
- finishInline(outputState)
777
+ reporter.reportTerminal(nextStatus, task.error || null)
587
778
  }
588
779
 
589
780
  function renderProgress(data) {
@@ -592,7 +783,7 @@ async function consumeTaskEvents(baseUrl, apiKey, taskId, initialTask, outputSta
592
783
  ...task,
593
784
  status: 'transcribing',
594
785
  }
595
- writeInline(outputState, buildTranscribingProgressMessage(data))
786
+ reporter.reportProgress(data)
596
787
  }
597
788
 
598
789
  while (true) {
@@ -621,6 +812,8 @@ async function consumeTaskEvents(baseUrl, apiKey, taskId, initialTask, outputSta
621
812
  if (event === 'job.snapshot') {
622
813
  if (data?.status === 'transcribing') {
623
814
  renderProgress(data)
815
+ } else if (TERMINAL_STATUSES.has(data?.status)) {
816
+ applyTerminal(data.status, data?.error || data?.message || null)
624
817
  } else {
625
818
  applyStatus(data?.status)
626
819
  }
@@ -654,7 +847,7 @@ async function consumeTaskEvents(baseUrl, apiKey, taskId, initialTask, outputSta
654
847
  }
655
848
  }
656
849
 
657
- finishInline(outputState)
850
+ reporter.close()
658
851
  return task
659
852
  }
660
853
 
@@ -739,85 +932,95 @@ async function handleAuthCommand(argv) {
739
932
 
740
933
  async function handleTranscription(argv) {
741
934
  const args = parseTranscriptionArgs(argv)
742
- const storedConfig = await readStoredConfig()
743
- const resolvedApiKey = resolveApiKey(args.apiKey, storedConfig)
744
-
745
- if (!resolvedApiKey) {
746
- printUsage()
747
- fail('缺少 API key。请传 --api-key、设置 DEYO_API_KEY,或先执行 deyo auth login')
748
- }
935
+ const reporter = createProgressReporter(args.progressFormat)
936
+ try {
937
+ const storedConfig = await readStoredConfig()
938
+ const resolvedApiKey = resolveApiKey(args.apiKey, storedConfig)
749
939
 
750
- if (!args.url) {
751
- printUsage()
752
- fail('缺少内容链接')
753
- }
940
+ if (!resolvedApiKey) {
941
+ printUsage()
942
+ fail('缺少 API key。请传 --api-key、设置 DEYO_API_KEY,或先执行 deyo auth login')
943
+ }
754
944
 
755
- const { baseUrl } = resolveBaseUrl(args.baseUrl, storedConfig)
756
- const format = inferFormat(args.output, args.format)
757
- const createPayload = {
758
- url: args.url,
759
- language: args.language || DEFAULT_LANGUAGE,
760
- }
945
+ if (!args.url) {
946
+ printUsage()
947
+ fail('缺少内容链接')
948
+ }
761
949
 
762
- if (args.source) {
763
- createPayload.source = args.source
764
- }
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
+ }
765
956
 
766
- const created = await requestJson(baseUrl, resolvedApiKey.apiKey, '/api/cli/transcriptions', {
767
- method: 'POST',
768
- body: createPayload,
769
- })
770
- const outputState = createOutputState()
957
+ if (args.source) {
958
+ createPayload.source = args.source
959
+ }
771
960
 
772
- process.stderr.write(`任务已创建:${created.taskId}\n`)
773
- process.stderr.write(`标题:${created.title}\n`)
774
- process.stderr.write(`来源:${created.sourceType}\n`)
775
- process.stderr.write(`状态:${formatStatus(created.status)}\n`)
776
- process.stderr.write(`本次消耗分钟:${created.consumedMinutes}\n`)
777
- 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
+ }
778
978
 
779
- let task = created
780
- 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)}`)
781
982
 
782
- if (task.mode === 'transcription' && !TERMINAL_STATUSES.has(task.status)) {
783
- try {
784
- task = await consumeTaskEvents(baseUrl, resolvedApiKey.apiKey, task.taskId, task, outputState)
785
- lastStatus = task.status
786
- } catch (error) {
787
- finishInline(outputState)
788
- 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
+ }
789
991
  }
790
- }
791
992
 
792
- while (!TERMINAL_STATUSES.has(task.status)) {
793
- await sleep(POLL_INTERVAL_MS)
794
- task = await requestJson(baseUrl, resolvedApiKey.apiKey, `/api/cli/transcriptions/${encodeURIComponent(task.taskId)}`)
993
+ reporter.reportTerminal(task.status, task.error || null)
795
994
 
796
- if (task.status !== lastStatus) {
797
- finishInline(outputState)
798
- process.stderr.write(`状态更新:${formatStatus(task.status)}\n`)
799
- lastStatus = task.status
995
+ if (task.status !== 'completed') {
996
+ throw new ReportedCliError(task.error || `任务未成功完成:${formatStatus(task.status)}`)
800
997
  }
801
- }
802
998
 
803
- finishInline(outputState)
999
+ const result = await requestResult(baseUrl, resolvedApiKey.apiKey, task.taskId, format)
804
1000
 
805
- if (task.status !== 'completed') {
806
- throw new Error(task.error || `任务未成功完成:${formatStatus(task.status)}`)
807
- }
1001
+ if (args.output) {
1002
+ const outputPath = await writeOutput(args.output, result)
1003
+ reporter.reportResultWritten(outputPath, format)
1004
+ return
1005
+ }
808
1006
 
809
- const result = await requestResult(baseUrl, resolvedApiKey.apiKey, task.taskId, format)
1007
+ reporter.close()
1008
+ process.stdout.write(result)
810
1009
 
811
- if (args.output) {
812
- const outputPath = await writeOutput(args.output, result)
813
- process.stderr.write(`结果已写入:${outputPath}\n`)
814
- return
815
- }
1010
+ if (!result.endsWith('\n')) {
1011
+ process.stdout.write('\n')
1012
+ }
1013
+ } catch (error) {
1014
+ if (error instanceof ReportedCliError) {
1015
+ throw error
1016
+ }
816
1017
 
817
- process.stdout.write(result)
1018
+ if (reporter.mode === 'jsonl') {
1019
+ reporter.reportNotice(`执行失败:${error instanceof Error ? error.message : String(error)}`)
1020
+ throw new ReportedCliError('执行失败')
1021
+ }
818
1022
 
819
- if (!result.endsWith('\n')) {
820
- process.stdout.write('\n')
1023
+ throw error
821
1024
  }
822
1025
  }
823
1026
 
@@ -825,6 +1028,11 @@ async function main() {
825
1028
  const argv = process.argv.slice(2)
826
1029
  const primaryCommand = argv[0]
827
1030
 
1031
+ if (primaryCommand === '-v' || primaryCommand === '--version') {
1032
+ process.stdout.write(`${CLI_VERSION}\n`)
1033
+ return
1034
+ }
1035
+
828
1036
  if (!primaryCommand || primaryCommand === '-h' || primaryCommand === '--help') {
829
1037
  printUsage()
830
1038
  return
@@ -839,6 +1047,10 @@ async function main() {
839
1047
  }
840
1048
 
841
1049
  main().catch((error) => {
1050
+ if (error instanceof ReportedCliError) {
1051
+ process.exit(1)
1052
+ }
1053
+
842
1054
  const message = error instanceof Error ? error.message : String(error)
843
1055
  fail(`执行失败:${message}`)
844
1056
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@casatwy/deyo",
3
- "version": "0.1.1",
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",