dsh-nerd-mode 0.4.4 → 0.4.6
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/README.md +2 -0
- package/README.zh.md +2 -0
- package/client.js +492 -77
- package/package.json +1 -1
- package/skills/study/SKILL.md +1 -1
- package/skills/study-plus/SKILL.md +7 -7
- package/skills/study-plus/scripts/book-client.js +8 -10
- package/skills/study-plus/scripts/build-book.mjs +9 -3
package/README.md
CHANGED
|
@@ -213,6 +213,8 @@ Change the defaults under Settings → Nerd Mode.
|
|
|
213
213
|
|
|
214
214
|
A four-option question appears as a card with one correct choice. The guide asks a short-answer question in the conversation. Save applies to the next session, Cancel discards a draft, and Restore defaults returns to recall off, 3 questions, short answer. A preference stated inside one `/study-plus` request wins for that session only and does not update the profile.
|
|
215
215
|
|
|
216
|
+
The About group at the bottom shows the installed and the newest npm version. Its button installs an update through DSH Market and offers a restart when the host allows one. The whole page follows the DSH interface language.
|
|
217
|
+
|
|
216
218
|
## How it fits into dsh
|
|
217
219
|
|
|
218
220
|
Nerd Mode is a profile-level plugin, mounted from `cordis.patch.yml`. The patch covers the whole profile, so every mode gains the command and the pill.
|
package/README.zh.md
CHANGED
|
@@ -213,6 +213,8 @@ HTML 书页会在每次批量写稿和每节课后刷新,每节一页,还没
|
|
|
213
213
|
|
|
214
214
|
四选一以卡片出现,四个选项里只有一个对;简答直接在对话里问。保存从下一节课开始生效,取消只丢草稿,恢复默认回到关闭、3 题、简答。在一次 `/study-plus` 请求里明确说出的偏好,只对那一次会话生效,不会改 profile。
|
|
215
215
|
|
|
216
|
+
底部的「关于」显示已安装版本和 npm 上的最新版本。点按钮会通过 DSH Market 安装更新;宿主允许时还会给出重启按钮。整页文案跟随 DSH 界面语言。
|
|
217
|
+
|
|
216
218
|
## 它怎么接进 dsh
|
|
217
219
|
|
|
218
220
|
Nerd Mode 是一个挂在 profile 层的插件,由 `cordis.patch.yml` 装入。补丁覆盖整个 profile,所以每个模式都有命令和胶囊。
|
package/client.js
CHANGED
|
@@ -59,7 +59,110 @@ window.__ModuleLoader__.load({
|
|
|
59
59
|
&& left.unseenQuestionType === right.unseenQuestionType;
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
|
|
62
|
+
const MARKET_API = '/dsh-market/api/v1';
|
|
63
|
+
const PACKAGE = 'dsh-nerd-mode';
|
|
64
|
+
const UPDATE_SCHEMA = 'dsh-market/update-api/v1';
|
|
65
|
+
const REQUEST_TIMEOUT = 10000;
|
|
66
|
+
const POLL_INTERVAL = 1000;
|
|
67
|
+
|
|
68
|
+
function useT(locale) {
|
|
69
|
+
React.useSyncExternalStore(
|
|
70
|
+
(listener) => locale.subscribe(listener),
|
|
71
|
+
() => locale.getSnapshot(),
|
|
72
|
+
() => locale.getSnapshot(),
|
|
73
|
+
);
|
|
74
|
+
return (text) => locale.resolveText(text);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Every Market call carries the session cookies and gives up after 10 s.
|
|
78
|
+
async function marketRequest(url, options = {}, life = null) {
|
|
79
|
+
const controller = new AbortController();
|
|
80
|
+
if (life !== null) life.controller = controller;
|
|
81
|
+
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT);
|
|
82
|
+
try {
|
|
83
|
+
const response = await fetch(url, {
|
|
84
|
+
credentials: 'same-origin',
|
|
85
|
+
...options,
|
|
86
|
+
signal: controller.signal,
|
|
87
|
+
});
|
|
88
|
+
const body = await response.json().catch(() => null);
|
|
89
|
+
return { ok: response.ok, status: response.status, body };
|
|
90
|
+
} catch (error) {
|
|
91
|
+
if (controller.signal.aborted) throw new Error('DSH Market did not answer within 10 seconds.');
|
|
92
|
+
throw error;
|
|
93
|
+
} finally {
|
|
94
|
+
clearTimeout(timer);
|
|
95
|
+
if (life !== null && life.controller === controller) life.controller = null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function marketFailure(result, fallback) {
|
|
100
|
+
const body = result?.body ?? {};
|
|
101
|
+
return body.error
|
|
102
|
+
?? body.failure?.message
|
|
103
|
+
?? body.failure?.code
|
|
104
|
+
?? `${fallback} (HTTP ${result?.status ?? 0}).`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// The schema is optional on the operation endpoints, but a wrong one is a different API.
|
|
108
|
+
const wrongSchema = (body) => body?.schema !== undefined && body.schema !== UPDATE_SCHEMA;
|
|
109
|
+
|
|
110
|
+
async function readCapabilities(life) {
|
|
111
|
+
const result = await marketRequest(`${MARKET_API}/capabilities`, {}, life);
|
|
112
|
+
const body = result.body ?? {};
|
|
113
|
+
if (!result.ok) throw new Error(marketFailure(result, 'DSH Market did not answer'));
|
|
114
|
+
if (body.schema !== UPDATE_SCHEMA || body.features?.update !== true) {
|
|
115
|
+
throw new Error('DSH Market does not support plugin updates.');
|
|
116
|
+
}
|
|
117
|
+
const endpoints = body.endpoints ?? {};
|
|
118
|
+
if (typeof endpoints.updates !== 'string' || typeof endpoints.operations !== 'string') {
|
|
119
|
+
throw new Error('DSH Market returned no update endpoints.');
|
|
120
|
+
}
|
|
121
|
+
return {
|
|
122
|
+
endpoints: { updates: endpoints.updates, operations: endpoints.operations },
|
|
123
|
+
profile: typeof body.profile === 'string' ? body.profile : null,
|
|
124
|
+
restart: body.features.restart === true && typeof endpoints.restart === 'string' ? endpoints.restart : null,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function readRelease(market, force, life) {
|
|
129
|
+
const url = `${market.endpoints.updates}?name=${encodeURIComponent(PACKAGE)}${force ? '&force=1' : ''}`;
|
|
130
|
+
const result = await marketRequest(url, {}, life);
|
|
131
|
+
const body = result.body ?? {};
|
|
132
|
+
if (!result.ok) throw new Error(marketFailure(result, 'DSH Market did not check for an update'));
|
|
133
|
+
if (body.schema !== UPDATE_SCHEMA || typeof body.package !== 'object' || body.package === null) {
|
|
134
|
+
throw new Error('DSH Market returned no release for this plugin.');
|
|
135
|
+
}
|
|
136
|
+
return body.package;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const VERSION = /^(\d+)\.(\d+)\.(\d+)$/;
|
|
140
|
+
|
|
141
|
+
// Only a plain `major.minor.patch` pair can prove the local copy is newer.
|
|
142
|
+
function compareVersions(left, right) {
|
|
143
|
+
const first = VERSION.exec(String(left ?? ''));
|
|
144
|
+
const second = VERSION.exec(String(right ?? ''));
|
|
145
|
+
if (first === null || second === null) return 0;
|
|
146
|
+
for (let at = 1; at <= 3; at += 1) {
|
|
147
|
+
const difference = Number(first[at]) - Number(second[at]);
|
|
148
|
+
if (difference !== 0) return difference;
|
|
149
|
+
}
|
|
150
|
+
return 0;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const manualCommand = (profile) => `dsh plugin --profile ${profile ?? '<profile>'} add ${PACKAGE}`;
|
|
154
|
+
|
|
155
|
+
function NerdModeSettingsEntry({ configForms, close, locale }) {
|
|
156
|
+
return React.createElement(
|
|
157
|
+
'div',
|
|
158
|
+
{ 'data-nerd-mode-settings': '', style: settingsColumnStyle },
|
|
159
|
+
React.createElement(NerdModeSettingsBody, { configForms, close, locale }),
|
|
160
|
+
React.createElement(AboutGroup, { locale }),
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function NerdModeSettingsBody({ configForms, close, locale }) {
|
|
165
|
+
const t = useT(locale);
|
|
63
166
|
const description = configForms.describe();
|
|
64
167
|
const described = React.useSyncExternalStore(
|
|
65
168
|
(listener) => description.subscribe(listener),
|
|
@@ -69,30 +172,32 @@ window.__ModuleLoader__.load({
|
|
|
69
172
|
const settingsExist = described.view?.namespaces.some(({ ns }) => ns === SETTINGS_NAMESPACE) === true;
|
|
70
173
|
if (!settingsExist) {
|
|
71
174
|
const loading = (described.status === 'idle' || described.status === 'loading') && !described.error;
|
|
72
|
-
return React.createElement(
|
|
175
|
+
return React.createElement(React.Fragment, null,
|
|
73
176
|
React.createElement('p', {
|
|
74
177
|
role: 'status',
|
|
75
178
|
'aria-busy': loading,
|
|
76
179
|
style: feedbackStyle,
|
|
77
180
|
}, loading
|
|
78
|
-
? 'Loading Nerd Mode settings…'
|
|
181
|
+
? t({ en: 'Loading Nerd Mode settings…', zh: '正在加载 Nerd Mode 设置…' })
|
|
79
182
|
: described.status === 'ready'
|
|
80
|
-
? 'Nerd Mode settings are unavailable in this profile.'
|
|
81
|
-
: 'Nerd Mode settings could not be loaded. Refresh the Settings window and try again.'),
|
|
183
|
+
? t({ en: 'Nerd Mode settings are unavailable in this profile.', zh: '此 profile 中没有 Nerd Mode 设置。' })
|
|
184
|
+
: t({ en: 'Nerd Mode settings could not be loaded. Refresh the Settings window and try again.', zh: '无法加载 Nerd Mode 设置。请刷新设置窗口后重试。' })),
|
|
82
185
|
loading ? null : React.createElement('button', {
|
|
83
186
|
type: 'button',
|
|
84
187
|
onClick: close,
|
|
85
188
|
style: secondaryButtonStyle,
|
|
86
|
-
}, 'Close'),
|
|
189
|
+
}, t({ en: 'Close', zh: '关闭' })),
|
|
87
190
|
);
|
|
88
191
|
}
|
|
89
192
|
return React.createElement(NerdModeSettings, {
|
|
90
193
|
close,
|
|
194
|
+
locale,
|
|
91
195
|
scope: configForms.get(SETTINGS_NAMESPACE),
|
|
92
196
|
});
|
|
93
197
|
}
|
|
94
198
|
|
|
95
|
-
function NerdModeSettings({ scope, close }) {
|
|
199
|
+
function NerdModeSettings({ scope, close, locale }) {
|
|
200
|
+
const t = useT(locale);
|
|
96
201
|
const snapshot = React.useSyncExternalStore(
|
|
97
202
|
(listener) => scope.subscribe(listener),
|
|
98
203
|
() => scope.getSnapshot(),
|
|
@@ -111,19 +216,19 @@ window.__ModuleLoader__.load({
|
|
|
111
216
|
role: 'status',
|
|
112
217
|
'aria-busy': true,
|
|
113
218
|
style: feedbackStyle,
|
|
114
|
-
}, 'Loading Nerd Mode settings…');
|
|
219
|
+
}, t({ en: 'Loading Nerd Mode settings…', zh: '正在加载 Nerd Mode 设置…' }));
|
|
115
220
|
}
|
|
116
221
|
if (snapshot?.status !== 'ready') {
|
|
117
|
-
return React.createElement(
|
|
222
|
+
return React.createElement(React.Fragment, null,
|
|
118
223
|
React.createElement('p', { role: 'status', style: feedbackStyle },
|
|
119
224
|
snapshot?.status === 'unavailable'
|
|
120
|
-
? 'Nerd Mode settings are unavailable in this profile.'
|
|
121
|
-
: 'Nerd Mode settings could not be loaded. Refresh the Settings window and try again.'),
|
|
225
|
+
? t({ en: 'Nerd Mode settings are unavailable in this profile.', zh: '此 profile 中没有 Nerd Mode 设置。' })
|
|
226
|
+
: t({ en: 'Nerd Mode settings could not be loaded. Refresh the Settings window and try again.', zh: '无法加载 Nerd Mode 设置。请刷新设置窗口后重试。' })),
|
|
122
227
|
React.createElement('button', {
|
|
123
228
|
type: 'button',
|
|
124
229
|
onClick: close,
|
|
125
230
|
style: secondaryButtonStyle,
|
|
126
|
-
}, 'Close'),
|
|
231
|
+
}, t({ en: 'Close', zh: '关闭' })),
|
|
127
232
|
);
|
|
128
233
|
}
|
|
129
234
|
const current = settingsValues(snapshot);
|
|
@@ -153,38 +258,39 @@ window.__ModuleLoader__.load({
|
|
|
153
258
|
setFeedback(null);
|
|
154
259
|
try {
|
|
155
260
|
const accepted = await scope.mutate(operations, latest.revision);
|
|
156
|
-
if (accepted === false)
|
|
261
|
+
if (accepted === false) {
|
|
262
|
+
throw new Error(t({ en: 'The profile rejected these settings.', zh: 'profile 拒绝了这些设置。' }));
|
|
263
|
+
}
|
|
157
264
|
setDraft(settingsValues(scope.getSnapshot()));
|
|
158
|
-
setFeedback(
|
|
265
|
+
setFeedback({
|
|
266
|
+
tone: 'status',
|
|
267
|
+
text: {
|
|
268
|
+
en: 'Saved to this profile. Changes apply to the next study-plus session.',
|
|
269
|
+
zh: '已保存到此 profile。更改将在下次 study-plus 会话中生效。',
|
|
270
|
+
},
|
|
271
|
+
});
|
|
159
272
|
} catch (error) {
|
|
160
|
-
setFeedback(
|
|
273
|
+
setFeedback({
|
|
274
|
+
tone: 'alert',
|
|
275
|
+
text: {
|
|
276
|
+
en: `Could not save settings: ${error?.message ?? 'the profile rejected the changes'}`,
|
|
277
|
+
zh: `无法保存设置:${error?.message ?? 'profile 拒绝了这些更改'}`,
|
|
278
|
+
},
|
|
279
|
+
});
|
|
161
280
|
} finally {
|
|
162
281
|
setSaving(false);
|
|
163
282
|
}
|
|
164
283
|
};
|
|
165
284
|
|
|
166
285
|
return React.createElement(
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
'data-nerd-mode-settings': '',
|
|
170
|
-
style: {
|
|
171
|
-
boxSizing: 'border-box',
|
|
172
|
-
color: 'var(--dsw-alias-label-primary)',
|
|
173
|
-
display: 'flex',
|
|
174
|
-
flexDirection: 'column',
|
|
175
|
-
fontFamily: 'var(--dsw-font-family, inherit)',
|
|
176
|
-
gap: 16,
|
|
177
|
-
maxWidth: 720,
|
|
178
|
-
paddingBottom: 24,
|
|
179
|
-
width: '100%',
|
|
180
|
-
},
|
|
181
|
-
},
|
|
286
|
+
React.Fragment,
|
|
287
|
+
null,
|
|
182
288
|
React.createElement(
|
|
183
289
|
'header',
|
|
184
290
|
{ style: { display: 'flex', flexDirection: 'column', gap: 4 } },
|
|
185
291
|
React.createElement('h2', {
|
|
186
292
|
style: { fontSize: 16, fontWeight: 600, lineHeight: '24px', margin: 0 },
|
|
187
|
-
}, 'Nerd Mode'),
|
|
293
|
+
}, t({ en: 'Nerd Mode', zh: 'Nerd Mode' })),
|
|
188
294
|
React.createElement('p', {
|
|
189
295
|
style: {
|
|
190
296
|
color: 'var(--dsw-alias-label-tertiary)',
|
|
@@ -192,7 +298,7 @@ window.__ModuleLoader__.load({
|
|
|
192
298
|
lineHeight: '22px',
|
|
193
299
|
margin: 0,
|
|
194
300
|
},
|
|
195
|
-
}, 'Profile defaults for new study-plus sessions.'),
|
|
301
|
+
}, t({ en: 'Profile defaults for new study-plus sessions.', zh: '新 study-plus 会话的 profile 默认值。' })),
|
|
196
302
|
),
|
|
197
303
|
React.createElement(
|
|
198
304
|
'section',
|
|
@@ -203,8 +309,8 @@ window.__ModuleLoader__.load({
|
|
|
203
309
|
React.createElement(
|
|
204
310
|
'div',
|
|
205
311
|
{ style: { minWidth: 0 } },
|
|
206
|
-
React.createElement('h3', { style: groupTitleStyle }, 'Recall Session / 课前回忆'),
|
|
207
|
-
React.createElement('p', { style: groupDescriptionStyle }, 'One ungraded recall question before teaching.'),
|
|
312
|
+
React.createElement('h3', { style: groupTitleStyle }, t({ en: 'Recall Session / 课前回忆', zh: '课前回忆' })),
|
|
313
|
+
React.createElement('p', { style: groupDescriptionStyle }, t({ en: 'One ungraded recall question before teaching.', zh: '讲授前的一次不计分课前回忆。' })),
|
|
208
314
|
),
|
|
209
315
|
React.createElement(
|
|
210
316
|
'button',
|
|
@@ -212,14 +318,16 @@ window.__ModuleLoader__.load({
|
|
|
212
318
|
type: 'button',
|
|
213
319
|
role: 'switch',
|
|
214
320
|
'aria-checked': draft.recallSessionEnabled,
|
|
215
|
-
'aria-label': 'Recall Session',
|
|
321
|
+
'aria-label': t({ en: 'Recall Session', zh: '课前回忆' }),
|
|
216
322
|
'data-setting': 'recallSessionEnabled',
|
|
217
323
|
'data-state': draft.recallSessionEnabled ? 'on' : 'off',
|
|
218
324
|
disabled: !writable || saving,
|
|
219
325
|
onClick: () => updateDraft('recallSessionEnabled', !draft.recallSessionEnabled),
|
|
220
326
|
style: switchStyle(draft.recallSessionEnabled, !writable || saving),
|
|
221
327
|
},
|
|
222
|
-
React.createElement('span', null, draft.recallSessionEnabled
|
|
328
|
+
React.createElement('span', null, draft.recallSessionEnabled
|
|
329
|
+
? t({ en: 'On', zh: '开' })
|
|
330
|
+
: t({ en: 'Off', zh: '关' })),
|
|
223
331
|
React.createElement('span', {
|
|
224
332
|
'aria-hidden': 'true',
|
|
225
333
|
style: {
|
|
@@ -241,8 +349,8 @@ window.__ModuleLoader__.load({
|
|
|
241
349
|
React.createElement(
|
|
242
350
|
'div',
|
|
243
351
|
null,
|
|
244
|
-
React.createElement('h3', { style: groupTitleStyle }, 'Post-lecture questions'),
|
|
245
|
-
React.createElement('p', { style: groupDescriptionStyle }, 'Choose how many unseen questions follow each lecture.'),
|
|
352
|
+
React.createElement('h3', { style: groupTitleStyle }, t({ en: 'Post-lecture questions', zh: '课后提问' })),
|
|
353
|
+
React.createElement('p', { style: groupDescriptionStyle }, t({ en: 'Choose how many unseen questions follow each lecture.', zh: '选择每节课后出几道题。' })),
|
|
246
354
|
),
|
|
247
355
|
React.createElement(
|
|
248
356
|
'div',
|
|
@@ -250,7 +358,7 @@ window.__ModuleLoader__.load({
|
|
|
250
358
|
React.createElement(
|
|
251
359
|
'label',
|
|
252
360
|
{ htmlFor: 'nerd-mode-question-count', style: fieldLabelStyle },
|
|
253
|
-
React.createElement('span', null, 'Question count'),
|
|
361
|
+
React.createElement('span', null, t({ en: 'Question count', zh: '题数' })),
|
|
254
362
|
React.createElement('input', {
|
|
255
363
|
id: 'nerd-mode-question-count',
|
|
256
364
|
type: 'number',
|
|
@@ -264,12 +372,12 @@ window.__ModuleLoader__.load({
|
|
|
264
372
|
onChange: (event) => updateDraft('unseenQuestionCount', event.target.value),
|
|
265
373
|
style: fieldControlStyle,
|
|
266
374
|
}),
|
|
267
|
-
React.createElement('span', { style: fieldHintStyle }, 'Use 0 to skip unseen questions.'),
|
|
375
|
+
React.createElement('span', { style: fieldHintStyle }, t({ en: 'Use 0 to skip unseen questions.', zh: '设为 0 可跳过课后提问。' })),
|
|
268
376
|
),
|
|
269
377
|
React.createElement(
|
|
270
378
|
'label',
|
|
271
379
|
{ htmlFor: 'nerd-mode-question-type', style: fieldLabelStyle },
|
|
272
|
-
React.createElement('span', null, 'Question type'),
|
|
380
|
+
React.createElement('span', null, t({ en: 'Question type', zh: '题型' })),
|
|
273
381
|
React.createElement(
|
|
274
382
|
'select',
|
|
275
383
|
{
|
|
@@ -280,11 +388,11 @@ window.__ModuleLoader__.load({
|
|
|
280
388
|
onChange: (event) => updateDraft('unseenQuestionType', event.target.value),
|
|
281
389
|
style: fieldControlStyle,
|
|
282
390
|
},
|
|
283
|
-
React.createElement('option', { value: 'short-answer' }, 'Short answer'),
|
|
284
|
-
React.createElement('option', { value: 'multiple-choice' }, 'Multiple choice'),
|
|
285
|
-
React.createElement('option', { value: 'mixed' }, 'Mixed'),
|
|
391
|
+
React.createElement('option', { value: 'short-answer' }, t({ en: 'Short answer', zh: '简答' })),
|
|
392
|
+
React.createElement('option', { value: 'multiple-choice' }, t({ en: 'Multiple choice', zh: '四选一' })),
|
|
393
|
+
React.createElement('option', { value: 'mixed' }, t({ en: 'Mixed', zh: '混合' })),
|
|
286
394
|
),
|
|
287
|
-
React.createElement('span', { style: fieldHintStyle }, 'Mixed chooses a type for each question.'),
|
|
395
|
+
React.createElement('span', { style: fieldHintStyle }, t({ en: 'Mixed chooses a type for each question.', zh: '混合会为每道题随机选题型。' })),
|
|
288
396
|
),
|
|
289
397
|
),
|
|
290
398
|
),
|
|
@@ -306,7 +414,7 @@ window.__ModuleLoader__.load({
|
|
|
306
414
|
disabled: !writable || saving,
|
|
307
415
|
onClick: restoreDefaults,
|
|
308
416
|
style: secondaryButtonStyle,
|
|
309
|
-
}, 'Restore defaults'),
|
|
417
|
+
}, t({ en: 'Restore defaults', zh: '恢复默认' })),
|
|
310
418
|
React.createElement(
|
|
311
419
|
'div',
|
|
312
420
|
{ style: { display: 'flex', flexWrap: 'wrap', gap: 8, justifyContent: 'flex-end' } },
|
|
@@ -315,27 +423,301 @@ window.__ModuleLoader__.load({
|
|
|
315
423
|
disabled: !dirty || saving,
|
|
316
424
|
onClick: cancel,
|
|
317
425
|
style: secondaryButtonStyle,
|
|
318
|
-
}, 'Cancel'),
|
|
426
|
+
}, t({ en: 'Cancel', zh: '取消' })),
|
|
319
427
|
React.createElement('button', {
|
|
320
428
|
type: 'button',
|
|
321
429
|
disabled: !dirty || !validSettings(draft) || !writable || saving,
|
|
322
430
|
onClick: save,
|
|
323
431
|
style: primaryButtonStyle,
|
|
324
|
-
}, saving ? 'Saving…' : 'Save'),
|
|
432
|
+
}, saving ? t({ en: 'Saving…', zh: '保存中…' }) : t({ en: 'Save', zh: '保存' })),
|
|
325
433
|
),
|
|
326
434
|
),
|
|
327
435
|
snapshot?.writable === false
|
|
328
|
-
? React.createElement('p', { role: 'status', style: feedbackStyle }, 'These profile settings are read-only.')
|
|
436
|
+
? React.createElement('p', { role: 'status', style: feedbackStyle }, t({ en: 'These profile settings are read-only.', zh: '这些 profile 设置为只读。' }))
|
|
329
437
|
: null,
|
|
330
438
|
feedback === null
|
|
331
439
|
? null
|
|
332
440
|
: React.createElement('p', {
|
|
333
|
-
role: feedback.
|
|
441
|
+
role: feedback.tone,
|
|
334
442
|
style: feedbackStyle,
|
|
335
|
-
}, feedback),
|
|
443
|
+
}, t(feedback.text)),
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function AboutGroup({ locale }) {
|
|
448
|
+
const t = useT(locale);
|
|
449
|
+
const [life] = React.useState(() => ({ cancelled: false, timer: null, controller: null }));
|
|
450
|
+
const [view, setView] = React.useState({ phase: 'checking' });
|
|
451
|
+
|
|
452
|
+
// An answer that arrives after the panel closed writes nothing.
|
|
453
|
+
const show = (next) => {
|
|
454
|
+
if (!life.cancelled) setView(next);
|
|
455
|
+
};
|
|
456
|
+
|
|
457
|
+
async function check(force) {
|
|
458
|
+
setView({ phase: 'checking' });
|
|
459
|
+
let market;
|
|
460
|
+
try {
|
|
461
|
+
market = await readCapabilities(life);
|
|
462
|
+
} catch {
|
|
463
|
+
show({ phase: 'unavailable' });
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
try {
|
|
467
|
+
const release = await readRelease(market, force, life);
|
|
468
|
+
show({ phase: 'ready', market, release, confirming: false, linkedNote: false });
|
|
469
|
+
} catch (error) {
|
|
470
|
+
show({
|
|
471
|
+
phase: 'failed',
|
|
472
|
+
market,
|
|
473
|
+
release: null,
|
|
474
|
+
message: error?.message ?? 'The update check failed.',
|
|
475
|
+
retry: 'check',
|
|
476
|
+
force: false,
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function follow(market, operationId, release) {
|
|
482
|
+
return new Promise((resolve, reject) => {
|
|
483
|
+
const tick = async () => {
|
|
484
|
+
if (life.cancelled) {
|
|
485
|
+
resolve(null);
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
let result;
|
|
489
|
+
try {
|
|
490
|
+
result = await marketRequest(`${market.endpoints.operations}?operationId=${encodeURIComponent(operationId)}`, {}, life);
|
|
491
|
+
} catch (error) {
|
|
492
|
+
reject(error);
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
if (life.cancelled) {
|
|
496
|
+
resolve(null);
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
if (!result.ok || wrongSchema(result.body)) {
|
|
500
|
+
reject(new Error(marketFailure(result, 'The update could not be followed')));
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
const operation = result.body?.operation ?? {};
|
|
504
|
+
if (operation.state === 'succeeded') {
|
|
505
|
+
resolve(operation);
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
if (operation.state === 'failed' || operation.state === 'cancelled' || operation.state === 'rolled-back') {
|
|
509
|
+
const error = new Error(operation.failure?.message ?? operation.failure?.code ?? `The update ${operation.state}.`);
|
|
510
|
+
error.code = operation.failure?.code ?? null;
|
|
511
|
+
reject(error);
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
show({ phase: 'updating', market, release, progress: operation.state ?? 'running' });
|
|
515
|
+
life.timer = setTimeout(tick, POLL_INTERVAL);
|
|
516
|
+
};
|
|
517
|
+
tick();
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
async function install(market, release, force) {
|
|
522
|
+
setView({ phase: 'updating', market, release, progress: 'queued' });
|
|
523
|
+
try {
|
|
524
|
+
const result = await marketRequest(market.endpoints.updates, {
|
|
525
|
+
method: 'POST',
|
|
526
|
+
headers: { 'content-type': 'application/json' },
|
|
527
|
+
body: JSON.stringify({ packageName: PACKAGE, ...(force ? { force: true } : {}) }),
|
|
528
|
+
}, life);
|
|
529
|
+
if (!result.ok || wrongSchema(result.body)) {
|
|
530
|
+
const error = new Error(marketFailure(result, 'The update did not start'));
|
|
531
|
+
error.code = result.body?.failure?.code ?? null;
|
|
532
|
+
throw error;
|
|
533
|
+
}
|
|
534
|
+
const operationId = result.body?.operation?.operationId;
|
|
535
|
+
if (typeof operationId !== 'string') throw new Error('DSH Market returned no operation to follow.');
|
|
536
|
+
const operation = await follow(market, operationId, release);
|
|
537
|
+
if (operation === null) return;
|
|
538
|
+
show({
|
|
539
|
+
phase: 'installed',
|
|
540
|
+
market,
|
|
541
|
+
release,
|
|
542
|
+
version: operation.installedVersion ?? release.latestVersion,
|
|
543
|
+
restartNeeded: operation.outcome?.restartRequired === true,
|
|
544
|
+
restarting: false,
|
|
545
|
+
restartSent: false,
|
|
546
|
+
});
|
|
547
|
+
} catch (error) {
|
|
548
|
+
const code = error?.code ?? null;
|
|
549
|
+
show({
|
|
550
|
+
phase: 'failed',
|
|
551
|
+
market,
|
|
552
|
+
release,
|
|
553
|
+
message: error?.message ?? 'The update failed.',
|
|
554
|
+
retry: 'install',
|
|
555
|
+
force: code === 'RELEASE_TOO_FRESH' || code === 'VERSION_UNCHANGED',
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
async function restart(current) {
|
|
561
|
+
setView({ ...current, restarting: true });
|
|
562
|
+
try {
|
|
563
|
+
const result = await marketRequest(current.market.restart, {
|
|
564
|
+
method: 'POST',
|
|
565
|
+
headers: { 'content-type': 'application/json' },
|
|
566
|
+
body: '{}',
|
|
567
|
+
}, life);
|
|
568
|
+
if (!result.ok) throw new Error(marketFailure(result, 'DSH could not restart'));
|
|
569
|
+
show({ ...current, restarting: false, restartSent: true });
|
|
570
|
+
} catch (error) {
|
|
571
|
+
show({
|
|
572
|
+
phase: 'failed',
|
|
573
|
+
market: current.market,
|
|
574
|
+
release: current.release,
|
|
575
|
+
message: error?.message ?? 'DSH could not restart.',
|
|
576
|
+
retry: 'check',
|
|
577
|
+
force: false,
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
React.useEffect(() => {
|
|
583
|
+
check(false);
|
|
584
|
+
return () => {
|
|
585
|
+
life.cancelled = true;
|
|
586
|
+
if (life.timer !== null) clearTimeout(life.timer);
|
|
587
|
+
if (life.controller !== null) life.controller.abort();
|
|
588
|
+
};
|
|
589
|
+
}, []);
|
|
590
|
+
|
|
591
|
+
const busy = view.phase === 'checking' || view.phase === 'updating' || view.restarting === true;
|
|
592
|
+
const button = (text, onClick, disabled = false, primary = false) => React.createElement('button', {
|
|
593
|
+
type: 'button',
|
|
594
|
+
disabled,
|
|
595
|
+
onClick,
|
|
596
|
+
style: primary ? primaryButtonStyle : secondaryButtonStyle,
|
|
597
|
+
}, text);
|
|
598
|
+
const code = (text) => React.createElement('code', { style: commandStyle }, text);
|
|
599
|
+
const checkAgain = button(t({ en: 'Check again', zh: '重新检查' }), () => check(true));
|
|
600
|
+
const versions = (release) => t({
|
|
601
|
+
en: `Installed v${release.installedVersion} · latest v${release.latestVersion}`,
|
|
602
|
+
zh: `已安装 v${release.installedVersion} · 最新 v${release.latestVersion}`,
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
let versionLine = null;
|
|
606
|
+
const feedback = [];
|
|
607
|
+
let actions = null;
|
|
608
|
+
|
|
609
|
+
if (view.phase === 'checking') {
|
|
610
|
+
versionLine = t({ en: 'Checking…', zh: '正在检查…' });
|
|
611
|
+
} else if (view.phase === 'unavailable') {
|
|
612
|
+
feedback.push(t({
|
|
613
|
+
en: 'DSH Market is not available on this host, so Nerd Mode cannot check for updates. Run this command in a terminal:',
|
|
614
|
+
zh: '此主机上没有 DSH Market,因此 Nerd Mode 无法检查更新。请在终端运行:',
|
|
615
|
+
}));
|
|
616
|
+
feedback.push(code(manualCommand(null)));
|
|
617
|
+
} else if (view.phase === 'failed') {
|
|
618
|
+
if (view.release !== null) versionLine = versions(view.release);
|
|
619
|
+
feedback.push(t({ en: 'The update did not finish: ', zh: '更新未完成:' }));
|
|
620
|
+
feedback.push(view.message);
|
|
621
|
+
feedback.push(t({ en: ' Install it by hand with:', zh: ' 可手动安装:' }));
|
|
622
|
+
feedback.push(code(manualCommand(view.market?.profile ?? null)));
|
|
623
|
+
actions = [button(t({ en: 'Try again', zh: '重试' }), () => {
|
|
624
|
+
if (view.retry === 'install') install(view.market, view.release, view.force === true);
|
|
625
|
+
else check(true);
|
|
626
|
+
})];
|
|
627
|
+
} else if (view.phase === 'installed') {
|
|
628
|
+
versionLine = t({ en: `Installed v${view.version}`, zh: `已安装 v${view.version}` });
|
|
629
|
+
feedback.push(view.restartSent
|
|
630
|
+
? t({ en: `DSH is restarting to load v${view.version}.`, zh: `DSH 正在重启以加载 v${view.version}。` })
|
|
631
|
+
: t({ en: `Restart DSH to use v${view.version}.`, zh: `重启 DSH 以使用 v${view.version}。` }));
|
|
632
|
+
actions = view.restartNeeded && view.market.restart !== null && view.restartSent !== true
|
|
633
|
+
? [button(
|
|
634
|
+
t({ en: view.restarting ? 'Restarting…' : 'Restart now', zh: view.restarting ? '正在重启…' : '立即重启' }),
|
|
635
|
+
() => restart(view),
|
|
636
|
+
view.restarting === true,
|
|
637
|
+
true,
|
|
638
|
+
)]
|
|
639
|
+
: [checkAgain];
|
|
640
|
+
} else if (view.release.source === 'linked') {
|
|
641
|
+
versionLine = t({ en: `Local v${view.release.installedVersion}`, zh: `本地 v${view.release.installedVersion}` });
|
|
642
|
+
actions = [button(t({ en: 'About local installs', zh: '关于本地安装' }), () => {
|
|
643
|
+
setView({ ...view, linkedNote: view.linkedNote !== true });
|
|
644
|
+
})];
|
|
645
|
+
if (view.linkedNote === true) {
|
|
646
|
+
feedback.push(t({ en: 'This copy is linked to a folder. Update it with git, not npm.', zh: '此副本链接到本地文件夹。请用 git 更新,不要用 npm。' }));
|
|
647
|
+
}
|
|
648
|
+
} else if (view.phase === 'updating') {
|
|
649
|
+
versionLine = versions(view.release);
|
|
650
|
+
feedback.push(t({ en: `Update in progress (${view.progress}).`, zh: `更新进行中(${view.progress})。` }));
|
|
651
|
+
actions = [button(t({ en: 'Updating…', zh: '正在更新…' }), () => {}, true)];
|
|
652
|
+
} else if (view.release.updateAvailable === true) {
|
|
653
|
+
versionLine = versions(view.release);
|
|
654
|
+
if (view.confirming === true) {
|
|
655
|
+
feedback.push(t({
|
|
656
|
+
en: `Install v${view.release.latestVersion} and restart DSH afterwards?`,
|
|
657
|
+
zh: `安装 v${view.release.latestVersion} 并在之后重启 DSH?`,
|
|
658
|
+
}));
|
|
659
|
+
actions = [
|
|
660
|
+
button(t({ en: 'Cancel', zh: '取消' }), () => setView({ ...view, confirming: false })),
|
|
661
|
+
button(t({ en: 'Update', zh: '更新' }), () => install(view.market, view.release, false), false, true),
|
|
662
|
+
];
|
|
663
|
+
} else {
|
|
664
|
+
actions = [button(
|
|
665
|
+
t({ en: `Update to v${view.release.latestVersion}`, zh: `更新到 v${view.release.latestVersion}` }),
|
|
666
|
+
() => setView({ ...view, confirming: true }),
|
|
667
|
+
false,
|
|
668
|
+
true,
|
|
669
|
+
)];
|
|
670
|
+
}
|
|
671
|
+
} else if (compareVersions(view.release.installedVersion, view.release.latestVersion) > 0) {
|
|
672
|
+
versionLine = t({
|
|
673
|
+
en: `Local v${view.release.installedVersion} · npm v${view.release.latestVersion}`,
|
|
674
|
+
zh: `本地 v${view.release.installedVersion} · npm v${view.release.latestVersion}`,
|
|
675
|
+
});
|
|
676
|
+
feedback.push(t({ en: 'This copy is newer than npm. No update.', zh: '此副本比 npm 上的版本更新,无需更新。' }));
|
|
677
|
+
actions = [checkAgain];
|
|
678
|
+
} else {
|
|
679
|
+
versionLine = versions(view.release);
|
|
680
|
+
actions = [checkAgain];
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
return React.createElement(
|
|
684
|
+
'div',
|
|
685
|
+
{ 'data-settings-group': 'about', style: { display: 'flex', flexDirection: 'column', gap: 8 } },
|
|
686
|
+
React.createElement(
|
|
687
|
+
'div',
|
|
688
|
+
{ style: groupStyle },
|
|
689
|
+
React.createElement(
|
|
690
|
+
'div',
|
|
691
|
+
{ style: { minWidth: 0 } },
|
|
692
|
+
React.createElement('h3', { style: groupTitleStyle }, t({ en: 'About', zh: '关于' })),
|
|
693
|
+
versionLine === null ? null : React.createElement('p', { style: groupDescriptionStyle }, versionLine),
|
|
694
|
+
),
|
|
695
|
+
actions === null ? null : React.createElement(
|
|
696
|
+
'div',
|
|
697
|
+
{ style: { alignItems: 'center', display: 'flex', flexWrap: 'wrap', gap: 8, justifyContent: 'flex-end' } },
|
|
698
|
+
...actions,
|
|
699
|
+
),
|
|
700
|
+
),
|
|
701
|
+
React.createElement('p', {
|
|
702
|
+
role: view.phase === 'failed' ? 'alert' : 'status',
|
|
703
|
+
'aria-busy': busy,
|
|
704
|
+
style: feedbackStyle,
|
|
705
|
+
}, ...feedback),
|
|
336
706
|
);
|
|
337
707
|
}
|
|
338
708
|
|
|
709
|
+
const settingsColumnStyle = {
|
|
710
|
+
boxSizing: 'border-box',
|
|
711
|
+
color: 'var(--dsw-alias-label-primary)',
|
|
712
|
+
display: 'flex',
|
|
713
|
+
flexDirection: 'column',
|
|
714
|
+
fontFamily: 'var(--dsw-font-family, inherit)',
|
|
715
|
+
gap: 16,
|
|
716
|
+
maxWidth: 720,
|
|
717
|
+
paddingBottom: 24,
|
|
718
|
+
width: '100%',
|
|
719
|
+
};
|
|
720
|
+
|
|
339
721
|
const groupStyle = {
|
|
340
722
|
alignItems: 'center',
|
|
341
723
|
background: 'var(--dsw-alias-bg-layer-1)',
|
|
@@ -408,6 +790,16 @@ window.__ModuleLoader__.load({
|
|
|
408
790
|
lineHeight: '20px',
|
|
409
791
|
margin: 0,
|
|
410
792
|
};
|
|
793
|
+
const commandStyle = {
|
|
794
|
+
background: 'var(--dsw-alias-bg-layer-2)',
|
|
795
|
+
borderRadius: 'var(--dsw-radius-sm, 6px)',
|
|
796
|
+
display: 'inline-block',
|
|
797
|
+
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
|
|
798
|
+
fontSize: 12,
|
|
799
|
+
lineHeight: '18px',
|
|
800
|
+
padding: '2px 6px',
|
|
801
|
+
wordBreak: 'break-all',
|
|
802
|
+
};
|
|
411
803
|
|
|
412
804
|
function switchStyle(active, disabled) {
|
|
413
805
|
const accent = 'var(--dsw-alias-state-business-primary)';
|
|
@@ -1164,6 +1556,24 @@ window.__ModuleLoader__.load({
|
|
|
1164
1556
|
return model;
|
|
1165
1557
|
}
|
|
1166
1558
|
|
|
1559
|
+
// The page fills the dock body so the picker and course name stay in view
|
|
1560
|
+
// while only the lecture rows scroll. The scrollport owns the side padding,
|
|
1561
|
+
// so its bar sits at the page edge rather than inside the text.
|
|
1562
|
+
const lecturesPanelStyle = { ...panelStyle, height: '100%', padding: '24px 0 0' };
|
|
1563
|
+
const lecturesHeaderStyle = { ...panelHeaderStyle, padding: '0 16px' };
|
|
1564
|
+
const lecturesMessageStyle = { ...panelMutedStyle, padding: '0 16px' };
|
|
1565
|
+
const lecturesHeadingStyle = { ...panelHeadingStyle, padding: '0 16px' };
|
|
1566
|
+
const lecturesSectionStyle = { ...panelSectionStyle, flex: 1, minHeight: 0 };
|
|
1567
|
+
const lecturesScrollStyle = {
|
|
1568
|
+
display: 'flex',
|
|
1569
|
+
flex: 1,
|
|
1570
|
+
flexDirection: 'column',
|
|
1571
|
+
gap: 10,
|
|
1572
|
+
minHeight: 0,
|
|
1573
|
+
overflowY: 'auto',
|
|
1574
|
+
padding: '0 16px 40px',
|
|
1575
|
+
};
|
|
1576
|
+
|
|
1167
1577
|
// The rows and the fold are the only elements the host cannot style for us.
|
|
1168
1578
|
const LECTURES_CSS = '.lectures-entry:focus-visible,.lectures-fold:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary, currentColor);outline-offset:2px}';
|
|
1169
1579
|
const lectureRowStyle = {
|
|
@@ -1241,14 +1651,14 @@ window.__ModuleLoader__.load({
|
|
|
1241
1651
|
function lectureTopic(group, open, onToggle, onOpen) {
|
|
1242
1652
|
const main = group.entries.filter((entry) => entry.mark !== 'archived');
|
|
1243
1653
|
const archived = group.entries.filter((entry) => entry.mark === 'archived');
|
|
1244
|
-
const
|
|
1654
|
+
const rows = [];
|
|
1245
1655
|
if (group.entries.length === 0) {
|
|
1246
|
-
|
|
1656
|
+
rows.push(React.createElement('p', { key: 'empty', style: panelMutedStyle }, 'No lectures yet.'));
|
|
1247
1657
|
} else if (main.length > 0) {
|
|
1248
|
-
|
|
1658
|
+
rows.push(React.createElement('ul', { key: 'main', style: panelListStyle }, ...main.map((entry) => lectureRow(entry, onOpen))));
|
|
1249
1659
|
}
|
|
1250
1660
|
if (archived.length > 0) {
|
|
1251
|
-
|
|
1661
|
+
rows.push(React.createElement('button', {
|
|
1252
1662
|
key: 'fold',
|
|
1253
1663
|
type: 'button',
|
|
1254
1664
|
className: 'lectures-fold',
|
|
@@ -1257,10 +1667,13 @@ window.__ModuleLoader__.load({
|
|
|
1257
1667
|
style: lectureFoldStyle,
|
|
1258
1668
|
}, `Archived lectures (${archived.length})`));
|
|
1259
1669
|
if (open) {
|
|
1260
|
-
|
|
1670
|
+
rows.push(React.createElement('ul', { key: 'archive', style: panelListStyle }, ...archived.map((entry) => lectureRow(entry, onOpen))));
|
|
1261
1671
|
}
|
|
1262
1672
|
}
|
|
1263
|
-
|
|
1673
|
+
// The Course key remounts the section on a switch, so its scrollport starts at the top.
|
|
1674
|
+
return React.createElement('section', { key: group.topic, style: lecturesSectionStyle },
|
|
1675
|
+
React.createElement('h2', { key: 'heading', style: lecturesHeadingStyle }, group.topic),
|
|
1676
|
+
React.createElement('div', { style: lecturesScrollStyle }, ...rows));
|
|
1264
1677
|
}
|
|
1265
1678
|
|
|
1266
1679
|
function LecturesPage({ useTabInfo, sessionId, workspaceFiles }) {
|
|
@@ -1288,22 +1701,21 @@ window.__ModuleLoader__.load({
|
|
|
1288
1701
|
setSelected(course);
|
|
1289
1702
|
};
|
|
1290
1703
|
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
);
|
|
1704
|
+
// No placement option: the preview opens as a tab in this pane and the
|
|
1705
|
+
// host focuses it when that lecture is already open.
|
|
1706
|
+
const open = (entry) => tab.actions.openResource(sessionFileAddress(sessionId, entry.path));
|
|
1295
1707
|
|
|
1296
1708
|
let nodes;
|
|
1297
1709
|
if (content.status === 'loading') {
|
|
1298
|
-
nodes = [React.createElement('p', { role: 'status', 'aria-busy': true, style:
|
|
1710
|
+
nodes = [React.createElement('p', { role: 'status', 'aria-busy': true, style: lecturesMessageStyle }, 'Loading the lectures…')];
|
|
1299
1711
|
} else if (content.error === null && !content.rootFound) {
|
|
1300
|
-
nodes = [React.createElement('p', { style:
|
|
1712
|
+
nodes = [React.createElement('p', { style: lecturesMessageStyle }, `No Course in ${TRACKER_ROOT}/. Run /study-plus to create one.`)];
|
|
1301
1713
|
} else {
|
|
1302
1714
|
nodes = content.error === null
|
|
1303
1715
|
? []
|
|
1304
|
-
: [React.createElement('p', { role: 'alert', style:
|
|
1716
|
+
: [React.createElement('p', { role: 'alert', style: lecturesMessageStyle }, `The lecture library could not be read: ${content.error}.`)];
|
|
1305
1717
|
const picker = topicPicker(content.courses, content.course ?? selected, choose);
|
|
1306
|
-
if (picker !== null) nodes.push(React.createElement('div', { style:
|
|
1718
|
+
if (picker !== null) nodes.push(React.createElement('div', { style: lecturesHeaderStyle }, picker));
|
|
1307
1719
|
if (content.course !== null) {
|
|
1308
1720
|
nodes.push(lectureTopic(
|
|
1309
1721
|
{ topic: content.course, entries: content.entries },
|
|
@@ -1316,7 +1728,7 @@ window.__ModuleLoader__.load({
|
|
|
1316
1728
|
return React.createElement('div', {
|
|
1317
1729
|
role: 'region',
|
|
1318
1730
|
'aria-label': LECTURES_LABEL,
|
|
1319
|
-
style:
|
|
1731
|
+
style: lecturesPanelStyle,
|
|
1320
1732
|
}, React.createElement('style', null, LECTURES_CSS), ...nodes);
|
|
1321
1733
|
}
|
|
1322
1734
|
|
|
@@ -1349,17 +1761,20 @@ window.__ModuleLoader__.load({
|
|
|
1349
1761
|
}),
|
|
1350
1762
|
}, NerdModeToggle),
|
|
1351
1763
|
);
|
|
1352
|
-
ctx.
|
|
1353
|
-
ctx.slots.
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
close,
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1764
|
+
ctx.inject(['locale'], (localeCtx) => {
|
|
1765
|
+
ctx.slots.inject(SETTINGS_SLOT, () =>
|
|
1766
|
+
ctx.slots.register({
|
|
1767
|
+
name: SETTINGS_SLOT,
|
|
1768
|
+
id: 'dsh-nerd-mode',
|
|
1769
|
+
order: 30,
|
|
1770
|
+
label: LABEL,
|
|
1771
|
+
}, ({ close }) => React.createElement(NerdModeSettingsEntry, {
|
|
1772
|
+
close,
|
|
1773
|
+
configForms: ctx.configForms,
|
|
1774
|
+
locale: localeCtx.locale,
|
|
1775
|
+
})),
|
|
1776
|
+
);
|
|
1777
|
+
});
|
|
1363
1778
|
ctx.slots.inject(VIEW_SLOT, () =>
|
|
1364
1779
|
ctx.slots.register({
|
|
1365
1780
|
name: VIEW_SLOT,
|
package/package.json
CHANGED
package/skills/study/SKILL.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: study
|
|
3
|
-
description: Teach one concept or help complete one task, using the supplied material as scope and defaulting to chat output.
|
|
3
|
+
description: "[v0.4.6] Teach one concept or help complete one task, using the supplied material as scope and defaulting to chat output."
|
|
4
4
|
argument-hint: "<concept or task>"
|
|
5
5
|
---
|
|
6
6
|
# Study
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: study-plus
|
|
3
|
-
description: Run one tracked session against a dated learning record. Load for an explicit command or after the user chooses tracked study in the upper router.
|
|
3
|
+
description: "[v0.4.6] Run one tracked session against a dated learning record. Load for an explicit command or after the user chooses tracked study in the upper router."
|
|
4
4
|
argument-hint: "<course-slug>, or empty to continue the current Course"
|
|
5
5
|
---
|
|
6
6
|
# Study Plus
|
|
@@ -52,15 +52,15 @@ For a new graph:
|
|
|
52
52
|
2. Make a concept table in prerequisite order. Each row has a one-line definition and prerequisite numbers. Keep prerequisite order separate from textbook reading order.
|
|
53
53
|
3. Use the source's book and chapter hierarchy when present. Otherwise, propose logical chapter groupings.
|
|
54
54
|
4. Show the full graph and chapter layout in the reply before the approval question, as a Markdown table. Keep the question text short, because it renders as one paragraph: ask whether to approve, edit, or rebuild the graph shown above, and state that approval starts draft generation for every concept, including any concept the learner then skips, and that drafts are not teaching. Do not put the table in the question text. Do not add another approval step.
|
|
55
|
-
5. After approval, run calibration as one
|
|
55
|
+
5. After approval, run calibration as one question-tool call with up to three items: what the learner wants to be able to do after this Course, what they have already read, studied, or used in this area in their own words, and which concepts in the approved graph they know well enough to skip, as a multi-select list. Items 1 and 2 are free text. Ask the goal and background items only when nothing already answers them: not the current request, not the opening question, not the supplied material. Reuse an opening-question answer verbatim. Derive the level. Then write `graph.md`, create `state.md` with every concept `new` and undated except the concepts selected to skip, which are `skipped`, create an empty `log.md`, and generate one draft per concept (section 3). Do not create `plan.md`.
|
|
56
56
|
|
|
57
57
|
Calibration writes `Goal`, `Background`, and `Teaching level` to `graph.md`. Derive the level from the two answers:
|
|
58
58
|
|
|
59
|
-
| Answers
|
|
60
|
-
|
|
61
|
-
| Most prerequisites are unknown, or the goal is to learn the topic from zero
|
|
59
|
+
| Answers | Level |
|
|
60
|
+
| ------------------------------------------------------------------------------------------ | ------------ |
|
|
61
|
+
| Most prerequisites are unknown, or the goal is to learn the topic from zero | `beginner` |
|
|
62
62
|
| Some prerequisites are known, and the goal is to understand the principles or pass an exam | `standard` |
|
|
63
|
-
| Most prerequisites are known, and the goal is review, reference, or use in a project
|
|
63
|
+
| Most prerequisites are known, and the goal is review, reference, or use in a project | `advanced` |
|
|
64
64
|
|
|
65
65
|
When the two answers clearly conflict, you may choose an adjacent level and state the reason in one line. When the learner declines or gives no usable answer, write `Teaching level: standard` and omit the `Goal` and `Background` lines. Each selected concept becomes `skipped`, with no date and no passes.
|
|
66
66
|
|
|
@@ -136,7 +136,7 @@ If the row was already `in_process`, say that the last session stopped before th
|
|
|
136
136
|
|
|
137
137
|
**Ask unseen questions.** Use the explicit count or type preference in the current `/study-plus` request when present. Otherwise, use the profile defaults in the top-level Nerd Mode instructions. Plan the configured number of unseen question slots, from 0 to 10. A hint, re-ask, or similar question stays in its current slot and does not add a slot. For each slot, write down the expected answer and criteria for a good answer. Keep both private. A draft never contains them. The criteria test the conclusion and whether each step taken is valid, not the route you had in mind. Ask one question at a time, then wait. One call asks one question. Outside `study-plus`, ignore these preferences.
|
|
138
138
|
|
|
139
|
-
For `short-answer`, ask the question in the conversation. For `multiple-choice`, ask it as one
|
|
139
|
+
For `short-answer`, ask the question in the conversation. For `multiple-choice`, ask it as one question-tool call with four options and exactly one correct answer; choosing it is sufficient. Give the labels alone, with no description, and shuffle the four positions for each slot. A host without a question tool gets the same four options as a numbered text list. A typed answer that names or repeats one option counts as choosing it. Any other typed answer is graded like a short answer. For `mixed`, choose short-answer or multiple-choice independently at random for each planned slot. Do not add question-type details to the session log. When the configured count is zero, skip unseen questions. A passed Teach-back alone is sufficient to pass. With one or more slots, require a passed Teach-back and `ceil(2 × slot count / 3)` correct slots.
|
|
140
140
|
|
|
141
141
|
A question exists to find the gap, not to stop the session. Nothing in this step blocks the learner.
|
|
142
142
|
|
|
@@ -13,8 +13,8 @@
|
|
|
13
13
|
if (!panels.length) return;
|
|
14
14
|
|
|
15
15
|
var L = book.lang === 'en'
|
|
16
|
-
? { search: 'Search the book', toc: 'Contents', none: 'No match', hits: ' hits', chapter: 'Chapter',
|
|
17
|
-
: { search: '搜索全书', toc: '目录', none: '没有匹配', hits: ' 处', chapter: '第',
|
|
16
|
+
? { search: 'Search the book', toc: 'Contents', none: 'No match', hits: ' hits', chapter: 'Chapter', sections: 'sections', menu: 'Chapters', draft: 'Draft' }
|
|
17
|
+
: { search: '搜索全书', toc: '目录', none: '没有匹配', hits: ' 处', chapter: '第', sections: '节', menu: '章节', draft: '草稿' };
|
|
18
18
|
|
|
19
19
|
var layout = document.querySelector('.am-doc-layout');
|
|
20
20
|
var body = document.querySelector('.am-doc-body');
|
|
@@ -33,10 +33,8 @@
|
|
|
33
33
|
: L.chapter + chapter.n + '章 ' + chapter.name;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
function
|
|
37
|
-
return
|
|
38
|
-
? L.section + ' ' + unit.section
|
|
39
|
-
: L.section + unit.section + '节';
|
|
36
|
+
function sectionNumber(unit) {
|
|
37
|
+
return unit.chapter + '.' + unit.section;
|
|
40
38
|
}
|
|
41
39
|
|
|
42
40
|
function chapterCount(chapter) {
|
|
@@ -46,9 +44,7 @@
|
|
|
46
44
|
}
|
|
47
45
|
|
|
48
46
|
function sectionTitle(unit) {
|
|
49
|
-
return
|
|
50
|
-
? L.section + ' ' + unit.section + '. ' + unit.title
|
|
51
|
-
: L.section + unit.section + '节 ' + unit.title;
|
|
47
|
+
return sectionNumber(unit) + ' ' + unit.title;
|
|
52
48
|
}
|
|
53
49
|
|
|
54
50
|
var style = document.createElement('style');
|
|
@@ -70,6 +66,8 @@
|
|
|
70
66
|
'.am-doc-body .am-md h3 { font-size: 18px; font-weight: 650; margin-top: 28px; }',
|
|
71
67
|
'.am-doc-body .am-md h3:first-child { margin-top: 0; }',
|
|
72
68
|
'.am-doc-body .am-md h4 { font-size: 16px; font-weight: 600; margin-top: 20px; }',
|
|
69
|
+
// am's overflow-x:auto makes overflow-y auto too; a 1px overshoot draws scrollbar arrows on Windows.
|
|
70
|
+
'.am-math-block { overflow-y: hidden; }',
|
|
73
71
|
'.bk-context { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; margin: 0 0 8px; font-size: 13px; }',
|
|
74
72
|
'.bk-context-chapter { color: var(--ink-2); font-weight: 600; }',
|
|
75
73
|
'.bk-context-section { color: var(--ink-3); white-space: nowrap; }',
|
|
@@ -151,7 +149,7 @@
|
|
|
151
149
|
chapter.textContent = chapterTitle(g.chapter);
|
|
152
150
|
var section = document.createElement('span');
|
|
153
151
|
section.className = 'bk-context-section';
|
|
154
|
-
section.innerHTML = esc(
|
|
152
|
+
section.innerHTML = esc(sectionNumber(u)) + draftTag(u);
|
|
155
153
|
context.appendChild(chapter);
|
|
156
154
|
context.appendChild(section);
|
|
157
155
|
var heading = panel.querySelector('.am-panel-head');
|
|
@@ -82,9 +82,13 @@ function build() {
|
|
|
82
82
|
...(book.goal ? [`subtitle: ${JSON.stringify(book.goal)}`] : []),
|
|
83
83
|
'---',
|
|
84
84
|
'',
|
|
85
|
-
book.units.map((unit) => `## ${unit.section}
|
|
85
|
+
book.units.map((unit) => `## ${unit.chapter}.${unit.section} ${unit.title}\n\n${unit.body}`).join('\n\n'),
|
|
86
86
|
'',
|
|
87
|
-
].join('\n')
|
|
87
|
+
].join('\n')
|
|
88
|
+
// am reads $ and $$ math only: a LaTeX display delimiter on its own line
|
|
89
|
+
// would reach the page as a literal bracket, so turn it into a $$ block.
|
|
90
|
+
.replace(/^ {0,3}\\\[\s*$/gm, () => '$$')
|
|
91
|
+
.replace(/^ {0,3}\\\]\s*$/gm, () => '$$');
|
|
88
92
|
const outPath = join(dir, 'book.html');
|
|
89
93
|
const backupPath = join(dir, 'book.html.bak');
|
|
90
94
|
const guardPath = (path, what) => {
|
|
@@ -123,7 +127,9 @@ function build() {
|
|
|
123
127
|
units: book.units.map(({ concept, title, chapter, section, index, status }) => ({ concept, title, chapter, section, index, status })),
|
|
124
128
|
};
|
|
125
129
|
const json = JSON.stringify(payload).replace(/</g, '\\u003c');
|
|
126
|
-
|
|
130
|
+
// HTML parsing turns CRLF into LF before the browser checks the CSP hash,
|
|
131
|
+
// so a Windows checkout would otherwise be blocked as the wrong hash.
|
|
132
|
+
const client = readFileSync(CLIENT, 'utf8').replace(/\r\n?/g, '\n');
|
|
127
133
|
const html = readFileSync(tmpPath, 'utf8');
|
|
128
134
|
const scriptText = `\n${client}\n`;
|
|
129
135
|
const hash = createHash('sha256').update(scriptText, 'utf8').digest('base64');
|