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.
Files changed (109) hide show
  1. package/README.md +35 -24
  2. package/agents/chinese-tutor.md +67 -0
  3. package/agents/{homework-aide.md → homework-tutor.md} +4 -4
  4. package/agents/math-tutor.md +68 -0
  5. package/agents/reading-tutor.md +65 -0
  6. package/agents/scene-maker.md +41 -0
  7. package/dist/cards/canvas.d.ts +22 -0
  8. package/dist/cards/canvas.js +65 -0
  9. package/dist/cards/choice.d.ts +20 -0
  10. package/dist/cards/choice.js +76 -0
  11. package/dist/cards/code.d.ts +9 -0
  12. package/dist/cards/code.js +13 -0
  13. package/dist/cards/fill.d.ts +14 -0
  14. package/dist/cards/fill.js +64 -0
  15. package/dist/cards/image.d.ts +13 -0
  16. package/dist/cards/image.js +35 -0
  17. package/dist/cards/index.d.ts +48 -0
  18. package/dist/cards/index.js +115 -0
  19. package/dist/cards/kind.d.ts +27 -0
  20. package/dist/cards/kind.js +7 -0
  21. package/dist/cards/read.d.ts +8 -0
  22. package/dist/cards/read.js +27 -0
  23. package/dist/cards/scene.d.ts +24 -0
  24. package/dist/cards/scene.js +67 -0
  25. package/dist/cards/text.d.ts +18 -0
  26. package/dist/cards/text.js +41 -0
  27. package/dist/cli/cert.d.ts +7 -0
  28. package/dist/cli/cert.js +36 -0
  29. package/dist/cli/doctor.d.ts +5 -2
  30. package/dist/cli/doctor.js +183 -18
  31. package/dist/cli/init.d.ts +1 -1
  32. package/dist/cli/init.js +41 -39
  33. package/dist/cli/main.js +128 -8
  34. package/dist/cli/serve.d.ts +15 -4
  35. package/dist/cli/serve.js +40 -6
  36. package/dist/cli/skeleton.d.ts +23 -5
  37. package/dist/cli/skeleton.js +131 -29
  38. package/dist/cli/skills.d.ts +40 -0
  39. package/dist/cli/skills.js +170 -0
  40. package/dist/cli/tutors.d.ts +84 -0
  41. package/dist/cli/tutors.js +274 -0
  42. package/dist/cli/workspace.d.ts +9 -1
  43. package/dist/cli/workspace.js +25 -4
  44. package/dist/lib/agent-file.d.ts +1 -1
  45. package/dist/lib/board.d.ts +15 -0
  46. package/dist/lib/board.js +107 -0
  47. package/dist/lib/context-pack.js +10 -1
  48. package/dist/lib/conversation.d.ts +43 -6
  49. package/dist/lib/conversation.js +53 -9
  50. package/dist/lib/index.d.ts +4 -0
  51. package/dist/lib/index.js +4 -0
  52. package/dist/lib/kid-board.d.ts +148 -0
  53. package/dist/lib/kid-board.js +294 -0
  54. package/dist/lib/kid-view.d.ts +44 -7
  55. package/dist/lib/kid-view.js +63 -8
  56. package/dist/lib/ledger.d.ts +8 -0
  57. package/dist/lib/ledger.js +8 -0
  58. package/dist/lib/plan.d.ts +2 -0
  59. package/dist/lib/plan.js +9 -0
  60. package/dist/lib/run-plan.d.ts +30 -0
  61. package/dist/lib/run-plan.js +32 -0
  62. package/dist/lib/stream.d.ts +15 -0
  63. package/dist/lib/stream.js +69 -0
  64. package/dist/lib/timetable.d.ts +22 -0
  65. package/dist/lib/timetable.js +106 -0
  66. package/dist/schema/board.d.ts +59 -0
  67. package/dist/schema/board.js +30 -0
  68. package/dist/schema/config.d.ts +76 -18
  69. package/dist/schema/config.js +67 -37
  70. package/dist/schema/context-pack.d.ts +7 -0
  71. package/dist/schema/context-pack.js +6 -0
  72. package/dist/schema/conversation.d.ts +114 -4
  73. package/dist/schema/conversation.js +29 -5
  74. package/dist/schema/index.d.ts +6 -2
  75. package/dist/schema/index.js +6 -2
  76. package/dist/schema/json-schema.d.ts +2 -0
  77. package/dist/schema/json-schema.js +23 -0
  78. package/dist/schema/ledger.d.ts +1 -1
  79. package/dist/schema/timetable.d.ts +26 -0
  80. package/dist/schema/timetable.js +18 -0
  81. package/dist/server/app.d.ts +67 -6
  82. package/dist/server/app.js +399 -38
  83. package/dist/server/kid-page.d.ts +1 -0
  84. package/dist/server/kid-page.js +768 -0
  85. package/dist/server/mock.d.ts +58 -0
  86. package/dist/server/mock.js +403 -0
  87. package/dist/server/parent-page.d.ts +8 -0
  88. package/dist/server/parent-page.js +393 -0
  89. package/dist/server/runner.d.ts +64 -0
  90. package/dist/server/runner.js +277 -0
  91. package/dist/server/scene-props.d.ts +16 -0
  92. package/dist/server/scene-props.js +39 -0
  93. package/dist/server/stage.d.ts +15 -0
  94. package/dist/server/stage.js +69 -0
  95. package/dist/server/store.d.ts +43 -0
  96. package/dist/server/store.js +210 -0
  97. package/dist/server/tts.d.ts +51 -0
  98. package/dist/server/tts.js +99 -0
  99. package/dist/stage/Assistant-Bold-ZDZZ6JHA.woff2 +0 -0
  100. package/dist/stage/Assistant-Medium-DZ25RZU3.woff2 +0 -0
  101. package/dist/stage/Assistant-Regular-PLF2XOGW.woff2 +0 -0
  102. package/dist/stage/Assistant-SemiBold-CZ5MX6FK.woff2 +0 -0
  103. package/dist/stage/index.html +10 -0
  104. package/dist/stage/stage.css +1 -0
  105. package/dist/stage/stage.js +4383 -0
  106. package/package.json +13 -4
  107. package/agents/chinese-teacher.md +0 -19
  108. package/agents/math-teacher.md +0 -19
  109. package/agents/reading-teacher.md +0 -19
@@ -0,0 +1,210 @@
1
+ /**
2
+ * 对话与配置的文件层:索引读写(坏索引响亮报错,不静默覆盖)、日期列表、转录读取、老师正文、cotutor.json 补丁写回。
3
+ * 纯函数在 lib/,这里只碰文件系统。
4
+ */
5
+ import { mkdir, readFile, readdir, rename, stat, writeFile } from 'node:fs/promises';
6
+ import { join } from 'node:path';
7
+ import { parseAgentFile } from "../lib/agent-file.js";
8
+ import { cardAssetName, conversationFiles, emptyIndex } from "../lib/conversation.js";
9
+ import { parseTranscript } from "../lib/transcript.js";
10
+ import { ConversationIndexSchema, CotutorConfigSchema, DATE_RE, explainIssues, } from "../schema/index.js";
11
+ import { ConfigError, assembleWorkspace, parseConfig, readJson, redactHome } from "../cli/workspace.js";
12
+ export class IndexError extends Error {
13
+ constructor(file, cause) {
14
+ super(`对话索引用不了:${redactHome(file)}\n${cause}\n修好它或改名挪开(转录 .log 还在,能重建);不要删。`);
15
+ }
16
+ }
17
+ export async function readIndex(ws, tutor, date) {
18
+ const file = conversationFiles(ws.dirs.conversations, tutor, date).index;
19
+ let text;
20
+ try {
21
+ text = await readFile(file, 'utf8');
22
+ }
23
+ catch {
24
+ return emptyIndex(tutor, date);
25
+ }
26
+ let raw;
27
+ try {
28
+ raw = JSON.parse(text);
29
+ }
30
+ catch (err) {
31
+ throw new IndexError(file, `不是合法 JSON:${err instanceof Error ? err.message : String(err)}`);
32
+ }
33
+ const r = ConversationIndexSchema.safeParse(raw);
34
+ if (!r.success)
35
+ throw new IndexError(file, explainIssues(r.error.issues).map((l) => ` - ${l}`).join('\n'));
36
+ return r.data;
37
+ }
38
+ /** 先写 .tmp 再 rename:进程半路死掉不会留下半份索引 */
39
+ export async function writeIndex(ws, index) {
40
+ const { index: file } = conversationFiles(ws.dirs.conversations, index.tutor, index.date);
41
+ await mkdir(join(ws.dirs.conversations, index.tutor), { recursive: true });
42
+ const tmp = `${file}.tmp`;
43
+ await writeFile(tmp, `${JSON.stringify(index, null, 2)}\n`);
44
+ await rename(tmp, file);
45
+ }
46
+ /** 有过对话的日期,新的在前 */
47
+ export async function listDates(ws, tutor) {
48
+ try {
49
+ return (await readdir(join(ws.dirs.conversations, tutor)))
50
+ .filter((f) => /^\d{4}-\d{2}-\d{2}\.json$/.test(f))
51
+ .map((f) => f.slice(0, 10))
52
+ .filter((d) => DATE_RE.test(d))
53
+ .sort()
54
+ .reverse();
55
+ }
56
+ catch {
57
+ return [];
58
+ }
59
+ }
60
+ export async function readTranscript(ws, tutor, date, job) {
61
+ try {
62
+ return parseTranscript(await readFile(conversationFiles(ws.dirs.conversations, tutor, date).log(job), 'utf8'));
63
+ }
64
+ catch {
65
+ return null;
66
+ }
67
+ }
68
+ /**
69
+ * 一天里卡的目录 <date>.<job>.cards/ 全扫一遍:<n>.json 是孩子做的事(坏文件跳过,孩子端不报),<n>/<k>.mp3 是后台生成好的资产。
70
+ */
71
+ export async function scanCards(ws, tutor, date) {
72
+ const dir = join(ws.dirs.conversations, tutor);
73
+ const states = {};
74
+ const assets = {};
75
+ let names;
76
+ try {
77
+ names = await readdir(dir);
78
+ }
79
+ catch {
80
+ return { states, assets };
81
+ }
82
+ for (const name of names) {
83
+ const m = new RegExp(`^${date}\\.(\\d{4}-\\d+)\\.cards$`).exec(name);
84
+ if (!m)
85
+ continue;
86
+ const job = m[1];
87
+ let files;
88
+ try {
89
+ files = await readdir(join(dir, name), { withFileTypes: true }).then((es) => es.map((e) => (e.isDirectory() ? `${e.name}/` : e.name)));
90
+ }
91
+ catch {
92
+ continue;
93
+ }
94
+ for (const f of files) {
95
+ const n = /^(\d+)\.json$/.exec(f);
96
+ if (n) {
97
+ try {
98
+ const raw = JSON.parse(await readFile(join(dir, name, f), 'utf8'));
99
+ if (raw && typeof raw.at === 'string' && typeof raw.turn === 'string' && 'state' in raw)
100
+ (states[job] ??= {})[Number(n[1])] = raw;
101
+ }
102
+ catch {
103
+ /* 坏文件:当没做过 */
104
+ }
105
+ continue;
106
+ }
107
+ const d = /^(\d+)\/$/.exec(f);
108
+ if (!d)
109
+ continue;
110
+ try {
111
+ const inner = (await readdir(join(dir, name, d[1]))).filter((x) => /^\d+\.mp3$/.test(x)).sort((a, b) => parseInt(a, 10) - parseInt(b, 10));
112
+ if (inner.length)
113
+ (assets[job] ??= {})[Number(d[1])] = inner.map((x) => cardAssetName(date, job, Number(d[1]), x));
114
+ }
115
+ catch {
116
+ /* 目录没了:当没有 */
117
+ }
118
+ }
119
+ }
120
+ return { states, assets };
121
+ }
122
+ /** 只要孩子做的事(发消息时挑「上一轮之后改过的」) */
123
+ export async function readCardStates(ws, tutor, date) {
124
+ return (await scanCards(ws, tutor, date)).states;
125
+ }
126
+ /** 存一张卡的状态(先 .tmp 再 rename);turn = 存的时候索引里最后一条的 job */
127
+ export async function writeCardState(ws, tutor, date, job, n, file) {
128
+ const files = conversationFiles(ws.dirs.conversations, tutor, date);
129
+ await mkdir(files.cardsDir(job), { recursive: true });
130
+ const target = files.card(job, n);
131
+ await writeFile(`${target}.tmp`, `${JSON.stringify(file, null, 2)}\n`);
132
+ await rename(`${target}.tmp`, target);
133
+ }
134
+ /** 画板导出的 png:<date>.<job>.cards/<n>.png;返回相对 conversations/<老师>/ 的名字 */
135
+ export async function writeCardImage(ws, tutor, date, job, n, png) {
136
+ const files = conversationFiles(ws.dirs.conversations, tutor, date);
137
+ await mkdir(files.cardsDir(job), { recursive: true });
138
+ const target = `${files.cardsDir(job)}/${n}.png`;
139
+ await writeFile(target, png);
140
+ return `${date}.${job}.cards/${n}.png`;
141
+ }
142
+ export async function readErrLog(ws, tutor, date, job) {
143
+ try {
144
+ return await readFile(conversationFiles(ws.dirs.conversations, tutor, date).err(job), 'utf8');
145
+ }
146
+ catch {
147
+ return '';
148
+ }
149
+ }
150
+ /** 老师文件正文(系统提示),给 {agentBody};从 .claude/agents/ 读,那里的链是必需项 */
151
+ export async function readAgentBody(ws, name) {
152
+ for (const dir of [ws.dirs.claudeAgents, ws.dirs.qwenAgents]) {
153
+ try {
154
+ return parseAgentFile(await readFile(join(dir, `${name}.md`), 'utf8')).body;
155
+ }
156
+ catch {
157
+ /* 试下一处 */
158
+ }
159
+ }
160
+ throw new ConfigError(join(ws.dirs.claudeAgents, `${name}.md`), '老师文件读不到;cotutor init 补拷');
161
+ }
162
+ /** PATCH 允许改的顶层键(老师团页与设置页);kid / version / 运行时模板走编辑器 */
163
+ export const CONFIG_PATCH_KEYS = ['title', 'policyDefaults', 'tutors', 'agents', 'paths', 'server', 'tts'];
164
+ const isObj = (v) => Boolean(v) && typeof v === 'object' && !Array.isArray(v);
165
+ /** 深合并:对象递归、其余覆盖、null 删键(老师条目可整体删:tutors.x = null) */
166
+ export function deepMerge(base, patch) {
167
+ if (!isObj(base) || !isObj(patch))
168
+ return patch;
169
+ const out = { ...base };
170
+ for (const [k, v] of Object.entries(patch)) {
171
+ if (v === null)
172
+ delete out[k];
173
+ else
174
+ out[k] = deepMerge(out[k], v);
175
+ }
176
+ return out;
177
+ }
178
+ /**
179
+ * 补丁合到原始 JSON 上(保留 _note 等机器不认识的键),整份过契约再写;写坏了不落盘。
180
+ * 返回新配置;agents 只允许改 default(运行时模板走编辑器,页面上改错一个引号就把老师全弄哑)。
181
+ */
182
+ export async function patchConfig(ws, patch) {
183
+ const unknown = Object.keys(patch).filter((k) => !CONFIG_PATCH_KEYS.includes(k));
184
+ if (unknown.length)
185
+ throw new ConfigError(ws.files.config, `页面只能改 ${CONFIG_PATCH_KEYS.join(' / ')},不认识:${unknown.join(', ')};其余字段请直接编辑文件`);
186
+ if (isObj(patch.agents) && Object.keys(patch.agents).some((k) => k !== 'default')) {
187
+ throw new ConfigError(ws.files.config, 'agents 只能在页面上改 default;运行时模板请直接编辑文件');
188
+ }
189
+ const raw = readJson(ws.files.config);
190
+ if (raw === null)
191
+ throw new ConfigError(ws.files.config, '不存在');
192
+ const merged = deepMerge(raw, patch);
193
+ const r = CotutorConfigSchema.safeParse(merged);
194
+ if (!r.success)
195
+ throw new ConfigError(ws.files.config, `补丁应用后不合契约,未写入:\n${explainIssues(r.error.issues).map((l) => ` - ${l}`).join('\n')}`);
196
+ const tmp = `${ws.files.config}.tmp`;
197
+ await writeFile(tmp, `${JSON.stringify(merged, null, 2)}\n`);
198
+ await rename(tmp, ws.files.config);
199
+ return r.data;
200
+ }
201
+ /** 文件改了(页面写的、或家长在编辑器改的)就重读;坏了抛 ConfigError,调用方决定是留旧的还是响 */
202
+ export async function reloadIfChanged(ws, lastMtime) {
203
+ const st = await stat(ws.files.config);
204
+ if (st.mtimeMs === lastMtime)
205
+ return { ws, mtime: lastMtime };
206
+ const raw = readJson(ws.files.config);
207
+ if (raw === null)
208
+ throw new ConfigError(ws.files.config, '不存在了');
209
+ return { ws: assembleWorkspace(ws.root, ws.source, parseConfig(raw, ws.files.config)), mtime: st.mtimeMs };
210
+ }
@@ -0,0 +1,51 @@
1
+ import { type Tts } from '../schema/index.ts';
2
+ export interface TtsResult {
3
+ /** 成功 → 输出文件路径;失败 → null */
4
+ file: string | null;
5
+ error: string | null;
6
+ }
7
+ export declare function synthesize(tts: Tts, vars: {
8
+ text: string;
9
+ voice: string;
10
+ out: string;
11
+ }, opts?: {
12
+ env?: NodeJS.ProcessEnv;
13
+ timeoutMs?: number;
14
+ }): Promise<TtsResult>;
15
+ /** 合成一条并把失败原因追加到 err.log;返回文件名(相对 conversations/<老师>/)或 null */
16
+ export declare function dubReply(tts: Tts, text: string, voice: string, files: {
17
+ audio: string;
18
+ err: string;
19
+ }, env?: NodeJS.ProcessEnv): Promise<string | null>;
20
+ /**
21
+ * 一轮的配音队列:讲稿句与点读段同一条队,三个并行,先进先出(讲稿句在流式时就进了,自然排在点读段前面)。
22
+ * 每个文件一项;失败写 err.log、结果 null。
23
+ */
24
+ export declare class DubQueue {
25
+ private readonly queue;
26
+ private running;
27
+ private readonly tts;
28
+ private readonly voice;
29
+ private readonly err;
30
+ private readonly env?;
31
+ constructor(tts: Tts, voice: string, err: string, env?: NodeJS.ProcessEnv);
32
+ /** 合成 text 到 out;resolve 成文件名(最后一段)或 null;label 是 err.log 里的称呼 */
33
+ add(out: string, text: string, label: string): Promise<string | null>;
34
+ private pump;
35
+ }
36
+ /**
37
+ * 边讲边配:流式时讲稿每写完一句就交进来(add),整轮跑完 finish(最终句子)——同下标同文本的直接用,
38
+ * 不一样的(极少:末句被截、partial 与最终切句不同)重配到同一个文件。失败句 null。
39
+ */
40
+ export declare class LineDubber {
41
+ private readonly jobs;
42
+ private readonly q;
43
+ private readonly lineAudio;
44
+ constructor(q: DubQueue, lineAudio: (n: number) => string);
45
+ /** 第 i 句(0 起)定稿了;同下标同文本不重配 */
46
+ add(i: number, text: string): void;
47
+ /** 最终讲稿:每句拿文件名(已配的直接用,没配的现配) */
48
+ finish(lines: readonly {
49
+ text: string;
50
+ }[]): Promise<(string | null)[]>;
51
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * 配音:老师说完,把 kidText 按 cotutor.json 的 tts 运行时合成到 conversations/<老师>/<日期>.<job>.mp3。
3
+ * 失败不响、不阻塞对话:这条没有音频,孩子端退回浏览器自带的声;原因写进 err.log 供家长看。
4
+ * 音色是人设(tutors[].voice),没配就不合成——「无缺省音色」是 voxtell 的政策,这里照办。
5
+ */
6
+ import { execFile } from 'node:child_process';
7
+ import { appendFile, stat } from 'node:fs/promises';
8
+ import { fillTts } from "../schema/index.js";
9
+ export function synthesize(tts, vars, opts = {}) {
10
+ const argv = fillTts(tts.say, vars);
11
+ return new Promise((resolveResult) => {
12
+ execFile(argv[0], argv.slice(1), { env: opts.env ?? process.env, timeout: opts.timeoutMs ?? 60_000, maxBuffer: 1 << 20 }, async (err, _stdout, stderr) => {
13
+ if (err) {
14
+ const code = err.code;
15
+ const why = code === 'ENOENT' ? `PATH 里没有 ${argv[0]}` : `${err.message}${stderr ? `\n${String(stderr).trim().slice(-500)}` : ''}`;
16
+ return resolveResult({ file: null, error: why });
17
+ }
18
+ const st = await stat(vars.out).catch(() => null);
19
+ if (!st?.isFile() || st.size === 0)
20
+ return resolveResult({ file: null, error: `命令跑完了但 ${vars.out} 没有内容` });
21
+ resolveResult({ file: vars.out, error: null });
22
+ });
23
+ });
24
+ }
25
+ /** 合成一条并把失败原因追加到 err.log;返回文件名(相对 conversations/<老师>/)或 null */
26
+ export async function dubReply(tts, text, voice, files, env) {
27
+ const r = await synthesize(tts, { text, voice, out: files.audio }, { env });
28
+ if (r.error)
29
+ await appendFile(files.err, `cotutor tts: 没合成(${r.error});孩子端用浏览器的声\n`).catch(() => { });
30
+ return r.file ? files.audio.slice(files.audio.lastIndexOf('/') + 1) : null;
31
+ }
32
+ /**
33
+ * 一轮的配音队列:讲稿句与点读段同一条队,三个并行,先进先出(讲稿句在流式时就进了,自然排在点读段前面)。
34
+ * 每个文件一项;失败写 err.log、结果 null。
35
+ */
36
+ export class DubQueue {
37
+ queue = [];
38
+ running = 0;
39
+ tts;
40
+ voice;
41
+ err;
42
+ env;
43
+ constructor(tts, voice, err, env) {
44
+ this.tts = tts;
45
+ this.voice = voice;
46
+ this.err = err;
47
+ this.env = env;
48
+ }
49
+ /** 合成 text 到 out;resolve 成文件名(最后一段)或 null;label 是 err.log 里的称呼 */
50
+ add(out, text, label) {
51
+ return new Promise((resolve) => {
52
+ this.queue.push(async () => {
53
+ const r = await synthesize(this.tts, { text, voice: this.voice, out }, { env: this.env });
54
+ if (r.error)
55
+ await appendFile(this.err, `cotutor tts: ${label}没合成(${r.error});用浏览器的声\n`).catch(() => { });
56
+ resolve(r.error ? null : out.slice(out.lastIndexOf('/') + 1));
57
+ });
58
+ this.pump();
59
+ });
60
+ }
61
+ pump() {
62
+ while (this.running < 3 && this.queue.length) {
63
+ const job = this.queue.shift();
64
+ this.running++;
65
+ void job().finally(() => {
66
+ this.running--;
67
+ this.pump();
68
+ });
69
+ }
70
+ }
71
+ }
72
+ /**
73
+ * 边讲边配:流式时讲稿每写完一句就交进来(add),整轮跑完 finish(最终句子)——同下标同文本的直接用,
74
+ * 不一样的(极少:末句被截、partial 与最终切句不同)重配到同一个文件。失败句 null。
75
+ */
76
+ export class LineDubber {
77
+ jobs = new Map();
78
+ q;
79
+ lineAudio;
80
+ constructor(q, lineAudio) {
81
+ this.q = q;
82
+ this.lineAudio = lineAudio;
83
+ }
84
+ /** 第 i 句(0 起)定稿了;同下标同文本不重配 */
85
+ add(i, text) {
86
+ const t = text.trim();
87
+ const had = this.jobs.get(i);
88
+ if (!t || (had && had.text === t))
89
+ return;
90
+ const after = had ? had.done.catch(() => null) : Promise.resolve(null);
91
+ const done = after.then(() => this.q.add(this.lineAudio(i + 1), t, `第 ${i + 1} 句`));
92
+ this.jobs.set(i, { text: t, done });
93
+ }
94
+ /** 最终讲稿:每句拿文件名(已配的直接用,没配的现配) */
95
+ async finish(lines) {
96
+ lines.forEach((l, i) => this.add(i, l.text));
97
+ return Promise.all(lines.map((l, i) => (l.text.trim() ? this.jobs.get(i).done : Promise.resolve(null))));
98
+ }
99
+ }
@@ -0,0 +1,10 @@
1
+ <!doctype html>
2
+ <html lang="zh-CN">
3
+ <meta charset="utf-8">
4
+ <meta name="viewport" content="width=device-width,initial-scale=1,user-scalable=no">
5
+ <title>stage</title>
6
+ <script>window.EXCALIDRAW_ASSET_PATH = '/stage/';</script>
7
+ <link rel="stylesheet" href="/stage/stage.css">
8
+ <div id="root"></div>
9
+ <script type="module" src="/stage/stage.js"></script>
10
+ </html>