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:
Iqbal
2026-10-23 11:38:08 +07:00
parent f55cccb6eb
commit a1d49b37b5
6 changed files with 195 additions and 38 deletions

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { EMPTY, nextQuestion, respond, type AgentState } from './agent'
import { chatLangOf, EMPTY, nextQuestion, respond, type AgentState } from './agent'
import { t } from '../i18n'
const say = (...inputs: string[]) => inputs.reduce<AgentState>((s, input) => respond(s, input).state, EMPTY)
@@ -71,4 +71,16 @@ describe('instruction agent', () => {
expect(nextQuestion(s)?.type).toBe('tooMany')
expect(rules(say('10 summaries per day about sales', '30 per week'))[0]).toMatchObject({ count: 30, per: 'week' })
})
it('takes "dari file X" as the topic', () => {
expect(rules(say('Saya mau buat video dari file LLM'))[0]).toMatchObject({ kind: 'video', topic: 'LLM' })
})
})
describe('chatLangOf', () => {
it('tells Indonesian from English, and stays undecided on short replies', () => {
expect(chatLangOf('Saya mau buat video dari file LLM')).toBe('id')
expect(chatLangOf('I want to make 2 quizzes per week')).toBe('en')
expect(chatLangOf('LLM')).toBeNull()
})
})

View File

@@ -2,7 +2,7 @@
// draft plan, and the agent asks about one gap at a time (a part it did not understand, a video
// cadence, a weekday, a topic, the output language) right in the chat, so nothing is left for the
// user to spot alone.
import { t, type Key } from '../i18n'
import { t, type Key, type Lang } from '../i18n'
import {
dayName, describeRule, LANGUAGES, languageLabel, languageOf, parseInstruction, videoCadence, weekdayOf, weeklyTarget,
type ContentRule, type OutputLanguage, type ResearchRule, type Rule,
@@ -81,6 +81,22 @@ export function editRule(s: AgentState, id: number, patch: Partial<ContentRule>
export const removeRule = (s: AgentState, id: number): AgentState => ({ ...s, items: s.items.filter(i => i.id !== id) })
// Words that tell which language a message is written in, so the agent can answer in it.
const ID_CHAT = new Set(('saya aku mau ingin bikin bikinin buat buatkan tolong dong ya iya tidak gak nggak enggak yang dan dengan untuk dari '
+ 'ke di ini itu apa bagaimana gimana hari minggu bulan setiap tiap soal tentang mengenai riset ringkasan kuis lewati hapus seluruh '
+ 'otak senin selasa rabu kamis jumat sabtu sip lanjut bahasa pakai jadi kalau kalo juga aja saja sekarang mulai').split(' '))
const EN_CHAT = new Set(('i me my want would like make create please the and with for from to in this that what how day week month every '
+ 'each about research summary summaries quiz quizzes skip remove whole brain monday tuesday wednesday thursday friday saturday '
+ 'sunday yes no okay sure start use language also just now').split(' '))
/** The language a message is written in, or null when it is too short or mixed to tell. */
export function chatLangOf(text: string): Lang | null {
const words = text.toLowerCase().match(/\p{L}+/gu) ?? []
const id = words.filter(w => ID_CHAT.has(w)).length
const en = words.filter(w => EN_CHAT.has(w)).length
return id > en ? 'id' : en > id ? 'en' : null
}
export interface Turn { state: AgentState; reply: string; approve?: boolean }
const YES = /^(ya|iya|yes|y|ok|oke|okay|okey|setuju|approve|lanjut|gas|sip|go|boleh)\b/

View File

@@ -97,7 +97,8 @@ export interface ParseResult {
language?: OutputLanguage
}
const TOPIC = /\b(?:soal|tentang|mengenai|seputar|terkait|topik|bertema|perihal|about|regarding|covering|related to|on the topic of|topic)\s+(?!latihan\b)(.+)$/
// "dari file LLM" / "from the document onboarding" names a Brain file, which works as the topic.
const TOPIC = /\b(?:soal|tentang|mengenai|seputar|terkait|topik|bertema|perihal|about|regarding|covering|related to|on the topic of|topic|(?:dari|from)(?: the)? (?:file|berkas|dokumen|documents?|materi|material))\s+(?!latihan\b)(.+)$/
const FREQUENCY_TAIL = /\b(tiap|setiap|per|sehari|seminggu|every|each|daily|weekly|monthly|a day|a week|a month)\b.*$/
function topicOf(clause: string): string {
@@ -176,6 +177,14 @@ export function parseInstruction(text: string): ParseResult {
const topics = new Set(rules.map(r => r.topic).filter(Boolean))
const fallback = shared || (topics.size === 1 ? [...topics][0] : '')
for (const r of rules) if (!r.topic) r.topic = fallback
// Topics are found in lower case; they keep the user's own capitals ("LLM", "HR").
const lowered = text.toLowerCase()
if (lowered.length === text.length) {
for (const r of rules) {
const at = r.topic ? lowered.indexOf(r.topic) : -1
if (at >= 0) r.topic = text.slice(at, at + r.topic.length)
}
}
return language ? { rules, unknown, language } : { rules, unknown }
}

View File

@@ -269,6 +269,11 @@ const en = {
'ins.budgetVideo': '{n} per video',
'agent.name': 'Express Agent',
'agent.status': 'Asks when something is unclear',
'agent.statusThinking': 'Thinking…',
'agent.think1': 'Reading your message',
'agent.think2': 'Checking the plan',
'agent.think3': 'Working out what is still unclear',
'agent.thought': 'Thought for {s}s',
'agent.greeting': 'Hi! Tell me what to create from the Brain and how often, or what to research on the internet. If anything is unclear, I will ask you here.',
'agent.understood': 'Got it. Here is the plan so far:\n{list}',
'agent.added': 'Added to the plan:\n{list}',
@@ -638,6 +643,11 @@ const id: Record<Key, string> = {
'ins.budgetVideo': '{n} per video',
'agent.name': 'Agen Express',
'agent.status': 'Bertanya bila ada yang belum jelas',
'agent.statusThinking': 'Sedang berpikir…',
'agent.think1': 'Membaca pesan Anda',
'agent.think2': 'Memeriksa rencana',
'agent.think3': 'Mencari yang masih belum jelas',
'agent.thought': 'Berpikir {s} detik',
'agent.greeting': 'Halo! Ceritakan konten apa yang perlu dibuat dari Otak dan seberapa sering, atau apa yang perlu diriset di internet. Kalau ada yang belum jelas, saya tanyakan di sini.',
'agent.understood': 'Siap. Ini rencananya sejauh ini:\n{list}',
'agent.added': 'Ditambahkan ke rencana:\n{list}',
@@ -760,6 +770,8 @@ function initial(): Lang {
}
let lang: Lang = initial()
// The language of the text being built right now, when it differs from the UI (see inLang).
let override: Lang | null = null
const listeners = new Set<() => void>()
if (typeof document !== 'undefined') document.documentElement.lang = lang
@@ -774,10 +786,17 @@ export function setLang(next: Lang) {
}
export function t(key: Key, vars?: Record<string, string | number>): string {
const text = dictionaries[lang][key] ?? en[key]
const text = dictionaries[override ?? lang][key] ?? en[key]
return vars ? text.replace(/\{(\w+)\}/g, (m, k) => (k in vars ? String(vars[k]) : m)) : text
}
/** Runs fn with t() in another language, e.g. the agent answering in the language the user writes in. */
export function inLang<T>(l: Lang, fn: () => T): T {
const previous = override
override = l
try { return fn() } finally { override = previous }
}
/** Re-renders the component when the language changes; returns the active language. */
export function useLang(): Lang {
return useSyncExternalStore(cb => { listeners.add(cb); return () => listeners.delete(cb) }, getLang)

View File

@@ -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

View File

@@ -158,3 +158,17 @@ textarea{resize:vertical;line-height:1.6}
.rule-fields select,.rule-fields input{font-size:13px}
.plan-language{display:flex;align-items:center;gap:12px;margin:0;padding:14px 22px;border-bottom:1px solid var(--line);font-size:13px;font-weight:600;color:#344054}
.plan-language select{width:auto!important;min-height:36px!important;padding:7px 10px!important;font-size:13px}
/* The agent's pace: thinking, typing, then the next question */
.ai-thought{font-size:11px;color:#98a2b3;padding-left:2px}
.ai-message.thinking p{color:#667085;font-style:italic;display:flex;align-items:center;gap:8px}
.ai-message .typing:after{content:"";display:inline-block;width:2px;height:1em;margin-left:2px;vertical-align:-2px;background:#98a2b3;animation:blink .8s steps(1) infinite}
.dots{display:inline-flex;gap:4px;font-style:normal}
.dots b{width:6px;height:6px;border-radius:50%;background:#98a2b3;animation:dot 1.2s ease-in-out infinite}
.dots b:nth-child(2){animation-delay:.15s}
.dots b:nth-child(3){animation-delay:.3s}
.appear{animation:appear .3s ease-out}
.agent-chat .ai-message{animation:appear .25s ease-out}
@keyframes dot{0%,60%,100%{opacity:.3;transform:translateY(0)}30%{opacity:1;transform:translateY(-3px)}}
@keyframes blink{50%{opacity:0}}
@keyframes appear{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}
@media (prefers-reduced-motion:reduce){.dots b,.agent-chat .ai-message,.appear,.ai-message .typing:after{animation:none}}