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
@@ -1,11 +1,12 @@
1
1
  /**
2
2
  * cotutor 契约(类型即文档)。每个文件一个契约面:
3
- * config cotutor.json(老师表、政策、运行时预设、paths 角色)
3
+ * config cotutor.json(老师表、政策、运行时、paths 角色)
4
4
  * context-pack 每条消息前的上下文包
5
- * conversation 一老师一天一份的会话索引
5
+ * conversation 一老师一天一份的对话索引
6
6
  * ledger 观察与产物两本追加式账本
7
7
  * sections 最终文本里的「待裁量」「转交」段
8
8
  * plan 计划文件
9
+ * timetable 课程表(vault 里的 markdown 表)
9
10
  * 运行时校验用 zod,失败信息经 explainIssues 变成修复指南。
10
11
  */
11
12
  export * from "./issues.js";
@@ -14,4 +15,7 @@ export * from "./context-pack.js";
14
15
  export * from "./conversation.js";
15
16
  export * from "./ledger.js";
16
17
  export * from "./sections.js";
18
+ export * from "./board.js";
17
19
  export * from "./plan.js";
20
+ export * from "./timetable.js";
21
+ export * from "./json-schema.js";
@@ -0,0 +1,2 @@
1
+ export declare const CONFIG_SCHEMA_FILE = ".cotutor/cotutor.schema.json";
2
+ export declare function cotutorJsonSchema(): Record<string, unknown>;
@@ -0,0 +1,23 @@
1
+ /**
2
+ * cotutor.json 的 JSON Schema:从 zod 契约现生成(类型即文档,不手写第二份)。
3
+ * init / upgrade 把它写到 workspace 的 .cotutor/cotutor.schema.json,cotutor.json 首行 "$schema" 指过去,
4
+ * 编辑器(VS Code、Obsidian 的 JSON 插件……)就有补全、悬停说明与红线,家长不必背字段。
5
+ */
6
+ import { z } from 'zod';
7
+ import { CotutorConfigSchema } from "./config.js";
8
+ export const CONFIG_SCHEMA_FILE = '.cotutor/cotutor.schema.json';
9
+ export function cotutorJsonSchema() {
10
+ const js = z.toJSONSchema(CotutorConfigSchema, { unrepresentable: 'any', io: 'input' });
11
+ return {
12
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
13
+ $id: 'https://github.com/TarsLab/cotutor/cotutor.schema.json',
14
+ title: 'cotutor.json',
15
+ description: '一孩一 workspace 的政策文件:老师表、政策、运行时、配音、路径角色。家长改这里;机器不覆盖。',
16
+ ...js,
17
+ properties: {
18
+ $schema: { type: 'string', description: '指向本 schema,给编辑器用' },
19
+ ...(js.properties ?? {}),
20
+ _note: { type: 'string', description: '给人看的备注,机器不读' },
21
+ },
22
+ };
23
+ }
@@ -72,8 +72,8 @@ export declare const ArtifactEventSchema: z.ZodObject<{
72
72
  by: z.ZodOptional<z.ZodString>;
73
73
  at: z.ZodString;
74
74
  status: z.ZodOptional<z.ZodEnum<{
75
- draft: "draft";
76
75
  ready: "ready";
76
+ draft: "draft";
77
77
  accepted: "accepted";
78
78
  retired: "retired";
79
79
  }>>;
@@ -0,0 +1,26 @@
1
+ /**
2
+ * 课程表:一周的固定时段(星期 × 时间 × 学科 [× 孩子])。真相是 vault 里的 `课程表.md`(paths.timetable),
3
+ * 家长在 Obsidian 里改一张 markdown 表;解析器在 lib/timetable.ts(抄自 growth-apps,错误即修复指南带行号)。
4
+ * 一孩一 workspace 后「孩子」列可选(《产品规划.md》拍板 5),留着是给多孩共用一张表的家。
5
+ * 课程表只做归类线索(今天有什么、现在是什么时段),不做逻辑约束——孩子任何时候都能找任何老师。
6
+ */
7
+ import { z } from 'zod';
8
+ export declare const TIME_RE: RegExp;
9
+ export declare const TimetableEntrySchema: z.ZodObject<{
10
+ day: z.ZodNumber;
11
+ start: z.ZodString;
12
+ end: z.ZodString;
13
+ subject: z.ZodString;
14
+ kid: z.ZodOptional<z.ZodString>;
15
+ }, z.core.$strip>;
16
+ export type TimetableEntry = z.infer<typeof TimetableEntrySchema>;
17
+ export declare const TimetableSchema: z.ZodObject<{
18
+ entries: z.ZodArray<z.ZodObject<{
19
+ day: z.ZodNumber;
20
+ start: z.ZodString;
21
+ end: z.ZodString;
22
+ subject: z.ZodString;
23
+ kid: z.ZodOptional<z.ZodString>;
24
+ }, z.core.$strip>>;
25
+ }, z.core.$strip>;
26
+ export type Timetable = z.infer<typeof TimetableSchema>;
@@ -0,0 +1,18 @@
1
+ /**
2
+ * 课程表:一周的固定时段(星期 × 时间 × 学科 [× 孩子])。真相是 vault 里的 `课程表.md`(paths.timetable),
3
+ * 家长在 Obsidian 里改一张 markdown 表;解析器在 lib/timetable.ts(抄自 growth-apps,错误即修复指南带行号)。
4
+ * 一孩一 workspace 后「孩子」列可选(《产品规划.md》拍板 5),留着是给多孩共用一张表的家。
5
+ * 课程表只做归类线索(今天有什么、现在是什么时段),不做逻辑约束——孩子任何时候都能找任何老师。
6
+ */
7
+ import { z } from 'zod';
8
+ export const TIME_RE = /^\d{2}:\d{2}$/;
9
+ export const TimetableEntrySchema = z.object({
10
+ /** 1–7 = 周一–周日 */
11
+ day: z.number().int().min(1).max(7),
12
+ start: z.string().regex(TIME_RE),
13
+ end: z.string().regex(TIME_RE),
14
+ /** 自由文本(语文 / 数学 / 英语……),与 tutors[].subject 对齐即归到那位老师 */
15
+ subject: z.string().min(1),
16
+ kid: z.string().optional(),
17
+ });
18
+ export const TimetableSchema = z.object({ entries: z.array(TimetableEntrySchema) });
@@ -1,13 +1,74 @@
1
- /**
2
- * 服务的路由层:纯函数 route(method, path, ws) → {status, json|html},测试不用起端口。
3
- * R1 只有查询接口;发消息 / 会话 / 页面在 R2。
4
- */
5
1
  import type { IncomingMessage, ServerResponse } from 'node:http';
2
+ import { type TranscriptRow } from '../lib/transcript.ts';
3
+ import { type KidMessage } from '../lib/kid-view.ts';
4
+ import { type Artifact, type ConversationIndex, type TimetableEntry } from '../schema/index.ts';
6
5
  import { type Workspace } from '../cli/workspace.ts';
6
+ import { Runner } from './runner.ts';
7
7
  export interface RouteResult {
8
8
  status: number;
9
9
  json?: unknown;
10
10
  html?: string;
11
+ /** 静态文件(配音);handler 流式发 */
12
+ file?: string;
13
+ contentType?: string;
11
14
  }
12
- export declare function route(method: string, path: string, ws: Workspace): RouteResult;
13
- export declare function createHandler(ws: Workspace): (req: IncomingMessage, res: ServerResponse) => void;
15
+ export interface AppContext {
16
+ ws: Workspace;
17
+ runner: Runner;
18
+ /** 当前时间(测试可注入;孩子端「今天」与 today 别名都按它) */
19
+ now: () => Date;
20
+ /** 上次重载失败的原因(配置改坏了),健康接口回报 */
21
+ configError: string | null;
22
+ /** 重读 cotutor.json(改了才读) */
23
+ reload(): Promise<void>;
24
+ }
25
+ export declare function createContext(ws: Workspace, opts?: {
26
+ now?: () => Date;
27
+ env?: NodeJS.ProcessEnv;
28
+ }): AppContext;
29
+ /** 一天的家长视图:索引 + 每条消息的转录行(主线 + 子代理折叠)+ 出错时的 err.log 尾巴 */
30
+ export interface DayView {
31
+ index: ConversationIndex;
32
+ running: string | null;
33
+ runs: Record<string, TranscriptRow[]>;
34
+ errors: Record<string, string>;
35
+ }
36
+ export declare function dayView(ctx: AppContext, tutor: string, date: string): Promise<DayView>;
37
+ /** 孩子端看到的老师:enabled 且不 hidden;每日上限用完 available = false(头像灰) */
38
+ export interface KidTutor {
39
+ name: string;
40
+ display: string;
41
+ avatar: string | null;
42
+ subject: string | null;
43
+ hasVoice: boolean;
44
+ remaining: number;
45
+ available: boolean;
46
+ }
47
+ export declare function kidTutors(ctx: AppContext, date: string): Promise<KidTutor[]>;
48
+ export interface KidHome {
49
+ title: string;
50
+ date: string;
51
+ /** 1–7 */
52
+ day: number;
53
+ timetable: TimetableEntry[];
54
+ slot: string | null;
55
+ tutors: KidTutor[];
56
+ /** 今天的产物,按学科(老师的 subject)分叠;验收开关开着的老师只给 accepted 的 */
57
+ stacks: {
58
+ subject: string;
59
+ tutor: string | null;
60
+ items: Artifact[];
61
+ }[];
62
+ }
63
+ export declare function kidHome(ctx: AppContext, now: Date): Promise<KidHome>;
64
+ export interface KidDay {
65
+ tutor: string;
66
+ date: string;
67
+ messages: KidMessage[];
68
+ remaining: number;
69
+ /** 老师正在回的那条 job */
70
+ pending: string | null;
71
+ }
72
+ export declare function kidDay(ctx: AppContext, tutor: string, date: string): Promise<KidDay>;
73
+ export declare function route(method: string, path: string, ctx: AppContext, body?: unknown): Promise<RouteResult>;
74
+ export declare function createHandler(ctx: AppContext): (req: IncomingMessage, res: ServerResponse) => void;
@@ -1,45 +1,406 @@
1
- import { listTeachers } from "../schema/index.js";
2
- import { workspaceReport } from "../cli/workspace.js";
1
+ /**
2
+ * 服务的路由层:route(method, path, ctx, body) → {status, json|html},测试不用起端口。
3
+ * R1 的查询接口照旧;R2 加:对话(列日期、看一天的家长视图、发消息)、cotutor.json 补丁(老师团页)、家长页 /parent。
4
+ * R3 加:孩子端 `/`(KID_PAGE)与 /api/kid/*(首页:课程表 + 老师 + 今天的产物叠;对话:服务端过滤后的孩子视图;发消息:from 固定 kid、每日上限 429)、配音文件 /api/audio。
5
+ * 配置热重载:每个请求先看 cotutor.json 的 mtime,改了就重读;改坏了留旧配置并把错误挂在 /api/health 上。
6
+ */
7
+ import { createReadStream } from 'node:fs';
8
+ import { readFile, stat } from 'node:fs/promises';
9
+ import { join } from 'node:path';
10
+ import { foldRuns } from "../lib/transcript.js";
11
+ import { RuntimeError } from "../lib/run-plan.js";
12
+ import { localDate } from "../lib/conversation.js";
13
+ import { kidConversation, kidMessageCount } from "../lib/kid-view.js";
14
+ import { mergeArtifacts, parseArtifactEvents } from "../lib/ledger.js";
15
+ import { currentSlot, dayOf, parseTimetable, slotLabel } from "../lib/timetable.js";
16
+ import { DATE_RE, FocusSchema, MESSAGE_FROM, listTutors, resolvePolicy } from "../schema/index.js";
17
+ import { ConfigError, UsageError, redactHome, workspaceReport } from "../cli/workspace.js";
18
+ import { tutorStatuses } from "../cli/tutors.js";
19
+ import { KID_PAGE } from "./kid-page.js";
20
+ import { PARENT_PAGE } from "./parent-page.js";
21
+ import { BusyError, Runner } from "./runner.js";
22
+ import { IndexError, listDates, patchConfig, readErrLog, readIndex, readTranscript, reloadIfChanged, scanCards, writeCardImage, writeCardState } from "./store.js";
23
+ import { IMAGE_EXT, parseCardState, stripSecrets } from "../cards/index.js";
24
+ import { resolve, sep } from 'node:path';
25
+ import { bundleAsset, stageAsset } from "./stage.js";
26
+ import { enrichScenes } from "./scene-props.js";
27
+ import { addTutorFile, readTutorFile, removeTutorFile, writeTutorFile } from "../cli/tutors.js";
28
+ export function createContext(ws, opts = {}) {
29
+ let mtime = -1;
30
+ const ctx = {
31
+ ws,
32
+ runner: new Runner(() => ctx.ws, opts),
33
+ now: opts.now ?? (() => new Date()),
34
+ configError: null,
35
+ async reload() {
36
+ try {
37
+ const r = await reloadIfChanged(ctx.ws, mtime);
38
+ ctx.ws = r.ws;
39
+ mtime = r.mtime;
40
+ ctx.configError = null;
41
+ }
42
+ catch (err) {
43
+ if (!(err instanceof ConfigError))
44
+ throw err;
45
+ ctx.configError = err.message;
46
+ }
47
+ },
48
+ };
49
+ return ctx;
50
+ }
3
51
  const esc = (s) => s.replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c] ?? c);
4
- export function route(method, path, ws) {
5
- if (method !== 'GET')
6
- return { status: 405, json: { error: 'method_not_allowed' } };
52
+ export async function dayView(ctx, tutor, date) {
53
+ const index = await readIndex(ctx.ws, tutor, date);
54
+ const runs = {};
55
+ const errors = {};
56
+ for (const m of index.messages) {
57
+ const t = await readTranscript(ctx.ws, tutor, date, m.job);
58
+ runs[m.job] = t ? foldRuns(t.items) : [];
59
+ if (m.result === 'error') {
60
+ const tail = (await readErrLog(ctx.ws, tutor, date, m.job)).trim().split('\n').slice(-5).join('\n');
61
+ if (tail)
62
+ errors[m.job] = tail;
63
+ }
64
+ }
65
+ const active = ctx.runner.running(tutor);
66
+ return { index, running: active && active.date === date ? active.job : null, runs, errors };
67
+ }
68
+ const isObj = (v) => Boolean(v) && typeof v === 'object' && !Array.isArray(v);
69
+ export async function kidTutors(ctx, date) {
70
+ const out = [];
71
+ for (const t of listTutors(ctx.ws.config, { kidOnly: true })) {
72
+ const used = kidMessageCount(await readIndex(ctx.ws, t.name, date));
73
+ const remaining = Math.max(0, t.policy.dailyMessages - used);
74
+ out.push({ name: t.name, display: t.display, avatar: t.avatar ?? null, subject: t.subject ?? null, hasVoice: Boolean(t.voice), remaining, available: remaining > 0 });
75
+ }
76
+ return out;
77
+ }
78
+ export async function kidHome(ctx, now) {
79
+ const ws = ctx.ws;
80
+ const date = localDate(now);
81
+ let timetable = [];
82
+ try {
83
+ timetable = parseTimetable(await readFile(ws.paths.timetable, 'utf8')).entries;
84
+ }
85
+ catch {
86
+ /* 没课程表:今天照画 */
87
+ }
88
+ const slot = currentSlot(timetable, now);
89
+ let artifacts = [];
90
+ try {
91
+ artifacts = mergeArtifacts(parseArtifactEvents(await readFile(ws.files.artifacts, 'utf8')).rows).artifacts;
92
+ }
93
+ catch {
94
+ /* 账本还没有 */
95
+ }
96
+ const bySubject = new Map();
97
+ for (const a of artifacts) {
98
+ if (!a.at.startsWith(date) || a.status === 'draft' || a.status === 'retired')
99
+ continue;
100
+ const tutor = ws.config.tutors[a.by];
101
+ if (tutor && resolvePolicy(ws.config, a.by).reviewGate && a.status !== 'accepted')
102
+ continue;
103
+ const subject = tutor?.subject ?? tutor?.display ?? a.by;
104
+ const stack = bySubject.get(subject) ?? { subject, tutor: tutor ? a.by : null, items: [] };
105
+ stack.items.push(a);
106
+ bySubject.set(subject, stack);
107
+ }
108
+ return { title: ws.config.title, date, day: dayOf(now), timetable, slot: slot ? slotLabel(slot) : null, tutors: await kidTutors(ctx, date), stacks: [...bySubject.values()] };
109
+ }
110
+ export async function kidDay(ctx, tutor, date) {
111
+ const index = await readIndex(ctx.ws, tutor, date);
112
+ const policy = resolvePolicy(ctx.ws.config, tutor);
113
+ const active = ctx.runner.running(tutor);
114
+ const { states, assets } = await scanCards(ctx.ws, tutor, date);
115
+ const messages = kidConversation(index, states, assets);
116
+ // 流式:正在回的那条带上已经出来的板书(partial),卡随围栏闭合逐张出现;答案照样剥
117
+ const partial = active && active.date === date ? ctx.runner.partial(tutor) : null;
118
+ if (partial) {
119
+ const m = messages.find((x) => x.job === active.job);
120
+ if (m)
121
+ m.section = stripSecrets(partial);
122
+ }
123
+ // 场景卡:课包在不在、题面、步数、缩略图,每次现读(课包落地卡就变成可播)
124
+ const sceneDirs = { bundles: ctx.ws.dirs.bundles, snaps: ctx.ws.dirs.snaps, thumbBase: 'snaps' };
125
+ for (const m of messages)
126
+ if (m.section)
127
+ m.section = await enrichScenes(sceneDirs, m.section);
128
+ return { tutor, date, messages, remaining: Math.max(0, policy.dailyMessages - kidMessageCount(index)), pending: active && active.date === date ? active.job : null };
129
+ }
130
+ /** <日期>.<job>.mp3(整段)/ .<n>.mp3(讲稿第 n 句)/ .cards/<n>/<k>.mp3(第 n 张卡的第 k 个资产) */
131
+ const AUDIO_FILE_RE = /^\d{4}-\d{2}-\d{2}\.\d{4}-\d+(?:\.\d+|\.cards\/\d+\/\d+)?\.mp3$/;
132
+ const IMAGE_TYPES = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', webp: 'image/webp', gif: 'image/gif', svg: 'image/svg+xml' };
133
+ export async function route(method, path, ctx, body) {
134
+ await ctx.reload();
135
+ const ws = ctx.ws;
7
136
  const url = new URL(path, 'http://x');
8
- switch (url.pathname) {
9
- case '/api/health':
10
- return { status: 200, json: { ok: true } };
11
- case '/api/workspace':
137
+ const p = url.pathname;
138
+ try {
139
+ if (p === '/api/health')
140
+ return { status: 200, json: { ok: true, configError: ctx.configError } };
141
+ if (p === '/api/workspace' && method === 'GET')
12
142
  return { status: 200, json: workspaceReport(ws) };
13
- case '/api/config':
14
- return {
15
- status: 200,
16
- json: { title: ws.config.title, kid: ws.config.kid, server: ws.config.server, agent: ws.config.agents.default, teachers: listTeachers(ws.config) },
17
- };
18
- case '/api/teachers':
19
- return { status: 200, json: listTeachers(ws.config, { kidOnly: url.searchParams.get('kid') === '1' }) };
20
- case '/': {
21
- const teachers = listTeachers(ws.config, { kidOnly: true })
22
- .map((t) => `<li>${esc(t.avatar ?? '')} ${esc(t.display)}${t.subject ? `<small> · ${esc(t.subject)}</small>` : ''}</li>`)
23
- .join('');
24
- return {
25
- status: 200,
26
- html: `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${esc(ws.config.title)}</title><h1>${esc(ws.config.title)}</h1><p>R1 骨架:老师们已就位,对话页在 R2。</p><ul>${teachers}</ul>`,
27
- };
28
- }
29
- default:
30
- return { status: 404, json: { error: 'not_found', path: url.pathname } };
31
- }
32
- }
33
- export function createHandler(ws) {
34
- return (req, res) => {
35
- const r = route(req.method ?? 'GET', req.url ?? '/', ws);
36
- if (r.html !== undefined) {
37
- res.writeHead(r.status, { 'content-type': 'text/html; charset=utf-8' });
38
- res.end(r.html);
143
+ if (p === '/api/config') {
144
+ if (method === 'GET') {
145
+ return {
146
+ status: 200,
147
+ json: {
148
+ title: ws.config.title,
149
+ kid: ws.config.kid,
150
+ server: ws.config.server,
151
+ runtime: ws.config.runtimes.default,
152
+ runtimes: Object.keys(ws.config.runtimes).filter((k) => k !== 'default'),
153
+ policyDefaults: ws.config.policyDefaults,
154
+ paths: ws.config.paths,
155
+ resolvedPaths: Object.fromEntries(Object.entries(ws.paths).map(([k, v]) => [k, redactHome(v)])),
156
+ tts: ws.config.tts,
157
+ https: ws.config.server.https ?? null,
158
+ shipped: (await tutorStatuses(ws.root)).map((s) => s.name),
159
+ tutors: listTutors(ws.config),
160
+ /** 老师条目的原始政策补丁(页面区分「继承」与「覆盖」) */
161
+ tutorPatches: Object.fromEntries(Object.entries(ws.config.tutors).map(([k, t]) => [k, t.policy ?? {}])),
162
+ },
163
+ };
164
+ }
165
+ if (method === 'PATCH') {
166
+ if (!isObj(body))
167
+ return { status: 400, json: { error: 'bad_request', message: '要一个 JSON 对象' } };
168
+ await patchConfig(ws, body);
169
+ await ctx.reload();
170
+ return { status: 200, json: { ok: true, tutors: listTutors(ctx.ws.config), runtime: ctx.ws.config.runtimes.default } };
171
+ }
172
+ return { status: 405, json: { error: 'method_not_allowed' } };
173
+ }
174
+ if (p === '/api/tutors' && method === 'GET') {
175
+ return { status: 200, json: listTutors(ws.config, { kidOnly: url.searchParams.get('kid') === '1' }) };
39
176
  }
40
- else {
41
- res.writeHead(r.status, { 'content-type': 'application/json; charset=utf-8' });
42
- res.end(JSON.stringify(r.json ?? null));
177
+ // ---- 加老师 / 老师文件 / 删老师:与 cotutor add 同一条路 ----
178
+ if (p === '/api/tutors' && method === 'POST') {
179
+ if (!isObj(body) || typeof body.name !== 'string' || typeof body.display !== 'string' || !body.display.trim())
180
+ return { status: 400, json: { error: 'bad_request', message: '要 {name, display, subject?, avatar?, hidden?, description?}' } };
181
+ const name = body.name.trim();
182
+ if (ws.config.tutors[name])
183
+ return { status: 409, json: { error: 'exists', message: `cotutor.json 里已经有 ${name}` } };
184
+ const str = (k) => (typeof body[k] === 'string' && body[k].trim() ? body[k].trim() : undefined);
185
+ const r = await addTutorFile(ws.root, { name, display: body.display.trim(), subject: str('subject'), description: str('description') });
186
+ await patchConfig(ws, { tutors: { [name]: { display: body.display.trim(), ...(str('subject') ? { subject: str('subject') } : {}), ...(str('avatar') ? { avatar: str('avatar') } : {}), enabled: true, ...(body.hidden === true ? { hidden: true } : {}) } } });
187
+ await ctx.reload();
188
+ return { status: 201, json: { ok: true, ...r, tutors: listTutors(ctx.ws.config) } };
43
189
  }
190
+ const tf = /^\/api\/tutors\/([a-z0-9][a-z0-9-]*)(\/file)?$/.exec(p);
191
+ if (tf) {
192
+ const [, name, isFile] = tf;
193
+ if (!ws.config.tutors[name])
194
+ return { status: 404, json: { error: 'no_such_tutor', tutor: name } };
195
+ if (isFile && method === 'GET')
196
+ return { status: 200, json: await readTutorFile(ws.root, name) };
197
+ if (isFile && method === 'PUT') {
198
+ if (!isObj(body) || typeof body.text !== 'string')
199
+ return { status: 400, json: { error: 'bad_request', message: '要 {text}' } };
200
+ return { status: 200, json: await writeTutorFile(ws.root, name, body.text) };
201
+ }
202
+ if (!isFile && method === 'DELETE') {
203
+ const r = await removeTutorFile(ws.root, name);
204
+ await patchConfig(ws, { tutors: { [name]: null } });
205
+ await ctx.reload();
206
+ return { status: 200, json: { ok: true, ...r, tutors: listTutors(ctx.ws.config) } };
207
+ }
208
+ return { status: 405, json: { error: 'method_not_allowed' } };
209
+ }
210
+ // ---- 孩子端:过滤在服务端做,永远不带工具 / 错误 / 评判 ----
211
+ if (p === '/api/kid/home' && method === 'GET')
212
+ return { status: 200, json: await kidHome(ctx, ctx.now()) };
213
+ const kid = /^\/api\/kid\/conversations\/([a-z0-9][a-z0-9-]*)\/(today|messages)$/.exec(p);
214
+ if (kid) {
215
+ const [, tutor, tail] = kid;
216
+ const t = ws.config.tutors[tutor];
217
+ if (!t || !t.enabled || t.hidden)
218
+ return { status: 404, json: { error: 'no_such_tutor' } };
219
+ const date = localDate(ctx.now());
220
+ if (tail === 'today' && method === 'GET')
221
+ return { status: 200, json: await kidDay(ctx, tutor, date) };
222
+ if (tail === 'messages' && method === 'POST') {
223
+ if (!isObj(body) || typeof body.text !== 'string')
224
+ return { status: 400, json: { error: 'bad_request' } };
225
+ const focus = body.focus === undefined ? undefined : FocusSchema.safeParse(body.focus);
226
+ if (focus && !focus.success)
227
+ return { status: 400, json: { error: 'bad_request' } };
228
+ const action = body.action === undefined ? undefined : body.action === 'continue' || body.action === 'submit' ? body.action : null;
229
+ if (action === null)
230
+ return { status: 400, json: { error: 'bad_request' } };
231
+ if (!body.text.trim() && !action)
232
+ return { status: 400, json: { error: 'bad_request' } };
233
+ const policy = resolvePolicy(ws.config, tutor);
234
+ // 「继续」不计每日上限:到了上限也能把老师讲完的听完
235
+ if (action !== 'continue' && kidMessageCount(await readIndex(ws, tutor, date)) >= policy.dailyMessages)
236
+ return { status: 429, json: { error: 'limit', remaining: 0 } };
237
+ const started = await ctx.runner.send(tutor, { from: 'kid', text: body.text, focus: focus?.data, action });
238
+ return { status: 202, json: { tutor, date: started.date, job: started.job } };
239
+ }
240
+ return { status: 405, json: { error: 'method_not_allowed' } };
241
+ }
242
+ // 卡的状态:孩子在舞台里选了、填了 → 存 <日期>.<job>.cards/<n>.json,不起老师;下一条消息带给老师
243
+ const card = /^\/api\/kid\/conversations\/([a-z0-9][a-z0-9-]*)\/cards\/(\d{4}-\d+)\/(\d+)$/.exec(p);
244
+ if (card) {
245
+ const [, tutor, job, nStr] = card;
246
+ const t = ws.config.tutors[tutor];
247
+ if (!t || !t.enabled || t.hidden)
248
+ return { status: 404, json: { error: 'no_such_tutor' } };
249
+ if (method !== 'PUT')
250
+ return { status: 405, json: { error: 'method_not_allowed' } };
251
+ const date = localDate(ctx.now());
252
+ const index = await readIndex(ws, tutor, date);
253
+ const msg = index.messages.find((m) => m.job === job);
254
+ const n = Number(nStr);
255
+ const target = msg?.result === 'ok' ? msg.section?.cards[n] : undefined;
256
+ if (!target)
257
+ return { status: 404, json: { error: 'no_such_card' } };
258
+ // 画板:body 里可以带 image(data:image/png;base64,…),存成 .cards/<n>.png,状态里只留路径(相对 workspace 根,老师 Read 看)
259
+ let state = body;
260
+ if (target.kind === 'canvas' && isObj(body) && typeof body.image === 'string') {
261
+ const { image, ...rest } = body;
262
+ const m = /^data:image\/png;base64,([A-Za-z0-9+/=]+)$/.exec(image);
263
+ if (!m || m[1].length > 8_000_000)
264
+ return { status: 400, json: { error: 'bad_state' } };
265
+ const png = await writeCardImage(ws, tutor, date, job, n, Buffer.from(m[1], 'base64'));
266
+ state = { ...rest, image: `conversations/${tutor}/${png}` };
267
+ }
268
+ const r = parseCardState(target, state);
269
+ if (!r.ok)
270
+ return { status: 400, json: { error: 'bad_state' } };
271
+ const last = index.messages[index.messages.length - 1];
272
+ await writeCardState(ws, tutor, date, job, n, { at: ctx.now().toISOString(), turn: last.job, state: r.state });
273
+ return { status: 200, json: { ok: true, card: `${job}/${n}` } };
274
+ }
275
+ // 图片卡的图:只认 workspace 根以内的图片文件(产物、照片);越界、不是图、不存在都 404
276
+ if (p === '/api/kid/image' && method === 'GET') {
277
+ const rel = url.searchParams.get('p') ?? '';
278
+ const file = resolve(ws.root, rel);
279
+ const ext = IMAGE_EXT.exec(rel)?.[1]?.toLowerCase() ?? '';
280
+ if (!rel || rel.startsWith('/') || !file.startsWith(ws.root + sep) || !IMAGE_TYPES[ext])
281
+ return { status: 404, json: { error: 'not_found' } };
282
+ if (!(await stat(file).catch(() => null))?.isFile())
283
+ return { status: 404, json: { error: 'not_found' } };
284
+ return { status: 200, file, contentType: IMAGE_TYPES[ext] };
285
+ }
286
+ const audio = /^\/api\/audio\/([a-z0-9][a-z0-9-]*)\/(.+)$/.exec(p);
287
+ if (audio && method === 'GET') {
288
+ const [, tutor, name] = audio;
289
+ if (!ws.config.tutors[tutor] || !AUDIO_FILE_RE.test(name))
290
+ return { status: 404, json: { error: 'not_found' } };
291
+ const file = join(ws.dirs.conversations, tutor, name);
292
+ if (!(await stat(file).catch(() => null))?.isFile())
293
+ return { status: 404, json: { error: 'not_found' } };
294
+ return { status: 200, file, contentType: 'audio/mpeg' };
295
+ }
296
+ const conv = /^\/api\/conversations\/([a-z0-9][a-z0-9-]*)(?:\/([^/]+))?$/.exec(p);
297
+ if (conv) {
298
+ const [, tutor, tail] = conv;
299
+ if (!ws.config.tutors[tutor])
300
+ return { status: 404, json: { error: 'no_such_tutor', tutor } };
301
+ if (tail === undefined && method === 'GET') {
302
+ return { status: 200, json: { tutor, today: localDate(ctx.now()), dates: await listDates(ws, tutor), running: ctx.runner.running(tutor) } };
303
+ }
304
+ if (tail === 'messages' && method === 'POST') {
305
+ if (!isObj(body) || typeof body.text !== 'string')
306
+ return { status: 400, json: { error: 'bad_request', message: '要 {text, from?, focus?, runtime?}' } };
307
+ const from = body.from ?? 'parent';
308
+ if (!MESSAGE_FROM.includes(from))
309
+ return { status: 400, json: { error: 'bad_request', message: `from 只能是 ${MESSAGE_FROM.join(' / ')}` } };
310
+ const focus = body.focus === undefined ? undefined : FocusSchema.safeParse(body.focus);
311
+ if (focus && !focus.success)
312
+ return { status: 400, json: { error: 'bad_request', message: 'focus 形状不对' } };
313
+ const started = await ctx.runner.send(tutor, {
314
+ from: from,
315
+ text: body.text,
316
+ focus: focus?.data,
317
+ runtime: typeof body.runtime === 'string' ? body.runtime : undefined,
318
+ });
319
+ return { status: 202, json: { tutor, date: started.date, job: started.job, runtime: started.plan.runtime, resume: started.plan.resume } };
320
+ }
321
+ if (tail !== undefined && tail !== 'messages' && method === 'GET') {
322
+ const date = tail === 'today' ? localDate(ctx.now()) : tail;
323
+ if (!DATE_RE.test(date))
324
+ return { status: 400, json: { error: 'bad_request', message: '日期要是 YYYY-MM-DD 或 today' } };
325
+ return { status: 200, json: await dayView(ctx, tutor, date) };
326
+ }
327
+ return { status: 405, json: { error: 'method_not_allowed' } };
328
+ }
329
+ // 舞台包(重卡在 iframe 里开)与课包文件:静态,越界 404
330
+ if (method === 'GET' && p.startsWith('/stage/')) {
331
+ const f = await stageAsset(p);
332
+ return f ? { status: 200, file: f.file, contentType: f.contentType } : { status: 404, json: { error: 'not_found' } };
333
+ }
334
+ if (method === 'GET' && p.startsWith('/api/bundles/')) {
335
+ const f = await bundleAsset(ws.dirs.bundles, p);
336
+ return f ? { status: 200, file: f.file, contentType: f.contentType } : { status: 404, json: { error: 'not_found' } };
337
+ }
338
+ if (method !== 'GET')
339
+ return { status: 405, json: { error: 'method_not_allowed' } };
340
+ if (p === '/parent')
341
+ return { status: 200, html: PARENT_PAGE };
342
+ if (p === '/')
343
+ return { status: 200, html: KID_PAGE.replace('__TITLE__', esc(ws.config.title)) };
344
+ return { status: 404, json: { error: 'not_found', path: p } };
345
+ }
346
+ catch (err) {
347
+ if (err instanceof BusyError)
348
+ return { status: 409, json: { error: 'busy', message: err.message } };
349
+ if (err instanceof RuntimeError)
350
+ return { status: 400, json: { error: 'bad_runtime', message: err.message } };
351
+ if (err instanceof ConfigError)
352
+ return { status: 422, json: { error: 'config', message: err.message } };
353
+ if (err instanceof IndexError)
354
+ return { status: 500, json: { error: 'index', message: err.message } };
355
+ if (err instanceof UsageError)
356
+ return { status: 400, json: { error: 'usage', message: err.message } };
357
+ throw err;
358
+ }
359
+ }
360
+ async function readBody(req) {
361
+ const chunks = [];
362
+ let size = 0;
363
+ for await (const c of req) {
364
+ size += c.length;
365
+ if (size > 1_000_000)
366
+ throw new UsageError('请求体超过 1MB');
367
+ chunks.push(c);
368
+ }
369
+ const text = Buffer.concat(chunks).toString('utf8');
370
+ if (!text.trim())
371
+ return undefined;
372
+ try {
373
+ return JSON.parse(text);
374
+ }
375
+ catch {
376
+ throw new UsageError('请求体不是合法 JSON');
377
+ }
378
+ }
379
+ export function createHandler(ctx) {
380
+ return (req, res) => {
381
+ void (async () => {
382
+ let r;
383
+ try {
384
+ const body = req.method === 'GET' || req.method === 'HEAD' ? undefined : await readBody(req);
385
+ r = await route(req.method ?? 'GET', req.url ?? '/', ctx, body);
386
+ }
387
+ catch (err) {
388
+ r = err instanceof UsageError ? { status: 400, json: { error: 'usage', message: err.message } } : { status: 500, json: { error: 'internal', message: err instanceof Error ? err.message : String(err) } };
389
+ if (r.status === 500)
390
+ console.error(err);
391
+ }
392
+ if (r.file !== undefined) {
393
+ res.writeHead(r.status, { 'content-type': r.contentType ?? 'application/octet-stream', 'cache-control': 'private, max-age=86400' });
394
+ createReadStream(r.file).on('error', () => res.end()).pipe(res);
395
+ }
396
+ else if (r.html !== undefined) {
397
+ res.writeHead(r.status, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
398
+ res.end(r.html);
399
+ }
400
+ else {
401
+ res.writeHead(r.status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
402
+ res.end(JSON.stringify(r.json ?? null));
403
+ }
404
+ })();
44
405
  };
45
406
  }
@@ -0,0 +1 @@
1
+ export declare const KID_PAGE: string;