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
@@ -45,6 +45,7 @@ import { pageDomJs } from "../shared/page-dom.js";
45
45
  import { KID_LOG_JS } from "./kid-log.js";
46
46
  import { BACK_TINT_CSS, BACK_TINT_JS } from "./tutor-face.js";
47
47
  import { BOARD_MARK_CSS, BOARD_MARK_HAND_LEFT_CSS, BOARD_MARK_JS } from "./board-mark.js";
48
+ import { HOME_TILES_JS } from "./home-tiles.js";
48
49
  /** 内联进页面的那几份纯文件(仓库里是 .ts,npm 包里是 dist 的 .js;inline-lib 剥类型、去 export,变成能内联的普通脚本) */
49
50
  const INLINE = {
50
51
  'kid-board': new URL('../../board/presentation.ts', import.meta.url),
@@ -127,17 +128,8 @@ ${faviconLinks()}
127
128
  .c-mark .mk-t small { font-size:13px; font-weight:700; color:#2f6fd6; }
128
129
  .c-mark .mk-t b { font-size:22px; font-variant-numeric:tabular-nums; }
129
130
  .c-mark .x { position:absolute; right:4px; top:4px; width:40px; height:40px; border-radius:50%; border:none; background:transparent; font-size:22px; line-height:1; color:var(--dim); }
130
- /* 孩子的话:答问题卡的进卡下沿的答案栏,其余的就是那一节的节头(.sh.said);语音前面一个蓝底小话筒,一行截断,点字看原话 */
131
+ /* 卡下沿的一条(拍给我看卡的「等你拍…」):孩子的话不在卡上,在左栏 */
131
132
  .kid-ans { margin:8px -16px -14px; padding:10px 16px 12px; border:0; border-top:1.5px dashed var(--tint-paper-line); border-radius:0 0 14px 14px; background:transparent; display:flex; align-items:center; gap:9px; font:inherit; font-size:18px; font-weight:500; line-height:1.5; color:var(--ink); text-align:left; cursor:pointer; }
132
- .kid-ans.wait { display:none; font-weight:400; color:var(--dim); cursor:default; }
133
- body.waiting .kid-ans.wait { display:flex; }
134
- .sh.said { gap:10px; padding-top:14px; }
135
- .sh.said::after { display:none; }
136
- .sh.said .ln { flex:1 1 12px; min-width:12px; height:1px; margin-left:6px; background:var(--line); }
137
- .sh.said .ka-t { font-size:22px; font-weight:600; letter-spacing:.5px; line-height:1.4; color:var(--ink); cursor:pointer; }
138
- .kid-ans .ka-t, .sh.said .ka-t { flex:0 1 auto; min-width:0; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
139
- .kid-ans .ka-t { flex-grow:1; }
140
- .kid-ans.open .ka-t, .sh.said.open .ka-t { white-space:normal; }
141
133
  .lc-still { width:100%; height:100%; }
142
134
  .lc-still img { width:100%; height:100%; display:block; object-fit:contain; }
143
135
  #rest { display:none; text-align:center; color:var(--dim); font-size:16px; padding:12px 0; }
@@ -275,7 +267,6 @@ ${BACK_TINT_CSS}
275
267
  .c-draw .dw-t b { font-size:20px; }
276
268
  .c-draw .dw-go { margin-left:8px; height:44px; padding:0 16px; border-radius:22px; border:1.5px solid #7a4fc9; background:#fff; color:#7a4fc9; font:inherit; font-size:16px; font-weight:600; }
277
269
  #board { flex:1; min-height:0; overflow:auto; padding:calc(env(safe-area-inset-top) + 68px) 16px 24px; display:flex; flex-direction:column; gap:12px; -webkit-overflow-scrolling:touch; scroll-behavior:smooth; }
278
- .sh .ph { display:inline-block; height:34px; width:auto; max-width:120px; border-radius:6px; object-fit:cover; border:1px solid var(--line); background:#fff; vertical-align:middle; }
279
270
  /* 输入条 */
280
271
  #bar { padding:8px 16px calc(env(safe-area-inset-bottom) + 12px); }
281
272
  #pill { display:flex; align-items:center; gap:12px; height:56px; padding:0 14px; border-radius:28px; background:#fff; border:1px solid var(--line); box-shadow:0 2px 10px #00000010; }
@@ -385,7 +376,6 @@ __BRAND_CSS__
385
376
  #lb .nav.l { left:12px; } #lb .nav.r { right:12px; }
386
377
  #lb .nav[hidden] { display:none; }
387
378
  #lb .n { position:absolute; left:0; right:0; bottom:calc(env(safe-area-inset-bottom) + 18px); text-align:center; color:#fffc; font-size:15px; z-index:2; pointer-events:none; }
388
- .sh .ph { cursor:zoom-in; }
389
379
  /* ---- 按压反馈(全局关了系统的点击高亮,这里统一补):三种手感,按下快(60ms)、松开慢(200ms)。
390
380
  圆钮与胶囊:缩到 94% + 暗一档;深色屏上的钮:缩 + 变淡;大块面:缩到 98.5% + 暗一点;行内文字:变淡。
391
381
  板书里的卡在滚动容器里,按下延迟 80ms 才显(不然一滑整屏都在闪);卡里的小钮按着时卡自己不动。交互不是皮肤,写在页面里,主题不用跟 ---- */
@@ -556,7 +546,7 @@ __VOICE_CSS__
556
546
  body.live #cam, body.live #plus, body.live #lv-mic { display:none !important; }
557
547
  /* 按着时按钮自己变蓝(voice-view 的 hold),不再盖一块 #hold 上去 */
558
548
  body.live #hold { display:none !important; }
559
- /* 左撇子镜像(cotutor.json 的 kid.hand: left,工作台切):白纸挪到右边、板在左边、按钮里头像在右头;回首页、「更多」的位置同右手(回首页浮在板的左上) */
549
+ /* 按住说话在右边(cotutor.json 的 kid.hand: left,家长端设置页切):白纸挪到右边、板在左边、按钮里头像在右头;回首页、「更多」的位置同右手(回首页浮在板的左上) */
560
550
  body.live.hand-left #board { padding-left:calc(env(safe-area-inset-left) + 48px); padding-right:calc(env(safe-area-inset-right) + 400px); }
561
551
  body.live.hand-left #stage { left:calc(env(safe-area-inset-left) + 16px); right:calc(env(safe-area-inset-right) + 376px); }
562
552
  body.live.hand-left #lv { left:auto; right:0; width:calc(env(safe-area-inset-right) + 360px); padding-top:calc(env(safe-area-inset-top) + 80px); padding-left:24px; padding-right:calc(env(safe-area-inset-right) + 24px); box-shadow:-1px 0 0 #efece4, -6px 0 24px #46371e0d; }
@@ -827,6 +817,11 @@ ${BOARD_MARK_JS}
827
817
  h('span', { class: 'lm' }, h('small', { class: 'tg' }, '小课堂'), h('span', { class: 'btx' }, b.label), b.ms ? h('small', { class: 'ln' }, lcLen(b.ms)) : null));
828
818
  return h('button', { type: 'button', class: 'bt bt-' + b.kind, 'data-bid': String(b.id), on }, h('span', { class: 'bi', html: ICON[BUTTON_ICON[b.kind]] || ICON.start }), h('span', { class: 'btx' }, b.label));
829
819
  };
820
+ // ---- 常驻的三块方砖(预习、检查作业、复习;服务端照老师家里的 activity 文件给,src/ui/kid/home-tiles.ts):新话题下面一行;点了和 activity 按钮一样 ----
821
+ ${HOME_TILES_JS}
822
+ const tileButton = (t, x) => h('button', { type: 'button', class: 'bt bt-tile', 'data-tile': x.id, style: '--sc:' + colorOf(t), on: { click: () => pickButton(t, { id: x.id, kind: 'start', label: x.label, resume: x.resume }) } },
823
+ h('span', { class: 'ta', html: tileArt(t.mark, x.id) }), h('span', { class: 'btx' }, x.label));
824
+ const tilesRow = (t, tiles) => (Array.isArray(tiles) && tiles.length ? h('div', { class: 'tls' }, ...tiles.map((x) => tileButton(t, x))) : null);
830
825
  /** 挨着的几个小课堂收进一格(平板上两张海报并排,手机上一张一行) */
831
826
  const buttonRuns = (t, buttons) => {
832
827
  const out = [];
@@ -939,7 +934,8 @@ ${BOARD_MARK_JS}
939
934
  const bt = pc ? e.target.closest('.bt') : null;
940
935
  if (!bt) return;
941
936
  const bid = bt.dataset.bid;
942
- if (bid !== undefined) pvTell({ act: 'button', tutor: pc.dataset.tutor, id: bid === 'new' || bid === 'draft' ? bid : Number(bid) });
937
+ if (bt.dataset.tile) pvTell({ act: 'tile', tutor: pc.dataset.tutor, id: bt.dataset.tile });
938
+ else if (bid !== undefined) pvTell({ act: 'button', tutor: pc.dataset.tutor, id: bid === 'new' || bid === 'draft' ? bid : Number(bid) });
943
939
  else pvTell({ act: 'extra', tutor: pc.dataset.tutor, kind: bt.classList.contains('bt-x-talk') ? 'talk' : 'dictation' });
944
940
  }, true);
945
941
  }
@@ -953,7 +949,8 @@ ${BOARD_MARK_JS}
953
949
  const { t, card } = list.find((x) => x.t.name === n);
954
950
  const buttons = pvButtons(n, Array.isArray(card.props.buttons) ? card.props.buttons : []);
955
951
  const owned = (Array.isArray(H.cards) ? H.cards : []).filter((c) => c.kind !== 'tutor' && c.owner === n);
956
- const sig = JSON.stringify([t.avatar, t.subject, t.display, buttons, owned, t.extras, S.talkLast && (S.talkLast.last ? S.talkLast.last.id : 'on'), H.home]);
952
+ const tiles = Array.isArray(card.props.tiles) ? card.props.tiles : [];
953
+ const sig = JSON.stringify([t.avatar, t.subject, t.display, buttons, tiles, owned, t.extras, S.talkLast && (S.talkLast.last ? S.talkLast.last.id : 'on'), H.home]);
957
954
  let el = K.els.get(n);
958
955
  if (!el) {
959
956
  const pc = h('div', { class: 'c c-tutor pc', 'data-tutor': n, on: { click: (e) => { if (e.target.closest && e.target.closest('a[href]')) stashFront(pc); if (!pc.classList.contains('front')) pickTutor(n); } } });
@@ -966,7 +963,8 @@ ${BOARD_MARK_JS}
966
963
  const others = owned.filter((c) => c.kind !== 'dictation');
967
964
  el.replaceChildren(...[
968
965
  h('div', { class: 'pc-hd' }, deal ? avatarEl(t, 'draw', 0.1 + slot * 0.12) : avatarEl(t), h('span', { class: 'nm' }, t.display)),
969
- h('div', { class: 'kc' }, h('div', { class: 'bts' }, ...buttonRuns(t, buttons)), extras.length ? h('div', { class: 'bts extras' }, ...extras) : null),
966
+ // 新话题(第一颗)、方砖一行、家长排的按钮
967
+ h('div', { class: 'kc' }, h('div', { class: 'bts' }, ...((runs) => [runs[0], tilesRow(t, tiles), ...runs.slice(1)])(buttonRuns(t, buttons))), extras.length ? h('div', { class: 'bts extras' }, ...extras) : null),
970
968
  others.length ? h('div', { class: 'hcards kcards' }, ...others.map((c, i) => renderCard(c, i, null, false))) : null,
971
969
  h('span', { class: 'pc-ix' }, avatarEl(t)),
972
970
  h('span', { class: 'veil' }),
@@ -1190,15 +1188,12 @@ ${BOARD_MARK_JS}
1190
1188
  const box = (cls, ...kids) => h('div', { class: 'c c-' + cls, 'data-card': idx, 'data-tint': tintFor(c), 'data-look': lookFor(c), on: board ? { click: () => openStage(secIdx, idx) } : {} }, ...kids, lk);
1191
1189
  switch (c.kind) {
1192
1190
  case 'text': {
1193
- if (isHeading(c)) return board && liveSec(secIdx) ? liveHeadEl(p.title || '', secIdx, idx) : h('div', { class: 'heading', 'data-card': idx }, p.title || '');
1191
+ if (isHeading(c)) return board ? liveHeadEl(p.title || '', secIdx, idx) : h('div', { class: 'heading', 'data-card': idx }, p.title || '');
1194
1192
  const emoji = c.look && c.look.emoji ? c.look.emoji + ' ' : '';
1195
1193
  const title = p.title ? h('div', { class: 'ct' }, emoji + p.title) : null;
1196
1194
  const body = p.text ? h('div', { class: 'cb' }, p.text) : null;
1197
- // 提问卡:末句那一问(解析器补的),和选择题的问题一个字重;问题卡下沿是孩子的答案栏
1198
- const a = board ? ansFor(secIdx, idx) : null;
1199
- const el = box(isAskCard(c) ? 'text c-ask' : 'text', title, body, a ? ansEl(a) : null);
1200
- if (a) el.dataset.ans = ansKey(a);
1201
- return el;
1195
+ // 提问卡:末句那一问(解析器补的),和选择题的问题一个字重;孩子的答在左栏,卡上没有答案栏
1196
+ return box(isAskCard(c) ? 'text c-ask' : 'text', title, body);
1202
1197
  }
1203
1198
  case 'read':
1204
1199
  return box('read', ...(p.segments || []).map((seg, k) => h('div', { class: 'rd', on: { click: (e) => { e.stopPropagation(); readSegment(e.currentTarget, c, k); } } }, seg)));
@@ -1241,10 +1236,10 @@ ${BOARD_MARK_JS}
1241
1236
  return box('canvas', h('div', { class: 'cp' }, p.prompt || '画一画'), src ? h('div', { class: 'th' + (base ? ' base' : '') }, h('img', { src, alt: '', loading: 'lazy' }), h('span', { class: 'pl' }, n ? '画了 ' + n + ' 笔' : '点开画一画 ✎')) : h('div', { class: 'cb' }, n ? '已经画了 ' + n + ' 笔,点开接着画' : '点开画一画 ✎'));
1242
1237
  }
1243
1238
  case 'photo': {
1244
- // 拍给我看(cards/photo/card.md):相机 + 要拍什么;下沿是答案栏:等你拍(拍照、相册)或拍来的照片。点卡不开舞台,点下沿的按钮拍
1239
+ // 拍给我看(cards/photo/card.md):相机 + 要拍什么。还没拍:最后一节时下沿「等你拍…」带拍照、相册;拍了:照片放大在卡里,配的话在照片下面(2026-11-04)。点卡不开舞台
1245
1240
  const a = board ? ansFor(secIdx, idx) : null;
1246
- const el = h('div', { class: 'c c-photo', 'data-card': idx, 'data-tint': tintFor(c), 'data-look': lookFor(c) },
1247
- h('div', { class: 'pc-row' }, h('span', { class: 'pc-ic', html: ICON.camera }), h('div', { class: 'pc-t' }, p.text || '')), a ? ansEl(a) : null);
1241
+ const el = h('div', { class: 'c c-photo' + (a && !a.snap ? ' got' : ''), 'data-card': idx, 'data-tint': tintFor(c), 'data-look': lookFor(c) },
1242
+ h('div', { class: 'pc-row' }, h('span', { class: 'pc-ic', html: ICON.camera }), h('div', { class: 'pc-t' }, p.text || '')), a ? (a.snap ? snapEl() : photoAnsEl(a)) : null);
1248
1243
  if (a) el.dataset.ans = ansKey(a);
1249
1244
  return el;
1250
1245
  }
@@ -1441,9 +1436,6 @@ __WORD_DRAW_JS__
1441
1436
  const up = (e) => { pts.delete(e.pointerId); start = pts.size ? { scale, tx, ty, d: pts.size === 2 ? dist() : 0, x: [...pts.values()][0].x, y: [...pts.values()][0].y } : null; };
1442
1437
  el.addEventListener('pointerup', up); el.addEventListener('pointercancel', up);
1443
1438
  };
1444
- // 节头:时间 · 节名;孩子问这节时拍的照片(R5)缩略图跟在后面,点小图看大图(不是再听)
1445
- const headPhotos = (s) => (s.photos || []).map((p, k) => h('img', { class: 'ph', src: '/api/kid/image?p=' + encodeURIComponent(p), alt: '', loading: 'lazy', on: { click: (e) => { e.stopPropagation(); openLb(s.photos, k); } } }));
1446
- const sectionHead = (s) => h('div', { class: 'sh' }, (s.at ? clock(s.at) + ' · ' : '') + sectionTitle(s), ...headPhotos(s));
1447
1439
  const rowEl = (n, ...kids) => h('div', { class: 'row', style: 'grid-template-columns:repeat(' + n + ',minmax(0,1fr))' }, ...kids);
1448
1440
  // ---- 排版:每张卡放进量具量一次,半宽放得下(fitsHalf)就半宽,相邻两张半宽的并一行(rowsFor) ----
1449
1441
  // 量具在 body 下、屏幕外:板书藏着(首页开着)时也量得出;按 端 + 半宽像素 + 卡的内容缓存,看录像每次重画不再量
@@ -1594,62 +1586,54 @@ __WORD_DRAW_JS__
1594
1586
  hideLecture(); renderLecturePending(); renderBar(); renderSubtitle();
1595
1587
  } else if (m.type === 'error') { const first = S.lecture && !S.lecture.watch; hideLecture(); if (first) closeTutor(); }
1596
1588
  });
1597
- // ---- 一个话题一节:孩子端节头只留板上第一节、带照片的节与孩子单独说了话的节(他的话就是节头);孩子的话答问题卡的进卡的答案栏 ----
1598
- const headOf = (s, i) => (i === 0 || (s.photos && s.photos.length) ? sectionHead(s) : null);
1599
- const saidBody = (sd, on = {}) => [
1600
- // 答拍给我看卡拍来的照片(kidSaid 的 photos):缩在答案栏里,点开看大图;只发了照片没说话就没有字
1601
- ...(sd.photos || []).map((p, k) => h('img', { class: 'kp', src: '/api/kid/image?p=' + encodeURIComponent(p), alt: '', loading: 'lazy', on: { click: (e) => { e.stopPropagation(); openLb(sd.photos, k); } } })),
1602
- sd.text ? h('span', { class: 'ka-t', on }, sd.text) : null];
1603
- /** 点开看原话(语音的显示是去了口头禅的),再点收起 */
1604
- const toggleSaid = (sd) => (e) => { e.stopPropagation(); const el = e.currentTarget.closest('.kid-ans, .sh'); const t = el.querySelector('.ka-t'); if (!t) return; const open = el.classList.toggle('open'); t.textContent = open ? sd.full : sd.text; };
1605
- /** 孩子单独说的那句当节头:话 —— 线 —— [时间] [照片] 再听;meta = false 只要话和线(刚发出去挂在板尾的、小课堂卡下面的) */
1606
- const saidHead = (s, sd, meta, first) => h('div', { class: 'sh said' }, ...saidBody(sd, { click: toggleSaid(sd) }), h('span', { class: 'ln' }), ...(meta ? [first && s.at ? clock(s.at) : null, ...headPhotos(s)] : []));
1607
- /** 一节开头:孩子没单独说话 → 原来的节头(只留第一节与带照片的);说了 → 他的话就是节头;看完小课堂后的第一问写在小课堂卡下面 */
1589
+ // ---- 一个话题一节(板上只有实时一种画法,2026-11-04;以前的话题也这样画):节头是脑写的那张「# 标题」卡,孩子的话不当节头(在左栏 #lv-kid);
1590
+ // 答拍给我看卡的照片与话放进那张卡(photoAnsEl),别的卡没有答案栏 ----
1608
1591
  /** 孩子从纸上交的画:节前一张——图(点开看大图)、几笔、「接着画」打开这个话题的纸(只读、家长端、录像里不出) */
1609
1592
  const drawHead = (s) => !s.drawing ? null : h('div', { class: 'c c-draw' },
1610
1593
  h('img', { class: 'dw-th', src: '/api/kid/image?p=' + encodeURIComponent(s.drawing.image), alt: '', loading: 'lazy', on: { click: (e) => { e.stopPropagation(); openLb([s.drawing.image], 0); } } }),
1611
1594
  h('div', { class: 'dw-t' }, h('small', {}, '我画的'), h('b', {}, '画了 ' + s.drawing.strokes + ' 笔')),
1612
1595
  !S.readonly && !REEL ? h('button', { class: 'dw-go', type: 'button', on: { click: (e) => { e.stopPropagation(); openPaper(); } } }, '接着画') : null);
1613
- const headEls = (s, i) => [...headEls0(s, i), drawHead(s)];
1614
- const headEls0 = (s, i) => {
1615
- // 实时:节头是脑写的那张卡(liveHeadEl,随卡画);板上第一节没写节头,只挂时间。孩子的话不当节头(在左栏 #lv-kid)
1616
- if (s.live) return [i === 0 && !s.head && s.at ? h('div', { class: 'sh lh' }, h('span', { class: 'ln' }), h('span', { class: 'tm' }, clock(s.at))) : null, ...lectureHead(s)];
1617
- const sd = s.said && !s.saidOn ? s.said : null;
1618
- const lec = lectureHead(s);
1619
- if (!sd) return [headOf(s, i), ...lec];
1620
- if (!lec.some(Boolean)) return [saidHead(s, sd, true, i === 0)];
1621
- return [headOf(s, i), ...lec, saidHead(s, sd, false)];
1622
- };
1623
- /** 实时的一节:节头脑写、孩子的话不进答案栏、卡和讲稿不同步 */
1624
- const liveSec = (i) => Boolean(i !== null && S.sections[i] && S.sections[i].live);
1596
+ /** 节头是脑写的那张卡(liveHeadEl,随卡画);板上第一节没写节头,只挂时间 */
1597
+ const headEls = (s, i) => [i === 0 && !s.head && s.at ? h('div', { class: 'sh lh' }, h('span', { class: 'ln' }), h('span', { class: 'tm' }, clock(s.at))) : null, ...lectureHead(s), drawHead(s)];
1625
1598
  /** 脑写的节头(只有一行「# 标题」的文字卡):一行摘要 —— 线 —— 板上第一节带时间;一轮中间的也照这样断成新一节 */
1626
1599
  const liveHeadEl = (title, i, k) => { const s = S.sections[i]; return h('div', { class: 'sh lh', 'data-card': k }, h('span', { class: 't' }, title), h('span', { class: 'ln' }), i === 0 && k === 0 && s && s.at ? h('span', { class: 'tm' }, clock(s.at)) : null); };
1627
- /** 实时的卡落下:讲稿里标这张卡的 [词] 都画上(不等念到),田字格、单词卡写一遍(这一页只写一次);animate = false 是重画、回来看,只画不写 */
1600
+ /** 卡落下:讲稿里标这张卡的 [词] 都画上(不等念到、卡和讲稿不同步),田字格、单词卡写一遍(这一页只写一次);animate = false 是重画、回来看,只画不写 */
1628
1601
  const liveLand = (i, k, animate) => {
1629
- const s = S.sections[i]; if (!s || !s.live) return;
1602
+ const s = S.sections[i]; if (!s) return;
1630
1603
  for (const l of s.lines) for (const m of l.marks) if (m.card === k) applyMark(i, m, animate);
1631
1604
  const el = animate ? $('#board').querySelector('[data-sec="' + i + '"] [data-card="' + k + '"]') : null;
1632
1605
  if (el && (el.classList.contains('c-tianzige') || el.classList.contains('c-word')) && !el.dataset.wrote) { el.dataset.wrote = '1'; if (el.classList.contains('c-word')) wordWrite(el.querySelector('.wg'), false, null); else tianzigePlay(el); }
1633
1606
  };
1634
- /** 第 j 节第 k 张卡的答案栏:孩子答了就是他的话;最后一节的问题卡还没人答、老师在等,写「等你说…」(body.waiting 时才露) */
1607
+ /**
1608
+ * 拍给我看卡上孩子答的(只有它有):答了 = 他拍来的照片与配的话(没拍、直接说的那句也算);刚发出去、老师还没写出下一节的也先放上;
1609
+ * 最后一节的这张还没人答 = 等你拍(拍照、相册;拍照键只在这里,没有它这张卡点不动)。别的卡没有答案栏,孩子的话在左栏
1610
+ */
1635
1611
  const ansFor = (j, k) => {
1636
1612
  const sec = S.sections[j];
1637
- if (!sec || sec.live) return null;
1613
+ if (!sec || !sec.cards[k] || sec.cards[k].kind !== 'photo') return null;
1638
1614
  for (const s of S.sections) if (s.said && s.saidOn && s.saidOn.job === sec.job && s.saidOn.card === k) return s.said;
1639
1615
  if (S.pendSaid && S.pendSaid.on && S.pendSaid.on.job === sec.job && S.pendSaid.on.card === k) return S.pendSaid.said;
1640
- if ((!S.readonly || REEL) && !S.pendSaid && j === S.sections.length - 1 && askCard(sec) === k) return sec.cards[k].kind === 'photo' ? { wait: true, snap: true } : { wait: true };
1616
+ if ((!S.readonly || REEL) && !S.pendSaid && j === S.sections.length - 1 && askCard(sec) === k) return { snap: true };
1641
1617
  return null;
1642
1618
  };
1643
- /** 答案栏画的是什么(data-ans):变了才重画那张卡 */
1644
- const ansKey = (a) => (a.snap ? 'snap' : a.wait ? 'wait' : (a.text || '') + '|' + (a.photos || []).join(','));
1619
+ /** 卡上画的是什么(data-ans):变了才重画那张卡 */
1620
+ const ansKey = (a) => (a.snap ? 'snap' : (a.text || '') + '|' + (a.photos || []).join(','));
1645
1621
  /** 拍给我看卡上的拍照 / 相册:一个藏起来的 file input,和输入条的相机同一套(wireFile) */
1646
1622
  const snapBtn = (camera) => {
1647
1623
  const f = h('input', camera ? { type: 'file', accept: 'image/*', capture: 'environment' } : { type: 'file', accept: 'image/*', multiple: '' });
1648
1624
  wireFile(f);
1649
1625
  return h('label', { class: 'pc-go' + (camera ? '' : ' alt'), on: { click: (e) => e.stopPropagation() } }, h('span', { html: camera ? ICON.camera : ICON.album }), camera ? '拍照' : '相册', f);
1650
1626
  };
1651
- const ansEl = (a) => (a.snap ? h('div', { class: 'kid-ans snap' }, h('span', { class: 'ka-w' }, '等你拍…'), snapBtn(true), snapBtn(false))
1652
- : a.wait ? h('div', { class: 'kid-ans wait' }, '等你说…') : h('button', { type: 'button', class: 'kid-ans', on: { click: toggleSaid(a) } }, ...saidBody(a)));
1627
+ /** 等你拍:卡下沿一条,拍照、相册 */
1628
+ const snapEl = () => h('div', { class: 'kid-ans snap' }, h('span', { class: 'ka-w' }, '等你拍…'), snapBtn(true), snapBtn(false));
1629
+ /** 拍来的:照片放大在卡里(一张整宽、整张看得见;几张两列),点开看大图;配的话在照片下面,像图注;点话看原话(语音的显示去了口头禅) */
1630
+ const photoAnsEl = (a) => {
1631
+ const ps = a.photos || [];
1632
+ const words = a.text ? h('div', { class: 'pc-said', on: { click: (e) => { e.stopPropagation(); const el = e.currentTarget; const open = el.classList.toggle('open'); el.textContent = open ? a.full : a.text; } } }, a.text) : null;
1633
+ return h('div', { class: 'pc-ans' },
1634
+ ps.length ? h('div', { class: 'pc-shots' + (ps.length === 1 ? ' one' : '') }, ...ps.map((p, k) => h('img', { src: '/api/kid/image?p=' + encodeURIComponent(p), alt: '', loading: 'lazy', on: { click: (e) => { e.stopPropagation(); openLb(ps, k); } } }))) : null,
1635
+ words);
1636
+ };
1653
1637
  /** 拿起相机老师就停(同点卡开舞台);拍完 / 选完进发照片屏。相册最多 4 张,多的只取前 4 张。输入条、加号面板、拍给我看卡都走这里 */
1654
1638
  const wireFile = (f) => {
1655
1639
  f.addEventListener('change', () => { $('#sheet').classList.remove('on'); const files = Array.from(f.files || []).slice(0, f.multiple ? PHOTO_ALBUM_MAX : 1); f.value = ''; psOpen(files); });
@@ -1671,22 +1655,11 @@ __WORD_DRAW_JS__
1671
1655
  if ((el.dataset.ans || '') !== (a ? ansKey(a) : '')) repaintCard(j, k);
1672
1656
  }
1673
1657
  };
1674
- /** 刚发出去、老师还没写出这一节:不答卡的那句先挂在板尾 */
1675
- const syncPendSay = () => {
1676
- const old = $('#board > .sh.said.pend');
1677
- const p = S.pendSaid && !S.pendSaid.on && !S.pendSaid.live && !LV.on ? S.pendSaid : null;
1678
- if (old && (!p || old.dataset.said !== p.job)) old.remove();
1679
- if (!p || (old && old.dataset.said === p.job)) return;
1680
- const el = saidHead(null, p.said, false); el.classList.add('pend'); el.dataset.said = p.job;
1681
- $('#board').append(el);
1682
- };
1683
- const renderSection = (s, i) => h('div', { class: 'sec' + (s.live && s.cont ? ' cont' : ''), 'data-sec': i, 'data-job': s.job }, ...headEls(s, i), ...rowEls(s, s.cards.map((c, k) => renderCard(c, k, i, false))).map((r) => r.el));
1658
+ const renderSection = (s, i) => h('div', { class: 'sec' + (s.cont ? ' cont' : ''), 'data-sec': i, 'data-job': s.job }, ...headEls(s, i), ...rowEls(s, s.cards.map((c, k) => renderCard(c, k, i, false))).map((r) => r.el));
1684
1659
  /**
1685
- * 老师还在说(2026-09-13,一拍一就绪):第一拍就绪前板上什么都不出(头像在想,见 renderSubtitle);就绪了就把这条转成 S.sections 里的一节(live)开播,
1686
- * 之后每次 poll 只铺新就绪的拍的卡(一行一张,没有排版)、把 lines 换成最新的(配音名填进来),播到头等着(thinking)的就接上。
1687
- * 铺卡在拍就绪时而不是围栏闭合时:铺的时候声音已齐,不会先素后彩地闪
1660
+ * 老师还在写:卡闭合了就铺、有句就出节(讲稿嘴在念,不等拍),转成 S.sections 里的一节(partial);之后每次 poll 只铺新闭合的卡、把 lines 换成最新的;
1661
+ * 写完了 finalizeLive 换成正式的一节
1688
1662
  */
1689
- const liveCardsOf = (e) => { if (e.live) return e.cards.length; const bs = beatsOf(e).slice(0, e.ready || 0); let n = 0; for (const b of bs) if (b.card !== null) n = Math.max(n, b.card + 1); return n; };
1690
1663
  /** 一张卡落到行里:和前面的卡并一行(半宽 + 半宽,或明写的并排)就进那一行,否则新起一行;只看这张和前面的,后面的卡不影响前面的行 */
1691
1664
  // ---- 跟到底:新露出来的卡在板的最后 → 板滚到底(连底下留的空一起露);之后这一节再长高(问题卡的「等你说…」、图片载完、排版量完),
1692
1665
  // 只要孩子没自己往上翻,就再滚到底。原来是 scrollIntoView nearest:只把卡的下沿贴到可视区下沿,之后长高的那截就在屏幕外
@@ -1735,14 +1708,14 @@ __WORD_DRAW_JS__
1735
1708
  const renderLive = (e) => {
1736
1709
  if (!S.partial || S.partial.job !== e.job) { if (S.partial) S.partial.el.remove(); const idx = S.sections.length; S.partial = { job: e.job, idx, live: false, shown: 0, el: h('div', { class: 'sec', 'data-sec': idx, 'data-job': e.job }, ...headEls(e, idx)) }; $('#board').append(S.partial.el); }
1737
1710
  const P = S.partial, idx = P.idx;
1738
- P.el.classList.toggle('cont', Boolean(e.live && e.cont));
1739
- // 不是实时的一轮:一拍就绪才铺;实时的一轮:卡闭合了就铺、有句就出节(讲稿嘴在念,不等拍)
1740
- if (!(e.ready > 0) && !(e.live && (e.cards.length || e.lines.length))) return;
1711
+ P.el.classList.toggle('cont', Boolean(e.cont));
1712
+ // 卡闭合了就铺、有句就出节(讲稿嘴在念,不等拍)
1713
+ if (!(e.cards.length || e.lines.length)) return;
1741
1714
  const sec = { ...e, partial: true };
1742
1715
  const wasLive = P.live;
1743
1716
  if (!wasLive) { P.live = true; S.played.add(e.job); S.sections.push(sec); }
1744
1717
  else S.sections[idx] = sec;
1745
- const n = liveCardsOf(sec);
1718
+ const n = sec.cards.length;
1746
1719
  const placed = n > P.shown;
1747
1720
  for (; P.shown < n; P.shown++) { follow(placeCard(P, sec, P.shown, idx)); liveLand(idx, P.shown, true); }
1748
1721
  dispatch({ type: wasLive ? 'liveBeat' : 'liveStart', section: idx });
@@ -1755,12 +1728,12 @@ __WORD_DRAW_JS__
1755
1728
  const sec = { ...e };
1756
1729
  S.sections[idx] = sec;
1757
1730
  const el = P.el;
1758
- el.classList.toggle('cont', Boolean(sec.live && sec.cont));
1731
+ el.classList.toggle('cont', Boolean(sec.cont));
1759
1732
  for (let k = 0; k < sec.cards.length; k++) {
1760
1733
  const old = k < P.shown ? el.querySelector('[data-card="' + k + '"]') : null;
1761
1734
  if (old) swapCard(old, renderCard(sec.cards[k], k, idx, false));
1762
1735
  else placeCard(P, sec, k, idx);
1763
- // 实时:换了样子的卡标注补画,新铺的照落下画、写
1736
+ // 换了样子的卡标注补画,新铺的照落下画、写
1764
1737
  liveLand(idx, k, !old);
1765
1738
  }
1766
1739
  dispatch({ type: 'liveFinal', section: idx });
@@ -1795,7 +1768,6 @@ __WORD_DRAW_JS__
1795
1768
  if (!el) return;
1796
1769
  el.classList.add('now');
1797
1770
  };
1798
- const showNow = () => setNow(S.state.section, nowCard(S.sections, S.state));
1799
1771
  /** 换端(转屏)或窗口变了:端或半宽变了就整节重排(卡重新量),标注画齐,选中态照旧 */
1800
1772
  const relayout = () => {
1801
1773
  const d = debug.get('device') || deviceFor(innerWidth, innerHeight);
@@ -1805,7 +1777,7 @@ __WORD_DRAW_JS__
1805
1777
  if (d !== S.device || hw !== S.layHalf) {
1806
1778
  S.device = d; S.layHalf = hw;
1807
1779
  S.sections.forEach((s, i) => { const old = $('#board').querySelector('[data-sec="' + i + '"]'); if (old && !(S.partial && S.partial.el === old)) { old.replaceWith(renderSection(s, i)); paintAll(i); } });
1808
- if (S.stage) setNow(S.stage.section, S.stage.card); else if (!LV.on) showNow();
1780
+ if (S.stage) setNow(S.stage.section, S.stage.card);
1809
1781
  } else for (const span of document.querySelectorAll('#board .mk[data-pen]')) { const card = span.closest('.c'); if (card && LINE_PENS.includes(span.dataset.pen)) drawPen(span, card, span.dataset.pen, false); }
1810
1782
  };
1811
1783
  let relayoutTimer = null;
@@ -2037,7 +2009,7 @@ __WORD_DRAW_JS__
2037
2009
  const closeStage = () => { if (S.stage && S.sections[S.stage.section]) playRec({ k: 'stage', job: S.sections[S.stage.section].job, card: S.stage.card, open: false });
2038
2010
  // 单词卡舞台:自动那一遍停下,慢念也停
2039
2011
  for (const v of document.querySelectorAll('#st-body video')) { try { v.pause(); } catch {} }
2040
- wordRun++; if (S.stage && !S.reel && S.sections[S.stage.section] && (S.sections[S.stage.section].cards[S.stage.card] || {}).kind === 'word') silence(); S.stage = null; frame.src = 'about:blank'; canvasClosed(); $('#stage').classList.remove('on'); renderSubtitle(); if (LV.on) setNow(null, null); else showNow(); };
2012
+ wordRun++; if (S.stage && !S.reel && S.sections[S.stage.section] && (S.sections[S.stage.section].cards[S.stage.card] || {}).kind === 'word') silence(); S.stage = null; frame.src = 'about:blank'; canvasClosed(); $('#stage').classList.remove('on'); renderSubtitle(); setNow(null, null); };
2041
2013
  $('#st-x').innerHTML = ICON.close;
2042
2014
  // 孩子关:讲稿交给小课堂卡、还没放完的,关了接着念
2043
2015
  const closeByKid = () => { const d = S.stage; const card = d && S.sections[d.section] ? S.sections[d.section].cards[d.card] : null; closeStage(); if (d && d.delegate && card && (!isHeavy(card) || card.kind === 'lecture')) resumeAfter(); };
@@ -2127,7 +2099,8 @@ __WORD_DRAW_JS__
2127
2099
  document.body.classList.toggle('speaking', status === 'mouth');
2128
2100
  if (LV.was === 'mouth' && (LV.on ? status !== 'mouth' : status === 'waiting' || status === 'done') && !S.pending) liveNod();
2129
2101
  LV.was = status;
2130
- if (!REEL) liveCaption();
2102
+ // 录像里孩子的屏幕:老师的字幕由 reelLiveCap 照那一刻画,孩子刚说的那句照孩子端一样写在左栏(板上不挂孩子的话)
2103
+ if (!REEL) liveCaption(); else lvKid();
2131
2104
  voiceSync();
2132
2105
  };
2133
2106
 
@@ -2140,7 +2113,6 @@ __WORD_DRAW_JS__
2140
2113
  switch (f.kind) {
2141
2114
  case 'stop': silence(); break;
2142
2115
  case 'render': renderSubtitle(); break;
2143
- case 'showNow': if (!LV.on) showNow(); break;
2144
2116
  case 'paint': paintAll(f.section); break;
2145
2117
  case 'scrollLast': { pin.on = false; const last = $('#board').querySelector('[data-sec="' + (S.sections.length - 1) + '"] .c'); if (last) last.scrollIntoView({ block: 'start', behavior: 'instant' }); break; }
2146
2118
  }
@@ -2230,9 +2202,7 @@ __WORD_DRAW_JS__
2230
2202
  const loadDay = async (silent) => {
2231
2203
  if (!S.tutor || S.reel) return;
2232
2204
  try {
2233
- // 今天、选了话题:带上它,服务端把 resume 那个预热进程换成这个话题的
2234
- const warmHint = !S.hist && S.thread ? '?thread=' + encodeURIComponent(S.thread) : '';
2235
- const d = await api('GET', CONV + S.tutor.name + '/' + (S.hist || 'today') + warmHint);
2205
+ const d = await api('GET', CONV + S.tutor.name + '/' + (S.hist || 'today'));
2236
2206
  setOffline(false);
2237
2207
  S.day = d;
2238
2208
  // 话题:没选过就是当前(末条所在)的;板书只铺这个话题的节。新话题空白态什么都不铺,发出第一句才有话题
@@ -2255,21 +2225,21 @@ __WORD_DRAW_JS__
2255
2225
  const el = S.partial.el; S.partial = null;
2256
2226
  el.replaceWith(renderSection(e, idx));
2257
2227
  } else $('#board').append(renderSection(e, idx));
2258
- // 实时的一节整节到了(没流过):标注都画上;不是回来看(silent)就当刚落下,田字格写一遍
2259
- if (e.live) e.cards.forEach((_, k) => liveLand(idx, k, !silent));
2228
+ // 一节整节到了(没流过):标注都画上;不是回来看(silent)就当刚落下,田字格写一遍
2229
+ e.cards.forEach((_, k) => liveLand(idx, k, !silent));
2260
2230
  }
2261
2231
  // 这条既不 pending 也没定稿(运行出错了):撤掉;live 的那节也撤(孩子端出错的运行不出现)
2262
2232
  if (S.partial && !entries.some((e) => e.job === S.partial.job)) { const P = S.partial; S.partial = null; P.el.remove(); if (P.live) { S.sections.splice(P.idx, 1); dispatch({ type: 'liveDropped' }); } }
2263
2233
  // 服务端的状态是真相(别的设备上选的、重开页面):没在舞台里改着的卡照它画;props 也跟(小课堂卡的 ready、起止是服务端下发时现读的)
2264
2234
  entries.forEach((e) => { const i = S.sections.findIndex((x) => x.job === e.job); if (i < 0 || fresh.includes(i)) return; e.cards.forEach((c, idx) => { const mine = S.sections[i].cards[idx]; if (!mine || (S.stage && S.stage.section === i && S.stage.card === idx)) return; const ds = JSON.stringify(mine.state) !== JSON.stringify(c.state), dp = JSON.stringify(mine.props) !== JSON.stringify(c.props); if (ds || dp) { mine.state = c.state; mine.props = c.props; repaintCard(i, idx); } }); });
2265
2235
  if (fresh.length && S.stage && !S.stage.paper) closeStage();
2266
- // 孩子的话:刚发出去、这一节还没出来的先挂上;答案栏对一遍
2236
+ // 孩子的话:刚发出去、这一节还没出来的记着(左栏照它写;答拍给我看卡的先放进那张卡)
2267
2237
  {
2268
2238
  const lastM = mine[mine.length - 1];
2269
2239
  const sd = lastM && lastM.pending ? kidSaid(mine).get(lastM.job) : null;
2270
2240
  const shown = lastM && (S.sections.some((s) => s.job === lastM.job) || (S.partial && S.partial.job === lastM.job));
2271
- S.pendSaid = sd && !shown ? { job: lastM.job, said: sd.said, on: sd.on, live: Boolean(lastM.live) } : null;
2272
- syncPendSay(); syncAnswers();
2241
+ S.pendSaid = sd && !shown ? { job: lastM.job, said: sd.said, on: sd.on } : null;
2242
+ syncAnswers();
2273
2243
  }
2274
2244
  const stillPending = Boolean(d.pending) || d.messages.some((m) => m.pending);
2275
2245
  S.pending = stillPending;
@@ -2452,7 +2422,7 @@ __WORD_DRAW_JS__
2452
2422
  const lastN = f.notes[f.notes.length - 1];
2453
2423
  const sd = lastN && !shown.some((fs) => fs.job === lastN.job) ? R.said.get(lastN.job) : null;
2454
2424
  const pk = sd ? lastN.job : '';
2455
- if (pk !== R.pend || R.rebuilt) { R.pend = pk; S.pendSaid = sd ? { job: lastN.job, said: sd.said, on: sd.on } : null; syncPendSay(); syncAnswers(); }
2425
+ if (pk !== R.pend || R.rebuilt) { R.pend = pk; S.pendSaid = sd ? { job: lastN.job, said: sd.said, on: sd.on } : null; syncAnswers(); }
2456
2426
  let fresh = null;
2457
2427
  shown.forEach((fs, i) => {
2458
2428
  const el = $('#board').querySelector(':scope > .sec[data-sec="' + i + '"]'); if (!el) return;
@@ -2470,16 +2440,11 @@ __WORD_DRAW_JS__
2470
2440
  for (const l of sec.lines) for (const m of l.marks) if (m.card === k) applyMark(i, m, false);
2471
2441
  }
2472
2442
  });
2473
- // 念到哪张亮哪张
2474
- const at = f.saying || f.last;
2475
- const i = at ? S.sections.findIndex((x) => x.job === at.job) : -1;
2476
- const nk = i >= 0 ? i + '/' + at.line : '';
2477
- if (nk !== R.now) { R.now = nk; if (i >= 0 && !S.sections[i].live) { setNow(i, nowCard(S.sections, { section: i, line: at.line, status: 'playing' })); const el = $('#board .c.now'); if (el) fresh = el; } }
2478
2443
  // 跟着最新的走:板上看得见的多了(新的一节、刚发出去的话、露出来的卡)或刚拖过进度条,就滚到最底下那个;只是念到了上面另一张卡,滚到那张
2479
2444
  const board = $('#board');
2480
- const seen = board.querySelectorAll(':scope > .sec, :scope > .sh.said.pend, :scope > .sec .c:not(.pend)').length;
2445
+ const seen = board.querySelectorAll(':scope > .sec, :scope > .sec .c:not(.pend)').length;
2481
2446
  if (seen > R.seen || R.follow) {
2482
- const tail = [...board.querySelectorAll(':scope > .sec, :scope > .sh.said.pend')].reverse().find((el) => el.getClientRects().length);
2447
+ const tail = [...board.querySelectorAll(':scope > .sec')].reverse().find((el) => el.getClientRects().length);
2483
2448
  if (tail) tail.scrollIntoView({ block: 'end', behavior: R.follow ? 'instant' : 'smooth' });
2484
2449
  } else if (fresh) fresh.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
2485
2450
  R.seen = seen; R.follow = false;
@@ -3045,7 +3010,7 @@ __REEL_DOCK_JS__
3045
3010
  const lvKid = () => {
3046
3011
  const el = $('#lv-kid');
3047
3012
  const last = S.sections[S.sections.length - 1];
3048
- const p = S.pendSaid ? { job: S.pendSaid.job, sd: S.pendSaid.said } : last && last.live && last.said ? { job: last.job, sd: last.said } : null;
3013
+ const p = S.pendSaid ? { job: S.pendSaid.job, sd: S.pendSaid.said } : last && last.said ? { job: last.job, sd: last.said } : null;
3049
3014
  if (!p || !p.sd || !p.sd.text) { if (el.firstChild) el.replaceChildren(); delete el.dataset.k; return; }
3050
3015
  const old = LV.heard.has(p.job) || !S.pending;
3051
3016
  const key = p.job + '|' + p.sd.text;
@@ -3056,7 +3021,7 @@ __REEL_DOCK_JS__
3056
3021
  // (孩子按下时清掉、没放出来的也在),没有(以前的轮、mock 以外没念过)就不露。谁让谁在 liveStep;这里只取、放、让字幕跟着走 ----
3057
3022
  const RP = { job: null, data: null, miss: false, loading: false, el: null, timer: 0, line: -1 };
3058
3023
  /** 这一轮:板上最后一节实时的、有讲稿的 */
3059
- const replaySec = () => { for (let i = S.sections.length - 1; i >= 0; i--) { const s = S.sections[i]; if (s.live && s.lines.length) return s; } return null; };
3024
+ const replaySec = () => { for (let i = S.sections.length - 1; i >= 0; i--) { const s = S.sections[i]; if (s.lines.length) return s; } return null; };
3060
3025
  const replayUi = () => {
3061
3026
  const b = $('#lv-replay');
3062
3027
  const s = LV.on ? replaySec() : null;
@@ -7,7 +7,7 @@
7
7
  * - 老师的:头像旁紧挨着三道小弧「)))」,老师的颜色,不走、不放大,只有深浅跟老师的音量(平滑、三道依次错开一点);没声音淡到最低。
8
8
  * 10-21 Jack:有字幕在变、有声音,老师这边动画太强会把孩子的注意力从内容上拉走——所以只留这么一点,头像也不再一圈圈放大。
9
9
  * - 孩子的:竖条从按钮另一头往头像里走,高低跟麦克风音量,走到头像收进去、头像外圈亮一下;按着没声音是小点慢慢走。
10
- * 插话(一直听时孩子开口而老师还在说)两样都在,老师的淡到三成。镜像(左撇子)时方向反过来。系统「减弱动态效果」时竖条不走,只按音量变高低。
10
+ * 插话(一直听时孩子开口而老师还在说)两样都在,老师的淡到三成。镜像(按住说话在右边,kid.hand: left)时方向反过来。系统「减弱动态效果」时竖条不走,只按音量变高低。
11
11
  *
12
12
  * 要改动画:先改下面的 VOICE_TUNE(快慢、深浅、大小、颜色都在这里);样子的种类在 voice-view.ts;打开 /dev/voice 一页看全部。
13
13
  * VOICE_JS 插进页面的模板字符串:不写反斜杠、反引号、美元加花括号(AGENTS.md「坑」)。
@@ -7,7 +7,7 @@
7
7
  * - 老师的:头像旁紧挨着三道小弧「)))」,老师的颜色,不走、不放大,只有深浅跟老师的音量(平滑、三道依次错开一点);没声音淡到最低。
8
8
  * 10-21 Jack:有字幕在变、有声音,老师这边动画太强会把孩子的注意力从内容上拉走——所以只留这么一点,头像也不再一圈圈放大。
9
9
  * - 孩子的:竖条从按钮另一头往头像里走,高低跟麦克风音量,走到头像收进去、头像外圈亮一下;按着没声音是小点慢慢走。
10
- * 插话(一直听时孩子开口而老师还在说)两样都在,老师的淡到三成。镜像(左撇子)时方向反过来。系统「减弱动态效果」时竖条不走,只按音量变高低。
10
+ * 插话(一直听时孩子开口而老师还在说)两样都在,老师的淡到三成。镜像(按住说话在右边,kid.hand: left)时方向反过来。系统「减弱动态效果」时竖条不走,只按音量变高低。
11
11
  *
12
12
  * 要改动画:先改下面的 VOICE_TUNE(快慢、深浅、大小、颜色都在这里);样子的种类在 voice-view.ts;打开 /dev/voice 一页看全部。
13
13
  * VOICE_JS 插进页面的模板字符串:不写反斜杠、反引号、美元加花括号(AGENTS.md「坑」)。
@@ -45,12 +45,14 @@ export async function wordEmoji(words, opts = {}) {
45
45
  return {};
46
46
  const model = opts.model ?? EMOJI_MODEL;
47
47
  const line = qianwenLine(model, qianwenKeys(opts.env ?? process.env));
48
- if (!line)
48
+ // 指了端点的是测试与探针的假服务,不看 key
49
+ const url = opts.endpoint ?? (line && chatUrl(line.host));
50
+ if (!url)
49
51
  return {};
50
52
  try {
51
- const res = await fetch(opts.endpoint ?? chatUrl(line.host), {
53
+ const res = await fetch(url, {
52
54
  method: 'POST',
53
- headers: { authorization: `Bearer ${line.key}`, 'content-type': 'application/json' },
55
+ headers: { authorization: `Bearer ${line?.key ?? 'sk-test'}`, 'content-type': 'application/json' },
54
56
  body: JSON.stringify({ model, reasoning_effort: 'none', messages: [{ role: 'user', content: emojiPrompt(words) }] }),
55
57
  signal: AbortSignal.timeout(opts.timeoutMs ?? EMOJI_TIMEOUT_MS),
56
58
  });