407 lines
15 KiB
JavaScript
407 lines
15 KiB
JavaScript
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)
|
|
})
|