cotutor 0.4.0 → 0.4.1
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 +32 -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 +55 -16
- package/dist/app/cli/init.js +1 -1
- package/dist/app/cli/main.js +45 -16
- package/dist/app/cli/service.d.ts +14 -0
- package/dist/app/cli/service.js +14 -0
- package/dist/app/cli/tray-run.js +11 -2
- package/dist/app/cli/tray.d.ts +35 -4
- package/dist/app/cli/tray.js +61 -9
- package/dist/app/cli/usage.d.ts +1 -1
- package/dist/app/cli/usage.js +3 -3
- package/dist/app/http/app.js +3 -1
- package/dist/app/machine/update.d.ts +25 -0
- package/dist/app/machine/update.js +88 -0
- package/dist/shared/upgrade.d.ts +9 -0
- package/dist/shared/upgrade.js +23 -0
- package/dist/ui/parent/parent-home-page.js +22 -0
- package/package.json +5 -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
package/README.md
CHANGED
|
@@ -6,15 +6,24 @@
|
|
|
6
6
|
|
|
7
7
|
## 五分钟用起来
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
**让 AI 助手帮你装:** 把这句话发给编程助手(Claude Code、Codex 这类能在电脑上跑命令的),或者网页版的聊天机器人,它会一步步带你做。百炼的 Key 由你自己填在页面上,不用告诉它。
|
|
10
|
+
|
|
11
|
+
```text
|
|
12
|
+
请阅读 https://raw.githubusercontent.com/TarsLab/cotutor/main/INSTALL.md ,照着在这台电脑上安装 cotutor
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
**自己装:** 需要一台电脑(Mac 最省事)、Node ≥ 22.18,和一个百炼账号(北京地域,按量付费)。老师的嘴和脑都走百炼,由应用直接调用,不用另装模型客户端:嘴是 `qwen3.8-omni-flash-realtime`,听孩子说、照着讲稿念;脑是 `qwen3.8-max`,写讲稿和板书。
|
|
10
16
|
|
|
11
17
|
```sh
|
|
12
18
|
npm i -g cotutor
|
|
13
19
|
cotutor init ming --name 小明 --grade 2 # 建 ~/cotutor/ming/:三位老师、技能、账本、对话目录、cotutor.json
|
|
14
|
-
cotutor service install #
|
|
15
|
-
cotutor open # 打开家长端,不用记地址
|
|
20
|
+
cotutor service install # 装成这台电脑的后台服务,第一次装会打开家长端,孩子的平板在那里扫码
|
|
16
21
|
```
|
|
17
22
|
|
|
23
|
+
Windows 的 PowerShell 缺省禁止运行脚本,报这个错时把 `npm`、`cotutor` 写成 `npm.cmd`、`cotutor.cmd`,不用改系统设置。
|
|
24
|
+
|
|
25
|
+
以后要打开家长端,点菜单栏(任务栏)的 cotutor 图标或跑 `cotutor open`,服务重启、登录电脑都不会自己弹出来。不想第一次弹,安装时加 `--no-open`。
|
|
26
|
+
|
|
18
27
|
后台服务登录后自己起来、崩了自己再起、关了终端照样跑,日志按天落文件:`cotutor service status` 看在不在跑,`cotutor service logs` 看日志,`cotutor service restart` 重起。Mac 上用 launchd,Linux 上用 systemd 用户服务,Windows 上是一条用户自己的登录项(不用管理员、没有黑窗口);第一次起来 Windows 可能弹窗问「允许访问」,点允许,平板才连得进来。要在终端里调试用 `cotutor serve`,先 `cotutor service stop` 让出端口。
|
|
19
28
|
|
|
20
29
|
Mac 和 Windows 上,`service install` 会一起装一个菜单栏(任务栏)图标,不用开终端就能看状态:正常时就是 logo;有事要处理出橙点;后台服务没在跑时 logo 变淡并出红点。状态变差时通知一次。点开图标能看原因、打开家长端、重启服务、看日志。「退出 cotutor」会停掉这台电脑上所有孩子的后台服务,平板打不开,下次登录自动恢复。Windows 的新图标先收在任务栏右下角的 `^` 里。不要图标就 `cotutor tray off`,装服务时加 `--no-tray` 也行。Linux 上没做,用 `cotutor service status` 看。
|
|
@@ -25,7 +34,9 @@ Mac 和 Windows 上,`service install` 会一起装一个菜单栏(任务栏)图
|
|
|
25
34
|
|
|
26
35
|
1. **填百炼**:在这台电脑上打开家长端 `/parent` 的「首页」,填百炼「按量付费」的 API Key 和业务空间 ID,保存前会先试连。API Key 在百炼控制台的「密钥管理」里创建,业务空间 ID 在控制台的业务空间详情里看,以百炼文档为准:[获取与配置 API Key](https://help.aliyun.com/zh/model-studio/get-api-key)、[首次调用千问 API](https://help.aliyun.com/zh/model-studio/first-api-call-to-qwen)(里面讲了业务空间 ID)。Key 跟地域绑定,控制台右上角先选北京。Token Plan 的 `sk-sp-` 不行。没填时孩子端只出「老师们还没到」。
|
|
27
36
|
2. **填档案**:workspace 里的 `孩子.md` 填 `school_start`(读一年级的年月,如 `2025-09`),老师据此知道现在是几年级哪个学期。想用自己的 Obsidian 库,改 `cotutor.json` 的 `paths.vault`。
|
|
28
|
-
3. **体检**:`cotutor doctor`
|
|
37
|
+
3. **体检**:`cotutor doctor` 逐项检查,不过的每条都带修法。
|
|
38
|
+
|
|
39
|
+
装好以后也可以让编程助手帮你管:在孩子的文件夹(`~/cotutor/<孩子>/`)里打开 Claude Code 或 Codex,已带的技能直接能用。`cotutor-home` 排孩子的首页,`cotutor-tune` 调老师的口气和规矩,`cotutor-analyze` 分析某次对话为什么那样答。
|
|
29
40
|
|
|
30
41
|
`init` 把出厂老师拷进 `agents/<老师>.md`,把做课包用的四个技能 chalk-* 和五个机器件 cotutor-tutor / cotutor-board / cotutor-analyze / cotutor-tune / cotutor-home 拷进 `.agents/skills/`(Codex、Qwen Code 直接读;Claude Code 读的 `.claude/skills` 是链到它的);
|
|
31
42
|
做小课堂的课包用 `cotutor chalk`(check / build / bake / snap / dub),烤画面与截图用本机的 Chrome(Windows 上没有 Chrome 就用 Edge)。要在平板上用(按住说话要 HTTPS):`cotutor cert` 签一张
|
|
@@ -95,14 +106,27 @@ import { loadWorkspace } from 'cotutor/workspace'; // 解
|
|
|
95
106
|
|
|
96
107
|
默认 workspace 是 `~/cotutor/<孩子>/`:配置在 `cotutor.json`,老师在 `agents/<老师>.md`,activity 在 `agents/<老师>/activities/`,技能在 `.agents/skills/`,对话在 `conversations/`,账本在 `ledger/`,照片在 `captures/`,课包在 `bundles/`,首页在 `home/`。长期档案、记忆和日记在配置的 Obsidian vault,规则见 [obsidian 仓库设计](docs/obsidian仓库设计.md)。备份时保留整个 workspace 与 vault。
|
|
97
108
|
|
|
109
|
+
**有新版本时**,后台服务每天问一次 npm(只发一个普通请求,不带孩子或使用的任何信息),菜单栏(任务栏)图标的菜单和家长端「首页」顶部会说,通知一次,孩子端不说。不会自动升级,因为要重启服务,孩子可能正在上课。不想让它问:在 `~/.config/cotutor/update.json` 写 `{"off": true}`。
|
|
110
|
+
|
|
111
|
+
升级挑孩子不用的时候,依次运行(分开写是因为 Windows 默认的 PowerShell 不认 `&&`):
|
|
112
|
+
|
|
98
113
|
```sh
|
|
99
|
-
cotutor
|
|
114
|
+
npm i -g cotutor # 换上新版程序
|
|
115
|
+
cotutor upgrade # 更新未修改的出厂老师、activity、主题与技能
|
|
116
|
+
cotutor service restart # 服务和图标换上新代码(在终端里跑的就重开 serve)
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Windows 上写成 `npm.cmd`、`cotutor.cmd`(PowerShell 缺省禁止运行脚本),提示里给的就是这样。
|
|
120
|
+
|
|
121
|
+
几个孩子的话,后两条每个孩子各跑一次,加 `--workspace ~/cotutor/<孩子>`。然后看一眼体检,它提示要迁移配置时再跑后面两条:
|
|
122
|
+
|
|
123
|
+
```sh
|
|
124
|
+
cotutor doctor
|
|
100
125
|
cotutor upgrade --config --dry-run # 预览 cotutor.json 的迁移
|
|
101
126
|
cotutor upgrade --config # 执行配置迁移
|
|
102
|
-
cotutor doctor
|
|
103
127
|
```
|
|
104
128
|
|
|
105
|
-
|
|
129
|
+
家长改过的老师与主题保留并显示差异;机器技能会随版本覆盖。配置迁移补缺,并更新未自定义的旧命令模板,保留自定义配置。换了 Node 版本、或包装到了别处,再 `cotutor service install` 一次。
|
|
106
130
|
|
|
107
131
|
2026-10-30 之前建的 workspace,老师在 `.claude/agents/`、技能在 `.claude/skills/`。`cotutor upgrade`(或 `init`)先把它们原样挪到新位置,再照常升级;新位置已有同名的不挪,会点名。`.claude/` 里别的文件不动。
|
|
108
132
|
|
|
@@ -135,7 +159,7 @@ node bin/cotutor.js mock # 假模型接口,查看前端
|
|
|
135
159
|
|
|
136
160
|
改卡片文档或技能生成代码后运行 `pnpm gen:skills`。开发约定与分模块验证见 [AGENTS.md](AGENTS.md) 和 [CONTRIBUTING.md](CONTRIBUTING.md)。
|
|
137
161
|
|
|
138
|
-
|
|
162
|
+
根目录的 [INSTALL.md](INSTALL.md) 给替家长安装的 AI 助手看。`docs/` 只保留以下六篇,历史讨论查 Git:
|
|
139
163
|
|
|
140
164
|
- [iPad 与 iPhone](docs/iPad与iPhone.md)
|
|
141
165
|
- [obsidian 仓库设计](docs/obsidian仓库设计.md)
|
package/dist/app/cli/cert.d.ts
CHANGED
|
@@ -3,10 +3,16 @@
|
|
|
3
3
|
* 没有 winget 的(11-02 那台就没有)下官方的 exe。
|
|
4
4
|
*/
|
|
5
5
|
export declare function mkcertHint(platform?: NodeJS.Platform): string;
|
|
6
|
+
/** mkcert 在、根证书建过了:cotutor cert 不用人就能跑。mkcert -install 要开机密码或点弹窗,那一步是人的 */
|
|
7
|
+
export declare function mkcertReady(caRootOf?: () => Promise<string>): Promise<boolean>;
|
|
8
|
+
/** 根证书没进这台电脑的系统信任。mkcert 没有根证书时自己建一个、照样签成功,只在 stderr 说一句(Firefox、Java 的信任库它也说,那些不算);没有单独的查询命令,只能这样看 */
|
|
9
|
+
export declare const mkcertUntrusted: (stderr: string) => boolean;
|
|
6
10
|
export interface CertResult {
|
|
7
11
|
cert: string;
|
|
8
12
|
key: string;
|
|
9
13
|
hosts: string[];
|
|
10
14
|
caRoot: string;
|
|
15
|
+
/** 这台电脑信任根证书了;false 时证书能用,但这台电脑的浏览器会报警告 */
|
|
16
|
+
trusted: boolean;
|
|
11
17
|
}
|
|
12
18
|
export declare function makeCert(extraHosts?: string[]): Promise<CertResult>;
|
package/dist/app/cli/cert.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* 地址认电脑名,IP 变了不用重签;证书里的 IP 只给手输数字地址的退路用。改了电脑名才要重签。
|
|
7
7
|
*/
|
|
8
8
|
import { execFile } from 'node:child_process';
|
|
9
|
+
import { existsSync } from 'node:fs';
|
|
9
10
|
import { mkdir } from 'node:fs/promises';
|
|
10
11
|
import { hostname } from 'node:os';
|
|
11
12
|
import { join } from 'node:path';
|
|
@@ -24,6 +25,17 @@ export function mkcertHint(platform = process.platform) {
|
|
|
24
25
|
return 'winget install FiloSottile.mkcert(没有 winget 就到 https://github.com/FiloSottile/mkcert/releases 下 windows-amd64 那个,改名 mkcert.exe 放进 PATH),重开终端跑 mkcert -install(弹窗问装不装根证书,点「是」)';
|
|
25
26
|
return '用系统的包管理器装 mkcert(如 apt install mkcert),再 mkcert -install';
|
|
26
27
|
}
|
|
28
|
+
/** mkcert 在、根证书建过了:cotutor cert 不用人就能跑。mkcert -install 要开机密码或点弹窗,那一步是人的 */
|
|
29
|
+
export async function mkcertReady(caRootOf = async () => (await execFileP('mkcert', ['-CAROOT'], { timeout: 8000 })).stdout.trim()) {
|
|
30
|
+
try {
|
|
31
|
+
return existsSync(join(await caRootOf(), 'rootCA.pem'));
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/** 根证书没进这台电脑的系统信任。mkcert 没有根证书时自己建一个、照样签成功,只在 stderr 说一句(Firefox、Java 的信任库它也说,那些不算);没有单独的查询命令,只能这样看 */
|
|
38
|
+
export const mkcertUntrusted = (stderr) => stderr.includes('not installed in the system trust store');
|
|
27
39
|
export async function makeCert(extraHosts = []) {
|
|
28
40
|
let caRoot;
|
|
29
41
|
try {
|
|
@@ -39,11 +51,13 @@ export async function makeCert(extraHosts = []) {
|
|
|
39
51
|
await mkdir(dir, { recursive: true });
|
|
40
52
|
const cert = join(dir, 'cert.pem');
|
|
41
53
|
const key = join(dir, 'key.pem');
|
|
54
|
+
let trusted;
|
|
42
55
|
try {
|
|
43
|
-
await execFileP('mkcert', ['-cert-file', cert, '-key-file', key, ...hosts], { timeout: 60_000 });
|
|
56
|
+
const { stderr } = await execFileP('mkcert', ['-cert-file', cert, '-key-file', key, ...hosts], { timeout: 60_000 });
|
|
57
|
+
trusted = !mkcertUntrusted(stderr);
|
|
44
58
|
}
|
|
45
59
|
catch (err) {
|
|
46
60
|
throw new UsageError(`mkcert 签证书失败:${err instanceof Error ? err.message : String(err)}\n先跑 mkcert -install,再重试。`);
|
|
47
61
|
}
|
|
48
|
-
return { cert, key, hosts, caRoot };
|
|
62
|
+
return { cert, key, hosts, caRoot, trusted };
|
|
49
63
|
}
|
package/dist/app/cli/doctor.d.ts
CHANGED
|
@@ -4,7 +4,12 @@ export interface DoctorCheck {
|
|
|
4
4
|
ok: boolean;
|
|
5
5
|
required: boolean;
|
|
6
6
|
detail: string;
|
|
7
|
+
/** 给人看的修法 */
|
|
7
8
|
fix?: string;
|
|
9
|
+
/** 不过时能直接跑的修复命令:幂等、不碰孩子的数据、不用人输东西(INSTALL.md 让 agent 照跑) */
|
|
10
|
+
cmd?: string;
|
|
11
|
+
/** 不过、又没有 cmd:要人来(填 key、输开机密码、点弹窗、写孩子的情况),agent 别动手。finish 统一补 */
|
|
12
|
+
human?: true;
|
|
8
13
|
}
|
|
9
14
|
export interface DoctorReport {
|
|
10
15
|
ok: boolean;
|
package/dist/app/cli/doctor.js
CHANGED
|
@@ -25,11 +25,23 @@ import { parseTimetable } from "../../memory/timetable.js";
|
|
|
25
25
|
import { configGapsOf, legacyLayout } from "./migrate.js";
|
|
26
26
|
import { httpsFiles } from "./serve.js";
|
|
27
27
|
import { readPin } from "../http/access.js";
|
|
28
|
+
import { BUILD } from "../build.js";
|
|
29
|
+
import { isNewer, upgradeSteps } from "../../shared/upgrade.js";
|
|
28
30
|
import { gradeOfSemester } from "../../memory/grade.js";
|
|
29
31
|
import { DIRS } from "./skeleton.js";
|
|
30
32
|
import { tutorStatuses, readManifest } from "./tutors.js";
|
|
31
33
|
import { workspaceGradePack } from "./grades.js";
|
|
32
34
|
import { CONFIG_FILE, ConfigError, USER_CONFIG, UsageError, assembleWorkspace, expandPath, parseConfig, readJson, redactHome, resolveRoot, } from "../workspace.js";
|
|
35
|
+
/** 每条不过的要么能跑(cmd)要么要人来(human),没有第三种:新加的检查项忘了写 cmd,也只是交给人,不会让 agent 瞎猜 */
|
|
36
|
+
function finish(report) {
|
|
37
|
+
for (const c of report.checks) {
|
|
38
|
+
if (c.ok)
|
|
39
|
+
delete c.cmd;
|
|
40
|
+
else if (!c.cmd)
|
|
41
|
+
c.human = true;
|
|
42
|
+
}
|
|
43
|
+
return report;
|
|
44
|
+
}
|
|
33
45
|
async function statOrNull(p) {
|
|
34
46
|
return stat(p).catch(() => null);
|
|
35
47
|
}
|
|
@@ -89,9 +101,14 @@ export async function doctorWorkspace(override, opts = {}) {
|
|
|
89
101
|
detail: err.message,
|
|
90
102
|
fix: err instanceof ConfigError ? `修复 ${redactHome(err.file)} 的 JSON,或删掉它` : 'cotutor init <slug>,或用 --workspace / COTUTOR_WORKSPACE 指定',
|
|
91
103
|
});
|
|
92
|
-
return { ok: false, root: null, source: null, checks };
|
|
104
|
+
return finish({ ok: false, root: null, source: null, checks });
|
|
93
105
|
}
|
|
94
106
|
push({ name: 'workspace', ok: true, required: true, detail: `${redactHome(root)}(来源:${source})` });
|
|
107
|
+
// cmd 要能照原样跑:体检时用 --workspace 指的,修的时候也得指
|
|
108
|
+
// 路径写 $HOME/…,和别的字段一样:用户名不传给 agent 的厂商;redactDeep 只换开头,管不到命令中间的路径;sh 与 PowerShell 都展开
|
|
109
|
+
const quoted = `"${redactHome(root)}"`;
|
|
110
|
+
const flag = source === 'flag' ? ` --workspace ${quoted}` : '';
|
|
111
|
+
const upgrade = `cotutor upgrade${flag}`;
|
|
95
112
|
// ---- cotutor.json ----
|
|
96
113
|
let ws = null;
|
|
97
114
|
const configFile = join(root, CONFIG_FILE);
|
|
@@ -117,12 +134,20 @@ export async function doctorWorkspace(override, opts = {}) {
|
|
|
117
134
|
push({ name: CONFIG_FILE, ok: false, required: true, detail: err.message, fix: '按上面逐条修;政策文件机器不重建,改坏了靠 git 回退' });
|
|
118
135
|
}
|
|
119
136
|
let teachingPack = null;
|
|
137
|
+
// 补缺的 init:slug 从配置来;年级照已装的写(init 不切换年级);建在缺省位置的不用写 --dir
|
|
138
|
+
const initCmd = () => {
|
|
139
|
+
if (!ws)
|
|
140
|
+
return undefined;
|
|
141
|
+
const slug = ws.config.kid.slug;
|
|
142
|
+
const grade = teachingPack ? ` --grade ${teachingPack.grade}` : '';
|
|
143
|
+
return `cotutor init ${slug}${grade}${root === join(homedir(), 'cotutor', slug) ? '' : ` --dir ${quoted}`}`;
|
|
144
|
+
};
|
|
120
145
|
try {
|
|
121
146
|
const manifest = await readManifest(root);
|
|
122
147
|
teachingPack = await workspaceGradePack(root);
|
|
123
148
|
push({ name: 'teachingPack', ok: true, required: true, detail: `${teachingPack.grade} 年级教学包 · ${redactHome(teachingPack.dir)}` });
|
|
124
149
|
if (!manifest.teachingPack)
|
|
125
|
-
push({ name: 'teachingPack.origin', ok: false, required: false, detail: '旧版出厂内容按二年级来源读取,尚未补记;孩子档案不变', fix: 'cotutor upgrade 补记来源,保留自定义文件与原 hash' });
|
|
150
|
+
push({ name: 'teachingPack.origin', ok: false, required: false, detail: '旧版出厂内容按二年级来源读取,尚未补记;孩子档案不变', fix: 'cotutor upgrade 补记来源,保留自定义文件与原 hash', cmd: upgrade });
|
|
126
151
|
}
|
|
127
152
|
catch (err) {
|
|
128
153
|
push({ name: 'teachingPack', ok: false, required: true, detail: err.message, fix: '检查 .cotutor/shipped.json 的 teachingPack 与本包 grades/;恢复原来源记录或安装包含该年级的版本,不要改成其他年级绕过' });
|
|
@@ -136,6 +161,7 @@ export async function doctorWorkspace(override, opts = {}) {
|
|
|
136
161
|
required: false,
|
|
137
162
|
detail: gaps.length ? `${gaps.length} 项要改:${gaps.map((g) => g.detail).join(';')}` : 'cotutor.json 有出厂模板里的全部老师、运行时与旗标,也没有不再用的段',
|
|
138
163
|
fix: gaps.length ? 'cotutor upgrade --config --dry-run 先看,再 cotutor upgrade --config 改(只加缺的、删不再用的出厂段,你改过的值不动)' : undefined,
|
|
164
|
+
cmd: gaps.length ? `cotutor upgrade --config${flag}` : undefined,
|
|
139
165
|
});
|
|
140
166
|
}
|
|
141
167
|
// ---- 骨架 ----
|
|
@@ -149,11 +175,12 @@ export async function doctorWorkspace(override, opts = {}) {
|
|
|
149
175
|
required: true,
|
|
150
176
|
detail: missing.length ? `缺 ${missing.join(' ')}` : [...DIRS].map((d) => `${d}/`).join(' '),
|
|
151
177
|
fix: missing.length ? `cotutor init <slug> --dir ${redactHome(root)}(幂等补缺)` : undefined,
|
|
178
|
+
cmd: missing.length ? initCmd() : undefined,
|
|
152
179
|
});
|
|
153
180
|
// 老布局(2026-10-30 之前):老师文件在 .claude/agents/、技能在 .claude/skills/,现在都读不到了
|
|
154
181
|
const legacy = await legacyLayout(root);
|
|
155
182
|
if (legacy.length)
|
|
156
|
-
push({ name: 'layout', ok: false, required: true, detail: `${legacy.join(' ')} 是老位置:老师文件现在在 agents/<老师>.md,技能在 ${SKILLS_DIR}/`, fix: 'cotutor upgrade(原样挪过去,不改内容;新位置已有同名的不挪、会点名)' });
|
|
183
|
+
push({ name: 'layout', ok: false, required: true, detail: `${legacy.join(' ')} 是老位置:老师文件现在在 agents/<老师>.md,技能在 ${SKILLS_DIR}/`, fix: 'cotutor upgrade(原样挪过去,不改内容;新位置已有同名的不挪、会点名)', cmd: upgrade });
|
|
157
184
|
// 家规(根的 QWEN.md)2026-09-15 起不出厂:有就是家长自建的共同规矩,提一句;没有不缺
|
|
158
185
|
const rulesThere = (await Promise.all(['QWEN.md'].map(async (f) => ((await statOrNull(join(root, f))) ? f : null)))).filter(Boolean);
|
|
159
186
|
push({ name: 'rules', ok: true, required: false, detail: rulesThere.length ? `家长自建的家规 ${rulesThere.join(' ')} 在,所有老师每轮都读` : '没有根目录的 QWEN.md(不需要:老师要知道的都在老师文件与技能里)' });
|
|
@@ -184,6 +211,7 @@ export async function doctorWorkspace(override, opts = {}) {
|
|
|
184
211
|
: shipped
|
|
185
212
|
? `cotutor init 补拷(拷自本包 agents/${name}.md),或把老师键改成文件里的 name`
|
|
186
213
|
: `这是自家加的老师:写 agents/${name}.md(frontmatter name: ${name}),cotutor add ${name} --display <显示名> 可出模板;或把 cotutor.json 里这条删掉`,
|
|
214
|
+
cmd: !ok && shipped ? initCmd() : undefined,
|
|
187
215
|
});
|
|
188
216
|
const st = statuses.get(name);
|
|
189
217
|
if (!st)
|
|
@@ -196,11 +224,12 @@ export async function doctorWorkspace(override, opts = {}) {
|
|
|
196
224
|
required: false,
|
|
197
225
|
detail: label[st.state],
|
|
198
226
|
fix: st.state === 'upgradable' ? 'cotutor upgrade 换新版' : undefined,
|
|
227
|
+
cmd: st.state === 'upgradable' ? upgrade : undefined,
|
|
199
228
|
});
|
|
200
229
|
}
|
|
201
230
|
const home = join(ws.dirs.agents, name);
|
|
202
231
|
const there = (await statOrNull(home))?.isDirectory() ?? false;
|
|
203
|
-
push({ name: `tutor.${name}.home`, ok: there, required: true, detail: there ? `agents/${name}/ 在(会话 cwd)` : `agents/${name}/ 不在`, fix: there ? undefined : 'cotutor init 补建' });
|
|
232
|
+
push({ name: `tutor.${name}.home`, ok: there, required: true, detail: there ? `agents/${name}/ 在(会话 cwd)` : `agents/${name}/ 不在`, fix: there ? undefined : 'cotutor init 补建', cmd: there ? undefined : initCmd() });
|
|
204
233
|
// 音色:一老师一音色走 API 是正路;没配的孩子端只剩浏览器合成声(只该在测试里);hidden 的老师不对孩子说话,不用音色
|
|
205
234
|
const tc = ws.config.tutors[name];
|
|
206
235
|
if (tc.enabled && !tc.hidden && !cardVoice(ws.config.tutors, name))
|
|
@@ -259,11 +288,11 @@ export async function doctorWorkspace(override, opts = {}) {
|
|
|
259
288
|
const { CLAUDE_SKILLS_LINK, skillStatuses } = await import("./skills.js");
|
|
260
289
|
for (const sk of teachingPack ? await skillStatuses(root) : []) {
|
|
261
290
|
const label = { latest: sk.machine ? '机器件,最新' : '出厂件,最新', upgradable: sk.machine ? '机器件,和包里不一样(改过或包已更新)' : `出厂件,基于 ${sk.basedOn},包已更新`, custom: `自定义(基于 ${sk.basedOn})`, untracked: '自定义(没有出厂记录)', missing: sk.machine ? '缺(机器件,老师或家长的技能靠它)' : '缺' };
|
|
262
|
-
push({ name: `skill.${sk.name}`, ok: sk.state !== 'missing' && sk.state !== 'upgradable', required: Boolean(sk.machine), detail: `${SKILLS_DIR}/${sk.name}/:${label[sk.state]}`, fix: sk.state === 'missing' ? 'cotutor init 补拷' : sk.state === 'upgradable' ? 'cotutor upgrade 换新版' : undefined });
|
|
291
|
+
push({ name: `skill.${sk.name}`, ok: sk.state !== 'missing' && sk.state !== 'upgradable', required: Boolean(sk.machine), detail: `${SKILLS_DIR}/${sk.name}/:${label[sk.state]}`, fix: sk.state === 'missing' ? 'cotutor init 补拷' : sk.state === 'upgradable' ? 'cotutor upgrade 换新版' : undefined, cmd: sk.state === 'missing' ? initCmd() : sk.state === 'upgradable' ? upgrade : undefined });
|
|
263
292
|
}
|
|
264
293
|
// Claude Code 只认 .claude/skills:那里该是指向 .agents/skills 的链;老师用不到,家长在 workspace 里用 Claude Code 才要
|
|
265
294
|
const linked = (await lstat(join(root, CLAUDE_SKILLS_LINK)).catch(() => null))?.isSymbolicLink() ?? false;
|
|
266
|
-
push({ name: 'skills.claude', ok: linked, required: false, detail: linked ? `${CLAUDE_SKILLS_LINK} 链到 ${SKILLS_DIR}(Claude Code 读的是同一份)` : `${CLAUDE_SKILLS_LINK} 不是链,在 workspace 里用 Claude Code 看不到 ${SKILLS_DIR}/ 的技能`, fix: linked ? undefined : 'cotutor upgrade 建链(是实体目录的话先把里面的挪进 .agents/skills/ 再删掉)' });
|
|
295
|
+
push({ name: 'skills.claude', ok: linked, required: false, detail: linked ? `${CLAUDE_SKILLS_LINK} 链到 ${SKILLS_DIR}(Claude Code 读的是同一份)` : `${CLAUDE_SKILLS_LINK} 不是链,在 workspace 里用 Claude Code 看不到 ${SKILLS_DIR}/ 的技能`, fix: linked ? undefined : 'cotutor upgrade 建链(是实体目录的话先把里面的挪进 .agents/skills/ 再删掉)', cmd: linked ? undefined : upgrade });
|
|
267
296
|
// ---- activity:老师的家里 agents/<老师>/activities/<名>.md;出厂的看新旧,每份都过一遍 activity check ----
|
|
268
297
|
{
|
|
269
298
|
const { activityStatuses, checkActivities } = await import("./activities.js");
|
|
@@ -271,7 +300,7 @@ export async function doctorWorkspace(override, opts = {}) {
|
|
|
271
300
|
const label = { latest: '出厂件,最新', upgradable: '出厂件,包已更新', custom: '自定义', untracked: '自定义(没有出厂记录)', missing: '缺', broken: '读不到' };
|
|
272
301
|
for (const s of states.values())
|
|
273
302
|
if (s.state === 'missing')
|
|
274
|
-
push({ name: `activity.${s.key}`, ok: false, required: false, detail: `agents/${s.key.replace('/', '/activities/')}.md 不在`, fix: 'cotutor init 补拷' });
|
|
303
|
+
push({ name: `activity.${s.key}`, ok: false, required: false, detail: `agents/${s.key.replace('/', '/activities/')}.md 不在`, fix: 'cotutor init 补拷', cmd: initCmd() });
|
|
275
304
|
// 学科色:手改 cotutor.json 写的颜色,新话题气泡的白字要压得住
|
|
276
305
|
for (const [name, t] of Object.entries(ws.config.tutors)) {
|
|
277
306
|
if (!t.color)
|
|
@@ -295,6 +324,7 @@ export async function doctorWorkspace(override, opts = {}) {
|
|
|
295
324
|
required: false,
|
|
296
325
|
detail: fixes.length ? `${origin};${fixes.length} 条要改,第一条:${fixes[0].line ? `第 ${fixes[0].line} 行 ` : ''}${fixes[0].text}` : origin,
|
|
297
326
|
fix: fixes.length ? `cotutor activity check ${r.tutor} ${r.name} 看全` : st?.state === 'upgradable' ? 'cotutor upgrade 换新版' : undefined,
|
|
327
|
+
cmd: !fixes.length && st?.state === 'upgradable' ? upgrade : undefined,
|
|
298
328
|
});
|
|
299
329
|
}
|
|
300
330
|
}
|
|
@@ -308,7 +338,7 @@ export async function doctorWorkspace(override, opts = {}) {
|
|
|
308
338
|
push({ name: 'theme.manifest', ok: true, required: false, detail: `themes/${name}/:${Object.keys(t.manifest.tints).length} 个底色槽、${Object.keys(t.manifest.looks).length} 个字形槽、${Object.keys(t.manifest.pens).length} 支笔,default = ${t.manifest.default}` });
|
|
309
339
|
}
|
|
310
340
|
catch (err) {
|
|
311
|
-
push({ name: 'theme.manifest', ok: false, required: false, detail: `themes/${name}/ 用不了(服务退回出厂 default):${err instanceof Error ? err.message : String(err)}`, fix: name === 'default' ? 'cotutor init 补拷(已有的不动)或 cotutor upgrade' : `修 themes/${name}/theme.json 与 kid.css,或把 cotutor.json 的 kid.theme 改回 default
|
|
341
|
+
push({ name: 'theme.manifest', ok: false, required: false, detail: `themes/${name}/ 用不了(服务退回出厂 default):${err instanceof Error ? err.message : String(err)}`, fix: name === 'default' ? 'cotutor init 补拷(已有的不动)或 cotutor upgrade' : `修 themes/${name}/theme.json 与 kid.css,或把 cotutor.json 的 kid.theme 改回 default`, cmd: name === 'default' ? initCmd() : undefined });
|
|
312
342
|
}
|
|
313
343
|
for (const th of teachingPack ? await themeStatuses(root) : []) {
|
|
314
344
|
if (!th.shipped) {
|
|
@@ -316,7 +346,7 @@ export async function doctorWorkspace(override, opts = {}) {
|
|
|
316
346
|
continue;
|
|
317
347
|
}
|
|
318
348
|
const label = { latest: '出厂件,最新', upgradable: `出厂件,基于 ${th.basedOn},包已更新`, custom: `自定义(基于 ${th.basedOn})`, untracked: '自定义(没有出厂记录)', missing: '缺' };
|
|
319
|
-
push({ name: `theme.${th.name}.origin`, ok: th.state !== 'missing' && th.state !== 'upgradable', required: false, detail: `themes/${th.name}/:${label[th.state]}`, fix: th.state === 'missing' ? 'cotutor init 补拷' : th.state === 'upgradable' ? 'cotutor upgrade 换新版' : undefined });
|
|
349
|
+
push({ name: `theme.${th.name}.origin`, ok: th.state !== 'missing' && th.state !== 'upgradable', required: false, detail: `themes/${th.name}/:${label[th.state]}`, fix: th.state === 'missing' ? 'cotutor init 补拷' : th.state === 'upgradable' ? 'cotutor upgrade 换新版' : undefined, cmd: th.state === 'missing' ? initCmd() : th.state === 'upgradable' ? upgrade : undefined });
|
|
320
350
|
}
|
|
321
351
|
}
|
|
322
352
|
}
|
|
@@ -442,8 +472,10 @@ export async function doctorWorkspace(override, opts = {}) {
|
|
|
442
472
|
push({ name: 'server.access', ok: pin !== null, required: false, detail: pin ? '已设 PIN' : '家长端、工作台没上锁', fix: pin ? undefined : 'cotutor pin' });
|
|
443
473
|
// ---- HTTPS:iPad 上录音要;没有只提醒 ----
|
|
444
474
|
const tls = httpsFiles(ws);
|
|
445
|
-
const { mkcertHint } = await import("./cert.js");
|
|
446
|
-
|
|
475
|
+
const { mkcertHint, mkcertReady } = await import("./cert.js");
|
|
476
|
+
// mkcert -install 要开机密码或点弹窗,是人的事;做过了,签证书就不用人了
|
|
477
|
+
const canSign = !tls && probeEnv && await mkcertReady();
|
|
478
|
+
push({ name: 'https', ok: tls !== null, required: false, detail: tls ? `${redactHome(tls.cert)}` : '没有证书,serve 走 HTTP(平板、手机上按住说话不可用)', fix: tls ? undefined : `cotutor cert(需要 mkcert:${mkcertHint()}),签完 cotutor service restart`, cmd: canSign ? 'cotutor cert' : undefined });
|
|
447
479
|
// ---- 百炼:老师靠实时模型开口,没配孩子端打不开;--live 真试连一次(握手就挂,不花钱) ----
|
|
448
480
|
// key 只认 ~/.config/cotutor/(2026-11-02 起);shell 里还 export 着旧的,说一句免得以为配了
|
|
449
481
|
const bl = dashscopeAuth(env);
|
|
@@ -502,14 +534,14 @@ export async function doctorWorkspace(override, opts = {}) {
|
|
|
502
534
|
if (traySupported(process.platform)) {
|
|
503
535
|
const t = await trayStatus({ ...realHost(), env }).catch(() => null);
|
|
504
536
|
if (t && !t.off)
|
|
505
|
-
push({ name: 'tray', ok: t.installed, required: false, detail: t.installed ? `托盘图标装了${t.running ? ',在跑' : ',现在没在跑(点过退出,下次登录回来)'}` : '没装托盘图标:看状态只能开终端', fix: t.installed ? undefined : 'cotutor tray on' });
|
|
537
|
+
push({ name: 'tray', ok: t.installed, required: false, detail: t.installed ? `托盘图标装了${t.running ? ',在跑' : ',现在没在跑(点过退出,下次登录回来)'}` : '没装托盘图标:看状态只能开终端', fix: t.installed ? undefined : 'cotutor tray on', cmd: t.installed ? undefined : 'cotutor tray on' });
|
|
506
538
|
}
|
|
507
539
|
}
|
|
508
540
|
if (!s)
|
|
509
|
-
push({ name: 'service', ok: false, required: false, detail: '没装后台服务:在终端里跑的 cotutor serve,关了终端就停', fix: 'cotutor service install(登录后自己起来、崩了自己再起)' });
|
|
541
|
+
push({ name: 'service', ok: false, required: false, detail: '没装后台服务:在终端里跑的 cotutor serve,关了终端就停', fix: 'cotutor service install(登录后自己起来、崩了自己再起)', cmd: `cotutor service install --no-open${flag}` });
|
|
510
542
|
else {
|
|
511
543
|
const gone = s.args.slice(0, 2).filter((f) => !existsSync(f));
|
|
512
|
-
push({ name: 'service', ok: !gone.length, required: false, detail: gone.length ? `后台服务 ${s.label} 指的 ${gone.map((f) => redactHome(f)).join('、')} 不在了(换了 node 版本、包挪了地方)` : `后台服务 ${s.label}(cotutor service status 看在不在跑)`, fix: gone.length ? 'cotutor service install(照现在的 node 与包重写一遍)' : undefined });
|
|
544
|
+
push({ name: 'service', ok: !gone.length, required: false, detail: gone.length ? `后台服务 ${s.label} 指的 ${gone.map((f) => redactHome(f)).join('、')} 不在了(换了 node 版本、包挪了地方)` : `后台服务 ${s.label}(cotutor service status 看在不在跑)`, fix: gone.length ? 'cotutor service install(照现在的 node 与包重写一遍)' : undefined, cmd: gone.length ? `cotutor service install${flag}` : undefined });
|
|
513
545
|
}
|
|
514
546
|
}
|
|
515
547
|
if (probeEnv && process.platform === 'darwin') {
|
|
@@ -528,6 +560,13 @@ export async function doctorWorkspace(override, opts = {}) {
|
|
|
528
560
|
if (sec !== null)
|
|
529
561
|
push({ name: 'windows.sleep', ok: sec === 0, required: false, detail: sec === 0 ? '接着电源时不闲置睡眠' : `接着电源 ${Math.round(sec / 60)} 分钟没人碰就睡,睡着时平板连不上(有通话时 cotutor 拦着它睡,别的时候拦不了)`, fix: sec === 0 ? undefined : '设置 › 系统 › 电源和电池 › 屏幕、睡眠和休眠超时,「插入电源时,使设备在以下时间后进入睡眠状态」选「从不」;合盖看「电源按钮和盖子」那一项' });
|
|
530
562
|
}
|
|
563
|
+
// ---- 有没有新版本:只看服务问来存下的那份,体检不上网;什么时候升级家长定(要重起服务,孩子可能在上课),所以没有 cmd ----
|
|
564
|
+
{
|
|
565
|
+
const { readUpdateRecord } = await import("../machine/update.js");
|
|
566
|
+
const u = await readUpdateRecord();
|
|
567
|
+
if (!u.off && isNewer(u.latest, BUILD.version))
|
|
568
|
+
push({ name: 'update', ok: false, required: false, detail: `有新版本 ${u.latest}(现在是 ${BUILD.version})`, fix: `挑孩子不用的时候,依次运行:${upgradeSteps().join(' ; ')}` });
|
|
569
|
+
}
|
|
531
570
|
// ---- 用户配置 ----
|
|
532
571
|
try {
|
|
533
572
|
const text = await readFile(USER_CONFIG, 'utf8');
|
|
@@ -547,7 +586,7 @@ export async function doctorWorkspace(override, opts = {}) {
|
|
|
547
586
|
push({ name: 'sandbox', ok: true, required: false, detail: `试用 workspace:${ws.sandbox.forkedAt.slice(0, 10)} 从 ${redactHome(ws.sandbox.from)} 分出来;--sandbox --fresh 重新分` });
|
|
548
587
|
else {
|
|
549
588
|
const gitThere = (await statOrNull(join(root, '.git'))) !== null;
|
|
550
|
-
push({ name: 'git', ok: gitThere, required: false, detail: gitThere ? 'workspace是 git 仓' : 'workspace不在 git 里', fix: gitThere ? undefined : '建议 git init:老师记忆、账本、政策都是不可再生状态' });
|
|
589
|
+
push({ name: 'git', ok: gitThere, required: false, detail: gitThere ? 'workspace是 git 仓' : 'workspace不在 git 里', fix: gitThere ? undefined : '建议 git init:老师记忆、账本、政策都是不可再生状态', cmd: gitThere ? undefined : `git init ${quoted}` });
|
|
551
590
|
}
|
|
552
|
-
return { ok: checks.filter((c) => c.required).every((c) => c.ok), root, source, checks };
|
|
591
|
+
return finish({ ok: checks.filter((c) => c.required).every((c) => c.ok), root, source, checks });
|
|
553
592
|
}
|
package/dist/app/cli/init.js
CHANGED
|
@@ -150,7 +150,7 @@ export async function initWorkspace(opts) {
|
|
|
150
150
|
if (!(await exists(join(root, '.git'))))
|
|
151
151
|
suggestions.push('git init(建议:老师记忆、账本、政策都是不可再生状态,git 是破坏后的兜底)');
|
|
152
152
|
suggestions.push('档案里填 school_start(读一年级的年月,如 2025-09),老师才知道现在是几年级哪个学期;想用自己的 Obsidian 库,改 cotutor.json 的 paths.vault');
|
|
153
|
-
suggestions.push('cotutor service install 装成后台服务(登录后自己起来、关了终端照样跑;在终端里调试用 cotutor serve)
|
|
153
|
+
suggestions.push('cotutor service install 装成后台服务(登录后自己起来、关了终端照样跑;在终端里调试用 cotutor serve),第一次装会打开家长端(以后 cotutor open),在「首页」填百炼的按量 API Key 与业务空间 ID(老师的嘴和脑都用它;没填孩子端只出「老师们还没到」)');
|
|
154
154
|
suggestions.push('在平板、手机上用:cotutor cert 签证书(要 mkcert),根证书装到设备上并打开信任,iPad / iPhone 的步骤见 README 里的《iPad 与 iPhone》');
|
|
155
155
|
suggestions.push('可选:点读、单词卡的配音用百炼(和老师的嘴同一把 key),到家长端首页点老师的「样子与声音」挑声音;cotutor pin 给家长端与工作台加锁');
|
|
156
156
|
suggestions.push('cotutor doctor 逐项体检');
|
package/dist/app/cli/main.js
CHANGED
|
@@ -21,11 +21,15 @@ import { addTheme, upgradeThemes } from "./themes.js";
|
|
|
21
21
|
import { patchConfig } from "../workspace.js";
|
|
22
22
|
import { resolveRoot } from "../workspace.js";
|
|
23
23
|
import { bindFailure, portHolder, whoHolds } from "./port.js";
|
|
24
|
-
import { httpsFiles, serveWorkspace, siteUrl } from "./serve.js";
|
|
24
|
+
import { certCovers, httpsFiles, localName, serveWorkspace, siteUrl } from "./serve.js";
|
|
25
|
+
import { dashscopeAuth } from "../../realtime/bailian.js";
|
|
25
26
|
import { teeLogs } from "./serve-log.js";
|
|
26
|
-
import { controlService, earlyLogHint, findService, installService, listServices, probeHealth, realHost, runKeeper, servicePlatform, serviceStatus, tailLog, uninstallService, waitHealthy } from "./service.js";
|
|
27
|
+
import { controlService, earlyLogHint, findService, installService, listServices, opensAfterInstall, probeHealth, realHost, runKeeper, servicePlatform, serviceStatus, tailLog, uninstallService, waitHealthy } from "./service.js";
|
|
27
28
|
import { openSandbox } from "./sandbox.js";
|
|
28
|
-
import { dropTrayIfNoServices, ensureTray, installTray, removeTray, trayDir, trayStatus, traySupported } from "./tray.js";
|
|
29
|
+
import { dropTrayIfNoServices, ensureTray, installTray, openTarget, refreshTray, removeTray, trayDir, trayStatus, traySupported } from "./tray.js";
|
|
30
|
+
import { BUILD } from "../build.js";
|
|
31
|
+
import { startUpdateChecks } from "../machine/update.js";
|
|
32
|
+
import { isNewer, upgradeSteps } from "../../shared/upgrade.js";
|
|
29
33
|
import { serveMock } from "../mock/mock.js";
|
|
30
34
|
import { UsageError, loadWorkspace, redactDeep, redactHome, workspaceReport } from "../workspace.js";
|
|
31
35
|
import { USAGE } from "./usage.js";
|
|
@@ -74,16 +78,11 @@ function parseArgs(argv, valued) {
|
|
|
74
78
|
return { cmd, positionals, flags };
|
|
75
79
|
}
|
|
76
80
|
/** 用默认浏览器打开(各系统各自的打开命令,失败不吭声) */
|
|
77
|
-
async function openInBrowser(page) {
|
|
78
|
-
const { spawn } = await import('node:child_process');
|
|
79
|
-
const [cmd, args] = process.platform === 'darwin' ? ['open', [page]] : process.platform === 'win32' ? ['cmd', ['/c', 'start', '', page]] : ['xdg-open', [page]];
|
|
80
|
-
spawn(cmd, args, { stdio: 'ignore', detached: true }).on('error', () => { }).unref();
|
|
81
|
-
}
|
|
82
81
|
/** 打开哪一页的那一行(地址独占行尾:后面紧跟全角括号的话,终端不把它认成链接);--open(旧名 --open-qr)顺手打开 */
|
|
83
|
-
|
|
82
|
+
function printOpen(label, page, hint, open) {
|
|
84
83
|
process.stdout.write(` ${label} ${page}\n${' '.repeat(label.length * 2 + 3)}${hint}\n`);
|
|
85
84
|
if (open)
|
|
86
|
-
|
|
85
|
+
openTarget(process.platform, page);
|
|
87
86
|
}
|
|
88
87
|
const wantsOpen = (flags) => flags.open === true || flags['open-qr'] === true;
|
|
89
88
|
/** 不用 workspace 的命令:--sandbox 对它们没有意义 */
|
|
@@ -197,6 +196,9 @@ export async function main(argv) {
|
|
|
197
196
|
r.ctx.runner.onEvent((e) => { if (keep(e.event))
|
|
198
197
|
process.stdout.write(`${e.tutor} ${e.job} ${formatEvent(e.event)}\n`); });
|
|
199
198
|
}
|
|
199
|
+
// 有没有新版本:从仓库跑的是开发,不问(npm 的提醒对它没意义);测试起的 serve 也都在仓库里
|
|
200
|
+
if (!BUILD.commit)
|
|
201
|
+
startUpdateChecks();
|
|
200
202
|
process.stdout.write(`cotutor serve ${JSON.stringify(workspaceReport(r.ws))}\n`);
|
|
201
203
|
if (r.ws.sandbox)
|
|
202
204
|
process.stdout.write(` 试用:从 ${redactHome(r.ws.sandbox.from)} 分出来,对话、照片、vault 的写入都留在这里,不进孩子的记录;--sandbox --fresh 重新分\n`);
|
|
@@ -205,7 +207,7 @@ export async function main(argv) {
|
|
|
205
207
|
if (!r.https)
|
|
206
208
|
process.stdout.write(' ! HTTP:平板、手机上按住说话要 HTTPS;cotutor cert 建证书后重启即走 HTTPS\n');
|
|
207
209
|
// 只给家长端一个入口:孩子的平板扫码、排首页、记录都在里面,工作台从它的右上角进
|
|
208
|
-
|
|
210
|
+
printOpen('家长端', r.parentPage, '在这台电脑上打开:孩子的平板扫码、排首页、看记录都在里面;--open 顺手打开', wantsOpen(flags));
|
|
209
211
|
return;
|
|
210
212
|
}
|
|
211
213
|
case 'open': {
|
|
@@ -225,7 +227,7 @@ export async function main(argv) {
|
|
|
225
227
|
return;
|
|
226
228
|
}
|
|
227
229
|
if (!json && flags.print !== true)
|
|
228
|
-
|
|
230
|
+
openTarget(process.platform, page);
|
|
229
231
|
return;
|
|
230
232
|
}
|
|
231
233
|
case 'tray': {
|
|
@@ -299,6 +301,10 @@ export async function main(argv) {
|
|
|
299
301
|
: `! 后台服务读不到 vault(${redactHome(ws.paths.vault)}):看看这个目录的权限,然后 cotutor service restart`);
|
|
300
302
|
if (h?.version && h.version !== version())
|
|
301
303
|
out.push(`! 在跑的是 ${h.version},装的是 ${version()}:cotutor service restart 换上新的`);
|
|
304
|
+
if (h?.scheme === 'http' && httpsFiles(ws))
|
|
305
|
+
out.push('! 证书有了,服务还在走 HTTP(平板上按住说话不可用):cotutor service restart');
|
|
306
|
+
if (isNewer(h?.latest, version()))
|
|
307
|
+
out.push(`! 有新版本 ${h?.latest}(现在是 ${version()}),升级依次运行:${upgradeSteps().join(' ; ')}`);
|
|
302
308
|
return out;
|
|
303
309
|
};
|
|
304
310
|
const tellLog = async (dir) => {
|
|
@@ -356,8 +362,9 @@ export async function main(argv) {
|
|
|
356
362
|
process.stdout.write(` ${platform === 'darwin' ? '菜单栏' : '任务栏'}多了 cotutor 图标:不用开终端就能看状态、打开家长端${tray === 'later' ? '(这个会话里没起来,下次登录出现)' : platform === 'win32' ? '(新图标先收在右下角的 ^ 里)' : ''};不要它:cotutor tray off\n`);
|
|
357
363
|
else if (tray === 'failed')
|
|
358
364
|
process.stdout.write(' ! 图标没装上,服务不受影响;cotutor tray on 再试\n');
|
|
365
|
+
const opening = opensAfterInstall({ firstTime: !had, open: wantsOpen(flags), noOpen: flags['no-open'] === true, platform, env: process.env });
|
|
359
366
|
if (h) {
|
|
360
|
-
|
|
367
|
+
printOpen('家长端', parentPage, `${opening ? '刚帮你打开了:' : ''}孩子的平板扫码、排首页、看记录都在里面`, opening);
|
|
361
368
|
for (const n of notes(h))
|
|
362
369
|
process.stdout.write(`${n}\n`);
|
|
363
370
|
}
|
|
@@ -393,6 +400,9 @@ export async function main(argv) {
|
|
|
393
400
|
return;
|
|
394
401
|
}
|
|
395
402
|
const h = await waitHealthy(port, 20_000, before?.started);
|
|
403
|
+
// 升级后跑的就是这一条:图标进程不归服务管,不换就还是旧代码,要等下次登录。它出问题不算重起失败
|
|
404
|
+
if (sub === 'restart')
|
|
405
|
+
await refreshTray(host).catch(() => false);
|
|
396
406
|
if (h) {
|
|
397
407
|
process.stdout.write(`${sub === 'start' ? '起来了' : '重起了'}:pid ${h.pid ?? '?'},版本 ${h.version ?? '?'}\n`);
|
|
398
408
|
for (const n of notes(h))
|
|
@@ -409,8 +419,25 @@ export async function main(argv) {
|
|
|
409
419
|
const st = await serviceStatus(host, root, port);
|
|
410
420
|
// 没答应时看看是不是别的程序占着:后台服务遇到这种情况只写一句日志就不起了,这里要说得出来
|
|
411
421
|
const holder = st.health ? null : await portHolder(host, port);
|
|
422
|
+
// 地址按实际走的协议算:签了证书还没重起时仍是 http
|
|
423
|
+
const served = st.health?.scheme ?? scheme;
|
|
424
|
+
const page = siteUrl(served, 'localhost', port, '/parent/home');
|
|
412
425
|
if (json) {
|
|
413
|
-
|
|
426
|
+
// 一次答清「装好没有」(INSTALL.md 靠它):服务、平板地址、证书、百炼、图标、版本
|
|
427
|
+
const name = localName();
|
|
428
|
+
const tls = httpsFiles(ws);
|
|
429
|
+
const tray = traySupported(host.platform) ? await trayStatus(host) : null;
|
|
430
|
+
process.stdout.write(`${JSON.stringify(redactDeep({
|
|
431
|
+
...st,
|
|
432
|
+
portHolder: holder,
|
|
433
|
+
version: version(),
|
|
434
|
+
parentPage: page,
|
|
435
|
+
tabletUrl: siteUrl(served, name, port),
|
|
436
|
+
cert: { present: Boolean(tls), coversName: tls ? certCovers(tls.cert, name) : false, serving: st.health?.scheme === 'https' },
|
|
437
|
+
bailian: Boolean(dashscopeAuth()),
|
|
438
|
+
tray: tray && { installed: tray.installed, running: tray.running },
|
|
439
|
+
latest: isNewer(st.health?.latest, version()) ? st.health?.latest : null,
|
|
440
|
+
}), null, 2)}\n`);
|
|
414
441
|
return;
|
|
415
442
|
}
|
|
416
443
|
if (!st.installed)
|
|
@@ -419,7 +446,7 @@ export async function main(argv) {
|
|
|
419
446
|
process.stdout.write(`后台服务:装了 ${st.installed.label}(${redactHome(st.installed.file)})\n ${st.running ? `在跑:pid ${st.pid ?? '?'}` : `没在跑${st.lastExit ? `(上次退出:${st.lastExit})` : ''};cotutor service start`}\n`);
|
|
420
447
|
const h = st.health;
|
|
421
448
|
if (h)
|
|
422
|
-
process.stdout.write(` 端口 ${port} 答应:${h.mode === 'service' ? '后台服务' : `终端里的 cotutor serve(pid ${h.pid ?? '?'})`},版本 ${h.version ?? '?'},起来于 ${when(h.started)}\n 家长端 ${
|
|
449
|
+
process.stdout.write(` 端口 ${port} 答应:${h.mode === 'service' ? '后台服务' : `终端里的 cotutor serve(pid ${h.pid ?? '?'})`},版本 ${h.version ?? '?'},起来于 ${when(h.started)}\n 家长端 ${page}\n`);
|
|
423
450
|
else
|
|
424
451
|
process.stdout.write(holder ? ` 端口 ${port} 没答应,被占着:${holder.name}(pid ${holder.pid})在用。腾出来以后 cotutor service start;或改 cotutor.json 的 server.port,平板要重新扫码、重加主屏幕图标\n` : ` 端口 ${port} 没答应\n`);
|
|
425
452
|
for (const n of notes(h))
|
|
@@ -491,7 +518,7 @@ export async function main(argv) {
|
|
|
491
518
|
for (const u of r.urls)
|
|
492
519
|
process.stdout.write(` ${u}\n`);
|
|
493
520
|
process.stdout.write(' 孩子端 /,家长端 /parent;直接开某位老师:/?tutor=chinese-tutor\n');
|
|
494
|
-
|
|
521
|
+
printOpen('扫码页', r.qrPage, '在这台电脑上打开,平板、手机用相机扫;--open 顺手打开', wantsOpen(flags));
|
|
495
522
|
return;
|
|
496
523
|
}
|
|
497
524
|
case 'add': {
|
|
@@ -699,6 +726,8 @@ export async function main(argv) {
|
|
|
699
726
|
process.stdout.write(` iPad / iPhone:${send} → 设置里安装描述文件 → 通用 › 关于本机 › 证书信任设置里**把开关打开**(装了不等于信任)\n`);
|
|
700
727
|
process.stdout.write(` 安卓:${process.platform === 'darwin' ? '传到设备上' : send} → 设置里搜「CA 证书」,按提示安装(各家菜单不一样,会提示「网络可能受到监控」)\n`);
|
|
701
728
|
process.stdout.write(`然后 cotutor service restart(在终端里跑的就重开 cotutor serve),cotutor open 打开家长端,用相机扫「扫码打开」那张。地址认电脑名,换 Wi-Fi、IP 变了都不用重签;改了电脑名才要重跑这条。详见 docs/iPad与iPhone.md\n`);
|
|
729
|
+
if (!r.trusted)
|
|
730
|
+
process.stdout.write(`! 这台电脑还没信任根证书,浏览器打开家长端会报证书警告。请在这台电脑的终端里自己跑一次 mkcert -install(${process.platform === 'win32' ? '弹窗问时点「是」' : '要输开机密码'}),不用重签\n`);
|
|
702
731
|
}
|
|
703
732
|
return;
|
|
704
733
|
}
|
|
@@ -28,6 +28,18 @@ export declare function earlyLogHint(platform: ServicePlatform, s: {
|
|
|
28
28
|
}): string;
|
|
29
29
|
/** 看护进程的日志(Windows):serve 退了几次、起来之前的输出 */
|
|
30
30
|
export declare const KEEPER_LOG = "keeper.log";
|
|
31
|
+
/**
|
|
32
|
+
* service install 之后开不开家长端:只有这个孩子第一次装时开,下一步就是填百炼、扫码。
|
|
33
|
+
* 别的启动都不开:登录、崩溃重起、升级、重启都不是人要看页面;放进 serve 的话,一直崩就隔 10 秒开一个标签页。
|
|
34
|
+
* ssh 进来的不开:没人看,macOS 的 open 还会开到那台电脑的屏幕上。Linux 要有图形会话。--open 是人明说的,重装也开;--no-open 最大
|
|
35
|
+
*/
|
|
36
|
+
export declare function opensAfterInstall(o: {
|
|
37
|
+
firstTime: boolean;
|
|
38
|
+
open: boolean;
|
|
39
|
+
noOpen: boolean;
|
|
40
|
+
platform: NodeJS.Platform;
|
|
41
|
+
env: NodeJS.ProcessEnv;
|
|
42
|
+
}): boolean;
|
|
31
43
|
/**
|
|
32
44
|
* 看护进程(Windows;cotutor service run):起 serve,非 0 隔 10 秒再起,0 不起(端口被占着);同 launchd 的 KeepAlive、systemd 的 Restart=on-failure。
|
|
33
45
|
* 给 serve 带上 COTUTOR_SERVICE(mode 算后台服务、端口被占以 0 退);返回退出码
|
|
@@ -108,6 +120,8 @@ export interface Health {
|
|
|
108
120
|
calls?: number;
|
|
109
121
|
/** 百炼配了没有(托盘:没配算有事要处理) */
|
|
110
122
|
bailian?: boolean;
|
|
123
|
+
/** npm 上比在跑的这份新的版本;没有 → null(machine/update.ts) */
|
|
124
|
+
latest?: string | null;
|
|
111
125
|
configError?: string | null;
|
|
112
126
|
/** 问到的是 https 还是 http(这边加的,不是服务回的) */
|
|
113
127
|
scheme?: 'https' | 'http';
|
package/dist/app/cli/service.js
CHANGED
|
@@ -88,6 +88,20 @@ export function earlyLogHint(platform, s) {
|
|
|
88
88
|
export const KEEPER_LOG = 'keeper.log';
|
|
89
89
|
const KEEPER_LOG_MAX = 5 * 1024 * 1024;
|
|
90
90
|
const KEEPER_RESTART_MS = 10_000;
|
|
91
|
+
/**
|
|
92
|
+
* service install 之后开不开家长端:只有这个孩子第一次装时开,下一步就是填百炼、扫码。
|
|
93
|
+
* 别的启动都不开:登录、崩溃重起、升级、重启都不是人要看页面;放进 serve 的话,一直崩就隔 10 秒开一个标签页。
|
|
94
|
+
* ssh 进来的不开:没人看,macOS 的 open 还会开到那台电脑的屏幕上。Linux 要有图形会话。--open 是人明说的,重装也开;--no-open 最大
|
|
95
|
+
*/
|
|
96
|
+
export function opensAfterInstall(o) {
|
|
97
|
+
if (o.noOpen)
|
|
98
|
+
return false;
|
|
99
|
+
if (o.open)
|
|
100
|
+
return true;
|
|
101
|
+
if (!o.firstTime || o.env.SSH_CONNECTION || o.env.SSH_TTY)
|
|
102
|
+
return false;
|
|
103
|
+
return o.platform !== 'linux' || Boolean(o.env.DISPLAY || o.env.WAYLAND_DISPLAY);
|
|
104
|
+
}
|
|
91
105
|
/**
|
|
92
106
|
* 看护进程(Windows;cotutor service run):起 serve,非 0 隔 10 秒再起,0 不起(端口被占着);同 launchd 的 KeepAlive、systemd 的 Restart=on-failure。
|
|
93
107
|
* 给 serve 带上 COTUTOR_SERVICE(mode 算后台服务、端口被占以 0 退);返回退出码
|
package/dist/app/cli/tray-run.js
CHANGED
|
@@ -6,7 +6,16 @@ import { mkdirSync, readFileSync } from 'node:fs';
|
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
7
7
|
import { realHost } from "./service.js";
|
|
8
8
|
import { realTrayDeps, runTray, trayDir, trayNote, traySupported, writeTrayFiles } from "./tray.js";
|
|
9
|
-
const
|
|
9
|
+
const readVersion = () => JSON.parse(readFileSync(fileURLToPath(new URL('../../../package.json', import.meta.url)), 'utf8')).version;
|
|
10
|
+
let installed = readVersion();
|
|
11
|
+
/** 每轮重读:npm 升级时这个进程一直开着,只记起来那一刻的就会误报。装到一半读不到就用上次的 */
|
|
12
|
+
const version = () => {
|
|
13
|
+
try {
|
|
14
|
+
installed = readVersion();
|
|
15
|
+
}
|
|
16
|
+
catch { /* 下一轮再读 */ }
|
|
17
|
+
return installed;
|
|
18
|
+
};
|
|
10
19
|
export async function main(argv) {
|
|
11
20
|
const host = realHost();
|
|
12
21
|
if (!traySupported(host.platform)) {
|
|
@@ -27,7 +36,7 @@ export async function main(argv) {
|
|
|
27
36
|
process.on('unhandledRejection', fatal('没接住的 Promise 错误'));
|
|
28
37
|
const file = await writeTrayFiles(host.platform, dir);
|
|
29
38
|
note(`起来了,node ${process.versions.node},pid ${process.pid}`);
|
|
30
|
-
const code = await runTray(realTrayDeps(host, dir, file, version
|
|
39
|
+
const code = await runTray(realTrayDeps(host, dir, file, version));
|
|
31
40
|
note(`退出 ${code}`);
|
|
32
41
|
process.exit(code);
|
|
33
42
|
}
|
package/dist/app/cli/tray.d.ts
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 托盘图标(macOS 菜单栏、Windows 任务栏;Linux 没做,GNOME 缺省不显示托盘):家长不开终端也看得到服务好不好、点得开家长端。
|
|
3
|
+
* 三层:
|
|
4
|
+
* node 进程(cotutor tray run,tray-run.ts 是它的轻入口):隔几秒问每个后台服务的 /api/health,判断状态,决定显示什么、要不要通知;这里全是能测的 TS。
|
|
5
|
+
* 显示脚本(tray-mac.ts 的 JXA、tray-windows.ts 的 PowerShell,都是系统自带、不用签名):只管画和回报点击,不判断。
|
|
6
|
+
* 登录项:macOS 一个 LaunchAgent,Windows 一条 Run 项 + wscript 隐藏启动器;service install 时一起装(--no-tray 不装,cotutor tray off 关掉并记住)。
|
|
7
|
+
* 为什么不放在 serve 里:服务挂了图标也没了,就说不出「没在跑」;多个孩子时一台电脑也只该有一个图标。
|
|
8
|
+
* 为什么不用 npm 的托盘库、Electron:见 docs/wip;systray2 只有 x86 的 Mac 二进制且没签名,Electron 要多下一百多 MB。
|
|
9
|
+
* 状态:正常只有 logo(和菜单栏别的图标一样单色);有事才出状态点,橙 = 要处理,红 = 没在跑(logo 同时变淡)。正常 → 有事、→ 没在跑时通知一次,恢复不通知。
|
|
10
|
+
* 「退出 cotutor」= 停掉所有后台服务并退出图标,下次登录自己回来;正开着电话时先问一声。
|
|
11
|
+
*/
|
|
12
|
+
import { type SpawnOptions } from 'node:child_process';
|
|
1
13
|
import { type Health, type InstalledService, type ServiceHost } from './service.ts';
|
|
2
14
|
import { HELPER_NO_SESSION, TRAY_AUMID, TRAY_LABEL, TRAY_RUN_VALUE } from './tray-ids.ts';
|
|
3
15
|
export { HELPER_NO_SESSION, TRAY_AUMID, TRAY_LABEL, TRAY_RUN_VALUE };
|
|
@@ -45,6 +57,8 @@ export interface TrayRow {
|
|
|
45
57
|
issues: number;
|
|
46
58
|
fix: TrayFix | null;
|
|
47
59
|
calls: number;
|
|
60
|
+
/** npm 上有比装的新的版本 → 版本号;不算有事,不出状态点 */
|
|
61
|
+
update: string | null;
|
|
48
62
|
}
|
|
49
63
|
export type TrayEntry = {
|
|
50
64
|
kind: 'info';
|
|
@@ -89,7 +103,7 @@ export declare class TrayModel {
|
|
|
89
103
|
private readonly o;
|
|
90
104
|
constructor(o: {
|
|
91
105
|
platform: NodeJS.Platform;
|
|
92
|
-
version: string;
|
|
106
|
+
version: () => string;
|
|
93
107
|
});
|
|
94
108
|
/** 刚点了启动、重启:这一阵问不到是正常的 */
|
|
95
109
|
expect(name: string, now: number): void;
|
|
@@ -100,6 +114,8 @@ export declare class TrayModel {
|
|
|
100
114
|
private healthy;
|
|
101
115
|
private placeholder;
|
|
102
116
|
}
|
|
117
|
+
/** 有新版本的通知:每个版本最多一次,记在托盘目录,重起、重新登录不再弹 */
|
|
118
|
+
export declare function updateNote(version: string, platform: NodeJS.Platform): TrayNote;
|
|
103
119
|
/** 这一轮显示什么 */
|
|
104
120
|
export declare function trayShow(rows: TrayRow[]): TrayShow;
|
|
105
121
|
export interface HelperMessage {
|
|
@@ -115,7 +131,11 @@ export interface TrayHelper {
|
|
|
115
131
|
}
|
|
116
132
|
export interface TrayDeps {
|
|
117
133
|
platform: NodeJS.Platform;
|
|
118
|
-
|
|
134
|
+
/** 现在装的是哪个版本:每轮问,程序可能刚被 npm 换了 */
|
|
135
|
+
version(): string;
|
|
136
|
+
/** 已经通知过的新版本 */
|
|
137
|
+
updateSeen(): string | null;
|
|
138
|
+
setUpdateSeen(version: string): void;
|
|
119
139
|
now(): number;
|
|
120
140
|
list(): Promise<InstalledService[]>;
|
|
121
141
|
/** 读 cotutor.json:孩子叫什么、哪个端口 */
|
|
@@ -155,7 +175,16 @@ export declare function spawnTrayHelper(platform: NodeJS.Platform, file: string,
|
|
|
155
175
|
message(m: HelperMessage): void;
|
|
156
176
|
exit(code: number | null): void;
|
|
157
177
|
}): TrayHelper;
|
|
158
|
-
/**
|
|
178
|
+
/**
|
|
179
|
+
* 打开地址或目录用什么命令。windowsHide 不弹黑窗口。
|
|
180
|
+
* detached 不能省:Windows 上非 detached 的子进程在「父进程退出就杀」的 job 里(libuv),cotutor open、service install 起完马上退,cmd 还没把地址交给浏览器就被一起杀了。
|
|
181
|
+
* 退出码是 0、地址也打印了,就是没打开,也不报错(2026-11-07 真机);托盘进程一直开着,所以光点图标看不出来。
|
|
182
|
+
*/
|
|
183
|
+
export declare function openCommand(platform: NodeJS.Platform, target: string): {
|
|
184
|
+
cmd: string;
|
|
185
|
+
args: string[];
|
|
186
|
+
options: SpawnOptions;
|
|
187
|
+
};
|
|
159
188
|
export declare function openTarget(platform: NodeJS.Platform, target: string): void;
|
|
160
189
|
/** 日志:一个文件,过 1 MB 换一次,只留上一份 */
|
|
161
190
|
export declare function trayNote(dir: string): (text: string) => void;
|
|
@@ -165,7 +194,7 @@ export declare function inspectService(s: InstalledService): Promise<{
|
|
|
165
194
|
port: number;
|
|
166
195
|
}>;
|
|
167
196
|
/** 真的依赖,给 runTray */
|
|
168
|
-
export declare function realTrayDeps(host: ServiceHost, dir: string, file: string, version: string): TrayDeps;
|
|
197
|
+
export declare function realTrayDeps(host: ServiceHost, dir: string, file: string, version: () => string): TrayDeps;
|
|
169
198
|
/** 登录项跑的命令 */
|
|
170
199
|
export declare const trayArgs: (host: HostPaths, o?: {
|
|
171
200
|
node?: string;
|
|
@@ -201,5 +230,7 @@ export declare function ensureTray(host: ServiceHost, o?: {
|
|
|
201
230
|
node?: string;
|
|
202
231
|
bin?: string;
|
|
203
232
|
}): Promise<'unsupported' | 'off' | 'started' | 'later'>;
|
|
233
|
+
/** service restart 时:图标进程也换上新代码(升级后跑的就是这条)。没开图标的不动,不能把 --no-tray 的装回来 */
|
|
234
|
+
export declare function refreshTray(host: ServiceHost): Promise<boolean>;
|
|
204
235
|
/** 服务卸完了:图标也撤(不算家长说不要,以后再装服务还会带上) */
|
|
205
236
|
export declare function dropTrayIfNoServices(host: ServiceHost): Promise<boolean>;
|
package/dist/app/cli/tray.js
CHANGED
|
@@ -10,13 +10,14 @@
|
|
|
10
10
|
* 「退出 cotutor」= 停掉所有后台服务并退出图标,下次登录自己回来;正开着电话时先问一声。
|
|
11
11
|
*/
|
|
12
12
|
import { spawn } from 'node:child_process';
|
|
13
|
-
import { appendFileSync, existsSync, renameSync, statSync } from 'node:fs';
|
|
13
|
+
import { appendFileSync, existsSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
|
|
14
14
|
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
15
15
|
import { join } from 'node:path';
|
|
16
16
|
import { createInterface } from 'node:readline';
|
|
17
17
|
import { DEFAULT_PORT } from "../../shared/default-port.js";
|
|
18
18
|
import { UsageError } from "../../shared/errors.js";
|
|
19
19
|
import { siteUrl } from "../../shared/site-url.js";
|
|
20
|
+
import { isNewer } from "../../shared/upgrade.js";
|
|
20
21
|
import { portHolder } from "./port-holder.js";
|
|
21
22
|
import { RUN_KEY, STARTUP_APPROVED_KEY, encodeLauncher, parseProcs, psArgs, runValue, startupDisabled, winCommandLine } from "./service-windows.js";
|
|
22
23
|
import { binPath, controlService, listServices, parseLaunchctlPrint, probeHealth, wscriptPath, xml } from "./service.js";
|
|
@@ -73,6 +74,7 @@ const rank = (l) => (l === 'bad' ? 2 : l === 'warn' ? 1 : 0);
|
|
|
73
74
|
export class TrayModel {
|
|
74
75
|
mem = new Map();
|
|
75
76
|
last = 0;
|
|
77
|
+
// 版本每轮问:npm 升级时这个进程一直开着,记死了就把「都对了」报成「要重启」,把真要重启的放过去
|
|
76
78
|
o;
|
|
77
79
|
constructor(o) { this.o = o; }
|
|
78
80
|
/** 刚点了启动、重启:这一阵问不到是正常的 */
|
|
@@ -128,7 +130,8 @@ export class TrayModel {
|
|
|
128
130
|
}
|
|
129
131
|
healthy(p, now) {
|
|
130
132
|
const h = p.health;
|
|
131
|
-
const
|
|
133
|
+
const installed = this.o.version();
|
|
134
|
+
const issues = issuesOf(p, { platform: this.o.platform, version: installed });
|
|
132
135
|
const started = Date.parse(h.started);
|
|
133
136
|
const running = h.mode === 'foreground' ? '在终端里跑,关了终端就停' : `已运行 ${uptimeText(Number.isNaN(started) ? now : started, now)}`;
|
|
134
137
|
return {
|
|
@@ -141,10 +144,12 @@ export class TrayModel {
|
|
|
141
144
|
issues: issues.length,
|
|
142
145
|
fix: issues[0]?.fix ?? null,
|
|
143
146
|
calls: h.calls ?? 0,
|
|
147
|
+
// 和装的比,不只和在跑的比:程序已经换上新版、只差重启时,再说「有新版本」就错了
|
|
148
|
+
update: isNewer(h.latest, installed) ? h.latest : null,
|
|
144
149
|
};
|
|
145
150
|
}
|
|
146
151
|
placeholder(p, level, detail) {
|
|
147
|
-
return { name: p.name, kid: p.kid, logDir: p.logDir, level, url: null, detail, issues: 0, fix: null, calls: 0 };
|
|
152
|
+
return { name: p.name, kid: p.kid, logDir: p.logDir, level, url: null, detail, issues: 0, fix: null, calls: 0, update: null };
|
|
148
153
|
}
|
|
149
154
|
}
|
|
150
155
|
const titleOf = (r, multi) => {
|
|
@@ -152,9 +157,14 @@ const titleOf = (r, multi) => {
|
|
|
152
157
|
return r.level === 'ok' ? 'cotutor 正常' : r.level === 'warn' ? `有 ${r.issues} 件事要处理` : r.level === 'bad' ? '后台服务没在跑' : 'cotutor 正在启动';
|
|
153
158
|
return `${r.kid} · ${r.level === 'ok' ? '正常' : r.level === 'warn' ? `有 ${r.issues} 件事要处理` : r.level === 'bad' ? '没在跑' : '正在启动'}`;
|
|
154
159
|
};
|
|
160
|
+
// 点通知回不到这里(Mac 的来源是系统的脚本程序;Windows 的 PowerShell 5.1 接不了 WinRT 的事件),只能请家长去点图标
|
|
161
|
+
const iconHint = (platform) => (platform === 'darwin' ? '点菜单栏的 cotutor 图标看看' : '点任务栏的 cotutor 图标看看');
|
|
162
|
+
/** 有新版本的通知:每个版本最多一次,记在托盘目录,重起、重新登录不再弹 */
|
|
163
|
+
export function updateNote(version, platform) {
|
|
164
|
+
return { type: 'notify', title: `cotutor 有新版本 ${version}`, body: iconHint(platform) };
|
|
165
|
+
}
|
|
155
166
|
function noteFor(r, multi, platform) {
|
|
156
|
-
|
|
157
|
-
const hint = platform === 'darwin' ? '点菜单栏的 cotutor 图标看看' : '点任务栏的 cotutor 图标看看';
|
|
167
|
+
const hint = iconHint(platform);
|
|
158
168
|
const who = multi ? r.kid : 'cotutor ';
|
|
159
169
|
if (r.level === 'bad')
|
|
160
170
|
return { type: 'notify', title: `${who}${multi ? '的' : ''}后台服务没在跑`, body: `孩子的平板现在打不开,${hint}` };
|
|
@@ -190,6 +200,12 @@ export function trayShow(rows) {
|
|
|
190
200
|
act('logs', '看日志');
|
|
191
201
|
menu.push({ kind: 'sep' });
|
|
192
202
|
}
|
|
203
|
+
// 版本是这台电脑的事,几个孩子也只说一行;点了去家长端,升级的命令写在那里
|
|
204
|
+
const newer = rows.find((r) => r.update);
|
|
205
|
+
if (newer) {
|
|
206
|
+
menu.push({ kind: 'action', id: `update:${newer.name}`, title: `有新版本 ${newer.update},看怎么升级` });
|
|
207
|
+
menu.push({ kind: 'sep' });
|
|
208
|
+
}
|
|
193
209
|
menu.push({ kind: 'action', id: 'quit', title: '退出 cotutor' });
|
|
194
210
|
return { type: 'show', icon: level === 'starting' ? 'ok' : level, tip: worst ? titleOf(worst, multi) : 'cotutor', menu };
|
|
195
211
|
}
|
|
@@ -269,7 +285,7 @@ export function runTray(d) {
|
|
|
269
285
|
const r = rows.find((x) => x.name === id.slice(at + 1));
|
|
270
286
|
if (!r)
|
|
271
287
|
return;
|
|
272
|
-
if (what === 'open' && r.url)
|
|
288
|
+
if ((what === 'open' || what === 'update') && r.url)
|
|
273
289
|
d.open(r.url);
|
|
274
290
|
else if (what === 'logs' && r.logDir)
|
|
275
291
|
d.open(r.logDir);
|
|
@@ -368,6 +384,13 @@ export function runTray(d) {
|
|
|
368
384
|
helper?.send(n);
|
|
369
385
|
d.note(`通知:${n.title}`);
|
|
370
386
|
}
|
|
387
|
+
const newer = rows.find((x) => x.update)?.update;
|
|
388
|
+
if (newer && helper && newer !== d.updateSeen()) {
|
|
389
|
+
const n = updateNote(newer, d.platform);
|
|
390
|
+
helper.send(n);
|
|
391
|
+
d.setUpdateSeen(newer);
|
|
392
|
+
d.note(`通知:${n.title}`);
|
|
393
|
+
}
|
|
371
394
|
}
|
|
372
395
|
catch (err) {
|
|
373
396
|
d.note(`这一轮出错:${String(err)}`);
|
|
@@ -446,10 +469,18 @@ export function spawnTrayHelper(platform, file, dir, note, on) {
|
|
|
446
469
|
close() { child.stdin?.end(); },
|
|
447
470
|
};
|
|
448
471
|
}
|
|
449
|
-
/**
|
|
450
|
-
|
|
472
|
+
/**
|
|
473
|
+
* 打开地址或目录用什么命令。windowsHide 不弹黑窗口。
|
|
474
|
+
* detached 不能省:Windows 上非 detached 的子进程在「父进程退出就杀」的 job 里(libuv),cotutor open、service install 起完马上退,cmd 还没把地址交给浏览器就被一起杀了。
|
|
475
|
+
* 退出码是 0、地址也打印了,就是没打开,也不报错(2026-11-07 真机);托盘进程一直开着,所以光点图标看不出来。
|
|
476
|
+
*/
|
|
477
|
+
export function openCommand(platform, target) {
|
|
451
478
|
const [cmd, args] = platform === 'darwin' ? ['/usr/bin/open', [target]] : platform === 'win32' ? ['cmd', ['/c', 'start', '', target]] : ['xdg-open', [target]];
|
|
452
|
-
|
|
479
|
+
return { cmd: cmd, args: args, options: { stdio: 'ignore', windowsHide: true, detached: true } };
|
|
480
|
+
}
|
|
481
|
+
export function openTarget(platform, target) {
|
|
482
|
+
const { cmd, args, options } = openCommand(platform, target);
|
|
483
|
+
const child = spawn(cmd, args, options);
|
|
453
484
|
child.on('error', () => undefined);
|
|
454
485
|
child.unref();
|
|
455
486
|
}
|
|
@@ -482,9 +513,21 @@ export async function inspectService(s) {
|
|
|
482
513
|
/** 真的依赖,给 runTray */
|
|
483
514
|
export function realTrayDeps(host, dir, file, version) {
|
|
484
515
|
const note = trayNote(dir);
|
|
516
|
+
const seenFile = join(dir, 'update-seen');
|
|
485
517
|
return {
|
|
486
518
|
platform: host.platform,
|
|
487
519
|
version,
|
|
520
|
+
updateSeen: () => { try {
|
|
521
|
+
return readFileSync(seenFile, 'utf8').trim() || null;
|
|
522
|
+
}
|
|
523
|
+
catch {
|
|
524
|
+
return null;
|
|
525
|
+
} },
|
|
526
|
+
// 写不了就当没记住:最坏多弹一次
|
|
527
|
+
setUpdateSeen: (v) => { try {
|
|
528
|
+
writeFileSync(seenFile, `${v}\n`);
|
|
529
|
+
}
|
|
530
|
+
catch { /* 见上 */ } },
|
|
488
531
|
note,
|
|
489
532
|
now: () => Date.now(),
|
|
490
533
|
list: () => listServices(host),
|
|
@@ -608,6 +651,15 @@ export async function ensureTray(host, o = {}) {
|
|
|
608
651
|
return 'off';
|
|
609
652
|
return (await installTray(host, o)).started ? 'started' : 'later';
|
|
610
653
|
}
|
|
654
|
+
/** service restart 时:图标进程也换上新代码(升级后跑的就是这条)。没开图标的不动,不能把 --no-tray 的装回来 */
|
|
655
|
+
export async function refreshTray(host) {
|
|
656
|
+
if (!traySupported(host.platform))
|
|
657
|
+
return false;
|
|
658
|
+
if (!(await trayStatus(host)).installed)
|
|
659
|
+
return false;
|
|
660
|
+
await installTray(host);
|
|
661
|
+
return true;
|
|
662
|
+
}
|
|
611
663
|
/** 服务卸完了:图标也撤(不算家长说不要,以后再装服务还会带上) */
|
|
612
664
|
export async function dropTrayIfNoServices(host) {
|
|
613
665
|
if (!traySupported(host.platform))
|
package/dist/app/cli/usage.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** 命令用法(cotutor --help;技能 cotutor-analyze 的 references/命令与文件.md 从这里取分析用的那几行,所以放单独文件,别的模块 import 不会跑到 CLI) */
|
|
2
|
-
export declare const USAGE = "\u7528\u6CD5:\n cotutor init <slug> [--dir <path>] [--name <\u5B69\u5B50\u540D>] [--grade <1-6>] [--port <n>] \u5EFA ~/cotutor/<slug>/ \u9AA8\u67B6(\u5E42\u7B49\u8865\u7F3A;\u9ED8\u8BA4\u4E8C\u5E74\u7EA7\u6559\u5B66\u5305;\u7AEF\u53E3\u5728\u8FD9\u91CC\u9009\u4E00\u6B21\u5199\u8FDB cotutor.json:443 \u4F18\u5148,\u4E0D\u884C\u5C31 8443\u30018445\u2026,\u4EE5\u540E\u4E0D\u53D8)\n cotutor grades [--json] \u5217\u51FA\u672C\u5305\u53EF\u5B89\u88C5\u7684\u5E74\u7EA7\u6559\u5B66\u5305\n cotutor doctor [--workspace <dir>] [--json] [--live] \u9010\u9879\u4F53\u68C0;--live \u771F\u8FDE\u4E00\u6B21\u767E\u70BC\u5B9E\u65F6\u6A21\u578B\u3001\u771F\u5408\u6210\u4E00\u53E5\u914D\u97F3(\u82B1\u4E00\u5206\u94B1)\u628A API \u5C42\u7684\u5751\u6446\u51FA\u6765(\u771F\u8DD1\u8001\u5E08\u7528 cotutor send)\n cotutor upgrade [--workspace <dir>] [--force <\u8001\u5E08>]... \u8001\u5E08\u6587\u4EF6\u3001activity \u4E0E skill \u6362\u65B0\u7248:\u6CA1\u6539\u8FC7\u7684\u76F4\u63A5\u6362,\u6539\u8FC7\u7684\u53EA\u62A5 diff(--force <\u8001\u5E08> \u6216 --force <\u8001\u5E08>/<\u540D> \u624D\u8986\u76D6,\u539F\u6587\u7559 .bak)\n cotutor upgrade --config [--dry-run] [--workspace <dir>] cotutor.json \u8865\u7F3A:\u65B0\u51FA\u5382\u8001\u5E08 / \u8FD0\u884C\u65F6 / \u547D\u4EE4\u6A21\u677F\u65D7\u6807(\u53EA\u52A0\u7F3A\u7684,\u4F60\u6539\u8FC7\u7684\u503C\u4E0D\u52A8)\n cotutor add <\u8001\u5E08\u540D> --display <\u663E\u793A\u540D> [--subject <\u5B66\u79D1>] [--avatar <emoji|\u56FE\u7247\u76F8\u5BF9\u8DEF\u5F84>] [--hidden] \u52A0\u4E00\u4F4D\u81EA\u5BB6\u7684\u8001\u5E08:\u51FA\u6A21\u677F\u6587\u4EF6\u3001\u8FDB cotutor.json\u3001\u5EFA\u76EE\u5F55(\u540D\u5B57\u4EE5 -tutor \u7ED3\u5C3E\u7684\u662F\u8001\u5E08,figshot \u7ED9\u5B83\u505A\u5934\u50CF)\n cotutor add-theme <\u4E3B\u9898\u540D> [--from <\u4E3B\u9898>] [--workspace <dir>] \u52A0\u4E00\u4E2A\u81EA\u5BB6\u7684\u4E3B\u9898:\u62F7\u4E00\u4EFD(\u7F3A\u7701\u51FA\u5382\u7684 default)\u5230 themes/<\u4E3B\u9898\u540D>/,\u6539 cotutor.json \u7684 kid.theme \u6362\u8FC7\u53BB\n cotutor serve [--workspace <dir>] [--port <n>] [--http] [--trace] [--open] [--log-dir <\u76EE\u5F55>] \u5728\u7EC8\u7AEF\u91CC\u8D77\u670D\u52A1(\u4E00 workspace \u4E00\u8FDB\u7A0B;~/.config/cotutor/certs/ \u6709\u8BC1\u4E66\u5C31\u8D70 HTTPS;--trace \u6BCF\u4E00\u8F6E\u7684\u4E8B\u4EF6\u6309\u9053\u6253\u5370;\u53EA\u6253\u5370\u5BB6\u957F\u7AEF\u9996\u9875\u7684\u5730\u5740,\u5B69\u5B50\u7684\u5E73\u677F\u5728\u90A3\u91CC\u626B\u7801;--open \u987A\u624B\u6253\u5F00,\u65E7\u540D --open-qr \u7167\u6837\u8BA4;--log-dir \u8F93\u51FA\u6309\u5929\u843D\u6587\u4EF6,\u540E\u53F0\u670D\u52A1\u7528;\u5173\u4E86\u7EC8\u7AEF\u5C31\u505C,\u4E00\u76F4\u5F00\u7740\u7528 cotutor service)\n cotutor open [--workspace <dir>] [--sandbox] [--print] [--json] \u6253\u5F00\u5BB6\u957F\u7AEF(\u8BFB cotutor.json,\u4E0D\u7528\u8BB0\u7AEF\u53E3);--sandbox \u5F00\u8BD5\u7528\u7684\u90A3\u4E2A(\u7AEF\u53E3\u6C38\u8FDC\u662F\u771F\u7684 +1);--print \u53EA\u6253\u5370\u5730\u5740;\u6CA1\u5728\u8DD1\u5C31\u8BF4\u600E\u4E48\u8D77,\u4EE5 1 \u9000\n cotutor service install | uninstall | start | stop | restart [--workspace <dir>] [--no-tray] \u628A serve \u88C5\u6210\u8FD9\u53F0\u7535\u8111\u7684\u540E\u53F0\u670D\u52A1(macOS launchd\u3001Linux systemd \u7528\u6237\u670D\u52A1\u3001Windows \u7528\u6237\u7684\u767B\u5F55\u9879,\u90FD\u4E0D\u8981\u7BA1\u7406\u5458):\u767B\u5F55\u540E\u81EA\u5DF1\u8D77\u6765\u3001\u5D29\u4E86 10 \u79D2\u540E\u518D\u8D77\u3001\u65E5\u5FD7\u6309\u5929\u843D\u6587\u4EF6\u7559 14 \u5929;\u4E00\u4E2A workspace \u4E00\u4E2A;\u6362\u4E86 node \u7248\u672C\u6216\u5305\u632A\u4E86\u5730\u65B9\u518D install \u4E00\u6B21\n cotutor tray on | off | status [--json] \u83DC\u5355\u680F / \u4EFB\u52A1\u680F\u56FE\u6807(macOS\u3001Windows;service install \u65F6\u4E00\u8D77\u88C5,--no-tray \u4E0D\u5E26):\u6B63\u5E38\u65F6\u53EA\u6709 logo,\u6709\u4E8B\u51FA\u6A59\u70B9,\u6CA1\u5728\u8DD1\u65F6\u53D8\u6DE1\u52A0\u7EA2\u70B9\u5E76\u901A\u77E5\u4E00\u6B21;\u70B9\u5B83\u770B\u72B6\u6001\u3001\u6253\u5F00\u5BB6\u957F\u7AEF\u3001\u91CD\u542F\u3001\u770B\u65E5\u5FD7\u3001\u9000\u51FA;off \u5173\u6389\u5E76\u8BB0\u4F4F\n cotutor service status [--json] | logs [--lines <n>] [--follow] \u540E\u53F0\u670D\u52A1\u5728\u4E0D\u5728\u8DD1\u3001\u7AEF\u53E3\u7B54\u4E0D\u7B54\u5E94\u3001\u8DD1\u7684\u54EA\u4E2A\u7248\u672C\u3001vault \u8BFB\u4E0D\u8BFB\u5F97\u5230\u3001\u65E5\u5FD7\u5728\u54EA;logs \u770B\u6700\u8FD1\u7684\u65E5\u5FD7\n cotutor pin [off | status] [--pin <\u6570\u5B57>] [--json] \u5BB6\u957F\u7AEF /parent \u4E0E\u5DE5\u4F5C\u53F0 /dev \u7684\u9501:\u673A\u5668\u7EA7 PIN(\u6240\u6709 workspace \u5171\u7528),iPad \u4E0A\u8F93\u4E00\u6B21\u3001\u8FD9\u53F0\u7535\u8111\u514D\u5BC6;\u6CA1\u8BBE\u5C31\u5168\u5F00(\u5BB6\u957F\u5728\u65C1\u8FB9\u53EF\u4EE5\u4E0D\u8BBE);off \u64A4\u6389\n cotutor cert [--host <\u540D\u6216IP>]... \u7528 mkcert \u5EFA\u8FD9\u53F0\u673A\u5668\u7684\u81EA\u7B7E\u8BC1\u4E66\u5230 ~/.config/cotutor/certs/(\u5E73\u677F\u3001\u624B\u673A\u4E0A\u5F55\u97F3\u8981 HTTPS;\u6240\u6709 workspace \u5171\u7528)\n cotutor send <\u8001\u5E08> [<\u6D88\u606F>] [--from kid|system] [--runtime <\u540D>] [--new] [--photo a.jpg,b.png] [--activity <\u540D> [--param '\u540D=\u503C;\u540D=\u503C']] [--lane main,tts] [--quiet] \u7EC8\u7AEF\u91CC\u53D1\u4E00\u6761,\u73B0\u573A\u6309\u9053\u6253\u5370\u6BCF\u9053\u5DE5\u5E8F\u7684\u4E8B\u4EF6,\u8BF4\u5B8C\u6253\u5370\u7ED3\u679C(\u5B69\u5B50\u7684\u8BDD\u7167\u5B9E\u65F6\u5199,\u540C\u5B69\u5B50\u7AEF:\u4E0A\u4E0B\u6587\u5305\u5E26 live:\u3001\u4E0D\u8865\u63D0\u95EE\u5361,\u53EA\u662F\u6CA1\u6709\u5634;--from system \u4E0D\u7B97;--new \u5F00\u65B0\u8BDD\u9898;--photo \u5E26\u4F5C\u4E1A\u7167\u7247,\u62F7\u8FDB captures/,\u6709\u7167\u7247\u65F6\u6D88\u606F\u53EF\u4EE5\u7A7A;--activity \u50CF\u5B69\u5B50\u70B9\u4E86\u9996\u9875\u7684 activity \u6309\u94AE,\u65B0\u5F00\u8BDD\u9898,\u53C2\u6570\u540C\u6309\u94AE\u4E0B\u9762\u90A3\u51E0\u884C;--quiet \u53EA\u8981\u7ED3\u679C)\n cotutor pack <\u8001\u5E08> [<\u6D88\u606F>] [--from kid|system] [--at <ISO\u65F6\u95F4>] [--activity <\u540D> [--param '\u540D=\u503C;\u540D=\u503C']] [--json] \u5E72\u8DD1\u4E0A\u4E0B\u6587\u5305(--activity \u770B activity \u8BDD\u9898\u7B2C\u4E00\u6761\u8001\u5E08\u62FF\u5230\u4EC0\u4E48):\u4E0D\u8D77\u6A21\u578B,\u6253\u5370\u73B0\u5728\u4F1A\u53D1\u7ED9\u8001\u5E08\u7684\u90A3\u4EFD + \u6BCF\u6BB5\u6765\u81EA\u54EA\u4E2A\u6587\u4EF6\u3001\u90A3\u91CC\u4E00\u5171\u51E0\u6761\u3001\u6309\u653F\u7B56\u5E26\u4E86\u51E0\u6761(\u6539\u4E86\u6863\u6848 / \u65E5\u8BB0 / \u8BA1\u5212\u7ACB\u523B\u770B\u6548\u679C)\n cotutor replay <\u8001\u5E08> <job> [<\u65E5\u671F>] [--runtime <\u540D>] [--json] \u56DE\u653E\u4E00\u8F6E:\u540C\u4E00\u95EE\u6309\u73B0\u5728\u7684 vault \u4E0E\u63D0\u793A\u8BCD\u518D\u8DD1\u4E00\u904D(\u843D evals/,\u4E0D\u8FDB\u5B69\u5B50\u7684\u5BF9\u8BDD\u3001\u4E0D\u914D\u97F3),\u8DD1\u5B8C\u5E76\u6392\u6253\u5370\u4E0A\u4E0B\u6587\u5305 / \u8BB2\u7A3F / \u5361 / \u8BFB\u4E86\u4EC0\u4E48\u7684 diff\n cotutor compare <\u8001\u5E08> [<evalJob>] [<\u65E5\u671F>] [--json] \u518D\u770B\u4E00\u6B21\u56DE\u653E\u7684\u5BF9\u7167(\u4E0D\u7ED9 evalJob \u5C31\u5217\u8FD9\u5929\u56DE\u653E\u8FC7\u54EA\u4E9B)\n cotutor show <\u8001\u5E08> <job> [<\u65E5\u671F>] [--json] [--evals] [--workspace <dir>] \u770B\u4E00\u8F6E:\u95EE\u4E86\u4EC0\u4E48\u3001\u4E0A\u4E0B\u6587\u5305\u3001\u5F53\u65F6\u7684\u8001\u5E08\u6587\u4EF6\u4E0E\u6280\u80FD hash\u3001\u8BB2\u7A3F\u4E0E\u5361\u3001\u8BFB\u4E86\u4EC0\u4E48\u3001token \u4E0E\u7528\u65F6\u3001\u7ED9\u5BB6\u957F\u7684\u5C3E\u5DF4;--json \u662F\u5BB6\u957F\u7AEF\u300C\u770B\u539F\u6587\u300D\u540C\u4E00\u4EFD\u6570\u636E,\u7ED9\u7F16\u7A0B\u52A9\u624B\u5206\u6790\u7528\n cotutor trace <\u8001\u5E08> <job> [<\u65E5\u671F>] [--lane \u2026] [--workspace <dir>] \u56DE\u653E\u4E00\u8F6E\u7684\u4E8B\u4EF6(<\u65E5\u671F>.<job>.events.jsonl;\u6392\u67E5\u6628\u5929\u90A3\u8F6E\u7528)\n cotutor rate <\u8001\u5E08> <\u8BDD\u9898> <1-5> [--date <\u65E5\u671F>] \u7ED9\u4E00\u4E2A\u8BDD\u9898\u6253\u661F(\u4E0E\u5BB6\u957F\u7AEF\u540C\u4E00\u6761\u8DEF;\u2265 vault.keepScore \u7684\u8BDD\u9898\u8BB0\u8D26\u65F6\u6458\u8981\u624D\u8FDB\u65E5\u8BB0)\n cotutor bookkeep <\u8001\u5E08> [--date <\u65E5\u671F>] [--thread <\u8BDD\u9898>]... \u8BB0\u8D26:\u8FD9\u5929\u6BCF\u4E2A\u8FD8\u6CA1\u8BB0\u8FC7\u7684\u8BDD\u9898\u5404\u8D77\u4E00\u8F6E\u8BB0\u8D26\u4EFB\u52A1,\u8001\u5E08\u56DE\u300C## \u8BB0\u8D26\u300D,\u5E94\u7528\u5199\u8FDB vault \u7684\u65E5\u8BB0(\u8BDD\u9898\u540D\u3001\u5B69\u5B50\u95EE\u3001\u6458\u8981\u3001\u89C2\u5BDF)\n cotutor home check [--published] [--json] \u68C0\u67E5\u9996\u9875\u8349\u7A3F home/draft.md(\u6216\u5DF2\u53D1\u5E03\u7684\u90A3\u4EFD):\u5B69\u5B50\u4F1A\u770B\u5230\u54EA\u51E0\u5F20\u5361\u3001\u6BCF\u4F4D\u8001\u5E08\u7684\u6309\u94AE\u3001\u8981\u6539\u7684\u4E0E\u63D0\u9192(\u5E26\u884C\u53F7);\u4E0D\u82B1\u94B1\n cotutor home publish [--force] [--from <\u6587\u4EF6>] [--json] \u53D1\u5E03\u9996\u9875:\u68C0\u67E5 \u2192 \u7559\u5386\u53F2 home/history/<id>.md \u2192 \u5199 home/published.json(\u5B69\u5B50\u7AEF\u53EA\u8BFB\u5B83);\u6709\u8981\u6539\u7684\u4E0D\u53D1,--force \u4E22\u6389\u90A3\u51E0\u5F20\u7167\u53D1;--from \u53D1\u53E6\u4E00\u4EFD(\u5982\u5386\u53F2)\n cotutor home show [--json] \u73B0\u5728\u53D1\u5E03\u7684\u662F\u54EA\u4EFD\u3001\u51E0\u5929\u524D\u3001\u6BCF\u4E2A\u6309\u94AE\u88AB\u70B9\u4E86\u51E0\u6B21(\u54EA\u5929\u3001\u54EA\u4E2A\u8BDD\u9898)\n cotutor activity check [<\u8001\u5E08> [<\u540D>]] [--json] \u68C0\u67E5 activity \u6587\u4EF6(agents/<\u8001\u5E08>/activities/<\u540D>.md):\u5BB6\u957F\u586B\u7684\u3001\u5E94\u7528\u7B97\u7684\u3001\u5E94\u7528\u62E6\u7684\u3001\u88C1\u6BB5\u3001\u63D2\u69FD\u3001\u6B63\u6587\u7528\u7684\u5361\u3001\u7B2C\u4E00\u53E5\u4E0E\u6536(\u5E26\u884C\u53F7);\u4E0D\u82B1\u94B1\n cotutor activity eval <\u8001\u5E08> <\u540D> [--runs <n>] [--only <\u4F8B\u5B50>] [--kid-model <\u5343\u95EE\u6A21\u578B>] [--json] \u7167 <\u540D>.\u4F8B\u5B50.md \u8DD1:\u771F\u8001\u5E08 + \u6A21\u62DF\u5B69\u5B50(\u5343\u95EE,\u7F3A\u7701 qwen3.8-flash,\u6709 Token Plan \u8D70\u5B83)\u591A\u8F6E(\u7167\u5B9E\u65F6\u5199,\u540C\u5B69\u5B50\u7AEF),\u5224\u53EA\u770B\u677F\u4E66;\u4E00\u4E2A\u4F8B\u5B50\u8DD1 n \u6B21(\u7F3A\u7701 3)\u6B21\u6B21\u90FD\u8FC7\u624D\u7B97\u8FC7;\u843D evals/,\u753B\u5BB6\u662F\u5047\u7684;\u82B1\u94B1\n cotutor chalk <check|build|bake|snap|dub> <scene.ts|\u8BFE\u5305 id> [...] \u505A\u5C0F\u8BFE\u5802\u7684\u8BFE\u5305:check \u6821\u9A8C\u573A\u666F\u3001build \u51FA\u8BFE\u5305\u5230 bundles/\u3001bake \u70E4\u753B\u9762\u3001snap \u6BCF\u6B65\u622A\u56FE\u3001dub \u914D\u97F3(cotutor chalk \u770B\u5404\u81EA\u7684\u9009\u9879;bake / snap \u8981\u672C\u673A\u7684 Chrome)\n cotutor mock [--port <n>] [--scenario normal|nokey] [--delay <ms>] [--http] \u4E0D\u7ECF\u771F\u5B9E\u8001\u5E08\u4E0E\u767E\u70BC,\u7528\u56FA\u5B9A\u7684\u677F\u4E66 JSON \u8D77\u5B69\u5B50\u7AEF,\u6D4B\u524D\u7AEF\u4EA4\u4E92\u4E0E\u6E32\u67D3(\u4E0D\u9700\u8981 workspace;\u8001\u5E08\u90FD\u8D70\u5B9E\u65F6\u8001\u5E08\u9875,\u5047\u5634\u7167\u811A\u672C\u5FF5\u9759\u97F3\u7684\u58F0\u97F3;nokey = \u6CA1\u914D\u767E\u70BC,\u5B69\u5B50\u7AEF\u53EA\u51FA\u300C\u8001\u5E08\u4EEC\u8FD8\u6CA1\u5230\u300D)\n cotutor --version | --help\nworkspace\u89E3\u6790:--workspace > COTUTOR_WORKSPACE > cwd \u6216\u7956\u5148\u6709 cotutor.json > ~/.config/cotutor/config.json > ~/cotutor/ \u4E0B\u552F\u4E00\u7684\u5B69\u5B50\u76EE\u5F55\n\u8BD5\u7528:\u8981 workspace \u7684\u547D\u4EE4\u52A0 --sandbox,\u6362\u5230\u771F workspace \u65C1\u8FB9\u7684 .sandbox/<\u76EE\u5F55\u540D>/,\u8BD5\u7684\u5BF9\u8BDD\u4E0E vault \u7684\u5199\u5165\u4E0D\u8FDB\u5B69\u5B50\u7684\u8BB0\u5F55(\u7B2C\u4E00\u6B21\u81EA\u52A8\u5206:\u8BBE\u7F6E\u7167\u62F7,\u5BF9\u8BDD\u4E0E\u7167\u7247\u4E0D\u5E26,vault \u62F7\u4E00\u4EFD;\u6807\u9898\u524D\u52A0\u300C\u8BD5\u7528\u300D\u3001\u7AEF\u53E3 +1\u3001\u81EA\u52A8\u8BB0\u8D26\u5173\u6389);--sandbox --fresh \u91CD\u65B0\u5206\n";
|
|
2
|
+
export declare const USAGE = "\u7528\u6CD5:\n cotutor init <slug> [--dir <path>] [--name <\u5B69\u5B50\u540D>] [--grade <1-6>] [--port <n>] \u5EFA ~/cotutor/<slug>/ \u9AA8\u67B6(\u5E42\u7B49\u8865\u7F3A;\u9ED8\u8BA4\u4E8C\u5E74\u7EA7\u6559\u5B66\u5305;\u7AEF\u53E3\u5728\u8FD9\u91CC\u9009\u4E00\u6B21\u5199\u8FDB cotutor.json:443 \u4F18\u5148,\u4E0D\u884C\u5C31 8443\u30018445\u2026,\u4EE5\u540E\u4E0D\u53D8)\n cotutor grades [--json] \u5217\u51FA\u672C\u5305\u53EF\u5B89\u88C5\u7684\u5E74\u7EA7\u6559\u5B66\u5305\n cotutor doctor [--workspace <dir>] [--json] [--live] \u9010\u9879\u4F53\u68C0;--live \u771F\u8FDE\u4E00\u6B21\u767E\u70BC\u5B9E\u65F6\u6A21\u578B\u3001\u771F\u5408\u6210\u4E00\u53E5\u914D\u97F3(\u82B1\u4E00\u5206\u94B1)\u628A API \u5C42\u7684\u5751\u6446\u51FA\u6765(\u771F\u8DD1\u8001\u5E08\u7528 cotutor send);--json \u91CC\u4E0D\u8FC7\u7684\u9879\u5E26 cmd(\u80FD\u76F4\u63A5\u8DD1)\u6216 human(\u8981\u4EBA\u6765)\n cotutor upgrade [--workspace <dir>] [--force <\u8001\u5E08>]... \u8001\u5E08\u6587\u4EF6\u3001activity \u4E0E skill \u6362\u65B0\u7248:\u6CA1\u6539\u8FC7\u7684\u76F4\u63A5\u6362,\u6539\u8FC7\u7684\u53EA\u62A5 diff(--force <\u8001\u5E08> \u6216 --force <\u8001\u5E08>/<\u540D> \u624D\u8986\u76D6,\u539F\u6587\u7559 .bak)\n cotutor upgrade --config [--dry-run] [--workspace <dir>] cotutor.json \u8865\u7F3A:\u65B0\u51FA\u5382\u8001\u5E08 / \u8FD0\u884C\u65F6 / \u547D\u4EE4\u6A21\u677F\u65D7\u6807(\u53EA\u52A0\u7F3A\u7684,\u4F60\u6539\u8FC7\u7684\u503C\u4E0D\u52A8)\n cotutor add <\u8001\u5E08\u540D> --display <\u663E\u793A\u540D> [--subject <\u5B66\u79D1>] [--avatar <emoji|\u56FE\u7247\u76F8\u5BF9\u8DEF\u5F84>] [--hidden] \u52A0\u4E00\u4F4D\u81EA\u5BB6\u7684\u8001\u5E08:\u51FA\u6A21\u677F\u6587\u4EF6\u3001\u8FDB cotutor.json\u3001\u5EFA\u76EE\u5F55(\u540D\u5B57\u4EE5 -tutor \u7ED3\u5C3E\u7684\u662F\u8001\u5E08,figshot \u7ED9\u5B83\u505A\u5934\u50CF)\n cotutor add-theme <\u4E3B\u9898\u540D> [--from <\u4E3B\u9898>] [--workspace <dir>] \u52A0\u4E00\u4E2A\u81EA\u5BB6\u7684\u4E3B\u9898:\u62F7\u4E00\u4EFD(\u7F3A\u7701\u51FA\u5382\u7684 default)\u5230 themes/<\u4E3B\u9898\u540D>/,\u6539 cotutor.json \u7684 kid.theme \u6362\u8FC7\u53BB\n cotutor serve [--workspace <dir>] [--port <n>] [--http] [--trace] [--open] [--log-dir <\u76EE\u5F55>] \u5728\u7EC8\u7AEF\u91CC\u8D77\u670D\u52A1(\u4E00 workspace \u4E00\u8FDB\u7A0B;~/.config/cotutor/certs/ \u6709\u8BC1\u4E66\u5C31\u8D70 HTTPS;--trace \u6BCF\u4E00\u8F6E\u7684\u4E8B\u4EF6\u6309\u9053\u6253\u5370;\u53EA\u6253\u5370\u5BB6\u957F\u7AEF\u9996\u9875\u7684\u5730\u5740,\u5B69\u5B50\u7684\u5E73\u677F\u5728\u90A3\u91CC\u626B\u7801;--open \u987A\u624B\u6253\u5F00,\u65E7\u540D --open-qr \u7167\u6837\u8BA4;--log-dir \u8F93\u51FA\u6309\u5929\u843D\u6587\u4EF6,\u540E\u53F0\u670D\u52A1\u7528;\u5173\u4E86\u7EC8\u7AEF\u5C31\u505C,\u4E00\u76F4\u5F00\u7740\u7528 cotutor service)\n cotutor open [--workspace <dir>] [--sandbox] [--print] [--json] \u6253\u5F00\u5BB6\u957F\u7AEF(\u8BFB cotutor.json,\u4E0D\u7528\u8BB0\u7AEF\u53E3);--sandbox \u5F00\u8BD5\u7528\u7684\u90A3\u4E2A(\u7AEF\u53E3\u6C38\u8FDC\u662F\u771F\u7684 +1);--print \u53EA\u6253\u5370\u5730\u5740;\u6CA1\u5728\u8DD1\u5C31\u8BF4\u600E\u4E48\u8D77,\u4EE5 1 \u9000\n cotutor service install | uninstall | start | stop | restart [--workspace <dir>] [--no-tray] [--no-open] \u628A serve \u88C5\u6210\u8FD9\u53F0\u7535\u8111\u7684\u540E\u53F0\u670D\u52A1(macOS launchd\u3001Linux systemd \u7528\u6237\u670D\u52A1\u3001Windows \u7528\u6237\u7684\u767B\u5F55\u9879,\u90FD\u4E0D\u8981\u7BA1\u7406\u5458):\u767B\u5F55\u540E\u81EA\u5DF1\u8D77\u6765\u3001\u5D29\u4E86 10 \u79D2\u540E\u518D\u8D77\u3001\u65E5\u5FD7\u6309\u5929\u843D\u6587\u4EF6\u7559 14 \u5929;\u4E00\u4E2A workspace \u4E00\u4E2A;\u7B2C\u4E00\u6B21 install \u6253\u5F00\u4E00\u6B21\u5BB6\u957F\u7AEF(--no-open \u4E0D\u5F00),\u4EE5\u540E\u7684\u542F\u52A8\u90FD\u4E0D\u5F00;\u6362\u4E86 node \u7248\u672C\u6216\u5305\u632A\u4E86\u5730\u65B9\u518D install \u4E00\u6B21\n cotutor tray on | off | status [--json] \u83DC\u5355\u680F / \u4EFB\u52A1\u680F\u56FE\u6807(macOS\u3001Windows;service install \u65F6\u4E00\u8D77\u88C5,--no-tray \u4E0D\u5E26):\u6B63\u5E38\u65F6\u53EA\u6709 logo,\u6709\u4E8B\u51FA\u6A59\u70B9,\u6CA1\u5728\u8DD1\u65F6\u53D8\u6DE1\u52A0\u7EA2\u70B9\u5E76\u901A\u77E5\u4E00\u6B21;\u70B9\u5B83\u770B\u72B6\u6001\u3001\u6253\u5F00\u5BB6\u957F\u7AEF\u3001\u91CD\u542F\u3001\u770B\u65E5\u5FD7\u3001\u9000\u51FA;off \u5173\u6389\u5E76\u8BB0\u4F4F\n cotutor service status [--json] | logs [--lines <n>] [--follow] \u540E\u53F0\u670D\u52A1\u5728\u4E0D\u5728\u8DD1\u3001\u7AEF\u53E3\u7B54\u4E0D\u7B54\u5E94\u3001\u8DD1\u7684\u54EA\u4E2A\u7248\u672C\u3001vault \u8BFB\u4E0D\u8BFB\u5F97\u5230\u3001\u65E5\u5FD7\u5728\u54EA;--json \u8FD8\u7B54\u5E73\u677F\u5730\u5740\u3001\u8BC1\u4E66\u3001\u767E\u70BC\u914D\u6CA1\u914D\u3001\u56FE\u6807;logs \u770B\u6700\u8FD1\u7684\u65E5\u5FD7\n cotutor pin [off | status] [--pin <\u6570\u5B57>] [--json] \u5BB6\u957F\u7AEF /parent \u4E0E\u5DE5\u4F5C\u53F0 /dev \u7684\u9501:\u673A\u5668\u7EA7 PIN(\u6240\u6709 workspace \u5171\u7528),iPad \u4E0A\u8F93\u4E00\u6B21\u3001\u8FD9\u53F0\u7535\u8111\u514D\u5BC6;\u6CA1\u8BBE\u5C31\u5168\u5F00(\u5BB6\u957F\u5728\u65C1\u8FB9\u53EF\u4EE5\u4E0D\u8BBE);off \u64A4\u6389\n cotutor cert [--host <\u540D\u6216IP>]... \u7528 mkcert \u5EFA\u8FD9\u53F0\u673A\u5668\u7684\u81EA\u7B7E\u8BC1\u4E66\u5230 ~/.config/cotutor/certs/(\u5E73\u677F\u3001\u624B\u673A\u4E0A\u5F55\u97F3\u8981 HTTPS;\u6240\u6709 workspace \u5171\u7528)\n cotutor send <\u8001\u5E08> [<\u6D88\u606F>] [--from kid|system] [--runtime <\u540D>] [--new] [--photo a.jpg,b.png] [--activity <\u540D> [--param '\u540D=\u503C;\u540D=\u503C']] [--lane main,tts] [--quiet] \u7EC8\u7AEF\u91CC\u53D1\u4E00\u6761,\u73B0\u573A\u6309\u9053\u6253\u5370\u6BCF\u9053\u5DE5\u5E8F\u7684\u4E8B\u4EF6,\u8BF4\u5B8C\u6253\u5370\u7ED3\u679C(\u5B69\u5B50\u7684\u8BDD\u7167\u5B9E\u65F6\u5199,\u540C\u5B69\u5B50\u7AEF:\u4E0A\u4E0B\u6587\u5305\u5E26 live:\u3001\u4E0D\u8865\u63D0\u95EE\u5361,\u53EA\u662F\u6CA1\u6709\u5634;--from system \u4E0D\u7B97;--new \u5F00\u65B0\u8BDD\u9898;--photo \u5E26\u4F5C\u4E1A\u7167\u7247,\u62F7\u8FDB captures/,\u6709\u7167\u7247\u65F6\u6D88\u606F\u53EF\u4EE5\u7A7A;--activity \u50CF\u5B69\u5B50\u70B9\u4E86\u9996\u9875\u7684 activity \u6309\u94AE,\u65B0\u5F00\u8BDD\u9898,\u53C2\u6570\u540C\u6309\u94AE\u4E0B\u9762\u90A3\u51E0\u884C;--quiet \u53EA\u8981\u7ED3\u679C)\n cotutor pack <\u8001\u5E08> [<\u6D88\u606F>] [--from kid|system] [--at <ISO\u65F6\u95F4>] [--activity <\u540D> [--param '\u540D=\u503C;\u540D=\u503C']] [--json] \u5E72\u8DD1\u4E0A\u4E0B\u6587\u5305(--activity \u770B activity \u8BDD\u9898\u7B2C\u4E00\u6761\u8001\u5E08\u62FF\u5230\u4EC0\u4E48):\u4E0D\u8D77\u6A21\u578B,\u6253\u5370\u73B0\u5728\u4F1A\u53D1\u7ED9\u8001\u5E08\u7684\u90A3\u4EFD + \u6BCF\u6BB5\u6765\u81EA\u54EA\u4E2A\u6587\u4EF6\u3001\u90A3\u91CC\u4E00\u5171\u51E0\u6761\u3001\u6309\u653F\u7B56\u5E26\u4E86\u51E0\u6761(\u6539\u4E86\u6863\u6848 / \u65E5\u8BB0 / \u8BA1\u5212\u7ACB\u523B\u770B\u6548\u679C)\n cotutor replay <\u8001\u5E08> <job> [<\u65E5\u671F>] [--runtime <\u540D>] [--json] \u56DE\u653E\u4E00\u8F6E:\u540C\u4E00\u95EE\u6309\u73B0\u5728\u7684 vault \u4E0E\u63D0\u793A\u8BCD\u518D\u8DD1\u4E00\u904D(\u843D evals/,\u4E0D\u8FDB\u5B69\u5B50\u7684\u5BF9\u8BDD\u3001\u4E0D\u914D\u97F3),\u8DD1\u5B8C\u5E76\u6392\u6253\u5370\u4E0A\u4E0B\u6587\u5305 / \u8BB2\u7A3F / \u5361 / \u8BFB\u4E86\u4EC0\u4E48\u7684 diff\n cotutor compare <\u8001\u5E08> [<evalJob>] [<\u65E5\u671F>] [--json] \u518D\u770B\u4E00\u6B21\u56DE\u653E\u7684\u5BF9\u7167(\u4E0D\u7ED9 evalJob \u5C31\u5217\u8FD9\u5929\u56DE\u653E\u8FC7\u54EA\u4E9B)\n cotutor show <\u8001\u5E08> <job> [<\u65E5\u671F>] [--json] [--evals] [--workspace <dir>] \u770B\u4E00\u8F6E:\u95EE\u4E86\u4EC0\u4E48\u3001\u4E0A\u4E0B\u6587\u5305\u3001\u5F53\u65F6\u7684\u8001\u5E08\u6587\u4EF6\u4E0E\u6280\u80FD hash\u3001\u8BB2\u7A3F\u4E0E\u5361\u3001\u8BFB\u4E86\u4EC0\u4E48\u3001token \u4E0E\u7528\u65F6\u3001\u7ED9\u5BB6\u957F\u7684\u5C3E\u5DF4;--json \u662F\u5BB6\u957F\u7AEF\u300C\u770B\u539F\u6587\u300D\u540C\u4E00\u4EFD\u6570\u636E,\u7ED9\u7F16\u7A0B\u52A9\u624B\u5206\u6790\u7528\n cotutor trace <\u8001\u5E08> <job> [<\u65E5\u671F>] [--lane \u2026] [--workspace <dir>] \u56DE\u653E\u4E00\u8F6E\u7684\u4E8B\u4EF6(<\u65E5\u671F>.<job>.events.jsonl;\u6392\u67E5\u6628\u5929\u90A3\u8F6E\u7528)\n cotutor rate <\u8001\u5E08> <\u8BDD\u9898> <1-5> [--date <\u65E5\u671F>] \u7ED9\u4E00\u4E2A\u8BDD\u9898\u6253\u661F(\u4E0E\u5BB6\u957F\u7AEF\u540C\u4E00\u6761\u8DEF;\u2265 vault.keepScore \u7684\u8BDD\u9898\u8BB0\u8D26\u65F6\u6458\u8981\u624D\u8FDB\u65E5\u8BB0)\n cotutor bookkeep <\u8001\u5E08> [--date <\u65E5\u671F>] [--thread <\u8BDD\u9898>]... \u8BB0\u8D26:\u8FD9\u5929\u6BCF\u4E2A\u8FD8\u6CA1\u8BB0\u8FC7\u7684\u8BDD\u9898\u5404\u8D77\u4E00\u8F6E\u8BB0\u8D26\u4EFB\u52A1,\u8001\u5E08\u56DE\u300C## \u8BB0\u8D26\u300D,\u5E94\u7528\u5199\u8FDB vault \u7684\u65E5\u8BB0(\u8BDD\u9898\u540D\u3001\u5B69\u5B50\u95EE\u3001\u6458\u8981\u3001\u89C2\u5BDF)\n cotutor home check [--published] [--json] \u68C0\u67E5\u9996\u9875\u8349\u7A3F home/draft.md(\u6216\u5DF2\u53D1\u5E03\u7684\u90A3\u4EFD):\u5B69\u5B50\u4F1A\u770B\u5230\u54EA\u51E0\u5F20\u5361\u3001\u6BCF\u4F4D\u8001\u5E08\u7684\u6309\u94AE\u3001\u8981\u6539\u7684\u4E0E\u63D0\u9192(\u5E26\u884C\u53F7);\u4E0D\u82B1\u94B1\n cotutor home publish [--force] [--from <\u6587\u4EF6>] [--json] \u53D1\u5E03\u9996\u9875:\u68C0\u67E5 \u2192 \u7559\u5386\u53F2 home/history/<id>.md \u2192 \u5199 home/published.json(\u5B69\u5B50\u7AEF\u53EA\u8BFB\u5B83);\u6709\u8981\u6539\u7684\u4E0D\u53D1,--force \u4E22\u6389\u90A3\u51E0\u5F20\u7167\u53D1;--from \u53D1\u53E6\u4E00\u4EFD(\u5982\u5386\u53F2)\n cotutor home show [--json] \u73B0\u5728\u53D1\u5E03\u7684\u662F\u54EA\u4EFD\u3001\u51E0\u5929\u524D\u3001\u6BCF\u4E2A\u6309\u94AE\u88AB\u70B9\u4E86\u51E0\u6B21(\u54EA\u5929\u3001\u54EA\u4E2A\u8BDD\u9898)\n cotutor activity check [<\u8001\u5E08> [<\u540D>]] [--json] \u68C0\u67E5 activity \u6587\u4EF6(agents/<\u8001\u5E08>/activities/<\u540D>.md):\u5BB6\u957F\u586B\u7684\u3001\u5E94\u7528\u7B97\u7684\u3001\u5E94\u7528\u62E6\u7684\u3001\u88C1\u6BB5\u3001\u63D2\u69FD\u3001\u6B63\u6587\u7528\u7684\u5361\u3001\u7B2C\u4E00\u53E5\u4E0E\u6536(\u5E26\u884C\u53F7);\u4E0D\u82B1\u94B1\n cotutor activity eval <\u8001\u5E08> <\u540D> [--runs <n>] [--only <\u4F8B\u5B50>] [--kid-model <\u5343\u95EE\u6A21\u578B>] [--json] \u7167 <\u540D>.\u4F8B\u5B50.md \u8DD1:\u771F\u8001\u5E08 + \u6A21\u62DF\u5B69\u5B50(\u5343\u95EE,\u7F3A\u7701 qwen3.8-flash,\u6709 Token Plan \u8D70\u5B83)\u591A\u8F6E(\u7167\u5B9E\u65F6\u5199,\u540C\u5B69\u5B50\u7AEF),\u5224\u53EA\u770B\u677F\u4E66;\u4E00\u4E2A\u4F8B\u5B50\u8DD1 n \u6B21(\u7F3A\u7701 3)\u6B21\u6B21\u90FD\u8FC7\u624D\u7B97\u8FC7;\u843D evals/,\u753B\u5BB6\u662F\u5047\u7684;\u82B1\u94B1\n cotutor chalk <check|build|bake|snap|dub> <scene.ts|\u8BFE\u5305 id> [...] \u505A\u5C0F\u8BFE\u5802\u7684\u8BFE\u5305:check \u6821\u9A8C\u573A\u666F\u3001build \u51FA\u8BFE\u5305\u5230 bundles/\u3001bake \u70E4\u753B\u9762\u3001snap \u6BCF\u6B65\u622A\u56FE\u3001dub \u914D\u97F3(cotutor chalk \u770B\u5404\u81EA\u7684\u9009\u9879;bake / snap \u8981\u672C\u673A\u7684 Chrome)\n cotutor mock [--port <n>] [--scenario normal|nokey] [--delay <ms>] [--http] \u4E0D\u7ECF\u771F\u5B9E\u8001\u5E08\u4E0E\u767E\u70BC,\u7528\u56FA\u5B9A\u7684\u677F\u4E66 JSON \u8D77\u5B69\u5B50\u7AEF,\u6D4B\u524D\u7AEF\u4EA4\u4E92\u4E0E\u6E32\u67D3(\u4E0D\u9700\u8981 workspace;\u8001\u5E08\u90FD\u8D70\u5B9E\u65F6\u8001\u5E08\u9875,\u5047\u5634\u7167\u811A\u672C\u5FF5\u9759\u97F3\u7684\u58F0\u97F3;nokey = \u6CA1\u914D\u767E\u70BC,\u5B69\u5B50\u7AEF\u53EA\u51FA\u300C\u8001\u5E08\u4EEC\u8FD8\u6CA1\u5230\u300D)\n cotutor --version | --help\nworkspace\u89E3\u6790:--workspace > COTUTOR_WORKSPACE > cwd \u6216\u7956\u5148\u6709 cotutor.json > ~/.config/cotutor/config.json > ~/cotutor/ \u4E0B\u552F\u4E00\u7684\u5B69\u5B50\u76EE\u5F55\n\u8BD5\u7528:\u8981 workspace \u7684\u547D\u4EE4\u52A0 --sandbox,\u6362\u5230\u771F workspace \u65C1\u8FB9\u7684 .sandbox/<\u76EE\u5F55\u540D>/,\u8BD5\u7684\u5BF9\u8BDD\u4E0E vault \u7684\u5199\u5165\u4E0D\u8FDB\u5B69\u5B50\u7684\u8BB0\u5F55(\u7B2C\u4E00\u6B21\u81EA\u52A8\u5206:\u8BBE\u7F6E\u7167\u62F7,\u5BF9\u8BDD\u4E0E\u7167\u7247\u4E0D\u5E26,vault \u62F7\u4E00\u4EFD;\u6807\u9898\u524D\u52A0\u300C\u8BD5\u7528\u300D\u3001\u7AEF\u53E3 +1\u3001\u81EA\u52A8\u8BB0\u8D26\u5173\u6389);--sandbox --fresh \u91CD\u65B0\u5206\n";
|
package/dist/app/cli/usage.js
CHANGED
|
@@ -2,16 +2,16 @@
|
|
|
2
2
|
export const USAGE = `用法:
|
|
3
3
|
cotutor init <slug> [--dir <path>] [--name <孩子名>] [--grade <1-6>] [--port <n>] 建 ~/cotutor/<slug>/ 骨架(幂等补缺;默认二年级教学包;端口在这里选一次写进 cotutor.json:443 优先,不行就 8443、8445…,以后不变)
|
|
4
4
|
cotutor grades [--json] 列出本包可安装的年级教学包
|
|
5
|
-
cotutor doctor [--workspace <dir>] [--json] [--live] 逐项体检;--live 真连一次百炼实时模型、真合成一句配音(花一分钱)把 API 层的坑摆出来(真跑老师用 cotutor send)
|
|
5
|
+
cotutor doctor [--workspace <dir>] [--json] [--live] 逐项体检;--live 真连一次百炼实时模型、真合成一句配音(花一分钱)把 API 层的坑摆出来(真跑老师用 cotutor send);--json 里不过的项带 cmd(能直接跑)或 human(要人来)
|
|
6
6
|
cotutor upgrade [--workspace <dir>] [--force <老师>]... 老师文件、activity 与 skill 换新版:没改过的直接换,改过的只报 diff(--force <老师> 或 --force <老师>/<名> 才覆盖,原文留 .bak)
|
|
7
7
|
cotutor upgrade --config [--dry-run] [--workspace <dir>] cotutor.json 补缺:新出厂老师 / 运行时 / 命令模板旗标(只加缺的,你改过的值不动)
|
|
8
8
|
cotutor add <老师名> --display <显示名> [--subject <学科>] [--avatar <emoji|图片相对路径>] [--hidden] 加一位自家的老师:出模板文件、进 cotutor.json、建目录(名字以 -tutor 结尾的是老师,figshot 给它做头像)
|
|
9
9
|
cotutor add-theme <主题名> [--from <主题>] [--workspace <dir>] 加一个自家的主题:拷一份(缺省出厂的 default)到 themes/<主题名>/,改 cotutor.json 的 kid.theme 换过去
|
|
10
10
|
cotutor serve [--workspace <dir>] [--port <n>] [--http] [--trace] [--open] [--log-dir <目录>] 在终端里起服务(一 workspace 一进程;~/.config/cotutor/certs/ 有证书就走 HTTPS;--trace 每一轮的事件按道打印;只打印家长端首页的地址,孩子的平板在那里扫码;--open 顺手打开,旧名 --open-qr 照样认;--log-dir 输出按天落文件,后台服务用;关了终端就停,一直开着用 cotutor service)
|
|
11
11
|
cotutor open [--workspace <dir>] [--sandbox] [--print] [--json] 打开家长端(读 cotutor.json,不用记端口);--sandbox 开试用的那个(端口永远是真的 +1);--print 只打印地址;没在跑就说怎么起,以 1 退
|
|
12
|
-
cotutor service install | uninstall | start | stop | restart [--workspace <dir>] [--no-tray] 把 serve 装成这台电脑的后台服务(macOS launchd、Linux systemd 用户服务、Windows 用户的登录项,都不要管理员):登录后自己起来、崩了 10 秒后再起、日志按天落文件留 14 天;一个 workspace
|
|
12
|
+
cotutor service install | uninstall | start | stop | restart [--workspace <dir>] [--no-tray] [--no-open] 把 serve 装成这台电脑的后台服务(macOS launchd、Linux systemd 用户服务、Windows 用户的登录项,都不要管理员):登录后自己起来、崩了 10 秒后再起、日志按天落文件留 14 天;一个 workspace 一个;第一次 install 打开一次家长端(--no-open 不开),以后的启动都不开;换了 node 版本或包挪了地方再 install 一次
|
|
13
13
|
cotutor tray on | off | status [--json] 菜单栏 / 任务栏图标(macOS、Windows;service install 时一起装,--no-tray 不带):正常时只有 logo,有事出橙点,没在跑时变淡加红点并通知一次;点它看状态、打开家长端、重启、看日志、退出;off 关掉并记住
|
|
14
|
-
cotutor service status [--json] | logs [--lines <n>] [--follow] 后台服务在不在跑、端口答不答应、跑的哪个版本、vault
|
|
14
|
+
cotutor service status [--json] | logs [--lines <n>] [--follow] 后台服务在不在跑、端口答不答应、跑的哪个版本、vault 读不读得到、日志在哪;--json 还答平板地址、证书、百炼配没配、图标;logs 看最近的日志
|
|
15
15
|
cotutor pin [off | status] [--pin <数字>] [--json] 家长端 /parent 与工作台 /dev 的锁:机器级 PIN(所有 workspace 共用),iPad 上输一次、这台电脑免密;没设就全开(家长在旁边可以不设);off 撤掉
|
|
16
16
|
cotutor cert [--host <名或IP>]... 用 mkcert 建这台机器的自签证书到 ~/.config/cotutor/certs/(平板、手机上录音要 HTTPS;所有 workspace 共用)
|
|
17
17
|
cotutor send <老师> [<消息>] [--from kid|system] [--runtime <名>] [--new] [--photo a.jpg,b.png] [--activity <名> [--param '名=值;名=值']] [--lane main,tts] [--quiet] 终端里发一条,现场按道打印每道工序的事件,说完打印结果(孩子的话照实时写,同孩子端:上下文包带 live:、不补提问卡,只是没有嘴;--from system 不算;--new 开新话题;--photo 带作业照片,拷进 captures/,有照片时消息可以空;--activity 像孩子点了首页的 activity 按钮,新开话题,参数同按钮下面那几行;--quiet 只要结果)
|
package/dist/app/http/app.js
CHANGED
|
@@ -33,6 +33,8 @@ 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";
|
|
37
|
+
import { isNewer } from "../../shared/upgrade.js";
|
|
36
38
|
import { qrPage } from "../../ui/parent/qr-page.js";
|
|
37
39
|
import { kidPage } from "../../ui/kid/kid-page.js";
|
|
38
40
|
import { parentPage } from "../../ui/parent/parent-page.js";
|
|
@@ -461,7 +463,7 @@ export async function route(method, path, ctx, body, who = 'local', client = {})
|
|
|
461
463
|
if (p === '/api/health') {
|
|
462
464
|
const head = await headNow();
|
|
463
465
|
const vault = await readdir(ws.paths.vault).then(() => 'ok', (err) => (err.code === 'EPERM' || err.code === 'EACCES' ? 'denied' : 'missing'));
|
|
464
|
-
return { status: 200, json: { ok: true, configError: ctx.configError, started: BUILD.started, version: BUILD.version, commit: shortCommit(BUILD.commit), dirty: BUILD.dirty, head: shortCommit(head), changed: codeChanged(BUILD.commit, head), pid: process.pid, mode: process.env.COTUTOR_SERVICE ? 'service' : 'foreground', vault, calls: ctx.openCalls?.() ?? 0, bailian: ctx.bailian.status().ready } };
|
|
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 } };
|
|
465
467
|
}
|
|
466
468
|
// 家长端与工作台的锁(access.ts):对 PIN 换 cookie;设了 PIN、没凭证的设备,孩子端以外的页面回输 PIN 的页、接口回 401。没设 PIN 什么都不拦
|
|
467
469
|
if (p === '/api/auth') {
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export declare const UPDATE_URLS: string[];
|
|
2
|
+
export declare const updateFile: () => string;
|
|
3
|
+
export interface UpdateRecord {
|
|
4
|
+
checkedAt?: string;
|
|
5
|
+
latest?: string;
|
|
6
|
+
off?: boolean;
|
|
7
|
+
}
|
|
8
|
+
/** 读不到、写坏了 → 空的,当作没问过 */
|
|
9
|
+
export declare function readUpdateRecord(file?: string): Promise<UpdateRecord>;
|
|
10
|
+
/** 依次问,谁先答谁算;都问不到 → null */
|
|
11
|
+
export declare function fetchLatest(fetchFn?: typeof fetch, urls?: string[]): Promise<string | null>;
|
|
12
|
+
/** 这个进程现在知道的最新版本;还不知道、关掉了 → null */
|
|
13
|
+
export declare const knownLatest: () => string | null;
|
|
14
|
+
export interface UpdateOptions {
|
|
15
|
+
now?: number;
|
|
16
|
+
fetch?: typeof fetch;
|
|
17
|
+
file?: string;
|
|
18
|
+
}
|
|
19
|
+
/** 文件里的还新鲜就用它,否则问一次并存下;问不到就继续用上次的 */
|
|
20
|
+
export declare function refreshLatest(o?: UpdateOptions): Promise<string | null>;
|
|
21
|
+
/** serve 起来时调:先用存着的那份,一分钟后问(刚开机网可能还没好),之后每小时看一眼,过了一天才真的问。定时器不拦进程退出 */
|
|
22
|
+
export declare function startUpdateChecks(o?: UpdateOptions & {
|
|
23
|
+
firstMs?: number;
|
|
24
|
+
everyMs?: number;
|
|
25
|
+
}): () => void;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 有没有新版本:服务每天问一次 npm 的 latest,结果存机器级 ~/.config/cotutor/update.json(几个孩子的服务共用,一天只问一次,重起不重问)。
|
|
3
|
+
* /api/health 带上 latest(比在跑的新才有),托盘、家长端、service status 都从它拿;doctor 只读这份文件,不上网。
|
|
4
|
+
* 只发一个普通的 GET,不带孩子或使用的任何信息;问不到(断网、被墙)不报错,下个小时再问。先问 npm,不通再问 npmmirror(中国家庭常连不上 npm)。
|
|
5
|
+
* latest 这个标签要等 npm 扫描通过才变(2026-07 起发布后先扫描),不会提前提醒。
|
|
6
|
+
* 不自动升级:升级要重起服务,孩子可能正在上课;老师文件的更新也要家长知道。
|
|
7
|
+
* 不想让它问:update.json 写 {"off": true}。从仓库跑的(BUILD.commit 有值)不问,见 main.ts。
|
|
8
|
+
*/
|
|
9
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
10
|
+
import { homedir } from 'node:os';
|
|
11
|
+
import { dirname, join } from 'node:path';
|
|
12
|
+
import { isPlainVersion } from "../../shared/upgrade.js";
|
|
13
|
+
export const UPDATE_URLS = ['https://registry.npmjs.org/cotutor/latest', 'https://registry.npmmirror.com/cotutor/latest'];
|
|
14
|
+
const DAY_MS = 24 * 3600_000;
|
|
15
|
+
const HOUR_MS = 3600_000;
|
|
16
|
+
const FIRST_MS = 60_000;
|
|
17
|
+
const TIMEOUT_MS = 8000;
|
|
18
|
+
export const updateFile = () => join(homedir(), '.config', 'cotutor', 'update.json');
|
|
19
|
+
/** 读不到、写坏了 → 空的,当作没问过 */
|
|
20
|
+
export async function readUpdateRecord(file = updateFile()) {
|
|
21
|
+
try {
|
|
22
|
+
const j = JSON.parse(await readFile(file, 'utf8'));
|
|
23
|
+
return {
|
|
24
|
+
...(typeof j.checkedAt === 'string' ? { checkedAt: j.checkedAt } : {}),
|
|
25
|
+
...(isPlainVersion(j.latest) ? { latest: j.latest } : {}),
|
|
26
|
+
...(j.off === true ? { off: true } : {}),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return {};
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/** 依次问,谁先答谁算;都问不到 → null */
|
|
34
|
+
export async function fetchLatest(fetchFn = fetch, urls = UPDATE_URLS) {
|
|
35
|
+
for (const url of urls) {
|
|
36
|
+
try {
|
|
37
|
+
const r = await fetchFn(url, { signal: AbortSignal.timeout(TIMEOUT_MS), headers: { accept: 'application/json' } });
|
|
38
|
+
if (!r.ok)
|
|
39
|
+
continue;
|
|
40
|
+
const v = (await r.json()).version;
|
|
41
|
+
if (isPlainVersion(v))
|
|
42
|
+
return v;
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
/* 这家不通,问下一家 */
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
let known = null;
|
|
51
|
+
/** 这个进程现在知道的最新版本;还不知道、关掉了 → null */
|
|
52
|
+
export const knownLatest = () => known;
|
|
53
|
+
/** 文件里的还新鲜就用它,否则问一次并存下;问不到就继续用上次的 */
|
|
54
|
+
export async function refreshLatest(o = {}) {
|
|
55
|
+
const file = o.file ?? updateFile();
|
|
56
|
+
const now = o.now ?? Date.now();
|
|
57
|
+
const rec = await readUpdateRecord(file);
|
|
58
|
+
if (rec.off)
|
|
59
|
+
return (known = null);
|
|
60
|
+
const at = rec.checkedAt ? Date.parse(rec.checkedAt) : Number.NaN;
|
|
61
|
+
// 时间在将来(改过系统时间)算不新鲜
|
|
62
|
+
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;
|
|
68
|
+
try {
|
|
69
|
+
await mkdir(dirname(file), { recursive: true });
|
|
70
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
71
|
+
await writeFile(tmp, `${JSON.stringify({ checkedAt: new Date(now).toISOString(), latest }, null, 2)}\n`);
|
|
72
|
+
await rename(tmp, file);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
/* 写不了:下个小时再问一次,不影响别的 */
|
|
76
|
+
}
|
|
77
|
+
return latest;
|
|
78
|
+
}
|
|
79
|
+
/** serve 起来时调:先用存着的那份,一分钟后问(刚开机网可能还没好),之后每小时看一眼,过了一天才真的问。定时器不拦进程退出 */
|
|
80
|
+
export function startUpdateChecks(o = {}) {
|
|
81
|
+
void readUpdateRecord(o.file).then((rec) => { known = rec.off ? null : rec.latest ?? null; });
|
|
82
|
+
const run = () => { void refreshLatest(o).catch(() => { }); };
|
|
83
|
+
const first = setTimeout(run, o.firstMs ?? FIRST_MS);
|
|
84
|
+
first.unref();
|
|
85
|
+
const every = setInterval(run, o.everyMs ?? HOUR_MS);
|
|
86
|
+
every.unref();
|
|
87
|
+
return () => { clearTimeout(first); clearInterval(every); };
|
|
88
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
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 真机) */
|
|
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
|
+
/** 升级程序的三步。分开写不用 &&: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 真机) */
|
|
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
|
+
}
|
|
@@ -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,14 @@ ${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 .sub { color:var(--dim); font-size:14px; padding-right:64px; }
|
|
222
|
+
.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
|
+
.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
224
|
</style></head><body>
|
|
220
225
|
<div class="wrap" id="main">
|
|
221
226
|
<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>
|
|
227
|
+
<div id="updatebar"></div>
|
|
222
228
|
<div id="qrtop"></div>
|
|
223
229
|
<div class="main">
|
|
224
230
|
<section id="pv" aria-label="孩子看到的首页">
|
|
@@ -363,7 +369,23 @@ ${pageDomJs(3200)}
|
|
|
363
369
|
h('div', { class: 'note' }, d.https ? (d.notes[0] || '') : '', d.devices ? '' : (d.https ? ' ' : '') + '还没有平板连进来过,所以这块先开着;连过以后自动收起。'));
|
|
364
370
|
};
|
|
365
371
|
|
|
372
|
+
// 有新版本(服务每天问一次 npm,machine/update.ts):只在家长端说,孩子端不说;点「知道了」这个版本不再提,读不了 localStorage 就每次都说
|
|
373
|
+
const UPGRADE_STEPS = ${JSON.stringify(upgradeSteps())};
|
|
374
|
+
const showUpdate = async () => {
|
|
375
|
+
const box = $('#updatebar');
|
|
376
|
+
const hh = await api('GET', '/api/health').catch(() => null);
|
|
377
|
+
const latest = hh && hh.latest;
|
|
378
|
+
let seen = null;
|
|
379
|
+
try { seen = localStorage.getItem('cotutor.updateSeen'); } catch {}
|
|
380
|
+
if (!latest || seen === latest) { box.replaceChildren(); return; }
|
|
381
|
+
box.replaceChildren(h('div', { class: 'upd' },
|
|
382
|
+
h('b', {}, '有新版本 ' + latest),
|
|
383
|
+
h('span', { class: 'sub' }, '现在是 ' + hh.version + '。挑孩子不用的时候,在这台电脑的终端里依次运行:'),
|
|
384
|
+
h('pre', {}, UPGRADE_STEPS.map((s) => h('div', {}, s))),
|
|
385
|
+
h('button', { type: 'button', on: { click: () => { try { localStorage.setItem('cotutor.updateSeen', latest); } catch {} box.replaceChildren(); } } }, '知道了')));
|
|
386
|
+
};
|
|
366
387
|
const load = async () => {
|
|
388
|
+
showUpdate();
|
|
367
389
|
try { D = await api('GET', '/api/parent/home'); } catch (e) { toast('读不到:' + e.message); return; }
|
|
368
390
|
BL.st = await api('GET', '/api/parent/bailian').catch(() => null);
|
|
369
391
|
QR.d = await api('GET', '/api/parent/qr').catch(() => null);
|
package/package.json
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cotutor",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"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
5
|
"type": "module",
|
|
6
6
|
"license": "SEE LICENSE IN LICENSE",
|
|
7
|
-
"repository":
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/TarsLab/cotutor.git"
|
|
10
|
+
},
|
|
8
11
|
"homepage": "https://github.com/TarsLab/cotutor#readme",
|
|
9
12
|
"bugs": "https://github.com/TarsLab/cotutor/issues",
|
|
10
13
|
"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 给它做头像)
|