Codex Deploy
Prepare local Hugging Face deployment
191b322
import React, { useState, useRef, useEffect } from 'react';
import { ChatMessage, User } from '../types';
import { Send, Bot, User as UserIcon, X, Maximize2, Minimize2, Loader2, Sparkles } from 'lucide-react';
import ReactMarkdown from 'react-markdown';
import { motion, AnimatePresence } from 'framer-motion';
interface LocalAssistantProps {
currentUser: User;
projectContext?: any;
}
const LocalAssistant: React.FC<LocalAssistantProps> = ({ currentUser, projectContext }) => {
const [isOpen, setIsOpen] = useState(false);
const [isMinimized, setIsMinimized] = useState(false);
const [input, setInput] = useState('');
const [messages, setMessages] = useState<ChatMessage[]>([
{
role: 'model',
parts: [{ text: "Hello! I'm BuildTrack Local Assistant. I can summarize project data without using any external AI service." }]
}
]);
const [isLoading, setIsLoading] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [messages]);
const buildLocalResponse = (question: string) => {
const project = projectContext;
const normalized = question.toLowerCase();
if (!project) {
return "No project is selected yet. Open a project and I can summarize progress, risks, BOQ status, bills, documents, and pending suggestions from the local project data.";
}
const boq = project.boq || [];
const bills = project.bills || [];
const liabilities = project.liabilities || [];
const dprs = project.dprs || [];
const documents = project.documents || [];
const suggestions = project.aiSuggestions || [];
const planned = boq.reduce((sum: number, item: any) => sum + (item.plannedQty || 0) * (item.rate || 0), 0);
const executed = boq.reduce((sum: number, item: any) => sum + (item.executedQty || 0) * (item.rate || 0), 0);
const progress = planned > 0 ? ((executed / planned) * 100).toFixed(1) : "0.0";
const pendingHigh = boq.filter((item: any) => item.priority === 'HIGH' && (item.executedQty || 0) < (item.plannedQty || 0));
const money = (value: number) => new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 }).format(value || 0);
if (normalized.includes('risk')) {
const risk = project.riskAssessment;
if (risk?.risks?.length) {
return `Current local risk score is **${risk.overallRiskScore}/100**.\n\n${risk.risks.slice(0, 3).map((r: any) => `- **${r.category} (${r.impact})**: ${r.description} Mitigation: ${r.mitigation}`).join('\n')}`;
}
return `No saved risk assessment is available yet. Based on local project data, I would first review ${pendingHigh.length} high-priority pending BOQ item(s), ${liabilities.length} liability record(s), and upcoming milestones.`;
}
if (normalized.includes('bill') || normalized.includes('finance') || normalized.includes('money')) {
const clientBilled = bills.filter((b: any) => b.type === 'CLIENT_RA').reduce((sum: number, b: any) => sum + (b.amount || 0), 0);
const expenses = bills.filter((b: any) => b.type !== 'CLIENT_RA').reduce((sum: number, b: any) => sum + (b.amount || 0), 0);
const liabilityTotal = liabilities.reduce((sum: number, l: any) => sum + (l.amount || 0), 0);
return `Financial snapshot:\n\n- Client billed: **${money(clientBilled)}**\n- Recorded expenses: **${money(expenses)}**\n- Open liabilities: **${money(liabilityTotal)}**\n- Executed value: **${money(executed)}**`;
}
if (normalized.includes('document') || normalized.includes('file')) {
return `This project has **${documents.length}** document(s). Pending local suggestions: **${suggestions.filter((s: any) => s.status === 'PENDING').length}**. Use the document manager's scan action to create local suggestions from file names and available text.`;
}
if (normalized.includes('boq') || normalized.includes('progress') || normalized.includes('status')) {
const pendingText = pendingHigh.length
? pendingHigh.slice(0, 3).map((item: any) => `- ${item.description}: ${(item.plannedQty || 0) - (item.executedQty || 0)} ${item.unit} pending`).join('\n')
: "- No high-priority BOQ items are pending.";
return `Project status for **${project.name}**:\n\n- Progress: **${progress}%** by value\n- Planned value: **${money(planned)}**\n- Executed value: **${money(executed)}**\n- DPR entries: **${dprs.length}**\n\n${pendingText}`;
}
return `I am running fully locally for ${currentUser.name} (${currentUser.role}). For **${project.name}**, I can summarize progress, BOQ, risks, bills, DPRs, documents, and pending suggestions using data already stored in this app.`;
};
const handleSend = async () => {
if (!input.trim() || isLoading) return;
const question = input.trim();
const userMessage: ChatMessage = {
role: 'user',
parts: [{ text: question }]
};
setMessages(prev => [...prev, userMessage]);
setInput('');
setIsLoading(true);
try {
await new Promise(resolve => setTimeout(resolve, 250));
const modelResponse: ChatMessage = {
role: 'model',
parts: [{ text: buildLocalResponse(question) }]
};
setMessages(prev => [...prev, modelResponse]);
} catch (error) {
console.error("Local assistant error:", error);
setMessages(prev => [...prev, {
role: 'model',
parts: [{ text: "I could not process that local request. Please try a shorter question about progress, risk, bills, documents, or BOQ." }]
}]);
} finally {
setIsLoading(false);
}
};
return (
<div className="fixed bottom-6 right-6 z-50 flex flex-col items-end">
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0, scale: 0.9, y: 20 }}
animate={{
opacity: 1,
scale: 1,
y: 0,
height: isMinimized ? '64px' : '600px',
width: isMinimized ? '300px' : '400px'
}}
exit={{ opacity: 0, scale: 0.9, y: 20 }}
className="bg-white rounded-2xl shadow-2xl border border-slate-200 overflow-hidden mb-4 flex flex-col max-h-[80vh] w-[90vw] md:w-[400px]"
>
{/* Header */}
<div className="p-4 bg-blue-600 text-white flex items-center justify-between shrink-0">
<div className="flex items-center gap-2">
<div className="p-1.5 bg-white/20 rounded-lg">
<Sparkles className="w-4 h-4 text-white" />
</div>
<div>
<h3 className="font-bold text-sm leading-none">Local Assistant</h3>
<span className="text-[10px] text-blue-100 animate-pulse">Local & Ready</span>
</div>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => setIsMinimized(!isMinimized)}
className="p-1.5 hover:bg-white/10 rounded-lg transition-colors"
>
{isMinimized ? <Maximize2 className="w-4 h-4" /> : <Minimize2 className="w-4 h-4" />}
</button>
<button
onClick={() => setIsOpen(false)}
className="p-1.5 hover:bg-white/10 rounded-lg transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
{!isMinimized && (
<>
{/* Messages */}
<div
ref={scrollRef}
className="flex-1 overflow-y-auto p-4 space-y-4 bg-slate-50"
>
{messages.map((m, i) => (
<div
key={i}
className={`flex gap-3 ${m.role === 'user' ? 'flex-row-reverse' : ''}`}
>
<div className={`w-8 h-8 rounded-full flex items-center justify-center shrink-0 ${
m.role === 'user' ? 'bg-blue-600 text-white' : 'bg-white border border-slate-200 text-blue-600 shadow-sm'
}`}>
{m.role === 'user' ? <UserIcon className="w-4 h-4" /> : <Bot className="w-4 h-4" />}
</div>
<div className={`max-w-[80%] rounded-2xl p-3 text-sm shadow-sm ${
m.role === 'user'
? 'bg-blue-600 text-white rounded-tr-none'
: 'bg-white text-slate-700 rounded-tl-none border border-slate-100'
}`}>
<div className="prose prose-sm max-w-none prose-slate">
<ReactMarkdown
components={{
p: ({ children }) => <p className="mb-0">{children}</p>,
ul: ({ children }) => <ul className="my-1 list-disc pl-4">{children}</ul>,
ol: ({ children }) => <ol className="my-1 list-decimal pl-4">{children}</ol>,
}}
>
{m.parts[0].text}
</ReactMarkdown>
</div>
</div>
</div>
))}
{isLoading && (
<div className="flex gap-3">
<div className="w-8 h-8 rounded-full bg-white border border-slate-200 text-blue-600 flex items-center justify-center shrink-0 shadow-sm">
<Bot className="w-4 h-4" />
</div>
<div className="bg-white border border-slate-100 rounded-2xl rounded-tl-none p-3 shadow-sm">
<Loader2 className="w-4 h-4 animate-spin text-blue-600" />
</div>
</div>
)}
</div>
{/* Input */}
<div className="p-4 bg-white border-t border-slate-100 shrink-0">
<div className="relative">
<input
type="text"
placeholder="Ask local assistant..."
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSend()}
className="w-full pl-4 pr-12 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-600 outline-none text-sm transition-all"
/>
<button
onClick={handleSend}
disabled={!input.trim() || isLoading}
className="absolute right-1.5 top-1/2 -translate-y-1/2 p-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-all disabled:bg-slate-300 disabled:shadow-none shadow-lg shadow-blue-200"
>
<Send className="w-4 h-4" />
</button>
</div>
<p className="text-[10px] text-slate-400 mt-2 text-center">
Local assistant uses project data stored in this app.
</p>
</div>
</>
)}
</motion.div>
)}
</AnimatePresence>
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={() => {
if (!isOpen) setIsOpen(true);
setIsMinimized(false);
}}
className={`w-14 h-14 rounded-full flex items-center justify-center shadow-2xl transition-colors ${
isOpen ? 'bg-white text-blue-600 border border-blue-100' : 'bg-blue-600 text-white'
}`}
>
<Sparkles className="w-6 h-6" />
</motion.button>
</div>
);
};
export default LocalAssistant;