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:
250
src/pages/Brain.tsx
Normal file
250
src/pages/Brain.tsx
Normal file
@@ -0,0 +1,250 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
answerVersion, buildKnowledge, deleteDocument, knowledgeDetail, listDocuments, NEEDS_ANSWER, supportedExtensions, uploadDocument,
|
||||
type Source,
|
||||
} from '../api/engine'
|
||||
import { Icon } from '../components/Icon'
|
||||
import { Empty, PageHead, Skeleton, SkeletonLines, SkeletonRows } from '../components/Page'
|
||||
import { toast } from '../components/Toast'
|
||||
import { locale, t } from '../i18n'
|
||||
|
||||
type Phase = 'ready' | 'working' | 'answer' | 'failed'
|
||||
|
||||
function phaseOf(s: Source): Phase {
|
||||
if (s.status === 'failed' || s.knowledge === 'failed') return 'failed'
|
||||
if (s.knowledge === 'ready') return 'ready'
|
||||
if (s.knowledge === 'building' || s.status !== 'completed') return 'working'
|
||||
return NEEDS_ANSWER.has(s.knowledge ?? '') ? 'answer' : 'working'
|
||||
}
|
||||
|
||||
const extOf = (name: string) => '.' + (name.split('.').pop() ?? '').toLowerCase()
|
||||
|
||||
export function BrainPage() {
|
||||
const docs = useQuery({
|
||||
queryKey: ['documents'],
|
||||
queryFn: listDocuments,
|
||||
// Poll quickly while anything is processing or building; slowly otherwise (colleagues may upload too).
|
||||
refetchInterval: q => (q.state.data?.some(d => phaseOf(d) === 'working') ? 3000 : 20_000),
|
||||
})
|
||||
const ready = docs.data?.filter(d => phaseOf(d) === 'ready').length ?? 0
|
||||
const waiting = docs.data?.filter(d => phaseOf(d) === 'answer').length ?? 0
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={t('brain.title')} description={t('brain.description')} />
|
||||
<div className="metrics">
|
||||
<div className="metric"><span>{t('brain.metricMaterial')}</span>{docs.data ? <strong>{docs.data.length}</strong> : <Skeleton className="skel-num" />}<small>{t('brain.metricMaterialSub')}</small></div>
|
||||
<div className="metric"><span>{t('brain.metricReady')}</span>{docs.data ? <strong>{ready}</strong> : <Skeleton className="skel-num" />}<small>{t('brain.metricReadySub')}</small></div>
|
||||
<div className="metric"><span>{t('brain.metricAction')}</span>{docs.data ? <strong>{waiting}</strong> : <Skeleton className="skel-num" />}<small>{t('brain.metricActionSub')}</small></div>
|
||||
</div>
|
||||
<UploadBox />
|
||||
<div className="card">
|
||||
<div className="card-head"><h3>{t('brain.listTitle')}</h3><Icon name="brain" /></div>
|
||||
{docs.isPending && <SkeletonRows label={t('brain.loading')} />}
|
||||
{docs.isError && <Empty icon="info" title={t('brain.engineDown')}>{docs.error.message}</Empty>}
|
||||
{docs.data?.length === 0 && <Empty icon="upload" title={t('brain.empty')}>{t('brain.emptyCopy')}</Empty>}
|
||||
{!!docs.data?.length && <div className="result-list">{docs.data.map(d => <DocumentRow key={d.docId} doc={d} />)}</div>}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
interface UploadItem { id: number; name: string; state: 'uploading' | 'done' | 'error'; message?: string }
|
||||
|
||||
let nextUploadId = 1
|
||||
const hasFiles = (e: globalThis.DragEvent) => !!e.dataTransfer?.types.includes('Files')
|
||||
|
||||
function UploadBox() {
|
||||
const queryClient = useQueryClient()
|
||||
const input = useRef<HTMLInputElement>(null)
|
||||
const [drag, setDrag] = useState(false)
|
||||
const [items, setItems] = useState<UploadItem[]>([])
|
||||
const exts = useQuery({ queryKey: ['supported-extensions'], queryFn: supportedExtensions, staleTime: Infinity })
|
||||
const accept = exts.data ? [...exts.data].join(',') : undefined
|
||||
const update = (id: number, patch: Partial<UploadItem>) => setItems(prev => prev.map(it => (it.id === id ? { ...it, ...patch } : it)))
|
||||
|
||||
async function upload(files: File[]) {
|
||||
if (!files.length) return
|
||||
const allowed = exts.data
|
||||
const batch: UploadItem[] = files.map(f => (allowed && !allowed.has(extOf(f.name))
|
||||
? { id: nextUploadId++, name: f.name, state: 'error', message: t('brain.unsupported') }
|
||||
: { id: nextUploadId++, name: f.name, state: 'uploading' }))
|
||||
// Appended, so files dropped while an earlier batch is still uploading keep their own rows.
|
||||
setItems(prev => [...prev.filter(it => it.state === 'uploading'), ...batch])
|
||||
// One at a time: each upload enqueues a processing job, and the list refreshes after each.
|
||||
for (const [i, file] of files.entries()) {
|
||||
if (batch[i].state === 'error') continue
|
||||
try {
|
||||
await uploadDocument(file)
|
||||
update(batch[i].id, { state: 'done' })
|
||||
} catch (e) {
|
||||
update(batch[i].id, { state: 'error', message: (e as Error).message })
|
||||
}
|
||||
await queryClient.invalidateQueries({ queryKey: ['documents'] })
|
||||
}
|
||||
}
|
||||
const uploadRef = useRef(upload)
|
||||
uploadRef.current = upload
|
||||
|
||||
// The whole page is the drop target, and a file dropped anywhere is uploaded instead of the
|
||||
// browser opening it and leaving the app. The counter absorbs dragenter/leave from child elements.
|
||||
useEffect(() => {
|
||||
let depth = 0
|
||||
const enter = (e: globalThis.DragEvent) => { if (hasFiles(e)) { depth++; setDrag(true) } }
|
||||
const leave = (e: globalThis.DragEvent) => { if (hasFiles(e) && --depth <= 0) { depth = 0; setDrag(false) } }
|
||||
const over = (e: globalThis.DragEvent) => { if (hasFiles(e)) { e.preventDefault(); e.dataTransfer!.dropEffect = 'copy' } }
|
||||
const drop = (e: globalThis.DragEvent) => {
|
||||
if (!hasFiles(e)) return
|
||||
e.preventDefault()
|
||||
depth = 0
|
||||
setDrag(false)
|
||||
uploadRef.current([...e.dataTransfer!.files])
|
||||
}
|
||||
window.addEventListener('dragenter', enter)
|
||||
window.addEventListener('dragleave', leave)
|
||||
window.addEventListener('dragover', over)
|
||||
window.addEventListener('drop', drop)
|
||||
return () => {
|
||||
window.removeEventListener('dragenter', enter)
|
||||
window.removeEventListener('dragleave', leave)
|
||||
window.removeEventListener('dragover', over)
|
||||
window.removeEventListener('drop', drop)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const busy = items.some(i => i.state === 'uploading')
|
||||
return (
|
||||
<div className={`upload-box upload-compact${drag ? ' drag' : ''}`}>
|
||||
{drag && (
|
||||
<div className="drop-overlay" aria-hidden="true">
|
||||
<div><Icon name="upload" /><b>{t('brain.dropHere')}</b></div>
|
||||
</div>
|
||||
)}
|
||||
<span className="upload-symbol"><Icon name="upload" /></span>
|
||||
<div>
|
||||
<h3>{t('brain.uploadTitle')}</h3>
|
||||
<p>{t('brain.uploadCopy')}</p>
|
||||
<small>{exts.data ? t('brain.formats', { list: [...exts.data].map(x => x.slice(1).toUpperCase()).join(', ') }) : t('brain.formatsLoading')}</small>
|
||||
{items.length > 0 && (
|
||||
<ul className="upload-progress">
|
||||
{items.map(it => (
|
||||
<li key={it.id}>
|
||||
<b>{it.name}</b>{' '}
|
||||
{it.state === 'uploading' && <span className="status draft">{t('brain.uploading')}</span>}
|
||||
{it.state === 'done' && <span className="status ready">{t('brain.uploaded')}</span>}
|
||||
{it.state === 'error' && <span className="error">{it.message}</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<button className="btn primary" disabled={busy} onClick={() => input.current?.click()}>
|
||||
<Icon name="plus" />{busy ? t('brain.uploading') : t('brain.chooseFiles')}
|
||||
</button>
|
||||
<input ref={input} type="file" multiple hidden accept={accept}
|
||||
onChange={e => { upload([...(e.target.files ?? [])]); e.target.value = '' }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DocumentRow({ doc }: { doc: Source }) {
|
||||
const phase = phaseOf(doc)
|
||||
return (
|
||||
<div className="result-row">
|
||||
<span className={`file-icon ${doc.format.toLowerCase()}`}>{doc.format.slice(0, 4)}</span>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<b>{doc.title}</b>
|
||||
<div><small>{new Date(doc.createdAt).toLocaleDateString(locale())}</small></div>
|
||||
{phase === 'working' && <Progress doc={doc} />}
|
||||
{(phase === 'answer' || phase === 'failed') && <KnowledgeAction doc={doc} />}
|
||||
</div>
|
||||
<StatusBadge doc={doc} phase={phase} />
|
||||
<DeleteButton doc={doc} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusBadge({ doc, phase }: { doc: Source; phase: Phase }) {
|
||||
const label = {
|
||||
ready: t('brain.statusReady'),
|
||||
working: doc.knowledge === 'building' ? t('brain.statusBuilding') : t('brain.statusProcessing'),
|
||||
answer: t('brain.statusAnswer'),
|
||||
failed: t('brain.statusFailed'),
|
||||
}[phase]
|
||||
return <span className={`status ${phase === 'ready' ? 'ready' : 'draft'}`}><i className="dot"></i>{label}</span>
|
||||
}
|
||||
|
||||
function Progress({ doc }: { doc: Source }) {
|
||||
const pct = Math.max(5, Math.min(100, doc.progress ?? 5))
|
||||
return (
|
||||
<div className="doc-progress">
|
||||
<div className="progress-track"><i style={{ width: `${pct}%` }} /></div>
|
||||
<small>{doc.stage || t('brain.queued')}</small>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Each stop the engine can make before building, with the one answer it needs.
|
||||
function KnowledgeAction({ doc }: { doc: Source }) {
|
||||
const queryClient = useQueryClient()
|
||||
const detail = useQuery({ queryKey: ['knowledge', doc.docId, doc.knowledge], queryFn: () => knowledgeDetail(doc.docId) })
|
||||
const refresh = () => queryClient.invalidateQueries({ queryKey: ['documents'] })
|
||||
const build = useMutation({ mutationFn: () => buildKnowledge(doc.docId), onSuccess: refresh })
|
||||
const version = useMutation({ mutationFn: (link: boolean) => answerVersion(doc.docId, link), onSuccess: refresh })
|
||||
const error = build.error ?? version.error
|
||||
const busy = build.isPending || version.isPending
|
||||
|
||||
const d = detail.data
|
||||
if (detail.isPending) return <div className="doc-action"><SkeletonLines label={t('common.loading')} /></div>
|
||||
if (!d) return <div className="doc-action"><span className="error">{detail.error?.message}</span></div>
|
||||
|
||||
const credits = d.quote ? t('common.credits', { n: d.quote.credits }) : null
|
||||
let text: string
|
||||
let actions: ReactNode = null
|
||||
switch (d.status) {
|
||||
case 'awaiting_version_answer':
|
||||
text = d.candidate ? t('brain.versionWithCandidate', { name: d.candidate.filename }) : t('brain.versionGeneric')
|
||||
actions = (
|
||||
<>
|
||||
<button className="btn small" disabled={busy} onClick={() => version.mutate(true)}>{t('brain.versionYes')}</button>
|
||||
<button className="btn small" disabled={busy} onClick={() => version.mutate(false)}>{t('brain.versionNo')}</button>
|
||||
</>
|
||||
)
|
||||
break
|
||||
case 'needs_credits':
|
||||
text = t('brain.needsCredits', { need: credits ? t('brain.needsCreditsNeed', { credits }) : '' })
|
||||
actions = <button className="btn small" disabled={busy} onClick={() => build.mutate()}>{t('common.retry')}</button>
|
||||
break
|
||||
case 'failed':
|
||||
text = d.error || t('brain.failed')
|
||||
if (d.quote) actions = <button className="btn small" disabled={busy} onClick={() => build.mutate()}>{t('brain.retryWith', { credits: credits ?? '' })}</button>
|
||||
break
|
||||
default:
|
||||
text = t('brain.confirmCopy', { credits: credits ?? t('brain.confirmCreditsFallback') })
|
||||
actions = <button className="btn small primary" disabled={busy} onClick={() => build.mutate()}>{t('brain.addToBrain')}{credits && ` · ${credits}`}</button>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="doc-action">
|
||||
<small>{text}</small>
|
||||
{actions && <div className="doc-action-buttons">{actions}</div>}
|
||||
{error && <span className="error">{error.message}</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DeleteButton({ doc }: { doc: Source }) {
|
||||
const queryClient = useQueryClient()
|
||||
const del = useMutation({
|
||||
mutationFn: () => deleteDocument(doc.docId),
|
||||
onSuccess: () => { toast(t('brain.deleted', { name: doc.title })); queryClient.invalidateQueries({ queryKey: ['documents'] }) },
|
||||
onError: e => toast(e.message, { error: true }),
|
||||
})
|
||||
return (
|
||||
<button className="icon-btn" aria-label={t('brain.delete', { name: doc.title })} disabled={del.isPending}
|
||||
onClick={() => confirm(t('brain.deleteConfirm', { name: doc.title })) && del.mutate()}>
|
||||
<Icon name="trash" />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
194
src/pages/Instruction.tsx
Normal file
194
src/pages/Instruction.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Icon, ruleIcon } from '../components/Icon'
|
||||
import { PageHead } from '../components/Page'
|
||||
import {
|
||||
dayName, describeRule, kindLabel, KINDS, monthNote, parseInstruction, videoCadence, weeklyTarget,
|
||||
type ContentKind, type ContentRule, type ResearchRule, type Rule,
|
||||
} from '../express/parse'
|
||||
import { t, type Key } from '../i18n'
|
||||
import { budgetFor, canSchedule, createSchedule, VIDEO, videoQuote } from '../express/schedules'
|
||||
|
||||
const EXAMPLES: Key[] = ['ins.ex1', 'ins.ex2', 'ins.ex3', 'ins.ex4', 'ins.ex5']
|
||||
|
||||
// How the kinds that are not a plain Compose output are made, shown under the rule.
|
||||
const KIND_NOTE: Partial<Record<ContentKind, Key>> = { video: 'ins.noteVideo', audio: 'ins.noteAudio', scorm: 'ins.noteScorm' }
|
||||
|
||||
type Outcome = { rule: Rule; ok: boolean; message?: string }
|
||||
|
||||
export function InstructionPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const [text, setText] = useState('')
|
||||
const [rules, setRules] = useState<Rule[] | null>(null)
|
||||
const [unknown, setUnknown] = useState<string[]>([])
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [outcomes, setOutcomes] = useState<Outcome[] | null>(null)
|
||||
|
||||
function compose() {
|
||||
const result = parseInstruction(text)
|
||||
setRules(result.rules)
|
||||
setUnknown(result.unknown)
|
||||
setOutcomes(null)
|
||||
}
|
||||
|
||||
// A count or frequency typed in by hand replaces the monthly request it was derived from.
|
||||
const update = (i: number, patch: Partial<ContentRule> | Partial<ResearchRule>) =>
|
||||
setRules(rs => rs!.map((r, j) => (j === i
|
||||
? ({ ...r, ...patch, ...('count' in patch || 'per' in patch ? { monthly: undefined } : {}) } as Rule)
|
||||
: r)))
|
||||
const remove = (i: number) => setRules(rs => rs!.filter((_, j) => j !== i))
|
||||
|
||||
const ready = rules?.filter(canSchedule) ?? []
|
||||
|
||||
async function approve() {
|
||||
setSaving(true)
|
||||
const done: Outcome[] = []
|
||||
// One at a time so a failure (e.g. not an organization owner) is reported per schedule.
|
||||
for (const rule of ready) {
|
||||
try {
|
||||
await createSchedule(rule)
|
||||
done.push({ rule, ok: true })
|
||||
} catch (e) {
|
||||
done.push({ rule, ok: false, message: (e as Error).message })
|
||||
}
|
||||
setOutcomes([...done])
|
||||
}
|
||||
setSaving(false)
|
||||
await queryClient.invalidateQueries({ queryKey: ['schedules'] })
|
||||
if (done.every(o => o.ok)) { setRules(null); setText('') }
|
||||
// Only the failed rules stay, so approving again does not create the others twice.
|
||||
else setRules(rs => rs!.filter(r => !done.some(o => o.ok && o.rule === r)))
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={t('ins.title')} description={t('ins.description')} />
|
||||
|
||||
<div className="card">
|
||||
<div className="card-head"><h3>{t('ins.cardTitle')}</h3><Icon name="spark" /></div>
|
||||
<div style={{ padding: '20px 22px 8px' }}>
|
||||
<label htmlFor="instruction">{t('ins.label')}</label>
|
||||
<textarea id="instruction" rows={3} value={text} placeholder={t('ins.placeholder')}
|
||||
onChange={e => setText(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && (e.metaKey || e.ctrlKey) && text.trim()) compose() }} />
|
||||
<div className="ai-compose-footer">
|
||||
<small>{t('ins.hint')}</small>
|
||||
<button className="btn primary" disabled={!text.trim()} onClick={compose}><Icon name="calendar" />{t('ins.compose')}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="prompt-chips" style={{ paddingTop: 12 }}>
|
||||
{EXAMPLES.map(key => <button key={key} onClick={() => { setText(t(key)); setRules(null); setOutcomes(null) }}>{t(key)}</button>)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{rules && (
|
||||
<div className="card" style={{ marginTop: 22 }}>
|
||||
<div className="card-head"><h3>{t('ins.previewTitle')}</h3><span className="badge neutral">{t('ins.readyCount', { n: ready.length })}</span></div>
|
||||
{rules.length === 0 && (
|
||||
<div className="empty"><h3>{t('ins.notUnderstood')}</h3><p>{t('ins.notUnderstoodCopy')}</p></div>
|
||||
)}
|
||||
<div className="rule-list">
|
||||
{rules.map((rule, i) => <RuleCard key={i} rule={rule} onChange={p => update(i, p)} onRemove={() => remove(i)} />)}
|
||||
</div>
|
||||
{unknown.length > 0 && (
|
||||
<div className="notice-row" style={{ margin: '0 22px 18px' }}>
|
||||
<Icon name="info" /><span>{t('ins.skipped', { parts: unknown.map(u => `“${u}”`).join(', ') })}</span>
|
||||
</div>
|
||||
)}
|
||||
{ready.length > 0 && (
|
||||
<div className="rule-approve">
|
||||
<small>{t('ins.approveNote')}</small>
|
||||
<button className="btn primary" disabled={saving} onClick={approve}>
|
||||
<Icon name="check" />{saving ? t('ins.approving') : t('ins.approve', { n: ready.length })}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{outcomes && (
|
||||
<ul className="rule-outcomes">
|
||||
{outcomes.map((o, i) => (
|
||||
<li key={i}>
|
||||
<span className={`status ${o.ok ? 'ready' : 'draft'}`}><i className="dot"></i>{o.ok ? t('ins.active') : t('ins.failed')}</span>
|
||||
<span>{describeRule(o.rule)}</span>
|
||||
{o.message && <span className="error">{o.message}</span>}
|
||||
</li>
|
||||
))}
|
||||
{outcomes.some(o => o.ok) && <li><Link className="btn small" to="/schedule">{t('ins.viewSchedule')} <Icon name="arrow-right" /></Link></li>}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function RuleCard({ rule, onChange, onRemove }: {
|
||||
rule: Rule
|
||||
onChange: (patch: Partial<ContentRule> | Partial<ResearchRule>) => void
|
||||
onRemove: () => void
|
||||
}) {
|
||||
const video = rule.type === 'content' && rule.kind === 'video'
|
||||
const badCadence = video && videoCadence(rule) === null
|
||||
const tooMany = rule.type === 'content' && !video && weeklyTarget(rule) > 50
|
||||
// A video's credit limit follows today's price of one video.
|
||||
const quote = useQuery({ queryKey: ['video-quote'], queryFn: videoQuote, enabled: video, staleTime: 5 * 60_000 })
|
||||
const budget = canSchedule(rule) && (!video || quote.data) ? budgetFor(rule, quote.data?.max_credits) : null
|
||||
|
||||
return (
|
||||
<div className={`rule-card${badCadence ? ' muted' : ''}`}>
|
||||
<span className="rule-icon"><Icon name={ruleIcon(rule)} /></span>
|
||||
<div className="rule-body">
|
||||
<b>{describeRule(rule)}</b>
|
||||
<div className="rule-fields">
|
||||
{rule.type === 'content' ? (
|
||||
<>
|
||||
<select aria-label={t('ins.kind')} value={rule.kind} onChange={e => onChange({ kind: e.target.value as ContentKind })}>
|
||||
{KINDS.map(k => <option key={k} value={k}>{kindLabel(k)}</option>)}
|
||||
</select>
|
||||
<input aria-label={t('ins.count')} type="number" min={1} max={50} value={rule.count}
|
||||
onChange={e => onChange({ count: Math.max(1, Number(e.target.value) || 1) })} />
|
||||
<select aria-label={t('ins.frequency')} value={rule.per} onChange={e => onChange({ per: e.target.value as ContentRule['per'] })}>
|
||||
<option value="day">{t('ins.perDay')}</option>
|
||||
<option value="week">{t('ins.perWeek')}</option>
|
||||
</select>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<select aria-label={t('ins.researchFrequency')} value={rule.cadence} onChange={e => onChange({ cadence: e.target.value as ResearchRule['cadence'] })}>
|
||||
<option value="daily">{t('ins.everyDay')}</option>
|
||||
<option value="weekly">{t('ins.everyWeek')}</option>
|
||||
</select>
|
||||
{rule.cadence === 'weekly' && (
|
||||
<select aria-label={t('ins.weekday')} value={rule.weekday} onChange={e => onChange({ weekday: Number(e.target.value) })}>
|
||||
{[0, 1, 2, 3, 4, 5, 6].map(d => <option key={d} value={d}>{dayName(d)}</option>)}
|
||||
</select>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<input aria-label={t('ins.topic')} className="rule-topic" value={rule.topic} placeholder={t('ins.topicPlaceholder')}
|
||||
onChange={e => onChange({ topic: e.target.value })} />
|
||||
</div>
|
||||
{rule.type === 'research' && (
|
||||
<label className="rule-check">
|
||||
<input type="checkbox" checked={rule.autoApprove} onChange={e => onChange({ autoApprove: e.target.checked })} />
|
||||
{t('ins.autoApprove')}
|
||||
</label>
|
||||
)}
|
||||
{rule.type === 'content' && KIND_NOTE[rule.kind] && <small className="rule-note">{t(KIND_NOTE[rule.kind]!, { seconds: VIDEO.seconds, scenes: VIDEO.scenes })}</small>}
|
||||
{badCadence && <small className="error">{t('ins.videoCadence')} {t('ins.notCreated')}</small>}
|
||||
{video && quote.isError && <small className="error">{t('ins.videoQuoteFailed', { message: quote.error.message })}</small>}
|
||||
{tooMany && <small className="error">{t('ins.tooMany')}</small>}
|
||||
{rule.type === 'content' && rule.monthly && <small className="rule-note">{monthNote(rule)}</small>}
|
||||
{budget && (
|
||||
<small className="rule-note">
|
||||
{t('ins.budget', {
|
||||
first: budget.weekly ? t('ins.budgetWeekly', { n: budget.weekly }) : t(video ? 'ins.budgetVideo' : 'ins.budgetRun', { n: budget.run }),
|
||||
monthly: budget.monthly,
|
||||
})}
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
<button className="icon-btn" aria-label={t('ins.removeRule')} onClick={onRemove}><Icon name="trash" /></button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
423
src/pages/Results.tsx
Normal file
423
src/pages/Results.tsx
Normal file
@@ -0,0 +1,423 @@
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { AudioPlayer } from '../components/AudioPlayer'
|
||||
import { Icon, KIND_ICON, type IconName } from '../components/Icon'
|
||||
import { Empty, PageHead, SkeletonCards, SkeletonLines, SkeletonRows } from '../components/Page'
|
||||
import { toast } from '../components/Toast'
|
||||
import { describeRule, kindLabel, type ContentKind } from '../express/parse'
|
||||
import {
|
||||
decideMaterial, download, exportPptx, fileName, isWorking, printPdf, listOf, listResults, materialContent, materialKey, questionsOf, reviewSource, slidesOf, textOf,
|
||||
titleOf, toMarkdown, type FoundSource, type Material, type MaterialContent, type MaterialStatus,
|
||||
} from '../express/results'
|
||||
import { MASTERY, scormPackage } from '../express/scorm'
|
||||
import { getLang, locale, t, type Key } from '../i18n'
|
||||
|
||||
const STUDIO_URL: string = import.meta.env.VITE_STUDIO_URL
|
||||
|
||||
// Setup fields of roleplay and simulator content, in display order.
|
||||
const FIELDS = ['description', 'topic', 'objective', 'learning_objectives', 'user_role', 'ai_character', 'difficulty', 'context'] as const
|
||||
|
||||
type StatusFilter = 'all' | 'review' | 'approved' | 'working' | 'failed'
|
||||
const STATUS_GROUP: Record<MaterialStatus, StatusFilter> = {
|
||||
preparing: 'working', generating: 'working', ready: 'review', accepted: 'approved', failed: 'failed', rejected: 'failed',
|
||||
}
|
||||
|
||||
const fmt = (iso: string) => new Intl.DateTimeFormat(locale(), { day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' }).format(new Date(iso))
|
||||
|
||||
export function ResultsPage() {
|
||||
const results = useQuery({
|
||||
queryKey: ['results'],
|
||||
queryFn: listResults,
|
||||
refetchInterval: q => (q.state.data?.materials.some(isWorking) ? 10_000 : 60_000),
|
||||
})
|
||||
const [tab, setTab] = useState<'content' | 'research'>('content')
|
||||
const [query, setQuery] = useState('')
|
||||
const [kind, setKind] = useState<ContentKind | 'all'>('all')
|
||||
const [status, setStatus] = useState<StatusFilter>('all')
|
||||
const [openId, setOpenId] = useState<string | null>(null)
|
||||
|
||||
const materials = results.data?.materials ?? []
|
||||
const sources = results.data?.sources ?? []
|
||||
const kinds = useMemo(() => [...new Set(materials.map(m => m.kind))], [materials])
|
||||
const needle = query.trim().toLowerCase()
|
||||
const shownMaterials = materials.filter(m => (kind === 'all' || m.kind === kind) && (status === 'all' || STATUS_GROUP[m.status] === status)
|
||||
&& (!needle || titleOf(m).toLowerCase().includes(needle)))
|
||||
const shownSources = sources.filter(s => !needle || s.title.toLowerCase().includes(needle) || s.url?.toLowerCase().includes(needle))
|
||||
const open = materials.find(m => materialKey(m) === openId) ?? null
|
||||
const pending = sources.filter(s => s.status === 'pending').length
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={t('res.title')} description={t('res.description')} />
|
||||
<div className="result-tabs" role="tablist">
|
||||
<button role="tab" aria-selected={tab === 'content'} className={tab === 'content' ? 'active' : undefined} onClick={() => setTab('content')}>
|
||||
{t('res.tabContent')} <b>{materials.length}</b>
|
||||
</button>
|
||||
<button role="tab" aria-selected={tab === 'research'} className={tab === 'research' ? 'active' : undefined} onClick={() => setTab('research')}>
|
||||
{t('res.tabResearch')} <b>{pending ? t('res.pendingCount', { n: pending }) : sources.length}</b>
|
||||
</button>
|
||||
</div>
|
||||
<section className="card">
|
||||
<div className="toolbar">
|
||||
<h3>{tab === 'content' ? t('res.allContent') : t('res.foundSources')} <span className="badge neutral">{tab === 'content' ? shownMaterials.length : shownSources.length}</span></h3>
|
||||
<div className="toolbar-controls">
|
||||
<div className="search"><Icon name="search" />
|
||||
<input aria-label={t('res.search')} placeholder={t('res.search')} value={query} onChange={e => setQuery(e.target.value)} />
|
||||
</div>
|
||||
{tab === 'content' && (
|
||||
<>
|
||||
<select aria-label={t('res.filterKind')} value={kind} onChange={e => setKind(e.target.value as ContentKind | 'all')}>
|
||||
<option value="all">{t('res.allKinds')}</option>
|
||||
{kinds.map(k => <option key={k} value={k}>{kindLabel(k)}</option>)}
|
||||
</select>
|
||||
<select aria-label={t('res.filterStatus')} value={status} onChange={e => setStatus(e.target.value as StatusFilter)}>
|
||||
{(['all', 'review', 'approved', 'working', 'failed'] as const).map(s => <option key={s} value={s}>{t(`res.status.${s}` as Key)}</option>)}
|
||||
</select>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{results.isPending && (tab === 'content' ? <SkeletonCards label={t('common.loading')} /> : <SkeletonRows label={t('common.loading')} />)}
|
||||
{results.isError && !results.data && <Empty icon="info" title={t('res.loadFailed')}>{results.error.message}</Empty>}
|
||||
{results.data && tab === 'content' && (
|
||||
shownMaterials.length
|
||||
? <div className="content-grid">{shownMaterials.map(m => <ContentCard key={materialKey(m)} material={m} onOpen={() => setOpenId(materialKey(m))} />)}</div>
|
||||
: <NoResults filtered={materials.length > 0} icon="layers" />
|
||||
)}
|
||||
{results.data && tab === 'research' && (
|
||||
shownSources.length
|
||||
? <div className="research-list">{shownSources.map(s => <SourceRow key={`${s.agentId}-${s.id}`} source={s} />)}</div>
|
||||
: <NoResults filtered={sources.length > 0} icon="search" />
|
||||
)}
|
||||
<div className="table-note">{tab === 'content' ? t('res.noteContent') : t('res.noteResearch')}</div>
|
||||
</section>
|
||||
{open && <Preview material={open} onClose={() => setOpenId(null)} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function NoResults({ filtered, icon }: { filtered: boolean; icon: IconName }) {
|
||||
if (filtered) return <Empty icon="search" title={t('res.noMatch')}>{t('res.noMatchCopy')}</Empty>
|
||||
return (
|
||||
<div className="empty">
|
||||
<span className="empty-icon"><Icon name={icon} /></span>
|
||||
<h3>{t('res.empty')}</h3>
|
||||
<p>{t('res.emptyCopy')}</p>
|
||||
<Link className="btn light" to="/instruction"><Icon name="plus" />{t('sch.newInstruction')}</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: MaterialStatus }) {
|
||||
const group = STATUS_GROUP[status]
|
||||
const tone = group === 'approved' ? 'ready' : group === 'failed' ? 'failed' : 'draft'
|
||||
return <span className={`status ${tone}`}><i className="dot"></i>{t(`res.state.${status}` as Key)}</span>
|
||||
}
|
||||
|
||||
function ContentCard({ material: m, onOpen }: { material: Material; onOpen: () => void }) {
|
||||
const working = isWorking(m)
|
||||
return (
|
||||
<article className="content-card">
|
||||
<div className={`content-cover kind-${m.kind}`}>
|
||||
<Icon name={KIND_ICON[m.kind]} /><span>{kindLabel(m.kind)}</span>
|
||||
<small>{m.rule.topic ? m.rule.topic.toUpperCase() : 'EXPRESS'}</small>
|
||||
</div>
|
||||
<div className="content-card-body">
|
||||
<div className="content-card-top"><span className="type-badge"><Icon name={KIND_ICON[m.kind]} />{kindLabel(m.kind)}</span><StatusBadge status={m.status} /></div>
|
||||
<button className="content-title" disabled={working} onClick={onOpen}>{titleOf(m)}</button>
|
||||
<p>{t(m.sources.length === 1 ? 'res.sourceOne' : 'res.sourceCount', { n: m.sources.length })} · {fmt(m.createdAt)}</p>
|
||||
<div className="content-card-foot">
|
||||
<span title={describeRule(m.rule)}>{describeRule(m.rule)}</span>
|
||||
{working
|
||||
? <span>{m.video ? t('res.videoProgress', { n: m.video.progress }) : t('res.working')}</span>
|
||||
: <button className="text-btn" onClick={onOpen}>{t('res.open')} <Icon name="arrow-right" /></button>}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
function Preview({ material: m, onClose }: { material: Material; onClose: () => void }) {
|
||||
const ref = useRef<HTMLDialogElement>(null)
|
||||
const queryClient = useQueryClient()
|
||||
// Every part's revision, so a SCORM module refetches when only its quiz changes.
|
||||
const content = useQuery({ queryKey: ['material', materialKey(m), m.parts.map(p => p.revision).join('.')], queryFn: () => materialContent(m) })
|
||||
const decide = useMutation({
|
||||
mutationFn: (decision: 'accept' | 'reject') => decideMaterial(m, decision),
|
||||
onSuccess: (_, decision) => {
|
||||
toast(t(decision === 'accept' ? 'res.accepted' : 'res.rejected'))
|
||||
queryClient.invalidateQueries({ queryKey: ['results'] })
|
||||
},
|
||||
})
|
||||
|
||||
// No close() on cleanup: its async "close" event would call onClose, and StrictMode's
|
||||
// mount → cleanup → mount in dev would then shut the dialog right after it opened.
|
||||
// Unmounting removes the dialog element, which ends the modal anyway.
|
||||
useEffect(() => {
|
||||
if (ref.current && !ref.current.open) ref.current.showModal()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<dialog ref={ref} className="preview" aria-labelledby="preview-title" onClose={onClose}
|
||||
onClick={e => e.target === ref.current && ref.current.close()}>
|
||||
<div className="modal-head">
|
||||
<div><h2 id="preview-title">{titleOf(m)}</h2><p>{describeRule(m.rule)}</p></div>
|
||||
<button className="icon-btn" aria-label={t('res.close')} onClick={() => ref.current?.close()}><Icon name="close" /></button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
{m.status === 'ready' && <div className="preview-notice"><Icon name="info" /><span>{t('res.reviewNotice')}</span></div>}
|
||||
{m.kind === 'audio' && <div className="preview-notice concept"><Icon name="info" /><span>{t('res.audioNotice')}</span></div>}
|
||||
<div className="preview-layout">
|
||||
<div>
|
||||
{content.isPending && <SkeletonLines count={6} label={t('common.loading')} />}
|
||||
{content.isError && <Empty icon="info" title={t('res.loadFailed')}>{content.error.message}</Empty>}
|
||||
{content.isSuccess && (content.data.content ? <Body material={m} content={content.data.content} /> : <Empty icon="info" title={t('res.noContent')}>{t('res.noContentCopy')}</Empty>)}
|
||||
</div>
|
||||
<aside className="preview-side">
|
||||
<h3>{t('res.info')}</h3>
|
||||
<span className="type-badge"><Icon name={KIND_ICON[m.kind]} />{kindLabel(m.kind)}</span>
|
||||
<div className="detail-label">{t('res.statusLabel')}</div><StatusBadge status={m.status} />
|
||||
<div className="detail-label">{t('res.schedule')}</div><b>{describeRule(m.rule)}</b>
|
||||
<div className="detail-label">{t('res.created')}</div><b>{fmt(m.createdAt)}</b>
|
||||
{m.video?.credits != null && m.video.credits > 0 && <><div className="detail-label">{t('res.creditsUsed')}</div><b>{t('common.credits', { n: m.video.credits })}</b></>}
|
||||
<div className="detail-label">{t('res.sources')}</div>
|
||||
{m.sources.length ? m.sources.map(s => <div key={s} className="preview-source"><Icon name="file" />{s}</div>) : <b>–</b>}
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-foot">
|
||||
{decide.error ? <small className="error">{decide.error.message}</small> : <small>{t('res.footNote')}</small>}
|
||||
{m.composeSetId && <a className="btn" href={`${STUDIO_URL}/compose/${m.composeSetId}`} target="_blank" rel="noreferrer">{t('res.openStudio')}</a>}
|
||||
{content.data?.content && <DownloadMenu material={m} content={content.data.content} outputId={content.data.outputId} />}
|
||||
{m.status === 'ready' && (
|
||||
<>
|
||||
<button className="btn danger" disabled={decide.isPending} onClick={() => decide.mutate('reject')}>{t('res.reject')}</button>
|
||||
<button className="btn primary" disabled={decide.isPending} onClick={() => decide.mutate('accept')}><Icon name="check" />{t('res.accept')}</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</dialog>
|
||||
)
|
||||
}
|
||||
|
||||
// What a learner sees in a SCORM package: the lesson without the answer key (the quiz is interactive there).
|
||||
function lessonHtml(m: Material, content: MaterialContent): string {
|
||||
if (m.kind === 'quiz') return ''
|
||||
if (m.kind === 'summary' || m.kind === 'audio' || m.kind === 'scorm') {
|
||||
return renderToStaticMarkup(<article className="prose"><Paragraphs text={summaryText(m, content)} /></article>)
|
||||
}
|
||||
return renderToStaticMarkup(<Body material={m} content={content} />)
|
||||
}
|
||||
|
||||
function scorm(m: Material, content: MaterialContent) {
|
||||
const blob = scormPackage({
|
||||
title: textOf(content.title) || titleOf(m),
|
||||
lessonHtml: lessonHtml(m, content),
|
||||
questions: m.kind === 'quiz' || m.kind === 'scorm' ? questionsOf(content) : [],
|
||||
lang: getLang(),
|
||||
labels: {
|
||||
quiz: t('res.quiz'), submit: t('scorm.submit'), retry: t('scorm.retry'), complete: t('scorm.complete'),
|
||||
completed: t('scorm.completed'), score: t('scorm.score'), passed: t('scorm.passed', { n: MASTERY }),
|
||||
failed: t('scorm.failed', { n: MASTERY }), answerAll: t('scorm.answerAll'), noLms: t('scorm.noLms'),
|
||||
},
|
||||
})
|
||||
download(fileName(m, 'zip'), blob)
|
||||
toast(t('res.scormReady'))
|
||||
}
|
||||
|
||||
function DownloadMenu({ material: m, content, outputId }: { material: Material; content: MaterialContent; outputId: number | null }) {
|
||||
const ref = useRef<HTMLDetailsElement>(null)
|
||||
const pptx = useMutation({
|
||||
mutationFn: () => exportPptx(outputId!),
|
||||
onSuccess: blob => download(fileName(m, 'pptx'), blob),
|
||||
onError: e => toast(e.message, { error: true }),
|
||||
})
|
||||
const close = () => ref.current?.removeAttribute('open')
|
||||
|
||||
if (m.kind === 'video') {
|
||||
return m.video?.url
|
||||
? <a className="btn" href={m.video.url} download={fileName(m, 'mp4')}><Icon name="download" />{t('res.downloadVideo')}</a>
|
||||
: null
|
||||
}
|
||||
|
||||
const scormItem = (
|
||||
<button role="menuitem" onClick={() => { close(); scorm(m, content) }}>
|
||||
<b>SCORM 1.2 (.zip)</b><small>{t(m.kind === 'scorm' || m.kind === 'quiz' ? 'res.scormQuizHint' : 'res.scormHint')}</small>
|
||||
</button>
|
||||
)
|
||||
|
||||
const pdf = () => {
|
||||
const title = textOf(content.title) || titleOf(m)
|
||||
const meta = [describeRule(m.rule), fmt(m.createdAt)].join(' · ')
|
||||
const html = renderToStaticMarkup(
|
||||
<>
|
||||
<header><small>Learning Lab Express</small><h1>{title}</h1><p>{meta}</p></header>
|
||||
{/* The audio player is for the screen; on paper the script is the lesson. */}
|
||||
{m.kind === 'audio' ? <article className="prose"><Paragraphs text={summaryText(m, content)} /></article> : <Body material={m} content={content} />}
|
||||
{!!m.sources.length && <footer>{t('res.sources')}: {m.sources.join(', ')}</footer>}
|
||||
</>,
|
||||
)
|
||||
printPdf(title, html)
|
||||
}
|
||||
|
||||
return (
|
||||
<details className="download-menu" ref={ref}>
|
||||
<summary className="btn"><Icon name="download" />{pptx.isPending ? t('res.preparingFile') : t('res.download')}</summary>
|
||||
<div role="menu">
|
||||
{m.kind === 'scorm' && scormItem}
|
||||
{m.kind === 'audio' && (
|
||||
<button role="menuitem" onClick={() => { close(); download(fileName(m, 'txt'), new Blob([summaryText(m, content)], { type: 'text/plain;charset=utf-8' })) }}>
|
||||
<b>{t('res.scriptTxt')}</b><small>{t('res.scriptHint')}</small>
|
||||
</button>
|
||||
)}
|
||||
<button role="menuitem" onClick={() => { close(); pdf() }}><b>PDF</b><small>{t('res.pdfHint')}</small></button>
|
||||
{m.kind === 'slide_deck' && outputId && (
|
||||
<button role="menuitem" disabled={pptx.isPending} onClick={() => { close(); pptx.mutate() }}><b>PowerPoint (.pptx)</b><small>{t('res.pptxHint')}</small></button>
|
||||
)}
|
||||
<button role="menuitem" onClick={() => { close(); download(fileName(m, 'md'), toMarkdown(m, content)) }}><b>Markdown (.md)</b><small>{t('res.mdHint')}</small></button>
|
||||
{m.kind !== 'scorm' && scormItem}
|
||||
</div>
|
||||
</details>
|
||||
)
|
||||
}
|
||||
|
||||
function Paragraphs({ text }: { text: string }) {
|
||||
// Generated text is light Markdown: headings, bullets and paragraphs are enough to read it.
|
||||
const blocks: ReactNode[] = []
|
||||
let bullets: string[] = []
|
||||
const flush = () => { if (bullets.length) blocks.push(<ul key={blocks.length}>{bullets.map((b, i) => <li key={i}>{b}</li>)}</ul>); bullets = [] }
|
||||
for (const line of text.split('\n').map(l => l.trim())) {
|
||||
if (/^[-*•]\s+/.test(line)) { bullets.push(line.replace(/^[-*•]\s+/, '').replace(/\*\*/g, '')); continue }
|
||||
flush()
|
||||
if (!line) continue
|
||||
const heading = line.match(/^#{1,6}\s+(.*)/)
|
||||
blocks.push(heading ? <h2 key={blocks.length}>{heading[1]}</h2> : <p key={blocks.length}>{line.replace(/\*\*/g, '')}</p>)
|
||||
}
|
||||
flush()
|
||||
return <>{blocks}</>
|
||||
}
|
||||
|
||||
// Generated summaries usually open with their own title as a heading; it is already the h1.
|
||||
function summaryText(m: Material, content: MaterialContent) {
|
||||
const title = textOf(content.title) || titleOf(m)
|
||||
return textOf(content.text).replace(/^\s*#{1,6}\s+(.+)\n/, (line, heading: string) =>
|
||||
heading.trim().toLowerCase() === title.trim().toLowerCase() ? '' : line)
|
||||
}
|
||||
|
||||
function QuizList({ content }: { content: MaterialContent }) {
|
||||
return (
|
||||
<ol className="quiz-list">
|
||||
{questionsOf(content).map((q, i) => (
|
||||
<li key={i}><b>{q.question}</b>
|
||||
{!!q.options.length && <ul>{q.options.map((o, j) => <li key={j} className={j === q.correct ? 'correct' : undefined}>{o}</li>)}</ul>}
|
||||
{q.answer && q.correct < 0 && <p><b>{t('res.answer')}:</b> {q.answer}</p>}
|
||||
{q.explanation && <p className="muted">{q.explanation}</p>}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)
|
||||
}
|
||||
|
||||
function Body({ material: m, content }: { material: Material; content: MaterialContent }) {
|
||||
const title = textOf(content.title) || titleOf(m)
|
||||
if (m.kind === 'video') {
|
||||
if (m.video?.url) return <article className="prose"><h1>{title}</h1><video className="source-player" controls preload="metadata" src={m.video.url} /></article>
|
||||
if (m.status === 'failed') return <Empty icon="video" title={t('res.videoFailed')}>{t('res.videoFailedCopy')}</Empty>
|
||||
return <Empty icon="video" title={t('res.videoNotReady')}>{t('res.videoProgress', { n: m.video?.progress ?? 0 })}</Empty>
|
||||
}
|
||||
if (m.kind === 'summary') {
|
||||
return <article className="prose"><h1>{title}</h1><Paragraphs text={summaryText(m, content)} /></article>
|
||||
}
|
||||
if (m.kind === 'audio') {
|
||||
const text = summaryText(m, content)
|
||||
return (
|
||||
<>
|
||||
<AudioPlayer title={title} text={text} />
|
||||
<details className="script-details" style={{ marginTop: 20 }}><summary>{t('res.fullScript')}</summary>
|
||||
<article className="prose"><Paragraphs text={text} /></article>
|
||||
</details>
|
||||
</>
|
||||
)
|
||||
}
|
||||
if (m.kind === 'scorm') {
|
||||
const questions = questionsOf(content).length
|
||||
return (
|
||||
<article className="prose scorm-preview">
|
||||
<div className="scorm-preview-head"><Icon name="scorm" /><div><b>{t('res.scormModule')}</b><small>{t('res.scormMeta', { n: questions, mastery: MASTERY })}</small></div></div>
|
||||
<h1>{title}</h1>
|
||||
<Paragraphs text={summaryText(m, content)} />
|
||||
<h2>{t('res.quiz')}</h2>
|
||||
{questions ? <QuizList content={content} /> : <p className="muted">{t('res.quizPending')}</p>}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
if (m.kind === 'quiz') {
|
||||
return <article className="prose"><h1>{title}</h1><QuizList content={content} /></article>
|
||||
}
|
||||
if (m.kind === 'flashcards') {
|
||||
return (
|
||||
<article className="prose"><h1>{title}</h1>
|
||||
<div className="flashcards">
|
||||
{(listOf(content.cards) as Record<string, unknown>[]).map((c, i) => (
|
||||
<div key={i} className="flashcard"><b>{textOf(c.front)}</b><span>{textOf(c.back)}</span></div>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
if (m.kind === 'slide_deck') {
|
||||
return (
|
||||
<article className="prose"><h1>{title}</h1>
|
||||
{!!content.description && <p>{textOf(content.description)}</p>}
|
||||
{slidesOf(content).map((s, i) => (
|
||||
<section key={i} className="slide-preview"><small>{t('res.slide', { n: i + 1 })}</small><h2>{s.title}</h2>
|
||||
{!!s.bullets.length && <ul>{s.bullets.map((b, j) => <li key={j}>{b}</li>)}</ul>}
|
||||
</section>
|
||||
))}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
// Roleplay and simulator: show their setup fields; the interactive versions live in Learning Studio.
|
||||
return (
|
||||
<article className="prose"><h1>{title}</h1>
|
||||
{FIELDS.map(k => [k, content[k]] as const).filter(([, v]) => v != null && v !== '').map(([k, v]) => (
|
||||
<div key={k}><h2>{t(`res.field.${k}`)}</h2>
|
||||
{Array.isArray(v) ? <ul>{v.map((x, i) => <li key={i}>{textOf(x)}</li>)}</ul> : <Paragraphs text={textOf(v)} />}
|
||||
</div>
|
||||
))}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
function SourceRow({ source: s }: { source: FoundSource }) {
|
||||
const queryClient = useQueryClient()
|
||||
const review = useMutation({
|
||||
mutationFn: (decision: 'accept' | 'reject') => reviewSource(s, decision),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['results'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['documents'] })
|
||||
},
|
||||
})
|
||||
const tone = s.status === 'accepted' ? 'ready' : s.status === 'pending' ? 'draft' : 'failed'
|
||||
const label = ['pending', 'accepted', 'rejected'].includes(s.status) ? t(`res.source.${s.status}` as Key) : s.status
|
||||
return (
|
||||
<article className="research-row">
|
||||
<span><Icon name="search" /></span>
|
||||
<div>
|
||||
<span className={`status ${tone}`}><i className="dot"></i>{label}</span>
|
||||
<h3>{s.url ? <a href={s.url} target="_blank" rel="noreferrer">{s.title}</a> : s.title}</h3>
|
||||
<p>{describeRule(s.rule)} · {fmt(s.createdAt)}{s.status === 'accepted' && <> · <Link to="/brain">{t('res.inBrain')}</Link></>}</p>
|
||||
{s.preview && <p className="source-preview">{s.preview}</p>}
|
||||
{review.error && <span className="error">{review.error.message}</span>}
|
||||
</div>
|
||||
{s.status === 'pending' && (
|
||||
<div className="research-actions">
|
||||
<button className="btn small danger" disabled={review.isPending} onClick={() => review.mutate('reject')}>{t('res.reject')}</button>
|
||||
<button className="btn small primary" disabled={review.isPending} onClick={() => review.mutate('accept')}>{t('res.addToBrain')}</button>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
222
src/pages/Schedule.tsx
Normal file
222
src/pages/Schedule.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Icon, ruleIcon } from '../components/Icon'
|
||||
import { Empty, PageHead, SkeletonLines, SkeletonRows } from '../components/Page'
|
||||
import { toast } from '../components/Toast'
|
||||
import { describeRule } from '../express/parse'
|
||||
import { locale, t, type Key } from '../i18n'
|
||||
import {
|
||||
archiveSchedule, confirmProduction, isRunSchedule, listRuns, listSchedules, pauseSchedule, PLAN_ACTIVE, productionStatus, quoteProduction, resumeSchedule,
|
||||
planProgress, runNow as startRun, updateTopic, type ContentPlan, type Schedule,
|
||||
} from '../express/schedules'
|
||||
|
||||
const fmt = (iso: string | null | undefined) => (iso
|
||||
? new Intl.DateTimeFormat(locale(), { weekday: 'short', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }).format(new Date(iso))
|
||||
: '–')
|
||||
|
||||
const RUN_STATUSES = new Set(['queued', 'planning', 'running', 'completed', 'failed', 'cancelled'])
|
||||
const runStatus = (s: string) => (RUN_STATUSES.has(s) ? t(`run.${s}` as Key) : s)
|
||||
|
||||
export function SchedulePage() {
|
||||
const schedules = useQuery({ queryKey: ['schedules'], queryFn: listSchedules, refetchInterval: 30_000 })
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={t('sch.title')} description={t('sch.description')}
|
||||
actions={<Link className="btn primary" to="/instruction"><Icon name="plus" />{t('sch.newInstruction')}</Link>} />
|
||||
<div className="card">
|
||||
<div className="card-head"><h3>{t('sch.cardTitle')}</h3><Icon name="calendar" /></div>
|
||||
{schedules.isPending && <SkeletonRows label={t('sch.loading')} />}
|
||||
{schedules.isError && <Empty icon="info" title={t('sch.loadFailed')}>{schedules.error.message}</Empty>}
|
||||
{schedules.data?.length === 0 && <Empty icon="calendar" title={t('sch.empty')}>{t('sch.emptyCopy')}</Empty>}
|
||||
{!!schedules.data?.length && <div className="result-list">{schedules.data.map(s => <ScheduleRow key={s.agent.id} schedule={s} />)}</div>}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ScheduleRow({ schedule }: { schedule: Schedule }) {
|
||||
const { agent, rule, active } = schedule
|
||||
const queryClient = useQueryClient()
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [topic, setTopic] = useState(rule.topic)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [producing, setProducing] = useState(false)
|
||||
const refresh = () => queryClient.invalidateQueries({ queryKey: ['schedules'] })
|
||||
const video = rule.type === 'content' && rule.kind === 'video'
|
||||
// Content kinds run as weekly production plans; research and video as one run per slot.
|
||||
const production = !isRunSchedule(rule)
|
||||
|
||||
const toggle = useMutation({ mutationFn: () => (active ? pauseSchedule(agent.id) : resumeSchedule(agent.id)), onSuccess: refresh })
|
||||
const retopic = useMutation({ mutationFn: () => updateTopic(schedule, topic), onSuccess: () => { setEditing(false); refresh() } })
|
||||
const archive = useMutation({ mutationFn: () => archiveSchedule(agent.id), onSuccess: () => { toast(t('sch.deleted')); refresh() } })
|
||||
const runNow = useMutation({
|
||||
mutationFn: () => startRun(agent.id),
|
||||
onSuccess: () => {
|
||||
toast(t(video ? 'sch.videoStarted' : 'sch.runStarted'))
|
||||
setOpen(true)
|
||||
queryClient.invalidateQueries({ queryKey: ['runs', agent.id] })
|
||||
queryClient.invalidateQueries({ queryKey: ['results'] })
|
||||
},
|
||||
})
|
||||
const error = toggle.error ?? retopic.error ?? archive.error ?? runNow.error
|
||||
const busy = toggle.isPending || retopic.isPending || archive.isPending || runNow.isPending
|
||||
|
||||
return (
|
||||
<div className="schedule-row">
|
||||
<div className="result-row">
|
||||
<span className="rule-icon"><Icon name={ruleIcon(rule)} /></span>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<b>{describeRule(rule)}</b>
|
||||
<div><small>{production
|
||||
? <ProductionLine agentId={agent.id} active={active} />
|
||||
: t(video ? 'sch.nextVideo' : 'sch.nextResearch', { when: active ? fmt(agent.next_run_at) : t('sch.paused') })}</small></div>
|
||||
{editing && (
|
||||
<form className="topic-edit" onSubmit={e => { e.preventDefault(); retopic.mutate() }}>
|
||||
<input aria-label={t('sch.newTopic')} value={topic} placeholder={t('ins.topicPlaceholder')} autoFocus onChange={e => setTopic(e.target.value)} />
|
||||
<button className="btn small primary" disabled={busy}>{t('common.save')}</button>
|
||||
<button type="button" className="btn small" onClick={() => { setEditing(false); setTopic(rule.topic) }}>{t('common.cancel')}</button>
|
||||
</form>
|
||||
)}
|
||||
{error && <span className="error">{error.message}</span>}
|
||||
</div>
|
||||
<span className={`status ${active ? 'ready' : 'draft'}`}><i className="dot"></i>{active ? t('sch.active') : t('sch.paused')}</span>
|
||||
</div>
|
||||
<div className="schedule-actions">
|
||||
{!editing && <button className="btn small" disabled={busy} onClick={() => setEditing(true)}>{t('sch.changeTopic')}</button>}
|
||||
<button className="btn small" disabled={busy} onClick={() => toggle.mutate()}>{active ? t('sch.pause') : t('sch.resume')}</button>
|
||||
{!production && active && <button className="btn small" disabled={busy} onClick={() => runNow.mutate()}>{t(video ? 'sch.videoNow' : 'sch.runNow')}</button>}
|
||||
{production && active && !producing && <button className="btn small" disabled={busy} onClick={() => setProducing(true)}>{t('sch.produceNow')}</button>}
|
||||
<button className="btn small" onClick={() => setOpen(o => !o)}>{open ? t('sch.hideHistory') : t('sch.history')}</button>
|
||||
<button className="btn small danger" disabled={busy}
|
||||
onClick={() => confirm(t('sch.deleteConfirm', { name: describeRule(rule) })) && archive.mutate()}>{t('sch.delete')}</button>
|
||||
</div>
|
||||
{production && !producing && <ProductionProgress agentId={agent.id} />}
|
||||
{producing && production && <ProducePanel schedule={schedule} onClose={() => setProducing(false)} />}
|
||||
{open && <RunHistory agentId={agent.id} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Content schedules report this week's progress from the engine's production period.
|
||||
const useProduction = (agentId: number) => useQuery({
|
||||
queryKey: ['production', agentId],
|
||||
queryFn: () => productionStatus(agentId),
|
||||
refetchInterval: q => (q.state.data?.plans.some(p => PLAN_ACTIVE.has(p.status)) ? 5000 : 60_000),
|
||||
})
|
||||
|
||||
function ProductionLine({ agentId, active }: { agentId: number; active: boolean }) {
|
||||
const status = useProduction(agentId)
|
||||
const s = status.data
|
||||
if (!s) return <>{status.isError ? status.error.message : t('sch.loadingProgress')}</>
|
||||
if (!s.planning_available) {
|
||||
return <span className="error">{t('sch.planningOff')}</span>
|
||||
}
|
||||
if (!active) return <>{t('sch.paused')}</>
|
||||
const p = s.period
|
||||
return <>{p ? t('sch.thisWeek', { ready: p.ready, target: p.target, failed: p.failed ? t('sch.thisWeekFailed', { n: p.failed }) : '' }) : t('sch.waitingFirst')}</>
|
||||
}
|
||||
|
||||
// Starts this week's production early: quote the planning step, show its price, then confirm it.
|
||||
// The latest production of this schedule, step by step: topics → creating → done.
|
||||
function ProductionProgress({ agentId }: { agentId: number }) {
|
||||
const plan = useProduction(agentId).data?.plans[0]
|
||||
if (!plan || plan.status === 'quoted' || plan.status === 'cancelled') return null
|
||||
const { total, ready, failed, shortfall } = planProgress(plan)
|
||||
const step = plan.status === 'planning' ? 0 : plan.status === 'running' || plan.status === 'paused' ? 2 : plan.status === 'completed' ? 3 : 1
|
||||
const steps = [t('prod.stepTopics'), t('prod.stepStart'), t('prod.stepCreate', { ready, total }), t('prod.stepDone')]
|
||||
const finished = plan.status === 'completed' || plan.status === 'failed'
|
||||
|
||||
return (
|
||||
<div className={`production-progress${finished ? ' finished' : ''}`} role="status" aria-live="polite">
|
||||
<div className="production-head">
|
||||
{!finished && <span className="spinner" aria-hidden="true" />}
|
||||
<b>{finished ? t('prod.lastTitle') : t('prod.runningTitle', { n: plan.target })}</b>
|
||||
{plan.status !== 'planning' && total > 0 && <span className="production-count">{t('prod.count', { ready, total })}</span>}
|
||||
</div>
|
||||
{plan.status === 'failed' ? (
|
||||
<span className="error">{t('prod.failed', { code: plan.error_code ?? '–' })}</span>
|
||||
) : (
|
||||
<ol className="production-steps">
|
||||
{steps.map((label, i) => (
|
||||
<li key={i} className={i < step ? 'done' : i === step ? (finished ? 'done' : 'current') : undefined}>{label}</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
{!!total && plan.status !== 'planning' && <div className="production-bar"><i style={{ width: `${Math.round(((ready + failed) / total) * 100)}%` }} /></div>}
|
||||
{(failed > 0 || (shortfall > 0 && plan.status !== 'planning')) && (
|
||||
<small>{[failed ? t('prod.someFailed', { n: failed }) : '', shortfall ? t('prod.shortfall', { n: shortfall }) : ''].filter(Boolean).join(' ')}</small>
|
||||
)}
|
||||
{(ready > 0 || plan.status === 'running') && <Link className="text-btn" to="/results">{t('prod.viewResults')} <Icon name="arrow-right" /></Link>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ProducePanel({ schedule: { agent, rule }, onClose }: { schedule: Schedule; onClose: () => void }) {
|
||||
const queryClient = useQueryClient()
|
||||
const status = useProduction(agent.id)
|
||||
const period = status.data?.period
|
||||
const remaining = period ? Math.max(0, period.target - period.claimed) : agent.content_goal?.weekly_target ?? 0
|
||||
const [count, setCount] = useState(rule.type === 'content' ? rule.count : 1)
|
||||
const n = Math.min(Math.max(1, count), Math.max(1, remaining))
|
||||
const quote = useMutation({ mutationFn: () => quoteProduction(agent.id, n) })
|
||||
const start = useMutation({
|
||||
mutationFn: (plan: ContentPlan) => confirmProduction(agent.id, plan),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['production', agent.id] })
|
||||
queryClient.invalidateQueries({ queryKey: ['results'] })
|
||||
onClose()
|
||||
},
|
||||
})
|
||||
const error = quote.error ?? start.error
|
||||
const running = status.data?.plans.some(p => PLAN_ACTIVE.has(p.status))
|
||||
|
||||
if (status.isPending) return <div className="produce-panel"><SkeletonLines label={t('sch.loadingProgress')} /></div>
|
||||
return (
|
||||
<div className="produce-panel">
|
||||
{running ? (
|
||||
<p>{t('sch.alreadyProducing')}</p>
|
||||
) : remaining === 0 ? (
|
||||
<p>{t('sch.weekFull')}</p>
|
||||
) : quote.data ? (
|
||||
<p>{t('sch.produceQuote', { n: quote.data.target, credits: quote.data.planning_quote.credits ?? 0, budget: agent.content_goal?.weekly_budget ?? 0 })}</p>
|
||||
) : (
|
||||
<label>{t('sch.produceHowMany', { max: remaining })}
|
||||
<input type="number" min={1} max={remaining} value={n} onChange={e => setCount(Number(e.target.value) || 1)} />
|
||||
</label>
|
||||
)}
|
||||
{error && <span className="error">{error.message}</span>}
|
||||
<div className="produce-actions">
|
||||
{!running && remaining > 0 && (quote.data
|
||||
? <button className="btn small primary" disabled={start.isPending} onClick={() => start.mutate(quote.data)}>{start.isPending ? t('sch.starting') : t('sch.startProduction')}</button>
|
||||
: <button className="btn small primary" disabled={quote.isPending} onClick={() => quote.mutate()}>{quote.isPending ? t('sch.checkingPrice') : t('sch.checkPrice')}</button>)}
|
||||
<button className="btn small" onClick={onClose}>{running || remaining === 0 ? t('res.close') : t('common.cancel')}</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RunHistory({ agentId }: { agentId: number }) {
|
||||
const runs = useQuery({
|
||||
queryKey: ['runs', agentId],
|
||||
queryFn: () => listRuns(agentId),
|
||||
refetchInterval: q => (q.state.data?.some(r => ['queued', 'planning', 'running'].includes(r.status)) ? 3000 : false),
|
||||
})
|
||||
if (runs.isPending) return <div className="run-history"><SkeletonLines count={3} label={t('sch.loadingHistory')} /></div>
|
||||
if (runs.isError) return <div className="run-history"><span className="error">{runs.error.message}</span></div>
|
||||
if (!runs.data.length) return <div className="run-history"><small>{t('sch.neverRan')}</small></div>
|
||||
return (
|
||||
<ul className="run-history">
|
||||
{runs.data.slice(0, 8).map(r => (
|
||||
<li key={r.id}>
|
||||
<span>{fmt(r.created_at)}</span>
|
||||
<span>{r.trigger_kind === 'scheduled' ? t('sch.scheduled') : t('sch.manual')}</span>
|
||||
<span className={`status ${r.status === 'completed' ? 'ready' : 'draft'}`}><i className="dot"></i>{runStatus(r.status)}</span>
|
||||
{r.budget_spent != null && <span>{t('common.credits', { n: r.budget_spent })}</span>}
|
||||
{r.safe_error_code && <span className="error">{r.safe_error_code}</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user