@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/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
+ })()