'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 */}
Retrieving and analysing Investor Relations evidence…
);
}
/* ── Welcome Screen ── */
function WelcomeScreen({ backendLive }: { backendLive: boolean }) {
return (
{/* eslint-disable-next-line @next/next/no-img-element */}
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 */}
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 &&
}
)}
);
}