mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-30 11:52:04 +00:00
591 lines
17 KiB
JavaScript
591 lines
17 KiB
JavaScript
const {
|
|
app,
|
|
BrowserWindow,
|
|
Menu,
|
|
Notification,
|
|
clipboard,
|
|
dialog,
|
|
ipcMain,
|
|
nativeImage,
|
|
session,
|
|
shell,
|
|
systemPreferences
|
|
} = require('electron')
|
|
const crypto = require('node:crypto')
|
|
const fs = require('node:fs')
|
|
const http = require('node:http')
|
|
const https = require('node:https')
|
|
const net = require('node:net')
|
|
const path = require('node:path')
|
|
const { fileURLToPath, pathToFileURL } = require('node:url')
|
|
const { spawn } = require('node:child_process')
|
|
|
|
const PORT_FLOOR = 9120
|
|
const PORT_CEILING = 9199
|
|
const DEV_SERVER = process.env.HERMES_DESKTOP_DEV_SERVER
|
|
const REPO_ROOT = path.resolve(__dirname, '../../..')
|
|
const DESKTOP_ROOT = path.resolve(__dirname, '..')
|
|
const IS_MAC = process.platform === 'darwin'
|
|
const APP_NAME = 'Hermes'
|
|
const TITLEBAR_HEIGHT = 34
|
|
const MACOS_TRAFFIC_LIGHTS_HEIGHT = 14
|
|
const WINDOW_BUTTON_POSITION = {
|
|
x: 24,
|
|
y: TITLEBAR_HEIGHT / 2 - MACOS_TRAFFIC_LIGHTS_HEIGHT / 2
|
|
}
|
|
const APP_ICON_PATH = path.join(DESKTOP_ROOT, 'public', 'apple-touch-icon.png')
|
|
|
|
app.setName(APP_NAME)
|
|
|
|
let mainWindow = null
|
|
let hermesProcess = null
|
|
let connectionPromise = null
|
|
const hermesLog = []
|
|
|
|
function rememberLog(chunk) {
|
|
const text = String(chunk || '').trim()
|
|
if (!text) return
|
|
hermesLog.push(...text.split(/\r?\n/).map(line => `[hermes] ${line}`))
|
|
if (hermesLog.length > 300) {
|
|
hermesLog.splice(0, hermesLog.length - 300)
|
|
}
|
|
}
|
|
|
|
function findPython() {
|
|
const local = [path.join(REPO_ROOT, '.venv', 'bin', 'python'), path.join(REPO_ROOT, 'venv', 'bin', 'python')]
|
|
return local.find(candidate => fs.existsSync(candidate)) || 'python3'
|
|
}
|
|
|
|
function isPortAvailable(port) {
|
|
return new Promise(resolve => {
|
|
const server = net.createServer()
|
|
server.once('error', () => resolve(false))
|
|
server.once('listening', () => {
|
|
server.close(() => resolve(true))
|
|
})
|
|
server.listen(port, '127.0.0.1')
|
|
})
|
|
}
|
|
|
|
async function pickPort() {
|
|
for (let port = PORT_FLOOR; port <= PORT_CEILING; port += 1) {
|
|
if (await isPortAvailable(port)) return port
|
|
}
|
|
throw new Error(`No free localhost port in ${PORT_FLOOR}-${PORT_CEILING}`)
|
|
}
|
|
|
|
function fetchJson(url, token, options = {}) {
|
|
return new Promise((resolve, reject) => {
|
|
const body = options.body === undefined ? undefined : Buffer.from(JSON.stringify(options.body))
|
|
|
|
const req = http.request(
|
|
url,
|
|
{
|
|
method: options.method || 'GET',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-Hermes-Session-Token': token,
|
|
...(body ? { 'Content-Length': String(body.length) } : {})
|
|
}
|
|
},
|
|
res => {
|
|
const chunks = []
|
|
res.on('data', chunk => chunks.push(chunk))
|
|
res.on('end', () => {
|
|
const text = Buffer.concat(chunks).toString('utf8')
|
|
if ((res.statusCode || 500) >= 400) {
|
|
reject(new Error(`${res.statusCode}: ${text || res.statusMessage}`))
|
|
return
|
|
}
|
|
try {
|
|
resolve(text ? JSON.parse(text) : null)
|
|
} catch (error) {
|
|
reject(error)
|
|
}
|
|
})
|
|
}
|
|
)
|
|
|
|
req.on('error', reject)
|
|
if (body) req.write(body)
|
|
req.end()
|
|
})
|
|
}
|
|
|
|
function mimeTypeForPath(filePath) {
|
|
const ext = path.extname(filePath || '').toLowerCase()
|
|
if (ext === '.png') return 'image/png'
|
|
if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg'
|
|
if (ext === '.gif') return 'image/gif'
|
|
if (ext === '.webp') return 'image/webp'
|
|
if (ext === '.svg') return 'image/svg+xml'
|
|
if (ext === '.bmp') return 'image/bmp'
|
|
return 'application/octet-stream'
|
|
}
|
|
|
|
function extensionForMimeType(mimeType) {
|
|
const type = String(mimeType || '')
|
|
.split(';')[0]
|
|
.trim()
|
|
.toLowerCase()
|
|
if (type === 'image/png') return '.png'
|
|
if (type === 'image/jpeg') return '.jpg'
|
|
if (type === 'image/gif') return '.gif'
|
|
if (type === 'image/webp') return '.webp'
|
|
if (type === 'image/bmp') return '.bmp'
|
|
if (type === 'image/svg+xml') return '.svg'
|
|
return ''
|
|
}
|
|
|
|
function filenameFromUrl(rawUrl, fallback = 'image') {
|
|
try {
|
|
const parsed = new URL(rawUrl)
|
|
const base = path.basename(decodeURIComponent(parsed.pathname || ''))
|
|
return base && base.includes('.') ? base : fallback
|
|
} catch {
|
|
return fallback
|
|
}
|
|
}
|
|
|
|
async function resourceBufferFromUrl(rawUrl) {
|
|
if (!rawUrl) throw new Error('Missing URL')
|
|
if (rawUrl.startsWith('data:')) {
|
|
const match = rawUrl.match(/^data:([^;,]+)?(;base64)?,(.*)$/s)
|
|
if (!match) throw new Error('Invalid data URL')
|
|
const mimeType = match[1] || 'application/octet-stream'
|
|
const encoded = match[3] || ''
|
|
const buffer = match[2] ? Buffer.from(encoded, 'base64') : Buffer.from(decodeURIComponent(encoded), 'utf8')
|
|
return { buffer, mimeType }
|
|
}
|
|
if (rawUrl.startsWith('file:')) {
|
|
const filePath = fileURLToPath(rawUrl)
|
|
const buffer = await fs.promises.readFile(filePath)
|
|
return { buffer, mimeType: mimeTypeForPath(filePath) }
|
|
}
|
|
|
|
const parsed = new URL(rawUrl)
|
|
const client = parsed.protocol === 'https:' ? https : http
|
|
return new Promise((resolve, reject) => {
|
|
const req = client.get(parsed, res => {
|
|
if ((res.statusCode || 500) >= 400) {
|
|
reject(new Error(`Failed to fetch ${rawUrl}: ${res.statusCode}`))
|
|
res.resume()
|
|
return
|
|
}
|
|
const chunks = []
|
|
res.on('data', chunk => chunks.push(chunk))
|
|
res.on('end', () => {
|
|
resolve({
|
|
buffer: Buffer.concat(chunks),
|
|
mimeType: res.headers['content-type'] || 'application/octet-stream'
|
|
})
|
|
})
|
|
})
|
|
req.on('error', reject)
|
|
})
|
|
}
|
|
|
|
async function copyImageFromUrl(rawUrl) {
|
|
const { buffer } = await resourceBufferFromUrl(rawUrl)
|
|
const image = nativeImage.createFromBuffer(buffer)
|
|
if (image.isEmpty()) throw new Error('Could not read image')
|
|
clipboard.writeImage(image)
|
|
}
|
|
|
|
async function saveImageFromUrl(rawUrl) {
|
|
const { buffer, mimeType } = await resourceBufferFromUrl(rawUrl)
|
|
const fallbackName = filenameFromUrl(rawUrl, `image${extensionForMimeType(mimeType) || '.png'}`)
|
|
const result = await dialog.showSaveDialog(mainWindow, {
|
|
title: 'Save Image',
|
|
defaultPath: fallbackName
|
|
})
|
|
if (result.canceled || !result.filePath) return false
|
|
await fs.promises.writeFile(result.filePath, buffer)
|
|
return true
|
|
}
|
|
|
|
async function waitForHermes(baseUrl, token) {
|
|
const deadline = Date.now() + 45_000
|
|
let lastError = null
|
|
|
|
while (Date.now() < deadline) {
|
|
try {
|
|
await fetchJson(`${baseUrl}/api/status`, token)
|
|
return
|
|
} catch (error) {
|
|
lastError = error
|
|
await new Promise(resolve => setTimeout(resolve, 500))
|
|
}
|
|
}
|
|
|
|
throw new Error(`Hermes dashboard did not become ready: ${lastError?.message || 'timeout'}`)
|
|
}
|
|
|
|
function getWindowButtonPosition() {
|
|
if (!IS_MAC) return null
|
|
return mainWindow?.getWindowButtonPosition?.() || WINDOW_BUTTON_POSITION
|
|
}
|
|
|
|
function getAppIconPath() {
|
|
return fs.existsSync(APP_ICON_PATH) ? APP_ICON_PATH : undefined
|
|
}
|
|
|
|
function resolveHermesCwd() {
|
|
const candidates = [process.env.HERMES_DESKTOP_CWD, process.env.INIT_CWD, process.cwd(), REPO_ROOT]
|
|
for (const candidate of candidates) {
|
|
if (!candidate) continue
|
|
const resolved = path.resolve(String(candidate))
|
|
try {
|
|
if (fs.statSync(resolved).isDirectory()) return resolved
|
|
} catch {
|
|
// Try the next candidate.
|
|
}
|
|
}
|
|
return REPO_ROOT
|
|
}
|
|
|
|
function buildApplicationMenu() {
|
|
const template = []
|
|
if (IS_MAC) {
|
|
template.push({
|
|
label: APP_NAME,
|
|
submenu: [
|
|
{ role: 'about', label: `About ${APP_NAME}` },
|
|
{ type: 'separator' },
|
|
{ role: 'services' },
|
|
{ type: 'separator' },
|
|
{ role: 'hide' },
|
|
{ role: 'hideOthers' },
|
|
{ role: 'unhide' },
|
|
{ type: 'separator' },
|
|
{ role: 'quit' }
|
|
]
|
|
})
|
|
}
|
|
|
|
template.push({
|
|
label: 'File',
|
|
submenu: [IS_MAC ? { role: 'close' } : { role: 'quit' }]
|
|
})
|
|
template.push({
|
|
label: 'Edit',
|
|
submenu: [
|
|
{ role: 'undo' },
|
|
{ role: 'redo' },
|
|
{ type: 'separator' },
|
|
{ role: 'cut' },
|
|
{ role: 'copy' },
|
|
{ role: 'paste' },
|
|
{ role: 'delete' },
|
|
{ role: 'selectAll' }
|
|
]
|
|
})
|
|
template.push({
|
|
label: 'View',
|
|
submenu: [
|
|
{ role: 'reload' },
|
|
{ role: 'forceReload' },
|
|
{ role: 'toggleDevTools' },
|
|
{ type: 'separator' },
|
|
{ role: 'resetZoom' },
|
|
{ role: 'zoomIn' },
|
|
{ role: 'zoomOut' },
|
|
{ type: 'separator' },
|
|
{ role: 'togglefullscreen' }
|
|
]
|
|
})
|
|
template.push({
|
|
label: 'Window',
|
|
submenu: IS_MAC
|
|
? [{ role: 'minimize' }, { role: 'zoom' }, { role: 'front' }]
|
|
: [{ role: 'minimize' }, { role: 'close' }]
|
|
})
|
|
|
|
return Menu.buildFromTemplate(template)
|
|
}
|
|
|
|
function toggleDevTools(window) {
|
|
if (!DEV_SERVER) return
|
|
const { webContents } = window
|
|
if (webContents.isDevToolsOpened()) {
|
|
webContents.closeDevTools()
|
|
} else {
|
|
webContents.openDevTools({ mode: 'detach' })
|
|
}
|
|
}
|
|
|
|
function installDevToolsShortcut(window) {
|
|
if (!DEV_SERVER) return
|
|
window.webContents.on('before-input-event', (event, input) => {
|
|
const key = input.key.toLowerCase()
|
|
const isInspectShortcut =
|
|
input.key === 'F12' ||
|
|
(IS_MAC && input.meta && input.alt && key === 'i') ||
|
|
(!IS_MAC && input.control && input.shift && key === 'i')
|
|
if (!isInspectShortcut) return
|
|
event.preventDefault()
|
|
toggleDevTools(window)
|
|
})
|
|
}
|
|
|
|
function installContextMenu(window) {
|
|
window.webContents.on('context-menu', (_event, params) => {
|
|
const template = []
|
|
const hasSelection = Boolean(params.selectionText?.trim())
|
|
const hasImage = params.mediaType === 'image' && Boolean(params.srcURL)
|
|
const hasLink = Boolean(params.linkURL)
|
|
const isEditable = Boolean(params.isEditable)
|
|
|
|
if (hasImage) {
|
|
template.push(
|
|
{
|
|
label: 'Open Image',
|
|
click: () => {
|
|
if (params.srcURL && !params.srcURL.startsWith('data:')) {
|
|
void shell.openExternal(params.srcURL)
|
|
}
|
|
},
|
|
enabled: !params.srcURL.startsWith('data:')
|
|
},
|
|
{
|
|
label: 'Copy Image',
|
|
click: () => {
|
|
void copyImageFromUrl(params.srcURL).catch(error => rememberLog(`Copy image failed: ${error.message}`))
|
|
}
|
|
},
|
|
{
|
|
label: 'Copy Image Address',
|
|
click: () => clipboard.writeText(params.srcURL)
|
|
},
|
|
{
|
|
label: 'Save Image As...',
|
|
click: () => {
|
|
void saveImageFromUrl(params.srcURL).catch(error => rememberLog(`Save image failed: ${error.message}`))
|
|
}
|
|
}
|
|
)
|
|
}
|
|
|
|
if (hasLink) {
|
|
if (template.length) template.push({ type: 'separator' })
|
|
template.push(
|
|
{
|
|
label: 'Open Link',
|
|
click: () => void shell.openExternal(params.linkURL)
|
|
},
|
|
{
|
|
label: 'Copy Link',
|
|
click: () => clipboard.writeText(params.linkURL)
|
|
}
|
|
)
|
|
}
|
|
|
|
if (hasSelection || isEditable) {
|
|
if (template.length) template.push({ type: 'separator' })
|
|
if (isEditable) {
|
|
template.push(
|
|
{ role: 'cut', enabled: params.editFlags.canCut },
|
|
{ role: 'copy', enabled: params.editFlags.canCopy },
|
|
{ role: 'paste', enabled: params.editFlags.canPaste },
|
|
{ type: 'separator' },
|
|
{ role: 'selectAll', enabled: params.editFlags.canSelectAll }
|
|
)
|
|
} else {
|
|
template.push({ role: 'copy', enabled: params.editFlags.canCopy })
|
|
}
|
|
}
|
|
|
|
if (!template.length) {
|
|
template.push({ role: 'selectAll' })
|
|
}
|
|
|
|
Menu.buildFromTemplate(template).popup({ window })
|
|
})
|
|
}
|
|
|
|
function installMediaPermissions() {
|
|
session.defaultSession.setPermissionRequestHandler((_webContents, permission, callback, details) => {
|
|
if (permission === 'media' && details?.mediaTypes?.includes('audio')) {
|
|
callback(true)
|
|
|
|
return
|
|
}
|
|
|
|
callback(false)
|
|
})
|
|
}
|
|
|
|
async function startHermes() {
|
|
if (connectionPromise) return connectionPromise
|
|
|
|
connectionPromise = (async () => {
|
|
const port = await pickPort()
|
|
const token = crypto.randomBytes(32).toString('base64url')
|
|
const python = findPython()
|
|
const args = [
|
|
'-m',
|
|
'hermes_cli.main',
|
|
'dashboard',
|
|
'--no-open',
|
|
'--tui',
|
|
'--host',
|
|
'127.0.0.1',
|
|
'--port',
|
|
String(port)
|
|
]
|
|
const hermesCwd = resolveHermesCwd()
|
|
|
|
hermesProcess = spawn(python, args, {
|
|
cwd: hermesCwd,
|
|
env: {
|
|
...process.env,
|
|
PYTHONPATH: [REPO_ROOT, process.env.PYTHONPATH].filter(Boolean).join(path.delimiter),
|
|
HERMES_DASHBOARD_SESSION_TOKEN: token,
|
|
HERMES_DASHBOARD_TUI: '1',
|
|
HERMES_WEB_DIST: path.join(REPO_ROOT, 'apps', 'desktop', 'dist')
|
|
},
|
|
stdio: ['ignore', 'pipe', 'pipe']
|
|
})
|
|
|
|
hermesProcess.stdout.on('data', rememberLog)
|
|
hermesProcess.stderr.on('data', rememberLog)
|
|
hermesProcess.once('exit', (code, signal) => {
|
|
rememberLog(`Hermes dashboard exited (${signal || code})`)
|
|
hermesProcess = null
|
|
connectionPromise = null
|
|
mainWindow?.webContents.send('hermes:backend-exit', { code, signal })
|
|
})
|
|
|
|
const baseUrl = `http://127.0.0.1:${port}`
|
|
await waitForHermes(baseUrl, token)
|
|
|
|
return {
|
|
baseUrl,
|
|
token,
|
|
wsUrl: `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(token)}`,
|
|
logs: hermesLog.slice(-80),
|
|
windowButtonPosition: getWindowButtonPosition()
|
|
}
|
|
})()
|
|
|
|
return connectionPromise
|
|
}
|
|
|
|
function createWindow() {
|
|
const icon = getAppIconPath()
|
|
mainWindow = new BrowserWindow({
|
|
width: 1220,
|
|
height: 800,
|
|
minWidth: 900,
|
|
minHeight: 620,
|
|
title: 'Hermes',
|
|
titleBarStyle: IS_MAC ? 'hidden' : 'default',
|
|
titleBarOverlay: IS_MAC ? { height: TITLEBAR_HEIGHT } : undefined,
|
|
trafficLightPosition: IS_MAC ? WINDOW_BUTTON_POSITION : undefined,
|
|
vibrancy: IS_MAC ? 'sidebar' : undefined,
|
|
icon,
|
|
backgroundColor: '#f7f7f7',
|
|
webPreferences: {
|
|
preload: path.join(__dirname, 'preload.cjs'),
|
|
contextIsolation: true,
|
|
nodeIntegration: false,
|
|
devTools: Boolean(DEV_SERVER)
|
|
}
|
|
})
|
|
|
|
if (IS_MAC) {
|
|
mainWindow.setWindowButtonPosition?.(WINDOW_BUTTON_POSITION)
|
|
if (icon) {
|
|
app.dock?.setIcon(icon)
|
|
}
|
|
}
|
|
|
|
installDevToolsShortcut(mainWindow)
|
|
installContextMenu(mainWindow)
|
|
|
|
if (DEV_SERVER) {
|
|
mainWindow.loadURL(DEV_SERVER)
|
|
} else {
|
|
mainWindow.loadURL(pathToFileURL(path.join(__dirname, '..', 'dist', 'index.html')).toString())
|
|
}
|
|
}
|
|
|
|
ipcMain.handle('hermes:connection', async () => startHermes())
|
|
|
|
ipcMain.handle('hermes:requestMicrophoneAccess', async () => {
|
|
if (!IS_MAC || typeof systemPreferences.askForMediaAccess !== 'function') {
|
|
return true
|
|
}
|
|
|
|
return systemPreferences.askForMediaAccess('microphone')
|
|
})
|
|
|
|
ipcMain.handle('hermes:api', async (_event, request) => {
|
|
const connection = await startHermes()
|
|
return fetchJson(`${connection.baseUrl}${request.path}`, connection.token, {
|
|
method: request.method,
|
|
body: request.body
|
|
})
|
|
})
|
|
|
|
ipcMain.handle('hermes:notify', (_event, payload) => {
|
|
if (!Notification.isSupported()) return false
|
|
new Notification({
|
|
title: payload?.title || 'Hermes',
|
|
body: payload?.body || '',
|
|
silent: Boolean(payload?.silent)
|
|
}).show()
|
|
return true
|
|
})
|
|
|
|
ipcMain.handle('hermes:readFileDataUrl', async (_event, filePath) => {
|
|
const resolved = path.resolve(String(filePath || ''))
|
|
const data = await fs.promises.readFile(resolved)
|
|
return `data:${mimeTypeForPath(resolved)};base64,${data.toString('base64')}`
|
|
})
|
|
|
|
ipcMain.handle('hermes:selectPaths', async (_event, options = {}) => {
|
|
const properties = ['openFile']
|
|
if (options?.directories) properties.push('openDirectory')
|
|
if (options?.multiple !== false) properties.push('multiSelections')
|
|
|
|
const result = await dialog.showOpenDialog(mainWindow, {
|
|
title: options?.title || 'Add context',
|
|
defaultPath: options?.defaultPath ? path.resolve(String(options.defaultPath)) : undefined,
|
|
properties,
|
|
filters: Array.isArray(options?.filters) ? options.filters : undefined
|
|
})
|
|
|
|
if (result.canceled) return []
|
|
return result.filePaths
|
|
})
|
|
|
|
ipcMain.handle('hermes:writeClipboard', (_event, text) => {
|
|
clipboard.writeText(String(text || ''))
|
|
return true
|
|
})
|
|
|
|
ipcMain.handle('hermes:saveImageFromUrl', (_event, url) => saveImageFromUrl(String(url || '')))
|
|
|
|
ipcMain.handle('hermes:openExternal', (_event, url) => shell.openExternal(url))
|
|
|
|
app.whenReady().then(() => {
|
|
Menu.setApplicationMenu(buildApplicationMenu())
|
|
installMediaPermissions()
|
|
createWindow()
|
|
startHermes().catch(error => rememberLog(error.stack || error.message))
|
|
|
|
app.on('activate', () => {
|
|
if (BrowserWindow.getAllWindows().length === 0) createWindow()
|
|
})
|
|
})
|
|
|
|
app.on('before-quit', () => {
|
|
if (hermesProcess && !hermesProcess.killed) {
|
|
hermesProcess.kill('SIGTERM')
|
|
}
|
|
})
|
|
|
|
app.on('window-all-closed', () => {
|
|
if (process.platform !== 'darwin') app.quit()
|
|
})
|