'use client'; import React from 'react'; import { ExecutiveSummary } from '@/components/chat/ExecutiveSummary'; import { SourcesFootnotes } from '@/components/chat/SourcesFootnotes'; import { KeyDriversAccordion } from '@/components/chat/KeyDriversAccordion'; import { SourceVisualEvidenceAccordion } from '@/components/chat/SourceVisualEvidenceAccordion'; import { QuestionInput } from '@/components/chat/QuestionInput'; import { UnsupportedRequest, InsufficientEvidence } from '@/components/chat/StateCards'; import { submitQuestion, checkBackendHealth, type ChatResponse, normalizePageImageUrl, } from '@/lib/api'; // Client-side domain guard (mirrors backend guardrail for instant UX feedback) const OUT_OF_SCOPE_PATTERNS = [ /\bweather\b/i, /\bsport(s)?\b/i, /\bfootball\b/i, /\bcricket\b/i, /\btemperature\b/i, /\bforecast\b/i, /\bclimate outside\b/i, /\btravel\b/i, /\bhotel\b/i, /\bflight\b/i, /\bmovie\b/i, /\bmusic\b/i, /\bpython\b/i, /\bjavascript\b/i, /\bcode\b/i, /\bprogram\b/i, /\bmedical\b/i, /\bdoctor\b/i, /\bhealth\b/i, /\blegal\b/i, /\blawyer\b/i, /\bpoliti/i, /\belection\b/i, /\bstock tip\b/i, /\bbuy.*shares?\b/i, /\binvest.*advice\b/i, /\bsalary\b/i, /\bhr\b/i, /system prompt/i, /prompt injection/i, /ignore.*instruct/i, /reveal.*prompt/i, /bypass/i, /api.?key/i, /vector.*database/i, /embedding.*model/i, /\brag\b/i, ]; type MessageType = 'question' | 'ir-response' | 'unsupported' | 'insufficient' | 'thinking'; interface Message { id: string; type: MessageType; content: string; response?: ChatResponse; } interface ResponseWorkspaceProps { onOpenPage: (docName: string, page: number) => void; onQuestionAsked?: (question: string) => void; triggeredQuestion?: string; onClearTriggeredQuestion?: () => void; selectedDocIds: string[]; } /* ── Thinking Indicator ── */ function ThinkingIndicator() { return (
{/* eslint-disable-next-line @next/next/no-img-element */} Emirates NBD Logo
Retrieving and analysing Investor Relations evidence…
); } /* ── Welcome Screen ── */ function WelcomeScreen({ backendLive }: { backendLive: boolean }) { return (
{/* eslint-disable-next-line @next/next/no-img-element */} Emirates NBD Logo

IRIS

Investor Relations Intelligence

Ask questions about Emirates NBD's investor presentations. Responses are grounded strictly in validated source evidence.

{!backendLive && (
Evidence service starting — IRIS will continue serving validated cached Investor Relations answers while the live retrieval service warms up.
)}
); } /* ── Main Response Card ── */ function IRResponseCard({ response, onOpenPage, }: { response: ChatResponse; onOpenPage: (docName: string, page: number) => void; }) { const execSummary = response.executive_summary ?? ''; const sources = response.sources.map(s => ({ id: s.id, docName: s.doc_name ?? '', page: s.page, support: s.support, })); const kpis = response.financial_kpis.map(k => ({ metric: k.metric, current: k.current, previous: k.previous, change: k.change, interpretation: k.interpretation, direction: (k.direction ?? 'neutral') as 'positive' | 'negative' | 'neutral', period: k.period, value: k.value, })); const driversSum = response.key_drivers_summary ?? ''; const drivers = response.key_drivers.map(d => ({ title: d.title, detail: d.detail })); const visuals = response.visual_evidence.map(v => { const firstSrc = sources[0]; const rawDocId = firstSrc ? firstSrc.docName.toLowerCase().replace(/ /g, '_').replace(/-/g, '_') : 'emiratesnbd_investor_presentation_2026_q1'; const finalDocId = (rawDocId.includes('emirates') && rawDocId.includes('nbd') && rawDocId.includes('2026') && rawDocId.includes('q1')) ? 'emiratesnbd_investor_presentation_2026_q1' : rawDocId; const imageUrl = normalizePageImageUrl(v.image_url, finalDocId, v.page); return { id: v.id ?? `v-${v.page}`, alt: v.alt ?? `Page ${v.page}`, caption: v.alt ?? `Page ${v.page}`, page: v.page, image_url: imageUrl, }; }); return (
{/* IRIS header */}
{/* eslint-disable-next-line @next/next/no-img-element */} Emirates NBD Logo IRIS · IR Intelligence
{/* 1. Executive Summary — always expanded */} {/* 2-3. Accordions — collapsed by default */}
onOpenPage(sources[0]?.docName ?? '', page)} />
{/* 4. Sources & Footnotes — now at the end */} onOpenPage(docName, page)} />
); } /* ── Main Workspace ── */ export function ResponseWorkspace({ onOpenPage, onQuestionAsked, triggeredQuestion, onClearTriggeredQuestion, selectedDocIds, }: ResponseWorkspaceProps) { const [messages, setMessages] = React.useState([]); const [isLoading, setIsLoading] = React.useState(false); const [backendLive, setBackendLive] = React.useState(false); const messagesEndRef = React.useRef(null); const showWelcome = messages.length === 0; // Check backend health on mount React.useEffect(() => { checkBackendHealth().then(setBackendLive); const interval = setInterval(() => checkBackendHealth().then(setBackendLive), 15000); return () => clearInterval(interval); }, []); React.useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages]); const handleQuestion = React.useCallback(async (question: string) => { const qId = Date.now().toString(); setMessages(prev => [...prev, { id: qId, type: 'question', content: question }]); setIsLoading(true); onQuestionAsked?.(question); const isOutOfScope = OUT_OF_SCOPE_PATTERNS.some(p => p.test(question)); if (isOutOfScope) { await new Promise(r => setTimeout(r, 400)); setMessages(prev => [...prev, { id: Date.now().toString(), type: 'unsupported', content: question }]); setIsLoading(false); return; } try { // Attempt the backend on every submit. The health check is only a UI hint, // and can be stale during startup or after a backend restart. const response = await submitQuestion(question, selectedDocIds); setBackendLive(true); if (response.response_type === 'unsupported') { setMessages(prev => [...prev, { id: Date.now().toString(), type: 'unsupported', content: question }]); } else if (response.response_type === 'insufficient') { setMessages(prev => [...prev, { id: Date.now().toString(), type: 'insufficient', content: question }]); } else { setMessages(prev => [...prev, { id: Date.now().toString(), type: 'ir-response', content: question, response }]); } } catch (err) { console.error('Chat error:', err); setBackendLive(false); setMessages(prev => [...prev, { id: Date.now().toString(), type: 'insufficient', content: question }]); } finally { setIsLoading(false); } }, [onQuestionAsked, selectedDocIds]); React.useEffect(() => { if (triggeredQuestion) { queueMicrotask(() => { handleQuestion(triggeredQuestion); }); onClearTriggeredQuestion?.(); } }, [triggeredQuestion, onClearTriggeredQuestion, handleQuestion]); return (
{showWelcome ? (
) : (
{messages.map(msg => { if (msg.type === 'question') { return
{msg.content}
; } if (msg.type === 'unsupported') { return (
); } if (msg.type === 'insufficient') { return (
); } if (msg.type === 'ir-response' && msg.response) { return ( ); } return null; })} {isLoading && }
)}
); }