feat(A_memorix)&fix(Docker): 记忆搜索支持平台/用户筛选及界面优化

新增平台和用户账号维度的筛选能力,并对记忆/画像功能进行交互体验优化
前端:MemoryEpisodeManager 与 MemoryProfileManager 组件增加了平台和用户 ID 输入项,人员 ID 调整为可折叠的“高级”入口,新增原始 JSON 切换、人员名称展示,优化了标签文案和列表模式;搜索触发逻辑现已支持基于账号的查询和画像搜索结果的展示。
API客户端:统一记忆 API 的基础路径,请求携带凭证,增加 HTML 回退页面的检测和本地备用地址的重试机制,优化了错误提示信息。
服务端:记忆路由在返回片段和画像时,补充了来自数据库的人员名称字段,新增 /profiles/search 及兼容性接口,并在片段/画像列表接口中支持传入 platform 和 user_id 参数
其他优化:防止 SPA 路由劫持 /api 路径,入口脚本改用 venv 中的 Python,Dockerfile 中将 venv 加入 PATH 并调整了 uv sync 相关参数。
This commit is contained in:
DawnARC
2026-05-06 01:03:51 +08:00
parent aa9b437ad5
commit 85cb2b45dc
8 changed files with 545 additions and 95 deletions

View File

@@ -1,10 +1,11 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Loader2, Play, RefreshCw, RotateCcw, Search } from 'lucide-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'
@@ -88,7 +89,11 @@ 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')
@@ -118,11 +123,14 @@ export function MemoryEpisodeManager() {
const loadEpisodes = useCallback(async () => {
setLoading(true)
try {
const directPersonId = showAdvancedPersonId ? personId.trim() : ''
const [listPayload] = await Promise.all([
getMemoryEpisodes({
query,
source,
personId,
query: query.trim(),
source: source.trim(),
platform: platform.trim(),
userId: userId.trim(),
personId: directPersonId,
limit: parsePositiveInt(limit, 20),
timeStart: parseOptionalNumber(timeStart),
timeEnd: parseOptionalNumber(timeEnd),
@@ -143,7 +151,7 @@ export function MemoryEpisodeManager() {
} finally {
setLoading(false)
}
}, [limit, loadStatus, personId, query, selectedId, source, timeEnd, timeStart, toast])
}, [limit, loadStatus, personId, platform, query, selectedId, showAdvancedPersonId, source, timeEnd, timeStart, toast, userId])
const loadDetail = useCallback(async (episodeId: string) => {
if (!episodeId) {
@@ -260,10 +268,23 @@ export function MemoryEpisodeManager() {
<Search className="h-4 w-4" />
Episode
</CardTitle>
<CardDescription></CardDescription>
<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="搜索摘要或内容" />
@@ -272,10 +293,6 @@ export function MemoryEpisodeManager() {
<Label htmlFor="episode-source"></Label>
<Input id="episode-source" value={source} onChange={(event) => setSource(event.target.value)} placeholder="chat_summary:..." />
</div>
<div className="space-y-2">
<Label htmlFor="episode-person"> ID</Label>
<Input id="episode-person" value={personId} onChange={(event) => setPersonId(event.target.value)} placeholder="person_id" />
</div>
<div className="space-y-2">
<Label htmlFor="episode-limit"></Label>
<Input id="episode-limit" type="number" value={limit} onChange={(event) => setLimit(event.target.value)} />
@@ -289,6 +306,25 @@ export function MemoryEpisodeManager() {
<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
@@ -314,6 +350,12 @@ export function MemoryEpisodeManager() {
>
<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>
@@ -350,12 +392,23 @@ export function MemoryEpisodeManager() {
<div className="flex flex-wrap gap-2">
<Badge variant="outline">{getEpisodeId(selectedEpisode) || '无 ID'}</Badge>
{selectedEpisode.source ? <Badge variant="secondary">{String(selectedEpisode.source)}</Badge> : null}
{selectedEpisode.person_id ? <Badge>{String(selectedEpisode.person_id)}</Badge> : null}
{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]" />
<pre className="max-h-56 overflow-auto rounded-lg border bg-muted/20 p-3 text-xs break-words whitespace-pre-wrap">
{JSON.stringify(selectedEpisode, null, 2)}
</pre>
<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 ? (
@@ -386,9 +439,9 @@ export function MemoryEpisodeManager() {
<RotateCcw className="h-4 w-4" />
Episode
</CardTitle>
<CardDescription></CardDescription>
<CardDescription> Episode </CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<CardContent className="space-y-5">
{failedItems.length > 0 ? (
<Alert>
<AlertDescription>
@@ -396,38 +449,66 @@ export function MemoryEpisodeManager() {
</AlertDescription>
</Alert>
) : null}
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="episode-rebuild-source"></Label>
<Input id="episode-rebuild-source" value={rebuildSource} onChange={(event) => setRebuildSource(event.target.value)} />
<div 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="space-y-2">
<Label htmlFor="episode-rebuild-sources"></Label>
<Input id="episode-rebuild-sources" value={rebuildSources} onChange={(event) => setRebuildSources(event.target.value)} />
<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>
</div>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={rebuildAll} onChange={(event) => setRebuildAll(event.target.checked)} />
</label>
<Button onClick={() => void submitRebuild()} disabled={actionLoading}>
<RotateCcw className="mr-2 h-4 w-4" />
Episode
</Button>
<div className="grid gap-3 md:grid-cols-[1fr_1fr_auto] md:items-end">
<div className="space-y-2">
<Label htmlFor="episode-pending-limit"></Label>
<Input id="episode-pending-limit" type="number" value={pendingLimit} onChange={(event) => setPendingLimit(event.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="episode-pending-retry"></Label>
<Input id="episode-pending-retry" type="number" value={pendingMaxRetry} onChange={(event) => setPendingMaxRetry(event.target.value)} />
</div>
<Button variant="outline" onClick={() => void submitProcessPending()} disabled={actionLoading}>
<Play className="mr-2 h-4 w-4" />
pending
<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>

View File

@@ -1,11 +1,12 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Loader2, RefreshCw, Save, Search, Trash2 } from 'lucide-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'
@@ -16,6 +17,7 @@ import {
deleteMemoryProfileOverride,
getMemoryProfiles,
queryMemoryProfile,
searchMemoryProfiles,
setMemoryProfileOverride,
type MemoryProfileItemPayload,
type MemoryProfileQueryPayload,
@@ -77,6 +79,7 @@ function resolveProfileText(queryResult: MemoryProfileQueryPayload | null, selec
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('')
@@ -84,6 +87,8 @@ export function MemoryProfileManager() {
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)
@@ -96,6 +101,7 @@ export function MemoryProfileManager() {
[profiles, selectedPersonId],
)
const profileText = resolveProfileText(queryResult, selectedProfile)
const selectedDisplayName = selectedProfile?.person_name || selectedPersonId || String(queryResult?.person_id ?? '未选择')
const loadProfiles = useCallback(async () => {
setLoading(true)
@@ -103,6 +109,7 @@ export function MemoryProfileManager() {
const payload = await getMemoryProfiles(80)
const nextItems = payload.items ?? []
setProfiles(nextItems)
setProfileListMode('library')
if (!selectedPersonId && nextItems.length > 0) {
setSelectedPersonId(nextItems[0].person_id)
}
@@ -127,42 +134,74 @@ export function MemoryProfileManager() {
useEffect(() => {
setOverrideText(stringifyOverride(selectedProfile?.manual_override))
if (selectedProfile?.person_id) {
setQueryPersonId(selectedProfile.person_id)
}
}, [selectedProfile])
const submitQuery = useCallback(async () => {
const hasAccountLocator = queryPlatform.trim() && queryUserId.trim()
if (!queryPersonId.trim() && !queryKeyword.trim() && !hasAccountLocator) {
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、关键词、或平台与账号至少填写一种。',
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: queryPersonId.trim(),
personKeyword: queryKeyword.trim(),
platform: queryPlatform.trim(),
userId: queryUserId.trim(),
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 ?? queryPersonId ?? '')
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 ? '已请求强制刷新画像。' : '已获取画像结果。',
})
await loadProfiles()
} catch (error) {
toast({
title: '人物画像查询失败',
@@ -172,7 +211,7 @@ export function MemoryProfileManager() {
} finally {
setQuerying(false)
}
}, [forceRefresh, loadProfiles, queryKeyword, queryLimit, queryPersonId, queryPlatform, queryUserId, toast])
}, [forceRefresh, queryKeyword, queryLimit, queryPersonId, queryPlatform, queryUserId, showAdvancedPersonId, toast])
const saveOverride = useCallback(async () => {
const personId = selectedPersonId || queryPersonId.trim()
@@ -238,18 +277,10 @@ export function MemoryProfileManager() {
<Search className="h-4 w-4" />
</CardTitle>
<CardDescription> person_id</CardDescription>
<CardDescription>person_id </CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="profile-person-id">person_id</Label>
<Input id="profile-person-id" value={queryPersonId} onChange={(event) => setQueryPersonId(event.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="profile-keyword"></Label>
<Input id="profile-keyword" value={queryKeyword} onChange={(event) => setQueryKeyword(event.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="profile-platform"></Label>
<Input
@@ -260,7 +291,7 @@ export function MemoryProfileManager() {
/>
</div>
<div className="space-y-2">
<Label htmlFor="profile-user-id"></Label>
<Label htmlFor="profile-user-id"></Label>
<Input
id="profile-user-id"
value={queryUserId}
@@ -268,6 +299,10 @@ export function MemoryProfileManager() {
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)} />
@@ -283,6 +318,32 @@ export function MemoryProfileManager() {
</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" />
@@ -290,10 +351,19 @@ export function MemoryProfileManager() {
</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">
@@ -311,7 +381,8 @@ export function MemoryProfileManager() {
onClick={() => setSelectedPersonId(item.person_id)}
>
<TableCell>
<div className="font-medium break-all">{item.person_id}</div>
<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}
@@ -323,7 +394,7 @@ export function MemoryProfileManager() {
)) : (
<TableRow>
<TableCell colSpan={3} className="text-center text-muted-foreground">
{loading ? '正在加载人物画像...' : '还没有人物画像快照'}
{loading ? '正在加载人物画像...' : profileListMode === 'search' ? '没有匹配的人物画像' : '还没有人物画像快照'}
</TableCell>
</TableRow>
)}
@@ -353,9 +424,19 @@ export function MemoryProfileManager() {
{selectedProfile?.expires_at ? <Badge variant="secondary"> {formatMemoryTime(selectedProfile.expires_at)}</Badge> : null}
</div>
<Textarea value={profileText} readOnly className="min-h-[180px]" placeholder="当前没有画像文本" />
<pre className="max-h-72 overflow-auto rounded-lg border bg-muted/20 p-3 text-xs break-words whitespace-pre-wrap">
{JSON.stringify(queryResult ?? selectedProfile ?? {}, null, 2)}
</pre>
<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">
@@ -376,6 +457,7 @@ export function MemoryProfileManager() {
<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)}

View File

@@ -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 {
@@ -589,6 +667,7 @@ export interface MemoryEpisodeItemPayload extends Record<string, unknown> {
content?: string
source?: string
person_id?: string
person_name?: string
time_start?: number | null
time_end?: number | null
created_at?: number | null
@@ -635,6 +714,7 @@ export interface MemoryEpisodeActionPayload extends Record<string, unknown> {
export interface MemoryProfileItemPayload extends Record<string, unknown> {
person_id: string
person_name?: string
profile_version?: number
profile_text?: string
updated_at?: number | null
@@ -845,6 +925,8 @@ export async function getMemoryEpisodes(options?: {
limit?: number
source?: string
personId?: string
platform?: string
userId?: string
timeStart?: number
timeEnd?: number
}): Promise<MemoryEpisodeListPayload> {
@@ -853,6 +935,8 @@ export async function getMemoryEpisodes(options?: {
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))
@@ -898,6 +982,23 @@ export async function getMemoryProfiles(limit: number = 50): Promise<MemoryProfi
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

View File

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