Cybersecurity-Panel / frontend /src /components /SuggestionsPanel.js
NeonClary's picture
Workspace widget preview, multi-source reference search, and arXiv CORS proxy.
d0878a6 verified
Raw
History Blame Contribute Delete
10.1 kB
// 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 (
<div className="suggestions-panel">
<div className="suggestions-header">
<h2 className="suggestions-title">Getting Started</h2>
</div>
<div className="suggestions-grid">
{categories.map((category, categoryIndex) => {
const Icon = resolveIcon(category.icon);
return (
<div key={categoryIndex} className="suggestion-category">
<div className="category-header">
<div
className="category-icon"
style={{
backgroundColor: category.bg_color || '#F3F4F6',
color: category.color || '#6B7280'
}}
>
<Icon size={16} />
</div>
<h3
className="category-title"
style={{ color: category.color || '#6B7280' }}
>
{category.title}
</h3>
</div>
<div className="suggestion-buttons">
{(category.suggestions || []).map((suggestion, suggestionIndex) => {
const pending = category.pending?.[suggestionIndex];
const label = suggestion || (pending ? 'Writing a question…' : '');
return (
<button
key={`${categoryIndex}-${suggestionIndex}`}
type="button"
disabled={!suggestion}
onClick={() => handleChipClick(categoryIndex, suggestionIndex, suggestion)}
className={
pending
? 'suggestion-button suggestion-pending'
: 'suggestion-button'
}
style={{
borderColor: (category.color || '#6B7280') + '20',
'--hover-bg': category.bg_color || '#F3F4F6',
'--hover-border': category.color || '#6B7280',
'--hover-text': category.color || '#6B7280',
}}
>
{label}
</button>
);
})}
</div>
</div>
);
})}
</div>
</div>
);
};
export default SuggestionsPanel;