dsh-tingdong 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -7,10 +7,14 @@
7
7
 
8
8
  首页有两个入口:**导入音频**(有声书、讲座、引导音频)和**导入文档**(文章、手册、书)。
9
9
 
10
- **导入文档**:把整理好的英文纯文本粘贴进来,或选 .txt / .md 文件。格式很简单——每章一行 `# 章节标题`(每章做成一课),`## 小标题` 也会读出来,段落之间空一行。
11
- PDF、电子书、网页先交给别的 AI(ChatGPT、DeepSeek、Kimi……)整理:界面上有现成的「整理要求」可以一键复制,连同材料一起发过去。
12
- 在 DSH 对话里更省事:把 PDF 发给 AI,说「按听懂的格式整理后导入听懂」,它会整理好直接导入(AI 工具 `tingdong_import_text`)。
13
- 导入后用 **微软 Edge** 的英文语音朗读(默认 Andrew,14 种美式 / 英式男女声,可调语速;也可选离线的 Kokoro),句与句之间停 0.6 秒、换段 1.2 秒,再逐句翻译讲解。之后和音频课一样学:精听、复习、磨耳朵、连续听、导出中文配音和字幕。
10
+ **导入文档**:直接选文件(或拖进来)——PDF、Word(.docx)、EPUB 电子书、网页(.html)、TXT、Markdown;没有文件也可以粘贴英文。
11
+
12
+ 1. 插件先取出文字,给你一张**确认卡片**:读到了多少词,预计分几课、朗读多长、处理要多久、大约用多少模型 token,并说明「会用 AI 整理文字」。
13
+ 2. 你点确定后,在后台:**AI 整理**(去掉页眉页码目录、修好断行、分好章节,原文一个字都不改、不翻译;长文分段、几段同时整理,逐段核对单词数,模型删多了就重来或改用原文)→ **存档**(原文件、取出的原始文字、整理后的文字都留在 `~/.dsh/dsh-tingdong/docs/`,课程概览里能下载)→ **分课**(每个大标题一课,太长的按段落拆开)→ **朗读**(默认微软 Edge,14 种美式 / 英式男女声,可选离线 Kokoro)→ **逐句翻译讲解**。
14
+ 3. 做好一课就能先学一课,之后和音频课一样:精听、复习、磨耳朵、连续听、导出。
15
+
16
+ **大小和耗时**:文件最大 100 MB;文字最多约 6 万个英文单词(40 万字符,相当于 7 小时左右的朗读),再长请拆开导入。真正花时间和 token 的是 AI 整理和翻译:1 万词大约整理 4 万字符、朗读 1 小时多,处理约十几分钟(看模型速度)。扫描版 PDF(每页是图片)读不出文字,要先用 OCR 或 AI 转成文字。
17
+ 在 DSH 对话里也可以把 PDF 发给 AI,说「整理后导入听懂」(AI 工具 `tingdong_import_text`)。
14
18
 
15
19
  **导入音频**:
16
20
 
@@ -4,7 +4,7 @@
4
4
  // dsh-tingdong import <文件或文件夹> [--title 标题] 识别 + 翻译,做成课
5
5
  // dsh-tingdong serve [--port 4810] 在浏览器里打开学习面板
6
6
  // dsh-tingdong list 看有哪些课
7
- // dsh-tingdong import-doc <整理好的 .txt / .md> 导入英文文档(# 章节 分课),Edge 朗读 + 翻译
7
+ // dsh-tingdong import-doc <文档> 导入英文文档:AI 整理 → 分课 → Edge 朗读 + 翻译
8
8
  // dsh-tingdong voices <课程id|all> [--track clean,cleannr,say,kokoro] [--voice 名字] [--speed 0.9]
9
9
  // 生成声音轨(去空白原声 / 压背景 / 系统朗读 / Kokoro)
10
10
  //
@@ -52,9 +52,7 @@ async function main() {
52
52
  }
53
53
 
54
54
  if (cmd === 'import-doc') {
55
- if (!rest[0]) throw new Error('用法:dsh-tingdong import-doc <整理好的 .txt / .md> [--title 标题] [--voice en-US-AndrewNeural] [--engine edge|kokoro]')
56
- const { readFile } = await import('node:fs/promises')
57
- const text = await readFile(rest[0], 'utf8')
55
+ if (!rest[0]) throw new Error('用法:dsh-tingdong import-doc <PDF / Word / EPUB / 网页 / TXT / MD> [--title 标题] [--voice en-US-AndrewNeural] [--engine edge|kokoro]')
58
56
  const llm = await cliLlm(flags)
59
57
  console.log(llm ? `翻译模型:${llm.label()}(${llm.via})` : '没有可用的大模型:只朗读英文')
60
58
  const importer = new Importer({ llm: () => llm, options, log: m => console.error(m) })
@@ -66,8 +64,12 @@ async function main() {
66
64
  if (key !== last) { last = key; console.log(`${j.title}:${j.stage}${j.total ? ` ${j.done}/${j.total}` : ''}`) }
67
65
  }
68
66
  })
69
- const r = await importer.importDoc(text, { title: typeof flags.title === 'string' ? flags.title : '', engine: flags.engine === 'kokoro' ? 'kokoro' : 'edge', voice: typeof flags.voice === 'string' ? flags.voice : undefined })
70
- console.log(`《${r.title}》分成 ${r.items.length} 课:${r.items.map(x => `${x.title}(${x.sentences || '已有'} 句)`).join('、')}`)
67
+ // 和界面一样:取出文字、预估 → 确认 → AI 整理、存档 → 分课 → 朗读、翻译
68
+ const { basename } = await import('node:path')
69
+ const doc = await importer.prepareDoc(rest[0], { name: basename(rest[0]) })
70
+ const e = doc.estimate
71
+ console.log(`${doc.name}:${doc.type}${doc.pages ? ` ${doc.pages} 页` : ''},约 ${e.words} 词 → 约 ${e.lessons} 课,朗读约 ${e.audioMinutes} 分钟,处理约 ${e.minutes} 分钟,约 ${e.tokens} token;${doc.needsCleanup ? 'AI 整理' : '已是整理好的格式'}`)
72
+ await importer.confirmDoc(doc.docId, { title: typeof flags.title === 'string' ? flags.title : doc.title, engine: flags.engine === 'kokoro' ? 'kokoro' : 'edge', voice: typeof flags.voice === 'string' ? flags.voice : undefined })
71
73
  await new Promise(resolve => {
72
74
  const check = () => { if (!importer.running && !importer.jobs.some(j => j.state === 'queued')) resolve() }
73
75
  importer.onChange(() => setImmediate(check))
@@ -159,7 +161,7 @@ async function main() {
159
161
  dsh-tingdong import <文件或文件夹> [--title 标题]
160
162
  dsh-tingdong serve [--port 4810]
161
163
  dsh-tingdong list
162
- dsh-tingdong import-doc <整理好的 .txt / .md> [--title 标题] [--voice en-US-AndrewNeural]
164
+ dsh-tingdong import-doc <PDF / Word / EPUB / 网页 / TXT / MD> [--title 标题] [--voice en-US-AndrewNeural]
163
165
  dsh-tingdong voices <课程id|all> [--track clean,cleannr,say,kokoro] [--voice 名字] [--speed 0.9]
164
166
 
165
167
  模型:--base-url URL --api-key KEY --model ID(或 TINGDONG_BASE_URL / TINGDONG_API_KEY / TINGDONG_MODEL)
package/lib/docprep.js ADDED
@@ -0,0 +1,178 @@
1
+ // lib/docprep.js — 用 AI 把从文档里取出的原始文字整理成能导入的格式(# 章节、段落、只留正文)
2
+ //
3
+ // 原始文字按约 1 万字符分段,几段同时整理;每段都告诉模型上一段的结尾和当前章节,接缝处才连得上。
4
+ // 整理完逐段核对英文单词数:模型删掉太多(少于 60%)就重来一次,还不行就用原文简单接好断行——宁可难看,不能丢内容。
5
+
6
+ import { normalize } from './doc.js'
7
+
8
+ export const CHUNK_CHARS = 10000
9
+
10
+ const SYSTEM = `你是文字整理员。给你的是从 PDF / Word / 电子书 / 网页里取出的英文原始文字(其中一段),要整理成英语听力软件能导入的纯文本。规则:
11
+ 1. 只保留正文英文:删掉页眉、页脚、页码、目录、版权声明、联系方式、网址和邮箱、图片说明、脚注编号,以及 ® ™ 这类符号。
12
+ 2. 修好断行:行尾被拆开的单词接回去;同一段落的多行接成一行;段落之间空一行。
13
+ 3. 章节:原文的大标题(一章的开头)单独一行写成「# 标题」,小标题写成「## 小标题」。原文没有章节的长文,大约每 800–1500 个英文单词加一个「# 」标题,用几个英文单词概括。
14
+ 4. 原文的句子一个字都不要改:不要翻译、改写、总结、补充,也不要加任何解释或前后说明。
15
+ 5. 这一段可能从段落中间开始、在段落中间结束,照原样整理,不要补写缺的部分。
16
+ 6. 格式必须严格:标题前后各空一行,段落和段落之间空一行。原文里单独成行、很短、没有句号的行(如 "Cautions and Warnings"、"Chapter 3—Free Flow")就是标题,一定要加「# 」或「## 」。
17
+ 输出示例:
18
+ # The First Chapter
19
+
20
+ First paragraph. Its second sentence.
21
+
22
+ ## A Section Title
23
+
24
+ Another paragraph.
25
+
26
+ 只输出整理后的文字。`
27
+
28
+ const words = t => (String(t).match(/[A-Za-z][A-Za-z'’-]*/g) || []).length
29
+
30
+ /** 按行切成约 CHUNK_CHARS 的几段,尽量在空行处切 */
31
+ export function chunkText(raw, size = CHUNK_CHARS) {
32
+ const lines = normalize(raw).split('\n')
33
+ const chunks = []
34
+ let cur = []
35
+ let len = 0
36
+ for (const line of lines) {
37
+ cur.push(line)
38
+ len += line.length + 1
39
+ const blank = line.trim() === ''
40
+ if (len >= size && (blank || len >= size * 1.3)) { chunks.push(cur.join('\n').trim()); cur = []; len = 0 }
41
+ }
42
+ if (cur.join('').trim()) chunks.push(cur.join('\n').trim())
43
+ return chunks.filter(Boolean)
44
+ }
45
+
46
+ /** 原文简单整理(模型靠不住时的退路):接断行、段落空一行 */
47
+ export function plainTidy(raw) {
48
+ return normalize(raw)
49
+ .split(/\n\s*\n/)
50
+ .map(p => p.replace(/\s*\n\s*/g, ' ').trim())
51
+ .filter(Boolean)
52
+ .join('\n\n')
53
+ }
54
+
55
+ /** 像标题的行:不长、没有句末标点、多数词首字母大写("Cautions and Warnings: Please Read") */
56
+ function looksLikeHeading(line, next) {
57
+ if (line.length > 90 || /[.!?,;]$/.test(line) || !/^[A-Z0-9"“‘'(]/.test(line)) return false
58
+ const ws = line.split(/\s+/).filter(Boolean)
59
+ if (ws.length > 12) return false
60
+ // 只看以字母开头的实词(「#4—NVC」「12」这种不算)
61
+ const big = ws.filter(w => w.length > 3 && /^["“‘'(]?[A-Za-z]/.test(w))
62
+ if (big.length && big.filter(w => /^["“‘'(]?[A-Z0-9]/.test(w)).length / big.length < 0.6) return false
63
+ // 下一行以小写开头:这是被断开的句子,不是标题
64
+ return !next || !/^[a-z]/.test(next)
65
+ }
66
+
67
+ /**
68
+ * 把模型的输出(或原文)规整成导入格式,不管它守没守规矩:
69
+ * 认出标题加「## 」,句子中途断开的行接回去,每段之间空一行。已经合格的文字过一遍不变。
70
+ */
71
+ export function ensureStructure(text) {
72
+ const lines = String(text).split('\n').map(l => l.trim())
73
+ const blocks = []
74
+ let para = ''
75
+ const flush = () => { if (para) { blocks.push(para); para = '' } }
76
+ for (let k = 0; k < lines.length; k++) {
77
+ const line = lines[k]
78
+ if (!line) { flush(); continue }
79
+ if (/^#{1,6}\s/.test(line)) { flush(); blocks.push(line); continue }
80
+ const next = lines.slice(k + 1).find(Boolean) || ''
81
+ if (!para && looksLikeHeading(line, next)) { blocks.push(`## ${line}`); continue }
82
+ para = para ? `${para} ${line}` : line
83
+ // 这一行说完了(句末标点)、下一行另起:就是一段的结尾
84
+ if (/[.!?…"”’)]$/.test(line) && !/^[a-z]/.test(next)) flush()
85
+ }
86
+ flush()
87
+ return blocks.join('\n\n')
88
+ }
89
+
90
+ /** 去掉模型偶尔加上的代码块围栏和开场白 */
91
+ function stripWrapper(t) {
92
+ return String(t || '').replace(/^```[a-z]*\n?|```\s*$/gi, '').trim()
93
+ }
94
+
95
+ /**
96
+ * 预估:词数、朗读时长、大约几课、要多久、多少 token
97
+ * 粗算:朗读每分钟 150 词;一课最多 160 句(约 2000 词);
98
+ * 模型输出 token:整理 ≈ 字符数 / 4,翻译讲解 ≈ 每句 120
99
+ */
100
+ export function estimate(text, { needsCleanup = true } = {}) {
101
+ const w = words(text)
102
+ const chars = text.length
103
+ const sentences = Math.max(1, Math.round(w / 16))
104
+ const chunks = needsCleanup ? Math.max(1, Math.ceil(chars / CHUNK_CHARS)) : 0
105
+ const tokens = Math.round((needsCleanup ? chars / 4 : 0) + sentences * 120)
106
+ // 整理:每段约 40 秒、3 段并行;朗读(Edge)约为时长的 1/4;翻译:每 20 句一批约 15 秒、3 批并行
107
+ const seconds = Math.ceil(chunks / 3) * 40 + (w / 150) * 60 / 4 + Math.ceil(sentences / 20 / 3) * 15
108
+ return {
109
+ words: w, chars, sentences,
110
+ audioMinutes: Math.max(1, Math.round(w / 150)),
111
+ lessons: Math.max(1, Math.ceil(sentences / 130)),
112
+ chunks, tokens,
113
+ minutes: Math.max(1, Math.round(seconds / 60)),
114
+ }
115
+ }
116
+
117
+ /**
118
+ * @param {object} llm complete() 接口
119
+ * @param {string} raw 原始文字
120
+ * @param {{title?:string, signal?:AbortSignal, concurrency?:number, chunkChars?:number, onProgress?(done,total):void}} o
121
+ * @returns {Promise<{text:string, warnings:string[]}>}
122
+ */
123
+ export async function cleanupText(llm, raw, o = {}) {
124
+ const chunks = chunkText(raw, o.chunkChars || CHUNK_CHARS)
125
+ const out = new Array(chunks.length)
126
+ const warnings = []
127
+ let done = 0
128
+ let next = 0
129
+ const one = async (k) => {
130
+ const chunk = chunks[k]
131
+ const prev = k > 0 ? chunks[k - 1].slice(-300).replace(/\s+/g, ' ') : ''
132
+ const user = [
133
+ o.title ? `材料:${o.title}` : '',
134
+ `这是第 ${k + 1} / ${chunks.length} 段。`,
135
+ prev ? `上一段原文的结尾是:「…${prev}」(只用来接上下文,不要输出)` : '这是开头。',
136
+ '',
137
+ '原始文字:',
138
+ chunk,
139
+ ].filter(x => x !== null).join('\n')
140
+ const want = words(chunk)
141
+ for (let attempt = 0; attempt < 2; attempt++) {
142
+ if (o.signal && o.signal.aborted) throw new Error('已取消')
143
+ try {
144
+ const text = stripWrapper(await llm.complete({ system: SYSTEM, user, maxTokens: 16000, signal: o.signal }))
145
+ const got = words(text)
146
+ if (want < 30 || got >= want * 0.6) { out[k] = ensureStructure(text); return }
147
+ } catch (e) {
148
+ if (o.signal && o.signal.aborted) throw e
149
+ if (attempt === 1) warnings.push(`第 ${k + 1} 段整理失败(${e.message}),用了原文`)
150
+ }
151
+ }
152
+ if (!out[k]) {
153
+ out[k] = ensureStructure(plainTidy(chunk))
154
+ if (!warnings.some(w => w.startsWith(`第 ${k + 1} 段`))) warnings.push(`第 ${k + 1} 段 AI 整理后少了很多内容,改用原文(可能留有页眉页码)`)
155
+ }
156
+ }
157
+ const workers = Array.from({ length: Math.min(o.concurrency || 3, chunks.length) }, async () => {
158
+ while (next < chunks.length) {
159
+ const k = next++
160
+ await one(k)
161
+ done++
162
+ if (o.onProgress) o.onProgress(done, chunks.length)
163
+ }
164
+ })
165
+ await Promise.all(workers)
166
+ // 接缝:下一段开头重复了上一段最后的章节标题就去掉
167
+ const parts = []
168
+ let lastHeading = ''
169
+ for (const t of out) {
170
+ let s = t
171
+ const first = /^#\s+(.+)$/m.exec(s)
172
+ if (first && s.trimStart().startsWith('#') && first[1].trim() === lastHeading) s = s.replace(/^\s*#\s+.+\n?/, '')
173
+ const hs = [...s.matchAll(/^#\s+(.+)$/gm)]
174
+ if (hs.length) lastHeading = hs[hs.length - 1][1].trim()
175
+ parts.push(s.trim())
176
+ }
177
+ return { text: parts.filter(Boolean).join('\n\n'), warnings }
178
+ }
package/lib/extract.js ADDED
@@ -0,0 +1,139 @@
1
+ // lib/extract.js — 从文档里取出文字:PDF、Word(.docx)、EPUB 电子书、网页(.html)、TXT / Markdown
2
+ //
3
+ // 只取文字,不管排版:页眉页码、断行这些交给后面的 AI 整理(lib/docprep.js)。
4
+ // docx / epub 本质是 zip:这里用 Node 自带的 zlib 读,不另加依赖;PDF 用 unpdf(纯 JS)。
5
+
6
+ import { readFile } from 'node:fs/promises'
7
+ import { extname } from 'node:path'
8
+ import { inflateRawSync } from 'node:zlib'
9
+
10
+ export const DOC_EXT = ['.pdf', '.docx', '.epub', '.html', '.htm', '.txt', '.md', '.markdown']
11
+
12
+ // ———————— zip ————————
13
+
14
+ /** 读 zip 的目录,返回 name → 取内容的函数 */
15
+ export function readZip(buf) {
16
+ let eocd = -1
17
+ for (let i = buf.length - 22; i >= Math.max(0, buf.length - 65557); i--) {
18
+ if (buf.readUInt32LE(i) === 0x06054b50) { eocd = i; break }
19
+ }
20
+ if (eocd < 0) throw new Error('不是有效的 zip 文件')
21
+ const count = buf.readUInt16LE(eocd + 10)
22
+ let p = buf.readUInt32LE(eocd + 16)
23
+ const files = new Map()
24
+ for (let k = 0; k < count; k++) {
25
+ if (buf.readUInt32LE(p) !== 0x02014b50) break
26
+ const method = buf.readUInt16LE(p + 10)
27
+ const size = buf.readUInt32LE(p + 20)
28
+ const nameLen = buf.readUInt16LE(p + 28)
29
+ const extraLen = buf.readUInt16LE(p + 30)
30
+ const commentLen = buf.readUInt16LE(p + 32)
31
+ const local = buf.readUInt32LE(p + 42)
32
+ const name = buf.toString('utf8', p + 46, p + 46 + nameLen)
33
+ files.set(name, () => {
34
+ const start = local + 30 + buf.readUInt16LE(local + 26) + buf.readUInt16LE(local + 28)
35
+ const data = buf.subarray(start, start + size)
36
+ if (method === 0) return data
37
+ if (method === 8) return inflateRawSync(data)
38
+ throw new Error(`zip 里用了不支持的压缩方式(${method})`)
39
+ })
40
+ p += 46 + nameLen + extraLen + commentLen
41
+ }
42
+ return files
43
+ }
44
+
45
+ // ———————— HTML / XML → 文字 ————————
46
+
47
+ const ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', mdash: '—', ndash: '–', hellip: '…', lsquo: '‘', rsquo: '’', ldquo: '“', rdquo: '”' }
48
+ export function decodeEntities(s) {
49
+ return s.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (m, e) => {
50
+ if (e[0] === '#') return String.fromCodePoint(e[1].toLowerCase() === 'x' ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10))
51
+ return ENTITIES[e.toLowerCase()] ?? m
52
+ })
53
+ }
54
+
55
+ /** 网页 / EPUB 章节 → 文字:标题变成「# 」「## 」,段落之间空一行 */
56
+ export function htmlToText(html) {
57
+ return decodeEntities(String(html)
58
+ .replace(/<(script|style|head|nav|footer|aside)[\s\S]*?<\/\1>/gi, '')
59
+ .replace(/<h1[^>]*>([\s\S]*?)<\/h1>/gi, (_, t) => `\n\n# ${t.replace(/<[^>]+>/g, '').trim()}\n\n`)
60
+ .replace(/<h[2-6][^>]*>([\s\S]*?)<\/h[2-6]>/gi, (_, t) => `\n\n## ${t.replace(/<[^>]+>/g, '').trim()}\n\n`)
61
+ .replace(/<br\s*\/?>/gi, '\n')
62
+ .replace(/<\/(p|div|li|blockquote|section|article|tr)>/gi, '\n\n')
63
+ .replace(/<[^>]+>/g, ''))
64
+ .replace(/[ \t]+/g, ' ')
65
+ .replace(/\n[ \t]+/g, '\n')
66
+ .replace(/\n{3,}/g, '\n\n')
67
+ .trim()
68
+ }
69
+
70
+ /** Word:word/document.xml 里一个 <w:p> 是一段,标题样式变成「# / ## 」 */
71
+ export function docxToText(buf) {
72
+ const zip = readZip(buf)
73
+ const doc = zip.get('word/document.xml')
74
+ if (!doc) throw new Error('这个 Word 文件里没有正文')
75
+ const xml = doc().toString('utf8')
76
+ const paras = []
77
+ for (const m of xml.matchAll(/<w:p[ >][\s\S]*?<\/w:p>/g)) {
78
+ const p = m[0]
79
+ const style = (/<w:pStyle w:val="([^"]+)"/.exec(p) || [])[1] || ''
80
+ const text = decodeEntities([...p.matchAll(/<w:t(?: [^>]*)?>([\s\S]*?)<\/w:t>|<w:tab\/>|<w:br\/>/g)].map(t => (t[1] !== undefined ? t[1] : ' ')).join('')).trim()
81
+ if (!text) continue
82
+ const h = /^(Heading|标题)\s*(\d)/i.exec(style) || (/^Title$/i.test(style) ? [0, 0, '1'] : null)
83
+ paras.push(h ? `${h[2] === '1' ? '#' : '##'} ${text}` : text)
84
+ }
85
+ return paras.join('\n\n')
86
+ }
87
+
88
+ /** EPUB:按目录(spine)的顺序把各章 XHTML 接起来 */
89
+ export function epubToText(buf) {
90
+ const zip = readZip(buf)
91
+ const container = zip.get('META-INF/container.xml')
92
+ const opfPath = container && (/full-path="([^"]+)"/.exec(container().toString('utf8')) || [])[1]
93
+ if (!opfPath || !zip.get(opfPath)) throw new Error('这个 EPUB 文件不完整')
94
+ const opf = zip.get(opfPath)().toString('utf8')
95
+ const base = opfPath.includes('/') ? opfPath.slice(0, opfPath.lastIndexOf('/') + 1) : ''
96
+ const items = new Map([...opf.matchAll(/<item\b[^>]*>/g)].map(m => [(/id="([^"]+)"/.exec(m[0]) || [])[1], (/href="([^"]+)"/.exec(m[0]) || [])[1]]))
97
+ const parts = []
98
+ for (const m of opf.matchAll(/<itemref\b[^>]*idref="([^"]+)"/g)) {
99
+ const href = items.get(m[1])
100
+ if (!href) continue
101
+ const name = decodeURIComponent(base + href.split('#')[0])
102
+ const f = zip.get(name)
103
+ if (f) parts.push(htmlToText(f().toString('utf8')))
104
+ }
105
+ return parts.filter(Boolean).join('\n\n')
106
+ }
107
+
108
+ /** TXT:UTF-8 / UTF-16(带 BOM)都认 */
109
+ function decodeText(buf) {
110
+ if (buf[0] === 0xff && buf[1] === 0xfe) return buf.subarray(2).toString('utf16le')
111
+ if (buf[0] === 0xfe && buf[1] === 0xff) return Buffer.from(buf.subarray(2)).swap16().toString('utf16le')
112
+ return buf.toString('utf8').replace(/^/, '')
113
+ }
114
+
115
+ async function pdfToText(buf) {
116
+ const { extractText, getDocumentProxy } = await import('unpdf')
117
+ const pdf = await getDocumentProxy(new Uint8Array(buf))
118
+ const { totalPages, text } = await extractText(pdf, { mergePages: false })
119
+ return { text: text.join('\n\n'), pages: totalPages, perPage: text.map(t => t.trim().length) }
120
+ }
121
+
122
+ /**
123
+ * @returns {Promise<{type:string, text:string, pages?:number, scanned?:boolean}>}
124
+ */
125
+ export async function extractText(file, name = file) {
126
+ const ext = extname(name).toLowerCase()
127
+ if (!DOC_EXT.includes(ext)) throw Object.assign(new Error('不支持这种文件:可以导入 PDF、Word(.docx)、EPUB、网页(.html)、TXT、Markdown'), { status: 400 })
128
+ const buf = await readFile(file)
129
+ if (ext === '.pdf') {
130
+ const r = await pdfToText(buf)
131
+ // 每页平均不到 80 个字:多半是扫描版(整页是图片),读不出文字
132
+ const scanned = r.pages > 0 && r.text.replace(/\s/g, '').length / r.pages < 80
133
+ return { type: 'PDF', text: r.text, pages: r.pages, scanned }
134
+ }
135
+ if (ext === '.docx') return { type: 'Word', text: docxToText(buf) }
136
+ if (ext === '.epub') return { type: 'EPUB', text: epubToText(buf) }
137
+ if (ext === '.html' || ext === '.htm') return { type: '网页', text: htmlToText(decodeText(buf)) }
138
+ return { type: ext === '.txt' ? 'TXT' : 'Markdown', text: decodeText(buf) }
139
+ }
package/lib/importer.js CHANGED
@@ -4,14 +4,17 @@
4
4
  // 模型不可用时也能出课(只有英文),之后再「补全翻译」。
5
5
 
6
6
  import { existsSync } from 'node:fs'
7
- import { readdir, rename, rm, stat } from 'node:fs/promises'
7
+ import { copyFile, mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'
8
8
  import { basename, dirname, extname, join, resolve } from 'node:path'
9
9
  import { compactAudio, mergeSegments, transcribe } from './asr.js'
10
10
  import { ensureModels } from './models.js'
11
11
  import { audioPath, getLesson, makeId, saveLesson, titleFromFile } from './store.js'
12
12
  import { summarizeLesson, translateSentences } from './translate.js'
13
13
  import { buildDocAudio, buildTrack, TRACKS } from './tracks.js'
14
- import { parseDoc } from './doc.js'
14
+ import { MAX_CHARS, parseDoc } from './doc.js'
15
+ import { cleanupText, estimate, plainTidy } from './docprep.js'
16
+ import { extractText } from './extract.js'
17
+ import { dataRoot } from './paths.js'
15
18
  import { createHash } from 'node:crypto'
16
19
  import { getVpsConfig, sendLesson } from './vps.js'
17
20
 
@@ -108,6 +111,55 @@ export class Importer {
108
111
  return out
109
112
  }
110
113
 
114
+ /**
115
+ * 导入文档第一步:取出文字、存档、预估,等用户确认(不调用 AI)
116
+ * @param {string} file 上传或本机的文件;text 模式时为 null
117
+ * @param {{name:string, text?:string, removeSource?:boolean}} o
118
+ */
119
+ async prepareDoc(file, o) {
120
+ const ext = (/\.[a-z0-9]+$/i.exec(o.name || '') || ['.txt'])[0].toLowerCase()
121
+ const r = file ? await extractText(file, o.name) : { type: '粘贴的文字', text: String(o.text || '') }
122
+ if (r.scanned) throw Object.assign(new Error('这是扫描版 PDF(每页都是图片),读不出文字。先用手机或电脑的文字识别(OCR)、或让 AI 把它转成文字(.txt),再导入。'), { status: 400 })
123
+ const text = r.text.trim()
124
+ if ((text.match(/[A-Za-z]+/g) || []).length < 20) throw Object.assign(new Error('文档里几乎没有英文'), { status: 400 })
125
+ if (text.length > MAX_CHARS) {
126
+ throw Object.assign(new Error(`太长了:约 ${Math.round(text.length / 6 / 1000)} 千词。一次最多约 6 万词(${MAX_CHARS / 1000} 千字符,相当于 7 小时左右的朗读),拆成几份导入。`), { status: 400 })
127
+ }
128
+ // 已经是「# 章节」格式的 Markdown 不用 AI 再整理
129
+ const formatted = (ext === '.md' || ext === '.markdown' || !file) && /^#\s+\S/m.test(text)
130
+ const llm = this.deps.llm()
131
+ const docId = createHash('sha1').update(text).digest('hex').slice(0, 12)
132
+ const dir = join(dataRoot(), 'docs', docId)
133
+ await mkdir(dir, { recursive: true })
134
+ if (file) await copyFile(file, join(dir, `original${ext}`))
135
+ if (o.removeSource && file) await rm(file, { force: true })
136
+ await writeFile(join(dir, 'raw.txt'), text)
137
+ // 粘贴的文字没有文件名:标题留空,整理后用第一章的标题
138
+ const title = file ? (o.name || '').replace(/\.[a-z0-9]+$/i, '').replace(/[_]+/g, ' ').trim() : (o.title || '')
139
+ const meta = {
140
+ docId, name: o.name || '粘贴的文字', title, type: r.type, ext, pages: r.pages || null,
141
+ needsCleanup: !formatted, model: llm && llm.label ? llm.label() : '', hasModel: Boolean(llm),
142
+ estimate: estimate(text, { needsCleanup: !formatted }), createdAt: Date.now(),
143
+ preview: text.slice(0, 600),
144
+ }
145
+ await writeFile(join(dir, 'meta.json'), JSON.stringify(meta, null, 1))
146
+ return meta
147
+ }
148
+
149
+ /** 导入文档第二步:用户确定后排队——AI 整理 → 存档 → 分课(每课再朗读、翻译) */
150
+ async confirmDoc(docId, o = {}) {
151
+ if (!/^[a-f0-9]{12}$/.test(String(docId))) throw Object.assign(new Error('文档编号不对'), { status: 400 })
152
+ const dir = join(dataRoot(), 'docs', docId)
153
+ let meta
154
+ try { meta = JSON.parse(await readFile(join(dir, 'meta.json'), 'utf8')) } catch { throw Object.assign(new Error('找不到这份文档,重新选一次文件'), { status: 404 }) }
155
+ meta.title = (o.title || meta.title || '').trim()
156
+ meta.voice = { engine: o.engine, voice: o.voice, speed: o.speed }
157
+ meta.confirmedAt = Date.now()
158
+ await writeFile(join(dir, 'meta.json'), JSON.stringify(meta, null, 1))
159
+ this.enqueue({ kind: 'docprep', docId, title: `整理文档:${meta.title || meta.name}` })
160
+ return meta
161
+ }
162
+
111
163
  /**
112
164
  * 导入英文文档:一章一课;每课排一个任务:朗读 → 翻译讲解 → 概述
113
165
  * @param {string} text 整理好的纯文本(# 章节标题 分章)
@@ -124,8 +176,8 @@ export class Importer {
124
176
  if (existing && existing.status === 'ready') { out.push({ lessonId: id, existed: true, title: existing.title }); continue }
125
177
  const lesson = existing || {
126
178
  id, version: 1, kind: 'doc',
127
- title: c.title, group: doc.chapters.length > 1 ? doc.title : (o.group || '文档'), order: k,
128
- source: { type: 'doc', name: doc.title, chars: text.length },
179
+ title: c.title, group: doc.title || o.group || '文档', order: k,
180
+ source: { type: 'doc', name: o.docName || doc.title, chars: text.length, ...(o.docId ? { docId: o.docId } : {}) },
129
181
  status: 'queued', createdAt: Date.now(),
130
182
  sentences: c.sentences.map((s, i) => ({ i, start: 0, end: 0, text: s.text, en: s.text, zh: '', words: [], sound: '', skip: false, para: s.para, ...(s.heading ? { heading: true } : {}) })),
131
183
  }
@@ -239,6 +291,32 @@ export class Importer {
239
291
  async run(job) {
240
292
  const signal = job.abort.signal
241
293
  const opt = this.opts()
294
+ if (job.kind === 'docprep') {
295
+ const dir = join(dataRoot(), 'docs', job.docId)
296
+ const meta = JSON.parse(await readFile(join(dir, 'meta.json'), 'utf8'))
297
+ const raw = await readFile(join(dir, 'raw.txt'), 'utf8')
298
+ let text = raw
299
+ let warnings = []
300
+ if (meta.needsCleanup) {
301
+ const llm = this.deps.llm()
302
+ if (llm) {
303
+ this.stage(job, 'AI 整理文档', 0, Math.max(1, meta.estimate.chunks), '段')
304
+ const r = await cleanupText(llm, raw, { title: meta.title, signal, concurrency: opt.concurrency || 3, onProgress: (d, t) => this.stage(job, 'AI 整理文档', d, t, '段') })
305
+ text = r.text
306
+ warnings = r.warnings
307
+ } else {
308
+ text = plainTidy(raw)
309
+ warnings = ['没有可用的大模型:只简单接了断行,页眉页码可能还在']
310
+ }
311
+ }
312
+ await writeFile(join(dir, 'clean.md'), text)
313
+ const r = await this.importDoc(text, { title: meta.title, ...meta.voice, docId: job.docId, docName: meta.name })
314
+ Object.assign(meta, { cleanedAt: Date.now(), warnings, lessons: r.items.map(x => x.lessonId) })
315
+ await writeFile(join(dir, 'meta.json'), JSON.stringify(meta, null, 1))
316
+ if (warnings.length) job.message = warnings[0]
317
+ return
318
+ }
319
+
242
320
  if (job.kind === 'doc') {
243
321
  if (!existsSync(audioPath(job.lessonId))) {
244
322
  const l = await getLesson(job.lessonId)
package/lib/server.js CHANGED
@@ -10,6 +10,10 @@
10
10
  // POST <prefix>/import {path, title?} 导入本机的文件或文件夹
11
11
  // POST <prefix>/import-doc {text, title?, engine?, voice?, speed?} 导入整理好的英文文档(# 章节 分课),AI 朗读
12
12
  // GET <prefix>/doc-help 导入文档的格式要求、给别的 AI 的整理指令
13
+ // POST <prefix>/doc/upload?name= 上传文档(PDF / Word / EPUB / 网页 / TXT / MD)→ 取出文字、存档、预估,等确认
14
+ // POST <prefix>/doc/text {text, title?} 粘贴的文字,同上
15
+ // POST <prefix>/doc/confirm {docId, title?, engine?, voice?, speed?} 确定:AI 整理 → 分课朗读翻译
16
+ // POST <prefix>/doc/cancel {docId} · GET /doc/file?id=&which=clean|raw|original 存档
13
17
  // POST <prefix>/upload?name= 上传音频(请求体就是文件)
14
18
  // POST <prefix>/grade {id, i, g} 给一句评分(0 没懂 / 1 半懂 / 2 懂了)
15
19
  // POST <prefix>/resume | retranslate | reset | delete {id}
@@ -34,7 +38,7 @@ import { mkdir, readFile, rm, stat } from 'node:fs/promises'
34
38
  import { extname, join } from 'node:path'
35
39
  import { pipeline } from 'node:stream/promises'
36
40
  import { findModels } from './models.js'
37
- import { findFfmpeg, uploadsDir } from './paths.js'
41
+ import { dataRoot, findFfmpeg, uploadsDir } from './paths.js'
38
42
  import { AUDIO_EXT } from './importer.js'
39
43
  import { listTracks, trackAudio, trackFlac, TRACKS, zhSample } from './tracks.js'
40
44
  import { buildDrill, DRILL_RE } from './drill.js'
@@ -43,6 +47,7 @@ import { today } from './today.js'
43
47
  import { exportText, FORMATS } from './export.js'
44
48
  import { EDGE_EN_VOICES, EDGE_ZH_VOICES, edgeStatus, installEdge } from './edge.js'
45
49
  import { MAX_CHARS, PREP_PROMPT } from './doc.js'
50
+ import { DOC_EXT } from './extract.js'
46
51
  import { detect, getVpsConfig, panelUrl, saveVpsConfig, sshHosts } from './vps.js'
47
52
  import { KOKORO_VOICES, kokoroReady, kokoroZhReady, SAY_HINT, sayVoices, ZH_VOICES } from './voices.js'
48
53
  import { checkId, deleteLesson, editSentence, getLesson, getProgress, gradeSentence, listLessons, mergeWithNext, resetProgress, setProgressFields, setStar, splitSentence, summary } from './store.js'
@@ -56,6 +61,8 @@ const STATIC = {
56
61
  for (const name of ['panel', 'core', 'study', 'listen', 'edit', 'dict']) STATIC[`/${name}.js`] = [`web/${name}.js`, 'text/javascript; charset=utf-8']
57
62
 
58
63
  const MAX_UPLOAD = 2 * 1024 * 1024 * 1024
64
+ const MAX_DOC_UPLOAD = 100 * 1024 * 1024
65
+ const DOC_ID = /^[a-f0-9]{12}$/
59
66
 
60
67
  function send(res, status, body) {
61
68
  res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
@@ -183,7 +190,26 @@ export function createHandler(o) {
183
190
  return res.end(out.text)
184
191
  }
185
192
  if (path === '/formats') return send(res, 200, { formats: FORMATS })
186
- if (path === '/doc-help') return send(res, 200, { prompt: PREP_PROMPT, maxChars: MAX_CHARS })
193
+ if (path === '/doc-help') return send(res, 200, { prompt: PREP_PROMPT, maxChars: MAX_CHARS, maxUpload: MAX_DOC_UPLOAD, ext: DOC_EXT })
194
+ if (path === '/doc/file') {
195
+ const id = q.get('id')
196
+ if (!DOC_ID.test(String(id))) return send(res, 400, { error: '文档编号不对' })
197
+ const dir = join(dataRoot(), 'docs', id)
198
+ let meta
199
+ try { meta = JSON.parse(await readFile(join(dir, 'meta.json'), 'utf8')) } catch { return send(res, 404, { error: '没有这份文档' }) }
200
+ const which = q.get('which') === 'original' ? 'original' : q.get('which') === 'raw' ? 'raw' : 'clean'
201
+ const file = which === 'original' ? join(dir, `original${meta.ext}`) : join(dir, which === 'raw' ? 'raw.txt' : 'clean.md')
202
+ const base = (meta.title || meta.name || 'document').replace(/\.[a-z0-9]+$/i, '')
203
+ const filename = which === 'original' ? meta.name : `${base}${which === 'raw' ? '(原始文字).txt' : '(整理后).md'}`
204
+ let buf
205
+ try { buf = await readFile(file) } catch { return send(res, 404, { error: which === 'clean' ? '还没整理好' : '没有这个文件' }) }
206
+ res.writeHead(200, {
207
+ 'content-type': which === 'original' ? 'application/octet-stream' : 'text/plain; charset=utf-8',
208
+ 'content-disposition': `attachment; filename="document${extname(filename)}"; filename*=UTF-8''${encodeURIComponent(filename.replace(/[\\/:*?"<>|]/g, '_'))}`,
209
+ 'cache-control': 'no-store',
210
+ })
211
+ return res.end(buf)
212
+ }
187
213
  if (path === '/update') {
188
214
  if (!o.manage) return send(res, 200, { managed: false })
189
215
  return send(res, 200, { managed: true, ...(await o.manage.info(q.get('force') === '1')) })
@@ -200,6 +226,19 @@ export function createHandler(o) {
200
226
 
201
227
  if (!safePost(req)) return send(res, 403, { error: '只接受本页面发出的请求' })
202
228
 
229
+ if (path === '/doc/upload') {
230
+ const name = String(q.get('name') || 'document.txt').replace(/[\\/]/g, '_').slice(0, 200)
231
+ const ext = extname(name).toLowerCase()
232
+ if (!DOC_EXT.includes(ext)) return send(res, 400, { error: '不支持这种文件:可以导入 PDF、Word(.docx)、EPUB、网页(.html)、TXT、Markdown' })
233
+ if (Number(req.headers['content-length'] || 0) > MAX_DOC_UPLOAD) return send(res, 413, { error: '文件太大(最多 100 MB)' })
234
+ await mkdir(uploadsDir(), { recursive: true })
235
+ const tmp = join(uploadsDir(), `${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}`)
236
+ try { await pipeline(req, createWriteStream(tmp)) } catch (e) { await rm(tmp, { force: true }); throw e }
237
+ try {
238
+ return send(res, 200, { ok: true, doc: await o.importer.prepareDoc(tmp, { name, removeSource: true }) })
239
+ } finally { await rm(tmp, { force: true }) }
240
+ }
241
+
203
242
  if (path === '/upload') {
204
243
  const name = String(q.get('name') || 'audio.mp3').replace(/[\\/]/g, '_').slice(0, 200)
205
244
  const ext = extname(name).toLowerCase()
@@ -219,6 +258,31 @@ export function createHandler(o) {
219
258
 
220
259
  // 导入文档的请求体是整篇文字,放宽到 2 MB;其余都是小参数
221
260
  const body = await readJson(req, path === '/import-doc' ? 2 * 1024 * 1024 : 64 * 1024)
261
+ if (path === '/doc/text') {
262
+ if (typeof body.text !== 'string' || !body.text.trim()) return send(res, 400, { error: '没有文字' })
263
+ const title = typeof body.title === 'string' && body.title.trim() ? body.title.trim().slice(0, 120) : ''
264
+ return send(res, 200, { ok: true, doc: await o.importer.prepareDoc(null, { name: title || '粘贴的文字', title, text: body.text }) })
265
+ }
266
+ if (path === '/doc/confirm') {
267
+ const speed = Number(body.speed)
268
+ const meta = await o.importer.confirmDoc(String(body.docId || ''), {
269
+ title: typeof body.title === 'string' ? body.title.slice(0, 120) : '',
270
+ engine: body.engine === 'kokoro' ? 'kokoro' : 'edge',
271
+ voice: typeof body.voice === 'string' ? body.voice.slice(0, 60) : undefined,
272
+ speed: Number.isFinite(speed) && speed >= 0.6 && speed <= 1.4 ? speed : 1,
273
+ })
274
+ return send(res, 200, { ok: true, doc: meta })
275
+ }
276
+ if (path === '/doc/cancel') {
277
+ const id = String(body.docId || '')
278
+ if (!DOC_ID.test(id)) return send(res, 400, { error: '文档编号不对' })
279
+ const dir = join(dataRoot(), 'docs', id)
280
+ try {
281
+ const meta = JSON.parse(await readFile(join(dir, 'meta.json'), 'utf8'))
282
+ if (!meta.confirmedAt) await rm(dir, { recursive: true, force: true }) // 确认过的是存档,不删
283
+ } catch { /* 本来就没有 */ }
284
+ return send(res, 200, { ok: true })
285
+ }
222
286
  if (path === '/import-doc') {
223
287
  if (typeof body.text !== 'string' || !body.text.trim()) return send(res, 400, { error: '没有文字:粘贴进来,或选一个 .txt / .md 文件' })
224
288
  const speed = Number(body.speed)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-tingdong",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "听懂:把英文有声读物(MP3)或英文文档变成听力课。本地识别 / AI 朗读每一句英文、AI 翻译讲解,再用盲听、精听、间隔复习把它练到「一听就懂」。DeepSeek Harness (dsh) 插件。",
5
5
  "keywords": [
6
6
  "dsh-plugin",
@@ -54,7 +54,8 @@
54
54
  "node": ">=20"
55
55
  },
56
56
  "dependencies": {
57
- "sherpa-onnx-node": "1.13.8"
57
+ "sherpa-onnx-node": "1.13.8",
58
+ "unpdf": "1.9.1"
58
59
  },
59
60
  "dsh": {
60
61
  "bundle": {
package/web/panel.css CHANGED
@@ -333,3 +333,13 @@ code.cmd { font: 12px ui-monospace, SFMono-Regular, Menlo, monospace; padding: 6
333
333
  .kind { font-size: 11px; font-weight: 600; padding: 0 6px; border-radius: 4px; margin-right: 6px; vertical-align: 1px; background: var(--accent-soft); color: var(--accent); }
334
334
  .kind.doc { background: rgba(201, 138, 18, .14); color: var(--mid); }
335
335
  @media (max-width: 640px) { .new-grid { grid-template-columns: 1fr; } }
336
+
337
+ /* —— 导入文档:确认 —— */
338
+ #docDrop { padding: 22px 14px; }
339
+ #docDrop .new-ico { font-size: 28px; }
340
+ .doc-confirm .ai-note { margin: 10px 0; padding: 10px 12px; border-radius: 10px; background: var(--accent-soft); line-height: 1.6; font-size: 13px; }
341
+ .doc-confirm .est { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; text-align: center; margin: 6px 0 8px; }
342
+ .doc-confirm .est b { display: block; font-size: 20px; font-variant-numeric: tabular-nums; }
343
+ .doc-confirm .est span { font-size: 11px; color: var(--sub); }
344
+ .doc-confirm .form input[type=text] { margin: 0; }
345
+ @media (max-width: 380px) { .doc-confirm .est { grid-template-columns: repeat(2, 1fr); } }
package/web/panel.js CHANGED
@@ -233,72 +233,111 @@ function newAudioPanel(box) {
233
233
  drop.ondrop = e => { e.preventDefault(); drop.classList.remove('over'); uploadFiles([...e.dataTransfer.files]) }
234
234
  }
235
235
 
236
- let docHelp = null
237
- async function newDocPanel(box) {
238
- box.innerHTML = '<div class="card"><div class="faint">…</div></div>'
239
- try {
240
- if (!docHelp) docHelp = await api('doc-help')
241
- if (!S.voices) S.voices = await api('voices')
242
- } catch (e) { box.innerHTML = `<div class="card small" style="color:var(--bad)">${esc(e.message)}</div>`; return }
243
- const V = S.voices
236
+ // —— 导入文档:选文件 → 取出文字、预估 → 确认(提示会用 AI 整理)→ 后台整理、存档、分课、朗读、翻译 ——
237
+ let pendingDoc = null // 已经读出文字、等确认的文档
238
+ function newDocPanel(box) {
239
+ if (pendingDoc) return docConfirm(box, pendingDoc)
240
+ box.innerHTML = `<div class="card">
241
+ <h3>导入英文文档</h3>
242
+ <div class="drop" id="docDrop">
243
+ <div class="new-ico">📄</div>
244
+ <div><b>把文档拖到这里</b>,或 <a href="#" class="link" id="docPick">选择文件</a></div>
245
+ <div class="faint" style="margin-top:4px">PDF、Word(.docx)、EPUB 电子书、网页(.html)、TXT、Markdown · 最大 100 MB · 约 6 万词以内</div>
246
+ <input type="file" id="docFile" accept=".pdf,.docx,.epub,.html,.htm,.txt,.md,.markdown" hidden>
247
+ </div>
248
+ <div class="faint" style="margin-top:8px">导入后先让你确认:AI 会把文字整理好(去掉页眉页码、修好断行、分好章节,原文不改),再朗读成音频、逐句翻译讲解。</div>
249
+ <details style="margin-top:8px"><summary class="link">没有文件?直接粘贴英文</summary>
250
+ <textarea id="docText" rows="6" placeholder="把英文文章粘贴到这里"></textarea>
251
+ <button class="btn" id="docPaste" style="margin-top:6px">下一步</button>
252
+ </details>
253
+ <div id="docBusy"></div>
254
+ </div>`
255
+ const busy = msg => { $('#docBusy').innerHTML = msg ? `<div class="faint" style="margin-top:8px">${msg}</div>` : '' }
256
+ const got = doc => { pendingDoc = doc; docConfirm(box, doc) }
257
+ const upload = f => {
258
+ if (!f) return
259
+ if (!/\.(pdf|docx|epub|html?|txt|md|markdown)$/i.test(f.name)) return toast('不支持这种文件:可以导入 PDF、Word、EPUB、网页、TXT、Markdown')
260
+ if (f.size > 100 * 1024 * 1024) return toast('文件太大(最多 100 MB)')
261
+ const xhr = new XMLHttpRequest()
262
+ xhr.open('POST', `doc/upload?name=${encodeURIComponent(f.name)}`)
263
+ xhr.setRequestHeader('content-type', 'application/octet-stream')
264
+ xhr.setRequestHeader('x-tingdong', '1')
265
+ xhr.upload.onprogress = e => { if (e.lengthComputable) busy(`正在上传 ${esc(f.name)}… ${Math.round(e.loaded / e.total * 100)}%`) }
266
+ xhr.upload.onload = () => busy(`正在读取 ${esc(f.name)} 里的文字…`)
267
+ xhr.onload = () => {
268
+ let j = {}
269
+ try { j = JSON.parse(xhr.responseText) } catch { /* 忽略 */ }
270
+ busy('')
271
+ if (xhr.status >= 400 || !j.doc) return toast(j.error || '读取失败')
272
+ got(j.doc)
273
+ }
274
+ xhr.onerror = () => { busy(''); toast('上传失败') }
275
+ busy(`正在上传 ${esc(f.name)}…`)
276
+ xhr.send(f)
277
+ }
278
+ $('#docPick').onclick = e => { e.preventDefault(); $('#docFile').click() }
279
+ $('#docFile').onchange = e => upload(e.target.files[0])
280
+ const drop = $('#docDrop')
281
+ drop.ondragover = e => { e.preventDefault(); drop.classList.add('over') }
282
+ drop.ondragleave = () => drop.classList.remove('over')
283
+ drop.ondrop = e => { e.preventDefault(); drop.classList.remove('over'); upload(e.dataTransfer.files[0]) }
284
+ $('#docPaste').onclick = async () => {
285
+ const text = $('#docText').value
286
+ if (!text.trim()) return toast('先粘贴英文')
287
+ busy('正在读取…')
288
+ try { got((await api('doc/text', { text })).doc) } catch (e) { toast(e.message) }
289
+ busy('')
290
+ }
291
+ }
292
+
293
+ /** 确认卡片:读到了什么、AI 会做什么、预计多久,选朗读的声音,确定后开始 */
294
+ async function docConfirm(box, doc) {
295
+ try { if (!S.voices) S.voices = await api('voices') } catch { /* 下面按没装 Edge 处理 */ }
296
+ const V = S.voices || {}
244
297
  const edgeReady = V.edge && V.edge.ready
245
298
  const engine = store.get('docEngine', 'edge')
246
299
  const list = engine === 'edge' ? ((V.edge && V.edge.en) || []) : ((V.kokoro && V.kokoro.voices) || []).map(v => ({ ...v, gender: v.id[1] === 'm' ? 'm' : 'f' }))
247
300
  const voice = store.get(engine === 'edge' ? 'docVoiceEdge' : 'docVoiceKokoro', engine === 'edge' ? 'en-US-AndrewNeural' : 'am_michael')
248
301
  const speed = store.get('docSpeed', 1)
249
302
  const group = (g, name) => `<optgroup label="${name}">${list.filter(x => x.gender === g).map(x => `<option value="${esc(x.id)}" ${x.id === voice ? 'selected' : ''}>${esc(x.label)}</option>`).join('')}</optgroup>`
250
- box.innerHTML = `<div class="card doc-new">
251
- <h3>导入英文文档</h3>
252
- <div class="step"><span class="num">1</span><div class="grow">
253
- <b>先让 AI 把材料整理好</b>
254
- <div class="faint">PDF、电子书、网页都行:把下面这段话连同材料一起发给 ChatGPT、DeepSeek、Kimi 等,它会整理成能直接导入的英文文字(只留正文、修好断行、按章节分好)。扫描版 PDF 也可以,让它先识别文字。</div>
255
- ${S.status && S.status.host === 'dsh' ? '<div class="faint" style="margin-top:4px">💡 更省事:在 DSH 的对话里把 PDF 发给 AI,说「按听懂的格式整理后导入听懂」,它会整理好直接导入,不用复制粘贴。</div>' : ''}
256
- <details style="margin-top:6px"><summary class="link">查看整理要求</summary><pre class="prompt">${esc(docHelp.prompt)}</pre></details>
257
- <button class="btn" id="copyPrompt" style="margin-top:6px">复制整理要求</button>
258
- </div></div>
259
- <div class="step"><span class="num">2</span><div class="grow">
260
- <b>把整理好的文字粘贴进来</b>
261
- <textarea id="docText" rows="8" placeholder="# 第一章标题&#10;&#10;First paragraph of the chapter...&#10;&#10;Second paragraph...&#10;&#10;# 第二章标题&#10;&#10;..."></textarea>
262
- <div class="row" style="margin-top:4px">
263
- <span class="faint grow" id="docCount">每个「# 标题」开始一课;没有标题就整篇一课</span>
264
- <a href="#" class="link" id="docPick">或选择 .txt / .md 文件</a>
265
- <input type="file" id="docFile" accept=".txt,.md,.markdown,text/plain,text/markdown" hidden>
266
- </div>
267
- <input type="text" id="docTitle" placeholder="文档标题(可不填,用第一章的标题)" style="margin-top:6px">
268
- </div></div>
269
- <div class="step"><span class="num">3</span><div class="grow">
270
- <b>选朗读的声音</b>
271
- <div class="chips" style="margin-top:4px">
303
+ const e = doc.estimate
304
+ const blocked = engine === 'edge' && !edgeReady
305
+ box.innerHTML = `<div class="card doc-confirm">
306
+ <h3>确认导入</h3>
307
+ <div class="row"><span class="new-ico">📄</span><div class="grow"><b>${esc(doc.name)}</b>
308
+ <div class="faint">${esc(doc.type)}${doc.pages ? ` · ${doc.pages} 页` : ''} · 约 ${e.words.toLocaleString()} 个英文单词</div></div></div>
309
+ <details style="margin-top:6px"><summary class="link">看看读到的文字</summary><pre class="prompt">${esc(doc.preview)}${doc.preview.length >= 600 ? '…' : ''}</pre></details>
310
+ <div class="ai-note">
311
+ ${doc.needsCleanup
312
+ ? (doc.hasModel
313
+ ? `🤖 <b>会用 AI 整理文字</b>(${esc(doc.model)}):去掉页眉页码和目录、修好断行、分好章节。只整理格式,原文一个字都不改,不翻译。整理好的文字会存档,随时能下载。`
314
+ : '⚠ 没有可用的大模型:只能简单接好断行,页眉页码可能留下。配好 DSH 的默认模型再导入效果更好。')
315
+ : '✓ 已经是整理好的格式(有「# 章节」),不用 AI 整理。'}
316
+ </div>
317
+ <div class="est">
318
+ <div><b>${e.lessons}</b><span>约几课</span></div>
319
+ <div><b>${e.audioMinutes}</b><span>朗读分钟</span></div>
320
+ <div><b>${e.minutes}</b><span>处理分钟</span></div>
321
+ <div><b>${e.tokens >= 10000 ? (e.tokens / 10000).toFixed(1) + '万' : e.tokens.toLocaleString()}</b><span>模型 token</span></div>
322
+ </div>
323
+ <div class="faint">处理时间取决于模型速度,都在后台进行;分好的课做好一课就能先学一课。${e.words > 20000 ? '这份比较长,可以先导入其中一部分试试。' : ''}</div>
324
+ <div class="form">
325
+ <label>标题</label><input type="text" id="docTitle" value="${esc(doc.title || '')}" placeholder="文档标题">
326
+ <label>朗读</label><div class="chips">
272
327
  <button class="chip ${engine === 'edge' ? 'on' : ''}" data-docengine="edge">微软 Edge(最自然,要联网)</button>
273
328
  <button class="chip ${engine === 'kokoro' ? 'on' : ''}" data-docengine="kokoro">Kokoro(离线)</button>
274
329
  </div>
275
- ${engine === 'edge' && !edgeReady ? `<div class="faint">Edge 语音要先装一个小组件(edge-tts,约 15 MB,装在插件自己的目录里,需要电脑上有 Python 3)。文字会发到微软的服务器朗读。</div>
276
- <button class="btn" id="docEdgeInstall" style="margin-top:6px">安装 Edge 语音组件</button>` : `
277
- <div class="row" style="margin-top:4px"><select id="docVoice">${group('m', '男声')}${group('f', '女声')}</select></div>
278
- <div class="chips" style="margin-top:6px">${[0.85, 1, 1.15].map(x => `<button class="chip ${x === speed ? 'on' : ''}" data-docspeed="${x}">${x === 1 ? '正常语速' : x < 1 ? '慢一点' : '快一点'}</button>`).join('')}</div>`}
279
- </div></div>
280
- <button class="btn primary big" id="docGo" ${engine === 'edge' && !edgeReady ? 'disabled' : ''}>开始导入</button>
330
+ ${blocked ? `<label></label><div><div class="faint">Edge 语音要先装一个小组件(edge-tts,约 15 MB,装在插件自己的目录里,需要电脑上有 Python 3)。朗读时文字会发到微软的服务器。</div>
331
+ <button class="btn" id="docEdgeInstall" style="margin-top:6px">安装 Edge 语音组件</button></div>` : `
332
+ <label>声音</label><select id="docVoice">${group('m', '男声')}${group('f', '女声')}</select>
333
+ <label>语速</label><div class="chips">${[0.85, 1, 1.15].map(x => `<button class="chip ${x === speed ? 'on' : ''}" data-docspeed="${x}">${x === 1 ? '正常' : x < 1 ? '慢一点' : '快一点'}</button>`).join('')}</div>`}
334
+ </div>
335
+ <div class="row" style="margin-top:12px">
336
+ <button class="btn primary big" id="docGo" ${blocked ? 'disabled' : ''}>确定,开始导入</button>
337
+ <button class="btn ghost" id="docCancel">取消</button>
338
+ </div>
281
339
  </div>`
282
- const ta = $('#docText')
283
- ta.value = store.get('docDraft', '')
284
- const count = () => {
285
- const t = ta.value
286
- const chapters = (t.match(/^#\s+\S/gm) || []).length
287
- const words = (t.match(/[A-Za-z']+/g) || []).length
288
- $('#docCount').textContent = t.trim() ? `${words.toLocaleString()} 个英文单词 · ${chapters ? `${chapters} 章(每章一课)` : '没有「# 标题」:整篇一课'}` : '每个「# 标题」开始一课;没有标题就整篇一课'
289
- }
290
- ta.oninput = () => { count(); try { store.set('docDraft', ta.value.slice(0, 200000)) } catch { /* 太大就不存草稿 */ } }
291
- count()
292
- $('#copyPrompt').onclick = () => copy(docHelp.prompt)
293
- $('#docPick').onclick = e => { e.preventDefault(); $('#docFile').click() }
294
- $('#docFile').onchange = async e => {
295
- const f = e.target.files[0]
296
- if (!f) return
297
- ta.value = await f.text()
298
- if (!$('#docTitle').value) $('#docTitle').value = f.name.replace(/\.(txt|md|markdown)$/i, '')
299
- ta.oninput()
300
- }
301
- box.querySelectorAll('[data-docengine]').forEach(b => { b.onclick = () => { store.set('docEngine', b.dataset.docengine); newDocPanel(box) } })
340
+ box.querySelectorAll('[data-docengine]').forEach(b => { b.onclick = () => { store.set('docEngine', b.dataset.docengine); docConfirm(box, doc) } })
302
341
  box.querySelectorAll('[data-docspeed]').forEach(b => {
303
342
  b.onclick = () => { store.set('docSpeed', Number(b.dataset.docspeed)); box.querySelectorAll('[data-docspeed]').forEach(x => x.classList.toggle('on', x === b)) }
304
343
  })
@@ -312,22 +351,24 @@ async function newDocPanel(box) {
312
351
  const r = await api('edge/install', {})
313
352
  toast(r.ready ? `Edge 语音组件已装好(${r.version})` : '安装没成功')
314
353
  S.voices = null
315
- newDocPanel(box)
316
- } catch (e) { toast(e.message); inst.disabled = false; inst.textContent = '安装 Edge 语音组件' }
354
+ docConfirm(box, doc)
355
+ } catch (err) { toast(err.message); inst.disabled = false; inst.textContent = '安装 Edge 语音组件' }
356
+ }
357
+ $('#docCancel').onclick = async () => {
358
+ pendingDoc = null
359
+ api('doc/cancel', { docId: doc.docId }).catch(() => {})
360
+ newDocPanel(box)
317
361
  }
318
362
  $('#docGo').onclick = async () => {
319
363
  const btn = $('#docGo')
320
364
  btn.disabled = true
321
365
  try {
322
- const r = await api('import-doc', { text: ta.value, title: $('#docTitle').value.trim(), engine, voice: vs ? vs.value : undefined, speed: store.get('docSpeed', 1) })
323
- const fresh = r.items.filter(x => !x.existed)
324
- toast(fresh.length ? `《${r.title}》分成 ${r.items.length} 课,正在朗读和翻译` : '这份文档导入过了')
325
- store.set('docDraft', '')
326
- ta.value = ''
327
- count()
366
+ await api('doc/confirm', { docId: doc.docId, title: $('#docTitle').value.trim(), engine, voice: vs ? vs.value : undefined, speed: store.get('docSpeed', 1) })
367
+ toast(doc.needsCleanup && doc.hasModel ? '开始了:AI 整理 → 分课 → 朗读 → 翻译,进度在下面' : '开始了:分课 → 朗读 → 翻译,进度在下面')
368
+ pendingDoc = null
369
+ newDocPanel(box)
328
370
  pollJobsNow()
329
- } catch (e) { toast(e.message) }
330
- btn.disabled = false
371
+ } catch (err) { toast(err.message); btn.disabled = false }
331
372
  }
332
373
  }
333
374
 
@@ -572,6 +613,7 @@ function paneOverview(pane, l, st) {
572
613
  <div class="card">
573
614
  ${l.titleZh ? `<div style="font-weight:600">${esc(l.titleZh)}</div><div class="faint">${esc(l.title)}</div>` : `<div style="font-weight:600">${esc(l.title)}</div>`}
574
615
  <div class="faint" style="margin-top:2px">${l.kind === 'doc' ? `文档 · 朗读:${esc(l.voiceLabel || 'AI')} · ` : ''}${fmtDur(l.duration)} · ${l.sentences.length} 句${l.model ? ` · 翻译:${esc(l.model)}` : ''}</div>
616
+ ${l.source && l.source.docId ? `<div class="faint" style="margin-top:2px">原文档:${esc(l.source.name)} · <a class="link" href="doc/file?id=${l.source.docId}&which=original" download>下载原文件</a> · <a class="link" href="doc/file?id=${l.source.docId}&which=clean" download>整理后的文字</a></div>` : ''}
575
617
  ${l.summaryZh ? `<p class="summary">${esc(l.summaryZh)}</p>` : ''}
576
618
  ${l.note ? `<div class="small" style="color:var(--mid)">${esc(l.note)}</div>` : ''}
577
619
  ${l.status === 'error' ? `<div class="small" style="color:var(--bad)">${esc(l.error)}</div>` : ''}