Files
learning-lab-express/src/api/engine.ts
Iqbal 42464d88b3 Show the workspace credit balance in the topbar
Read from GET /organizations/me, refreshed after every action and every
30 seconds, since agents and schedules spend credits in the background.
The date is dropped from the topbar, and its items wrap on phones.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:57:01 +07:00

201 lines
8.2 KiB
TypeScript

// 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 const documentFile = (id: number) => request<Blob>(`/upload/${id}/file`, { as: 'blob' })
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)
}
}
// ── Workspace wallet ──
// The organization's credit balance: Express spends workspace credits, never personal ones.
export const workspaceCredits = async () =>
(await request<{ organization: { credit_balance: number } | null }>('/organizations/me')).organization?.credit_balance ?? null
// ── 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 }
}