Files
PTSRaiAgent/web/src/screens/Messages.tsx
T
ElebobaandClaude Opus 5.5 e20da1f21c
CI / ruff · pytest (pull_request) Successful in 2m24s
feat(web): voice input — record, check the recognised text, send
Microphone button in the chat composer and in free-text steps. The browser
records 16 kHz mono WAV (no server-side ffmpeg), /me/{token}/stt transcribes,
the text lands in the field for the patient to check and edit — never sent
automatically, so safety and scrub() apply on send. Hidden when the server has
voice input off or the browser lacks a microphone API. nginx: 4 MB body limit.

Refs #117

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 11:45:36 +03:00

129 lines
4.5 KiB
TypeScript

import { useEffect, useRef, useState, type FormEvent, type KeyboardEvent } from "react";
import { useNavigate } from "react-router-dom";
import { AppShell, TopBar } from "../components/AppShell";
import { Icon } from "../components/Icon";
import { VoiceInput } from "../components/VoiceInput";
import { ru } from "../content/ru";
import { useDialog } from "../dialog/DialogContext";
// I1 · Сообщения: вся переписка с помощником; быстрые ответы — кнопки последнего сообщения движка.
export function Messages() {
const t = ru.messages;
const navigate = useNavigate();
const dialog = useDialog();
const [draft, setDraft] = useState("");
const endRef = useRef<HTMLDivElement>(null);
const quick = dialog.pending.flatMap((r) => r.buttons.flat());
useEffect(() => {
dialog.markRead();
endRef.current?.scrollIntoView({ block: "end" });
}, [dialog.entries.length, dialog.live?.text, dialog.live?.status.length]);
const send = async (e?: FormEvent) => {
e?.preventDefault();
const text = draft.trim();
if (!text || dialog.busy) return;
setDraft("");
if (!(await dialog.send({ type: "text", text }, text))) setDraft(text);
};
const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
e.preventDefault();
void send();
}
};
return (
<AppShell>
<div className="stack chat">
<TopBar onBack={() => navigate("/today")} title={t.title} />
{dialog.entries.length === 0 && <p className="muted">{t.empty}</p>}
<ol className="bubbles" aria-live="polite">
{dialog.entries.map((e) =>
e.from === "assistant" ? (
<li key={e.id} className={`bubble bubble--bot bubble--${e.reply.kind}`}>
<span className="bubble__avatar">
<Icon name="smile" size={20} />
</span>
<p>{e.reply.text}</p>
</li>
) : (
<li key={e.id} className="bubble bubble--me">
<span className="visually-hidden">{t.you}: </span>
<p>{e.text}</p>
</li>
),
)}
{dialog.live && (
<li className="bubble bubble--bot bubble--live" aria-busy="true">
<span className="bubble__avatar">
<Icon name="smile" size={20} />
</span>
<div className="live">
{dialog.live.status.map((st, i) => (
<p key={i} className={`live__step${i === dialog.live!.status.length - 1 && !dialog.live!.text ? " is-active" : ""}`}>
{st}
</p>
))}
{dialog.live.text ? (
<p className="live__text">
{dialog.live.text}
<span className="live__cursor" aria-hidden="true" />
</p>
) : (
<p className="live__dots" aria-label={t.typing}>
<span />
<span />
<span />
</p>
)}
</div>
</li>
)}
</ol>
{quick.length > 0 && (
<div className="quickreplies">
{quick.map((b) => (
<button
key={b.data}
type="button"
className="qr"
disabled={dialog.busy}
onClick={() => void dialog.send({ type: "button", button: b.data }, b.label)}
>
{b.label}
</button>
))}
</div>
)}
<div ref={endRef} />
<form className="composer" onSubmit={send}>
<label htmlFor="msg" className="visually-hidden">
{t.inputLabel}
</label>
<textarea
id="msg"
rows={1}
value={draft}
placeholder={t.inputPlaceholder}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={onKeyDown}
maxLength={2000}
/>
<VoiceInput
compact
disabled={dialog.busy}
onText={(text) => setDraft((prev) => (prev.trim() ? `${prev.trim()} ${text}` : text).slice(0, 2000))}
/>
<button type="submit" className="send" disabled={dialog.busy || !draft.trim()} aria-label={ru.common.send}>
<Icon name="send" size={20} />
</button>
</form>
</div>
</AppShell>
);
}