feat: 品牌更名、用户管理增强、画像版本管理、对话体验优化
- 品牌统一为"左右(BossAi)":网页标签、侧边栏、登录页、manifest、后端 - 用户管理:搜索、对话统计、重置密码、激活/停用账号 - 用户画像:确认事实步骤(带入内容+追问内容)、版本历史管理、PUT /profile/me - 对话历史:按场景分组、保密模式标题处理 - AI助手页面:话题区可收起、模式选择紧凑化、footer免责声明 - 设置页:toggle宽度修复、工单文案改为专家咨询 - 去掉工单功能(前后端) Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
@@ -967,8 +967,43 @@ class UserProfileStore:
|
||||
(phone, json.dumps(profile.to_dict(), ensure_ascii=False),
|
||||
profile.interaction_count),
|
||||
)
|
||||
# 写入历史快照(版本号递增)
|
||||
cur.execute(
|
||||
"SELECT COALESCE(MAX(version), 0) FROM user_profile_history WHERE phone = %s",
|
||||
(phone,),
|
||||
)
|
||||
next_version = cur.fetchone()[0] + 1
|
||||
source = signals.get("_source", "auto")
|
||||
cur.execute(
|
||||
"INSERT INTO user_profile_history (phone, version, profile, source) "
|
||||
"VALUES (%s, %s, %s, %s)",
|
||||
(phone, next_version,
|
||||
json.dumps(profile.to_dict(), ensure_ascii=False), source),
|
||||
)
|
||||
return profile
|
||||
|
||||
def list_history(self, phone: str, limit: int = 20) -> List[dict]:
|
||||
"""列出用户画像历史版本(按版本号倒序)。"""
|
||||
with self._conn() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT id, version, profile, source, created_at "
|
||||
"FROM user_profile_history WHERE phone = %s "
|
||||
"ORDER BY version DESC LIMIT %s",
|
||||
(phone, limit),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
return [
|
||||
{
|
||||
"id": r[0],
|
||||
"version": r[1],
|
||||
"profile": r[2],
|
||||
"source": r[3],
|
||||
"created_at": str(r[4]) if r[4] else "",
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
class UserStore:
|
||||
"""PostgreSQL 用户账号存储:手机号+密码注册登录。
|
||||
@@ -1077,18 +1112,61 @@ class UserStore:
|
||||
{"phone": str, "name": str, "token": str}
|
||||
|
||||
Raises:
|
||||
ValueError: 手机号或密码错误
|
||||
ValueError: 手机号或密码错误 / 账号已停用
|
||||
"""
|
||||
with self._conn() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT password, name FROM users WHERE phone = %s", (phone,))
|
||||
cur.execute("SELECT password, name, status FROM users WHERE phone = %s", (phone,))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
raise ValueError("手机号或密码错误")
|
||||
if not self._verify_password(password, row[0]):
|
||||
raise ValueError("手机号或密码错误")
|
||||
if row[2] == "inactive":
|
||||
raise ValueError("账号已被停用,请联系管理员")
|
||||
return {"phone": phone, "name": row[1] or "", "token": self._make_token(phone)}
|
||||
|
||||
def reset_password(self, user_id: int, new_password: str) -> bool:
|
||||
"""管理员重置用户密码。
|
||||
|
||||
Args:
|
||||
user_id: 用户 ID
|
||||
new_password: 新密码(明文,内部 bcrypt 哈希)
|
||||
|
||||
Returns:
|
||||
是否成功
|
||||
"""
|
||||
if len(new_password) < 6:
|
||||
raise ValueError("密码至少 6 位")
|
||||
hashed = self._hash_password(new_password)
|
||||
with self._conn() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"UPDATE users SET password = %s, updated_at = now() WHERE id = %s",
|
||||
(hashed, user_id),
|
||||
)
|
||||
return cur.rowcount > 0
|
||||
|
||||
def update_status(self, user_id: int, status: str) -> bool:
|
||||
"""管理员激活/停用用户。
|
||||
|
||||
Args:
|
||||
user_id: 用户 ID
|
||||
status: 'active' 或 'inactive'
|
||||
|
||||
Returns:
|
||||
是否成功
|
||||
"""
|
||||
if status not in ("active", "inactive"):
|
||||
raise ValueError("状态应为 active 或 inactive")
|
||||
with self._conn() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"UPDATE users SET status = %s, updated_at = now() WHERE id = %s",
|
||||
(status, user_id),
|
||||
)
|
||||
return cur.rowcount > 0
|
||||
|
||||
|
||||
class TicketStore:
|
||||
"""PostgreSQL 人工复核工单存储,含 SLA 和专家自动分配。"""
|
||||
@@ -3052,6 +3130,7 @@ class ChatStore:
|
||||
inference_location: str = "",
|
||||
model_name: str = "",
|
||||
fallback_used: bool = False,
|
||||
rag_context: list | None = None,
|
||||
) -> int | None:
|
||||
"""保存一条对话消息。
|
||||
|
||||
@@ -3070,6 +3149,7 @@ class ChatStore:
|
||||
inference_location: 推理位置(cloud/local)
|
||||
model_name: 模型名称
|
||||
fallback_used: 是否使用了降级
|
||||
rag_context: 知识库引用文档列表(仅 assistant 消息)
|
||||
|
||||
Returns:
|
||||
消息 ID,ephemeral 模式返回 None
|
||||
@@ -3093,11 +3173,22 @@ class ChatStore:
|
||||
"escalation": risk_escalation,
|
||||
}, ensure_ascii=False)
|
||||
|
||||
# 知识库引用摘要(只存标题/类型/地区,不存全文)
|
||||
rag_summary = []
|
||||
if rag_context:
|
||||
for doc in rag_context:
|
||||
rag_summary.append({
|
||||
"title": doc.get("title", ""),
|
||||
"doc_type": doc.get("doc_type", ""),
|
||||
"region": doc.get("region"),
|
||||
})
|
||||
|
||||
meta_data = json.dumps({
|
||||
"scene": scene,
|
||||
"inference_location": inference_location,
|
||||
"model": model_name,
|
||||
"fallback_used": fallback_used,
|
||||
"rag_context": rag_summary,
|
||||
}, ensure_ascii=False)
|
||||
|
||||
with self._conn() as conn:
|
||||
@@ -3127,7 +3218,8 @@ class ChatStore:
|
||||
COUNT(*) AS msg_count,
|
||||
MIN(created_at) AS first_at,
|
||||
MAX(created_at) AS last_at,
|
||||
(array_agg(meta_data->>'scene' ORDER BY created_at DESC))[1] AS scene
|
||||
(array_agg(meta_data->>'scene' ORDER BY created_at DESC))[1] AS scene,
|
||||
(array_agg(content ORDER BY created_at ASC) FILTER (WHERE role = 'user'))[1] AS first_question
|
||||
FROM chat_messages
|
||||
WHERE phone = %s
|
||||
"""
|
||||
@@ -3140,17 +3232,30 @@ class ChatStore:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(query, params)
|
||||
rows = cur.fetchall()
|
||||
return [
|
||||
{
|
||||
"session_id": r[0],
|
||||
"mode": r[1],
|
||||
"message_count": r[2],
|
||||
"first_message_at": r[3].isoformat() if r[3] else None,
|
||||
"last_message_at": r[4].isoformat() if r[4] else None,
|
||||
"scene": r[5],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
# 场景中文映射(保密模式无法解密内容,用场景标签生成标题)
|
||||
SCENE_LABELS = {"legal": "法务参谋", "decision": "经营决策",
|
||||
"life": "生活助理", "emotion": "情绪中心"}
|
||||
results = []
|
||||
for r in rows:
|
||||
sid, smode, msg_count, first_at, last_at, scene, first_q = r
|
||||
if smode == "confidential":
|
||||
# 保密模式:内容已加密,用场景标签 + 日期生成标题
|
||||
scene_label = SCENE_LABELS.get(scene or "", "保密对话")
|
||||
date_str = first_at.strftime("%m/%d") if first_at else ""
|
||||
title = f"{scene_label} · {date_str}" if date_str else scene_label
|
||||
else:
|
||||
# 普通模式:取首条用户消息前 30 字作为标题
|
||||
title = (first_q[:30] + "…") if first_q and len(first_q) > 30 else (first_q or "未命名对话")
|
||||
results.append({
|
||||
"session_id": sid,
|
||||
"mode": smode,
|
||||
"message_count": msg_count,
|
||||
"first_message_at": first_at.isoformat() if first_at else None,
|
||||
"last_message_at": last_at.isoformat() if last_at else None,
|
||||
"scene": scene,
|
||||
"title": title,
|
||||
})
|
||||
return results
|
||||
|
||||
def get_messages(self, session_id: str, phone: str, decrypt: bool = True) -> list:
|
||||
"""获取会话的所有消息(按时间正序)。
|
||||
|
||||
Reference in New Issue
Block a user