Spaces:
Sleeping
Sleeping
| '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 ( | |
| <div style={{ | |
| display: 'flex', alignItems: 'center', gap: '0.875rem', | |
| padding: '1rem 1.25rem', | |
| background: 'var(--bg-card)', | |
| border: '1px solid var(--border-subtle)', | |
| borderRadius: 'var(--radius-lg)', | |
| animation: 'fadeInUp 0.25s ease both', | |
| }}> | |
| {/* eslint-disable-next-line @next/next/no-img-element */} | |
| <img | |
| src="/Emirates NBD Bank Logo.png" | |
| alt="Emirates NBD Logo" | |
| style={{ | |
| height: '24px', | |
| width: 'auto', | |
| objectFit: 'contain', | |
| flexShrink: 0, | |
| }} | |
| /> | |
| <div> | |
| <div style={{ fontSize: '0.72rem', color: 'var(--text-muted)', marginBottom: '0.3rem' }}> | |
| Retrieving and analysing Investor Relations evidence… | |
| </div> | |
| <div className="thinking-dots"><span /><span /><span /></div> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| /* ── Welcome Screen ── */ | |
| function WelcomeScreen({ backendLive }: { backendLive: boolean }) { | |
| return ( | |
| <div className="welcome-screen"> | |
| {/* eslint-disable-next-line @next/next/no-img-element */} | |
| <img | |
| src="/Emirates NBD Bank Logo.png" | |
| alt="Emirates NBD Logo" | |
| style={{ | |
| height: '64px', | |
| width: 'auto', | |
| objectFit: 'contain', | |
| }} | |
| /> | |
| <div> | |
| <h1 className="welcome-title">IRIS</h1> | |
| <p style={{ fontSize: '0.75rem', color: 'var(--enbd-blue)', fontWeight: 500, letterSpacing: '0.1em', textTransform: 'uppercase', textAlign: 'center', marginTop: '0.2rem' }}> | |
| Investor Relations Intelligence | |
| </p> | |
| </div> | |
| <p className="welcome-subtitle" style={{ fontSize: '0.85rem', maxWidth: '380px' }}> | |
| Ask questions about Emirates NBD's investor presentations. | |
| Responses are grounded strictly in validated source evidence. | |
| </p> | |
| {!backendLive && ( | |
| <div style={{ | |
| maxWidth: 480, padding: '0.75rem 1rem', | |
| background: 'var(--enbd-blue-muted)', | |
| border: '1px solid var(--enbd-blue-border)', | |
| borderRadius: 'var(--radius-md)', | |
| fontSize: '0.75rem', color: 'var(--text-muted)', lineHeight: 1.6, | |
| textAlign: 'left', | |
| marginTop: '1rem', | |
| }}> | |
| <span style={{ color: 'var(--enbd-blue)', fontWeight: 700 }}>Evidence service starting</span> — | |
| IRIS will continue serving validated cached Investor Relations answers while the live retrieval service warms up. | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |
| /* ── 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 ( | |
| <div className="response-card" style={{ maxWidth: '860px', width: '100%' }}> | |
| {/* IRIS header */} | |
| <div style={{ display: 'flex', alignItems: 'center', gap: '0.625rem', padding: '0.7rem 1.25rem 0' }}> | |
| {/* eslint-disable-next-line @next/next/no-img-element */} | |
| <img | |
| src="/Emirates NBD Bank Logo.png" | |
| alt="Emirates NBD Logo" | |
| style={{ | |
| height: '18px', | |
| width: 'auto', | |
| objectFit: 'contain', | |
| }} | |
| /> | |
| <span style={{ fontSize: '0.72rem', color: 'var(--text-muted)', fontWeight: 500 }}> | |
| IRIS · IR Intelligence | |
| </span> | |
| </div> | |
| {/* 1. Executive Summary — always expanded */} | |
| <ExecutiveSummary text={execSummary} /> | |
| {/* 2-3. Accordions — collapsed by default */} | |
| <div style={{ display: 'flex', flexDirection: 'column', gap: '2px', padding: '0 0.5rem 0.75rem' }}> | |
| <KeyDriversAccordion summary={driversSum} drivers={drivers} kpis={kpis} /> | |
| <SourceVisualEvidenceAccordion | |
| visuals={visuals} | |
| onOpenPage={page => onOpenPage(sources[0]?.docName ?? '', page)} | |
| /> | |
| </div> | |
| {/* 4. Sources & Footnotes — now at the end */} | |
| <SourcesFootnotes | |
| sources={sources} | |
| onOpenPage={(docName, page) => onOpenPage(docName, page)} | |
| /> | |
| </div> | |
| ); | |
| } | |
| /* ── Main Workspace ── */ | |
| export function ResponseWorkspace({ | |
| onOpenPage, | |
| onQuestionAsked, | |
| triggeredQuestion, | |
| onClearTriggeredQuestion, | |
| selectedDocIds, | |
| }: ResponseWorkspaceProps) { | |
| const [messages, setMessages] = React.useState<Message[]>([]); | |
| const [isLoading, setIsLoading] = React.useState(false); | |
| const [backendLive, setBackendLive] = React.useState(false); | |
| const messagesEndRef = React.useRef<HTMLDivElement>(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 ( | |
| <div className="app-main"> | |
| {showWelcome ? ( | |
| <div style={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}> | |
| <WelcomeScreen backendLive={backendLive} /> | |
| </div> | |
| ) : ( | |
| <div className="messages-scroll"> | |
| <div className="messages-inner" style={{ | |
| maxWidth: '900px', | |
| width: '100%', | |
| display: 'flex', | |
| flexDirection: 'column', | |
| gap: '1.25rem', | |
| paddingBottom: '1.5rem', | |
| }}> | |
| {messages.map(msg => { | |
| if (msg.type === 'question') { | |
| return <div key={msg.id} className="question-bubble fade-in-up">{msg.content}</div>; | |
| } | |
| if (msg.type === 'unsupported') { | |
| return ( | |
| <div key={msg.id} className="fade-in-up" style={{ maxWidth: '860px', width: '100%' }}> | |
| <UnsupportedRequest question={msg.content} onSubmit={handleQuestion} /> | |
| </div> | |
| ); | |
| } | |
| if (msg.type === 'insufficient') { | |
| return ( | |
| <div key={msg.id} className="fade-in-up" style={{ maxWidth: '860px', width: '100%' }}> | |
| <InsufficientEvidence question={msg.content} onSubmit={handleQuestion} /> | |
| </div> | |
| ); | |
| } | |
| if (msg.type === 'ir-response' && msg.response) { | |
| return ( | |
| <IRResponseCard | |
| key={msg.id} | |
| response={msg.response} | |
| onOpenPage={onOpenPage} | |
| /> | |
| ); | |
| } | |
| return null; | |
| })} | |
| {isLoading && <ThinkingIndicator />} | |
| <div ref={messagesEndRef} /> | |
| </div> | |
| </div> | |
| )} | |
| <QuestionInput | |
| onSubmit={handleQuestion} | |
| isLoading={isLoading} | |
| showWelcome={showWelcome} | |
| onSampleQuestion={handleQuestion} | |
| /> | |
| </div> | |
| ); | |
| } | |