CI / ruff · pytest (pull_request) Successful in 2m24s
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>
129 lines
4.5 KiB
TypeScript
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>
|
|
);
|
|
}
|