lct-39: пауза останавливает действия и голос

This commit is contained in:
kaifarikman 2026-09-27 18:06:49 +03:00
commit 88bb54fc24
12 changed files with 252 additions and 47 deletions

View file

@ -59,6 +59,7 @@ export function useCall(sessionId: string | null) {
const outbox = useRef<Record<string, unknown>>({});
const patchTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const textMode = useRef(false);
const pausedRef = useRef(false);
const append = useCallback((line: Line) => {
setLines((prev) => {
@ -72,19 +73,10 @@ export function useCall(sessionId: string | null) {
const ch = callChannel(sessionId, {
onStatus: (next) => {
setStatus(next);
if (next === "open") {
// Браузер не подтверждает доставку WebSocket-кадра. Всё, что ещё
// отмечено pending и не пришло эхом от сервера, повторяем после
// reconnect: kio.patch идемпотентен и не создаёт двойных действий.
queueMicrotask(() => setCard((current) => {
if (Object.keys(current.pending).length) {
channel.current?.send({ type: "kio.patch", fields: current.pending });
}
return current;
}));
}
},
onBinary: (frame) => audio.current?.playback.enqueue(frame),
onBinary: (frame) => {
if (!pausedRef.current) audio.current?.playback.enqueue(frame);
},
onEvent: (event: ServerToTrainee) => {
if (event.type === "text.turn.accepted") {
append({ speaker: "operator", text: event.text });
@ -155,7 +147,27 @@ export function useCall(sessionId: string | null) {
setScoreReady(true);
break;
case "session.paused":
pausedRef.current = event.paused;
setPaused(event.paused);
if (event.paused) {
audio.current?.playback.flush();
setCallerSpeaking(false);
if (patchTimer.current) clearTimeout(patchTimer.current);
patchTimer.current = null;
} else {
// При переподключении сервер первым присылает состояние паузы.
// Только после этого повторяем правки, ожидающие подтверждения.
queueMicrotask(() => setCard((current) => {
if (Object.keys(current.pending).length) {
channel.current?.send({ type: "kio.patch", fields: current.pending });
}
if (Object.keys(outbox.current).length) {
channel.current?.send({ type: "kio.patch", fields: outbox.current });
outbox.current = {};
}
return current;
}));
}
break;
case "error":
setError(event.message);
@ -174,13 +186,15 @@ export function useCall(sessionId: string | null) {
}, [sessionId, append]);
const answer = useCallback(async () => {
if (pausedRef.current) return;
try {
setError(null);
// Контекст создаётся по нажатию: браузер не даёт играть звук без действия человека.
const context = new AudioContext({ sampleRate: CALLER_RATE });
audio.current = { context, playback: new Playback(context), ambience: new Ambience(context) };
capture.current = await startCapture((frame) => {
const startedCapture = await startCapture((frame) => {
if (pausedRef.current) return;
const playback = audio.current?.playback;
// Перебивание: фронт гасит звук мгновенно, сервер подтвердит `tts.cancel`.
if (gate.current.push(frame) && playback?.speaking) {
@ -189,6 +203,13 @@ export function useCall(sessionId: string | null) {
}
channel.current?.sendBinary(frame);
});
if (pausedRef.current) {
await startedCapture.stop();
await context.close();
audio.current = null;
return;
}
capture.current = startedCapture;
setMicOn(true);
channel.current?.send({ type: "call.answer" });
} catch (err) {
@ -197,36 +218,45 @@ export function useCall(sessionId: string | null) {
}, []);
const hangup = useCallback(() => {
if (pausedRef.current) return;
channel.current?.send({ type: "call.hangup" });
void capture.current?.stop();
capture.current = null;
setMicOn(false);
}, []);
const hint = useCallback(() => channel.current?.send({ type: "hint.request" }), []);
const hint = useCallback(() => {
if (!pausedRef.current) channel.current?.send({ type: "hint.request" });
}, []);
const patchKio = useCallback((path: string, value: unknown) => {
if (pausedRef.current) return;
setCard((prev) => edit(prev, path, value));
outbox.current[path] = value;
if (patchTimer.current) clearTimeout(patchTimer.current);
patchTimer.current = setTimeout(() => {
const fields = outbox.current;
outbox.current = {};
if (Object.keys(fields).length) channel.current?.send({ type: "kio.patch", fields });
if (pausedRef.current) {
outbox.current = { ...fields, ...outbox.current };
} else if (Object.keys(fields).length) channel.current?.send({ type: "kio.patch", fields });
}, PATCH_DEBOUNCE_MS);
}, []);
const dispatch = useCallback((service?: DDSCode) => {
if (pausedRef.current) return;
channel.current?.send(service ? { type: "dds.dispatch", service } : { type: "dds.dispatch" });
}, []);
const sendText = useCallback((text: string) => {
if (pausedRef.current) return false;
const clean = text.trim();
if (!clean || clean.length > 1000) return false;
return Boolean(channel.current?.send({ type: "text.turn", text: clean }));
}, []);
const submitCard = useCallback(() => {
if (pausedRef.current) return;
if (patchTimer.current) clearTimeout(patchTimer.current);
patchTimer.current = null;
const fields = outbox.current;
@ -247,10 +277,12 @@ export function useCall(sessionId: string | null) {
* Отдельное действие, а не «положил трубку»: система должна отличить
* осознанное решение от брошенного вызова (docs/spec/TICKETS.md). */
const resolve = useCallback((outcome: Outcome, comment: string) => {
if (pausedRef.current) return;
channel.current?.send({ type: "call.resolve", outcome, comment });
}, []);
const submitSelfAssessment = useCallback((missed: string[], comment: string) => {
if (pausedRef.current) return;
channel.current?.send({ type: "self_assessment.submit", missed, comment });
setSelfAssessed(true);
}, []);

View file

@ -20,6 +20,7 @@ export function Debrief({ report, big = false }: { report: SessionReport; big?:
const improvementPoints = report.metrics.filter((metric) => !metric.passed).slice(0, 3);
const metricTitles = new Map(report.metrics.map((metric) => [metric.key, metric.title]));
const coaching = report.ai_coaching?.status === "ready" ? report.ai_coaching.recommendations ?? [] : [];
const pausedSeconds = Math.round((report.total_paused_ms ?? 0) / 1000);
return (
<section className={big ? "debrief debrief-big" : "debrief"}>
@ -43,6 +44,7 @@ export function Debrief({ report, big = false }: { report: SessionReport; big?:
<small>{report.attempt ? `Попытка ${report.attempt}` : "Завершённое упражнение"}</small>
</article>
</div>
<p className="ref">Паузы занятия: {Math.floor(pausedSeconds / 60)} мин {pausedSeconds % 60} с. Время пауз исключено из нормативов.</p>
<details className="debrief-tech-details">
<summary>Служебные сведения</summary>
<p>Идентификатор сценария: {report.scenario_id} · номер занятия: {report.session_id}</p>

View file

@ -321,7 +321,7 @@ export function Dds() {
return () => clearInterval(timer);
}, []);
const send = (command: StationToServer): boolean => channel.current?.send(command) ?? false;
const send = (command: StationToServer): boolean => !paused && (channel.current?.send(command) ?? false);
if (!sessionId) return <DdsHistoryOnly cards={ddsHistory.cards} error={ddsHistory.error} />;
const reports = station?.phone_reports ?? [];
@ -331,6 +331,7 @@ export function Dds() {
const phoneCall = station?.phone_pending ?? null;
const phoneLines = (station?.phone_lines ?? []).filter((line) => line.crew === selectedCrew);
const isConnected = status === "open";
const pausedSeconds = Math.round((report?.total_paused_ms ?? 0) / 1000);
const cardId = card ? String(readPath(card.server, "card_id") ?? "") : "";
const cardIndex = station?.card_index ?? 1;
const cardTotal = station?.card_total ?? 1;
@ -378,6 +379,7 @@ export function Dds() {
{report ? <div style={{ padding: 12 }}>
<p><strong>Итоговая оценка: {report.score_final.toFixed(0)} из 100</strong>
{report.score_final !== report.score_auto && ` · автооценка ${report.score_auto.toFixed(0)}`}</p>
<p className="ref">Паузы занятия: {Math.floor(pausedSeconds / 60)} мин {pausedSeconds % 60} с. Время пауз исключено из нормативов.</p>
<p className="ref">Скачать отчёт: <a href={`/api/sessions/${sessionId}/report.csv`}>CSV</a>
{" · "}<a href={`/api/sessions/${sessionId}/report.pdf`}>PDF</a></p>
{Boolean(report.card_results?.length) && <>
@ -413,6 +415,7 @@ export function Dds() {
</div>}
</section>}
<fieldset className="dds-pause-controls" disabled={paused && !ended}>
{view === "registry" && <>
<section className="dds-search">
<div className="dds-search-main">
@ -707,6 +710,7 @@ export function Dds() {
</div>
</nav>
</div>}
</fieldset>
</main>;
}

View file

@ -122,16 +122,16 @@ export function Call() {
event.preventDefault();
if (call.sendText(textTurn)) setTextTurn("");
}}>
<input aria-label="Ваша реплика заявителю" value={textTurn} maxLength={1000}
<input aria-label="Ваша реплика заявителю" value={textTurn} maxLength={1000} disabled={call.paused}
placeholder="Ваша реплика…" onChange={(event) => setTextTurn(event.target.value)} />
<button type="submit" disabled={call.status !== "open" || !textTurn.trim() || call.cardSubmitted}>Отправить</button>
<button type="submit" disabled={call.paused || call.status !== "open" || !textTurn.trim() || call.cardSubmitted}>Отправить</button>
</form>
{call.paused && <p role="status" className="state-warn">Пауза, ждите преподавателя</p>}
{call.error && <p role="alert" className="state-violated">{call.error}</p>}
</section>
<div className="arm-card-wrap">
<KioCard state={call.card} required={call.briefing.required_fields}
readOnly={call.cardSubmitted} referenceArm onChange={call.patchKio} />
readOnly={call.paused || call.cardSubmitted} referenceArm onChange={call.patchKio} />
</div>
</div>
<section className="arm-support" aria-label="Норматив заполнения карточки">
@ -143,7 +143,7 @@ export function Call() {
? notifiedServices.map((service) => <b key={service}>{service}</b>)
: <em>появятся после выбора признаков</em>}
</span>
{!call.cardSubmitted && <button type="button" className="arm-primary" disabled={call.status !== "open"}
{!call.cardSubmitted && <button type="button" className="arm-primary" disabled={call.paused || call.status !== "open"}
onClick={call.submitCard}>Сохранить и проверить</button>}
{call.cardSubmitted && <strong>{call.briefing.handoff_to_dds ? "Карточка передана ДДС" : "Карточка отправлена"}</strong>}
</footer>
@ -175,11 +175,11 @@ export function Call() {
</header>
<div className="arm-call-actions">
{(call.phase === "incoming" || call.phase === "talking") && !call.micOn && <button type="button" className="arm-answer" onClick={() => void call.answer()}>
{(call.phase === "incoming" || call.phase === "talking") && !call.micOn && <button type="button" className="arm-answer" disabled={call.paused} onClick={() => void call.answer()}>
{call.phase === "talking" ? "☎ Продолжить разговор" : "☎ Ответить на вызов"}
</button>}
{call.micOn && <span className="arm-live">● Разговор идёт</span>}
{call.micOn && hintsAllowed(call.incoming?.mode) && <button type="button" onClick={call.hint}>Подсказка</button>}
{call.micOn && hintsAllowed(call.incoming?.mode) && <button type="button" disabled={call.paused} onClick={call.hint}>Подсказка</button>}
<span className="arm-call-actions-note">{call.phase === "ended"
? "Вызов завершён"
: !call.micOn && call.phase === "incoming"
@ -198,7 +198,7 @@ export function Call() {
<KioCard
state={call.card}
required={call.incoming?.required_fields ?? []}
readOnly={!call.micOn}
readOnly={call.paused || !call.micOn}
referenceArm
onChange={call.patchKio}
/>
@ -209,12 +209,12 @@ export function Call() {
<button
type="button"
className="arm-primary"
disabled={!call.micOn || (!service && !autoRoute)}
disabled={call.paused || !call.micOn || (!service && !autoRoute)}
onClick={() => call.dispatch(service)}
>
Передать в ДДС
</button>
{call.micOn && <button type="button" onClick={call.hangup}>Завершить вызов</button>}
{call.micOn && <button type="button" disabled={call.paused} onClick={call.hangup}>Завершить вызов</button>}
</div>
</footer>
<div className="arm-secondary">
@ -227,6 +227,7 @@ export function Call() {
type="text"
aria-label="Комментарий при закрытии вызова без карточки"
value={resolveComment}
disabled={call.paused}
placeholder="что сказали заявителю, куда передали"
onChange={(event) => setResolveComment(event.target.value)}
/>
@ -234,7 +235,7 @@ export function Call() {
<p>
<button
type="button"
disabled={!call.micOn || resolved !== null}
disabled={call.paused || !call.micOn || resolved !== null}
onClick={() => {
call.resolve("consultation", resolveComment);
setResolved("consultation");
@ -244,7 +245,7 @@ export function Call() {
</button>{" "}
<button
type="button"
disabled={!call.micOn || resolved !== null}
disabled={call.paused || !call.micOn || resolved !== null}
onClick={() => {
call.resolve("transfer_region", resolveComment);
setResolved("transfer_region");

View file

@ -316,6 +316,7 @@ button:disabled { opacity: 0.5; cursor: default; }
/* АРМ ДДС: реестр остаётся плотной таблицей, карточка и связь — рядом. */
.dds-workspace { max-width: 1800px; margin: 0 auto; }
.dds-pause-controls { display: contents; border: 0; margin: 0; padding: 0; min-width: 0; }
.dds-header { display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 8px 0 16px; }
.dds-header h1 { margin: 2px 0 0; }
.dds-header h1 span { color: #576273; font-size: 15px; font-weight: 400; }