diff --git a/README.md b/README.md index 669ff07f..6a15a842 100644 --- a/README.md +++ b/README.md @@ -166,7 +166,7 @@ MaiSaka 不仅仅是一个机器人,不仅仅是一个可以帮你完成任务 ## 🙋 贡献和致谢 Contributing and Acknowledgments -欢迎参与贡献!请先阅读 [贡献指南](docs/CONTRIBUTE.md)。 +欢迎参与贡献!请先阅读 [贡献指南](docs/CONTRIBUTE.md)。 Contributions are welcome. Please read the Contribution Guide first. ### 🌟 贡献者 diff --git a/dashboard/src/components/memory/MemoryEpisodeManager.tsx b/dashboard/src/components/memory/MemoryEpisodeManager.tsx new file mode 100644 index 00000000..1f392f39 --- /dev/null +++ b/dashboard/src/components/memory/MemoryEpisodeManager.tsx @@ -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([]) + const [status, setStatus] = useState(null) + const [selectedId, setSelectedId] = useState('') + const [detail, setDetail] = useState(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 ( + + + {[ + { 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) => ( + + + {item.label} + {item.value} + + + ))} + + + + + + + + Episode 查询 + + 按平台账号、来源和时间范围查看情节记忆构建结果;person_id 查询放在高级入口。 + + + + + 平台 + setPlatform(event.target.value)} + placeholder="例如 qq、telegram、webui" + /> + + + 用户账号 + setUserId(event.target.value)} placeholder="输入平台侧 user_id" /> + + + 关键词 + setQuery(event.target.value)} placeholder="搜索摘要或内容" /> + + + 来源 + setSource(event.target.value)} placeholder="chat_summary:..." /> + + + 数量 + setLimit(event.target.value)} /> + + + 开始时间戳 + setTimeStart(event.target.value)} placeholder="可选" /> + + + 结束时间戳 + setTimeEnd(event.target.value)} placeholder="可选" /> + + + + + + + 高级查询 + + + + + person_id + setPersonId(event.target.value)} + placeholder="调试或后台管理时直接输入" + /> + + + + void loadEpisodes()} disabled={loading}> + + 刷新 Episode + + + + + + + Episode + 来源 + 更新时间 + + + + {items.length > 0 ? items.map((item) => { + const episodeId = getEpisodeId(item) + return ( + setSelectedId(episodeId)} + > + + {getEpisodeTitle(item)} + {item.person_name || item.person_id ? ( + + {String(item.person_name || item.person_id)} + {item.person_name && item.person_id ? · {String(item.person_id)} : null} + + ) : null} + {episodeId || '-'} + + {String(item.source ?? '-')} + {formatMemoryTime(item.updated_at ?? item.created_at)} + + ) + }) : ( + + + {loading ? '正在加载 Episode...' : '没有匹配的 Episode'} + + + )} + + + + + + + + + + Episode 详情 + 查看情节摘要、原始字段和关联段落。 + + + {detailLoading ? ( + + + 正在加载详情 + + ) : selectedEpisode ? ( + <> + + {getEpisodeId(selectedEpisode) || '无 ID'} + {selectedEpisode.source ? {String(selectedEpisode.source)} : null} + {selectedEpisode.person_name ? {String(selectedEpisode.person_name)} : null} + {selectedEpisode.person_id ? {String(selectedEpisode.person_id)} : null} + + + + + + 原始响应 JSON + + + + + + {JSON.stringify(selectedEpisode, null, 2)} + + + + + 关联段落 + {selectedEpisodeParagraphs.length > 0 ? ( + + + {selectedEpisodeParagraphs.map((paragraph, index) => ( + + {String(paragraph.hash ?? '-')} + {String(paragraph.preview ?? paragraph.content ?? '')} + + ))} + + + ) : ( + 当前详情没有段落明细。 + )} + + > + ) : ( + 选择一个 Episode 查看详情。 + )} + + + + + + + + Episode 运维 + + 重新生成指定来源的情景记忆,或处理后台尚未生成的 Episode 任务。 + + + {failedItems.length > 0 ? ( + + + 最近失败来源:{failedItems.slice(0, 3).map((item) => String(item.source ?? item.id ?? item.error ?? '未知')).join('、')} + + + ) : null} + + + + 重新生成来源 Episode + + 适用于导入内容变化、反馈纠错后,需要用来源下的段落替换旧 Episode 的场景。 + + + + + 来源 ID + setRebuildSource(event.target.value)} + placeholder="例如 chat_summary:test-webui:coffee" + /> + + + 多个来源 ID + setRebuildSources(event.target.value)} + placeholder="用英文逗号分隔多个来源" + /> + + + + setRebuildAll(event.target.checked)} /> + 重新生成全部可用来源 + + void submitRebuild()} disabled={actionLoading}> + + 重新生成 Episode + + + + + + 处理待生成任务 + + 适用于后台已有待生成段落时,手动推进这些段落生成 Episode。 + + + + + 本次处理上限 + setPendingLimit(event.target.value)} /> + + + 失败重试上限 + setPendingMaxRetry(event.target.value)} /> + + void submitProcessPending()} disabled={actionLoading}> + + 处理待生成任务 + + + + + + + + + ) +} diff --git a/dashboard/src/components/memory/MemoryMaintenanceManager.tsx b/dashboard/src/components/memory/MemoryMaintenanceManager.tsx new file mode 100644 index 00000000..b2c7dea3 --- /dev/null +++ b/dashboard/src/components/memory/MemoryMaintenanceManager.tsx @@ -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('reinforce') + const [protectHours, setProtectHours] = useState('') + const [recycleLimit, setRecycleLimit] = useState('50') + const [items, setItems] = useState([]) + 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 ( + + + + + + 记忆维护操作 + + 对关系 hash 或查询文本命中的长期记忆执行强化、冻结、保护和恢复。 + + + + + 维护目标沿用后端解析规则:优先匹配关系 hash,也可以输入查询文本让后端解析命中的关系。 + + + + 维护目标 + setTarget(event.target.value)} placeholder="relation hash 或查询文本" /> + + + + 动作 + setAction(value as MaintenanceAction)}> + + + + + 强化 + 冻结 + 保护 + 恢复 + + + + + 保护时长(小时) + setProtectHours(event.target.value)} + placeholder="空值表示永久保护" + disabled={action !== 'protect'} + /> + + + void runAction(action, target)} disabled={actionLoading}> + {action === 'reinforce' ? : null} + {action === 'freeze' ? : null} + {action === 'protect' ? : null} + {action === 'restore' ? : null} + 执行{getActionLabel(action)} + + + + + + + + + 记忆回收站 + + 查看已删除关系,并支持按行恢复。 + + + + + 筛选 + setItemSearch(event.target.value)} placeholder="按 hash、主体、谓词、来源筛选" /> + + + 数量 + setRecycleLimit(event.target.value)} /> + + void loadRecycleBin()} disabled={loading}> + + 刷新 + + + + 已加载 {items.length} 条 + 当前命中 {filteredItems.length} 条 + + + + + + 关系 + 删除时间 + 操作 + + + + {filteredItems.length > 0 ? filteredItems.map((item, index) => { + const rowTarget = getRelationTarget(item) + return ( + + + {getRelationText(item) || '-'} + {rowTarget || '-'} + {item.source ? {String(item.source)} : null} + + {formatMemoryTime(item.deleted_at ?? item.updated_at)} + + void runAction('restore', rowTarget)} + disabled={!rowTarget || actionLoading} + > + 恢复 + + + + ) + }) : ( + + + {loading ? '正在加载回收站...' : '回收站没有可展示的关系'} + + + )} + + + + + + + ) +} diff --git a/dashboard/src/components/memory/MemoryProfileManager.tsx b/dashboard/src/components/memory/MemoryProfileManager.tsx new file mode 100644 index 00000000..588b6a59 --- /dev/null +++ b/dashboard/src/components/memory/MemoryProfileManager.tsx @@ -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([]) + 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(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 ( + + + + + + 人物画像查询 + + 按平台账号定位人物画像,可用关键词辅助检索;person_id 查询放在高级入口。 + + + + + 平台 + setQueryPlatform(event.target.value)} + placeholder="例如 qq、telegram、webui" + /> + + + 用户账号 + setQueryUserId(event.target.value)} + placeholder="输入平台侧 user_id" + /> + + + 人物关键词 + setQueryKeyword(event.target.value)} placeholder="可选" /> + + + 证据数量 + setQueryLimit(event.target.value)} /> + + + setForceRefresh(Boolean(value))} + /> + + 强制刷新画像 + + + + + + + + 高级查询 + + + + + person_id + setQueryPersonId(event.target.value)} + placeholder="调试或后台管理时直接输入" + /> + + + + {selectedPersonId || queryPersonId ? ( + + 当前定位 person_id + {selectedPersonId || queryPersonId} + + ) : null} + + + void submitQuery()} disabled={querying}> + + 查询人物画像 + + void loadProfiles()} disabled={loading}> + + 查看画像库 + + + + + {profileListMode === 'search' ? '检索结果' : '画像库'} + + {profileListMode === 'search' + ? '根据当前平台账号、关键词或 person_id 筛选出的画像候选。' + : '系统中已生成的最新人物画像快照,按更新时间排序。'} + + + + + + + + 人物 + 版本 + 更新时间 + + + + {profiles.length > 0 ? profiles.map((item) => ( + setSelectedPersonId(item.person_id)} + > + + {item.person_name || item.person_id} + {item.person_name ? {item.person_id} : null} + + {item.has_manual_override ? 手动 override : null} + {item.source_note ? {item.source_note} : null} + + + {Number(item.profile_version ?? 0)} + {formatMemoryTime(item.updated_at)} + + )) : ( + + + {loading ? '正在加载人物画像...' : profileListMode === 'search' ? '没有匹配的人物画像' : '还没有人物画像快照'} + + + )} + + + + + + + + + + 画像详情 + 展示当前快照、查询结果和原始响应。 + + + {querying ? ( + + + 正在查询人物画像 + + ) : null} + {selectedProfile || queryResult ? ( + <> + + {selectedPersonId || String(queryResult?.person_id ?? '未选择')} + {selectedProfile?.expires_at ? 过期时间 {formatMemoryTime(selectedProfile.expires_at)} : null} + + + + + + 原始响应 JSON + + + + + + {JSON.stringify(queryResult ?? selectedProfile ?? {}, null, 2)} + + + + > + ) : ( + + 选择一个人物或执行查询后查看详情。 + + )} + + + + + + 手动 Override + 用人工画像覆盖自动生成结果;留空保存表示清空文本但保留 override 记录。 + + + {!selectedPersonId && !queryPersonId.trim() ? ( + + 请选择或输入 person_id 后再编辑 override。 + + ) : null} + {selectedDisplayName ? 当前编辑对象:{selectedDisplayName} : null} + setOverrideText(event.target.value)} + className="min-h-[180px]" + placeholder="输入希望固定使用的人物画像文本" + /> + + void saveOverride()} disabled={saving}> + + 保存 override + + void deleteOverride()} disabled={saving || (!selectedPersonId && !queryPersonId.trim())}> + + 删除 override + + + + + + + ) +} diff --git a/dashboard/src/lib/memory-api.ts b/dashboard/src/lib/memory-api.ts index fd4a1b7c..ac20039f 100644 --- a/dashboard/src/lib/memory-api.ts +++ b/dashboard/src/lib/memory-api.ts @@ -6,26 +6,104 @@ import { isElectron } from './runtime' async function getMemoryApiBase(): Promise { 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(' base !== primaryBase) } async function requestJson(path: string, init?: RequestInit): Promise { - 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 + + throw new Error('接口请求失败') } export interface MemoryGraphNodePayload { @@ -581,6 +659,120 @@ export interface MemorySourceListPayload { count: number } +export interface MemoryEpisodeItemPayload extends Record { + 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 { + 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 { + success: boolean + pending_queue?: number + counts?: Record + failed?: Array> + error?: string +} + +export interface MemoryEpisodeActionPayload extends Record { + success: boolean + error?: string + detail?: string +} + +export interface MemoryProfileItemPayload extends Record { + 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 | null +} + +export interface MemoryProfileListPayload { + success: boolean + items: MemoryProfileItemPayload[] + count?: number + error?: string +} + +export interface MemoryProfileQueryPayload extends Record { + success?: boolean + profile?: MemoryProfileItemPayload | Record + person_id?: string + profile_text?: string + evidence?: Array> + error?: string +} + +export interface MemoryProfileOverridePayload extends Record { + success: boolean + override?: Record + deleted?: boolean + person_id?: string + error?: string +} + +export interface MemoryMaintenanceItemPayload extends Record { + 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 { + success: boolean + detail?: string + error?: string +} + export async function getMemoryGraph(limit: number = 120): Promise { return requestJson(`/graph?limit=${limit}`) } @@ -728,6 +920,151 @@ export async function getMemorySources(): Promise { return requestJson('/sources') } +export async function getMemoryEpisodes(options?: { + query?: string + limit?: number + source?: string + personId?: string + platform?: string + userId?: string + timeStart?: number + timeEnd?: number +}): Promise { + 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(`/episodes?${params.toString()}`) +} + +export async function getMemoryEpisode(episodeId: string): Promise { + return requestJson(`/episodes/${encodeURIComponent(episodeId)}`) +} + +export async function rebuildMemoryEpisodes(payload: { + source?: string + sources?: string[] + all?: boolean +}): Promise { + return requestJson('/episodes/rebuild', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) +} + +export async function getMemoryEpisodeStatus(limit: number = 20): Promise { + return requestJson(`/episodes/status?limit=${limit}`) +} + +export async function processMemoryEpisodePending(payload: { + limit?: number + max_retry?: number +}): Promise { + return requestJson('/episodes/process-pending', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) +} + +export async function getMemoryProfiles(limit: number = 50): Promise { + return requestJson(`/profiles?limit=${limit}`) +} + +export async function searchMemoryProfiles(options: { + personId?: string + personKeyword?: string + platform?: string + userId?: string + limit?: number +}): Promise { + 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(`/profiles/search?${params.toString()}`) +} + +export async function queryMemoryProfile(options: { + personId?: string + personKeyword?: string + platform?: string + userId?: string + limit?: number + forceRefresh?: boolean +}): Promise { + 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(`/profiles/query?${params.toString()}`) +} + +export async function setMemoryProfileOverride(payload: { + person_id: string + override_text: string + updated_by?: string + source?: string +}): Promise { + return requestJson('/profiles/override', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) +} + +export async function deleteMemoryProfileOverride(personId: string): Promise { + return requestJson(`/profiles/override/${encodeURIComponent(personId)}`, { + method: 'DELETE', + }) +} + +export async function getMemoryRecycleBin(limit: number = 50): Promise { + return requestJson(`/maintenance/recycle-bin?limit=${limit}`) +} + +function maintainMemory(path: string, payload: { target: string; hours?: number }): Promise { + return requestJson(path, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) +} + +export async function restoreMaintainedMemory(target: string): Promise { + return maintainMemory('/maintenance/restore', { target }) +} + +export async function reinforceMemory(target: string): Promise { + return maintainMemory('/maintenance/reinforce', { target }) +} + +export async function freezeMemory(target: string): Promise { + return maintainMemory('/maintenance/freeze', { target }) +} + +export async function protectMemory(target: string, hours?: number): Promise { + return maintainMemory('/maintenance/protect', hours === undefined ? { target } : { target, hours }) +} + export async function getMemoryRuntimeConfig(): Promise { return requestJson('/runtime/config') } diff --git a/dashboard/src/routes/resource/knowledge-base.tsx b/dashboard/src/routes/resource/knowledge-base.tsx index 58033ae5..c49418ba 100644 --- a/dashboard/src/routes/resource/knowledge-base.tsx +++ b/dashboard/src/routes/resource/knowledge-base.tsx @@ -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>(() => new Set()) const [runtimeConfig, setRuntimeConfig] = useState(null) const [selfCheckReport, setSelfCheckReport] = useState | 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} /> + + {visitedMemoryTabs.has('episodes') ? : null} + + + + {visitedMemoryTabs.has('profiles') ? : null} + + + + {visitedMemoryTabs.has('maintenance') ? : null} + + - 查看 feedback correction 的判定、修改轨迹与回退结果;本期仅覆盖自动纠错任务 + 查看 feedback correction 的判定、修改轨迹与回退结果 diff --git a/pytests/webui/test_memory_routes.py b/pytests/webui/test_memory_routes.py index 42f26ad4..d2894786 100644 --- a/pytests/webui/test_memory_routes.py +++ b/pytests/webui/test_memory_routes.py @@ -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" diff --git a/src/webui/app.py b/src/webui/app.py index 6fcb8f40..1b66863e 100644 --- a/src/webui/app.py +++ b/src/webui/app.py @@ -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" diff --git a/src/webui/routers/memory.py b/src/webui/routers/memory.py index 4edd43b6..d1a2bc6c 100644 --- a/src/webui/routers/memory.py +++ b/src/webui/routers/memory.py @@ -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)
+ {JSON.stringify(selectedEpisode, null, 2)} +
+ {JSON.stringify(queryResult ?? selectedProfile ?? {}, null, 2)} +