Initial commit: Learning Lab Express

Includes fixes found while reviewing the app:
- Results: open each result by a type+agent+id key, so a video and a
  material draft with the same id no longer open each other's popup
- AuthGate: clear the query cache during render, before pages build their
  queries, and remount pages on organization switch (pages no longer stay
  on skeletons for up to a minute after sign-in or refresh)
- Instruction: keep only failed rules after a partial approve, so retrying
  does not create the successful schedules twice; drop the monthly note
  once count or frequency is edited
- Parser/schedules: never schedule a count below one
- Audio: ignore the end event of a cancelled utterance when changing speed
- Downloads: revoke the object URL later so Safari/Firefox can read it
- Results: refetch SCORM content when any part's revision changes; show a
  failed video as failed instead of "still rendering"

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Iqbal
2026-10-23 10:42:12 +07:00
commit f219b26f71
41 changed files with 8172 additions and 0 deletions

27
prototype/README.md Normal file
View File

@@ -0,0 +1,27 @@
# Learning Lab Express
UI fase development (`index.html`, satu file, tanpa dependensi) dengan desain yang sama dengan `mockup.html` (CSS dipakai apa adanya) tetapi isi/alurnya memakai data engine yang terhubung ke engine **learning-lab** (FastAPI, `/api/v1`).
```bash
# 1. jalankan engine di ../learning-lab (docker compose up) → http://localhost:8000
# 2. jalankan Express
node server.mjs # http://localhost:3000 ; ENGINE_URL / PORT dapat diubah
```
`server.mjs` menyajikan file statis dan mem-proxy `/api/*` ke engine (tanpa CORS).
`engine.js` adalah client engine.
| Alur | Engine |
|---|---|
| Login / logout / refresh token | `/auth/login`, `/auth/refresh`, `/auth/logout` |
| Upload materi → Brain | `/upload/`, `/upload/list`, `DELETE /upload/{id}` |
| Knowledge base (Brain) | `/upload/{id}/knowledge/build` (memakai kredit workspace) |
| Login Clerk (akun organisasi) | `clerk-js` dari instance Clerk milik engine (key dibaca dari `../learning-lab/backend/.env`, atau `CLERK_PUBLISHABLE_KEY`) |
| Riset konten | agen "Learning Lab Express" di `/organizations/current/agents` → `runs` → `source-drafts` |
| Jadwal riset | `PATCH agents/{id}` `run_schedule` (harian/mingguan; bulanan & hari kerja dijalankan harian) |
| Generate Text | `/compose/sets` + `/compose/sets/{id}/generate/summary` |
Agents API hanya berjalan dalam konteks organisasi, jadi riset dan jadwal memerlukan tombol **Masuk dengan Clerk** dengan akun yang menjadi owner organisasi. Dengan login email/password biasa, riset tetap simulasi.
Agen dibuat otomatis (budget 50 kredit/run, 1000/bulan; ubah di `Engine.ensureAgent`).
Belum ada: video, audio, dan SCORM. Hanya format yang didukung engine yang bisa diunggah.

192
prototype/engine.js vendored Normal file
View File

@@ -0,0 +1,192 @@
// Client for the learning-lab engine (FastAPI, /api/v1).
// Auth, document upload/Brain and text generation go through the real backend.
(function (root) {
const BASE = '/api/v1', KEY = 'lle-engine-tokens';
const Engine = {};
let tokens = null;
try { tokens = JSON.parse(localStorage.getItem(KEY) || 'null'); } catch { tokens = null; }
const save = t => { tokens = t; try { t ? localStorage.setItem(KEY, JSON.stringify(t)) : localStorage.removeItem(KEY); } catch {} };
class EngineError extends Error { constructor(message, status) { super(message); this.status = status; } }
const detail = d => typeof d === 'string' ? d : Array.isArray(d) ? d.map(x => x.msg || JSON.stringify(x)).join('; ') : (d && d.message) || 'Permintaan ke engine gagal.';
// ── Clerk: the only source of organization-scoped tokens (needed by the agents API) ──
let clerk = null;
Engine.initClerk = async () => {
const key = root.LLE_CONFIG?.clerkKey;
if (!key) return false;
if (clerk) return true;
const domain = atob(key.split('_')[2]).replace(/\$$/, '');
await new Promise((ok, fail) => {
const s = document.createElement('script');
s.src = `https://${domain}/npm/@clerk/clerk-js@5/dist/clerk.browser.js`;
s.async = true; s.crossOrigin = 'anonymous'; s.setAttribute('data-clerk-publishable-key', key);
s.onload = ok; s.onerror = () => fail(new EngineError('Clerk tidak dapat dimuat.', 0));
document.head.appendChild(s);
});
await root.Clerk.load();
clerk = root.Clerk;
return true;
};
Engine.clerkEnabled = () => !!clerk;
Engine.clerkUser = () => clerk?.user && { email: clerk.user.primaryEmailAddress?.emailAddress, name: clerk.user.fullName || clerk.user.firstName };
Engine.onClerkChange = cb => clerk?.addListener(() => cb());
Engine.clerkSignIn = () => clerk.openSignIn({});
// Agents belong to an organization: make sure one is active on the session before asking for a token.
Engine.ensureOrg = async () => {
if (!clerk?.session) return null;
if (!clerk.organization) {
const memberships = (await clerk.user.getOrganizationMemberships()).data || [];
if (!memberships.length) return null;
await clerk.setActive({ organization: memberships[0].organization.id });
}
return clerk.organization;
};
async function accessToken() {
if (clerk?.session) return clerk.session.getToken();
return tokens?.access;
}
async function raw(path, opts = {}) {
const headers = { ...(opts.headers || {}) };
const access = await accessToken();
if (access) headers.Authorization = 'Bearer ' + access;
if (opts.json !== undefined) { headers['Content-Type'] = 'application/json'; opts = { ...opts, body: JSON.stringify(opts.json) }; }
let res;
try { res = await fetch(BASE + path, { ...opts, headers }); }
catch { throw new EngineError('Engine learning-lab tidak dapat dihubungi.', 0); }
return res;
}
let refreshing = null;
async function refresh() {
if (!tokens?.refresh) return false;
refreshing ||= (async () => {
const res = await fetch(BASE + '/auth/refresh', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ refresh_token: tokens.refresh }) });
if (!res.ok) { save(null); return false; }
const body = await res.json();
save({ access: body.access_token, refresh: body.refresh_token || tokens.refresh });
return true;
})().finally(() => { refreshing = null; });
return refreshing;
}
async function call(path, opts = {}) {
let res = await raw(path, opts);
if (res.status === 401 && !path.startsWith('/auth/') && await refresh()) res = await raw(path, opts);
if (res.status === 204) return null;
const body = await res.json().catch(() => null);
if (!res.ok) throw new EngineError(detail(body?.detail), res.status);
return body;
}
Engine.hasSession = () => !!(clerk?.session || tokens?.access);
Engine.login = async (email, password) => {
const res = await raw('/auth/login', { method: 'POST', json: { email, password } });
const body = await res.json().catch(() => null);
if (!res.ok) throw new EngineError(res.status === 401 ? 'Email atau password salah.' : detail(body?.detail), res.status);
save({ access: body.access_token, refresh: body.refresh_token });
return body.user;
};
Engine.me = () => call('/auth/me');
Engine.logout = async () => {
try { if (tokens?.refresh) await call('/auth/logout', { method: 'POST', json: { refresh_token: tokens.refresh } }); } catch {}
save(null);
if (clerk?.session) await clerk.signOut();
};
let formats = null;
Engine.supportedExtensions = async () => (formats ||= call('/upload/supported-formats').then(f => new Set(f.extensions)).catch(() => { formats = null; return new Set(); }));
const asSource = d => ({
id: 'doc-' + d.id, docId: d.id, remote: true, sample: false,
title: (d.original_filename || d.filename).replace(/\.[^.]+$/, ''), originalName: d.original_filename || d.filename,
format: (d.file_type || (d.original_filename || '').split('.').pop() || '').toUpperCase(), tags: [], text: '',
createdAt: d.created_at, status: d.status, knowledge: d.knowledge_status, progress: d.progress_percentage, stage: d.processing_stage,
});
Engine.listDocuments = async () => (await call('/upload/list')).map(asSource);
Engine.documentStatus = async id => call('/upload/' + id);
Engine.upload = async file => {
const form = new FormData(); form.append('file', file, file.name);
const body = await call('/upload/', { method: 'POST', body: form });
return { id: body.id ?? body.document_id, raw: body };
};
Engine.deleteDocument = id => call('/upload/' + id, { method: 'DELETE' });
// Building a knowledge base spends workspace credits; a 402 surfaces as an EngineError with the shortfall.
const BUILDABLE = new Set(['not_built', 'awaiting_confirmation', 'needs_credits', 'failed']);
Engine.buildKnowledge = async id => {
const s = await Engine.documentStatus(id);
if (!BUILDABLE.has(s.knowledge_status)) return s;
try { await call(`/upload/${id}/knowledge/build`, { method: 'POST' }); }
catch (e) { throw e.status === 402 ? new EngineError('Kredit tidak cukup untuk membangun knowledge base: ' + e.message, 402) : e; }
return s;
};
// Polls until the document's knowledge base is built (what generation needs).
Engine.waitReady = async (id, onProgress, { timeoutMs = 10 * 60 * 1000 } = {}) => {
const t0 = Date.now();
for (;;) {
const s = await Engine.documentStatus(id);
onProgress?.(s);
if (s.error || s.status === 'failed') throw new EngineError(s.error || 'Dokumen gagal diproses.', 422);
if (s.knowledge_status === 'ready') return s;
if (Date.now() - t0 > timeoutMs) throw new EngineError('Pemrosesan dokumen melebihi batas waktu.', 408);
await new Promise(r => setTimeout(r, 2500));
}
};
// Text content: a compose set over whole documents, then a generated summary.
Engine.generateText = async (docIds, name) => {
const set = await call('/compose/sets', { method: 'POST', json: { name, mode: 'knowledge_documents', source_document_ids: docIds } });
const summary = await call(`/compose/sets/${set.id}/generate/summary`, { method: 'POST', json: { operation_key: `lle-${set.id}-${Date.now()}` } });
return { setId: set.id, summary };
};
Engine.generateQuiz = (setId, n = 5) => call(`/compose/sets/${setId}/generate/quiz`, { method: 'POST', json: { num_questions: n, question_type: 'mixed', operation_key: `lle-q-${setId}-${Date.now()}` } });
// ── Agents (organization-scoped): research runs and the recurring schedule ──
const AGENTS = '/organizations/current/agents', AGENT_NAME = 'Learning Lab Express';
Engine.agentsAvailable = async () => !!(await Engine.ensureOrg());
let agentCache = null;
Engine.ensureAgent = async () => {
if (agentCache) return agentCache;
let agent = (await call(AGENTS)).find(a => a.name === AGENT_NAME && a.status !== 'archived');
agent ||= await call(AGENTS, { method: 'POST', json: {
name: AGENT_NAME, goal: 'Riset topik pembelajaran dan temukan sumber publik baru untuk Brain tim LND.',
enabled_tools: ['knowledge.retrieve', 'compose.graph.retrieve', 'browse.search', 'library.add_url'],
research_options: { max_results: 5 }, timezone: 'Asia/Jakarta', run_budget: 50, monthly_budget: 1000,
} });
if (agent.status !== 'active') agent = await call(`${AGENTS}/${agent.id}/activate`, { method: 'POST' });
return (agentCache = agent);
};
Engine.attachSources = async (agentId, docIds) => {
for (const id of docIds) { try { await call(`${AGENTS}/${agentId}/knowledge-sources`, { method: 'PUT', json: { kind: 'document', source_id: id } }); } catch (e) { if (e.status !== 404) throw e; } }
};
// Server-side recurring run. weekday: UI uses 0=Sunday, the engine 0=Monday.
Engine.setSchedule = async ({ enabled, frequency, time, weekday }) => {
const agent = await Engine.ensureAgent(), [hour, minute] = String(time || '09:00').split(':').map(Number);
const cadence = !enabled ? 'off' : frequency === 'weekly' ? 'weekly' : 'daily';
agentCache = await call(`${AGENTS}/${agent.id}`, { method: 'PATCH', json: { run_schedule: { cadence, hour, minute, weekday: (Number(weekday) + 6) % 7 } } });
return agentCache;
};
Engine.research = async ({ topic, prompt, urls = [], docIds = [] }, onStatus) => {
const agent = await Engine.ensureAgent();
await Engine.attachSources(agent.id, docIds);
const focus = [topic && `Topik: ${topic}`, prompt, urls.length && `Referensi: ${urls.join(' ')}`].filter(Boolean).join('\n');
await call(`${AGENTS}/${agent.id}`, { method: 'PATCH', json: { goal: (topic ? `Riset topik: ${topic}` : agent.goal).slice(0, 4000), instructions: focus || null } });
const run = await call(`${AGENTS}/${agent.id}/runs`, { method: 'POST', headers: { 'Idempotency-Key': `lle-${agent.id}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` }, json: {} });
for (let detail = run; ; ) {
onStatus?.(detail);
if (['completed', 'failed', 'cancelled'].includes(detail.status)) { if (detail.status !== 'completed') throw new EngineError('Riset gagal dijalankan (' + (detail.safe_error_code || detail.status) + ').', 422); break; }
await new Promise(r => setTimeout(r, 3000));
detail = await call(`${AGENTS}/${agent.id}/runs/${run.id}`);
}
const detail = await call(`${AGENTS}/${agent.id}/runs/${run.id}`);
const drafts = (await call(`${AGENTS}/${agent.id}/source-drafts`)).filter(d => d.run_id === run.id);
return { run: detail, drafts };
};
root.Engine = Engine; root.EngineError = EngineError;
})(window);

333
prototype/index.html Normal file

File diff suppressed because one or more lines are too long

52
prototype/server.mjs Normal file
View File

@@ -0,0 +1,52 @@
// Static host for Learning Lab Express. Proxies /api to the learning-lab engine
// so the browser stays same-origin (no CORS setup needed on the engine).
import http from 'node:http';
import { readFile } from 'node:fs/promises';
import { extname, join, normalize } from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = fileURLToPath(new URL('.', import.meta.url));
const PORT = Number(process.env.PORT || 3000);
const ENGINE = new URL(process.env.ENGINE_URL || 'http://localhost:8000');
const TYPES = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css', '.svg': 'image/svg+xml' };
function proxy(req, res) {
const upstream = http.request({
host: ENGINE.hostname, port: ENGINE.port, path: req.url, method: req.method,
headers: { ...req.headers, host: ENGINE.host },
}, up => { res.writeHead(up.statusCode, up.headers); up.pipe(res); });
upstream.on('error', () => {
res.writeHead(502, { 'content-type': 'application/json' });
res.end(JSON.stringify({ detail: `Engine learning-lab tidak dapat dihubungi di ${ENGINE.origin}` }));
});
req.pipe(upstream);
}
// Clerk publishable keys are public; default to the engine's own dev key so both share one Clerk instance.
async function clerkKey() {
if (process.env.CLERK_PUBLISHABLE_KEY) return process.env.CLERK_PUBLISHABLE_KEY;
try {
const env = await readFile(join(ROOT, '../../learning-lab/backend/.env'), 'utf8');
return env.match(/^VITE_CLERK_PUBLISHABLE_KEY=["']?([^"'\s]+)/m)?.[1] || '';
} catch { return ''; }
}
async function serve(req, res) {
const path = new URL(req.url, 'http://x').pathname;
if (path === '/config.js') {
res.writeHead(200, { 'content-type': TYPES['.js'] });
return res.end(`window.LLE_CONFIG=${JSON.stringify({ clerkKey: await clerkKey() })};`);
}
const name = path === '/' ? 'index.html' : normalize(path).replace(/^([/\\])+/, '');
if (name.startsWith('..')) { res.writeHead(403); return res.end(); }
try {
const body = await readFile(join(ROOT, name));
res.writeHead(200, { 'content-type': TYPES[extname(name)] || 'application/octet-stream' });
res.end(body);
} catch {
res.writeHead(404); res.end('Not found');
}
}
http.createServer((req, res) => (req.url.startsWith('/api/') ? proxy(req, res) : serve(req, res)))
.listen(PORT, () => console.log(`Learning Lab Express: http://localhost:${PORT} → engine ${ENGINE.origin}`));