feat:完善长期记忆控制台导入链路与联调测试

summary:\n- 扩展长期记忆控制台导入、调优与删除相关 UI/接口,补充中文化展示与任务细粒度状态管理\n- 强化 memory API 与后端路由能力,补齐导入任务、图谱检索、配置与运行态相关字段\n- 新增与增强前后端测试,覆盖导入多文件类型、检索、调优、删除及图谱查询关键路径

description:\n- dashboard: 重构 knowledge-base 页面与 memory-api,统一任务队列、分块分页、来源删除恢复、调优闭环交互\n- backend: 扩展 webui memory 路由与 A_Memorix 内核检索逻辑,完善服务侧能力与配置 schema\n- tests: 增加 webui 集成测试和 kernel 单测,提升导入/检索/调优/删除全流程回归保障
This commit is contained in:
DawnARC
2026-04-03 19:50:08 +08:00
parent eac5495d00
commit da95b06f96
18 changed files with 4045 additions and 299 deletions

View File

@@ -53,6 +53,31 @@ export interface MemoryGraphPayload {
total_edges: number
}
export interface MemoryGraphSearchItem {
type: 'entity' | 'relation'
title: string
matched_field: string
matched_value: string
entity_name?: string
entity_hash?: string
appearance_count?: number
subject?: string
predicate?: string
object?: string
relation_hash?: string
confidence?: number
created_at?: number
}
export interface MemoryGraphSearchPayload {
success: boolean
query: string
limit: number
count: number
items: MemoryGraphSearchItem[]
error?: string
}
export interface MemoryGraphRelationDetailPayload {
hash: string
subject: string
@@ -185,6 +210,8 @@ export interface MemoryRawConfigPayload {
success: boolean
config: string
path: string
exists?: boolean
using_default?: boolean
}
export interface MemoryConfigSchemaPayload {
@@ -198,7 +225,7 @@ export interface MemoryImportGuidePayload {
content: string
source?: string
path?: string
settings?: Record<string, unknown>
settings?: MemoryImportSettings
}
export interface MemoryTaskPayload {
@@ -217,6 +244,158 @@ export interface MemoryTaskListPayload {
settings?: Record<string, unknown>
}
export type MemoryImportInputMode = 'text' | 'json'
export type MemoryImportTaskKind =
| 'upload'
| 'paste'
| 'raw_scan'
| 'lpmm_openie'
| 'lpmm_convert'
| 'temporal_backfill'
| 'maibot_migration'
export interface MemoryImportSettings {
max_queue_size?: number
max_files_per_task?: number
max_file_size_mb?: number
max_paste_chars?: number
default_file_concurrency?: number
default_chunk_concurrency?: number
max_file_concurrency?: number
max_chunk_concurrency?: number
poll_interval_ms?: number
maibot_source_db_default?: string
maibot_target_data_dir?: string
path_aliases?: Record<string, string>
llm_retry?: Record<string, number>
convert_enable_staging_switch?: boolean
convert_keep_backup_count?: number
}
export interface MemoryImportSettingsPayload {
success: boolean
settings: MemoryImportSettings
}
export interface MemoryImportPathAliasesPayload {
success: boolean
path_aliases: Record<string, string>
}
export interface MemoryImportResolvePathPayload {
success?: boolean
alias: string
relative_path: string
resolved_path: string
exists: boolean
is_file: boolean
is_dir: boolean
error?: string
}
export interface MemoryImportChunkPayload {
chunk_id: string
index: number
chunk_type: string
status: string
step: string
failed_at: string
retryable: boolean
error: string
progress: number
content_preview: string
updated_at: number
}
export interface MemoryImportFilePayload {
file_id: string
name: string
source_kind: string
input_mode: MemoryImportInputMode
status: string
current_step: string
detected_strategy_type: string
total_chunks: number
done_chunks: number
failed_chunks: number
cancelled_chunks: number
progress: number
error: string
created_at: number
updated_at: number
source_path?: string
content_hash?: string
retry_chunk_indexes?: number[]
retry_mode?: string
chunks?: MemoryImportChunkPayload[]
}
export interface MemoryImportRetrySummary {
chunk_retry_files?: number
chunk_retry_chunks?: number
file_fallback_files?: number
skipped_files?: number
parent_task_id?: string
skipped_details?: Array<Record<string, string>>
}
export interface MemoryImportTaskPayload extends MemoryTaskPayload {
task_id: string
source: string
status: string
current_step: string
total_chunks: number
done_chunks: number
failed_chunks: number
cancelled_chunks: number
progress: number
error: string
file_count: number
created_at: number
started_at?: number | null
finished_at?: number | null
updated_at: number
task_kind?: MemoryImportTaskKind | string
schema_detected?: string
artifact_paths?: Record<string, string>
rollback_info?: Record<string, unknown>
retry_parent_task_id?: string
retry_summary?: MemoryImportRetrySummary
params?: Record<string, unknown>
files?: MemoryImportFilePayload[]
}
export interface MemoryImportTaskListPayload {
success: boolean
items: MemoryImportTaskPayload[]
count?: number
settings?: MemoryImportSettings
}
export interface MemoryImportTaskDetailPayload {
success: boolean
task?: MemoryImportTaskPayload
error?: string
}
export interface MemoryImportChunkListPayload {
success: boolean
task_id?: string
file_id?: string
offset?: number
limit?: number
total?: number
items?: MemoryImportChunkPayload[]
error?: string
}
export interface MemoryImportActionPayload {
success: boolean
task?: MemoryImportTaskPayload
error?: string
}
export interface MemoryTuningProfilePayload {
success: boolean
profile?: Record<string, unknown>
@@ -335,6 +514,17 @@ export async function getMemoryGraph(limit: number = 120): Promise<MemoryGraphPa
return requestJson<MemoryGraphPayload>(`/graph?limit=${limit}`)
}
export async function getMemoryGraphSearch(
query: string,
limit: number = 50,
): Promise<MemoryGraphSearchPayload> {
const params = new URLSearchParams({
query,
limit: String(limit),
})
return requestJson<MemoryGraphSearchPayload>(`/graph/search?${params.toString()}`)
}
export async function getMemoryGraphNodeDetail(
nodeId: string,
options?: {
@@ -466,16 +656,120 @@ export async function getMemoryImportGuide(): Promise<MemoryImportGuidePayload>
return requestJson<MemoryImportGuidePayload>('/import/guide')
}
export async function getMemoryImportSettings(): Promise<Record<string, unknown>> {
return requestJson('/import/settings')
export async function getMemoryImportSettings(): Promise<MemoryImportSettingsPayload> {
return requestJson<MemoryImportSettingsPayload>('/import/settings')
}
export async function getMemoryImportTasks(limit: number = 20): Promise<MemoryTaskListPayload> {
return requestJson<MemoryTaskListPayload>(`/import/tasks?limit=${limit}`)
export async function getMemoryImportPathAliases(): Promise<MemoryImportPathAliasesPayload> {
return requestJson<MemoryImportPathAliasesPayload>('/import/path-aliases')
}
export async function createMemoryPasteImport(payload: Record<string, unknown>): Promise<{ success: boolean; task?: MemoryTaskPayload }> {
return requestJson('/import/paste', {
export async function resolveMemoryImportPath(payload: {
alias: string
relative_path?: string
must_exist?: boolean
}): Promise<MemoryImportResolvePathPayload> {
return requestJson<MemoryImportResolvePathPayload>('/import/resolve-path', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
}
export async function getMemoryImportTasks(limit: number = 20): Promise<MemoryImportTaskListPayload> {
return requestJson<MemoryImportTaskListPayload>(`/import/tasks?limit=${limit}`)
}
export async function getMemoryImportTask(taskId: string, includeChunks: boolean = false): Promise<MemoryImportTaskDetailPayload> {
return requestJson<MemoryImportTaskDetailPayload>(
`/import/tasks/${encodeURIComponent(taskId)}?include_chunks=${includeChunks ? 'true' : 'false'}`,
)
}
export async function getMemoryImportTaskChunks(
taskId: string,
fileId: string,
offset: number = 0,
limit: number = 50,
): Promise<MemoryImportChunkListPayload> {
return requestJson<MemoryImportChunkListPayload>(
`/import/tasks/${encodeURIComponent(taskId)}/chunks/${encodeURIComponent(fileId)}?offset=${offset}&limit=${limit}`,
)
}
export async function createMemoryUploadImport(files: File[], payload: Record<string, unknown>): Promise<MemoryImportActionPayload> {
const formData = new FormData()
files.forEach((file) => {
formData.append('files', file)
})
formData.append('payload_json', JSON.stringify(payload))
return requestJson<MemoryImportActionPayload>('/import/upload', {
method: 'POST',
body: formData,
})
}
export async function createMemoryPasteImport(payload: Record<string, unknown>): Promise<MemoryImportActionPayload> {
return requestJson<MemoryImportActionPayload>('/import/paste', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
}
export async function createMemoryRawScanImport(payload: Record<string, unknown>): Promise<MemoryImportActionPayload> {
return requestJson<MemoryImportActionPayload>('/import/raw-scan', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
}
export async function createMemoryLpmmOpenieImport(payload: Record<string, unknown>): Promise<MemoryImportActionPayload> {
return requestJson<MemoryImportActionPayload>('/import/lpmm-openie', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
}
export async function createMemoryLpmmConvertImport(payload: Record<string, unknown>): Promise<MemoryImportActionPayload> {
return requestJson<MemoryImportActionPayload>('/import/lpmm-convert', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
}
export async function createMemoryTemporalBackfillImport(payload: Record<string, unknown>): Promise<MemoryImportActionPayload> {
return requestJson<MemoryImportActionPayload>('/import/temporal-backfill', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
}
export async function createMemoryMaibotMigrationImport(payload: Record<string, unknown>): Promise<MemoryImportActionPayload> {
return requestJson<MemoryImportActionPayload>('/import/maibot-migration', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
}
export async function cancelMemoryImportTask(taskId: string): Promise<MemoryImportActionPayload> {
return requestJson<MemoryImportActionPayload>(`/import/tasks/${encodeURIComponent(taskId)}/cancel`, {
method: 'POST',
})
}
export async function retryMemoryImportTask(
taskId: string,
payload: {
overrides?: Record<string, unknown>
} = {},
): Promise<MemoryImportActionPayload> {
return requestJson<MemoryImportActionPayload>(`/import/tasks/${encodeURIComponent(taskId)}/retry`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),

View File

@@ -1,4 +1,4 @@
import { render, screen } from '@testing-library/react'
import { act, render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -28,12 +28,25 @@ vi.mock('@/components/memory/MemoryConfigEditor', () => ({
vi.mock('@/components/memory/MemoryDeleteDialog', () => ({
MemoryDeleteDialog: ({
open,
onExecute,
onRestore,
preview,
result,
}: {
open: boolean
preview?: { mode?: string; item_count?: number } | null
result?: { operation_id?: string } | null
onExecute?: () => void
onRestore?: () => void
}) => (
open ? <div data-testid="memory-delete-dialog">{`delete:${preview?.mode ?? 'none'}:${preview?.item_count ?? 0}`}</div> : null
open ? (
<div data-testid="memory-delete-dialog">
<div>{`preview:${preview?.mode ?? 'none'}:${preview?.item_count ?? 0}`}</div>
<div>{`result:${result?.operation_id ?? 'none'}`}</div>
<button type="button" onClick={onExecute}></button>
<button type="button" onClick={onRestore}></button>
</div>
) : null
),
}))
@@ -41,26 +54,104 @@ vi.mock('@/lib/memory-api', () => ({
getMemoryConfigSchema: vi.fn(),
getMemoryConfig: vi.fn(),
getMemoryConfigRaw: vi.fn(),
getMemoryDeleteOperation: vi.fn(),
getMemoryRuntimeConfig: vi.fn(),
getMemoryImportGuide: vi.fn(),
getMemoryImportSettings: vi.fn(),
getMemoryImportPathAliases: vi.fn(),
getMemoryImportTasks: vi.fn(),
getMemoryTuningProfile: vi.fn(),
getMemoryTuningTasks: vi.fn(),
getMemorySources: vi.fn(),
getMemoryDeleteOperations: vi.fn(),
getMemoryImportTask: vi.fn(),
getMemoryImportTaskChunks: vi.fn(),
createMemoryUploadImport: vi.fn(),
createMemoryPasteImport: vi.fn(),
createMemoryRawScanImport: vi.fn(),
createMemoryLpmmOpenieImport: vi.fn(),
createMemoryLpmmConvertImport: vi.fn(),
createMemoryTemporalBackfillImport: vi.fn(),
createMemoryMaibotMigrationImport: vi.fn(),
cancelMemoryImportTask: vi.fn(),
retryMemoryImportTask: vi.fn(),
resolveMemoryImportPath: vi.fn(),
refreshMemoryRuntimeSelfCheck: vi.fn(),
updateMemoryConfig: vi.fn(),
updateMemoryConfigRaw: vi.fn(),
createMemoryPasteImport: vi.fn(),
getMemoryTuningProfile: vi.fn(),
getMemoryTuningTasks: vi.fn(),
createMemoryTuningTask: vi.fn(),
applyBestMemoryTuningProfile: vi.fn(),
getMemorySources: vi.fn(),
getMemoryDeleteOperations: vi.fn(),
getMemoryDeleteOperation: vi.fn(),
previewMemoryDelete: vi.fn(),
executeMemoryDelete: vi.fn(),
restoreMemoryDelete: vi.fn(),
}))
describe('KnowledgeBasePage', () => {
function mockImportTask(taskId: string, status: string = 'running'): memoryApi.MemoryImportTaskPayload {
return {
task_id: taskId,
source: 'webui',
status,
current_step: status === 'completed' ? 'completed' : 'running',
total_chunks: 120,
done_chunks: status === 'completed' ? 120 : 36,
failed_chunks: status === 'completed' ? 0 : 2,
cancelled_chunks: 0,
progress: status === 'completed' ? 100 : 30,
error: '',
file_count: 2,
created_at: 1_710_000_000,
started_at: 1_710_000_001,
finished_at: status === 'completed' ? 1_710_000_099 : null,
updated_at: 1_710_000_100,
task_kind: 'paste',
params: {},
files: [],
}
}
function mockImportDetail(taskId: string): memoryApi.MemoryImportTaskPayload {
return {
...mockImportTask(taskId),
files: [
{
file_id: 'file-alpha',
name: 'alpha.txt',
source_kind: 'paste',
input_mode: 'text',
status: 'running',
current_step: 'running',
detected_strategy_type: 'auto',
total_chunks: 80,
done_chunks: 30,
failed_chunks: 1,
cancelled_chunks: 0,
progress: 37.5,
error: '',
created_at: 1_710_000_000,
updated_at: 1_710_000_100,
},
{
file_id: 'file-beta',
name: 'beta.txt',
source_kind: 'paste',
input_mode: 'text',
status: 'failed',
current_step: 'extracting',
detected_strategy_type: 'auto',
total_chunks: 40,
done_chunks: 6,
failed_chunks: 4,
cancelled_chunks: 0,
progress: 25,
error: 'mock error',
created_at: 1_710_000_000,
updated_at: 1_710_000_100,
},
],
}
}
describe('KnowledgeBasePage import workflow', () => {
beforeEach(() => {
navigateMock.mockReset()
toastMock.mockReset()
@@ -119,14 +210,113 @@ describe('KnowledgeBasePage', () => {
paragraph_vector_backfill_failed: 1,
paragraph_vector_backfill_done: 3,
})
vi.mocked(memoryApi.getMemoryImportGuide).mockResolvedValue({
success: true,
content: '# 导入指南\n导入说明',
})
vi.mocked(memoryApi.getMemoryImportSettings).mockResolvedValue({
success: true,
settings: {
max_paste_chars: 200_000,
max_file_concurrency: 8,
max_chunk_concurrency: 16,
default_file_concurrency: 2,
default_chunk_concurrency: 4,
poll_interval_ms: 60_000,
maibot_source_db_default: 'data/maibot.db',
},
})
vi.mocked(memoryApi.getMemoryImportPathAliases).mockResolvedValue({
success: true,
path_aliases: {
lpmm: 'data/lpmm',
plugin_data: 'data/plugins/a-dawn.a-memorix',
raw: 'data/raw',
},
})
vi.mocked(memoryApi.getMemoryImportTasks).mockResolvedValue({
success: true,
items: [{ task_id: 'import-1', status: 'done', mode: 'text' }],
items: [
mockImportTask('import-run-1', 'running'),
mockImportTask('import-queued-1', 'queued'),
mockImportTask('import-done-1', 'completed'),
],
})
vi.mocked(memoryApi.getMemoryImportTask).mockResolvedValue({
success: true,
task: mockImportDetail('import-run-1'),
})
vi.mocked(memoryApi.getMemoryImportTaskChunks).mockImplementation(async (_taskId, fileId, offset = 0) => ({
success: true,
task_id: 'import-run-1',
file_id: fileId,
offset,
limit: 50,
total: 120,
items: [
{
chunk_id: `${fileId}-${offset + 0}`,
index: offset + 0,
chunk_type: 'text',
status: 'running',
step: 'extracting',
failed_at: '',
retryable: true,
error: '',
progress: 50,
content_preview: `chunk-preview-${offset + 0}`,
updated_at: 1_710_000_111,
},
],
}))
vi.mocked(memoryApi.createMemoryUploadImport).mockResolvedValue({
success: true,
task: mockImportTask('upload-task-1', 'queued'),
})
vi.mocked(memoryApi.createMemoryPasteImport).mockResolvedValue({
success: true,
task: mockImportTask('paste-task-1', 'queued'),
})
vi.mocked(memoryApi.createMemoryRawScanImport).mockResolvedValue({
success: true,
task: mockImportTask('raw-task-1', 'queued'),
})
vi.mocked(memoryApi.createMemoryLpmmOpenieImport).mockResolvedValue({
success: true,
task: mockImportTask('openie-task-1', 'queued'),
})
vi.mocked(memoryApi.createMemoryLpmmConvertImport).mockResolvedValue({
success: true,
task: mockImportTask('convert-task-1', 'queued'),
})
vi.mocked(memoryApi.createMemoryTemporalBackfillImport).mockResolvedValue({
success: true,
task: mockImportTask('backfill-task-1', 'queued'),
})
vi.mocked(memoryApi.createMemoryMaibotMigrationImport).mockResolvedValue({
success: true,
task: mockImportTask('migration-task-1', 'queued'),
})
vi.mocked(memoryApi.cancelMemoryImportTask).mockResolvedValue({
success: true,
task: mockImportTask('import-run-1', 'cancel_requested'),
})
vi.mocked(memoryApi.retryMemoryImportTask).mockResolvedValue({
success: true,
task: mockImportTask('retry-task-1', 'queued'),
})
vi.mocked(memoryApi.resolveMemoryImportPath).mockResolvedValue({
success: true,
alias: 'raw',
relative_path: 'exports',
resolved_path: 'D:/Dev/rdev/MaiBot/data/raw/exports',
exists: true,
is_file: false,
is_dir: true,
})
vi.mocked(memoryApi.getMemoryTuningProfile).mockResolvedValue({
success: true,
profile: { retrieval: { top_k: 10 } },
@@ -136,13 +326,13 @@ describe('KnowledgeBasePage', () => {
success: true,
items: [{ task_id: 'tune-1', status: 'done' }],
})
vi.mocked(memoryApi.createMemoryTuningTask).mockResolvedValue({ success: true } as never)
vi.mocked(memoryApi.applyBestMemoryTuningProfile).mockResolvedValue({ success: true } as never)
vi.mocked(memoryApi.getMemorySources).mockResolvedValue({
success: true,
items: [
{ source: 'demo-1', paragraph_count: 2, relation_count: 1 },
{ source: 'demo-2', paragraph_count: 1, relation_count: 0 },
],
count: 2,
items: [{ source: 'demo-1', paragraph_count: 2, relation_count: 1 }],
count: 1,
})
vi.mocked(memoryApi.getMemoryDeleteOperations).mockResolvedValue({
success: true,
@@ -164,31 +354,9 @@ describe('KnowledgeBasePage', () => {
status: 'executed',
selector: { sources: ['demo-1'] },
summary: { counts: { paragraphs: 2, relations: 1, sources: 1 }, sources: ['demo-1'] },
items: [
{
item_type: 'paragraph',
item_hash: 'p-1',
item_key: 'paragraph:p-1',
payload: { paragraph: { source: 'demo-1', content: '这是用于测试删除详情展示的段落内容。' } },
},
],
items: [],
},
})
vi.mocked(memoryApi.refreshMemoryRuntimeSelfCheck).mockResolvedValue({
success: true,
report: { ok: true },
})
vi.mocked(memoryApi.updateMemoryConfig).mockResolvedValue({
success: true,
config_path: 'config/a_memorix.toml',
} as never)
vi.mocked(memoryApi.updateMemoryConfigRaw).mockResolvedValue({
success: true,
config_path: 'config/a_memorix.toml',
} as never)
vi.mocked(memoryApi.createMemoryPasteImport).mockResolvedValue({ success: true } as never)
vi.mocked(memoryApi.createMemoryTuningTask).mockResolvedValue({ success: true } as never)
vi.mocked(memoryApi.applyBestMemoryTuningProfile).mockResolvedValue({ success: true } as never)
vi.mocked(memoryApi.previewMemoryDelete).mockResolvedValue({
success: true,
mode: 'source',
@@ -212,59 +380,243 @@ describe('KnowledgeBasePage', () => {
deleted_source_count: 1,
} as never)
vi.mocked(memoryApi.restoreMemoryDelete).mockResolvedValue({ success: true } as never)
vi.mocked(memoryApi.refreshMemoryRuntimeSelfCheck).mockResolvedValue({
success: true,
report: { ok: true },
})
vi.mocked(memoryApi.updateMemoryConfig).mockResolvedValue({ success: true } as never)
vi.mocked(memoryApi.updateMemoryConfigRaw).mockResolvedValue({ success: true } as never)
})
it('renders long-term memory console and key tabs', async () => {
it('loads import settings/guide/tasks on first render', async () => {
const user = userEvent.setup()
render(<KnowledgeBasePage />)
expect(await screen.findByText('长期记忆控制台')).toBeInTheDocument()
expect(screen.getByText(/config\/a_memorix\.toml/)).toBeInTheDocument()
expect(screen.getByText('运行就绪')).toBeInTheDocument()
await user.click(screen.getByRole('tab', { name: '配置' }))
expect(await screen.findByTestId('memory-config-editor')).toBeInTheDocument()
expect(await screen.findByText('长期记忆控制台', undefined, { timeout: 10_000 })).toBeInTheDocument()
await user.click(screen.getByRole('tab', { name: '导入' }))
expect(await screen.findByText(/导入说明/)).toBeInTheDocument()
expect(screen.getByText('import-1')).toBeInTheDocument()
expect(await screen.findByRole('button', { name: '创建导入任务' })).toBeInTheDocument()
expect((await screen.findAllByText('import-run-1')).length).toBeGreaterThan(0)
expect(memoryApi.getMemoryImportSettings).toHaveBeenCalled()
expect(memoryApi.getMemoryImportPathAliases).toHaveBeenCalled()
expect(memoryApi.getMemoryImportTasks).toHaveBeenCalled()
})
it('creates import tasks for all 7 modes and calls correct endpoints', async () => {
const user = userEvent.setup()
const { container } = render(<KnowledgeBasePage />)
const openImportTab = async () => {
await user.click(screen.getByRole('tab', { name: '导入' }))
await screen.findByRole('button', { name: '创建导入任务' })
}
await screen.findByText('长期记忆控制台', undefined, { timeout: 10_000 })
await openImportTab()
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement
const uploadFiles = [
new File(['hello'], 'demo.txt', { type: 'text/plain' }),
new File(['{"name":"mai"}'], 'demo.json', { type: 'application/json' }),
new File(['a,b\n1,2'], 'demo.csv', { type: 'text/csv' }),
new File(['# note'], 'demo.md', { type: 'text/markdown' }),
]
await user.upload(fileInput, uploadFiles)
await user.click(screen.getByRole('button', { name: '创建导入任务' }))
await waitFor(() => expect(memoryApi.createMemoryUploadImport).toHaveBeenCalledTimes(1))
await openImportTab()
await user.click(screen.getByRole('tab', { name: '粘贴导入' }))
const editableTextarea = Array.from(container.querySelectorAll('textarea')).find((item) => !item.readOnly)
if (!editableTextarea) {
throw new Error('missing editable textarea')
}
await user.type(editableTextarea, 'paste content')
await user.click(screen.getByRole('button', { name: '创建导入任务' }))
await waitFor(() => expect(memoryApi.createMemoryPasteImport).toHaveBeenCalledTimes(1))
await openImportTab()
await user.click(screen.getByRole('tab', { name: '本地扫描' }))
await user.click(screen.getByRole('button', { name: '创建导入任务' }))
await waitFor(() => expect(memoryApi.createMemoryRawScanImport).toHaveBeenCalledTimes(1))
await openImportTab()
await user.click(screen.getByRole('tab', { name: 'LPMM OpenIE' }))
await user.click(screen.getByRole('button', { name: '创建导入任务' }))
await waitFor(() => expect(memoryApi.createMemoryLpmmOpenieImport).toHaveBeenCalledTimes(1))
await openImportTab()
await user.click(screen.getByRole('tab', { name: 'LPMM 转换' }))
await user.click(screen.getByRole('button', { name: '创建导入任务' }))
await waitFor(() => expect(memoryApi.createMemoryLpmmConvertImport).toHaveBeenCalledTimes(1))
await openImportTab()
await user.click(screen.getByRole('tab', { name: '时序回填' }))
await user.click(screen.getByRole('button', { name: '创建导入任务' }))
await waitFor(() => expect(memoryApi.createMemoryTemporalBackfillImport).toHaveBeenCalledTimes(1))
await openImportTab()
await user.click(screen.getByRole('tab', { name: 'MaiBot 迁移' }))
await user.click(screen.getByRole('button', { name: '创建导入任务' }))
await waitFor(() => expect(memoryApi.createMemoryMaibotMigrationImport).toHaveBeenCalledTimes(1))
const [uploadedFiles, uploadPayload] = vi.mocked(memoryApi.createMemoryUploadImport).mock.calls[0]
expect(uploadedFiles).toHaveLength(4)
expect(uploadedFiles.map((file) => file.name)).toEqual(['demo.txt', 'demo.json', 'demo.csv', 'demo.md'])
expect(uploadPayload).toMatchObject({
input_mode: 'text',
llm_enabled: true,
strategy_override: 'auto',
dedupe_policy: 'content_hash',
})
}, 60_000)
it('loads task detail and supports chunk pagination', async () => {
const user = userEvent.setup()
render(<KnowledgeBasePage />)
await screen.findByText('长期记忆控制台', undefined, { timeout: 10_000 })
await user.click(screen.getByRole('tab', { name: '导入' }))
expect(await screen.findByText('alpha.txt')).toBeInTheDocument()
expect(await screen.findByText('chunk-preview-0')).toBeInTheDocument()
const betaButton = screen.getByText('beta.txt').closest('button')
if (!betaButton) {
throw new Error('missing file beta button')
}
await user.click(betaButton)
await waitFor(() =>
expect(memoryApi.getMemoryImportTaskChunks).toHaveBeenCalledWith('import-run-1', 'file-beta', 0, 50),
)
await user.click(screen.getByRole('button', { name: '下一页分块' }))
await waitFor(() =>
expect(memoryApi.getMemoryImportTaskChunks).toHaveBeenCalledWith('import-run-1', 'file-beta', 50, 50),
)
}, 20_000)
it('supports cancel and retry actions for selected task', async () => {
const user = userEvent.setup()
render(<KnowledgeBasePage />)
await screen.findByText('长期记忆控制台', undefined, { timeout: 10_000 })
await user.click(screen.getByRole('tab', { name: '导入' }))
await screen.findByText('任务详情')
await user.click(screen.getByRole('button', { name: '取消选中导入任务' }))
await waitFor(() => expect(memoryApi.cancelMemoryImportTask).toHaveBeenCalledWith('import-run-1'))
await user.click(screen.getByRole('button', { name: '重试选中导入任务' }))
await waitFor(() => expect(memoryApi.retryMemoryImportTask).toHaveBeenCalled())
const [taskId, retryPayload] = vi.mocked(memoryApi.retryMemoryImportTask).mock.calls[0]
expect(taskId).toBe('import-run-1')
expect(retryPayload).toMatchObject({
overrides: {
llm_enabled: true,
strategy_override: 'auto',
},
})
}, 20_000)
it('auto polling updates queue and keeps page stable when refresh fails once', async () => {
vi.mocked(memoryApi.getMemoryImportSettings).mockResolvedValue({
success: true,
settings: {
max_paste_chars: 200_000,
max_file_concurrency: 8,
max_chunk_concurrency: 16,
default_file_concurrency: 2,
default_chunk_concurrency: 4,
poll_interval_ms: 200,
maibot_source_db_default: 'data/maibot.db',
},
})
const user = userEvent.setup()
render(<KnowledgeBasePage />)
await screen.findByText('长期记忆控制台', undefined, { timeout: 10_000 })
await user.click(screen.getByRole('tab', { name: '导入' }))
await screen.findByText('导入队列')
const initialCalls = vi.mocked(memoryApi.getMemoryImportTasks).mock.calls.length
vi.mocked(memoryApi.getMemoryImportTasks).mockRejectedValueOnce(new Error('poll failure'))
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 350))
})
expect(screen.getByText('长期记忆控制台')).toBeInTheDocument()
expect(vi.mocked(memoryApi.getMemoryImportTasks).mock.calls.length).toBeGreaterThan(initialCalls)
}, 20_000)
it('creates tuning task and applies best profile (tuning module)', async () => {
const user = userEvent.setup()
render(<KnowledgeBasePage />)
await screen.findByText('长期记忆控制台', undefined, { timeout: 10_000 })
await user.click(screen.getByRole('tab', { name: '调优' }))
expect(await screen.findByText('tune-1')).toBeInTheDocument()
expect(screen.getByRole('button', { name: '应用最佳' })).toBeInTheDocument()
})
await screen.findByText('调优任务')
it('shows delete tab and opens source delete preview', async () => {
await user.click(screen.getByRole('button', { name: '创建调优任务' }))
await waitFor(() =>
expect(memoryApi.createMemoryTuningTask).toHaveBeenCalledWith({
objective: 'precision_priority',
intensity: 'standard',
sample_size: 24,
top_k_eval: 20,
}),
)
await user.click(screen.getByRole('button', { name: '应用最佳' }))
await waitFor(() => expect(memoryApi.applyBestMemoryTuningProfile).toHaveBeenCalledWith('tune-1'))
}, 20_000)
it('previews executes and restores source delete (delete module)', async () => {
const user = userEvent.setup()
render(<KnowledgeBasePage />)
expect(await screen.findByText('长期记忆控制台')).toBeInTheDocument()
await screen.findByText('长期记忆控制台', undefined, { timeout: 10_000 })
await user.click(screen.getByRole('tab', { name: '删除' }))
await screen.findByText('来源批量删除')
expect(await screen.findByText('来源批量删除')).toBeInTheDocument()
expect(screen.getAllByText('demo-1').length).toBeGreaterThan(0)
expect(screen.getAllByText('del-1').length).toBeGreaterThan(0)
expect(screen.getByText('恢复这次删除')).toBeInTheDocument()
const sourceCellCandidates = await screen.findAllByText('demo-1')
const sourceRow = sourceCellCandidates
.map((item) => item.closest('tr'))
.find((row): row is HTMLTableRowElement => Boolean(row && within(row).queryByRole('checkbox')))
if (!sourceRow) {
throw new Error('missing source row')
}
await user.click(within(sourceRow).getByRole('checkbox'))
await user.click(screen.getAllByRole('checkbox')[0])
await user.click(screen.getByRole('button', { name: '预览删除' }))
await waitFor(() =>
expect(memoryApi.previewMemoryDelete).toHaveBeenCalledWith({
mode: 'source',
selector: { sources: ['demo-1'] },
reason: 'knowledge_base_source_delete',
requested_by: 'knowledge_base',
}),
)
expect(await screen.findByTestId('memory-delete-dialog')).toHaveTextContent('delete:source:1')
})
const dialog = await screen.findByTestId('memory-delete-dialog')
expect(dialog).toHaveTextContent('preview:source:1')
it('loads selected delete operation detail items from detail endpoint', async () => {
const user = userEvent.setup()
await user.click(screen.getByRole('button', { name: '执行删除' }))
await waitFor(() =>
expect(memoryApi.executeMemoryDelete).toHaveBeenCalledWith({
mode: 'source',
selector: { sources: ['demo-1'] },
reason: 'knowledge_base_source_delete',
requested_by: 'knowledge_base',
}),
)
render(<KnowledgeBasePage />)
expect(await screen.findByText('长期记忆控制台')).toBeInTheDocument()
await user.click(screen.getByRole('tab', { name: '删除' }))
expect(await screen.findByText('删除操作恢复')).toBeInTheDocument()
expect(await screen.findByText('paragraph')).toBeInTheDocument()
expect(screen.getByText('p-1')).toBeInTheDocument()
expect(screen.getByText('这是用于测试删除详情展示的段落内容。')).toBeInTheDocument()
})
await user.click(screen.getByRole('button', { name: '执行恢复' }))
await waitFor(() =>
expect(memoryApi.restoreMemoryDelete).toHaveBeenCalledWith({
operation_id: 'del-2',
requested_by: 'knowledge_base',
}),
)
}, 20_000)
})

View File

@@ -107,6 +107,7 @@ vi.mock('../knowledge-graph/GraphDialogs', () => ({
vi.mock('@/lib/memory-api', () => ({
getMemoryGraph: vi.fn(),
getMemoryGraphSearch: vi.fn(),
getMemoryGraphNodeDetail: vi.fn(),
getMemoryGraphEdgeDetail: vi.fn(),
previewMemoryDelete: vi.fn(),
@@ -139,6 +140,13 @@ describe('KnowledgeGraphPage', () => {
total_nodes: 2,
total_edges: 1,
})
vi.mocked(memoryApi.getMemoryGraphSearch).mockResolvedValue({
success: true,
query: 'alpha',
limit: 50,
count: 0,
items: [],
})
vi.mocked(memoryApi.getMemoryGraphNodeDetail).mockResolvedValue({
success: true,
node: { id: 'alpha', type: 'entity', content: 'Alpha', hash: 'entity-1', appearance_count: 3 },
@@ -255,7 +263,7 @@ describe('KnowledgeGraphPage', () => {
vi.mocked(memoryApi.restoreMemoryDelete).mockResolvedValue({ success: true } as never)
})
it('renders graph summary and supports empty-result filtering', async () => {
it('calls backend graph search and renders no-hit state', async () => {
const user = userEvent.setup()
render(<KnowledgeGraphPage />)
@@ -264,11 +272,102 @@ describe('KnowledgeGraphPage', () => {
expect(screen.getByText(/总节点 2/)).toBeInTheDocument()
expect(screen.getByTestId('graph-visualization')).toHaveTextContent('nodes:2,edges:1')
await user.type(screen.getByPlaceholderText('筛选实体名称、节点 ID 或边标签'), 'missing')
await user.type(screen.getByPlaceholderText('搜索实体、关系、hash后端全库'), 'missing')
expect(memoryApi.getMemoryGraph).toHaveBeenCalledTimes(1)
await user.click(screen.getByRole('button', { name: '筛选' }))
await user.click(screen.getByRole('button', { name: '搜索' }))
await waitFor(() => {
expect(memoryApi.getMemoryGraphSearch).toHaveBeenCalledWith('missing', 50)
})
expect(await screen.findByText('未命中实体或关系。')).toBeInTheDocument()
})
it('supports clicking entity search result to locate evidence', async () => {
const user = userEvent.setup()
vi.mocked(memoryApi.getMemoryGraphSearch).mockResolvedValue({
success: true,
query: 'alpha',
limit: 50,
count: 1,
items: [
{
type: 'entity',
title: 'Alpha',
matched_field: 'name',
matched_value: 'Alpha',
entity_name: 'alpha',
entity_hash: 'entity-1',
appearance_count: 3,
},
],
})
render(<KnowledgeGraphPage />)
await screen.findByTestId('graph-visualization')
await user.type(screen.getByPlaceholderText('搜索实体、关系、hash后端全库'), 'alpha')
await user.click(screen.getByRole('button', { name: '搜索' }))
await screen.findByText('搜索词alpha')
await user.click(screen.getByRole('button', { name: /Alpha/ }))
await waitFor(() => {
expect(memoryApi.getMemoryGraphNodeDetail).toHaveBeenCalledWith('alpha')
})
expect(screen.getByRole('tab', { name: '证据视图' })).toHaveAttribute('data-state', 'active')
})
it('supports clicking relation search result to locate evidence', async () => {
const user = userEvent.setup()
vi.mocked(memoryApi.getMemoryGraphSearch).mockResolvedValue({
success: true,
query: '关联',
limit: 50,
count: 1,
items: [
{
type: 'relation',
title: 'alpha 关联 beta',
matched_field: 'predicate',
matched_value: '关联',
subject: 'alpha',
predicate: '关联',
object: 'beta',
relation_hash: 'rel-1',
confidence: 0.9,
},
],
})
render(<KnowledgeGraphPage />)
await screen.findByTestId('graph-visualization')
await user.type(screen.getByPlaceholderText('搜索实体、关系、hash后端全库'), '关联')
await user.click(screen.getByRole('button', { name: '搜索' }))
await user.click(screen.getByRole('button', { name: /alpha 关联 beta/ }))
await waitFor(() => {
expect(memoryApi.getMemoryGraphEdgeDetail).toHaveBeenCalledWith('alpha', 'beta')
})
expect(screen.getByRole('tab', { name: '证据视图' })).toHaveAttribute('data-state', 'active')
})
it('falls back to local filtering when backend search fails', async () => {
const user = userEvent.setup()
vi.mocked(memoryApi.getMemoryGraphSearch).mockRejectedValue(new Error('search unavailable'))
render(<KnowledgeGraphPage />)
await screen.findByTestId('graph-visualization')
await user.type(screen.getByPlaceholderText('搜索实体、关系、hash后端全库'), 'missing')
await user.click(screen.getByRole('button', { name: '搜索' }))
expect(await screen.findByText('还没有可展示的长期记忆图谱')).toBeInTheDocument()
expect(toastMock).toHaveBeenCalledWith(
expect.objectContaining({
title: '后端检索失败,已回退本地筛选',
}),
)
})
it('shows empty state when switching to evidence view without a selection', async () => {

File diff suppressed because it is too large Load Diff

View File

@@ -22,6 +22,7 @@ import {
getMemoryGraph,
getMemoryGraphEdgeDetail,
getMemoryGraphNodeDetail,
getMemoryGraphSearch,
previewMemoryDelete,
restoreMemoryDelete,
type MemoryDeleteExecutePayload,
@@ -34,6 +35,7 @@ import {
type MemoryGraphParagraphDetailPayload,
type MemoryGraphPayload,
type MemoryGraphRelationDetailPayload,
type MemoryGraphSearchItem,
} from '@/lib/memory-api'
import {
@@ -211,6 +213,9 @@ export function KnowledgeGraphPage() {
const [nodeLimit, setNodeLimit] = useState('120')
const [searchInput, setSearchInput] = useState('')
const [appliedSearchQuery, setAppliedSearchQuery] = useState('')
const [searchLoading, setSearchLoading] = useState(false)
const [searchResults, setSearchResults] = useState<MemoryGraphSearchItem[]>([])
const [searchFallbackMode, setSearchFallbackMode] = useState(false)
const [viewMode, setViewMode] = useState<GraphViewMode>('entity')
const [fullGraph, setFullGraph] = useState<GraphData>({ nodes: [], edges: [] })
const [graphData, setGraphData] = useState<GraphData>({ nodes: [], edges: [] })
@@ -258,9 +263,12 @@ export function KnowledgeGraphPage() {
setLoading(true)
const payload = await getMemoryGraph(Number(nodeLimit))
const nextGraph = toEntityGraphData(payload)
const visibleGraph = searchFallbackMode && appliedSearchQuery
? filterGraphData(nextGraph, appliedSearchQuery)
: nextGraph
setGraphMeta(payload)
setFullGraph(nextGraph)
setGraphData(filterGraphData(nextGraph, appliedSearchQuery))
setGraphData(visibleGraph)
setEvidenceGraph({ nodes: [], edges: [] })
resetDetailSelections()
if (!options?.silent) {
@@ -278,21 +286,54 @@ export function KnowledgeGraphPage() {
} finally {
setLoading(false)
}
}, [appliedSearchQuery, nodeLimit, resetDetailSelections, toast])
}, [appliedSearchQuery, nodeLimit, resetDetailSelections, searchFallbackMode, toast])
useEffect(() => {
void loadGraph({ silent: true })
}, [loadGraph])
const handleSearch = useCallback(() => {
const handleSearch = useCallback(async () => {
const nextQuery = searchInput.trim()
if (!nextQuery) {
setAppliedSearchQuery('')
setSearchFallbackMode(false)
setSearchResults([])
setGraphData(fullGraph)
toast({
title: '已重置筛选',
description: `当前显示 ${fullGraph.nodes.length} 个节点、${fullGraph.edges.length} 条关系`,
})
return
}
setSearchLoading(true)
setAppliedSearchQuery(nextQuery)
const filtered = filterGraphData(fullGraph, nextQuery)
setGraphData(filtered)
toast({
title: nextQuery ? '筛选完成' : '已重置筛选',
description: `当前显示 ${filtered.nodes.length} 个节点、${filtered.edges.length} 条关系`,
})
try {
const payload = await getMemoryGraphSearch(nextQuery, 50)
if (!payload.success) {
throw new Error(payload.error || '图谱检索失败')
}
const items = Array.isArray(payload.items) ? payload.items : []
setSearchResults(items)
setSearchFallbackMode(false)
setGraphData(fullGraph)
toast({
title: '全库检索完成',
description: `命中 ${payload.count ?? items.length} 条结果`,
})
} catch (error) {
const filtered = filterGraphData(fullGraph, nextQuery)
setSearchResults([])
setSearchFallbackMode(true)
setGraphData(filtered)
toast({
title: '后端检索失败,已回退本地筛选',
description: `仅当前已加载范围(${filtered.nodes.length} 个节点、${filtered.edges.length} 条关系)`,
variant: 'destructive',
})
} finally {
setSearchLoading(false)
}
}, [fullGraph, searchInput, toast])
const stats = useMemo(
@@ -397,21 +438,41 @@ export function KnowledgeGraphPage() {
}
}, [closeDeleteDialog, deleteResult?.operation_id, loadGraph, toast])
const handleNodeClick = useCallback(async (_: React.MouseEvent, node: Node) => {
const selected = graphData.nodes.find((item) => item.id === node.id)
setSelectedNodeData(selected ?? null)
const openNodeDetail = useCallback(async (
nodeId: string,
options?: { locateInEvidence?: boolean },
) => {
const nodeToken = String(nodeId || '').trim()
if (!nodeToken) {
return
}
const selected = graphData.nodes.find((item) => item.id === nodeToken)
if (options?.locateInEvidence) {
setSelectedNodeData(null)
} else {
setSelectedNodeData(
selected ?? {
id: nodeToken,
type: 'entity',
content: nodeToken,
metadata: {},
},
)
}
setSelectedEdgeData(null)
setEdgeDetail(null)
setSelectedRelationDetail(null)
setSelectedRelationMetadata(null)
setSelectedParagraphDetail(null)
if (!selected) {
return
}
setSelectedParagraphMetadata(null)
try {
setDetailLoading(true)
const detail = await getMemoryGraphNodeDetail(selected.id)
const detail = await getMemoryGraphNodeDetail(nodeToken)
setNodeDetail(detail)
setEvidenceGraph(toEvidenceGraphData(detail.evidence_graph))
if (options?.locateInEvidence) {
setViewMode('evidence')
}
} catch (error) {
toast({
title: '加载节点详情失败',
@@ -423,27 +484,62 @@ export function KnowledgeGraphPage() {
}
}, [graphData.nodes, toast])
const handleEdgeClick = useCallback(async (_: React.MouseEvent, edge: Edge) => {
const sourceNode = graphData.nodes.find((nodeItem) => nodeItem.id === edge.source)
const targetNode = graphData.nodes.find((nodeItem) => nodeItem.id === edge.target)
const edgeData = graphData.edges.find((item) => item.source === edge.source && item.target === edge.target)
if (!sourceNode || !targetNode || !edgeData) {
const openEdgeDetail = useCallback(async (
source: string,
target: string,
options?: { locateInEvidence?: boolean },
) => {
const sourceToken = String(source || '').trim()
const targetToken = String(target || '').trim()
if (!sourceToken || !targetToken) {
return
}
setSelectedNodeData(null)
setNodeDetail(null)
setSelectedRelationDetail(null)
setSelectedRelationMetadata(null)
setSelectedParagraphDetail(null)
setSelectedEdgeData({
source: sourceNode,
target: targetNode,
edge: edgeData,
})
setSelectedParagraphMetadata(null)
if (options?.locateInEvidence) {
setSelectedEdgeData(null)
} else {
const sourceNode = graphData.nodes.find((nodeItem) => nodeItem.id === sourceToken) ?? {
id: sourceToken,
type: 'entity' as const,
content: sourceToken,
metadata: {},
}
const targetNode = graphData.nodes.find((nodeItem) => nodeItem.id === targetToken) ?? {
id: targetToken,
type: 'entity' as const,
content: targetToken,
metadata: {},
}
const edgeData = graphData.edges.find((item) => item.source === sourceToken && item.target === targetToken) ?? {
source: sourceToken,
target: targetToken,
weight: 1,
kind: 'relation' as const,
label: '',
relationHashes: [],
predicates: [],
relationCount: 0,
evidenceCount: 0,
}
setSelectedEdgeData({
source: sourceNode,
target: targetNode,
edge: edgeData,
})
}
try {
setDetailLoading(true)
const detail = await getMemoryGraphEdgeDetail(edge.source, edge.target)
const detail = await getMemoryGraphEdgeDetail(sourceToken, targetToken)
setEdgeDetail(detail)
setEvidenceGraph(toEvidenceGraphData(detail.evidence_graph))
if (options?.locateInEvidence) {
setViewMode('evidence')
}
} catch (error) {
toast({
title: '加载关系详情失败',
@@ -455,6 +551,36 @@ export function KnowledgeGraphPage() {
}
}, [graphData.edges, graphData.nodes, toast])
const handleNodeClick = useCallback((_: React.MouseEvent, node: Node) => {
void openNodeDetail(node.id)
}, [openNodeDetail])
const handleEdgeClick = useCallback((_: React.MouseEvent, edge: Edge) => {
void openEdgeDetail(edge.source, edge.target)
}, [openEdgeDetail])
const handleSearchResultClick = useCallback((item: MemoryGraphSearchItem) => {
if (item.type === 'entity') {
const entityName = String(item.entity_name ?? item.title ?? '').trim()
if (!entityName) {
return
}
void openNodeDetail(entityName, { locateInEvidence: true })
return
}
const source = String(item.subject ?? '').trim()
const target = String(item.object ?? '').trim()
if (!source || !target) {
toast({
title: '结果缺少定位信息',
description: '该关系记录没有可用的 subject/object无法定位。',
variant: 'destructive',
})
return
}
void openEdgeDetail(source, target, { locateInEvidence: true })
}, [openEdgeDetail, openNodeDetail, toast])
const handleEvidenceNodeClick = useCallback(async (_: React.MouseEvent, node: Node) => {
const selected = evidenceGraph.nodes.find((item) => item.id === node.id)
if (!selected) {
@@ -640,12 +766,12 @@ export function KnowledgeGraphPage() {
<Input
value={searchInput}
onChange={(event) => setSearchInput(event.target.value)}
onKeyDown={(event) => event.key === 'Enter' && handleSearch()}
placeholder="筛选实体名称、节点 ID 或边标签"
onKeyDown={(event) => event.key === 'Enter' && void handleSearch()}
placeholder="搜索实体、关系、hash后端全库"
/>
<Button onClick={handleSearch} variant="secondary">
<Button onClick={() => void handleSearch()} variant="secondary" disabled={searchLoading}>
<Search className="mr-2 h-4 w-4" />
{searchLoading ? '检索中' : '搜索'}
</Button>
</div>
@@ -678,6 +804,48 @@ export function KnowledgeGraphPage() {
<TabsTrigger value="evidence"></TabsTrigger>
</TabsList>
</Tabs>
{appliedSearchQuery ? (
<div className="rounded-lg border bg-background/80 p-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-sm font-medium">
{appliedSearchQuery}
</div>
<Badge variant={searchFallbackMode ? 'destructive' : 'secondary'}>
{searchFallbackMode ? '仅当前已加载范围' : `全库命中 ${searchResults.length}`}
</Badge>
</div>
{searchFallbackMode ? (
<p className="mt-2 text-sm text-muted-foreground">
</p>
) : searchResults.length <= 0 ? (
<p className="mt-2 text-sm text-muted-foreground"></p>
) : (
<div className="mt-3 max-h-56 space-y-2 overflow-auto pr-1">
{searchResults.map((item, index) => (
<button
key={`${item.type}-${item.entity_hash ?? item.relation_hash ?? `${item.title}-${index}`}`}
type="button"
className="w-full rounded-md border bg-card px-3 py-2 text-left transition hover:bg-accent/40"
onClick={() => handleSearchResultClick(item)}
>
<div className="flex items-center gap-2">
<Badge variant="outline">{item.type === 'entity' ? '实体' : '关系'}</Badge>
<span className="truncate text-sm font-medium">{item.title || '(无标题结果)'}</span>
</div>
<p className="mt-1 text-xs text-muted-foreground">
{item.matched_field} = {item.matched_value}
{item.type === 'entity'
? ` · appearance=${item.appearance_count ?? 0}`
: ` · confidence=${Number(item.confidence ?? 0).toFixed(2)}`}
</p>
</button>
))}
</div>
)}
</div>
) : null}
</div>
</div>