show-run-errors-and-reprocess #2
@@ -79,6 +79,8 @@ export interface KnowledgeDetail {
|
||||
export const knowledgeDetail = (id: number) => request<KnowledgeDetail>(`/upload/${id}/knowledge`)
|
||||
|
||||
// Agree to the quoted price, build after a top-up, or retry a failed build. Spends workspace credits (402 when short).
|
||||
// Reads a failed document's stored file again, for free. A knowledge base it already has is kept.
|
||||
export const reprocessDocument = (id: number) => request<{ id: number; status: string }>(`/upload/${id}/reprocess`, { method: 'POST' })
|
||||
export const buildKnowledge = (id: number) => request<{ status: string }>(`/upload/${id}/knowledge/build`, { method: 'POST' })
|
||||
|
||||
// The engine asks whether an upload is the next version of a similar document before building it.
|
||||
|
||||
@@ -135,6 +135,9 @@ export function AppLayout() {
|
||||
const { signOut } = useClerk()
|
||||
const { pathname } = useLocation()
|
||||
const current = NAV.find(n => pathname.startsWith(n.to))
|
||||
// Nav groups stay closed unless they hold the current page; a click on the label overrides that until the next route.
|
||||
const [toggled, setToggled] = useState<Partial<Record<Key, boolean>>>({})
|
||||
useEffect(() => setToggled({}), [pathname])
|
||||
const name = user?.fullName || user?.primaryEmailAddress?.emailAddress || '-'
|
||||
|
||||
return (
|
||||
@@ -143,14 +146,22 @@ export function AppLayout() {
|
||||
<NavLink to="/production"><Brand /></NavLink>
|
||||
<OrgSwitcher />
|
||||
<nav aria-label={t('layout.mainNav')}>
|
||||
{NAV_GROUPS.map(g => [
|
||||
<div key={g.label} className="nav-label">{t(g.label)}</div>,
|
||||
...g.items.map(n => (
|
||||
{NAV_GROUPS.map(g => {
|
||||
const open = toggled[g.label] ?? g.items.some(n => pathname.startsWith(n.to))
|
||||
return [
|
||||
<button key={g.label} type="button" className="nav-label" aria-expanded={open} aria-controls={`nav-${g.label}`}
|
||||
onClick={() => setToggled(s => ({ ...s, [g.label]: !open }))}>
|
||||
{t(g.label)}<Icon name="chevron-down" />
|
||||
</button>,
|
||||
<div key={`${g.label}-items`} id={`nav-${g.label}`} className={open ? 'nav-items' : 'nav-items closed'}>
|
||||
{g.items.map(n => (
|
||||
<NavLink key={n.to} to={n.to} className={({ isActive }) => (isActive ? 'active' : undefined)}>
|
||||
<Icon name={n.icon} />{t(n.label)}
|
||||
</NavLink>
|
||||
)),
|
||||
])}
|
||||
))}
|
||||
</div>,
|
||||
]
|
||||
})}
|
||||
</nav>
|
||||
<div className="sidebar-help">
|
||||
<Icon name="info" />
|
||||
|
||||
@@ -17,6 +17,7 @@ const paths: Record<string, string> = {
|
||||
search: '<circle cx="10" cy="10" r="6"/><path d="m15 15 5 5"/>',
|
||||
info: '<circle cx="12" cy="12" r="9"/><path d="M12 11v6M12 7v.2"/>',
|
||||
check: '<path d="m5 12 4 4L19 6"/>',
|
||||
'chevron-down': '<path d="m6 9 6 6 6-6"/>',
|
||||
'chevron-updown': '<path d="m8 9 4-4 4 4M8 15l4 4 4-4"/>',
|
||||
logout:'<path d="M10 4H4v16h6M10 12h11m-4-4 4 4-4 4"/>',
|
||||
plus: '<path d="M12 4v16M4 12h16"/>',
|
||||
|
||||
@@ -87,6 +87,53 @@ describe('instruction agent', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('instruction agent with vague or long instructions', () => {
|
||||
const LONG = `Buat 5 konten pembelajaran baru bertema Management Skills setiap hari secara otomatis pada pukul 09.00 WIB.
|
||||
|
||||
Gunakan seluruh materi pembelajaran yang tersedia di dalam sistem sebagai referensi. Pastikan setiap konten memiliki topik yang berbeda, relevan, informatif, dan tidak menduplikasi konten yang sudah pernah dibuat.
|
||||
|
||||
Simpan seluruh konten yang berhasil dibuat ke dalam sistem dengan status Draft untuk ditinjau oleh admin sebelum dipublikasikan.
|
||||
|
||||
Jalankan proses ini menggunakan scheduler atau background job yang tersedia di sistem.`
|
||||
|
||||
it('reads a long brief as one rule and answers the rest truthfully instead of asking about it', () => {
|
||||
const turn = respond(EMPTY, LONG)
|
||||
expect(rules(turn.state)).toEqual([expect.objectContaining({ count: 5, per: 'day', topic: 'Management Skills', kindGuessed: true })])
|
||||
expect(turn.state.unknown).toEqual([])
|
||||
for (const key of ['note.draft', 'note.novelty', 'note.automatic', 'note.wholeBrain'] as const) expect(turn.reply).toContain(t(key))
|
||||
expect(turn.reply).toContain(t('note.hourMatches', { at: '09.00' }))
|
||||
expect(nextQuestion(turn.state)?.type).toBe('kind')
|
||||
})
|
||||
|
||||
it('asks which kind "content" means, and splits the count over the kinds named', () => {
|
||||
const s = say(LONG, 'ringkasan dan kuis')
|
||||
expect(rules(s)).toMatchObject([{ kind: 'summary', count: 3 }, { kind: 'quiz', count: 2 }])
|
||||
expect(rules(say(LONG, t('agent.optMix'))).map(r => r.type === 'content' && r.kind)).toEqual(['summary', 'quiz', 'flashcards'])
|
||||
})
|
||||
|
||||
it('asks how often instead of assuming per week', () => {
|
||||
const s = say('bikin 10 konten baru dalam 20 menit', 'kuis')
|
||||
expect(nextQuestion(s)?.type).toBe('per')
|
||||
expect(rules(say('bikin 10 konten baru dalam 20 menit', 'kuis', t('agent.optEveryWeek')))[0]).toMatchObject({ kind: 'quiz', count: 10, per: 'week', perGuessed: undefined })
|
||||
})
|
||||
|
||||
it('says plainly that it cannot work in minutes', () => {
|
||||
expect(respond(EMPTY, 'bikin 10 konten baru dalam 20 menit').reply).toContain(t('note.subDay'))
|
||||
})
|
||||
|
||||
it('replaces the placeholder when the user restates the request, rather than adding another', () => {
|
||||
const s = say('10 pdf per day', '10 konten')
|
||||
expect(rules(s)).toHaveLength(1)
|
||||
expect(rules(s)[0]).toMatchObject({ count: 10, per: 'day', kindGuessed: true })
|
||||
})
|
||||
|
||||
it('does not repeat the same question when a rephrasing still makes no sense', () => {
|
||||
const s = say('2 summaries per day about sales. hello there', 'blabla')
|
||||
expect(s.unknown).toEqual([])
|
||||
expect(nextQuestion(s)?.type).toBe('language')
|
||||
})
|
||||
})
|
||||
|
||||
describe('chatLangOf', () => {
|
||||
it('tells Indonesian from English, and stays undecided on short replies', () => {
|
||||
expect(chatLangOf('Saya mau buat video dari file LLM')).toBe('id')
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
// user to spot alone.
|
||||
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,
|
||||
dayName, describeRule, KINDS, kindLabel, kindsIn, LANGUAGES, languageLabel, languageOf, parseInstruction, videoCadence, weekdayOf,
|
||||
weeklyTarget, type ContentKind, type ContentRule, type Note, type OutputLanguage, type ResearchRule, type Rule,
|
||||
} from './parse'
|
||||
|
||||
export interface Item { id: number; rule: Rule }
|
||||
@@ -26,13 +26,20 @@ export const EMPTY: AgentState = { items: [], unknown: [], settled: [], language
|
||||
|
||||
export type Question =
|
||||
| { key: string; type: 'unknown'; text: string }
|
||||
| { key: string; type: 'video' | 'tooMany' | 'weekday'; id: number }
|
||||
| { key: string; type: 'kind' | 'video' | 'tooMany' | 'weekday'; id: number }
|
||||
| { key: string; type: 'topic'; ids: number[] }
|
||||
| { key: string; type: 'per'; ids: number[] }
|
||||
| { key: string; type: 'language' }
|
||||
|
||||
/** 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] }
|
||||
// The kind and the frequency come first: the other checks depend on them.
|
||||
const generic = s.items.find(i => i.rule.type === 'content' && i.rule.kindGuessed)
|
||||
if (generic) return { key: `kind:${generic.id}`, type: 'kind', id: generic.id }
|
||||
// One question for every rule with no frequency, since they usually come from the same request.
|
||||
const unset = s.items.filter(i => i.rule.type === 'content' && i.rule.perGuessed).map(i => i.id)
|
||||
if (unset.length) return { key: `per:${unset.join(',')}`, type: 'per', ids: unset }
|
||||
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 }
|
||||
@@ -51,12 +58,27 @@ const describe = (s: AgentState, rule: Rule) => describeRule(rule, s.sources ??
|
||||
const bullets = (s: AgentState, rules: Rule[]) => rules.map(r => `• ${describe(s, 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
|
||||
const contentById = (s: AgentState, id: number) => ruleById(s, id) as ContentRule
|
||||
// "Mix" splits a generic request over the three kinds most teams start with.
|
||||
const MIX: ContentKind[] = ['summary', 'quiz', 'flashcards']
|
||||
|
||||
/** 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 'kind': {
|
||||
const rule = contentById(s, q.id)
|
||||
return {
|
||||
text: t('agent.askKind', { count: rule.count, about: about(rule) }),
|
||||
options: [...KINDS.map(kindLabel), t('agent.optMix')],
|
||||
}
|
||||
}
|
||||
case 'per':
|
||||
return {
|
||||
text: t('agent.askPer', { list: bullets(s, q.ids.map(id => ruleById(s, id))) }),
|
||||
options: [t('agent.optEveryDay'), t('agent.optEveryWeek')],
|
||||
}
|
||||
case 'video':
|
||||
return { text: t('agent.askVideo', { rule: describe(s, ruleById(s, q.id)) }), options: [t('agent.optPerWeek'), t('agent.optPerDay'), t('agent.optRemove')] }
|
||||
case 'tooMany':
|
||||
@@ -79,6 +101,8 @@ export function editRule(s: AgentState, id: number, patch: Partial<ContentRule>
|
||||
// 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 } : {}),
|
||||
...('kind' in patch ? { kindGuessed: undefined } : {}),
|
||||
...('per' in patch ? { perGuessed: undefined } : {}),
|
||||
}
|
||||
return { ...s, items: s.items.map(i => (i.id === id ? { ...i, rule: { ...i.rule, ...patch, ...guessed } as Rule } : i)) }
|
||||
}
|
||||
@@ -88,10 +112,11 @@ export const removeRule = (s: AgentState, id: number): AgentState => ({ ...s, it
|
||||
// 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(' '))
|
||||
+ 'otak senin selasa rabu kamis jumat sabtu sip lanjut bahasa pakai jadi kalau kalo juga aja saja sekarang mulai konten baru materi '
|
||||
+ 'pembelajaran menit jam dalam simpan gunakan pastikan').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(' '))
|
||||
+ 'sunday yes no okay sure start use language also just now content new minutes hours save make sure').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 {
|
||||
@@ -109,19 +134,72 @@ 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/
|
||||
const MONTHLY = /\b(per ?bulan|tiap bulan|setiap bulan|sebulan|bulanan|per month|a month|every month|each month|monthly)\b/
|
||||
const MIX_REPLY = /^(campur|campuran|mix|mixed|bebas|terserah|apa saja|any)\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.
|
||||
const noteText = (notes: Note[]) => notes.map(n => `\n${t(n.key, n.vars)}`).join('')
|
||||
|
||||
// A new instruction: its rules join the plan and its unclear parts are queued as questions. Requests about
|
||||
// how schedules run (drafts, the hour, "automatically") get a truthful note instead of being ignored.
|
||||
function instruct(s: AgentState, text: string): Turn | null {
|
||||
const { rules, unknown, language } = parseInstruction(text)
|
||||
const languageNote = language ? `\n${t('agent.languageSet', { language: languageLabel(language) })}` : ''
|
||||
if (!rules.length) return language ? { state: { ...s, language }, reply: languageNote.trim() } : null
|
||||
return {
|
||||
state: { ...s, items: [...s.items, ...withIds(rules)], unknown: [...s.unknown, ...unknown], language: language ?? s.language },
|
||||
reply: t(s.items.length ? 'agent.added' : 'agent.understood', { list: bullets(s, rules) }) + languageNote,
|
||||
const { rules, unknown, language, notes } = parseInstruction(text)
|
||||
const extra = (language ? `\n${t('agent.languageSet', { language: languageLabel(language) })}` : '') + noteText(notes)
|
||||
if (!rules.length) return extra ? { state: { ...s, language: language ?? s.language }, reply: extra.trim() } : null
|
||||
let state: AgentState = { ...s, items: [...s.items, ...withIds(rules)], unknown: [...s.unknown, ...unknown], language: language ?? s.language }
|
||||
// "Gunakan seluruh materi" already answers the topic question with the whole Brain.
|
||||
const q = nextQuestion(state)
|
||||
if (q?.type === 'topic' && notes.some(n => n.key === 'note.wholeBrain')) state = { ...state, settled: [...state.settled, q.key] }
|
||||
return { state, reply: t(s.items.length ? 'agent.added' : 'agent.understood', { list: bullets(s, rules) }) + extra }
|
||||
}
|
||||
|
||||
// A placeholder rule becomes the rules the reply asks for, in its place in the plan.
|
||||
function replaceRule(s: AgentState, id: number, rules: Rule[], notes: Note[] = []): Turn {
|
||||
const at = s.items.findIndex(i => i.id === id)
|
||||
const items = [...s.items.slice(0, at), ...withIds(rules), ...s.items.slice(at + 1)]
|
||||
return { state: { ...s, items }, reply: t('agent.kindSet', { list: bullets(s, rules) }) + noteText(notes) }
|
||||
}
|
||||
|
||||
// "Ringkasan dan kuis" for "10 konten" splits the count: 5 summaries and 5 quizzes.
|
||||
function answerKind(s: AgentState, id: number, text: string, lower: string, mix: boolean): Turn | null {
|
||||
const rule = contentById(s, id)
|
||||
// A full instruction ("2 ringkasan per hari", or "10 konten" again) restates the request, so it
|
||||
// replaces the placeholder instead of adding a second one; what it leaves out is kept from before.
|
||||
if (/\d/.test(lower)) {
|
||||
const { rules: parsed, notes } = parseInstruction(text)
|
||||
if (!parsed.length) return null
|
||||
return replaceRule(s, id, parsed.map(r => {
|
||||
if (r.type === 'research') return r.topic ? r : { ...r, topic: rule.topic }
|
||||
const keepPer = r.perGuessed && !rule.perGuessed ? { per: rule.per, perGuessed: undefined } : {}
|
||||
return { ...r, topic: r.topic || rule.topic, ...keepPer }
|
||||
}), notes)
|
||||
}
|
||||
const kinds = mix ? MIX : [...new Set([...kindsIn(lower), ...KINDS.filter(k => lower === kindLabel(k).toLowerCase())])]
|
||||
if (!kinds.length) return null
|
||||
const { kindGuessed: _, ...base } = rule
|
||||
return replaceRule(s, id, kinds.map((kind, i) => ({
|
||||
...base,
|
||||
kind,
|
||||
count: Math.max(1, Math.floor(rule.count / kinds.length) + (i < rule.count % kinds.length ? 1 : 0)),
|
||||
})))
|
||||
}
|
||||
|
||||
// "Per hari" sets the frequency of every rule asked about; a number ("5 per hari") only when there is one.
|
||||
function answerPer(s: AgentState, ids: number[], lower: string): Turn | null {
|
||||
// A reply naming a kind or research is a new request, not a frequency for these.
|
||||
if (kindsIn(lower).length || /\b(riset|research)\b/.test(lower)) return null
|
||||
const n = ids.length === 1 ? Number(lower.match(/\d+/)?.[0]) || null : null
|
||||
const monthly = MONTHLY.test(lower) || /\b(bulan|month)\b/.test(lower)
|
||||
const per = monthly || WEEKLY.test(lower) || /\b(minggu|week)\b/.test(lower) ? 'week'
|
||||
: DAILY.test(lower) || /\b(hari|day)\b/.test(lower) ? 'day' : null
|
||||
if (!per) return null
|
||||
const state = ids.reduce((st, id) => {
|
||||
const count = n ?? contentById(st, id).count
|
||||
return editRule(st, id, { per, count: monthly ? Math.max(1, Math.round(count / 4)) : count })
|
||||
}, s)
|
||||
return { state, reply: t('agent.kindSet', { list: bullets(state, ids.map(id => ruleById(state, id))) }) }
|
||||
}
|
||||
|
||||
// A reply to the language question may be short ("indo", "inggris", "zh"), so it is read more loosely.
|
||||
@@ -157,9 +235,11 @@ function answer(q: Question, s: AgentState, text: string, lower: string, is: (k:
|
||||
return { state: q.ids.reduce((st, id) => editRule(st, id, { topic }), s), reply: t('agent.topicSet', { topic }) }
|
||||
}
|
||||
|
||||
if (q.type === 'per') return answerPer(s, q.ids, is('agent.optEveryDay') ? 'per hari' : is('agent.optEveryWeek') ? 'per minggu' : lower)
|
||||
if (REMOVE.test(lower) || is('agent.optRemove')) {
|
||||
return { state: removeRule(s, q.id), reply: t('agent.removed', { rule: describe(s, ruleById(s, q.id)) }) }
|
||||
}
|
||||
if (q.type === 'kind') return answerKind(s, q.id, text, lower, MIX_REPLY.test(lower) || is('agent.optMix'))
|
||||
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') {
|
||||
@@ -182,8 +262,13 @@ export function respond(s: AgentState, input: string): Turn {
|
||||
|
||||
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') }
|
||||
const turn = answer(q, s, text, lower, is) ?? instruct(s, text)
|
||||
if (turn) return turn
|
||||
// A rephrasing that still made no sense: asking about the old wording again would loop, so it is
|
||||
// dropped and the agent says what it can do instead.
|
||||
if (q.type === 'unknown') return { state: { ...s, unknown: s.unknown.slice(1) }, reply: t('agent.notUnderstood') }
|
||||
// Otherwise the same question is asked again.
|
||||
return { 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') }
|
||||
|
||||
@@ -78,3 +78,26 @@ describe('counts', () => {
|
||||
expect(parseInstruction('buat 0 ringkasan per minggu').rules[0]).toMatchObject({ kind: 'summary', count: 1 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('generic content and sentences', () => {
|
||||
it('reads "konten" and "pdf" with a count as content of a kind still to choose', () => {
|
||||
expect(content('10 pdf per day')[0]).toMatchObject({ kind: 'summary', count: 10, per: 'day', kindGuessed: true })
|
||||
expect(content('bikin konten tiap minggu soal sales')[0]).toMatchObject({ kindGuessed: true, topic: 'sales' })
|
||||
})
|
||||
|
||||
it('does not read "gunakan seluruh materi" as a request', () => {
|
||||
expect(parseInstruction('gunakan seluruh materi pembelajaran sebagai referensi').rules).toEqual([])
|
||||
})
|
||||
|
||||
it('marks a rule with no frequency instead of silently making it weekly', () => {
|
||||
expect(content('3 kuis soal sales')[0]).toMatchObject({ per: 'week', perGuessed: true })
|
||||
expect(content('3 kuis per minggu soal sales')[0].perGuessed).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not end a sentence inside a time such as 09.00', () => {
|
||||
const { rules, unknown } = parseInstruction('buat 2 kuis tiap hari soal sales pada pukul 09.00 WIB. simpan sebagai draft')
|
||||
expect(rules).toHaveLength(1)
|
||||
expect(rules[0]).toMatchObject({ topic: 'sales' })
|
||||
expect(unknown).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -30,6 +30,20 @@ const KIND_WORDS: [ContentKind, RegExp][] = [
|
||||
['summary', /\b(summary|summaries|ringkasan|rangkuman|artikel|articles?|teks|texts?)\b/],
|
||||
]
|
||||
|
||||
/** Every content kind a text names, in the order it names them: "ringkasan dan kuis" → summary, quiz. */
|
||||
export function kindsIn(text: string): ContentKind[] {
|
||||
return KIND_WORDS
|
||||
.map(([kind, re]) => [kind, text.search(re)] as const)
|
||||
.filter(([, at]) => at >= 0)
|
||||
.sort((a, b) => a[1] - b[1])
|
||||
.map(([kind]) => kind)
|
||||
}
|
||||
|
||||
// "10 konten", "5 materi pembelajaran", "10 pdf": content of no particular kind, so the agent asks which.
|
||||
// Only with a count or a create verb before it, so "gunakan seluruh materi" is not a request.
|
||||
const GENERIC = /\b(konten|content|contents|materi|materials?|bahan ajar|pdf|pdfs|dokumen|documents?|modul|modules?|items?)\b/
|
||||
const CREATE = /\b(bikin|bikinin|buat|buatkan|membuat|hasilkan|produksi|make|create|generate|produce|write|tulis)\b/
|
||||
|
||||
const NUMBER_WORDS: Record<string, number> = {
|
||||
satu: 1, se: 1, sebuah: 1, dua: 2, tiga: 3, empat: 4, lima: 5, enam: 6, tujuh: 7, delapan: 8, sembilan: 9, sepuluh: 10,
|
||||
a: 1, an: 1, one: 1, two: 2, three: 3, four: 4, five: 5, six: 6, seven: 7, eight: 8, nine: 9, ten: 10,
|
||||
@@ -77,6 +91,10 @@ export interface ContentRule {
|
||||
language?: OutputLanguage
|
||||
/** Made from material chosen in the Brain panel rather than the whole Brain. */
|
||||
chosen?: boolean
|
||||
/** The instruction said "content" without a kind, so the kind is only a placeholder the agent asks about. */
|
||||
kindGuessed?: boolean
|
||||
/** The instruction named no frequency, so per week is only a placeholder the agent asks about. */
|
||||
perGuessed?: boolean
|
||||
}
|
||||
|
||||
export interface ResearchRule {
|
||||
@@ -91,37 +109,81 @@ export interface ResearchRule {
|
||||
|
||||
export type Rule = ContentRule | ResearchRule
|
||||
|
||||
/** Something the instruction asked for that is not a rule: the agent answers it truthfully instead of ignoring it. */
|
||||
export interface Note { key: Key; vars?: Record<string, string | number> }
|
||||
|
||||
export interface ParseResult {
|
||||
rules: Rule[]
|
||||
/** Clauses that said something but matched no rule, so the user can rephrase them. */
|
||||
unknown: string[]
|
||||
/** The output language the instruction named. */
|
||||
language?: OutputLanguage
|
||||
notes: Note[]
|
||||
}
|
||||
|
||||
// Requests about how schedules work rather than what they make. Each is answered with what Express
|
||||
// actually does, so the agent neither ignores them nor promises something it cannot do.
|
||||
const META: [RegExp, Key | null][] = [
|
||||
[/\b(?:seluruh|semua)\s+(?:materi|bahan|dokumen|otak|brain|isi)|\bwhole brain\b|\ball (?:the )?(?:materials?|documents?|brain)\b/, 'note.wholeBrain'],
|
||||
[/duplika|duplicat|\b(?:tidak|jangan|tanpa|bukan)\s+(?:meng)?ulang|\b(?:without|not|don'?t|no)\s+repeat|\b(?:topik|topics?)\s+(?:yang\s+)?(?:berbeda|beda|different|unik|unique)|\bdifferent topics?\b/, 'note.novelty'],
|
||||
[/\b(?:draf|draft|drafts|ditinjau|tinjau|peninjauan|reviews?|reviewed|approval|persetujuan|disetujui|dipublikasikan|publikasi|published?)\b/, 'note.draft'],
|
||||
[/\b(?:scheduler|penjadwal|background|latar belakang|cron|otomatis|automatic(?:ally)?|terjadwal)\b/, 'note.automatic'],
|
||||
// Wishes every schedule already aims for; nothing to add, but not a part the agent failed to understand.
|
||||
[/\b(?:relevan|relevant|informatif|informative|berkualitas|bermanfaat|menarik|useful|engaging|quality|referensi|reference)\b/, null],
|
||||
]
|
||||
const HOUR = /\b(?:pukul|pkl|jam|at)\s*(\d{1,2})(?:[.:](\d{2}))?\s*(am|pm)?/
|
||||
const SUB_DAY = /\b\d+\s*(?:menit|jam|detik|minutes?|mins?|hours?|seconds?)\b|\b(?:tiap|setiap|per|every|each)\s+(?:menit|jam|minute|hour)\b|\bhourly\b/
|
||||
|
||||
function notesOf(text: string): Note[] {
|
||||
const notes: Note[] = META.flatMap(([re, key]) => (key && re.test(text) ? [{ key }] : []))
|
||||
const hour = text.match(HOUR)
|
||||
if (hour) {
|
||||
let h = Number(hour[1])
|
||||
if (hour[3] === 'pm' && h < 12) h += 12
|
||||
if (hour[3] === 'am' && h === 12) h = 0
|
||||
const at = `${String(h).padStart(2, '0')}.${hour[2] ?? '00'}`
|
||||
notes.push({ key: at === '09.00' ? 'note.hourMatches' : 'note.hourFixed', vars: { at } })
|
||||
}
|
||||
if (SUB_DAY.test(text)) notes.push({ key: 'note.subDay' })
|
||||
return notes
|
||||
}
|
||||
|
||||
const isMeta = (clause: string) => META.some(([re]) => re.test(clause)) || HOUR.test(clause) || SUB_DAY.test(clause)
|
||||
|
||||
// "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.*$/
|
||||
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|yang\b)(.+)$/
|
||||
const FREQUENCY_TAIL = /\b(tiap|setiap|per|sehari|seminggu|every|each|daily|weekly|monthly|a day|a week|a month|pada pukul|pada jam|pukul|pkl|secara|otomatis|automatically|dalam \d|in \d|at \d)\b.*$/
|
||||
|
||||
function topicOf(clause: string): string {
|
||||
return clause.match(TOPIC)?.[1].replace(FREQUENCY_TAIL, '').replace(/[\s,]+$/, '').trim() ?? ''
|
||||
}
|
||||
|
||||
function countOf(clause: string, kindAt: number): number {
|
||||
/** The count right before a kind word, or null when the instruction named none. */
|
||||
function countBefore(clause: string, kindAt: number): number | null {
|
||||
const before = clause.slice(0, kindAt)
|
||||
const digits = before.match(/(\d+)\s*(?:buah|x|kali|new)?\s*$/)
|
||||
// "0" schedules nothing; the preview shows 1, which the user can still change.
|
||||
if (digits) return Math.max(1, Number(digits[1]))
|
||||
const word = before.match(/(\p{L}+)\s*(?:buah|new)?\s*$/u)?.[1]
|
||||
return (word && NUMBER_WORDS[word]) || 1
|
||||
return (word && NUMBER_WORDS[word]) || null
|
||||
}
|
||||
|
||||
const countOf = (clause: string, kindAt: number) => countBefore(clause, kindAt) ?? 1
|
||||
|
||||
const DAY_WORDS = /\b(per ?hari|tiap hari|setiap hari|sehari|harian|per day|a day|every day|each day|daily)\b/
|
||||
const WEEK_WORDS = /\b(per ?minggu|tiap minggu|setiap minggu|seminggu|mingguan|per week|a week|every week|each week|weekly)\b/
|
||||
const MONTH_WORDS = /\b(per ?bulan|tiap bulan|setiap bulan|sebulan|bulanan|per month|a month|every month|each month|monthly)\b/
|
||||
|
||||
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'
|
||||
if (DAY_WORDS.test(clause)) return 'day'
|
||||
if (MONTH_WORDS.test(clause)) return 'month'
|
||||
return 'week'
|
||||
}
|
||||
|
||||
/** Whether a clause says how often, rather than leaving perOf to fall back to per week. */
|
||||
export const namesFrequency = (clause: string) =>
|
||||
DAY_WORDS.test(clause) || WEEK_WORDS.test(clause) || MONTH_WORDS.test(clause) || weekdayOf(clause) !== null
|
||||
|
||||
export function weekdayOf(clause: string): number | null {
|
||||
for (const [re, day] of WEEKDAYS) if (re.test(clause)) return day
|
||||
return null
|
||||
@@ -130,7 +192,8 @@ export function weekdayOf(clause: string): number | null {
|
||||
const CONNECTORS = 'dan|lalu|serta|terus|kemudian|juga|and|then|also|plus'
|
||||
// A connector or comma starts a new rule only when a new request follows; "about sales and marketing" stays one topic.
|
||||
const NEXT_REQUEST = String.raw`(?=(?:\d|satu|dua|tiga|empat|lima|enam|tujuh|delapan|sembilan|sepuluh|se(?:buah)?\s|one|two|three|four|five|six|seven|eight|nine|ten|an?\s|riset|research|bikin|buat|cari|telusuri|pantau|make|create|generate|search|monitor|tiap|setiap|every|each))`
|
||||
const SPLIT = new RegExp(String.raw`[\n.;]+|\s*,\s*(?:(?:${CONNECTORS})\s+)?${NEXT_REQUEST}|\s+(?:${CONNECTORS})\s+${NEXT_REQUEST}`)
|
||||
// A full stop ends a sentence, but not inside a time or number such as "09.00".
|
||||
const SPLIT = new RegExp(String.raw`(?:[\n;]|\.(?!\d))+|\s*,\s*(?:(?:${CONNECTORS})\s+)?${NEXT_REQUEST}|\s+(?:${CONNECTORS})\s+${NEXT_REQUEST}`)
|
||||
|
||||
function parseClause(clause: string): Rule | null {
|
||||
if (/\b(riset|research|telusuri|cari(?:kan)?|pantau|look up|search|monitor)\b/.test(clause)) {
|
||||
@@ -143,15 +206,23 @@ function parseClause(clause: string): Rule | null {
|
||||
}
|
||||
for (const [kind, re] of KIND_WORDS) {
|
||||
const m = re.exec(clause)
|
||||
if (!m) continue
|
||||
const count = countOf(clause, m.index)
|
||||
if (m) return contentRule(clause, kind, countOf(clause, m.index))
|
||||
}
|
||||
const g = GENERIC.exec(clause)
|
||||
if (g) {
|
||||
const count = countBefore(clause, g.index)
|
||||
if (count !== null || CREATE.test(clause.slice(0, g.index))) return { ...contentRule(clause, 'summary', count ?? 1), kindGuessed: true }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function contentRule(clause: string, kind: ContentKind, count: number): ContentRule {
|
||||
const per = perOf(clause)
|
||||
const guessed = namesFrequency(clause) ? {} : { perGuessed: true }
|
||||
if (per === 'month') {
|
||||
return { type: 'content', kind, count: Math.max(1, Math.round(count / 4)), per: 'week', topic: topicOf(clause), monthly: count }
|
||||
}
|
||||
return { type: 'content', kind, count, per, topic: topicOf(clause) }
|
||||
}
|
||||
return null
|
||||
return { type: 'content', kind, count, per, topic: topicOf(clause), ...guessed }
|
||||
}
|
||||
|
||||
export function parseInstruction(text: string): ParseResult {
|
||||
@@ -170,6 +241,8 @@ export function parseInstruction(text: string): ParseResult {
|
||||
for (const clause of clauses) {
|
||||
const rule = parseClause(clause)
|
||||
if (rule) rules.push(rule)
|
||||
// "simpan sebagai draft", "jalankan otomatis": answered as notes, not asked about.
|
||||
else if (isMeta(clause)) continue
|
||||
// A clause that only names a topic ("about leadership") applies to the rules around it.
|
||||
else if (topicOf(clause) || /^(soal|tentang|topik|about|topic)\b/.test(clause)) shared = topicOf(clause) || clause.replace(/^\S+\s*/, '')
|
||||
else if (!/^(tolong|bikin|buat|buatkan|ya|please|make|create|ok)$/.test(clause)) unknown.push(clause)
|
||||
@@ -187,7 +260,8 @@ export function parseInstruction(text: string): ParseResult {
|
||||
if (at >= 0) r.topic = text.slice(at, at + r.topic.length)
|
||||
}
|
||||
}
|
||||
return language ? { rules, unknown, language } : { rules, unknown }
|
||||
const notes = notesOf(text.toLowerCase())
|
||||
return language ? { rules, unknown, language, notes } : { rules, unknown, notes }
|
||||
}
|
||||
|
||||
export function weeklyTarget(rule: ContentRule): number {
|
||||
@@ -209,10 +283,11 @@ export function describeRule(rule: Rule, from?: string): string {
|
||||
const when = rule.cadence === 'daily' ? t('rule.everyDay') : t('rule.everyWeekday', { day: dayName(rule.weekday) })
|
||||
return t('rule.research', { when, about })
|
||||
}
|
||||
// A placeholder kind or frequency is shown as still open, not as if the user had chosen it.
|
||||
return t('rule.content', {
|
||||
kind: kindLabel(rule.kind),
|
||||
kind: rule.kindGuessed ? t('rule.anyKind') : kindLabel(rule.kind),
|
||||
count: rule.count,
|
||||
per: t(rule.per === 'day' ? 'ins.perDay' : 'ins.perWeek'),
|
||||
per: rule.perGuessed ? t('rule.perUnset') : t(rule.per === 'day' ? 'ins.perDay' : 'ins.perWeek'),
|
||||
about: about || (from ?? (rule.chosen ? t('rule.chosenMaterial') : null)
|
||||
? t('rule.from', { source: from ?? t('rule.chosenMaterial') })
|
||||
: t('rule.wholeBrain')),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { ContentRule, ResearchRule } from './parse'
|
||||
import { budgetFor, canSchedule, configFor, ruleOf, VIDEO, type ExpressAgent } from './schedules'
|
||||
import { budgetFor, canSchedule, configFor, needsTwoSources, productionError, ruleOf, VIDEO, type ExpressAgent } from './schedules'
|
||||
|
||||
const rule = (kind: ContentRule['kind'], count = 2, per: ContentRule['per'] = 'week', topic = 'onboarding', language: ContentRule['language'] = 'English'): ContentRule =>
|
||||
({ type: 'content', kind, count, per, topic, language })
|
||||
@@ -108,3 +108,17 @@ describe('canSchedule', () => {
|
||||
expect(canSchedule(rule('summary', 0))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('production sources', () => {
|
||||
it('needs two sources for planned content, not for video or research', () => {
|
||||
expect(needsTwoSources(rule('quiz'))).toBe(true)
|
||||
expect(needsTwoSources(rule('video', 1))).toBe(false)
|
||||
expect(needsTwoSources({ type: 'research', cadence: 'weekly', weekday: 2, topic: 'sales', autoApprove: false })).toBe(false)
|
||||
})
|
||||
|
||||
it("explains the engine's refusal for too few sources and passes others through", () => {
|
||||
expect(productionError('At least two ready sources with complementary evidence are required')).toMatch(/2/)
|
||||
expect(productionError('At least two ready sources with complementary evidence are required')).not.toMatch(/complementary/)
|
||||
expect(productionError('Something else')).toBe('Something else')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// use run_schedule (one run per day or week; a video run renders one video).
|
||||
import { opKey, request } from '../api/client'
|
||||
import { listDocuments, type Agent, type RunSchedule } from '../api/engine'
|
||||
import { getLang } from '../i18n'
|
||||
import { getLang, t } from '../i18n'
|
||||
import {
|
||||
KINDS, LANGUAGES, SCHEDULABLE, videoCadence, weeklyTarget,
|
||||
type ContentKind, type ContentRule, type OutputLanguage, type Per, type ResearchRule, type Rule,
|
||||
@@ -110,6 +110,15 @@ export const listSchedules = () => expressAgents()
|
||||
/** Schedules made of one engine run per slot (research, video), rather than weekly production plans. */
|
||||
export const isRunSchedule = (rule: Rule) => rule.type === 'research' || rule.kind === 'video'
|
||||
|
||||
// The engine plans each production piece from a pair of documents, so a content schedule
|
||||
// with fewer ready sources than this cannot plan anything. Video and research runs need one.
|
||||
export const MIN_PRODUCTION_SOURCES = 2
|
||||
export const needsTwoSources = (rule: Rule) => !isRunSchedule(rule)
|
||||
const TOO_FEW_SOURCES = 'At least two ready sources with complementary evidence are required'
|
||||
/** The engine's planner refusal, said in the UI language; any other message as it came. */
|
||||
export const productionError = (message: string) =>
|
||||
(message.includes(TOO_FEW_SOURCES) ? t('sch.tooFewSources', { n: MIN_PRODUCTION_SOURCES }) : message)
|
||||
|
||||
// Video length is fixed per schedule: 4 scenes, 40 seconds in all (the engine allows 4–15 s per scene).
|
||||
export const VIDEO = { scenes: 4, seconds: 40 }
|
||||
|
||||
@@ -355,8 +364,22 @@ export interface RunSummary {
|
||||
trigger_kind: string
|
||||
safe_error_code: string | null
|
||||
budget_spent: number | null
|
||||
has_readiness_issues: boolean
|
||||
created_at: string
|
||||
finished_at: string | null
|
||||
}
|
||||
|
||||
export const listRuns = (id: number) => request<RunSummary[]>(`${AGENTS}/${id}/runs`)
|
||||
|
||||
// What stops a run with knowledge_not_ready: the schedule's own material that is not ready (the whole
|
||||
// Library only for an agent with no material). This is the current state, not the run's.
|
||||
export interface ReadinessIssue {
|
||||
kind: 'document' | 'compose_set'
|
||||
resource_id: number
|
||||
title: string
|
||||
reason: string
|
||||
action_url: string
|
||||
}
|
||||
|
||||
export const knowledgeReadiness = (id: number) =>
|
||||
request<{ ready: boolean; issues: ReadinessIssue[] }>(`${AGENTS}/${id}/knowledge-readiness`)
|
||||
|
||||
106
src/i18n.ts
106
src/i18n.ts
@@ -126,7 +126,11 @@ const en = {
|
||||
'brain.needsCreditsNeed': ' (needs {credits})',
|
||||
'brain.failed': 'Could not add this to the Brain.',
|
||||
'brain.retryWith': 'Try again ({credits})',
|
||||
'brain.failedAfterBuild': 'Processing this file failed after its knowledge was built. Schedules that use the whole Brain stop until it is removed: delete it and upload it again.',
|
||||
'brain.failedAfterBuild': 'Processing this file failed after its knowledge was built. Schedules that use it stop until it is fixed. Process it again: its knowledge is kept.',
|
||||
'brain.failedProcessing': 'This file could not be processed. Schedules that use it stop until it is fixed. Process it again, or delete it.',
|
||||
'brain.failedVideoAfterBuild': 'Processing this video failed after its knowledge was built. Schedules that use it stop until it is fixed: upload it again.',
|
||||
'brain.reprocess': 'Process again',
|
||||
'brain.reprocessing': 'Starting…',
|
||||
'brain.confirmCopy': 'Adding this document to the Brain uses {credits} from the workspace.',
|
||||
'brain.confirmCreditsFallback': 'credits',
|
||||
'brain.addToBrain': 'Add to Brain',
|
||||
@@ -178,6 +182,8 @@ const en = {
|
||||
'rule.wholeBrain': ' from the whole Brain',
|
||||
'rule.from': ' from {source}',
|
||||
'rule.chosenMaterial': 'chosen material',
|
||||
'rule.anyKind': 'Content (kind not chosen yet)',
|
||||
'rule.perUnset': '(how often not set yet)',
|
||||
'rule.content': '{kind}: {count} {per}{about}',
|
||||
'rule.monthNote': '{count} per month is scheduled as {weekly} per week.',
|
||||
'kind.summary': 'Summary',
|
||||
@@ -242,6 +248,33 @@ const en = {
|
||||
'sch.scheduled': 'Scheduled',
|
||||
'sch.manual': 'Manual',
|
||||
'run.queued': 'Queued', 'run.planning': 'Planning', 'run.running': 'Running', 'run.completed': 'Completed', 'run.failed': 'Failed', 'run.cancelled': 'Cancelled',
|
||||
'run.paused_configuration': 'Needs attention', 'run.paused_budget': 'Paused: credits',
|
||||
'runErr.knowledge_not_ready': 'Some material this schedule uses is not ready, so the engine will not start.',
|
||||
'runErr.video_sources_unavailable': 'No ready material matched this schedule for a video.',
|
||||
'runErr.material_sources_unavailable': 'No ready material matched this schedule.',
|
||||
'runErr.knowledge_search_failed': 'Searching the material failed. Try again.',
|
||||
'runErr.organization_wallet': 'The organization does not have enough credits.',
|
||||
'runErr.run_cap': 'This run would exceed the per-run credit limit.',
|
||||
'runErr.monthly_cap': 'The monthly credit limit for this schedule is reached.',
|
||||
'runErr.reservation_cap': 'Too many credits are already reserved by other runs.',
|
||||
'runErr.price_changed': 'The price changed. Try again.',
|
||||
'runErr.video_price_changed': 'The video price changed. Try again.',
|
||||
'runErr.agent_not_active': 'The schedule is paused or deleted.',
|
||||
'runErr.run_access_revoked': 'You no longer have permission to run this schedule.',
|
||||
'runErr.required_tool_unavailable': 'A tool this schedule needs is turned off.',
|
||||
'sch.blockedBy': 'Blocking material:',
|
||||
'sch.blockedNone': 'All material is ready now. Try again.',
|
||||
'sch.fixInStudio': 'Open in Learning Studio',
|
||||
'issue.document_processing': 'still processing',
|
||||
'issue.document_failed': 'failed to process; reprocess or delete it',
|
||||
'issue.graph_building': 'knowledge graph still building',
|
||||
'issue.graph_not_ready': 'knowledge graph not prepared yet',
|
||||
'issue.legacy_set_needs_conversion': 'old Compose set; convert it',
|
||||
'issue.no_knowledge_sources': 'Compose set has no sources',
|
||||
'issue.source_not_accessible': 'Compose set uses a document you cannot access',
|
||||
'issue.source_graph_not_ready': 'a source document is not ready',
|
||||
'issue.source_version_changed': 'a source document changed; refresh the set',
|
||||
'issue.snapshot_not_ready': 'Compose graph not prepared yet',
|
||||
|
||||
// Results
|
||||
'res.title': 'Results',
|
||||
@@ -327,7 +360,20 @@ const en = {
|
||||
'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.notUnderstood': 'I could not find a content type or research request in that. I can make summaries, quizzes, flashcards, roleplays, simulations, slides, videos, audio and SCORM modules from the Brain, or research the internet. Try something like “2 summaries per day about leadership”.',
|
||||
'agent.askKind': '{count} pieces of content{about}: which kind? Pick one, or name several (for example “summaries and quizzes”) to split them.',
|
||||
'agent.askPer': 'How often should this be made? You did not say, and I would rather not guess.\n{list}',
|
||||
'agent.optMix': 'Mix (summaries, quizzes, flashcards)',
|
||||
'agent.optEveryDay': 'Every day',
|
||||
'agent.optEveryWeek': 'Every week',
|
||||
'agent.kindSet': 'Updated the plan:\n{list}',
|
||||
'note.wholeBrain': 'Sources: the whole Brain (every ready document), unless you tick specific material in the Brain panel.',
|
||||
'note.novelty': 'Each production plan is checked against what was already made, so topics are not repeated.',
|
||||
'note.draft': 'Everything made lands in Results as a draft marked “Needs review”: an admin approves or rejects it there before it is used.',
|
||||
'note.automatic': 'Once you approve, the schedules run on their own in the background; nothing needs starting by hand.',
|
||||
'note.hourMatches': 'Content is produced at {at} WIB, as you asked.',
|
||||
'note.hourFixed': 'Content is produced at 09.00 WIB and research runs at 07.00 WIB; a different hour such as {at} cannot be set here yet.',
|
||||
'note.subDay': 'Schedules run per day or per week, so I cannot promise results within minutes or hours. Once a schedule is active, “Produce now” on the Schedule page starts it early.',
|
||||
'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?',
|
||||
@@ -423,6 +469,9 @@ const en = {
|
||||
'brainPanel.notReady': 'Can be chosen once it is in the Brain',
|
||||
'ins.sourcesChosen': 'Source: chosen material ({n})',
|
||||
'ins.sourcesAll': 'Source: the whole Brain',
|
||||
'ins.tooFewChosen': ' Content schedules (summary, quiz and the like) need at least {n} materials. Choose one more in the Brain panel.',
|
||||
'ins.tooFewInBrain': ' Content schedules (summary, quiz and the like) need at least {n} ready materials in the Brain. Upload another one first.',
|
||||
'sch.tooFewSources': 'This schedule has fewer than {n} ready materials, and content is planned from pairs of materials. Delete it and make it again with {n} or more.',
|
||||
'brainPanel.upload': 'Upload material',
|
||||
|
||||
// Guide
|
||||
@@ -569,7 +618,11 @@ const id: Record<Key, string> = {
|
||||
'brain.needsCreditsNeed': ' (butuh {credits})',
|
||||
'brain.failed': 'Gagal memasukkan ke Otak.',
|
||||
'brain.retryWith': 'Coba lagi ({credits})',
|
||||
'brain.failedAfterBuild': 'Pemrosesan file ini gagal setelah pengetahuannya dibangun. Jadwal yang memakai seluruh Otak berhenti sampai file ini dihapus: hapus lalu unggah ulang.',
|
||||
'brain.failedAfterBuild': 'Pemrosesan file ini gagal setelah pengetahuannya dibangun. Jadwal yang memakainya berhenti sampai ini diperbaiki. Proses ulang: pengetahuannya tetap disimpan.',
|
||||
'brain.failedProcessing': 'File ini gagal diproses. Jadwal yang memakainya berhenti sampai ini diperbaiki. Proses ulang, atau hapus.',
|
||||
'brain.failedVideoAfterBuild': 'Pemrosesan video ini gagal setelah pengetahuannya dibangun. Jadwal yang memakainya berhenti sampai ini diperbaiki: unggah ulang.',
|
||||
'brain.reprocess': 'Proses ulang',
|
||||
'brain.reprocessing': 'Memulai…',
|
||||
'brain.confirmCopy': 'Memasukkan dokumen ini ke Otak memakai {credits} dari ruang kerja.',
|
||||
'brain.confirmCreditsFallback': 'kredit',
|
||||
'brain.addToBrain': 'Masukkan ke Otak',
|
||||
@@ -619,6 +672,8 @@ const id: Record<Key, string> = {
|
||||
'rule.wholeBrain': ' dari seluruh Otak',
|
||||
'rule.from': ' dari {source}',
|
||||
'rule.chosenMaterial': 'materi pilihan',
|
||||
'rule.anyKind': 'Konten (jenis belum dipilih)',
|
||||
'rule.perUnset': '(frekuensi belum ditentukan)',
|
||||
'rule.content': '{kind}: {count} {per}{about}',
|
||||
'rule.monthNote': '{count} per bulan dijadwalkan sebagai {weekly} per minggu.',
|
||||
'kind.summary': 'Ringkasan',
|
||||
@@ -682,6 +737,33 @@ const id: Record<Key, string> = {
|
||||
'sch.scheduled': 'Terjadwal',
|
||||
'sch.manual': 'Manual',
|
||||
'run.queued': 'Antre', 'run.planning': 'Merencanakan', 'run.running': 'Berjalan', 'run.completed': 'Selesai', 'run.failed': 'Gagal', 'run.cancelled': 'Dibatalkan',
|
||||
'run.paused_configuration': 'Perlu tindakan', 'run.paused_budget': 'Tertahan: kredit',
|
||||
'runErr.knowledge_not_ready': 'Sebagian materi yang dipakai jadwal ini belum siap, jadi engine tidak mau mulai.',
|
||||
'runErr.video_sources_unavailable': 'Tidak ada materi siap yang cocok untuk video jadwal ini.',
|
||||
'runErr.material_sources_unavailable': 'Tidak ada materi siap yang cocok dengan jadwal ini.',
|
||||
'runErr.knowledge_search_failed': 'Pencarian materi gagal. Coba lagi.',
|
||||
'runErr.organization_wallet': 'Kredit organisasi tidak cukup.',
|
||||
'runErr.run_cap': 'Run ini melebihi batas kredit per run.',
|
||||
'runErr.monthly_cap': 'Batas kredit bulanan jadwal ini sudah tercapai.',
|
||||
'runErr.reservation_cap': 'Terlalu banyak kredit sudah dipesan oleh run lain.',
|
||||
'runErr.price_changed': 'Harga berubah. Coba lagi.',
|
||||
'runErr.video_price_changed': 'Harga video berubah. Coba lagi.',
|
||||
'runErr.agent_not_active': 'Jadwal sedang dijeda atau sudah dihapus.',
|
||||
'runErr.run_access_revoked': 'Kamu tidak lagi punya izin menjalankan jadwal ini.',
|
||||
'runErr.required_tool_unavailable': 'Alat yang dibutuhkan jadwal ini sedang nonaktif.',
|
||||
'sch.blockedBy': 'Materi yang menahan:',
|
||||
'sch.blockedNone': 'Semua materi sudah siap. Coba lagi.',
|
||||
'sch.fixInStudio': 'Buka di Learning Studio',
|
||||
'issue.document_processing': 'masih diproses',
|
||||
'issue.document_failed': 'gagal diproses; proses ulang atau hapus',
|
||||
'issue.graph_building': 'knowledge graph masih dibangun',
|
||||
'issue.graph_not_ready': 'knowledge graph belum disiapkan',
|
||||
'issue.legacy_set_needs_conversion': 'set Compose lama; perlu dikonversi',
|
||||
'issue.no_knowledge_sources': 'set Compose tidak punya sumber',
|
||||
'issue.source_not_accessible': 'set Compose memakai dokumen yang tidak bisa kamu akses',
|
||||
'issue.source_graph_not_ready': 'salah satu dokumen sumber belum siap',
|
||||
'issue.source_version_changed': 'dokumen sumber berubah; perbarui set-nya',
|
||||
'issue.snapshot_not_ready': 'graph Compose belum disiapkan',
|
||||
|
||||
'res.title': 'Hasil',
|
||||
'res.description': 'Semua konten yang dihasilkan Express: teks, kuis, roleplay, video, audio, dan SCORM.',
|
||||
@@ -766,7 +848,20 @@ const id: Record<Key, string> = {
|
||||
'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.notUnderstood': 'Saya belum menemukan jenis konten atau permintaan riset di situ. Saya bisa membuat ringkasan, kuis, flashcard, roleplay, simulasi, slide, video, audio, dan modul SCORM dari Otak, atau riset internet. Coba misalnya “2 ringkasan per hari soal leadership”.',
|
||||
'agent.askKind': '{count} konten{about}: jenisnya apa? Pilih satu, atau sebut beberapa (misalnya “ringkasan dan kuis”) untuk membaginya.',
|
||||
'agent.askPer': 'Seberapa sering ini dibuat? Anda belum menyebutkannya, dan saya tidak mau menebak.\n{list}',
|
||||
'agent.optMix': 'Campuran (ringkasan, kuis, flashcard)',
|
||||
'agent.optEveryDay': 'Setiap hari',
|
||||
'agent.optEveryWeek': 'Setiap minggu',
|
||||
'agent.kindSet': 'Rencana diperbarui:\n{list}',
|
||||
'note.wholeBrain': 'Sumbernya seluruh Otak (semua dokumen yang siap), kecuali Anda mencentang materi tertentu di panel Otak.',
|
||||
'note.novelty': 'Setiap rencana produksi dicek terhadap konten yang sudah ada, jadi topiknya tidak berulang.',
|
||||
'note.draft': 'Semua hasil masuk ke Hasil sebagai draf berstatus “Perlu ditinjau”: admin menyetujui atau menolaknya di sana sebelum dipakai.',
|
||||
'note.automatic': 'Setelah Anda setujui, jadwal berjalan sendiri di latar belakang; tidak perlu dijalankan manual.',
|
||||
'note.hourMatches': 'Konten diproduksi pukul {at} WIB, sesuai permintaan.',
|
||||
'note.hourFixed': 'Konten diproduksi pukul 09.00 WIB dan riset pukul 07.00 WIB; jam lain seperti {at} belum bisa diatur dari sini.',
|
||||
'note.subDay': 'Jadwal berjalan per hari atau per minggu, jadi saya tidak bisa menjanjikan hasil dalam hitungan menit atau jam. Setelah jadwal aktif, tombol “Produksi sekarang” di halaman Jadwal memulainya lebih awal.',
|
||||
'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?',
|
||||
@@ -862,6 +957,9 @@ const id: Record<Key, string> = {
|
||||
'brainPanel.notReady': 'Bisa dipilih setelah masuk Brain',
|
||||
'ins.sourcesChosen': 'Sumber: {n} materi dipilih',
|
||||
'ins.sourcesAll': 'Sumber: seluruh Brain',
|
||||
'ins.tooFewChosen': ' Jadwal konten (ringkasan, kuis, dan sejenisnya) butuh minimal {n} materi. Pilih satu lagi di panel Brain.',
|
||||
'ins.tooFewInBrain': ' Jadwal konten (ringkasan, kuis, dan sejenisnya) butuh minimal {n} materi siap di Brain. Unggah satu lagi dulu.',
|
||||
'sch.tooFewSources': 'Jadwal ini punya kurang dari {n} materi siap, padahal konten direncanakan dari pasangan materi. Hapus lalu buat ulang dengan {n} materi atau lebih.',
|
||||
'brainPanel.upload': 'Upload materi',
|
||||
|
||||
// Panduan
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
answerVersion, buildKnowledge, deleteDocument, documentFile, knowledgeDetail, listDocuments, NEEDS_ANSWER, supportedExtensions, uploadDocument,
|
||||
answerVersion, buildKnowledge, deleteDocument, documentFile, knowledgeDetail, listDocuments, NEEDS_ANSWER, reprocessDocument, supportedExtensions, uploadDocument,
|
||||
type Source,
|
||||
} from '../api/engine'
|
||||
import { Icon } from '../components/Icon'
|
||||
@@ -13,10 +13,12 @@ import { locale, t } from '../i18n'
|
||||
type Phase = 'ready' | 'working' | 'answer' | 'failed'
|
||||
|
||||
function phaseOf(s: Source): Phase {
|
||||
// A failed document blocks every whole-Brain schedule in the engine, even with a built knowledge base.
|
||||
// A failed document blocks every schedule that uses it, even with a built knowledge base.
|
||||
if (s.status === 'failed' || s.knowledge === 'failed') return 'failed'
|
||||
// A reprocessed document keeps its built knowledge while it is read again, and still blocks schedules.
|
||||
if (s.status !== 'completed') return 'working'
|
||||
if (s.knowledge === 'ready') return 'ready'
|
||||
if (s.knowledge === 'building' || s.status !== 'completed') return 'working'
|
||||
if (s.knowledge === 'building') return 'working'
|
||||
return NEEDS_ANSWER.has(s.knowledge ?? '') ? 'answer' : 'working'
|
||||
}
|
||||
|
||||
@@ -298,8 +300,26 @@ function KnowledgeAction({ doc }: { doc: Source }) {
|
||||
const refresh = () => queryClient.invalidateQueries({ queryKey: ['documents'] })
|
||||
const build = useMutation({ mutationFn: () => buildKnowledge(doc.docId), onSuccess: refresh })
|
||||
const version = useMutation({ mutationFn: (link: boolean) => answerVersion(doc.docId, link), onSuccess: refresh })
|
||||
const error = build.error ?? version.error
|
||||
const busy = build.isPending || version.isPending
|
||||
const reprocess = useMutation({ mutationFn: () => reprocessDocument(doc.docId), onSuccess: refresh })
|
||||
const error = build.error ?? version.error ?? reprocess.error
|
||||
const busy = build.isPending || version.isPending || reprocess.isPending
|
||||
|
||||
// A failed file is read again, free; a knowledge base it already has is kept, so Compose sets
|
||||
// that pin it (and block deleting it) keep working. Video goes back through upload instead.
|
||||
if (doc.status === 'failed' && (doc.knowledge === 'ready' || doc.knowledge === 'not_built' || doc.knowledge === null)
|
||||
&& doc.format.toLowerCase() !== 'mp4') {
|
||||
return (
|
||||
<div className="doc-action">
|
||||
<small>{t(doc.knowledge === 'ready' ? 'brain.failedAfterBuild' : 'brain.failedProcessing')}</small>
|
||||
<div className="doc-action-buttons">
|
||||
<button className="btn small primary" disabled={busy} onClick={() => reprocess.mutate()}>
|
||||
{reprocess.isPending ? t('brain.reprocessing') : t('brain.reprocess')}
|
||||
</button>
|
||||
</div>
|
||||
{error && <span className="error">{error.message}</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const d = detail.data
|
||||
if (detail.isPending) return <div className="doc-action"><SkeletonLines label={t('common.loading')} /></div>
|
||||
@@ -323,8 +343,8 @@ function KnowledgeAction({ doc }: { doc: Source }) {
|
||||
actions = <button className="btn small" disabled={busy} onClick={() => build.mutate()}>{t('common.retry')}</button>
|
||||
break
|
||||
case 'ready':
|
||||
// Processing failed after the build: building again is refused, only removing it unblocks schedules.
|
||||
text = t('brain.failedAfterBuild')
|
||||
// Processing failed after the build. Documents are reprocessed above; this is a video.
|
||||
text = t('brain.failedVideoAfterBuild')
|
||||
break
|
||||
case 'failed':
|
||||
text = d.error || t('brain.failed')
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from '../express/parse'
|
||||
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'
|
||||
import { budgetFor, canSchedule, createSchedule, MIN_PRODUCTION_SOURCES, needsTwoSources, 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']
|
||||
@@ -59,6 +59,9 @@ export function InstructionPage() {
|
||||
const view: AgentState = { ...agent, sources }
|
||||
const question = nextQuestion(view)
|
||||
const ready = agent.items.filter(i => canSchedule(i.rule))
|
||||
// Without a choice the whole Brain is attached, so every ready document counts.
|
||||
const sourceCount = chosen.length || choosable(docs.data).length
|
||||
const tooFewSources = sourceCount < MIN_PRODUCTION_SOURCES && ready.some(i => needsTwoSources(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
|
||||
@@ -108,7 +111,7 @@ export function InstructionPage() {
|
||||
|
||||
async function approve(state = agent, replyLang = lang) {
|
||||
const items = state.items.filter(i => canSchedule(i.rule))
|
||||
if (!items.length) return
|
||||
if (!items.length || tooFewSources) return
|
||||
setSaving(true)
|
||||
const done: Outcome[] = []
|
||||
const created = new Set<number>()
|
||||
@@ -201,8 +204,9 @@ export function InstructionPage() {
|
||||
<small>
|
||||
<b className="rule-sources"><Icon name="brain" />{chosen.length ? t('ins.sourcesChosen', { n: chosen.length }) : t('ins.sourcesAll')}</b>
|
||||
{t('ins.approveNote')}
|
||||
{tooFewSources && <span className="error">{t(chosen.length ? 'ins.tooFewChosen' : 'ins.tooFewInBrain', { n: MIN_PRODUCTION_SOURCES })}</span>}
|
||||
</small>
|
||||
<button className="btn primary" disabled={saving || !idle} onClick={() => approve()}>
|
||||
<button className="btn primary" disabled={saving || !idle || tooFewSources} onClick={() => approve()}>
|
||||
<Icon name="check" />{saving ? t('ins.approving') : t('ins.approve', { n: ready.length })}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -7,16 +7,31 @@ import { toast } from '../components/Toast'
|
||||
import { describeRule } from '../express/parse'
|
||||
import { locale, t, type Key } from '../i18n'
|
||||
import {
|
||||
archiveSchedule, confirmProduction, isRunSchedule, listRuns, listSchedules, pauseSchedule, PLAN_ACTIVE, productionStatus, quoteProduction, resumeSchedule,
|
||||
planProgress, runNow as startRun, updateTopic, type ContentPlan, type Schedule,
|
||||
archiveSchedule, confirmProduction, isRunSchedule, knowledgeReadiness, listRuns, listSchedules, pauseSchedule, PLAN_ACTIVE, productionStatus, quoteProduction, resumeSchedule,
|
||||
planProgress, productionError, runNow as startRun, updateTopic, type ContentPlan, type RunSummary, type Schedule,
|
||||
} from '../express/schedules'
|
||||
|
||||
const STUDIO_URL: string = import.meta.env.VITE_STUDIO_URL
|
||||
|
||||
const fmt = (iso: string | null | undefined) => (iso
|
||||
? new Intl.DateTimeFormat(locale(), { weekday: 'short', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }).format(new Date(iso))
|
||||
: '–')
|
||||
|
||||
const RUN_STATUSES = new Set(['queued', 'planning', 'running', 'completed', 'failed', 'cancelled'])
|
||||
const RUN_STATUSES = new Set(['queued', 'planning', 'running', 'completed', 'failed', 'cancelled', 'paused_configuration', 'paused_budget'])
|
||||
const runStatus = (s: string) => (RUN_STATUSES.has(s) ? t(`run.${s}` as Key) : s)
|
||||
const RUN_ERRORS = new Set([
|
||||
'knowledge_not_ready', 'video_sources_unavailable', 'material_sources_unavailable', 'knowledge_search_failed',
|
||||
'organization_wallet', 'run_cap', 'monthly_cap', 'reservation_cap', 'price_changed', 'video_price_changed',
|
||||
'agent_not_active', 'run_access_revoked', 'required_tool_unavailable',
|
||||
])
|
||||
const ISSUE_REASONS = new Set([
|
||||
'document_processing', 'document_failed', 'graph_building', 'graph_not_ready', 'legacy_set_needs_conversion',
|
||||
'no_knowledge_sources', 'source_not_accessible', 'source_graph_not_ready', 'source_version_changed', 'snapshot_not_ready',
|
||||
])
|
||||
const WAITING_REASONS = new Set(['document_processing', 'graph_building', 'snapshot_not_ready'])
|
||||
const runError = (code: string) => (RUN_ERRORS.has(code) ? t(`runErr.${code}` as Key) : code)
|
||||
const issueReason = (reason: string) => (ISSUE_REASONS.has(reason) ? t(`issue.${reason}` as Key) : reason)
|
||||
const isNotReady = (r: RunSummary) => r.safe_error_code === 'knowledge_not_ready' || r.has_readiness_issues
|
||||
|
||||
export function SchedulePage() {
|
||||
const schedules = useQuery({ queryKey: ['schedules'], queryFn: listSchedules, refetchInterval: 30_000 })
|
||||
@@ -186,7 +201,7 @@ function ProducePanel({ schedule: { agent, rule }, onClose }: { schedule: Schedu
|
||||
<input type="number" min={1} max={remaining} value={n} onChange={e => setCount(Number(e.target.value) || 1)} />
|
||||
</label>
|
||||
)}
|
||||
{error && <span className="error">{error.message}</span>}
|
||||
{error && <span className="error">{productionError(error.message)}</span>}
|
||||
<div className="produce-actions">
|
||||
{!running && remaining > 0 && (quote.data
|
||||
? <button className="btn small primary" disabled={start.isPending} onClick={() => start.mutate(quote.data)}>{start.isPending ? t('sch.starting') : t('sch.startProduction')}</button>
|
||||
@@ -206,17 +221,46 @@ function RunHistory({ agentId }: { agentId: number }) {
|
||||
if (runs.isPending) return <div className="run-history"><SkeletonLines count={3} label={t('sch.loadingHistory')} /></div>
|
||||
if (runs.isError) return <div className="run-history"><span className="error">{runs.error.message}</span></div>
|
||||
if (!runs.data.length) return <div className="run-history"><small>{t('sch.neverRan')}</small></div>
|
||||
const shown = runs.data.slice(0, 8)
|
||||
// Readiness is today's state, so it is shown once, under the newest run it stopped.
|
||||
const blockedId = shown.find(isNotReady)?.id
|
||||
return (
|
||||
<ul className="run-history">
|
||||
{runs.data.slice(0, 8).map(r => (
|
||||
{shown.map(r => (
|
||||
<li key={r.id}>
|
||||
<span>{fmt(r.created_at)}</span>
|
||||
<span>{r.trigger_kind === 'scheduled' ? t('sch.scheduled') : t('sch.manual')}</span>
|
||||
<span className={`status ${r.status === 'completed' ? 'ready' : 'draft'}`}><i className="dot"></i>{runStatus(r.status)}</span>
|
||||
{r.budget_spent != null && <span>{t('common.credits', { n: r.budget_spent })}</span>}
|
||||
{r.safe_error_code && <span className="error">{r.safe_error_code}</span>}
|
||||
{r.safe_error_code && <span className="error" title={r.safe_error_code}>{runError(r.safe_error_code)}</span>}
|
||||
{r.id === blockedId && <ReadinessIssues agentId={agentId} />}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
function ReadinessIssues({ agentId }: { agentId: number }) {
|
||||
const readiness = useQuery({
|
||||
queryKey: ['readiness', agentId],
|
||||
queryFn: () => knowledgeReadiness(agentId),
|
||||
// Processing and building finish on their own; keep the list current until they do.
|
||||
refetchInterval: q => (q.state.data?.issues.some(i => WAITING_REASONS.has(i.reason)) ? 5000 : false),
|
||||
})
|
||||
if (readiness.isPending) return <div className="run-issues"><SkeletonLines count={1} label={t('sch.loadingHistory')} /></div>
|
||||
if (readiness.isError) return <div className="run-issues"><span className="error">{readiness.error.message}</span></div>
|
||||
if (!readiness.data.issues.length) return <div className="run-issues"><small>{t('sch.blockedNone')}</small></div>
|
||||
return (
|
||||
<div className="run-issues">
|
||||
<strong>{t('sch.blockedBy')}</strong>
|
||||
<ul>
|
||||
{readiness.data.issues.map(issue => (
|
||||
<li key={`${issue.kind}-${issue.resource_id}`}>
|
||||
<span><b>{issue.title}</b> · {issueReason(issue.reason)}</span>
|
||||
<a href={`${STUDIO_URL}${issue.action_url}`} target="_blank" rel="noreferrer">{t('sch.fixInStudio')}</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -49,6 +49,10 @@ textarea{resize:vertical;line-height:1.6}
|
||||
.run-history li{display:flex;gap:12px;align-items:center;flex-wrap:wrap}
|
||||
.run-history .status{margin-left:0}
|
||||
.run-history .error{margin:0;font-size:11px}
|
||||
.run-issues{flex-basis:100%;padding:8px 10px;border-radius:8px;background:#fff7ed;color:#7a4b1f;display:grid;gap:4px}
|
||||
.run-issues ul{list-style:none;margin:0;padding:0;display:grid;gap:4px}
|
||||
.run-issues li{display:flex;gap:10px;flex-wrap:wrap;align-items:center}
|
||||
.run-issues a{color:var(--blue);font-weight:600}
|
||||
@media(max-width:700px){.schedule-actions,.run-history{padding-left:0;margin-left:0}.rule-approve{flex-direction:column;align-items:stretch}}
|
||||
.lang-switch{display:inline-flex;border:1px solid var(--line);border-radius:7px;overflow:hidden;background:white}
|
||||
.lang-switch button{border:0;background:transparent;padding:5px 9px;font-size:10px;font-weight:650;color:#8b97a9}
|
||||
|
||||
@@ -287,10 +287,15 @@ dialog::backdrop{background:rgba(16,24,40,.45);backdrop-filter:blur(4px)}
|
||||
@keyframes page-in{from{opacity:0;transform:translateY(6px)}}
|
||||
@media (prefers-reduced-motion:reduce){.skel,.app main{animation:none}}
|
||||
|
||||
/* Sidebar groups: Dashboard, then Settings */
|
||||
.sidebar nav .nav-label{margin:0 12px 6px}
|
||||
.sidebar nav .nav-label~.nav-label{margin-top:18px}
|
||||
@media(max-width:700px){.sidebar nav .nav-label{display:none}}
|
||||
/* Sidebar groups: Dashboard, then Settings, each a dropdown that opens on its active page */
|
||||
.sidebar nav button.nav-label{display:flex;align-items:center;justify-content:space-between;width:auto;margin:0 0 2px;padding:6px 12px;border-radius:8px;color:var(--muted);font-size:11px;font-weight:600;box-shadow:none;background:none}
|
||||
.sidebar nav button.nav-label:hover{background:#f2f7ff;color:var(--ink)}
|
||||
.sidebar nav button.nav-label svg{width:14px;height:14px;color:inherit;transition:transform .15s}
|
||||
.sidebar nav button.nav-label[aria-expanded=false] svg{transform:rotate(-90deg)}
|
||||
.sidebar nav .nav-items~.nav-label{margin-top:12px}
|
||||
.nav-items{display:contents}
|
||||
@media(min-width:701px){.nav-items.closed{display:none}}
|
||||
@media(max-width:700px){.sidebar nav button.nav-label{display:none}}
|
||||
|
||||
/* Production: the knowledge graph, then the metric row and the latest content */
|
||||
.prod-metrics{grid-template-columns:repeat(3,minmax(0,1fr));margin-top:24px}
|
||||
|
||||
Reference in New Issue
Block a user