码桶
发现社区成员的开源项目
MemoryPanel.tsx14.6 KB
import { useEffect, useState } from 'react'
import { api } from '@/lib/api'
import { toast } from 'sonner'
import {
Loader2,
Brain,
FileText,
Download,
Trash2,
Save,
ShieldAlert,
RefreshCw,
Users,
} from 'lucide-react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import { Switch } from '@/components/ui/switch'
import { Badge } from '@/components/ui/badge'
interface ProfileItem {
bot_id: string
peer_id: string
generated_at: number
last_chat_at: number
sample_count: number
consent: number | null
size: number
has_profile?: boolean
}
function fmt(ts: number) {
if (!ts) return '—'
return new Date(ts * 1000).toLocaleString('zh-CN')
}
export default function MemoryPanel() {
const [selBotId, setSelBotId] = useState<number>(0)
const [enabled, setEnabled] = useState(false)
const [list, setList] = useState<ProfileItem[]>([])
const [loading, setLoading] = useState(false)
// 详情
const [selPeer, setSelPeer] = useState<string>('')
const [markdown, setMarkdown] = useState('')
const [detailLoading, setDetailLoading] = useState(false)
const [saving, setSaving] = useState(false)
const [regenerating, setRegenerating] = useState(false)
// 正在生成画像的用户 peer_id(用于按行显示 loading,每个用户彼此独立)
const [genPeer, setGenPeer] = useState<string>('')
useEffect(() => {
api.post('/api/me').then((d: any) => {
if (d?.ok) {
const bs: any[] = d.bots || []
const first = bs[0]
if (first) setSelBotId(first.id)
else setSelBotId(0)
}
}).catch(() => {})
}, [])
const load = async () => {
if (!selBotId) return
setLoading(true)
try {
const s: any = await api.post('/api/memory/settings', { bot_id: selBotId })
setEnabled(!!s?.enabled)
const l: any = await api.post('/api/memory/list', { bot_id: selBotId })
setList(l?.list || [])
setSelPeer('')
setMarkdown('')
} catch (e: any) {
toast.error('加载失败: ' + (e?.message || e))
} finally {
setLoading(false)
}
}
useEffect(() => {
load()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selBotId])
const onChangeEnable = async (val: boolean) => {
if (!selBotId) return
try {
await api.post('/api/memory/settings_save', { bot_id: selBotId, enabled: val })
setEnabled(val)
toast.success(val ? '已开启用户画像功能' : '已关闭用户画像功能')
await load()
} catch (e: any) {
toast.error('保存失败: ' + (e?.message || e))
}
}
const openPeer = async (peer: string) => {
if (!selBotId) return
setSelPeer(peer)
setDetailLoading(true)
try {
const d: any = await api.post('/api/memory/detail', { bot_id: selBotId, peer_id: peer })
setMarkdown(d?.markdown || '')
} catch (e: any) {
toast.error('加载失败: ' + (e?.message || e))
} finally {
setDetailLoading(false)
}
}
const onSave = async () => {
if (!selBotId || !selPeer) return
setSaving(true)
try {
await api.post('/api/memory/save', { bot_id: selBotId, peer_id: selPeer, markdown })
toast.success('已保存画像')
} catch (e: any) {
toast.error('保存失败: ' + (e?.message || e))
} finally {
setSaving(false)
}
}
const onDelete = async () => {
if (!selBotId || !selPeer) return
if (!confirm('确定删除该用户的画像?此操作不可恢复,聊天记录不受影响。')) return
try {
await api.post('/api/memory/delete', { bot_id: selBotId, peer_id: selPeer })
toast.success('已删除画像')
setSelPeer('')
setMarkdown('')
await load()
} catch (e: any) {
toast.error('删除失败: ' + (e?.message || e))
}
}
// 为「单个用户」手动授权并生成画像(每个用户彼此独立,只处理指定 peer_id)
const onGenerateOne = async (peer: string) => {
if (!selBotId || !peer) return
setGenPeer(peer)
try {
const r: any = await api.post('/api/memory/generate', { bot_id: selBotId, peer_id: peer })
if (r?.generated) {
toast.success(r?.msg || '已生成该用户画像')
await load()
} else {
toast.error(r?.msg || '该用户聊天记录不足,无法生成画像')
}
} catch (e: any) {
toast.error('生成失败: ' + (e?.message || e))
} finally {
setGenPeer('')
}
}
// 对当前选中用户重新生成画像
const onRegenerate = async () => {
if (!selBotId || !selPeer) return
setRegenerating(true)
try {
const r: any = await api.post('/api/memory/generate', { bot_id: selBotId, peer_id: selPeer })
if (r?.generated) {
toast.success(r?.msg || '已重新生成画像')
await openPeer(selPeer)
await load()
} else {
toast.error(r?.msg || '生成失败')
}
} catch (e: any) {
toast.error('生成失败: ' + (e?.message || e))
} finally {
setRegenerating(false)
}
}
const downloadUrl =
selBotId && selPeer
? `/api/memory/download?bot_id=${selBotId}&peer_id=${encodeURIComponent(selPeer)}`
: ''
return (
<div className="space-y-6">
<div className="flex items-center gap-2">
<Brain className="h-6 w-6 text-primary" />
<h2 className="text-xl font-semibold">用户画像管理</h2>
<Badge variant="secondary" className="gap-1">
<Users className="h-3 w-3" />基于聊天记录
</Badge>
</div>
{/* 隐私 + 全局开关 */}
<Card className="border-amber-500/40 bg-amber-500/5">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<ShieldAlert className="h-5 w-5 text-amber-500" /> 隐私提醒(重要)
</CardTitle>
<CardDescription>
用户画像会在用户<strong>授权</strong>后,基于其与机器人的聊天记录自动分析「用户是个什么样的人」(兴趣、风格、需求等),
并注入 AI 对话以让回复更懂用户。这是<strong>涉及个人隐私</strong>的功能,<strong>默认关闭、绝不强制</strong>,
每个用户可随时在微信里说「拒绝画像」关闭。管理员可在此查看 / 编辑 / 删除任何用户的画像。
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center justify-between">
<div className="space-y-1">
<div className="text-sm font-medium">开启用户画像功能(全局)</div>
<div className="text-xs text-muted-foreground">
关闭后,后台将不再自动生成 / 更新任何用户的画像(已生成的文件仍保留、可查看)
</div>
</div>
<Switch checked={enabled} onCheckedChange={onChangeEnable} />
</div>
</CardContent>
</Card>
{!selBotId && (
<Card>
<CardContent className="py-10 text-center text-muted-foreground">你还没有机器人</CardContent>
</Card>
)}
{selBotId && (
<Card>
<CardHeader className="pb-3">
<div className="space-y-1">
<CardTitle className="flex items-center gap-2 text-base">
<FileText className="h-5 w-5" /> 聊天用户与画像
</CardTitle>
<CardDescription>
列出该机器人下有足够聊天记录的用户,可手动生成、查看或编辑其画像。
</CardDescription>
</div>
</CardHeader>
<CardContent className="space-y-3">
{loading && (
<div className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> 加载中…
</div>
)}
{!loading && list.length === 0 && (
<div className="py-8 text-center text-muted-foreground">
暂无有足够聊天记录的用户。用户与机器人聊天累计达到门槛后,即可在此对其单独生成画像。
</div>
)}
{!loading && list.length > 0 && (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="py-2 pr-2 font-medium">用户(peer)</th>
<th className="py-2 pr-2 font-medium">授权</th>
<th className="py-2 pr-2 font-medium">样本</th>
<th className="py-2 pr-2 font-medium">生成时间</th>
<th className="py-2 pr-2 font-medium">最近聊天</th>
<th className="py-2 font-medium">操作</th>
</tr>
</thead>
<tbody>
{list.map((it) => (
<tr
key={it.peer_id}
className={'border-b ' + (selPeer === it.peer_id ? 'bg-primary/5' : '')}
>
<td className="py-2 pr-2 font-mono text-xs break-all">{it.peer_id}</td>
<td className="py-2 pr-2">
{it.consent === 1 ? (
<Badge variant="secondary" className="bg-green-500/15 text-green-600">
已授权
</Badge>
) : it.consent === 0 ? (
<Badge variant="secondary" className="bg-red-500/15 text-red-600">
已拒绝
</Badge>
) : (
<Badge variant="secondary">未表态</Badge>
)}
</td>
<td className="py-2 pr-2">{it.sample_count}</td>
<td className="py-2 pr-2 text-xs text-muted-foreground">
{it.has_profile ? fmt(it.generated_at) : <span className="text-amber-600">未生成</span>}
</td>
<td className="py-2 pr-2 text-xs text-muted-foreground">{fmt(it.last_chat_at)}</td>
<td className="py-2">
<div className="flex flex-wrap items-center gap-2">
{it.has_profile && (
<Button size="sm" variant="outline" onClick={() => openPeer(it.peer_id)}>
查看 / 编辑
</Button>
)}
<Button
size="sm"
onClick={() => onGenerateOne(it.peer_id)}
disabled={genPeer === it.peer_id}
>
{genPeer === it.peer_id ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
) : (
<Brain className="mr-1 h-3.5 w-3.5" />
)}
{it.has_profile ? '重新生成' : '生成画像'}
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
)}
{/* 详情编辑区 */}
{selBotId && selPeer && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<FileText className="h-5 w-5" /> 画像详情
<span className="font-mono text-xs text-muted-foreground break-all">{selPeer}</span>
</CardTitle>
<CardDescription>可编辑 Markdown 内容并保存,或下载 / 删除该画像文件。</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{detailLoading && (
<div className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> 加载中…
</div>
)}
{!detailLoading && (
<>
<Textarea
value={markdown}
onChange={(e) => setMarkdown(e.target.value)}
className="min-h-[360px] font-mono text-xs leading-relaxed"
placeholder={'# 用户画像\n\n(暂无内容)'}
/>
<div className="flex flex-wrap items-center gap-2">
<Button onClick={onSave} disabled={saving}>
{saving ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
保存
</Button>
{downloadUrl && (
<a href={downloadUrl} download>
<Button variant="outline">
<Download className="mr-2 h-4 w-4" />下载文件
</Button>
</a>
)}
<Button variant="outline" onClick={onRegenerate} disabled={regenerating}>
{regenerating ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Brain className="mr-2 h-4 w-4" />
)}
重新生成画像
</Button>
<Button variant="ghost" className="text-destructive" onClick={onDelete}>
<Trash2 className="mr-2 h-4 w-4" />删除画像
</Button>
<Button variant="ghost" onClick={() => openPeer(selPeer)}>
<RefreshCw className="mr-2 h-4 w-4" />重新加载
</Button>
</div>
</>
)}
</CardContent>
</Card>
)}
</div>
)
}