cotutor 0.4.1 → 0.4.2
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/README.md +12 -5
- package/dist/app/cli/doctor.js +11 -2
- package/dist/app/cli/main.js +14 -2
- package/dist/app/cli/serve.d.ts +0 -1
- package/dist/app/cli/serve.js +5 -0
- package/dist/app/cli/service.d.ts +26 -0
- package/dist/app/cli/service.js +36 -5
- package/dist/app/cli/tray.js +6 -4
- package/dist/app/cli/update-cmd.d.ts +38 -0
- package/dist/app/cli/update-cmd.js +174 -0
- package/dist/app/cli/usage.d.ts +1 -1
- package/dist/app/cli/usage.js +1 -0
- package/dist/app/http/app.d.ts +2 -0
- package/dist/app/http/app.js +18 -3
- package/dist/app/machine/bailian.d.ts +26 -0
- package/dist/app/machine/bailian.js +91 -4
- package/dist/app/machine/update.d.ts +14 -1
- package/dist/app/machine/update.js +35 -14
- package/dist/brain/api.js +6 -3
- package/dist/realtime/preview.d.ts +16 -0
- package/dist/realtime/preview.js +43 -1
- package/dist/runtime/live/call.d.ts +2 -0
- package/dist/runtime/live/call.js +6 -1
- package/dist/shared/upgrade.d.ts +3 -3
- package/dist/shared/upgrade.js +3 -3
- package/dist/ui/kid/kid-page.js +8 -2
- package/dist/ui/parent/parent-home-page.js +26 -3
- package/package.json +7 -1
package/dist/app/http/app.js
CHANGED
|
@@ -33,7 +33,7 @@ import { factoryAvatarFile, shownAvatar } from "./avatars.js";
|
|
|
33
33
|
import { deviceCookie, newDeviceId, parseKidLog, readDevices, recordKidLog, renameDevice, validDevice } from "../../runtime/journal/kid-log.js";
|
|
34
34
|
import { DEVICE_COOKIE } from "../../schema/kid-device.js";
|
|
35
35
|
import { BUILD, codeChanged, headNow, shortCommit } from "../build.js";
|
|
36
|
-
import { knownLatest } from "../machine/update.js";
|
|
36
|
+
import { knownLatest, knownNotes } from "../machine/update.js";
|
|
37
37
|
import { isNewer } from "../../shared/upgrade.js";
|
|
38
38
|
import { qrPage } from "../../ui/parent/qr-page.js";
|
|
39
39
|
import { kidPage } from "../../ui/kid/kid-page.js";
|
|
@@ -82,6 +82,12 @@ export function createContext(ws, opts = {}) {
|
|
|
82
82
|
openCalls: null,
|
|
83
83
|
access: opts.access ?? createAccess({ now: () => ctx.now() }),
|
|
84
84
|
bailian: opts.bailian ?? createBailian({ env: opts.env }),
|
|
85
|
+
async mouthHealth(why, midCall) {
|
|
86
|
+
if (why === null)
|
|
87
|
+
ctx.bailian.fine('mouth');
|
|
88
|
+
else
|
|
89
|
+
await ctx.bailian.note(midCall ? 'call' : 'mouth', why);
|
|
90
|
+
},
|
|
85
91
|
async reload() {
|
|
86
92
|
try {
|
|
87
93
|
const r = await reloadIfChanged(ctx.ws, configText);
|
|
@@ -96,6 +102,15 @@ export function createContext(ws, opts = {}) {
|
|
|
96
102
|
}
|
|
97
103
|
},
|
|
98
104
|
};
|
|
105
|
+
// 脑的结果:请求失败(欠费、key 失效)记下给家长看;成功了就算好了。不是请求的错(比如卡住)不算百炼的事
|
|
106
|
+
ctx.runner.onEvent((e) => {
|
|
107
|
+
if (e.event.lane !== 'main' || e.event.kind !== 'exit')
|
|
108
|
+
return;
|
|
109
|
+
if (e.event.ok)
|
|
110
|
+
ctx.bailian.fine('brain');
|
|
111
|
+
else if (e.event.reason?.startsWith('api:'))
|
|
112
|
+
void ctx.bailian.note('brain', e.event.reason);
|
|
113
|
+
});
|
|
99
114
|
return ctx;
|
|
100
115
|
}
|
|
101
116
|
const esc = (s) => s.replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c] ?? c);
|
|
@@ -129,7 +144,7 @@ export async function kidHome(ctx, now) {
|
|
|
129
144
|
const date = localDate(now);
|
|
130
145
|
const view = await kidHomeView(ws, now);
|
|
131
146
|
const figshot = ws.config.figshot ? { port: ws.config.figshot.port } : null;
|
|
132
|
-
return { title: ws.config.title, date, tutors: await kidTutors(ctx), home: view.home, cards: view.cards, figshot, hand: ws.config.kid.hand ?? 'right', ready: ctx.bailian.status().ready };
|
|
147
|
+
return { title: ws.config.title, date, tutors: await kidTutors(ctx), home: view.home, cards: view.cards, figshot, hand: ws.config.kid.hand ?? 'right', ready: ctx.bailian.status().ready && !ctx.bailian.blocked() };
|
|
133
148
|
}
|
|
134
149
|
export async function kidHistory(ctx, tutor, days) {
|
|
135
150
|
const today = localDate(ctx.now());
|
|
@@ -463,7 +478,7 @@ export async function route(method, path, ctx, body, who = 'local', client = {})
|
|
|
463
478
|
if (p === '/api/health') {
|
|
464
479
|
const head = await headNow();
|
|
465
480
|
const vault = await readdir(ws.paths.vault).then(() => 'ok', (err) => (err.code === 'EPERM' || err.code === 'EACCES' ? 'denied' : 'missing'));
|
|
466
|
-
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, latest: isNewer(knownLatest(), BUILD.version) ? knownLatest() : null } };
|
|
481
|
+
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, bailianTrouble: ctx.bailian.trouble(), latest: isNewer(knownLatest(), BUILD.version) ? knownLatest() : null, latestNotes: isNewer(knownLatest(), BUILD.version) ? knownNotes() : [] } };
|
|
467
482
|
}
|
|
468
483
|
// 家长端与工作台的锁(access.ts):对 PIN 换 cookie;设了 PIN、没凭证的设备,孩子端以外的页面回输 PIN 的页、接口回 401。没设 PIN 什么都不拦
|
|
469
484
|
if (p === '/api/auth') {
|
|
@@ -11,15 +11,41 @@ export interface BailianStatus {
|
|
|
11
11
|
keyTail: string | null;
|
|
12
12
|
workspaceId: string | null;
|
|
13
13
|
}
|
|
14
|
+
export type TroubleKind = 'key' | 'money' | 'space' | 'network' | 'other';
|
|
15
|
+
/** 谁报的:嘴连不上、通话中百炼报错、脑的请求失败 */
|
|
16
|
+
export type TroubleSource = 'mouth' | 'call' | 'brain';
|
|
17
|
+
export interface BailianTrouble {
|
|
18
|
+
kind: TroubleKind;
|
|
19
|
+
message: string;
|
|
20
|
+
fix: string;
|
|
21
|
+
/** 百炼的原话(错误码),排查用 */
|
|
22
|
+
why: string;
|
|
23
|
+
/** 孩子端要显示「叫爸爸妈妈」 */
|
|
24
|
+
blocked: boolean;
|
|
25
|
+
}
|
|
26
|
+
/** 按错误码分类;官方表里没有的算 other */
|
|
27
|
+
export declare function troubleKind(why: string): TroubleKind;
|
|
14
28
|
export interface Bailian {
|
|
15
29
|
status(): BailianStatus;
|
|
16
30
|
/** 试连;不给 auth 就用现在那份。null = 通 */
|
|
17
31
|
probe(auth?: BailianAuth): Promise<string | null>;
|
|
18
32
|
save(auth: BailianAuth): Promise<void>;
|
|
33
|
+
/** 跑起来以后的故障;没有 → null */
|
|
34
|
+
trouble(): BailianTrouble | null;
|
|
35
|
+
/** 孩子端该灰着:嘴连不上,而且要家长动手 */
|
|
36
|
+
blocked(): boolean;
|
|
37
|
+
/** 出错了:记一笔;「连不上」这种说不清的先问清原因 */
|
|
38
|
+
note(source: TroubleSource, why: string): Promise<void>;
|
|
39
|
+
/** 好了;不给 source 就全清(换了 key、试连通了) */
|
|
40
|
+
fine(source?: TroubleSource): void;
|
|
41
|
+
/** 嘴连不上时隔一阵再试一次,好了就清(充上钱、网回来了,没人点也会恢复) */
|
|
42
|
+
recheck(): Promise<void>;
|
|
19
43
|
}
|
|
20
44
|
export declare function createBailian(opts?: {
|
|
21
45
|
env?: NodeJS.ProcessEnv;
|
|
22
46
|
probe?: (auth?: BailianAuth) => Promise<string | null>;
|
|
47
|
+
ask?: () => Promise<string | null>;
|
|
48
|
+
now?: () => number;
|
|
23
49
|
}): Bailian;
|
|
24
50
|
/** 试连没通的原因,写给家长看 */
|
|
25
51
|
export declare function probeMessage(why: string): string;
|
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
41
|
-
if (isPlainVersion(
|
|
42
|
-
return
|
|
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 (
|
|
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 (
|
|
64
|
-
const
|
|
65
|
-
if (!
|
|
66
|
-
return (
|
|
67
|
-
|
|
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) => {
|
|
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
|
-
/**
|
|
79
|
+
/** 报错只说状态码和错误码(欠费、key 失效靠它分):SDK 的异常里带着整个请求,别的都不要 */
|
|
80
80
|
function failure(err) {
|
|
81
|
-
const
|
|
82
|
-
|
|
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));
|
|
@@ -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>;
|
package/dist/realtime/preview.js
CHANGED
|
@@ -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
|
/** 换上一条嘴的会话:一直听的状态都从头来 */
|
package/dist/shared/upgrade.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** 版本比较与升级步骤:服务、托盘(轻入口,不带 zod)、家长端、doctor 共用 */
|
|
2
|
-
/**
|
|
3
|
-
export declare const UPGRADE_STEPS: readonly ["
|
|
4
|
-
/** 给家长看的那份。Windows 上带 .cmd:PowerShell 缺省禁止跑脚本,不带后缀找到的是被拦的 .ps1
|
|
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
6
|
/** 正式版的版本号。预发布不算;从网上拿来的还要显示给家长,其余一律不认 */
|
|
7
7
|
export declare function isPlainVersion(v: unknown): v is string;
|
package/dist/shared/upgrade.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** 版本比较与升级步骤:服务、托盘(轻入口,不带 zod)、家长端、doctor 共用 */
|
|
2
|
-
/**
|
|
3
|
-
export const UPGRADE_STEPS = ['
|
|
4
|
-
/** 给家长看的那份。Windows 上带 .cmd:PowerShell 缺省禁止跑脚本,不带后缀找到的是被拦的 .ps1
|
|
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
6
|
return UPGRADE_STEPS.map((s) => (platform === 'win32' ? s.replace(' ', '.cmd ') : s));
|
|
7
7
|
}
|
package/dist/ui/kid/kid-page.js
CHANGED
|
@@ -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':
|
|
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,15 @@ ${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
|
+
.upd ul { margin:0; padding-left:18px; font-size:14px; }
|
|
221
223
|
.upd .sub { color:var(--dim); font-size:14px; padding-right:64px; }
|
|
222
224
|
.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
225
|
.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
226
|
</style></head><body>
|
|
225
227
|
<div class="wrap" id="main">
|
|
226
228
|
<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>
|
|
229
|
+
<div id="troublebar"></div>
|
|
227
230
|
<div id="updatebar"></div>
|
|
228
231
|
<div id="qrtop"></div>
|
|
229
232
|
<div class="main">
|
|
@@ -297,7 +300,7 @@ ${pageDomJs(3200)}
|
|
|
297
300
|
window.addEventListener('resize', fit);
|
|
298
301
|
|
|
299
302
|
// ---- 百炼:老师开口靠它,没配孩子那边打不开。一块常驻的元素,重画只挪它的位置,输着的 key 不会被抹掉 ----
|
|
300
|
-
const BL = { st: null, open: false, busy: false, msg: '' };
|
|
303
|
+
const BL = { st: null, open: false, opened: false, busy: false, msg: '' };
|
|
301
304
|
const blEl = h('div', { id: 'bailian' });
|
|
302
305
|
const blKey = h('input', { class: 'fld', id: 'bl-key', type: 'password', autocomplete: 'off', spellcheck: 'false', placeholder: 'sk- 开头' });
|
|
303
306
|
const blWs = h('input', { class: 'fld', id: 'bl-ws', autocomplete: 'off', spellcheck: 'false', autocapitalize: 'off', placeholder: '如 llm-xxxxxxxx' });
|
|
@@ -371,16 +374,33 @@ ${pageDomJs(3200)}
|
|
|
371
374
|
|
|
372
375
|
// 有新版本(服务每天问一次 npm,machine/update.ts):只在家长端说,孩子端不说;点「知道了」这个版本不再提,读不了 localStorage 就每次都说
|
|
373
376
|
const UPGRADE_STEPS = ${JSON.stringify(upgradeSteps())};
|
|
377
|
+
// 跑起来以后百炼出的问题(欠费、key 失效、连不上,machine/bailian.ts):配好了不等于用得了。试连通了就没了
|
|
378
|
+
const showTrouble = (hh) => {
|
|
379
|
+
const box = $('#troublebar');
|
|
380
|
+
const t = hh && hh.bailianTrouble;
|
|
381
|
+
if (!t) { box.replaceChildren(); return; }
|
|
382
|
+
box.replaceChildren(h('div', { class: 'upd bad' },
|
|
383
|
+
h('b', {}, t.message),
|
|
384
|
+
h('span', { class: 'sub' }, t.fix + (t.blocked ? ' 现在孩子那边显示「老师们还没到」。' : '')),
|
|
385
|
+
t.why ? h('small', { class: 'sub' }, '百炼说:' + t.why) : null,
|
|
386
|
+
h('button', { type: 'button', on: { click: async () => {
|
|
387
|
+
try { const r = await api('POST', '/api/parent/bailian/test'); toast(r.ok ? '通了' : '还是没通:' + r.message); } catch (e) { toast('没试成:' + e.message); }
|
|
388
|
+
load();
|
|
389
|
+
} } }, '试连')));
|
|
390
|
+
};
|
|
374
391
|
const showUpdate = async () => {
|
|
375
392
|
const box = $('#updatebar');
|
|
376
393
|
const hh = await api('GET', '/api/health').catch(() => null);
|
|
394
|
+
showTrouble(hh);
|
|
377
395
|
const latest = hh && hh.latest;
|
|
378
396
|
let seen = null;
|
|
379
397
|
try { seen = localStorage.getItem('cotutor.updateSeen'); } catch {}
|
|
380
398
|
if (!latest || seen === latest) { box.replaceChildren(); return; }
|
|
381
399
|
box.replaceChildren(h('div', { class: 'upd' },
|
|
382
400
|
h('b', {}, '有新版本 ' + latest),
|
|
383
|
-
h('span', { class: 'sub' }, '现在是 ' + hh.version + '
|
|
401
|
+
h('span', { class: 'sub' }, '现在是 ' + hh.version + (hh.latestNotes && hh.latestNotes.length ? '。这次改了:' : '。')),
|
|
402
|
+
hh.latestNotes && hh.latestNotes.length ? h('ul', {}, hh.latestNotes.map((n) => h('li', {}, n))) : null,
|
|
403
|
+
h('span', { class: 'sub' }, '挑孩子不用的时候,在这台电脑的终端里运行(服务会重起,孩子在通话时不会动):'),
|
|
384
404
|
h('pre', {}, UPGRADE_STEPS.map((s) => h('div', {}, s))),
|
|
385
405
|
h('button', { type: 'button', on: { click: () => { try { localStorage.setItem('cotutor.updateSeen', latest); } catch {} box.replaceChildren(); } } }, '知道了')));
|
|
386
406
|
};
|
|
@@ -502,7 +522,10 @@ ${pageDomJs(3200)}
|
|
|
502
522
|
: inlineForm(t, sel);
|
|
503
523
|
blDraw();
|
|
504
524
|
qrDraw();
|
|
505
|
-
|
|
525
|
+
// 没配、key 或业务空间不对:要改的就是这一块,放最上面、展开,别让家长往下翻
|
|
526
|
+
const blFix = BL.st && BL.st.trouble && (BL.st.trouble.kind === 'key' || BL.st.trouble.kind === 'space');
|
|
527
|
+
if (blFix && BL.st.canEdit && !BL.opened) { BL.opened = true; BL.open = true; }
|
|
528
|
+
const blTop = BL.st && (!BL.st.ready || blFix);
|
|
506
529
|
if (wide) $('#qrtop').replaceChildren(); else $('#qrtop').replaceChildren(qrEl);
|
|
507
530
|
$('#body').replaceChildren(h('div', {}, blTop ? blEl : null, wide ? qrEl : null, pane, extras, self, blTop ? null : blEl));
|
|
508
531
|
};
|