feat(avatar): 优化会会 H5 嵌入管理流程 #8
@@ -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(
|
||||||
|
|||||||
@@ -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):
|
||||||
@@ -188,7 +188,7 @@ class KnowledgeDoc(Base):
|
|||||||
file_type = Column(String, default="") # pdf | doc | docx | xlsx
|
file_type = Column(String, default="") # pdf | doc | docx | xlsx
|
||||||
file_size = Column(Integer, default=0)
|
file_size = Column(Integer, default=0)
|
||||||
file_url = Column(String, default="")
|
file_url = Column(String, default="")
|
||||||
status = Column(String, default="uploaded") # uploaded | parsing | ready
|
status = Column(String, default="uploaded") # uploaded | parsing | ready | failed
|
||||||
vectorized = Column(Boolean, default=False) # 是否已向量化
|
vectorized = Column(Boolean, default=False) # 是否已向量化
|
||||||
embedding_model = Column(String, default="") # 向量模型标识
|
embedding_model = Column(String, default="") # 向量模型标识
|
||||||
chunk_count = Column(Integer, default=0) # 切片数量
|
chunk_count = Column(Integer, default=0) # 切片数量
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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})
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
@@ -13,6 +14,7 @@ from responses import ok, fail
|
|||||||
import embeddings
|
import embeddings
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
UPLOAD_DIR = os.path.abspath(os.getenv("UPLOAD_DIR", os.path.join(BASE_DIR, "uploads")))
|
UPLOAD_DIR = os.path.abspath(os.getenv("UPLOAD_DIR", os.path.join(BASE_DIR, "uploads")))
|
||||||
@@ -69,6 +71,15 @@ def list_docs(avatar_id: str, authorization: str = Header(None), db: Session = D
|
|||||||
.order_by(KnowledgeDoc.created_at.desc())
|
.order_by(KnowledgeDoc.created_at.desc())
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
|
# Older synchronous uploads could be interrupted after persisting "parsing".
|
||||||
|
# New uploads are committed only after indexing finishes, so these rows are stale.
|
||||||
|
stale_docs = [doc for doc in docs if doc.status == "parsing"]
|
||||||
|
if stale_docs:
|
||||||
|
for doc in stale_docs:
|
||||||
|
doc.status = "failed"
|
||||||
|
doc.vectorized = False
|
||||||
|
doc.chunk_count = 0
|
||||||
|
db.commit()
|
||||||
return ok([_doc_payload(d) for d in docs])
|
return ok([_doc_payload(d) for d in docs])
|
||||||
|
|
||||||
|
|
||||||
@@ -88,6 +99,7 @@ async def upload_doc(avatar_id: str, file: UploadFile = File(...), authorization
|
|||||||
with open(path, "wb") as f:
|
with open(path, "wb") as f:
|
||||||
f.write(content)
|
f.write(content)
|
||||||
doc = KnowledgeDoc(
|
doc = KnowledgeDoc(
|
||||||
|
id=uuid.uuid4().hex,
|
||||||
avatar_id=avatar_id,
|
avatar_id=avatar_id,
|
||||||
filename=file.filename,
|
filename=file.filename,
|
||||||
file_type=ext.lstrip("."),
|
file_type=ext.lstrip("."),
|
||||||
@@ -95,39 +107,47 @@ async def upload_doc(avatar_id: str, file: UploadFile = File(...), authorization
|
|||||||
file_url=f"/api/files/{avatar_id}/{stored}",
|
file_url=f"/api/files/{avatar_id}/{stored}",
|
||||||
status="parsing",
|
status="parsing",
|
||||||
)
|
)
|
||||||
db.add(doc)
|
|
||||||
db.commit()
|
|
||||||
db.refresh(doc)
|
|
||||||
|
|
||||||
# 向量化:抽取文本 -> 分块 -> 调第三方/本地嵌入 -> 存切片
|
# Complete extraction and embedding before the first database commit so a
|
||||||
|
# process restart cannot leave a permanent "parsing" row behind.
|
||||||
try:
|
try:
|
||||||
text = embeddings.extract_text(path, ext)
|
text = embeddings.extract_text(path, ext)
|
||||||
chunks = embeddings.chunk_text(text)
|
chunks = embeddings.chunk_text(text)
|
||||||
if chunks:
|
if not chunks:
|
||||||
|
raise ValueError("文档没有可建立索引的文字内容")
|
||||||
vectors = embeddings.embed(chunks)
|
vectors = embeddings.embed(chunks)
|
||||||
for i, (c, v) in enumerate(zip(chunks, vectors)):
|
if len(vectors) != len(chunks):
|
||||||
db.add(
|
raise ValueError("向量服务返回数量与文档分段不一致")
|
||||||
KnowledgeChunk(
|
|
||||||
doc_id=doc.id,
|
|
||||||
avatar_id=avatar_id,
|
|
||||||
content=c,
|
|
||||||
vector=json.dumps(v),
|
|
||||||
chunk_index=i,
|
|
||||||
embedding_model=embeddings.MODEL,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
doc.vectorized = True
|
doc.vectorized = True
|
||||||
doc.embedding_model = embeddings.MODEL
|
doc.embedding_model = embeddings.MODEL
|
||||||
doc.chunk_count = len(chunks)
|
doc.chunk_count = len(chunks)
|
||||||
doc.vectorized_at = datetime.now(timezone.utc)
|
doc.vectorized_at = datetime.now(timezone.utc)
|
||||||
doc.status = "ready"
|
doc.status = "ready"
|
||||||
|
db.add(doc)
|
||||||
|
for i, (chunk, vector) in enumerate(zip(chunks, vectors)):
|
||||||
|
db.add(
|
||||||
|
KnowledgeChunk(
|
||||||
|
doc_id=doc.id,
|
||||||
|
avatar_id=avatar_id,
|
||||||
|
content=chunk,
|
||||||
|
vector=json.dumps(vector),
|
||||||
|
chunk_index=i,
|
||||||
|
embedding_model=embeddings.MODEL,
|
||||||
|
)
|
||||||
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(doc)
|
db.refresh(doc)
|
||||||
except Exception as e:
|
except Exception as exc:
|
||||||
print("vectorize failed:", e)
|
db.rollback()
|
||||||
doc.status = "ready" # 上传成功但向量化失败,仍可展示
|
doc.status = "failed"
|
||||||
|
doc.vectorized = False
|
||||||
|
doc.embedding_model = ""
|
||||||
|
doc.chunk_count = 0
|
||||||
|
doc.vectorized_at = None
|
||||||
|
db.add(doc)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(doc)
|
db.refresh(doc)
|
||||||
|
logger.exception("knowledge vectorization failed for %s: %s", doc.id, exc)
|
||||||
|
|
||||||
return ok(_doc_payload(doc))
|
return ok(_doc_payload(doc))
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
@@ -369,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()
|
||||||
@@ -404,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 = (
|
||||||
@@ -446,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,
|
||||||
@@ -474,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)
|
||||||
@@ -520,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)
|
||||||
@@ -529,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",
|
||||||
@@ -606,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()
|
||||||
@@ -2,9 +2,17 @@ from pathlib import Path
|
|||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from database import SessionLocal
|
||||||
|
from main import app
|
||||||
|
from models import Avatar, KnowledgeChunk, KnowledgeDoc, QAPair
|
||||||
from routers.knowledge import _doc_payload
|
from routers.knowledge import _doc_payload
|
||||||
|
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
def test_doc_payload_reports_whether_the_persisted_file_exists(tmp_path: Path):
|
def test_doc_payload_reports_whether_the_persisted_file_exists(tmp_path: Path):
|
||||||
avatar_id = "avatar-1"
|
avatar_id = "avatar-1"
|
||||||
stored_name = "knowledge.md"
|
stored_name = "knowledge.md"
|
||||||
@@ -21,3 +29,157 @@ def test_doc_payload_reports_whether_the_persisted_file_exists(tmp_path: Path):
|
|||||||
assert _doc_payload(doc)["filePresent"] is False
|
assert _doc_payload(doc)["filePresent"] is False
|
||||||
stored_file.write_text("knowledge", encoding="utf-8")
|
stored_file.write_text("knowledge", encoding="utf-8")
|
||||||
assert _doc_payload(doc)["filePresent"] is True
|
assert _doc_payload(doc)["filePresent"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_marks_vectorization_failure_instead_of_staying_processing(
|
||||||
|
tmp_path: Path,
|
||||||
|
authorization_context,
|
||||||
|
):
|
||||||
|
context = authorization_context
|
||||||
|
with (
|
||||||
|
patch("routers.knowledge.UPLOAD_DIR", str(tmp_path)),
|
||||||
|
patch("routers.knowledge.embeddings.embed", side_effect=RuntimeError("provider unavailable")),
|
||||||
|
):
|
||||||
|
response = client.post(
|
||||||
|
f"/api/avatar/{context['avatar'].id}/knowledge/docs",
|
||||||
|
headers=context["owner_headers"],
|
||||||
|
files={"file": ("knowledge.md", b"# Knowledge\n\nTest content", "text/markdown")},
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = response.json()["data"]
|
||||||
|
assert payload["status"] == "failed"
|
||||||
|
assert payload["vectorized"] is False
|
||||||
|
assert payload["chunkCount"] == 0
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
stored = db.query(KnowledgeDoc).filter(KnowledgeDoc.id == payload["id"]).one()
|
||||||
|
assert stored.status == "failed"
|
||||||
|
assert db.query(KnowledgeChunk).filter(KnowledgeChunk.doc_id == stored.id).count() == 0
|
||||||
|
db.delete(stored)
|
||||||
|
db.commit()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_markdown_upload_commits_ready_document_and_chunks_together(
|
||||||
|
tmp_path: Path,
|
||||||
|
authorization_context,
|
||||||
|
):
|
||||||
|
context = authorization_context
|
||||||
|
with (
|
||||||
|
patch("routers.knowledge.UPLOAD_DIR", str(tmp_path)),
|
||||||
|
patch("routers.knowledge.embeddings.embed", return_value=[[1.0, 0.0]]),
|
||||||
|
):
|
||||||
|
response = client.post(
|
||||||
|
f"/api/avatar/{context['avatar'].id}/knowledge/docs",
|
||||||
|
headers=context["owner_headers"],
|
||||||
|
files={"file": ("knowledge.md", b"# Knowledge\n\nTest content", "text/markdown")},
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = response.json()["data"]
|
||||||
|
assert payload["status"] == "ready"
|
||||||
|
assert payload["vectorized"] is True
|
||||||
|
assert payload["chunkCount"] == 1
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
stored = db.query(KnowledgeDoc).filter(KnowledgeDoc.id == payload["id"]).one()
|
||||||
|
assert stored.status == "ready"
|
||||||
|
assert db.query(KnowledgeChunk).filter(KnowledgeChunk.doc_id == stored.id).count() == 1
|
||||||
|
db.query(KnowledgeChunk).filter(KnowledgeChunk.doc_id == stored.id).delete()
|
||||||
|
db.delete(stored)
|
||||||
|
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 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()
|
||||||
|
|||||||
@@ -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. 微信和支付宝各创建一笔最小套餐订单,未付款时积分不变;支付成功后回调到账一次,重复回调积分不重复增加。
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
type SmsLoginResult,
|
type SmsLoginResult,
|
||||||
type UserProfile
|
type UserProfile
|
||||||
} from '@/api'
|
} from '@/api'
|
||||||
|
import { clearHuihuiEmbeddedMode, markHuihuiEmbeddedMode } from '@/utils/embed-mode'
|
||||||
|
|
||||||
const TOKEN_KEY = 'hh_app_token'
|
const TOKEN_KEY = 'hh_app_token'
|
||||||
const USER_KEY = 'hh_app_user'
|
const USER_KEY = 'hh_app_user'
|
||||||
@@ -70,16 +71,23 @@ export const useUserStore = defineStore('smsuser', () => {
|
|||||||
|
|
||||||
// 短信登录
|
// 短信登录
|
||||||
const login = async (phone: string, code: string) => {
|
const login = async (phone: string, code: string) => {
|
||||||
return acceptLogin(await loginBySms(phone, code))
|
const result = await loginBySms(phone, code)
|
||||||
|
clearHuihuiEmbeddedMode()
|
||||||
|
return acceptLogin(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 账号密码登录
|
// 账号密码登录
|
||||||
const loginByPwd = async (account: string, password: string) => {
|
const loginByPwd = async (account: string, password: string) => {
|
||||||
return acceptLogin(await loginByPassword(account, password))
|
const result = await loginByPassword(account, password)
|
||||||
|
clearHuihuiEmbeddedMode()
|
||||||
|
return acceptLogin(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
const loginByToken = async (huihuiToken: string) =>
|
const loginByToken = async (huihuiToken: string) => {
|
||||||
acceptLogin(await loginByHuihuiToken(huihuiToken))
|
const result = await loginByHuihuiToken(huihuiToken)
|
||||||
|
markHuihuiEmbeddedMode()
|
||||||
|
return acceptLogin(result)
|
||||||
|
}
|
||||||
|
|
||||||
// 退出
|
// 退出
|
||||||
const logout = async () => {
|
const logout = async () => {
|
||||||
@@ -88,6 +96,7 @@ export const useUserStore = defineStore('smsuser', () => {
|
|||||||
} catch {
|
} catch {
|
||||||
/* 忽略网络错误,本地清除即可 */
|
/* 忽略网络错误,本地清除即可 */
|
||||||
}
|
}
|
||||||
|
clearHuihuiEmbeddedMode()
|
||||||
clearSession()
|
clearSession()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
const HUIHUI_EMBED_MODE_KEY = 'hh_huihui_embed_mode'
|
||||||
|
|
||||||
|
export function markHuihuiEmbeddedMode(): void {
|
||||||
|
sessionStorage.setItem(HUIHUI_EMBED_MODE_KEY, '1')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearHuihuiEmbeddedMode(): void {
|
||||||
|
sessionStorage.removeItem(HUIHUI_EMBED_MODE_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isHuihuiEmbeddedMode(): boolean {
|
||||||
|
return sessionStorage.getItem(HUIHUI_EMBED_MODE_KEY) === '1'
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="authorization-page">
|
<div class="authorization-page" :class="{ embedded: isEmbedded }">
|
||||||
<header class="page-header">
|
<header v-if="!isEmbedded" class="page-header">
|
||||||
<button class="back-button" type="button" aria-label="返回数字分身管理" @click="goBack">
|
<button class="back-button" type="button" aria-label="返回数字分身管理" @click="goBack">
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
<path d="m15 18-6-6 6-6" />
|
<path d="m15 18-6-6 6-6" />
|
||||||
@@ -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>
|
||||||
|
|
||||||
@@ -95,6 +122,9 @@
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer v-if="activeAvatarId" class="save-area">
|
<footer v-if="activeAvatarId" class="save-area">
|
||||||
|
<button v-if="isEmbedded" class="footer-back-button" type="button" :disabled="saving" @click="goBack">
|
||||||
|
返回
|
||||||
|
</button>
|
||||||
<button class="save-button" type="button" :disabled="loading || saving" @click="saveSettings()">
|
<button class="save-button" type="button" :disabled="loading || saving" @click="saveSettings()">
|
||||||
<span v-if="saving" class="saving-spinner" aria-hidden="true"></span>
|
<span v-if="saving" class="saving-spinner" aria-hidden="true"></span>
|
||||||
{{ saving ? '保存中...' : '保存授权设置' }}
|
{{ saving ? '保存中...' : '保存授权设置' }}
|
||||||
@@ -119,6 +149,7 @@ import {
|
|||||||
} from '@/api'
|
} from '@/api'
|
||||||
import { useAvatarStore } from '@/store/avatar'
|
import { useAvatarStore } from '@/store/avatar'
|
||||||
import { pickScopedAvatarId } from '@/utils/avatar-page-data.js'
|
import { pickScopedAvatarId } from '@/utils/avatar-page-data.js'
|
||||||
|
import { isHuihuiEmbeddedMode } from '@/utils/embed-mode'
|
||||||
|
|
||||||
type PermissionState = Record<AvatarPermission, boolean>
|
type PermissionState = Record<AvatarPermission, boolean>
|
||||||
|
|
||||||
@@ -126,6 +157,7 @@ const router = useRouter()
|
|||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const avatarStore = useAvatarStore()
|
const avatarStore = useAvatarStore()
|
||||||
const avatarId = computed(() => pickScopedAvatarId(route.params.avatarId, avatarStore.currentAvatarId, avatarStore.avatars))
|
const avatarId = computed(() => pickScopedAvatarId(route.params.avatarId, avatarStore.currentAvatarId, avatarStore.avatars))
|
||||||
|
const isEmbedded = isHuihuiEmbeddedMode()
|
||||||
|
|
||||||
const permissionItems: Array<{
|
const permissionItems: Array<{
|
||||||
key: AvatarPermission
|
key: AvatarPermission
|
||||||
@@ -166,7 +198,7 @@ const permissionItems: Array<{
|
|||||||
{
|
{
|
||||||
key: 'takeover',
|
key: 'takeover',
|
||||||
title: '分身主动接管聊天回复',
|
title: '分身主动接管聊天回复',
|
||||||
description: '收到私聊消息 3 秒后回复,主人发言时暂停',
|
description: '收到私聊消息后按设定时间回复,主人发言时暂停',
|
||||||
tone: 'cyan',
|
tone: 'cyan',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -185,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
|
||||||
|
|
||||||
@@ -205,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)
|
||||||
@@ -260,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) {
|
||||||
@@ -286,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) {
|
||||||
@@ -394,6 +471,10 @@ svg {
|
|||||||
padding: 0 20px;
|
padding: 0 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.authorization-page.embedded .page-content {
|
||||||
|
padding-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
.permission-intro {
|
.permission-intro {
|
||||||
min-height: 96px;
|
min-height: 96px;
|
||||||
padding: 15px 16px 14px;
|
padding: 15px 16px 14px;
|
||||||
@@ -459,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;
|
||||||
@@ -599,12 +749,15 @@ svg {
|
|||||||
bottom: 0;
|
bottom: 0;
|
||||||
width: min(100%, 390px);
|
width: min(100%, 390px);
|
||||||
padding: 12px 20px calc(20px + env(safe-area-inset-bottom));
|
padding: 12px 20px calc(20px + env(safe-area-inset-bottom));
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
background: linear-gradient(to bottom, rgba(250, 250, 250, 0), #fafafa 20%, #fafafa 100%);
|
background: linear-gradient(to bottom, rgba(250, 250, 250, 0), #fafafa 20%, #fafafa 100%);
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.save-button {
|
.save-button {
|
||||||
width: 100%;
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
height: 48px;
|
height: 48px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -620,6 +773,20 @@ svg {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.footer-back-button {
|
||||||
|
flex: 0 0 96px;
|
||||||
|
height: 48px;
|
||||||
|
border: 1px solid #eadfd6;
|
||||||
|
border-radius: 24px;
|
||||||
|
color: #6f665f;
|
||||||
|
background: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-back-button:disabled { opacity: .58; }
|
||||||
|
|
||||||
.save-button:disabled {
|
.save-button:disabled {
|
||||||
opacity: .68;
|
opacity: .68;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="edit-avatar-page">
|
<div class="edit-avatar-page">
|
||||||
<!-- 顶部导航 -->
|
<!-- 顶部导航 -->
|
||||||
<header class="page-header">
|
<header v-if="!isEmbedded" class="page-header">
|
||||||
<button class="back-btn" @click="goBack">‹</button>
|
<button class="back-btn" @click="goBack">‹</button>
|
||||||
<h1 class="page-title">分身微调</h1>
|
<h1 class="page-title">分身微调</h1>
|
||||||
<button class="save-btn" :disabled="loading || saving || uploadingPhoto" @click="saveChanges">
|
<span class="header-spacer" aria-hidden="true"></span>
|
||||||
{{ saving ? '保存中...' : '保存' }}
|
|
||||||
</button>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div v-if="loading" class="status-banner">加载中...</div>
|
<div v-if="loading" class="status-banner">加载中...</div>
|
||||||
@@ -159,6 +157,13 @@
|
|||||||
{{ deleting ? '删除中...' : '删除数字分身' }}
|
{{ deleting ? '删除中...' : '删除数字分身' }}
|
||||||
</button>
|
</button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<footer class="edit-action-bar">
|
||||||
|
<button class="action-back-btn" type="button" :disabled="saving" @click="goBack">返回</button>
|
||||||
|
<button class="action-save-btn" type="button" :disabled="loading || saving || uploadingPhoto" @click="saveChanges">
|
||||||
|
{{ saving ? '保存中...' : '保存修改' }}
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -168,11 +173,13 @@ import { useRoute, useRouter } from 'vue-router'
|
|||||||
import { deleteAvatar as apiDeleteAvatar, getAvatarDetail, updateAvatar, uploadAvatarPhoto } from '@/api'
|
import { deleteAvatar as apiDeleteAvatar, getAvatarDetail, updateAvatar, uploadAvatarPhoto } from '@/api'
|
||||||
import { useAvatarStore } from '@/store/avatar'
|
import { useAvatarStore } from '@/store/avatar'
|
||||||
import { buildAvatarUpdatePayload, normalizeAvatarEditForm } from '@/utils/avatar-page-data.js'
|
import { buildAvatarUpdatePayload, normalizeAvatarEditForm } from '@/utils/avatar-page-data.js'
|
||||||
|
import { isHuihuiEmbeddedMode } from '@/utils/embed-mode'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const avatarStore = useAvatarStore()
|
const avatarStore = useAvatarStore()
|
||||||
const avatarId = route.params.id as string
|
const avatarId = route.params.id as string
|
||||||
|
const isEmbedded = isHuihuiEmbeddedMode()
|
||||||
|
|
||||||
// 表单数据
|
// 表单数据
|
||||||
const formData = reactive({
|
const formData = reactive({
|
||||||
@@ -288,7 +295,7 @@ onMounted(async () => {
|
|||||||
.edit-avatar-page {
|
.edit-avatar-page {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: #F8F9FA;
|
background: #F8F9FA;
|
||||||
padding-bottom: 40px;
|
padding-bottom: calc(104px + env(safe-area-inset-bottom));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 顶部导航 */
|
/* 顶部导航 */
|
||||||
@@ -331,16 +338,7 @@ onMounted(async () => {
|
|||||||
color: #B91C1C;
|
color: #B91C1C;
|
||||||
}
|
}
|
||||||
|
|
||||||
.save-btn {
|
.header-spacer { width: 40px; }
|
||||||
background: #F97316;
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
padding: 8px 20px;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 头像上传 */
|
/* 头像上传 */
|
||||||
.photo-section {
|
.photo-section {
|
||||||
@@ -580,4 +578,47 @@ onMounted(async () => {
|
|||||||
background: #EF4444;
|
background: #EF4444;
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.edit-action-bar {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 30;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 20px calc(14px + env(safe-area-inset-bottom));
|
||||||
|
border-top: 1px solid rgba(229, 231, 235, .9);
|
||||||
|
background: rgba(248, 249, 250, .96);
|
||||||
|
box-shadow: 0 -8px 24px rgba(56, 38, 24, .06);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-back-btn,
|
||||||
|
.action-save-btn {
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 14px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-back-btn {
|
||||||
|
flex: 0 0 104px;
|
||||||
|
border: 1px solid #E4E0DC;
|
||||||
|
color: #655E58;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-save-btn {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
border: 0;
|
||||||
|
color: #fff;
|
||||||
|
background: linear-gradient(105deg, #F79A38, #F97316);
|
||||||
|
box-shadow: 0 8px 18px rgba(249, 115, 22, .18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-back-btn:disabled,
|
||||||
|
.action-save-btn:disabled { opacity: .6; cursor: not-allowed; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,15 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="avatar-manage-page">
|
<div class="avatar-manage-page">
|
||||||
<!-- 顶部导航 -->
|
<!-- 顶部导航 -->
|
||||||
<header class="page-header">
|
<header v-if="!isEmbedded" class="page-header">
|
||||||
<div class="header-left">
|
<div class="header-left">
|
||||||
<button class="back-btn" @click="goBack">‹</button>
|
<button class="back-btn" @click="goBack">‹</button>
|
||||||
<h1 class="page-title">数字分身管理</h1>
|
<h1 class="page-title">数字分身管理</h1>
|
||||||
</div>
|
</div>
|
||||||
<!-- 右上角创建入口 -->
|
|
||||||
<div class="header-right">
|
|
||||||
<button class="icon-btn" @click="goCreate" title="创建数字分身">➕</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- 用户资料头(会会登录账号的头像 / 昵称) -->
|
<!-- 用户资料头(会会登录账号的头像 / 昵称) -->
|
||||||
@@ -39,9 +35,14 @@
|
|||||||
<!-- 数字分身列表(只放分身相关) -->
|
<!-- 数字分身列表(只放分身相关) -->
|
||||||
<section class="avatar-list-section">
|
<section class="avatar-list-section">
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
|
<div class="section-heading-copy">
|
||||||
<h3 class="section-title">我的数字分身</h3>
|
<h3 class="section-title">我的数字分身</h3>
|
||||||
<span class="count-badge">{{ avatars.length }}</span>
|
<span class="count-badge">{{ avatars.length }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<button class="section-create-btn" type="button" @click="goCreate">
|
||||||
|
<span aria-hidden="true">+</span> 添加分身
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="avatars.length" class="avatar-list">
|
<div v-if="avatars.length" class="avatar-list">
|
||||||
<div class="avatar-card" v-for="a in avatars" :key="a.id">
|
<div class="avatar-card" v-for="a in avatars" :key="a.id">
|
||||||
@@ -87,10 +88,12 @@ import { useRouter } from 'vue-router'
|
|||||||
import { useAvatarStore } from '@/store/avatar'
|
import { useAvatarStore } from '@/store/avatar'
|
||||||
import { useUserStore } from '@/store/user'
|
import { useUserStore } from '@/store/user'
|
||||||
import { createAvatarShareLink } from '@/api'
|
import { createAvatarShareLink } from '@/api'
|
||||||
|
import { isHuihuiEmbeddedMode } from '@/utils/embed-mode'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const avatarStore = useAvatarStore()
|
const avatarStore = useAvatarStore()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
const isEmbedded = isHuihuiEmbeddedMode()
|
||||||
|
|
||||||
// 临时产品开关:余额卡片代码保留,后续改为 true 即可恢复展示。
|
// 临时产品开关:余额卡片代码保留,后续改为 true 即可恢复展示。
|
||||||
const SHOW_POINTS_BALANCE_CARD = false
|
const SHOW_POINTS_BALANCE_CARD = false
|
||||||
@@ -354,10 +357,36 @@ onMounted(() => {
|
|||||||
.section-head {
|
.section-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
margin: 8px 0 12px;
|
margin: 8px 0 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.section-heading-copy {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-create-btn {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid #FED7B5;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: #E9650C;
|
||||||
|
background: #FFF7ED;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 650;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-create-btn span { font-size: 17px; line-height: 1; }
|
||||||
|
.section-create-btn:active { background: #FFEDD5; }
|
||||||
|
|
||||||
.section-title {
|
.section-title {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="knowledge-page">
|
<div class="knowledge-page" :class="{ embedded: isEmbedded }">
|
||||||
<!-- 顶部导航 -->
|
<!-- 顶部导航 -->
|
||||||
<header class="page-header">
|
<header v-if="!isEmbedded" class="page-header">
|
||||||
<div class="header-left">
|
<div class="header-left">
|
||||||
<button class="back-btn" @click="goBack">‹</button>
|
<button class="back-btn" @click="goBack">‹</button>
|
||||||
<h1 class="page-title">知识库管理</h1>
|
<h1 class="page-title">知识库管理</h1>
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
<div class="upload-section">
|
<div class="upload-section">
|
||||||
<div class="upload-zone" :class="{ 'drag-over': dragOver }" @click="triggerFile" @dragover.prevent="dragOver = true" @dragleave.prevent="dragOver = false" @drop.prevent="onDrop">
|
<div class="upload-zone" :class="{ 'drag-over': dragOver }" @click="triggerFile" @dragover.prevent="dragOver = true" @dragleave.prevent="dragOver = false" @drop.prevent="onDrop">
|
||||||
<div class="upload-icon">📥</div>
|
<div class="upload-icon">📥</div>
|
||||||
<p class="upload-title">拖拽文件到此处,或<span class="upload-link">点击上传</span></p>
|
<p class="upload-title"><span class="upload-link">点击上传</span></p>
|
||||||
<p class="upload-hint">支持 MD / TXT / PDF / DOC / DOCX / XLSX,上传后自动向量化</p>
|
<p class="upload-hint">支持 MD / TXT / PDF / DOC / DOCX / XLSX,上传后自动向量化</p>
|
||||||
<input ref="fileInput" type="file" accept=".md,.txt,.pdf,.doc,.docx,.xlsx" class="hidden-input" @change="onFileChange" />
|
<input ref="fileInput" type="file" accept=".md,.txt,.pdf,.doc,.docx,.xlsx" class="hidden-input" @change="onFileChange" />
|
||||||
</div>
|
</div>
|
||||||
@@ -37,10 +37,10 @@
|
|||||||
<div class="card-content">
|
<div class="card-content">
|
||||||
<div class="card-title-row">
|
<div class="card-title-row">
|
||||||
<strong>{{ doc.filename }}</strong>
|
<strong>{{ doc.filename }}</strong>
|
||||||
<span class="status-pill" :class="{ pending: !doc.vectorized && doc.filePresent !== false, missing: doc.filePresent === false }">{{ doc.filePresent === false ? '文件缺失' : (doc.vectorized ? '已入库' : '处理中') }}</span>
|
<span class="status-pill" :class="documentState(doc).tone">{{ documentState(doc).label }}</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="card-meta">{{ doc.fileType.toUpperCase() }} · {{ formatSize(doc.fileSize) }} · {{ formatDate(doc.createdAt) }}</p>
|
<p class="card-meta">{{ doc.fileType.toUpperCase() }} · {{ formatSize(doc.fileSize) }} · {{ formatDate(doc.createdAt) }}</p>
|
||||||
<p class="card-detail">{{ doc.filePresent === false ? '原文件不可用,请删除后重新上传' : (doc.vectorized ? `已切分 ${doc.chunkCount || 0} 段,可用于对话` : '正在解析并建立知识索引') }}</p>
|
<p class="card-detail">{{ documentState(doc).detail }}</p>
|
||||||
</div>
|
</div>
|
||||||
<button class="card-delete" @click="removeDoc(doc.id)">删除</button>
|
<button class="card-delete" @click="removeDoc(doc.id)">删除</button>
|
||||||
</article>
|
</article>
|
||||||
@@ -82,6 +82,7 @@ import { ref, onMounted, computed } from 'vue'
|
|||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { useAvatarStore } from '@/store/avatar'
|
import { useAvatarStore } from '@/store/avatar'
|
||||||
import { pickScopedAvatarId, unwrapListData } from '@/utils/avatar-page-data.js'
|
import { pickScopedAvatarId, unwrapListData } from '@/utils/avatar-page-data.js'
|
||||||
|
import { isHuihuiEmbeddedMode } from '@/utils/embed-mode'
|
||||||
import {
|
import {
|
||||||
getKnowledgeDocs,
|
getKnowledgeDocs,
|
||||||
uploadKnowledgeDoc,
|
uploadKnowledgeDoc,
|
||||||
@@ -95,6 +96,7 @@ import {
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const store = useAvatarStore()
|
const store = useAvatarStore()
|
||||||
|
const isEmbedded = isHuihuiEmbeddedMode()
|
||||||
|
|
||||||
const avatarId = computed(() => pickScopedAvatarId(route.params.avatarId, store.currentAvatarId, store.avatars))
|
const avatarId = computed(() => pickScopedAvatarId(route.params.avatarId, store.currentAvatarId, store.avatars))
|
||||||
const activeTab = ref<'docs' | 'qa'>('docs')
|
const activeTab = ref<'docs' | 'qa'>('docs')
|
||||||
@@ -111,6 +113,19 @@ const searching = ref(false)
|
|||||||
const searched = ref(false)
|
const searched = ref(false)
|
||||||
const searchResults = ref<any[]>([])
|
const searchResults = ref<any[]>([])
|
||||||
|
|
||||||
|
const documentState = (doc: any) => {
|
||||||
|
if (doc.filePresent === false) {
|
||||||
|
return { tone: 'missing', label: '文件缺失', detail: '原文件不可用,请删除后重新上传' }
|
||||||
|
}
|
||||||
|
if (doc.vectorized) {
|
||||||
|
return { tone: 'ready', label: '已入库', detail: `已切分 ${doc.chunkCount || 0} 段,可用于对话` }
|
||||||
|
}
|
||||||
|
if (['uploaded', 'parsing'].includes(String(doc.status || '').toLowerCase())) {
|
||||||
|
return { tone: 'pending', label: '处理中', detail: '正在解析并建立知识索引' }
|
||||||
|
}
|
||||||
|
return { tone: 'failed', label: '处理失败', detail: '未能建立知识索引,请删除后重新上传' }
|
||||||
|
}
|
||||||
|
|
||||||
const loadDocs = async () => {
|
const loadDocs = async () => {
|
||||||
if (!avatarId.value) return
|
if (!avatarId.value) return
|
||||||
try {
|
try {
|
||||||
@@ -283,6 +298,7 @@ onMounted(async () => {
|
|||||||
.card-title-row strong { min-width: 0; flex: 1; overflow: hidden; color: #27201C; font-size: 14px; text-overflow: ellipsis; white-space: nowrap; }
|
.card-title-row strong { min-width: 0; flex: 1; overflow: hidden; color: #27201C; font-size: 14px; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.status-pill { flex: 0 0 auto; display: inline-flex; padding: 4px 7px; border-radius: 999px; color: #15803D; background: #ECFDF3; font-size: 10px; white-space: nowrap; }.status-pill.pending { color: #B45309; background: #FFFBEB; }
|
.status-pill { flex: 0 0 auto; display: inline-flex; padding: 4px 7px; border-radius: 999px; color: #15803D; background: #ECFDF3; font-size: 10px; white-space: nowrap; }.status-pill.pending { color: #B45309; background: #FFFBEB; }
|
||||||
.status-pill.missing { color: #B91C1C; background: #FEF2F2; }
|
.status-pill.missing { color: #B91C1C; background: #FEF2F2; }
|
||||||
|
.status-pill.failed { color: #B91C1C; background: #FEF2F2; }
|
||||||
.card-meta, .card-detail { margin: 5px 0 0; color: #9398AE; font-size: 11px; line-height: 1.4; }.card-detail { color: #8B6B58; }
|
.card-meta, .card-detail { margin: 5px 0 0; color: #9398AE; font-size: 11px; line-height: 1.4; }.card-detail { color: #8B6B58; }
|
||||||
.card-delete { flex: 0 0 auto; align-self: center; border: 0; color: #EF4444; background: #FEF2F2; border-radius: 8px; padding: 7px 9px; font-size: 12px; cursor: pointer; }
|
.card-delete { flex: 0 0 auto; align-self: center; border: 0; color: #EF4444; background: #FEF2F2; border-radius: 8px; padding: 7px 9px; font-size: 12px; cursor: pointer; }
|
||||||
.card-empty { padding: 42px 16px; border: 1px dashed #F1D9C3; border-radius: 16px; color: #9398AE; background: #fff; font-size: 14px; text-align: center; }
|
.card-empty { padding: 42px 16px; border: 1px dashed #F1D9C3; border-radius: 16px; color: #9398AE; background: #fff; font-size: 14px; text-align: center; }
|
||||||
|
|||||||
@@ -10,10 +10,12 @@
|
|||||||
<section class="form-section">
|
<section class="form-section">
|
||||||
<label class="field-label">问题</label>
|
<label class="field-label">问题</label>
|
||||||
<textarea
|
<textarea
|
||||||
|
ref="questionInput"
|
||||||
v-model="form.question"
|
v-model="form.question"
|
||||||
class="field-input"
|
class="field-input question-input"
|
||||||
rows="3"
|
rows="1"
|
||||||
placeholder="例如:你们的退款政策是什么?"
|
placeholder="例如:你们的退款政策是什么?"
|
||||||
|
@input="resizeQuestion"
|
||||||
></textarea>
|
></textarea>
|
||||||
|
|
||||||
<label class="field-label">标准答案</label>
|
<label class="field-label">标准答案</label>
|
||||||
@@ -46,7 +48,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, computed, onMounted } from 'vue'
|
import { ref, reactive, computed, nextTick, onMounted } from 'vue'
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
import { useAvatarStore } from '@/store/avatar'
|
import { useAvatarStore } from '@/store/avatar'
|
||||||
import { pickScopedAvatarId, unwrapListData } from '@/utils/avatar-page-data.js'
|
import { pickScopedAvatarId, unwrapListData } from '@/utils/avatar-page-data.js'
|
||||||
@@ -63,6 +65,14 @@ const isEdit = computed(() => !!qaId.value)
|
|||||||
const form = reactive({ question: '', answer: '', enabled: true })
|
const form = reactive({ question: '', answer: '', enabled: true })
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
|
const questionInput = ref<HTMLTextAreaElement | null>(null)
|
||||||
|
|
||||||
|
const resizeQuestion = (event?: Event) => {
|
||||||
|
const element = (event?.target as HTMLTextAreaElement | null) || questionInput.value
|
||||||
|
if (!element) return
|
||||||
|
element.style.height = 'auto'
|
||||||
|
element.style.height = `${element.scrollHeight}px`
|
||||||
|
}
|
||||||
|
|
||||||
const goBack = () => router.back()
|
const goBack = () => router.back()
|
||||||
|
|
||||||
@@ -124,6 +134,8 @@ onMounted(async () => {
|
|||||||
if (isEdit.value) {
|
if (isEdit.value) {
|
||||||
await loadForEdit()
|
await loadForEdit()
|
||||||
}
|
}
|
||||||
|
await nextTick()
|
||||||
|
resizeQuestion()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -194,6 +206,13 @@ onMounted(async () => {
|
|||||||
border-color: #F97316;
|
border-color: #F97316;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.question-input {
|
||||||
|
min-height: 44px;
|
||||||
|
overflow: hidden;
|
||||||
|
resize: none;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
.switch-row {
|
.switch-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
Reference in New Issue
Block a user