@ivanbard/dd2l 0.1.0 → 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 -21
- package/README.md +47 -8
- package/bin/dd2l.mjs +7 -7
- package/docs/README.md +108 -11
- package/package.json +3 -2
- package/src/app-runner.mjs +58 -35
- package/src/assignment-workspace.mjs +128 -0
- package/src/canvas.mjs +17 -0
- package/src/cli-banner.mjs +58 -0
- package/src/cli.mjs +41 -19
- package/src/client.mjs +150 -137
- package/src/connector.mjs +587 -345
- package/src/discovery.mjs +45 -45
- package/src/documents.mjs +154 -154
- package/src/links.mjs +99 -99
- package/src/llm.mjs +43 -6
- package/src/media.mjs +54 -54
- package/src/ocr.mjs +24 -24
- package/src/office.mjs +61 -61
- package/src/pdf-worker.mjs +26 -26
- package/src/research.mjs +70 -0
- package/src/session.mjs +5 -5
- package/src/store.mjs +108 -106
- package/src/syllabus.mjs +191 -191
- package/src/task-workspace.mjs +138 -0
- package/src/ui-data.mjs +36 -14
- package/src/ui-demo.mjs +17 -1
- package/src/ui-server.mjs +51 -15
- package/src/windows-ocr.ps1 +32 -32
- package/ui/answer-renderer.js +88 -88
- package/ui/answer-view.js +47 -47
- package/ui/app.js +96 -13
- package/ui/assets/widget-announcements.svg +4 -4
- package/ui/assets/widget-shortcuts.svg +5 -5
- 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 +15 -4
- package/ui/loading.js +33 -33
- package/ui/style.css +15 -0
- package/ui/widget-layout.js +39 -39
- package/ui/widgets.js +252 -252
package/ui/answer-renderer.js
CHANGED
|
@@ -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:
|
|
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
|
-
$('
|
|
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
|
|
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
|
|
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?${
|
|
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.
|
|
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', '
|
|
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
|
|
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
|
+
}
|