Álvaro Valenzuela Valdes commited on
Commit ·
e3da83f
1
Parent(s): da1e56d
feat: responsive agent chat with file upload capability
Browse files
frontend/components/AgentChat.tsx
CHANGED
|
@@ -2,6 +2,7 @@
|
|
| 2 |
|
| 3 |
import { useState, useRef, useEffect } from "react";
|
| 4 |
import type { Tender, CompanyProfile } from "../lib/types";
|
|
|
|
| 5 |
|
| 6 |
type Message = {
|
| 7 |
role: "user" | "assistant";
|
|
@@ -34,7 +35,10 @@ export default function AgentChat({ tender, companyProfile }: Props) {
|
|
| 34 |
const [selectedModel, setSelectedModel] = useState(models[0]);
|
| 35 |
const [isLoading, setIsLoading] = useState(false);
|
| 36 |
const [isTyping, setIsTyping] = useState(false);
|
|
|
|
|
|
|
| 37 |
const scrollRef = useRef<HTMLDivElement>(null);
|
|
|
|
| 38 |
|
| 39 |
const suggestedQuestions = [
|
| 40 |
"Summarize the main requirements",
|
|
@@ -74,12 +78,13 @@ export default function AgentChat({ tender, companyProfile }: Props) {
|
|
| 74 |
}
|
| 75 |
}, [messages]);
|
| 76 |
|
| 77 |
-
const handleSend = async () => {
|
| 78 |
-
|
|
|
|
| 79 |
|
| 80 |
-
const userMsg: Message = { role: "user", content:
|
| 81 |
setMessages(prev => [...prev, userMsg]);
|
| 82 |
-
setInput("");
|
| 83 |
setIsLoading(true);
|
| 84 |
|
| 85 |
try {
|
|
@@ -89,10 +94,10 @@ export default function AgentChat({ tender, companyProfile }: Props) {
|
|
| 89 |
body: JSON.stringify({
|
| 90 |
tender,
|
| 91 |
company_profile: companyProfile,
|
| 92 |
-
message:
|
| 93 |
agent: selectedAgent.id,
|
| 94 |
model: selectedModel,
|
| 95 |
-
history: messages,
|
| 96 |
}),
|
| 97 |
});
|
| 98 |
|
|
@@ -108,42 +113,68 @@ export default function AgentChat({ tender, companyProfile }: Props) {
|
|
| 108 |
}
|
| 109 |
};
|
| 110 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
const handleSuggestedClick = (question: string) => {
|
| 112 |
setInput(question);
|
| 113 |
};
|
| 114 |
|
| 115 |
return (
|
| 116 |
-
<div className="flex flex-col h-[600px] glass-card rounded-[2rem] overflow-hidden border border-white/10 bg-slate-900/60 backdrop-blur-xl">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
{/* Chat Header */}
|
| 118 |
-
<div className="p-6 border-b border-white/5 flex items-center justify-between bg-white/[0.03]">
|
| 119 |
<div className="flex items-center gap-4">
|
| 120 |
-
<div className={`text-3xl transition-all duration-500 relative ${isLoading || isTyping ? 'scale-110' : ''}`}>
|
| 121 |
{selectedAgent.avatar}
|
| 122 |
-
{(isLoading || isTyping) && (
|
| 123 |
<div className="absolute inset-0 bg-purple-500/20 blur-xl rounded-full animate-pulse" />
|
| 124 |
)}
|
| 125 |
</div>
|
| 126 |
<div>
|
| 127 |
-
<h4 className="text-white font-bold flex items-center gap-2">
|
| 128 |
{selectedAgent.name}
|
| 129 |
-
{(isLoading || isTyping) && <span className="h-1.5 w-1.5 bg-green-500 rounded-full animate-pulse shadow-[0_0_8px_rgba(34,197,94,0.8)]" />}
|
| 130 |
</h4>
|
| 131 |
-
<p className="text-[
|
| 132 |
</div>
|
| 133 |
</div>
|
| 134 |
-
<div className="flex items-center gap-3">
|
| 135 |
<select
|
| 136 |
value={selectedAgent.id}
|
| 137 |
onChange={(e) => setSelectedAgent(agents.find(a => a.id === e.target.value) || agents[0])}
|
| 138 |
-
className="bg-white/5 border border-white/10 rounded-xl px-3 py-1.5 text-[10px] uppercase font-black tracking-widest text-slate-400 hover:text-white transition-all cursor-pointer outline-none focus:border-purple-500/50"
|
| 139 |
>
|
| 140 |
{agents.map(a => <option key={a.id} value={a.id} className="bg-slate-900">{a.name}</option>)}
|
| 141 |
</select>
|
| 142 |
-
<div className="h-6 w-px bg-white/5" />
|
| 143 |
<select
|
| 144 |
value={selectedModel}
|
| 145 |
onChange={(e) => setSelectedModel(e.target.value)}
|
| 146 |
-
className="bg-white/5 border border-white/10 rounded-xl px-3 py-1.5 text-[10px] uppercase font-black tracking-widest text-slate-400 hover:text-white transition-all cursor-pointer outline-none focus:border-purple-500/50"
|
| 147 |
>
|
| 148 |
{models.map(m => <option key={m} value={m} className="bg-slate-900">{m}</option>)}
|
| 149 |
</select>
|
|
@@ -210,20 +241,31 @@ export default function AgentChat({ tender, companyProfile }: Props) {
|
|
| 210 |
)}
|
| 211 |
|
| 212 |
{/* Input Area */}
|
| 213 |
-
<div className="p-6 bg-white/5 border-t border-white/5">
|
| 214 |
-
<div className="flex gap-3">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 215 |
<input
|
| 216 |
type="text"
|
| 217 |
value={input}
|
| 218 |
onChange={(e) => setInput(e.target.value)}
|
| 219 |
onKeyDown={(e) => e.key === 'Enter' && handleSend()}
|
| 220 |
-
placeholder={`Message ${selectedAgent.name}...`}
|
| 221 |
-
|
|
|
|
| 222 |
/>
|
|
|
|
| 223 |
<button
|
| 224 |
-
onClick={handleSend}
|
| 225 |
-
disabled={!input.trim() || isLoading}
|
| 226 |
-
className="w-12 h-12 rounded-2xl premium-gradient text-white flex items-center justify-center transition-all active:scale-95 disabled:opacity-30 shadow-lg shadow-purple-500/20"
|
| 227 |
>
|
| 228 |
<span className="text-xl">✈️</span>
|
| 229 |
</button>
|
|
|
|
| 2 |
|
| 3 |
import { useState, useRef, useEffect } from "react";
|
| 4 |
import type { Tender, CompanyProfile } from "../lib/types";
|
| 5 |
+
import { uploadDocument } from "../lib/api";
|
| 6 |
|
| 7 |
type Message = {
|
| 8 |
role: "user" | "assistant";
|
|
|
|
| 35 |
const [selectedModel, setSelectedModel] = useState(models[0]);
|
| 36 |
const [isLoading, setIsLoading] = useState(false);
|
| 37 |
const [isTyping, setIsTyping] = useState(false);
|
| 38 |
+
const [isUploading, setIsUploading] = useState(false);
|
| 39 |
+
const [contextText, setContextText] = useState("");
|
| 40 |
const scrollRef = useRef<HTMLDivElement>(null);
|
| 41 |
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
| 42 |
|
| 43 |
const suggestedQuestions = [
|
| 44 |
"Summarize the main requirements",
|
|
|
|
| 78 |
}
|
| 79 |
}, [messages]);
|
| 80 |
|
| 81 |
+
const handleSend = async (overrideInput?: string) => {
|
| 82 |
+
const messageToSend = overrideInput || input;
|
| 83 |
+
if (!messageToSend.trim() || isLoading) return;
|
| 84 |
|
| 85 |
+
const userMsg: Message = { role: "user", content: messageToSend };
|
| 86 |
setMessages(prev => [...prev, userMsg]);
|
| 87 |
+
if (!overrideInput) setInput("");
|
| 88 |
setIsLoading(true);
|
| 89 |
|
| 90 |
try {
|
|
|
|
| 94 |
body: JSON.stringify({
|
| 95 |
tender,
|
| 96 |
company_profile: companyProfile,
|
| 97 |
+
message: contextText ? `[DOC CONTEXT: ${contextText.slice(0, 3000)}]\n\nUSER QUESTION: ${messageToSend}` : messageToSend,
|
| 98 |
agent: selectedAgent.id,
|
| 99 |
model: selectedModel,
|
| 100 |
+
history: messages.map(({role, content}) => ({role, content})),
|
| 101 |
}),
|
| 102 |
});
|
| 103 |
|
|
|
|
| 113 |
}
|
| 114 |
};
|
| 115 |
|
| 116 |
+
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
| 117 |
+
if (e.target.files && e.target.files[0]) {
|
| 118 |
+
const file = e.target.files[0];
|
| 119 |
+
setIsUploading(true);
|
| 120 |
+
try {
|
| 121 |
+
const result = await uploadDocument(file);
|
| 122 |
+
setContextText(prev => prev + "\n" + result.text);
|
| 123 |
+
setMessages(prev => [...prev, { role: "user", content: `📎 Attached document: ${file.name}` }]);
|
| 124 |
+
simulateTyping(`He analizado el documento "${file.name}". ¿Qué te gustaría saber sobre su contenido?`, selectedAgent.name);
|
| 125 |
+
} catch (error) {
|
| 126 |
+
console.error(error);
|
| 127 |
+
alert("Error uploading document.");
|
| 128 |
+
} finally {
|
| 129 |
+
setIsUploading(false);
|
| 130 |
+
}
|
| 131 |
+
}
|
| 132 |
+
};
|
| 133 |
+
|
| 134 |
const handleSuggestedClick = (question: string) => {
|
| 135 |
setInput(question);
|
| 136 |
};
|
| 137 |
|
| 138 |
return (
|
| 139 |
+
<div className="flex flex-col h-[600px] md:h-[650px] glass-card rounded-[2rem] overflow-hidden border border-white/10 bg-slate-900/60 backdrop-blur-xl shadow-2xl transition-all duration-500">
|
| 140 |
+
<input
|
| 141 |
+
type="file"
|
| 142 |
+
ref={fileInputRef}
|
| 143 |
+
onChange={handleFileUpload}
|
| 144 |
+
className="hidden"
|
| 145 |
+
accept=".pdf,.docx,.doc,.txt"
|
| 146 |
+
/>
|
| 147 |
+
|
| 148 |
{/* Chat Header */}
|
| 149 |
+
<div className="p-4 md:p-6 border-b border-white/5 flex flex-col md:flex-row md:items-center justify-between gap-4 bg-white/[0.03]">
|
| 150 |
<div className="flex items-center gap-4">
|
| 151 |
+
<div className={`text-2xl md:text-3xl transition-all duration-500 relative ${isLoading || isTyping ? 'scale-110' : ''}`}>
|
| 152 |
{selectedAgent.avatar}
|
| 153 |
+
{(isLoading || isTyping || isUploading) && (
|
| 154 |
<div className="absolute inset-0 bg-purple-500/20 blur-xl rounded-full animate-pulse" />
|
| 155 |
)}
|
| 156 |
</div>
|
| 157 |
<div>
|
| 158 |
+
<h4 className="text-white text-sm md:text-base font-bold flex items-center gap-2">
|
| 159 |
{selectedAgent.name}
|
| 160 |
+
{(isLoading || isTyping || isUploading) && <span className="h-1.5 w-1.5 bg-green-500 rounded-full animate-pulse shadow-[0_0_8px_rgba(34,197,94,0.8)]" />}
|
| 161 |
</h4>
|
| 162 |
+
<p className="text-[9px] text-slate-500 uppercase tracking-widest font-black">Expert Consultant</p>
|
| 163 |
</div>
|
| 164 |
</div>
|
| 165 |
+
<div className="flex items-center gap-2 md:gap-3 overflow-x-auto no-scrollbar">
|
| 166 |
<select
|
| 167 |
value={selectedAgent.id}
|
| 168 |
onChange={(e) => setSelectedAgent(agents.find(a => a.id === e.target.value) || agents[0])}
|
| 169 |
+
className="bg-white/5 border border-white/10 rounded-xl px-2 md:px-3 py-1.5 text-[9px] md:text-[10px] uppercase font-black tracking-widest text-slate-400 hover:text-white transition-all cursor-pointer outline-none focus:border-purple-500/50"
|
| 170 |
>
|
| 171 |
{agents.map(a => <option key={a.id} value={a.id} className="bg-slate-900">{a.name}</option>)}
|
| 172 |
</select>
|
| 173 |
+
<div className="h-6 w-px bg-white/5 shrink-0" />
|
| 174 |
<select
|
| 175 |
value={selectedModel}
|
| 176 |
onChange={(e) => setSelectedModel(e.target.value)}
|
| 177 |
+
className="bg-white/5 border border-white/10 rounded-xl px-2 md:px-3 py-1.5 text-[9px] md:text-[10px] uppercase font-black tracking-widest text-slate-400 hover:text-white transition-all cursor-pointer outline-none focus:border-purple-500/50"
|
| 178 |
>
|
| 179 |
{models.map(m => <option key={m} value={m} className="bg-slate-900">{m}</option>)}
|
| 180 |
</select>
|
|
|
|
| 241 |
)}
|
| 242 |
|
| 243 |
{/* Input Area */}
|
| 244 |
+
<div className="p-4 md:p-6 bg-white/5 border-t border-white/5">
|
| 245 |
+
<div className="flex gap-2 md:gap-3 items-center">
|
| 246 |
+
<button
|
| 247 |
+
onClick={() => fileInputRef.current?.click()}
|
| 248 |
+
disabled={isUploading || isLoading}
|
| 249 |
+
className="w-10 h-10 md:w-12 md:h-12 rounded-2xl bg-white/5 border border-white/10 text-slate-400 flex items-center justify-center transition-all hover:bg-white/10 active:scale-95 disabled:opacity-30"
|
| 250 |
+
title="Attach Document"
|
| 251 |
+
>
|
| 252 |
+
<span className={`text-xl ${isUploading ? 'animate-spin' : ''}`}>{isUploading ? '⌛' : '📎'}</span>
|
| 253 |
+
</button>
|
| 254 |
+
|
| 255 |
<input
|
| 256 |
type="text"
|
| 257 |
value={input}
|
| 258 |
onChange={(e) => setInput(e.target.value)}
|
| 259 |
onKeyDown={(e) => e.key === 'Enter' && handleSend()}
|
| 260 |
+
placeholder={isUploading ? "Uploading document..." : `Message ${selectedAgent.name}...`}
|
| 261 |
+
disabled={isUploading}
|
| 262 |
+
className="flex-1 bg-black/40 border border-white/10 rounded-2xl px-4 md:px-5 py-3 text-white text-sm placeholder:text-slate-600 focus:outline-none focus:ring-2 focus:ring-purple-500/40 transition-all disabled:opacity-50"
|
| 263 |
/>
|
| 264 |
+
|
| 265 |
<button
|
| 266 |
+
onClick={() => handleSend()}
|
| 267 |
+
disabled={!input.trim() || isLoading || isUploading}
|
| 268 |
+
className="w-10 h-10 md:w-12 md:h-12 rounded-2xl premium-gradient text-white flex items-center justify-center transition-all active:scale-95 disabled:opacity-30 shadow-lg shadow-purple-500/20"
|
| 269 |
>
|
| 270 |
<span className="text-xl">✈️</span>
|
| 271 |
</button>
|