cotutor 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (205) hide show
  1. package/LICENSE +9 -7
  2. package/NOTICE +3 -3
  3. package/README.md +36 -13
  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 +5 -0
  17. package/dist/app/cli/cert.js +20 -7
  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 +6 -5
  22. package/dist/app/cli/doctor.js +107 -171
  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 +347 -24
  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 +138 -0
  42. package/dist/app/cli/service.js +429 -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 +33 -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 +205 -0
  58. package/dist/app/cli/tray.js +618 -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 +45 -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/mock/mock-live.js +1 -1
  77. package/dist/app/mock/mock.d.ts +4 -2
  78. package/dist/app/mock/mock.js +20 -12
  79. package/dist/app/replay.js +4 -4
  80. package/dist/app/skills/home-doc.js +1 -1
  81. package/dist/app/workspace.d.ts +9 -4
  82. package/dist/app/workspace.js +33 -10
  83. package/dist/app/ws/route.d.ts +8 -2
  84. package/dist/app/ws/route.js +7 -3
  85. package/dist/board/parse.d.ts +0 -2
  86. package/dist/board/parse.js +0 -6
  87. package/dist/board/presentation.d.ts +9 -15
  88. package/dist/board/presentation.js +8 -10
  89. package/dist/brain/api-tools.js +2 -1
  90. package/dist/brain/api.d.ts +12 -17
  91. package/dist/brain/api.js +16 -33
  92. package/dist/brain/index.d.ts +23 -71
  93. package/dist/brain/index.js +6 -69
  94. package/dist/brain/log.d.ts +5 -0
  95. package/dist/brain/log.js +22 -0
  96. package/dist/brain/runtime.d.ts +30 -0
  97. package/dist/brain/runtime.js +23 -0
  98. package/dist/brain/{cli/stream-json.d.ts → stream-json.d.ts} +2 -4
  99. package/dist/brain/{cli/stream-json.js → stream-json.js} +0 -4
  100. package/dist/brain/stream.d.ts +5 -5
  101. package/dist/brain/stream.js +7 -7
  102. package/dist/brain/transcript.d.ts +3 -3
  103. package/dist/brain/transcript.js +6 -6
  104. package/dist/brain/{cli/types.d.ts → types.d.ts} +3 -48
  105. package/dist/brain/types.js +14 -0
  106. package/dist/chalk/glyphs/skeleton.d.ts +3 -3
  107. package/dist/chalk/glyphs/skeleton.js +1 -1
  108. package/dist/lib/index.d.ts +1 -1
  109. package/dist/lib/index.js +1 -1
  110. package/dist/memory/diary.js +3 -3
  111. package/dist/realtime/bailian.d.ts +1 -1
  112. package/dist/realtime/bailian.js +3 -2
  113. package/dist/realtime/preview.js +1 -1
  114. package/dist/realtime/voices.d.ts +2 -0
  115. package/dist/realtime/voices.js +57 -57
  116. package/dist/runtime/activities.d.ts +13 -1
  117. package/dist/runtime/activities.js +28 -2
  118. package/dist/runtime/activity-state.js +2 -2
  119. package/dist/runtime/agent-file.d.ts +3 -3
  120. package/dist/runtime/agent-file.js +3 -3
  121. package/dist/runtime/context/tutor-rules.d.ts +2 -2
  122. package/dist/runtime/context/tutor-rules.js +3 -3
  123. package/dist/runtime/home/home.d.ts +12 -1
  124. package/dist/runtime/home/home.js +8 -1
  125. package/dist/runtime/home/store.d.ts +1 -1
  126. package/dist/runtime/home/store.js +36 -21
  127. package/dist/runtime/journal/mix.d.ts +15 -6
  128. package/dist/runtime/journal/mix.js +60 -19
  129. package/dist/runtime/journal/recorder.d.ts +58 -13
  130. package/dist/runtime/journal/recorder.js +106 -16
  131. package/dist/runtime/journal/store.d.ts +4 -4
  132. package/dist/runtime/journal/store.js +6 -6
  133. package/dist/runtime/kid-input.js +2 -3
  134. package/dist/runtime/live/call.d.ts +12 -0
  135. package/dist/runtime/live/call.js +51 -18
  136. package/dist/runtime/live/turns.js +1 -1
  137. package/dist/runtime/runner.d.ts +9 -55
  138. package/dist/runtime/runner.js +52 -173
  139. package/dist/scenes/paint/painter.js +4 -3
  140. package/dist/schema/config.d.ts +43 -22
  141. package/dist/schema/config.js +45 -47
  142. package/dist/schema/conversation.d.ts +6 -0
  143. package/dist/schema/home.d.ts +11 -1
  144. package/dist/schema/home.js +7 -2
  145. package/dist/schema/workspace.d.ts +27 -0
  146. package/dist/schema/workspace.js +28 -0
  147. package/dist/shared/default-port.d.ts +2 -0
  148. package/dist/shared/default-port.js +2 -0
  149. package/dist/shared/qianwen.d.ts +14 -10
  150. package/dist/shared/qianwen.js +26 -22
  151. package/dist/shared/site-url.d.ts +2 -0
  152. package/dist/shared/site-url.js +5 -0
  153. package/dist/ui/dev/dev-page.js +25 -252
  154. package/dist/ui/dev/dev-theme.d.ts +1 -1
  155. package/dist/ui/dev/dev-theme.js +1 -1
  156. package/dist/ui/dev/voice-gallery-page.js +1 -1
  157. package/dist/ui/kid/board-mark.d.ts +2 -2
  158. package/dist/ui/kid/board-mark.js +2 -2
  159. package/dist/ui/kid/home-tiles.d.ts +14 -0
  160. package/dist/ui/kid/home-tiles.js +44 -0
  161. package/dist/ui/kid/kid-page.js +68 -103
  162. package/dist/ui/kid/voice-ui.d.ts +1 -1
  163. package/dist/ui/kid/voice-ui.js +1 -1
  164. package/dist/ui/kid/word-emoji.js +5 -3
  165. package/dist/ui/parent/parent-home-page.js +315 -7
  166. package/dist/ui/parent/parent-page.js +6 -2
  167. package/dist/ui/parent/parent-settings-page.d.ts +1 -0
  168. package/dist/ui/parent/parent-settings-page.js +133 -0
  169. package/dist/ui/{dev → parent}/qr-page.d.ts +2 -2
  170. package/dist/ui/{dev → parent}/qr-page.js +19 -14
  171. package/dist/ui/shared/brand.d.ts +3 -1
  172. package/dist/ui/shared/brand.js +3 -1
  173. package/grades/grade-2/README.md +1 -1
  174. package/grades/grade-2/VALIDATION.md +1 -1
  175. package/grades/grade-2/agents/chinese-tutor/activities//345/244/215/344/271/240.md +1 -1
  176. 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
  177. 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
  178. package/grades/grade-2/agents/chinese-tutor/activities//351/242/204/344/271/240.md +1 -1
  179. package/grades/grade-2/agents/english-tutor/activities//345/244/215/344/271/240.md +1 -1
  180. 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
  181. 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
  182. package/grades/grade-2/agents/english-tutor/activities//351/242/204/344/271/240.md +1 -1
  183. package/grades/grade-2/agents/math-tutor/activities//345/244/215/344/271/240.md +1 -1
  184. 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
  185. 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
  186. package/grades/grade-2/agents/math-tutor/activities//351/242/204/344/271/240.md +1 -1
  187. package/package.json +2 -1
  188. package/skills/cotutor-analyze/references//345/221/275/344/273/244/344/270/216/346/226/207/344/273/266.md +2 -2
  189. package/skills/cotutor-board/references/photo.md +2 -2
  190. package/skills/cotutor-home/SKILL.md +1 -0
  191. package/skills/cotutor-home/references/tutor.md +2 -1
  192. package/skills/cotutor-home/references//351/246/226/351/241/265/350/257/255/346/263/225.md +2 -1
  193. package/skills/cotutor-tune/references//345/255/227/346/256/265.md +6 -3
  194. package/skills/cotutor-tutor/SKILL.md +5 -13
  195. package/dist/brain/cli/adapters.d.ts +0 -60
  196. package/dist/brain/cli/adapters.js +0 -91
  197. package/dist/brain/cli/qwen.d.ts +0 -11
  198. package/dist/brain/cli/qwen.js +0 -141
  199. package/dist/brain/cli/run-plan.d.ts +0 -59
  200. package/dist/brain/cli/run-plan.js +0 -71
  201. package/dist/brain/cli/run.d.ts +0 -35
  202. package/dist/brain/cli/run.js +0 -256
  203. package/dist/brain/cli/types.js +0 -3
  204. package/dist/brain/cli/warm.d.ts +0 -53
  205. package/dist/brain/cli/warm.js +0 -103
@@ -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) {
@@ -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;
@@ -14,14 +14,14 @@ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'no
14
14
  import { readdir } from 'node:fs/promises';
15
15
  import { createServer as createHttp } from 'node:http';
16
16
  import { createServer as createHttps } from 'node:https';
17
- import { hostname, tmpdir } from 'node:os';
17
+ import { tmpdir } from 'node:os';
18
18
  import { join } from 'node:path';
19
19
  import { parseBoard } from "../../board/parse.js";
20
20
  import { cardAssets, parseCardState, stripSecrets, DictationPropsSchema } from "../../board/cards/index.js";
21
21
  import { fileURLToPath } from 'node:url';
22
22
  import { bundleAsset, stageAsset, stageIndex, stageVersion } from "../http/stage.js";
23
23
  import { sendFile } from "../http/send-file.js";
24
- import { subjectLang, tutorExtras } from "../../schema/index.js";
24
+ import { HOME_TILES, HOME_TILE_IDS, canonicalSubject, subjectLang, tutorExtras } from "../../schema/index.js";
25
25
  import { NEUTRAL_COLOR, tutorColors } from "../../ui/shared/tutor-color.js";
26
26
  import { boardMarkOf } from "../../ui/kid/board-mark.js";
27
27
  import { backTutorOf } from "../../ui/kid/tutor-face.js";
@@ -67,15 +67,15 @@ import { wavOfPcm } from "../../shared/wav.js";
67
67
  import { buildReel, playRecordOk, reelHolds, reelMissRuns } from "../../ui/kid/reel.js";
68
68
  import { weekOf } from "../../shared/time.js";
69
69
  import {} from "../../schema/index.js";
70
- import { lanAddresses, listenInfo } from "../cli/serve.js";
71
- import { qrPage } from "../../ui/dev/qr-page.js";
70
+ import { lanAddresses, listenInfo, localName, siteUrl } from "../cli/serve.js";
71
+ import { qrPage } from "../../ui/parent/qr-page.js";
72
72
  import { USER_CERT_DIR } from "../workspace.js";
73
73
  import { kidThreads } from "../../runtime/kid-view.js";
74
74
  import { ICON_SIZES, appIconPng, webManifest } from "../../ui/shared/icon.js";
75
75
  import { faviconResponse } from "../../ui/shared/favicon.js";
76
76
  import { kidPage } from "../../ui/kid/kid-page.js";
77
77
  import { parentPage } from "../../ui/parent/parent-page.js";
78
- import { arrangeHome, homeOwners, kidButtons, parseHome } from "../../runtime/home/home.js";
78
+ import { arrangeHome, homeOwners, kidButtons, kidTiles, parseHome } from "../../runtime/home/home.js";
79
79
  import { packageTheme } from "../cli/themes.js";
80
80
  import { cardsCss } from "../../board/cards/docs.js";
81
81
  import { VOICE_GALLERY_PAGE } from "../../ui/dev/voice-gallery-page.js";
@@ -533,7 +533,9 @@ export function createMock(opts = {}) {
533
533
  // mock 的话题都是新话题进去的:今天孩子问过的那个,挂在新话题按钮上(空白板上的胶囊)
534
534
  const resumes = asked ? new Map([['free', { thread: asked.thread, title: (asked.question ?? '').slice(0, 10), at: list[list.length - 1]?.at ?? '' }]]) : undefined;
535
535
  const alive = (date, thread) => (date === yesterday() && (past.get(name) ?? []).some((m) => m.thread === thread)) || (date === localDate(d) && list.some((m) => m.thread === thread));
536
- return { kind: 'tutor', props: { tutor: name, buttons: kidButtons((c.props.buttons ?? []), { resumes, alive, lecture: (id) => Boolean(mockLecture(id)), today: localDate(d) }).map((b) => { const l = b.kind === 'lecture' ? mockLecture(b.bundle) : null; return l ? { ...b, title: l.title, ms: l.ms, ...(l.video ? { video: true } : {}) } : b; }) } };
536
+ // 方砖:三门主科出厂都有预习、检查作业、复习(真服务看老师家里有没有那个 activity 文件)
537
+ const main = ['语文', '数学', '英语'].includes(canonicalSubject(MOCK_TUTORS.find((t) => t.name === name)?.subject) ?? '');
538
+ return { kind: 'tutor', props: { tutor: name, tiles: main ? kidTiles(HOME_TILE_IDS, { usable: () => true }) : [], buttons: kidButtons((c.props.buttons ?? []), { resumes, alive, lecture: (id) => Boolean(mockLecture(id)), today: localDate(d) }).map((b) => { const l = b.kind === 'lecture' ? mockLecture(b.bundle) : null; return l ? { ...b, title: l.title, ms: l.ms, ...(l.video ? { video: true } : {}) } : b; }) } };
537
539
  });
538
540
  // figshot:和配了 figshot 的 workspace 一样给端口;这台电脑上 figshot 没开着,页面照样藏着这张卡
539
541
  return { title, date: localDate(d), tutors: tutorsJson(), home: mockHomeId(), cards, figshot: { port: 8477 }, hand: 'right', ready: scenario !== 'nokey' };
@@ -579,6 +581,9 @@ export function createMock(opts = {}) {
579
581
  return null;
580
582
  if (via.button === 'new' || via.button === 'recent')
581
583
  return via.button;
584
+ const tile = HOME_TILES.find((t) => t.id === via.button);
585
+ if (tile)
586
+ return { kind: 'activity', label: tile.activity, activity: tile.activity, params: {} };
582
587
  if (via.home !== mockHomeId() || typeof via.button !== 'number')
583
588
  return null;
584
589
  const card = parseHome(mockHomeMd(localDate(now()), yesterday())).cards.find((c) => c.kind === 'tutor' && c.props.tutor === name);
@@ -626,13 +631,14 @@ export function createMock(opts = {}) {
626
631
  setTimeout(reveal, tick);
627
632
  });
628
633
  };
629
- const route = async (method, path, body) => {
634
+ /** internal = 实时那条连接交来的孩子发的一条(kidSend);HTTP 上不收(同真服务,2026-11-04 删了 HTTP 的发) */
635
+ const routeIn = async (method, path, body, internal = false) => {
630
636
  await lecturesLoaded;
631
637
  const url = new URL(path, 'http://x');
632
638
  const p = url.pathname;
633
639
  if (p === '/')
634
640
  return { status: 200, html: kidPage(title, {}, await stageVersion()) };
635
- if (p === '/dev/qr')
641
+ if (p === '/parent/qr')
636
642
  return mock.listen ? { status: 200, html: qrPage(title, mock.listen(), url.searchParams.get('via') === 'ip' ? 'ip' : 'name', url.searchParams.get('to') === 'parent' ? 'parent' : 'kid') } : { status: 404, json: { error: 'not_listening' } };
637
643
  if (p === '/dev/voice')
638
644
  return { status: 200, html: VOICE_GALLERY_PAGE };
@@ -884,7 +890,7 @@ export function createMock(opts = {}) {
884
890
  const out = { tutor: name, date: tail, messages: await Promise.all(old.map(kidMessage)), pending: null, thread: old.length ? old[old.length - 1].thread : null };
885
891
  return { status: 200, json: out };
886
892
  }
887
- if (tail === 'messages' && method === 'POST') {
893
+ if (tail === 'messages' && method === 'POST' && internal) {
888
894
  if (!isObj(body) || typeof body.text !== 'string')
889
895
  return { status: 400, json: { error: 'bad_request' } };
890
896
  const action = body.action === 'submit' ? 'submit' : undefined;
@@ -1085,7 +1091,9 @@ export function createMock(opts = {}) {
1085
1091
  })().catch((err) => { res.writeHead(500, { 'content-type': 'application/json' }); res.end(JSON.stringify({ error: 'internal', message: String(err) })); });
1086
1092
  };
1087
1093
  const settleJob = async (job) => { await inflight.get(job); };
1088
- const mock = { route, settle, settleJob, handler, listen: null };
1094
+ const route = (method, path, body) => routeIn(method, path, body);
1095
+ const kidSend = (tutor, body) => routeIn('POST', `/api/kid/conversations/${tutor}/messages`, isObj(body) ? { ...body, live: true } : body, true);
1096
+ const mock = { route, kidSend, settle, settleJob, handler, listen: null };
1089
1097
  return mock;
1090
1098
  }
1091
1099
  export async function serveMock(opts = {}) {
@@ -1111,7 +1119,7 @@ export async function serveMock(opts = {}) {
1111
1119
  });
1112
1120
  const port = server.address().port;
1113
1121
  const scheme = tls ? 'https' : 'http';
1114
- const urls = [hostname(), ...lanAddresses()].map((h) => `${scheme}://${h}:${port}/`);
1122
+ const urls = [localName(), ...lanAddresses()].map((h) => siteUrl(scheme, h, port));
1115
1123
  mock.listen = () => listenInfo(Boolean(tls), port);
1116
- return { server, mock, port, https: Boolean(tls), urls, qrPage: `${scheme}://localhost:${port}/dev/qr` };
1124
+ return { server, mock, port, https: Boolean(tls), urls, qrPage: siteUrl(scheme, 'localhost', port, '/parent/qr') };
1117
1125
  }
@@ -10,9 +10,9 @@
10
10
  import { readFile } from 'node:fs/promises';
11
11
  import { join } from 'node:path';
12
12
  import { cardLabel } from "../board/cards/index.js";
13
- import { logParser } from "../brain/index.js";
13
+ import { parseLogLine } from "../brain/index.js";
14
14
  import { UsageError } from "../shared/errors.js";
15
- import { conversationFiles, threads } from "../schema/index.js";
15
+ import { conversationFiles, threads, viaSendsLabel } from "../schema/index.js";
16
16
  import { compactDiff, diffLines } from "./diff.js";
17
17
  import { tokenText } from "../shared/tokens.js";
18
18
  import { toolCalls } from "../brain/transcript.js";
@@ -61,7 +61,7 @@ export async function startReplay(ws, tutor, date, job, opts = {}) {
61
61
  replayOf: job,
62
62
  runtime: opts.runtime,
63
63
  ...(m.via ? { via: m.via } : {}),
64
- ...(m.via && typeof m.via.button === 'number' ? { home: { button: m.via.label, ...(button?.brief ? { brief: button.brief } : {}) } } : {}),
64
+ ...(m.via && viaSendsLabel(m.via) ? { home: { button: m.via.label, ...(button?.brief ? { brief: button.brief } : {}) } } : {}),
65
65
  ...(m.continues && continued ? { continues: { ...m.continues, pack: continued } } : {}),
66
66
  ...(activity ? { activity } : {}),
67
67
  // 原轮是实时的:回放也按实时写(上下文包带 live:、不补提问卡);嘴不在,接住与打断的 caught / cut 不带
@@ -119,7 +119,7 @@ async function toolsOf(ws, tutor, date, m) {
119
119
  if (m.tools)
120
120
  return m.tools;
121
121
  const text = await readFile(conversationFiles(ws.dirs.conversations, tutor, date).log(m.job), 'utf8').catch(() => '');
122
- return toolCalls(text, logParser(ws.config.runtimes, m.runtime));
122
+ return toolCalls(text, parseLogLine);
123
123
  }
124
124
  export async function compareReplay(ws, tutor, date, evalJob) {
125
125
  const evalWs = evalWorkspace(ws);
@@ -35,7 +35,7 @@ export function homeSyntaxDoc(dir = PACKAGE_CARDS_DIR) {
35
35
  - **第一个 \`## \` 起是给家长的**(「## 为什么这么排」),孩子看不到。
36
36
  - 围栏外的普通行首页不显示(检查会提醒),要给孩子看的写进卡里。
37
37
 
38
- 应用保证的:每位孩子端露出的老师都有一张老师卡(没写就补一张只有「新话题」的);「新话题」永远是第一个按钮;应用不加「接着刚才的」——今天从同一个入口聊过的,孩子进老师页后在空白板上接(新话题是一个胶囊,activity、小课堂先问接着那个还是重新开始);引用坏了(话题找不到)的按钮不出现。
38
+ 应用保证的:每位孩子端露出的老师都有一张老师卡(没写就补一张只有「新话题」的);「新话题」永远是第一个按钮;老师家里有预习、检查作业、复习这几个 activity 的,新话题下面常驻这几块方砖(不用写,家长端能关,不占按钮的名额);应用不加「接着刚才的」——今天从同一个入口聊过的,孩子进老师页后在空白板上接(新话题是一个胶囊,activity、小课堂先问接着那个还是重新开始);引用坏了(话题找不到)的按钮不出现。
39
39
 
40
40
  老师卡的按钮最多 ${TUTOR_BUTTONS_MAX} 个,每个最多 ${BUTTON_LABEL_MAX} 个字。
41
41