From 7b509f6af37fcb82e922cc2c8e940cd9cdfc08db Mon Sep 17 00:00:00 2001 From: TronoSfera <119615520+TronoSfera@users.noreply.github.com> Date: Fri, 27 Feb 2026 20:43:57 +0300 Subject: [PATCH] fix client chat --- app/api/admin/chat.py | 95 + app/api/public/chat.py | 98 +- app/services/chat_presence.py | 195 + app/services/chat_service.py | 29 + app/services/sms_service.py | 8 +- app/web/admin.css | 75 + app/web/admin.jsx | 4 + .../features/requests/RequestWorkspace.jsx | 247 +- app/web/admin/hooks/useRequestWorkspace.js | 28 + app/web/client.html | 2 +- app/web/client.js | 3361 ++++++++++++----- app/web/client.jsx | 38 +- app/web/landing.html | 2 +- app/web/landing.js | 8 + celerybeat-schedule | Bin 16384 -> 16384 bytes e2e/tests/helpers.js | 2 +- tests/admin/test_lawyer_chat.py | 109 + tests/test_public_cabinet.py | 63 + tests/test_sms_service.py | 1 + 19 files changed, 3371 insertions(+), 994 deletions(-) create mode 100644 app/services/chat_presence.py diff --git a/app/api/admin/chat.py b/app/api/admin/chat.py index 4ca1820..2fdc92d 100644 --- a/app/api/admin/chat.py +++ b/app/api/admin/chat.py @@ -1,5 +1,6 @@ from __future__ import annotations +from datetime import datetime, timezone from uuid import UUID from fastapi import APIRouter, Depends, HTTPException @@ -17,15 +18,53 @@ from app.models.request_data_template_item import RequestDataTemplateItem from app.models.topic_data_template import TopicDataTemplate from app.services.chat_service import ( create_admin_or_lawyer_message, + get_chat_activity_summary, list_messages_for_request, serialize_message, serialize_messages_for_request, ) +from app.services.chat_presence import list_typing_presence, set_typing_presence router = APIRouter() ALLOWED_VALUE_TYPES = {"string", "text", "date", "number", "file"} +def _parse_cursor(raw: str | None) -> datetime | None: + value = str(raw or "").strip() + if not value: + return None + normalized = value.replace("Z", "+00:00") + try: + dt = datetime.fromisoformat(normalized) + except ValueError: + return None + if dt.tzinfo is None: + return dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc) + + +def _iso_or_none(dt: datetime | None) -> str | None: + if dt is None: + return None + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + else: + dt = dt.astimezone(timezone.utc) + return dt.isoformat() + + +def _as_utc_datetime(value) -> datetime | None: + if value is None: + return None + if isinstance(value, datetime): + if value.tzinfo is None: + return value.replace(tzinfo=timezone.utc) + return value.astimezone(timezone.utc) + if isinstance(value, str): + return _parse_cursor(value) + return None + + def _request_uuid_or_400(request_id: str) -> UUID: try: return UUID(str(request_id)) @@ -224,6 +263,62 @@ def create_request_message( return serialize_message(row) +@router.get("/requests/{request_id}/live") +def get_request_live_state( + request_id: str, + cursor: str | None = None, + db: Session = Depends(get_db), + admin: dict = Depends(require_role("ADMIN", "LAWYER", "CURATOR")), +): + req = _request_for_id_or_404(db, request_id) + _ensure_lawyer_can_view_request_or_403(admin, req) + summary = get_chat_activity_summary(db, req.id) + latest_activity_at = _as_utc_datetime(summary.get("latest_activity_at")) + latest_activity_iso = _iso_or_none(latest_activity_at) + cursor_dt = _parse_cursor(cursor) + has_updates = bool(latest_activity_at and (cursor_dt is None or latest_activity_at > cursor_dt)) + + actor_sub = str(admin.get("sub") or "").strip() or "unknown" + actor_role = str(admin.get("role") or "").strip().upper() or "UNKNOWN" + actor_key = f"{actor_role}:{actor_sub}" + typing_rows = list_typing_presence(request_key=str(req.id), exclude_actor_key=actor_key) + return { + "request_id": str(req.id), + "cursor": latest_activity_iso, + "has_updates": has_updates, + "message_count": int(summary.get("message_count") or 0), + "attachment_count": int(summary.get("attachment_count") or 0), + "latest_message_at": _iso_or_none(_as_utc_datetime(summary.get("latest_message_at"))), + "latest_attachment_at": _iso_or_none(_as_utc_datetime(summary.get("latest_attachment_at"))), + "typing": typing_rows, + } + + +@router.post("/requests/{request_id}/typing") +def set_request_typing_state( + request_id: str, + payload: dict, + db: Session = Depends(get_db), + admin: dict = Depends(require_role("ADMIN", "LAWYER", "CURATOR")), +): + req = _request_for_id_or_404(db, request_id) + _ensure_lawyer_can_manage_request_or_403(admin, req) + actor_role = str(admin.get("role") or "").strip().upper() or "UNKNOWN" + actor_sub = str(admin.get("sub") or "").strip() or "unknown" + actor_email = str(admin.get("email") or "").strip() + actor_key = f"{actor_role}:{actor_sub}" + actor_label = actor_email or ("Юрист" if actor_role == "LAWYER" else "Администратор") + typing = bool((payload or {}).get("typing")) + set_typing_presence( + request_key=str(req.id), + actor_key=actor_key, + actor_label=actor_label, + actor_role=actor_role, + typing=typing, + ) + return {"status": "ok", "typing": typing} + + @router.get("/requests/{request_id}/data-request-templates") def list_data_request_templates( request_id: str, diff --git a/app/api/public/chat.py b/app/api/public/chat.py index 68d8367..809a7a8 100644 --- a/app/api/public/chat.py +++ b/app/api/public/chat.py @@ -1,4 +1,5 @@ from __future__ import annotations +from datetime import datetime, timezone from uuid import UUID from fastapi import APIRouter, Depends, HTTPException @@ -11,12 +12,55 @@ from app.models.message import Message from app.models.request import Request from app.models.request_data_requirement import RequestDataRequirement from app.schemas.public import PublicMessageCreate -from app.services.chat_service import create_client_message, list_messages_for_request, serialize_message, serialize_messages_for_request +from app.services.chat_presence import list_typing_presence, set_typing_presence +from app.services.chat_service import ( + create_client_message, + get_chat_activity_summary, + list_messages_for_request, + serialize_message, + serialize_messages_for_request, +) from app.services.request_read_markers import EVENT_MESSAGE, mark_unread_for_lawyer router = APIRouter() +def _parse_cursor(raw: str | None) -> datetime | None: + value = str(raw or "").strip() + if not value: + return None + normalized = value.replace("Z", "+00:00") + try: + dt = datetime.fromisoformat(normalized) + except ValueError: + return None + if dt.tzinfo is None: + return dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc) + + +def _iso_or_none(dt: datetime | None) -> str | None: + if dt is None: + return None + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + else: + dt = dt.astimezone(timezone.utc) + return dt.isoformat() + + +def _as_utc_datetime(value) -> datetime | None: + if value is None: + return None + if isinstance(value, datetime): + if value.tzinfo is None: + return value.replace(tzinfo=timezone.utc) + return value.astimezone(timezone.utc) + if isinstance(value, str): + return _parse_cursor(value) + return None + + def _attachment_meta_for_public(req: Request, value_text: str | None, db: Session) -> dict | None: raw = str(value_text or "").strip() if not raw: @@ -101,6 +145,58 @@ def create_message_by_track( return serialize_message(row) +@router.get("/requests/{track_number}/live") +def get_live_chat_state_by_track( + track_number: str, + cursor: str | None = None, + db: Session = Depends(get_db), + session: dict = Depends(get_public_session), +): + req = _request_for_track_or_404(db, track_number) + _ensure_view_access_or_403(session, req) + summary = get_chat_activity_summary(db, req.id) + latest_activity_at = _as_utc_datetime(summary.get("latest_activity_at")) + latest_activity_iso = _iso_or_none(latest_activity_at) + cursor_dt = _parse_cursor(cursor) + has_updates = bool(latest_activity_at and (cursor_dt is None or latest_activity_at > cursor_dt)) + + subject = _require_view_session_or_403(session) + actor_key = f"CLIENT:{_normalize_track(subject) or _normalize_phone(subject)}" + typing_rows = list_typing_presence(request_key=str(req.id), exclude_actor_key=actor_key) + return { + "track_number": req.track_number, + "cursor": latest_activity_iso, + "has_updates": has_updates, + "message_count": int(summary.get("message_count") or 0), + "attachment_count": int(summary.get("attachment_count") or 0), + "latest_message_at": _iso_or_none(_as_utc_datetime(summary.get("latest_message_at"))), + "latest_attachment_at": _iso_or_none(_as_utc_datetime(summary.get("latest_attachment_at"))), + "typing": typing_rows, + } + + +@router.post("/requests/{track_number}/typing") +def set_live_chat_typing_by_track( + track_number: str, + payload: dict, + db: Session = Depends(get_db), + session: dict = Depends(get_public_session), +): + req = _request_for_track_or_404(db, track_number) + _ensure_view_access_or_403(session, req) + subject = _require_view_session_or_403(session) + typing = bool((payload or {}).get("typing")) + actor_key = f"CLIENT:{_normalize_track(subject) or _normalize_phone(subject)}" + set_typing_presence( + request_key=str(req.id), + actor_key=actor_key, + actor_label=str(req.client_name or "Клиент"), + actor_role="CLIENT", + typing=typing, + ) + return {"status": "ok", "typing": typing} + + @router.get("/requests/{track_number}/data-requests/{message_id}") def get_data_request_by_message( track_number: str, diff --git a/app/services/chat_presence.py b/app/services/chat_presence.py new file mode 100644 index 0000000..d2388d4 --- /dev/null +++ b/app/services/chat_presence.py @@ -0,0 +1,195 @@ +from __future__ import annotations + +import json +import threading +from datetime import datetime, timezone +from typing import Any + +import redis + +from app.core.config import settings + +_DEFAULT_TYPING_TTL_SECONDS = 9 + +_redis_client: redis.Redis | None = None +_redis_lock = threading.Lock() + +_memory_lock = threading.Lock() +_memory_state: dict[str, dict[str, dict[str, Any]]] = {} + + +def _utc_now() -> datetime: + return datetime.now(timezone.utc) + + +def _iso_utc(dt: datetime) -> str: + return dt.astimezone(timezone.utc).isoformat() + + +def _get_redis_client() -> redis.Redis | None: + global _redis_client + if _redis_client is not None: + return _redis_client + with _redis_lock: + if _redis_client is not None: + return _redis_client + try: + client = redis.Redis.from_url( + settings.REDIS_URL, + decode_responses=True, + socket_timeout=0.25, + socket_connect_timeout=0.25, + ) + client.ping() + _redis_client = client + return _redis_client + except Exception: + _redis_client = None + return None + + +def _request_actors_key(request_key: str) -> str: + return f"chat:typing:req:{request_key}:actors" + + +def _actor_payload_key(request_key: str, actor_key: str) -> str: + return f"chat:typing:req:{request_key}:actor:{actor_key}" + + +def set_typing_presence( + *, + request_key: str, + actor_key: str, + actor_label: str, + actor_role: str, + typing: bool, + ttl_seconds: int = _DEFAULT_TYPING_TTL_SECONDS, +) -> None: + normalized_request = str(request_key or "").strip() + normalized_actor = str(actor_key or "").strip() + if not normalized_request or not normalized_actor: + return + + ttl = max(2, int(ttl_seconds or _DEFAULT_TYPING_TTL_SECONDS)) + if typing: + payload = { + "actor_key": normalized_actor, + "actor_label": str(actor_label or "").strip() or "Собеседник", + "actor_role": str(actor_role or "").strip().upper() or "UNKNOWN", + "updated_at": _iso_utc(_utc_now()), + } + else: + payload = None + + client = _get_redis_client() + if client is not None: + actors_key = _request_actors_key(normalized_request) + actor_payload_key = _actor_payload_key(normalized_request, normalized_actor) + try: + pipe = client.pipeline() + if payload is None: + pipe.delete(actor_payload_key) + pipe.srem(actors_key, normalized_actor) + else: + pipe.sadd(actors_key, normalized_actor) + pipe.setex(actor_payload_key, ttl, json.dumps(payload, ensure_ascii=False)) + pipe.expire(actors_key, max(60, ttl * 8)) + pipe.execute() + return + except Exception: + pass + + with _memory_lock: + actors = _memory_state.setdefault(normalized_request, {}) + if payload is None: + actors.pop(normalized_actor, None) + if not actors: + _memory_state.pop(normalized_request, None) + return + expires_at = _utc_now().timestamp() + ttl + actors[normalized_actor] = {**payload, "expires_at": expires_at} + + +def list_typing_presence( + *, + request_key: str, + exclude_actor_key: str | None = None, +) -> list[dict[str, Any]]: + normalized_request = str(request_key or "").strip() + if not normalized_request: + return [] + excluded = str(exclude_actor_key or "").strip() + now_ts = _utc_now().timestamp() + + client = _get_redis_client() + if client is not None: + actors_key = _request_actors_key(normalized_request) + try: + members = list(client.smembers(actors_key) or []) + if not members: + return [] + keys = [_actor_payload_key(normalized_request, str(member)) for member in members] + rows = client.mget(keys) + stale_members: list[str] = [] + result: list[dict[str, Any]] = [] + for actor, raw in zip(members, rows): + actor_str = str(actor) + if not raw: + stale_members.append(actor_str) + continue + try: + payload = json.loads(str(raw)) + except Exception: + stale_members.append(actor_str) + continue + if excluded and actor_str == excluded: + continue + result.append( + { + "actor_key": actor_str, + "actor_label": str(payload.get("actor_label") or "Собеседник"), + "actor_role": str(payload.get("actor_role") or "UNKNOWN"), + "updated_at": str(payload.get("updated_at") or ""), + } + ) + if stale_members: + try: + client.srem(actors_key, *stale_members) + except Exception: + pass + result.sort(key=lambda item: str(item.get("updated_at") or ""), reverse=True) + return result + except Exception: + pass + + with _memory_lock: + actors = _memory_state.get(normalized_request) or {} + stale: list[str] = [] + result: list[dict[str, Any]] = [] + for actor_key, payload in actors.items(): + expires_at = float(payload.get("expires_at") or 0) + if expires_at <= now_ts: + stale.append(actor_key) + continue + if excluded and actor_key == excluded: + continue + result.append( + { + "actor_key": actor_key, + "actor_label": str(payload.get("actor_label") or "Собеседник"), + "actor_role": str(payload.get("actor_role") or "UNKNOWN"), + "updated_at": str(payload.get("updated_at") or ""), + } + ) + for actor_key in stale: + actors.pop(actor_key, None) + if not actors: + _memory_state.pop(normalized_request, None) + result.sort(key=lambda item: str(item.get("updated_at") or ""), reverse=True) + return result + + +def clear_presence_for_tests() -> None: + with _memory_lock: + _memory_state.clear() + diff --git a/app/services/chat_service.py b/app/services/chat_service.py index 974a0e5..3a6f224 100644 --- a/app/services/chat_service.py +++ b/app/services/chat_service.py @@ -3,6 +3,7 @@ from __future__ import annotations from typing import Any from fastapi import HTTPException +from sqlalchemy import func from sqlalchemy.orm import Session from app.models.message import Message @@ -212,3 +213,31 @@ def create_admin_or_lawyer_message( db.commit() db.refresh(row) return row + + +def get_chat_activity_summary(db: Session, request_id: Any) -> dict[str, Any]: + message_count, latest_message_at = ( + db.query( + func.count(Message.id), + func.max(func.coalesce(Message.updated_at, Message.created_at)), + ) + .filter(Message.request_id == request_id) + .one() + ) + attachment_count, latest_attachment_at = ( + db.query( + func.count(Attachment.id), + func.max(func.coalesce(Attachment.updated_at, Attachment.created_at)), + ) + .filter(Attachment.request_id == request_id) + .one() + ) + latest_candidates = [value for value in (latest_message_at, latest_attachment_at) if value is not None] + latest_activity_at = max(latest_candidates) if latest_candidates else None + return { + "message_count": int(message_count or 0), + "attachment_count": int(attachment_count or 0), + "latest_message_at": latest_message_at, + "latest_attachment_at": latest_attachment_at, + "latest_activity_at": latest_activity_at, + } diff --git a/app/services/sms_service.py b/app/services/sms_service.py index 19e3d09..69b8929 100644 --- a/app/services/sms_service.py +++ b/app/services/sms_service.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio import importlib.util +import logging from typing import Any from app.core.config import settings @@ -11,6 +12,9 @@ class SmsDeliveryError(Exception): pass +logger = logging.getLogger("uvicorn.error") + + def _otp_dev_mode_enabled() -> bool: return bool(getattr(settings, "OTP_DEV_MODE", False)) @@ -39,7 +43,8 @@ def _build_otp_message(*, code: str, purpose: str, track_number: str | None) -> def _mock_sms_send(*, phone: str, code: str, purpose: str, track_number: str | None) -> dict[str, Any]: - print(f"[OTP MOCK] purpose={purpose} phone={phone} track={track_number or '-'} code={code}") + line = f"[OTP MOCK] purpose={purpose} phone={phone} track={track_number or '-'} code={code}" + logger.warning(line) return { "provider": "mock_sms", "status": "accepted", @@ -203,6 +208,7 @@ def send_otp_message(*, phone: str, code: str, purpose: str, track_number: str | if _otp_dev_mode_enabled(): payload = _mock_sms_send(phone=phone, code=code, purpose=purpose, track_number=track_number) payload["dev_mode"] = True + payload["debug_code"] = str(code) return payload provider = str(settings.SMS_PROVIDER or "dummy").strip().lower() diff --git a/app/web/admin.css b/app/web/admin.css index 3e66f7d..5d4a1d2 100644 --- a/app/web/admin.css +++ b/app/web/admin.css @@ -1911,6 +1911,47 @@ padding-bottom: 0.02rem; } + .request-data-progress-line { + display: flex; + justify-content: flex-end; + margin-top: -0.08rem; + } + + .request-data-progress-chip { + display: inline-flex; + align-items: center; + border-radius: 999px; + border: 1px solid rgba(105, 179, 132, 0.42); + background: rgba(57, 138, 88, 0.18); + color: #d5f5df; + padding: 0.16rem 0.56rem; + font-size: 0.74rem; + font-weight: 700; + letter-spacing: 0.015em; + } + + .request-data-row-client-value { + grid-column: 2 / -1; + display: flex; + align-items: center; + gap: 0.4rem; + margin-top: 0.05rem; + min-height: 1.6rem; + } + + .request-data-row-client-label { + font-size: 0.73rem; + color: #94a9c4; + white-space: nowrap; + } + + .request-data-row-client-text { + color: #dbe8f8; + font-size: 0.83rem; + line-height: 1.3; + word-break: break-word; + } + .modal-actions-right { justify-content: flex-end; } @@ -2210,6 +2251,35 @@ flex-wrap: wrap; } + .request-chat-live-row { + display: inline-flex; + align-items: center; + gap: 0.42rem; + margin: -0.1rem 0 0.55rem; + min-height: 1rem; + } + + .chat-live-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: rgba(68, 208, 138, 0.95); + box-shadow: 0 0 0 3px rgba(68, 208, 138, 0.16); + flex-shrink: 0; + } + + .chat-live-dot.degraded { + background: rgba(240, 167, 72, 0.95); + box-shadow: 0 0 0 3px rgba(240, 167, 72, 0.16); + } + + .request-chat-live-text { + font-size: 0.77rem; + color: #a8bad0; + line-height: 1.2; + letter-spacing: 0.01em; + } + .request-chat-tabs { display: inline-flex; align-items: center; @@ -2393,6 +2463,11 @@ grid-template-columns: 1fr; gap: 0.25rem; } + + .request-data-row-client-value { + grid-column: 1 / -1; + flex-wrap: wrap; + } } .chat-message-files { diff --git a/app/web/admin.jsx b/app/web/admin.jsx index 35b1247..a2ecf33 100644 --- a/app/web/admin.jsx +++ b/app/web/admin.jsx @@ -981,6 +981,8 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__"; clearPendingStatusChangePreset, submitRequestStatusChange, submitRequestModalMessage, + probeRequestLive, + setRequestTyping, loadRequestDataTemplates, loadRequestDataBatch, loadRequestDataTemplateDetails, @@ -3129,6 +3131,8 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__"; onSaveRequestDataBatch={saveRequestDataBatch} onChangeStatus={submitRequestStatusChange} onConsumePendingStatusChangePreset={clearPendingStatusChangePreset} + onLiveProbe={probeRequestLive} + onTypingSignal={setRequestTyping} AttachmentPreviewModalComponent={AttachmentPreviewModal} StatusLineComponent={StatusLine} /> diff --git a/app/web/admin/features/requests/RequestWorkspace.jsx b/app/web/admin/features/requests/RequestWorkspace.jsx index db76820..232a131 100644 --- a/app/web/admin/features/requests/RequestWorkspace.jsx +++ b/app/web/admin/features/requests/RequestWorkspace.jsx @@ -42,6 +42,8 @@ export function RequestWorkspace({ onUploadRequestAttachment, onChangeStatus, onConsumePendingStatusChangePreset, + onLiveProbe, + onTypingSignal, domIds, AttachmentPreviewModalComponent, StatusLineComponent, @@ -97,8 +99,19 @@ export function RequestWorkspace({ status: "", error: "", }); + const [composerFocused, setComposerFocused] = useState(false); + const [typingPeers, setTypingPeers] = useState([]); + const [liveMode, setLiveMode] = useState("online"); const fileInputRef = useRef(null); const statusChangeFileInputRef = useRef(null); + const chatListRef = useRef(null); + const liveCursorRef = useRef(""); + const liveTimerRef = useRef(null); + const liveInFlightRef = useRef(false); + const liveFailCountRef = useRef(0); + const typingHeartbeatRef = useRef(null); + const typingActiveRef = useRef(false); + const lastAutoScrollCursorRef = useRef(""); const idMap = useMemo( () => ({ messagesList: "request-modal-messages", @@ -367,6 +380,40 @@ export function RequestWorkspace({ return map; }, [safeAttachments]); + const localActivityCursor = useMemo(() => { + let latestTs = 0; + const pickLatest = (value) => { + if (!value) return; + const ts = new Date(value).getTime(); + if (Number.isFinite(ts) && ts > latestTs) latestTs = ts; + }; + safeMessages.forEach((item) => { + pickLatest(item?.updated_at); + pickLatest(item?.created_at); + }); + safeAttachments.forEach((item) => { + pickLatest(item?.updated_at); + pickLatest(item?.created_at); + }); + return latestTs > 0 ? new Date(latestTs).toISOString() : ""; + }, [safeAttachments, safeMessages]); + + const typingHintText = useMemo(() => { + const rows = Array.isArray(typingPeers) ? typingPeers : []; + if (!rows.length) return ""; + const labels = rows + .map((item) => String(item?.actor_label || item?.label || "").trim()) + .filter(Boolean); + if (!labels.length) return "Собеседник печатает..."; + const unique = []; + labels.forEach((label) => { + if (!unique.includes(label)) unique.push(label); + }); + if (unique.length === 1) return unique[0] + " печатает..."; + if (unique.length === 2) return unique[0] + " и " + unique[1] + " печатают..."; + return unique[0] + ", " + unique[1] + " и еще " + String(unique.length - 2) + " печатают..."; + }, [typingPeers]); + const openAttachmentFromMessage = (item) => { if (!item?.download_url) return; const kind = detectAttachmentPreviewKind(item.file_name, item.mime_type); @@ -377,6 +424,121 @@ export function RequestWorkspace({ openPreview(item); }; + useEffect(() => { + liveCursorRef.current = localActivityCursor || ""; + }, [localActivityCursor, row?.id]); + + useEffect(() => { + if (!row || typeof onLiveProbe !== "function") { + setTypingPeers([]); + setLiveMode("online"); + if (liveTimerRef.current) { + clearTimeout(liveTimerRef.current); + liveTimerRef.current = null; + } + liveInFlightRef.current = false; + liveFailCountRef.current = 0; + return undefined; + } + + let cancelled = false; + const scheduleNext = (ms) => { + if (cancelled) return; + if (liveTimerRef.current) clearTimeout(liveTimerRef.current); + liveTimerRef.current = setTimeout(runProbe, ms); + }; + + const runProbe = async () => { + if (cancelled || liveInFlightRef.current) return; + liveInFlightRef.current = true; + try { + const payload = await onLiveProbe({ cursor: liveCursorRef.current }); + const cursor = String(payload?.cursor || "").trim(); + if (cursor) liveCursorRef.current = cursor; + setTypingPeers(Array.isArray(payload?.typing) ? payload.typing : []); + liveFailCountRef.current = 0; + setLiveMode("online"); + } catch (_) { + liveFailCountRef.current += 1; + setLiveMode(liveFailCountRef.current >= 3 ? "degraded" : "online"); + } finally { + liveInFlightRef.current = false; + const hidden = typeof document !== "undefined" && document.visibilityState === "hidden"; + const baseInterval = hidden ? 8000 : 2500; + const failStep = Math.min(5, Math.max(0, liveFailCountRef.current)); + const backoffInterval = failStep > 0 ? Math.min(30000, baseInterval * Math.pow(2, failStep - 1)) : baseInterval; + scheduleNext(backoffInterval); + } + }; + + runProbe(); + return () => { + cancelled = true; + if (liveTimerRef.current) { + clearTimeout(liveTimerRef.current); + liveTimerRef.current = null; + } + liveInFlightRef.current = false; + liveFailCountRef.current = 0; + setTypingPeers([]); + setLiveMode("online"); + }; + }, [onLiveProbe, row, trackNumber]); + + const typingEnabled = Boolean( + row && + typeof onTypingSignal === "function" && + !loading && + !fileUploading && + composerFocused && + String(messageDraft || "").trim() + ); + + useEffect(() => { + if (typeof onTypingSignal !== "function" || !row) { + if (typingHeartbeatRef.current) { + clearInterval(typingHeartbeatRef.current); + typingHeartbeatRef.current = null; + } + typingActiveRef.current = false; + return; + } + if (typingEnabled) { + if (!typingActiveRef.current) { + typingActiveRef.current = true; + void onTypingSignal({ typing: true }).catch(() => null); + } + if (!typingHeartbeatRef.current) { + typingHeartbeatRef.current = setInterval(() => { + void onTypingSignal({ typing: true }).catch(() => null); + }, 2500); + } + return; + } + if (typingHeartbeatRef.current) { + clearInterval(typingHeartbeatRef.current); + typingHeartbeatRef.current = null; + } + if (typingActiveRef.current) { + typingActiveRef.current = false; + void onTypingSignal({ typing: false }).catch(() => null); + } + }, [onTypingSignal, row, typingEnabled]); + + useEffect( + () => () => { + if (typingHeartbeatRef.current) { + clearInterval(typingHeartbeatRef.current); + typingHeartbeatRef.current = null; + } + if (typingActiveRef.current && typeof onTypingSignal === "function") { + typingActiveRef.current = false; + void onTypingSignal({ typing: false }).catch(() => null); + } + }, + [onTypingSignal] + ); + const newDataRequestRow = (source) => { const item = source || {}; const label = String(item.label || "").trim(); @@ -392,6 +554,7 @@ export function RequestWorkspace({ field_type: fieldType, document_name: String(item.document_name || "").trim(), value_text: item.value_text == null ? "" : String(item.value_text), + value_file: item.value_file || null, is_filled: Boolean(item.is_filled), }; }; @@ -821,15 +984,7 @@ export function RequestWorkspace({ message_id: currentMessageId, items: payloadItems, }); - setClientDataModal((prev) => ({ - ...prev, - saving: false, - status: "Данные сохранены.", - items: (prev.items || []).map((item) => ({ - ...item, - pendingFile: null, - })), - })); + closeClientDataModal(); } catch (error) { setClientDataModal((prev) => ({ ...prev, @@ -956,6 +1111,20 @@ export function RequestWorkspace({ chatTimelineItems.push(entry); }); + useEffect(() => { + if (chatTab !== "chat") return; + const listNode = chatListRef.current; + if (!listNode) return; + const cursor = String(localActivityCursor || ""); + if (!cursor || cursor === lastAutoScrollCursorRef.current) return; + lastAutoScrollCursorRef.current = cursor; + const raf = window.requestAnimationFrame(() => { + if (!chatListRef.current) return; + chatListRef.current.scrollTop = chatListRef.current.scrollHeight; + }); + return () => window.cancelAnimationFrame(raf); + }, [chatTab, localActivityCursor]); + const routeNodes = Array.isArray(statusRouteNodes) && statusRouteNodes.length ? statusRouteNodes @@ -972,7 +1141,7 @@ export function RequestWorkspace({ if (!items.length) return
Запрос
; if (allFilled) { const fileOnly = items.length === 1 && String(items[0]?.field_type || "").toLowerCase() === "file"; - return{fileOnly ? "Файл" : "Запрос"}
; + return{fileOnly ? "Файл" : "Заполнен"}
; } const visibleItems = items.slice(0, 7); const hiddenCount = Math.max(0, items.length - visibleItems.length); @@ -1014,6 +1183,13 @@ export function RequestWorkspace({ return text || "Не заполнено"; }; + const dataRequestProgress = useMemo(() => { + const rows = Array.isArray(dataRequestModal.rows) ? dataRequestModal.rows : []; + const total = rows.length; + const filled = rows.filter((rowItem) => Boolean(rowItem?.is_filled || String(rowItem?.value_text || "").trim())).length; + return { total, filled }; + }, [dataRequestModal.rows]); + return (