From 2b37d80e26400818afa8fa5be2dd0e4a873fc06c Mon Sep 17 00:00:00 2001 From: Iqbal Date: Fri, 23 Oct 2026 11:23:47 +0700 Subject: [PATCH] Turn Instruction into a chat with an agent that asks The Instruction page is now a conversation with the Express Agent. Each message updates the draft plan, and when something is unclear (a part it did not understand, a video count with no cadence, a schedule over 50 per week, weekly research with no day, a missing topic) the agent asks right in the chat, one question at a time, with replies to pick or type. Once the plan is clear it offers to start the schedules and reports the result in the chat. The plan stays editable beside the chat. The chat text is also larger and darker than the mockup's 9-11px grey. Co-Authored-By: Claude Opus 5.5 --- README.md | 1 + src/express/agent.test.ts | 55 ++++++++++++ src/express/agent.ts | 144 ++++++++++++++++++++++++++++++++ src/express/parse.ts | 10 ++- src/i18n.ts | 82 ++++++++++++++---- src/pages/Instruction.tsx | 170 +++++++++++++++++++++++--------------- src/styles/app.css | 29 +++++++ 7 files changed, 409 insertions(+), 82 deletions(-) create mode 100644 src/express/agent.test.ts create mode 100644 src/express/agent.ts diff --git a/README.md b/README.md index 60cd888..3276cbd 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ npm test # tes parser, jadwal, SCORM, dan ekspor (Vitest) - Clerk key dibaca dari `VITE_CLERK_PUBLISHABLE_KEY`, atau otomatis dari `../learning-lab/backend/.env`. - Tombol "Open in Learning Studio" di Results membuka `VITE_STUDIO_URL` (default `http://localhost:5173`). - Dua bahasa: English (default) dan Indonesia, lewat tombol EN/ID di topbar dan halaman login. Teks ada di `src/i18n.ts`; pilihan disimpan per perangkat. +- Instruction berbentuk chat dengan agen (`src/express/agent.ts`): bila ada yang belum jelas (bagian yang tidak dipahami, frekuensi video, hari riset, topik), agen langsung bertanya di chat dengan opsi jawaban, lalu menawarkan untuk memulai jadwal. Pemahamannya tetap memakai parser deterministik, bukan LLM. - Login wajib memakai akun Clerk dengan organisasi aktif: agents, jadwal, dan kredit semuanya per organisasi. | Folder | Isi | diff --git a/src/express/agent.test.ts b/src/express/agent.test.ts new file mode 100644 index 0000000..7fa7494 --- /dev/null +++ b/src/express/agent.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest' +import { EMPTY, nextQuestion, respond, type AgentState } from './agent' +import { t } from '../i18n' + +const say = (...inputs: string[]) => inputs.reduce((s, input) => respond(s, input).state, EMPTY) +const rules = (s: AgentState) => s.items.map(i => i.rule) + +describe('instruction agent', () => { + it('needs no question when the instruction is complete', () => { + const s = say('make 2 summaries per day about leadership') + expect(nextQuestion(s)).toBeNull() + expect(respond(s, 'yes').approve).toBe(true) + }) + + it('asks for a topic, and takes the reply as the topic', () => { + const s = say('make 3 quizzes per week') + expect(nextQuestion(s)?.type).toBe('topic') + expect(rules(say('make 3 quizzes per week', 'negotiation'))[0]).toMatchObject({ topic: 'negotiation' }) + }) + + it('stops asking for a topic once the whole Brain is chosen', () => { + expect(nextQuestion(say('make 3 quizzes per week', t('agent.optWholeBrain')))).toBeNull() + }) + + it('asks about a video count that has no cadence', () => { + const s = say('3 videos per week about onboarding') + expect(nextQuestion(s)?.type).toBe('video') + expect(rules(say('3 videos per week about onboarding', t('agent.optPerDay')))[0]).toMatchObject({ count: 1, per: 'day' }) + }) + + it('asks which day weekly research runs when none was named', () => { + const s = say('research every week about sales') + expect(nextQuestion(s)?.type).toBe('weekday') + const answered = say('research every week about sales', 'thursday') + expect(rules(answered)[0]).toMatchObject({ weekday: 3, weekdayGuessed: undefined }) + expect(nextQuestion(answered)).toBeNull() + }) + + it('asks about parts it did not understand, and can skip them', () => { + const s = say('2 summaries per day about sales. hello there') + expect(nextQuestion(s)).toMatchObject({ type: 'unknown', text: 'hello there' }) + expect(nextQuestion(say('2 summaries per day about sales. hello there', 'skip'))).toBeNull() + }) + + it('treats a reply with a frequency as a new request', () => { + const s = say('make 3 quizzes per week', '1 video per week about onboarding') + expect(rules(s).map(r => r.type === 'content' && r.kind)).toEqual(['quiz', 'video']) + }) + + it('caps a schedule that is over the weekly limit', () => { + const s = say('10 summaries per day about sales') + expect(nextQuestion(s)?.type).toBe('tooMany') + expect(rules(say('10 summaries per day about sales', '30 per week'))[0]).toMatchObject({ count: 30, per: 'week' }) + }) +}) diff --git a/src/express/agent.ts b/src/express/agent.ts new file mode 100644 index 0000000..ad71654 --- /dev/null +++ b/src/express/agent.ts @@ -0,0 +1,144 @@ +// The Instruction agent: a conversation on top of the deterministic parser. Each message updates the +// 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) right in the chat, so nothing is left for the user to spot alone. +import { t, type Key } from '../i18n' +import { dayName, describeRule, parseInstruction, videoCadence, weekdayOf, weeklyTarget, type ContentRule, type ResearchRule, type Rule } from './parse' + +export interface Item { id: number; rule: Rule } + +export interface AgentState { + items: Item[] + /** Parts of the user's messages that matched no rule, asked about one by one. */ + unknown: string[] + /** Questions settled without changing the plan, e.g. "use the whole Brain". */ + settled: string[] +} + +export const EMPTY: AgentState = { items: [], unknown: [], settled: [] } + +export type Question = + | { key: string; type: 'unknown'; text: string } + | { key: string; type: 'video' | 'tooMany' | 'weekday'; id: number } + | { key: string; type: 'topic'; ids: number[] } + +/** What the plan still needs, in the order the agent asks about it. */ +export function nextQuestion(s: AgentState): Question | null { + if (s.unknown.length) return { key: `unknown:${s.unknown[0]}`, type: 'unknown', text: s.unknown[0] } + for (const { id, rule } of s.items) { + if (rule.type === 'content' && rule.kind === 'video' && videoCadence(rule) === null) return { key: `video:${id}`, type: 'video', id } + if (rule.type === 'content' && rule.kind !== 'video' && weeklyTarget(rule) > 50) return { key: `tooMany:${id}`, type: 'tooMany', id } + if (rule.type === 'research' && rule.cadence === 'weekly' && rule.weekdayGuessed) return { key: `weekday:${id}`, type: 'weekday', id } + } + const ids = s.items.filter(i => !i.rule.topic).map(i => i.id) + const key = `topic:${ids.join(',')}` + return ids.length && !s.settled.includes(key) ? { key, type: 'topic', ids } : null +} + +const bullets = (rules: Rule[]) => rules.map(r => `• ${describeRule(r)}`).join('\n') +const about = (rule: Rule) => (rule.topic ? t('rule.about', { topic: rule.topic }) : '') +const ruleById = (s: AgentState, id: number) => s.items.find(i => i.id === id)!.rule + +/** The agent's question, with replies the user can pick instead of typing. */ +export function ask(q: Question, s: AgentState): { text: string; options: string[] } { + switch (q.type) { + case 'unknown': + return { text: t('agent.askUnknown', { part: q.text }), options: [t('agent.optSkip')] } + case 'video': + return { text: t('agent.askVideo', { rule: describeRule(ruleById(s, q.id)) }), options: [t('agent.optPerWeek'), t('agent.optPerDay'), t('agent.optRemove')] } + case 'tooMany': + return { text: t('agent.askTooMany', { rule: describeRule(ruleById(s, q.id)) }), options: [t('agent.opt50'), t('agent.optRemove')] } + case 'weekday': + return { text: t('agent.askWeekday', { about: about(ruleById(s, q.id)) }), options: [0, 1, 2, 3, 4, 5, 6].map(dayName) } + case 'topic': + return { + text: t('agent.askTopic', { list: bullets(q.ids.map(id => ruleById(s, id))) }), + options: [t('agent.optWholeBrain')], + } + } +} + +/** A change made by hand in the plan; it replaces what the agent had guessed. */ +export function editRule(s: AgentState, id: number, patch: Partial | Partial): AgentState { + const guessed = { + // A count or frequency typed in by hand replaces the monthly request it was derived from. + ...('count' in patch || 'per' in patch ? { monthly: undefined } : {}), + ...('weekday' in patch || 'cadence' in patch ? { weekdayGuessed: undefined } : {}), + } + return { ...s, items: s.items.map(i => (i.id === id ? { ...i, rule: { ...i.rule, ...patch, ...guessed } as Rule } : i)) } +} + +export const removeRule = (s: AgentState, id: number): AgentState => ({ ...s, items: s.items.filter(i => i.id !== id) }) + +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/ +const SKIP = /^(skip|lewati|lewat|abaikan|ignore|no|nope|tidak|nggak|gak|enggak|ga)$/ +const REMOVE = /^(remove|hapus|buang|delete)\b/ +const WHOLE = /^(whole brain|the whole brain|seluruh otak|semua|semuanya|all|everything|apa saja|bebas)$/ +const DAILY = /\b(per ?hari|tiap hari|setiap hari|sehari|harian|per day|a day|every day|each day|daily)\b/ +const WEEKLY = /\b(per ?minggu|tiap minggu|setiap minggu|seminggu|mingguan|per week|a week|every week|each week|weekly)\b/ + +let seq = 0 +const withIds = (rules: Rule[]) => rules.map(rule => ({ id: ++seq, rule })) + +// A new instruction: its rules join the plan and its unclear parts are queued as questions. +function instruct(s: AgentState, text: string): Turn | null { + const { rules, unknown } = parseInstruction(text) + if (!rules.length) return null + return { + state: { ...s, items: [...s.items, ...withIds(rules)], unknown: [...s.unknown, ...unknown] }, + reply: t(s.items.length ? 'agent.added' : 'agent.understood', { list: bullets(rules) }), + } +} + +function updated(s: AgentState, id: number, patch: Partial | Partial): Turn { + const state = editRule(s, id, patch) + return { state, reply: t('agent.updated', { rule: describeRule(ruleById(state, id)) }) } +} + +// Reads a reply to the agent's question; null when it does not answer it. +function answer(q: Question, s: AgentState, text: string, lower: string, is: (k: Key) => boolean): Turn | null { + if (q.type === 'unknown') { + if (SKIP.test(lower) || is('agent.optSkip')) return { state: { ...s, unknown: s.unknown.slice(1) }, reply: t('agent.skipped', { part: q.text }) } + return instruct({ ...s, unknown: s.unknown.slice(1) }, text) + } + if (q.type === 'topic') { + if (WHOLE.test(lower) || SKIP.test(lower) || is('agent.optWholeBrain')) return { state: { ...s, settled: [...s.settled, q.key] }, reply: t('agent.wholeBrain') } + // A reply with a frequency is a new request, not a topic. + if (DAILY.test(lower) || WEEKLY.test(lower) || /\bper\b/.test(lower)) return null + const topic = text.replace(/^(about|regarding|tentang|soal|mengenai|topik|topic)\s+/i, '').replace(/[.!]+$/, '').trim() + if (!topic) return null + return { state: q.ids.reduce((st, id) => editRule(st, id, { topic }), s), reply: t('agent.topicSet', { topic }) } + } + + if (REMOVE.test(lower) || is('agent.optRemove')) { + return { state: removeRule(s, q.id), reply: t('agent.removed', { rule: describeRule(ruleById(s, q.id)) }) } + } + const per = DAILY.test(lower) || /\b(hari|day)\b/.test(lower) ? 'day' : WEEKLY.test(lower) || /\b(minggu|week)\b/.test(lower) ? 'week' : null + if (q.type === 'video') return per ? updated(s, q.id, { count: 1, per }) : null + if (q.type === 'tooMany') { + const n = Number(lower.match(/\d+/)?.[0]) + if (!n) return null + return per === 'day' ? updated(s, q.id, { count: Math.min(n, 7), per }) : updated(s, q.id, { count: Math.min(n, 50), per: 'week' }) + } + // weekday: "every day" turns it into daily research instead. + if (DAILY.test(lower)) return updated(s, q.id, { cadence: 'daily' }) + const day = weekdayOf(lower) ?? [0, 1, 2, 3, 4, 5, 6].find(d => lower === dayName(d).toLowerCase()) + return day === undefined || day === null ? null : updated(s, q.id, { weekday: day }) +} + +/** The agent's turn after the user says something, typed or picked from the options. */ +export function respond(s: AgentState, input: string): Turn { + const text = input.trim() + const lower = text.toLowerCase() + const is = (key: Key) => lower === t(key).toLowerCase() + if (is('agent.optReset')) return { state: EMPTY, reply: t('agent.reset') } + + const q = nextQuestion(s) + if (q) { + // Not an answer, so maybe a new instruction; otherwise the same question is asked again. + return answer(q, s, text, lower, is) ?? instruct(s, text) ?? { state: s, reply: t('agent.unclear') } + } + if (s.items.length && (YES.test(lower) || is('agent.optApprove'))) return { state: s, reply: '', approve: true } + return instruct(s, text) ?? { state: s, reply: t('agent.notUnderstood') } +} diff --git a/src/express/parse.ts b/src/express/parse.ts index f7373b6..a7a88e4 100644 --- a/src/express/parse.ts +++ b/src/express/parse.ts @@ -61,6 +61,8 @@ export interface ResearchRule { weekday: number topic: string autoApprove: boolean + /** Weekly research whose instruction named no day, so Monday is only a default the agent asks about. */ + weekdayGuessed?: boolean } export type Rule = ContentRule | ResearchRule @@ -87,13 +89,13 @@ function countOf(clause: string, kindAt: number): number { return (word && NUMBER_WORDS[word]) || 1 } -function perOf(clause: string): Per | 'month' { +export function perOf(clause: string): Per | 'month' { if (/\b(per ?hari|tiap hari|setiap hari|sehari|harian|per day|a day|every day|each day|daily)\b/.test(clause)) return 'day' if (/\b(per ?bulan|tiap bulan|setiap bulan|sebulan|bulanan|per month|a month|every month|each month|monthly)\b/.test(clause)) return 'month' return 'week' } -function weekdayOf(clause: string): number | null { +export function weekdayOf(clause: string): number | null { for (const [re, day] of WEEKDAYS) if (re.test(clause)) return day return null } @@ -108,7 +110,9 @@ function parseClause(clause: string): Rule | null { const per = perOf(clause) const day = weekdayOf(clause) const cadence = per === 'day' && day === null ? 'daily' : 'weekly' - return { type: 'research', cadence, weekday: day ?? 0, topic: topicOf(clause), autoApprove: true } + const rule: ResearchRule = { type: 'research', cadence, weekday: day ?? 0, topic: topicOf(clause), autoApprove: true } + if (cadence === 'weekly' && day === null) rule.weekdayGuessed = true + return rule } for (const [kind, re] of KIND_WORDS) { const m = re.exec(clause) diff --git a/src/i18n.ts b/src/i18n.ts index 44af812..5b48596 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -88,11 +88,7 @@ const en = { // Instruction 'ins.title': 'Instruction', 'ins.description': 'Write a plain instruction. The AI turns it into research and content schedules that keep running until you stop them.', - 'ins.cardTitle': 'What should be created?', 'ins.label': 'Instruction', - 'ins.placeholder': 'Example: make 3 quizzes per week and 2 summaries per day about leadership', - 'ins.hint': 'Name the content type, how many, how often (per day / per week), and the topic. Internet research can be scheduled too.', - 'ins.compose': 'Build schedule', 'ins.ex1': 'Make 2 summaries per day and 1 quiz per week about leadership', 'ins.ex2': 'Research the internet every day about leadership', 'ins.ex3': '3 roleplays per week about negotiating with customers', @@ -100,9 +96,6 @@ const en = { 'ins.ex5': '1 video per week, 2 podcasts per week and 1 SCORM module per week about onboarding', 'ins.previewTitle': 'Schedules to create', 'ins.readyCount': '{n} ready', - 'ins.notUnderstood': 'Instruction not understood', - 'ins.notUnderstoodCopy': 'Try naming the content type and how often, e.g. “2 summaries per day”.', - 'ins.skipped': 'Not understood and skipped: {parts}', 'ins.approveNote': 'Once approved, schedules start right away. Content is created from the Brain without repeating existing content, using workspace credits within the limits above.', 'ins.approve': 'Approve {n} schedules', 'ins.approving': 'Creating schedules…', @@ -274,6 +267,40 @@ const en = { 'ins.videoCadence': 'A video schedule makes one video per run: choose 1 per day or 1 per week.', 'ins.videoQuoteFailed': 'The video price could not be checked: {message}', 'ins.budgetVideo': '{n} per video', + 'agent.name': 'Express Agent', + 'agent.status': 'Asks when something is unclear', + '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}', + 'agent.notUnderstood': 'I could not find a content type or research request in that. Try something like “2 summaries per day about leadership”.', + 'agent.unclear': 'Sorry, I did not catch that. Pick one of the options below or answer in your own words.', + 'agent.askUnknown': 'I did not understand “{part}”. What did you mean? Rephrase it, or skip it.', + 'agent.askVideo': '{rule}: a video schedule makes one video per run, so it can be 1 per week or 1 per day. Which one?', + 'agent.askTooMany': '{rule} is more than 50 per week, the limit for one schedule. How many per week should it be?', + 'agent.askWeekday': 'Weekly internet research{about}: which day should it run? It is set to Monday for now.', + 'agent.askTopic': 'What topic should this be about?\n{list}\nType a topic, or use the whole Brain.', + 'agent.ready': 'Everything is clear: {n} schedules are ready. You can still change them in the plan. Shall I start them?', + 'agent.skipped': 'OK, I skipped “{part}”.', + 'agent.removed': 'Removed {rule}.', + 'agent.updated': 'Updated: {rule}.', + 'agent.topicSet': 'Topic set to “{topic}”.', + 'agent.wholeBrain': 'OK, the whole Brain it is.', + 'agent.reset': 'Starting over. What should I create?', + 'agent.approving': 'Creating the schedules…', + 'agent.done': 'Done: {n} schedules are active and start right away.', + 'agent.partial': '{ok} of {n} schedules were created. The ones that failed are still in the plan: fix them, then approve again.', + 'agent.optSkip': 'Skip', + 'agent.optRemove': 'Remove it', + 'agent.optWholeBrain': 'Whole Brain', + 'agent.optApprove': 'Yes, start them', + 'agent.optReset': 'Start over', + 'agent.optPerWeek': '1 per week', + 'agent.optPerDay': '1 per day', + 'agent.opt50': '50 per week', + 'agent.placeholder': 'Reply to the agent or give a new instruction…', + 'agent.send': 'Send', + 'agent.hint': 'Enter to send, Shift+Enter for a new line.', + 'agent.planEmpty': 'The plan fills in as you chat with the agent.', 'sch.nextVideo': 'Next video: {when}', 'sch.videoNow': 'Make a video now', 'sch.videoStarted': 'Video started. It shows in Results while it is being made.', @@ -428,11 +455,7 @@ const id: Record = { 'ins.title': 'Perintah', 'ins.description': 'Tulis instruksi biasa. AI menyusunnya menjadi jadwal riset dan pembuatan konten, lalu terus berjalan sampai Anda menghentikannya.', - 'ins.cardTitle': 'Apa yang perlu dibuat?', 'ins.label': 'Instruksi', - 'ins.placeholder': 'Contoh: bikin 3 kuis per minggu dan 2 ringkasan per hari soal leadership', - 'ins.hint': 'Sebut jenis konten, jumlah, frekuensi (per hari / per minggu), dan topiknya. Riset internet juga bisa dijadwalkan.', - 'ins.compose': 'Susun jadwal', 'ins.ex1': 'Bikin 2 ringkasan per hari dan 1 kuis per minggu soal leadership', 'ins.ex2': 'Riset internet tiap hari soal leadership', 'ins.ex3': '3 roleplay per minggu tentang negosiasi dengan pelanggan', @@ -440,9 +463,6 @@ const id: Record = { 'ins.ex5': '1 video per minggu, 2 podcast per minggu dan 1 modul SCORM per minggu soal onboarding', 'ins.previewTitle': 'Jadwal yang akan dibuat', 'ins.readyCount': '{n} siap', - 'ins.notUnderstood': 'Instruksi belum dipahami', - 'ins.notUnderstoodCopy': 'Coba sebut jenis konten dan frekuensinya, misalnya “2 ringkasan per hari”.', - 'ins.skipped': 'Bagian ini belum dipahami dan dilewati: {parts}', 'ins.approveNote': 'Setelah disetujui, jadwal langsung aktif. Konten dibuat dari materi di Otak tanpa mengulang yang sudah ada, dan memakai kredit ruang kerja sesuai batas di atas.', 'ins.approve': 'Setujui {n} jadwal', 'ins.approving': 'Membuat jadwal…', @@ -611,6 +631,40 @@ const id: Record = { 'ins.videoCadence': 'Jadwal video membuat satu video per proses: pilih 1 per hari atau 1 per minggu.', 'ins.videoQuoteFailed': 'Harga video tidak bisa dicek: {message}', 'ins.budgetVideo': '{n} per video', + 'agent.name': 'Agen Express', + 'agent.status': 'Bertanya bila ada yang belum jelas', + '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}', + 'agent.notUnderstood': 'Saya belum menemukan jenis konten atau permintaan riset di situ. Coba misalnya “2 ringkasan per hari soal leadership”.', + 'agent.unclear': 'Maaf, saya belum menangkap maksudnya. Pilih salah satu opsi di bawah atau jawab dengan kata-kata sendiri.', + 'agent.askUnknown': 'Saya belum paham “{part}”. Maksudnya apa? Tulis ulang, atau lewati saja.', + 'agent.askVideo': '{rule}: jadwal video membuat satu video per proses, jadi pilihannya 1 per minggu atau 1 per hari. Mau yang mana?', + 'agent.askTooMany': '{rule} lebih dari 50 per minggu, batas untuk satu jadwal. Berapa per minggu?', + 'agent.askWeekday': 'Riset internet mingguan{about}: dijalankan hari apa? Sementara diatur ke Senin.', + 'agent.askTopic': 'Topiknya tentang apa?\n{list}\nTulis topiknya, atau pakai seluruh Otak.', + 'agent.ready': 'Semua sudah jelas: {n} jadwal siap. Anda masih bisa mengubahnya di rencana. Mulai sekarang?', + 'agent.skipped': 'Oke, “{part}” saya lewati.', + 'agent.removed': '{rule} dihapus.', + 'agent.updated': 'Diperbarui: {rule}.', + 'agent.topicSet': 'Topik diatur ke “{topic}”.', + 'agent.wholeBrain': 'Oke, pakai seluruh Otak.', + 'agent.reset': 'Mulai dari awal. Apa yang perlu dibuat?', + 'agent.approving': 'Membuat jadwal…', + 'agent.done': 'Selesai: {n} jadwal aktif dan langsung berjalan.', + 'agent.partial': '{ok} dari {n} jadwal berhasil dibuat. Yang gagal masih ada di rencana: perbaiki, lalu setujui lagi.', + 'agent.optSkip': 'Lewati', + 'agent.optRemove': 'Hapus saja', + 'agent.optWholeBrain': 'Seluruh Otak', + 'agent.optApprove': 'Ya, mulai', + 'agent.optReset': 'Mulai ulang', + 'agent.optPerWeek': '1 per minggu', + 'agent.optPerDay': '1 per hari', + 'agent.opt50': '50 per minggu', + 'agent.placeholder': 'Balas agen atau beri instruksi baru…', + 'agent.send': 'Kirim', + 'agent.hint': 'Enter untuk kirim, Shift+Enter untuk baris baru.', + 'agent.planEmpty': 'Rencana terisi seiring Anda mengobrol dengan agen.', 'sch.nextVideo': 'Video berikutnya: {when}', 'sch.videoNow': 'Buat video sekarang', 'sch.videoStarted': 'Video mulai dibuat. Video tampil di Hasil selama proses berjalan.', diff --git a/src/pages/Instruction.tsx b/src/pages/Instruction.tsx index f032ac4..85a7d90 100644 --- a/src/pages/Instruction.tsx +++ b/src/pages/Instruction.tsx @@ -1,12 +1,13 @@ -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, + dayName, describeRule, kindLabel, KINDS, monthNote, videoCadence, weeklyTarget, type ContentKind, type ContentRule, 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, VIDEO, videoQuote } from '../express/schedules' @@ -16,112 +17,151 @@ const EXAMPLES: Key[] = ['ins.ex1', 'ins.ex2', 'ins.ex3', 'ins.ex4', 'ins.ex5'] 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[] } export function InstructionPage() { const queryClient = useQueryClient() 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 chat = useRef(null) - 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)) + // 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: [] } + : question + ? ask(question, agent) + : agent.items.length + ? { text: 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)) } + + // 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]) + + function send(input: string) { + if (!input.trim() || saving) return + const turn = 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) + setText('') + if (turn.approve) void approve(turn.state) } - // 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)) - - const ready = rules?.filter(canSchedule) ?? [] - - async function approve() { + async function approve(state = agent) { + const items = state.items.filter(i => canSchedule(i.rule)) + if (!items.length) return setSaving(true) const done: Outcome[] = [] + const created = new Set() // 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) 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 = ok === done.length ? t('agent.done', { n: ok }) : t('agent.partial', { ok, n: done.length }) + setMessages(ms => [...ms, { role: 'agent', text: report, outcomes: done }]) // 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 ( <> -
-

{t('ins.cardTitle')}

-
- -