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);
}, []);