import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Loader2, RefreshCw, Save, Search, Trash2 } from 'lucide-react' import { Alert, AlertDescription } from '@/components/ui/alert' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Checkbox } from '@/components/ui/checkbox' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { ScrollArea } from '@/components/ui/scroll-area' import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' import { Textarea } from '@/components/ui/textarea' import { useToast } from '@/hooks/use-toast' import { deleteMemoryProfileOverride, getMemoryProfiles, queryMemoryProfile, setMemoryProfileOverride, type MemoryProfileItemPayload, type MemoryProfileQueryPayload, } from '@/lib/memory-api' import { cn } from '@/lib/utils' function formatMemoryTime(timestamp?: number | null): string { if (!timestamp) { return '-' } const normalized = timestamp > 1_000_000_000_000 ? timestamp : timestamp * 1000 const value = new Date(normalized) if (Number.isNaN(value.getTime())) { return '-' } return value.toLocaleString('zh-CN', { hour12: false, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', }) } function parsePositiveInt(value: string, fallback: number): number { const parsed = Number(value) if (!Number.isInteger(parsed) || parsed <= 0) { return fallback } return parsed } function stringifyOverride(value: MemoryProfileItemPayload['manual_override']): string { if (!value) { return '' } if (typeof value === 'string') { return value } const text = value.override_text ?? value.text if (typeof text === 'string') { return text } return JSON.stringify(value, null, 2) } function resolveProfileText(queryResult: MemoryProfileQueryPayload | null, selectedProfile: MemoryProfileItemPayload | null): string { if (typeof queryResult?.profile_text === 'string') { return queryResult.profile_text } const queryProfile = queryResult?.profile if (queryProfile && typeof queryProfile === 'object' && typeof queryProfile.profile_text === 'string') { return queryProfile.profile_text } return selectedProfile?.profile_text ?? '' } export function MemoryProfileManager() { const { toast } = useToast() const [profiles, setProfiles] = useState([]) const [selectedPersonId, setSelectedPersonId] = useState('') const [queryPersonId, setQueryPersonId] = useState('') const [queryKeyword, setQueryKeyword] = useState('') const [queryLimit, setQueryLimit] = useState('12') const [forceRefresh, setForceRefresh] = useState(false) const [overrideText, setOverrideText] = useState('') const [queryResult, setQueryResult] = useState(null) const [loading, setLoading] = useState(false) const [querying, setQuerying] = useState(false) const [saving, setSaving] = useState(false) const initialLoadedRef = useRef(false) const selectedProfile = useMemo( () => profiles.find((item) => item.person_id === selectedPersonId) ?? null, [profiles, selectedPersonId], ) const profileText = resolveProfileText(queryResult, selectedProfile) const loadProfiles = useCallback(async () => { setLoading(true) try { const payload = await getMemoryProfiles(80) const nextItems = payload.items ?? [] setProfiles(nextItems) if (!selectedPersonId && nextItems.length > 0) { setSelectedPersonId(nextItems[0].person_id) } } catch (error) { toast({ title: '加载人物画像失败', description: error instanceof Error ? error.message : String(error), variant: 'destructive', }) } finally { setLoading(false) } }, [selectedPersonId, toast]) useEffect(() => { if (initialLoadedRef.current) { return } initialLoadedRef.current = true void loadProfiles() }, [loadProfiles]) useEffect(() => { setOverrideText(stringifyOverride(selectedProfile?.manual_override)) if (selectedProfile?.person_id) { setQueryPersonId(selectedProfile.person_id) } }, [selectedProfile]) const submitQuery = useCallback(async () => { if (!queryPersonId.trim() && !queryKeyword.trim()) { toast({ title: '请输入查询条件', description: 'person_id 和关键词至少填写一个。', variant: 'destructive', }) return } setQuerying(true) try { const payload = await queryMemoryProfile({ personId: queryPersonId.trim(), personKeyword: queryKeyword.trim(), limit: parsePositiveInt(queryLimit, 12), forceRefresh, }) setQueryResult(payload) const nextPersonId = String(payload.person_id ?? payload.profile?.person_id ?? queryPersonId ?? '') if (nextPersonId) { setSelectedPersonId(nextPersonId) } toast({ title: '人物画像查询完成', description: forceRefresh ? '已请求强制刷新画像。' : '已获取画像结果。', }) await loadProfiles() } catch (error) { toast({ title: '人物画像查询失败', description: error instanceof Error ? error.message : String(error), variant: 'destructive', }) } finally { setQuerying(false) } }, [forceRefresh, loadProfiles, queryKeyword, queryLimit, queryPersonId, toast]) const saveOverride = useCallback(async () => { const personId = selectedPersonId || queryPersonId.trim() if (!personId) { toast({ title: '缺少人物 ID', description: '请选择或输入一个 person_id 后再保存 override。', variant: 'destructive', }) return } setSaving(true) try { await setMemoryProfileOverride({ person_id: personId, override_text: overrideText, updated_by: 'knowledge_base', source: 'webui', }) toast({ title: '人物画像 override 已保存' }) await loadProfiles() } catch (error) { toast({ title: '保存人物画像 override 失败', description: error instanceof Error ? error.message : String(error), variant: 'destructive', }) } finally { setSaving(false) } }, [loadProfiles, overrideText, queryPersonId, selectedPersonId, toast]) const deleteOverride = useCallback(async () => { const personId = selectedPersonId || queryPersonId.trim() if (!personId) { return } if (!window.confirm(`确认删除 ${personId} 的人物画像 override?`)) { return } setSaving(true) try { await deleteMemoryProfileOverride(personId) setOverrideText('') toast({ title: '人物画像 override 已删除' }) await loadProfiles() } catch (error) { toast({ title: '删除人物画像 override 失败', description: error instanceof Error ? error.message : String(error), variant: 'destructive', }) } finally { setSaving(false) } }, [loadProfiles, queryPersonId, selectedPersonId, toast]) return (
人物画像查询 查看最近画像快照,或按 person_id / 关键词触发查询与刷新。
setQueryPersonId(event.target.value)} />
setQueryKeyword(event.target.value)} />
setQueryLimit(event.target.value)} />
setForceRefresh(Boolean(value))} />
人物 版本 更新时间 {profiles.length > 0 ? profiles.map((item) => ( setSelectedPersonId(item.person_id)} >
{item.person_id}
{item.has_manual_override ? 手动 override : null} {item.source_note ? {item.source_note} : null}
{Number(item.profile_version ?? 0)} {formatMemoryTime(item.updated_at)}
)) : ( {loading ? '正在加载人物画像...' : '还没有人物画像快照'} )}
画像详情 展示当前快照、查询结果和原始响应。 {querying ? (
正在查询人物画像
) : null} {selectedProfile || queryResult ? ( <>
{selectedPersonId || String(queryResult?.person_id ?? '未选择')} {selectedProfile?.expires_at ? 过期时间 {formatMemoryTime(selectedProfile.expires_at)} : null}