/** * Reporter Builder * * @package BoostMedia_AI * @license GPL-2.0-or-later */ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Bot, Loader2, Save, Sparkles, User, X } from 'lucide-react' import { Badge, Button, Card } from '../common' import { endpoints } from '../../api/client' import { useDataLanguage } from '../../lib/DataLanguageContext' import type { Reporter } from '../../types' import { t } from '../../lib/i18n' interface BuilderMessage { role: 'assistant' | 'user' content: string } interface BuilderUsage { coins_used?: number } interface BuilderResult { text: string controlPayload: Record | null usage?: BuilderUsage } interface ReporterBuilderProps { open: boolean initialReporter?: Reporter | null onClose: () => void onSaved: (reporter: Reporter) => void onUseManualDraft: (draft: Partial) => void } const AUTO_CREATE_MESSAGE = '__BC_AUTO_GREETING_NEW__' const AUTO_REFINE_MESSAGE = '__BC_AUTO_GREETING_REFINE__' function getDefaultWritingLanguage(dataLanguage: string, reporter?: Reporter | null): string { return reporter?.writing_language || reporter?.language || dataLanguage || 'he' } function formatCoins(value: number): string { return value.toFixed(1) } function filterVisibleMessages(messages: BuilderMessage[]) { return messages.filter( (message) => message.content !== AUTO_CREATE_MESSAGE && message.content !== AUTO_REFINE_MESSAGE, ) } function mergeDraft(current: Record, controlPayload: Record | null) { const next = { ...current } if (controlPayload && typeof controlPayload === 'object') { const profilePatch = controlPayload.profile_patch if (profilePatch && typeof profilePatch === 'object' && !Array.isArray(profilePatch)) { Object.assign(next, profilePatch) } if ('suggested_name' in controlPayload && typeof controlPayload.suggested_name === 'string' && controlPayload.suggested_name) { next.suggested_name = controlPayload.suggested_name } if ('ready' in controlPayload) { next.ready = Boolean(controlPayload.ready) } if (Array.isArray(controlPayload.missing)) { next.missing = controlPayload.missing } } return next } async function pollResponse(responseId: string): Promise { for (let attempt = 0; attempt < 120; attempt += 1) { const res = await endpoints.getChatResponse(responseId) const data = res.data as any if (data.status === 'complete') { return { text: data.text || '', controlPayload: data.control_payload || null, usage: data.usage || undefined, } } if (data.status === 'error') { throw new Error(data.message || t('AI response failed')) } await new Promise((resolve) => setTimeout(resolve, 1000)) } throw new Error(t('AI response timed out')) } export function ReporterBuilder({ open, initialReporter = null, onClose, onSaved, onUseManualDraft, }: ReporterBuilderProps) { const { dataLanguage } = useDataLanguage() const desiredWritingLanguage = getDefaultWritingLanguage(dataLanguage, initialReporter) const [sessionId, setSessionId] = useState(null) const [messages, setMessages] = useState([]) const [input, setInput] = useState('') const [sending, setSending] = useState(false) const [draftState, setDraftState] = useState>({}) const [sessionCoins, setSessionCoins] = useState(0) const [balance, setBalance] = useState(null) const [statusMessage, setStatusMessage] = useState(null) const initializedRef = useRef(false) const messagesRef = useRef(null) const visibleMessages = useMemo(() => filterVisibleMessages(messages), [messages]) const ready = Boolean(draftState.ready) const loadBalance = useCallback(async () => { try { const credits = await endpoints.getCreditsStatus() const remaining = Number((credits.data as any)?.boost_credits?.remaining ?? 0) setBalance(remaining) } catch { setBalance(null) } }, []) const openSession = useCallback(async () => { const structuredState = initialReporter ? { name: initialReporter.name, specializations: initialReporter.specializations || [], writing_style: initialReporter.writing_style, depth_level: initialReporter.depth_level, tone: initialReporter.tone, perspective: initialReporter.perspective, gender: initialReporter.gender, writing_language: desiredWritingLanguage, language_quirks: initialReporter.language_quirks || [], audience: initialReporter.audience, custom_instructions: initialReporter.custom_instructions, } : { writing_language: desiredWritingLanguage, } const res = await endpoints.createChatSession({ session_type: 'reporter_builder', reporter_id: initialReporter?.id, structured_state: structuredState, summary: initialReporter?.builder_summary || '', wizard_context: initialReporter ? { reporter: initialReporter, language: dataLanguage, reporter_language: desiredWritingLanguage } : { language: dataLanguage, reporter_language: desiredWritingLanguage }, }) const data = res.data as any setSessionId(data.session_id) setDraftState(data.structured_state || structuredState) setMessages((data.messages as BuilderMessage[]) || []) }, [dataLanguage, initialReporter]) const sendViaPolling = useCallback(async (message: string) => { const res = await endpoints.sendChatRespond({ mode: 'reporter_builder', session_id: sessionId, user_message: message, summary: '', structured_state: {}, recent_messages: [], context: initialReporter ? { reporter: initialReporter, language: dataLanguage, reporter_language: desiredWritingLanguage } : { language: dataLanguage, reporter_language: desiredWritingLanguage }, }) const responseId = (res.data as any).response_id as string return pollResponse(responseId) }, [dataLanguage, initialReporter, sessionId]) const applyAssistantResult = useCallback((result: BuilderResult) => { setMessages((current) => [...current, { role: 'assistant', content: result.text }]) setDraftState((current) => mergeDraft(current, result.controlPayload)) const coinsUsed = Number(result.usage?.coins_used ?? 0) if (coinsUsed > 0) { setSessionCoins((current) => current + coinsUsed) setBalance((current) => current === null ? current : current - coinsUsed) } }, []) const sendMessage = useCallback(async (message: string, showUser = true) => { if (!sessionId) { return } setSending(true) setStatusMessage(null) if (showUser) { setMessages((current) => [...current, { role: 'user', content: message }]) } try { const result = await sendViaPolling(message) applyAssistantResult(result) } catch { setStatusMessage(t('AI response failed')) } finally { setSending(false) } }, [applyAssistantResult, sendViaPolling, sessionId]) useEffect(() => { if (!open || initializedRef.current) { return } initializedRef.current = true void (async () => { await loadBalance() await openSession() })() }, [loadBalance, open, openSession]) useEffect(() => { if (!open || !sessionId || messages.length > 0) { return } void sendMessage( initialReporter ? `${AUTO_REFINE_MESSAGE} I want to refine this reporter. Please greet me, acknowledge the existing profile, and ask the most useful next question.` : `${AUTO_CREATE_MESSAGE} I want to create a new reporter. Please greet me and ask the most useful first question.`, false, ) }, [initialReporter, messages.length, open, sendMessage, sessionId]) useEffect(() => { if (!open) { initializedRef.current = false setSessionId(null) setMessages([]) setInput('') setSending(false) setDraftState({}) setSessionCoins(0) setStatusMessage(null) } }, [open]) useEffect(() => { if (!open || !messagesRef.current) { return } messagesRef.current.scrollTop = messagesRef.current.scrollHeight }, [open, sending, statusMessage, visibleMessages]) if (!open) { return null } const handleManualOverride = () => { onUseManualDraft({ name: String(draftState.name || draftState.suggested_name || initialReporter?.name || ''), specializations: Array.isArray(draftState.specializations) ? draftState.specializations as string[] : initialReporter?.specializations || [], writing_style: String(draftState.writing_style || initialReporter?.writing_style || 'conversational') as Reporter['writing_style'], depth_level: String(draftState.depth_level || initialReporter?.depth_level || 'detailed') as Reporter['depth_level'], tone: String(draftState.tone || initialReporter?.tone || 'friendly') as Reporter['tone'], perspective: String(draftState.perspective || initialReporter?.perspective || 'third_person') as Reporter['perspective'], gender: String(draftState.gender || initialReporter?.gender || 'neutral') as Reporter['gender'], writing_language: String(draftState.writing_language || desiredWritingLanguage), language_quirks: Array.isArray(draftState.language_quirks) ? draftState.language_quirks as string[] : initialReporter?.language_quirks || [], audience: String(draftState.audience || initialReporter?.audience || ''), custom_instructions: String(draftState.custom_instructions || initialReporter?.custom_instructions || ''), }) onClose() } const handleSave = async () => { if (!sessionId) { return } setSending(true) try { const res = await endpoints.completeReporterBuilderSession(sessionId) onSaved(res.data as Reporter) } catch { setStatusMessage(t('Failed to save reporter')) } finally { setSending(false) } } return (

{t('Reporter Builder')}

{initialReporter ? t('AI is refining the current reporter profile') : t('Ask AI to help shape this reporter profile')}

{visibleMessages.map((message, index) => (
{message.role === 'assistant' ? : } {message.role === 'assistant' ? t('Assistant') : t('You')}
{message.content}
))} {sending ? (
{t('Generating response...')}
) : null} {statusMessage ? (
{statusMessage}
) : null}