dsh-plugin-lookatstudy 0.12.4 → 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 +630 -6
- package/lib/client.js.map +1 -1
- package/lib/index.mjs +307 -4
- 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()) {
|
|
@@ -945,7 +1167,10 @@ function setMemory(state, category, content, lessonId) {
|
|
|
945
1167
|
function addNote(state, lessonId, zone, title, text, source, quote, now) {
|
|
946
1168
|
const ref = findLesson(state, lessonId);
|
|
947
1169
|
const note = {
|
|
948
|
-
id: `${lessonId}:n${ref.lesson.notes.
|
|
1170
|
+
id: `${lessonId}:n${ref.lesson.notes.reduce((max, n) => {
|
|
1171
|
+
const m = /:n(\d+)$/.exec(n.id);
|
|
1172
|
+
return m !== null ? Math.max(max, Number(m[1])) : max;
|
|
1173
|
+
}, -1) + 1}`,
|
|
949
1174
|
zone,
|
|
950
1175
|
title,
|
|
951
1176
|
text,
|
|
@@ -957,6 +1182,19 @@ function addNote(state, lessonId, zone, title, text, source, quote, now) {
|
|
|
957
1182
|
return note;
|
|
958
1183
|
}
|
|
959
1184
|
/**
|
|
1185
|
+
* Delete one notebook entry from a lesson's Cornell zones.
|
|
1186
|
+
* @param state - state to mutate.
|
|
1187
|
+
* @param lessonId - lesson the note belongs to.
|
|
1188
|
+
* @param noteId - id of the note to remove.
|
|
1189
|
+
* @throws when the lesson or the note id is unknown (fail loud, like every id lookup).
|
|
1190
|
+
*/
|
|
1191
|
+
function deleteNote(state, lessonId, noteId) {
|
|
1192
|
+
const ref = findLesson(state, lessonId);
|
|
1193
|
+
const index = ref.lesson.notes.findIndex((n) => n.id === noteId);
|
|
1194
|
+
if (index === -1) throw new Error(`lookatstudy-plugin: note ${JSON.stringify(noteId)} not found on lesson ${JSON.stringify(lessonId)}`);
|
|
1195
|
+
ref.lesson.notes.splice(index, 1);
|
|
1196
|
+
}
|
|
1197
|
+
/**
|
|
960
1198
|
* Propose early mastery graduation for the learner to accept or reject in chat.
|
|
961
1199
|
* @param state - state to mutate.
|
|
962
1200
|
* @param lessonId - lesson judged mastered.
|
|
@@ -1396,7 +1634,8 @@ function workbenchState(state, now) {
|
|
|
1396
1634
|
quote: n.quote
|
|
1397
1635
|
})),
|
|
1398
1636
|
html: renderMarkdown(normalizeMathNotation(ref.lesson.translation === void 0 ? ref.lesson.body : renderBilingual(ref.lesson.body, ref.lesson.translation))),
|
|
1399
|
-
markdown: ref.lesson.body
|
|
1637
|
+
markdown: ref.lesson.body,
|
|
1638
|
+
speechText: normalizeSpeechText(normalizeMathNotation(ref.lesson.body))
|
|
1400
1639
|
};
|
|
1401
1640
|
} catch {
|
|
1402
1641
|
lesson = null;
|
|
@@ -1597,6 +1836,28 @@ function registerDashboard(webServer, deps) {
|
|
|
1597
1836
|
}
|
|
1598
1837
|
return;
|
|
1599
1838
|
}
|
|
1839
|
+
if (req.method === "POST" && pathname === "/lookatstudy/api/note/delete") {
|
|
1840
|
+
const body = await readJsonBodySafe(req, res);
|
|
1841
|
+
if (body === void 0) return;
|
|
1842
|
+
if (typeof body.lessonId !== "string" || typeof body.noteId !== "string") {
|
|
1843
|
+
sendJson(res, 400, {
|
|
1844
|
+
ok: false,
|
|
1845
|
+
error: "lessonId and noteId (strings) required"
|
|
1846
|
+
});
|
|
1847
|
+
return;
|
|
1848
|
+
}
|
|
1849
|
+
try {
|
|
1850
|
+
deleteNote(deps.store.get(), body.lessonId, body.noteId);
|
|
1851
|
+
deps.store.save();
|
|
1852
|
+
sendJson(res, 200, { ok: true });
|
|
1853
|
+
} catch (error) {
|
|
1854
|
+
sendJson(res, 404, {
|
|
1855
|
+
ok: false,
|
|
1856
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1857
|
+
});
|
|
1858
|
+
}
|
|
1859
|
+
return;
|
|
1860
|
+
}
|
|
1600
1861
|
if (req.method === "POST" && pathname === "/lookatstudy/api/lesson-session") {
|
|
1601
1862
|
const body = await readJsonBodySafe(req, res);
|
|
1602
1863
|
if (body === void 0) return;
|
|
@@ -1612,6 +1873,40 @@ function registerDashboard(webServer, deps) {
|
|
|
1612
1873
|
sendJson(res, 200, { ok: true });
|
|
1613
1874
|
return;
|
|
1614
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
|
+
}
|
|
1615
1910
|
if (req.method === "POST" && pathname === "/lookatstudy/api/mode") {
|
|
1616
1911
|
const body = await readJsonBodySafe(req, res);
|
|
1617
1912
|
if (body === void 0) return;
|
|
@@ -1687,6 +1982,14 @@ const ZH = {
|
|
|
1687
1982
|
"rail.delete": "删除本课程",
|
|
1688
1983
|
"rail.delete.confirm": "确认删除?",
|
|
1689
1984
|
"rail.delete.title.confirm": "再点一次确认删除(含全部进度与笔记)。反悔前可先在设置页备份状态文件",
|
|
1985
|
+
"note.delete": "删除本条笔记",
|
|
1986
|
+
"note.delete.confirm": "确认删除?",
|
|
1987
|
+
"read.play": "朗读本课",
|
|
1988
|
+
"read.stop": "停止朗读",
|
|
1989
|
+
"read.pause": "暂停朗读",
|
|
1990
|
+
"read.resume": "继续朗读",
|
|
1991
|
+
"read.engine.system": "网络合成不可用,已切换为系统语音",
|
|
1992
|
+
"read.unavailable": "朗读不可用",
|
|
1690
1993
|
"rail.section.collapse": "折叠本章节",
|
|
1691
1994
|
"rail.section.expand": "展开本章节({count} 课时)",
|
|
1692
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",
|