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) })