diff --git a/.gitignore b/.gitignore index 093ba248..ac2a837a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,10 @@ data/ +!pytests/A_memorix_test/data/ +!pytests/A_memorix_test/data/benchmarks/ +!pytests/A_memorix_test/data/benchmarks/long_novel_memory_benchmark.json +!pytests/A_memorix_test/data/real_dialogues/ +!pytests/A_memorix_test/data/real_dialogues/private_alice_weekend.json +pytests/A_memorix_test/data/benchmarks/results/ data1/ mai_knowledge/knowledge.json mongodb/ @@ -343,6 +349,7 @@ run_pet.bat /plugins/* !/plugins +!/plugins/A_memorix !/plugins/hello_world_plugin !/plugins/emoji_manage_plugin !/plugins/take_picture_plugin @@ -364,3 +371,4 @@ packages/ ## Claude Code and OMC data .claude/ .omc/ +/.venv312 diff --git a/dashboard/scripts/a_memorix_electron_validate.cjs b/dashboard/scripts/a_memorix_electron_validate.cjs new file mode 100644 index 00000000..1d5de90e --- /dev/null +++ b/dashboard/scripts/a_memorix_electron_validate.cjs @@ -0,0 +1,406 @@ +const { app, BrowserWindow } = require('electron') +const fs = require('fs') +const path = require('path') + +const DASHBOARD_URL = process.env.MAIBOT_DASHBOARD_URL || 'http://127.0.0.1:7999' +const OUTPUT_DIR = process.env.MAIBOT_UI_SNAPSHOT_DIR + || path.resolve(__dirname, '..', '..', 'tmp', 'ui-snapshots', 'a_memorix-electron') +const TOKEN_PATH = process.env.MAIBOT_WEBUI_TOKEN_PATH + || path.resolve(__dirname, '..', '..', 'data', 'webui.json') +const sampleStamp = String(Date.now()) +const sampleSource = process.env.MAIBOT_UI_SAMPLE_SOURCE || `webui-demo:a_memorix-json-${sampleStamp}` +const sampleName = process.env.MAIBOT_UI_SAMPLE_NAME || `webui-json-validation-${sampleStamp}.json` + +const DEFAULT_SAMPLE = { + paragraphs: [ + { + content: 'Alice 在杭州西湖与 Bob 讨论 A_Memorix 的前端接入与 embedding 调优方案。', + source: sampleSource, + entities: ['Alice', 'Bob', '杭州西湖', 'A_Memorix'], + relations: [ + { subject: 'Alice', predicate: '在', object: '杭州西湖' }, + { subject: 'Alice', predicate: '讨论', object: 'A_Memorix' }, + { subject: 'Bob', predicate: '讨论', object: 'A_Memorix' }, + { subject: 'Bob', predicate: '负责', object: 'embedding 调优' }, + ], + knowledge_type: 'factual', + }, + ], + entities: ['Alice', 'Bob', '杭州西湖', 'A_Memorix', 'embedding 调优'], + relations: [{ subject: 'Alice', predicate: '认识', object: 'Bob' }], +} + +function loadSampleJson() { + const customPath = String(process.env.MAIBOT_UI_IMPORT_JSON_PATH || '').trim() + if (!customPath) { + return JSON.stringify(DEFAULT_SAMPLE, null, 2) + } + return fs.readFileSync(customPath, 'utf8') +} + +const sampleJson = loadSampleJson() + +fs.mkdirSync(OUTPUT_DIR, { recursive: true }) + +function wait(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +async function exec(win, code) { + return win.webContents.executeJavaScript(code, true) +} + +async function waitFor(win, predicateCode, label, timeout = 30000, interval = 300) { + const start = Date.now() + while (Date.now() - start < timeout) { + try { + const ok = await exec(win, predicateCode) + if (ok) { + return ok + } + } catch { + // keep polling + } + await wait(interval) + } + throw new Error(`Timeout waiting for ${label}`) +} + +async function sendClick(win, x, y) { + win.webContents.sendInputEvent({ type: 'mouseMove', x, y, movementX: 0, movementY: 0 }) + win.webContents.sendInputEvent({ type: 'mouseDown', x, y, button: 'left', clickCount: 1 }) + win.webContents.sendInputEvent({ type: 'mouseUp', x, y, button: 'left', clickCount: 1 }) +} + +async function capture(win, name) { + const image = await win.webContents.capturePage() + fs.writeFileSync(path.join(OUTPUT_DIR, name), image.toPNG()) + const text = await exec(win, 'document.body ? document.body.innerText : ""') + fs.writeFileSync(path.join(OUTPUT_DIR, name.replace(/\.png$/, '.txt')), text || '') +} + +async function getJson(win, relativePath) { + return exec( + win, + `fetch(${JSON.stringify(relativePath)}, { credentials: 'include' }).then((r) => r.json())`, + ) +} + +async function setSessionCookie(win) { + const raw = fs.readFileSync(TOKEN_PATH, 'utf8') + const config = JSON.parse(raw) + const token = String(config.access_token || '').trim() + if (!token) { + throw new Error(`No access token found in ${TOKEN_PATH}`) + } + const payload = await exec( + win, + `fetch('/api/webui/auth/verify', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ token: ${JSON.stringify(token)} }), + }).then(async (response) => ({ + ok: response.ok, + status: response.status, + body: await response.json(), + }))`, + ) + if (!payload?.ok || !payload?.body?.valid) { + throw new Error(`Failed to authenticate WebUI token via /auth/verify: ${JSON.stringify(payload)}`) + } +} + +async function openImportTab(win) { + await exec(win, `(() => { + const tab = Array.from(document.querySelectorAll('[role="tab"]')).find((el) => (el.textContent || '').trim() === '导入') + if (!tab) return false + tab.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, cancelable: true, pointerId: 1, button: 0, pointerType: 'mouse', isPrimary: true })) + tab.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true, button: 0 })) + tab.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true, button: 0 })) + tab.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, button: 0 })) + return true + })()`) + await waitFor( + win, + `document.body && document.body.innerText.includes('粘贴导入') && document.body.innerText.includes('创建导入任务')`, + 'import panel', + ) +} + +async function setJsonMode(win) { + const trigger = await exec(win, `(() => { + const label = Array.from(document.querySelectorAll('label')).find((node) => (node.textContent || '').includes('输入模式')) + const root = label?.closest('div')?.parentElement || label?.parentElement + const button = root?.querySelector('button') + if (!button) return null + const rect = button.getBoundingClientRect() + return { x: Math.round(rect.left + rect.width / 2), y: Math.round(rect.top + rect.height / 2) } + })()`) + if (!trigger) { + throw new Error('select trigger not found') + } + await sendClick(win, trigger.x, trigger.y) + await waitFor(win, `document.querySelectorAll('[role="option"]').length > 0`, 'select options', 5000, 200) + + const option = await exec(win, `(() => { + const item = Array.from(document.querySelectorAll('[role="option"]')).find((el) => (el.textContent || '').trim() === 'json') + if (!item) return null + const rect = item.getBoundingClientRect() + return { x: Math.round(rect.left + rect.width / 2), y: Math.round(rect.top + rect.height / 2) } + })()`) + if (!option) { + throw new Error('json option not found') + } + await sendClick(win, option.x, option.y) + await waitFor( + win, + `(() => { + const label = Array.from(document.querySelectorAll('label')).find((node) => (node.textContent || '').includes('输入模式')) + const root = label?.closest('div')?.parentElement || label?.parentElement + const button = root?.querySelector('button') + return (button?.textContent || '').trim() === 'json' + })()`, + 'json mode selected', + 8000, + 300, + ) +} + +async function typeIntoLabeled(win, labelText, selector, text) { + const rect = await exec(win, `(() => { + const label = Array.from(document.querySelectorAll('label')).find((node) => (node.textContent || '').includes(${JSON.stringify(labelText)})) + const root = label?.closest('div')?.parentElement || label?.parentElement + const el = root?.querySelector(${JSON.stringify(selector)}) + if (!el) return null + const r = el.getBoundingClientRect() + return { x: Math.round(r.left + 20), y: Math.round(r.top + 20) } + })()`) + if (!rect) { + throw new Error(`field not found: ${labelText}`) + } + await sendClick(win, rect.x, rect.y) + await wait(150) + await win.webContents.insertText(text) + await wait(250) +} + +async function clickButton(win, text) { + const ok = await exec(win, `(() => { + const target = Array.from(document.querySelectorAll('button')).find((el) => (el.textContent || '').includes(${JSON.stringify(text)})) + if (!target) return false + target.scrollIntoView({ block: 'center' }) + target.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, cancelable: true, pointerId: 1, button: 0, pointerType: 'mouse', isPrimary: true })) + target.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true, button: 0 })) + target.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true, button: 0 })) + target.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, button: 0 })) + return true + })()`) + if (!ok) { + throw new Error(`button not found: ${text}`) + } +} + +async function clickTab(win, text) { + const ok = await exec(win, `(() => { + const target = Array.from(document.querySelectorAll('[role="tab"]')).find((el) => (el.textContent || '').includes(${JSON.stringify(text)})) + if (!target) return false + target.scrollIntoView({ block: 'center' }) + target.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, cancelable: true, pointerId: 1, button: 0, pointerType: 'mouse', isPrimary: true })) + target.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true, button: 0 })) + target.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true, button: 0 })) + target.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, button: 0 })) + return true + })()`) + if (!ok) { + throw new Error(`tab not found: ${text}`) + } +} + +async function clickGraphElement(win, selector, index = 0) { + const rect = await exec(win, `(() => { + const targets = Array.from(document.querySelectorAll(${JSON.stringify(selector)})) + const target = targets[${index}] + if (!target) return null + target.scrollIntoView({ block: 'center', inline: 'center' }) + const r = target.getBoundingClientRect() + return { x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2) } + })()`) + if (!rect) { + throw new Error(`graph element not found: ${selector}[${index}]`) + } + await sendClick(win, rect.x, rect.y) +} + +async function capturePluginFilterState(win) { + await win.loadURL(`${DASHBOARD_URL}/plugin-config`) + await waitFor( + win, + `document.body && document.body.innerText.includes('插件配置') && document.querySelector('input[placeholder="搜索插件..."]')`, + 'plugin config page', + 30000, + 400, + ) + await exec(win, `(() => { + const input = document.querySelector('input[placeholder="搜索插件..."]') + if (!input) return false + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set + setter?.call(input, 'memorix') + input.dispatchEvent(new Event('input', { bubbles: true })) + input.dispatchEvent(new Event('change', { bubbles: true })) + return true + })()`) + await wait(500) + await capture(win, '01-plugin-config-filtered.png') +} + +app.whenReady().then(async () => { + const win = new BrowserWindow({ + width: 1600, + height: 1200, + show: false, + webPreferences: { + contextIsolation: true, + nodeIntegration: false, + }, + }) + + await win.loadURL(`${DASHBOARD_URL}/auth`) + await waitFor(win, `document.readyState === 'complete'`, 'auth page') + await capture(win, '00-auth-login.png') + await setSessionCookie(win) + + await capturePluginFilterState(win) + + await win.loadURL(`${DASHBOARD_URL}/resource/knowledge-base`) + await waitFor( + win, + `document.body && document.body.innerText.includes('运行时自检') && document.body.innerText.includes('刷新数据')`, + 'memory console ready', + 30000, + 500, + ) + await capture(win, '02-memory-console-before-import.png') + + const beforeGraph = await getJson(win, '/api/webui/memory/graph?limit=120') + const beforeTasks = await getJson(win, '/api/webui/memory/import/tasks?limit=20') + const knownTaskIds = new Set( + Array.isArray(beforeTasks.items) + ? beforeTasks.items.map((item) => String(item.task_id || item.taskId || '')) + : [], + ) + + await openImportTab(win) + await setJsonMode(win) + await typeIntoLabeled(win, '名称', 'input', sampleName) + await typeIntoLabeled(win, '粘贴内容', 'textarea', sampleJson) + await capture(win, '03-memory-import-json-filled.png') + + await clickButton(win, '创建导入任务') + + let taskId = null + let taskStatus = null + const start = Date.now() + while (Date.now() - start < 120000) { + const payload = await getJson(win, '/api/webui/memory/import/tasks?limit=20') + fs.writeFileSync(path.join(OUTPUT_DIR, 'tasks-last.json'), JSON.stringify(payload, null, 2)) + const items = Array.isArray(payload.items) ? payload.items : [] + const task = items.find((item) => !knownTaskIds.has(String(item.task_id || item.taskId || ''))) + if (task) { + taskId = task.task_id || task.taskId || null + taskStatus = task.status || null + if (['completed', 'failed', 'cancelled'].includes(String(taskStatus))) { + break + } + } + await wait(1500) + } + + if (!taskId) { + throw new Error('new json import task not observed') + } + + const detail = await getJson( + win, + `/api/webui/memory/import/tasks/${encodeURIComponent(taskId)}?include_chunks=true`, + ) + fs.writeFileSync(path.join(OUTPUT_DIR, 'task-detail.json'), JSON.stringify(detail, null, 2)) + fs.writeFileSync( + path.join(OUTPUT_DIR, 'task-status.txt'), + `taskId=${taskId}\nstatus=${taskStatus}\nsource=${sampleSource}\n`, + ) + + await clickButton(win, '刷新数据') + await wait(2000) + await capture(win, '04-memory-console-after-import.png') + + await win.loadURL(`${DASHBOARD_URL}/resource/knowledge-graph`) + await waitFor( + win, + `document.body && document.body.innerText.includes('长期记忆图谱') && document.body.innerText.includes('实体关系图') && document.body.innerText.includes('证据视图')`, + 'graph page ready', + 30000, + 400, + ) + await wait(3000) + const afterGraph = await getJson(win, '/api/webui/memory/graph?limit=120') + fs.writeFileSync(path.join(OUTPUT_DIR, 'graph-after.json'), JSON.stringify(afterGraph, null, 2)) + await capture(win, '05-memory-graph-after-import.png') + + if (Array.isArray(afterGraph.nodes) && afterGraph.nodes.length > 0) { + await clickGraphElement(win, '.react-flow__node', 0) + await waitFor(win, `document.body && document.body.innerText.includes('实体详情')`, 'node detail dialog', 10000, 250) + await capture(win, '06-memory-node-detail.png') + try { + await clickButton(win, '切到证据视图') + await waitFor( + win, + `document.body && document.body.innerText.includes('证据视图') && document.querySelectorAll('.react-flow__node').length > 0`, + 'evidence graph after node click', + 10000, + 250, + ) + await capture(win, '07-memory-evidence-view.png') + } catch (error) { + fs.writeFileSync(path.join(OUTPUT_DIR, '07-memory-evidence-view-error.txt'), String(error?.stack || error)) + } + } + + if (Array.isArray(afterGraph.edges) && afterGraph.edges.length > 0) { + try { + await clickTab(win, '实体关系图') + await wait(800) + await clickGraphElement(win, '.react-flow__edge', 0) + await waitFor(win, `document.body && document.body.innerText.includes('关系详情')`, 'edge detail dialog', 10000, 250) + await capture(win, '08-memory-edge-detail.png') + } catch (error) { + fs.writeFileSync(path.join(OUTPUT_DIR, '08-memory-edge-detail-error.txt'), String(error?.stack || error)) + } + } + + const summary = { + before: { + nodes: beforeGraph.total_nodes, + edges: beforeGraph.total_edges, + }, + after: { + nodes: afterGraph.total_nodes, + edges: afterGraph.total_edges, + }, + taskId, + taskStatus, + source: sampleSource, + inputMode: detail?.task?.files?.[0]?.input_mode || null, + strategyType: detail?.task?.files?.[0]?.detected_strategy_type || null, + fileStatus: detail?.task?.files?.[0]?.status || null, + outputDir: OUTPUT_DIR, + } + fs.writeFileSync(path.join(OUTPUT_DIR, 'validation-summary.json'), JSON.stringify(summary, null, 2)) + console.log(JSON.stringify(summary, null, 2)) + + await win.close() + app.quit() +}).catch((error) => { + console.error(error) + app.exit(1) +}) diff --git a/dashboard/src/components/CodeEditor.tsx b/dashboard/src/components/CodeEditor.tsx index 00438655..ae928f90 100644 --- a/dashboard/src/components/CodeEditor.tsx +++ b/dashboard/src/components/CodeEditor.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react' import CodeMirror from '@uiw/react-codemirror' import { css } from '@codemirror/lang-css' import { json, jsonParseLinter } from '@codemirror/lang-json' +import { linter } from '@codemirror/lint' import { python } from '@codemirror/lang-python' import { oneDark } from '@codemirror/theme-one-dark' import { EditorView } from '@codemirror/view' @@ -29,7 +30,7 @@ interface CodeEditorProps { // eslint-disable-next-line @typescript-eslint/no-explicit-any const languageExtensions: Record = { python: [python()], - json: [json(), jsonParseLinter()], + json: [json(), linter(jsonParseLinter())], toml: [StreamLanguage.define(tomlMode)], css: [css()], text: [], diff --git a/dashboard/src/components/memory/MemoryConfigEditor.tsx b/dashboard/src/components/memory/MemoryConfigEditor.tsx new file mode 100644 index 00000000..d9cec1c2 --- /dev/null +++ b/dashboard/src/components/memory/MemoryConfigEditor.tsx @@ -0,0 +1,311 @@ +import { useMemo, useState } from 'react' + +import { ListFieldEditor } from '@/components' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Switch } from '@/components/ui/switch' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { Textarea } from '@/components/ui/textarea' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import type { ConfigFieldSchema, PluginConfigSchema } from '@/lib/plugin-api' + +interface MemoryConfigEditorProps { + schema: PluginConfigSchema + config: Record + onChange: (nextConfig: Record) => void + disabled?: boolean +} + +function getNestedRecord(config: Record, path: string): Record | undefined { + const parts = path.split('.').filter(Boolean) + let current: unknown = config + + for (const part of parts) { + if (!current || typeof current !== 'object' || Array.isArray(current)) { + return undefined + } + current = (current as Record)[part] + } + + if (!current || typeof current !== 'object' || Array.isArray(current)) { + return undefined + } + + return current as Record +} + +function setNestedField( + config: Record, + path: string, + fieldName: string, + value: unknown, +): Record { + const parts = path.split('.').filter(Boolean) + const nextConfig: Record = { ...config } + let target = nextConfig + let source: Record | undefined = config + + for (const part of parts) { + const sourceValue: unknown = source?.[part] + const nextValue = + sourceValue && typeof sourceValue === 'object' && !Array.isArray(sourceValue) + ? { ...(sourceValue as Record) } + : {} + target[part] = nextValue + target = nextValue + source = + sourceValue && typeof sourceValue === 'object' && !Array.isArray(sourceValue) + ? (sourceValue as Record) + : undefined + } + + target[fieldName] = value + return nextConfig +} + +function FieldRenderer({ + field, + value, + onChange, + disabled, +}: { + field: ConfigFieldSchema + value: unknown + onChange: (value: unknown) => void + disabled?: boolean +}) { + const [jsonDraft, setJsonDraft] = useState( + typeof value === 'string' ? String(value) : JSON.stringify(value ?? field.default ?? {}, null, 2), + ) + + switch (field.ui_type) { + case 'switch': + return ( +
+
+ + {field.hint &&

{field.hint}

} +
+ +
+ ) + + case 'number': + return ( +
+ + onChange(Number(event.target.value))} + min={field.min} + max={field.max} + step={field.step ?? 1} + disabled={disabled || field.disabled} + placeholder={field.placeholder} + /> + {field.hint &&

{field.hint}

} +
+ ) + + case 'select': + return ( +
+ + + {field.hint &&

{field.hint}

} +
+ ) + + case 'textarea': + return ( +
+ + +
+ +
+
+ +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+ +
+
+ +
+
+ +
+
+
+ + +
+
+ + +
+
+ +
+ +
+
+ +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
将执行 staging 转换与切换,请确认输入目录正确。
+
+ +
+
+ +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+ + +
+
+ +
+
+ +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + + + +
+
+ +
+
+ + + +
+
任务队列轮询: 1000ms
+
+
+ + + +
+
运行中 / 准备中
+
+
排队中
+
+
最近完成
+
+
+
+ + +
+
+
任务详情
+
+
请选择任务查看详情
+ +
+
+ +
+
文件级状态
+
+ + + +
文件类型状态步骤进度统计
+
+
+ +
+
分块级状态
+
+ + + +
#类型状态步骤预览错误
+
+
0 / 0
+
+ + +
+
+
+
+
+ + + + + + diff --git a/src/A_memorix/web/index.html b/src/A_memorix/web/index.html new file mode 100644 index 00000000..36402286 --- /dev/null +++ b/src/A_memorix/web/index.html @@ -0,0 +1,3136 @@ + + + + + + A_Memorix | 知识全景图 + + + + + + + + + + + + +
+
+

正在同步全景知识图谱...

+

+ 初次使用?请在加载完成后点击操作指南了解基础操作 +

+
+ + +
+ + +
+ +
+
+ + +
+
+
🔄
+ 同步状态 +
+
+
📐
+ 重排布局 +
+
+
⏸️
+ 暂停模拟 +
+
+
📖
+ 内容字典 +
+
+
♻️
+ 回收站 +
+
+
+
+ 新增节点 +
+
+
📂
+ 记忆溯源 +
+
+
📥
+ 导入中心 +
+
+
🎯
+ 检索调优 +
+
+
👤
+ 人物画像 +
+
+
💾
+ 持久化 +
+
+
+
⚙️
+ 视图配置 +
+
+
+
+ 操作指南 +
+
+ + + + + +
+
+

属性信息

+
+
+
+ +
+
+ + +