lct-39: пауза останавливает действия и голос
This commit is contained in:
parent
2304d94877
commit
88bb54fc24
12 changed files with 252 additions and 47 deletions
|
|
@ -228,7 +228,10 @@ async def active(
|
||||||
states[state.session_id] = state
|
states[state.session_id] = state
|
||||||
|
|
||||||
for state in states.values():
|
for state in states.values():
|
||||||
elapsed = (max(0, int((now - state.started_at).total_seconds()))
|
paused_ms = state.total_paused_ms
|
||||||
|
if state.paused and state.paused_at is not None:
|
||||||
|
paused_ms += max(0, int((now - state.paused_at).total_seconds() * 1000))
|
||||||
|
elapsed = (max(0, int((now - state.started_at).total_seconds() - paused_ms / 1000))
|
||||||
if state.started_at else 0)
|
if state.started_at else 0)
|
||||||
station = state.station_snapshot() if state.exercise is Exercise.DDS else None
|
station = state.station_snapshot() if state.exercise is Exercise.DDS else None
|
||||||
queue = station.queue_cards if station else []
|
queue = station.queue_cards if station else []
|
||||||
|
|
|
||||||
|
|
@ -235,6 +235,9 @@ async def _handle(session_id: UUID, state, event) -> None:
|
||||||
code=ErrorKind.INTERNAL, message="Не удалось получить ответ заявителя. Попробуйте ещё раз.",
|
code=ErrorKind.INTERNAL, message="Не удалось получить ответ заявителя. Попробуйте ещё раз.",
|
||||||
))
|
))
|
||||||
return
|
return
|
||||||
|
if state.paused:
|
||||||
|
# Ответ модели мог закончиться уже после команды преподавателя.
|
||||||
|
return
|
||||||
caller_entry = state.append(Speaker.CALLER, line.text, line.mood)
|
caller_entry = state.append(Speaker.CALLER, line.text, line.mood)
|
||||||
hub.to_trainee(session_id, CallerUtterance(
|
hub.to_trainee(session_id, CallerUtterance(
|
||||||
utterance_id=uuid4(), text=line.text,
|
utterance_id=uuid4(), text=line.text,
|
||||||
|
|
@ -399,10 +402,9 @@ async def call(ws: WebSocket, session_id: UUID) -> None:
|
||||||
_who, state = entered
|
_who, state = entered
|
||||||
|
|
||||||
with hub.trainee(session_id) as queue:
|
with hub.trainee(session_id) as queue:
|
||||||
if state.paused:
|
# Каждый новый канал получает серверное состояние, включая снятую паузу:
|
||||||
# Переподключение не ждёт следующего `session.pause`: баннер должен
|
# клиент мог потерять событие resume во время переподключения.
|
||||||
# быть на экране сразу, раз сервер уже отклоняет команды (lct-39).
|
queue.put_nowait(SessionPaused(paused=state.paused))
|
||||||
hub.to_trainee(session_id, SessionPaused(paused=True))
|
|
||||||
if state.exercise is Exercise.CARD:
|
if state.exercise is Exercise.CARD:
|
||||||
from app.api.ws.control import card_briefing
|
from app.api.ws.control import card_briefing
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ import logging
|
||||||
import time
|
import time
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from app.domain.events import CallEnded, CallEndReason, Exercise, ScoreReady, SessionEnded
|
from app.domain.events import CallEnded, CallEndReason, Exercise, ScoreReady, SessionEnded, SessionPaused
|
||||||
from app.domain.statuses import ServiceStatus, current
|
from app.domain.statuses import ServiceStatus, current
|
||||||
from app.domain.timers import TimerCode
|
from app.domain.timers import TimerCode
|
||||||
from app.scoring.ai_coach import coach
|
from app.scoring.ai_coach import coach
|
||||||
|
|
@ -27,6 +27,7 @@ from app.session.score import ScoringSnapshot, score_session, scoring_scenario
|
||||||
from app.session.state import DdsCardRecord, DdsLiveCard, now_utc
|
from app.session.state import DdsCardRecord, DdsLiveCard, now_utc
|
||||||
from app.session.store import (
|
from app.session.store import (
|
||||||
LessonEnded,
|
LessonEnded,
|
||||||
|
LessonResumed,
|
||||||
ScoreArchived,
|
ScoreArchived,
|
||||||
ScoreCalculated,
|
ScoreCalculated,
|
||||||
ScoreOverridden,
|
ScoreOverridden,
|
||||||
|
|
@ -181,7 +182,16 @@ async def end_session(session_id: UUID, state, reason: CallEndReason) -> None:
|
||||||
"""
|
"""
|
||||||
if state.ended:
|
if state.ended:
|
||||||
return
|
return
|
||||||
|
was_paused = state.paused
|
||||||
|
paused_ms_before = state.total_paused_ms
|
||||||
ended_at = state.end(reason)
|
ended_at = state.end(reason)
|
||||||
|
if was_paused:
|
||||||
|
hub.record(session_id, LessonResumed(
|
||||||
|
at=ended_at, author="система", role="system",
|
||||||
|
paused_ms=state.total_paused_ms - paused_ms_before,
|
||||||
|
))
|
||||||
|
hub.broadcast(session_id, SessionPaused(paused=False))
|
||||||
|
hub.to_station(session_id, SessionPaused(paused=False))
|
||||||
if state.exercise is Exercise.CARD and state.dispatched_card is None:
|
if state.exercise is Exercise.CARD and state.dispatched_card is None:
|
||||||
state.on_event("card.end")
|
state.on_event("card.end")
|
||||||
# Норматив отработки фиксируется событием, а не текущим значением часов.
|
# Норматив отработки фиксируется событием, а не текущим значением часов.
|
||||||
|
|
|
||||||
|
|
@ -197,6 +197,8 @@ class SessionState(PersistedSession):
|
||||||
card.timers.pause(now)
|
card.timers.pause(now)
|
||||||
self.paused = True
|
self.paused = True
|
||||||
self.paused_at = now_utc()
|
self.paused_at = now_utc()
|
||||||
|
if self.voice is not None:
|
||||||
|
self.voice.pause()
|
||||||
|
|
||||||
def resume(self) -> None:
|
def resume(self) -> None:
|
||||||
"""Снять паузу: таймеры продолжают с прежней отметки, очередь сдвигается на простой."""
|
"""Снять паузу: таймеры продолжают с прежней отметки, очередь сдвигается на простой."""
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,8 @@ class VoiceSession:
|
||||||
# ── вход: кадры микрофона ──
|
# ── вход: кадры микрофона ──
|
||||||
|
|
||||||
def feed(self, frame: bytes) -> None:
|
def feed(self, frame: bytes) -> None:
|
||||||
|
if self.state.paused:
|
||||||
|
return
|
||||||
for event in self._vad.push(frame):
|
for event in self._vad.push(frame):
|
||||||
if isinstance(event, SpeechStarted) and self.speaking:
|
if isinstance(event, SpeechStarted) and self.speaking:
|
||||||
self.barge_in()
|
self.barge_in()
|
||||||
|
|
@ -104,6 +106,8 @@ class VoiceSession:
|
||||||
первая фраза «Алло! Помогите!» — ровно та, которую оператор перебивает
|
первая фраза «Алло! Помогите!» — ровно та, которую оператор перебивает
|
||||||
чаще всего, — не гасилась вовсе.
|
чаще всего, — не гасилась вовсе.
|
||||||
"""
|
"""
|
||||||
|
if self.state.paused:
|
||||||
|
return asyncio.create_task(asyncio.sleep(0))
|
||||||
if self.speaking:
|
if self.speaking:
|
||||||
self._reply.cancel()
|
self._reply.cancel()
|
||||||
self._reply = asyncio.create_task(self.say(text, mood))
|
self._reply = asyncio.create_task(self.say(text, mood))
|
||||||
|
|
@ -119,6 +123,17 @@ class VoiceSession:
|
||||||
self.send_event(TtsCancel(utterance_id=self._utterance_id, reason="barge_in"))
|
self.send_event(TtsCancel(utterance_id=self._utterance_id, reason="barge_in"))
|
||||||
log.info("сессия %s: перебивание", self.session_id)
|
log.info("сессия %s: перебивание", self.session_id)
|
||||||
|
|
||||||
|
def pause(self) -> None:
|
||||||
|
"""Прервать текущую реплику и убрать речь, накопленную до паузы."""
|
||||||
|
if self.speaking:
|
||||||
|
self._reply.cancel()
|
||||||
|
if self._utterance_id is not None:
|
||||||
|
self.send_event(TtsCancel(utterance_id=self._utterance_id, reason="director"))
|
||||||
|
self._utterance_id = None
|
||||||
|
while not self._queue.empty():
|
||||||
|
self._queue.get_nowait()
|
||||||
|
self._vad.reset()
|
||||||
|
|
||||||
async def close(self) -> None:
|
async def close(self) -> None:
|
||||||
for task in (self._reply, self._worker):
|
for task in (self._reply, self._worker):
|
||||||
if task is not None:
|
if task is not None:
|
||||||
|
|
@ -129,6 +144,8 @@ class VoiceSession:
|
||||||
async def _work(self) -> None:
|
async def _work(self) -> None:
|
||||||
while True:
|
while True:
|
||||||
audio, ended_at = await self._queue.get()
|
audio, ended_at = await self._queue.get()
|
||||||
|
if self.state.paused:
|
||||||
|
continue
|
||||||
# Новая фраза оператора, пока звонящий ещё говорит, — тоже перебивание.
|
# Новая фраза оператора, пока звонящий ещё говорит, — тоже перебивание.
|
||||||
if self.speaking:
|
if self.speaking:
|
||||||
self.barge_in()
|
self.barge_in()
|
||||||
|
|
@ -143,6 +160,8 @@ class VoiceSession:
|
||||||
timing = TurnTiming()
|
timing = TurnTiming()
|
||||||
started = time.monotonic()
|
started = time.monotonic()
|
||||||
text = await self.models.transcribe(audio)
|
text = await self.models.transcribe(audio)
|
||||||
|
if self.state.paused:
|
||||||
|
return
|
||||||
timing.stt_ms = (time.monotonic() - started) * 1000
|
timing.stt_ms = (time.monotonic() - started) * 1000
|
||||||
if not text:
|
if not text:
|
||||||
return
|
return
|
||||||
|
|
@ -155,6 +174,8 @@ class VoiceSession:
|
||||||
|
|
||||||
started = time.monotonic()
|
started = time.monotonic()
|
||||||
line = await self._caller_line(text)
|
line = await self._caller_line(text)
|
||||||
|
if self.state.paused:
|
||||||
|
return
|
||||||
timing.caller_ms = (time.monotonic() - started) * 1000
|
timing.caller_ms = (time.monotonic() - started) * 1000
|
||||||
await self.say(line.text, line.mood, ended_at=ended_at, timing=timing)
|
await self.say(line.text, line.mood, ended_at=ended_at, timing=timing)
|
||||||
|
|
||||||
|
|
@ -171,6 +192,8 @@ class VoiceSession:
|
||||||
self, text: str, mood: Mood, *, ended_at: float | None = None, timing: TurnTiming | None = None
|
self, text: str, mood: Mood, *, ended_at: float | None = None, timing: TurnTiming | None = None
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Произнести реплику: событие с текстом, звук по предложениям, ожидание конца."""
|
"""Произнести реплику: событие с текстом, звук по предложениям, ожидание конца."""
|
||||||
|
if self.state.paused:
|
||||||
|
return
|
||||||
self._utterance_id = utterance_id = uuid4()
|
self._utterance_id = utterance_id = uuid4()
|
||||||
entry = self.state.append(Speaker.CALLER, text, mood)
|
entry = self.state.append(Speaker.CALLER, text, mood)
|
||||||
self.send_event(CallerUtterance(utterance_id=utterance_id, text=text, at=entry.at, mood=mood))
|
self.send_event(CallerUtterance(utterance_id=utterance_id, text=text, at=entry.at, mood=mood))
|
||||||
|
|
@ -186,6 +209,8 @@ class VoiceSession:
|
||||||
pcm = await self._first_sentence(sentence, mood, ended_at, timing)
|
pcm = await self._first_sentence(sentence, mood, ended_at, timing)
|
||||||
else:
|
else:
|
||||||
pcm = await self.synthesize(sentence)
|
pcm = await self.synthesize(sentence)
|
||||||
|
if self.state.paused:
|
||||||
|
return
|
||||||
if index == 0 and timing is not None:
|
if index == 0 and timing is not None:
|
||||||
timing.tts_first_ms = (time.monotonic() - synth_started) * 1000
|
timing.tts_first_ms = (time.monotonic() - synth_started) * 1000
|
||||||
if not pcm:
|
if not pcm:
|
||||||
|
|
@ -205,6 +230,7 @@ class VoiceSession:
|
||||||
# Ждём, пока курсант дослушает: перебивание в это время отменит задачу.
|
# Ждём, пока курсант дослушает: перебивание в это время отменит задачу.
|
||||||
await asyncio.sleep(max(0.0, playback_ends - time.monotonic()))
|
await asyncio.sleep(max(0.0, playback_ends - time.monotonic()))
|
||||||
self.send_event(TtsEnd(utterance_id=utterance_id))
|
self.send_event(TtsEnd(utterance_id=utterance_id))
|
||||||
|
self._utterance_id = None
|
||||||
|
|
||||||
async def _first_sentence(
|
async def _first_sentence(
|
||||||
self, sentence: str, mood: Mood, ended_at: float, timing: TurnTiming | None
|
self, sentence: str, mood: Mood, ended_at: float, timing: TurnTiming | None
|
||||||
|
|
@ -212,17 +238,25 @@ class VoiceSession:
|
||||||
"""Первое предложение с филлером: если к секунде после конца фразы звука
|
"""Первое предложение с филлером: если к секунде после конца фразы звука
|
||||||
ещё нет, звонящий «переспрашивает», а ответ встанет в очередь за ним."""
|
ещё нет, звонящий «переспрашивает», а ответ встанет в очередь за ним."""
|
||||||
synthesis = asyncio.ensure_future(self.synthesize(sentence))
|
synthesis = asyncio.ensure_future(self.synthesize(sentence))
|
||||||
remaining = FILLER_AFTER_S - (time.monotonic() - ended_at)
|
try:
|
||||||
if remaining > 0:
|
remaining = FILLER_AFTER_S - (time.monotonic() - ended_at)
|
||||||
done, _ = await asyncio.wait({synthesis}, timeout=remaining)
|
if remaining > 0:
|
||||||
if done:
|
done, _ = await asyncio.wait({synthesis}, timeout=remaining)
|
||||||
return synthesis.result()
|
if done:
|
||||||
filler = await self.synthesize(FILLERS.get(mood, FILLERS[Mood.PANIC]))
|
return synthesis.result()
|
||||||
if filler:
|
filler = await self.synthesize(FILLERS.get(mood, FILLERS[Mood.PANIC]))
|
||||||
self.send_audio(filler)
|
if filler and not self.state.paused:
|
||||||
if timing is not None:
|
self.send_audio(filler)
|
||||||
timing.filler = True
|
if timing is not None:
|
||||||
return await synthesis
|
timing.filler = True
|
||||||
|
return await synthesis
|
||||||
|
finally:
|
||||||
|
if not synthesis.done():
|
||||||
|
synthesis.cancel()
|
||||||
|
try:
|
||||||
|
await synthesis
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
async def synthesize(self, text: str) -> bytes:
|
async def synthesize(self, text: str) -> bytes:
|
||||||
return await cached_synthesize(self.models, text)
|
return await cached_synthesize(self.models, text)
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,17 @@ class StreamingVad:
|
||||||
def in_speech(self) -> bool:
|
def in_speech(self) -> bool:
|
||||||
return self._in_speech
|
return self._in_speech
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
"""Не склеивать фрагменты речи до и после паузы занятия."""
|
||||||
|
self._state.fill(0)
|
||||||
|
self._context.fill(0)
|
||||||
|
self._pending = np.zeros(0, dtype=np.float32)
|
||||||
|
self._preroll.clear()
|
||||||
|
self._speech.clear()
|
||||||
|
self._voiced_ms = 0
|
||||||
|
self._silence_ms = 0
|
||||||
|
self._in_speech = False
|
||||||
|
|
||||||
def _probability(self, window: np.ndarray) -> float:
|
def _probability(self, window: np.ndarray) -> float:
|
||||||
frame = np.concatenate([self._context, window])[None, :]
|
frame = np.concatenate([self._context, window])[None, :]
|
||||||
output, self._state = self._session.run(
|
output, self._state = self._session.run(
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import asyncio
|
||||||
import time
|
import time
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
@ -16,7 +17,8 @@ from fastapi.testclient import TestClient
|
||||||
|
|
||||||
from app.api.http import sessions as sessions_http
|
from app.api.http import sessions as sessions_http
|
||||||
from app.api.ws import control as control_ws
|
from app.api.ws import control as control_ws
|
||||||
from app.domain.events import CallEndReason, Exercise, LessonCriteria, SessionMode
|
from app.api.ws import call as call_ws
|
||||||
|
from app.domain.events import CallEndReason, Exercise, LessonCriteria, SessionMode, SessionPaused, TtsCancel, TtsEnd
|
||||||
from app.domain.statuses import ServiceStatus
|
from app.domain.statuses import ServiceStatus
|
||||||
from app.domain.timers import TimerCode
|
from app.domain.timers import TimerCode
|
||||||
from app.main import app
|
from app.main import app
|
||||||
|
|
@ -163,6 +165,12 @@ def test_checkpoint_round_trip_keeps_pause_without_adding_downtime(clock):
|
||||||
assert restored_timer.elapsed_ms == card.timers.timers[TimerCode.DDS_ACK].elapsed_ms
|
assert restored_timer.elapsed_ms == card.timers.timers[TimerCode.DDS_ACK].elapsed_ms
|
||||||
assert restored_timer.started_at is None
|
assert restored_timer.started_at is None
|
||||||
|
|
||||||
|
# После takeover пауза всё ещё действует; лишь явное возобновление
|
||||||
|
# продолжает норматив с пяти секунд до прерывания.
|
||||||
|
restored.resume()
|
||||||
|
clock.advance(10)
|
||||||
|
assert restored_timer.current_ms(clock.now) == 15_000
|
||||||
|
|
||||||
|
|
||||||
def test_pause_and_resume_are_recorded_for_audit(monkeypatch):
|
def test_pause_and_resume_are_recorded_for_audit(monkeypatch):
|
||||||
scenario = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
|
scenario = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
|
||||||
|
|
@ -250,15 +258,30 @@ def test_ending_session_while_paused_finalizes_pause_and_unblocks_report(clock,
|
||||||
idle = timedelta(seconds=45)
|
idle = timedelta(seconds=45)
|
||||||
monkeypatch.setattr("app.session.state.now_utc", lambda: base + idle)
|
monkeypatch.setattr("app.session.state.now_utc", lambda: base + idle)
|
||||||
|
|
||||||
|
events = []
|
||||||
|
|
||||||
async def run():
|
async def run():
|
||||||
async with hub.operation(state.session_id):
|
with hub.trainee(state.session_id) as queue:
|
||||||
await end_session(state.session_id, state, CallEndReason.INSTRUCTOR)
|
async with hub.operation(state.session_id):
|
||||||
|
await end_session(state.session_id, state, CallEndReason.INSTRUCTOR)
|
||||||
|
async with hub.operation(state.session_id):
|
||||||
|
await call_ws._handle(
|
||||||
|
state.session_id, state,
|
||||||
|
SimpleNamespace(type="self_assessment.submit", missed=[], comment="Проверил"),
|
||||||
|
)
|
||||||
|
while not queue.empty():
|
||||||
|
events.append(queue.get_nowait())
|
||||||
|
|
||||||
asyncio.run(run())
|
asyncio.run(run())
|
||||||
|
|
||||||
assert state.ended
|
assert state.ended
|
||||||
assert state.paused is False
|
assert state.paused is False
|
||||||
assert state.total_paused_ms >= 30_000
|
assert state.total_paused_ms >= 30_000
|
||||||
|
assert state.self_assessed
|
||||||
|
assert state.score is not None
|
||||||
|
assert state.score["full_report"]["total_paused_ms"] >= 30_000
|
||||||
|
assert any(isinstance(event, SessionPaused) and not event.paused for event in events)
|
||||||
|
assert any(isinstance(record, LessonResumed) for _sid, batch in store.commits for record in batch)
|
||||||
|
|
||||||
|
|
||||||
def test_dds_work_overdue_survives_pause():
|
def test_dds_work_overdue_survives_pause():
|
||||||
|
|
@ -280,6 +303,27 @@ def test_dds_work_overdue_survives_pause():
|
||||||
assert overdue is True
|
assert overdue is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_active_registry_keeps_existing_overdue_during_pause(http_client, clock):
|
||||||
|
app.dependency_overrides[sessions_http.optional_session] = lambda: None
|
||||||
|
state, card = _dds_state_with_active_card(clock)
|
||||||
|
state.owner_login = "dev"
|
||||||
|
timer = card.timers.timers.setdefault(TimerCode.DDS_WORK, Timer(code=TimerCode.DDS_WORK))
|
||||||
|
card.timers.limits[TimerCode.DDS_WORK] = 1_000
|
||||||
|
timer.start(clock.now)
|
||||||
|
clock.advance(5)
|
||||||
|
state.pause()
|
||||||
|
hub._sessions[state.session_id] = state
|
||||||
|
try:
|
||||||
|
response = http_client.get("/api/sessions/active")
|
||||||
|
assert response.status_code == 200
|
||||||
|
row = next(item for item in response.json() if item["session_id"] == str(state.session_id))
|
||||||
|
assert row["dds_work_overdue_cards"] == 1
|
||||||
|
assert row["paused"] is True
|
||||||
|
finally:
|
||||||
|
hub._sessions.pop(state.session_id, None)
|
||||||
|
app.dependency_overrides.pop(sessions_http.optional_session, None)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def http_client(monkeypatch):
|
def http_client(monkeypatch):
|
||||||
async def audit_in_memory(*_args, **_kwargs):
|
async def audit_in_memory(*_args, **_kwargs):
|
||||||
|
|
@ -347,3 +391,62 @@ def test_reconnect_to_call_replays_paused_banner(http_client):
|
||||||
message["type"] == "session.paused" and message["paused"]
|
message["type"] == "session.paused" and message["paused"]
|
||||||
for message in messages
|
for message in messages
|
||||||
)
|
)
|
||||||
|
|
||||||
|
control.send_json({"type": "session.resume"})
|
||||||
|
_wait(lambda: not hub.get(session_id).paused)
|
||||||
|
with http_client.websocket_connect(f"/ws/call/{session_id}") as trainee:
|
||||||
|
message = trainee.receive_json()
|
||||||
|
assert message == {"type": "session.paused", "paused": False}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_pause_cancels_inflight_voice_and_clears_buffer(monkeypatch):
|
||||||
|
from app.voice import pipeline
|
||||||
|
|
||||||
|
class FakeVad:
|
||||||
|
def __init__(self, *_args, **_kwargs):
|
||||||
|
self.reset_count = 0
|
||||||
|
|
||||||
|
def reset(self):
|
||||||
|
self.reset_count += 1
|
||||||
|
|
||||||
|
def push(self, _frame):
|
||||||
|
return []
|
||||||
|
|
||||||
|
monkeypatch.setattr(pipeline, "StreamingVad", FakeVad)
|
||||||
|
state, _card = _dds_state_with_active_card(lambda: 1_000.0)
|
||||||
|
events = []
|
||||||
|
audio = []
|
||||||
|
started = asyncio.Event()
|
||||||
|
release = asyncio.Event()
|
||||||
|
|
||||||
|
class Models:
|
||||||
|
vad_path = None
|
||||||
|
|
||||||
|
voice = pipeline.VoiceSession(
|
||||||
|
state.session_id, state, Models(), events.append, lambda _event: None, audio.append,
|
||||||
|
)
|
||||||
|
state.voice = voice
|
||||||
|
|
||||||
|
async def slow_synthesis(_text):
|
||||||
|
started.set()
|
||||||
|
await release.wait()
|
||||||
|
return b"\x00\x00" * 2_400
|
||||||
|
|
||||||
|
monkeypatch.setattr(voice, "synthesize", slow_synthesis)
|
||||||
|
try:
|
||||||
|
speaking = voice.speak("Алло, помогите!", state.persona.mood if state.persona else pipeline.Mood.PANIC)
|
||||||
|
await asyncio.wait_for(started.wait(), timeout=1)
|
||||||
|
voice._queue.put_nowait((b"old", time.monotonic()))
|
||||||
|
state.pause()
|
||||||
|
release.set()
|
||||||
|
with pytest.raises(asyncio.CancelledError):
|
||||||
|
await speaking
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
assert any(isinstance(event, TtsCancel) and event.reason == "director" for event in events)
|
||||||
|
assert not any(isinstance(event, TtsEnd) for event in events)
|
||||||
|
assert audio == []
|
||||||
|
assert voice._queue.empty()
|
||||||
|
assert voice._vad.reset_count == 1
|
||||||
|
finally:
|
||||||
|
await voice.close()
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,7 @@ export function useCall(sessionId: string | null) {
|
||||||
const outbox = useRef<Record<string, unknown>>({});
|
const outbox = useRef<Record<string, unknown>>({});
|
||||||
const patchTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const patchTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const textMode = useRef(false);
|
const textMode = useRef(false);
|
||||||
|
const pausedRef = useRef(false);
|
||||||
|
|
||||||
const append = useCallback((line: Line) => {
|
const append = useCallback((line: Line) => {
|
||||||
setLines((prev) => {
|
setLines((prev) => {
|
||||||
|
|
@ -72,19 +73,10 @@ export function useCall(sessionId: string | null) {
|
||||||
const ch = callChannel(sessionId, {
|
const ch = callChannel(sessionId, {
|
||||||
onStatus: (next) => {
|
onStatus: (next) => {
|
||||||
setStatus(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) => {
|
onEvent: (event: ServerToTrainee) => {
|
||||||
if (event.type === "text.turn.accepted") {
|
if (event.type === "text.turn.accepted") {
|
||||||
append({ speaker: "operator", text: event.text });
|
append({ speaker: "operator", text: event.text });
|
||||||
|
|
@ -155,7 +147,27 @@ export function useCall(sessionId: string | null) {
|
||||||
setScoreReady(true);
|
setScoreReady(true);
|
||||||
break;
|
break;
|
||||||
case "session.paused":
|
case "session.paused":
|
||||||
|
pausedRef.current = event.paused;
|
||||||
setPaused(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;
|
break;
|
||||||
case "error":
|
case "error":
|
||||||
setError(event.message);
|
setError(event.message);
|
||||||
|
|
@ -174,13 +186,15 @@ export function useCall(sessionId: string | null) {
|
||||||
}, [sessionId, append]);
|
}, [sessionId, append]);
|
||||||
|
|
||||||
const answer = useCallback(async () => {
|
const answer = useCallback(async () => {
|
||||||
|
if (pausedRef.current) return;
|
||||||
try {
|
try {
|
||||||
setError(null);
|
setError(null);
|
||||||
// Контекст создаётся по нажатию: браузер не даёт играть звук без действия человека.
|
// Контекст создаётся по нажатию: браузер не даёт играть звук без действия человека.
|
||||||
const context = new AudioContext({ sampleRate: CALLER_RATE });
|
const context = new AudioContext({ sampleRate: CALLER_RATE });
|
||||||
audio.current = { context, playback: new Playback(context), ambience: new Ambience(context) };
|
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;
|
const playback = audio.current?.playback;
|
||||||
// Перебивание: фронт гасит звук мгновенно, сервер подтвердит `tts.cancel`.
|
// Перебивание: фронт гасит звук мгновенно, сервер подтвердит `tts.cancel`.
|
||||||
if (gate.current.push(frame) && playback?.speaking) {
|
if (gate.current.push(frame) && playback?.speaking) {
|
||||||
|
|
@ -189,6 +203,13 @@ export function useCall(sessionId: string | null) {
|
||||||
}
|
}
|
||||||
channel.current?.sendBinary(frame);
|
channel.current?.sendBinary(frame);
|
||||||
});
|
});
|
||||||
|
if (pausedRef.current) {
|
||||||
|
await startedCapture.stop();
|
||||||
|
await context.close();
|
||||||
|
audio.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
capture.current = startedCapture;
|
||||||
setMicOn(true);
|
setMicOn(true);
|
||||||
channel.current?.send({ type: "call.answer" });
|
channel.current?.send({ type: "call.answer" });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -197,36 +218,45 @@ export function useCall(sessionId: string | null) {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const hangup = useCallback(() => {
|
const hangup = useCallback(() => {
|
||||||
|
if (pausedRef.current) return;
|
||||||
channel.current?.send({ type: "call.hangup" });
|
channel.current?.send({ type: "call.hangup" });
|
||||||
void capture.current?.stop();
|
void capture.current?.stop();
|
||||||
capture.current = null;
|
capture.current = null;
|
||||||
setMicOn(false);
|
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) => {
|
const patchKio = useCallback((path: string, value: unknown) => {
|
||||||
|
if (pausedRef.current) return;
|
||||||
setCard((prev) => edit(prev, path, value));
|
setCard((prev) => edit(prev, path, value));
|
||||||
outbox.current[path] = value;
|
outbox.current[path] = value;
|
||||||
if (patchTimer.current) clearTimeout(patchTimer.current);
|
if (patchTimer.current) clearTimeout(patchTimer.current);
|
||||||
patchTimer.current = setTimeout(() => {
|
patchTimer.current = setTimeout(() => {
|
||||||
const fields = outbox.current;
|
const fields = outbox.current;
|
||||||
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);
|
}, PATCH_DEBOUNCE_MS);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const dispatch = useCallback((service?: DDSCode) => {
|
const dispatch = useCallback((service?: DDSCode) => {
|
||||||
|
if (pausedRef.current) return;
|
||||||
channel.current?.send(service ? { type: "dds.dispatch", service } : { type: "dds.dispatch" });
|
channel.current?.send(service ? { type: "dds.dispatch", service } : { type: "dds.dispatch" });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const sendText = useCallback((text: string) => {
|
const sendText = useCallback((text: string) => {
|
||||||
|
if (pausedRef.current) return false;
|
||||||
const clean = text.trim();
|
const clean = text.trim();
|
||||||
if (!clean || clean.length > 1000) return false;
|
if (!clean || clean.length > 1000) return false;
|
||||||
return Boolean(channel.current?.send({ type: "text.turn", text: clean }));
|
return Boolean(channel.current?.send({ type: "text.turn", text: clean }));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const submitCard = useCallback(() => {
|
const submitCard = useCallback(() => {
|
||||||
|
if (pausedRef.current) return;
|
||||||
if (patchTimer.current) clearTimeout(patchTimer.current);
|
if (patchTimer.current) clearTimeout(patchTimer.current);
|
||||||
patchTimer.current = null;
|
patchTimer.current = null;
|
||||||
const fields = outbox.current;
|
const fields = outbox.current;
|
||||||
|
|
@ -247,10 +277,12 @@ export function useCall(sessionId: string | null) {
|
||||||
* Отдельное действие, а не «положил трубку»: система должна отличить
|
* Отдельное действие, а не «положил трубку»: система должна отличить
|
||||||
* осознанное решение от брошенного вызова (docs/spec/TICKETS.md). */
|
* осознанное решение от брошенного вызова (docs/spec/TICKETS.md). */
|
||||||
const resolve = useCallback((outcome: Outcome, comment: string) => {
|
const resolve = useCallback((outcome: Outcome, comment: string) => {
|
||||||
|
if (pausedRef.current) return;
|
||||||
channel.current?.send({ type: "call.resolve", outcome, comment });
|
channel.current?.send({ type: "call.resolve", outcome, comment });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const submitSelfAssessment = useCallback((missed: string[], comment: string) => {
|
const submitSelfAssessment = useCallback((missed: string[], comment: string) => {
|
||||||
|
if (pausedRef.current) return;
|
||||||
channel.current?.send({ type: "self_assessment.submit", missed, comment });
|
channel.current?.send({ type: "self_assessment.submit", missed, comment });
|
||||||
setSelfAssessed(true);
|
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 improvementPoints = report.metrics.filter((metric) => !metric.passed).slice(0, 3);
|
||||||
const metricTitles = new Map(report.metrics.map((metric) => [metric.key, metric.title]));
|
const metricTitles = new Map(report.metrics.map((metric) => [metric.key, metric.title]));
|
||||||
const coaching = report.ai_coaching?.status === "ready" ? report.ai_coaching.recommendations ?? [] : [];
|
const coaching = report.ai_coaching?.status === "ready" ? report.ai_coaching.recommendations ?? [] : [];
|
||||||
|
const pausedSeconds = Math.round((report.total_paused_ms ?? 0) / 1000);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className={big ? "debrief debrief-big" : "debrief"}>
|
<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>
|
<small>{report.attempt ? `Попытка ${report.attempt}` : "Завершённое упражнение"}</small>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="ref">Паузы занятия: {Math.floor(pausedSeconds / 60)} мин {pausedSeconds % 60} с. Время пауз исключено из нормативов.</p>
|
||||||
<details className="debrief-tech-details">
|
<details className="debrief-tech-details">
|
||||||
<summary>Служебные сведения</summary>
|
<summary>Служебные сведения</summary>
|
||||||
<p>Идентификатор сценария: {report.scenario_id} · номер занятия: {report.session_id}</p>
|
<p>Идентификатор сценария: {report.scenario_id} · номер занятия: {report.session_id}</p>
|
||||||
|
|
|
||||||
|
|
@ -321,7 +321,7 @@ export function Dds() {
|
||||||
return () => clearInterval(timer);
|
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} />;
|
if (!sessionId) return <DdsHistoryOnly cards={ddsHistory.cards} error={ddsHistory.error} />;
|
||||||
|
|
||||||
const reports = station?.phone_reports ?? [];
|
const reports = station?.phone_reports ?? [];
|
||||||
|
|
@ -331,6 +331,7 @@ export function Dds() {
|
||||||
const phoneCall = station?.phone_pending ?? null;
|
const phoneCall = station?.phone_pending ?? null;
|
||||||
const phoneLines = (station?.phone_lines ?? []).filter((line) => line.crew === selectedCrew);
|
const phoneLines = (station?.phone_lines ?? []).filter((line) => line.crew === selectedCrew);
|
||||||
const isConnected = status === "open";
|
const isConnected = status === "open";
|
||||||
|
const pausedSeconds = Math.round((report?.total_paused_ms ?? 0) / 1000);
|
||||||
const cardId = card ? String(readPath(card.server, "card_id") ?? "") : "";
|
const cardId = card ? String(readPath(card.server, "card_id") ?? "") : "";
|
||||||
const cardIndex = station?.card_index ?? 1;
|
const cardIndex = station?.card_index ?? 1;
|
||||||
const cardTotal = station?.card_total ?? 1;
|
const cardTotal = station?.card_total ?? 1;
|
||||||
|
|
@ -378,6 +379,7 @@ export function Dds() {
|
||||||
{report ? <div style={{ padding: 12 }}>
|
{report ? <div style={{ padding: 12 }}>
|
||||||
<p><strong>Итоговая оценка: {report.score_final.toFixed(0)} из 100</strong>
|
<p><strong>Итоговая оценка: {report.score_final.toFixed(0)} из 100</strong>
|
||||||
{report.score_final !== report.score_auto && ` · автооценка ${report.score_auto.toFixed(0)}`}</p>
|
{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>
|
<p className="ref">Скачать отчёт: <a href={`/api/sessions/${sessionId}/report.csv`}>CSV</a>
|
||||||
{" · "}<a href={`/api/sessions/${sessionId}/report.pdf`}>PDF</a></p>
|
{" · "}<a href={`/api/sessions/${sessionId}/report.pdf`}>PDF</a></p>
|
||||||
{Boolean(report.card_results?.length) && <>
|
{Boolean(report.card_results?.length) && <>
|
||||||
|
|
@ -413,6 +415,7 @@ export function Dds() {
|
||||||
</div>}
|
</div>}
|
||||||
</section>}
|
</section>}
|
||||||
|
|
||||||
|
<fieldset className="dds-pause-controls" disabled={paused && !ended}>
|
||||||
{view === "registry" && <>
|
{view === "registry" && <>
|
||||||
<section className="dds-search">
|
<section className="dds-search">
|
||||||
<div className="dds-search-main">
|
<div className="dds-search-main">
|
||||||
|
|
@ -707,6 +710,7 @@ export function Dds() {
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
</div>}
|
</div>}
|
||||||
|
</fieldset>
|
||||||
</main>;
|
</main>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -122,16 +122,16 @@ export function Call() {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (call.sendText(textTurn)) setTextTurn("");
|
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)} />
|
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>
|
</form>
|
||||||
{call.paused && <p role="status" className="state-warn">Пауза, ждите преподавателя</p>}
|
{call.paused && <p role="status" className="state-warn">Пауза, ждите преподавателя</p>}
|
||||||
{call.error && <p role="alert" className="state-violated">{call.error}</p>}
|
{call.error && <p role="alert" className="state-violated">{call.error}</p>}
|
||||||
</section>
|
</section>
|
||||||
<div className="arm-card-wrap">
|
<div className="arm-card-wrap">
|
||||||
<KioCard state={call.card} required={call.briefing.required_fields}
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<section className="arm-support" aria-label="Норматив заполнения карточки">
|
<section className="arm-support" aria-label="Норматив заполнения карточки">
|
||||||
|
|
@ -143,7 +143,7 @@ export function Call() {
|
||||||
? notifiedServices.map((service) => <b key={service}>{service}</b>)
|
? notifiedServices.map((service) => <b key={service}>{service}</b>)
|
||||||
: <em>появятся после выбора признаков</em>}
|
: <em>появятся после выбора признаков</em>}
|
||||||
</span>
|
</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>}
|
onClick={call.submitCard}>Сохранить и проверить</button>}
|
||||||
{call.cardSubmitted && <strong>{call.briefing.handoff_to_dds ? "Карточка передана ДДС" : "Карточка отправлена"}</strong>}
|
{call.cardSubmitted && <strong>{call.briefing.handoff_to_dds ? "Карточка передана ДДС" : "Карточка отправлена"}</strong>}
|
||||||
</footer>
|
</footer>
|
||||||
|
|
@ -175,11 +175,11 @@ export function Call() {
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="arm-call-actions">
|
<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" ? "☎ Продолжить разговор" : "☎ Ответить на вызов"}
|
{call.phase === "talking" ? "☎ Продолжить разговор" : "☎ Ответить на вызов"}
|
||||||
</button>}
|
</button>}
|
||||||
{call.micOn && <span className="arm-live">● Разговор идёт</span>}
|
{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"
|
<span className="arm-call-actions-note">{call.phase === "ended"
|
||||||
? "Вызов завершён"
|
? "Вызов завершён"
|
||||||
: !call.micOn && call.phase === "incoming"
|
: !call.micOn && call.phase === "incoming"
|
||||||
|
|
@ -198,7 +198,7 @@ export function Call() {
|
||||||
<KioCard
|
<KioCard
|
||||||
state={call.card}
|
state={call.card}
|
||||||
required={call.incoming?.required_fields ?? []}
|
required={call.incoming?.required_fields ?? []}
|
||||||
readOnly={!call.micOn}
|
readOnly={call.paused || !call.micOn}
|
||||||
referenceArm
|
referenceArm
|
||||||
onChange={call.patchKio}
|
onChange={call.patchKio}
|
||||||
/>
|
/>
|
||||||
|
|
@ -209,12 +209,12 @@ export function Call() {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="arm-primary"
|
className="arm-primary"
|
||||||
disabled={!call.micOn || (!service && !autoRoute)}
|
disabled={call.paused || !call.micOn || (!service && !autoRoute)}
|
||||||
onClick={() => call.dispatch(service)}
|
onClick={() => call.dispatch(service)}
|
||||||
>
|
>
|
||||||
Передать в ДДС
|
Передать в ДДС
|
||||||
</button>
|
</button>
|
||||||
{call.micOn && <button type="button" onClick={call.hangup}>Завершить вызов</button>}
|
{call.micOn && <button type="button" disabled={call.paused} onClick={call.hangup}>Завершить вызов</button>}
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
<div className="arm-secondary">
|
<div className="arm-secondary">
|
||||||
|
|
@ -227,6 +227,7 @@ export function Call() {
|
||||||
type="text"
|
type="text"
|
||||||
aria-label="Комментарий при закрытии вызова без карточки"
|
aria-label="Комментарий при закрытии вызова без карточки"
|
||||||
value={resolveComment}
|
value={resolveComment}
|
||||||
|
disabled={call.paused}
|
||||||
placeholder="что сказали заявителю, куда передали"
|
placeholder="что сказали заявителю, куда передали"
|
||||||
onChange={(event) => setResolveComment(event.target.value)}
|
onChange={(event) => setResolveComment(event.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|
@ -234,7 +235,7 @@ export function Call() {
|
||||||
<p>
|
<p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!call.micOn || resolved !== null}
|
disabled={call.paused || !call.micOn || resolved !== null}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
call.resolve("consultation", resolveComment);
|
call.resolve("consultation", resolveComment);
|
||||||
setResolved("consultation");
|
setResolved("consultation");
|
||||||
|
|
@ -244,7 +245,7 @@ export function Call() {
|
||||||
</button>{" "}
|
</button>{" "}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!call.micOn || resolved !== null}
|
disabled={call.paused || !call.micOn || resolved !== null}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
call.resolve("transfer_region", resolveComment);
|
call.resolve("transfer_region", resolveComment);
|
||||||
setResolved("transfer_region");
|
setResolved("transfer_region");
|
||||||
|
|
|
||||||
|
|
@ -316,6 +316,7 @@ button:disabled { opacity: 0.5; cursor: default; }
|
||||||
|
|
||||||
/* АРМ ДДС: реестр остаётся плотной таблицей, карточка и связь — рядом. */
|
/* АРМ ДДС: реестр остаётся плотной таблицей, карточка и связь — рядом. */
|
||||||
.dds-workspace { max-width: 1800px; margin: 0 auto; }
|
.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 { 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 { margin: 2px 0 0; }
|
||||||
.dds-header h1 span { color: #576273; font-size: 15px; font-weight: 400; }
|
.dds-header h1 span { color: #576273; font-size: 15px; font-weight: 400; }
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue