'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 && (
)}
{(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
)}
);
}