@ivanbard/dd2l 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 +21 -0
- package/README.md +122 -3
- package/THIRD_PARTY_NOTICES.md +50 -0
- package/bin/dd2l.mjs +7 -0
- package/docs/README.md +227 -0
- package/package.json +56 -6
- package/src/app-runner.mjs +58 -0
- package/src/assignment-workspace.mjs +128 -0
- package/src/canvas.mjs +17 -0
- package/src/cli-banner.mjs +58 -0
- package/src/cli.mjs +142 -0
- package/src/client.mjs +150 -0
- package/src/connector.mjs +587 -0
- package/src/discovery.mjs +45 -0
- package/src/documents.mjs +154 -0
- package/src/links.mjs +99 -0
- package/src/llm.mjs +243 -0
- package/src/media.mjs +54 -0
- package/src/ocr.mjs +24 -0
- package/src/office.mjs +61 -0
- package/src/pdf-worker.mjs +26 -0
- package/src/research.mjs +70 -0
- package/src/session.mjs +142 -0
- package/src/store.mjs +108 -0
- package/src/syllabus.mjs +191 -0
- package/src/task-workspace.mjs +138 -0
- package/src/ui-data.mjs +123 -0
- package/src/ui-demo.mjs +36 -0
- package/src/ui-server.mjs +161 -0
- package/src/windows-ocr.ps1 +32 -0
- package/ui/answer-renderer.js +88 -0
- package/ui/answer-view.js +47 -0
- package/ui/app.js +385 -0
- package/ui/assets/LUCIDE-LICENSE.txt +15 -0
- package/ui/assets/avatar.svg +3 -0
- package/ui/assets/chevron.svg +5 -0
- package/ui/assets/courses.svg +14 -0
- package/ui/assets/logo.svg +13 -0
- package/ui/assets/new-chat.svg +14 -0
- package/ui/assets/search.svg +14 -0
- package/ui/assets/send.svg +8 -0
- package/ui/assets/settings.svg +14 -0
- package/ui/assets/widget-announcements.svg +4 -0
- package/ui/assets/widget-shortcuts.svg +5 -0
- package/ui/assignment-workspace.js +106 -0
- package/ui/canvas-contract.js +41 -0
- package/ui/canvas-runtime.js +158 -0
- package/ui/canvas.js +96 -0
- package/ui/index.html +84 -0
- package/ui/loading.js +33 -0
- package/ui/style.css +241 -0
- package/ui/widget-layout.js +39 -0
- package/ui/widgets.js +252 -0
package/ui/app.js
ADDED
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
import { createWidgets } from './widgets.js';
|
|
2
|
+
import { renderAnswerView } from './answer-renderer.js';
|
|
3
|
+
import { startLoading } from './loading.js';
|
|
4
|
+
import { renderAssignmentWorkspace } from './assignment-workspace.js';
|
|
5
|
+
import { createCanvas } from './canvas.js';
|
|
6
|
+
|
|
7
|
+
const $ = id => document.getElementById(id);
|
|
8
|
+
const canvas = createCanvas($('canvas-host'), notice);
|
|
9
|
+
let canvasSources = [];
|
|
10
|
+
const refreshWidgets = createWidgets(async (course, title) => {
|
|
11
|
+
try { await load(); } catch (error) { notice(error.message); return; }
|
|
12
|
+
showAssignment(null);
|
|
13
|
+
$('course').value = course;
|
|
14
|
+
$('prompt').value = title;
|
|
15
|
+
search(!title);
|
|
16
|
+
});
|
|
17
|
+
let status;
|
|
18
|
+
let courses = [];
|
|
19
|
+
let request;
|
|
20
|
+
let aiRequest;
|
|
21
|
+
let assignmentContext;
|
|
22
|
+
let taskContext;
|
|
23
|
+
let defaultModel = '';
|
|
24
|
+
let finishPromptLoading = () => {};
|
|
25
|
+
|
|
26
|
+
function element(tag, text, className) {
|
|
27
|
+
const node = document.createElement(tag);
|
|
28
|
+
if (text !== undefined) node.textContent = text;
|
|
29
|
+
if (className) node.className = className;
|
|
30
|
+
return node;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function api(path, signal) {
|
|
34
|
+
const response = await fetch(path, { signal });
|
|
35
|
+
const data = await response.json();
|
|
36
|
+
if (!response.ok) throw new Error(data.error || 'Could not read the local inventory.');
|
|
37
|
+
return data;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function aiAction(action, body = {}, signal) {
|
|
41
|
+
const response = await fetch(`/api/llm/${action}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), signal });
|
|
42
|
+
const data = await response.json();
|
|
43
|
+
if (!response.ok) throw new Error(data.error || 'AI request failed.');
|
|
44
|
+
return data;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function loadAI() {
|
|
48
|
+
const finish = startLoading($('model-loading'), 'Loading models');
|
|
49
|
+
try {
|
|
50
|
+
clearAnswer();
|
|
51
|
+
$('model').replaceChildren(new Option('Local search', ''));
|
|
52
|
+
defaultModel = '';
|
|
53
|
+
updateComposer();
|
|
54
|
+
const connection = await api('/api/llm/status');
|
|
55
|
+
if (connection.active) {
|
|
56
|
+
const { models } = await api('/api/llm/models');
|
|
57
|
+
const group = element('optgroup');
|
|
58
|
+
group.label = 'ChatGPT';
|
|
59
|
+
group.append(...models.map(m => new Option(m.name, m.id)));
|
|
60
|
+
$('model').append(group);
|
|
61
|
+
defaultModel = models[0]?.id || '';
|
|
62
|
+
$('model').value = defaultModel;
|
|
63
|
+
}
|
|
64
|
+
updateComposer();
|
|
65
|
+
} finally { finish(); }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function updateComposer() {
|
|
69
|
+
const model = $('model').value;
|
|
70
|
+
$('model-sharing').hidden = !model;
|
|
71
|
+
$('adaptive-help').hidden = !!model;
|
|
72
|
+
$('model').setAttribute('aria-describedby', model ? 'model-sharing' : 'adaptive-help');
|
|
73
|
+
$('send').disabled = !$('prompt').value.trim() || !$('busy').hidden;
|
|
74
|
+
$('send').setAttribute('aria-label', model ? `Ask ${$('model').selectedOptions[0].textContent}` : 'Search locally');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function setBusy(busy, label = 'Searching…') {
|
|
78
|
+
finishPromptLoading();
|
|
79
|
+
$('busy').hidden = !busy;
|
|
80
|
+
if (busy) finishPromptLoading = startLoading($('busy'), label);
|
|
81
|
+
if (busy) $('search-form').setAttribute('aria-busy', 'true');
|
|
82
|
+
else $('search-form').removeAttribute('aria-busy');
|
|
83
|
+
updateComposer();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function clearAnswer() {
|
|
87
|
+
aiRequest?.abort();
|
|
88
|
+
request?.abort();
|
|
89
|
+
aiRequest = request = undefined;
|
|
90
|
+
setBusy(false);
|
|
91
|
+
$('ai-answer').hidden = true;
|
|
92
|
+
$('answer-text').textContent = '';
|
|
93
|
+
$('answer-view').replaceChildren();
|
|
94
|
+
$('answer-view').hidden = true;
|
|
95
|
+
$('ai-answer-title').textContent = 'Your answer';
|
|
96
|
+
$('answer-sources').replaceChildren();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function askAI() {
|
|
100
|
+
if (!$('prompt').value.trim()) { notice('Enter a coursework question first.'); return; }
|
|
101
|
+
if (!$('model').value) return search();
|
|
102
|
+
clearAnswer();
|
|
103
|
+
const current = aiRequest = new AbortController();
|
|
104
|
+
setBusy(true, 'Thinking…');
|
|
105
|
+
$('results').hidden = true;
|
|
106
|
+
notice();
|
|
107
|
+
try {
|
|
108
|
+
const previousCanvas = await canvas.snapshot();
|
|
109
|
+
if (aiRequest !== current) return;
|
|
110
|
+
const data = await aiAction('answer', { q: $('prompt').value.trim(), course: $('course').value,
|
|
111
|
+
context: taskContext || null, canvas: previousCanvas, model: $('model').value, consent: true }, current.signal);
|
|
112
|
+
if (aiRequest !== current) return;
|
|
113
|
+
$('workspace').classList.add('has-results');
|
|
114
|
+
if (data.workspace) {
|
|
115
|
+
showAssignment(data.workspace, status?.demo || false);
|
|
116
|
+
const stopped = data.workspace.research?.find(item => item.message);
|
|
117
|
+
if (stopped) notice(stopped.message);
|
|
118
|
+
if (data.workspace.kind === 'task') taskContext.summary = JSON.stringify({
|
|
119
|
+
question: data.workspace.question,
|
|
120
|
+
sections: data.workspace.sections.map(({ title, purpose }) => ({ title, purpose })),
|
|
121
|
+
answer: data.answer || '', view: data.view || null,
|
|
122
|
+
}).slice(0, 12_000);
|
|
123
|
+
if (data.workspace.question || data.answerWarning) {
|
|
124
|
+
if (data.answerWarning) notice(data.answerWarning);
|
|
125
|
+
$('assignment-heading').focus();
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (data.canvas) {
|
|
130
|
+
const latestCanvas = await canvas.snapshot();
|
|
131
|
+
if (aiRequest !== current) return;
|
|
132
|
+
canvasSources = data.sources;
|
|
133
|
+
canvas.render(data.canvas, data.sources, latestCanvas?.state || previousCanvas?.state || {});
|
|
134
|
+
$('canvas-title').textContent = data.canvas.title;
|
|
135
|
+
$('canvas-workspace').hidden = false;
|
|
136
|
+
$('assignment-workspace').hidden = true;
|
|
137
|
+
$('canvas-sources').setAttribute('aria-expanded', 'false');
|
|
138
|
+
$('canvas-title').focus();
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
$('answer-text').textContent = data.answer;
|
|
142
|
+
const rendered = renderAnswerView($('answer-view'), data.view, data.sources);
|
|
143
|
+
$('answer-view').hidden = !rendered || !data.view.sections.length;
|
|
144
|
+
$('ai-answer-title').textContent = rendered ? data.view.title || data.workspace?.title || 'Your answer' : data.workspace?.title || 'Your answer';
|
|
145
|
+
if (data.viewWarning) notice(data.viewWarning);
|
|
146
|
+
$('answer-sources').replaceChildren();
|
|
147
|
+
$('ai-answer').querySelector('.answer-sources-heading').hidden = data.workspace?.kind === 'task';
|
|
148
|
+
for (const [i, source] of data.sources.entries()) {
|
|
149
|
+
if (data.workspace?.kind === 'task') continue;
|
|
150
|
+
const article = element('article', undefined, 'source');
|
|
151
|
+
article.id = `answer-source-${i + 1}`;
|
|
152
|
+
article.tabIndex = -1;
|
|
153
|
+
article.append(element('h3', `[${i + 1}] ${source.title}`), element('p', source.excerpt),
|
|
154
|
+
element('p', `${source.courseName} · ${source.stale ? 'Stale cache' : 'Cached'} · ${date(source.lastSuccess)}`, 'source-meta'));
|
|
155
|
+
const link = element('a', source.url.includes('/dropbox/') ? 'Open assignment in Brightspace ↗' : 'Open course in Brightspace ↗');
|
|
156
|
+
link.href = source.url;
|
|
157
|
+
link.target = '_blank';
|
|
158
|
+
link.rel = 'noopener noreferrer';
|
|
159
|
+
article.append(link);
|
|
160
|
+
$('answer-sources').append(article);
|
|
161
|
+
}
|
|
162
|
+
$('ai-answer').hidden = false;
|
|
163
|
+
$('ai-answer').querySelector('h2').focus();
|
|
164
|
+
} catch (error) { if (error.name !== 'AbortError' && aiRequest === current) notice(error.message); }
|
|
165
|
+
finally {
|
|
166
|
+
if (aiRequest === current) {
|
|
167
|
+
setBusy(false);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function notice(text = '') {
|
|
173
|
+
$('notice').textContent = text;
|
|
174
|
+
$('notice').hidden = !text;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function date(value) {
|
|
178
|
+
const stamp = new Date(value);
|
|
179
|
+
return value && !Number.isNaN(stamp.valueOf()) ? stamp.toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' }) : 'Unknown';
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function load() {
|
|
183
|
+
const finish = startLoading($('course-loading'), 'Loading courses');
|
|
184
|
+
try {
|
|
185
|
+
[status, { courses }] = await Promise.all([api('/api/status'), api('/api/courses')]);
|
|
186
|
+
const selected = $('course').value;
|
|
187
|
+
$('course').replaceChildren(new Option('All courses', ''), ...courses.map(course => new Option(course.name, course.id)));
|
|
188
|
+
if (courses.some(course => course.id === selected)) $('course').value = selected;
|
|
189
|
+
$('demo-badge').hidden = !status.demo;
|
|
190
|
+
if (!courses.length) notice('No cached courses yet. If setup or start is running, collection is in progress. Otherwise run npx @ivanbard/dd2l setup (npm run setup from source).');
|
|
191
|
+
} finally { finish(); }
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function reset() {
|
|
195
|
+
clearAnswer();
|
|
196
|
+
showAssignment(null);
|
|
197
|
+
$('prompt').value = '';
|
|
198
|
+
$('model').value = defaultModel;
|
|
199
|
+
updateComposer();
|
|
200
|
+
$('results').hidden = true;
|
|
201
|
+
$('result-list').replaceChildren();
|
|
202
|
+
$('workspace').classList.remove('has-results');
|
|
203
|
+
$('prompt').focus();
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function searchParams(query) {
|
|
207
|
+
return new URLSearchParams({ q: query, course: $('course').value, assignment: assignmentContext?.course === $('course').value ? assignmentContext.id : '' });
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function showAssignment(workspace, demo = false) {
|
|
211
|
+
if (!workspace || workspace.kind !== 'task') {
|
|
212
|
+
canvas.clear(); canvasSources = [];
|
|
213
|
+
$('canvas-workspace').hidden = true;
|
|
214
|
+
}
|
|
215
|
+
taskContext = workspace?.kind === 'task' ? workspace.context : workspace?.status === 'resolved'
|
|
216
|
+
? { goal: `Work on ${workspace.title} in ${workspace.courseName}`, sourceIds: [] } : undefined;
|
|
217
|
+
assignmentContext = workspace?.status === 'resolved' ? { id: workspace.id, course: workspace.course } : undefined;
|
|
218
|
+
if (assignmentContext) $('course').value = assignmentContext.course;
|
|
219
|
+
renderAssignmentWorkspace($('assignment-workspace'), workspace, demo, choice => {
|
|
220
|
+
assignmentContext = { id: choice.id, course: choice.course };
|
|
221
|
+
$('course').value = choice.course;
|
|
222
|
+
if ($('model').value) askAI(); else search();
|
|
223
|
+
});
|
|
224
|
+
if (workspace) $('workspace').classList.add('has-results');
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async function search(browse = false) {
|
|
228
|
+
clearAnswer();
|
|
229
|
+
const query = $('prompt').value.trim();
|
|
230
|
+
if (!browse && !query) return;
|
|
231
|
+
request?.abort();
|
|
232
|
+
const current = request = new AbortController();
|
|
233
|
+
setBusy(true);
|
|
234
|
+
notice();
|
|
235
|
+
try {
|
|
236
|
+
const data = await api(`/api/search?${searchParams(browse ? '' : query)}`, current.signal);
|
|
237
|
+
if (request !== current) return;
|
|
238
|
+
showAssignment(data.workspace, data.demo);
|
|
239
|
+
if (data.workspace) {
|
|
240
|
+
$('results').hidden = true;
|
|
241
|
+
$('assignment-heading').focus();
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
$('workspace').classList.add('has-results');
|
|
245
|
+
$('results').hidden = false;
|
|
246
|
+
$('results-title').textContent = browse ? 'Course materials' : 'Search results';
|
|
247
|
+
$('result-count').textContent = `${Math.min(30, data.total)} of ${data.total} sources`;
|
|
248
|
+
$('result-list').replaceChildren();
|
|
249
|
+
for (const source of data.results) {
|
|
250
|
+
const article = element('article', undefined, 'source');
|
|
251
|
+
article.append(element('div', `${source.courseName} · ${source.category.replaceAll('_', ' ')}`, 'source-meta'),
|
|
252
|
+
element('h3', source.title), element('p', source.excerpt));
|
|
253
|
+
const meta = element('div', `${source.stale ? 'Stale cache · ' : 'Cached · '}${date(source.lastSuccess)}`, `source-meta${source.stale ? ' stale' : ''}`);
|
|
254
|
+
article.append(meta);
|
|
255
|
+
if (!data.demo) {
|
|
256
|
+
const link = element('a', 'Open course in onQ ↗');
|
|
257
|
+
link.href = source.url;
|
|
258
|
+
link.target = '_blank';
|
|
259
|
+
link.rel = 'noopener noreferrer';
|
|
260
|
+
article.append(link);
|
|
261
|
+
}
|
|
262
|
+
$('result-list').append(article);
|
|
263
|
+
}
|
|
264
|
+
if (!data.total) $('result-list').append(element('p', 'No matching cached sources. Try a course name, topic, or assignment keyword. Only material already retrieved by the connector can appear here.', 'empty'));
|
|
265
|
+
$('results-title').focus();
|
|
266
|
+
} catch (error) {
|
|
267
|
+
if (error.name !== 'AbortError' && request === current) {
|
|
268
|
+
$('results').hidden = true;
|
|
269
|
+
notice(error.message);
|
|
270
|
+
}
|
|
271
|
+
} finally {
|
|
272
|
+
if (request === current) {
|
|
273
|
+
setBusy(false);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function openPanel(title) {
|
|
279
|
+
$('panel-title').textContent = title;
|
|
280
|
+
$('panel-body').replaceChildren();
|
|
281
|
+
if (!$('panel').open) $('panel').showModal();
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async function showCourses() {
|
|
285
|
+
openPanel('Courses');
|
|
286
|
+
const finish = startLoading($('panel-loading'), 'Loading cached courses');
|
|
287
|
+
try {
|
|
288
|
+
await load();
|
|
289
|
+
$('panel-body').replaceChildren(element('p', 'Browse materials already collected from onQ.'));
|
|
290
|
+
for (const course of courses) {
|
|
291
|
+
const button = element('button', course.name, 'course-item');
|
|
292
|
+
button.type = 'button';
|
|
293
|
+
button.append(element('small', course.active ? 'Active course' : 'Historical course'));
|
|
294
|
+
button.addEventListener('click', () => {
|
|
295
|
+
showAssignment(null);
|
|
296
|
+
$('course').value = course.id;
|
|
297
|
+
$('prompt').value = '';
|
|
298
|
+
$('panel').close();
|
|
299
|
+
search(true);
|
|
300
|
+
});
|
|
301
|
+
$('panel-body').append(button);
|
|
302
|
+
}
|
|
303
|
+
if (!courses.length) $('panel-body').append(element('p', 'No cached courses yet. Let setup finish collecting, then reopen Courses. To connect onQ, run npx @ivanbard/dd2l setup (npm run setup from source).'));
|
|
304
|
+
} catch (error) { $('panel-body').replaceChildren(element('p', error.message)); }
|
|
305
|
+
finally { finish(); }
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async function showStatus() {
|
|
309
|
+
openPanel('Connection status');
|
|
310
|
+
const finish = startLoading($('panel-loading'), 'Loading connection status');
|
|
311
|
+
try {
|
|
312
|
+
await load();
|
|
313
|
+
$('panel-body').replaceChildren(
|
|
314
|
+
element('p', status.demo ? 'Demo mode · fictional sample coursework. No school account is connected to this view.' : 'Local inventory · read-only. Local search stays on this device. Sending with a ChatGPT model shares your request, course previews, selected materials and workspace progress with OpenAI.'),
|
|
315
|
+
element('p', `${status.courseCount} courses · ${status.sourceCount} searchable sources`),
|
|
316
|
+
element('p', `Connector: ${status.state?.state || 'Not synced'} · Last successful source: ${date(status.lastSuccess)}`),
|
|
317
|
+
element('p', 'Select a ChatGPT model to build adaptive workspaces around your goals. Local mode uses keyword matching and assignment records. Missing sources and access restrictions mean coverage may be incomplete.'),
|
|
318
|
+
element('p', 'Setup and start collect coursework automatically while the terminal stays open.'),
|
|
319
|
+
element('p', 'If sign-in has expired, stop the app and run npx @ivanbard/dd2l setup (npm run setup from source). For later launches use npx @ivanbard/dd2l start (npm start -- start from source). Reopen Courses or search again to read the latest cache.'),
|
|
320
|
+
);
|
|
321
|
+
const connection = await api('/api/llm/status');
|
|
322
|
+
$('panel-body').append(element('h3', 'Use your ChatGPT plan'));
|
|
323
|
+
if (connection.disabled) { $('panel-body').append(element('p', 'Provider sign-in is disabled in demo mode. Start the live UI to connect ChatGPT.')); return; }
|
|
324
|
+
$('panel-body').append(element('p', 'Eligible workspace requests use your ChatGPT plan and its usage limits. Sending shares your request, course previews, selected materials and local workspace progress with OpenAI.'));
|
|
325
|
+
const action = (label, handler) => {
|
|
326
|
+
const button = element('button', label, 'account-action');
|
|
327
|
+
button.type = 'button';
|
|
328
|
+
button.addEventListener('click', async () => {
|
|
329
|
+
button.disabled = true;
|
|
330
|
+
const finishAction = startLoading(button, `${label} in progress`);
|
|
331
|
+
try { await handler(); } catch (error) { $('panel-body').append(element('p', error.message)); }
|
|
332
|
+
finally { finishAction(); button.disabled = false; }
|
|
333
|
+
});
|
|
334
|
+
$('panel-body').append(button);
|
|
335
|
+
};
|
|
336
|
+
for (const account of connection.accounts) {
|
|
337
|
+
$('panel-body').append(element('p', `${account.label}${account.id === connection.active ? ' · Active' : ''}`));
|
|
338
|
+
action(account.connected ? 'Use this account' : 'Continue with ChatGPT', async () => {
|
|
339
|
+
if (account.connected) { clearAnswer(); await aiAction('select', { id: account.id }); await loadAI(); await showStatus(); }
|
|
340
|
+
else location.assign((await aiAction('login', { id: account.id })).url);
|
|
341
|
+
});
|
|
342
|
+
if (account.connected) action('Sign in again to this account', async () => location.assign((await aiAction('login', { id: account.id })).url));
|
|
343
|
+
}
|
|
344
|
+
action('Continue with ChatGPT', async () => location.assign((await aiAction('login')).url));
|
|
345
|
+
if (connection.active) action('Sign out of active ChatGPT account', async () => {
|
|
346
|
+
clearAnswer();
|
|
347
|
+
const result = await aiAction('logout');
|
|
348
|
+
await loadAI();
|
|
349
|
+
await showStatus();
|
|
350
|
+
$('panel-body').append(element('p', result.message));
|
|
351
|
+
});
|
|
352
|
+
const usage = element('a', 'Manage usage in ChatGPT');
|
|
353
|
+
usage.href = 'https://chatgpt.com/settings/usage'; usage.target = '_blank'; usage.rel = 'noopener noreferrer';
|
|
354
|
+
$('panel-body').append(usage);
|
|
355
|
+
} catch (error) { $('panel-body').replaceChildren(element('p', error.message)); }
|
|
356
|
+
finally { finish(); }
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
$('prompt').addEventListener('input', clearAnswer);
|
|
360
|
+
$('model').addEventListener('change', () => { clearAnswer(); notice(); });
|
|
361
|
+
$('search-form').addEventListener('submit', event => { event.preventDefault(); if ($('model').value) askAI(); else search(); });
|
|
362
|
+
$('course').addEventListener('change', () => { const browsing = !$('results').hidden || !$('assignment-workspace').hidden || !$('busy').hidden; clearAnswer(); showAssignment(null); if (browsing && !$('model').value) search(!$('prompt').value.trim()); });
|
|
363
|
+
$('new-chat').addEventListener('click', reset);
|
|
364
|
+
$('canvas-sources').addEventListener('click', () => {
|
|
365
|
+
const show = $('assignment-workspace').hidden;
|
|
366
|
+
$('assignment-workspace').hidden = !show;
|
|
367
|
+
$('canvas-sources').setAttribute('aria-expanded', String(show));
|
|
368
|
+
if (show) $('assignment-heading')?.focus();
|
|
369
|
+
});
|
|
370
|
+
$('canvas-restart').addEventListener('click', async () => {
|
|
371
|
+
try { const current = await canvas.snapshot(); if (current) canvas.render(current.program, canvasSources, {}); }
|
|
372
|
+
catch (error) { notice(error.message); }
|
|
373
|
+
});
|
|
374
|
+
$('search-nav').addEventListener('click', () => $('prompt').focus());
|
|
375
|
+
$('courses-nav').addEventListener('click', showCourses);
|
|
376
|
+
$('settings-nav').addEventListener('click', showStatus);
|
|
377
|
+
$('profile').addEventListener('click', showStatus);
|
|
378
|
+
$('refresh-widgets').addEventListener('click', refreshWidgets);
|
|
379
|
+
$('close-panel').addEventListener('click', () => $('panel').close());
|
|
380
|
+
$('panel').addEventListener('click', event => { if (event.target === $('panel')) {
|
|
381
|
+
const rect = $('panel').getBoundingClientRect();
|
|
382
|
+
if (event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom) $('panel').close();
|
|
383
|
+
} });
|
|
384
|
+
load().then(refreshWidgets).catch(error => notice(error.message));
|
|
385
|
+
loadAI().catch(error => notice(error.message));
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
ISC License
|
|
2
|
+
|
|
3
|
+
Copyright (c) for portions of Lucide are held by Cole Bemis 2013-2022 as part of Feather (MIT). All other copyright (c) for Lucide are held by Lucide Contributors 2022.
|
|
4
|
+
|
|
5
|
+
Permission to use, copy, modify, and/or distribute this software for any
|
|
6
|
+
purpose with or without fee is hereby granted, provided that the above
|
|
7
|
+
copyright notice and this permission notice appear in all copies.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
|
10
|
+
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
|
11
|
+
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
|
12
|
+
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
|
13
|
+
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
|
14
|
+
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
|
15
|
+
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
2
|
+
<g id="Chevron down">
|
|
3
|
+
<path id="Vector" d="M3.5 5.25L7 8.75L10.5 5.25" stroke-width="1.16667" stroke-linecap="round" stroke-linejoin="round" stroke="#ABABAB"/>
|
|
4
|
+
</g>
|
|
5
|
+
</svg>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<svg
|
|
2
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
3
|
+
width="24"
|
|
4
|
+
height="24"
|
|
5
|
+
viewBox="0 0 24 24"
|
|
6
|
+
fill="none"
|
|
7
|
+
stroke="#bababa"
|
|
8
|
+
stroke-width="2"
|
|
9
|
+
stroke-linecap="round"
|
|
10
|
+
stroke-linejoin="round"
|
|
11
|
+
>
|
|
12
|
+
<path d="M12 7v14" />
|
|
13
|
+
<path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z" />
|
|
14
|
+
</svg>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="30" height="30" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
2
|
+
<g id="logo">
|
|
3
|
+
<circle id="Ellipse 6" cx="4.33333" cy="4.33333" r="4.33333" fill="white" fill-opacity="0.1"/>
|
|
4
|
+
<circle id="Ellipse 7" cx="15" cy="4.33333" r="4.33333" fill="white"/>
|
|
5
|
+
<circle id="Ellipse 8" cx="25.6667" cy="4.33333" r="4.33333" fill="white" fill-opacity="0.1"/>
|
|
6
|
+
<circle id="Ellipse 3" cx="4.33333" cy="15" r="4.33333" fill="white"/>
|
|
7
|
+
<circle id="Ellipse 4" cx="15" cy="15" r="4.33333" fill="white"/>
|
|
8
|
+
<circle id="Ellipse 5" cx="25.6667" cy="15" r="4.33333" fill="white"/>
|
|
9
|
+
<circle id="Ellipse 9" cx="4.33333" cy="25.6667" r="4.33333" fill="white" fill-opacity="0.1"/>
|
|
10
|
+
<circle id="Ellipse 10" cx="15" cy="25.6667" r="4.33333" fill="white"/>
|
|
11
|
+
<circle id="Ellipse 11" cx="25.6667" cy="25.6667" r="4.33333" fill="white" fill-opacity="0.1"/>
|
|
12
|
+
</g>
|
|
13
|
+
</svg>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<svg
|
|
2
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
3
|
+
width="24"
|
|
4
|
+
height="24"
|
|
5
|
+
viewBox="0 0 24 24"
|
|
6
|
+
fill="none"
|
|
7
|
+
stroke="#bababa"
|
|
8
|
+
stroke-width="2"
|
|
9
|
+
stroke-linecap="round"
|
|
10
|
+
stroke-linejoin="round"
|
|
11
|
+
>
|
|
12
|
+
<path d="M12 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
|
13
|
+
<path d="M18.375 2.625a1 1 0 0 1 3 3l-9.013 9.014a2 2 0 0 1-.853.505l-2.873.84a.5.5 0 0 1-.62-.62l.84-2.873a2 2 0 0 1 .506-.852z" />
|
|
14
|
+
</svg>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<svg
|
|
2
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
3
|
+
width="24"
|
|
4
|
+
height="24"
|
|
5
|
+
viewBox="0 0 24 24"
|
|
6
|
+
fill="none"
|
|
7
|
+
stroke="#bababa"
|
|
8
|
+
stroke-width="2"
|
|
9
|
+
stroke-linecap="round"
|
|
10
|
+
stroke-linejoin="round"
|
|
11
|
+
>
|
|
12
|
+
<circle cx="11" cy="11" r="8" />
|
|
13
|
+
<path d="m21 21-4.3-4.3" />
|
|
14
|
+
</svg>
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
2
|
+
<g id="Send-Greyed">
|
|
3
|
+
<circle id="Ellipse 1" cx="16" cy="16" r="16" fill="#525252"/>
|
|
4
|
+
<g id="Arrow up">
|
|
5
|
+
<path id="Icon" d="M16 21.6V10.4M21.6 16L16 10.4L10.4 16" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" stroke="#353535"/>
|
|
6
|
+
</g>
|
|
7
|
+
</g>
|
|
8
|
+
</svg>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<svg
|
|
2
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
3
|
+
width="24"
|
|
4
|
+
height="24"
|
|
5
|
+
viewBox="0 0 24 24"
|
|
6
|
+
fill="none"
|
|
7
|
+
stroke="#bababa"
|
|
8
|
+
stroke-width="2"
|
|
9
|
+
stroke-linecap="round"
|
|
10
|
+
stroke-linejoin="round"
|
|
11
|
+
>
|
|
12
|
+
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" />
|
|
13
|
+
<circle cx="12" cy="12" r="3" />
|
|
14
|
+
</svg>
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 320" fill="none">
|
|
2
|
+
<defs><pattern id="dots" width="4" height="4" patternUnits="userSpaceOnUse"><circle cx="1" cy="1" r=".9" fill="#c2c3ae"/></pattern></defs>
|
|
3
|
+
<g fill="url(#dots)"><circle cx="160" cy="72" r="47"/><circle cx="244" cy="133" r="47"/><circle cx="212" cy="231" r="47"/><circle cx="108" cy="231" r="47"/><circle cx="76" cy="133" r="47"/></g>
|
|
4
|
+
</svg>
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 320" fill="none">
|
|
2
|
+
<defs><pattern id="dots" width="4" height="4" patternUnits="userSpaceOnUse"><circle cx="1" cy="1" r=".9" fill="#c9c0ad"/></pattern></defs>
|
|
3
|
+
<path d="M40 99h81L39 181l35 35 82-82v81h49V50H40z" fill="url(#dots)"/>
|
|
4
|
+
<path d="M143 154h65l-66 66 28 28 66-66v65h39V115H143z" fill="url(#dots)" opacity=".4"/>
|
|
5
|
+
</svg>
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
const element = (tag, text, className) => {
|
|
2
|
+
const node = document.createElement(tag);
|
|
3
|
+
if (text !== undefined) node.textContent = text;
|
|
4
|
+
if (className) node.className = className;
|
|
5
|
+
return node;
|
|
6
|
+
};
|
|
7
|
+
const date = value => value ? new Date(value).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' }) : 'Not retrieved';
|
|
8
|
+
const labels = { missing: 'Not retrieved', inaccessible: 'Inaccessible', not_configured: 'Not configured', stale: 'Stale cache', available: 'Cached' };
|
|
9
|
+
|
|
10
|
+
function sourceLink(source, demo, label = 'Open assignment in Brightspace ↗') {
|
|
11
|
+
if (demo) return element('small', 'Demo · sample coursework', 'source-meta');
|
|
12
|
+
const link = element('a', label);
|
|
13
|
+
link.href = source.url;
|
|
14
|
+
link.target = '_blank';
|
|
15
|
+
link.rel = 'noopener noreferrer';
|
|
16
|
+
return link;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function renderAssignmentWorkspace(target, workspace, demo, choose) {
|
|
20
|
+
target.replaceChildren();
|
|
21
|
+
target.hidden = !workspace;
|
|
22
|
+
if (!workspace) return;
|
|
23
|
+
if (workspace.kind === 'task') { renderTask(target, workspace, demo); return; }
|
|
24
|
+
const heading = element('h2', workspace.status === 'resolved' ? workspace.title : workspace.status === 'choose' ? 'Which assignment?' : 'Assignment not found');
|
|
25
|
+
heading.id = 'assignment-heading';
|
|
26
|
+
heading.tabIndex = -1;
|
|
27
|
+
target.append(heading);
|
|
28
|
+
if (workspace.status !== 'resolved') {
|
|
29
|
+
target.append(element('p', workspace.status === 'choose' ? 'Choose the assignment you want to work on.' : 'No matching assignment in the accessible cache. Try another course or refresh after collection finishes.'));
|
|
30
|
+
for (const choice of workspace.choices) {
|
|
31
|
+
const button = element('button', `${choice.courseName} · ${choice.title}`, 'course-item');
|
|
32
|
+
button.type = 'button';
|
|
33
|
+
button.addEventListener('click', () => choose(choice));
|
|
34
|
+
target.append(button);
|
|
35
|
+
}
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
target.append(element('p', `${workspace.courseName} · ${workspace.stale ? 'Stale cache' : 'Cached'} · ${date(workspace.lastSuccess)}`, 'source-meta'));
|
|
39
|
+
const overview = element('article', undefined, 'widget assignment-overview');
|
|
40
|
+
overview.append(element('p', workspace.intent === 'submit' ? 'SUBMISSION CHECK' : 'YOUR ASSIGNMENT', 'eyebrow'),
|
|
41
|
+
element('h3', `Due ${date(workspace.dueDate)}`), element('p', workspace.submissionStatus),
|
|
42
|
+
element('p', `Opens ${date(workspace.startDate)} · Closes ${date(workspace.endDate)}`, 'source-meta'),
|
|
43
|
+
sourceLink(workspace, demo, 'Open submission dropbox in Brightspace ↗'));
|
|
44
|
+
target.append(overview);
|
|
45
|
+
const grid = element('div', undefined, 'assignment-grid');
|
|
46
|
+
for (const record of [...workspace.records, ...workspace.supporting]) {
|
|
47
|
+
const card = element('article', undefined, 'widget assignment-card');
|
|
48
|
+
card.dataset.category = record.category;
|
|
49
|
+
card.append(element('p', record.reason || labels[record.status], 'eyebrow'), element('h3', record.title));
|
|
50
|
+
if (record.text) {
|
|
51
|
+
const details = element('details');
|
|
52
|
+
details.open = ['instructions', 'rubric'].includes(record.category);
|
|
53
|
+
details.append(element('summary', 'Read source'), element('p', record.text, 'assignment-text'));
|
|
54
|
+
card.append(details);
|
|
55
|
+
} else card.append(element('p', record.status === 'available' ? 'No text in the cached response. Check Brightspace for details.' : `${labels[record.status]}. Check Brightspace or refresh after collection finishes.`));
|
|
56
|
+
card.append(element('p', `${labels[record.status]} · Last retrieved ${date(record.lastSuccess)}`, 'source-meta'), sourceLink(record, demo,
|
|
57
|
+
record.reason ? 'Open source in Brightspace ↗' : 'Open assignment in Brightspace ↗'));
|
|
58
|
+
grid.append(card);
|
|
59
|
+
}
|
|
60
|
+
target.append(grid, element('p', 'Follow-up questions use this assignment. Choose New search or change course to clear it. Cached submissions do not confirm current submission status.', 'retrieval-note'));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function renderTask(target, workspace, demo) {
|
|
64
|
+
const heading = element('h2', workspace.title);
|
|
65
|
+
heading.id = 'assignment-heading';
|
|
66
|
+
heading.tabIndex = -1;
|
|
67
|
+
target.append(element('p', 'YOUR WORKSPACE', 'eyebrow'), heading, element('p', workspace.goal));
|
|
68
|
+
if (workspace.research?.length) {
|
|
69
|
+
const live = element('div', undefined, 'research-status');
|
|
70
|
+
live.append(element('p', `Checked Brightspace during this request · ${workspace.research.length} connector operations`, 'retrieval-note'));
|
|
71
|
+
for (const item of workspace.research) live.append(element('p', `${item.section}: ${item.outcome}${item.message ? ` · ${item.message}` : ''}`, 'source-meta'));
|
|
72
|
+
target.append(live);
|
|
73
|
+
}
|
|
74
|
+
if (workspace.question) {
|
|
75
|
+
const question = element('p', workspace.question, 'notice');
|
|
76
|
+
question.setAttribute('role', 'status');
|
|
77
|
+
target.append(question);
|
|
78
|
+
}
|
|
79
|
+
if (workspace.catalogueCount < workspace.total) target.append(element('p',
|
|
80
|
+
`Planned from ${workspace.catalogueCount} of ${workspace.total} cached records. Narrow the course or describe the missing topic if needed.`, 'retrieval-note'));
|
|
81
|
+
for (const section of workspace.sections) {
|
|
82
|
+
const group = element('section', undefined, 'task-section');
|
|
83
|
+
group.append(element('h3', section.title), element('p', section.purpose, 'retrieval-note'));
|
|
84
|
+
const grid = element('div', undefined, 'assignment-grid');
|
|
85
|
+
for (const source of section.sources) {
|
|
86
|
+
const card = element('article', undefined, 'widget assignment-card');
|
|
87
|
+
card.dataset.category = source.category;
|
|
88
|
+
const citation = workspace.context.sourceIds.indexOf(source.id) + 1;
|
|
89
|
+
if (citation && !target.querySelector(`#answer-source-${citation}`)) {
|
|
90
|
+
card.id = `answer-source-${citation}`;
|
|
91
|
+
card.tabIndex = -1;
|
|
92
|
+
}
|
|
93
|
+
card.append(element('p', `${source.courseName} · ${labels[source.status] || 'Cached'}`, 'eyebrow'), element('h4', source.title));
|
|
94
|
+
if (source.dueDate) card.append(element('p', `Due ${date(source.dueDate)}`));
|
|
95
|
+
const details = element('details');
|
|
96
|
+
details.open = section.sources.length <= 3;
|
|
97
|
+
details.append(element('summary', 'Read source'), element('p', source.text, 'assignment-text'));
|
|
98
|
+
card.append(details, element('p', `${source.stale ? 'Stale cache' : labels[source.status] || 'Cached'} · Last retrieved ${date(source.lastSuccess)}`, 'source-meta'),
|
|
99
|
+
sourceLink(source, demo, source.url.includes('/dropbox/') ? 'Open assignment in Brightspace ↗' : 'Open course in Brightspace ↗'));
|
|
100
|
+
grid.append(card);
|
|
101
|
+
}
|
|
102
|
+
group.append(grid);
|
|
103
|
+
target.append(group);
|
|
104
|
+
}
|
|
105
|
+
target.append(element('p', 'Describe what you want to do next to adapt this workspace. New search clears its goal. All records reflect the local cache.', 'retrieval-note'));
|
|
106
|
+
}
|