Files
learning-lab-express/src/pages/Instruction.tsx
Iqbal 7a7f67d3e2 Approve a plan only once its kind, frequency and language are chosen
The plan's title said the kind and frequency were not chosen yet while
its fields showed Summary / per week and the Approve button was on, with
no content language picked. A guessed kind or frequency now shows as not
chosen in the fields too, and Approve waits until they and the language
are set, saying what is missing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 13:49:38 +07:00

361 lines
19 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)
// While schedules are created: which one, and how many Brain documents it has been given so far.
const [progress, setProgress] = useState<{ n: number; count: number; done: number; total: number } | null>(null)
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')
// A kind or frequency the agent only guessed, or no content language yet: the plan is not finished.
const incomplete = ready.some(i => i.rule.type === 'content' && (i.rule.kindGuessed || i.rule.perGuessed)) || (hasContent && !agent.language)
// 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))
const unfinished = items.some(i => i.rule.type === 'content' && (i.rule.kindGuessed || i.rule.perGuessed))
if (!items.length || tooFewSources || unfinished) 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 [i, { id, rule }] of items.entries()) {
try {
await createSchedule(rule.type === 'content' ? { ...rule, language } : rule, undefined,
(d, total) => setProgress({ n: i + 1, count: items.length, done: d, total }))
done.push({ rule, ok: true })
created.add(id)
} catch (e) {
done.push({ rule, ok: false, message: (e as Error).message })
}
}
setSaving(false)
setProgress(null)
// 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}
note={saving && progress?.total ? inLang(lang, () => t('agent.attaching', progress)) : undefined}
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>}
{incomplete && <span className="error">{t('ins.incomplete')}</span>}
</small>
<button className="btn primary" disabled={saving || !idle || tooFewSources || incomplete} 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, note, onTyped, onType }: {
message: Message; lang: Lang; busy?: boolean; note?: string; 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>
{note && <small className="ai-thought" role="status">{note}</small>}
{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' ? (
<>
{/* A guessed kind or frequency shows as not chosen, as the rule's title says. */}
<select aria-label={t('ins.kind')} value={rule.kindGuessed ? '' : rule.kind} onChange={e => onChange({ kind: e.target.value as ContentKind })}>
{rule.kindGuessed && <option value="" disabled>{t('ins.kindPending')}</option>}
{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.perGuessed ? '' : rule.per} onChange={e => onChange({ per: e.target.value as ContentRule['per'] })}>
{rule.perGuessed && <option value="" disabled>{t('ins.perPending')}</option>}
<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>
)
}