Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
510 lines
19 KiB
TypeScript
510 lines
19 KiB
TypeScript
/** 对话历史页:按场景分组列表、查看详情、保密模式密码验证、删除会话 */
|
|
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>
|
|
)
|
|
}
|