@ivanbard/dd2l 0.0.0-stage → 0.1.0

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.
@@ -0,0 +1,125 @@
1
+ import { createServer } from 'node:http';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { join, extname } from 'node:path';
4
+ import { fileURLToPath, pathToFileURL } from 'node:url';
5
+ import { parseArgs } from 'node:util';
6
+ import { readInventory, searchInventory, homeInventory } from './ui-data.mjs';
7
+ import { demoInventory } from './ui-demo.mjs';
8
+ import { ChatGPT } from './llm.mjs';
9
+
10
+ const root = fileURLToPath(new URL('../ui/', import.meta.url));
11
+ const mime = { '.html': 'text/html; charset=utf-8', '.css': 'text/css', '.js': 'text/javascript', '.svg': 'image/svg+xml', '.png': 'image/png' };
12
+
13
+ function signInPage(connected, first = false) {
14
+ const title = connected ? (first ? "You're using your ChatGPT plan" : 'ChatGPT connected') : 'Sign-in did not complete';
15
+ const message = connected
16
+ ? 'Eligible AI requests use your ChatGPT plan. You can manage limits in ChatGPT Settings → Usage.'
17
+ : 'Return to dd2l and open Settings to try signing in again.';
18
+ return `<!doctype html>
19
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
20
+ <meta name="color-scheme" content="dark"><title>${title} · dd2l</title>
21
+ <link rel="icon" href="/assets/logo.svg" type="image/svg+xml"><link rel="stylesheet" href="/style.css"></head>
22
+ <body class="auth-page"><main class="auth-content" aria-labelledby="auth-heading">
23
+ <a class="auth-brand" href="/" aria-label="dd2l home"><img src="/assets/logo.svg" width="40" height="40" alt=""><span>dd2l</span></a>
24
+ <p class="auth-status">${connected ? 'Account connected' : 'Connection interrupted'}</p>
25
+ <h1 id="auth-heading">${title}</h1><p class="auth-message">${message}</p>
26
+ <a class="auth-return" href="/">${connected && first ? 'Got it — return to dd2l' : 'Return to dd2l'} <span aria-hidden="true">→</span></a>
27
+ ${connected ? '<a class="auth-usage" href="https://chatgpt.com/settings/usage" target="_blank" rel="noopener noreferrer">Manage usage in ChatGPT ↗</a>' : ''}
28
+ </main></body></html>`;
29
+ }
30
+
31
+ export function createUiServer({ demo = false, inventoryPath = join(process.env.LOCALAPPDATA || '', 'dd2l', 'inventory.sqlite'),
32
+ llm = demo ? null : new ChatGPT(join(process.env.LOCALAPPDATA || '', 'dd2l')) } = {}) {
33
+ return createServer(async (req, res) => {
34
+ const authority = `127.0.0.1:${res.socket.localPort}`;
35
+ const send = (status, body, type = 'application/json') => {
36
+ res.writeHead(status, { 'Content-Type': type, 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff',
37
+ 'Referrer-Policy': 'no-referrer', 'Cross-Origin-Resource-Policy': 'same-origin', 'X-Frame-Options': 'DENY',
38
+ 'Content-Security-Policy': "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'" });
39
+ res.end(type === 'application/json' ? JSON.stringify(body) : body);
40
+ };
41
+ const callback = req.method === 'GET' && req.url.startsWith('/auth/callback?');
42
+ if (req.headers.host !== authority || (!callback && ((req.headers.origin && req.headers.origin !== `http://${authority}`) || req.headers['sec-fetch-site'] === 'cross-site'))) {
43
+ send(403, { error: 'Open dd2l using its local URL.' }); return;
44
+ }
45
+ try {
46
+ const url = new URL(req.url, `http://${authority}`);
47
+ if (callback) {
48
+ try {
49
+ if (!llm || demo) throw new Error('disabled');
50
+ const result = await llm.callback(url.searchParams);
51
+ send(200, signInPage(true, result.first), 'text/html; charset=utf-8');
52
+ } catch { send(400, signInPage(false), 'text/html; charset=utf-8'); }
53
+ return;
54
+ }
55
+ if (url.pathname.startsWith('/api/llm/')) {
56
+ if (!llm || demo) { send(200, { disabled: true, accounts: [], active: null }); return; }
57
+ if (req.method === 'GET' && url.pathname === '/api/llm/status') { send(200, await llm.status()); return; }
58
+ if (req.method === 'GET' && url.pathname === '/api/llm/models') { send(200, await llm.models()); return; }
59
+ if (req.method !== 'POST') { send(405, { error: 'Use POST for AI actions.' }); return; }
60
+ if (req.headers.origin !== `http://${authority}` || !/^application\/json(?:;|$)/i.test(req.headers['content-type'] || '')) { send(403, { error: 'AI actions require a local JSON request.' }); return; }
61
+ const chunks = [];
62
+ let bytes = 0;
63
+ for await (const chunk of req) {
64
+ bytes += chunk.length;
65
+ if (bytes > 8192) { send(413, { error: 'Request too large.' }); return; }
66
+ chunks.push(chunk);
67
+ }
68
+ let data;
69
+ try { data = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { send(400, { error: 'Invalid JSON.' }); return; }
70
+ if (!data || typeof data !== 'object' || Array.isArray(data)) { send(400, { error: 'Invalid request.' }); return; }
71
+ if (['login', 'select'].includes(url.pathname.split('/').at(-1)) && data.id !== undefined && (typeof data.id !== 'string' || data.id.length > 300)) { send(400, { error: 'Invalid account.' }); return; }
72
+ if (url.pathname === '/api/llm/login') send(200, await llm.login(`http://${authority}/auth/callback`, data.id));
73
+ else if (url.pathname === '/api/llm/select') send(200, await llm.select(data.id));
74
+ else if (url.pathname === '/api/llm/logout') send(200, await llm.logout());
75
+ else if (url.pathname === '/api/llm/answer') {
76
+ const { q, course = '', model, consent } = data;
77
+ if (typeof q !== 'string' || !q.trim() || q.length > 500 || typeof course !== 'string' || (course && !/^\d+$/.test(course)) || typeof model !== 'string' || !model || model.length > 200 || consent !== true) {
78
+ send(400, { error: 'Provide a question, course, model and permission to send excerpts to OpenAI.' }); return;
79
+ }
80
+ const inventory = readInventory(inventoryPath);
81
+ if (course && !inventory.courses.some(c => c.id === course)) { send(404, { error: 'Course is not available in this inventory.' }); return; }
82
+ const sources = searchInventory(inventory, q, course).results.slice(0, 8);
83
+ const controller = new AbortController();
84
+ const abort = () => controller.abort();
85
+ res.on('close', abort);
86
+ try { send(200, { ...await llm.answer(q, model, sources, AbortSignal.any([controller.signal, AbortSignal.timeout(90_000)])), sources }); }
87
+ finally { res.off('close', abort); }
88
+ } else send(404, { error: 'Not found.' });
89
+ return;
90
+ }
91
+ if (req.method !== 'GET') { send(405, { error: 'This route is read-only.' }); return; }
92
+ if (url.pathname.startsWith('/api/')) {
93
+ if (!['/api/status', '/api/courses', '/api/search', '/api/home'].includes(url.pathname)) { send(404, { error: 'Not found.' }); return; }
94
+ const query = url.searchParams.get('q') || '';
95
+ const course = url.searchParams.get('course') || '';
96
+ if (query.length > 500 || (course && !/^\d+$/.test(course))) { send(400, { error: 'Use a query of at most 500 characters and a valid course.' }); return; }
97
+ const inventory = demo ? demoInventory() : readInventory(inventoryPath);
98
+ if (course && !inventory.courses.some(item => item.id === course)) { send(404, { error: 'Course is not available in this inventory.' }); return; }
99
+ if (url.pathname === '/api/status') send(200, { demo, state: inventory.state,
100
+ courseCount: inventory.courses.length, sourceCount: inventory.sources.length,
101
+ lastSuccess: inventory.sources.map(source => source.lastSuccess).filter(Boolean).sort().at(-1) || null });
102
+ else if (url.pathname === '/api/home') send(200, { ...homeInventory(inventory), demo });
103
+ else if (url.pathname === '/api/courses') send(200, { courses: inventory.courses });
104
+ else send(200, { ...searchInventory(inventory, query, course), demo });
105
+ return;
106
+ }
107
+ const path = url.pathname === '/' ? 'index.html' : url.pathname.slice(1);
108
+ if (!/^(?:index\.html|app\.js|widgets\.js|widget-layout\.js|answer-view\.js|answer-renderer\.js|loading\.js|style\.css|assets\/[a-z-]+\.(?:svg|png))$/.test(path)) { send(404, { error: 'Not found.' }); return; }
109
+ send(200, await readFile(join(root, path)), mime[extname(path)]);
110
+ } catch (error) {
111
+ send(error.code === 'ENOENT' ? 404 : 503, { error: error.public ? error.message : error.code === 'ENOENT' ? 'Not found.' : 'Local data or AI service is unavailable. Retry after the current operation finishes.' });
112
+ }
113
+ });
114
+ }
115
+
116
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
117
+ const { values } = parseArgs({ options: { demo: { type: 'boolean' }, port: { type: 'string', default: '4173' } } });
118
+ const port = Number(values.port);
119
+ if (!Number.isInteger(port) || port < 1024 || port > 65535) throw new Error('Port must be between 1024 and 65535.');
120
+ if (!values.demo && !process.env.LOCALAPPDATA) throw new Error('Live inventory requires Windows LOCALAPPDATA. Use --demo for sample data.');
121
+ const server = createUiServer({ demo: values.demo });
122
+ server.on('error', error => { console.error(`Could not start UI: ${error.code}`); process.exitCode = 1; });
123
+ server.listen(port, '127.0.0.1', () => console.log(`dd2l ${values.demo ? 'DEMO (sample data)' : '(local cached coursework)'}: http://127.0.0.1:${port}`));
124
+ for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, () => server.close());
125
+ }
@@ -0,0 +1,32 @@
1
+ param([Parameter(Mandatory=$true)][string]$ImagePath)
2
+ $ErrorActionPreference = 'Stop'
3
+ try {
4
+ Add-Type -AssemblyName System.Runtime.WindowsRuntime
5
+ $null = [Windows.Storage.StorageFile, Windows.Storage, ContentType=WindowsRuntime]
6
+ $null = [Windows.Graphics.Imaging.BitmapDecoder, Windows.Graphics.Imaging, ContentType=WindowsRuntime]
7
+ $null = [Windows.Media.Ocr.OcrEngine, Windows.Foundation, ContentType=WindowsRuntime]
8
+ $asTask = [System.WindowsRuntimeSystemExtensions].GetMethods() | Where-Object { $_.Name -eq 'AsTask' -and $_.GetParameters().Count -eq 1 -and $_.IsGenericMethod -and $_.GetParameters()[0].ParameterType.Name -eq 'IAsyncOperation`1' } | Select-Object -First 1
9
+ function Await-Result($operation, $resultType) {
10
+ $task = $asTask.MakeGenericMethod($resultType).Invoke($null, @($operation))
11
+ $task.Wait()
12
+ $task.Result
13
+ }
14
+ $file = Await-Result ([Windows.Storage.StorageFile]::GetFileFromPathAsync($ImagePath)) ([Windows.Storage.StorageFile])
15
+ $stream = Await-Result ($file.OpenAsync([Windows.Storage.FileAccessMode]::Read)) ([Windows.Storage.Streams.IRandomAccessStream])
16
+ try {
17
+ $decoder = Await-Result ([Windows.Graphics.Imaging.BitmapDecoder]::CreateAsync($stream)) ([Windows.Graphics.Imaging.BitmapDecoder])
18
+ if ([double]$decoder.PixelWidth * $decoder.PixelHeight -gt 16000000) { throw 'Image pixel limit exceeded' }
19
+ $maximum = [Windows.Media.Ocr.OcrEngine]::MaxImageDimension
20
+ $scale = [Math]::Min(1.0, $maximum / [double][Math]::Max($decoder.PixelWidth, $decoder.PixelHeight))
21
+ $transform = [Windows.Graphics.Imaging.BitmapTransform]::new()
22
+ $transform.ScaledWidth = [uint32][Math]::Max(1, [Math]::Floor($decoder.PixelWidth * $scale))
23
+ $transform.ScaledHeight = [uint32][Math]::Max(1, [Math]::Floor($decoder.PixelHeight * $scale))
24
+ $bitmap = Await-Result ($decoder.GetSoftwareBitmapAsync([Windows.Graphics.Imaging.BitmapPixelFormat]::Bgra8, [Windows.Graphics.Imaging.BitmapAlphaMode]::Ignore, $transform, [Windows.Graphics.Imaging.ExifOrientationMode]::RespectExifOrientation, [Windows.Graphics.Imaging.ColorManagementMode]::DoNotColorManage)) ([Windows.Graphics.Imaging.SoftwareBitmap])
25
+ try {
26
+ $engine = [Windows.Media.Ocr.OcrEngine]::TryCreateFromUserProfileLanguages()
27
+ if ($null -eq $engine) { throw 'OCR language unavailable' }
28
+ $result = Await-Result ($engine.RecognizeAsync($bitmap)) ([Windows.Media.Ocr.OcrResult])
29
+ [Console]::Out.Write([Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($result.Text)))
30
+ } finally { $bitmap.Dispose() }
31
+ } finally { $stream.Dispose() }
32
+ } catch { exit 1 }
@@ -0,0 +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
+ }
@@ -0,0 +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
+ }
package/ui/app.js ADDED
@@ -0,0 +1,302 @@
1
+ import { createWidgets } from './widgets.js';
2
+ import { renderAnswerView } from './answer-renderer.js';
3
+ import { startLoading } from './loading.js';
4
+
5
+ const $ = id => document.getElementById(id);
6
+ const refreshWidgets = createWidgets(async (course, title) => {
7
+ try { await load(); } catch (error) { notice(error.message); return; }
8
+ $('course').value = course;
9
+ $('prompt').value = title;
10
+ search(!title);
11
+ });
12
+ let status;
13
+ let courses = [];
14
+ let request;
15
+ let aiRequest;
16
+ let finishPromptLoading = () => {};
17
+
18
+ function element(tag, text, className) {
19
+ const node = document.createElement(tag);
20
+ if (text !== undefined) node.textContent = text;
21
+ if (className) node.className = className;
22
+ return node;
23
+ }
24
+
25
+ async function api(path, signal) {
26
+ const response = await fetch(path, { signal });
27
+ const data = await response.json();
28
+ if (!response.ok) throw new Error(data.error || 'Could not read the local inventory.');
29
+ return data;
30
+ }
31
+
32
+ async function aiAction(action, body = {}, signal) {
33
+ const response = await fetch(`/api/llm/${action}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), signal });
34
+ const data = await response.json();
35
+ if (!response.ok) throw new Error(data.error || 'AI request failed.');
36
+ return data;
37
+ }
38
+
39
+ async function loadAI() {
40
+ const finish = startLoading($('model-loading'), 'Loading models');
41
+ try {
42
+ clearAnswer();
43
+ $('model').replaceChildren(new Option('Local search', ''));
44
+ updateComposer();
45
+ const connection = await api('/api/llm/status');
46
+ if (connection.active) {
47
+ const { models } = await api('/api/llm/models');
48
+ const group = element('optgroup');
49
+ group.label = 'ChatGPT';
50
+ group.append(...models.map(m => new Option(m.name, m.id)));
51
+ $('model').append(group);
52
+ }
53
+ updateComposer();
54
+ } finally { finish(); }
55
+ }
56
+
57
+ function updateComposer() {
58
+ const model = $('model').value;
59
+ $('model-sharing').hidden = !model;
60
+ $('model').setAttribute('aria-describedby', model ? 'model-sharing' : 'search-help');
61
+ $('send').disabled = !$('prompt').value.trim() || !$('busy').hidden;
62
+ $('send').setAttribute('aria-label', model ? `Ask ${$('model').selectedOptions[0].textContent}` : 'Search locally');
63
+ }
64
+
65
+ function setBusy(busy, label = 'Searching…') {
66
+ finishPromptLoading();
67
+ $('busy').hidden = !busy;
68
+ if (busy) finishPromptLoading = startLoading($('busy'), label);
69
+ if (busy) $('search-form').setAttribute('aria-busy', 'true');
70
+ else $('search-form').removeAttribute('aria-busy');
71
+ updateComposer();
72
+ }
73
+
74
+ function clearAnswer() {
75
+ aiRequest?.abort();
76
+ request?.abort();
77
+ aiRequest = request = undefined;
78
+ setBusy(false);
79
+ $('ai-answer').hidden = true;
80
+ $('answer-text').textContent = '';
81
+ $('answer-view').replaceChildren();
82
+ $('answer-view').hidden = true;
83
+ $('ai-answer-title').textContent = 'Your answer';
84
+ $('answer-sources').replaceChildren();
85
+ }
86
+
87
+ async function askAI() {
88
+ if (!$('prompt').value.trim()) { notice('Enter a coursework question first.'); return; }
89
+ if (!$('model').value) return search();
90
+ clearAnswer();
91
+ const current = aiRequest = new AbortController();
92
+ setBusy(true, 'Thinking…');
93
+ $('results').hidden = true;
94
+ notice();
95
+ try {
96
+ const data = await aiAction('answer', { q: $('prompt').value.trim(), course: $('course').value, model: $('model').value, consent: true }, current.signal);
97
+ if (aiRequest !== current) return;
98
+ $('workspace').classList.add('has-results');
99
+ $('answer-text').textContent = data.answer;
100
+ const rendered = renderAnswerView($('answer-view'), data.view, data.sources);
101
+ $('answer-view').hidden = !rendered || !data.view.sections.length;
102
+ $('ai-answer-title').textContent = rendered ? data.view.title || 'Your answer' : 'Your answer';
103
+ if (data.viewWarning) notice(data.viewWarning);
104
+ $('answer-sources').replaceChildren();
105
+ for (const [i, source] of data.sources.entries()) {
106
+ const article = element('article', undefined, 'source');
107
+ article.id = `answer-source-${i + 1}`;
108
+ article.tabIndex = -1;
109
+ article.append(element('h3', `[${i + 1}] ${source.title}`), element('p', source.excerpt),
110
+ element('p', `${source.courseName} · ${source.stale ? 'Stale cache' : 'Cached'} · ${date(source.lastSuccess)}`, 'source-meta'));
111
+ const link = element('a', 'Open course in onQ ↗');
112
+ link.href = source.url;
113
+ link.target = '_blank';
114
+ link.rel = 'noopener noreferrer';
115
+ article.append(link);
116
+ $('answer-sources').append(article);
117
+ }
118
+ $('ai-answer').hidden = false;
119
+ $('ai-answer').querySelector('h2').focus();
120
+ } catch (error) { if (error.name !== 'AbortError' && aiRequest === current) notice(error.message); }
121
+ finally {
122
+ if (aiRequest === current) {
123
+ setBusy(false);
124
+ }
125
+ }
126
+ }
127
+
128
+ function notice(text = '') {
129
+ $('notice').textContent = text;
130
+ $('notice').hidden = !text;
131
+ }
132
+
133
+ function date(value) {
134
+ const stamp = new Date(value);
135
+ return value && !Number.isNaN(stamp.valueOf()) ? stamp.toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' }) : 'Unknown';
136
+ }
137
+
138
+ async function load() {
139
+ const finish = startLoading($('course-loading'), 'Loading courses');
140
+ try {
141
+ [status, { courses }] = await Promise.all([api('/api/status'), api('/api/courses')]);
142
+ const selected = $('course').value;
143
+ $('course').replaceChildren(new Option('All courses', ''), ...courses.map(course => new Option(course.name, course.id)));
144
+ if (courses.some(course => course.id === selected)) $('course').value = selected;
145
+ $('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).');
147
+ } finally { finish(); }
148
+ }
149
+
150
+ function reset() {
151
+ clearAnswer();
152
+ $('prompt').value = '';
153
+ $('model').value = '';
154
+ updateComposer();
155
+ $('results').hidden = true;
156
+ $('result-list').replaceChildren();
157
+ $('workspace').classList.remove('has-results');
158
+ $('prompt').focus();
159
+ }
160
+
161
+ async function search(browse = false) {
162
+ clearAnswer();
163
+ const query = $('prompt').value.trim();
164
+ if (!browse && !query) return;
165
+ request?.abort();
166
+ const current = request = new AbortController();
167
+ setBusy(true);
168
+ notice();
169
+ try {
170
+ const data = await api(`/api/search?${new URLSearchParams({ q: browse ? '' : query, course: $('course').value })}`, current.signal);
171
+ if (request !== current) return;
172
+ $('workspace').classList.add('has-results');
173
+ $('results').hidden = false;
174
+ $('results-title').textContent = browse ? 'Course materials' : 'Search results';
175
+ $('result-count').textContent = `${Math.min(30, data.total)} of ${data.total} sources`;
176
+ $('result-list').replaceChildren();
177
+ for (const source of data.results) {
178
+ const article = element('article', undefined, 'source');
179
+ article.append(element('div', `${source.courseName} · ${source.category.replaceAll('_', ' ')}`, 'source-meta'),
180
+ element('h3', source.title), element('p', source.excerpt));
181
+ const meta = element('div', `${source.stale ? 'Stale cache · ' : 'Cached · '}${date(source.lastSuccess)}`, `source-meta${source.stale ? ' stale' : ''}`);
182
+ article.append(meta);
183
+ if (!data.demo) {
184
+ const link = element('a', 'Open course in onQ ↗');
185
+ link.href = source.url;
186
+ link.target = '_blank';
187
+ link.rel = 'noopener noreferrer';
188
+ article.append(link);
189
+ }
190
+ $('result-list').append(article);
191
+ }
192
+ 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'));
193
+ $('results-title').focus();
194
+ } catch (error) {
195
+ if (error.name !== 'AbortError' && request === current) {
196
+ $('results').hidden = true;
197
+ notice(error.message);
198
+ }
199
+ } finally {
200
+ if (request === current) {
201
+ setBusy(false);
202
+ }
203
+ }
204
+ }
205
+
206
+ function openPanel(title) {
207
+ $('panel-title').textContent = title;
208
+ $('panel-body').replaceChildren();
209
+ if (!$('panel').open) $('panel').showModal();
210
+ }
211
+
212
+ async function showCourses() {
213
+ openPanel('Courses');
214
+ const finish = startLoading($('panel-loading'), 'Loading cached courses');
215
+ try {
216
+ await load();
217
+ $('panel-body').replaceChildren(element('p', 'Browse materials already collected from onQ.'));
218
+ for (const course of courses) {
219
+ const button = element('button', course.name, 'course-item');
220
+ button.type = 'button';
221
+ button.append(element('small', course.active ? 'Active course' : 'Historical course'));
222
+ button.addEventListener('click', () => {
223
+ $('course').value = course.id;
224
+ $('prompt').value = '';
225
+ $('panel').close();
226
+ search(true);
227
+ });
228
+ $('panel-body').append(button);
229
+ }
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).'));
231
+ } catch (error) { $('panel-body').replaceChildren(element('p', error.message)); }
232
+ finally { finish(); }
233
+ }
234
+
235
+ async function showStatus() {
236
+ openPanel('Connection status');
237
+ const finish = startLoading($('panel-loading'), 'Loading connection status');
238
+ try {
239
+ await load();
240
+ $('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.'),
242
+ element('p', `${status.courseCount} courses · ${status.sourceCount} searchable sources`),
243
+ 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.'),
245
+ 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.'),
247
+ );
248
+ const connection = await api('/api/llm/status');
249
+ $('panel-body').append(element('h3', 'Use your ChatGPT plan'));
250
+ 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.'));
252
+ const action = (label, handler) => {
253
+ const button = element('button', label, 'account-action');
254
+ button.type = 'button';
255
+ button.addEventListener('click', async () => {
256
+ button.disabled = true;
257
+ const finishAction = startLoading(button, `${label} in progress`);
258
+ try { await handler(); } catch (error) { $('panel-body').append(element('p', error.message)); }
259
+ finally { finishAction(); button.disabled = false; }
260
+ });
261
+ $('panel-body').append(button);
262
+ };
263
+ for (const account of connection.accounts) {
264
+ $('panel-body').append(element('p', `${account.label}${account.id === connection.active ? ' · Active' : ''}`));
265
+ action(account.connected ? 'Use this account' : 'Continue with ChatGPT', async () => {
266
+ if (account.connected) { clearAnswer(); await aiAction('select', { id: account.id }); await loadAI(); await showStatus(); }
267
+ else location.assign((await aiAction('login', { id: account.id })).url);
268
+ });
269
+ if (account.connected) action('Sign in again to this account', async () => location.assign((await aiAction('login', { id: account.id })).url));
270
+ }
271
+ action('Continue with ChatGPT', async () => location.assign((await aiAction('login')).url));
272
+ if (connection.active) action('Sign out of active ChatGPT account', async () => {
273
+ clearAnswer();
274
+ const result = await aiAction('logout');
275
+ await loadAI();
276
+ await showStatus();
277
+ $('panel-body').append(element('p', result.message));
278
+ });
279
+ const usage = element('a', 'Manage usage in ChatGPT');
280
+ usage.href = 'https://chatgpt.com/settings/usage'; usage.target = '_blank'; usage.rel = 'noopener noreferrer';
281
+ $('panel-body').append(usage);
282
+ } catch (error) { $('panel-body').replaceChildren(element('p', error.message)); }
283
+ finally { finish(); }
284
+ }
285
+
286
+ $('prompt').addEventListener('input', clearAnswer);
287
+ $('model').addEventListener('change', () => { clearAnswer(); notice(); });
288
+ $('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()); });
290
+ $('new-chat').addEventListener('click', reset);
291
+ $('search-nav').addEventListener('click', () => $('prompt').focus());
292
+ $('courses-nav').addEventListener('click', showCourses);
293
+ $('settings-nav').addEventListener('click', showStatus);
294
+ $('profile').addEventListener('click', showStatus);
295
+ $('refresh-widgets').addEventListener('click', refreshWidgets);
296
+ $('close-panel').addEventListener('click', () => $('panel').close());
297
+ $('panel').addEventListener('click', event => { if (event.target === $('panel')) {
298
+ const rect = $('panel').getBoundingClientRect();
299
+ if (event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom) $('panel').close();
300
+ } });
301
+ load().then(refreshWidgets).catch(error => notice(error.message));
302
+ 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,3 @@
1
+ <svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
2
+ <circle id="Ellipse 2" cx="20" cy="20" r="20" fill="#008ABD"/>
3
+ </svg>
@@ -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>