Cybersecurity-Panel / frontend /src /utils /starterSuggestionsUi.js
NeonClary's picture
Welcome path navigator, Practical Next Steps chips (user-voice, diversity, refresh), and distinct advisor personas.
ad7840f verified
Raw History Blame Contribute Delete
10 kB
/** Pure helpers for Practical Next Steps chip slots, fallback, and stable order. */
export const STARTER_FALLBACK_MS = 5000;
export const MAX_STARTER_SLOTS = 12;
export const PROMPT_VARIATION_COUNT = 6;
const FILLER_OPENER_RE = /^(?:to begin(?:\s+with)?|to shape(?:\s+your\s+path)?|to start(?:\s+with)?|to identify|to dissect|to analyze|to explore|to consider|to examine|to discuss|let'?s|first off|as a first step|i'?d like|i would like|so,?|now,?|here,?|for(?:\s+a)?\s+start|getting started|to get started|to understand|in order to|when it comes to)\b[\s,:-]*/i;
const TOPIC_COMMA_LEAD_RE = /^for\s+[^?]{1,48}?,[\s]*/i;
const ADVISOR_TO_USER_RE = /\b(?:let'?s|let us|have you considered|have you thought|have you looked|you should|you might|you need to|you can start|for instance|this idea blends|the core concept|to identify the|which creates a unique)\b/i;
const FIRST_PERSON_RE = /\b(?:i|i'm|i'd|i've|i'll|my|mine)\b/i;
const SECOND_PERSON_YOUR_RE = /\byour\b/i;
function looksLikeJsonPayload(text) {
const trimmed = String(text || '').trim();
return trimmed.startsWith('{') || trimmed.startsWith('[');
}
function extractQuestionFromPayload(raw) {
const text = String(raw || '').trim();
if (!looksLikeJsonPayload(text)) return text;
try {
const parsed = JSON.parse(text);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
return String(parsed.question || parsed.text || '').trim();
}
if (Array.isArray(parsed) && parsed[0]) {
return extractQuestionFromPayload(parsed[0]);
}
} catch {
const match = text.match(/"question"\s*:\s*"((?:\\.|[^"\\])*)/);
if (match) {
try {
return JSON.parse(`"${match[1]}"`);
} catch {
return match[1].replace(/\\"/g, '"');
}
}
}
return '';
}
function isMultiSentence(text) {
let body = String(text || '').trim();
if (body.endsWith('?')) body = body.slice(0, -1);
body = body.replace(/\b(?:e\.g|i\.e|etc)\./gi, ' ').replace(/\.{2,}/g, ' ');
return /[.!?]/.test(body);
}
export function looksLikeAdvisorToUser(text) {
const cleaned = String(text || '').replace(/\s+/g, ' ').trim();
if (!cleaned) return false;
if (ADVISOR_TO_USER_RE.test(cleaned)) return true;
if (isMultiSentence(cleaned)) return true;
if (SECOND_PERSON_YOUR_RE.test(cleaned) && !FIRST_PERSON_RE.test(cleaned)) return true;
return false;
}
function userFacingChatPrompt(chatPrompt, question) {
const chat = String(chatPrompt || '').trim();
const q = String(question || '').trim();
if (chat && !looksLikeAdvisorToUser(chat)) return chat;
if (q && !looksLikeAdvisorToUser(q)) return q;
return q || chat;
}
export function stripForbiddenOpeners(text) {
let cleaned = extractQuestionFromPayload(text);
cleaned = String(cleaned || '').trim().replace(/^["']+|["']+$/g, '');
cleaned = cleaned.replace(/\s+/g, ' ').trim();
if (looksLikeJsonPayload(cleaned)) return '';
let prev = null;
while (cleaned && cleaned !== prev) {
prev = cleaned;
cleaned = cleaned.replace(FILLER_OPENER_RE, '').replace(TOPIC_COMMA_LEAD_RE, '').trim();
}
if (!cleaned) return '';
return cleaned.charAt(0).toUpperCase() + cleaned.slice(1);
}
export function fallbackToSuggestion(text) {
const question = stripForbiddenOpeners(text) || String(text || '').trim();
return {
lead: '',
question,
chat_prompt: question,
};
}
export function normalizeSuggestion(raw, fallbackText = '') {
if (raw && typeof raw === 'object') {
const question = stripForbiddenOpeners(raw.question || raw.text || '');
const chatPrompt = String(raw.chat_prompt || raw.chatPrompt || '').trim();
if (question || chatPrompt) {
const display = question || stripForbiddenOpeners(chatPrompt);
return {
lead: '',
question: display,
chat_prompt: userFacingChatPrompt(chatPrompt, display),
};
}
}
const text = String(raw || fallbackText || '').trim();
return fallbackToSuggestion(text);
}
export function suggestionDisplayText(suggestion) {
return (suggestion?.question || '').trim();
}
export function suggestionChatPrompt(suggestion) {
const question = (suggestion?.question || suggestionDisplayText(suggestion) || '').trim();
return userFacingChatPrompt(suggestion?.chat_prompt, question);
}
export function suggestionExcludeKeys(suggestion) {
const keys = [
suggestionChatPrompt(suggestion),
suggestionDisplayText(suggestion),
suggestion?.question,
];
return [...new Set(keys.map((k) => String(k || '').trim()).filter(Boolean))];
}
/**
* Flatten example categories into stable slots 0..n (category-major order).
* Caps at MAX_STARTER_SLOTS without reordering remaining items.
*/
export function buildStarterSlots(examples, maxSlots = MAX_STARTER_SLOTS) {
const slots = [];
(examples || []).forEach((category, categoryIndex) => {
(category.suggestions || []).forEach((text, suggestionIndex) => {
if (slots.length >= maxSlots) return;
const fallback = typeof text === 'string' ? text : '';
slots.push({
slot: slots.length,
categoryIndex,
suggestionIndex,
categoryTitle: category.title || `Starter ${slots.length + 1}`,
fallback,
suggestion: fallbackToSuggestion(fallback),
text: '',
generated: false,
pending: true,
promptVariation: slots.length % PROMPT_VARIATION_COUNT,
refreshing: false,
});
});
});
return slots;
}
export function applySlotDelta(slots, slotIndex, chunk) {
if (!Array.isArray(slots) || slotIndex < 0 || slotIndex >= slots.length) return slots;
const question = stripForbiddenOpeners(String(chunk || ''));
if (!question) return slots;
const next = slots.slice();
const current = next[slotIndex];
next[slotIndex] = {
...current,
text: question,
suggestion: {
lead: '',
question,
chat_prompt: current.suggestion?.chat_prompt || question,
},
pending: false,
generated: true,
};
return next;
}
export function applySlotDone(slots, slotIndex, payload) {
if (!Array.isArray(slots) || slotIndex < 0 || slotIndex >= slots.length) return slots;
const next = slots.slice();
const current = next[slotIndex];
const suggestion = normalizeSuggestion(
payload?.suggestion ?? payload,
typeof payload === 'string' ? payload : current.text || current.fallback,
);
const finalText = suggestionDisplayText(suggestion) || (current.text || '').trim();
next[slotIndex] = {
...current,
suggestion,
text: finalText,
pending: false,
refreshing: false,
generated: Boolean(finalText) && finalText !== current.fallback,
};
if (finalText) next[slotIndex].generated = true;
return next;
}
export function applySlotRefreshing(slots, slotIndex, refreshing = true) {
if (!Array.isArray(slots) || slotIndex < 0 || slotIndex >= slots.length) return slots;
const next = slots.slice();
next[slotIndex] = {
...next[slotIndex],
refreshing,
pending: refreshing,
};
return next;
}
/** If a slot has no tokens after timeout, fill with YAML fallback. Later LLM text may replace it. */
export function applyFallbackIfTimedOut(slots, startedAt, now, timeoutMs = STARTER_FALLBACK_MS) {
if (!Array.isArray(slots) || now - startedAt < timeoutMs) return slots;
let changed = false;
const next = slots.map((slot) => {
if (!slot.pending) return slot;
if (slot.text) return { ...slot, pending: false, generated: true };
if (!slot.fallback) return { ...slot, pending: false };
changed = true;
const suggestion = fallbackToSuggestion(slot.fallback);
return {
...slot,
suggestion,
text: suggestionDisplayText(suggestion),
pending: false,
generated: false,
};
});
return changed ? next : slots;
}
export function slotsToFlatList(slots) {
return (slots || [])
.slice()
.sort((a, b) => a.slot - b.slot)
.map((slot) => ({
...slot,
suggestion: slot.suggestion || fallbackToSuggestion(slot.fallback),
displayText: slot.text || suggestionDisplayText(slot.suggestion) || slot.fallback || '',
isPending: Boolean(slot.pending && !slot.text && !slot.refreshing),
}));
}
/** @deprecated Use slotsToFlatList — kept for tests migrating off category grid. */
export function slotsToCategories(examples, slots) {
return (examples || []).map((category, categoryIndex) => {
const owned = (slots || []).filter((s) => s.categoryIndex === categoryIndex);
return {
...category,
suggestions: owned.map((s) => s.text || (s.pending ? '' : s.fallback) || ''),
generated: owned.map((s) => Boolean(s.generated)),
pending: owned.map((s) => Boolean(s.pending && !s.text)),
};
}).filter((_category, categoryIndex) => (
(slots || []).some((s) => s.categoryIndex === categoryIndex)
));
}
export function visiblePrompts(slots) {
return (slots || [])
.map((s) => suggestionChatPrompt(s.suggestion || fallbackToSuggestion(s.text || s.fallback)))
.filter(Boolean);
}
export function collectExcludeKeys(slots, extra = []) {
const normalize = (value) => String(value || '').trim().replace(/[.?!]+$/g, '').toLowerCase();
const fromSlots = (slots || []).flatMap((slot) => suggestionExcludeKeys(
slot.suggestion || fallbackToSuggestion(slot.text || slot.fallback),
));
const merged = [...(extra || []), ...fromSlots]
.map((s) => String(s || '').trim())
.filter(Boolean);
const seen = new Set();
return merged.filter((item) => {
const key = normalize(item);
if (!key || seen.has(key)) return false;
seen.add(key);
return true;
});
}
export function nextPromptVariation(current = 0) {
return (Number(current) + 1) % PROMPT_VARIATION_COUNT;
}