refactor(frontend): migrate page layout and transcripts to Tailwind
This commit is contained in:
+72
-56
@@ -8,6 +8,7 @@ import { ReportsPanel } from "./components/ReportsPanel";
|
|||||||
import { AboutDialog, ProfileDialog, RagDialog, SettingsDialog, ToolsDialog } from "./components/SettingsDialogs";
|
import { AboutDialog, ProfileDialog, RagDialog, SettingsDialog, ToolsDialog } from "./components/SettingsDialogs";
|
||||||
import { VoiceStage } from "./components/VoiceStage";
|
import { VoiceStage } from "./components/VoiceStage";
|
||||||
import { WakeWordControl } from "./components/WakeWordControl";
|
import { WakeWordControl } from "./components/WakeWordControl";
|
||||||
|
import { Button, DialogFooter, DialogIntro, IconButton } from "./components/ui";
|
||||||
import { useMediaQuery } from "./hooks/use-media-query";
|
import { useMediaQuery } from "./hooks/use-media-query";
|
||||||
import { initials, profileName } from "./lib/context";
|
import { initials, profileName } from "./lib/context";
|
||||||
import { isDebug } from "./lib/settings";
|
import { isDebug } from "./lib/settings";
|
||||||
@@ -59,26 +60,28 @@ export default function App() {
|
|||||||
};
|
};
|
||||||
if (state.loading) {
|
if (state.loading) {
|
||||||
return (
|
return (
|
||||||
<main className="loading-screen" role="status">
|
<main
|
||||||
|
className="fixed inset-0 z-1000 grid place-items-center bg-white font-sans text-[16px] font-medium text-slate-700"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
Preparando Vocero…
|
Preparando Vocero…
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!state.configReady) {
|
if (!state.configReady) {
|
||||||
return (
|
return (
|
||||||
<main className="loading-screen">
|
<main className="fixed inset-0 z-1000 grid place-items-center bg-white font-sans text-[16px] font-medium text-slate-700">
|
||||||
<div className="startup-error">
|
<div className="max-w-lg p-6 text-center">
|
||||||
<h1>No pudimos preparar Vocero</h1>
|
<h1>No pudimos preparar Vocero</h1>
|
||||||
<p role="alert">{state.configError}</p>
|
<p role="alert">{state.configError}</p>
|
||||||
<button
|
<Button
|
||||||
className="btn primary"
|
variant="primary"
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
void controller.initialize();
|
void controller.initialize();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Reintentar
|
Reintentar
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
@@ -92,49 +95,53 @@ export default function App() {
|
|||||||
controller.dismissUnlock();
|
controller.dismissUnlock();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<p className="tools-intro">
|
<DialogIntro>
|
||||||
Activa el micrófono para hablar con Vocero. Puedes detener la conversación en cualquier momento.
|
Activa el micrófono para hablar con Vocero. Puedes detener la conversación en cualquier momento.
|
||||||
</p>
|
</DialogIntro>
|
||||||
<footer className="modal-footer">
|
<DialogFooter>
|
||||||
<button
|
<Button
|
||||||
className="btn secondary"
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
controller.dismissUnlock();
|
controller.dismissUnlock();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Ahora no
|
Ahora no
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
className="btn primary"
|
variant="primary"
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
void controller.start();
|
void controller.start();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Empezar a hablar
|
Empezar a hablar
|
||||||
</button>
|
</Button>
|
||||||
</footer>
|
</DialogFooter>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)}
|
)}
|
||||||
<div id="app" className={hasReports ? "has-reports" : ""}>
|
<div id="app" className={`flex w-full min-w-0 flex-1 flex-col ${hasReports ? "lg:pl-[320px]" : ""}`}>
|
||||||
<header className="topbar">
|
<header className="relative z-2 mt-[max(6px,calc(env(safe-area-inset-top,0px)*0.2))] flex w-full flex-none flex-wrap items-start justify-between gap-2 px-4 pt-4 white:static white:grid white:auto-rows-min white:grid-cols-[52px_minmax(0,1fr)_52px] white:justify-items-center white:gap-x-1 white:gap-y-0 white:px-2.5 white:pt-4 white:max-[480px]:px-1.5 compact:gap-3 [@media(max-height:560px)]:px-3.5 [@media(max-height:560px)]:pt-1.5 [@media(max-height:780px)]:px-5 [@media(max-height:780px)]:pt-3">
|
||||||
<div className="brand">
|
<div className="brand-art relative flex min-w-0 flex-[1_1_360px] items-start gap-2.5 overflow-hidden rounded-panel border border-solid border-line px-3 py-2.5 text-[15px] font-semibold tracking-[0.01em] text-muted white:col-start-2 white:row-start-1 white:m-0 white:w-full white:flex-none white:justify-center white:rounded-none white:border-0 white:p-0 white:text-center compact:text-[14px]">
|
||||||
<div className="ident">
|
<div className="flex w-full min-w-0 items-center justify-between gap-3 white:flex-col white:justify-center white:gap-0.5 white:px-1">
|
||||||
<div className="ident-head">
|
<div className="flex items-center gap-[9px] white:mx-auto white:w-full white:flex-col white:justify-center white:gap-0.5">
|
||||||
<h1 className="m-0 leading-none">
|
<h1 className="m-0 leading-none">
|
||||||
<a className="ident-title" href={window.location.pathname + window.location.search}>
|
<a
|
||||||
|
className="block border-0 bg-(image:--brand-gradient) bg-clip-text text-[24px] leading-[1.1] font-semibold tracking-[0.005em] text-transparent hover:underline hover:underline-offset-2 white:bg-none white:text-[clamp(24px,6vw,32px)] white:leading-[1.05] white:font-extrabold white:tracking-[-0.02em] white:whitespace-nowrap white:text-foreground compact:text-[22px] [@media(max-height:560px)]:text-[17px] [@media(max-height:780px)]:text-[20px]"
|
||||||
|
href={window.location.pathname + window.location.search}
|
||||||
|
>
|
||||||
Vocero
|
Vocero
|
||||||
</a>
|
</a>
|
||||||
</h1>
|
</h1>
|
||||||
{white && <p className="ident-subtitle">Agente conversacional</p>}
|
{white && (
|
||||||
|
<p className="m-0 text-[clamp(12px,2.6vw,14px)] leading-[1.3] font-medium tracking-[-0.005em] whitespace-nowrap text-slate-900/60">
|
||||||
|
Agente conversacional
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{white && name && (
|
{white && name && (
|
||||||
<button
|
<button
|
||||||
id="ident-user-name"
|
id="ident-user-name"
|
||||||
className="ident-user-name"
|
className="z-2 col-start-3 row-start-1 mr-0.5 inline-flex size-[42px] cursor-pointer items-center justify-center self-center justify-self-end rounded-full border-2 border-solid border-brand-secondary/25 bg-linear-to-br from-speaking to-accent p-0 text-[13px] font-bold text-white shadow-[0_3px_10px_rgb(37_99_235/0.22)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={`Ver datos de ${name}`}
|
aria-label={`Ver datos de ${name}`}
|
||||||
aria-haspopup="dialog"
|
aria-haspopup="dialog"
|
||||||
@@ -146,9 +153,8 @@ export default function App() {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{hasReports && !desktop && (
|
{hasReports && !desktop && (
|
||||||
<button
|
<IconButton
|
||||||
id="reports-sidebar-toggle"
|
id="reports-sidebar-toggle"
|
||||||
className="icon-btn"
|
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Abrir panel de reportes"
|
aria-label="Abrir panel de reportes"
|
||||||
aria-expanded={reportsOpen}
|
aria-expanded={reportsOpen}
|
||||||
@@ -158,69 +164,69 @@ export default function App() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<PanelLeft size={18} />
|
<PanelLeft size={18} />
|
||||||
<span id="reports-sidebar-badge" className="chat-badge" data-count={state.reports?.count}>
|
<span
|
||||||
|
id="reports-sidebar-badge"
|
||||||
|
className="pointer-events-none absolute -top-[3px] -right-[3px] inline-flex h-[18px] min-w-[18px] items-center justify-center rounded-full bg-linear-to-br from-brand-secondary to-brand px-[5px] text-[10px] leading-[18px] font-bold text-white"
|
||||||
|
data-count={state.reports?.count}
|
||||||
|
>
|
||||||
{state.reports?.count}
|
{state.reports?.count}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
{debug && (
|
{debug && (
|
||||||
<div className="topbar-right" data-debug-enabled="true">
|
<div
|
||||||
<button
|
className="flex items-center gap-2 white:col-span-full white:row-start-2 white:mt-3 white:flex-wrap white:justify-center compact:flex-wrap [&_svg]:size-5"
|
||||||
className="icon-btn"
|
data-debug-enabled="true"
|
||||||
type="button"
|
>
|
||||||
|
<IconButton
|
||||||
aria-label="Acerca de"
|
aria-label="Acerca de"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setModal("about");
|
setModal("about");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Info size={18} />
|
<Info size={18} />
|
||||||
</button>
|
</IconButton>
|
||||||
<button
|
<IconButton
|
||||||
className="icon-btn"
|
|
||||||
type="button"
|
|
||||||
aria-label="Herramientas"
|
aria-label="Herramientas"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setModal("tools");
|
setModal("tools");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Wrench size={18} />
|
<Wrench size={18} />
|
||||||
</button>
|
</IconButton>
|
||||||
<button
|
<IconButton
|
||||||
className="icon-btn"
|
|
||||||
type="button"
|
|
||||||
aria-label="Contexto RAG personalizado"
|
aria-label="Contexto RAG personalizado"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setModal("rag");
|
setModal("rag");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<BookOpen size={18} />
|
<BookOpen size={18} />
|
||||||
</button>
|
</IconButton>
|
||||||
<button
|
<IconButton
|
||||||
className="icon-btn"
|
|
||||||
type="button"
|
|
||||||
aria-label="Configuración"
|
aria-label="Configuración"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setModal("settings");
|
setModal("settings");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Settings size={18} />
|
<Settings size={18} />
|
||||||
</button>
|
</IconButton>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</header>
|
</header>
|
||||||
{state.error && (
|
{state.error && (
|
||||||
<div className="app-error" role="alert">
|
<div
|
||||||
|
className="mx-auto my-3 flex max-w-[680px] items-center gap-3 rounded-xl border border-solid border-error bg-surface-raised px-4 py-2.5 text-[13px]"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
<span>{state.error}</span>
|
<span>{state.error}</span>
|
||||||
<button
|
<IconButton
|
||||||
className="icon-btn shrink-0"
|
|
||||||
type="button"
|
|
||||||
aria-label="Cerrar aviso"
|
aria-label="Cerrar aviso"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
controller.dismissError();
|
controller.dismissError();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<X size={16} />
|
<X size={16} />
|
||||||
</button>
|
</IconButton>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<VoiceStage state={state} controller={controller} />
|
<VoiceStage state={state} controller={controller} />
|
||||||
@@ -251,11 +257,21 @@ export default function App() {
|
|||||||
{modal === "rag" && <RagDialog state={state} controller={controller} onClose={close} />}
|
{modal === "rag" && <RagDialog state={state} controller={controller} onClose={close} />}
|
||||||
{modal === "profile" && <ProfileDialog state={state} onClose={close} />}
|
{modal === "profile" && <ProfileDialog state={state} onClose={close} />}
|
||||||
{modal === "about" && <AboutDialog onClose={close} />}
|
{modal === "about" && <AboutDialog onClose={close} />}
|
||||||
<footer className="app-footer" aria-label="Información de la aplicación">
|
<footer
|
||||||
<a className="feedback-link" href="https://tally.so/r/pb8LWq" target="_blank" rel="noopener noreferrer">
|
className={`relative flex flex-none flex-col items-center gap-2 px-3 pt-4 pb-[max(12px,env(safe-area-inset-bottom))] text-center before:absolute before:top-0 before:left-1/2 before:h-px before:w-[min(640px,70vw)] before:-translate-x-1/2 before:bg-linear-to-r before:from-transparent before:via-brand/40 before:to-transparent before:opacity-70 before:content-[''] ${hasReports ? "lg:ml-[320px]" : ""}`}
|
||||||
|
aria-label="Información de la aplicación"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
className="rounded border-0 p-1 text-[13px] text-muted underline underline-offset-3 hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-3 focus-visible:outline-accent"
|
||||||
|
href="https://tally.so/r/pb8LWq"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
Enviar comentarios
|
Enviar comentarios
|
||||||
</a>
|
</a>
|
||||||
<span className="app-footer-text">Azurian Vocero en tiempo real ver. Beta 2.1</span>
|
<span className="max-w-full bg-(image:--brand-gradient) bg-clip-text text-[clamp(10px,1.5vw,11px)] leading-[1.35] font-medium tracking-[0.18em] wrap-anywhere text-transparent uppercase white:bg-none white:font-semibold white:tracking-[0.12em] white:text-slate-900/60">
|
||||||
|
Azurian Vocero en tiempo real ver. Beta 2.1
|
||||||
|
</span>
|
||||||
</footer>
|
</footer>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -20,21 +20,29 @@ function Bubble({ message, userName }: { message: Message; userName: string }) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className={`bubble ${message.role} in`}>
|
<div
|
||||||
<div className="bubble-role">
|
className={`pointer-events-auto max-w-full rounded-panel border border-solid border-line bg-surface-raised px-3.5 py-2.5 text-[14px] leading-[1.5] wrap-anywhere shadow-[0_4px_18px_rgb(0_0_0/0.32)] compact:text-[13px] ${message.role === "assistant" ? "self-end" : "self-start"} ${message.role === "tool" ? "flex items-center gap-2 font-mono text-[13px] text-muted" : ""}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`mb-1 font-mono text-[11px] font-medium tracking-[0.1em] uppercase opacity-70 compact:text-[10px] ${message.role === "user" ? "text-voice-user" : message.role === "assistant" ? "text-voice-assistant" : "text-voice-tool"}`}
|
||||||
|
>
|
||||||
{message.role === "user" ? userName : message.role === "tool" ? "Herramienta" : "Vocero"}
|
{message.role === "user" ? userName : message.role === "tool" ? "Herramienta" : "Vocero"}
|
||||||
</div>
|
</div>
|
||||||
{message.role === "assistant" ? (
|
{message.role === "assistant" ? (
|
||||||
<AssistantMarkdown text={message.text} className="bubble-body" />
|
<AssistantMarkdown text={message.text} className="max-h-60 overflow-auto" />
|
||||||
) : (
|
) : (
|
||||||
<div className="bubble-body">{message.role === "tool" ? message.toolName : message.text}</div>
|
<div className="max-h-60 overflow-auto">{message.role === "tool" ? message.toolName : message.text}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
export const Bubbles = memo(function Bubbles({ messages, userName }: { messages: Message[]; userName: string }) {
|
export const Bubbles = memo(function Bubbles({ messages, userName }: { messages: Message[]; userName: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="bubble-stack" aria-hidden="true" inert>
|
<div
|
||||||
|
className="pointer-events-none fixed top-24 right-7 z-100 flex w-[min(300px,calc(100vw-56px))] flex-col gap-2 compact:hidden"
|
||||||
|
aria-hidden="true"
|
||||||
|
inert
|
||||||
|
>
|
||||||
{messages.slice(-3).map((message) => (
|
{messages.slice(-3).map((message) => (
|
||||||
<Bubble key={message.id} message={message} userName={userName} />
|
<Bubble key={message.id} message={message} userName={userName} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ArrowDown, Download, MessageCircle, Wrench } from "lucide-react";
|
import { ArrowDown, Bot, Download, MessageCircle, Wrench } from "lucide-react";
|
||||||
import { memo, useEffect, useRef, useState } from "react";
|
import { memo, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
import { initials } from "../lib/context";
|
import { initials } from "../lib/context";
|
||||||
@@ -6,49 +6,84 @@ import type { ConnectionStatus } from "../lib/contracts";
|
|||||||
import { displayTime, exportConversation, type Message } from "../lib/messages";
|
import { displayTime, exportConversation, type Message } from "../lib/messages";
|
||||||
import { LIVE_STATES } from "../realtime/controller";
|
import { LIVE_STATES } from "../realtime/controller";
|
||||||
import { AssistantMarkdown } from "./AssistantMarkdown";
|
import { AssistantMarkdown } from "./AssistantMarkdown";
|
||||||
|
import { Button, IconButton } from "./ui";
|
||||||
|
|
||||||
|
const messagePositions = {
|
||||||
|
user: "mr-auto self-start white:my-1 white:mr-10 white:ml-auto white:self-end",
|
||||||
|
assistant: "ml-auto self-end white:my-1 white:mr-auto white:ml-10 white:self-start",
|
||||||
|
};
|
||||||
|
const messageSurfaces = {
|
||||||
|
user: "border-(--message-user-border) [background:var(--message-user-background)] shadow-(--message-user-shadow) white:rounded-br-md",
|
||||||
|
assistant:
|
||||||
|
"border-(--message-assistant-border) [background:var(--message-assistant-background)] shadow-(--message-assistant-shadow) white:rounded-bl-md",
|
||||||
|
};
|
||||||
|
|
||||||
const MessageRow = memo(function MessageRow({ message, userName }: { message: Message; userName: string }) {
|
const MessageRow = memo(function MessageRow({ message, userName }: { message: Message; userName: string }) {
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
const time = displayTime(message.timestamp);
|
const time = displayTime(message.timestamp);
|
||||||
if (message.role === "tool") {
|
if (message.role === "tool") {
|
||||||
return (
|
return (
|
||||||
<div className="hist-msg tool">
|
<div className="flex max-w-full min-w-0 shrink-0 flex-col gap-1.5 self-start white:my-1">
|
||||||
<div className="hist-role">Llamada a herramienta</div>
|
<div className="px-1 font-mono text-[11px] font-bold tracking-[0.14em] text-voice-tool uppercase compact:text-[10px]">
|
||||||
|
Llamada a herramienta
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
className="hist-tool-header"
|
className="flex w-full cursor-pointer items-center gap-2 rounded-t-panel border border-solid border-processing/25 bg-surface-overlay px-3 py-2 text-left font-sans text-[14px] font-medium text-foreground hover:bg-processing/15 focus-visible:outline-2 focus-visible:outline-accent compact:text-[13px]"
|
||||||
type="button"
|
type="button"
|
||||||
aria-expanded={expanded}
|
aria-expanded={expanded}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setExpanded(!expanded);
|
setExpanded(!expanded);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Wrench className="hist-tool-icon" />
|
<Wrench className="size-[13px] shrink-0 text-processing" />
|
||||||
<span className="hist-tool-name">{message.toolName}</span>
|
<span className="font-mono text-[13px] font-semibold text-voice-tool compact:text-[12px]">
|
||||||
|
{message.toolName}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<div className={`hist-tool-body ${expanded ? "open" : ""}`}>
|
{expanded && (
|
||||||
<div className="hist-tool-label">Entrada</div>
|
<div className="rounded-b-panel border border-t-0 border-solid border-processing/25 bg-surface px-3 pt-2.5 pb-3">
|
||||||
<pre className="hist-tool-block">{message.toolArguments}</pre>
|
<div className="mb-1 font-mono text-[10px] tracking-[0.08em] text-faint uppercase">Entrada</div>
|
||||||
<div className="hist-tool-label">Salida</div>
|
<pre className="m-0 overflow-x-auto font-mono text-[12px] leading-[1.65] wrap-anywhere whitespace-pre-wrap text-muted compact:text-[11px]">
|
||||||
<pre className="hist-tool-block hist-tool-output">{message.text}</pre>
|
{message.toolArguments}
|
||||||
</div>
|
</pre>
|
||||||
|
<div className="mt-2 mb-1 font-mono text-[10px] tracking-[0.08em] text-faint uppercase">Salida</div>
|
||||||
|
<pre className="m-0 overflow-x-auto font-mono text-[12px] leading-[1.65] wrap-anywhere whitespace-pre-wrap text-foreground compact:text-[11px]">
|
||||||
|
{message.text}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
const bodyClass = `min-w-0 rounded-[14px] border border-solid px-3.5 py-[11px] text-[14px] leading-[1.55] wrap-anywhere text-foreground backdrop-blur-[8px] backdrop-saturate-130 white:rounded-[18px] white:px-[15px] white:py-3 white:text-[clamp(14.5px,3.3vw,16.5px)] white:leading-[1.45] white:tracking-[-0.005em] white:backdrop-filter-none ${messageSurfaces[message.role]} ${message.interrupted ? "opacity-70" : message.partial ? "opacity-68 white:opacity-92" : ""}`;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`hist-msg ${message.role} ${message.interrupted ? "interrupted" : ""}`}
|
className={`relative flex max-w-[86%] min-w-0 shrink-0 flex-col gap-1.5 white:max-w-[calc(100%-40px)] ${messagePositions[message.role]}`}
|
||||||
data-user-initials={initials(userName)}
|
|
||||||
>
|
>
|
||||||
<div className="hist-meta">
|
<span
|
||||||
<span>{message.role === "user" ? userName : "Vocero"}</span>
|
aria-hidden="true"
|
||||||
<time dateTime={message.timestamp}>{time}</time>
|
className={`absolute top-[22px] z-2 hidden size-7 items-center justify-center rounded-full border border-solid text-[11px] font-bold tracking-[-0.01em] text-white shadow-[0_2px_8px_rgb(15_23_42/0.1)] white:inline-flex ${message.role === "user" ? "-right-10 border-brand-tertiary/35 bg-linear-to-br from-brand-secondary to-brand-tertiary" : "-left-10 border-brand-secondary/25 bg-accent"}`}
|
||||||
|
>
|
||||||
|
{message.role === "user" ? initials(userName) : <Bot size={20} strokeWidth={1.8} />}
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
className={`flex flex-wrap items-baseline gap-x-2.5 gap-y-1 text-[12px] text-muted white:text-slate-500 ${message.role === "user" ? "white:justify-end" : ""}`}
|
||||||
|
>
|
||||||
|
<span className="font-semibold wrap-anywhere">{message.role === "user" ? userName : "Vocero"}</span>
|
||||||
|
<time className="whitespace-nowrap" dateTime={message.timestamp}>
|
||||||
|
{time}
|
||||||
|
</time>
|
||||||
</div>
|
</div>
|
||||||
{message.role === "assistant" ? (
|
{message.role === "assistant" ? (
|
||||||
<AssistantMarkdown text={message.text} className={`hist-body ${message.partial ? "partial" : ""}`} />
|
<AssistantMarkdown text={message.text} className={bodyClass} />
|
||||||
) : (
|
) : (
|
||||||
<div className={`hist-body ${message.partial ? "partial" : ""}`}>{message.text}</div>
|
<div className={`${bodyClass} whitespace-pre-wrap`}>{message.text}</div>
|
||||||
|
)}
|
||||||
|
{message.interrupted && (
|
||||||
|
<div className="self-end px-0.5 font-mono text-[10px] font-medium tracking-[0.1em] text-faint uppercase">
|
||||||
|
Interrumpido
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{message.interrupted && <div className="hist-note">Interrumpido</div>}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -77,26 +112,35 @@ export const Conversation = memo(function Conversation({
|
|||||||
const live = LIVE_STATES.has(status);
|
const live = LIVE_STATES.has(status);
|
||||||
const waiting = ["connecting", "queued", "your-turn"].includes(status);
|
const waiting = ["connecting", "queued", "your-turn"].includes(status);
|
||||||
return (
|
return (
|
||||||
<section id="chat-panel" className="chat-panel chat-panel-inline" aria-label="Transcripción de la conversación">
|
<section
|
||||||
<div className="chat-panel-inner">
|
id="chat-panel"
|
||||||
<header className="chat-panel-header">
|
className="relative z-1 mx-3 mt-2 flex h-[clamp(240px,45dvh,640px)] min-w-0 flex-[1_0_auto] flex-col white:rounded-[20px] white:border white:border-solid white:border-line white:bg-surface white:shadow-panel compact:mx-0"
|
||||||
<h2>Conversación</h2>
|
aria-label="Transcripción de la conversación"
|
||||||
<div className="chat-header-actions" role="group" aria-label="Acciones de la conversación">
|
>
|
||||||
|
<div className="relative mx-auto flex h-full min-h-0 w-full max-w-[920px] flex-col overflow-hidden p-3 compact:p-2.5">
|
||||||
|
<header className="flex flex-none flex-wrap items-center justify-between gap-2 pb-2.5 white:border-0 white:border-b white:border-solid white:border-line">
|
||||||
|
<h2 className="m-0 min-w-0 bg-(image:--brand-gradient) bg-clip-text text-[15px] font-extrabold tracking-[0.04em] text-transparent uppercase opacity-92 white:bg-none white:text-[clamp(18px,4.3vw,22px)] white:font-bold white:tracking-[-0.01em] white:text-foreground white:normal-case white:opacity-100 compact:text-[13px] white:compact:text-[17px]">
|
||||||
|
Conversación
|
||||||
|
</h2>
|
||||||
|
<div
|
||||||
|
className="inline-flex min-w-0 flex-none flex-wrap items-center justify-end gap-1.5"
|
||||||
|
role="group"
|
||||||
|
aria-label="Acciones de la conversación"
|
||||||
|
>
|
||||||
{white && (
|
{white && (
|
||||||
<span
|
<span
|
||||||
className="chat-status-pill"
|
className="inline-flex flex-none items-center gap-[7px] rounded-full bg-slate-100 py-[5px] pr-[11px] pl-2 text-[12.5px] font-semibold text-slate-600 data-[status=error]:bg-red-50 data-[status=error]:text-red-700 data-[status=online]:bg-emerald-50 data-[status=online]:text-green-700 data-[status=waiting]:bg-amber-50 data-[status=waiting]:text-amber-900 compact:px-[7px] compact:text-[11px]"
|
||||||
data-status={live ? "online" : status === "error" ? "error" : waiting ? "waiting" : "idle"}
|
data-status={live ? "online" : status === "error" ? "error" : waiting ? "waiting" : "idle"}
|
||||||
role="status"
|
role="status"
|
||||||
>
|
>
|
||||||
<span className="chat-status-dot" aria-hidden="true" />
|
<span className="block size-[9px] flex-none rounded-full bg-current" aria-hidden="true" />
|
||||||
<span>
|
<span>
|
||||||
{live ? "En línea" : status === "error" ? "Sin conexión" : waiting ? "Conectando" : "Sin sesión"}
|
{live ? "En línea" : status === "error" ? "Sin conexión" : waiting ? "Conectando" : "Sin sesión"}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<button
|
<IconButton
|
||||||
className="chat-export-btn icon-btn"
|
round
|
||||||
type="button"
|
|
||||||
aria-label="Descargar conversación"
|
aria-label="Descargar conversación"
|
||||||
title="Descargar conversación (JSON + TXT)"
|
title="Descargar conversación (JSON + TXT)"
|
||||||
disabled={!messages.length}
|
disabled={!messages.length}
|
||||||
@@ -105,13 +149,13 @@ export const Conversation = memo(function Conversation({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Download size={18} />
|
<Download size={18} />
|
||||||
</button>
|
</IconButton>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div
|
<div
|
||||||
ref={history}
|
ref={history}
|
||||||
id="chat-history"
|
id="chat-history"
|
||||||
className="chat-history"
|
className="flex min-h-0 flex-1 [scrollbar-width:thin] flex-col gap-3.5 overflow-x-hidden overflow-y-auto overscroll-contain px-1 py-3 [overflow-anchor:none] focus-visible:rounded-lg focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-accent"
|
||||||
role="log"
|
role="log"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
aria-label="Mensajes de la conversación"
|
aria-label="Mensajes de la conversación"
|
||||||
@@ -128,10 +172,12 @@ export const Conversation = memo(function Conversation({
|
|||||||
{messages.length ? (
|
{messages.length ? (
|
||||||
messages.map((message) => <MessageRow key={message.id} message={message} userName={userName} />)
|
messages.map((message) => <MessageRow key={message.id} message={message} userName={userName} />)
|
||||||
) : (
|
) : (
|
||||||
<div className="chat-empty">
|
<div className="flex flex-1 flex-col items-center justify-center gap-1.5 py-12 text-muted white:text-slate-500">
|
||||||
<MessageCircle aria-hidden="true" />
|
<MessageCircle className="mb-1.5 opacity-80" aria-hidden="true" />
|
||||||
<span className="chat-empty-title">Aún no hay mensajes</span>
|
<span className="font-mono text-[12px] font-medium tracking-[0.14em] uppercase compact:text-[11px]">
|
||||||
<span className="chat-empty-hint">
|
Aún no hay mensajes
|
||||||
|
</span>
|
||||||
|
<span className="text-[13px] compact:text-[12px]">
|
||||||
{muted
|
{muted
|
||||||
? "Activa el micrófono para hablar"
|
? "Activa el micrófono para hablar"
|
||||||
: live
|
: live
|
||||||
@@ -144,9 +190,8 @@ export const Conversation = memo(function Conversation({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{showLatest && (
|
{showLatest && (
|
||||||
<button
|
<Button
|
||||||
className="jump-to-latest btn secondary"
|
className="absolute right-[18px] bottom-[18px] z-2 inline-flex items-center gap-1.5 shadow-panel"
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (history.current) {
|
if (history.current) {
|
||||||
history.current.scrollTop = history.current.scrollHeight;
|
history.current.scrollTop = history.current.scrollHeight;
|
||||||
@@ -158,7 +203,7 @@ export const Conversation = memo(function Conversation({
|
|||||||
>
|
>
|
||||||
<ArrowDown size={16} aria-hidden="true" />
|
<ArrowDown size={16} aria-hidden="true" />
|
||||||
Ir al último mensaje
|
Ir al último mensaje
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import { Dialog } from "./Dialog";
|
|||||||
function ReportContent({ reports }: { reports: Reports }) {
|
function ReportContent({ reports }: { reports: Reports }) {
|
||||||
const suggestions = reportSuggestions(reports);
|
const suggestions = reportSuggestions(reports);
|
||||||
return (
|
return (
|
||||||
<div className="reports-content">
|
<div>
|
||||||
<div className="reports-hint">
|
<div className="report-art relative m-0 max-w-full rounded-control border border-solid p-3 text-[12px] leading-[1.3] text-muted">
|
||||||
<p>{reports.count} reportes disponibles</p>
|
<p>{reports.count} reportes disponibles</p>
|
||||||
<ul>
|
<ul>
|
||||||
{reports.hints.map((hint, index) => (
|
{reports.hints.map((hint, index) => (
|
||||||
@@ -16,9 +16,9 @@ function ReportContent({ reports }: { reports: Reports }) {
|
|||||||
{reports.note && <p>{reports.note}</p>}
|
{reports.note && <p>{reports.note}</p>}
|
||||||
</div>
|
</div>
|
||||||
{suggestions.length > 0 && (
|
{suggestions.length > 0 && (
|
||||||
<div className="reports-suggestions">
|
<div>
|
||||||
<h3 className="reports-suggestions-title">Puedes preguntar</h3>
|
<h3>Puedes preguntar</h3>
|
||||||
<ul className="reports-suggestions-list">
|
<ul>
|
||||||
{suggestions.map((suggestion) => (
|
{suggestions.map((suggestion) => (
|
||||||
<li key={suggestion}>{suggestion}</li>
|
<li key={suggestion}>{suggestion}</li>
|
||||||
))}
|
))}
|
||||||
@@ -41,8 +41,12 @@ export function ReportsPanel({
|
|||||||
}) {
|
}) {
|
||||||
if (desktop) {
|
if (desktop) {
|
||||||
return (
|
return (
|
||||||
<aside id="reports-sidebar" className="reports-panel" aria-label="Reportes programados">
|
<aside
|
||||||
<h2>Reportes</h2>
|
id="reports-sidebar"
|
||||||
|
className="fixed inset-y-0 left-0 z-3 w-[320px] overflow-y-auto border-0 border-r border-solid border-line bg-surface-raised p-5"
|
||||||
|
aria-label="Reportes programados"
|
||||||
|
>
|
||||||
|
<h2 className="mt-0 mb-4 text-[18px]">Reportes</h2>
|
||||||
<ReportContent reports={reports} />
|
<ReportContent reports={reports} />
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { VoiceController } from "../realtime/controller";
|
|||||||
function WakeWordStatus({ controller }: { controller: VoiceController }) {
|
function WakeWordStatus({ controller }: { controller: VoiceController }) {
|
||||||
const status = useWakeWord(true, controller);
|
const status = useWakeWord(true, controller);
|
||||||
return (
|
return (
|
||||||
<span role="status">
|
<span className="text-[12px] text-muted white:text-slate-500" role="status">
|
||||||
{status === "listening"
|
{status === "listening"
|
||||||
? "Micrófono activo: escuchando la frase de activación."
|
? "Micrófono activo: escuchando la frase de activación."
|
||||||
: status === "blocked"
|
: status === "blocked"
|
||||||
@@ -18,8 +18,8 @@ export function WakeWordControl({ enabled, controller }: { enabled: boolean; con
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="wake-word-control">
|
<div className="grid gap-1 px-4 pb-3 text-center text-[13px]">
|
||||||
<label>
|
<label className="flex cursor-pointer items-center justify-center gap-2">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={enabled}
|
checked={enabled}
|
||||||
@@ -32,7 +32,9 @@ export function WakeWordControl({ enabled, controller }: { enabled: boolean; con
|
|||||||
{enabled ? (
|
{enabled ? (
|
||||||
<WakeWordStatus controller={controller} />
|
<WakeWordStatus controller={controller} />
|
||||||
) : (
|
) : (
|
||||||
<span role="status">Opcional. Se desactiva al detener la conversación.</span>
|
<span className="text-[12px] text-muted white:text-slate-500" role="status">
|
||||||
|
Opcional. Se desactiva al detener la conversación.
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
@import "./styles/theme.css";
|
@import "./styles/theme.css";
|
||||||
@import "./styles/legacy-theme.css" layer(components);
|
@import "./styles/legacy-theme.css" layer(components);
|
||||||
@import "./styles/layout.css" layer(components);
|
@import "./styles/layout.css" layer(components);
|
||||||
|
@import "./styles/surfaces.css" layer(components);
|
||||||
@import "tailwindcss/utilities.css" layer(utilities);
|
@import "tailwindcss/utilities.css" layer(utilities);
|
||||||
|
|
||||||
@layer components {
|
@layer components {
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
/* Decorative masks and layered glass surfaces; component layout uses utilities. */
|
||||||
|
.brand-art {
|
||||||
|
background: linear-gradient(180deg, rgb(255 255 255 / 0.03), transparent), var(--glass);
|
||||||
|
backdrop-filter: blur(14px) saturate(135%);
|
||||||
|
box-shadow:
|
||||||
|
var(--shadow-glass),
|
||||||
|
inset 0 1px 0 rgb(255 255 255 / 0.05);
|
||||||
|
}
|
||||||
|
.brand-art::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: -1px;
|
||||||
|
border-radius: inherit;
|
||||||
|
padding: 1px;
|
||||||
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
color-mix(in srgb, var(--brand) 45%, transparent),
|
||||||
|
transparent 40%,
|
||||||
|
color-mix(in srgb, var(--brand-2) 35%, transparent)
|
||||||
|
);
|
||||||
|
mask:
|
||||||
|
linear-gradient(#000, #000) content-box,
|
||||||
|
linear-gradient(#000, #000);
|
||||||
|
mask-composite: exclude;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
.report-art {
|
||||||
|
background: linear-gradient(180deg, rgb(255 255 255 / 0.028), transparent), var(--glass);
|
||||||
|
border-color: color-mix(in srgb, var(--brand) 26%, var(--border));
|
||||||
|
backdrop-filter: blur(12px) saturate(135%);
|
||||||
|
box-shadow:
|
||||||
|
var(--shadow-glass),
|
||||||
|
0 0 0 1px color-mix(in srgb, var(--brand) 14%, transparent) inset,
|
||||||
|
0 0 20px -10px color-mix(in srgb, var(--brand) 55%, transparent);
|
||||||
|
}
|
||||||
|
.report-art::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: -1px;
|
||||||
|
left: -1px;
|
||||||
|
right: -1px;
|
||||||
|
height: 1px;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
transparent,
|
||||||
|
color-mix(in srgb, var(--brand) 70%, transparent) 25%,
|
||||||
|
color-mix(in srgb, var(--brand-2) 60%, transparent) 55%,
|
||||||
|
color-mix(in srgb, var(--brand-3) 65%, transparent) 80%,
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
opacity: 0.85;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
@variant white {
|
||||||
|
.brand-art {
|
||||||
|
background: transparent;
|
||||||
|
backdrop-filter: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
.brand-art::before {
|
||||||
|
content: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -90,6 +90,27 @@
|
|||||||
0 0 40px -10px color-mix(in srgb, var(--brand) 60%, transparent),
|
0 0 40px -10px color-mix(in srgb, var(--brand) 60%, transparent),
|
||||||
0 0 80px -30px color-mix(in srgb, var(--brand-2) 55%, transparent);
|
0 0 80px -30px color-mix(in srgb, var(--brand-2) 55%, transparent);
|
||||||
--shadow-glass: 0 8px 40px rgba(0, 0, 0, 0.32), 0 2px 8px rgba(0, 0, 0, 0.18);
|
--shadow-glass: 0 8px 40px rgba(0, 0, 0, 0.32), 0 2px 8px rgba(0, 0, 0, 0.18);
|
||||||
|
--message-user-background: linear-gradient(
|
||||||
|
180deg,
|
||||||
|
color-mix(in srgb, var(--voice-user) 10%, var(--bg-elev-2)),
|
||||||
|
color-mix(in srgb, var(--voice-user) 6%, var(--bg-elev-2))
|
||||||
|
)
|
||||||
|
padding-box;
|
||||||
|
--message-assistant-background: linear-gradient(
|
||||||
|
180deg,
|
||||||
|
color-mix(in srgb, var(--voice-assistant) 10%, var(--bg-elev-2)),
|
||||||
|
color-mix(in srgb, var(--voice-assistant) 5%, var(--bg-elev-2))
|
||||||
|
)
|
||||||
|
padding-box;
|
||||||
|
--message-user-border: color-mix(in srgb, var(--voice-user) 44%, var(--border));
|
||||||
|
--message-assistant-border: color-mix(in srgb, var(--voice-assistant) 42%, var(--border));
|
||||||
|
--message-user-shadow:
|
||||||
|
0 10px 28px rgb(0 0 0 / 0.28), 0 0 0 1px color-mix(in srgb, var(--voice-user) 16%, rgb(255 255 255 / 0.05)) inset,
|
||||||
|
0 0 28px -14px color-mix(in srgb, var(--voice-user) 66%, transparent);
|
||||||
|
--message-assistant-shadow:
|
||||||
|
0 10px 28px rgb(0 0 0 / 0.28),
|
||||||
|
0 0 0 1px color-mix(in srgb, var(--voice-assistant) 14%, rgb(255 255 255 / 0.05)) inset,
|
||||||
|
0 0 28px -14px color-mix(in srgb, var(--voice-assistant) 62%, transparent);
|
||||||
|
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
}
|
}
|
||||||
@@ -133,6 +154,12 @@
|
|||||||
--shadow-soft: 0 6px 20px rgba(15, 23, 42, 0.06);
|
--shadow-soft: 0 6px 20px rgba(15, 23, 42, 0.06);
|
||||||
--shadow-glow-brand: 0 0 28px -6px rgba(37, 99, 235, 0.45), 0 0 60px -18px rgba(59, 130, 246, 0.4);
|
--shadow-glow-brand: 0 0 28px -6px rgba(37, 99, 235, 0.45), 0 0 60px -18px rgba(59, 130, 246, 0.4);
|
||||||
--shadow-glass: 0 2px 12px rgba(15, 23, 42, 0.06), 0 1px 3px rgba(15, 23, 42, 0.04);
|
--shadow-glass: 0 2px 12px rgba(15, 23, 42, 0.06), 0 1px 3px rgba(15, 23, 42, 0.04);
|
||||||
|
--message-user-background: #dbeafe;
|
||||||
|
--message-assistant-background: #f3f4f6;
|
||||||
|
--message-user-border: transparent;
|
||||||
|
--message-assistant-border: transparent;
|
||||||
|
--message-user-shadow: none;
|
||||||
|
--message-assistant-shadow: none;
|
||||||
|
|
||||||
--radius-sm: 10px;
|
--radius-sm: 10px;
|
||||||
--radius-md: 16px;
|
--radius-md: 16px;
|
||||||
|
|||||||
Reference in New Issue
Block a user