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:
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user