cotutor 0.4.0 → 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 +39 -8
- package/dist/app/cli/cert.d.ts +6 -0
- package/dist/app/cli/cert.js +16 -2
- package/dist/app/cli/doctor.d.ts +5 -0
- package/dist/app/cli/doctor.js +65 -17
- package/dist/app/cli/init.js +1 -1
- package/dist/app/cli/main.js +58 -17
- package/dist/app/cli/serve.d.ts +0 -1
- package/dist/app/cli/serve.js +5 -0
- package/dist/app/cli/service.d.ts +40 -0
- package/dist/app/cli/service.js +50 -5
- package/dist/app/cli/tray-run.js +11 -2
- package/dist/app/cli/tray.d.ts +35 -4
- package/dist/app/cli/tray.js +67 -13
- 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 +4 -3
- package/dist/app/http/app.d.ts +2 -0
- package/dist/app/http/app.js +19 -2
- package/dist/app/machine/bailian.d.ts +26 -0
- package/dist/app/machine/bailian.js +91 -4
- package/dist/app/machine/update.d.ts +38 -0
- package/dist/app/machine/update.js +109 -0
- 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 +9 -0
- package/dist/shared/upgrade.js +23 -0
- package/dist/ui/kid/kid-page.js +8 -2
- package/dist/ui/parent/parent-home-page.js +47 -2
- package/package.json +11 -2
- package/skills/cotutor-analyze/references//345/221/275/344/273/244/344/270/216/346/226/207/344/273/266.md +1 -1
- package/skills/cotutor-tune/references//345/255/227/346/256/265.md +1 -1
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 有没有新版本:服务每天问一次 npm 的 latest,结果存机器级 ~/.config/cotutor/update.json(几个孩子的服务共用,一天只问一次,重起不重问)。
|
|
3
|
+
* /api/health 带上 latest(比在跑的新才有),托盘、家长端、service status 都从它拿;doctor 只读这份文件,不上网。
|
|
4
|
+
* 更新说明写在 package.json 的 releaseNotes,跟着 npm 一起下来(npm、npmmirror 都留自定义字段,2026-11-07 查过),国内连不上 GitHub 也看得到。
|
|
5
|
+
* 只发一个普通的 GET,不带孩子或使用的任何信息;问不到(断网、被墙)不报错,下个小时再问。先问 npm,不通再问 npmmirror(中国家庭常连不上 npm)。
|
|
6
|
+
* latest 这个标签要等 npm 扫描通过才变(2026-07 起发布后先扫描),不会提前提醒。
|
|
7
|
+
* 不自动升级:升级要重起服务,孩子可能正在上课;老师文件的更新也要家长知道。
|
|
8
|
+
* 不想让它问:update.json 写 {"off": true}。从仓库跑的(BUILD.commit 有值)不问,见 main.ts。
|
|
9
|
+
*/
|
|
10
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
11
|
+
import { homedir } from 'node:os';
|
|
12
|
+
import { dirname, join } from 'node:path';
|
|
13
|
+
import { isPlainVersion } from "../../shared/upgrade.js";
|
|
14
|
+
export const UPDATE_URLS = ['https://registry.npmjs.org/cotutor/latest', 'https://registry.npmmirror.com/cotutor/latest'];
|
|
15
|
+
const DAY_MS = 24 * 3600_000;
|
|
16
|
+
const HOUR_MS = 3600_000;
|
|
17
|
+
const FIRST_MS = 60_000;
|
|
18
|
+
const TIMEOUT_MS = 8000;
|
|
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
|
+
}
|
|
26
|
+
/** 读不到、写坏了 → 空的,当作没问过 */
|
|
27
|
+
export async function readUpdateRecord(file = updateFile()) {
|
|
28
|
+
try {
|
|
29
|
+
const j = JSON.parse(await readFile(file, 'utf8'));
|
|
30
|
+
return {
|
|
31
|
+
...(typeof j.checkedAt === 'string' ? { checkedAt: j.checkedAt } : {}),
|
|
32
|
+
...(isPlainVersion(j.latest) ? { latest: j.latest, notes: cleanNotes(j.notes) } : {}),
|
|
33
|
+
...(j.off === true ? { off: true } : {}),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return {};
|
|
38
|
+
}
|
|
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
|
+
}
|
|
45
|
+
/** 依次问,谁先答谁算;都问不到 → null */
|
|
46
|
+
export async function fetchRelease(fetchFn = fetch, urls = UPDATE_URLS) {
|
|
47
|
+
for (const [i, url] of urls.entries()) {
|
|
48
|
+
try {
|
|
49
|
+
const r = await fetchFn(url, { signal: AbortSignal.timeout(TIMEOUT_MS), headers: { accept: 'application/json' } });
|
|
50
|
+
if (!r.ok)
|
|
51
|
+
continue;
|
|
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 };
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
/* 这家不通,问下一家 */
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
let known = null;
|
|
63
|
+
let knownNoteLines = [];
|
|
64
|
+
/** 这个进程现在知道的最新版本;还不知道、关掉了 → null */
|
|
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
|
+
};
|
|
73
|
+
/** 文件里的还新鲜就用它,否则问一次并存下;问不到就继续用上次的 */
|
|
74
|
+
export async function refreshLatest(o = {}) {
|
|
75
|
+
const file = o.file ?? updateFile();
|
|
76
|
+
const now = o.now ?? Date.now();
|
|
77
|
+
const rec = await readUpdateRecord(file);
|
|
78
|
+
if (rec.off)
|
|
79
|
+
return remember(null);
|
|
80
|
+
const at = rec.checkedAt ? Date.parse(rec.checkedAt) : Number.NaN;
|
|
81
|
+
// 时间在将来(改过系统时间)算不新鲜
|
|
82
|
+
if (rec.latest && now >= at && now - at < DAY_MS)
|
|
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);
|
|
89
|
+
try {
|
|
90
|
+
await mkdir(dirname(file), { recursive: true });
|
|
91
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
92
|
+
await writeFile(tmp, `${JSON.stringify({ checkedAt: new Date(now).toISOString(), latest, notes }, null, 2)}\n`);
|
|
93
|
+
await rename(tmp, file);
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
/* 写不了:下个小时再问一次,不影响别的 */
|
|
97
|
+
}
|
|
98
|
+
return latest;
|
|
99
|
+
}
|
|
100
|
+
/** serve 起来时调:先用存着的那份,一分钟后问(刚开机网可能还没好),之后每小时看一眼,过了一天才真的问。定时器不拦进程退出 */
|
|
101
|
+
export function startUpdateChecks(o = {}) {
|
|
102
|
+
void readUpdateRecord(o.file).then((rec) => { remember(rec.off ? null : rec.latest ?? null, rec.notes); });
|
|
103
|
+
const run = () => { void refreshLatest(o).catch(() => { }); };
|
|
104
|
+
const first = setTimeout(run, o.firstMs ?? FIRST_MS);
|
|
105
|
+
first.unref();
|
|
106
|
+
const every = setInterval(run, o.everyMs ?? HOUR_MS);
|
|
107
|
+
every.unref();
|
|
108
|
+
return () => { clearTimeout(first); clearInterval(every); };
|
|
109
|
+
}
|
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
|
/** 换上一条嘴的会话:一直听的状态都从头来 */
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** 版本比较与升级步骤:服务、托盘(轻入口,不带 zod)、家长端、doctor 共用 */
|
|
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
|
+
export declare function upgradeSteps(platform?: NodeJS.Platform): string[];
|
|
6
|
+
/** 正式版的版本号。预发布不算;从网上拿来的还要显示给家长,其余一律不认 */
|
|
7
|
+
export declare function isPlainVersion(v: unknown): v is string;
|
|
8
|
+
/** a 比 b 新;任一边不是正式版的版本号 → false,宁可不提醒 */
|
|
9
|
+
export declare function isNewer(a: unknown, b: unknown): boolean;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** 版本比较与升级步骤:服务、托盘(轻入口,不带 zod)、家长端、doctor 共用 */
|
|
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
|
+
export function upgradeSteps(platform = process.platform) {
|
|
6
|
+
return UPGRADE_STEPS.map((s) => (platform === 'win32' ? s.replace(' ', '.cmd ') : s));
|
|
7
|
+
}
|
|
8
|
+
const PLAIN = /^(\d+)\.(\d+)\.(\d+)$/;
|
|
9
|
+
/** 正式版的版本号。预发布不算;从网上拿来的还要显示给家长,其余一律不认 */
|
|
10
|
+
export function isPlainVersion(v) {
|
|
11
|
+
return typeof v === 'string' && v.length <= 32 && PLAIN.test(v);
|
|
12
|
+
}
|
|
13
|
+
/** a 比 b 新;任一边不是正式版的版本号 → false,宁可不提醒 */
|
|
14
|
+
export function isNewer(a, b) {
|
|
15
|
+
if (!isPlainVersion(a) || !isPlainVersion(b))
|
|
16
|
+
return false;
|
|
17
|
+
const x = a.split('.').map(Number);
|
|
18
|
+
const y = b.split('.').map(Number);
|
|
19
|
+
for (let i = 0; i < 3; i++)
|
|
20
|
+
if (x[i] !== y[i])
|
|
21
|
+
return x[i] > y[i];
|
|
22
|
+
return false;
|
|
23
|
+
}
|
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;
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import { faviconLinks } from "../shared/favicon.js";
|
|
11
11
|
import { BRAND_CSS, BRAND_FOOTER, BRAND_JS, titleLogo } from "../shared/brand.js";
|
|
12
12
|
import { pageDomJs } from "../shared/page-dom.js";
|
|
13
|
+
import { upgradeSteps } from "../../shared/upgrade.js";
|
|
13
14
|
export const PARENT_HOME_PAGE = String.raw `<!doctype html>
|
|
14
15
|
<html lang="zh-CN"><head><meta charset="utf-8">
|
|
15
16
|
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
|
|
@@ -216,9 +217,17 @@ ${BRAND_CSS}
|
|
|
216
217
|
.main { display:grid; grid-template-columns:minmax(0,1fr) 380px; gap:22px; align-items:start; }
|
|
217
218
|
.gh { padding:0 6px 6px; }
|
|
218
219
|
}
|
|
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; }
|
|
223
|
+
.upd .sub { color:var(--dim); font-size:14px; padding-right:64px; }
|
|
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; }
|
|
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; }
|
|
219
226
|
</style></head><body>
|
|
220
227
|
<div class="wrap" id="main">
|
|
221
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>
|
|
230
|
+
<div id="updatebar"></div>
|
|
222
231
|
<div id="qrtop"></div>
|
|
223
232
|
<div class="main">
|
|
224
233
|
<section id="pv" aria-label="孩子看到的首页">
|
|
@@ -291,7 +300,7 @@ ${pageDomJs(3200)}
|
|
|
291
300
|
window.addEventListener('resize', fit);
|
|
292
301
|
|
|
293
302
|
// ---- 百炼:老师开口靠它,没配孩子那边打不开。一块常驻的元素,重画只挪它的位置,输着的 key 不会被抹掉 ----
|
|
294
|
-
const BL = { st: null, open: false, busy: false, msg: '' };
|
|
303
|
+
const BL = { st: null, open: false, opened: false, busy: false, msg: '' };
|
|
295
304
|
const blEl = h('div', { id: 'bailian' });
|
|
296
305
|
const blKey = h('input', { class: 'fld', id: 'bl-key', type: 'password', autocomplete: 'off', spellcheck: 'false', placeholder: 'sk- 开头' });
|
|
297
306
|
const blWs = h('input', { class: 'fld', id: 'bl-ws', autocomplete: 'off', spellcheck: 'false', autocapitalize: 'off', placeholder: '如 llm-xxxxxxxx' });
|
|
@@ -363,7 +372,40 @@ ${pageDomJs(3200)}
|
|
|
363
372
|
h('div', { class: 'note' }, d.https ? (d.notes[0] || '') : '', d.devices ? '' : (d.https ? ' ' : '') + '还没有平板连进来过,所以这块先开着;连过以后自动收起。'));
|
|
364
373
|
};
|
|
365
374
|
|
|
375
|
+
// 有新版本(服务每天问一次 npm,machine/update.ts):只在家长端说,孩子端不说;点「知道了」这个版本不再提,读不了 localStorage 就每次都说
|
|
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
|
+
};
|
|
391
|
+
const showUpdate = async () => {
|
|
392
|
+
const box = $('#updatebar');
|
|
393
|
+
const hh = await api('GET', '/api/health').catch(() => null);
|
|
394
|
+
showTrouble(hh);
|
|
395
|
+
const latest = hh && hh.latest;
|
|
396
|
+
let seen = null;
|
|
397
|
+
try { seen = localStorage.getItem('cotutor.updateSeen'); } catch {}
|
|
398
|
+
if (!latest || seen === latest) { box.replaceChildren(); return; }
|
|
399
|
+
box.replaceChildren(h('div', { class: 'upd' },
|
|
400
|
+
h('b', {}, '有新版本 ' + latest),
|
|
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' }, '挑孩子不用的时候,在这台电脑的终端里运行(服务会重起,孩子在通话时不会动):'),
|
|
404
|
+
h('pre', {}, UPGRADE_STEPS.map((s) => h('div', {}, s))),
|
|
405
|
+
h('button', { type: 'button', on: { click: () => { try { localStorage.setItem('cotutor.updateSeen', latest); } catch {} box.replaceChildren(); } } }, '知道了')));
|
|
406
|
+
};
|
|
366
407
|
const load = async () => {
|
|
408
|
+
showUpdate();
|
|
367
409
|
try { D = await api('GET', '/api/parent/home'); } catch (e) { toast('读不到:' + e.message); return; }
|
|
368
410
|
BL.st = await api('GET', '/api/parent/bailian').catch(() => null);
|
|
369
411
|
QR.d = await api('GET', '/api/parent/qr').catch(() => null);
|
|
@@ -480,7 +522,10 @@ ${pageDomJs(3200)}
|
|
|
480
522
|
: inlineForm(t, sel);
|
|
481
523
|
blDraw();
|
|
482
524
|
qrDraw();
|
|
483
|
-
|
|
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);
|
|
484
529
|
if (wide) $('#qrtop').replaceChildren(); else $('#qrtop').replaceChildren(qrEl);
|
|
485
530
|
$('#body').replaceChildren(h('div', {}, blTop ? blEl : null, wide ? qrEl : null, pane, extras, self, blTop ? null : blEl));
|
|
486
531
|
};
|
package/package.json
CHANGED
|
@@ -1,10 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cotutor",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.2",
|
|
4
|
+
"releaseNotes": [
|
|
5
|
+
"百炼欠费、key 失效或连不上时,图标和家长端会说清楚,孩子那边会请爸爸妈妈来看,不再一直转",
|
|
6
|
+
"升级只要一条命令 cotutor update,几个孩子一次升好,孩子在通话时不动",
|
|
7
|
+
"用 Homebrew 装 Node 的电脑:Node 升级后服务和图标不会再起不来",
|
|
8
|
+
"有新版本时能直接看到这次改了什么"
|
|
9
|
+
],
|
|
4
10
|
"description": "AI tutors that co-teach with parents at home: real-time voice, an interactive whiteboard, and learning memory in your own Obsidian vault. Self-hosted; built for China's elementary curriculum.",
|
|
5
11
|
"type": "module",
|
|
6
12
|
"license": "SEE LICENSE IN LICENSE",
|
|
7
|
-
"repository":
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/TarsLab/cotutor.git"
|
|
16
|
+
},
|
|
8
17
|
"homepage": "https://github.com/TarsLab/cotutor#readme",
|
|
9
18
|
"bugs": "https://github.com/TarsLab/cotutor/issues",
|
|
10
19
|
"keywords": [
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
## 分析用的命令(在 workspace 根跑;完整用法 cotutor --help)
|
|
4
4
|
|
|
5
5
|
```
|
|
6
|
-
cotutor doctor [--workspace <dir>] [--json] [--live] 逐项体检;--live 真连一次百炼实时模型、真合成一句配音(花一分钱)把 API 层的坑摆出来(真跑老师用 cotutor send)
|
|
6
|
+
cotutor doctor [--workspace <dir>] [--json] [--live] 逐项体检;--live 真连一次百炼实时模型、真合成一句配音(花一分钱)把 API 层的坑摆出来(真跑老师用 cotutor send);--json 里不过的项带 cmd(能直接跑)或 human(要人来)
|
|
7
7
|
cotutor send <老师> [<消息>] [--from kid|system] [--runtime <名>] [--new] [--photo a.jpg,b.png] [--activity <名> [--param '名=值;名=值']] [--lane main,tts] [--quiet] 终端里发一条,现场按道打印每道工序的事件,说完打印结果(孩子的话照实时写,同孩子端:上下文包带 live:、不补提问卡,只是没有嘴;--from system 不算;--new 开新话题;--photo 带作业照片,拷进 captures/,有照片时消息可以空;--activity 像孩子点了首页的 activity 按钮,新开话题,参数同按钮下面那几行;--quiet 只要结果)
|
|
8
8
|
cotutor pack <老师> [<消息>] [--from kid|system] [--at <ISO时间>] [--activity <名> [--param '名=值;名=值']] [--json] 干跑上下文包(--activity 看 activity 话题第一条老师拿到什么):不起模型,打印现在会发给老师的那份 + 每段来自哪个文件、那里一共几条、按政策带了几条(改了档案 / 日记 / 计划立刻看效果)
|
|
9
9
|
cotutor replay <老师> <job> [<日期>] [--runtime <名>] [--json] 回放一轮:同一问按现在的 vault 与提示词再跑一遍(落 evals/,不进孩子的对话、不配音),跑完并排打印上下文包 / 讲稿 / 卡 / 读了什么的 diff
|
|
@@ -41,7 +41,7 @@ frontmatter 只写应用支持的这几个键:`name`、`description`、`maxTurns
|
|
|
41
41
|
## 相关命令(完整用法 cotutor --help)
|
|
42
42
|
|
|
43
43
|
```
|
|
44
|
-
cotutor doctor [--workspace <dir>] [--json] [--live] 逐项体检;--live 真连一次百炼实时模型、真合成一句配音(花一分钱)把 API 层的坑摆出来(真跑老师用 cotutor send)
|
|
44
|
+
cotutor doctor [--workspace <dir>] [--json] [--live] 逐项体检;--live 真连一次百炼实时模型、真合成一句配音(花一分钱)把 API 层的坑摆出来(真跑老师用 cotutor send);--json 里不过的项带 cmd(能直接跑)或 human(要人来)
|
|
45
45
|
cotutor upgrade [--workspace <dir>] [--force <老师>]... 老师文件、activity 与 skill 换新版:没改过的直接换,改过的只报 diff(--force <老师> 或 --force <老师>/<名> 才覆盖,原文留 .bak)
|
|
46
46
|
cotutor upgrade --config [--dry-run] [--workspace <dir>] cotutor.json 补缺:新出厂老师 / 运行时 / 命令模板旗标(只加缺的,你改过的值不动)
|
|
47
47
|
cotutor add <老师名> --display <显示名> [--subject <学科>] [--avatar <emoji|图片相对路径>] [--hidden] 加一位自家的老师:出模板文件、进 cotutor.json、建目录(名字以 -tutor 结尾的是老师,figshot 给它做头像)
|