production-dashboard #1

Merged
iqbal merged 13 commits from production-dashboard into main 2026-10-27 14:58:53 +00:00
7 changed files with 409 additions and 82 deletions
Showing only changes of commit 2b37d80e26 - Show all commits

View File

@@ -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`. - 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`). - 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. - 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. - Login wajib memakai akun Clerk dengan organisasi aktif: agents, jadwal, dan kredit semuanya per organisasi.
| Folder | Isi | | Folder | Isi |

55
src/express/agent.test.ts Normal file
View File

@@ -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<AgentState>((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' })
})
})

144
src/express/agent.ts Normal file
View File

@@ -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<ContentRule> | Partial<ResearchRule>): 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<ContentRule> | Partial<ResearchRule>): 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') }
}

View File

@@ -61,6 +61,8 @@ export interface ResearchRule {
weekday: number weekday: number
topic: string topic: string
autoApprove: boolean 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 export type Rule = ContentRule | ResearchRule
@@ -87,13 +89,13 @@ function countOf(clause: string, kindAt: number): number {
return (word && NUMBER_WORDS[word]) || 1 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 ?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' 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' 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 for (const [re, day] of WEEKDAYS) if (re.test(clause)) return day
return null return null
} }
@@ -108,7 +110,9 @@ function parseClause(clause: string): Rule | null {
const per = perOf(clause) const per = perOf(clause)
const day = weekdayOf(clause) const day = weekdayOf(clause)
const cadence = per === 'day' && day === null ? 'daily' : 'weekly' 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) { for (const [kind, re] of KIND_WORDS) {
const m = re.exec(clause) const m = re.exec(clause)

View File

@@ -88,11 +88,7 @@ const en = {
// Instruction // Instruction
'ins.title': '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.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.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.ex1': 'Make 2 summaries per day and 1 quiz per week about leadership',
'ins.ex2': 'Research the internet every day about leadership', 'ins.ex2': 'Research the internet every day about leadership',
'ins.ex3': '3 roleplays per week about negotiating with customers', '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.ex5': '1 video per week, 2 podcasts per week and 1 SCORM module per week about onboarding',
'ins.previewTitle': 'Schedules to create', 'ins.previewTitle': 'Schedules to create',
'ins.readyCount': '{n} ready', '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.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.approve': 'Approve {n} schedules',
'ins.approving': 'Creating 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.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.videoQuoteFailed': 'The video price could not be checked: {message}',
'ins.budgetVideo': '{n} per video', '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.nextVideo': 'Next video: {when}',
'sch.videoNow': 'Make a video now', 'sch.videoNow': 'Make a video now',
'sch.videoStarted': 'Video started. It shows in Results while it is being made.', 'sch.videoStarted': 'Video started. It shows in Results while it is being made.',
@@ -428,11 +455,7 @@ const id: Record<Key, string> = {
'ins.title': 'Perintah', 'ins.title': 'Perintah',
'ins.description': 'Tulis instruksi biasa. AI menyusunnya menjadi jadwal riset dan pembuatan konten, lalu terus berjalan sampai Anda menghentikannya.', '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.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.ex1': 'Bikin 2 ringkasan per hari dan 1 kuis per minggu soal leadership',
'ins.ex2': 'Riset internet tiap hari soal leadership', 'ins.ex2': 'Riset internet tiap hari soal leadership',
'ins.ex3': '3 roleplay per minggu tentang negosiasi dengan pelanggan', 'ins.ex3': '3 roleplay per minggu tentang negosiasi dengan pelanggan',
@@ -440,9 +463,6 @@ const id: Record<Key, string> = {
'ins.ex5': '1 video per minggu, 2 podcast per minggu dan 1 modul SCORM per minggu soal onboarding', '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.previewTitle': 'Jadwal yang akan dibuat',
'ins.readyCount': '{n} siap', '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.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.approve': 'Setujui {n} jadwal',
'ins.approving': 'Membuat jadwal…', 'ins.approving': 'Membuat jadwal…',
@@ -611,6 +631,40 @@ const id: Record<Key, string> = {
'ins.videoCadence': 'Jadwal video membuat satu video per proses: pilih 1 per hari atau 1 per minggu.', '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.videoQuoteFailed': 'Harga video tidak bisa dicek: {message}',
'ins.budgetVideo': '{n} per video', '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.nextVideo': 'Video berikutnya: {when}',
'sch.videoNow': 'Buat video sekarang', 'sch.videoNow': 'Buat video sekarang',
'sch.videoStarted': 'Video mulai dibuat. Video tampil di Hasil selama proses berjalan.', 'sch.videoStarted': 'Video mulai dibuat. Video tampil di Hasil selama proses berjalan.',

View File

@@ -1,12 +1,13 @@
import { useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { useQuery, useQueryClient } from '@tanstack/react-query' import { useQuery, useQueryClient } from '@tanstack/react-query'
import { Icon, ruleIcon } from '../components/Icon' import { Icon, ruleIcon } from '../components/Icon'
import { PageHead } from '../components/Page' import { PageHead } from '../components/Page'
import { import {
dayName, describeRule, kindLabel, KINDS, monthNote, parseInstruction, videoCadence, weeklyTarget, dayName, describeRule, kindLabel, KINDS, monthNote, videoCadence, weeklyTarget,
type ContentKind, type ContentRule, type ResearchRule, type Rule, type ContentKind, type ContentRule, type ResearchRule, type Rule,
} from '../express/parse' } from '../express/parse'
import { ask, editRule, EMPTY, nextQuestion, removeRule, respond, type AgentState } from '../express/agent'
import { t, type Key } from '../i18n' import { t, type Key } from '../i18n'
import { budgetFor, canSchedule, createSchedule, VIDEO, videoQuote } from '../express/schedules' import { budgetFor, canSchedule, createSchedule, VIDEO, videoQuote } from '../express/schedules'
@@ -16,109 +17,148 @@ const EXAMPLES: Key[] = ['ins.ex1', 'ins.ex2', 'ins.ex3', 'ins.ex4', 'ins.ex5']
const KIND_NOTE: Partial<Record<ContentKind, Key>> = { video: 'ins.noteVideo', audio: 'ins.noteAudio', scorm: 'ins.noteScorm' } 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 Outcome = { rule: Rule; ok: boolean; message?: string }
type Message = { role: 'agent' | 'user'; text: string; outcomes?: Outcome[] }
export function InstructionPage() { export function InstructionPage() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const [text, setText] = useState('') const [text, setText] = useState('')
const [rules, setRules] = useState<Rule[] | null>(null) const [agent, setAgent] = useState<AgentState>(EMPTY)
const [unknown, setUnknown] = useState<string[]>([]) const [messages, setMessages] = useState<Message[]>([])
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [outcomes, setOutcomes] = useState<Outcome[] | null>(null) const chat = useRef<HTMLDivElement>(null)
function compose() { const question = nextQuestion(agent)
const result = parseInstruction(text) const ready = agent.items.filter(i => canSchedule(i.rule))
setRules(result.rules) // The agent's current turn: its question, or what it is waiting for. It stays live, so an edit in the plan updates it.
setUnknown(result.unknown) const live = saving
setOutcomes(null) ? { 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. async function approve(state = agent) {
const update = (i: number, patch: Partial<ContentRule> | Partial<ResearchRule>) => const items = state.items.filter(i => canSchedule(i.rule))
setRules(rs => rs!.map((r, j) => (j === i if (!items.length) return
? ({ ...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() {
setSaving(true) setSaving(true)
const done: Outcome[] = [] const done: Outcome[] = []
const created = new Set<number>()
// One at a time so a failure (e.g. not an organization owner) is reported per schedule. // 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 { try {
await createSchedule(rule) await createSchedule(rule)
done.push({ rule, ok: true }) done.push({ rule, ok: true })
created.add(id)
} catch (e) { } catch (e) {
done.push({ rule, ok: false, message: (e as Error).message }) done.push({ rule, ok: false, message: (e as Error).message })
} }
setOutcomes([...done])
} }
setSaving(false) setSaving(false)
await queryClient.invalidateQueries({ queryKey: ['schedules'] }) 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. // 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 ( return (
<> <>
<PageHead title={t('ins.title')} description={t('ins.description')} /> <PageHead title={t('ins.title')} description={t('ins.description')} />
<div className="card"> <div className="ai-workspace">
<div className="card-head"><h3>{t('ins.cardTitle')}</h3><Icon name="spark" /></div> <div className="card agent-chat">
<div style={{ padding: '20px 22px 8px' }}> <div className="card-head">
<label htmlFor="instruction">{t('ins.label')}</label> <div className="assistant-title">
<textarea id="instruction" rows={3} value={text} placeholder={t('ins.placeholder')} <span><Icon name="spark" /></span>
<div><h3>{t('agent.name')}</h3><small>{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} />}
</div>
{live.options.length > 0 && (
<div className="prompt-chips">
{live.options.map((o, i) => (
<button key={o} className={i === 0 && !question && agent.items.length ? 'primary' : undefined} onClick={() => send(o)}>{o}</button>
))}
</div>
)}
<form id="ai-request-form" onSubmit={e => { e.preventDefault(); send(text) }}>
<label htmlFor="instruction" className="sr-only">{t('ins.label')}</label>
<textarea id="instruction" rows={2} value={text} placeholder={t('agent.placeholder')} disabled={saving}
onChange={e => setText(e.target.value)} onChange={e => setText(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && (e.metaKey || e.ctrlKey) && text.trim()) compose() }} /> onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) { e.preventDefault(); send(text) } }} />
<div className="ai-compose-footer"> <div className="ai-compose-footer">
<small>{t('ins.hint')}</small> <small>{t('agent.hint')}</small>
<button className="btn primary" disabled={!text.trim()} onClick={compose}><Icon name="calendar" />{t('ins.compose')}</button> <button className="btn primary" type="submit" disabled={!text.trim() || saving}><Icon name="arrow-right" />{t('agent.send')}</button>
</div>
</div>
<div className="prompt-chips" style={{ paddingTop: 12 }}>
{EXAMPLES.map(key => <button key={key} onClick={() => { setText(t(key)); setRules(null); setOutcomes(null) }}>{t(key)}</button>)}
</div> </div>
</form>
</div> </div>
{rules && ( <div className="card">
<div className="card" style={{ marginTop: 22 }}>
<div className="card-head"><h3>{t('ins.previewTitle')}</h3><span className="badge neutral">{t('ins.readyCount', { n: ready.length })}</span></div> <div className="card-head"><h3>{t('ins.previewTitle')}</h3><span className="badge neutral">{t('ins.readyCount', { n: ready.length })}</span></div>
{rules.length === 0 && ( {agent.items.length === 0 && <div className="empty"><p>{t('agent.planEmpty')}</p></div>}
<div className="empty"><h3>{t('ins.notUnderstood')}</h3><p>{t('ins.notUnderstoodCopy')}</p></div>
)}
<div className="rule-list"> <div className="rule-list">
{rules.map((rule, i) => <RuleCard key={i} rule={rule} onChange={p => update(i, p)} onRemove={() => remove(i)} />)} {agent.items.map(({ id, rule }) => (
<RuleCard key={id} rule={rule} onChange={p => setAgent(s => editRule(s, id, p))} onRemove={() => setAgent(s => removeRule(s, id))} />
))}
</div> </div>
{unknown.length > 0 && (
<div className="notice-row" style={{ margin: '0 22px 18px' }}>
<Icon name="info" /><span>{t('ins.skipped', { parts: unknown.map(u => `“${u}”`).join(', ') })}</span>
</div>
)}
{ready.length > 0 && ( {ready.length > 0 && (
<div className="rule-approve"> <div className="rule-approve">
<small>{t('ins.approveNote')}</small> <small>{t('ins.approveNote')}</small>
<button className="btn primary" disabled={saving} onClick={approve}> <button className="btn primary" disabled={saving} onClick={() => approve()}>
<Icon name="check" />{saving ? t('ins.approving') : t('ins.approve', { n: ready.length })} <Icon name="check" />{saving ? t('ins.approving') : t('ins.approve', { n: ready.length })}
</button> </button>
</div> </div>
)} )}
{outcomes && ( </div>
</div>
</>
)
}
function Bubble({ message, busy }: { message: Message; busy?: boolean }) {
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"> <ul className="rule-outcomes">
{outcomes.map((o, i) => ( {message.outcomes.map((o, i) => (
<li key={i}> <li key={i}>
<span className={`status ${o.ok ? 'ready' : 'draft'}`}><i className="dot"></i>{o.ok ? t('ins.active') : t('ins.failed')}</span> <span className={`status ${o.ok ? 'ready' : 'draft'}`}><i className="dot"></i>{o.ok ? t('ins.active') : t('ins.failed')}</span>
<span>{describeRule(o.rule)}</span> <span>{describeRule(o.rule)}</span>
{o.message && <span className="error">{o.message}</span>} {o.message && <span className="error">{o.message}</span>}
</li> </li>
))} ))}
{outcomes.some(o => o.ok) && <li><Link className="btn small" to="/schedule">{t('ins.viewSchedule')} <Icon name="arrow-right" /></Link></li>} {message.outcomes.some(o => o.ok) && <li><Link className="btn small" to="/schedule">{t('ins.viewSchedule')} <Icon name="arrow-right" /></Link></li>}
</ul> </ul>
)} )}
</div> </div>
)} </div>
</>
) )
} }

View File

@@ -127,3 +127,32 @@ textarea{resize:vertical;line-height:1.6}
.content-cover.kind-audio{background:#edf7f4;color:#679b8d} .content-cover.kind-audio{background:#edf7f4;color:#679b8d}
.content-cover.kind-scorm{background:#f2effa;color:#9a82bb} .content-cover.kind-scorm{background:#f2effa;color:#9a82bb}
.scorm-preview .scorm-preview-head b{font-size:12px} .scorm-preview .scorm-preview-head b{font-size:12px}
/* Instruction agent chat */
.sr-only{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
.agent-chat .assistant-title small{display:block;font-size:10px;color:#7a8ba5;margin-top:3px}
.agent-chat .ai-messages{max-height:520px}
.ai-bubble{min-width:0;max-width:85%;display:grid;gap:8px}
.ai-message.user .ai-bubble{justify-items:end}
.ai-bubble .rule-outcomes{padding:4px 2px}
.ai-message .spinner{width:12px;height:12px}
.prompt-chips button.primary{background:var(--blue);border-color:var(--blue);color:white}
.prompt-chips button.primary:hover{background:var(--blue-dark);color:white}
.agent-chat #ai-request-form textarea{min-height:0}
/* The mockup's chat text is 9–11px in light grey; the agent chat is read closely, so it gets body-size, darker text. */
.agent-chat .ai-message p{font-size:13.5px;line-height:1.7;color:#344054;background:#f2f5fa}
.agent-chat .ai-message.user p{color:#fff;background:var(--blue)}
.agent-chat .ai-message>span{width:30px;height:30px}
.agent-chat .ai-message svg{width:16px;height:16px}
.agent-chat .assistant-title h3{font-size:15px}
.agent-chat .assistant-title small{font-size:12px;color:#667085}
.agent-chat .prompt-chips button{font-size:12.5px;padding:8px 13px;color:#344054;border-color:#d0d9e8}
.agent-chat .prompt-chips button.primary{color:#fff}
.agent-chat #ai-request-form textarea{font-size:14px}
.agent-chat .ai-compose-footer small{font-size:11.5px;color:#667085}
.agent-chat .ai-compose-footer .btn{font-size:13px}
.agent-chat .rule-outcomes{font-size:13px}
.rule-body>b{font-size:14px}
.rule-note,.rule-check{font-size:12px;color:#667085}
.rule-body .error{font-size:12px}
.rule-approve small{font-size:12px;color:#667085}
.rule-fields select,.rule-fields input{font-size:13px}