dsh-tingdong 0.3.0 → 0.4.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 +17 -5
- package/bin/dsh-tingdong.js +28 -0
- package/lib/doc.js +132 -0
- package/lib/edge.js +19 -0
- package/lib/importer.js +54 -5
- package/lib/pcm.js +6 -3
- package/lib/server.js +35 -8
- package/lib/store.js +2 -0
- package/lib/tracks.js +46 -4
- package/lib/translate.js +8 -6
- package/lib/tts-worker.js +26 -1
- package/package.json +2 -2
- package/plugin/client.js +102 -7
- package/plugin/index.js +14 -0
- package/plugin/manage.js +129 -0
- package/plugin/tools.js +29 -0
- package/web/core.js +3 -1
- package/web/listen.js +3 -3
- package/web/panel.css +32 -0
- package/web/panel.html +1 -0
- package/web/panel.js +187 -26
package/lib/translate.js
CHANGED
|
@@ -48,9 +48,10 @@ export function cleanItem(raw) {
|
|
|
48
48
|
}
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
function batchPrompt(context, items, hint) {
|
|
51
|
+
function batchPrompt(context, items, hint, doc) {
|
|
52
52
|
const lines = []
|
|
53
53
|
if (hint) lines.push(`材料:${hint}`)
|
|
54
|
+
if (doc) lines.push('注意:这是书面文档(会用 AI 朗读给学生听),英文是准确的原文,不是语音识别:en 照抄原句,不要改;skip 一律 false。')
|
|
54
55
|
if (context.length) {
|
|
55
56
|
lines.push('前文(仅供参考,不用输出):')
|
|
56
57
|
for (const c of context) lines.push(`- ${c}`)
|
|
@@ -61,8 +62,8 @@ function batchPrompt(context, items, hint) {
|
|
|
61
62
|
}
|
|
62
63
|
|
|
63
64
|
/** 跑一批;返回 Map<i, item>(可能缺一些) */
|
|
64
|
-
async function runBatch(llm, items, context, hint, signal) {
|
|
65
|
-
const text = await llm.complete({ system: SYSTEM, user: batchPrompt(context, items, hint), maxTokens: 16000, signal })
|
|
65
|
+
async function runBatch(llm, items, context, hint, signal, doc) {
|
|
66
|
+
const text = await llm.complete({ system: SYSTEM, user: batchPrompt(context, items, hint, doc), maxTokens: 16000, signal })
|
|
66
67
|
const json = extractJson(text)
|
|
67
68
|
const got = new Map()
|
|
68
69
|
for (const raw of Array.isArray(json.items) ? json.items : []) {
|
|
@@ -97,12 +98,13 @@ export async function translateSentences(llm, sentences, opt = {}) {
|
|
|
97
98
|
const batches = []
|
|
98
99
|
for (let k = 0; k < todo.length; k += size) batches.push(todo.slice(k, k + size))
|
|
99
100
|
|
|
101
|
+
// 文档课:英文是准确的书面原文,不让模型改;也没有「非讲话」
|
|
100
102
|
const apply = (s, item) => {
|
|
101
|
-
s.en = item.en || s.text
|
|
103
|
+
s.en = opt.doc ? s.text : (item.en || s.text)
|
|
102
104
|
s.zh = item.zh
|
|
103
105
|
s.words = item.words
|
|
104
106
|
s.sound = item.sound
|
|
105
|
-
s.skip = item.skip
|
|
107
|
+
s.skip = opt.doc ? false : item.skip
|
|
106
108
|
}
|
|
107
109
|
const contextFor = first => {
|
|
108
110
|
const idx = sentences.indexOf(first)
|
|
@@ -118,7 +120,7 @@ export async function translateSentences(llm, sentences, opt = {}) {
|
|
|
118
120
|
for (const part of parts) {
|
|
119
121
|
if (opt.signal && opt.signal.aborted) throw new Error('已取消')
|
|
120
122
|
try {
|
|
121
|
-
const got = await runBatch(llm, part, contextFor(part[0]), opt.hint, opt.signal)
|
|
123
|
+
const got = await runBatch(llm, part, contextFor(part[0]), opt.hint, opt.signal, opt.doc)
|
|
122
124
|
for (const s of part) {
|
|
123
125
|
const item = got.get(s.i)
|
|
124
126
|
if (item && (item.zh || item.skip)) { apply(s, item); done++ } else still.push(s)
|
package/lib/tts-worker.js
CHANGED
|
@@ -124,7 +124,7 @@ function sayEngine(opt) {
|
|
|
124
124
|
|
|
125
125
|
/** 按时间表放(中文对译):每句放在 start;比空档长就读快一点,最快 1.6 倍 */
|
|
126
126
|
async function timeline(opt, speak) {
|
|
127
|
-
const enc = createEncoder({ ffmpeg: opt.ffmpeg, out: opt.out, gap: 0, bitrate: opt.bitrate })
|
|
127
|
+
const enc = createEncoder({ ffmpeg: opt.ffmpeg, out: opt.out, flac: opt.flac, gap: 0, bitrate: opt.bitrate })
|
|
128
128
|
const list = (opt.sentences || []).map(s => ({ ...s, text: tidyZh(s.text) })).filter(s => s.text).sort((a, b) => a.start - b.start)
|
|
129
129
|
const base = opt.speed || 1
|
|
130
130
|
let squeezed = 0
|
|
@@ -152,10 +152,35 @@ async function timeline(opt, speak) {
|
|
|
152
152
|
emit({ type: 'done', ...res, squeezed })
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
+
/**
|
|
156
|
+
* 文档朗读:一句接一句,停顿由每句的 pause(这句之前停几秒)决定——
|
|
157
|
+
* 句与句之间短、段落之间长,小标题后面也停一下,听起来像人在念书
|
|
158
|
+
*/
|
|
159
|
+
async function reading(opt, speak) {
|
|
160
|
+
const enc = createEncoder({ ffmpeg: opt.ffmpeg, out: opt.out, gap: 0, bitrate: opt.bitrate })
|
|
161
|
+
const list = (opt.sentences || []).map(s => ({ ...s, text: tidy(s.text) })).filter(s => s.text)
|
|
162
|
+
const AHEAD = opt.engine === 'edge-en' ? 4 : 1
|
|
163
|
+
const pending = []
|
|
164
|
+
let next = 0
|
|
165
|
+
const fill = () => { while (next < list.length && pending.length < AHEAD) { const p = speak(list[next++].text); p.catch(() => {}); pending.push(p) } }
|
|
166
|
+
fill()
|
|
167
|
+
for (const [k, s] of list.entries()) {
|
|
168
|
+
const pcm = await pending.shift()
|
|
169
|
+
fill()
|
|
170
|
+
if (k) await enc.pad(s.pause ?? 0.6)
|
|
171
|
+
await enc.write(s.i, pcm)
|
|
172
|
+
emit({ type: 'progress', done: k + 1, total: list.length })
|
|
173
|
+
}
|
|
174
|
+
const res = await enc.finish()
|
|
175
|
+
emit({ type: 'done', ...res })
|
|
176
|
+
}
|
|
177
|
+
|
|
155
178
|
async function main() {
|
|
156
179
|
const opt = JSON.parse(process.argv[2] || '{}')
|
|
157
180
|
if (opt.engine === 'kokoro-zh') return timeline(opt, kokoroZhEngine(opt))
|
|
158
181
|
if (opt.engine === 'edge-zh') return timeline(opt, edgeEngine(opt))
|
|
182
|
+
if (opt.engine === 'edge-en') return reading(opt, edgeEngine(opt))
|
|
183
|
+
if (opt.mode === 'reading') return reading(opt, kokoroEngine(opt))
|
|
159
184
|
const speak = opt.engine === 'kokoro' ? kokoroEngine(opt) : sayEngine(opt)
|
|
160
185
|
const enc = createEncoder({ ffmpeg: opt.ffmpeg, out: opt.out, gap: opt.gap ?? 0.7 })
|
|
161
186
|
const list = (opt.sentences || []).map(s => ({ i: s.i, text: tidy(s.text) })).filter(s => s.text)
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-tingdong",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "听懂:把英文有声读物(MP3)或英文文档变成听力课。本地识别 / AI 朗读每一句英文、AI 翻译讲解,再用盲听、精听、间隔复习把它练到「一听就懂」。DeepSeek Harness (dsh) 插件。",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dsh-plugin",
|
|
7
7
|
"dsh-plugins",
|
package/plugin/client.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
//
|
|
4
4
|
// 手写单文件 bundle,没有构建链:由 dsh web 客户端的 ModuleLoader 注入(写法照 dsh-pick)。挂两处:
|
|
5
5
|
// - 对话页右侧栏的一个页签:整个学习面板(iframe 指向宿主路由 /api-dsh-tingdong/panel)
|
|
6
|
-
// - 「设置 →
|
|
6
|
+
// - 「设置 → 听懂」:版本、更新、卸载,状态(ffmpeg、识别模型、翻译模型)和用法
|
|
7
7
|
// 面板本身是独立调好的页面(dsh-tingdong serve 也能直接打开),这里原样复用。
|
|
8
8
|
// 硬约束:界面崩了不能影响 dsh,注册一律包在 try/catch 里。
|
|
9
9
|
|
|
@@ -25,18 +25,115 @@ window.__ModuleLoader__.load({
|
|
|
25
25
|
border: 'var(--dsw-alias-border-l3, rgba(127,127,127,.25))',
|
|
26
26
|
good: '#1f9d55',
|
|
27
27
|
warn: '#c98a12',
|
|
28
|
+
accent: 'var(--dsw-alias-link, #2f6bff)',
|
|
29
|
+
danger: '#c94040',
|
|
28
30
|
}
|
|
29
31
|
|
|
30
|
-
function Btn({ onClick, children }) {
|
|
32
|
+
function Btn({ onClick, disabled, primary, danger, title, children }) {
|
|
31
33
|
return h('button', {
|
|
32
|
-
type: 'button', onClick,
|
|
34
|
+
type: 'button', onClick, disabled, title,
|
|
33
35
|
style: {
|
|
34
36
|
appearance: 'none', fontFamily: 'inherit', fontSize: 12, lineHeight: '18px', padding: '2px 10px',
|
|
35
|
-
borderRadius: 6, cursor: '
|
|
37
|
+
borderRadius: 6, cursor: disabled ? 'default' : 'pointer', opacity: disabled ? 0.6 : 1, whiteSpace: 'nowrap',
|
|
38
|
+
border: '1px solid ' + (danger ? C.danger : primary ? C.accent : C.border),
|
|
39
|
+
background: primary ? C.accent : 'transparent',
|
|
40
|
+
color: primary ? '#fff' : danger ? C.danger : C.text,
|
|
36
41
|
},
|
|
37
42
|
}, children)
|
|
38
43
|
}
|
|
39
44
|
|
|
45
|
+
async function api(path, method = 'GET') {
|
|
46
|
+
const r = await fetch('/api-dsh-tingdong/' + path, {
|
|
47
|
+
method, credentials: 'same-origin',
|
|
48
|
+
headers: method === 'POST' ? { 'content-type': 'application/json', 'x-tingdong': '1' } : undefined,
|
|
49
|
+
body: method === 'POST' ? '{}' : undefined,
|
|
50
|
+
})
|
|
51
|
+
return r.json()
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** 给用户自己在终端执行的命令:等宽显示,旁边一个「复制」 */
|
|
55
|
+
function CommandLine({ command }) {
|
|
56
|
+
const [copied, setCopied] = useState(false)
|
|
57
|
+
const copy = async () => {
|
|
58
|
+
try { await navigator.clipboard.writeText(command) } catch {
|
|
59
|
+
const ta = document.createElement('textarea')
|
|
60
|
+
ta.value = command
|
|
61
|
+
ta.style.position = 'fixed'
|
|
62
|
+
ta.style.opacity = '0'
|
|
63
|
+
document.body.appendChild(ta)
|
|
64
|
+
ta.select()
|
|
65
|
+
try { document.execCommand('copy') } catch { /* 实在不行用户还能手动选 */ }
|
|
66
|
+
ta.remove()
|
|
67
|
+
}
|
|
68
|
+
setCopied(true)
|
|
69
|
+
setTimeout(() => setCopied(false), 2000)
|
|
70
|
+
}
|
|
71
|
+
return h('div', { style: { display: 'flex', alignItems: 'center', gap: 6, marginTop: 6, flexWrap: 'wrap' } },
|
|
72
|
+
h('code', { style: { flex: '1 1 auto', minWidth: 0, padding: '4px 8px', borderRadius: 6, fontSize: 12, color: C.text, border: '1px solid ' + C.border, userSelect: 'all', overflowWrap: 'anywhere' } }, command),
|
|
73
|
+
h(Btn, { onClick: copy }, copied ? '已复制' : '复制'))
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* 标题行:听懂 · 版本号 · 更新 ……(最右)卸载
|
|
78
|
+
* 打开设置页时自动去 npm 查新版本;更新、卸载交给 DSH 的插件管理器(宿主路由 /update、/uninstall)
|
|
79
|
+
*/
|
|
80
|
+
function HeaderBar() {
|
|
81
|
+
const [info, setInfo] = useState(null)
|
|
82
|
+
const [phase, setPhase] = useState('checking') // checking | idle | updating | updated | removing | removed
|
|
83
|
+
const [confirm, setConfirm] = useState(false)
|
|
84
|
+
const [msg, setMsg] = useState(null) // { text, command?, ok? }
|
|
85
|
+
const check = (force) => {
|
|
86
|
+
setPhase('checking')
|
|
87
|
+
api('update' + (force ? '?force=1' : '')).then(j => { setInfo(j); setPhase('idle') }).catch(() => setPhase('idle'))
|
|
88
|
+
}
|
|
89
|
+
useEffect(() => { check(false) }, [])
|
|
90
|
+
const update = async () => {
|
|
91
|
+
setPhase('updating'); setMsg(null)
|
|
92
|
+
try {
|
|
93
|
+
const j = await api('update', 'POST')
|
|
94
|
+
setMsg({ text: j.message, command: j.command, ok: j.ok })
|
|
95
|
+
if (j.ok) { setPhase('updated'); return }
|
|
96
|
+
} catch (e) { setMsg({ text: '更新请求失败:' + e.message }) }
|
|
97
|
+
setPhase('idle')
|
|
98
|
+
}
|
|
99
|
+
const uninstall = async () => {
|
|
100
|
+
setConfirm(false); setPhase('removing'); setMsg(null)
|
|
101
|
+
try {
|
|
102
|
+
const j = await api('uninstall', 'POST')
|
|
103
|
+
setMsg({ text: j.message, command: j.command, ok: j.ok })
|
|
104
|
+
setPhase(j.ok ? 'removed' : 'idle')
|
|
105
|
+
} catch {
|
|
106
|
+
// 卸下插件时它自己的接口也随之消失,请求多半就是这么断的
|
|
107
|
+
setPhase('removed')
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
let upd
|
|
111
|
+
if (phase === 'updating') upd = { label: '正在更新…', disabled: true }
|
|
112
|
+
else if (phase === 'updated') upd = { label: '已更新,重启 DSH 生效', disabled: true }
|
|
113
|
+
else if (info && info.restartPending) upd = { label: `v${info.restartPending} 已装好,重启 DSH 生效`, disabled: true }
|
|
114
|
+
else if (info && info.available) upd = { label: `更新到 v${info.latest}`, primary: true, onClick: update }
|
|
115
|
+
else if (phase === 'checking') upd = { label: '检查更新…', disabled: true }
|
|
116
|
+
else upd = { label: info && info.latest ? '已是最新' : '检查更新', onClick: () => check(true), title: '点一下重新检查' }
|
|
117
|
+
const busy = phase === 'updating' || phase === 'removing' || phase === 'removed'
|
|
118
|
+
const managed = !info || info.managed !== false
|
|
119
|
+
return h('div', { style: { marginBottom: 8 } },
|
|
120
|
+
h('div', { style: { display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' } },
|
|
121
|
+
h('span', { style: { fontSize: 15, fontWeight: 650 } }, '听懂'),
|
|
122
|
+
info && info.running ? h('span', { style: { fontSize: 12, color: C.sub, fontVariantNumeric: 'tabular-nums' } }, 'v' + info.running) : null,
|
|
123
|
+
managed ? h(Btn, { onClick: upd.onClick, disabled: upd.disabled || busy, primary: upd.primary, title: upd.title }, upd.label) : null,
|
|
124
|
+
h('span', { style: { flex: 1 } }),
|
|
125
|
+
!managed ? null
|
|
126
|
+
: phase === 'removed' ? h('span', { style: { fontSize: 12, color: C.sub } }, '已卸载,重启 DSH 后彻底移除')
|
|
127
|
+
: phase === 'removing' ? h('span', { style: { fontSize: 12, color: C.sub } }, '正在卸载…')
|
|
128
|
+
: confirm ? h('span', { style: { display: 'inline-flex', gap: 6, alignItems: 'center' } },
|
|
129
|
+
h('span', { style: { fontSize: 12, color: C.sub } }, '确定卸载?课程和进度会保留'),
|
|
130
|
+
h(Btn, { danger: true, onClick: uninstall }, '确认卸载'),
|
|
131
|
+
h(Btn, { onClick: () => setConfirm(false) }, '取消'))
|
|
132
|
+
: h(Btn, { danger: true, disabled: busy, onClick: () => setConfirm(true) }, '卸载')),
|
|
133
|
+
msg ? h('div', { style: { fontSize: 12, color: msg.ok ? C.sub : C.danger, marginTop: 6, lineHeight: 1.6 } },
|
|
134
|
+
msg.text, msg.command ? h(CommandLine, { command: msg.command }) : null) : null)
|
|
135
|
+
}
|
|
136
|
+
|
|
40
137
|
/** 右侧栏:占满栏高的 iframe */
|
|
41
138
|
function Panel() {
|
|
42
139
|
return h('div', { style: { height: '100%', display: 'flex', flexDirection: 'column', boxSizing: 'border-box' } },
|
|
@@ -68,9 +165,7 @@ window.__ModuleLoader__.load({
|
|
|
68
165
|
catch { setMsg('先回到一个对话,再从右侧栏打开') }
|
|
69
166
|
}
|
|
70
167
|
return h('div', { style: { color: C.text } },
|
|
71
|
-
h(
|
|
72
|
-
h('span', { style: { fontSize: 15, fontWeight: 650 } }, '听懂'),
|
|
73
|
-
st && st.version ? h('span', { style: { fontSize: 12, color: C.sub } }, 'v' + st.version) : null),
|
|
168
|
+
h(HeaderBar),
|
|
74
169
|
h('div', { style: { fontSize: 12.5, color: C.sub, lineHeight: 1.7, marginTop: 6 } },
|
|
75
170
|
'把英文有声读物(MP3)变成听力课:本机识别每一句英文,AI 翻译讲解,再用盲听、精听、间隔复习练到一听就懂。'),
|
|
76
171
|
h('div', { style: { marginTop: 14 } },
|
package/plugin/index.js
CHANGED
|
@@ -21,6 +21,7 @@ import { Importer } from '../lib/importer.js'
|
|
|
21
21
|
import { dshLlm } from '../lib/llm.js'
|
|
22
22
|
import { createHandler } from '../lib/server.js'
|
|
23
23
|
import { registerTools } from './tools.js'
|
|
24
|
+
import { createManager } from './manage.js'
|
|
24
25
|
|
|
25
26
|
const PKG_ROOT = fileURLToPath(new URL('..', import.meta.url))
|
|
26
27
|
export const PREFIX = '/api-dsh-tingdong'
|
|
@@ -77,7 +78,20 @@ export function apply(ctx, config = {}) {
|
|
|
77
78
|
if (typeof ctx.effect === 'function') ctx.effect(() => stopAll)
|
|
78
79
|
else if (typeof ctx.on === 'function') ctx.on('dispose', stopAll)
|
|
79
80
|
|
|
81
|
+
// DSH 自己的插件管理器(更新 / 卸载按钮用;老版本 DSH 没有)
|
|
82
|
+
let pluginManager
|
|
83
|
+
ctx.inject(['pluginManager'], (c) => {
|
|
84
|
+
c.effect(() => {
|
|
85
|
+
pluginManager = c.pluginManager || (c.get && c.get('pluginManager'))
|
|
86
|
+
return () => { pluginManager = undefined }
|
|
87
|
+
})
|
|
88
|
+
})
|
|
89
|
+
let profile
|
|
90
|
+
try { profile = ctx.get && ctx.get('profileContext') && ctx.get('profileContext').name } catch { profile = undefined }
|
|
91
|
+
const manage = createManager({ pkgRoot: PKG_ROOT, pluginManager: () => pluginManager, profile: () => profile })
|
|
92
|
+
|
|
80
93
|
const handle = createHandler({
|
|
94
|
+
manage,
|
|
81
95
|
prefix: PREFIX,
|
|
82
96
|
pkgRoot: PKG_ROOT,
|
|
83
97
|
importer,
|
package/plugin/manage.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// plugin/manage.js — 插件自己的版本、更新、卸载(首页标题旁的按钮)
|
|
2
|
+
//
|
|
3
|
+
// 更新、卸载交给 DSH 自己的插件管理器(pluginManager.installBundle / removeBundle,和 DSH「插件」页同一套:
|
|
4
|
+
// 带锁跑 pnpm、失败自动还原)。写法照 dsh-pick。拿不到插件管理器(老版本 DSH)或它拒绝时,
|
|
5
|
+
// 给出用户可以自己在终端执行的命令。装完要重启 DSH 才生效。
|
|
6
|
+
|
|
7
|
+
import { readFile } from 'node:fs/promises'
|
|
8
|
+
import { join } from 'node:path'
|
|
9
|
+
|
|
10
|
+
export const PACKAGE = 'dsh-tingdong'
|
|
11
|
+
// npm 官方源连不上(国内常见)就问镜像
|
|
12
|
+
const REGISTRIES = ['https://registry.npmjs.org', 'https://registry.npmmirror.com']
|
|
13
|
+
const VERSION_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/
|
|
14
|
+
|
|
15
|
+
/** a 比 b 新返回正数(x.y.z,预发布版比同号正式版旧) */
|
|
16
|
+
export function compareVersions(a, b) {
|
|
17
|
+
const parse = v => {
|
|
18
|
+
const [core, pre = ''] = String(v || '').replace(/^v/, '').split('-', 2)
|
|
19
|
+
return { nums: core.split('.').map(n => parseInt(n, 10) || 0), pre }
|
|
20
|
+
}
|
|
21
|
+
const x = parse(a), y = parse(b)
|
|
22
|
+
for (let i = 0; i < 3; i++) {
|
|
23
|
+
const d = (x.nums[i] || 0) - (y.nums[i] || 0)
|
|
24
|
+
if (d) return d
|
|
25
|
+
}
|
|
26
|
+
if (x.pre === y.pre) return 0
|
|
27
|
+
if (!x.pre) return 1
|
|
28
|
+
if (!y.pre) return -1
|
|
29
|
+
return x.pre < y.pre ? -1 : 1
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** 插件管理器的失败结果 → 错误码 / 一行人话 */
|
|
33
|
+
function managerCode(result) {
|
|
34
|
+
if (!(result && (result.application === 'failed' || result.application === 'cancelled' || result.error))) return ''
|
|
35
|
+
return (result.error && result.error.code) || result.application || 'failed'
|
|
36
|
+
}
|
|
37
|
+
function managerError(result) {
|
|
38
|
+
const code = managerCode(result)
|
|
39
|
+
if (!code) return ''
|
|
40
|
+
const detail = String((result.error && result.error.diagnostic) || (result.packageResult && result.packageResult.output) || '')
|
|
41
|
+
.trim().split('\n').slice(-3).join(' ').slice(-300)
|
|
42
|
+
return detail ? `${code}:${detail}` : code
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const RESTART = 'DSH 要重启才用上新版本:电脑上完全退出 DSH(Mac 按 Cmd+Q,不是关窗口)再打开;VPS 上在「设置 → VPS 部署」里重启。'
|
|
46
|
+
const ALREADY = '这个版本的 DSH 不能在运行中升级已装的插件。完全退出 DSH(Mac 上按 Cmd+Q)后在终端执行下面的命令,再打开 DSH。'
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @param {object} o
|
|
50
|
+
* @param {string} o.pkgRoot 本包根目录
|
|
51
|
+
* @param {() => any} o.pluginManager 拿 DSH 的插件管理器(可能没有)
|
|
52
|
+
* @param {() => string|undefined} o.profile 当前 profile 名(拼命令用)
|
|
53
|
+
*/
|
|
54
|
+
export function createManager(o) {
|
|
55
|
+
let running = ''
|
|
56
|
+
const readPkg = async () => { try { return JSON.parse(await readFile(join(o.pkgRoot, 'package.json'), 'utf8')) } catch { return {} } }
|
|
57
|
+
readPkg().then(p => { running = p.version || '' })
|
|
58
|
+
|
|
59
|
+
let cache = null
|
|
60
|
+
async function latest(force) {
|
|
61
|
+
if (!force && cache && Date.now() - cache.at < 10 * 60 * 1000) return cache.version
|
|
62
|
+
for (const r of REGISTRIES) {
|
|
63
|
+
try {
|
|
64
|
+
const res = await fetch(`${r}/${PACKAGE}/latest`, { signal: AbortSignal.timeout(6000) })
|
|
65
|
+
if (!res.ok) continue
|
|
66
|
+
const { version } = await res.json()
|
|
67
|
+
if (VERSION_RE.test(String(version || ''))) { cache = { version, at: Date.now() }; return version }
|
|
68
|
+
} catch { /* 换下一个源 */ }
|
|
69
|
+
}
|
|
70
|
+
return ''
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const command = args => {
|
|
74
|
+
const p = o.profile()
|
|
75
|
+
return /^[A-Za-z0-9._-]+$/.test(p || '') ? `dsh plugin --profile ${p} ${args}` : `dsh plugin ${args}`
|
|
76
|
+
}
|
|
77
|
+
const pm = () => { try { return o.pluginManager() } catch { return null } }
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
/** 版本信息:正在运行的、磁盘上装好的、npm 上最新的 */
|
|
81
|
+
async info(force) {
|
|
82
|
+
const [pkg, newest] = await Promise.all([readPkg(), latest(force)])
|
|
83
|
+
const installed = pkg.version || ''
|
|
84
|
+
const current = compareVersions(installed, running) > 0 ? installed : running
|
|
85
|
+
const m = pm()
|
|
86
|
+
return {
|
|
87
|
+
running,
|
|
88
|
+
restartPending: installed && running && compareVersions(installed, running) > 0 ? installed : '',
|
|
89
|
+
latest: newest,
|
|
90
|
+
available: Boolean(newest) && compareVersions(newest, current) > 0,
|
|
91
|
+
canInstall: Boolean(m && typeof m.installBundle === 'function'),
|
|
92
|
+
canRemove: Boolean(m && typeof m.removeBundle === 'function'),
|
|
93
|
+
command: command(`add ${PACKAGE}@${newest || 'latest'}`),
|
|
94
|
+
removeCommand: command(`remove ${PACKAGE}`),
|
|
95
|
+
restartHint: RESTART,
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
async update() {
|
|
99
|
+
const newest = await latest(true)
|
|
100
|
+
const cmd = command(`add ${PACKAGE}@${newest || 'latest'}`)
|
|
101
|
+
if (!newest) return { ok: false, message: '查不到 npm 上的最新版本,检查网络后再试', command: cmd }
|
|
102
|
+
if (compareVersions(newest, running) <= 0) return { ok: false, message: `已经是最新版 v${running}` }
|
|
103
|
+
const m = pm()
|
|
104
|
+
if (!m || typeof m.installBundle !== 'function') return { ok: false, message: '这个版本的 DSH 不能在这里装插件,请在终端执行命令', command: cmd }
|
|
105
|
+
try {
|
|
106
|
+
const result = await m.installBundle(`${PACKAGE}@${newest}`, { enabled: true })
|
|
107
|
+
if (managerCode(result) === 'already-installed') return { ok: false, message: ALREADY, command: cmd }
|
|
108
|
+
const err = managerError(result)
|
|
109
|
+
if (err) return { ok: false, message: `更新失败(${err})`, command: cmd }
|
|
110
|
+
return { ok: true, version: newest, message: RESTART }
|
|
111
|
+
} catch (e) {
|
|
112
|
+
if (e && e.code === 'already-installed') return { ok: false, message: ALREADY, command: cmd }
|
|
113
|
+
return { ok: false, message: `更新失败:${e.message}`, command: cmd }
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
async uninstall() {
|
|
117
|
+
const cmd = command(`remove ${PACKAGE}`)
|
|
118
|
+
const m = pm()
|
|
119
|
+
if (!m || typeof m.removeBundle !== 'function') return { ok: false, message: '这里没法直接卸载:完全退出 DSH 后在终端执行命令', command: cmd }
|
|
120
|
+
try {
|
|
121
|
+
const err = managerError(await m.removeBundle(PACKAGE))
|
|
122
|
+
if (err) return { ok: false, message: `卸载失败(${err}),可完全退出 DSH 后在终端执行命令`, command: cmd }
|
|
123
|
+
return { ok: true, message: '已卸载,重启 DSH 后彻底移除。课程和学习进度还留在 ~/.dsh/dsh-tingdong,重新装上就回来。' }
|
|
124
|
+
} catch (e) {
|
|
125
|
+
return { ok: false, message: `卸载失败:${e.message}`, command: cmd }
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
}
|
|
129
|
+
}
|
package/plugin/tools.js
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
|
|
8
8
|
import { isMastered } from '../lib/srs.js'
|
|
9
9
|
import { checkId, getLesson, getProgress, listLessons } from '../lib/store.js'
|
|
10
|
+
import { PREP_PROMPT } from '../lib/doc.js'
|
|
11
|
+
import { EDGE_EN_VOICES } from '../lib/edge.js'
|
|
10
12
|
|
|
11
13
|
const ANY_OBJECT = { type: 'object' }
|
|
12
14
|
|
|
@@ -62,6 +64,33 @@ export async function buildToolDefinitions({ importer }) {
|
|
|
62
64
|
},
|
|
63
65
|
},
|
|
64
66
|
|
|
67
|
+
{
|
|
68
|
+
name: 'tingdong_import_text',
|
|
69
|
+
description: '把一篇英文文档导入「听懂」做成听力课:AI 朗读成音频,再逐句翻译讲解。用户给了 PDF、网页、电子书或一段英文,想用来练听力时用。' +
|
|
70
|
+
'调用前先把材料整理好:' + PREP_PROMPT.split('\n').slice(1, 4).join(' '),
|
|
71
|
+
parameters: params({
|
|
72
|
+
text: { type: 'string', required: true, description: '整理好的英文纯文本:每章一行「# 章节标题」开头(每章做成一课),段落之间空一行,只留正文、原文不改不译' },
|
|
73
|
+
title: { type: 'string', description: '文档标题(可省略)' },
|
|
74
|
+
voice: { type: 'string', enum: EDGE_EN_VOICES.map(v => v.id), description: '朗读的声音(微软 Edge),默认 en-US-AndrewNeural(美式男声)' },
|
|
75
|
+
}),
|
|
76
|
+
output: {
|
|
77
|
+
schema: ANY_OBJECT,
|
|
78
|
+
render: (_a, v) => [{
|
|
79
|
+
type: 'text',
|
|
80
|
+
text: `《${v.title}》分成 ${v.items.length} 课:${v.items.map(it => `${it.title}${it.existed ? '(已导入过)' : `(${it.sentences} 句)`}`).join('、')}。` +
|
|
81
|
+
`正在后台朗读和翻译,几分钟后就能学。${OPEN_HINT}`,
|
|
82
|
+
}],
|
|
83
|
+
},
|
|
84
|
+
async execute(args) {
|
|
85
|
+
const r = await importer.importDoc(String(args.text || ''), {
|
|
86
|
+
title: typeof args.title === 'string' ? args.title : '',
|
|
87
|
+
engine: 'edge',
|
|
88
|
+
voice: typeof args.voice === 'string' ? args.voice : undefined,
|
|
89
|
+
})
|
|
90
|
+
return plain(r)
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
|
|
65
94
|
{
|
|
66
95
|
name: 'tingdong_lessons',
|
|
67
96
|
description: '列出「听懂」里的听力课和学习进度(掌握多少句、待复习多少句),以及正在处理的导入任务。',
|
package/web/core.js
CHANGED
|
@@ -184,7 +184,9 @@ export function useTrack(t) {
|
|
|
184
184
|
loadAudio()
|
|
185
185
|
}
|
|
186
186
|
|
|
187
|
-
export const
|
|
187
|
+
export const isDoc = (c = S.cur) => Boolean(c && c.lesson.kind === 'doc')
|
|
188
|
+
/** 声音的名字;文档课的「原声」其实是导入时的朗读 */
|
|
189
|
+
export const trackLabel = t => (t === 'full' && isDoc() ? `朗读(${S.cur.lesson.voiceLabel || 'AI'})` : (S.cur.trackDefs[t] && S.cur.trackDefs[t].label) || t)
|
|
188
190
|
|
|
189
191
|
/** 学到第几句:存服务器(防抖),换设备接着学 */
|
|
190
192
|
let posTimer
|
package/web/listen.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// web/listen.js — 连续听、磨耳朵、词汇
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
|
-
$, api, audio, clearMediaControls, curTrack, esc, fmt, getPos, keys, mediaControls, player, playSentence, S, store, tm,
|
|
4
|
+
$, api, audio, clearMediaControls, curTrack, esc, fmt, getPos, isDoc, keys, mediaControls, player, playSentence, S, store, tm,
|
|
5
5
|
toast, trackLabel, usable,
|
|
6
6
|
} from './core.js'
|
|
7
7
|
|
|
@@ -17,10 +17,10 @@ export function paneText(pane, l) {
|
|
|
17
17
|
<button class="btn primary" id="playAll">▶ 连续播放</button>
|
|
18
18
|
<button class="btn ${opt.en ? 'on' : ''}" id="tEn">英文</button>
|
|
19
19
|
<button class="btn ${opt.zh ? 'on' : ''}" id="tZh">中文</button>
|
|
20
|
-
${full ? `<button class="btn ${opt.skip ? 'on' : ''}" id="tSkip" title="跳过句子之间较长的空白和音乐">跳过空白</button>` : ''}
|
|
20
|
+
${full && !isDoc() ? `<button class="btn ${opt.skip ? 'on' : ''}" id="tSkip" title="跳过句子之间较长的空白和音乐">跳过空白</button>` : ''}
|
|
21
21
|
<button class="btn speed" id="speed">${player.rate}×</button>
|
|
22
22
|
<a class="btn ghost" href="audio?id=${encodeURIComponent(l.id)}&track=${curTrack()}&download=1" download title="存成 MP3,放到手机或播放器上听">下载 MP3</a>
|
|
23
|
-
</div>${full ? '<div class="faint" style="margin-top:4px">现在是完整原声(含音乐和空白)。上面「声音」选「原声·去空白」或朗读,就能不停顿地连续听。</div>' : ''}</div>
|
|
23
|
+
</div>${full && !isDoc() ? '<div class="faint" style="margin-top:4px">现在是完整原声(含音乐和空白)。上面「声音」选「原声·去空白」或朗读,就能不停顿地连续听。</div>' : ''}</div>
|
|
24
24
|
<div class="card" id="sents" style="padding:6px">${list.map(s => {
|
|
25
25
|
const t = tm(s)
|
|
26
26
|
return `<div class="sent ${opt.en ? '' : 'hide-e'} ${opt.zh ? '' : 'hide-z'}" data-i="${s.i}">
|
package/web/panel.css
CHANGED
|
@@ -301,3 +301,35 @@ textarea:focus { border-color: var(--accent); }
|
|
|
301
301
|
/* —— 导出 —— */
|
|
302
302
|
.sub-h { font-size: 13px; font-weight: 600; margin: 4px 0 2px; }
|
|
303
303
|
.row.wrap { flex-wrap: wrap; }
|
|
304
|
+
|
|
305
|
+
/* —— 标题旁的版本、更新、卸载 —— */
|
|
306
|
+
.manage { display: inline-flex; align-items: center; gap: 6px; margin-left: 4px; flex-wrap: wrap; }
|
|
307
|
+
.manage .ver { font-variant-numeric: tabular-nums; }
|
|
308
|
+
.btn.small { padding: 2px 9px; font-size: 12px; line-height: 18px; }
|
|
309
|
+
code.cmd { font: 12px ui-monospace, SFMono-Regular, Menlo, monospace; padding: 6px 8px; border: 1px solid var(--line); border-radius: 6px; user-select: all; overflow-wrap: anywhere; }
|
|
310
|
+
@media (max-width: 640px) { .btn.small { padding: 4px 10px; font-size: 13px; } .top { flex-wrap: wrap; } }
|
|
311
|
+
|
|
312
|
+
/* —— 首页:新建课程 —— */
|
|
313
|
+
.section-title { font-size: 13px; font-weight: 600; color: var(--sub); margin: 18px 2px 8px; }
|
|
314
|
+
.new-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
|
315
|
+
.new-card {
|
|
316
|
+
appearance: none; cursor: pointer; text-align: left; font: inherit; color: var(--text);
|
|
317
|
+
background: var(--card); border: 1.5px solid var(--line); border-radius: var(--radius);
|
|
318
|
+
padding: 14px; display: flex; flex-direction: column; gap: 4px;
|
|
319
|
+
}
|
|
320
|
+
.new-card:hover { border-color: var(--accent); }
|
|
321
|
+
.new-card.on { border-color: var(--accent); background: var(--accent-soft); }
|
|
322
|
+
.new-card b { font-size: 15px; }
|
|
323
|
+
.new-card .faint { line-height: 1.5; }
|
|
324
|
+
.new-ico { font-size: 22px; }
|
|
325
|
+
#newPanel .card { margin-top: 10px; }
|
|
326
|
+
.doc-new .step { display: flex; gap: 10px; margin: 12px 0; }
|
|
327
|
+
.doc-new .num {
|
|
328
|
+
flex: none; width: 22px; height: 22px; border-radius: 50%; background: var(--accent); color: #fff;
|
|
329
|
+
font-size: 12px; font-weight: 700; display: grid; place-items: center; margin-top: 1px;
|
|
330
|
+
}
|
|
331
|
+
.doc-new textarea { margin-top: 6px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12.5px; }
|
|
332
|
+
.prompt { white-space: pre-wrap; font: 12px/1.6 ui-monospace, SFMono-Regular, Menlo, monospace; background: var(--bg); border-radius: 8px; padding: 8px 10px; margin: 6px 0 0; color: var(--sub); }
|
|
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
|
+
.kind.doc { background: rgba(201, 138, 18, .14); color: var(--mid); }
|
|
335
|
+
@media (max-width: 640px) { .new-grid { grid-template-columns: 1fr; } }
|
package/web/panel.html
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
<header class="top">
|
|
16
16
|
<button class="icon-btn" id="back" hidden title="返回课程列表" aria-label="返回">‹</button>
|
|
17
17
|
<div class="brand" id="brand">听懂</div>
|
|
18
|
+
<span class="manage" id="manage" hidden></span>
|
|
18
19
|
<div class="spacer"></div>
|
|
19
20
|
<a class="link" id="popout" href="panel" target="_blank" rel="noopener" hidden>新窗口打开</a>
|
|
20
21
|
</header>
|