dsh-plugin-lookatstudy 0.12.5 → 0.13.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/lib/client.js +565 -1
- package/lib/client.js.map +1 -1
- package/lib/index.mjs +266 -3
- package/package.json +1 -1
package/lib/index.mjs
CHANGED
|
@@ -4,11 +4,11 @@ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "
|
|
|
4
4
|
import { basename, dirname, join, relative, sep } from "node:path";
|
|
5
5
|
import z from "@deepseek-ai/schemastery";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
8
|
+
import https from "node:https";
|
|
7
9
|
import { homedir } from "node:os";
|
|
8
|
-
import { randomBytes } from "node:crypto";
|
|
9
10
|
import { readFile, readdir } from "node:fs/promises";
|
|
10
11
|
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
11
|
-
import https from "node:https";
|
|
12
12
|
//#region \0rolldown/runtime.js
|
|
13
13
|
var __defProp = Object.defineProperty;
|
|
14
14
|
var __exportAll = (all, no_symbols) => {
|
|
@@ -233,6 +233,228 @@ function levelFromTotalXp(totalXp) {
|
|
|
233
233
|
levelSpan: span
|
|
234
234
|
};
|
|
235
235
|
}
|
|
236
|
+
"])".slice(0, 1);
|
|
237
|
+
function normalizeSpeechText(md) {
|
|
238
|
+
let s = md;
|
|
239
|
+
s = s.replace(/\r\n?/g, "\n");
|
|
240
|
+
s = s.replace(/(?:^|\n)[ \t]*(?:```|~~~)[^\n]*\n[\s\S]*?(?:\n[ \t]*(?:```|~~~)[^\n]*|\n?$)/g, "\n");
|
|
241
|
+
s = s.replace(/`[^`\n]*`/g, "");
|
|
242
|
+
s = s.replace(/!\[[^\]]*\]\([^)\n]*\)/g, "");
|
|
243
|
+
s = s.replace(/\[([^\]]+)\]\([^)\n]*\)/g, "$1");
|
|
244
|
+
s = s.replace(/^[ \t]{0,3}#{1,6}[ \t]+/gm, "");
|
|
245
|
+
s = s.replace(/^[ \t]*(?:[-*+]|\d+\.)[ \t]+/gm, "");
|
|
246
|
+
s = s.replace(/^[ \t]*>[ \t]?/gm, "");
|
|
247
|
+
s = s.replace(/\|/g, " ");
|
|
248
|
+
s = s.replace(/^[ \t]*[-: ]{3,}[ \t]*$/gm, "");
|
|
249
|
+
s = s.replace(/(\*\*|__)(.*?)\1/g, "$2");
|
|
250
|
+
s = s.replace(/(?<![*\w])(\*|_)(?!\s)(.+?)(?<!\s)\1(?![*\w])/g, "$2");
|
|
251
|
+
s = s.replace(/~~(.+?)~~/g, "$1");
|
|
252
|
+
s = s.replace(/\n{3,}/g, "\n\n");
|
|
253
|
+
return s.trim();
|
|
254
|
+
}
|
|
255
|
+
//#endregion
|
|
256
|
+
//#region src/tts.ts
|
|
257
|
+
/**
|
|
258
|
+
* Edge read-aloud synthesis: Microsoft's read-aloud endpoint (the one behind
|
|
259
|
+
* Edge's own 朗读 feature), spoken to over a hand-rolled WebSocket on
|
|
260
|
+
* node:https/node:crypto — zero dependencies. Synthesis runs HOST-side (the
|
|
261
|
+
* browser cannot open that socket), so the dashboard streams the cached MP3
|
|
262
|
+
* down and the client falls back to speechSynthesis when this path fails.
|
|
263
|
+
*
|
|
264
|
+
* Three live-verified protocol details this module pins (2026-09-06 probes):
|
|
265
|
+
* 1. Sec-MS-GEC ticks are ~1.36e17 — past Number's 2^53. BigInt or the hash
|
|
266
|
+
* silently mismatches (403).
|
|
267
|
+
* 2. `Sec-MS-GEC-Version` must carry a CURRENT Edge full version; stale
|
|
268
|
+
* strings are rejected with 403 before any upgrade.
|
|
269
|
+
* 3. The service sends WebSocket PINGs; unanswered PONGs get the socket reset
|
|
270
|
+
* mid-turn (read the turn.end path name with the dot — `\w+` eats it).
|
|
271
|
+
* @module dsh-plugin-lookatstudy/tts
|
|
272
|
+
*/
|
|
273
|
+
/** Public read-aloud client token (same constant every edge-tts client ships). */
|
|
274
|
+
const TRUSTED_CLIENT_TOKEN = "6A5AA1D4EAFF4E9FB37E23D68491D6F4";
|
|
275
|
+
/** Must track a shipped Edge release; a stale string is rejected outright. */
|
|
276
|
+
const CHROMIUM_FULL_VERSION = "143.0.3650.75";
|
|
277
|
+
/** The plugin's default tutor voice (晓晓, zh-CN female neural). */
|
|
278
|
+
const DEFAULT_TTS_VOICE = "zh-CN-XiaoxiaoNeural";
|
|
279
|
+
/** Voices the dashboard route accepts (others fall back to the default). */
|
|
280
|
+
const VOICES = /* @__PURE__ */ new Set([
|
|
281
|
+
DEFAULT_TTS_VOICE,
|
|
282
|
+
"zh-CN-YunxiNeural",
|
|
283
|
+
"zh-CN-YunyangNeural",
|
|
284
|
+
"zh-CN-XiaoyiNeural",
|
|
285
|
+
"en-US-AriaNeural",
|
|
286
|
+
"en-US-GuyNeural"
|
|
287
|
+
]);
|
|
288
|
+
function normalizeVoice(voice) {
|
|
289
|
+
return voice !== void 0 && VOICES.has(voice) ? voice : DEFAULT_TTS_VOICE;
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* The DRM token (Sec-MS-GEC): SHA-256 over the Windows-filetime ticks of the
|
|
293
|
+
* current 5-minute window plus the trusted token. BigInt is mandatory — the
|
|
294
|
+
* ticks exceed Number.MAX_SAFE_INTEGER and the hash consumes exact digits.
|
|
295
|
+
* @param nowMs - caller clock in unix milliseconds.
|
|
296
|
+
*/
|
|
297
|
+
function secMsGec(nowMs = Date.now()) {
|
|
298
|
+
let ticks = (BigInt(Math.floor(nowMs / 1e3)) + 11644473600n) * 10000000n;
|
|
299
|
+
ticks -= ticks % 3000000000n;
|
|
300
|
+
return createHash("sha256").update(`${ticks}${TRUSTED_CLIENT_TOKEN}`).digest("hex").toUpperCase();
|
|
301
|
+
}
|
|
302
|
+
function xmlEscape(s) {
|
|
303
|
+
return s.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll("\"", """).replaceAll("'", "'");
|
|
304
|
+
}
|
|
305
|
+
/** Build one speak utterance. */
|
|
306
|
+
function buildSsml(text, voice) {
|
|
307
|
+
return `<speak version='1.0' xmlns='http://www.w3.org/2001/10/synthesis' xml:lang='${voice.split("-").slice(0, 2).join("-")}'><voice name='${voice}'><prosody rate='+0%' pitch='+0Hz'>${xmlEscape(text)}</prosody></voice></speak>`;
|
|
308
|
+
}
|
|
309
|
+
/** Client→server frames are masked; server→client frames are not. */
|
|
310
|
+
function maskFrame(opcode, payload) {
|
|
311
|
+
const mask = randomBytes(4);
|
|
312
|
+
const head = payload.length < 126 ? Buffer.from([128 | opcode, 128 | payload.length]) : Buffer.from([
|
|
313
|
+
128 | opcode,
|
|
314
|
+
254,
|
|
315
|
+
payload.length >> 8,
|
|
316
|
+
payload.length & 255
|
|
317
|
+
]);
|
|
318
|
+
const masked = Buffer.from(payload.map((b, i) => b ^ mask[i % 4]));
|
|
319
|
+
return Buffer.concat([
|
|
320
|
+
head,
|
|
321
|
+
mask,
|
|
322
|
+
masked
|
|
323
|
+
]);
|
|
324
|
+
}
|
|
325
|
+
const timestamp = () => (/* @__PURE__ */ new Date()).toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
326
|
+
/**
|
|
327
|
+
* Synthesize one utterance to MP3 (audio-24khz-48kbit-mono-mp3).
|
|
328
|
+
* @param text - plain speakable text (short: one sentence or a small group).
|
|
329
|
+
* @param voice - neural voice name.
|
|
330
|
+
* @param timeoutMs - hard ceiling on the whole turn.
|
|
331
|
+
* @returns the MP3 bytes.
|
|
332
|
+
*/
|
|
333
|
+
function synthesizeSpeech(text, voice = DEFAULT_TTS_VOICE, timeoutMs = 2e4) {
|
|
334
|
+
return new Promise((resolve, reject) => {
|
|
335
|
+
const gec = secMsGec();
|
|
336
|
+
const host = "speech.platform.bing.com";
|
|
337
|
+
const path = `/consumer/speech/synthesize/readaloud/edge/v1?TrustedClientToken=${TRUSTED_CLIENT_TOKEN}&Sec-MS-GEC=${gec}&Sec-MS-GEC-Version=1-${CHROMIUM_FULL_VERSION}`;
|
|
338
|
+
const key = randomBytes(16).toString("base64");
|
|
339
|
+
const req = https.request(`https://${host}${path}`, { headers: {
|
|
340
|
+
Connection: "Upgrade",
|
|
341
|
+
Upgrade: "websocket",
|
|
342
|
+
"Sec-WebSocket-Key": key,
|
|
343
|
+
"Sec-WebSocket-Version": "13",
|
|
344
|
+
Pragma: "no-cache",
|
|
345
|
+
"Cache-Control": "no-cache",
|
|
346
|
+
Origin: "chrome-extension://jdiccldimpdaibmpdkjnbmckianbfold",
|
|
347
|
+
"User-Agent": `Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${CHROMIUM_FULL_VERSION.split(".")[0]}.0.0.0 Safari/537.36 Edg/${CHROMIUM_FULL_VERSION}`
|
|
348
|
+
} });
|
|
349
|
+
const timer = setTimeout(() => {
|
|
350
|
+
socket?.destroy();
|
|
351
|
+
reject(/* @__PURE__ */ new Error("edge tts: turn timed out"));
|
|
352
|
+
}, timeoutMs);
|
|
353
|
+
const debug = process.env.LKS_TTS_DEBUG === "1";
|
|
354
|
+
const log = (line) => {
|
|
355
|
+
if (debug) console.error(`[lks-tts] ${line}`);
|
|
356
|
+
};
|
|
357
|
+
let socket;
|
|
358
|
+
const finish = (err, mp3) => {
|
|
359
|
+
clearTimeout(timer);
|
|
360
|
+
socket?.destroy();
|
|
361
|
+
if (err !== null) reject(err);
|
|
362
|
+
else resolve(mp3);
|
|
363
|
+
};
|
|
364
|
+
req.on("upgrade", (res, sock) => {
|
|
365
|
+
log(`upgrade ok (${String(res.statusCode)})`);
|
|
366
|
+
socket = sock;
|
|
367
|
+
const accept = createHash("sha1").update(`${key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`).digest("base64");
|
|
368
|
+
if (res.headers["sec-websocket-accept"] !== accept) return finish(/* @__PURE__ */ new Error("edge tts: bad upgrade accept"));
|
|
369
|
+
if (res.statusCode !== 101) return finish(/* @__PURE__ */ new Error(`edge tts: upgrade refused (${String(res.statusCode)})`));
|
|
370
|
+
sock.setNoDelay(true);
|
|
371
|
+
sock.write(maskFrame(1, Buffer.from(`X-Timestamp:${timestamp()}\r\nContent-Type:application/json; charset=utf-8\r\nPath:speech.config\r\n\r\n` + JSON.stringify({ context: { synthesis: { audio: {
|
|
372
|
+
metadataoptions: {
|
|
373
|
+
sentenceBoundaryEnabled: "false",
|
|
374
|
+
wordBoundaryEnabled: "false"
|
|
375
|
+
},
|
|
376
|
+
outputFormat: "audio-24khz-48kbitrate-mono-mp3"
|
|
377
|
+
} } } }))));
|
|
378
|
+
sock.write(maskFrame(1, Buffer.from(`X-RequestId:${randomBytes(16).toString("hex")}\r\nContent-Type:application/ssml+xml\r\nX-Timestamp:${timestamp()}\r\nPath:ssml\r\n\r\n${buildSsml(text, voice)}`)));
|
|
379
|
+
let acc = Buffer.alloc(0);
|
|
380
|
+
const audio = [];
|
|
381
|
+
sock.on("data", (chunk) => {
|
|
382
|
+
if (debug) log(`recv ${chunk.length}B head=${chunk.subarray(0, 8).toString("hex")}`);
|
|
383
|
+
acc = Buffer.concat([acc, chunk]);
|
|
384
|
+
for (;;) {
|
|
385
|
+
if (acc.length < 2) return;
|
|
386
|
+
const opcode = acc[0] & 15;
|
|
387
|
+
let len = acc[1] & 127;
|
|
388
|
+
let off = 2;
|
|
389
|
+
if (len === 126) {
|
|
390
|
+
if (acc.length < 4) return;
|
|
391
|
+
len = acc.readUInt16BE(2);
|
|
392
|
+
off = 4;
|
|
393
|
+
} else if (len === 127) {
|
|
394
|
+
if (acc.length < 10) return;
|
|
395
|
+
len = Number(acc.readBigUInt64BE(2));
|
|
396
|
+
off = 10;
|
|
397
|
+
}
|
|
398
|
+
if (acc.length < off + len) return;
|
|
399
|
+
const payload = acc.subarray(off, off + len);
|
|
400
|
+
acc = acc.subarray(off + len);
|
|
401
|
+
if (opcode === 9) {
|
|
402
|
+
log("ping→pong");
|
|
403
|
+
sock.write(maskFrame(10, payload));
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
if (opcode === 8) {
|
|
407
|
+
log("close frame");
|
|
408
|
+
return finish(audio.length > 0 ? null : /* @__PURE__ */ new Error("edge tts: closed before audio"));
|
|
409
|
+
}
|
|
410
|
+
if (opcode !== 1 && opcode !== 2) continue;
|
|
411
|
+
const headerLen = payload.readUInt16BE(0);
|
|
412
|
+
const head = payload.subarray(2, 2 + headerLen).toString("utf8");
|
|
413
|
+
if (debug) log(`frame op=${opcode} len=${len} path=${/Path:([\w.]+)/.exec(head)?.[1] ?? "?"}`);
|
|
414
|
+
if (opcode === 1 && /Path:turn\.end/.test(head)) {
|
|
415
|
+
log(`turn.end with ${audio.length} audio chunk(s)`);
|
|
416
|
+
return finish(audio.length > 0 ? null : /* @__PURE__ */ new Error("edge tts: turn ended without audio"), Buffer.concat(audio));
|
|
417
|
+
}
|
|
418
|
+
if (opcode === 2 && /Path:audio/.test(head)) {
|
|
419
|
+
const chunk = payload.subarray(2 + headerLen);
|
|
420
|
+
audio.push(chunk);
|
|
421
|
+
if (debug) log(`audio chunk ${chunk.length}B (total ${audio.reduce((n, c) => n + c.length, 0)}B)`);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
});
|
|
425
|
+
sock.on("error", (err) => finish(err));
|
|
426
|
+
sock.on("close", () => {
|
|
427
|
+
if (timer.hasRef()) finish(/* @__PURE__ */ new Error("edge tts: socket closed mid-turn"));
|
|
428
|
+
});
|
|
429
|
+
});
|
|
430
|
+
req.on("response", (res) => {
|
|
431
|
+
res.resume();
|
|
432
|
+
finish(/* @__PURE__ */ new Error(`edge tts: endpoint refused the upgrade (HTTP ${String(res.statusCode)})`));
|
|
433
|
+
});
|
|
434
|
+
req.on("error", (err) => finish(err));
|
|
435
|
+
req.end();
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
/** Cache key: voice-scoped (different voices never share files). */
|
|
439
|
+
function ttsCachePath(cacheDir, text, voice) {
|
|
440
|
+
const hash = createHash("sha256").update(`${voice}\n${text}`).digest("hex");
|
|
441
|
+
return join(cacheDir, `${hash}.mp3`);
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* Cache-first synthesis: study-area/tts-cache/{sha256(voice+text)}.mp3.
|
|
445
|
+
* Misses synthesize once and persist; later listens (and other learners on
|
|
446
|
+
* the same install) replay from disk without touching the endpoint.
|
|
447
|
+
* @param synth - the synthesizer (injectable for tests; real Edge TTS by default).
|
|
448
|
+
*/
|
|
449
|
+
async function cachedTtsMp3(cacheDir, text, voice = DEFAULT_TTS_VOICE, synth = synthesizeSpeech) {
|
|
450
|
+
const file = ttsCachePath(cacheDir, text, voice);
|
|
451
|
+
if (existsSync(file)) return readFileSync(file);
|
|
452
|
+
mkdirSync(cacheDir, { recursive: true });
|
|
453
|
+
const mp3 = await synth(text, voice);
|
|
454
|
+
if (mp3.length === 0) throw new Error("edge tts: synthesis returned empty audio (not cached)");
|
|
455
|
+
writeFileSync(file, mp3);
|
|
456
|
+
return mp3;
|
|
457
|
+
}
|
|
236
458
|
//#endregion
|
|
237
459
|
//#region src/vendor/sm2.ts
|
|
238
460
|
function computeSm2(prev, quality, now = /* @__PURE__ */ new Date()) {
|
|
@@ -1412,7 +1634,8 @@ function workbenchState(state, now) {
|
|
|
1412
1634
|
quote: n.quote
|
|
1413
1635
|
})),
|
|
1414
1636
|
html: renderMarkdown(normalizeMathNotation(ref.lesson.translation === void 0 ? ref.lesson.body : renderBilingual(ref.lesson.body, ref.lesson.translation))),
|
|
1415
|
-
markdown: ref.lesson.body
|
|
1637
|
+
markdown: ref.lesson.body,
|
|
1638
|
+
speechText: normalizeSpeechText(normalizeMathNotation(ref.lesson.body))
|
|
1416
1639
|
};
|
|
1417
1640
|
} catch {
|
|
1418
1641
|
lesson = null;
|
|
@@ -1650,6 +1873,40 @@ function registerDashboard(webServer, deps) {
|
|
|
1650
1873
|
sendJson(res, 200, { ok: true });
|
|
1651
1874
|
return;
|
|
1652
1875
|
}
|
|
1876
|
+
if (req.method === "POST" && pathname === "/lookatstudy/api/tts") {
|
|
1877
|
+
const body = await readJsonBodySafe(req, res);
|
|
1878
|
+
if (body === void 0) return;
|
|
1879
|
+
const text = typeof body.text === "string" ? body.text.trim() : "";
|
|
1880
|
+
if (text === "") {
|
|
1881
|
+
sendJson(res, 400, {
|
|
1882
|
+
ok: false,
|
|
1883
|
+
error: "text (non-empty string) required"
|
|
1884
|
+
});
|
|
1885
|
+
return;
|
|
1886
|
+
}
|
|
1887
|
+
if (text.length > 4e3) {
|
|
1888
|
+
sendJson(res, 400, {
|
|
1889
|
+
ok: false,
|
|
1890
|
+
error: "text exceeds 4000 chars (speak sentence groups, not whole lessons)"
|
|
1891
|
+
});
|
|
1892
|
+
return;
|
|
1893
|
+
}
|
|
1894
|
+
const voice = normalizeVoice(typeof body.voice === "string" ? body.voice : void 0);
|
|
1895
|
+
const cacheDir = join(deps.studyAreaPath, "tts-cache");
|
|
1896
|
+
try {
|
|
1897
|
+
sendJson(res, 200, {
|
|
1898
|
+
ok: true,
|
|
1899
|
+
mime: "audio/mpeg",
|
|
1900
|
+
dataBase64: (await cachedTtsMp3(cacheDir, text, voice, deps.tts?.synthesize)).toString("base64")
|
|
1901
|
+
});
|
|
1902
|
+
} catch (error) {
|
|
1903
|
+
sendJson(res, 502, {
|
|
1904
|
+
ok: false,
|
|
1905
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1906
|
+
});
|
|
1907
|
+
}
|
|
1908
|
+
return;
|
|
1909
|
+
}
|
|
1653
1910
|
if (req.method === "POST" && pathname === "/lookatstudy/api/mode") {
|
|
1654
1911
|
const body = await readJsonBodySafe(req, res);
|
|
1655
1912
|
if (body === void 0) return;
|
|
@@ -1727,6 +1984,12 @@ const ZH = {
|
|
|
1727
1984
|
"rail.delete.title.confirm": "再点一次确认删除(含全部进度与笔记)。反悔前可先在设置页备份状态文件",
|
|
1728
1985
|
"note.delete": "删除本条笔记",
|
|
1729
1986
|
"note.delete.confirm": "确认删除?",
|
|
1987
|
+
"read.play": "朗读本课",
|
|
1988
|
+
"read.stop": "停止朗读",
|
|
1989
|
+
"read.pause": "暂停朗读",
|
|
1990
|
+
"read.resume": "继续朗读",
|
|
1991
|
+
"read.engine.system": "网络合成不可用,已切换为系统语音",
|
|
1992
|
+
"read.unavailable": "朗读不可用",
|
|
1730
1993
|
"rail.section.collapse": "折叠本章节",
|
|
1731
1994
|
"rail.section.expand": "展开本章节({count} 课时)",
|
|
1732
1995
|
"rail.section.count": "{count} 课",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-lookatstudy",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"packageManager": "pnpm@11.7.0",
|
|
5
5
|
"description": "Turn any markdown, local folder, or GitHub learning repo into a guided course inside DeepSeek Harness: gated skill-tree progression, BKT mastery tracking, SM-2 spaced repetition.",
|
|
6
6
|
"type": "module",
|