cotutor 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -24
- package/agents/chinese-tutor.md +67 -0
- package/agents/{homework-aide.md → homework-tutor.md} +4 -4
- package/agents/math-tutor.md +68 -0
- package/agents/reading-tutor.md +65 -0
- package/agents/scene-maker.md +41 -0
- package/dist/cards/canvas.d.ts +22 -0
- package/dist/cards/canvas.js +65 -0
- package/dist/cards/choice.d.ts +20 -0
- package/dist/cards/choice.js +76 -0
- package/dist/cards/code.d.ts +9 -0
- package/dist/cards/code.js +13 -0
- package/dist/cards/fill.d.ts +14 -0
- package/dist/cards/fill.js +64 -0
- package/dist/cards/image.d.ts +13 -0
- package/dist/cards/image.js +35 -0
- package/dist/cards/index.d.ts +48 -0
- package/dist/cards/index.js +115 -0
- package/dist/cards/kind.d.ts +27 -0
- package/dist/cards/kind.js +7 -0
- package/dist/cards/read.d.ts +8 -0
- package/dist/cards/read.js +27 -0
- package/dist/cards/scene.d.ts +24 -0
- package/dist/cards/scene.js +67 -0
- package/dist/cards/text.d.ts +18 -0
- package/dist/cards/text.js +41 -0
- package/dist/cli/cert.d.ts +7 -0
- package/dist/cli/cert.js +36 -0
- package/dist/cli/doctor.d.ts +5 -2
- package/dist/cli/doctor.js +183 -18
- package/dist/cli/init.d.ts +1 -1
- package/dist/cli/init.js +41 -39
- package/dist/cli/main.js +128 -8
- package/dist/cli/serve.d.ts +15 -4
- package/dist/cli/serve.js +40 -6
- package/dist/cli/skeleton.d.ts +23 -5
- package/dist/cli/skeleton.js +131 -29
- package/dist/cli/skills.d.ts +40 -0
- package/dist/cli/skills.js +170 -0
- package/dist/cli/tutors.d.ts +84 -0
- package/dist/cli/tutors.js +274 -0
- package/dist/cli/workspace.d.ts +9 -1
- package/dist/cli/workspace.js +25 -4
- package/dist/lib/agent-file.d.ts +1 -1
- package/dist/lib/board.d.ts +15 -0
- package/dist/lib/board.js +107 -0
- package/dist/lib/context-pack.js +10 -1
- package/dist/lib/conversation.d.ts +43 -6
- package/dist/lib/conversation.js +53 -9
- package/dist/lib/index.d.ts +4 -0
- package/dist/lib/index.js +4 -0
- package/dist/lib/kid-board.d.ts +148 -0
- package/dist/lib/kid-board.js +294 -0
- package/dist/lib/kid-view.d.ts +44 -7
- package/dist/lib/kid-view.js +63 -8
- package/dist/lib/ledger.d.ts +8 -0
- package/dist/lib/ledger.js +8 -0
- package/dist/lib/plan.d.ts +2 -0
- package/dist/lib/plan.js +9 -0
- package/dist/lib/run-plan.d.ts +30 -0
- package/dist/lib/run-plan.js +32 -0
- package/dist/lib/stream.d.ts +15 -0
- package/dist/lib/stream.js +69 -0
- package/dist/lib/timetable.d.ts +22 -0
- package/dist/lib/timetable.js +106 -0
- package/dist/schema/board.d.ts +59 -0
- package/dist/schema/board.js +30 -0
- package/dist/schema/config.d.ts +76 -18
- package/dist/schema/config.js +67 -37
- package/dist/schema/context-pack.d.ts +7 -0
- package/dist/schema/context-pack.js +6 -0
- package/dist/schema/conversation.d.ts +114 -4
- package/dist/schema/conversation.js +29 -5
- package/dist/schema/index.d.ts +6 -2
- package/dist/schema/index.js +6 -2
- package/dist/schema/json-schema.d.ts +2 -0
- package/dist/schema/json-schema.js +23 -0
- package/dist/schema/ledger.d.ts +1 -1
- package/dist/schema/timetable.d.ts +26 -0
- package/dist/schema/timetable.js +18 -0
- package/dist/server/app.d.ts +67 -6
- package/dist/server/app.js +399 -38
- package/dist/server/kid-page.d.ts +1 -0
- package/dist/server/kid-page.js +768 -0
- package/dist/server/mock.d.ts +58 -0
- package/dist/server/mock.js +403 -0
- package/dist/server/parent-page.d.ts +8 -0
- package/dist/server/parent-page.js +393 -0
- package/dist/server/runner.d.ts +64 -0
- package/dist/server/runner.js +277 -0
- package/dist/server/scene-props.d.ts +16 -0
- package/dist/server/scene-props.js +39 -0
- package/dist/server/stage.d.ts +15 -0
- package/dist/server/stage.js +69 -0
- package/dist/server/store.d.ts +43 -0
- package/dist/server/store.js +210 -0
- package/dist/server/tts.d.ts +51 -0
- package/dist/server/tts.js +99 -0
- package/dist/stage/Assistant-Bold-ZDZZ6JHA.woff2 +0 -0
- package/dist/stage/Assistant-Medium-DZ25RZU3.woff2 +0 -0
- package/dist/stage/Assistant-Regular-PLF2XOGW.woff2 +0 -0
- package/dist/stage/Assistant-SemiBold-CZ5MX6FK.woff2 +0 -0
- package/dist/stage/index.html +10 -0
- package/dist/stage/stage.css +1 -0
- package/dist/stage/stage.js +4383 -0
- package/package.json +13 -4
- package/agents/chinese-teacher.md +0 -19
- package/agents/math-teacher.md +0 -19
- package/agents/reading-teacher.md +0 -19
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { type IncomingMessage, type Server, type ServerResponse } from 'node:http';
|
|
2
|
+
/** mock 的课包目录:仓库里的样本(tests/fixtures/bundles/),场景卡从这里播 */
|
|
3
|
+
export declare const MOCK_BUNDLES_DIR: string;
|
|
4
|
+
import type { BoardSection } from '../lib/kid-board.ts';
|
|
5
|
+
export type MockScenario = 'normal' | 'limit' | 'offline';
|
|
6
|
+
export interface MockTutor {
|
|
7
|
+
name: string;
|
|
8
|
+
display: string;
|
|
9
|
+
subject: string;
|
|
10
|
+
avatar: string;
|
|
11
|
+
motto: string;
|
|
12
|
+
/** 每轮一节:老师会写的那种正文(段落 = 讲稿,围栏 = 卡),与真老师的样本同一种写法(tests/fixtures/board/) */
|
|
13
|
+
script: string[];
|
|
14
|
+
/** 打开页面时已经讲过的轮数(取脚本前几节) */
|
|
15
|
+
preloaded: number;
|
|
16
|
+
/** 首轮的那句问题(孩子问的;不上板,只进索引) */
|
|
17
|
+
firstQuestion: string;
|
|
18
|
+
}
|
|
19
|
+
/** 脚本 → 板书节:走真解析器(mock 也是解析器的一次演练) */
|
|
20
|
+
export declare function sectionFromScript(md: string): BoardSection;
|
|
21
|
+
export declare const MOCK_TUTORS: MockTutor[];
|
|
22
|
+
export interface MockRouteResult {
|
|
23
|
+
status: number;
|
|
24
|
+
json?: unknown;
|
|
25
|
+
html?: string;
|
|
26
|
+
/** 静态文件(舞台包、课包) */
|
|
27
|
+
file?: string;
|
|
28
|
+
/** html / file 的 content-type */
|
|
29
|
+
contentType?: string;
|
|
30
|
+
}
|
|
31
|
+
export interface MockOptions {
|
|
32
|
+
scenario?: MockScenario;
|
|
33
|
+
/** 老师「想」多久(毫秒);测试传 0 */
|
|
34
|
+
delayMs?: number;
|
|
35
|
+
now?: () => Date;
|
|
36
|
+
title?: string;
|
|
37
|
+
}
|
|
38
|
+
export interface Mock {
|
|
39
|
+
route(method: string, path: string, body?: unknown): Promise<MockRouteResult>;
|
|
40
|
+
/** 等所有还在「想」的老师答完(测试用) */
|
|
41
|
+
settle(): Promise<void>;
|
|
42
|
+
handler(req: IncomingMessage, res: ServerResponse): void;
|
|
43
|
+
}
|
|
44
|
+
export declare function createMock(opts?: MockOptions): Mock;
|
|
45
|
+
export interface ServeMockOptions extends MockOptions {
|
|
46
|
+
port?: number;
|
|
47
|
+
host?: string;
|
|
48
|
+
/** 强制 HTTP(缺省:机器级证书在就走 HTTPS,iPad 上按住说话要它) */
|
|
49
|
+
http?: boolean;
|
|
50
|
+
}
|
|
51
|
+
export interface ServeMockResult {
|
|
52
|
+
server: Server;
|
|
53
|
+
mock: Mock;
|
|
54
|
+
port: number;
|
|
55
|
+
https: boolean;
|
|
56
|
+
urls: string[];
|
|
57
|
+
}
|
|
58
|
+
export declare function serveMock(opts?: ServeMockOptions): Promise<ServeMockResult>;
|
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 模拟接口(`cotutor mock`):不经真实老师与配音,用固定的板书 JSON 把孩子端页面喂起来,
|
|
3
|
+
* 专门测前端的交互逻辑与渲染效果(卡先铺、笔跟声、停下等、追问追加、上限、离线)。
|
|
4
|
+
* 不需要 workspace。老师的每一轮从各自的脚本里按顺序取(脚本就是老师会写的正文,过真解析器);脚本用完给一句收尾话。
|
|
5
|
+
* 没有配音文件(/api/audio 一律 404),页面退回浏览器自带的合成声,所以逐句节奏是真的。
|
|
6
|
+
* 老师「想」的期间按流式模拟:卡在 delay 里一张张出现(pending 条目带 partial 板书),想完才有讲稿与声音。
|
|
7
|
+
* 卡的状态 PUT 假存在内存里(过真的 state 契约),today 里并回卡上;发消息接 {text, action, focus},「交给老师」后照常追加下一节。
|
|
8
|
+
* 场景:normal(缺省)/ limit(每日上限已到)/ offline(接口全 500,页面该灰)。
|
|
9
|
+
*/
|
|
10
|
+
import { createReadStream, readFileSync } from 'node:fs';
|
|
11
|
+
import { createServer as createHttp } from 'node:http';
|
|
12
|
+
import { createServer as createHttps } from 'node:https';
|
|
13
|
+
import { hostname } from 'node:os';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
import { parseBoard } from "../lib/board.js";
|
|
16
|
+
import { parseCardState, stripSecrets } from "../cards/index.js";
|
|
17
|
+
import { fileURLToPath } from 'node:url';
|
|
18
|
+
import { bundleAsset, stageAsset } from "./stage.js";
|
|
19
|
+
import { enrichScenes } from "./scene-props.js";
|
|
20
|
+
/** mock 的课包目录:仓库里的样本(tests/fixtures/bundles/),场景卡从这里播 */
|
|
21
|
+
export const MOCK_BUNDLES_DIR = fileURLToPath(new URL('../../tests/fixtures/bundles/', import.meta.url));
|
|
22
|
+
import { lanAddresses } from "../cli/serve.js";
|
|
23
|
+
import { USER_CERT_DIR } from "../cli/workspace.js";
|
|
24
|
+
import { KID_PAGE } from "./kid-page.js";
|
|
25
|
+
/** 脚本 → 板书节:走真解析器(mock 也是解析器的一次演练) */
|
|
26
|
+
export function sectionFromScript(md) {
|
|
27
|
+
return parseBoard(md).section;
|
|
28
|
+
}
|
|
29
|
+
export const MOCK_TUTORS = [
|
|
30
|
+
{
|
|
31
|
+
name: 'chinese-tutor',
|
|
32
|
+
display: '语文老师',
|
|
33
|
+
subject: '语文',
|
|
34
|
+
avatar: '语',
|
|
35
|
+
motto: '故事里都有道理',
|
|
36
|
+
preloaded: 1,
|
|
37
|
+
firstQuestion: '画蛇添足是什么意思?',
|
|
38
|
+
script: [
|
|
39
|
+
`你有没有过这种事:本来做得好好的,又多加了一点,结果反而糟了?
|
|
40
|
+
|
|
41
|
+
~~~text cover
|
|
42
|
+
画蛇添足
|
|
43
|
+
一个成语,一杯酒的故事
|
|
44
|
+
~~~
|
|
45
|
+
|
|
46
|
+
这个成语说的是:[多做一步,反而坏事]。
|
|
47
|
+
|
|
48
|
+
~~~text note
|
|
49
|
+
画蛇添足 = 多做一步,反而坏事
|
|
50
|
+
~~~
|
|
51
|
+
|
|
52
|
+
它出自《战国策》,原文是这样的。
|
|
53
|
+
|
|
54
|
+
~~~read
|
|
55
|
+
楚有祠者,赐其舍人卮酒。
|
|
56
|
+
舍人相谓曰:数人饮之不足,一人饮之有余。
|
|
57
|
+
请画地为蛇,先成者饮酒。
|
|
58
|
+
~~~
|
|
59
|
+
|
|
60
|
+
几个人分一壶酒,酒不够大家喝,一个人喝正好,就比赛画蛇,[先成者饮酒],谁先画完谁喝。
|
|
61
|
+
|
|
62
|
+
~~~choice
|
|
63
|
+
如果第一个画完蛇的人,不去给蛇添上脚,酒是谁的?
|
|
64
|
+
- [x] 他自己的
|
|
65
|
+
- [ ] 第二个画完的
|
|
66
|
+
- [ ] 大家平分
|
|
67
|
+
~~~
|
|
68
|
+
|
|
69
|
+
我想问你:如果第一个画完蛇的人,不给蛇添脚,酒本来是谁的?`,
|
|
70
|
+
`你是这么想的:酒是他自己的。
|
|
71
|
+
|
|
72
|
+
~~~text note
|
|
73
|
+
先画完的人本来就赢了
|
|
74
|
+
~~~
|
|
75
|
+
|
|
76
|
+
他多画了脚,蛇就[不是蛇]了,第二个画完的说,你画的不是蛇。
|
|
77
|
+
|
|
78
|
+
~~~text quote
|
|
79
|
+
为蛇足者,终亡其酒。
|
|
80
|
+
~~~
|
|
81
|
+
|
|
82
|
+
古人把这件事记成一句话:[为蛇足者,终亡其酒]。
|
|
83
|
+
|
|
84
|
+
~~~fill
|
|
85
|
+
画蛇添足,就是做到了还要___,反而把事情弄糟。
|
|
86
|
+
= 多做一步
|
|
87
|
+
~~~
|
|
88
|
+
|
|
89
|
+
你来填一填:画蛇添足,就是做到了还要什么?`,
|
|
90
|
+
`~~~text note
|
|
91
|
+
做到了,就停下来
|
|
92
|
+
~~~
|
|
93
|
+
|
|
94
|
+
说得好,这就是画蛇添足要提醒我们的:[做到了,就停下来]。
|
|
95
|
+
|
|
96
|
+
~~~canvas
|
|
97
|
+
画一条蛇,不要给它添脚。
|
|
98
|
+
~~~
|
|
99
|
+
|
|
100
|
+
你来画一条蛇,画好了给我看看。`,
|
|
101
|
+
],
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
name: 'math-tutor',
|
|
105
|
+
display: '数学老师',
|
|
106
|
+
subject: '数学',
|
|
107
|
+
avatar: '数',
|
|
108
|
+
motto: '不懂的都来问我',
|
|
109
|
+
preloaded: 1,
|
|
110
|
+
firstQuestion: '三角形的面积怎么算?',
|
|
111
|
+
script: [
|
|
112
|
+
`~~~text cover
|
|
113
|
+
三角形的面积
|
|
114
|
+
两个一样的三角形拼成一个平行四边形
|
|
115
|
+
~~~
|
|
116
|
+
|
|
117
|
+
我们先拿两个一模一样的三角形来拼一拼。
|
|
118
|
+
|
|
119
|
+
~~~text step
|
|
120
|
+
拼
|
|
121
|
+
把两个一样的三角形,一个转过来,和另一个拼在一起
|
|
122
|
+
~~~
|
|
123
|
+
|
|
124
|
+
拼好以后,它们正好变成一个[平行四边形]。
|
|
125
|
+
|
|
126
|
+
先记住一句话,[三角形的面积是平行四边形的一半]。
|
|
127
|
+
|
|
128
|
+
你觉得,这个平行四边形的底和高,跟原来那个三角形的底和高,是一样的,还是不一样?`,
|
|
129
|
+
`你说得对,底和高都一样。
|
|
130
|
+
|
|
131
|
+
~~~text formula
|
|
132
|
+
平行四边形面积 = 底 × 高
|
|
133
|
+
~~~
|
|
134
|
+
|
|
135
|
+
三角形只占平行四边形的一半,所以要在后面除以 2。
|
|
136
|
+
|
|
137
|
+
~~~text formula
|
|
138
|
+
三角形面积 = 底 × 高 ÷ 2
|
|
139
|
+
~~~
|
|
140
|
+
|
|
141
|
+
~~~choice
|
|
142
|
+
底 6 厘米、高 4 厘米的三角形,面积是多少?
|
|
143
|
+
- [ ] 24 平方厘米
|
|
144
|
+
- [x] 12 平方厘米
|
|
145
|
+
- [ ] 10 平方厘米
|
|
146
|
+
~~~
|
|
147
|
+
|
|
148
|
+
你来试试,底 6 厘米、高 4 厘米,面积是多少?`,
|
|
149
|
+
`对,12 平方厘米。
|
|
150
|
+
|
|
151
|
+
我们换一道找规律的题,我把它画出来看。
|
|
152
|
+
|
|
153
|
+
~~~scene
|
|
154
|
+
2026-09-04-guilv5
|
|
155
|
+
75、70、65,后面三个空填什么?
|
|
156
|
+
~~~
|
|
157
|
+
|
|
158
|
+
看我一步一步画。[[play]]
|
|
159
|
+
|
|
160
|
+
看完了,你自己说说,每次少几?`,
|
|
161
|
+
],
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
name: 'reading-tutor',
|
|
165
|
+
display: '朗读老师',
|
|
166
|
+
subject: '英语',
|
|
167
|
+
avatar: '读',
|
|
168
|
+
motto: '一起大声读',
|
|
169
|
+
preloaded: 0,
|
|
170
|
+
firstQuestion: '',
|
|
171
|
+
script: [
|
|
172
|
+
`Today we learn three fruits. 今天学三种水果。
|
|
173
|
+
|
|
174
|
+
~~~text cover
|
|
175
|
+
Fruits
|
|
176
|
+
水果
|
|
177
|
+
~~~
|
|
178
|
+
|
|
179
|
+
~~~read
|
|
180
|
+
apple 苹果
|
|
181
|
+
banana 香蕉
|
|
182
|
+
orange 橘子
|
|
183
|
+
~~~
|
|
184
|
+
|
|
185
|
+
Listen and repeat: [apple], [banana], [orange]. 点一下听一下,跟着我读。
|
|
186
|
+
|
|
187
|
+
~~~image
|
|
188
|
+
vault/照片/fruits.png
|
|
189
|
+
三种水果,你家有哪种?
|
|
190
|
+
~~~
|
|
191
|
+
|
|
192
|
+
Which one do you want to try first, [apple], [banana], or [orange]? 你先读哪一个?`,
|
|
193
|
+
],
|
|
194
|
+
},
|
|
195
|
+
];
|
|
196
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
197
|
+
const localDate = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
|
198
|
+
const isObj = (x) => typeof x === 'object' && x !== null && !Array.isArray(x);
|
|
199
|
+
export function createMock(opts = {}) {
|
|
200
|
+
const scenario = opts.scenario ?? 'normal';
|
|
201
|
+
const delay = opts.delayMs ?? 1800;
|
|
202
|
+
const now = opts.now ?? (() => new Date());
|
|
203
|
+
const title = opts.title ?? '小明的老师们';
|
|
204
|
+
const messages = new Map();
|
|
205
|
+
const cursor = new Map();
|
|
206
|
+
const inflight = new Map();
|
|
207
|
+
let seq = 0;
|
|
208
|
+
const nextJob = () => { const d = now(); return `${pad(d.getHours())}${pad(d.getMinutes())}-${++seq}`; };
|
|
209
|
+
for (const t of MOCK_TUTORS) {
|
|
210
|
+
const list = [];
|
|
211
|
+
for (let i = 0; i < t.preloaded && i < t.script.length; i++) {
|
|
212
|
+
const section = sectionFromScript(t.script[i]);
|
|
213
|
+
list.push({ job: nextJob(), at: now().toISOString(), question: i === 0 ? t.firstQuestion : '继续', reply: section.lines[section.lines.length - 1]?.text ?? null, audio: null, pending: false, artifacts: [], section });
|
|
214
|
+
}
|
|
215
|
+
messages.set(t.name, list);
|
|
216
|
+
cursor.set(t.name, Math.min(t.preloaded, t.script.length));
|
|
217
|
+
}
|
|
218
|
+
const dailyLimit = 30;
|
|
219
|
+
const used = (name) => (messages.get(name) ?? []).filter((m) => m.question !== null && m.action !== 'continue').length;
|
|
220
|
+
/** 下发孩子端的形状:答案剥掉、状态并到卡上、场景卡补课包快照(样本课包在仓库里) */
|
|
221
|
+
const kidMessage = async (m) => {
|
|
222
|
+
const { states, action: _a, ...rest } = m;
|
|
223
|
+
const section = m.section ? await enrichScenes({ bundles: MOCK_BUNDLES_DIR }, stripSecrets({ ...m.section, cards: m.section.cards.map((c, i) => (states && i in states ? { ...c, state: states[i] } : c)) })) : null;
|
|
224
|
+
return { ...rest, section };
|
|
225
|
+
};
|
|
226
|
+
const remaining = (name) => (scenario === 'limit' ? 0 : Math.max(0, dailyLimit - used(name)));
|
|
227
|
+
const tutorsJson = () => MOCK_TUTORS.map((t) => ({ name: t.name, display: t.display, avatar: t.avatar, subject: t.subject, motto: t.motto, hasVoice: false, remaining: remaining(t.name), available: remaining(t.name) > 0 }));
|
|
228
|
+
const timetable = [
|
|
229
|
+
{ day: 1, subject: '数学', start: '17:00', end: '17:20' },
|
|
230
|
+
{ day: 2, subject: '语文', start: '16:00', end: '16:30' },
|
|
231
|
+
{ day: 3, subject: '语文', start: '16:00', end: '16:30' },
|
|
232
|
+
{ day: 3, subject: '数学', start: '17:00', end: '17:20' },
|
|
233
|
+
{ day: 4, subject: '英语', start: '16:30', end: '16:50' },
|
|
234
|
+
{ day: 5, subject: '数学', start: '17:00', end: '17:20' },
|
|
235
|
+
];
|
|
236
|
+
const home = () => {
|
|
237
|
+
const d = now();
|
|
238
|
+
const day = d.getDay() === 0 ? 7 : d.getDay();
|
|
239
|
+
return { title, date: localDate(d), day, timetable, slot: null, tutors: tutorsJson(), stacks: [], suggestions: [{ tutor: 'chinese-tutor', text: '「静夜思」怎么背' }, { tutor: 'math-tutor', text: '25 加 17 怎么算' }, { tutor: 'reading-tutor', text: '再听一遍昨天的故事' }] };
|
|
240
|
+
};
|
|
241
|
+
const answer = (m, full) => {
|
|
242
|
+
if (full) {
|
|
243
|
+
m.section = full;
|
|
244
|
+
m.reply = full.lines[full.lines.length - 1]?.text ?? null;
|
|
245
|
+
}
|
|
246
|
+
else {
|
|
247
|
+
m.section = null;
|
|
248
|
+
m.reply = '这个我们明天接着说,好不好?';
|
|
249
|
+
}
|
|
250
|
+
m.pending = false;
|
|
251
|
+
};
|
|
252
|
+
/** 流式模拟:想的期间每隔一段露一张卡(讲稿句跟到那张卡为止),整段想完才定稿 */
|
|
253
|
+
const think = (t, m) => {
|
|
254
|
+
const i = cursor.get(t.name) ?? 0;
|
|
255
|
+
const step = t.script[i];
|
|
256
|
+
cursor.set(t.name, i + 1);
|
|
257
|
+
const full = step ? sectionFromScript(step) : null;
|
|
258
|
+
const n = full ? full.cards.length : 0;
|
|
259
|
+
const tick = delay / (n + 1);
|
|
260
|
+
return new Promise((resolve) => {
|
|
261
|
+
let k = 0;
|
|
262
|
+
const reveal = () => {
|
|
263
|
+
k++;
|
|
264
|
+
if (k <= n && full) {
|
|
265
|
+
m.section = { cards: full.cards.slice(0, k), lines: full.lines.filter((l) => l.anchor !== null && l.anchor < k - 1), partial: true };
|
|
266
|
+
setTimeout(reveal, tick);
|
|
267
|
+
}
|
|
268
|
+
else {
|
|
269
|
+
answer(m, full);
|
|
270
|
+
resolve();
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
setTimeout(reveal, tick);
|
|
274
|
+
});
|
|
275
|
+
};
|
|
276
|
+
const route = async (method, path, body) => {
|
|
277
|
+
const url = new URL(path, 'http://x');
|
|
278
|
+
const p = url.pathname;
|
|
279
|
+
if (p === '/')
|
|
280
|
+
return { status: 200, html: KID_PAGE.replace('__TITLE__', title) };
|
|
281
|
+
if (p === '/api/health')
|
|
282
|
+
return { status: 200, json: { ok: scenario !== 'offline', mock: true, scenario } };
|
|
283
|
+
if (scenario === 'offline' && p.startsWith('/api/'))
|
|
284
|
+
return { status: 500, json: { error: 'mock_offline' } };
|
|
285
|
+
if (p === '/api/kid/home' && method === 'GET')
|
|
286
|
+
return { status: 200, json: home() };
|
|
287
|
+
const kid = /^\/api\/kid\/conversations\/([a-z0-9][a-z0-9-]*)\/(today|messages)$/.exec(p);
|
|
288
|
+
if (kid) {
|
|
289
|
+
const [, name, tail] = kid;
|
|
290
|
+
const t = MOCK_TUTORS.find((x) => x.name === name);
|
|
291
|
+
if (!t)
|
|
292
|
+
return { status: 404, json: { error: 'no_such_tutor' } };
|
|
293
|
+
const list = messages.get(name) ?? [];
|
|
294
|
+
const date = localDate(now());
|
|
295
|
+
if (tail === 'today' && method === 'GET') {
|
|
296
|
+
const pending = list.find((m) => m.pending);
|
|
297
|
+
return { status: 200, json: { tutor: name, date, messages: await Promise.all(list.map(kidMessage)), remaining: remaining(name), pending: pending ? pending.job : null } };
|
|
298
|
+
}
|
|
299
|
+
if (tail === 'messages' && method === 'POST') {
|
|
300
|
+
if (!isObj(body) || typeof body.text !== 'string')
|
|
301
|
+
return { status: 400, json: { error: 'bad_request' } };
|
|
302
|
+
const action = body.action === 'continue' || body.action === 'submit' ? body.action : undefined;
|
|
303
|
+
if (!body.text.trim() && !action)
|
|
304
|
+
return { status: 400, json: { error: 'bad_request' } };
|
|
305
|
+
if (action !== 'continue' && remaining(name) <= 0)
|
|
306
|
+
return { status: 429, json: { error: 'limit', remaining: 0 } };
|
|
307
|
+
if (list.some((m) => m.pending))
|
|
308
|
+
return { status: 409, json: { error: 'busy' } };
|
|
309
|
+
const text = body.text.trim() || (action === 'continue' ? '继续' : '(交了答案,没说话)');
|
|
310
|
+
const m = { job: nextJob(), at: now().toISOString(), question: text, reply: null, audio: null, pending: true, artifacts: [], section: null, ...(action ? { action } : {}) };
|
|
311
|
+
list.push(m);
|
|
312
|
+
const done = think(t, m).then(() => { inflight.delete(m.job); });
|
|
313
|
+
inflight.set(m.job, done);
|
|
314
|
+
return { status: 202, json: { tutor: name, date, job: m.job } };
|
|
315
|
+
}
|
|
316
|
+
return { status: 405, json: { error: 'method_not_allowed' } };
|
|
317
|
+
}
|
|
318
|
+
const card = /^\/api\/kid\/conversations\/([a-z0-9][a-z0-9-]*)\/cards\/([^/]+)\/(\d+)$/.exec(p);
|
|
319
|
+
if (card) {
|
|
320
|
+
const [, name, job, n] = card;
|
|
321
|
+
const m = (messages.get(name) ?? []).find((x) => x.job === job);
|
|
322
|
+
const target = m?.section?.cards[Number(n)];
|
|
323
|
+
if (!target)
|
|
324
|
+
return { status: 404, json: { error: 'no_such_card' } };
|
|
325
|
+
if (method !== 'PUT')
|
|
326
|
+
return { status: 405, json: { error: 'method_not_allowed' } };
|
|
327
|
+
const r = parseCardState(target, body);
|
|
328
|
+
if (!r.ok)
|
|
329
|
+
return { status: 400, json: { error: 'bad_state' } };
|
|
330
|
+
(m.states ??= {})[Number(n)] = r.state;
|
|
331
|
+
return { status: 200, json: { ok: true, card: `${job}/${n}` } };
|
|
332
|
+
}
|
|
333
|
+
if (p === '/api/kid/image') {
|
|
334
|
+
// 图片卡:任何路径都给一张占位 svg(写着路径),前端能看到版式
|
|
335
|
+
const rel = url.searchParams.get('p') ?? '';
|
|
336
|
+
const esc = rel.replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c] ?? c);
|
|
337
|
+
return { status: 200, contentType: 'image/svg+xml', html: `<svg xmlns="http://www.w3.org/2000/svg" width="800" height="500"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#f7d9a8"/><stop offset="1" stop-color="#8fbf9f"/></linearGradient></defs><rect width="800" height="500" fill="url(#g)"/><circle cx="260" cy="250" r="90" fill="#e8743b"/><circle cx="420" cy="230" r="80" fill="#f4c542"/><circle cx="560" cy="270" r="85" fill="#e0508a"/><text x="400" y="460" font-size="22" text-anchor="middle" fill="#2b2b2b" font-family="sans-serif">${esc}</text></svg>` };
|
|
338
|
+
}
|
|
339
|
+
if (p.startsWith('/stage/') && method === 'GET') {
|
|
340
|
+
const f = await stageAsset(p);
|
|
341
|
+
return f ? { status: 200, file: f.file, contentType: f.contentType } : { status: 404, json: { error: 'not_found' } };
|
|
342
|
+
}
|
|
343
|
+
if (p.startsWith('/api/bundles/') && method === 'GET') {
|
|
344
|
+
const f = await bundleAsset(MOCK_BUNDLES_DIR, p);
|
|
345
|
+
return f ? { status: 200, file: f.file, contentType: f.contentType } : { status: 404, json: { error: 'not_found' } };
|
|
346
|
+
}
|
|
347
|
+
if (p.startsWith('/api/audio/'))
|
|
348
|
+
return { status: 404, json: { error: 'not_found' } };
|
|
349
|
+
if (p.startsWith('/api/'))
|
|
350
|
+
return { status: 404, json: { error: 'not_found', path: p } };
|
|
351
|
+
return { status: 404, json: { error: 'not_found', path: p } };
|
|
352
|
+
};
|
|
353
|
+
const settle = async () => { await Promise.all([...inflight.values()]); };
|
|
354
|
+
const handler = (req, res) => {
|
|
355
|
+
void (async () => {
|
|
356
|
+
const chunks = [];
|
|
357
|
+
for await (const c of req)
|
|
358
|
+
chunks.push(c);
|
|
359
|
+
let body;
|
|
360
|
+
try {
|
|
361
|
+
body = chunks.length ? JSON.parse(Buffer.concat(chunks).toString('utf8')) : undefined;
|
|
362
|
+
}
|
|
363
|
+
catch {
|
|
364
|
+
body = undefined;
|
|
365
|
+
}
|
|
366
|
+
const r = await route(req.method ?? 'GET', req.url ?? '/', body);
|
|
367
|
+
if (r.file !== undefined) {
|
|
368
|
+
res.writeHead(r.status, { 'content-type': r.contentType ?? 'application/octet-stream', 'cache-control': 'private, max-age=3600' });
|
|
369
|
+
createReadStream(r.file).on('error', () => res.end()).pipe(res);
|
|
370
|
+
}
|
|
371
|
+
else if (r.html !== undefined) {
|
|
372
|
+
res.writeHead(r.status, { 'content-type': r.contentType ?? 'text/html; charset=utf-8', 'cache-control': 'no-store' });
|
|
373
|
+
res.end(r.html);
|
|
374
|
+
}
|
|
375
|
+
else {
|
|
376
|
+
res.writeHead(r.status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
|
|
377
|
+
res.end(JSON.stringify(r.json ?? null));
|
|
378
|
+
}
|
|
379
|
+
})().catch((err) => { res.writeHead(500, { 'content-type': 'application/json' }); res.end(JSON.stringify({ error: 'internal', message: String(err) })); });
|
|
380
|
+
};
|
|
381
|
+
return { route, settle, handler };
|
|
382
|
+
}
|
|
383
|
+
export async function serveMock(opts = {}) {
|
|
384
|
+
const mock = createMock(opts);
|
|
385
|
+
let tls = null;
|
|
386
|
+
if (!opts.http) {
|
|
387
|
+
try {
|
|
388
|
+
tls = { cert: readFileSync(join(USER_CERT_DIR, 'cert.pem')), key: readFileSync(join(USER_CERT_DIR, 'key.pem')) };
|
|
389
|
+
}
|
|
390
|
+
catch {
|
|
391
|
+
tls = null;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
const server = tls ? createHttps(tls, mock.handler) : createHttp(mock.handler);
|
|
395
|
+
await new Promise((resolve, reject) => {
|
|
396
|
+
server.once('error', reject);
|
|
397
|
+
server.listen(opts.port ?? 8790, opts.host ?? '0.0.0.0', () => resolve());
|
|
398
|
+
});
|
|
399
|
+
const port = server.address().port;
|
|
400
|
+
const scheme = tls ? 'https' : 'http';
|
|
401
|
+
const urls = [hostname(), ...lanAddresses()].map((h) => `${scheme}://${h}:${port}/`);
|
|
402
|
+
return { server, mock, port, https: Boolean(tls), urls };
|
|
403
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 家长端 /parent:三个标签(对话 / 老师团 / 设置),零依赖内联脚本,只走 /api/*。
|
|
3
|
+
* 对话 = 家长视图:主线说话、工具行折叠、子代理折叠、待裁量按钮、孩子视图预览(kidText)、出错红条。
|
|
4
|
+
* 老师团 = 老师卡片(人设与政策 → PATCH /api/config;老师文件正文 → /api/tutors/<name>/file;新老师 → POST /api/tutors;自家的能删)。
|
|
5
|
+
* 设置 = paths / 端口 / 证书 / 配音命令,同样只写 cotutor.json;kid、version、运行时模板留给编辑器。
|
|
6
|
+
* 放在 .ts 里而不是 .html,是因为 tsc 不拷贝静态文件,dist 里就少一份。
|
|
7
|
+
*/
|
|
8
|
+
export declare const PARENT_PAGE = "<!doctype html>\n<html lang=\"zh-CN\">\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n<title>cotutor \u5BB6\u957F\u7AEF</title>\n<style>\n :root { --bg:#f7f7f5; --panel:#fff; --line:#e3e3df; --ink:#222; --dim:#777; --accent:#2b6cb0; --kid:#e8f3ff; --parent:#fff4e0; --sys:#eee; --err:#c0392b; --ok:#2e7d32; }\n * { box-sizing:border-box; }\n body { margin:0; background:var(--bg); color:var(--ink); font:15px/1.5 -apple-system,\"PingFang SC\",\"Helvetica Neue\",sans-serif; }\n header { display:flex; align-items:center; gap:16px; padding:10px 16px; background:var(--panel); border-bottom:1px solid var(--line); }\n header h1 { font-size:16px; margin:0; }\n header nav a { margin-right:12px; color:var(--dim); text-decoration:none; padding:4px 0; border-bottom:2px solid transparent; }\n header nav a.on { color:var(--ink); border-color:var(--accent); }\n header .health { margin-left:auto; font-size:12px; color:var(--dim); }\n header .health.bad { color:var(--err); }\n main { display:none; }\n main.on { display:block; }\n /* \u5BF9\u8BDD */\n #chat { display:grid; grid-template-columns:220px 1fr; height:calc(100vh - 47px); }\n #chat.on { display:grid; }\n aside { border-right:1px solid var(--line); background:var(--panel); overflow:auto; }\n aside .tutor { display:flex; gap:8px; align-items:center; padding:10px 12px; cursor:pointer; border-bottom:1px solid var(--line); }\n aside .tutor.on { background:var(--kid); }\n aside .tutor .av { font-size:22px; }\n aside .tutor small { display:block; color:var(--dim); font-size:12px; }\n aside .dates { padding:8px 12px; }\n aside .dates select { width:100%; }\n #thread { display:flex; flex-direction:column; min-width:0; }\n #msgs { flex:1; overflow:auto; padding:16px; }\n .msg { max-width:900px; margin:0 auto 18px; }\n .bubble { padding:8px 12px; border-radius:10px; white-space:pre-wrap; word-break:break-word; }\n .from-kid .bubble.in { background:var(--kid); }\n .from-parent .bubble.in { background:var(--parent); }\n .from-system .bubble.in { background:var(--sys); }\n .meta { font-size:12px; color:var(--dim); margin:2px 4px; }\n .run { margin:8px 0 0 16px; border-left:3px solid var(--line); padding-left:12px; }\n .run .say { white-space:pre-wrap; margin:4px 0 8px; }\n .run .tool, .run .done { font:12px/1.5 ui-monospace,Menlo,monospace; color:var(--dim); white-space:pre-wrap; }\n .run .tool-error { font:12px/1.5 ui-monospace,Menlo,monospace; color:var(--err); white-space:pre-wrap; }\n .run details { margin:4px 0; font-size:12px; color:var(--dim); }\n .run details summary { cursor:pointer; }\n .run .sub { border-left:2px dashed var(--line); padding-left:8px; margin:4px 0; }\n .kid { margin:8px 0 0 16px; font-size:13px; color:var(--dim); }\n .kid b { color:var(--ok); font-weight:500; }\n .kid .none { color:var(--dim); font-style:italic; }\n .holdup { margin:8px 0 0 16px; padding:10px 12px; background:#fffbe6; border:1px solid #f0e0a0; border-radius:8px; }\n .holdup .q { font-weight:600; margin-bottom:6px; }\n .holdup button { margin:2px 6px 2px 0; }\n .holdup small { color:var(--dim); }\n .err { margin:8px 0 0 16px; padding:8px 12px; background:#fdecea; color:var(--err); border-radius:8px; font:12px/1.5 ui-monospace,Menlo,monospace; white-space:pre-wrap; }\n .running { color:var(--accent); font-size:13px; margin-left:16px; }\n #composer { border-top:1px solid var(--line); background:var(--panel); padding:10px 16px; display:flex; gap:8px; align-items:flex-end; }\n #composer textarea { flex:1; min-height:44px; max-height:160px; font:inherit; padding:8px; border:1px solid var(--line); border-radius:8px; resize:vertical; }\n #composer select, #composer button { font:inherit; padding:8px 10px; }\n #composer button { background:var(--accent); color:#fff; border:0; border-radius:8px; cursor:pointer; }\n #composer button:disabled { opacity:.5; cursor:default; }\n .empty { color:var(--dim); text-align:center; margin-top:60px; }\n /* \u8001\u5E08\u56E2 */\n #team, #settings { padding:16px; max-width:1000px; margin:0 auto; }\n .card { background:var(--panel); border:1px solid var(--line); border-radius:10px; padding:14px 16px; margin-bottom:14px; }\n .card h2 { font-size:15px; margin:0 0 10px; display:flex; align-items:center; gap:8px; }\n .card h2 .av { font-size:22px; }\n .card h2 .off { color:var(--dim); font-weight:400; font-size:12px; }\n .grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(200px,1fr)); gap:8px 16px; }\n .grid label { display:flex; flex-direction:column; font-size:12px; color:var(--dim); }\n .grid input[type=text], .grid input[type=number], .grid select { font:inherit; padding:5px 7px; border:1px solid var(--line); border-radius:6px; }\n .grid label.chk { flex-direction:row; align-items:center; gap:6px; font-size:14px; color:var(--ink); }\n .card .actions { margin-top:10px; display:flex; gap:8px; align-items:center; }\n .card .actions button { font:inherit; padding:6px 12px; border-radius:6px; border:1px solid var(--line); background:#fff; cursor:pointer; }\n .card .actions button.primary { background:var(--accent); color:#fff; border-color:var(--accent); }\n .card .actions .msg-ok { color:var(--ok); font-size:13px; }\n .card .actions .msg-err { color:var(--err); font-size:13px; white-space:pre-wrap; }\n .hint { color:var(--dim); font-size:12px; }\n .card textarea { width:100%; min-height:260px; font:13px/1.5 ui-monospace,Menlo,monospace; padding:8px; border:1px solid var(--line); border-radius:6px; resize:vertical; }\n .card details { margin-top:10px; }\n .card details summary { cursor:pointer; color:var(--dim); font-size:13px; }\n .card .danger { color:var(--err); border-color:#f0c0c0 !important; }\n .card .origin { font-size:12px; color:var(--dim); margin-left:auto; }\n @media (max-width:700px) { #chat { grid-template-columns:1fr; } aside { display:none; } }\n</style>\n<header>\n <h1 id=\"title\">cotutor</h1>\n <nav><a href=\"#chat\" data-tab=\"chat\" class=\"on\">\u5BF9\u8BDD</a><a href=\"#team\" data-tab=\"team\">\u8001\u5E08\u56E2</a><a href=\"#settings\" data-tab=\"settings\">\u8BBE\u7F6E</a></nav>\n <span class=\"health\" id=\"health\"></span>\n</header>\n<main id=\"chat\" class=\"on\">\n <aside>\n <div id=\"tutors\"></div>\n <div class=\"dates\"><select id=\"dates\"></select></div>\n </aside>\n <section id=\"thread\">\n <div id=\"msgs\"><p class=\"empty\">\u5DE6\u8FB9\u9009\u4E00\u4F4D\u8001\u5E08</p></div>\n <form id=\"composer\">\n <select id=\"from\" title=\"\u4EE5\u8C01\u7684\u8EAB\u4EFD\u8BF4\"><option value=\"parent\">\u5BB6\u957F</option><option value=\"kid\">\u5B69\u5B50(\u6A21\u62DF)</option><option value=\"system\">\u7CFB\u7EDF</option></select>\n <select id=\"runtime\" title=\"\u8FD0\u884C\u65F6\"></select>\n <textarea id=\"text\" placeholder=\"\u5BF9\u8001\u5E08\u8BF4\u2026\u2026(\u2318/Ctrl+Enter \u53D1\u9001)\"></textarea>\n <button type=\"submit\" id=\"send\">\u53D1\u9001</button>\n </form>\n </section>\n</main>\n<main id=\"team\"></main>\n<main id=\"settings\"></main>\n<script>\n(() => {\n const $ = (s, el = document) => el.querySelector(s);\n const h = (tag, attrs = {}, ...kids) => {\n const el = document.createElement(tag);\n for (const [k, v] of Object.entries(attrs)) {\n if (k === 'class') el.className = v; else if (k === 'on') for (const [e, f] of Object.entries(v)) el.addEventListener(e, f); else if (v !== undefined && v !== null) el.setAttribute(k, v);\n }\n for (const k of kids.flat()) if (k !== null && k !== undefined) el.append(k.nodeType ? k : document.createTextNode(String(k)));\n return el;\n };\n const api = async (method, path, body) => {\n const r = await fetch(path, { method, headers: body ? { 'content-type': 'application/json' } : {}, body: body ? JSON.stringify(body) : undefined });\n const j = await r.json().catch(() => null);\n if (!r.ok) throw new Error((j && j.message) || (j && j.error) || (r.status + ' ' + r.statusText));\n return j;\n };\n\n const state = { config: null, tutor: null, date: null, dates: [], view: null, timer: null, tab: location.hash === '#team' ? 'team' : location.hash === '#settings' ? 'settings' : 'chat' };\n\n // ---- \u9876\u680F\u4E0E\u6807\u7B7E ----\n const showTab = (tab) => {\n state.tab = tab;\n for (const a of document.querySelectorAll('header nav a')) a.classList.toggle('on', a.dataset.tab === tab);\n for (const m of document.querySelectorAll('main')) m.classList.toggle('on', m.id === tab);\n if (tab === 'team') renderTeam();\n if (tab === 'settings') renderSettings();\n };\n for (const a of document.querySelectorAll('header nav a')) a.addEventListener('click', (e) => { e.preventDefault(); location.hash = '#' + a.dataset.tab; showTab(a.dataset.tab); });\n\n const refreshHealth = async () => {\n try {\n const hh = await api('GET', '/api/health');\n const el = $('#health');\n el.textContent = hh.configError ? '\u26A0 cotutor.json \u6539\u574F\u4E86,\u4ECD\u7528\u4E0A\u4E00\u4EFD:' + hh.configError.split('\\n')[0] : '';\n el.classList.toggle('bad', Boolean(hh.configError));\n } catch (e) { $('#health').textContent = '\u540E\u7AEF\u4E0D\u901A'; $('#health').classList.add('bad'); }\n };\n\n const loadConfig = async () => {\n state.config = await api('GET', '/api/config');\n $('#title').textContent = state.config.title + ' \u00B7 \u5BB6\u957F\u7AEF';\n document.title = state.config.title + ' \u00B7 \u5BB6\u957F\u7AEF';\n const ps = $('#runtime');\n ps.replaceChildren(...state.config.runtimes.map((p) => h('option', { value: p, selected: p === state.config.runtime ? '' : undefined }, p === state.config.runtime ? p + '(\u7F3A\u7701)' : p)));\n renderTutors();\n };\n\n // ---- \u5BF9\u8BDD:\u8001\u5E08\u5217\u8868(enabled \u7684\u624D\u6709,\u5173\u6389\u5373\u6D88\u5931;hidden \u7684\u5BB6\u957F\u4ECD\u53EF\u89C1)----\n const renderTutors = () => {\n const list = state.config.tutors.filter((t) => t.enabled);\n if (state.tutor && !list.some((t) => t.name === state.tutor)) { state.tutor = null; state.view = null; $('#msgs').replaceChildren(h('p', { class: 'empty' }, '\u8FD9\u4F4D\u8001\u5E08\u5DF2\u5173\u95ED')); }\n $('#tutors').replaceChildren(...list.map((t) => h('div', { class: 'tutor' + (t.name === state.tutor ? ' on' : ''), on: { click: () => pickTutor(t.name) } },\n h('span', { class: 'av' }, t.avatar || '\uD83D\uDE42'),\n h('div', {}, t.display, h('small', {}, t.name + (t.hidden ? ' \u00B7 \u5B69\u5B50\u7AEF\u4E0D\u9732' : '') + (t.subject ? ' \u00B7 ' + t.subject : ''))))));\n };\n\n const pickTutor = async (name, date) => {\n state.tutor = name;\n renderTutors();\n const d = await api('GET', '/api/conversations/' + name);\n state.dates = d.dates.includes(d.today) ? d.dates : [d.today, ...d.dates];\n state.date = date || d.today;\n $('#dates').replaceChildren(...state.dates.map((x) => h('option', { value: x, selected: x === state.date ? '' : undefined }, x === d.today ? x + '(\u4ECA\u5929)' : x)));\n await loadDay();\n };\n $('#dates').addEventListener('change', () => { state.date = $('#dates').value; loadDay(); });\n\n const loadDay = async () => {\n if (!state.tutor || !state.date) return;\n const stick = $('#msgs').scrollHeight - $('#msgs').scrollTop - $('#msgs').clientHeight < 40;\n state.view = await api('GET', '/api/conversations/' + state.tutor + '/' + state.date);\n renderDay();\n if (stick) $('#msgs').scrollTop = $('#msgs').scrollHeight;\n clearTimeout(state.timer);\n if (state.view.running) state.timer = setTimeout(loadDay, 2000);\n $('#send').disabled = Boolean(state.view.running);\n };\n\n const rowsEl = (rows) => {\n const out = [];\n let subs = [];\n const flushSubs = () => { if (subs.length) { out.push(h('details', { class: 'sub' }, h('summary', {}, '\u5B50\u4EE3\u7406 \u00B7 ' + subs.length + ' \u6761'), ...subs)); subs = []; } };\n const one = (r) => {\n if (r.kind === 'fold') return h('details', {}, h('summary', {}, '\u7528\u4E86 ' + r.tools.length + ' \u6B65\u5DE5\u5177'), ...r.tools.map((t) => h('div', { class: 'tool' }, t.text)));\n if (r.kind === 'text') return h('div', { class: 'say' }, r.text);\n return h('div', { class: r.kind }, r.text);\n };\n for (const r of rows) {\n if (r.kind !== 'fold' && r.sub) { subs.push(one(r)); continue; }\n flushSubs();\n out.push(one(r));\n }\n flushSubs();\n return out;\n };\n\n const renderDay = () => {\n const v = state.view;\n const t = state.config.tutors.find((x) => x.name === state.tutor) || { display: state.tutor };\n if (!v.index.messages.length) { $('#msgs').replaceChildren(h('p', { class: 'empty' }, state.date + ' \u8FD8\u6CA1\u548C' + t.display + '\u8BF4\u8FC7\u8BDD')); return; }\n const FROM = { kid: '\u5B69\u5B50', parent: '\u5BB6\u957F', system: '\u7CFB\u7EDF' };\n const nodes = v.index.messages.map((m) => {\n const el = h('div', { class: 'msg from-' + m.from });\n el.append(h('div', { class: 'meta' }, FROM[m.from] || m.from, ' \u00B7 ', m.at, ' \u00B7 ', m.job, m.runtime ? ' \u00B7 ' + m.runtime : '', m.focus && m.focus.artifact ? ' \u00B7 \u770B\u7740 ' + m.focus.artifact + (m.focus.step !== undefined ? ' \u7B2C ' + m.focus.step + ' \u6B65' : '') : '', m.focus && m.focus.card ? ' \u00B7 \u5F00\u7740\u5361 ' + m.focus.card : '', m.action === 'continue' ? ' \u00B7 \u7EE7\u7EED' : m.action === 'submit' ? ' \u00B7 \u4EA4\u7ED9\u8001\u5E08' : ''));\n if (m.cards && m.cards.length) el.append(h('div', { class: 'kid' }, '\u5B69\u5B50\u5728\u677F\u4E66\u4E0A\u505A\u7684:', ...m.cards.map((c) => h('div', {}, h('b', {}, c.card + ' ' + c.text)))));\n el.append(h('div', { class: 'bubble in' }, m.text));\n const rows = v.runs[m.job] || [];\n const run = h('div', { class: 'run' }, h('div', { class: 'meta' }, (t.avatar || '') + ' ' + t.display), ...rowsEl(rows));\n if (m.result === 'running') run.append(h('div', { class: 'running' }, v.running === m.job ? '\u8001\u5E08\u5728\u60F3\u2026\u2026' : '(\u6CA1\u8DD1\u5B8C:\u670D\u52A1\u91CD\u542F\u8FC7\u6216\u8FDB\u7A0B\u88AB\u6740,\u770B ' + m.job + '.err.log)'));\n el.append(run);\n if (m.result === 'error') el.append(h('div', { class: 'err' }, '\u672C\u8F6E\u51FA\u9519:' + (m.error || '\u672A\u77E5') + (v.errors[m.job] ? '\\n' + v.errors[m.job] : '')));\n if (m.result === 'ok') el.append(h('div', { class: 'kid' }, '\u5B69\u5B50\u770B\u5230:', m.kidText ? h('b', {}, m.kidText) : h('span', { class: 'none' }, '(\u8FD9\u8F6E\u6CA1\u6709\u7ED9\u5B69\u5B50\u7684\u8BDD)'), m.section && m.section.cards.length ? h('span', { class: 'none' }, ' \u00B7 \u677F\u4E66 ' + m.section.cards.length + ' \u5F20\u5361 ' + m.section.lines.length + ' \u53E5') : null));\n if (m.parentText) el.append(h('div', { class: 'kid' }, '\u7ED9\u5BB6\u957F:', h('b', {}, m.parentText.replace(/^## \u5BB6\u957Fs*/, ''))));\n if (m.warnings && m.warnings.length) el.append(h('div', { class: 'kid' }, h('span', { class: 'none' }, m.warnings.join(';'))));\n if (m.holdup) {\n const card = h('div', { class: 'holdup' }, h('div', { class: 'q' }, '\u5F85\u88C1\u91CF:' + m.holdup.question));\n for (const o of m.holdup.options) card.append(h('button', { type: 'button', on: { click: () => send('\u5F85\u88C1\u91CF\u300C' + m.holdup.question + '\u300D:\u9009\u300C' + o.label + '\u300D', 'parent') } }, o.label + (o.recommended ? ' \u2605' : '')), o.note ? h('small', {}, o.note + ' ') : null);\n if (!m.holdup.options.length) card.append(h('small', {}, '(\u6CA1\u7ED9\u9009\u9879,\u76F4\u63A5\u5728\u4E0B\u9762\u56DE\u590D)'));\n el.append(card);\n }\n if (m.handoff) el.append(h('div', { class: 'kid' }, '\u8F6C\u4EA4 \u2192 ' + m.handoff.to + (m.handoff.why ? ':' + m.handoff.why : ''), m.handoffJob ? h('span', { class: 'none' }, ' \u00B7 \u5DF2\u8D77 ' + m.handoffJob.tutor + ' \u7684 ' + m.handoffJob.job) : h('span', { class: 'none' }, ' \u00B7 \u6CA1\u8D77(\u89C1\u63D0\u9192)')));\n return el;\n });\n nodes.push(h('div', { class: 'meta', style: 'text-align:center' }, '\u4ECA\u65E5\u8D39\u7528 $' + v.index.costUsd.toFixed(2), v.index.session ? ' \u00B7 \u4F1A\u8BDD ' + v.index.session.id.slice(0, 8) + '(' + v.index.session.runtime + ')' : ''));\n $('#msgs').replaceChildren(...nodes);\n };\n\n const send = async (text, from) => {\n if (!state.tutor) return alert('\u5148\u9009\u4E00\u4F4D\u8001\u5E08');\n const today = state.dates[0];\n try {\n $('#send').disabled = true;\n await api('POST', '/api/conversations/' + state.tutor + '/messages', { text, from: from || $('#from').value, runtime: $('#runtime').value });\n $('#text').value = '';\n if (state.date !== today) await pickTutor(state.tutor, today); else await loadDay();\n } catch (e) { alert(e.message); $('#send').disabled = false; }\n };\n $('#composer').addEventListener('submit', (e) => { e.preventDefault(); const t = $('#text').value.trim(); if (t) send(t); });\n $('#text').addEventListener('keydown', (e) => { if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); $('#composer').requestSubmit(); } });\n\n // ---- \u8001\u5E08\u56E2:\u53EA\u8BFB\u5199 cotutor.json ----\n const POLICY_FIELDS = [['replyMaxChars', '\u5355\u6761\u56DE\u590D\u5B57\u6570\u4E0A\u9650', 'number'], ['dailyMessages', '\u6BCF\u65E5\u6D88\u606F\u4E0A\u9650', 'number'], ['dailyRegen', '\u6BCF\u65E5\u91CD\u751F\u4E0A\u9650', 'number'], ['reviewGate', '\u9A8C\u6536\u5F00\u5173(\u5148\u7ECF\u5BB6\u957F)', 'bool'], ['forms', '\u56DE\u590D\u5F62\u5F0F(\u9017\u53F7\u5206\u9694 L0-L4)', 'forms'], ['contextPack.recent', '\u4E0A\u4E0B\u6587\u5305:\u6700\u8FD1\u89C2\u5BDF\u6761\u6570', 'number'], ['contextPack.planLines', '\u4E0A\u4E0B\u6587\u5305:\u8BA1\u5212\u884C\u6570', 'number']];\n const getPath = (o, p) => p.split('.').reduce((a, k) => (a == null ? undefined : a[k]), o);\n const setPath = (o, p, v) => { const ks = p.split('.'); let cur = o; for (const k of ks.slice(0, -1)) cur = cur[k] = cur[k] || {}; cur[ks[ks.length - 1]] = v; };\n\n const policyInputs = (patch, effective, opts) => POLICY_FIELDS.map(([key, label, type]) => {\n const cur = getPath(patch, key);\n const eff = getPath(effective, key);\n const inherit = opts.inherit && cur === undefined;\n if (type === 'bool') {\n const sel = h('select', { 'data-key': key }, opts.inherit ? h('option', { value: '', selected: inherit ? '' : undefined }, '\u7EE7\u627F(' + (eff ? '\u5F00' : '\u5173') + ')') : null, h('option', { value: 'true', selected: cur === true ? '' : undefined }, '\u5F00'), h('option', { value: 'false', selected: cur === false || (!opts.inherit && cur === undefined && eff === false) ? '' : undefined }, '\u5173'));\n return h('label', {}, label, sel);\n }\n const val = cur === undefined ? '' : type === 'forms' ? cur.join(',') : String(cur);\n return h('label', {}, label, h('input', { type: type === 'number' ? 'number' : 'text', 'data-key': key, value: val, placeholder: (inherit ? '\u7EE7\u627F:' : '') + (type === 'forms' ? (eff || []).join(',') : eff) }));\n });\n const readPolicy = (card) => {\n const out = {};\n for (const el of card.querySelectorAll('[data-key]')) {\n const key = el.dataset.key;\n const type = POLICY_FIELDS.find((f) => f[0] === key)[2];\n const raw = el.value.trim();\n if (raw === '') { setPath(out, key, null); continue; }\n if (type === 'number') { const n = Number(raw); if (!Number.isInteger(n)) throw new Error(key + ' \u8981\u662F\u6574\u6570'); setPath(out, key, n); }\n else if (type === 'bool') setPath(out, key, raw === 'true');\n else setPath(out, key, raw.split(/[,,\\s]+/).filter(Boolean));\n }\n return out;\n };\n const feedback = (card, ok, text) => { const m = $('.actions .fb', card); m.className = 'fb ' + (ok ? 'msg-ok' : 'msg-err'); m.textContent = text; };\n const patchAndReload = async (card, patch) => {\n try { await api('PATCH', '/api/config', patch); await loadConfig(); feedback(card, true, '\u5DF2\u5199\u5165 cotutor.json'); if (state.tab === 'team') renderTeam(); }\n catch (e) { feedback(card, false, e.message); }\n };\n\n const AVATARS = ['\uD83E\uDDEE', '\uD83D\uDCDA', '\uD83D\uDCD6', '\uD83D\uDD2C', '\uD83C\uDFA8', '\uD83C\uDFB5', '\uD83C\uDF0D', '\uD83D\uDCBB', '\uD83C\uDFC3', '\uD83E\uDDE9', '\uD83D\uDCF7', '\uD83D\uDDD3'];\n const newTutorCard = () => {\n const card = h('div', { class: 'card' }, h('h2', {}, '\u2795 \u65B0\u8001\u5E08'), h('div', { class: 'grid' },\n h('label', {}, '\u8001\u5E08\u540D(\u82F1\u6587\u952E,\u5982 science-tutor)', h('input', { type: 'text', id: 'n-name', placeholder: 'science-tutor' })),\n h('label', {}, '\u663E\u793A\u540D', h('input', { type: 'text', id: 'n-display', placeholder: '\u79D1\u5B66\u8001\u5E08' })),\n h('label', {}, '\u5B66\u79D1(\u53EF\u7A7A)', h('input', { type: 'text', id: 'n-subject', placeholder: '\u79D1\u5B66' })),\n h('label', {}, '\u5934\u50CF', h('select', { id: 'n-avatar' }, ...AVATARS.map((a) => h('option', { value: a }, a)))),\n h('label', { class: 'chk' }, h('input', { type: 'checkbox', id: 'n-hidden' }), '\u5B69\u5B50\u7AEF\u4E0D\u9732')),\n h('p', { class: 'hint' }, '\u4F1A\u5199\u4E00\u4EFD\u5E26\u5168\u90E8\u7EA6\u5B9A\u7684\u8001\u5E08\u6587\u4EF6\u3001\u8FDB cotutor.json\u3001\u5EFA\u76EE\u5F55;\u4E4B\u540E\u5728\u4E0B\u9762\u8FD9\u4F4D\u8001\u5E08\u7684\u5361\u91CC\u6539\u300C\u8001\u5E08\u6587\u4EF6\u300D\u628A\u6027\u5B50\u586B\u4E0A\u3002\u4E0E\u7EC8\u7AEF cotutor add \u540C\u4E00\u6761\u8DEF\u3002'),\n h('div', { class: 'actions' }, h('button', { class: 'primary', type: 'button', on: { click: async () => {\n const name = $('#n-name').value.trim(), display = $('#n-display').value.trim();\n if (!/^[a-z0-9][a-z0-9-]*$/.test(name)) return feedback(card, false, '\u8001\u5E08\u540D\u8981\u5C0F\u5199\u5B57\u6BCD\u6570\u5B57\u8FDE\u5B57\u7B26');\n if (!display) return feedback(card, false, '\u663E\u793A\u540D\u4E0D\u80FD\u7A7A');\n try { await api('POST', '/api/tutors', { name, display, subject: $('#n-subject').value.trim(), avatar: $('#n-avatar').value, hidden: $('#n-hidden').checked }); await loadConfig(); renderTeam(); feedback($('#team .card'), true, '\u52A0\u4E86 ' + display); }\n catch (e) { feedback(card, false, e.message); }\n } } }, '\u52A0\u8FDB\u6765'), h('span', { class: 'fb' })));\n return card;\n };\n const fileEditor = (t) => {\n const box = h('details', {}, h('summary', {}, '\u8001\u5E08\u6587\u4EF6(\u7CFB\u7EDF\u63D0\u793A;\u6539\u4E86\u5C31\u662F\u4F60\u5BB6\u7684,\u5347\u7EA7\u4E0D\u8986\u76D6)'));\n const ta = h('textarea', { spellcheck: 'false' });\n const fb = h('span', { class: 'fb' });\n const origin = h('span', { class: 'origin' });\n const load = async () => { try { const f = await api('GET', '/api/tutors/' + t.name + '/file'); ta.value = f.text; origin.textContent = f.state === 'own' ? '\u81EA\u5BB6\u52A0\u7684' : f.state === 'latest' ? '\u51FA\u5382\u4EF6,\u6700\u65B0' : f.state === 'upgradable' ? '\u51FA\u5382\u4EF6,\u53EF\u5347\u7EA7' : '\u81EA\u5B9A\u4E49' + (f.basedOn ? '(\u57FA\u4E8E ' + f.basedOn + ')' : ''); } catch (e) { ta.value = ''; fb.className = 'fb msg-err'; fb.textContent = e.message; } };\n box.addEventListener('toggle', () => { if (box.open && !ta.value) load(); });\n box.append(ta, h('div', { class: 'actions' }, h('button', { class: 'primary', type: 'button', on: { click: async () => { try { const f = await api('PUT', '/api/tutors/' + t.name + '/file', { text: ta.value }); fb.className = 'fb msg-ok'; fb.textContent = '\u5DF2\u5199\u5165'; origin.textContent = f.state === 'own' ? '\u81EA\u5BB6\u52A0\u7684' : '\u81EA\u5B9A\u4E49'; } catch (e) { fb.className = 'fb msg-err'; fb.textContent = e.message; } } } }, '\u4FDD\u5B58\u8001\u5E08\u6587\u4EF6'), h('button', { type: 'button', on: { click: load } }, '\u91CD\u65B0\u8BFB'), origin, fb));\n return box;\n };\n\n const renderTeam = () => {\n const c = state.config;\n if (!c) return;\n const root = $('#team');\n const top = h('div', { class: 'card' }, h('h2', {}, '\u5168\u5C40'), h('div', { class: 'grid' },\n h('label', {}, '\u5B69\u5B50\u7AEF\u6807\u9898', h('input', { type: 'text', id: 'g-title', value: c.title })),\n h('label', {}, '\u7F3A\u7701\u8FD0\u884C\u65F6(runtimes.default)', h('select', { id: 'g-agent' }, ...c.runtimes.map((p) => h('option', { value: p, selected: p === c.runtime ? '' : undefined }, p)))),\n ...policyInputs(c.policyDefaults, c.tutors[0] ? Object.assign({}, c.tutors[0].policy, c.policyDefaults) : {}, { inherit: false })),\n h('p', { class: 'hint' }, 'policyDefaults \u7559\u7A7A = \u7528\u51FA\u5382\u7F3A\u7701(60 \u5B57 / 30 \u6761 / 3 \u6B21 / \u9A8C\u6536\u5173 / L0,L1,L3,L4 / 10,10)\u3002\u8FD0\u884C\u65F6\u6A21\u677F\u3001paths\u3001\u7AEF\u53E3\u8BF7\u76F4\u63A5\u7F16\u8F91 cotutor.json\u3002'),\n h('div', { class: 'actions' }, h('button', { class: 'primary', type: 'button', on: { click: () => { try { patchAndReload(top, { title: $('#g-title').value, runtimes: { default: $('#g-agent').value }, policyDefaults: readPolicy(top) }); } catch (e) { feedback(top, false, e.message); } } } }, '\u4FDD\u5B58\u5168\u5C40'), h('span', { class: 'fb' })));\n const cards = c.tutors.map((t) => {\n const patch = c.tutorPatches[t.name] || {};\n const card = h('div', { class: 'card' },\n h('h2', {}, h('span', { class: 'av' }, t.avatar || '\uD83D\uDE42'), t.display, h('span', { class: 'off' }, t.name + (t.enabled ? '' : ' \u00B7 \u5DF2\u5173\u95ED') + (t.hidden ? ' \u00B7 \u5B69\u5B50\u7AEF\u4E0D\u9732' : ''))),\n h('div', { class: 'grid' },\n h('label', {}, '\u663E\u793A\u540D', h('input', { type: 'text', 'data-f': 'display', value: t.display })),\n h('label', {}, '\u5B66\u79D1', h('input', { type: 'text', 'data-f': 'subject', value: t.subject || '' })),\n h('label', {}, '\u5934\u50CF(emoji)', h('input', { type: 'text', 'data-f': 'avatar', value: t.avatar || '' })),\n h('label', {}, '\u97F3\u8272(voxtell id)', h('input', { type: 'text', 'data-f': 'voice', value: t.voice || '' })),\n h('label', { class: 'chk' }, h('input', { type: 'checkbox', 'data-f': 'enabled', checked: t.enabled ? '' : undefined }), '\u5F00\u542F'),\n h('label', { class: 'chk' }, h('input', { type: 'checkbox', 'data-f': 'hidden', checked: t.hidden ? '' : undefined }), '\u5B69\u5B50\u7AEF\u4E0D\u9732'),\n ...policyInputs(patch, t.policy, { inherit: true })),\n h('div', { class: 'actions' }, h('button', { class: 'primary', type: 'button' }, '\u4FDD\u5B58'), h('span', { class: 'fb' }), c.shipped.includes(t.name) ? null : h('button', { type: 'button', class: 'danger', style: 'margin-left:auto', on: { click: async () => { if (!confirm('\u5220\u6389 ' + t.display + '?\u8001\u5E08\u6587\u4EF6\u6539\u540D\u4FDD\u7559,\u4F1A\u8BDD\u4E0E\u8BB0\u5FC6\u4E0D\u52A8\u3002')) return; try { await api('DELETE', '/api/tutors/' + t.name); await loadConfig(); renderTeam(); } catch (e) { feedback(card, false, e.message); } } } }, '\u5220\u6389\u8FD9\u4F4D\u8001\u5E08')),\n fileEditor(t));\n $('.actions button', card).addEventListener('click', () => {\n try {\n const f = (name) => $('[data-f=' + name + ']', card);\n const entry = { display: f('display').value.trim(), subject: f('subject').value.trim() || null, avatar: f('avatar').value.trim() || null, voice: f('voice').value.trim() || null, enabled: f('enabled').checked, hidden: f('hidden').checked, policy: readPolicy(card) };\n patchAndReload(card, { tutors: { [t.name]: entry } });\n } catch (e) { feedback(card, false, e.message); }\n });\n return card;\n });\n root.replaceChildren(top, newTutorCard(), ...cards);\n };\n\n // ---- \u8BBE\u7F6E:paths / \u7AEF\u53E3 / \u8BC1\u4E66 / \u914D\u97F3\u547D\u4EE4;\u6587\u4EF6\u4ECD\u662F\u771F\u76F8 ----\n const PATH_ROLES = [['vault', 'vault \u6839(Obsidian \u4ED3\u5E93;\u7A7A = workspace \u6839)'], ['timetable', '\u8BFE\u7A0B\u8868\u6587\u4EF6(\u76F8\u5BF9 vault)'], ['plans', '\u8BA1\u5212\u76EE\u5F55(\u76F8\u5BF9 vault)'], ['diary', '\u65E5\u8BB0\u76EE\u5F55'], ['photos', '\u7167\u7247\u76EE\u5F55'], ['profile', '\u5B69\u5B50\u6863\u6848\u6587\u4EF6']];\n const renderSettings = () => {\n const c = state.config;\n if (!c) return;\n const card = h('div', { class: 'card' }, h('h2', {}, '\u8DEF\u5F84\u4E0E\u670D\u52A1'), h('div', { class: 'grid' },\n ...PATH_ROLES.map(([k, label]) => h('label', {}, label, h('input', { type: 'text', 'data-p': k, value: c.paths[k] || '', placeholder: c.resolvedPaths[k] || '' }))),\n h('label', {}, '\u7AEF\u53E3(\u6539\u4E86\u8981\u91CD\u542F serve)', h('input', { type: 'number', id: 's-port', value: c.server.port })),\n h('label', {}, 'HTTPS \u8BC1\u4E66(\u76F8\u5BF9 workspace;\u7A7A = \u7528\u673A\u5668\u7EA7 ~/.config/cotutor/certs/)', h('input', { type: 'text', id: 's-cert', value: c.https ? c.https.cert : '', placeholder: '\u7559\u7A7A\u5373\u7528 cotutor cert \u7B7E\u7684' })),\n h('label', {}, 'HTTPS \u79C1\u94A5', h('input', { type: 'text', id: 's-key', value: c.https ? c.https.key : '', placeholder: '\u7559\u7A7A\u5373\u7528 cotutor cert \u7B7E\u7684' }))),\n h('label', { style: 'display:block;margin-top:10px;font-size:12px;color:var(--dim)' }, '\u914D\u97F3\u547D\u4EE4(JSON \u6570\u7EC4;\u5360\u4F4D {text} {voice} {out};voxtell \u4E0D\u5728 PATH \u5C31\u628A\u7B2C\u4E00\u9879\u5199\u6210\u5B8C\u6574\u8DEF\u5F84)', h('textarea', { id: 's-tts', style: 'min-height:60px' }, JSON.stringify(c.tts.say))),\n h('p', { class: 'hint' }, '\u5199\u56DE cotutor.json;kid\u3001version\u3001agents \u7684\u8FD0\u884C\u65F6\u6A21\u677F\u8BF7\u76F4\u63A5\u7F16\u8F91\u6587\u4EF6\u3002\u7559\u7A7A\u7684\u8DEF\u5F84\u89D2\u8272\u7528\u7F3A\u7701;\u53F3\u4FA7\u7070\u5B57\u662F\u73B0\u5728\u89E3\u6790\u5230\u7684\u7EDD\u5BF9\u8DEF\u5F84\u3002'),\n h('div', { class: 'actions' }, h('button', { class: 'primary', type: 'button', on: { click: async () => {\n try {\n const paths = {};\n for (const el of card.querySelectorAll('[data-p]')) paths[el.dataset.p] = el.value.trim() || null;\n const port = Number($('#s-port').value);\n if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error('\u7AEF\u53E3\u8981\u662F 1\u201365535');\n const cert = $('#s-cert').value.trim(), key = $('#s-key').value.trim();\n let say;\n try { say = JSON.parse($('#s-tts').value); } catch { throw new Error('\u914D\u97F3\u547D\u4EE4\u8981\u662F JSON \u6570\u7EC4'); }\n if (!Array.isArray(say) || !say.length || !say.every((x) => typeof x === 'string')) throw new Error('\u914D\u97F3\u547D\u4EE4\u8981\u662F\u975E\u7A7A\u5B57\u7B26\u4E32\u6570\u7EC4');\n await api('PATCH', '/api/config', { paths, server: { port, https: cert && key ? { cert, key } : null }, tts: { say } });\n await loadConfig(); feedback(card, true, '\u5DF2\u5199\u5165 cotutor.json' + (port !== c.server.port ? ';\u7AEF\u53E3\u6539\u4E86,\u91CD\u542F serve \u624D\u751F\u6548' : ''));\n } catch (e) { feedback(card, false, e.message); }\n } } }, '\u4FDD\u5B58\u8BBE\u7F6E'), h('span', { class: 'fb' })));\n $('#settings').replaceChildren(card);\n };\n\n // ---- \u542F\u52A8 ----\n (async () => {\n await refreshHealth();\n await loadConfig();\n showTab(state.tab);\n const first = state.config.tutors.find((t) => t.enabled && !t.hidden) || state.config.tutors.find((t) => t.enabled);\n if (first) pickTutor(first.name);\n setInterval(refreshHealth, 10000);\n setInterval(async () => { const before = JSON.stringify(state.config && state.config.tutors); await loadConfig(); if (JSON.stringify(state.config.tutors) !== before && state.tab === 'team') renderTeam(); }, 10000);\n })().catch((e) => { $('#msgs').replaceChildren(h('p', { class: 'empty' }, '\u52A0\u8F7D\u5931\u8D25:' + e.message)); });\n})();\n</script>\n</html>\n";
|