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,393 @@
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 const PARENT_PAGE = `<!doctype html>
9
+ <html lang="zh-CN">
10
+ <meta charset="utf-8">
11
+ <meta name="viewport" content="width=device-width,initial-scale=1">
12
+ <title>cotutor 家长端</title>
13
+ <style>
14
+ :root { --bg:#f7f7f5; --panel:#fff; --line:#e3e3df; --ink:#222; --dim:#777; --accent:#2b6cb0; --kid:#e8f3ff; --parent:#fff4e0; --sys:#eee; --err:#c0392b; --ok:#2e7d32; }
15
+ * { box-sizing:border-box; }
16
+ body { margin:0; background:var(--bg); color:var(--ink); font:15px/1.5 -apple-system,"PingFang SC","Helvetica Neue",sans-serif; }
17
+ header { display:flex; align-items:center; gap:16px; padding:10px 16px; background:var(--panel); border-bottom:1px solid var(--line); }
18
+ header h1 { font-size:16px; margin:0; }
19
+ header nav a { margin-right:12px; color:var(--dim); text-decoration:none; padding:4px 0; border-bottom:2px solid transparent; }
20
+ header nav a.on { color:var(--ink); border-color:var(--accent); }
21
+ header .health { margin-left:auto; font-size:12px; color:var(--dim); }
22
+ header .health.bad { color:var(--err); }
23
+ main { display:none; }
24
+ main.on { display:block; }
25
+ /* 对话 */
26
+ #chat { display:grid; grid-template-columns:220px 1fr; height:calc(100vh - 47px); }
27
+ #chat.on { display:grid; }
28
+ aside { border-right:1px solid var(--line); background:var(--panel); overflow:auto; }
29
+ aside .tutor { display:flex; gap:8px; align-items:center; padding:10px 12px; cursor:pointer; border-bottom:1px solid var(--line); }
30
+ aside .tutor.on { background:var(--kid); }
31
+ aside .tutor .av { font-size:22px; }
32
+ aside .tutor small { display:block; color:var(--dim); font-size:12px; }
33
+ aside .dates { padding:8px 12px; }
34
+ aside .dates select { width:100%; }
35
+ #thread { display:flex; flex-direction:column; min-width:0; }
36
+ #msgs { flex:1; overflow:auto; padding:16px; }
37
+ .msg { max-width:900px; margin:0 auto 18px; }
38
+ .bubble { padding:8px 12px; border-radius:10px; white-space:pre-wrap; word-break:break-word; }
39
+ .from-kid .bubble.in { background:var(--kid); }
40
+ .from-parent .bubble.in { background:var(--parent); }
41
+ .from-system .bubble.in { background:var(--sys); }
42
+ .meta { font-size:12px; color:var(--dim); margin:2px 4px; }
43
+ .run { margin:8px 0 0 16px; border-left:3px solid var(--line); padding-left:12px; }
44
+ .run .say { white-space:pre-wrap; margin:4px 0 8px; }
45
+ .run .tool, .run .done { font:12px/1.5 ui-monospace,Menlo,monospace; color:var(--dim); white-space:pre-wrap; }
46
+ .run .tool-error { font:12px/1.5 ui-monospace,Menlo,monospace; color:var(--err); white-space:pre-wrap; }
47
+ .run details { margin:4px 0; font-size:12px; color:var(--dim); }
48
+ .run details summary { cursor:pointer; }
49
+ .run .sub { border-left:2px dashed var(--line); padding-left:8px; margin:4px 0; }
50
+ .kid { margin:8px 0 0 16px; font-size:13px; color:var(--dim); }
51
+ .kid b { color:var(--ok); font-weight:500; }
52
+ .kid .none { color:var(--dim); font-style:italic; }
53
+ .holdup { margin:8px 0 0 16px; padding:10px 12px; background:#fffbe6; border:1px solid #f0e0a0; border-radius:8px; }
54
+ .holdup .q { font-weight:600; margin-bottom:6px; }
55
+ .holdup button { margin:2px 6px 2px 0; }
56
+ .holdup small { color:var(--dim); }
57
+ .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; }
58
+ .running { color:var(--accent); font-size:13px; margin-left:16px; }
59
+ #composer { border-top:1px solid var(--line); background:var(--panel); padding:10px 16px; display:flex; gap:8px; align-items:flex-end; }
60
+ #composer textarea { flex:1; min-height:44px; max-height:160px; font:inherit; padding:8px; border:1px solid var(--line); border-radius:8px; resize:vertical; }
61
+ #composer select, #composer button { font:inherit; padding:8px 10px; }
62
+ #composer button { background:var(--accent); color:#fff; border:0; border-radius:8px; cursor:pointer; }
63
+ #composer button:disabled { opacity:.5; cursor:default; }
64
+ .empty { color:var(--dim); text-align:center; margin-top:60px; }
65
+ /* 老师团 */
66
+ #team, #settings { padding:16px; max-width:1000px; margin:0 auto; }
67
+ .card { background:var(--panel); border:1px solid var(--line); border-radius:10px; padding:14px 16px; margin-bottom:14px; }
68
+ .card h2 { font-size:15px; margin:0 0 10px; display:flex; align-items:center; gap:8px; }
69
+ .card h2 .av { font-size:22px; }
70
+ .card h2 .off { color:var(--dim); font-weight:400; font-size:12px; }
71
+ .grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(200px,1fr)); gap:8px 16px; }
72
+ .grid label { display:flex; flex-direction:column; font-size:12px; color:var(--dim); }
73
+ .grid input[type=text], .grid input[type=number], .grid select { font:inherit; padding:5px 7px; border:1px solid var(--line); border-radius:6px; }
74
+ .grid label.chk { flex-direction:row; align-items:center; gap:6px; font-size:14px; color:var(--ink); }
75
+ .card .actions { margin-top:10px; display:flex; gap:8px; align-items:center; }
76
+ .card .actions button { font:inherit; padding:6px 12px; border-radius:6px; border:1px solid var(--line); background:#fff; cursor:pointer; }
77
+ .card .actions button.primary { background:var(--accent); color:#fff; border-color:var(--accent); }
78
+ .card .actions .msg-ok { color:var(--ok); font-size:13px; }
79
+ .card .actions .msg-err { color:var(--err); font-size:13px; white-space:pre-wrap; }
80
+ .hint { color:var(--dim); font-size:12px; }
81
+ .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; }
82
+ .card details { margin-top:10px; }
83
+ .card details summary { cursor:pointer; color:var(--dim); font-size:13px; }
84
+ .card .danger { color:var(--err); border-color:#f0c0c0 !important; }
85
+ .card .origin { font-size:12px; color:var(--dim); margin-left:auto; }
86
+ @media (max-width:700px) { #chat { grid-template-columns:1fr; } aside { display:none; } }
87
+ </style>
88
+ <header>
89
+ <h1 id="title">cotutor</h1>
90
+ <nav><a href="#chat" data-tab="chat" class="on">对话</a><a href="#team" data-tab="team">老师团</a><a href="#settings" data-tab="settings">设置</a></nav>
91
+ <span class="health" id="health"></span>
92
+ </header>
93
+ <main id="chat" class="on">
94
+ <aside>
95
+ <div id="tutors"></div>
96
+ <div class="dates"><select id="dates"></select></div>
97
+ </aside>
98
+ <section id="thread">
99
+ <div id="msgs"><p class="empty">左边选一位老师</p></div>
100
+ <form id="composer">
101
+ <select id="from" title="以谁的身份说"><option value="parent">家长</option><option value="kid">孩子(模拟)</option><option value="system">系统</option></select>
102
+ <select id="runtime" title="运行时"></select>
103
+ <textarea id="text" placeholder="对老师说……(⌘/Ctrl+Enter 发送)"></textarea>
104
+ <button type="submit" id="send">发送</button>
105
+ </form>
106
+ </section>
107
+ </main>
108
+ <main id="team"></main>
109
+ <main id="settings"></main>
110
+ <script>
111
+ (() => {
112
+ const $ = (s, el = document) => el.querySelector(s);
113
+ const h = (tag, attrs = {}, ...kids) => {
114
+ const el = document.createElement(tag);
115
+ for (const [k, v] of Object.entries(attrs)) {
116
+ 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);
117
+ }
118
+ for (const k of kids.flat()) if (k !== null && k !== undefined) el.append(k.nodeType ? k : document.createTextNode(String(k)));
119
+ return el;
120
+ };
121
+ const api = async (method, path, body) => {
122
+ const r = await fetch(path, { method, headers: body ? { 'content-type': 'application/json' } : {}, body: body ? JSON.stringify(body) : undefined });
123
+ const j = await r.json().catch(() => null);
124
+ if (!r.ok) throw new Error((j && j.message) || (j && j.error) || (r.status + ' ' + r.statusText));
125
+ return j;
126
+ };
127
+
128
+ const state = { config: null, tutor: null, date: null, dates: [], view: null, timer: null, tab: location.hash === '#team' ? 'team' : location.hash === '#settings' ? 'settings' : 'chat' };
129
+
130
+ // ---- 顶栏与标签 ----
131
+ const showTab = (tab) => {
132
+ state.tab = tab;
133
+ for (const a of document.querySelectorAll('header nav a')) a.classList.toggle('on', a.dataset.tab === tab);
134
+ for (const m of document.querySelectorAll('main')) m.classList.toggle('on', m.id === tab);
135
+ if (tab === 'team') renderTeam();
136
+ if (tab === 'settings') renderSettings();
137
+ };
138
+ for (const a of document.querySelectorAll('header nav a')) a.addEventListener('click', (e) => { e.preventDefault(); location.hash = '#' + a.dataset.tab; showTab(a.dataset.tab); });
139
+
140
+ const refreshHealth = async () => {
141
+ try {
142
+ const hh = await api('GET', '/api/health');
143
+ const el = $('#health');
144
+ el.textContent = hh.configError ? '⚠ cotutor.json 改坏了,仍用上一份:' + hh.configError.split('\\n')[0] : '';
145
+ el.classList.toggle('bad', Boolean(hh.configError));
146
+ } catch (e) { $('#health').textContent = '后端不通'; $('#health').classList.add('bad'); }
147
+ };
148
+
149
+ const loadConfig = async () => {
150
+ state.config = await api('GET', '/api/config');
151
+ $('#title').textContent = state.config.title + ' · 家长端';
152
+ document.title = state.config.title + ' · 家长端';
153
+ const ps = $('#runtime');
154
+ ps.replaceChildren(...state.config.runtimes.map((p) => h('option', { value: p, selected: p === state.config.runtime ? '' : undefined }, p === state.config.runtime ? p + '(缺省)' : p)));
155
+ renderTutors();
156
+ };
157
+
158
+ // ---- 对话:老师列表(enabled 的才有,关掉即消失;hidden 的家长仍可见)----
159
+ const renderTutors = () => {
160
+ const list = state.config.tutors.filter((t) => t.enabled);
161
+ if (state.tutor && !list.some((t) => t.name === state.tutor)) { state.tutor = null; state.view = null; $('#msgs').replaceChildren(h('p', { class: 'empty' }, '这位老师已关闭')); }
162
+ $('#tutors').replaceChildren(...list.map((t) => h('div', { class: 'tutor' + (t.name === state.tutor ? ' on' : ''), on: { click: () => pickTutor(t.name) } },
163
+ h('span', { class: 'av' }, t.avatar || '🙂'),
164
+ h('div', {}, t.display, h('small', {}, t.name + (t.hidden ? ' · 孩子端不露' : '') + (t.subject ? ' · ' + t.subject : ''))))));
165
+ };
166
+
167
+ const pickTutor = async (name, date) => {
168
+ state.tutor = name;
169
+ renderTutors();
170
+ const d = await api('GET', '/api/conversations/' + name);
171
+ state.dates = d.dates.includes(d.today) ? d.dates : [d.today, ...d.dates];
172
+ state.date = date || d.today;
173
+ $('#dates').replaceChildren(...state.dates.map((x) => h('option', { value: x, selected: x === state.date ? '' : undefined }, x === d.today ? x + '(今天)' : x)));
174
+ await loadDay();
175
+ };
176
+ $('#dates').addEventListener('change', () => { state.date = $('#dates').value; loadDay(); });
177
+
178
+ const loadDay = async () => {
179
+ if (!state.tutor || !state.date) return;
180
+ const stick = $('#msgs').scrollHeight - $('#msgs').scrollTop - $('#msgs').clientHeight < 40;
181
+ state.view = await api('GET', '/api/conversations/' + state.tutor + '/' + state.date);
182
+ renderDay();
183
+ if (stick) $('#msgs').scrollTop = $('#msgs').scrollHeight;
184
+ clearTimeout(state.timer);
185
+ if (state.view.running) state.timer = setTimeout(loadDay, 2000);
186
+ $('#send').disabled = Boolean(state.view.running);
187
+ };
188
+
189
+ const rowsEl = (rows) => {
190
+ const out = [];
191
+ let subs = [];
192
+ const flushSubs = () => { if (subs.length) { out.push(h('details', { class: 'sub' }, h('summary', {}, '子代理 · ' + subs.length + ' 条'), ...subs)); subs = []; } };
193
+ const one = (r) => {
194
+ if (r.kind === 'fold') return h('details', {}, h('summary', {}, '用了 ' + r.tools.length + ' 步工具'), ...r.tools.map((t) => h('div', { class: 'tool' }, t.text)));
195
+ if (r.kind === 'text') return h('div', { class: 'say' }, r.text);
196
+ return h('div', { class: r.kind }, r.text);
197
+ };
198
+ for (const r of rows) {
199
+ if (r.kind !== 'fold' && r.sub) { subs.push(one(r)); continue; }
200
+ flushSubs();
201
+ out.push(one(r));
202
+ }
203
+ flushSubs();
204
+ return out;
205
+ };
206
+
207
+ const renderDay = () => {
208
+ const v = state.view;
209
+ const t = state.config.tutors.find((x) => x.name === state.tutor) || { display: state.tutor };
210
+ if (!v.index.messages.length) { $('#msgs').replaceChildren(h('p', { class: 'empty' }, state.date + ' 还没和' + t.display + '说过话')); return; }
211
+ const FROM = { kid: '孩子', parent: '家长', system: '系统' };
212
+ const nodes = v.index.messages.map((m) => {
213
+ const el = h('div', { class: 'msg from-' + m.from });
214
+ el.append(h('div', { class: 'meta' }, FROM[m.from] || m.from, ' · ', m.at, ' · ', m.job, m.runtime ? ' · ' + m.runtime : '', m.focus && m.focus.artifact ? ' · 看着 ' + m.focus.artifact + (m.focus.step !== undefined ? ' 第 ' + m.focus.step + ' 步' : '') : '', m.focus && m.focus.card ? ' · 开着卡 ' + m.focus.card : '', m.action === 'continue' ? ' · 继续' : m.action === 'submit' ? ' · 交给老师' : ''));
215
+ if (m.cards && m.cards.length) el.append(h('div', { class: 'kid' }, '孩子在板书上做的:', ...m.cards.map((c) => h('div', {}, h('b', {}, c.card + ' ' + c.text)))));
216
+ el.append(h('div', { class: 'bubble in' }, m.text));
217
+ const rows = v.runs[m.job] || [];
218
+ const run = h('div', { class: 'run' }, h('div', { class: 'meta' }, (t.avatar || '') + ' ' + t.display), ...rowsEl(rows));
219
+ if (m.result === 'running') run.append(h('div', { class: 'running' }, v.running === m.job ? '老师在想……' : '(没跑完:服务重启过或进程被杀,看 ' + m.job + '.err.log)'));
220
+ el.append(run);
221
+ if (m.result === 'error') el.append(h('div', { class: 'err' }, '本轮出错:' + (m.error || '未知') + (v.errors[m.job] ? '\\n' + v.errors[m.job] : '')));
222
+ if (m.result === 'ok') el.append(h('div', { class: 'kid' }, '孩子看到:', m.kidText ? h('b', {}, m.kidText) : h('span', { class: 'none' }, '(这轮没有给孩子的话)'), m.section && m.section.cards.length ? h('span', { class: 'none' }, ' · 板书 ' + m.section.cards.length + ' 张卡 ' + m.section.lines.length + ' 句') : null));
223
+ if (m.parentText) el.append(h('div', { class: 'kid' }, '给家长:', h('b', {}, m.parentText.replace(/^## 家长\s*/, ''))));
224
+ if (m.warnings && m.warnings.length) el.append(h('div', { class: 'kid' }, h('span', { class: 'none' }, m.warnings.join(';'))));
225
+ if (m.holdup) {
226
+ const card = h('div', { class: 'holdup' }, h('div', { class: 'q' }, '待裁量:' + m.holdup.question));
227
+ for (const o of m.holdup.options) card.append(h('button', { type: 'button', on: { click: () => send('待裁量「' + m.holdup.question + '」:选「' + o.label + '」', 'parent') } }, o.label + (o.recommended ? ' ★' : '')), o.note ? h('small', {}, o.note + ' ') : null);
228
+ if (!m.holdup.options.length) card.append(h('small', {}, '(没给选项,直接在下面回复)'));
229
+ el.append(card);
230
+ }
231
+ if (m.handoff) el.append(h('div', { class: 'kid' }, '转交 → ' + m.handoff.to + (m.handoff.why ? ':' + m.handoff.why : ''), m.handoffJob ? h('span', { class: 'none' }, ' · 已起 ' + m.handoffJob.tutor + ' 的 ' + m.handoffJob.job) : h('span', { class: 'none' }, ' · 没起(见提醒)')));
232
+ return el;
233
+ });
234
+ nodes.push(h('div', { class: 'meta', style: 'text-align:center' }, '今日费用 $' + v.index.costUsd.toFixed(2), v.index.session ? ' · 会话 ' + v.index.session.id.slice(0, 8) + '(' + v.index.session.runtime + ')' : ''));
235
+ $('#msgs').replaceChildren(...nodes);
236
+ };
237
+
238
+ const send = async (text, from) => {
239
+ if (!state.tutor) return alert('先选一位老师');
240
+ const today = state.dates[0];
241
+ try {
242
+ $('#send').disabled = true;
243
+ await api('POST', '/api/conversations/' + state.tutor + '/messages', { text, from: from || $('#from').value, runtime: $('#runtime').value });
244
+ $('#text').value = '';
245
+ if (state.date !== today) await pickTutor(state.tutor, today); else await loadDay();
246
+ } catch (e) { alert(e.message); $('#send').disabled = false; }
247
+ };
248
+ $('#composer').addEventListener('submit', (e) => { e.preventDefault(); const t = $('#text').value.trim(); if (t) send(t); });
249
+ $('#text').addEventListener('keydown', (e) => { if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); $('#composer').requestSubmit(); } });
250
+
251
+ // ---- 老师团:只读写 cotutor.json ----
252
+ const POLICY_FIELDS = [['replyMaxChars', '单条回复字数上限', 'number'], ['dailyMessages', '每日消息上限', 'number'], ['dailyRegen', '每日重生上限', 'number'], ['reviewGate', '验收开关(先经家长)', 'bool'], ['forms', '回复形式(逗号分隔 L0-L4)', 'forms'], ['contextPack.recent', '上下文包:最近观察条数', 'number'], ['contextPack.planLines', '上下文包:计划行数', 'number']];
253
+ const getPath = (o, p) => p.split('.').reduce((a, k) => (a == null ? undefined : a[k]), o);
254
+ 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; };
255
+
256
+ const policyInputs = (patch, effective, opts) => POLICY_FIELDS.map(([key, label, type]) => {
257
+ const cur = getPath(patch, key);
258
+ const eff = getPath(effective, key);
259
+ const inherit = opts.inherit && cur === undefined;
260
+ if (type === 'bool') {
261
+ const sel = h('select', { 'data-key': key }, opts.inherit ? h('option', { value: '', selected: inherit ? '' : undefined }, '继承(' + (eff ? '开' : '关') + ')') : null, h('option', { value: 'true', selected: cur === true ? '' : undefined }, '开'), h('option', { value: 'false', selected: cur === false || (!opts.inherit && cur === undefined && eff === false) ? '' : undefined }, '关'));
262
+ return h('label', {}, label, sel);
263
+ }
264
+ const val = cur === undefined ? '' : type === 'forms' ? cur.join(',') : String(cur);
265
+ return h('label', {}, label, h('input', { type: type === 'number' ? 'number' : 'text', 'data-key': key, value: val, placeholder: (inherit ? '继承:' : '') + (type === 'forms' ? (eff || []).join(',') : eff) }));
266
+ });
267
+ const readPolicy = (card) => {
268
+ const out = {};
269
+ for (const el of card.querySelectorAll('[data-key]')) {
270
+ const key = el.dataset.key;
271
+ const type = POLICY_FIELDS.find((f) => f[0] === key)[2];
272
+ const raw = el.value.trim();
273
+ if (raw === '') { setPath(out, key, null); continue; }
274
+ if (type === 'number') { const n = Number(raw); if (!Number.isInteger(n)) throw new Error(key + ' 要是整数'); setPath(out, key, n); }
275
+ else if (type === 'bool') setPath(out, key, raw === 'true');
276
+ else setPath(out, key, raw.split(/[,,\\s]+/).filter(Boolean));
277
+ }
278
+ return out;
279
+ };
280
+ const feedback = (card, ok, text) => { const m = $('.actions .fb', card); m.className = 'fb ' + (ok ? 'msg-ok' : 'msg-err'); m.textContent = text; };
281
+ const patchAndReload = async (card, patch) => {
282
+ try { await api('PATCH', '/api/config', patch); await loadConfig(); feedback(card, true, '已写入 cotutor.json'); if (state.tab === 'team') renderTeam(); }
283
+ catch (e) { feedback(card, false, e.message); }
284
+ };
285
+
286
+ const AVATARS = ['🧮', '📚', '📖', '🔬', '🎨', '🎵', '🌍', '💻', '🏃', '🧩', '📷', '🗓'];
287
+ const newTutorCard = () => {
288
+ const card = h('div', { class: 'card' }, h('h2', {}, '➕ 新老师'), h('div', { class: 'grid' },
289
+ h('label', {}, '老师名(英文键,如 science-tutor)', h('input', { type: 'text', id: 'n-name', placeholder: 'science-tutor' })),
290
+ h('label', {}, '显示名', h('input', { type: 'text', id: 'n-display', placeholder: '科学老师' })),
291
+ h('label', {}, '学科(可空)', h('input', { type: 'text', id: 'n-subject', placeholder: '科学' })),
292
+ h('label', {}, '头像', h('select', { id: 'n-avatar' }, ...AVATARS.map((a) => h('option', { value: a }, a)))),
293
+ h('label', { class: 'chk' }, h('input', { type: 'checkbox', id: 'n-hidden' }), '孩子端不露')),
294
+ h('p', { class: 'hint' }, '会写一份带全部约定的老师文件、进 cotutor.json、建目录;之后在下面这位老师的卡里改「老师文件」把性子填上。与终端 cotutor add 同一条路。'),
295
+ h('div', { class: 'actions' }, h('button', { class: 'primary', type: 'button', on: { click: async () => {
296
+ const name = $('#n-name').value.trim(), display = $('#n-display').value.trim();
297
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(name)) return feedback(card, false, '老师名要小写字母数字连字符');
298
+ if (!display) return feedback(card, false, '显示名不能空');
299
+ 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, '加了 ' + display); }
300
+ catch (e) { feedback(card, false, e.message); }
301
+ } } }, '加进来'), h('span', { class: 'fb' })));
302
+ return card;
303
+ };
304
+ const fileEditor = (t) => {
305
+ const box = h('details', {}, h('summary', {}, '老师文件(系统提示;改了就是你家的,升级不覆盖)'));
306
+ const ta = h('textarea', { spellcheck: 'false' });
307
+ const fb = h('span', { class: 'fb' });
308
+ const origin = h('span', { class: 'origin' });
309
+ const load = async () => { try { const f = await api('GET', '/api/tutors/' + t.name + '/file'); ta.value = f.text; origin.textContent = f.state === 'own' ? '自家加的' : f.state === 'latest' ? '出厂件,最新' : f.state === 'upgradable' ? '出厂件,可升级' : '自定义' + (f.basedOn ? '(基于 ' + f.basedOn + ')' : ''); } catch (e) { ta.value = ''; fb.className = 'fb msg-err'; fb.textContent = e.message; } };
310
+ box.addEventListener('toggle', () => { if (box.open && !ta.value) load(); });
311
+ 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 = '已写入'; origin.textContent = f.state === 'own' ? '自家加的' : '自定义'; } catch (e) { fb.className = 'fb msg-err'; fb.textContent = e.message; } } } }, '保存老师文件'), h('button', { type: 'button', on: { click: load } }, '重新读'), origin, fb));
312
+ return box;
313
+ };
314
+
315
+ const renderTeam = () => {
316
+ const c = state.config;
317
+ if (!c) return;
318
+ const root = $('#team');
319
+ const top = h('div', { class: 'card' }, h('h2', {}, '全局'), h('div', { class: 'grid' },
320
+ h('label', {}, '孩子端标题', h('input', { type: 'text', id: 'g-title', value: c.title })),
321
+ h('label', {}, '缺省运行时(runtimes.default)', h('select', { id: 'g-agent' }, ...c.runtimes.map((p) => h('option', { value: p, selected: p === c.runtime ? '' : undefined }, p)))),
322
+ ...policyInputs(c.policyDefaults, c.tutors[0] ? Object.assign({}, c.tutors[0].policy, c.policyDefaults) : {}, { inherit: false })),
323
+ h('p', { class: 'hint' }, 'policyDefaults 留空 = 用出厂缺省(60 字 / 30 条 / 3 次 / 验收关 / L0,L1,L3,L4 / 10,10)。运行时模板、paths、端口请直接编辑 cotutor.json。'),
324
+ 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); } } } }, '保存全局'), h('span', { class: 'fb' })));
325
+ const cards = c.tutors.map((t) => {
326
+ const patch = c.tutorPatches[t.name] || {};
327
+ const card = h('div', { class: 'card' },
328
+ h('h2', {}, h('span', { class: 'av' }, t.avatar || '🙂'), t.display, h('span', { class: 'off' }, t.name + (t.enabled ? '' : ' · 已关闭') + (t.hidden ? ' · 孩子端不露' : ''))),
329
+ h('div', { class: 'grid' },
330
+ h('label', {}, '显示名', h('input', { type: 'text', 'data-f': 'display', value: t.display })),
331
+ h('label', {}, '学科', h('input', { type: 'text', 'data-f': 'subject', value: t.subject || '' })),
332
+ h('label', {}, '头像(emoji)', h('input', { type: 'text', 'data-f': 'avatar', value: t.avatar || '' })),
333
+ h('label', {}, '音色(voxtell id)', h('input', { type: 'text', 'data-f': 'voice', value: t.voice || '' })),
334
+ h('label', { class: 'chk' }, h('input', { type: 'checkbox', 'data-f': 'enabled', checked: t.enabled ? '' : undefined }), '开启'),
335
+ h('label', { class: 'chk' }, h('input', { type: 'checkbox', 'data-f': 'hidden', checked: t.hidden ? '' : undefined }), '孩子端不露'),
336
+ ...policyInputs(patch, t.policy, { inherit: true })),
337
+ h('div', { class: 'actions' }, h('button', { class: 'primary', type: 'button' }, '保存'), 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('删掉 ' + t.display + '?老师文件改名保留,会话与记忆不动。')) return; try { await api('DELETE', '/api/tutors/' + t.name); await loadConfig(); renderTeam(); } catch (e) { feedback(card, false, e.message); } } } }, '删掉这位老师')),
338
+ fileEditor(t));
339
+ $('.actions button', card).addEventListener('click', () => {
340
+ try {
341
+ const f = (name) => $('[data-f=' + name + ']', card);
342
+ 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) };
343
+ patchAndReload(card, { tutors: { [t.name]: entry } });
344
+ } catch (e) { feedback(card, false, e.message); }
345
+ });
346
+ return card;
347
+ });
348
+ root.replaceChildren(top, newTutorCard(), ...cards);
349
+ };
350
+
351
+ // ---- 设置:paths / 端口 / 证书 / 配音命令;文件仍是真相 ----
352
+ const PATH_ROLES = [['vault', 'vault 根(Obsidian 仓库;空 = workspace 根)'], ['timetable', '课程表文件(相对 vault)'], ['plans', '计划目录(相对 vault)'], ['diary', '日记目录'], ['photos', '照片目录'], ['profile', '孩子档案文件']];
353
+ const renderSettings = () => {
354
+ const c = state.config;
355
+ if (!c) return;
356
+ const card = h('div', { class: 'card' }, h('h2', {}, '路径与服务'), h('div', { class: 'grid' },
357
+ ...PATH_ROLES.map(([k, label]) => h('label', {}, label, h('input', { type: 'text', 'data-p': k, value: c.paths[k] || '', placeholder: c.resolvedPaths[k] || '' }))),
358
+ h('label', {}, '端口(改了要重启 serve)', h('input', { type: 'number', id: 's-port', value: c.server.port })),
359
+ h('label', {}, 'HTTPS 证书(相对 workspace;空 = 用机器级 ~/.config/cotutor/certs/)', h('input', { type: 'text', id: 's-cert', value: c.https ? c.https.cert : '', placeholder: '留空即用 cotutor cert 签的' })),
360
+ h('label', {}, 'HTTPS 私钥', h('input', { type: 'text', id: 's-key', value: c.https ? c.https.key : '', placeholder: '留空即用 cotutor cert 签的' }))),
361
+ h('label', { style: 'display:block;margin-top:10px;font-size:12px;color:var(--dim)' }, '配音命令(JSON 数组;占位 {text} {voice} {out};voxtell 不在 PATH 就把第一项写成完整路径)', h('textarea', { id: 's-tts', style: 'min-height:60px' }, JSON.stringify(c.tts.say))),
362
+ h('p', { class: 'hint' }, '写回 cotutor.json;kid、version、agents 的运行时模板请直接编辑文件。留空的路径角色用缺省;右侧灰字是现在解析到的绝对路径。'),
363
+ h('div', { class: 'actions' }, h('button', { class: 'primary', type: 'button', on: { click: async () => {
364
+ try {
365
+ const paths = {};
366
+ for (const el of card.querySelectorAll('[data-p]')) paths[el.dataset.p] = el.value.trim() || null;
367
+ const port = Number($('#s-port').value);
368
+ if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error('端口要是 1–65535');
369
+ const cert = $('#s-cert').value.trim(), key = $('#s-key').value.trim();
370
+ let say;
371
+ try { say = JSON.parse($('#s-tts').value); } catch { throw new Error('配音命令要是 JSON 数组'); }
372
+ if (!Array.isArray(say) || !say.length || !say.every((x) => typeof x === 'string')) throw new Error('配音命令要是非空字符串数组');
373
+ await api('PATCH', '/api/config', { paths, server: { port, https: cert && key ? { cert, key } : null }, tts: { say } });
374
+ await loadConfig(); feedback(card, true, '已写入 cotutor.json' + (port !== c.server.port ? ';端口改了,重启 serve 才生效' : ''));
375
+ } catch (e) { feedback(card, false, e.message); }
376
+ } } }, '保存设置'), h('span', { class: 'fb' })));
377
+ $('#settings').replaceChildren(card);
378
+ };
379
+
380
+ // ---- 启动 ----
381
+ (async () => {
382
+ await refreshHealth();
383
+ await loadConfig();
384
+ showTab(state.tab);
385
+ const first = state.config.tutors.find((t) => t.enabled && !t.hidden) || state.config.tutors.find((t) => t.enabled);
386
+ if (first) pickTutor(first.name);
387
+ setInterval(refreshHealth, 10000);
388
+ 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);
389
+ })().catch((e) => { $('#msgs').replaceChildren(h('p', { class: 'empty' }, '加载失败:' + e.message)); });
390
+ })();
391
+ </script>
392
+ </html>
393
+ `;
@@ -0,0 +1,64 @@
1
+ import type { BoardSection } from '../lib/kid-board.ts';
2
+ import { type RunPlan } from '../lib/run-plan.ts';
3
+ import { type ContextPack, type ConversationIndex, type Focus, type Handoff, type MessageFrom } from '../schema/index.ts';
4
+ import { type Workspace } from '../cli/workspace.ts';
5
+ export declare class BusyError extends Error {
6
+ constructor(tutor: string, job: string);
7
+ }
8
+ export interface SendInput {
9
+ from: MessageFrom;
10
+ /** 带 action 时可以是空的:继续 → 「继续」;交给老师 → 「(交了答案,没说话)」 */
11
+ text: string;
12
+ focus?: Focus;
13
+ /** 孩子端的动作:继续(不计每日上限)/ 交给老师(把改过的卡交出去) */
14
+ action?: 'continue' | 'submit';
15
+ /** 运行时名,缺省 cotutor.json 的 runtimes.default */
16
+ runtime?: string;
17
+ }
18
+ export interface SendStarted {
19
+ tutor: string;
20
+ date: string;
21
+ job: string;
22
+ plan: RunPlan;
23
+ /** 老师说完(进程退出、索引写好)后 resolve */
24
+ done: Promise<ConversationIndex>;
25
+ }
26
+ /** 上下文包的取材:课程表命中的时段 + 本周计划里本老师的行 + 本学科最近观察 */
27
+ export declare function gatherContext(ws: Workspace, tutor: string, input: {
28
+ from: MessageFrom;
29
+ at: Date;
30
+ focus?: Focus;
31
+ }): Promise<ContextPack>;
32
+ export declare class Runner {
33
+ private readonly active;
34
+ /** 还在后台跑的资产生成(测试与关服前 flush) */
35
+ private readonly background;
36
+ private readonly getWs;
37
+ private readonly opts;
38
+ constructor(getWs: () => Workspace, opts?: {
39
+ now?: () => Date;
40
+ env?: NodeJS.ProcessEnv;
41
+ });
42
+ running(tutor: string): {
43
+ job: string;
44
+ date: string;
45
+ } | null;
46
+ /** 等所有后台资产生成完(测试用) */
47
+ flush(): Promise<void>;
48
+ /** 正在回的那条已经出来的板书(流式);没在跑或还没出卡 → null */
49
+ partial(tutor: string): BoardSection | null;
50
+ send(tutor: string, input: SendInput): Promise<SendStarted>;
51
+ /**
52
+ * 给 scene-maker 的转交,课包 id 要在卡、refs、作业三处一致:refs[0] 不是合法 id 就取这节里场景卡的 bundle;
53
+ * scenes/<id>.ts 或 bundles/<id>/ 已经有了(老师起名撞了,真跑见过:孩子卡上放了旧课包)就改成 <id>-2、-3…,卡上的 bundle 一起改。
54
+ */
55
+ settleBundleId(ws: Workspace, h: Handoff, section: BoardSection | null): {
56
+ handoff: Handoff;
57
+ section: BoardSection | null;
58
+ warnings: string[];
59
+ };
60
+ /** 转交单:目标老师看到的那条消息(from: system) */
61
+ static handoffText(ws: Workspace, from: string, job: string, h: Handoff, kidText: string): string;
62
+ private handoff;
63
+ private spawn;
64
+ }