diff --git a/dashboard/src/components/memory/MemoryEpisodeManager.tsx b/dashboard/src/components/memory/MemoryEpisodeManager.tsx new file mode 100644 index 00000000..d52e09a5 --- /dev/null +++ b/dashboard/src/components/memory/MemoryEpisodeManager.tsx @@ -0,0 +1,437 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { Loader2, Play, RefreshCw, RotateCcw, Search } 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 { 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 { + getMemoryEpisode, + getMemoryEpisodes, + getMemoryEpisodeStatus, + processMemoryEpisodePending, + rebuildMemoryEpisodes, + type MemoryEpisodeDetailPayload, + type MemoryEpisodeItemPayload, + type MemoryEpisodeParagraphPayload, + type MemoryEpisodeStatusPayload, +} 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 parseOptionalNumber(value: string): number | undefined { + const trimmed = value.trim() + if (!trimmed) { + return undefined + } + const parsed = Number(trimmed) + return Number.isFinite(parsed) ? parsed : undefined +} + +function parsePositiveInt(value: string, fallback: number): number { + const parsed = Number(value) + if (!Number.isInteger(parsed) || parsed <= 0) { + return fallback + } + return parsed +} + +function getEpisodeId(item: MemoryEpisodeItemPayload | null | undefined): string { + return String(item?.episode_id ?? item?.id ?? '') +} + +function getEpisodeTitle(item: MemoryEpisodeItemPayload): string { + return String(item.title ?? item.summary ?? item.content ?? getEpisodeId(item) ?? '未命名 Episode') +} + +function getEpisodeParagraphs( + item: MemoryEpisodeItemPayload | MemoryEpisodeDetailPayload['episode'] | null | undefined, +): MemoryEpisodeParagraphPayload[] { + const paragraphs = item?.paragraphs + return Array.isArray(paragraphs) ? paragraphs : [] +} + +function getStatusCount(status: MemoryEpisodeStatusPayload | null, key: string): number { + const counts = status?.counts + if (counts && typeof counts[key] === 'number') { + return counts[key] + } + const value = status?.[key] + return typeof value === 'number' ? value : 0 +} + +export function MemoryEpisodeManager() { + const { toast } = useToast() + const [query, setQuery] = useState('') + const [source, setSource] = useState('') + const [personId, setPersonId] = useState('') + const [timeStart, setTimeStart] = useState('') + const [timeEnd, setTimeEnd] = useState('') + const [limit, setLimit] = useState('20') + const [items, setItems] = useState([]) + const [status, setStatus] = useState(null) + const [selectedId, setSelectedId] = useState('') + const [detail, setDetail] = useState(null) + const [loading, setLoading] = useState(false) + const [detailLoading, setDetailLoading] = useState(false) + const [actionLoading, setActionLoading] = useState(false) + const [rebuildSource, setRebuildSource] = useState('') + const [rebuildSources, setRebuildSources] = useState('') + const [rebuildAll, setRebuildAll] = useState(false) + const [pendingLimit, setPendingLimit] = useState('20') + const [pendingMaxRetry, setPendingMaxRetry] = useState('3') + const initialLoadedRef = useRef(false) + + const selectedEpisode = useMemo(() => detail?.episode ?? items.find((item) => getEpisodeId(item) === selectedId), [detail?.episode, items, selectedId]) + const selectedEpisodeParagraphs = useMemo(() => getEpisodeParagraphs(selectedEpisode), [selectedEpisode]) + const failedItems = Array.isArray(status?.failed) ? status.failed : [] + + const loadStatus = useCallback(async () => { + const payload = await getMemoryEpisodeStatus(parsePositiveInt(limit, 20)) + setStatus(payload) + }, [limit]) + + const loadEpisodes = useCallback(async () => { + setLoading(true) + try { + const [listPayload] = await Promise.all([ + getMemoryEpisodes({ + query, + source, + personId, + limit: parsePositiveInt(limit, 20), + timeStart: parseOptionalNumber(timeStart), + timeEnd: parseOptionalNumber(timeEnd), + }), + loadStatus(), + ]) + const nextItems = listPayload.items ?? [] + setItems(nextItems) + if (!selectedId && nextItems.length > 0) { + setSelectedId(getEpisodeId(nextItems[0])) + } + } catch (error) { + toast({ + title: '加载情节记忆失败', + description: error instanceof Error ? error.message : String(error), + variant: 'destructive', + }) + } finally { + setLoading(false) + } + }, [limit, loadStatus, personId, query, selectedId, source, timeEnd, timeStart, toast]) + + const loadDetail = useCallback(async (episodeId: string) => { + if (!episodeId) { + setDetail(null) + return + } + setDetailLoading(true) + try { + const payload = await getMemoryEpisode(episodeId) + setDetail(payload) + } catch (error) { + toast({ + title: '加载 Episode 详情失败', + description: error instanceof Error ? error.message : String(error), + variant: 'destructive', + }) + } finally { + setDetailLoading(false) + } + }, [toast]) + + useEffect(() => { + if (initialLoadedRef.current) { + return + } + initialLoadedRef.current = true + void loadEpisodes() + }, [loadEpisodes]) + + useEffect(() => { + if (selectedId) { + void loadDetail(selectedId) + } + }, [loadDetail, selectedId]) + + const submitRebuild = useCallback(async () => { + if (rebuildAll && !window.confirm('确认重建全部可用来源的 Episode?这个操作可能耗时较长。')) { + return + } + const sources = rebuildSources + .split(',') + .map((item) => item.trim()) + .filter(Boolean) + setActionLoading(true) + try { + const payload = await rebuildMemoryEpisodes({ + source: rebuildSource.trim(), + sources, + all: rebuildAll, + }) + toast({ + title: payload.success ? 'Episode 重建已提交' : 'Episode 重建失败', + description: String(payload.detail ?? payload.error ?? `影响来源 ${payload.rebuilt ?? 0} 个`), + variant: payload.success ? 'default' : 'destructive', + }) + await loadEpisodes() + } catch (error) { + toast({ + title: 'Episode 重建失败', + description: error instanceof Error ? error.message : String(error), + variant: 'destructive', + }) + } finally { + setActionLoading(false) + } + }, [loadEpisodes, rebuildAll, rebuildSource, rebuildSources, toast]) + + const submitProcessPending = useCallback(async () => { + setActionLoading(true) + try { + const payload = await processMemoryEpisodePending({ + limit: parsePositiveInt(pendingLimit, 20), + max_retry: parsePositiveInt(pendingMaxRetry, 3), + }) + toast({ + title: payload.success ? '已处理待生成 Episode' : '处理待生成 Episode 失败', + description: String(payload.detail ?? payload.error ?? `已处理 ${payload.processed ?? 0} 项`), + variant: payload.success ? 'default' : 'destructive', + }) + await loadEpisodes() + } catch (error) { + toast({ + title: '处理待生成 Episode 失败', + description: error instanceof Error ? error.message : String(error), + variant: 'destructive', + }) + } finally { + setActionLoading(false) + } + }, [loadEpisodes, pendingLimit, pendingMaxRetry, toast]) + + return ( +
+
+ {[ + { label: '待处理队列', value: Number(status?.pending_queue ?? 0) }, + { label: '待重建', value: getStatusCount(status, 'pending') }, + { label: '运行中', value: getStatusCount(status, 'running') }, + { label: '失败来源', value: failedItems.length || getStatusCount(status, 'failed') }, + ].map((item) => ( + + + {item.label} + {item.value} + + + ))} +
+ +
+ + + + + Episode 查询 + + 按来源、人物和时间范围查看情节记忆构建结果。 + + +
+
+ + setQuery(event.target.value)} placeholder="搜索摘要或内容" /> +
+
+ + setSource(event.target.value)} placeholder="chat_summary:..." /> +
+
+ + setPersonId(event.target.value)} placeholder="person_id" /> +
+
+ + setLimit(event.target.value)} /> +
+
+ + setTimeStart(event.target.value)} placeholder="可选" /> +
+
+ + setTimeEnd(event.target.value)} placeholder="可选" /> +
+
+ + + + + + + Episode + 来源 + 更新时间 + + + + {items.length > 0 ? items.map((item) => { + const episodeId = getEpisodeId(item) + return ( + setSelectedId(episodeId)} + > + +
{getEpisodeTitle(item)}
+
{episodeId || '-'}
+
+ {String(item.source ?? '-')} + {formatMemoryTime(item.updated_at ?? item.created_at)} +
+ ) + }) : ( + + + {loading ? '正在加载 Episode...' : '没有匹配的 Episode'} + + + )} +
+
+
+
+
+ +
+ + + Episode 详情 + 查看情节摘要、原始字段和关联段落。 + + + {detailLoading ? ( +
+ + 正在加载详情 +
+ ) : selectedEpisode ? ( + <> +
+ {getEpisodeId(selectedEpisode) || '无 ID'} + {selectedEpisode.source ? {String(selectedEpisode.source)} : null} + {selectedEpisode.person_id ? {String(selectedEpisode.person_id)} : null} +
+