production-dashboard #1
@@ -11,7 +11,8 @@ export function sentencesOf(text: string): string[] {
|
||||
return text
|
||||
.replace(/^\s*(?:#{1,6}|[-*•>]|\d+[.)])\s+/gm, '') // headings, bullets, numbered items, quotes
|
||||
.replace(/[*_`]+/g, '')
|
||||
.split(/(?<=[.!?…])\s+|\n+/)
|
||||
// Chinese ends sentences with 。!? and no space after them.
|
||||
.split(/(?<=[.!?…])\s+|(?<=[。!?])|\n+/)
|
||||
.map(s => s.replace(/\s+/g, ' ').trim())
|
||||
.filter(s => /\p{L}/u.test(s))
|
||||
}
|
||||
@@ -19,7 +20,12 @@ export function sentencesOf(text: string): string[] {
|
||||
// The script's language decides the voice; the UI language may differ from the content's.
|
||||
const ID_WORDS = /\b(yang|dan|dengan|untuk|adalah|ini|itu|kita|anda|tidak|dalam|akan|bisa|dari)\b/gi
|
||||
const EN_WORDS = /\b(the|and|with|for|is|this|that|we|you|not|in|will|can|from)\b/gi
|
||||
export const scriptLang = (text: string) => ((text.match(ID_WORDS)?.length ?? 0) > (text.match(EN_WORDS)?.length ?? 0) ? 'id' : 'en')
|
||||
// Chinese has no spaces, so it is told apart by its characters before the word counts.
|
||||
export function scriptLang(text: string): 'id' | 'en' | 'zh' {
|
||||
if ((text.match(/\p{Script=Han}/gu)?.length ?? 0) > (text.match(/[a-z]/gi)?.length ?? 0)) return 'zh'
|
||||
return (text.match(ID_WORDS)?.length ?? 0) > (text.match(EN_WORDS)?.length ?? 0) ? 'id' : 'en'
|
||||
}
|
||||
const SPEECH_LANG = { id: 'id-ID', en: 'en-US', zh: 'zh-CN' }
|
||||
|
||||
const supported = typeof window !== 'undefined' && 'speechSynthesis' in window
|
||||
|
||||
@@ -44,7 +50,7 @@ export function AudioPlayer({ title, text }: { title: string; text: string }) {
|
||||
at.current = i
|
||||
setIndex(i)
|
||||
const u = new SpeechSynthesisUtterance(sentences[i])
|
||||
u.lang = lang === 'id' ? 'id-ID' : 'en-US'
|
||||
u.lang = SPEECH_LANG[lang]
|
||||
u.voice = voice()
|
||||
u.rate = rate
|
||||
u.onend = () => { if (playing.current && current.current === u) speak(at.current + 1) }
|
||||
|
||||
@@ -7,7 +7,8 @@ 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')
|
||||
const s = say('make 2 summaries per day about leadership in english')
|
||||
expect(s.language).toBe('English')
|
||||
expect(nextQuestion(s)).toBeNull()
|
||||
expect(respond(s, 'yes').approve).toBe(true)
|
||||
})
|
||||
@@ -19,7 +20,7 @@ describe('instruction agent', () => {
|
||||
})
|
||||
|
||||
it('stops asking for a topic once the whole Brain is chosen', () => {
|
||||
expect(nextQuestion(say('make 3 quizzes per week', t('agent.optWholeBrain')))).toBeNull()
|
||||
expect(nextQuestion(say('make 3 quizzes per week', t('agent.optWholeBrain')))?.type).toBe('language')
|
||||
})
|
||||
|
||||
it('asks about a video count that has no cadence', () => {
|
||||
@@ -39,7 +40,25 @@ describe('instruction agent', () => {
|
||||
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()
|
||||
expect(nextQuestion(say('2 summaries per day about sales. hello there', 'skip'))?.type).toBe('language')
|
||||
})
|
||||
|
||||
it('asks which language the content is written in', () => {
|
||||
expect(nextQuestion(say('3 quizzes per week about sales'))?.type).toBe('language')
|
||||
expect(say('3 quizzes per week about sales', 'mandarin').language).toBe('中文')
|
||||
expect(say('3 quizzes per week about sales', 'indo').language).toBe('Bahasa Indonesia')
|
||||
expect(nextQuestion(say('3 quizzes per week about sales', '中文 (Mandarin)'))).toBeNull()
|
||||
})
|
||||
|
||||
it('reads the language from the instruction without leaving it in the topic', () => {
|
||||
const s = say('bikin 3 kuis per minggu soal negosiasi pakai bahasa inggris')
|
||||
expect(s.language).toBe('English')
|
||||
expect(rules(s)[0]).toMatchObject({ topic: 'negosiasi' })
|
||||
expect(s.unknown).toEqual([])
|
||||
})
|
||||
|
||||
it('does not ask about the language for research alone', () => {
|
||||
expect(nextQuestion(say('research every day about sales in China'))).toBeNull()
|
||||
})
|
||||
|
||||
it('treats a reply with a frequency as a new request', () => {
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
// 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.
|
||||
// cadence, a weekday, a topic, the output language) right in the chat, so nothing is left for the
|
||||
// user to spot alone.
|
||||
import { t, type Key } from '../i18n'
|
||||
import { dayName, describeRule, parseInstruction, videoCadence, weekdayOf, weeklyTarget, type ContentRule, type ResearchRule, type Rule } from './parse'
|
||||
import {
|
||||
dayName, describeRule, LANGUAGES, languageLabel, languageOf, parseInstruction, videoCadence, weekdayOf, weeklyTarget,
|
||||
type ContentRule, type OutputLanguage, type ResearchRule, type Rule,
|
||||
} from './parse'
|
||||
|
||||
export interface Item { id: number; rule: Rule }
|
||||
|
||||
@@ -12,14 +16,17 @@ export interface AgentState {
|
||||
unknown: string[]
|
||||
/** Questions settled without changing the plan, e.g. "use the whole Brain". */
|
||||
settled: string[]
|
||||
/** The language the content is written in, confirmed by the user; null until then. */
|
||||
language: OutputLanguage | null
|
||||
}
|
||||
|
||||
export const EMPTY: AgentState = { items: [], unknown: [], settled: [] }
|
||||
export const EMPTY: AgentState = { items: [], unknown: [], settled: [], language: null }
|
||||
|
||||
export type Question =
|
||||
| { key: string; type: 'unknown'; text: string }
|
||||
| { key: string; type: 'video' | 'tooMany' | 'weekday'; id: number }
|
||||
| { key: string; type: 'topic'; 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 {
|
||||
@@ -31,7 +38,10 @@ export function nextQuestion(s: AgentState): Question | null {
|
||||
}
|
||||
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
|
||||
if (ids.length && !s.settled.includes(key)) return { key, type: 'topic', ids }
|
||||
// Research only adds sources to the Brain, so the language matters once there is content to write.
|
||||
if (!s.language && s.items.some(i => i.rule.type === 'content')) return { key: 'language', type: 'language' }
|
||||
return null
|
||||
}
|
||||
|
||||
const bullets = (rules: Rule[]) => rules.map(r => `• ${describeRule(r)}`).join('\n')
|
||||
@@ -54,6 +64,8 @@ export function ask(q: Question, s: AgentState): { text: string; options: string
|
||||
text: t('agent.askTopic', { list: bullets(q.ids.map(id => ruleById(s, id))) }),
|
||||
options: [t('agent.optWholeBrain')],
|
||||
}
|
||||
case 'language':
|
||||
return { text: t('agent.askLanguage'), options: LANGUAGES.map(languageLabel) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,14 +95,24 @@ 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
|
||||
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] },
|
||||
reply: t(s.items.length ? 'agent.added' : 'agent.understood', { list: bullets(rules) }),
|
||||
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(rules) }) + languageNote,
|
||||
}
|
||||
}
|
||||
|
||||
// A reply to the language question may be short ("indo", "inggris", "zh"), so it is read more loosely.
|
||||
function languageAnswer(lower: string): OutputLanguage | null {
|
||||
return languageOf(lower)
|
||||
?? LANGUAGES.find(l => lower === languageLabel(l).toLowerCase())
|
||||
?? (/^(id|indo|indonesia)$/.test(lower) ? 'Bahasa Indonesia'
|
||||
: /^(en|eng|inggris)$/.test(lower) ? 'English'
|
||||
: /^(zh|cn|cina|china|tionghoa|mandarin|chinese)$/.test(lower) ? '中文' : null)
|
||||
}
|
||||
|
||||
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)) }) }
|
||||
@@ -102,10 +124,14 @@ function answer(q: Question, s: AgentState, text: string, lower: string, is: (k:
|
||||
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 === 'language') {
|
||||
const language = languageAnswer(lower)
|
||||
return language ? { state: { ...s, language }, reply: t('agent.languageSet', { language: languageLabel(language) }) } : null
|
||||
}
|
||||
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
|
||||
if (DAILY.test(lower) || WEEKLY.test(lower) || /\bper\b/.test(lower) || languageOf(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 }) }
|
||||
|
||||
@@ -45,6 +45,26 @@ const WEEKDAYS: [RegExp, number][] = [
|
||||
|
||||
export type Per = 'day' | 'week'
|
||||
|
||||
// The output languages the engine writes in (generation_brief.OutputLanguage, AgentVideoOptions.language).
|
||||
export type OutputLanguage = 'English' | 'Bahasa Indonesia' | '中文'
|
||||
export const LANGUAGES: OutputLanguage[] = ['Bahasa Indonesia', 'English', '中文']
|
||||
export const languageLabel = (l: OutputLanguage) => (l === '中文' ? '中文 (Mandarin)' : l)
|
||||
|
||||
// "in English", "pakai bahasa Inggris", "dalam bahasa Mandarin". A bare country name needs "bahasa",
|
||||
// so a topic such as "sales in China" is not read as a language.
|
||||
const LANGUAGE_CORE = String.raw`bahasa\s+(?:indonesia|indo|inggris|english|mandarin|cina|china|tionghoa)|indonesian|english|inggris|mandarin|chinese|中文`
|
||||
const LANGUAGE_PHRASE = new RegExp(
|
||||
String.raw`(?<!\p{L})(?:(?:in|dalam|pakai|pake|memakai|menggunakan|gunakan|using|use|with|berbahasa)\s+)?(?:the\s+)?(?:${LANGUAGE_CORE})(?:\s+language)?(?!\p{L})`,
|
||||
'gu',
|
||||
)
|
||||
|
||||
/** The output language a text names, if any. */
|
||||
export function languageOf(text: string): OutputLanguage | null {
|
||||
const m = text.toLowerCase().match(LANGUAGE_PHRASE)?.[0]
|
||||
if (!m) return null
|
||||
return /indo/.test(m) ? 'Bahasa Indonesia' : /english|inggris/.test(m) ? 'English' : '中文'
|
||||
}
|
||||
|
||||
export interface ContentRule {
|
||||
type: 'content'
|
||||
kind: ContentKind
|
||||
@@ -53,6 +73,8 @@ export interface ContentRule {
|
||||
topic: string
|
||||
/** The monthly count the instruction asked for, when it had to be turned into a weekly one. */
|
||||
monthly?: number
|
||||
/** The language the content is written in; unset means the UI language. */
|
||||
language?: OutputLanguage
|
||||
}
|
||||
|
||||
export interface ResearchRule {
|
||||
@@ -71,6 +93,8 @@ 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
|
||||
}
|
||||
|
||||
const TOPIC = /\b(?:soal|tentang|mengenai|seputar|terkait|topik|bertema|perihal|about|regarding|covering|related to|on the topic of|topic)\s+(?!latihan\b)(.+)$/
|
||||
@@ -128,8 +152,10 @@ function parseClause(clause: string): Rule | null {
|
||||
}
|
||||
|
||||
export function parseInstruction(text: string): ParseResult {
|
||||
const language = languageOf(text)
|
||||
const clauses = text
|
||||
.toLowerCase()
|
||||
.replace(LANGUAGE_PHRASE, ' ')
|
||||
.replace(/[“”"]/g, '')
|
||||
.split(SPLIT)
|
||||
.map(s => s.trim())
|
||||
@@ -150,7 +176,7 @@ export function parseInstruction(text: string): ParseResult {
|
||||
const topics = new Set(rules.map(r => r.topic).filter(Boolean))
|
||||
const fallback = shared || (topics.size === 1 ? [...topics][0] : '')
|
||||
for (const r of rules) if (!r.topic) r.topic = fallback
|
||||
return { rules, unknown }
|
||||
return language ? { rules, unknown, language } : { rules, unknown }
|
||||
}
|
||||
|
||||
export function weeklyTarget(rule: ContentRule): number {
|
||||
|
||||
@@ -2,15 +2,15 @@ import { describe, expect, it } from 'vitest'
|
||||
import type { ContentRule, ResearchRule } from './parse'
|
||||
import { budgetFor, canSchedule, configFor, ruleOf, VIDEO, type ExpressAgent } from './schedules'
|
||||
|
||||
const rule = (kind: ContentRule['kind'], count = 2, per: ContentRule['per'] = 'week', topic = 'onboarding'): ContentRule =>
|
||||
({ type: 'content', kind, count, per, topic })
|
||||
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 })
|
||||
|
||||
// What the engine sends back for an agent created from configFor.
|
||||
function agentFrom(config: ReturnType<typeof configFor>): ExpressAgent {
|
||||
const c = config as Record<string, unknown>
|
||||
return {
|
||||
id: 1, status: 'active', next_run_at: null, created_at: '2026-10-01T00:00:00Z', auto_approve_sources: false,
|
||||
name: c.name as string, goal: c.goal as string,
|
||||
name: c.name as string, goal: c.goal as string, instructions: c.instructions as string,
|
||||
enabled_tools: c.enabled_tools as string[],
|
||||
material_outputs: (c.material_outputs as ExpressAgent['material_outputs']) ?? [],
|
||||
content_goal: (c.content_goal as ExpressAgent['content_goal']) ?? null,
|
||||
@@ -54,6 +54,15 @@ describe('configFor', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('content language', () => {
|
||||
it('writes the chosen language into the brief and the video options', () => {
|
||||
expect((configFor(rule('summary', 2, 'week', 'sales', '中文')) as { instructions: string }).instructions)
|
||||
.toMatch(/Write all content in Simplified Chinese \(中文\)\./)
|
||||
expect((configFor(rule('video', 1, 'week', 'sales', 'Bahasa Indonesia'), 50) as { video_options: { language: string } }).video_options.language)
|
||||
.toBe('Bahasa Indonesia')
|
||||
})
|
||||
})
|
||||
|
||||
describe('ruleOf', () => {
|
||||
it.each([
|
||||
rule('summary', 3),
|
||||
@@ -62,6 +71,8 @@ describe('ruleOf', () => {
|
||||
rule('quiz', 1, 'day'),
|
||||
rule('video', 1, 'week'),
|
||||
rule('video', 1, 'day'),
|
||||
rule('summary', 2, 'week', 'sales', 'Bahasa Indonesia'),
|
||||
rule('video', 1, 'week', 'sales', '中文'),
|
||||
])('reads back the rule a schedule was created from: $count $kind per $per', r => {
|
||||
expect(ruleOf(agentFrom(configFor(r, 50)))).toEqual(r)
|
||||
})
|
||||
|
||||
@@ -4,7 +4,10 @@
|
||||
import { opKey, request } from '../api/client'
|
||||
import { listDocuments, type Agent, type RunSchedule } from '../api/engine'
|
||||
import { getLang } from '../i18n'
|
||||
import { KINDS, SCHEDULABLE, videoCadence, weeklyTarget, type ContentKind, type ContentRule, type Per, type ResearchRule, type Rule } from './parse'
|
||||
import {
|
||||
KINDS, LANGUAGES, SCHEDULABLE, videoCadence, weeklyTarget,
|
||||
type ContentKind, type ContentRule, type OutputLanguage, type Per, type ResearchRule, type Rule,
|
||||
} from './parse'
|
||||
|
||||
const AGENTS = '/organizations/current/agents'
|
||||
export const PREFIX = 'Express · '
|
||||
@@ -25,7 +28,7 @@ export interface VideoOptions {
|
||||
max_scenes: number
|
||||
max_duration_seconds: number
|
||||
art_style: 'realistic' | 'cartoon' | 'anime' | 'watercolor' | '3d'
|
||||
language: 'English' | 'Bahasa Indonesia'
|
||||
language: OutputLanguage
|
||||
}
|
||||
|
||||
export interface ExpressAgent extends Agent {
|
||||
@@ -56,11 +59,24 @@ function scheduleName(rule: Rule): string {
|
||||
|
||||
const isKind = (k: string | undefined): k is ContentKind => KINDS.includes(k as ContentKind)
|
||||
|
||||
/** A rule with no language chosen is written in the UI language, as before languages could be chosen. */
|
||||
export const defaultLanguage = (): OutputLanguage => (getLang() === 'id' ? 'Bahasa Indonesia' : 'English')
|
||||
|
||||
// How the agent's English brief names each language; read back by languageOfAgent.
|
||||
const LANGUAGE_TEXT: Record<OutputLanguage, string> = {
|
||||
English: 'English', 'Bahasa Indonesia': 'Bahasa Indonesia', 中文: 'Simplified Chinese (中文)',
|
||||
}
|
||||
const languageSentence = (l: OutputLanguage) => `Write all content in ${LANGUAGE_TEXT[l]}.`
|
||||
|
||||
function languageOfAgent(agent: ExpressAgent): OutputLanguage | undefined {
|
||||
return agent.video_options?.language ?? LANGUAGES.find(l => agent.instructions?.includes(languageSentence(l)))
|
||||
}
|
||||
|
||||
export function ruleOf(agent: ExpressAgent): Rule {
|
||||
const topic = agent.name.split(' · ').slice(2).join(' · ')
|
||||
if (agent.enabled_tools.includes('studio.create_video')) {
|
||||
const daily = agent.run_schedule?.cadence === 'daily'
|
||||
return { type: 'content', kind: 'video', count: 1, per: daily ? 'day' : 'week', topic }
|
||||
return { type: 'content', kind: 'video', count: 1, per: daily ? 'day' : 'week', topic, language: languageOfAgent(agent) }
|
||||
}
|
||||
if (agent.content_goal?.enabled && agent.material_outputs[0]) {
|
||||
// " per hari" comes from schedules created before names were in English.
|
||||
@@ -69,7 +85,7 @@ export function ruleOf(agent: ExpressAgent): Rule {
|
||||
// Audio and SCORM are Express kinds made of Compose outputs, so the name carries the kind.
|
||||
const named = agent.name.match(/^Express · \d+ (\w+) per /)?.[1]
|
||||
const kind = isKind(named) ? named : agent.material_outputs[0].kind
|
||||
return { type: 'content', kind, per, count: per === 'day' ? Math.max(1, Math.round(target / 7)) : target, topic }
|
||||
return { type: 'content', kind, per, count: per === 'day' ? Math.max(1, Math.round(target / 7)) : target, topic, language: languageOfAgent(agent) }
|
||||
}
|
||||
const s = agent.run_schedule ?? { cadence: 'off' }
|
||||
return { type: 'research', cadence: s.cadence === 'weekly' ? 'weekly' : 'daily', weekday: s.weekday ?? 0, topic, autoApprove: agent.auto_approve_sources }
|
||||
@@ -135,7 +151,8 @@ function outputsFor(kind: ContentKind): ExpressAgent['material_outputs'] {
|
||||
return [{ kind, options: {} }]
|
||||
}
|
||||
|
||||
// The agent's brief is in English; the content language follows the UI language at creation time.
|
||||
// The agent's brief is in English; the content language is the one chosen in the Instruction chat.
|
||||
// The engine's content production has no language field, so it is part of the instructions.
|
||||
function goalText(rule: Rule): string {
|
||||
const about = rule.topic ? ` about ${rule.topic}` : ''
|
||||
if (rule.type === 'research') {
|
||||
@@ -145,7 +162,7 @@ function goalText(rule: Rule): string {
|
||||
}
|
||||
|
||||
function instructionsFor(rule: Rule): string {
|
||||
const language = getLang() === 'id' ? 'Write all content in Bahasa Indonesia.' : 'Write all content in English.'
|
||||
const language = languageSentence((rule.type === 'content' && rule.language) || defaultLanguage())
|
||||
const brief = rule.type === 'content' ? KIND_BRIEF[rule.kind] : undefined
|
||||
return [rule.topic && `Focus on the topic: ${rule.topic}.`, brief, language].filter(Boolean).join(' ')
|
||||
}
|
||||
@@ -178,7 +195,7 @@ export function configFor(rule: Rule, videoCredits = 0) {
|
||||
max_scenes: VIDEO.scenes,
|
||||
max_duration_seconds: VIDEO.seconds,
|
||||
art_style: 'realistic',
|
||||
language: getLang() === 'id' ? 'Bahasa Indonesia' : 'English',
|
||||
language: rule.language ?? defaultLanguage(),
|
||||
} satisfies VideoOptions,
|
||||
run_schedule: { cadence: videoCadence(rule) ?? 'weekly', hour: 9, minute: 0, weekday: 0 } satisfies RunSchedule,
|
||||
}
|
||||
|
||||
10
src/i18n.ts
10
src/i18n.ts
@@ -280,6 +280,11 @@ const en = {
|
||||
'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.readyLanguage': 'Everything is clear: {n} schedules are ready, with content written in {language}. You can still change them in the plan. Shall I start them?',
|
||||
'agent.askLanguage': 'Which language should the content be written in?',
|
||||
'agent.languageSet': 'Content will be written in {language}.',
|
||||
'ins.language': 'Content language',
|
||||
'ins.languagePending': 'Not chosen yet',
|
||||
'agent.skipped': 'OK, I skipped “{part}”.',
|
||||
'agent.removed': 'Removed {rule}.',
|
||||
'agent.updated': 'Updated: {rule}.',
|
||||
@@ -644,6 +649,11 @@ const id: Record<Key, string> = {
|
||||
'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.readyLanguage': 'Semua sudah jelas: {n} jadwal siap, dengan konten berbahasa {language}. Anda masih bisa mengubahnya di rencana. Mulai sekarang?',
|
||||
'agent.askLanguage': 'Kontennya mau ditulis dalam bahasa apa?',
|
||||
'agent.languageSet': 'Konten akan ditulis dalam {language}.',
|
||||
'ins.language': 'Bahasa konten',
|
||||
'ins.languagePending': 'Belum dipilih',
|
||||
'agent.skipped': 'Oke, “{part}” saya lewati.',
|
||||
'agent.removed': '{rule} dihapus.',
|
||||
'agent.updated': 'Diperbarui: {rule}.',
|
||||
|
||||
@@ -4,12 +4,12 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Icon, ruleIcon } from '../components/Icon'
|
||||
import { PageHead } from '../components/Page'
|
||||
import {
|
||||
dayName, describeRule, kindLabel, KINDS, monthNote, videoCadence, weeklyTarget,
|
||||
type ContentKind, type ContentRule, type ResearchRule, type Rule,
|
||||
dayName, describeRule, kindLabel, KINDS, LANGUAGES, languageLabel, monthNote, videoCadence, weeklyTarget,
|
||||
type ContentKind, type ContentRule, type OutputLanguage, type ResearchRule, type Rule,
|
||||
} from '../express/parse'
|
||||
import { ask, editRule, EMPTY, nextQuestion, removeRule, respond, type AgentState } from '../express/agent'
|
||||
import { t, type Key } from '../i18n'
|
||||
import { budgetFor, canSchedule, createSchedule, VIDEO, videoQuote } from '../express/schedules'
|
||||
import { budgetFor, canSchedule, createSchedule, defaultLanguage, VIDEO, videoQuote } from '../express/schedules'
|
||||
|
||||
const EXAMPLES: Key[] = ['ins.ex1', 'ins.ex2', 'ins.ex3', 'ins.ex4', 'ins.ex5']
|
||||
|
||||
@@ -29,13 +29,18 @@ export function InstructionPage() {
|
||||
|
||||
const question = nextQuestion(agent)
|
||||
const ready = agent.items.filter(i => canSchedule(i.rule))
|
||||
const hasContent = agent.items.some(i => i.rule.type === 'content')
|
||||
// The agent's current turn: its question, or what it is waiting for. It stays live, so an edit in the plan updates it.
|
||||
const live = saving
|
||||
? { text: t('agent.approving'), options: [] }
|
||||
: question
|
||||
? ask(question, agent)
|
||||
: agent.items.length
|
||||
? { text: t('agent.ready', { n: ready.length }), options: [t('agent.optApprove'), t('agent.optReset')] }
|
||||
? {
|
||||
text: hasContent && agent.language
|
||||
? t('agent.readyLanguage', { n: ready.length, language: languageLabel(agent.language) })
|
||||
: t('agent.ready', { n: ready.length }),
|
||||
options: [t('agent.optApprove'), t('agent.optReset')] }
|
||||
// The greeting comes once; after that the examples alone invite the next request.
|
||||
: { text: messages.length ? '' : t('agent.greeting'), options: EXAMPLES.map(k => t(k)) }
|
||||
|
||||
@@ -64,9 +69,11 @@ export function InstructionPage() {
|
||||
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.
|
||||
// Approving from the plan before the agent asked about the language keeps the old default.
|
||||
const language = state.language ?? defaultLanguage()
|
||||
for (const { id, rule } of items) {
|
||||
try {
|
||||
await createSchedule(rule)
|
||||
await createSchedule(rule.type === 'content' ? { ...rule, language } : rule)
|
||||
done.push({ rule, ok: true })
|
||||
created.add(id)
|
||||
} catch (e) {
|
||||
@@ -120,6 +127,15 @@ export function InstructionPage() {
|
||||
<div className="card">
|
||||
<div className="card-head"><h3>{t('ins.previewTitle')}</h3><span className="badge neutral">{t('ins.readyCount', { n: ready.length })}</span></div>
|
||||
{agent.items.length === 0 && <div className="empty"><p>{t('agent.planEmpty')}</p></div>}
|
||||
{hasContent && (
|
||||
<label className="plan-language">
|
||||
<span>{t('ins.language')}</span>
|
||||
<select value={agent.language ?? ''} onChange={e => setAgent(s => ({ ...s, language: e.target.value as OutputLanguage }))}>
|
||||
{!agent.language && <option value="" disabled>{t('ins.languagePending')}</option>}
|
||||
{LANGUAGES.map(l => <option key={l} value={l}>{languageLabel(l)}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
<div className="rule-list">
|
||||
{agent.items.map(({ id, rule }) => (
|
||||
<RuleCard key={id} rule={rule} onChange={p => setAgent(s => editRule(s, id, p))} onRemove={() => setAgent(s => removeRule(s, id))} />
|
||||
|
||||
@@ -156,3 +156,5 @@ textarea{resize:vertical;line-height:1.6}
|
||||
.rule-body .error{font-size:12px}
|
||||
.rule-approve small{font-size:12px;color:#667085}
|
||||
.rule-fields select,.rule-fields input{font-size:13px}
|
||||
.plan-language{display:flex;align-items:center;gap:12px;margin:0;padding:14px 22px;border-bottom:1px solid var(--line);font-size:13px;font-weight:600;color:#344054}
|
||||
.plan-language select{width:auto!important;min-height:36px!important;padding:7px 10px!important;font-size:13px}
|
||||
|
||||
Reference in New Issue
Block a user