diff --git a/src/components/AudioPlayer.tsx b/src/components/AudioPlayer.tsx index 0a0907f..88383ac 100644 --- a/src/components/AudioPlayer.tsx +++ b/src/components/AudioPlayer.tsx @@ -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) } diff --git a/src/express/agent.test.ts b/src/express/agent.test.ts index 7fa7494..178ec25 100644 --- a/src/express/agent.test.ts +++ b/src/express/agent.test.ts @@ -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', () => { diff --git a/src/express/agent.ts b/src/express/agent.ts index ad71654..3cfcd4b 100644 --- a/src/express/agent.ts +++ b/src/express/agent.ts @@ -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 | Partial): 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 }) } diff --git a/src/express/parse.ts b/src/express/parse.ts index a7a88e4..45830cd 100644 --- a/src/express/parse.ts +++ b/src/express/parse.ts @@ -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`(? 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 { diff --git a/src/express/schedules.test.ts b/src/express/schedules.test.ts index cef1129..eeccf45 100644 --- a/src/express/schedules.test.ts +++ b/src/express/schedules.test.ts @@ -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): ExpressAgent { const c = config as Record 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) }) diff --git a/src/express/schedules.ts b/src/express/schedules.ts index ad0bfaf..4f3965b 100644 --- a/src/express/schedules.ts +++ b/src/express/schedules.ts @@ -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 = { + 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, } diff --git a/src/i18n.ts b/src/i18n.ts index 5b48596..b9f85a1 100644 --- a/src/i18n.ts +++ b/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 = { '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}.', diff --git a/src/pages/Instruction.tsx b/src/pages/Instruction.tsx index 85a7d90..c4065f2 100644 --- a/src/pages/Instruction.tsx +++ b/src/pages/Instruction.tsx @@ -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() // 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() {

{t('ins.previewTitle')}

{t('ins.readyCount', { n: ready.length })}
{agent.items.length === 0 &&

{t('agent.planEmpty')}

} + {hasContent && ( + + )}
{agent.items.map(({ id, rule }) => ( setAgent(s => editRule(s, id, p))} onRemove={() => setAgent(s => removeRule(s, id))} /> diff --git a/src/styles/app.css b/src/styles/app.css index 5d7d12e..f0ae59f 100644 --- a/src/styles/app.css +++ b/src/styles/app.css @@ -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}