Merge pull request #1644 from A-Dawn/dev

为记忆系统补全情节记忆/人物画像/纠错功能,并修复docker的依赖安装问题
This commit is contained in:
SengokuCola
2026-05-06 13:37:30 +08:00
committed by GitHub
10 changed files with 1990 additions and 33 deletions

View File

@@ -0,0 +1,518 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { ChevronDown, 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 { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
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 [platform, setPlatform] = useState('')
const [userId, setUserId] = useState('')
const [personId, setPersonId] = useState('')
const [showAdvancedPersonId, setShowAdvancedPersonId] = useState(false)
const [showRawEpisodePayload, setShowRawEpisodePayload] = useState(false)
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 directPersonId = showAdvancedPersonId ? personId.trim() : ''
const [listPayload] = await Promise.all([
getMemoryEpisodes({
query: query.trim(),
source: source.trim(),
platform: platform.trim(),
userId: userId.trim(),
personId: directPersonId,
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, platform, query, selectedId, showAdvancedPersonId, source, timeEnd, timeStart, toast, userId])
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>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="episode-platform"></Label>
<Input
id="episode-platform"
value={platform}
onChange={(event) => setPlatform(event.target.value)}
placeholder="例如 qq、telegram、webui"
/>
</div>
<div className="space-y-2">
<Label htmlFor="episode-user-id"></Label>
<Input id="episode-user-id" value={userId} onChange={(event) => setUserId(event.target.value)} placeholder="输入平台侧 user_id" />
</div>
<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-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>
<Collapsible open={showAdvancedPersonId} onOpenChange={setShowAdvancedPersonId} className="rounded-lg border bg-muted/10">
<CollapsibleTrigger asChild>
<Button variant="ghost" className="flex h-10 w-full justify-between px-3">
<span></span>
<ChevronDown className={cn('h-4 w-4 transition-transform', showAdvancedPersonId && 'rotate-180')} />
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-2 border-t px-3 py-3">
<Label htmlFor="episode-person">person_id</Label>
<Input
id="episode-person"
value={personId}
onChange={(event) => setPersonId(event.target.value)}
placeholder="调试或后台管理时直接输入"
/>
</CollapsibleContent>
</Collapsible>
<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>
{item.person_name || item.person_id ? (
<div className="max-w-[280px] truncate text-xs text-muted-foreground">
{String(item.person_name || item.person_id)}
{item.person_name && item.person_id ? <span className="font-mono"> · {String(item.person_id)}</span> : null}
</div>
) : null}
<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_name ? <Badge>{String(selectedEpisode.person_name)}</Badge> : null}
{selectedEpisode.person_id ? <Badge variant="outline">{String(selectedEpisode.person_id)}</Badge> : null}
</div>
<Textarea value={String(selectedEpisode.summary ?? selectedEpisode.content ?? '')} readOnly className="min-h-[120px]" />
<Collapsible open={showRawEpisodePayload} onOpenChange={setShowRawEpisodePayload} className="rounded-lg border bg-muted/10">
<CollapsibleTrigger asChild>
<Button variant="ghost" className="flex h-10 w-full justify-between px-3">
<span> JSON</span>
<ChevronDown className={cn('h-4 w-4 transition-transform', showRawEpisodePayload && 'rotate-180')} />
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="border-t">
<pre className="max-h-56 overflow-auto p-3 text-xs break-words whitespace-pre-wrap">
{JSON.stringify(selectedEpisode, null, 2)}
</pre>
</CollapsibleContent>
</Collapsible>
<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> Episode </CardDescription>
</CardHeader>
<CardContent className="space-y-5">
{failedItems.length > 0 ? (
<Alert>
<AlertDescription>
{failedItems.slice(0, 3).map((item) => String(item.source ?? item.id ?? item.error ?? '未知')).join('、')}
</AlertDescription>
</Alert>
) : null}
<div className="space-y-3 rounded-lg border bg-muted/10 p-3">
<div>
<div className="text-sm font-medium"> Episode</div>
<div className="mt-1 text-xs text-muted-foreground">
Episode
</div>
</div>
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="episode-rebuild-source"> ID</Label>
<Input
id="episode-rebuild-source"
value={rebuildSource}
onChange={(event) => setRebuildSource(event.target.value)}
placeholder="例如 chat_summary:test-webui:coffee"
/>
</div>
<div className="space-y-2">
<Label htmlFor="episode-rebuild-sources"> ID</Label>
<Input
id="episode-rebuild-sources"
value={rebuildSources}
onChange={(event) => setRebuildSources(event.target.value)}
placeholder="用英文逗号分隔多个来源"
/>
</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>
<div className="space-y-3 rounded-lg border bg-muted/10 p-3">
<div>
<div className="text-sm font-medium"></div>
<div className="mt-1 text-xs text-muted-foreground">
Episode
</div>
</div>
<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" />
</Button>
</div>
</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,482 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { ChevronDown, 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 { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
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,
searchMemoryProfiles,
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 [profileListMode, setProfileListMode] = useState<'library' | 'search'>('library')
const [selectedPersonId, setSelectedPersonId] = useState('')
const [queryPersonId, setQueryPersonId] = useState('')
const [queryKeyword, setQueryKeyword] = useState('')
const [queryPlatform, setQueryPlatform] = useState('')
const [queryUserId, setQueryUserId] = useState('')
const [queryLimit, setQueryLimit] = useState('12')
const [forceRefresh, setForceRefresh] = useState(false)
const [showAdvancedPersonId, setShowAdvancedPersonId] = useState(false)
const [showRawProfilePayload, setShowRawProfilePayload] = 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 selectedDisplayName = selectedProfile?.person_name || selectedPersonId || String(queryResult?.person_id ?? '未选择')
const loadProfiles = useCallback(async () => {
setLoading(true)
try {
const payload = await getMemoryProfiles(80)
const nextItems = payload.items ?? []
setProfiles(nextItems)
setProfileListMode('library')
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))
}, [selectedProfile])
const submitQuery = useCallback(async () => {
const directPersonId = showAdvancedPersonId ? queryPersonId.trim() : ''
const cleanKeyword = queryKeyword.trim()
const cleanPlatform = queryPlatform.trim()
const cleanUserId = queryUserId.trim()
const hasAccountLocator = Boolean(cleanPlatform && cleanUserId)
if (!directPersonId && !cleanKeyword && !hasAccountLocator) {
toast({
title: '请输入查询条件',
description: '用户账号、关键词、或高级 person_id 至少填写一种。',
variant: 'destructive',
})
return
}
setQuerying(true)
try {
if (!directPersonId && !hasAccountLocator) {
const searchPayload = await searchMemoryProfiles({
personKeyword: cleanKeyword,
limit: 80,
})
const nextItems = searchPayload.items ?? []
setProfiles(nextItems)
setProfileListMode('search')
setQueryResult(null)
setSelectedPersonId(nextItems[0]?.person_id ?? '')
toast({
title: '人物画像检索完成',
description: `命中 ${nextItems.length} 个画像。`,
})
return
}
const payload = await queryMemoryProfile({
personId: directPersonId,
personKeyword: cleanKeyword,
platform: cleanPlatform,
userId: cleanUserId,
limit: parsePositiveInt(queryLimit, 12),
forceRefresh,
})
if (payload.success === false) {
throw new Error(String(payload.error ?? '人物画像查询失败'))
}
setQueryResult(payload)
const nextPersonId = String(payload.person_id ?? payload.profile?.person_id ?? directPersonId ?? '')
const searchPayload = await searchMemoryProfiles({
personId: nextPersonId || directPersonId,
personKeyword: cleanKeyword,
platform: cleanPlatform,
userId: cleanUserId,
limit: 80,
})
const nextItems = searchPayload.items ?? []
setProfiles(nextItems)
setProfileListMode('search')
if (nextPersonId) {
setSelectedPersonId(nextPersonId)
setQueryPersonId(nextPersonId)
} else if (nextItems.length > 0) {
setSelectedPersonId(nextItems[0].person_id)
}
toast({
title: '人物画像查询完成',
description: forceRefresh ? '已请求强制刷新画像。' : '已获取画像结果。',
})
} catch (error) {
toast({
title: '人物画像查询失败',
description: error instanceof Error ? error.message : String(error),
variant: 'destructive',
})
} finally {
setQuerying(false)
}
}, [forceRefresh, queryKeyword, queryLimit, queryPersonId, queryPlatform, queryUserId, showAdvancedPersonId, 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-platform"></Label>
<Input
id="profile-platform"
value={queryPlatform}
onChange={(event) => setQueryPlatform(event.target.value)}
placeholder="例如 qq、telegram、webui"
/>
</div>
<div className="space-y-2">
<Label htmlFor="profile-user-id"></Label>
<Input
id="profile-user-id"
value={queryUserId}
onChange={(event) => setQueryUserId(event.target.value)}
placeholder="输入平台侧 user_id"
/>
</div>
<div className="space-y-2">
<Label htmlFor="profile-keyword"></Label>
<Input id="profile-keyword" value={queryKeyword} onChange={(event) => setQueryKeyword(event.target.value)} placeholder="可选" />
</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>
<Collapsible open={showAdvancedPersonId} onOpenChange={setShowAdvancedPersonId} className="rounded-lg border bg-muted/10">
<CollapsibleTrigger asChild>
<Button variant="ghost" className="flex h-10 w-full justify-between px-3">
<span></span>
<ChevronDown className={cn('h-4 w-4 transition-transform', showAdvancedPersonId && 'rotate-180')} />
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-2 border-t px-3 py-3">
<Label htmlFor="profile-person-id">person_id</Label>
<Input
id="profile-person-id"
value={queryPersonId}
onChange={(event) => setQueryPersonId(event.target.value)}
placeholder="调试或后台管理时直接输入"
/>
</CollapsibleContent>
</Collapsible>
{selectedPersonId || queryPersonId ? (
<div className="rounded-lg border bg-muted/20 px-3 py-2 text-sm">
<div className="text-muted-foreground"> person_id</div>
<div className="mt-1 break-all font-mono text-xs">{selectedPersonId || queryPersonId}</div>
</div>
) : null}
<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>
<div className="rounded-lg border bg-muted/10 px-3 py-2">
<div className="text-sm font-medium">{profileListMode === 'search' ? '检索结果' : '画像库'}</div>
<div className="mt-1 text-xs text-muted-foreground">
{profileListMode === 'search'
? '根据当前平台账号、关键词或 person_id 筛选出的画像候选。'
: '系统中已生成的最新人物画像快照,按更新时间排序。'}
</div>
</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_name || item.person_id}</div>
{item.person_name ? <div className="mt-0.5 font-mono text-xs text-muted-foreground break-all">{item.person_id}</div> : null}
<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 ? '正在加载人物画像...' : profileListMode === 'search' ? '没有匹配的人物画像' : '还没有人物画像快照'}
</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="当前没有画像文本" />
<Collapsible open={showRawProfilePayload} onOpenChange={setShowRawProfilePayload} className="rounded-lg border bg-muted/10">
<CollapsibleTrigger asChild>
<Button variant="ghost" className="flex h-10 w-full justify-between px-3">
<span> JSON</span>
<ChevronDown className={cn('h-4 w-4 transition-transform', showRawProfilePayload && 'rotate-180')} />
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="border-t">
<pre className="max-h-72 overflow-auto p-3 text-xs break-words whitespace-pre-wrap">
{JSON.stringify(queryResult ?? selectedProfile ?? {}, null, 2)}
</pre>
</CollapsibleContent>
</Collapsible>
</>
) : (
<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}
{selectedDisplayName ? <div className="text-sm text-muted-foreground">{selectedDisplayName}</div> : 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

@@ -6,26 +6,104 @@ import { isElectron } from './runtime'
async function getMemoryApiBase(): Promise<string> { async function getMemoryApiBase(): Promise<string> {
if (isElectron()) { if (isElectron()) {
const base = await getApiBaseUrl() const base = await getApiBaseUrl()
return base ? `${base}/api/webui/memory` : '/api/webui/memory' return normalizeMemoryApiBase(base)
} }
return import.meta.env.VITE_API_BASE_URL return normalizeMemoryApiBase(import.meta.env.VITE_API_BASE_URL)
? `${import.meta.env.VITE_API_BASE_URL}/memory` }
: '/api/webui/memory'
function normalizeMemoryApiBase(rawBase?: string | null): string {
const base = String(rawBase ?? '').replace(/\/+$/, '')
if (!base) {
return '/api/webui/memory'
}
if (base.endsWith('/api/webui/memory')) {
return base
}
if (base.endsWith('/api/webui')) {
return `${base}/memory`
}
return `${base}/api/webui/memory`
}
function withMemoryRequestDefaults(init?: RequestInit): RequestInit {
return {
...init,
credentials: init?.credentials ?? 'include',
}
}
function isHtmlResponse(rawText: string): boolean {
const normalizedText = rawText.trimStart().toLowerCase()
return normalizedText.startsWith('<!doctype') || normalizedText.startsWith('<html')
}
function formatRequestUrl(url: string): string {
if (typeof window === 'undefined') {
return url
}
try {
return new URL(url, window.location.href).toString()
} catch {
return url
}
}
function getLocalMemoryApiFallbackBases(primaryBase: string): string[] {
const fallbackBases: string[] = []
if (typeof window !== 'undefined') {
const hostname = window.location.hostname
if (hostname === 'localhost' || hostname === '127.0.0.1') {
fallbackBases.push(`http://${hostname}:8001/api/webui/memory`)
}
}
fallbackBases.push('http://127.0.0.1:8001/api/webui/memory')
fallbackBases.push('http://localhost:8001/api/webui/memory')
return Array.from(new Set(fallbackBases)).filter((base) => base !== primaryBase)
} }
async function requestJson<T>(path: string, init?: RequestInit): Promise<T> { async function requestJson<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(`${await getMemoryApiBase()}${path}`, init) const primaryBase = await getMemoryApiBase()
if (!response.ok) { const urls = [
let detail = `${response.status}` `${primaryBase}${path}`,
try { ...getLocalMemoryApiFallbackBases(primaryBase).map((base) => `${base}${path}`),
const payload = await response.json() ]
detail = String(payload?.detail ?? payload?.error ?? detail) const requestInit = withMemoryRequestDefaults(init)
} catch {
// ignore json parsing fallback for (let index = 0; index < urls.length; index += 1) {
const url = urls[index]
const response = await fetch(url, requestInit)
const rawText = await response.text()
const htmlResponse = isHtmlResponse(rawText)
const canRetry = index < urls.length - 1
if ((htmlResponse || response.status === 404) && canRetry) {
continue
}
if (!response.ok) {
let detail = `${response.status}`
try {
const payload = JSON.parse(rawText)
detail = String(payload?.detail ?? payload?.error ?? detail)
} catch {
if (htmlResponse) {
detail = `接口返回了前端页面,未命中后端 API 路由;当前请求:${formatRequestUrl(url)}`
}
}
throw new Error(detail)
}
try {
return JSON.parse(rawText) as T
} catch {
if (htmlResponse) {
throw new Error(`接口返回了前端页面,未命中后端 API 路由;当前请求:${formatRequestUrl(url)}`)
}
throw new Error(rawText ? '接口响应不是合法 JSON' : '接口返回了空响应')
} }
throw new Error(detail)
} }
return response.json() as Promise<T>
throw new Error('接口请求失败')
} }
export interface MemoryGraphNodePayload { export interface MemoryGraphNodePayload {
@@ -581,6 +659,120 @@ export interface MemorySourceListPayload {
count: number 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
person_name?: 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
person_name?: 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> { export async function getMemoryGraph(limit: number = 120): Promise<MemoryGraphPayload> {
return requestJson<MemoryGraphPayload>(`/graph?limit=${limit}`) return requestJson<MemoryGraphPayload>(`/graph?limit=${limit}`)
} }
@@ -728,6 +920,151 @@ export async function getMemorySources(): Promise<MemorySourceListPayload> {
return requestJson<MemorySourceListPayload>('/sources') return requestJson<MemorySourceListPayload>('/sources')
} }
export async function getMemoryEpisodes(options?: {
query?: string
limit?: number
source?: string
personId?: string
platform?: string
userId?: 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 ?? '',
platform: options?.platform ?? '',
user_id: options?.userId ?? '',
})
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 searchMemoryProfiles(options: {
personId?: string
personKeyword?: string
platform?: string
userId?: string
limit?: number
}): Promise<MemoryProfileListPayload> {
const params = new URLSearchParams({
person_id: options.personId ?? '',
person_keyword: options.personKeyword ?? '',
platform: options.platform ?? '',
user_id: options.userId ?? '',
limit: String(options.limit ?? 50),
})
return requestJson<MemoryProfileListPayload>(`/profiles/search?${params.toString()}`)
}
export async function queryMemoryProfile(options: {
personId?: string
personKeyword?: string
platform?: string
userId?: string
limit?: number
forceRefresh?: boolean
}): Promise<MemoryProfileQueryPayload> {
const params = new URLSearchParams({
person_id: options.personId ?? '',
person_keyword: options.personKeyword ?? '',
platform: options.platform ?? '',
user_id: options.userId ?? '',
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> { export async function getMemoryRuntimeConfig(): Promise<MemoryRuntimeConfigPayload> {
return requestJson<MemoryRuntimeConfigPayload>('/runtime/config') return requestJson<MemoryRuntimeConfigPayload>('/runtime/config')
} }

View File

@@ -17,7 +17,10 @@ import {
import { CodeEditor } from '@/components/CodeEditor' import { CodeEditor } from '@/components/CodeEditor'
import { MemoryDeleteDialog } from '@/components/memory/MemoryDeleteDialog' import { MemoryDeleteDialog } from '@/components/memory/MemoryDeleteDialog'
import { MemoryEpisodeManager } from '@/components/memory/MemoryEpisodeManager'
import { MemoryMaintenanceManager } from '@/components/memory/MemoryMaintenanceManager'
import { MemoryMiniTabs } from '@/components/memory/MemoryMiniTabs' import { MemoryMiniTabs } from '@/components/memory/MemoryMiniTabs'
import { MemoryProfileManager } from '@/components/memory/MemoryProfileManager'
import { Alert, AlertDescription } from '@/components/ui/alert' import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
@@ -113,8 +116,9 @@ export function KnowledgeBasePage() {
const [creatingImport, setCreatingImport] = useState(false) const [creatingImport, setCreatingImport] = useState(false)
const [creatingTuning, setCreatingTuning] = useState(false) const [creatingTuning, setCreatingTuning] = useState(false)
const [activeTab, setActiveTab] = useState< const [activeTab, setActiveTab] = useState<
'overview' | 'graph' | 'import' | 'tuning' | 'delete' | 'feedback' 'overview' | 'graph' | 'import' | 'tuning' | 'episodes' | 'profiles' | 'maintenance' | 'delete' | 'feedback'
>('overview') >('overview')
const [visitedMemoryTabs, setVisitedMemoryTabs] = useState<Set<string>>(() => new Set())
const [runtimeConfig, setRuntimeConfig] = useState<MemoryRuntimeConfigPayload | null>(null) const [runtimeConfig, setRuntimeConfig] = useState<MemoryRuntimeConfigPayload | null>(null)
const [selfCheckReport, setSelfCheckReport] = useState<Record<string, unknown> | null>(null) const [selfCheckReport, setSelfCheckReport] = useState<Record<string, unknown> | null>(null)
@@ -304,6 +308,20 @@ export function KnowledgeBasePage() {
void loadPage() void loadPage()
}, [loadPage]) }, [loadPage])
useEffect(() => {
if (!['episodes', 'profiles', 'maintenance'].includes(activeTab)) {
return
}
setVisitedMemoryTabs((current) => {
if (current.has(activeTab)) {
return current
}
const next = new Set(current)
next.add(activeTab)
return next
})
}, [activeTab])
const runtimeBadges = useMemo(() => { const runtimeBadges = useMemo(() => {
if (!runtimeConfig) { if (!runtimeConfig) {
return [] return []
@@ -1866,6 +1884,9 @@ export function KnowledgeBasePage() {
{ value: 'graph', label: '图谱', description: '实体关系图与证据视图' }, { value: 'graph', label: '图谱', description: '实体关系图与证据视图' },
{ value: 'import', label: '导入', description: '创建并管理导入任务' }, { value: 'import', label: '导入', description: '创建并管理导入任务' },
{ value: 'tuning', label: '调优', description: '检索策略调优' }, { value: 'tuning', label: '调优', description: '检索策略调优' },
{ value: 'episodes', label: '情景记忆', description: '查看和重建情景记忆' },
{ value: 'profiles', label: '人物画像', description: '查询和维护人物画像' },
{ value: 'maintenance', label: '维护', description: '回收站与记忆状态维护' },
{ value: 'delete', label: '删除', description: '批量删除与历史回溯' }, { value: 'delete', label: '删除', description: '批量删除与历史回溯' },
{ value: 'feedback', label: '纠错历史', description: '查看反馈与回滚' }, { value: 'feedback', label: '纠错历史', description: '查看反馈与回滚' },
]} ]}
@@ -2095,6 +2116,18 @@ export function KnowledgeBasePage() {
applyBestTask={applyBestTask} applyBestTask={applyBestTask}
/> />
<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>
<DeleteTab <DeleteTab
sourceSearch={sourceSearch} sourceSearch={sourceSearch}
setSourceSearch={setSourceSearch} setSourceSearch={setSourceSearch}

View File

@@ -104,7 +104,7 @@ export function FeedbackTab(props: FeedbackTabProps) {
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
feedback correction 退 feedback correction 退
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">

View File

@@ -188,6 +188,59 @@ def test_webui_memory_graph_edge_detail_route_returns_404(client: TestClient, mo
assert response.json()["detail"] == "未找到边: Alice -> Missing" assert response.json()["detail"] == "未找到边: Alice -> Missing"
def test_webui_memory_profile_query_resolves_platform_user_id(client: TestClient, monkeypatch):
def fake_resolve_person_id_for_memory(**kwargs):
assert kwargs == {"platform": "qq", "user_id": "12345", "strict_known": False}
return "resolved-person-id"
async def fake_profile_admin(*, action: str, **kwargs):
assert action == "query"
assert kwargs["person_id"] == "resolved-person-id"
assert kwargs["person_keyword"] == "Alice"
assert kwargs["limit"] == 9
assert kwargs["force_refresh"] is True
return {"success": True, "person_id": kwargs["person_id"], "profile_text": "profile"}
monkeypatch.setattr(memory_router_module, "resolve_person_id_for_memory", fake_resolve_person_id_for_memory)
monkeypatch.setattr(memory_router_module.memory_service, "profile_admin", fake_profile_admin)
response = client.get(
"/api/webui/memory/profiles/query",
params={
"platform": "qq",
"user_id": "12345",
"person_keyword": "Alice",
"limit": 9,
"force_refresh": True,
},
)
assert response.status_code == 200
assert response.json()["success"] is True
assert response.json()["person_id"] == "resolved-person-id"
def test_webui_memory_profile_query_prefers_explicit_person_id(client: TestClient, monkeypatch):
def fake_resolve_person_id_for_memory(**kwargs):
raise AssertionError(f"不应解析平台账号: {kwargs}")
async def fake_profile_admin(*, action: str, **kwargs):
assert action == "query"
assert kwargs["person_id"] == "explicit-person-id"
return {"success": True, "person_id": kwargs["person_id"]}
monkeypatch.setattr(memory_router_module, "resolve_person_id_for_memory", fake_resolve_person_id_for_memory)
monkeypatch.setattr(memory_router_module.memory_service, "profile_admin", fake_profile_admin)
response = client.get(
"/api/webui/memory/profiles/query",
params={"person_id": "explicit-person-id", "platform": "qq", "user_id": "12345"},
)
assert response.status_code == 200
assert response.json()["person_id"] == "explicit-person-id"
def test_compat_aggregate_route(client: TestClient, monkeypatch): def test_compat_aggregate_route(client: TestClient, monkeypatch):
async def fake_search(query: str, **kwargs): async def fake_search(query: str, **kwargs):
assert kwargs["mode"] == "aggregate" assert kwargs["mode"] == "aggregate"

View File

@@ -197,6 +197,9 @@ def _setup_static_files(app: FastAPI):
@app.get("/{full_path:path}", include_in_schema=False) @app.get("/{full_path:path}", include_in_schema=False)
async def serve_spa(full_path: str): async def serve_spa(full_path: str):
if full_path == "api" or full_path.startswith("api/"):
raise HTTPException(status_code=404, detail=t("core.not_found"))
if not full_path or full_path == "/": if not full_path or full_path == "/":
response = FileResponse(static_path / "index.html", media_type="text/html") response = FileResponse(static_path / "index.html", media_type="text/html")
response.headers["X-Robots-Tag"] = "noindex, nofollow, noarchive" response.headers["X-Robots-Tag"] = "noindex, nofollow, noarchive"

View File

@@ -9,8 +9,12 @@ from typing import Any, Optional
import tomlkit import tomlkit
from fastapi import APIRouter, Body, Depends, File, Form, HTTPException, Query, UploadFile from fastapi import APIRouter, Body, Depends, File, Form, HTTPException, Query, UploadFile
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from sqlmodel import col, select
from src.A_memorix.host_service import a_memorix_host_service from src.A_memorix.host_service import a_memorix_host_service
from src.common.database.database import get_db_session
from src.common.database.database_model import PersonInfo
from src.person_info.person_info import resolve_person_id_for_memory
from src.services.memory_service import MemorySearchResult, memory_service from src.services.memory_service import MemorySearchResult, memory_service
from src.webui.dependencies import require_auth from src.webui.dependencies import require_auth
@@ -297,22 +301,49 @@ async def _episode_list(
limit: int, limit: int,
source: str, source: str,
person_id: str, person_id: str,
platform: str,
user_id: str,
time_start: float | None, time_start: float | None,
time_end: float | None, time_end: float | None,
) -> dict: ) -> dict:
return await memory_service.episode_admin( clean_person_id = str(person_id or "").strip()
if not clean_person_id and str(platform or "").strip() and str(user_id or "").strip():
clean_person_id = resolve_person_id_for_memory(
platform=str(platform or "").strip(),
user_id=str(user_id or "").strip(),
strict_known=False,
)
payload = await memory_service.episode_admin(
action="list", action="list",
query=query, query=query,
limit=limit, limit=limit,
source=source, source=source,
person_id=person_id, person_id=clean_person_id,
time_start=time_start, time_start=time_start,
time_end=time_end, time_end=time_end,
) )
if not isinstance(payload, dict) or not isinstance(payload.get("items"), list):
return payload
items = []
for item in payload["items"]:
if not isinstance(item, dict):
items.append(item)
continue
items.append(_enrich_episode_person_name(item))
payload = dict(payload)
payload["items"] = items
return payload
async def _episode_get(episode_id: str) -> dict: async def _episode_get(episode_id: str) -> dict:
return await memory_service.episode_admin(action="get", episode_id=episode_id) payload = await memory_service.episode_admin(action="get", episode_id=episode_id)
if isinstance(payload, dict) and isinstance(payload.get("episode"), dict):
payload = dict(payload)
payload["episode"] = _enrich_episode_person_name(payload["episode"])
return payload
async def _episode_rebuild(payload: EpisodeRebuildRequest) -> dict: async def _episode_rebuild(payload: EpisodeRebuildRequest) -> dict:
@@ -336,18 +367,143 @@ async def _episode_process_pending(payload: EpisodeProcessPendingRequest) -> dic
) )
async def _profile_query(*, person_id: str, person_keyword: str, limit: int, force_refresh: bool) -> dict: async def _profile_query(
*,
person_id: str,
person_keyword: str,
platform: str,
user_id: str,
limit: int,
force_refresh: bool,
) -> dict:
clean_person_id = str(person_id or "").strip()
if not clean_person_id and str(platform or "").strip() and str(user_id or "").strip():
clean_person_id = resolve_person_id_for_memory(
platform=str(platform or "").strip(),
user_id=str(user_id or "").strip(),
strict_known=False,
)
return await memory_service.profile_admin( return await memory_service.profile_admin(
action="query", action="query",
person_id=person_id, person_id=clean_person_id,
person_keyword=person_keyword, person_keyword=person_keyword,
limit=limit, limit=limit,
force_refresh=force_refresh, force_refresh=force_refresh,
) )
def _get_person_name_for_person_id(person_id: str) -> str:
clean_person_id = str(person_id or "").strip()
if not clean_person_id:
return ""
try:
with get_db_session(auto_commit=False) as session:
statement = select(PersonInfo.person_name).where(col(PersonInfo.person_id) == clean_person_id).limit(1)
person_name = session.exec(statement).first()
return str(person_name or "").strip()
except Exception:
return ""
def _enrich_episode_person_name(item: dict) -> dict:
enriched = dict(item)
item_person_id = str(enriched.get("person_id", "") or "").strip()
participants = enriched.get("participants")
if not item_person_id and isinstance(participants, list):
for participant in participants:
if isinstance(participant, dict):
candidate = str(participant.get("person_id", "") or participant.get("id", "") or "").strip()
else:
candidate = str(participant or "").strip()
if candidate:
item_person_id = candidate
break
enriched["person_id"] = item_person_id
enriched["person_name"] = _get_person_name_for_person_id(item_person_id)
return enriched
async def _profile_list(limit: int) -> dict: async def _profile_list(limit: int) -> dict:
return await memory_service.profile_admin(action="list", limit=limit) payload = await memory_service.profile_admin(action="list", limit=limit)
if not isinstance(payload, dict) or not isinstance(payload.get("items"), list):
return payload
items = []
for item in payload["items"]:
if not isinstance(item, dict):
items.append(item)
continue
enriched = dict(item)
person_id = str(enriched.get("person_id", "") or "").strip()
enriched["person_name"] = _get_person_name_for_person_id(person_id)
items.append(enriched)
payload = dict(payload)
payload["items"] = items
return payload
async def _profile_search(
*,
person_id: str,
person_keyword: str,
platform: str,
user_id: str,
limit: int,
) -> dict:
clean_person_id = str(person_id or "").strip()
if not clean_person_id and str(platform or "").strip() and str(user_id or "").strip():
clean_person_id = resolve_person_id_for_memory(
platform=str(platform or "").strip(),
user_id=str(user_id or "").strip(),
strict_known=False,
)
payload = await _profile_list(max(limit, 200))
if not isinstance(payload, dict) or not isinstance(payload.get("items"), list):
return payload
keyword = str(person_keyword or "").strip().lower()
def _matches(item: dict) -> bool:
if clean_person_id and str(item.get("person_id", "") or "").strip() != clean_person_id:
return False
if not keyword:
return True
override = item.get("manual_override")
override_text = ""
if isinstance(override, dict):
override_text = str(override.get("override_text", "") or override.get("text", "") or "")
elif isinstance(override, str):
override_text = override
haystack = "\n".join(
[
str(item.get("person_id", "") or ""),
str(item.get("person_name", "") or ""),
str(item.get("profile_text", "") or ""),
str(item.get("source_note", "") or ""),
override_text,
]
).lower()
return keyword in haystack
items = [item for item in payload["items"] if isinstance(item, dict) and _matches(item)]
items = items[:limit]
return {
"success": True,
"items": items,
"count": len(items),
"query": {
"person_id": clean_person_id,
"person_keyword": person_keyword,
"platform": platform,
"user_id": user_id,
},
}
async def _profile_set_override(payload: ProfileOverrideRequest) -> dict: async def _profile_set_override(payload: ProfileOverrideRequest) -> dict:
@@ -797,6 +953,8 @@ async def list_memory_episodes(
limit: int = Query(20, ge=1, le=200), limit: int = Query(20, ge=1, le=200),
source: str = Query(""), source: str = Query(""),
person_id: str = Query(""), person_id: str = Query(""),
platform: str = Query(""),
user_id: str = Query(""),
time_start: float | None = Query(None), time_start: float | None = Query(None),
time_end: float | None = Query(None), time_end: float | None = Query(None),
): ):
@@ -805,11 +963,18 @@ async def list_memory_episodes(
limit=limit, limit=limit,
source=source, source=source,
person_id=person_id, person_id=person_id,
platform=platform,
user_id=user_id,
time_start=time_start, time_start=time_start,
time_end=time_end, time_end=time_end,
) )
@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}") @router.get("/episodes/{episode_id}")
async def get_memory_episode(episode_id: str): async def get_memory_episode(episode_id: str):
return await _episode_get(episode_id) return await _episode_get(episode_id)
@@ -820,11 +985,6 @@ async def rebuild_memory_episodes(payload: EpisodeRebuildRequest):
return await _episode_rebuild(payload) 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") @router.post("/episodes/process-pending")
async def process_memory_episode_pending(payload: EpisodeProcessPendingRequest): async def process_memory_episode_pending(payload: EpisodeProcessPendingRequest):
return await _episode_process_pending(payload) return await _episode_process_pending(payload)
@@ -834,12 +994,16 @@ async def process_memory_episode_pending(payload: EpisodeProcessPendingRequest):
async def query_memory_profile( async def query_memory_profile(
person_id: str = Query(""), person_id: str = Query(""),
person_keyword: str = Query(""), person_keyword: str = Query(""),
platform: str = Query(""),
user_id: str = Query(""),
limit: int = Query(12, ge=1, le=100), limit: int = Query(12, ge=1, le=100),
force_refresh: bool = Query(False), force_refresh: bool = Query(False),
): ):
return await _profile_query( return await _profile_query(
person_id=person_id, person_id=person_id,
person_keyword=person_keyword, person_keyword=person_keyword,
platform=platform,
user_id=user_id,
limit=limit, limit=limit,
force_refresh=force_refresh, force_refresh=force_refresh,
) )
@@ -850,6 +1014,23 @@ async def list_memory_profiles(limit: int = Query(50, ge=1, le=200)):
return await _profile_list(limit) return await _profile_list(limit)
@router.get("/profiles/search")
async def search_memory_profiles(
person_id: str = Query(""),
person_keyword: str = Query(""),
platform: str = Query(""),
user_id: str = Query(""),
limit: int = Query(50, ge=1, le=200),
):
return await _profile_search(
person_id=person_id,
person_keyword=person_keyword,
platform=platform,
user_id=user_id,
limit=limit,
)
@router.post("/profiles/override") @router.post("/profiles/override")
async def set_memory_profile_override(payload: ProfileOverrideRequest): async def set_memory_profile_override(payload: ProfileOverrideRequest):
return await _profile_set_override(payload) return await _profile_set_override(payload)
@@ -1269,6 +1450,8 @@ async def compat_list_episodes(
limit: int = Query(20, ge=1, le=200), limit: int = Query(20, ge=1, le=200),
source: str = Query(""), source: str = Query(""),
person_id: str = Query(""), person_id: str = Query(""),
platform: str = Query(""),
user_id: str = Query(""),
time_start: float | None = Query(None), time_start: float | None = Query(None),
time_end: float | None = Query(None), time_end: float | None = Query(None),
): ):
@@ -1277,11 +1460,18 @@ async def compat_list_episodes(
limit=limit, limit=limit,
source=source, source=source,
person_id=person_id, person_id=person_id,
platform=platform,
user_id=user_id,
time_start=time_start, time_start=time_start,
time_end=time_end, time_end=time_end,
) )
@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}") @compat_router.get("/episodes/{episode_id}")
async def compat_get_episode(episode_id: str): async def compat_get_episode(episode_id: str):
return await _episode_get(episode_id) return await _episode_get(episode_id)
@@ -1292,11 +1482,6 @@ async def compat_rebuild_episodes(payload: EpisodeRebuildRequest):
return await _episode_rebuild(payload) 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") @compat_router.post("/episodes/process_pending")
async def compat_process_episode_pending(payload: EpisodeProcessPendingRequest): async def compat_process_episode_pending(payload: EpisodeProcessPendingRequest):
return await _episode_process_pending(payload) return await _episode_process_pending(payload)
@@ -1306,12 +1491,16 @@ async def compat_process_episode_pending(payload: EpisodeProcessPendingRequest):
async def compat_profile_query( async def compat_profile_query(
person_id: str = Query(""), person_id: str = Query(""),
person_keyword: str = Query(""), person_keyword: str = Query(""),
platform: str = Query(""),
user_id: str = Query(""),
limit: int = Query(12, ge=1, le=100), limit: int = Query(12, ge=1, le=100),
force_refresh: bool = Query(False), force_refresh: bool = Query(False),
): ):
return await _profile_query( return await _profile_query(
person_id=person_id, person_id=person_id,
person_keyword=person_keyword, person_keyword=person_keyword,
platform=platform,
user_id=user_id,
limit=limit, limit=limit,
force_refresh=force_refresh, force_refresh=force_refresh,
) )
@@ -1322,6 +1511,23 @@ async def compat_profile_list(limit: int = Query(50, ge=1, le=200)):
return await _profile_list(limit) return await _profile_list(limit)
@compat_router.get("/person_profile/search")
async def compat_profile_search(
person_id: str = Query(""),
person_keyword: str = Query(""),
platform: str = Query(""),
user_id: str = Query(""),
limit: int = Query(50, ge=1, le=200),
):
return await _profile_search(
person_id=person_id,
person_keyword=person_keyword,
platform=platform,
user_id=user_id,
limit=limit,
)
@compat_router.post("/person_profile/override") @compat_router.post("/person_profile/override")
async def compat_set_profile_override(payload: ProfileOverrideRequest): async def compat_set_profile_override(payload: ProfileOverrideRequest):
return await _profile_set_override(payload) return await _profile_set_override(payload)