@ivanbard/dd2l 0.1.0 → 0.1.2

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.
@@ -1,88 +1,88 @@
1
- import { validAnswerView } from './answer-view.js';
2
-
3
- const element = (tag, text, className) => {
4
- const node = document.createElement(tag);
5
- if (text !== undefined) node.textContent = text;
6
- if (className) node.className = className;
7
- return node;
8
- };
9
-
10
- function references(ids, sources) {
11
- const links = element('div', undefined, 'answer-references');
12
- for (const id of [...new Set(ids)]) {
13
- const source = sources[id - 1];
14
- const link = element('a', `[${id}]${source.stale ? ' · Stale cache' : ''}`);
15
- link.href = `#answer-source-${id}`;
16
- link.setAttribute('aria-label', `Source ${id}: ${source.title}${source.stale ? ', stale cache' : ''}`);
17
- links.append(link);
18
- }
19
- return links;
20
- }
21
-
22
- export function renderAnswerView(target, view, sources) {
23
- target.replaceChildren();
24
- if (!validAnswerView(view, sources.length)) return false;
25
- for (const section of view.sections) {
26
- const card = element('article', undefined, `widget generated-card answer-${section.type}`);
27
- const edition = element('div', undefined, 'widget-edition');
28
- edition.append(element('span', { checklist: 'STUDY / CHECKLIST', comparison: 'COURSEWORK / COMPARE', cards: 'SOURCES / COLLECTION' }[section.type]), element('span', 'dd2l'));
29
- const header = element('header', undefined, 'widget-header');
30
- header.append(element('h3', section.title));
31
- card.append(edition, header);
32
- if (section.type === 'checklist') {
33
- const progress = element('p', `0 of ${section.items.length} complete`, 'answer-progress');
34
- progress.setAttribute('role', 'status');
35
- const list = element('ul', undefined, 'answer-checklist');
36
- let completed = 0;
37
- for (const item of section.items) {
38
- const row = element('li');
39
- const label = element('label');
40
- const checkbox = element('input');
41
- checkbox.type = 'checkbox';
42
- checkbox.addEventListener('change', () => {
43
- completed += checkbox.checked ? 1 : -1;
44
- row.classList.toggle('completed', checkbox.checked);
45
- progress.textContent = `${completed} of ${section.items.length} complete`;
46
- });
47
- label.append(checkbox, element('span', item.text));
48
- row.append(label, references(item.citations, sources));
49
- list.append(row);
50
- }
51
- card.append(progress, list, element('small', 'Checks are kept for this answer only.', 'answer-session-note'));
52
- } else if (section.type === 'comparison') {
53
- const scroll = element('div', undefined, 'answer-table-scroll');
54
- scroll.tabIndex = 0;
55
- scroll.setAttribute('role', 'region');
56
- scroll.setAttribute('aria-label', section.title);
57
- const table = element('table');
58
- table.append(element('caption', section.title, 'sr-only'));
59
- const head = element('thead'), heading = element('tr');
60
- for (const title of [...section.columns, 'Sources']) {
61
- const cell = element('th', title); cell.scope = 'col'; heading.append(cell);
62
- }
63
- head.append(heading);
64
- const body = element('tbody');
65
- for (const row of section.rows) {
66
- const tr = element('tr');
67
- for (const [index, value] of row.cells.entries()) {
68
- const cell = element(index ? 'td' : 'th', value);
69
- if (!index) cell.scope = 'row';
70
- tr.append(cell);
71
- }
72
- const citations = element('td'); citations.append(references(row.citations, sources)); tr.append(citations);
73
- body.append(tr);
74
- }
75
- table.append(head, body); scroll.append(table); card.append(scroll);
76
- } else {
77
- const list = element('div', undefined, 'answer-source-cards');
78
- for (const item of section.cards) {
79
- const entry = element('section');
80
- entry.append(element('h4', item.title), element('p', item.text), references(item.citations, sources));
81
- list.append(entry);
82
- }
83
- card.append(list);
84
- }
85
- target.append(card);
86
- }
87
- return true;
88
- }
1
+ import { validAnswerView } from './answer-view.js';
2
+
3
+ const element = (tag, text, className) => {
4
+ const node = document.createElement(tag);
5
+ if (text !== undefined) node.textContent = text;
6
+ if (className) node.className = className;
7
+ return node;
8
+ };
9
+
10
+ function references(ids, sources) {
11
+ const links = element('div', undefined, 'answer-references');
12
+ for (const id of [...new Set(ids)]) {
13
+ const source = sources[id - 1];
14
+ const link = element('a', `[${id}]${source.stale ? ' · Stale cache' : ''}`);
15
+ link.href = `#answer-source-${id}`;
16
+ link.setAttribute('aria-label', `Source ${id}: ${source.title}${source.stale ? ', stale cache' : ''}`);
17
+ links.append(link);
18
+ }
19
+ return links;
20
+ }
21
+
22
+ export function renderAnswerView(target, view, sources) {
23
+ target.replaceChildren();
24
+ if (!validAnswerView(view, sources.length)) return false;
25
+ for (const section of view.sections) {
26
+ const card = element('article', undefined, `widget generated-card answer-${section.type}`);
27
+ const edition = element('div', undefined, 'widget-edition');
28
+ edition.append(element('span', { checklist: 'STUDY / CHECKLIST', comparison: 'COURSEWORK / COMPARE', cards: 'SOURCES / COLLECTION' }[section.type]), element('span', 'dd2l'));
29
+ const header = element('header', undefined, 'widget-header');
30
+ header.append(element('h3', section.title));
31
+ card.append(edition, header);
32
+ if (section.type === 'checklist') {
33
+ const progress = element('p', `0 of ${section.items.length} complete`, 'answer-progress');
34
+ progress.setAttribute('role', 'status');
35
+ const list = element('ul', undefined, 'answer-checklist');
36
+ let completed = 0;
37
+ for (const item of section.items) {
38
+ const row = element('li');
39
+ const label = element('label');
40
+ const checkbox = element('input');
41
+ checkbox.type = 'checkbox';
42
+ checkbox.addEventListener('change', () => {
43
+ completed += checkbox.checked ? 1 : -1;
44
+ row.classList.toggle('completed', checkbox.checked);
45
+ progress.textContent = `${completed} of ${section.items.length} complete`;
46
+ });
47
+ label.append(checkbox, element('span', item.text));
48
+ row.append(label, references(item.citations, sources));
49
+ list.append(row);
50
+ }
51
+ card.append(progress, list, element('small', 'Checks are kept for this answer only.', 'answer-session-note'));
52
+ } else if (section.type === 'comparison') {
53
+ const scroll = element('div', undefined, 'answer-table-scroll');
54
+ scroll.tabIndex = 0;
55
+ scroll.setAttribute('role', 'region');
56
+ scroll.setAttribute('aria-label', section.title);
57
+ const table = element('table');
58
+ table.append(element('caption', section.title, 'sr-only'));
59
+ const head = element('thead'), heading = element('tr');
60
+ for (const title of [...section.columns, 'Sources']) {
61
+ const cell = element('th', title); cell.scope = 'col'; heading.append(cell);
62
+ }
63
+ head.append(heading);
64
+ const body = element('tbody');
65
+ for (const row of section.rows) {
66
+ const tr = element('tr');
67
+ for (const [index, value] of row.cells.entries()) {
68
+ const cell = element(index ? 'td' : 'th', value);
69
+ if (!index) cell.scope = 'row';
70
+ tr.append(cell);
71
+ }
72
+ const citations = element('td'); citations.append(references(row.citations, sources)); tr.append(citations);
73
+ body.append(tr);
74
+ }
75
+ table.append(head, body); scroll.append(table); card.append(scroll);
76
+ } else {
77
+ const list = element('div', undefined, 'answer-source-cards');
78
+ for (const item of section.cards) {
79
+ const entry = element('section');
80
+ entry.append(element('h4', item.title), element('p', item.text), references(item.citations, sources));
81
+ list.append(entry);
82
+ }
83
+ card.append(list);
84
+ }
85
+ target.append(card);
86
+ }
87
+ return true;
88
+ }
package/ui/answer-view.js CHANGED
@@ -1,47 +1,47 @@
1
- const text = (maxLength = 1000) => ({ type: 'string', maxLength });
2
- const list = (items, maxItems, minItems = 0) => ({ type: 'array', items, minItems, maxItems });
3
- const object = properties => ({ type: 'object', properties, required: Object.keys(properties), additionalProperties: false });
4
- const kind = value => ({ type: 'string', enum: [value] });
5
- const citations = list({ type: 'integer', minimum: 1, maximum: 8 }, 8, 1);
6
-
7
- export const answerSchema = object({
8
- title: text(160),
9
- answer: text(6000),
10
- sections: list({ anyOf: [
11
- object({ type: kind('checklist'), title: text(160), items: list(object({ text: text(), citations }), 20, 1) }),
12
- object({ type: kind('comparison'), title: text(160), columns: list(text(120), 5, 2),
13
- rows: list(object({ cells: list(text(), 5, 2), citations }), 20, 1) }),
14
- object({ type: kind('cards'), title: text(160), cards: list(object({ title: text(160), text: text(2000), citations }), 12, 1) }),
15
- ] }, 6),
16
- });
17
-
18
- // Validate the same bounded schema we send to the provider, including unexpected keys.
19
- function matches(value, schema) {
20
- if (schema.anyOf) return schema.anyOf.some(choice => matches(value, choice));
21
- if (schema.type === 'object') return value !== null && typeof value === 'object' && !Array.isArray(value) &&
22
- Object.keys(value).length === schema.required.length && schema.required.every(key => Object.hasOwn(value, key) && matches(value[key], schema.properties[key]));
23
- if (schema.type === 'array') return Array.isArray(value) && value.length >= schema.minItems && value.length <= schema.maxItems && value.every(item => matches(item, schema.items));
24
- if (schema.type === 'string') return typeof value === 'string' && value.length <= (schema.maxLength ?? Infinity) && (!schema.enum || schema.enum.includes(value));
25
- return schema.type === 'integer' && Number.isInteger(value) && value >= schema.minimum && value <= schema.maximum;
26
- }
27
-
28
- export function validAnswerView(value, sourceCount) {
29
- if (!matches(value, answerSchema)) return false;
30
- return value.sections.every(section => {
31
- const items = section.items || section.rows || section.cards;
32
- return items.every(item => item.citations.every(id => id <= sourceCount) &&
33
- (section.type !== 'comparison' || item.cells.length === section.columns.length));
34
- });
35
- }
36
-
37
- export function parseAnswerView(raw, sourceCount) {
38
- let value;
39
- try { value = JSON.parse(raw); }
40
- catch {
41
- if (!/^\s*(?:[\[{]|```)/.test(raw)) return { answer: raw, view: null };
42
- }
43
- if (validAnswerView(value, sourceCount)) return { answer: value.answer, view: value };
44
- const answer = typeof value?.answer === 'string' && value.answer.trim() && value.answer.length <= 6000 ? value.answer
45
- : 'The model returned an unreadable answer. Please try again.';
46
- return { answer, view: null, viewWarning: 'The generated layout could not be verified. Showing text and available sources instead.' };
47
- }
1
+ const text = (maxLength = 1000) => ({ type: 'string', maxLength });
2
+ const list = (items, maxItems, minItems = 0) => ({ type: 'array', items, minItems, maxItems });
3
+ const object = properties => ({ type: 'object', properties, required: Object.keys(properties), additionalProperties: false });
4
+ const kind = value => ({ type: 'string', enum: [value] });
5
+ const citations = list({ type: 'integer', minimum: 1, maximum: 24 }, 24, 1);
6
+
7
+ export const answerSchema = object({
8
+ title: text(160),
9
+ answer: text(6000),
10
+ sections: list({ anyOf: [
11
+ object({ type: kind('checklist'), title: text(160), items: list(object({ text: text(), citations }), 20, 1) }),
12
+ object({ type: kind('comparison'), title: text(160), columns: list(text(120), 5, 2),
13
+ rows: list(object({ cells: list(text(), 5, 2), citations }), 20, 1) }),
14
+ object({ type: kind('cards'), title: text(160), cards: list(object({ title: text(160), text: text(2000), citations }), 12, 1) }),
15
+ ] }, 6),
16
+ });
17
+
18
+ // Validate the same bounded schema we send to the provider, including unexpected keys.
19
+ export function matches(value, schema) {
20
+ if (schema.anyOf) return schema.anyOf.some(choice => matches(value, choice));
21
+ if (schema.type === 'object') return value !== null && typeof value === 'object' && !Array.isArray(value) &&
22
+ Object.keys(value).length === schema.required.length && schema.required.every(key => Object.hasOwn(value, key) && matches(value[key], schema.properties[key]));
23
+ if (schema.type === 'array') return Array.isArray(value) && value.length >= schema.minItems && value.length <= schema.maxItems && value.every(item => matches(item, schema.items));
24
+ if (schema.type === 'string') return typeof value === 'string' && value.length <= (schema.maxLength ?? Infinity) && (!schema.enum || schema.enum.includes(value));
25
+ return schema.type === 'integer' && Number.isInteger(value) && value >= schema.minimum && value <= schema.maximum;
26
+ }
27
+
28
+ export function validAnswerView(value, sourceCount) {
29
+ if (!matches(value, answerSchema)) return false;
30
+ return value.sections.every(section => {
31
+ const items = section.items || section.rows || section.cards;
32
+ return items.every(item => item.citations.every(id => id <= sourceCount) &&
33
+ (section.type !== 'comparison' || item.cells.length === section.columns.length));
34
+ });
35
+ }
36
+
37
+ export function parseAnswerView(raw, sourceCount) {
38
+ let value;
39
+ try { value = JSON.parse(raw); }
40
+ catch {
41
+ if (!/^\s*(?:[\[{]|```)/.test(raw)) return { answer: raw, view: null };
42
+ }
43
+ if (validAnswerView(value, sourceCount)) return { answer: value.answer, view: value };
44
+ const answer = typeof value?.answer === 'string' && value.answer.trim() && value.answer.length <= 6000 ? value.answer
45
+ : 'The model returned an unreadable answer. Please try again.';
46
+ return { answer, view: null, viewWarning: 'The generated layout could not be verified. Showing text and available sources instead.' };
47
+ }
package/ui/app.js CHANGED
@@ -1,10 +1,15 @@
1
1
  import { createWidgets } from './widgets.js';
2
2
  import { renderAnswerView } from './answer-renderer.js';
3
3
  import { startLoading } from './loading.js';
4
+ import { renderAssignmentWorkspace } from './assignment-workspace.js';
5
+ import { createCanvas } from './canvas.js';
4
6
 
5
7
  const $ = id => document.getElementById(id);
8
+ const canvas = createCanvas($('canvas-host'), notice);
9
+ let canvasSources = [];
6
10
  const refreshWidgets = createWidgets(async (course, title) => {
7
11
  try { await load(); } catch (error) { notice(error.message); return; }
12
+ showAssignment(null);
8
13
  $('course').value = course;
9
14
  $('prompt').value = title;
10
15
  search(!title);
@@ -13,6 +18,9 @@ let status;
13
18
  let courses = [];
14
19
  let request;
15
20
  let aiRequest;
21
+ let assignmentContext;
22
+ let taskContext;
23
+ let defaultModel = '';
16
24
  let finishPromptLoading = () => {};
17
25
 
18
26
  function element(tag, text, className) {
@@ -41,6 +49,7 @@ async function loadAI() {
41
49
  try {
42
50
  clearAnswer();
43
51
  $('model').replaceChildren(new Option('Local search', ''));
52
+ defaultModel = '';
44
53
  updateComposer();
45
54
  const connection = await api('/api/llm/status');
46
55
  if (connection.active) {
@@ -49,6 +58,8 @@ async function loadAI() {
49
58
  group.label = 'ChatGPT';
50
59
  group.append(...models.map(m => new Option(m.name, m.id)));
51
60
  $('model').append(group);
61
+ defaultModel = models[0]?.id || '';
62
+ $('model').value = defaultModel;
52
63
  }
53
64
  updateComposer();
54
65
  } finally { finish(); }
@@ -57,7 +68,8 @@ async function loadAI() {
57
68
  function updateComposer() {
58
69
  const model = $('model').value;
59
70
  $('model-sharing').hidden = !model;
60
- $('model').setAttribute('aria-describedby', model ? 'model-sharing' : 'search-help');
71
+ $('adaptive-help').hidden = !!model;
72
+ $('model').setAttribute('aria-describedby', model ? 'model-sharing' : 'adaptive-help');
61
73
  $('send').disabled = !$('prompt').value.trim() || !$('busy').hidden;
62
74
  $('send').setAttribute('aria-label', model ? `Ask ${$('model').selectedOptions[0].textContent}` : 'Search locally');
63
75
  }
@@ -93,22 +105,54 @@ async function askAI() {
93
105
  $('results').hidden = true;
94
106
  notice();
95
107
  try {
96
- const data = await aiAction('answer', { q: $('prompt').value.trim(), course: $('course').value, model: $('model').value, consent: true }, current.signal);
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);
97
112
  if (aiRequest !== current) return;
98
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
+ }
99
141
  $('answer-text').textContent = data.answer;
100
142
  const rendered = renderAnswerView($('answer-view'), data.view, data.sources);
101
143
  $('answer-view').hidden = !rendered || !data.view.sections.length;
102
- $('ai-answer-title').textContent = rendered ? data.view.title || 'Your answer' : 'Your answer';
144
+ $('ai-answer-title').textContent = rendered ? data.view.title || data.workspace?.title || 'Your answer' : data.workspace?.title || 'Your answer';
103
145
  if (data.viewWarning) notice(data.viewWarning);
104
146
  $('answer-sources').replaceChildren();
147
+ $('ai-answer').querySelector('.answer-sources-heading').hidden = data.workspace?.kind === 'task';
105
148
  for (const [i, source] of data.sources.entries()) {
149
+ if (data.workspace?.kind === 'task') continue;
106
150
  const article = element('article', undefined, 'source');
107
151
  article.id = `answer-source-${i + 1}`;
108
152
  article.tabIndex = -1;
109
153
  article.append(element('h3', `[${i + 1}] ${source.title}`), element('p', source.excerpt),
110
154
  element('p', `${source.courseName} · ${source.stale ? 'Stale cache' : 'Cached'} · ${date(source.lastSuccess)}`, 'source-meta'));
111
- const link = element('a', 'Open course in onQ ↗');
155
+ const link = element('a', source.url.includes('/dropbox/') ? 'Open assignment in Brightspace ↗' : 'Open course in Brightspace ↗');
112
156
  link.href = source.url;
113
157
  link.target = '_blank';
114
158
  link.rel = 'noopener noreferrer';
@@ -143,14 +187,15 @@ async function load() {
143
187
  $('course').replaceChildren(new Option('All courses', ''), ...courses.map(course => new Option(course.name, course.id)));
144
188
  if (courses.some(course => course.id === selected)) $('course').value = selected;
145
189
  $('demo-badge').hidden = !status.demo;
146
- if (!courses.length) notice('No cached courses yet. If setup or start is running, collection is in progress. Otherwise run npx dd2l setup (npm run setup from source).');
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).');
147
191
  } finally { finish(); }
148
192
  }
149
193
 
150
194
  function reset() {
151
195
  clearAnswer();
196
+ showAssignment(null);
152
197
  $('prompt').value = '';
153
- $('model').value = '';
198
+ $('model').value = defaultModel;
154
199
  updateComposer();
155
200
  $('results').hidden = true;
156
201
  $('result-list').replaceChildren();
@@ -158,6 +203,27 @@ function reset() {
158
203
  $('prompt').focus();
159
204
  }
160
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
+
161
227
  async function search(browse = false) {
162
228
  clearAnswer();
163
229
  const query = $('prompt').value.trim();
@@ -167,8 +233,14 @@ async function search(browse = false) {
167
233
  setBusy(true);
168
234
  notice();
169
235
  try {
170
- const data = await api(`/api/search?${new URLSearchParams({ q: browse ? '' : query, course: $('course').value })}`, current.signal);
236
+ const data = await api(`/api/search?${searchParams(browse ? '' : query)}`, current.signal);
171
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
+ }
172
244
  $('workspace').classList.add('has-results');
173
245
  $('results').hidden = false;
174
246
  $('results-title').textContent = browse ? 'Course materials' : 'Search results';
@@ -220,6 +292,7 @@ async function showCourses() {
220
292
  button.type = 'button';
221
293
  button.append(element('small', course.active ? 'Active course' : 'Historical course'));
222
294
  button.addEventListener('click', () => {
295
+ showAssignment(null);
223
296
  $('course').value = course.id;
224
297
  $('prompt').value = '';
225
298
  $('panel').close();
@@ -227,7 +300,7 @@ async function showCourses() {
227
300
  });
228
301
  $('panel-body').append(button);
229
302
  }
230
- if (!courses.length) $('panel-body').append(element('p', 'No cached courses yet. Let setup finish collecting, then reopen Courses. To connect onQ, run npx dd2l setup (npm run setup from source).'));
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).'));
231
304
  } catch (error) { $('panel-body').replaceChildren(element('p', error.message)); }
232
305
  finally { finish(); }
233
306
  }
@@ -238,17 +311,17 @@ async function showStatus() {
238
311
  try {
239
312
  await load();
240
313
  $('panel-body').replaceChildren(
241
- 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. Selecting a ChatGPT model and sending a question shares matching coursework with OpenAI.'),
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.'),
242
315
  element('p', `${status.courseCount} courses · ${status.sourceCount} searchable sources`),
243
316
  element('p', `Connector: ${status.state?.state || 'Not synced'} · Last successful source: ${date(status.lastSuccess)}`),
244
- element('p', 'Search uses keywords and cached excerpts. Missing sources and access restrictions mean coverage may be incomplete.'),
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.'),
245
318
  element('p', 'Setup and start collect coursework automatically while the terminal stays open.'),
246
- element('p', 'If sign-in has expired, stop the app and run npx dd2l setup (npm run setup from source). For later launches use npx dd2l start (npm start -- start from source). Reopen Courses or search again to read the latest cache.'),
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.'),
247
320
  );
248
321
  const connection = await api('/api/llm/status');
249
322
  $('panel-body').append(element('h3', 'Use your ChatGPT plan'));
250
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; }
251
- $('panel-body').append(element('p', 'Eligible answers use your ChatGPT plan and its usage limits. dd2l does not automatically switch to an API key. Select a model and send a question to share matching coursework for that answer.'));
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.'));
252
325
  const action = (label, handler) => {
253
326
  const button = element('button', label, 'account-action');
254
327
  button.type = 'button';
@@ -286,8 +359,18 @@ async function showStatus() {
286
359
  $('prompt').addEventListener('input', clearAnswer);
287
360
  $('model').addEventListener('change', () => { clearAnswer(); notice(); });
288
361
  $('search-form').addEventListener('submit', event => { event.preventDefault(); if ($('model').value) askAI(); else search(); });
289
- $('course').addEventListener('change', () => { const browsing = !$('results').hidden || !$('busy').hidden; clearAnswer(); if (browsing && !$('model').value) search(!$('prompt').value.trim()); });
362
+ $('course').addEventListener('change', () => { const browsing = !$('results').hidden || !$('assignment-workspace').hidden || !$('busy').hidden; clearAnswer(); showAssignment(null); if (browsing && !$('model').value) search(!$('prompt').value.trim()); });
290
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
+ });
291
374
  $('search-nav').addEventListener('click', () => $('prompt').focus());
292
375
  $('courses-nav').addEventListener('click', showCourses);
293
376
  $('settings-nav').addEventListener('click', showStatus);
@@ -1,4 +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>
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>
@@ -1,5 +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>
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
+ }