dsh-plugin-lookatstudy 0.22.2 → 0.24.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 +389 -33
- package/lib/client.js.map +1 -1
- package/lib/index.mjs +429 -60
- package/package.json +1 -1
package/lib/index.mjs
CHANGED
|
@@ -228,6 +228,13 @@ const ZH = {
|
|
|
228
228
|
"review.banner": "正在复习:{lesson}",
|
|
229
229
|
"review.threadTitle": "复习:{lesson}",
|
|
230
230
|
"threads.label": "课时线程",
|
|
231
|
+
"threads.scope": "跨课时连续对话",
|
|
232
|
+
"threads.scope.on": "已开启",
|
|
233
|
+
"threads.scope.off": "未开启",
|
|
234
|
+
"threads.coverage": "覆盖",
|
|
235
|
+
"ask.title": "导师想先确认一下",
|
|
236
|
+
"ask.free": "在下方输入框直接回复即可",
|
|
237
|
+
"ask.answered": "已回答",
|
|
231
238
|
"meter.label": "上下文用量",
|
|
232
239
|
"meter.aria": "上下文已用 {pct}%",
|
|
233
240
|
"meter.brk.system": "系统提示",
|
|
@@ -256,6 +263,10 @@ const ZH = {
|
|
|
256
263
|
"rail.due.start": "开始复习",
|
|
257
264
|
"rail.due.none": "暂时没有到期的复习,保持节奏!",
|
|
258
265
|
"chat.reasoning": "思考过程({n} 字)",
|
|
266
|
+
"chat.think": "思考",
|
|
267
|
+
"langview.original": "原文",
|
|
268
|
+
"langview.bilingual": "对照",
|
|
269
|
+
"langview.translation": "译文",
|
|
259
270
|
"msg.speak": "朗读",
|
|
260
271
|
"note.pin": "置顶笔记",
|
|
261
272
|
"note.unpin": "取消置顶",
|
|
@@ -576,6 +587,13 @@ const EN = {
|
|
|
576
587
|
"palette.kind.action": "Action",
|
|
577
588
|
"palette.empty": "No matches",
|
|
578
589
|
"threads.label": "Lesson threads",
|
|
590
|
+
"threads.scope": "Continuous course thread",
|
|
591
|
+
"threads.scope.on": "on",
|
|
592
|
+
"threads.scope.off": "off",
|
|
593
|
+
"threads.coverage": "covers",
|
|
594
|
+
"ask.title": "The tutor wants a quick confirm",
|
|
595
|
+
"ask.free": "Answer in the composer below",
|
|
596
|
+
"ask.answered": "answered",
|
|
579
597
|
"threads.open": "{n} threads",
|
|
580
598
|
"threads.now": "current",
|
|
581
599
|
"threads.new": "New thread (your next message starts it)",
|
|
@@ -616,6 +634,10 @@ const EN = {
|
|
|
616
634
|
"rail.due.start": "Start reviewing",
|
|
617
635
|
"rail.due.none": "Nothing due right now — keep the streak!",
|
|
618
636
|
"chat.reasoning": "Thinking ({n} chars)",
|
|
637
|
+
"chat.think": "Think",
|
|
638
|
+
"langview.original": "Original",
|
|
639
|
+
"langview.bilingual": "Both",
|
|
640
|
+
"langview.translation": "Translation",
|
|
619
641
|
"msg.speak": "Speak",
|
|
620
642
|
"note.pin": "Pin note",
|
|
621
643
|
"note.unpin": "Unpin",
|
|
@@ -1482,6 +1504,23 @@ function computeStreakTransition(state, now = /* @__PURE__ */ new Date()) {
|
|
|
1482
1504
|
};
|
|
1483
1505
|
}
|
|
1484
1506
|
//#endregion
|
|
1507
|
+
//#region src/thread-key.ts
|
|
1508
|
+
/** Resolve the thread-group map key for a send from `lessonId`. Absent scope
|
|
1509
|
+
* is legacy lesson behavior — old files never move. */
|
|
1510
|
+
function threadOwnerKey(scope, courseId, lessonId) {
|
|
1511
|
+
return scope === "course" ? `course:${courseId}` : lessonId;
|
|
1512
|
+
}
|
|
1513
|
+
/** True when a thread-group key names a course-scoped group. Disambiguated
|
|
1514
|
+
* by COLON COUNT, not by prefix alone: a course titled "Course" slugs to
|
|
1515
|
+
* `course`, and its lesson keys (`course:0:0`) live inside the naive
|
|
1516
|
+
* `course:` prefix space (caught live by the B7 trash test). Lesson ids are
|
|
1517
|
+
* always `<slug>:<si>:<li>` — three segments, since slugs strip the colon —
|
|
1518
|
+
* while a course key is exactly `course:<slug>`, two segments. */
|
|
1519
|
+
function isCourseOwnerKey(key) {
|
|
1520
|
+
if (!key.startsWith("course:")) return false;
|
|
1521
|
+
return !key.slice(7).includes(":");
|
|
1522
|
+
}
|
|
1523
|
+
//#endregion
|
|
1485
1524
|
//#region src/state.ts
|
|
1486
1525
|
/**
|
|
1487
1526
|
* Learning state: courses → sections → lessons with mastery-driven gating,
|
|
@@ -1814,7 +1853,7 @@ function deleteCourse(state, courseId) {
|
|
|
1814
1853
|
const [course] = state.courses.splice(i, 1);
|
|
1815
1854
|
const prefix = `${courseId}:`;
|
|
1816
1855
|
const lessonThreads = [];
|
|
1817
|
-
for (const key of Object.keys(state.lessonThreads)) if (key.startsWith(prefix)) {
|
|
1856
|
+
for (const key of Object.keys(state.lessonThreads)) if (key.startsWith(prefix) || key === `course:${courseId}`) {
|
|
1818
1857
|
lessonThreads.push([key, state.lessonThreads[key]]);
|
|
1819
1858
|
delete state.lessonThreads[key];
|
|
1820
1859
|
}
|
|
@@ -1871,7 +1910,11 @@ function restoreCourse(state, courseId) {
|
|
|
1871
1910
|
}));
|
|
1872
1911
|
for (const p of entry.proposals) p.lessonId = remap(p.lessonId);
|
|
1873
1912
|
entry.artifacts = entry.artifacts.map(([k, v]) => [remap(k), v]);
|
|
1874
|
-
entry.lessonThreads = entry.lessonThreads.map(([k, v]) =>
|
|
1913
|
+
entry.lessonThreads = entry.lessonThreads.map(([k, v]) => {
|
|
1914
|
+
if (!isCourseOwnerKey(k)) return [remap(k), v];
|
|
1915
|
+
for (const t of v.threads) if (t.touchedLessons !== void 0) t.touchedLessons = t.touchedLessons.map(remap);
|
|
1916
|
+
return [`course:${id}`, v];
|
|
1917
|
+
});
|
|
1875
1918
|
entry.lessonSessions = entry.lessonSessions.map(([k, v]) => [remap(k), v]);
|
|
1876
1919
|
if (entry.focusLessonId !== null) entry.focusLessonId = remap(entry.focusLessonId);
|
|
1877
1920
|
}
|
|
@@ -2303,25 +2346,67 @@ function recordAnswer(state, lessonId, correct, concept, now) {
|
|
|
2303
2346
|
* @returns completion result including the unlocked lessons.
|
|
2304
2347
|
*/
|
|
2305
2348
|
/**
|
|
2349
|
+
* Set a course's thread granularity (0.23.0). Switching never touches the
|
|
2350
|
+
* sediment: both sides' groups stay put and are resumed when the scope flips
|
|
2351
|
+
* back. Unknown ids fail loud.
|
|
2352
|
+
*/
|
|
2353
|
+
function setCourseThreadScope(state, courseId, scope) {
|
|
2354
|
+
const course = state.courses.find((c) => c.id === courseId);
|
|
2355
|
+
if (course === void 0) throw new Error(`lookatstudy-plugin: unknown course id ${JSON.stringify(courseId)}`);
|
|
2356
|
+
course.threadScope = scope;
|
|
2357
|
+
}
|
|
2358
|
+
/**
|
|
2359
|
+
* Resolve the thread-group map key a send from `lessonId` belongs to — the
|
|
2360
|
+
* 0.23.0 granularity funnel: lesson scope keeps legacy lessonId keys, course
|
|
2361
|
+
* scope namespaces the course. Every thread-group access in this module goes
|
|
2362
|
+
* through here (or through an ownerKey already derived from it).
|
|
2363
|
+
*/
|
|
2364
|
+
function ownerKeyFor(state, lessonId) {
|
|
2365
|
+
const ref = findLesson(state, lessonId);
|
|
2366
|
+
const scope = ref.course.threadScope === "course" ? "course" : "lesson";
|
|
2367
|
+
return {
|
|
2368
|
+
key: threadOwnerKey(scope, ref.course.id, lessonId),
|
|
2369
|
+
scope
|
|
2370
|
+
};
|
|
2371
|
+
}
|
|
2372
|
+
/**
|
|
2373
|
+
* Under course scope, mirrors drift per lesson (each remembers the last
|
|
2374
|
+
* thread IT used) — drift onto a LIVE thread is fine. The moment a thread
|
|
2375
|
+
* leaves the live set (archived, deleted, or rolled off the active pointer),
|
|
2376
|
+
* every mirror still naming it is a dead reference — re-point them at the
|
|
2377
|
+
* group's current active thread (or drop them when none is left). Lesson
|
|
2378
|
+
* scope keeps the legacy exact-mirror behavior at its own key.
|
|
2379
|
+
*/
|
|
2380
|
+
function repointMirrorsOff(state, key, threadId, group) {
|
|
2381
|
+
if (threadId === null || !isCourseOwnerKey(key)) return;
|
|
2382
|
+
for (const lessonId of Object.keys(state.lessonSessions)) if (state.lessonSessions[lessonId] === threadId) {
|
|
2383
|
+
if (group.active === null) delete state.lessonSessions[lessonId];
|
|
2384
|
+
else state.lessonSessions[lessonId] = group.active;
|
|
2385
|
+
}
|
|
2386
|
+
}
|
|
2387
|
+
/**
|
|
2306
2388
|
* Bind a dsh session into a lesson's thread group (the issue-#11 write, the
|
|
2307
2389
|
* upstream sendMessage/ensureThreadForSend translation): an UNKNOWN session id
|
|
2308
2390
|
* appends a new thread (titled by the caller — the first message's text or a
|
|
2309
2391
|
* short action label) and becomes active; a KNOWN id re-activates it (bumping
|
|
2310
2392
|
* lastAt); `null` clears the active pointer (the +新建 affordance — the next
|
|
2311
2393
|
* send mints a fresh thread). The legacy lessonSessions map stays in lockstep
|
|
2312
|
-
* with the active pointer so pre-0.22 readers never disagree.
|
|
2394
|
+
* with the active pointer so pre-0.22 readers never disagree. Under course
|
|
2395
|
+
* scope (0.23.0) the group is the course's, the send-from lesson joins the
|
|
2396
|
+
* thread's touchedLessons coverage, and mirrors sync per
|
|
2397
|
+
* {@link repointRolledMirrors}.
|
|
2313
2398
|
*/
|
|
2314
2399
|
function bindLessonThread(state, lessonId, sessionId, title) {
|
|
2315
|
-
|
|
2400
|
+
const { key, scope } = ownerKeyFor(state, lessonId);
|
|
2316
2401
|
state.lessonThreads ??= {};
|
|
2317
|
-
const group = state.lessonThreads[
|
|
2402
|
+
const group = state.lessonThreads[key] ?? {
|
|
2318
2403
|
active: null,
|
|
2319
2404
|
threads: []
|
|
2320
2405
|
};
|
|
2321
2406
|
if (sessionId === null) {
|
|
2322
2407
|
group.active = null;
|
|
2323
|
-
state.lessonThreads[
|
|
2324
|
-
delete state.lessonSessions[lessonId];
|
|
2408
|
+
state.lessonThreads[key] = group;
|
|
2409
|
+
if (scope === "lesson") delete state.lessonSessions[lessonId];
|
|
2325
2410
|
return group;
|
|
2326
2411
|
}
|
|
2327
2412
|
const existing = group.threads.find((t) => t.id === sessionId);
|
|
@@ -2331,31 +2416,37 @@ function bindLessonThread(state, lessonId, sessionId, title) {
|
|
|
2331
2416
|
id: sessionId,
|
|
2332
2417
|
title: (title ?? "").trim() !== "" ? title.trim() : lessonId,
|
|
2333
2418
|
createdAt: now,
|
|
2334
|
-
lastAt: now
|
|
2419
|
+
lastAt: now,
|
|
2420
|
+
touchedLessons: [lessonId]
|
|
2335
2421
|
});
|
|
2336
2422
|
} else {
|
|
2337
2423
|
existing.lastAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2338
2424
|
if (existing.status === "archived") existing.status = "active";
|
|
2425
|
+
const touched = existing.touchedLessons ??= [];
|
|
2426
|
+
if (!touched.includes(lessonId)) touched.push(lessonId);
|
|
2339
2427
|
}
|
|
2340
2428
|
group.active = sessionId;
|
|
2341
|
-
state.lessonThreads[
|
|
2429
|
+
state.lessonThreads[key] = group;
|
|
2342
2430
|
state.lessonSessions[lessonId] = sessionId;
|
|
2343
2431
|
return group;
|
|
2344
2432
|
}
|
|
2345
2433
|
/** Roll the group's active pointer to the freshest ACTIVE thread (or null)
|
|
2346
2434
|
* and keep the legacy lessonSessions mirror in lockstep. Shared by the
|
|
2347
2435
|
* archive/delete operations when they take out the current thread. */
|
|
2348
|
-
function rollActiveToFresh(state,
|
|
2436
|
+
function rollActiveToFresh(state, key, group) {
|
|
2437
|
+
const prev = group.active;
|
|
2349
2438
|
const live = group.threads.filter((t) => t.status !== "archived");
|
|
2350
2439
|
group.active = live.length > 0 ? live.reduce((a, b) => a.lastAt > b.lastAt ? a : b).id : null;
|
|
2351
|
-
if (
|
|
2352
|
-
else
|
|
2440
|
+
if (isCourseOwnerKey(key)) repointMirrorsOff(state, key, prev, group);
|
|
2441
|
+
else if (group.active !== null) state.lessonSessions[key] = group.active;
|
|
2442
|
+
else delete state.lessonSessions[key];
|
|
2353
2443
|
}
|
|
2354
2444
|
/** Rename a thread's stored title (issue #11 management: upstream gear menu).
|
|
2355
2445
|
* The dsh session's own title is renamed host-side by the client (best
|
|
2356
2446
|
* effort) — this is the durable plugin-side half. Throws on unknown ids. */
|
|
2357
2447
|
function renameLessonThread(state, lessonId, sessionId, title) {
|
|
2358
|
-
const
|
|
2448
|
+
const key = ownerKeyFor(state, lessonId).key;
|
|
2449
|
+
const group = state.lessonThreads?.[key];
|
|
2359
2450
|
if (group === void 0) throw new Error(`lookatstudy-plugin: lesson ${JSON.stringify(lessonId)} has no thread group`);
|
|
2360
2451
|
const thread = group.threads.find((t) => t.id === sessionId);
|
|
2361
2452
|
if (thread === void 0) throw new Error(`lookatstudy-plugin: lesson ${JSON.stringify(lessonId)} has no thread ${JSON.stringify(sessionId)}`);
|
|
@@ -2368,12 +2459,14 @@ function renameLessonThread(state, lessonId, sessionId, title) {
|
|
|
2368
2459
|
* threads leave the switcher list entirely (no unarchive entry there; the
|
|
2369
2460
|
* state API keeps `archived: false` for symmetry/future UI). */
|
|
2370
2461
|
function archiveLessonThread(state, lessonId, sessionId, archived) {
|
|
2371
|
-
const
|
|
2462
|
+
const key = ownerKeyFor(state, lessonId).key;
|
|
2463
|
+
const group = state.lessonThreads?.[key];
|
|
2372
2464
|
if (group === void 0) throw new Error(`lookatstudy-plugin: lesson ${JSON.stringify(lessonId)} has no thread group`);
|
|
2373
2465
|
const thread = group.threads.find((t) => t.id === sessionId);
|
|
2374
2466
|
if (thread === void 0) throw new Error(`lookatstudy-plugin: lesson ${JSON.stringify(lessonId)} has no thread ${JSON.stringify(sessionId)}`);
|
|
2375
2467
|
thread.status = archived ? "archived" : "active";
|
|
2376
|
-
if (archived && group.active === sessionId) rollActiveToFresh(state,
|
|
2468
|
+
if (archived && group.active === sessionId) rollActiveToFresh(state, key, group);
|
|
2469
|
+
if (archived) repointMirrorsOff(state, key, sessionId, group);
|
|
2377
2470
|
if (!archived && group.active === null) {
|
|
2378
2471
|
group.active = sessionId;
|
|
2379
2472
|
state.lessonSessions[lessonId] = sessionId;
|
|
@@ -2385,12 +2478,14 @@ function archiveLessonThread(state, lessonId, sessionId, archived) {
|
|
|
2385
2478
|
* the host session face (no delete primitive) — it stays in the host's session
|
|
2386
2479
|
* list; the UI discloses this. Throws on unknown ids. */
|
|
2387
2480
|
function deleteLessonThread(state, lessonId, sessionId) {
|
|
2388
|
-
const
|
|
2481
|
+
const key = ownerKeyFor(state, lessonId).key;
|
|
2482
|
+
const group = state.lessonThreads?.[key];
|
|
2389
2483
|
if (group === void 0) throw new Error(`lookatstudy-plugin: lesson ${JSON.stringify(lessonId)} has no thread group`);
|
|
2390
2484
|
const idx = group.threads.findIndex((t) => t.id === sessionId);
|
|
2391
2485
|
if (idx < 0) throw new Error(`lookatstudy-plugin: lesson ${JSON.stringify(lessonId)} has no thread ${JSON.stringify(sessionId)}`);
|
|
2392
2486
|
group.threads.splice(idx, 1);
|
|
2393
|
-
if (group.active === sessionId) rollActiveToFresh(state,
|
|
2487
|
+
if (group.active === sessionId) rollActiveToFresh(state, key, group);
|
|
2488
|
+
repointMirrorsOff(state, key, sessionId, group);
|
|
2394
2489
|
return group;
|
|
2395
2490
|
}
|
|
2396
2491
|
function completeLesson(state, lessonId, now) {
|
|
@@ -2965,6 +3060,7 @@ function workbenchState(state, now) {
|
|
|
2965
3060
|
return {
|
|
2966
3061
|
courseId: course.id,
|
|
2967
3062
|
title: course.title,
|
|
3063
|
+
threadScope: course.threadScope === "course" ? "course" : "lesson",
|
|
2968
3064
|
mastered: lessons.filter((l) => l.status === "mastered").length,
|
|
2969
3065
|
total: lessons.length,
|
|
2970
3066
|
avgMasteryPct: answered.length === 0 ? null : Math.round(answered.reduce((sum, l) => sum + (l.mastery ?? 0), 0) / answered.length * 100),
|
|
@@ -3025,9 +3121,14 @@ function workbenchState(state, now) {
|
|
|
3025
3121
|
quote: n.quote,
|
|
3026
3122
|
pinned: n.pinned === true
|
|
3027
3123
|
})),
|
|
3028
|
-
html: renderMarkdown(normalizeMathNotation(ref.lesson.
|
|
3124
|
+
html: renderMarkdown(normalizeMathNotation(ref.lesson.body)),
|
|
3029
3125
|
markdown: ref.lesson.body,
|
|
3030
|
-
speechText: normalizeSpeechText(normalizeMathNotation(ref.lesson.body))
|
|
3126
|
+
speechText: normalizeSpeechText(normalizeMathNotation(ref.lesson.body)),
|
|
3127
|
+
...ref.lesson.translation !== void 0 ? {
|
|
3128
|
+
bilingualHtml: renderMarkdown(normalizeMathNotation(renderBilingual(ref.lesson.body, ref.lesson.translation))),
|
|
3129
|
+
translationHtml: renderMarkdown(normalizeMathNotation(ref.lesson.translation)),
|
|
3130
|
+
translationLang: ref.lesson.translationLang ?? ""
|
|
3131
|
+
} : {}
|
|
3031
3132
|
};
|
|
3032
3133
|
} catch {
|
|
3033
3134
|
lesson = null;
|
|
@@ -3272,6 +3373,7 @@ function registerDashboard(webServer, deps) {
|
|
|
3272
3373
|
}
|
|
3273
3374
|
try {
|
|
3274
3375
|
const ref = findLesson(deps.store.get(), body.lessonId);
|
|
3376
|
+
if (ref.lesson.kind !== "exam" && ref.lesson.status === "available") attemptLesson(deps.store.get(), ref.lesson.id, /* @__PURE__ */ new Date());
|
|
3275
3377
|
deps.store.get().focus = { lessonId: ref.lesson.id };
|
|
3276
3378
|
deps.store.save();
|
|
3277
3379
|
sendJson(res, 200, { ok: true });
|
|
@@ -3662,6 +3764,7 @@ function registerDashboard(webServer, deps) {
|
|
|
3662
3764
|
return;
|
|
3663
3765
|
}
|
|
3664
3766
|
const state = deps.store.get();
|
|
3767
|
+
let group = null;
|
|
3665
3768
|
try {
|
|
3666
3769
|
if (body.op === "rename") {
|
|
3667
3770
|
if (typeof body.title !== "string" || body.title.trim() === "") {
|
|
@@ -3671,7 +3774,7 @@ function registerDashboard(webServer, deps) {
|
|
|
3671
3774
|
});
|
|
3672
3775
|
return;
|
|
3673
3776
|
}
|
|
3674
|
-
renameLessonThread(state, body.lessonId, body.sessionId, body.title);
|
|
3777
|
+
group = renameLessonThread(state, body.lessonId, body.sessionId, body.title);
|
|
3675
3778
|
} else if (body.op === "archive") {
|
|
3676
3779
|
if (typeof body.archived !== "boolean") {
|
|
3677
3780
|
sendJson(res, 400, {
|
|
@@ -3680,8 +3783,8 @@ function registerDashboard(webServer, deps) {
|
|
|
3680
3783
|
});
|
|
3681
3784
|
return;
|
|
3682
3785
|
}
|
|
3683
|
-
archiveLessonThread(state, body.lessonId, body.sessionId, body.archived);
|
|
3684
|
-
} else deleteLessonThread(state, body.lessonId, body.sessionId);
|
|
3786
|
+
group = archiveLessonThread(state, body.lessonId, body.sessionId, body.archived);
|
|
3787
|
+
} else group = deleteLessonThread(state, body.lessonId, body.sessionId);
|
|
3685
3788
|
} catch (error) {
|
|
3686
3789
|
sendJson(res, 404, {
|
|
3687
3790
|
ok: false,
|
|
@@ -3690,7 +3793,6 @@ function registerDashboard(webServer, deps) {
|
|
|
3690
3793
|
return;
|
|
3691
3794
|
}
|
|
3692
3795
|
deps.store.save();
|
|
3693
|
-
const group = state.lessonThreads[body.lessonId];
|
|
3694
3796
|
sendJson(res, 200, {
|
|
3695
3797
|
ok: true,
|
|
3696
3798
|
active: group?.active ?? null,
|
|
@@ -3698,6 +3800,32 @@ function registerDashboard(webServer, deps) {
|
|
|
3698
3800
|
});
|
|
3699
3801
|
return;
|
|
3700
3802
|
}
|
|
3803
|
+
if (req.method === "POST" && pathname === "/lookatstudy/api/course-scope") {
|
|
3804
|
+
const body = await readJsonBodySafe(req, res);
|
|
3805
|
+
if (body === void 0) return;
|
|
3806
|
+
if (typeof body.courseId !== "string" || body.scope !== "lesson" && body.scope !== "course") {
|
|
3807
|
+
sendJson(res, 400, {
|
|
3808
|
+
ok: false,
|
|
3809
|
+
error: "courseId and scope (lesson|course) required"
|
|
3810
|
+
});
|
|
3811
|
+
return;
|
|
3812
|
+
}
|
|
3813
|
+
try {
|
|
3814
|
+
setCourseThreadScope(deps.store.get(), body.courseId, body.scope);
|
|
3815
|
+
} catch (error) {
|
|
3816
|
+
sendJson(res, 404, {
|
|
3817
|
+
ok: false,
|
|
3818
|
+
error: error instanceof Error ? error.message : String(error)
|
|
3819
|
+
});
|
|
3820
|
+
return;
|
|
3821
|
+
}
|
|
3822
|
+
deps.store.save();
|
|
3823
|
+
sendJson(res, 200, {
|
|
3824
|
+
ok: true,
|
|
3825
|
+
scope: body.scope
|
|
3826
|
+
});
|
|
3827
|
+
return;
|
|
3828
|
+
}
|
|
3701
3829
|
if (req.method === "GET" && pathname === "/lookatstudy/api/course-pack") {
|
|
3702
3830
|
const courseId = new URL(req.url ?? "/", "http://x").searchParams.get("courseId") ?? "";
|
|
3703
3831
|
const course = findCourseForPack(deps.store.get(), courseId);
|
|
@@ -3779,6 +3907,26 @@ function registerDashboard(webServer, deps) {
|
|
|
3779
3907
|
});
|
|
3780
3908
|
return;
|
|
3781
3909
|
}
|
|
3910
|
+
if (req.method === "POST" && pathname === "/lookatstudy/api/prefs") {
|
|
3911
|
+
const body = await readJsonBodySafe(req, res);
|
|
3912
|
+
if (body === void 0) return;
|
|
3913
|
+
if (typeof body.interfaceLang !== "string" || body.interfaceLang === "" || body.interfaceLang.length > 35) {
|
|
3914
|
+
sendJson(res, 400, {
|
|
3915
|
+
ok: false,
|
|
3916
|
+
error: "interfaceLang (non-empty string, max 35 chars) required"
|
|
3917
|
+
});
|
|
3918
|
+
return;
|
|
3919
|
+
}
|
|
3920
|
+
if (deps.store.get().interfaceLang !== body.interfaceLang) {
|
|
3921
|
+
deps.store.get().interfaceLang = body.interfaceLang;
|
|
3922
|
+
deps.store.save();
|
|
3923
|
+
}
|
|
3924
|
+
sendJson(res, 200, {
|
|
3925
|
+
ok: true,
|
|
3926
|
+
interfaceLang: body.interfaceLang
|
|
3927
|
+
});
|
|
3928
|
+
return;
|
|
3929
|
+
}
|
|
3782
3930
|
if (req.method === "POST" && pathname === "/lookatstudy/api/attachment") {
|
|
3783
3931
|
const body = await readJsonBodySafe(req, res, 29360128);
|
|
3784
3932
|
if (body === void 0) return;
|
|
@@ -4863,6 +5011,104 @@ async function fetchRepoFileTree(owner, repo, branch, _fetchFn, signal) {
|
|
|
4863
5011
|
}
|
|
4864
5012
|
/** 文件数上限(防爆,和 IPC handler 一致) */
|
|
4865
5013
|
const MAX_FILES = 500;
|
|
5014
|
+
/** 从 markdown 链接中提取翻译语言列表 */
|
|
5015
|
+
function extractLanguagesFromReadme(readmeMd) {
|
|
5016
|
+
const pattern = /\[([^\]]+)\]\(\.?\/?translations\/([^/)]+)\/README\.md\)/g;
|
|
5017
|
+
const langs = [];
|
|
5018
|
+
const seen = /* @__PURE__ */ new Set();
|
|
5019
|
+
let m;
|
|
5020
|
+
while ((m = pattern.exec(readmeMd)) !== null) {
|
|
5021
|
+
const name = m[1].trim();
|
|
5022
|
+
const code = m[2].trim();
|
|
5023
|
+
if (!seen.has(code)) {
|
|
5024
|
+
seen.add(code);
|
|
5025
|
+
langs.push({
|
|
5026
|
+
code,
|
|
5027
|
+
name
|
|
5028
|
+
});
|
|
5029
|
+
}
|
|
5030
|
+
}
|
|
5031
|
+
return langs;
|
|
5032
|
+
}
|
|
5033
|
+
/**
|
|
5034
|
+
* 净化翻译版 markdown —— 翻译内容是 CDN 原样拉取的,未经原文管道的
|
|
5035
|
+
* code-fence-aware parser 处理,可能含畸形结构导致 react-markdown 崩溃。
|
|
5036
|
+
*
|
|
5037
|
+
* 处理:
|
|
5038
|
+
* 1. 未闭合代码围栏:统计 ``` 和 ~~~ 数量,奇数则补一个闭合围栏
|
|
5039
|
+
* 2. 去除 <script>/<style>/<iframe> 等危险 HTML(防 XSS + 防渲染崩溃)
|
|
5040
|
+
* 3. 去 BOM、统一换行
|
|
5041
|
+
*
|
|
5042
|
+
* 这是确定性规则处理(高置信度),不是 LLM 判断 —— 格式修复是规则擅长的。
|
|
5043
|
+
*/
|
|
5044
|
+
function sanitizeTranslatedMarkdown(md) {
|
|
5045
|
+
let s = md.replace(/^\uFEFF/, "");
|
|
5046
|
+
s = s.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
5047
|
+
const lines = s.split("\n");
|
|
5048
|
+
let fence = null;
|
|
5049
|
+
for (const line of lines) {
|
|
5050
|
+
const m = line.match(/^\s*(```|~~~)/);
|
|
5051
|
+
if (m) fence = fence ? null : m[1];
|
|
5052
|
+
}
|
|
5053
|
+
if (fence) s = s + "\n" + fence + "\n";
|
|
5054
|
+
s = s.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, "");
|
|
5055
|
+
s = s.replace(/<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style>/gi, "");
|
|
5056
|
+
s = s.replace(/<\/?(iframe|object|embed)\b[^>]*>/gi, "");
|
|
5057
|
+
return s.trim();
|
|
5058
|
+
}
|
|
5059
|
+
/**
|
|
5060
|
+
* 拉取翻译版课程内容。
|
|
5061
|
+
*
|
|
5062
|
+
* 策略:不依赖翻译版 README 的链接(很多翻译只翻译了大纲,README 里没有 lesson 链接,
|
|
5063
|
+
* 或链接指向其他语言翻译)。直接用原文课程的文件路径,在前面加 translations/<code>/
|
|
5064
|
+
* 前缀去探测翻译版是否存在。5 并发拉取,404 跳过(该课无翻译)。
|
|
5065
|
+
*
|
|
5066
|
+
* @returns Map<originalPath, { title, content }> — key 是原文路径
|
|
5067
|
+
*/
|
|
5068
|
+
async function fetchTranslatedContent(owner, repo, branch, langCode, originalFiles, fetchFn, onProgress) {
|
|
5069
|
+
const send = (msg) => onProgress?.(msg);
|
|
5070
|
+
const result = /* @__PURE__ */ new Map();
|
|
5071
|
+
const transReadmeUrl = cdnUrl(owner, repo, branch, `translations/${langCode}/README.md`);
|
|
5072
|
+
send(`检查翻译版 README (${langCode})…`);
|
|
5073
|
+
try {
|
|
5074
|
+
const r = await fetchFn(transReadmeUrl);
|
|
5075
|
+
if (!r.ok) {
|
|
5076
|
+
send(`翻译版不存在 (${r.status}),跳过`);
|
|
5077
|
+
return result;
|
|
5078
|
+
}
|
|
5079
|
+
} catch {
|
|
5080
|
+
send("翻译版检查失败,跳过");
|
|
5081
|
+
return result;
|
|
5082
|
+
}
|
|
5083
|
+
const mdFiles = originalFiles.filter((f) => !f.path.toLowerCase().endsWith(".ipynb"));
|
|
5084
|
+
send(`探测 ${mdFiles.length} 个文件的翻译版(${langCode})…`);
|
|
5085
|
+
const transPrefix = `translations/${langCode}/`;
|
|
5086
|
+
const CONCURRENCY = 5;
|
|
5087
|
+
let done = 0;
|
|
5088
|
+
for (let i = 0; i < mdFiles.length; i += CONCURRENCY) {
|
|
5089
|
+
const batch = mdFiles.slice(i, i + CONCURRENCY);
|
|
5090
|
+
const results = await Promise.allSettled(batch.map(async (file) => {
|
|
5091
|
+
const r = await fetchFn(cdnUrl(owner, repo, branch, transPrefix + file.path));
|
|
5092
|
+
if (!r.ok) return null;
|
|
5093
|
+
const md = await r.text();
|
|
5094
|
+
return {
|
|
5095
|
+
originalPath: file.path,
|
|
5096
|
+
title: file.title,
|
|
5097
|
+
content: sanitizeTranslatedMarkdown(md)
|
|
5098
|
+
};
|
|
5099
|
+
}));
|
|
5100
|
+
for (const res of results) {
|
|
5101
|
+
done++;
|
|
5102
|
+
if (res.status === "fulfilled" && res.value) result.set(res.value.originalPath, {
|
|
5103
|
+
title: res.value.title,
|
|
5104
|
+
content: res.value.content
|
|
5105
|
+
});
|
|
5106
|
+
}
|
|
5107
|
+
if (done % 10 === 0 || done === mdFiles.length) send(`翻译探测 ${done}/${mdFiles.length}(命中 ${result.size})`);
|
|
5108
|
+
}
|
|
5109
|
+
send(`翻译版拉取完成: ${result.size}/${mdFiles.length} 文件有翻译`);
|
|
5110
|
+
return result;
|
|
5111
|
+
}
|
|
4866
5112
|
/**
|
|
4867
5113
|
* Step 1: 拉取仓库清单 —— README 全文 + 完整目录树 + 课程文件列表。
|
|
4868
5114
|
*
|
|
@@ -5958,6 +6204,42 @@ function estimateTokens(text) {
|
|
|
5958
6204
|
return Math.ceil(cjk * 11 / 10 + other / 4);
|
|
5959
6205
|
}
|
|
5960
6206
|
/**
|
|
6207
|
+
* Match the learner's interface language against the repo's declared
|
|
6208
|
+
* translation languages (0.24.0): exact code first (case-insensitive), then
|
|
6209
|
+
* the base subtag family — a zh-CN host matches a zh-cn mirror, pt-PT still
|
|
6210
|
+
* finds pt-br. No family match (or nothing recorded) returns null and the
|
|
6211
|
+
* import stays original-language. Pure.
|
|
6212
|
+
*/
|
|
6213
|
+
function matchTranslationLang(want, available) {
|
|
6214
|
+
if (want === void 0 || want.trim() === "" || available.length === 0) return null;
|
|
6215
|
+
const norm = (code) => code.trim().toLowerCase();
|
|
6216
|
+
const exact = available.find((a) => norm(a.code) === norm(want));
|
|
6217
|
+
if (exact !== void 0) return exact;
|
|
6218
|
+
const base = (code) => norm(code).split("-")[0];
|
|
6219
|
+
const wantBase = base(want);
|
|
6220
|
+
return available.find((a) => base(a.code) === wantBase) ?? null;
|
|
6221
|
+
}
|
|
6222
|
+
/**
|
|
6223
|
+
* 0.23.1 issue 5: the pacing gate's hard cap (the brief's 3000-8000 target's
|
|
6224
|
+
* upper edge). A sliced lesson over this WITH splittable in-span headings is
|
|
6225
|
+
* rejected — the brief has always carried the rule, but nothing enforced it,
|
|
6226
|
+
* so a flash-tier tutor's 1:1 file→lesson design shipped 45k-char lessons.
|
|
6227
|
+
*/
|
|
6228
|
+
const LESSON_MAX_CHARS = 8e3;
|
|
6229
|
+
/**
|
|
6230
|
+
* Count the in-span headings a lesson could be split by: direct children for
|
|
6231
|
+
* an anchored lesson (an H2 anchor's span ends at the next ≤-level heading,
|
|
6232
|
+
* so its H3s are the split unit; an H3 anchor is the terminal unit — nothing
|
|
6233
|
+
* deeper exists to split by), any H2/H3 for a whole-file lesson.
|
|
6234
|
+
*/
|
|
6235
|
+
function splittableHeadingCount(headings, titleIndex) {
|
|
6236
|
+
if (titleIndex < 0) return headings.filter((h) => h.level >= 2).length;
|
|
6237
|
+
const anchorLevel = headings[titleIndex].level;
|
|
6238
|
+
let count = 0;
|
|
6239
|
+
for (let i = titleIndex + 1; i < headings.length && headings[i].level > anchorLevel; i++) if (headings[i].level === anchorLevel + 1) count++;
|
|
6240
|
+
return count;
|
|
6241
|
+
}
|
|
6242
|
+
/**
|
|
5961
6243
|
* Build the pending design from Step 1 (inventory) + Step 3 (outlines).
|
|
5962
6244
|
* @param url - the GitHub URL the learner asked to import.
|
|
5963
6245
|
* @param owner - repo owner.
|
|
@@ -6130,6 +6412,7 @@ function renderDesignBrief(pending, part = 1) {
|
|
|
6130
6412
|
}
|
|
6131
6413
|
lines.push("");
|
|
6132
6414
|
lines.push("### Design rules (from the LookatStudy import pipeline)");
|
|
6415
|
+
if (pending.availableLangs !== void 0 && pending.availableLangs.length > 0) lines.push(`Translations available in this repo: ${pending.availableLangs.map((l) => `${l.name} (${l.code})`).join(", ")}. study_apply_design automatically pairs the learner's interface-language mirror (translations/<code>/ file copies) with each lesson — no action needed from you; mention the availability to the learner.`);
|
|
6133
6416
|
lines.push("Classify every lesson as exactly one of:");
|
|
6134
6417
|
lines.push("- **study**: explanation/theory/tutorial content — the learning-world spine, its own lesson.");
|
|
6135
6418
|
lines.push("- **practice**: Exercise / Lab / notebook — its own lesson.");
|
|
@@ -6143,7 +6426,7 @@ function renderDesignBrief(pending, part = 1) {
|
|
|
6143
6426
|
lines.push("Other rules:");
|
|
6144
6427
|
lines.push("- role hints are references, not verdicts — README tables usually mark real roles (Lesson link = study, Notebook/Lab = practice).");
|
|
6145
6428
|
lines.push("- if the directory layout is already clear (e.g. lessons/N-Topic/), keep its sections; do not over-reorganize.");
|
|
6146
|
-
lines.push(`- anchor is the full heading text used to slice the body; omit it for whole-file lessons.${shown.length > 80 ? " This part is large: design at file granularity (omit anchors, one lesson per file) to keep the JSON manageable." : ""}`);
|
|
6429
|
+
lines.push(`- anchor is the full heading text used to slice the body; omit it for whole-file lessons.${shown.length > 80 ? " This part is large: design at file granularity (omit anchors, one lesson per file) to keep the JSON manageable — EXCEPT files over 8000 chars: those still need anchor splitting (anchor = an H2/H3 heading) into 3000-8000-char lessons; study_apply_design rejects over-long lessons whose file has splittable headings." : ""}`);
|
|
6147
6430
|
lines.push("");
|
|
6148
6431
|
lines.push("### Language-course check (decide while designing)");
|
|
6149
6432
|
lines.push("Is this course teaching a language ITSELF (vocabulary, grammar, reading/writing/listening of English/Japanese/Korean/Chinese/…), rather than a knowledge/tech course that merely happens to be written in some language? Typical tells: vocab lists, grammar-point explanations, dialogues/readings, sentence-translation pairs, level markers (N5/N1, TOEFL/IELTS, HSK, JLPT), pronunciation content.");
|
|
@@ -6330,42 +6613,56 @@ function buildCourseFromDesign(courseTitle, validated, contents, translations) {
|
|
|
6330
6613
|
return cached;
|
|
6331
6614
|
};
|
|
6332
6615
|
const firstLessonSeen = /* @__PURE__ */ new Set();
|
|
6333
|
-
|
|
6334
|
-
|
|
6335
|
-
|
|
6336
|
-
const
|
|
6337
|
-
|
|
6338
|
-
|
|
6339
|
-
|
|
6340
|
-
|
|
6341
|
-
|
|
6342
|
-
|
|
6343
|
-
|
|
6344
|
-
|
|
6345
|
-
|
|
6346
|
-
|
|
6347
|
-
|
|
6348
|
-
|
|
6349
|
-
|
|
6350
|
-
|
|
6351
|
-
|
|
6352
|
-
|
|
6353
|
-
|
|
6354
|
-
|
|
6355
|
-
world: lesson.world,
|
|
6356
|
-
...translation !== void 0 ? {
|
|
6357
|
-
translation,
|
|
6358
|
-
translationLang: pair.lang
|
|
6359
|
-
} : {}
|
|
6360
|
-
};
|
|
6616
|
+
const pacingViolations = [];
|
|
6617
|
+
const sections = validated.sections.map((section) => {
|
|
6618
|
+
const lessons = section.lessons.map((lesson) => {
|
|
6619
|
+
const content = contents.get(lesson.file);
|
|
6620
|
+
if (content === void 0) throw new Error(`lookatstudy-plugin: no fetched content for designed file ${JSON.stringify(lesson.file)} — fetch failed earlier; retry study_apply_design`);
|
|
6621
|
+
const headings = headingsOf(lesson.file);
|
|
6622
|
+
const titleIndex = lesson.anchor === null ? -1 : findTitleIndex(headings, lesson.anchor);
|
|
6623
|
+
const isFirst = !firstLessonSeen.has(lesson.file);
|
|
6624
|
+
firstLessonSeen.add(lesson.file);
|
|
6625
|
+
const pair = translations?.get(lesson.file);
|
|
6626
|
+
let translation;
|
|
6627
|
+
if (pair !== void 0) {
|
|
6628
|
+
const tHeadings = extractHeadings(pair.content);
|
|
6629
|
+
const tIndex = lesson.anchor === null ? -1 : findTitleIndex(tHeadings, lesson.anchor);
|
|
6630
|
+
translation = sliceLessonBody(pair.content, tHeadings, tIndex, isFirst);
|
|
6631
|
+
}
|
|
6632
|
+
const body = sliceLessonBody(content, headings, titleIndex, isFirst);
|
|
6633
|
+
if (body.length > 8e3 && splittableHeadingCount(headings, titleIndex) > 0) pacingViolations.push({
|
|
6634
|
+
title: lesson.title,
|
|
6635
|
+
file: lesson.file,
|
|
6636
|
+
chars: body.length,
|
|
6637
|
+
splittable: splittableHeadingCount(headings, titleIndex)
|
|
6361
6638
|
});
|
|
6362
6639
|
return {
|
|
6363
|
-
title:
|
|
6364
|
-
anchor: slugAnchor(
|
|
6365
|
-
|
|
6366
|
-
|
|
6640
|
+
title: lesson.title,
|
|
6641
|
+
anchor: slugAnchor(lesson.title),
|
|
6642
|
+
body,
|
|
6643
|
+
sourceFilePath: lesson.file,
|
|
6644
|
+
world: lesson.world,
|
|
6645
|
+
...translation !== void 0 ? {
|
|
6646
|
+
translation,
|
|
6647
|
+
translationLang: pair.lang
|
|
6648
|
+
} : {}
|
|
6367
6649
|
};
|
|
6368
|
-
})
|
|
6650
|
+
});
|
|
6651
|
+
return {
|
|
6652
|
+
title: section.title,
|
|
6653
|
+
anchor: slugAnchor(section.title),
|
|
6654
|
+
world: lessons.every((l) => l.world === "practice") ? "practice" : "study",
|
|
6655
|
+
lessons
|
|
6656
|
+
};
|
|
6657
|
+
});
|
|
6658
|
+
if (pacingViolations.length > 0) {
|
|
6659
|
+
const listed = pacingViolations.slice(0, 5).map((v) => `「${v.title}」 ${v.file} (${v.chars} chars, ${v.splittable} splittable heading${v.splittable === 1 ? "" : "s"})`).join("; ");
|
|
6660
|
+
const more = pacingViolations.length > 5 ? ` (+${pacingViolations.length - 5} more)` : "";
|
|
6661
|
+
throw new Error(`lookatstudy-plugin: pacing gate — ${pacingViolations.length} lesson${pacingViolations.length === 1 ? "" : "s"} over ${LESSON_MAX_CHARS} chars while the file has splittable H2/H3 sections (design rule: 3000-8000 chars per lesson): ${listed}${more}. Split each into one lesson per H2/H3 (anchor = the heading's full title, merge fragments under 1000 chars), then re-submit study_apply_design`);
|
|
6662
|
+
}
|
|
6663
|
+
return {
|
|
6664
|
+
title: courseTitle,
|
|
6665
|
+
sections
|
|
6369
6666
|
};
|
|
6370
6667
|
}
|
|
6371
6668
|
//#endregion
|
|
@@ -7028,6 +7325,26 @@ function studyTools(store, deps = {}) {
|
|
|
7028
7325
|
type: "integer",
|
|
7029
7326
|
description: "Pending-design sequence (audit C29): renders detect stale briefs."
|
|
7030
7327
|
},
|
|
7328
|
+
availableLangs: {
|
|
7329
|
+
type: "array",
|
|
7330
|
+
description: "Translation languages the repo README declares (translations/<code>/ mirrors) — apply pairs the learner's interface language automatically.",
|
|
7331
|
+
items: {
|
|
7332
|
+
type: "object",
|
|
7333
|
+
additionalProperties: false,
|
|
7334
|
+
properties: {
|
|
7335
|
+
code: {
|
|
7336
|
+
type: "string",
|
|
7337
|
+
required: true,
|
|
7338
|
+
description: "BCP-47-ish code as it appears in the translations/ path."
|
|
7339
|
+
},
|
|
7340
|
+
name: {
|
|
7341
|
+
type: "string",
|
|
7342
|
+
required: true,
|
|
7343
|
+
description: "Display name from the README link text."
|
|
7344
|
+
}
|
|
7345
|
+
}
|
|
7346
|
+
}
|
|
7347
|
+
},
|
|
7031
7348
|
part: {
|
|
7032
7349
|
type: "integer",
|
|
7033
7350
|
description: "Brief part this call rendered (context-budget batching)."
|
|
@@ -7131,6 +7448,10 @@ function studyTools(store, deps = {}) {
|
|
|
7131
7448
|
courseTitle: partCount > 1 ? `${pd.courseTitle} (part ${part})` : pd.courseTitle,
|
|
7132
7449
|
fileCount: planBriefParts(pd.files)[Math.min(part, partCount) - 1].length,
|
|
7133
7450
|
fullTreeCount: pd.fullTreeCount,
|
|
7451
|
+
...pd.availableLangs !== void 0 && pd.availableLangs.length > 0 ? { availableLangs: pd.availableLangs.map((l) => ({
|
|
7452
|
+
code: l.code,
|
|
7453
|
+
name: l.name
|
|
7454
|
+
})) } : {},
|
|
7134
7455
|
...partCount > 1 ? {
|
|
7135
7456
|
part,
|
|
7136
7457
|
partCount
|
|
@@ -7151,6 +7472,8 @@ function studyTools(store, deps = {}) {
|
|
|
7151
7472
|
};
|
|
7152
7473
|
const inventory = await fetchRepoInventory(owner, repo, resolvedBranch, fetchFn, void 0, exec.signal);
|
|
7153
7474
|
const pd = buildPendingDesign(canonicalRef, owner, repo, inventory, await fetchFileOutlines(inventory.fileList.map((f) => f.path), owner, repo, inventory.branch, fetchFn, void 0, exec.signal));
|
|
7475
|
+
const availableLangs = extractLanguagesFromReadme(inventory.readmeMd);
|
|
7476
|
+
if (availableLangs.length > 0) pd.availableLangs = availableLangs;
|
|
7154
7477
|
setPendingDesign(pd);
|
|
7155
7478
|
if (pd.files.length === 0) throw new Error("lookatstudy-plugin: course files were discovered but no outlines could be fetched (CDN unreachable?)");
|
|
7156
7479
|
pendingPart = part;
|
|
@@ -7287,7 +7610,11 @@ ${p.text}`));
|
|
|
7287
7610
|
parameters: {
|
|
7288
7611
|
languageTarget: {
|
|
7289
7612
|
type: "string",
|
|
7290
|
-
description: "Set ONLY when this course teaches a language ITSELF (vocabulary, grammar, reading/writing of English/Japanese/Chinese/…) rather than being a knowledge/tech course that merely happens to be written in some language: the taught language as a BCP-47 tag (\"en\", \"ja\", \"zh-CN\", …). The writing language is NOT the taught language — a Chinese-written English-textbook repo has languageTarget \"en\". Typical tells: vocab lists, grammar points, dialogues/readings, sentence-translation pairs, level markers (N5/N1, TOEFL, HSK, JLPT), pronunciation content. When unsure, omit it — a normal course misflagged as a language course is worse than the reverse."
|
|
7613
|
+
description: "Set ONLY when this course teaches a language ITSELF (vocabulary, grammar, reading/writing of English/Japanese/Chinese/…) rather than being a knowledge/tech course that merely happens to be written in some language: the taught language as a BCP-47 tag (\"en\", \"ja\", \"zh-CN\", …). The writing language is NOT the taught language — a Chinese-written English-textbook repo has languageTarget \"en\". Typical tells: vocab lists, grammar points, dialogues/readings, sentence-translation pairs, level markers (N5/N1, TOEFL/IELTS, HSK, JLPT), pronunciation content. When unsure, omit it — a normal course misflagged as a language course is worse than the reverse."
|
|
7614
|
+
},
|
|
7615
|
+
translationLang: {
|
|
7616
|
+
type: "string",
|
|
7617
|
+
description: "Override the translation mirror language (0.24.0). Default: the learner's interface language, matched against the repo's declared translations (zh-CN matches zh-cn, en-US falls back to en). Set this only when the LEARNER explicitly asks for a different language than their interface."
|
|
7291
7618
|
},
|
|
7292
7619
|
sections: {
|
|
7293
7620
|
type: "array",
|
|
@@ -7367,6 +7694,11 @@ ${p.text}`));
|
|
|
7367
7694
|
type: "integer",
|
|
7368
7695
|
required: true
|
|
7369
7696
|
},
|
|
7697
|
+
translations: {
|
|
7698
|
+
type: "integer",
|
|
7699
|
+
required: true,
|
|
7700
|
+
description: "Lessons that landed with a paired translation (the interface-language mirror existed and its anchor matched) — their teach tab offers 原文/对照/译文."
|
|
7701
|
+
},
|
|
7370
7702
|
created: {
|
|
7371
7703
|
type: "boolean",
|
|
7372
7704
|
required: true,
|
|
@@ -7380,7 +7712,7 @@ ${p.text}`));
|
|
|
7380
7712
|
},
|
|
7381
7713
|
render: (_args, value) => [{
|
|
7382
7714
|
type: "text",
|
|
7383
|
-
text: value.created === false ? `“${value.title}” (id ${value.courseId}) was ALREADY imported — the design did not create a new course. Continue with the existing course, or delete it first (study_delete_course) to rebuild from this design.` : `Imported designed course “${value.title}” (${value.sections} sections, ${value.lessons} lessons${value.droppedLessons > 0 ? `, ${value.droppedLessons} hallucinated lesson(s) dropped` : ""})${typeof value.languageTarget === "string" ? ` — language course (${value.languageTarget}): keep reading passages, example sentences, and quiz language material in the target language` : ""}. First lesson: “${value.firstLessonTitle}” (id ${value.firstLessonId}). Present the course map to the learner.`
|
|
7715
|
+
text: value.created === false ? `“${value.title}” (id ${value.courseId}) was ALREADY imported — the design did not create a new course. Continue with the existing course, or delete it first (study_delete_course) to rebuild from this design.` : `Imported designed course “${value.title}” (${value.sections} sections, ${value.lessons} lessons${value.droppedLessons > 0 ? `, ${value.droppedLessons} hallucinated lesson(s) dropped` : ""})${typeof value.translations === "number" && value.translations > 0 ? ` — ${value.translations} lesson(s) carry the learner's-language translation (原文/对照/译文 in the teach tab)` : ""}${typeof value.languageTarget === "string" ? ` — language course (${value.languageTarget}): keep reading passages, example sentences, and quiz language material in the target language` : ""}. First lesson: “${value.firstLessonTitle}” (id ${value.firstLessonId}). Present the course map to the learner.`
|
|
7384
7716
|
}]
|
|
7385
7717
|
},
|
|
7386
7718
|
async execute(args, exec) {
|
|
@@ -7407,7 +7739,22 @@ ${p.text}`));
|
|
|
7407
7739
|
if (contents.size === 0) throw new Error(`lookatstudy-plugin: every designed file failed to fetch (${failed.length}) — the CDN path is unreachable; retry or re-import`);
|
|
7408
7740
|
if (failed.length > 0) throw new Error(`lookatstudy-plugin: ${failed.length} designed file(s) failed to fetch: ${failed.join(", ")} — drop or fix them and call study_apply_design again`);
|
|
7409
7741
|
}
|
|
7410
|
-
|
|
7742
|
+
let translations = pd.translations;
|
|
7743
|
+
if (pd.localContents === void 0 && pd.availableLangs !== void 0 && pd.availableLangs.length > 0) {
|
|
7744
|
+
const match = matchTranslationLang(typeof args.translationLang === "string" && args.translationLang.trim() !== "" ? args.translationLang : store.get().interfaceLang, pd.availableLangs);
|
|
7745
|
+
if (match !== null && !exec.signal.aborted) {
|
|
7746
|
+
const fetched = await fetchTranslatedContent(pd.owner, pd.repo, pd.branch, match.code, uniqueFiles.map((f) => ({
|
|
7747
|
+
path: f,
|
|
7748
|
+
title: f,
|
|
7749
|
+
md: ""
|
|
7750
|
+
})), signalFetch(exec.signal, baseFetch));
|
|
7751
|
+
if (fetched.size > 0) translations = new Map([...fetched].map(([path, v]) => [path, {
|
|
7752
|
+
lang: match.code,
|
|
7753
|
+
content: v.content
|
|
7754
|
+
}]));
|
|
7755
|
+
}
|
|
7756
|
+
}
|
|
7757
|
+
const parsed = buildCourseFromDesign(pd.part !== void 0 && pd.partCount !== void 0 && pd.partCount > 1 ? `${pd.courseTitle} (part ${pd.part})` : pd.courseTitle, validated, contents, translations);
|
|
7411
7758
|
requireParsedLessons(parsed);
|
|
7412
7759
|
const languageTarget = typeof args.languageTarget === "string" && args.languageTarget.trim() !== "" ? args.languageTarget.trim().slice(0, 35) : null;
|
|
7413
7760
|
const value = mutate((state) => {
|
|
@@ -7419,9 +7766,11 @@ ${p.text}`));
|
|
|
7419
7766
|
});
|
|
7420
7767
|
setPendingDesign(null);
|
|
7421
7768
|
pendingPart = 1;
|
|
7769
|
+
const translatedLessons = parsed.sections.reduce((n, s) => n + s.lessons.filter((l) => l.translation !== void 0).length, 0);
|
|
7422
7770
|
return {
|
|
7423
7771
|
...value,
|
|
7424
7772
|
droppedLessons: validated.droppedLessons,
|
|
7773
|
+
translations: translatedLessons,
|
|
7425
7774
|
...languageTarget !== null ? { languageTarget } : {}
|
|
7426
7775
|
};
|
|
7427
7776
|
},
|
|
@@ -10144,6 +10493,26 @@ function snapshotSectionText(state) {
|
|
|
10144
10493
|
if (snap.dueCount > 0) lines.push(`今日待复习: ${snap.dueCount} 项`);
|
|
10145
10494
|
if (snap.pendingProposal !== null) lines.push(`待决提案 ${snap.pendingProposal.id}: ${sanitizePromptText(snap.pendingProposal.rationale, 160)}(等学习者表态)`);
|
|
10146
10495
|
const focusCourse = state.courses.find((c) => c.id === snap.focus?.courseId);
|
|
10496
|
+
if (focusCourse?.threadScope === "course" && snap.focus !== null) {
|
|
10497
|
+
const group = state.lessonThreads[threadOwnerKey("course", focusCourse.id, snap.focus.lessonId)];
|
|
10498
|
+
const touched = group?.threads.find((t) => t.id === group.active)?.touchedLessons;
|
|
10499
|
+
const titleOf = /* @__PURE__ */ new Map();
|
|
10500
|
+
for (const sec of focusCourse.sections) for (const l of sec.lessons) titleOf.set(l.id, l.title);
|
|
10501
|
+
const digest = focusCourse.sections.flatMap((sec) => sec.lessons.filter((l) => l.kind !== "exam")).slice(0, 60).map((l) => {
|
|
10502
|
+
const pct = l.mastery === null ? "" : ` ${String(Math.round(l.mastery * 100))}%`;
|
|
10503
|
+
const weak = (conceptViews(l) ?? []).some((c) => c.weak) ? " ⚡薄弱" : "";
|
|
10504
|
+
const fric = l.friction.length > 0 ? ` ⚑卡点${String(l.friction.length)}` : "";
|
|
10505
|
+
return `- ${sanitizePromptText(l.title)}[${String(l.status)}${pct}]${weak}${fric}`;
|
|
10506
|
+
});
|
|
10507
|
+
lines.push("");
|
|
10508
|
+
lines.push("【本课程进度摘要】(跨课时连续对话:本线跨课时延续,换课时不改线)");
|
|
10509
|
+
lines.push(...digest);
|
|
10510
|
+
if (touched !== void 0 && touched.length > 0) {
|
|
10511
|
+
const names = touched.map((id) => sanitizePromptText(titleOf.get(id) ?? id)).join(" · ");
|
|
10512
|
+
lines.push(`本对话线已覆盖课时: ${names}; 当前焦点: ${sanitizePromptText(snap.focus.lessonTitle)}`);
|
|
10513
|
+
}
|
|
10514
|
+
lines.push("出跨课时综合题前,先调用 study_lesson 调出相关课时正文再命题——组合题优先利用本线已有的跨课时现场。");
|
|
10515
|
+
}
|
|
10147
10516
|
if (focusCourse?.languageTarget != null && focusCourse.languageTarget !== "") {
|
|
10148
10517
|
const t = localeToLanguageName(focusCourse.languageTarget);
|
|
10149
10518
|
lines.push("");
|