141 lines
5.0 KiB
TypeScript
141 lines
5.0 KiB
TypeScript
import { Download, MessageCircle, Wrench } from "lucide-react";
|
|
import { memo, useEffect, useRef, useState } from "react";
|
|
|
|
import { initials } from "../lib/context";
|
|
import type { ConnectionStatus } from "../lib/contracts";
|
|
import { displayTime, exportConversation, renderMarkdown, type Message } from "../lib/messages";
|
|
import { LIVE_STATES } from "../realtime/controller";
|
|
|
|
const MessageRow = memo(function MessageRow({ message, userName }: { message: Message; userName: string }) {
|
|
const [expanded, setExpanded] = useState(false);
|
|
const time = displayTime(message.timestamp);
|
|
if (message.role === "tool") {
|
|
return (
|
|
<div className="hist-msg tool">
|
|
<div className="hist-role">Llamada a herramienta</div>
|
|
<button
|
|
className="hist-tool-header"
|
|
type="button"
|
|
aria-expanded={expanded}
|
|
onClick={() => {
|
|
setExpanded(!expanded);
|
|
}}
|
|
>
|
|
<Wrench className="hist-tool-icon" />
|
|
<span className="hist-tool-name">{message.toolName}</span>
|
|
</button>
|
|
<div className={`hist-tool-body ${expanded ? "open" : ""}`}>
|
|
<div className="hist-tool-label">Entrada</div>
|
|
<pre className="hist-tool-block">{message.toolArguments}</pre>
|
|
<div className="hist-tool-label">Salida</div>
|
|
<pre className="hist-tool-block hist-tool-output">{message.text}</pre>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
const data = {
|
|
"data-user-name": userName,
|
|
"data-user-initials": initials(userName),
|
|
"data-asst-name": "Vocero",
|
|
"data-timestamp": time,
|
|
};
|
|
return (
|
|
<div className={`hist-msg ${message.role} ${message.interrupted ? "interrupted" : ""}`} {...data}>
|
|
<div className="hist-role">{message.role === "user" ? userName : "Vocero"}</div>
|
|
{message.role === "assistant" ? (
|
|
<div
|
|
className={`hist-body markdown-content ${message.partial ? "partial" : ""}`}
|
|
{...data}
|
|
dangerouslySetInnerHTML={{ __html: renderMarkdown(message.text) }}
|
|
/>
|
|
) : (
|
|
<div className={`hist-body ${message.partial ? "partial" : ""}`} {...data}>
|
|
{message.text}
|
|
</div>
|
|
)}
|
|
{message.interrupted && <div className="hist-note">Interrumpido</div>}
|
|
</div>
|
|
);
|
|
});
|
|
export const Conversation = memo(function Conversation({
|
|
messages,
|
|
userName,
|
|
status,
|
|
white,
|
|
}: {
|
|
messages: Message[];
|
|
userName: string;
|
|
status: ConnectionStatus;
|
|
white: boolean;
|
|
}) {
|
|
const history = useRef<HTMLDivElement>(null);
|
|
const atBottom = useRef(true);
|
|
useEffect(() => {
|
|
const element = history.current;
|
|
if (element && atBottom.current) {
|
|
element.scrollTop = element.scrollHeight;
|
|
}
|
|
}, [messages]);
|
|
const live = LIVE_STATES.has(status);
|
|
const waiting = ["connecting", "queued", "your-turn"].includes(status);
|
|
return (
|
|
<section id="chat-panel" className="chat-panel chat-panel-inline" aria-label="Transcripción de la conversación">
|
|
<div className="chat-panel-inner">
|
|
<header className="chat-panel-header">
|
|
<h2>Conversación</h2>
|
|
<div className="chat-header-actions" role="group" aria-label="Acciones de la conversación">
|
|
{white && (
|
|
<span
|
|
className="chat-status-pill"
|
|
data-status={live ? "online" : status === "error" ? "error" : waiting ? "waiting" : "idle"}
|
|
role="status"
|
|
>
|
|
<span className="chat-status-dot" aria-hidden="true" />
|
|
<span>
|
|
{live ? "En línea" : status === "error" ? "Sin conexión" : waiting ? "Conectando" : "Sin sesión"}
|
|
</span>
|
|
</span>
|
|
)}
|
|
<button
|
|
className="chat-export-btn icon-btn"
|
|
type="button"
|
|
aria-label="Descargar conversación"
|
|
title="Descargar conversación (JSON + TXT)"
|
|
disabled={!messages.length}
|
|
onClick={() => {
|
|
exportConversation(messages, userName);
|
|
}}
|
|
>
|
|
<Download size={18} />
|
|
</button>
|
|
</div>
|
|
</header>
|
|
<div
|
|
ref={history}
|
|
id="chat-history"
|
|
className="chat-history"
|
|
role="log"
|
|
aria-live="polite"
|
|
aria-atomic="false"
|
|
onScroll={() => {
|
|
const element = history.current;
|
|
if (element) {
|
|
atBottom.current = element.scrollHeight - element.scrollTop - element.clientHeight < 60;
|
|
}
|
|
}}
|
|
>
|
|
{messages.length ? (
|
|
messages.map((message) => <MessageRow key={message.id} message={message} userName={userName} />)
|
|
) : (
|
|
<div className="chat-empty">
|
|
<MessageCircle aria-hidden="true" />
|
|
<span className="chat-empty-title">Aún no hay mensajes</span>
|
|
<span className="chat-empty-hint">Toca el círculo y empieza a hablar</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
);
|
|
});
|