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:
78
src/api/client.ts
Normal file
78
src/api/client.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { t } from '../i18n'
|
||||
|
||||
// HTTP client for the learning-lab engine (FastAPI, /api/v1).
|
||||
// Auth is a Clerk session token: Express Mode runs on organization-scoped APIs (agents, credits).
|
||||
const BASE = '/api/v1'
|
||||
|
||||
export class EngineError extends Error {
|
||||
status: number
|
||||
/** The engine's error body, e.g. `required`/`balance` on a 402. */
|
||||
data: Record<string, unknown> | null
|
||||
constructor(message: string, status: number, data: Record<string, unknown> | null = null) {
|
||||
super(message)
|
||||
this.status = status
|
||||
this.data = data
|
||||
}
|
||||
}
|
||||
|
||||
// A 402 from any credit-spending call; the engine includes what was needed and what the wallet has.
|
||||
export function creditMessage(e: EngineError): string {
|
||||
const { required, balance } = e.data ?? {}
|
||||
return typeof required === 'number' && typeof balance === 'number'
|
||||
? t('err.credits', { required, balance })
|
||||
: t('err.creditsShort')
|
||||
}
|
||||
|
||||
type TokenGetter = () => Promise<string | null>
|
||||
let getToken: TokenGetter = async () => null
|
||||
|
||||
export function setTokenGetter(getter: TokenGetter) {
|
||||
getToken = getter
|
||||
}
|
||||
|
||||
function detail(d: unknown): string {
|
||||
if (typeof d === 'string') return d
|
||||
if (Array.isArray(d)) return d.map(x => (x && typeof x === 'object' && 'msg' in x ? String(x.msg) : JSON.stringify(x))).join('; ')
|
||||
if (d && typeof d === 'object' && 'message' in d) return String(d.message)
|
||||
return t('err.generic')
|
||||
}
|
||||
|
||||
export interface RequestOptions extends Omit<RequestInit, 'body'> {
|
||||
json?: unknown
|
||||
body?: BodyInit
|
||||
/** 'blob' for file downloads; errors still come back as JSON. */
|
||||
as?: 'json' | 'blob'
|
||||
}
|
||||
|
||||
export async function request<T>(path: string, { json, headers, as = 'json', ...opts }: RequestOptions = {}): Promise<T> {
|
||||
const h = new Headers(headers)
|
||||
const token = await getToken()
|
||||
if (token) h.set('Authorization', `Bearer ${token}`)
|
||||
let body = opts.body
|
||||
if (json !== undefined) {
|
||||
h.set('Content-Type', 'application/json')
|
||||
body = JSON.stringify(json)
|
||||
}
|
||||
|
||||
let res: Response
|
||||
try {
|
||||
res = await fetch(BASE + path, { ...opts, headers: h, body })
|
||||
} catch {
|
||||
throw new EngineError(t('err.unreachable'), 0)
|
||||
}
|
||||
if (res.status === 204) return null as T
|
||||
if (as === 'blob' && res.ok) return (await res.blob()) as T
|
||||
const data = await res.json().catch(() => null)
|
||||
if (!res.ok) {
|
||||
if (res.status === 502 || res.status === 504) throw new EngineError(t('err.unreachable'), res.status)
|
||||
const err = new EngineError(detail(data?.detail), res.status, data && typeof data === 'object' ? data : null)
|
||||
if (res.status === 402) err.message = creditMessage(err)
|
||||
throw err
|
||||
}
|
||||
return data as T
|
||||
}
|
||||
|
||||
// Idempotency keys for operations that spend credits, so a retried click is not billed twice.
|
||||
export const opKey = (prefix: string) => `lle-${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||
|
||||
export const sleep = (ms: number) => new Promise(r => setTimeout(r, ms))
|
||||
193
src/api/engine.ts
Normal file
193
src/api/engine.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
// Typed engine calls used by Express Mode. Ported from prototype/engine.js.
|
||||
import { EngineError, opKey, request, sleep } from './client'
|
||||
import { t } from '../i18n'
|
||||
|
||||
// ── Documents (Brain) ──
|
||||
|
||||
export type KnowledgeStatus = 'not_built' | 'awaiting_confirmation' | 'needs_credits' | 'building' | 'ready' | 'failed' | string
|
||||
|
||||
export interface EngineDocument {
|
||||
id: number
|
||||
filename: string
|
||||
original_filename?: string | null
|
||||
file_type?: string | null
|
||||
status: string
|
||||
knowledge_status?: KnowledgeStatus | null
|
||||
progress_percentage?: number | null
|
||||
processing_stage?: string | null
|
||||
error?: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface Source {
|
||||
docId: number
|
||||
title: string
|
||||
originalName: string
|
||||
format: string
|
||||
createdAt: string
|
||||
status: string
|
||||
knowledge: KnowledgeStatus | null
|
||||
progress: number | null
|
||||
stage: string | null
|
||||
}
|
||||
|
||||
function asSource(d: EngineDocument): Source {
|
||||
const name = d.original_filename || d.filename
|
||||
return {
|
||||
docId: d.id,
|
||||
title: name.replace(/\.[^.]+$/, ''),
|
||||
originalName: name,
|
||||
format: (d.file_type || name.split('.').pop() || '').toUpperCase(),
|
||||
createdAt: d.created_at,
|
||||
status: d.status,
|
||||
knowledge: d.knowledge_status ?? null,
|
||||
progress: d.progress_percentage ?? null,
|
||||
stage: d.processing_stage ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
export const listDocuments = async () => (await request<EngineDocument[]>('/upload/list')).map(asSource)
|
||||
export const documentStatus = (id: number) => request<EngineDocument>(`/upload/${id}`)
|
||||
export const deleteDocument = (id: number) => request<null>(`/upload/${id}`, { method: 'DELETE' })
|
||||
|
||||
export async function supportedExtensions(): Promise<Set<string>> {
|
||||
const f = await request<{ extensions: string[] }>('/upload/supported-formats')
|
||||
return new Set(f.extensions)
|
||||
}
|
||||
|
||||
export async function uploadDocument(file: File): Promise<number> {
|
||||
const form = new FormData()
|
||||
form.append('file', file, file.name)
|
||||
const body = await request<{ id?: number; document_id?: number }>('/upload/', { method: 'POST', body: form })
|
||||
return (body.id ?? body.document_id)!
|
||||
}
|
||||
|
||||
// After processing, the engine builds the knowledge base itself when the price is small.
|
||||
// Otherwise it stops in one of these states and waits for the user.
|
||||
export const NEEDS_ANSWER = new Set(['not_built', 'awaiting_confirmation', 'needs_credits', 'awaiting_version_answer', 'failed'])
|
||||
|
||||
export interface KnowledgeDetail {
|
||||
status: KnowledgeStatus
|
||||
document_status: string
|
||||
stage: string | null
|
||||
quote: { credits: number; activity_key: string } | null
|
||||
candidate: { id: number; filename: string; created_at: string | null } | null
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export const knowledgeDetail = (id: number) => request<KnowledgeDetail>(`/upload/${id}/knowledge`)
|
||||
|
||||
// Agree to the quoted price, build after a top-up, or retry a failed build. Spends workspace credits (402 when short).
|
||||
export const buildKnowledge = (id: number) => request<{ status: string }>(`/upload/${id}/knowledge/build`, { method: 'POST' })
|
||||
|
||||
// The engine asks whether an upload is the next version of a similar document before building it.
|
||||
export const answerVersion = (id: number, isNewVersion: boolean) =>
|
||||
request<{ status: string }>(`/upload/${id}/versions/${isNewVersion ? 'link' : 'decline'}`, { method: 'POST', json: isNewVersion ? {} : undefined })
|
||||
|
||||
// Polls until the document's knowledge base is built (what generation needs).
|
||||
export async function waitReady(id: number, onProgress?: (d: EngineDocument) => void, timeoutMs = 10 * 60 * 1000) {
|
||||
const t0 = Date.now()
|
||||
for (;;) {
|
||||
const s = await documentStatus(id)
|
||||
onProgress?.(s)
|
||||
if (s.error || s.status === 'failed') throw new EngineError(s.error || t('err.docFailed'), 422)
|
||||
if (s.knowledge_status === 'ready') return s
|
||||
if (Date.now() - t0 > timeoutMs) throw new EngineError(t('err.timeout'), 408)
|
||||
await sleep(2500)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Compose (text content) ──
|
||||
|
||||
export interface ComposeSet { id: number; name: string }
|
||||
|
||||
export async function generateText(docIds: number[], name: string) {
|
||||
const set = await request<ComposeSet>('/compose/sets', { method: 'POST', json: { name, mode: 'knowledge_documents', source_document_ids: docIds } })
|
||||
const summary = await request<unknown>(`/compose/sets/${set.id}/generate/summary`, { method: 'POST', json: { operation_key: opKey(`s-${set.id}`) } })
|
||||
return { setId: set.id, summary }
|
||||
}
|
||||
|
||||
export const generateQuiz = (setId: number, n = 5) =>
|
||||
request<unknown>(`/compose/sets/${setId}/generate/quiz`, { method: 'POST', json: { num_questions: n, question_type: 'mixed', operation_key: opKey(`q-${setId}`) } })
|
||||
|
||||
// ── Agents (organization-scoped): research runs and recurring schedule ──
|
||||
|
||||
const AGENTS = '/organizations/current/agents'
|
||||
export const AGENT_NAME = 'Learning Lab Express'
|
||||
|
||||
export interface RunSchedule { cadence: 'off' | 'daily' | 'weekly'; hour?: number; minute?: number; weekday?: number }
|
||||
|
||||
export interface Agent {
|
||||
id: number
|
||||
name: string
|
||||
goal: string
|
||||
status: string
|
||||
instructions?: string | null
|
||||
run_schedule?: RunSchedule | null
|
||||
}
|
||||
|
||||
export interface AgentRun { id: number; status: string; safe_error_code?: string | null }
|
||||
export interface SourceDraft { id: number; run_id: number; [key: string]: unknown }
|
||||
|
||||
let agentCache: Agent | null = null
|
||||
export const resetAgentCache = () => { agentCache = null }
|
||||
|
||||
export async function ensureAgent(): Promise<Agent> {
|
||||
if (agentCache) return agentCache
|
||||
let agent = (await request<Agent[]>(AGENTS)).find(a => a.name === AGENT_NAME && a.status !== 'archived')
|
||||
agent ??= await request<Agent>(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 request<Agent>(`${AGENTS}/${agent.id}/activate`, { method: 'POST' })
|
||||
return (agentCache = agent)
|
||||
}
|
||||
|
||||
export async function attachSources(agentId: number, docIds: number[]) {
|
||||
for (const id of docIds) {
|
||||
try {
|
||||
await request(`${AGENTS}/${agentId}/knowledge-sources`, { method: 'PUT', json: { kind: 'document', source_id: id } })
|
||||
} catch (e) {
|
||||
if (!(e instanceof EngineError && e.status === 404)) throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Server-side recurring run. weekday follows the engine: 0 = Monday.
|
||||
export async function setSchedule(schedule: RunSchedule) {
|
||||
const agent = await ensureAgent()
|
||||
agentCache = await request<Agent>(`${AGENTS}/${agent.id}`, { method: 'PATCH', json: { run_schedule: schedule } })
|
||||
return agentCache
|
||||
}
|
||||
|
||||
export async function research(
|
||||
{ topic, prompt, urls = [], docIds = [] }: { topic?: string; prompt?: string; urls?: string[]; docIds?: number[] },
|
||||
onStatus?: (run: AgentRun) => void,
|
||||
) {
|
||||
const agent = await ensureAgent()
|
||||
await attachSources(agent.id, docIds)
|
||||
const focus = [topic && `Topik: ${topic}`, prompt, urls.length && `Referensi: ${urls.join(' ')}`].filter(Boolean).join('\n')
|
||||
await request(`${AGENTS}/${agent.id}`, {
|
||||
method: 'PATCH',
|
||||
json: { goal: (topic ? `Riset topik: ${topic}` : agent.goal).slice(0, 4000), instructions: focus || null },
|
||||
})
|
||||
const run = await request<AgentRun>(`${AGENTS}/${agent.id}/runs`, { method: 'POST', headers: { 'Idempotency-Key': opKey(`run-${agent.id}`) }, json: {} })
|
||||
let detail = run
|
||||
for (;;) {
|
||||
onStatus?.(detail)
|
||||
if (['completed', 'failed', 'cancelled'].includes(detail.status)) break
|
||||
await sleep(3000)
|
||||
detail = await request<AgentRun>(`${AGENTS}/${agent.id}/runs/${run.id}`)
|
||||
}
|
||||
if (detail.status !== 'completed') throw new EngineError(t('err.researchFailed', { code: detail.safe_error_code || detail.status }), 422)
|
||||
const drafts = (await request<SourceDraft[]>(`${AGENTS}/${agent.id}/source-drafts`)).filter(d => d.run_id === run.id)
|
||||
return { run: detail, drafts }
|
||||
}
|
||||
Reference in New Issue
Block a user