feat: 白龙马数字意识框架 v2.2.0 - 3D意识空间、多技能协同、自我进化系统
This commit is contained in:
14
electron/focus-banner-preload.cjs
Normal file
14
electron/focus-banner-preload.cjs
Normal file
@@ -0,0 +1,14 @@
|
||||
const { contextBridge, ipcRenderer } = require('electron')
|
||||
|
||||
contextBridge.exposeInMainWorld('focusBanner', {
|
||||
close: () => ipcRenderer.send('focus-banner:close'),
|
||||
toggleTask: (idx, done) => ipcRenderer.send('focus-banner:toggle-task', { idx, done }),
|
||||
setExpanded: (expanded) => ipcRenderer.send('focus-banner:set-expanded', { expanded }),
|
||||
requestResize: () => ipcRenderer.send('focus-banner:request-resize'),
|
||||
onUpdate: (handler) => {
|
||||
ipcRenderer.on('focus-banner:update', (_e, data) => handler(data))
|
||||
},
|
||||
onConfig: (handler) => {
|
||||
ipcRenderer.on('focus-banner:config', (_e, cfg) => handler(cfg))
|
||||
},
|
||||
})
|
||||
499
electron/main.cjs
Normal file
499
electron/main.cjs
Normal file
@@ -0,0 +1,499 @@
|
||||
// Windows: 把控制台代码页切到 UTF-8,避免中文 stdout 显示为乱码
|
||||
if (process.platform === 'win32') {
|
||||
try {
|
||||
require('child_process').execSync('chcp 65001', { stdio: 'ignore', windowsHide: true })
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
const { app, BrowserWindow, shell, dialog, Menu, ipcMain, Tray, nativeImage } = require('electron')
|
||||
const path = require('path')
|
||||
const fs = require('fs')
|
||||
const net = require('net')
|
||||
const http = require('http')
|
||||
const { EventEmitter } = require('events')
|
||||
const { pathToFileURL } = require('url')
|
||||
const { autoUpdater } = require('electron-updater')
|
||||
|
||||
const IS_DEV = !app.isPackaged
|
||||
const WINDOWS_APP_USER_MODEL_ID = 'com.xiaoyuanda.bailongma'
|
||||
const USER_DIR = app.getPath('userData')
|
||||
const CODE_ROOT = app.getAppPath()
|
||||
const RESOURCE_ROOT = CODE_ROOT
|
||||
const BACKEND_ENTRY = path.join(CODE_ROOT, 'src', 'index.js')
|
||||
|
||||
// 持久化日志:把 console.* 镜像到 USER_DIR/logs/bailongma.log,
|
||||
// 安装版没有 stdout 的情况下,卡死/崩溃后还能 tail 这个文件复盘。
|
||||
// 简易 rotate:> 5MB 时把当前文件改名 .old(覆盖上一份 .old),下次写入重开。
|
||||
const LOG_DIR = path.join(USER_DIR, 'logs')
|
||||
const LOG_FILE = path.join(LOG_DIR, 'bailongma.log')
|
||||
const LOG_FILE_OLD = path.join(LOG_DIR, 'bailongma.old.log')
|
||||
const LOG_MAX_BYTES = 5 * 1024 * 1024
|
||||
try { fs.mkdirSync(LOG_DIR, { recursive: true }) } catch {}
|
||||
function rotateLogIfNeeded() {
|
||||
try {
|
||||
const stat = fs.statSync(LOG_FILE)
|
||||
if (stat.size > LOG_MAX_BYTES) {
|
||||
try { fs.rmSync(LOG_FILE_OLD, { force: true }) } catch {}
|
||||
try { fs.renameSync(LOG_FILE, LOG_FILE_OLD) } catch {}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
function writeLog(level, args) {
|
||||
let line
|
||||
try {
|
||||
line = args.map(a => {
|
||||
if (typeof a === 'string') return a
|
||||
if (a instanceof Error) return a.stack || a.message
|
||||
try { return JSON.stringify(a) } catch { return String(a) }
|
||||
}).join(' ')
|
||||
} catch { line = '[log-serialize-failed]' }
|
||||
const ts = new Date().toISOString()
|
||||
const out = `${ts} [${level}] ${line}\n`
|
||||
try { fs.appendFileSync(LOG_FILE, out) } catch {}
|
||||
}
|
||||
// Hijack 一次就够;后端 import 在同一进程,console.* 引用的是同一个 console 对象。
|
||||
// 把原始方法存起来,appendFile 失败时仍能输出到 stdout/stderr(开发模式可见)。
|
||||
;(function installLogHijack() {
|
||||
const levels = ['log', 'info', 'warn', 'error', 'debug']
|
||||
for (const level of levels) {
|
||||
const original = console[level]?.bind(console) || (() => {})
|
||||
console[level] = (...args) => {
|
||||
try { original(...args) } catch {}
|
||||
try {
|
||||
rotateLogIfNeeded()
|
||||
writeLog(level, args)
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
})()
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
console.error('[unhandledRejection]', reason instanceof Error ? (reason.stack || reason.message) : String(reason))
|
||||
})
|
||||
process.on('uncaughtException', (err) => {
|
||||
console.error('[uncaughtException]', err?.stack || err?.message || String(err))
|
||||
})
|
||||
console.log(`[main] Bailongma ${app.getVersion()} starting, logs → ${LOG_FILE}`)
|
||||
|
||||
let mainWindow = null
|
||||
let backendPort = 0
|
||||
let tray = null
|
||||
let focusBannerWindow = null
|
||||
|
||||
// 后端通过 global.focusBannerBridge 控制横幅窗口
|
||||
const focusBannerBridge = new EventEmitter()
|
||||
global.focusBannerBridge = focusBannerBridge
|
||||
global.bailongmaAppControl = {
|
||||
restart() {
|
||||
console.log('[main] restart requested')
|
||||
app.isQuiting = true
|
||||
app.relaunch()
|
||||
app.quit()
|
||||
},
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
app.setAppUserModelId(WINDOWS_APP_USER_MODEL_ID)
|
||||
}
|
||||
|
||||
function sendUpdaterStatus(payload = {}) {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return
|
||||
mainWindow.webContents.send('updater:status', {
|
||||
currentVersion: app.getVersion(),
|
||||
...payload,
|
||||
})
|
||||
}
|
||||
|
||||
async function bootstrapBackend(port) {
|
||||
process.env.BAILONGMA_USER_DIR ||= USER_DIR
|
||||
process.env.BAILONGMA_RESOURCES_DIR ||= RESOURCE_ROOT
|
||||
process.env.BAILONGMA_PORT = String(port)
|
||||
await import(pathToFileURL(BACKEND_ENTRY).href)
|
||||
}
|
||||
|
||||
const gotLock = app.requestSingleInstanceLock()
|
||||
if (!gotLock) {
|
||||
app.quit()
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
async function findFreePort(preferred = 3721) {
|
||||
for (const port of [preferred, 0]) {
|
||||
try {
|
||||
const actual = await new Promise((resolve, reject) => {
|
||||
const server = net.createServer()
|
||||
server.once('error', reject)
|
||||
server.listen(port, '127.0.0.1', () => {
|
||||
const address = server.address()
|
||||
server.close(() => resolve(address.port))
|
||||
})
|
||||
})
|
||||
return actual
|
||||
} catch {}
|
||||
}
|
||||
throw new Error('Unable to find a free local port')
|
||||
}
|
||||
|
||||
function waitForBackend(port, timeoutMs = 30000) {
|
||||
const startedAt = Date.now()
|
||||
const url = `http://127.0.0.1:${port}/activation-status`
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const tick = () => {
|
||||
if (Date.now() - startedAt > timeoutMs) {
|
||||
reject(new Error('Backend startup timed out'))
|
||||
return
|
||||
}
|
||||
|
||||
const req = http.get(url, res => {
|
||||
res.resume()
|
||||
resolve()
|
||||
})
|
||||
req.on('error', () => setTimeout(tick, 300))
|
||||
req.setTimeout(1500, () => {
|
||||
req.destroy()
|
||||
setTimeout(tick, 300)
|
||||
})
|
||||
}
|
||||
|
||||
tick()
|
||||
})
|
||||
}
|
||||
|
||||
async function createWindow() {
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1280,
|
||||
height: 840,
|
||||
minWidth: 900,
|
||||
minHeight: 600,
|
||||
backgroundColor: '#0b0b0e',
|
||||
title: 'Bailongma',
|
||||
icon: path.join(RESOURCE_ROOT, 'build', 'icon.png'),
|
||||
webPreferences: {
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
preload: path.join(__dirname, 'preload.cjs'),
|
||||
},
|
||||
})
|
||||
|
||||
// 授予麦克风权限(语音输入需要)
|
||||
mainWindow.webContents.session.setPermissionRequestHandler((webContents, permission, callback) => {
|
||||
if (permission === 'media') return callback(true)
|
||||
callback(false)
|
||||
})
|
||||
mainWindow.webContents.session.setPermissionCheckHandler((webContents, permission) => {
|
||||
if (permission === 'media') return true
|
||||
return false
|
||||
})
|
||||
|
||||
// 窗口级快捷键(不用 globalShortcut,避免劫持其他应用的 F11/Ctrl+R 等)
|
||||
// F12 → 切换 DevTools
|
||||
// F11 → 切换全屏
|
||||
// Ctrl+R → reload(仅 dev)
|
||||
mainWindow.webContents.on('before-input-event', (event, input) => {
|
||||
if (input.type !== 'keyDown') return
|
||||
if (input.key === 'F12') {
|
||||
mainWindow.webContents.toggleDevTools()
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
if (input.key === 'F11') {
|
||||
mainWindow.setFullScreen(!mainWindow.isFullScreen())
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
if (IS_DEV && (input.control || input.meta) && input.key.toLowerCase() === 'r') {
|
||||
mainWindow.webContents.reload()
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||
if (/^https?:\/\//i.test(url)) {
|
||||
shell.openExternal(url)
|
||||
return { action: 'deny' }
|
||||
}
|
||||
return { action: 'allow' }
|
||||
})
|
||||
|
||||
await mainWindow.loadURL(`http://127.0.0.1:${backendPort}/`)
|
||||
// 关闭主窗口时最小化到托盘,不退出
|
||||
mainWindow.on('close', (e) => {
|
||||
if (!app.isQuiting) {
|
||||
e.preventDefault()
|
||||
mainWindow.hide()
|
||||
}
|
||||
})
|
||||
|
||||
mainWindow.on('closed', () => {
|
||||
mainWindow = null
|
||||
})
|
||||
}
|
||||
|
||||
function setupTray() {
|
||||
const iconPath = path.join(RESOURCE_ROOT, 'build', 'icon.ico')
|
||||
tray = new Tray(nativeImage.createFromPath(iconPath))
|
||||
tray.setToolTip('Bailongma')
|
||||
|
||||
const contextMenu = Menu.buildFromTemplate([
|
||||
{
|
||||
label: '显示主界面',
|
||||
click: () => {
|
||||
if (mainWindow) {
|
||||
mainWindow.show()
|
||||
mainWindow.focus()
|
||||
}
|
||||
},
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
|
||||
{
|
||||
label: '3D 意识空间',
|
||||
click: () => {
|
||||
if (mainWindow) {
|
||||
mainWindow.loadURL(`http://127.0.0.1:`+port+`/consciousness-3d`)
|
||||
mainWindow.show()
|
||||
mainWindow.focus()
|
||||
}
|
||||
},
|
||||
},
|
||||
{ type: 'separator' }, label: '退出',
|
||||
click: () => {
|
||||
app.isQuiting = true
|
||||
app.quit()
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
tray.setContextMenu(contextMenu)
|
||||
tray.on('double-click', () => {
|
||||
if (mainWindow) {
|
||||
mainWindow.show()
|
||||
mainWindow.focus()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function createFocusBannerWindow({ task = '', current_step = '', tasks = [] } = {}) {
|
||||
if (focusBannerWindow && !focusBannerWindow.isDestroyed()) {
|
||||
focusBannerWindow.webContents.send('focus-banner:update', { task, current_step, tasks })
|
||||
return
|
||||
}
|
||||
|
||||
const { width: screenW } = require('electron').screen.getPrimaryDisplay().workAreaSize
|
||||
|
||||
focusBannerWindow = new BrowserWindow({
|
||||
width: 280,
|
||||
height: 60,
|
||||
x: Math.round(screenW / 2 - 140),
|
||||
y: 48,
|
||||
frame: false,
|
||||
transparent: true,
|
||||
alwaysOnTop: true,
|
||||
skipTaskbar: true,
|
||||
resizable: false,
|
||||
movable: true,
|
||||
minimizable: false,
|
||||
maximizable: false,
|
||||
focusable: true,
|
||||
webPreferences: {
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
preload: path.join(__dirname, 'focus-banner-preload.cjs'),
|
||||
},
|
||||
})
|
||||
|
||||
// 给 banner 窗口的 session 也授权麦克风
|
||||
focusBannerWindow.webContents.session.setPermissionRequestHandler((wc, permission, callback) => {
|
||||
if (permission === 'media') return callback(true)
|
||||
callback(false)
|
||||
})
|
||||
focusBannerWindow.webContents.session.setPermissionCheckHandler((wc, permission) => {
|
||||
if (permission === 'media') return true
|
||||
return false
|
||||
})
|
||||
|
||||
focusBannerWindow.loadFile(path.join(RESOURCE_ROOT, 'focus-banner.html'))
|
||||
|
||||
focusBannerWindow.webContents.once('did-finish-load', () => {
|
||||
if (!focusBannerWindow || focusBannerWindow.isDestroyed()) return
|
||||
// 先发端口配置,让语音识别结果能发回后端
|
||||
focusBannerWindow.webContents.send('focus-banner:config', { port: backendPort })
|
||||
focusBannerWindow.webContents.send('focus-banner:update', { task, current_step, tasks })
|
||||
autoResizeBannerWindow()
|
||||
})
|
||||
|
||||
focusBannerWindow.on('closed', () => {
|
||||
focusBannerWindow = null
|
||||
})
|
||||
}
|
||||
|
||||
function autoResizeBannerWindow() {
|
||||
if (!focusBannerWindow || focusBannerWindow.isDestroyed()) return
|
||||
focusBannerWindow.webContents.executeJavaScript(`
|
||||
(() => {
|
||||
const b = document.getElementById('banner')
|
||||
return b ? { w: b.offsetWidth, h: b.offsetHeight } : null
|
||||
})()
|
||||
`).then(size => {
|
||||
if (!size || !focusBannerWindow || focusBannerWindow.isDestroyed()) return
|
||||
const padW = 0
|
||||
const padH = 0
|
||||
focusBannerWindow.setSize(Math.max(160, size.w + padW), Math.max(40, size.h + padH))
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
// Focus Banner IPC handlers
|
||||
ipcMain.on('focus-banner:close', () => {
|
||||
if (focusBannerWindow && !focusBannerWindow.isDestroyed()) {
|
||||
focusBannerWindow.close()
|
||||
focusBannerWindow = null
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.on('focus-banner:set-expanded', (_e, { expanded }) => {
|
||||
if (!focusBannerWindow || focusBannerWindow.isDestroyed()) return
|
||||
setTimeout(() => autoResizeBannerWindow(), 50)
|
||||
})
|
||||
|
||||
ipcMain.on('focus-banner:request-resize', () => {
|
||||
setTimeout(() => autoResizeBannerWindow(), 30)
|
||||
})
|
||||
|
||||
ipcMain.on('focus-banner:toggle-task', (_e, { idx, done }) => {
|
||||
// 任务勾选状态更改,横幅已在前端自行更新,无需额外操作
|
||||
})
|
||||
|
||||
// 后端 bridge 事件监听
|
||||
focusBannerBridge.on('command', ({ action, task, current_step, tasks }) => {
|
||||
if (action === 'show' || action === 'update') {
|
||||
createFocusBannerWindow({ task, current_step, tasks })
|
||||
}
|
||||
})
|
||||
|
||||
focusBannerBridge.on('hide', () => {
|
||||
if (focusBannerWindow && !focusBannerWindow.isDestroyed()) {
|
||||
focusBannerWindow.close()
|
||||
focusBannerWindow = null
|
||||
}
|
||||
})
|
||||
|
||||
function setupAutoUpdater() {
|
||||
autoUpdater.autoDownload = false
|
||||
autoUpdater.autoInstallOnAppQuit = true
|
||||
|
||||
autoUpdater.on('checking-for-update', () => {
|
||||
sendUpdaterStatus({ stage: 'checking' })
|
||||
})
|
||||
|
||||
autoUpdater.on('update-available', info => {
|
||||
console.log('[updater] update available', info?.version)
|
||||
sendUpdaterStatus({ stage: 'available', version: info?.version })
|
||||
})
|
||||
|
||||
autoUpdater.on('download-progress', progress => {
|
||||
sendUpdaterStatus({
|
||||
stage: 'downloading',
|
||||
percent: Number(progress?.percent || 0),
|
||||
transferred: progress?.transferred || 0,
|
||||
total: progress?.total || 0,
|
||||
})
|
||||
})
|
||||
|
||||
autoUpdater.on('update-downloaded', info => {
|
||||
console.log('[updater] update downloaded', info?.version)
|
||||
sendUpdaterStatus({ stage: 'downloaded', version: info?.version })
|
||||
})
|
||||
|
||||
autoUpdater.on('update-not-available', info => {
|
||||
sendUpdaterStatus({
|
||||
stage: 'up-to-date',
|
||||
version: info?.version || app.getVersion(),
|
||||
})
|
||||
})
|
||||
|
||||
autoUpdater.on('error', err => {
|
||||
const message = err?.message || String(err || 'Update failed')
|
||||
console.warn('[updater] update failed', message)
|
||||
sendUpdaterStatus({ stage: 'error', message })
|
||||
})
|
||||
|
||||
if (!IS_DEV) {
|
||||
autoUpdater.checkForUpdates().catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
ipcMain.handle('app:get-version', () => app.getVersion())
|
||||
|
||||
ipcMain.handle('updater:check-for-updates', async () => {
|
||||
if (IS_DEV) {
|
||||
sendUpdaterStatus({ stage: 'dev' })
|
||||
return { ok: false, skipped: true, reason: 'dev' }
|
||||
}
|
||||
try {
|
||||
sendUpdaterStatus({ stage: 'checking' })
|
||||
const result = await autoUpdater.checkForUpdates()
|
||||
return { ok: true, updateInfo: result?.updateInfo || null }
|
||||
} catch (error) {
|
||||
const message = error?.message || String(error || 'Update check failed')
|
||||
sendUpdaterStatus({ stage: 'error', message })
|
||||
return { ok: false, message }
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('updater:start-download', async () => {
|
||||
try {
|
||||
await autoUpdater.downloadUpdate()
|
||||
return { ok: true }
|
||||
} catch (error) {
|
||||
const message = error?.message || String(error || 'Download failed')
|
||||
sendUpdaterStatus({ stage: 'error', message })
|
||||
return { ok: false, message }
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('updater:quit-and-install', () => {
|
||||
autoUpdater.quitAndInstall()
|
||||
})
|
||||
|
||||
app.on('second-instance', () => {
|
||||
if (!mainWindow) return
|
||||
if (mainWindow.isMinimized()) mainWindow.restore()
|
||||
if (!mainWindow.isVisible()) mainWindow.show()
|
||||
mainWindow.focus()
|
||||
})
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
// 主窗口关闭后保持后台运行(Focus Banner 等桌面功能继续工作)
|
||||
// 只有托盘菜单「退出」才真正退出
|
||||
})
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
|
||||
|
||||
ipcMain.on('open-consciousness-3d', () => {
|
||||
if (mainWindow) {
|
||||
mainWindow.loadURL(`http://127.0.0.1:`+port+`/consciousness-3d`)
|
||||
}
|
||||
})
|
||||
|
||||
Menu.setApplicationMenu(null)
|
||||
|
||||
try {
|
||||
backendPort = await findFreePort(3721)
|
||||
await bootstrapBackend(backendPort)
|
||||
await waitForBackend(backendPort)
|
||||
} catch (err) {
|
||||
dialog.showErrorBox('Startup failed', `Unable to start the Bailongma backend:\n${err.message}`)
|
||||
app.quit()
|
||||
return
|
||||
}
|
||||
|
||||
await createWindow()
|
||||
setupTray()
|
||||
setupAutoUpdater()
|
||||
// 不再注册任何系统级 globalShortcut;F11 / F12 / Ctrl+R 已由 mainWindow
|
||||
// 的 before-input-event 处理(见 createWindow),只在窗口获焦时生效,
|
||||
// 不会劫持浏览器/IDE 等其他应用的同键操作。
|
||||
})
|
||||
18
electron/preload.cjs
Normal file
18
electron/preload.cjs
Normal file
@@ -0,0 +1,18 @@
|
||||
const { contextBridge, ipcRenderer, webFrame } = require('electron')
|
||||
|
||||
contextBridge.exposeInMainWorld('bailongma', {
|
||||
platform: process.platform,
|
||||
isElectron: true,
|
||||
getVersion: () => ipcRenderer.invoke('app:get-version'),
|
||||
checkForUpdates: () => ipcRenderer.invoke('updater:check-for-updates'),
|
||||
startDownload: () => ipcRenderer.invoke('updater:start-download'),
|
||||
quitAndInstall: () => ipcRenderer.invoke('updater:quit-and-install'),
|
||||
getZoomFactor: () => webFrame.getZoomFactor(),
|
||||
setZoomFactor: (factor) => webFrame.setZoomFactor(factor),
|
||||
onUpdaterStatus: (handler) => {
|
||||
if (typeof handler !== 'function') return () => {}
|
||||
const listener = (_event, payload) => handler(payload)
|
||||
ipcRenderer.on('updater:status', listener)
|
||||
return () => ipcRenderer.removeListener('updater:status', listener)
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user