Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2b02ddf30 |
@@ -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 180"),
|
||||
("authorizations", "takeover_delay_seconds", "INTEGER DEFAULT 30"),
|
||||
("avatars", "share_token", "VARCHAR DEFAULT NULL"),
|
||||
("token_account", "user_id", "VARCHAR DEFAULT ''"),
|
||||
("token_account", "total_granted", "BIGINT DEFAULT 0"),
|
||||
@@ -47,7 +47,6 @@ def init_db():
|
||||
("token_account", "updated_at", "TIMESTAMP"),
|
||||
)
|
||||
_normalize_optional_unique_values()
|
||||
_normalize_takeover_delays()
|
||||
_create_token_indexes()
|
||||
|
||||
|
||||
@@ -67,15 +66,6 @@ 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(
|
||||
|
||||
@@ -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=180) # 延迟秒数,默认 3 分钟
|
||||
takeover_delay_seconds = Column(Integer, default=30) # 延迟秒数
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
def to_dict(self):
|
||||
|
||||
@@ -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, Avatar, TakeoverCursor, TakeoverReplyTask
|
||||
from models import Authorization, TakeoverCursor, TakeoverReplyTask
|
||||
from responses import fail, ok
|
||||
from routers.avatars import _require_owned_avatar
|
||||
|
||||
@@ -14,10 +14,6 @@ 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",
|
||||
@@ -95,62 +91,9 @@ 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)
|
||||
@@ -201,19 +144,10 @@ def update_permission_settings(
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
avatar = _require_owned_avatar(db, avatar_id, authorization)
|
||||
if "permissions" not in payload and TAKEOVER_DELAY_KEY not in payload:
|
||||
return fail("缺少授权设置", 400)
|
||||
if "permissions" not in payload:
|
||||
return fail("缺少 permissions", 400)
|
||||
try:
|
||||
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)
|
||||
)
|
||||
permissions = _normalize_avatar_permissions(payload["permissions"])
|
||||
except ValueError as exc:
|
||||
return fail(str(exc), 400)
|
||||
|
||||
@@ -221,9 +155,7 @@ 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
|
||||
@@ -247,9 +179,7 @@ def update_permission_settings(
|
||||
task.locked_at = None
|
||||
db.commit()
|
||||
db.refresh(avatar)
|
||||
response = _permission_settings_payload(avatar)
|
||||
response["disabledAvatarIds"] = disabled_avatar_ids
|
||||
return ok(response, "授权设置已保存")
|
||||
return ok(_permission_settings_payload(avatar), "授权设置已保存")
|
||||
|
||||
|
||||
@router.get("/avatar/{avatar_id}/authorizations")
|
||||
@@ -310,7 +240,7 @@ def create_auth(
|
||||
status="active",
|
||||
takeover_enabled=False,
|
||||
takeover_mode="immediate",
|
||||
takeover_delay_seconds=DEFAULT_TAKEOVER_DELAY_SECONDS,
|
||||
takeover_delay_seconds=30,
|
||||
)
|
||||
db.add(item)
|
||||
db.commit()
|
||||
|
||||
@@ -6,17 +6,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from database import get_db
|
||||
from routers.knowledge import UPLOAD_DIR
|
||||
from models import (
|
||||
Authorization,
|
||||
Avatar,
|
||||
KnowledgeChunk,
|
||||
KnowledgeDoc,
|
||||
QAPair,
|
||||
TakeoverCursor,
|
||||
TakeoverMessage,
|
||||
TakeoverReplyTask,
|
||||
User,
|
||||
)
|
||||
from models import Avatar, KnowledgeDoc, KnowledgeChunk, QAPair, Authorization, User
|
||||
from responses import ok, fail
|
||||
|
||||
router = APIRouter(tags=["分身"])
|
||||
@@ -84,21 +74,18 @@ def list_avatars(page: int = 1, limit: int = 20, authorization: str = Header(Non
|
||||
|
||||
|
||||
@router.get("/avatar/{avatar_id}")
|
||||
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())
|
||||
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())
|
||||
|
||||
|
||||
@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,
|
||||
owner_id=user.huihui_user_id if user else "",
|
||||
name=payload.get("name", "未命名分身"),
|
||||
display_name=payload.get("displayName", "") or payload.get("display_name", ""),
|
||||
description=payload.get("description", ""),
|
||||
@@ -115,13 +102,10 @@ def create_avatar(payload: dict = Body(...), authorization: str = Header(None),
|
||||
|
||||
|
||||
@router.put("/avatar/{avatar_id}")
|
||||
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)
|
||||
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)
|
||||
mapping = {
|
||||
"displayName": "display_name",
|
||||
"photoUrl": "photo_url",
|
||||
@@ -130,32 +114,22 @@ def update_avatar(
|
||||
for key in ("name", "displayName", "description", "photoUrl", "emoji", "status", "tokenBalance", "config"):
|
||||
if key in payload:
|
||||
col = mapping.get(key, key)
|
||||
value = payload[key]
|
||||
if key == "config":
|
||||
if not isinstance(value, dict):
|
||||
return fail("分身配置格式不正确", 400)
|
||||
value = {**(a.config or {}), **value}
|
||||
setattr(a, col, value)
|
||||
setattr(a, col, payload[key])
|
||||
db.commit()
|
||||
db.refresh(a)
|
||||
return ok(a.to_dict())
|
||||
|
||||
|
||||
@router.delete("/avatar/{avatar_id}")
|
||||
def delete_avatar(
|
||||
avatar_id: str,
|
||||
authorization: str = Header(None),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
a = _require_owned_avatar(db, avatar_id, authorization)
|
||||
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)
|
||||
# 级联清理关联数据,避免孤儿记录
|
||||
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})
|
||||
|
||||
@@ -34,19 +34,6 @@ QA_SEMANTIC_THRESHOLD = 0.72
|
||||
QA_MATCH_MARGIN = 0.06
|
||||
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):
|
||||
role: str = Field(pattern="^(user|assistant)$")
|
||||
@@ -83,30 +70,6 @@ def _normalize_question(value: str) -> str:
|
||||
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:
|
||||
value = _normalize_question(value)
|
||||
replacements = (
|
||||
@@ -226,14 +189,7 @@ def _config(avatar: Avatar) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _build_prompt(
|
||||
avatar: Avatar,
|
||||
history: list[Any],
|
||||
question: str,
|
||||
knowledge_hits: list[dict],
|
||||
*,
|
||||
standard_answer: str = "",
|
||||
) -> list[dict]:
|
||||
def _build_prompt(avatar: Avatar, history: list[Any], question: str, knowledge_hits: list[dict]) -> list[dict]:
|
||||
config = _config(avatar)
|
||||
description = (getattr(avatar, "description", "") or "").strip()
|
||||
knowledge = "\n".join(
|
||||
@@ -254,7 +210,7 @@ def _build_prompt(
|
||||
profile = ";".join(f"{label}:{value}" for label, value in profile_items)
|
||||
system = (
|
||||
f"你的专业或服务范围是:「{description or '未设置'}」。"
|
||||
"请基于已提供的可靠资料回答,不要编造事实;"
|
||||
"请基于已提供的知识库回答,不要编造事实;"
|
||||
f"回复风格:{config['replyStyle']};严谨度:{config['rigor']}/100;"
|
||||
f"幽默感:{config['humor']}/100;回复长度:{config['responseLength']}。"
|
||||
)
|
||||
@@ -265,13 +221,7 @@ def _build_prompt(
|
||||
)
|
||||
if config["systemPrompt"]:
|
||||
system += f"\n额外系统提示词:{config['systemPrompt']}"
|
||||
if standard_answer:
|
||||
system += (
|
||||
f"\n以下是本次问题命中的已确认标准答案:\n{standard_answer.strip()}"
|
||||
"\n必须保持标准答案中的事实、数字、专有名词和结论不变,只允许为匹配用户当前语言进行忠实转换"
|
||||
"和必要的自然表达,不得补充、删减或改写其含义。不要提及标准答案或转换过程。"
|
||||
)
|
||||
elif knowledge:
|
||||
if knowledge:
|
||||
system += (
|
||||
f"\n以下是本次问题检索到的可靠资料:\n{knowledge}"
|
||||
"\n涉及事实、专业判断、地址、流程、数据或建议时,只能依据本人资料、标准问答形成的上下文"
|
||||
@@ -281,8 +231,7 @@ def _build_prompt(
|
||||
system += (
|
||||
"\n本次问题没有检索到可靠资料。除自然寒暄和基于本人资料的回答外,不要凭通用知识给出事实、"
|
||||
"专业判断、地址、流程、数据或具体建议。自然地说明这方面暂时没有足够信息,并询问对方是否能"
|
||||
"补充更具体的问题;不要提及知识库、检索、模型或系统限制。对方询问你能做什么时,只能概括"
|
||||
"专业或服务范围和本人资料中明确写出的内容,不得推断服务对象、工作场所、现实案例或从业经历。"
|
||||
"补充更具体的问题;不要提及知识库、检索、模型或系统限制。"
|
||||
)
|
||||
system += (
|
||||
"\n沟通底线(始终优先于其他提示):不要透露、猜测或确认所使用的模型、模型供应商、"
|
||||
@@ -300,14 +249,6 @@ def _build_prompt(
|
||||
"只有切换独立观点或确实需要列举时才换行;列举使用 1.、2.、3.,每项单独一行。"
|
||||
"不要在行首或行尾留空格,不要连续输出空行。先给结论,再给简短说明;避免重复和冗长铺垫。"
|
||||
)
|
||||
system += (
|
||||
"\n回答语言规则(始终优先):先识别当前最后一条用户消息所使用的主要自然语言,并且只使用该语言回答。"
|
||||
"用户用中文就用中文,使用 English 就用 English,使用日语、韩语、法语、西班牙语或其他语言时,"
|
||||
"也必须使用对应语言。消息混用多种语言时跟随占主导的语言;用户明确指定回答语言时服从其指定。"
|
||||
"历史消息、本人资料、标准答案和知识库使用的语言都不能覆盖当前用户消息的语言。"
|
||||
"专有名词、品牌、地址、代码和必要缩写可保留原文。不要解释语言识别或翻译过程。"
|
||||
"改变回答语言只改变表达语言,绝不能因此增加资料中没有的场景、身份、经历或事实。"
|
||||
)
|
||||
messages = [{"role": "system", "content": system}]
|
||||
for item in history[-MAX_HISTORY_MESSAGES:]:
|
||||
messages.append({"role": item.role, "content": item.content} if hasattr(item, "role") else item)
|
||||
@@ -443,30 +384,14 @@ def _resolve_reply(
|
||||
if qa_pairs is None:
|
||||
qa_pairs = db.query(QAPair).filter(QAPair.avatar_id == avatar.id).all()
|
||||
matched = _match_standard_qa(question, qa_pairs)
|
||||
adapt_qa_language = bool(
|
||||
matched and _qa_requires_language_adaptation(question, matched.answer)
|
||||
)
|
||||
if matched and not adapt_qa_language:
|
||||
if matched:
|
||||
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))
|
||||
hits = search_fn(question, avatar.id)
|
||||
messages = _build_prompt(avatar, history, question, hits)
|
||||
search_fn = search_fn or (lambda query, avatar_id: _search_knowledge(db, avatar_id, query))
|
||||
hits = search_fn(question, avatar.id)
|
||||
messages = _build_prompt(avatar, history, question, hits)
|
||||
config = _config(avatar)
|
||||
temperature = 0.0 if matched else min(
|
||||
0.45 if hits else 0.25,
|
||||
0.2 + config["creativity"] / 100 * 0.6,
|
||||
)
|
||||
temperature = min(0.45 if hits else 0.25, 0.2 + config["creativity"] / 100 * 0.6)
|
||||
token_usage = None
|
||||
if model_client is not None:
|
||||
answer = model_client(messages=messages, temperature=temperature)
|
||||
@@ -498,7 +423,7 @@ def _resolve_reply(
|
||||
raise
|
||||
result = {
|
||||
"answer": answer,
|
||||
"source": "qa" if matched else ("knowledge" if hits else "qwen"),
|
||||
"source": "knowledge" if hits else "qwen",
|
||||
"references": hits,
|
||||
}
|
||||
if token_usage:
|
||||
@@ -517,32 +442,14 @@ def _stream_reply(
|
||||
):
|
||||
qa_pairs = db.query(QAPair).filter(QAPair.avatar_id == avatar.id).all()
|
||||
matched = _match_standard_qa(question, qa_pairs)
|
||||
adapt_qa_language = bool(
|
||||
matched and _qa_requires_language_adaptation(question, matched.answer)
|
||||
)
|
||||
messages, reservation = [], None
|
||||
if matched and not adapt_qa_language:
|
||||
if matched:
|
||||
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:
|
||||
references = _search_knowledge(db, avatar.id, question)
|
||||
source = "knowledge" if references else "qwen"
|
||||
messages = _build_prompt(avatar, history, question, references)
|
||||
references = _search_knowledge(db, avatar.id, question)
|
||||
source = "knowledge" if references else "qwen"
|
||||
config = _config(avatar)
|
||||
temperature = 0.0 if matched else min(
|
||||
0.45 if references else 0.25,
|
||||
0.2 + config["creativity"] / 100 * 0.6,
|
||||
)
|
||||
temperature = min(0.45 if references else 0.25, 0.2 + config["creativity"] / 100 * 0.6)
|
||||
messages = _build_prompt(avatar, history, question, references)
|
||||
model_config = get_chat_model_config()
|
||||
reservation = reserve_avatar_tokens(
|
||||
db,
|
||||
@@ -553,6 +460,8 @@ def _stream_reply(
|
||||
model_config.max_tokens,
|
||||
)
|
||||
chunks = _iter_qwen_stream(messages, temperature, model_config)
|
||||
if matched:
|
||||
messages, reservation = [], None
|
||||
if public:
|
||||
source, references = "public", []
|
||||
|
||||
|
||||
@@ -8,25 +8,13 @@ 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 (
|
||||
DEFAULT_TAKEOVER_DELAY_SECONDS,
|
||||
MAX_TAKEOVER_DELAY_SECONDS,
|
||||
MIN_TAKEOVER_DELAY_SECONDS,
|
||||
_require_authorization,
|
||||
_stored_takeover_delay,
|
||||
)
|
||||
from routers.authorizations import _require_authorization
|
||||
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,
|
||||
@@ -36,7 +24,6 @@ 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 = (
|
||||
@@ -62,10 +49,7 @@ def get_takeover_status(
|
||||
and cursor.last_polled_at
|
||||
>= datetime.utcnow() - timedelta(seconds=BOXIM_STATUS_FRESH_SECONDS)
|
||||
):
|
||||
status, message = (
|
||||
"ready",
|
||||
f"BOXIM 已连接,收到私聊消息 {_delay_label(reply_delay_seconds)}后自动回复",
|
||||
)
|
||||
status, message = "ready", "BOXIM 已连接,收到私聊消息 3 秒后自动回复"
|
||||
else:
|
||||
status, message = "connecting", "正在连接 BOXIM"
|
||||
|
||||
@@ -75,7 +59,6 @@ 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,
|
||||
}
|
||||
)
|
||||
@@ -108,7 +91,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 DEFAULT_TAKEOVER_DELAY_SECONDS
|
||||
delay = auth.takeover_delay_seconds or 30
|
||||
|
||||
if _has(payload, "takeoverEnabled", "takeover_enabled"):
|
||||
raw_enabled = _read(payload, "takeoverEnabled", "takeover_enabled")
|
||||
@@ -123,12 +106,8 @@ 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 MIN_TAKEOVER_DELAY_SECONDS <= delay <= MAX_TAKEOVER_DELAY_SECONDS
|
||||
):
|
||||
return fail("延迟时间需在 3 秒到 24 小时之间", 400)
|
||||
if isinstance(delay, bool) or not isinstance(delay, int) or not 5 <= delay <= 3600:
|
||||
return fail("延迟时间需在 5 到 3600 秒之间", 400)
|
||||
|
||||
if enabled and auth.target_type != "user":
|
||||
return fail("本期仅支持对会会用户开启单聊接管", 400)
|
||||
|
||||
@@ -28,14 +28,6 @@ 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:
|
||||
@@ -78,43 +70,15 @@ 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, honor the owner grace period, then generate and send one reply."""
|
||||
"""Poll BOXIM, prepare replies during the grace period, then send at +3s."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
session_factory: Callable[[], Session],
|
||||
boxim_client: BoxIMClient,
|
||||
*,
|
||||
reply_delay_seconds: int | None = None,
|
||||
reply_delay_seconds: int = 3,
|
||||
now: Callable[[], datetime] = _utcnow,
|
||||
):
|
||||
self.session_factory = session_factory
|
||||
@@ -155,17 +119,11 @@ class TakeoverService:
|
||||
def _enabled_avatar_ids(self) -> list[str]:
|
||||
db = self.session_factory()
|
||||
try:
|
||||
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())
|
||||
return [
|
||||
avatar.id
|
||||
for avatar in db.query(Avatar).filter(Avatar.status == "active").all()
|
||||
if _takeover_enabled(avatar)
|
||||
]
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -243,20 +201,13 @@ class TakeoverService:
|
||||
def _forget_boxim_session(self, user_id: str):
|
||||
self._sessions.pop(user_id, None)
|
||||
|
||||
def _record_connection_failure(
|
||||
def _disable_after_connection_failure(
|
||||
self,
|
||||
db: Session,
|
||||
avatar: Avatar,
|
||||
cursor: TakeoverCursor,
|
||||
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", [])
|
||||
avatar.config = {
|
||||
**(avatar.config or {}),
|
||||
@@ -266,6 +217,8 @@ class TakeoverService:
|
||||
if permission != TAKEOVER_PERMISSION
|
||||
],
|
||||
}
|
||||
cursor.last_error = message
|
||||
cursor.last_polled_at = self.now()
|
||||
tasks = (
|
||||
db.query(TakeoverReplyTask)
|
||||
.filter(
|
||||
@@ -292,12 +245,11 @@ class TakeoverService:
|
||||
db.add(cursor)
|
||||
db.flush()
|
||||
if not user or not user.huihui_token:
|
||||
self._record_connection_failure(
|
||||
self._disable_after_connection_failure(
|
||||
db,
|
||||
avatar,
|
||||
cursor,
|
||||
"请重新登录会会生产账号后再开启主动接管",
|
||||
disable_takeover=True,
|
||||
)
|
||||
db.commit()
|
||||
return False
|
||||
@@ -316,24 +268,11 @@ class TakeoverService:
|
||||
if isinstance(exc, BoxIMError) and exc.auth_error:
|
||||
self._forget_boxim_session(user.id)
|
||||
message = "BOXIM 授权已失效,请重新登录会会生产账号"
|
||||
disable_takeover = True
|
||||
else:
|
||||
message = f"BOXIM 暂时连接失败:{str(exc)[:160]}"
|
||||
disable_takeover = False
|
||||
self._record_connection_failure(
|
||||
db,
|
||||
avatar,
|
||||
cursor,
|
||||
message,
|
||||
disable_takeover=disable_takeover,
|
||||
)
|
||||
self._disable_after_connection_failure(db, avatar, cursor, message)
|
||||
db.commit()
|
||||
logger.warning(
|
||||
"BOXIM sync failed for avatar %s (will_retry=%s): %s",
|
||||
avatar.id,
|
||||
not disable_takeover,
|
||||
exc,
|
||||
)
|
||||
logger.warning("BOXIM sync failed for avatar %s: %s", avatar.id, exc)
|
||||
return False
|
||||
|
||||
messages.sort(key=lambda item: (_numeric_id(item.get("id")), item.get("sendTime") or 0))
|
||||
@@ -411,21 +350,13 @@ class TakeoverService:
|
||||
|
||||
now = self.now()
|
||||
send_time = _boxim_time(message.get("sendTime"), now)
|
||||
is_avatar = _is_avatar_local_id(local_id)
|
||||
if not is_avatar and local_id:
|
||||
is_avatar = False
|
||||
if direction == "outgoing" 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()
|
||||
@@ -454,52 +385,8 @@ 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 = (
|
||||
@@ -540,11 +427,9 @@ 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=_configured_reply_delay(avatar, self.reply_delay_seconds)
|
||||
)
|
||||
due_at = event.send_time + timedelta(seconds=self.reply_delay_seconds)
|
||||
task_id = secrets.token_hex(16)
|
||||
local_id = _avatar_local_id(avatar.owner_id, event.boxim_message_id)
|
||||
local_id = int(time.time() * 1000) * 1000 + secrets.randbelow(1000)
|
||||
db.add(
|
||||
TakeoverReplyTask(
|
||||
id=task_id,
|
||||
@@ -570,7 +455,6 @@ 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)
|
||||
@@ -617,7 +501,6 @@ 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)
|
||||
@@ -627,8 +510,6 @@ 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",
|
||||
@@ -706,15 +587,6 @@ 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)
|
||||
|
||||
@@ -103,7 +103,6 @@ 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(
|
||||
@@ -116,7 +115,6 @@ 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):
|
||||
@@ -158,57 +156,3 @@ 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()
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
"""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,15 +5,7 @@ from unittest.mock import Mock
|
||||
from fastapi import HTTPException
|
||||
|
||||
from models import Avatar, User
|
||||
from routers.chat import (
|
||||
_build_prompt,
|
||||
_iter_text_chunks,
|
||||
_match_standard_qa,
|
||||
_public_avatar_payload,
|
||||
_qa_requires_language_adaptation,
|
||||
_require_owned_avatar,
|
||||
_resolve_reply,
|
||||
)
|
||||
from routers.chat import _build_prompt, _iter_text_chunks, _match_standard_qa, _public_avatar_payload, _require_owned_avatar, _resolve_reply
|
||||
|
||||
|
||||
class ChatOrchestrationTests(unittest.TestCase):
|
||||
@@ -58,34 +50,6 @@ class ChatOrchestrationTests(unittest.TestCase):
|
||||
self.assertEqual(result["answer"], "标准地址")
|
||||
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):
|
||||
for question in ("请问一下,你们公司在哪里呀?", "请问去你们那边怎么走"):
|
||||
with self.subTest(question=question):
|
||||
@@ -142,9 +106,6 @@ 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"])
|
||||
|
||||
def test_prompt_blocks_ungrounded_factual_answers(self):
|
||||
messages = _build_prompt(self.avatar, [], "聊聊国际新闻", [])
|
||||
@@ -152,8 +113,6 @@ class ChatOrchestrationTests(unittest.TestCase):
|
||||
self.assertIn("没有检索到可靠资料", system)
|
||||
self.assertIn("不要凭通用知识", system)
|
||||
self.assertIn("不要提及知识库", system)
|
||||
self.assertIn("不得推断服务对象", system)
|
||||
self.assertIn("工作场所", system)
|
||||
|
||||
def test_public_avatar_payload_excludes_internal_configuration(self):
|
||||
payload = _public_avatar_payload(self.avatar)
|
||||
|
||||
@@ -6,7 +6,7 @@ from fastapi.testclient import TestClient
|
||||
|
||||
from database import SessionLocal
|
||||
from main import app
|
||||
from models import Avatar, KnowledgeChunk, KnowledgeDoc, QAPair
|
||||
from models import KnowledgeChunk, KnowledgeDoc
|
||||
from routers.knowledge import _doc_payload
|
||||
|
||||
|
||||
@@ -92,94 +92,3 @@ 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()
|
||||
|
||||
@@ -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 == 180
|
||||
assert auth.takeover_delay_seconds == 30
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@@ -11,12 +11,7 @@ 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 (
|
||||
AVATAR_LOCAL_ID_PREFIX,
|
||||
TakeoverService,
|
||||
_avatar_local_id,
|
||||
_plain_text_reply,
|
||||
)
|
||||
from services.takeover_service import TakeoverService, _plain_text_reply
|
||||
|
||||
|
||||
class Clock:
|
||||
@@ -82,10 +77,7 @@ def service_context(tmp_path):
|
||||
owner_id=user.huihui_user_id,
|
||||
name="分身",
|
||||
status="active",
|
||||
config={
|
||||
"authorizationPermissions": ["chat", "takeover"],
|
||||
"takeoverReplyDelaySeconds": 3,
|
||||
},
|
||||
config={"authorizationPermissions": ["chat", "takeover"]},
|
||||
)
|
||||
db.add_all([user, avatar])
|
||||
db.commit()
|
||||
@@ -128,7 +120,8 @@ 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": "你好"}
|
||||
)
|
||||
|
||||
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 == []
|
||||
assert boxim.read_receipts == [{"friendId": "200", "messageId": "11"}]
|
||||
|
||||
@@ -137,8 +130,7 @@ async def test_incoming_message_is_prepared_then_sent_at_three_seconds(service_c
|
||||
assert boxim.sent == []
|
||||
|
||||
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"]}]
|
||||
|
||||
db = session_factory()
|
||||
@@ -150,131 +142,6 @@ 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
|
||||
@@ -291,11 +158,11 @@ async def test_different_contacts_generate_without_blocking_each_other(service_c
|
||||
both_generating.wait()
|
||||
return {"answer": f"回复{prompt[-1]}"}
|
||||
|
||||
await service.poll_and_process_messages()
|
||||
|
||||
clock.advance(3)
|
||||
with patch("routers.chat._resolve_reply", side_effect=resolve):
|
||||
await service.poll_and_process_messages()
|
||||
|
||||
clock.advance(3)
|
||||
await service.process_reply_tasks()
|
||||
assert {(item["peerId"], item["content"]) for item in boxim.sent} == {
|
||||
("200", "回复甲"),
|
||||
("300", "回复乙"),
|
||||
@@ -370,34 +237,6 @@ 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
|
||||
@@ -405,18 +244,19 @@ 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": "第一句"}
|
||||
)
|
||||
await service.poll_and_process_messages()
|
||||
with patch("routers.chat._resolve_reply", return_value={"answer": "第一版"}):
|
||||
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()
|
||||
@@ -451,41 +291,5 @@ async def test_connection_failure_disables_takeover_and_stops_retrying(service_c
|
||||
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():
|
||||
assert _plain_text_reply("## 建议\n\n**不能自行用药**\n`必要时就医`") == "建议\n不能自行用药\n必要时就医"
|
||||
|
||||
@@ -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. 微信和支付宝各创建一笔最小套餐订单,未付款时积分不变;支付成功后回调到账一次,重复回调积分不重复增加。
|
||||
|
||||
@@ -191,28 +191,19 @@ 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<AvatarPermissionSettings>(`/avatar/${avatarId}/permission-settings`)
|
||||
|
||||
export const updateAvatarPermissionSettings = (
|
||||
avatarId: string,
|
||||
permissions: AvatarPermission[],
|
||||
takeoverReplyDelaySeconds: number
|
||||
) => request.put<AvatarPermissionSettings>(`/avatar/${avatarId}/permission-settings`, {
|
||||
permissions,
|
||||
takeoverReplyDelaySeconds,
|
||||
})
|
||||
export const updateAvatarPermissionSettings = (avatarId: string, permissions: AvatarPermission[]) =>
|
||||
request.put<AvatarPermissionSettings>(`/avatar/${avatarId}/permission-settings`, { permissions })
|
||||
|
||||
export interface TakeoverStatus {
|
||||
enabled: boolean
|
||||
status: 'disabled' | 'connecting' | 'ready' | 'needs_login' | 'error'
|
||||
message: string
|
||||
pendingCount: number
|
||||
takeoverReplyDelaySeconds: number
|
||||
lastPolledAt: string | null
|
||||
}
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@
|
||||
<span class="permission-copy">
|
||||
<strong>{{ item.title }}</strong>
|
||||
<small>
|
||||
{{ item.key === 'takeover' ? takeoverDescription : item.description }}
|
||||
{{ item.description }}
|
||||
<span
|
||||
v-if="item.key === 'takeover' && takeoverConnectionLabel"
|
||||
class="connection-state"
|
||||
@@ -79,33 +79,6 @@
|
||||
</button>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
@@ -198,7 +171,7 @@ const permissionItems: Array<{
|
||||
{
|
||||
key: 'takeover',
|
||||
title: '分身主动接管聊天回复',
|
||||
description: '收到私聊消息后按设定时间回复,主人发言时暂停',
|
||||
description: '收到私聊消息 3 秒后回复,主人发言时暂停',
|
||||
tone: 'cyan',
|
||||
},
|
||||
]
|
||||
@@ -217,8 +190,6 @@ const saving = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const toastMessage = ref('')
|
||||
const takeoverStatus = ref<TakeoverStatus | null>(null)
|
||||
const takeoverDelayValue = ref(3)
|
||||
const takeoverDelayUnit = ref<'seconds' | 'minutes'>('minutes')
|
||||
let toastTimer: number | undefined
|
||||
let takeoverStatusTimer: number | undefined
|
||||
|
||||
@@ -239,38 +210,6 @@ 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)
|
||||
@@ -326,7 +265,6 @@ const loadSettings = async () => {
|
||||
try {
|
||||
const settings = await getAvatarPermissionSettings(activeAvatarId.value)
|
||||
setPermissions(settings.permissions || [])
|
||||
applyTakeoverDelay(settings.takeoverReplyDelaySeconds || 180)
|
||||
await loadTakeoverStatus()
|
||||
scheduleTakeoverStatusRefresh()
|
||||
} catch (error: any) {
|
||||
@@ -353,18 +291,8 @@ const saveSettings = async (takeoverToggle = false): Promise<boolean> => {
|
||||
saving.value = true
|
||||
errorMessage.value = ''
|
||||
try {
|
||||
normalizeTakeoverDelay()
|
||||
if (takeoverDelaySeconds.value < 3 || takeoverDelaySeconds.value > 86400) {
|
||||
errorMessage.value = '自动回复等待时间需在 3 秒到 24 小时之间'
|
||||
return false
|
||||
}
|
||||
const settings = await updateAvatarPermissionSettings(
|
||||
activeAvatarId.value,
|
||||
selectedPermissions(),
|
||||
takeoverDelaySeconds.value,
|
||||
)
|
||||
const settings = await updateAvatarPermissionSettings(activeAvatarId.value, selectedPermissions())
|
||||
setPermissions(settings.permissions || [])
|
||||
applyTakeoverDelay(settings.takeoverReplyDelaySeconds || 180)
|
||||
await loadTakeoverStatus()
|
||||
scheduleTakeoverStatusRefresh()
|
||||
if (takeoverToggle) {
|
||||
@@ -540,75 +468,6 @@ 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;
|
||||
|
||||
Reference in New Issue
Block a user