@khorsheed/dsh-library 0.0.0-stage → 0.1.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/CHANGELOG.md +14 -0
- package/README.en.md +49 -0
- package/README.i18n.yaml +6 -0
- package/README.md +48 -2
- package/cordis.patch.yml +17 -0
- package/lib/client.js +66276 -0
- package/lib/index.js +3318 -0
- package/lib/profiles-BXM8xNUf.js +1608 -0
- package/lib/profiles-BrbeM-bi.js +1635 -0
- package/lib/profiles-CFrPQdfO.js +1692 -0
- package/lib/profiles-DxtzIUvd.js +1632 -0
- package/lib/profiles-O0ddEit3.js +1631 -0
- package/lib/profiles-Oj-zBAY9.js +1692 -0
- package/lib/profiles-gIEviEuO.js +1610 -0
- package/lib/store-CIcZgTsg.js +1456 -0
- package/lib/store-DfYOmS44.js +1418 -0
- package/lib/tool.js +1597 -0
- package/lib/typert.host.d.ts +3 -0
- package/lib/typert.host.js +3811 -0
- package/lib/typert.remote-client.d.ts +137 -0
- package/lib/typert.remote-client.js +3077 -0
- package/lib/types/cards.d.ts +41 -0
- package/lib/types/cards.js +111 -0
- package/lib/types/client/LibraryPane.d.ts +26 -0
- package/lib/types/client/LibraryPane.js +117 -0
- package/lib/types/client/bind/BindChip.d.ts +33 -0
- package/lib/types/client/bind/BindChip.js +102 -0
- package/lib/types/client/contract.d.ts +164 -0
- package/lib/types/client/contract.js +1 -0
- package/lib/types/client/covers.d.ts +36 -0
- package/lib/types/client/covers.js +66 -0
- package/lib/types/client/definition.d.ts +27 -0
- package/lib/types/client/definition.js +32 -0
- package/lib/types/client/icons-local.d.ts +41 -0
- package/lib/types/client/icons-local.js +25 -0
- package/lib/types/client/icons.d.ts +27 -0
- package/lib/types/client/icons.js +11 -0
- package/lib/types/client/index.d.ts +35 -0
- package/lib/types/client/index.js +219 -0
- package/lib/types/client/judge.d.ts +70 -0
- package/lib/types/client/judge.js +262 -0
- package/lib/types/client/locales.d.ts +553 -0
- package/lib/types/client/locales.js +1076 -0
- package/lib/types/client/look.d.ts +63 -0
- package/lib/types/client/look.js +64 -0
- package/lib/types/client/math.d.ts +24 -0
- package/lib/types/client/math.js +47 -0
- package/lib/types/client/mistakes.d.ts +21 -0
- package/lib/types/client/mistakes.js +28 -0
- package/lib/types/client/preset-visibility.d.ts +109 -0
- package/lib/types/client/preset-visibility.js +123 -0
- package/lib/types/client/quiz-drafts.d.ts +29 -0
- package/lib/types/client/quiz-drafts.js +50 -0
- package/lib/types/client/store.d.ts +109 -0
- package/lib/types/client/store.js +44 -0
- package/lib/types/client/titles.d.ts +17 -0
- package/lib/types/client/titles.js +23 -0
- package/lib/types/client/ui.d.ts +96 -0
- package/lib/types/client/ui.js +124 -0
- package/lib/types/client/views/book.d.ts +57 -0
- package/lib/types/client/views/book.js +164 -0
- package/lib/types/client/views/cards.d.ts +17 -0
- package/lib/types/client/views/cards.js +53 -0
- package/lib/types/client/views/home.d.ts +26 -0
- package/lib/types/client/views/home.js +263 -0
- package/lib/types/client/views/lesson.d.ts +42 -0
- package/lib/types/client/views/lesson.js +146 -0
- package/lib/types/client/views/path.d.ts +32 -0
- package/lib/types/client/views/path.js +64 -0
- package/lib/types/client/views/quiz.d.ts +42 -0
- package/lib/types/client/views/quiz.js +366 -0
- package/lib/types/client/views/station-quiz.d.ts +43 -0
- package/lib/types/client/views/station-quiz.js +203 -0
- package/lib/types/client/views/station.d.ts +18 -0
- package/lib/types/client/views/station.js +142 -0
- package/lib/types/image-refs.d.ts +35 -0
- package/lib/types/image-refs.js +71 -0
- package/lib/types/index.d.ts +51 -0
- package/lib/types/index.js +31 -0
- package/lib/types/mastery.d.ts +81 -0
- package/lib/types/mastery.js +110 -0
- package/lib/types/profiles.d.ts +31 -0
- package/lib/types/profiles.js +25 -0
- package/lib/types/remote.d.ts +355 -0
- package/lib/types/remote.js +332 -0
- package/lib/types/service/assess.d.ts +144 -0
- package/lib/types/service/assess.js +773 -0
- package/lib/types/service/bindings.d.ts +92 -0
- package/lib/types/service/bindings.js +198 -0
- package/lib/types/service/cards.d.ts +85 -0
- package/lib/types/service/cards.js +320 -0
- package/lib/types/service/core.d.ts +124 -0
- package/lib/types/service/core.js +169 -0
- package/lib/types/service/pages.d.ts +60 -0
- package/lib/types/service/pages.js +125 -0
- package/lib/types/service/roadmap.d.ts +99 -0
- package/lib/types/service/roadmap.js +284 -0
- package/lib/types/service/rules.d.ts +63 -0
- package/lib/types/service/rules.js +245 -0
- package/lib/types/service/shelf.d.ts +136 -0
- package/lib/types/service/shelf.js +422 -0
- package/lib/types/service.d.ts +42 -0
- package/lib/types/service.js +41 -0
- package/lib/types/store.d.ts +257 -0
- package/lib/types/store.js +1376 -0
- package/lib/types/tool-definition.d.ts +49 -0
- package/lib/types/tool-definition.js +49 -0
- package/lib/types/tool.d.ts +71 -0
- package/lib/types/tool.js +174 -0
- package/lib/types/tools/assess.d.ts +11 -0
- package/lib/types/tools/assess.js +82 -0
- package/lib/types/tools/binding.d.ts +11 -0
- package/lib/types/tools/binding.js +46 -0
- package/lib/types/tools/cards.d.ts +11 -0
- package/lib/types/tools/cards.js +100 -0
- package/lib/types/tools/kit.d.ts +215 -0
- package/lib/types/tools/kit.js +281 -0
- package/lib/types/tools/notes.d.ts +11 -0
- package/lib/types/tools/notes.js +40 -0
- package/lib/types/tools/page.d.ts +11 -0
- package/lib/types/tools/page.js +59 -0
- package/lib/types/tools/project.d.ts +11 -0
- package/lib/types/tools/project.js +67 -0
- package/lib/types/tools/quiz.d.ts +11 -0
- package/lib/types/tools/quiz.js +74 -0
- package/lib/types/tools/read.d.ts +11 -0
- package/lib/types/tools/read.js +127 -0
- package/lib/types/tools/resource.d.ts +11 -0
- package/lib/types/tools/resource.js +147 -0
- package/lib/types/tools/roadmap.d.ts +11 -0
- package/lib/types/tools/roadmap.js +102 -0
- package/lib/types/types.d.ts +752 -0
- package/lib/types/types.js +159 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +135 -3
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* A station's gate tab: 章节考 on a quiz / read station (the paper card, the
|
|
4
|
+
* points, the ask settings with the pending ask kept per station, the
|
|
5
|
+
* station's own mistakes folded into one card, earlier papers), and the
|
|
6
|
+
* acceptance sheet on an acceptance station.
|
|
7
|
+
*
|
|
8
|
+
* @module @khorsheed/dsh-library/client/views/station-quiz
|
|
9
|
+
*/
|
|
10
|
+
import { useEffect, useState } from 'react';
|
|
11
|
+
import { ListRow } from '@khorsheed/dsh-client-ui-panel-kit/src/client/index.ts';
|
|
12
|
+
import { acceptanceLines, coverKind, } from "../../types.js";
|
|
13
|
+
import { collectVoided } from "../mistakes.js";
|
|
14
|
+
import { IconCheckOutlineMedium, IconInfoOutlineMedium } from "../icons.js";
|
|
15
|
+
import { outcome, StateLine, useLoad, value, when } from "../ui.js";
|
|
16
|
+
import { MASTERY_MIN_RIGHT, MASTERY_MIN_SITTINGS, stationMastery } from "../../mastery.js";
|
|
17
|
+
import { MathText } from "../math.js";
|
|
18
|
+
import css from '../LibraryPane.module.css';
|
|
19
|
+
import { QuizRows, sourceMix } from "./book.js";
|
|
20
|
+
/** The right answer as a row caption: choice questions get their letter. */
|
|
21
|
+
function answerText(question) {
|
|
22
|
+
const at = question.options?.indexOf(question.answer) ?? -1;
|
|
23
|
+
return at >= 0 ? `${String.fromCharCode(65 + at)}. ${question.answer}` : question.answer;
|
|
24
|
+
}
|
|
25
|
+
/** One missed / flagged question row: the mark, the prompt, and where it sits (answer · when · book). */
|
|
26
|
+
function MissRow({ p, row, variant, book }) {
|
|
27
|
+
const { t } = p;
|
|
28
|
+
return (_jsxs("div", { className: css.missRow, children: [_jsx("span", { className: css.missMark, "data-void": variant === 'voided' ? 'true' : undefined, "aria-hidden": "true", children: variant === 'voided' ? '?' : '✕' }), _jsxs("div", { className: css.missText, children: [_jsx("span", { children: _jsx(MathText, { text: row.question.prompt }) }), _jsxs("span", { className: css.muted, children: [variant === 'missed' && _jsxs(_Fragment, { children: [_jsx(MathText, { text: t('node.missedAnswer', { answer: answerText(row.question) }) }), " \u00B7 "] }), when(row.at, t), book === undefined ? '' : ` · ${book}`] })] })] }));
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* 章节考 on a quiz / read station, one line of travel (proposal
|
|
32
|
+
* 2026-10-19-library-point-mastery): the pass line, the points map (tap a
|
|
33
|
+
* point for why and what to do), then ONE primary action by state — an
|
|
34
|
+
* untaken paper → 开始作答; open misses → 开始订正 (a retake paper, judged
|
|
35
|
+
* here, never counted toward passing); else → 出题, its settings folded into
|
|
36
|
+
* a one-line summary beside the button. Earlier papers fold at the bottom.
|
|
37
|
+
* The settings and the pending ask live in the pane store per station.
|
|
38
|
+
*/
|
|
39
|
+
export function QuizTab({ p, projectId, resourceId, node, passRatio, onPrime, onReplace }) {
|
|
40
|
+
const { t, actions, useStore } = p;
|
|
41
|
+
const rev = useStore(s => s.rev);
|
|
42
|
+
const [showSettings, setShowSettings] = useState(false);
|
|
43
|
+
const [showPointsHint, setShowPointsHint] = useState(false);
|
|
44
|
+
const [expanded, setExpanded] = useState();
|
|
45
|
+
const [retaking, setRetaking] = useState(false);
|
|
46
|
+
const [error, setError] = useState();
|
|
47
|
+
// The settings (范围 / 重点 / 题型 / 题量) and the pending ask live in the pane
|
|
48
|
+
// store per station, so leaving the tab and coming back keeps both.
|
|
49
|
+
const stationKey = `${projectId}:${resourceId ?? ''}:${node.id}`;
|
|
50
|
+
const station = useStore(s => s.quiz[stationKey]);
|
|
51
|
+
const settings = station?.settings ?? DEFAULT_SETTINGS;
|
|
52
|
+
const direction = settings.focus;
|
|
53
|
+
const picked = settings.picked;
|
|
54
|
+
const kinds = settings.kinds;
|
|
55
|
+
const countText = settings.countText;
|
|
56
|
+
const update = (patch) => { actions.setQuizSettings(stationKey, { ...settings, ...patch }); };
|
|
57
|
+
// After asking, re-read until the coach has stored the new quiz; past the
|
|
58
|
+
// patience window the card says so instead of silently giving up.
|
|
59
|
+
const watching = station?.ask;
|
|
60
|
+
const [now, setNow] = useState(Date.now());
|
|
61
|
+
const stale = watching !== undefined && now - watching.since > ASK_PATIENCE_MS;
|
|
62
|
+
const [tick, setTick] = useState(0);
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
if (watching === undefined || stale)
|
|
65
|
+
return;
|
|
66
|
+
const timer = setInterval(() => {
|
|
67
|
+
setNow(Date.now());
|
|
68
|
+
setTick(current => current + 1);
|
|
69
|
+
}, 4000);
|
|
70
|
+
return () => { clearInterval(timer); };
|
|
71
|
+
}, [watching, stale]);
|
|
72
|
+
// Every assessment of the project (papers, retakes): mastery reads them all.
|
|
73
|
+
const assessments = useLoad(() => value(p.listAssessments(projectId)), [projectId, rev, tick]);
|
|
74
|
+
const attempts = useLoad(() => value(p.listAttempts(projectId)), [projectId, rev]);
|
|
75
|
+
const mine = useLoad(() => value(p.listCards({ projectId, nodeId: node.id, limit: 5 })), [projectId, node.id, rev]);
|
|
76
|
+
const all = assessments.data?.assessments ?? [];
|
|
77
|
+
const allAttempts = attempts.data?.attempts ?? [];
|
|
78
|
+
const shown = all.filter(quiz => quiz.type === 'quiz'
|
|
79
|
+
&& (quiz.scope.nodeId === node.id || (quiz.scope.nodeId === undefined && (resourceId !== undefined ? quiz.scope.resourceId === resourceId : quiz.scope.resourceId === undefined))));
|
|
80
|
+
const quizCount = shown.length;
|
|
81
|
+
// The quiz the learner is waiting for opens itself when it lands.
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
if (watching === undefined || assessments.data === undefined || quizCount <= watching.count)
|
|
84
|
+
return;
|
|
85
|
+
actions.setQuizAsk(stationKey, undefined);
|
|
86
|
+
const landed = shown.at(-1);
|
|
87
|
+
if (landed !== undefined)
|
|
88
|
+
actions.push({ view: 'quiz', projectId, ...(landed.scope.resourceId === undefined ? {} : { resourceId: landed.scope.resourceId }), quizId: landed.id });
|
|
89
|
+
}, [quizCount, watching]);
|
|
90
|
+
const stats = node.quizStats;
|
|
91
|
+
const mineCount = mine.data?.total ?? 0;
|
|
92
|
+
const papers = shown.filter(quiz => quiz.scope.nodeId === node.id);
|
|
93
|
+
const live = papers.filter(quiz => quiz.supersededBy === undefined);
|
|
94
|
+
const latest = live.at(-1) ?? papers.at(-1);
|
|
95
|
+
const loose = shown.filter(quiz => quiz.scope.nodeId === undefined);
|
|
96
|
+
const latestByQuiz = new Map();
|
|
97
|
+
for (const attempt of [...allAttempts].sort((a, b) => b.at.localeCompare(a.at))) {
|
|
98
|
+
if (attempt.assessmentId !== undefined && !latestByQuiz.has(attempt.assessmentId))
|
|
99
|
+
latestByQuiz.set(attempt.assessmentId, attempt);
|
|
100
|
+
}
|
|
101
|
+
const untaken = latest !== undefined && !latestByQuiz.has(latest.id) ? latest : undefined;
|
|
102
|
+
const mastery = stationMastery(node, all, allAttempts);
|
|
103
|
+
const open = mastery.open;
|
|
104
|
+
const voided = collectVoided(allAttempts, latest === undefined ? [] : [latest]);
|
|
105
|
+
const weakTitles = mastery.points.filter(point => point.state === 'weak').map(point => point.title);
|
|
106
|
+
const pickedTitles = mastery.points.filter(point => picked.includes(point.id)).map(point => point.title);
|
|
107
|
+
const primary = untaken !== undefined ? 'answer' : open.length > 0 ? 'retake' : 'ask';
|
|
108
|
+
const openPaper = (quiz) => {
|
|
109
|
+
actions.push({ view: 'quiz', projectId, ...(quiz.scope.resourceId === undefined ? (resourceId === undefined ? {} : { resourceId }) : { resourceId: quiz.scope.resourceId }), quizId: quiz.id });
|
|
110
|
+
};
|
|
111
|
+
const parsedCount = () => {
|
|
112
|
+
const parsed = Number.parseInt(countText.trim(), 10);
|
|
113
|
+
return Number.isFinite(parsed) ? Math.min(Math.max(parsed, 1), MAX_QUIZ_COUNT) : DEFAULT_QUIZ_COUNT;
|
|
114
|
+
};
|
|
115
|
+
const ask = (focusPoints) => {
|
|
116
|
+
const count = parsedCount();
|
|
117
|
+
// No picked points: the weak ones are the focus (the coach also reads them every turn).
|
|
118
|
+
const points = focusPoints ?? (pickedTitles.length > 0 ? pickedTitles : weakTitles);
|
|
119
|
+
onPrime(direction, { weak: weakTitles, points, kinds, count });
|
|
120
|
+
const summary = [t(`focus.${direction}`), kinds.length === 0 ? t('node.kindsMixed') : kinds.map(kind => t(`kind.${kind}`)).join('、'), t('node.askCount', { count: String(count) }), ...(points.length > 0 ? [t('node.askFocusPicked', { points: points.join('、') })] : [])].join(' · ');
|
|
121
|
+
actions.setQuizAsk(stationKey, { since: Date.now(), count: quizCount, summary });
|
|
122
|
+
setNow(Date.now());
|
|
123
|
+
};
|
|
124
|
+
const retake = async (keys) => {
|
|
125
|
+
if (resourceId === undefined || retaking)
|
|
126
|
+
return;
|
|
127
|
+
setRetaking(true);
|
|
128
|
+
setError(undefined);
|
|
129
|
+
try {
|
|
130
|
+
const created = await outcome(p.createRetake(projectId, resourceId, node.id, keys));
|
|
131
|
+
setExpanded(undefined);
|
|
132
|
+
actions.push({ view: 'quiz', projectId, resourceId, quizId: created.assessment.id });
|
|
133
|
+
}
|
|
134
|
+
catch (failure) {
|
|
135
|
+
setError(failure instanceof Error ? failure.message : String(failure));
|
|
136
|
+
}
|
|
137
|
+
finally {
|
|
138
|
+
setRetaking(false);
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
const asking = watching !== undefined && !stale;
|
|
142
|
+
const askParts = [
|
|
143
|
+
[t('node.setting.range'), t(`focus.${direction}`)],
|
|
144
|
+
[t('node.kindsTitle'), kinds.length === 0 || kinds.length === QUIZ_KINDS.length ? t('node.kindsMixed') : kinds.map(kind => t(`kind.${kind}`)).join('、')],
|
|
145
|
+
[t('node.countTitle'), t('node.askCount', { count: String(parsedCount()) })],
|
|
146
|
+
[t('node.focusPoints'), pickedTitles.length > 0 ? t('node.pointsPicked', { count: String(pickedTitles.length) }) : weakTitles.length > 0 ? t('node.pointsWeak', { count: String(weakTitles.length) }) : t('node.pointsCoach')],
|
|
147
|
+
];
|
|
148
|
+
const openByPoint = mastery.points.filter(point => point.open.length > 0).map(point => `${point.title} ${point.open.length}`);
|
|
149
|
+
const openLoose = open.filter(miss => miss.question.point === undefined || !mastery.points.some(point => point.id === miss.question.point)).length;
|
|
150
|
+
return (_jsxs(_Fragment, { children: [node.state === 'locked' && _jsx("div", { className: css.notice, children: t('node.locked') }), _jsxs("div", { className: css.muted, children: [t('node.passRule', { percent: String(Math.round(passRatio * 100)) }), stats !== undefined && stats.attempts > 0 ? ` · ${t('node.best', { percent: String(Math.round((stats.bestRatio ?? 0) * 100)), attempts: String(stats.attempts) })}` : ` · ${t('node.neverSat')}`, node.state === 'done' ? ` · ${t('node.passedTag')}` : ''] }), _jsx(StateLine, { loaded: assessments, t: t }), error !== undefined && _jsx("div", { className: css.error, children: error }), watching !== undefined && (_jsxs("section", { className: css.asking, role: "status", "data-stale": stale, children: [_jsxs("span", { className: css.askingText, children: [_jsx("strong", { children: stale ? t('node.askStale') : t('node.asking') }), _jsxs("span", { className: css.muted, children: [watching.summary, " \u00B7 ", t('node.askSince', { minutes: String(Math.max(1, Math.round((now - watching.since) / 60_000))) })] })] }), stale ? _jsx("button", { type: "button", className: css.secondary, onClick: () => { ask(); }, children: t('node.askAgain') }) : null, _jsx("button", { type: "button", className: css.textButton, onClick: () => { actions.setQuizAsk(stationKey, undefined); }, children: t('node.askDismiss') })] })), mastery.points.length > 0 && (_jsxs("section", { className: css.section, children: [_jsxs("h3", { className: `${css.sectionTitle} ${css.pointsHead}`, children: [t('node.pointsTitle'), _jsx("button", { type: "button", className: css.infoButton, "aria-expanded": showPointsHint, "aria-label": t('node.pointsHintLabel'), onClick: () => { setShowPointsHint(shown => !shown); }, children: _jsx(IconInfoOutlineMedium, { size: 13 }) })] }), _jsx("div", { className: css.pointRows, children: mastery.points.map(point => {
|
|
151
|
+
const isOpen = expanded === point.id;
|
|
152
|
+
return (_jsxs("div", { className: css.pointItem, "data-open": isOpen, children: [_jsxs("button", { type: "button", className: css.pointRow, "aria-expanded": isOpen, onClick: () => { setExpanded(isOpen ? undefined : point.id); }, children: [_jsxs("span", { className: css.pointRowMain, children: [_jsx("span", { className: css.pointRowTitle, children: point.title }), _jsxs("span", { className: css.muted, children: [point.asked === 0 ? t('point.never') : t('point.counts', { asked: String(point.asked), right: String(point.right) }), point.open.length > 0 ? ` · ${t('node.pointOpen', { count: String(point.open.length) })}` : '', point.notes.length > 0 ? ` · ${t('point.noteCount', { count: String(point.notes.length) })}` : ''] })] }), _jsx("span", { className: css.pointBadge, "data-state": point.state, children: t(`point.${point.state}`) }), _jsx("span", { className: css.chevron, "aria-hidden": "true", children: "\u203A" })] }), isOpen && _jsx(PointDetail, { p: p, point: point, busy: retaking, onRetake: () => { void retake(point.open.map(miss => miss.key)); } })] }, point.id));
|
|
153
|
+
}) }), showPointsHint && _jsx("div", { className: css.muted, children: t('node.pointsRule') })] })), untaken !== undefined && (_jsxs("section", { className: css.stepBox, children: [_jsxs("span", { className: css.stepText, children: [_jsx("strong", { children: t('node.untakenBox', { count: String(untaken.questions?.length ?? 0) }) }), _jsx("span", { className: css.muted, children: [t(`focus.${untaken.focus ?? 'mixed'}`), sourceMix((untaken.questions ?? []).map(question => question.evidence.kind), t), when(untaken.createdAt, t)].join(' · ') })] }), _jsx("button", { type: "button", className: css.primary, onClick: () => { openPaper(untaken); }, children: t('quiz.startNow') })] })), open.length > 0 && (_jsxs("section", { className: css.stepBox, children: [_jsxs("span", { className: css.stepText, children: [_jsx("strong", { children: t('node.retakeBox', { count: String(open.length) }) }), _jsx("span", { className: css.muted, children: [...openByPoint, ...(openLoose > 0 ? [t('node.retakeOther', { count: String(openLoose) })] : []), t('node.missedMeta', { when: when(open[0].at, t) })].join(' · ') }), _jsx("span", { className: css.muted, children: t('node.retakeHint') })] }), _jsx("button", { type: "button", className: primary === 'retake' ? css.primary : css.secondary, disabled: retaking || resourceId === undefined, onClick: () => { void retake(); }, children: t('node.retakeStart') })] })), _jsxs("section", { className: `${css.stepBox} ${css.stepBoxPlain}`, children: [_jsxs("span", { className: css.stepText, children: [_jsx("strong", { children: t('node.askBox') }), _jsx("span", { className: css.askParts, children: askParts.map(([label, valueText]) => _jsxs("span", { className: css.askPart, children: [_jsx("span", { className: css.muted, children: label }), valueText] }, label)) })] }), _jsxs("span", { className: css.askActions, children: [_jsx("button", { type: "button", className: css.ghost, "aria-expanded": showSettings, onClick: () => { setShowSettings(!showSettings); }, children: showSettings ? t('node.askDone') : t('node.askAdjust') }), _jsx("button", { type: "button", className: primary === 'ask' ? css.primary : css.secondary, disabled: asking, onClick: () => { ask(); }, children: asking ? t('node.quizWaiting') : t('node.askGo') })] }), showSettings && (_jsxs("div", { className: css.stepSettings, children: [_jsxs("div", { className: css.settingRow, children: [_jsx("span", { className: css.settingLabel, children: t('node.setting.range') }), _jsxs("div", { className: css.settingControl, children: [_jsx("div", { className: css.pickWrap, role: "radiogroup", "aria-label": t('node.setting.range'), children: FOCUSES.map(id => (_jsxs("button", { type: "button", role: "radio", className: css.pick, "aria-checked": direction === id, "aria-pressed": direction === id, onClick: () => { update({ focus: id }); }, children: [t(`focus.${id}`), id === 'mine' ? _jsx("span", { className: css.pickState, children: mineCount }) : null] }, id))) }), _jsx("div", { className: css.muted, children: direction === 'mine'
|
|
154
|
+
? mineCount === 0 ? t('node.focusMineEmpty') : t('node.focusMineHint', { terms: (mine.data?.cards ?? []).map(card => card.term).join('、') })
|
|
155
|
+
: t(`node.focusHint.${direction}`) })] })] }), _jsxs("div", { className: css.settingRow, children: [_jsx("span", { className: css.settingLabel, children: t('node.kindsTitle') }), _jsxs("div", { className: css.settingControl, children: [_jsx("div", { className: css.pickWrap, role: "group", "aria-label": t('node.kindsTitle'), children: QUIZ_KINDS.map(id => (_jsx("button", { type: "button", className: css.pick, "aria-pressed": kinds.includes(id), onClick: () => { update({ kinds: kinds.includes(id) ? kinds.filter(kind => kind !== id) : [...kinds, id] }); }, children: t(`kind.${id}`) }, id))) }), _jsx("div", { className: css.muted, children: t('node.kindsHint') })] })] }), _jsxs("div", { className: css.settingRow, children: [_jsx("span", { className: css.settingLabel, children: t('node.countTitle') }), _jsx("input", { className: `${css.input} ${css.countInput}`, inputMode: "numeric", "aria-label": t('node.countTitle'), placeholder: String(DEFAULT_QUIZ_COUNT), value: countText, onChange: event => { update({ countText: event.target.value }); }, onBlur: () => { update({ countText: String(parsedCount()) }); } })] }), mastery.points.length > 0 && (_jsxs("div", { className: css.settingRow, children: [_jsx("span", { className: css.settingLabel, children: t('node.focusPoints') }), _jsxs("div", { className: css.settingControl, children: [_jsx("div", { className: css.pickWrap, role: "group", "aria-label": t('node.focusPoints'), children: mastery.points.map(point => (_jsxs("button", { type: "button", className: css.pick, "aria-pressed": picked.includes(point.id), onClick: () => { update({ picked: picked.includes(point.id) ? picked.filter(entry => entry !== point.id) : [...picked, point.id] }); }, children: [point.title, _jsx("span", { className: css.pickState, "data-state": point.state, children: t(`point.${point.state}`) })] }, point.id))) }), _jsx("div", { className: css.muted, children: t('node.focusPointsHint') })] })] }))] }))] }), voided.length > 0 && (_jsxs("div", { className: css.muted, children: [t('node.voidedSummary', { count: String(voided.length) }), ' · ', _jsx("button", { type: "button", className: css.textButton, onClick: () => { onReplace(voided.map(row => row.question.prompt)); }, children: t('node.voidedReplace') })] })), papers.length > 0 && (_jsxs("details", { className: css.earlier, children: [_jsx("summary", { children: t('node.earlier', { count: String(papers.length) }) }), [...papers].reverse().map(quiz => {
|
|
156
|
+
const sat = latestByQuiz.get(quiz.id);
|
|
157
|
+
return (_jsx(ListRow, { title: t('node.quizRow', { count: String(quiz.questions?.length ?? 0) }), meta: [sat === undefined ? t('node.paperUntaken') : `${Math.round(sat.ratio * 100)}%`, t(`focus.${quiz.focus ?? 'mixed'}`), when(quiz.createdAt, t)].join(' · '), trailing: _jsx("span", { className: css.linkText, children: sat === undefined ? t('quiz.start') : t('node.redoSame') }), onSelect: () => { openPaper(quiz); } }, quiz.id));
|
|
158
|
+
})] })), loose.length > 0 && (_jsxs("section", { className: css.section, children: [_jsx("h3", { className: css.sectionTitle, children: t(resourceId === undefined ? 'project.quizzes' : 'book.quizzes', { count: String(loose.length) }) }), _jsx(QuizRows, { p: p, projectId: projectId, quizzes: loose.slice().reverse() })] }))] }));
|
|
159
|
+
}
|
|
160
|
+
/** One point, expanded under its row: why it stands where it does, and the way forward. */
|
|
161
|
+
function PointDetail({ p, point, busy, onRetake }) {
|
|
162
|
+
const { t } = p;
|
|
163
|
+
const needRight = Math.max(0, MASTERY_MIN_RIGHT - point.right);
|
|
164
|
+
const needSittings = Math.max(0, MASTERY_MIN_SITTINGS - point.rightSittings);
|
|
165
|
+
return (_jsxs("div", { className: css.pointDetail, children: [_jsx("div", { className: css.muted, children: t(`point.why.${point.state}`, { right: String(needRight), sittings: String(needSittings) }) }), point.state === 'practicing' && (_jsxs("div", { className: css.pointNeed, children: [needRight > 0 && _jsx("span", { children: t('point.needRight', { count: String(needRight) }) }), needSittings > 0 && _jsx("span", { children: t('point.needSitting') })] })), point.history.length > 0 && (_jsxs("div", { className: css.pointBlock, children: [_jsx("h4", { className: css.pointBlockTitle, children: t('point.history') }), point.history.map(sitting => (_jsxs("div", { className: css.moveRow, children: [_jsxs("span", { children: [when(sitting.at, t), " \u00B7 ", t(sitting.retake ? 'point.sitRetake' : 'point.sitPaper')] }), _jsx("span", { className: sitting.right === sitting.total ? css.ok : css.bad, children: t('point.sitScore', { right: String(sitting.right), total: String(sitting.total) }) })] }, sitting.at)))] })), point.notes.length > 0 && (_jsxs("div", { className: css.pointBlock, children: [_jsx("h4", { className: css.pointBlockTitle, children: t('point.notes') }), [...point.notes].reverse().map(note => (_jsxs("div", { className: css.pointNote, children: [_jsx(MathText, { text: note.text }), _jsxs("span", { className: css.muted, children: [" \u00B7 ", when(note.at, t)] })] }, note.at)))] })), point.open.length > 0 && (_jsxs("div", { className: css.pointBlock, children: [_jsx("h4", { className: css.pointBlockTitle, children: t('point.open', { count: String(point.open.length) }) }), _jsx("div", { className: css.mistakeRows, children: point.open.map(miss => _jsx(MissRow, { p: p, row: { question: miss.question, at: miss.at }, variant: "missed" }, miss.key)) }), _jsx("div", { className: css.actions, children: _jsx("button", { type: "button", className: css.primary, disabled: busy, onClick: onRetake, children: t('point.retake', { count: String(point.open.length) }) }) })] })), point.open.length === 0 && point.state !== 'mastered' && _jsx("div", { className: css.muted, children: t('point.askBelow') })] }));
|
|
166
|
+
}
|
|
167
|
+
/** An acceptance station's gate tab: the rubric, the submit gesture, the history. */
|
|
168
|
+
export function AcceptanceTab({ p, projectId, node, passRatio, onPrime }) {
|
|
169
|
+
const { t, actions, useStore } = p;
|
|
170
|
+
const rev = useStore(s => s.rev);
|
|
171
|
+
const attempts = useLoad(() => value(p.listAttempts(projectId, node.id)), [projectId, node.id, rev]);
|
|
172
|
+
// The station gates no quiz itself, but its covered thick resources may hold
|
|
173
|
+
// resource-level ones (本书测验) — the acceptance lines often ref them.
|
|
174
|
+
const coveredThick = [...new Set(node.covers.flatMap(cover => coverKind(cover) === 'resource' ? [cover.ref] : []))];
|
|
175
|
+
const looseQuizzes = useLoad(async () => {
|
|
176
|
+
const lists = await Promise.all(coveredThick.map(id => value(p.listAssessments(projectId, id, undefined, 'quiz'))));
|
|
177
|
+
return lists.flatMap(result => result.assessments).filter(quiz => quiz.scope.nodeId === undefined);
|
|
178
|
+
}, [projectId, node.id, rev, coveredThick.join(',')]);
|
|
179
|
+
// A ref line's live state, before any acceptance sitting: the referenced
|
|
180
|
+
// assessment's latest sitting does the judging (attempts arrive newest first).
|
|
181
|
+
const allAttempts = useLoad(() => value(p.listAttempts(projectId)), [projectId, rev]);
|
|
182
|
+
const latest = attempts.data?.attempts[0];
|
|
183
|
+
const rubric = acceptanceLines(node);
|
|
184
|
+
const verdictOf = (line) => latest?.verdicts.find(verdict => (verdict.task === line.id || verdict.criterion === line.line) && verdict.void !== true)
|
|
185
|
+
?? (line.ref === undefined ? undefined : (() => {
|
|
186
|
+
const sitting = (allAttempts.data?.attempts ?? []).find(attempt => attempt.assessmentId === line.ref);
|
|
187
|
+
return sitting === undefined ? undefined : { task: line.id, criterion: line.line, pass: sitting.ratio >= passRatio, ratio: sitting.ratio, evidence: '', by: 'auto' };
|
|
188
|
+
})());
|
|
189
|
+
return (_jsxs(_Fragment, { children: [node.state === 'locked' && _jsx("div", { className: css.notice, children: t('node.locked') }), _jsxs("div", { className: css.muted, children: [t('accept.station', { count: String(rubric.length) }), " \u00B7 ", t('accept.passRule', { percent: String(Math.round(passRatio * 100)) })] }), _jsxs("section", { className: css.section, children: [_jsx("h3", { className: css.sectionTitle, children: t('accept.title') }), _jsx("ul", { className: css.acceptList, children: rubric.map(line => {
|
|
190
|
+
const verdict = verdictOf(line);
|
|
191
|
+
return (_jsxs("li", { className: css.acceptItem, children: [_jsx("span", { className: css.acceptMark, "data-state": verdict === undefined ? 'none' : (verdict.pass && (verdict.ratio ?? 1) >= 0.5) ? 'pass' : 'fail', "aria-hidden": "true", children: verdict === undefined ? '' : (verdict.pass && (verdict.ratio ?? 1) >= 0.5) ? _jsx(IconCheckOutlineMedium, { size: 11 }) : '✕' }), _jsxs("span", { className: css.acceptText, children: [line.line, line.ref !== undefined && (_jsxs("button", { type: "button", className: css.textButton, onClick: () => { actions.push({ view: 'quiz', projectId, quizId: line.ref }); }, children: [t('accept.machine'), " \u2192"] })), verdict !== undefined && verdict.ratio !== undefined && verdict.ratio > 0 && verdict.ratio < 1 ? _jsxs("span", { className: css.muted, children: [" \u00B7 ", Math.round(verdict.ratio * 100), "%"] }) : null] })] }, line.id));
|
|
192
|
+
}) }), _jsx("div", { className: css.muted, children: t('accept.hint') })] }), (looseQuizzes.data?.length ?? 0) > 0 && (_jsxs("section", { className: css.section, children: [_jsx("h3", { className: css.sectionTitle, children: t('book.quizzes', { count: String(looseQuizzes.data.length) }) }), _jsx(QuizRows, { p: p, projectId: projectId, quizzes: looseQuizzes.data })] })), _jsx("div", { className: css.actions, children: _jsx("button", { type: "button", className: css.primary, onClick: onPrime, children: t('accept.submit') }) }), _jsx("div", { className: css.muted, children: t('accept.submitHint') }), (attempts.data?.attempts.length ?? 0) > 0 && (_jsxs("section", { className: css.section, children: [_jsx("h3", { className: css.sectionTitle, children: t('accept.history') }), attempts.data.attempts.map(attempt => (_jsxs("div", { className: css.moveRow, children: [_jsxs("span", { children: [Math.round(attempt.ratio * 100), "%"] }), _jsx("span", { className: css.muted, children: when(attempt.at, t) })] }, attempt.id)))] }))] }));
|
|
193
|
+
}
|
|
194
|
+
const FOCUSES = ['mine', 'book', 'mixed'];
|
|
195
|
+
const QUIZ_KINDS = ['choice', 'fill', 'free'];
|
|
196
|
+
/** 题量 field's default — a chapter quiz's one-sitting size. */
|
|
197
|
+
const DEFAULT_QUIZ_COUNT = 10;
|
|
198
|
+
/** A station's 章节考 settings before the learner touches them: 都考, the coach's mix of kinds, 10 questions. */
|
|
199
|
+
const DEFAULT_SETTINGS = { focus: 'mixed', kinds: [], countText: String(DEFAULT_QUIZ_COUNT), picked: [] };
|
|
200
|
+
/** How long the tab waits for an asked quiz before saying it has not landed (a 20-question paper takes minutes). */
|
|
201
|
+
const ASK_PATIENCE_MS = 8 * 60_000;
|
|
202
|
+
/** The 题量 field's cap — past this a chapter quiz stops being one sitting. */
|
|
203
|
+
const MAX_QUIZ_COUNT = 20;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One station of a textbook's path: 导读 (with the lesson card: 开始学这一站 /
|
|
3
|
+
* 打开讲义 — the 讲义 are their own page, views/lesson.tsx) / 我的记录 (the
|
|
4
|
+
* photographed pages) / 章节考 (views/station-quiz.tsx).
|
|
5
|
+
*
|
|
6
|
+
* @module @khorsheed/dsh-library/client/views/station
|
|
7
|
+
*/
|
|
8
|
+
import { type ReactNode } from 'react';
|
|
9
|
+
import type { LibraryNodeTab } from '../store.ts';
|
|
10
|
+
import { type ViewProps } from '../ui.tsx';
|
|
11
|
+
/** One node: guide (with the lesson card), photographed pages, and the gate tab 章节考 — on every kind of station. */
|
|
12
|
+
export declare function NodeView({ p, projectId, resourceId, nodeId, tab }: ViewProps & {
|
|
13
|
+
projectId: string;
|
|
14
|
+
resourceId: string;
|
|
15
|
+
nodeId: string;
|
|
16
|
+
tab: LibraryNodeTab;
|
|
17
|
+
}): ReactNode;
|
|
18
|
+
//# sourceMappingURL=station.d.ts.map
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* One station of a textbook's path: 导读 (with the lesson card: 开始学这一站 /
|
|
4
|
+
* 打开讲义 — the 讲义 are their own page, views/lesson.tsx) / 我的记录 (the
|
|
5
|
+
* photographed pages) / 章节考 (views/station-quiz.tsx).
|
|
6
|
+
*
|
|
7
|
+
* @module @khorsheed/dsh-library/client/views/station
|
|
8
|
+
*/
|
|
9
|
+
import { useEffect, useState } from 'react';
|
|
10
|
+
import { ListRow, PanelHeader, UnderlineTabs } from '@khorsheed/dsh-client-ui-panel-kit/src/client/index.ts';
|
|
11
|
+
import { acceptanceLines, coverKind, isThickResource } from "../../types.js";
|
|
12
|
+
import { CoverChips, coverLabel } from "../covers.js";
|
|
13
|
+
import { IconSparkleMedium } from "../icons.js";
|
|
14
|
+
import { IconCameraOutlineMedium } from "../icons-local.js";
|
|
15
|
+
import { outcome, shortGloss, StateLine, useLoad, value, WeaknessBars } from "../ui.js";
|
|
16
|
+
import css from '../LibraryPane.module.css';
|
|
17
|
+
/** The zigzag column (0..2) a node at `index` sits in. */
|
|
18
|
+
import { AcceptanceTab, QuizTab } from "./station-quiz.js";
|
|
19
|
+
import { rowsOf } from "./book.js";
|
|
20
|
+
import { LessonCard } from "./lesson.js";
|
|
21
|
+
/** One node: guide (with the lesson card), photographed pages, and the gate tab 章节考 — on every kind of station. */
|
|
22
|
+
export function NodeView({ p, projectId, resourceId, nodeId, tab }) {
|
|
23
|
+
const { t, actions, useStore } = p;
|
|
24
|
+
const rev = useStore(s => s.rev);
|
|
25
|
+
const loaded = useLoad(async () => {
|
|
26
|
+
const [detail, roadmaps] = await Promise.all([outcome(p.getProject(projectId)), value(p.getRoadmap(projectId, resourceId))]);
|
|
27
|
+
const resource = detail.resources.find(candidate => candidate.id === resourceId);
|
|
28
|
+
const node = roadmaps.roadmap?.nodes.find(candidate => candidate.id === nodeId);
|
|
29
|
+
return { resource, resources: detail.resources, project: detail.project, node, passRatio: roadmaps.roadmap?.passRatio ?? 0.8 };
|
|
30
|
+
}, [projectId, resourceId, nodeId, rev]);
|
|
31
|
+
const [notice, setNotice] = useState();
|
|
32
|
+
const node = loaded.data?.node;
|
|
33
|
+
// The pages tab rides the station's own textbook, when the station covers it.
|
|
34
|
+
const coversOwn = (node?.covers ?? []).some(cover => coverKind(cover) === 'resource' && cover.ref === resourceId);
|
|
35
|
+
const resource = loaded.data?.resource;
|
|
36
|
+
const prime = (text) => {
|
|
37
|
+
void p.bindTarget({ projectId, resourceId });
|
|
38
|
+
setNotice(p.primeConversation(text) ? t('notice.primed') : t('notice.noConversation'));
|
|
39
|
+
};
|
|
40
|
+
const tabs = [
|
|
41
|
+
{ id: 'guide', label: t('node.guide') },
|
|
42
|
+
...(coversOwn && resource !== undefined && isThickResource(resource) ? [{ id: 'pages', label: t('node.pages') }] : []),
|
|
43
|
+
// Every station ends in 章节考: a quiz / read station's papers, an
|
|
44
|
+
// acceptance station's rubric sheet.
|
|
45
|
+
{ id: 'quiz', label: t('node.quiz') },
|
|
46
|
+
];
|
|
47
|
+
const activeTab = tabs.some(candidate => candidate.id === tab) ? tab : 'guide';
|
|
48
|
+
const coverContext = { resources: loaded.data?.resources ?? [] };
|
|
49
|
+
return (_jsxs(_Fragment, { children: [_jsx(PanelHeader, { title: node?.title ?? '', meta: resource?.title ?? loaded.data?.project.name, onBack: () => { actions.pop(); }, backLabel: t('action.back'), actions: node === undefined ? [] : [
|
|
50
|
+
{ id: 'ask', label: t('action.askCoach'), icon: _jsx(IconSparkleMedium, {}), onSelect: () => { prime(t('prime.node', { book: resource?.title ?? '', node: node.title })); } },
|
|
51
|
+
], moreLabel: t('action.more') }), node !== undefined && (_jsx("div", { className: css.coversRow, children: _jsx(CoverChips, { covers: node.covers, context: coverContext, t: t }) })), _jsx(UnderlineTabs, { label: t('node.tabs'), tabs: tabs, value: activeTab, onChange: next => { actions.replace({ view: 'node', projectId, resourceId, nodeId, tab: next }); } }), _jsxs("div", { className: css.body, children: [_jsx(StateLine, { loaded: loaded, t: t }), notice !== undefined && _jsx("div", { className: css.notice, children: notice }), node !== undefined && activeTab === 'guide' && (_jsx(GuideTab, { p: p, projectId: projectId, resourceId: resourceId, node: node, resource: resource, coverContext: coverContext })), node !== undefined && activeTab === 'pages' && resource !== undefined && (_jsx(PagesTab, { p: p, projectId: projectId, resourceId: resourceId, resourceTitle: resource.title, node: node, onBind: () => { void p.bindTarget({ projectId, resourceId }); } })), node !== undefined && activeTab === 'quiz' && node.gate === 'acceptance' && (_jsx(AcceptanceTab, { p: p, projectId: projectId, node: node, passRatio: loaded.data?.passRatio ?? 0.8, onPrime: () => { prime(t('prime.accept', { node: node.title, nodeId: node.id, criteria: acceptanceLines(node).map(line => `- ${line.id}: ${line.line}`).join('\n') })); } })), node !== undefined && activeTab === 'quiz' && node.gate !== 'acceptance' && (_jsx(QuizTab, { p: p, projectId: projectId, resourceId: resourceId, node: node, passRatio: loaded.data?.passRatio ?? 0.8, onPrime: (focus, context) => {
|
|
52
|
+
const base = t(`prime.quiz.${focus}`, { book: resource?.title ?? '', node: node.title });
|
|
53
|
+
const weak = context?.weak ?? [];
|
|
54
|
+
const points = context?.points ?? [];
|
|
55
|
+
const kinds = context?.kinds ?? [];
|
|
56
|
+
prime(base
|
|
57
|
+
+ (weak.length > 0 ? t('prime.quiz.weakSuffix', { terms: weak.join('、') }) : '')
|
|
58
|
+
+ (points.length > 0 ? t('prime.quiz.pointsSuffix', { points: points.join('、') }) : '')
|
|
59
|
+
+ (kinds.length > 0 ? t('prime.quiz.kindsSuffix', { kinds: kinds.map(kind => t(`kind.${kind}`)).join('、') }) : '')
|
|
60
|
+
+ (context?.count !== undefined ? t('prime.quiz.countSuffix', { count: String(context.count) }) : ''));
|
|
61
|
+
}, onReplace: prompts => { prime(t('prime.quiz.replace', { node: node.title, list: prompts.map(prompt => `- ${prompt}`).join('\n') })); } }))] })] }));
|
|
62
|
+
}
|
|
63
|
+
function GuideTab({ p, projectId, resourceId, node, resource, coverContext }) {
|
|
64
|
+
const { t, actions } = p;
|
|
65
|
+
const rows = resource === undefined ? [] : rowsOf(node, resource);
|
|
66
|
+
const [error, setError] = useState();
|
|
67
|
+
const [marking, setMarking] = useState(false);
|
|
68
|
+
return (_jsxs(_Fragment, { children: [node.state === 'locked' && _jsx("div", { className: css.notice, children: t('node.locked') }), resourceId !== undefined && _jsx(LessonCard, { p: p, projectId: projectId, resourceId: resourceId, node: node, resource: resource }), node.goals.length > 0 && (_jsxs("section", { className: css.section, children: [_jsx("h3", { className: css.sectionTitle, children: t('node.goals') }), _jsx("ul", { className: css.goals, children: node.goals.map(goal => _jsx("li", { children: goal }, goal)) })] })), rows.length > 0 && (_jsxs("section", { className: css.section, children: [_jsx("h3", { className: css.sectionTitle, children: t('node.covers') }), _jsx("ul", { className: css.goals, children: rows.map(row => _jsx("li", { children: row }, row)) })] })), resource === undefined && node.covers.length > 0 && (_jsxs("section", { className: css.section, children: [_jsx("h3", { className: css.sectionTitle, children: t('node.covers') }), _jsx("ul", { className: css.goals, children: node.covers.map((cover, at) => _jsx("li", { children: coverLabel(cover, coverContext, t) }, at)) })] })), _jsx("div", { className: css.actions, children: node.gate === 'read' && node.state === 'current' && (_jsx("button", { type: "button", className: css.primary, disabled: marking, onClick: () => {
|
|
69
|
+
setMarking(true);
|
|
70
|
+
void outcome(p.markNodeRead(projectId, resourceId, node.id)).then(() => { actions.pop(); }, (failure) => { setError(String(failure)); setMarking(false); });
|
|
71
|
+
}, children: t('node.markRead') })) }), error !== undefined && _jsx("div", { className: css.error, children: error })] }));
|
|
72
|
+
}
|
|
73
|
+
function PagesTab({ p, projectId, resourceId, resourceTitle, node, onBind }) {
|
|
74
|
+
const { t, useStore, actions } = p;
|
|
75
|
+
const rev = useStore(s => s.rev);
|
|
76
|
+
const [notice, setNotice] = useState();
|
|
77
|
+
// After a photo goes out, re-read every few seconds until the coach has
|
|
78
|
+
// recorded a new page (or two minutes pass) — the page lands here by itself.
|
|
79
|
+
const [watching, setWatching] = useState();
|
|
80
|
+
const [tick, setTick] = useState(0);
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
if (watching === undefined)
|
|
83
|
+
return;
|
|
84
|
+
const timer = setInterval(() => {
|
|
85
|
+
if (Date.now() - watching.since > 120_000)
|
|
86
|
+
setWatching(undefined);
|
|
87
|
+
else
|
|
88
|
+
setTick(value => value + 1);
|
|
89
|
+
}, 4000);
|
|
90
|
+
return () => { clearInterval(timer); };
|
|
91
|
+
}, [watching]);
|
|
92
|
+
const pages = useLoad(() => value(p.listPages(projectId, resourceId, node.id)), [projectId, resourceId, node.id, rev, tick]);
|
|
93
|
+
const cards = useLoad(() => value(p.listCards({ projectId, nodeId: node.id, limit: 50 })), [projectId, node.id, rev, tick]);
|
|
94
|
+
const count = pages.data?.pages.length ?? 0;
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
if (watching !== undefined && count > watching.pages) {
|
|
97
|
+
setWatching(undefined);
|
|
98
|
+
setNotice(t('node.photoRecorded'));
|
|
99
|
+
}
|
|
100
|
+
}, [count, watching]);
|
|
101
|
+
const send = (sent, ok) => {
|
|
102
|
+
setNotice(sent ? ok : t('notice.noConversation'));
|
|
103
|
+
};
|
|
104
|
+
const onPick = (file) => {
|
|
105
|
+
if (file === undefined)
|
|
106
|
+
return;
|
|
107
|
+
onBind();
|
|
108
|
+
const sent = p.sendPhoto(file, t('prime.photo', { book: resourceTitle, node: node.title }));
|
|
109
|
+
if (sent)
|
|
110
|
+
setWatching({ since: Date.now(), pages: count });
|
|
111
|
+
send(sent, t('node.photoSent'));
|
|
112
|
+
};
|
|
113
|
+
return (_jsxs(_Fragment, { children: [_jsx(StateLine, { loaded: pages, t: t }), notice !== undefined && _jsx("div", { className: css.notice, children: notice }), _jsxs("div", { className: css.pages, children: [(pages.data?.pages ?? []).map(page => _jsx(PageThumb, { p: p, projectId: projectId, page: page }, page.id)), _jsxs("label", { className: css.pageAdd, children: [_jsx(IconCameraOutlineMedium, {}), _jsx("span", { children: watching === undefined ? t('node.photo') : t('node.photoWaiting') }), _jsx("input", { className: css.fileInput, type: "file", accept: "image/*", capture: "environment", onChange: event => { onPick(event.target.files?.[0]); event.target.value = ''; } })] })] }), (pages.data?.pages ?? []).map(page => (_jsx(PageFixes, { p: p, page: page, onSend: (text, ok) => { onBind(); send(p.sendMessage(text), ok); } }, page.id))), (cards.data?.cards.length ?? 0) > 0 && (_jsxs("section", { className: css.section, children: [_jsx("h3", { className: css.sectionTitle, children: t('node.cardsHere', { count: String(cards.data.total) }) }), cards.data.cards.map(card => (_jsx(ListRow, { title: card.term, meta: shortGloss(card.gloss), trailing: _jsx(WeaknessBars, { weakness: card.weakness, label: t('cards.weakness', { level: String(card.weakness) }) }), onSelect: () => { actions.push({ view: 'cards', projectId, filter: 'all' }); } }, card.id)))] }))] }));
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* One page's corrections: the marks the coach was unsure of (tap to keep
|
|
117
|
+
* one) and a word it missed (type it). Both go out as the learner's own
|
|
118
|
+
* message, so the chat shows what was asked and the coach cards it in context.
|
|
119
|
+
*/
|
|
120
|
+
function PageFixes({ p, page, onSend }) {
|
|
121
|
+
const { t } = p;
|
|
122
|
+
const [adding, setAdding] = useState(false);
|
|
123
|
+
const [word, setWord] = useState('');
|
|
124
|
+
const label = page.label ?? t('node.thisPage');
|
|
125
|
+
const uncertain = page.marks.filter(mark => mark.uncertain === true);
|
|
126
|
+
return (_jsxs("section", { className: css.fixes, children: [_jsxs("div", { className: css.fixesHead, children: [_jsx("span", { className: css.muted, children: label }), !adding && _jsx("button", { type: "button", className: css.textButton, onClick: () => { setAdding(true); }, children: t('node.addMissed') })] }), uncertain.length > 0 && (_jsxs("div", { className: css.chips, children: [_jsx("span", { className: css.muted, children: t('node.uncertain') }), uncertain.map(mark => (_jsx("button", { type: "button", className: css.chipButton, onClick: () => { onSend(t('prime.keepMark', { page: label, word: mark.text }), t('node.fixSent')); }, children: mark.text }, mark.text)))] })), adding && (_jsxs("form", { className: css.row, onSubmit: event => {
|
|
127
|
+
event.preventDefault();
|
|
128
|
+
if (word.trim() === '')
|
|
129
|
+
return;
|
|
130
|
+
onSend(t('prime.addMissed', { page: label, word: word.trim() }), t('node.fixSent'));
|
|
131
|
+
setWord('');
|
|
132
|
+
setAdding(false);
|
|
133
|
+
}, children: [_jsx("input", { className: css.input, autoFocus: true, value: word, placeholder: t('node.addMissedPlaceholder'), onChange: event => { setWord(event.target.value); } }), _jsx("button", { type: "submit", className: css.secondary, disabled: word.trim() === '', children: t('node.send') })] }))] }));
|
|
134
|
+
}
|
|
135
|
+
/** A page thumbnail: the photo read back on demand, its label and mark count. */
|
|
136
|
+
function PageThumb({ p, projectId, page }) {
|
|
137
|
+
const { t } = p;
|
|
138
|
+
const photo = useLoad(() => outcome(p.readPagePhoto(projectId, page.id)), [projectId, page.id]);
|
|
139
|
+
return (_jsxs("figure", { className: css.page, children: [photo.data !== undefined
|
|
140
|
+
? _jsx("img", { className: css.pageImage, src: `data:${photo.data.mediaType};base64,${photo.data.data}`, alt: page.label ?? '' })
|
|
141
|
+
: _jsx("span", { className: css.pageImage, "aria-hidden": "true" }), _jsx("figcaption", { className: css.muted, children: t('node.pageCaption', { label: page.label ?? '', marks: String(page.marks.length) }) })] }));
|
|
142
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The image references a session's messages carried, remembered as they are
|
|
3
|
+
* committed — so recording a photographed page never depends on the model
|
|
4
|
+
* copying a reference it only partly sees.
|
|
5
|
+
*
|
|
6
|
+
* Live trial (2026-10-06, deepseek-flash): the model sees an image block's id
|
|
7
|
+
* without its `sha256:` prefix and without `bytes`, and guessed the rest four
|
|
8
|
+
* times over. The full reference is on the committed `user/message` event,
|
|
9
|
+
* so the tool row watches `session/event` and keeps the last few per session
|
|
10
|
+
* (the synchronous history readers are deprecated; this is the delivered
|
|
11
|
+
* current event). A restarted host starts empty: a page then needs the full
|
|
12
|
+
* reference, or a photo sent after the restart.
|
|
13
|
+
*
|
|
14
|
+
* @module @khorsheed/dsh-library/image-refs
|
|
15
|
+
*/
|
|
16
|
+
import type { LibraryAttachmentRef } from './types.ts';
|
|
17
|
+
/** A whole reference, when a value carries every field. */
|
|
18
|
+
export declare function asAttachmentRef(value: unknown): LibraryAttachmentRef | undefined;
|
|
19
|
+
/** The image references each session's user messages carried, newest last. */
|
|
20
|
+
export declare class ImageRefCache {
|
|
21
|
+
private readonly bySession;
|
|
22
|
+
/** Remember the images one committed event carries (non-image events are ignored). */
|
|
23
|
+
observe(sessionId: string, event: {
|
|
24
|
+
type?: unknown;
|
|
25
|
+
data?: unknown;
|
|
26
|
+
}): void;
|
|
27
|
+
remember(sessionId: string, ref: LibraryAttachmentRef): void;
|
|
28
|
+
/**
|
|
29
|
+
* The whole reference for what the model wrote: a complete reference
|
|
30
|
+
* passes through; an id (with or without its prefix) is looked up; nothing
|
|
31
|
+
* at all means the session's latest photo.
|
|
32
|
+
*/
|
|
33
|
+
resolve(sessionId: string | undefined, written: unknown): LibraryAttachmentRef | undefined;
|
|
34
|
+
}
|
|
35
|
+
//# sourceMappingURL=image-refs.d.ts.map
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/** How many images per session are remembered. */
|
|
2
|
+
const PER_SESSION = 20;
|
|
3
|
+
/** How many sessions are remembered. */
|
|
4
|
+
const SESSIONS = 200;
|
|
5
|
+
/** The id without its digest prefix, for matching what the model wrote. */
|
|
6
|
+
function bareId(id) {
|
|
7
|
+
return id.replace(/^sha256:/, '');
|
|
8
|
+
}
|
|
9
|
+
/** A whole reference, when a value carries every field. */
|
|
10
|
+
export function asAttachmentRef(value) {
|
|
11
|
+
if (typeof value !== 'object' || value === null)
|
|
12
|
+
return undefined;
|
|
13
|
+
const ref = value;
|
|
14
|
+
if (typeof ref.attachmentId !== 'string' || typeof ref.mediaType !== 'string')
|
|
15
|
+
return undefined;
|
|
16
|
+
if (typeof ref.bytes !== 'number' || ref.bytes <= 0)
|
|
17
|
+
return undefined;
|
|
18
|
+
if (typeof ref.width !== 'number' || typeof ref.height !== 'number')
|
|
19
|
+
return undefined;
|
|
20
|
+
return { attachmentId: ref.attachmentId, mediaType: ref.mediaType, bytes: ref.bytes, width: ref.width, height: ref.height };
|
|
21
|
+
}
|
|
22
|
+
/** The image references each session's user messages carried, newest last. */
|
|
23
|
+
export class ImageRefCache {
|
|
24
|
+
bySession = new Map();
|
|
25
|
+
/** Remember the images one committed event carries (non-image events are ignored). */
|
|
26
|
+
observe(sessionId, event) {
|
|
27
|
+
if (event.type !== 'user/message')
|
|
28
|
+
return;
|
|
29
|
+
const content = event.data?.content;
|
|
30
|
+
if (!Array.isArray(content))
|
|
31
|
+
return;
|
|
32
|
+
for (const block of content) {
|
|
33
|
+
if (block?.type !== 'image')
|
|
34
|
+
continue;
|
|
35
|
+
const ref = asAttachmentRef(block.attachment);
|
|
36
|
+
if (ref !== undefined)
|
|
37
|
+
this.remember(sessionId, ref);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
remember(sessionId, ref) {
|
|
41
|
+
const list = this.bySession.get(sessionId) ?? [];
|
|
42
|
+
const kept = list.filter(entry => entry.attachmentId !== ref.attachmentId);
|
|
43
|
+
kept.push(ref);
|
|
44
|
+
this.bySession.delete(sessionId);
|
|
45
|
+
this.bySession.set(sessionId, kept.slice(-PER_SESSION));
|
|
46
|
+
while (this.bySession.size > SESSIONS)
|
|
47
|
+
this.bySession.delete(this.bySession.keys().next().value);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* The whole reference for what the model wrote: a complete reference
|
|
51
|
+
* passes through; an id (with or without its prefix) is looked up; nothing
|
|
52
|
+
* at all means the session's latest photo.
|
|
53
|
+
*/
|
|
54
|
+
resolve(sessionId, written) {
|
|
55
|
+
const whole = asAttachmentRef(written);
|
|
56
|
+
const known = sessionId === undefined ? [] : this.bySession.get(sessionId) ?? [];
|
|
57
|
+
const id = typeof written === 'string'
|
|
58
|
+
? written
|
|
59
|
+
: typeof written?.attachmentId === 'string'
|
|
60
|
+
? written.attachmentId
|
|
61
|
+
: undefined;
|
|
62
|
+
if (id !== undefined) {
|
|
63
|
+
const match = known.find(ref => bareId(ref.attachmentId) === bareId(id));
|
|
64
|
+
if (match !== undefined)
|
|
65
|
+
return match;
|
|
66
|
+
}
|
|
67
|
+
if (whole !== undefined)
|
|
68
|
+
return whole;
|
|
69
|
+
return id === undefined ? known.at(-1) : undefined;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Library plugin, host half: provides the service core and mounts its Remote
|
|
3
|
+
* face. Composing this plugin out of a profile removes every surface it adds.
|
|
4
|
+
*
|
|
5
|
+
* The host half owns the two things the browser cannot do: persisting state
|
|
6
|
+
* next to the deployment (`$DSH_HOME/state/dsh-library`, plain `node:fs` — the
|
|
7
|
+
* reader precedent; `ctx.fs` is the session-fenced sandbox and refuses it) and
|
|
8
|
+
* the attachment store (saving sidebar photos, reading page photos back). The
|
|
9
|
+
* browser half renders.
|
|
10
|
+
*
|
|
11
|
+
* The model face (library_read, one library_<noun> tool per write,
|
|
12
|
+
* library_assess, the coach prompt section, the per-turn bound context) is
|
|
13
|
+
* deliberately NOT here: it is the `./tool` composition entry
|
|
14
|
+
* (`src/tool.ts`), which an agent preset names — the evaluation-family fold
|
|
15
|
+
* pattern.
|
|
16
|
+
*
|
|
17
|
+
* @module @khorsheed/dsh-library
|
|
18
|
+
*/
|
|
19
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
20
|
+
import { LibraryService } from './service.ts';
|
|
21
|
+
export { LibraryService, LibraryDisposedError, leitnerDueAt, unlockReady, validateRoadmapShape, coversVerbatimBlocker } from './service.ts';
|
|
22
|
+
export { cardKey, cardKeys, cardMatchesQuery, exactMatch, keySimilarity, nearMatches, NEAR_DUPLICATE } from './cards.ts';
|
|
23
|
+
export { LibraryRemoteService } from './remote.ts';
|
|
24
|
+
export type { LibraryRemoteConfig } from './remote.ts';
|
|
25
|
+
export { acquireLibraryLock, assertLibraryId, emptyLibraryIndex, LibraryStore, LibraryStoreError, mintLibraryId, normalizeAssessment, normalizeAttachmentRef, normalizeAttempt, normalizeBindings, normalizeCard, normalizeEvent, normalizeNodeCover, normalizePage, normalizeLibraryIndex, normalizeProject, normalizeResource, normalizeRoadmap, resolveLibraryStateRoot, } from './store.ts';
|
|
26
|
+
export * from './types.ts';
|
|
27
|
+
declare module '@deepseek-ai/cordis' {
|
|
28
|
+
interface Context {
|
|
29
|
+
/** The library plugin's service core. */
|
|
30
|
+
library: LibraryService;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/** Cordis plugin name used by loader diagnostics. */
|
|
34
|
+
export declare const name = "library";
|
|
35
|
+
/** Optional state-root override, for deployments that keep state elsewhere. */
|
|
36
|
+
export interface LibraryConfig {
|
|
37
|
+
/** Directory holding `state.json`; defaults to `$DSH_HOME/state/dsh-library`. */
|
|
38
|
+
readonly stateRoot?: string;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Plugin body: provide the service core, then mount the Remote data face.
|
|
42
|
+
*
|
|
43
|
+
* No `static inject`: every optional capability (`attachments`) is probed at
|
|
44
|
+
* the point of use, so the row boots in any composition and degrades —
|
|
45
|
+
* `unavailable`/`storage-unavailable` refusals instead of a boot failure.
|
|
46
|
+
*
|
|
47
|
+
* @param ctx - owning Cordis context.
|
|
48
|
+
* @param config - optional plugin config.
|
|
49
|
+
*/
|
|
50
|
+
export declare function apply(ctx: Context, config?: LibraryConfig): void;
|
|
51
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { LibraryService } from "./service.js";
|
|
2
|
+
import { LibraryRemoteService } from "./remote.js";
|
|
3
|
+
import { libraryErrorMessage } from "./types.js";
|
|
4
|
+
export { LibraryService, LibraryDisposedError, leitnerDueAt, unlockReady, validateRoadmapShape, coversVerbatimBlocker } from "./service.js";
|
|
5
|
+
export { cardKey, cardKeys, cardMatchesQuery, exactMatch, keySimilarity, nearMatches, NEAR_DUPLICATE } from "./cards.js";
|
|
6
|
+
export { LibraryRemoteService } from "./remote.js";
|
|
7
|
+
export { acquireLibraryLock, assertLibraryId, emptyLibraryIndex, LibraryStore, LibraryStoreError, mintLibraryId, normalizeAssessment, normalizeAttachmentRef, normalizeAttempt, normalizeBindings, normalizeCard, normalizeEvent, normalizeNodeCover, normalizePage, normalizeLibraryIndex, normalizeProject, normalizeResource, normalizeRoadmap, resolveLibraryStateRoot, } from "./store.js";
|
|
8
|
+
export * from "./types.js";
|
|
9
|
+
/** Cordis plugin name used by loader diagnostics. */
|
|
10
|
+
export const name = 'library';
|
|
11
|
+
/**
|
|
12
|
+
* Plugin body: provide the service core, then mount the Remote data face.
|
|
13
|
+
*
|
|
14
|
+
* No `static inject`: every optional capability (`attachments`) is probed at
|
|
15
|
+
* the point of use, so the row boots in any composition and degrades —
|
|
16
|
+
* `unavailable`/`storage-unavailable` refusals instead of a boot failure.
|
|
17
|
+
*
|
|
18
|
+
* @param ctx - owning Cordis context.
|
|
19
|
+
* @param config - optional plugin config.
|
|
20
|
+
*/
|
|
21
|
+
export function apply(ctx, config = {}) {
|
|
22
|
+
const service = new LibraryService(ctx, config);
|
|
23
|
+
ctx.provide('library', service);
|
|
24
|
+
ctx.plugin(LibraryRemoteService, {});
|
|
25
|
+
// Migrate a v2 state root to v3 (roadmap nodes' chapterRanges → covers).
|
|
26
|
+
// Fire-and-forget: reads tolerate v2 until it lands, and a failed pass (a
|
|
27
|
+
// read-only root) only means the files stay v2 — never a boot failure.
|
|
28
|
+
void service.store.migrateState().catch((error) => {
|
|
29
|
+
ctx.logger.warn(`library: state migration did not run — ${libraryErrorMessage(error)}`);
|
|
30
|
+
});
|
|
31
|
+
}
|