码桶

发现社区成员的开源项目

奶狗 /

ng-webot

公开
main
ng-webot/web/src/pages/MemoryPanel.tsx
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>
  )
}