@edryslabs/edrys-dev 0.3.2 → 0.3.4
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 +22 -10
- package/dist/chat.js +5 -4
- package/dist/index.js +50 -10
- package/dist/init.js +25 -6
- package/dist/mcp.js +16 -9
- package/dist/server.js +103 -6
- package/dist/tutor.js +317 -43
- package/package.json +2 -2
- package/views/panel.js +289 -15
package/views/panel.js
CHANGED
|
@@ -10,10 +10,15 @@
|
|
|
10
10
|
}
|
|
11
11
|
return
|
|
12
12
|
}
|
|
13
|
-
// the chat only with an agent to answer it (edrys-dev --mcp)
|
|
14
|
-
|
|
13
|
+
// the chat only with an agent to answer it (edrys-dev --mcp), the students
|
|
14
|
+
// view only with a tutor (--tutor)
|
|
15
|
+
const script = document.currentScript
|
|
16
|
+
const hasChat = !!script && script.hasAttribute('data-chat')
|
|
17
|
+
const hasTutor = !!script && script.hasAttribute('data-tutor')
|
|
18
|
+
if (!hasChat && !hasTutor) return
|
|
15
19
|
|
|
16
20
|
const OPEN_KEY = 'edrys-dev:chat-open'
|
|
21
|
+
const VIEW_KEY = 'edrys-dev:chat-view'
|
|
17
22
|
|
|
18
23
|
const css = `
|
|
19
24
|
:host { all: initial; }
|
|
@@ -53,6 +58,46 @@
|
|
|
53
58
|
font-size: 14px; font-weight: 600; display: flex; align-items: center; gap: 8px;
|
|
54
59
|
}
|
|
55
60
|
header small { font-weight: 400; color: var(--muted); white-space: nowrap; }
|
|
61
|
+
.tabs { display: flex; gap: 2px; }
|
|
62
|
+
.tabs button {
|
|
63
|
+
font: inherit; font-size: 13px; padding: 3px 8px; border-radius: 6px; cursor: pointer;
|
|
64
|
+
border: 1px solid transparent; background: transparent; color: var(--muted);
|
|
65
|
+
}
|
|
66
|
+
.tabs button[aria-selected="true"] { border-color: var(--line); color: var(--fg); font-weight: 600; }
|
|
67
|
+
.tabs .count { display: inline-block; min-width: 16px; padding: 0 4px; margin-left: 2px; border-radius: 8px;
|
|
68
|
+
background: #e0452b; color: #fff; font-size: 11px; font-weight: 600; }
|
|
69
|
+
.tabs .count:empty { display: none; }
|
|
70
|
+
.spacer { flex: 1; }
|
|
71
|
+
.view { flex: 1; min-height: 0; display: flex; flex-direction: column; }
|
|
72
|
+
.view[hidden] { display: none; }
|
|
73
|
+
.share { padding: 8px 12px; border-bottom: 1px solid var(--line); font-size: 13px; }
|
|
74
|
+
.share[hidden] { display: none; }
|
|
75
|
+
.share .row { display: flex; gap: 6px; align-items: center; }
|
|
76
|
+
.share .row[hidden] { display: none; }
|
|
77
|
+
.share .row span { font-weight: 600; white-space: nowrap; }
|
|
78
|
+
.share input {
|
|
79
|
+
flex: 1; min-width: 0; font: 12px ui-monospace, monospace; padding: 4px 6px; border-radius: 6px;
|
|
80
|
+
border: 1px solid var(--line); background: var(--bg); color: var(--fg);
|
|
81
|
+
}
|
|
82
|
+
.share button {
|
|
83
|
+
font-size: 12px; padding: 4px 10px; border-radius: 6px; cursor: pointer;
|
|
84
|
+
border: 1px solid var(--accent); background: transparent; color: var(--accent);
|
|
85
|
+
}
|
|
86
|
+
.share .note { margin-top: 2px; color: var(--muted); font-size: 12px; }
|
|
87
|
+
.share .note:empty { display: none; }
|
|
88
|
+
.switch { padding: 8px 12px; border-bottom: 1px solid var(--line); font-size: 13px; }
|
|
89
|
+
.switch label { display: flex; gap: 6px; align-items: center; font-weight: 600; }
|
|
90
|
+
.switch .note { margin-top: 2px; color: var(--muted); font-size: 12px; }
|
|
91
|
+
.threads { flex: 1; overflow-y: auto; padding: 8px 12px; display: flex; flex-direction: column; gap: 8px; }
|
|
92
|
+
.threads .empty { color: var(--muted); font-size: 13px; }
|
|
93
|
+
.thread { border: 1px solid var(--line); border-radius: 8px; }
|
|
94
|
+
.thread summary { padding: 6px 10px; cursor: pointer; font-size: 13px; font-weight: 600; }
|
|
95
|
+
.thread summary small { font-weight: 400; color: var(--muted); }
|
|
96
|
+
.thread .entries { padding: 0 8px 8px; display: flex; flex-direction: column; gap: 6px; }
|
|
97
|
+
.msg.student { background: var(--me); color: var(--me-fg); align-self: flex-end; white-space: pre-wrap; }
|
|
98
|
+
.msg.tutor { background: var(--ai); align-self: flex-start; }
|
|
99
|
+
.msg .meta { display: block; font-size: 11px; opacity: .75; margin-bottom: 2px; white-space: normal; }
|
|
100
|
+
.msg.failed { align-self: stretch; font-size: 12px; border: 1px solid #e0452b; background: rgba(224,69,43,.1); }
|
|
56
101
|
header select {
|
|
57
102
|
flex: 1; min-width: 0; font-size: 13px; padding: 3px 4px; border-radius: 6px;
|
|
58
103
|
border: 1px solid var(--line); background: var(--bg); color: var(--fg);
|
|
@@ -101,25 +146,46 @@
|
|
|
101
146
|
root.innerHTML = `
|
|
102
147
|
<style>${css}</style>
|
|
103
148
|
<div class="wrap">
|
|
104
|
-
<button class="toggle" type="button" aria-expanded="false" aria-controls="panel" aria-label="
|
|
149
|
+
<button class="toggle" type="button" aria-expanded="false" aria-controls="panel" aria-label="edrys-dev">
|
|
105
150
|
🤖<span class="dot"></span>
|
|
106
151
|
</button>
|
|
107
|
-
<section class="panel" id="panel" aria-label="
|
|
152
|
+
<section class="panel" id="panel" aria-label="edrys-dev">
|
|
108
153
|
<header>
|
|
109
|
-
|
|
154
|
+
<div class="tabs" role="tablist">
|
|
155
|
+
<button type="button" role="tab" data-view="agent" aria-controls="agent">Agent</button>
|
|
156
|
+
<button type="button" role="tab" data-view="students" aria-controls="students">Students<span class="count"></span></button>
|
|
157
|
+
</div>
|
|
110
158
|
<label for="sessions" hidden>Chats</label>
|
|
111
159
|
<select id="sessions"></select>
|
|
160
|
+
<span class="spacer"></span>
|
|
112
161
|
<small title="edrys-dev: not the class's chat">only you see this</small>
|
|
113
162
|
</header>
|
|
114
|
-
<div class="
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
<
|
|
121
|
-
|
|
122
|
-
|
|
163
|
+
<div class="share" hidden>
|
|
164
|
+
<div class="row" hidden>
|
|
165
|
+
<span>Link for students</span>
|
|
166
|
+
<input readonly aria-label="Link for your students">
|
|
167
|
+
<button type="button">Copy</button>
|
|
168
|
+
</div>
|
|
169
|
+
<div class="note" aria-live="polite"></div>
|
|
170
|
+
</div>
|
|
171
|
+
<div class="view" id="agent" role="tabpanel">
|
|
172
|
+
<div class="log" role="log" aria-live="polite"></div>
|
|
173
|
+
<div class="problem" role="alert" hidden></div>
|
|
174
|
+
<div class="status" aria-live="polite"></div>
|
|
175
|
+
<div class="hint" aria-live="polite"></div>
|
|
176
|
+
<form class="compose">
|
|
177
|
+
<label for="text" hidden>Message to the agent</label>
|
|
178
|
+
<textarea id="text" placeholder="Message to the agent (Enter sends)"></textarea>
|
|
179
|
+
<button type="submit">Send</button>
|
|
180
|
+
</form>
|
|
181
|
+
</div>
|
|
182
|
+
<div class="view" id="students" role="tabpanel" hidden>
|
|
183
|
+
<div class="switch">
|
|
184
|
+
<label><input type="checkbox" id="tutor-on"> Tutor answers students</label>
|
|
185
|
+
<div class="note" aria-live="polite"></div>
|
|
186
|
+
</div>
|
|
187
|
+
<div class="threads" role="log" aria-live="polite"></div>
|
|
188
|
+
</div>
|
|
123
189
|
</section>
|
|
124
190
|
</div>`
|
|
125
191
|
document.documentElement.appendChild(host)
|
|
@@ -133,6 +199,41 @@
|
|
|
133
199
|
const compose = root.querySelector('.compose')
|
|
134
200
|
const textarea = root.querySelector('textarea')
|
|
135
201
|
const menu = root.querySelector('#sessions')
|
|
202
|
+
const tabs = [...root.querySelectorAll('.tabs button')]
|
|
203
|
+
const count = root.querySelector('.tabs .count')
|
|
204
|
+
const tutorBox = root.querySelector('#tutor-on')
|
|
205
|
+
const tutorNote = root.querySelector('.switch .note')
|
|
206
|
+
const threadList = root.querySelector('.threads')
|
|
207
|
+
|
|
208
|
+
// the views there are; the tabs only when there are two
|
|
209
|
+
let view = null
|
|
210
|
+
function showView(name) {
|
|
211
|
+
view = name
|
|
212
|
+
for (const tab of tabs) tab.setAttribute('aria-selected', String(tab.dataset.view === name))
|
|
213
|
+
root.querySelector('#agent').hidden = name !== 'agent'
|
|
214
|
+
root.querySelector('#students').hidden = name !== 'students'
|
|
215
|
+
menu.hidden = name !== 'agent'
|
|
216
|
+
// the menu fills the header; without it, the note goes right
|
|
217
|
+
root.querySelector('.spacer').hidden = name === 'agent'
|
|
218
|
+
if (name === 'students') count.textContent = ''
|
|
219
|
+
try {
|
|
220
|
+
sessionStorage.setItem(VIEW_KEY, name)
|
|
221
|
+
} catch {}
|
|
222
|
+
}
|
|
223
|
+
for (const tab of tabs) {
|
|
224
|
+
tab.hidden = !(hasChat && hasTutor) || (tab.dataset.view === 'agent' ? !hasChat : !hasTutor)
|
|
225
|
+
tab.addEventListener('click', () => showView(tab.dataset.view))
|
|
226
|
+
}
|
|
227
|
+
if (!(hasChat && hasTutor)) {
|
|
228
|
+
// one view: its name as the title
|
|
229
|
+
const title = root.querySelector('.tabs')
|
|
230
|
+
title.textContent = hasChat ? 'Agent' : 'Students'
|
|
231
|
+
}
|
|
232
|
+
let savedView = null
|
|
233
|
+
try {
|
|
234
|
+
savedView = sessionStorage.getItem(VIEW_KEY)
|
|
235
|
+
} catch {}
|
|
236
|
+
showView(!hasChat ? 'students' : !hasTutor ? 'agent' : savedView === 'students' ? 'students' : 'agent')
|
|
136
237
|
|
|
137
238
|
let open = null // the ask id of the form the user can still answer
|
|
138
239
|
let current = null // the chat shown, {id, title}
|
|
@@ -167,6 +268,7 @@
|
|
|
167
268
|
panel.classList.toggle('open', isOpen)
|
|
168
269
|
toggle.setAttribute('aria-expanded', String(isOpen))
|
|
169
270
|
if (isOpen) toggle.classList.remove('unread')
|
|
271
|
+
if (isOpen && view === 'students') count.textContent = ''
|
|
170
272
|
try {
|
|
171
273
|
sessionStorage.setItem(OPEN_KEY, isOpen ? '1' : '')
|
|
172
274
|
} catch {}
|
|
@@ -376,12 +478,184 @@
|
|
|
376
478
|
}
|
|
377
479
|
}
|
|
378
480
|
|
|
481
|
+
// --- the students' tutor threads ---
|
|
482
|
+
|
|
483
|
+
const threads = new Map() // thread id → {box, entries, when}
|
|
484
|
+
const time = (t) => new Date(t).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
|
485
|
+
|
|
486
|
+
function emptyNote() {
|
|
487
|
+
if (threads.size) return threadList.querySelector('.empty')?.remove()
|
|
488
|
+
const note = document.createElement('div')
|
|
489
|
+
note.className = 'empty'
|
|
490
|
+
note.textContent = 'No questions yet. Students ask with the 🤖 button in the class.'
|
|
491
|
+
threadList.replaceChildren(note)
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// the thread's box, moved to the top: the one changed last first
|
|
495
|
+
function threadFor(id, name) {
|
|
496
|
+
let thread = threads.get(id)
|
|
497
|
+
if (!thread) {
|
|
498
|
+
const box = document.createElement('details')
|
|
499
|
+
box.className = 'thread'
|
|
500
|
+
box.open = true
|
|
501
|
+
const summary = document.createElement('summary')
|
|
502
|
+
const entries = document.createElement('div')
|
|
503
|
+
entries.className = 'entries'
|
|
504
|
+
box.append(summary, entries)
|
|
505
|
+
thread = { box, summary, entries }
|
|
506
|
+
threads.set(id, thread)
|
|
507
|
+
emptyNote()
|
|
508
|
+
}
|
|
509
|
+
// the name is the student's choice: text only
|
|
510
|
+
const when = document.createElement('small')
|
|
511
|
+
when.textContent = thread.last ? ' · ' + time(thread.last) : ''
|
|
512
|
+
thread.summary.replaceChildren(name, when)
|
|
513
|
+
threadList.prepend(thread.box)
|
|
514
|
+
return thread
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function showTutorEntry(thread, entry) {
|
|
518
|
+
const div = document.createElement('div')
|
|
519
|
+
const meta = document.createElement('span')
|
|
520
|
+
meta.className = 'meta'
|
|
521
|
+
meta.textContent = time(entry.t) + (entry.room ? ' · ' + entry.room : '')
|
|
522
|
+
if (entry.from === 'student') {
|
|
523
|
+
div.className = 'msg student'
|
|
524
|
+
div.append(meta, entry.text)
|
|
525
|
+
} else {
|
|
526
|
+
div.className = 'msg tutor'
|
|
527
|
+
div.append(meta, render(entry.text))
|
|
528
|
+
}
|
|
529
|
+
thread.entries.appendChild(div)
|
|
530
|
+
thread.last = entry.t
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function addTutorEntry(id, name, entry) {
|
|
534
|
+
const thread = threadFor(id, name)
|
|
535
|
+
showTutorEntry(thread, entry)
|
|
536
|
+
threadFor(id, name)
|
|
537
|
+
threadList.scrollTop = 0
|
|
538
|
+
if (entry.from !== 'student') return
|
|
539
|
+
if (view !== 'students' || !panel.classList.contains('open')) {
|
|
540
|
+
count.textContent = String(Number(count.textContent || 0) + 1)
|
|
541
|
+
if (!panel.classList.contains('open')) toggle.classList.add('unread')
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function showSwitch(on) {
|
|
546
|
+
tutorBox.checked = on
|
|
547
|
+
tutorNote.textContent = on
|
|
548
|
+
? 'Students see the 🤖 button while this tab is open.'
|
|
549
|
+
: 'Off: students do not see the button, and questions get "The tutor is off".'
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
tutorBox.addEventListener('change', async () => {
|
|
553
|
+
const on = tutorBox.checked
|
|
554
|
+
const response = await fetch('/edrys-dev/tutor', {
|
|
555
|
+
method: 'POST',
|
|
556
|
+
headers: { 'Content-Type': 'application/json' },
|
|
557
|
+
body: JSON.stringify({ on }),
|
|
558
|
+
}).catch(() => null)
|
|
559
|
+
if (response?.ok) return
|
|
560
|
+
showSwitch(!on)
|
|
561
|
+
tutorNote.textContent = 'Not switched: is edrys-dev still running? Try again.'
|
|
562
|
+
})
|
|
563
|
+
|
|
564
|
+
function connectTutor() {
|
|
565
|
+
const events = new EventSource('/edrys-dev/tutor/events')
|
|
566
|
+
events.onmessage = (event) => {
|
|
567
|
+
const message = JSON.parse(event.data)
|
|
568
|
+
switch (message.type) {
|
|
569
|
+
case 'threads':
|
|
570
|
+
threads.clear()
|
|
571
|
+
threadList.replaceChildren()
|
|
572
|
+
showSwitch(message.on)
|
|
573
|
+
// oldest first, so the newest ends up on top
|
|
574
|
+
for (const saved of [...message.threads].reverse()) {
|
|
575
|
+
const thread = threadFor(saved.id, saved.name)
|
|
576
|
+
if (saved.skipped) {
|
|
577
|
+
const note = document.createElement('div')
|
|
578
|
+
note.className = 'skipped'
|
|
579
|
+
note.textContent = `${saved.skipped} earlier messages not shown`
|
|
580
|
+
thread.entries.appendChild(note)
|
|
581
|
+
}
|
|
582
|
+
saved.entries.forEach((entry) => showTutorEntry(thread, entry))
|
|
583
|
+
threadFor(saved.id, saved.name)
|
|
584
|
+
}
|
|
585
|
+
emptyNote()
|
|
586
|
+
break
|
|
587
|
+
case 'entry':
|
|
588
|
+
addTutorEntry(message.id, message.name, message.entry)
|
|
589
|
+
break
|
|
590
|
+
case 'failed': {
|
|
591
|
+
// the reason is for the teacher; the student was told it failed
|
|
592
|
+
const thread = threadFor(message.id, message.name)
|
|
593
|
+
const div = document.createElement('div')
|
|
594
|
+
div.className = 'msg failed'
|
|
595
|
+
div.textContent = 'No answer: ' + message.error
|
|
596
|
+
thread.entries.appendChild(div)
|
|
597
|
+
break
|
|
598
|
+
}
|
|
599
|
+
case 'on':
|
|
600
|
+
showSwitch(message.on)
|
|
601
|
+
break
|
|
602
|
+
case 'limit':
|
|
603
|
+
tutorNote.textContent = `The class reached its limit of ${message.perHour} questions this hour: more get no answer for now. Start edrys-dev with --tutor-per-hour to change it.`
|
|
604
|
+
if (!panel.classList.contains('open')) toggle.classList.add('unread')
|
|
605
|
+
break
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// --- the link for students: the class's share link would only work on this computer ---
|
|
611
|
+
const shareBox = root.querySelector('.share')
|
|
612
|
+
const shareRow = shareBox.querySelector('.row')
|
|
613
|
+
const shareInput = shareBox.querySelector('input')
|
|
614
|
+
const shareButton = shareBox.querySelector('button')
|
|
615
|
+
const shareNote = shareBox.querySelector('.note')
|
|
616
|
+
|
|
617
|
+
function showLink({ reachable, link, local_urls }) {
|
|
618
|
+
shareBox.hidden = false
|
|
619
|
+
shareRow.hidden = !link
|
|
620
|
+
shareInput.value = link || ''
|
|
621
|
+
const modules = local_urls.length
|
|
622
|
+
shareNote.textContent = !reachable
|
|
623
|
+
? 'Students on other computers cannot join: edrys-dev runs with --local, which reaches this computer only.'
|
|
624
|
+
: !link
|
|
625
|
+
? 'The link for students appears once the class is open.'
|
|
626
|
+
: modules
|
|
627
|
+
? `${modules === 1 ? 'A module loads' : `${modules} modules load`} from this computer (localhost) and will not load for students: publish ${modules === 1 ? 'it' : 'them'} first.`
|
|
628
|
+
: ''
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
shareInput.addEventListener('focus', () => shareInput.select())
|
|
632
|
+
shareButton.addEventListener('click', async () => {
|
|
633
|
+
let copied = false
|
|
634
|
+
try {
|
|
635
|
+
await navigator.clipboard.writeText(shareInput.value)
|
|
636
|
+
copied = true
|
|
637
|
+
} catch {
|
|
638
|
+
shareInput.select()
|
|
639
|
+
try {
|
|
640
|
+
copied = document.execCommand('copy')
|
|
641
|
+
} catch {}
|
|
642
|
+
}
|
|
643
|
+
shareButton.textContent = copied ? 'Copied' : 'Select and copy it'
|
|
644
|
+
setTimeout(() => (shareButton.textContent = 'Copy'), 2000)
|
|
645
|
+
})
|
|
646
|
+
|
|
647
|
+
new EventSource('/edrys-dev/link/events').onmessage = (event) => showLink(JSON.parse(event.data))
|
|
648
|
+
|
|
379
649
|
// the renderer first, then the messages that need it
|
|
380
650
|
let loaded = 0
|
|
381
651
|
for (const name of ['marked', 'purify']) {
|
|
382
652
|
const script = document.createElement('script')
|
|
383
653
|
script.src = `/edrys-dev/${name}.js`
|
|
384
|
-
script.onload = () =>
|
|
654
|
+
script.onload = () => {
|
|
655
|
+
if (++loaded < 2) return
|
|
656
|
+
if (hasChat) connect()
|
|
657
|
+
if (hasTutor) connectTutor()
|
|
658
|
+
}
|
|
385
659
|
document.head.appendChild(script)
|
|
386
660
|
}
|
|
387
661
|
})()
|