Files
BaiLongma/scripts/smoke-brain-ui.mjs

295 lines
12 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import http from 'http'
import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
import { chromium } from 'playwright'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const root = path.resolve(__dirname, '..')
const brainUiRoot = path.join(root, 'src', 'ui', 'brain-ui')
function contentTypeFor(filePath) {
switch (path.extname(filePath).toLowerCase()) {
case '.html': return 'text/html; charset=utf-8'
case '.js': return 'text/javascript; charset=utf-8'
case '.css': return 'text/css; charset=utf-8'
case '.json': return 'application/json; charset=utf-8'
default: return 'text/plain; charset=utf-8'
}
}
function sendJson(res, body) {
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' })
res.end(JSON.stringify(body))
}
function isPathInside(parentDir, candidatePath) {
const parent = path.resolve(parentDir)
const candidate = path.resolve(candidatePath)
const relative = path.relative(parent, candidate)
return relative === '' || (!!relative && !relative.startsWith('..') && !path.isAbsolute(relative))
}
function sendFile(res, filePath) {
try {
const stat = fs.statSync(filePath)
if (!stat.isFile()) throw new Error('not a file')
res.writeHead(200, {
'Content-Type': contentTypeFor(filePath),
'Content-Length': stat.size,
'Cache-Control': 'no-cache',
})
fs.createReadStream(filePath).pipe(res)
} catch {
res.writeHead(404)
res.end('not found')
}
}
function createServer() {
const sseClients = new Set()
const server = http.createServer((req, res) => {
const url = new URL(req.url, 'http://127.0.0.1')
if (url.pathname === '/brain-ui' || url.pathname === '/brain-ui.html' || url.pathname === '/') {
sendFile(res, path.join(root, 'brain-ui.html'))
return
}
if (url.pathname === '/vendor/d3/d3.min.js') {
sendFile(res, path.join(root, 'node_modules', 'd3', 'dist', 'd3.min.js'))
return
}
if (url.pathname.startsWith('/src/ui/brain-ui/')) {
const relativePath = decodeURIComponent(url.pathname.slice('/src/ui/brain-ui/'.length))
const assetPath = path.resolve(brainUiRoot, relativePath)
if (!isPathInside(brainUiRoot, assetPath)) {
res.writeHead(403)
res.end('forbidden')
return
}
sendFile(res, assetPath)
return
}
if (url.pathname === '/agent-profile') {
sendJson(res, { name: 'SmokeLongma' })
return
}
if (url.pathname === '/memories') {
sendJson(res, [
{ id: 1, mem_id: 'm1', type: 'fact', content: 'Alpha memory', detail: 'First smoke node', created_at: new Date().toISOString() },
{ id: 2, mem_id: 'm2', type: 'preference', content: 'Beta memory', detail: 'Second smoke node', created_at: new Date().toISOString() },
])
return
}
if (url.pathname === '/conversations') {
sendJson(res, [])
return
}
if (url.pathname === '/settings') {
sendJson(res, {
llm: { activated: true, provider: 'deepseek', model: 'smoke', models: [{ id: 'smoke', label: 'Smoke' }] },
providers: { deepseek: { models: [{ id: 'smoke', label: 'Smoke' }] } },
minimax: { configured: false },
})
return
}
if (url.pathname === '/settings/tts') {
sendJson(res, {
ok: true,
tts: { ttsProvider: 'minimax', ttsVoiceId: 'male-qn-qingse' },
providers: [{ id: 'minimax', label: 'MiniMax', streaming: false }],
voices: { minimax: [{ id: 'male-qn-qingse', label: '青涩男声' }] },
})
return
}
if (url.pathname === '/hotspots') {
sendJson(res, {
ok: true,
refreshMinutes: 30,
fetchedAt: new Date().toISOString(),
stale: false,
platforms: {
douyin: [
{ rank: 1, title: 'Smoke 热点一', heat: '100万', trend: 'same', isNew: false, source: 'smoke' },
{ rank: 2, title: 'Smoke 热点二', heat: '80万', trend: 'same', isNew: true, source: 'smoke' },
],
},
})
return
}
if (url.pathname === '/person-card') {
const name = url.searchParams.get('name') || ''
if (name.includes('马云')) {
sendJson(res, {
ok: true,
card: {
name: '马云',
title: '人物卡片',
summary: '暂时没有内置资料。可以让 Longma 补充身份、代表作品和为什么被提到。',
knownFor: [],
tags: ['待补充'],
image: 'data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 640 360%22%3E%3Crect width=%22640%22 height=%22360%22 fill=%22%23112332%22/%3E%3Ccircle cx=%22320%22 cy=%22130%22 r=%2260%22 fill=%22%2382d2ff%22/%3E%3Crect x=%22205%22 y=%22210%22 width=%22230%22 height=%2280%22 rx=%2240%22 fill=%22%2382d2ff%22/%3E%3C/svg%3E',
source: 'fallback',
updatedAt: new Date().toISOString(),
},
})
return
}
sendJson(res, {
ok: true,
card: {
name: '周杰伦',
title: '歌手 / 音乐人',
summary: '华语流行音乐代表人物之一。',
knownFor: ['七里香', '青花瓷'],
tags: ['华语音乐', '创作歌手'],
image: 'data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 640 360%22%3E%3Crect width=%22640%22 height=%22360%22 fill=%22%23112332%22/%3E%3Ccircle cx=%22320%22 cy=%22130%22 r=%2260%22 fill=%22%2382d2ff%22/%3E%3Crect x=%22205%22 y=%22210%22 width=%22230%22 height=%2280%22 rx=%2240%22 fill=%22%2382d2ff%22/%3E%3C/svg%3E',
source: 'smoke',
updatedAt: new Date().toISOString(),
},
})
return
}
if (url.pathname === '/person-card-state') {
sendJson(res, { ok: true, state: { active: true } })
return
}
if (url.pathname === '/social/wechat-clawbot/qr') {
sendJson(res, { ok: true, qr: null, status: 'unavailable' })
return
}
if (url.pathname === '/events') {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
})
res.write(`data: ${JSON.stringify({ type: 'connected', data: {}, ts: new Date().toISOString() })}\n\n`)
sseClients.add(res)
req.on('close', () => sseClients.delete(res))
return
}
if (url.pathname === '/message') {
sendJson(res, { ok: true })
return
}
res.writeHead(404)
res.end('not found')
})
server.closeAllSse = () => {
for (const client of sseClients) {
try { client.end() } catch {}
}
sseClients.clear()
}
server.emitSse = (event) => {
for (const client of sseClients) {
try { client.write(`data: ${JSON.stringify(event)}\n\n`) } catch {}
}
}
return server
}
function listen(server) {
return new Promise((resolve, reject) => {
server.listen(0, '127.0.0.1', () => resolve(server.address().port))
server.on('error', reject)
})
}
const server = createServer()
const port = await listen(server)
const baseUrl = `http://127.0.0.1:${port}`
const browser = await chromium.launch()
const page = await browser.newPage({ viewport: { width: 1280, height: 840 } })
await page.addInitScript(() => {
localStorage.setItem('bailongma-memory-graph-enabled', 'true')
})
const errors = []
page.on('pageerror', err => errors.push(err.message))
page.on('console', msg => {
if (msg.text().includes('/acui') && msg.text().includes('WebSocket connection')) return
if (msg.text().includes('Failed to load resource: the server responded with a status of 404')) return
if (msg.type() === 'error') errors.push(msg.text())
})
page.on('response', response => {
if (response.status() >= 400) errors.push(`${response.status()} ${response.url()}`)
})
try {
const vendorResponse = await page.goto(`${baseUrl}/vendor/d3/d3.min.js`)
if (!vendorResponse?.ok()) throw new Error('local d3 vendor route failed')
await page.goto(`${baseUrl}/brain-ui`, { waitUntil: 'domcontentloaded' })
await page.waitForSelector('#graph circle', { timeout: 5000 })
await page.waitForFunction(() => window.d3 && document.querySelector('#agent-brand-name')?.textContent.includes('SmokeLongma'))
await page.fill('#msg-input', '马云是谁')
await page.click('#send-btn')
await page.waitForTimeout(300)
const appearedTooFast = await page.evaluate(() => document.body.classList.contains('person-card-mode'))
if (appearedTooFast) throw new Error('person card appeared before the intended reveal delay')
await page.waitForFunction(() => document.body.classList.contains('person-card-mode') && document.querySelector('#pc-name')?.textContent.includes('马云'))
const enteringSeen = await page.evaluate(() => document.querySelector('#person-card-panel')?.classList.contains('pc-entering'))
if (!enteringSeen) throw new Error('person card did not use the entering glitch state')
server.emitSse({
type: 'message',
data: {
from: 'consciousness',
content: '马云1964年生浙江杭州人阿里巴巴集团创始人曾任董事局主席创办了淘宝、支付宝多次成为中国首富。',
},
ts: new Date().toISOString(),
})
await page.waitForFunction(() => document.querySelector('#pc-summary')?.textContent.includes('阿里巴巴集团创始人'))
const snapshot = await page.evaluate(() => ({
d3: Boolean(window.d3),
nodes: document.querySelectorAll('#graph circle').length,
links: document.querySelectorAll('#graph line').length,
acuiHost: Boolean(document.getElementById('acui-host')),
personCard: document.querySelector('#pc-name')?.textContent || '',
personSummary: document.querySelector('#pc-summary')?.textContent || '',
personKnownFor: [...document.querySelectorAll('#pc-known-list li')].map(li => li.textContent).join(' / '),
personImage: !document.querySelector('#pc-hero-img')?.hidden,
closeHidden: getComputedStyle(document.querySelector('#pc-exit-btn')).opacity === '0',
brand: document.querySelector('#agent-brand-name')?.textContent || '',
}))
if (!snapshot.d3) throw new Error('d3 global missing')
if (snapshot.nodes < 2) throw new Error(`expected at least 2 graph nodes, saw ${snapshot.nodes}`)
if (!snapshot.acuiHost) throw new Error('ACUI host was not bootstrapped')
if (!snapshot.personCard.includes('马云')) throw new Error('person card did not render the requested person')
if (!snapshot.personSummary.includes('阿里巴巴集团创始人')) throw new Error('person card did not absorb assistant summary')
if (!snapshot.personKnownFor.includes('淘宝')) throw new Error('person card did not absorb assistant known-for items')
if (!snapshot.personImage) throw new Error('person card hero image was not visible')
if (!snapshot.closeHidden) throw new Error('person card close button should be hidden until hover')
await page.hover('.pc-card')
await page.waitForFunction(() => Number(getComputedStyle(document.querySelector('#pc-exit-btn')).opacity) > 0.5)
await page.click('#pc-exit-btn')
const leavingSeen = await page.waitForFunction(() => document.querySelector('#person-card-panel')?.classList.contains('pc-leaving'), null, { timeout: 1000 })
if (!leavingSeen) throw new Error('person card did not use the leaving glitch state')
await page.waitForFunction(() => !document.body.classList.contains('person-card-mode') && !document.querySelector('#person-card-panel')?.classList.contains('pc-visible'))
if (errors.length) throw new Error(`browser errors:\n${errors.join('\n')}`)
console.log('[PASS] brain-ui smoke')
console.log(JSON.stringify(snapshot, null, 2))
} finally {
await browser.close()
server.closeAllSse()
await new Promise(resolve => server.close(resolve))
}