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:
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