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
+136 -9
View File
@@ -76,7 +76,7 @@ class Metrics:
} }
app = FastAPI(title="BossAi MVP", version="1.1.0") app = FastAPI(title="左右(BossAi)", version="1.1.0")
sessions = SessionManager() sessions = SessionManager()
knowledge = KnowledgeStore() knowledge = KnowledgeStore()
settings_store = SettingsStore() settings_store = SettingsStore()
@@ -264,7 +264,7 @@ class SummarySaveRequest(BaseModel):
@app.get("/health") @app.get("/health")
def health(): def health():
return {"status": "ok", "service": "bossai-mvp", "version": "1.1.0", "db": "connected"} return {"status": "ok", "service": "左右(BossAi)", "version": "1.1.0", "db": "connected"}
class SampleRequest(BaseModel): class SampleRequest(BaseModel):
@@ -434,6 +434,7 @@ def ask(sid: str, req: AskRequest, authorization: str | None = Header(default=No
risk_escalation=card.escalation, risk_escalation=card.escalation,
inference_location=inference_location, model_name=model_name, inference_location=inference_location, model_name=model_name,
fallback_used=fallback_used, fallback_used=fallback_used,
rag_context=rag_context,
) )
except Exception as e: except Exception as e:
logger.warning(f"chat_store save failed: {e}") logger.warning(f"chat_store save failed: {e}")
@@ -492,6 +493,27 @@ def ask_analyze(sid: str, req: AskRequest, authorization: str | None = Header(de
req.question, req.scene, answers, kimi_client=kimi, req.question, req.scene, answers, kimi_client=kimi,
) )
# 收集用户画像中的已知事实(带入内容)
profile_facts: list[dict] = []
if req.phone:
try:
profile = profile_store.get(req.phone)
if profile.industry:
profile_facts.append({"dim_key": "industry", "dim_desc": "行业", "answer": profile.industry})
if profile.company_size:
profile_facts.append({"dim_key": "company_size", "dim_desc": "企业规模", "answer": profile.company_size})
if profile.role:
profile_facts.append({"dim_key": "role", "dim_desc": "角色", "answer": profile.role})
if profile.risk_tolerance:
profile_facts.append({"dim_key": "risk_tolerance", "dim_desc": "风险偏好", "answer": profile.risk_tolerance})
if profile.decision_style:
profile_facts.append({"dim_key": "decision_style", "dim_desc": "决策风格", "answer": profile.decision_style})
if profile.key_facts:
for k, v in profile.key_facts.items():
profile_facts.append({"dim_key": f"fact_{k}", "dim_desc": k, "answer": str(v)})
except Exception:
pass
if meta["need_ask"]: if meta["need_ask"]:
return { return {
"need_ask": True, "need_ask": True,
@@ -501,6 +523,7 @@ def ask_analyze(sid: str, req: AskRequest, authorization: str | None = Header(de
"question_type": meta["analysis"]["question_type"], "question_type": meta["analysis"]["question_type"],
"risk_level": card.level, "risk_level": card.level,
"source": meta.get("source", "rule"), "source": meta.get("source", "rule"),
"profile_facts": profile_facts,
} }
else: else:
return { return {
@@ -510,6 +533,7 @@ def ask_analyze(sid: str, req: AskRequest, authorization: str | None = Header(de
"fact_summary": meta["fact_summary"], "fact_summary": meta["fact_summary"],
"question_type": meta["analysis"]["question_type"], "question_type": meta["analysis"]["question_type"],
"risk_level": card.level, "risk_level": card.level,
"profile_facts": profile_facts,
} }
@@ -786,6 +810,56 @@ def get_my_profile(authorization: str | None = Header(default=None)):
} }
@app.put("/profile/me")
def update_my_profile(body: dict, authorization: str | None = Header(default=None)):
"""更新当前登录用户的画像(用户确认事实后调用)。
Body:
{
"industry": "...",
"company_size": "...",
"role": "...",
"risk_tolerance": "...",
"decision_style": "...",
"key_facts": {"事实名": "事实值", ...}
}
"""
phone = get_current_phone(authorization)
if not phone:
raise HTTPException(401, "未登录或登录已过期")
# 构造 signals,只更新非空字段
signals: dict = {}
for key in ("industry", "company_size", "role", "risk_tolerance",
"communication_style", "decision_style"):
val = body.get(key)
if val:
signals[key] = val
if body.get("key_facts") and isinstance(body["key_facts"], dict):
signals["key_facts"] = body["key_facts"]
if not signals:
return {"updated": False}
signals["_source"] = "manual" # 标记为用户手动确认
profile = profile_store.update(phone, signals)
return {
"updated": True,
"industry": profile.industry,
"company_size": profile.company_size,
"role": profile.role,
"risk_tolerance": profile.risk_tolerance,
"decision_style": profile.decision_style,
"key_facts": profile.key_facts,
}
@app.get("/profile/me/history")
def get_my_profile_history(authorization: str | None = Header(default=None)):
"""获取当前登录用户的画像历史版本。"""
phone = get_current_phone(authorization)
if not phone:
raise HTTPException(401, "未登录或登录已过期")
return {"results": profile_store.list_history(phone)}
# ===== 用户设置持久化 ===== # ===== 用户设置持久化 =====
@app.get("/settings") @app.get("/settings")
@@ -1194,18 +1268,71 @@ async def admin_upload_expert_avatar(expert_id: int, file: UploadFile = File(...
@app.get("/admin/users") @app.get("/admin/users")
def admin_list_users(): def admin_list_users(q: str | None = None, limit: int = 50):
"""管理端:列出所有注册用户。""" """管理端:列出所有注册用户,支持搜索,含对话统计和状态。"""
with user_store._conn() as conn: with user_store._conn() as conn:
with conn.cursor() as cur: with conn.cursor() as cur:
if q:
cur.execute( cur.execute(
"SELECT id, phone, name, created_at FROM users ORDER BY created_at DESC" "SELECT id, phone, name, status, created_at FROM users "
"WHERE phone LIKE %s OR name LIKE %s "
"ORDER BY created_at DESC LIMIT %s",
(f"%{q}%", f"%{q}%", limit),
)
else:
cur.execute(
"SELECT id, phone, name, status, created_at FROM users "
"ORDER BY created_at DESC LIMIT %s",
(limit,),
) )
rows = cur.fetchall() rows = cur.fetchall()
return {"results": [ # 批量查询每个用户的对话数和最后活跃时间
{"id": r[0], "phone": r[1][:3] + "****" + r[1][-4:], "name": r[2], "created_at": str(r[3])} results = []
for r in rows for r in rows:
]} uid, phone, name, status, created_at = r
cur.execute(
"SELECT COUNT(*), MAX(created_at), "
"(array_agg(meta_data->>'scene' ORDER BY created_at DESC) FILTER (WHERE role = 'assistant'))[1] "
"FROM chat_messages WHERE phone = %s",
(phone,),
)
stat = cur.fetchone()
msg_count = stat[0] or 0
last_active = stat[1]
last_scene = stat[2]
results.append({
"id": uid,
"phone": phone[:3] + "****" + phone[-4:],
"name": name,
"status": status,
"created_at": str(created_at),
"message_count": msg_count,
"last_active": str(last_active) if last_active else None,
"last_scene": last_scene,
})
return {"results": results}
@app.post("/admin/users/{user_id}/reset-password")
def admin_reset_password(user_id: int, body: dict):
"""管理端:重置用户密码。"""
new_password = body.get("password", "")
if not new_password or len(new_password) < 6:
raise HTTPException(400, "密码至少 6 位")
if not user_store.reset_password(user_id, new_password):
raise HTTPException(404, "用户不存在")
return {"reset": True}
@app.post("/admin/users/{user_id}/status")
def admin_update_user_status(user_id: int, body: dict):
"""管理端:激活/停用用户。"""
status = body.get("status", "")
if status not in ("active", "inactive"):
raise HTTPException(400, "状态应为 active 或 inactive")
if not user_store.update_status(user_id, status):
raise HTTPException(404, "用户不存在")
return {"status": status}
@app.get("/admin/stats") @app.get("/admin/stats")
+119 -14
View File
@@ -967,8 +967,43 @@ class UserProfileStore:
(phone, json.dumps(profile.to_dict(), ensure_ascii=False), (phone, json.dumps(profile.to_dict(), ensure_ascii=False),
profile.interaction_count), 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 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: class UserStore:
"""PostgreSQL 用户账号存储:手机号+密码注册登录。 """PostgreSQL 用户账号存储:手机号+密码注册登录。
@@ -1077,18 +1112,61 @@ class UserStore:
{"phone": str, "name": str, "token": str} {"phone": str, "name": str, "token": str}
Raises: Raises:
ValueError: 手机号或密码错误 ValueError: 手机号或密码错误 / 账号已停用
""" """
with self._conn() as conn: with self._conn() as conn:
with conn.cursor() as cur: 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() row = cur.fetchone()
if not row: if not row:
raise ValueError("手机号或密码错误") raise ValueError("手机号或密码错误")
if not self._verify_password(password, row[0]): if not self._verify_password(password, row[0]):
raise ValueError("手机号或密码错误") raise ValueError("手机号或密码错误")
if row[2] == "inactive":
raise ValueError("账号已被停用,请联系管理员")
return {"phone": phone, "name": row[1] or "", "token": self._make_token(phone)} 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: class TicketStore:
"""PostgreSQL 人工复核工单存储,含 SLA 和专家自动分配。""" """PostgreSQL 人工复核工单存储,含 SLA 和专家自动分配。"""
@@ -3052,6 +3130,7 @@ class ChatStore:
inference_location: str = "", inference_location: str = "",
model_name: str = "", model_name: str = "",
fallback_used: bool = False, fallback_used: bool = False,
rag_context: list | None = None,
) -> int | None: ) -> int | None:
"""保存一条对话消息。 """保存一条对话消息。
@@ -3070,6 +3149,7 @@ class ChatStore:
inference_location: 推理位置(cloud/local) inference_location: 推理位置(cloud/local)
model_name: 模型名称 model_name: 模型名称
fallback_used: 是否使用了降级 fallback_used: 是否使用了降级
rag_context: 知识库引用文档列表(仅 assistant 消息)
Returns: Returns:
消息 ID,ephemeral 模式返回 None 消息 ID,ephemeral 模式返回 None
@@ -3093,11 +3173,22 @@ class ChatStore:
"escalation": risk_escalation, "escalation": risk_escalation,
}, ensure_ascii=False) }, 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({ meta_data = json.dumps({
"scene": scene, "scene": scene,
"inference_location": inference_location, "inference_location": inference_location,
"model": model_name, "model": model_name,
"fallback_used": fallback_used, "fallback_used": fallback_used,
"rag_context": rag_summary,
}, ensure_ascii=False) }, ensure_ascii=False)
with self._conn() as conn: with self._conn() as conn:
@@ -3127,7 +3218,8 @@ class ChatStore:
COUNT(*) AS msg_count, COUNT(*) AS msg_count,
MIN(created_at) AS first_at, MIN(created_at) AS first_at,
MAX(created_at) AS last_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 FROM chat_messages
WHERE phone = %s WHERE phone = %s
""" """
@@ -3140,17 +3232,30 @@ class ChatStore:
with conn.cursor() as cur: with conn.cursor() as cur:
cur.execute(query, params) cur.execute(query, params)
rows = cur.fetchall() rows = cur.fetchall()
return [ # 场景中文映射(保密模式无法解密内容,用场景标签生成标题)
{ SCENE_LABELS = {"legal": "法务参谋", "decision": "经营决策",
"session_id": r[0], "life": "生活助理", "emotion": "情绪中心"}
"mode": r[1], results = []
"message_count": r[2], for r in rows:
"first_message_at": r[3].isoformat() if r[3] else None, sid, smode, msg_count, first_at, last_at, scene, first_q = r
"last_message_at": r[4].isoformat() if r[4] else None, if smode == "confidential":
"scene": r[5], # 保密模式:内容已加密,用场景标签 + 日期生成标题
} scene_label = SCENE_LABELS.get(scene or "", "保密对话")
for r in rows 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: def get_messages(self, session_id: str, phone: str, decrypt: bool = True) -> list:
"""获取会话的所有消息(按时间正序)。 """获取会话的所有消息(按时间正序)。
+14
View File
@@ -75,12 +75,25 @@ CREATE TABLE IF NOT EXISTS user_profiles (
updated_at TIMESTAMPTZ NOT NULL DEFAULT now() updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
); );
-- 用户画像历史版本:每次更新时写入快照,支持版本回溯
CREATE TABLE IF NOT EXISTS user_profile_history (
id SERIAL PRIMARY KEY,
phone TEXT NOT NULL,
version INTEGER NOT NULL, -- 版本号(递增)
profile JSONB NOT NULL DEFAULT '{}', -- 画像快照
source TEXT NOT NULL DEFAULT 'auto', -- auto(AI提取) / manual(用户确认)
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_profile_history_phone ON user_profile_history(phone);
CREATE INDEX IF NOT EXISTS idx_profile_history_phone_version ON user_profile_history(phone, version DESC);
-- 用户账号:手机号+密码注册登录 -- 用户账号:手机号+密码注册登录
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
phone TEXT NOT NULL UNIQUE, -- 手机号(登录账号) phone TEXT NOT NULL UNIQUE, -- 手机号(登录账号)
password TEXT NOT NULL, -- bcrypt 哈希 password TEXT NOT NULL, -- bcrypt 哈希
name TEXT, -- 昵称(可选) name TEXT, -- 昵称(可选)
status TEXT NOT NULL DEFAULT 'active', -- active/inactive(管理员终止)
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now() updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
); );
@@ -272,6 +285,7 @@ def main():
BEGIN ALTER TABLE review_tickets ADD COLUMN expert_type TEXT; EXCEPTION WHEN duplicate_column THEN NULL; END; BEGIN ALTER TABLE review_tickets ADD COLUMN expert_type TEXT; EXCEPTION WHEN duplicate_column THEN NULL; END;
BEGIN ALTER TABLE review_tickets ADD COLUMN sla_deadline TIMESTAMPTZ; EXCEPTION WHEN duplicate_column THEN NULL; END; BEGIN ALTER TABLE review_tickets ADD COLUMN sla_deadline TIMESTAMPTZ; EXCEPTION WHEN duplicate_column THEN NULL; END;
BEGIN ALTER TABLE review_tickets ADD COLUMN sla_status TEXT NOT NULL DEFAULT 'normal'; EXCEPTION WHEN duplicate_column THEN NULL; END; BEGIN ALTER TABLE review_tickets ADD COLUMN sla_status TEXT NOT NULL DEFAULT 'normal'; EXCEPTION WHEN duplicate_column THEN NULL; END;
BEGIN ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT 'active'; EXCEPTION WHEN duplicate_column THEN NULL; END;
END $$; END $$;
""") """)
+2 -2
View File
@@ -1,6 +1,6 @@
<!doctype html><meta charset="utf-8"><title>BossAi MVP</title> <!doctype html><meta charset="utf-8"><title>左右(BossAi)</title>
<style>body{font:16px system-ui;max-width:760px;margin:40px auto;padding:0 16px}textarea{width:100%;height:100px}button{padding:8px 14px;margin:6px 4px 6px 0}.card{white-space:pre-wrap;background:#f5f6f8;padding:16px;border-radius:8px}</style> <style>body{font:16px system-ui;max-width:760px;margin:40px auto;padding:0 16px}textarea{width:100%;height:100px}button{padding:8px 14px;margin:6px 4px 6px 0}.card{white-space:pre-wrap;background:#f5f6f8;padding:16px;border-radius:8px}</style>
<h1>BossAi</h1><p>老板经营、法务与情绪支持助手(MVP)</p> <h1>左右(BossAi)</h1><p>老板经营、法务与情绪支持助手</p>
<select id="mode"><option value="normal">普通模式</option><option value="confidential">保密模式</option><option value="ephemeral">阅后即焚</option></select><button onclick="start()">新建会话</button> <select id="mode"><option value="normal">普通模式</option><option value="confidential">保密模式</option><option value="ephemeral">阅后即焚</option></select><button onclick="start()">新建会话</button>
<textarea id="q" placeholder="例如:员工工伤后能否开除?"></textarea><button onclick="ask()">分析</button><button onclick="destroy()">隐私急停/销毁</button><div id="out" class="card"></div> <textarea id="q" placeholder="例如:员工工伤后能否开除?"></textarea><button onclick="ask()">分析</button><button onclick="destroy()">隐私急停/销毁</button><div id="out" class="card"></div>
<script>let sid;async function start(){let r=await fetch('/sessions',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({mode:mode.value})});sid=(await r.json()).session_id;out.textContent='会话已创建:'+sid}async function ask(){if(!sid)await start();let r=await fetch('/sessions/'+sid+'/ask',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({question:q.value})});out.textContent=JSON.stringify(await r.json(),null,2)}async function destroy(){if(!sid)return;let r=await fetch('/sessions/'+sid+'/destroy',{method:'POST'});out.textContent=JSON.stringify(await r.json())}</script> <script>let sid;async function start(){let r=await fetch('/sessions',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({mode:mode.value})});sid=(await r.json()).session_id;out.textContent='会话已创建:'+sid}async function ask(){if(!sid)await start();let r=await fetch('/sessions/'+sid+'/ask',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({question:q.value})});out.textContent=JSON.stringify(await r.json(),null,2)}async function destroy(){if(!sid)return;let r=await fetch('/sessions/'+sid+'/destroy',{method:'POST'});out.textContent=JSON.stringify(await r.json())}</script>
+5 -4
View File
@@ -5,19 +5,20 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0" />
<meta name="theme-color" content="#0f172a" /> <meta name="theme-color" content="#0f172a" />
<meta name="description" content="BossAi - 隐私优先的企业法律与经营智能助手" /> <meta name="description" content="左右(BossAi)- 隐私优先的企业法律与经营智能助手" />
<link rel="manifest" href="/manifest.json" /> <link rel="manifest" href="/manifest.json" />
<meta name="mobile-web-app-capable" content="yes" /> <meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" /> <meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="BossAi" /> <meta name="apple-mobile-web-app-title" content="左右(BossAi)" />
<title>BossAi · 老板经营助理</title> <title>左右(BossAi)</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
<script> <script>
// 注册 Service Worker(PWA 离线支持) // 注册 Service Worker(PWA 离线支持)
if ('serviceWorker' in navigator) { // 开发环境(localhost:5173)不注册,避免 Vite HMR 模块被 SW 缓存导致刷新看不到最新代码
if ('serviceWorker' in navigator && location.port !== '5173') {
window.addEventListener('load', () => { window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').catch(() => { navigator.serviceWorker.register('/sw.js').catch(() => {
// SW 注册失败不影响正常使用 // SW 注册失败不影响正常使用
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "BossAi - 老板智能助手", "name": "左右(BossAi)",
"short_name": "BossAi", "short_name": "左右(BossAi)",
"description": "隐私优先的企业法律与经营智能助手", "description": "隐私优先的企业法律与经营智能助手",
"start_url": "/", "start_url": "/",
"display": "standalone", "display": "standalone",
+3 -1
View File
@@ -1,7 +1,9 @@
// BossAi Service Worker - 离线访问支持 // BossAi Service Worker - 离线访问支持
// 策略:API 请求 network-first,静态资源 cache-first // 策略:API 请求 network-first,静态资源 cache-first
// 注意:开发环境(localhost:5173)不应注册 SW,index.html 已做端口判断
// 本文件仅在生产环境生效
const CACHE_VERSION = 'bossai-v1' const CACHE_VERSION = 'bossai-v2'
const STATIC_CACHE = `${CACHE_VERSION}-static` const STATIC_CACHE = `${CACHE_VERSION}-static`
const API_CACHE = `${CACHE_VERSION}-api` const API_CACHE = `${CACHE_VERSION}-api`
+2
View File
@@ -10,6 +10,7 @@ import { KnowledgeBase } from '@/pages/KnowledgeBase'
import { Experts } from '@/pages/Experts' import { Experts } from '@/pages/Experts'
import { WorkbenchPendingServices, WorkbenchPending, WorkbenchReplied, WorkbenchUsers } from '@/pages/workbench' import { WorkbenchPendingServices, WorkbenchPending, WorkbenchReplied, WorkbenchUsers } from '@/pages/workbench'
import { ChatHistory } from '@/pages/ChatHistory' import { ChatHistory } from '@/pages/ChatHistory'
import { MyProfile } from '@/pages/MyProfile'
import { AdminStats, AdminAiUsage, AdminExpertActivity, AdminUsers, AdminExperts } from '@/pages/admin' import { AdminStats, AdminAiUsage, AdminExpertActivity, AdminUsers, AdminExperts } from '@/pages/admin'
import { Placeholder } from '@/pages/Placeholder' import { Placeholder } from '@/pages/Placeholder'
import { Login } from '@/pages/Login' import { Login } from '@/pages/Login'
@@ -43,6 +44,7 @@ export default function App() {
<Route path="knowledge" element={<ProtectedRoute allowedRoles={['user']}><KnowledgeBase /></ProtectedRoute>} /> <Route path="knowledge" element={<ProtectedRoute allowedRoles={['user']}><KnowledgeBase /></ProtectedRoute>} />
<Route path="experts" element={<ProtectedRoute allowedRoles={['user']}><Experts /></ProtectedRoute>} /> <Route path="experts" element={<ProtectedRoute allowedRoles={['user']}><Experts /></ProtectedRoute>} />
<Route path="history" element={<ProtectedRoute allowedRoles={['user']}><ChatHistory /></ProtectedRoute>} /> <Route path="history" element={<ProtectedRoute allowedRoles={['user']}><ChatHistory /></ProtectedRoute>} />
<Route path="profile" element={<ProtectedRoute allowedRoles={['user']}><MyProfile /></ProtectedRoute>} />
<Route path="settings" element={<ProtectedRoute allowedRoles={['user']}><Settings /></ProtectedRoute>} /> <Route path="settings" element={<ProtectedRoute allowedRoles={['user']}><Settings /></ProtectedRoute>} />
{/* 专家工作台:仅专家可访问,拆分为独立页面 */} {/* 专家工作台:仅专家可访问,拆分为独立页面 */}
<Route path="workbench" element={<ProtectedRoute allowedRoles={['expert']}><WorkbenchPendingServices /></ProtectedRoute>} /> <Route path="workbench" element={<ProtectedRoute allowedRoles={['expert']}><WorkbenchPendingServices /></ProtectedRoute>} />
+262 -62
View File
@@ -11,11 +11,13 @@ import {
HelpCircle, HelpCircle,
RefreshCw, RefreshCw,
Check, Check,
CheckCircle,
FileText, FileText,
BookOpen,
Sparkles,
} from 'lucide-react' } from 'lucide-react'
import { RiskCardView } from './RiskCardView' import { RiskCardView } from './RiskCardView'
import { Markdown } from './Markdown' import { Markdown } from './Markdown'
import { EmptyState } from './EmptyState'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
@@ -33,7 +35,43 @@ import {
} from '@/components/ui/dialog' } from '@/components/ui/dialog'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import type { AskResponse, EmotionMode, ExpertService, Scene, SessionMode } from '@/types' import type { AskResponse, EmotionMode, ExpertService, KnowledgeDoc, Scene, SessionMode } from '@/types'
/** 画像字段英文值 → 中文显示映射 */
const PROFILE_VALUE_LABELS: Record<string, string> = {
// 企业规模
micro: '微型(<10人)',
small: '小型(<50人)',
medium: '中型(<200人)',
large: '大型(>200人)',
// 角色
owner: '老板/创始人',
manager: '管理者',
ceo: 'CEO',
director: '总监',
// 风险偏好
conservative: '保守型',
moderate: '稳健型',
aggressive: '进取型',
// 决策风格
'data-driven': '数据驱动型',
intuitive: '直觉型',
collaborative: '协作型',
// 沟通偏好
concise: '简洁',
detailed: '详细',
structured: '结构化',
// 通用 yes/no
yes: '是',
no: '否',
}
/** 将画像字段的英文值翻译为中文显示 */
function translateProfileValue(value: string): string {
// key_facts 类的值可能是中文也可能是英文,统一查映射表
const lower = value.toLowerCase().trim()
return PROFILE_VALUE_LABELS[lower] ?? value
}
export interface ChatPanelMode { export interface ChatPanelMode {
value: SessionMode value: SessionMode
@@ -63,6 +101,8 @@ export interface ChatPanelProps {
showRiskCard?: boolean showRiskCard?: boolean
/** 文件上传支持 */ /** 文件上传支持 */
enableUpload?: boolean enableUpload?: boolean
/** 卡片底部脚注(如免责声明) */
footer?: React.ReactNode
} }
interface Message { interface Message {
@@ -73,12 +113,23 @@ interface Message {
model?: string model?: string
inferenceLocation?: string inferenceLocation?: string
fallbackUsed?: boolean fallbackUsed?: boolean
/** 知识库引用来源 */
ragContext?: KnowledgeDoc[]
/** 流式输出中 */ /** 流式输出中 */
streaming?: boolean streaming?: boolean
/** 事实追问回答(样式区分) */ /** 事实追问回答(样式区分) */
isFactAnswer?: boolean isFactAnswer?: boolean
} }
/** 知识库文档类型标签映射 */
const DOC_TYPE_LABELS: Record<string, string> = {
law: '法规',
case: '案例',
guide: '指南',
industry: '行业',
template: '模板',
}
const DEFAULT_MODES: ChatPanelMode[] = [ const DEFAULT_MODES: ChatPanelMode[] = [
{ value: 'normal', label: '普通模式', desc: '日常经营,可保存历史', Icon: MessageSquare }, { value: 'normal', label: '普通模式', desc: '日常经营,可保存历史', Icon: MessageSquare },
{ value: 'confidential', label: '保密模式', desc: '加密保存,文件自动过期', Icon: Lock }, { value: 'confidential', label: '保密模式', desc: '加密保存,文件自动过期', Icon: Lock },
@@ -97,6 +148,7 @@ export function ChatPanel({
placeholder = '描述你的问题…', placeholder = '描述你的问题…',
showRiskCard = true, showRiskCard = true,
enableUpload = true, enableUpload = true,
footer,
}: ChatPanelProps) { }: ChatPanelProps) {
const [mode, setMode] = useState<SessionMode>(defaultMode) const [mode, setMode] = useState<SessionMode>(defaultMode)
const [sid, setSid] = useState<string | null>(null) const [sid, setSid] = useState<string | null>(null)
@@ -123,6 +175,10 @@ export function ChatPanel({
const [factQuestions, setFactQuestions] = useState<Array<{ dim_key: string; dim_desc: string; question: string }>>([]) const [factQuestions, setFactQuestions] = useState<Array<{ dim_key: string; dim_desc: string; question: string }>>([])
const [factAnswers, setFactAnswers] = useState<Array<{ dim_key: string; dim_desc: string; answer: string }>>([]) const [factAnswers, setFactAnswers] = useState<Array<{ dim_key: string; dim_desc: string; answer: string }>>([])
const [factInputs, setFactInputs] = useState<Record<string, string>>({}) const [factInputs, setFactInputs] = useState<Record<string, string>>({})
/** 事实确认区:追问完成后展示,允许用户修改再继续 */
const [factConfirm, setFactConfirm] = useState<Array<{ dim_key: string; dim_desc: string; answer: string }>>([])
const [factConfirmEditing, setFactConfirmEditing] = useState<Record<string, string>>({})
const [profileFacts, setProfileFacts] = useState<Array<{ dim_key: string; dim_desc: string; answer: string }>>([])
const [analyzing, setAnalyzing] = useState(false) const [analyzing, setAnalyzing] = useState(false)
const fileRef = useRef<HTMLInputElement>(null) const fileRef = useRef<HTMLInputElement>(null)
/** 待切换的目标模式(销毁确认后切换) */ /** 待切换的目标模式(销毁确认后切换) */
@@ -188,6 +244,7 @@ export function ChatPanel({
model: doneData.model, model: doneData.model,
inferenceLocation: doneData.inference_location, inferenceLocation: doneData.inference_location,
fallbackUsed: doneData.fallback_used, fallbackUsed: doneData.fallback_used,
ragContext: doneData.rag_context,
} }
} }
return next return next
@@ -231,6 +288,8 @@ export function ChatPanel({
setFactAnswers([]) setFactAnswers([])
setFactQuestions([]) setFactQuestions([])
setFactInputs({}) setFactInputs({})
setFactConfirm([])
setProfileFacts([])
currentQuestionRef.current = userQuestion currentQuestionRef.current = userQuestion
// 添加用户消息 // 添加用户消息
@@ -244,6 +303,10 @@ export function ChatPanel({
const analysis = await api.askAnalyze(currentSid, userQuestion, scene, emotionMode) const analysis = await api.askAnalyze(currentSid, userQuestion, scene, emotionMode)
setAnalyzing(false) setAnalyzing(false)
// 保存画像带入事实
const pf = analysis.profile_facts || []
setProfileFacts(pf)
if (analysis.need_ask && analysis.questions && analysis.questions.length > 0) { if (analysis.need_ask && analysis.questions && analysis.questions.length > 0) {
// 需要追问:显示追问问题 // 需要追问:显示追问问题
setFactQuestions(analysis.questions) setFactQuestions(analysis.questions)
@@ -251,8 +314,9 @@ export function ChatPanel({
return return
} }
// 不需要追问,直接流式输出分析结论 // 不需要追问,进入确认区(带入内容 + 无追问内容)
await streamAnalysis(currentSid, userQuestion, []) setFactConfirm(pf)
setFactConfirmEditing(Object.fromEntries(pf.map((f) => [f.dim_key, f.answer])))
} catch (e) { } catch (e) {
setAnalyzing(false) setAnalyzing(false)
toast.error(e instanceof Error ? e.message : '提问失败') toast.error(e instanceof Error ? e.message : '提问失败')
@@ -297,6 +361,10 @@ export function ChatPanel({
) )
setAnalyzing(false) setAnalyzing(false)
// 更新画像带入事实
const pf = analysis.profile_facts || []
setProfileFacts(pf)
if (analysis.need_ask && analysis.questions && analysis.questions.length > 0) { if (analysis.need_ask && analysis.questions && analysis.questions.length > 0) {
// 还需追问 // 还需追问
setFactQuestions(analysis.questions) setFactQuestions(analysis.questions)
@@ -304,11 +372,16 @@ export function ChatPanel({
return return
} }
// 事实充分,流式输出分析结论 // 事实充分,进入确认区(带入内容 + 追问内容)
setFactQuestions([]) setFactQuestions([])
setFactInputs({}) setFactInputs({})
const originalQuestion = currentQuestionRef.current // 合并:画像带入事实在前,追问回答在后(去重 by dim_key)
await streamAnalysis(currentSid, originalQuestion, allAnswers) const mergedMap = new Map<string, { dim_key: string; dim_desc: string; answer: string }>()
for (const f of pf) mergedMap.set(f.dim_key, f)
for (const a of allAnswers) mergedMap.set(a.dim_key, a)
const merged = Array.from(mergedMap.values())
setFactConfirm(merged)
setFactConfirmEditing(Object.fromEntries(merged.map((a) => [a.dim_key, a.answer])))
} catch (e) { } catch (e) {
setAnalyzing(false) setAnalyzing(false)
toast.error(e instanceof Error ? e.message : '追问失败') toast.error(e instanceof Error ? e.message : '追问失败')
@@ -343,6 +416,16 @@ export function ChatPanel({
} }
} }
/** 触发问专家:提取原始问题和追问补充,打开专家咨询弹窗 */
const handleEscalate = () => {
const originalQuestion = messages.find((m) => m.role === 'user' && !m.isFactAnswer)
const factAnswers = messages.filter((m) => m.isFactAnswer)
const supplement = factAnswers
.map((m) => m.content.replace(/\*\*/g, ''))
.join('\n')
openExpertConsult(originalQuestion?.content || '', supplement)
}
/** 打开专家咨询弹窗:加载该场景分类的专家 */ /** 打开专家咨询弹窗:加载该场景分类的专家 */
const openExpertConsult = async (topic: string, supplement: string) => { const openExpertConsult = async (topic: string, supplement: string) => {
setConsultQuestion(topic) setConsultQuestion(topic)
@@ -429,8 +512,29 @@ export function ChatPanel({
<> <>
<Card> <Card>
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<div className="flex items-center justify-between"> <div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2">
<CardTitle className="text-base">对话</CardTitle> <CardTitle className="text-base">对话</CardTitle>
{/* 模式选择:紧凑 segmented control */}
<div className="flex items-center gap-0.5 rounded-md border bg-muted/50 p-0.5">
{modes.map((m) => (
<button
key={m.value}
onClick={() => switchMode(m.value)}
className={cn(
'flex items-center gap-1 rounded px-2 py-1 text-xs font-medium transition-colors',
mode === m.value
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground',
)}
title={m.desc}
>
<m.Icon className="h-3 w-3" aria-hidden />
{m.label}
</button>
))}
</div>
</div>
<Dialog open={confirmDestroy} onOpenChange={setConfirmDestroy}> <Dialog open={confirmDestroy} onOpenChange={setConfirmDestroy}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button <Button
@@ -471,34 +575,12 @@ export function ChatPanel({
</Dialog> </Dialog>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-3">
{/* 会话模式选择 */}
<div className="grid gap-3 sm:grid-cols-3">
{modes.map((m) => (
<button
key={m.value}
onClick={() => switchMode(m.value)}
className={cn(
'flex flex-col items-start gap-1 rounded-md border p-3 text-left transition-colors',
mode === m.value
? 'border-primary bg-primary/5 ring-1 ring-primary'
: 'border-input hover:bg-accent',
)}
>
<div className="flex items-center gap-2">
<m.Icon className="h-4 w-4" aria-hidden />
<span className="text-sm font-medium">{m.label}</span>
</div>
<span className="text-xs text-muted-foreground">{m.desc}</span>
</button>
))}
</div>
{/* 场景特定选择器(如情绪模式) */} {/* 场景特定选择器(如情绪模式) */}
{modeSelector} {modeSelector}
{sid && ( {sid && (
<div className="flex items-center gap-2 rounded-md bg-muted/50 px-3 py-2 text-xs text-muted-foreground"> <div className="flex items-center gap-2 rounded-md bg-muted/50 px-3 py-1.5 text-xs text-muted-foreground">
<currentMode.Icon className="h-3.5 w-3.5" aria-hidden /> <currentMode.Icon className="h-3.5 w-3.5" aria-hidden />
{currentMode.label}会话已建立 {currentMode.label}会话已建立
</div> </div>
@@ -506,18 +588,13 @@ export function ChatPanel({
{/* 对话区 */} {/* 对话区 */}
{messages.length === 0 ? ( {messages.length === 0 ? (
<EmptyState <div className="py-2">
title="开始提问" <div className="flex flex-wrap items-center gap-2">
description="选择模式后输入问题,系统会给出结构化建议。"
action={
<div className="flex flex-col items-center gap-3">
<div className="flex flex-wrap justify-center gap-2">
{dynamicSamples.map((s) => ( {dynamicSamples.map((s) => (
<Button key={s} variant="outline" size="sm" onClick={() => setQuestion(s)}> <Button key={s} variant="outline" size="sm" onClick={() => setQuestion(s)} className="text-xs">
{s} {s}
</Button> </Button>
))} ))}
</div>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
@@ -533,8 +610,7 @@ export function ChatPanel({
换一批 换一批
</Button> </Button>
</div> </div>
} </div>
/>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
{messages.map((msg, i) => ( {messages.map((msg, i) => (
@@ -555,20 +631,9 @@ export function ChatPanel({
model={msg.model} model={msg.model}
inferenceLocation={msg.inferenceLocation} inferenceLocation={msg.inferenceLocation}
fallbackUsed={msg.fallbackUsed} fallbackUsed={msg.fallbackUsed}
ragContext={msg.ragContext}
streaming={msg.streaming} streaming={msg.streaming}
onEscalate={() => { onEscalate={handleEscalate}
// 原始问题 = 第一条非追问的 user 消息
const originalQuestion = messages.find((m) => m.role === 'user' && !m.isFactAnswer)
// 补充内容 = 追问回答(去掉 ** 标记)
const factAnswers = messages.filter((m) => m.isFactAnswer)
const supplement = factAnswers
.map((m) => m.content.replace(/\*\*/g, ''))
.join('\n')
openExpertConsult(
originalQuestion?.content || '',
supplement,
)
}}
/> />
) : msg.role === 'user' && !msg.isFactAnswer ? ( ) : msg.role === 'user' && !msg.isFactAnswer ? (
<p className="whitespace-pre-wrap leading-relaxed">{msg.content}</p> <p className="whitespace-pre-wrap leading-relaxed">{msg.content}</p>
@@ -579,7 +644,45 @@ export function ChatPanel({
className="prose-headings:text-primary-foreground prose-p:text-primary-foreground prose-li:text-primary-foreground prose-strong:text-primary-foreground" className="prose-headings:text-primary-foreground prose-p:text-primary-foreground prose-li:text-primary-foreground prose-strong:text-primary-foreground"
/> />
) : ( ) : (
<div>
<Markdown content={msg.content} streaming={msg.streaming} /> <Markdown content={msg.content} streaming={msg.streaming} />
{/* 参考来源 tag + 问专家按钮(流式完成后显示) */}
{!msg.streaming && (
<>
<div className="mt-2 flex flex-wrap items-center gap-1.5 border-t border-border/30 pt-2">
<span className="text-[10px] text-muted-foreground">参考来源:</span>
{msg.ragContext && msg.ragContext.length > 0 ? (
<>
<BookOpen className="h-3 w-3 text-muted-foreground" aria-hidden />
{msg.ragContext.map((doc, i) => (
<span key={i} className="flex items-center gap-1 text-[10px]">
<span className="font-medium">{doc.title}</span>
{doc.doc_type && (
<Badge variant="secondary" className="text-[10px]">
{DOC_TYPE_LABELS[doc.doc_type] || doc.doc_type}
</Badge>
)}
{doc.region && (
<Badge variant="outline" className="text-[10px]">{doc.region}</Badge>
)}
</span>
))}
</>
) : (
<>
<Sparkles className="h-3 w-3 text-muted-foreground" aria-hidden />
<Badge variant="outline" className="text-[10px]">AI 提供</Badge>
</>
)}
</div>
<div className="mt-2">
<Button size="sm" variant="outline" onClick={handleEscalate}>
<Users className="h-3.5 w-3.5" /> 问专家
</Button>
</div>
</>
)}
</div>
)} )}
</div> </div>
</div> </div>
@@ -610,15 +713,15 @@ export function ChatPanel({
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => { onClick={() => {
// 跳过追问,直接分析 // 跳过追问,进入确认区(带入内容 + 已有追问回答)
setFactQuestions([]) setFactQuestions([])
setFactInputs({}) setFactInputs({})
setLoading(true) const mergedMap = new Map<string, { dim_key: string; dim_desc: string; answer: string }>()
const originalQuestion = currentQuestionRef.current for (const f of profileFacts) mergedMap.set(f.dim_key, f)
ensureSession() for (const a of factAnswers) mergedMap.set(a.dim_key, a)
.then((currentSid) => streamAnalysis(currentSid, originalQuestion, factAnswers)) const merged = Array.from(mergedMap.values())
.catch((e) => toast.error(e instanceof Error ? e.message : '分析失败')) setFactConfirm(merged)
.finally(() => setLoading(false)) setFactConfirmEditing(Object.fromEntries(merged.map((a) => [a.dim_key, a.answer])))
}} }}
disabled={loading} disabled={loading}
> >
@@ -632,6 +735,96 @@ export function ChatPanel({
</div> </div>
)} )}
{/* 确认事实区:带入内容 + 追问内容,允许用户修改再继续 */}
{factConfirm.length > 0 && (
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4 space-y-2">
<div className="flex items-center gap-2 text-sm font-medium text-blue-900">
<CheckCircle className="h-4 w-4" aria-hidden />
确认事实
</div>
<p className="text-xs text-blue-700">请核对以下信息,可直接修改后继续分析。确认后将更新你的用户画像。</p>
<div className="grid gap-2 sm:grid-cols-2">
{factConfirm.map((f) => {
const isProfile = profileFacts.some((p) => p.dim_key === f.dim_key)
// 画像带入的字段值翻译为中文显示
const displayValue = isProfile
? (factConfirmEditing[f.dim_key] ?? translateProfileValue(f.answer))
: (factConfirmEditing[f.dim_key] ?? f.answer)
return (
<div key={f.dim_key} className="space-y-0.5">
<div className="flex items-center gap-1">
<Label className="text-xs text-blue-800">{f.dim_desc}</Label>
{isProfile && (
<Badge variant="secondary" className="text-[10px] px-1 py-0">画像</Badge>
)}
</div>
<Input
value={displayValue}
onChange={(e) => setFactConfirmEditing((prev) => ({ ...prev, [f.dim_key]: e.target.value }))}
className="bg-white h-8 text-sm"
/>
</div>
)
})}
</div>
<div className="flex flex-wrap items-center justify-between gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => {
// 取消确认,回到追问
setFactConfirm([])
setFactConfirmEditing({})
}}
disabled={loading}
>
返回修改
</Button>
<Button
onClick={async () => {
// 确认事实,更新用户画像,再开始流式分析
const confirmedAnswers = factConfirm.map((f) => ({
dim_key: f.dim_key,
dim_desc: f.dim_desc,
answer: (factConfirmEditing[f.dim_key] ?? '').trim() || f.answer,
}))
setFactAnswers(confirmedAnswers)
setFactConfirm([])
setFactConfirmEditing({})
setLoading(true)
const originalQuestion = currentQuestionRef.current
try {
const currentSid = sid || (await ensureSession())
// 更新用户画像(将确认的事实写回画像)
const profileUpdate: Record<string, string> = {}
const keyFactsUpdate: Record<string, string> = {}
for (const a of confirmedAnswers) {
if (['industry', 'company_size', 'role', 'risk_tolerance', 'decision_style'].includes(a.dim_key)) {
profileUpdate[a.dim_key] = a.answer
} else if (a.dim_key.startsWith('fact_')) {
keyFactsUpdate[a.dim_desc] = a.answer
}
}
if (Object.keys(profileUpdate).length > 0 || Object.keys(keyFactsUpdate).length > 0) {
api.updateProfile({ ...profileUpdate, key_facts: keyFactsUpdate }).catch(() => {})
}
await streamAnalysis(currentSid, originalQuestion, confirmedAnswers)
} catch (e) {
toast.error(e instanceof Error ? e.message : '分析失败')
} finally {
setLoading(false)
}
}}
disabled={loading}
size="sm"
>
{loading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Check className="h-3.5 w-3.5" />}
确认并分析
</Button>
</div>
</div>
)}
{/* 分析中提示 */} {/* 分析中提示 */}
{analyzing && ( {analyzing && (
<div className="flex items-center justify-center gap-2 py-3 text-sm text-muted-foreground"> <div className="flex items-center justify-center gap-2 py-3 text-sm text-muted-foreground">
@@ -686,6 +879,13 @@ export function ChatPanel({
</Button> </Button>
</div> </div>
</div> </div>
{/* 卡片底部脚注 */}
{footer && (
<p className="border-t pt-2 text-center text-xs text-muted-foreground">
{footer}
</p>
)}
</CardContent> </CardContent>
</Card> </Card>
+56 -10
View File
@@ -1,10 +1,20 @@
import { AlertOctagon, FileText, ListChecks, Users, Cloud, Cpu, AlertTriangle } from 'lucide-react' import { AlertOctagon, FileText, ListChecks, Users, Cloud, Cpu, AlertTriangle, BookOpen, Sparkles } from 'lucide-react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { StatusBadge } from './StatusBadge' import { StatusBadge } from './StatusBadge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Markdown } from './Markdown' import { Markdown } from './Markdown'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import type { RiskCard } from '@/types' import type { RiskCard, KnowledgeDoc } from '@/types'
/** 知识库文档类型标签映射 */
const DOC_TYPE_LABELS: Record<string, string> = {
law: '法规',
case: '案例',
guide: '指南',
industry: '行业',
template: '模板',
}
const BORDER: Record<string, string> = { const BORDER: Record<string, string> = {
green: 'border-l-emerald-500', green: 'border-l-emerald-500',
@@ -19,13 +29,15 @@ interface RiskCardViewProps {
model?: string model?: string
inferenceLocation?: string inferenceLocation?: string
fallbackUsed?: boolean fallbackUsed?: boolean
/** 知识库引用来源 */
ragContext?: KnowledgeDoc[]
/** 流式输出中(显示光标) */ /** 流式输出中(显示光标) */
streaming?: boolean streaming?: boolean
onEscalate?: () => void onEscalate?: () => void
} }
/** 风险卡片:等级 + 结论 + 行动 + 证据 + 升级提示 + 模型答复 + 推理位置 */ /** 风险卡片:等级 + 结论 + 行动 + 证据 + 升级提示 + 模型答复 + 推理位置 + 参考来源 */
export function RiskCardView({ card, answer, model, inferenceLocation, fallbackUsed, streaming, onEscalate }: RiskCardViewProps) { export function RiskCardView({ card, answer, model, inferenceLocation, fallbackUsed, ragContext, streaming, onEscalate }: RiskCardViewProps) {
return ( return (
<Card className={cn('border-l-4', BORDER[card.level])}> <Card className={cn('border-l-4', BORDER[card.level])}>
<CardHeader className="pb-3"> <CardHeader className="pb-3">
@@ -81,8 +93,47 @@ export function RiskCardView({ card, answer, model, inferenceLocation, fallbackU
{answer && ( {answer && (
<div className="rounded-md bg-muted/50 p-3"> <div className="rounded-md bg-muted/50 p-3">
<h4 className="mb-2 text-xs font-semibold text-muted-foreground">模型分析</h4> {/* 标题行:模型分析 + 参考来源 tag */}
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
<h4 className="text-xs font-semibold text-muted-foreground">模型分析</h4>
{/* 参考来源 tag:有知识库引用时展示文档 tag,无引用时标注 AI 提供 */}
{!streaming && (
<div className="flex flex-wrap items-center gap-1.5">
{ragContext && ragContext.length > 0 ? (
<>
<BookOpen className="h-3 w-3 text-muted-foreground" aria-hidden />
{ragContext.map((doc, i) => (
<span key={i} className="flex items-center gap-1 text-[10px]">
<span className="font-medium text-foreground">{doc.title}</span>
{doc.doc_type && (
<Badge variant="secondary" className="text-[10px]">
{DOC_TYPE_LABELS[doc.doc_type] || doc.doc_type}
</Badge>
)}
{doc.region && (
<Badge variant="outline" className="text-[10px]">{doc.region}</Badge>
)}
</span>
))}
</>
) : (
<>
<Sparkles className="h-3 w-3 text-muted-foreground" aria-hidden />
<Badge variant="outline" className="text-[10px]">AI 提供</Badge>
</>
)}
</div>
)}
</div>
<Markdown content={answer} streaming={streaming} className="text-sm" /> <Markdown content={answer} streaming={streaming} className="text-sm" />
{/* 问专家按钮:每次对话都显示 */}
{!streaming && onEscalate && (
<div className="mt-3 border-t pt-2">
<Button size="sm" variant="outline" onClick={onEscalate}>
<Users className="h-3.5 w-3.5" /> 问专家
</Button>
</div>
)}
</div> </div>
)} )}
@@ -95,11 +146,6 @@ export function RiskCardView({ card, answer, model, inferenceLocation, fallbackU
本事项可能造成明显损失或涉及重大风险,AI 输出不替代执业律师/会计师正式意见。 本事项可能造成明显损失或涉及重大风险,AI 输出不替代执业律师/会计师正式意见。
</p> </p>
</div> </div>
{onEscalate && (
<Button size="sm" variant="outline" className="shrink-0" onClick={onEscalate}>
<Users className="h-3.5 w-3.5" /> 问专家
</Button>
)}
</div> </div>
)} )}
</CardContent> </CardContent>
+2 -2
View File
@@ -27,7 +27,7 @@ export function Header({ nav }: { nav: React.ReactNode }) {
const { pathname } = useLocation() const { pathname } = useLocation()
const navigate = useNavigate() const navigate = useNavigate()
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const title = TITLES[pathname] ?? 'BossAi' const title = TITLES[pathname] ?? '左右(BossAi)'
const handleLogout = () => { const handleLogout = () => {
clearAuth() clearAuth()
@@ -71,7 +71,7 @@ export function Header({ nav }: { nav: React.ReactNode }) {
<div className="absolute inset-0 bg-black/60" onClick={() => setOpen(false)} /> <div className="absolute inset-0 bg-black/60" onClick={() => setOpen(false)} />
<div className={cn('absolute left-0 top-0 h-full w-60 bg-card p-3 shadow-lg')}> <div className={cn('absolute left-0 top-0 h-full w-60 bg-card p-3 shadow-lg')}>
<div className="flex h-11 items-center justify-between px-2"> <div className="flex h-11 items-center justify-between px-2">
<span className="font-semibold">BossAi</span> <span className="font-semibold">左右(BossAi)</span>
<Button variant="ghost" size="icon" aria-label="关闭菜单" onClick={() => setOpen(false)}> <Button variant="ghost" size="icon" aria-label="关闭菜单" onClick={() => setOpen(false)}>
<X className="h-5 w-5" /> <X className="h-5 w-5" />
</Button> </Button>
+59 -26
View File
@@ -19,37 +19,67 @@ import {
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { getRole, type Role } from '@/lib/auth' import { getRole, type Role } from '@/lib/auth'
/** 全部导航项(与 Sidebar 保持一致) */ /** 导航项类型(与 Sidebar 保持一致) */
const ALL_NAV = [ interface NavItem {
{ to: '/', label: '驾驶舱', icon: LayoutDashboard, end: true, roles: ['user'] as Role[] }, to: string
{ to: '/legal', label: '法务参谋', icon: Scale, roles: ['user'] as Role[] }, label: string
{ to: '/decisions', label: '经营决策', icon: TrendingUp, roles: ['user'] as Role[] }, icon: React.ComponentType<{ className?: string }>
{ to: '/life', label: '生活助理', icon: Heart, roles: ['user'] as Role[] }, end?: boolean
{ to: '/emotion', label: '情绪中心', icon: Smile, roles: ['user'] as Role[] }, roles: Role[]
{ to: '/knowledge', label: '知识库', icon: BookOpen, roles: ['user'] as Role[] }, group: string
{ to: '/experts', label: '专家库', icon: Users, roles: ['user'] as Role[] }, }
{ to: '/history', label: '对话历史', icon: History, roles: ['user'] as Role[] },
// 专家工作台菜单项(仅专家可见,拆分为独立页面) /** 全部导航项(按 group 分组渲染) */
{ to: '/workbench', label: '待确认服务', icon: Check, end: true, roles: ['expert'] as Role[] }, const ALL_NAV: NavItem[] = [
{ to: '/workbench/pending', label: '待回复咨询', icon: MessageSquare, roles: ['expert'] as Role[] }, // ===== 用户角色 =====
{ to: '/workbench/replied', label: '已回复咨询', icon: Star, roles: ['expert'] as Role[] }, { to: '/', label: '驾驶舱', icon: LayoutDashboard, end: true, roles: ['user'], group: '总览' },
{ to: '/workbench/users', label: '服务中用户', icon: Briefcase, roles: ['expert'] as Role[] }, { to: '/legal', label: '法务参谋', icon: Scale, roles: ['user'], group: 'AI 助手' },
// 管理后台菜单项(仅管理员可见) { to: '/decisions', label: '经营决策', icon: TrendingUp, roles: ['user'], group: 'AI 助手' },
{ to: '/admin', label: '平台统计', icon: BarChart3, end: true, roles: ['admin'] as Role[] }, { to: '/life', label: '生活助理', icon: Heart, roles: ['user'], group: 'AI 助手' },
{ to: '/admin/ai-usage', label: 'AI 使用', icon: MessageSquare, roles: ['admin'] as Role[] }, { to: '/emotion', label: '情绪中心', icon: Smile, roles: ['user'], group: 'AI 助手' },
{ to: '/admin/expert-activity', label: '专家沟通', icon: Activity, roles: ['admin'] as Role[] }, { to: '/knowledge', label: '知识库', icon: BookOpen, roles: ['user'], group: '我的资源' },
{ to: '/admin/users', label: '用户管理', icon: Users, roles: ['admin'] as Role[] }, { to: '/experts', label: '专家库', icon: Users, roles: ['user'], group: '我的资源' },
{ to: '/admin/experts', label: '专家管理', icon: Briefcase, roles: ['admin'] as Role[] }, { to: '/history', label: '对话历史', icon: History, roles: ['user'], group: '我的资源' },
{ to: '/settings', label: '设置', icon: Settings, roles: ['user'] as Role[] }, { to: '/settings', label: '设置', icon: Settings, roles: ['user'], group: '系统' },
// ===== 专家角色 =====
{ to: '/workbench', label: '待确认服务', icon: Check, end: true, roles: ['expert'], group: '工作台' },
{ to: '/workbench/pending', label: '待回复咨询', icon: MessageSquare, roles: ['expert'], group: '工作台' },
{ to: '/workbench/replied', label: '已回复咨询', icon: Star, roles: ['expert'], group: '工作台' },
{ to: '/workbench/users', label: '服务中用户', icon: Briefcase, roles: ['expert'], group: '工作台' },
// ===== 管理员角色 =====
{ to: '/admin', label: '平台统计', icon: BarChart3, end: true, roles: ['admin'], group: '平台管理' },
{ to: '/admin/ai-usage', label: 'AI 使用', icon: MessageSquare, roles: ['admin'], group: '平台管理' },
{ to: '/admin/expert-activity', label: '专家沟通', icon: Activity, roles: ['admin'], group: '平台管理' },
{ to: '/admin/users', label: '用户管理', icon: Users, roles: ['admin'], group: '平台管理' },
{ to: '/admin/experts', label: '专家管理', icon: Briefcase, roles: ['admin'], group: '平台管理' },
] ]
/** 移动端抽屉内导航:按角色过滤 */ /** 移动端抽屉内导航:按角色过滤并分组 */
export function MobileNav() { export function MobileNav() {
const currentRole = getRole() const currentRole = getRole()
const nav = ALL_NAV.filter((item) => item.roles.includes(currentRole)) const nav = ALL_NAV.filter((item) => item.roles.includes(currentRole))
// 按 group 分组,保持原始顺序
const groups: { name: string; items: NavItem[] }[] = []
for (const item of nav) {
let g = groups.find(g => g.name === item.group)
if (!g) {
g = { name: item.group, items: [] }
groups.push(g)
}
g.items.push(item)
}
return ( return (
<nav className="mt-2 space-y-1" aria-label="移动端导航"> <nav className="mt-2 space-y-4" aria-label="移动端导航">
{nav.map((item) => ( {groups.map((group) => (
<div key={group.name}>
<p className="mb-1 px-3 text-[11px] font-medium uppercase tracking-wider text-muted-foreground/70">
{group.name}
</p>
<div className="space-y-1">
{group.items.map((item) => (
<NavLink <NavLink
key={item.to} key={item.to}
to={item.to} to={item.to}
@@ -63,10 +93,13 @@ export function MobileNav() {
) )
} }
> >
<item.icon className="h-4 w-4" aria-hidden /> <item.icon className="h-4 w-4 shrink-0" aria-hidden />
{item.label} {item.label}
</NavLink> </NavLink>
))} ))}
</div>
</div>
))}
</nav> </nav>
) )
} }
+65 -27
View File
@@ -16,46 +16,81 @@ import {
Activity, Activity,
Check, Check,
Star, Star,
UserCircle,
} from 'lucide-react' } from 'lucide-react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { getRole, type Role } from '@/lib/auth' import { getRole, type Role } from '@/lib/auth'
/** 全部导航项 */ /** 导航项类型 */
const ALL_NAV = [ interface NavItem {
{ to: '/', label: '驾驶舱', icon: LayoutDashboard, end: true, roles: ['user'] as Role[] }, to: string
{ to: '/legal', label: '法务参谋', icon: Scale, roles: ['user'] as Role[] }, label: string
{ to: '/decisions', label: '经营决策', icon: TrendingUp, roles: ['user'] as Role[] }, icon: React.ComponentType<{ className?: string }>
{ to: '/life', label: '生活助理', icon: Heart, roles: ['user'] as Role[] }, end?: boolean
{ to: '/emotion', label: '情绪中心', icon: Smile, roles: ['user'] as Role[] }, roles: Role[]
{ to: '/knowledge', label: '知识库', icon: BookOpen, roles: ['user'] as Role[] }, group: string
{ to: '/experts', label: '专家库', icon: Users, roles: ['user'] as Role[] }, }
{ to: '/history', label: '对话历史', icon: History, roles: ['user'] as Role[] },
// 专家工作台菜单项(仅专家可见,拆分为独立页面) /** 全部导航项(按 group 分组渲染) */
{ to: '/workbench', label: '待确认服务', icon: Check, end: true, roles: ['expert'] as Role[] }, const ALL_NAV: NavItem[] = [
{ to: '/workbench/pending', label: '待回复咨询', icon: MessageSquare, roles: ['expert'] as Role[] }, // ===== 用户角色 =====
{ to: '/workbench/replied', label: '已回复咨询', icon: Star, roles: ['expert'] as Role[] }, // 总览
{ to: '/workbench/users', label: '服务中用户', icon: Briefcase, roles: ['expert'] as Role[] }, { to: '/', label: '驾驶舱', icon: LayoutDashboard, end: true, roles: ['user'], group: '总览' },
// 管理后台菜单项(仅管理员可见) // AI 助手
{ to: '/admin', label: '平台统计', icon: BarChart3, end: true, roles: ['admin'] as Role[] }, { to: '/legal', label: '法务参谋', icon: Scale, roles: ['user'], group: 'AI 助手' },
{ to: '/admin/ai-usage', label: 'AI 使用', icon: MessageSquare, roles: ['admin'] as Role[] }, { to: '/decisions', label: '经营决策', icon: TrendingUp, roles: ['user'], group: 'AI 助手' },
{ to: '/admin/expert-activity', label: '专家沟通', icon: Activity, roles: ['admin'] as Role[] }, { to: '/life', label: '生活助理', icon: Heart, roles: ['user'], group: 'AI 助手' },
{ to: '/admin/users', label: '用户管理', icon: Users, roles: ['admin'] as Role[] }, { to: '/emotion', label: '情绪中心', icon: Smile, roles: ['user'], group: 'AI 助手' },
{ to: '/admin/experts', label: '专家管理', icon: Briefcase, roles: ['admin'] as Role[] }, // 我的资源
{ to: '/settings', label: '设置', icon: Settings, roles: ['user'] as Role[] }, { to: '/knowledge', label: '知识库', icon: BookOpen, roles: ['user'], group: '我的资源' },
{ to: '/experts', label: '专家库', icon: Users, roles: ['user'], group: '我的资源' },
{ to: '/history', label: '对话历史', icon: History, roles: ['user'], group: '我的资源' },
{ to: '/profile', label: '我的画像', icon: UserCircle, roles: ['user'], group: '我的资源' },
// 系统
{ to: '/settings', label: '设置', icon: Settings, roles: ['user'], group: '系统' },
// ===== 专家角色 =====
{ to: '/workbench', label: '待确认服务', icon: Check, end: true, roles: ['expert'], group: '工作台' },
{ to: '/workbench/pending', label: '待回复咨询', icon: MessageSquare, roles: ['expert'], group: '工作台' },
{ to: '/workbench/replied', label: '已回复咨询', icon: Star, roles: ['expert'], group: '工作台' },
{ to: '/workbench/users', label: '服务中用户', icon: Briefcase, roles: ['expert'], group: '工作台' },
// ===== 管理员角色 =====
{ to: '/admin', label: '平台统计', icon: BarChart3, end: true, roles: ['admin'], group: '平台管理' },
{ to: '/admin/ai-usage', label: 'AI 使用', icon: MessageSquare, roles: ['admin'], group: '平台管理' },
{ to: '/admin/expert-activity', label: '专家沟通', icon: Activity, roles: ['admin'], group: '平台管理' },
{ to: '/admin/users', label: '用户管理', icon: Users, roles: ['admin'], group: '平台管理' },
{ to: '/admin/experts', label: '专家管理', icon: Briefcase, roles: ['admin'], group: '平台管理' },
] ]
export function Sidebar() { export function Sidebar() {
const currentRole = getRole() const currentRole = getRole()
const nav = ALL_NAV.filter((item) => item.roles.includes(currentRole)) const nav = ALL_NAV.filter((item) => item.roles.includes(currentRole))
// 按 group 分组,保持原始顺序
const groups: { name: string; items: NavItem[] }[] = []
for (const item of nav) {
let g = groups.find(g => g.name === item.group)
if (!g) {
g = { name: item.group, items: [] }
groups.push(g)
}
g.items.push(item)
}
return ( return (
<aside className="hidden md:flex md:w-60 md:flex-col md:fixed md:inset-y-0 md:border-r md:bg-card"> <aside className="hidden md:flex md:w-60 md:flex-col md:fixed md:inset-y-0 md:border-r md:bg-card">
<div className="flex h-14 items-center gap-2 border-b px-5"> <div className="flex h-14 items-center gap-2 border-b px-5">
<ShieldCheck className="h-5 w-5 text-primary" aria-hidden /> <ShieldCheck className="h-5 w-5 text-primary" aria-hidden />
<span className="font-semibold tracking-tight">BossAi</span> <span className="font-semibold tracking-tight">左右(BossAi)</span>
<span className="ml-auto text-[10px] text-muted-foreground">BossAi</span>
</div> </div>
<nav className="flex-1 space-y-1 p-3" aria-label="主导航"> <nav className="flex-1 space-y-4 overflow-y-auto p-3" aria-label="主导航">
{nav.map((item) => ( {groups.map((group) => (
<div key={group.name}>
<p className="mb-1 px-3 text-[11px] font-medium uppercase tracking-wider text-muted-foreground/70">
{group.name}
</p>
<div className="space-y-1">
{group.items.map((item) => (
<NavLink <NavLink
key={item.to} key={item.to}
to={item.to} to={item.to}
@@ -69,10 +104,13 @@ export function Sidebar() {
) )
} }
> >
<item.icon className="h-4 w-4" aria-hidden /> <item.icon className="h-4 w-4 shrink-0" aria-hidden />
{item.label} {item.label}
</NavLink> </NavLink>
))} ))}
</div>
</div>
))}
</nav> </nav>
<div className="border-t p-4 text-xs text-muted-foreground"> <div className="border-t p-4 text-xs text-muted-foreground">
<p>老板敢问,系统敢忘</p> <p>老板敢问,系统敢忘</p>
+37 -1
View File
@@ -168,6 +168,24 @@ export const api = {
getProfile: () => getProfile: () =>
requestWithRetry<Record<string, unknown>>('/profile/me', {}), requestWithRetry<Record<string, unknown>>('/profile/me', {}),
/** 更新用户画像(确认事实后调用) */
updateProfile: (data: {
industry?: string
company_size?: string
role?: string
risk_tolerance?: string
decision_style?: string
key_facts?: Record<string, string>
}) =>
request<{ updated: boolean }>('/profile/me', {
method: 'PUT',
body: JSON.stringify(data),
}),
/** 获取用户画像历史版本 */
getProfileHistory: () =>
request<{ results: Array<{ id: number; version: number; profile: Record<string, unknown>; source: string; created_at: string }> }>('/profile/me/history'),
/** 事实追问分析:返回追问问题或标记事实已充分 */ /** 事实追问分析:返回追问问题或标记事实已充分 */
askAnalyze: ( askAnalyze: (
sid: string, sid: string,
@@ -184,6 +202,7 @@ export const api = {
fact_summary?: string fact_summary?: string
question_type: string question_type: string
risk_level: string risk_level: string
profile_facts?: Array<{ dim_key: string; dim_desc: string; answer: string }>
}>(`/sessions/${sid}/ask-analyze`, { }>(`/sessions/${sid}/ask-analyze`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
@@ -419,7 +438,24 @@ export const api = {
}, { timeout: 30000, retries: 0 }) }, { timeout: 30000, retries: 0 })
}, },
adminListUsers: () => request<{ results: Array<{ id: number; phone: string; name: string; created_at: string }> }>('/admin/users'), adminListUsers: (q?: string) =>
request<{ results: Array<{ id: number; phone: string; name: string; status: string; created_at: string; message_count: number; last_active: string | null; last_scene: string | null }> }>(
`/admin/users${q ? `?q=${encodeURIComponent(q)}` : ''}`,
),
/** 管理端:重置用户密码 */
adminResetPassword: (userId: number, password: string) =>
request<{ reset: boolean }>(`/admin/users/${userId}/reset-password`, {
method: 'POST',
body: JSON.stringify({ password }),
}),
/** 管理端:激活/停用用户 */
adminUpdateUserStatus: (userId: number, status: 'active' | 'inactive') =>
request<{ status: string }>(`/admin/users/${userId}/status`, {
method: 'POST',
body: JSON.stringify({ status }),
}),
adminStats: () => request<{ adminStats: () => request<{
users: number users: number
+91 -18
View File
@@ -9,11 +9,14 @@ import {
Shield, Shield,
ChevronRight, ChevronRight,
ArrowLeft, ArrowLeft,
BookOpen,
Sparkles,
} from 'lucide-react' } from 'lucide-react'
import { PageLayout } from '@/components/common/PageLayout' import { PageLayout } from '@/components/common/PageLayout'
import { EmptyState } from '@/components/common/EmptyState' import { EmptyState } from '@/components/common/EmptyState'
import { LoadingState } from '@/components/common/LoadingState' import { LoadingState } from '@/components/common/LoadingState'
import { ErrorState } from '@/components/common/ErrorState' import { ErrorState } from '@/components/common/ErrorState'
import { Markdown } from '@/components/common/Markdown'
import { Card, CardContent } from '@/components/ui/card' import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
@@ -46,6 +49,15 @@ const RISK_COLORS: Record<string, string> = {
red: 'bg-red-100 text-red-700', red: 'bg-red-100 text-red-700',
} }
/** 知识库文档类型标签映射 */
const DOC_TYPE_LABELS: Record<string, string> = {
law: '法规',
case: '案例',
guide: '指南',
industry: '行业',
template: '模板',
}
/** 场景中文映射 */ /** 场景中文映射 */
const SCENE_LABELS: Record<string, string> = { const SCENE_LABELS: Record<string, string> = {
legal: '法务参谋', legal: '法务参谋',
@@ -202,18 +214,19 @@ export function ChatHistory() {
<div className="mb-4 flex items-center gap-3 border-b pb-3"> <div className="mb-4 flex items-center gap-3 border-b pb-3">
<modeCfg.icon className="h-5 w-5 text-muted-foreground" /> <modeCfg.icon className="h-5 w-5 text-muted-foreground" />
<div className="flex-1"> <div className="flex-1">
<div className="flex items-center gap-2"> <p className="font-medium">{currentSession.title || '未命名对话'}</p>
<span className="font-medium">{modeCfg.label}</span> <div className="mt-0.5 flex flex-wrap items-center gap-2">
<span className="text-xs text-muted-foreground">{modeCfg.label}</span>
{currentSession.scene && ( {currentSession.scene && (
<Badge variant="secondary" className="text-xs"> <Badge variant="secondary" className="text-xs">
{SCENE_LABELS[currentSession.scene] || currentSession.scene} {SCENE_LABELS[currentSession.scene] || currentSession.scene}
</Badge> </Badge>
)} )}
</div> <span className="text-xs text-muted-foreground">
<p className="mt-0.5 text-xs text-muted-foreground">
{formatTime(currentSession.first_message_at)} ~ {formatTime(currentSession.last_message_at)} {formatTime(currentSession.first_message_at)} ~ {formatTime(currentSession.last_message_at)}
{' · '}{currentSession.message_count} 条消息 {' · '}{currentSession.message_count} 条消息
</p> </span>
</div>
</div> </div>
</div> </div>
@@ -315,8 +328,36 @@ export function ChatHistory() {
description="普通模式和保密模式的对话会保存在这里,阅后即焚不落库" description="普通模式和保密模式的对话会保存在这里,阅后即焚不落库"
/> />
) : ( ) : (
<div className="space-y-6">
{/* 按场景分组,每组内按时间倒序 */}
{(() => {
// 定义场景顺序
const SCENE_ORDER = ['legal', 'decision', 'life', 'emotion']
// 按 scene 分组(无 scene 归为"其他")
const groups = new Map<string, HistorySession[]>()
for (const s of sessions) {
const key = s.scene || 'other'
if (!groups.has(key)) groups.set(key, [])
groups.get(key)!.push(s)
}
// 每组内按 last_message_at 倒序
for (const items of groups.values()) {
items.sort((a, b) => {
const ta = a.last_message_at ? new Date(a.last_message_at).getTime() : 0
const tb = b.last_message_at ? new Date(b.last_message_at).getTime() : 0
return tb - ta
})
}
// 渲染:按 SCENE_ORDER 顺序,最后是 other
const orderedKeys = [...SCENE_ORDER.filter(k => groups.has(k)), ...['other']].filter(k => groups.has(k))
return orderedKeys.map((key) => (
<div key={key}>
<p className="mb-2 px-1 text-xs font-medium uppercase tracking-wider text-muted-foreground/70">
{SCENE_LABELS[key] || '其他'}
<span className="ml-1.5 text-muted-foreground/50">{groups.get(key)!.length}</span>
</p>
<div className="space-y-2"> <div className="space-y-2">
{sessions.map((session) => { {groups.get(key)!.map((session) => {
const modeCfg = MODE_CONFIG[session.mode] const modeCfg = MODE_CONFIG[session.mode]
return ( return (
<Card <Card
@@ -329,20 +370,13 @@ export function ChatHistory() {
<modeCfg.icon className="h-5 w-5" /> <modeCfg.icon className="h-5 w-5" />
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2"> <p className="truncate text-sm font-medium">{session.title || '未命名对话'}</p>
<span className="text-sm font-medium">{modeCfg.label}</span> <div className="mt-0.5 flex flex-wrap items-center gap-2">
{session.scene && ( <span className="text-xs text-muted-foreground">{modeCfg.label}</span>
<Badge variant="secondary" className="text-xs">
{SCENE_LABELS[session.scene] || session.scene}
</Badge>
)}
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{session.message_count} 条 {session.message_count} 条 · {formatTime(session.last_message_at)}
</span> </span>
</div> </div>
<p className="mt-0.5 truncate text-xs text-muted-foreground">
{formatTime(session.last_message_at)}
</p>
</div> </div>
<ChevronRight className="h-4 w-4 text-muted-foreground" /> <ChevronRight className="h-4 w-4 text-muted-foreground" />
</CardContent> </CardContent>
@@ -350,6 +384,10 @@ export function ChatHistory() {
) )
})} })}
</div> </div>
</div>
))
})()}
</div>
)} )}
<DeleteDialog <DeleteDialog
@@ -369,8 +407,12 @@ function MessageBubble({ message }: { message: HistoryMessage }) {
return ( return (
<div className={cn('flex', isUser ? 'justify-end' : 'justify-start')}> <div className={cn('flex', isUser ? 'justify-end' : 'justify-start')}>
<div className={cn('max-w-[80%] rounded-lg p-3', isUser ? 'bg-primary text-primary-foreground' : 'bg-muted')}> <div className={cn('max-w-[80%] rounded-lg p-3', isUser ? 'bg-primary text-primary-foreground' : 'bg-muted')}>
{/* 消息正文 */} {/* 消息正文:用户消息纯文本,AI 消息 Markdown 渲染 */}
{isUser ? (
<p className="whitespace-pre-wrap text-sm">{message.content}</p> <p className="whitespace-pre-wrap text-sm">{message.content}</p>
) : (
<Markdown content={message.content} className="text-sm" />
)}
{/* AI 回复的风险卡信息 */} {/* AI 回复的风险卡信息 */}
{!isUser && riskLevel && ( {!isUser && riskLevel && (
@@ -398,6 +440,37 @@ function MessageBubble({ message }: { message: HistoryMessage }) {
</div> </div>
)} )}
{/* 参考来源:知识库引用 tag 或 AI 提供 tag */}
{!isUser && (
<div className="mt-2 border-t border-border/30 pt-2">
<div className="mb-1 flex items-center gap-1 text-[10px] text-muted-foreground">
<BookOpen className="h-3 w-3" /> 参考来源
</div>
{message.meta?.rag_context && message.meta.rag_context.length > 0 ? (
<div className="space-y-1">
{message.meta.rag_context.map((doc, i) => (
<div key={i} className="flex flex-wrap items-center gap-1 text-xs">
<span className="font-medium">{doc.title}</span>
{doc.doc_type && (
<Badge variant="secondary" className="text-[10px]">
{DOC_TYPE_LABELS[doc.doc_type] || doc.doc_type}
</Badge>
)}
{doc.region && (
<Badge variant="outline" className="text-[10px]">{doc.region}</Badge>
)}
</div>
))}
</div>
) : (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Sparkles className="h-3 w-3" />
<Badge variant="outline" className="text-[10px]">AI 提供</Badge>
</div>
)}
</div>
)}
{/* 时间戳 */} {/* 时间戳 */}
<p className={cn('mt-1 text-[10px]', isUser ? 'text-primary-foreground/60' : 'text-muted-foreground')}> <p className={cn('mt-1 text-[10px]', isUser ? 'text-primary-foreground/60' : 'text-muted-foreground')}>
{formatTime(message.created_at)} {formatTime(message.created_at)}
+13 -31
View File
@@ -1,17 +1,10 @@
import { useState } from 'react' import { useState } from 'react'
import { PageLayout } from '@/components/common/PageLayout' import { PageLayout } from '@/components/common/PageLayout'
import { ChatPanel } from '@/components/common/ChatPanel' import { ChatPanel } from '@/components/common/ChatPanel'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader } from '@/components/ui/card'
import { Lightbulb, Scale, TrendingUp, Wallet, Store, Handshake, Tag, UserPlus, Repeat } from 'lucide-react' import { Wallet, Store, Handshake, Tag, UserPlus, Repeat, ChevronDown } from 'lucide-react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
const FRAMEWORK = [
{ icon: TrendingUp, title: '预期收益', desc: '各方案的收益估算与时间线' },
{ icon: Scale, title: '主要风险', desc: '每个方案的核心风险点' },
{ icon: Wallet, title: '现金流影响', desc: '对短期和长期现金流的冲击' },
{ icon: Lightbulb, title: '推荐程度', desc: '综合评估后的推荐等级' },
]
/** 决策话题:每个话题对应不同的示例问题 */ /** 决策话题:每个话题对应不同的示例问题 */
const TOPICS = [ const TOPICS = [
{ {
@@ -85,6 +78,7 @@ const TOPICS = [
/** 经营决策助手:情景推演、方案对比、收益/风险/现金流/推荐程度 */ /** 经营决策助手:情景推演、方案对比、收益/风险/现金流/推荐程度 */
export function Decisions() { export function Decisions() {
const [selectedTopic, setSelectedTopic] = useState(0) const [selectedTopic, setSelectedTopic] = useState(0)
const [topicsCollapsed, setTopicsCollapsed] = useState(false)
return ( return (
<PageLayout <PageLayout
@@ -93,25 +87,15 @@ export function Decisions() {
> >
<Card> <Card>
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<CardTitle className="text-base">决策推演框架</CardTitle> <button
</CardHeader> onClick={() => setTopicsCollapsed(!topicsCollapsed)}
<CardContent> className="flex items-center gap-1.5 text-base font-semibold transition-opacity hover:opacity-70"
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4"> >
{FRAMEWORK.map((f) => ( <ChevronDown className={cn('h-4 w-4 transition-transform', topicsCollapsed && '-rotate-90')} />
<div key={f.title} className="rounded-md border p-3"> 决策话题
<f.icon className="mb-2 h-5 w-5 text-primary" aria-hidden /> </button>
<p className="text-sm font-medium">{f.title}</p>
<p className="mt-1 text-xs text-muted-foreground">{f.desc}</p>
</div>
))}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">决策话题</CardTitle>
</CardHeader> </CardHeader>
{!topicsCollapsed && (
<CardContent> <CardContent>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{TOPICS.map((t, i) => ( {TOPICS.map((t, i) => (
@@ -134,6 +118,7 @@ export function Decisions() {
))} ))}
</div> </div>
</CardContent> </CardContent>
)}
</Card> </Card>
<ChatPanel <ChatPanel
@@ -144,11 +129,8 @@ export function Decisions() {
placeholder={`描述你的${TOPICS[selectedTopic].title}问题…`} placeholder={`描述你的${TOPICS[selectedTopic].title}问题…`}
showRiskCard={false} showRiskCard={false}
enableUpload={false} enableUpload={false}
footer="AI 提供情景推演和利弊分析,最终决策由老板做出。"
/> />
<p className="text-center text-xs text-muted-foreground">
AI 提供情景推演和利弊分析,最终决策由老板做出。
</p>
</PageLayout> </PageLayout>
) )
} }
+13 -7
View File
@@ -1,8 +1,8 @@
import { useState } from 'react' import { useState } from 'react'
import { Brain, HeartHandshake, Zap, Phone } from 'lucide-react' import { Brain, HeartHandshake, Zap, Phone, ChevronDown } from 'lucide-react'
import { PageLayout } from '@/components/common/PageLayout' import { PageLayout } from '@/components/common/PageLayout'
import { ChatPanel } from '@/components/common/ChatPanel' import { ChatPanel } from '@/components/common/ChatPanel'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader } from '@/components/ui/card'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import type { EmotionMode } from '@/types' import type { EmotionMode } from '@/types'
@@ -48,6 +48,7 @@ const MODES: { value: EmotionMode; label: string; desc: string; icon: typeof Bra
/** 情绪价值引擎:三模式切换 + 危机干预入口 */ /** 情绪价值引擎:三模式切换 + 危机干预入口 */
export function EmotionCenter() { export function EmotionCenter() {
const [emotionMode, setEmotionMode] = useState<EmotionMode>('calm') const [emotionMode, setEmotionMode] = useState<EmotionMode>('calm')
const [modesCollapsed, setModesCollapsed] = useState(false)
return ( return (
<PageLayout <PageLayout
@@ -70,8 +71,15 @@ export function EmotionCenter() {
{/* 情绪模式选择器 */} {/* 情绪模式选择器 */}
<Card> <Card>
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<CardTitle className="text-base">回复模式</CardTitle> <button
onClick={() => setModesCollapsed(!modesCollapsed)}
className="flex items-center gap-1.5 text-base font-semibold transition-opacity hover:opacity-70"
>
<ChevronDown className={cn('h-4 w-4 transition-transform', modesCollapsed && '-rotate-90')} />
回复模式
</button>
</CardHeader> </CardHeader>
{!modesCollapsed && (
<CardContent> <CardContent>
<div className="grid gap-3 sm:grid-cols-3"> <div className="grid gap-3 sm:grid-cols-3">
{MODES.map((m) => ( {MODES.map((m) => (
@@ -92,6 +100,7 @@ export function EmotionCenter() {
))} ))}
</div> </div>
</CardContent> </CardContent>
)}
</Card> </Card>
<ChatPanel <ChatPanel
@@ -103,11 +112,8 @@ export function EmotionCenter() {
placeholder="说说你现在的感受…" placeholder="说说你现在的感受…"
showRiskCard={false} showRiskCard={false}
enableUpload={false} enableUpload={false}
footer="情绪对话默认使用阅后即焚模式,退出后不留痕迹。传统文化内容仅供反思与陪伴。"
/> />
<p className="text-center text-xs text-muted-foreground">
情绪对话默认使用阅后即焚模式,退出后不留痕迹。传统文化内容仅供反思与陪伴。
</p>
</PageLayout> </PageLayout>
) )
} }
+13 -7
View File
@@ -1,8 +1,8 @@
import { useState } from 'react' import { useState } from 'react'
import { PageLayout } from '@/components/common/PageLayout' import { PageLayout } from '@/components/common/PageLayout'
import { ChatPanel } from '@/components/common/ChatPanel' import { ChatPanel } from '@/components/common/ChatPanel'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader } from '@/components/ui/card'
import { Briefcase, FileText, Coins, Users, Receipt, Gavel } from 'lucide-react' import { Briefcase, FileText, Coins, Users, Receipt, Gavel, ChevronDown } from 'lucide-react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
/** 法务话题:每个话题对应不同的示例问题 */ /** 法务话题:每个话题对应不同的示例问题 */
@@ -78,6 +78,7 @@ const TOPICS = [
/** 法务参谋:劳动用工、合同审查、债权债务、股东纠纷、税务风险 */ /** 法务参谋:劳动用工、合同审查、债权债务、股东纠纷、税务风险 */
export function LegalAdvisor() { export function LegalAdvisor() {
const [selectedTopic, setSelectedTopic] = useState(0) const [selectedTopic, setSelectedTopic] = useState(0)
const [topicsCollapsed, setTopicsCollapsed] = useState(false)
return ( return (
<PageLayout <PageLayout
@@ -86,8 +87,15 @@ export function LegalAdvisor() {
> >
<Card> <Card>
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<CardTitle className="text-base">法务话题</CardTitle> <button
onClick={() => setTopicsCollapsed(!topicsCollapsed)}
className="flex items-center gap-1.5 text-base font-semibold transition-opacity hover:opacity-70"
>
<ChevronDown className={cn('h-4 w-4 transition-transform', topicsCollapsed && '-rotate-90')} />
法务话题
</button>
</CardHeader> </CardHeader>
{!topicsCollapsed && (
<CardContent> <CardContent>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{TOPICS.map((t, i) => ( {TOPICS.map((t, i) => (
@@ -110,6 +118,7 @@ export function LegalAdvisor() {
))} ))}
</div> </div>
</CardContent> </CardContent>
)}
</Card> </Card>
<ChatPanel <ChatPanel
@@ -120,11 +129,8 @@ export function LegalAdvisor() {
placeholder={`描述你的${TOPICS[selectedTopic].title}问题…`} placeholder={`描述你的${TOPICS[selectedTopic].title}问题…`}
showRiskCard showRiskCard
enableUpload enableUpload
footer="AI 输出仅供参考,不构成律师、会计师、税务师或医生的正式意见。重大风险事项请寻求执业专业人士复核。"
/> />
<p className="text-center text-xs text-muted-foreground">
AI 输出仅供参考,不构成律师、会计师、税务师或医生的正式意见。
重大风险事项请寻求执业专业人士复核。
</p>
</PageLayout> </PageLayout>
) )
} }
+13 -3
View File
@@ -1,8 +1,8 @@
import { useState } from 'react' import { useState } from 'react'
import { PageLayout } from '@/components/common/PageLayout' import { PageLayout } from '@/components/common/PageLayout'
import { ChatPanel } from '@/components/common/ChatPanel' import { ChatPanel } from '@/components/common/ChatPanel'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader } from '@/components/ui/card'
import { Heart, Users, Plane, Moon, GraduationCap, Stethoscope } from 'lucide-react' import { Heart, Users, Plane, Moon, GraduationCap, Stethoscope, ChevronDown } from 'lucide-react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
/** 话题定义:每个话题对应不同的示例问题 */ /** 话题定义:每个话题对应不同的示例问题 */
@@ -72,6 +72,7 @@ const TOPICS = [
/** 老板生活助理:降低非经营决策负担 */ /** 老板生活助理:降低非经营决策负担 */
export function LifeAssistant() { export function LifeAssistant() {
const [selectedTopic, setSelectedTopic] = useState(0) const [selectedTopic, setSelectedTopic] = useState(0)
const [topicsCollapsed, setTopicsCollapsed] = useState(false)
return ( return (
<PageLayout <PageLayout
@@ -80,8 +81,15 @@ export function LifeAssistant() {
> >
<Card> <Card>
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<CardTitle className="text-base">支持话题</CardTitle> <button
onClick={() => setTopicsCollapsed(!topicsCollapsed)}
className="flex items-center gap-1.5 text-base font-semibold transition-opacity hover:opacity-70"
>
<ChevronDown className={cn('h-4 w-4 transition-transform', topicsCollapsed && '-rotate-90')} />
支持话题
</button>
</CardHeader> </CardHeader>
{!topicsCollapsed && (
<CardContent> <CardContent>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{TOPICS.map((t, i) => ( {TOPICS.map((t, i) => (
@@ -104,6 +112,7 @@ export function LifeAssistant() {
))} ))}
</div> </div>
</CardContent> </CardContent>
)}
</Card> </Card>
<ChatPanel <ChatPanel
@@ -114,6 +123,7 @@ export function LifeAssistant() {
placeholder={`描述你的${TOPICS[selectedTopic].title}问题…`} placeholder={`描述你的${TOPICS[selectedTopic].title}问题…`}
showRiskCard={false} showRiskCard={false}
enableUpload={false} enableUpload={false}
footer="AI 输出仅供参考,重大事项请咨询相关领域专业人士。"
/> />
</PageLayout> </PageLayout>
) )
+4 -1
View File
@@ -9,6 +9,7 @@ import { Label } from '@/components/ui/label'
import { toast } from 'sonner' import { toast } from 'sonner'
import { Loader2, ShieldCheck, UserCog, Briefcase, Shield, User } from 'lucide-react' import { Loader2, ShieldCheck, UserCog, Briefcase, Shield, User } from 'lucide-react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { Toaster } from '@/components/ui/sonner'
/** 角色配置 */ /** 角色配置 */
const ROLES: Array<{ const ROLES: Array<{
@@ -130,7 +131,7 @@ export function Login() {
<ShieldCheck className="h-6 w-6 text-primary" /> <ShieldCheck className="h-6 w-6 text-primary" />
</div> </div>
<CardTitle className="text-xl"> <CardTitle className="text-xl">
{mode === 'login' ? '登录 BossAi' : '注册 BossAi'} {mode === 'login' ? '登录 左右(BossAi)' : '注册 左右(BossAi)'}
</CardTitle> </CardTitle>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{currentRole.desc} {currentRole.desc}
@@ -229,6 +230,8 @@ export function Login() {
)} )}
</CardContent> </CardContent>
</Card> </Card>
{/* Login 页在 AppLayout 之外,需自带 Toaster 才能显示 toast */}
<Toaster />
</div> </div>
) )
} }
+316
View File
@@ -0,0 +1,316 @@
import { useEffect, useState, useCallback } from 'react'
import { UserCircle, History, ChevronRight, ArrowLeft } from 'lucide-react'
import { PageLayout } from '@/components/common/PageLayout'
import { EmptyState } from '@/components/common/EmptyState'
import { LoadingState } from '@/components/common/LoadingState'
import { ErrorState } from '@/components/common/ErrorState'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { api } from '@/lib/api'
import { cn } from '@/lib/utils'
/** 用户画像数据 */
interface ProfileData {
phone: string
industry: string
company_size: string
role: string
common_scenes: string[]
common_topics: string[]
risk_tolerance: string
communication_style: string
decision_style: string
key_facts: Record<string, string>
interaction_count: number
last_active: string
}
/** 画像历史版本 */
interface ProfileHistory {
id: number
version: number
profile: Record<string, unknown>
source: string
created_at: string
}
/** 字段中文标签 */
const FIELD_LABELS: Record<string, string> = {
industry: '行业',
company_size: '企业规模',
role: '角色',
risk_tolerance: '风险偏好',
communication_style: '沟通偏好',
decision_style: '决策风格',
}
/** 企业规模映射 */
const SIZE_LABELS: Record<string, string> = {
micro: '微型(<10人)',
small: '小型(10-50人)',
medium: '中型(50-200人)',
large: '大型(200+人)',
}
/** 风险偏好映射 */
const RISK_LABELS: Record<string, string> = {
conservative: '保守型',
moderate: '稳健型',
aggressive: '进取型',
}
/** 场景中文映射 */
const SCENE_LABELS: Record<string, string> = {
legal: '法务参谋',
decision: '经营决策',
life: '生活助理',
emotion: '情绪中心',
}
/** 来源映射 */
const SOURCE_LABELS: Record<string, string> = {
auto: 'AI 自动提取',
manual: '用户确认',
}
/** 格式化时间 */
function formatTime(dt: string): string {
if (!dt) return '-'
return new Date(dt).toLocaleString('zh-CN', {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit',
})
}
/** 格式化字段值 */
function formatValue(key: string, value: string): string {
if (key === 'company_size') return SIZE_LABELS[value] || value
if (key === 'risk_tolerance') return RISK_LABELS[value] || value
return value
}
export function MyProfile() {
const [profile, setProfile] = useState<ProfileData | null>(null)
const [history, setHistory] = useState<ProfileHistory[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [currentHistory, setCurrentHistory] = useState<ProfileHistory | null>(null)
const load = useCallback(async () => {
setLoading(true)
setError(null)
try {
const [profileRes, historyRes] = await Promise.all([
api.getProfile(),
api.getProfileHistory(),
])
setProfile(profileRes as unknown as ProfileData)
setHistory(historyRes.results)
} catch (e) {
setError(e instanceof Error ? e.message : '加载失败')
} finally {
setLoading(false)
}
}, [])
useEffect(() => { load() }, [load])
if (loading) return <PageLayout title="我的画像"><LoadingState /></PageLayout>
if (error) return <PageLayout title="我的画像"><ErrorState message={error} onRetry={load} /></PageLayout>
if (!profile) return null
// ===== 历史详情视图 =====
if (currentHistory) {
const hp = currentHistory.profile as Record<string, unknown>
return (
<PageLayout
title={`画像版本 v${currentHistory.version}`}
description={`${SOURCE_LABELS[currentHistory.source] || currentHistory.source} · ${formatTime(currentHistory.created_at)}`}
actions={
<Button variant="outline" size="sm" onClick={() => setCurrentHistory(null)}>
<ArrowLeft className="h-3.5 w-3.5" /> 返回
</Button>
}
>
<Card>
<CardContent className="p-5">
<div className="space-y-3">
{Object.entries(FIELD_LABELS).map(([key, label]) => {
const val = hp[key] as string
if (!val) return null
return (
<div key={key} className="flex items-center justify-between border-b pb-2">
<span className="text-sm text-muted-foreground">{label}</span>
<span className="text-sm font-medium">{formatValue(key, val)}</span>
</div>
)
})}
{Array.isArray(hp.common_topics) && (hp.common_topics as string[]).length > 0 && (
<div className="border-b pb-2">
<p className="mb-1.5 text-sm text-muted-foreground">常问话题</p>
<div className="flex flex-wrap gap-1.5">
{(hp.common_topics as string[]).map((t, i) => (
<Badge key={i} variant="secondary" className="text-xs">{String(t)}</Badge>
))}
</div>
</div>
)}
{Boolean(hp.key_facts && typeof hp.key_facts === 'object' && !Array.isArray(hp.key_facts) && Object.keys(hp.key_facts as Record<string, unknown>).length > 0) && (
<div className="border-b pb-2">
<p className="mb-1.5 text-sm text-muted-foreground">关键事实</p>
<div className="space-y-1">
{Object.entries(hp.key_facts as Record<string, string>).map(([k, v]) => (
<div key={k} className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">{k}</span>
<span className="font-medium">{String(v)}</span>
</div>
))}
</div>
</div>
)}
{Array.isArray(hp.common_scenes) && (hp.common_scenes as string[]).length > 0 && (
<div>
<p className="mb-1.5 text-sm text-muted-foreground">常用场景</p>
<div className="flex flex-wrap gap-1.5">
{(hp.common_scenes as string[]).map((s, i) => (
<Badge key={i} variant="outline" className="text-xs">
{SCENE_LABELS[String(s)] || String(s)}
</Badge>
))}
</div>
</div>
)}
</div>
</CardContent>
</Card>
</PageLayout>
)
}
// ===== 画像主视图 =====
return (
<PageLayout
title="我的画像"
description="系统根据你的对话累积的抽象特征,用于个性化回复。不存储对话原文。"
>
{/* 当前画像 */}
<Card>
<CardContent className="p-5">
<div className="mb-4 flex items-center gap-2">
<UserCircle className="h-5 w-5 text-primary" aria-hidden />
<h3 className="text-sm font-semibold">当前画像</h3>
<Badge variant="secondary" className="ml-auto text-xs">
交互 {profile.interaction_count} 次
</Badge>
</div>
<div className="space-y-3">
{Object.entries(FIELD_LABELS).map(([key, label]) => {
const val = profile[key as keyof ProfileData] as string
if (!val) return null
return (
<div key={key} className="flex items-center justify-between border-b pb-2">
<span className="text-sm text-muted-foreground">{label}</span>
<span className="text-sm font-medium">{formatValue(key, val)}</span>
</div>
)
})}
{profile.common_topics && profile.common_topics.length > 0 && (
<div className="border-b pb-2">
<p className="mb-1.5 text-sm text-muted-foreground">常问话题</p>
<div className="flex flex-wrap gap-1.5">
{profile.common_topics.map((t, i) => (
<Badge key={i} variant="secondary" className="text-xs">{t}</Badge>
))}
</div>
</div>
)}
{profile.key_facts && Object.keys(profile.key_facts).length > 0 && (
<div className="border-b pb-2">
<p className="mb-1.5 text-sm text-muted-foreground">关键事实</p>
<div className="space-y-1">
{Object.entries(profile.key_facts).map(([k, v]) => (
<div key={k} className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">{k}</span>
<span className="font-medium">{v}</span>
</div>
))}
</div>
</div>
)}
{profile.common_scenes && profile.common_scenes.length > 0 && (
<div>
<p className="mb-1.5 text-sm text-muted-foreground">常用场景</p>
<div className="flex flex-wrap gap-1.5">
{profile.common_scenes.map((s, i) => (
<Badge key={i} variant="outline" className="text-xs">
{SCENE_LABELS[s] || s}
</Badge>
))}
</div>
</div>
)}
{profile.last_active && (
<p className="pt-2 text-xs text-muted-foreground">
最后活跃:{formatTime(profile.last_active)}
</p>
)}
</div>
</CardContent>
</Card>
{/* 版本历史 */}
<div>
<div className="mb-2 flex items-center gap-2 px-1">
<History className="h-4 w-4 text-muted-foreground" aria-hidden />
<h3 className="text-sm font-semibold">版本历史</h3>
<span className="text-xs text-muted-foreground">{history.length} 个版本</span>
</div>
{history.length === 0 ? (
<EmptyState
title="暂无历史版本"
description="每次画像更新都会记录版本快照"
icon={<History className="h-8 w-8" />}
/>
) : (
<div className="space-y-2">
{history.map((h) => (
<Card
key={h.id}
className="cursor-pointer transition-colors hover:bg-accent/50"
onClick={() => setCurrentHistory(h)}
>
<CardContent className="flex items-center gap-3 p-4">
<div className={cn(
'flex h-9 w-9 shrink-0 items-center justify-center rounded-lg',
h.source === 'manual' ? 'bg-blue-100 text-blue-700' : 'bg-muted text-muted-foreground',
)}>
<History className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">版本 v{h.version}</p>
<div className="mt-0.5 flex items-center gap-2">
<Badge
variant={h.source === 'manual' ? 'default' : 'secondary'}
className="text-[10px]"
>
{SOURCE_LABELS[h.source] || h.source}
</Badge>
<span className="text-xs text-muted-foreground">{formatTime(h.created_at)}</span>
</div>
</div>
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
</CardContent>
</Card>
))}
</div>
)}
</div>
</PageLayout>
)
}
+5 -5
View File
@@ -41,7 +41,7 @@ const SETTING_GROUPS = [
items: [ items: [
{ key: 'long_term_memory', label: '长期记忆', desc: '记住你的压力来源、性格和决策风格,提供个性化建议。可随时关闭', icon: Database, danger: false }, { key: 'long_term_memory', label: '长期记忆', desc: '记住你的压力来源、性格和决策风格,提供个性化建议。可随时关闭', icon: Database, danger: false },
{ key: 'knowledge_base_write', label: '企业知识库写入', desc: '允许将脱敏内容写入企业知识库,供团队复用', icon: Database, danger: false }, { key: 'knowledge_base_write', label: '企业知识库写入', desc: '允许将脱敏内容写入企业知识库,供团队复用', icon: Database, danger: false },
{ key: 'training_data_collection', label: '模型训练数据采集', desc: '允许使用你的对话数据改进模型。默认关闭, BossAi 不采集训练数据', icon: Info, danger: false }, { key: 'training_data_collection', label: '模型训练数据采集', desc: '允许使用你的对话数据改进模型。默认关闭, 左右(BossAi) 不采集训练数据', icon: Info, danger: false },
], ],
}, },
{ {
@@ -60,8 +60,8 @@ const SETTING_GROUPS = [
items: [ items: [
{ key: 'daily_reminder', label: '每日经营提醒', desc: '每天早上推送今天最该做的一件事', icon: Bell, danger: false }, { key: 'daily_reminder', label: '每日经营提醒', desc: '每天早上推送今天最该做的一件事', icon: Bell, danger: false },
{ key: 'risk_alert', label: '风险预警', desc: '检测到合同到期、应收逾期、员工试用期届满等风险时主动提醒', icon: Shield, danger: false }, { key: 'risk_alert', label: '风险预警', desc: '检测到合同到期、应收逾期、员工试用期届满等风险时主动提醒', icon: Shield, danger: false },
{ key: 'auto_escalate_red', label: '红色风险自动升级', desc: '检测到红色风险时自动创建复核工单,无需手动操作', icon: Shield, danger: false }, { key: 'auto_escalate_red', label: '红色风险自动提醒', desc: '检测到红色风险时自动提醒用户咨询专家,无需手动操作', icon: Shield, danger: false },
{ key: 'auto_escalate_orange', label: '橙色风险自动升级', desc: '检测到橙色风险时自动创建复核工单(默认手动)', icon: Shield, danger: false }, { key: 'auto_escalate_orange', label: '橙色风险自动提醒', desc: '检测到橙色风险时自动提醒用户咨询专家(默认手动)', icon: Shield, danger: false },
], ],
}, },
] ]
@@ -172,8 +172,8 @@ export function Settings() {
> >
<span <span
className={cn( className={cn(
'absolute top-0.5 h-5 w-5 rounded-full bg-white shadow transition-transform', 'absolute top-0.5 left-0.5 h-5 w-5 rounded-full bg-white shadow transition-transform',
on ? 'translate-x-5' : 'translate-x-0.5', on ? 'translate-x-5' : 'translate-x-0',
)} )}
/> />
</button> </button>
+312 -29
View File
@@ -1,48 +1,331 @@
import { useEffect, useState } from 'react' import { useEffect, useState, useCallback } from 'react'
import { Users } from 'lucide-react' import { toast } from 'sonner'
import { Users, Search, MessageSquare, Clock, KeyRound, Ban, CheckCircle, Loader2 } from 'lucide-react'
import { PageLayout } from '@/components/common/PageLayout' import { PageLayout } from '@/components/common/PageLayout'
import { EmptyState } from '@/components/common/EmptyState' import { EmptyState } from '@/components/common/EmptyState'
import { LoadingState } from '@/components/common/LoadingState' import { LoadingState } from '@/components/common/LoadingState'
import { ErrorState } from '@/components/common/ErrorState'
import { Card, CardContent } from '@/components/ui/card' import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
} from '@/components/ui/dialog'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { cn } from '@/lib/utils'
/** 管理后台 - 用户管理:列出所有注册用户 */ /** 用户列表项(含统计) */
export function AdminUsers() { interface AdminUser {
const [users, setUsers] = useState<Array<{ id: number; phone: string; name: string; created_at: string }>>([]) id: number
const [loading, setLoading] = useState(true) phone: string
name: string
useEffect(() => { status: string
api.adminListUsers() created_at: string
.then(res => setUsers(res.results)) message_count: number
.catch(() => {}) last_active: string | null
.finally(() => setLoading(false)) last_scene: string | null
}, [])
if (loading) {
return (
<PageLayout title="用户管理" description="平台注册用户列表">
<LoadingState />
</PageLayout>
)
} }
/** 场景中文映射 */
const SCENE_LABELS: Record<string, string> = {
legal: '法务参谋',
decision: '经营决策',
life: '生活助理',
emotion: '情绪中心',
}
/** 格式化时间 */
function formatTime(dt: string | null): string {
if (!dt) return '从未使用'
return new Date(dt).toLocaleString('zh-CN', {
month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit',
})
}
export function AdminUsers() {
const [users, setUsers] = useState<AdminUser[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [search, setSearch] = useState('')
const [searching, setSearching] = useState(false)
// 重置密码弹窗
const [resetTarget, setResetTarget] = useState<AdminUser | null>(null)
const [newPassword, setNewPassword] = useState('')
const [resetting, setResetting] = useState(false)
// 状态切换弹窗
const [statusTarget, setStatusTarget] = useState<AdminUser | null>(null)
const [statusUpdating, setStatusUpdating] = useState(false)
const load = useCallback(async (q?: string) => {
setLoading(true)
setError(null)
try {
const res = await api.adminListUsers(q)
setUsers(res.results as AdminUser[])
} catch (e) {
setError(e instanceof Error ? e.message : '加载失败')
} finally {
setLoading(false)
setSearching(false)
}
}, [])
useEffect(() => { load() }, [load])
/** 搜索(防抖) */
useEffect(() => {
if (search === undefined) return
const timer = setTimeout(() => {
if (search !== '') {
setSearching(true)
load(search)
} else {
load()
}
}, 400)
return () => clearTimeout(timer)
}, [search]) // eslint-disable-line react-hooks/exhaustive-deps
/** 确认重置密码 */
const handleResetPassword = async () => {
if (!resetTarget || !newPassword.trim()) return
if (newPassword.length < 6) {
toast.error('密码至少 6 位')
return
}
setResetting(true)
try {
await api.adminResetPassword(resetTarget.id, newPassword)
toast.success(`已重置 ${resetTarget.name || resetTarget.phone} 的密码`)
setResetTarget(null)
setNewPassword('')
load(search || undefined)
} catch (e) {
toast.error(e instanceof Error ? e.message : '重置失败')
} finally {
setResetting(false)
}
}
/** 确认激活/停用 */
const handleToggleStatus = async () => {
if (!statusTarget) return
const newStatus = statusTarget.status === 'active' ? 'inactive' : 'active'
setStatusUpdating(true)
try {
await api.adminUpdateUserStatus(statusTarget.id, newStatus)
toast.success(`已${newStatus === 'active' ? '激活' : '停用'} ${statusTarget.name || statusTarget.phone}`)
setStatusTarget(null)
load(search || undefined)
} catch (e) {
toast.error(e instanceof Error ? e.message : '操作失败')
} finally {
setStatusUpdating(false)
}
}
/** 统计概览 */
const totalUsers = users.length
const activeUsers = users.filter(u => u.status === 'active' && u.message_count > 0).length
const inactiveUsers = users.filter(u => u.status === 'inactive').length
const totalMessages = users.reduce((sum, u) => sum + u.message_count, 0)
return ( return (
<PageLayout title="用户管理" description="平台注册用户列表"> <PageLayout
{users.length === 0 ? ( title="用户管理"
<EmptyState title="暂无用户" icon={<Users className="h-8 w-8" />} /> description="平台注册用户列表,含对话统计、活跃度和账号管理"
actions={
<div className="relative">
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="搜索手机号或昵称..."
className="h-8 w-48 pl-8 text-xs"
/>
</div>
}
>
{/* 统计概览 */}
<div className="mb-4 grid grid-cols-4 gap-3">
<Card>
<CardContent className="p-3">
<p className="text-xs text-muted-foreground">总用户数</p>
<p className="mt-1 text-xl font-semibold">{totalUsers}</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-3">
<p className="text-xs text-muted-foreground">活跃用户</p>
<p className="mt-1 text-xl font-semibold text-emerald-600">{activeUsers}</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-3">
<p className="text-xs text-muted-foreground">已停用</p>
<p className="mt-1 text-xl font-semibold text-rose-600">{inactiveUsers}</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-3">
<p className="text-xs text-muted-foreground">总对话数</p>
<p className="mt-1 text-xl font-semibold">{totalMessages}</p>
</CardContent>
</Card>
</div>
{loading ? (
<LoadingState label={searching ? '搜索中...' : '加载用户...'} />
) : error ? (
<ErrorState message={error} onRetry={() => load(search || undefined)} />
) : users.length === 0 ? (
<EmptyState
title={search ? '未找到匹配用户' : '暂无用户'}
description={search ? '尝试其他关键词' : '注册用户将显示在这里'}
icon={<Users className="h-8 w-8" />}
/>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{users.map((u) => ( {users.map((u) => (
<Card key={u.id}><CardContent className="flex items-center justify-between py-3"> <Card key={u.id} className={u.status === 'inactive' ? 'opacity-60' : ''}>
<div> <CardContent className="p-4">
<p className="text-sm font-medium">{u.name || '未设置昵称'}</p> <div className="flex items-center gap-3">
<p className="text-xs text-muted-foreground">{u.phone}</p> {/* 头像占位 */}
<div className={cn(
'flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-sm font-medium',
u.status === 'inactive'
? 'bg-muted text-muted-foreground'
: u.message_count > 0
? 'bg-primary/10 text-primary'
: 'bg-muted text-muted-foreground',
)}>
{(u.name || u.phone.slice(-2)).slice(0, 2)}
</div> </div>
<p className="text-xs text-muted-foreground">注册:{u.created_at}</p> <div className="min-w-0 flex-1">
</CardContent></Card> <div className="flex items-center gap-2">
<p className="truncate text-sm font-medium">{u.name || '未设置昵称'}</p>
<span className="truncate text-xs text-muted-foreground">{u.phone}</span>
</div>
<div className="mt-1 flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<MessageSquare className="h-3 w-3" />
{u.message_count} 条对话
</span>
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
{formatTime(u.last_active)}
</span>
{u.last_scene && (
<Badge variant="secondary" className="text-[10px]">
{SCENE_LABELS[u.last_scene] || u.last_scene}
</Badge>
)}
<span>注册:{formatTime(u.created_at)}</span>
</div>
</div>
{/* 状态 Badge */}
<Badge
variant={u.status === 'active' ? 'default' : 'destructive'}
className="shrink-0 text-xs"
>
{u.status === 'active' ? '正常' : '已停用'}
</Badge>
</div>
{/* 操作按钮 */}
<div className="mt-3 flex flex-wrap gap-2 border-t pt-3">
<Button
size="sm"
variant="outline"
onClick={() => { setResetTarget(u); setNewPassword('') }}
>
<KeyRound className="h-3.5 w-3.5" /> 重置密码
</Button>
{u.status === 'active' ? (
<Button
size="sm"
variant="outline"
className="text-rose-600 hover:bg-rose-50"
onClick={() => setStatusTarget(u)}
>
<Ban className="h-3.5 w-3.5" /> 停用账号
</Button>
) : (
<Button
size="sm"
variant="outline"
className="text-emerald-600 hover:bg-emerald-50"
onClick={() => setStatusTarget(u)}
>
<CheckCircle className="h-3.5 w-3.5" /> 激活账号
</Button>
)}
</div>
</CardContent>
</Card>
))} ))}
</div> </div>
)} )}
{/* 重置密码弹窗 */}
<Dialog open={!!resetTarget} onOpenChange={(o) => !o && setResetTarget(null)}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>重置密码</DialogTitle>
<DialogDescription>
为用户「{resetTarget?.name || resetTarget?.phone}」设置新密码,用户需用新密码登录。
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="new-password">新密码(至少 6 位)</Label>
<Input
id="new-password"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="输入新密码..."
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setResetTarget(null)}>取消</Button>
<Button onClick={handleResetPassword} disabled={resetting || newPassword.length < 6}>
{resetting ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <KeyRound className="h-3.5 w-3.5" />}
确认重置
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 激活/停用确认弹窗 */}
<Dialog open={!!statusTarget} onOpenChange={(o) => !o && setStatusTarget(null)}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>
{statusTarget?.status === 'active' ? '停用账号' : '激活账号'}
</DialogTitle>
<DialogDescription>
{statusTarget?.status === 'active'
? `确认停用用户「${statusTarget?.name || statusTarget?.phone}」?停用后该用户将无法登录。`
: `确认激活用户「${statusTarget?.name || statusTarget?.phone}」?激活后该用户可正常登录。`
}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setStatusTarget(null)}>再想想</Button>
<Button
variant={statusTarget?.status === 'active' ? 'destructive' : 'default'}
onClick={handleToggleStatus}
disabled={statusUpdating}
>
{statusUpdating ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : null}
确认{statusTarget?.status === 'active' ? '停用' : '激活'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</PageLayout> </PageLayout>
) )
} }
+7
View File
@@ -138,6 +138,8 @@ export interface HistorySession {
first_message_at: string | null first_message_at: string | null
last_message_at: string | null last_message_at: string | null
scene: string | null scene: string | null
/** AI 建议标题(取自首条用户消息) */
title?: string
} }
/** 对话历史消息(含风险卡和元数据) */ /** 对话历史消息(含风险卡和元数据) */
@@ -158,6 +160,11 @@ export interface HistoryMessage {
inference_location?: string inference_location?: string
model?: string model?: string
fallback_used?: boolean fallback_used?: boolean
rag_context?: Array<{
title: string
doc_type?: string
region?: string | null
}>
} }
created_at: string | null created_at: string | null
} }