// 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);