import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Bot, Loader2, Send, User } from 'lucide-react' import { Badge, Button, Card, Loader } from '../../components/common' import { endpoints, getErrorMessage } from '../../api/client' import { t, tf } from '../../lib/i18n' import type { ConversationMessage } from '../../types' import { areTechnicalQuestionCardsComplete, buildTechnicalQuestionSubmission, formatCoins, getSuggestedQuestionOptionIds, mergeConversationState, normalizeTechnicalQuestionCards, normalizeTechnicalQuestionResponses, normalizeUnderstandingPercent, sendConversationPolling, type ConversationResult, } from './conversation-utils' interface ConversationPhaseStepProps { mode: 'generation_technical' | 'generation_content' title: string subtitle: string assistantLabel?: string sessionId: string | null initialStructuredState: Record initialSummary?: string context: Record autoStartMessage: string onSessionChange: (sessionId: string) => void onStateChange: (state: Record) => void onBack?: () => void onContinue: (state: Record) => void onSkip: () => void continueLabel: string skipLabel: string submitting?: boolean renderSidebar: (args: { draftState: Record ready: boolean sessionCoins: number balance: number | null updateDraftState: ( updater: Record | ((current: Record) => Record) ) => void }) => React.ReactNode renderSecondaryPanel?: (args: { draftState: Record ready: boolean sessionCoins: number balance: number | null updateDraftState: ( updater: Record | ((current: Record) => Record) ) => void }) => React.ReactNode } export function ConversationPhaseStep({ mode, title, subtitle, assistantLabel, sessionId, initialStructuredState, initialSummary = '', context, autoStartMessage, onSessionChange, onStateChange, onBack, onContinue, onSkip, continueLabel, skipLabel, submitting = false, renderSidebar, renderSecondaryPanel, }: ConversationPhaseStepProps) { const [messages, setMessages] = useState([]) const [draftState, setDraftState] = useState>(initialStructuredState) const [input, setInput] = useState('') const [sending, setSending] = useState(false) const [loading, setLoading] = useState(true) const [sessionCoins, setSessionCoins] = useState(0) const [balance, setBalance] = useState(null) const [statusMessage, setStatusMessage] = useState(null) const lastUserMessageRef = useRef<{ text: string; showUser: boolean } | null>(null) const loadedSessionKeyRef = useRef('') const messagesRef = useRef(null) const abortControllerRef = useRef(null) useEffect(() => { return () => { abortControllerRef.current?.abort() } }, []) const ready = Boolean(draftState.ready) const activeSessionId = sessionId const resolvedAssistantLabel = assistantLabel?.trim() || t('Assistant') const understandingPercent = normalizeUnderstandingPercent(draftState.understanding_percent) const normalizedQuestionCards = useMemo( () => normalizeTechnicalQuestionCards(draftState.question_cards), [draftState.question_cards], ) const questionCards = normalizedQuestionCards const questionResponses = useMemo( () => normalizeTechnicalQuestionResponses(draftState.question_responses), [draftState.question_responses], ) const hasQuestionWidget = questionCards.length > 0 && !ready const canSubmitQuestionWidget = areTechnicalQuestionCardsComplete(questionCards, questionResponses) const visibleMessages = useMemo( () => messages.filter((message) => message.content !== autoStartMessage), [autoStartMessage, messages], ) const lastAssistantMessage = useMemo( () => [...visibleMessages].reverse().find((message) => message.role === 'assistant') || null, [visibleMessages], ) const lastAssistantContent = lastAssistantMessage?.content ?? '' const openQuestions = Array.isArray(draftState.open_questions) ? draftState.open_questions : [] const shouldShowQuestionFallback = !hasQuestionWidget && !ready && openQuestions.length > 0 && Boolean(lastAssistantContent) const questionWidgetTitle = mode === 'generation_technical' ? t('Answer the open technical questions') : t('Answer the open planning questions') const questionWidgetDescription = mode === 'generation_technical' ? t('Choose an answer for each question, add details where needed, and then submit everything together.') : t('Choose the best options, keep or change the suggested answers, and add extra context where helpful.') const showQuestionSidebar = hasQuestionWidget const updateDraftState = useCallback(( updater: Record | ((current: Record) => Record), ) => { setDraftState((current) => { const nextState = typeof updater === 'function' ? updater(current) : updater onStateChange(nextState) return nextState }) }, [onStateChange]) const applyAssistantResult = useCallback((result: ConversationResult) => { setMessages((current) => [...current, { role: 'assistant', content: result.text }]) setDraftState((current) => { const nextState = mergeConversationState(current, result.controlPayload) onStateChange(nextState) return nextState }) const coinsUsed = Number(result.usage?.coins_used ?? 0) if (coinsUsed > 0) { setSessionCoins((current) => current + coinsUsed) setBalance((current) => current === null ? current : current - coinsUsed) } }, [onStateChange]) const loadBalance = useCallback(async () => { try { const credits = await endpoints.getCreditsStatus() const cd = credits.data as any const freeRemaining = Math.max(0, (cd?.credits?.free?.total ?? 0) - (cd?.credits?.free?.used ?? 0)) const purchasedRemaining = Math.max(0, (cd?.boost_credits?.total ?? 0) - (cd?.boost_credits?.used ?? 0)) const subRemaining = Math.max(0, (cd?.credits?.subscription?.total ?? 0) - (cd?.credits?.subscription?.used ?? 0)) setBalance(freeRemaining + purchasedRemaining + subRemaining) } catch { setBalance(null) } }, []) const openSession = useCallback(async () => { if (activeSessionId) { const res = await endpoints.getChatSession(activeSessionId) const data = res.data as any const loadedState = mergeConversationState( initialStructuredState, ((data.structured_state || {}) as Record), ) setMessages((data.messages as ConversationMessage[]) || []) setDraftState(loadedState) onStateChange(loadedState) return } const res = await endpoints.createChatSession({ session_type: mode, structured_state: initialStructuredState, summary: initialSummary, wizard_context: context, }) const data = res.data as any const createdSessionId = data.session_id as string onSessionChange(createdSessionId) setMessages((data.messages as ConversationMessage[]) || []) setDraftState((data.structured_state || initialStructuredState) as Record) onStateChange((data.structured_state || initialStructuredState) as Record) }, [activeSessionId, context, initialStructuredState, initialSummary, mode, onSessionChange, onStateChange]) const sendMessage = useCallback(async (message: string, showUser = true) => { if (!activeSessionId) { return } setSending(true) setStatusMessage(null) lastUserMessageRef.current = { text: message, showUser } if (showUser) { setMessages((current) => [...current, { role: 'user', content: message }]) } abortControllerRef.current?.abort() const controller = new AbortController() abortControllerRef.current = controller try { const result = await sendConversationPolling({ mode, sessionId: activeSessionId, userMessage: message, context, structuredState: draftState, signal: controller.signal, }) applyAssistantResult(result) lastUserMessageRef.current = null } catch (error) { if (error instanceof DOMException && error.name === 'AbortError') return const rawMsg = getErrorMessage(error, '') const isTokenLimit = rawMsg.includes('RESPONSE_TOO_LARGE') || rawMsg.includes('output limit') const isJsonError = !isTokenLimit && (rawMsg.includes('JSON') || rawMsg.includes('parse') || rawMsg.includes('position')) if (isTokenLimit) { setStatusMessage(t('The site has many custom fields and the AI response was too large. Please try again — this has been automatically adjusted.')) } else if (isJsonError) { setStatusMessage(t('The AI response had a formatting issue. Please try again.')) } else { setStatusMessage(rawMsg || t('AI response failed')) } } finally { setSending(false) } }, [activeSessionId, applyAssistantResult, context, draftState, mode]) useEffect(() => { const nextLoadKey = `${mode}:${activeSessionId || 'new'}` if (loadedSessionKeyRef.current === nextLoadKey) { return } loadedSessionKeyRef.current = nextLoadKey setLoading(true) void (async () => { await loadBalance() await openSession() setLoading(false) })() }, [activeSessionId, loadBalance, mode, openSession]) useEffect(() => { if (loading || !activeSessionId || messages.length > 0) { return } void sendMessage(autoStartMessage, false) }, [activeSessionId, autoStartMessage, loading, messages.length, sendMessage]) useEffect(() => { if (!messagesRef.current) { return } messagesRef.current.scrollTop = messagesRef.current.scrollHeight }, [sending, statusMessage, visibleMessages]) useEffect(() => { if (questionCards.length === 0) { return } const nextResponses = normalizeTechnicalQuestionResponses(draftState.question_responses) let changed = false questionCards.forEach((card) => { const currentResponse = nextResponses[card.id] if (currentResponse?.selected_option_ids.length) { return } const suggestedOptionIds = getSuggestedQuestionOptionIds(card) if (suggestedOptionIds.length === 0) { return } nextResponses[card.id] = { selected_option_ids: suggestedOptionIds, other_text: currentResponse?.other_text || '', } changed = true }) if (!changed) { return } updateDraftState((current) => ({ ...current, question_responses: nextResponses, })) }, [draftState.question_responses, questionCards, updateDraftState]) const lastContextCountRef = useRef(null) useEffect(() => { if (mode !== 'generation_content') { return } const count = typeof (context as Record).count === 'number' ? (context as Record).count as number : null if (count === null) { return } if (lastContextCountRef.current === null) { lastContextCountRef.current = count return } if (count === lastContextCountRef.current) { return } const previousCount = lastContextCountRef.current lastContextCountRef.current = count if (!activeSessionId || messages.length === 0 || loading) { return } void sendMessage( `[SYSTEM] ${tf('Important update: The number of articles has been changed from %d to %d. Please update the topic tree accordingly.', previousCount, count)}`, false, ) }, [activeSessionId, context, loading, messages.length, mode, sendMessage]) if (loading) { return (
) } return (

{title}

{subtitle}

{mode === 'generation_technical' ? (
{t('Technical understanding')} {understandingPercent}%
) : null}
{visibleMessages.map((message, index) => (
{message.role === 'assistant' ? : } {message.role === 'assistant' ? resolvedAssistantLabel : t('You')}
{message.content}
))} {sending ? (
{t('Generating response...')}
) : null} {statusMessage ? (
{statusMessage} {lastUserMessageRef.current && ( )}
) : null}
{shouldShowQuestionFallback ? (
{t('Question cards are unavailable for this reply. Use the text box below so the conversation can continue.')}
) : null}