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:
27
prototype/README.md
Normal file
27
prototype/README.md
Normal 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
192
prototype/engine.js
vendored
Normal 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
333
prototype/index.html
Normal file
File diff suppressed because one or more lines are too long
52
prototype/server.mjs
Normal file
52
prototype/server.mjs
Normal 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}`));
|
||||
Reference in New Issue
Block a user