diff --git a/src/express/agent.test.ts b/src/express/agent.test.ts index 178ec25..4cd6ae8 100644 --- a/src/express/agent.test.ts +++ b/src/express/agent.test.ts @@ -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((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() + }) }) diff --git a/src/express/agent.ts b/src/express/agent.ts index 3cfcd4b..e8773ea 100644 --- a/src/express/agent.ts +++ b/src/express/agent.ts @@ -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 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/ diff --git a/src/express/parse.ts b/src/express/parse.ts index 45830cd..344a62d 100644 --- a/src/express/parse.ts +++ b/src/express/parse.ts @@ -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 } } diff --git a/src/i18n.ts b/src/i18n.ts index b9f85a1..b94439b 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -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 = { '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 { - 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(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) diff --git a/src/pages/Instruction.tsx b/src/pages/Instruction.tsx index c4065f2..55bd4c8 100644 --- a/src/pages/Instruction.tsx +++ b/src/pages/Instruction.tsx @@ -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> = { 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(EMPTY) const [messages, setMessages] = useState([]) const [saving, setSaving] = useState(false) + 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)) 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() + // 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() {
-

{t('agent.name')}

{t('agent.status')}
+

{t('agent.name')}

{phase === 'thinking' ? t('agent.statusThinking') : t('agent.status')}
- {messages.map((m, i) => )} - {live.text && } + {messages.map((m, i) => ( + + ))} + {phase === 'thinking' && } + {phase === 'pausing' && } + {idle && live.text && ( + setTypedLive(live.text)} onType={scroll} /> + )}
- {live.options.length > 0 && ( -
+ {showOptions && ( +
{live.options.map((o, i) => ( ))} @@ -119,7 +160,7 @@ export function InstructionPage() { onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) { e.preventDefault(); send(text) } }} />
{t('agent.hint')} - +
@@ -144,7 +185,7 @@ export function InstructionPage() { {ready.length > 0 && (
{t('ins.approveNote')} -
@@ -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 (
{message.role === 'agent' && {busy ? : }}
-

{message.text}

- {message.outcomes && ( -
    + {message.thought && {inLang(lang, () => t('agent.thought', { s: message.thought! }))}} +

    {text}

    + {message.outcomes && !typing && ( +
      {message.outcomes.map((o, i) => (
    • {o.ok ? t('ins.active') : t('ins.failed')} @@ -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 ( +
      + +

      {inLang(lang, () => t(THINKING[step]))}

      +
      + ) +} + +// The short pause before the next question, like someone typing. +function Typing() { + return ( + + ) +} + function RuleCard({ rule, onChange, onRemove }: { rule: Rule onChange: (patch: Partial | Partial) => void diff --git a/src/styles/app.css b/src/styles/app.css index f0ae59f..99151f5 100644 --- a/src/styles/app.css +++ b/src/styles/app.css @@ -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}}