Change user-facing strings from American to British English: analyze→analyse, analyzing→analysing, optimized→optimised, color→colour, analyzes→analyses, synthesizes→synthesises, optimization→optimisation. Code identifiers, status enums, and developer-facing messages are intentionally unchanged. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
338 lines
16 KiB
TypeScript
Executable file
338 lines
16 KiB
TypeScript
Executable file
|
|
import React, { useState, useRef, useEffect } from 'react';
|
|
import { IPublicClientApplication } from '@azure/msal-browser';
|
|
import type { DropdownOptions } from '../App';
|
|
import { analyzeWIPProof, getWIPChatResponse } from '../services/geminiService';
|
|
import type { AgentName } from '../types';
|
|
import { UserIcon } from './icons/UserIcon';
|
|
import { LeadAgentIcon } from './icons/LeadAgentIcon';
|
|
import { PaperClipIcon } from './icons/PaperClipIcon';
|
|
import { SendIcon } from './icons/SendIcon';
|
|
import { ChevronDownIcon } from './icons/ChevronDownIcon';
|
|
import { SpinnerIcon } from './icons/SpinnerIcon';
|
|
import { DocumentIcon } from './icons/DocumentIcon';
|
|
|
|
|
|
// --- TYPE DEFINITIONS ---
|
|
interface TextContent { type: 'text'; text: string; }
|
|
interface FilePreviewContent { type: 'file_preview'; fileName: string; previewUrl: string; mimeType: string; }
|
|
interface ConfirmationContent { type: 'confirmation'; file: File; }
|
|
interface LoadingContent { type: 'loading'; }
|
|
interface ErrorContent { type: 'error'; text: string; }
|
|
|
|
type MessageContent = TextContent | FilePreviewContent | ConfirmationContent | LoadingContent | ErrorContent;
|
|
|
|
interface Message {
|
|
id: string;
|
|
sender: 'user' | 'agent';
|
|
content: MessageContent;
|
|
}
|
|
|
|
const fileToDataUrl = (file: File): Promise<string> => {
|
|
return new Promise((resolve, reject) => {
|
|
const reader = new FileReader();
|
|
reader.onloadend = () => resolve(reader.result as string);
|
|
reader.onerror = reject;
|
|
reader.readAsDataURL(file);
|
|
});
|
|
};
|
|
|
|
// --- SUB-COMPONENTS ---
|
|
|
|
const ConfirmationComponent: React.FC<{
|
|
dropdownOptions: DropdownOptions;
|
|
onConfirm: (details: { channel: string; subChannel: string; proofType: string }) => void;
|
|
}> = ({ dropdownOptions, onConfirm }) => {
|
|
const [channel, setChannel] = useState('');
|
|
const [subChannel, setSubChannel] = useState('');
|
|
const [proofType, setProofType] = useState('');
|
|
|
|
const availableChannels = Object.keys(dropdownOptions.channels);
|
|
const availableSubChannels = channel ? Object.keys(dropdownOptions.channels[channel] || {}) : [];
|
|
const availableProofTypes = (channel && subChannel) ? (dropdownOptions.channels[channel][subChannel] || []) : [];
|
|
const showProofType = availableProofTypes.length > 0;
|
|
|
|
useEffect(() => {
|
|
setSubChannel('');
|
|
setProofType('');
|
|
}, [channel]);
|
|
|
|
useEffect(() => {
|
|
if (!showProofType) setProofType('');
|
|
else setProofType('');
|
|
}, [subChannel, showProofType]);
|
|
|
|
const handleSubmit = () => {
|
|
if (channel && subChannel && (!showProofType || proofType)) {
|
|
onConfirm({ channel, subChannel, proofType });
|
|
}
|
|
};
|
|
|
|
const isSubmitDisabled = !channel || !subChannel || (showProofType && !proofType);
|
|
|
|
return (
|
|
<div className="p-4 bg-white rounded-lg border border-gray-200 shadow-sm space-y-3">
|
|
<p className="font-semibold text-gray-800">Please confirm the details for this proof:</p>
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
|
<div className="relative">
|
|
<select
|
|
value={channel}
|
|
onChange={e => setChannel(e.target.value)}
|
|
className="w-full bg-white border border-gray-300 rounded-md py-2 px-3 text-gray-900 focus:outline-none focus:ring-2 focus:ring-active-blue appearance-none"
|
|
>
|
|
<option value="" disabled>Select Channel</option>
|
|
{availableChannels.map(opt => <option key={opt} value={opt}>{opt}</option>)}
|
|
</select>
|
|
<ChevronDownIcon className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-500 pointer-events-none"/>
|
|
</div>
|
|
|
|
<div className="relative">
|
|
<select
|
|
value={subChannel}
|
|
onChange={e => setSubChannel(e.target.value)}
|
|
disabled={!channel}
|
|
className="w-full bg-white border border-gray-300 rounded-md py-2 px-3 text-gray-900 focus:outline-none focus:ring-2 focus:ring-active-blue appearance-none disabled:bg-gray-100"
|
|
>
|
|
<option value="" disabled>Select Sub-Channel</option>
|
|
{availableSubChannels.map(opt => <option key={opt} value={opt}>{opt}</option>)}
|
|
</select>
|
|
<ChevronDownIcon className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-500 pointer-events-none"/>
|
|
</div>
|
|
</div>
|
|
{showProofType && (
|
|
<div className="relative">
|
|
<select
|
|
value={proofType}
|
|
onChange={e => setProofType(e.target.value)}
|
|
className="w-full bg-white border border-gray-300 rounded-md py-2 px-3 text-gray-900 focus:outline-none focus:ring-2 focus:ring-active-blue appearance-none"
|
|
>
|
|
<option value="" disabled>Select Proof Type</option>
|
|
{availableProofTypes.map(opt => <option key={opt} value={opt}>{opt}</option>)}
|
|
</select>
|
|
<ChevronDownIcon className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-500 pointer-events-none"/>
|
|
</div>
|
|
)}
|
|
<button
|
|
onClick={handleSubmit}
|
|
disabled={isSubmitDisabled}
|
|
className="w-full bg-active-blue text-white font-bold py-2 px-4 rounded-md hover:bg-primary-blue transition-colors disabled:bg-gray-400 disabled:cursor-not-allowed"
|
|
>
|
|
Confirm & Analyse
|
|
</button>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
|
|
const MessageBubble: React.FC<{ sender: 'user' | 'agent'; children: React.ReactNode }> = ({ sender, children }) => {
|
|
const isUser = sender === 'user';
|
|
const bubbleClasses = isUser
|
|
? 'bg-active-blue text-white'
|
|
: 'bg-white text-gray-800 border border-gray-200';
|
|
const alignmentClasses = isUser ? 'items-end' : 'items-start';
|
|
const avatar = isUser
|
|
? <div className="w-8 h-8 rounded-full bg-primary-blue flex items-center justify-center ring-2 ring-white"><UserIcon className="h-5 w-5 text-white"/></div>
|
|
: <div className="w-8 h-8 rounded-full bg-cyan-brand flex items-center justify-center ring-2 ring-white"><LeadAgentIcon className="h-5 w-5 text-primary-blue"/></div>;
|
|
|
|
const isConfirmation = React.isValidElement(children) && children.type === ConfirmationComponent;
|
|
|
|
return (
|
|
<div className={`flex flex-col ${alignmentClasses} w-full max-w-lg mx-auto`}>
|
|
<div className={`flex gap-3 items-end ${isUser ? 'flex-row-reverse' : 'flex-row'}`}>
|
|
<div className="flex-shrink-0">{avatar}</div>
|
|
<div className={
|
|
isConfirmation
|
|
? "w-full max-w-md"
|
|
: `p-3 rounded-lg max-w-xs sm:max-w-md ${bubbleClasses}`
|
|
}>
|
|
{children}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// --- MAIN COMPONENT ---
|
|
|
|
interface WIPReviewerProps {
|
|
dropdownOptions: DropdownOptions;
|
|
msalInstance: IPublicClientApplication;
|
|
}
|
|
|
|
export const WIPReviewer: React.FC<WIPReviewerProps> = ({ dropdownOptions, msalInstance }) => {
|
|
const [messages, setMessages] = useState<Message[]>([
|
|
{
|
|
id: `msg_${Date.now()}`,
|
|
sender: 'agent',
|
|
content: { type: 'text', text: "Hello! I'm the Lead Agent. Upload a work-in-progress file for review, or ask me a question about marketing best practices." },
|
|
}
|
|
]);
|
|
const [userInput, setUserInput] = useState('');
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
|
}, [messages]);
|
|
|
|
const addMessage = (sender: 'user' | 'agent', content: MessageContent) => {
|
|
setMessages(prev => [...prev, { id: `msg_${Date.now()}`, sender, content }]);
|
|
};
|
|
|
|
const updateLastMessage = (newContent: MessageContent) => {
|
|
setMessages(prev => {
|
|
const newMessages = [...prev];
|
|
newMessages[newMessages.length - 1].content = newContent;
|
|
return newMessages;
|
|
});
|
|
};
|
|
|
|
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = event.target.files?.[0];
|
|
if (!file) return;
|
|
|
|
try {
|
|
const previewUrl = await fileToDataUrl(file);
|
|
addMessage('user', { type: 'file_preview', fileName: file.name, previewUrl, mimeType: file.type });
|
|
addMessage('agent', { type: 'confirmation', file });
|
|
} catch (error) {
|
|
console.error("Error creating file preview:", error);
|
|
addMessage('agent', { type: 'error', text: "Sorry, I couldn't load a preview for that file. Please try another file." });
|
|
}
|
|
|
|
// Reset file input
|
|
if (event.target) event.target.value = '';
|
|
};
|
|
|
|
const handleSendMessage = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
const trimmedInput = userInput.trim();
|
|
if (!trimmedInput || isLoading) return;
|
|
|
|
addMessage('user', { type: 'text', text: trimmedInput });
|
|
setUserInput('');
|
|
setIsLoading(true);
|
|
addMessage('agent', { type: 'loading' });
|
|
|
|
try {
|
|
const responseText = await getWIPChatResponse(trimmedInput);
|
|
updateLastMessage({ type: 'text', text: responseText });
|
|
} catch (err) {
|
|
console.error("Chat response failed:", err);
|
|
updateLastMessage({ type: 'error', text: "Sorry, I couldn't get a response. Please try again." });
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleConfirmAnalysis = async (
|
|
messageId: string,
|
|
details: { channel: string; subChannel: string; proofType: string },
|
|
file: File
|
|
) => {
|
|
setIsLoading(true);
|
|
// Replace confirmation message with loading indicator
|
|
setMessages(prev => prev.map(msg => msg.id === messageId ? { ...msg, content: { type: 'loading' } } : msg));
|
|
|
|
try {
|
|
const summary = await analyzeWIPProof(file, () => {}, msalInstance);
|
|
setMessages(prev => prev.map(msg => msg.id === messageId ? { ...msg, content: { type: 'text', text: summary } } : msg));
|
|
} catch (err) {
|
|
console.error("Analysis failed:", err);
|
|
setMessages(prev => prev.map(msg => msg.id === messageId ? { ...msg, content: { type: 'error', text: "Sorry, an error occurred during the analysis. Please try again." } } : msg));
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="flex flex-col h-full bg-grey-100">
|
|
<header className="p-4 border-b border-gray-200 bg-white/80 backdrop-blur-sm flex-shrink-0">
|
|
<h1 className="text-xl font-bold text-primary-blue text-center">WIP Reviewer</h1>
|
|
</header>
|
|
|
|
{/* Message Display Area */}
|
|
<div className="flex-1 overflow-y-auto p-4 space-y-6">
|
|
{messages.map((msg) => (
|
|
<MessageBubble key={msg.id} sender={msg.sender}>
|
|
{(() => {
|
|
switch (msg.content.type) {
|
|
case 'text':
|
|
return <p className="whitespace-pre-wrap">{msg.content.text}</p>;
|
|
case 'file_preview':
|
|
const { mimeType, previewUrl, fileName } = msg.content;
|
|
if (mimeType.startsWith('image/')) {
|
|
return <img src={previewUrl} alt={fileName} className="max-w-full h-auto rounded-md object-contain" />;
|
|
}
|
|
return (
|
|
<div className="flex items-center gap-3 p-2 bg-black/20 rounded-md">
|
|
<DocumentIcon className="h-8 w-8 text-white"/>
|
|
<span className="font-semibold truncate">{fileName}</span>
|
|
</div>
|
|
);
|
|
case 'confirmation':
|
|
return <ConfirmationComponent
|
|
dropdownOptions={dropdownOptions}
|
|
onConfirm={(details) => handleConfirmAnalysis(msg.id, details, msg.content.file)}
|
|
/>;
|
|
case 'loading':
|
|
return (
|
|
<div className="flex items-center gap-2 text-gray-600">
|
|
<SpinnerIcon className="h-5 w-5 custom-spinner"/>
|
|
<span>Thinking...</span>
|
|
</div>
|
|
);
|
|
case 'error':
|
|
return <p className="text-red-600 font-medium">{msg.content.text}</p>
|
|
default:
|
|
return null;
|
|
}
|
|
})()}
|
|
</MessageBubble>
|
|
))}
|
|
<div ref={messagesEndRef} />
|
|
</div>
|
|
|
|
{/* Message Input Area */}
|
|
<div className="flex-shrink-0 p-4 bg-white/80 backdrop-blur-sm border-t border-gray-200">
|
|
<div className="max-w-lg mx-auto">
|
|
<form onSubmit={handleSendMessage} className="relative flex items-center">
|
|
<input type="file" ref={fileInputRef} onChange={handleFileSelect} className="hidden" />
|
|
<button
|
|
type="button"
|
|
onClick={() => fileInputRef.current?.click()}
|
|
disabled={isLoading}
|
|
className="p-2 text-gray-500 hover:text-active-blue rounded-full transition-colors disabled:opacity-50"
|
|
aria-label="Attach file"
|
|
>
|
|
<PaperClipIcon className="h-6 w-6"/>
|
|
</button>
|
|
<textarea
|
|
value={userInput}
|
|
onChange={(e) => setUserInput(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault();
|
|
handleSendMessage(e);
|
|
}
|
|
}}
|
|
className="w-full p-2.5 mx-2 border border-gray-300 rounded-lg resize-none focus:ring-2 focus:ring-active-blue focus:border-active-blue transition"
|
|
placeholder="Type your message..."
|
|
rows={1}
|
|
disabled={isLoading}
|
|
/>
|
|
<button
|
|
type="submit"
|
|
className="p-2.5 rounded-full text-white bg-active-blue hover:bg-primary-blue disabled:bg-gray-400 transition-colors"
|
|
disabled={!userInput.trim() || isLoading}
|
|
aria-label="Send message"
|
|
>
|
|
<SendIcon className="h-5 w-5" />
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|