-
{doc.title}
+
{new Date(doc.createdAt).toLocaleDateString(locale())}
{phase === 'working' &&
}
{(phase === 'answer' || phase === 'failed') &&
}
@@ -165,6 +166,36 @@ function DocumentRow({ doc }: { doc: Source }) {
)
}
+// Formats a browser can show in a tab; anything else is saved under its original name.
+const VIEWABLE = new Set(['pdf', 'png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'txt', 'md', 'csv', 'mp3', 'wav', 'm4a', 'ogg', 'mp4', 'webm', 'mov'])
+
+function OpenButton({ doc }: { doc: Source }) {
+ const [busy, setBusy] = useState(false)
+ async function open() {
+ const inTab = VIEWABLE.has(doc.format.toLowerCase())
+ // Opened before the fetch: a tab opened after an await counts as a popup and is blocked.
+ const tab = inTab ? window.open('', '_blank') : null
+ setBusy(true)
+ try {
+ const url = URL.createObjectURL(await documentFile(doc.docId))
+ if (tab) tab.location.href = url
+ // Not viewable, or the tab was blocked: save it instead of doing nothing.
+ else Object.assign(document.createElement('a'), { href: url, download: doc.originalName }).click()
+ setTimeout(() => URL.revokeObjectURL(url), 60_000)
+ } catch (e) {
+ tab?.close()
+ toast((e as Error).message, { error: true })
+ } finally {
+ setBusy(false)
+ }
+ }
+ return (
+
+ )
+}
+
function StatusBadge({ doc, phase }: { doc: Source; phase: Phase }) {
const label = {
ready: t('brain.statusReady'),
@@ -216,6 +247,10 @@ function KnowledgeAction({ doc }: { doc: Source }) {
text = t('brain.needsCredits', { need: credits ? t('brain.needsCreditsNeed', { credits }) : '' })
actions =
break
+ case 'ready':
+ // Processing failed after the build: building again is refused, only removing it unblocks schedules.
+ text = t('brain.failedAfterBuild')
+ break
case 'failed':
text = d.error || t('brain.failed')
if (d.quote) actions =
diff --git a/src/pages/Instruction.tsx b/src/pages/Instruction.tsx
index f032ac4..55bd4c8 100644
--- a/src/pages/Instruction.tsx
+++ b/src/pages/Instruction.tsx
@@ -1,127 +1,270 @@
-import { useState } from 'react'
+import { useEffect, useRef, useState } from 'react'
import { Link } from 'react-router-dom'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { Icon, ruleIcon } from '../components/Icon'
import { PageHead } from '../components/Page'
import {
- dayName, describeRule, kindLabel, KINDS, monthNote, parseInstruction, videoCadence, weeklyTarget,
- type ContentKind, type ContentRule, type ResearchRule, type Rule,
+ dayName, describeRule, kindLabel, KINDS, LANGUAGES, languageLabel, monthNote, videoCadence, weeklyTarget,
+ type ContentKind, type ContentRule, type OutputLanguage, type ResearchRule, type Rule,
} from '../express/parse'
-import { t, type Key } from '../i18n'
+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[]; 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 [rules, setRules] = useState(null)
- const [unknown, setUnknown] = useState([])
+ const [agent, setAgent] = useState(EMPTY)
+ const [messages, setMessages] = useState([])
const [saving, setSaving] = useState(false)
- const [outcomes, setOutcomes] = useState(null)
+ 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), [])
- function compose() {
- const result = parseInstruction(text)
- setRules(result.rules)
- setUnknown(result.unknown)
- setOutcomes(null)
+ 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 = inLang(lang, () => saving
+ ? { text: t('agent.approving'), options: [] as string[] }
+ : question
+ ? ask(question, agent)
+ : agent.items.length
+ ? {
+ text: hasContent && agent.language
+ ? t('agent.readyLanguage', { n: ready.length, language: languageLabel(agent.language) })
+ : 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)) })
+ 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.
+ // 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 || !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() }])
+ setText('')
+ 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)
}
- // A count or frequency typed in by hand replaces the monthly request it was derived from.
- const update = (i: number, patch: Partial | Partial) =>
- setRules(rs => rs!.map((r, j) => (j === i
- ? ({ ...r, ...patch, ...('count' in patch || 'per' in patch ? { monthly: undefined } : {}) } as Rule)
- : r)))
- const remove = (i: number) => setRules(rs => rs!.filter((_, j) => j !== i))
+ // After a reply is typed out, a short pause before the next question appears.
+ function replied() {
+ setPhase('pausing')
+ later(() => setPhase('idle'), reducedMotion() ? 0 : PAUSE)
+ }
- const ready = rules?.filter(canSchedule) ?? []
-
- async function approve() {
+ 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.
- for (const rule of ready) {
+ for (const { id, rule } of items) {
try {
- await createSchedule(rule)
+ await createSchedule(rule.type === 'content' ? { ...rule, language } : rule)
done.push({ rule, ok: true })
+ created.add(id)
} catch (e) {
done.push({ rule, ok: false, message: (e as Error).message })
}
- setOutcomes([...done])
}
setSaving(false)
await queryClient.invalidateQueries({ queryKey: ['schedules'] })
- if (done.every(o => o.ok)) { setRules(null); setText('') }
+ const ok = done.filter(o => o.ok).length
+ 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.
- else setRules(rs => rs!.filter(r => !done.some(o => o.ok && o.rule === r)))
+ setAgent(s => (ok === done.length ? EMPTY : { ...s, items: s.items.filter(i => !created.has(i.id)) }))
}
return (
<>
-