File size: 12,035 Bytes
191b322 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 | 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;
|