dsh-aha-tutor 1.3.0 → 1.5.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/AGENTS.md +5 -2
- package/CHANGELOG.md +38 -0
- package/README.en.md +6 -6
- package/README.md +6 -6
- package/docs/guide.zh-CN.md +39 -8
- package/lib/agent.js +1 -1
- package/lib/client.js +125 -42
- package/lib/index.js +668 -231
- package/lib/{runtime-BCGFUDlg.js → runtime-DqCjJ0yL.js} +65 -6
- package/package.json +2 -2
package/lib/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { $ as
|
|
1
|
+
import { $ as conceptIndex, A as generateText, B as readProgress, C as revisePrompt, D as TutorError, E as summaryPrompt, F as materialImages, G as applyEvidence, H as formatPassages, I as materialsSummary, J as recommendNext, K as createConceptState, L as passagesFor, M as EMPTY_PROFILE, N as emptyProgress, O as generateJson, P as masteryFor, Q as blueprintOutline, R as readMaterials, S as researchQueriesPrompt, T as shortPrompt, U as passageRange, V as documentParts, W as spreadPassages, X as boardToPlainText, Y as extractJson, Z as normalizeBoard, _ as projectReviewPrompt, _t as assertId, a as blueprintPrompt, at as lessonAfter, b as quizPrompt, c as detectLanguage, ct as normalizeCards, d as flashcardsPrompt, dt as normalizePractice, et as courseProgress, f as gradePrompt, ft as normalizeProject, g as profilePrompt, gt as text, h as practicePrompt, ht as safeRelativePath, i as assistPrompt, it as intakeSummary, j as streamCounter, k as generateSteps, l as digestPartPrompt, lt as normalizeCheatsheet, m as materialBriefPrompt, mt as outlineForPrompt, n as publishRuntime, nt as flattenLessons, o as cheatsheetPrompt, ot as nextLesson, p as lessonPrompt, pt as normalizeQuestionnaire, q as masteryPercent, r as askPrompt, rt as insertLesson, s as correctionPrompt, st as normalizeBlueprint, tt as findLesson, u as digestPrompt, ut as normalizeLesson, v as projectsPrompt, vt as newId, w as reworkPrompt, x as reexplainPrompt, y as questionnairePrompt, z as readProfile } from "./runtime-DqCjJ0yL.js";
|
|
2
2
|
import { createRequire } from "node:module";
|
|
3
3
|
import { dirname, join, relative, sep } from "node:path";
|
|
4
4
|
import { credentialKey } from "@deepseek-ai/dsh-credentials";
|
|
@@ -46,17 +46,20 @@ function exportCoursePack({ course, progress, boards = {}, practice = {}, sheets
|
|
|
46
46
|
function parseCoursePack(input) {
|
|
47
47
|
let pack = input;
|
|
48
48
|
if (typeof input === "string") {
|
|
49
|
-
if (input.length > 8388608) throw
|
|
49
|
+
if (input.length > 8388608) throw packError("pack-too-large", "course pack is too large");
|
|
50
50
|
try {
|
|
51
51
|
pack = JSON.parse(input);
|
|
52
52
|
} catch {
|
|
53
|
-
throw
|
|
53
|
+
throw packError("bad-pack", "course pack is not valid JSON");
|
|
54
54
|
}
|
|
55
55
|
}
|
|
56
|
-
if (pack?.format !== "aha-tutor.coursepack") throw
|
|
57
|
-
if (!Number.isInteger(pack.version) || pack.version > 1) throw new
|
|
56
|
+
if (pack?.format !== "aha-tutor.coursepack") throw packError("bad-pack", "not a course pack");
|
|
57
|
+
if (!Number.isInteger(pack.version) || pack.version > 1) throw packError("pack-too-new", "course pack was made by a newer version of the plugin");
|
|
58
58
|
return pack;
|
|
59
59
|
}
|
|
60
|
+
function packError(code, message) {
|
|
61
|
+
return Object.assign(new Error(message), { code });
|
|
62
|
+
}
|
|
60
63
|
function importCoursePack(input, { now = /* @__PURE__ */ new Date() } = {}) {
|
|
61
64
|
const pack = parseCoursePack(input);
|
|
62
65
|
const blueprint = normalizeBlueprint(stripIds(pack.course), { language: pack.course?.language });
|
|
@@ -236,7 +239,7 @@ function outlineStream(api) {
|
|
|
236
239
|
counter(delta);
|
|
237
240
|
};
|
|
238
241
|
}
|
|
239
|
-
function createCourses({ store, llm, jobs, now = () => /* @__PURE__ */ new Date() }) {
|
|
242
|
+
function createCourses({ store, llm, jobs, research, now = () => /* @__PURE__ */ new Date() }) {
|
|
240
243
|
const stamp = () => now().toISOString();
|
|
241
244
|
async function readCourse(courseId) {
|
|
242
245
|
const course = await store.readJson(store.paths.course(assertId(courseId, "crs")), null);
|
|
@@ -309,9 +312,9 @@ function createCourses({ store, llm, jobs, now = () => /* @__PURE__ */ new Date(
|
|
|
309
312
|
}
|
|
310
313
|
};
|
|
311
314
|
}
|
|
312
|
-
function startCraft({ goal, materialIds = [], quick = false, language }) {
|
|
315
|
+
function startCraft({ goal, materialIds = [], quick = false, language, web = false }) {
|
|
313
316
|
const cleanGoal = text(goal, 2e3);
|
|
314
|
-
if (!cleanGoal && !materialIds.length) throw new TutorError("
|
|
317
|
+
if (!cleanGoal && !materialIds.length) throw new TutorError("empty-goal", "Tell me what you want to learn");
|
|
315
318
|
const ids = materialIds.filter((id) => /^mat_[a-z0-9]+$/u.test(id)).slice(0, 8);
|
|
316
319
|
const courseId = newId("crs");
|
|
317
320
|
return jobs.start("craft/start", async (api) => {
|
|
@@ -327,10 +330,29 @@ function createCourses({ store, llm, jobs, now = () => /* @__PURE__ */ new Date(
|
|
|
327
330
|
createdAt: stamp()
|
|
328
331
|
};
|
|
329
332
|
await saveCourse(draft);
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
333
|
+
let found = null;
|
|
334
|
+
if (web && research) {
|
|
335
|
+
api.progress(0, quick ? 1 : 2, "research");
|
|
336
|
+
found = await research.run({
|
|
337
|
+
goal: draft.goal,
|
|
338
|
+
language: draft.language,
|
|
339
|
+
signal: api.signal
|
|
340
|
+
});
|
|
341
|
+
api.progress(0, quick ? 1 : 2, "questions");
|
|
342
|
+
}
|
|
343
|
+
if (quick) {
|
|
344
|
+
if (found) await store.writeJson(store.paths.intake(courseId), {
|
|
345
|
+
questionnaire: null,
|
|
346
|
+
answers: {},
|
|
347
|
+
research: found,
|
|
348
|
+
at: stamp()
|
|
349
|
+
});
|
|
350
|
+
return {
|
|
351
|
+
courseId,
|
|
352
|
+
questionnaire: null,
|
|
353
|
+
research: found
|
|
354
|
+
};
|
|
355
|
+
}
|
|
334
356
|
const profile = await readProfile(store);
|
|
335
357
|
const questionnaire = await generateJson(llm, {
|
|
336
358
|
purpose: "teach",
|
|
@@ -338,7 +360,8 @@ function createCourses({ store, llm, jobs, now = () => /* @__PURE__ */ new Date(
|
|
|
338
360
|
goal: draft.goal,
|
|
339
361
|
language: draft.language,
|
|
340
362
|
materialsSummary: materialsSummary(materials),
|
|
341
|
-
profile
|
|
363
|
+
profile,
|
|
364
|
+
sources: found?.sources
|
|
342
365
|
}),
|
|
343
366
|
maxTokens: 3e3,
|
|
344
367
|
signal: api.signal,
|
|
@@ -351,12 +374,14 @@ function createCourses({ store, llm, jobs, now = () => /* @__PURE__ */ new Date(
|
|
|
351
374
|
await store.writeJson(store.paths.intake(courseId), {
|
|
352
375
|
questionnaire,
|
|
353
376
|
answers: {},
|
|
377
|
+
...found ? { research: found } : {},
|
|
354
378
|
at: stamp()
|
|
355
379
|
});
|
|
356
380
|
api.progress(1, 2, "questions");
|
|
357
381
|
return {
|
|
358
382
|
courseId,
|
|
359
|
-
questionnaire
|
|
383
|
+
questionnaire,
|
|
384
|
+
research: found
|
|
360
385
|
};
|
|
361
386
|
}, {
|
|
362
387
|
key: `questions:${courseId}`,
|
|
@@ -397,7 +422,8 @@ function createCourses({ store, llm, jobs, now = () => /* @__PURE__ */ new Date(
|
|
|
397
422
|
profile,
|
|
398
423
|
timeline: settings.timeline,
|
|
399
424
|
size: settings.size,
|
|
400
|
-
weeklyMinutes: settings.weeklyMinutes
|
|
425
|
+
weeklyMinutes: settings.weeklyMinutes,
|
|
426
|
+
sources: intake.research?.sources
|
|
401
427
|
}),
|
|
402
428
|
images: llm.supportsImages?.("teach") ? await materialImages(store, materials) : void 0,
|
|
403
429
|
maxTokens: 16e3,
|
|
@@ -405,12 +431,14 @@ function createCourses({ store, llm, jobs, now = () => /* @__PURE__ */ new Date(
|
|
|
405
431
|
onText: outlineStream(api),
|
|
406
432
|
normalize: (raw) => attachProjectUnits(raw, normalizeBlueprint(raw, { language: course.language }))
|
|
407
433
|
});
|
|
434
|
+
const sources = intake.research?.sources ?? [];
|
|
408
435
|
const next = {
|
|
409
436
|
...course,
|
|
410
437
|
...blueprint,
|
|
411
438
|
language: course.language,
|
|
412
439
|
status: "blueprint",
|
|
413
|
-
settings
|
|
440
|
+
settings,
|
|
441
|
+
...sources.length ? { sources } : {}
|
|
414
442
|
};
|
|
415
443
|
await saveCourse(next);
|
|
416
444
|
return { course: next };
|
|
@@ -421,10 +449,10 @@ function createCourses({ store, llm, jobs, now = () => /* @__PURE__ */ new Date(
|
|
|
421
449
|
}
|
|
422
450
|
function revise({ courseId, instruction }) {
|
|
423
451
|
const request = text(instruction, 1500);
|
|
424
|
-
if (!request) throw new TutorError("
|
|
452
|
+
if (!request) throw new TutorError("empty-change", "Describe the change you want");
|
|
425
453
|
return jobs.start("craft/revise", async (api) => {
|
|
426
454
|
const course = await readCourse(courseId);
|
|
427
|
-
if (!course.units) throw new TutorError("
|
|
455
|
+
if (!course.units) throw new TutorError("no-blueprint", "There is no blueprint to revise yet");
|
|
428
456
|
const current = {
|
|
429
457
|
title: course.title,
|
|
430
458
|
subtitle: course.subtitle,
|
|
@@ -465,7 +493,7 @@ function createCourses({ store, llm, jobs, now = () => /* @__PURE__ */ new Date(
|
|
|
465
493
|
async function applyProposal({ courseId }) {
|
|
466
494
|
const course = await readCourse(courseId);
|
|
467
495
|
const proposal = await store.readJson(store.paths.proposal(courseId), null);
|
|
468
|
-
if (!proposal?.blueprint?.units) throw new TutorError("
|
|
496
|
+
if (!proposal?.blueprint?.units) throw new TutorError("no-proposal", "There is no proposed change");
|
|
469
497
|
const next = {
|
|
470
498
|
...course,
|
|
471
499
|
...proposal.blueprint,
|
|
@@ -569,7 +597,7 @@ function createCourses({ store, llm, jobs, now = () => /* @__PURE__ */ new Date(
|
|
|
569
597
|
function confirm({ courseId }) {
|
|
570
598
|
return jobs.start("craft/confirm", async (api) => {
|
|
571
599
|
const course = await readCourse(courseId);
|
|
572
|
-
if (!course.units) throw new TutorError("
|
|
600
|
+
if (!course.units) throw new TutorError("no-blueprint", "Generate a blueprint first");
|
|
573
601
|
const lectures = course.units.slice(0, 1).flatMap((unit) => unit.lectures.map((lecture) => ({
|
|
574
602
|
unit,
|
|
575
603
|
lecture
|
|
@@ -699,7 +727,17 @@ function createCourses({ store, llm, jobs, now = () => /* @__PURE__ */ new Date(
|
|
|
699
727
|
};
|
|
700
728
|
}
|
|
701
729
|
async function importPack({ json }) {
|
|
702
|
-
|
|
730
|
+
let imported;
|
|
731
|
+
try {
|
|
732
|
+
imported = importCoursePack(json);
|
|
733
|
+
} catch (error) {
|
|
734
|
+
const known = [
|
|
735
|
+
"bad-pack",
|
|
736
|
+
"pack-too-large",
|
|
737
|
+
"pack-too-new"
|
|
738
|
+
].includes(error?.code);
|
|
739
|
+
throw new TutorError(known ? error.code : "bad-pack", known ? error.message : "not a course pack");
|
|
740
|
+
}
|
|
703
741
|
const { course } = imported;
|
|
704
742
|
await saveCourse(course);
|
|
705
743
|
for (const [lessonId, board] of Object.entries(imported.boards)) await store.writeJson(store.paths.board(course.id, lessonId), board);
|
|
@@ -830,6 +868,150 @@ const BOARD_FONT_FILES = Object.freeze([
|
|
|
830
868
|
})
|
|
831
869
|
]);
|
|
832
870
|
//#endregion
|
|
871
|
+
//#region src/host/jobs.js
|
|
872
|
+
const KEEP_FINISHED_MS = 10 * 6e4;
|
|
873
|
+
const MAX_JOBS = 200;
|
|
874
|
+
function createJobs({ logger, now = Date.now } = {}) {
|
|
875
|
+
const jobs = /* @__PURE__ */ new Map();
|
|
876
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
877
|
+
function sweep() {
|
|
878
|
+
const cutoff = now() - KEEP_FINISHED_MS;
|
|
879
|
+
for (const [id, job] of jobs) if (job.finishedAt && job.finishedAt < cutoff) {
|
|
880
|
+
jobs.delete(id);
|
|
881
|
+
if (job.key && byKey.get(job.key) === id) byKey.delete(job.key);
|
|
882
|
+
}
|
|
883
|
+
if (jobs.size > MAX_JOBS) {
|
|
884
|
+
const finished = [...jobs.values()].filter((job) => job.finishedAt).sort((a, b) => a.finishedAt - b.finishedAt);
|
|
885
|
+
for (const job of finished.slice(0, jobs.size - MAX_JOBS)) jobs.delete(job.id);
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
function start(kind, run, { key, meta } = {}) {
|
|
889
|
+
sweep();
|
|
890
|
+
if (key && byKey.has(key)) {
|
|
891
|
+
const existing = jobs.get(byKey.get(key));
|
|
892
|
+
if (existing && !existing.finishedAt && !existing.controller.signal.aborted) return snapshot(existing);
|
|
893
|
+
}
|
|
894
|
+
const controller = new AbortController();
|
|
895
|
+
const job = {
|
|
896
|
+
id: newId("job"),
|
|
897
|
+
kind,
|
|
898
|
+
key: key ?? null,
|
|
899
|
+
meta: meta ?? {},
|
|
900
|
+
status: "running",
|
|
901
|
+
progress: {
|
|
902
|
+
done: 0,
|
|
903
|
+
total: 0,
|
|
904
|
+
label: ""
|
|
905
|
+
},
|
|
906
|
+
partial: [],
|
|
907
|
+
preview: null,
|
|
908
|
+
result: void 0,
|
|
909
|
+
error: void 0,
|
|
910
|
+
startedAt: now(),
|
|
911
|
+
finishedAt: null,
|
|
912
|
+
controller
|
|
913
|
+
};
|
|
914
|
+
jobs.set(job.id, job);
|
|
915
|
+
if (key) byKey.set(key, job.id);
|
|
916
|
+
const api = {
|
|
917
|
+
signal: controller.signal,
|
|
918
|
+
progress(done, total, label = job.progress.label) {
|
|
919
|
+
job.progress = {
|
|
920
|
+
done,
|
|
921
|
+
total,
|
|
922
|
+
label
|
|
923
|
+
};
|
|
924
|
+
},
|
|
925
|
+
push(item) {
|
|
926
|
+
job.partial.push(item);
|
|
927
|
+
},
|
|
928
|
+
preview(value) {
|
|
929
|
+
job.preview = value ?? null;
|
|
930
|
+
}
|
|
931
|
+
};
|
|
932
|
+
const aborted = new Promise((_, reject) => {
|
|
933
|
+
controller.signal.addEventListener("abort", () => reject(controller.signal.reason ?? /* @__PURE__ */ new Error("cancelled")), { once: true });
|
|
934
|
+
});
|
|
935
|
+
Promise.race([Promise.resolve().then(() => run(api)), aborted]).then((result) => {
|
|
936
|
+
job.status = "done";
|
|
937
|
+
job.result = result;
|
|
938
|
+
}).catch((error) => {
|
|
939
|
+
job.status = controller.signal.aborted ? "cancelled" : "failed";
|
|
940
|
+
job.error = controller.signal.aborted ? {
|
|
941
|
+
code: "cancelled",
|
|
942
|
+
message: "Cancelled"
|
|
943
|
+
} : {
|
|
944
|
+
code: publicCode(error),
|
|
945
|
+
message: publicMessage(error)
|
|
946
|
+
};
|
|
947
|
+
if (job.status === "failed") logger?.warn?.("aha-tutor job %s failed: %s", kind, error?.message);
|
|
948
|
+
}).finally(() => {
|
|
949
|
+
job.finishedAt = now();
|
|
950
|
+
});
|
|
951
|
+
return snapshot(job);
|
|
952
|
+
}
|
|
953
|
+
function snapshot(job, { since = 0 } = {}) {
|
|
954
|
+
return {
|
|
955
|
+
id: job.id,
|
|
956
|
+
kind: job.kind,
|
|
957
|
+
meta: job.meta,
|
|
958
|
+
status: job.status,
|
|
959
|
+
progress: job.progress,
|
|
960
|
+
partial: job.partial.slice(since),
|
|
961
|
+
partialCount: job.partial.length,
|
|
962
|
+
preview: job.preview ?? void 0,
|
|
963
|
+
elapsedMs: (job.finishedAt ?? now()) - job.startedAt,
|
|
964
|
+
result: job.status === "done" ? job.result : void 0,
|
|
965
|
+
error: job.error
|
|
966
|
+
};
|
|
967
|
+
}
|
|
968
|
+
function get(id, options) {
|
|
969
|
+
const job = jobs.get(id);
|
|
970
|
+
return job ? snapshot(job, options) : null;
|
|
971
|
+
}
|
|
972
|
+
function cancel(id) {
|
|
973
|
+
const job = jobs.get(id);
|
|
974
|
+
if (job && !job.finishedAt) job.controller.abort(/* @__PURE__ */ new Error("cancelled"));
|
|
975
|
+
return job ? snapshot(job) : null;
|
|
976
|
+
}
|
|
977
|
+
function running(key) {
|
|
978
|
+
const id = byKey.get(key);
|
|
979
|
+
const job = id ? jobs.get(id) : void 0;
|
|
980
|
+
return job && !job.finishedAt && !job.controller.signal.aborted ? snapshot(job) : null;
|
|
981
|
+
}
|
|
982
|
+
function cancelKey(key) {
|
|
983
|
+
const job = running(key);
|
|
984
|
+
return job ? cancel(job.id) : null;
|
|
985
|
+
}
|
|
986
|
+
function dispose() {
|
|
987
|
+
for (const job of jobs.values()) if (!job.finishedAt) job.controller.abort(/* @__PURE__ */ new Error("plugin stopped"));
|
|
988
|
+
jobs.clear();
|
|
989
|
+
byKey.clear();
|
|
990
|
+
}
|
|
991
|
+
return Object.freeze({
|
|
992
|
+
start,
|
|
993
|
+
get,
|
|
994
|
+
cancel,
|
|
995
|
+
cancelKey,
|
|
996
|
+
running,
|
|
997
|
+
dispose
|
|
998
|
+
});
|
|
999
|
+
}
|
|
1000
|
+
function publicMessage(error) {
|
|
1001
|
+
if (error?.userMessage) return String(error.userMessage);
|
|
1002
|
+
if (USER_ERRORS$1.includes(error?.name)) return String(error.message);
|
|
1003
|
+
return "Generation failed";
|
|
1004
|
+
}
|
|
1005
|
+
function publicCode(error) {
|
|
1006
|
+
if (!USER_ERRORS$1.includes(error?.name)) return "failed";
|
|
1007
|
+
return error.name === "TtsError" ? `tts-${error.code ?? "failed"}` : error.code ?? "failed";
|
|
1008
|
+
}
|
|
1009
|
+
const USER_ERRORS$1 = [
|
|
1010
|
+
"MaterialError",
|
|
1011
|
+
"TtsError",
|
|
1012
|
+
"TutorError"
|
|
1013
|
+
];
|
|
1014
|
+
//#endregion
|
|
833
1015
|
//#region src/host/web-fetch.js
|
|
834
1016
|
const LOCAL_ADDRESS_MESSAGE = "Local network addresses are not supported";
|
|
835
1017
|
var AddressError = class extends Error {
|
|
@@ -1096,7 +1278,7 @@ function detectKind(name, bytes) {
|
|
|
1096
1278
|
if (head[0] === 137 && head[1] === 80) return "image";
|
|
1097
1279
|
if (head[0] === 255 && head[1] === 216) return "image";
|
|
1098
1280
|
if (head.subarray(0, 2).toString("latin1") === "PK") {
|
|
1099
|
-
if (lower.endsWith(".doc") || lower.endsWith(".ppt")) throw new MaterialError("
|
|
1281
|
+
if (lower.endsWith(".doc") || lower.endsWith(".ppt")) throw new MaterialError("old-office", "Old .doc/.ppt files are not supported; save as .docx/.pptx");
|
|
1100
1282
|
}
|
|
1101
1283
|
throw new MaterialError("unsupported", "This file type is not supported");
|
|
1102
1284
|
}
|
|
@@ -1350,7 +1532,7 @@ function extractPlain(bytes, kind) {
|
|
|
1350
1532
|
chunks.push(...chunkText(part, heading ? { section: heading.slice(0, 80) } : {}));
|
|
1351
1533
|
}
|
|
1352
1534
|
} else chunks.push(...chunkText(value, {}));
|
|
1353
|
-
if (!chunks.length) throw new MaterialError("
|
|
1535
|
+
if (!chunks.length) throw new MaterialError("empty", "This file is empty");
|
|
1354
1536
|
return {
|
|
1355
1537
|
title: kind === "markdown" ? /^#\s+(.+)$/mu.exec(value)?.[1]?.trim() ?? "" : "",
|
|
1356
1538
|
chunks
|
|
@@ -1362,7 +1544,7 @@ async function ingestFile({ name, bytes, signal }) {
|
|
|
1362
1544
|
const kind = detectKind(name, bytes);
|
|
1363
1545
|
const baseTitle = String(name ?? "").replace(/\.[^.]+$/u, "").slice(0, 120) || "Untitled";
|
|
1364
1546
|
if (kind === "image") {
|
|
1365
|
-
if (bytes.length > 10485760) throw new MaterialError("too-large", "Images must be smaller than 10 MB");
|
|
1547
|
+
if (bytes.length > 10485760) throw new MaterialError("image-too-large", "Images must be smaller than 10 MB");
|
|
1366
1548
|
return {
|
|
1367
1549
|
kind,
|
|
1368
1550
|
title: baseTitle,
|
|
@@ -1417,7 +1599,7 @@ async function ingestUrl(address, { fetchImpl = pageFetch, signal } = {}) {
|
|
|
1417
1599
|
} catch (error) {
|
|
1418
1600
|
if (signal?.aborted) throw signal.reason ?? error;
|
|
1419
1601
|
if (error?.name === "AddressError") throw new MaterialError("bad-url", error.message);
|
|
1420
|
-
if (error?.code === "ETOOLARGE") throw new MaterialError("too-large", "The page is too large");
|
|
1602
|
+
if (error?.code === "ETOOLARGE") throw new MaterialError("page-too-large", "The page is too large");
|
|
1421
1603
|
throw new MaterialError("network", timeout.aborted ? "The page took too long to load" : "The page could not be loaded");
|
|
1422
1604
|
}
|
|
1423
1605
|
if (response.status >= 300 && response.status < 400 && response.headers.get("location")) {
|
|
@@ -1429,7 +1611,7 @@ async function ingestUrl(address, { fetchImpl = pageFetch, signal } = {}) {
|
|
|
1429
1611
|
if (!response?.ok) throw new MaterialError("http", `The page returned an error (${response?.status ?? "redirect loop"})`);
|
|
1430
1612
|
const type = String(response.headers.get("content-type") ?? "").toLowerCase();
|
|
1431
1613
|
const bytes = Buffer.from(await response.arrayBuffer());
|
|
1432
|
-
if (bytes.length > MAX_PAGE_BYTES && !type.includes("pdf")) throw new MaterialError("too-large", "The page is too large");
|
|
1614
|
+
if (bytes.length > MAX_PAGE_BYTES && !type.includes("pdf")) throw new MaterialError("page-too-large", "The page is too large");
|
|
1433
1615
|
if (type.includes("application/pdf") || bytes.subarray(0, 5).toString("latin1") === "%PDF-") return {
|
|
1434
1616
|
...await ingestFile({
|
|
1435
1617
|
name: `${url.pathname.split("/").pop() || "page"}.pdf`,
|
|
@@ -1455,14 +1637,77 @@ async function ingestUrl(address, { fetchImpl = pageFetch, signal } = {}) {
|
|
|
1455
1637
|
}
|
|
1456
1638
|
const title = String(article?.title || document.querySelector("title")?.textContent || url.hostname).trim().slice(0, 160);
|
|
1457
1639
|
const chunks = chunkText(article?.textContent || document.body?.textContent || "", { section: url.hostname });
|
|
1458
|
-
if (!chunks.length) throw new MaterialError("no-text", "No readable text on this page");
|
|
1640
|
+
if (!chunks.length) throw new MaterialError("page-no-text", "No readable text on this page");
|
|
1459
1641
|
return finish("url", title, chunks, { url: url.href });
|
|
1460
1642
|
}
|
|
1643
|
+
const BYTES_PER_SECOND = 32e3;
|
|
1644
|
+
const MAX_SPEECH_BYTES = 3840044;
|
|
1645
|
+
const MAX_TEXT = 2e3;
|
|
1646
|
+
const USABLE = /* @__PURE__ */ new Set(["ready", "standby"]);
|
|
1647
|
+
const PREPARING = /* @__PURE__ */ new Set([
|
|
1648
|
+
"checking",
|
|
1649
|
+
"downloading",
|
|
1650
|
+
"cancelling"
|
|
1651
|
+
]);
|
|
1652
|
+
function wavSeconds(bytes) {
|
|
1653
|
+
const data = bytes instanceof Uint8Array ? bytes : null;
|
|
1654
|
+
if (!data || data.length < 46) return null;
|
|
1655
|
+
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
|
1656
|
+
const tag = (at) => String.fromCharCode(...data.subarray(at, at + 4));
|
|
1657
|
+
return tag(0) === "RIFF" && tag(8) === "WAVE" && tag(12) === "fmt " && tag(36) === "data" && view.getUint32(4, true) === data.length - 8 && view.getUint32(16, true) === 16 && view.getUint16(20, true) === 1 && view.getUint16(22, true) === 1 && view.getUint32(24, true) === 16e3 && view.getUint32(28, true) === BYTES_PER_SECOND && view.getUint16(32, true) === 2 && view.getUint16(34, true) === 16 && view.getUint32(40, true) === data.length - 44 && (data.length - 44) % 2 === 0 ? (data.length - 44) / BYTES_PER_SECOND : null;
|
|
1658
|
+
}
|
|
1659
|
+
function createSpeechInput({ getSpeech, logger }) {
|
|
1660
|
+
function status() {
|
|
1661
|
+
const speech = getSpeech();
|
|
1662
|
+
let snapshot = null;
|
|
1663
|
+
try {
|
|
1664
|
+
snapshot = speech?.snapshot?.() ?? null;
|
|
1665
|
+
} catch {
|
|
1666
|
+
snapshot = null;
|
|
1667
|
+
}
|
|
1668
|
+
const chosen = snapshot?.selection?.providerId;
|
|
1669
|
+
const provider = snapshot?.providers?.find((entry) => entry.id === chosen);
|
|
1670
|
+
if (!provider) return {
|
|
1671
|
+
state: "off",
|
|
1672
|
+
maxSeconds: 120
|
|
1673
|
+
};
|
|
1674
|
+
const phase = provider.preparation?.phase ?? "ready";
|
|
1675
|
+
return {
|
|
1676
|
+
state: USABLE.has(phase) ? "ready" : PREPARING.has(phase) ? "preparing" : phase === "failed" ? "failed" : "unprepared",
|
|
1677
|
+
maxSeconds: 120
|
|
1678
|
+
};
|
|
1679
|
+
}
|
|
1680
|
+
async function transcribe({ audio, signal }) {
|
|
1681
|
+
const { state } = status();
|
|
1682
|
+
if (state === "off") throw new TutorError("speech-off", "Voice input is not enabled in DSH");
|
|
1683
|
+
if (state !== "ready") throw new TutorError("speech-unprepared", "The speech recognizer is not ready yet");
|
|
1684
|
+
const seconds = wavSeconds(audio);
|
|
1685
|
+
if (seconds === null) throw new TutorError("speech-audio", "The recording could not be read");
|
|
1686
|
+
if (seconds > 120) throw new TutorError("speech-too-long", `Recordings are limited to 120 seconds`);
|
|
1687
|
+
const speech = getSpeech();
|
|
1688
|
+
let result;
|
|
1689
|
+
try {
|
|
1690
|
+
result = await speech.transcribe(speech.resolve({ audio: Buffer.from(audio.buffer, audio.byteOffset, audio.byteLength) }), signal ?? new AbortController().signal);
|
|
1691
|
+
} catch (error) {
|
|
1692
|
+
if (signal?.aborted) throw error;
|
|
1693
|
+
logger?.debug?.("aha-tutor speech recognition failed: %s", error?.message);
|
|
1694
|
+
throw new TutorError("speech-failed", "Speech recognition failed");
|
|
1695
|
+
}
|
|
1696
|
+
const text = String(typeof result === "string" ? result : result?.text ?? "").replace(/\s+/gu, " ").trim().slice(0, MAX_TEXT);
|
|
1697
|
+
if (!text) throw new TutorError("speech-empty", "No speech was heard");
|
|
1698
|
+
return { text };
|
|
1699
|
+
}
|
|
1700
|
+
return Object.freeze({
|
|
1701
|
+
status,
|
|
1702
|
+
transcribe
|
|
1703
|
+
});
|
|
1704
|
+
}
|
|
1461
1705
|
//#endregion
|
|
1462
1706
|
//#region src/host/http.js
|
|
1463
1707
|
const UPLOAD_PATH = "/api/aha-tutor/upload";
|
|
1464
1708
|
const TTS_PATH = "/api/aha-tutor/tts";
|
|
1465
1709
|
const FONT_PATH = "/api/aha-tutor/font";
|
|
1710
|
+
const SPEECH_PATH = "/api/aha-tutor/speech";
|
|
1466
1711
|
async function readCapped(stream, max, signal) {
|
|
1467
1712
|
const parts = [];
|
|
1468
1713
|
let size = 0;
|
|
@@ -1510,7 +1755,35 @@ function uploadRoute({ studio }) {
|
|
|
1510
1755
|
}) }
|
|
1511
1756
|
});
|
|
1512
1757
|
} catch (error) {
|
|
1513
|
-
return failure$1(error
|
|
1758
|
+
return failure$1(publicCode(error), error?.name === "TutorError" ? error.message : "Upload failed", 400);
|
|
1759
|
+
}
|
|
1760
|
+
}
|
|
1761
|
+
};
|
|
1762
|
+
}
|
|
1763
|
+
function speechRoute({ speechInput }) {
|
|
1764
|
+
return {
|
|
1765
|
+
path: SPEECH_PATH,
|
|
1766
|
+
methods: ["POST"],
|
|
1767
|
+
requestBody: "streaming",
|
|
1768
|
+
fetch: async (request) => {
|
|
1769
|
+
let audio;
|
|
1770
|
+
try {
|
|
1771
|
+
audio = await readCapped(request.body, MAX_SPEECH_BYTES, request.signal);
|
|
1772
|
+
} catch {
|
|
1773
|
+
return failure$1("aborted", "Upload interrupted", 400);
|
|
1774
|
+
}
|
|
1775
|
+
if (audio === null) return failure$1("speech-too-long", "The recording is too long", 413);
|
|
1776
|
+
try {
|
|
1777
|
+
return json({
|
|
1778
|
+
ok: true,
|
|
1779
|
+
value: await speechInput.transcribe({
|
|
1780
|
+
audio: new Uint8Array(audio.buffer, audio.byteOffset, audio.byteLength),
|
|
1781
|
+
signal: request.signal
|
|
1782
|
+
})
|
|
1783
|
+
});
|
|
1784
|
+
} catch (error) {
|
|
1785
|
+
const known = error?.name === "TutorError";
|
|
1786
|
+
return failure$1(known ? publicCode(error) : "speech-failed", known ? error.message : "Speech recognition failed", known && error.code !== "speech-failed" ? 400 : 502);
|
|
1514
1787
|
}
|
|
1515
1788
|
}
|
|
1516
1789
|
};
|
|
@@ -1541,7 +1814,7 @@ function ttsRoute({ speak }) {
|
|
|
1541
1814
|
});
|
|
1542
1815
|
} catch (error) {
|
|
1543
1816
|
const known = error?.name === "TtsError" || error?.name === "TutorError";
|
|
1544
|
-
return failure$1(error
|
|
1817
|
+
return failure$1(known ? publicCode(error) : "tts-failed", known ? error.message : "Speech synthesis failed", ["auth", "no-key"].includes(error?.code) ? 401 : 502);
|
|
1545
1818
|
}
|
|
1546
1819
|
}
|
|
1547
1820
|
};
|
|
@@ -1575,144 +1848,6 @@ function fontRoute({ dir = fontsDir() } = {}) {
|
|
|
1575
1848
|
};
|
|
1576
1849
|
}
|
|
1577
1850
|
//#endregion
|
|
1578
|
-
//#region src/host/jobs.js
|
|
1579
|
-
const KEEP_FINISHED_MS = 10 * 6e4;
|
|
1580
|
-
const MAX_JOBS = 200;
|
|
1581
|
-
function createJobs({ logger, now = Date.now } = {}) {
|
|
1582
|
-
const jobs = /* @__PURE__ */ new Map();
|
|
1583
|
-
const byKey = /* @__PURE__ */ new Map();
|
|
1584
|
-
function sweep() {
|
|
1585
|
-
const cutoff = now() - KEEP_FINISHED_MS;
|
|
1586
|
-
for (const [id, job] of jobs) if (job.finishedAt && job.finishedAt < cutoff) {
|
|
1587
|
-
jobs.delete(id);
|
|
1588
|
-
if (job.key && byKey.get(job.key) === id) byKey.delete(job.key);
|
|
1589
|
-
}
|
|
1590
|
-
if (jobs.size > MAX_JOBS) {
|
|
1591
|
-
const finished = [...jobs.values()].filter((job) => job.finishedAt).sort((a, b) => a.finishedAt - b.finishedAt);
|
|
1592
|
-
for (const job of finished.slice(0, jobs.size - MAX_JOBS)) jobs.delete(job.id);
|
|
1593
|
-
}
|
|
1594
|
-
}
|
|
1595
|
-
function start(kind, run, { key, meta } = {}) {
|
|
1596
|
-
sweep();
|
|
1597
|
-
if (key && byKey.has(key)) {
|
|
1598
|
-
const existing = jobs.get(byKey.get(key));
|
|
1599
|
-
if (existing && !existing.finishedAt && !existing.controller.signal.aborted) return snapshot(existing);
|
|
1600
|
-
}
|
|
1601
|
-
const controller = new AbortController();
|
|
1602
|
-
const job = {
|
|
1603
|
-
id: newId("job"),
|
|
1604
|
-
kind,
|
|
1605
|
-
key: key ?? null,
|
|
1606
|
-
meta: meta ?? {},
|
|
1607
|
-
status: "running",
|
|
1608
|
-
progress: {
|
|
1609
|
-
done: 0,
|
|
1610
|
-
total: 0,
|
|
1611
|
-
label: ""
|
|
1612
|
-
},
|
|
1613
|
-
partial: [],
|
|
1614
|
-
preview: null,
|
|
1615
|
-
result: void 0,
|
|
1616
|
-
error: void 0,
|
|
1617
|
-
startedAt: now(),
|
|
1618
|
-
finishedAt: null,
|
|
1619
|
-
controller
|
|
1620
|
-
};
|
|
1621
|
-
jobs.set(job.id, job);
|
|
1622
|
-
if (key) byKey.set(key, job.id);
|
|
1623
|
-
const api = {
|
|
1624
|
-
signal: controller.signal,
|
|
1625
|
-
progress(done, total, label = job.progress.label) {
|
|
1626
|
-
job.progress = {
|
|
1627
|
-
done,
|
|
1628
|
-
total,
|
|
1629
|
-
label
|
|
1630
|
-
};
|
|
1631
|
-
},
|
|
1632
|
-
push(item) {
|
|
1633
|
-
job.partial.push(item);
|
|
1634
|
-
},
|
|
1635
|
-
preview(value) {
|
|
1636
|
-
job.preview = value ?? null;
|
|
1637
|
-
}
|
|
1638
|
-
};
|
|
1639
|
-
const aborted = new Promise((_, reject) => {
|
|
1640
|
-
controller.signal.addEventListener("abort", () => reject(controller.signal.reason ?? /* @__PURE__ */ new Error("cancelled")), { once: true });
|
|
1641
|
-
});
|
|
1642
|
-
Promise.race([Promise.resolve().then(() => run(api)), aborted]).then((result) => {
|
|
1643
|
-
job.status = "done";
|
|
1644
|
-
job.result = result;
|
|
1645
|
-
}).catch((error) => {
|
|
1646
|
-
job.status = controller.signal.aborted ? "cancelled" : "failed";
|
|
1647
|
-
job.error = controller.signal.aborted ? {
|
|
1648
|
-
code: "cancelled",
|
|
1649
|
-
message: "Cancelled"
|
|
1650
|
-
} : {
|
|
1651
|
-
code: error?.code ?? "failed",
|
|
1652
|
-
message: publicMessage(error)
|
|
1653
|
-
};
|
|
1654
|
-
if (job.status === "failed") logger?.warn?.("aha-tutor job %s failed: %s", kind, error?.message);
|
|
1655
|
-
}).finally(() => {
|
|
1656
|
-
job.finishedAt = now();
|
|
1657
|
-
});
|
|
1658
|
-
return snapshot(job);
|
|
1659
|
-
}
|
|
1660
|
-
function snapshot(job, { since = 0 } = {}) {
|
|
1661
|
-
return {
|
|
1662
|
-
id: job.id,
|
|
1663
|
-
kind: job.kind,
|
|
1664
|
-
meta: job.meta,
|
|
1665
|
-
status: job.status,
|
|
1666
|
-
progress: job.progress,
|
|
1667
|
-
partial: job.partial.slice(since),
|
|
1668
|
-
partialCount: job.partial.length,
|
|
1669
|
-
preview: job.preview ?? void 0,
|
|
1670
|
-
result: job.status === "done" ? job.result : void 0,
|
|
1671
|
-
error: job.error
|
|
1672
|
-
};
|
|
1673
|
-
}
|
|
1674
|
-
function get(id, options) {
|
|
1675
|
-
const job = jobs.get(id);
|
|
1676
|
-
return job ? snapshot(job, options) : null;
|
|
1677
|
-
}
|
|
1678
|
-
function cancel(id) {
|
|
1679
|
-
const job = jobs.get(id);
|
|
1680
|
-
if (job && !job.finishedAt) job.controller.abort(/* @__PURE__ */ new Error("cancelled"));
|
|
1681
|
-
return job ? snapshot(job) : null;
|
|
1682
|
-
}
|
|
1683
|
-
function running(key) {
|
|
1684
|
-
const id = byKey.get(key);
|
|
1685
|
-
const job = id ? jobs.get(id) : void 0;
|
|
1686
|
-
return job && !job.finishedAt && !job.controller.signal.aborted ? snapshot(job) : null;
|
|
1687
|
-
}
|
|
1688
|
-
function cancelKey(key) {
|
|
1689
|
-
const job = running(key);
|
|
1690
|
-
return job ? cancel(job.id) : null;
|
|
1691
|
-
}
|
|
1692
|
-
function dispose() {
|
|
1693
|
-
for (const job of jobs.values()) if (!job.finishedAt) job.controller.abort(/* @__PURE__ */ new Error("plugin stopped"));
|
|
1694
|
-
jobs.clear();
|
|
1695
|
-
byKey.clear();
|
|
1696
|
-
}
|
|
1697
|
-
return Object.freeze({
|
|
1698
|
-
start,
|
|
1699
|
-
get,
|
|
1700
|
-
cancel,
|
|
1701
|
-
cancelKey,
|
|
1702
|
-
running,
|
|
1703
|
-
dispose
|
|
1704
|
-
});
|
|
1705
|
-
}
|
|
1706
|
-
function publicMessage(error) {
|
|
1707
|
-
if (error?.userMessage) return String(error.userMessage);
|
|
1708
|
-
if ([
|
|
1709
|
-
"MaterialError",
|
|
1710
|
-
"TtsError",
|
|
1711
|
-
"TutorError"
|
|
1712
|
-
].includes(error?.name)) return String(error.message);
|
|
1713
|
-
return "Generation failed";
|
|
1714
|
-
}
|
|
1715
|
-
//#endregion
|
|
1716
1851
|
//#region src/core/planner.js
|
|
1717
1852
|
const DAY_MS$1 = 1440 * 60 * 1e3;
|
|
1718
1853
|
const HORIZON_DAYS = 400;
|
|
@@ -1756,7 +1891,7 @@ function clampNumber(value, min, max, fallback) {
|
|
|
1756
1891
|
if (!Number.isFinite(number)) return fallback;
|
|
1757
1892
|
return Math.min(max, Math.max(min, number));
|
|
1758
1893
|
}
|
|
1759
|
-
function buildPlan({ today, settings, courses = [], reviews = [], exams = [] }) {
|
|
1894
|
+
function buildPlan({ today, settings, courses = [], reviews = [], exams = [], spentToday = 0 }) {
|
|
1760
1895
|
const start = today ?? toDay(/* @__PURE__ */ new Date());
|
|
1761
1896
|
const { studyDays, weeklyMinutes, dailyMinutes } = normalizeSettings(settings);
|
|
1762
1897
|
const queues = courses.map((course) => ({
|
|
@@ -1790,8 +1925,9 @@ function buildPlan({ today, settings, courses = [], reviews = [], exams = [] })
|
|
|
1790
1925
|
minutes: 0
|
|
1791
1926
|
}));
|
|
1792
1927
|
if (studyDays.includes(weekday)) {
|
|
1793
|
-
|
|
1794
|
-
|
|
1928
|
+
const spent = offset === 0 ? Math.max(0, Number(spentToday) || 0) : 0;
|
|
1929
|
+
let budget = dailyMinutes - spent;
|
|
1930
|
+
const reviewBudget = Math.min(Math.max(0, budget), Math.round(dailyMinutes * REVIEW_SHARE));
|
|
1795
1931
|
let reviewUsed = 0;
|
|
1796
1932
|
while (pendingReviews.length && pendingReviews[0].dueDay <= day && reviewUsed + pendingReviews[0].minutes <= reviewBudget) {
|
|
1797
1933
|
const review = pendingReviews.shift();
|
|
@@ -1801,6 +1937,8 @@ function buildPlan({ today, settings, courses = [], reviews = [], exams = [] })
|
|
|
1801
1937
|
refId: review.id,
|
|
1802
1938
|
courseId: review.courseId ?? null,
|
|
1803
1939
|
title: review.title,
|
|
1940
|
+
courseTitle: review.courseTitle ?? "",
|
|
1941
|
+
courseEmoji: review.courseEmoji ?? "",
|
|
1804
1942
|
minutes: review.minutes
|
|
1805
1943
|
});
|
|
1806
1944
|
}
|
|
@@ -1813,7 +1951,7 @@ function buildPlan({ today, settings, courses = [], reviews = [], exams = [] })
|
|
|
1813
1951
|
for (const queue of ordered) {
|
|
1814
1952
|
const lesson = queue.remaining[0];
|
|
1815
1953
|
if (!lesson) continue;
|
|
1816
|
-
const dayIsEmpty = !items.some((item) => item.kind === "lesson");
|
|
1954
|
+
const dayIsEmpty = !spent && !items.some((item) => item.kind === "lesson");
|
|
1817
1955
|
if (lesson.minutes <= budget || dayIsEmpty) {
|
|
1818
1956
|
queue.remaining.shift();
|
|
1819
1957
|
budget -= lesson.minutes;
|
|
@@ -1899,9 +2037,61 @@ function neededWeeklyMinutes(course, start, deadline, studyDays) {
|
|
|
1899
2037
|
const perDay = remaining / studyDayCount / (1 - REVIEW_SHARE);
|
|
1900
2038
|
return Math.ceil(perDay * studyDays.length / 10) * 10;
|
|
1901
2039
|
}
|
|
2040
|
+
const itemKey = (item) => `${item.kind}:${item.refId}`;
|
|
2041
|
+
function mergeAgenda(saved = [], planned = []) {
|
|
2042
|
+
const seen = new Set(saved.map(itemKey));
|
|
2043
|
+
return [...saved, ...planned.filter((item) => !seen.has(itemKey(item)))];
|
|
2044
|
+
}
|
|
2045
|
+
function planCalendar({ today, agenda = {}, plan, done = {}, checks = [], ahead = 120 }) {
|
|
2046
|
+
const lessonsDone = done.lessons ?? /* @__PURE__ */ new Map();
|
|
2047
|
+
const reviewsDone = done.reviews ?? /* @__PURE__ */ new Map();
|
|
2048
|
+
const last = addDays(today, ahead);
|
|
2049
|
+
const days = /* @__PURE__ */ new Map();
|
|
2050
|
+
const put = (day, item) => {
|
|
2051
|
+
const list = days.get(day) ?? [];
|
|
2052
|
+
if (!list.some((entry) => itemKey(entry) === itemKey(item))) list.push(item);
|
|
2053
|
+
days.set(day, list);
|
|
2054
|
+
};
|
|
2055
|
+
for (const [day, items] of Object.entries(agenda)) if (day <= today) for (const item of items) put(day, item);
|
|
2056
|
+
for (const entry of plan?.days ?? []) if (entry.day >= today && entry.day <= last) for (const item of entry.items) put(entry.day, item);
|
|
2057
|
+
for (const { day, item } of lessonsDone.values()) if (day <= today) put(day, item);
|
|
2058
|
+
for (const check of checks) if (check.day <= today && check.item) put(check.day, check.item);
|
|
2059
|
+
const status = (item, day) => {
|
|
2060
|
+
if (day > today || item.kind === "exam") return false;
|
|
2061
|
+
if (checks.some((check) => check.refId === item.refId && check.day === day)) return "manual";
|
|
2062
|
+
if (item.kind === "lesson" && lessonsDone.get(item.refId)?.day === day) return "auto";
|
|
2063
|
+
if (item.kind === "review" && reviewsDone.get(item.refId) === day) return "auto";
|
|
2064
|
+
return false;
|
|
2065
|
+
};
|
|
2066
|
+
const out = [...days.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([day, items]) => ({
|
|
2067
|
+
day,
|
|
2068
|
+
items: items.map((item) => ({
|
|
2069
|
+
...item,
|
|
2070
|
+
checked: status(item, day)
|
|
2071
|
+
}))
|
|
2072
|
+
}));
|
|
2073
|
+
return {
|
|
2074
|
+
today: out.find((entry) => entry.day === today)?.items ?? [],
|
|
2075
|
+
days: out
|
|
2076
|
+
};
|
|
2077
|
+
}
|
|
1902
2078
|
//#endregion
|
|
1903
2079
|
//#region src/host/learner.js
|
|
1904
2080
|
const PROFILE_REFRESH_MS = 10 * 6e4;
|
|
2081
|
+
function agendaItem(raw) {
|
|
2082
|
+
const kind = raw?.kind === "lesson" || raw?.kind === "review" ? raw.kind : null;
|
|
2083
|
+
const refId = text(raw?.refId, 80);
|
|
2084
|
+
if (!kind || !refId) return null;
|
|
2085
|
+
return {
|
|
2086
|
+
kind,
|
|
2087
|
+
refId,
|
|
2088
|
+
courseId: /^crs_[a-z0-9]{6,40}$/u.test(String(raw.courseId)) ? raw.courseId : null,
|
|
2089
|
+
title: text(raw.title, 160),
|
|
2090
|
+
courseTitle: text(raw.courseTitle, 160),
|
|
2091
|
+
courseEmoji: text(raw.courseEmoji, 8),
|
|
2092
|
+
minutes: Math.min(240, Math.max(0, Math.round(Number(raw.minutes) || 0)))
|
|
2093
|
+
};
|
|
2094
|
+
}
|
|
1905
2095
|
function createLearner({ store, llm, now = () => /* @__PURE__ */ new Date(), logger }) {
|
|
1906
2096
|
let lastRefresh = 0;
|
|
1907
2097
|
let refreshing = null;
|
|
@@ -1991,9 +2181,12 @@ function createLearner({ store, llm, now = () => /* @__PURE__ */ new Date(), log
|
|
|
1991
2181
|
0
|
|
1992
2182
|
]
|
|
1993
2183
|
},
|
|
1994
|
-
exams: Array.isArray(plan.exams) ? plan.exams : []
|
|
2184
|
+
exams: Array.isArray(plan.exams) ? plan.exams : [],
|
|
2185
|
+
checks: Array.isArray(plan.checks) ? plan.checks : [],
|
|
2186
|
+
agenda: plan.agenda && typeof plan.agenda === "object" ? plan.agenda : {}
|
|
1995
2187
|
};
|
|
1996
2188
|
}
|
|
2189
|
+
const validDay = (value) => /^\d{4}-\d{2}-\d{2}$/u.test(String(value ?? "")) ? value : null;
|
|
1997
2190
|
async function activeCourses() {
|
|
1998
2191
|
const ids = await store.listIds(store.paths.courses(), "crs");
|
|
1999
2192
|
const result = [];
|
|
@@ -2009,25 +2202,56 @@ function createLearner({ store, llm, now = () => /* @__PURE__ */ new Date(), log
|
|
|
2009
2202
|
}
|
|
2010
2203
|
async function plan({ today } = {}) {
|
|
2011
2204
|
const file = await readPlanFile();
|
|
2012
|
-
const day =
|
|
2205
|
+
const day = validDay(today) ?? toDay(now());
|
|
2013
2206
|
const active = await activeCourses();
|
|
2207
|
+
const tickedLessons = new Set(file.checks.filter((check) => check.kind === "lesson" && check.day <= day).map((check) => check.refId));
|
|
2208
|
+
const tickedToday = new Set(file.checks.filter((check) => check.day === day).map((check) => check.refId));
|
|
2014
2209
|
const reviews = [];
|
|
2210
|
+
const done = {
|
|
2211
|
+
lessons: /* @__PURE__ */ new Map(),
|
|
2212
|
+
reviews: /* @__PURE__ */ new Map()
|
|
2213
|
+
};
|
|
2015
2214
|
for (const { course, progress } of active) {
|
|
2215
|
+
const label = {
|
|
2216
|
+
courseId: course.id,
|
|
2217
|
+
courseTitle: course.title,
|
|
2218
|
+
courseEmoji: course.emoji ?? ""
|
|
2219
|
+
};
|
|
2016
2220
|
const index = conceptIndex(course);
|
|
2017
2221
|
for (const [conceptId, card] of Object.entries(progress.reviews ?? {})) {
|
|
2018
2222
|
const concept = index.get(conceptId);
|
|
2019
2223
|
if (!concept) continue;
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2224
|
+
const id = `${course.id}:${conceptId}`;
|
|
2225
|
+
if (card.lastReviewedAt) done.reviews.set(id, toDay(new Date(card.lastReviewedAt)));
|
|
2226
|
+
if (!tickedToday.has(id)) reviews.push({
|
|
2227
|
+
id,
|
|
2228
|
+
...label,
|
|
2023
2229
|
title: concept.name,
|
|
2024
2230
|
dueDay: toDay(new Date(card.dueAt)),
|
|
2025
2231
|
minutes: 5
|
|
2026
2232
|
});
|
|
2027
2233
|
}
|
|
2234
|
+
for (const { lesson } of flattenLessons(course)) {
|
|
2235
|
+
const record = progress.lessons?.[lesson.id];
|
|
2236
|
+
if (record?.status === "done" && record.completedAt) done.lessons.set(lesson.id, {
|
|
2237
|
+
day: toDay(new Date(record.completedAt)),
|
|
2238
|
+
item: {
|
|
2239
|
+
kind: "lesson",
|
|
2240
|
+
refId: lesson.id,
|
|
2241
|
+
title: lesson.title,
|
|
2242
|
+
minutes: lesson.estMinutes,
|
|
2243
|
+
...label
|
|
2244
|
+
}
|
|
2245
|
+
});
|
|
2246
|
+
}
|
|
2028
2247
|
}
|
|
2248
|
+
const spent = /* @__PURE__ */ new Map();
|
|
2249
|
+
for (const { day: doneDay, item } of done.lessons.values()) if (doneDay === day) spent.set(item.refId, item.minutes);
|
|
2250
|
+
for (const item of file.agenda[day] ?? []) if (item.kind === "review" && (done.reviews.get(item.refId) === day || tickedToday.has(item.refId))) spent.set(item.refId, item.minutes);
|
|
2251
|
+
for (const check of file.checks) if (check.day === day && check.item) spent.set(check.refId, check.item.minutes);
|
|
2029
2252
|
const built = buildPlan({
|
|
2030
2253
|
today: day,
|
|
2254
|
+
spentToday: [...spent.values()].reduce((sum, minutes) => sum + (Number(minutes) || 0), 0),
|
|
2031
2255
|
settings: file.settings,
|
|
2032
2256
|
courses: active.map(({ course, progress }) => ({
|
|
2033
2257
|
id: course.id,
|
|
@@ -2038,7 +2262,7 @@ function createLearner({ store, llm, now = () => /* @__PURE__ */ new Date(), log
|
|
|
2038
2262
|
id: lesson.id,
|
|
2039
2263
|
title: lesson.title,
|
|
2040
2264
|
minutes: lesson.estMinutes,
|
|
2041
|
-
done: progress.lessons?.[lesson.id]?.status === "done"
|
|
2265
|
+
done: progress.lessons?.[lesson.id]?.status === "done" || tickedLessons.has(lesson.id)
|
|
2042
2266
|
}))
|
|
2043
2267
|
})),
|
|
2044
2268
|
reviews,
|
|
@@ -2049,13 +2273,57 @@ function createLearner({ store, llm, now = () => /* @__PURE__ */ new Date(), log
|
|
|
2049
2273
|
courseId: exam.courseId ?? null
|
|
2050
2274
|
}))
|
|
2051
2275
|
});
|
|
2276
|
+
const planned = (built.days.find((entry) => entry.day === day)?.items ?? []).filter((item) => item.kind !== "exam").map(agendaItem);
|
|
2277
|
+
const oldest = addDays(day, -180);
|
|
2278
|
+
const merged = mergeAgenda(file.agenda[day] ?? [], planned).slice(0, 40);
|
|
2279
|
+
let agenda = file.agenda;
|
|
2280
|
+
if (JSON.stringify(merged) !== JSON.stringify(file.agenda[day] ?? []) || Object.keys(file.agenda).some((key) => key < oldest)) agenda = (await store.updateJson(store.paths.plan(), {}, (current) => {
|
|
2281
|
+
const kept = Object.fromEntries(Object.entries(current.agenda ?? {}).filter(([key]) => validDay(key) && key >= oldest));
|
|
2282
|
+
kept[day] = mergeAgenda(kept[day] ?? [], planned).slice(0, 40);
|
|
2283
|
+
return {
|
|
2284
|
+
...current,
|
|
2285
|
+
agenda: kept
|
|
2286
|
+
};
|
|
2287
|
+
})).agenda;
|
|
2288
|
+
const calendar = planCalendar({
|
|
2289
|
+
today: day,
|
|
2290
|
+
agenda,
|
|
2291
|
+
plan: built,
|
|
2292
|
+
done,
|
|
2293
|
+
checks: file.checks
|
|
2294
|
+
});
|
|
2052
2295
|
return {
|
|
2053
2296
|
settings: file.settings,
|
|
2054
2297
|
exams: file.exams,
|
|
2055
2298
|
plan: built,
|
|
2056
|
-
today:
|
|
2299
|
+
today: calendar.today,
|
|
2300
|
+
calendar: calendar.days
|
|
2057
2301
|
};
|
|
2058
2302
|
}
|
|
2303
|
+
async function check({ today, day, item }) {
|
|
2304
|
+
const current = validDay(today) ?? toDay(now());
|
|
2305
|
+
const when = validDay(day);
|
|
2306
|
+
const entry = agendaItem(item ?? {});
|
|
2307
|
+
if (!when || !entry || when > current) throw new TutorError("cannot-tick", "Only today or an earlier day can be ticked");
|
|
2308
|
+
await store.updateJson(store.paths.plan(), {}, (current) => ({
|
|
2309
|
+
...current,
|
|
2310
|
+
checks: [...(current.checks ?? []).filter((existing) => !(existing.refId === entry.refId && existing.day === when)), {
|
|
2311
|
+
refId: entry.refId,
|
|
2312
|
+
kind: entry.kind,
|
|
2313
|
+
day: when,
|
|
2314
|
+
item: entry,
|
|
2315
|
+
at: now().toISOString()
|
|
2316
|
+
}].slice(-1e3)
|
|
2317
|
+
}));
|
|
2318
|
+
return plan({ today });
|
|
2319
|
+
}
|
|
2320
|
+
async function uncheck({ today, day, refId }) {
|
|
2321
|
+
await store.updateJson(store.paths.plan(), {}, (current) => ({
|
|
2322
|
+
...current,
|
|
2323
|
+
checks: (current.checks ?? []).filter((existing) => !(existing.refId === refId && existing.day === day))
|
|
2324
|
+
}));
|
|
2325
|
+
return plan({ today });
|
|
2326
|
+
}
|
|
2059
2327
|
async function savePlanSettings({ weeklyMinutes, studyDays }) {
|
|
2060
2328
|
await store.updateJson(store.paths.plan(), {}, (current) => ({
|
|
2061
2329
|
...current,
|
|
@@ -2068,7 +2336,7 @@ function createLearner({ store, llm, now = () => /* @__PURE__ */ new Date(), log
|
|
|
2068
2336
|
}
|
|
2069
2337
|
async function addExam({ title, day, courseId }) {
|
|
2070
2338
|
const name = text(title, 120);
|
|
2071
|
-
if (!name || !/^\d{4}-\d{2}-\d{2}$/u.test(String(day))) throw new
|
|
2339
|
+
if (!name || !/^\d{4}-\d{2}-\d{2}$/u.test(String(day))) throw new TutorError("exam-incomplete", "Exam needs a title and a date");
|
|
2072
2340
|
await store.updateJson(store.paths.plan(), {}, (current) => ({
|
|
2073
2341
|
...current,
|
|
2074
2342
|
exams: [...current.exams ?? [], {
|
|
@@ -2122,6 +2390,8 @@ function createLearner({ store, llm, now = () => /* @__PURE__ */ new Date(), log
|
|
|
2122
2390
|
removeNote,
|
|
2123
2391
|
noteLesson,
|
|
2124
2392
|
plan,
|
|
2393
|
+
check,
|
|
2394
|
+
uncheck,
|
|
2125
2395
|
savePlanSettings,
|
|
2126
2396
|
addExam,
|
|
2127
2397
|
removeExam,
|
|
@@ -2220,6 +2490,7 @@ function round2(value) {
|
|
|
2220
2490
|
//#region src/host/lessons.js
|
|
2221
2491
|
const EVENTS_KEPT = 200;
|
|
2222
2492
|
const STEP_ID = /^s\d{1,4}$/u;
|
|
2493
|
+
const BOARD_JOBS = ["lesson/board", "lesson/prefetch"];
|
|
2223
2494
|
function createLessons({ store, llm, jobs, courses, learner, now = () => /* @__PURE__ */ new Date() }) {
|
|
2224
2495
|
const stamp = () => now().toISOString();
|
|
2225
2496
|
async function context(courseId, lessonId) {
|
|
@@ -2263,7 +2534,7 @@ function createLessons({ store, llm, jobs, courses, learner, now = () => /* @__P
|
|
|
2263
2534
|
mastery: Object.fromEntries(row.lesson.concepts.map((concept) => [concept.id, masteryPercent(progress.concepts?.[concept.id] ?? createConceptState(concept.id, concept.name))]))
|
|
2264
2535
|
};
|
|
2265
2536
|
}
|
|
2266
|
-
async function buildBoard({ course, row, signal, onStep, directive, style }) {
|
|
2537
|
+
async function buildBoard({ course, row, signal, onStep, onProgress, directive, style }) {
|
|
2267
2538
|
const progress = await readProgress(store, course.id);
|
|
2268
2539
|
const profile = await readProfile(store);
|
|
2269
2540
|
const materials = await readMaterials(store, course.materialIds ?? []);
|
|
@@ -2275,7 +2546,7 @@ function createLessons({ store, llm, jobs, courses, learner, now = () => /* @__P
|
|
|
2275
2546
|
const kindDirective = row.lesson.kind === "remedial" ? "remediate" : row.lesson.kind === "review" ? "review" : row.lesson.kind === "challenge" ? "challenge" : void 0;
|
|
2276
2547
|
return generateSteps(llm, {
|
|
2277
2548
|
purpose: "teach",
|
|
2278
|
-
effort: "
|
|
2549
|
+
effort: "lowest",
|
|
2279
2550
|
prompt: lessonPrompt({
|
|
2280
2551
|
course,
|
|
2281
2552
|
unit: row.unit,
|
|
@@ -2295,6 +2566,7 @@ function createLessons({ store, llm, jobs, courses, learner, now = () => /* @__P
|
|
|
2295
2566
|
signal,
|
|
2296
2567
|
conceptIds: row.lesson.concepts.map((concept) => concept.id),
|
|
2297
2568
|
onStep,
|
|
2569
|
+
onProgress,
|
|
2298
2570
|
minSteps: 3
|
|
2299
2571
|
});
|
|
2300
2572
|
}
|
|
@@ -2319,6 +2591,7 @@ function createLessons({ store, llm, jobs, courses, learner, now = () => /* @__P
|
|
|
2319
2591
|
row,
|
|
2320
2592
|
signal: api.signal,
|
|
2321
2593
|
onStep: (step) => api.push(step),
|
|
2594
|
+
onProgress: (info) => api.preview(info),
|
|
2322
2595
|
style: text(style, 300) || void 0
|
|
2323
2596
|
});
|
|
2324
2597
|
const saved = {
|
|
@@ -2351,7 +2624,8 @@ function createLessons({ store, llm, jobs, courses, learner, now = () => /* @__P
|
|
|
2351
2624
|
course,
|
|
2352
2625
|
row: next,
|
|
2353
2626
|
signal: api.signal,
|
|
2354
|
-
onStep: (step) => api.push(step)
|
|
2627
|
+
onStep: (step) => api.push(step),
|
|
2628
|
+
onProgress: (info) => api.preview(info)
|
|
2355
2629
|
});
|
|
2356
2630
|
const saved = {
|
|
2357
2631
|
...board,
|
|
@@ -2371,10 +2645,18 @@ function createLessons({ store, llm, jobs, courses, learner, now = () => /* @__P
|
|
|
2371
2645
|
});
|
|
2372
2646
|
}).catch(() => void 0);
|
|
2373
2647
|
}
|
|
2648
|
+
function followedJob(scope, kinds, meta) {
|
|
2649
|
+
const job = scope?.jobId ? jobs.get(scope.jobId) : null;
|
|
2650
|
+
return job && kinds.includes(job.kind) && Object.entries(meta).every(([key, value]) => job.meta?.[key] === value) ? job : null;
|
|
2651
|
+
}
|
|
2374
2652
|
async function scopeBoard(scope) {
|
|
2375
2653
|
if (scope?.kind === "lesson") {
|
|
2376
2654
|
const { course, row } = await context(scope.courseId, scope.lessonId);
|
|
2377
|
-
const
|
|
2655
|
+
const writing = followedJob(scope, BOARD_JOBS, {
|
|
2656
|
+
courseId: course.id,
|
|
2657
|
+
lessonId: row.lesson.id
|
|
2658
|
+
});
|
|
2659
|
+
const board = writing ? { steps: writing.partial } : await store.readJson(store.paths.board(course.id, row.lesson.id), null);
|
|
2378
2660
|
return {
|
|
2379
2661
|
course,
|
|
2380
2662
|
lesson: row.lesson,
|
|
@@ -2383,18 +2665,20 @@ function createLessons({ store, llm, jobs, courses, learner, now = () => /* @__P
|
|
|
2383
2665
|
};
|
|
2384
2666
|
}
|
|
2385
2667
|
if (scope?.kind === "thread" || scope?.kind === "short") {
|
|
2386
|
-
const
|
|
2387
|
-
const
|
|
2388
|
-
|
|
2389
|
-
|
|
2668
|
+
const thread = scope.kind === "thread";
|
|
2669
|
+
const writing = thread ? followedJob(scope, ["assist/turn"], { threadId: scope.id }) : followedJob(scope, ["short/make"], { id: scope.id });
|
|
2670
|
+
const item = await store.readJson(store.paths.item(thread ? "assist" : "shorts", scope.id), null);
|
|
2671
|
+
if (!item && !writing) throw new TutorError("not-found", "Not found");
|
|
2672
|
+
const board = writing ? { steps: writing.partial } : thread ? item.turns?.[Math.max(0, Math.min(item.turns.length - 1, Number(scope.turn) || 0))]?.board : item.board;
|
|
2673
|
+
const language = item?.language ?? detectLanguage((board?.steps ?? []).map((step) => step.say).join(" "));
|
|
2390
2674
|
return {
|
|
2391
2675
|
course: null,
|
|
2392
2676
|
lesson: {
|
|
2393
|
-
title: item.title,
|
|
2677
|
+
title: item?.title ?? writing.meta?.title ?? "",
|
|
2394
2678
|
objectives: []
|
|
2395
2679
|
},
|
|
2396
2680
|
board,
|
|
2397
|
-
language
|
|
2681
|
+
language
|
|
2398
2682
|
};
|
|
2399
2683
|
}
|
|
2400
2684
|
throw new TutorError("invalid", "Unknown board");
|
|
@@ -2412,7 +2696,7 @@ function createLessons({ store, llm, jobs, courses, learner, now = () => /* @__P
|
|
|
2412
2696
|
const anchorFor = (board, stepIndex, anchorStepId) => STEP_ID.test(String(anchorStepId)) ? anchorStepId : board?.steps?.[Math.max(0, Number(stepIndex) || 0)]?.id ?? "s1";
|
|
2413
2697
|
function ask({ scope, stepIndex = 0, question, anchorStepId }) {
|
|
2414
2698
|
const request = text(question, 1500);
|
|
2415
|
-
if (!request) throw new TutorError("
|
|
2699
|
+
if (!request) throw new TutorError("empty-question", "Type your question");
|
|
2416
2700
|
return jobs.start("lesson/ask", async (api) => {
|
|
2417
2701
|
const { course, lesson, board, language } = await scopeBoard(scope);
|
|
2418
2702
|
const profile = await readProfile(store);
|
|
@@ -2454,11 +2738,11 @@ function createLessons({ store, llm, jobs, courses, learner, now = () => /* @__P
|
|
|
2454
2738
|
function reexplain({ scope, stepIndex = 0, style = "example", anchorStepId }) {
|
|
2455
2739
|
return jobs.start("lesson/reexplain", async (api) => {
|
|
2456
2740
|
const { course, lesson, board, language } = await scopeBoard(scope);
|
|
2457
|
-
if (!board?.steps?.length) throw new TutorError("
|
|
2741
|
+
if (!board?.steps?.length) throw new TutorError("no-board", "The board is not written yet. Try again once its first step is up.");
|
|
2458
2742
|
const index = Math.min(board.steps.length - 1, Math.max(0, Number(stepIndex) || 0));
|
|
2459
2743
|
const answer = await generateSteps(llm, {
|
|
2460
2744
|
purpose: "teach",
|
|
2461
|
-
effort: "
|
|
2745
|
+
effort: "lowest",
|
|
2462
2746
|
prompt: reexplainPrompt({
|
|
2463
2747
|
lesson,
|
|
2464
2748
|
language,
|
|
@@ -2943,12 +3227,22 @@ const REASONING_ROOM = Object.freeze({
|
|
|
2943
3227
|
off: 0,
|
|
2944
3228
|
none: 0,
|
|
2945
3229
|
minimal: 4e3,
|
|
2946
|
-
low:
|
|
3230
|
+
low: 16e3,
|
|
2947
3231
|
medium: 16e3,
|
|
2948
3232
|
high: 24e3,
|
|
2949
3233
|
xhigh: 48e3,
|
|
2950
3234
|
max: 48e3
|
|
2951
3235
|
});
|
|
3236
|
+
const EFFORT_ORDER = [
|
|
3237
|
+
"off",
|
|
3238
|
+
"none",
|
|
3239
|
+
"minimal",
|
|
3240
|
+
"low",
|
|
3241
|
+
"medium",
|
|
3242
|
+
"high",
|
|
3243
|
+
"xhigh",
|
|
3244
|
+
"max"
|
|
3245
|
+
];
|
|
2952
3246
|
const STOPPED_AT_LIMIT = /* @__PURE__ */ new Set(["max-tokens", "length"]);
|
|
2953
3247
|
const CALL_TIMEOUT_MS = 6 * 6e4;
|
|
2954
3248
|
const MAX_RETRIES = 2;
|
|
@@ -2973,7 +3267,10 @@ function toTutorError(code, fallback) {
|
|
|
2973
3267
|
}
|
|
2974
3268
|
function pickEffort(efforts, wanted) {
|
|
2975
3269
|
if (!efforts?.length || !wanted) return void 0;
|
|
2976
|
-
if (wanted === "lowest")
|
|
3270
|
+
if (wanted === "lowest") {
|
|
3271
|
+
const rank = (effort) => EFFORT_ORDER.includes(effort.id) ? EFFORT_ORDER.indexOf(effort.id) : EFFORT_ORDER.length;
|
|
3272
|
+
return efforts.reduce((lightest, effort) => rank(effort) < rank(lightest) ? effort : lightest).id;
|
|
3273
|
+
}
|
|
2977
3274
|
return (efforts.find((effort) => effort.id === wanted) ?? efforts.find((effort) => effort.id !== "off" && effort.id !== "none") ?? efforts[0]).id;
|
|
2978
3275
|
}
|
|
2979
3276
|
function outputBudget(maxTokens, effort, model) {
|
|
@@ -3007,7 +3304,7 @@ function createLlmPort({ getLlm, getDefault, getAttachments, prefs, logger }) {
|
|
|
3007
3304
|
provider: selection.provider,
|
|
3008
3305
|
model: selection.model
|
|
3009
3306
|
};
|
|
3010
|
-
throw new TutorError("llm-
|
|
3307
|
+
throw new TutorError("llm-missing", "No model is configured in DSH yet.");
|
|
3011
3308
|
}
|
|
3012
3309
|
async function modalities(purpose, signal) {
|
|
3013
3310
|
const llm = getLlm();
|
|
@@ -3041,9 +3338,9 @@ function createLlmPort({ getLlm, getDefault, getAttachments, prefs, logger }) {
|
|
|
3041
3338
|
}
|
|
3042
3339
|
return blocks;
|
|
3043
3340
|
}
|
|
3044
|
-
async function attempt({ purpose, effort, system, user, images, maxTokens, signal, onText }) {
|
|
3341
|
+
async function attempt({ purpose, effort, system, user, images, maxTokens, signal, onText, onReasoning }) {
|
|
3045
3342
|
const llm = getLlm();
|
|
3046
|
-
if (!llm) throw new TutorError("llm-
|
|
3343
|
+
if (!llm) throw new TutorError("llm-missing", "DSH has no model service in this profile.");
|
|
3047
3344
|
const target = route(purpose);
|
|
3048
3345
|
const timeout = AbortSignal.timeout(CALL_TIMEOUT_MS);
|
|
3049
3346
|
const fused = signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
@@ -3079,7 +3376,8 @@ function createLlmPort({ getLlm, getDefault, getAttachments, prefs, logger }) {
|
|
|
3079
3376
|
})) if (chunk.type === "text-delta" && typeof chunk.text === "string") {
|
|
3080
3377
|
textValue += chunk.text;
|
|
3081
3378
|
onText?.(chunk.text);
|
|
3082
|
-
} else if (chunk.type === "
|
|
3379
|
+
} else if (chunk.type === "reasoning-delta" && typeof chunk.text === "string") onReasoning?.(chunk.text);
|
|
3380
|
+
else if (chunk.type === "finish") finish = chunk.reason;
|
|
3083
3381
|
} catch (error) {
|
|
3084
3382
|
if (signal?.aborted) throw signal.reason ?? error;
|
|
3085
3383
|
throw Object.assign(toTutorError(timeout.aborted ? "TIMEOUT" : error?.code, error?.message), {
|
|
@@ -3122,7 +3420,8 @@ function createLlmPort({ getLlm, getDefault, getAttachments, prefs, logger }) {
|
|
|
3122
3420
|
return Object.freeze({
|
|
3123
3421
|
complete: (options) => call({
|
|
3124
3422
|
...options,
|
|
3125
|
-
onText: void 0
|
|
3423
|
+
onText: void 0,
|
|
3424
|
+
onReasoning: void 0
|
|
3126
3425
|
}),
|
|
3127
3426
|
stream: (options) => call(options),
|
|
3128
3427
|
route,
|
|
@@ -3782,7 +4081,7 @@ function createProjects({ store, llm, jobs, courses, projectsRoot = resolveProje
|
|
|
3782
4081
|
const content = await readWorkspace(folderOf(course, project));
|
|
3783
4082
|
if (content) parts.push(`Files in the project folder:\n${content}`);
|
|
3784
4083
|
}
|
|
3785
|
-
if (!parts.length) throw new TutorError("
|
|
4084
|
+
if (!parts.length) throw new TutorError("nothing-to-review", "Nothing to review yet: write a note or work in the project folder first");
|
|
3786
4085
|
const result = await generateJson(llm, {
|
|
3787
4086
|
purpose: "teach",
|
|
3788
4087
|
prompt: projectReviewPrompt({
|
|
@@ -3844,6 +4143,100 @@ function createProjects({ store, llm, jobs, courses, projectsRoot = resolveProje
|
|
|
3844
4143
|
});
|
|
3845
4144
|
}
|
|
3846
4145
|
//#endregion
|
|
4146
|
+
//#region src/host/research.js
|
|
4147
|
+
const MAX_QUERIES = 3;
|
|
4148
|
+
const RESULTS_PER_QUERY = 6;
|
|
4149
|
+
const MAX_SOURCES = 10;
|
|
4150
|
+
const UNAVAILABLE = /* @__PURE__ */ new Set([
|
|
4151
|
+
"WEB_PROVIDER_UNAVAILABLE",
|
|
4152
|
+
"WEB_PROVIDER_CONFIGURED_MISSING",
|
|
4153
|
+
"WEB_PROVIDER_CONFIGURED_UNAVAILABLE",
|
|
4154
|
+
"WEB_PROVIDER_AMBIGUOUS"
|
|
4155
|
+
]);
|
|
4156
|
+
function cleanQueries(raw, goal) {
|
|
4157
|
+
const queries = [...new Set((Array.isArray(raw) ? raw : []).map((query) => text(query, 120)).filter(Boolean))].slice(0, MAX_QUERIES);
|
|
4158
|
+
return queries.length ? queries : [text(goal, 120)].filter(Boolean);
|
|
4159
|
+
}
|
|
4160
|
+
function mergeSources(lists) {
|
|
4161
|
+
const seen = /* @__PURE__ */ new Set();
|
|
4162
|
+
const out = [];
|
|
4163
|
+
const longest = Math.max(0, ...lists.map((list) => list.length));
|
|
4164
|
+
for (let rank = 0; rank < longest && out.length < MAX_SOURCES; rank += 1) for (const list of lists) {
|
|
4165
|
+
const source = list[rank];
|
|
4166
|
+
const url = safeUrl(source?.url);
|
|
4167
|
+
if (!url || seen.has(url) || out.length >= MAX_SOURCES) continue;
|
|
4168
|
+
seen.add(url);
|
|
4169
|
+
out.push({
|
|
4170
|
+
url,
|
|
4171
|
+
title: text(source.title, 200) || new URL(url).hostname,
|
|
4172
|
+
snippet: text(source.snippet ?? source.text ?? source.excerpt, 400),
|
|
4173
|
+
publishedAt: text(source.publishedAt ?? source.date, 40)
|
|
4174
|
+
});
|
|
4175
|
+
}
|
|
4176
|
+
return out;
|
|
4177
|
+
}
|
|
4178
|
+
function safeUrl(value) {
|
|
4179
|
+
const raw = text(value, 500);
|
|
4180
|
+
if (!raw) return "";
|
|
4181
|
+
try {
|
|
4182
|
+
const url = new URL(raw);
|
|
4183
|
+
return url.protocol === "https:" || url.protocol === "http:" ? url.href : "";
|
|
4184
|
+
} catch {
|
|
4185
|
+
return "";
|
|
4186
|
+
}
|
|
4187
|
+
}
|
|
4188
|
+
function skipReason(error) {
|
|
4189
|
+
const code = String(error?.code ?? "");
|
|
4190
|
+
if (code === "WEB_PROVIDER_CREDENTIAL_MISSING") return "no-credential";
|
|
4191
|
+
if (UNAVAILABLE.has(code)) return "unavailable";
|
|
4192
|
+
return error ? "failed" : "empty";
|
|
4193
|
+
}
|
|
4194
|
+
function createResearch({ getWeb, llm, logger }) {
|
|
4195
|
+
async function run({ goal, language, signal, onSearching }) {
|
|
4196
|
+
const web = getWeb?.();
|
|
4197
|
+
if (typeof web?.search !== "function") return {
|
|
4198
|
+
skipped: "unavailable",
|
|
4199
|
+
sources: []
|
|
4200
|
+
};
|
|
4201
|
+
let queries;
|
|
4202
|
+
try {
|
|
4203
|
+
queries = await generateJson(llm, {
|
|
4204
|
+
purpose: "quick",
|
|
4205
|
+
prompt: researchQueriesPrompt({
|
|
4206
|
+
goal,
|
|
4207
|
+
language
|
|
4208
|
+
}),
|
|
4209
|
+
maxTokens: 400,
|
|
4210
|
+
signal,
|
|
4211
|
+
normalize: (raw) => cleanQueries(raw?.queries, goal)
|
|
4212
|
+
});
|
|
4213
|
+
} catch (error) {
|
|
4214
|
+
if (signal?.aborted) throw error;
|
|
4215
|
+
queries = cleanQueries([], goal);
|
|
4216
|
+
}
|
|
4217
|
+
onSearching?.(queries);
|
|
4218
|
+
const settled = await Promise.allSettled(queries.map((query) => web.search({
|
|
4219
|
+
query,
|
|
4220
|
+
maxResults: RESULTS_PER_QUERY
|
|
4221
|
+
}, signal)));
|
|
4222
|
+
signal?.throwIfAborted?.();
|
|
4223
|
+
const failure = settled.find((result) => result.status === "rejected")?.reason;
|
|
4224
|
+
if (failure) logger?.debug?.("aha-tutor web research: %s", failure?.code ?? failure?.message);
|
|
4225
|
+
const sources = mergeSources(settled.filter((result) => result.status === "fulfilled").map((result) => result.value?.sources ?? []));
|
|
4226
|
+
if (!sources.length) return {
|
|
4227
|
+
skipped: skipReason(failure),
|
|
4228
|
+
queries,
|
|
4229
|
+
sources: []
|
|
4230
|
+
};
|
|
4231
|
+
return {
|
|
4232
|
+
queries,
|
|
4233
|
+
sources,
|
|
4234
|
+
at: (/* @__PURE__ */ new Date()).toISOString()
|
|
4235
|
+
};
|
|
4236
|
+
}
|
|
4237
|
+
return Object.freeze({ run });
|
|
4238
|
+
}
|
|
4239
|
+
//#endregion
|
|
3847
4240
|
//#region src/host/rpc.js
|
|
3848
4241
|
const ID = (prefix) => new RegExp(`^${prefix}_[a-z0-9]{6,40}$`, "u");
|
|
3849
4242
|
const IDS = {
|
|
@@ -3873,19 +4266,24 @@ function idList(value, prefix, max = 8) {
|
|
|
3873
4266
|
}
|
|
3874
4267
|
function scopeOf(payload) {
|
|
3875
4268
|
const scope = payload?.scope ?? {};
|
|
4269
|
+
const jobId = optional(scope, "jobId", "job");
|
|
4270
|
+
const writing = jobId ? { jobId } : {};
|
|
3876
4271
|
if (scope.kind === "lesson") return {
|
|
3877
4272
|
kind: "lesson",
|
|
3878
4273
|
courseId: need(scope, "courseId", "crs"),
|
|
3879
|
-
lessonId: need(scope, "lessonId", "les")
|
|
4274
|
+
lessonId: need(scope, "lessonId", "les"),
|
|
4275
|
+
...writing
|
|
3880
4276
|
};
|
|
3881
4277
|
if (scope.kind === "thread") return {
|
|
3882
4278
|
kind: "thread",
|
|
3883
4279
|
id: need(scope, "id", "thd"),
|
|
3884
|
-
turn: Number(scope.turn) || 0
|
|
4280
|
+
turn: Number(scope.turn) || 0,
|
|
4281
|
+
...writing
|
|
3885
4282
|
};
|
|
3886
4283
|
if (scope.kind === "short") return {
|
|
3887
4284
|
kind: "short",
|
|
3888
|
-
id: need(scope, "id", "sht")
|
|
4285
|
+
id: need(scope, "id", "sht"),
|
|
4286
|
+
...writing
|
|
3889
4287
|
};
|
|
3890
4288
|
throw new TutorError("invalid", "Invalid board scope");
|
|
3891
4289
|
}
|
|
@@ -3908,7 +4306,7 @@ const STYLES = [
|
|
|
3908
4306
|
"simpler",
|
|
3909
4307
|
"deeper"
|
|
3910
4308
|
];
|
|
3911
|
-
function createRoutes({ courses, lessons, learner, studio, projects, jobs, settings, tts, plugin, info }) {
|
|
4309
|
+
function createRoutes({ courses, lessons, learner, studio, projects, jobs, settings, tts, plugin, info, speechInput }) {
|
|
3912
4310
|
return {
|
|
3913
4311
|
"app/info": () => info(),
|
|
3914
4312
|
"job/get": (payload) => jobs.get(need(payload, "id", "job"), { since: Math.max(0, Number(payload.since) || 0) }) ?? { status: "missing" },
|
|
@@ -3919,6 +4317,7 @@ function createRoutes({ courses, lessons, learner, studio, projects, jobs, setti
|
|
|
3919
4317
|
goal: payload.goal,
|
|
3920
4318
|
materialIds: idList(payload.materialIds, "mat"),
|
|
3921
4319
|
quick: payload.quick === true,
|
|
4320
|
+
web: payload.web === true,
|
|
3922
4321
|
language: typeof payload.language === "string" ? payload.language.slice(0, 12) : void 0
|
|
3923
4322
|
}) }),
|
|
3924
4323
|
"course/proposal-apply": (payload) => courses.applyProposal({ courseId: need(payload, "courseId", "crs") }),
|
|
@@ -4066,6 +4465,16 @@ function createRoutes({ courses, lessons, learner, studio, projects, jobs, setti
|
|
|
4066
4465
|
"profile/clear": () => learner.clearProfile(),
|
|
4067
4466
|
"profile/remove-note": (payload) => learner.removeNote({ id: need(payload, "id", "nte") }),
|
|
4068
4467
|
"plan/get": (payload) => learner.plan({ today: payload.today }),
|
|
4468
|
+
"plan/check": (payload) => learner.check({
|
|
4469
|
+
today: payload.today,
|
|
4470
|
+
day: payload.day,
|
|
4471
|
+
item: payload.item
|
|
4472
|
+
}),
|
|
4473
|
+
"plan/uncheck": (payload) => learner.uncheck({
|
|
4474
|
+
today: payload.today,
|
|
4475
|
+
day: String(payload.day ?? "").slice(0, 10),
|
|
4476
|
+
refId: String(payload.refId ?? "").slice(0, 80)
|
|
4477
|
+
}),
|
|
4069
4478
|
"plan/settings": (payload) => learner.savePlanSettings({
|
|
4070
4479
|
weeklyMinutes: payload.weeklyMinutes,
|
|
4071
4480
|
studyDays: payload.studyDays
|
|
@@ -4173,6 +4582,14 @@ function createRoutes({ courses, lessons, learner, studio, projects, jobs, setti
|
|
|
4173
4582
|
cached: result.cached === true
|
|
4174
4583
|
};
|
|
4175
4584
|
},
|
|
4585
|
+
"speech/status": () => speechInput.status(),
|
|
4586
|
+
"speech/transcribe": async (payload) => {
|
|
4587
|
+
const encoded = typeof payload.base64 === "string" ? payload.base64 : "";
|
|
4588
|
+
if (encoded.length > Math.ceil(3840044 / 3) * 4) throw new TutorError("speech-too-long", "The recording is too long");
|
|
4589
|
+
const audio = Buffer.from(encoded, "base64");
|
|
4590
|
+
if (!encoded || audio.toString("base64") !== encoded) throw new TutorError("speech-audio", "The recording could not be read");
|
|
4591
|
+
return speechInput.transcribe({ audio: new Uint8Array(audio.buffer, audio.byteOffset, audio.byteLength) });
|
|
4592
|
+
},
|
|
4176
4593
|
"settings/get": () => settings.get(),
|
|
4177
4594
|
"settings/save": (payload) => settings.save(payload.settings ?? {}),
|
|
4178
4595
|
"settings/set-key": (payload) => settings.setKey({
|
|
@@ -4198,13 +4615,12 @@ function createRpcHandler(routes, { logger, checkPeer = isOwner } = {}) {
|
|
|
4198
4615
|
};
|
|
4199
4616
|
} catch (error) {
|
|
4200
4617
|
if (signal?.aborted) throw error;
|
|
4201
|
-
|
|
4618
|
+
if (![
|
|
4202
4619
|
"TutorError",
|
|
4203
4620
|
"MaterialError",
|
|
4204
4621
|
"TtsError"
|
|
4205
|
-
].includes(error?.name);
|
|
4206
|
-
|
|
4207
|
-
return failure(known ? error.code ?? "failed" : "failed", known ? error.message : publicMessage(error));
|
|
4622
|
+
].includes(error?.name)) logger?.warn?.("aha-tutor %s failed: %s", endpoint, error?.message);
|
|
4623
|
+
return failure(publicCode(error), publicMessage(error));
|
|
4208
4624
|
}
|
|
4209
4625
|
};
|
|
4210
4626
|
}
|
|
@@ -4364,15 +4780,15 @@ function checkEndpoint(value) {
|
|
|
4364
4780
|
try {
|
|
4365
4781
|
url = new URL(String(value));
|
|
4366
4782
|
} catch {
|
|
4367
|
-
throw new TtsError("
|
|
4783
|
+
throw new TtsError("address", "TTS address is not a valid URL");
|
|
4368
4784
|
}
|
|
4369
4785
|
const local = [
|
|
4370
4786
|
"localhost",
|
|
4371
4787
|
"127.0.0.1",
|
|
4372
4788
|
"[::1]"
|
|
4373
4789
|
].includes(url.hostname);
|
|
4374
|
-
if (url.protocol !== "https:" && !(url.protocol === "http:" && local)) throw new TtsError("
|
|
4375
|
-
if (url.username || url.password) throw new TtsError("
|
|
4790
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && local)) throw new TtsError("address", "TTS address must use https (http only for localhost)");
|
|
4791
|
+
if (url.username || url.password) throw new TtsError("address", "TTS address must not contain credentials");
|
|
4376
4792
|
return url;
|
|
4377
4793
|
}
|
|
4378
4794
|
const SENTENCE_END = /([。!?!?;;…]+|\.(?=\s)|\n+)/u;
|
|
@@ -4475,7 +4891,7 @@ const synthesizers = {
|
|
|
4475
4891
|
return readBounded(response);
|
|
4476
4892
|
},
|
|
4477
4893
|
async volcengine({ text, config, secret, rate, fetchImpl, signal }) {
|
|
4478
|
-
if (!config.appId) throw new TtsError("
|
|
4894
|
+
if (!config.appId) throw new TtsError("app-id", "Volcengine App ID is required");
|
|
4479
4895
|
const response = await boundedFetch(fetchImpl, "https://openspeech.bytedance.com/api/v1/tts", {
|
|
4480
4896
|
method: "POST",
|
|
4481
4897
|
headers: {
|
|
@@ -4556,9 +4972,9 @@ const synthesizers = {
|
|
|
4556
4972
|
},
|
|
4557
4973
|
async azure({ text, config, secret, rate, fetchImpl, signal }) {
|
|
4558
4974
|
const region = String(config.region || "").trim().toLowerCase();
|
|
4559
|
-
if (!/^[a-z0-9]{2,30}$/u.test(region)) throw new TtsError("
|
|
4975
|
+
if (!/^[a-z0-9]{2,30}$/u.test(region)) throw new TtsError("azure", "Azure region is invalid");
|
|
4560
4976
|
const voice = String(config.voice || "zh-CN-XiaoxiaoNeural");
|
|
4561
|
-
if (!/^[A-Za-z]{2,3}-[A-Za-z]{2,4}-[A-Za-z0-9:]+$/u.test(voice)) throw new TtsError("
|
|
4977
|
+
if (!/^[A-Za-z]{2,3}-[A-Za-z]{2,4}-[A-Za-z0-9:]+$/u.test(voice)) throw new TtsError("azure", "Azure voice name is invalid");
|
|
4562
4978
|
const lang = voice.split("-").slice(0, 2).join("-");
|
|
4563
4979
|
const percent = Math.round((rateNumber(rate) - 1) * 100);
|
|
4564
4980
|
const ssml = `<speak version="1.0" xmlns="http://www.w3.org/2001/10/synthesis" xml:lang="${lang}"><voice name="${voice}"><prosody rate="${percent >= 0 ? "+" : ""}${percent}%">${xmlEscape(text)}</prosody></voice></speak>`;
|
|
@@ -4598,7 +5014,7 @@ function createTts({ cacheDir, fetchImpl = globalThis.fetch, getSecret }) {
|
|
|
4598
5014
|
async function synthesize({ provider, config = {}, text, rate = 1, signal }) {
|
|
4599
5015
|
if (!TTS_PROVIDERS.includes(provider)) throw new TtsError("config", "Unknown TTS provider");
|
|
4600
5016
|
const content = String(text ?? "").trim().slice(0, 4e3);
|
|
4601
|
-
if (!content) throw new TtsError("
|
|
5017
|
+
if (!content) throw new TtsError("empty", "Nothing to speak");
|
|
4602
5018
|
const merged = {
|
|
4603
5019
|
...providerDefaults(provider),
|
|
4604
5020
|
...config
|
|
@@ -4614,7 +5030,7 @@ function createTts({ cacheDir, fetchImpl = globalThis.fetch, getSecret }) {
|
|
|
4614
5030
|
};
|
|
4615
5031
|
} catch {}
|
|
4616
5032
|
const secret = await getSecret(provider);
|
|
4617
|
-
if (!secret) throw new TtsError("
|
|
5033
|
+
if (!secret) throw new TtsError("no-key", "No key saved for this TTS provider");
|
|
4618
5034
|
const parts = [];
|
|
4619
5035
|
for (const chunk of splitForTts(content, CHUNK_UNITS[provider])) parts.push(await synthesizers[provider]({
|
|
4620
5036
|
text: chunk,
|
|
@@ -4681,6 +5097,7 @@ const PREF_DEFAULTS = Object.freeze({
|
|
|
4681
5097
|
voiceRate: 1,
|
|
4682
5098
|
boardTheme: "auto",
|
|
4683
5099
|
boardFont: "hand",
|
|
5100
|
+
boardView: "canvas",
|
|
4684
5101
|
ttsProvider: "openai",
|
|
4685
5102
|
tts: Object.fromEntries(TTS_PROVIDERS.map((provider) => [provider, providerDefaults(provider)]))
|
|
4686
5103
|
});
|
|
@@ -4711,6 +5128,7 @@ function cleanPrefs(input = {}, base = PREF_DEFAULTS) {
|
|
|
4711
5128
|
"dark"
|
|
4712
5129
|
].includes(input.boardTheme)) next.boardTheme = input.boardTheme;
|
|
4713
5130
|
if (["hand", "standard"].includes(input.boardFont)) next.boardFont = input.boardFont;
|
|
5131
|
+
if (["canvas", "pages"].includes(input.boardView)) next.boardView = input.boardView;
|
|
4714
5132
|
if (TTS_PROVIDERS.includes(input.ttsProvider)) next.ttsProvider = input.ttsProvider;
|
|
4715
5133
|
for (const provider of TTS_PROVIDERS) {
|
|
4716
5134
|
const patch = input.tts?.[provider];
|
|
@@ -4747,7 +5165,7 @@ function createKeyStore({ getCredentials, credentialKey }) {
|
|
|
4747
5165
|
};
|
|
4748
5166
|
const service = () => {
|
|
4749
5167
|
const credentials = getCredentials();
|
|
4750
|
-
if (!credentials) throw new TutorError("
|
|
5168
|
+
if (!credentials) throw new TutorError("no-credentials", "DSH credentials are not available in this profile");
|
|
4751
5169
|
return credentials;
|
|
4752
5170
|
};
|
|
4753
5171
|
return {
|
|
@@ -4766,7 +5184,7 @@ function createKeyStore({ getCredentials, credentialKey }) {
|
|
|
4766
5184
|
},
|
|
4767
5185
|
async write(provider, value) {
|
|
4768
5186
|
const key = String(value ?? "").trim();
|
|
4769
|
-
if (!/^[\x21-\x7E]{8,1024}$/u.test(key)) throw new TutorError("
|
|
5187
|
+
if (!/^[\x21-\x7E]{8,1024}$/u.test(key)) throw new TutorError("bad-key", "The key looks wrong (8–1024 printable characters, no spaces)");
|
|
4770
5188
|
await service().modifyRecord(keyOf(provider), async () => ({
|
|
4771
5189
|
kind: "api-key",
|
|
4772
5190
|
key
|
|
@@ -4928,7 +5346,7 @@ function createStudio({ store, llm, jobs, now = () => /* @__PURE__ */ new Date()
|
|
|
4928
5346
|
function digest({ id }) {
|
|
4929
5347
|
return jobs.start("material/digest", async (api) => {
|
|
4930
5348
|
const [material] = await readMaterials(store, [assertId(id, "mat")]);
|
|
4931
|
-
if (!material?.chunks.length) throw new TutorError("
|
|
5349
|
+
if (!material?.chunks.length) throw new TutorError("material-no-text", "This material has no text to digest");
|
|
4932
5350
|
const title = material.meta.title;
|
|
4933
5351
|
const language = detectLanguage(material.chunks[0].text, uiLanguage());
|
|
4934
5352
|
const size = material.chunks.reduce((sum, chunk) => sum + chunk.text.length, 0);
|
|
@@ -5011,9 +5429,9 @@ function createStudio({ store, llm, jobs, now = () => /* @__PURE__ */ new Date()
|
|
|
5011
5429
|
}
|
|
5012
5430
|
async function scopeOf(materialIds) {
|
|
5013
5431
|
const ids = (Array.isArray(materialIds) ? materialIds : []).filter((id) => /^mat_[a-z0-9]+$/u.test(String(id))).slice(0, 8);
|
|
5014
|
-
if (!ids.length) throw new TutorError("
|
|
5432
|
+
if (!ids.length) throw new TutorError("no-material", "Pick at least one material");
|
|
5015
5433
|
const materials = await readMaterials(store, ids);
|
|
5016
|
-
if (!materials.some((item) => item.chunks.length)) throw new TutorError("
|
|
5434
|
+
if (!materials.some((item) => item.chunks.length)) throw new TutorError("material-no-text", "These materials have no text");
|
|
5017
5435
|
const title = materials.map((item) => item.meta.title).join(" + ");
|
|
5018
5436
|
return {
|
|
5019
5437
|
ids,
|
|
@@ -5228,8 +5646,9 @@ function createStudio({ store, llm, jobs, now = () => /* @__PURE__ */ new Date()
|
|
|
5228
5646
|
}
|
|
5229
5647
|
function assist({ mode = "explain", request, materialIds = [], threadId, newThreadId }) {
|
|
5230
5648
|
const ask = text(request, 4e3);
|
|
5231
|
-
if (!ask) throw new TutorError("
|
|
5649
|
+
if (!ask) throw new TutorError("empty-question", "Type your question");
|
|
5232
5650
|
const chosen = ASSIST_MODES.includes(mode) ? mode : "explain";
|
|
5651
|
+
const fresh = /^thd_[a-z0-9]{6,40}$/u.test(String(newThreadId)) ? newThreadId : null;
|
|
5233
5652
|
return jobs.start("assist/turn", async (api) => {
|
|
5234
5653
|
let thread = threadId ? await store.readJson(store.paths.item("assist", assertId(threadId, "thd")), null) : null;
|
|
5235
5654
|
const ids = [.../* @__PURE__ */ new Set([...thread?.materialIds ?? [], ...Array.isArray(materialIds) ? materialIds : []])].filter((id) => /^mat_[a-z0-9]+$/u.test(String(id))).slice(0, 8);
|
|
@@ -5240,7 +5659,7 @@ function createStudio({ store, llm, jobs, now = () => /* @__PURE__ */ new Date()
|
|
|
5240
5659
|
const images = await assistImages(ids);
|
|
5241
5660
|
const board = await generateSteps(llm, {
|
|
5242
5661
|
purpose: "teach",
|
|
5243
|
-
effort: "
|
|
5662
|
+
effort: "lowest",
|
|
5244
5663
|
prompt: assistPrompt({
|
|
5245
5664
|
mode: chosen,
|
|
5246
5665
|
request: ask,
|
|
@@ -5261,7 +5680,7 @@ function createStudio({ store, llm, jobs, now = () => /* @__PURE__ */ new Date()
|
|
|
5261
5680
|
board,
|
|
5262
5681
|
at: stamp()
|
|
5263
5682
|
};
|
|
5264
|
-
const id = thread?.id ??
|
|
5683
|
+
const id = thread?.id ?? fresh ?? newId("thd");
|
|
5265
5684
|
const saved = await store.updateJson(store.paths.item("assist", id), null, (current) => {
|
|
5266
5685
|
const next = current ?? {
|
|
5267
5686
|
id,
|
|
@@ -5281,14 +5700,18 @@ function createStudio({ store, llm, jobs, now = () => /* @__PURE__ */ new Date()
|
|
|
5281
5700
|
turn: saved.turns.length - 1,
|
|
5282
5701
|
board
|
|
5283
5702
|
};
|
|
5284
|
-
}, { meta: {
|
|
5703
|
+
}, { meta: {
|
|
5704
|
+
threadId: threadId ?? fresh,
|
|
5705
|
+
title: ask.slice(0, 60)
|
|
5706
|
+
} });
|
|
5285
5707
|
}
|
|
5286
5708
|
async function assistImages(ids) {
|
|
5287
5709
|
return llm.supportsImages?.("teach") ? materialImages(store, await readMaterials(store, ids)) : [];
|
|
5288
5710
|
}
|
|
5289
5711
|
function makeShort({ topic, url, newId: presetId }) {
|
|
5290
5712
|
const subject = text(topic, 300);
|
|
5291
|
-
if (!subject && !url) throw new TutorError("
|
|
5713
|
+
if (!subject && !url) throw new TutorError("empty-topic", "Give a topic or a web address");
|
|
5714
|
+
const id = /^sht_[a-z0-9]{6,40}$/u.test(String(presetId)) ? presetId : newId("sht");
|
|
5292
5715
|
return jobs.start("short/make", async (api) => {
|
|
5293
5716
|
let passages = "";
|
|
5294
5717
|
let sourceTitle = "";
|
|
@@ -5302,7 +5725,7 @@ function createStudio({ store, llm, jobs, now = () => /* @__PURE__ */ new Date()
|
|
|
5302
5725
|
const language = detectLanguage(subject || passages, uiLanguage());
|
|
5303
5726
|
const board = await generateSteps(llm, {
|
|
5304
5727
|
purpose: "teach",
|
|
5305
|
-
effort: "
|
|
5728
|
+
effort: "lowest",
|
|
5306
5729
|
prompt: shortPrompt({
|
|
5307
5730
|
topic: subject || sourceTitle,
|
|
5308
5731
|
passages,
|
|
@@ -5314,7 +5737,7 @@ function createStudio({ store, llm, jobs, now = () => /* @__PURE__ */ new Date()
|
|
|
5314
5737
|
minSteps: 3
|
|
5315
5738
|
});
|
|
5316
5739
|
const item = {
|
|
5317
|
-
id
|
|
5740
|
+
id,
|
|
5318
5741
|
title: board.title || subject || sourceTitle,
|
|
5319
5742
|
topic: subject,
|
|
5320
5743
|
sourceUrl: url ? String(url) : null,
|
|
@@ -5324,7 +5747,10 @@ function createStudio({ store, llm, jobs, now = () => /* @__PURE__ */ new Date()
|
|
|
5324
5747
|
};
|
|
5325
5748
|
await store.writeJson(store.paths.item("shorts", item.id), item);
|
|
5326
5749
|
return { item };
|
|
5327
|
-
}
|
|
5750
|
+
}, { meta: {
|
|
5751
|
+
id,
|
|
5752
|
+
title: subject
|
|
5753
|
+
} });
|
|
5328
5754
|
}
|
|
5329
5755
|
return Object.freeze({
|
|
5330
5756
|
upload,
|
|
@@ -5437,7 +5863,12 @@ function apply(ctx) {
|
|
|
5437
5863
|
const courses = createCourses({
|
|
5438
5864
|
store,
|
|
5439
5865
|
llm,
|
|
5440
|
-
jobs
|
|
5866
|
+
jobs,
|
|
5867
|
+
research: createResearch({
|
|
5868
|
+
getWeb: () => ctx.get?.("web"),
|
|
5869
|
+
llm,
|
|
5870
|
+
logger
|
|
5871
|
+
})
|
|
5441
5872
|
});
|
|
5442
5873
|
const lessons = createLessons({
|
|
5443
5874
|
store,
|
|
@@ -5472,6 +5903,10 @@ function apply(ctx) {
|
|
|
5472
5903
|
keys,
|
|
5473
5904
|
llmPort: llm
|
|
5474
5905
|
});
|
|
5906
|
+
const speechInput = createSpeechInput({
|
|
5907
|
+
getSpeech: () => ctx.get?.("speechToText"),
|
|
5908
|
+
logger
|
|
5909
|
+
});
|
|
5475
5910
|
async function speak({ text, provider, signal }) {
|
|
5476
5911
|
await ready;
|
|
5477
5912
|
const current = prefs.read();
|
|
@@ -5508,7 +5943,8 @@ function apply(ctx) {
|
|
|
5508
5943
|
settings,
|
|
5509
5944
|
tts: { speak },
|
|
5510
5945
|
plugin,
|
|
5511
|
-
info
|
|
5946
|
+
info,
|
|
5947
|
+
speechInput
|
|
5512
5948
|
}),
|
|
5513
5949
|
"app/info": (payload) => info(payload),
|
|
5514
5950
|
"samples/list": () => samples.list(),
|
|
@@ -5523,6 +5959,7 @@ function apply(ctx) {
|
|
|
5523
5959
|
if (typeof fetchRoutes?.register !== "function") return;
|
|
5524
5960
|
connectionCtx.effect(() => fetchRoutes.register(uploadRoute({ studio })), "aha-tutor: upload route");
|
|
5525
5961
|
connectionCtx.effect(() => fetchRoutes.register(ttsRoute({ speak })), "aha-tutor: tts route");
|
|
5962
|
+
connectionCtx.effect(() => fetchRoutes.register(speechRoute({ speechInput })), "aha-tutor: speech route");
|
|
5526
5963
|
connectionCtx.effect(() => fetchRoutes.register(fontRoute()), "aha-tutor: font route");
|
|
5527
5964
|
});
|
|
5528
5965
|
ctx.effect(() => publishRuntime({
|