iris-ir-platform / src /components /layout /ResponseWorkspace.tsx
rajvivan's picture
sync: push iris-ir-platform to HuggingFace Space
94eb7c7 verified
Raw
History Blame Contribute Delete
12.1 kB
'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&apos;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>
);
}