Make the Instruction agent reply in the user's language, at a human pace
The agent answers in the language each message is written in (Indonesian or English), whatever the UI language; short replies such as "LLM" keep the conversation's language. inLang() runs t() in that language. Each turn now reads like a person: a thinking step with what the agent is doing, the reply typed out with "Thought for Ns" above it, a short typing pause, then one question, with its options shown once it is typed. Reduced motion skips the animation. "dari file LLM" / "from the document X" is read as the topic, and topics keep the user's capitals. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -7,32 +7,51 @@ 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, editRule, EMPTY, nextQuestion, removeRule, respond, type AgentState } from '../express/agent'
|
||||
import { t, type Key } from '../i18n'
|
||||
import { budgetFor, canSchedule, createSchedule, defaultLanguage, VIDEO, videoQuote } from '../express/schedules'
|
||||
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, 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[] }
|
||||
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 [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))
|
||||
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 = saving
|
||||
? { text: t('agent.approving'), options: [] }
|
||||
const live = inLang(lang, () => saving
|
||||
? { text: t('agent.approving'), options: [] as string[] }
|
||||
: question
|
||||
? ask(question, agent)
|
||||
: agent.items.length
|
||||
@@ -42,35 +61,49 @@ export function InstructionPage() {
|
||||
: 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)) }
|
||||
: { 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.
|
||||
useEffect(() => { chat.current?.scrollTo({ top: chat.current.scrollHeight, behavior: 'smooth' }) }, [messages.length, live.text])
|
||||
// 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) return
|
||||
const turn = respond(agent, input)
|
||||
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() },
|
||||
...(turn.reply ? [{ role: 'agent' as const, text: turn.reply }] : []),
|
||||
])
|
||||
setAgent(turn.state)
|
||||
setMessages(ms => [...ms, ...(live.text ? [{ role: 'agent' as const, text: live.text }] : []), { role: 'user', text: input.trim() }])
|
||||
setText('')
|
||||
if (turn.approve) void approve(turn.state)
|
||||
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)
|
||||
}
|
||||
|
||||
async function approve(state = agent) {
|
||||
// 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) 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.
|
||||
// Approving from the plan before the agent asked about the language keeps the old default.
|
||||
const language = state.language ?? defaultLanguage()
|
||||
for (const { id, rule } of items) {
|
||||
try {
|
||||
await createSchedule(rule.type === 'content' ? { ...rule, language } : rule)
|
||||
@@ -83,8 +116,9 @@ export function InstructionPage() {
|
||||
setSaving(false)
|
||||
await queryClient.invalidateQueries({ queryKey: ['schedules'] })
|
||||
const ok = done.filter(o => o.ok).length
|
||||
const report = ok === done.length ? t('agent.done', { n: ok }) : t('agent.partial', { ok, n: done.length })
|
||||
setMessages(ms => [...ms, { role: 'agent', text: report, outcomes: done }])
|
||||
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)) }))
|
||||
}
|
||||
@@ -98,15 +132,22 @@ export function InstructionPage() {
|
||||
<div className="card-head">
|
||||
<div className="assistant-title">
|
||||
<span><Icon name="spark" /></span>
|
||||
<div><h3>{t('agent.name')}</h3><small>{t('agent.status')}</small></div>
|
||||
<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} />)}
|
||||
{live.text && <Bubble message={{ role: 'agent', text: live.text }} busy={saving} />}
|
||||
{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>
|
||||
{live.options.length > 0 && (
|
||||
<div className="prompt-chips">
|
||||
{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>
|
||||
))}
|
||||
@@ -119,7 +160,7 @@ export function InstructionPage() {
|
||||
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}><Icon name="arrow-right" />{t('agent.send')}</button>
|
||||
<button className="btn primary" type="submit" disabled={!text.trim() || saving || !idle}><Icon name="arrow-right" />{t('agent.send')}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -144,7 +185,7 @@ export function InstructionPage() {
|
||||
{ready.length > 0 && (
|
||||
<div className="rule-approve">
|
||||
<small>{t('ins.approveNote')}</small>
|
||||
<button className="btn primary" disabled={saving} onClick={() => approve()}>
|
||||
<button className="btn primary" disabled={saving || !idle} onClick={() => approve()}>
|
||||
<Icon name="check" />{saving ? t('ins.approving') : t('ins.approve', { n: ready.length })}
|
||||
</button>
|
||||
</div>
|
||||
@@ -155,14 +196,35 @@ export function InstructionPage() {
|
||||
)
|
||||
}
|
||||
|
||||
function Bubble({ message, busy }: { message: Message; busy?: boolean }) {
|
||||
// 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">
|
||||
<p>{message.text}</p>
|
||||
{message.outcomes && (
|
||||
<ul className="rule-outcomes">
|
||||
{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>
|
||||
@@ -178,6 +240,31 @@ function Bubble({ message, busy }: { message: Message; busy?: boolean }) {
|
||||
)
|
||||
}
|
||||
|
||||
// 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, onChange, onRemove }: {
|
||||
rule: Rule
|
||||
onChange: (patch: Partial<ContentRule> | Partial<ResearchRule>) => void
|
||||
|
||||
Reference in New Issue
Block a user