fix(frontend): improve responsive transcripts and simplify theme styles
This commit is contained in:
@@ -57,7 +57,8 @@ audio. It is not an unconditional byte-for-byte tunnel.
|
|||||||
| [client.ts](../../vocero-frontend/src/realtime/client.ts) | Realtime protocol, session lifecycle, audio and transcript events |
|
| [client.ts](../../vocero-frontend/src/realtime/client.ts) | Realtime protocol, session lifecycle, audio and transcript events |
|
||||||
| [components](../../vocero-frontend/src/components) | Chat, caller profile, settings and voice components |
|
| [components](../../vocero-frontend/src/components) | Chat, caller profile, settings and voice components |
|
||||||
| [audio](../../vocero-frontend/src/audio) | TypeScript capture/playback worklets, PCM codec, and visualizer |
|
| [audio](../../vocero-frontend/src/audio) | TypeScript capture/playback worklets, PCM codec, and visualizer |
|
||||||
| [legacy-theme.css](../../vocero-frontend/src/styles/legacy-theme.css) | Retained themes and responsive visual styles alongside Tailwind |
|
| [legacy-theme.css](../../vocero-frontend/src/styles/legacy-theme.css) | Theme tokens, voice artwork and component appearance |
|
||||||
|
| [layout.css](../../vocero-frontend/src/styles/layout.css) | Shared responsive layout, transcript sizing and reduced-motion behavior |
|
||||||
|
|
||||||
## Server routes
|
## Server routes
|
||||||
|
|
||||||
@@ -95,6 +96,12 @@ describe service-owned structure and retain loose upstream values. See
|
|||||||
4. Assistant PCM16 audio at the current 16 kHz protocol rate is decoded and sent to
|
4. Assistant PCM16 audio at the current 16 kHz protocol rate is decoded and sent to
|
||||||
[audio-playback.worklet.ts](../../vocero-frontend/src/audio/audio-playback.worklet.ts), which buffers,
|
[audio-playback.worklet.ts](../../vocero-frontend/src/audio/audio-playback.worklet.ts), which buffers,
|
||||||
resamples and fades playback edges. Input speech can clear playback for barge-in.
|
resamples and fades playback edges. Input speech can clear playback for barge-in.
|
||||||
|
The client flushes short final chunks on response completion and remains in
|
||||||
|
the speaking state until the worklet reports that playback has drained.
|
||||||
|
Sequence numbers reject drain notifications from superseded playback.
|
||||||
|
|
||||||
|
Microphone meter subscribers are separate from application-state subscribers,
|
||||||
|
so level updates do not rerender the main React tree.
|
||||||
|
|
||||||
Audio worklets are loaded through `AudioWorklet.addModule`; keep their processor
|
Audio worklets are loaded through `AudioWorklet.addModule`; keep their processor
|
||||||
registration structure intact when editing them. The speech backend's thread
|
registration structure intact when editing them. The speech backend's thread
|
||||||
@@ -105,6 +112,8 @@ and cancellation model is documented in [realtime API](../speech/realtime-api.md
|
|||||||
The browser triggers external reasoning on completed user transcripts only in
|
The browser triggers external reasoning on completed user transcripts only in
|
||||||
`OPENAI` mode. A non-empty answer replaces the native response through the
|
`OPENAI` mode. A non-empty answer replaces the native response through the
|
||||||
external-answer path. These HTTP operations supplement the realtime audio path.
|
external-answer path. These HTTP operations supplement the realtime audio path.
|
||||||
|
New user speech and session teardown cancel pending reasoning. Turn identity
|
||||||
|
checks reject stale HTTP results and speech callbacks even if cancellation is late.
|
||||||
|
|
||||||
Read [reasoning](reasoning.md), [context injection](context-injection.md), and
|
Read [reasoning](reasoning.md), [context injection](context-injection.md), and
|
||||||
[tool calling](tool-calling.md) for the conditions and limitations of each path.
|
[tool calling](tool-calling.md) for the conditions and limitations of each path.
|
||||||
|
|||||||
+25
-6
@@ -117,6 +117,10 @@ browsable pages.
|
|||||||
Voice, editable system instructions and connection settings are kept in browser
|
Voice, editable system instructions and connection settings are kept in browser
|
||||||
`localStorage` under `s2s.ws.*` keys. A non-empty `S2S_PRIMARY_PROMPT` pins the
|
`localStorage` under `s2s.ws.*` keys. A non-empty `S2S_PRIMARY_PROMPT` pins the
|
||||||
instruction field. Restart applies connection/voice changes to a new session.
|
instruction field. Restart applies connection/voice changes to a new session.
|
||||||
|
Settings and tool preferences use a draft: **Guardar** applies them and
|
||||||
|
**Cancelar** or dialog dismissal discards unsaved edits. Voice and instructions
|
||||||
|
update the live session on Save; connection changes apply on the next start.
|
||||||
|
Noise-gate calibration is explicitly live and saves immediately.
|
||||||
Model voice names are runtime identifiers and stay unchanged across UI
|
Model voice names are runtime identifiers and stay unchanged across UI
|
||||||
translations.
|
translations.
|
||||||
|
|
||||||
@@ -135,19 +139,33 @@ enabled data sources and limits.
|
|||||||
`S2S_THEME=white` selects the light interface. `dark` or an empty value selects
|
`S2S_THEME=white` selects the light interface. `dark` or an empty value selects
|
||||||
the dark aurora theme. Theme-specific sections remain hidden until configuration
|
the dark aurora theme. Theme-specific sections remain hidden until configuration
|
||||||
resolves, with a fallback to avoid an invisible page after config failure.
|
resolves, with a fallback to avoid an invisible page after config failure.
|
||||||
|
Configuration failures offer **Reintentar** and block session startup until a
|
||||||
|
retry succeeds. Reports load independently. Caller context gets an eight-second
|
||||||
|
loading window before the interface allows a generic session.
|
||||||
|
|
||||||
`S2S_START_SESSION_MODE` controls initial microphone/session startup:
|
`S2S_START_SESSION_MODE` controls initial microphone/session startup:
|
||||||
|
|
||||||
| Value | Behavior |
|
| Value | Behavior |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Empty/default | User taps to start |
|
| Empty/default | User taps to start |
|
||||||
| `TALK` | After a 2-second warmup, show an audio unlocker; a user gesture starts the session and greeting |
|
| `TALK` | After a 2-second warmup, show a dismissible startup dialog; a user gesture starts the session and greeting |
|
||||||
| `TALKFIRST` | After a 2-second warmup, attempt automatic start and inject the Spanish wake phrase; microphone failure falls back to the unlocker |
|
| `TALKFIRST` | After a 2-second warmup, attempt automatic start and inject the Spanish wake phrase; microphone failure falls back to the startup dialog |
|
||||||
|
|
||||||
The warmup is a timer, not proof that all upstream context requests have
|
The warmup starts after configuration and caller-context loading settle;
|
||||||
finished. Browser permission and audio policies can still require a gesture.
|
optional reports and tools can still be loading. Browser permission and audio
|
||||||
|
policies can still require a gesture.
|
||||||
The greeting uses the same `hola vocero` input as wake-word handling.
|
The greeting uses the same `hola vocero` input as wake-word handling.
|
||||||
|
|
||||||
|
On browsers with speech recognition, **Activar con «hola vocero»** opts into
|
||||||
|
wake-word listening for the current page session. The interface shows when the
|
||||||
|
microphone is listening for that phrase. It is off by default, pauses while a
|
||||||
|
dialog or voice session is active, and turns off when the conversation stops.
|
||||||
|
|
||||||
|
The transcript keeps your reading position when new messages arrive. **Ir al
|
||||||
|
último mensaje** resumes following the conversation. Names and Santiago-local
|
||||||
|
times appear with each message; downloaded JSON and text retain raw timestamps
|
||||||
|
and message text. The interface respects the browser's reduced-motion setting.
|
||||||
|
|
||||||
## Queue and session lifecycle
|
## Queue and session lifecycle
|
||||||
|
|
||||||
Load-balancer mode proxies `POST /api/session`. A free compute slot returns a
|
Load-balancer mode proxies `POST /api/session`. A free compute slot returns a
|
||||||
@@ -161,8 +179,9 @@ to a keepalive fetch. Expired tickets and missed join deadlines require a new
|
|||||||
attempt. Full queues return `503` with `state: "at_capacity"`.
|
attempt. Full queues return `503` with `state: "at_capacity"`.
|
||||||
|
|
||||||
Once connected, session teardown closes the microphone, audio graph and
|
Once connected, session teardown closes the microphone, audio graph and
|
||||||
WebSocket. The 30-second inactivity timeout remains active when the assistant
|
WebSocket. The 30-second inactivity timeout counts time waiting for user input;
|
||||||
is not speaking. Caller-profile lookup and upstream authorization operate
|
processing, external reasoning and buffered playback do not consume it.
|
||||||
|
Caller-profile lookup and upstream authorization operate
|
||||||
independently of allocation and use their existing configured endpoints.
|
independently of allocation and use their existing configured endpoints.
|
||||||
|
|
||||||
For external transcript processing, configure the selected
|
For external transcript processing, configure the selected
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ the implementation at the recorded date; current operating instructions live in
|
|||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
- Consolidate responsive transcript layout, keep export controls visible on narrow screens,
|
||||||
|
and place feedback and version details below the conversation. Add jump-to-latest,
|
||||||
|
semantic message metadata, state-aware empty hints, shared sanitized Markdown rendering,
|
||||||
|
and reduced-motion support. Remove retired camera, account, and overlay styles.
|
||||||
|
|
||||||
- Use native dialogs for startup and mobile reports, restore focus on dismissal,
|
- Use native dialogs for startup and mobile reports, restore focus on dismissal,
|
||||||
and make duplicate transcript bubbles inert. Require explicit wake-word opt-in,
|
and make duplicate transcript bubbles inert. Require explicit wake-word opt-in,
|
||||||
show when it is listening, and turn it off when the conversation stops.
|
show when it is listening, and turn it off when the conversation stops.
|
||||||
|
|||||||
@@ -208,11 +208,6 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</header>
|
</header>
|
||||||
<nav className="feedback-navigation" aria-label="Comentarios">
|
|
||||||
<a className="feedback-link" href="https://tally.so/r/pb8LWq" target="_blank" rel="noopener noreferrer">
|
|
||||||
Enviar comentarios
|
|
||||||
</a>
|
|
||||||
</nav>
|
|
||||||
{state.error && (
|
{state.error && (
|
||||||
<div className="app-error" role="alert">
|
<div className="app-error" role="alert">
|
||||||
<span>{state.error}</span>
|
<span>{state.error}</span>
|
||||||
@@ -229,10 +224,16 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<VoiceStage state={state} controller={controller} />
|
<VoiceStage state={state} controller={controller} />
|
||||||
{ready && !ACTIVE_STATES.has(state.status) && !modal && !state.unlock && !reportsOpen && (
|
{ready && !ACTIVE_STATES.has(state.status) && !modal && !state.unlock && !(reportsOpen && !desktop) && (
|
||||||
<WakeWordControl enabled={state.wakeWordEnabled} controller={controller} />
|
<WakeWordControl enabled={state.wakeWordEnabled} controller={controller} />
|
||||||
)}
|
)}
|
||||||
<Conversation messages={state.messages} userName={name || "Usuario"} status={state.status} white={white} />
|
<Conversation
|
||||||
|
messages={state.messages}
|
||||||
|
userName={name || "Usuario"}
|
||||||
|
status={state.status}
|
||||||
|
white={white}
|
||||||
|
muted={state.muted}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
{!white && ACTIVE_STATES.has(state.status) && <Bubbles messages={state.messages} userName={name || "Usuario"} />}
|
{!white && ACTIVE_STATES.has(state.status) && <Bubbles messages={state.messages} userName={name || "Usuario"} />}
|
||||||
{hasReports && state.reports && (
|
{hasReports && state.reports && (
|
||||||
@@ -250,7 +251,10 @@ 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="Versión de la aplicación">
|
<footer className="app-footer" aria-label="Información de la aplicación">
|
||||||
|
<a className="feedback-link" href="https://tally.so/r/pb8LWq" target="_blank" rel="noopener noreferrer">
|
||||||
|
Enviar comentarios
|
||||||
|
</a>
|
||||||
<span className="app-footer-text">Azurian Vocero en tiempo real ver. Beta 2.1</span>
|
<span className="app-footer-text">Azurian Vocero en tiempo real ver. Beta 2.1</span>
|
||||||
</footer>
|
</footer>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { memo, useMemo } from "react";
|
||||||
|
|
||||||
|
import { renderMarkdown } from "../lib/messages";
|
||||||
|
|
||||||
|
export const AssistantMarkdown = memo(function AssistantMarkdown({
|
||||||
|
text,
|
||||||
|
className = "",
|
||||||
|
}: {
|
||||||
|
text: string;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const html = useMemo(() => renderMarkdown(text), [text]);
|
||||||
|
return <div className={`markdown-content ${className}`} dangerouslySetInnerHTML={{ __html: html }} />;
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { memo, useEffect, useState } from "react";
|
import { memo, useEffect, useState } from "react";
|
||||||
|
|
||||||
import { renderMarkdown, type Message } from "../lib/messages";
|
import type { Message } from "../lib/messages";
|
||||||
|
import { AssistantMarkdown } from "./AssistantMarkdown";
|
||||||
|
|
||||||
function Bubble({ message, userName }: { message: Message; userName: string }) {
|
function Bubble({ message, userName }: { message: Message; userName: string }) {
|
||||||
const [expiredText, setExpiredText] = useState<string | null>(null);
|
const [expiredText, setExpiredText] = useState<string | null>(null);
|
||||||
@@ -24,10 +25,7 @@ function Bubble({ message, userName }: { message: Message; userName: string }) {
|
|||||||
{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" ? (
|
||||||
<div
|
<AssistantMarkdown text={message.text} className="bubble-body" />
|
||||||
className="bubble-body markdown-content"
|
|
||||||
dangerouslySetInnerHTML={{ __html: renderMarkdown(message.text) }}
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="bubble-body">{message.role === "tool" ? message.toolName : message.text}</div>
|
<div className="bubble-body">{message.role === "tool" ? message.toolName : message.text}</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { expect, it } from "vitest";
|
||||||
|
|
||||||
|
import type { Message } from "../lib/messages";
|
||||||
|
import { Conversation } from "./Conversation";
|
||||||
|
|
||||||
|
const message: Message = {
|
||||||
|
id: "1",
|
||||||
|
role: "assistant",
|
||||||
|
text: "Una respuesta con [enlace](https://example.test)",
|
||||||
|
timestamp: "2026-10-07T12:00:00Z",
|
||||||
|
partial: false,
|
||||||
|
};
|
||||||
|
it("renders accessible names, a real timestamp, and sanitized assistant content", () => {
|
||||||
|
render(<Conversation messages={[message]} userName="Ana" status="listening" white muted={false} />);
|
||||||
|
expect(screen.getByText("Vocero")).toBeVisible();
|
||||||
|
expect(screen.getByText(/^09:00/).closest("time")).toHaveAttribute("datetime", message.timestamp);
|
||||||
|
expect(screen.getByRole("link", { name: "enlace" })).toHaveAttribute("rel", "noopener noreferrer");
|
||||||
|
});
|
||||||
|
it("preserves a reader's scroll position during updates and offers a jump to the latest message", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const view = render(<Conversation messages={[message]} userName="Ana" status="listening" white muted={false} />);
|
||||||
|
const history = screen.getByRole("log");
|
||||||
|
Object.defineProperties(history, { scrollHeight: { configurable: true, value: 1000 }, clientHeight: { value: 200 } });
|
||||||
|
history.scrollTop = 200;
|
||||||
|
fireEvent.scroll(history);
|
||||||
|
view.rerender(
|
||||||
|
<Conversation
|
||||||
|
messages={[message, { ...message, id: "2", text: "Nueva respuesta" }]}
|
||||||
|
userName="Ana"
|
||||||
|
status="listening"
|
||||||
|
white
|
||||||
|
muted={false}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(history.scrollTop).toBe(200);
|
||||||
|
await user.click(screen.getByRole("button", { name: "Ir al último mensaje" }));
|
||||||
|
expect(history.scrollTop).toBe(1000);
|
||||||
|
expect(history).toHaveFocus();
|
||||||
|
expect(screen.queryByRole("button", { name: "Ir al último mensaje" })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
it("gives useful empty-state instructions during a live session and when muted", () => {
|
||||||
|
const view = render(<Conversation messages={[]} userName="Ana" status="listening" white muted={false} />);
|
||||||
|
expect(screen.getByText("Habla cuando quieras. Tu conversación aparecerá aquí.")).toBeVisible();
|
||||||
|
view.rerender(<Conversation messages={[]} userName="Ana" status="listening" white muted />);
|
||||||
|
expect(screen.getByText("Activa el micrófono para hablar")).toBeVisible();
|
||||||
|
});
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import { Download, MessageCircle, Wrench } from "lucide-react";
|
import { ArrowDown, 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";
|
||||||
import type { ConnectionStatus } from "../lib/contracts";
|
import type { ConnectionStatus } from "../lib/contracts";
|
||||||
import { displayTime, exportConversation, renderMarkdown, 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";
|
||||||
|
|
||||||
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);
|
||||||
@@ -33,25 +34,19 @@ const MessageRow = memo(function MessageRow({ message, userName }: { message: Me
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const data = {
|
|
||||||
"data-user-name": userName,
|
|
||||||
"data-user-initials": initials(userName),
|
|
||||||
"data-asst-name": "Vocero",
|
|
||||||
"data-timestamp": time,
|
|
||||||
};
|
|
||||||
return (
|
return (
|
||||||
<div className={`hist-msg ${message.role} ${message.interrupted ? "interrupted" : ""}`} {...data}>
|
<div
|
||||||
<div className="hist-role">{message.role === "user" ? userName : "Vocero"}</div>
|
className={`hist-msg ${message.role} ${message.interrupted ? "interrupted" : ""}`}
|
||||||
|
data-user-initials={initials(userName)}
|
||||||
|
>
|
||||||
|
<div className="hist-meta">
|
||||||
|
<span>{message.role === "user" ? userName : "Vocero"}</span>
|
||||||
|
<time dateTime={message.timestamp}>{time}</time>
|
||||||
|
</div>
|
||||||
{message.role === "assistant" ? (
|
{message.role === "assistant" ? (
|
||||||
<div
|
<AssistantMarkdown text={message.text} className={`hist-body ${message.partial ? "partial" : ""}`} />
|
||||||
className={`hist-body markdown-content ${message.partial ? "partial" : ""}`}
|
|
||||||
{...data}
|
|
||||||
dangerouslySetInnerHTML={{ __html: renderMarkdown(message.text) }}
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<div className={`hist-body ${message.partial ? "partial" : ""}`} {...data}>
|
<div className={`hist-body ${message.partial ? "partial" : ""}`}>{message.text}</div>
|
||||||
{message.text}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
{message.interrupted && <div className="hist-note">Interrumpido</div>}
|
{message.interrupted && <div className="hist-note">Interrumpido</div>}
|
||||||
</div>
|
</div>
|
||||||
@@ -62,14 +57,17 @@ export const Conversation = memo(function Conversation({
|
|||||||
userName,
|
userName,
|
||||||
status,
|
status,
|
||||||
white,
|
white,
|
||||||
|
muted,
|
||||||
}: {
|
}: {
|
||||||
messages: Message[];
|
messages: Message[];
|
||||||
userName: string;
|
userName: string;
|
||||||
status: ConnectionStatus;
|
status: ConnectionStatus;
|
||||||
white: boolean;
|
white: boolean;
|
||||||
|
muted: boolean;
|
||||||
}) {
|
}) {
|
||||||
const history = useRef<HTMLDivElement>(null);
|
const history = useRef<HTMLDivElement>(null);
|
||||||
const atBottom = useRef(true);
|
const atBottom = useRef(true);
|
||||||
|
const [showLatest, setShowLatest] = useState(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const element = history.current;
|
const element = history.current;
|
||||||
if (element && atBottom.current) {
|
if (element && atBottom.current) {
|
||||||
@@ -115,12 +113,15 @@ export const Conversation = memo(function Conversation({
|
|||||||
id="chat-history"
|
id="chat-history"
|
||||||
className="chat-history"
|
className="chat-history"
|
||||||
role="log"
|
role="log"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label="Mensajes de la conversación"
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
aria-atomic="false"
|
aria-atomic="false"
|
||||||
onScroll={() => {
|
onScroll={() => {
|
||||||
const element = history.current;
|
const element = history.current;
|
||||||
if (element) {
|
if (element) {
|
||||||
atBottom.current = element.scrollHeight - element.scrollTop - element.clientHeight < 60;
|
atBottom.current = element.scrollHeight - element.scrollTop - element.clientHeight < 60;
|
||||||
|
setShowLatest(!atBottom.current);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -130,10 +131,35 @@ export const Conversation = memo(function Conversation({
|
|||||||
<div className="chat-empty">
|
<div className="chat-empty">
|
||||||
<MessageCircle aria-hidden="true" />
|
<MessageCircle aria-hidden="true" />
|
||||||
<span className="chat-empty-title">Aún no hay mensajes</span>
|
<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>
|
<span className="chat-empty-hint">
|
||||||
|
{muted
|
||||||
|
? "Activa el micrófono para hablar"
|
||||||
|
: live
|
||||||
|
? "Habla cuando quieras. Tu conversación aparecerá aquí."
|
||||||
|
: waiting
|
||||||
|
? "Tu conversación aparecerá aquí al conectar."
|
||||||
|
: "Toca el círculo y empieza a hablar"}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{showLatest && (
|
||||||
|
<button
|
||||||
|
className="jump-to-latest btn secondary"
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (history.current) {
|
||||||
|
history.current.scrollTop = history.current.scrollHeight;
|
||||||
|
}
|
||||||
|
atBottom.current = true;
|
||||||
|
setShowLatest(false);
|
||||||
|
history.current?.focus({ preventScroll: true });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ArrowDown size={16} aria-hidden="true" />
|
||||||
|
Ir al último mensaje
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { reportSuggestions } from "../lib/reports";
|
|||||||
import { Dialog } from "./Dialog";
|
import { Dialog } from "./Dialog";
|
||||||
|
|
||||||
function ReportContent({ reports }: { reports: Reports }) {
|
function ReportContent({ reports }: { reports: Reports }) {
|
||||||
|
const suggestions = reportSuggestions(reports);
|
||||||
return (
|
return (
|
||||||
<div className="reports-content">
|
<div className="reports-content">
|
||||||
<div className="reports-hint">
|
<div className="reports-hint">
|
||||||
@@ -14,14 +15,16 @@ function ReportContent({ reports }: { reports: Reports }) {
|
|||||||
</ul>
|
</ul>
|
||||||
{reports.note && <p>{reports.note}</p>}
|
{reports.note && <p>{reports.note}</p>}
|
||||||
</div>
|
</div>
|
||||||
<div className="reports-suggestions">
|
{suggestions.length > 0 && (
|
||||||
<h3 className="reports-suggestions-title">Puedes preguntar</h3>
|
<div className="reports-suggestions">
|
||||||
<ul className="reports-suggestions-list">
|
<h3 className="reports-suggestions-title">Puedes preguntar</h3>
|
||||||
{reportSuggestions(reports).map((suggestion) => (
|
<ul className="reports-suggestions-list">
|
||||||
<li key={suggestion}>{suggestion}</li>
|
{suggestions.map((suggestion) => (
|
||||||
))}
|
<li key={suggestion}>{suggestion}</li>
|
||||||
</ul>
|
))}
|
||||||
</div>
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,9 +15,7 @@ class Recognition {
|
|||||||
onend: (() => void) | null = null;
|
onend: (() => void) | null = null;
|
||||||
onerror = null;
|
onerror = null;
|
||||||
onresult = null;
|
onresult = null;
|
||||||
start = vi.fn(() => {
|
start = vi.fn();
|
||||||
this.onstart?.();
|
|
||||||
});
|
|
||||||
abort = vi.fn();
|
abort = vi.fn();
|
||||||
constructor() {
|
constructor() {
|
||||||
Recognition.instances.push(this);
|
Recognition.instances.push(this);
|
||||||
@@ -39,11 +37,19 @@ it("requires opt-in, displays actual listening, and releases recognition on Stop
|
|||||||
expect(Recognition.instances).toHaveLength(0);
|
expect(Recognition.instances).toHaveLength(0);
|
||||||
await user.click(screen.getByRole("checkbox"));
|
await user.click(screen.getByRole("checkbox"));
|
||||||
expect(Recognition.instances).toHaveLength(1);
|
expect(Recognition.instances).toHaveLength(1);
|
||||||
|
expect(screen.getByRole("status")).toHaveTextContent("Preparando activación");
|
||||||
|
act(() => {
|
||||||
|
Recognition.instances[0]?.onstart?.();
|
||||||
|
});
|
||||||
expect(screen.getByRole("status")).toHaveTextContent("Micrófono activo");
|
expect(screen.getByRole("status")).toHaveTextContent("Micrófono activo");
|
||||||
|
await user.click(screen.getByRole("checkbox"));
|
||||||
|
await user.click(screen.getByRole("checkbox"));
|
||||||
|
expect(screen.getByRole("status")).toHaveTextContent("Preparando activación");
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await controller.stop();
|
await controller.stop();
|
||||||
});
|
});
|
||||||
expect(Recognition.instances[0]?.abort).toHaveBeenCalledOnce();
|
expect(Recognition.instances[0]?.abort).toHaveBeenCalledOnce();
|
||||||
|
expect(Recognition.instances[1]?.abort).toHaveBeenCalledOnce();
|
||||||
expect(screen.getByRole("checkbox")).not.toBeChecked();
|
expect(screen.getByRole("checkbox")).not.toBeChecked();
|
||||||
expect(Recognition.instances).toHaveLength(1);
|
expect(Recognition.instances).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,20 @@
|
|||||||
import { useWakeWord } from "../hooks/use-wake-word";
|
import { useWakeWord } from "../hooks/use-wake-word";
|
||||||
import type { VoiceController } from "../realtime/controller";
|
import type { VoiceController } from "../realtime/controller";
|
||||||
|
|
||||||
|
function WakeWordStatus({ controller }: { controller: VoiceController }) {
|
||||||
|
const status = useWakeWord(true, controller);
|
||||||
|
return (
|
||||||
|
<span role="status">
|
||||||
|
{status === "listening"
|
||||||
|
? "Micrófono activo: escuchando la frase de activación."
|
||||||
|
: status === "blocked"
|
||||||
|
? "No se pudo activar. Revisa el permiso del micrófono y vuelve a marcar la opción."
|
||||||
|
: "Preparando activación por voz…"}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
export function WakeWordControl({ enabled, controller }: { enabled: boolean; controller: VoiceController }) {
|
export function WakeWordControl({ enabled, controller }: { enabled: boolean; controller: VoiceController }) {
|
||||||
const status = useWakeWord(enabled, controller);
|
if (!(window.SpeechRecognition ?? window.webkitSpeechRecognition)) {
|
||||||
if (status === "unsupported") {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
@@ -18,15 +29,11 @@ export function WakeWordControl({ enabled, controller }: { enabled: boolean; con
|
|||||||
/>
|
/>
|
||||||
Activar con «hola vocero»
|
Activar con «hola vocero»
|
||||||
</label>
|
</label>
|
||||||
<span role="status">
|
{enabled ? (
|
||||||
{status === "listening"
|
<WakeWordStatus controller={controller} />
|
||||||
? "Micrófono activo: escuchando la frase de activación."
|
) : (
|
||||||
: status === "blocked"
|
<span role="status">Opcional. Se desactiva al detener la conversación.</span>
|
||||||
? "No se pudo activar. Revisa el permiso del micrófono y vuelve a marcar la opción."
|
)}
|
||||||
: status === "starting"
|
|
||||||
? "Preparando activación por voz…"
|
|
||||||
: "Opcional. Se desactiva al detener la conversación."}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ describe("assistant Markdown and export", () => {
|
|||||||
container.innerHTML = html;
|
container.innerHTML = html;
|
||||||
expect(container.querySelector("h2")?.textContent).toBe("Resumen");
|
expect(container.querySelector("h2")?.textContent).toBe("Resumen");
|
||||||
expect(container.querySelector("strong")?.textContent).toBe("Bien");
|
expect(container.querySelector("strong")?.textContent).toBe("Bien");
|
||||||
expect(container.querySelector("[role='region']")?.getAttribute("tabindex")).toBe("0");
|
expect(container.querySelector("[role='group']")?.getAttribute("tabindex")).toBe("0");
|
||||||
expect(container.querySelector("script")).toBeNull();
|
expect(container.querySelector("script")).toBeNull();
|
||||||
expect(container.querySelector("[href^='javascript:']")).toBeNull();
|
expect(container.querySelector("[href^='javascript:']")).toBeNull();
|
||||||
expect(container.querySelector("a")?.getAttribute("rel")).toBe("noopener noreferrer");
|
expect(container.querySelector("a")?.getAttribute("rel")).toBe("noopener noreferrer");
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ export function renderMarkdown(text: string): string {
|
|||||||
const wrapper = document.createElement("div");
|
const wrapper = document.createElement("div");
|
||||||
wrapper.className = "markdown-table";
|
wrapper.className = "markdown-table";
|
||||||
wrapper.tabIndex = 0;
|
wrapper.tabIndex = 0;
|
||||||
wrapper.setAttribute("role", "region");
|
wrapper.setAttribute("role", "group");
|
||||||
wrapper.setAttribute("aria-label", "Tabla de la respuesta");
|
wrapper.setAttribute("aria-label", "Tabla de la respuesta");
|
||||||
table.replaceWith(wrapper);
|
table.replaceWith(wrapper);
|
||||||
wrapper.append(table);
|
wrapper.append(table);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
@layer theme, base, components, utilities;
|
@layer theme, base, components, utilities;
|
||||||
@import "tailwindcss/theme.css" layer(theme);
|
@import "tailwindcss/theme.css" layer(theme);
|
||||||
@import "./styles/legacy-theme.css" layer(components);
|
@import "./styles/legacy-theme.css" layer(components);
|
||||||
|
@import "./styles/layout.css" layer(components);
|
||||||
@import "tailwindcss/utilities.css" layer(utilities);
|
@import "tailwindcss/utilities.css" layer(utilities);
|
||||||
|
|
||||||
@layer components {
|
@layer components {
|
||||||
@@ -13,10 +14,6 @@
|
|||||||
html[data-theme="white"] .chat-empty-hint {
|
html[data-theme="white"] .chat-empty-hint {
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
}
|
}
|
||||||
.feedback-navigation {
|
|
||||||
align-self: flex-end;
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
#root {
|
#root {
|
||||||
display: contents;
|
display: contents;
|
||||||
}
|
}
|
||||||
@@ -35,9 +32,6 @@
|
|||||||
html[data-theme="white"] .white-mic-circle svg.wmc-mic {
|
html[data-theme="white"] .white-mic-circle svg.wmc-mic {
|
||||||
fill: none;
|
fill: none;
|
||||||
}
|
}
|
||||||
#app:not(.has-reports) {
|
|
||||||
padding-left: 0;
|
|
||||||
}
|
|
||||||
.loading-screen {
|
.loading-screen {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
|
|||||||
@@ -0,0 +1,298 @@
|
|||||||
|
/* Shared layout. Theme colors and voice artwork remain in legacy-theme.css. */
|
||||||
|
body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: auto;
|
||||||
|
min-height: 100dvh;
|
||||||
|
padding: max(8px, env(safe-area-inset-top)) max(8px, env(safe-area-inset-right)) 0 max(8px, env(safe-area-inset-left));
|
||||||
|
}
|
||||||
|
#app {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.stage,
|
||||||
|
html[data-theme="white"] .stage {
|
||||||
|
padding: 12px 12px 8px;
|
||||||
|
gap: 12px;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
.chat-panel {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
flex: 1 0 auto;
|
||||||
|
flex-direction: column;
|
||||||
|
height: clamp(240px, 45dvh, 640px);
|
||||||
|
min-width: 0;
|
||||||
|
margin: 8px 12px 0;
|
||||||
|
}
|
||||||
|
.circle-caption {
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
.chat-panel-inner {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
max-width: 920px;
|
||||||
|
margin-inline: auto;
|
||||||
|
padding: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.chat-panel-header {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 0 0 10px;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
.chat-panel-header h2 {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.chat-header-actions,
|
||||||
|
html[data-theme="white"] .chat-header-actions {
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.chat-export-btn {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
.chat-history {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 12px 4px;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
overflow-anchor: none;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
}
|
||||||
|
.chat-history:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: -2px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.hist-msg {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex-shrink: 0;
|
||||||
|
gap: 6px;
|
||||||
|
max-width: 86%;
|
||||||
|
min-width: 0;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.hist-msg.user {
|
||||||
|
align-self: flex-start;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
.hist-msg.assistant {
|
||||||
|
align-self: flex-end;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
.hist-msg.tool {
|
||||||
|
align-self: flex-start;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
.hist-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 4px 10px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.hist-meta span {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.hist-meta time {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.hist-body {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.hist-body:not(.markdown-content) {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
.jump-to-latest {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
position: absolute;
|
||||||
|
bottom: 18px;
|
||||||
|
right: 18px;
|
||||||
|
z-index: 2;
|
||||||
|
gap: 6px;
|
||||||
|
box-shadow: var(--shadow-soft);
|
||||||
|
}
|
||||||
|
html[data-theme="white"] .chat-panel {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 20px;
|
||||||
|
box-shadow: var(--shadow-soft);
|
||||||
|
}
|
||||||
|
html[data-theme="white"] .chat-panel-header {
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
html[data-theme="white"] .chat-history .hist-msg {
|
||||||
|
max-width: calc(100% - 40px);
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
html[data-theme="white"] .chat-history .hist-msg.assistant {
|
||||||
|
align-self: flex-start;
|
||||||
|
margin: 4px auto 4px 40px;
|
||||||
|
}
|
||||||
|
html[data-theme="white"] .chat-history .hist-msg.user {
|
||||||
|
align-self: flex-end;
|
||||||
|
margin: 4px 40px 4px auto;
|
||||||
|
}
|
||||||
|
html[data-theme="white"] .chat-history .hist-msg.tool {
|
||||||
|
max-width: 100%;
|
||||||
|
margin: 4px 0;
|
||||||
|
}
|
||||||
|
html[data-theme="white"] .chat-history .hist-msg::before {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
html[data-theme="white"] .hist-meta {
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
html[data-theme="white"] .hist-msg.user .hist-meta {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.app-footer {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 16px 12px max(12px, env(safe-area-inset-bottom));
|
||||||
|
flex: none;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.feedback-link {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 3px;
|
||||||
|
border: 0;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
.feedback-link:hover {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.feedback-link:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 3px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
#app.has-reports {
|
||||||
|
padding-left: 320px;
|
||||||
|
}
|
||||||
|
body:has(#app.has-reports) .app-footer {
|
||||||
|
margin-left: 320px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.topbar {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.topbar-right {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.chat-panel {
|
||||||
|
margin-inline: 0;
|
||||||
|
}
|
||||||
|
.chat-panel-inner {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
html[data-theme="white"] .chat-panel-header h2 {
|
||||||
|
font-size: 17px;
|
||||||
|
}
|
||||||
|
html[data-theme="white"] .chat-status-pill {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 5px 7px;
|
||||||
|
}
|
||||||
|
.orb-wrap {
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
.circle {
|
||||||
|
width: clamp(110px, 32vw, 176px);
|
||||||
|
}
|
||||||
|
.orb-wrap.live .side-btn {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
}
|
||||||
|
.mic-gate-arc {
|
||||||
|
width: 62px;
|
||||||
|
height: 62px;
|
||||||
|
}
|
||||||
|
html[data-theme="white"] .white-mic-stage {
|
||||||
|
gap: 12px;
|
||||||
|
padding-top: 16px;
|
||||||
|
}
|
||||||
|
html[data-theme="white"] .white-waveform {
|
||||||
|
top: 74px;
|
||||||
|
}
|
||||||
|
html[data-theme="white"] .white-session-controls {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
html[data-theme="white"] .white-mute-btn,
|
||||||
|
html[data-theme="white"] .white-stop-btn {
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
min-height: 44px;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (max-height: 640px) {
|
||||||
|
.stage,
|
||||||
|
html[data-theme="white"] .stage {
|
||||||
|
padding-block: 8px;
|
||||||
|
}
|
||||||
|
html[data-theme="white"] .white-mic-stage {
|
||||||
|
padding-top: 8px;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
html[data-theme="white"] .white-waveform {
|
||||||
|
top: 57px;
|
||||||
|
}
|
||||||
|
html[data-theme="white"] .white-mic-caption {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
:root {
|
||||||
|
--audio-level: 0 !important;
|
||||||
|
--ai-audio-level: 0 !important;
|
||||||
|
--bar0: 0 !important;
|
||||||
|
--bar1: 0 !important;
|
||||||
|
--bar2: 0 !important;
|
||||||
|
--bar3: 0 !important;
|
||||||
|
--bar4: 0 !important;
|
||||||
|
}
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
animation: none !important;
|
||||||
|
transition: none !important;
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
}
|
||||||
|
.circle.state-ai-speaking .bar,
|
||||||
|
.circle.state-user-speaking .bar {
|
||||||
|
transform: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user