feat: BossAi v2.0 UI/UX 重构 + 看板断点续接 + 多轮上下文
- 新增 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:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user