dsh-plugin-lookatstudy 0.12.5 → 0.13.1
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 +4 -4
- package/lib/client.js +665 -2
- package/lib/client.js.map +1 -1
- package/lib/index.mjs +274 -3
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -249,11 +249,18 @@ window.__ModuleLoader__.load({
|
|
|
249
249
|
/* inline error text (write-action failures) */
|
|
250
250
|
.lks-propcard-err{color:var(--dsw-alias-state-error-primary);font-size:12px;margin-top:6px;flex:none}
|
|
251
251
|
|
|
252
|
+
/* read-aloud bar (teach pane, under the view tabs) */
|
|
253
|
+
.lks-readbar{display:flex;align-items:center;gap:6px;margin:0 0 8px;min-height:26px}
|
|
254
|
+
.lks-readbar .lks-btn{display:inline-flex;align-items:center;gap:4px}
|
|
255
|
+
.lks-readbar-cur{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12.5px;color:var(--dsw-alias-label-secondary);border-left:2px solid var(--dsw-alias-state-info-primary,var(--dsw-alias-business-primary));padding-left:8px}
|
|
256
|
+
.lks-readbar-notice{flex:none;font-size:11.5px;color:var(--dsw-alias-label-tertiary)}
|
|
257
|
+
|
|
252
258
|
/* settings section (settings.section entry inside the host settings shell) */
|
|
253
259
|
.lks-settings{display:flex;flex-direction:column;gap:18px;font-family:var(--dsw-font-family);color:var(--dsw-alias-label-primary)}
|
|
254
260
|
.lks-set-row h3{margin:0 0 4px;font-size:15px;font-weight:600}
|
|
255
261
|
.lks-set-hint{margin:0 0 10px;font-size:13px;color:var(--dsw-alias-label-secondary)}
|
|
256
262
|
.lks-set-state{margin-left:10px;font-size:13px;color:var(--dsw-alias-label-secondary)}
|
|
263
|
+
.lks-set-select{font:inherit;font-size:13px;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:5px 8px;max-width:100%}
|
|
257
264
|
.lks-set-stats{margin:0;padding-left:18px;font-size:14px;line-height:1.9}
|
|
258
265
|
.lks-set-path{font-family:var(--dsw-font-markdown-code);font-size:12.5px;background:var(--dsw-alias-bg-layer-3);border-radius:6px;padding:3px 8px;word-break:break-all}
|
|
259
266
|
|
|
@@ -730,7 +737,69 @@ window.__ModuleLoader__.load({
|
|
|
730
737
|
});
|
|
731
738
|
this.refresh();
|
|
732
739
|
}
|
|
740
|
+
/** Synthesize one speakable chunk to MP3 (host-side Edge TTS, cache-first). */
|
|
741
|
+
async tts(text, voice) {
|
|
742
|
+
const body = await fetchJson("/lookatstudy/api/tts", {
|
|
743
|
+
method: "POST",
|
|
744
|
+
headers: { "content-type": "application/json" },
|
|
745
|
+
body: JSON.stringify({
|
|
746
|
+
text,
|
|
747
|
+
voice
|
|
748
|
+
})
|
|
749
|
+
});
|
|
750
|
+
const raw = body.dataBase64;
|
|
751
|
+
if (body.ok !== true || typeof raw !== "string") throw new Error("tts response missing audio payload");
|
|
752
|
+
const bin = atob(raw);
|
|
753
|
+
const bytes = new Uint8Array(bin.length);
|
|
754
|
+
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
|
755
|
+
return bytes.buffer;
|
|
756
|
+
}
|
|
733
757
|
};
|
|
758
|
+
/** The voices the dashboard route accepts (same allowlist server-side). */
|
|
759
|
+
const TTS_VOICES = [
|
|
760
|
+
{
|
|
761
|
+
id: "zh-CN-XiaoxiaoNeural",
|
|
762
|
+
labelKey: "voice.xiaoxiao"
|
|
763
|
+
},
|
|
764
|
+
{
|
|
765
|
+
id: "zh-CN-YunxiNeural",
|
|
766
|
+
labelKey: "voice.yunxi"
|
|
767
|
+
},
|
|
768
|
+
{
|
|
769
|
+
id: "zh-CN-YunyangNeural",
|
|
770
|
+
labelKey: "voice.yunyang"
|
|
771
|
+
},
|
|
772
|
+
{
|
|
773
|
+
id: "zh-CN-XiaoyiNeural",
|
|
774
|
+
labelKey: "voice.xiaoyi"
|
|
775
|
+
},
|
|
776
|
+
{
|
|
777
|
+
id: "en-US-AriaNeural",
|
|
778
|
+
labelKey: "voice.aria"
|
|
779
|
+
},
|
|
780
|
+
{
|
|
781
|
+
id: "en-US-GuyNeural",
|
|
782
|
+
labelKey: "voice.guy"
|
|
783
|
+
}
|
|
784
|
+
];
|
|
785
|
+
const TTS_VOICE_KEY = "dsh-plugin-lookatstudy:tts-voice";
|
|
786
|
+
/** Narrow a stored voice id to the allowlist; anything else falls back to 晓晓. Pure. */
|
|
787
|
+
function normalizeStoredVoice(raw) {
|
|
788
|
+
return TTS_VOICES.some((v) => v.id === raw) ? raw : TTS_VOICES[0].id;
|
|
789
|
+
}
|
|
790
|
+
/** The selected read-aloud voice (browser-local preference; host state untouched). */
|
|
791
|
+
function storedTtsVoice() {
|
|
792
|
+
try {
|
|
793
|
+
return normalizeStoredVoice(typeof localStorage === "undefined" ? null : localStorage.getItem(TTS_VOICE_KEY));
|
|
794
|
+
} catch {
|
|
795
|
+
return TTS_VOICES[0].id;
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
function storeTtsVoice(id) {
|
|
799
|
+
try {
|
|
800
|
+
localStorage.setItem(TTS_VOICE_KEY, normalizeStoredVoice(id));
|
|
801
|
+
} catch {}
|
|
802
|
+
}
|
|
734
803
|
/** The one shared store instance backing every study seat component. */
|
|
735
804
|
const studyStore = new StudyStore();
|
|
736
805
|
/** React binding: live snapshot plus the write actions. */
|
|
@@ -1585,6 +1654,12 @@ window.__ModuleLoader__.load({
|
|
|
1585
1654
|
"rail.delete.title.confirm": "再点一次确认删除(含全部进度与笔记)。反悔前可先在设置页备份状态文件",
|
|
1586
1655
|
"note.delete": "删除本条笔记",
|
|
1587
1656
|
"note.delete.confirm": "确认删除?",
|
|
1657
|
+
"read.play": "朗读本课",
|
|
1658
|
+
"read.stop": "停止朗读",
|
|
1659
|
+
"read.pause": "暂停朗读",
|
|
1660
|
+
"read.resume": "继续朗读",
|
|
1661
|
+
"read.engine.system": "网络合成不可用,已切换为系统语音",
|
|
1662
|
+
"read.unavailable": "朗读不可用",
|
|
1588
1663
|
"rail.section.collapse": "折叠本章节",
|
|
1589
1664
|
"rail.section.expand": "展开本章节({count} 课时)",
|
|
1590
1665
|
"rail.section.count": "{count} 课",
|
|
@@ -1635,6 +1710,14 @@ window.__ModuleLoader__.load({
|
|
|
1635
1710
|
"settings.mode": "教学风格",
|
|
1636
1711
|
"settings.mode.hint": "导师的讲解风格;也可在学习页随时切换",
|
|
1637
1712
|
"settings.studyMode": "学习模式",
|
|
1713
|
+
"settings.voice": "朗读语音",
|
|
1714
|
+
"settings.voice.hint": "「朗读本课」使用的神经网络声音(浏览器本地偏好)",
|
|
1715
|
+
"voice.xiaoxiao": "晓晓 · 中文女声",
|
|
1716
|
+
"voice.yunxi": "云希 · 中文男声",
|
|
1717
|
+
"voice.yunyang": "云扬 · 中文男声(新闻)",
|
|
1718
|
+
"voice.xiaoyi": "晓伊 · 中文女声(轻快)",
|
|
1719
|
+
"voice.aria": "Aria · English female",
|
|
1720
|
+
"voice.guy": "Guy · English male",
|
|
1638
1721
|
"settings.studyMode.hint": "开启后注册 study_* 工具并载入导师人格;进度与笔记始终保留",
|
|
1639
1722
|
"settings.on": "状态:已开启",
|
|
1640
1723
|
"settings.off": "状态:已关闭",
|
|
@@ -1707,6 +1790,12 @@ window.__ModuleLoader__.load({
|
|
|
1707
1790
|
"rail.delete.title.confirm": "Click again to confirm (all progress and notes included). Back up via the state file in Settings first",
|
|
1708
1791
|
"note.delete": "Delete this note",
|
|
1709
1792
|
"note.delete.confirm": "Delete?",
|
|
1793
|
+
"read.play": "Read aloud",
|
|
1794
|
+
"read.stop": "Stop reading",
|
|
1795
|
+
"read.pause": "Pause reading",
|
|
1796
|
+
"read.resume": "Resume reading",
|
|
1797
|
+
"read.engine.system": "Network synthesis unavailable — switched to the system voice",
|
|
1798
|
+
"read.unavailable": "Read-aloud unavailable",
|
|
1710
1799
|
"rail.section.collapse": "Collapse this section",
|
|
1711
1800
|
"rail.section.expand": "Expand this section ({count} lessons)",
|
|
1712
1801
|
"rail.section.count": "{count} lessons",
|
|
@@ -1757,6 +1846,14 @@ window.__ModuleLoader__.load({
|
|
|
1757
1846
|
"settings.mode": "Teaching style",
|
|
1758
1847
|
"settings.mode.hint": "The tutor's explanation style; switchable anytime in the study tab too",
|
|
1759
1848
|
"settings.studyMode": "Study mode",
|
|
1849
|
+
"settings.voice": "Read-aloud voice",
|
|
1850
|
+
"settings.voice.hint": "The neural voice used by 朗读本课 (a browser-local preference)",
|
|
1851
|
+
"voice.xiaoxiao": "Xiaoxiao · Chinese female",
|
|
1852
|
+
"voice.yunxi": "Yunxi · Chinese male",
|
|
1853
|
+
"voice.yunyang": "Yunyang · Chinese male (news)",
|
|
1854
|
+
"voice.xiaoyi": "Xiaoyi · Chinese female (lively)",
|
|
1855
|
+
"voice.aria": "Aria · English female",
|
|
1856
|
+
"voice.guy": "Guy · English male",
|
|
1760
1857
|
"settings.studyMode.hint": "On registers the study_* tools and the tutor persona; progress and notes are always kept",
|
|
1761
1858
|
"settings.on": "Status: on",
|
|
1762
1859
|
"settings.off": "Status: off",
|
|
@@ -1925,6 +2022,436 @@ window.__ModuleLoader__.load({
|
|
|
1925
2022
|
container.append(legend);
|
|
1926
2023
|
}
|
|
1927
2024
|
//#endregion
|
|
2025
|
+
//#region src/vendor/speech-text.ts
|
|
2026
|
+
/**
|
|
2027
|
+
* Vendored from LookatStudy shared/speech-text.ts (MIT License,
|
|
2028
|
+
* https://github.com/Kaiji-Z/LookatStudy), upstream v0.28.0 — includes the
|
|
2029
|
+
* v11.5 whole-display-group avoidance fix era. Unmodified except this
|
|
2030
|
+
* provenance header.
|
|
2031
|
+
*
|
|
2032
|
+
* speech-text —— TTS 朗读文本处理(纯函数,渲染层/主进程共用)。
|
|
2033
|
+
*
|
|
2034
|
+
* normalizeSpeechText: markdown → 可朗读纯文本。代码不读(围栏/行内整体移除),
|
|
2035
|
+
* 链接留文字,强调/标题/列表/引用/表格标记剥离 —— 导师"念的是话,不是版面"。
|
|
2036
|
+
*
|
|
2037
|
+
* splitSentences: 流式友好的切句。每次喂"累积文本"返回完整句 + 余量(幂等,StrictMode 安全);
|
|
2038
|
+
* flush=true 时尾句强制吐出。终止标点:中英句号/叹/问/分号 + 省略号;ASCII '.' 要求后跟
|
|
2039
|
+
* 空白且前字符非数字(3.14 不切)。超过 maxBuffer 仍无终止标点 → 在最后的软标点(、,;: 空白)
|
|
2040
|
+
* 处断开,再无则硬断 —— 保证"导师边生成边念"的流式管线永远不会饿死。
|
|
2041
|
+
*
|
|
2042
|
+
* v11.2 表意分隔:换行与 emoji 也是句界。无标点的段落/列表/短句流文本,
|
|
2043
|
+
* 旧逻辑的强制断句块会在显示层并组吞整段(高亮整段到结尾);现在逐行/逐 emoji
|
|
2044
|
+
* 成句,显示组另加长度上限兜底 —— karaoke 高亮粒度永远可控。
|
|
2045
|
+
*/
|
|
2046
|
+
/** 终止标点(出现即成句,连续终止/右引号并吞进句尾) */
|
|
2047
|
+
const HARD = /* @__PURE__ */ new Set([
|
|
2048
|
+
"。",
|
|
2049
|
+
"!",
|
|
2050
|
+
"?",
|
|
2051
|
+
"!",
|
|
2052
|
+
"?",
|
|
2053
|
+
";",
|
|
2054
|
+
";",
|
|
2055
|
+
"…"
|
|
2056
|
+
]);
|
|
2057
|
+
/** 软标点(超长兜底断句位) */
|
|
2058
|
+
const SOFT = /* @__PURE__ */ new Set([
|
|
2059
|
+
",",
|
|
2060
|
+
",",
|
|
2061
|
+
"、",
|
|
2062
|
+
":",
|
|
2063
|
+
":",
|
|
2064
|
+
" ",
|
|
2065
|
+
" "
|
|
2066
|
+
]);
|
|
2067
|
+
/** v11.2 emoji 基字符范围(表意/象形/杂项符号/箭头符号区);修饰符(VS16/ZWJ/肤色)不单独成界 */
|
|
2068
|
+
const EMOJI_CP_RE = /[\u{1F000}-\u{1FAFF}\u{2600}-\u{27BF}\u{2B00}-\u{2BFF}]/u;
|
|
2069
|
+
function isEmojiCp(cp) {
|
|
2070
|
+
return cp != null && EMOJI_CP_RE.test(String.fromCodePoint(cp));
|
|
2071
|
+
}
|
|
2072
|
+
/** 句尾可并吞的右闭合符 */
|
|
2073
|
+
const CLOSERS = /* @__PURE__ */ new Set([
|
|
2074
|
+
"”",
|
|
2075
|
+
"\"",
|
|
2076
|
+
"」",
|
|
2077
|
+
"』",
|
|
2078
|
+
"》",
|
|
2079
|
+
")",
|
|
2080
|
+
"】",
|
|
2081
|
+
"])".slice(0, 1)
|
|
2082
|
+
]);
|
|
2083
|
+
function normalizeSpeechText(md) {
|
|
2084
|
+
let s = md;
|
|
2085
|
+
s = s.replace(/\r\n?/g, "\n");
|
|
2086
|
+
s = s.replace(/(?:^|\n)[ \t]*(?:```|~~~)[^\n]*\n[\s\S]*?(?:\n[ \t]*(?:```|~~~)[^\n]*|\n?$)/g, "\n");
|
|
2087
|
+
s = s.replace(/`[^`\n]*`/g, "");
|
|
2088
|
+
s = s.replace(/!\[[^\]]*\]\([^)\n]*\)/g, "");
|
|
2089
|
+
s = s.replace(/\[([^\]]+)\]\([^)\n]*\)/g, "$1");
|
|
2090
|
+
s = s.replace(/^[ \t]{0,3}#{1,6}[ \t]+/gm, "");
|
|
2091
|
+
s = s.replace(/^[ \t]*(?:[-*+]|\d+\.)[ \t]+/gm, "");
|
|
2092
|
+
s = s.replace(/^[ \t]*>[ \t]?/gm, "");
|
|
2093
|
+
s = s.replace(/\|/g, " ");
|
|
2094
|
+
s = s.replace(/^[ \t]*[-: ]{3,}[ \t]*$/gm, "");
|
|
2095
|
+
s = s.replace(/(\*\*|__)(.*?)\1/g, "$2");
|
|
2096
|
+
s = s.replace(/(?<![*\w])(\*|_)(?!\s)(.+?)(?<!\s)\1(?![*\w])/g, "$2");
|
|
2097
|
+
s = s.replace(/~~(.+?)~~/g, "$1");
|
|
2098
|
+
s = s.replace(/\n{3,}/g, "\n\n");
|
|
2099
|
+
return s.trim();
|
|
2100
|
+
}
|
|
2101
|
+
/**
|
|
2102
|
+
* v11.2 朗读句表单一入口(v11.4 起仅合成侧使用):tts-service 用它切段;
|
|
2103
|
+
* 渲染层 karaoke **不再调用**(改吃 ttsAudio.sentence 权威原文,见
|
|
2104
|
+
* playedSentencePrefix)——句表从合成侧单向流出,显示侧零复算零分叉。
|
|
2105
|
+
*/
|
|
2106
|
+
function speechSentencesOf(text) {
|
|
2107
|
+
return splitSentences(normalizeSpeechText(text), { flush: true }).sentences;
|
|
2108
|
+
}
|
|
2109
|
+
function splitSentences(text, opts = {}) {
|
|
2110
|
+
const maxBuffer = Math.max(8, opts.maxBuffer ?? 120);
|
|
2111
|
+
const flush = opts.flush ?? false;
|
|
2112
|
+
const out = [];
|
|
2113
|
+
const n = text.length;
|
|
2114
|
+
let start = 0;
|
|
2115
|
+
let i = 0;
|
|
2116
|
+
let lastSoft = -1;
|
|
2117
|
+
const emit = (end) => {
|
|
2118
|
+
const piece = text.slice(start, end).trim();
|
|
2119
|
+
if (piece) out.push(piece);
|
|
2120
|
+
start = end;
|
|
2121
|
+
lastSoft = -1;
|
|
2122
|
+
};
|
|
2123
|
+
while (i < n) {
|
|
2124
|
+
const ch = text[i];
|
|
2125
|
+
const cp = text.codePointAt(i);
|
|
2126
|
+
if (isEmojiCp(cp)) {
|
|
2127
|
+
let j = i + (cp > 65535 ? 2 : 1);
|
|
2128
|
+
while (j < n) {
|
|
2129
|
+
const cj = text.codePointAt(j);
|
|
2130
|
+
const ul = cj > 65535 ? 2 : 1;
|
|
2131
|
+
if (cj === 65039 || cj === 8205 || cj >= 127995 && cj <= 127999 || isEmojiCp(cj)) {
|
|
2132
|
+
j += ul;
|
|
2133
|
+
continue;
|
|
2134
|
+
}
|
|
2135
|
+
break;
|
|
2136
|
+
}
|
|
2137
|
+
emit(j);
|
|
2138
|
+
i = j;
|
|
2139
|
+
continue;
|
|
2140
|
+
}
|
|
2141
|
+
if (ch === "\n") {
|
|
2142
|
+
const piece = text.slice(start, i + 1).replace(/[^\S\n]+$/, "");
|
|
2143
|
+
if (piece.trim()) out.push(piece);
|
|
2144
|
+
start = i + 1;
|
|
2145
|
+
lastSoft = -1;
|
|
2146
|
+
i++;
|
|
2147
|
+
continue;
|
|
2148
|
+
}
|
|
2149
|
+
if (HARD.has(ch)) {
|
|
2150
|
+
let j = i + 1;
|
|
2151
|
+
while (j < n && (HARD.has(text[j]) || CLOSERS.has(text[j]))) j++;
|
|
2152
|
+
emit(j);
|
|
2153
|
+
i = j;
|
|
2154
|
+
continue;
|
|
2155
|
+
}
|
|
2156
|
+
if (ch === ".") {
|
|
2157
|
+
const prev = i > 0 ? text[i - 1] : "";
|
|
2158
|
+
const next = i + 1 < n ? text[i + 1] : "";
|
|
2159
|
+
if ((next === "" || /\s/.test(next)) && !/\d/.test(prev)) {
|
|
2160
|
+
let j = i + 1;
|
|
2161
|
+
while (j < n && (CLOSERS.has(text[j]) || HARD.has(text[j]))) j++;
|
|
2162
|
+
emit(j);
|
|
2163
|
+
i = j;
|
|
2164
|
+
continue;
|
|
2165
|
+
}
|
|
2166
|
+
}
|
|
2167
|
+
if (SOFT.has(ch)) lastSoft = i;
|
|
2168
|
+
if (i - start + 1 > maxBuffer) {
|
|
2169
|
+
if (lastSoft > start) {
|
|
2170
|
+
const cut = lastSoft;
|
|
2171
|
+
emit(cut);
|
|
2172
|
+
start = cut + 1;
|
|
2173
|
+
} else {
|
|
2174
|
+
emit(i + 1);
|
|
2175
|
+
i++;
|
|
2176
|
+
continue;
|
|
2177
|
+
}
|
|
2178
|
+
}
|
|
2179
|
+
i++;
|
|
2180
|
+
}
|
|
2181
|
+
const rest = text.slice(start).trim();
|
|
2182
|
+
if (flush && rest) out.push(rest);
|
|
2183
|
+
return {
|
|
2184
|
+
sentences: out,
|
|
2185
|
+
rest: flush ? "" : rest
|
|
2186
|
+
};
|
|
2187
|
+
}
|
|
2188
|
+
//#endregion
|
|
2189
|
+
//#region src/vendor/math-speech.ts
|
|
2190
|
+
/**
|
|
2191
|
+
* Vendored from LookatStudy shared/math-speech.ts (MIT License,
|
|
2192
|
+
* https://github.com/Kaiji-Z/LookatStudy), upstream v0.28.0. Unmodified
|
|
2193
|
+
* except this provenance header. zh-only by design upstream — the plugin's
|
|
2194
|
+
* read-aloud pipeline applies it for zh and strips $..$ delimiters for other
|
|
2195
|
+
* locales (conversion for more languages is future work).
|
|
2196
|
+
*
|
|
2197
|
+
* math-speech —— LaTeX 公式 → 中文口语(v0.19 朗读口语化,纯函数,verify 直测)。
|
|
2198
|
+
*
|
|
2199
|
+
* 只在**合成侧**应用(真正念出来的文本);karaoke 高亮/匹配层继续吃 `$..$` 原文
|
|
2200
|
+
* (DOM 侧 getTextModel 收 KaTeX annotation 的 TeX 源,两侧在 canonical 空间对齐)
|
|
2201
|
+
* ——念的是人话,亮的是原文,互不干扰。
|
|
2202
|
+
*
|
|
2203
|
+
* 规则表起步:常见记号(分式/根号/上下标/希腊字母/关系与算符)覆盖优先,
|
|
2204
|
+
* 未覆盖宏退化为逐字母(好于念"反斜杠 f-r-a-c")。
|
|
2205
|
+
*/
|
|
2206
|
+
/** 单符号命令 → 中文(长命令先试,避免 \le 被 \leq 截断)。 */
|
|
2207
|
+
const SYMBOLS = [
|
|
2208
|
+
["\\leq", "小于等于"],
|
|
2209
|
+
["\\le", "小于等于"],
|
|
2210
|
+
["\\geq", "大于等于"],
|
|
2211
|
+
["\\ge", "大于等于"],
|
|
2212
|
+
["\\neq", "不等于"],
|
|
2213
|
+
["\\ne", "不等于"],
|
|
2214
|
+
["\\approx", "约等于"],
|
|
2215
|
+
["\\equiv", "恒等于"],
|
|
2216
|
+
["\\pm", "正负"],
|
|
2217
|
+
["\\times", "乘以"],
|
|
2218
|
+
["\\cdot", "点乘"],
|
|
2219
|
+
["\\div", "除以"],
|
|
2220
|
+
["\\to", "趋于"],
|
|
2221
|
+
["\\rightarrow", "趋于"],
|
|
2222
|
+
["\\Rightarrow", "推出"],
|
|
2223
|
+
["\\infty", "无穷"],
|
|
2224
|
+
["\\sum", "求和"],
|
|
2225
|
+
["\\prod", "连乘"],
|
|
2226
|
+
["\\int", "积分"],
|
|
2227
|
+
["\\lim", "极限"],
|
|
2228
|
+
["\\log", "对数"],
|
|
2229
|
+
["\\ln", "自然对数"],
|
|
2230
|
+
["\\exp", "指数"],
|
|
2231
|
+
["\\in", "属于"],
|
|
2232
|
+
["\\subset", "包含于"],
|
|
2233
|
+
["\\cup", "并"],
|
|
2234
|
+
["\\cap", "交"],
|
|
2235
|
+
["\\forall", "任意"],
|
|
2236
|
+
["\\exists", "存在"],
|
|
2237
|
+
["\\partial", "偏导"],
|
|
2238
|
+
["\\nabla", "梯度"],
|
|
2239
|
+
["\\angle", "角"],
|
|
2240
|
+
["\\degree", "度"],
|
|
2241
|
+
["\\cdots", "点点点"],
|
|
2242
|
+
["\\ldots", "点点点"]
|
|
2243
|
+
];
|
|
2244
|
+
const GREEK = {
|
|
2245
|
+
alpha: "阿尔法",
|
|
2246
|
+
beta: "贝塔",
|
|
2247
|
+
gamma: "伽马",
|
|
2248
|
+
delta: "德尔塔",
|
|
2249
|
+
epsilon: "艾普西隆",
|
|
2250
|
+
zeta: "泽塔",
|
|
2251
|
+
eta: "伊塔",
|
|
2252
|
+
theta: "西塔",
|
|
2253
|
+
iota: "约塔",
|
|
2254
|
+
kappa: "卡帕",
|
|
2255
|
+
lambda: "拉姆达",
|
|
2256
|
+
mu: "缪",
|
|
2257
|
+
nu: "纽",
|
|
2258
|
+
xi: "克西",
|
|
2259
|
+
pi: "派",
|
|
2260
|
+
rho: "柔",
|
|
2261
|
+
sigma: "西格马",
|
|
2262
|
+
tau: "陶",
|
|
2263
|
+
phi: "斐",
|
|
2264
|
+
chi: "凯",
|
|
2265
|
+
psi: "普西",
|
|
2266
|
+
omega: "欧米伽"
|
|
2267
|
+
};
|
|
2268
|
+
/** 读取 `{...}` 花括号组(从 openIdx 的下一个字符起,返回内容与结束下标)。 */
|
|
2269
|
+
function readGroup(s, i) {
|
|
2270
|
+
if (s[i] !== "{") return null;
|
|
2271
|
+
let depth = 0;
|
|
2272
|
+
for (let j = i; j < s.length; j++) if (s[j] === "{") depth++;
|
|
2273
|
+
else if (s[j] === "}") {
|
|
2274
|
+
depth--;
|
|
2275
|
+
if (depth === 0) return {
|
|
2276
|
+
body: s.slice(i + 1, j),
|
|
2277
|
+
end: j + 1
|
|
2278
|
+
};
|
|
2279
|
+
}
|
|
2280
|
+
return null;
|
|
2281
|
+
}
|
|
2282
|
+
/** 单 token(花括号组或单字符)读取:返回 {body, end}。 */
|
|
2283
|
+
function readAtom(s, i) {
|
|
2284
|
+
const g = readGroup(s, i);
|
|
2285
|
+
if (g) return g;
|
|
2286
|
+
return {
|
|
2287
|
+
body: s[i] ?? "",
|
|
2288
|
+
end: i + 1
|
|
2289
|
+
};
|
|
2290
|
+
}
|
|
2291
|
+
/** LaTeX 片段 → 中文口语(递归:命令参数体内再走一遍)。 */
|
|
2292
|
+
function mathToSpokenZH(tex) {
|
|
2293
|
+
let s = tex;
|
|
2294
|
+
s = s.replace(/\\(?:left|right|displaystyle|limits)\b/g, " ");
|
|
2295
|
+
s = s.replace(/\\[,;!\s]|\\quad|\\qquad|~/g, " ");
|
|
2296
|
+
for (let guard = 0; guard < 12; guard++) {
|
|
2297
|
+
let next = s;
|
|
2298
|
+
next = next.replace(/\\[dt]?frac(?![A-Za-z])/g, "\\frac");
|
|
2299
|
+
{
|
|
2300
|
+
let i;
|
|
2301
|
+
while ((i = next.indexOf("\\frac")) >= 0) {
|
|
2302
|
+
const a = readAtom(next, i + 5);
|
|
2303
|
+
const b = readAtom(next, a.end);
|
|
2304
|
+
next = next.slice(0, i) + `${a.body} 分之 ${b.body} ` + next.slice(b.end);
|
|
2305
|
+
}
|
|
2306
|
+
}
|
|
2307
|
+
{
|
|
2308
|
+
let i;
|
|
2309
|
+
while ((i = next.indexOf("\\binom")) >= 0) {
|
|
2310
|
+
const a = readAtom(next, i + 6);
|
|
2311
|
+
const b = readAtom(next, a.end);
|
|
2312
|
+
next = next.slice(0, i) + `${a.body} 取 ${b.body} ` + next.slice(b.end);
|
|
2313
|
+
}
|
|
2314
|
+
}
|
|
2315
|
+
next = next.replace(/\\sqrt\s*\[( {[^{}]*} |[^\s\\]+)\s*\]\s*/g, (_m, n) => `${n.trim()} 次根号 `);
|
|
2316
|
+
{
|
|
2317
|
+
let i;
|
|
2318
|
+
while ((i = next.indexOf("\\sqrt")) >= 0) {
|
|
2319
|
+
const a = readAtom(next, i + 5);
|
|
2320
|
+
next = next.slice(0, i) + `根号 ${a.body} ` + next.slice(a.end);
|
|
2321
|
+
}
|
|
2322
|
+
}
|
|
2323
|
+
next = next.replace(/\\(?:mathbf|mathbb|mathrm|mathit|boldsymbol|text|operatorname)\s*/g, (m) => m.startsWith("\\text") ? "" : " ");
|
|
2324
|
+
if (next === s) {
|
|
2325
|
+
s = next;
|
|
2326
|
+
break;
|
|
2327
|
+
}
|
|
2328
|
+
s = next;
|
|
2329
|
+
}
|
|
2330
|
+
for (let guard = 0; guard < 8; guard++) {
|
|
2331
|
+
const next = s.replace(/(\\frac|[\w)\]}]|\s)\s*\^\s*(\{[^{}]*\}|[^\s])|(\\frac|[\w)\]}]| )\s*_\s*(\{[^{}]*\}|[^\s])/g, (_m, base1, sup, base2, sub) => {
|
|
2332
|
+
if (sup !== void 0) return `${base1} 的 ${sup.replace(/[{}]/g, "")} 次方`;
|
|
2333
|
+
return `${base2} 下标 ${sub.replace(/[{}]/g, "")}`;
|
|
2334
|
+
});
|
|
2335
|
+
if (next === s) break;
|
|
2336
|
+
s = next;
|
|
2337
|
+
}
|
|
2338
|
+
for (const [cmd, zh] of SYMBOLS) s = s.split(cmd).join(` ${zh} `);
|
|
2339
|
+
s = s.replace(/\\([A-Za-z]+)\b/g, (_m, name) => ` ${GREEK[name] ?? name} `);
|
|
2340
|
+
s = s.replace(/[{}]/g, " ");
|
|
2341
|
+
return s.replace(/\s+/g, " ").trim();
|
|
2342
|
+
}
|
|
2343
|
+
/** 句子级入口:把 `$..$`/`$$..$$` 段替换为口语;段外文本逐字节不变。 */
|
|
2344
|
+
function speakMathInSentence(sentence) {
|
|
2345
|
+
return sentence.replace(/\$\$([^$]+)\$\$|\$([^$]+)\$/g, (_m, block, inline) => {
|
|
2346
|
+
const spoken = mathToSpokenZH(block ?? inline ?? "");
|
|
2347
|
+
return spoken ? ` ${spoken} ` : "";
|
|
2348
|
+
});
|
|
2349
|
+
}
|
|
2350
|
+
//#endregion
|
|
2351
|
+
//#region src/client/readaloud.ts
|
|
2352
|
+
const sleep = (ms) => new Promise((resolve) => {
|
|
2353
|
+
setTimeout(resolve, ms);
|
|
2354
|
+
});
|
|
2355
|
+
var ReadAloudController = class {
|
|
2356
|
+
sentences;
|
|
2357
|
+
primary;
|
|
2358
|
+
fallback;
|
|
2359
|
+
notify;
|
|
2360
|
+
index = 0;
|
|
2361
|
+
stopped = false;
|
|
2362
|
+
paused = false;
|
|
2363
|
+
engine = null;
|
|
2364
|
+
degraded = false;
|
|
2365
|
+
run = null;
|
|
2366
|
+
constructor(sentences, primary, fallback, notify) {
|
|
2367
|
+
this.sentences = sentences;
|
|
2368
|
+
this.primary = primary;
|
|
2369
|
+
this.fallback = fallback;
|
|
2370
|
+
this.notify = notify;
|
|
2371
|
+
}
|
|
2372
|
+
emit(state) {
|
|
2373
|
+
this.notify?.({
|
|
2374
|
+
index: this.index,
|
|
2375
|
+
total: this.sentences.length,
|
|
2376
|
+
engine: this.engine,
|
|
2377
|
+
state,
|
|
2378
|
+
degraded: this.degraded
|
|
2379
|
+
});
|
|
2380
|
+
}
|
|
2381
|
+
/** Speak the whole queue; the returned promise settles when done or stopped. */
|
|
2382
|
+
start() {
|
|
2383
|
+
if (this.run !== null) return this.run;
|
|
2384
|
+
this.stopped = false;
|
|
2385
|
+
this.run = (async () => {
|
|
2386
|
+
this.engine = "edge";
|
|
2387
|
+
this.emit("speaking");
|
|
2388
|
+
while (!this.stopped && this.index < this.sentences.length) {
|
|
2389
|
+
const text = this.sentences[this.index];
|
|
2390
|
+
try {
|
|
2391
|
+
await this.speakOn(text);
|
|
2392
|
+
} catch {
|
|
2393
|
+
if (this.stopped) return;
|
|
2394
|
+
if (this.engine === "edge") {
|
|
2395
|
+
this.degraded = true;
|
|
2396
|
+
this.engine = "system";
|
|
2397
|
+
this.emit("speaking");
|
|
2398
|
+
try {
|
|
2399
|
+
await this.speakOn(text);
|
|
2400
|
+
} catch {
|
|
2401
|
+
this.emit("idle");
|
|
2402
|
+
return;
|
|
2403
|
+
}
|
|
2404
|
+
} else {
|
|
2405
|
+
this.emit("idle");
|
|
2406
|
+
return;
|
|
2407
|
+
}
|
|
2408
|
+
}
|
|
2409
|
+
if (this.stopped) return;
|
|
2410
|
+
this.index += 1;
|
|
2411
|
+
this.emit("speaking");
|
|
2412
|
+
}
|
|
2413
|
+
this.emit("idle");
|
|
2414
|
+
})();
|
|
2415
|
+
return this.run;
|
|
2416
|
+
}
|
|
2417
|
+
async speakOn(text) {
|
|
2418
|
+
const impl = this.engine === "system" ? this.fallback : this.primary;
|
|
2419
|
+
const next = this.sentences[this.index + 1];
|
|
2420
|
+
if (next !== void 0) impl.prewarm?.(next);
|
|
2421
|
+
await impl.speak(text);
|
|
2422
|
+
while (this.paused && !this.stopped) await sleep(120);
|
|
2423
|
+
}
|
|
2424
|
+
pause() {
|
|
2425
|
+
if (this.stopped || this.paused) return;
|
|
2426
|
+
this.paused = true;
|
|
2427
|
+
(this.engine === "system" ? this.fallback : this.primary).pause();
|
|
2428
|
+
this.emit("paused");
|
|
2429
|
+
}
|
|
2430
|
+
resume() {
|
|
2431
|
+
if (!this.paused) return;
|
|
2432
|
+
this.paused = false;
|
|
2433
|
+
(this.engine === "system" ? this.fallback : this.primary).resume();
|
|
2434
|
+
this.emit("speaking");
|
|
2435
|
+
}
|
|
2436
|
+
/** Stop everything; the controller is single-use — build a fresh one to replay. */
|
|
2437
|
+
stop() {
|
|
2438
|
+
this.stopped = true;
|
|
2439
|
+
this.paused = false;
|
|
2440
|
+
this.primary.cancel();
|
|
2441
|
+
this.fallback.cancel();
|
|
2442
|
+
this.emit("idle");
|
|
2443
|
+
}
|
|
2444
|
+
get status() {
|
|
2445
|
+
return {
|
|
2446
|
+
index: this.index,
|
|
2447
|
+
total: this.sentences.length,
|
|
2448
|
+
engine: this.engine,
|
|
2449
|
+
state: this.stopped ? "idle" : this.paused ? "paused" : "speaking",
|
|
2450
|
+
degraded: this.degraded
|
|
2451
|
+
};
|
|
2452
|
+
}
|
|
2453
|
+
};
|
|
2454
|
+
//#endregion
|
|
1928
2455
|
//#region src/client/views.tsx
|
|
1929
2456
|
/**
|
|
1930
2457
|
* The study tab: one `conversation.view` entry rendering the whole plugin —
|
|
@@ -2623,11 +3150,108 @@ window.__ModuleLoader__.load({
|
|
|
2623
3150
|
}
|
|
2624
3151
|
}, s.label))) : null, (0, react.createElement)(ActionError, { error }));
|
|
2625
3152
|
}
|
|
3153
|
+
/** Browser speechSynthesis engine — the offline/endpoint-gone fallback voice. */
|
|
3154
|
+
function systemSpeechEngine() {
|
|
3155
|
+
return {
|
|
3156
|
+
speak(text) {
|
|
3157
|
+
return new Promise((resolve, reject) => {
|
|
3158
|
+
const synth = typeof window === "undefined" ? void 0 : window.speechSynthesis;
|
|
3159
|
+
if (synth === void 0) {
|
|
3160
|
+
reject(/* @__PURE__ */ new Error("no speechSynthesis"));
|
|
3161
|
+
return;
|
|
3162
|
+
}
|
|
3163
|
+
const utterance = new SpeechSynthesisUtterance(text);
|
|
3164
|
+
utterance.lang = "zh-CN";
|
|
3165
|
+
utterance.onend = () => resolve();
|
|
3166
|
+
utterance.onerror = () => reject(/* @__PURE__ */ new Error("speechSynthesis failed"));
|
|
3167
|
+
synth.speak(utterance);
|
|
3168
|
+
});
|
|
3169
|
+
},
|
|
3170
|
+
pause() {
|
|
3171
|
+
window.speechSynthesis?.pause();
|
|
3172
|
+
},
|
|
3173
|
+
resume() {
|
|
3174
|
+
window.speechSynthesis?.resume();
|
|
3175
|
+
},
|
|
3176
|
+
cancel() {
|
|
3177
|
+
window.speechSynthesis?.cancel();
|
|
3178
|
+
}
|
|
3179
|
+
};
|
|
3180
|
+
}
|
|
3181
|
+
/** Edge-over-dashboard engine: MP3 from /api/tts played through an <audio>, with per-session prefetch. */
|
|
3182
|
+
function audioSpeechEngine(fetchTts) {
|
|
3183
|
+
let audio = null;
|
|
3184
|
+
const prefetch = /* @__PURE__ */ new Map();
|
|
3185
|
+
const load = (text) => {
|
|
3186
|
+
let pending = prefetch.get(text);
|
|
3187
|
+
if (pending === void 0) {
|
|
3188
|
+
pending = fetchTts(text);
|
|
3189
|
+
pending.catch(() => prefetch.delete(text));
|
|
3190
|
+
prefetch.set(text, pending);
|
|
3191
|
+
}
|
|
3192
|
+
return pending;
|
|
3193
|
+
};
|
|
3194
|
+
return {
|
|
3195
|
+
speak(text) {
|
|
3196
|
+
return load(text).then((buf) => new Promise((resolve, reject) => {
|
|
3197
|
+
const blobUrl = URL.createObjectURL(new Blob([buf], { type: "audio/mpeg" }));
|
|
3198
|
+
audio = new Audio(blobUrl);
|
|
3199
|
+
const done = () => {
|
|
3200
|
+
URL.revokeObjectURL(blobUrl);
|
|
3201
|
+
resolve();
|
|
3202
|
+
};
|
|
3203
|
+
audio.onended = done;
|
|
3204
|
+
audio.onerror = () => {
|
|
3205
|
+
URL.revokeObjectURL(blobUrl);
|
|
3206
|
+
reject(/* @__PURE__ */ new Error("audio playback failed"));
|
|
3207
|
+
};
|
|
3208
|
+
audio.play().catch(reject);
|
|
3209
|
+
}));
|
|
3210
|
+
},
|
|
3211
|
+
pause() {
|
|
3212
|
+
audio?.pause();
|
|
3213
|
+
},
|
|
3214
|
+
resume() {
|
|
3215
|
+
audio?.play();
|
|
3216
|
+
},
|
|
3217
|
+
cancel() {
|
|
3218
|
+
audio?.pause();
|
|
3219
|
+
audio = null;
|
|
3220
|
+
},
|
|
3221
|
+
prewarm(text) {
|
|
3222
|
+
load(text);
|
|
3223
|
+
}
|
|
3224
|
+
};
|
|
3225
|
+
}
|
|
2626
3226
|
/** Right column: the blackboard — focus-lesson 讲解/脑图/概念图 plus the Cornell 笔记. */
|
|
2627
3227
|
function BlackboardColumn({ data, deleteNote }) {
|
|
2628
3228
|
const lesson = data?.lesson ?? null;
|
|
3229
|
+
const { tts } = useStudy();
|
|
2629
3230
|
const [pane, setPane] = (0, react.useState)("teach");
|
|
2630
3231
|
const [error, setError] = (0, react.useState)(null);
|
|
3232
|
+
const [read, setRead] = (0, react.useState)(null);
|
|
3233
|
+
const readCtl = (0, react.useRef)(null);
|
|
3234
|
+
const [readError, setReadError] = (0, react.useState)(null);
|
|
3235
|
+
const startReading = () => {
|
|
3236
|
+
if (lesson === null || lesson.speechText.trim() === "") return;
|
|
3237
|
+
readCtl.current?.stop();
|
|
3238
|
+
const sentences = speechSentencesOf(lesson.speechText);
|
|
3239
|
+
const voice = storedTtsVoice();
|
|
3240
|
+
const controller = new ReadAloudController(sentences.map((s) => speakMathInSentence(s)), audioSpeechEngine((text) => tts(text, voice)), systemSpeechEngine(), (s) => {
|
|
3241
|
+
setRead(s);
|
|
3242
|
+
if (s.degraded && s.engine === "system" && !readError) setReadError(tr("read.engine.system"));
|
|
3243
|
+
});
|
|
3244
|
+
readCtl.current = controller;
|
|
3245
|
+
setReadError(null);
|
|
3246
|
+
controller.start().catch((err) => {
|
|
3247
|
+
setReadError(err instanceof Error ? err.message : String(err));
|
|
3248
|
+
});
|
|
3249
|
+
};
|
|
3250
|
+
const stopReading = () => {
|
|
3251
|
+
readCtl.current?.stop();
|
|
3252
|
+
setRead(null);
|
|
3253
|
+
setReadError(null);
|
|
3254
|
+
};
|
|
2631
3255
|
const [armedNote, setArmedNote] = (0, react.useState)(null);
|
|
2632
3256
|
(0, react.useEffect)(() => {
|
|
2633
3257
|
if (armedNote === null) return;
|
|
@@ -2690,7 +3314,35 @@ window.__ModuleLoader__.load({
|
|
|
2690
3314
|
onClick: () => {
|
|
2691
3315
|
setPane("cmap");
|
|
2692
3316
|
}
|
|
2693
|
-
}, (0, react.createElement)(IconGlobeOutline14, { size: 13 }), tr("viewtab.cmap"))), pane === "teach" ? (0, react.createElement)("div", {
|
|
3317
|
+
}, (0, react.createElement)(IconGlobeOutline14, { size: 13 }), tr("viewtab.cmap"))), pane === "teach" ? (0, react.createElement)("div", { className: "lks-readbar" }, (0, react.createElement)("button", {
|
|
3318
|
+
className: "lks-btn ghost",
|
|
3319
|
+
style: {
|
|
3320
|
+
padding: "3px 8px",
|
|
3321
|
+
fontSize: "12.5px",
|
|
3322
|
+
flex: "none"
|
|
3323
|
+
},
|
|
3324
|
+
title: read !== null && read.state === "speaking" ? tr("read.pause") : tr("read.play"),
|
|
3325
|
+
onClick: () => {
|
|
3326
|
+
if (read !== null && read.state === "speaking") {
|
|
3327
|
+
readCtl.current?.pause();
|
|
3328
|
+
return;
|
|
3329
|
+
}
|
|
3330
|
+
if (read !== null && read.state === "paused") {
|
|
3331
|
+
readCtl.current?.resume();
|
|
3332
|
+
return;
|
|
3333
|
+
}
|
|
3334
|
+
startReading();
|
|
3335
|
+
}
|
|
3336
|
+
}, (0, react.createElement)(IconPlayOutline16, { size: 12 }), read !== null && read.state === "speaking" ? tr("read.pause") : read !== null && read.state === "paused" ? tr("read.resume") : tr("read.play")), read !== null ? (0, react.createElement)("button", {
|
|
3337
|
+
className: "lks-btn ghost",
|
|
3338
|
+
style: {
|
|
3339
|
+
padding: "3px 8px",
|
|
3340
|
+
fontSize: "12.5px",
|
|
3341
|
+
flex: "none"
|
|
3342
|
+
},
|
|
3343
|
+
title: tr("read.stop"),
|
|
3344
|
+
onClick: stopReading
|
|
3345
|
+
}, tr("read.stop")) : null, read !== null && read.state !== "idle" && lesson !== null ? (0, react.createElement)("span", { className: "lks-readbar-cur" }, (speechSentencesOf(lesson.speechText)[read.index] ?? "").slice(0, 80)) : null, readError !== null ? (0, react.createElement)("span", { className: "lks-readbar-notice" }, readError) : null) : null, pane === "teach" ? (0, react.createElement)("div", {
|
|
2694
3346
|
className: "lks-prose",
|
|
2695
3347
|
ref: proseRef,
|
|
2696
3348
|
dangerouslySetInnerHTML: { __html: lesson.html }
|
|
@@ -2837,6 +3489,7 @@ window.__ModuleLoader__.load({
|
|
|
2837
3489
|
function StudySettingsSection() {
|
|
2838
3490
|
const { data, setMode, activate } = useStudy();
|
|
2839
3491
|
const [error, setError] = (0, react.useState)(null);
|
|
3492
|
+
const [voice, setVoice] = (0, react.useState)(storedTtsVoice);
|
|
2840
3493
|
const fire = (action) => {
|
|
2841
3494
|
action.then(() => {
|
|
2842
3495
|
setError(null);
|
|
@@ -2857,7 +3510,17 @@ window.__ModuleLoader__.load({
|
|
|
2857
3510
|
onClick: () => {
|
|
2858
3511
|
if (data !== null) fire(activate(!data.active));
|
|
2859
3512
|
}
|
|
2860
|
-
}, data?.active === true ? tr("settings.turnOff") : tr("settings.turnOn")), (0, react.createElement)("span", { className: "lks-set-state" }, data?.active === true ? tr("settings.on") : tr("settings.off")))),
|
|
3513
|
+
}, data?.active === true ? tr("settings.turnOff") : tr("settings.turnOn")), (0, react.createElement)("span", { className: "lks-set-state" }, data?.active === true ? tr("settings.on") : tr("settings.off")))), (0, react.createElement)("section", { className: "lks-set-row" }, (0, react.createElement)("h3", null, tr("settings.voice")), (0, react.createElement)("p", { className: "lks-set-hint" }, tr("settings.voice.hint")), (0, react.createElement)("div", null, (0, react.createElement)("select", {
|
|
3514
|
+
className: "lks-set-select",
|
|
3515
|
+
value: voice,
|
|
3516
|
+
onChange: (e) => {
|
|
3517
|
+
storeTtsVoice(e.target.value);
|
|
3518
|
+
setVoice(e.target.value);
|
|
3519
|
+
}
|
|
3520
|
+
}, ...TTS_VOICES.map((v) => (0, react.createElement)("option", {
|
|
3521
|
+
key: v.id,
|
|
3522
|
+
value: v.id
|
|
3523
|
+
}, tr(v.labelKey)))))), progress === void 0 ? null : (0, react.createElement)("section", { className: "lks-set-row" }, (0, react.createElement)("h3", null, tr("settings.stats")), (0, react.createElement)("ul", { className: "lks-set-stats" }, (0, react.createElement)("li", null, tr("settings.stats.courses", { count: data?.courses.length ?? 0 })), (0, react.createElement)("li", null, tr("settings.stats.xp", {
|
|
2861
3524
|
xp: progress.totalXp,
|
|
2862
3525
|
level: progress.level,
|
|
2863
3526
|
pct: progress.levelPct
|