lct-39: пауза останавливает действия и голос
This commit is contained in:
parent
1467c2665c
commit
1c54eeaf36
12 changed files with 252 additions and 47 deletions
|
|
@ -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);
|
||||
}, []);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
Loading…
Reference in a new issue