Merge pull request #1644 from A-Dawn/dev
为记忆系统补全情节记忆/人物画像/纠错功能,并修复docker的依赖安装问题
This commit is contained in:
@@ -166,7 +166,7 @@ MaiSaka 不仅仅是一个机器人,不仅仅是一个可以帮你完成任务
|
||||
## 🙋 贡献和致谢
|
||||
<sub><sup>Contributing and Acknowledgments</sup></sub>
|
||||
|
||||
欢迎参与贡献!请先阅读 [贡献指南](docs/CONTRIBUTE.md)。
|
||||
欢迎参与贡献!请先阅读 [贡献指南](docs/CONTRIBUTE.md)。
|
||||
<sub><sup>Contributions are welcome. Please read the <a href="docs/CONTRIBUTE.md">Contribution Guide</a> first.</sup></sub>
|
||||
|
||||
### 🌟 贡献者
|
||||
|
||||
518
dashboard/src/components/memory/MemoryEpisodeManager.tsx
Normal file
518
dashboard/src/components/memory/MemoryEpisodeManager.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
325
dashboard/src/components/memory/MemoryMaintenanceManager.tsx
Normal file
325
dashboard/src/components/memory/MemoryMaintenanceManager.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
482
dashboard/src/components/memory/MemoryProfileManager.tsx
Normal file
482
dashboard/src/components/memory/MemoryProfileManager.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -6,26 +6,104 @@ import { isElectron } from './runtime'
|
||||
async function getMemoryApiBase(): Promise<string> {
|
||||
if (isElectron()) {
|
||||
const base = await getApiBaseUrl()
|
||||
return base ? `${base}/api/webui/memory` : '/api/webui/memory'
|
||||
return normalizeMemoryApiBase(base)
|
||||
}
|
||||
return import.meta.env.VITE_API_BASE_URL
|
||||
? `${import.meta.env.VITE_API_BASE_URL}/memory`
|
||||
: '/api/webui/memory'
|
||||
return normalizeMemoryApiBase(import.meta.env.VITE_API_BASE_URL)
|
||||
}
|
||||
|
||||
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> {
|
||||
const response = await fetch(`${await getMemoryApiBase()}${path}`, init)
|
||||
if (!response.ok) {
|
||||
let detail = `${response.status}`
|
||||
try {
|
||||
const payload = await response.json()
|
||||
detail = String(payload?.detail ?? payload?.error ?? detail)
|
||||
} catch {
|
||||
// ignore json parsing fallback
|
||||
const primaryBase = await getMemoryApiBase()
|
||||
const urls = [
|
||||
`${primaryBase}${path}`,
|
||||
...getLocalMemoryApiFallbackBases(primaryBase).map((base) => `${base}${path}`),
|
||||
]
|
||||
const requestInit = withMemoryRequestDefaults(init)
|
||||
|
||||
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 {
|
||||
@@ -581,6 +659,120 @@ 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
|
||||
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> {
|
||||
return requestJson<MemoryGraphPayload>(`/graph?limit=${limit}`)
|
||||
}
|
||||
@@ -728,6 +920,151 @@ export async function getMemorySources(): Promise<MemorySourceListPayload> {
|
||||
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> {
|
||||
return requestJson<MemoryRuntimeConfigPayload>('/runtime/config')
|
||||
}
|
||||
|
||||
@@ -17,7 +17,10 @@ import {
|
||||
|
||||
import { CodeEditor } from '@/components/CodeEditor'
|
||||
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 { MemoryProfileManager } from '@/components/memory/MemoryProfileManager'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
@@ -113,8 +116,9 @@ export function KnowledgeBasePage() {
|
||||
const [creatingImport, setCreatingImport] = useState(false)
|
||||
const [creatingTuning, setCreatingTuning] = useState(false)
|
||||
const [activeTab, setActiveTab] = useState<
|
||||
'overview' | 'graph' | 'import' | 'tuning' | 'delete' | 'feedback'
|
||||
'overview' | 'graph' | 'import' | 'tuning' | 'episodes' | 'profiles' | 'maintenance' | 'delete' | 'feedback'
|
||||
>('overview')
|
||||
const [visitedMemoryTabs, setVisitedMemoryTabs] = useState<Set<string>>(() => new Set())
|
||||
|
||||
const [runtimeConfig, setRuntimeConfig] = useState<MemoryRuntimeConfigPayload | null>(null)
|
||||
const [selfCheckReport, setSelfCheckReport] = useState<Record<string, unknown> | null>(null)
|
||||
@@ -304,6 +308,20 @@ export function KnowledgeBasePage() {
|
||||
void 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(() => {
|
||||
if (!runtimeConfig) {
|
||||
return []
|
||||
@@ -1866,6 +1884,9 @@ export function KnowledgeBasePage() {
|
||||
{ value: 'graph', label: '图谱', description: '实体关系图与证据视图' },
|
||||
{ value: 'import', label: '导入', description: '创建并管理导入任务' },
|
||||
{ value: 'tuning', label: '调优', description: '检索策略调优' },
|
||||
{ value: 'episodes', label: '情景记忆', description: '查看和重建情景记忆' },
|
||||
{ value: 'profiles', label: '人物画像', description: '查询和维护人物画像' },
|
||||
{ value: 'maintenance', label: '维护', description: '回收站与记忆状态维护' },
|
||||
{ value: 'delete', label: '删除', description: '批量删除与历史回溯' },
|
||||
{ value: 'feedback', label: '纠错历史', description: '查看反馈与回滚' },
|
||||
]}
|
||||
@@ -2095,6 +2116,18 @@ export function KnowledgeBasePage() {
|
||||
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
|
||||
sourceSearch={sourceSearch}
|
||||
setSourceSearch={setSourceSearch}
|
||||
|
||||
@@ -104,7 +104,7 @@ export function FeedbackTab(props: FeedbackTabProps) {
|
||||
反馈纠错历史
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
查看 feedback correction 的判定、修改轨迹与回退结果;本期仅覆盖自动纠错任务
|
||||
查看 feedback correction 的判定、修改轨迹与回退结果
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
|
||||
@@ -188,6 +188,59 @@ def test_webui_memory_graph_edge_detail_route_returns_404(client: TestClient, mo
|
||||
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):
|
||||
async def fake_search(query: str, **kwargs):
|
||||
assert kwargs["mode"] == "aggregate"
|
||||
|
||||
@@ -197,6 +197,9 @@ def _setup_static_files(app: FastAPI):
|
||||
|
||||
@app.get("/{full_path:path}", include_in_schema=False)
|
||||
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 == "/":
|
||||
response = FileResponse(static_path / "index.html", media_type="text/html")
|
||||
response.headers["X-Robots-Tag"] = "noindex, nofollow, noarchive"
|
||||
|
||||
@@ -9,8 +9,12 @@ from typing import Any, Optional
|
||||
import tomlkit
|
||||
from fastapi import APIRouter, Body, Depends, File, Form, HTTPException, Query, UploadFile
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlmodel import col, select
|
||||
|
||||
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.webui.dependencies import require_auth
|
||||
|
||||
@@ -297,22 +301,49 @@ async def _episode_list(
|
||||
limit: int,
|
||||
source: str,
|
||||
person_id: str,
|
||||
platform: str,
|
||||
user_id: str,
|
||||
time_start: float | None,
|
||||
time_end: float | None,
|
||||
) -> 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",
|
||||
query=query,
|
||||
limit=limit,
|
||||
source=source,
|
||||
person_id=person_id,
|
||||
person_id=clean_person_id,
|
||||
time_start=time_start,
|
||||
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:
|
||||
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:
|
||||
@@ -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(
|
||||
action="query",
|
||||
person_id=person_id,
|
||||
person_id=clean_person_id,
|
||||
person_keyword=person_keyword,
|
||||
limit=limit,
|
||||
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:
|
||||
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:
|
||||
@@ -797,6 +953,8 @@ async def list_memory_episodes(
|
||||
limit: int = Query(20, ge=1, le=200),
|
||||
source: str = Query(""),
|
||||
person_id: str = Query(""),
|
||||
platform: str = Query(""),
|
||||
user_id: str = Query(""),
|
||||
time_start: float | None = Query(None),
|
||||
time_end: float | None = Query(None),
|
||||
):
|
||||
@@ -805,11 +963,18 @@ async def list_memory_episodes(
|
||||
limit=limit,
|
||||
source=source,
|
||||
person_id=person_id,
|
||||
platform=platform,
|
||||
user_id=user_id,
|
||||
time_start=time_start,
|
||||
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}")
|
||||
async def get_memory_episode(episode_id: str):
|
||||
return await _episode_get(episode_id)
|
||||
@@ -820,11 +985,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)
|
||||
@@ -834,12 +994,16 @@ async def process_memory_episode_pending(payload: EpisodeProcessPendingRequest):
|
||||
async def query_memory_profile(
|
||||
person_id: str = Query(""),
|
||||
person_keyword: str = Query(""),
|
||||
platform: str = Query(""),
|
||||
user_id: str = Query(""),
|
||||
limit: int = Query(12, ge=1, le=100),
|
||||
force_refresh: bool = Query(False),
|
||||
):
|
||||
return await _profile_query(
|
||||
person_id=person_id,
|
||||
person_keyword=person_keyword,
|
||||
platform=platform,
|
||||
user_id=user_id,
|
||||
limit=limit,
|
||||
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)
|
||||
|
||||
|
||||
@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")
|
||||
async def set_memory_profile_override(payload: ProfileOverrideRequest):
|
||||
return await _profile_set_override(payload)
|
||||
@@ -1269,6 +1450,8 @@ async def compat_list_episodes(
|
||||
limit: int = Query(20, ge=1, le=200),
|
||||
source: str = Query(""),
|
||||
person_id: str = Query(""),
|
||||
platform: str = Query(""),
|
||||
user_id: str = Query(""),
|
||||
time_start: float | None = Query(None),
|
||||
time_end: float | None = Query(None),
|
||||
):
|
||||
@@ -1277,11 +1460,18 @@ async def compat_list_episodes(
|
||||
limit=limit,
|
||||
source=source,
|
||||
person_id=person_id,
|
||||
platform=platform,
|
||||
user_id=user_id,
|
||||
time_start=time_start,
|
||||
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}")
|
||||
async def compat_get_episode(episode_id: str):
|
||||
return await _episode_get(episode_id)
|
||||
@@ -1292,11 +1482,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)
|
||||
@@ -1306,12 +1491,16 @@ async def compat_process_episode_pending(payload: EpisodeProcessPendingRequest):
|
||||
async def compat_profile_query(
|
||||
person_id: str = Query(""),
|
||||
person_keyword: str = Query(""),
|
||||
platform: str = Query(""),
|
||||
user_id: str = Query(""),
|
||||
limit: int = Query(12, ge=1, le=100),
|
||||
force_refresh: bool = Query(False),
|
||||
):
|
||||
return await _profile_query(
|
||||
person_id=person_id,
|
||||
person_keyword=person_keyword,
|
||||
platform=platform,
|
||||
user_id=user_id,
|
||||
limit=limit,
|
||||
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)
|
||||
|
||||
|
||||
@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")
|
||||
async def compat_set_profile_override(payload: ProfileOverrideRequest):
|
||||
return await _profile_set_override(payload)
|
||||
|
||||
Reference in New Issue
Block a user