Files
learning-lab-express/src/pages/Instruction.tsx
Iqbal 643c3c39f9 Remove the Brain panel from the Brain and AI page
The page is back to the chat and the plan side by side. With nothing to
tick, new schedules always use the whole Brain.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 11:50:39 +07:00

348 lines
18 KiB
TypeScript

import { useEffect, useRef, 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 { choosable, useDocuments } from './Brain'
import {
dayName, describeRule, kindLabel, KINDS, LANGUAGES, languageLabel, monthNote, videoCadence, weeklyTarget,
type ContentKind, type ContentRule, type OutputLanguage, type ResearchRule, type Rule,
} from '../express/parse'
import { ask, chatLangOf, editRule, EMPTY, nextQuestion, removeRule, respond, type AgentState } from '../express/agent'
import { inLang, t, useLang, type Key, type Lang } from '../i18n'
import { budgetFor, canSchedule, createSchedule, MIN_PRODUCTION_SOURCES, needsTwoSources, VIDEO, videoQuote } from '../express/schedules'
const EXAMPLES: Key[] = ['ins.ex1', 'ins.ex2', 'ins.ex3', 'ins.ex4', 'ins.ex5']
const THINKING: Key[] = ['agent.think1', 'agent.think2', 'agent.think3']
// 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 }
type Message = { role: 'agent' | 'user'; text: string; outcomes?: Outcome[]; thought?: number; typed?: boolean }
// The agent's pace: it thinks before replying, types its reply out, and pauses before the next question.
// Users who ask for reduced motion get the replies at once.
const reducedMotion = () => typeof matchMedia !== 'undefined' && matchMedia('(prefers-reduced-motion: reduce)').matches
const thinkFor = (input: string) => (reducedMotion() ? 250 : Math.min(2200, 900 + input.length * 12) + Math.random() * 400)
const PAUSE = 650
type Phase = 'idle' | 'thinking' | 'replying' | 'pausing'
export function InstructionPage() {
const queryClient = useQueryClient()
const uiLang = useLang()
const [text, setText] = useState('')
const [agent, setAgent] = useState<AgentState>(EMPTY)
const [messages, setMessages] = useState<Message[]>([])
const [saving, setSaving] = useState(false)
const docs = useDocuments()
const [phase, setPhase] = useState<Phase>('idle')
// The live question whose typing has finished, so its reply options can show.
const [typedLive, setTypedLive] = useState('')
// The agent answers in the language the user writes in; until they write, in the UI language.
const [chatLang, setChatLang] = useState<Lang | null>(null)
const lang = chatLang ?? uiLang
const chat = useRef<HTMLDivElement>(null)
const timers = useRef<number[]>([])
const later = (fn: () => void, ms: number) => { timers.current.push(window.setTimeout(fn, ms)) }
useEffect(() => () => timers.current.forEach(clearTimeout), [])
const question = nextQuestion(agent)
const ready = agent.items.filter(i => canSchedule(i.rule))
// The whole Brain is attached, so every ready document counts.
const tooFewSources = choosable(docs.data).length < MIN_PRODUCTION_SOURCES && ready.some(i => needsTwoSources(i.rule))
const hasContent = agent.items.some(i => i.rule.type === 'content')
// The agent's current turn: its question, or what it is waiting for. It stays live, so an edit in the plan updates it.
const live = inLang(lang, () => saving
? { text: t('agent.approving'), options: [] as string[] }
: question
? ask(question, agent)
: agent.items.length
? {
text: hasContent && agent.language
? t('agent.readyLanguage', { n: ready.length, language: languageLabel(agent.language) })
: t('agent.ready', { n: ready.length }),
options: [t('agent.optApprove'), t('agent.optReset')] }
// The greeting comes once; after that the examples alone invite the next request.
: { text: messages.length ? '' : t('agent.greeting'), options: EXAMPLES.map(k => t(k)) })
const idle = phase === 'idle'
const showOptions = idle && !saving && live.options.length > 0 && (!live.text || typedLive === live.text)
// Scrolls only the chat, so the page itself does not jump while the user is reading the plan.
// A block body: newer browsers return a promise from scrollTo, which an effect must not return.
const scroll = () => { chat.current?.scrollTo({ top: chat.current.scrollHeight, behavior: 'smooth' }) }
useEffect(scroll, [messages.length, live.text, phase, showOptions])
function send(input: string) {
if (!input.trim() || saving || !idle) return
// A picked option is in the conversation's language already; typed text may switch it.
const replyLang = live.options.includes(input) ? lang : chatLangOf(input) ?? lang
setChatLang(replyLang)
const turn = inLang(replyLang, () => respond(agent, input))
// The question being answered is kept in the history, with the reply after it.
setMessages(ms => [...ms, ...(live.text ? [{ role: 'agent' as const, text: live.text }] : []), { role: 'user', text: input.trim() }])
setText('')
setPhase('thinking')
const ms = thinkFor(input)
later(() => {
setAgent(turn.state)
if (turn.approve) { setPhase('idle'); void approve(turn.state, replyLang); return }
setMessages(m => [...m, { role: 'agent', text: turn.reply, thought: Math.max(1, Math.round(ms / 1000)), typed: true }])
setPhase('replying')
}, ms)
}
// After a reply is typed out, a short pause before the next question appears.
function replied() {
setPhase('pausing')
later(() => setPhase('idle'), reducedMotion() ? 0 : PAUSE)
}
async function approve(state = agent, replyLang = lang) {
const items = state.items.filter(i => canSchedule(i.rule))
if (!items.length || tooFewSources) return
setSaving(true)
const done: Outcome[] = []
const created = new Set<number>()
// Approving from the plan before the agent asked about the language uses the conversation's language.
const language: OutputLanguage = state.language ?? (replyLang === 'id' ? 'Bahasa Indonesia' : 'English')
// One at a time so a failure (e.g. not an organization owner) is reported per schedule.
for (const { id, rule } of items) {
try {
await createSchedule(rule.type === 'content' ? { ...rule, language } : rule)
done.push({ rule, ok: true })
created.add(id)
} catch (e) {
done.push({ rule, ok: false, message: (e as Error).message })
}
}
setSaving(false)
// A new schedule may start producing right away; the dashboard shows it under content in progress.
await Promise.all([queryClient.invalidateQueries({ queryKey: ['schedules'] }), queryClient.invalidateQueries({ queryKey: ['results'] })])
const ok = done.filter(o => o.ok).length
const report = inLang(replyLang, () => (ok === done.length ? t('agent.done', { n: ok }) : t('agent.partial', { ok, n: done.length })))
setMessages(ms => [...ms, { role: 'agent', text: report, outcomes: done, typed: true }])
setPhase('replying')
// Only the failed rules stay, so approving again does not create the others twice.
setAgent(s => (ok === done.length ? EMPTY : { ...s, items: s.items.filter(i => !created.has(i.id)) }))
}
return (
<>
<PageHead title={t('ins.title')} description={t('ins.description')} />
<div className="ai-workspace">
<div className="card agent-chat">
<div className="card-head">
<div className="assistant-title">
<span><Icon name="spark" /></span>
<div><h3>{t('agent.name')}</h3><small>{phase === 'thinking' ? t('agent.statusThinking') : t('agent.status')}</small></div>
</div>
</div>
<div className="ai-messages" aria-live="polite" ref={chat}>
{messages.map((m, i) => (
<Bubble key={i} message={m} lang={lang} onTyped={i === messages.length - 1 && phase === 'replying' ? replied : undefined} onType={scroll} />
))}
{phase === 'thinking' && <Thinking lang={lang} />}
{phase === 'pausing' && <Typing />}
{idle && live.text && (
<Bubble key={live.text} message={{ role: 'agent', text: live.text, typed: true }} lang={lang} busy={saving}
onTyped={() => setTypedLive(live.text)} onType={scroll} />
)}
</div>
{showOptions && (
<div className="prompt-chips appear">
{live.options.map((o, i) => (
<button key={o} className={i === 0 && !question && agent.items.length ? 'primary' : undefined} onClick={() => send(o)}>{o}</button>
))}
</div>
)}
<form id="ai-request-form" onSubmit={e => { e.preventDefault(); send(text) }}>
<label htmlFor="instruction" className="sr-only">{t('ins.label')}</label>
<textarea id="instruction" rows={2} value={text} placeholder={t('agent.placeholder')} disabled={saving}
onChange={e => setText(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) { e.preventDefault(); send(text) } }} />
<div className="ai-compose-footer">
<small>{t('agent.hint')}</small>
<button className="btn primary" type="submit" disabled={!text.trim() || saving || !idle}><Icon name="arrow-right" />{t('agent.send')}</button>
</div>
</form>
</div>
<div className="card">
<div className="card-head"><h3>{t('ins.previewTitle')}</h3><span className="badge neutral">{t('ins.readyCount', { n: ready.length })}</span></div>
{agent.items.length === 0 && <div className="empty"><p>{t('agent.planEmpty')}</p></div>}
{hasContent && (
<label className="plan-language">
<span>{t('ins.language')}</span>
<select value={agent.language ?? ''} onChange={e => setAgent(s => ({ ...s, language: e.target.value as OutputLanguage }))}>
{!agent.language && <option value="" disabled>{t('ins.languagePending')}</option>}
{LANGUAGES.map(l => <option key={l} value={l}>{languageLabel(l)}</option>)}
</select>
</label>
)}
<div className="rule-list">
{agent.items.map(({ id, rule }) => (
<RuleCard key={id} rule={rule} onChange={p => setAgent(s => editRule(s, id, p))} onRemove={() => setAgent(s => removeRule(s, id))} />
))}
</div>
{ready.length > 0 && (
<div className="rule-approve">
<small>
<b className="rule-sources"><Icon name="brain" />{t('ins.sourcesAll')}</b>
{t('ins.approveNote')}
{tooFewSources && <span className="error">{t('ins.tooFewInBrain', { n: MIN_PRODUCTION_SOURCES })}</span>}
</small>
<button className="btn primary" disabled={saving || !idle || tooFewSources} onClick={() => approve()}>
<Icon name="check" />{saving ? t('ins.approving') : t('ins.approve', { n: ready.length })}
</button>
</div>
)}
</div>
</div>
</>
)
}
// Types the text out over at most ~1.2 s, then calls onDone once.
function useTyped(text: string, enabled: boolean, onDone?: () => void, onType?: () => void) {
const [shown, setShown] = useState(enabled && !reducedMotion() ? 0 : text.length)
const done = useRef(onDone)
done.current = onDone
const step = useRef(onType)
step.current = onType
useEffect(() => {
if (shown >= text.length) { done.current?.(); return }
const perTick = Math.max(1, Math.ceil(text.length / 60))
const id = window.setTimeout(() => { setShown(n => Math.min(text.length, n + perTick)); step.current?.() }, 20)
return () => clearTimeout(id)
}, [shown, text.length])
return text.slice(0, shown)
}
function Bubble({ message, lang, busy, onTyped, onType }: {
message: Message; lang: Lang; busy?: boolean; onTyped?: () => void; onType?: () => void
}) {
const text = useTyped(message.text, !!message.typed, onTyped, onType)
const typing = text.length < message.text.length
return (
<div className={`ai-message${message.role === 'user' ? ' user' : ''}`}>
{message.role === 'agent' && <span>{busy ? <i className="spinner" /> : <Icon name="spark" />}</span>}
<div className="ai-bubble">
{message.thought && <small className="ai-thought">{inLang(lang, () => t('agent.thought', { s: message.thought! }))}</small>}
<p className={typing ? 'typing' : undefined}>{text}</p>
{message.outcomes && !typing && (
<ul className="rule-outcomes appear">
{message.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>
))}
{message.outcomes.some(o => o.ok) && <li><Link className="btn small" to="/schedule">{t('ins.viewSchedule')} <Icon name="arrow-right" /></Link></li>}
</ul>
)}
</div>
</div>
)
}
// While the agent thinks: what it is doing, a step at a time.
function Thinking({ lang }: { lang: Lang }) {
const [step, setStep] = useState(0)
useEffect(() => {
const id = window.setInterval(() => setStep(n => Math.min(n + 1, THINKING.length - 1)), 700)
return () => clearInterval(id)
}, [])
return (
<div className="ai-message thinking" role="status">
<span><i className="spinner" /></span>
<div className="ai-bubble"><p>{inLang(lang, () => t(THINKING[step]))}<i className="dots"><b /><b /><b /></i></p></div>
</div>
)
}
// The short pause before the next question, like someone typing.
function Typing() {
return (
<div className="ai-message thinking" aria-hidden="true">
<span><Icon name="spark" /></span>
<div className="ai-bubble"><p><i className="dots"><b /><b /><b /></i></p></div>
</div>
)
}
function RuleCard({ rule, from, onChange, onRemove }: {
rule: Rule
from?: string
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, from)}</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(from && rule.type === 'content' ? 'ins.topicPlaceholderChosen' : '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>
)
}