feat(A_Memorix): 补全长期记忆控制台管理功能

This commit is contained in:
A-Dawn
2026-04-27 17:01:58 +08:00
parent 8de950df2e
commit edc8d40d03
6 changed files with 1420 additions and 11 deletions

View File

@@ -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<MemoryEpisodeItemPayload[]>([])
const [status, setStatus] = useState<MemoryEpisodeStatusPayload | null>(null)
const [selectedId, setSelectedId] = useState('')
const [detail, setDetail] = useState<MemoryEpisodeDetailPayload | null>(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 (
<div className="space-y-4">
<div className="grid gap-4 xl:grid-cols-4">
{[
{ 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) => (
<Card key={item.label}>
<CardHeader className="pb-3">
<CardDescription>{item.label}</CardDescription>
<CardTitle className="text-2xl">{item.value}</CardTitle>
</CardHeader>
</Card>
))}
</div>
<div className="grid gap-4 xl:grid-cols-[minmax(0,0.95fr)_minmax(0,1.05fr)]">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Search className="h-4 w-4" />
Episode
</CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="episode-query"></Label>
<Input id="episode-query" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索摘要或内容" />
</div>
<div className="space-y-2">
<Label htmlFor="episode-source"></Label>
<Input id="episode-source" value={source} onChange={(event) => setSource(event.target.value)} placeholder="chat_summary:..." />
</div>
<div className="space-y-2">
<Label htmlFor="episode-person"> ID</Label>
<Input id="episode-person" value={personId} onChange={(event) => setPersonId(event.target.value)} placeholder="person_id" />
</div>
<div className="space-y-2">
<Label htmlFor="episode-limit"></Label>
<Input id="episode-limit" type="number" value={limit} onChange={(event) => setLimit(event.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="episode-time-start"></Label>
<Input id="episode-time-start" value={timeStart} onChange={(event) => setTimeStart(event.target.value)} placeholder="可选" />
</div>
<div className="space-y-2">
<Label htmlFor="episode-time-end"></Label>
<Input id="episode-time-end" value={timeEnd} onChange={(event) => setTimeEnd(event.target.value)} placeholder="可选" />
</div>
</div>
<Button onClick={() => void loadEpisodes()} disabled={loading}>
<RefreshCw className={cn('mr-2 h-4 w-4', loading && 'animate-spin')} />
Episode
</Button>
<ScrollArea className="h-[420px] rounded-lg border">
<Table>
<TableHeader className="sticky top-0 bg-background">
<TableRow>
<TableHead>Episode</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.length > 0 ? items.map((item) => {
const episodeId = getEpisodeId(item)
return (
<TableRow
key={episodeId || getEpisodeTitle(item)}
className={cn('cursor-pointer', selectedId === episodeId && 'bg-muted/60')}
onClick={() => setSelectedId(episodeId)}
>
<TableCell>
<div className="max-w-[280px] truncate font-medium">{getEpisodeTitle(item)}</div>
<div className="font-mono text-[11px] text-muted-foreground break-all">{episodeId || '-'}</div>
</TableCell>
<TableCell className="max-w-[180px] truncate">{String(item.source ?? '-')}</TableCell>
<TableCell>{formatMemoryTime(item.updated_at ?? item.created_at)}</TableCell>
</TableRow>
)
}) : (
<TableRow>
<TableCell colSpan={3} className="text-center text-muted-foreground">
{loading ? '正在加载 Episode...' : '没有匹配的 Episode'}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</ScrollArea>
</CardContent>
</Card>
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Episode </CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{detailLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
) : selectedEpisode ? (
<>
<div className="flex flex-wrap gap-2">
<Badge variant="outline">{getEpisodeId(selectedEpisode) || '无 ID'}</Badge>
{selectedEpisode.source ? <Badge variant="secondary">{String(selectedEpisode.source)}</Badge> : null}
{selectedEpisode.person_id ? <Badge>{String(selectedEpisode.person_id)}</Badge> : null}
</div>
<Textarea value={String(selectedEpisode.summary ?? selectedEpisode.content ?? '')} readOnly className="min-h-[120px]" />
<pre className="max-h-56 overflow-auto rounded-lg border bg-muted/20 p-3 text-xs break-words whitespace-pre-wrap">
{JSON.stringify(selectedEpisode, null, 2)}
</pre>
<div className="space-y-2">
<div className="text-sm font-medium"></div>
{selectedEpisodeParagraphs.length > 0 ? (
<ScrollArea className="h-[220px] rounded-lg border bg-background/60">
<div className="space-y-2 p-3">
{selectedEpisodeParagraphs.map((paragraph, index) => (
<div key={String(paragraph.hash ?? index)} className="rounded-lg border bg-muted/20 p-3">
<div className="font-mono text-[11px] text-muted-foreground break-all">{String(paragraph.hash ?? '-')}</div>
<div className="mt-2 text-sm break-words">{String(paragraph.preview ?? paragraph.content ?? '')}</div>
</div>
))}
</div>
</ScrollArea>
) : (
<div className="rounded-lg border border-dashed bg-muted/20 p-4 text-sm text-muted-foreground"></div>
)}
</div>
</>
) : (
<div className="rounded-lg border border-dashed bg-muted/20 p-6 text-center text-sm text-muted-foreground"> Episode </div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<RotateCcw className="h-4 w-4" />
Episode
</CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{failedItems.length > 0 ? (
<Alert>
<AlertDescription>
{failedItems.slice(0, 3).map((item) => String(item.source ?? item.id ?? item.error ?? '未知')).join('、')}
</AlertDescription>
</Alert>
) : null}
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="episode-rebuild-source"></Label>
<Input id="episode-rebuild-source" value={rebuildSource} onChange={(event) => setRebuildSource(event.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="episode-rebuild-sources"></Label>
<Input id="episode-rebuild-sources" value={rebuildSources} onChange={(event) => setRebuildSources(event.target.value)} />
</div>
</div>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={rebuildAll} onChange={(event) => setRebuildAll(event.target.checked)} />
</label>
<Button onClick={() => void submitRebuild()} disabled={actionLoading}>
<RotateCcw className="mr-2 h-4 w-4" />
Episode
</Button>
<div className="grid gap-3 md:grid-cols-[1fr_1fr_auto] md:items-end">
<div className="space-y-2">
<Label htmlFor="episode-pending-limit"></Label>
<Input id="episode-pending-limit" type="number" value={pendingLimit} onChange={(event) => setPendingLimit(event.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="episode-pending-retry"></Label>
<Input id="episode-pending-retry" type="number" value={pendingMaxRetry} onChange={(event) => setPendingMaxRetry(event.target.value)} />
</div>
<Button variant="outline" onClick={() => void submitProcessPending()} disabled={actionLoading}>
<Play className="mr-2 h-4 w-4" />
pending
</Button>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,325 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Lock, RefreshCw, RotateCcw, Shield, Snowflake } 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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { useToast } from '@/hooks/use-toast'
import {
freezeMemory,
getMemoryRecycleBin,
protectMemory,
reinforceMemory,
restoreMaintainedMemory,
type MemoryMaintenanceActionPayload,
type MemoryMaintenanceItemPayload,
} from '@/lib/memory-api'
import { cn } from '@/lib/utils'
type MaintenanceAction = 'reinforce' | 'freeze' | 'protect' | 'restore'
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 parseOptionalHours(value: string): number | undefined {
const trimmed = value.trim()
if (!trimmed) {
return undefined
}
const parsed = Number(trimmed)
return Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined
}
function getRelationTarget(item: MemoryMaintenanceItemPayload): string {
return String(item.hash ?? item.relation_hash ?? '')
}
function getRelationText(item: MemoryMaintenanceItemPayload): string {
const direct = String(item.text ?? '').trim()
if (direct) {
return direct
}
return [item.subject, item.predicate, item.object].map((value) => String(value ?? '').trim()).filter(Boolean).join(' ')
}
function getActionLabel(action: MaintenanceAction): string {
switch (action) {
case 'reinforce':
return '强化'
case 'freeze':
return '冻结'
case 'protect':
return '保护'
case 'restore':
return '恢复'
default:
return action
}
}
export function MemoryMaintenanceManager() {
const { toast } = useToast()
const [target, setTarget] = useState('')
const [action, setAction] = useState<MaintenanceAction>('reinforce')
const [protectHours, setProtectHours] = useState('')
const [recycleLimit, setRecycleLimit] = useState('50')
const [items, setItems] = useState<MemoryMaintenanceItemPayload[]>([])
const [loading, setLoading] = useState(false)
const [actionLoading, setActionLoading] = useState(false)
const [itemSearch, setItemSearch] = useState('')
const initialLoadedRef = useRef(false)
const filteredItems = useMemo(() => {
const keyword = itemSearch.trim().toLowerCase()
if (!keyword) {
return items
}
return items.filter((item) =>
[
getRelationTarget(item),
getRelationText(item),
item.source,
item.subject,
item.predicate,
item.object,
].some((value) => String(value ?? '').toLowerCase().includes(keyword)),
)
}, [itemSearch, items])
const loadRecycleBin = useCallback(async () => {
setLoading(true)
try {
const payload = await getMemoryRecycleBin(parsePositiveInt(recycleLimit, 50))
setItems(payload.items ?? [])
} catch (error) {
toast({
title: '加载记忆回收站失败',
description: error instanceof Error ? error.message : String(error),
variant: 'destructive',
})
} finally {
setLoading(false)
}
}, [recycleLimit, toast])
useEffect(() => {
if (initialLoadedRef.current) {
return
}
initialLoadedRef.current = true
void loadRecycleBin()
}, [loadRecycleBin])
const runAction = useCallback(async (nextAction: MaintenanceAction, nextTarget: string) => {
const cleanTarget = nextTarget.trim()
if (!cleanTarget) {
toast({
title: '缺少维护目标',
description: '请输入关系 hash 或查询文本。',
variant: 'destructive',
})
return
}
if (nextAction === 'freeze' && !window.confirm('确认冻结命中的记忆关系?冻结后关系会从活跃图谱中移除。')) {
return
}
if (nextAction === 'restore' && !window.confirm('确认恢复命中的记忆关系?')) {
return
}
setActionLoading(true)
try {
let payload: MemoryMaintenanceActionPayload
if (nextAction === 'reinforce') {
payload = await reinforceMemory(cleanTarget)
} else if (nextAction === 'freeze') {
payload = await freezeMemory(cleanTarget)
} else if (nextAction === 'protect') {
payload = await protectMemory(cleanTarget, parseOptionalHours(protectHours))
} else {
payload = await restoreMaintainedMemory(cleanTarget)
}
toast({
title: payload.success ? `记忆${getActionLabel(nextAction)}完成` : `记忆${getActionLabel(nextAction)}失败`,
description: String(payload.detail ?? payload.error ?? ''),
variant: payload.success ? 'default' : 'destructive',
})
await loadRecycleBin()
} catch (error) {
toast({
title: `记忆${getActionLabel(nextAction)}失败`,
description: error instanceof Error ? error.message : String(error),
variant: 'destructive',
})
} finally {
setActionLoading(false)
}
}, [loadRecycleBin, protectHours, toast])
return (
<div className="grid gap-4 xl:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Shield className="h-4 w-4" />
</CardTitle>
<CardDescription> hash </CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Alert>
<AlertDescription>
沿 hash
</AlertDescription>
</Alert>
<div className="space-y-2">
<Label htmlFor="maintenance-target"></Label>
<Input id="maintenance-target" value={target} onChange={(event) => setTarget(event.target.value)} placeholder="relation hash 或查询文本" />
</div>
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-2">
<Label></Label>
<Select value={action} onValueChange={(value) => setAction(value as MaintenanceAction)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="reinforce"></SelectItem>
<SelectItem value="freeze"></SelectItem>
<SelectItem value="protect"></SelectItem>
<SelectItem value="restore"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="maintenance-hours"></Label>
<Input
id="maintenance-hours"
type="number"
value={protectHours}
onChange={(event) => setProtectHours(event.target.value)}
placeholder="空值表示永久保护"
disabled={action !== 'protect'}
/>
</div>
</div>
<Button onClick={() => void runAction(action, target)} disabled={actionLoading}>
{action === 'reinforce' ? <Lock className="mr-2 h-4 w-4" /> : null}
{action === 'freeze' ? <Snowflake className="mr-2 h-4 w-4" /> : null}
{action === 'protect' ? <Shield className="mr-2 h-4 w-4" /> : null}
{action === 'restore' ? <RotateCcw className="mr-2 h-4 w-4" /> : null}
{getActionLabel(action)}
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<RotateCcw className="h-4 w-4" />
</CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr)_140px_auto] md:items-end">
<div className="space-y-2">
<Label htmlFor="maintenance-search"></Label>
<Input id="maintenance-search" value={itemSearch} onChange={(event) => setItemSearch(event.target.value)} placeholder="按 hash、主体、谓词、来源筛选" />
</div>
<div className="space-y-2">
<Label htmlFor="maintenance-limit"></Label>
<Input id="maintenance-limit" type="number" value={recycleLimit} onChange={(event) => setRecycleLimit(event.target.value)} />
</div>
<Button variant="outline" onClick={() => void loadRecycleBin()} disabled={loading}>
<RefreshCw className={cn('mr-2 h-4 w-4', loading && 'animate-spin')} />
</Button>
</div>
<div className="flex flex-wrap gap-2 text-sm text-muted-foreground">
<Badge variant="outline"> {items.length} </Badge>
<Badge variant="secondary"> {filteredItems.length} </Badge>
</div>
<ScrollArea className="h-[520px] rounded-lg border">
<Table>
<TableHeader className="sticky top-0 bg-background">
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredItems.length > 0 ? filteredItems.map((item, index) => {
const rowTarget = getRelationTarget(item)
return (
<TableRow key={`${rowTarget}:${index}`}>
<TableCell>
<div className="font-medium break-words">{getRelationText(item) || '-'}</div>
<div className="mt-1 font-mono text-[11px] text-muted-foreground break-all">{rowTarget || '-'}</div>
{item.source ? <Badge variant="outline" className="mt-2">{String(item.source)}</Badge> : null}
</TableCell>
<TableCell>{formatMemoryTime(item.deleted_at ?? item.updated_at)}</TableCell>
<TableCell>
<Button
size="sm"
variant="outline"
onClick={() => void runAction('restore', rowTarget)}
disabled={!rowTarget || actionLoading}
>
</Button>
</TableCell>
</TableRow>
)
}) : (
<TableRow>
<TableCell colSpan={3} className="text-center text-muted-foreground">
{loading ? '正在加载回收站...' : '回收站没有可展示的关系'}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</ScrollArea>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,376 @@
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<MemoryProfileItemPayload[]>([])
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<MemoryProfileQueryPayload | null>(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 (
<div className="grid gap-4 xl:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Search className="h-4 w-4" />
</CardTitle>
<CardDescription> person_id / </CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="profile-person-id">person_id</Label>
<Input id="profile-person-id" value={queryPersonId} onChange={(event) => setQueryPersonId(event.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="profile-keyword"></Label>
<Input id="profile-keyword" value={queryKeyword} onChange={(event) => setQueryKeyword(event.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="profile-limit"></Label>
<Input id="profile-limit" type="number" value={queryLimit} onChange={(event) => setQueryLimit(event.target.value)} />
</div>
<div className="flex items-center gap-2 self-end pb-2">
<Checkbox
id="profile-force-refresh"
checked={forceRefresh}
onCheckedChange={(value) => setForceRefresh(Boolean(value))}
/>
<Label htmlFor="profile-force-refresh" className="text-sm font-normal">
</Label>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button onClick={() => void submitQuery()} disabled={querying}>
<Search className="mr-2 h-4 w-4" />
</Button>
<Button variant="outline" onClick={() => void loadProfiles()} disabled={loading}>
<RefreshCw className={cn('mr-2 h-4 w-4', loading && 'animate-spin')} />
</Button>
</div>
<ScrollArea className="h-[520px] rounded-lg border">
<Table>
<TableHeader className="sticky top-0 bg-background">
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{profiles.length > 0 ? profiles.map((item) => (
<TableRow
key={item.person_id}
className={cn('cursor-pointer', selectedPersonId === item.person_id && 'bg-muted/60')}
onClick={() => setSelectedPersonId(item.person_id)}
>
<TableCell>
<div className="font-medium break-all">{item.person_id}</div>
<div className="mt-1 flex flex-wrap gap-1">
{item.has_manual_override ? <Badge variant="secondary"> override</Badge> : null}
{item.source_note ? <Badge variant="outline">{item.source_note}</Badge> : null}
</div>
</TableCell>
<TableCell>{Number(item.profile_version ?? 0)}</TableCell>
<TableCell>{formatMemoryTime(item.updated_at)}</TableCell>
</TableRow>
)) : (
<TableRow>
<TableCell colSpan={3} className="text-center text-muted-foreground">
{loading ? '正在加载人物画像...' : '还没有人物画像快照'}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</ScrollArea>
</CardContent>
</Card>
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{querying ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
) : null}
{selectedProfile || queryResult ? (
<>
<div className="flex flex-wrap gap-2">
<Badge variant="outline">{selectedPersonId || String(queryResult?.person_id ?? '未选择')}</Badge>
{selectedProfile?.expires_at ? <Badge variant="secondary"> {formatMemoryTime(selectedProfile.expires_at)}</Badge> : null}
</div>
<Textarea value={profileText} readOnly className="min-h-[180px]" placeholder="当前没有画像文本" />
<pre className="max-h-72 overflow-auto rounded-lg border bg-muted/20 p-3 text-xs break-words whitespace-pre-wrap">
{JSON.stringify(queryResult ?? selectedProfile ?? {}, null, 2)}
</pre>
</>
) : (
<div className="rounded-lg border border-dashed bg-muted/20 p-6 text-center text-sm text-muted-foreground">
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle> Override</CardTitle>
<CardDescription> override </CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{!selectedPersonId && !queryPersonId.trim() ? (
<Alert>
<AlertDescription> person_id override</AlertDescription>
</Alert>
) : null}
<Textarea
value={overrideText}
onChange={(event) => setOverrideText(event.target.value)}
className="min-h-[180px]"
placeholder="输入希望固定使用的人物画像文本"
/>
<div className="flex flex-wrap gap-2">
<Button onClick={() => void saveOverride()} disabled={saving}>
<Save className="mr-2 h-4 w-4" />
override
</Button>
<Button variant="outline" onClick={() => void deleteOverride()} disabled={saving || (!selectedPersonId && !queryPersonId.trim())}>
<Trash2 className="mr-2 h-4 w-4" />
override
</Button>
</div>
</CardContent>
</Card>
</div>
</div>
)
}

View File

@@ -581,6 +581,118 @@ export interface MemorySourceListPayload {
count: number
}
export interface MemoryEpisodeItemPayload extends Record<string, unknown> {
episode_id?: string
id?: string
title?: string
summary?: string
content?: string
source?: string
person_id?: string
time_start?: number | null
time_end?: number | null
created_at?: number | null
updated_at?: number | null
}
export interface MemoryEpisodeParagraphPayload extends Record<string, unknown> {
hash?: string
content?: string
preview?: string
source?: string
created_at?: number | null
updated_at?: number | null
}
export interface MemoryEpisodeListPayload {
success: boolean
items: MemoryEpisodeItemPayload[]
count?: number
error?: string
}
export interface MemoryEpisodeDetailPayload {
success: boolean
episode?: MemoryEpisodeItemPayload & {
paragraphs?: MemoryEpisodeParagraphPayload[]
}
error?: string
}
export interface MemoryEpisodeStatusPayload extends Record<string, unknown> {
success: boolean
pending_queue?: number
counts?: Record<string, number>
failed?: Array<Record<string, unknown>>
error?: string
}
export interface MemoryEpisodeActionPayload extends Record<string, unknown> {
success: boolean
error?: string
detail?: string
}
export interface MemoryProfileItemPayload extends Record<string, unknown> {
person_id: string
profile_version?: number
profile_text?: string
updated_at?: number | null
expires_at?: number | null
source_note?: string
has_manual_override?: boolean
manual_override?: Record<string, unknown> | string | null
}
export interface MemoryProfileListPayload {
success: boolean
items: MemoryProfileItemPayload[]
count?: number
error?: string
}
export interface MemoryProfileQueryPayload extends Record<string, unknown> {
success?: boolean
profile?: MemoryProfileItemPayload | Record<string, unknown>
person_id?: string
profile_text?: string
evidence?: Array<Record<string, unknown>>
error?: string
}
export interface MemoryProfileOverridePayload extends Record<string, unknown> {
success: boolean
override?: Record<string, unknown>
deleted?: boolean
person_id?: string
error?: string
}
export interface MemoryMaintenanceItemPayload extends Record<string, unknown> {
hash?: string
relation_hash?: string
subject?: string
predicate?: string
object?: string
text?: string
deleted_at?: number | null
updated_at?: number | null
source?: string
}
export interface MemoryRecycleBinPayload {
success: boolean
items: MemoryMaintenanceItemPayload[]
count?: number
error?: string
}
export interface MemoryMaintenanceActionPayload extends Record<string, unknown> {
success: boolean
detail?: string
error?: string
}
export async function getMemoryGraph(limit: number = 120): Promise<MemoryGraphPayload> {
return requestJson<MemoryGraphPayload>(`/graph?limit=${limit}`)
}
@@ -728,6 +840,126 @@ export async function getMemorySources(): Promise<MemorySourceListPayload> {
return requestJson<MemorySourceListPayload>('/sources')
}
export async function getMemoryEpisodes(options?: {
query?: string
limit?: number
source?: string
personId?: string
timeStart?: number
timeEnd?: number
}): Promise<MemoryEpisodeListPayload> {
const params = new URLSearchParams({
query: options?.query ?? '',
limit: String(options?.limit ?? 20),
source: options?.source ?? '',
person_id: options?.personId ?? '',
})
if (options?.timeStart !== undefined) {
params.set('time_start', String(options.timeStart))
}
if (options?.timeEnd !== undefined) {
params.set('time_end', String(options.timeEnd))
}
return requestJson<MemoryEpisodeListPayload>(`/episodes?${params.toString()}`)
}
export async function getMemoryEpisode(episodeId: string): Promise<MemoryEpisodeDetailPayload> {
return requestJson<MemoryEpisodeDetailPayload>(`/episodes/${encodeURIComponent(episodeId)}`)
}
export async function rebuildMemoryEpisodes(payload: {
source?: string
sources?: string[]
all?: boolean
}): Promise<MemoryEpisodeActionPayload> {
return requestJson<MemoryEpisodeActionPayload>('/episodes/rebuild', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
}
export async function getMemoryEpisodeStatus(limit: number = 20): Promise<MemoryEpisodeStatusPayload> {
return requestJson<MemoryEpisodeStatusPayload>(`/episodes/status?limit=${limit}`)
}
export async function processMemoryEpisodePending(payload: {
limit?: number
max_retry?: number
}): Promise<MemoryEpisodeActionPayload> {
return requestJson<MemoryEpisodeActionPayload>('/episodes/process-pending', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
}
export async function getMemoryProfiles(limit: number = 50): Promise<MemoryProfileListPayload> {
return requestJson<MemoryProfileListPayload>(`/profiles?limit=${limit}`)
}
export async function queryMemoryProfile(options: {
personId?: string
personKeyword?: string
limit?: number
forceRefresh?: boolean
}): Promise<MemoryProfileQueryPayload> {
const params = new URLSearchParams({
person_id: options.personId ?? '',
person_keyword: options.personKeyword ?? '',
limit: String(options.limit ?? 12),
force_refresh: options.forceRefresh ? 'true' : 'false',
})
return requestJson<MemoryProfileQueryPayload>(`/profiles/query?${params.toString()}`)
}
export async function setMemoryProfileOverride(payload: {
person_id: string
override_text: string
updated_by?: string
source?: string
}): Promise<MemoryProfileOverridePayload> {
return requestJson<MemoryProfileOverridePayload>('/profiles/override', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
}
export async function deleteMemoryProfileOverride(personId: string): Promise<MemoryProfileOverridePayload> {
return requestJson<MemoryProfileOverridePayload>(`/profiles/override/${encodeURIComponent(personId)}`, {
method: 'DELETE',
})
}
export async function getMemoryRecycleBin(limit: number = 50): Promise<MemoryRecycleBinPayload> {
return requestJson<MemoryRecycleBinPayload>(`/maintenance/recycle-bin?limit=${limit}`)
}
function maintainMemory(path: string, payload: { target: string; hours?: number }): Promise<MemoryMaintenanceActionPayload> {
return requestJson<MemoryMaintenanceActionPayload>(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
}
export async function restoreMaintainedMemory(target: string): Promise<MemoryMaintenanceActionPayload> {
return maintainMemory('/maintenance/restore', { target })
}
export async function reinforceMemory(target: string): Promise<MemoryMaintenanceActionPayload> {
return maintainMemory('/maintenance/reinforce', { target })
}
export async function freezeMemory(target: string): Promise<MemoryMaintenanceActionPayload> {
return maintainMemory('/maintenance/freeze', { target })
}
export async function protectMemory(target: string, hours?: number): Promise<MemoryMaintenanceActionPayload> {
return maintainMemory('/maintenance/protect', hours === undefined ? { target } : { target, hours })
}
export async function getMemoryRuntimeConfig(): Promise<MemoryRuntimeConfigPayload> {
return requestJson<MemoryRuntimeConfigPayload>('/runtime/config')
}

View File

@@ -23,6 +23,9 @@ import {
import { CodeEditor } from '@/components/CodeEditor'
import { MemoryDeleteDialog } from '@/components/memory/MemoryDeleteDialog'
import { MemoryConfigEditor } from '@/components/memory/MemoryConfigEditor'
import { MemoryEpisodeManager } from '@/components/memory/MemoryEpisodeManager'
import { MemoryMaintenanceManager } from '@/components/memory/MemoryMaintenanceManager'
import { MemoryProfileManager } from '@/components/memory/MemoryProfileManager'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
@@ -645,6 +648,8 @@ export function KnowledgeBasePage() {
const [creatingImport, setCreatingImport] = useState(false)
const [creatingTuning, setCreatingTuning] = useState(false)
const [rawMode, setRawMode] = useState(false)
const [activeTab, setActiveTab] = useState('overview')
const [visitedMemoryTabs, setVisitedMemoryTabs] = useState<Set<string>>(() => new Set())
const [schemaPayload, setSchemaPayload] = useState<MemoryConfigSchemaPayload | null>(null)
const [visualConfig, setVisualConfig] = useState<Record<string, unknown>>({})
@@ -850,6 +855,19 @@ export function KnowledgeBasePage() {
void loadPage()
}, [loadPage])
useEffect(() => {
if (['episodes', 'profiles', 'maintenance'].includes(activeTab)) {
setVisitedMemoryTabs((current) => {
if (current.has(activeTab)) {
return current
}
const next = new Set(current)
next.add(activeTab)
return next
})
}
}, [activeTab])
const configPath = schemaPayload?.path ?? 'config/a_memorix.toml'
const schema = schemaPayload?.schema
@@ -2310,7 +2328,7 @@ export function KnowledgeBasePage() {
))}
</div>
<Tabs defaultValue="overview" className="space-y-5">
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-5">
<TabsList className="h-auto flex-wrap justify-start gap-1 rounded-xl border bg-muted/30 p-1">
<TabsTrigger value="overview" className="rounded-lg px-4 py-1.5">
@@ -2324,6 +2342,15 @@ export function KnowledgeBasePage() {
<TabsTrigger value="tuning" className="rounded-lg px-4 py-1.5">
</TabsTrigger>
<TabsTrigger value="episodes" className="rounded-lg px-4 py-1.5">
</TabsTrigger>
<TabsTrigger value="profiles" className="rounded-lg px-4 py-1.5">
</TabsTrigger>
<TabsTrigger value="maintenance" className="rounded-lg px-4 py-1.5">
</TabsTrigger>
<TabsTrigger value="delete" className="rounded-lg px-4 py-1.5">
</TabsTrigger>
@@ -3536,6 +3563,18 @@ export function KnowledgeBasePage() {
</div>
</TabsContent>
<TabsContent value="episodes" className="space-y-4">
{visitedMemoryTabs.has('episodes') ? <MemoryEpisodeManager /> : null}
</TabsContent>
<TabsContent value="profiles" className="space-y-4">
{visitedMemoryTabs.has('profiles') ? <MemoryProfileManager /> : null}
</TabsContent>
<TabsContent value="maintenance" className="space-y-4">
{visitedMemoryTabs.has('maintenance') ? <MemoryMaintenanceManager /> : null}
</TabsContent>
<TabsContent value="delete" className="space-y-4">
<div className="space-y-4">
<Card>

View File

@@ -810,6 +810,11 @@ async def list_memory_episodes(
)
@router.get("/episodes/status")
async def get_memory_episode_status(limit: int = Query(20, ge=1, le=200)):
return await _episode_status(limit)
@router.get("/episodes/{episode_id}")
async def get_memory_episode(episode_id: str):
return await _episode_get(episode_id)
@@ -820,11 +825,6 @@ async def rebuild_memory_episodes(payload: EpisodeRebuildRequest):
return await _episode_rebuild(payload)
@router.get("/episodes/status")
async def get_memory_episode_status(limit: int = Query(20, ge=1, le=200)):
return await _episode_status(limit)
@router.post("/episodes/process-pending")
async def process_memory_episode_pending(payload: EpisodeProcessPendingRequest):
return await _episode_process_pending(payload)
@@ -1282,6 +1282,11 @@ async def compat_list_episodes(
)
@compat_router.get("/episodes/status")
async def compat_episode_status(limit: int = Query(20, ge=1, le=200)):
return await _episode_status(limit)
@compat_router.get("/episodes/{episode_id}")
async def compat_get_episode(episode_id: str):
return await _episode_get(episode_id)
@@ -1292,11 +1297,6 @@ async def compat_rebuild_episodes(payload: EpisodeRebuildRequest):
return await _episode_rebuild(payload)
@compat_router.get("/episodes/status")
async def compat_episode_status(limit: int = Query(20, ge=1, le=200)):
return await _episode_status(limit)
@compat_router.post("/episodes/process_pending")
async def compat_process_episode_pending(payload: EpisodeProcessPendingRequest):
return await _episode_process_pending(payload)