Spaces:
Running
Running
update dockerfile
Browse files- .env.example +1 -0
- Dockerfile +2 -2
- src/app/components/KnowledgeManagement.tsx +166 -115
- src/app/components/Login.tsx +30 -17
- src/app/components/Main.tsx +265 -151
- src/services/api.ts +133 -0
.env.example
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
VITE_API_BASE_URL=
|
Dockerfile
CHANGED
|
@@ -18,7 +18,7 @@ COPY --from=builder /app/dist /usr/share/nginx/html
|
|
| 18 |
|
| 19 |
# SPA fallback: route all requests to index.html
|
| 20 |
RUN printf 'server {\n\
|
| 21 |
-
listen
|
| 22 |
root /usr/share/nginx/html;\n\
|
| 23 |
index index.html;\n\
|
| 24 |
location / {\n\
|
|
@@ -26,6 +26,6 @@ RUN printf 'server {\n\
|
|
| 26 |
}\n\
|
| 27 |
}\n' > /etc/nginx/conf.d/default.conf
|
| 28 |
|
| 29 |
-
EXPOSE
|
| 30 |
|
| 31 |
CMD ["nginx", "-g", "daemon off;"]
|
|
|
|
| 18 |
|
| 19 |
# SPA fallback: route all requests to index.html
|
| 20 |
RUN printf 'server {\n\
|
| 21 |
+
listen 7860;\n\
|
| 22 |
root /usr/share/nginx/html;\n\
|
| 23 |
index index.html;\n\
|
| 24 |
location / {\n\
|
|
|
|
| 26 |
}\n\
|
| 27 |
}\n' > /etc/nginx/conf.d/default.conf
|
| 28 |
|
| 29 |
+
EXPOSE 7860
|
| 30 |
|
| 31 |
CMD ["nginx", "-g", "daemon off;"]
|
src/app/components/KnowledgeManagement.tsx
CHANGED
|
@@ -8,111 +8,144 @@ import {
|
|
| 8 |
Database,
|
| 9 |
X,
|
| 10 |
} from "lucide-react";
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
}
|
| 20 |
|
| 21 |
interface KnowledgeManagementProps {
|
| 22 |
open: boolean;
|
| 23 |
onClose: () => void;
|
| 24 |
}
|
| 25 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
export default function KnowledgeManagement({
|
| 27 |
open,
|
| 28 |
onClose,
|
| 29 |
}: KnowledgeManagementProps) {
|
| 30 |
-
const [documents, setDocuments] = useState<
|
|
|
|
|
|
|
| 31 |
const [uploading, setUploading] = useState(false);
|
|
|
|
| 32 |
const [processing, setProcessing] = useState<string | null>(null);
|
|
|
|
| 33 |
|
| 34 |
useEffect(() => {
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
}, []);
|
| 40 |
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
}
|
| 45 |
-
}
|
| 46 |
|
| 47 |
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
| 48 |
const files = e.target.files;
|
| 49 |
if (!files || files.length === 0) return;
|
|
|
|
|
|
|
| 50 |
|
| 51 |
setUploading(true);
|
|
|
|
| 52 |
|
| 53 |
for (let i = 0; i < files.length; i++) {
|
| 54 |
const file = files[i];
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
};
|
| 67 |
|
| 68 |
-
|
|
|
|
|
|
|
|
|
|
| 69 |
}
|
| 70 |
|
| 71 |
setUploading(false);
|
| 72 |
e.target.value = "";
|
| 73 |
};
|
| 74 |
|
| 75 |
-
const
|
| 76 |
setProcessing(docId);
|
| 77 |
-
|
| 78 |
-
// Simulate PDF processing (text extraction)
|
| 79 |
-
await new Promise((resolve) => setTimeout(resolve, 2000));
|
| 80 |
-
|
| 81 |
-
const mockExtractedText = `This is simulated extracted text from the PDF document.
|
| 82 |
-
|
| 83 |
-
In a real implementation, this would contain the actual text extracted from the PDF using a library like PDF.js or similar.
|
| 84 |
-
|
| 85 |
-
The document has been processed and its content is now available for use in the chatbot knowledge base. You can use this information to answer questions and provide context-aware responses.
|
| 86 |
-
|
| 87 |
-
Key features:
|
| 88 |
-
- Text extraction from PDF
|
| 89 |
-
- Knowledge storage
|
| 90 |
-
- Searchable content
|
| 91 |
-
- Integration with chat responses`;
|
| 92 |
-
|
| 93 |
setDocuments((prev) =>
|
| 94 |
-
prev.map((
|
| 95 |
-
|
| 96 |
-
? { ...doc, processed: true, content: mockExtractedText }
|
| 97 |
-
: doc
|
| 98 |
)
|
| 99 |
);
|
| 100 |
-
|
| 101 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
};
|
| 103 |
|
| 104 |
-
const
|
| 105 |
-
|
| 106 |
-
if (
|
| 107 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
}
|
| 109 |
};
|
| 110 |
|
| 111 |
-
const deleteAllDocuments = () => {
|
| 112 |
-
if (window.confirm("Are you sure you want to delete all documents?"))
|
| 113 |
-
|
| 114 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
}
|
|
|
|
| 116 |
};
|
| 117 |
|
| 118 |
const formatFileSize = (bytes: number) => {
|
|
@@ -121,8 +154,43 @@ Key features:
|
|
| 121 |
return (bytes / (1024 * 1024)).toFixed(2) + " MB";
|
| 122 |
};
|
| 123 |
|
| 124 |
-
const formatDate = (
|
| 125 |
-
return new Date(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
};
|
| 127 |
|
| 128 |
if (!open) return null;
|
|
@@ -155,7 +223,7 @@ Key features:
|
|
| 155 |
<Upload className="w-5 h-5 text-slate-600" />
|
| 156 |
<div className="text-center">
|
| 157 |
<p className="text-slate-900 font-medium text-sm">
|
| 158 |
-
Upload PDF
|
| 159 |
</p>
|
| 160 |
<p className="text-xs text-slate-500 mt-0.5">
|
| 161 |
Click to browse or drag and drop
|
|
@@ -164,13 +232,18 @@ Key features:
|
|
| 164 |
<input
|
| 165 |
id="file-upload"
|
| 166 |
type="file"
|
| 167 |
-
accept=".pdf"
|
| 168 |
multiple
|
| 169 |
onChange={handleFileUpload}
|
| 170 |
className="hidden"
|
| 171 |
disabled={uploading}
|
| 172 |
/>
|
| 173 |
</label>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
</div>
|
| 175 |
|
| 176 |
{/* Documents List */}
|
|
@@ -190,12 +263,22 @@ Key features:
|
|
| 190 |
)}
|
| 191 |
</div>
|
| 192 |
|
| 193 |
-
{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 194 |
<div className="text-center py-8">
|
| 195 |
<FileText className="w-12 h-12 text-slate-300 mx-auto mb-3" />
|
| 196 |
-
<p className="text-slate-500 text-sm">
|
|
|
|
|
|
|
| 197 |
<p className="text-xs text-slate-400 mt-1">
|
| 198 |
-
Upload
|
| 199 |
</p>
|
| 200 |
</div>
|
| 201 |
) : (
|
|
@@ -210,61 +293,30 @@ Key features:
|
|
| 210 |
<div className="flex items-start justify-between gap-2">
|
| 211 |
<div className="flex-1 min-w-0">
|
| 212 |
<h4 className="text-slate-900 font-medium truncate text-sm">
|
| 213 |
-
{doc.
|
| 214 |
</h4>
|
| 215 |
<p className="text-xs text-slate-500 mt-0.5">
|
| 216 |
-
{formatFileSize(doc.
|
| 217 |
-
{formatDate(doc.
|
| 218 |
</p>
|
| 219 |
</div>
|
| 220 |
<button
|
| 221 |
-
onClick={() =>
|
| 222 |
-
|
|
|
|
| 223 |
title="Delete document"
|
| 224 |
>
|
| 225 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 226 |
</button>
|
| 227 |
</div>
|
| 228 |
|
| 229 |
<div className="mt-2 flex items-center gap-2">
|
| 230 |
-
{
|
| 231 |
-
<div className="flex items-center gap-1.5 text-green-600">
|
| 232 |
-
<Check className="w-3.5 h-3.5" />
|
| 233 |
-
<span className="text-xs font-medium">
|
| 234 |
-
Processed
|
| 235 |
-
</span>
|
| 236 |
-
</div>
|
| 237 |
-
) : (
|
| 238 |
-
<button
|
| 239 |
-
onClick={() => processDocument(doc.id)}
|
| 240 |
-
disabled={processing === doc.id}
|
| 241 |
-
className="flex items-center gap-1.5 bg-gradient-to-r from-[#00C853] to-[#00A843] text-white px-3 py-1.5 rounded-lg hover:from-[#00A843] hover:to-[#00962B] transition disabled:opacity-50 disabled:cursor-not-allowed text-xs"
|
| 242 |
-
>
|
| 243 |
-
{processing === doc.id ? (
|
| 244 |
-
<>
|
| 245 |
-
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
| 246 |
-
Processing...
|
| 247 |
-
</>
|
| 248 |
-
) : (
|
| 249 |
-
<>
|
| 250 |
-
<Database className="w-3.5 h-3.5" />
|
| 251 |
-
Process to Knowledge
|
| 252 |
-
</>
|
| 253 |
-
)}
|
| 254 |
-
</button>
|
| 255 |
-
)}
|
| 256 |
</div>
|
| 257 |
-
|
| 258 |
-
{doc.processed && doc.content && (
|
| 259 |
-
<div className="mt-2 p-2 bg-white rounded border border-slate-200">
|
| 260 |
-
<p className="text-[10px] text-slate-500 mb-1">
|
| 261 |
-
Extracted Content Preview:
|
| 262 |
-
</p>
|
| 263 |
-
<p className="text-xs text-slate-700 line-clamp-3">
|
| 264 |
-
{doc.content}
|
| 265 |
-
</p>
|
| 266 |
-
</div>
|
| 267 |
-
)}
|
| 268 |
</div>
|
| 269 |
</div>
|
| 270 |
</div>
|
|
@@ -276,8 +328,7 @@ Key features:
|
|
| 276 |
{/* Footer */}
|
| 277 |
<div className="border-t border-slate-200 p-3 bg-slate-50 rounded-b-xl">
|
| 278 |
<p className="text-[10px] text-slate-500 text-center">
|
| 279 |
-
|
| 280 |
-
extract actual text from PDFs.
|
| 281 |
</p>
|
| 282 |
</div>
|
| 283 |
</div>
|
|
|
|
| 8 |
Database,
|
| 9 |
X,
|
| 10 |
} from "lucide-react";
|
| 11 |
+
import {
|
| 12 |
+
getDocuments,
|
| 13 |
+
uploadDocument,
|
| 14 |
+
processDocument,
|
| 15 |
+
deleteDocument,
|
| 16 |
+
type ApiDocument,
|
| 17 |
+
type DocumentStatus,
|
| 18 |
+
} from "../../services/api";
|
|
|
|
| 19 |
|
| 20 |
interface KnowledgeManagementProps {
|
| 21 |
open: boolean;
|
| 22 |
onClose: () => void;
|
| 23 |
}
|
| 24 |
|
| 25 |
+
const getUserId = (): string | null => {
|
| 26 |
+
const stored = localStorage.getItem("chatbot_user");
|
| 27 |
+
if (!stored) return null;
|
| 28 |
+
return (JSON.parse(stored).user_id as string) ?? null;
|
| 29 |
+
};
|
| 30 |
+
|
| 31 |
export default function KnowledgeManagement({
|
| 32 |
open,
|
| 33 |
onClose,
|
| 34 |
}: KnowledgeManagementProps) {
|
| 35 |
+
const [documents, setDocuments] = useState<ApiDocument[]>([]);
|
| 36 |
+
const [loadingDocs, setLoadingDocs] = useState(false);
|
| 37 |
+
const [docsError, setDocsError] = useState<string | null>(null);
|
| 38 |
const [uploading, setUploading] = useState(false);
|
| 39 |
+
const [uploadError, setUploadError] = useState<string | null>(null);
|
| 40 |
const [processing, setProcessing] = useState<string | null>(null);
|
| 41 |
+
const [deleting, setDeleting] = useState<string | null>(null);
|
| 42 |
|
| 43 |
useEffect(() => {
|
| 44 |
+
if (!open) return;
|
| 45 |
+
const userId = getUserId();
|
| 46 |
+
if (!userId) return;
|
| 47 |
+
loadDocuments(userId);
|
| 48 |
+
}, [open]);
|
| 49 |
|
| 50 |
+
const loadDocuments = async (userId: string) => {
|
| 51 |
+
setLoadingDocs(true);
|
| 52 |
+
setDocsError(null);
|
| 53 |
+
try {
|
| 54 |
+
setDocuments(await getDocuments(userId));
|
| 55 |
+
} catch (err) {
|
| 56 |
+
setDocsError(
|
| 57 |
+
err instanceof Error ? err.message : "Failed to load documents"
|
| 58 |
+
);
|
| 59 |
+
} finally {
|
| 60 |
+
setLoadingDocs(false);
|
| 61 |
}
|
| 62 |
+
};
|
| 63 |
|
| 64 |
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
| 65 |
const files = e.target.files;
|
| 66 |
if (!files || files.length === 0) return;
|
| 67 |
+
const userId = getUserId();
|
| 68 |
+
if (!userId) return;
|
| 69 |
|
| 70 |
setUploading(true);
|
| 71 |
+
setUploadError(null);
|
| 72 |
|
| 73 |
for (let i = 0; i < files.length; i++) {
|
| 74 |
const file = files[i];
|
| 75 |
+
try {
|
| 76 |
+
const uploadRes = await uploadDocument(userId, file);
|
| 77 |
+
const newDoc: ApiDocument = {
|
| 78 |
+
id: uploadRes.data.id,
|
| 79 |
+
filename: uploadRes.data.filename,
|
| 80 |
+
status: "pending",
|
| 81 |
+
file_size: file.size,
|
| 82 |
+
file_type: file.name.split(".").pop() ?? "",
|
| 83 |
+
created_at: new Date().toISOString(),
|
| 84 |
+
};
|
| 85 |
+
setDocuments((prev) => [newDoc, ...prev]);
|
|
|
|
| 86 |
|
| 87 |
+
await processDocumentById(userId, uploadRes.data.id);
|
| 88 |
+
} catch (err) {
|
| 89 |
+
setUploadError(err instanceof Error ? err.message : "Upload failed");
|
| 90 |
+
}
|
| 91 |
}
|
| 92 |
|
| 93 |
setUploading(false);
|
| 94 |
e.target.value = "";
|
| 95 |
};
|
| 96 |
|
| 97 |
+
const processDocumentById = async (userId: string, docId: string) => {
|
| 98 |
setProcessing(docId);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
setDocuments((prev) =>
|
| 100 |
+
prev.map((d) =>
|
| 101 |
+
d.id === docId ? { ...d, status: "processing" as DocumentStatus } : d
|
|
|
|
|
|
|
| 102 |
)
|
| 103 |
);
|
| 104 |
+
try {
|
| 105 |
+
await processDocument(userId, docId);
|
| 106 |
+
setDocuments((prev) =>
|
| 107 |
+
prev.map((d) =>
|
| 108 |
+
d.id === docId ? { ...d, status: "completed" as DocumentStatus } : d
|
| 109 |
+
)
|
| 110 |
+
);
|
| 111 |
+
} catch {
|
| 112 |
+
setDocuments((prev) =>
|
| 113 |
+
prev.map((d) =>
|
| 114 |
+
d.id === docId ? { ...d, status: "failed" as DocumentStatus } : d
|
| 115 |
+
)
|
| 116 |
+
);
|
| 117 |
+
} finally {
|
| 118 |
+
setProcessing(null);
|
| 119 |
+
}
|
| 120 |
};
|
| 121 |
|
| 122 |
+
const handleDeleteDocument = async (docId: string) => {
|
| 123 |
+
const userId = getUserId();
|
| 124 |
+
if (!userId) return;
|
| 125 |
+
setDeleting(docId);
|
| 126 |
+
try {
|
| 127 |
+
await deleteDocument(userId, docId);
|
| 128 |
+
setDocuments((prev) => prev.filter((d) => d.id !== docId));
|
| 129 |
+
} catch (err) {
|
| 130 |
+
console.error("Delete failed:", err);
|
| 131 |
+
} finally {
|
| 132 |
+
setDeleting(null);
|
| 133 |
}
|
| 134 |
};
|
| 135 |
|
| 136 |
+
const deleteAllDocuments = async () => {
|
| 137 |
+
if (!window.confirm("Are you sure you want to delete all documents?"))
|
| 138 |
+
return;
|
| 139 |
+
const userId = getUserId();
|
| 140 |
+
if (!userId) return;
|
| 141 |
+
for (const doc of documents) {
|
| 142 |
+
try {
|
| 143 |
+
await deleteDocument(userId, doc.id);
|
| 144 |
+
} catch {
|
| 145 |
+
// continue deleting others
|
| 146 |
+
}
|
| 147 |
}
|
| 148 |
+
setDocuments([]);
|
| 149 |
};
|
| 150 |
|
| 151 |
const formatFileSize = (bytes: number) => {
|
|
|
|
| 154 |
return (bytes / (1024 * 1024)).toFixed(2) + " MB";
|
| 155 |
};
|
| 156 |
|
| 157 |
+
const formatDate = (isoString: string) => {
|
| 158 |
+
return new Date(isoString).toLocaleString();
|
| 159 |
+
};
|
| 160 |
+
|
| 161 |
+
const renderStatus = (doc: ApiDocument) => {
|
| 162 |
+
if (doc.status === "completed") {
|
| 163 |
+
return (
|
| 164 |
+
<div className="flex items-center gap-1.5 text-green-600">
|
| 165 |
+
<Check className="w-3.5 h-3.5" />
|
| 166 |
+
<span className="text-xs font-medium">Processed</span>
|
| 167 |
+
</div>
|
| 168 |
+
);
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
if (doc.status === "processing" || processing === doc.id) {
|
| 172 |
+
return (
|
| 173 |
+
<div className="flex items-center gap-1.5 text-blue-600">
|
| 174 |
+
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
| 175 |
+
<span className="text-xs">Processing...</span>
|
| 176 |
+
</div>
|
| 177 |
+
);
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
// pending or failed
|
| 181 |
+
return (
|
| 182 |
+
<button
|
| 183 |
+
onClick={() => {
|
| 184 |
+
const userId = getUserId();
|
| 185 |
+
if (userId) processDocumentById(userId, doc.id);
|
| 186 |
+
}}
|
| 187 |
+
disabled={processing === doc.id}
|
| 188 |
+
className="flex items-center gap-1.5 bg-gradient-to-r from-[#00C853] to-[#00A843] text-white px-3 py-1.5 rounded-lg hover:from-[#00A843] hover:to-[#00962B] transition disabled:opacity-50 disabled:cursor-not-allowed text-xs"
|
| 189 |
+
>
|
| 190 |
+
<Database className="w-3.5 h-3.5" />
|
| 191 |
+
{doc.status === "failed" ? "Retry Process" : "Process to Knowledge"}
|
| 192 |
+
</button>
|
| 193 |
+
);
|
| 194 |
};
|
| 195 |
|
| 196 |
if (!open) return null;
|
|
|
|
| 223 |
<Upload className="w-5 h-5 text-slate-600" />
|
| 224 |
<div className="text-center">
|
| 225 |
<p className="text-slate-900 font-medium text-sm">
|
| 226 |
+
Upload Documents (PDF, DOCX, TXT)
|
| 227 |
</p>
|
| 228 |
<p className="text-xs text-slate-500 mt-0.5">
|
| 229 |
Click to browse or drag and drop
|
|
|
|
| 232 |
<input
|
| 233 |
id="file-upload"
|
| 234 |
type="file"
|
| 235 |
+
accept=".pdf,.docx,.txt"
|
| 236 |
multiple
|
| 237 |
onChange={handleFileUpload}
|
| 238 |
className="hidden"
|
| 239 |
disabled={uploading}
|
| 240 |
/>
|
| 241 |
</label>
|
| 242 |
+
{uploadError && (
|
| 243 |
+
<p className="mt-2 text-xs text-red-600 bg-red-50 border border-red-200 px-3 py-2 rounded-lg">
|
| 244 |
+
{uploadError}
|
| 245 |
+
</p>
|
| 246 |
+
)}
|
| 247 |
</div>
|
| 248 |
|
| 249 |
{/* Documents List */}
|
|
|
|
| 263 |
)}
|
| 264 |
</div>
|
| 265 |
|
| 266 |
+
{loadingDocs ? (
|
| 267 |
+
<div className="flex justify-center py-8">
|
| 268 |
+
<Loader2 className="w-6 h-6 animate-spin text-slate-400" />
|
| 269 |
+
</div>
|
| 270 |
+
) : docsError ? (
|
| 271 |
+
<p className="text-center text-sm text-red-600 py-4">
|
| 272 |
+
{docsError}
|
| 273 |
+
</p>
|
| 274 |
+
) : documents.length === 0 ? (
|
| 275 |
<div className="text-center py-8">
|
| 276 |
<FileText className="w-12 h-12 text-slate-300 mx-auto mb-3" />
|
| 277 |
+
<p className="text-slate-500 text-sm">
|
| 278 |
+
No documents uploaded yet
|
| 279 |
+
</p>
|
| 280 |
<p className="text-xs text-slate-400 mt-1">
|
| 281 |
+
Upload files to build your knowledge base
|
| 282 |
</p>
|
| 283 |
</div>
|
| 284 |
) : (
|
|
|
|
| 293 |
<div className="flex items-start justify-between gap-2">
|
| 294 |
<div className="flex-1 min-w-0">
|
| 295 |
<h4 className="text-slate-900 font-medium truncate text-sm">
|
| 296 |
+
{doc.filename}
|
| 297 |
</h4>
|
| 298 |
<p className="text-xs text-slate-500 mt-0.5">
|
| 299 |
+
{formatFileSize(doc.file_size)} β’{" "}
|
| 300 |
+
{formatDate(doc.created_at)}
|
| 301 |
</p>
|
| 302 |
</div>
|
| 303 |
<button
|
| 304 |
+
onClick={() => handleDeleteDocument(doc.id)}
|
| 305 |
+
disabled={deleting === doc.id}
|
| 306 |
+
className="text-slate-400 hover:text-red-600 transition flex-shrink-0 disabled:opacity-50"
|
| 307 |
title="Delete document"
|
| 308 |
>
|
| 309 |
+
{deleting === doc.id ? (
|
| 310 |
+
<Loader2 className="w-4 h-4 animate-spin" />
|
| 311 |
+
) : (
|
| 312 |
+
<Trash2 className="w-4 h-4" />
|
| 313 |
+
)}
|
| 314 |
</button>
|
| 315 |
</div>
|
| 316 |
|
| 317 |
<div className="mt-2 flex items-center gap-2">
|
| 318 |
+
{renderStatus(doc)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 319 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 320 |
</div>
|
| 321 |
</div>
|
| 322 |
</div>
|
|
|
|
| 328 |
{/* Footer */}
|
| 329 |
<div className="border-t border-slate-200 p-3 bg-slate-50 rounded-b-xl">
|
| 330 |
<p className="text-[10px] text-slate-500 text-center">
|
| 331 |
+
Supported formats: PDF, DOCX, TXT
|
|
|
|
| 332 |
</p>
|
| 333 |
</div>
|
| 334 |
</div>
|
src/app/components/Login.tsx
CHANGED
|
@@ -1,14 +1,16 @@
|
|
| 1 |
import { useState } from "react";
|
| 2 |
import { useNavigate } from "react-router";
|
| 3 |
-
import { LogIn } from "lucide-react";
|
|
|
|
| 4 |
|
| 5 |
export default function Login() {
|
| 6 |
const [email, setEmail] = useState("");
|
| 7 |
const [password, setPassword] = useState("");
|
| 8 |
const [error, setError] = useState("");
|
|
|
|
| 9 |
const navigate = useNavigate();
|
| 10 |
|
| 11 |
-
const handleLogin = (e: React.FormEvent) => {
|
| 12 |
e.preventDefault();
|
| 13 |
setError("");
|
| 14 |
|
|
@@ -22,15 +24,22 @@ export default function Login() {
|
|
| 22 |
return;
|
| 23 |
}
|
| 24 |
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
email,
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
};
|
| 35 |
|
| 36 |
return (
|
|
@@ -65,6 +74,7 @@ export default function Login() {
|
|
| 65 |
onChange={(e) => setEmail(e.target.value)}
|
| 66 |
className="w-full px-3 py-2 text-sm rounded-lg border border-slate-300 focus:outline-none focus:ring-2 focus:ring-[#4FC3F7] focus:border-transparent transition"
|
| 67 |
placeholder="you@example.com"
|
|
|
|
| 68 |
/>
|
| 69 |
</div>
|
| 70 |
|
|
@@ -82,6 +92,7 @@ export default function Login() {
|
|
| 82 |
onChange={(e) => setPassword(e.target.value)}
|
| 83 |
className="w-full px-3 py-2 text-sm rounded-lg border border-slate-300 focus:outline-none focus:ring-2 focus:ring-[#4FC3F7] focus:border-transparent transition"
|
| 84 |
placeholder="Enter your password"
|
|
|
|
| 85 |
/>
|
| 86 |
</div>
|
| 87 |
|
|
@@ -93,15 +104,17 @@ export default function Login() {
|
|
| 93 |
|
| 94 |
<button
|
| 95 |
type="submit"
|
| 96 |
-
|
|
|
|
| 97 |
>
|
| 98 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
</button>
|
| 100 |
</form>
|
| 101 |
-
|
| 102 |
-
<p className="text-center text-slate-500 text-xs mt-4">
|
| 103 |
-
Demo mode: Use any email and password
|
| 104 |
-
</p>
|
| 105 |
</div>
|
| 106 |
</div>
|
| 107 |
</div>
|
|
|
|
| 1 |
import { useState } from "react";
|
| 2 |
import { useNavigate } from "react-router";
|
| 3 |
+
import { LogIn, Loader2 } from "lucide-react";
|
| 4 |
+
import { login } from "../../services/api";
|
| 5 |
|
| 6 |
export default function Login() {
|
| 7 |
const [email, setEmail] = useState("");
|
| 8 |
const [password, setPassword] = useState("");
|
| 9 |
const [error, setError] = useState("");
|
| 10 |
+
const [isLoading, setIsLoading] = useState(false);
|
| 11 |
const navigate = useNavigate();
|
| 12 |
|
| 13 |
+
const handleLogin = async (e: React.FormEvent) => {
|
| 14 |
e.preventDefault();
|
| 15 |
setError("");
|
| 16 |
|
|
|
|
| 24 |
return;
|
| 25 |
}
|
| 26 |
|
| 27 |
+
setIsLoading(true);
|
| 28 |
+
try {
|
| 29 |
+
const res = await login(email, password);
|
| 30 |
+
const user = {
|
| 31 |
+
user_id: res.data.user_id,
|
| 32 |
+
email: res.data.email,
|
| 33 |
+
name: res.data.name,
|
| 34 |
+
loginTime: new Date().toISOString(),
|
| 35 |
+
};
|
| 36 |
+
localStorage.setItem("chatbot_user", JSON.stringify(user));
|
| 37 |
+
navigate("/");
|
| 38 |
+
} catch (err: unknown) {
|
| 39 |
+
setError(err instanceof Error ? err.message : "Login failed");
|
| 40 |
+
} finally {
|
| 41 |
+
setIsLoading(false);
|
| 42 |
+
}
|
| 43 |
};
|
| 44 |
|
| 45 |
return (
|
|
|
|
| 74 |
onChange={(e) => setEmail(e.target.value)}
|
| 75 |
className="w-full px-3 py-2 text-sm rounded-lg border border-slate-300 focus:outline-none focus:ring-2 focus:ring-[#4FC3F7] focus:border-transparent transition"
|
| 76 |
placeholder="you@example.com"
|
| 77 |
+
disabled={isLoading}
|
| 78 |
/>
|
| 79 |
</div>
|
| 80 |
|
|
|
|
| 92 |
onChange={(e) => setPassword(e.target.value)}
|
| 93 |
className="w-full px-3 py-2 text-sm rounded-lg border border-slate-300 focus:outline-none focus:ring-2 focus:ring-[#4FC3F7] focus:border-transparent transition"
|
| 94 |
placeholder="Enter your password"
|
| 95 |
+
disabled={isLoading}
|
| 96 |
/>
|
| 97 |
</div>
|
| 98 |
|
|
|
|
| 104 |
|
| 105 |
<button
|
| 106 |
type="submit"
|
| 107 |
+
disabled={isLoading}
|
| 108 |
+
className="w-full flex items-center justify-center gap-2 bg-gradient-to-r from-[#00C853] to-[#00A843] text-white py-2.5 text-sm rounded-lg hover:from-[#00A843] hover:to-[#00962B] transition font-medium disabled:opacity-60 disabled:cursor-not-allowed"
|
| 109 |
>
|
| 110 |
+
{isLoading ? (
|
| 111 |
+
<Loader2 className="w-4 h-4 animate-spin" />
|
| 112 |
+
) : (
|
| 113 |
+
<LogIn className="w-4 h-4" />
|
| 114 |
+
)}
|
| 115 |
+
{isLoading ? "Signing in..." : "Sign In"}
|
| 116 |
</button>
|
| 117 |
</form>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
</div>
|
| 119 |
</div>
|
| 120 |
</div>
|
src/app/components/Main.tsx
CHANGED
|
@@ -10,73 +10,95 @@ import {
|
|
| 10 |
MessageSquare,
|
| 11 |
User,
|
| 12 |
Database,
|
|
|
|
| 13 |
} from "lucide-react";
|
| 14 |
import KnowledgeManagement from "./KnowledgeManagement";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
interface Message {
|
| 17 |
id: string;
|
| 18 |
role: "user" | "assistant";
|
| 19 |
content: string;
|
| 20 |
timestamp: number;
|
|
|
|
| 21 |
}
|
| 22 |
|
| 23 |
-
interface
|
| 24 |
id: string;
|
| 25 |
title: string;
|
| 26 |
messages: Message[];
|
| 27 |
-
createdAt:
|
| 28 |
-
updatedAt:
|
| 29 |
}
|
| 30 |
|
| 31 |
export default function Main() {
|
| 32 |
const navigate = useNavigate();
|
| 33 |
const [sidebarOpen, setSidebarOpen] = useState(true);
|
| 34 |
-
const [chats, setChats] = useState<
|
| 35 |
const [currentChatId, setCurrentChatId] = useState<string | null>(null);
|
| 36 |
const [input, setInput] = useState("");
|
| 37 |
const [isStreaming, setIsStreaming] = useState(false);
|
|
|
|
|
|
|
| 38 |
const messagesEndRef = useRef<HTMLDivElement>(null);
|
| 39 |
-
const [user, setUser] = useState<
|
| 40 |
const [knowledgeOpen, setKnowledgeOpen] = useState(false);
|
|
|
|
| 41 |
|
| 42 |
useEffect(() => {
|
| 43 |
const storedUser = localStorage.getItem("chatbot_user");
|
| 44 |
if (storedUser) {
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
const storedChats = localStorage.getItem("chatbot_chats");
|
| 49 |
-
if (storedChats) {
|
| 50 |
-
const parsedChats = JSON.parse(storedChats);
|
| 51 |
-
setChats(parsedChats);
|
| 52 |
-
if (parsedChats.length > 0) {
|
| 53 |
-
setCurrentChatId(parsedChats[0].id);
|
| 54 |
-
}
|
| 55 |
}
|
| 56 |
}, []);
|
| 57 |
|
| 58 |
-
useEffect(() => {
|
| 59 |
-
if (chats.length > 0) {
|
| 60 |
-
localStorage.setItem("chatbot_chats", JSON.stringify(chats));
|
| 61 |
-
}
|
| 62 |
-
}, [chats]);
|
| 63 |
-
|
| 64 |
useEffect(() => {
|
| 65 |
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
| 66 |
}, [currentChatId, chats]);
|
| 67 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
const currentChat = chats.find((chat) => chat.id === currentChatId);
|
| 69 |
|
| 70 |
const createNewChat = () => {
|
| 71 |
-
|
| 72 |
-
id: Date.now().toString(),
|
| 73 |
-
title: "New Chat",
|
| 74 |
-
messages: [],
|
| 75 |
-
createdAt: Date.now(),
|
| 76 |
-
updatedAt: Date.now(),
|
| 77 |
-
};
|
| 78 |
-
setChats([newChat, ...chats]);
|
| 79 |
-
setCurrentChatId(newChat.id);
|
| 80 |
};
|
| 81 |
|
| 82 |
const deleteChat = (chatId: string) => {
|
|
@@ -90,7 +112,6 @@ export default function Main() {
|
|
| 90 |
const deleteAllChats = () => {
|
| 91 |
setChats([]);
|
| 92 |
setCurrentChatId(null);
|
| 93 |
-
localStorage.removeItem("chatbot_chats");
|
| 94 |
};
|
| 95 |
|
| 96 |
const handleLogout = () => {
|
|
@@ -98,112 +119,173 @@ export default function Main() {
|
|
| 98 |
navigate("/login");
|
| 99 |
};
|
| 100 |
|
| 101 |
-
const simulateStreamingResponse = async (
|
| 102 |
-
userMessage: string,
|
| 103 |
-
chatId: string
|
| 104 |
-
) => {
|
| 105 |
-
const responses = [
|
| 106 |
-
"I'm a demo chatbot. I can help you with various tasks and answer your questions.",
|
| 107 |
-
"That's an interesting question! In this demo version, I provide simulated responses.",
|
| 108 |
-
"I understand. Let me help you with that. This is a demonstration of how the chat interface works.",
|
| 109 |
-
"Great question! The actual AI responses would come from a backend API in a production environment.",
|
| 110 |
-
"I'm here to assist you. This demo showcases the chat interface with streaming responses.",
|
| 111 |
-
];
|
| 112 |
-
|
| 113 |
-
const response =
|
| 114 |
-
responses[Math.floor(Math.random() * responses.length)] +
|
| 115 |
-
" " +
|
| 116 |
-
userMessage;
|
| 117 |
-
const assistantMessageId = Date.now().toString() + "-assistant";
|
| 118 |
-
|
| 119 |
-
setChats((prevChats) =>
|
| 120 |
-
prevChats.map((chat) => {
|
| 121 |
-
if (chat.id === chatId) {
|
| 122 |
-
return {
|
| 123 |
-
...chat,
|
| 124 |
-
messages: [
|
| 125 |
-
...chat.messages,
|
| 126 |
-
{
|
| 127 |
-
id: assistantMessageId,
|
| 128 |
-
role: "assistant",
|
| 129 |
-
content: "",
|
| 130 |
-
timestamp: Date.now(),
|
| 131 |
-
},
|
| 132 |
-
],
|
| 133 |
-
updatedAt: Date.now(),
|
| 134 |
-
};
|
| 135 |
-
}
|
| 136 |
-
return chat;
|
| 137 |
-
})
|
| 138 |
-
);
|
| 139 |
-
|
| 140 |
-
for (let i = 0; i < response.length; i++) {
|
| 141 |
-
await new Promise((resolve) => setTimeout(resolve, 20));
|
| 142 |
-
setChats((prevChats) =>
|
| 143 |
-
prevChats.map((chat) => {
|
| 144 |
-
if (chat.id === chatId) {
|
| 145 |
-
return {
|
| 146 |
-
...chat,
|
| 147 |
-
messages: chat.messages.map((msg) =>
|
| 148 |
-
msg.id === assistantMessageId
|
| 149 |
-
? { ...msg, content: response.slice(0, i + 1) }
|
| 150 |
-
: msg
|
| 151 |
-
),
|
| 152 |
-
};
|
| 153 |
-
}
|
| 154 |
-
return chat;
|
| 155 |
-
})
|
| 156 |
-
);
|
| 157 |
-
}
|
| 158 |
-
};
|
| 159 |
-
|
| 160 |
const handleSend = async () => {
|
| 161 |
-
if (!input.trim() || isStreaming) return;
|
| 162 |
-
|
| 163 |
-
let
|
| 164 |
-
|
| 165 |
-
if (!
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 176 |
}
|
| 177 |
|
| 178 |
const userMessage: Message = {
|
| 179 |
-
id:
|
| 180 |
role: "user",
|
| 181 |
content: input,
|
| 182 |
timestamp: Date.now(),
|
| 183 |
};
|
| 184 |
|
| 185 |
-
setChats((
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
}
|
| 196 |
-
return updatedChat;
|
| 197 |
-
}
|
| 198 |
-
return chat;
|
| 199 |
-
})
|
| 200 |
);
|
| 201 |
|
|
|
|
| 202 |
setInput("");
|
| 203 |
setIsStreaming(true);
|
| 204 |
|
| 205 |
-
|
| 206 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 207 |
};
|
| 208 |
|
| 209 |
const handleKeyPress = (e: React.KeyboardEvent) => {
|
|
@@ -232,33 +314,43 @@ export default function Main() {
|
|
| 232 |
</div>
|
| 233 |
|
| 234 |
<div className="flex-1 overflow-y-auto p-2 space-y-1">
|
| 235 |
-
{
|
| 236 |
-
<div
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
<div
|
| 246 |
-
|
| 247 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 248 |
>
|
| 249 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 250 |
</div>
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
e.stopPropagation();
|
| 254 |
-
deleteChat(chat.id);
|
| 255 |
-
}}
|
| 256 |
-
className="opacity-0 group-hover:opacity-100 transition"
|
| 257 |
-
>
|
| 258 |
-
<Trash2 className="w-3.5 h-3.5 text-red-100 hover:text-white" />
|
| 259 |
-
</button>
|
| 260 |
-
</div>
|
| 261 |
-
))}
|
| 262 |
</div>
|
| 263 |
|
| 264 |
<div className="border-t border-white/20 p-3 space-y-2">
|
|
@@ -352,11 +444,36 @@ export default function Main() {
|
|
| 352 |
<p className="whitespace-pre-wrap break-words text-sm">
|
| 353 |
{message.content}
|
| 354 |
</p>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 355 |
</div>
|
| 356 |
</div>
|
| 357 |
))}
|
| 358 |
|
| 359 |
-
{!currentChat && chats.length === 0 && (
|
| 360 |
<div className="flex items-center justify-center h-full">
|
| 361 |
<div className="text-center">
|
| 362 |
<MessageSquare className="w-12 h-12 text-slate-300 mx-auto mb-3" />
|
|
@@ -394,9 +511,6 @@ export default function Main() {
|
|
| 394 |
<Send className="w-4 h-4" />
|
| 395 |
</button>
|
| 396 |
</div>
|
| 397 |
-
<p className="text-[10px] text-slate-400 mt-1.5 text-center">
|
| 398 |
-
Demo mode: Responses are simulated
|
| 399 |
-
</p>
|
| 400 |
</div>
|
| 401 |
</div>
|
| 402 |
</div>
|
|
|
|
| 10 |
MessageSquare,
|
| 11 |
User,
|
| 12 |
Database,
|
| 13 |
+
Loader2,
|
| 14 |
} from "lucide-react";
|
| 15 |
import KnowledgeManagement from "./KnowledgeManagement";
|
| 16 |
+
import {
|
| 17 |
+
getRooms,
|
| 18 |
+
createRoom,
|
| 19 |
+
streamChat,
|
| 20 |
+
type ChatSource,
|
| 21 |
+
} from "../../services/api";
|
| 22 |
+
|
| 23 |
+
interface StoredUser {
|
| 24 |
+
user_id: string;
|
| 25 |
+
email: string;
|
| 26 |
+
name: string;
|
| 27 |
+
loginTime: string;
|
| 28 |
+
}
|
| 29 |
|
| 30 |
interface Message {
|
| 31 |
id: string;
|
| 32 |
role: "user" | "assistant";
|
| 33 |
content: string;
|
| 34 |
timestamp: number;
|
| 35 |
+
sources?: ChatSource[];
|
| 36 |
}
|
| 37 |
|
| 38 |
+
interface ChatRoom {
|
| 39 |
id: string;
|
| 40 |
title: string;
|
| 41 |
messages: Message[];
|
| 42 |
+
createdAt: string;
|
| 43 |
+
updatedAt: string | null;
|
| 44 |
}
|
| 45 |
|
| 46 |
export default function Main() {
|
| 47 |
const navigate = useNavigate();
|
| 48 |
const [sidebarOpen, setSidebarOpen] = useState(true);
|
| 49 |
+
const [chats, setChats] = useState<ChatRoom[]>([]);
|
| 50 |
const [currentChatId, setCurrentChatId] = useState<string | null>(null);
|
| 51 |
const [input, setInput] = useState("");
|
| 52 |
const [isStreaming, setIsStreaming] = useState(false);
|
| 53 |
+
const [roomsLoading, setRoomsLoading] = useState(false);
|
| 54 |
+
const [roomsError, setRoomsError] = useState<string | null>(null);
|
| 55 |
const messagesEndRef = useRef<HTMLDivElement>(null);
|
| 56 |
+
const [user, setUser] = useState<StoredUser | null>(null);
|
| 57 |
const [knowledgeOpen, setKnowledgeOpen] = useState(false);
|
| 58 |
+
const abortControllerRef = useRef<AbortController | null>(null);
|
| 59 |
|
| 60 |
useEffect(() => {
|
| 61 |
const storedUser = localStorage.getItem("chatbot_user");
|
| 62 |
if (storedUser) {
|
| 63 |
+
const parsedUser: StoredUser = JSON.parse(storedUser);
|
| 64 |
+
setUser(parsedUser);
|
| 65 |
+
loadRooms(parsedUser.user_id);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
}
|
| 67 |
}, []);
|
| 68 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
useEffect(() => {
|
| 70 |
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
| 71 |
}, [currentChatId, chats]);
|
| 72 |
|
| 73 |
+
const loadRooms = async (userId: string) => {
|
| 74 |
+
setRoomsLoading(true);
|
| 75 |
+
setRoomsError(null);
|
| 76 |
+
try {
|
| 77 |
+
const apiRooms = await getRooms(userId);
|
| 78 |
+
const mapped: ChatRoom[] = apiRooms.map((r) => ({
|
| 79 |
+
id: r.id,
|
| 80 |
+
title: r.title,
|
| 81 |
+
messages: [],
|
| 82 |
+
createdAt: r.created_at,
|
| 83 |
+
updatedAt: r.updated_at,
|
| 84 |
+
}));
|
| 85 |
+
setChats(mapped);
|
| 86 |
+
if (mapped.length > 0) {
|
| 87 |
+
setCurrentChatId(mapped[0].id);
|
| 88 |
+
}
|
| 89 |
+
} catch (err) {
|
| 90 |
+
setRoomsError(
|
| 91 |
+
err instanceof Error ? err.message : "Failed to load chats"
|
| 92 |
+
);
|
| 93 |
+
} finally {
|
| 94 |
+
setRoomsLoading(false);
|
| 95 |
+
}
|
| 96 |
+
};
|
| 97 |
+
|
| 98 |
const currentChat = chats.find((chat) => chat.id === currentChatId);
|
| 99 |
|
| 100 |
const createNewChat = () => {
|
| 101 |
+
setCurrentChatId(null);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
};
|
| 103 |
|
| 104 |
const deleteChat = (chatId: string) => {
|
|
|
|
| 112 |
const deleteAllChats = () => {
|
| 113 |
setChats([]);
|
| 114 |
setCurrentChatId(null);
|
|
|
|
| 115 |
};
|
| 116 |
|
| 117 |
const handleLogout = () => {
|
|
|
|
| 119 |
navigate("/login");
|
| 120 |
};
|
| 121 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
const handleSend = async () => {
|
| 123 |
+
if (!input.trim() || isStreaming || !user) return;
|
| 124 |
+
|
| 125 |
+
let roomId = currentChatId;
|
| 126 |
+
|
| 127 |
+
if (!roomId) {
|
| 128 |
+
try {
|
| 129 |
+
const res = await createRoom(user.user_id, input.slice(0, 50));
|
| 130 |
+
const newRoom: ChatRoom = {
|
| 131 |
+
id: res.data.id,
|
| 132 |
+
title: res.data.title,
|
| 133 |
+
messages: [],
|
| 134 |
+
createdAt: res.data.created_at,
|
| 135 |
+
updatedAt: res.data.updated_at,
|
| 136 |
+
};
|
| 137 |
+
setChats((prev) => [newRoom, ...prev]);
|
| 138 |
+
roomId = newRoom.id;
|
| 139 |
+
setCurrentChatId(roomId);
|
| 140 |
+
} catch {
|
| 141 |
+
return;
|
| 142 |
+
}
|
| 143 |
}
|
| 144 |
|
| 145 |
const userMessage: Message = {
|
| 146 |
+
id: crypto.randomUUID(),
|
| 147 |
role: "user",
|
| 148 |
content: input,
|
| 149 |
timestamp: Date.now(),
|
| 150 |
};
|
| 151 |
|
| 152 |
+
setChats((prev) =>
|
| 153 |
+
prev.map((chat) =>
|
| 154 |
+
chat.id === roomId
|
| 155 |
+
? {
|
| 156 |
+
...chat,
|
| 157 |
+
messages: [...chat.messages, userMessage],
|
| 158 |
+
updatedAt: new Date().toISOString(),
|
| 159 |
+
}
|
| 160 |
+
: chat
|
| 161 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 162 |
);
|
| 163 |
|
| 164 |
+
const sentMessage = input;
|
| 165 |
setInput("");
|
| 166 |
setIsStreaming(true);
|
| 167 |
|
| 168 |
+
const assistantMsgId = crypto.randomUUID();
|
| 169 |
+
|
| 170 |
+
setChats((prev) =>
|
| 171 |
+
prev.map((chat) =>
|
| 172 |
+
chat.id === roomId
|
| 173 |
+
? {
|
| 174 |
+
...chat,
|
| 175 |
+
messages: [
|
| 176 |
+
...chat.messages,
|
| 177 |
+
{
|
| 178 |
+
id: assistantMsgId,
|
| 179 |
+
role: "assistant",
|
| 180 |
+
content: "",
|
| 181 |
+
timestamp: Date.now(),
|
| 182 |
+
sources: [],
|
| 183 |
+
},
|
| 184 |
+
],
|
| 185 |
+
}
|
| 186 |
+
: chat
|
| 187 |
+
)
|
| 188 |
+
);
|
| 189 |
+
|
| 190 |
+
abortControllerRef.current = new AbortController();
|
| 191 |
+
|
| 192 |
+
try {
|
| 193 |
+
const response = await streamChat(user.user_id, roomId, sentMessage);
|
| 194 |
+
|
| 195 |
+
if (!response.body) throw new Error("No response body");
|
| 196 |
+
|
| 197 |
+
const reader = response.body.getReader();
|
| 198 |
+
const decoder = new TextDecoder();
|
| 199 |
+
let buffer = "";
|
| 200 |
+
let currentEvent = "";
|
| 201 |
+
|
| 202 |
+
while (true) {
|
| 203 |
+
const { done, value } = await reader.read();
|
| 204 |
+
if (done) break;
|
| 205 |
+
|
| 206 |
+
buffer += decoder.decode(value, { stream: true });
|
| 207 |
+
const lines = buffer.split("\n");
|
| 208 |
+
buffer = lines.pop() ?? "";
|
| 209 |
+
|
| 210 |
+
for (const line of lines) {
|
| 211 |
+
if (line.startsWith("event:")) {
|
| 212 |
+
currentEvent = line.replace("event:", "").trim();
|
| 213 |
+
} else if (line.startsWith("data:")) {
|
| 214 |
+
const data = line.replace("data:", "").trim();
|
| 215 |
+
|
| 216 |
+
if (currentEvent === "sources" && data) {
|
| 217 |
+
const sources: ChatSource[] = JSON.parse(data);
|
| 218 |
+
setChats((prev) =>
|
| 219 |
+
prev.map((chat) =>
|
| 220 |
+
chat.id === roomId
|
| 221 |
+
? {
|
| 222 |
+
...chat,
|
| 223 |
+
messages: chat.messages.map((m) =>
|
| 224 |
+
m.id === assistantMsgId ? { ...m, sources } : m
|
| 225 |
+
),
|
| 226 |
+
}
|
| 227 |
+
: chat
|
| 228 |
+
)
|
| 229 |
+
);
|
| 230 |
+
} else if (currentEvent === "chunk" && data) {
|
| 231 |
+
setChats((prev) =>
|
| 232 |
+
prev.map((chat) =>
|
| 233 |
+
chat.id === roomId
|
| 234 |
+
? {
|
| 235 |
+
...chat,
|
| 236 |
+
messages: chat.messages.map((m) =>
|
| 237 |
+
m.id === assistantMsgId
|
| 238 |
+
? { ...m, content: m.content + data }
|
| 239 |
+
: m
|
| 240 |
+
),
|
| 241 |
+
}
|
| 242 |
+
: chat
|
| 243 |
+
)
|
| 244 |
+
);
|
| 245 |
+
} else if (currentEvent === "message" && data) {
|
| 246 |
+
setChats((prev) =>
|
| 247 |
+
prev.map((chat) =>
|
| 248 |
+
chat.id === roomId
|
| 249 |
+
? {
|
| 250 |
+
...chat,
|
| 251 |
+
messages: chat.messages.map((m) =>
|
| 252 |
+
m.id === assistantMsgId
|
| 253 |
+
? { ...m, content: data }
|
| 254 |
+
: m
|
| 255 |
+
),
|
| 256 |
+
}
|
| 257 |
+
: chat
|
| 258 |
+
)
|
| 259 |
+
);
|
| 260 |
+
}
|
| 261 |
+
}
|
| 262 |
+
}
|
| 263 |
+
}
|
| 264 |
+
} catch (err: unknown) {
|
| 265 |
+
if ((err as Error).name !== "AbortError") {
|
| 266 |
+
setChats((prev) =>
|
| 267 |
+
prev.map((chat) =>
|
| 268 |
+
chat.id === roomId
|
| 269 |
+
? {
|
| 270 |
+
...chat,
|
| 271 |
+
messages: chat.messages.map((m) =>
|
| 272 |
+
m.id === assistantMsgId
|
| 273 |
+
? {
|
| 274 |
+
...m,
|
| 275 |
+
content:
|
| 276 |
+
"Error: Failed to get response. Please try again.",
|
| 277 |
+
}
|
| 278 |
+
: m
|
| 279 |
+
),
|
| 280 |
+
}
|
| 281 |
+
: chat
|
| 282 |
+
)
|
| 283 |
+
);
|
| 284 |
+
}
|
| 285 |
+
} finally {
|
| 286 |
+
setIsStreaming(false);
|
| 287 |
+
abortControllerRef.current = null;
|
| 288 |
+
}
|
| 289 |
};
|
| 290 |
|
| 291 |
const handleKeyPress = (e: React.KeyboardEvent) => {
|
|
|
|
| 314 |
</div>
|
| 315 |
|
| 316 |
<div className="flex-1 overflow-y-auto p-2 space-y-1">
|
| 317 |
+
{roomsLoading ? (
|
| 318 |
+
<div className="flex justify-center py-4">
|
| 319 |
+
<Loader2 className="w-4 h-4 animate-spin text-white/70" />
|
| 320 |
+
</div>
|
| 321 |
+
) : roomsError ? (
|
| 322 |
+
<p className="text-xs text-red-200 text-center px-2 py-2">
|
| 323 |
+
{roomsError}
|
| 324 |
+
</p>
|
| 325 |
+
) : (
|
| 326 |
+
chats.map((chat) => (
|
| 327 |
<div
|
| 328 |
+
key={chat.id}
|
| 329 |
+
className={`flex items-center gap-2 p-2 rounded-lg cursor-pointer transition group ${
|
| 330 |
+
currentChatId === chat.id
|
| 331 |
+
? "bg-white/25"
|
| 332 |
+
: "hover:bg-white/15"
|
| 333 |
+
}`}
|
| 334 |
>
|
| 335 |
+
<MessageSquare className="w-3.5 h-3.5 flex-shrink-0" />
|
| 336 |
+
<div
|
| 337 |
+
className="flex-1 truncate text-sm"
|
| 338 |
+
onClick={() => setCurrentChatId(chat.id)}
|
| 339 |
+
>
|
| 340 |
+
{chat.title}
|
| 341 |
+
</div>
|
| 342 |
+
<button
|
| 343 |
+
onClick={(e) => {
|
| 344 |
+
e.stopPropagation();
|
| 345 |
+
deleteChat(chat.id);
|
| 346 |
+
}}
|
| 347 |
+
className="opacity-0 group-hover:opacity-100 transition"
|
| 348 |
+
>
|
| 349 |
+
<Trash2 className="w-3.5 h-3.5 text-red-100 hover:text-white" />
|
| 350 |
+
</button>
|
| 351 |
</div>
|
| 352 |
+
))
|
| 353 |
+
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 354 |
</div>
|
| 355 |
|
| 356 |
<div className="border-t border-white/20 p-3 space-y-2">
|
|
|
|
| 444 |
<p className="whitespace-pre-wrap break-words text-sm">
|
| 445 |
{message.content}
|
| 446 |
</p>
|
| 447 |
+
{message.role === "assistant" &&
|
| 448 |
+
message.sources &&
|
| 449 |
+
message.sources.length > 0 && (
|
| 450 |
+
<div className="mt-2 pt-2 border-t border-slate-100">
|
| 451 |
+
<p className="text-[10px] text-slate-400 mb-1">
|
| 452 |
+
Sources:
|
| 453 |
+
</p>
|
| 454 |
+
<div className="flex flex-wrap gap-1">
|
| 455 |
+
{message.sources.map((src, i) => (
|
| 456 |
+
<span
|
| 457 |
+
key={i}
|
| 458 |
+
className="text-[10px] bg-slate-100 text-slate-600 px-1.5 py-0.5 rounded"
|
| 459 |
+
title={
|
| 460 |
+
src.page_label
|
| 461 |
+
? `Page ${src.page_label}`
|
| 462 |
+
: undefined
|
| 463 |
+
}
|
| 464 |
+
>
|
| 465 |
+
{src.filename}
|
| 466 |
+
{src.page_label ? ` p.${src.page_label}` : ""}
|
| 467 |
+
</span>
|
| 468 |
+
))}
|
| 469 |
+
</div>
|
| 470 |
+
</div>
|
| 471 |
+
)}
|
| 472 |
</div>
|
| 473 |
</div>
|
| 474 |
))}
|
| 475 |
|
| 476 |
+
{!currentChat && chats.length === 0 && !roomsLoading && (
|
| 477 |
<div className="flex items-center justify-center h-full">
|
| 478 |
<div className="text-center">
|
| 479 |
<MessageSquare className="w-12 h-12 text-slate-300 mx-auto mb-3" />
|
|
|
|
| 511 |
<Send className="w-4 h-4" />
|
| 512 |
</button>
|
| 513 |
</div>
|
|
|
|
|
|
|
|
|
|
| 514 |
</div>
|
| 515 |
</div>
|
| 516 |
</div>
|
src/services/api.ts
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// βββ Types ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 2 |
+
|
| 3 |
+
export interface LoginResponse {
|
| 4 |
+
status: string;
|
| 5 |
+
message: string;
|
| 6 |
+
data: { user_id: string; email: string; name: string };
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
export interface Room {
|
| 10 |
+
id: string;
|
| 11 |
+
title: string;
|
| 12 |
+
created_at: string;
|
| 13 |
+
updated_at: string | null;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
export interface CreateRoomResponse {
|
| 17 |
+
status: string;
|
| 18 |
+
message: string;
|
| 19 |
+
data: Room;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
export type DocumentStatus = "pending" | "processing" | "completed" | "failed";
|
| 23 |
+
|
| 24 |
+
export interface ApiDocument {
|
| 25 |
+
id: string;
|
| 26 |
+
filename: string;
|
| 27 |
+
status: DocumentStatus;
|
| 28 |
+
file_size: number;
|
| 29 |
+
file_type: string;
|
| 30 |
+
created_at: string;
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
export interface UploadDocumentResponse {
|
| 34 |
+
status: string;
|
| 35 |
+
message: string;
|
| 36 |
+
data: { id: string; filename: string; status: DocumentStatus };
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
export interface ChatSource {
|
| 40 |
+
document_id: string;
|
| 41 |
+
filename: string;
|
| 42 |
+
page_label: string | null;
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
// βββ Base Client ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 46 |
+
|
| 47 |
+
const BASE_URL = (import.meta.env.VITE_API_BASE_URL as string) ?? "";
|
| 48 |
+
|
| 49 |
+
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
| 50 |
+
const res = await fetch(`${BASE_URL}${path}`, {
|
| 51 |
+
headers: { "Content-Type": "application/json", ...options?.headers },
|
| 52 |
+
...options,
|
| 53 |
+
});
|
| 54 |
+
if (!res.ok) {
|
| 55 |
+
const err = await res
|
| 56 |
+
.json()
|
| 57 |
+
.catch(() => ({ detail: `HTTP ${res.status}` }));
|
| 58 |
+
throw new Error(err.detail ?? `HTTP ${res.status}`);
|
| 59 |
+
}
|
| 60 |
+
return res.json() as Promise<T>;
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
// βββ Auth βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 64 |
+
|
| 65 |
+
export const login = (email: string, password: string) =>
|
| 66 |
+
request<LoginResponse>("/api/login", {
|
| 67 |
+
method: "POST",
|
| 68 |
+
body: JSON.stringify({ email, password }),
|
| 69 |
+
});
|
| 70 |
+
|
| 71 |
+
// βββ Rooms ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 72 |
+
|
| 73 |
+
export const getRooms = (userId: string) =>
|
| 74 |
+
request<Room[]>(`/api/v1/rooms/${userId}`);
|
| 75 |
+
|
| 76 |
+
export const createRoom = (userId: string, title?: string) =>
|
| 77 |
+
request<CreateRoomResponse>("/api/v1/room/create", {
|
| 78 |
+
method: "POST",
|
| 79 |
+
body: JSON.stringify({ user_id: userId, title }),
|
| 80 |
+
});
|
| 81 |
+
|
| 82 |
+
// βββ Documents ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 83 |
+
|
| 84 |
+
export const getDocuments = (userId: string) =>
|
| 85 |
+
request<ApiDocument[]>(`/api/v1/documents/${userId}`);
|
| 86 |
+
|
| 87 |
+
export const uploadDocument = async (
|
| 88 |
+
userId: string,
|
| 89 |
+
file: File
|
| 90 |
+
): Promise<UploadDocumentResponse> => {
|
| 91 |
+
const form = new FormData();
|
| 92 |
+
form.append("file", file);
|
| 93 |
+
const res = await fetch(
|
| 94 |
+
`${BASE_URL}/api/v1/document/upload?user_id=${userId}`,
|
| 95 |
+
{ method: "POST", body: form }
|
| 96 |
+
);
|
| 97 |
+
if (!res.ok) {
|
| 98 |
+
const err = await res
|
| 99 |
+
.json()
|
| 100 |
+
.catch(() => ({ detail: `HTTP ${res.status}` }));
|
| 101 |
+
throw new Error(err.detail ?? `HTTP ${res.status}`);
|
| 102 |
+
}
|
| 103 |
+
return res.json() as Promise<UploadDocumentResponse>;
|
| 104 |
+
};
|
| 105 |
+
|
| 106 |
+
export const processDocument = (userId: string, documentId: string) =>
|
| 107 |
+
request<{
|
| 108 |
+
status: string;
|
| 109 |
+
message: string;
|
| 110 |
+
data: { document_id: string; chunks_processed: number };
|
| 111 |
+
}>(
|
| 112 |
+
`/api/v1/document/process?document_id=${documentId}&user_id=${userId}`,
|
| 113 |
+
{ method: "POST" }
|
| 114 |
+
);
|
| 115 |
+
|
| 116 |
+
export const deleteDocument = (userId: string, documentId: string) =>
|
| 117 |
+
request<{ status: string; message: string }>(
|
| 118 |
+
`/api/v1/document/delete?document_id=${documentId}&user_id=${userId}`,
|
| 119 |
+
{ method: "DELETE" }
|
| 120 |
+
);
|
| 121 |
+
|
| 122 |
+
// βββ Chat βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 123 |
+
|
| 124 |
+
export const streamChat = (
|
| 125 |
+
userId: string,
|
| 126 |
+
roomId: string,
|
| 127 |
+
message: string
|
| 128 |
+
): Promise<Response> =>
|
| 129 |
+
fetch(`${BASE_URL}/api/v1/chat/stream`, {
|
| 130 |
+
method: "POST",
|
| 131 |
+
headers: { "Content-Type": "application/json" },
|
| 132 |
+
body: JSON.stringify({ user_id: userId, room_id: roomId, message }),
|
| 133 |
+
});
|