This commit is contained in:
Anibal Angulo
2025-11-07 09:41:18 -06:00
parent cafe0bf5f3
commit af9b5fed01
21 changed files with 3065 additions and 266 deletions

View File

@@ -8,6 +8,7 @@
"name": "frontend",
"version": "0.0.0",
"dependencies": {
"@ai-sdk/react": "^2.0.89",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
@@ -110,6 +111,30 @@
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/@ai-sdk/react": {
"version": "2.0.89",
"resolved": "https://registry.npmjs.org/@ai-sdk/react/-/react-2.0.89.tgz",
"integrity": "sha512-r2uCqx042JOjNrSlDrjh7ufSIfU2BM6Lo4qe47KHkYuJjPfssxhLpJUCFLB01iV7Foyn/xpbq06Zr6WI4qUDgw==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider-utils": "3.0.16",
"ai": "5.0.89",
"swr": "^2.2.5",
"throttleit": "2.1.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"react": "^18 || ^19 || ^19.0.0-rc",
"zod": "^3.25.76 || ^4.1.8"
},
"peerDependenciesMeta": {
"zod": {
"optional": true
}
}
},
"node_modules/@alloc/quick-lru": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
@@ -8818,6 +8843,19 @@
"node": ">=8"
}
},
"node_modules/swr": {
"version": "2.3.6",
"resolved": "https://registry.npmjs.org/swr/-/swr-2.3.6.tgz",
"integrity": "sha512-wfHRmHWk/isGNMwlLGlZX5Gzz/uTgo0o2IRuTMcf4CPuPFJZlq0rDaKUx+ozB5nBOReNV1kiOyzMfj+MBMikLw==",
"license": "MIT",
"dependencies": {
"dequal": "^2.0.3",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/tailwind-merge": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.3.1.tgz",
@@ -8857,6 +8895,18 @@
"url": "https://opencollective.com/webpack"
}
},
"node_modules/throttleit": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/throttleit/-/throttleit-2.1.0.tgz",
"integrity": "sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",

View File

@@ -10,6 +10,7 @@
"preview": "vite preview"
},
"dependencies": {
"@ai-sdk/react": "^2.0.89",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",

View File

@@ -0,0 +1,241 @@
import {
Conversation,
ConversationContent,
ConversationScrollButton,
} from "@/components/ai-elements/conversation";
import { Message, MessageContent } from "@/components/ai-elements/message";
import {
PromptInput,
PromptInputActionAddAttachments,
PromptInputActionMenu,
PromptInputActionMenuContent,
PromptInputActionMenuTrigger,
PromptInputAttachment,
PromptInputAttachments,
PromptInputBody,
PromptInputButton,
PromptInputHeader,
type PromptInputMessage,
PromptInputSelect,
PromptInputSelectContent,
PromptInputSelectItem,
PromptInputSelectTrigger,
PromptInputSelectValue,
PromptInputSubmit,
PromptInputTextarea,
PromptInputFooter,
PromptInputTools,
} from "@/components/ai-elements/prompt-input";
import { Action, Actions } from "@/components/ai-elements/actions";
import { Fragment, useState } from "react";
import { useChat } from "@ai-sdk/react";
import { Response } from "@/components/ai-elements/response";
import { CopyIcon, GlobeIcon, RefreshCcwIcon } from "lucide-react";
import {
Source,
Sources,
SourcesContent,
SourcesTrigger,
} from "@/components/ai-elements/sources";
import {
Reasoning,
ReasoningContent,
ReasoningTrigger,
} from "@/components/ai-elements/reasoning";
import { Loader } from "@/components/ai-elements/loader";
import { DefaultChatTransport } from "ai";
const models = [
{
name: "GPT 4o",
value: "openai/gpt-4o",
},
{
name: "Deepseek R1",
value: "deepseek/deepseek-r1",
},
];
const ChatBotDemo = () => {
const [input, setInput] = useState("");
const [model, setModel] = useState<string>(models[0].value);
const [webSearch, setWebSearch] = useState(false);
const { messages, sendMessage, status, regenerate } = useChat({
transport: new DefaultChatTransport({
api: "/api/v1/chat",
}),
});
const handleSubmit = (message: PromptInputMessage) => {
const hasText = Boolean(message.text);
const hasAttachments = Boolean(message.files?.length);
if (!(hasText || hasAttachments)) {
return;
}
sendMessage(
{
text: message.text || "Sent with attachments",
files: message.files,
},
{
body: {
model: model,
webSearch: webSearch,
},
},
);
setInput("");
};
return (
<div className="max-w-4xl mx-auto p-6 relative size-full h-screen">
<div className="flex flex-col h-full">
<Conversation className="h-full">
<ConversationContent>
{messages.map((message) => (
<div key={message.id}>
{message.role === "assistant" &&
message.parts.filter((part) => part.type === "source-url")
.length > 0 && (
<Sources>
<SourcesTrigger
count={
message.parts.filter(
(part) => part.type === "source-url",
).length
}
/>
{message.parts
.filter((part) => part.type === "source-url")
.map((part, i) => (
<SourcesContent key={`${message.id}-${i}`}>
<Source
key={`${message.id}-${i}`}
href={part.url}
title={part.url}
/>
</SourcesContent>
))}
</Sources>
)}
{message.parts.map((part, i) => {
switch (part.type) {
case "text":
return (
<Fragment key={`${message.id}-${i}`}>
<Message from={message.role}>
<MessageContent>
<Response>{part.text}</Response>
</MessageContent>
</Message>
{message.role === "assistant" &&
i === messages.length - 1 && (
<Actions className="mt-2">
<Action
onClick={() => regenerate()}
label="Retry"
>
<RefreshCcwIcon className="size-3" />
</Action>
<Action
onClick={() =>
navigator.clipboard.writeText(part.text)
}
label="Copy"
>
<CopyIcon className="size-3" />
</Action>
</Actions>
)}
</Fragment>
);
case "reasoning":
return (
<Reasoning
key={`${message.id}-${i}`}
className="w-full"
isStreaming={
status === "streaming" &&
i === message.parts.length - 1 &&
message.id === messages.at(-1)?.id
}
>
<ReasoningTrigger />
<ReasoningContent>{part.text}</ReasoningContent>
</Reasoning>
);
default:
return null;
}
})}
</div>
))}
{status === "submitted" && <Loader />}
</ConversationContent>
<ConversationScrollButton />
</Conversation>
<PromptInput
onSubmit={handleSubmit}
className="mt-4"
globalDrop
multiple
>
<PromptInputHeader>
<PromptInputAttachments>
{(attachment) => <PromptInputAttachment data={attachment} />}
</PromptInputAttachments>
</PromptInputHeader>
<PromptInputBody>
<PromptInputTextarea
onChange={(e) => setInput(e.target.value)}
value={input}
/>
</PromptInputBody>
<PromptInputFooter>
<PromptInputTools>
<PromptInputActionMenu>
<PromptInputActionMenuTrigger />
<PromptInputActionMenuContent>
<PromptInputActionAddAttachments />
</PromptInputActionMenuContent>
</PromptInputActionMenu>
<PromptInputButton
variant={webSearch ? "default" : "ghost"}
onClick={() => setWebSearch(!webSearch)}
>
<GlobeIcon size={16} />
<span>Search</span>
</PromptInputButton>
<PromptInputSelect
onValueChange={(value) => {
setModel(value);
}}
value={model}
>
<PromptInputSelectTrigger>
<PromptInputSelectValue />
</PromptInputSelectTrigger>
<PromptInputSelectContent>
{models.map((model) => (
<PromptInputSelectItem
key={model.value}
value={model.value}
>
{model.name}
</PromptInputSelectItem>
))}
</PromptInputSelectContent>
</PromptInputSelect>
</PromptInputTools>
<PromptInputSubmit disabled={!input && !status} status={status} />
</PromptInputFooter>
</PromptInput>
</div>
</div>
);
};
export default ChatBotDemo;

View File

@@ -1,12 +1,94 @@
import { MessageCircle, Send, Bot, User } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Conversation,
ConversationContent,
ConversationScrollButton,
} from "@/components/ai-elements/conversation";
import { Message, MessageContent } from "@/components/ai-elements/message";
import {
PromptInput,
PromptInputActionAddAttachments,
PromptInputActionMenu,
PromptInputActionMenuContent,
PromptInputActionMenuTrigger,
PromptInputAttachment,
PromptInputAttachments,
PromptInputBody,
PromptInputHeader,
type PromptInputMessage,
PromptInputSubmit,
PromptInputTextarea,
PromptInputFooter,
PromptInputTools,
} from "@/components/ai-elements/prompt-input";
import { Action, Actions } from "@/components/ai-elements/actions";
import { Fragment, useState, useEffect } from "react";
import { useChat } from "@ai-sdk/react";
import { Response } from "@/components/ai-elements/response";
import {
CopyIcon,
RefreshCcwIcon,
MessageCircle,
Bot,
AlertCircle,
PaperclipIcon,
} from "lucide-react";
import { Loader } from "@/components/ai-elements/loader";
import { DefaultChatTransport } from "ai";
interface ChatTabProps {
selectedTema: string | null;
}
export function ChatTab({ selectedTema }: ChatTabProps) {
const [input, setInput] = useState("");
const [error, setError] = useState<string | null>(null);
const {
messages,
sendMessage,
status,
regenerate,
error: chatError,
} = useChat({
transport: new DefaultChatTransport({
api: "/api/v1/agent/chat",
}),
onError: (error) => {
setError(`Error en el chat: ${error.message}`);
},
});
// Clear error when starting new conversation
useEffect(() => {
if (status === "streaming") {
setError(null);
}
}, [status]);
const handleSubmit = (message: PromptInputMessage) => {
const hasText = Boolean(message.text?.trim());
const hasAttachments = Boolean(message.files?.length);
if (!(hasText || hasAttachments)) {
return;
}
setError(null);
sendMessage(
{
text: message.text || "Enviado con archivos adjuntos",
files: message.files,
},
{
body: {
dataroom: selectedTema,
context: `Usuario está consultando sobre el dataroom: ${selectedTema}`,
},
},
);
setInput("");
};
if (!selectedTema) {
return (
<div className="flex flex-col items-center justify-center h-64">
@@ -37,55 +119,137 @@ export function ChatTab({ selectedTema }: ChatTabProps) {
</div>
</div>
{/* Chat Messages Area */}
<div className="flex-1 overflow-y-auto p-6">
<div className="max-w-4xl mx-auto space-y-4">
{/* Welcome Message */}
<div className="flex items-start gap-3">
<div className="p-2 bg-blue-100 rounded-full">
<Bot className="w-4 h-4 text-blue-600" />
</div>
<div className="flex-1 bg-gray-50 rounded-lg p-4">
<p className="text-sm text-gray-800">
¡Hola! Soy tu asistente de IA para el dataroom <strong>{selectedTema}</strong>.
Puedes hacerme preguntas sobre los documentos almacenados aquí.
</p>
</div>
</div>
{/* Chat Content */}
<div className="flex-1 flex flex-col">
<Conversation className="flex-1">
<ConversationContent className="p-6">
<div className="max-w-4xl mx-auto w-full">
{/* Welcome Message */}
{messages.length === 0 && (
<div className="flex items-start gap-3 mb-6">
<div className="p-2 bg-blue-100 rounded-full">
<Bot className="w-4 h-4 text-blue-600" />
</div>
<div className="flex-1 bg-gray-50 rounded-lg p-4">
<p className="text-sm text-gray-800">
¡Hola! Soy tu asistente de IA para el dataroom{" "}
<strong>{selectedTema}</strong>. Puedes hacerme preguntas
sobre los documentos almacenados aquí.
</p>
</div>
</div>
)}
{/* Placeholder for future messages */}
<div className="text-center py-8">
<MessageCircle className="w-16 h-16 text-gray-300 mx-auto mb-4" />
<h4 className="text-lg font-medium text-gray-900 mb-2">
Chat Inteligente
</h4>
<p className="text-gray-500 max-w-md mx-auto">
El chat estará disponible próximamente. Podrás hacer preguntas sobre los
documentos y obtener respuestas basadas en el contenido del dataroom.
</p>
</div>
</div>
</div>
{/* Error Message */}
{error && (
<div className="flex items-start gap-3 mb-4">
<div className="p-2 bg-red-100 rounded-full">
<AlertCircle className="w-4 h-4 text-red-600" />
</div>
<div className="flex-1 bg-red-50 rounded-lg p-4 border border-red-200">
<p className="text-sm text-red-800">{error}</p>
</div>
</div>
)}
{/* Chat Input Area */}
<div className="border-t border-gray-200 p-6">
<div className="max-w-4xl mx-auto">
<div className="flex items-center gap-3">
<div className="flex-1">
<Input
{/* Chat Messages */}
{messages.map((message) => (
<div key={message.id}>
{message.parts.map((part, i) => {
switch (part.type) {
case "text":
return (
<Fragment key={`${message.id}-${i}`}>
<Message from={message.role} className="max-w-none">
<MessageContent>
<Response>{part.text}</Response>
</MessageContent>
</Message>
{message.role === "assistant" &&
i === message.parts.length - 1 && (
<Actions className="mt-2">
<Action
onClick={() => regenerate()}
label="Regenerar"
disabled={status === "streaming"}
>
<RefreshCcwIcon className="size-3" />
</Action>
<Action
onClick={() =>
navigator.clipboard.writeText(part.text)
}
label="Copiar"
>
<CopyIcon className="size-3" />
</Action>
</Actions>
)}
</Fragment>
);
default:
return null;
}
})}
</div>
))}
{status === "streaming" && <Loader />}
{status === "loading" && <Loader />}
</div>
</ConversationContent>
<ConversationScrollButton />
</Conversation>
{/* Chat Input */}
<div className="border-t border-gray-200 p-6 bg-gray-50/50">
<PromptInput
onSubmit={handleSubmit}
className="max-w-4xl mx-auto border-2 border-gray-200 rounded-xl focus-within:border-slate-500 transition-colors duration-200 bg-white"
globalDrop
multiple
>
<PromptInputHeader className="p-2 pb-0">
<PromptInputAttachments>
{(attachment) => <PromptInputAttachment data={attachment} />}
</PromptInputAttachments>
</PromptInputHeader>
<PromptInputBody>
<PromptInputTextarea
onChange={(e) => setInput(e.target.value)}
value={input}
placeholder={`Pregunta algo sobre ${selectedTema}...`}
disabled
className="w-full"
disabled={status === "streaming" || status === "loading"}
className="min-h-[60px] resize-none border-0 focus:ring-0 transition-all duration-200 text-base px-4 py-3 bg-white rounded-xl"
/>
</div>
<Button disabled className="gap-2">
<Send className="w-4 h-4" />
Enviar
</Button>
</div>
<p className="text-xs text-gray-500 mt-2">
Esta funcionalidad estará disponible próximamente
</p>
</PromptInputBody>
<PromptInputFooter className="mt-3 flex justify-between items-center">
<PromptInputTools>
<PromptInputActionMenu>
<PromptInputActionMenuTrigger>
<PaperclipIcon className="size-4" />
</PromptInputActionMenuTrigger>
<PromptInputActionMenuContent>
<PromptInputActionAddAttachments />
</PromptInputActionMenuContent>
</PromptInputActionMenu>
</PromptInputTools>
<PromptInputSubmit
disabled={
(!input.trim() && !status) ||
status === "streaming" ||
status === "loading"
}
status={status}
className={`rounded-full px-6 py-2 font-medium transition-all duration-200 flex items-center gap-2 ${
(!input.trim() && !status) ||
status === "streaming" ||
status === "loading"
? "bg-gray-300 cursor-not-allowed text-gray-500"
: "bg-blue-600 hover:bg-blue-700 text-white"
}`}
/>
</PromptInputFooter>
</PromptInput>
</div>
</div>
</div>

View File

@@ -1,10 +1,91 @@
import { FileText, Users, Database, Activity } from "lucide-react";
import { useState, useEffect } from "react";
import {
FileText,
Database,
Activity,
TrendingUp,
AlertCircle,
CheckCircle,
Loader2,
} from "lucide-react";
import { api } from "@/services/api";
interface DashboardTabProps {
selectedTema: string | null;
}
interface DataroomInfo {
name: string;
collection: string;
storage: string;
file_count: number;
total_size_bytes: number;
total_size_mb: number;
collection_exists: boolean;
vector_count: number | null;
collection_info: {
vectors_count: number;
indexed_vectors_count: number;
points_count: number;
segments_count: number;
status: string;
} | null;
file_types: Record<string, number>;
recent_files: Array<{
name: string;
size_mb: number;
last_modified: string;
}>;
}
export function DashboardTab({ selectedTema }: DashboardTabProps) {
const [dataroomInfo, setDataroomInfo] = useState<DataroomInfo | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (selectedTema) {
fetchDataroomInfo();
}
}, [selectedTema]);
const fetchDataroomInfo = async () => {
if (!selectedTema) return;
setLoading(true);
setError(null);
try {
const info = await api.getDataroomInfo(selectedTema);
setDataroomInfo(info);
} catch (err) {
const errorMessage =
err instanceof Error ? err.message : "Error desconocido";
setError(`Error cargando información: ${errorMessage}`);
console.error("Error fetching dataroom info:", err);
} finally {
setLoading(false);
}
};
const formatFileTypes = (fileTypes: Record<string, number>) => {
const entries = Object.entries(fileTypes);
if (entries.length === 0) return "Sin archivos";
return entries
.sort(([, a], [, b]) => b - a) // Sort by count descending
.slice(0, 3) // Take top 3
.map(([ext, count]) => `${ext.toUpperCase()}: ${count}`)
.join(", ");
};
const formatBytes = (bytes: number) => {
if (bytes === 0) return "0 MB";
const mb = bytes / (1024 * 1024);
if (mb < 1) return `${(bytes / 1024).toFixed(1)} KB`;
return `${mb.toFixed(1)} MB`;
};
if (!selectedTema) {
return (
<div className="flex flex-col items-center justify-center h-64">
@@ -16,6 +97,40 @@ export function DashboardTab({ selectedTema }: DashboardTabProps) {
);
}
if (loading) {
return (
<div className="flex flex-col items-center justify-center h-64">
<Loader2 className="w-8 h-8 text-blue-600 animate-spin mb-4" />
<p className="text-gray-600">Cargando métricas...</p>
</div>
);
}
if (error) {
return (
<div className="p-6">
<div className="bg-red-50 border border-red-200 rounded-lg p-4 flex items-center gap-3">
<AlertCircle className="w-5 h-5 text-red-600 flex-shrink-0" />
<div>
<p className="text-sm font-medium text-red-800">Error</p>
<p className="text-sm text-red-600">{error}</p>
</div>
</div>
</div>
);
}
if (!dataroomInfo) {
return (
<div className="flex flex-col items-center justify-center h-64">
<AlertCircle className="w-12 h-12 text-gray-400 mb-4" />
<p className="text-gray-500">
No se pudo cargar la información del dataroom
</p>
</div>
);
}
return (
<div className="p-6">
<div className="mb-6">
@@ -36,7 +151,12 @@ export function DashboardTab({ selectedTema }: DashboardTabProps) {
</div>
<div>
<p className="text-sm font-medium text-gray-600">Archivos</p>
<p className="text-2xl font-bold text-gray-900">--</p>
<p className="text-2xl font-bold text-gray-900">
{dataroomInfo.file_count}
</p>
<p className="text-xs text-gray-500 mt-1">
{formatFileTypes(dataroomInfo.file_types)}
</p>
</div>
</div>
</div>
@@ -48,8 +168,15 @@ export function DashboardTab({ selectedTema }: DashboardTabProps) {
<Database className="w-5 h-5 text-green-600" />
</div>
<div>
<p className="text-sm font-medium text-gray-600">Almacenamiento</p>
<p className="text-2xl font-bold text-gray-900">--</p>
<p className="text-sm font-medium text-gray-600">
Almacenamiento
</p>
<p className="text-2xl font-bold text-gray-900">
{dataroomInfo.total_size_mb.toFixed(1)} MB
</p>
<p className="text-xs text-gray-500 mt-1">
{formatBytes(dataroomInfo.total_size_bytes)}
</p>
</div>
</div>
</div>
@@ -62,37 +189,139 @@ export function DashboardTab({ selectedTema }: DashboardTabProps) {
</div>
<div>
<p className="text-sm font-medium text-gray-600">Vectores</p>
<p className="text-2xl font-bold text-gray-900">--</p>
<p className="text-2xl font-bold text-gray-900">
{dataroomInfo.vector_count ?? 0}
</p>
<p className="text-xs text-gray-500 mt-1">
{dataroomInfo.collection_exists
? "Vectores indexados"
: "Sin vectores"}
</p>
</div>
</div>
</div>
{/* Activity Card */}
{/* Collection Status Card */}
<div className="bg-white border border-gray-200 rounded-lg p-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-orange-100 rounded-lg">
<Users className="w-5 h-5 text-orange-600" />
<TrendingUp className="w-5 h-5 text-orange-600" />
</div>
<div>
<p className="text-sm font-medium text-gray-600">Actividad</p>
<p className="text-2xl font-bold text-gray-900">--</p>
<p className="text-sm font-medium text-gray-600">Estado</p>
<div className="flex items-center gap-2">
<p className="text-2xl font-bold text-gray-900">
{dataroomInfo.collection_exists ? "Activo" : "Inactivo"}
</p>
{dataroomInfo.collection_exists ? (
<CheckCircle className="w-6 h-6 text-green-600" />
) : (
<AlertCircle className="w-6 h-6 text-yellow-600" />
)}
</div>
{dataroomInfo.collection_info ? (
<p className="text-xs text-gray-500 mt-1">
{dataroomInfo.collection_info.indexed_vectors_count}/
{dataroomInfo.collection_info.vectors_count} vectores
indexados
</p>
) : (
<p className="text-xs text-gray-500 mt-1">
{dataroomInfo.collection_exists
? "Colección sin datos"
: "Sin colección"}
</p>
)}
</div>
</div>
</div>
</div>
{/* Coming Soon Message */}
<div className="mt-8 bg-gray-50 border border-gray-200 rounded-lg p-6">
<div className="text-center">
<Activity className="w-8 h-8 text-gray-400 mx-auto mb-3" />
<h4 className="text-sm font-medium text-gray-900 mb-2">
Panel de Métricas
{/* Recent Files Section */}
{dataroomInfo.recent_files.length > 0 && (
<div className="mt-8">
<h4 className="text-md font-semibold text-gray-900 mb-4">
Archivos Recientes
</h4>
<p className="text-sm text-gray-500">
Este panel se llenará con métricas detalladas y gráficos interactivos próximamente.
</p>
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden">
<div className="divide-y divide-gray-200">
{dataroomInfo.recent_files.map((file, index) => (
<div
key={index}
className="p-4 flex items-center justify-between hover:bg-gray-50"
>
<div className="flex items-center gap-3">
<FileText className="w-4 h-4 text-gray-400" />
<div>
<p className="text-sm font-medium text-gray-900">
{file.name}
</p>
<p className="text-xs text-gray-500">
{new Date(file.last_modified).toLocaleDateString(
"es-ES",
{
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
},
)}
</p>
</div>
</div>
<div className="text-right">
<p className="text-sm text-gray-600">
{file.size_mb.toFixed(2)} MB
</p>
</div>
</div>
))}
</div>
</div>
</div>
</div>
)}
{/* Collection Details */}
{dataroomInfo.collection_info && (
<div className="mt-8">
<h4 className="text-md font-semibold text-gray-900 mb-4">
Detalles de la Colección
</h4>
<div className="bg-white border border-gray-200 rounded-lg p-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div>
<p className="text-sm font-medium text-gray-600">
Total Vectores
</p>
<p className="text-lg font-bold text-gray-900">
{dataroomInfo.collection_info.vectors_count}
</p>
</div>
<div>
<p className="text-sm font-medium text-gray-600">
Vectores Indexados
</p>
<p className="text-lg font-bold text-gray-900">
{dataroomInfo.collection_info.indexed_vectors_count}
</p>
</div>
<div>
<p className="text-sm font-medium text-gray-600">Puntos</p>
<p className="text-lg font-bold text-gray-900">
{dataroomInfo.collection_info.points_count}
</p>
</div>
<div>
<p className="text-sm font-medium text-gray-600">Segmentos</p>
<p className="text-lg font-bold text-gray-900">
{dataroomInfo.collection_info.segments_count}
</p>
</div>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -5,11 +5,6 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { FilesTab } from "./FilesTab";
import { DashboardTab } from "./DashboardTab";
import { ChatTab } from "./ChatTab";
import {
CheckCircle2,
AlertCircle,
Loader2,
} from "lucide-react";
interface DataroomViewProps {
onProcessingChange?: (isProcessing: boolean) => void;
@@ -18,133 +13,27 @@ interface DataroomViewProps {
export function DataroomView({ onProcessingChange }: DataroomViewProps = {}) {
const { selectedTema, files } = useFileStore();
// Collection status states
const [isCheckingCollection, setIsCheckingCollection] = useState(false);
const [collectionExists, setCollectionExists] = useState<boolean | null>(
null,
);
const [collectionError, setCollectionError] = useState<string | null>(null);
const [processing, setProcessing] = useState(false);
// Check collection status when tema changes
useEffect(() => {
checkCollectionStatus();
}, [selectedTema]);
// Load files when tema changes
useEffect(() => {
loadFiles();
}, [selectedTema]);
const checkCollectionStatus = async () => {
if (!selectedTema) {
setCollectionExists(null);
return;
}
setIsCheckingCollection(true);
setCollectionError(null);
try {
const result = await api.checkCollectionExists(selectedTema);
setCollectionExists(result.exists);
} catch (err) {
console.error("Error checking collection:", err);
setCollectionError(
err instanceof Error ? err.message : "Error al verificar colección",
);
setCollectionExists(null);
} finally {
setIsCheckingCollection(false);
}
};
const handleCreateCollection = async () => {
if (!selectedTema) return;
setIsCheckingCollection(true);
setCollectionError(null);
try {
const result = await api.createCollection(selectedTema);
if (result.success) {
setCollectionExists(true);
console.log(`Collection "${selectedTema}" created successfully`);
}
} catch (err) {
console.error("Error creating collection:", err);
setCollectionError(
err instanceof Error ? err.message : "Error al crear colección",
);
} finally {
setIsCheckingCollection(false);
}
};
const loadFiles = async () => {
// This will be handled by FilesTab component
};
const handleProcessingChange = (isProcessing: boolean) => {
setProcessing(isProcessing);
onProcessingChange?.(isProcessing);
};
const totalFiles = files.length;
return (
<div className="flex flex-col h-full bg-white">
<div className="border-b border-gray-200 px-6 py-4">
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<div className="flex items-center gap-3 mb-2">
<h2 className="text-2xl font-semibold text-gray-900">
{selectedTema
? `Dataroom: ${selectedTema}`
: "Selecciona un dataroom"}
</h2>
{/* Collection Status Indicator */}
{selectedTema && (
<div className="flex items-center gap-2">
{isCheckingCollection ? (
<>
<Loader2 className="w-4 h-4 animate-spin text-gray-500" />
<span className="text-xs text-gray-500">
Verificando...
</span>
</>
) : collectionExists === true ? (
<>
<CheckCircle2 className="w-4 h-4 text-green-600" />
<span className="text-xs text-green-600">
Colección disponible
</span>
</>
) : collectionExists === false ? (
<>
<AlertCircle className="w-4 h-4 text-yellow-600" />
<button
onClick={handleCreateCollection}
className="text-xs text-yellow-600 hover:text-yellow-700 underline"
>
Crear colección
</button>
</>
) : collectionError ? (
<>
<AlertCircle className="w-4 h-4 text-red-600" />
<span className="text-xs text-red-600">
Error de conexión
</span>
</>
) : null}
</div>
)}
</div>
<h2 className="text-2xl font-semibold text-gray-900 mb-2">
{selectedTema
? `Dataroom: ${selectedTema}`
: "Selecciona un dataroom"}
</h2>
<p className="text-sm text-gray-600">
{selectedTema
? `${totalFiles} archivo${totalFiles !== 1 ? "s" : ""}`
: "Selecciona un dataroom de la barra lateral para ver sus archivos"}
? "Gestiona archivos, consulta métricas y chatea con IA sobre el contenido"
: "Selecciona un dataroom de la barra lateral para comenzar"}
</p>
</div>
</div>

View File

@@ -1,4 +1,4 @@
import { useState } from "react";
import { useState, useEffect } from "react";
import { useFileStore } from "@/stores/fileStore";
import { api } from "@/services/api";
import { Button } from "@/components/ui/button";
@@ -80,6 +80,11 @@ export function FilesTab({
const [chunkingFileTema, setChunkingFileTema] = useState("");
const [chunkingCollectionName, setChunkingCollectionName] = useState("");
// Load files when component mounts or selectedTema changes
useEffect(() => {
loadFiles();
}, [selectedTema]);
const loadFiles = async () => {
// Don't load files if no dataroom is selected
if (!selectedTema) {

View File

@@ -273,14 +273,11 @@ export function Sidebar({
collapsed ? "justify-center" : "justify-between",
)}
>
<h2
className={cn(
"text-sm font-medium text-slate-300",
collapsed && "text-xs text-center",
)}
>
{collapsed ? "Rooms" : "Datarooms"}
</h2>
{!collapsed && (
<h2 className="text-sm font-medium text-slate-300">
Datarooms
</h2>
)}
{renderWithTooltip(
"Crear dataroom",
<Button

View File

@@ -39,6 +39,30 @@ interface DataroomsResponse {
}>;
}
interface DataroomInfo {
name: string;
collection: string;
storage: string;
file_count: number;
total_size_bytes: number;
total_size_mb: number;
collection_exists: boolean;
vector_count: number | null;
collection_info: {
vectors_count: number;
indexed_vectors_count: number;
points_count: number;
segments_count: number;
status: string;
} | null;
file_types: Record<string, number>;
recent_files: Array<{
name: string;
size_mb: number;
last_modified: string;
}>;
}
interface CreateDataroomRequest {
name: string;
collection?: string;
@@ -100,6 +124,15 @@ export const api = {
return response.json();
},
// Obtener información detallada de un dataroom
getDataroomInfo: async (dataroomName: string): Promise<DataroomInfo> => {
const response = await fetch(
`${API_BASE_URL}/dataroom/${encodeURIComponent(dataroomName)}/info`,
);
if (!response.ok) throw new Error("Error fetching dataroom info");
return response.json();
},
// Obtener archivos (todos o por tema)
getFiles: async (tema?: string): Promise<FileListResponse> => {
const url = tema