cotutor 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (209) hide show
  1. package/LICENSE +9 -7
  2. package/NOTICE +3 -3
  3. package/README.md +65 -18
  4. package/avatars/chinese.webp +0 -0
  5. package/avatars/english.webp +0 -0
  6. package/avatars/math.webp +0 -0
  7. package/bin/cotutor.js +13 -3
  8. package/cards/photo/card.css +10 -3
  9. package/cards/photo/card.md +2 -2
  10. package/cards/tutor/card.css +13 -0
  11. package/cards/tutor/card.md +2 -1
  12. package/cards//346/235/277/344/271/246/346/200/216/344/271/210/345/206/231.md +1 -1
  13. package/dist/app/activity-eval.js +3 -2
  14. package/dist/app/cli/activities.d.ts +8 -1
  15. package/dist/app/cli/activities.js +42 -4
  16. package/dist/app/cli/cert.d.ts +11 -0
  17. package/dist/app/cli/cert.js +36 -9
  18. package/dist/app/cli/chalk/browser.d.ts +2 -2
  19. package/dist/app/cli/chalk/browser.js +22 -7
  20. package/dist/app/cli/chalk/main.js +3 -2
  21. package/dist/app/cli/doctor.d.ts +11 -5
  22. package/dist/app/cli/doctor.js +156 -181
  23. package/dist/app/cli/firewall.d.ts +24 -0
  24. package/dist/app/cli/firewall.js +79 -0
  25. package/dist/app/cli/init.js +8 -5
  26. package/dist/app/cli/main.js +379 -27
  27. package/dist/app/cli/migrate.d.ts +2 -11
  28. package/dist/app/cli/migrate.js +1 -59
  29. package/dist/app/cli/port-holder.d.ts +16 -0
  30. package/dist/app/cli/port-holder.js +34 -0
  31. package/dist/app/cli/port.d.ts +37 -0
  32. package/dist/app/cli/port.js +116 -0
  33. package/dist/app/cli/sandbox.d.ts +25 -0
  34. package/dist/app/cli/sandbox.js +166 -0
  35. package/dist/app/cli/serve-log.d.ts +13 -0
  36. package/dist/app/cli/serve-log.js +90 -0
  37. package/dist/app/cli/serve.d.ts +18 -4
  38. package/dist/app/cli/serve.js +78 -8
  39. package/dist/app/cli/service-windows.d.ts +50 -0
  40. package/dist/app/cli/service-windows.js +117 -0
  41. package/dist/app/cli/service.d.ts +152 -0
  42. package/dist/app/cli/service.js +443 -0
  43. package/dist/app/cli/skeleton.d.ts +1 -1
  44. package/dist/app/cli/skeleton.js +13 -13
  45. package/dist/app/cli/skills.d.ts +5 -1
  46. package/dist/app/cli/skills.js +14 -4
  47. package/dist/app/cli/tray-icons.d.ts +11 -0
  48. package/dist/app/cli/tray-icons.js +87 -0
  49. package/dist/app/cli/tray-ids.d.ts +6 -0
  50. package/dist/app/cli/tray-ids.js +6 -0
  51. package/dist/app/cli/tray-mac.d.ts +8 -0
  52. package/dist/app/cli/tray-mac.js +140 -0
  53. package/dist/app/cli/tray-run.d.ts +1 -0
  54. package/dist/app/cli/tray-run.js +42 -0
  55. package/dist/app/cli/tray-windows.d.ts +5 -0
  56. package/dist/app/cli/tray-windows.js +166 -0
  57. package/dist/app/cli/tray.d.ts +236 -0
  58. package/dist/app/cli/tray.js +670 -0
  59. package/dist/app/cli/usage.d.ts +1 -1
  60. package/dist/app/cli/usage.js +11 -6
  61. package/dist/app/dictation/route.js +3 -2
  62. package/dist/app/http/app.d.ts +5 -10
  63. package/dist/app/http/app.js +47 -98
  64. package/dist/app/http/avatars.d.ts +17 -0
  65. package/dist/app/http/avatars.js +35 -0
  66. package/dist/app/http/dev-tutor-files.js +1 -1
  67. package/dist/app/http/parent-home.d.ts +14 -0
  68. package/dist/app/http/parent-home.js +69 -6
  69. package/dist/app/http/parent-look.d.ts +2 -0
  70. package/dist/app/http/parent-look.js +151 -0
  71. package/dist/app/http/raw-view.js +3 -3
  72. package/dist/app/http/voice-preview.d.ts +4 -0
  73. package/dist/app/http/voice-preview.js +34 -0
  74. package/dist/app/machine/bailian.d.ts +0 -2
  75. package/dist/app/machine/bailian.js +5 -6
  76. package/dist/app/machine/update.d.ts +25 -0
  77. package/dist/app/machine/update.js +88 -0
  78. package/dist/app/mock/mock-live.js +1 -1
  79. package/dist/app/mock/mock.d.ts +4 -2
  80. package/dist/app/mock/mock.js +20 -12
  81. package/dist/app/replay.js +4 -4
  82. package/dist/app/skills/home-doc.js +1 -1
  83. package/dist/app/workspace.d.ts +9 -4
  84. package/dist/app/workspace.js +33 -10
  85. package/dist/app/ws/route.d.ts +8 -2
  86. package/dist/app/ws/route.js +7 -3
  87. package/dist/board/parse.d.ts +0 -2
  88. package/dist/board/parse.js +0 -6
  89. package/dist/board/presentation.d.ts +9 -15
  90. package/dist/board/presentation.js +8 -10
  91. package/dist/brain/api-tools.js +2 -1
  92. package/dist/brain/api.d.ts +12 -17
  93. package/dist/brain/api.js +16 -33
  94. package/dist/brain/index.d.ts +23 -71
  95. package/dist/brain/index.js +6 -69
  96. package/dist/brain/log.d.ts +5 -0
  97. package/dist/brain/log.js +22 -0
  98. package/dist/brain/runtime.d.ts +30 -0
  99. package/dist/brain/runtime.js +23 -0
  100. package/dist/brain/{cli/stream-json.d.ts → stream-json.d.ts} +2 -4
  101. package/dist/brain/{cli/stream-json.js → stream-json.js} +0 -4
  102. package/dist/brain/stream.d.ts +5 -5
  103. package/dist/brain/stream.js +7 -7
  104. package/dist/brain/transcript.d.ts +3 -3
  105. package/dist/brain/transcript.js +6 -6
  106. package/dist/brain/{cli/types.d.ts → types.d.ts} +3 -48
  107. package/dist/brain/types.js +14 -0
  108. package/dist/chalk/glyphs/skeleton.d.ts +3 -3
  109. package/dist/chalk/glyphs/skeleton.js +1 -1
  110. package/dist/lib/index.d.ts +1 -1
  111. package/dist/lib/index.js +1 -1
  112. package/dist/memory/diary.js +3 -3
  113. package/dist/realtime/bailian.d.ts +1 -1
  114. package/dist/realtime/bailian.js +3 -2
  115. package/dist/realtime/preview.js +1 -1
  116. package/dist/realtime/voices.d.ts +2 -0
  117. package/dist/realtime/voices.js +57 -57
  118. package/dist/runtime/activities.d.ts +13 -1
  119. package/dist/runtime/activities.js +28 -2
  120. package/dist/runtime/activity-state.js +2 -2
  121. package/dist/runtime/agent-file.d.ts +3 -3
  122. package/dist/runtime/agent-file.js +3 -3
  123. package/dist/runtime/context/tutor-rules.d.ts +2 -2
  124. package/dist/runtime/context/tutor-rules.js +3 -3
  125. package/dist/runtime/home/home.d.ts +12 -1
  126. package/dist/runtime/home/home.js +8 -1
  127. package/dist/runtime/home/store.d.ts +1 -1
  128. package/dist/runtime/home/store.js +36 -21
  129. package/dist/runtime/journal/mix.d.ts +15 -6
  130. package/dist/runtime/journal/mix.js +60 -19
  131. package/dist/runtime/journal/recorder.d.ts +58 -13
  132. package/dist/runtime/journal/recorder.js +106 -16
  133. package/dist/runtime/journal/store.d.ts +4 -4
  134. package/dist/runtime/journal/store.js +6 -6
  135. package/dist/runtime/kid-input.js +2 -3
  136. package/dist/runtime/live/call.d.ts +12 -0
  137. package/dist/runtime/live/call.js +51 -18
  138. package/dist/runtime/live/turns.js +1 -1
  139. package/dist/runtime/runner.d.ts +9 -55
  140. package/dist/runtime/runner.js +52 -173
  141. package/dist/scenes/paint/painter.js +4 -3
  142. package/dist/schema/config.d.ts +43 -22
  143. package/dist/schema/config.js +45 -47
  144. package/dist/schema/conversation.d.ts +6 -0
  145. package/dist/schema/home.d.ts +11 -1
  146. package/dist/schema/home.js +7 -2
  147. package/dist/schema/workspace.d.ts +27 -0
  148. package/dist/schema/workspace.js +28 -0
  149. package/dist/shared/default-port.d.ts +2 -0
  150. package/dist/shared/default-port.js +2 -0
  151. package/dist/shared/qianwen.d.ts +14 -10
  152. package/dist/shared/qianwen.js +26 -22
  153. package/dist/shared/site-url.d.ts +2 -0
  154. package/dist/shared/site-url.js +5 -0
  155. package/dist/shared/upgrade.d.ts +9 -0
  156. package/dist/shared/upgrade.js +23 -0
  157. package/dist/ui/dev/dev-page.js +25 -252
  158. package/dist/ui/dev/dev-theme.d.ts +1 -1
  159. package/dist/ui/dev/dev-theme.js +1 -1
  160. package/dist/ui/dev/voice-gallery-page.js +1 -1
  161. package/dist/ui/kid/board-mark.d.ts +2 -2
  162. package/dist/ui/kid/board-mark.js +2 -2
  163. package/dist/ui/kid/home-tiles.d.ts +14 -0
  164. package/dist/ui/kid/home-tiles.js +44 -0
  165. package/dist/ui/kid/kid-page.js +68 -103
  166. package/dist/ui/kid/voice-ui.d.ts +1 -1
  167. package/dist/ui/kid/voice-ui.js +1 -1
  168. package/dist/ui/kid/word-emoji.js +5 -3
  169. package/dist/ui/parent/parent-home-page.js +337 -7
  170. package/dist/ui/parent/parent-page.js +6 -2
  171. package/dist/ui/parent/parent-settings-page.d.ts +1 -0
  172. package/dist/ui/parent/parent-settings-page.js +133 -0
  173. package/dist/ui/{dev → parent}/qr-page.d.ts +2 -2
  174. package/dist/ui/{dev → parent}/qr-page.js +19 -14
  175. package/dist/ui/shared/brand.d.ts +3 -1
  176. package/dist/ui/shared/brand.js +3 -1
  177. package/grades/grade-2/README.md +1 -1
  178. package/grades/grade-2/VALIDATION.md +1 -1
  179. package/grades/grade-2/agents/chinese-tutor/activities//345/244/215/344/271/240.md +1 -1
  180. package/grades/grade-2/agents/chinese-tutor/activities//346/{211/271/346/224/271.md → 243/200/346/237/245/344/275/234/344/270/232.md} +4 -4
  181. package/grades/grade-2/agents/chinese-tutor/activities//346/{211/271/346/224/271. → 243/200/346/237/245/344/275/234/344/270/232.}/344/276/213/345/255/220.md +2 -2
  182. package/grades/grade-2/agents/chinese-tutor/activities//351/242/204/344/271/240.md +1 -1
  183. package/grades/grade-2/agents/english-tutor/activities//345/244/215/344/271/240.md +1 -1
  184. package/grades/grade-2/agents/english-tutor/activities//346/{211/271/346/224/271.md → 243/200/346/237/245/344/275/234/344/270/232.md} +4 -4
  185. package/grades/grade-2/agents/english-tutor/activities//346/{211/271/346/224/271. → 243/200/346/237/245/344/275/234/344/270/232.}/344/276/213/345/255/220.md +2 -2
  186. package/grades/grade-2/agents/english-tutor/activities//351/242/204/344/271/240.md +1 -1
  187. package/grades/grade-2/agents/math-tutor/activities//345/244/215/344/271/240.md +1 -1
  188. package/grades/grade-2/agents/math-tutor/activities//346/{211/271/346/224/271.md → 243/200/346/237/245/344/275/234/344/270/232.md} +4 -4
  189. package/grades/grade-2/agents/math-tutor/activities//346/{211/271/346/224/271. → 243/200/346/237/245/344/275/234/344/270/232.}/344/276/213/345/255/220.md +2 -2
  190. package/grades/grade-2/agents/math-tutor/activities//351/242/204/344/271/240.md +1 -1
  191. package/package.json +6 -2
  192. package/skills/cotutor-analyze/references//345/221/275/344/273/244/344/270/216/346/226/207/344/273/266.md +2 -2
  193. package/skills/cotutor-board/references/photo.md +2 -2
  194. package/skills/cotutor-home/SKILL.md +1 -0
  195. package/skills/cotutor-home/references/tutor.md +2 -1
  196. package/skills/cotutor-home/references//351/246/226/351/241/265/350/257/255/346/263/225.md +2 -1
  197. package/skills/cotutor-tune/references//345/255/227/346/256/265.md +6 -3
  198. package/skills/cotutor-tutor/SKILL.md +5 -13
  199. package/dist/brain/cli/adapters.d.ts +0 -60
  200. package/dist/brain/cli/adapters.js +0 -91
  201. package/dist/brain/cli/qwen.d.ts +0 -11
  202. package/dist/brain/cli/qwen.js +0 -141
  203. package/dist/brain/cli/run-plan.d.ts +0 -59
  204. package/dist/brain/cli/run-plan.js +0 -71
  205. package/dist/brain/cli/run.d.ts +0 -35
  206. package/dist/brain/cli/run.js +0 -256
  207. package/dist/brain/cli/types.js +0 -3
  208. package/dist/brain/cli/warm.d.ts +0 -53
  209. package/dist/brain/cli/warm.js +0 -103
package/LICENSE CHANGED
@@ -12,10 +12,10 @@ Definitions
12
12
 
13
13
  "Household" means one family living together.
14
14
 
15
- "Brand Marks" means the cotutor logo, the name "cotutor" and the link
16
- to the cotutor repository, as defined in `src/server/brand.ts`, wherever
17
- the Software displays them, including the footer of the parent-side
18
- pages (paths under `/parent`) and the header of the workbench (`/dev`).
15
+ "Brand Marks" means the cotutor logo (two nested letter "C" shapes
16
+ opening to the right), the name "cotutor" and a link to the cotutor
17
+ repository (currently https://github.com/TarsLab/cotutor), as the
18
+ Software displays them.
19
19
 
20
20
  1. Multi-tenant service. Unless you have written permission from the
21
21
  Licensor, you may not use the Software, or any work derived from it,
@@ -27,9 +27,11 @@ Definitions
27
27
  restricted by this condition.
28
28
 
29
29
  2. Brand Marks. You may not remove, hide, alter or replace the Brand
30
- Marks in the Software or in any work derived from it, and the
31
- parent-side pages and the workbench must continue to display them.
32
- You may change themes, tutor names and avatars, and Workspace titles.
30
+ Marks in the Software or in any work derived from it. The pages the
31
+ Software provides for parents and its workbench, however they are
32
+ named or arranged, must continue to display them as the Software
33
+ does. You may change themes, tutor names and avatars, and Workspace
34
+ titles.
33
35
 
34
36
  3. Contributions. By contributing to the Software, you agree that:
35
37
  (a) the Licensor may change the license of the Software, including
package/NOTICE CHANGED
@@ -32,9 +32,9 @@ Arphic fonts and is licensed under the Arphic Public License. cotutor does
32
32
  not include that data; it reads it at runtime from the installed
33
33
  hanzi-writer-data dependency.
34
34
 
35
- The subject marks on the kid board (src/server/board-mark-art.ts) are glyph
36
- outlines baked by scripts/bake-board-marks.py from these fonts, all licensed
37
- under the SIL Open Font License, Version 1.1 (https://openfontlicense.org):
35
+ The subject marks shown on the kid board are glyph outlines baked from these
36
+ fonts, all licensed under the SIL Open Font License, Version 1.1
37
+ (https://openfontlicense.org):
38
38
 
39
39
  Ma Shan Zheng: Copyright 2018 The MaShanZheng Project Authors
40
40
  (https://github.com/googlefonts/mashanzheng)
package/README.md CHANGED
@@ -6,38 +6,56 @@
6
6
 
7
7
  ## 五分钟用起来
8
8
 
9
- 需要一台电脑(Mac 最省事)、Node ≥ 22.18,和一个百炼账号(北京地域,按量付费)。老师的嘴和脑都走百炼,由应用直接调用,不用另装模型客户端:嘴是 `qwen3.8-omni-flash-realtime`,听孩子说、照着讲稿念;脑是 `qwen3.8-max`,写讲稿和板书。
9
+ **让 AI 助手帮你装:** 把这句话发给编程助手(Claude Code、Codex 这类能在电脑上跑命令的),或者网页版的聊天机器人,它会一步步带你做。百炼的 Key 由你自己填在页面上,不用告诉它。
10
+
11
+ ```text
12
+ 请阅读 https://raw.githubusercontent.com/TarsLab/cotutor/main/INSTALL.md ,照着在这台电脑上安装 cotutor
13
+ ```
14
+
15
+ **自己装:** 需要一台电脑(Mac 最省事)、Node ≥ 22.18,和一个百炼账号(北京地域,按量付费)。老师的嘴和脑都走百炼,由应用直接调用,不用另装模型客户端:嘴是 `qwen3.8-omni-flash-realtime`,听孩子说、照着讲稿念;脑是 `qwen3.8-max`,写讲稿和板书。
10
16
 
11
17
  ```sh
12
18
  npm i -g cotutor
13
19
  cotutor init ming --name 小明 --grade 2 # 建 ~/cotutor/ming/:三位老师、技能、账本、对话目录、cotutor.json
14
- cotutor serve # 孩子端 http://<ip>:5180/,家长端 /parent
20
+ cotutor service install # 装成这台电脑的后台服务,第一次装会打开家长端,孩子的平板在那里扫码
15
21
  ```
16
22
 
23
+ Windows 的 PowerShell 缺省禁止运行脚本,报这个错时把 `npm`、`cotutor` 写成 `npm.cmd`、`cotutor.cmd`,不用改系统设置。
24
+
25
+ 以后要打开家长端,点菜单栏(任务栏)的 cotutor 图标或跑 `cotutor open`,服务重启、登录电脑都不会自己弹出来。不想第一次弹,安装时加 `--no-open`。
26
+
27
+ 后台服务登录后自己起来、崩了自己再起、关了终端照样跑,日志按天落文件:`cotutor service status` 看在不在跑,`cotutor service logs` 看日志,`cotutor service restart` 重起。Mac 上用 launchd,Linux 上用 systemd 用户服务,Windows 上是一条用户自己的登录项(不用管理员、没有黑窗口);第一次起来 Windows 可能弹窗问「允许访问」,点允许,平板才连得进来。要在终端里调试用 `cotutor serve`,先 `cotutor service stop` 让出端口。
28
+
29
+ Mac 和 Windows 上,`service install` 会一起装一个菜单栏(任务栏)图标,不用开终端就能看状态:正常时就是 logo;有事要处理出橙点;后台服务没在跑时 logo 变淡并出红点。状态变差时通知一次。点开图标能看原因、打开家长端、重启服务、看日志。「退出 cotutor」会停掉这台电脑上所有孩子的后台服务,平板打不开,下次登录自动恢复。Windows 的新图标先收在任务栏右下角的 `^` 里。不要图标就 `cotutor tray off`,装服务时加 `--no-tray` 也行。Linux 上没做,用 `cotutor service status` 看。
30
+
31
+ 地址认电脑名,换 Wi-Fi、路由器换 IP 都不用重签证书、不用重扫。端口在 `init` 时选一次写进 `cotutor.json`,以后不变:优先 443,地址里不用写端口(`https://<电脑名>.local/`);443 被占着或系统不让用(Linux 默认不让)就用 8443,再往后 8445、8447;几个孩子各用各的,自动让开。运行时被占只报错说明是谁占着,不会自己换,因为换了端口平板要重新扫码、重加图标。试用(`--sandbox`)的端口永远是真 workspace 的 +1,`cotutor open --sandbox` 打开。
32
+
17
33
  接着三件事:
18
34
 
19
35
  1. **填百炼**:在这台电脑上打开家长端 `/parent` 的「首页」,填百炼「按量付费」的 API Key 和业务空间 ID,保存前会先试连。API Key 在百炼控制台的「密钥管理」里创建,业务空间 ID 在控制台的业务空间详情里看,以百炼文档为准:[获取与配置 API Key](https://help.aliyun.com/zh/model-studio/get-api-key)、[首次调用千问 API](https://help.aliyun.com/zh/model-studio/first-api-call-to-qwen)(里面讲了业务空间 ID)。Key 跟地域绑定,控制台右上角先选北京。Token Plan 的 `sk-sp-` 不行。没填时孩子端只出「老师们还没到」。
20
36
  2. **填档案**:workspace 里的 `孩子.md` 填 `school_start`(读一年级的年月,如 `2025-09`),老师据此知道现在是几年级哪个学期。想用自己的 Obsidian 库,改 `cotutor.json` 的 `paths.vault`。
21
- 3. **体检**:`cotutor doctor` 逐项检查,不过的每条都带修复命令。
37
+ 3. **体检**:`cotutor doctor` 逐项检查,不过的每条都带修法。
38
+
39
+ 装好以后也可以让编程助手帮你管:在孩子的文件夹(`~/cotutor/<孩子>/`)里打开 Claude Code 或 Codex,已带的技能直接能用。`cotutor-home` 排孩子的首页,`cotutor-tune` 调老师的口气和规矩,`cotutor-analyze` 分析某次对话为什么那样答。
22
40
 
23
41
  `init` 把出厂老师拷进 `agents/<老师>.md`,把做课包用的四个技能 chalk-* 和五个机器件 cotutor-tutor / cotutor-board / cotutor-analyze / cotutor-tune / cotutor-home 拷进 `.agents/skills/`(Codex、Qwen Code 直接读;Claude Code 读的 `.claude/skills` 是链到它的);
24
- 做小课堂的课包用 `cotutor chalk`(check / build / bake / snap / dub),烤画面与截图用本机的 Chrome。要在 iPad 上用(按住说话要 HTTPS):`cotutor cert` 签一张
25
- 自签证书(要 mkcert:`brew install mkcert && mkcert -install`),把根证书装到 iPad 并打开信任,重启 `serve`,扫码打开。步骤见 [iPad 与 iPhone](docs/iPad与iPhone.md)。
42
+ 做小课堂的课包用 `cotutor chalk`(check / build / bake / snap / dub),烤画面与截图用本机的 Chrome(Windows 上没有 Chrome 就用 Edge)。要在平板上用(按住说话要 HTTPS):`cotutor cert` 签一张
43
+ 自签证书(要 mkcert:Mac 上 `brew install mkcert && mkcert -install`;Windows 上 `winget install FiloSottile.mkcert`,重开终端再 `mkcert -install`),把根证书装到平板并打开信任,`cotutor service restart`(在终端里跑的就重开 `cotutor serve`),扫码打开。iPad 的步骤见 [iPad 与 iPhone](docs/iPad与iPhone.md)。
26
44
 
27
45
  然后在浏览器里:
28
46
 
29
- - **家长端 `/parent`**:只看。按天看孩子和每位老师的板书(答案不剥)、看录像、打星、记账、删话题;看这天写进 vault 的每一行,不要的一行一行撤回。
30
- - **工作台 `/dev`**:给有技术背景的家长与开发者。和任何一位老师说话、看一轮的原文;老师团页改人设、政策、老师文件,加新老师;音色页试听挑声音;设置页改路径、端口、配音。
47
+ - **家长端 `/parent`**:「首页」设孩子的首页(按钮、这周的词、老师的先后),扫码打开(孩子的平板一张、家长自己的手机一张),给老师换头像和声音;「设置」定孩子的平板上按住说话那一栏放哪边;「记录」按天看孩子和每位老师的板书(答案不剥)、看录像、打星、记账、删话题,看这天写进 vault 的每一行,不要的一行一行撤回。右上角「工作台 ↗」去工作台。
48
+ - **工作台 `/dev`**:给有技术背景的家长与开发者。和任何一位老师说话、看一轮的原文;老师团页改人设、政策、老师文件,加新老师;设置页改路径、端口。头像与声音只在家长端改。
31
49
  - **孩子端 `/`**:首页每位老师一张卡,卡上的按钮是家长排好的(新话题、接着以前的话题、带讲法的开场、先看一堂小课堂);点了进板书页,按住说话或打字。老师的回答是一页板书:卡先铺开(选择题、点读、田字格、单词、图、小课堂、画板、拍照),讲稿一句句念、笔跟着声音标注,末句问句停下来等孩子。点一张卡进舞台做题,做完「交给老师」。孩子永远看不到工具、报错、评判。
32
50
 
33
- 实时老师的声音由 realtime 模型提供;点读、单词等卡片的配音也走百炼(同一把 key),在工作台「音色」页给老师挑声音。
51
+ 实时老师的声音由 realtime 模型提供;点读、单词等卡片的配音也走百炼(同一把 key),出厂三位各有一个缺省的。在家长端「首页」点老师的「样子与声音」,再点「换一个」在弹窗里挑(全部音色都在,能搜,能按男女、口音、中英、年纪、场景筛,能试听)。
34
52
  不花钱先看孩子端长什么样:`cotutor mock` 起一份模拟接口,不经真实老师。
35
53
 
36
54
  ## 年级教学包
37
55
 
38
56
  面向中国大陆小学一至六年级,当前提供二年级教学包。`cotutor grades` 查看可用年级;新建时未指定 `--grade` 默认二年级,未提供的年级不会自动回退。
39
57
 
40
- 出厂文件在 `grades/grade-2/`。三科分别提供预习、批改、复习;语文有写话与看图写话,英语有口语课,数学小课堂配合提前做好的课包。家长按教材版本、学期和孩子实际进度设置材料。
58
+ 出厂文件在 `grades/grade-2/`。三科分别提供预习、检查作业、复习(孩子端老师牌上常驻三块方砖);语文有写话与看图写话,英语有口语课,数学小课堂配合提前做好的课包。家长按教材版本、学期和孩子实际进度设置材料。
41
59
 
42
60
  孩子当前学期仍由档案决定;升年级不会自动替换家长的老师文件。旧 workspace 执行 `cotutor upgrade` 补记二年级来源,保留自定义内容。贡献其他年级与验证方式见 [年级教学包设计](docs/年级教学包.md)。
43
61
 
@@ -49,7 +67,8 @@ cotutor serve # 孩子端 http://<ip>:5180/,家长
49
67
  cotutor add science-tutor --display 科学老师 --subject 科学 --avatar 🔬
50
68
  ```
51
69
 
52
- 名字以 `-tutor` 结尾的是老师(有脸),其余是孩子看不到的工具人。头像可以是 emoji,也可以是
70
+ 名字以 `-tutor` 结尾的是老师(有脸),其余是孩子看不到的工具人。没设头像时,语文、数学、英语老师用包里自带的
71
+ 出厂头像(包根 `avatars/`,三个木钉娃娃),别的老师显示学科首字。头像可以是 emoji,也可以是
53
72
  workspace 里的一张图(`avatars/<老师>.png`):用 [figshot](https://github.com/tarslab/figshot)
54
73
  `figshot pick --workspace ~/cotutor/<孩子>` 让孩子自己搭一个乐高人仔当老师,存盘直接落到这里。
55
74
  `cotutor.json` 里加一行 `"figshot": { "port": 8477 }`,孩子端首页就多一张「给老师们换个样子」,iPad 上点了直接进去(figshot 没开着时这张卡不出现)。
@@ -68,9 +87,16 @@ import { loadWorkspace } from 'cotutor/workspace'; // 解
68
87
 
69
88
  ## 配置模型
70
89
 
71
- **缺省:应用直连百炼。** 嘴和脑都用家长端「首页」填的那份百炼按量 API Key 与业务空间 ID,存在本机 `~/.config/cotutor/bailian.json`(几个孩子的 workspace 共用);也可以在起 `serve` 的终端设 `DASHSCOPE_API_KEY` 与 `DASHSCOPE_WORKSPACE_ID`。老师用哪个模型写在 `cotutor.json` 的 `runtimes` 里:出厂的 `api`(`qwen3.8-max`,三位老师都用它)和 `api-flash`(`qwen3.8-flash`,便宜些;换之前先跑 `cotutor activity eval` 看例子过不过)。卡的配音、听写认字、口语单、画图也用这份 key;机器上同时有千问 Token Plan 的 key 时,Token Plan 有的模型先走它。
90
+ **缺省:应用直连百炼。** 嘴和脑都用家长端「首页」填的那份百炼按量 API Key 与业务空间 ID,存在本机 `~/.config/cotutor/bailian.json`(几个孩子的 workspace 共用)。老师用哪个模型写在 `cotutor.json` 的 `runtimes` 里:出厂的 `api`(`qwen3.8-max`,三位老师都用它)和 `api-flash`(`qwen3.8-flash`,便宜些;换之前先跑 `cotutor activity eval` 看例子过不过)。卡的配音、听写认字、口语单、画图也用这份 key。
72
91
 
73
- **备用:Qwen Code。** 订了千问 Token Plan 的开发者,可以让老师的脑走 Qwen Code:装 Qwen Code ≥ 0.25,在起 `serve` 的终端设 `QWENCLOUD_TOKEN_PLAN_API_KEY`(或 `DASHSCOPE_API_KEY`),把老师的 `runtime` 改成出厂的 `qwen`(`qwen3.8-max`)或 `qwen-flash`(`qwen3.8-flash`)。嘴照样要百炼按量 key,Token Plan 的 key 不能用于实时语音。
92
+ **key 只放 `~/.config/cotutor/`**,所有命令(`serve`、`send`、`replay`、`doctor`……)读同一处,环境变量(`DASHSCOPE_API_KEY` 之类)和别的工具的配置都不看:
93
+
94
+ | 文件 | 内容 | 谁写 |
95
+ |---|---|---|
96
+ | `bailian.json` | `{"apiKey": "sk-…", "workspaceId": "llm-…"}`,百炼按量 | 家长端「首页」;没有浏览器的机器照格式手写,或从别的机器拷过来 |
97
+ | `token-plan.json` | `{"apiKey": "sk-sp-…"}`,千问 Token Plan | 订了 Token Plan 的开发者手写;有它,Token Plan 有的模型先走它 |
98
+
99
+ 手写的存成 UTF-8(不带 BOM)。配了什么、通不通看 `cotutor doctor`(`--live` 真连一次)。
74
100
 
75
101
  换了运行时,之前的话题接不上,也不导入历史:孩子接着说会开新话题;换之前聊的话题不记账,家长端卡上标「换过运行时 · 不记账」,记录照样能看。模型、预算与老师运行时都在 workspace 的 `cotutor.json` 里,编辑器按首行 `$schema` 提供字段说明。
76
102
 
@@ -80,22 +106,43 @@ import { loadWorkspace } from 'cotutor/workspace'; // 解
80
106
 
81
107
  默认 workspace 是 `~/cotutor/<孩子>/`:配置在 `cotutor.json`,老师在 `agents/<老师>.md`,activity 在 `agents/<老师>/activities/`,技能在 `.agents/skills/`,对话在 `conversations/`,账本在 `ledger/`,照片在 `captures/`,课包在 `bundles/`,首页在 `home/`。长期档案、记忆和日记在配置的 Obsidian vault,规则见 [obsidian 仓库设计](docs/obsidian仓库设计.md)。备份时保留整个 workspace 与 vault。
82
108
 
109
+ **有新版本时**,后台服务每天问一次 npm(只发一个普通请求,不带孩子或使用的任何信息),菜单栏(任务栏)图标的菜单和家长端「首页」顶部会说,通知一次,孩子端不说。不会自动升级,因为要重启服务,孩子可能正在上课。不想让它问:在 `~/.config/cotutor/update.json` 写 `{"off": true}`。
110
+
111
+ 升级挑孩子不用的时候,依次运行(分开写是因为 Windows 默认的 PowerShell 不认 `&&`):
112
+
113
+ ```sh
114
+ npm i -g cotutor # 换上新版程序
115
+ cotutor upgrade # 更新未修改的出厂老师、activity、主题与技能
116
+ cotutor service restart # 服务和图标换上新代码(在终端里跑的就重开 serve)
117
+ ```
118
+
119
+ Windows 上写成 `npm.cmd`、`cotutor.cmd`(PowerShell 缺省禁止运行脚本),提示里给的就是这样。
120
+
121
+ 几个孩子的话,后两条每个孩子各跑一次,加 `--workspace ~/cotutor/<孩子>`。然后看一眼体检,它提示要迁移配置时再跑后面两条:
122
+
83
123
  ```sh
84
- cotutor upgrade # 更新未修改的出厂老师、activity、主题与技能
124
+ cotutor doctor
85
125
  cotutor upgrade --config --dry-run # 预览 cotutor.json 的迁移
86
126
  cotutor upgrade --config # 执行配置迁移
87
- cotutor doctor
88
127
  ```
89
128
 
90
- 家长改过的老师与主题保留并显示差异;机器技能会随版本覆盖。配置迁移补缺,并更新未自定义的旧命令模板,保留自定义配置。升级程序后重新启动 `serve`。
129
+ 家长改过的老师与主题保留并显示差异;机器技能会随版本覆盖。配置迁移补缺,并更新未自定义的旧命令模板,保留自定义配置。换了 Node 版本、或包装到了别处,再 `cotutor service install` 一次。
91
130
 
92
131
  2026-10-30 之前建的 workspace,老师在 `.claude/agents/`、技能在 `.claude/skills/`。`cotutor upgrade`(或 `init`)先把它们原样挪到新位置,再照常升级;新位置已有同名的不挪,会点名。`.claude/` 里别的文件不动。
93
132
 
94
133
  ### 旧 workspace 换运行时
95
134
 
96
- 2026-10-31 之前建的 workspace 缺省走 Qwen Code。要换成应用直连百炼:先 `cotutor upgrade --config` 补上出厂的 `api` / `api-flash`,再把 `runtimes.default` 和 `tutors.*.runtime` 改成 `api`,在家长端「首页」填好百炼。换之前聊的话题照上面「配置模型」说的办:不导入、不记账。
135
+ 2026-11-02 起不再起命令行(Qwen Code;Claude CLI 更早就去掉了),老师只走应用直连百炼。2026-10-31 之前建的 workspace 缺省走 Qwen Code,doctor 会报「命令行运行时不再支持」:先 `cotutor upgrade --config` 补上出厂的 `api` / `api-flash`,把 `runtimes.default` 和 `tutors.*.runtime` 改成 `api`,在家长端「首页」填好百炼,再删掉 `qwen`、`qwen-flash`(或更早的 Claude)条目。换之前聊的话题照上面「配置模型」说的办:记录照样能看,不导入、不记账。旧 `proxy` 字段可删除,对应命令已移除。
136
+
137
+ ### 试用不进孩子的记录
97
138
 
98
- 更早还留着 Claude 运行时的:同样先 `cotutor upgrade --config`,把指向 Claude 的 `runtimes.default` 和 `tutors.*.runtime` 改成 `api`(或 `qwen`),再删掉旧条目。旧 `proxy` 字段可删除,对应命令已移除。
139
+ 自己手动试功能时,命令加 `--sandbox`:第一次从真 workspace 分出一份到旁边的 `.sandbox/<目录名>/`(`~/cotutor/ray` → `~/cotutor/.sandbox/ray`)。老师、activity、首页、主题、课包照拷,对话、照片、听写、账本不带,vault 拷一份;试的对话、记忆、日记都写在这份里。页面标题前带「试用」,端口是真的那个 +1,可以同时开;自动记账关着(要试记账,家长端点「记账」)。
140
+
141
+ ```sh
142
+ cotutor serve --sandbox # 试用的那份;没有就先分出来
143
+ cotutor send math-tutor 你好 --sandbox
144
+ cotutor serve --sandbox --fresh # 丢掉试过的,从真 workspace 重新分(真的那边改了老师文件、vault 也用这个带过来)
145
+ ```
99
146
 
100
147
  ## 开发
101
148
 
@@ -112,7 +159,7 @@ node bin/cotutor.js mock # 假模型接口,查看前端
112
159
 
113
160
  改卡片文档或技能生成代码后运行 `pnpm gen:skills`。开发约定与分模块验证见 [AGENTS.md](AGENTS.md) 和 [CONTRIBUTING.md](CONTRIBUTING.md)。
114
161
 
115
- 正式文档只保留以下六篇,历史讨论查 Git:
162
+ 根目录的 [INSTALL.md](INSTALL.md) 给替家长安装的 AI 助手看。`docs/` 只保留以下六篇,历史讨论查 Git:
116
163
 
117
164
  - [iPad 与 iPhone](docs/iPad与iPhone.md)
118
165
  - [obsidian 仓库设计](docs/obsidian仓库设计.md)
Binary file
Binary file
Binary file
package/bin/cotutor.js CHANGED
@@ -6,11 +6,21 @@
6
6
  import { existsSync } from 'node:fs';
7
7
  import { fileURLToPath } from 'node:url';
8
8
 
9
- const src = new URL('../src/app/cli/main.ts', import.meta.url);
10
- const entry = existsSync(fileURLToPath(src)) ? src : new URL('../dist/app/cli/main.js', import.meta.url);
9
+ // 版本不够先说清楚:不然源码直跑报「Unknown file extension .ts」,npm 装的报「没有 stripTypeScriptTypes」,都带一串栈(Windows 自带的常是 Node 18)
10
+ const [major, minor] = process.versions.node.split('.').map(Number);
11
+ if (major < 22 || (major === 22 && minor < 18)) {
12
+ console.error(`cotutor: 要 Node ≥ 22.18,这台是 ${process.versions.node}。到 https://nodejs.org 装新的 LTS 版,装好重开终端再跑。`);
13
+ process.exit(2);
14
+ }
15
+
16
+ // 托盘进程一直开着,走轻入口:不把所有命令都加载进来(src/app/cli/tray-run.ts)
17
+ const argv = process.argv.slice(2);
18
+ const name = argv[0] === 'tray' && argv[1] === 'run' ? 'tray-run' : 'main';
19
+ const src = new URL(`../src/app/cli/${name}.ts`, import.meta.url);
20
+ const entry = existsSync(fileURLToPath(src)) ? src : new URL(`../dist/app/cli/${name}.js`, import.meta.url);
11
21
  try {
12
22
  const { main } = await import(entry.href);
13
- await main(process.argv.slice(2));
23
+ await main(argv);
14
24
  } catch (err) {
15
25
  console.error(`cotutor: 内部错误:${err?.stack ?? err}`);
16
26
  process.exitCode = 4;
@@ -1,4 +1,4 @@
1
- /* 拍给我看卡(cards/photo/card.md):相机 + 要拍什么;最后一节时下沿是答案栏「等你拍…」带拍照、相册,拍来的照片缩在下沿 */
1
+ /* 拍给我看卡(cards/photo/card.md):相机 + 要拍什么;还没拍:最后一节时下沿「等你拍…」带拍照、相册;拍了:照片放大在卡里,配的话在下面 */
2
2
  .c.c-photo { gap:6px; }
3
3
  .pc-row { display:flex; align-items:center; gap:14px; }
4
4
  .pc-ic { flex:none; width:52px; height:52px; border-radius:50%; background:#ffffff; border:1.5px solid var(--tint-plum-line); color:var(--tint-plum-head); display:inline-flex; align-items:center; justify-content:center; }
@@ -11,5 +11,12 @@ body.pending .kid-ans.snap { display:none; }
11
11
  .pc-go { display:inline-flex; align-items:center; gap:6px; height:44px; padding:0 18px; border-radius:22px; background:var(--tint-plum-head); color:#ffffff; font-size:16px; font-weight:600; cursor:pointer; white-space:nowrap; }
12
12
  .pc-go.alt { background:#ffffff; color:var(--tint-plum-head); border:1.5px solid var(--tint-plum-line); }
13
13
  .pc-go svg { width:20px; height:20px; }
14
- /* 拍来的照片:缩在答案栏里,点开看大图 */
15
- .kid-ans .kp { flex:none; height:64px; width:auto; max-width:160px; border-radius:8px; object-fit:cover; border:1px solid var(--line); background:#ffffff; cursor:zoom-in; }
14
+ /* 拍来的:照片放大在卡里(同图片卡的分量),一张整宽、整张看得见(作业页不裁);几张两列;点开看大图。配的话在照片下面,像图注,点了看原话 */
15
+ .c.c-photo.got { gap:12px; }
16
+ .pc-ans { display:flex; flex-direction:column; gap:10px; }
17
+ .pc-shots { display:grid; grid-template-columns:repeat(2, minmax(0, 1fr)); gap:8px; }
18
+ .pc-shots img { display:block; width:100%; height:220px; object-fit:cover; border-radius:12px; border:1px solid var(--line); background:#ffffff; cursor:zoom-in; }
19
+ .pc-shots.one { grid-template-columns:minmax(0, 1fr); }
20
+ .pc-shots.one img { height:auto; max-height:420px; object-fit:contain; }
21
+ .pc-said { font-size:18px; line-height:1.5; color:var(--ink); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; cursor:pointer; }
22
+ .pc-said.open { white-space:normal; }
@@ -48,13 +48,13 @@
48
48
 
49
49
  ## 孩子看到什么
50
50
 
51
- 紫色卡:左边一个相机,右边大字写要拍什么。它在最后一节时,卡下沿写「等你拍…」,旁边「拍照」「相册」两颗按钮;点了进发照片屏(能裁、能圈、能配一句话),发给你。拍来的照片缩在卡下沿,配的话跟在旁边,点照片看大图。
51
+ 紫色卡:左边一个相机,右边大字写要拍什么。它在最后一节时,卡下沿写「等你拍…」,旁边「拍照」「相册」两颗按钮;点了进发照片屏(能裁、能圈、能配一句话),发给你。拍来的照片放大在这张卡里,配的话写在照片下面,点照片看大图。
52
52
 
53
53
  ## 你会收回什么
54
54
 
55
55
  卡上没有状态。孩子拍来的照片随下一条消息到你手里,和他自己拍的作业照片一样:上下文包里有 `photos:` 与 `photoFiles:`,照板书写法的「作业照片」读。他配的话就是这条消息的字;没配就是「(拍了 N 张)」。
56
56
 
57
- 孩子没拍、直接说话(「我找不到那幅图」),那句话也写在这张卡下沿。
57
+ 孩子没拍、直接说话(「我找不到那幅图」),那句话也写在这张卡里。
58
58
 
59
59
  ## 状态的形状
60
60
 
@@ -29,6 +29,19 @@
29
29
  .c-tutor.off .nm, .c-tutor.off .bt { color:var(--dim); }
30
30
  .c-tutor.off .bt .bi { color:var(--dim); }
31
31
  .c-tutor.off .bt-new { background:var(--grey); }
32
+ /* 常驻的三块方砖(预习、检查作业、复习;src/ui/kid/home-tiles.ts):新话题下面一行三块,白底学科色细边,图在上字在下;三科同一个样子,分辨全靠图 */
33
+ .c-tutor .tls { display:grid; grid-template-columns:repeat(3, minmax(0, 1fr)); gap:12px; }
34
+ .c-tutor .bt.bt-tile { flex-direction:column; justify-content:center; gap:8px; min-height:132px; padding:14px 8px; border-radius:20px; border-color:color-mix(in srgb, var(--sc) 32%, #fff); text-align:center; }
35
+ .c-tutor .bt-tile .ta { display:grid; place-items:center; color:var(--sc); }
36
+ .c-tutor .bt-tile .ta svg { width:100px; height:68px; display:block; }
37
+ .c-tutor .bt-tile .btx { font-size:20px; font-weight:700; letter-spacing:.5px; line-height:1.2; }
38
+ @media (max-width:560px) {
39
+ .c-tutor .tls { gap:8px; }
40
+ .c-tutor .bt.bt-tile { min-height:100px; padding:10px 4px; gap:6px; border-radius:16px; }
41
+ .c-tutor .bt-tile .ta svg { width:72px; height:49px; }
42
+ .c-tutor .bt-tile .btx { font-size:17px; }
43
+ }
44
+ .c-tutor.off .bt-tile .ta, body.offline .c-tutor .bt-tile .ta, body.gated .c-tutor .bt-tile .ta { filter:grayscale(1); opacity:.5; }
32
45
  /* 小课堂:带预览图的海报,图上一个播放键,下面「小课堂 · 课名 · 课长」;挨着的几张收在 .lcs 里,够宽就并排 */
33
46
  .c-tutor .lcs { display:grid; grid-template-columns:repeat(auto-fit, minmax(220px, 1fr)); gap:12px; }
34
47
  .c-tutor .bt-lecture { flex-direction:column; align-items:stretch; gap:0; min-height:0; padding:0; border-radius:20px; overflow:hidden; }
@@ -25,6 +25,7 @@
25
25
  - `讲法: …` 挂在上一个按钮下面,写给老师的:从哪开始、重点是什么、讲到哪收。可以写多行。孩子看不到。
26
26
  - `留到: <日期>` 挂在上一个按钮下面:这个按钮留到那天(含那天),过了孩子就看不到。不写就一直留着。
27
27
  - `新话题` 不用写,应用永远放第一个。
28
+ - 预习、检查作业、复习三块方砖不用写:老师家里有那个 activity 文件就常驻在牌上(家长端「每天都在」能关),点了参数全用缺省、老师先请孩子拍材料。要带范围、重点,才另写一个 `activity 预习` 按钮;两个都留着,方砖不占按钮的名额。
28
29
 
29
30
  ## 例子
30
31
 
@@ -137,7 +138,7 @@ activity 的名字写错、参数写错或少了要填的,解析得过,首页检
137
138
 
138
139
  ## 孩子看到什么
139
140
 
140
- 首页最上面,一位老师一张卡:头像、名字,下面一个学科色大气泡「新话题 · 想问我什么?」,然后是你写的(开场按钮与 activity 按钮是带 ▶ 的大胶囊,接着按钮带 ↻;小课堂是一张带预览图的海报,图上一个播放键,下面写课名与课长)。点开场或接着按钮,老师页打开、按钮上的字立刻发出去,老师接着就讲;点新话题是空白的老师页,等孩子开口。今天从同一个按钮聊过的,在老师页的空白板上接(新话题一个胶囊;activity、小课堂先问接着还是重新开始),不占按钮。今天的条数用完、或服务不通,整张卡变灰。讲法孩子永远看不到。
141
+ 首页最上面,一位老师一张卡:头像、名字,下面一个学科色大气泡「新话题 · 想问我什么?」,再下面一行三块方砖(预习、检查作业、复习,图在上字在下,不写小字),然后是你写的(开场按钮与 activity 按钮是带 ▶ 的大胶囊,接着按钮带 ↻;小课堂是一张带预览图的海报,图上一个播放键,下面写课名与课长)。点开场或接着按钮,老师页打开、按钮上的字立刻发出去,老师接着就讲;点新话题是空白的老师页,等孩子开口。今天从同一个按钮聊过的,在老师页的空白板上接(新话题一个胶囊;activity、小课堂先问接着还是重新开始),不占按钮。今天的条数用完、或服务不通,整张卡变灰。讲法孩子永远看不到。
141
142
 
142
143
  ## 你会收回什么
143
144
 
@@ -1,6 +1,6 @@
1
1
  <!-- 给人看
2
2
  谁读:有脸的老师。生成器(src/board/cards/docs.ts)把这篇和各 cards/<种类>/card.md 的「是什么 / 写法 / 例子」拼成 skills/cotutor-board/SKILL.md;
3
- Qwen 运行时把它和老师正文一起放进系统提示;自定义运行时也可放进话题第一条的 <cotutor-board>
3
+ 应用把它(按老师的卡裁过)和老师正文一起放进系统提示
4
4
  能改:改这篇或 card.md,再跑 pnpm run gen:skills。独占一行的注释生成时剥掉,老师看不到;{{卡的种类}} 那一行由生成器替换;
5
5
  {{有 x y}} 到 {{/有}} 之间的行,这位老师的卡里 x、y 都有才留(cotutor.json tutors.<老师>.cards,《卡片协议.md》「谁拿到哪些卡」)
6
6
  改了:tests/cards.test.ts、skills.test,再 cotutor replay 对照(《写提示词.md》§一)
@@ -90,7 +90,7 @@ export async function evalActivity(ws, tutor, name, opts = {}) {
90
90
  throw new UsageError(`例子「${e.name}」的课 ${e.lecture.bundle} 不在 bundles/ 或 lectures/ 里:拷一份进来,或把「课:」换成你自己的课`);
91
91
  const kid = opts.kid ?? makeKidModel({ env: opts.env });
92
92
  const evalWs = evalWorkspace(ws);
93
- const runner = new Runner(() => evalWs, { env: opts.env, warm: false, draw: opts.draw ?? fakeDraw });
93
+ const runner = new Runner(() => evalWs, { env: opts.env, draw: opts.draw ?? fakeDraw });
94
94
  const out = [];
95
95
  for (const e of examples) {
96
96
  const run = runsOf.get(e);
@@ -108,7 +108,8 @@ export async function evalActivity(ws, tutor, name, opts = {}) {
108
108
  let photos = [];
109
109
  let lecture = e.lecture ? { bundle: e.lecture.bundle, watchedMs: 0, finished: true, pauses: e.lecture.pauses, marks: e.lecture.marks.map((m) => ({ atMs: m.atMs, strokes: [], hit: m.text })) } : undefined;
110
110
  while (say !== null && turns.length < e.maxTurns) {
111
- const started = await runner.send(tutor, { from: 'kid', text: say, eval: true, ...(photos.length ? { photos } : {}), ...(lecture ? { lecture } : {}), ...(thread ? { thread } : { newThread: true, activity: run }) });
111
+ // 真孩子都是实时的:模拟孩子的每一句也照实时写(上下文包带 live:、不补提问卡、讲稿不截);嘴不在,接住与打断不带
112
+ const started = await runner.send(tutor, { from: 'kid', text: say, eval: true, live: {}, ...(photos.length ? { photos } : {}), ...(lecture ? { lecture } : {}), ...(thread ? { thread } : { newThread: true, activity: run }) });
112
113
  photos = [];
113
114
  lecture = undefined;
114
115
  thread = started.thread;
@@ -12,6 +12,11 @@ export interface ActivityStatus {
12
12
  basedOn?: string;
13
13
  }
14
14
  export declare function activityStatuses(root: string): Promise<ActivityStatus[]>;
15
+ export interface ActivityRename {
16
+ /** 新的键「老师/新名」 */
17
+ key: string;
18
+ from: string;
19
+ }
15
20
  export interface ActivityInstallStep {
16
21
  item: string;
17
22
  action: 'created' | 'exists' | 'kept' | 'replaced';
@@ -21,9 +26,11 @@ export interface ActivityInstallStep {
21
26
  export declare function installActivities(root: string): Promise<ActivityInstallStep[]>;
22
27
  export interface ActivityUpgradeStep {
23
28
  key: string;
24
- action: 'upgraded' | 'latest' | 'kept-custom' | 'forced' | 'installed';
29
+ action: 'upgraded' | 'latest' | 'kept-custom' | 'forced' | 'installed' | 'renamed';
25
30
  basedOn?: string;
26
31
  diff?: string[];
32
+ /** renamed:原来的名字 */
33
+ from?: string;
27
34
  }
28
35
  /** upgrade 用:同 upgradeTutors;force 里写「老师/名」才覆盖(原文留 .bak) */
29
36
  export declare function upgradeActivities(root: string, opts?: {
@@ -3,14 +3,15 @@
3
3
  * 包里的 grades/grade-<n>/agents/<老师>/activities/<名>.md 是出厂件;init 拷进 workspace 同一个位置(老师的家里,会话 cwd 下),这份是家长的;
4
4
  * 出厂 hash 记 .cotutor/shipped.json 的 activities(键「老师/名」),据此分 latest / upgradable / custom / untracked / missing,意思同 tutors.ts。
5
5
  */
6
- import { readFile, mkdir, writeFile, lstat } from 'node:fs/promises';
6
+ import { existsSync } from 'node:fs';
7
+ import { readFile, readdir, mkdir, writeFile, lstat, unlink } from 'node:fs/promises';
7
8
  import { dirname, join } from 'node:path';
8
9
  import { lintActivity } from "../../runtime/activity.js";
9
10
  import { PACKAGE_AGENTS_DIR, PACKAGE_VERSION } from "./skeleton.js";
10
11
  import { lineDiff, readManifest, sha256, writeManifest } from "./tutors.js";
11
12
  import { UsageError } from "../workspace.js";
12
13
  import { workspaceGradePack } from "./grades.js";
13
- import { ACTIVITIES_DIR, EXAMPLES_SUFFIX, activityFile, activityRunFor, listActivities } from "../../runtime/activities.js";
14
+ import { ACTIVITIES_DIR, ACTIVITY_RENAMES, EXAMPLES_SUFFIX, activityFile, activityRunFor, listActivities } from "../../runtime/activities.js";
14
15
  // 读的那一半在 src/runtime/activities.ts;照旧从这里转出
15
16
  export { ACTIVITIES_DIR, EXAMPLES_SUFFIX, activityFile, activityRunFor, listActivities };
16
17
  /** 出厂的 activity 与它们的例子文件 */
@@ -49,12 +50,49 @@ async function installOne(root, a, manifest) {
49
50
  manifest.activities ??= {};
50
51
  manifest.activities[keyOf(a)] = { hash: sha256(text), version: PACKAGE_VERSION };
51
52
  }
53
+ /** 头上的 name(activity 文件)/ activity(例子文件)从旧名改成新名;别处不动 */
54
+ function renameHead(text, from, to) {
55
+ return text.replace(/^---\n[\s\S]*?\n---/, (fm) => fm.replace(new RegExp(`^(name|activity):[ \t]*${from}[ \t]*$`, 'm'), `$1: ${to}`));
56
+ }
57
+ /**
58
+ * 改过名的出厂 activity(ACTIVITY_RENAMES):workspace 里旧名的文件与例子挪成新名,头上的名字跟着改;出厂记录跟着挪——
59
+ * 没改过的照旧算没改过(下面比新旧时换成新版),家长改过的照旧算自定义(内容不动,只改头上的名字)。新名已经有了就不动旧的
60
+ */
61
+ async function migrateRenames(root, manifest) {
62
+ const out = [];
63
+ const tutors = (await readdir(join(root, 'agents'), { withFileTypes: true }).catch(() => [])).filter((d) => d.isDirectory()).map((d) => d.name).sort();
64
+ for (const t of tutors) {
65
+ for (const [from, to] of Object.entries(ACTIVITY_RENAMES)) {
66
+ for (const [a, b] of [[from, to], [from + EXAMPLES_SUFFIX, to + EXAMPLES_SUFFIX]]) {
67
+ const src = activityFile(root, t, a);
68
+ const dst = activityFile(root, t, b);
69
+ const text = await readFile(src, 'utf8').catch(() => null);
70
+ if (text === null || existsSync(dst))
71
+ continue;
72
+ const next = renameHead(text, from, to);
73
+ await writeFile(dst, next);
74
+ await unlink(src);
75
+ const rec = manifest.activities?.[`${t}/${a}`];
76
+ if (rec && manifest.activities) {
77
+ delete manifest.activities[`${t}/${a}`];
78
+ manifest.activities[`${t}/${b}`] = { ...rec, hash: rec.hash === sha256(text) ? sha256(next) : rec.hash };
79
+ }
80
+ out.push({ key: `${t}/${b}`, from: a });
81
+ }
82
+ }
83
+ }
84
+ return out;
85
+ }
52
86
  /** init 用:缺的拷,有的不动(家长的) */
53
87
  export async function installActivities(root) {
54
88
  const steps = [];
55
89
  const manifest = await readManifest(root);
56
- let touched = !manifest.teachingPack;
90
+ // 改过名的先挪:不然 init 会在旧名旁边再拷一份新名的
91
+ const renamed = await migrateRenames(root, manifest);
92
+ let touched = !manifest.teachingPack || renamed.length > 0;
57
93
  manifest.teachingPack ??= { grade: (await workspaceGradePack(root)).grade };
94
+ for (const r of renamed)
95
+ steps.push({ item: `agents/${r.key.replace('/', `/${ACTIVITIES_DIR}/`)}.md`, action: 'replaced', note: `改了名(原来叫「${r.from}」)` });
58
96
  for (const a of await shippedActivities(root)) {
59
97
  const s = await readState(root, a, manifest);
60
98
  const item = `agents/${a.tutor}/${ACTIVITIES_DIR}/${a.name}.md`;
@@ -74,7 +112,7 @@ export async function installActivities(root) {
74
112
  export async function upgradeActivities(root, opts = {}) {
75
113
  const manifest = await readManifest(root);
76
114
  manifest.teachingPack ??= { grade: (await workspaceGradePack(root)).grade };
77
- const steps = [];
115
+ const steps = (await migrateRenames(root, manifest)).map((r) => ({ key: r.key, action: 'renamed', from: r.from }));
78
116
  for (const a of await shippedActivities(root)) {
79
117
  const s = await readState(root, a, manifest);
80
118
  if (s.state === 'latest')
@@ -1,7 +1,18 @@
1
+ /**
2
+ * 这个系统上怎么装 mkcert、信任一次根证书(doctor 与 cert 共用)。Windows 上 mkcert -install 装进当前用户的根证书,会弹窗问,点「是」;
3
+ * 没有 winget 的(11-02 那台就没有)下官方的 exe。
4
+ */
5
+ export declare function mkcertHint(platform?: NodeJS.Platform): string;
6
+ /** mkcert 在、根证书建过了:cotutor cert 不用人就能跑。mkcert -install 要开机密码或点弹窗,那一步是人的 */
7
+ export declare function mkcertReady(caRootOf?: () => Promise<string>): Promise<boolean>;
8
+ /** 根证书没进这台电脑的系统信任。mkcert 没有根证书时自己建一个、照样签成功,只在 stderr 说一句(Firefox、Java 的信任库它也说,那些不算);没有单独的查询命令,只能这样看 */
9
+ export declare const mkcertUntrusted: (stderr: string) => boolean;
1
10
  export interface CertResult {
2
11
  cert: string;
3
12
  key: string;
4
13
  hosts: string[];
5
14
  caRoot: string;
15
+ /** 这台电脑信任根证书了;false 时证书能用,但这台电脑的浏览器会报警告 */
16
+ trusted: boolean;
6
17
  }
7
18
  export declare function makeCert(extraHosts?: string[]): Promise<CertResult>;
@@ -1,36 +1,63 @@
1
1
  /**
2
2
  * cotutor cert:用 mkcert 给这台机器签一张证书到 ~/.config/cotutor/certs/(cert.pem + key.pem),serve 看到就走 HTTPS。
3
- * 为什么要:iPad Safari 的录音 / 语音识别只在安全上下文里给;局域网 IP 不是 localhost,得自签。
4
- * 为什么放机器级而不是 workspace:签的是本机名与局域网 IP,与孩子无关;多个 workspace 共用一张,IP 变了只重签一次。
5
- * 主机名 = 本机名、本机名.local、所有局域网 IPv4、localhost、127.0.0.1,再加 --host 给的。已有证书就覆盖(证书可再生)。不需要 workspace。
3
+ * 为什么要:平板、手机浏览器的录音 / 语音识别只在安全上下文里给;局域网 IP 不是 localhost,得自签。
4
+ * 为什么放机器级而不是 workspace:签的是电脑名与局域网 IP,与孩子无关;多个 workspace 共用一张。
5
+ * 主机名 = 电脑名、电脑名.local、所有局域网 IPv4、localhost、127.0.0.1,再加 --host 给的。已有证书就覆盖(证书可再生)。不需要 workspace。
6
+ * 地址认电脑名,IP 变了不用重签;证书里的 IP 只给手输数字地址的退路用。改了电脑名才要重签。
6
7
  */
7
8
  import { execFile } from 'node:child_process';
9
+ import { existsSync } from 'node:fs';
8
10
  import { mkdir } from 'node:fs/promises';
9
11
  import { hostname } from 'node:os';
10
12
  import { join } from 'node:path';
11
13
  import { promisify } from 'node:util';
12
- import { lanAddresses } from "./serve.js";
14
+ import { computerName, lanAddresses } from "./serve.js";
13
15
  import { USER_CERT_DIR, UsageError } from "../workspace.js";
14
16
  const execFileP = promisify(execFile);
17
+ /**
18
+ * 这个系统上怎么装 mkcert、信任一次根证书(doctor 与 cert 共用)。Windows 上 mkcert -install 装进当前用户的根证书,会弹窗问,点「是」;
19
+ * 没有 winget 的(11-02 那台就没有)下官方的 exe。
20
+ */
21
+ export function mkcertHint(platform = process.platform) {
22
+ if (platform === 'darwin')
23
+ return 'brew install mkcert && mkcert -install';
24
+ if (platform === 'win32')
25
+ return 'winget install FiloSottile.mkcert(没有 winget 就到 https://github.com/FiloSottile/mkcert/releases 下 windows-amd64 那个,改名 mkcert.exe 放进 PATH),重开终端跑 mkcert -install(弹窗问装不装根证书,点「是」)';
26
+ return '用系统的包管理器装 mkcert(如 apt install mkcert),再 mkcert -install';
27
+ }
28
+ /** mkcert 在、根证书建过了:cotutor cert 不用人就能跑。mkcert -install 要开机密码或点弹窗,那一步是人的 */
29
+ export async function mkcertReady(caRootOf = async () => (await execFileP('mkcert', ['-CAROOT'], { timeout: 8000 })).stdout.trim()) {
30
+ try {
31
+ return existsSync(join(await caRootOf(), 'rootCA.pem'));
32
+ }
33
+ catch {
34
+ return false;
35
+ }
36
+ }
37
+ /** 根证书没进这台电脑的系统信任。mkcert 没有根证书时自己建一个、照样签成功,只在 stderr 说一句(Firefox、Java 的信任库它也说,那些不算);没有单独的查询命令,只能这样看 */
38
+ export const mkcertUntrusted = (stderr) => stderr.includes('not installed in the system trust store');
15
39
  export async function makeCert(extraHosts = []) {
16
40
  let caRoot;
17
41
  try {
18
42
  caRoot = (await execFileP('mkcert', ['-CAROOT'], { timeout: 8000 })).stdout.trim();
19
43
  }
20
44
  catch {
21
- throw new UsageError('PATH 里没有 mkcert。装:brew install mkcert && mkcert -install(在 Mac 上信任一次根证书);iPad / iPhone 端见 cert 命令输出的提示与 docs/iPad与iPhone.md。');
45
+ throw new UsageError(`PATH 里没有 mkcert。装:${mkcertHint()}(这台电脑信任一次根证书);平板、手机端见 cert 命令输出的提示与 docs/iPad与iPhone.md。`);
22
46
  }
23
- const host = hostname();
24
- const hosts = [...new Set([host, host.endsWith('.local') ? host : `${host}.local`, ...lanAddresses(), 'localhost', '127.0.0.1', ...extraHosts])];
47
+ const name = computerName();
48
+ // os.hostname() 也带上:和电脑名不一样时(macOS 上可能跟着网络变),以前能开的地址别丢
49
+ const hosts = [...new Set([name, `${name}.local`, hostname(), ...lanAddresses(), 'localhost', '127.0.0.1', ...extraHosts])];
25
50
  const dir = USER_CERT_DIR;
26
51
  await mkdir(dir, { recursive: true });
27
52
  const cert = join(dir, 'cert.pem');
28
53
  const key = join(dir, 'key.pem');
54
+ let trusted;
29
55
  try {
30
- await execFileP('mkcert', ['-cert-file', cert, '-key-file', key, ...hosts], { timeout: 60_000 });
56
+ const { stderr } = await execFileP('mkcert', ['-cert-file', cert, '-key-file', key, ...hosts], { timeout: 60_000 });
57
+ trusted = !mkcertUntrusted(stderr);
31
58
  }
32
59
  catch (err) {
33
60
  throw new UsageError(`mkcert 签证书失败:${err instanceof Error ? err.message : String(err)}\n先跑 mkcert -install,再重试。`);
34
61
  }
35
- return { cert, key, hosts, caRoot };
62
+ return { cert, key, hosts, caRoot, trusted };
36
63
  }
@@ -1,5 +1,5 @@
1
- /** 本机 Chrome 的可执行文件;找不到 null */
2
- export declare function chromePath(env?: NodeJS.ProcessEnv): string | null;
1
+ /** 本机 Chrome(Windows 上没有 Chrome 就是 Edge)的可执行文件;找不到 null */
2
+ export declare function chromePath(env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform): string | null;
3
3
  export interface Page {
4
4
  goto(url: string): Promise<void>;
5
5
  /** 轮询到页面里这个表达式为真;超时报错 */