feat: 品牌更名、用户管理增强、画像版本管理、对话体验优化
CI / 后端测试 (push) Has been cancelled
CI / 前端构建 (push) Has been cancelled

- 品牌统一为"左右(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:
selfrelease
2026-08-30 09:05:31 +08:00
co-authored by Devin
parent 038ab17278
commit 4e2b4666aa
24 changed files with 1623 additions and 333 deletions
+119 -14
View File
@@ -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:
"""获取会话的所有消息(按时间正序)。