mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-10 08:32:09 +00:00
Merge pull request #21995 from NousResearch/feature/desktop-remote-gateway-settings
Add desktop remote gateway settings
This commit is contained in:
commit
94fbfb2019
8 changed files with 606 additions and 23 deletions
|
|
@ -7,6 +7,7 @@ const {
|
|||
dialog,
|
||||
ipcMain,
|
||||
nativeImage,
|
||||
safeStorage,
|
||||
session,
|
||||
shell,
|
||||
systemPreferences
|
||||
|
|
@ -44,6 +45,7 @@ const SOURCE_REPO_ROOT = path.resolve(APP_ROOT, '../..')
|
|||
const BUNDLED_HERMES_ROOT = path.join(process.resourcesPath, 'hermes-agent')
|
||||
const BUNDLED_VENV_ROOT = path.join(app.getPath('userData'), 'hermes-runtime')
|
||||
const BUNDLED_VENV_MARKER = path.join(BUNDLED_VENV_ROOT, '.hermes-desktop-runtime.json')
|
||||
const DESKTOP_CONNECTION_CONFIG_PATH = path.join(app.getPath('userData'), 'connection.json')
|
||||
const DESKTOP_LOG_PATH = path.join(app.getPath('userData'), 'desktop.log')
|
||||
const DESKTOP_LOG_FLUSH_MS = 120
|
||||
const DESKTOP_LOG_BUFFER_MAX_CHARS = 64 * 1024
|
||||
|
|
@ -205,6 +207,7 @@ app.setAboutPanelOptions({
|
|||
let mainWindow = null
|
||||
let hermesProcess = null
|
||||
let connectionPromise = null
|
||||
let connectionConfigCache = null
|
||||
const hermesLog = []
|
||||
const previewWatchers = new Map()
|
||||
let previewShortcutActive = false
|
||||
|
|
@ -692,9 +695,16 @@ async function pickPort() {
|
|||
function fetchJson(url, token, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const body = options.body === undefined ? undefined : Buffer.from(JSON.stringify(options.body))
|
||||
const parsed = new URL(url)
|
||||
const client = parsed.protocol === 'https:' ? https : http
|
||||
|
||||
const req = http.request(
|
||||
url,
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
reject(new Error(`Unsupported Hermes backend URL protocol: ${parsed.protocol}`))
|
||||
return
|
||||
}
|
||||
|
||||
const req = client.request(
|
||||
parsed,
|
||||
{
|
||||
method: options.method || 'GET',
|
||||
headers: {
|
||||
|
|
@ -722,6 +732,11 @@ function fetchJson(url, token, options = {}) {
|
|||
)
|
||||
|
||||
req.on('error', reject)
|
||||
if (options.timeoutMs) {
|
||||
req.setTimeout(options.timeoutMs, () => {
|
||||
req.destroy(new Error(`Timed out connecting to Hermes backend after ${options.timeoutMs}ms`))
|
||||
})
|
||||
}
|
||||
if (body) req.write(body)
|
||||
req.end()
|
||||
})
|
||||
|
|
@ -1229,32 +1244,247 @@ function installMediaPermissions() {
|
|||
})
|
||||
}
|
||||
|
||||
function resolveRemoteBackend() {
|
||||
const rawUrl = process.env.HERMES_DESKTOP_REMOTE_URL
|
||||
const rawToken = process.env.HERMES_DESKTOP_REMOTE_TOKEN
|
||||
if (!rawUrl) return null
|
||||
if (!rawToken) {
|
||||
throw new Error(
|
||||
'HERMES_DESKTOP_REMOTE_URL is set but HERMES_DESKTOP_REMOTE_TOKEN is not. ' +
|
||||
'Both must be provided to connect to a remote Hermes backend.'
|
||||
)
|
||||
function normalizeRemoteBaseUrl(rawUrl) {
|
||||
const value = String(rawUrl || '').trim()
|
||||
|
||||
if (!value) {
|
||||
throw new Error('Remote gateway URL is required.')
|
||||
}
|
||||
|
||||
let parsed
|
||||
try {
|
||||
parsed = new URL(rawUrl)
|
||||
parsed = new URL(value)
|
||||
} catch (error) {
|
||||
throw new Error(`HERMES_DESKTOP_REMOTE_URL is not a valid URL: ${error.message}`)
|
||||
throw new Error(`Remote gateway URL is not valid: ${error.message}`)
|
||||
}
|
||||
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
throw new Error(`HERMES_DESKTOP_REMOTE_URL must be http:// or https://, got ${parsed.protocol}`)
|
||||
throw new Error(`Remote gateway URL must be http:// or https://, got ${parsed.protocol}`)
|
||||
}
|
||||
|
||||
const baseUrl = `${parsed.protocol}//${parsed.host}`
|
||||
const wsScheme = parsed.protocol === 'https:' ? 'wss' : 'ws'
|
||||
const wsUrl = `${wsScheme}://${parsed.host}/api/ws?token=${encodeURIComponent(rawToken)}`
|
||||
parsed.hash = ''
|
||||
parsed.search = ''
|
||||
parsed.pathname = parsed.pathname.replace(/\/+$/, '')
|
||||
|
||||
return { baseUrl, token: rawToken, wsUrl }
|
||||
return parsed.toString().replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
function buildGatewayWsUrl(baseUrl, token) {
|
||||
const parsed = new URL(baseUrl)
|
||||
const wsScheme = parsed.protocol === 'https:' ? 'wss' : 'ws'
|
||||
const prefix = parsed.pathname.replace(/\/+$/, '')
|
||||
|
||||
return `${wsScheme}://${parsed.host}${prefix}/api/ws?token=${encodeURIComponent(token)}`
|
||||
}
|
||||
|
||||
function tokenPreview(value) {
|
||||
const raw = String(value || '')
|
||||
|
||||
if (!raw) {
|
||||
return null
|
||||
}
|
||||
|
||||
return raw.length <= 8 ? 'set' : `...${raw.slice(-6)}`
|
||||
}
|
||||
|
||||
function encryptDesktopSecret(value) {
|
||||
const raw = String(value || '')
|
||||
|
||||
if (!raw) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
if (safeStorage.isEncryptionAvailable()) {
|
||||
return {
|
||||
encoding: 'safeStorage',
|
||||
value: safeStorage.encryptString(raw).toString('base64')
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Fall through to plaintext for platforms where Electron cannot encrypt.
|
||||
}
|
||||
|
||||
return { encoding: 'plain', value: raw }
|
||||
}
|
||||
|
||||
function decryptDesktopSecret(secret) {
|
||||
if (!secret || typeof secret !== 'object') {
|
||||
return ''
|
||||
}
|
||||
|
||||
const value = String(secret.value || '')
|
||||
|
||||
if (!value) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (secret.encoding === 'safeStorage') {
|
||||
try {
|
||||
return safeStorage.decryptString(Buffer.from(value, 'base64'))
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
function readDesktopConnectionConfig() {
|
||||
if (connectionConfigCache) {
|
||||
return connectionConfigCache
|
||||
}
|
||||
|
||||
let config = { mode: 'local', remote: {} }
|
||||
|
||||
try {
|
||||
const raw = fs.readFileSync(DESKTOP_CONNECTION_CONFIG_PATH, 'utf8')
|
||||
const parsed = JSON.parse(raw)
|
||||
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
config = {
|
||||
mode: parsed.mode === 'remote' ? 'remote' : 'local',
|
||||
remote: parsed.remote && typeof parsed.remote === 'object' ? parsed.remote : {}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Missing or malformed connection settings should fall back to local.
|
||||
}
|
||||
|
||||
connectionConfigCache = config
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
function writeDesktopConnectionConfig(config) {
|
||||
fs.mkdirSync(path.dirname(DESKTOP_CONNECTION_CONFIG_PATH), { recursive: true })
|
||||
fs.writeFileSync(DESKTOP_CONNECTION_CONFIG_PATH, JSON.stringify(config, null, 2))
|
||||
connectionConfigCache = config
|
||||
}
|
||||
|
||||
function sanitizeDesktopConnectionConfig(config = readDesktopConnectionConfig()) {
|
||||
const remoteToken = decryptDesktopSecret(config.remote?.token)
|
||||
|
||||
return {
|
||||
mode: config.mode === 'remote' ? 'remote' : 'local',
|
||||
remoteUrl: String(config.remote?.url || ''),
|
||||
remoteTokenPreview: tokenPreview(remoteToken),
|
||||
remoteTokenSet: Boolean(remoteToken),
|
||||
envOverride: Boolean(process.env.HERMES_DESKTOP_REMOTE_URL)
|
||||
}
|
||||
}
|
||||
|
||||
function coerceDesktopConnectionConfig(input = {}, existing = readDesktopConnectionConfig()) {
|
||||
const mode = input.mode === 'remote' ? 'remote' : 'local'
|
||||
const remoteUrl = String(input.remoteUrl ?? existing.remote?.url ?? '').trim()
|
||||
const incomingToken = typeof input.remoteToken === 'string' ? input.remoteToken.trim() : ''
|
||||
const existingToken = existing.remote?.token
|
||||
const nextRemote = {
|
||||
url: remoteUrl,
|
||||
token: incomingToken ? encryptDesktopSecret(incomingToken) : existingToken
|
||||
}
|
||||
|
||||
if (mode === 'remote') {
|
||||
nextRemote.url = normalizeRemoteBaseUrl(remoteUrl)
|
||||
|
||||
if (!decryptDesktopSecret(nextRemote.token)) {
|
||||
throw new Error('Remote gateway session token is required.')
|
||||
}
|
||||
} else if (remoteUrl) {
|
||||
nextRemote.url = normalizeRemoteBaseUrl(remoteUrl)
|
||||
}
|
||||
|
||||
return { mode, remote: nextRemote }
|
||||
}
|
||||
|
||||
function resolveRemoteBackend() {
|
||||
const rawEnvUrl = process.env.HERMES_DESKTOP_REMOTE_URL
|
||||
const rawEnvToken = process.env.HERMES_DESKTOP_REMOTE_TOKEN
|
||||
|
||||
if (rawEnvUrl) {
|
||||
if (!rawEnvToken) {
|
||||
throw new Error(
|
||||
'HERMES_DESKTOP_REMOTE_URL is set but HERMES_DESKTOP_REMOTE_TOKEN is not. ' +
|
||||
'Both must be provided to connect to a remote Hermes backend.'
|
||||
)
|
||||
}
|
||||
|
||||
const baseUrl = normalizeRemoteBaseUrl(rawEnvUrl)
|
||||
|
||||
return {
|
||||
baseUrl,
|
||||
mode: 'remote',
|
||||
source: 'env',
|
||||
token: rawEnvToken,
|
||||
wsUrl: buildGatewayWsUrl(baseUrl, rawEnvToken)
|
||||
}
|
||||
}
|
||||
|
||||
const config = readDesktopConnectionConfig()
|
||||
|
||||
if (config.mode !== 'remote') {
|
||||
return null
|
||||
}
|
||||
|
||||
const token = decryptDesktopSecret(config.remote?.token)
|
||||
|
||||
if (!token) {
|
||||
throw new Error(
|
||||
'Remote Hermes gateway is selected, but no session token is saved. ' +
|
||||
'Open Settings → Gateway and save a token, or switch back to Local.'
|
||||
)
|
||||
}
|
||||
|
||||
const baseUrl = normalizeRemoteBaseUrl(config.remote?.url)
|
||||
|
||||
return {
|
||||
baseUrl,
|
||||
mode: 'remote',
|
||||
source: 'settings',
|
||||
token,
|
||||
wsUrl: buildGatewayWsUrl(baseUrl, token)
|
||||
}
|
||||
}
|
||||
|
||||
async function testDesktopConnectionConfig(input = {}) {
|
||||
const config = coerceDesktopConnectionConfig(input)
|
||||
const remote = config.mode === 'remote'
|
||||
? {
|
||||
baseUrl: normalizeRemoteBaseUrl(config.remote.url),
|
||||
token: decryptDesktopSecret(config.remote.token)
|
||||
}
|
||||
: resolveRemoteBackend() || (await startHermes())
|
||||
const status = await fetchJson(`${remote.baseUrl}/api/status`, remote.token, { timeoutMs: 8_000 })
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
baseUrl: remote.baseUrl,
|
||||
version: status?.version || null
|
||||
}
|
||||
}
|
||||
|
||||
function resetBootProgressForReconnect() {
|
||||
updateBootProgress(
|
||||
{
|
||||
error: null,
|
||||
message: 'Restarting desktop connection',
|
||||
phase: 'backend.resolve',
|
||||
progress: 4,
|
||||
running: true
|
||||
},
|
||||
{ allowDecrease: true }
|
||||
)
|
||||
}
|
||||
|
||||
function resetHermesConnection() {
|
||||
connectionPromise = null
|
||||
|
||||
if (hermesProcess && !hermesProcess.killed) {
|
||||
hermesProcess.kill('SIGTERM')
|
||||
}
|
||||
|
||||
hermesProcess = null
|
||||
resetBootProgressForReconnect()
|
||||
}
|
||||
|
||||
async function startHermes() {
|
||||
|
|
@ -1275,6 +1505,8 @@ async function startHermes() {
|
|||
})
|
||||
return {
|
||||
baseUrl: remote.baseUrl,
|
||||
mode: 'remote',
|
||||
source: remote.source,
|
||||
token: remote.token,
|
||||
wsUrl: remote.wsUrl,
|
||||
logs: hermesLog.slice(-80),
|
||||
|
|
@ -1368,6 +1600,8 @@ async function startHermes() {
|
|||
|
||||
return {
|
||||
baseUrl,
|
||||
mode: 'local',
|
||||
source: 'local',
|
||||
token,
|
||||
wsUrl: `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(token)}`,
|
||||
logs: hermesLog.slice(-80),
|
||||
|
|
@ -1440,6 +1674,22 @@ function createWindow() {
|
|||
|
||||
ipcMain.handle('hermes:connection', async () => startHermes())
|
||||
ipcMain.handle('hermes:boot-progress:get', async () => bootProgressState)
|
||||
ipcMain.handle('hermes:connection-config:get', async () => sanitizeDesktopConnectionConfig())
|
||||
ipcMain.handle('hermes:connection-config:test', async (_event, payload) => testDesktopConnectionConfig(payload))
|
||||
ipcMain.handle('hermes:connection-config:save', async (_event, payload) => {
|
||||
const config = coerceDesktopConnectionConfig(payload)
|
||||
writeDesktopConnectionConfig(config)
|
||||
|
||||
return sanitizeDesktopConnectionConfig(config)
|
||||
})
|
||||
ipcMain.handle('hermes:connection-config:apply', async (_event, payload) => {
|
||||
const config = coerceDesktopConnectionConfig(payload)
|
||||
writeDesktopConnectionConfig(config)
|
||||
resetHermesConnection()
|
||||
setTimeout(() => mainWindow?.reload(), 150)
|
||||
|
||||
return sanitizeDesktopConnectionConfig(config)
|
||||
})
|
||||
|
||||
ipcMain.on('hermes:previewShortcutActive', (_event, active) => {
|
||||
previewShortcutActive = Boolean(active)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,10 @@ const { contextBridge, ipcRenderer, webUtils } = require('electron')
|
|||
contextBridge.exposeInMainWorld('hermesDesktop', {
|
||||
getConnection: () => ipcRenderer.invoke('hermes:connection'),
|
||||
getBootProgress: () => ipcRenderer.invoke('hermes:boot-progress:get'),
|
||||
getConnectionConfig: () => ipcRenderer.invoke('hermes:connection-config:get'),
|
||||
saveConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:save', payload),
|
||||
applyConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:apply', payload),
|
||||
testConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:test', payload),
|
||||
api: request => ipcRenderer.invoke('hermes:api', request),
|
||||
notify: payload => ipcRenderer.invoke('hermes:notify', payload),
|
||||
requestMicrophoneAccess: () => ipcRenderer.invoke('hermes:requestMicrophoneAccess'),
|
||||
|
|
|
|||
|
|
@ -311,8 +311,9 @@ export const MODE_OPTIONS: ModeOption[] = [
|
|||
{ id: 'system', label: 'System', description: 'Follow macOS appearance', icon: Monitor }
|
||||
]
|
||||
|
||||
export const SEARCH_PLACEHOLDER: Record<'config' | 'keys' | 'tools', string> = {
|
||||
export const SEARCH_PLACEHOLDER: Record<'config' | 'gateway' | 'keys' | 'tools', string> = {
|
||||
config: 'Search settings...',
|
||||
gateway: 'Gateway connection...',
|
||||
keys: 'Search API keys...',
|
||||
tools: 'Search skills and tools...'
|
||||
}
|
||||
|
|
|
|||
279
apps/desktop/src/app/settings/gateway-settings.tsx
Normal file
279
apps/desktop/src/app/settings/gateway-settings.tsx
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { AlertCircle, Check, Globe, Loader2, Monitor } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
|
||||
import { CONTROL_TEXT } from './constants'
|
||||
import { EmptyState, ListRow, LoadingState, Pill, SettingsContent } from './primitives'
|
||||
|
||||
type Mode = 'local' | 'remote'
|
||||
|
||||
interface GatewaySettingsState {
|
||||
envOverride: boolean
|
||||
mode: Mode
|
||||
remoteTokenPreview: string | null
|
||||
remoteTokenSet: boolean
|
||||
remoteUrl: string
|
||||
}
|
||||
|
||||
const EMPTY_STATE: GatewaySettingsState = {
|
||||
envOverride: false,
|
||||
mode: 'local',
|
||||
remoteTokenPreview: null,
|
||||
remoteTokenSet: false,
|
||||
remoteUrl: ''
|
||||
}
|
||||
|
||||
function ModeCard({
|
||||
active,
|
||||
description,
|
||||
disabled,
|
||||
icon: Icon,
|
||||
onSelect,
|
||||
title
|
||||
}: {
|
||||
active: boolean
|
||||
description: string
|
||||
disabled?: boolean
|
||||
icon: typeof Monitor
|
||||
onSelect: () => void
|
||||
title: string
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'rounded-2xl border p-4 text-left transition',
|
||||
active ? 'border-primary bg-primary/10 ring-2 ring-primary/15' : 'border-border bg-background/60 hover:bg-muted/40',
|
||||
disabled && 'cursor-not-allowed opacity-50'
|
||||
)}
|
||||
disabled={disabled}
|
||||
onClick={onSelect}
|
||||
type="button"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<Icon className="size-4 text-muted-foreground" />
|
||||
<span>{title}</span>
|
||||
{active ? <Check className="ml-auto size-4 text-primary" /> : null}
|
||||
</div>
|
||||
<p className="mt-2 text-xs leading-5 text-muted-foreground">{description}</p>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export function GatewaySettings() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [testing, setTesting] = useState(false)
|
||||
const [state, setState] = useState<GatewaySettingsState>(EMPTY_STATE)
|
||||
const [remoteToken, setRemoteToken] = useState('')
|
||||
const [lastTest, setLastTest] = useState<null | string>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const desktop = window.hermesDesktop
|
||||
|
||||
if (!desktop?.getConnectionConfig) {
|
||||
setLoading(false)
|
||||
|
||||
return () => void (cancelled = true)
|
||||
}
|
||||
|
||||
desktop
|
||||
.getConnectionConfig()
|
||||
.then(config => {
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
|
||||
setState(config)
|
||||
})
|
||||
.catch(err => notifyError(err, 'Gateway settings failed to load'))
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setLoading(false)
|
||||
}
|
||||
})
|
||||
|
||||
return () => void (cancelled = true)
|
||||
}, [])
|
||||
|
||||
const canUseRemote = useMemo(
|
||||
() => Boolean(state.remoteUrl.trim()) && (Boolean(remoteToken.trim()) || state.remoteTokenSet),
|
||||
[remoteToken, state.remoteTokenSet, state.remoteUrl]
|
||||
)
|
||||
|
||||
const payload = () => ({
|
||||
mode: state.mode,
|
||||
remoteToken: remoteToken.trim() || undefined,
|
||||
remoteUrl: state.remoteUrl.trim()
|
||||
})
|
||||
|
||||
const save = async (apply: boolean) => {
|
||||
if (state.mode === 'remote' && !canUseRemote) {
|
||||
notify({
|
||||
kind: 'warning',
|
||||
title: 'Remote gateway incomplete',
|
||||
message: 'Enter a remote URL and session token before switching to remote.'
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
|
||||
try {
|
||||
const next = apply
|
||||
? await window.hermesDesktop.applyConnectionConfig(payload())
|
||||
: await window.hermesDesktop.saveConnectionConfig(payload())
|
||||
|
||||
setState(next)
|
||||
setRemoteToken('')
|
||||
notify({
|
||||
kind: 'success',
|
||||
title: apply ? 'Gateway connection restarting' : 'Gateway settings saved',
|
||||
message: apply ? 'Hermes Desktop will reconnect using the saved settings.' : 'Saved for the next restart.'
|
||||
})
|
||||
} catch (err) {
|
||||
notifyError(err, apply ? 'Could not apply gateway settings' : 'Could not save gateway settings')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const testRemote = async () => {
|
||||
if (!canUseRemote) {
|
||||
notify({
|
||||
kind: 'warning',
|
||||
title: 'Remote gateway incomplete',
|
||||
message: 'Enter a remote URL and session token before testing.'
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setTesting(true)
|
||||
setLastTest(null)
|
||||
|
||||
try {
|
||||
const result = await window.hermesDesktop.testConnectionConfig({
|
||||
mode: 'remote',
|
||||
remoteToken: remoteToken.trim() || undefined,
|
||||
remoteUrl: state.remoteUrl.trim()
|
||||
})
|
||||
|
||||
const message = `Connected to ${result.baseUrl}${result.version ? ` · Hermes ${result.version}` : ''}`
|
||||
setLastTest(message)
|
||||
notify({ kind: 'success', title: 'Remote gateway reachable', message })
|
||||
} catch (err) {
|
||||
notifyError(err, 'Remote gateway test failed')
|
||||
} finally {
|
||||
setTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <LoadingState label="Loading gateway settings..." />
|
||||
}
|
||||
|
||||
if (!window.hermesDesktop?.getConnectionConfig) {
|
||||
return <EmptyState description="The desktop IPC bridge does not expose gateway settings." title="Gateway settings unavailable" />
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsContent>
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<Globe className="size-4 text-muted-foreground" />
|
||||
Gateway Connection
|
||||
{state.envOverride ? <Pill tone="primary">env override</Pill> : null}
|
||||
</div>
|
||||
<p className="mt-2 max-w-2xl text-xs leading-5 text-muted-foreground">
|
||||
Hermes Desktop starts its own local gateway by default. Use a remote gateway when you want this app to
|
||||
control an already-running Hermes dashboard backend on another machine or behind a trusted proxy.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{state.envOverride ? (
|
||||
<div className="mb-5 flex items-start gap-2 rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-xs text-destructive">
|
||||
<AlertCircle className="mt-0.5 size-4 shrink-0" />
|
||||
<div>
|
||||
<div className="font-medium">Environment variables are controlling this desktop session.</div>
|
||||
<div className="mt-1 leading-5">
|
||||
Unset <code>HERMES_DESKTOP_REMOTE_URL</code> and <code>HERMES_DESKTOP_REMOTE_TOKEN</code> to use the saved
|
||||
setting below.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<ModeCard
|
||||
active={state.mode === 'local'}
|
||||
description="Start a private Hermes dashboard backend on localhost. This is the default and works offline."
|
||||
disabled={state.envOverride}
|
||||
icon={Monitor}
|
||||
onSelect={() => setState(current => ({ ...current, mode: 'local' }))}
|
||||
title="Local gateway"
|
||||
/>
|
||||
<ModeCard
|
||||
active={state.mode === 'remote'}
|
||||
description="Connect this desktop shell to a remote Hermes dashboard backend using its session token."
|
||||
disabled={state.envOverride}
|
||||
icon={Globe}
|
||||
onSelect={() => setState(current => ({ ...current, mode: 'remote' }))}
|
||||
title="Remote gateway"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 divide-y divide-border/40">
|
||||
<ListRow
|
||||
action={
|
||||
<Input
|
||||
className={cn('h-8', CONTROL_TEXT)}
|
||||
disabled={state.envOverride}
|
||||
onChange={event => setState(current => ({ ...current, remoteUrl: event.target.value }))}
|
||||
placeholder="https://gateway.example.com/hermes"
|
||||
value={state.remoteUrl}
|
||||
/>
|
||||
}
|
||||
description="Base URL for the remote dashboard backend. Path prefixes are supported, for example /hermes."
|
||||
title="Remote URL"
|
||||
/>
|
||||
<ListRow
|
||||
action={
|
||||
<Input
|
||||
autoComplete="off"
|
||||
className={cn('h-8 font-mono', CONTROL_TEXT)}
|
||||
disabled={state.envOverride}
|
||||
onChange={event => setRemoteToken(event.target.value)}
|
||||
placeholder={state.remoteTokenSet ? `Existing token ${state.remoteTokenPreview ?? 'saved'}` : 'Paste session token'}
|
||||
type="password"
|
||||
value={remoteToken}
|
||||
/>
|
||||
}
|
||||
description="The dashboard session token used for REST and WebSocket access. Leave blank to keep the saved token."
|
||||
title="Session token"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{lastTest ? <div className="mt-4 text-xs text-primary">{lastTest}</div> : null}
|
||||
|
||||
<div className="mt-6 flex flex-wrap justify-end gap-3">
|
||||
<Button disabled={state.envOverride || testing || !canUseRemote} onClick={() => void testRemote()} variant="outline">
|
||||
{testing ? <Loader2 className="size-4 animate-spin" /> : null}
|
||||
Test remote
|
||||
</Button>
|
||||
<Button disabled={state.envOverride || saving} onClick={() => void save(false)} variant="outline">
|
||||
Save for next restart
|
||||
</Button>
|
||||
<Button disabled={state.envOverride || saving} onClick={() => void save(true)}>
|
||||
{saving ? <Loader2 className="size-4 animate-spin" /> : null}
|
||||
Save and reconnect
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsContent>
|
||||
)
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@ import { useEffect, useRef, useState } from 'react'
|
|||
|
||||
import { getHermesConfigDefaults, getHermesConfigRecord, saveHermesConfig } from '@/hermes'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { KeyRound, Package } from '@/lib/icons'
|
||||
import { Globe, KeyRound, Package } from '@/lib/icons'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
|
||||
import { OverlayIconButton } from '../overlays/overlay-chrome'
|
||||
|
|
@ -14,6 +14,7 @@ import { OverlayView } from '../overlays/overlay-view'
|
|||
import { AppearanceSettings } from './appearance-settings'
|
||||
import { ConfigSettings } from './config-settings'
|
||||
import { SEARCH_PLACEHOLDER, SECTIONS } from './constants'
|
||||
import { GatewaySettings } from './gateway-settings'
|
||||
import { KeysSettings } from './keys-settings'
|
||||
import { ToolsSettings } from './tools-settings'
|
||||
import type { SettingsPageProps, SettingsQueryKey, SettingsView as SettingsViewId } from './types'
|
||||
|
|
@ -23,6 +24,7 @@ export function SettingsView({ onClose, onConfigSaved }: SettingsPageProps) {
|
|||
|
||||
const [queries, setQueries] = useState<Record<SettingsQueryKey, string>>({
|
||||
config: '',
|
||||
gateway: '',
|
||||
keys: '',
|
||||
tools: ''
|
||||
})
|
||||
|
|
@ -116,6 +118,12 @@ export function SettingsView({ onClose, onConfigSaved }: SettingsPageProps) {
|
|||
)
|
||||
})}
|
||||
<div className="my-2 h-px bg-border/30" />
|
||||
<OverlayNavItem
|
||||
active={activeView === 'gateway'}
|
||||
icon={Globe}
|
||||
label="Gateway"
|
||||
onClick={() => setActiveView('gateway')}
|
||||
/>
|
||||
<OverlayNavItem
|
||||
active={activeView === 'keys'}
|
||||
icon={KeyRound}
|
||||
|
|
@ -157,6 +165,8 @@ export function SettingsView({ onClose, onConfigSaved }: SettingsPageProps) {
|
|||
<OverlayMain className="p-0">
|
||||
{activeView === 'config:appearance' ? (
|
||||
<AppearanceSettings />
|
||||
) : activeView === 'gateway' ? (
|
||||
<GatewaySettings />
|
||||
) : activeView.startsWith('config:') ? (
|
||||
<ConfigSettings
|
||||
activeSectionId={activeView.slice('config:'.length)}
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ import type { Dispatch, SetStateAction } from 'react'
|
|||
import type { LucideIcon } from '@/lib/icons'
|
||||
import type { EnvVarInfo } from '@/types/hermes'
|
||||
|
||||
export type SettingsView = 'keys' | 'tools' | `config:${string}`
|
||||
export type SettingsQueryKey = 'config' | 'keys' | 'tools'
|
||||
export type SettingsView = 'gateway' | 'keys' | 'tools' | `config:${string}`
|
||||
export type SettingsQueryKey = 'config' | 'gateway' | 'keys' | 'tools'
|
||||
export type EnvPatch = Partial<Pick<EnvVarInfo, 'is_set' | 'redacted_value'>>
|
||||
|
||||
export interface SettingsPageProps {
|
||||
|
|
|
|||
|
|
@ -161,6 +161,10 @@ function Preparing({ boot }: { boot: DesktopBootState }) {
|
|||
const progress = Math.max(2, Math.min(100, Math.round(boot.progress)))
|
||||
const hasError = Boolean(boot.error)
|
||||
|
||||
const resetToLocalGateway = async () => {
|
||||
await window.hermesDesktop?.applyConnectionConfig({ mode: 'local' })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-3" role="status">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
|
|
@ -179,7 +183,16 @@ function Preparing({ boot }: { boot: DesktopBootState }) {
|
|||
<span className="truncate">{boot.message}</span>
|
||||
<span>{progress}%</span>
|
||||
</div>
|
||||
{hasError ? <p className="text-xs text-destructive">{boot.error}</p> : null}
|
||||
{hasError ? (
|
||||
<div className="grid gap-3">
|
||||
<p className="text-xs text-destructive">{boot.error}</p>
|
||||
<div>
|
||||
<Button onClick={() => void resetToLocalGateway()} size="sm" variant="outline">
|
||||
Use local gateway
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
26
apps/desktop/src/global.d.ts
vendored
26
apps/desktop/src/global.d.ts
vendored
|
|
@ -5,6 +5,10 @@ declare global {
|
|||
hermesDesktop: {
|
||||
getConnection: () => Promise<HermesConnection>
|
||||
getBootProgress: () => Promise<DesktopBootProgress>
|
||||
getConnectionConfig: () => Promise<DesktopConnectionConfig>
|
||||
saveConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise<DesktopConnectionConfig>
|
||||
applyConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise<DesktopConnectionConfig>
|
||||
testConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise<DesktopConnectionTestResult>
|
||||
api: <T>(request: HermesApiRequest) => Promise<T>
|
||||
notify: (payload: HermesNotification) => Promise<boolean>
|
||||
requestMicrophoneAccess: () => Promise<boolean>
|
||||
|
|
@ -33,12 +37,34 @@ declare global {
|
|||
|
||||
export interface HermesConnection {
|
||||
baseUrl: string
|
||||
mode?: 'local' | 'remote'
|
||||
source?: 'env' | 'local' | 'settings'
|
||||
token: string
|
||||
wsUrl: string
|
||||
logs: string[]
|
||||
windowButtonPosition: { x: number; y: number } | null
|
||||
}
|
||||
|
||||
export interface DesktopConnectionConfig {
|
||||
envOverride: boolean
|
||||
mode: 'local' | 'remote'
|
||||
remoteTokenPreview: string | null
|
||||
remoteTokenSet: boolean
|
||||
remoteUrl: string
|
||||
}
|
||||
|
||||
export interface DesktopConnectionConfigInput {
|
||||
mode: 'local' | 'remote'
|
||||
remoteToken?: string
|
||||
remoteUrl?: string
|
||||
}
|
||||
|
||||
export interface DesktopConnectionTestResult {
|
||||
baseUrl: string
|
||||
ok: boolean
|
||||
version: string | null
|
||||
}
|
||||
|
||||
export interface DesktopBootProgress {
|
||||
error: string | null
|
||||
fakeMode: boolean
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue