'use client'; import { useState, useRef, useCallback, useEffect } from 'react'; import { useRouter } from 'next/navigation'; import { motion } from 'framer-motion'; import { Brain, CircleNotch as Loader2, Dna, MagnifyingGlass as Search } from '@phosphor-icons/react'; import type { AssembledContext } from '@/types/pipeline'; import type { StreamEvent } from '@/types/results'; import { interpretStream } from '@/lib/api'; import { extractErrorMessage } from '@/lib/errors'; import { fadeUp, fadeIn, cardHover } from '@/lib/animations'; const RATE_LIMIT_MESSAGE = (delay: number) => `The AI provider is rate-limited (try again in ~${Math.max(1, Math.round(delay))}s). Retrying with backups…`; function rateLimitDelay(text: string): number | null { const m = text.match(/try again in ~(\d+)s/i); if (m) return Math.max(1, parseInt(m[1], 10)); return /rate[- ]limit|429|too many requests/i.test(text) ? 30 : null; } function isRetryNotice(text: string): boolean { return /retrying .+ in ~\d+s/i.test(text) || /unavailable, trying next/i.test(text); } function addCitationLinks(text: string): string { return text.replace( /\b([A-Z][0-9][A-Z0-9]{3,})\b/g, (match) => { if (match.length < 5 || /^[A-Z]{5,}$/.test(match)) return match; return `[${match}](https://www.ncbi.nlm.nih.gov/protein/${match})`; } ); } function renderInlineMarkdown(text: string): React.ReactNode { const parts: React.ReactNode[] = []; const regex = /\[([^\]]+)\]\(([^)]+)\)/g; let lastIndex = 0; let match: RegExpExecArray | null; while ((match = regex.exec(text)) !== null) { if (match.index > lastIndex) parts.push(text.slice(lastIndex, match.index)); parts.push( {match[1]} ); lastIndex = regex.lastIndex; } if (lastIndex < text.length) parts.push(text.slice(lastIndex)); return parts.length === 1 ? parts[0] : <>{parts}; } function renderMarkdown(text: string) { const linked = addCitationLinks(text); const lines = linked.split('\n'); const elements: React.ReactNode[] = []; let listItems: React.ReactNode[] = []; const flushList = () => { if (listItems.length > 0) { elements.push(); listItems = []; } }; for (let i = 0; i < lines.length; i++) { const line = lines[i]; if (line.startsWith('## ')) { flushList(); elements.push(

{line.slice(3)}

); } else if (line.startsWith('- ')) { listItems.push(
  • {renderInlineMarkdown(line.slice(2))}
  • ); } else if (line.match(/^\d+\.\s/)) { flushList(); elements.push(

    {renderInlineMarkdown(line)}

    ); } else if (line.trim() === '') { flushList(); } else { flushList(); elements.push(

    {renderInlineMarkdown(line)}

    ); } } flushList(); return elements; } interface AIInterpretationProps { context: AssembledContext; pipelineType: string; } export function AIInterpretation({ context, pipelineType }: AIInterpretationProps) { const router = useRouter(); const [text, setText] = useState(''); const [model, setModel] = useState(''); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [notice, setNotice] = useState(null); const [retry, setRetry] = useState(null); const abortRef = useRef(null); // Countdown while the provider is backing off — ticks only during a live stream. useEffect(() => { if (retry === null || retry <= 1) return; const t = setTimeout(() => setRetry(r => (r === null || r <= 1 ? r : r - 1)), 1000); return () => clearTimeout(t); }, [retry]); const handleInterpret = useCallback(async () => { if (loading) return; if (abortRef.current) abortRef.current.abort(); setLoading(true); setText(''); setModel(''); setError(null); setNotice(null); setRetry(null); try { const response = await interpretStream({ pipeline_type: pipelineType, context }); if (!response.ok) throw new Error('Failed to start interpretation'); const reader = response.body?.getReader(); const decoder = new TextDecoder(); let accumulated = ''; if (reader) { while (true) { const { done, value } = await reader.read(); if (done) break; const chunk = decoder.decode(value, { stream: true }); for (const line of chunk.split('\n')) { if (line.startsWith('data: ')) { try { const payload: StreamEvent = JSON.parse(line.slice(6)); if (payload.chunk) { accumulated += payload.chunk; setText(accumulated); } if (payload.done) { setModel(payload.meta?.model || ''); } if (payload.notice) { const delay = rateLimitDelay(payload.notice); if (delay !== null || isRetryNotice(payload.notice)) { setRetry(delay ?? 30); } else { setNotice(payload.notice); } } if (payload.error) { const delay = rateLimitDelay(payload.error); if (delay !== null) setRetry(delay); const msg = payload.error.includes('organization_restricted') ? 'AI interpretation is temporarily unavailable due to a provider restriction. Please try again later.' : payload.error; setError(msg); } } catch {} } } } } } catch (err: unknown) { if (!(err instanceof Error) || err.name !== 'AbortError') { setError(extractErrorMessage(err, 'Interpretation failed')); } } finally { setLoading(false); } }, [context, pipelineType, loading]); return (

    AI Interpretation

    {model && model !== 'fallback-static' && ( Llama 3.3 70B )}
    {!text && !loading && ( {error ? 'Retry' : 'Interpret results'} )} {loading && ( )}
    {error && !loading && (

    {error}

    )} {(loading && (retry !== null || notice)) && (
    {retry !== null ? RATE_LIMIT_MESSAGE(retry) : notice}
    )} {text ? ( {renderMarkdown(text)} {model &&
    Model: {model}
    }
    Bridges
    ) : loading ? (
    Interpreting results...
    ) : (

    Click to get an AI explanation combining BLAST, UniProt, and AlphaFold data

    )}
    ); }