diff --git a/dashboard/src/lib/memory-api.ts b/dashboard/src/lib/memory-api.ts index 6d2d1a9c..18a584b2 100644 --- a/dashboard/src/lib/memory-api.ts +++ b/dashboard/src/lib/memory-api.ts @@ -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 + settings?: MemoryImportSettings } export interface MemoryTaskPayload { @@ -217,6 +244,158 @@ export interface MemoryTaskListPayload { settings?: Record } +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 + llm_retry?: Record + 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 +} + +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> +} + +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 + rollback_info?: Record + retry_parent_task_id?: string + retry_summary?: MemoryImportRetrySummary + params?: Record + 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 @@ -335,6 +514,17 @@ export async function getMemoryGraph(limit: number = 120): Promise(`/graph?limit=${limit}`) } +export async function getMemoryGraphSearch( + query: string, + limit: number = 50, +): Promise { + const params = new URLSearchParams({ + query, + limit: String(limit), + }) + return requestJson(`/graph/search?${params.toString()}`) +} + export async function getMemoryGraphNodeDetail( nodeId: string, options?: { @@ -466,16 +656,120 @@ export async function getMemoryImportGuide(): Promise return requestJson('/import/guide') } -export async function getMemoryImportSettings(): Promise> { - return requestJson('/import/settings') +export async function getMemoryImportSettings(): Promise { + return requestJson('/import/settings') } -export async function getMemoryImportTasks(limit: number = 20): Promise { - return requestJson(`/import/tasks?limit=${limit}`) +export async function getMemoryImportPathAliases(): Promise { + return requestJson('/import/path-aliases') } -export async function createMemoryPasteImport(payload: Record): Promise<{ success: boolean; task?: MemoryTaskPayload }> { - return requestJson('/import/paste', { +export async function resolveMemoryImportPath(payload: { + alias: string + relative_path?: string + must_exist?: boolean +}): Promise { + return requestJson('/import/resolve-path', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) +} + +export async function getMemoryImportTasks(limit: number = 20): Promise { + return requestJson(`/import/tasks?limit=${limit}`) +} + +export async function getMemoryImportTask(taskId: string, includeChunks: boolean = false): Promise { + return requestJson( + `/import/tasks/${encodeURIComponent(taskId)}?include_chunks=${includeChunks ? 'true' : 'false'}`, + ) +} + +export async function getMemoryImportTaskChunks( + taskId: string, + fileId: string, + offset: number = 0, + limit: number = 50, +): Promise { + return requestJson( + `/import/tasks/${encodeURIComponent(taskId)}/chunks/${encodeURIComponent(fileId)}?offset=${offset}&limit=${limit}`, + ) +} + +export async function createMemoryUploadImport(files: File[], payload: Record): Promise { + const formData = new FormData() + files.forEach((file) => { + formData.append('files', file) + }) + formData.append('payload_json', JSON.stringify(payload)) + return requestJson('/import/upload', { + method: 'POST', + body: formData, + }) +} + +export async function createMemoryPasteImport(payload: Record): Promise { + return requestJson('/import/paste', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) +} + +export async function createMemoryRawScanImport(payload: Record): Promise { + return requestJson('/import/raw-scan', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) +} + +export async function createMemoryLpmmOpenieImport(payload: Record): Promise { + return requestJson('/import/lpmm-openie', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) +} + +export async function createMemoryLpmmConvertImport(payload: Record): Promise { + return requestJson('/import/lpmm-convert', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) +} + +export async function createMemoryTemporalBackfillImport(payload: Record): Promise { + return requestJson('/import/temporal-backfill', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) +} + +export async function createMemoryMaibotMigrationImport(payload: Record): Promise { + return requestJson('/import/maibot-migration', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) +} + +export async function cancelMemoryImportTask(taskId: string): Promise { + return requestJson(`/import/tasks/${encodeURIComponent(taskId)}/cancel`, { + method: 'POST', + }) +} + +export async function retryMemoryImportTask( + taskId: string, + payload: { + overrides?: Record + } = {}, +): Promise { + return requestJson(`/import/tasks/${encodeURIComponent(taskId)}/retry`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), diff --git a/dashboard/src/routes/resource/__tests__/knowledge-base.test.tsx b/dashboard/src/routes/resource/__tests__/knowledge-base.test.tsx index 68f54636..a745abeb 100644 --- a/dashboard/src/routes/resource/__tests__/knowledge-base.test.tsx +++ b/dashboard/src/routes/resource/__tests__/knowledge-base.test.tsx @@ -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 ?
{`delete:${preview?.mode ?? 'none'}:${preview?.item_count ?? 0}`}
: null + open ? ( +
+
{`preview:${preview?.mode ?? 'none'}:${preview?.item_count ?? 0}`}
+
{`result:${result?.operation_id ?? 'none'}`}
+ + +
+ ) : 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() - 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() + + 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() + + 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() + + 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() + + 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() + + 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() - 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() - - 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) }) diff --git a/dashboard/src/routes/resource/__tests__/knowledge-graph.test.tsx b/dashboard/src/routes/resource/__tests__/knowledge-graph.test.tsx index df41d2b8..5445c7cb 100644 --- a/dashboard/src/routes/resource/__tests__/knowledge-graph.test.tsx +++ b/dashboard/src/routes/resource/__tests__/knowledge-graph.test.tsx @@ -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() @@ -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() + + 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() + + 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() + + 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 () => { diff --git a/dashboard/src/routes/resource/knowledge-base.tsx b/dashboard/src/routes/resource/knowledge-base.tsx index dd4ab5c3..d50426c2 100644 --- a/dashboard/src/routes/resource/knowledge-base.tsx +++ b/dashboard/src/routes/resource/knowledge-base.tsx @@ -2,9 +2,11 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import { useNavigate } from '@tanstack/react-router' import { + ChevronLeft, + ChevronRight, Database, - FileDown, Gauge, + Loader2, RefreshCw, RotateCcw, Save, @@ -14,7 +16,7 @@ import { Upload, } from 'lucide-react' -import { CodeEditor, MarkdownRenderer } from '@/components' +import { CodeEditor } from '@/components' import { MemoryDeleteDialog } from '@/components/memory/MemoryDeleteDialog' import { MemoryConfigEditor } from '@/components/memory/MemoryConfigEditor' import { Alert, AlertDescription } from '@/components/ui/alert' @@ -24,6 +26,8 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com import { Checkbox } from '@/components/ui/checkbox' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' +import { Progress } from '@/components/ui/progress' +import { ScrollArea } from '@/components/ui/scroll-area' import { Select, SelectContent, @@ -36,26 +40,42 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Textarea } from '@/components/ui/textarea' import { useToast } from '@/hooks/use-toast' import { cn } from '@/lib/utils' -import { ScrollArea } from '@/components/ui/scroll-area' import { + cancelMemoryImportTask, + createMemoryLpmmConvertImport, + createMemoryLpmmOpenieImport, + createMemoryMaibotMigrationImport, + createMemoryRawScanImport, + createMemoryTemporalBackfillImport, executeMemoryDelete, + getMemoryImportPathAliases, + getMemoryImportSettings, + getMemoryImportTask, + getMemoryImportTaskChunks, applyBestMemoryTuningProfile, createMemoryPasteImport, createMemoryTuningTask, + createMemoryUploadImport, getMemoryConfig, getMemoryConfigRaw, getMemoryConfigSchema, getMemoryDeleteOperation, getMemoryDeleteOperations, - getMemoryImportGuide, getMemoryImportTasks, getMemoryRuntimeConfig, getMemorySources, getMemoryTuningProfile, getMemoryTuningTasks, type MemoryDeleteRequestPayload, + type MemoryImportChunkListPayload, + type MemoryImportInputMode, + type MemoryImportSettings, + type MemoryImportTaskKind, + type MemoryImportTaskPayload, previewMemoryDelete, refreshMemoryRuntimeSelfCheck, + resolveMemoryImportPath, + retryMemoryImportTask, restoreMemoryDelete, updateMemoryConfig, updateMemoryConfigRaw, @@ -70,6 +90,138 @@ import { const DELETE_OPERATION_FETCH_LIMIT = 100 const DELETE_OPERATION_PAGE_SIZE = 6 const DELETE_OPERATION_ITEM_PAGE_SIZE = 8 +const IMPORT_CHUNK_PAGE_SIZE = 50 + +const RUNNING_IMPORT_STATUS = new Set(['preparing', 'running', 'cancel_requested']) +const QUEUED_IMPORT_STATUS = new Set(['queued']) + +const IMPORT_STATUS_TEXT: Record = { + queued: '排队中', + preparing: '准备中', + running: '运行中', + cancel_requested: '取消中', + cancelled: '已取消', + completed: '已完成', + completed_with_errors: '完成(有错误)', + failed: '失败', +} + +const IMPORT_STEP_TEXT: Record = { + queued: '排队中', + preparing: '准备中', + running: '运行中', + splitting: '分块中', + extracting: '抽取中', + writing: '写入中', + saving: '保存中', + backfilling: '回填中', + converting: '转换中', + verifying: '校验中', + switching: '切换中', + cancel_requested: '取消中', + cancelled: '已取消', + completed: '已完成', + completed_with_errors: '完成(有错误)', + failed: '失败', +} + +const IMPORT_KIND_OPTIONS: Array<{ value: MemoryImportTaskKind; label: string; description: string }> = [ + { value: 'upload', label: '上传文件', description: '从本地批量上传文本文件' }, + { value: 'paste', label: '粘贴导入', description: '直接粘贴文本或 JSON 内容创建任务' }, + { value: 'raw_scan', label: '本地扫描', description: '按路径别名和匹配规则批量扫描导入' }, + { value: 'lpmm_openie', label: 'LPMM OpenIE', description: '读取 LPMM 数据并抽取关系' }, + { value: 'lpmm_convert', label: 'LPMM 转换', description: '将 LPMM 数据转换到目标目录' }, + { value: 'temporal_backfill', label: '时序回填', description: '对既有数据执行时间字段回填' }, + { value: 'maibot_migration', label: 'MaiBot 迁移', description: '从 MaiBot 历史库迁移长期记忆数据' }, +] + +function normalizeProgress(value: number | string | null | undefined): number { + const numeric = Number(value ?? 0) + if (!Number.isFinite(numeric)) { + return 0 + } + if (numeric < 0) { + return 0 + } + if (numeric > 100) { + return 100 + } + return numeric +} + +function parseOptionalPositiveInt(input: string): number | undefined { + const value = input.trim() + if (!value) { + return undefined + } + const parsed = Number(value) + if (!Number.isInteger(parsed) || parsed <= 0) { + return undefined + } + return parsed +} + +function parseCommaSeparatedList(input: string): string[] { + return input + .split(',') + .map((item) => item.trim()) + .filter(Boolean) +} + +function normalizeImportInputMode(value: string): MemoryImportInputMode { + return value === 'json' ? 'json' : 'text' +} + +function getImportStatusLabel(status: string): string { + const normalized = String(status ?? '').trim() + if (!normalized) { + return '-' + } + return IMPORT_STATUS_TEXT[normalized] ?? normalized +} + +function getImportStepLabel(step: string): string { + const normalized = String(step ?? '').trim() + if (!normalized) { + return '-' + } + return IMPORT_STEP_TEXT[normalized] ?? normalized +} + +function getImportStatusVariant(status: string): 'default' | 'secondary' | 'destructive' | 'outline' { + if (status === 'failed') { + return 'destructive' + } + if (status === 'completed') { + return 'default' + } + if (status === 'completed_with_errors' || status === 'cancelled') { + return 'secondary' + } + if (RUNNING_IMPORT_STATUS.has(status) || QUEUED_IMPORT_STATUS.has(status)) { + return 'outline' + } + return 'secondary' +} + +function formatImportTime(timestamp?: number | null): string { + if (!timestamp) { + return '-' + } + const normalized = timestamp > 1_000_000_000_000 ? timestamp : timestamp * 1000 + const value = new Date(normalized) + if (Number.isNaN(value.getTime())) { + return '-' + } + return value.toLocaleString('zh-CN', { + hour12: false, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }) +} function formatDeleteOperationMode(mode: string): string { switch (mode) { @@ -213,10 +365,85 @@ export function KnowledgeBasePage() { const [schemaPayload, setSchemaPayload] = useState(null) const [visualConfig, setVisualConfig] = useState>({}) const [rawConfig, setRawConfig] = useState('') + const [rawConfigExists, setRawConfigExists] = useState(true) + const [rawConfigUsingDefault, setRawConfigUsingDefault] = useState(false) const [runtimeConfig, setRuntimeConfig] = useState(null) const [selfCheckReport, setSelfCheckReport] = useState | null>(null) - const [importGuide, setImportGuide] = useState('') - const [importTasks, setImportTasks] = useState([]) + const [importSettings, setImportSettings] = useState({}) + const [importPathAliases, setImportPathAliases] = useState>({}) + const [importTasks, setImportTasks] = useState([]) + const [selectedImportTaskId, setSelectedImportTaskId] = useState('') + const [selectedImportTask, setSelectedImportTask] = useState(null) + const [selectedImportTaskLoading, setSelectedImportTaskLoading] = useState(false) + const [selectedImportFileId, setSelectedImportFileId] = useState('') + const [importChunkOffset, setImportChunkOffset] = useState(0) + const [importChunksPayload, setImportChunksPayload] = useState(null) + const [importChunksLoading, setImportChunksLoading] = useState(false) + const [importCreateMode, setImportCreateMode] = useState('upload') + const [importAutoPolling, setImportAutoPolling] = useState(true) + const [importErrorText, setImportErrorText] = useState('') + const [importCommonFileConcurrency, setImportCommonFileConcurrency] = useState('2') + const [importCommonChunkConcurrency, setImportCommonChunkConcurrency] = useState('4') + const [importCommonLlmEnabled, setImportCommonLlmEnabled] = useState(true) + const [importCommonStrategyOverride, setImportCommonStrategyOverride] = useState('auto') + const [importCommonDedupePolicy, setImportCommonDedupePolicy] = useState('content_hash') + const [importCommonChatLog, setImportCommonChatLog] = useState(false) + const [importCommonChatReferenceTime, setImportCommonChatReferenceTime] = useState('') + const [importCommonForce, setImportCommonForce] = useState(false) + const [importCommonClearManifest, setImportCommonClearManifest] = useState(false) + + const [uploadInputMode, setUploadInputMode] = useState('text') + const [uploadFiles, setUploadFiles] = useState([]) + + const [pasteName, setPasteName] = useState('') + const [pasteMode, setPasteMode] = useState('text') + const [pasteContent, setPasteContent] = useState('') + + const [rawAlias, setRawAlias] = useState('raw') + const [rawRelativePath, setRawRelativePath] = useState('') + const [rawGlob, setRawGlob] = useState('*') + const [rawInputMode, setRawInputMode] = useState('text') + const [rawRecursive, setRawRecursive] = useState(true) + + const [openieAlias, setOpenieAlias] = useState('lpmm') + const [openieRelativePath, setOpenieRelativePath] = useState('') + const [openieIncludeAllJson, setOpenieIncludeAllJson] = useState(false) + + const [convertAlias, setConvertAlias] = useState('lpmm') + const [convertRelativePath, setConvertRelativePath] = useState('') + const [convertTargetAlias, setConvertTargetAlias] = useState('plugin_data') + const [convertTargetRelativePath, setConvertTargetRelativePath] = useState('') + const [convertDimension, setConvertDimension] = useState('') + const [convertBatchSize, setConvertBatchSize] = useState('1024') + + const [backfillAlias, setBackfillAlias] = useState('plugin_data') + const [backfillRelativePath, setBackfillRelativePath] = useState('') + const [backfillLimit, setBackfillLimit] = useState('100000') + const [backfillDryRun, setBackfillDryRun] = useState(false) + const [backfillNoCreatedFallback, setBackfillNoCreatedFallback] = useState(false) + + const [maibotSourceDb, setMaibotSourceDb] = useState('') + const [maibotTimeFrom, setMaibotTimeFrom] = useState('') + const [maibotTimeTo, setMaibotTimeTo] = useState('') + const [maibotStartId, setMaibotStartId] = useState('') + const [maibotEndId, setMaibotEndId] = useState('') + const [maibotStreamIds, setMaibotStreamIds] = useState('') + const [maibotGroupIds, setMaibotGroupIds] = useState('') + const [maibotUserIds, setMaibotUserIds] = useState('') + const [maibotReadBatchSize, setMaibotReadBatchSize] = useState('2000') + const [maibotCommitWindowRows, setMaibotCommitWindowRows] = useState('20000') + const [maibotEmbedWorkers, setMaibotEmbedWorkers] = useState('') + const [maibotNoResume, setMaibotNoResume] = useState(false) + const [maibotResetState, setMaibotResetState] = useState(false) + const [maibotDryRun, setMaibotDryRun] = useState(false) + const [maibotVerifyOnly, setMaibotVerifyOnly] = useState(false) + + const [pathResolveAlias, setPathResolveAlias] = useState('raw') + const [pathResolveRelativePath, setPathResolveRelativePath] = useState('') + const [pathResolveMustExist, setPathResolveMustExist] = useState(true) + const [pathResolveOutput, setPathResolveOutput] = useState('') + const [resolvingPath, setResolvingPath] = useState(false) + const [tuningTasks, setTuningTasks] = useState([]) const [tuningProfile, setTuningProfile] = useState>({}) const [tuningProfileToml, setTuningProfileToml] = useState('') @@ -244,10 +471,6 @@ export function KnowledgeBasePage() { const [deleteRestoring, setDeleteRestoring] = useState(false) const [deleteResult, setDeleteResult] = useState(null) const [pendingDeleteRequest, setPendingDeleteRequest] = useState(null) - - const [pasteName, setPasteName] = useState('') - const [pasteMode, setPasteMode] = useState('text') - const [pasteContent, setPasteContent] = useState('') const [tuningObjective, setTuningObjective] = useState('precision_priority') const [tuningIntensity, setTuningIntensity] = useState('standard') const [tuningSampleSize, setTuningSampleSize] = useState('24') @@ -261,7 +484,8 @@ export function KnowledgeBasePage() { configPayload, rawPayload, runtimePayload, - guidePayload, + importSettingsPayload, + pathAliasPayload, importTaskPayload, tuningProfilePayload, tuningTaskPayload, @@ -272,7 +496,8 @@ export function KnowledgeBasePage() { getMemoryConfig(), getMemoryConfigRaw(), getMemoryRuntimeConfig(), - getMemoryImportGuide(), + getMemoryImportSettings(), + getMemoryImportPathAliases(), getMemoryImportTasks(20), getMemoryTuningProfile(), getMemoryTuningTasks(20), @@ -283,14 +508,32 @@ export function KnowledgeBasePage() { setSchemaPayload(schema) setVisualConfig(configPayload.config ?? {}) setRawConfig(rawPayload.config ?? '') + setRawConfigExists(rawPayload.exists ?? true) + setRawConfigUsingDefault(rawPayload.using_default ?? false) setRuntimeConfig(runtimePayload) - setImportGuide(guidePayload.content ?? '') + setImportSettings(importSettingsPayload.settings ?? {}) + setImportPathAliases(pathAliasPayload.path_aliases ?? {}) setImportTasks(importTaskPayload.items ?? []) setTuningProfile(tuningProfilePayload.profile ?? {}) setTuningProfileToml(tuningProfilePayload.toml ?? '') setTuningTasks(tuningTaskPayload.items ?? []) setMemorySources(sourcePayload.items ?? []) setDeleteOperations(deleteOperationPayload.items ?? []) + if (!selectedImportTaskId && (importTaskPayload.items ?? []).length > 0) { + const initialTaskId = String(importTaskPayload.items?.[0]?.task_id ?? '') + if (initialTaskId) { + setSelectedImportTaskId(initialTaskId) + } + } + if (!maibotSourceDb && String(importSettingsPayload.settings?.maibot_source_db_default ?? '').trim()) { + setMaibotSourceDb(String(importSettingsPayload.settings?.maibot_source_db_default ?? '').trim()) + } + if (!pathResolveAlias) { + const aliasKeys = Object.keys(pathAliasPayload.path_aliases ?? {}) + if (aliasKeys.length > 0) { + setPathResolveAlias(aliasKeys[0]) + } + } } catch (error) { toast({ title: '加载长期记忆控制台失败', @@ -300,7 +543,7 @@ export function KnowledgeBasePage() { } finally { setLoading(false) } - }, [toast]) + }, [maibotSourceDb, pathResolveAlias, selectedImportTaskId, toast]) useEffect(() => { void loadPage() @@ -321,6 +564,706 @@ export function KnowledgeBasePage() { ] }, [runtimeConfig]) + const importPollInterval = useMemo( + () => Math.max(200, Number(importSettings.poll_interval_ms ?? 1000)), + [importSettings.poll_interval_ms], + ) + + const importAliasKeys = useMemo( + () => Object.keys(importPathAliases).sort((left, right) => left.localeCompare(right)), + [importPathAliases], + ) + + const runningImportTasks = useMemo( + () => importTasks.filter((task) => RUNNING_IMPORT_STATUS.has(String(task.status ?? '').trim())), + [importTasks], + ) + const queuedImportTasks = useMemo( + () => importTasks.filter((task) => QUEUED_IMPORT_STATUS.has(String(task.status ?? '').trim())), + [importTasks], + ) + const recentImportTasks = useMemo( + () => + importTasks.filter((task) => { + const status = String(task.status ?? '').trim() + return !RUNNING_IMPORT_STATUS.has(status) && !QUEUED_IMPORT_STATUS.has(status) + }), + [importTasks], + ) + const selectedImportTaskSummary = useMemo(() => { + if (!selectedImportTaskId) { + return null + } + return importTasks.find((task) => task.task_id === selectedImportTaskId) ?? null + }, [importTasks, selectedImportTaskId]) + + const selectedImportFiles = useMemo(() => { + return Array.isArray(selectedImportTask?.files) ? selectedImportTask.files : [] + }, [selectedImportTask?.files]) + + const selectedImportChunks = useMemo(() => { + return Array.isArray(importChunksPayload?.items) ? importChunksPayload.items : [] + }, [importChunksPayload?.items]) + + const selectedImportTaskResolved = selectedImportTask ?? selectedImportTaskSummary + const selectedImportTaskErrorText = String(selectedImportTaskResolved?.error ?? '').trim() + const selectedImportRetrySummary = selectedImportTaskResolved?.retry_summary + + const importChunkTotal = Number(importChunksPayload?.total ?? 0) + const canImportChunkPrev = importChunkOffset > 0 + const canImportChunkNext = importChunkOffset + IMPORT_CHUNK_PAGE_SIZE < importChunkTotal + + const buildCommonImportPayload = useCallback((): Record => { + const payload: Record = { + llm_enabled: importCommonLlmEnabled, + strategy_override: importCommonStrategyOverride, + dedupe_policy: importCommonDedupePolicy, + chat_log: importCommonChatLog, + force: importCommonForce, + clear_manifest: importCommonClearManifest, + } + + const fileConcurrency = parseOptionalPositiveInt(importCommonFileConcurrency) + const chunkConcurrency = parseOptionalPositiveInt(importCommonChunkConcurrency) + if (fileConcurrency !== undefined) { + payload.file_concurrency = fileConcurrency + } + if (chunkConcurrency !== undefined) { + payload.chunk_concurrency = chunkConcurrency + } + if (importCommonChatReferenceTime.trim()) { + payload.chat_reference_time = importCommonChatReferenceTime.trim() + } + return payload + }, [ + importCommonChatLog, + importCommonChatReferenceTime, + importCommonChunkConcurrency, + importCommonClearManifest, + importCommonDedupePolicy, + importCommonFileConcurrency, + importCommonForce, + importCommonLlmEnabled, + importCommonStrategyOverride, + ]) + + const refreshImportQueue = useCallback(async (silent: boolean = false) => { + try { + const [taskPayload, settingsPayload, pathAliasPayload] = await Promise.all([ + getMemoryImportTasks(20), + getMemoryImportSettings(), + getMemoryImportPathAliases(), + ]) + const nextTasks = taskPayload.items ?? [] + setImportTasks(nextTasks) + setImportSettings(settingsPayload.settings ?? {}) + setImportPathAliases(pathAliasPayload.path_aliases ?? {}) + setImportErrorText('') + + if (nextTasks.length <= 0) { + setSelectedImportTaskId('') + setSelectedImportTask(null) + setSelectedImportFileId('') + setImportChunksPayload(null) + return + } + + if (!selectedImportTaskId || !nextTasks.some((item) => item.task_id === selectedImportTaskId)) { + setSelectedImportTaskId(nextTasks[0].task_id) + } + } catch (error) { + const message = error instanceof Error ? error.message : '刷新导入任务失败' + setImportErrorText(message) + if (!silent) { + toast({ + title: '刷新导入任务失败', + description: message, + variant: 'destructive', + }) + } + } + }, [selectedImportTaskId, toast]) + + const loadImportChunks = useCallback( + async ( + taskId: string, + fileId: string, + offset: number = 0, + silent: boolean = false, + ) => { + if (!taskId || !fileId) { + setImportChunksPayload(null) + return + } + try { + setImportChunksLoading(true) + const payload = await getMemoryImportTaskChunks(taskId, fileId, offset, IMPORT_CHUNK_PAGE_SIZE) + if (!payload.success) { + throw new Error(payload.error || '加载分块详情失败') + } + setImportChunksPayload(payload) + setImportErrorText('') + } catch (error) { + const message = error instanceof Error ? error.message : '加载分块详情失败' + setImportChunksPayload(null) + setImportErrorText(message) + if (!silent) { + toast({ + title: '加载分块详情失败', + description: message, + variant: 'destructive', + }) + } + } finally { + setImportChunksLoading(false) + } + }, + [toast], + ) + + const loadImportTaskDetail = useCallback( + async (taskId: string, silent: boolean = false) => { + if (!taskId) { + setSelectedImportTask(null) + setSelectedImportFileId('') + setImportChunksPayload(null) + return + } + try { + if (!silent) { + setSelectedImportTaskLoading(true) + } + const payload = await getMemoryImportTask(taskId, false) + if (!payload.success || !payload.task) { + throw new Error(payload.error || '任务不存在') + } + const task = payload.task + setSelectedImportTask(task) + setImportErrorText('') + const files = Array.isArray(task.files) ? task.files : [] + const keepCurrentFile = files.some((file) => file.file_id === selectedImportFileId) + const nextFileId = keepCurrentFile ? selectedImportFileId : String(files[0]?.file_id ?? '') + const nextOffset = keepCurrentFile ? importChunkOffset : 0 + if (!keepCurrentFile) { + setImportChunkOffset(0) + } + setSelectedImportFileId(nextFileId) + if (nextFileId) { + await loadImportChunks(taskId, nextFileId, nextOffset, silent) + } else { + setImportChunksPayload(null) + } + } catch (error) { + const message = error instanceof Error ? error.message : '加载导入任务详情失败' + setSelectedImportTask(null) + setSelectedImportFileId('') + setImportChunksPayload(null) + setImportErrorText(message) + if (!silent) { + toast({ + title: '加载导入任务详情失败', + description: message, + variant: 'destructive', + }) + } + } finally { + if (!silent) { + setSelectedImportTaskLoading(false) + } + } + }, + [importChunkOffset, loadImportChunks, selectedImportFileId, toast], + ) + + const afterImportTaskCreated = useCallback( + async (taskId: string, successTitle: string) => { + await refreshImportQueue(true) + if (taskId) { + setSelectedImportTaskId(taskId) + await loadImportTaskDetail(taskId, true) + } + toast({ + title: successTitle, + description: taskId ? `任务 ${taskId.slice(0, 12)} 已加入导入队列` : '导入任务已加入队列', + }) + }, + [loadImportTaskDetail, refreshImportQueue, toast], + ) + + const submitUploadImport = useCallback(async () => { + if (uploadFiles.length <= 0) { + toast({ + title: '请选择上传文件', + description: '至少选择一个 txt/md/json 文件后再提交', + variant: 'destructive', + }) + return + } + try { + setCreatingImport(true) + const payload = { + ...buildCommonImportPayload(), + input_mode: uploadInputMode, + } + const result = await createMemoryUploadImport(uploadFiles, payload) + if (!result.success) { + throw new Error(result.error || '创建上传导入任务失败') + } + const taskId = String(result.task?.task_id ?? '') + setUploadFiles([]) + await afterImportTaskCreated(taskId, '上传导入任务已创建') + } catch (error) { + const message = error instanceof Error ? error.message : '创建上传导入任务失败' + setImportErrorText(message) + toast({ + title: '创建上传导入任务失败', + description: message, + variant: 'destructive', + }) + } finally { + setCreatingImport(false) + } + }, [afterImportTaskCreated, buildCommonImportPayload, toast, uploadFiles, uploadInputMode]) + + const submitPasteImport = useCallback(async () => { + if (!pasteContent.trim()) { + toast({ + title: '粘贴内容不能为空', + description: '请填写导入内容后再提交', + variant: 'destructive', + }) + return + } + try { + setCreatingImport(true) + const result = await createMemoryPasteImport({ + ...buildCommonImportPayload(), + name: pasteName || undefined, + content: pasteContent, + input_mode: pasteMode, + }) + if (!result.success) { + throw new Error(result.error || '创建粘贴导入任务失败') + } + const taskId = String(result.task?.task_id ?? '') + setPasteContent('') + setPasteName('') + await afterImportTaskCreated(taskId, '粘贴导入任务已创建') + } catch (error) { + const message = error instanceof Error ? error.message : '创建粘贴导入任务失败' + setImportErrorText(message) + toast({ + title: '创建粘贴导入任务失败', + description: message, + variant: 'destructive', + }) + } finally { + setCreatingImport(false) + } + }, [afterImportTaskCreated, buildCommonImportPayload, pasteContent, pasteMode, pasteName, toast]) + + const submitRawScanImport = useCallback(async () => { + try { + setCreatingImport(true) + const result = await createMemoryRawScanImport({ + ...buildCommonImportPayload(), + alias: rawAlias, + relative_path: rawRelativePath, + glob: rawGlob, + recursive: rawRecursive, + input_mode: rawInputMode, + }) + if (!result.success) { + throw new Error(result.error || '创建本地扫描任务失败') + } + await afterImportTaskCreated(String(result.task?.task_id ?? ''), '本地扫描任务已创建') + } catch (error) { + const message = error instanceof Error ? error.message : '创建本地扫描任务失败' + setImportErrorText(message) + toast({ + title: '创建本地扫描任务失败', + description: message, + variant: 'destructive', + }) + } finally { + setCreatingImport(false) + } + }, [ + afterImportTaskCreated, + buildCommonImportPayload, + rawAlias, + rawGlob, + rawInputMode, + rawRecursive, + rawRelativePath, + toast, + ]) + + const submitOpenieImport = useCallback(async () => { + try { + setCreatingImport(true) + const result = await createMemoryLpmmOpenieImport({ + ...buildCommonImportPayload(), + alias: openieAlias, + relative_path: openieRelativePath, + include_all_json: openieIncludeAllJson, + }) + if (!result.success) { + throw new Error(result.error || '创建 LPMM OpenIE 任务失败') + } + await afterImportTaskCreated(String(result.task?.task_id ?? ''), 'LPMM OpenIE 任务已创建') + } catch (error) { + const message = error instanceof Error ? error.message : '创建 LPMM OpenIE 任务失败' + setImportErrorText(message) + toast({ + title: '创建 LPMM OpenIE 任务失败', + description: message, + variant: 'destructive', + }) + } finally { + setCreatingImport(false) + } + }, [ + afterImportTaskCreated, + buildCommonImportPayload, + openieAlias, + openieIncludeAllJson, + openieRelativePath, + toast, + ]) + + const submitConvertImport = useCallback(async () => { + try { + setCreatingImport(true) + const result = await createMemoryLpmmConvertImport({ + alias: convertAlias, + relative_path: convertRelativePath, + target_alias: convertTargetAlias, + target_relative_path: convertTargetRelativePath, + dimension: parseOptionalPositiveInt(convertDimension), + batch_size: parseOptionalPositiveInt(convertBatchSize), + }) + if (!result.success) { + throw new Error(result.error || '创建 LPMM 转换任务失败') + } + await afterImportTaskCreated(String(result.task?.task_id ?? ''), 'LPMM 转换任务已创建') + } catch (error) { + const message = error instanceof Error ? error.message : '创建 LPMM 转换任务失败' + setImportErrorText(message) + toast({ + title: '创建 LPMM 转换任务失败', + description: message, + variant: 'destructive', + }) + } finally { + setCreatingImport(false) + } + }, [ + afterImportTaskCreated, + convertAlias, + convertBatchSize, + convertDimension, + convertRelativePath, + convertTargetAlias, + convertTargetRelativePath, + toast, + ]) + + const submitBackfillImport = useCallback(async () => { + try { + setCreatingImport(true) + const result = await createMemoryTemporalBackfillImport({ + alias: backfillAlias, + relative_path: backfillRelativePath, + limit: parseOptionalPositiveInt(backfillLimit), + dry_run: backfillDryRun, + no_created_fallback: backfillNoCreatedFallback, + }) + if (!result.success) { + throw new Error(result.error || '创建时序回填任务失败') + } + await afterImportTaskCreated(String(result.task?.task_id ?? ''), '时序回填任务已创建') + } catch (error) { + const message = error instanceof Error ? error.message : '创建时序回填任务失败' + setImportErrorText(message) + toast({ + title: '创建时序回填任务失败', + description: message, + variant: 'destructive', + }) + } finally { + setCreatingImport(false) + } + }, [ + afterImportTaskCreated, + backfillAlias, + backfillDryRun, + backfillLimit, + backfillNoCreatedFallback, + backfillRelativePath, + toast, + ]) + + const submitMaibotMigrationImport = useCallback(async () => { + try { + setCreatingImport(true) + const result = await createMemoryMaibotMigrationImport({ + source_db: maibotSourceDb || undefined, + time_from: maibotTimeFrom || undefined, + time_to: maibotTimeTo || undefined, + start_id: parseOptionalPositiveInt(maibotStartId), + end_id: parseOptionalPositiveInt(maibotEndId), + stream_ids: parseCommaSeparatedList(maibotStreamIds), + group_ids: parseCommaSeparatedList(maibotGroupIds), + user_ids: parseCommaSeparatedList(maibotUserIds), + read_batch_size: parseOptionalPositiveInt(maibotReadBatchSize), + commit_window_rows: parseOptionalPositiveInt(maibotCommitWindowRows), + embed_workers: parseOptionalPositiveInt(maibotEmbedWorkers), + no_resume: maibotNoResume, + reset_state: maibotResetState, + dry_run: maibotDryRun, + verify_only: maibotVerifyOnly, + }) + if (!result.success) { + throw new Error(result.error || '创建 MaiBot 迁移任务失败') + } + await afterImportTaskCreated(String(result.task?.task_id ?? ''), 'MaiBot 迁移任务已创建') + } catch (error) { + const message = error instanceof Error ? error.message : '创建 MaiBot 迁移任务失败' + setImportErrorText(message) + toast({ + title: '创建 MaiBot 迁移任务失败', + description: message, + variant: 'destructive', + }) + } finally { + setCreatingImport(false) + } + }, [ + afterImportTaskCreated, + maibotCommitWindowRows, + maibotDryRun, + maibotEmbedWorkers, + maibotEndId, + maibotGroupIds, + maibotNoResume, + maibotReadBatchSize, + maibotResetState, + maibotSourceDb, + maibotStartId, + maibotStreamIds, + maibotTimeFrom, + maibotTimeTo, + maibotUserIds, + maibotVerifyOnly, + toast, + ]) + + const cancelSelectedImportTask = useCallback(async () => { + if (!selectedImportTaskId) { + return + } + try { + const payload = await cancelMemoryImportTask(selectedImportTaskId) + if (!payload.success) { + throw new Error(payload.error || '取消导入任务失败') + } + await refreshImportQueue(true) + await loadImportTaskDetail(selectedImportTaskId, true) + toast({ + title: '已请求取消任务', + description: `任务 ${selectedImportTaskId.slice(0, 12)} 正在取消`, + }) + } catch (error) { + const message = error instanceof Error ? error.message : '取消导入任务失败' + setImportErrorText(message) + toast({ + title: '取消导入任务失败', + description: message, + variant: 'destructive', + }) + } + }, [loadImportTaskDetail, refreshImportQueue, selectedImportTaskId, toast]) + + const retrySelectedImportTask = useCallback(async () => { + if (!selectedImportTaskId) { + return + } + try { + const payload = await retryMemoryImportTask(selectedImportTaskId, { + overrides: buildCommonImportPayload(), + }) + if (!payload.success) { + throw new Error(payload.error || '重试失败项失败') + } + const nextTaskId = String(payload.task?.task_id ?? '') + await refreshImportQueue(true) + if (nextTaskId) { + setSelectedImportTaskId(nextTaskId) + await loadImportTaskDetail(nextTaskId, true) + } else { + await loadImportTaskDetail(selectedImportTaskId, true) + } + toast({ + title: '重试任务已创建', + description: nextTaskId ? `重试任务 ${nextTaskId.slice(0, 12)} 已进入队列` : '失败项已提交重试', + }) + } catch (error) { + const message = error instanceof Error ? error.message : '重试失败项失败' + setImportErrorText(message) + toast({ + title: '重试失败项失败', + description: message, + variant: 'destructive', + }) + } + }, [buildCommonImportPayload, loadImportTaskDetail, refreshImportQueue, selectedImportTaskId, toast]) + + const resolveImportPath = useCallback(async () => { + if (!pathResolveAlias.trim()) { + return + } + try { + setResolvingPath(true) + const payload = await resolveMemoryImportPath({ + alias: pathResolveAlias, + relative_path: pathResolveRelativePath, + must_exist: pathResolveMustExist, + }) + const lines = [ + `路径别名: ${payload.alias}`, + `相对路径: ${payload.relative_path || '(空)'}`, + `解析结果: ${payload.resolved_path}`, + `是否存在: ${String(payload.exists)}`, + `是否文件: ${String(payload.is_file)}`, + `是否目录: ${String(payload.is_dir)}`, + ] + setPathResolveOutput(lines.join('\n')) + } catch (error) { + const message = error instanceof Error ? error.message : '路径解析失败' + setPathResolveOutput(`解析失败:${message}`) + } finally { + setResolvingPath(false) + } + }, [pathResolveAlias, pathResolveMustExist, pathResolveRelativePath]) + + const selectImportTask = useCallback( + async (taskId: string) => { + setSelectedImportTaskId(taskId) + setImportChunkOffset(0) + await loadImportTaskDetail(taskId) + }, + [loadImportTaskDetail], + ) + + const selectImportFile = useCallback( + async (fileId: string) => { + if (!selectedImportTaskId) { + return + } + setSelectedImportFileId(fileId) + setImportChunkOffset(0) + await loadImportChunks(selectedImportTaskId, fileId, 0) + }, + [loadImportChunks, selectedImportTaskId], + ) + + const moveImportChunkPage = useCallback( + async (direction: -1 | 1) => { + if (!selectedImportTaskId || !selectedImportFileId) { + return + } + const nextOffset = + direction < 0 + ? Math.max(0, importChunkOffset - IMPORT_CHUNK_PAGE_SIZE) + : importChunkOffset + IMPORT_CHUNK_PAGE_SIZE + if (nextOffset === importChunkOffset) { + return + } + setImportChunkOffset(nextOffset) + await loadImportChunks(selectedImportTaskId, selectedImportFileId, nextOffset) + }, + [importChunkOffset, loadImportChunks, selectedImportFileId, selectedImportTaskId], + ) + + useEffect(() => { + if (importAliasKeys.length <= 0) { + return + } + const pickAlias = (current: string, preferred: string): string => { + if (current && importAliasKeys.includes(current)) { + return current + } + if (importAliasKeys.includes(preferred)) { + return preferred + } + return importAliasKeys[0] + } + setRawAlias((current) => pickAlias(current, 'raw')) + setOpenieAlias((current) => pickAlias(current, 'lpmm')) + setConvertAlias((current) => pickAlias(current, 'lpmm')) + setConvertTargetAlias((current) => pickAlias(current, 'plugin_data')) + setBackfillAlias((current) => pickAlias(current, 'plugin_data')) + setPathResolveAlias((current) => pickAlias(current, 'raw')) + }, [importAliasKeys]) + + useEffect(() => { + const defaultFileConcurrency = String(importSettings.default_file_concurrency ?? '').trim() + const defaultChunkConcurrency = String(importSettings.default_chunk_concurrency ?? '').trim() + if (defaultFileConcurrency && importCommonFileConcurrency === '2') { + setImportCommonFileConcurrency(defaultFileConcurrency) + } + if (defaultChunkConcurrency && importCommonChunkConcurrency === '4') { + setImportCommonChunkConcurrency(defaultChunkConcurrency) + } + const defaultSourceDb = String(importSettings.maibot_source_db_default ?? '').trim() + if (defaultSourceDb && !maibotSourceDb.trim()) { + setMaibotSourceDb(defaultSourceDb) + } + }, [ + importCommonChunkConcurrency, + importCommonFileConcurrency, + importSettings.default_chunk_concurrency, + importSettings.default_file_concurrency, + importSettings.maibot_source_db_default, + maibotSourceDb, + ]) + + useEffect(() => { + if (!selectedImportTaskId && importTasks.length > 0) { + void selectImportTask(importTasks[0].task_id) + } + }, [importTasks, selectImportTask, selectedImportTaskId]) + + useEffect(() => { + if (!selectedImportTaskId) { + setSelectedImportTask(null) + setSelectedImportFileId('') + setImportChunksPayload(null) + return + } + if (!importTasks.some((task) => task.task_id === selectedImportTaskId) && importTasks.length > 0) { + void selectImportTask(importTasks[0].task_id) + return + } + void loadImportTaskDetail(selectedImportTaskId, true) + }, [importTasks, loadImportTaskDetail, selectImportTask, selectedImportTaskId]) + + useEffect(() => { + if (!importAutoPolling) { + return + } + const timerId = window.setInterval(() => { + void refreshImportQueue(true) + if (selectedImportTaskId) { + void loadImportTaskDetail(selectedImportTaskId, true) + } + }, importPollInterval) + return () => { + window.clearInterval(timerId) + } + }, [importAutoPolling, importPollInterval, loadImportTaskDetail, refreshImportQueue, selectedImportTaskId]) + const filteredSources = useMemo(() => { const keyword = sourceSearch.trim().toLowerCase() if (!keyword) { @@ -450,7 +1393,7 @@ export function KnowledgeBasePage() { if (selectedSources.length <= 0) { toast({ title: '请选择来源', - description: '至少选择一个来源后再进行删除预览。', + description: '至少选择一个来源后再进行删除预览', variant: 'destructive', }) return @@ -462,7 +1405,7 @@ export function KnowledgeBasePage() { requested_by: 'knowledge_base', } setDeleteDialogTitle('批量删除来源') - setDeleteDialogDescription('删除来源只会删除该来源下的段落,以及失去全部证据的关系,不会自动删除实体。') + setDeleteDialogDescription('删除来源只会删除该来源下的段落,以及失去全部证据的关系,不会自动删除实体') setPendingDeleteRequest(request) setDeletePreview(null) setDeleteResult(null) @@ -619,8 +1562,10 @@ export function KnowledgeBasePage() { ]) setVisualConfig(nextConfig.config) setRawConfig(nextRaw.config) + setRawConfigExists(nextRaw.exists ?? true) + setRawConfigUsingDefault(nextRaw.using_default ?? false) setRuntimeConfig(nextRuntime) - toast({ title: '配置已保存', description: '长期记忆配置已经应用到运行时。' }) + toast({ title: '配置已保存', description: '长期记忆配置已经应用到运行时' }) } catch (error) { toast({ title: '保存配置失败', @@ -636,10 +1581,17 @@ export function KnowledgeBasePage() { try { setSaving(true) await updateMemoryConfigRaw(rawConfig) - const [nextConfig, nextRuntime] = await Promise.all([getMemoryConfig(), getMemoryRuntimeConfig()]) + const [nextConfig, nextRaw, nextRuntime] = await Promise.all([ + getMemoryConfig(), + getMemoryConfigRaw(), + getMemoryRuntimeConfig(), + ]) setVisualConfig(nextConfig.config) + setRawConfig(nextRaw.config ?? '') + setRawConfigExists(nextRaw.exists ?? true) + setRawConfigUsingDefault(nextRaw.using_default ?? false) setRuntimeConfig(nextRuntime) - toast({ title: '原始 TOML 已保存', description: '长期记忆配置已经重新加载。' }) + toast({ title: '原始 TOML 已保存', description: '长期记忆配置已经重新加载' }) } catch (error) { toast({ title: '保存原始配置失败', @@ -660,7 +1612,7 @@ export function KnowledgeBasePage() { setRuntimeConfig(nextRuntime) toast({ title: payload.success ? '自检通过' : '自检未通过', - description: payload.success ? '运行时状态正常。' : '请检查 embedding 配置和外部服务连通性。', + description: payload.success ? '运行时状态正常' : '请检查 embedding 配置和外部服务连通性', variant: payload.success ? 'default' : 'destructive', }) } catch (error) { @@ -674,29 +1626,46 @@ export function KnowledgeBasePage() { } }, [toast]) - const submitPasteImport = useCallback(async () => { - try { - setCreatingImport(true) - await createMemoryPasteImport({ - name: pasteName || undefined, - content: pasteContent, - input_mode: pasteMode, - }) - const tasks = await getMemoryImportTasks(20) - setImportTasks(tasks.items ?? []) - setPasteContent('') - setPasteName('') - toast({ title: '导入任务已创建', description: '粘贴内容已经进入 A_Memorix 导入队列。' }) - } catch (error) { - toast({ - title: '创建导入任务失败', - description: error instanceof Error ? error.message : '未知错误', - variant: 'destructive', - }) - } finally { - setCreatingImport(false) + const submitImportByMode = useCallback(async () => { + if (creatingImport) { + return } - }, [pasteContent, pasteMode, pasteName, toast]) + switch (importCreateMode) { + case 'upload': + await submitUploadImport() + break + case 'paste': + await submitPasteImport() + break + case 'raw_scan': + await submitRawScanImport() + break + case 'lpmm_openie': + await submitOpenieImport() + break + case 'lpmm_convert': + await submitConvertImport() + break + case 'temporal_backfill': + await submitBackfillImport() + break + case 'maibot_migration': + await submitMaibotMigrationImport() + break + default: + break + } + }, [ + creatingImport, + importCreateMode, + submitBackfillImport, + submitConvertImport, + submitMaibotMigrationImport, + submitOpenieImport, + submitPasteImport, + submitRawScanImport, + submitUploadImport, + ]) const submitTuningTask = useCallback(async () => { try { @@ -709,7 +1678,7 @@ export function KnowledgeBasePage() { }) const tasks = await getMemoryTuningTasks(20) setTuningTasks(tasks.items ?? []) - toast({ title: '调优任务已创建', description: '新的检索调优任务已经进入队列。' }) + toast({ title: '调优任务已创建', description: '新的检索调优任务已经进入队列' }) } catch (error) { toast({ title: '创建调优任务失败', @@ -733,7 +1702,7 @@ export function KnowledgeBasePage() { setTuningProfileToml(profilePayload.toml ?? '') setRuntimeConfig(runtimePayload) setTuningTasks(tuningTaskPayload.items ?? []) - toast({ title: '最佳参数已应用', description: `任务 ${taskId} 的最佳轮次已经写入运行时。` }) + toast({ title: '最佳参数已应用', description: `任务 ${taskId} 的最佳轮次已经写入运行时` }) } catch (error) { toast({ title: '应用最佳参数失败', @@ -760,7 +1729,7 @@ export function KnowledgeBasePage() {

长期记忆控制台

- 统一管理 A_Memorix 的配置、自检、导入和检索调优,替代旧 LPMM 知识库管理入口。 + A_Memorix 的配置、自检、导入和检索调优,都在这里!

@@ -778,7 +1747,7 @@ export function KnowledgeBasePage() {
-
+
{runtimeBadges.map((item) => ( @@ -790,13 +1759,23 @@ export function KnowledgeBasePage() { ))}
- - - 概览 - 配置 - 导入 - 调优 - 删除 + + + + 概览 + + + 配置 + + + 导入 + + + 调优 + + + 删除 + @@ -808,7 +1787,7 @@ export function KnowledgeBasePage() { 运行时自检 - 用于确认 embedding、向量库与运行时状态是否一致。 + 用于确认 embedding、向量库与运行时状态是否一致