feat: BossAi v2.0 UI/UX 重构 + 看板断点续接 + 多轮上下文
CI / 后端测试 (push) Has been cancelled
CI / 前端构建 (push) Has been cancelled

- 新增 web2/ 前端:任务卡看板、风险雷达、决策日志、我的团队、快捷指令
- ChatPanel 增强:风险卡置顶、案卡接入、起诉状向导、语音输入
- 事项看板:点击查看详情弹窗、断点续接(恢复历史对话+matterId)
- 保密模式恢复:密码验证后重建内存 session,保留多轮上下文
- 多轮对话:ask/ask-stream/ask-analyze 注入 session 历史,AI 有上下文
- 追问优化:紧扣用户问题,不再偏离主题(社保问社保,工伤问工伤)
- 对话区可滚动,输入区固定可见
- 状态机放宽:允许从任意非终态转到 pending_expert
- session 存活检查端点,后端重启后自动重建 session
- DB migration:风险推送表、案卡表

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-31 08:38:33 +08:00
co-authored by Devin
parent 7a788bf8dc
commit 4de36547cd
88 changed files with 15237 additions and 62 deletions
+509
View File
@@ -0,0 +1,509 @@
/** 对话历史页:按场景分组列表、查看详情、保密模式密码验证、删除会话 */
import { useEffect, useState, useCallback } from 'react'
import { toast } from 'sonner'
import {
History,
Lock,
Trash2,
Loader2,
MessageSquare,
Shield,
ChevronRight,
ArrowLeft,
BookOpen,
Sparkles,
} 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 { Markdown } from '@/components/common/Markdown'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { api } from '@/lib/api'
import { cn, formatTime } from '@/lib/utils'
import type { HistorySession, HistoryMessage, SessionMode } from '@/types'
/** 模式标签配置 */
const MODE_CONFIG: Record<SessionMode, { label: string; icon: typeof History; color: string }> = {
normal: { label: '普通模式', icon: MessageSquare, color: 'bg-blue-100 text-blue-700' },
confidential: { label: '保密模式', icon: Lock, color: 'bg-amber-100 text-amber-700' },
ephemeral: { label: '阅后即焚', icon: Shield, color: 'bg-rose-100 text-rose-700' },
}
/** 风险等级颜色 */
const RISK_COLORS: Record<string, string> = {
green: 'bg-green-100 text-green-700',
amber: 'bg-yellow-100 text-yellow-700',
orange: 'bg-orange-100 text-orange-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> = {
legal: '法务参谋',
decision: '经营决策',
life: '生活助理',
emotion: '情绪中心',
}
export function ChatHistory() {
const [sessions, setSessions] = useState<HistorySession[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [modeFilter, setModeFilter] = useState<'all' | 'normal' | 'confidential'>('all')
// 当前查看的会话
const [currentSession, setCurrentSession] = useState<HistorySession | null>(null)
const [messages, setMessages] = useState<HistoryMessage[]>([])
const [msgLoading, setMsgLoading] = useState(false)
const [msgError, setMsgError] = useState<string | null>(null)
const [needVerify, setNeedVerify] = useState(false)
// 保密模式密码验证
const [verifyPassword, setVerifyPassword] = useState('')
const [verifying, setVerifying] = useState(false)
// 删除确认
const [deleteTarget, setDeleteTarget] = useState<HistorySession | null>(null)
const [deleting, setDeleting] = useState(false)
const loadSessions = useCallback(async () => {
setLoading(true)
setError(null)
try {
const mode = modeFilter === 'all' ? undefined : modeFilter
const res = await api.listHistorySessions(mode)
setSessions(res.results)
} catch (err) {
setError(err instanceof Error ? err.message : '加载失败')
} finally {
setLoading(false)
}
}, [modeFilter])
useEffect(() => {
loadSessions()
}, [loadSessions])
/** 查看会话历史 */
const handleView = useCallback(async (session: HistorySession) => {
setCurrentSession(session)
setMessages([])
setMsgError(null)
setNeedVerify(false)
setVerifyPassword('')
setMsgLoading(true)
try {
const res = await api.getHistoryMessages(session.session_id)
if (res.need_verify) {
setNeedVerify(true)
} else {
setMessages(res.messages || [])
}
} catch (err) {
setMsgError(err instanceof Error ? err.message : '加载失败')
} finally {
setMsgLoading(false)
}
}, [])
/** 保密模式密码验证 */
const handleVerify = useCallback(async () => {
if (!currentSession || !verifyPassword) return
setVerifying(true)
setMsgError(null)
try {
const res = await api.verifyHistory(currentSession.session_id, verifyPassword)
setMessages(res.messages)
setNeedVerify(false)
toast.success('验证通过,已解密历史消息')
} catch (err) {
setMsgError(err instanceof Error ? err.message : '验证失败')
} finally {
setVerifying(false)
}
}, [currentSession, verifyPassword])
/** 删除会话历史 */
const handleDelete = useCallback(async () => {
if (!deleteTarget) return
setDeleting(true)
try {
await api.deleteHistory(deleteTarget.session_id)
toast.success('已删除该会话历史')
setDeleteTarget(null)
// 如果删除的是当前查看的会话,返回列表
if (currentSession?.session_id === deleteTarget.session_id) {
setCurrentSession(null)
}
loadSessions()
} catch (err) {
toast.error(err instanceof Error ? err.message : '删除失败')
} finally {
setDeleting(false)
}
}, [deleteTarget, currentSession, loadSessions])
/** 返回列表 */
const handleBack = useCallback(() => {
setCurrentSession(null)
setMessages([])
setNeedVerify(false)
setVerifyPassword('')
}, [])
// ===== 会话详情视图 =====
if (currentSession) {
const modeCfg = MODE_CONFIG[currentSession.mode]
return (
<PageLayout
title="对话历史"
description="查看和管理历史对话记录"
actions={
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={handleBack}>
<ArrowLeft className="mr-1 h-4 w-4" />
返回列表
</Button>
<Button
variant="outline"
size="sm"
className="text-destructive hover:text-destructive"
onClick={() => setDeleteTarget(currentSession)}
>
<Trash2 className="mr-1 h-4 w-4" />
删除
</Button>
</div>
}
>
<Card>
<CardContent className="p-4">
<div className="mb-4 flex items-center gap-3 border-b pb-3">
<modeCfg.icon className="h-5 w-5 text-muted-foreground" />
<div className="flex-1">
<p className="font-medium">{currentSession.title || '未命名对话'}</p>
<div className="mt-0.5 flex flex-wrap items-center gap-2">
<span className="text-xs text-muted-foreground">{modeCfg.label}</span>
{currentSession.scene && (
<Badge variant="secondary" className="text-xs">
{SCENE_LABELS[currentSession.scene] || currentSession.scene}
</Badge>
)}
<span className="text-xs text-muted-foreground">
{formatTime(currentSession.first_message_at)} ~ {formatTime(currentSession.last_message_at)}
{' · '}{currentSession.message_count} 条消息
</span>
</div>
</div>
</div>
{/* 保密模式密码验证 */}
{needVerify ? (
<div className="mx-auto max-w-sm py-8">
<div className="mb-4 flex flex-col items-center gap-2 text-center">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-amber-100">
<Lock className="h-6 w-6 text-amber-600" />
</div>
<p className="text-sm font-medium">保密模式历史</p>
<p className="text-xs text-muted-foreground">
该会话为加密存储,请输入登录密码验证后查看
</p>
</div>
<div className="space-y-3">
<div>
<Label htmlFor="verify-pwd" className="text-xs">登录密码</Label>
<Input
id="verify-pwd"
type="password"
value={verifyPassword}
onChange={(e) => setVerifyPassword(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleVerify()}
placeholder="请输入登录密码"
className="mt-1"
/>
</div>
{msgError && <p className="text-xs text-destructive">{msgError}</p>}
<Button
className="w-full"
onClick={handleVerify}
disabled={verifying || !verifyPassword}
>
{verifying ? (
<><Loader2 className="mr-1 h-4 w-4 animate-spin" />验证中</>
) : (
'验证并查看'
)}
</Button>
</div>
</div>
) : msgLoading ? (
<LoadingState label="加载历史消息..." />
) : msgError ? (
<ErrorState message={msgError} onRetry={() => handleView(currentSession)} />
) : messages.length === 0 ? (
<EmptyState icon={MessageSquare} title="暂无消息" description="该会话没有历史记录" />
) : (
<div className="space-y-4">
{messages.map((msg) => (
<MessageBubble key={msg.id} message={msg} />
))}
</div>
)}
</CardContent>
</Card>
{/* 删除确认弹窗 */}
<DeleteDialog
open={!!deleteTarget}
onOpenChange={(o) => !o && setDeleteTarget(null)}
onConfirm={handleDelete}
deleting={deleting}
/>
</PageLayout>
)
}
// ===== 会话列表视图 =====
return (
<PageLayout
title="对话历史"
description="普通模式可直接查看,保密模式需密码验证,阅后即焚不落库"
actions={
<div className="flex flex-wrap items-center gap-1 rounded-md border bg-card p-1">
{(['all', 'normal', 'confidential'] as const).map((m) => (
<Button
key={m}
variant={modeFilter === m ? 'default' : 'ghost'}
size="sm"
className="h-7 text-xs"
onClick={() => setModeFilter(m)}
>
{m === 'all' ? '全部' : m === 'normal' ? '普通' : '保密'}
</Button>
))}
</div>
}
>
{loading ? (
<LoadingState label="加载历史会话..." />
) : error ? (
<ErrorState message={error} onRetry={loadSessions} />
) : sessions.length === 0 ? (
<EmptyState
icon={History}
title="暂无历史会话"
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">
{groups.get(key)!.map((session) => {
const modeCfg = MODE_CONFIG[session.mode]
return (
<Card
key={session.session_id}
className="cursor-pointer transition-colors hover:bg-accent/50"
onClick={() => handleView(session)}
>
<CardContent className="flex items-center gap-3 p-4">
<div className={cn('flex h-10 w-10 items-center justify-center rounded-lg', modeCfg.color)}>
<modeCfg.icon className="h-5 w-5" />
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{session.title || '未命名对话'}</p>
<div className="mt-0.5 flex flex-wrap items-center gap-2">
<span className="text-xs text-muted-foreground">{modeCfg.label}</span>
<span className="text-xs text-muted-foreground">
{session.message_count} 条 · {formatTime(session.last_message_at)}
</span>
</div>
</div>
<ChevronRight className="h-4 w-4 text-muted-foreground" />
</CardContent>
</Card>
)
})}
</div>
</div>
))
})()}
</div>
)}
<DeleteDialog
open={!!deleteTarget}
onOpenChange={(o) => !o && setDeleteTarget(null)}
onConfirm={handleDelete}
deleting={deleting}
/>
</PageLayout>
)
}
/** 单条消息气泡 */
function MessageBubble({ message }: { message: HistoryMessage }) {
const isUser = message.role === 'user'
const riskLevel = message.risk?.level
return (
<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')}>
{/* 消息正文:用户消息纯文本,AI 消息 Markdown 渲染 */}
{isUser ? (
<p className="whitespace-pre-wrap text-sm">{message.content}</p>
) : (
<Markdown content={message.content} className="text-sm" />
)}
{/* AI 回复的风险卡信息 */}
{!isUser && riskLevel && (
<div className="mt-2 space-y-1 border-t border-border/30 pt-2">
<div className="flex items-center gap-2">
<Badge className={cn('text-xs', RISK_COLORS[riskLevel])}>
{riskLevel === 'green' ? '低风险' : riskLevel === 'amber' ? '需补证' : riskLevel === 'orange' ? '建议复核' : '高风险'}
</Badge>
{message.risk?.escalation && (
<Badge variant="destructive" className="text-xs">建议升级</Badge>
)}
{message.meta?.inference_location && (
<span className="text-[10px] opacity-70">
{message.meta.inference_location === 'cloud' ? '云端' : '本地'}
</span>
)}
</div>
{message.risk?.actions && message.risk.actions.length > 0 && (
<ul className="text-xs opacity-80">
{message.risk.actions.map((a, i) => (
<li key={i}>• {a}</li>
))}
</ul>
)}
</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')}>
{formatTime(message.created_at)}
</p>
</div>
</div>
)
}
/** 删除确认弹窗 */
function DeleteDialog({
open,
onOpenChange,
onConfirm,
deleting,
}: {
open: boolean
onOpenChange: (open: boolean) => void
onConfirm: () => void
deleting: boolean
}) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>删除对话历史</DialogTitle>
<DialogDescription>
确定要删除该会话的所有历史消息吗?此操作不可恢复。
{open && ' 保密模式的加密数据将一并清除。'}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={deleting}>
取消
</Button>
<Button variant="destructive" onClick={onConfirm} disabled={deleting}>
{deleting ? (
<><Loader2 className="mr-1 h-4 w-4 animate-spin" />删除中</>
) : (
'确认删除'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}