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>
193 lines
11 KiB
JavaScript
193 lines
11 KiB
JavaScript
// 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);
|