@edryslabs/edrys-dev 0.0.0-stage → 0.1.1
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/LICENSE +27 -0
- package/README.md +84 -2
- package/dist/chat.js +136 -0
- package/dist/index.js +86 -0
- package/dist/mcp.js +587 -0
- package/dist/modules.js +130 -0
- package/dist/origin.js +5 -0
- package/dist/server.js +260 -0
- package/dist/sync.js +15 -0
- package/dist/watch.js +44 -0
- package/package.json +58 -4
- package/views/panel.js +277 -0
package/views/panel.js
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
// The agent chat over the class tab, injected by edrys-dev into edrys-Lite's
|
|
2
|
+
// page. It lives in a shadow root on <html>: edrys-Lite mounts its app into
|
|
3
|
+
// <body> again on every route, which would wipe anything put there.
|
|
4
|
+
;(function () {
|
|
5
|
+
// only the tab opened with ?dev (DevBridge keeps that in sessionStorage);
|
|
6
|
+
// student and station tabs only load again when a local module changed
|
|
7
|
+
if (location.search !== '?dev' && !sessionStorage.getItem('edrys-dev')) {
|
|
8
|
+
if (/^\?\/(classroom|station)\//.test(location.search)) {
|
|
9
|
+
new EventSource('/edrys-dev/reload').addEventListener('reload-module', () => location.reload())
|
|
10
|
+
}
|
|
11
|
+
return
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const OPEN_KEY = 'edrys-dev:chat-open'
|
|
15
|
+
|
|
16
|
+
const css = `
|
|
17
|
+
:host { all: initial; }
|
|
18
|
+
* { box-sizing: border-box; font-family: system-ui, sans-serif; }
|
|
19
|
+
.wrap {
|
|
20
|
+
--bg: #fff; --fg: #1d1d1f; --muted: #6b6b70; --line: #d9d9de;
|
|
21
|
+
--ai: #f2f2f5; --me: #1f5fbf; --me-fg: #fff; --accent: #1f5fbf;
|
|
22
|
+
color: var(--fg);
|
|
23
|
+
}
|
|
24
|
+
@media (prefers-color-scheme: dark) {
|
|
25
|
+
.wrap {
|
|
26
|
+
--bg: #1e1e21; --fg: #ececf0; --muted: #a0a0a8; --line: #3a3a40;
|
|
27
|
+
--ai: #2a2a2f; --me: #3d7be0; --me-fg: #fff; --accent: #6ea2ff;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
.toggle {
|
|
31
|
+
position: fixed; right: 16px; bottom: 16px; z-index: 2147483000;
|
|
32
|
+
width: 48px; height: 48px; border-radius: 50%; border: 1px solid var(--line);
|
|
33
|
+
background: var(--bg); color: var(--fg); font-size: 22px; cursor: pointer;
|
|
34
|
+
box-shadow: 0 2px 8px rgba(0,0,0,.25);
|
|
35
|
+
}
|
|
36
|
+
.toggle .dot {
|
|
37
|
+
position: absolute; top: 4px; right: 4px; width: 10px; height: 10px;
|
|
38
|
+
border-radius: 50%; background: #e0452b; display: none;
|
|
39
|
+
}
|
|
40
|
+
.toggle.unread .dot { display: block; }
|
|
41
|
+
.panel {
|
|
42
|
+
position: fixed; right: 16px; bottom: 76px; z-index: 2147483000;
|
|
43
|
+
width: min(380px, calc(100vw - 32px)); height: min(520px, calc(100vh - 100px));
|
|
44
|
+
display: none; flex-direction: column; background: var(--bg);
|
|
45
|
+
border: 1px solid var(--line); border-radius: 12px; overflow: hidden;
|
|
46
|
+
box-shadow: 0 4px 20px rgba(0,0,0,.3);
|
|
47
|
+
}
|
|
48
|
+
.panel.open { display: flex; }
|
|
49
|
+
header {
|
|
50
|
+
padding: 10px 14px; border-bottom: 1px solid var(--line);
|
|
51
|
+
font-size: 14px; font-weight: 600; display: flex; justify-content: space-between;
|
|
52
|
+
}
|
|
53
|
+
header small { font-weight: 400; color: var(--muted); }
|
|
54
|
+
.log { flex: 1; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 8px; }
|
|
55
|
+
.msg { max-width: 88%; padding: 8px 11px; border-radius: 10px; font-size: 14px; line-height: 1.4;
|
|
56
|
+
overflow-wrap: anywhere; }
|
|
57
|
+
.msg.ai { background: var(--ai); align-self: flex-start; }
|
|
58
|
+
.msg.user, .msg.answer { background: var(--me); color: var(--me-fg); align-self: flex-end; white-space: pre-wrap; }
|
|
59
|
+
.msg p { margin: 0 0 6px; } .msg p:last-child { margin-bottom: 0; }
|
|
60
|
+
.msg pre { overflow-x: auto; background: rgba(127,127,127,.15); padding: 6px; border-radius: 6px; }
|
|
61
|
+
.msg code { font-family: ui-monospace, monospace; font-size: 13px; }
|
|
62
|
+
.msg a { color: var(--accent); }
|
|
63
|
+
.msg form { margin-top: 8px; display: flex; flex-direction: column; gap: 6px; }
|
|
64
|
+
fieldset { border: 0; padding: 0; margin: 0; display: flex; flex-wrap: wrap; gap: 6px; }
|
|
65
|
+
legend { font-size: 13px; color: var(--muted); margin-bottom: 4px; }
|
|
66
|
+
label.option { font-size: 14px; display: flex; gap: 6px; align-items: center; width: 100%; }
|
|
67
|
+
button.choice, button.submit {
|
|
68
|
+
font-size: 13px; padding: 5px 10px; border-radius: 8px; cursor: pointer;
|
|
69
|
+
border: 1px solid var(--accent); background: transparent; color: var(--accent);
|
|
70
|
+
}
|
|
71
|
+
.msg form[aria-disabled="true"] { opacity: .5; pointer-events: none; }
|
|
72
|
+
.status { padding: 0 12px 6px; font-size: 12px; color: var(--muted); min-height: 1em; }
|
|
73
|
+
.compose { display: flex; gap: 6px; padding: 8px; border-top: 1px solid var(--line); }
|
|
74
|
+
textarea {
|
|
75
|
+
flex: 1; resize: none; height: 40px; padding: 8px; font-size: 14px; border-radius: 8px;
|
|
76
|
+
border: 1px solid var(--line); background: var(--bg); color: var(--fg);
|
|
77
|
+
}
|
|
78
|
+
.compose button {
|
|
79
|
+
padding: 0 14px; border-radius: 8px; border: 0; background: var(--accent); color: #fff; cursor: pointer;
|
|
80
|
+
}
|
|
81
|
+
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
82
|
+
`
|
|
83
|
+
|
|
84
|
+
const host = document.createElement('edrys-dev-chat')
|
|
85
|
+
const root = host.attachShadow({ mode: 'open' })
|
|
86
|
+
root.innerHTML = `
|
|
87
|
+
<style>${css}</style>
|
|
88
|
+
<div class="wrap">
|
|
89
|
+
<button class="toggle" type="button" aria-expanded="false" aria-controls="panel" aria-label="Agent chat">
|
|
90
|
+
🤖<span class="dot"></span>
|
|
91
|
+
</button>
|
|
92
|
+
<section class="panel" id="panel" aria-label="Agent chat">
|
|
93
|
+
<header>Agent <small>edrys-dev, only you see this</small></header>
|
|
94
|
+
<div class="log" role="log" aria-live="polite"></div>
|
|
95
|
+
<div class="status" aria-live="polite"></div>
|
|
96
|
+
<form class="compose">
|
|
97
|
+
<label for="text" hidden>Message to the agent</label>
|
|
98
|
+
<textarea id="text" placeholder="Message to the agent (Enter sends)"></textarea>
|
|
99
|
+
<button type="submit">Send</button>
|
|
100
|
+
</form>
|
|
101
|
+
</section>
|
|
102
|
+
</div>`
|
|
103
|
+
document.documentElement.appendChild(host)
|
|
104
|
+
|
|
105
|
+
const toggle = root.querySelector('.toggle')
|
|
106
|
+
const panel = root.querySelector('.panel')
|
|
107
|
+
const log = root.querySelector('.log')
|
|
108
|
+
const statusLine = root.querySelector('.status')
|
|
109
|
+
const compose = root.querySelector('.compose')
|
|
110
|
+
const textarea = root.querySelector('textarea')
|
|
111
|
+
|
|
112
|
+
let open = null // the ask id of the form the user can still answer
|
|
113
|
+
|
|
114
|
+
function setOpen(isOpen) {
|
|
115
|
+
panel.classList.toggle('open', isOpen)
|
|
116
|
+
toggle.setAttribute('aria-expanded', String(isOpen))
|
|
117
|
+
if (isOpen) toggle.classList.remove('unread')
|
|
118
|
+
try {
|
|
119
|
+
sessionStorage.setItem(OPEN_KEY, isOpen ? '1' : '')
|
|
120
|
+
} catch {}
|
|
121
|
+
}
|
|
122
|
+
toggle.addEventListener('click', () => setOpen(!panel.classList.contains('open')))
|
|
123
|
+
setOpen(sessionStorage.getItem(OPEN_KEY) === '1')
|
|
124
|
+
|
|
125
|
+
function post(body) {
|
|
126
|
+
return fetch('/edrys-dev/chat', {
|
|
127
|
+
method: 'POST',
|
|
128
|
+
headers: { 'Content-Type': 'application/json' },
|
|
129
|
+
body: JSON.stringify(body),
|
|
130
|
+
})
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
compose.addEventListener('submit', (event) => {
|
|
134
|
+
event.preventDefault()
|
|
135
|
+
const text = textarea.value.trim()
|
|
136
|
+
if (!text) return
|
|
137
|
+
textarea.value = ''
|
|
138
|
+
post({ text })
|
|
139
|
+
})
|
|
140
|
+
textarea.addEventListener('keydown', (event) => {
|
|
141
|
+
if (event.key === 'Enter' && !event.shiftKey) {
|
|
142
|
+
event.preventDefault()
|
|
143
|
+
compose.requestSubmit()
|
|
144
|
+
}
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
// the agent writes Markdown; nothing it relays may run in this page
|
|
148
|
+
function render(markdown) {
|
|
149
|
+
const html = window.DOMPurify.sanitize(window.marked.parse(markdown))
|
|
150
|
+
const div = document.createElement('div')
|
|
151
|
+
div.innerHTML = html
|
|
152
|
+
div.querySelectorAll('a').forEach((a) => {
|
|
153
|
+
a.target = '_blank'
|
|
154
|
+
a.rel = 'noopener noreferrer'
|
|
155
|
+
})
|
|
156
|
+
return div
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function formFor(entry) {
|
|
160
|
+
const form = document.createElement('form')
|
|
161
|
+
form.dataset.ask = entry.ask
|
|
162
|
+
const values = {}
|
|
163
|
+
const single = entry.inputs.length === 1 && entry.inputs[0].type === 'buttons'
|
|
164
|
+
|
|
165
|
+
for (const input of entry.inputs) {
|
|
166
|
+
const set = document.createElement('fieldset')
|
|
167
|
+
if (input.label) {
|
|
168
|
+
const legend = document.createElement('legend')
|
|
169
|
+
legend.textContent = input.label
|
|
170
|
+
set.appendChild(legend)
|
|
171
|
+
}
|
|
172
|
+
for (const option of input.options) {
|
|
173
|
+
if (input.type === 'buttons') {
|
|
174
|
+
const button = document.createElement('button')
|
|
175
|
+
button.type = single ? 'submit' : 'button'
|
|
176
|
+
button.className = 'choice'
|
|
177
|
+
button.textContent = option
|
|
178
|
+
button.addEventListener('click', () => {
|
|
179
|
+
values[input.id] = option
|
|
180
|
+
set.querySelectorAll('button').forEach((b) => b.setAttribute('aria-pressed', String(b === button)))
|
|
181
|
+
})
|
|
182
|
+
set.appendChild(button)
|
|
183
|
+
} else {
|
|
184
|
+
const label = document.createElement('label')
|
|
185
|
+
label.className = 'option'
|
|
186
|
+
const box = document.createElement('input')
|
|
187
|
+
box.type = input.type
|
|
188
|
+
box.name = entry.ask + '-' + input.id
|
|
189
|
+
box.value = option
|
|
190
|
+
label.append(box, option)
|
|
191
|
+
set.appendChild(label)
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
form.appendChild(set)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (!single) {
|
|
198
|
+
const submit = document.createElement('button')
|
|
199
|
+
submit.type = 'submit'
|
|
200
|
+
submit.className = 'submit'
|
|
201
|
+
submit.textContent = 'Send'
|
|
202
|
+
form.appendChild(submit)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
form.addEventListener('submit', async (event) => {
|
|
206
|
+
event.preventDefault()
|
|
207
|
+
for (const input of entry.inputs) {
|
|
208
|
+
if (input.type === 'buttons') continue
|
|
209
|
+
const checked = [...form.querySelectorAll(`input[name="${entry.ask}-${input.id}"]:checked`)].map((b) => b.value)
|
|
210
|
+
values[input.id] = input.type === 'radio' ? checked[0] ?? '' : checked
|
|
211
|
+
}
|
|
212
|
+
const response = await post({ ask: entry.ask, values })
|
|
213
|
+
if (!response.ok) disable(entry.ask)
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
if (entry.ask !== open) form.setAttribute('aria-disabled', 'true')
|
|
217
|
+
return form
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function disable(ask) {
|
|
221
|
+
root.querySelectorAll(`form[data-ask="${ask}"]`).forEach((form) => form.setAttribute('aria-disabled', 'true'))
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function show(entry) {
|
|
225
|
+
const div = document.createElement('div')
|
|
226
|
+
div.className = 'msg ' + entry.type
|
|
227
|
+
if (entry.type === 'ai') {
|
|
228
|
+
div.appendChild(render(entry.markdown))
|
|
229
|
+
if (entry.ask) div.appendChild(formFor(entry))
|
|
230
|
+
} else if (entry.type === 'user') {
|
|
231
|
+
div.textContent = entry.text
|
|
232
|
+
} else if (entry.type === 'answer') {
|
|
233
|
+
disable(entry.ask)
|
|
234
|
+
div.textContent = Object.values(entry.values).flat().join(', ')
|
|
235
|
+
}
|
|
236
|
+
log.appendChild(div)
|
|
237
|
+
log.scrollTop = log.scrollHeight
|
|
238
|
+
if (!panel.classList.contains('open') && entry.type === 'ai') toggle.classList.add('unread')
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function connect() {
|
|
242
|
+
const events = new EventSource('/edrys-dev/chat/events')
|
|
243
|
+
events.onmessage = (event) => {
|
|
244
|
+
const message = JSON.parse(event.data)
|
|
245
|
+
switch (message.type) {
|
|
246
|
+
case 'history':
|
|
247
|
+
log.replaceChildren()
|
|
248
|
+
open = message.open
|
|
249
|
+
message.entries.forEach(show)
|
|
250
|
+
statusLine.textContent = message.status
|
|
251
|
+
toggle.classList.remove('unread')
|
|
252
|
+
break
|
|
253
|
+
case 'entry':
|
|
254
|
+
if (message.entry.type === 'ai' && message.entry.ask) open = message.entry.ask
|
|
255
|
+
if (message.entry.type === 'answer' && open === message.entry.ask) open = null
|
|
256
|
+
show(message.entry)
|
|
257
|
+
break
|
|
258
|
+
case 'status':
|
|
259
|
+
statusLine.textContent = message.text
|
|
260
|
+
break
|
|
261
|
+
case 'close':
|
|
262
|
+
if (open === message.ask) open = null
|
|
263
|
+
disable(message.ask)
|
|
264
|
+
break
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// the renderer first, then the messages that need it
|
|
270
|
+
let loaded = 0
|
|
271
|
+
for (const name of ['marked', 'purify']) {
|
|
272
|
+
const script = document.createElement('script')
|
|
273
|
+
script.src = `/edrys-dev/${name}.js`
|
|
274
|
+
script.onload = () => ++loaded === 2 && connect()
|
|
275
|
+
document.head.appendChild(script)
|
|
276
|
+
}
|
|
277
|
+
})()
|