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> = { 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(EMPTY) const [messages, setMessages] = useState([]) const [saving, setSaving] = useState(false) const docs = useDocuments() const [phase, setPhase] = useState('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(null) const lang = chatLang ?? uiLang const chat = useRef(null) const timers = useRef([]) 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() // 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 ( <>

{t('agent.name')}

{phase === 'thinking' ? t('agent.statusThinking') : t('agent.status')}
{messages.map((m, i) => ( ))} {phase === 'thinking' && } {phase === 'pausing' && } {idle && live.text && ( setTypedLive(live.text)} onType={scroll} /> )}
{showOptions && (
{live.options.map((o, i) => ( ))}
)}
{ e.preventDefault(); send(text) }}>