cotutor 0.3.0 → 0.4.1

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.
Files changed (209) hide show
  1. package/LICENSE +9 -7
  2. package/NOTICE +3 -3
  3. package/README.md +65 -18
  4. package/avatars/chinese.webp +0 -0
  5. package/avatars/english.webp +0 -0
  6. package/avatars/math.webp +0 -0
  7. package/bin/cotutor.js +13 -3
  8. package/cards/photo/card.css +10 -3
  9. package/cards/photo/card.md +2 -2
  10. package/cards/tutor/card.css +13 -0
  11. package/cards/tutor/card.md +2 -1
  12. package/cards//346/235/277/344/271/246/346/200/216/344/271/210/345/206/231.md +1 -1
  13. package/dist/app/activity-eval.js +3 -2
  14. package/dist/app/cli/activities.d.ts +8 -1
  15. package/dist/app/cli/activities.js +42 -4
  16. package/dist/app/cli/cert.d.ts +11 -0
  17. package/dist/app/cli/cert.js +36 -9
  18. package/dist/app/cli/chalk/browser.d.ts +2 -2
  19. package/dist/app/cli/chalk/browser.js +22 -7
  20. package/dist/app/cli/chalk/main.js +3 -2
  21. package/dist/app/cli/doctor.d.ts +11 -5
  22. package/dist/app/cli/doctor.js +156 -181
  23. package/dist/app/cli/firewall.d.ts +24 -0
  24. package/dist/app/cli/firewall.js +79 -0
  25. package/dist/app/cli/init.js +8 -5
  26. package/dist/app/cli/main.js +379 -27
  27. package/dist/app/cli/migrate.d.ts +2 -11
  28. package/dist/app/cli/migrate.js +1 -59
  29. package/dist/app/cli/port-holder.d.ts +16 -0
  30. package/dist/app/cli/port-holder.js +34 -0
  31. package/dist/app/cli/port.d.ts +37 -0
  32. package/dist/app/cli/port.js +116 -0
  33. package/dist/app/cli/sandbox.d.ts +25 -0
  34. package/dist/app/cli/sandbox.js +166 -0
  35. package/dist/app/cli/serve-log.d.ts +13 -0
  36. package/dist/app/cli/serve-log.js +90 -0
  37. package/dist/app/cli/serve.d.ts +18 -4
  38. package/dist/app/cli/serve.js +78 -8
  39. package/dist/app/cli/service-windows.d.ts +50 -0
  40. package/dist/app/cli/service-windows.js +117 -0
  41. package/dist/app/cli/service.d.ts +152 -0
  42. package/dist/app/cli/service.js +443 -0
  43. package/dist/app/cli/skeleton.d.ts +1 -1
  44. package/dist/app/cli/skeleton.js +13 -13
  45. package/dist/app/cli/skills.d.ts +5 -1
  46. package/dist/app/cli/skills.js +14 -4
  47. package/dist/app/cli/tray-icons.d.ts +11 -0
  48. package/dist/app/cli/tray-icons.js +87 -0
  49. package/dist/app/cli/tray-ids.d.ts +6 -0
  50. package/dist/app/cli/tray-ids.js +6 -0
  51. package/dist/app/cli/tray-mac.d.ts +8 -0
  52. package/dist/app/cli/tray-mac.js +140 -0
  53. package/dist/app/cli/tray-run.d.ts +1 -0
  54. package/dist/app/cli/tray-run.js +42 -0
  55. package/dist/app/cli/tray-windows.d.ts +5 -0
  56. package/dist/app/cli/tray-windows.js +166 -0
  57. package/dist/app/cli/tray.d.ts +236 -0
  58. package/dist/app/cli/tray.js +670 -0
  59. package/dist/app/cli/usage.d.ts +1 -1
  60. package/dist/app/cli/usage.js +11 -6
  61. package/dist/app/dictation/route.js +3 -2
  62. package/dist/app/http/app.d.ts +5 -10
  63. package/dist/app/http/app.js +47 -98
  64. package/dist/app/http/avatars.d.ts +17 -0
  65. package/dist/app/http/avatars.js +35 -0
  66. package/dist/app/http/dev-tutor-files.js +1 -1
  67. package/dist/app/http/parent-home.d.ts +14 -0
  68. package/dist/app/http/parent-home.js +69 -6
  69. package/dist/app/http/parent-look.d.ts +2 -0
  70. package/dist/app/http/parent-look.js +151 -0
  71. package/dist/app/http/raw-view.js +3 -3
  72. package/dist/app/http/voice-preview.d.ts +4 -0
  73. package/dist/app/http/voice-preview.js +34 -0
  74. package/dist/app/machine/bailian.d.ts +0 -2
  75. package/dist/app/machine/bailian.js +5 -6
  76. package/dist/app/machine/update.d.ts +25 -0
  77. package/dist/app/machine/update.js +88 -0
  78. package/dist/app/mock/mock-live.js +1 -1
  79. package/dist/app/mock/mock.d.ts +4 -2
  80. package/dist/app/mock/mock.js +20 -12
  81. package/dist/app/replay.js +4 -4
  82. package/dist/app/skills/home-doc.js +1 -1
  83. package/dist/app/workspace.d.ts +9 -4
  84. package/dist/app/workspace.js +33 -10
  85. package/dist/app/ws/route.d.ts +8 -2
  86. package/dist/app/ws/route.js +7 -3
  87. package/dist/board/parse.d.ts +0 -2
  88. package/dist/board/parse.js +0 -6
  89. package/dist/board/presentation.d.ts +9 -15
  90. package/dist/board/presentation.js +8 -10
  91. package/dist/brain/api-tools.js +2 -1
  92. package/dist/brain/api.d.ts +12 -17
  93. package/dist/brain/api.js +16 -33
  94. package/dist/brain/index.d.ts +23 -71
  95. package/dist/brain/index.js +6 -69
  96. package/dist/brain/log.d.ts +5 -0
  97. package/dist/brain/log.js +22 -0
  98. package/dist/brain/runtime.d.ts +30 -0
  99. package/dist/brain/runtime.js +23 -0
  100. package/dist/brain/{cli/stream-json.d.ts → stream-json.d.ts} +2 -4
  101. package/dist/brain/{cli/stream-json.js → stream-json.js} +0 -4
  102. package/dist/brain/stream.d.ts +5 -5
  103. package/dist/brain/stream.js +7 -7
  104. package/dist/brain/transcript.d.ts +3 -3
  105. package/dist/brain/transcript.js +6 -6
  106. package/dist/brain/{cli/types.d.ts → types.d.ts} +3 -48
  107. package/dist/brain/types.js +14 -0
  108. package/dist/chalk/glyphs/skeleton.d.ts +3 -3
  109. package/dist/chalk/glyphs/skeleton.js +1 -1
  110. package/dist/lib/index.d.ts +1 -1
  111. package/dist/lib/index.js +1 -1
  112. package/dist/memory/diary.js +3 -3
  113. package/dist/realtime/bailian.d.ts +1 -1
  114. package/dist/realtime/bailian.js +3 -2
  115. package/dist/realtime/preview.js +1 -1
  116. package/dist/realtime/voices.d.ts +2 -0
  117. package/dist/realtime/voices.js +57 -57
  118. package/dist/runtime/activities.d.ts +13 -1
  119. package/dist/runtime/activities.js +28 -2
  120. package/dist/runtime/activity-state.js +2 -2
  121. package/dist/runtime/agent-file.d.ts +3 -3
  122. package/dist/runtime/agent-file.js +3 -3
  123. package/dist/runtime/context/tutor-rules.d.ts +2 -2
  124. package/dist/runtime/context/tutor-rules.js +3 -3
  125. package/dist/runtime/home/home.d.ts +12 -1
  126. package/dist/runtime/home/home.js +8 -1
  127. package/dist/runtime/home/store.d.ts +1 -1
  128. package/dist/runtime/home/store.js +36 -21
  129. package/dist/runtime/journal/mix.d.ts +15 -6
  130. package/dist/runtime/journal/mix.js +60 -19
  131. package/dist/runtime/journal/recorder.d.ts +58 -13
  132. package/dist/runtime/journal/recorder.js +106 -16
  133. package/dist/runtime/journal/store.d.ts +4 -4
  134. package/dist/runtime/journal/store.js +6 -6
  135. package/dist/runtime/kid-input.js +2 -3
  136. package/dist/runtime/live/call.d.ts +12 -0
  137. package/dist/runtime/live/call.js +51 -18
  138. package/dist/runtime/live/turns.js +1 -1
  139. package/dist/runtime/runner.d.ts +9 -55
  140. package/dist/runtime/runner.js +52 -173
  141. package/dist/scenes/paint/painter.js +4 -3
  142. package/dist/schema/config.d.ts +43 -22
  143. package/dist/schema/config.js +45 -47
  144. package/dist/schema/conversation.d.ts +6 -0
  145. package/dist/schema/home.d.ts +11 -1
  146. package/dist/schema/home.js +7 -2
  147. package/dist/schema/workspace.d.ts +27 -0
  148. package/dist/schema/workspace.js +28 -0
  149. package/dist/shared/default-port.d.ts +2 -0
  150. package/dist/shared/default-port.js +2 -0
  151. package/dist/shared/qianwen.d.ts +14 -10
  152. package/dist/shared/qianwen.js +26 -22
  153. package/dist/shared/site-url.d.ts +2 -0
  154. package/dist/shared/site-url.js +5 -0
  155. package/dist/shared/upgrade.d.ts +9 -0
  156. package/dist/shared/upgrade.js +23 -0
  157. package/dist/ui/dev/dev-page.js +25 -252
  158. package/dist/ui/dev/dev-theme.d.ts +1 -1
  159. package/dist/ui/dev/dev-theme.js +1 -1
  160. package/dist/ui/dev/voice-gallery-page.js +1 -1
  161. package/dist/ui/kid/board-mark.d.ts +2 -2
  162. package/dist/ui/kid/board-mark.js +2 -2
  163. package/dist/ui/kid/home-tiles.d.ts +14 -0
  164. package/dist/ui/kid/home-tiles.js +44 -0
  165. package/dist/ui/kid/kid-page.js +68 -103
  166. package/dist/ui/kid/voice-ui.d.ts +1 -1
  167. package/dist/ui/kid/voice-ui.js +1 -1
  168. package/dist/ui/kid/word-emoji.js +5 -3
  169. package/dist/ui/parent/parent-home-page.js +337 -7
  170. package/dist/ui/parent/parent-page.js +6 -2
  171. package/dist/ui/parent/parent-settings-page.d.ts +1 -0
  172. package/dist/ui/parent/parent-settings-page.js +133 -0
  173. package/dist/ui/{dev → parent}/qr-page.d.ts +2 -2
  174. package/dist/ui/{dev → parent}/qr-page.js +19 -14
  175. package/dist/ui/shared/brand.d.ts +3 -1
  176. package/dist/ui/shared/brand.js +3 -1
  177. package/grades/grade-2/README.md +1 -1
  178. package/grades/grade-2/VALIDATION.md +1 -1
  179. package/grades/grade-2/agents/chinese-tutor/activities//345/244/215/344/271/240.md +1 -1
  180. package/grades/grade-2/agents/chinese-tutor/activities//346/{211/271/346/224/271.md → 243/200/346/237/245/344/275/234/344/270/232.md} +4 -4
  181. package/grades/grade-2/agents/chinese-tutor/activities//346/{211/271/346/224/271. → 243/200/346/237/245/344/275/234/344/270/232.}/344/276/213/345/255/220.md +2 -2
  182. package/grades/grade-2/agents/chinese-tutor/activities//351/242/204/344/271/240.md +1 -1
  183. package/grades/grade-2/agents/english-tutor/activities//345/244/215/344/271/240.md +1 -1
  184. package/grades/grade-2/agents/english-tutor/activities//346/{211/271/346/224/271.md → 243/200/346/237/245/344/275/234/344/270/232.md} +4 -4
  185. package/grades/grade-2/agents/english-tutor/activities//346/{211/271/346/224/271. → 243/200/346/237/245/344/275/234/344/270/232.}/344/276/213/345/255/220.md +2 -2
  186. package/grades/grade-2/agents/english-tutor/activities//351/242/204/344/271/240.md +1 -1
  187. package/grades/grade-2/agents/math-tutor/activities//345/244/215/344/271/240.md +1 -1
  188. package/grades/grade-2/agents/math-tutor/activities//346/{211/271/346/224/271.md → 243/200/346/237/245/344/275/234/344/270/232.md} +4 -4
  189. package/grades/grade-2/agents/math-tutor/activities//346/{211/271/346/224/271. → 243/200/346/237/245/344/275/234/344/270/232.}/344/276/213/345/255/220.md +2 -2
  190. package/grades/grade-2/agents/math-tutor/activities//351/242/204/344/271/240.md +1 -1
  191. package/package.json +6 -2
  192. package/skills/cotutor-analyze/references//345/221/275/344/273/244/344/270/216/346/226/207/344/273/266.md +2 -2
  193. package/skills/cotutor-board/references/photo.md +2 -2
  194. package/skills/cotutor-home/SKILL.md +1 -0
  195. package/skills/cotutor-home/references/tutor.md +2 -1
  196. package/skills/cotutor-home/references//351/246/226/351/241/265/350/257/255/346/263/225.md +2 -1
  197. package/skills/cotutor-tune/references//345/255/227/346/256/265.md +6 -3
  198. package/skills/cotutor-tutor/SKILL.md +5 -13
  199. package/dist/brain/cli/adapters.d.ts +0 -60
  200. package/dist/brain/cli/adapters.js +0 -91
  201. package/dist/brain/cli/qwen.d.ts +0 -11
  202. package/dist/brain/cli/qwen.js +0 -141
  203. package/dist/brain/cli/run-plan.d.ts +0 -59
  204. package/dist/brain/cli/run-plan.js +0 -71
  205. package/dist/brain/cli/run.d.ts +0 -35
  206. package/dist/brain/cli/run.js +0 -256
  207. package/dist/brain/cli/types.js +0 -3
  208. package/dist/brain/cli/warm.d.ts +0 -53
  209. package/dist/brain/cli/warm.js +0 -103
@@ -8,8 +8,10 @@
8
8
  * PUT /api/parent/home/tutors/<老师>/dictation {words: [{chars, say?}]} 这周的词;[] = 删掉
9
9
  * PUT /api/parent/home/order {tutors: [老师…]} 老师的先后(排第一的进首页时在最前)
10
10
  * PATCH /api/parent/settings {tutors?: {<老师>: {extras?: [..] | null, hidden?}}, talk?: {note?, minutes?}}
11
+ * GET /api/parent/qr 孩子端与家长端地址的二维码(各有主机名一张、局域网 IP 一张)、是不是 HTTPS、孩子的设备连过几台、上次什么时候;服务没在听(测试走 route)→ {ready: false}
11
12
  */
12
13
  import { type DictationLang, type TutorButton } from '../../board/cards/index.ts';
14
+ import { type HomeTileId } from '../../schema/index.ts';
13
15
  import type { AppContext, RouteResult } from './app.ts';
14
16
  export interface ParentHomeTutor {
15
17
  name: string;
@@ -20,6 +22,13 @@ export interface ParentHomeTutor {
20
22
  /** 卡上的常驻入口(按学科补过缺省的);extrasSet = cotutor.json 里写的(没写 = null,走缺省) */
21
23
  extras: string[];
22
24
  extrasSet: string[] | null;
25
+ /** 常驻的方砖(HOME_TILES 的先后):家里有那个 activity 文件的才列;on = 没关掉,usable = 文件检查过得去(写坏了孩子端不出) */
26
+ tiles: {
27
+ id: HomeTileId;
28
+ label: string;
29
+ on: boolean;
30
+ usable: boolean;
31
+ }[];
23
32
  /** 首页里这位老师的按钮(文件里的,带讲法与留到) */
24
33
  buttons: TutorButton[];
25
34
  /** 这周的词:写了这位老师的听写卡;没有 = null */
@@ -32,6 +41,11 @@ export interface ParentHomeTutor {
32
41
  lang: DictationLang;
33
42
  /** 学科色(src/ui/shared/tutor-color.ts) */
34
43
  color: string;
44
+ /** 「样子与声音」那一行:说话的声音、点读的声音叫什么(点开见 ./parent-look.ts);点读没配 = null */
45
+ voices: {
46
+ mouth: string;
47
+ card: string | null;
48
+ };
35
49
  }
36
50
  export declare function parentHome(ctx: AppContext): Promise<Record<string, unknown>>;
37
51
  export declare function parentHomeRoute(method: string, p: string, ctx: AppContext, body: unknown): Promise<RouteResult | null>;
@@ -8,19 +8,33 @@
8
8
  * PUT /api/parent/home/tutors/<老师>/dictation {words: [{chars, say?}]} 这周的词;[] = 删掉
9
9
  * PUT /api/parent/home/order {tutors: [老师…]} 老师的先后(排第一的进首页时在最前)
10
10
  * PATCH /api/parent/settings {tutors?: {<老师>: {extras?: [..] | null, hidden?}}, talk?: {note?, minutes?}}
11
+ * GET /api/parent/qr 孩子端与家长端地址的二维码(各有主机名一张、局域网 IP 一张)、是不是 HTTPS、孩子的设备连过几台、上次什么时候;服务没在听(测试走 route)→ {ready: false}
11
12
  */
12
13
  import { DICTATION_EN_MAX, DICTATION_WORD_MAX, DICTATION_WORDS_MAX, TUTOR_BUTTONS_MAX, TutorButtonSchema, dictationWordLang } from "../../board/cards/index.js";
14
+ import { existsSync } from 'node:fs';
13
15
  import { localDate } from "../../shared/time.js";
14
16
  import { orderTutors, setTutorButtons, setTutorDictation } from "../../runtime/home/edit.js";
15
- import { AGENT_NAME_RE, TUTOR_EXTRAS, subjectLang, tutorExtras } from "../../schema/index.js";
16
- import { editPublished, readPublished } from "../../runtime/home/store.js";
17
+ import { AGENT_NAME_RE, HOME_TILES, HOME_TILE_IDS, TUTOR_EXTRAS, cardVoice, mouthVoice, subjectLang, tutorExtras, tutorTiles } from "../../schema/index.js";
18
+ import { findActivityFile } from "../../runtime/activities.js";
19
+ import { REALTIME_VOICES } from "../../realtime/voices.js";
20
+ import { TTS_VOICES } from "../../media/tts.js";
21
+ import { editPublished, readPublished, usableActivity } from "../../runtime/home/store.js";
17
22
  import { patchConfig } from "../workspace.js";
18
23
  import { tutorColors } from "../../ui/shared/tutor-color.js";
19
24
  import { wordEmoji } from "../../ui/kid/word-emoji.js";
25
+ import { shownAvatar } from "./avatars.js";
26
+ import { qrNotes, qrSvg } from "../../ui/shared/qr-svg.js";
27
+ import { readDevices } from "../../runtime/journal/kid-log.js";
20
28
  const isObj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
21
29
  const bad = (message) => ({ status: 400, json: { error: 'bad_request', message } });
22
30
  /** 家长端列的老师:有脸的(-tutor 结尾)、开着的;藏着的也列(家长要能把他放回首页) */
23
31
  const faceTutors = (c) => Object.entries(c.tutors).filter(([n, t]) => n.endsWith('-tutor') && t.enabled).map(([n]) => n);
32
+ /** 音色 id → 官方的中文名(没在表里的照写 id) */
33
+ function voiceNames(c, name) {
34
+ const mouth = mouthVoice(c.live, name);
35
+ const card = cardVoice(c.tutors, name);
36
+ return { mouth: REALTIME_VOICES.find((v) => v.voice === mouth)?.name ?? mouth, card: card ? (TTS_VOICES.find((v) => v.voice === card)?.name ?? card) : null };
37
+ }
24
38
  export async function parentHome(ctx) {
25
39
  const c = ctx.ws.config;
26
40
  const { home } = await readPublished(ctx.ws);
@@ -30,6 +44,19 @@ export async function parentHome(ctx) {
30
44
  // 先后 = 首页里老师卡的先后,没写卡的照 cotutor.json 的顺序排在后面
31
45
  const written = cards.filter((x) => x.kind === 'tutor').map((x) => String(x.props.tutor)).filter((n, i, a) => names.includes(n) && a.indexOf(n) === i);
32
46
  const order = [...written, ...names.filter((n) => !written.includes(n))];
47
+ const tilesOf = async (name) => {
48
+ const on = tutorTiles(c.tutors[name]);
49
+ const out = [];
50
+ for (const t of HOME_TILES) {
51
+ if (!existsSync(findActivityFile(ctx.ws.root, name, t.activity)))
52
+ continue;
53
+ out.push({ id: t.id, label: t.activity, on: on.includes(t.id), usable: Boolean(await usableActivity(ctx.ws, name, t.activity)) });
54
+ }
55
+ return out;
56
+ };
57
+ const tiles = new Map();
58
+ for (const name of order)
59
+ tiles.set(name, await tilesOf(name));
33
60
  const tutors = order.map((name) => {
34
61
  const t = c.tutors[name];
35
62
  const card = cards.find((x) => x.kind === 'tutor' && x.props.tutor === name);
@@ -37,15 +64,17 @@ export async function parentHome(ctx) {
37
64
  return {
38
65
  name,
39
66
  display: t.display,
40
- avatar: t.avatar ?? null,
67
+ avatar: shownAvatar(t),
41
68
  subject: t.subject ?? null,
42
69
  hidden: t.hidden,
43
70
  extras: tutorExtras(t, c.talk.enabled),
44
71
  extrasSet: t.extras ?? null,
72
+ tiles: tiles.get(name) ?? [],
45
73
  buttons: (card?.props.buttons ?? []),
46
74
  dictation: dict ? (dict.props.words ?? []) : null,
47
75
  lang: subjectLang(t.subject),
48
76
  color: colors[name],
77
+ voices: voiceNames(c, name),
49
78
  };
50
79
  });
51
80
  return {
@@ -107,7 +136,7 @@ async function withEmoji(ctx, name, words) {
107
136
  had.set(w.chars, w.emoji);
108
137
  const ask = [...new Set(words.map((w) => w.chars).filter((w) => !had.has(w)))];
109
138
  const fake = process.env.COTUTOR_EMOJI_ENDPOINT;
110
- const got = ask.length ? await wordEmoji(ask, fake ? { endpoint: fake, env: { DASHSCOPE_API_KEY: 'sk-fake' } } : {}) : {};
139
+ const got = ask.length ? await wordEmoji(ask, fake ? { endpoint: fake } : {}) : {};
111
140
  return words.map((w) => { const e = had.get(w.chars) ?? got[w.chars]; return e ? { ...w, emoji: e } : w; });
112
141
  }
113
142
  /** 发布的结果:有「要改」的不发,把检查说的回给页面 */
@@ -116,9 +145,25 @@ function published(r) {
116
145
  return { status: 400, json: { error: 'home_check', message: r.check.issues.filter((i) => i.level === 'fix').map((i) => i.text).join(';') || '首页检查没过' } };
117
146
  return { status: 200, json: { ok: true, home: r?.home ? { id: r.home.id, publishedAt: r.home.publishedAt } : null, changed: Boolean(r) } };
118
147
  }
148
+ /** 首页上「扫码打开」那块:孩子的平板一张(孩子端 /)、家长自己的手机一张(家长端 /parent);编什么地址由服务端定(同 /parent/qr,不看请求的 Host);连过的设备拿来定这块缺省开着还是收着 */
149
+ async function kidQr(ctx) {
150
+ const info = ctx.listen?.() ?? null;
151
+ if (!info)
152
+ return { ready: false };
153
+ const devices = Object.values(await readDevices(ctx.ws));
154
+ const last = devices.map((d) => d.last).sort().at(-1) ?? null;
155
+ const parent = { url: `${info.name}parent`, ip: info.ip ? `${info.ip}parent` : null };
156
+ return {
157
+ ready: true, https: info.https, url: info.name, svg: qrSvg(info.name), ip: info.ip, svgIp: info.ip ? qrSvg(info.ip) : null,
158
+ parent: { ...parent, svg: qrSvg(parent.url), svgIp: parent.ip ? qrSvg(parent.ip) : null },
159
+ notes: qrNotes(info.https).slice(1), devices: devices.length, last,
160
+ };
161
+ }
119
162
  export async function parentHomeRoute(method, p, ctx, body) {
120
163
  if (p === '/api/parent/home' && method === 'GET')
121
164
  return { status: 200, json: await parentHome(ctx) };
165
+ if (p === '/api/parent/qr' && method === 'GET')
166
+ return { status: 200, json: await kidQr(ctx) };
122
167
  const names = faceTutors(ctx.ws.config);
123
168
  const tb = /^\/api\/parent\/home\/tutors\/([^/]+)\/(buttons|dictation)$/.exec(p);
124
169
  if (tb) {
@@ -150,6 +195,9 @@ export async function parentHomeRoute(method, p, ctx, body) {
150
195
  return published(await editPublished(ctx.ws, (md) => orderTutors(md, list), ctx.now()));
151
196
  }
152
197
  if (p === '/api/parent/settings') {
198
+ // 设置页(/parent/settings)读的:孩子的平板上按住说话那一栏放哪边(kid.hand;缺省 right = 栏在左边)
199
+ if (method === 'GET')
200
+ return { status: 200, json: { title: ctx.ws.config.title, hand: ctx.ws.config.kid.hand ?? 'right' } };
153
201
  if (method !== 'PATCH')
154
202
  return { status: 405, json: { error: 'method_not_allowed' } };
155
203
  if (!isObj(body))
@@ -157,7 +205,7 @@ export async function parentHomeRoute(method, p, ctx, body) {
157
205
  const patch = {};
158
206
  if (body.tutors !== undefined) {
159
207
  if (!isObj(body.tutors))
160
- return bad('tutors 要 {<老师>: {extras?, hidden?}}');
208
+ return bad('tutors 要 {<老师>: {extras?, hidden?, tiles?}}');
161
209
  const tp = {};
162
210
  for (const [name, v] of Object.entries(body.tutors)) {
163
211
  if (!names.includes(name) || !AGENT_NAME_RE.test(name) || !isObj(v))
@@ -173,6 +221,15 @@ export async function parentHomeRoute(method, p, ctx, body) {
173
221
  return bad('hidden 要 true / false');
174
222
  one.hidden = v.hidden;
175
223
  }
224
+ // 方砖:{preview?: true/false, …};cotutor.json 只记关掉的(开 = 删掉那一项)
225
+ if (v.tiles !== undefined) {
226
+ if (!isObj(v.tiles) || Object.entries(v.tiles).some(([k, x]) => !HOME_TILE_IDS.includes(k) || typeof x !== 'boolean'))
227
+ return bad(`tiles 要 {${HOME_TILE_IDS.join(' / ')}: true / false}`);
228
+ const off = { ...(ctx.ws.config.tutors[name].tiles ?? {}), ...Object.fromEntries(Object.entries(v.tiles).map(([k, x]) => [k, x ? undefined : false])) };
229
+ const left = Object.entries(off).filter(([, x]) => x === false);
230
+ // 全开着 = 整个 tiles 删掉(null);不然只留关掉的那几项(开了的写 null 删掉)
231
+ one.tiles = left.length ? Object.fromEntries(Object.keys(off).map((k) => [k, off[k] === false ? false : null])) : null;
232
+ }
176
233
  tp[name] = one;
177
234
  }
178
235
  patch.tutors = tp;
@@ -190,8 +247,14 @@ export async function parentHomeRoute(method, p, ctx, body) {
190
247
  t.minutes = body.talk.minutes;
191
248
  patch.talk = t;
192
249
  }
250
+ // 孩子的平板(设置页):只收 kid.hand;缺省的 right 不写进文件
251
+ if (body.kid !== undefined) {
252
+ if (!isObj(body.kid) || Object.keys(body.kid).some((k) => k !== 'hand') || (body.kid.hand !== 'left' && body.kid.hand !== 'right'))
253
+ return bad('kid 只收 {hand: left / right}');
254
+ patch.kid = { hand: body.kid.hand === 'left' ? 'left' : null };
255
+ }
193
256
  try {
194
- await patchConfig(ctx.ws, patch, { also: ['talk'] });
257
+ await patchConfig(ctx.ws, patch, { also: ['talk', 'kid'] });
195
258
  }
196
259
  catch (err) {
197
260
  return bad(err instanceof Error ? err.message : String(err));
@@ -0,0 +1,2 @@
1
+ import type { AppContext, RouteResult } from './app.ts';
2
+ export declare function parentLookRoute(method: string, p: string, url: URL, ctx: AppContext, body: unknown): Promise<RouteResult | null>;
@@ -0,0 +1,151 @@
1
+ /**
2
+ * 家长端「样子与声音」(家长端首页点一位老师的那一行):头像、说话的声音(嘴,全部 56 个)、点读的声音(卡的配音,全部 597 个),点了就存。
3
+ * 声音全给,页面上点「换一个」开弹窗挑:搜、按男女 / 口音 / 中英 / 年纪 / 场景筛;现在用的排最前、出厂的那个标出来。头像与声音只在这里改(工作台不重复);别的头像直接写 cotutor.json。
4
+ *
5
+ * GET /api/parent/tutors/<老师>/look 现在用的与可选的(头像、嘴、卡的配音)
6
+ * PUT /api/parent/tutors/<老师>/look {avatar?: string | null, mouth?: string, voice?: string};回 GET 那份
7
+ * GET /api/parent/tutors/<老师>/look/avatar?k=factory|image 选项里那两张图:包里的出厂头像、workspace 里 figshot 画的
8
+ * GET /api/parent/tutors/<老师>/look/preview?kind=mouth|card&voice=… 试听,念的句子服务端定(./voice-preview.ts,有缓存)
9
+ *
10
+ * 头像只收选项里的值(不让页面往 cotutor.json 写任意路径);avatar null = 用出厂的(三门主科是木钉娃娃,别的显示学科首字)。
11
+ * 挑回出厂的那个声音就删掉那一项,cotutor.json 里不留和缺省一样的值。
12
+ */
13
+ import { existsSync } from 'node:fs';
14
+ import { join, resolve, sep } from 'node:path';
15
+ import { REALTIME_VOICES, isRealtimeVoice } from "../../realtime/voices.js";
16
+ import { TTS_VOICES } from "../../media/tts.js";
17
+ import { SHIPPED_CARD_VOICES, SHIPPED_MOUTHS, canonicalSubject, cardVoice, mouthVoice } from "../../schema/index.js";
18
+ import { patchConfig } from "../workspace.js";
19
+ import { ownAvatar, subjectAvatarFile } from "./avatars.js";
20
+ import { cardPreview, mouthPreview } from "./voice-preview.js";
21
+ /** 不放 📚 🧮 🔤:旧版 init 写的出厂 emoji,当作没写(avatars.ts) */
22
+ const EMOJI = ['📖', '✏️', '🌱', '🦉', '🐼', '⭐'];
23
+ const IMAGE_RE = /\.(png|jpe?g|webp)$/i;
24
+ /** 试听念的句子:学科的口吻 */
25
+ const MOUTH_SAMPLE = { 语文: '你好呀,今天我们读哪一课?', 数学: '你好呀,今天想算点什么?', 英语: 'Hello! 今天我们学哪几个单词?' };
26
+ const CARD_SAMPLE = { 语文: '小蝌蚪找妈妈', 数学: '二十八加十五等于四十三', 英语: 'apple, banana, orange' };
27
+ const isObj = (v) => Boolean(v) && typeof v === 'object' && !Array.isArray(v);
28
+ const bad = (message) => ({ status: 400, json: { error: 'bad_request', message } });
29
+ /** 现在用的排最前,出厂的其次,别的照表里的先后 */
30
+ const ranked = (all, current, shipped) => {
31
+ const rank = (v) => (v.voice === current ? 0 : v.voice === shipped ? 1 : 2);
32
+ return [...all].sort((a, b) => rank(a) - rank(b));
33
+ };
34
+ /** 表里十几种「某某型客服」并成一个场景,筛的时候不占一排 */
35
+ const sceneOf = (scene) => (scene.endsWith('客服') ? '客服' : scene);
36
+ function mouthOptions(c, name) {
37
+ const current = mouthVoice(c.live, name);
38
+ const known = REALTIME_VOICES.some((v) => v.voice === current) ? [] : [{ voice: current, name: current, gender: '', trait: '', lang: '普通话' }];
39
+ return ranked([...known, ...REALTIME_VOICES], current, SHIPPED_MOUTHS[name]).map((v) => {
40
+ // 表里的「(缺省)」是说 realtime 模型自己的缺省,家长用不着;「;中英夹着说……」拆成 warn
41
+ const [trait, extra = ''] = v.trait.replace(/\(缺省\)$/, '').split(';');
42
+ return { voice: v.voice, name: v.name, gender: v.gender, lang: v.lang, trait, ...(extra.includes('英文腔') ? { warn: '中英夹着说带英文腔' } : {}), shipped: SHIPPED_MOUTHS[name] === v.voice };
43
+ });
44
+ }
45
+ function cardOptions(c, name) {
46
+ const current = cardVoice(c.tutors, name);
47
+ const known = !current || TTS_VOICES.some((v) => v.voice === current) ? [] : [{ voice: current, name: current, gender: '', age: 0, trait: '', scene: '', lang: '中文' }];
48
+ return ranked([...known, ...TTS_VOICES], current, SHIPPED_CARD_VOICES[name]).map((v) => ({
49
+ voice: v.voice, name: v.name, gender: v.gender === '女' || v.gender === '男' ? v.gender : '', lang: v.lang, trait: v.trait,
50
+ ...(v.age ? { age: v.age } : {}), ...(v.scene ? { scene: sceneOf(v.scene) } : {}), shipped: SHIPPED_CARD_VOICES[name] === v.voice,
51
+ }));
52
+ }
53
+ /** figshot 画的图:现在用着的那张,或 workspace 里 avatars/<老师>.png 之类(换回别的以后还能选回来) */
54
+ function imageAvatar(ctx, name) {
55
+ const own = ownAvatar(ctx.ws.config.tutors[name]);
56
+ if (own && IMAGE_RE.test(own))
57
+ return own;
58
+ for (const ext of ['png', 'webp', 'jpg'])
59
+ if (existsSync(join(ctx.ws.root, 'avatars', `${name}.${ext}`)))
60
+ return `avatars/${name}.${ext}`;
61
+ return null;
62
+ }
63
+ function avatarOptions(ctx, name) {
64
+ const t = ctx.ws.config.tutors[name];
65
+ const base = `/api/parent/tutors/${name}/look/avatar`;
66
+ const out = [];
67
+ if (subjectAvatarFile(t.subject))
68
+ out.push({ value: null, label: '木钉娃娃', img: `${base}?k=factory` });
69
+ const img = imageAvatar(ctx, name);
70
+ if (img)
71
+ out.push({ value: img, label: 'figshot 画的', img: `${base}?k=image` });
72
+ const ch = (t.subject || t.display || '?').slice(0, 1);
73
+ out.push({ value: ch, label: '学科字', text: ch });
74
+ for (const e of EMOJI)
75
+ out.push({ value: e, label: '', text: e });
76
+ return out;
77
+ }
78
+ function look(ctx, name) {
79
+ const c = ctx.ws.config;
80
+ const t = c.tutors[name];
81
+ const own = ownAvatar(t);
82
+ return {
83
+ tutor: { name, display: t.display },
84
+ // 没写的、写的值不在选项里(cotutor.json 里手填的)都照实给,页面只是哪格都不亮
85
+ avatar: { current: own, options: avatarOptions(ctx, name) },
86
+ mouth: { current: mouthVoice(c.live, name), options: mouthOptions(c, name) },
87
+ card: { current: cardVoice(c.tutors, name) ?? null, options: cardOptions(c, name) },
88
+ };
89
+ }
90
+ export async function parentLookRoute(method, p, url, ctx, body) {
91
+ const m = /^\/api\/parent\/tutors\/([a-z0-9][a-z0-9-]*)\/look(?:\/(avatar|preview))?$/.exec(p);
92
+ if (!m)
93
+ return null;
94
+ const [, name, sub] = m;
95
+ const t = ctx.ws.config.tutors[name];
96
+ if (!t || !name.endsWith('-tutor'))
97
+ return { status: 404, json: { error: 'no_such_tutor' } };
98
+ const subject = canonicalSubject(t.subject) ?? '';
99
+ if (sub === 'avatar') {
100
+ if (method !== 'GET')
101
+ return { status: 405, json: { error: 'method_not_allowed' } };
102
+ if (url.searchParams.get('k') === 'factory') {
103
+ const file = subjectAvatarFile(t.subject);
104
+ return file ? { status: 200, file, contentType: 'image/webp', cacheControl: 'max-age=86400' } : { status: 404, json: { error: 'not_found' } };
105
+ }
106
+ const rel = imageAvatar(ctx, name);
107
+ const file = rel ? resolve(ctx.ws.root, rel) : '';
108
+ if (!rel || !file.startsWith(ctx.ws.root + sep) || !existsSync(file))
109
+ return { status: 404, json: { error: 'not_found' } };
110
+ const ext = (IMAGE_RE.exec(rel)?.[1] ?? '').toLowerCase();
111
+ return { status: 200, file, contentType: ext === 'png' ? 'image/png' : ext === 'webp' ? 'image/webp' : 'image/jpeg', cacheControl: 'no-cache' };
112
+ }
113
+ if (sub === 'preview') {
114
+ if (method !== 'GET')
115
+ return { status: 405, json: { error: 'method_not_allowed' } };
116
+ const voice = url.searchParams.get('voice')?.trim() ?? '';
117
+ return url.searchParams.get('kind') === 'card' ? cardPreview(ctx.ws, voice, CARD_SAMPLE[subject] ?? CARD_SAMPLE.语文) : mouthPreview(ctx.ws, voice, MOUTH_SAMPLE[subject] ?? MOUTH_SAMPLE.语文);
118
+ }
119
+ if (method === 'GET')
120
+ return { status: 200, json: look(ctx, name) };
121
+ if (method !== 'PUT')
122
+ return { status: 405, json: { error: 'method_not_allowed' } };
123
+ if (!isObj(body))
124
+ return bad('要 {avatar?, mouth?, voice?}');
125
+ const tutor = {};
126
+ const patch = { tutors: { [name]: tutor } };
127
+ if ('avatar' in body) {
128
+ const ok = avatarOptions(ctx, name).some((o) => o.value === body.avatar);
129
+ if (!ok)
130
+ return bad('头像只能从给的几个里挑;别的(图片路径、别的 emoji)直接写 cotutor.json');
131
+ tutor.avatar = body.avatar;
132
+ }
133
+ if (body.mouth !== undefined) {
134
+ if (typeof body.mouth !== 'string' || !isRealtimeVoice(body.mouth))
135
+ return bad(`说话的声音不认「${String(body.mouth)}」`);
136
+ patch.live = { voices: { [name]: body.mouth === SHIPPED_MOUTHS[name] ? null : body.mouth } };
137
+ }
138
+ if (body.voice !== undefined) {
139
+ if (typeof body.voice !== 'string' || !TTS_VOICES.some((v) => v.voice === body.voice))
140
+ return bad(`点读的声音不认「${String(body.voice)}」`);
141
+ tutor.voice = body.voice === SHIPPED_CARD_VOICES[name] ? null : body.voice;
142
+ }
143
+ try {
144
+ await patchConfig(ctx.ws, patch);
145
+ }
146
+ catch (err) {
147
+ return bad(err instanceof Error ? err.message : String(err));
148
+ }
149
+ await ctx.reload();
150
+ return { status: 200, json: look(ctx, name) };
151
+ }
@@ -11,7 +11,7 @@ import { annotateSource } from "../../board/parse.js";
11
11
  import { conversationFiles } from "../../schema/index.js";
12
12
  import { kidSource, truncateReply } from "../../runtime/kid-view.js";
13
13
  import { foldRuns, toolCalls } from "../../brain/transcript.js";
14
- import { logParser, toolKind } from "../../brain/index.js";
14
+ import { parseLogLine, toolKind } from "../../brain/index.js";
15
15
  import { resolvePolicy } from "../../schema/index.js";
16
16
  import { readErrLog, readIndex, readRunFile, readTranscript, scanCards } from "../../runtime/journal/store.js";
17
17
  import { formatEvent, parseEvents, timelineSpans } from "../../runtime/journal/events.js";
@@ -61,7 +61,7 @@ export async function rawView(ws, tutor, date, job) {
61
61
  return null;
62
62
  const files = conversationFiles(ws.dirs.conversations, tutor, date);
63
63
  const policy = resolvePolicy(ws.config, tutor);
64
- const transcript = await readTranscript(ws, tutor, date, job, m.runtime);
64
+ const transcript = await readTranscript(ws, tutor, date, job);
65
65
  const pack = await readRunFile(ws, tutor, date, job);
66
66
  const blocks = (transcript?.items ?? []).filter((i) => i.kind === 'text' && !i.sub).map((i) => i.text.trim());
67
67
  const src = (transcript ? kidSource(transcript) : null) ?? '';
@@ -93,7 +93,7 @@ export async function rawView(ws, tutor, date, job) {
93
93
  }));
94
94
  const err = await readErrLog(ws, tutor, date, job);
95
95
  // 读了什么:索引里物化过的优先(和当时一致),没有(老轮次)就从 .log 现抽
96
- const tools = (m.tools ?? toolCalls(await readFile(files.log(job), 'utf8').catch(() => ''), logParser(ws.config.runtimes, m.runtime))).map((t) => ({ ...t, kind: toolKind(t.name) }));
96
+ const tools = (m.tools ?? toolCalls(await readFile(files.log(job), 'utf8').catch(() => ''), parseLogLine)).map((t) => ({ ...t, kind: toolKind(t.name) }));
97
97
  const toolFiles = new Set(tools.filter((t) => t.kind === 'read' && t.arg).map((t) => t.arg)).size;
98
98
  const toolFailed = tools.filter((t) => t.ok === false).length;
99
99
  const events = parseEvents(await readFile(files.events(job), 'utf8').catch(() => ''));
@@ -0,0 +1,4 @@
1
+ import type { Workspace } from '../../schema/index.ts';
2
+ import type { RouteResult } from './app.ts';
3
+ export declare function mouthPreview(ws: Workspace, voice: string, text: string): Promise<RouteResult>;
4
+ export declare function cardPreview(ws: Workspace, voice: string, text: string): Promise<RouteResult>;
@@ -0,0 +1,34 @@
1
+ /**
2
+ * 试听一个音色(家长端「样子与声音」):
3
+ * 嘴(realtime)真连一次照念这一句(几分钱),存在 .cotutor/live-preview/,同一句同一音色以后直接给文件;
4
+ * 卡的配音合成一句,在配音缓存里(.cotutor/tts/),之后直接给文件。失败回 JSON 说原因(页面拿 message 显示)。
5
+ */
6
+ import { createHash } from 'node:crypto';
7
+ import { mkdir, stat, writeFile } from 'node:fs/promises';
8
+ import { join } from 'node:path';
9
+ import { synthesize } from "../../media/tts.js";
10
+ import { previewVoice } from "../../realtime/preview.js";
11
+ import { isRealtimeVoice } from "../../realtime/voices.js";
12
+ const VOICE_ID_RE = /^[A-Za-z0-9._:-]{1,120}$/;
13
+ export async function mouthPreview(ws, voice, text) {
14
+ if (!isRealtimeVoice(voice))
15
+ return { status: 400, json: { error: 'bad_voice', message: `这个模型不认「${voice}」这个音色` } };
16
+ const dir = join(ws.root, '.cotutor', 'live-preview');
17
+ const file = join(dir, `${createHash('sha1').update(`${ws.config.live.model}\n${voice}\n${text}`).digest('hex').slice(0, 20)}.wav`);
18
+ if (!(await stat(file).catch(() => null))?.isFile()) {
19
+ const r = await previewVoice({ voice, text, model: ws.config.live.model, env: process.env, endpoint: process.env.COTUTOR_LIVE_ENDPOINT });
20
+ if ('error' in r)
21
+ return { status: 502, json: { error: 'realtime_failed', voice, message: r.error } };
22
+ await mkdir(dir, { recursive: true });
23
+ await writeFile(file, r.wav);
24
+ }
25
+ return { status: 200, file, contentType: 'audio/wav' };
26
+ }
27
+ export async function cardPreview(ws, voice, text) {
28
+ if (!VOICE_ID_RE.test(voice))
29
+ return { status: 400, json: { error: 'bad_voice', message: '音色 id 只能是字母数字与 . _ : -' } };
30
+ const r = await synthesize({ text, voice }, { cacheDir: ws.dirs.tts, env: process.env });
31
+ if (!r.file)
32
+ return { status: 502, json: { error: 'tts_failed', voice, message: r.error } };
33
+ return { status: 200, file: r.file, contentType: 'audio/mpeg' };
34
+ }
@@ -7,8 +7,6 @@ export interface BailianAuth {
7
7
  export interface BailianStatus {
8
8
  /** 孩子端能用:按量 key 与业务空间 id 都有(或指了假端点) */
9
9
  ready: boolean;
10
- /** 用的那份是哪来的:parent = 家长端配的;machine = 这台电脑别处配的(环境变量、qwen、voxtell);null = 没有 */
11
- from: 'parent' | 'machine' | null;
12
10
  /** key 的末四位(不回显整把) */
13
11
  keyTail: string | null;
14
12
  workspaceId: string | null;
@@ -2,9 +2,9 @@
2
2
  * 百炼:孩子端的老师靠实时模型开口,没配百炼孩子端打不开,家长在家长端「首页」里配。
3
3
  * 配的是按量的 key + 业务空间 id(Token Plan 的 `sk-sp-` 没有 realtime;端点要业务空间 id 的域名)。
4
4
  * 存机器级 ~/.config/cotutor/bailian.json(0600,同 PIN、证书,几个孩子的 workspace 共用),不进 cotutor.json:
5
- * workspace 是 git 仓,工作台的 PATCH /api/config 也能改它。读的顺序(家长端配的在最前)见 src/shared/qianwen.ts。
5
+ * workspace 是 git 仓,工作台的 PATCH /api/config 也能改它。cotutor 的 key 只认这里(开发机另有 token-plan.json),见 src/shared/qianwen.ts。
6
6
  *
7
- * GET /api/parent/bailian {ready, from, keyTail, workspaceId, canEdit}
7
+ * GET /api/parent/bailian {ready, keyTail, workspaceId, canEdit}
8
8
  * PUT /api/parent/bailian {key, workspaceId}:先试连,通了才存;回 GET 那份 + ms
9
9
  * POST /api/parent/bailian/test 拿现在用的那份试连一次:{ok, ms, message?}
10
10
  *
@@ -14,7 +14,7 @@
14
14
  import { mkdir, writeFile } from 'node:fs/promises';
15
15
  import { homedir } from 'node:os';
16
16
  import { dirname } from 'node:path';
17
- import { bailianFile, cotutorBailian } from "../../shared/qianwen.js";
17
+ import { bailianFile } from "../../shared/qianwen.js";
18
18
  import { dashscopeAuth } from "../../realtime/bailian.js";
19
19
  import { probeRealtime } from "../../realtime/preview.js";
20
20
  export function createBailian(opts = {}) {
@@ -25,9 +25,8 @@ export function createBailian(opts = {}) {
25
25
  status() {
26
26
  const auth = dashscopeAuth(env);
27
27
  if (!auth)
28
- return { ready: Boolean(fake), from: null, keyTail: null, workspaceId: null };
29
- const mine = cotutorBailian(home);
30
- return { ready: true, from: mine.apiKey === auth.key && mine.workspaceId === auth.workspaceId ? 'parent' : 'machine', keyTail: auth.key.slice(-4), workspaceId: auth.workspaceId };
28
+ return { ready: Boolean(fake), keyTail: null, workspaceId: null };
29
+ return { ready: true, keyTail: auth.key.slice(-4), workspaceId: auth.workspaceId };
31
30
  },
32
31
  probe: opts.probe ?? ((auth) => probeRealtime({ auth, env, endpoint: fake })),
33
32
  async save(auth) {
@@ -0,0 +1,25 @@
1
+ export declare const UPDATE_URLS: string[];
2
+ export declare const updateFile: () => string;
3
+ export interface UpdateRecord {
4
+ checkedAt?: string;
5
+ latest?: string;
6
+ off?: boolean;
7
+ }
8
+ /** 读不到、写坏了 → 空的,当作没问过 */
9
+ export declare function readUpdateRecord(file?: string): Promise<UpdateRecord>;
10
+ /** 依次问,谁先答谁算;都问不到 → null */
11
+ export declare function fetchLatest(fetchFn?: typeof fetch, urls?: string[]): Promise<string | null>;
12
+ /** 这个进程现在知道的最新版本;还不知道、关掉了 → null */
13
+ export declare const knownLatest: () => string | null;
14
+ export interface UpdateOptions {
15
+ now?: number;
16
+ fetch?: typeof fetch;
17
+ file?: string;
18
+ }
19
+ /** 文件里的还新鲜就用它,否则问一次并存下;问不到就继续用上次的 */
20
+ export declare function refreshLatest(o?: UpdateOptions): Promise<string | null>;
21
+ /** serve 起来时调:先用存着的那份,一分钟后问(刚开机网可能还没好),之后每小时看一眼,过了一天才真的问。定时器不拦进程退出 */
22
+ export declare function startUpdateChecks(o?: UpdateOptions & {
23
+ firstMs?: number;
24
+ everyMs?: number;
25
+ }): () => void;
@@ -0,0 +1,88 @@
1
+ /**
2
+ * 有没有新版本:服务每天问一次 npm 的 latest,结果存机器级 ~/.config/cotutor/update.json(几个孩子的服务共用,一天只问一次,重起不重问)。
3
+ * /api/health 带上 latest(比在跑的新才有),托盘、家长端、service status 都从它拿;doctor 只读这份文件,不上网。
4
+ * 只发一个普通的 GET,不带孩子或使用的任何信息;问不到(断网、被墙)不报错,下个小时再问。先问 npm,不通再问 npmmirror(中国家庭常连不上 npm)。
5
+ * latest 这个标签要等 npm 扫描通过才变(2026-07 起发布后先扫描),不会提前提醒。
6
+ * 不自动升级:升级要重起服务,孩子可能正在上课;老师文件的更新也要家长知道。
7
+ * 不想让它问:update.json 写 {"off": true}。从仓库跑的(BUILD.commit 有值)不问,见 main.ts。
8
+ */
9
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
10
+ import { homedir } from 'node:os';
11
+ import { dirname, join } from 'node:path';
12
+ import { isPlainVersion } from "../../shared/upgrade.js";
13
+ export const UPDATE_URLS = ['https://registry.npmjs.org/cotutor/latest', 'https://registry.npmmirror.com/cotutor/latest'];
14
+ const DAY_MS = 24 * 3600_000;
15
+ const HOUR_MS = 3600_000;
16
+ const FIRST_MS = 60_000;
17
+ const TIMEOUT_MS = 8000;
18
+ export const updateFile = () => join(homedir(), '.config', 'cotutor', 'update.json');
19
+ /** 读不到、写坏了 → 空的,当作没问过 */
20
+ export async function readUpdateRecord(file = updateFile()) {
21
+ try {
22
+ const j = JSON.parse(await readFile(file, 'utf8'));
23
+ return {
24
+ ...(typeof j.checkedAt === 'string' ? { checkedAt: j.checkedAt } : {}),
25
+ ...(isPlainVersion(j.latest) ? { latest: j.latest } : {}),
26
+ ...(j.off === true ? { off: true } : {}),
27
+ };
28
+ }
29
+ catch {
30
+ return {};
31
+ }
32
+ }
33
+ /** 依次问,谁先答谁算;都问不到 → null */
34
+ export async function fetchLatest(fetchFn = fetch, urls = UPDATE_URLS) {
35
+ for (const url of urls) {
36
+ try {
37
+ const r = await fetchFn(url, { signal: AbortSignal.timeout(TIMEOUT_MS), headers: { accept: 'application/json' } });
38
+ if (!r.ok)
39
+ continue;
40
+ const v = (await r.json()).version;
41
+ if (isPlainVersion(v))
42
+ return v;
43
+ }
44
+ catch {
45
+ /* 这家不通,问下一家 */
46
+ }
47
+ }
48
+ return null;
49
+ }
50
+ let known = null;
51
+ /** 这个进程现在知道的最新版本;还不知道、关掉了 → null */
52
+ export const knownLatest = () => known;
53
+ /** 文件里的还新鲜就用它,否则问一次并存下;问不到就继续用上次的 */
54
+ export async function refreshLatest(o = {}) {
55
+ const file = o.file ?? updateFile();
56
+ const now = o.now ?? Date.now();
57
+ const rec = await readUpdateRecord(file);
58
+ if (rec.off)
59
+ return (known = null);
60
+ const at = rec.checkedAt ? Date.parse(rec.checkedAt) : Number.NaN;
61
+ // 时间在将来(改过系统时间)算不新鲜
62
+ if (rec.latest && now >= at && now - at < DAY_MS)
63
+ return (known = rec.latest);
64
+ const latest = await fetchLatest(o.fetch);
65
+ if (!latest)
66
+ return (known = rec.latest ?? null);
67
+ known = latest;
68
+ try {
69
+ await mkdir(dirname(file), { recursive: true });
70
+ const tmp = `${file}.${process.pid}.tmp`;
71
+ await writeFile(tmp, `${JSON.stringify({ checkedAt: new Date(now).toISOString(), latest }, null, 2)}\n`);
72
+ await rename(tmp, file);
73
+ }
74
+ catch {
75
+ /* 写不了:下个小时再问一次,不影响别的 */
76
+ }
77
+ return latest;
78
+ }
79
+ /** serve 起来时调:先用存着的那份,一分钟后问(刚开机网可能还没好),之后每小时看一眼,过了一天才真的问。定时器不拦进程退出 */
80
+ export function startUpdateChecks(o = {}) {
81
+ void readUpdateRecord(o.file).then((rec) => { known = rec.off ? null : rec.latest ?? null; });
82
+ const run = () => { void refreshLatest(o).catch(() => { }); };
83
+ const first = setTimeout(run, o.firstMs ?? FIRST_MS);
84
+ first.unref();
85
+ const every = setInterval(run, o.everyMs ?? HOUR_MS);
86
+ every.unref();
87
+ return () => { clearTimeout(first); clearInterval(every); };
88
+ }
@@ -45,7 +45,7 @@ export function mockLiveUpgrade(mock) {
45
45
  const my = turn;
46
46
  if (caught)
47
47
  say('catch', caught);
48
- const r = await mock.route('POST', `/api/kid/conversations/${tutor}/messages`, { ...body, live: true });
48
+ const r = await mock.kidSend(tutor, body);
49
49
  if (my !== turn)
50
50
  return;
51
51
  if (r.status !== 202) {
@@ -4,7 +4,7 @@ export declare const MOCK_BUNDLES_DIR: string;
4
4
  /** mock 的视频小课堂目录:仓库里的样本(tests/fixtures/lectures/) */
5
5
  export declare const MOCK_LECTURES_DIR: string;
6
6
  import { type BoardSection, type PenName } from '../../board/presentation.ts';
7
- import { type ListenInfo } from '../../ui/dev/qr-page.ts';
7
+ import { type ListenInfo } from '../../ui/parent/qr-page.ts';
8
8
  export type MockScenario = 'normal' | 'nokey';
9
9
  export interface MockTutor {
10
10
  name: string;
@@ -62,12 +62,14 @@ export interface MockOptions {
62
62
  }
63
63
  export interface Mock {
64
64
  route(method: string, path: string, body?: unknown): Promise<MockRouteResult>;
65
+ /** 孩子端发一条(同真服务只走实时那条连接:mock-live.ts 收到了交给它,测试也用它);回的形状同路由,收下 202 + {job, thread} */
66
+ kidSend(tutor: string, body: unknown): Promise<MockRouteResult>;
65
67
  /** 等所有还在「想」的老师答完(测试用) */
66
68
  settle(): Promise<void>;
67
69
  /** 等这一轮的老师答完(mock 的假嘴等它写完再「念」) */
68
70
  settleJob(job: string): Promise<void>;
69
71
  handler(req: IncomingMessage, res: ServerResponse): void;
70
- /** 在哪个地址上听着(serveMock 在 listen 之后填;扫码页 /dev/qr 每次现问) */
72
+ /** 在哪个地址上听着(serveMock 在 listen 之后填;扫码页 /parent/qr 每次现问) */
71
73
  listen: (() => ListenInfo) | null;
72
74
  }
73
75
  export declare function createMock(opts?: MockOptions): Mock;