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

@@ -6,6 +6,7 @@ COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
WORKDIR /MaiMBot WORKDIR /MaiMBot
ENV MAIBOT_LEGACY_0X_UPGRADE_CONFIRMED=1 ENV MAIBOT_LEGACY_0X_UPGRADE_CONFIRMED=1
ENV PATH="/MaiMBot/.venv/bin:${PATH}"
# Copy dependency metadata # Copy dependency metadata
COPY pyproject.toml uv.lock ./ COPY pyproject.toml uv.lock ./
@@ -13,7 +14,7 @@ COPY pyproject.toml uv.lock ./
RUN apt-get update && apt-get install -y git RUN apt-get update && apt-get install -y git
# Install runtime dependencies # Install runtime dependencies
RUN uv sync --frozen --no-dev --system --no-install-project RUN uv sync --frozen --no-dev --no-install-project
# Copy project source # Copy project source
COPY . . COPY . .

View File

@@ -1,10 +1,11 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' 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 { Alert, AlertDescription } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { ScrollArea } from '@/components/ui/scroll-area' import { ScrollArea } from '@/components/ui/scroll-area'
@@ -88,7 +89,11 @@ export function MemoryEpisodeManager() {
const { toast } = useToast() const { toast } = useToast()
const [query, setQuery] = useState('') const [query, setQuery] = useState('')
const [source, setSource] = useState('') const [source, setSource] = useState('')
const [platform, setPlatform] = useState('')
const [userId, setUserId] = useState('')
const [personId, setPersonId] = useState('') const [personId, setPersonId] = useState('')
const [showAdvancedPersonId, setShowAdvancedPersonId] = useState(false)
const [showRawEpisodePayload, setShowRawEpisodePayload] = useState(false)
const [timeStart, setTimeStart] = useState('') const [timeStart, setTimeStart] = useState('')
const [timeEnd, setTimeEnd] = useState('') const [timeEnd, setTimeEnd] = useState('')
const [limit, setLimit] = useState('20') const [limit, setLimit] = useState('20')
@@ -118,11 +123,14 @@ export function MemoryEpisodeManager() {
const loadEpisodes = useCallback(async () => { const loadEpisodes = useCallback(async () => {
setLoading(true) setLoading(true)
try { try {
const directPersonId = showAdvancedPersonId ? personId.trim() : ''
const [listPayload] = await Promise.all([ const [listPayload] = await Promise.all([
getMemoryEpisodes({ getMemoryEpisodes({
query, query: query.trim(),
source, source: source.trim(),
personId, platform: platform.trim(),
userId: userId.trim(),
personId: directPersonId,
limit: parsePositiveInt(limit, 20), limit: parsePositiveInt(limit, 20),
timeStart: parseOptionalNumber(timeStart), timeStart: parseOptionalNumber(timeStart),
timeEnd: parseOptionalNumber(timeEnd), timeEnd: parseOptionalNumber(timeEnd),
@@ -143,7 +151,7 @@ export function MemoryEpisodeManager() {
} finally { } finally {
setLoading(false) 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) => { const loadDetail = useCallback(async (episodeId: string) => {
if (!episodeId) { if (!episodeId) {
@@ -260,10 +268,23 @@ export function MemoryEpisodeManager() {
<Search className="h-4 w-4" /> <Search className="h-4 w-4" />
Episode Episode
</CardTitle> </CardTitle>
<CardDescription></CardDescription> <CardDescription>person_id </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="grid gap-3 md:grid-cols-2"> <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"> <div className="space-y-2">
<Label htmlFor="episode-query"></Label> <Label htmlFor="episode-query"></Label>
<Input id="episode-query" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索摘要或内容" /> <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> <Label htmlFor="episode-source"></Label>
<Input id="episode-source" value={source} onChange={(event) => setSource(event.target.value)} placeholder="chat_summary:..." /> <Input id="episode-source" value={source} onChange={(event) => setSource(event.target.value)} placeholder="chat_summary:..." />
</div> </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"> <div className="space-y-2">
<Label htmlFor="episode-limit"></Label> <Label htmlFor="episode-limit"></Label>
<Input id="episode-limit" type="number" value={limit} onChange={(event) => setLimit(event.target.value)} /> <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="可选" /> <Input id="episode-time-end" value={timeEnd} onChange={(event) => setTimeEnd(event.target.value)} placeholder="可选" />
</div> </div>
</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}> <Button onClick={() => void loadEpisodes()} disabled={loading}>
<RefreshCw className={cn('mr-2 h-4 w-4', loading && 'animate-spin')} /> <RefreshCw className={cn('mr-2 h-4 w-4', loading && 'animate-spin')} />
Episode Episode
@@ -314,6 +350,12 @@ export function MemoryEpisodeManager() {
> >
<TableCell> <TableCell>
<div className="max-w-[280px] truncate font-medium">{getEpisodeTitle(item)}</div> <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> <div className="font-mono text-[11px] text-muted-foreground break-all">{episodeId || '-'}</div>
</TableCell> </TableCell>
<TableCell className="max-w-[180px] truncate">{String(item.source ?? '-')}</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"> <div className="flex flex-wrap gap-2">
<Badge variant="outline">{getEpisodeId(selectedEpisode) || '无 ID'}</Badge> <Badge variant="outline">{getEpisodeId(selectedEpisode) || '无 ID'}</Badge>
{selectedEpisode.source ? <Badge variant="secondary">{String(selectedEpisode.source)}</Badge> : null} {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> </div>
<Textarea value={String(selectedEpisode.summary ?? selectedEpisode.content ?? '')} readOnly className="min-h-[120px]" /> <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"> <Collapsible open={showRawEpisodePayload} onOpenChange={setShowRawEpisodePayload} className="rounded-lg border bg-muted/10">
{JSON.stringify(selectedEpisode, null, 2)} <CollapsibleTrigger asChild>
</pre> <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="space-y-2">
<div className="text-sm font-medium"></div> <div className="text-sm font-medium"></div>
{selectedEpisodeParagraphs.length > 0 ? ( {selectedEpisodeParagraphs.length > 0 ? (
@@ -386,9 +439,9 @@ export function MemoryEpisodeManager() {
<RotateCcw className="h-4 w-4" /> <RotateCcw className="h-4 w-4" />
Episode Episode
</CardTitle> </CardTitle>
<CardDescription></CardDescription> <CardDescription> Episode </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-5">
{failedItems.length > 0 ? ( {failedItems.length > 0 ? (
<Alert> <Alert>
<AlertDescription> <AlertDescription>
@@ -396,38 +449,66 @@ export function MemoryEpisodeManager() {
</AlertDescription> </AlertDescription>
</Alert> </Alert>
) : null} ) : null}
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-2"> <div className="space-y-3 rounded-lg border bg-muted/10 p-3">
<Label htmlFor="episode-rebuild-source"></Label> <div>
<Input id="episode-rebuild-source" value={rebuildSource} onChange={(event) => setRebuildSource(event.target.value)} /> <div className="text-sm font-medium"> Episode</div>
<div className="mt-1 text-xs text-muted-foreground">
Episode
</div>
</div> </div>
<div className="space-y-2"> <div className="grid gap-3 md:grid-cols-2">
<Label htmlFor="episode-rebuild-sources"></Label> <div className="space-y-2">
<Input id="episode-rebuild-sources" value={rebuildSources} onChange={(event) => setRebuildSources(event.target.value)} /> <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>
</div> <label className="flex items-center gap-2 text-sm">
<label className="flex items-center gap-2 text-sm"> <input type="checkbox" checked={rebuildAll} onChange={(event) => setRebuildAll(event.target.checked)} />
<input type="checkbox" checked={rebuildAll} onChange={(event) => setRebuildAll(event.target.checked)} />
</label>
</label> <Button onClick={() => void submitRebuild()} disabled={actionLoading}>
<Button onClick={() => void submitRebuild()} disabled={actionLoading}> <RotateCcw className="mr-2 h-4 w-4" />
<RotateCcw className="mr-2 h-4 w-4" /> Episode
Episode
</Button>
<div className="grid gap-3 md:grid-cols-[1fr_1fr_auto] md:items-end">
<div className="space-y-2">
<Label htmlFor="episode-pending-limit"></Label>
<Input id="episode-pending-limit" type="number" value={pendingLimit} onChange={(event) => setPendingLimit(event.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="episode-pending-retry"></Label>
<Input id="episode-pending-retry" type="number" value={pendingMaxRetry} onChange={(event) => setPendingMaxRetry(event.target.value)} />
</div>
<Button variant="outline" onClick={() => void submitProcessPending()} disabled={actionLoading}>
<Play className="mr-2 h-4 w-4" />
pending
</Button> </Button>
</div> </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> </CardContent>
</Card> </Card>
</div> </div>

View File

@@ -1,11 +1,12 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' 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 { Alert, AlertDescription } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Checkbox } from '@/components/ui/checkbox' import { Checkbox } from '@/components/ui/checkbox'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { ScrollArea } from '@/components/ui/scroll-area' import { ScrollArea } from '@/components/ui/scroll-area'
@@ -16,6 +17,7 @@ import {
deleteMemoryProfileOverride, deleteMemoryProfileOverride,
getMemoryProfiles, getMemoryProfiles,
queryMemoryProfile, queryMemoryProfile,
searchMemoryProfiles,
setMemoryProfileOverride, setMemoryProfileOverride,
type MemoryProfileItemPayload, type MemoryProfileItemPayload,
type MemoryProfileQueryPayload, type MemoryProfileQueryPayload,
@@ -77,6 +79,7 @@ function resolveProfileText(queryResult: MemoryProfileQueryPayload | null, selec
export function MemoryProfileManager() { export function MemoryProfileManager() {
const { toast } = useToast() const { toast } = useToast()
const [profiles, setProfiles] = useState<MemoryProfileItemPayload[]>([]) const [profiles, setProfiles] = useState<MemoryProfileItemPayload[]>([])
const [profileListMode, setProfileListMode] = useState<'library' | 'search'>('library')
const [selectedPersonId, setSelectedPersonId] = useState('') const [selectedPersonId, setSelectedPersonId] = useState('')
const [queryPersonId, setQueryPersonId] = useState('') const [queryPersonId, setQueryPersonId] = useState('')
const [queryKeyword, setQueryKeyword] = useState('') const [queryKeyword, setQueryKeyword] = useState('')
@@ -84,6 +87,8 @@ export function MemoryProfileManager() {
const [queryUserId, setQueryUserId] = useState('') const [queryUserId, setQueryUserId] = useState('')
const [queryLimit, setQueryLimit] = useState('12') const [queryLimit, setQueryLimit] = useState('12')
const [forceRefresh, setForceRefresh] = useState(false) const [forceRefresh, setForceRefresh] = useState(false)
const [showAdvancedPersonId, setShowAdvancedPersonId] = useState(false)
const [showRawProfilePayload, setShowRawProfilePayload] = useState(false)
const [overrideText, setOverrideText] = useState('') const [overrideText, setOverrideText] = useState('')
const [queryResult, setQueryResult] = useState<MemoryProfileQueryPayload | null>(null) const [queryResult, setQueryResult] = useState<MemoryProfileQueryPayload | null>(null)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
@@ -96,6 +101,7 @@ export function MemoryProfileManager() {
[profiles, selectedPersonId], [profiles, selectedPersonId],
) )
const profileText = resolveProfileText(queryResult, selectedProfile) const profileText = resolveProfileText(queryResult, selectedProfile)
const selectedDisplayName = selectedProfile?.person_name || selectedPersonId || String(queryResult?.person_id ?? '未选择')
const loadProfiles = useCallback(async () => { const loadProfiles = useCallback(async () => {
setLoading(true) setLoading(true)
@@ -103,6 +109,7 @@ export function MemoryProfileManager() {
const payload = await getMemoryProfiles(80) const payload = await getMemoryProfiles(80)
const nextItems = payload.items ?? [] const nextItems = payload.items ?? []
setProfiles(nextItems) setProfiles(nextItems)
setProfileListMode('library')
if (!selectedPersonId && nextItems.length > 0) { if (!selectedPersonId && nextItems.length > 0) {
setSelectedPersonId(nextItems[0].person_id) setSelectedPersonId(nextItems[0].person_id)
} }
@@ -127,42 +134,74 @@ export function MemoryProfileManager() {
useEffect(() => { useEffect(() => {
setOverrideText(stringifyOverride(selectedProfile?.manual_override)) setOverrideText(stringifyOverride(selectedProfile?.manual_override))
if (selectedProfile?.person_id) {
setQueryPersonId(selectedProfile.person_id)
}
}, [selectedProfile]) }, [selectedProfile])
const submitQuery = useCallback(async () => { const submitQuery = useCallback(async () => {
const hasAccountLocator = queryPlatform.trim() && queryUserId.trim() const directPersonId = showAdvancedPersonId ? queryPersonId.trim() : ''
if (!queryPersonId.trim() && !queryKeyword.trim() && !hasAccountLocator) { const cleanKeyword = queryKeyword.trim()
const cleanPlatform = queryPlatform.trim()
const cleanUserId = queryUserId.trim()
const hasAccountLocator = Boolean(cleanPlatform && cleanUserId)
if (!directPersonId && !cleanKeyword && !hasAccountLocator) {
toast({ toast({
title: '请输入查询条件', title: '请输入查询条件',
description: 'person_id、关键词、或平台与账号至少填写一种。', description: '用户账号、关键词、或高级 person_id 至少填写一种。',
variant: 'destructive', variant: 'destructive',
}) })
return return
} }
setQuerying(true) setQuerying(true)
try { 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({ const payload = await queryMemoryProfile({
personId: queryPersonId.trim(), personId: directPersonId,
personKeyword: queryKeyword.trim(), personKeyword: cleanKeyword,
platform: queryPlatform.trim(), platform: cleanPlatform,
userId: queryUserId.trim(), userId: cleanUserId,
limit: parsePositiveInt(queryLimit, 12), limit: parsePositiveInt(queryLimit, 12),
forceRefresh, forceRefresh,
}) })
if (payload.success === false) {
throw new Error(String(payload.error ?? '人物画像查询失败'))
}
setQueryResult(payload) 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) { if (nextPersonId) {
setSelectedPersonId(nextPersonId) setSelectedPersonId(nextPersonId)
setQueryPersonId(nextPersonId) setQueryPersonId(nextPersonId)
} else if (nextItems.length > 0) {
setSelectedPersonId(nextItems[0].person_id)
} }
toast({ toast({
title: '人物画像查询完成', title: '人物画像查询完成',
description: forceRefresh ? '已请求强制刷新画像。' : '已获取画像结果。', description: forceRefresh ? '已请求强制刷新画像。' : '已获取画像结果。',
}) })
await loadProfiles()
} catch (error) { } catch (error) {
toast({ toast({
title: '人物画像查询失败', title: '人物画像查询失败',
@@ -172,7 +211,7 @@ export function MemoryProfileManager() {
} finally { } finally {
setQuerying(false) setQuerying(false)
} }
}, [forceRefresh, loadProfiles, queryKeyword, queryLimit, queryPersonId, queryPlatform, queryUserId, toast]) }, [forceRefresh, queryKeyword, queryLimit, queryPersonId, queryPlatform, queryUserId, showAdvancedPersonId, toast])
const saveOverride = useCallback(async () => { const saveOverride = useCallback(async () => {
const personId = selectedPersonId || queryPersonId.trim() const personId = selectedPersonId || queryPersonId.trim()
@@ -238,18 +277,10 @@ export function MemoryProfileManager() {
<Search className="h-4 w-4" /> <Search className="h-4 w-4" />
</CardTitle> </CardTitle>
<CardDescription> person_id</CardDescription> <CardDescription>person_id </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="grid gap-3 md:grid-cols-2"> <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"> <div className="space-y-2">
<Label htmlFor="profile-platform"></Label> <Label htmlFor="profile-platform"></Label>
<Input <Input
@@ -260,7 +291,7 @@ export function MemoryProfileManager() {
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="profile-user-id"></Label> <Label htmlFor="profile-user-id"></Label>
<Input <Input
id="profile-user-id" id="profile-user-id"
value={queryUserId} value={queryUserId}
@@ -268,6 +299,10 @@ export function MemoryProfileManager() {
placeholder="输入平台侧 user_id" placeholder="输入平台侧 user_id"
/> />
</div> </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"> <div className="space-y-2">
<Label htmlFor="profile-limit"></Label> <Label htmlFor="profile-limit"></Label>
<Input id="profile-limit" type="number" value={queryLimit} onChange={(event) => setQueryLimit(event.target.value)} /> <Input id="profile-limit" type="number" value={queryLimit} onChange={(event) => setQueryLimit(event.target.value)} />
@@ -283,6 +318,32 @@ export function MemoryProfileManager() {
</Label> </Label>
</div> </div>
</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"> <div className="flex flex-wrap gap-2">
<Button onClick={() => void submitQuery()} disabled={querying}> <Button onClick={() => void submitQuery()} disabled={querying}>
<Search className="mr-2 h-4 w-4" /> <Search className="mr-2 h-4 w-4" />
@@ -290,10 +351,19 @@ export function MemoryProfileManager() {
</Button> </Button>
<Button variant="outline" onClick={() => void loadProfiles()} disabled={loading}> <Button variant="outline" onClick={() => void loadProfiles()} disabled={loading}>
<RefreshCw className={cn('mr-2 h-4 w-4', loading && 'animate-spin')} /> <RefreshCw className={cn('mr-2 h-4 w-4', loading && 'animate-spin')} />
</Button> </Button>
</div> </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"> <ScrollArea className="h-[520px] rounded-lg border">
<Table> <Table>
<TableHeader className="sticky top-0 bg-background"> <TableHeader className="sticky top-0 bg-background">
@@ -311,7 +381,8 @@ export function MemoryProfileManager() {
onClick={() => setSelectedPersonId(item.person_id)} onClick={() => setSelectedPersonId(item.person_id)}
> >
<TableCell> <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"> <div className="mt-1 flex flex-wrap gap-1">
{item.has_manual_override ? <Badge variant="secondary"> override</Badge> : null} {item.has_manual_override ? <Badge variant="secondary"> override</Badge> : null}
{item.source_note ? <Badge variant="outline">{item.source_note}</Badge> : null} {item.source_note ? <Badge variant="outline">{item.source_note}</Badge> : null}
@@ -323,7 +394,7 @@ export function MemoryProfileManager() {
)) : ( )) : (
<TableRow> <TableRow>
<TableCell colSpan={3} className="text-center text-muted-foreground"> <TableCell colSpan={3} className="text-center text-muted-foreground">
{loading ? '正在加载人物画像...' : '还没有人物画像快照'} {loading ? '正在加载人物画像...' : profileListMode === 'search' ? '没有匹配的人物画像' : '还没有人物画像快照'}
</TableCell> </TableCell>
</TableRow> </TableRow>
)} )}
@@ -353,9 +424,19 @@ export function MemoryProfileManager() {
{selectedProfile?.expires_at ? <Badge variant="secondary"> {formatMemoryTime(selectedProfile.expires_at)}</Badge> : null} {selectedProfile?.expires_at ? <Badge variant="secondary"> {formatMemoryTime(selectedProfile.expires_at)}</Badge> : null}
</div> </div>
<Textarea value={profileText} readOnly className="min-h-[180px]" placeholder="当前没有画像文本" /> <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"> <Collapsible open={showRawProfilePayload} onOpenChange={setShowRawProfilePayload} className="rounded-lg border bg-muted/10">
{JSON.stringify(queryResult ?? selectedProfile ?? {}, null, 2)} <CollapsibleTrigger asChild>
</pre> <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 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> <AlertDescription> person_id override</AlertDescription>
</Alert> </Alert>
) : null} ) : null}
{selectedDisplayName ? <div className="text-sm text-muted-foreground">{selectedDisplayName}</div> : null}
<Textarea <Textarea
value={overrideText} value={overrideText}
onChange={(event) => setOverrideText(event.target.value)} onChange={(event) => setOverrideText(event.target.value)}

View File

@@ -6,26 +6,104 @@ import { isElectron } from './runtime'
async function getMemoryApiBase(): Promise<string> { async function getMemoryApiBase(): Promise<string> {
if (isElectron()) { if (isElectron()) {
const base = await getApiBaseUrl() const base = await getApiBaseUrl()
return base ? `${base}/api/webui/memory` : '/api/webui/memory' return normalizeMemoryApiBase(base)
} }
return import.meta.env.VITE_API_BASE_URL return normalizeMemoryApiBase(import.meta.env.VITE_API_BASE_URL)
? `${import.meta.env.VITE_API_BASE_URL}/memory` }
: '/api/webui/memory'
function normalizeMemoryApiBase(rawBase?: string | null): string {
const base = String(rawBase ?? '').replace(/\/+$/, '')
if (!base) {
return '/api/webui/memory'
}
if (base.endsWith('/api/webui/memory')) {
return base
}
if (base.endsWith('/api/webui')) {
return `${base}/memory`
}
return `${base}/api/webui/memory`
}
function withMemoryRequestDefaults(init?: RequestInit): RequestInit {
return {
...init,
credentials: init?.credentials ?? 'include',
}
}
function isHtmlResponse(rawText: string): boolean {
const normalizedText = rawText.trimStart().toLowerCase()
return normalizedText.startsWith('<!doctype') || normalizedText.startsWith('<html')
}
function formatRequestUrl(url: string): string {
if (typeof window === 'undefined') {
return url
}
try {
return new URL(url, window.location.href).toString()
} catch {
return url
}
}
function getLocalMemoryApiFallbackBases(primaryBase: string): string[] {
const fallbackBases: string[] = []
if (typeof window !== 'undefined') {
const hostname = window.location.hostname
if (hostname === 'localhost' || hostname === '127.0.0.1') {
fallbackBases.push(`http://${hostname}:8001/api/webui/memory`)
}
}
fallbackBases.push('http://127.0.0.1:8001/api/webui/memory')
fallbackBases.push('http://localhost:8001/api/webui/memory')
return Array.from(new Set(fallbackBases)).filter((base) => base !== primaryBase)
} }
async function requestJson<T>(path: string, init?: RequestInit): Promise<T> { async function requestJson<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(`${await getMemoryApiBase()}${path}`, init) const primaryBase = await getMemoryApiBase()
if (!response.ok) { const urls = [
let detail = `${response.status}` `${primaryBase}${path}`,
try { ...getLocalMemoryApiFallbackBases(primaryBase).map((base) => `${base}${path}`),
const payload = await response.json() ]
detail = String(payload?.detail ?? payload?.error ?? detail) const requestInit = withMemoryRequestDefaults(init)
} catch {
// ignore json parsing fallback for (let index = 0; index < urls.length; index += 1) {
const url = urls[index]
const response = await fetch(url, requestInit)
const rawText = await response.text()
const htmlResponse = isHtmlResponse(rawText)
const canRetry = index < urls.length - 1
if ((htmlResponse || response.status === 404) && canRetry) {
continue
}
if (!response.ok) {
let detail = `${response.status}`
try {
const payload = JSON.parse(rawText)
detail = String(payload?.detail ?? payload?.error ?? detail)
} catch {
if (htmlResponse) {
detail = `接口返回了前端页面,未命中后端 API 路由;当前请求:${formatRequestUrl(url)}`
}
}
throw new Error(detail)
}
try {
return JSON.parse(rawText) as T
} catch {
if (htmlResponse) {
throw new Error(`接口返回了前端页面,未命中后端 API 路由;当前请求:${formatRequestUrl(url)}`)
}
throw new Error(rawText ? '接口响应不是合法 JSON' : '接口返回了空响应')
} }
throw new Error(detail)
} }
return response.json() as Promise<T>
throw new Error('接口请求失败')
} }
export interface MemoryGraphNodePayload { export interface MemoryGraphNodePayload {
@@ -589,6 +667,7 @@ export interface MemoryEpisodeItemPayload extends Record<string, unknown> {
content?: string content?: string
source?: string source?: string
person_id?: string person_id?: string
person_name?: string
time_start?: number | null time_start?: number | null
time_end?: number | null time_end?: number | null
created_at?: number | null created_at?: number | null
@@ -635,6 +714,7 @@ export interface MemoryEpisodeActionPayload extends Record<string, unknown> {
export interface MemoryProfileItemPayload extends Record<string, unknown> { export interface MemoryProfileItemPayload extends Record<string, unknown> {
person_id: string person_id: string
person_name?: string
profile_version?: number profile_version?: number
profile_text?: string profile_text?: string
updated_at?: number | null updated_at?: number | null
@@ -845,6 +925,8 @@ export async function getMemoryEpisodes(options?: {
limit?: number limit?: number
source?: string source?: string
personId?: string personId?: string
platform?: string
userId?: string
timeStart?: number timeStart?: number
timeEnd?: number timeEnd?: number
}): Promise<MemoryEpisodeListPayload> { }): Promise<MemoryEpisodeListPayload> {
@@ -853,6 +935,8 @@ export async function getMemoryEpisodes(options?: {
limit: String(options?.limit ?? 20), limit: String(options?.limit ?? 20),
source: options?.source ?? '', source: options?.source ?? '',
person_id: options?.personId ?? '', person_id: options?.personId ?? '',
platform: options?.platform ?? '',
user_id: options?.userId ?? '',
}) })
if (options?.timeStart !== undefined) { if (options?.timeStart !== undefined) {
params.set('time_start', String(options.timeStart)) 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}`) 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: { export async function queryMemoryProfile(options: {
personId?: string personId?: string
personKeyword?: string personKeyword?: string

View File

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

View File

@@ -10,4 +10,4 @@ if [ ! -e "$ADAPTER_TARGET" ] && [ -d "$ADAPTER_TEMPLATE" ]; then
cp -a "$ADAPTER_TEMPLATE" "$ADAPTER_TARGET" cp -a "$ADAPTER_TEMPLATE" "$ADAPTER_TARGET"
fi fi
exec python bot.py "$@" exec /MaiMBot/.venv/bin/python bot.py "$@"

View File

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

View File

@@ -9,8 +9,11 @@ from typing import Any, Optional
import tomlkit import tomlkit
from fastapi import APIRouter, Body, Depends, File, Form, HTTPException, Query, UploadFile from fastapi import APIRouter, Body, Depends, File, Form, HTTPException, Query, UploadFile
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from sqlmodel import col, select
from src.A_memorix.host_service import a_memorix_host_service from src.A_memorix.host_service import a_memorix_host_service
from src.common.database.database import get_db_session
from src.common.database.database_model import PersonInfo
from src.person_info.person_info import resolve_person_id_for_memory from src.person_info.person_info import resolve_person_id_for_memory
from src.services.memory_service import MemorySearchResult, memory_service from src.services.memory_service import MemorySearchResult, memory_service
from src.webui.dependencies import require_auth from src.webui.dependencies import require_auth
@@ -298,22 +301,49 @@ async def _episode_list(
limit: int, limit: int,
source: str, source: str,
person_id: str, person_id: str,
platform: str,
user_id: str,
time_start: float | None, time_start: float | None,
time_end: float | None, time_end: float | None,
) -> dict: ) -> dict:
return await memory_service.episode_admin( clean_person_id = str(person_id or "").strip()
if not clean_person_id and str(platform or "").strip() and str(user_id or "").strip():
clean_person_id = resolve_person_id_for_memory(
platform=str(platform or "").strip(),
user_id=str(user_id or "").strip(),
strict_known=False,
)
payload = await memory_service.episode_admin(
action="list", action="list",
query=query, query=query,
limit=limit, limit=limit,
source=source, source=source,
person_id=person_id, person_id=clean_person_id,
time_start=time_start, time_start=time_start,
time_end=time_end, time_end=time_end,
) )
if not isinstance(payload, dict) or not isinstance(payload.get("items"), list):
return payload
items = []
for item in payload["items"]:
if not isinstance(item, dict):
items.append(item)
continue
items.append(_enrich_episode_person_name(item))
payload = dict(payload)
payload["items"] = items
return payload
async def _episode_get(episode_id: str) -> dict: async def _episode_get(episode_id: str) -> dict:
return await memory_service.episode_admin(action="get", episode_id=episode_id) payload = await memory_service.episode_admin(action="get", episode_id=episode_id)
if isinstance(payload, dict) and isinstance(payload.get("episode"), dict):
payload = dict(payload)
payload["episode"] = _enrich_episode_person_name(payload["episode"])
return payload
async def _episode_rebuild(payload: EpisodeRebuildRequest) -> dict: async def _episode_rebuild(payload: EpisodeRebuildRequest) -> dict:
@@ -362,8 +392,118 @@ async def _profile_query(
) )
def _get_person_name_for_person_id(person_id: str) -> str:
clean_person_id = str(person_id or "").strip()
if not clean_person_id:
return ""
try:
with get_db_session(auto_commit=False) as session:
statement = select(PersonInfo.person_name).where(col(PersonInfo.person_id) == clean_person_id).limit(1)
person_name = session.exec(statement).first()
return str(person_name or "").strip()
except Exception:
return ""
def _enrich_episode_person_name(item: dict) -> dict:
enriched = dict(item)
item_person_id = str(enriched.get("person_id", "") or "").strip()
participants = enriched.get("participants")
if not item_person_id and isinstance(participants, list):
for participant in participants:
if isinstance(participant, dict):
candidate = str(participant.get("person_id", "") or participant.get("id", "") or "").strip()
else:
candidate = str(participant or "").strip()
if candidate:
item_person_id = candidate
break
enriched["person_id"] = item_person_id
enriched["person_name"] = _get_person_name_for_person_id(item_person_id)
return enriched
async def _profile_list(limit: int) -> dict: async def _profile_list(limit: int) -> dict:
return await memory_service.profile_admin(action="list", limit=limit) payload = await memory_service.profile_admin(action="list", limit=limit)
if not isinstance(payload, dict) or not isinstance(payload.get("items"), list):
return payload
items = []
for item in payload["items"]:
if not isinstance(item, dict):
items.append(item)
continue
enriched = dict(item)
person_id = str(enriched.get("person_id", "") or "").strip()
enriched["person_name"] = _get_person_name_for_person_id(person_id)
items.append(enriched)
payload = dict(payload)
payload["items"] = items
return payload
async def _profile_search(
*,
person_id: str,
person_keyword: str,
platform: str,
user_id: str,
limit: int,
) -> dict:
clean_person_id = str(person_id or "").strip()
if not clean_person_id and str(platform or "").strip() and str(user_id or "").strip():
clean_person_id = resolve_person_id_for_memory(
platform=str(platform or "").strip(),
user_id=str(user_id or "").strip(),
strict_known=False,
)
payload = await _profile_list(max(limit, 200))
if not isinstance(payload, dict) or not isinstance(payload.get("items"), list):
return payload
keyword = str(person_keyword or "").strip().lower()
def _matches(item: dict) -> bool:
if clean_person_id and str(item.get("person_id", "") or "").strip() != clean_person_id:
return False
if not keyword:
return True
override = item.get("manual_override")
override_text = ""
if isinstance(override, dict):
override_text = str(override.get("override_text", "") or override.get("text", "") or "")
elif isinstance(override, str):
override_text = override
haystack = "\n".join(
[
str(item.get("person_id", "") or ""),
str(item.get("person_name", "") or ""),
str(item.get("profile_text", "") or ""),
str(item.get("source_note", "") or ""),
override_text,
]
).lower()
return keyword in haystack
items = [item for item in payload["items"] if isinstance(item, dict) and _matches(item)]
items = items[:limit]
return {
"success": True,
"items": items,
"count": len(items),
"query": {
"person_id": clean_person_id,
"person_keyword": person_keyword,
"platform": platform,
"user_id": user_id,
},
}
async def _profile_set_override(payload: ProfileOverrideRequest) -> dict: async def _profile_set_override(payload: ProfileOverrideRequest) -> dict:
@@ -813,6 +953,8 @@ async def list_memory_episodes(
limit: int = Query(20, ge=1, le=200), limit: int = Query(20, ge=1, le=200),
source: str = Query(""), source: str = Query(""),
person_id: str = Query(""), person_id: str = Query(""),
platform: str = Query(""),
user_id: str = Query(""),
time_start: float | None = Query(None), time_start: float | None = Query(None),
time_end: float | None = Query(None), time_end: float | None = Query(None),
): ):
@@ -821,6 +963,8 @@ async def list_memory_episodes(
limit=limit, limit=limit,
source=source, source=source,
person_id=person_id, person_id=person_id,
platform=platform,
user_id=user_id,
time_start=time_start, time_start=time_start,
time_end=time_end, time_end=time_end,
) )
@@ -870,6 +1014,23 @@ async def list_memory_profiles(limit: int = Query(50, ge=1, le=200)):
return await _profile_list(limit) return await _profile_list(limit)
@router.get("/profiles/search")
async def search_memory_profiles(
person_id: str = Query(""),
person_keyword: str = Query(""),
platform: str = Query(""),
user_id: str = Query(""),
limit: int = Query(50, ge=1, le=200),
):
return await _profile_search(
person_id=person_id,
person_keyword=person_keyword,
platform=platform,
user_id=user_id,
limit=limit,
)
@router.post("/profiles/override") @router.post("/profiles/override")
async def set_memory_profile_override(payload: ProfileOverrideRequest): async def set_memory_profile_override(payload: ProfileOverrideRequest):
return await _profile_set_override(payload) return await _profile_set_override(payload)
@@ -1289,6 +1450,8 @@ async def compat_list_episodes(
limit: int = Query(20, ge=1, le=200), limit: int = Query(20, ge=1, le=200),
source: str = Query(""), source: str = Query(""),
person_id: str = Query(""), person_id: str = Query(""),
platform: str = Query(""),
user_id: str = Query(""),
time_start: float | None = Query(None), time_start: float | None = Query(None),
time_end: float | None = Query(None), time_end: float | None = Query(None),
): ):
@@ -1297,6 +1460,8 @@ async def compat_list_episodes(
limit=limit, limit=limit,
source=source, source=source,
person_id=person_id, person_id=person_id,
platform=platform,
user_id=user_id,
time_start=time_start, time_start=time_start,
time_end=time_end, time_end=time_end,
) )
@@ -1346,6 +1511,23 @@ async def compat_profile_list(limit: int = Query(50, ge=1, le=200)):
return await _profile_list(limit) return await _profile_list(limit)
@compat_router.get("/person_profile/search")
async def compat_profile_search(
person_id: str = Query(""),
person_keyword: str = Query(""),
platform: str = Query(""),
user_id: str = Query(""),
limit: int = Query(50, ge=1, le=200),
):
return await _profile_search(
person_id=person_id,
person_keyword=person_keyword,
platform=platform,
user_id=user_id,
limit=limit,
)
@compat_router.post("/person_profile/override") @compat_router.post("/person_profile/override")
async def compat_set_profile_override(payload: ProfileOverrideRequest): async def compat_set_profile_override(payload: ProfileOverrideRequest):
return await _profile_set_override(payload) return await _profile_set_override(payload)