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/client.js
CHANGED
|
@@ -235,6 +235,9 @@ window.__ModuleLoader__.load({
|
|
|
235
235
|
.lks-zone-h{font-size:14px;color:var(--dsw-alias-label-secondary);margin:0 0 8px;font-weight:600}
|
|
236
236
|
.lks-note{background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);border-radius:10px;padding:10px 14px;margin-bottom:8px}
|
|
237
237
|
.lks-note .lks-note-src{float:right;font-size:11.5px;color:var(--dsw-alias-label-secondary)}
|
|
238
|
+
.lks-note-del{float:right;clear:right;border:none;background:none;color:var(--dsw-alias-label-tertiary);cursor:pointer;padding:2px;border-radius:5px;line-height:0}
|
|
239
|
+
.lks-note-del:hover{color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-bg-layer-2)}
|
|
240
|
+
.lks-note-del.armed{color:#fff;background:var(--dsw-alias-state-error-primary)}
|
|
238
241
|
.lks-note .lks-note-title{font-weight:600;font-size:13px}
|
|
239
242
|
.lks-note .lks-note-text{margin-top:4px;font-size:13px;color:var(--dsw-alias-label-secondary);line-height:1.65}
|
|
240
243
|
.lks-note .lks-note-text p{margin:4px 0}
|
|
@@ -246,6 +249,12 @@ window.__ModuleLoader__.load({
|
|
|
246
249
|
/* inline error text (write-action failures) */
|
|
247
250
|
.lks-propcard-err{color:var(--dsw-alias-state-error-primary);font-size:12px;margin-top:6px;flex:none}
|
|
248
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
|
+
|
|
249
258
|
/* settings section (settings.section entry inside the host settings shell) */
|
|
250
259
|
.lks-settings{display:flex;flex-direction:column;gap:18px;font-family:var(--dsw-font-family);color:var(--dsw-alias-label-primary)}
|
|
251
260
|
.lks-set-row h3{margin:0 0 4px;font-size:15px;font-weight:600}
|
|
@@ -715,6 +724,35 @@ window.__ModuleLoader__.load({
|
|
|
715
724
|
});
|
|
716
725
|
this.refresh();
|
|
717
726
|
}
|
|
727
|
+
/** Delete one notebook entry from a lesson's Cornell zones. */
|
|
728
|
+
async deleteNote(lessonId, noteId) {
|
|
729
|
+
await fetchJson("/lookatstudy/api/note/delete", {
|
|
730
|
+
method: "POST",
|
|
731
|
+
headers: { "content-type": "application/json" },
|
|
732
|
+
body: JSON.stringify({
|
|
733
|
+
lessonId,
|
|
734
|
+
noteId
|
|
735
|
+
})
|
|
736
|
+
});
|
|
737
|
+
this.refresh();
|
|
738
|
+
}
|
|
739
|
+
/** Synthesize one speakable chunk to MP3 (host-side Edge TTS, cache-first). */
|
|
740
|
+
async tts(text, voice) {
|
|
741
|
+
const body = await fetchJson("/lookatstudy/api/tts", {
|
|
742
|
+
method: "POST",
|
|
743
|
+
headers: { "content-type": "application/json" },
|
|
744
|
+
body: JSON.stringify({
|
|
745
|
+
text,
|
|
746
|
+
voice
|
|
747
|
+
})
|
|
748
|
+
});
|
|
749
|
+
const raw = body.dataBase64;
|
|
750
|
+
if (body.ok !== true || typeof raw !== "string") throw new Error("tts response missing audio payload");
|
|
751
|
+
const bin = atob(raw);
|
|
752
|
+
const bytes = new Uint8Array(bin.length);
|
|
753
|
+
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
|
754
|
+
return bytes.buffer;
|
|
755
|
+
}
|
|
718
756
|
};
|
|
719
757
|
/** The one shared store instance backing every study seat component. */
|
|
720
758
|
const studyStore = new StudyStore();
|
|
@@ -727,6 +765,7 @@ window.__ModuleLoader__.load({
|
|
|
727
765
|
setFocus: studyStore.setFocus.bind(studyStore),
|
|
728
766
|
searchLessons: studyStore.searchLessons.bind(studyStore),
|
|
729
767
|
deleteCourse: studyStore.deleteCourse.bind(studyStore),
|
|
768
|
+
deleteNote: studyStore.deleteNote.bind(studyStore),
|
|
730
769
|
bindLessonSession: studyStore.bindLessonSession.bind(studyStore)
|
|
731
770
|
};
|
|
732
771
|
}
|
|
@@ -1567,6 +1606,14 @@ window.__ModuleLoader__.load({
|
|
|
1567
1606
|
"rail.delete": "删除本课程",
|
|
1568
1607
|
"rail.delete.confirm": "确认删除?",
|
|
1569
1608
|
"rail.delete.title.confirm": "再点一次确认删除(含全部进度与笔记)。反悔前可先在设置页备份状态文件",
|
|
1609
|
+
"note.delete": "删除本条笔记",
|
|
1610
|
+
"note.delete.confirm": "确认删除?",
|
|
1611
|
+
"read.play": "朗读本课",
|
|
1612
|
+
"read.stop": "停止朗读",
|
|
1613
|
+
"read.pause": "暂停朗读",
|
|
1614
|
+
"read.resume": "继续朗读",
|
|
1615
|
+
"read.engine.system": "网络合成不可用,已切换为系统语音",
|
|
1616
|
+
"read.unavailable": "朗读不可用",
|
|
1570
1617
|
"rail.section.collapse": "折叠本章节",
|
|
1571
1618
|
"rail.section.expand": "展开本章节({count} 课时)",
|
|
1572
1619
|
"rail.section.count": "{count} 课",
|
|
@@ -1687,6 +1734,14 @@ window.__ModuleLoader__.load({
|
|
|
1687
1734
|
"rail.delete": "Delete this course",
|
|
1688
1735
|
"rail.delete.confirm": "Delete?",
|
|
1689
1736
|
"rail.delete.title.confirm": "Click again to confirm (all progress and notes included). Back up via the state file in Settings first",
|
|
1737
|
+
"note.delete": "Delete this note",
|
|
1738
|
+
"note.delete.confirm": "Delete?",
|
|
1739
|
+
"read.play": "Read aloud",
|
|
1740
|
+
"read.stop": "Stop reading",
|
|
1741
|
+
"read.pause": "Pause reading",
|
|
1742
|
+
"read.resume": "Resume reading",
|
|
1743
|
+
"read.engine.system": "Network synthesis unavailable — switched to the system voice",
|
|
1744
|
+
"read.unavailable": "Read-aloud unavailable",
|
|
1690
1745
|
"rail.section.collapse": "Collapse this section",
|
|
1691
1746
|
"rail.section.expand": "Expand this section ({count} lessons)",
|
|
1692
1747
|
"rail.section.count": "{count} lessons",
|
|
@@ -1905,6 +1960,433 @@ window.__ModuleLoader__.load({
|
|
|
1905
1960
|
container.append(legend);
|
|
1906
1961
|
}
|
|
1907
1962
|
//#endregion
|
|
1963
|
+
//#region src/vendor/speech-text.ts
|
|
1964
|
+
/**
|
|
1965
|
+
* Vendored from LookatStudy shared/speech-text.ts (MIT License,
|
|
1966
|
+
* https://github.com/Kaiji-Z/LookatStudy), upstream v0.28.0 — includes the
|
|
1967
|
+
* v11.5 whole-display-group avoidance fix era. Unmodified except this
|
|
1968
|
+
* provenance header.
|
|
1969
|
+
*
|
|
1970
|
+
* speech-text —— TTS 朗读文本处理(纯函数,渲染层/主进程共用)。
|
|
1971
|
+
*
|
|
1972
|
+
* normalizeSpeechText: markdown → 可朗读纯文本。代码不读(围栏/行内整体移除),
|
|
1973
|
+
* 链接留文字,强调/标题/列表/引用/表格标记剥离 —— 导师"念的是话,不是版面"。
|
|
1974
|
+
*
|
|
1975
|
+
* splitSentences: 流式友好的切句。每次喂"累积文本"返回完整句 + 余量(幂等,StrictMode 安全);
|
|
1976
|
+
* flush=true 时尾句强制吐出。终止标点:中英句号/叹/问/分号 + 省略号;ASCII '.' 要求后跟
|
|
1977
|
+
* 空白且前字符非数字(3.14 不切)。超过 maxBuffer 仍无终止标点 → 在最后的软标点(、,;: 空白)
|
|
1978
|
+
* 处断开,再无则硬断 —— 保证"导师边生成边念"的流式管线永远不会饿死。
|
|
1979
|
+
*
|
|
1980
|
+
* v11.2 表意分隔:换行与 emoji 也是句界。无标点的段落/列表/短句流文本,
|
|
1981
|
+
* 旧逻辑的强制断句块会在显示层并组吞整段(高亮整段到结尾);现在逐行/逐 emoji
|
|
1982
|
+
* 成句,显示组另加长度上限兜底 —— karaoke 高亮粒度永远可控。
|
|
1983
|
+
*/
|
|
1984
|
+
/** 终止标点(出现即成句,连续终止/右引号并吞进句尾) */
|
|
1985
|
+
const HARD = /* @__PURE__ */ new Set([
|
|
1986
|
+
"。",
|
|
1987
|
+
"!",
|
|
1988
|
+
"?",
|
|
1989
|
+
"!",
|
|
1990
|
+
"?",
|
|
1991
|
+
";",
|
|
1992
|
+
";",
|
|
1993
|
+
"…"
|
|
1994
|
+
]);
|
|
1995
|
+
/** 软标点(超长兜底断句位) */
|
|
1996
|
+
const SOFT = /* @__PURE__ */ new Set([
|
|
1997
|
+
",",
|
|
1998
|
+
",",
|
|
1999
|
+
"、",
|
|
2000
|
+
":",
|
|
2001
|
+
":",
|
|
2002
|
+
" ",
|
|
2003
|
+
" "
|
|
2004
|
+
]);
|
|
2005
|
+
/** v11.2 emoji 基字符范围(表意/象形/杂项符号/箭头符号区);修饰符(VS16/ZWJ/肤色)不单独成界 */
|
|
2006
|
+
const EMOJI_CP_RE = /[\u{1F000}-\u{1FAFF}\u{2600}-\u{27BF}\u{2B00}-\u{2BFF}]/u;
|
|
2007
|
+
function isEmojiCp(cp) {
|
|
2008
|
+
return cp != null && EMOJI_CP_RE.test(String.fromCodePoint(cp));
|
|
2009
|
+
}
|
|
2010
|
+
/** 句尾可并吞的右闭合符 */
|
|
2011
|
+
const CLOSERS = /* @__PURE__ */ new Set([
|
|
2012
|
+
"”",
|
|
2013
|
+
"\"",
|
|
2014
|
+
"」",
|
|
2015
|
+
"』",
|
|
2016
|
+
"》",
|
|
2017
|
+
")",
|
|
2018
|
+
"】",
|
|
2019
|
+
"])".slice(0, 1)
|
|
2020
|
+
]);
|
|
2021
|
+
function normalizeSpeechText(md) {
|
|
2022
|
+
let s = md;
|
|
2023
|
+
s = s.replace(/\r\n?/g, "\n");
|
|
2024
|
+
s = s.replace(/(?:^|\n)[ \t]*(?:```|~~~)[^\n]*\n[\s\S]*?(?:\n[ \t]*(?:```|~~~)[^\n]*|\n?$)/g, "\n");
|
|
2025
|
+
s = s.replace(/`[^`\n]*`/g, "");
|
|
2026
|
+
s = s.replace(/!\[[^\]]*\]\([^)\n]*\)/g, "");
|
|
2027
|
+
s = s.replace(/\[([^\]]+)\]\([^)\n]*\)/g, "$1");
|
|
2028
|
+
s = s.replace(/^[ \t]{0,3}#{1,6}[ \t]+/gm, "");
|
|
2029
|
+
s = s.replace(/^[ \t]*(?:[-*+]|\d+\.)[ \t]+/gm, "");
|
|
2030
|
+
s = s.replace(/^[ \t]*>[ \t]?/gm, "");
|
|
2031
|
+
s = s.replace(/\|/g, " ");
|
|
2032
|
+
s = s.replace(/^[ \t]*[-: ]{3,}[ \t]*$/gm, "");
|
|
2033
|
+
s = s.replace(/(\*\*|__)(.*?)\1/g, "$2");
|
|
2034
|
+
s = s.replace(/(?<![*\w])(\*|_)(?!\s)(.+?)(?<!\s)\1(?![*\w])/g, "$2");
|
|
2035
|
+
s = s.replace(/~~(.+?)~~/g, "$1");
|
|
2036
|
+
s = s.replace(/\n{3,}/g, "\n\n");
|
|
2037
|
+
return s.trim();
|
|
2038
|
+
}
|
|
2039
|
+
/**
|
|
2040
|
+
* v11.2 朗读句表单一入口(v11.4 起仅合成侧使用):tts-service 用它切段;
|
|
2041
|
+
* 渲染层 karaoke **不再调用**(改吃 ttsAudio.sentence 权威原文,见
|
|
2042
|
+
* playedSentencePrefix)——句表从合成侧单向流出,显示侧零复算零分叉。
|
|
2043
|
+
*/
|
|
2044
|
+
function speechSentencesOf(text) {
|
|
2045
|
+
return splitSentences(normalizeSpeechText(text), { flush: true }).sentences;
|
|
2046
|
+
}
|
|
2047
|
+
function splitSentences(text, opts = {}) {
|
|
2048
|
+
const maxBuffer = Math.max(8, opts.maxBuffer ?? 120);
|
|
2049
|
+
const flush = opts.flush ?? false;
|
|
2050
|
+
const out = [];
|
|
2051
|
+
const n = text.length;
|
|
2052
|
+
let start = 0;
|
|
2053
|
+
let i = 0;
|
|
2054
|
+
let lastSoft = -1;
|
|
2055
|
+
const emit = (end) => {
|
|
2056
|
+
const piece = text.slice(start, end).trim();
|
|
2057
|
+
if (piece) out.push(piece);
|
|
2058
|
+
start = end;
|
|
2059
|
+
lastSoft = -1;
|
|
2060
|
+
};
|
|
2061
|
+
while (i < n) {
|
|
2062
|
+
const ch = text[i];
|
|
2063
|
+
const cp = text.codePointAt(i);
|
|
2064
|
+
if (isEmojiCp(cp)) {
|
|
2065
|
+
let j = i + (cp > 65535 ? 2 : 1);
|
|
2066
|
+
while (j < n) {
|
|
2067
|
+
const cj = text.codePointAt(j);
|
|
2068
|
+
const ul = cj > 65535 ? 2 : 1;
|
|
2069
|
+
if (cj === 65039 || cj === 8205 || cj >= 127995 && cj <= 127999 || isEmojiCp(cj)) {
|
|
2070
|
+
j += ul;
|
|
2071
|
+
continue;
|
|
2072
|
+
}
|
|
2073
|
+
break;
|
|
2074
|
+
}
|
|
2075
|
+
emit(j);
|
|
2076
|
+
i = j;
|
|
2077
|
+
continue;
|
|
2078
|
+
}
|
|
2079
|
+
if (ch === "\n") {
|
|
2080
|
+
const piece = text.slice(start, i + 1).replace(/[^\S\n]+$/, "");
|
|
2081
|
+
if (piece.trim()) out.push(piece);
|
|
2082
|
+
start = i + 1;
|
|
2083
|
+
lastSoft = -1;
|
|
2084
|
+
i++;
|
|
2085
|
+
continue;
|
|
2086
|
+
}
|
|
2087
|
+
if (HARD.has(ch)) {
|
|
2088
|
+
let j = i + 1;
|
|
2089
|
+
while (j < n && (HARD.has(text[j]) || CLOSERS.has(text[j]))) j++;
|
|
2090
|
+
emit(j);
|
|
2091
|
+
i = j;
|
|
2092
|
+
continue;
|
|
2093
|
+
}
|
|
2094
|
+
if (ch === ".") {
|
|
2095
|
+
const prev = i > 0 ? text[i - 1] : "";
|
|
2096
|
+
const next = i + 1 < n ? text[i + 1] : "";
|
|
2097
|
+
if ((next === "" || /\s/.test(next)) && !/\d/.test(prev)) {
|
|
2098
|
+
let j = i + 1;
|
|
2099
|
+
while (j < n && (CLOSERS.has(text[j]) || HARD.has(text[j]))) j++;
|
|
2100
|
+
emit(j);
|
|
2101
|
+
i = j;
|
|
2102
|
+
continue;
|
|
2103
|
+
}
|
|
2104
|
+
}
|
|
2105
|
+
if (SOFT.has(ch)) lastSoft = i;
|
|
2106
|
+
if (i - start + 1 > maxBuffer) {
|
|
2107
|
+
if (lastSoft > start) {
|
|
2108
|
+
const cut = lastSoft;
|
|
2109
|
+
emit(cut);
|
|
2110
|
+
start = cut + 1;
|
|
2111
|
+
} else {
|
|
2112
|
+
emit(i + 1);
|
|
2113
|
+
i++;
|
|
2114
|
+
continue;
|
|
2115
|
+
}
|
|
2116
|
+
}
|
|
2117
|
+
i++;
|
|
2118
|
+
}
|
|
2119
|
+
const rest = text.slice(start).trim();
|
|
2120
|
+
if (flush && rest) out.push(rest);
|
|
2121
|
+
return {
|
|
2122
|
+
sentences: out,
|
|
2123
|
+
rest: flush ? "" : rest
|
|
2124
|
+
};
|
|
2125
|
+
}
|
|
2126
|
+
//#endregion
|
|
2127
|
+
//#region src/vendor/math-speech.ts
|
|
2128
|
+
/**
|
|
2129
|
+
* Vendored from LookatStudy shared/math-speech.ts (MIT License,
|
|
2130
|
+
* https://github.com/Kaiji-Z/LookatStudy), upstream v0.28.0. Unmodified
|
|
2131
|
+
* except this provenance header. zh-only by design upstream — the plugin's
|
|
2132
|
+
* read-aloud pipeline applies it for zh and strips $..$ delimiters for other
|
|
2133
|
+
* locales (conversion for more languages is future work).
|
|
2134
|
+
*
|
|
2135
|
+
* math-speech —— LaTeX 公式 → 中文口语(v0.19 朗读口语化,纯函数,verify 直测)。
|
|
2136
|
+
*
|
|
2137
|
+
* 只在**合成侧**应用(真正念出来的文本);karaoke 高亮/匹配层继续吃 `$..$` 原文
|
|
2138
|
+
* (DOM 侧 getTextModel 收 KaTeX annotation 的 TeX 源,两侧在 canonical 空间对齐)
|
|
2139
|
+
* ——念的是人话,亮的是原文,互不干扰。
|
|
2140
|
+
*
|
|
2141
|
+
* 规则表起步:常见记号(分式/根号/上下标/希腊字母/关系与算符)覆盖优先,
|
|
2142
|
+
* 未覆盖宏退化为逐字母(好于念"反斜杠 f-r-a-c")。
|
|
2143
|
+
*/
|
|
2144
|
+
/** 单符号命令 → 中文(长命令先试,避免 \le 被 \leq 截断)。 */
|
|
2145
|
+
const SYMBOLS = [
|
|
2146
|
+
["\\leq", "小于等于"],
|
|
2147
|
+
["\\le", "小于等于"],
|
|
2148
|
+
["\\geq", "大于等于"],
|
|
2149
|
+
["\\ge", "大于等于"],
|
|
2150
|
+
["\\neq", "不等于"],
|
|
2151
|
+
["\\ne", "不等于"],
|
|
2152
|
+
["\\approx", "约等于"],
|
|
2153
|
+
["\\equiv", "恒等于"],
|
|
2154
|
+
["\\pm", "正负"],
|
|
2155
|
+
["\\times", "乘以"],
|
|
2156
|
+
["\\cdot", "点乘"],
|
|
2157
|
+
["\\div", "除以"],
|
|
2158
|
+
["\\to", "趋于"],
|
|
2159
|
+
["\\rightarrow", "趋于"],
|
|
2160
|
+
["\\Rightarrow", "推出"],
|
|
2161
|
+
["\\infty", "无穷"],
|
|
2162
|
+
["\\sum", "求和"],
|
|
2163
|
+
["\\prod", "连乘"],
|
|
2164
|
+
["\\int", "积分"],
|
|
2165
|
+
["\\lim", "极限"],
|
|
2166
|
+
["\\log", "对数"],
|
|
2167
|
+
["\\ln", "自然对数"],
|
|
2168
|
+
["\\exp", "指数"],
|
|
2169
|
+
["\\in", "属于"],
|
|
2170
|
+
["\\subset", "包含于"],
|
|
2171
|
+
["\\cup", "并"],
|
|
2172
|
+
["\\cap", "交"],
|
|
2173
|
+
["\\forall", "任意"],
|
|
2174
|
+
["\\exists", "存在"],
|
|
2175
|
+
["\\partial", "偏导"],
|
|
2176
|
+
["\\nabla", "梯度"],
|
|
2177
|
+
["\\angle", "角"],
|
|
2178
|
+
["\\degree", "度"],
|
|
2179
|
+
["\\cdots", "点点点"],
|
|
2180
|
+
["\\ldots", "点点点"]
|
|
2181
|
+
];
|
|
2182
|
+
const GREEK = {
|
|
2183
|
+
alpha: "阿尔法",
|
|
2184
|
+
beta: "贝塔",
|
|
2185
|
+
gamma: "伽马",
|
|
2186
|
+
delta: "德尔塔",
|
|
2187
|
+
epsilon: "艾普西隆",
|
|
2188
|
+
zeta: "泽塔",
|
|
2189
|
+
eta: "伊塔",
|
|
2190
|
+
theta: "西塔",
|
|
2191
|
+
iota: "约塔",
|
|
2192
|
+
kappa: "卡帕",
|
|
2193
|
+
lambda: "拉姆达",
|
|
2194
|
+
mu: "缪",
|
|
2195
|
+
nu: "纽",
|
|
2196
|
+
xi: "克西",
|
|
2197
|
+
pi: "派",
|
|
2198
|
+
rho: "柔",
|
|
2199
|
+
sigma: "西格马",
|
|
2200
|
+
tau: "陶",
|
|
2201
|
+
phi: "斐",
|
|
2202
|
+
chi: "凯",
|
|
2203
|
+
psi: "普西",
|
|
2204
|
+
omega: "欧米伽"
|
|
2205
|
+
};
|
|
2206
|
+
/** 读取 `{...}` 花括号组(从 openIdx 的下一个字符起,返回内容与结束下标)。 */
|
|
2207
|
+
function readGroup(s, i) {
|
|
2208
|
+
if (s[i] !== "{") return null;
|
|
2209
|
+
let depth = 0;
|
|
2210
|
+
for (let j = i; j < s.length; j++) if (s[j] === "{") depth++;
|
|
2211
|
+
else if (s[j] === "}") {
|
|
2212
|
+
depth--;
|
|
2213
|
+
if (depth === 0) return {
|
|
2214
|
+
body: s.slice(i + 1, j),
|
|
2215
|
+
end: j + 1
|
|
2216
|
+
};
|
|
2217
|
+
}
|
|
2218
|
+
return null;
|
|
2219
|
+
}
|
|
2220
|
+
/** 单 token(花括号组或单字符)读取:返回 {body, end}。 */
|
|
2221
|
+
function readAtom(s, i) {
|
|
2222
|
+
const g = readGroup(s, i);
|
|
2223
|
+
if (g) return g;
|
|
2224
|
+
return {
|
|
2225
|
+
body: s[i] ?? "",
|
|
2226
|
+
end: i + 1
|
|
2227
|
+
};
|
|
2228
|
+
}
|
|
2229
|
+
/** LaTeX 片段 → 中文口语(递归:命令参数体内再走一遍)。 */
|
|
2230
|
+
function mathToSpokenZH(tex) {
|
|
2231
|
+
let s = tex;
|
|
2232
|
+
s = s.replace(/\\(?:left|right|displaystyle|limits)\b/g, " ");
|
|
2233
|
+
s = s.replace(/\\[,;!\s]|\\quad|\\qquad|~/g, " ");
|
|
2234
|
+
for (let guard = 0; guard < 12; guard++) {
|
|
2235
|
+
let next = s;
|
|
2236
|
+
next = next.replace(/\\[dt]?frac(?![A-Za-z])/g, "\\frac");
|
|
2237
|
+
{
|
|
2238
|
+
let i;
|
|
2239
|
+
while ((i = next.indexOf("\\frac")) >= 0) {
|
|
2240
|
+
const a = readAtom(next, i + 5);
|
|
2241
|
+
const b = readAtom(next, a.end);
|
|
2242
|
+
next = next.slice(0, i) + `${a.body} 分之 ${b.body} ` + next.slice(b.end);
|
|
2243
|
+
}
|
|
2244
|
+
}
|
|
2245
|
+
{
|
|
2246
|
+
let i;
|
|
2247
|
+
while ((i = next.indexOf("\\binom")) >= 0) {
|
|
2248
|
+
const a = readAtom(next, i + 6);
|
|
2249
|
+
const b = readAtom(next, a.end);
|
|
2250
|
+
next = next.slice(0, i) + `${a.body} 取 ${b.body} ` + next.slice(b.end);
|
|
2251
|
+
}
|
|
2252
|
+
}
|
|
2253
|
+
next = next.replace(/\\sqrt\s*\[( {[^{}]*} |[^\s\\]+)\s*\]\s*/g, (_m, n) => `${n.trim()} 次根号 `);
|
|
2254
|
+
{
|
|
2255
|
+
let i;
|
|
2256
|
+
while ((i = next.indexOf("\\sqrt")) >= 0) {
|
|
2257
|
+
const a = readAtom(next, i + 5);
|
|
2258
|
+
next = next.slice(0, i) + `根号 ${a.body} ` + next.slice(a.end);
|
|
2259
|
+
}
|
|
2260
|
+
}
|
|
2261
|
+
next = next.replace(/\\(?:mathbf|mathbb|mathrm|mathit|boldsymbol|text|operatorname)\s*/g, (m) => m.startsWith("\\text") ? "" : " ");
|
|
2262
|
+
if (next === s) {
|
|
2263
|
+
s = next;
|
|
2264
|
+
break;
|
|
2265
|
+
}
|
|
2266
|
+
s = next;
|
|
2267
|
+
}
|
|
2268
|
+
for (let guard = 0; guard < 8; guard++) {
|
|
2269
|
+
const next = s.replace(/(\\frac|[\w)\]}]|\s)\s*\^\s*(\{[^{}]*\}|[^\s])|(\\frac|[\w)\]}]| )\s*_\s*(\{[^{}]*\}|[^\s])/g, (_m, base1, sup, base2, sub) => {
|
|
2270
|
+
if (sup !== void 0) return `${base1} 的 ${sup.replace(/[{}]/g, "")} 次方`;
|
|
2271
|
+
return `${base2} 下标 ${sub.replace(/[{}]/g, "")}`;
|
|
2272
|
+
});
|
|
2273
|
+
if (next === s) break;
|
|
2274
|
+
s = next;
|
|
2275
|
+
}
|
|
2276
|
+
for (const [cmd, zh] of SYMBOLS) s = s.split(cmd).join(` ${zh} `);
|
|
2277
|
+
s = s.replace(/\\([A-Za-z]+)\b/g, (_m, name) => ` ${GREEK[name] ?? name} `);
|
|
2278
|
+
s = s.replace(/[{}]/g, " ");
|
|
2279
|
+
return s.replace(/\s+/g, " ").trim();
|
|
2280
|
+
}
|
|
2281
|
+
/** 句子级入口:把 `$..$`/`$$..$$` 段替换为口语;段外文本逐字节不变。 */
|
|
2282
|
+
function speakMathInSentence(sentence) {
|
|
2283
|
+
return sentence.replace(/\$\$([^$]+)\$\$|\$([^$]+)\$/g, (_m, block, inline) => {
|
|
2284
|
+
const spoken = mathToSpokenZH(block ?? inline ?? "");
|
|
2285
|
+
return spoken ? ` ${spoken} ` : "";
|
|
2286
|
+
});
|
|
2287
|
+
}
|
|
2288
|
+
//#endregion
|
|
2289
|
+
//#region src/client/readaloud.ts
|
|
2290
|
+
const sleep = (ms) => new Promise((resolve) => {
|
|
2291
|
+
setTimeout(resolve, ms);
|
|
2292
|
+
});
|
|
2293
|
+
var ReadAloudController = class {
|
|
2294
|
+
sentences;
|
|
2295
|
+
primary;
|
|
2296
|
+
fallback;
|
|
2297
|
+
notify;
|
|
2298
|
+
index = 0;
|
|
2299
|
+
stopped = false;
|
|
2300
|
+
paused = false;
|
|
2301
|
+
engine = null;
|
|
2302
|
+
degraded = false;
|
|
2303
|
+
run = null;
|
|
2304
|
+
constructor(sentences, primary, fallback, notify) {
|
|
2305
|
+
this.sentences = sentences;
|
|
2306
|
+
this.primary = primary;
|
|
2307
|
+
this.fallback = fallback;
|
|
2308
|
+
this.notify = notify;
|
|
2309
|
+
}
|
|
2310
|
+
emit(state) {
|
|
2311
|
+
this.notify?.({
|
|
2312
|
+
index: this.index,
|
|
2313
|
+
total: this.sentences.length,
|
|
2314
|
+
engine: this.engine,
|
|
2315
|
+
state,
|
|
2316
|
+
degraded: this.degraded
|
|
2317
|
+
});
|
|
2318
|
+
}
|
|
2319
|
+
/** Speak the whole queue; the returned promise settles when done or stopped. */
|
|
2320
|
+
start() {
|
|
2321
|
+
if (this.run !== null) return this.run;
|
|
2322
|
+
this.stopped = false;
|
|
2323
|
+
this.run = (async () => {
|
|
2324
|
+
this.engine = "edge";
|
|
2325
|
+
this.emit("speaking");
|
|
2326
|
+
while (!this.stopped && this.index < this.sentences.length) {
|
|
2327
|
+
const text = this.sentences[this.index];
|
|
2328
|
+
try {
|
|
2329
|
+
await this.speakOn(text);
|
|
2330
|
+
} catch {
|
|
2331
|
+
if (this.stopped) return;
|
|
2332
|
+
if (this.engine === "edge") {
|
|
2333
|
+
this.degraded = true;
|
|
2334
|
+
this.engine = "system";
|
|
2335
|
+
this.emit("speaking");
|
|
2336
|
+
try {
|
|
2337
|
+
await this.speakOn(text);
|
|
2338
|
+
} catch {
|
|
2339
|
+
this.emit("idle");
|
|
2340
|
+
return;
|
|
2341
|
+
}
|
|
2342
|
+
} else {
|
|
2343
|
+
this.emit("idle");
|
|
2344
|
+
return;
|
|
2345
|
+
}
|
|
2346
|
+
}
|
|
2347
|
+
if (this.stopped) return;
|
|
2348
|
+
this.index += 1;
|
|
2349
|
+
this.emit("speaking");
|
|
2350
|
+
}
|
|
2351
|
+
this.emit("idle");
|
|
2352
|
+
})();
|
|
2353
|
+
return this.run;
|
|
2354
|
+
}
|
|
2355
|
+
async speakOn(text) {
|
|
2356
|
+
await (this.engine === "system" ? this.fallback : this.primary).speak(text);
|
|
2357
|
+
while (this.paused && !this.stopped) await sleep(120);
|
|
2358
|
+
}
|
|
2359
|
+
pause() {
|
|
2360
|
+
if (this.stopped || this.paused) return;
|
|
2361
|
+
this.paused = true;
|
|
2362
|
+
(this.engine === "system" ? this.fallback : this.primary).pause();
|
|
2363
|
+
this.emit("paused");
|
|
2364
|
+
}
|
|
2365
|
+
resume() {
|
|
2366
|
+
if (!this.paused) return;
|
|
2367
|
+
this.paused = false;
|
|
2368
|
+
(this.engine === "system" ? this.fallback : this.primary).resume();
|
|
2369
|
+
this.emit("speaking");
|
|
2370
|
+
}
|
|
2371
|
+
/** Stop everything; the controller is single-use — build a fresh one to replay. */
|
|
2372
|
+
stop() {
|
|
2373
|
+
this.stopped = true;
|
|
2374
|
+
this.paused = false;
|
|
2375
|
+
this.primary.cancel();
|
|
2376
|
+
this.fallback.cancel();
|
|
2377
|
+
this.emit("idle");
|
|
2378
|
+
}
|
|
2379
|
+
get status() {
|
|
2380
|
+
return {
|
|
2381
|
+
index: this.index,
|
|
2382
|
+
total: this.sentences.length,
|
|
2383
|
+
engine: this.engine,
|
|
2384
|
+
state: this.stopped ? "idle" : this.paused ? "paused" : "speaking",
|
|
2385
|
+
degraded: this.degraded
|
|
2386
|
+
};
|
|
2387
|
+
}
|
|
2388
|
+
};
|
|
2389
|
+
//#endregion
|
|
1908
2390
|
//#region src/client/views.tsx
|
|
1909
2391
|
/**
|
|
1910
2392
|
* The study tab: one `conversation.view` entry rendering the whole plugin —
|
|
@@ -2149,7 +2631,7 @@ window.__ModuleLoader__.load({
|
|
|
2149
2631
|
}
|
|
2150
2632
|
/** Tab body: the factory-bound ctx carries workspaces/sessions for the per-lesson session jumps. */
|
|
2151
2633
|
function StudyTab({ inputActions, ctx, ...standard }) {
|
|
2152
|
-
const { data, activate, setMode, setFocus, searchLessons, deleteCourse, bindLessonSession } = useStudy();
|
|
2634
|
+
const { data, activate, setMode, setFocus, searchLessons, deleteCourse, deleteNote, bindLessonSession } = useStudy();
|
|
2153
2635
|
const chatLegacy = standard.useChat?.((s) => s.legacy);
|
|
2154
2636
|
const sessionSnapshot = standard.useSession?.((s) => s);
|
|
2155
2637
|
const snapshot = pickTranscript(chatLegacy, sessionSnapshot);
|
|
@@ -2257,7 +2739,10 @@ window.__ModuleLoader__.load({
|
|
|
2257
2739
|
setMode,
|
|
2258
2740
|
send,
|
|
2259
2741
|
snapshot
|
|
2260
|
-
}), (0, react.createElement)(BlackboardColumn, {
|
|
2742
|
+
}), (0, react.createElement)(BlackboardColumn, {
|
|
2743
|
+
data,
|
|
2744
|
+
deleteNote
|
|
2745
|
+
})));
|
|
2261
2746
|
}
|
|
2262
2747
|
/** Left column: course management (pick/delete/search/import), lesson tree, due box. */
|
|
2263
2748
|
function CourseRail({ data, activate, setFocus, searchLessons, deleteCourse, bindLessonSession, send, ctx, currentSessionId }) {
|
|
@@ -2600,10 +3085,108 @@ window.__ModuleLoader__.load({
|
|
|
2600
3085
|
}
|
|
2601
3086
|
}, s.label))) : null, (0, react.createElement)(ActionError, { error }));
|
|
2602
3087
|
}
|
|
3088
|
+
/** Browser speechSynthesis engine — the offline/endpoint-gone fallback voice. */
|
|
3089
|
+
function systemSpeechEngine() {
|
|
3090
|
+
return {
|
|
3091
|
+
speak(text) {
|
|
3092
|
+
return new Promise((resolve, reject) => {
|
|
3093
|
+
const synth = typeof window === "undefined" ? void 0 : window.speechSynthesis;
|
|
3094
|
+
if (synth === void 0) {
|
|
3095
|
+
reject(/* @__PURE__ */ new Error("no speechSynthesis"));
|
|
3096
|
+
return;
|
|
3097
|
+
}
|
|
3098
|
+
const utterance = new SpeechSynthesisUtterance(text);
|
|
3099
|
+
utterance.lang = "zh-CN";
|
|
3100
|
+
utterance.onend = () => resolve();
|
|
3101
|
+
utterance.onerror = () => reject(/* @__PURE__ */ new Error("speechSynthesis failed"));
|
|
3102
|
+
synth.speak(utterance);
|
|
3103
|
+
});
|
|
3104
|
+
},
|
|
3105
|
+
pause() {
|
|
3106
|
+
window.speechSynthesis?.pause();
|
|
3107
|
+
},
|
|
3108
|
+
resume() {
|
|
3109
|
+
window.speechSynthesis?.resume();
|
|
3110
|
+
},
|
|
3111
|
+
cancel() {
|
|
3112
|
+
window.speechSynthesis?.cancel();
|
|
3113
|
+
}
|
|
3114
|
+
};
|
|
3115
|
+
}
|
|
3116
|
+
/** Edge-over-dashboard engine: MP3 from /api/tts played through an <audio>. */
|
|
3117
|
+
function audioSpeechEngine(fetchTts) {
|
|
3118
|
+
let audio = null;
|
|
3119
|
+
return {
|
|
3120
|
+
speak(text) {
|
|
3121
|
+
return fetchTts(text).then((buf) => new Promise((resolve, reject) => {
|
|
3122
|
+
audio = new Audio(URL.createObjectURL(new Blob([buf], { type: "audio/mpeg" })));
|
|
3123
|
+
audio.onended = () => resolve();
|
|
3124
|
+
audio.onerror = () => reject(/* @__PURE__ */ new Error("audio playback failed"));
|
|
3125
|
+
audio.play().catch(reject);
|
|
3126
|
+
}));
|
|
3127
|
+
},
|
|
3128
|
+
pause() {
|
|
3129
|
+
audio?.pause();
|
|
3130
|
+
},
|
|
3131
|
+
resume() {
|
|
3132
|
+
audio?.play();
|
|
3133
|
+
},
|
|
3134
|
+
cancel() {
|
|
3135
|
+
audio?.pause();
|
|
3136
|
+
audio = null;
|
|
3137
|
+
}
|
|
3138
|
+
};
|
|
3139
|
+
}
|
|
2603
3140
|
/** Right column: the blackboard — focus-lesson 讲解/脑图/概念图 plus the Cornell 笔记. */
|
|
2604
|
-
function BlackboardColumn({ data }) {
|
|
3141
|
+
function BlackboardColumn({ data, deleteNote }) {
|
|
2605
3142
|
const lesson = data?.lesson ?? null;
|
|
3143
|
+
const { tts } = useStudy();
|
|
2606
3144
|
const [pane, setPane] = (0, react.useState)("teach");
|
|
3145
|
+
const [error, setError] = (0, react.useState)(null);
|
|
3146
|
+
const [read, setRead] = (0, react.useState)(null);
|
|
3147
|
+
const readCtl = (0, react.useRef)(null);
|
|
3148
|
+
const [readError, setReadError] = (0, react.useState)(null);
|
|
3149
|
+
const startReading = () => {
|
|
3150
|
+
if (lesson === null || lesson.speechText.trim() === "") return;
|
|
3151
|
+
readCtl.current?.stop();
|
|
3152
|
+
const controller = new ReadAloudController(speechSentencesOf(lesson.speechText).map((s) => speakMathInSentence(s)), audioSpeechEngine((text) => tts(text)), systemSpeechEngine(), (s) => {
|
|
3153
|
+
setRead(s);
|
|
3154
|
+
if (s.degraded && s.engine === "system" && !readError) setReadError(tr("read.engine.system"));
|
|
3155
|
+
});
|
|
3156
|
+
readCtl.current = controller;
|
|
3157
|
+
setReadError(null);
|
|
3158
|
+
controller.start().catch((err) => {
|
|
3159
|
+
setReadError(err instanceof Error ? err.message : String(err));
|
|
3160
|
+
});
|
|
3161
|
+
};
|
|
3162
|
+
const stopReading = () => {
|
|
3163
|
+
readCtl.current?.stop();
|
|
3164
|
+
setRead(null);
|
|
3165
|
+
setReadError(null);
|
|
3166
|
+
};
|
|
3167
|
+
const [armedNote, setArmedNote] = (0, react.useState)(null);
|
|
3168
|
+
(0, react.useEffect)(() => {
|
|
3169
|
+
if (armedNote === null) return;
|
|
3170
|
+
const disarm = () => {
|
|
3171
|
+
setArmedNote(null);
|
|
3172
|
+
};
|
|
3173
|
+
const onKey = (e) => {
|
|
3174
|
+
if (e.key === "Escape") disarm();
|
|
3175
|
+
};
|
|
3176
|
+
window.addEventListener("click", disarm);
|
|
3177
|
+
window.addEventListener("keydown", onKey);
|
|
3178
|
+
return () => {
|
|
3179
|
+
window.removeEventListener("click", disarm);
|
|
3180
|
+
window.removeEventListener("keydown", onKey);
|
|
3181
|
+
};
|
|
3182
|
+
}, [armedNote]);
|
|
3183
|
+
const fire = (action) => {
|
|
3184
|
+
action.then(() => {
|
|
3185
|
+
setError(null);
|
|
3186
|
+
}, (err) => {
|
|
3187
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
3188
|
+
});
|
|
3189
|
+
};
|
|
2607
3190
|
const proseRef = (0, react.useRef)(null);
|
|
2608
3191
|
const diagRef = (0, react.useRef)(null);
|
|
2609
3192
|
(0, react.useEffect)(() => {
|
|
@@ -2643,7 +3226,35 @@ window.__ModuleLoader__.load({
|
|
|
2643
3226
|
onClick: () => {
|
|
2644
3227
|
setPane("cmap");
|
|
2645
3228
|
}
|
|
2646
|
-
}, (0, react.createElement)(IconGlobeOutline14, { size: 13 }), tr("viewtab.cmap"))), pane === "teach" ? (0, react.createElement)("div", {
|
|
3229
|
+
}, (0, react.createElement)(IconGlobeOutline14, { size: 13 }), tr("viewtab.cmap"))), pane === "teach" ? (0, react.createElement)("div", { className: "lks-readbar" }, (0, react.createElement)("button", {
|
|
3230
|
+
className: "lks-btn ghost",
|
|
3231
|
+
style: {
|
|
3232
|
+
padding: "3px 8px",
|
|
3233
|
+
fontSize: "12.5px",
|
|
3234
|
+
flex: "none"
|
|
3235
|
+
},
|
|
3236
|
+
title: read !== null && read.state === "speaking" ? tr("read.pause") : tr("read.play"),
|
|
3237
|
+
onClick: () => {
|
|
3238
|
+
if (read !== null && read.state === "speaking") {
|
|
3239
|
+
readCtl.current?.pause();
|
|
3240
|
+
return;
|
|
3241
|
+
}
|
|
3242
|
+
if (read !== null && read.state === "paused") {
|
|
3243
|
+
readCtl.current?.resume();
|
|
3244
|
+
return;
|
|
3245
|
+
}
|
|
3246
|
+
startReading();
|
|
3247
|
+
}
|
|
3248
|
+
}, (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", {
|
|
3249
|
+
className: "lks-btn ghost",
|
|
3250
|
+
style: {
|
|
3251
|
+
padding: "3px 8px",
|
|
3252
|
+
fontSize: "12.5px",
|
|
3253
|
+
flex: "none"
|
|
3254
|
+
},
|
|
3255
|
+
title: tr("read.stop"),
|
|
3256
|
+
onClick: stopReading
|
|
3257
|
+
}, 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", {
|
|
2647
3258
|
className: "lks-prose",
|
|
2648
3259
|
ref: proseRef,
|
|
2649
3260
|
dangerouslySetInnerHTML: { __html: lesson.html }
|
|
@@ -2659,11 +3270,24 @@ window.__ModuleLoader__.load({
|
|
|
2659
3270
|
}, (0, react.createElement)("div", { className: "lks-zone-h" }, tr(labelKey)), ...lesson.notes.filter((n) => n.zone === zone).map((n) => (0, react.createElement)("div", {
|
|
2660
3271
|
key: n.id,
|
|
2661
3272
|
className: "lks-note"
|
|
2662
|
-
}, (0, react.createElement)("span", { className: "lks-note-src" }, n.source), (0, react.createElement)("
|
|
3273
|
+
}, (0, react.createElement)("span", { className: "lks-note-src" }, n.source), (0, react.createElement)("button", {
|
|
3274
|
+
className: `lks-note-del${armedNote === n.id ? " armed" : ""}`,
|
|
3275
|
+
title: armedNote === n.id ? tr("note.delete.confirm") : tr("note.delete"),
|
|
3276
|
+
"aria-label": armedNote === n.id ? tr("note.delete.confirm") : tr("note.delete"),
|
|
3277
|
+
onClick: (e) => {
|
|
3278
|
+
e.stopPropagation();
|
|
3279
|
+
if (armedNote !== n.id) {
|
|
3280
|
+
setArmedNote(n.id);
|
|
3281
|
+
return;
|
|
3282
|
+
}
|
|
3283
|
+
setArmedNote(null);
|
|
3284
|
+
fire(deleteNote(lesson.lessonId, n.id));
|
|
3285
|
+
}
|
|
3286
|
+
}, (0, react.createElement)(IconTrashOutline16, { size: 12 })), (0, react.createElement)("div", { className: "lks-note-title" }, n.title), (0, react.createElement)("div", {
|
|
2663
3287
|
className: "lks-note-text",
|
|
2664
3288
|
dangerouslySetInnerHTML: { __html: renderMarkdown(n.text) }
|
|
2665
3289
|
}), n.quote !== null ? (0, react.createElement)("div", { className: "lks-note-q" }, `“${n.quote}”`) : null))))));
|
|
2666
|
-
return (0, react.createElement)("div", { className: "lks-col lks-col-bb" }, (0, react.createElement)("div", { className: "lks-colhead" }, tr("col.bb")), body);
|
|
3290
|
+
return (0, react.createElement)("div", { className: "lks-col lks-col-bb" }, (0, react.createElement)("div", { className: "lks-colhead" }, tr("col.bb")), body, (0, react.createElement)(ActionError, { error }));
|
|
2667
3291
|
}
|
|
2668
3292
|
//#endregion
|
|
2669
3293
|
//#region src/client/starter.tsx
|