dsh-plugin-lookatstudy 0.25.3 → 0.26.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 +555 -26
- package/lib/client.js.map +1 -1
- package/lib/index.mjs +739 -64
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -396,11 +396,24 @@ window.__ModuleLoader__.load({
|
|
|
396
396
|
negative margins cancel the notebody padding so the bar covers the whole
|
|
397
397
|
column width while content scrolls underneath */
|
|
398
398
|
.lks-ui .lks14-viewtabs{position:sticky;top:0;z-index:20;margin:0 -20px 12px;padding:8px 20px 6px;background:var(--surface-2);border-bottom:1px solid var(--border-faint)}
|
|
399
|
-
.lks-ui .lks14-viewtab{display:inline-flex;align-items:center;gap:5px}
|
|
399
|
+
.lks-ui .lks14-viewtab{display:inline-flex;align-items:center;justify-content:center;gap:5px;flex:1 1 0}
|
|
400
400
|
/* 0.25.3 karaoke: the speaking sentence highlight (CSS Custom Highlight API
|
|
401
401
|
registration; the span class is the no-Highlight-API fallback) */
|
|
402
402
|
::highlight(lks-reading){background:rgb(var(--brand-rgb)/0.28);color:var(--ink-strong)}
|
|
403
403
|
.lks-ui .lks14-reading-mark{background:rgb(var(--brand-rgb)/0.28);color:var(--ink-strong);border-radius:3px}
|
|
404
|
+
/* v0.35.1-alignment: persistent note highlights ride the same registration
|
|
405
|
+
route (zero DOM mutation) — same skin as the mark.lks-hl fallback */
|
|
406
|
+
.lks-ui ::highlight(lks-note-hl){background:rgb(var(--gold-rgb)/0.25);border-bottom:2px solid var(--gold)}
|
|
407
|
+
/* v0.37-alignment: the progression-boundary card + the rail pulse ring the
|
|
408
|
+
「指出下一课」 action arms (bot points, user drives — nothing auto-switches) */
|
|
409
|
+
.lks-ui .lks14-boundary{display:flex;align-items:center;gap:10px;margin:10px 20px 4px;padding:10px 12px;border:1px solid rgb(var(--gold-rgb)/0.5);background:rgb(var(--gold-rgb)/0.08);border-radius:10px;flex:none;color:var(--gold-dark)}
|
|
410
|
+
.lks-ui .lks14-boundary-text{flex:1;min-width:0}
|
|
411
|
+
.lks-ui .lks14-boundary-title{font-weight:700;font-size:13.5px;color:var(--ink-strong)}
|
|
412
|
+
.lks-ui .lks14-boundary-sub{font-size:12px;color:var(--ink-muted);margin-top:2px}
|
|
413
|
+
.lks-ui .lks14-boundary-x{flex:none;background:none;border:none;color:var(--ink-muted);cursor:pointer;font-size:13px;padding:4px 6px}
|
|
414
|
+
.lks-ui .lks14-boundary-x:hover{color:var(--ink-strong)}
|
|
415
|
+
.lks-ui .lks-lessorow.next-hint{outline:2px dashed var(--gold);outline-offset:1px;animation:lks-hint-pulse 1.6s ease-in-out infinite}
|
|
416
|
+
@keyframes lks-hint-pulse{0%,100%{outline-color:rgb(var(--gold-rgb)/0.95)}50%{outline-color:rgb(var(--gold-rgb)/0.25)}}
|
|
404
417
|
/* #10-followup: the reading column is height:auto (the note col scrolls), so
|
|
405
418
|
.lks14-board's height:100% resolved against a content-sized parent and the
|
|
406
419
|
stage collapsed to a ~90px sliver — the never-upscale contain fit then
|
|
@@ -1990,6 +2003,37 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
|
|
|
1990
2003
|
});
|
|
1991
2004
|
this.refresh();
|
|
1992
2005
|
}
|
|
2006
|
+
/** Upstream v0.36: save the learner's OWN profile edit (the human path —
|
|
2007
|
+
* this is also what marks older AI suggestions stale). */
|
|
2008
|
+
async saveProfile(patch) {
|
|
2009
|
+
await fetchJson("/lookatstudy/api/profile", {
|
|
2010
|
+
method: "POST",
|
|
2011
|
+
headers: { "content-type": "application/json" },
|
|
2012
|
+
body: JSON.stringify(patch)
|
|
2013
|
+
});
|
|
2014
|
+
this.refresh();
|
|
2015
|
+
}
|
|
2016
|
+
/** Accept or ignore one AI profile suggestion (the settings-page consumer). */
|
|
2017
|
+
async resolveProfileProposal(id, accept) {
|
|
2018
|
+
await fetchJson("/lookatstudy/api/profile/resolve", {
|
|
2019
|
+
method: "POST",
|
|
2020
|
+
headers: { "content-type": "application/json" },
|
|
2021
|
+
body: JSON.stringify({
|
|
2022
|
+
id,
|
|
2023
|
+
accept
|
|
2024
|
+
})
|
|
2025
|
+
});
|
|
2026
|
+
this.refresh();
|
|
2027
|
+
}
|
|
2028
|
+
/** Anti-farming (upstream v0.35): a locally-judged practice card is human
|
|
2029
|
+
* grading — report it so the tutor-only mastery cap lifts on the lesson. */
|
|
2030
|
+
async reportPracticeGraded(lessonId) {
|
|
2031
|
+
await fetchJson("/lookatstudy/api/practice-graded", {
|
|
2032
|
+
method: "POST",
|
|
2033
|
+
headers: { "content-type": "application/json" },
|
|
2034
|
+
body: JSON.stringify({ lessonId })
|
|
2035
|
+
}).catch(() => void 0);
|
|
2036
|
+
}
|
|
1993
2037
|
/** Delete one notebook entry from a lesson's Cornell zones. */
|
|
1994
2038
|
async deleteNote(lessonId, noteId) {
|
|
1995
2039
|
await fetchJson("/lookatstudy/api/note/delete", {
|
|
@@ -2159,6 +2203,9 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
|
|
|
2159
2203
|
setFocus: studyStore.setFocus.bind(studyStore),
|
|
2160
2204
|
searchLessons: studyStore.searchLessons.bind(studyStore),
|
|
2161
2205
|
tts: studyStore.tts.bind(studyStore),
|
|
2206
|
+
saveProfile: studyStore.saveProfile.bind(studyStore),
|
|
2207
|
+
resolveProfileProposal: studyStore.resolveProfileProposal.bind(studyStore),
|
|
2208
|
+
reportPracticeGraded: studyStore.reportPracticeGraded.bind(studyStore),
|
|
2162
2209
|
setCourseScope: studyStore.setCourseScope.bind(studyStore),
|
|
2163
2210
|
deleteCourse: studyStore.deleteCourse.bind(studyStore),
|
|
2164
2211
|
deleteNote: studyStore.deleteNote.bind(studyStore),
|
|
@@ -2255,6 +2302,15 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
|
|
|
2255
2302
|
"proposal.banner": "《{lesson}》可以提前毕业了",
|
|
2256
2303
|
"proposal.accept.msg": "我接受《{lesson}》的掌握度提议,帮我标记为掌握。",
|
|
2257
2304
|
"proposal.decline.msg": "《{lesson}》我先再练练,暂不接受提前毕业。",
|
|
2305
|
+
"boundary.done": "本课完成,皇冠到手!",
|
|
2306
|
+
"boundary.course.done": "整门课程完成!",
|
|
2307
|
+
"boundary.next.sub": "下一课是《{title}》——在课程栏点它进入。",
|
|
2308
|
+
"boundary.thread.sub": "下一课是《{title}》——点开始,就在这条线上接着讲。",
|
|
2309
|
+
"boundary.thread.point": "开始下一课",
|
|
2310
|
+
"boundary.thread.send": "这一课完成了,我们开始下一课《{title}》吧。",
|
|
2311
|
+
"boundary.last.sub": "全部课时都已掌握,复习会按计划回来。",
|
|
2312
|
+
"boundary.point": "在课程栏指出下一课",
|
|
2313
|
+
"boundary.dismiss": "收起",
|
|
2258
2314
|
"tutor.thinking": "导师思考中…",
|
|
2259
2315
|
"header.xp": "经验值 {xp}",
|
|
2260
2316
|
"header.streak": "连续学习天数",
|
|
@@ -2489,7 +2545,34 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
|
|
|
2489
2545
|
"settings.stats.courses": "课程 {count} 个",
|
|
2490
2546
|
"settings.stats.xp": "XP {xp} · Lv{level}({pct}%)",
|
|
2491
2547
|
"settings.stats.today": "今日 {xp}/{goal}",
|
|
2492
|
-
"settings.stats.streak": "连续 {days} 天
|
|
2548
|
+
"settings.stats.streak": "连续 {days} 天 · 最长 {best} 天 · 冻结 {freeze}",
|
|
2549
|
+
"settings.stats.mastered": "已掌握 {count} 课 · 待复习 {due} 课",
|
|
2550
|
+
"settings.profile": "学习者画像",
|
|
2551
|
+
"settings.profile.hint": "只属于你的背景事实,用来调整怎么教——不进任何评分。AI 的建议也在这里处理,不打断对话。",
|
|
2552
|
+
"settings.profile.name": "称呼",
|
|
2553
|
+
"settings.profile.mbti": "MBTI(快捷入口,自动展开为风格四维)",
|
|
2554
|
+
"settings.profile.interests": "兴趣点(顿号/逗号分隔,最多 8 个)",
|
|
2555
|
+
"settings.profile.interests.ph": "例如:天文、做菜、悬疑小说",
|
|
2556
|
+
"settings.profile.note": "想对导师说的话",
|
|
2557
|
+
"settings.profile.motive": "学习动机(只有你自己能设置)",
|
|
2558
|
+
"settings.profile.unset": "未设置",
|
|
2559
|
+
"settings.profile.save": "保存画像",
|
|
2560
|
+
"settings.profile.private": "画像只存本地 state.json,不会上传。",
|
|
2561
|
+
"settings.style.start": "讲解起点",
|
|
2562
|
+
"settings.style.analogy": "先具体实例建直觉",
|
|
2563
|
+
"settings.style.framework": "先框架后细节",
|
|
2564
|
+
"settings.style.interaction": "互动密度",
|
|
2565
|
+
"settings.style.dialogue": "边讲边问",
|
|
2566
|
+
"settings.style.lecture": "讲完一节再答疑",
|
|
2567
|
+
"settings.style.feedback": "反馈风格",
|
|
2568
|
+
"settings.style.direct": "直接纠错",
|
|
2569
|
+
"settings.style.encouraging": "先肯定再指正",
|
|
2570
|
+
"settings.style.pacing": "节奏",
|
|
2571
|
+
"settings.style.sequential": "按顺序推进",
|
|
2572
|
+
"settings.style.exploratory": "允许跳着学",
|
|
2573
|
+
"settings.suggest.head": "AI 建议的",
|
|
2574
|
+
"settings.suggest.apply": "采纳",
|
|
2575
|
+
"settings.suggest.ignore": "忽略",
|
|
2493
2576
|
"settings.about": "关于",
|
|
2494
2577
|
"settings.about.hint": "当前运行的构建版本,点击查看该版更新内容。",
|
|
2495
2578
|
"settings.stateFile": "状态文件",
|
|
@@ -2626,6 +2709,15 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
|
|
|
2626
2709
|
"proposal.banner": "{lesson} is ready to graduate early",
|
|
2627
2710
|
"proposal.accept.msg": "I accept the mastery proposal for \"{lesson}\" — mark it mastered.",
|
|
2628
2711
|
"proposal.decline.msg": "I want more practice on \"{lesson}\" before graduating.",
|
|
2712
|
+
"boundary.done": "Lesson complete — crown earned!",
|
|
2713
|
+
"boundary.course.done": "Course complete!",
|
|
2714
|
+
"boundary.next.sub": "Next up: {title} — click it in the course rail to continue.",
|
|
2715
|
+
"boundary.thread.sub": "Next: {title} — continue right here in this thread.",
|
|
2716
|
+
"boundary.thread.point": "Start the next lesson",
|
|
2717
|
+
"boundary.thread.send": "This lesson is wrapped up — let's start the next one, {title}.",
|
|
2718
|
+
"boundary.last.sub": "Every lesson is mastered; reviews will come back on schedule.",
|
|
2719
|
+
"boundary.point": "Point at the next lesson",
|
|
2720
|
+
"boundary.dismiss": "Dismiss",
|
|
2629
2721
|
"tutor.thinking": "The tutor is thinking…",
|
|
2630
2722
|
"header.xp": "{xp} XP",
|
|
2631
2723
|
"header.streak": "Day streak",
|
|
@@ -2860,7 +2952,34 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
|
|
|
2860
2952
|
"settings.stats.courses": "{count} courses",
|
|
2861
2953
|
"settings.stats.xp": "XP {xp} · Lv{level} ({pct}%)",
|
|
2862
2954
|
"settings.stats.today": "Today {xp}/{goal}",
|
|
2863
|
-
"settings.stats.streak": "Streak {days}
|
|
2955
|
+
"settings.stats.streak": "Streak {days} days · best {best} · freezes {freeze}",
|
|
2956
|
+
"settings.stats.mastered": "{count} lessons mastered · {due} due",
|
|
2957
|
+
"settings.profile": "Learner profile",
|
|
2958
|
+
"settings.profile.hint": "Background facts only you can declare — they shape HOW you are taught, never any scoring. AI suggestions resolve here, never in the chat.",
|
|
2959
|
+
"settings.profile.name": "Name",
|
|
2960
|
+
"settings.profile.mbti": "MBTI (shortcut — expands into the style dims)",
|
|
2961
|
+
"settings.profile.interests": "Interests (comma-separated, max 8)",
|
|
2962
|
+
"settings.profile.interests.ph": "e.g. astronomy, cooking, mystery novels",
|
|
2963
|
+
"settings.profile.note": "Note to the tutor",
|
|
2964
|
+
"settings.profile.motive": "Learning motive (only you can set this)",
|
|
2965
|
+
"settings.profile.unset": "Not set",
|
|
2966
|
+
"settings.profile.save": "Save profile",
|
|
2967
|
+
"settings.profile.private": "Stored in your local state.json only — never uploaded.",
|
|
2968
|
+
"settings.style.start": "Explanation start",
|
|
2969
|
+
"settings.style.analogy": "Concrete examples first",
|
|
2970
|
+
"settings.style.framework": "Framework first",
|
|
2971
|
+
"settings.style.interaction": "Interaction density",
|
|
2972
|
+
"settings.style.dialogue": "Questions as we go",
|
|
2973
|
+
"settings.style.lecture": "Lecture first, Q&A after",
|
|
2974
|
+
"settings.style.feedback": "Feedback style",
|
|
2975
|
+
"settings.style.direct": "Direct corrections",
|
|
2976
|
+
"settings.style.encouraging": "Encouraging corrections",
|
|
2977
|
+
"settings.style.pacing": "Pacing",
|
|
2978
|
+
"settings.style.sequential": "Sequential",
|
|
2979
|
+
"settings.style.exploratory": "Exploratory",
|
|
2980
|
+
"settings.suggest.head": "AI suggested",
|
|
2981
|
+
"settings.suggest.apply": "Accept",
|
|
2982
|
+
"settings.suggest.ignore": "Ignore",
|
|
2864
2983
|
"settings.about": "About",
|
|
2865
2984
|
"settings.about.hint": "The version of the running build — click to see its release notes.",
|
|
2866
2985
|
"settings.stateFile": "State file",
|
|
@@ -4764,7 +4883,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
|
|
|
4764
4883
|
nodes
|
|
4765
4884
|
};
|
|
4766
4885
|
}
|
|
4767
|
-
function offsetsToRange(model, start, end) {
|
|
4886
|
+
function offsetsToRange$1(model, start, end) {
|
|
4768
4887
|
const range = document.createRange();
|
|
4769
4888
|
let setStart = false;
|
|
4770
4889
|
let setEnd = false;
|
|
@@ -4823,7 +4942,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
|
|
|
4823
4942
|
if (m === null && from > 0) m = matchSentenceAligned(model.text, trimmed, 0);
|
|
4824
4943
|
if (m === null) return null;
|
|
4825
4944
|
readingCursors.set(container, m[1]);
|
|
4826
|
-
const range = offsetsToRange(model, m[0], m[1]);
|
|
4945
|
+
const range = offsetsToRange$1(model, m[0], m[1]);
|
|
4827
4946
|
if (range === null) return null;
|
|
4828
4947
|
const cssLike = CSS;
|
|
4829
4948
|
if (typeof Highlight !== "undefined" && cssLike.highlights !== void 0) try {
|
|
@@ -5249,6 +5368,35 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
|
|
|
5249
5368
|
};
|
|
5250
5369
|
}
|
|
5251
5370
|
/**
|
|
5371
|
+
* Tool-error visibility (upstream v0.37.1 toolErrorVisibility, part-accumulator):
|
|
5372
|
+
* an error tool row that is SUPERSEDED by a later same-tool call within the
|
|
5373
|
+
* same turn (no learner message in between) is dropped — weak models fail a
|
|
5374
|
+
* zod-validated input on the first try and succeed on the retry (observed
|
|
5375
|
+
* live: the quiz error block flashed, then the quiz card landed), and the
|
|
5376
|
+
* stale transient failure is pure noise. A real failure — no successor —
|
|
5377
|
+
* stays visible; nothing is silently swallowed. Pure.
|
|
5378
|
+
*/
|
|
5379
|
+
function hideSupersededToolErrors(rows) {
|
|
5380
|
+
const out = [];
|
|
5381
|
+
for (let i = 0; i < rows.length; i++) {
|
|
5382
|
+
const row = rows[i];
|
|
5383
|
+
if (row.role === "tool" && row.toolState === "error") {
|
|
5384
|
+
let superseded = false;
|
|
5385
|
+
for (let j = i + 1; j < rows.length; j++) {
|
|
5386
|
+
const later = rows[j];
|
|
5387
|
+
if (later.role === "user") break;
|
|
5388
|
+
if (later.role === "tool" && later.text === row.text && (later.toolState === "loading" || later.toolState === "done")) {
|
|
5389
|
+
superseded = true;
|
|
5390
|
+
break;
|
|
5391
|
+
}
|
|
5392
|
+
}
|
|
5393
|
+
if (superseded) continue;
|
|
5394
|
+
}
|
|
5395
|
+
out.push(row);
|
|
5396
|
+
}
|
|
5397
|
+
return out;
|
|
5398
|
+
}
|
|
5399
|
+
/**
|
|
5252
5400
|
* Fold one event-window snapshot into ordered chat rows. The transient
|
|
5253
5401
|
* `text-delta` chunks of the most recent attempt accumulate into a single
|
|
5254
5402
|
* streaming row (the durable `assistant/message` replaces it on settlement).
|
|
@@ -5369,7 +5517,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
|
|
|
5369
5517
|
running: true
|
|
5370
5518
|
});
|
|
5371
5519
|
}
|
|
5372
|
-
return rows;
|
|
5520
|
+
return hideSupersededToolErrors(rows);
|
|
5373
5521
|
}
|
|
5374
5522
|
/**
|
|
5375
5523
|
* Parse an ask_user_question call's arguments into the answerable row payload
|
|
@@ -6058,6 +6206,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
|
|
|
6058
6206
|
if (!showScore || !allAnswered || hookSent) return;
|
|
6059
6207
|
setHookSent(true);
|
|
6060
6208
|
onFinished?.(score.correct === score.total);
|
|
6209
|
+
studyStore.reportPracticeGraded(lessonId);
|
|
6061
6210
|
const next = {
|
|
6062
6211
|
answers: [...progress.answers],
|
|
6063
6212
|
done: true
|
|
@@ -6924,11 +7073,39 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
|
|
|
6924
7073
|
nodes
|
|
6925
7074
|
};
|
|
6926
7075
|
}
|
|
6927
|
-
/** The mark class applied to persisted highlights. */
|
|
7076
|
+
/** The mark class applied to persisted highlights (DOM fallback channel only). */
|
|
6928
7077
|
const HIGHLIGHT_MARK = "lks-hl";
|
|
6929
7078
|
/**
|
|
7079
|
+
* Persistent-note highlight registry name — the CSS Custom Highlight API
|
|
7080
|
+
* channel (upstream v0.35.1 root fix): the old scheme split text nodes and
|
|
7081
|
+
* inserted <mark>, rewriting DOM a framework may reconcile over — any later
|
|
7082
|
+
* re-render could throw a DOMException into the error boundary. The registry
|
|
7083
|
+
* channel registers Ranges in CSS.highlights and touches ZERO DOM nodes.
|
|
7084
|
+
* Styled by the `::highlight(lks-note-hl)` rule in the upstream skin.
|
|
7085
|
+
*/
|
|
7086
|
+
const NOTE_HIGHLIGHT_NAME = "lks-note-hl";
|
|
7087
|
+
/** Whether the registration channel exists (Highlight ctor + CSS.highlights). */
|
|
7088
|
+
function supportsHighlightMarks() {
|
|
7089
|
+
return typeof Highlight !== "undefined" && !!CSS.highlights;
|
|
7090
|
+
}
|
|
7091
|
+
/** Live note ranges: quote → Range. CSS.highlights is a document-level
|
|
7092
|
+
* singleton, so every container shares one Highlight; dead ranges (nodes
|
|
7093
|
+
* detached by a re-render of the source) are pruned on rebuild so the map
|
|
7094
|
+
* cannot grow without bound. */
|
|
7095
|
+
const liveNoteRanges = /* @__PURE__ */ new Map();
|
|
7096
|
+
const noteRangeOwners = /* @__PURE__ */ new WeakMap();
|
|
7097
|
+
function rebuildNoteHighlight() {
|
|
7098
|
+
const cssLike = CSS;
|
|
7099
|
+
for (const [quote, r] of liveNoteRanges) if (!r.startContainer.isConnected) liveNoteRanges.delete(quote);
|
|
7100
|
+
try {
|
|
7101
|
+
if (liveNoteRanges.size === 0) cssLike.highlights?.delete(NOTE_HIGHLIGHT_NAME);
|
|
7102
|
+
else cssLike.highlights?.set(NOTE_HIGHLIGHT_NAME, new Highlight(...liveNoteRanges.values()));
|
|
7103
|
+
} catch {}
|
|
7104
|
+
}
|
|
7105
|
+
/**
|
|
6930
7106
|
* Apply one highlight range to the live DOM: split the boundary text nodes
|
|
6931
7107
|
* and wrap the covering segments in marks. Returns the created marks.
|
|
7108
|
+
* (The DOM fallback channel — only reached when the Highlight API is absent.)
|
|
6932
7109
|
*/
|
|
6933
7110
|
function wrapRange(model, start, end) {
|
|
6934
7111
|
const marks = [];
|
|
@@ -6949,12 +7126,35 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
|
|
|
6949
7126
|
}
|
|
6950
7127
|
/**
|
|
6951
7128
|
* Render the persisted highlights for one lesson: each record-zone note with a
|
|
6952
|
-
* quote anchors a
|
|
6953
|
-
*
|
|
6954
|
-
*
|
|
7129
|
+
* quote anchors a highlight. Registry channel first (zero DOM mutation — a
|
|
7130
|
+
* framework re-render can never hit a foreign node); the mark-wrapping
|
|
7131
|
+
* fallback only runs on engines without CSS.highlights. Idempotent per call —
|
|
7132
|
+
* call on a freshly rendered container; re-entry replaces this container's
|
|
7133
|
+
* registered ranges first.
|
|
7134
|
+
* @returns how many notes got at least one highlight.
|
|
6955
7135
|
*/
|
|
6956
7136
|
function applyHighlights(container, quotes) {
|
|
6957
7137
|
let applied = 0;
|
|
7138
|
+
if (supportsHighlightMarks()) {
|
|
7139
|
+
const owned = noteRangeOwners.get(container);
|
|
7140
|
+
if (owned !== void 0) for (const quote of owned) liveNoteRanges.delete(quote);
|
|
7141
|
+
const hits = /* @__PURE__ */ new Map();
|
|
7142
|
+
for (const quote of quotes) {
|
|
7143
|
+
const trimmed = quote.trim();
|
|
7144
|
+
if (trimmed.length < 2) continue;
|
|
7145
|
+
const model = getTextModel(container);
|
|
7146
|
+
const located = locateInModel(model, trimmed, void 0);
|
|
7147
|
+
if (located === null) continue;
|
|
7148
|
+
const range = offsetsToRange(model, located.start, located.end);
|
|
7149
|
+
if (range === null) continue;
|
|
7150
|
+
hits.set(trimmed, range);
|
|
7151
|
+
liveNoteRanges.set(trimmed, range);
|
|
7152
|
+
applied += 1;
|
|
7153
|
+
}
|
|
7154
|
+
noteRangeOwners.set(container, new Set(hits.keys()));
|
|
7155
|
+
rebuildNoteHighlight();
|
|
7156
|
+
return applied;
|
|
7157
|
+
}
|
|
6958
7158
|
for (const quote of quotes) {
|
|
6959
7159
|
const trimmed = quote.trim();
|
|
6960
7160
|
if (trimmed.length < 2) continue;
|
|
@@ -6965,6 +7165,22 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
|
|
|
6965
7165
|
}
|
|
6966
7166
|
return applied;
|
|
6967
7167
|
}
|
|
7168
|
+
/** Build one Range across the model's text nodes (null when the browser
|
|
7169
|
+
* refuses — e.g. a detached node between model build and call). */
|
|
7170
|
+
function offsetsToRange(model, start, end) {
|
|
7171
|
+
const segments = planSegments(model.nodes, start, end);
|
|
7172
|
+
if (segments.length === 0) return null;
|
|
7173
|
+
const first = segments[0];
|
|
7174
|
+
const last = segments[segments.length - 1];
|
|
7175
|
+
try {
|
|
7176
|
+
const range = new Range();
|
|
7177
|
+
range.setStart(model.nodes[first.index].node, first.localStart);
|
|
7178
|
+
range.setEnd(model.nodes[last.index].node, last.localEnd);
|
|
7179
|
+
return range;
|
|
7180
|
+
} catch {
|
|
7181
|
+
return null;
|
|
7182
|
+
}
|
|
7183
|
+
}
|
|
6968
7184
|
//#endregion
|
|
6969
7185
|
//#region src/client/maprail.tsx
|
|
6970
7186
|
/**
|
|
@@ -6995,17 +7211,31 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
|
|
|
6995
7211
|
if (lesson.status === "in_progress") return lesson.masteryPct ?? 0;
|
|
6996
7212
|
return null;
|
|
6997
7213
|
}
|
|
6998
|
-
|
|
6999
|
-
|
|
7000
|
-
|
|
7001
|
-
|
|
7002
|
-
|
|
7003
|
-
|
|
7214
|
+
function nextStudyAfterView(courses, lessonId) {
|
|
7215
|
+
for (const course of courses) for (let si = 0; si < course.sections.length; si++) {
|
|
7216
|
+
const lessons = course.sections[si].lessons;
|
|
7217
|
+
const li = lessons.findIndex((l) => l.id === lessonId);
|
|
7218
|
+
if (li < 0) continue;
|
|
7219
|
+
for (let j = li + 1; j < lessons.length; j++) if (lessons[j].kind === "study") return {
|
|
7220
|
+
id: lessons[j].id,
|
|
7221
|
+
title: lessons[j].title
|
|
7222
|
+
};
|
|
7223
|
+
for (let s = si + 1; s < course.sections.length; s++) {
|
|
7224
|
+
const first = course.sections[s].lessons.find((l) => l.kind === "study");
|
|
7225
|
+
if (first !== void 0) return {
|
|
7226
|
+
id: first.id,
|
|
7227
|
+
title: first.title
|
|
7228
|
+
};
|
|
7229
|
+
}
|
|
7230
|
+
return null;
|
|
7231
|
+
}
|
|
7232
|
+
return null;
|
|
7233
|
+
}
|
|
7004
7234
|
function sectionWorldOf(section) {
|
|
7005
7235
|
return section.lessons.length > 0 && section.lessons.every((l) => l.kind === "practice") ? "practice" : "study";
|
|
7006
7236
|
}
|
|
7007
7237
|
/** One section: a quiet header toggle + the lesson rows. */
|
|
7008
|
-
function ListSectionView({ section, examAllowed, open, onToggle, onJump, streamingId }) {
|
|
7238
|
+
function ListSectionView({ section, examAllowed, open, onToggle, onJump, streamingId, hintId }) {
|
|
7009
7239
|
const lessons = section.lessons;
|
|
7010
7240
|
const done = lessons.filter((l) => l.status === "mastered").length;
|
|
7011
7241
|
return (0, react.createElement)("section", { className: "lks-railsec" }, (0, react.createElement)("button", {
|
|
@@ -7022,7 +7252,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
|
|
|
7022
7252
|
return (0, react.createElement)("button", {
|
|
7023
7253
|
key: lesson.id,
|
|
7024
7254
|
type: "button",
|
|
7025
|
-
className: `lks-lessorow st-${state}${lesson.focus ? " selected" : ""}`,
|
|
7255
|
+
className: `lks-lessorow st-${state}${lesson.focus ? " selected" : ""}${hintId !== void 0 && hintId !== null && hintId === lesson.id ? " next-hint" : ""}`,
|
|
7026
7256
|
"data-node-id": lesson.id,
|
|
7027
7257
|
"aria-disabled": locked || void 0,
|
|
7028
7258
|
"data-tooltip": `${lesson.title} — ${stateWord}`,
|
|
@@ -7864,6 +8094,17 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
|
|
|
7864
8094
|
}
|
|
7865
8095
|
}
|
|
7866
8096
|
/** C8: the most recent decided proposal's badge payload (render order preserved). */
|
|
8097
|
+
/** Upstream v0.37 boundary card: lessons whose card the learner dismissed
|
|
8098
|
+
* (persisted so a remount does not re-nag about an already-mastered lesson). */
|
|
8099
|
+
const BOUNDARY_DISMISS_KEY = "lks-boundary-dismissed";
|
|
8100
|
+
function boundaryDismissedLessons() {
|
|
8101
|
+
try {
|
|
8102
|
+
const raw = JSON.parse(localStorage.getItem(BOUNDARY_DISMISS_KEY) ?? "[]");
|
|
8103
|
+
return new Set(Array.isArray(raw) ? raw.filter((s) => typeof s === "string") : []);
|
|
8104
|
+
} catch {
|
|
8105
|
+
return /* @__PURE__ */ new Set();
|
|
8106
|
+
}
|
|
8107
|
+
}
|
|
7867
8108
|
function decidedBadgeOf(decided) {
|
|
7868
8109
|
const entries = Object.values(decided);
|
|
7869
8110
|
return entries.length > 0 ? entries[entries.length - 1] : null;
|
|
@@ -8141,6 +8382,39 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
|
|
|
8141
8382
|
const [draft, setDraft] = (0, react.useState)("");
|
|
8142
8383
|
const [narrowPane, setNarrowPane] = (0, react.useState)(pickNarrowPane(null));
|
|
8143
8384
|
const lesson = data?.lesson ?? null;
|
|
8385
|
+
const [boundary, setBoundary] = (0, react.useState)(null);
|
|
8386
|
+
const [nextHint, setNextHint] = (0, react.useState)(null);
|
|
8387
|
+
const prevStatusRef = (0, react.useRef)(null);
|
|
8388
|
+
(0, react.useEffect)(() => {
|
|
8389
|
+
const status = lesson?.status ?? null;
|
|
8390
|
+
const prev = prevStatusRef.current;
|
|
8391
|
+
prevStatusRef.current = status;
|
|
8392
|
+
if (lesson === null || lesson.kind !== "study" || status !== "mastered") {
|
|
8393
|
+
setBoundary(null);
|
|
8394
|
+
return;
|
|
8395
|
+
}
|
|
8396
|
+
if (prev === "mastered") return;
|
|
8397
|
+
if (boundaryDismissedLessons().has(lesson.lessonId)) return;
|
|
8398
|
+
const next = data !== null ? nextStudyAfterView(data.courses, lesson.lessonId) : null;
|
|
8399
|
+
setBoundary({
|
|
8400
|
+
lessonId: lesson.lessonId,
|
|
8401
|
+
nextId: next?.id ?? null,
|
|
8402
|
+
nextTitle: next?.title ?? null,
|
|
8403
|
+
last: next === null
|
|
8404
|
+
});
|
|
8405
|
+
}, [lesson, data]);
|
|
8406
|
+
const dismissBoundary = (0, react.useCallback)(() => {
|
|
8407
|
+
if (boundary !== null) try {
|
|
8408
|
+
const set = boundaryDismissedLessons();
|
|
8409
|
+
set.add(boundary.lessonId);
|
|
8410
|
+
localStorage.setItem(BOUNDARY_DISMISS_KEY, JSON.stringify([...set]));
|
|
8411
|
+
} catch {}
|
|
8412
|
+
setBoundary(null);
|
|
8413
|
+
}, [boundary]);
|
|
8414
|
+
const pointAtNext = (0, react.useCallback)((nextId) => {
|
|
8415
|
+
setNextHint(nextId);
|
|
8416
|
+
setNarrowPane("rail");
|
|
8417
|
+
}, []);
|
|
8144
8418
|
const examLessonActive = lesson !== null && lesson.kind === "exam" && examOpen(data?.courses.flatMap((c) => c.sections).find((s) => s.lessons.some((l) => l.id === lesson.lessonId))?.lessons ?? []);
|
|
8145
8419
|
const localBound = (0, react.useRef)(null);
|
|
8146
8420
|
const boundSessionOf = (lessonId) => lessonId !== null && localBound.current?.lessonId === lessonId ? localBound.current.sessionId : null;
|
|
@@ -8542,7 +8816,10 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
|
|
|
8542
8816
|
}, (0, react.createElement)(CourseRail, {
|
|
8543
8817
|
data,
|
|
8544
8818
|
activate,
|
|
8545
|
-
setFocus:
|
|
8819
|
+
setFocus: (id) => {
|
|
8820
|
+
setNextHint(null);
|
|
8821
|
+
return guardedSetFocus(id);
|
|
8822
|
+
},
|
|
8546
8823
|
searchLessons,
|
|
8547
8824
|
deleteCourse,
|
|
8548
8825
|
send,
|
|
@@ -8555,7 +8832,8 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
|
|
|
8555
8832
|
stop,
|
|
8556
8833
|
sendImport,
|
|
8557
8834
|
endImportSession,
|
|
8558
|
-
paletteBus
|
|
8835
|
+
paletteBus,
|
|
8836
|
+
nextHintLessonId: nextHint
|
|
8559
8837
|
}), (0, react.createElement)(PaneResizeHandle, {
|
|
8560
8838
|
side: "rail",
|
|
8561
8839
|
clampLive: railClampLive,
|
|
@@ -8629,7 +8907,10 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
|
|
|
8629
8907
|
},
|
|
8630
8908
|
contextMeter,
|
|
8631
8909
|
contextBreakdown: breakdown,
|
|
8632
|
-
uploadAttachment
|
|
8910
|
+
uploadAttachment,
|
|
8911
|
+
boundary,
|
|
8912
|
+
onPointNext: pointAtNext,
|
|
8913
|
+
onDismissBoundary: dismissBoundary
|
|
8633
8914
|
}), (0, react.createElement)(PaneResizeHandle, {
|
|
8634
8915
|
side: "chat",
|
|
8635
8916
|
clampLive: chatClampLive,
|
|
@@ -8966,7 +9247,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
|
|
|
8966
9247
|
PANEL_SHELL.current = shell;
|
|
8967
9248
|
}
|
|
8968
9249
|
/** 左栏:course picker, tree, review box, import. */
|
|
8969
|
-
function CourseRail({ data, activate, setFocus, searchLessons, deleteCourse, send, onJumped, streamingLessonId, turnActive, importProgress, stop, paletteBus, sendImport, endImportSession }) {
|
|
9250
|
+
function CourseRail({ data, activate, setFocus, searchLessons, deleteCourse, send, onJumped, streamingLessonId, turnActive, importProgress, stop, paletteBus, sendImport, endImportSession, nextHintLessonId }) {
|
|
8970
9251
|
const [selectedCourse, setSelectedCourse] = (0, react.useState)("");
|
|
8971
9252
|
const [query, setQuery] = (0, react.useState)("");
|
|
8972
9253
|
const [searchRows, setSearchRows] = (0, react.useState)([]);
|
|
@@ -9194,6 +9475,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
|
|
|
9194
9475
|
return [(0, react.createElement)(ListSectionView, {
|
|
9195
9476
|
key: section.title,
|
|
9196
9477
|
streamingId: streamingLessonId,
|
|
9478
|
+
hintId: nextHintLessonId ?? null,
|
|
9197
9479
|
section: {
|
|
9198
9480
|
title: section.title,
|
|
9199
9481
|
index: section.index,
|
|
@@ -9597,7 +9879,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
|
|
|
9597
9879
|
return cut <= 0 ? path : norm.slice(0, cut);
|
|
9598
9880
|
}
|
|
9599
9881
|
/** 中栏:the tutor chat stream with its own composer (upstream ChatStream + ChatComposer). */
|
|
9600
|
-
function ChatPane({ data, lesson, rows, feedAttached, bound, busy, sendError, draft, setDraft, send, stop, setMode, narrowPane, onThreadSwitch, onThreadNew, onThreadRename, onThreadArchive, onThreadDelete, courseScope, onScopeToggle, contextMeter, contextBreakdown, uploadAttachment }) {
|
|
9882
|
+
function ChatPane({ data, lesson, rows, feedAttached, bound, busy, sendError, draft, setDraft, send, stop, setMode, narrowPane, onThreadSwitch, onThreadNew, onThreadRename, onThreadArchive, onThreadDelete, courseScope, onScopeToggle, contextMeter, contextBreakdown, uploadAttachment, boundary, onPointNext, onDismissBoundary }) {
|
|
9601
9883
|
const stuck = (0, react.useRef)(true);
|
|
9602
9884
|
const [showFab, setShowFab] = (0, react.useState)(false);
|
|
9603
9885
|
const [threadsOpen, setThreadsOpen] = (0, react.useState)(false);
|
|
@@ -10104,7 +10386,23 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
|
|
|
10104
10386
|
onClick: () => {
|
|
10105
10387
|
if (!dormant) send(s.message);
|
|
10106
10388
|
}
|
|
10107
|
-
}, s.label))) : null,
|
|
10389
|
+
}, s.label))) : null, boundary !== null ? (0, react.createElement)("div", {
|
|
10390
|
+
className: "lks14-boundary",
|
|
10391
|
+
"data-testid": "boundary-card"
|
|
10392
|
+
}, (0, react.createElement)(IconCrownFill16, { size: 16 }), (0, react.createElement)("div", { className: "lks14-boundary-text" }, (0, react.createElement)("div", { className: "lks14-boundary-title" }, boundary.last ? tr("boundary.course.done") : tr("boundary.done")), (0, react.createElement)("div", { className: "lks14-boundary-sub" }, boundary.last ? tr("boundary.last.sub") : boundary.nextTitle !== null ? courseScope === "course" ? tr("boundary.thread.sub", { title: boundary.nextTitle }) : tr("boundary.next.sub", { title: boundary.nextTitle }) : "")), !boundary.last && boundary.nextId !== null ? (0, react.createElement)("button", {
|
|
10393
|
+
className: "lks-btn primary",
|
|
10394
|
+
"data-testid": "boundary-next",
|
|
10395
|
+
onClick: () => {
|
|
10396
|
+
if (courseScope === "course" && !dormant) {
|
|
10397
|
+
send(tr("boundary.thread.send", { title: boundary.nextTitle ?? "" }));
|
|
10398
|
+
onDismissBoundary();
|
|
10399
|
+
} else onPointNext(boundary.nextId);
|
|
10400
|
+
}
|
|
10401
|
+
}, courseScope === "course" ? tr("boundary.thread.point") : tr("boundary.point")) : null, (0, react.createElement)("button", {
|
|
10402
|
+
className: "lks14-boundary-x",
|
|
10403
|
+
"aria-label": tr("boundary.dismiss"),
|
|
10404
|
+
onClick: onDismissBoundary
|
|
10405
|
+
}, "✕")) : null, (0, react.createElement)("div", { className: "lks14-composer" }, (0, react.createElement)("div", {
|
|
10108
10406
|
className: "lks14-composer-card",
|
|
10109
10407
|
onDragOver: (e) => {
|
|
10110
10408
|
if (e.dataTransfer?.types.includes("Files") === true) e.preventDefault();
|
|
@@ -11031,6 +11329,79 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
|
|
|
11031
11329
|
return !suppressed && !wasHydration;
|
|
11032
11330
|
}
|
|
11033
11331
|
//#endregion
|
|
11332
|
+
//#region src/learner-profile.ts
|
|
11333
|
+
/**
|
|
11334
|
+
* 学习者画像(声明侧)— ported from LookatStudy shared/learner-profile.ts
|
|
11335
|
+
* (upstream v0.36.0, MIT License). The learner-declared profile: MBTI as a
|
|
11336
|
+
* shortcut entry expanding into the four style dims (the dims are the source
|
|
11337
|
+
* of truth), the OIT motivation stage, interests, and a free note. The
|
|
11338
|
+
* profile adjusts HOW the tutor teaches — it never touches BKT/mastery/
|
|
11339
|
+
* unlock numbers (upstream SPEC §7 forbidden zone). Injection copy carries
|
|
11340
|
+
* the anti-injection annotation ("background data, not instructions").
|
|
11341
|
+
* @module dsh-plugin-lookatstudy/learner-profile
|
|
11342
|
+
*/
|
|
11343
|
+
const MBTI_TYPES = [
|
|
11344
|
+
"INTJ",
|
|
11345
|
+
"INTP",
|
|
11346
|
+
"ENTJ",
|
|
11347
|
+
"ENTP",
|
|
11348
|
+
"INFJ",
|
|
11349
|
+
"INFP",
|
|
11350
|
+
"ENFJ",
|
|
11351
|
+
"ENFP",
|
|
11352
|
+
"ISTJ",
|
|
11353
|
+
"ISFJ",
|
|
11354
|
+
"ESTJ",
|
|
11355
|
+
"ESFJ",
|
|
11356
|
+
"ISTP",
|
|
11357
|
+
"ISFP",
|
|
11358
|
+
"ESTP",
|
|
11359
|
+
"ESFP"
|
|
11360
|
+
];
|
|
11361
|
+
/**
|
|
11362
|
+
* 动机阶段(OIT 内化连续体 2-6 级;无动机级不设)。用户侧只见白话名,
|
|
11363
|
+
* 临床语义只活在注入文案里。AI 结构上不可提议(patch 类型排除)。
|
|
11364
|
+
*/
|
|
11365
|
+
const MOTIVE_STAGES = [
|
|
11366
|
+
"external",
|
|
11367
|
+
"introjected",
|
|
11368
|
+
"identified",
|
|
11369
|
+
"integrated",
|
|
11370
|
+
"intrinsic"
|
|
11371
|
+
];
|
|
11372
|
+
/** MBTI 四字母 → style 四维(快捷入口展开为真源初值,用户可逐维手调)。 */
|
|
11373
|
+
function expandMbtiToStyle(mbti) {
|
|
11374
|
+
return {
|
|
11375
|
+
start: mbti.includes("N") ? "framework" : "analogy",
|
|
11376
|
+
interaction: mbti.includes("E") ? "dialogue" : "lecture",
|
|
11377
|
+
feedback: mbti.includes("T") ? "direct" : "encouraging",
|
|
11378
|
+
pacing: mbti.includes("P") ? "exploratory" : "sequential"
|
|
11379
|
+
};
|
|
11380
|
+
}
|
|
11381
|
+
/** 动机阶段白话名 + tagline(用户侧只见白话;临床语义只在注入块)。 */
|
|
11382
|
+
const MOTIVE_DISPLAY = {
|
|
11383
|
+
external: {
|
|
11384
|
+
name: "为了考试/面试",
|
|
11385
|
+
tagline: "这场考试/面试在前头等着,得过去。"
|
|
11386
|
+
},
|
|
11387
|
+
introjected: {
|
|
11388
|
+
name: "为了填满休息时间",
|
|
11389
|
+
tagline: "闲着也是闲着,学点总没坏处。"
|
|
11390
|
+
},
|
|
11391
|
+
identified: {
|
|
11392
|
+
name: "未来能用得上",
|
|
11393
|
+
tagline: "这本事存着,总有一天用得上。"
|
|
11394
|
+
},
|
|
11395
|
+
integrated: {
|
|
11396
|
+
name: "终身学习",
|
|
11397
|
+
tagline: "学习这事儿,我打算干一辈子。"
|
|
11398
|
+
},
|
|
11399
|
+
intrinsic: {
|
|
11400
|
+
name: "享受学习过程",
|
|
11401
|
+
tagline: "懂的那个瞬间,本身就挺爽。"
|
|
11402
|
+
}
|
|
11403
|
+
};
|
|
11404
|
+
//#endregion
|
|
11034
11405
|
//#region src/client/settings.tsx
|
|
11035
11406
|
/**
|
|
11036
11407
|
* The plugin's settings page: one `settings.section` entry riding the host's
|
|
@@ -11059,12 +11430,162 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
|
|
|
11059
11430
|
hintKey: "soul.practice.hint"
|
|
11060
11431
|
}
|
|
11061
11432
|
];
|
|
11433
|
+
/** The four style dims (label key per value), upstream's DIM_LABELS UI face. */
|
|
11434
|
+
const STYLE_DIMS = [
|
|
11435
|
+
{
|
|
11436
|
+
key: "start",
|
|
11437
|
+
values: [["analogy", "settings.style.analogy"], ["framework", "settings.style.framework"]]
|
|
11438
|
+
},
|
|
11439
|
+
{
|
|
11440
|
+
key: "interaction",
|
|
11441
|
+
values: [["dialogue", "settings.style.dialogue"], ["lecture", "settings.style.lecture"]]
|
|
11442
|
+
},
|
|
11443
|
+
{
|
|
11444
|
+
key: "feedback",
|
|
11445
|
+
values: [["direct", "settings.style.direct"], ["encouraging", "settings.style.encouraging"]]
|
|
11446
|
+
},
|
|
11447
|
+
{
|
|
11448
|
+
key: "pacing",
|
|
11449
|
+
values: [["sequential", "settings.style.sequential"], ["exploratory", "settings.style.exploratory"]]
|
|
11450
|
+
}
|
|
11451
|
+
];
|
|
11452
|
+
/**
|
|
11453
|
+
* The learner-profile editor + AI-suggestion consumer (upstream v0.36).
|
|
11454
|
+
* Form state seeds from the persisted profile; saving writes the human path
|
|
11455
|
+
* (which stales older pending suggestions via the updatedAt arbitration).
|
|
11456
|
+
*/
|
|
11457
|
+
function ProfileSection({ data, save, resolve, fire }) {
|
|
11458
|
+
const profile = data?.profile ?? null;
|
|
11459
|
+
const [name, setName] = (0, react.useState)(profile?.name ?? "");
|
|
11460
|
+
const [mbti, setMbti] = (0, react.useState)(profile?.mbti ?? "");
|
|
11461
|
+
const [style, setStyle] = (0, react.useState)(() => ({
|
|
11462
|
+
start: profile?.style.start ?? null,
|
|
11463
|
+
interaction: profile?.style.interaction ?? null,
|
|
11464
|
+
feedback: profile?.style.feedback ?? null,
|
|
11465
|
+
pacing: profile?.style.pacing ?? null
|
|
11466
|
+
}));
|
|
11467
|
+
const [interests, setInterests] = (0, react.useState)(profile?.interests?.join("、") ?? "");
|
|
11468
|
+
const [motive, setMotive] = (0, react.useState)(profile?.motiveStage ?? "");
|
|
11469
|
+
const [freeNote, setFreeNote] = (0, react.useState)(profile?.freeNote ?? "");
|
|
11470
|
+
const seededFor = (0, react.useRef)(null);
|
|
11471
|
+
(0, react.useEffect)(() => {
|
|
11472
|
+
const stamp = profile?.updatedAt ?? null;
|
|
11473
|
+
if (stamp === null || stamp === seededFor.current) return;
|
|
11474
|
+
seededFor.current = stamp;
|
|
11475
|
+
setName(profile?.name ?? "");
|
|
11476
|
+
setMbti(profile?.mbti ?? "");
|
|
11477
|
+
setStyle({
|
|
11478
|
+
start: profile?.style.start ?? null,
|
|
11479
|
+
interaction: profile?.style.interaction ?? null,
|
|
11480
|
+
feedback: profile?.style.feedback ?? null,
|
|
11481
|
+
pacing: profile?.style.pacing ?? null
|
|
11482
|
+
});
|
|
11483
|
+
setInterests(profile?.interests?.join("、") ?? "");
|
|
11484
|
+
setMotive(profile?.motiveStage ?? "");
|
|
11485
|
+
setFreeNote(profile?.freeNote ?? "");
|
|
11486
|
+
}, [profile?.updatedAt]);
|
|
11487
|
+
const saveAll = () => {
|
|
11488
|
+
fire(save({
|
|
11489
|
+
name: name.trim() === "" ? null : name.trim(),
|
|
11490
|
+
mbti: mbti === "" ? null : mbti,
|
|
11491
|
+
interests,
|
|
11492
|
+
freeNote: freeNote.trim() === "" ? null : freeNote.trim(),
|
|
11493
|
+
motiveStage: motive === "" ? null : motive,
|
|
11494
|
+
style
|
|
11495
|
+
}));
|
|
11496
|
+
};
|
|
11497
|
+
const pickMbti = (next) => {
|
|
11498
|
+
setMbti(next);
|
|
11499
|
+
if (next !== "" && MBTI_TYPES.includes(next)) {
|
|
11500
|
+
const expanded = expandMbtiToStyle(next);
|
|
11501
|
+
setStyle({ ...expanded });
|
|
11502
|
+
}
|
|
11503
|
+
};
|
|
11504
|
+
const suggestions = data?.pendingProfileProposals ?? [];
|
|
11505
|
+
return (0, react.createElement)("section", {
|
|
11506
|
+
className: "lks-set-row",
|
|
11507
|
+
"data-testid": "profile-section"
|
|
11508
|
+
}, (0, react.createElement)("h3", null, tr("settings.profile")), (0, react.createElement)("p", { className: "lks-set-hint" }, tr("settings.profile.hint")), (0, react.createElement)("div", { className: "lks-profile-grid" }, (0, react.createElement)("label", null, tr("settings.profile.name"), (0, react.createElement)("input", {
|
|
11509
|
+
className: "lks-set-input",
|
|
11510
|
+
value: name,
|
|
11511
|
+
"data-testid": "profile-name",
|
|
11512
|
+
onChange: (e) => {
|
|
11513
|
+
setName(e.target.value);
|
|
11514
|
+
}
|
|
11515
|
+
})), (0, react.createElement)("label", null, tr("settings.profile.mbti"), (0, react.createElement)("select", {
|
|
11516
|
+
className: "lks-set-select",
|
|
11517
|
+
value: mbti,
|
|
11518
|
+
"data-testid": "profile-mbti",
|
|
11519
|
+
onChange: (e) => {
|
|
11520
|
+
pickMbti(e.target.value);
|
|
11521
|
+
}
|
|
11522
|
+
}, (0, react.createElement)("option", { value: "" }, tr("settings.profile.unset")), ...MBTI_TYPES.map((t) => (0, react.createElement)("option", {
|
|
11523
|
+
key: t,
|
|
11524
|
+
value: t
|
|
11525
|
+
}, t)))), (0, react.createElement)("label", null, tr("settings.profile.interests"), (0, react.createElement)("input", {
|
|
11526
|
+
className: "lks-set-input",
|
|
11527
|
+
value: interests,
|
|
11528
|
+
"data-testid": "profile-interests",
|
|
11529
|
+
placeholder: tr("settings.profile.interests.ph"),
|
|
11530
|
+
onChange: (e) => {
|
|
11531
|
+
setInterests(e.target.value);
|
|
11532
|
+
}
|
|
11533
|
+
})), (0, react.createElement)("label", null, tr("settings.profile.note"), (0, react.createElement)("input", {
|
|
11534
|
+
className: "lks-set-input",
|
|
11535
|
+
value: freeNote,
|
|
11536
|
+
onChange: (e) => {
|
|
11537
|
+
setFreeNote(e.target.value);
|
|
11538
|
+
}
|
|
11539
|
+
})), ...STYLE_DIMS.map((dim) => (0, react.createElement)("label", { key: dim.key }, tr(`settings.style.${dim.key}`), (0, react.createElement)("span", { className: "lks-pills" }, ...dim.values.map(([value, labelKey]) => (0, react.createElement)("button", {
|
|
11540
|
+
key: value,
|
|
11541
|
+
className: `lks-pill${style[dim.key] === value ? " on" : ""}`,
|
|
11542
|
+
onClick: () => {
|
|
11543
|
+
setStyle((cur) => ({
|
|
11544
|
+
...cur,
|
|
11545
|
+
[dim.key]: cur[dim.key] === value ? null : value
|
|
11546
|
+
}));
|
|
11547
|
+
}
|
|
11548
|
+
}, tr(labelKey)))))), (0, react.createElement)("label", null, tr("settings.profile.motive"), (0, react.createElement)("span", { className: "lks-pills" }, (0, react.createElement)("button", {
|
|
11549
|
+
className: `lks-pill${motive === "" ? " on" : ""}`,
|
|
11550
|
+
onClick: () => {
|
|
11551
|
+
setMotive("");
|
|
11552
|
+
}
|
|
11553
|
+
}, tr("settings.profile.unset")), ...MOTIVE_STAGES.map((stage) => (0, react.createElement)("button", {
|
|
11554
|
+
key: stage,
|
|
11555
|
+
className: `lks-pill${motive === stage ? " on" : ""}`,
|
|
11556
|
+
title: MOTIVE_DISPLAY[stage].tagline,
|
|
11557
|
+
onClick: () => {
|
|
11558
|
+
setMotive(stage);
|
|
11559
|
+
}
|
|
11560
|
+
}, MOTIVE_DISPLAY[stage].name))))), (0, react.createElement)("div", null, (0, react.createElement)("button", {
|
|
11561
|
+
className: "lks-btn primary",
|
|
11562
|
+
"data-testid": "profile-save",
|
|
11563
|
+
onClick: saveAll
|
|
11564
|
+
}, tr("settings.profile.save")), (0, react.createElement)("span", { className: "lks-set-state" }, tr("settings.profile.private"))), suggestions.length > 0 ? (0, react.createElement)("div", {
|
|
11565
|
+
className: "lks-profile-suggests",
|
|
11566
|
+
"data-testid": "profile-suggest-card"
|
|
11567
|
+
}, (0, react.createElement)("h4", null, tr("settings.suggest.head")), ...suggestions.map((sg) => (0, react.createElement)("div", {
|
|
11568
|
+
key: sg.id,
|
|
11569
|
+
className: "lks-profile-suggest"
|
|
11570
|
+
}, (0, react.createElement)("p", null, sg.rationale), (0, react.createElement)("div", { className: "lks-pills" }, (0, react.createElement)("button", {
|
|
11571
|
+
className: "lks-btn primary",
|
|
11572
|
+
"data-testid": "profile-suggest-apply",
|
|
11573
|
+
onClick: () => {
|
|
11574
|
+
fire(resolve(sg.id, true));
|
|
11575
|
+
}
|
|
11576
|
+
}, tr("settings.suggest.apply")), (0, react.createElement)("button", {
|
|
11577
|
+
className: "lks-btn ghost",
|
|
11578
|
+
onClick: () => {
|
|
11579
|
+
fire(resolve(sg.id, false));
|
|
11580
|
+
}
|
|
11581
|
+
}, tr("settings.suggest.ignore")))))) : null);
|
|
11582
|
+
}
|
|
11062
11583
|
/**
|
|
11063
11584
|
* The settings section component. Props from the settings shell (`close`) are
|
|
11064
11585
|
* unused — every control stays on the page.
|
|
11065
11586
|
*/
|
|
11066
11587
|
function StudySettingsSection() {
|
|
11067
|
-
const { data, setMode, activate, setHistoryBudget } = useStudy();
|
|
11588
|
+
const { data, setMode, activate, setHistoryBudget, saveProfile, resolveProfileProposal } = useStudy();
|
|
11068
11589
|
const [error, setError] = (0, react.useState)(null);
|
|
11069
11590
|
const [voice, setVoice] = (0, react.useState)(storedTtsVoice);
|
|
11070
11591
|
const fire = (action) => {
|
|
@@ -11114,7 +11635,15 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
|
|
|
11114
11635
|
days: progress.streak,
|
|
11115
11636
|
best: progress.longestStreak,
|
|
11116
11637
|
freeze: progress.freezeCount
|
|
11117
|
-
}))
|
|
11638
|
+
})), (0, react.createElement)("li", { "data-testid": "recap-mastered" }, tr("settings.stats.mastered", {
|
|
11639
|
+
count: (data?.courses ?? []).reduce((n, c) => n + c.mastered, 0),
|
|
11640
|
+
due: data?.dueCount ?? 0
|
|
11641
|
+
})))), (0, react.createElement)(ProfileSection, {
|
|
11642
|
+
data,
|
|
11643
|
+
save: saveProfile,
|
|
11644
|
+
resolve: resolveProfileProposal,
|
|
11645
|
+
fire
|
|
11646
|
+
}), (0, react.createElement)("section", { className: "lks-set-row" }, (0, react.createElement)("h3", null, tr("settings.about")), (0, react.createElement)("p", { className: "lks-set-hint" }, tr("settings.about.hint")), data?.version ? (0, react.createElement)("a", {
|
|
11118
11647
|
href: `https://github.com/Kaiji-Z/dsh-plugin-lookatstudy/releases/tag/v${data.version}`,
|
|
11119
11648
|
target: "_blank",
|
|
11120
11649
|
rel: "noreferrer"
|