cotutor 0.4.1 → 0.4.3

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 (46) hide show
  1. package/README.md +24 -6
  2. package/dist/app/cli/backup.d.ts +68 -0
  3. package/dist/app/cli/backup.js +243 -0
  4. package/dist/app/cli/doctor-fix.d.ts +18 -0
  5. package/dist/app/cli/doctor-fix.js +70 -0
  6. package/dist/app/cli/doctor.js +19 -2
  7. package/dist/app/cli/init.d.ts +2 -0
  8. package/dist/app/cli/init.js +6 -3
  9. package/dist/app/cli/main.js +117 -2
  10. package/dist/app/cli/report.d.ts +11 -0
  11. package/dist/app/cli/report.js +110 -0
  12. package/dist/app/cli/serve.d.ts +0 -1
  13. package/dist/app/cli/serve.js +14 -0
  14. package/dist/app/cli/service.d.ts +26 -0
  15. package/dist/app/cli/service.js +36 -5
  16. package/dist/app/cli/skeleton.d.ts +1 -1
  17. package/dist/app/cli/skeleton.js +2 -2
  18. package/dist/app/cli/tray.js +6 -4
  19. package/dist/app/cli/update-cmd.d.ts +38 -0
  20. package/dist/app/cli/update-cmd.js +174 -0
  21. package/dist/app/cli/usage.d.ts +1 -1
  22. package/dist/app/cli/usage.js +4 -1
  23. package/dist/app/http/app.d.ts +2 -0
  24. package/dist/app/http/app.js +18 -3
  25. package/dist/app/http/parent-home.js +4 -0
  26. package/dist/app/http/parent-setup.d.ts +19 -0
  27. package/dist/app/http/parent-setup.js +96 -0
  28. package/dist/app/machine/bailian.d.ts +26 -0
  29. package/dist/app/machine/bailian.js +91 -4
  30. package/dist/app/machine/update.d.ts +14 -1
  31. package/dist/app/machine/update.js +35 -14
  32. package/dist/brain/api.js +6 -3
  33. package/dist/memory/vault-notes.d.ts +4 -0
  34. package/dist/memory/vault-notes.js +11 -0
  35. package/dist/realtime/preview.d.ts +16 -0
  36. package/dist/realtime/preview.js +43 -1
  37. package/dist/runtime/live/call.d.ts +2 -0
  38. package/dist/runtime/live/call.js +6 -1
  39. package/dist/shared/upgrade.d.ts +5 -3
  40. package/dist/shared/upgrade.js +8 -4
  41. package/dist/ui/kid/kid-page.js +8 -2
  42. package/dist/ui/parent/parent-home-page.js +67 -3
  43. package/dist/ui/parent/parent-settings-page.js +65 -1
  44. package/package.json +7 -1
  45. package/skills/cotutor-analyze/references//345/221/275/344/273/244/344/270/216/346/226/207/344/273/266.md +1 -1
  46. package/skills/cotutor-tune/references//345/255/227/346/256/265.md +1 -1
@@ -10,31 +10,111 @@
10
10
  *
11
11
  * 改 key 要么在这台电脑上(本机免密),要么设了家长密码:没设 PIN 时局域网谁都进得了家长端,不让别的设备往这台电脑写 key。
12
12
  * 测试与探针指了假的实时端点(COTUTOR_LIVE_ENDPOINT)就当配好了,试连也连那个。
13
+ *
14
+ * 跑起来以后也会坏(欠费、key 失效、这台电脑断网):ready 只说「填过」,看不出来。嘴和脑出错时记一笔(note),/api/health 带给图标和家长端;
15
+ * 要家长动手的那几类,孩子端显示「叫爸爸妈妈」(blocked)。错误码照官方错误码表(help.aliyun.com/zh/model-studio/error-code);欠费没法真测,是照表写的,假 key 是真跑过的。
13
16
  */
14
17
  import { mkdir, writeFile } from 'node:fs/promises';
15
18
  import { homedir } from 'node:os';
16
19
  import { dirname } from 'node:path';
17
20
  import { bailianFile } from "../../shared/qianwen.js";
18
21
  import { dashscopeAuth } from "../../realtime/bailian.js";
19
- import { probeRealtime } from "../../realtime/preview.js";
22
+ import { askRealtime, probeRealtime, unclearWhy } from "../../realtime/preview.js";
23
+ const TROUBLE_TEXT = {
24
+ key: { message: '百炼的 key 不对或失效了', fix: '到家长端「首页」换一把百炼按量的 key。' },
25
+ money: { message: '百炼欠费或没开通按量付费', fix: '到百炼控制台充值或开通,好了几分钟内自己恢复。' },
26
+ space: { message: '这把 key 用不了这个业务空间', fix: '到家长端「首页」核对业务空间 id。' },
27
+ network: { message: '这台电脑连不上百炼', fix: '看看这台电脑的网络,好了几分钟内自己恢复。' },
28
+ other: { message: '百炼这几次都没连上', fix: '过一会儿再看;一直这样,到家长端「首页」点试连。' },
29
+ };
30
+ /** 按错误码分类;官方表里没有的算 other */
31
+ export function troubleKind(why) {
32
+ if (/InvalidApiKey|invalid_api_key|\b401\b/i.test(why))
33
+ return 'key';
34
+ if (/Arrearage|BillOverdue|AllocationQuota|CommodityNotPurchased|Unpurchased|insufficient_quota/i.test(why))
35
+ return 'money';
36
+ if (/AccessDenied|\b403\b/i.test(why))
37
+ return 'space';
38
+ if (/^network$|^timeout$|请求或流断了/.test(why))
39
+ return 'network';
40
+ return 'other';
41
+ }
42
+ /** 这几类等不好,要家长动手,一次就算;网络和别的可能只是抖一下,连着两次才算 */
43
+ const needsParent = (kind) => kind === 'key' || kind === 'money' || kind === 'space';
44
+ const STRIKES = 2;
45
+ /** 脑和通话中的错没法自己再试(再试要花钱),多久没再犯就算过去了;还在坏的话孩子下一次用又会记上 */
46
+ const EVENT_TTL_MS = 3600_000;
20
47
  export function createBailian(opts = {}) {
21
48
  const env = opts.env ?? process.env;
22
49
  const home = env.HOME || homedir();
23
50
  const fake = env.COTUTOR_LIVE_ENDPOINT || undefined;
24
- return {
51
+ const now = opts.now ?? Date.now;
52
+ const ask = opts.ask ?? (() => askRealtime({ env, endpoint: fake }));
53
+ const notes = new Map();
54
+ const probe = opts.probe ?? ((auth) => probeRealtime({ auth, env, endpoint: fake }));
55
+ const counted = (source) => {
56
+ const n = notes.get(source);
57
+ if (!n)
58
+ return null;
59
+ if (source !== 'mouth' && now() - n.at > EVENT_TTL_MS) {
60
+ notes.delete(source);
61
+ return null;
62
+ }
63
+ return needsParent(n.kind) || n.count >= STRIKES ? n : null;
64
+ };
65
+ const bailian = {
25
66
  status() {
26
67
  const auth = dashscopeAuth(env);
27
68
  if (!auth)
28
69
  return { ready: Boolean(fake), keyTail: null, workspaceId: null };
29
70
  return { ready: true, keyTail: auth.key.slice(-4), workspaceId: auth.workspaceId };
30
71
  },
31
- probe: opts.probe ?? ((auth) => probeRealtime({ auth, env, endpoint: fake })),
72
+ probe,
32
73
  async save(auth) {
33
74
  const file = bailianFile(home);
34
75
  await mkdir(dirname(file), { recursive: true });
35
76
  await writeFile(file, `${JSON.stringify({ apiKey: auth.key, workspaceId: auth.workspaceId, at: new Date().toISOString() }, null, 2)}\n`, { mode: 0o600 });
77
+ notes.clear();
78
+ },
79
+ trouble() {
80
+ const n = counted('mouth') ?? counted('call') ?? counted('brain');
81
+ return n ? { kind: n.kind, ...TROUBLE_TEXT[n.kind], why: n.why.slice(0, 200), blocked: bailian.blocked() } : null;
82
+ },
83
+ blocked() {
84
+ const n = counted('mouth');
85
+ return Boolean(n && needsParent(n.kind));
86
+ },
87
+ async note(source, why) {
88
+ // 通话自己结束了、还没配:都不是故障(没配有 ready 管)
89
+ if (why === 'ended' || why === 'no_auth')
90
+ return;
91
+ const real = unclearWhy(why) ? (await ask().catch(() => null)) ?? why : why;
92
+ if (real === 'no_auth')
93
+ return;
94
+ const kind = troubleKind(real);
95
+ // 通话中百炼报的多半是这一句的事(声音太短之类),只认要家长动手的
96
+ if (source === 'call' && !needsParent(kind))
97
+ return;
98
+ const was = notes.get(source);
99
+ notes.set(source, { why: real, kind, count: (was?.count ?? 0) + 1, at: now() });
100
+ },
101
+ fine(source) {
102
+ if (source)
103
+ notes.delete(source);
104
+ else
105
+ notes.clear();
106
+ },
107
+ async recheck() {
108
+ if (!notes.has('mouth'))
109
+ return;
110
+ const why = await probe();
111
+ if (why === null || why === 'no_auth')
112
+ notes.delete('mouth');
113
+ else
114
+ notes.set('mouth', { why, kind: troubleKind(why), count: STRIKES, at: now() });
36
115
  },
37
116
  };
117
+ return bailian;
38
118
  }
39
119
  /** 试连没通的原因,写给家长看 */
40
120
  export function probeMessage(why) {
@@ -44,6 +124,9 @@ export function probeMessage(why) {
44
124
  return '连不上:key 或业务空间 id 不对,或者这台电脑上不了网';
45
125
  if (why === 'timeout')
46
126
  return '连上了,等不到回音(超时),过一会儿再试';
127
+ const kind = troubleKind(why);
128
+ if (kind !== 'other')
129
+ return `${TROUBLE_TEXT[kind].message}。${TROUBLE_TEXT[kind].fix}`;
47
130
  return `百炼说:${why}`;
48
131
  }
49
132
  const KEY_RE = /^sk-[A-Za-z0-9._-]{16,}$/;
@@ -54,12 +137,16 @@ export async function bailianRoute(method, p, ctx, body, who) {
54
137
  if (p !== '/api/parent/bailian' && p !== '/api/parent/bailian/test')
55
138
  return null;
56
139
  const canEdit = who === 'local' || (await ctx.access.pinSet());
57
- const view = () => ({ ...ctx.bailian.status(), canEdit });
140
+ const view = () => ({ ...ctx.bailian.status(), canEdit, trouble: ctx.bailian.trouble() });
58
141
  if (p === '/api/parent/bailian/test') {
59
142
  if (method !== 'POST')
60
143
  return { status: 405, json: { error: 'method_not_allowed' } };
61
144
  const t0 = Date.now();
62
145
  const why = await ctx.bailian.probe();
146
+ if (why === null)
147
+ ctx.bailian.fine();
148
+ else
149
+ await ctx.bailian.note('mouth', why);
63
150
  return { status: 200, json: { ok: why === null, ms: Date.now() - t0, ...(why === null ? {} : { message: probeMessage(why) }) } };
64
151
  }
65
152
  if (method === 'GET')
@@ -3,14 +3,27 @@ export declare const updateFile: () => string;
3
3
  export interface UpdateRecord {
4
4
  checkedAt?: string;
5
5
  latest?: string;
6
+ notes?: string[];
6
7
  off?: boolean;
7
8
  }
9
+ /** 网上来的,要显示给家长:只留几行短的纯文本 */
10
+ export declare function cleanNotes(raw: unknown): string[];
8
11
  /** 读不到、写坏了 → 空的,当作没问过 */
9
12
  export declare function readUpdateRecord(file?: string): Promise<UpdateRecord>;
13
+ export interface Release {
14
+ version: string;
15
+ notes: string[];
16
+ /** 新版本要的 Node 主次版本,如 [22, 18];没写 → null */
17
+ minNode: [number, number] | null;
18
+ /** 答的是不是 npm 本家;不是就说明 npm 不通,下载也要走镜像 */
19
+ mirror: boolean;
20
+ }
10
21
  /** 依次问,谁先答谁算;都问不到 → null */
11
- export declare function fetchLatest(fetchFn?: typeof fetch, urls?: string[]): Promise<string | null>;
22
+ export declare function fetchRelease(fetchFn?: typeof fetch, urls?: string[]): Promise<Release | null>;
12
23
  /** 这个进程现在知道的最新版本;还不知道、关掉了 → null */
13
24
  export declare const knownLatest: () => string | null;
25
+ /** 那个版本的更新说明 */
26
+ export declare const knownNotes: () => string[];
14
27
  export interface UpdateOptions {
15
28
  now?: number;
16
29
  fetch?: typeof fetch;
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * 有没有新版本:服务每天问一次 npm 的 latest,结果存机器级 ~/.config/cotutor/update.json(几个孩子的服务共用,一天只问一次,重起不重问)。
3
3
  * /api/health 带上 latest(比在跑的新才有),托盘、家长端、service status 都从它拿;doctor 只读这份文件,不上网。
4
+ * 更新说明写在 package.json 的 releaseNotes,跟着 npm 一起下来(npm、npmmirror 都留自定义字段,2026-11-07 查过),国内连不上 GitHub 也看得到。
4
5
  * 只发一个普通的 GET,不带孩子或使用的任何信息;问不到(断网、被墙)不报错,下个小时再问。先问 npm,不通再问 npmmirror(中国家庭常连不上 npm)。
5
6
  * latest 这个标签要等 npm 扫描通过才变(2026-07 起发布后先扫描),不会提前提醒。
6
7
  * 不自动升级:升级要重起服务,孩子可能正在上课;老师文件的更新也要家长知道。
@@ -16,13 +17,19 @@ const HOUR_MS = 3600_000;
16
17
  const FIRST_MS = 60_000;
17
18
  const TIMEOUT_MS = 8000;
18
19
  export const updateFile = () => join(homedir(), '.config', 'cotutor', 'update.json');
20
+ /** 网上来的,要显示给家长:只留几行短的纯文本 */
21
+ export function cleanNotes(raw) {
22
+ if (!Array.isArray(raw))
23
+ return [];
24
+ return raw.filter((x) => typeof x === 'string').map((x) => x.replace(/[\u0000-\u001f\u007f]+/g, ' ').trim()).filter((x) => x && x.length <= 140).slice(0, 8);
25
+ }
19
26
  /** 读不到、写坏了 → 空的,当作没问过 */
20
27
  export async function readUpdateRecord(file = updateFile()) {
21
28
  try {
22
29
  const j = JSON.parse(await readFile(file, 'utf8'));
23
30
  return {
24
31
  ...(typeof j.checkedAt === 'string' ? { checkedAt: j.checkedAt } : {}),
25
- ...(isPlainVersion(j.latest) ? { latest: j.latest } : {}),
32
+ ...(isPlainVersion(j.latest) ? { latest: j.latest, notes: cleanNotes(j.notes) } : {}),
26
33
  ...(j.off === true ? { off: true } : {}),
27
34
  };
28
35
  }
@@ -30,16 +37,21 @@ export async function readUpdateRecord(file = updateFile()) {
30
37
  return {};
31
38
  }
32
39
  }
40
+ /** engines.node 里的下限;只认 >=22.18 这种,看不懂的不拦 */
41
+ function minNodeOf(raw) {
42
+ const m = /^\s*>=\s*(\d+)(?:\.(\d+))?/.exec(typeof raw === 'string' ? raw : '');
43
+ return m ? [Number(m[1]), Number(m[2] ?? 0)] : null;
44
+ }
33
45
  /** 依次问,谁先答谁算;都问不到 → null */
34
- export async function fetchLatest(fetchFn = fetch, urls = UPDATE_URLS) {
35
- for (const url of urls) {
46
+ export async function fetchRelease(fetchFn = fetch, urls = UPDATE_URLS) {
47
+ for (const [i, url] of urls.entries()) {
36
48
  try {
37
49
  const r = await fetchFn(url, { signal: AbortSignal.timeout(TIMEOUT_MS), headers: { accept: 'application/json' } });
38
50
  if (!r.ok)
39
51
  continue;
40
- const v = (await r.json()).version;
41
- if (isPlainVersion(v))
42
- return v;
52
+ const j = (await r.json());
53
+ if (isPlainVersion(j.version))
54
+ return { version: j.version, notes: cleanNotes(j.releaseNotes), minNode: minNodeOf(j.engines?.node), mirror: i > 0 };
43
55
  }
44
56
  catch {
45
57
  /* 这家不通,问下一家 */
@@ -48,27 +60,36 @@ export async function fetchLatest(fetchFn = fetch, urls = UPDATE_URLS) {
48
60
  return null;
49
61
  }
50
62
  let known = null;
63
+ let knownNoteLines = [];
51
64
  /** 这个进程现在知道的最新版本;还不知道、关掉了 → null */
52
65
  export const knownLatest = () => known;
66
+ /** 那个版本的更新说明 */
67
+ export const knownNotes = () => knownNoteLines;
68
+ const remember = (latest, notes = []) => {
69
+ known = latest;
70
+ knownNoteLines = latest ? notes : [];
71
+ return latest;
72
+ };
53
73
  /** 文件里的还新鲜就用它,否则问一次并存下;问不到就继续用上次的 */
54
74
  export async function refreshLatest(o = {}) {
55
75
  const file = o.file ?? updateFile();
56
76
  const now = o.now ?? Date.now();
57
77
  const rec = await readUpdateRecord(file);
58
78
  if (rec.off)
59
- return (known = null);
79
+ return remember(null);
60
80
  const at = rec.checkedAt ? Date.parse(rec.checkedAt) : Number.NaN;
61
81
  // 时间在将来(改过系统时间)算不新鲜
62
82
  if (rec.latest && now >= at && now - at < DAY_MS)
63
- return (known = rec.latest);
64
- const latest = await fetchLatest(o.fetch);
65
- if (!latest)
66
- return (known = rec.latest ?? null);
67
- known = latest;
83
+ return remember(rec.latest, rec.notes);
84
+ const release = await fetchRelease(o.fetch);
85
+ if (!release)
86
+ return remember(rec.latest ?? null, rec.notes);
87
+ const { version: latest, notes } = release;
88
+ remember(latest, notes);
68
89
  try {
69
90
  await mkdir(dirname(file), { recursive: true });
70
91
  const tmp = `${file}.${process.pid}.tmp`;
71
- await writeFile(tmp, `${JSON.stringify({ checkedAt: new Date(now).toISOString(), latest }, null, 2)}\n`);
92
+ await writeFile(tmp, `${JSON.stringify({ checkedAt: new Date(now).toISOString(), latest, notes }, null, 2)}\n`);
72
93
  await rename(tmp, file);
73
94
  }
74
95
  catch {
@@ -78,7 +99,7 @@ export async function refreshLatest(o = {}) {
78
99
  }
79
100
  /** serve 起来时调:先用存着的那份,一分钟后问(刚开机网可能还没好),之后每小时看一眼,过了一天才真的问。定时器不拦进程退出 */
80
101
  export function startUpdateChecks(o = {}) {
81
- void readUpdateRecord(o.file).then((rec) => { known = rec.off ? null : rec.latest ?? null; });
102
+ void readUpdateRecord(o.file).then((rec) => { remember(rec.off ? null : rec.latest ?? null, rec.notes); });
82
103
  const run = () => { void refreshLatest(o).catch(() => { }); };
83
104
  const first = setTimeout(run, o.firstMs ?? FIRST_MS);
84
105
  first.unref();
package/dist/brain/api.js CHANGED
@@ -76,10 +76,13 @@ export function effortFetch(effort, base = fetch) {
76
76
  return base(input, { ...init, body: JSON.stringify({ ...body, reasoning_effort: effort }) });
77
77
  };
78
78
  }
79
- /** 报错只说状态码:SDK 的异常里带着整个请求 */
79
+ /** 报错只说状态码和错误码(欠费、key 失效靠它分):SDK 的异常里带着整个请求,别的都不要 */
80
80
  function failure(err) {
81
- const status = err && typeof err === 'object' && 'statusCode' in err ? err.statusCode : undefined;
82
- return typeof status === 'number' ? `api:HTTP ${status}` : 'api:请求或流断了';
81
+ const e = err && typeof err === 'object' ? err : {};
82
+ if (typeof e.statusCode !== 'number')
83
+ return 'api:请求或流断了';
84
+ const code = typeof e.responseBody === 'string' ? /"code"\s*:\s*"([A-Za-z0-9_.-]{2,64})"/.exec(e.responseBody)?.[1] : undefined;
85
+ return `api:HTTP ${e.statusCode}${code ? ` ${code}` : ''}`;
83
86
  }
84
87
  const outputText = (o) => (o && typeof o === 'object' && typeof o.text === 'string' ? o.text : JSON.stringify(o) ?? '');
85
88
  const errorText = (e) => (e instanceof Error ? e.message : String(e));
@@ -20,6 +20,10 @@ export interface VaultNote {
20
20
  }
21
21
  /** 只认平铺的 `key: value`;值去掉成对引号。没有 frontmatter → {} */
22
22
  export declare function frontmatter(md: string): Record<string, string>;
23
+ /** 现在读 grade 年级的孩子,读一年级是哪年 9 月(学年从 9 月算,同下面的 semesterAt;7、8 月算刚读完的那个年级) */
24
+ export declare function schoolStartFor(grade: number, now: Date): string;
25
+ /** 把 school_start 写进档案的 frontmatter:有空着的那行就填,没有就加在第一行 --- 后面;别的一个字不动 */
26
+ export declare function setSchoolStart(md: string, value: string): string;
23
27
  /**
24
28
  * 某天是几年级的哪个学期 / 假期:9–1 月上学期、2 月寒假、3–6 月下学期、7–8 月暑假(刚读完的那个年级的暑假)。
25
29
  * 学年从 9 月算;`school_start` 是读一年级那年的 9 月(写别的月份也按它所在学年算)。年级不在 1–12 → null。
@@ -30,6 +30,17 @@ export function frontmatter(md) {
30
30
  return {};
31
31
  }
32
32
  const CN_DIGITS = ['', '一', '二', '三', '四', '五', '六', '七', '八', '九', '十', '十一', '十二'];
33
+ /** 现在读 grade 年级的孩子,读一年级是哪年 9 月(学年从 9 月算,同下面的 semesterAt;7、8 月算刚读完的那个年级) */
34
+ export function schoolStartFor(grade, now) {
35
+ const year = now.getMonth() + 1 >= 9 ? now.getFullYear() : now.getFullYear() - 1;
36
+ return `${year - (grade - 1)}-09`;
37
+ }
38
+ /** 把 school_start 写进档案的 frontmatter:有空着的那行就填,没有就加在第一行 --- 后面;别的一个字不动 */
39
+ export function setSchoolStart(md, value) {
40
+ if (/^school_start:.*$/m.test(md))
41
+ return md.replace(/^school_start:.*$/m, `school_start: ${value}`);
42
+ return md.replace(/^---\r?\n/, (m) => `${m}school_start: ${value}\n`);
43
+ }
33
44
  /**
34
45
  * 某天是几年级的哪个学期 / 假期:9–1 月上学期、2 月寒假、3–6 月下学期、7–8 月暑假(刚读完的那个年级的暑假)。
35
46
  * 学年从 9 月算;`school_start` 是读一年级那年的 9 月(写别的月份也按它所在学年算)。年级不在 1–12 → null。
@@ -21,3 +21,19 @@ export declare function probeRealtime(o?: {
21
21
  endpoint?: string;
22
22
  timeoutMs?: number;
23
23
  }): Promise<string | null>;
24
+ export declare const unclearWhy: (why: string) => boolean;
25
+ /**
26
+ * 连不上时问清原因。Node 的 WebSocket 客户端握手被拒只给 error,看不到状态码,key 错和断网一个样;
27
+ * 同一个地址发一次普通的升级请求,被拒时百炼回 401 和 {"code":"InvalidApiKey"}(2026-11-07 假 key 实测)。不花钱,升级成了也当场断。
28
+ * 回错误码,没有错误码回 http:<状态码>;答应升级了回 null(key 是好的,刚才那次是别的原因);根本连不上回 network。
29
+ */
30
+ export declare function askRealtime(o?: {
31
+ auth?: {
32
+ key: string;
33
+ workspaceId: string;
34
+ };
35
+ model?: string;
36
+ env?: NodeJS.ProcessEnv;
37
+ endpoint?: string;
38
+ timeoutMs?: number;
39
+ }): Promise<string | null>;
@@ -2,9 +2,12 @@
2
2
  * 试听嘴的一个音色(工作台音色页「实时的嘴」):真连一次百炼 realtime,让它照念一句(同实时老师的照念指令),收下 24k PCM 包成 wav。
3
3
  * 几分钱一次;调用方按「音色 + 句子」缓存,同一句同一个音色只念一次。
4
4
  */
5
+ import { randomBytes } from 'node:crypto';
6
+ import { request as httpRequest } from 'node:http';
7
+ import { request as httpsRequest } from 'node:https';
5
8
  import { readCue } from "./cues.js";
6
9
  import { wavOfPcm } from "../shared/wav.js";
7
- import { OUTPUT_RATE, RealtimeSession } from "./bailian.js";
10
+ import { OUTPUT_RATE, REALTIME_MODEL, RealtimeSession, dashscopeAuth, endpointOf } from "./bailian.js";
8
11
  export async function previewVoice(o) {
9
12
  const chunks = [];
10
13
  let err = '';
@@ -34,5 +37,44 @@ export async function probeRealtime(o = {}) {
34
37
  const s = new RealtimeSession({ instructions: '试连。', turn: 'manual', model: o.model, auth: o.auth, env: o.env, endpoint: o.endpoint, timeoutMs: o.timeoutMs }, {});
35
38
  const why = await s.connect();
36
39
  s.close('probe');
40
+ if (why !== null && UNCLEAR.test(why))
41
+ return (await askRealtime(o)) ?? why;
37
42
  return why;
38
43
  }
44
+ /** WebSocket 只告诉我们「连不上」的那几种:握手被拒、连上又断 */
45
+ const UNCLEAR = /^(connect|closed:)/;
46
+ export const unclearWhy = (why) => UNCLEAR.test(why);
47
+ /**
48
+ * 连不上时问清原因。Node 的 WebSocket 客户端握手被拒只给 error,看不到状态码,key 错和断网一个样;
49
+ * 同一个地址发一次普通的升级请求,被拒时百炼回 401 和 {"code":"InvalidApiKey"}(2026-11-07 假 key 实测)。不花钱,升级成了也当场断。
50
+ * 回错误码,没有错误码回 http:<状态码>;答应升级了回 null(key 是好的,刚才那次是别的原因);根本连不上回 network。
51
+ */
52
+ export function askRealtime(o = {}) {
53
+ const auth = o.endpoint ? null : o.auth ?? dashscopeAuth(o.env);
54
+ if (!o.endpoint && !auth)
55
+ return Promise.resolve('no_auth');
56
+ const url = new URL((o.endpoint ?? endpointOf(auth.workspaceId, o.model ?? REALTIME_MODEL)).replace(/^ws/, 'http'));
57
+ return new Promise((resolve) => {
58
+ const send = url.protocol === 'https:' ? httpsRequest : httpRequest;
59
+ const req = send(url, {
60
+ method: 'GET',
61
+ timeout: o.timeoutMs ?? 5000,
62
+ headers: { authorization: `Bearer ${auth?.key ?? 'sk-test'}`, connection: 'Upgrade', upgrade: 'websocket', 'sec-websocket-version': '13', 'sec-websocket-key': randomBytes(16).toString('base64') },
63
+ });
64
+ req.on('upgrade', (_res, socket) => { socket.destroy(); resolve(null); });
65
+ req.on('response', (res) => {
66
+ let body = '';
67
+ res.setEncoding('utf8');
68
+ res.on('data', (c) => { if (body.length < 4096)
69
+ body += c; });
70
+ res.on('end', () => {
71
+ // 只取错误码:别的留给人看没用,也不让对面的长文本跟着往下传
72
+ const code = /"code"\s*:\s*"([A-Za-z0-9_.-]{2,64})"/.exec(body)?.[1];
73
+ resolve(code ?? `http:${res.statusCode ?? 0}`);
74
+ });
75
+ });
76
+ req.on('timeout', () => req.destroy());
77
+ req.on('error', () => resolve('network'));
78
+ req.end();
79
+ });
80
+ }
@@ -9,6 +9,8 @@ export interface KidSocket {
9
9
  export interface LiveApp {
10
10
  readonly runner: Runner;
11
11
  now(): Date;
12
+ /** 嘴连上了(null)、连不上(原因)、通话中百炼报错(midCall):app 那层记下,给家长看 */
13
+ mouthHealth?(why: string | null, midCall?: boolean): Promise<void>;
12
14
  }
13
15
  export interface LiveDeps {
14
16
  ctx: LiveApp;
@@ -401,6 +401,7 @@ export class LiveCall {
401
401
  if (this.session !== s)
402
402
  return;
403
403
  this.send({ t: 'err', status: 502, error: m });
404
+ void this.deps.ctx.mouthHealth?.(m, true);
404
405
  // commit 没收下(声音太少):这一句不会有转写,照转写是空的办
405
406
  if ((this.pendingVoice || this.echoHeld) && /committing input audio buffer/i.test(m))
406
407
  this.heard('');
@@ -419,8 +420,12 @@ export class LiveCall {
419
420
  /** 开一条嘴的会话并换上(第一次、断了以后现连) */
420
421
  async connectMouth() {
421
422
  const { why, s, open } = await this.openMouth();
422
- if (why === null)
423
+ if (why === null) {
423
424
  this.mounted(s, open);
425
+ void this.deps.ctx.mouthHealth?.(null);
426
+ }
427
+ else
428
+ await this.deps.ctx.mouthHealth?.(why);
424
429
  return why;
425
430
  }
426
431
  /** 换上一条嘴的会话:一直听的状态都从头来 */
@@ -1,8 +1,10 @@
1
1
  /** 版本比较与升级步骤:服务、托盘(轻入口,不带 zod)、家长端、doctor 共用 */
2
- /** 升级程序的三步。分开写不用 &&:Windows 默认的 PowerShell 5.1 不认 &&;顺序是先换文件再重起,新代码不会读到旧文件;restart 顺手把图标也换上新代码 */
3
- export declare const UPGRADE_STEPS: readonly ["npm i -g cotutor", "cotutor upgrade", "cotutor service restart"];
4
- /** 给家长看的那份。Windows 上带 .cmd:PowerShell 缺省禁止跑脚本,不带后缀找到的是被拦的 .ps1,第一条就报错;.cmd 在 PowerShell 和 cmd.exe 里都能跑(2026-11-07 真机) */
2
+ /** 升级要跑的命令:cotutor update 一条管到底(换程序、更新每个孩子的出厂文件、重起服务和图标,update-cmd.ts) */
3
+ export declare const UPGRADE_STEPS: readonly ["cotutor update"];
4
+ /** 给家长看的那份。Windows 上带 .cmd:PowerShell 缺省禁止跑脚本,不带后缀找到的是被拦的 .ps1,一跑就报错;.cmd 在 PowerShell 和 cmd.exe 里都能跑(2026-11-07 真机) */
5
5
  export declare function upgradeSteps(platform?: NodeJS.Platform): string[];
6
+ /** 给家长抄的一条命令:Windows 上第一个词带 .cmd,原因同上 */
7
+ export declare function cliLine(cmd: string, platform?: NodeJS.Platform): string;
6
8
  /** 正式版的版本号。预发布不算;从网上拿来的还要显示给家长,其余一律不认 */
7
9
  export declare function isPlainVersion(v: unknown): v is string;
8
10
  /** a 比 b 新;任一边不是正式版的版本号 → false,宁可不提醒 */
@@ -1,9 +1,13 @@
1
1
  /** 版本比较与升级步骤:服务、托盘(轻入口,不带 zod)、家长端、doctor 共用 */
2
- /** 升级程序的三步。分开写不用 &&:Windows 默认的 PowerShell 5.1 不认 &&;顺序是先换文件再重起,新代码不会读到旧文件;restart 顺手把图标也换上新代码 */
3
- export const UPGRADE_STEPS = ['npm i -g cotutor', 'cotutor upgrade', 'cotutor service restart'];
4
- /** 给家长看的那份。Windows 上带 .cmd:PowerShell 缺省禁止跑脚本,不带后缀找到的是被拦的 .ps1,第一条就报错;.cmd 在 PowerShell 和 cmd.exe 里都能跑(2026-11-07 真机) */
2
+ /** 升级要跑的命令:cotutor update 一条管到底(换程序、更新每个孩子的出厂文件、重起服务和图标,update-cmd.ts) */
3
+ export const UPGRADE_STEPS = ['cotutor update'];
4
+ /** 给家长看的那份。Windows 上带 .cmd:PowerShell 缺省禁止跑脚本,不带后缀找到的是被拦的 .ps1,一跑就报错;.cmd 在 PowerShell 和 cmd.exe 里都能跑(2026-11-07 真机) */
5
5
  export function upgradeSteps(platform = process.platform) {
6
- return UPGRADE_STEPS.map((s) => (platform === 'win32' ? s.replace(' ', '.cmd ') : s));
6
+ return UPGRADE_STEPS.map((s) => cliLine(s, platform));
7
+ }
8
+ /** 给家长抄的一条命令:Windows 上第一个词带 .cmd,原因同上 */
9
+ export function cliLine(cmd, platform = process.platform) {
10
+ return platform === 'win32' ? cmd.replace(/^(cotutor|npm)(?= |$)/, '$1.cmd') : cmd;
7
11
  }
8
12
  const PLAIN = /^(\d+)\.(\d+)\.(\d+)$/;
9
13
  /** 正式版的版本号。预发布不算;从网上拿来的还要显示给家长,其余一律不认 */
@@ -1066,7 +1066,12 @@ ${HOME_TILES_JS}
1066
1066
  try { S.home = await api('GET', '/api/kid/home'); setOffline(false); document.body.classList.toggle('hand-left', S.home.hand === 'left'); setGated(!REEL && S.home.ready === false); renderHome(); phIdle(); }
1067
1067
  catch { setOffline(true); }
1068
1068
  };
1069
- /** 百炼没配:首页的老师灰着点不进去;在老师页上就回首页。家长配好了下一次读首页自己就好 */
1069
+ /** 嘴连不上时:要家长动手的(欠费、key 失效,服务端的 ready 变 false)回首页说「叫爸爸妈妈」,别的(网络抖)留在原地接着重连 */
1070
+ const gateCheck = async () => {
1071
+ if (REEL) return;
1072
+ try { const d = await api('GET', '/api/kid/home'); if (d.ready === false) { S.home = d; setGated(true); renderHome(); } } catch {}
1073
+ };
1074
+ /** 百炼没配、欠费、key 失效:首页的老师灰着点不进去;在老师页上就回首页。家长弄好了下一次读首页自己就好 */
1070
1075
  const setGated = (on) => {
1071
1076
  if (S.gated === on) return;
1072
1077
  S.gated = on;
@@ -3130,7 +3135,8 @@ __REEL_DOCK_JS__
3130
3135
  case 'vad': if (m.on) lvSend({ t: 'ctx', body: liveBody() }); liveDispatch({ type: 'vad', on: Boolean(m.on) }); break;
3131
3136
  case 'duck': liveDispatch({ type: 'duck' }); break;
3132
3137
  case 'unduck': liveDispatch({ type: 'unduck' }); break;
3133
- case 'down': case 'end': liveDispatch({ type: 'drop' }); break;
3138
+ case 'down': liveDispatch({ type: 'drop' }); gateCheck(); break;
3139
+ case 'end': liveDispatch({ type: 'drop' }); break;
3134
3140
  case 'tutor':
3135
3141
  // 嘴接住的字一到就上字幕;照念的字幕跟 read 走(到点才亮)
3136
3142
  if (m.of !== 'catch') break;
@@ -218,12 +218,29 @@ ${BRAND_CSS}
218
218
  .gh { padding:0 6px 6px; }
219
219
  }
220
220
  .upd { position:relative; display:flex; flex-direction:column; gap:6px; margin:0 0 12px; padding:12px 14px; border-radius:14px; background:var(--card); box-shadow:0 0 0 1px var(--line); font-size:15px; }
221
+ .upd.bad { box-shadow:0 0 0 1px var(--accent); }
222
+ .setup { position:relative; display:flex; flex-direction:column; gap:2px; margin:0 0 12px; padding:12px 14px; border-radius:14px; background:var(--card); box-shadow:0 0 0 1px var(--line); font-size:15px; }
223
+ .setup > b { padding-right:64px; }
224
+ .setup .sp { display:flex; gap:10px; padding:8px 0; border-top:1px solid var(--line); }
225
+ .setup .sp:first-of-type { border-top:0; }
226
+ .setup .mk { flex:none; width:20px; text-align:center; color:var(--dim); }
227
+ .setup .sp.on .mk { color:var(--accent); }
228
+ .setup .sp.on b { font-weight:500; color:var(--dim); }
229
+ .setup .tx { display:flex; flex-direction:column; gap:4px; min-width:0; flex:1; }
230
+ .setup small { color:var(--dim); font-size:14px; line-height:1.5; }
231
+ .setup pre { margin:2px 0 0; padding:10px 12px; border-radius:10px; background:var(--paper); font:13px/1.6 ui-monospace,Menlo,Consolas,monospace; white-space:pre-wrap; overflow-wrap:anywhere; user-select:all; -webkit-user-select:all; }
232
+ .setup .gr { display:flex; flex-wrap:wrap; gap:6px; margin-top:2px; }
233
+ .setup .gr button { min-height:44px; padding:0 12px; border:0; border-radius:10px; background:var(--paper); box-shadow:0 0 0 1px var(--line); color:inherit; font-size:15px; cursor:pointer; }
234
+ .setup .hide { position:absolute; top:6px; right:6px; min-height:44px; padding:0 10px; border:0; background:transparent; color:var(--accent); font-size:15px; cursor:pointer; }
235
+ .upd ul { margin:0; padding-left:18px; font-size:14px; }
221
236
  .upd .sub { color:var(--dim); font-size:14px; padding-right:64px; }
222
237
  .upd pre { margin:2px 0 0; padding:10px 12px; border-radius:10px; background:var(--paper); font:13px/1.6 ui-monospace,Menlo,Consolas,monospace; white-space:pre-wrap; overflow-wrap:anywhere; user-select:all; -webkit-user-select:all; }
223
238
  .upd button { position:absolute; top:6px; right:6px; min-height:44px; padding:0 10px; border:0; background:transparent; color:var(--accent); font-size:15px; cursor:pointer; }
224
239
  </style></head><body>
225
240
  <div class="wrap" id="main">
226
241
  <div class="top"><span class="ttl"><span id="ct-mark" class="ct-draw ct-hover">${titleLogo(true)}</span><b id="title">家长</b></span><nav class="seg"><a class="on" href="/parent/home">首页</a><a href="/parent">记录</a><a href="/parent/settings">设置</a></nav><small id="pub"></small><a class="devl" href="/dev" target="_blank" title="工作台:细调老师文件、activity(新窗口)">工作台<svg width="11" height="11" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true"><path d="M4 2h6v6"/><path d="M10 2 3 9"/></svg></a></div>
242
+ <div id="troublebar"></div>
243
+ <div id="setupbar"></div>
227
244
  <div id="updatebar"></div>
228
245
  <div id="qrtop"></div>
229
246
  <div class="main">
@@ -297,7 +314,7 @@ ${pageDomJs(3200)}
297
314
  window.addEventListener('resize', fit);
298
315
 
299
316
  // ---- 百炼:老师开口靠它,没配孩子那边打不开。一块常驻的元素,重画只挪它的位置,输着的 key 不会被抹掉 ----
300
- const BL = { st: null, open: false, busy: false, msg: '' };
317
+ const BL = { st: null, open: false, opened: false, busy: false, msg: '' };
301
318
  const blEl = h('div', { id: 'bailian' });
302
319
  const blKey = h('input', { class: 'fld', id: 'bl-key', type: 'password', autocomplete: 'off', spellcheck: 'false', placeholder: 'sk- 开头' });
303
320
  const blWs = h('input', { class: 'fld', id: 'bl-ws', autocomplete: 'off', spellcheck: 'false', autocapitalize: 'off', placeholder: '如 llm-xxxxxxxx' });
@@ -371,21 +388,65 @@ ${pageDomJs(3200)}
371
388
 
372
389
  // 有新版本(服务每天问一次 npm,machine/update.ts):只在家长端说,孩子端不说;点「知道了」这个版本不再提,读不了 localStorage 就每次都说
373
390
  const UPGRADE_STEPS = ${JSON.stringify(upgradeSteps())};
391
+ // 还差几步(parent-setup.ts):第一次打开家长端,告诉家长接下来做什么。必需的都好了就只剩建议,「知道了」以后不再提
392
+ const showSetup = async () => {
393
+ const box = $('#setupbar');
394
+ const d = await api('GET', '/api/parent/setup').catch(() => null);
395
+ if (!d) { box.replaceChildren(); return; }
396
+ let hidden = false;
397
+ try { hidden = localStorage.getItem('cotutor.setupHidden') === '1'; } catch {}
398
+ const must = d.steps.filter((s) => s.required);
399
+ const left = must.filter((s) => !s.done);
400
+ const extra = d.steps.filter((s) => !s.required && !s.done);
401
+ if (!left.length && (hidden || !extra.length)) { box.replaceChildren(); return; }
402
+ const pickGrade = async (g) => {
403
+ try { await api('POST', '/api/parent/setup/grade', { grade: g }); toast('记下了:' + g + ' 年级'); } catch (e) { toast(e.message); }
404
+ showSetup();
405
+ };
406
+ const row = (s) => h('div', { class: 'sp' + (s.done ? ' on' : '') },
407
+ h('span', { class: 'mk' }, s.done ? '✓' : '○'),
408
+ h('span', { class: 'tx' }, h('b', {}, s.title),
409
+ s.done ? null : h('small', {}, s.detail, s.link ? ' ' : null, s.link ? h('a', { class: 'lnk', href: s.link.href, target: '_blank', rel: 'noopener' }, s.link.text) : null),
410
+ !s.done && s.commands ? h('pre', {}, s.commands.map((c) => h('div', {}, c))) : null,
411
+ !s.done && s.grades ? h('span', { class: 'gr' }, [1, 2, 3, 4, 5, 6].map((g) => h('button', { type: 'button', on: { click: () => pickGrade(g) } }, g + ' 年级'))) : null));
412
+ box.replaceChildren(h('div', { class: 'setup' },
413
+ h('b', {}, left.length ? '还差 ' + left.length + ' 步,孩子就能用了' : '都好了,还有几件可以做的'),
414
+ ...(left.length ? must.concat(extra) : extra).map(row),
415
+ left.length ? null : h('button', { type: 'button', class: 'hide', on: { click: () => { try { localStorage.setItem('cotutor.setupHidden', '1'); } catch {} box.replaceChildren(); } } }, '知道了')));
416
+ };
417
+ // 跑起来以后百炼出的问题(欠费、key 失效、连不上,machine/bailian.ts):配好了不等于用得了。试连通了就没了
418
+ const showTrouble = (hh) => {
419
+ const box = $('#troublebar');
420
+ const t = hh && hh.bailianTrouble;
421
+ if (!t) { box.replaceChildren(); return; }
422
+ box.replaceChildren(h('div', { class: 'upd bad' },
423
+ h('b', {}, t.message),
424
+ h('span', { class: 'sub' }, t.fix + (t.blocked ? ' 现在孩子那边显示「老师们还没到」。' : '')),
425
+ t.why ? h('small', { class: 'sub' }, '百炼说:' + t.why) : null,
426
+ h('button', { type: 'button', on: { click: async () => {
427
+ try { const r = await api('POST', '/api/parent/bailian/test'); toast(r.ok ? '通了' : '还是没通:' + r.message); } catch (e) { toast('没试成:' + e.message); }
428
+ load();
429
+ } } }, '试连')));
430
+ };
374
431
  const showUpdate = async () => {
375
432
  const box = $('#updatebar');
376
433
  const hh = await api('GET', '/api/health').catch(() => null);
434
+ showTrouble(hh);
377
435
  const latest = hh && hh.latest;
378
436
  let seen = null;
379
437
  try { seen = localStorage.getItem('cotutor.updateSeen'); } catch {}
380
438
  if (!latest || seen === latest) { box.replaceChildren(); return; }
381
439
  box.replaceChildren(h('div', { class: 'upd' },
382
440
  h('b', {}, '有新版本 ' + latest),
383
- h('span', { class: 'sub' }, '现在是 ' + hh.version + '。挑孩子不用的时候,在这台电脑的终端里依次运行:'),
441
+ h('span', { class: 'sub' }, '现在是 ' + hh.version + (hh.latestNotes && hh.latestNotes.length ? '。这次改了:' : '。')),
442
+ hh.latestNotes && hh.latestNotes.length ? h('ul', {}, hh.latestNotes.map((n) => h('li', {}, n))) : null,
443
+ h('span', { class: 'sub' }, '挑孩子不用的时候,在这台电脑的终端里运行(服务会重起,孩子在通话时不会动):'),
384
444
  h('pre', {}, UPGRADE_STEPS.map((s) => h('div', {}, s))),
385
445
  h('button', { type: 'button', on: { click: () => { try { localStorage.setItem('cotutor.updateSeen', latest); } catch {} box.replaceChildren(); } } }, '知道了')));
386
446
  };
387
447
  const load = async () => {
388
448
  showUpdate();
449
+ showSetup();
389
450
  try { D = await api('GET', '/api/parent/home'); } catch (e) { toast('读不到:' + e.message); return; }
390
451
  BL.st = await api('GET', '/api/parent/bailian').catch(() => null);
391
452
  QR.d = await api('GET', '/api/parent/qr').catch(() => null);
@@ -502,7 +563,10 @@ ${pageDomJs(3200)}
502
563
  : inlineForm(t, sel);
503
564
  blDraw();
504
565
  qrDraw();
505
- const blTop = BL.st && !BL.st.ready;
566
+ // 没配、key 或业务空间不对:要改的就是这一块,放最上面、展开,别让家长往下翻
567
+ const blFix = BL.st && BL.st.trouble && (BL.st.trouble.kind === 'key' || BL.st.trouble.kind === 'space');
568
+ if (blFix && BL.st.canEdit && !BL.opened) { BL.opened = true; BL.open = true; }
569
+ const blTop = BL.st && (!BL.st.ready || blFix);
506
570
  if (wide) $('#qrtop').replaceChildren(); else $('#qrtop').replaceChildren(qrEl);
507
571
  $('#body').replaceChildren(h('div', {}, blTop ? blEl : null, wide ? qrEl : null, pane, extras, self, blTop ? null : blEl));
508
572
  };