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:
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
21
src/i18n.ts
21
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<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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}}
|
||||
|
||||
Reference in New Issue
Block a user