cotutor 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (205) hide show
  1. package/LICENSE +9 -7
  2. package/NOTICE +3 -3
  3. package/README.md +36 -13
  4. package/avatars/chinese.webp +0 -0
  5. package/avatars/english.webp +0 -0
  6. package/avatars/math.webp +0 -0
  7. package/bin/cotutor.js +13 -3
  8. package/cards/photo/card.css +10 -3
  9. package/cards/photo/card.md +2 -2
  10. package/cards/tutor/card.css +13 -0
  11. package/cards/tutor/card.md +2 -1
  12. package/cards//346/235/277/344/271/246/346/200/216/344/271/210/345/206/231.md +1 -1
  13. package/dist/app/activity-eval.js +3 -2
  14. package/dist/app/cli/activities.d.ts +8 -1
  15. package/dist/app/cli/activities.js +42 -4
  16. package/dist/app/cli/cert.d.ts +5 -0
  17. package/dist/app/cli/cert.js +20 -7
  18. package/dist/app/cli/chalk/browser.d.ts +2 -2
  19. package/dist/app/cli/chalk/browser.js +22 -7
  20. package/dist/app/cli/chalk/main.js +3 -2
  21. package/dist/app/cli/doctor.d.ts +6 -5
  22. package/dist/app/cli/doctor.js +107 -171
  23. package/dist/app/cli/firewall.d.ts +24 -0
  24. package/dist/app/cli/firewall.js +79 -0
  25. package/dist/app/cli/init.js +8 -5
  26. package/dist/app/cli/main.js +347 -24
  27. package/dist/app/cli/migrate.d.ts +2 -11
  28. package/dist/app/cli/migrate.js +1 -59
  29. package/dist/app/cli/port-holder.d.ts +16 -0
  30. package/dist/app/cli/port-holder.js +34 -0
  31. package/dist/app/cli/port.d.ts +37 -0
  32. package/dist/app/cli/port.js +116 -0
  33. package/dist/app/cli/sandbox.d.ts +25 -0
  34. package/dist/app/cli/sandbox.js +166 -0
  35. package/dist/app/cli/serve-log.d.ts +13 -0
  36. package/dist/app/cli/serve-log.js +90 -0
  37. package/dist/app/cli/serve.d.ts +18 -4
  38. package/dist/app/cli/serve.js +78 -8
  39. package/dist/app/cli/service-windows.d.ts +50 -0
  40. package/dist/app/cli/service-windows.js +117 -0
  41. package/dist/app/cli/service.d.ts +138 -0
  42. package/dist/app/cli/service.js +429 -0
  43. package/dist/app/cli/skeleton.d.ts +1 -1
  44. package/dist/app/cli/skeleton.js +13 -13
  45. package/dist/app/cli/skills.d.ts +5 -1
  46. package/dist/app/cli/skills.js +14 -4
  47. package/dist/app/cli/tray-icons.d.ts +11 -0
  48. package/dist/app/cli/tray-icons.js +87 -0
  49. package/dist/app/cli/tray-ids.d.ts +6 -0
  50. package/dist/app/cli/tray-ids.js +6 -0
  51. package/dist/app/cli/tray-mac.d.ts +8 -0
  52. package/dist/app/cli/tray-mac.js +140 -0
  53. package/dist/app/cli/tray-run.d.ts +1 -0
  54. package/dist/app/cli/tray-run.js +33 -0
  55. package/dist/app/cli/tray-windows.d.ts +5 -0
  56. package/dist/app/cli/tray-windows.js +166 -0
  57. package/dist/app/cli/tray.d.ts +205 -0
  58. package/dist/app/cli/tray.js +618 -0
  59. package/dist/app/cli/usage.d.ts +1 -1
  60. package/dist/app/cli/usage.js +11 -6
  61. package/dist/app/dictation/route.js +3 -2
  62. package/dist/app/http/app.d.ts +5 -10
  63. package/dist/app/http/app.js +45 -98
  64. package/dist/app/http/avatars.d.ts +17 -0
  65. package/dist/app/http/avatars.js +35 -0
  66. package/dist/app/http/dev-tutor-files.js +1 -1
  67. package/dist/app/http/parent-home.d.ts +14 -0
  68. package/dist/app/http/parent-home.js +69 -6
  69. package/dist/app/http/parent-look.d.ts +2 -0
  70. package/dist/app/http/parent-look.js +151 -0
  71. package/dist/app/http/raw-view.js +3 -3
  72. package/dist/app/http/voice-preview.d.ts +4 -0
  73. package/dist/app/http/voice-preview.js +34 -0
  74. package/dist/app/machine/bailian.d.ts +0 -2
  75. package/dist/app/machine/bailian.js +5 -6
  76. package/dist/app/mock/mock-live.js +1 -1
  77. package/dist/app/mock/mock.d.ts +4 -2
  78. package/dist/app/mock/mock.js +20 -12
  79. package/dist/app/replay.js +4 -4
  80. package/dist/app/skills/home-doc.js +1 -1
  81. package/dist/app/workspace.d.ts +9 -4
  82. package/dist/app/workspace.js +33 -10
  83. package/dist/app/ws/route.d.ts +8 -2
  84. package/dist/app/ws/route.js +7 -3
  85. package/dist/board/parse.d.ts +0 -2
  86. package/dist/board/parse.js +0 -6
  87. package/dist/board/presentation.d.ts +9 -15
  88. package/dist/board/presentation.js +8 -10
  89. package/dist/brain/api-tools.js +2 -1
  90. package/dist/brain/api.d.ts +12 -17
  91. package/dist/brain/api.js +16 -33
  92. package/dist/brain/index.d.ts +23 -71
  93. package/dist/brain/index.js +6 -69
  94. package/dist/brain/log.d.ts +5 -0
  95. package/dist/brain/log.js +22 -0
  96. package/dist/brain/runtime.d.ts +30 -0
  97. package/dist/brain/runtime.js +23 -0
  98. package/dist/brain/{cli/stream-json.d.ts → stream-json.d.ts} +2 -4
  99. package/dist/brain/{cli/stream-json.js → stream-json.js} +0 -4
  100. package/dist/brain/stream.d.ts +5 -5
  101. package/dist/brain/stream.js +7 -7
  102. package/dist/brain/transcript.d.ts +3 -3
  103. package/dist/brain/transcript.js +6 -6
  104. package/dist/brain/{cli/types.d.ts → types.d.ts} +3 -48
  105. package/dist/brain/types.js +14 -0
  106. package/dist/chalk/glyphs/skeleton.d.ts +3 -3
  107. package/dist/chalk/glyphs/skeleton.js +1 -1
  108. package/dist/lib/index.d.ts +1 -1
  109. package/dist/lib/index.js +1 -1
  110. package/dist/memory/diary.js +3 -3
  111. package/dist/realtime/bailian.d.ts +1 -1
  112. package/dist/realtime/bailian.js +3 -2
  113. package/dist/realtime/preview.js +1 -1
  114. package/dist/realtime/voices.d.ts +2 -0
  115. package/dist/realtime/voices.js +57 -57
  116. package/dist/runtime/activities.d.ts +13 -1
  117. package/dist/runtime/activities.js +28 -2
  118. package/dist/runtime/activity-state.js +2 -2
  119. package/dist/runtime/agent-file.d.ts +3 -3
  120. package/dist/runtime/agent-file.js +3 -3
  121. package/dist/runtime/context/tutor-rules.d.ts +2 -2
  122. package/dist/runtime/context/tutor-rules.js +3 -3
  123. package/dist/runtime/home/home.d.ts +12 -1
  124. package/dist/runtime/home/home.js +8 -1
  125. package/dist/runtime/home/store.d.ts +1 -1
  126. package/dist/runtime/home/store.js +36 -21
  127. package/dist/runtime/journal/mix.d.ts +15 -6
  128. package/dist/runtime/journal/mix.js +60 -19
  129. package/dist/runtime/journal/recorder.d.ts +58 -13
  130. package/dist/runtime/journal/recorder.js +106 -16
  131. package/dist/runtime/journal/store.d.ts +4 -4
  132. package/dist/runtime/journal/store.js +6 -6
  133. package/dist/runtime/kid-input.js +2 -3
  134. package/dist/runtime/live/call.d.ts +12 -0
  135. package/dist/runtime/live/call.js +51 -18
  136. package/dist/runtime/live/turns.js +1 -1
  137. package/dist/runtime/runner.d.ts +9 -55
  138. package/dist/runtime/runner.js +52 -173
  139. package/dist/scenes/paint/painter.js +4 -3
  140. package/dist/schema/config.d.ts +43 -22
  141. package/dist/schema/config.js +45 -47
  142. package/dist/schema/conversation.d.ts +6 -0
  143. package/dist/schema/home.d.ts +11 -1
  144. package/dist/schema/home.js +7 -2
  145. package/dist/schema/workspace.d.ts +27 -0
  146. package/dist/schema/workspace.js +28 -0
  147. package/dist/shared/default-port.d.ts +2 -0
  148. package/dist/shared/default-port.js +2 -0
  149. package/dist/shared/qianwen.d.ts +14 -10
  150. package/dist/shared/qianwen.js +26 -22
  151. package/dist/shared/site-url.d.ts +2 -0
  152. package/dist/shared/site-url.js +5 -0
  153. package/dist/ui/dev/dev-page.js +25 -252
  154. package/dist/ui/dev/dev-theme.d.ts +1 -1
  155. package/dist/ui/dev/dev-theme.js +1 -1
  156. package/dist/ui/dev/voice-gallery-page.js +1 -1
  157. package/dist/ui/kid/board-mark.d.ts +2 -2
  158. package/dist/ui/kid/board-mark.js +2 -2
  159. package/dist/ui/kid/home-tiles.d.ts +14 -0
  160. package/dist/ui/kid/home-tiles.js +44 -0
  161. package/dist/ui/kid/kid-page.js +68 -103
  162. package/dist/ui/kid/voice-ui.d.ts +1 -1
  163. package/dist/ui/kid/voice-ui.js +1 -1
  164. package/dist/ui/kid/word-emoji.js +5 -3
  165. package/dist/ui/parent/parent-home-page.js +315 -7
  166. package/dist/ui/parent/parent-page.js +6 -2
  167. package/dist/ui/parent/parent-settings-page.d.ts +1 -0
  168. package/dist/ui/parent/parent-settings-page.js +133 -0
  169. package/dist/ui/{dev → parent}/qr-page.d.ts +2 -2
  170. package/dist/ui/{dev → parent}/qr-page.js +19 -14
  171. package/dist/ui/shared/brand.d.ts +3 -1
  172. package/dist/ui/shared/brand.js +3 -1
  173. package/grades/grade-2/README.md +1 -1
  174. package/grades/grade-2/VALIDATION.md +1 -1
  175. package/grades/grade-2/agents/chinese-tutor/activities//345/244/215/344/271/240.md +1 -1
  176. package/grades/grade-2/agents/chinese-tutor/activities//346/{211/271/346/224/271.md → 243/200/346/237/245/344/275/234/344/270/232.md} +4 -4
  177. package/grades/grade-2/agents/chinese-tutor/activities//346/{211/271/346/224/271. → 243/200/346/237/245/344/275/234/344/270/232.}/344/276/213/345/255/220.md +2 -2
  178. package/grades/grade-2/agents/chinese-tutor/activities//351/242/204/344/271/240.md +1 -1
  179. package/grades/grade-2/agents/english-tutor/activities//345/244/215/344/271/240.md +1 -1
  180. package/grades/grade-2/agents/english-tutor/activities//346/{211/271/346/224/271.md → 243/200/346/237/245/344/275/234/344/270/232.md} +4 -4
  181. package/grades/grade-2/agents/english-tutor/activities//346/{211/271/346/224/271. → 243/200/346/237/245/344/275/234/344/270/232.}/344/276/213/345/255/220.md +2 -2
  182. package/grades/grade-2/agents/english-tutor/activities//351/242/204/344/271/240.md +1 -1
  183. package/grades/grade-2/agents/math-tutor/activities//345/244/215/344/271/240.md +1 -1
  184. package/grades/grade-2/agents/math-tutor/activities//346/{211/271/346/224/271.md → 243/200/346/237/245/344/275/234/344/270/232.md} +4 -4
  185. package/grades/grade-2/agents/math-tutor/activities//346/{211/271/346/224/271. → 243/200/346/237/245/344/275/234/344/270/232.}/344/276/213/345/255/220.md +2 -2
  186. package/grades/grade-2/agents/math-tutor/activities//351/242/204/344/271/240.md +1 -1
  187. package/package.json +2 -1
  188. package/skills/cotutor-analyze/references//345/221/275/344/273/244/344/270/216/346/226/207/344/273/266.md +2 -2
  189. package/skills/cotutor-board/references/photo.md +2 -2
  190. package/skills/cotutor-home/SKILL.md +1 -0
  191. package/skills/cotutor-home/references/tutor.md +2 -1
  192. package/skills/cotutor-home/references//351/246/226/351/241/265/350/257/255/346/263/225.md +2 -1
  193. package/skills/cotutor-tune/references//345/255/227/346/256/265.md +6 -3
  194. package/skills/cotutor-tutor/SKILL.md +5 -13
  195. package/dist/brain/cli/adapters.d.ts +0 -60
  196. package/dist/brain/cli/adapters.js +0 -91
  197. package/dist/brain/cli/qwen.d.ts +0 -11
  198. package/dist/brain/cli/qwen.js +0 -141
  199. package/dist/brain/cli/run-plan.d.ts +0 -59
  200. package/dist/brain/cli/run-plan.js +0 -71
  201. package/dist/brain/cli/run.d.ts +0 -35
  202. package/dist/brain/cli/run.js +0 -256
  203. package/dist/brain/cli/types.js +0 -3
  204. package/dist/brain/cli/warm.d.ts +0 -53
  205. package/dist/brain/cli/warm.js +0 -103
@@ -6,19 +6,18 @@
6
6
  import { isRealtimeVoice } from "../../realtime/voices.js";
7
7
  import { dashscopeAuth } from "../../realtime/bailian.js";
8
8
  import { TTS_MODEL, ttsTarget } from "../../media/tts.js";
9
- import { adapterFor, parserOf, prepareCli, processEnv, runtimeIssue } from "../../brain/index.js";
10
- import { execFile } from 'node:child_process';
9
+ import { compatibleLine, isPlanKey, tokenPlanKey } from "../../shared/qianwen.js";
10
+ import { runtimeIssue } from "../../brain/index.js";
11
+ import { homedir } from 'node:os';
11
12
  import { access, lstat, readFile, readdir, stat } from 'node:fs/promises';
12
- import { constants as fsConstants } from 'node:fs';
13
- import { basename, dirname, join, relative, resolve, sep } from 'node:path';
14
- import { promisify } from 'node:util';
15
- import { boardPreloaded, planRun, runtimeUses } from "../../brain/cli/run-plan.js";
16
- import { BOARD_GUIDE_PATH, boardGuideBody, takesTutorRules } from "../../runtime/context/tutor-rules.js";
13
+ import { constants as fsConstants, existsSync } from 'node:fs';
14
+ import { dirname, join, relative, resolve, sep } from 'node:path';
15
+ import { takesTutorRules } from "../../runtime/context/tutor-rules.js";
17
16
  import { parseAgentFile } from "../../runtime/agent-file.js";
18
17
  import { localDate } from "../../shared/time.js";
19
18
  import { MIN_CONTRAST, contrastWithWhite } from "../../ui/shared/tutor-color.js";
20
19
  import { memoryPath, missingEntry, pickNotes } from "../../memory/vault-notes.js";
21
- import { SKILLS_DIR, isApiRuntime, listTutors, mouthVoice, resolvePolicy, tutorFilePath } from "../../schema/index.js";
20
+ import { SKILLS_DIR, cardVoice, isApiRuntime, listTutors, mouthVoice, resolvePolicy, tutorFilePath } from "../../schema/index.js";
22
21
  import { kindsFor, tutorCardKinds } from "../../board/cards/index.js";
23
22
  import { scanVault } from "../../memory/vault.js";
24
23
  import { parseArtifactEvents } from "../../runtime/journal/ledger.js";
@@ -29,12 +28,8 @@ import { readPin } from "../http/access.js";
29
28
  import { gradeOfSemester } from "../../memory/grade.js";
30
29
  import { DIRS } from "./skeleton.js";
31
30
  import { tutorStatuses, readManifest } from "./tutors.js";
32
- import { tokenText } from "../../shared/tokens.js";
33
31
  import { workspaceGradePack } from "./grades.js";
34
32
  import { CONFIG_FILE, ConfigError, USER_CONFIG, UsageError, assembleWorkspace, expandPath, parseConfig, readJson, redactHome, resolveRoot, } from "../workspace.js";
35
- const execFileP = promisify(execFile);
36
- /** doctor --live 发给老师的那句 */
37
- const LIVE_PROMPT = '只回一个字:好';
38
33
  async function statOrNull(p) {
39
34
  return stat(p).catch(() => null);
40
35
  }
@@ -46,113 +41,19 @@ async function evictedCount(dir) {
46
41
  return 0;
47
42
  }
48
43
  }
49
- /** 运行时的 CLI 名(run[0] 的 basename) */
50
- export function runtimeCli(run) {
51
- return basename(run[0] ?? '');
52
- }
53
- /** 已知的环境坑,从 CLI 的输出里认出来给修复命令(错误信息即修复指南) */
54
- export function explainLlmFailure(text, runtime) {
55
- const bin = runtimeCli(runtime);
56
- if (/does not support this model|version [\d.]+ or newer is required/i.test(text)) {
57
- return `这版 ${bin} 不认模型:检查 cotutor.json 的运行时 run / resume 中 -m 后的模型名,或运行 ${bin} update 升级`;
58
- }
59
- if (/403|request not allowed/i.test(text))
60
- return `API 拒了(403):检查 ${bin} 使用的千问 key、端点与模型权限`;
61
- if (/not logged in|login|authentication|401|unauthorized/i.test(text))
62
- return `${bin} 的 key 无效:检查 QWENCLOUD_TOKEN_PLAN_API_KEY 或 DASHSCOPE_API_KEY,再跑 cotutor doctor`;
63
- if (/ENOENT|command not found/i.test(text))
64
- return `PATH 里没有 ${bin}`;
65
- return `看上面的原文;不认识的错先在终端手跑一次同样的命令`;
66
- }
67
44
  /**
68
- * --live:真起一次老师(运行时的 run 模板,第一位开着的老师,只回一个字),把 API 层的坑(模型不认、没登录)摆到明面。
69
- * 花一分钱级别的费用,所以缺省不跑。配音同理:老师配了 voice 就合成一句。
45
+ * --live:老师用的是 API 运行时,不在这里真起一轮(真跑一轮用 cotutor send);配音照真合成一句(老师配了 voice 的),花一分钱级别,所以缺省不跑。
70
46
  */
71
47
  async function probeLive(ws, push, env) {
72
- const { spawn } = await import('node:child_process');
73
48
  const { listTutors } = await import("../../schema/index.js");
74
49
  const { synthesize } = await import("../../media/tts.js");
75
- const { parseTranscript } = await import("../../brain/transcript.js");
76
- const { parseAgentFile } = await import("../../runtime/agent-file.js");
77
50
  const { tmpdir } = await import('node:os');
78
51
  const { mkdtemp, rm } = await import('node:fs/promises');
79
52
  const tutors = listTutors(ws.config).filter((t) => t.enabled);
80
- const first = tutors[0];
81
- if (!first) {
82
- push({ name: 'live.agent', ok: false, required: false, detail: '没有开着的老师,没法探', fix: 'cotutor.json 里至少开一位' });
83
- return;
84
- }
85
53
  const runtimeName = ws.config.runtimes.default;
86
54
  const runtime = ws.config.runtimes[runtimeName];
87
- if (!runtime || typeof runtime === 'string')
88
- return;
89
- if (isApiRuntime(runtime)) {
90
- push({ name: `live.${runtimeName}`, ok: true, required: false, detail: `API 运行时(百炼 ${runtime.model}),不起 CLI;真跑一轮用 cotutor send` });
91
- return;
92
- }
93
- if (runtimeIssue(runtime))
94
- return; // 静态体检已报退役,不启动旧 CLI
95
- let agentBody;
96
- try {
97
- agentBody = parseAgentFile(await readFile(tutorFilePath(ws.dirs.agents, first.name), 'utf8')).body;
98
- }
99
- catch {
100
- /* 上面 tutor.* 已报 */
101
- }
102
- let boardBody;
103
- try {
104
- boardBody = boardGuideBody(await readFile(join(ws.root, BOARD_GUIDE_PATH), 'utf8'));
105
- }
106
- catch {
107
- /* skills.* 已报 */
108
- }
109
- const systemBody = agentBody !== undefined ? (boardBody && takesTutorRules(first.name) ? `${agentBody}\n\n${boardBody}` : agentBody) : undefined;
110
- // 和真跑同一条规划:命令行、stdin 那行、环境变量都由这个运行时的 CLI 适配器定
111
- const plan = planRun(ws.config, { session: null }, { agent: first.name, prompt: LIVE_PROMPT, agentBody, systemBody, boardFile: join(ws.root, BOARD_GUIDE_PATH), runtime: runtimeName, root: ws.root, readDirs: [...new Set([ws.root, ws.paths.vault])] });
112
- const { argv } = plan;
113
- const parse = parserOf(plan.cli);
114
- await prepareCli(plan.cli, ws.root);
115
- const cwd = join(ws.dirs.agents, first.name);
116
- const run = await new Promise((resolveRun) => {
117
- let out = '';
118
- let err = '';
119
- const child = spawn(argv[0], argv.slice(1), { cwd, env: processEnv(plan, { ...env, COTUTOR_WORKSPACE: ws.root }), stdio: [plan.stdin ? 'pipe' : 'ignore', 'pipe', 'pipe'] });
120
- const timer = setTimeout(() => child.kill(), 120_000);
121
- // 消息走 stdin 的运行时:写一条,看到收尾就关 stdin
122
- if (plan.stdin) {
123
- child.stdin?.on('error', () => { });
124
- child.stdin?.write(plan.stdin);
125
- }
126
- let rest = '';
127
- child.stdout?.on('data', (d) => {
128
- out += d.toString();
129
- rest += d.toString();
130
- const lines = rest.split('\n');
131
- rest = lines.pop() ?? '';
132
- if (plan.stdin && lines.some((l) => parse(l).some((e) => e.kind === 'result')))
133
- child.stdin?.end();
134
- });
135
- child.stderr?.on('data', (d) => (err += d.toString()));
136
- child.once('error', (e) => {
137
- clearTimeout(timer);
138
- resolveRun({ out, err, code: null, spawnErr: e.message });
139
- });
140
- child.once('close', (code) => {
141
- clearTimeout(timer);
142
- resolveRun({ out, err, code });
143
- });
144
- });
145
- const t = parseTranscript(run.out, parse);
146
- const said = t.items.filter((i) => i.kind === 'text' && !i.sub).map((i) => i.text).join(' ');
147
- const ok = Boolean(t.final?.ok) && !run.spawnErr;
148
- const raw = run.spawnErr ?? (t.final ? `${t.final.reason ?? ''} ${said}`.trim() : `没有 result 事件(exit ${run.code}):${run.err.trim().split('\n').slice(-3).join(' / ')}`);
149
- push({
150
- name: `live.${runtimeName}`,
151
- ok,
152
- required: false,
153
- detail: ok ? `${first.display} 回了「${(t.final?.text ?? '').slice(0, 20)}」${t.final?.inTokens !== undefined ? ` · ${tokenText({ in: t.final.inTokens, out: t.final.outTokens ?? 0 })}` : ''}` : `起不来或没答上:${raw.slice(0, 300)}`,
154
- fix: ok ? undefined : explainLlmFailure(raw, runtime.run),
155
- });
55
+ if (runtime && typeof runtime !== 'string' && isApiRuntime(runtime))
56
+ push({ name: `live.${runtimeName}`, ok: true, required: false, detail: `API 运行时(百炼 ${runtime.model}),不起老师;真跑一轮用 cotutor send` });
156
57
  const voiced = tutors.find((x) => x.voice);
157
58
  if (!voiced) {
158
59
  push({ name: 'live.tts', ok: true, required: false, detail: '没有老师配 voice,不探配音(点读、单词卡没有声音)' });
@@ -163,7 +64,7 @@ async function probeLive(ws, push, env) {
163
64
  const r = await synthesize({ text: '你好', voice: voiced.voice }, { cacheDir: dir, env });
164
65
  const size = r.file ? ((await stat(r.file).catch(() => null))?.size ?? 0) : 0;
165
66
  await rm(dir, { recursive: true, force: true });
166
- push({ name: 'live.tts', ok: Boolean(r.file), required: false, detail: r.file ? `${voiced.display} 的音色 ${voiced.voice} 能合成(${size} 字节)` : `${voiced.display} 的音色 ${voiced.voice} 没合成:${r.error}`, fix: r.file ? undefined : '家长端「首页」里填百炼的 key 与业务空间 id;音色到工作台音色页「卡的配音」重挑' });
67
+ push({ name: 'live.tts', ok: Boolean(r.file), required: false, detail: r.file ? `${voiced.display} 的音色 ${voiced.voice} 能合成(${size} 字节)` : `${voiced.display} 的音色 ${voiced.voice} 没合成:${r.error}`, fix: r.file ? undefined : '家长端「首页」里填百炼的 key 与业务空间 id;音色到家长端首页的「样子与声音」重挑' });
167
68
  }
168
69
  export async function doctorWorkspace(override, opts = {}) {
169
70
  const probeEnv = opts.probeEnv ?? true;
@@ -258,7 +159,7 @@ export async function doctorWorkspace(override, opts = {}) {
258
159
  push({ name: 'rules', ok: true, required: false, detail: rulesThere.length ? `家长自建的家规 ${rulesThere.join(' ')} 在,所有老师每轮都读` : '没有根目录的 QWEN.md(不需要:老师要知道的都在老师文件与技能里)' });
259
160
  if (ws) {
260
161
  // ---- 老师:定义文件(拷贝)+ 名字一致 + 出厂 / 自定义状态 + 老师目录 ----
261
- // 老师文件只有 agents/<name>.md 这一份:应用读正文递进系统提示({agentBody} / {systemBody})
162
+ // 老师文件只有 agents/<name>.md 这一份:应用读正文递进系统提示
262
163
  const statuses = new Map((teachingPack ? await tutorStatuses(root) : []).map((s) => [s.name, s]));
263
164
  for (const name of Object.keys(ws.config.tutors)) {
264
165
  const shipped = statuses.has(name);
@@ -302,8 +203,8 @@ export async function doctorWorkspace(override, opts = {}) {
302
203
  push({ name: `tutor.${name}.home`, ok: there, required: true, detail: there ? `agents/${name}/ 在(会话 cwd)` : `agents/${name}/ 不在`, fix: there ? undefined : 'cotutor init 补建' });
303
204
  // 音色:一老师一音色走 API 是正路;没配的孩子端只剩浏览器合成声(只该在测试里);hidden 的老师不对孩子说话,不用音色
304
205
  const tc = ws.config.tutors[name];
305
- if (tc.enabled && !tc.hidden && !tc.voice)
306
- push({ name: `tutor.${name}.voice`, ok: false, required: false, detail: `${tc.display} 没配音色(cotutor.json tutors.${name}.voice),卡上的声音(点读卡、单词卡)配不了——只适合测试`, fix: '工作台 /dev 的「音色」页「卡的配音」能听着挑(挑中直接写进去)' });
206
+ if (tc.enabled && !tc.hidden && !cardVoice(ws.config.tutors, name))
207
+ push({ name: `tutor.${name}.voice`, ok: false, required: false, detail: `${tc.display} 没配音色(cotutor.json tutors.${name}.voice),卡上的声音(点读卡、单词卡)配不了——只适合测试`, fix: '家长端「首页」点这位老师的「样子与声音」挑一个' });
307
208
  // 卡的清单(《卡片协议.md》「谁拿到哪些卡」):有脸的老师才递板书写法;只是报一声讲哪几种
308
209
  if (takesTutorRules(name)) {
309
210
  const kinds = tutorCardKinds(tc.cards);
@@ -317,6 +218,36 @@ export async function doctorWorkspace(override, opts = {}) {
317
218
  push({ name: `tutor.${name}.avatar`, ok: there, required: false, detail: there ? `头像 ${tc.avatar}` : inside ? `头像 ${tc.avatar} 不在,孩子端退回显示首字` : `头像 ${tc.avatar} 在 workspace 根之外,页面不给`, fix: there ? undefined : `figshot pick --workspace ${redactHome(root)} 存一张,或把 cotutor.json 的 tutors.${name}.avatar 改回 emoji` });
318
219
  }
319
220
  }
221
+ if (process.platform === 'win32' && (opts.firewall ?? probeEnv)) {
222
+ // 平板连不连得进来:Windows 只在第一次监听时问一次「允许访问」,点了取消就再也连不上(firewall.ts)
223
+ const { judgeFirewall, probeFirewall } = await import("./firewall.js");
224
+ const port = opts.port ?? ws.config.server.port;
225
+ const state = await probeFirewall(port);
226
+ if (state) {
227
+ const v = judgeFirewall(state, port, process.execPath);
228
+ push({ name: 'firewall', ok: v.ok, required: false, detail: v.detail, fix: v.fix });
229
+ }
230
+ }
231
+ if (probeEnv) {
232
+ // 端口起不起得来:被别的程序占了,后台服务只在日志里写一句就不起了(serve 自己要绑,它起来时不查这项)
233
+ const { probeHealth, realHost } = await import("./service.js");
234
+ const { portHolder, portState } = await import("./port.js");
235
+ const port = opts.port ?? ws.config.server.port;
236
+ const fix = '腾出这个端口;或改 cotutor.json 的 server.port,平板要重新扫码、重加主屏幕图标';
237
+ if (await probeHealth(port))
238
+ push({ name: 'server.port', ok: true, required: false, detail: `端口 ${port} 上 cotutor 在答应` });
239
+ else {
240
+ const state = await portState(port);
241
+ if (state === 'free')
242
+ push({ name: 'server.port', ok: true, required: false, detail: `端口 ${port} 空着,起得来` });
243
+ else if (state === 'denied')
244
+ push({ name: 'server.port', ok: false, required: false, detail: `系统不让这个用户监听端口 ${port},服务起不来`, fix });
245
+ else {
246
+ const p = await portHolder(realHost(), port);
247
+ push({ name: 'server.port', ok: false, required: false, detail: `端口 ${port} 被占着${p ? `:${p.name}(pid ${p.pid})在用` : ''},服务起不来`, fix });
248
+ }
249
+ }
250
+ }
320
251
  {
321
252
  // 舞台包:小课堂 / 画板卡要它;没有只是重卡打不开,轻卡与对话照常。做课包(cotutor chalk bake / snap)与服务端烤画面也开它,在本机的 Chrome 里
322
253
  const { stageBuilt } = await import("../http/stage.js");
@@ -324,7 +255,7 @@ export async function doctorWorkspace(override, opts = {}) {
324
255
  push({ name: 'stage.bundle', ok: built, required: false, detail: built ? 'dist/stage/ 在(舞台包:小课堂与画板卡的舞台)' : 'dist/stage/ 不在,小课堂与画板卡的舞台打不开(轻卡照常)', fix: built ? undefined : '仓库根 pnpm run build:stage(npm 装的包自带)' });
325
256
  const { chromePath } = await import("./chalk/browser.js");
326
257
  const chrome = chromePath(env);
327
- push({ name: 'chalk.chrome', ok: chrome !== null, required: false, detail: chrome ? `本机的 Chrome 在(${redactHome(chrome)}):做课包截图、烤画面用它` : '找不到 Chrome:cotutor chalk bake / snap 做不了,没烤过的课包孩子端在浏览器里现画(慢、iPad 上可能不顺)', fix: chrome ? undefined : '装 Google Chrome,或环境变量 CHROME 指到 Chrome / Chromium 的可执行文件' });
258
+ push({ name: 'chalk.chrome', ok: chrome !== null, required: false, detail: chrome ? `本机的浏览器在(${redactHome(chrome)}):做课包截图、烤画面用它` : '找不到 Chrome:cotutor chalk bake / snap 做不了,没烤过的课包孩子端在浏览器里现画(慢、iPad 上可能不顺)', fix: chrome ? undefined : '装 Google Chrome,或环境变量 CHROME 指到 Chrome / Chromium / Edge 的可执行文件' });
328
259
  const { CLAUDE_SKILLS_LINK, skillStatuses } = await import("./skills.js");
329
260
  for (const sk of teachingPack ? await skillStatuses(root) : []) {
330
261
  const label = { latest: sk.machine ? '机器件,最新' : '出厂件,最新', upgradable: sk.machine ? '机器件,和包里不一样(改过或包已更新)' : `出厂件,基于 ${sk.basedOn},包已更新`, custom: `自定义(基于 ${sk.basedOn})`, untracked: '自定义(没有出厂记录)', missing: sk.machine ? '缺(机器件,老师或家长的技能靠它)' : '缺' };
@@ -508,36 +439,22 @@ export async function doctorWorkspace(override, opts = {}) {
508
439
  }
509
440
  // ---- 家长端与工作台的锁(src/app/http/access.ts):机器级 PIN;没设不算错,提一句 ----
510
441
  const pin = await readPin();
511
- push({ name: 'server.access', ok: pin !== null, required: false, detail: pin ? '设了 PIN:家长端 /parent 与工作台 /dev 要输一次,本机免密' : 'serve 对局域网全开,家长端 /parent 与工作台 /dev 没有锁:孩子在 iPad 上输 /parent 能看到答案,输 /dev 能改配置。家长在旁边、用完收回 iPad 的可以不设', fix: pin ? undefined : 'cotutor pin(机器级,所有 workspace 共用;cotutor pin off 撤掉)' });
442
+ push({ name: 'server.access', ok: pin !== null, required: false, detail: pin ? '已设 PIN' : '家长端、工作台没上锁', fix: pin ? undefined : 'cotutor pin' });
512
443
  // ---- HTTPS:iPad 上录音要;没有只提醒 ----
513
444
  const tls = httpsFiles(ws);
514
- push({ name: 'https', ok: tls !== null, required: false, detail: tls ? `${redactHome(tls.cert)}` : '没有证书,serve 走 HTTP(iPad / iPhone 上按住说话不可用)', fix: tls ? undefined : 'cotutor cert(需要 mkcert:brew install mkcert && mkcert -install)' });
515
- // ---- 板书写法怎么递给老师:有脸的老师用到的运行时逐个说清,run / resume 不一致点名 ----
516
- {
517
- const used = new Set();
518
- for (const [name, t] of Object.entries(ws.config.tutors))
519
- if (takesTutorRules(name) && t.enabled !== false)
520
- used.add(t.runtime ?? ws.config.runtimes.default);
521
- for (const name of used) {
522
- const rt = ws.config.runtimes[name];
523
- if (!rt || typeof rt === 'string')
524
- continue;
525
- if (isApiRuntime(rt)) {
526
- push({ name: `runtime.${name}.board`, ok: true, required: false, detail: 'API 运行时:板书写法连老师正文一起进系统提示' });
527
- continue;
528
- }
529
- const inRun = boardPreloaded({ run: rt.run, resume: rt.run });
530
- const inResume = boardPreloaded({ run: rt.resume, resume: rt.resume });
531
- const how = runtimeUses(rt, '{boardFile}') ? '{boardFile} 追加进系统提示' : runtimeUses(rt, '{systemBody}') ? '{systemBody} 连老师正文一起进系统提示' : '模板里写死的 SKILL.md 路径';
532
- if (inRun !== inResume)
533
- push({ name: `runtime.${name}.board`, ok: false, required: false, detail: `板书写法的预载只在 ${inRun ? 'run' : 'resume'} 模板里,两边不一致(压缩后系统提示按 resume 的旗标重建)`, fix: 'cotutor upgrade --config 补旗标,或手改 cotutor.json 让两条模板一致' });
534
- else
535
- push({ name: `runtime.${name}.board`, ok: true, required: false, detail: inRun ? `板书写法预载进系统提示(${how}),各话题共享缓存` : '板书写法由应用放进每个话题第一条(<cotutor-board>);这个 CLI 能从文件或参数收系统提示的话,模板里用 {boardFile} / {systemBody} 更省' });
536
- }
537
- }
445
+ const { mkcertHint } = await import("./cert.js");
446
+ push({ name: 'https', ok: tls !== null, required: false, detail: tls ? `${redactHome(tls.cert)}` : '没有证书,serve 走 HTTP(平板、手机上按住说话不可用)', fix: tls ? undefined : `cotutor cert(需要 mkcert:${mkcertHint()})` });
538
447
  // ---- 百炼:老师靠实时模型开口,没配孩子端打不开;--live 真试连一次(握手就挂,不花钱) ----
448
+ // key 只认 ~/.config/cotutor/(2026-11-02 起);shell 里还 export 着旧的,说一句免得以为配了
539
449
  const bl = dashscopeAuth(env);
540
- push({ name: 'bailian', ok: Boolean(bl), required: true, detail: bl ? `百炼按量 key …${bl.key.slice(-4)}、业务空间 ${bl.workspaceId}` : '没有百炼按量的 key 或业务空间 id:孩子端打不开(只出「老师们还没到」)', fix: bl ? undefined : '家长端「首页」里配(在这台电脑上开,或者先 cotutor pin 设家长密码);Token Plan 的 sk-sp- 不行' });
450
+ const oldEnv = env.DASHSCOPE_API_KEY ? '(环境变量 DASHSCOPE_API_KEY 已经不读了)' : '';
451
+ push({ name: 'bailian', ok: Boolean(bl), required: true, detail: bl ? `百炼按量 key …${bl.key.slice(-4)}、业务空间 ${bl.workspaceId}` : `没有百炼按量的 key 或业务空间 id:孩子端打不开(只出「老师们还没到」)${oldEnv}`, fix: bl ? undefined : '家长端「首页」里配(在这台电脑上开,或者先 cotutor pin 设家长密码),存在 ~/.config/cotutor/bailian.json;Token Plan 的 sk-sp- 不行' });
452
+ // Token Plan 只有开发机有,没有就不提
453
+ const plan = tokenPlanKey(env.HOME || homedir());
454
+ if (plan)
455
+ push({ name: 'token-plan', ok: isPlanKey(plan), required: false, detail: isPlanKey(plan) ? `Token Plan key …${plan.slice(-4)}:Token Plan 有的模型先走它` : 'token-plan.json 里的 key 不是 sk-sp- 开头,不认', fix: isPlanKey(plan) ? undefined : '换成千问 Token Plan 的 key,或删掉 ~/.config/cotutor/token-plan.json' });
456
+ else if (env.QWENCLOUD_TOKEN_PLAN_API_KEY)
457
+ push({ name: 'token-plan', ok: false, required: false, detail: '环境变量 QWENCLOUD_TOKEN_PLAN_API_KEY 已经不读了,全走百炼按量', fix: '写进 ~/.config/cotutor/token-plan.json:{"apiKey": "sk-sp-…"}' });
541
458
  if (bl && opts.live) {
542
459
  const { probeRealtime } = await import("../../realtime/preview.js");
543
460
  const { probeMessage } = await import("../machine/bailian.js");
@@ -552,51 +469,65 @@ export async function doctorWorkspace(override, opts = {}) {
552
469
  {
553
470
  const bad = Object.entries(lv.voices).filter(([, v]) => !isRealtimeVoice(v));
554
471
  const who = listTutors(ws.config, { kidOnly: true }).map((t) => `${t.name} 用 ${mouthVoice(lv, t.name)}`).join('、') || '没有有脸的老师';
555
- push({ name: 'live.voices', ok: !bad.length, required: false, detail: bad.length ? `${bad.map(([t, v]) => `${t} 的「${v}」`).join('、')}:这个模型不认,嘴会一直不出声` : `嘴的音色:${who}`, fix: bad.length ? '工作台「音色」页「实时的嘴」里换一个(这个模型认的 56 个在 src/realtime/voices.ts),或改 cotutor.json 的 live.voices' : undefined });
472
+ push({ name: 'live.voices', ok: !bad.length, required: false, detail: bad.length ? `${bad.map(([t, v]) => `${t} 的「${v}」`).join('、')}:这个模型不认,嘴会一直不出声` : `嘴的音色:${who}`, fix: bad.length ? '家长端首页的「样子与声音」里换一个说话的声音,或改 cotutor.json 的 live.voices' : undefined });
556
473
  }
557
- // 旧配置允许加载与升级;退役运行时不探版本、不启动。
474
+ // 旧配置里的命令行运行时(Qwen Code 等)读得进来,用不了:用到它的老师发不出去
558
475
  const usedRuntimes = new Set([ws.config.runtimes.default, ...Object.values(ws.config.tutors).filter((t) => t.enabled).map((t) => t.runtime ?? ws.config.runtimes.default)]);
559
476
  for (const [name, rt] of Object.entries(ws.config.runtimes)) {
560
477
  if (typeof rt === 'string' || isApiRuntime(rt))
561
478
  continue;
562
479
  const issue = runtimeIssue(rt);
563
480
  if (issue)
564
- push({ name: `runtime.${name}`, ok: false, required: usedRuntimes.has(name), detail: issue, fix: '按 README.md 的「旧 workspace 换运行时」说明迁移' });
481
+ push({ name: `runtime.${name}`, ok: false, required: usedRuntimes.has(name), detail: issue, fix: '按 README.md 的「旧 workspace 换运行时」说明改' });
565
482
  }
566
- // ---- 运行时的 CLI 在不在 ----
483
+ // ---- 运行时:API 的走哪条线 ----
567
484
  if (probeEnv) {
568
485
  for (const [runtime, p] of Object.entries(ws.config.runtimes)) {
569
- if (runtime === 'default' || typeof p === 'string')
570
- continue;
571
- if (isApiRuntime(p)) {
572
- push({ name: `runtime.${runtime}`, ok: true, required: usedRuntimes.has(runtime), detail: `API 运行时(百炼 ${p.model}),不用装 CLI;凭证看 bailian 那条` });
573
- continue;
574
- }
575
- if (runtimeIssue(p))
486
+ if (runtime === 'default' || typeof p === 'string' || !isApiRuntime(p))
576
487
  continue;
577
- const bin = runtimeCli(p.run);
578
- const required = usedRuntimes.has(runtime);
579
- // 没有老师用的 CLI 运行时(新装缺省 api,Qwen Code 那两条是备用):不探,免得没装 qwen 的家长看到一串「起不来」
580
- if (!required) {
581
- push({ name: `runtime.${runtime}`, ok: true, required: false, detail: `备用(${bin}),没有老师用它;要用就把老师的 runtime 改成 ${runtime},再跑一次 doctor` });
582
- continue;
583
- }
584
- try {
585
- const { stdout } = await execFileP(bin, ['--version'], { timeout: 8000 });
586
- const version = stdout.trim().split('\n')[0];
587
- push({ name: `runtime.${runtime}`, ok: true, required, detail: `${bin} ${version}` });
588
- // 这个 CLI 的适配器要多看的几样(qwen:版本、百炼 key)
589
- for (const c of adapterFor(p).check?.({ version, env }) ?? [])
590
- push({ name: `runtime.${runtime}.${c.name}`, ok: c.ok, required, detail: c.detail, fix: c.fix });
591
- }
592
- catch {
593
- push({ name: `runtime.${runtime}`, ok: false, required, detail: `PATH 里没有 ${bin}`, fix: required ? `装 ${bin},或把 runtimes.default 改成装了的那个运行时` : undefined });
594
- }
488
+ const via = compatibleLine(p.model, env)?.plan ? '走 Token Plan' : '走百炼按量';
489
+ push({ name: `runtime.${runtime}`, ok: true, required: usedRuntimes.has(runtime), detail: `API 运行时(${p.model},${via});凭证看 bailian 那条` });
595
490
  }
596
491
  }
597
492
  }
598
493
  if (ws && opts.live)
599
494
  await probeLive(ws, push, env);
495
+ // ---- 后台服务(cotutor service):装了没有、写死的 node 与 bin 还在不在;接着电源会不会闲置睡眠(睡着时平板连不上) ----
496
+ if (probeEnv && root && (process.platform === 'darwin' || process.platform === 'linux' || process.platform === 'win32')) {
497
+ const { findService, realHost } = await import("./service.js");
498
+ const s = await findService({ ...realHost(), env }, root).catch(() => null);
499
+ if (s) {
500
+ // 菜单栏 / 任务栏图标:家长不开终端看状态靠它;家长自己关掉的不提
501
+ const { trayStatus, traySupported } = await import("./tray.js");
502
+ if (traySupported(process.platform)) {
503
+ const t = await trayStatus({ ...realHost(), env }).catch(() => null);
504
+ if (t && !t.off)
505
+ push({ name: 'tray', ok: t.installed, required: false, detail: t.installed ? `托盘图标装了${t.running ? ',在跑' : ',现在没在跑(点过退出,下次登录回来)'}` : '没装托盘图标:看状态只能开终端', fix: t.installed ? undefined : 'cotutor tray on' });
506
+ }
507
+ }
508
+ if (!s)
509
+ push({ name: 'service', ok: false, required: false, detail: '没装后台服务:在终端里跑的 cotutor serve,关了终端就停', fix: 'cotutor service install(登录后自己起来、崩了自己再起)' });
510
+ else {
511
+ const gone = s.args.slice(0, 2).filter((f) => !existsSync(f));
512
+ push({ name: 'service', ok: !gone.length, required: false, detail: gone.length ? `后台服务 ${s.label} 指的 ${gone.map((f) => redactHome(f)).join('、')} 不在了(换了 node 版本、包挪了地方)` : `后台服务 ${s.label}(cotutor service status 看在不在跑)`, fix: gone.length ? 'cotutor service install(照现在的 node 与包重写一遍)' : undefined });
513
+ }
514
+ }
515
+ if (probeEnv && process.platform === 'darwin') {
516
+ const { acSleepMinutes } = await import("./service.js");
517
+ const { execFile } = await import('node:child_process');
518
+ const text = await new Promise((resolve) => execFile('/usr/bin/pmset', ['-g', 'custom'], { timeout: 5000, encoding: 'utf8' }, (_e, out) => resolve(String(out ?? ''))));
519
+ const min = acSleepMinutes(text);
520
+ if (min !== null)
521
+ push({ name: 'mac.sleep', ok: min === 0, required: false, detail: min === 0 ? '接着电源时不闲置睡眠' : `接着电源 ${min} 分钟没人碰就睡,睡着时平板连不上(有通话时 cotutor 拦着它睡,别的时候拦不了)`, fix: min === 0 ? undefined : '系统设置 › 电池 › 选项,打开「显示器关闭时,防止接通电源的 Mac 自动进入睡眠」(台式 Mac 在 系统设置 › 节能);合盖照样睡' });
522
+ }
523
+ if (probeEnv && process.platform === 'win32') {
524
+ const { winAcSleepSeconds } = await import("./service-windows.js");
525
+ const { execFile } = await import('node:child_process');
526
+ const text = await new Promise((resolve) => execFile('powercfg.exe', ['/query', 'SCHEME_CURRENT', 'SUB_SLEEP', 'STANDBYIDLE'], { timeout: 5000, encoding: 'latin1', windowsHide: true }, (_e, out) => resolve(String(out ?? ''))));
527
+ const sec = winAcSleepSeconds(text);
528
+ if (sec !== null)
529
+ push({ name: 'windows.sleep', ok: sec === 0, required: false, detail: sec === 0 ? '接着电源时不闲置睡眠' : `接着电源 ${Math.round(sec / 60)} 分钟没人碰就睡,睡着时平板连不上(有通话时 cotutor 拦着它睡,别的时候拦不了)`, fix: sec === 0 ? undefined : '设置 › 系统 › 电源和电池 › 屏幕、睡眠和休眠超时,「插入电源时,使设备在以下时间后进入睡眠状态」选「从不」;合盖看「电源按钮和盖子」那一项' });
530
+ }
600
531
  // ---- 用户配置 ----
601
532
  try {
602
533
  const text = await readFile(USER_CONFIG, 'utf8');
@@ -611,7 +542,12 @@ export async function doctorWorkspace(override, opts = {}) {
611
542
  catch {
612
543
  push({ name: 'user-config', ok: true, required: false, detail: `未配置(可选):{"workspace": "…"} 写进 ${redactHome(USER_CONFIG)};cotutor init 会代劳` });
613
544
  }
614
- const gitThere = (await statOrNull(join(root, '.git'))) !== null;
615
- push({ name: 'git', ok: gitThere, required: false, detail: gitThere ? 'workspace是 git 仓' : 'workspace不在 git 里', fix: gitThere ? undefined : '建议 git init:老师记忆、账本、政策都是不可再生状态' });
545
+ // sandbox(--sandbox 分出来的试用 workspace)随时能重新分,不要 git;说一句它是从哪分出来的
546
+ if (ws?.sandbox)
547
+ push({ name: 'sandbox', ok: true, required: false, detail: `试用 workspace:${ws.sandbox.forkedAt.slice(0, 10)} 从 ${redactHome(ws.sandbox.from)} 分出来;--sandbox --fresh 重新分` });
548
+ else {
549
+ const gitThere = (await statOrNull(join(root, '.git'))) !== null;
550
+ push({ name: 'git', ok: gitThere, required: false, detail: gitThere ? 'workspace是 git 仓' : 'workspace不在 git 里', fix: gitThere ? undefined : '建议 git init:老师记忆、账本、政策都是不可再生状态' });
551
+ }
616
552
  return { ok: checks.filter((c) => c.required).every((c) => c.ok), root, source, checks };
617
553
  }
@@ -0,0 +1,24 @@
1
+ /** 查到的样子;配置名是 Domain / Private / Public,规则上的 Any 覆盖全部 */
2
+ export interface FirewallState {
3
+ /** 连着的网络的类型(Get-NetConnectionProfile 的 NetworkCategory:Private / Public / DomainAuthenticated) */
4
+ networks: string[];
5
+ /** 防火墙开着的配置 */
6
+ enabled: string[];
7
+ /** 放行的规则(入站、允许、启用,按这个 node 或这个端口)各覆盖哪些配置,如 "Private"、"Private, Public"、"Any" */
8
+ allowed: string[];
9
+ /** 拦着这个 node 的规则覆盖哪些配置(家长在「允许访问」那一步点了取消,Windows 会建这种规则) */
10
+ blocked: string[];
11
+ }
12
+ export interface FirewallVerdict {
13
+ ok: boolean;
14
+ detail: string;
15
+ fix?: string;
16
+ }
17
+ /** 查一次的 PowerShell 脚本;node 的路径与端口写进去(单引号里的单引号写两个) */
18
+ export declare function firewallScript(node: string, port: number): string;
19
+ /** PowerShell 回的 JSON → 样子;一个元素的数组可能被拆成字符串,null 当空 */
20
+ export declare function parseFirewall(text: string): FirewallState | null;
21
+ /** 平板连不连得进来,怎么修 */
22
+ export declare function judgeFirewall(s: FirewallState, port: number, node: string): FirewallVerdict;
23
+ /** 真查一次(只在 Windows 上);查不了是 null */
24
+ export declare function probeFirewall(port: number, node?: string): Promise<FirewallState | null>;
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Windows 防火墙放没放行 cotutor:平板连不上多半是它。Windows 只在第一次监听时弹一次「允许访问」,家长点了取消(或没看见)就再也连不上,
3
+ * cotutor 什么都不说(2026-10-31 实测:服务在本机回 200,从局域网超时;网络是「专用」,没有 node 的规则,也没有端口的规则)。
4
+ * 2026-11-06 实测(Windows 11 build 26300,普通权限的账户):node 第一次监听的同一秒,防火墙服务自己建好两条 Block 规则(名字带 Query User)再弹窗问;点「允许」才改成 Allow。
5
+ * 所以刚起来时查到「拦着」,可能只是弹窗还没回答,不一定点了取消。
6
+ * 用 PowerShell 的 NetSecurity 命令查(输出与系统语言无关;netsh 的输出是本地化的,中文 Windows 上解析不了);查不了(没有 PowerShell、超时)就不报,不瞎猜。
7
+ */
8
+ import { execFile } from 'node:child_process';
9
+ const PS_TIMEOUT_MS = 20_000;
10
+ /** 查一次的 PowerShell 脚本;node 的路径与端口写进去(单引号里的单引号写两个) */
11
+ export function firewallScript(node, port) {
12
+ const q = (s) => `'${s.replaceAll("'", "''")}'`;
13
+ return [
14
+ "$ErrorActionPreference = 'SilentlyContinue'",
15
+ `$node = ${q(node)}`,
16
+ `$port = ${q(String(port))}`,
17
+ '$nets = @(Get-NetConnectionProfile | ForEach-Object { [string]$_.NetworkCategory })',
18
+ "$on = @(Get-NetFirewallProfile | Where-Object { [string]$_.Enabled -eq 'True' } | ForEach-Object { [string]$_.Name })",
19
+ // 规则里的程序可能写成 %ProgramFiles%\…,也可能是小写(Windows 弹窗建的):展开了比,-eq 本来就不分大小写
20
+ '$byApp = @(Get-NetFirewallApplicationFilter | Where-Object { [Environment]::ExpandEnvironmentVariables([string]$_.Program) -eq $node } | Get-NetFirewallRule)',
21
+ '$byPort = @(Get-NetFirewallPortFilter -Protocol TCP | Where-Object { @($_.LocalPort) -contains $port } | Get-NetFirewallRule)',
22
+ "$live = { [string]$_.Direction -eq 'Inbound' -and [string]$_.Enabled -eq 'True' }",
23
+ "$allow = @(($byApp + $byPort) | Where-Object $live | Where-Object { [string]$_.Action -eq 'Allow' } | ForEach-Object { [string]$_.Profile })",
24
+ "$block = @($byApp | Where-Object $live | Where-Object { [string]$_.Action -eq 'Block' } | ForEach-Object { [string]$_.Profile })",
25
+ 'ConvertTo-Json -Compress -InputObject @{ networks = $nets; enabled = $on; allowed = $allow; blocked = $block }',
26
+ ].join('\n');
27
+ }
28
+ /** PowerShell 回的 JSON → 样子;一个元素的数组可能被拆成字符串,null 当空 */
29
+ export function parseFirewall(text) {
30
+ let raw;
31
+ try {
32
+ raw = JSON.parse(text.trim());
33
+ }
34
+ catch {
35
+ return null;
36
+ }
37
+ if (!raw || typeof raw !== 'object')
38
+ return null;
39
+ const list = (v) => (Array.isArray(v) ? v.filter((x) => typeof x === 'string') : typeof v === 'string' ? [v] : []);
40
+ const r = raw;
41
+ return { networks: list(r.networks), enabled: list(r.enabled), allowed: list(r.allowed), blocked: list(r.blocked) };
42
+ }
43
+ /** 平板连不连得进来,怎么修 */
44
+ export function judgeFirewall(s, port, node) {
45
+ const nets = [...new Set(s.networks.map((n) => (n === 'DomainAuthenticated' ? 'Domain' : n)))];
46
+ const covers = (rules, profile) => rules.some((r) => r.split(/,\s*/).some((p) => p === 'Any' || p === profile));
47
+ const guarded = nets.filter((n) => s.enabled.includes(n));
48
+ const allowCmd = `netsh advfirewall firewall add rule name="cotutor" dir=in action=allow protocol=TCP localport=${port} profile=private`;
49
+ if (!nets.length)
50
+ return { ok: true, detail: '没连上网络,查不了防火墙(连上 Wi-Fi 再跑一次 doctor)' };
51
+ if (!guarded.length)
52
+ return { ok: true, detail: `防火墙在连着的网络上关着(${nets.join('、')}),平板连得进来` };
53
+ if (guarded.some((n) => covers(s.blocked, n))) {
54
+ return {
55
+ ok: false,
56
+ detail: '防火墙拦着这个 node(第一次起服务时 Windows 先拦着、再弹窗问「允许访问」:还没回答,或者点了取消):平板连不上,这台电脑上照常',
57
+ fix: `屏幕上有「允许访问」的窗口就点「允许」;没有窗口(点过取消了):管理员终端里先删拦截规则:netsh advfirewall firewall delete rule name=all dir=in program="${node}";再放行:${allowCmd}`,
58
+ };
59
+ }
60
+ if (guarded.every((n) => covers(s.allowed, n)))
61
+ return { ok: true, detail: `防火墙放行了(端口 ${port} 或这个 node)` };
62
+ if (guarded.includes('Public') && !covers(s.allowed, 'Public')) {
63
+ return {
64
+ ok: false,
65
+ detail: '这台电脑连的网络是「公用」,防火墙不让平板连进来;这台电脑上照常',
66
+ fix: `设置 › 网络和 Internet › 这个网络的属性,改成「专用」;再在管理员终端里放行:${allowCmd}`,
67
+ };
68
+ }
69
+ return { ok: false, detail: `防火墙没放行端口 ${port}:平板连不上,这台电脑上照常`, fix: `管理员终端(右键「终端」选「以管理员身份运行」)里跑:${allowCmd}` };
70
+ }
71
+ /** 真查一次(只在 Windows 上);查不了是 null */
72
+ export function probeFirewall(port, node = process.execPath) {
73
+ const encoded = Buffer.from(firewallScript(node, port), 'utf16le').toString('base64');
74
+ return new Promise((resolve) => {
75
+ execFile('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-EncodedCommand', encoded], { timeout: PS_TIMEOUT_MS, windowsHide: true }, (err, stdout) => {
76
+ resolve(err ? null : parseFirewall(String(stdout)));
77
+ });
78
+ });
79
+ }
@@ -12,6 +12,7 @@ import { DEFAULT_GRADE, LEGACY_GRADE, requireGradePack } from "./grades.js";
12
12
  import { installActivities } from "./activities.js";
13
13
  import { installSkills, linkClaudeSkills } from "./skills.js";
14
14
  import { migrateLayout } from "./migrate.js";
15
+ import { choiceNote, choosePort, knownPorts } from "./port.js";
15
16
  import { installThemes } from "./themes.js";
16
17
  import { scanVault } from "../../memory/vault.js";
17
18
  import { CONFIG_FILE, ConfigError, UsageError, HOME_ROOT, USER_CONFIG, expandPath, loadWorkspace, parseConfig, readJson, redactHome } from "../workspace.js";
@@ -53,8 +54,10 @@ export async function initWorkspace(opts) {
53
54
  if (await exists(config))
54
55
  steps.push({ item: CONFIG_FILE, action: 'kept', note: '政策文件不覆盖(家长的决定)' });
55
56
  else {
56
- await writeFile(config, configTemplate({ slug: opts.slug, name: opts.name, port: opts.port, tutors: agents }));
57
- steps.push({ item: CONFIG_FILE, action: 'created' });
57
+ // 端口只在这里选一次,写进文件以后不变(port.ts);--port 给了就听它的
58
+ const chosen = opts.port === undefined ? await choosePort(knownPorts()) : null;
59
+ await writeFile(config, configTemplate({ slug: opts.slug, name: opts.name, port: opts.port ?? chosen?.port, tutors: agents }));
60
+ steps.push({ item: CONFIG_FILE, action: 'created', ...(chosen ? { note: choiceNote(chosen) } : {}) });
58
61
  }
59
62
  // 老师按 cotutor.json 里有谁走(家长自己加的也补目录与链);配置坏了就只管出厂的,doctor 去报
60
63
  let tutorNames = [];
@@ -147,9 +150,9 @@ export async function initWorkspace(opts) {
147
150
  if (!(await exists(join(root, '.git'))))
148
151
  suggestions.push('git init(建议:老师记忆、账本、政策都是不可再生状态,git 是破坏后的兜底)');
149
152
  suggestions.push('档案里填 school_start(读一年级的年月,如 2025-09),老师才知道现在是几年级哪个学期;想用自己的 Obsidian 库,改 cotutor.json 的 paths.vault');
150
- suggestions.push('cotutor serve 起服务,在这台电脑上打开家长端 /parent 的「首页」,填百炼的按量 API Key 与业务空间 ID(老师的嘴和脑都用它;没填孩子端只出「老师们还没到」)');
151
- suggestions.push('在 iPad / iPhone 上用:cotutor cert 签证书(要 mkcert),根证书装到设备上并打开信任,步骤见 README 里的《iPad 与 iPhone》');
152
- suggestions.push('可选:点读、单词卡的配音用百炼(和老师的嘴同一把 key),到工作台 /dev 的「音色」页给老师挑声音;cotutor pin 给家长端与工作台加锁');
153
+ suggestions.push('cotutor service install 装成后台服务(登录后自己起来、关了终端照样跑;在终端里调试用 cotutor serve),cotutor open 打开家长端,在「首页」填百炼的按量 API Key 与业务空间 ID(老师的嘴和脑都用它;没填孩子端只出「老师们还没到」)');
154
+ suggestions.push('在平板、手机上用:cotutor cert 签证书(要 mkcert),根证书装到设备上并打开信任,iPad / iPhone 的步骤见 README 里的《iPad 与 iPhone》');
155
+ suggestions.push('可选:点读、单词卡的配音用百炼(和老师的嘴同一把 key),到家长端首页点老师的「样子与声音」挑声音;cotutor pin 给家长端与工作台加锁');
153
156
  suggestions.push('cotutor doctor 逐项体检');
154
157
  return { root, steps, suggestions };
155
158
  }