/** * Build citation-oriented search queries and URLs for advisor answers. * * Websites cannot read the browser's default search engine. Web search opens * in a new tab (the user's default browser); the engine is configurable via * localStorage key `webSearchEngine` (google | ddg | bing | brave). */ export const SEARCH_QUERY_MAX_CHARS = 1700; export const WEB_SEARCH_ENGINE_KEY = 'webSearchEngine'; export const WEB_SEARCH_ENGINES = { google: { id: 'google', label: 'Google', url: 'https://www.google.com/search?q=', }, ddg: { id: 'ddg', label: 'DuckDuckGo', url: 'https://duckduckgo.com/?q=', }, bing: { id: 'bing', label: 'Bing', url: 'https://www.bing.com/search?q=', }, brave: { id: 'brave', label: 'Brave', url: 'https://search.brave.com/search?q=', }, }; const CLAIM_HINT = /\b(must|should|require|requires|nist|iso|cve|cisa|owasp|because|therefore|according|standard|framework|control|risk|attack|vulnerability|encrypt|mfa|zero[- ]trust|gdpr|hipaa|pci)\b/i; export function stripSearchMarkdown(md) { if (!md) return ''; return String(md) .replace(/```[\s\S]*?```/g, ' ') .replace(/`([^`]+)`/g, '$1') .replace(/#{1,6}\s?/g, '') .replace(/[*_~]{1,3}([^*_~]+)[*_~]{1,3}/g, '$1') .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') .replace(/!\[[^\]]*\]\([^)]+\)/g, '$1') .replace(/^\s*[-*+]\s+/gm, '') .replace(/^\s*\d+\.\s+/gm, '') .replace(/\s+/g, ' ') .trim(); } function splitAssertions(text) { if (!text) return []; return text .replace(/([.!?])\s+/g, '$1\n') .split(/\n+/) .map((s) => s.replace(/^[\s•●▪◦-]+/, '').trim()) .filter((s) => s.length >= 28 && s.length <= 280) .filter((s) => !/^(hi|hello|thanks|thank you)\b/i.test(s)); } export function extractAssertions(text, { maxClaims = 6 } = {}) { const cleaned = stripSearchMarkdown(text); const parts = splitAssertions(cleaned); const scored = parts.map((claim, index) => ({ claim, index, score: (CLAIM_HINT.test(claim) ? 3 : 0) + Math.min(2, Math.floor(claim.length / 80)), })); scored.sort((a, b) => b.score - a.score || a.index - b.index); const picked = []; const seen = new Set(); for (const item of scored) { const key = item.claim.slice(0, 48).toLowerCase(); if (seen.has(key)) continue; seen.add(key); picked.push(item.claim.replace(/[.]+$/, '')); if (picked.length >= maxClaims) break; } if (picked.length === 0 && cleaned) { picked.push(cleaned.slice(0, 220).trim()); } return picked; } export function truncateQuery(text, maxChars = SEARCH_QUERY_MAX_CHARS) { const value = String(text || '').trim(); if (value.length <= maxChars) return value; const sliced = value.slice(0, maxChars); const lastBreak = Math.max(sliced.lastIndexOf(' '), sliced.lastIndexOf(','), sliced.lastIndexOf(';')); return `${(lastBreak > 40 ? sliced.slice(0, lastBreak) : sliced).trim()}…`; } export function buildReferenceSearchQuery({ advisorText = '', userQuestion = '', advisorName = '', } = {}) { const assertions = extractAssertions(advisorText); const claimBlock = assertions.map((c, i) => `${i + 1}. ${c}`).join(' '); const who = advisorName ? ` in the ${advisorName} advisor answer` : ' in the advisor answer'; const context = userQuestion ? ` User question for context: "${stripSearchMarkdown(userQuestion).slice(0, 220)}".` : ''; const query = [ 'Find primary sources, standards, and reputable citations that support or challenge these claims', `${who}:`, claimBlock, context, 'Prefer NIST, CISA, ISO, OWASP, RFCs, vendor advisories, and peer-reviewed sources over blogs.', ].filter(Boolean).join(' '); return truncateQuery(query); } export function getWebSearchEngine(storage = (typeof localStorage !== 'undefined' ? localStorage : null)) { try { const id = storage?.getItem?.(WEB_SEARCH_ENGINE_KEY); if (id && WEB_SEARCH_ENGINES[id]) return WEB_SEARCH_ENGINES[id]; } catch { /* ignore quota / private mode */ } return WEB_SEARCH_ENGINES.google; } export function setWebSearchEngine(id, storage = (typeof localStorage !== 'undefined' ? localStorage : null)) { const engine = WEB_SEARCH_ENGINES[id] || WEB_SEARCH_ENGINES.google; try { storage?.setItem?.(WEB_SEARCH_ENGINE_KEY, engine.id); } catch { /* ignore */ } return engine; } export function buildWebSearchUrl(query, engineId) { const engine = (engineId && WEB_SEARCH_ENGINES[engineId]) || getWebSearchEngine(); return `${engine.url}${encodeURIComponent(query || '')}`; } export function buildPerplexityUrl(query) { return `https://www.perplexity.ai/?q=${encodeURIComponent(query || '')}`; } export function formatReferenceSnippet(text) { const trimmed = String(text || '').trim(); return `Here are references I found:\n\n${trimmed}`; } export function wrapReferencesForAdvisorContext(snippet) { const body = String(snippet || '').trim(); if (!body) return ''; return [ '[User-provided references from web search. Treat these as supporting sources; use them to support or challenge claims in your answer.]', body, ].join('\n\n'); } export async function readClipboardText() { if (typeof navigator === 'undefined' || !navigator.clipboard?.readText) { const err = new Error('Clipboard API unavailable'); err.code = 'unavailable'; throw err; } try { const text = await navigator.clipboard.readText(); return { text: String(text || ''), via: 'clipboard' }; } catch (cause) { const err = new Error(cause?.message || 'Clipboard permission denied'); err.code = 'denied'; err.cause = cause; throw err; } }