diff --git a/digital-avatar-app/backend/database.py b/digital-avatar-app/backend/database.py index 84e7d10..7784721 100644 --- a/digital-avatar-app/backend/database.py +++ b/digital-avatar-app/backend/database.py @@ -38,7 +38,7 @@ def init_db(): ("avatars", "owner_id", "VARCHAR DEFAULT ''"), ("authorizations", "takeover_enabled", "BOOLEAN DEFAULT 0"), ("authorizations", "takeover_mode", "VARCHAR DEFAULT 'immediate'"), - ("authorizations", "takeover_delay_seconds", "INTEGER DEFAULT 30"), + ("authorizations", "takeover_delay_seconds", "INTEGER DEFAULT 180"), ("avatars", "share_token", "VARCHAR DEFAULT NULL"), ("token_account", "user_id", "VARCHAR DEFAULT ''"), ("token_account", "total_granted", "BIGINT DEFAULT 0"), @@ -47,6 +47,7 @@ def init_db(): ("token_account", "updated_at", "TIMESTAMP"), ) _normalize_optional_unique_values() + _normalize_takeover_delays() _create_token_indexes() @@ -66,6 +67,15 @@ def _normalize_optional_unique_values(): conn.exec_driver_sql("UPDATE avatars SET share_token = NULL WHERE share_token = ''") +def _normalize_takeover_delays(): + with engine.begin() as conn: + # The old 30-second column default was never wired into the scheduler. + conn.exec_driver_sql( + "UPDATE authorizations SET takeover_delay_seconds = 180 " + "WHERE takeover_delay_seconds IS NULL OR takeover_delay_seconds = 30" + ) + + def _create_token_indexes(): with engine.begin() as conn: conn.exec_driver_sql( diff --git a/digital-avatar-app/backend/models.py b/digital-avatar-app/backend/models.py index 345c454..19717fe 100644 --- a/digital-avatar-app/backend/models.py +++ b/digital-avatar-app/backend/models.py @@ -67,7 +67,7 @@ class Authorization(Base): status = Column(String, default="active") # active | inactive takeover_enabled = Column(Boolean, default=False) # 是否开启分身接管 takeover_mode = Column(String, default="immediate") # immediate | delayed - takeover_delay_seconds = Column(Integer, default=30) # 延迟秒数 + takeover_delay_seconds = Column(Integer, default=180) # 延迟秒数,默认 3 分钟 created_at = Column(DateTime, server_default=func.now()) def to_dict(self): diff --git a/digital-avatar-app/backend/routers/authorizations.py b/digital-avatar-app/backend/routers/authorizations.py index f6fe7e3..d51063b 100644 --- a/digital-avatar-app/backend/routers/authorizations.py +++ b/digital-avatar-app/backend/routers/authorizations.py @@ -2,7 +2,7 @@ from fastapi import APIRouter, Body, Depends, Header, HTTPException from sqlalchemy.orm import Session from database import get_db -from models import Authorization, TakeoverCursor, TakeoverReplyTask +from models import Authorization, Avatar, TakeoverCursor, TakeoverReplyTask from responses import fail, ok from routers.avatars import _require_owned_avatar @@ -14,6 +14,10 @@ ALLOWED_PERMISSIONS = set(PERMISSION_ORDER) AVATAR_PERMISSION_ORDER = PERMISSION_ORDER AVATAR_PERMISSION_KEY = "authorizationPermissions" DEFAULT_AVATAR_PERMISSIONS = ["friend", "chat"] +TAKEOVER_DELAY_KEY = "takeoverReplyDelaySeconds" +DEFAULT_TAKEOVER_DELAY_SECONDS = 180 +MIN_TAKEOVER_DELAY_SECONDS = 3 +MAX_TAKEOVER_DELAY_SECONDS = 86_400 LEGACY_PERMISSION_MAP = { "read": "browse", "reply": "chat", @@ -91,9 +95,62 @@ def _permission_settings_payload(avatar) -> dict: return { "avatarId": avatar.id, "permissions": _stored_avatar_permissions(avatar), + "takeoverReplyDelaySeconds": _stored_takeover_delay(avatar), } +def _stored_takeover_delay(avatar) -> int: + raw = (avatar.config or {}).get(TAKEOVER_DELAY_KEY, DEFAULT_TAKEOVER_DELAY_SECONDS) + if isinstance(raw, bool): + return DEFAULT_TAKEOVER_DELAY_SECONDS + try: + delay = int(raw) + except (TypeError, ValueError): + return DEFAULT_TAKEOVER_DELAY_SECONDS + if not MIN_TAKEOVER_DELAY_SECONDS <= delay <= MAX_TAKEOVER_DELAY_SECONDS: + return DEFAULT_TAKEOVER_DELAY_SECONDS + return delay + + +def _validate_takeover_delay(value) -> int: + if isinstance(value, bool) or not isinstance(value, int): + raise ValueError("自动回复等待时间必须是整数秒") + if not MIN_TAKEOVER_DELAY_SECONDS <= value <= MAX_TAKEOVER_DELAY_SECONDS: + raise ValueError("自动回复等待时间需在 3 秒到 24 小时之间") + return value + + +def _disable_other_takeovers(db: Session, avatar) -> list[str]: + disabled_ids = [] + others = ( + db.query(Avatar) + .filter(Avatar.owner_id == avatar.owner_id, Avatar.id != avatar.id) + .all() + ) + for other in others: + permissions = _stored_avatar_permissions(other) + if "takeover" not in permissions: + continue + other.config = { + **(other.config or {}), + AVATAR_PERMISSION_KEY: [item for item in permissions if item != "takeover"], + } + disabled_ids.append(other.id) + tasks = ( + db.query(TakeoverReplyTask) + .filter( + TakeoverReplyTask.avatar_id == other.id, + TakeoverReplyTask.status.in_(("pending", "generating", "ready", "sending")), + ) + .all() + ) + for task in tasks: + task.status = "cancelled" + task.cancel_reason = "another_avatar_takeover_enabled" + task.locked_at = None + return disabled_ids + + def _require_authorization(db: Session, avatar_id: str, authorization_id: str) -> Authorization: authorization = ( db.query(Authorization) @@ -144,10 +201,19 @@ def update_permission_settings( db: Session = Depends(get_db), ): avatar = _require_owned_avatar(db, avatar_id, authorization) - if "permissions" not in payload: - return fail("缺少 permissions", 400) + if "permissions" not in payload and TAKEOVER_DELAY_KEY not in payload: + return fail("缺少授权设置", 400) try: - permissions = _normalize_avatar_permissions(payload["permissions"]) + permissions = ( + _normalize_avatar_permissions(payload["permissions"]) + if "permissions" in payload + else _stored_avatar_permissions(avatar) + ) + takeover_delay = ( + _validate_takeover_delay(payload[TAKEOVER_DELAY_KEY]) + if TAKEOVER_DELAY_KEY in payload + else _stored_takeover_delay(avatar) + ) except ValueError as exc: return fail(str(exc), 400) @@ -155,7 +221,9 @@ def update_permission_settings( avatar.config = { **(avatar.config or {}), AVATAR_PERMISSION_KEY: permissions, + TAKEOVER_DELAY_KEY: takeover_delay, } + disabled_avatar_ids = _disable_other_takeovers(db, avatar) if "takeover" in permissions else [] cursor = db.query(TakeoverCursor).filter(TakeoverCursor.avatar_id == avatar.id).first() if cursor and "takeover" in permissions and "takeover" not in previous_permissions: cursor.initialized = False @@ -179,7 +247,9 @@ def update_permission_settings( task.locked_at = None db.commit() db.refresh(avatar) - return ok(_permission_settings_payload(avatar), "授权设置已保存") + response = _permission_settings_payload(avatar) + response["disabledAvatarIds"] = disabled_avatar_ids + return ok(response, "授权设置已保存") @router.get("/avatar/{avatar_id}/authorizations") @@ -240,7 +310,7 @@ def create_auth( status="active", takeover_enabled=False, takeover_mode="immediate", - takeover_delay_seconds=30, + takeover_delay_seconds=DEFAULT_TAKEOVER_DELAY_SECONDS, ) db.add(item) db.commit() diff --git a/digital-avatar-app/backend/routers/avatars.py b/digital-avatar-app/backend/routers/avatars.py index 57f4e12..ee20bce 100644 --- a/digital-avatar-app/backend/routers/avatars.py +++ b/digital-avatar-app/backend/routers/avatars.py @@ -6,7 +6,17 @@ from sqlalchemy.orm import Session from database import get_db from routers.knowledge import UPLOAD_DIR -from models import Avatar, KnowledgeDoc, KnowledgeChunk, QAPair, Authorization, User +from models import ( + Authorization, + Avatar, + KnowledgeChunk, + KnowledgeDoc, + QAPair, + TakeoverCursor, + TakeoverMessage, + TakeoverReplyTask, + User, +) from responses import ok, fail router = APIRouter(tags=["分身"]) @@ -74,18 +84,21 @@ def list_avatars(page: int = 1, limit: int = 20, authorization: str = Header(Non @router.get("/avatar/{avatar_id}") -def get_avatar(avatar_id: str, db: Session = Depends(get_db)): - a = db.query(Avatar).filter(Avatar.id == avatar_id).first() - if not a: - return fail("分身不存在", 404) - return ok(a.to_dict()) +def get_avatar( + avatar_id: str, + authorization: str = Header(None), + db: Session = Depends(get_db), +): + return ok(_require_owned_avatar(db, avatar_id, authorization).to_dict()) @router.post("/avatar") def create_avatar(payload: dict = Body(...), authorization: str = Header(None), db: Session = Depends(get_db)): user = _resolve_user(authorization, db) + if not user: + raise HTTPException(status_code=401, detail="未登录") a = Avatar( - owner_id=user.huihui_user_id if user else "", + owner_id=user.huihui_user_id, name=payload.get("name", "未命名分身"), display_name=payload.get("displayName", "") or payload.get("display_name", ""), description=payload.get("description", ""), @@ -102,10 +115,13 @@ def create_avatar(payload: dict = Body(...), authorization: str = Header(None), @router.put("/avatar/{avatar_id}") -def update_avatar(avatar_id: str, payload: dict = Body(...), db: Session = Depends(get_db)): - a = db.query(Avatar).filter(Avatar.id == avatar_id).first() - if not a: - return fail("分身不存在", 404) +def update_avatar( + avatar_id: str, + payload: dict = Body(...), + authorization: str = Header(None), + db: Session = Depends(get_db), +): + a = _require_owned_avatar(db, avatar_id, authorization) mapping = { "displayName": "display_name", "photoUrl": "photo_url", @@ -114,22 +130,32 @@ def update_avatar(avatar_id: str, payload: dict = Body(...), db: Session = Depen for key in ("name", "displayName", "description", "photoUrl", "emoji", "status", "tokenBalance", "config"): if key in payload: col = mapping.get(key, key) - setattr(a, col, payload[key]) + value = payload[key] + if key == "config": + if not isinstance(value, dict): + return fail("分身配置格式不正确", 400) + value = {**(a.config or {}), **value} + setattr(a, col, value) db.commit() db.refresh(a) return ok(a.to_dict()) @router.delete("/avatar/{avatar_id}") -def delete_avatar(avatar_id: str, db: Session = Depends(get_db)): - a = db.query(Avatar).filter(Avatar.id == avatar_id).first() - if not a: - return fail("分身不存在", 404) +def delete_avatar( + avatar_id: str, + authorization: str = Header(None), + db: Session = Depends(get_db), +): + a = _require_owned_avatar(db, avatar_id, authorization) # 级联清理关联数据,避免孤儿记录 db.query(KnowledgeDoc).filter(KnowledgeDoc.avatar_id == avatar_id).delete() db.query(KnowledgeChunk).filter(KnowledgeChunk.avatar_id == avatar_id).delete() db.query(QAPair).filter(QAPair.avatar_id == avatar_id).delete() db.query(Authorization).filter(Authorization.avatar_id == avatar_id).delete() + db.query(TakeoverReplyTask).filter(TakeoverReplyTask.avatar_id == avatar_id).delete() + db.query(TakeoverMessage).filter(TakeoverMessage.avatar_id == avatar_id).delete() + db.query(TakeoverCursor).filter(TakeoverCursor.avatar_id == avatar_id).delete() db.delete(a) db.commit() return ok({"success": True}) diff --git a/digital-avatar-app/backend/routers/takeover.py b/digital-avatar-app/backend/routers/takeover.py index d8f0b1d..51283f7 100644 --- a/digital-avatar-app/backend/routers/takeover.py +++ b/digital-avatar-app/backend/routers/takeover.py @@ -8,13 +8,25 @@ from sqlalchemy.orm import Session from database import get_db from models import TakeoverCursor, TakeoverReplyTask, User from responses import fail, ok -from routers.authorizations import _require_authorization +from routers.authorizations import ( + DEFAULT_TAKEOVER_DELAY_SECONDS, + MAX_TAKEOVER_DELAY_SECONDS, + MIN_TAKEOVER_DELAY_SECONDS, + _require_authorization, + _stored_takeover_delay, +) from routers.avatars import _require_owned_avatar router = APIRouter(tags=["分身接管"]) BOXIM_STATUS_FRESH_SECONDS = 60 +def _delay_label(seconds: int) -> str: + if seconds % 60 == 0: + return f"{seconds // 60} 分钟" + return f"{seconds} 秒" + + @router.get("/avatar/{avatar_id}/takeover/status") def get_takeover_status( avatar_id: str, @@ -24,6 +36,7 @@ def get_takeover_status( avatar = _require_owned_avatar(db, avatar_id, authorization) permissions = (avatar.config or {}).get("authorizationPermissions", []) enabled = isinstance(permissions, list) and "takeover" in permissions + reply_delay_seconds = _stored_takeover_delay(avatar) user = db.query(User).filter(User.huihui_user_id == avatar.owner_id).first() cursor = db.query(TakeoverCursor).filter(TakeoverCursor.avatar_id == avatar.id).first() pending_count = ( @@ -49,7 +62,10 @@ def get_takeover_status( and cursor.last_polled_at >= datetime.utcnow() - timedelta(seconds=BOXIM_STATUS_FRESH_SECONDS) ): - status, message = "ready", "BOXIM 已连接,收到私聊消息 3 秒后自动回复" + status, message = ( + "ready", + f"BOXIM 已连接,收到私聊消息 {_delay_label(reply_delay_seconds)}后自动回复", + ) else: status, message = "connecting", "正在连接 BOXIM" @@ -59,6 +75,7 @@ def get_takeover_status( "status": status, "message": message, "pendingCount": pending_count, + "takeoverReplyDelaySeconds": reply_delay_seconds, "lastPolledAt": cursor.last_polled_at.isoformat() if cursor and cursor.last_polled_at else None, } ) @@ -91,7 +108,7 @@ def update_takeover_config( auth = _require_authorization(db, avatar_id, str(auth_id)) enabled = bool(auth.takeover_enabled) mode = auth.takeover_mode or "immediate" - delay = auth.takeover_delay_seconds or 30 + delay = auth.takeover_delay_seconds or DEFAULT_TAKEOVER_DELAY_SECONDS if _has(payload, "takeoverEnabled", "takeover_enabled"): raw_enabled = _read(payload, "takeoverEnabled", "takeover_enabled") @@ -106,8 +123,12 @@ def update_takeover_config( if _has(payload, "takeoverDelaySeconds", "takeover_delay_seconds"): delay = _read(payload, "takeoverDelaySeconds", "takeover_delay_seconds") - if isinstance(delay, bool) or not isinstance(delay, int) or not 5 <= delay <= 3600: - return fail("延迟时间需在 5 到 3600 秒之间", 400) + if ( + isinstance(delay, bool) + or not isinstance(delay, int) + or not MIN_TAKEOVER_DELAY_SECONDS <= delay <= MAX_TAKEOVER_DELAY_SECONDS + ): + return fail("延迟时间需在 3 秒到 24 小时之间", 400) if enabled and auth.target_type != "user": return fail("本期仅支持对会会用户开启单聊接管", 400) diff --git a/digital-avatar-app/backend/services/takeover_service.py b/digital-avatar-app/backend/services/takeover_service.py index d3cbd76..5d37c7d 100644 --- a/digital-avatar-app/backend/services/takeover_service.py +++ b/digital-avatar-app/backend/services/takeover_service.py @@ -28,6 +28,14 @@ MAX_PROMPT_LENGTH = 4000 MAX_STALE_SECONDS = 120 STUCK_LOCK_SECONDS = 90 TAKEOVER_PERMISSION = "takeover" +TAKEOVER_DELAY_KEY = "takeoverReplyDelaySeconds" +DEFAULT_REPLY_DELAY_SECONDS = 180 +MIN_REPLY_DELAY_SECONDS = 3 +MAX_REPLY_DELAY_SECONDS = 86_400 +HUMAN_PAUSE_SECONDS = 600 +RATE_LIMIT_WINDOW_SECONDS = 300 +RATE_LIMIT_MAX_REPLIES = 5 +AVATAR_LOCAL_ID_PREFIX = "880" def _utcnow() -> datetime: @@ -70,15 +78,43 @@ def _plain_text_reply(value: str) -> str: return "\n".join(line for line in lines if line).strip() +def _avatar_local_id(owner_id: str, trigger_message_id: str) -> str: + """Build a deterministic BOXIM idempotency key that also marks avatar traffic.""" + digest = hashlib.sha256(f"{owner_id}:{trigger_message_id}".encode("utf-8")).digest() + suffix = int.from_bytes(digest[:8], "big") % (10**15) + return f"{AVATAR_LOCAL_ID_PREFIX}{suffix:015d}" + + +def _is_avatar_local_id(value: str | None) -> bool: + local_id = str(value or "").strip() + return len(local_id) == 18 and local_id.isdigit() and local_id.startswith(AVATAR_LOCAL_ID_PREFIX) + + +def _configured_reply_delay(avatar: Avatar, fallback: int | None = None) -> int: + raw = (avatar.config or {}).get( + TAKEOVER_DELAY_KEY, + fallback if fallback is not None else DEFAULT_REPLY_DELAY_SECONDS, + ) + if isinstance(raw, bool): + return DEFAULT_REPLY_DELAY_SECONDS + try: + delay = int(raw) + except (TypeError, ValueError): + return DEFAULT_REPLY_DELAY_SECONDS + if not MIN_REPLY_DELAY_SECONDS <= delay <= MAX_REPLY_DELAY_SECONDS: + return DEFAULT_REPLY_DELAY_SECONDS + return delay + + class TakeoverService: - """Poll BOXIM, prepare replies during the grace period, then send at +3s.""" + """Poll BOXIM, honor the owner grace period, then generate and send one reply.""" def __init__( self, session_factory: Callable[[], Session], boxim_client: BoxIMClient, *, - reply_delay_seconds: int = 3, + reply_delay_seconds: int | None = None, now: Callable[[], datetime] = _utcnow, ): self.session_factory = session_factory @@ -119,11 +155,17 @@ class TakeoverService: def _enabled_avatar_ids(self) -> list[str]: db = self.session_factory() try: - return [ - avatar.id - for avatar in db.query(Avatar).filter(Avatar.status == "active").all() - if _takeover_enabled(avatar) - ] + avatars = ( + db.query(Avatar) + .filter(Avatar.status == "active") + .order_by(Avatar.updated_at.desc(), Avatar.created_at.desc()) + .all() + ) + selected = {} + for avatar in avatars: + if _takeover_enabled(avatar) and avatar.owner_id not in selected: + selected[avatar.owner_id] = avatar.id + return list(selected.values()) finally: db.close() @@ -369,13 +411,21 @@ class TakeoverService: now = self.now() send_time = _boxim_time(message.get("sendTime"), now) - is_avatar = False - if direction == "outgoing" and local_id: + is_avatar = _is_avatar_local_id(local_id) + if not is_avatar and local_id: is_avatar = bool( db.query(TakeoverReplyTask) .filter( - TakeoverReplyTask.owner_id == avatar.owner_id, TakeoverReplyTask.boxim_local_id == local_id, + TakeoverReplyTask.status.in_(("ready", "sending", "sent")), + ) + .first() + ) + if not is_avatar: + is_avatar = bool( + db.query(TakeoverReplyTask) + .filter( + TakeoverReplyTask.boxim_sent_message_id == message_id, TakeoverReplyTask.status == "sent", ) .first() @@ -404,8 +454,52 @@ class TakeoverService: return if (now - send_time).total_seconds() > MAX_STALE_SECONDS: return + if is_avatar: + self._cancel_conversation(db, avatar.owner_id, peer_id, "peer_avatar_message") + return + if self._human_pause_active(db, avatar.owner_id, peer_id, now): + self._cancel_conversation(db, avatar.owner_id, peer_id, "owner_active") + return + if self._conversation_rate_limited(db, avatar.owner_id, peer_id, now): + self._cancel_conversation(db, avatar.owner_id, peer_id, "rate_limited") + return self._schedule_reply(db, avatar, event) + @staticmethod + def _human_pause_active(db: Session, owner_id: str, peer_id: str, now: datetime) -> bool: + threshold = now - timedelta(seconds=HUMAN_PAUSE_SECONDS) + return bool( + db.query(TakeoverMessage.id) + .filter( + TakeoverMessage.owner_id == owner_id, + TakeoverMessage.peer_id == peer_id, + TakeoverMessage.direction == "outgoing", + TakeoverMessage.is_avatar.is_(False), + TakeoverMessage.send_time >= threshold, + ) + .first() + ) + + @staticmethod + def _conversation_rate_limited( + db: Session, + owner_id: str, + peer_id: str, + now: datetime, + ) -> bool: + threshold = now - timedelta(seconds=RATE_LIMIT_WINDOW_SECONDS) + return ( + db.query(TakeoverReplyTask.id) + .filter( + TakeoverReplyTask.owner_id == owner_id, + TakeoverReplyTask.peer_id == peer_id, + TakeoverReplyTask.status == "sent", + TakeoverReplyTask.sent_at >= threshold, + ) + .count() + >= RATE_LIMIT_MAX_REPLIES + ) + @staticmethod def _cancel_conversation(db: Session, owner_id: str, peer_id: str, reason: str): tasks = ( @@ -446,9 +540,11 @@ class TakeoverService: prompt_parts.append(event.content.strip()) source_ids.append(event.boxim_message_id) prompt = "\n".join(part for part in prompt_parts if part).strip()[-MAX_PROMPT_LENGTH:] - due_at = event.send_time + timedelta(seconds=self.reply_delay_seconds) + due_at = event.send_time + timedelta( + seconds=_configured_reply_delay(avatar, self.reply_delay_seconds) + ) task_id = secrets.token_hex(16) - local_id = int(time.time() * 1000) * 1000 + secrets.randbelow(1000) + local_id = _avatar_local_id(avatar.owner_id, event.boxim_message_id) db.add( TakeoverReplyTask( id=task_id, @@ -474,6 +570,7 @@ class TakeoverService: .filter( TakeoverReplyTask.status.in_(GENERATABLE_TASK_STATUSES), TakeoverReplyTask.response_text == "", + TakeoverReplyTask.scheduled_at <= self.now(), ) .order_by(TakeoverReplyTask.created_at.asc()) .limit(10) @@ -520,6 +617,7 @@ class TakeoverService: .filter( TakeoverMessage.owner_id == task.owner_id, TakeoverMessage.peer_id == task.peer_id, + TakeoverMessage.avatar_id == task.avatar_id, ) .order_by(TakeoverMessage.send_time.desc()) .limit(30) @@ -529,6 +627,8 @@ class TakeoverService: for event in reversed(events): if event.boxim_message_id in excluded_ids or not event.content.strip(): continue + if event.direction == "incoming" and event.is_avatar: + continue history.append( { "role": "user" if event.direction == "incoming" else "assistant", @@ -606,6 +706,15 @@ class TakeoverService: task.cancel_reason = "stale_reply" db.commit() return False + cursor = ( + db.query(TakeoverCursor) + .filter(TakeoverCursor.avatar_id == task.avatar_id) + .first() + ) + if not cursor or not cursor.last_polled_at or cursor.last_polled_at < task.scheduled_at: + # Do not race the owner's final seconds of the grace period. A + # completed poll at/after the due time must confirm no human reply. + return False user = db.query(User).filter(User.huihui_user_id == task.owner_id).first() if not user or not user.huihui_token: raise BoxIMError("缺少会会登录凭证", auth_error=True) diff --git a/digital-avatar-app/backend/tests/test_authorizations_api.py b/digital-avatar-app/backend/tests/test_authorizations_api.py index 2da8e49..687118d 100644 --- a/digital-avatar-app/backend/tests/test_authorizations_api.py +++ b/digital-avatar-app/backend/tests/test_authorizations_api.py @@ -103,6 +103,7 @@ def test_avatar_permission_settings_default_and_persist(authorization_context): assert initial["data"] == { "avatarId": context["avatar"].id, "permissions": ["friend", "chat"], + "takeoverReplyDelaySeconds": 180, } updated = client.put( @@ -115,6 +116,7 @@ def test_avatar_permission_settings_default_and_persist(authorization_context): reloaded = client.get(endpoint, headers=context["owner_headers"]).json() assert reloaded["data"]["permissions"] == ["friend", "publish", "interact", "takeover"] + assert reloaded["data"]["takeoverReplyDelaySeconds"] == 180 def test_avatar_permission_settings_allow_all_disabled(authorization_context): @@ -156,3 +158,57 @@ def test_avatar_permission_settings_validate_owner_and_permissions(authorization unauthenticated = client.get(endpoint) assert unauthenticated.status_code == 401 + + +def test_takeover_delay_minimum_and_single_active_avatar_per_owner(authorization_context): + from database import SessionLocal + from models import Avatar + + context = authorization_context + endpoint = f"/api/avatar/{context['avatar'].id}/permission-settings" + invalid = client.put( + endpoint, + headers=context["owner_headers"], + json={"permissions": ["chat"], "takeoverReplyDelaySeconds": 2}, + ).json() + assert invalid["code"] == 400 + + second_avatar_id = f"second-{context['suffix']}" + db = SessionLocal() + try: + db.add( + Avatar( + id=second_avatar_id, + owner_id=context["owner"].huihui_user_id, + name="第二个分身", + status="active", + config={"authorizationPermissions": ["chat", "takeover"]}, + ) + ) + db.commit() + finally: + db.close() + + try: + updated = client.put( + endpoint, + headers=context["owner_headers"], + json={"permissions": ["chat", "takeover"], "takeoverReplyDelaySeconds": 3}, + ).json() + assert updated["code"] == 200 + assert updated["data"]["takeoverReplyDelaySeconds"] == 3 + assert updated["data"]["disabledAvatarIds"] == [second_avatar_id] + + db = SessionLocal() + try: + second = db.query(Avatar).filter(Avatar.id == second_avatar_id).one() + assert "takeover" not in second.config["authorizationPermissions"] + finally: + db.close() + finally: + db = SessionLocal() + try: + db.query(Avatar).filter(Avatar.id == second_avatar_id).delete() + db.commit() + finally: + db.close() diff --git a/digital-avatar-app/backend/tests/test_avatar_api.py b/digital-avatar-app/backend/tests/test_avatar_api.py new file mode 100644 index 0000000..e06b3d4 --- /dev/null +++ b/digital-avatar-app/backend/tests/test_avatar_api.py @@ -0,0 +1,93 @@ +"""Ownership and configuration-isolation tests for digital avatars.""" + +from fastapi.testclient import TestClient + +from database import SessionLocal +from main import app +from models import Avatar + + +client = TestClient(app) + + +def test_avatar_detail_and_update_require_the_owner(authorization_context): + context = authorization_context + avatar_id = context["avatar"].id + + assert client.get(f"/api/avatar/{avatar_id}").status_code == 401 + assert client.get( + f"/api/avatar/{avatar_id}", headers=context["other_headers"] + ).status_code == 403 + + updated = client.put( + f"/api/avatar/{avatar_id}", + headers=context["owner_headers"], + json={ + "description": "独立描述", + "config": {"replyStyle": "concise"}, + }, + ) + assert updated.status_code == 200 + assert updated.json()["data"]["description"] == "独立描述" + + forbidden = client.put( + f"/api/avatar/{avatar_id}", + headers=context["other_headers"], + json={"description": "越权修改"}, + ) + assert forbidden.status_code == 403 + + +def test_avatar_config_updates_do_not_erase_takeover_or_knowledge_scope(authorization_context): + context = authorization_context + avatar_id = context["avatar"].id + db = SessionLocal() + try: + avatar = db.query(Avatar).filter(Avatar.id == avatar_id).one() + avatar.config = { + "authorizationPermissions": ["chat", "takeover"], + "takeoverReplyDelaySeconds": 180, + } + db.commit() + finally: + db.close() + + response = client.put( + f"/api/avatar/{avatar_id}", + headers=context["owner_headers"], + json={"config": {"replyStyle": "warm", "creativity": 25}}, + ).json() + config = response["data"]["config"] + assert config["replyStyle"] == "warm" + assert config["creativity"] == 25 + assert config["authorizationPermissions"] == ["chat", "takeover"] + assert config["takeoverReplyDelaySeconds"] == 180 + + +def test_avatar_create_and_delete_require_login_and_ownership(authorization_context): + context = authorization_context + assert client.post("/api/avatar", json={"name": "匿名分身"}).status_code == 401 + + created = client.post( + "/api/avatar", + headers=context["owner_headers"], + json={"name": "待删除分身"}, + ) + assert created.status_code == 200 + avatar_id = created.json()["data"]["id"] + + try: + assert client.delete( + f"/api/avatar/{avatar_id}", headers=context["other_headers"] + ).status_code == 403 + deleted = client.delete( + f"/api/avatar/{avatar_id}", headers=context["owner_headers"] + ).json() + assert deleted["code"] == 200 + finally: + db = SessionLocal() + try: + db.query(Avatar).filter(Avatar.id == avatar_id).delete() + db.commit() + finally: + db.close() diff --git a/digital-avatar-app/backend/tests/test_knowledge_storage.py b/digital-avatar-app/backend/tests/test_knowledge_storage.py index 8e89fde..eede9e3 100644 --- a/digital-avatar-app/backend/tests/test_knowledge_storage.py +++ b/digital-avatar-app/backend/tests/test_knowledge_storage.py @@ -6,7 +6,7 @@ from fastapi.testclient import TestClient from database import SessionLocal from main import app -from models import KnowledgeChunk, KnowledgeDoc +from models import Avatar, KnowledgeChunk, KnowledgeDoc, QAPair from routers.knowledge import _doc_payload @@ -92,3 +92,94 @@ def test_markdown_upload_commits_ready_document_and_chunks_together( db.commit() finally: db.close() + + +def test_each_avatar_has_an_independent_document_and_qa_scope(authorization_context): + context = authorization_context + first_avatar_id = context["avatar"].id + second_avatar_id = f"knowledge-second-{context['suffix']}" + first_doc_id = f"knowledge-first-doc-{context['suffix']}" + second_doc_id = f"knowledge-second-doc-{context['suffix']}" + first_qa_id = f"knowledge-first-qa-{context['suffix']}" + second_qa_id = f"knowledge-second-qa-{context['suffix']}" + + db = SessionLocal() + try: + db.add_all( + [ + Avatar( + id=second_avatar_id, + owner_id=context["owner"].huihui_user_id, + name="独立知识库分身", + status="active", + config={}, + ), + KnowledgeDoc( + id=first_doc_id, + avatar_id=first_avatar_id, + filename="first.md", + status="ready", + vectorized=True, + ), + KnowledgeDoc( + id=second_doc_id, + avatar_id=second_avatar_id, + filename="second.md", + status="ready", + vectorized=True, + ), + QAPair( + id=first_qa_id, + avatar_id=first_avatar_id, + question="第一个分身问题", + answer="第一个分身答案", + ), + QAPair( + id=second_qa_id, + avatar_id=second_avatar_id, + question="第二个分身问题", + answer="第二个分身答案", + ), + ] + ) + db.commit() + finally: + db.close() + + try: + first_docs = client.get( + f"/api/avatar/{first_avatar_id}/knowledge/docs", + headers=context["owner_headers"], + ).json()["data"] + second_docs = client.get( + f"/api/avatar/{second_avatar_id}/knowledge/docs", + headers=context["owner_headers"], + ).json()["data"] + first_qa = client.get( + f"/api/avatar/{first_avatar_id}/knowledge/qa", + headers=context["owner_headers"], + ).json()["data"] + second_qa = client.get( + f"/api/avatar/{second_avatar_id}/knowledge/qa", + headers=context["owner_headers"], + ).json()["data"] + + assert [item["id"] for item in first_docs if item["id"] == first_doc_id] == [first_doc_id] + assert second_doc_id not in {item["id"] for item in first_docs} + assert [item["id"] for item in second_docs] == [second_doc_id] + assert first_qa_id in {item["id"] for item in first_qa} + assert second_qa_id not in {item["id"] for item in first_qa} + assert [item["id"] for item in second_qa] == [second_qa_id] + finally: + db = SessionLocal() + try: + db.query(QAPair).filter(QAPair.id.in_([first_qa_id, second_qa_id])).delete( + synchronize_session=False + ) + db.query(KnowledgeDoc).filter( + KnowledgeDoc.id.in_([first_doc_id, second_doc_id]) + ).delete(synchronize_session=False) + db.query(Avatar).filter(Avatar.id == second_avatar_id).delete() + db.commit() + finally: + db.close() diff --git a/digital-avatar-app/backend/tests/test_takeover_model.py b/digital-avatar-app/backend/tests/test_takeover_model.py index 35b3fb0..c62346d 100644 --- a/digital-avatar-app/backend/tests/test_takeover_model.py +++ b/digital-avatar-app/backend/tests/test_takeover_model.py @@ -13,7 +13,7 @@ def test_authorization_takeover_fields(): assert hasattr(auth, 'takeover_delay_seconds') assert auth.takeover_enabled == False assert auth.takeover_mode == 'immediate' - assert auth.takeover_delay_seconds == 30 + assert auth.takeover_delay_seconds == 180 finally: db.close() diff --git a/digital-avatar-app/backend/tests/test_takeover_service.py b/digital-avatar-app/backend/tests/test_takeover_service.py index 9c80596..bf221de 100644 --- a/digital-avatar-app/backend/tests/test_takeover_service.py +++ b/digital-avatar-app/backend/tests/test_takeover_service.py @@ -11,7 +11,12 @@ from sqlalchemy.orm import sessionmaker from database import Base from models import Avatar, TakeoverCursor, TakeoverMessage, TakeoverReplyTask, User from services.boxim_client import BoxIMError -from services.takeover_service import TakeoverService, _plain_text_reply +from services.takeover_service import ( + AVATAR_LOCAL_ID_PREFIX, + TakeoverService, + _avatar_local_id, + _plain_text_reply, +) class Clock: @@ -77,7 +82,10 @@ def service_context(tmp_path): owner_id=user.huihui_user_id, name="分身", status="active", - config={"authorizationPermissions": ["chat", "takeover"]}, + config={ + "authorizationPermissions": ["chat", "takeover"], + "takeoverReplyDelaySeconds": 3, + }, ) db.add_all([user, avatar]) db.commit() @@ -120,8 +128,7 @@ async def test_incoming_message_is_prepared_then_sent_at_three_seconds(service_c {"id": 11, "localId": 2, "sendId": 200, "recvId": 100, "sendTime": clock.millis(), "type": 0, "content": "你好"} ) - with patch("routers.chat._resolve_reply", return_value={"answer": "**你好**\n\n很高兴见到你"}): - await service.poll_and_process_messages() + await service.poll_and_process_messages() assert boxim.sent == [] assert boxim.read_receipts == [{"friendId": "200", "messageId": "11"}] @@ -130,7 +137,8 @@ async def test_incoming_message_is_prepared_then_sent_at_three_seconds(service_c assert boxim.sent == [] clock.advance(1) - await service.poll_and_process_messages() + with patch("routers.chat._resolve_reply", return_value={"answer": "**你好**\n\n很高兴见到你"}): + await service.poll_and_process_messages() assert boxim.sent == [{"peerId": "200", "content": "你好\n很高兴见到你", "localId": boxim.sent[0]["localId"]}] db = session_factory() @@ -142,6 +150,131 @@ async def test_incoming_message_is_prepared_then_sent_at_three_seconds(service_c db.close() +@pytest.mark.asyncio +async def test_default_reply_delay_is_three_minutes(service_context): + session_factory, service, boxim, clock = service_context + db = session_factory() + try: + avatar = db.query(Avatar).one() + avatar.config = {"authorizationPermissions": ["chat", "takeover"]} + db.commit() + finally: + db.close() + + await service.poll_and_process_messages() + boxim.messages.append( + {"id": 12, "localId": 12, "sendId": 200, "recvId": 100, "sendTime": clock.millis(), "type": 0, "content": "三分钟后回复"} + ) + await service.poll_and_process_messages() + + db = session_factory() + try: + task = db.query(TakeoverReplyTask).one() + assert task.scheduled_at == clock.now() + timedelta(seconds=180) + finally: + db.close() + + clock.advance(179) + await service.process_reply_tasks() + assert boxim.sent == [] + clock.advance(1) + with patch("routers.chat._resolve_reply", return_value={"answer": "好的"}): + await service.poll_and_process_messages() + assert [item["content"] for item in boxim.sent] == ["好的"] + + +@pytest.mark.asyncio +async def test_avatar_origin_message_never_schedules_a_reply(service_context): + session_factory, service, boxim, clock = service_context + await service.poll_and_process_messages() + local_id = _avatar_local_id("peer-owner", "peer-trigger") + boxim.messages.append( + {"id": 15, "localId": local_id, "sendId": 200, "recvId": 100, "sendTime": clock.millis(), "type": 0, "content": "另一端分身回复"} + ) + + with patch("routers.chat._resolve_reply") as resolver: + await service.poll_and_process_messages() + + resolver.assert_not_called() + db = session_factory() + try: + event = db.query(TakeoverMessage).filter(TakeoverMessage.boxim_message_id == "15").one() + assert event.is_avatar is True + assert db.query(TakeoverReplyTask).count() == 0 + finally: + db.close() + + +@pytest.mark.asyncio +async def test_peer_avatar_messages_are_excluded_from_later_human_context(service_context): + _session_factory, service, boxim, clock = service_context + await service.poll_and_process_messages() + boxim.messages.append( + { + "id": 16, + "localId": _avatar_local_id("peer-owner", "peer-trigger"), + "sendId": 200, + "recvId": 100, + "sendTime": clock.millis(), + "type": 0, + "content": "分身生成的夸张长文", + } + ) + await service.poll_and_process_messages() + + clock.advance(1) + boxim.messages.append( + { + "id": 17, + "localId": 17, + "sendId": 200, + "recvId": 100, + "sendTime": clock.millis(), + "type": 0, + "content": "真人的新问题", + } + ) + await service.poll_and_process_messages() + clock.advance(3) + with patch("routers.chat._resolve_reply", return_value={"answer": "正常回复"}) as resolver: + await service.poll_and_process_messages() + + assert resolver.call_args.args[3] == [] + + +@pytest.mark.asyncio +async def test_owner_message_pauses_future_takeover_for_ten_minutes(service_context): + session_factory, service, boxim, clock = service_context + await service.poll_and_process_messages() + boxim.messages.append( + {"id": 18, "localId": 18, "sendId": 100, "recvId": 200, "sendTime": clock.millis(), "type": 0, "content": "我先来回复"} + ) + await service.poll_and_process_messages() + + clock.advance(30) + boxim.messages.append( + {"id": 19, "localId": 19, "sendId": 200, "recvId": 100, "sendTime": clock.millis(), "type": 0, "content": "收到"} + ) + with patch("routers.chat._resolve_reply") as resolver: + await service.poll_and_process_messages() + + resolver.assert_not_called() + db = session_factory() + try: + assert db.query(TakeoverReplyTask).count() == 0 + finally: + db.close() + + +def test_avatar_local_id_is_deterministic_and_self_describing(): + first = _avatar_local_id("owner", "message-1") + assert first == _avatar_local_id("owner", "message-1") + assert first != _avatar_local_id("owner", "message-2") + assert first.startswith(AVATAR_LOCAL_ID_PREFIX) + assert len(first) == 18 + assert first.isdigit() + + @pytest.mark.asyncio async def test_different_contacts_generate_without_blocking_each_other(service_context): session_factory, service, boxim, clock = service_context @@ -158,11 +291,11 @@ async def test_different_contacts_generate_without_blocking_each_other(service_c both_generating.wait() return {"answer": f"回复{prompt[-1]}"} - with patch("routers.chat._resolve_reply", side_effect=resolve): - await service.poll_and_process_messages() + await service.poll_and_process_messages() clock.advance(3) - await service.process_reply_tasks() + with patch("routers.chat._resolve_reply", side_effect=resolve): + await service.poll_and_process_messages() assert {(item["peerId"], item["content"]) for item in boxim.sent} == { ("200", "回复甲"), ("300", "回复乙"), @@ -237,6 +370,34 @@ async def test_owner_message_cancels_pending_reply(service_context): db.close() +@pytest.mark.asyncio +async def test_owner_message_in_final_second_wins_before_generation(service_context): + session_factory, service, boxim, clock = service_context + await service.poll_and_process_messages() + boxim.messages.append( + {"id": 23, "localId": 23, "sendId": 200, "recvId": 100, "sendTime": clock.millis(), "type": 0, "content": "还在吗"} + ) + await service.poll_and_process_messages() + + clock.advance(2) + boxim.messages.append( + {"id": 24, "localId": 24, "sendId": 100, "recvId": 200, "sendTime": clock.millis(), "type": 0, "content": "我来处理"} + ) + clock.advance(1) + with patch("routers.chat._resolve_reply") as resolver: + await service.poll_and_process_messages() + + resolver.assert_not_called() + assert boxim.sent == [] + db = session_factory() + try: + task = db.query(TakeoverReplyTask).filter(TakeoverReplyTask.trigger_message_id == "23").one() + assert task.status == "cancelled" + assert task.cancel_reason == "owner_replied" + finally: + db.close() + + @pytest.mark.asyncio async def test_quick_successive_messages_are_coalesced_into_one_reply(service_context): session_factory, service, boxim, clock = service_context @@ -244,19 +405,18 @@ async def test_quick_successive_messages_are_coalesced_into_one_reply(service_co boxim.messages.append( {"id": 31, "localId": 5, "sendId": 200, "recvId": 100, "sendTime": clock.millis(), "type": 0, "content": "第一句"} ) - with patch("routers.chat._resolve_reply", return_value={"answer": "第一版"}): - await service.poll_and_process_messages() + await service.poll_and_process_messages() clock.advance(1) boxim.messages.append( {"id": 32, "localId": 6, "sendId": 200, "recvId": 100, "sendTime": clock.millis(), "type": 0, "content": "第二句"} ) + await service.poll_and_process_messages() + + clock.advance(3) with patch("routers.chat._resolve_reply", return_value={"answer": "合并回复"}) as resolver: await service.poll_and_process_messages() assert resolver.call_args.args[2] == "第一句\n第二句" - - clock.advance(3) - await service.poll_and_process_messages() assert [item["content"] for item in boxim.sent] == ["合并回复"] db = session_factory() diff --git a/digital-avatar-app/docs/H5_PRODUCTION_DEPLOYMENT.md b/digital-avatar-app/docs/H5_PRODUCTION_DEPLOYMENT.md index f694b22..3139594 100644 --- a/digital-avatar-app/docs/H5_PRODUCTION_DEPLOYMENT.md +++ b/digital-avatar-app/docs/H5_PRODUCTION_DEPLOYMENT.md @@ -112,7 +112,7 @@ location /api/ { 4. A、B 两个会会用户分别进入时只能看到各自的数字分身与知识库,不会继承上一用户缓存。 5. 使用过期或伪造 token 时进入登录页并显示凭证失效,不得继续访问旧用户数据。 6. 分身聊天 SSE 逐段输出正常,Markdown 正常渲染,知识库优先级和积分扣费正常。 -7. 开启 BOXIM 主动接管后保持在线,收到消息、三秒回复、已读回执和主人发言暂停均正常。 +7. 开启 BOXIM 主动接管后保持在线,默认三分钟回复、自定义等待时间、已读回执、分身防回环和主人发言暂停均正常。 8. 重建容器后数据库、头像、知识库文档仍存在,`/api/health` 返回成功。 9. `https://digital.99hui.com/api/health` 可访问,证书域名和有效期正确,HTTP 自动跳转 HTTPS。 10. 微信和支付宝各创建一笔最小套餐订单,未付款时积分不变;支付成功后回调到账一次,重复回调积分不重复增加。 diff --git a/digital-avatar-app/src/api/index.ts b/digital-avatar-app/src/api/index.ts index ebcbe83..8e1b325 100644 --- a/digital-avatar-app/src/api/index.ts +++ b/digital-avatar-app/src/api/index.ts @@ -191,19 +191,28 @@ export type AvatarPermission = 'friend' | 'chat' | 'publish' | 'browse' | 'inter export interface AvatarPermissionSettings { avatarId: string permissions: AvatarPermission[] + takeoverReplyDelaySeconds: number + disabledAvatarIds?: string[] } export const getAvatarPermissionSettings = (avatarId: string) => request.get(`/avatar/${avatarId}/permission-settings`) -export const updateAvatarPermissionSettings = (avatarId: string, permissions: AvatarPermission[]) => - request.put(`/avatar/${avatarId}/permission-settings`, { permissions }) +export const updateAvatarPermissionSettings = ( + avatarId: string, + permissions: AvatarPermission[], + takeoverReplyDelaySeconds: number +) => request.put(`/avatar/${avatarId}/permission-settings`, { + permissions, + takeoverReplyDelaySeconds, +}) export interface TakeoverStatus { enabled: boolean status: 'disabled' | 'connecting' | 'ready' | 'needs_login' | 'error' message: string pendingCount: number + takeoverReplyDelaySeconds: number lastPolledAt: string | null } diff --git a/digital-avatar-app/src/views/AuthorizationManage.vue b/digital-avatar-app/src/views/AuthorizationManage.vue index 0046f18..eedddea 100644 --- a/digital-avatar-app/src/views/AuthorizationManage.vue +++ b/digital-avatar-app/src/views/AuthorizationManage.vue @@ -64,7 +64,7 @@ {{ item.title }} - {{ item.description }} + {{ item.key === 'takeover' ? takeoverDescription : item.description }} +
+
+
+ 自动回复等待时间 + 等待期间主人发言会取消本次回复,最短 3 秒 +
+ {{ formattedTakeoverDelay }} +
+
+ + +
+
+ @@ -171,7 +198,7 @@ const permissionItems: Array<{ { key: 'takeover', title: '分身主动接管聊天回复', - description: '收到私聊消息 3 秒后回复,主人发言时暂停', + description: '收到私聊消息后按设定时间回复,主人发言时暂停', tone: 'cyan', }, ] @@ -190,6 +217,8 @@ const saving = ref(false) const errorMessage = ref('') const toastMessage = ref('') const takeoverStatus = ref(null) +const takeoverDelayValue = ref(3) +const takeoverDelayUnit = ref<'seconds' | 'minutes'>('minutes') let toastTimer: number | undefined let takeoverStatusTimer: number | undefined @@ -210,6 +239,38 @@ const takeoverConnectionTone = computed(() => { return 'connecting' }) +const takeoverDelaySeconds = computed(() => { + const value = Math.trunc(Number(takeoverDelayValue.value) || 0) + return takeoverDelayUnit.value === 'minutes' ? value * 60 : value +}) + +const formattedTakeoverDelay = computed(() => { + const seconds = takeoverDelaySeconds.value + if (seconds > 0 && seconds % 60 === 0) return `${seconds / 60} 分钟` + return `${seconds} 秒` +}) + +const takeoverDescription = computed(() => + `收到私聊消息 ${formattedTakeoverDelay.value}后回复,主人发言时暂停` +) + +const applyTakeoverDelay = (seconds: number) => { + const normalized = Number.isFinite(seconds) && seconds >= 3 ? Math.trunc(seconds) : 180 + if (normalized % 60 === 0) { + takeoverDelayUnit.value = 'minutes' + takeoverDelayValue.value = normalized / 60 + } else { + takeoverDelayUnit.value = 'seconds' + takeoverDelayValue.value = normalized + } +} + +const normalizeTakeoverDelay = () => { + const min = takeoverDelayUnit.value === 'minutes' ? 1 : 3 + const max = takeoverDelayUnit.value === 'minutes' ? 1440 : 86400 + takeoverDelayValue.value = Math.min(max, Math.max(min, Math.trunc(Number(takeoverDelayValue.value) || min))) +} + const setPermissions = (permissions: AvatarPermission[]) => { const enabled = new Set(permissions) for (const item of permissionItems) permissionState[item.key] = enabled.has(item.key) @@ -265,6 +326,7 @@ const loadSettings = async () => { try { const settings = await getAvatarPermissionSettings(activeAvatarId.value) setPermissions(settings.permissions || []) + applyTakeoverDelay(settings.takeoverReplyDelaySeconds || 180) await loadTakeoverStatus() scheduleTakeoverStatusRefresh() } catch (error: any) { @@ -291,8 +353,18 @@ const saveSettings = async (takeoverToggle = false): Promise => { saving.value = true errorMessage.value = '' try { - const settings = await updateAvatarPermissionSettings(activeAvatarId.value, selectedPermissions()) + normalizeTakeoverDelay() + if (takeoverDelaySeconds.value < 3 || takeoverDelaySeconds.value > 86400) { + errorMessage.value = '自动回复等待时间需在 3 秒到 24 小时之间' + return false + } + const settings = await updateAvatarPermissionSettings( + activeAvatarId.value, + selectedPermissions(), + takeoverDelaySeconds.value, + ) setPermissions(settings.permissions || []) + applyTakeoverDelay(settings.takeoverReplyDelaySeconds || 180) await loadTakeoverStatus() scheduleTakeoverStatusRefresh() if (takeoverToggle) { @@ -468,6 +540,75 @@ svg { min-height: 76px; } +.takeover-delay-card { + margin-top: 12px; + padding: 16px; + border: 1px solid #dff1ef; + border-radius: 15px; + background: linear-gradient(135deg, #f5fcfb 0%, #fff 100%); + box-shadow: 0 8px 24px rgba(53, 166, 162, .06); +} + +.delay-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.delay-heading strong, +.delay-heading small { + display: block; +} + +.delay-heading strong { + font-size: 14px; + line-height: 1.4; +} + +.delay-heading small { + margin-top: 5px; + color: #8c929f; + font-size: 11px; + line-height: 1.55; +} + +.delay-heading > span { + flex: none; + padding: 4px 8px; + border-radius: 999px; + color: #258e8a; + background: #e8f8f6; + font-size: 11px; + font-weight: 700; +} + +.delay-control { + margin-top: 14px; + display: grid; + grid-template-columns: minmax(0, 1fr) 88px; + gap: 10px; +} + +.delay-control input, +.delay-control select { + min-width: 0; + height: 42px; + padding: 0 12px; + border: 1px solid #dfe5e8; + border-radius: 11px; + outline: none; + color: #222528; + background: #fff; + font: inherit; +} + +.delay-control input:focus, +.delay-control select:focus { + border-color: #35a6a2; + box-shadow: 0 0 0 3px rgba(53, 166, 162, .1); +} + .permission-icon { width: 34px; height: 34px;