Compare commits

..
16 changed files with 1059 additions and 86 deletions
+11 -1
View File
@@ -38,7 +38,7 @@ def init_db():
("avatars", "owner_id", "VARCHAR DEFAULT ''"), ("avatars", "owner_id", "VARCHAR DEFAULT ''"),
("authorizations", "takeover_enabled", "BOOLEAN DEFAULT 0"), ("authorizations", "takeover_enabled", "BOOLEAN DEFAULT 0"),
("authorizations", "takeover_mode", "VARCHAR DEFAULT 'immediate'"), ("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"), ("avatars", "share_token", "VARCHAR DEFAULT NULL"),
("token_account", "user_id", "VARCHAR DEFAULT ''"), ("token_account", "user_id", "VARCHAR DEFAULT ''"),
("token_account", "total_granted", "BIGINT DEFAULT 0"), ("token_account", "total_granted", "BIGINT DEFAULT 0"),
@@ -47,6 +47,7 @@ def init_db():
("token_account", "updated_at", "TIMESTAMP"), ("token_account", "updated_at", "TIMESTAMP"),
) )
_normalize_optional_unique_values() _normalize_optional_unique_values()
_normalize_takeover_delays()
_create_token_indexes() _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 = ''") 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(): def _create_token_indexes():
with engine.begin() as conn: with engine.begin() as conn:
conn.exec_driver_sql( conn.exec_driver_sql(
+1 -1
View File
@@ -67,7 +67,7 @@ class Authorization(Base):
status = Column(String, default="active") # active | inactive status = Column(String, default="active") # active | inactive
takeover_enabled = Column(Boolean, default=False) # 是否开启分身接管 takeover_enabled = Column(Boolean, default=False) # 是否开启分身接管
takeover_mode = Column(String, default="immediate") # immediate | delayed 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()) created_at = Column(DateTime, server_default=func.now())
def to_dict(self): def to_dict(self):
@@ -2,7 +2,7 @@ from fastapi import APIRouter, Body, Depends, Header, HTTPException
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from database import get_db from database import get_db
from models import Authorization, TakeoverCursor, TakeoverReplyTask from models import Authorization, Avatar, TakeoverCursor, TakeoverReplyTask
from responses import fail, ok from responses import fail, ok
from routers.avatars import _require_owned_avatar from routers.avatars import _require_owned_avatar
@@ -14,6 +14,10 @@ ALLOWED_PERMISSIONS = set(PERMISSION_ORDER)
AVATAR_PERMISSION_ORDER = PERMISSION_ORDER AVATAR_PERMISSION_ORDER = PERMISSION_ORDER
AVATAR_PERMISSION_KEY = "authorizationPermissions" AVATAR_PERMISSION_KEY = "authorizationPermissions"
DEFAULT_AVATAR_PERMISSIONS = ["friend", "chat"] 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 = { LEGACY_PERMISSION_MAP = {
"read": "browse", "read": "browse",
"reply": "chat", "reply": "chat",
@@ -91,9 +95,62 @@ def _permission_settings_payload(avatar) -> dict:
return { return {
"avatarId": avatar.id, "avatarId": avatar.id,
"permissions": _stored_avatar_permissions(avatar), "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: def _require_authorization(db: Session, avatar_id: str, authorization_id: str) -> Authorization:
authorization = ( authorization = (
db.query(Authorization) db.query(Authorization)
@@ -144,10 +201,19 @@ def update_permission_settings(
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
avatar = _require_owned_avatar(db, avatar_id, authorization) avatar = _require_owned_avatar(db, avatar_id, authorization)
if "permissions" not in payload: if "permissions" not in payload and TAKEOVER_DELAY_KEY not in payload:
return fail("缺少 permissions", 400) return fail("缺少授权设置", 400)
try: 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: except ValueError as exc:
return fail(str(exc), 400) return fail(str(exc), 400)
@@ -155,7 +221,9 @@ def update_permission_settings(
avatar.config = { avatar.config = {
**(avatar.config or {}), **(avatar.config or {}),
AVATAR_PERMISSION_KEY: permissions, 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() cursor = db.query(TakeoverCursor).filter(TakeoverCursor.avatar_id == avatar.id).first()
if cursor and "takeover" in permissions and "takeover" not in previous_permissions: if cursor and "takeover" in permissions and "takeover" not in previous_permissions:
cursor.initialized = False cursor.initialized = False
@@ -179,7 +247,9 @@ def update_permission_settings(
task.locked_at = None task.locked_at = None
db.commit() db.commit()
db.refresh(avatar) 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") @router.get("/avatar/{avatar_id}/authorizations")
@@ -240,7 +310,7 @@ def create_auth(
status="active", status="active",
takeover_enabled=False, takeover_enabled=False,
takeover_mode="immediate", takeover_mode="immediate",
takeover_delay_seconds=30, takeover_delay_seconds=DEFAULT_TAKEOVER_DELAY_SECONDS,
) )
db.add(item) db.add(item)
db.commit() db.commit()
+42 -16
View File
@@ -6,7 +6,17 @@ from sqlalchemy.orm import Session
from database import get_db from database import get_db
from routers.knowledge import UPLOAD_DIR 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 from responses import ok, fail
router = APIRouter(tags=["分身"]) 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}") @router.get("/avatar/{avatar_id}")
def get_avatar(avatar_id: str, db: Session = Depends(get_db)): def get_avatar(
a = db.query(Avatar).filter(Avatar.id == avatar_id).first() avatar_id: str,
if not a: authorization: str = Header(None),
return fail("分身不存在", 404) db: Session = Depends(get_db),
return ok(a.to_dict()) ):
return ok(_require_owned_avatar(db, avatar_id, authorization).to_dict())
@router.post("/avatar") @router.post("/avatar")
def create_avatar(payload: dict = Body(...), authorization: str = Header(None), db: Session = Depends(get_db)): def create_avatar(payload: dict = Body(...), authorization: str = Header(None), db: Session = Depends(get_db)):
user = _resolve_user(authorization, db) user = _resolve_user(authorization, db)
if not user:
raise HTTPException(status_code=401, detail="未登录")
a = Avatar( a = Avatar(
owner_id=user.huihui_user_id if user else "", owner_id=user.huihui_user_id,
name=payload.get("name", "未命名分身"), name=payload.get("name", "未命名分身"),
display_name=payload.get("displayName", "") or payload.get("display_name", ""), display_name=payload.get("displayName", "") or payload.get("display_name", ""),
description=payload.get("description", ""), description=payload.get("description", ""),
@@ -102,10 +115,13 @@ def create_avatar(payload: dict = Body(...), authorization: str = Header(None),
@router.put("/avatar/{avatar_id}") @router.put("/avatar/{avatar_id}")
def update_avatar(avatar_id: str, payload: dict = Body(...), db: Session = Depends(get_db)): def update_avatar(
a = db.query(Avatar).filter(Avatar.id == avatar_id).first() avatar_id: str,
if not a: payload: dict = Body(...),
return fail("分身不存在", 404) authorization: str = Header(None),
db: Session = Depends(get_db),
):
a = _require_owned_avatar(db, avatar_id, authorization)
mapping = { mapping = {
"displayName": "display_name", "displayName": "display_name",
"photoUrl": "photo_url", "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"): for key in ("name", "displayName", "description", "photoUrl", "emoji", "status", "tokenBalance", "config"):
if key in payload: if key in payload:
col = mapping.get(key, key) 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.commit()
db.refresh(a) db.refresh(a)
return ok(a.to_dict()) return ok(a.to_dict())
@router.delete("/avatar/{avatar_id}") @router.delete("/avatar/{avatar_id}")
def delete_avatar(avatar_id: str, db: Session = Depends(get_db)): def delete_avatar(
a = db.query(Avatar).filter(Avatar.id == avatar_id).first() avatar_id: str,
if not a: authorization: str = Header(None),
return fail("分身不存在", 404) 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(KnowledgeDoc).filter(KnowledgeDoc.avatar_id == avatar_id).delete()
db.query(KnowledgeChunk).filter(KnowledgeChunk.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(QAPair).filter(QAPair.avatar_id == avatar_id).delete()
db.query(Authorization).filter(Authorization.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.delete(a)
db.commit() db.commit()
return ok({"success": True}) return ok({"success": True})
+103 -12
View File
@@ -34,6 +34,19 @@ QA_SEMANTIC_THRESHOLD = 0.72
QA_MATCH_MARGIN = 0.06 QA_MATCH_MARGIN = 0.06
KNOWLEDGE_MIN_SCORE = float(os.getenv("KNOWLEDGE_MIN_SCORE", "0.42")) KNOWLEDGE_MIN_SCORE = float(os.getenv("KNOWLEDGE_MIN_SCORE", "0.42"))
_WRITING_SYSTEM_PATTERNS = {
"han": re.compile(r"[\u3400-\u4dbf\u4e00-\u9fff]"),
"latin": re.compile(r"[A-Za-z\u00c0-\u024f]"),
"cyrillic": re.compile(r"[\u0400-\u052f]"),
"arabic": re.compile(r"[\u0600-\u06ff]"),
"hebrew": re.compile(r"[\u0590-\u05ff]"),
"devanagari": re.compile(r"[\u0900-\u097f]"),
"thai": re.compile(r"[\u0e00-\u0e7f]"),
"greek": re.compile(r"[\u0370-\u03ff]"),
}
_JAPANESE_KANA = re.compile(r"[\u3040-\u30ff]")
_KOREAN_HANGUL = re.compile(r"[\uac00-\ud7af\u1100-\u11ff]")
class ChatMessage(BaseModel): class ChatMessage(BaseModel):
role: str = Field(pattern="^(user|assistant)$") role: str = Field(pattern="^(user|assistant)$")
@@ -70,6 +83,30 @@ def _normalize_question(value: str) -> str:
return value.translate(str.maketrans("", "", string.punctuation + ",。!?;:、()【】「」‘’“”《》")) return value.translate(str.maketrans("", "", string.punctuation + ",。!?;:、()【】「」‘’“”《》"))
def _dominant_writing_system(value: str) -> str:
value = value or ""
if _JAPANESE_KANA.search(value):
return "japanese"
if _KOREAN_HANGUL.search(value):
return "korean"
counts = {
name: len(pattern.findall(value))
for name, pattern in _WRITING_SYSTEM_PATTERNS.items()
}
name, count = max(counts.items(), key=lambda item: item[1])
return name if count else "unknown"
def _qa_requires_language_adaptation(question: str, answer: str) -> bool:
question_system = _dominant_writing_system(question)
answer_system = _dominant_writing_system(answer)
return (
question_system != "unknown"
and answer_system != "unknown"
and question_system != answer_system
)
def _canonicalize_question(value: str) -> str: def _canonicalize_question(value: str) -> str:
value = _normalize_question(value) value = _normalize_question(value)
replacements = ( replacements = (
@@ -189,7 +226,14 @@ def _config(avatar: Avatar) -> dict:
} }
def _build_prompt(avatar: Avatar, history: list[Any], question: str, knowledge_hits: list[dict]) -> list[dict]: def _build_prompt(
avatar: Avatar,
history: list[Any],
question: str,
knowledge_hits: list[dict],
*,
standard_answer: str = "",
) -> list[dict]:
config = _config(avatar) config = _config(avatar)
description = (getattr(avatar, "description", "") or "").strip() description = (getattr(avatar, "description", "") or "").strip()
knowledge = "\n".join( knowledge = "\n".join(
@@ -210,7 +254,7 @@ def _build_prompt(avatar: Avatar, history: list[Any], question: str, knowledge_h
profile = ";".join(f"{label}:{value}" for label, value in profile_items) profile = ";".join(f"{label}:{value}" for label, value in profile_items)
system = ( system = (
f"你的专业或服务范围是:「{description or '未设置'}」。" f"你的专业或服务范围是:「{description or '未设置'}」。"
"请基于已提供的知识库回答,不要编造事实;" "请基于已提供的可靠资料回答,不要编造事实;"
f"回复风格:{config['replyStyle']};严谨度:{config['rigor']}/100;" f"回复风格:{config['replyStyle']};严谨度:{config['rigor']}/100;"
f"幽默感:{config['humor']}/100;回复长度:{config['responseLength']}。" f"幽默感:{config['humor']}/100;回复长度:{config['responseLength']}。"
) )
@@ -221,7 +265,13 @@ def _build_prompt(avatar: Avatar, history: list[Any], question: str, knowledge_h
) )
if config["systemPrompt"]: if config["systemPrompt"]:
system += f"\n额外系统提示词:{config['systemPrompt']}" system += f"\n额外系统提示词:{config['systemPrompt']}"
if knowledge: if standard_answer:
system += (
f"\n以下是本次问题命中的已确认标准答案:\n{standard_answer.strip()}"
"\n必须保持标准答案中的事实、数字、专有名词和结论不变,只允许为匹配用户当前语言进行忠实转换"
"和必要的自然表达,不得补充、删减或改写其含义。不要提及标准答案或转换过程。"
)
elif knowledge:
system += ( system += (
f"\n以下是本次问题检索到的可靠资料:\n{knowledge}" f"\n以下是本次问题检索到的可靠资料:\n{knowledge}"
"\n涉及事实、专业判断、地址、流程、数据或建议时,只能依据本人资料、标准问答形成的上下文" "\n涉及事实、专业判断、地址、流程、数据或建议时,只能依据本人资料、标准问答形成的上下文"
@@ -231,7 +281,8 @@ def _build_prompt(avatar: Avatar, history: list[Any], question: str, knowledge_h
system += ( system += (
"\n本次问题没有检索到可靠资料。除自然寒暄和基于本人资料的回答外,不要凭通用知识给出事实、" "\n本次问题没有检索到可靠资料。除自然寒暄和基于本人资料的回答外,不要凭通用知识给出事实、"
"专业判断、地址、流程、数据或具体建议。自然地说明这方面暂时没有足够信息,并询问对方是否能" "专业判断、地址、流程、数据或具体建议。自然地说明这方面暂时没有足够信息,并询问对方是否能"
"补充更具体的问题;不要提及知识库、检索、模型或系统限制。" "补充更具体的问题;不要提及知识库、检索、模型或系统限制。对方询问你能做什么时,只能概括"
"专业或服务范围和本人资料中明确写出的内容,不得推断服务对象、工作场所、现实案例或从业经历。"
) )
system += ( system += (
"\n沟通底线(始终优先于其他提示):不要透露、猜测或确认所使用的模型、模型供应商、" "\n沟通底线(始终优先于其他提示):不要透露、猜测或确认所使用的模型、模型供应商、"
@@ -249,6 +300,14 @@ def _build_prompt(avatar: Avatar, history: list[Any], question: str, knowledge_h
"只有切换独立观点或确实需要列举时才换行;列举使用 1.、2.、3.,每项单独一行。" "只有切换独立观点或确实需要列举时才换行;列举使用 1.、2.、3.,每项单独一行。"
"不要在行首或行尾留空格,不要连续输出空行。先给结论,再给简短说明;避免重复和冗长铺垫。" "不要在行首或行尾留空格,不要连续输出空行。先给结论,再给简短说明;避免重复和冗长铺垫。"
) )
system += (
"\n回答语言规则(始终优先):先识别当前最后一条用户消息所使用的主要自然语言,并且只使用该语言回答。"
"用户用中文就用中文,使用 English 就用 English,使用日语、韩语、法语、西班牙语或其他语言时,"
"也必须使用对应语言。消息混用多种语言时跟随占主导的语言;用户明确指定回答语言时服从其指定。"
"历史消息、本人资料、标准答案和知识库使用的语言都不能覆盖当前用户消息的语言。"
"专有名词、品牌、地址、代码和必要缩写可保留原文。不要解释语言识别或翻译过程。"
"改变回答语言只改变表达语言,绝不能因此增加资料中没有的场景、身份、经历或事实。"
)
messages = [{"role": "system", "content": system}] messages = [{"role": "system", "content": system}]
for item in history[-MAX_HISTORY_MESSAGES:]: for item in history[-MAX_HISTORY_MESSAGES:]:
messages.append({"role": item.role, "content": item.content} if hasattr(item, "role") else item) messages.append({"role": item.role, "content": item.content} if hasattr(item, "role") else item)
@@ -384,14 +443,30 @@ def _resolve_reply(
if qa_pairs is None: if qa_pairs is None:
qa_pairs = db.query(QAPair).filter(QAPair.avatar_id == avatar.id).all() qa_pairs = db.query(QAPair).filter(QAPair.avatar_id == avatar.id).all()
matched = _match_standard_qa(question, qa_pairs) matched = _match_standard_qa(question, qa_pairs)
if matched: adapt_qa_language = bool(
matched and _qa_requires_language_adaptation(question, matched.answer)
)
if matched and not adapt_qa_language:
return {"answer": matched.answer, "source": "qa", "references": []} return {"answer": matched.answer, "source": "qa", "references": []}
if matched:
hits = []
messages = _build_prompt(
avatar,
history,
question,
hits,
standard_answer=matched.answer,
)
else:
search_fn = search_fn or (lambda query, avatar_id: _search_knowledge(db, avatar_id, query)) search_fn = search_fn or (lambda query, avatar_id: _search_knowledge(db, avatar_id, query))
hits = search_fn(question, avatar.id) hits = search_fn(question, avatar.id)
messages = _build_prompt(avatar, history, question, hits) messages = _build_prompt(avatar, history, question, hits)
config = _config(avatar) config = _config(avatar)
temperature = min(0.45 if hits else 0.25, 0.2 + config["creativity"] / 100 * 0.6) temperature = 0.0 if matched else min(
0.45 if hits else 0.25,
0.2 + config["creativity"] / 100 * 0.6,
)
token_usage = None token_usage = None
if model_client is not None: if model_client is not None:
answer = model_client(messages=messages, temperature=temperature) answer = model_client(messages=messages, temperature=temperature)
@@ -423,7 +498,7 @@ def _resolve_reply(
raise raise
result = { result = {
"answer": answer, "answer": answer,
"source": "knowledge" if hits else "qwen", "source": "qa" if matched else ("knowledge" if hits else "qwen"),
"references": hits, "references": hits,
} }
if token_usage: if token_usage:
@@ -442,14 +517,32 @@ def _stream_reply(
): ):
qa_pairs = db.query(QAPair).filter(QAPair.avatar_id == avatar.id).all() qa_pairs = db.query(QAPair).filter(QAPair.avatar_id == avatar.id).all()
matched = _match_standard_qa(question, qa_pairs) matched = _match_standard_qa(question, qa_pairs)
if matched: adapt_qa_language = bool(
matched and _qa_requires_language_adaptation(question, matched.answer)
)
messages, reservation = [], None
if matched and not adapt_qa_language:
source, references, chunks = "qa", [], _iter_text_chunks(matched.answer) source, references, chunks = "qa", [], _iter_text_chunks(matched.answer)
else:
if matched:
references = []
source = "qa"
messages = _build_prompt(
avatar,
history,
question,
references,
standard_answer=matched.answer,
)
else: else:
references = _search_knowledge(db, avatar.id, question) references = _search_knowledge(db, avatar.id, question)
source = "knowledge" if references else "qwen" source = "knowledge" if references else "qwen"
config = _config(avatar)
temperature = min(0.45 if references else 0.25, 0.2 + config["creativity"] / 100 * 0.6)
messages = _build_prompt(avatar, history, question, references) messages = _build_prompt(avatar, history, question, references)
config = _config(avatar)
temperature = 0.0 if matched else min(
0.45 if references else 0.25,
0.2 + config["creativity"] / 100 * 0.6,
)
model_config = get_chat_model_config() model_config = get_chat_model_config()
reservation = reserve_avatar_tokens( reservation = reserve_avatar_tokens(
db, db,
@@ -460,8 +553,6 @@ def _stream_reply(
model_config.max_tokens, model_config.max_tokens,
) )
chunks = _iter_qwen_stream(messages, temperature, model_config) chunks = _iter_qwen_stream(messages, temperature, model_config)
if matched:
messages, reservation = [], None
if public: if public:
source, references = "public", [] source, references = "public", []
+26 -5
View File
@@ -8,13 +8,25 @@ from sqlalchemy.orm import Session
from database import get_db from database import get_db
from models import TakeoverCursor, TakeoverReplyTask, User from models import TakeoverCursor, TakeoverReplyTask, User
from responses import fail, ok 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 from routers.avatars import _require_owned_avatar
router = APIRouter(tags=["分身接管"]) router = APIRouter(tags=["分身接管"])
BOXIM_STATUS_FRESH_SECONDS = 60 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") @router.get("/avatar/{avatar_id}/takeover/status")
def get_takeover_status( def get_takeover_status(
avatar_id: str, avatar_id: str,
@@ -24,6 +36,7 @@ def get_takeover_status(
avatar = _require_owned_avatar(db, avatar_id, authorization) avatar = _require_owned_avatar(db, avatar_id, authorization)
permissions = (avatar.config or {}).get("authorizationPermissions", []) permissions = (avatar.config or {}).get("authorizationPermissions", [])
enabled = isinstance(permissions, list) and "takeover" in permissions 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() user = db.query(User).filter(User.huihui_user_id == avatar.owner_id).first()
cursor = db.query(TakeoverCursor).filter(TakeoverCursor.avatar_id == avatar.id).first() cursor = db.query(TakeoverCursor).filter(TakeoverCursor.avatar_id == avatar.id).first()
pending_count = ( pending_count = (
@@ -49,7 +62,10 @@ def get_takeover_status(
and cursor.last_polled_at and cursor.last_polled_at
>= datetime.utcnow() - timedelta(seconds=BOXIM_STATUS_FRESH_SECONDS) >= datetime.utcnow() - timedelta(seconds=BOXIM_STATUS_FRESH_SECONDS)
): ):
status, message = "ready", "BOXIM 已连接,收到私聊消息 3 秒后自动回复" status, message = (
"ready",
f"BOXIM 已连接,收到私聊消息 {_delay_label(reply_delay_seconds)}后自动回复",
)
else: else:
status, message = "connecting", "正在连接 BOXIM" status, message = "connecting", "正在连接 BOXIM"
@@ -59,6 +75,7 @@ def get_takeover_status(
"status": status, "status": status,
"message": message, "message": message,
"pendingCount": pending_count, "pendingCount": pending_count,
"takeoverReplyDelaySeconds": reply_delay_seconds,
"lastPolledAt": cursor.last_polled_at.isoformat() if cursor and cursor.last_polled_at else None, "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)) auth = _require_authorization(db, avatar_id, str(auth_id))
enabled = bool(auth.takeover_enabled) enabled = bool(auth.takeover_enabled)
mode = auth.takeover_mode or "immediate" 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"): if _has(payload, "takeoverEnabled", "takeover_enabled"):
raw_enabled = _read(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"): if _has(payload, "takeoverDelaySeconds", "takeover_delay_seconds"):
delay = _read(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: if (
return fail("延迟时间需在 5 到 3600 秒之间", 400) 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": if enabled and auth.target_type != "user":
return fail("本期仅支持对会会用户开启单聊接管", 400) return fail("本期仅支持对会会用户开启单聊接管", 400)
@@ -28,6 +28,14 @@ MAX_PROMPT_LENGTH = 4000
MAX_STALE_SECONDS = 120 MAX_STALE_SECONDS = 120
STUCK_LOCK_SECONDS = 90 STUCK_LOCK_SECONDS = 90
TAKEOVER_PERMISSION = "takeover" 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: 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() 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: 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__( def __init__(
self, self,
session_factory: Callable[[], Session], session_factory: Callable[[], Session],
boxim_client: BoxIMClient, boxim_client: BoxIMClient,
*, *,
reply_delay_seconds: int = 3, reply_delay_seconds: int | None = None,
now: Callable[[], datetime] = _utcnow, now: Callable[[], datetime] = _utcnow,
): ):
self.session_factory = session_factory self.session_factory = session_factory
@@ -119,11 +155,17 @@ class TakeoverService:
def _enabled_avatar_ids(self) -> list[str]: def _enabled_avatar_ids(self) -> list[str]:
db = self.session_factory() db = self.session_factory()
try: try:
return [ avatars = (
avatar.id db.query(Avatar)
for avatar in db.query(Avatar).filter(Avatar.status == "active").all() .filter(Avatar.status == "active")
if _takeover_enabled(avatar) .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: finally:
db.close() db.close()
@@ -201,13 +243,20 @@ class TakeoverService:
def _forget_boxim_session(self, user_id: str): def _forget_boxim_session(self, user_id: str):
self._sessions.pop(user_id, None) self._sessions.pop(user_id, None)
def _disable_after_connection_failure( def _record_connection_failure(
self, self,
db: Session, db: Session,
avatar: Avatar, avatar: Avatar,
cursor: TakeoverCursor, cursor: TakeoverCursor,
message: str, message: str,
*,
disable_takeover: bool,
): ):
cursor.last_error = message
cursor.last_polled_at = self.now()
if not disable_takeover:
return
permissions = (avatar.config or {}).get("authorizationPermissions", []) permissions = (avatar.config or {}).get("authorizationPermissions", [])
avatar.config = { avatar.config = {
**(avatar.config or {}), **(avatar.config or {}),
@@ -217,8 +266,6 @@ class TakeoverService:
if permission != TAKEOVER_PERMISSION if permission != TAKEOVER_PERMISSION
], ],
} }
cursor.last_error = message
cursor.last_polled_at = self.now()
tasks = ( tasks = (
db.query(TakeoverReplyTask) db.query(TakeoverReplyTask)
.filter( .filter(
@@ -245,11 +292,12 @@ class TakeoverService:
db.add(cursor) db.add(cursor)
db.flush() db.flush()
if not user or not user.huihui_token: if not user or not user.huihui_token:
self._disable_after_connection_failure( self._record_connection_failure(
db, db,
avatar, avatar,
cursor, cursor,
"请重新登录会会生产账号后再开启主动接管", "请重新登录会会生产账号后再开启主动接管",
disable_takeover=True,
) )
db.commit() db.commit()
return False return False
@@ -268,11 +316,24 @@ class TakeoverService:
if isinstance(exc, BoxIMError) and exc.auth_error: if isinstance(exc, BoxIMError) and exc.auth_error:
self._forget_boxim_session(user.id) self._forget_boxim_session(user.id)
message = "BOXIM 授权已失效,请重新登录会会生产账号" message = "BOXIM 授权已失效,请重新登录会会生产账号"
disable_takeover = True
else: else:
message = f"BOXIM 暂时连接失败:{str(exc)[:160]}" message = f"BOXIM 暂时连接失败:{str(exc)[:160]}"
self._disable_after_connection_failure(db, avatar, cursor, message) disable_takeover = False
self._record_connection_failure(
db,
avatar,
cursor,
message,
disable_takeover=disable_takeover,
)
db.commit() db.commit()
logger.warning("BOXIM sync failed for avatar %s: %s", avatar.id, exc) logger.warning(
"BOXIM sync failed for avatar %s (will_retry=%s): %s",
avatar.id,
not disable_takeover,
exc,
)
return False return False
messages.sort(key=lambda item: (_numeric_id(item.get("id")), item.get("sendTime") or 0)) messages.sort(key=lambda item: (_numeric_id(item.get("id")), item.get("sendTime") or 0))
@@ -350,13 +411,21 @@ class TakeoverService:
now = self.now() now = self.now()
send_time = _boxim_time(message.get("sendTime"), now) send_time = _boxim_time(message.get("sendTime"), now)
is_avatar = False is_avatar = _is_avatar_local_id(local_id)
if direction == "outgoing" and local_id: if not is_avatar and local_id:
is_avatar = bool( is_avatar = bool(
db.query(TakeoverReplyTask) db.query(TakeoverReplyTask)
.filter( .filter(
TakeoverReplyTask.owner_id == avatar.owner_id,
TakeoverReplyTask.boxim_local_id == local_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", TakeoverReplyTask.status == "sent",
) )
.first() .first()
@@ -385,8 +454,52 @@ class TakeoverService:
return return
if (now - send_time).total_seconds() > MAX_STALE_SECONDS: if (now - send_time).total_seconds() > MAX_STALE_SECONDS:
return 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) 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 @staticmethod
def _cancel_conversation(db: Session, owner_id: str, peer_id: str, reason: str): def _cancel_conversation(db: Session, owner_id: str, peer_id: str, reason: str):
tasks = ( tasks = (
@@ -427,9 +540,11 @@ class TakeoverService:
prompt_parts.append(event.content.strip()) prompt_parts.append(event.content.strip())
source_ids.append(event.boxim_message_id) source_ids.append(event.boxim_message_id)
prompt = "\n".join(part for part in prompt_parts if part).strip()[-MAX_PROMPT_LENGTH:] 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) 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( db.add(
TakeoverReplyTask( TakeoverReplyTask(
id=task_id, id=task_id,
@@ -455,6 +570,7 @@ class TakeoverService:
.filter( .filter(
TakeoverReplyTask.status.in_(GENERATABLE_TASK_STATUSES), TakeoverReplyTask.status.in_(GENERATABLE_TASK_STATUSES),
TakeoverReplyTask.response_text == "", TakeoverReplyTask.response_text == "",
TakeoverReplyTask.scheduled_at <= self.now(),
) )
.order_by(TakeoverReplyTask.created_at.asc()) .order_by(TakeoverReplyTask.created_at.asc())
.limit(10) .limit(10)
@@ -501,6 +617,7 @@ class TakeoverService:
.filter( .filter(
TakeoverMessage.owner_id == task.owner_id, TakeoverMessage.owner_id == task.owner_id,
TakeoverMessage.peer_id == task.peer_id, TakeoverMessage.peer_id == task.peer_id,
TakeoverMessage.avatar_id == task.avatar_id,
) )
.order_by(TakeoverMessage.send_time.desc()) .order_by(TakeoverMessage.send_time.desc())
.limit(30) .limit(30)
@@ -510,6 +627,8 @@ class TakeoverService:
for event in reversed(events): for event in reversed(events):
if event.boxim_message_id in excluded_ids or not event.content.strip(): if event.boxim_message_id in excluded_ids or not event.content.strip():
continue continue
if event.direction == "incoming" and event.is_avatar:
continue
history.append( history.append(
{ {
"role": "user" if event.direction == "incoming" else "assistant", "role": "user" if event.direction == "incoming" else "assistant",
@@ -587,6 +706,15 @@ class TakeoverService:
task.cancel_reason = "stale_reply" task.cancel_reason = "stale_reply"
db.commit() db.commit()
return False 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() user = db.query(User).filter(User.huihui_user_id == task.owner_id).first()
if not user or not user.huihui_token: if not user or not user.huihui_token:
raise BoxIMError("缺少会会登录凭证", auth_error=True) raise BoxIMError("缺少会会登录凭证", auth_error=True)
@@ -103,6 +103,7 @@ def test_avatar_permission_settings_default_and_persist(authorization_context):
assert initial["data"] == { assert initial["data"] == {
"avatarId": context["avatar"].id, "avatarId": context["avatar"].id,
"permissions": ["friend", "chat"], "permissions": ["friend", "chat"],
"takeoverReplyDelaySeconds": 180,
} }
updated = client.put( 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() reloaded = client.get(endpoint, headers=context["owner_headers"]).json()
assert reloaded["data"]["permissions"] == ["friend", "publish", "interact", "takeover"] assert reloaded["data"]["permissions"] == ["friend", "publish", "interact", "takeover"]
assert reloaded["data"]["takeoverReplyDelaySeconds"] == 180
def test_avatar_permission_settings_allow_all_disabled(authorization_context): 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) unauthenticated = client.get(endpoint)
assert unauthenticated.status_code == 401 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()
@@ -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()
@@ -5,7 +5,15 @@ from unittest.mock import Mock
from fastapi import HTTPException from fastapi import HTTPException
from models import Avatar, User from models import Avatar, User
from routers.chat import _build_prompt, _iter_text_chunks, _match_standard_qa, _public_avatar_payload, _require_owned_avatar, _resolve_reply from routers.chat import (
_build_prompt,
_iter_text_chunks,
_match_standard_qa,
_public_avatar_payload,
_qa_requires_language_adaptation,
_require_owned_avatar,
_resolve_reply,
)
class ChatOrchestrationTests(unittest.TestCase): class ChatOrchestrationTests(unittest.TestCase):
@@ -50,6 +58,34 @@ class ChatOrchestrationTests(unittest.TestCase):
self.assertEqual(result["answer"], "标准地址") self.assertEqual(result["answer"], "标准地址")
fake_model.assert_not_called() fake_model.assert_not_called()
def test_cross_language_qa_is_faithfully_adapted_by_model(self):
fake_model = Mock(return_value="Our address is Test Road 1.")
fake_search = Mock(return_value=[])
result = _resolve_reply(
None,
self.avatar,
"Where is your office?",
[],
qa_pairs=[SimpleNamespace(question="Where is your office?", answer="地址是测试路1号。", enabled=True)],
search_fn=fake_search,
model_client=fake_model,
)
self.assertEqual(result["source"], "qa")
self.assertEqual(result["answer"], "Our address is Test Road 1.")
self.assertEqual(fake_model.call_args.kwargs["temperature"], 0.0)
system = fake_model.call_args.kwargs["messages"][0]["content"]
self.assertIn("已确认标准答案", system)
self.assertIn("地址是测试路1号", system)
self.assertIn("只使用该语言回答", system)
fake_search.assert_not_called()
def test_qa_language_adaptation_detects_common_writing_system_changes(self):
self.assertTrue(_qa_requires_language_adaptation("Hello", "你好"))
self.assertTrue(_qa_requires_language_adaptation("こんにちは", "你好"))
self.assertTrue(_qa_requires_language_adaptation("안녕하세요", "你好"))
self.assertFalse(_qa_requires_language_adaptation("你好", "您好"))
def test_conversational_paraphrase_matches_standard_qa(self): def test_conversational_paraphrase_matches_standard_qa(self):
for question in ("请问一下,你们公司在哪里呀?", "请问去你们那边怎么走"): for question in ("请问一下,你们公司在哪里呀?", "请问去你们那边怎么走"):
with self.subTest(question=question): with self.subTest(question=question):
@@ -106,6 +142,9 @@ class ChatOrchestrationTests(unittest.TestCase):
self.assertIn("像熟人之间微信聊天一样", messages[0]["content"]) self.assertIn("像熟人之间微信聊天一样", messages[0]["content"])
self.assertIn("不隶属于任何机构", messages[0]["content"]) self.assertIn("不隶属于任何机构", messages[0]["content"])
self.assertIn("不要连续输出空行", messages[0]["content"]) self.assertIn("不要连续输出空行", messages[0]["content"])
self.assertIn("回答语言规则", messages[0]["content"])
self.assertIn("当前最后一条用户消息", messages[0]["content"])
self.assertIn("历史消息", messages[0]["content"])
def test_prompt_blocks_ungrounded_factual_answers(self): def test_prompt_blocks_ungrounded_factual_answers(self):
messages = _build_prompt(self.avatar, [], "聊聊国际新闻", []) messages = _build_prompt(self.avatar, [], "聊聊国际新闻", [])
@@ -113,6 +152,8 @@ class ChatOrchestrationTests(unittest.TestCase):
self.assertIn("没有检索到可靠资料", system) self.assertIn("没有检索到可靠资料", system)
self.assertIn("不要凭通用知识", system) self.assertIn("不要凭通用知识", system)
self.assertIn("不要提及知识库", system) self.assertIn("不要提及知识库", system)
self.assertIn("不得推断服务对象", system)
self.assertIn("工作场所", system)
def test_public_avatar_payload_excludes_internal_configuration(self): def test_public_avatar_payload_excludes_internal_configuration(self):
payload = _public_avatar_payload(self.avatar) payload = _public_avatar_payload(self.avatar)
@@ -6,7 +6,7 @@ from fastapi.testclient import TestClient
from database import SessionLocal from database import SessionLocal
from main import app from main import app
from models import KnowledgeChunk, KnowledgeDoc from models import Avatar, KnowledgeChunk, KnowledgeDoc, QAPair
from routers.knowledge import _doc_payload from routers.knowledge import _doc_payload
@@ -92,3 +92,94 @@ def test_markdown_upload_commits_ready_document_and_chunks_together(
db.commit() db.commit()
finally: finally:
db.close() 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()
@@ -13,7 +13,7 @@ def test_authorization_takeover_fields():
assert hasattr(auth, 'takeover_delay_seconds') assert hasattr(auth, 'takeover_delay_seconds')
assert auth.takeover_enabled == False assert auth.takeover_enabled == False
assert auth.takeover_mode == 'immediate' assert auth.takeover_mode == 'immediate'
assert auth.takeover_delay_seconds == 30 assert auth.takeover_delay_seconds == 180
finally: finally:
db.close() db.close()
@@ -11,7 +11,12 @@ from sqlalchemy.orm import sessionmaker
from database import Base from database import Base
from models import Avatar, TakeoverCursor, TakeoverMessage, TakeoverReplyTask, User from models import Avatar, TakeoverCursor, TakeoverMessage, TakeoverReplyTask, User
from services.boxim_client import BoxIMError 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: class Clock:
@@ -77,7 +82,10 @@ def service_context(tmp_path):
owner_id=user.huihui_user_id, owner_id=user.huihui_user_id,
name="分身", name="分身",
status="active", status="active",
config={"authorizationPermissions": ["chat", "takeover"]}, config={
"authorizationPermissions": ["chat", "takeover"],
"takeoverReplyDelaySeconds": 3,
},
) )
db.add_all([user, avatar]) db.add_all([user, avatar])
db.commit() db.commit()
@@ -120,7 +128,6 @@ 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": "你好"} {"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.sent == []
assert boxim.read_receipts == [{"friendId": "200", "messageId": "11"}] assert boxim.read_receipts == [{"friendId": "200", "messageId": "11"}]
@@ -130,6 +137,7 @@ async def test_incoming_message_is_prepared_then_sent_at_three_seconds(service_c
assert boxim.sent == [] assert boxim.sent == []
clock.advance(1) clock.advance(1)
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 == [{"peerId": "200", "content": "你好\n很高兴见到你", "localId": boxim.sent[0]["localId"]}] assert boxim.sent == [{"peerId": "200", "content": "你好\n很高兴见到你", "localId": boxim.sent[0]["localId"]}]
@@ -142,6 +150,131 @@ async def test_incoming_message_is_prepared_then_sent_at_three_seconds(service_c
db.close() 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 @pytest.mark.asyncio
async def test_different_contacts_generate_without_blocking_each_other(service_context): async def test_different_contacts_generate_without_blocking_each_other(service_context):
session_factory, service, boxim, clock = 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() both_generating.wait()
return {"answer": f"回复{prompt[-1]}"} 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) 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} == { assert {(item["peerId"], item["content"]) for item in boxim.sent} == {
("200", "回复甲"), ("200", "回复甲"),
("300", "回复乙"), ("300", "回复乙"),
@@ -237,6 +370,34 @@ async def test_owner_message_cancels_pending_reply(service_context):
db.close() 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 @pytest.mark.asyncio
async def test_quick_successive_messages_are_coalesced_into_one_reply(service_context): async def test_quick_successive_messages_are_coalesced_into_one_reply(service_context):
session_factory, service, boxim, clock = 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( boxim.messages.append(
{"id": 31, "localId": 5, "sendId": 200, "recvId": 100, "sendTime": clock.millis(), "type": 0, "content": "第一句"} {"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) clock.advance(1)
boxim.messages.append( boxim.messages.append(
{"id": 32, "localId": 6, "sendId": 200, "recvId": 100, "sendTime": clock.millis(), "type": 0, "content": "第二句"} {"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: with patch("routers.chat._resolve_reply", return_value={"answer": "合并回复"}) as resolver:
await service.poll_and_process_messages() await service.poll_and_process_messages()
assert resolver.call_args.args[2] == "第一句\n第二句" assert resolver.call_args.args[2] == "第一句\n第二句"
clock.advance(3)
await service.poll_and_process_messages()
assert [item["content"] for item in boxim.sent] == ["合并回复"] assert [item["content"] for item in boxim.sent] == ["合并回复"]
db = session_factory() db = session_factory()
@@ -291,5 +451,41 @@ async def test_connection_failure_disables_takeover_and_stops_retrying(service_c
boxim.exchange_access_token.assert_awaited_once_with("prod-huihui-token") boxim.exchange_access_token.assert_awaited_once_with("prod-huihui-token")
@pytest.mark.asyncio
async def test_transient_connection_failure_keeps_takeover_and_recovers(service_context):
session_factory, service, boxim, _ = service_context
boxim.exchange_access_token = AsyncMock(
side_effect=[
BoxIMError("连接超时"),
{"accessToken": "box-token", "accessTokenExpiresIn": 3600},
]
)
await service.poll_messages()
db = session_factory()
try:
avatar = db.query(Avatar).one()
cursor = db.query(TakeoverCursor).one()
assert "takeover" in avatar.config["authorizationPermissions"]
assert cursor.initialized is False
assert "暂时连接失败" in cursor.last_error
finally:
db.close()
await service.poll_messages()
db = session_factory()
try:
avatar = db.query(Avatar).one()
cursor = db.query(TakeoverCursor).one()
assert "takeover" in avatar.config["authorizationPermissions"]
assert cursor.initialized is True
assert cursor.last_error == ""
finally:
db.close()
assert boxim.exchange_access_token.await_count == 2
def test_plain_text_reply_removes_markdown_and_empty_lines(): def test_plain_text_reply_removes_markdown_and_empty_lines():
assert _plain_text_reply("## 建议\n\n**不能自行用药**\n`必要时就医`") == "建议\n不能自行用药\n必要时就医" assert _plain_text_reply("## 建议\n\n**不能自行用药**\n`必要时就医`") == "建议\n不能自行用药\n必要时就医"
@@ -112,7 +112,7 @@ location /api/ {
4. A、B 两个会会用户分别进入时只能看到各自的数字分身与知识库,不会继承上一用户缓存。 4. A、B 两个会会用户分别进入时只能看到各自的数字分身与知识库,不会继承上一用户缓存。
5. 使用过期或伪造 token 时进入登录页并显示凭证失效,不得继续访问旧用户数据。 5. 使用过期或伪造 token 时进入登录页并显示凭证失效,不得继续访问旧用户数据。
6. 分身聊天 SSE 逐段输出正常,Markdown 正常渲染,知识库优先级和积分扣费正常。 6. 分身聊天 SSE 逐段输出正常,Markdown 正常渲染,知识库优先级和积分扣费正常。
7. 开启 BOXIM 主动接管后保持在线,收到消息、三秒回复、已读回执和主人发言暂停均正常。 7. 开启 BOXIM 主动接管后保持在线,默认三分钟回复、自定义等待时间、已读回执、分身防回环和主人发言暂停均正常。
8. 重建容器后数据库、头像、知识库文档仍存在,`/api/health` 返回成功。 8. 重建容器后数据库、头像、知识库文档仍存在,`/api/health` 返回成功。
9. `https://digital.99hui.com/api/health` 可访问,证书域名和有效期正确,HTTP 自动跳转 HTTPS。 9. `https://digital.99hui.com/api/health` 可访问,证书域名和有效期正确,HTTP 自动跳转 HTTPS。
10. 微信和支付宝各创建一笔最小套餐订单,未付款时积分不变;支付成功后回调到账一次,重复回调积分不重复增加。 10. 微信和支付宝各创建一笔最小套餐订单,未付款时积分不变;支付成功后回调到账一次,重复回调积分不重复增加。
+11 -2
View File
@@ -191,19 +191,28 @@ export type AvatarPermission = 'friend' | 'chat' | 'publish' | 'browse' | 'inter
export interface AvatarPermissionSettings { export interface AvatarPermissionSettings {
avatarId: string avatarId: string
permissions: AvatarPermission[] permissions: AvatarPermission[]
takeoverReplyDelaySeconds: number
disabledAvatarIds?: string[]
} }
export const getAvatarPermissionSettings = (avatarId: string) => export const getAvatarPermissionSettings = (avatarId: string) =>
request.get<AvatarPermissionSettings>(`/avatar/${avatarId}/permission-settings`) request.get<AvatarPermissionSettings>(`/avatar/${avatarId}/permission-settings`)
export const updateAvatarPermissionSettings = (avatarId: string, permissions: AvatarPermission[]) => export const updateAvatarPermissionSettings = (
request.put<AvatarPermissionSettings>(`/avatar/${avatarId}/permission-settings`, { permissions }) avatarId: string,
permissions: AvatarPermission[],
takeoverReplyDelaySeconds: number
) => request.put<AvatarPermissionSettings>(`/avatar/${avatarId}/permission-settings`, {
permissions,
takeoverReplyDelaySeconds,
})
export interface TakeoverStatus { export interface TakeoverStatus {
enabled: boolean enabled: boolean
status: 'disabled' | 'connecting' | 'ready' | 'needs_login' | 'error' status: 'disabled' | 'connecting' | 'ready' | 'needs_login' | 'error'
message: string message: string
pendingCount: number pendingCount: number
takeoverReplyDelaySeconds: number
lastPolledAt: string | null lastPolledAt: string | null
} }
@@ -64,7 +64,7 @@
<span class="permission-copy"> <span class="permission-copy">
<strong>{{ item.title }}</strong> <strong>{{ item.title }}</strong>
<small> <small>
{{ item.description }} {{ item.key === 'takeover' ? takeoverDescription : item.description }}
<span <span
v-if="item.key === 'takeover' && takeoverConnectionLabel" v-if="item.key === 'takeover' && takeoverConnectionLabel"
class="connection-state" class="connection-state"
@@ -79,6 +79,33 @@
</button> </button>
</section> </section>
<section v-if="permissionState.takeover" class="takeover-delay-card" aria-label="自动回复等待时间">
<div class="delay-heading">
<div>
<strong>自动回复等待时间</strong>
<small>等待期间主人发言会取消本次回复,最短 3 秒</small>
</div>
<span>{{ formattedTakeoverDelay }}</span>
</div>
<div class="delay-control">
<input
v-model.number="takeoverDelayValue"
type="number"
inputmode="numeric"
step="1"
:min="takeoverDelayUnit === 'minutes' ? 1 : 3"
:max="takeoverDelayUnit === 'minutes' ? 1440 : 86400"
aria-label="等待时间"
:disabled="loading || saving"
@blur="normalizeTakeoverDelay"
/>
<select v-model="takeoverDelayUnit" aria-label="等待时间单位" :disabled="loading || saving">
<option value="seconds">秒</option>
<option value="minutes">分钟</option>
</select>
</div>
</section>
<p v-if="errorMessage" class="error-message" role="alert">{{ errorMessage }}</p> <p v-if="errorMessage" class="error-message" role="alert">{{ errorMessage }}</p>
</template> </template>
@@ -171,7 +198,7 @@ const permissionItems: Array<{
{ {
key: 'takeover', key: 'takeover',
title: '分身主动接管聊天回复', title: '分身主动接管聊天回复',
description: '收到私聊消息 3 秒后回复,主人发言时暂停', description: '收到私聊消息后按设定时间回复,主人发言时暂停',
tone: 'cyan', tone: 'cyan',
}, },
] ]
@@ -190,6 +217,8 @@ const saving = ref(false)
const errorMessage = ref('') const errorMessage = ref('')
const toastMessage = ref('') const toastMessage = ref('')
const takeoverStatus = ref<TakeoverStatus | null>(null) const takeoverStatus = ref<TakeoverStatus | null>(null)
const takeoverDelayValue = ref(3)
const takeoverDelayUnit = ref<'seconds' | 'minutes'>('minutes')
let toastTimer: number | undefined let toastTimer: number | undefined
let takeoverStatusTimer: number | undefined let takeoverStatusTimer: number | undefined
@@ -210,6 +239,38 @@ const takeoverConnectionTone = computed(() => {
return 'connecting' 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 setPermissions = (permissions: AvatarPermission[]) => {
const enabled = new Set(permissions) const enabled = new Set(permissions)
for (const item of permissionItems) permissionState[item.key] = enabled.has(item.key) for (const item of permissionItems) permissionState[item.key] = enabled.has(item.key)
@@ -265,6 +326,7 @@ const loadSettings = async () => {
try { try {
const settings = await getAvatarPermissionSettings(activeAvatarId.value) const settings = await getAvatarPermissionSettings(activeAvatarId.value)
setPermissions(settings.permissions || []) setPermissions(settings.permissions || [])
applyTakeoverDelay(settings.takeoverReplyDelaySeconds || 180)
await loadTakeoverStatus() await loadTakeoverStatus()
scheduleTakeoverStatusRefresh() scheduleTakeoverStatusRefresh()
} catch (error: any) { } catch (error: any) {
@@ -291,8 +353,18 @@ const saveSettings = async (takeoverToggle = false): Promise<boolean> => {
saving.value = true saving.value = true
errorMessage.value = '' errorMessage.value = ''
try { 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 || []) setPermissions(settings.permissions || [])
applyTakeoverDelay(settings.takeoverReplyDelaySeconds || 180)
await loadTakeoverStatus() await loadTakeoverStatus()
scheduleTakeoverStatusRefresh() scheduleTakeoverStatusRefresh()
if (takeoverToggle) { if (takeoverToggle) {
@@ -468,6 +540,75 @@ svg {
min-height: 76px; 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 { .permission-icon {
width: 34px; width: 34px;
height: 34px; height: 34px;