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