// src/components/SuggestionsPanel.js import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useAppConfig } from '../contexts/AppConfigContext'; import { goalCacheKey } from '../utils/statedGoal'; import { loadStarterSuggestions, saveStarterSuggestions } from '../utils/starterCache'; import { STARTER_FALLBACK_MS, applyFallbackIfTimedOut, applySlotDelta, applySlotDone, buildStarterSlots, slotsToCategories, visiblePrompts, } from '../utils/starterSuggestionsUi'; async function fetchStarterSuggestions({ authToken, count, exclude, categoryTitles, signal, }) { if (!authToken) return []; try { const response = await fetch( `${process.env.REACT_APP_API_URL}/chat/starter-suggestions`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${authToken}`, }, body: JSON.stringify({ count, exclude, category_titles: categoryTitles, }), signal, } ); if (!response.ok) return []; const data = await response.json(); if (!Array.isArray(data.suggestions)) return []; return data.suggestions .map((item) => (typeof item === 'string' ? item.trim() : '')) .filter(Boolean); } catch (err) { if (err?.name === 'AbortError') return []; return []; } } function restoreSlotsFromCache(examples, cached) { const slots = buildStarterSlots(examples); const saved = cached?.slots; if (!Array.isArray(saved) || saved.length === 0) return null; saved.forEach((item) => { const idx = typeof item.slot === 'number' ? item.slot : -1; if (idx < 0 || idx >= slots.length || !item.text) return; slots[idx] = { ...slots[idx], text: item.text, pending: false, generated: item.generated !== false, }; }); if (!slots.some((s) => s.text)) return null; return slots; } const SuggestionsPanel = ({ onSuggestionClick, guestPersona = null, authToken = null, user = null, statedGoal = '', onSuggestionsReady = null, }) => { const { config, resolveIcon } = useAppConfig(); const examples = useMemo(() => { const chatPage = config?.chat_page || {}; const byPersona = chatPage.examples_by_persona || {}; const personaKey = (guestPersona || '').toLowerCase(); const personaExamples = personaKey && Array.isArray(byPersona[personaKey]) ? byPersona[personaKey] : null; if (personaExamples && personaExamples.length > 0) return personaExamples; return chatPage.examples || []; }, [config, guestPersona]); const cacheKey = useMemo( () => goalCacheKey(statedGoal, user?.id || user?._id || user?.email || (user?.is_guest ? 'guest' : '')), [statedGoal, user], ); const [slots, setSlots] = useState(() => buildStarterSlots(examples)); const slotsRef = useRef(slots); const usedRef = useRef(new Set()); slotsRef.current = slots; const readyNotified = useRef(''); const categories = useMemo( () => slotsToCategories(examples, slots), [examples, slots], ); useEffect(() => { const current = slotsRef.current || []; if (current.length === 0 || current.some((s) => s.pending)) return; const prompts = visiblePrompts(current); if (!onSuggestionsReady || prompts.length === 0) return; const sig = prompts.join('\n'); if (readyNotified.current === sig) return; readyNotified.current = sig; onSuggestionsReady(prompts); }, [slots, onSuggestionsReady]); useEffect(() => { const cached = loadStarterSuggestions(cacheKey); const restored = restoreSlotsFromCache(examples, cached); if (restored) { setSlots(restored); slotsRef.current = restored; return undefined; } const initial = buildStarterSlots(examples); setSlots(initial); slotsRef.current = initial; usedRef.current = new Set(); if (!authToken || initial.length === 0) { setSlots(applyFallbackIfTimedOut(initial, 0, STARTER_FALLBACK_MS)); return undefined; } const controller = new AbortController(); const startedAt = Date.now(); const fallbackTimer = setTimeout(() => { setSlots((prev) => applyFallbackIfTimedOut(prev, startedAt, Date.now())); }, STARTER_FALLBACK_MS); const titles = initial.map((slot) => slot.categoryTitle); const exclude = initial.map((slot) => slot.fallback).filter(Boolean); (async () => { try { const response = await fetch( `${process.env.REACT_APP_API_URL}/chat/starter-suggestions-stream`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${authToken}`, }, body: JSON.stringify({ count: titles.length, exclude, category_titles: titles, }), signal: controller.signal, }, ); if (!response.ok || !response.body) return; const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n'); buffer = lines.pop() ?? ''; for (const line of lines) { if (!line.trim()) continue; let payload; try { payload = JSON.parse(line); } catch { continue; } if (payload.type === 'delta') { setSlots((prev) => applySlotDelta(prev, payload.slot, payload.text || '')); } else if (payload.type === 'done') { setSlots((prev) => applySlotDone(prev, payload.slot, payload.text || '')); } } } } catch (err) { if (err?.name !== 'AbortError') { setSlots((prev) => applyFallbackIfTimedOut(prev, 0, STARTER_FALLBACK_MS)); } } })(); return () => { clearTimeout(fallbackTimer); controller.abort(); }; }, [authToken, cacheKey, examples]); useEffect(() => { const filled = (slots || []).filter((s) => (s.text || '').trim()); if (filled.length === 0 || filled.length < slots.length) return; saveStarterSuggestions(cacheKey, { slots: slots.map((s) => ({ slot: s.slot, text: s.text, generated: s.generated, })), }); }, [slots, cacheKey]); const handleChipClick = (categoryIndex, suggestionIndex, text) => { if (!text) return; onSuggestionClick(text); usedRef.current.add(text); if (!authToken) return; const visible = visiblePrompts(slotsRef.current); const exclude = [...new Set([...usedRef.current, ...visible])]; const title = examples[categoryIndex]?.title; const slot = slotsRef.current.find( (s) => s.categoryIndex === categoryIndex && s.suggestionIndex === suggestionIndex, ); fetchStarterSuggestions({ authToken, count: 1, exclude, categoryTitles: title ? [title] : [], }).then((generated) => { const replacement = generated[0]; if (!replacement || slot == null) return; setSlots((prev) => applySlotDone(prev, slot.slot, replacement)); }); }; return (