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.
- package/LICENSE +9 -7
- package/NOTICE +3 -3
- package/README.md +36 -13
- package/avatars/chinese.webp +0 -0
- package/avatars/english.webp +0 -0
- package/avatars/math.webp +0 -0
- package/bin/cotutor.js +13 -3
- package/cards/photo/card.css +10 -3
- package/cards/photo/card.md +2 -2
- package/cards/tutor/card.css +13 -0
- package/cards/tutor/card.md +2 -1
- package/cards//346/235/277/344/271/246/346/200/216/344/271/210/345/206/231.md +1 -1
- package/dist/app/activity-eval.js +3 -2
- package/dist/app/cli/activities.d.ts +8 -1
- package/dist/app/cli/activities.js +42 -4
- package/dist/app/cli/cert.d.ts +5 -0
- package/dist/app/cli/cert.js +20 -7
- package/dist/app/cli/chalk/browser.d.ts +2 -2
- package/dist/app/cli/chalk/browser.js +22 -7
- package/dist/app/cli/chalk/main.js +3 -2
- package/dist/app/cli/doctor.d.ts +6 -5
- package/dist/app/cli/doctor.js +107 -171
- package/dist/app/cli/firewall.d.ts +24 -0
- package/dist/app/cli/firewall.js +79 -0
- package/dist/app/cli/init.js +8 -5
- package/dist/app/cli/main.js +347 -24
- package/dist/app/cli/migrate.d.ts +2 -11
- package/dist/app/cli/migrate.js +1 -59
- package/dist/app/cli/port-holder.d.ts +16 -0
- package/dist/app/cli/port-holder.js +34 -0
- package/dist/app/cli/port.d.ts +37 -0
- package/dist/app/cli/port.js +116 -0
- package/dist/app/cli/sandbox.d.ts +25 -0
- package/dist/app/cli/sandbox.js +166 -0
- package/dist/app/cli/serve-log.d.ts +13 -0
- package/dist/app/cli/serve-log.js +90 -0
- package/dist/app/cli/serve.d.ts +18 -4
- package/dist/app/cli/serve.js +78 -8
- package/dist/app/cli/service-windows.d.ts +50 -0
- package/dist/app/cli/service-windows.js +117 -0
- package/dist/app/cli/service.d.ts +138 -0
- package/dist/app/cli/service.js +429 -0
- package/dist/app/cli/skeleton.d.ts +1 -1
- package/dist/app/cli/skeleton.js +13 -13
- package/dist/app/cli/skills.d.ts +5 -1
- package/dist/app/cli/skills.js +14 -4
- package/dist/app/cli/tray-icons.d.ts +11 -0
- package/dist/app/cli/tray-icons.js +87 -0
- package/dist/app/cli/tray-ids.d.ts +6 -0
- package/dist/app/cli/tray-ids.js +6 -0
- package/dist/app/cli/tray-mac.d.ts +8 -0
- package/dist/app/cli/tray-mac.js +140 -0
- package/dist/app/cli/tray-run.d.ts +1 -0
- package/dist/app/cli/tray-run.js +33 -0
- package/dist/app/cli/tray-windows.d.ts +5 -0
- package/dist/app/cli/tray-windows.js +166 -0
- package/dist/app/cli/tray.d.ts +205 -0
- package/dist/app/cli/tray.js +618 -0
- package/dist/app/cli/usage.d.ts +1 -1
- package/dist/app/cli/usage.js +11 -6
- package/dist/app/dictation/route.js +3 -2
- package/dist/app/http/app.d.ts +5 -10
- package/dist/app/http/app.js +45 -98
- package/dist/app/http/avatars.d.ts +17 -0
- package/dist/app/http/avatars.js +35 -0
- package/dist/app/http/dev-tutor-files.js +1 -1
- package/dist/app/http/parent-home.d.ts +14 -0
- package/dist/app/http/parent-home.js +69 -6
- package/dist/app/http/parent-look.d.ts +2 -0
- package/dist/app/http/parent-look.js +151 -0
- package/dist/app/http/raw-view.js +3 -3
- package/dist/app/http/voice-preview.d.ts +4 -0
- package/dist/app/http/voice-preview.js +34 -0
- package/dist/app/machine/bailian.d.ts +0 -2
- package/dist/app/machine/bailian.js +5 -6
- package/dist/app/mock/mock-live.js +1 -1
- package/dist/app/mock/mock.d.ts +4 -2
- package/dist/app/mock/mock.js +20 -12
- package/dist/app/replay.js +4 -4
- package/dist/app/skills/home-doc.js +1 -1
- package/dist/app/workspace.d.ts +9 -4
- package/dist/app/workspace.js +33 -10
- package/dist/app/ws/route.d.ts +8 -2
- package/dist/app/ws/route.js +7 -3
- package/dist/board/parse.d.ts +0 -2
- package/dist/board/parse.js +0 -6
- package/dist/board/presentation.d.ts +9 -15
- package/dist/board/presentation.js +8 -10
- package/dist/brain/api-tools.js +2 -1
- package/dist/brain/api.d.ts +12 -17
- package/dist/brain/api.js +16 -33
- package/dist/brain/index.d.ts +23 -71
- package/dist/brain/index.js +6 -69
- package/dist/brain/log.d.ts +5 -0
- package/dist/brain/log.js +22 -0
- package/dist/brain/runtime.d.ts +30 -0
- package/dist/brain/runtime.js +23 -0
- package/dist/brain/{cli/stream-json.d.ts → stream-json.d.ts} +2 -4
- package/dist/brain/{cli/stream-json.js → stream-json.js} +0 -4
- package/dist/brain/stream.d.ts +5 -5
- package/dist/brain/stream.js +7 -7
- package/dist/brain/transcript.d.ts +3 -3
- package/dist/brain/transcript.js +6 -6
- package/dist/brain/{cli/types.d.ts → types.d.ts} +3 -48
- package/dist/brain/types.js +14 -0
- package/dist/chalk/glyphs/skeleton.d.ts +3 -3
- package/dist/chalk/glyphs/skeleton.js +1 -1
- package/dist/lib/index.d.ts +1 -1
- package/dist/lib/index.js +1 -1
- package/dist/memory/diary.js +3 -3
- package/dist/realtime/bailian.d.ts +1 -1
- package/dist/realtime/bailian.js +3 -2
- package/dist/realtime/preview.js +1 -1
- package/dist/realtime/voices.d.ts +2 -0
- package/dist/realtime/voices.js +57 -57
- package/dist/runtime/activities.d.ts +13 -1
- package/dist/runtime/activities.js +28 -2
- package/dist/runtime/activity-state.js +2 -2
- package/dist/runtime/agent-file.d.ts +3 -3
- package/dist/runtime/agent-file.js +3 -3
- package/dist/runtime/context/tutor-rules.d.ts +2 -2
- package/dist/runtime/context/tutor-rules.js +3 -3
- package/dist/runtime/home/home.d.ts +12 -1
- package/dist/runtime/home/home.js +8 -1
- package/dist/runtime/home/store.d.ts +1 -1
- package/dist/runtime/home/store.js +36 -21
- package/dist/runtime/journal/mix.d.ts +15 -6
- package/dist/runtime/journal/mix.js +60 -19
- package/dist/runtime/journal/recorder.d.ts +58 -13
- package/dist/runtime/journal/recorder.js +106 -16
- package/dist/runtime/journal/store.d.ts +4 -4
- package/dist/runtime/journal/store.js +6 -6
- package/dist/runtime/kid-input.js +2 -3
- package/dist/runtime/live/call.d.ts +12 -0
- package/dist/runtime/live/call.js +51 -18
- package/dist/runtime/live/turns.js +1 -1
- package/dist/runtime/runner.d.ts +9 -55
- package/dist/runtime/runner.js +52 -173
- package/dist/scenes/paint/painter.js +4 -3
- package/dist/schema/config.d.ts +43 -22
- package/dist/schema/config.js +45 -47
- package/dist/schema/conversation.d.ts +6 -0
- package/dist/schema/home.d.ts +11 -1
- package/dist/schema/home.js +7 -2
- package/dist/schema/workspace.d.ts +27 -0
- package/dist/schema/workspace.js +28 -0
- package/dist/shared/default-port.d.ts +2 -0
- package/dist/shared/default-port.js +2 -0
- package/dist/shared/qianwen.d.ts +14 -10
- package/dist/shared/qianwen.js +26 -22
- package/dist/shared/site-url.d.ts +2 -0
- package/dist/shared/site-url.js +5 -0
- package/dist/ui/dev/dev-page.js +25 -252
- package/dist/ui/dev/dev-theme.d.ts +1 -1
- package/dist/ui/dev/dev-theme.js +1 -1
- package/dist/ui/dev/voice-gallery-page.js +1 -1
- package/dist/ui/kid/board-mark.d.ts +2 -2
- package/dist/ui/kid/board-mark.js +2 -2
- package/dist/ui/kid/home-tiles.d.ts +14 -0
- package/dist/ui/kid/home-tiles.js +44 -0
- package/dist/ui/kid/kid-page.js +68 -103
- package/dist/ui/kid/voice-ui.d.ts +1 -1
- package/dist/ui/kid/voice-ui.js +1 -1
- package/dist/ui/kid/word-emoji.js +5 -3
- package/dist/ui/parent/parent-home-page.js +315 -7
- package/dist/ui/parent/parent-page.js +6 -2
- package/dist/ui/parent/parent-settings-page.d.ts +1 -0
- package/dist/ui/parent/parent-settings-page.js +133 -0
- package/dist/ui/{dev → parent}/qr-page.d.ts +2 -2
- package/dist/ui/{dev → parent}/qr-page.js +19 -14
- package/dist/ui/shared/brand.d.ts +3 -1
- package/dist/ui/shared/brand.js +3 -1
- package/grades/grade-2/README.md +1 -1
- package/grades/grade-2/VALIDATION.md +1 -1
- package/grades/grade-2/agents/chinese-tutor/activities//345/244/215/344/271/240.md +1 -1
- 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
- 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
- package/grades/grade-2/agents/chinese-tutor/activities//351/242/204/344/271/240.md +1 -1
- package/grades/grade-2/agents/english-tutor/activities//345/244/215/344/271/240.md +1 -1
- 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
- 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
- package/grades/grade-2/agents/english-tutor/activities//351/242/204/344/271/240.md +1 -1
- package/grades/grade-2/agents/math-tutor/activities//345/244/215/344/271/240.md +1 -1
- 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
- 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
- package/grades/grade-2/agents/math-tutor/activities//351/242/204/344/271/240.md +1 -1
- package/package.json +2 -1
- package/skills/cotutor-analyze/references//345/221/275/344/273/244/344/270/216/346/226/207/344/273/266.md +2 -2
- package/skills/cotutor-board/references/photo.md +2 -2
- package/skills/cotutor-home/SKILL.md +1 -0
- package/skills/cotutor-home/references/tutor.md +2 -1
- package/skills/cotutor-home/references//351/246/226/351/241/265/350/257/255/346/263/225.md +2 -1
- package/skills/cotutor-tune/references//345/255/227/346/256/265.md +6 -3
- package/skills/cotutor-tutor/SKILL.md +5 -13
- package/dist/brain/cli/adapters.d.ts +0 -60
- package/dist/brain/cli/adapters.js +0 -91
- package/dist/brain/cli/qwen.d.ts +0 -11
- package/dist/brain/cli/qwen.js +0 -141
- package/dist/brain/cli/run-plan.d.ts +0 -59
- package/dist/brain/cli/run-plan.js +0 -71
- package/dist/brain/cli/run.d.ts +0 -35
- package/dist/brain/cli/run.js +0 -256
- package/dist/brain/cli/types.js +0 -3
- package/dist/brain/cli/warm.d.ts +0 -53
- package/dist/brain/cli/warm.js +0 -103
package/dist/app/cli/usage.js
CHANGED
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
/** 命令用法(cotutor --help;技能 cotutor-analyze 的 references/命令与文件.md 从这里取分析用的那几行,所以放单独文件,别的模块 import 不会跑到 CLI) */
|
|
2
2
|
export const USAGE = `用法:
|
|
3
|
-
cotutor init <slug> [--dir <path>] [--name <孩子名>] [--grade <1-6>] [--port <n>] 建 ~/cotutor/<slug>/ 骨架(
|
|
3
|
+
cotutor init <slug> [--dir <path>] [--name <孩子名>] [--grade <1-6>] [--port <n>] 建 ~/cotutor/<slug>/ 骨架(幂等补缺;默认二年级教学包;端口在这里选一次写进 cotutor.json:443 优先,不行就 8443、8445…,以后不变)
|
|
4
4
|
cotutor grades [--json] 列出本包可安装的年级教学包
|
|
5
|
-
cotutor doctor [--workspace <dir>] [--json] [--live] 逐项体检;--live
|
|
5
|
+
cotutor doctor [--workspace <dir>] [--json] [--live] 逐项体检;--live 真连一次百炼实时模型、真合成一句配音(花一分钱)把 API 层的坑摆出来(真跑老师用 cotutor send)
|
|
6
6
|
cotutor upgrade [--workspace <dir>] [--force <老师>]... 老师文件、activity 与 skill 换新版:没改过的直接换,改过的只报 diff(--force <老师> 或 --force <老师>/<名> 才覆盖,原文留 .bak)
|
|
7
7
|
cotutor upgrade --config [--dry-run] [--workspace <dir>] cotutor.json 补缺:新出厂老师 / 运行时 / 命令模板旗标(只加缺的,你改过的值不动)
|
|
8
8
|
cotutor add <老师名> --display <显示名> [--subject <学科>] [--avatar <emoji|图片相对路径>] [--hidden] 加一位自家的老师:出模板文件、进 cotutor.json、建目录(名字以 -tutor 结尾的是老师,figshot 给它做头像)
|
|
9
9
|
cotutor add-theme <主题名> [--from <主题>] [--workspace <dir>] 加一个自家的主题:拷一份(缺省出厂的 default)到 themes/<主题名>/,改 cotutor.json 的 kid.theme 换过去
|
|
10
|
-
cotutor serve [--workspace <dir>] [--port <n>] [--http] [--trace] [--open-
|
|
10
|
+
cotutor serve [--workspace <dir>] [--port <n>] [--http] [--trace] [--open] [--log-dir <目录>] 在终端里起服务(一 workspace 一进程;~/.config/cotutor/certs/ 有证书就走 HTTPS;--trace 每一轮的事件按道打印;只打印家长端首页的地址,孩子的平板在那里扫码;--open 顺手打开,旧名 --open-qr 照样认;--log-dir 输出按天落文件,后台服务用;关了终端就停,一直开着用 cotutor service)
|
|
11
|
+
cotutor open [--workspace <dir>] [--sandbox] [--print] [--json] 打开家长端(读 cotutor.json,不用记端口);--sandbox 开试用的那个(端口永远是真的 +1);--print 只打印地址;没在跑就说怎么起,以 1 退
|
|
12
|
+
cotutor service install | uninstall | start | stop | restart [--workspace <dir>] [--no-tray] 把 serve 装成这台电脑的后台服务(macOS launchd、Linux systemd 用户服务、Windows 用户的登录项,都不要管理员):登录后自己起来、崩了 10 秒后再起、日志按天落文件留 14 天;一个 workspace 一个;换了 node 版本或包挪了地方再 install 一次
|
|
13
|
+
cotutor tray on | off | status [--json] 菜单栏 / 任务栏图标(macOS、Windows;service install 时一起装,--no-tray 不带):正常时只有 logo,有事出橙点,没在跑时变淡加红点并通知一次;点它看状态、打开家长端、重启、看日志、退出;off 关掉并记住
|
|
14
|
+
cotutor service status [--json] | logs [--lines <n>] [--follow] 后台服务在不在跑、端口答不答应、跑的哪个版本、vault 读不读得到、日志在哪;logs 看最近的日志
|
|
11
15
|
cotutor pin [off | status] [--pin <数字>] [--json] 家长端 /parent 与工作台 /dev 的锁:机器级 PIN(所有 workspace 共用),iPad 上输一次、这台电脑免密;没设就全开(家长在旁边可以不设);off 撤掉
|
|
12
|
-
cotutor cert [--host <名或IP>]... 用 mkcert 建这台机器的自签证书到 ~/.config/cotutor/certs/(
|
|
13
|
-
cotutor send <老师> [<消息>] [--from kid|system] [--runtime <名>] [--new] [--photo a.jpg,b.png] [--activity <名> [--param '名=值;名=值']] [--lane main,tts] [--quiet] 终端里发一条,现场按道打印每道工序的事件,说完打印结果(
|
|
16
|
+
cotutor cert [--host <名或IP>]... 用 mkcert 建这台机器的自签证书到 ~/.config/cotutor/certs/(平板、手机上录音要 HTTPS;所有 workspace 共用)
|
|
17
|
+
cotutor send <老师> [<消息>] [--from kid|system] [--runtime <名>] [--new] [--photo a.jpg,b.png] [--activity <名> [--param '名=值;名=值']] [--lane main,tts] [--quiet] 终端里发一条,现场按道打印每道工序的事件,说完打印结果(孩子的话照实时写,同孩子端:上下文包带 live:、不补提问卡,只是没有嘴;--from system 不算;--new 开新话题;--photo 带作业照片,拷进 captures/,有照片时消息可以空;--activity 像孩子点了首页的 activity 按钮,新开话题,参数同按钮下面那几行;--quiet 只要结果)
|
|
14
18
|
cotutor pack <老师> [<消息>] [--from kid|system] [--at <ISO时间>] [--activity <名> [--param '名=值;名=值']] [--json] 干跑上下文包(--activity 看 activity 话题第一条老师拿到什么):不起模型,打印现在会发给老师的那份 + 每段来自哪个文件、那里一共几条、按政策带了几条(改了档案 / 日记 / 计划立刻看效果)
|
|
15
19
|
cotutor replay <老师> <job> [<日期>] [--runtime <名>] [--json] 回放一轮:同一问按现在的 vault 与提示词再跑一遍(落 evals/,不进孩子的对话、不配音),跑完并排打印上下文包 / 讲稿 / 卡 / 读了什么的 diff
|
|
16
20
|
cotutor compare <老师> [<evalJob>] [<日期>] [--json] 再看一次回放的对照(不给 evalJob 就列这天回放过哪些)
|
|
@@ -22,9 +26,10 @@ export const USAGE = `用法:
|
|
|
22
26
|
cotutor home publish [--force] [--from <文件>] [--json] 发布首页:检查 → 留历史 home/history/<id>.md → 写 home/published.json(孩子端只读它);有要改的不发,--force 丢掉那几张照发;--from 发另一份(如历史)
|
|
23
27
|
cotutor home show [--json] 现在发布的是哪份、几天前、每个按钮被点了几次(哪天、哪个话题)
|
|
24
28
|
cotutor activity check [<老师> [<名>]] [--json] 检查 activity 文件(agents/<老师>/activities/<名>.md):家长填的、应用算的、应用拦的、裁段、插槽、正文用的卡、第一句与收(带行号);不花钱
|
|
25
|
-
cotutor activity eval <老师> <名> [--runs <n>] [--only <例子>] [--kid-model <千问模型>] [--json] 照 <名>.例子.md 跑:真老师 + 模拟孩子(千问,缺省 qwen3.8-flash,有 Token Plan 走它)
|
|
29
|
+
cotutor activity eval <老师> <名> [--runs <n>] [--only <例子>] [--kid-model <千问模型>] [--json] 照 <名>.例子.md 跑:真老师 + 模拟孩子(千问,缺省 qwen3.8-flash,有 Token Plan 走它)多轮(照实时写,同孩子端),判只看板书;一个例子跑 n 次(缺省 3)次次都过才算过;落 evals/,画家是假的;花钱
|
|
26
30
|
cotutor chalk <check|build|bake|snap|dub> <scene.ts|课包 id> [...] 做小课堂的课包:check 校验场景、build 出课包到 bundles/、bake 烤画面、snap 每步截图、dub 配音(cotutor chalk 看各自的选项;bake / snap 要本机的 Chrome)
|
|
27
31
|
cotutor mock [--port <n>] [--scenario normal|nokey] [--delay <ms>] [--http] 不经真实老师与百炼,用固定的板书 JSON 起孩子端,测前端交互与渲染(不需要 workspace;老师都走实时老师页,假嘴照脚本念静音的声音;nokey = 没配百炼,孩子端只出「老师们还没到」)
|
|
28
32
|
cotutor --version | --help
|
|
29
33
|
workspace解析:--workspace > COTUTOR_WORKSPACE > cwd 或祖先有 cotutor.json > ~/.config/cotutor/config.json > ~/cotutor/ 下唯一的孩子目录
|
|
34
|
+
试用:要 workspace 的命令加 --sandbox,换到真 workspace 旁边的 .sandbox/<目录名>/,试的对话与 vault 的写入不进孩子的记录(第一次自动分:设置照拷,对话与照片不带,vault 拷一份;标题前加「试用」、端口 +1、自动记账关掉);--sandbox --fresh 重新分
|
|
30
35
|
`;
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
import { join } from 'node:path';
|
|
26
26
|
import { DICTATION_SAY_MAX, DICTATION_WORD_MAX, DICTATION_WORDS_MAX, DictationPropsSchema, dictationEmoji, dictationWordLang } from "../../board/cards/index.js";
|
|
27
27
|
import { localDate } from "../../shared/time.js";
|
|
28
|
+
import { cardVoice } from "../../schema/index.js";
|
|
28
29
|
import { readPublished } from "../../runtime/home/store.js";
|
|
29
30
|
import { tianzigeData } from "../../ui/kid/tianzige.js";
|
|
30
31
|
import { synthesize } from "../../media/tts.js";
|
|
@@ -42,7 +43,7 @@ import { EVENT_KINDS, ID_RE, createPhoto, createSession, listSessions, photoFile
|
|
|
42
43
|
/** 真服务的依赖:首页从 home/published.json 读,音色从 cotutor.json,配音缓存在 .cotutor/tts/(和卡的配音同一份) */
|
|
43
44
|
export function dictationDeps(ctx) {
|
|
44
45
|
const ws = () => ctx.ws;
|
|
45
|
-
const voiced = (t) => Boolean(ws().config.tutors
|
|
46
|
+
const voiced = (t) => Boolean(cardVoice(ws().config.tutors, t));
|
|
46
47
|
return {
|
|
47
48
|
root: ctx.ws.root,
|
|
48
49
|
now: ctx.now,
|
|
@@ -72,7 +73,7 @@ export function dictationDeps(ctx) {
|
|
|
72
73
|
return Boolean(tutor && voiced(tutor));
|
|
73
74
|
},
|
|
74
75
|
async say(text, tutor) {
|
|
75
|
-
const voice = tutor ? ws().config.tutors
|
|
76
|
+
const voice = tutor ? cardVoice(ws().config.tutors, tutor) : undefined;
|
|
76
77
|
if (!voice)
|
|
77
78
|
return null;
|
|
78
79
|
const r = await synthesize({ text, voice }, { cacheDir: ws().dirs.tts, env: process.env });
|
package/dist/app/http/app.d.ts
CHANGED
|
@@ -7,8 +7,8 @@ import { type ConversationIndex, type ConversationMessage, type TutorExtra } fro
|
|
|
7
7
|
import type { BoardCard, Device } from '../../board/presentation.ts';
|
|
8
8
|
import type { Workspace } from '../../schema/index.ts';
|
|
9
9
|
import { type Access, type Who } from './access.ts';
|
|
10
|
-
import { type ListenInfo } from '../../ui/
|
|
11
|
-
import { Runner
|
|
10
|
+
import { type ListenInfo } from '../../ui/parent/qr-page.ts';
|
|
11
|
+
import { Runner } from '../../runtime/runner.ts';
|
|
12
12
|
import { type Bailian } from '../machine/bailian.ts';
|
|
13
13
|
import { type VaultWrite } from '../../runtime/journal/store.ts';
|
|
14
14
|
import type { Draw } from '../../media/draw.ts';
|
|
@@ -35,6 +35,8 @@ export interface AppContext {
|
|
|
35
35
|
configError: string | null;
|
|
36
36
|
/** 在哪个地址上听着(serve 在 listen 之后填;扫码页 /qr 每次现问——局域网 IP 会在服务跑着的时候变)。没起端口(测试走 route)→ null */
|
|
37
37
|
listen: (() => ListenInfo) | null;
|
|
38
|
+
/** 现在开着几通电话(serve 接 ws 那边的数;没起端口 → null) */
|
|
39
|
+
openCalls: (() => number) | null;
|
|
38
40
|
/** 重读 cotutor.json(改了才读) */
|
|
39
41
|
reload(): Promise<void>;
|
|
40
42
|
/** 家长端与工作台的锁(access.ts):机器级 PIN,没设就全开;测试可注入 */
|
|
@@ -45,7 +47,6 @@ export interface AppContext {
|
|
|
45
47
|
export declare function createContext(ws: Workspace, opts?: {
|
|
46
48
|
now?: () => Date;
|
|
47
49
|
env?: NodeJS.ProcessEnv;
|
|
48
|
-
warm?: boolean;
|
|
49
50
|
draw?: Draw;
|
|
50
51
|
access?: Access;
|
|
51
52
|
bailian?: Bailian;
|
|
@@ -89,7 +90,7 @@ export interface KidHome {
|
|
|
89
90
|
figshot: {
|
|
90
91
|
port: number;
|
|
91
92
|
} | null;
|
|
92
|
-
/**
|
|
93
|
+
/** 按住说话那一栏在哪边(cotutor.json 的 kid.hand,家长端设置页改;缺省 right = 在左边):left = 平板横屏实时老师页的那一栏挪到右边 */
|
|
93
94
|
hand: 'right' | 'left';
|
|
94
95
|
/** 百炼配好了:没配孩子端只出「老师们还没到」,家长端配 */
|
|
95
96
|
ready: boolean;
|
|
@@ -115,12 +116,6 @@ export interface KidHistory {
|
|
|
115
116
|
}
|
|
116
117
|
export declare function kidHistory(ctx: AppContext, tutor: string, days: number): Promise<KidHistory>;
|
|
117
118
|
export declare function kidDay(ctx: AppContext, tutor: string, date: string): Promise<KidDay>;
|
|
118
|
-
/** 孩子端发一条的验与拼(旧入口,测试用):Runner.accept(./kid-input.ts)的结果换成路由的形状 */
|
|
119
|
-
export declare function kidSendInput(ctx: AppContext, tutor: string, body: unknown, date: string, deviceId?: string | null): Promise<{
|
|
120
|
-
input: SendInput;
|
|
121
|
-
} | {
|
|
122
|
-
error: RouteResult;
|
|
123
|
-
}>;
|
|
124
119
|
/** 家长板书页的一天:形状同 KidDay;答案不剥,系统发的也在 */
|
|
125
120
|
export interface ParentDay {
|
|
126
121
|
tutor: string;
|
package/dist/app/http/app.js
CHANGED
|
@@ -3,14 +3,13 @@
|
|
|
3
3
|
* R1 的查询接口照旧;R2 加:对话(列日期、看一天的家长视图、发消息)、cotutor.json 补丁(老师团页)、工作台 /dev(2026-09-22 之前叫家长页 /parent;
|
|
4
4
|
* 现在 /parent 是家长端 = 家长板书页,给不懂技术的家长日常用)。
|
|
5
5
|
* R3 加:孩子端 `/`(kidPage)与 /api/kid/*(首页:课程表 + 老师卡 + 家长发布的首页卡;对话:服务端过滤后的孩子视图;发消息:from 固定 kid)、配音文件 /api/audio。
|
|
6
|
-
*
|
|
6
|
+
* 配置热重载:每个请求先读一遍 cotutor.json,原文变了就重读(不比修改时间,Windows 上连写两次常常一样);改坏了留旧配置并把错误挂在 /api/health 上。
|
|
7
7
|
*/
|
|
8
|
-
import { createHash } from 'node:crypto';
|
|
9
8
|
import { existsSync } from 'node:fs';
|
|
10
|
-
import { appendFile, mkdir, readFile, readdir, stat
|
|
9
|
+
import { appendFile, mkdir, readFile, readdir, stat } from 'node:fs/promises';
|
|
11
10
|
import { join } from 'node:path';
|
|
12
11
|
import { foldRuns } from "../../brain/transcript.js";
|
|
13
|
-
import { RuntimeError } from "../../brain/
|
|
12
|
+
import { RuntimeError } from "../../brain/index.js";
|
|
14
13
|
import { PHOTO_MAX_SIDE } from "../../ui/kid/photo-edit.js";
|
|
15
14
|
import { currentThread, lastJobOf, sameRuntime } from "../../runtime/journal/conversation.js";
|
|
16
15
|
import { localDate, weekOf } from "../../shared/time.js";
|
|
@@ -21,9 +20,7 @@ import {} from "../../schema/index.js";
|
|
|
21
20
|
import { NEUTRAL_COLOR, SUBJECT_COLORS, TUTOR_PALETTE, tutorColors } from "../../ui/shared/tutor-color.js";
|
|
22
21
|
import { boardMarkOf } from "../../ui/kid/board-mark.js";
|
|
23
22
|
import { buildReel, playRecordOk, reelCardTook, reelHolds, reelMissRuns } from "../../ui/kid/reel.js";
|
|
24
|
-
import {
|
|
25
|
-
import { previewVoice } from "../../realtime/preview.js";
|
|
26
|
-
import { DATE_RE, FocusSchema, MESSAGE_FROM, MOUTH_VOICE, listTutors, mouthVoice, subjectLang } from "../../schema/index.js";
|
|
23
|
+
import { DATE_RE, FocusSchema, MESSAGE_FROM, listTutors, subjectLang, viaSendsLabel } from "../../schema/index.js";
|
|
27
24
|
import { ConfigError, patchConfig, reloadIfChanged, workspaceReport } from "../workspace.js";
|
|
28
25
|
import { UsageError } from "../../shared/errors.js";
|
|
29
26
|
import { redactHome } from "../../shared/home.js";
|
|
@@ -32,10 +29,11 @@ import { configGapsOf, upgradeConfig } from "../cli/migrate.js";
|
|
|
32
29
|
import { ICON_SIZES, appIconPng, webManifest } from "../../ui/shared/icon.js";
|
|
33
30
|
import { faviconResponse } from "../../ui/shared/favicon.js";
|
|
34
31
|
import { cookieOf, createAccess, gatePage, isKidPath } from "./access.js";
|
|
32
|
+
import { factoryAvatarFile, shownAvatar } from "./avatars.js";
|
|
35
33
|
import { deviceCookie, newDeviceId, parseKidLog, readDevices, recordKidLog, renameDevice, validDevice } from "../../runtime/journal/kid-log.js";
|
|
36
34
|
import { DEVICE_COOKIE } from "../../schema/kid-device.js";
|
|
37
35
|
import { BUILD, codeChanged, headNow, shortCommit } from "../build.js";
|
|
38
|
-
import { qrPage } from "../../ui/
|
|
36
|
+
import { qrPage } from "../../ui/parent/qr-page.js";
|
|
39
37
|
import { kidPage } from "../../ui/kid/kid-page.js";
|
|
40
38
|
import { parentPage } from "../../ui/parent/parent-page.js";
|
|
41
39
|
import { kidHomeView } from "../../runtime/home/store.js";
|
|
@@ -50,14 +48,16 @@ import { BusyError, Runner } from "../../runtime/runner.js";
|
|
|
50
48
|
import { sendFile } from "./send-file.js";
|
|
51
49
|
import { talkRoute } from "../talk/route.js";
|
|
52
50
|
import { parentHomeRoute } from "./parent-home.js";
|
|
51
|
+
import { parentLookRoute } from "./parent-look.js";
|
|
53
52
|
import { bailianRoute, createBailian } from "../machine/bailian.js";
|
|
54
53
|
import { PARENT_HOME_PAGE } from "../../ui/parent/parent-home-page.js";
|
|
54
|
+
import { PARENT_SETTINGS_PAGE } from "../../ui/parent/parent-settings-page.js";
|
|
55
55
|
import { dictationRoute } from "../dictation/route.js";
|
|
56
56
|
import { IndexError, capturePathOk, deleteThread, listDates, rateThread, readErrLog, readIndex, readTranscript, scanCards, undoVaultWrite, writeCapture, writeCardImage, writeCardState } from "../../runtime/journal/store.js";
|
|
57
57
|
import { memoryLineText } from "../../memory/vault-notes.js";
|
|
58
58
|
import { childItem } from "../../memory/proposals.js";
|
|
59
59
|
import { IMAGE_EXT, parseCardState, stripSecrets } from "../../board/cards/index.js";
|
|
60
|
-
import { resolve, sep } from 'node:path';
|
|
60
|
+
import { isAbsolute, resolve, sep } from 'node:path';
|
|
61
61
|
import { bundleAsset, stageAsset, stageIndex, stageVersion } from "./stage.js";
|
|
62
62
|
import { themeFiles } from "./theme.js";
|
|
63
63
|
import { enrichPaints } from "../../scenes/paint/painter.js";
|
|
@@ -67,24 +67,24 @@ import { lettersData } from "../../ui/kid/letters.js";
|
|
|
67
67
|
import { fixtureOf, rawView } from "./raw-view.js";
|
|
68
68
|
import { DeviceSchema } from "../../schema/index.js";
|
|
69
69
|
import { CanvasStateSchema } from "../../board/cards/canvas.js";
|
|
70
|
-
import { PAPER_KEY_RE,
|
|
71
|
-
import { synthesize, TTS_VOICES } from "../../media/tts.js";
|
|
70
|
+
import { PAPER_KEY_RE, dropPaper, readPaper, writePaper } from "../../board/paper.js";
|
|
72
71
|
import { addTutorFile, readTutorFile, removeTutorFile, writeTutorFile } from "../cli/tutors.js";
|
|
73
72
|
export function createContext(ws, opts = {}) {
|
|
74
|
-
let
|
|
73
|
+
let configText = null;
|
|
75
74
|
const ctx = {
|
|
76
75
|
ws,
|
|
77
76
|
runner: new Runner(() => ctx.ws, opts),
|
|
78
77
|
now: opts.now ?? (() => new Date()),
|
|
79
78
|
configError: null,
|
|
80
79
|
listen: null,
|
|
80
|
+
openCalls: null,
|
|
81
81
|
access: opts.access ?? createAccess({ now: () => ctx.now() }),
|
|
82
82
|
bailian: opts.bailian ?? createBailian({ env: opts.env }),
|
|
83
83
|
async reload() {
|
|
84
84
|
try {
|
|
85
|
-
const r = await reloadIfChanged(ctx.ws,
|
|
85
|
+
const r = await reloadIfChanged(ctx.ws, configText);
|
|
86
86
|
ctx.ws = r.ws;
|
|
87
|
-
|
|
87
|
+
configText = r.text;
|
|
88
88
|
ctx.configError = null;
|
|
89
89
|
}
|
|
90
90
|
catch (err) {
|
|
@@ -102,7 +102,7 @@ export async function dayView(ctx, tutor, date) {
|
|
|
102
102
|
const runs = {};
|
|
103
103
|
const errors = {};
|
|
104
104
|
for (const m of index.messages) {
|
|
105
|
-
const t = await readTranscript(ctx.ws, tutor, date, m.job
|
|
105
|
+
const t = await readTranscript(ctx.ws, tutor, date, m.job);
|
|
106
106
|
runs[m.job] = t ? foldRuns(t.items) : [];
|
|
107
107
|
if (m.result === 'error') {
|
|
108
108
|
const tail = (await readErrLog(ctx.ws, tutor, date, m.job)).trim().split('\n').slice(-5).join('\n');
|
|
@@ -118,7 +118,7 @@ export async function kidTutors(ctx) {
|
|
|
118
118
|
const out = [];
|
|
119
119
|
const colors = tutorColors(ctx.ws.config.tutors);
|
|
120
120
|
for (const t of listTutors(ctx.ws.config, { kidOnly: true })) {
|
|
121
|
-
out.push({ name: t.name, display: t.display, avatar: t
|
|
121
|
+
out.push({ name: t.name, display: t.display, avatar: shownAvatar(t), subject: t.subject ?? null, color: colors[t.name], mark: boardMarkOf(t.subject), lang: subjectLang(t.subject), hasVoice: Boolean(t.voice), extras: t.extras.filter((x) => (x !== 'talk' || ctx.ws.config.talk.enabled)), listen: ctx.ws.config.live.listen });
|
|
122
122
|
}
|
|
123
123
|
return out;
|
|
124
124
|
}
|
|
@@ -162,11 +162,6 @@ export async function kidDay(ctx, tutor, date) {
|
|
|
162
162
|
}
|
|
163
163
|
return { tutor, date, messages, pending: active && active.date === date ? active.job : null, thread: currentThread(index) };
|
|
164
164
|
}
|
|
165
|
-
/** 孩子端发一条的验与拼(旧入口,测试用):Runner.accept(./kid-input.ts)的结果换成路由的形状 */
|
|
166
|
-
export async function kidSendInput(ctx, tutor, body, date, deviceId) {
|
|
167
|
-
const r = await ctx.runner.accept(tutor, body, date, deviceId);
|
|
168
|
-
return 'reject' in r ? { error: { status: r.reject.status, json: { error: r.reject.error, ...(r.reject.message ? { message: r.reject.message } : {}) } } } : r;
|
|
169
|
-
}
|
|
170
165
|
export async function parentDay(ctx, tutor, date) {
|
|
171
166
|
const index = await readIndex(ctx.ws, tutor, date);
|
|
172
167
|
const active = ctx.runner.running(tutor);
|
|
@@ -249,7 +244,7 @@ export async function parentReel(ctx, tutor, date, thread) {
|
|
|
249
244
|
const kid = await kidDay(ctx, tutor, date);
|
|
250
245
|
const device = [...mine].reverse().find((m) => m.device)?.device ?? null;
|
|
251
246
|
const t = ctx.ws.config.tutors[tutor];
|
|
252
|
-
const face = { name: tutor, display: t?.display ?? tutor, avatar: t
|
|
247
|
+
const face = { name: tutor, display: t?.display ?? tutor, avatar: t ? shownAvatar(t) : null, subject: t?.subject ?? null, color: tutorColors(ctx.ws.config.tutors)[tutor] ?? NEUTRAL_COLOR, mark: boardMarkOf(t?.subject) };
|
|
253
248
|
const live = date === localDate(ctx.now()) || mine.some((m) => m.result === 'running');
|
|
254
249
|
return { tutor, date, thread, reel, messages: day.messages.filter((m) => m.thread === thread), kid: kid.messages.filter((m) => m.thread === thread), device, face, live, realtime: mine.some((m) => m.live) };
|
|
255
250
|
}
|
|
@@ -316,7 +311,7 @@ export async function overview(ctx, date) {
|
|
|
316
311
|
if (m.lecture && !th.lecture)
|
|
317
312
|
th.lecture = { bundle: m.lecture.bundle, title: m.lecture.title, ...(m.lecture.video ? { video: true } : {}), watchedMs: m.lecture.watchedMs, finished: m.lecture.finished, marks: (m.lecture.marks ?? []).length };
|
|
318
313
|
// 孩子自己说的第一句:首页开场 / 接着按钮发出去的那句(正文就是按钮上的字)不算
|
|
319
|
-
if (th.said === null && m.from === 'kid' && m.text.trim() && !(
|
|
314
|
+
if (th.said === null && m.from === 'kid' && m.text.trim() && !(m.via && viaSendsLabel(m.via) && m.via.label === m.text))
|
|
320
315
|
th.said = Array.from(m.text.trim()).slice(0, 30).join('');
|
|
321
316
|
const t0 = Date.parse(m.timing?.startedAt ?? '');
|
|
322
317
|
const dt = m.timing?.doneMs;
|
|
@@ -357,7 +352,7 @@ export async function overview(ctx, date) {
|
|
|
357
352
|
th.misses = n;
|
|
358
353
|
}
|
|
359
354
|
const booking = index.messages.some((m) => (m.bookkeep || m.tidy) && m.result === 'running');
|
|
360
|
-
tutors.push({ name: t.name, display: t.display, avatar: t
|
|
355
|
+
tutors.push({ name: t.name, display: t.display, avatar: shownAvatar(t), subject: t.subject ?? null, color: colors[t.name] ?? NEUTRAL_COLOR, turns: index.messages.length, threads: [...by.values()], booking, extras: t.extras.filter((x) => (x !== 'talk' || ctx.ws.config.talk.enabled)) });
|
|
361
356
|
}
|
|
362
357
|
const today = localDate(ctx.now());
|
|
363
358
|
const names = tutors.map((t) => t.name);
|
|
@@ -416,9 +411,6 @@ const MOUTH_FILE_RE = /^\d{4}-\d{2}-\d{2}\.\d{4}-\d+\.mouth\.wav$/;
|
|
|
416
411
|
const IMAGE_TYPES = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', webp: 'image/webp', gif: 'image/gif', svg: 'image/svg+xml' };
|
|
417
412
|
/** 作业照片一张最多这么大(解码后;页面先缩到长边 PHOTO_MAX_SIDE 的 jpeg,通常几百 KB) */
|
|
418
413
|
export const PHOTO_MAX_BYTES = 3_000_000;
|
|
419
|
-
/** 音色页的试听句;与 tutors.*.voice 里合法的 id 形状(qwen-audio-3.0-tts-plus-xxx) */
|
|
420
|
-
const PREVIEW_TEXT = '你好呀,我是你的老师。今天我们一起来学一个新东西,准备好了吗?';
|
|
421
|
-
const VOICE_ID_RE = /^[A-Za-z0-9._:-]{1,120}$/;
|
|
422
414
|
/**
|
|
423
415
|
* 上传作业照片(R5):body {image: "data:image/jpeg;base64,…"}(也认 png)→ 落 captures/<日期>/<HHMM>-<n>.jpg → {path}。
|
|
424
416
|
* 孩子端与家长端同一条路;照片只是落盘,发消息时把 path 放进 photos[] 才到老师那里。
|
|
@@ -457,11 +449,6 @@ async function paperRoute(ctx, tutor, date, method, url, body) {
|
|
|
457
449
|
await dropPaper(ws, tutor, date, 'new');
|
|
458
450
|
return { status: 200, json: { ok: true, key } };
|
|
459
451
|
}
|
|
460
|
-
/** 页面拉今天时的预热参数:?thread= 是页面选着的话题;同一话题 30 秒看一次 */
|
|
461
|
-
function warmOpts(url) {
|
|
462
|
-
const thread = url.searchParams.get('thread');
|
|
463
|
-
return { throttleMs: 30_000, ...(thread ? { thread } : {}) };
|
|
464
|
-
}
|
|
465
452
|
/** who:请求是谁发的(createHandler 按来源地址与 cookie 算);直接调 route 的测试与 CLI 缺省 local = 本机免密 */
|
|
466
453
|
export async function route(method, path, ctx, body, who = 'local', client = {}) {
|
|
467
454
|
await ctx.reload();
|
|
@@ -469,10 +456,12 @@ export async function route(method, path, ctx, body, who = 'local', client = {})
|
|
|
469
456
|
const url = new URL(path, 'http://x');
|
|
470
457
|
const p = url.pathname;
|
|
471
458
|
try {
|
|
472
|
-
// 服务起来的时刻、版本、跑的是哪个提交;仓里的提交变了 = 代码更新了,重起才用上(build.ts)
|
|
459
|
+
// 服务起来的时刻、版本、跑的是哪个提交;仓里的提交变了 = 代码更新了,重起才用上(build.ts)。
|
|
460
|
+
// pid、mode(后台服务 service / 终端里 foreground)、vault 读不读得到(macOS 上后台服务读「文稿」、iCloud 云盘里的 vault 会被系统拦,没有弹窗):cotutor service status 看
|
|
473
461
|
if (p === '/api/health') {
|
|
474
462
|
const head = await headNow();
|
|
475
|
-
|
|
463
|
+
const vault = await readdir(ws.paths.vault).then(() => 'ok', (err) => (err.code === 'EPERM' || err.code === 'EACCES' ? 'denied' : 'missing'));
|
|
464
|
+
return { status: 200, json: { ok: true, configError: ctx.configError, started: BUILD.started, version: BUILD.version, commit: shortCommit(BUILD.commit), dirty: BUILD.dirty, head: shortCommit(head), changed: codeChanged(BUILD.commit, head), pid: process.pid, mode: process.env.COTUTOR_SERVICE ? 'service' : 'foreground', vault, calls: ctx.openCalls?.() ?? 0, bailian: ctx.bailian.status().ready } };
|
|
476
465
|
}
|
|
477
466
|
// 家长端与工作台的锁(access.ts):对 PIN 换 cookie;设了 PIN、没凭证的设备,孩子端以外的页面回输 PIN 的页、接口回 401。没设 PIN 什么都不拦
|
|
478
467
|
if (p === '/api/auth') {
|
|
@@ -592,7 +581,7 @@ export async function route(method, path, ctx, body, who = 'local', client = {})
|
|
|
592
581
|
policyDefaults: ws.config.policyDefaults,
|
|
593
582
|
paths: ws.config.paths,
|
|
594
583
|
resolvedPaths: Object.fromEntries(Object.entries(ws.paths).map(([k, v]) => [k, redactHome(v)])),
|
|
595
|
-
/** 实时老师:总开关、嘴的模型、哪几位老师走实时用哪个音色(
|
|
584
|
+
/** 实时老师:总开关、嘴的模型、哪几位老师走实时用哪个音色(家长端「样子与声音」改) */
|
|
596
585
|
live: ws.config.live,
|
|
597
586
|
theme: ws.config.kid.theme,
|
|
598
587
|
https: ws.config.server.https ?? null,
|
|
@@ -611,9 +600,8 @@ export async function route(method, path, ctx, body, who = 'local', client = {})
|
|
|
611
600
|
if (method === 'PATCH') {
|
|
612
601
|
if (!isObj(body))
|
|
613
602
|
return { status: 400, json: { error: 'bad_request', message: '要一个 JSON 对象' } };
|
|
614
|
-
//
|
|
615
|
-
|
|
616
|
-
await patchConfig(ws, body, handOnly ? { also: ['kid'] } : {});
|
|
603
|
+
// 孩子那段(kid)工作台不改:按住说话在哪边在家长端设置页(/api/parent/settings),别的直接编辑文件
|
|
604
|
+
await patchConfig(ws, body);
|
|
617
605
|
await ctx.reload();
|
|
618
606
|
return { status: 200, json: { ok: true, tutors: listTutors(ctx.ws.config), runtime: ctx.ws.config.runtimes.default } };
|
|
619
607
|
}
|
|
@@ -725,8 +713,6 @@ export async function route(method, path, ctx, body, who = 'local', client = {})
|
|
|
725
713
|
if (tail === 'paper')
|
|
726
714
|
return paperRoute(ctx, tutor, date, method, url, body);
|
|
727
715
|
if (tail === 'today' && method === 'GET') {
|
|
728
|
-
// 孩子点进这位老师 / 选了话题(页面轮询也走这里,同一话题 30 秒看一次):把下一轮的老师进程提前起好
|
|
729
|
-
void ctx.runner.prewarm(tutor, warmOpts(url)).catch(() => { });
|
|
730
716
|
return { status: 200, json: await kidDay(ctx, tutor, date) };
|
|
731
717
|
}
|
|
732
718
|
// 以前的某一天(只读回放;未来的日期与坏日期 400)
|
|
@@ -739,15 +725,7 @@ export async function route(method, path, ctx, body, who = 'local', client = {})
|
|
|
739
725
|
const days = Math.min(365, Math.max(1, Number(url.searchParams.get('days') ?? 30) || 30));
|
|
740
726
|
return { status: 200, json: await kidHistory(ctx, tutor, days) };
|
|
741
727
|
}
|
|
742
|
-
|
|
743
|
-
const r = await kidSendInput(ctx, tutor, body, date, client.device);
|
|
744
|
-
if ('error' in r)
|
|
745
|
-
return r.error;
|
|
746
|
-
const started = await ctx.runner.send(tutor, r.input);
|
|
747
|
-
if (started.thread === started.job)
|
|
748
|
-
void adoptPaper(ws, tutor, started.date, started.thread);
|
|
749
|
-
return { status: 202, json: { tutor, date: started.date, job: started.job, thread: started.thread } };
|
|
750
|
-
}
|
|
728
|
+
// 孩子端发一条只走实时那条连接(/api/live/<老师>/ws,src/runtime/live/turns.ts);2026-11-04 删了 HTTP 的发
|
|
751
729
|
return { status: 405, json: { error: 'method_not_allowed' } };
|
|
752
730
|
}
|
|
753
731
|
// 字幕重放:实时那一轮嘴照念的声音在哪、多长、讲稿每句从哪开始(毫秒);只给实时的轮,没落过声音 404
|
|
@@ -787,13 +765,17 @@ export async function route(method, path, ctx, body, who = 'local', client = {})
|
|
|
787
765
|
return putCardState(ctx, ws, tutor, job, Number(nStr), body);
|
|
788
766
|
}
|
|
789
767
|
// 老师头像(R5b,2026-09-16):cotutor.json 里 avatar 是图片相对路径时(figshot 写的 avatars/<老师>.png)从这里取;
|
|
768
|
+
// 没写(或还是旧的出厂 emoji)的三门主科给包里的出厂头像(avatars.ts);
|
|
790
769
|
// emoji 头像、越界、不是图、不存在都 404(孩子端退回显示 emoji / 首字)。no-cache:figshot 换了脸孩子端要马上见到
|
|
791
770
|
const av = /^\/api\/kid\/avatar\/([a-z0-9][a-z0-9-]*)$/.exec(p);
|
|
792
771
|
if (av && method === 'GET') {
|
|
772
|
+
const factory = ws.config.tutors[av[1]] ? factoryAvatarFile(ws.config.tutors[av[1]]) : null;
|
|
773
|
+
if (factory)
|
|
774
|
+
return { status: 200, file: factory, contentType: 'image/webp', cacheControl: 'no-cache' };
|
|
793
775
|
const rel = ws.config.tutors[av[1]]?.avatar ?? '';
|
|
794
776
|
const file = resolve(ws.root, rel);
|
|
795
777
|
const ext = IMAGE_EXT.exec(rel)?.[1]?.toLowerCase() ?? '';
|
|
796
|
-
if (!rel || rel
|
|
778
|
+
if (!rel || isAbsolute(rel) || !file.startsWith(ws.root + sep) || !IMAGE_TYPES[ext])
|
|
797
779
|
return { status: 404, json: { error: 'not_found' } };
|
|
798
780
|
if (!(await stat(file).catch(() => null))?.isFile())
|
|
799
781
|
return { status: 404, json: { error: 'not_found' } };
|
|
@@ -826,7 +808,7 @@ export async function route(method, path, ctx, body, who = 'local', client = {})
|
|
|
826
808
|
const rel = url.searchParams.get('p') ?? '';
|
|
827
809
|
const file = resolve(ws.root, rel);
|
|
828
810
|
const ext = IMAGE_EXT.exec(rel)?.[1]?.toLowerCase() ?? '';
|
|
829
|
-
if (!rel || rel
|
|
811
|
+
if (!rel || isAbsolute(rel) || !file.startsWith(ws.root + sep) || !IMAGE_TYPES[ext])
|
|
830
812
|
return { status: 404, json: { error: 'not_found' } };
|
|
831
813
|
if (!(await stat(file).catch(() => null))?.isFile())
|
|
832
814
|
return { status: 404, json: { error: 'not_found' } };
|
|
@@ -857,49 +839,6 @@ export async function route(method, path, ctx, body, who = 'local', client = {})
|
|
|
857
839
|
return { status: 404, json: { error: 'not_found', message: `${d} 没有 ${job} 这一轮` } };
|
|
858
840
|
return { status: 200, json: fixture ? fixtureOf(view) : view };
|
|
859
841
|
}
|
|
860
|
-
// 音色页(2026-09-17):卡的配音认的全部音色(src/media/tts-voices.ts),家长挑之前先听
|
|
861
|
-
if (p === '/api/tts/voices' && method === 'GET') {
|
|
862
|
-
const inUse = {};
|
|
863
|
-
for (const [name, t] of Object.entries(ws.config.tutors))
|
|
864
|
-
if (t.voice)
|
|
865
|
-
(inUse[t.voice] ??= []).push(name);
|
|
866
|
-
return { status: 200, json: { ok: true, count: TTS_VOICES.length, voices: TTS_VOICES, inUse, sample: PREVIEW_TEXT } };
|
|
867
|
-
}
|
|
868
|
-
// 嘴的音色(工作台音色页「实时的嘴」):这个 realtime 模型认的 56 个(src/realtime/voices.ts)、谁在用(没挑的用缺省)
|
|
869
|
-
if (p === '/api/tts/realtime' && method === 'GET') {
|
|
870
|
-
const inUse = {};
|
|
871
|
-
for (const t of listTutors(ws.config, { kidOnly: true }))
|
|
872
|
-
(inUse[mouthVoice(ws.config.live, t.name)] ??= []).push(t.name);
|
|
873
|
-
return { status: 200, json: { voices: REALTIME_VOICES, inUse, fallback: MOUTH_VOICE, model: ws.config.live.model, sample: PREVIEW_TEXT } };
|
|
874
|
-
}
|
|
875
|
-
// 试听嘴的一个音色:真连一次 realtime 照念这一句(几分钱),存在 .cotutor/live-preview/,同一句同一音色以后直接给文件
|
|
876
|
-
if (p === '/api/tts/realtime/preview' && method === 'GET') {
|
|
877
|
-
const voice = url.searchParams.get('voice')?.trim() ?? '';
|
|
878
|
-
if (!isRealtimeVoice(voice))
|
|
879
|
-
return { status: 400, json: { error: 'bad_voice', message: `这个模型不认「${voice}」这个音色` } };
|
|
880
|
-
const text = url.searchParams.get('text')?.trim().slice(0, 200) || PREVIEW_TEXT;
|
|
881
|
-
const dir = join(ws.root, '.cotutor', 'live-preview');
|
|
882
|
-
const file = join(dir, `${createHash('sha1').update(`${ws.config.live.model}\n${voice}\n${text}`).digest('hex').slice(0, 20)}.wav`);
|
|
883
|
-
if (!(await stat(file).catch(() => null))?.isFile()) {
|
|
884
|
-
const r = await previewVoice({ voice, text, model: ws.config.live.model, env: process.env, endpoint: process.env.COTUTOR_LIVE_ENDPOINT });
|
|
885
|
-
if ('error' in r)
|
|
886
|
-
return { status: 502, json: { error: 'realtime_failed', voice, message: r.error } };
|
|
887
|
-
await mkdir(dir, { recursive: true });
|
|
888
|
-
await writeFile(file, r.wav);
|
|
889
|
-
}
|
|
890
|
-
return { status: 200, file, contentType: 'audio/wav' };
|
|
891
|
-
}
|
|
892
|
-
// 试听一个音色:同一句同一音色合成一次就在配音缓存里(.cotutor/tts/),之后直接给文件;失败回 JSON 说原因(页面拿 message 显示)
|
|
893
|
-
if (p === '/api/tts/preview' && method === 'GET') {
|
|
894
|
-
const voice = url.searchParams.get('voice')?.trim() ?? '';
|
|
895
|
-
if (!VOICE_ID_RE.test(voice))
|
|
896
|
-
return { status: 400, json: { error: 'bad_voice', message: '音色 id 只能是字母数字与 . _ : -' } };
|
|
897
|
-
const text = url.searchParams.get('text')?.trim().slice(0, 200) || PREVIEW_TEXT;
|
|
898
|
-
const r = await synthesize({ text, voice }, { cacheDir: ws.dirs.tts, env: process.env });
|
|
899
|
-
if (!r.file)
|
|
900
|
-
return { status: 502, json: { error: 'tts_failed', voice, message: r.error } };
|
|
901
|
-
return { status: 200, file: r.file, contentType: 'audio/mpeg' };
|
|
902
|
-
}
|
|
903
842
|
// 话题打星(《obsidian仓库设计.md》§4):PUT {rating: 1–5 | null};记账:POST {threads?: [..]} → 每个话题一轮记账任务,老师回「## 记账」段,应用写日记
|
|
904
843
|
const rate = /^\/api\/conversations\/([a-z0-9][a-z0-9-]*)\/(\d{4}-\d{2}-\d{2})\/threads\/([^/]+)\/rating$/.exec(p);
|
|
905
844
|
if (rate && method === 'PUT') {
|
|
@@ -1045,6 +984,8 @@ export async function route(method, path, ctx, body, who = 'local', client = {})
|
|
|
1045
984
|
thread: typeof body.thread === 'string' ? body.thread : undefined,
|
|
1046
985
|
photos,
|
|
1047
986
|
device: device?.data,
|
|
987
|
+
// 孩子的话照实时写(同孩子端、同 replay:上下文包带 live:、不补提问卡、讲稿不截);嘴不在,接住与打断不带。系统发的不算(2026-11-04)
|
|
988
|
+
...(from === 'kid' ? { live: {} } : {}),
|
|
1048
989
|
});
|
|
1049
990
|
return { status: 202, json: { tutor, date: started.date, job: started.job, thread: started.thread, runtime: started.plan.runtime, resume: started.plan.resume } };
|
|
1050
991
|
}
|
|
@@ -1086,6 +1027,9 @@ export async function route(method, path, ctx, body, who = 'local', client = {})
|
|
|
1086
1027
|
const phr = await parentHomeRoute(method, p, ctx, body);
|
|
1087
1028
|
if (phr)
|
|
1088
1029
|
return phr;
|
|
1030
|
+
const plr = await parentLookRoute(method, p, url, ctx, body);
|
|
1031
|
+
if (plr)
|
|
1032
|
+
return plr;
|
|
1089
1033
|
const blr = await bailianRoute(method, p, ctx, body, who);
|
|
1090
1034
|
if (blr)
|
|
1091
1035
|
return blr;
|
|
@@ -1099,12 +1043,9 @@ export async function route(method, path, ctx, body, who = 'local', client = {})
|
|
|
1099
1043
|
return tx;
|
|
1100
1044
|
if (method !== 'GET')
|
|
1101
1045
|
return { status: 405, json: { error: 'method_not_allowed' } };
|
|
1102
|
-
//
|
|
1046
|
+
// 工作台:对话原始视图、看原文、老师团、activity、设置——给有技术背景的家长与开发者;家长端在 /parent
|
|
1103
1047
|
if (p === '/dev')
|
|
1104
1048
|
return { status: 200, html: DEV_PAGE };
|
|
1105
|
-
// 扫码页:在电脑上打开,iPad 用相机扫(不在孩子端的入口里)
|
|
1106
|
-
if (p === '/dev/qr')
|
|
1107
|
-
return ctx.listen ? { status: 200, html: qrPage(ws.config.title, ctx.listen(), url.searchParams.get('via') === 'ip' ? 'ip' : 'name', url.searchParams.get('to') === 'parent' ? 'parent' : 'kid') } : { status: 404, json: { error: 'not_listening' } };
|
|
1108
1049
|
// 按住说话的试验页(真机上比策略用;不在孩子端与家长端的入口里)
|
|
1109
1050
|
// 按钮样张:老师页那颗「头像 + 按住说话」的每一种样子,调动画时看这一页
|
|
1110
1051
|
if (p === '/dev/voice')
|
|
@@ -1131,6 +1072,12 @@ export async function route(method, path, ctx, body, who = 'local', client = {})
|
|
|
1131
1072
|
// 家长端「首页」(设孩子的首页与几项常改的设置)
|
|
1132
1073
|
if (p === '/parent/home')
|
|
1133
1074
|
return { status: 200, html: PARENT_HOME_PAGE };
|
|
1075
|
+
// 家长端「设置」(设一次就不常动的:孩子的平板上按住说话那一栏放哪边)
|
|
1076
|
+
if (p === '/parent/settings')
|
|
1077
|
+
return { status: 200, html: PARENT_SETTINGS_PAGE };
|
|
1078
|
+
// 二维码的打印页:家长端「扫码打开」的「打印 ›」开它(不在孩子端的入口里)
|
|
1079
|
+
if (p === '/parent/qr')
|
|
1080
|
+
return ctx.listen ? { status: 200, html: qrPage(ws.config.title, ctx.listen(), url.searchParams.get('via') === 'ip' ? 'ip' : 'name', url.searchParams.get('to') === 'parent' ? 'parent' : 'kid') } : { status: 404, json: { error: 'not_listening' } };
|
|
1134
1081
|
// 录像里孩子的屏幕:家长端录像时开在 iframe 里;直接打开也能看(调试),?tutor=&date=&thread=[&at=时:分:秒]
|
|
1135
1082
|
if (p === '/reel')
|
|
1136
1083
|
return { status: 200, html: kidPage(esc(ws.config.title), { reel: true }, await stageVersion()) };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** 本包自带的头像目录(仓库检出与 npm 安装都在包根 avatars/) */
|
|
2
|
+
export declare const PACKAGE_AVATARS_DIR: string;
|
|
3
|
+
/** 下发给页面的出厂头像名的前缀(不是 workspace 里的路径) */
|
|
4
|
+
export declare const FACTORY_PREFIX = "factory:";
|
|
5
|
+
type AvatarOf = {
|
|
6
|
+
avatar?: string;
|
|
7
|
+
subject?: string;
|
|
8
|
+
};
|
|
9
|
+
/** 这门学科的出厂头像在包里的文件(不管这位老师现在用不用);不是三门主科 → null */
|
|
10
|
+
export declare function subjectAvatarFile(subject: string | undefined): string | null;
|
|
11
|
+
/** cotutor.json 里家长定的头像:没写或还是旧版出厂的 emoji → null(用出厂的) */
|
|
12
|
+
export declare const ownAvatar: (t: AvatarOf) => string | null;
|
|
13
|
+
/** 出厂头像在包里的文件;不用出厂的 → null */
|
|
14
|
+
export declare function factoryAvatarFile(t: AvatarOf): string | null;
|
|
15
|
+
/** 下发给页面的 avatar:出厂的给「factory:<文件名>」,家长写的原样,都没有 null(页面显示学科首字) */
|
|
16
|
+
export declare function shownAvatar(t: AvatarOf): string | null;
|
|
17
|
+
export {};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 老师的出厂头像:三门主科各一张木钉娃娃(包根 avatars/<学科>.webp,出处与重渲见 scripts/avatars/peg.py)。
|
|
3
|
+
* cotutor.json 里这位老师没写 avatar,或还是旧版 init 写进去的那个 emoji(🧮 📚 🔤),就用它;家长写了别的(emoji 或图片路径)照家长的。
|
|
4
|
+
* 不拷进 workspace:图跟着包走,换了包就换了图。页面按 avatar 的后缀判定是不是图,所以下发的是一个带后缀的名字(FACTORY_PREFIX 打头),
|
|
5
|
+
* 图本身和 figshot 的一样从 /api/kid/avatar/<老师> 取。
|
|
6
|
+
*/
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import { canonicalSubject } from "../../schema/index.js";
|
|
10
|
+
/** 本包自带的头像目录(仓库检出与 npm 安装都在包根 avatars/) */
|
|
11
|
+
export const PACKAGE_AVATARS_DIR = fileURLToPath(new URL('../../../avatars/', import.meta.url));
|
|
12
|
+
const SUBJECT_AVATARS = { 语文: 'chinese.webp', 数学: 'math.webp', 英语: 'english.webp' };
|
|
13
|
+
/** 2026-11-02 以前 init 写进 cotutor.json 的出厂 emoji:当作没写 */
|
|
14
|
+
const OLD_FACTORY_EMOJI = new Set(['📚', '🧮', '🔤']);
|
|
15
|
+
/** 下发给页面的出厂头像名的前缀(不是 workspace 里的路径) */
|
|
16
|
+
export const FACTORY_PREFIX = 'factory:';
|
|
17
|
+
/** 用出厂头像的话是哪张(文件名);家长自己定了、或不是三门主科 → null */
|
|
18
|
+
const factoryName = (t) => (t.avatar && !OLD_FACTORY_EMOJI.has(t.avatar) ? null : SUBJECT_AVATARS[canonicalSubject(t.subject) ?? ''] ?? null);
|
|
19
|
+
/** 这门学科的出厂头像在包里的文件(不管这位老师现在用不用);不是三门主科 → null */
|
|
20
|
+
export function subjectAvatarFile(subject) {
|
|
21
|
+
const f = SUBJECT_AVATARS[canonicalSubject(subject) ?? ''];
|
|
22
|
+
return f ? join(PACKAGE_AVATARS_DIR, f) : null;
|
|
23
|
+
}
|
|
24
|
+
/** cotutor.json 里家长定的头像:没写或还是旧版出厂的 emoji → null(用出厂的) */
|
|
25
|
+
export const ownAvatar = (t) => (t.avatar && !OLD_FACTORY_EMOJI.has(t.avatar) ? t.avatar : null);
|
|
26
|
+
/** 出厂头像在包里的文件;不用出厂的 → null */
|
|
27
|
+
export function factoryAvatarFile(t) {
|
|
28
|
+
const f = factoryName(t);
|
|
29
|
+
return f ? join(PACKAGE_AVATARS_DIR, f) : null;
|
|
30
|
+
}
|
|
31
|
+
/** 下发给页面的 avatar:出厂的给「factory:<文件名>」,家长写的原样,都没有 null(页面显示学科首字) */
|
|
32
|
+
export function shownAvatar(t) {
|
|
33
|
+
const f = factoryName(t);
|
|
34
|
+
return f ? FACTORY_PREFIX + f : (t.avatar ?? null);
|
|
35
|
+
}
|
|
@@ -123,7 +123,7 @@ export async function checkDevTutorFile(ws, tutor, text) {
|
|
|
123
123
|
preview: {
|
|
124
124
|
text: body,
|
|
125
125
|
chars: charsOf(body),
|
|
126
|
-
board: guide ? { chars: charsOf(guide
|
|
126
|
+
board: guide ? { chars: charsOf(guide), cards: kinds ? [...kinds] : [] } : null,
|
|
127
127
|
rules,
|
|
128
128
|
},
|
|
129
129
|
diff: shipped === null ? null : diffHunks(shipped, text),
|
|
@@ -8,8 +8,10 @@
|
|
|
8
8
|
* PUT /api/parent/home/tutors/<老师>/dictation {words: [{chars, say?}]} 这周的词;[] = 删掉
|
|
9
9
|
* PUT /api/parent/home/order {tutors: [老师…]} 老师的先后(排第一的进首页时在最前)
|
|
10
10
|
* PATCH /api/parent/settings {tutors?: {<老师>: {extras?: [..] | null, hidden?}}, talk?: {note?, minutes?}}
|
|
11
|
+
* GET /api/parent/qr 孩子端与家长端地址的二维码(各有主机名一张、局域网 IP 一张)、是不是 HTTPS、孩子的设备连过几台、上次什么时候;服务没在听(测试走 route)→ {ready: false}
|
|
11
12
|
*/
|
|
12
13
|
import { type DictationLang, type TutorButton } from '../../board/cards/index.ts';
|
|
14
|
+
import { type HomeTileId } from '../../schema/index.ts';
|
|
13
15
|
import type { AppContext, RouteResult } from './app.ts';
|
|
14
16
|
export interface ParentHomeTutor {
|
|
15
17
|
name: string;
|
|
@@ -20,6 +22,13 @@ export interface ParentHomeTutor {
|
|
|
20
22
|
/** 卡上的常驻入口(按学科补过缺省的);extrasSet = cotutor.json 里写的(没写 = null,走缺省) */
|
|
21
23
|
extras: string[];
|
|
22
24
|
extrasSet: string[] | null;
|
|
25
|
+
/** 常驻的方砖(HOME_TILES 的先后):家里有那个 activity 文件的才列;on = 没关掉,usable = 文件检查过得去(写坏了孩子端不出) */
|
|
26
|
+
tiles: {
|
|
27
|
+
id: HomeTileId;
|
|
28
|
+
label: string;
|
|
29
|
+
on: boolean;
|
|
30
|
+
usable: boolean;
|
|
31
|
+
}[];
|
|
23
32
|
/** 首页里这位老师的按钮(文件里的,带讲法与留到) */
|
|
24
33
|
buttons: TutorButton[];
|
|
25
34
|
/** 这周的词:写了这位老师的听写卡;没有 = null */
|
|
@@ -32,6 +41,11 @@ export interface ParentHomeTutor {
|
|
|
32
41
|
lang: DictationLang;
|
|
33
42
|
/** 学科色(src/ui/shared/tutor-color.ts) */
|
|
34
43
|
color: string;
|
|
44
|
+
/** 「样子与声音」那一行:说话的声音、点读的声音叫什么(点开见 ./parent-look.ts);点读没配 = null */
|
|
45
|
+
voices: {
|
|
46
|
+
mouth: string;
|
|
47
|
+
card: string | null;
|
|
48
|
+
};
|
|
35
49
|
}
|
|
36
50
|
export declare function parentHome(ctx: AppContext): Promise<Record<string, unknown>>;
|
|
37
51
|
export declare function parentHomeRoute(method: string, p: string, ctx: AppContext, body: unknown): Promise<RouteResult | null>;
|