Compare commits

...

2 Commits

Author SHA1 Message Date
ethernet
0a8d4da69a WIPipw wipwip 2026-06-26 22:07:54 -04:00
ethernet
4dce531189 wip thin client 2026-06-26 19:30:29 -04:00
33 changed files with 1067 additions and 268 deletions

View File

@@ -0,0 +1,20 @@
'use strict'
/**
* build-mode.cjs — pure helper for the desktop's thin-vs-thick build mode.
*
* The desktop ships in two shapes:
* - thick (default): bundles the first-launch bootstrap installer, can
* spawn a local Hermes backend, and supports in-app self-update.
* - thin: no bootstrap, no local backend, no self-update. Connects ONLY
* to a remote gateway. Used for sandboxed/package-managed deployments
* (Flatpak, Snap, etc.) where the agent lives elsewhere.
*
* The esbuild bundler bakes this env var into the source code, so it's read at build time, not runtime.
*/
function isThinClient() {
return process.env.HERMES_DESKTOP_BUILD_MODE === 'thin'
}
module.exports = { isThinClient }

View File

@@ -0,0 +1,41 @@
'use strict'
const test = require('node:test')
const assert = require('node:assert/strict')
// We test build-mode.cjs by controlling process.env directly. The module
// reads process.env.HERMES_DESKTOP_BUILD_MODE at call time (not import time),
// so we can mutate the env and re-require to exercise both modes.
function freshModule() {
// Bust the require cache so the module re-evaluates with the current env.
delete require.cache[require.resolve('./build-mode.cjs')]
return require('./build-mode.cjs')
}
test('isThinClient returns false by default (thick mode)', () => {
const prev = process.env.HERMES_DESKTOP_BUILD_MODE
delete process.env.HERMES_DESKTOP_BUILD_MODE
const { isThinClient } = freshModule()
assert.equal(isThinClient(), false)
process.env.HERMES_DESKTOP_BUILD_MODE = prev
})
test('isThinClient returns true when HERMES_DESKTOP_BUILD_MODE=thin', () => {
const prev = process.env.HERMES_DESKTOP_BUILD_MODE
process.env.HERMES_DESKTOP_BUILD_MODE = 'thin'
const { isThinClient } = freshModule()
assert.equal(isThinClient(), true)
process.env.HERMES_DESKTOP_BUILD_MODE = prev
})
test('isThinClient returns false for non-thin values', () => {
const prev = process.env.HERMES_DESKTOP_BUILD_MODE
process.env.HERMES_DESKTOP_BUILD_MODE = 'thick'
const { isThinClient } = freshModule()
assert.equal(isThinClient(), false)
process.env.HERMES_DESKTOP_BUILD_MODE = 'thick-client'
const { isThinClient: isThin2 } = freshModule()
assert.equal(isThin2(), false)
process.env.HERMES_DESKTOP_BUILD_MODE = prev
})

View File

@@ -24,6 +24,7 @@ const https = require('node:https')
const path = require('node:path')
const { pathToFileURL } = require('node:url')
const { execFileSync, spawn } = require('node:child_process')
const { isThinClient } = require('./build-mode.cjs')
const { installEmbedReferer } = require('./embed-referer.cjs')
const { detectRemoteDisplay, isWindowsBinaryPathInWsl, isWslEnvironment } = require('./bootstrap-platform.cjs')
const { runBootstrap } = require('./bootstrap-runner.cjs')
@@ -1839,6 +1840,17 @@ async function resolveHealedBranch(updateRoot, branch) {
}
async function checkUpdates() {
// Thin client: no local checkout to git-pull, no bundled updater. Updates
// come from the package manager (Flatpak, Snap, etc.), not in-app self-update.
if (isThinClient()) {
return {
supported: false,
reason: 'thin-client',
message: 'Updates are managed by your package manager. This is a thin client build.',
fetchedAt: Date.now()
}
}
const updateRoot = resolveUpdateRoot()
let { branch } = readDesktopUpdateConfig()
const gitDir = path.join(updateRoot, '.git')
@@ -1972,6 +1984,10 @@ let updateInFlight = false
// updater isn't staged (e.g. a dev/source run that never went through the
// installer); callers degrade gracefully.
function resolveUpdaterBinary() {
// Thin client: no staged Tauri updater — the packaged app is managed
// externally. Returning null lets the existing callers degrade gracefully
// (the manual-command path), though applyUpdates already short-circuits.
if (isThinClient()) return null
const name = IS_WINDOWS ? 'hermes-setup.exe' : 'hermes-setup'
const candidate = path.join(HERMES_HOME, name)
return fileExists(candidate) ? candidate : null
@@ -2130,6 +2146,16 @@ async function releaseBackendLock(updateRoot, tag) {
// Detection (checkUpdates / commit changelog / "N behind") stays in the UI;
// only this apply action changed.
async function applyUpdates(opts = {}) {
// Thin client: self-update is not supported. The packaged app is managed
// externally (Flatpak, Snap, etc.).
if (isThinClient()) {
return {
ok: false,
error: 'unsupported',
message: 'Self-update is not available in thin client builds. Use your package manager to update.'
}
}
if (updateInFlight) {
throw new Error('An update is already in progress.')
}
@@ -2831,6 +2857,23 @@ function createActiveBackend(dashboardArgs) {
}
function resolveHermesBackend(dashboardArgs) {
// Thin client: no local backend, no bootstrap. The only valid path is a
// remote gateway connection. Returning the bootstrap-needed sentinel here
// would kick off install.ps1, which the thin build doesn't ship. Instead
// return a dedicated sentinel so startHermes() can produce a clear error
// directing the user to configure a remote gateway.
if (isThinClient()) {
return {
kind: 'thin-client-no-local',
label: 'Thin client build — remote gateway required',
command: null,
args: dashboardArgs,
bootstrap: false,
env: {},
shell: false
}
}
// 1. Explicit override -- HERMES_DESKTOP_HERMES_ROOT points at a developer
// checkout. Honour it as-is (no bootstrap; the user is driving).
const overrideRoot = process.env.HERMES_DESKTOP_HERMES_ROOT && path.resolve(process.env.HERMES_DESKTOP_HERMES_ROOT)
@@ -2974,6 +3017,19 @@ function resolveHermesBackend(dashboardArgs) {
}
async function ensureRuntime(backend) {
// Thin client: resolveHermesBackend returned a sentinel telling us there
// is no local backend to spawn. Rather than crashing on a null command
// spawn, throw a clear error so startHermes() catches it and the boot-
// failure overlay surfaces the "configure a remote gateway" message.
if (backend.kind === 'thin-client-no-local') {
const err = new Error(
'This is a thin client build with no bundled Hermes agent. ' +
'Go to Settings → Gateway and configure a remote gateway URL.'
)
err.isThinClientNoLocal = true
throw err
}
if (!backend.bootstrap) {
await advanceBootProgress('runtime.external', `Using ${backend.label}`, 32)
return applyWindowsNoConsoleSpawnHints(backend)
@@ -4985,6 +5041,10 @@ async function testDesktopConnectionConfig(input = {}) {
if (authMode !== 'oauth') {
token = decryptDesktopSecret(block.token)
}
} else if (isThinClient()) {
// Thin client: no local backend to test against. A "local" connection
// test is meaningless — there's no bundled agent to reach.
throw new Error('Local connection test is not available in thin client builds. Configure a remote gateway URL.')
} else {
const remote = (await resolveRemoteBackend(key)) || (await startHermes())
baseUrl = remote.baseUrl
@@ -5401,6 +5461,17 @@ async function startHermes() {
}
}
// Thin client: the remote check above was the only path. If we get here,
// no remote was configured — refuse to spawn a local backend.
if (isThinClient()) {
const err = new Error(
'No remote gateway configured. This thin client build requires a remote gateway. ' +
'Go to Settings → Gateway to configure one.'
)
err.isThinClientNoRemote = true
throw err
}
// Mutual exclusion with an in-app update (#50238). If this instance was
// relaunched while the Tauri updater is still applying an update, spawning
// a local backend now re-locks the venv shim and gets killed by the
@@ -6120,6 +6191,10 @@ ipcMain.on('hermes:pet-overlay:control', (_event, payload) => {
mainWindow.webContents.send('hermes:pet-overlay:control', payload)
})
ipcMain.handle('hermes:bootstrap:reset', async () => {
// Thin client: no bootstrap to reset. Just reload the window.
if (isThinClient()) {
return { ok: true }
}
// Renderer's "Reload and retry" path. Clear the latched failure and
// reset connection state so the next startHermes() call restarts the
// full backend flow (including a fresh runBootstrap pass).
@@ -6140,6 +6215,10 @@ ipcMain.handle('hermes:bootstrap:reset', async () => {
return { ok: true }
})
ipcMain.handle('hermes:bootstrap:repair', async () => {
// Thin client: no local install to repair.
if (isThinClient()) {
return { ok: true }
}
// Forceful repair: drop the bootstrap-complete marker so the next
// startHermes() re-runs the full installer (refreshing a broken/partial
// venv), and clear any latched failure + live connection. The renderer
@@ -7157,6 +7236,23 @@ function uninstallVenvPython() {
}
async function getUninstallSummary() {
// Thin client: no local agent install to uninstall. Return a minimal
// summary so the settings UI can show "nothing to remove" instead of
// probing for a venv that doesn't exist.
if (isThinClient()) {
return {
hermes_home: HERMES_HOME,
agent_installed: false,
gui_installed: true,
source_built_artifacts: [],
packaged_app_paths: [],
userdata_dir: app.getPath('userData'),
userdata_exists: true,
platform: process.platform,
probe: 'thin-client'
}
}
const py = uninstallVenvPython()
const agentRoot = ACTIVE_HERMES_ROOT
// Fast JS-side fallback used when the agent venv is gone (lite client) or the
@@ -7329,6 +7425,11 @@ async function runDesktopUninstall(mode) {
ipcMain.handle('hermes:uninstall:summary', async () => getUninstallSummary())
ipcMain.handle('hermes:uninstall:run', async (_event, payload) => {
// Thin client: no local agent to uninstall. The packaged app is managed
// by the OS package manager.
if (isThinClient()) {
return { ok: false, error: 'unsupported', message: 'Uninstall is handled by your package manager in thin client builds.' }
}
const mode = payload && typeof payload === 'object' ? payload.mode : payload
return runDesktopUninstall(String(mode || ''))
})
@@ -7421,6 +7522,7 @@ function registerDeepLinkProtocol() {
// whole new app instead of routing into the running one.
const _gotSingleInstanceLock = app.requestSingleInstanceLock()
if (!_gotSingleInstanceLock) {
console.log("Hermes Desktop is already running, exiting.")
app.quit()
} else {
app.on('second-instance', (_event, argv) => {

View File

@@ -18,11 +18,13 @@
"profile:main": "wait-on http://127.0.0.1:5174 && cross-env XCURSOR_SIZE=24 HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron --inspect=9229 .",
"profile:main:cpu": "wait-on http://127.0.0.1:5174 && cross-env XCURSOR_SIZE=24 NODE_OPTIONS=--cpu-prof HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron .",
"start": "npm run build && electron .",
"build": "node scripts/assert-root-install.cjs && node scripts/write-build-stamp.cjs && node scripts/stage-native-deps.cjs && tsc -b && vite build && node scripts/bundle-electron-main.mjs && npm run postbuild",
"build": "node scripts/assert-root-install.cjs && node scripts/write-build-stamp.cjs && node scripts/stage-native-deps.cjs && tsc -b && vite build && node scripts/bundle-electron-main.mjs && npm run postbuild",
"build:thin": "cross-env HERMES_DESKTOP_BUILD_MODE=thin npm run build",
"postbuild": "node scripts/assert-dist-built.cjs",
"prebuilder": "node scripts/patch-electron-builder-mac-binary.cjs",
"builder": "cross-env NODE_OPTIONS=--max-old-space-size=16384 node scripts/run-electron-builder.cjs",
"pack": "npm run build && npm run builder -- --dir",
"pack:thin": "cross-env HERMES_DESKTOP_BUILD_MODE=thin npm run pack",
"dist": "npm run build && npm run builder",
"dist:mac": "npm run build && npm run builder -- --mac",
"dist:mac:dmg": "npm run build && npm run builder -- --mac dmg",
@@ -31,13 +33,15 @@
"dist:win:msi": "npm run build && npm run builder -- --win msi",
"dist:win:nsis": "npm run build && npm run builder -- --win nsis",
"dist:linux": "npm run build && npm run builder -- --linux AppImage deb rpm",
"dist:thin": "cross-env HERMES_DESKTOP_BUILD_MODE=thin npm run dist",
"dist:linux:thin": "cross-env HERMES_DESKTOP_BUILD_MODE=thin npm run dist:linux",
"test:desktop": "node scripts/test-desktop.mjs",
"test:desktop:all": "node scripts/test-desktop.mjs all",
"test:desktop:dmg": "node scripts/test-desktop.mjs dmg",
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/backend-ready.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/link-title-window.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/git-worktree-ops.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/update-count.test.cjs electron/update-rebuild.test.cjs electron/update-marker.test.cjs electron/update-relaunch.test.cjs electron/windows-user-env.test.cjs electron/wsl-clipboard-image.test.cjs electron/titlebar-overlay-width.test.cjs electron/window-state.test.cjs",
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/backend-ready.test.cjs electron/bootstrap-runner.test.cjs electron/build-mode.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/link-title-window.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/git-worktree-ops.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/update-count.test.cjs electron/update-rebuild.test.cjs electron/update-marker.test.cjs electron/update-relaunch.test.cjs electron/windows-user-env.test.cjs electron/wsl-clipboard-image.test.cjs electron/titlebar-overlay-width.test.cjs electron/window-state.test.cjs",
"typecheck": "tsc -p . --noEmit",
"lint": "eslint src/ electron/",
"lint:fix": "eslint src/ electron/ --fix",

View File

@@ -2,10 +2,32 @@
* Desktop bundles ship precompiled renderer assets. Returning false here tells
* electron-builder to skip the node_modules collector/install step, which
* avoids workspace dependency graph explosions and keeps packaging
* deterministic across environments. The Hermes Agent Python payload is no
* longer bundled; the Electron app fetches it at first launch via
* `install.ps1`'s stage protocol (Windows). See `electron/main.cjs`.
* deterministic across environments.
*
* In thin-client builds we also strip the install-stamp and native-deps
* extraResources entries — no bootstrap, no local PTY.
*/
module.exports = async function beforeBuild() {
const path = require('node:path')
const THIN_CLIENT = process.env.HERMES_DESKTOP_BUILD_MODE === 'thin'
module.exports = async function beforeBuild(context) {
if (THIN_CLIENT && context.packager) {
// Strip install-stamp.json and native-deps from extraResources — neither
// exists in a thin build (write-build-stamp and stage-native-deps are
// skipped in build:thin).
const buildConfig = context.packager.config
if (Array.isArray(buildConfig.extraResources)) {
buildConfig.extraResources = buildConfig.extraResources.filter(
entry => {
const to = typeof entry === 'object' && entry ? entry.to : null
if (to === 'install-stamp.json' || to === 'native-deps') {
return false
}
return true
}
)
}
}
return false
}

View File

@@ -16,6 +16,11 @@ const root = resolve(here, '..')
const entry = resolve(root, 'electron/main.cjs')
const tmp = resolve(root, 'electron/main.bundled.cjs')
// Thin-client builds strip bootstrap, local backend, and self-update.
// Bake the flag into the bundle so build-mode.cjs's process.env read resolves
// to a string literal at runtime (no env var needed in the packaged app).
const thinClient = process.env.HERMES_DESKTOP_BUILD_MODE === 'thin'
await build({
entryPoints: [entry],
bundle: true,
@@ -24,6 +29,9 @@ await build({
target: 'node20',
outfile: tmp,
external: ['electron', 'node-pty'],
define: {
'process.env.HERMES_DESKTOP_BUILD_MODE': JSON.stringify(thinClient ? 'thin' : '')
},
logLevel: 'info'
})

View File

@@ -1,4 +1,4 @@
"use strict"
'use strict'
/**
* Writes apps/desktop/build/install-stamp.json with the git ref the desktop
@@ -17,29 +17,30 @@
* }
*
* Source preference order:
* 1. CI env vars ($GITHUB_SHA / $GITHUB_REF_NAME) -- avoid edge cases with
* 1. BUILD_STAMP env var (json)
* 2. CI env vars ($GITHUB_SHA / $GITHUB_REF_NAME) -- avoid edge cases with
* shallow clones, detached HEADs, etc. in CI.
* 2. Local `git rev-parse` against the parent repo (../..).
* 3. Local `git rev-parse` against the parent repo (../..).
*
* Dev / out-of-repo builds without git produce an explicit error rather than
* silently writing an unstamped manifest -- the packaged app refuses to
* bootstrap without a stamp.
*/
const fs = require("fs")
const path = require("path")
const { execSync } = require("child_process")
const fs = require('fs')
const path = require('path')
const { execSync } = require('child_process')
const STAMP_SCHEMA_VERSION = 1
const DESKTOP_ROOT = path.resolve(__dirname, "..")
const REPO_ROOT = path.resolve(DESKTOP_ROOT, "..", "..")
const OUT_DIR = path.join(DESKTOP_ROOT, "build")
const OUT_FILE = path.join(OUT_DIR, "install-stamp.json")
const DESKTOP_ROOT = path.resolve(__dirname, '..')
const REPO_ROOT = path.resolve(DESKTOP_ROOT, '..', '..')
const OUT_DIR = path.join(DESKTOP_ROOT, 'build')
const OUT_FILE = path.join(OUT_DIR, 'install-stamp.json')
function tryExec(cmd, opts) {
try {
return execSync(cmd, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], ...opts }).trim()
return execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], ...opts }).trim()
} catch {
return null
}
@@ -53,52 +54,79 @@ function fromCI() {
commit: sha,
branch: branch,
dirty: false, // CI builds from a checkout-of-ref by definition
source: "ci"
source: 'ci'
}
}
function fromLocalGit() {
const sha = tryExec("git rev-parse HEAD", { cwd: REPO_ROOT })
const sha = tryExec('git rev-parse HEAD', { cwd: REPO_ROOT })
if (!sha) return null
const branch = tryExec("git rev-parse --abbrev-ref HEAD", { cwd: REPO_ROOT })
const branch = tryExec('git rev-parse --abbrev-ref HEAD', { cwd: REPO_ROOT })
// `git status --porcelain -uno` is empty iff tracked files match HEAD.
// We exclude untracked files (-uno) intentionally: a developer who's
// checked out an installer scratch dir alongside the repo shouldn't
// poison every local build with a [DIRTY] stamp. We DO care about
// tracked-but-modified files because those mean the .exe content
// differs from the commit being pinned.
const status = tryExec("git status --porcelain -uno", { cwd: REPO_ROOT })
const status = tryExec('git status --porcelain -uno', { cwd: REPO_ROOT })
const dirty = status !== null && status.length > 0
return {
commit: sha,
branch: branch === "HEAD" ? null : branch, // detached HEAD -> null
branch: branch === 'HEAD' ? null : branch, // detached HEAD -> null
dirty: dirty,
source: "local"
source: 'local'
}
}
function fromEnv() {
const stamp = process.env.BUILD_STAMP
if (!stamp) return null
const json = JSON.parse(stamp)
if (json.schemaVersion !== 1) {
throw new Error('Schema version !== 1')
}
if (typeof json.branch !== 'string') {
throw new Error('Expected branch to be string')
}
if (typeof json.commit !== 'string') {
throw new Error('Expected commit to be string')
}
if (typeof json.builtAt !== 'string') {
throw new Error('Expected builtAt to be string')
}
if (typeof json.dirty !== 'boolean') {
throw new Error('Expected dirty to be boolean')
}
if (typeof json.source !== 'string') {
throw new Error('Expected source to be string')
}
return json
}
function main() {
const stamp = fromCI() || fromLocalGit()
const stamp = fromEnv() || fromCI() || fromLocalGit()
if (!stamp || !stamp.commit) {
console.error(
"[write-build-stamp] ERROR: could not determine git commit.\n" +
" - $GITHUB_SHA not set\n" +
" - `git rev-parse HEAD` failed at " +
'[write-build-stamp] ERROR: could not determine git commit.\n' +
' - $GITHUB_SHA not set\n' +
' - `git rev-parse HEAD` failed at ' +
REPO_ROOT +
"\n" +
"Packaged builds require a git ref to pin first-launch install.ps1\n" +
"against. Run from a git checkout or set $GITHUB_SHA explicitly."
'\n' +
'Packaged builds require a git ref to pin first-launch install.ps1\n' +
'against. Run from a git checkout or set $GITHUB_SHA explicitly.'
)
process.exit(1)
}
if (stamp.dirty) {
console.warn(
"[write-build-stamp] WARNING: working tree is dirty.\n" +
" Pinning to " +
'[write-build-stamp] WARNING: working tree is dirty.\n' +
' Pinning to ' +
stamp.commit.slice(0, 12) +
" but the packaged code may differ from that commit.\n" +
" Commit your changes before publishing this build."
' but the packaged code may differ from that commit.\n' +
' Commit your changes before publishing this build.'
)
}
@@ -112,14 +140,14 @@ function main() {
}
fs.mkdirSync(OUT_DIR, { recursive: true })
fs.writeFileSync(OUT_FILE, JSON.stringify(payload, null, 2) + "\n", "utf8")
fs.writeFileSync(OUT_FILE, JSON.stringify(payload, null, 2) + '\n', 'utf8')
console.log(
"[write-build-stamp] wrote " +
'[write-build-stamp] wrote ' +
path.relative(REPO_ROOT, OUT_FILE) +
" -> " +
' -> ' +
stamp.commit.slice(0, 12) +
(stamp.branch ? " (" + stamp.branch + ")" : "") +
(stamp.dirty ? " [DIRTY]" : "")
(stamp.branch ? ' (' + stamp.branch + ')' : '') +
(stamp.dirty ? ' [DIRTY]' : '')
)
}

View File

@@ -10,6 +10,7 @@ import { GatewayConnectingOverlay } from '@/components/gateway-connecting-overla
import { Pane, PaneMain } from '@/components/pane-shell'
import { RemoteDisplayBanner } from '@/components/remote-display-banner'
import { useMediaQuery } from '@/hooks/use-media-query'
import { isThinClient } from '@/lib/build-mode'
import { cn } from '@/lib/utils'
import { useSkinCommand } from '@/themes/use-skin-command'
@@ -1108,7 +1109,7 @@ export function DesktopController() {
{!isSecondaryWindow() && <DesktopInstallOverlay />}
{!isSecondaryWindow() && (
<DesktopOnboardingOverlay
enabled={gatewayState === 'open'}
enabled={isThinClient() || gatewayState === 'open'}
onCompleted={() => {
void refreshHermesConfig()
void refreshCurrentModel()

View File

@@ -5,6 +5,7 @@ import { BrandMark } from '@/components/brand-mark'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { type Translations, useI18n } from '@/i18n'
import { isThinClient } from '@/lib/build-mode'
import { CheckCircle2, ExternalLink, Loader2, RefreshCw } from '@/lib/icons'
import { cn } from '@/lib/utils'
import {
@@ -106,75 +107,79 @@ export function AboutSettings() {
</div>
<div className="mx-auto mt-4 w-full max-w-2xl">
<SectionHeading icon={RefreshCw} title={a.updates} />
{isThinClient() ? null : (
<>
<SectionHeading icon={RefreshCw} title={a.updates} />
<div
className={cn(
'rounded-xl border px-4 py-3 text-sm',
statusTone === 'available' && 'border-primary/30 bg-primary/5 text-foreground',
statusTone === 'error' && 'border-destructive/35 bg-destructive/5 text-destructive',
statusTone === 'idle' && 'border-border/70 bg-muted/20 text-foreground'
)}
>
<div className="flex items-start gap-2">
{statusTone === 'available' ? (
<Codicon className="mt-0.5 size-4 shrink-0 text-primary" name="cloud-download" size="1rem" />
) : statusTone === 'error' ? null : (
<CheckCircle2 className="mt-0.5 size-4 shrink-0 text-emerald-600 dark:text-emerald-400" />
)}
<div className="min-w-0">
<p className="font-medium">{statusLine}</p>
<p className="mt-1 text-xs text-muted-foreground">
{a.lastChecked(relativeTime(status?.fetchedAt, a))}
{justChecked && !checking ? a.justNowSuffix : ''}
</p>
</div>
</div>
<div className="mt-3 flex flex-wrap items-center gap-4">
<Button
disabled={checking || applying || !supported}
onClick={() => void handleCheck()}
size="sm"
variant="textStrong"
<div
className={cn(
'rounded-xl border px-4 py-3 text-sm',
statusTone === 'available' && 'border-primary/30 bg-primary/5 text-foreground',
statusTone === 'error' && 'border-destructive/35 bg-destructive/5 text-destructive',
statusTone === 'idle' && 'border-border/70 bg-muted/20 text-foreground'
)}
>
{checking ? <Loader2 className="size-3 animate-spin" /> : <RefreshCw className="size-3" />}
{checking ? a.checking : a.checkNow}
</Button>
<div className="flex items-start gap-2">
{statusTone === 'available' ? (
<Codicon className="mt-0.5 size-4 shrink-0 text-primary" name="cloud-download" size="1rem" />
) : statusTone === 'error' ? null : (
<CheckCircle2 className="mt-0.5 size-4 shrink-0 text-emerald-600 dark:text-emerald-400" />
)}
<div className="min-w-0">
<p className="font-medium">{statusLine}</p>
<p className="mt-1 text-xs text-muted-foreground">
{a.lastChecked(relativeTime(status?.fetchedAt, a))}
{justChecked && !checking ? a.justNowSuffix : ''}
</p>
</div>
</div>
{behind > 0 && supported && !applying && (
<>
<Button onClick={() => startActiveUpdate()} size="sm">
{a.updateNow}
<div className="mt-3 flex flex-wrap items-center gap-4">
<Button
disabled={checking || applying || !supported}
onClick={() => void handleCheck()}
size="sm"
variant="textStrong"
>
{checking ? <Loader2 className="size-3 animate-spin" /> : <RefreshCw className="size-3" />}
{checking ? a.checking : a.checkNow}
</Button>
<Button onClick={() => openUpdatesWindow()} size="sm" variant="textStrong">
{a.seeWhatsNew}
{behind > 0 && supported && !applying && (
<>
<Button onClick={() => startActiveUpdate()} size="sm">
{a.updateNow}
</Button>
<Button onClick={() => openUpdatesWindow()} size="sm" variant="textStrong">
{a.seeWhatsNew}
</Button>
</>
)}
<Button asChild className="ml-auto" size="sm" variant="text">
<a
href={RELEASE_NOTES_URL}
onClick={event => {
event.preventDefault()
void window.hermesDesktop?.openExternal?.(RELEASE_NOTES_URL)
}}
rel="noreferrer"
target="_blank"
>
<ExternalLink className="size-3" />
{a.releaseNotes}
</a>
</Button>
</>
)}
</div>
</div>
<Button asChild className="ml-auto" size="sm" variant="text">
<a
href={RELEASE_NOTES_URL}
onClick={event => {
event.preventDefault()
void window.hermesDesktop?.openExternal?.(RELEASE_NOTES_URL)
}}
rel="noreferrer"
target="_blank"
>
<ExternalLink className="size-3" />
{a.releaseNotes}
</a>
</Button>
</div>
</div>
<ListRow
description={a.automaticUpdatesDesc}
hint={a.branchCommit(status?.branch ?? 'unknown', status?.currentSha?.slice(0, 7) ?? 'unknown')}
title={a.automaticUpdates}
/>
<ListRow
description={a.automaticUpdatesDesc}
hint={a.branchCommit(status?.branch ?? 'unknown', status?.currentSha?.slice(0, 7) ?? 'unknown')}
title={a.automaticUpdates}
/>
</>
)}
<UninstallSection />
</div>

View File

@@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import type { DesktopAuthProvider, DesktopConnectionProbeResult } from '@/global'
import { useI18n } from '@/i18n'
import { isThinClient } from '@/lib/build-mode'
import { AlertCircle, Check, FileText, Globe, Loader2, LogIn, Monitor } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
@@ -466,16 +467,18 @@ export function GatewaySettings() {
) : null}
<div className="grid gap-3 sm:grid-cols-2">
{isThinClient() ? null : (
<ModeCard
active={state.mode === 'local'}
description={g.localDesc}
disabled={state.envOverride}
icon={Monitor}
onSelect={() => setState(current => ({ ...current, mode: 'local' }))}
title={g.localTitle}
/>
)}
<ModeCard
active={state.mode === 'local'}
description={g.localDesc}
disabled={state.envOverride}
icon={Monitor}
onSelect={() => setState(current => ({ ...current, mode: 'local' }))}
title={g.localTitle}
/>
<ModeCard
active={state.mode === 'remote'}
active={state.mode === 'remote' || isThinClient()}
description={g.remoteDesc}
disabled={state.envOverride}
icon={Globe}

View File

@@ -0,0 +1,238 @@
// ─── Gateway connect form ───────────────────────────────────────────────
// Shown on thin-client first run (the only onboarding path) and on the
// thick client when the user picks "Connect to a remote gateway". Reuses
// the same desktop bridge IPC (probe / save / apply / oauth-login) as
// Settings → Gateway, but wrapped in the onboarding card chrome.
import { useEffect, useRef, useState } from "react"
import type { DesktopConnectionProbeResult } from "@/global"
import { useI18n } from "@/i18n"
import { AlertCircle, ChevronLeft, Globe, Loader2, LogIn } from "@/lib/icons"
import { gatewayOauthLogin, saveGatewayConnection } from "@/store/onboarding"
import { Button } from "./ui/button"
import { ErrorIcon } from "./ui/error-state"
import { Input } from "./ui/input"
type GatewayProbeStatus = 'idle' | 'probing' | 'done' | 'error'
function useGatewayProbe(url: string) {
const [status, setStatus] = useState<GatewayProbeStatus>('idle')
const [probe, setProbe] = useState<DesktopConnectionProbeResult | null>(null)
const seq = useRef(0)
useEffect(() => {
const trimmed = url.trim()
if (!trimmed || !/^https?:\/\//i.test(trimmed)) {
setStatus('idle')
setProbe(null)
return
}
const desktop = window.hermesDesktop
if (!desktop?.probeConnectionConfig) {
return
}
const current = ++seq.current
setStatus('probing')
const timer = setTimeout(() => {
desktop
.probeConnectionConfig(trimmed)
.then(result => {
if (seq.current !== current) {
return
}
setProbe(result)
setStatus(result.reachable ? 'done' : 'error')
})
.catch(() => {
if (seq.current !== current) {
return
}
setProbe(null)
setStatus('error')
})
}, 500)
return () => clearTimeout(timer)
}, [url])
return { status, probe }
}
export function GatewayConnectForm({ onBack }: { onBack: null | (() => void) }) {
const { t } = useI18n()
const g = t.onboarding.gateway
const [url, setUrl] = useState('')
const [token, setToken] = useState('')
const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState<'save' | 'signin' | null>(null)
const { status: probeStatus, probe } = useGatewayProbe(url)
const trimmedUrl = url.trim()
const hasUrl = Boolean(trimmedUrl) && /^https?:\/\//i.test(trimmedUrl)
// Effective auth mode: a reachable probe wins; otherwise fall back to
// 'token' so the token box is visible by default.
const authMode = probeStatus === 'done' && probe && probe.authMode !== 'unknown'
? probe.authMode
: 'token'
const authResolved = probeStatus === 'done'
const providers = probe?.providers ?? []
const providerLabel = providers.length === 1
? (providers[0].displayName || providers[0].name)
: providers.length > 1
? providers.map(p => p.displayName || p.name).join(' / ')
: g.identityProvider
const isPasswordProvider = providers.length > 0 && providers.every(p => p.supportsPassword)
const canSubmit = hasUrl && (authMode === 'oauth' ? false : Boolean(token.trim()))
const submit = async () => {
if (!hasUrl || busy) {
return
}
setBusy('save')
setError(null)
const result = await saveGatewayConnection(trimmedUrl, authMode, token.trim() || undefined)
if (!result.ok) {
setError(result.message ?? g.saveFailed)
setBusy(null)
}
// On success, applyConnectionConfig reloads the window — no need to clear busy.
}
const signIn = async () => {
if (!hasUrl || busy) {
return
}
setBusy('signin')
setError(null)
const result = await gatewayOauthLogin(trimmedUrl)
if (!result.ok) {
setError(result.message ?? g.signInFailed)
setBusy(null)
}
// On success, applyConnectionConfig reloads the window.
}
return (
<div className="grid gap-4">
{onBack ? (
<Button className="-mt-1 self-start font-medium" onClick={onBack} size="xs" type="button" variant="text">
<ChevronLeft className="size-3" />
{g.backToProviders}
</Button>
) : null}
<div className="grid gap-2">
<label className="text-sm font-medium text-muted-foreground" htmlFor="gw-url">
{g.urlLabel}
</label>
<Input
autoComplete="off"
autoFocus
className={`font-mono`}
// className={`font-mono ${hasUrl ? (probeStatus === 'probing' ? 'border-orange' : 'border-green') : ''}`}
id="gw-url"
onChange={e => setUrl(e.target.value)}
placeholder="https://gateway.example.com/hermes"
value={url}
/>
<p className="text-xs text-muted-foreground">{g.urlHint}</p>
</div>
{/* Probe status */}
{hasUrl && probeStatus === 'probing' ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
{g.probing}
</div>
) : null}
{hasUrl && probeStatus === 'error' ? (
<div className="flex items-start gap-2 text-sm text-muted-foreground">
<AlertCircle className="mt-0.5 size-4 shrink-0" />
{g.probeError}
</div>
) : null}
{/* OAuth / password gateways: show sign-in button */}
{hasUrl && authResolved && authMode === 'oauth' ? (
<div className="grid gap-2">
<div className="flex items-center gap-2">
<Button disabled={Boolean(busy)} onClick={() => void signIn()}>
{busy === 'signin' ? <Loader2 className="animate-spin" /> : <LogIn />}
{isPasswordProvider ? g.signIn : g.signInWith(providerLabel)}
</Button>
</div>
<p className="text-xs text-muted-foreground">
{isPasswordProvider ? g.passwordHint : g.oauthHint(providerLabel)}
</p>
</div>
) : null}
{/* Token gateways: show token input */}
{hasUrl && authResolved && authMode === 'token' ? (
<div className="grid gap-2">
<label className="text-sm font-medium text-muted-foreground" htmlFor="gw-token">
{g.tokenLabel}
</label>
<Input
autoComplete="off"
className="font-mono"
id="gw-token"
onChange={e => setToken(e.target.value)}
onKeyDown={e => e.key === 'Enter' && void submit()}
placeholder={g.tokenPlaceholder}
type="password"
value={token}
/>
<p className="text-xs text-muted-foreground">{g.tokenHint}</p>
</div>
) : null}
{/* While probing (or probe error) and no saved config, neither auth UI shows —
show a hint instead of a blank gap. */}
{hasUrl && !authResolved ? (
<p className="text-xs text-muted-foreground">{g.probing}</p>
) : null}
{error ? (
<div className="flex items-start gap-2 text-sm text-destructive">
<ErrorIcon className="mt-0.5 shrink-0" size="0.875rem" />
{error}
</div>
) : null}
<div className="flex items-center justify-between gap-3 pt-1">
<div />
<Button disabled={!canSubmit || Boolean(busy)} onClick={() => void submit()}>
{busy === 'save' ? <Loader2 className="animate-spin" /> : <Globe />}
{busy === 'save' ? g.connecting : g.connect}
</Button>
</div>
</div>
)
}

View File

@@ -6,6 +6,7 @@ import { ErrorIcon } from '@/components/ui/error-state'
import { LogView } from '@/components/ui/log-view'
import type { DesktopConnectionConfig } from '@/global'
import { useI18n } from '@/i18n'
import { isThinClient } from '@/lib/build-mode'
import { FileText, Loader2, LogIn, RefreshCw, Wrench } from '@/lib/icons'
import { $desktopBoot } from '@/store/boot'
import { notify, notifyError } from '@/store/notifications'
@@ -37,10 +38,16 @@ export function BootFailureOverlay() {
const [remoteReauth, setRemoteReauth] = useState<RemoteReauth | null>(null)
const visible = Boolean(boot.error) && !boot.running
// While first-run onboarding owns the picker/flow we let it surface its own
// progress; the recovery overlay is for hard failures, which it covers via a
// higher z-index regardless of onboarding state.
const suppressed = onboarding.flow.status !== 'idle' && onboarding.flow.status !== 'error'
// Thin client: when the gateway connect form is showing (no remote configured
// yet), suppress the boot-failure overlay — the "no remote" error is expected
// and the user is already looking at the form to fix it.
const suppressed =
(onboarding.flow.status !== 'idle' && onboarding.flow.status !== 'error') ||
onboarding.gatewayMode
useEffect(() => {
if (!visible) {
@@ -205,16 +212,18 @@ export function BootFailureOverlay() {
{copy.retry}
</Button>
)}
{!remoteReauth ? (
{remoteReauth ? null : !isThinClient() ? (
<Button disabled={Boolean(busy)} onClick={() => void repair()} variant="secondary">
{busy === 'repair' ? <Loader2 className="animate-spin" /> : <Wrench />}
{copy.repairInstall}
</Button>
) : null}
<Button disabled={Boolean(busy)} onClick={() => void switchToLocalGateway()} variant="secondary">
{busy === 'local' ? <Loader2 className="animate-spin" /> : null}
{copy.useLocalGateway}
</Button>
{isThinClient() ? null : (
<Button disabled={Boolean(busy)} onClick={() => void switchToLocalGateway()} variant="secondary">
{busy === 'local' ? <Loader2 className="animate-spin" /> : null}
{copy.useLocalGateway}
</Button>
)}
<Button onClick={openLogs} variant="ghost">
<FileText />
{copy.openLogs}

View File

@@ -27,7 +27,8 @@ function setProviders(providers: OAuthProvider[]) {
requested: false,
firstRunSkipped: false,
manual: false,
localEndpoint: false
localEndpoint: false,
gatewayMode: false
} satisfies DesktopOnboardingState)
}
@@ -51,7 +52,8 @@ afterEach(() => {
requested: false,
firstRunSkipped: false,
manual: false,
localEndpoint: false
localEndpoint: false,
gatewayMode: false
})
})

View File

@@ -8,9 +8,11 @@ import { Codicon } from '@/components/ui/codicon'
import { ErrorIcon } from '@/components/ui/error-state'
import { Input } from '@/components/ui/input'
import { Loader } from '@/components/ui/loader'
import type { DesktopConnectionProbeResult } from '@/global'
import { getGlobalModelOptions } from '@/hermes'
import { useI18n } from '@/i18n'
import { Check, ChevronDown, ChevronLeft, ChevronRight, ExternalLink, KeyRound, Loader2, Terminal } from '@/lib/icons'
import { isThinClient } from '@/lib/build-mode'
import { AlertCircle, Check, ChevronDown, ChevronLeft, ChevronRight, ExternalLink, Globe, KeyRound, Loader2, LogIn, Terminal } from '@/lib/icons'
import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors'
import { cn } from '@/lib/utils'
import { $desktopBoot, type DesktopBootState } from '@/store/boot'
@@ -25,19 +27,24 @@ import {
DEFAULT_MANUAL_ONBOARDING_REASON,
DEFAULT_ONBOARDING_REASON,
dismissFirstRunOnboarding,
exitGatewayMode,
gatewayOauthLogin,
type OnboardingContext,
type OnboardingFlow,
peekPendingProviderOAuth,
recheckExternalSignin,
refreshOnboarding,
saveGatewayConnection,
saveOnboardingApiKey,
setOnboardingCode,
setOnboardingMode,
setOnboardingModel,
startGatewayOnboarding,
startProviderOAuth,
submitOnboardingCode
} from '@/store/onboarding'
import type { ModelOptionProvider, OAuthProvider } from '@/types/hermes'
import { GatewayConnectForm } from './OnboardingGatewayConnection'
interface DesktopOnboardingOverlayProps {
enabled: boolean
@@ -267,7 +274,10 @@ export function DesktopOnboardingOverlay({ enabled, onCompleted, requestGateway
// The user chose "I'll choose a provider later" on first run. Stay out of the
// way on every subsequent launch — they re-enter via Settings → Providers
// (manual mode), which sets manual=true and bypasses this gate.
if (onboarding.firstRunSkipped && !onboarding.manual) {
// Thin client: never respect the skip — there's no "choose later" escape from
// the gateway form (it's the only path), and a skipped thin client has no
// working backend to fall back on.
if (onboarding.firstRunSkipped && !onboarding.manual && !isThinClient()) {
return null
}
@@ -288,7 +298,9 @@ export function DesktopOnboardingOverlay({ enabled, onCompleted, requestGateway
// In manual mode the app is already configured, so the flow is "ready"
// immediately — no runtime gate needed. Otherwise wait for the readiness
// check (configured === false) before showing the picker.
const ready = onboarding.manual || (enabled && onboarding.configured === false)
// Gateway mode (thin client or user picked "remote gateway") is also ready
// immediately — there's no provider list to wait for.
const ready = onboarding.manual || onboarding.gatewayMode || (enabled && onboarding.configured === false)
const showPicker = flow.status === 'idle' || flow.status === 'success'
// The final "you're in" screen drops the card chrome and floats centered on
// the surface — same bare, cinematic treatment as the connecting overlay.
@@ -332,7 +344,11 @@ export function DesktopOnboardingOverlay({ enabled, onCompleted, requestGateway
<div className="grid gap-3 p-5">
{reason ? <ReasonNotice reason={reason} /> : null}
{ready ? (
showPicker ? (
onboarding.gatewayMode && showPicker ? (
<GatewayConnectForm
onBack={isThinClient() ? null : exitGatewayMode}
/>
) : showPicker ? (
<Picker ctx={ctx} />
) : (
<FlowPanel ctx={ctx} flow={flow} leaving={leaving} onBegin={finalizeOnboarding} />
@@ -397,6 +413,7 @@ function Header() {
)
}
export const FEATURED_ID = 'nous'
const SHOW_ALL_KEY = 'hermes-onboarding-show-all-v1'
@@ -491,15 +508,27 @@ export function Picker({ ctx }: { ctx: OnboardingContext }) {
In manual mode the overlay already has a close affordance, so the
"choose later" escape would be redundant — hide it. */}
{manual ? <span /> : <ChooseLaterLink />}
<Button
className="-mr-2 font-medium"
onClick={() => setOnboardingMode('apikey')}
size="xs"
type="button"
variant="text"
>
{t.onboarding.haveApiKey}
</Button>
<div className="flex items-center gap-2">
<Button
className="font-medium"
onClick={() => startGatewayOnboarding()}
size="xs"
type="button"
variant="text"
>
<Globe className="size-3" />
{t.onboarding.connectRemoteGateway}
</Button>
<Button
className="-mr-2 font-medium"
onClick={() => setOnboardingMode('apikey')}
size="xs"
type="button"
variant="text"
>
{t.onboarding.haveApiKey}
</Button>
</div>
</div>
</div>
)
@@ -811,6 +840,10 @@ function FlowPanel({
return <DecodedLabel text={t.onboarding.connectedPicking(title)} />
}
if (flow.status === 'gateway_connected') {
return <Status>{t.onboarding.gateway.connecting}</Status>
}
if (flow.status === 'confirming_model') {
return <ConfirmingModelPanel flow={flow} leaving={leaving} onBegin={onBegin} />
}

View File

@@ -42,7 +42,8 @@ function resetStores() {
requested: false,
firstRunSkipped: false,
manual: false,
localEndpoint: false
localEndpoint: false,
gatewayMode: false
})
}

View File

@@ -1735,7 +1735,27 @@ export const en: Translations = {
price: (input, output) => `${input} in / ${output} out per Mtok`,
change: 'Change',
startChatting: 'Begin',
docs: provider => `${provider} docs`
docs: provider => `${provider} docs`,
connectRemoteGateway: 'Connect to a remote gateway',
gateway: {
urlLabel: 'Gateway URL',
urlHint: 'Base URL for the remote Hermes gateway. Path prefixes are supported, e.g. /hermes.',
probing: 'Checking how this gateway authenticates…',
probeError: 'Could not reach this gateway yet. Check the URL — the auth method will appear once it responds.',
signIn: 'Sign in',
signInWith: provider => `Sign in with ${provider}`,
passwordHint: 'This gateway uses a username and password. Sign in to authorize this app.',
oauthHint: provider => `This gateway uses OAuth. Sign in with ${provider} to authorize this app.`,
tokenLabel: 'Session token',
tokenPlaceholder: 'Paste session token',
tokenHint: 'The dashboard session token used for REST and WebSocket access.',
connect: 'Connect',
connecting: 'Connecting',
saveFailed: 'Could not save the gateway connection.',
signInFailed: 'Sign-in failed. Try again.',
backToProviders: 'Back to providers',
identityProvider: 'the gateway'
}
},
modelPicker: {

View File

@@ -1859,7 +1859,27 @@ export const ja = defineLocale({
price: (input, output) => `${input} 入力 / ${output} 出力 per Mtok`,
change: '変更',
startChatting: '始める',
docs: provider => `${provider} ドキュメント`
docs: provider => `${provider} ドキュメント`,
connectRemoteGateway: 'リモートゲートウェイに接続',
gateway: {
urlLabel: 'ゲートウェイ URL',
urlHint: 'リモート Hermes ゲートウェイのベース URL。パスプレフィックス対応例: /hermes。',
probing: 'ゲートウェイの認証方式を確認中…',
probeError: 'ゲートウェイに接続できません。URL を確認してください — 応答後に認証方式が表示されます。',
signIn: 'サインイン',
signInWith: provider => `${provider} でサインイン`,
passwordHint: 'このゲートウェイはユーザー名とパスワードを使用します。サインインしてアプリを認証してください。',
oauthHint: provider => `このゲートウェイは OAuth を使用します。${provider} でサインインしてアプリを認証してください。`,
tokenLabel: 'セッショントークン',
tokenPlaceholder: 'セッショントークンを貼り付け',
tokenHint: 'REST および WebSocket アクセスに使用されるダッシュボードセッショントークン。',
connect: '接続',
connecting: '接続中',
saveFailed: 'ゲートウェイ接続を保存できませんでした。',
signInFailed: 'サインインに失敗しました。再試行してください。',
backToProviders: 'プロバイダーに戻る',
identityProvider: 'ゲートウェイ'
}
},
modelPicker: {

View File

@@ -1393,6 +1393,26 @@ export interface Translations {
change: string
startChatting: string
docs: (provider: string) => string
connectRemoteGateway: string
gateway: {
urlLabel: string
urlHint: string
probing: string
probeError: string
signIn: string
signInWith: (provider: string) => string
passwordHint: string
oauthHint: (provider: string) => string
tokenLabel: string
tokenPlaceholder: string
tokenHint: string
connect: string
connecting: string
saveFailed: string
signInFailed: string
backToProviders: string
identityProvider: string
}
}
modelPicker: {

View File

@@ -1797,7 +1797,27 @@ export const zhHant = defineLocale({
price: (input, output) => `${input} 輸入 / ${output} 輸出 每 Mtok`,
change: '變更',
startChatting: '開始',
docs: provider => `${provider} 文件`
docs: provider => `${provider} 文件`,
connectRemoteGateway: '連線到遠端閘道',
gateway: {
urlLabel: '閘道 URL',
urlHint: '遠端 Hermes 閘道的基礎 URL。支援路徑前綴例如 /hermes。',
probing: '正在檢查閘道認證方式…',
probeError: '暫時無法連線到此閘道。請檢查 URL — 認證方式將在閘道回應後顯示。',
signIn: '登入',
signInWith: provider => `使用 ${provider} 登入`,
passwordHint: '此閘道使用使用者名稱和密碼。登入以授權此應用。',
oauthHint: provider => `此閘道使用 OAuth。使用 ${provider} 登入以授權此應用。`,
tokenLabel: '工作階段權杖',
tokenPlaceholder: '貼上工作階段權杖',
tokenHint: '用於 REST 和 WebSocket 存取的儀表板工作階段權杖。',
connect: '連線',
connecting: '正在連線',
saveFailed: '無法儲存閘道連線。',
signInFailed: '登入失敗。請重試。',
backToProviders: '返回提供商列表',
identityProvider: '閘道'
}
},
modelPicker: {

View File

@@ -1909,7 +1909,27 @@ export const zh: Translations = {
price: (input, output) => `${input} 输入 / ${output} 输出每 Mtok`,
change: '更改',
startChatting: '开始',
docs: provider => `${provider} 文档`
docs: provider => `${provider} 文档`,
connectRemoteGateway: '连接到远程网关',
gateway: {
urlLabel: '网关地址',
urlHint: '远程 Hermes 网关的基础 URL。支持路径前缀例如 /hermes。',
probing: '正在检查网关认证方式…',
probeError: '暂时无法连接到此网关。请检查 URL — 认证方式将在网关响应后显示。',
signIn: '登录',
signInWith: provider => `使用 ${provider} 登录`,
passwordHint: '此网关使用用户名和密码。登录以授权此应用。',
oauthHint: provider => `此网关使用 OAuth。使用 ${provider} 登录以授权此应用。`,
tokenLabel: '会话令牌',
tokenPlaceholder: '粘贴会话令牌',
tokenHint: '用于 REST 和 WebSocket 访问的仪表板会话令牌。',
connect: '连接',
connecting: '正在连接',
saveFailed: '无法保存网关连接。',
signInFailed: '登录失败。请重试。',
backToProviders: '返回提供商列表',
identityProvider: '网关'
}
},
modelPicker: {

View File

@@ -0,0 +1,25 @@
/**
* Build-mode flag for the renderer.
*
* `__VITE_THIN_CLIENT__` is injected at compile time by vite.config.ts
* (see `define`). In dev it reads the live env var so
* `HERMES_DESKTOP_BUILD_MODE=thin npm run dev` works for testing.
*
* Thin-client builds:
* - No first-launch bootstrap (the app has no bundled installer)
* - No local backend spawn (the app connects ONLY to a remote gateway)
* - No in-app self-update (updates come from the package manager)
*/
// In a packaged build, __VITE_THIN_CLIENT__ is replaced with a literal
// boolean by vite's `define`. In dev (no define) the typeof check falls
// through to the live env-var read so `HERMES_DESKTOP_BUILD_MODE=thin npm
// run dev` works.
const THIN_CLIENT =
typeof __VITE_THIN_CLIENT__ !== 'undefined'
? __VITE_THIN_CLIENT__
: false
export function isThinClient(): boolean {
return THIN_CLIENT
}

View File

@@ -35,6 +35,7 @@ function baseState(overrides: Partial<DesktopOnboardingState> = {}): DesktopOnbo
firstRunSkipped: false,
manual: false,
localEndpoint: false,
gatewayMode: false,
...overrides
}
}

View File

@@ -12,6 +12,7 @@ import {
submitOAuthCode,
validateProviderCredential
} from '@/hermes'
import { isThinClient } from '@/lib/build-mode'
import { evaluateRuntimeReadiness, type RuntimeReadinessResult } from '@/lib/runtime-readiness'
import { notify, notifyError } from '@/store/notifications'
import type { ModelOptionProvider, OAuthProvider, OAuthStartResponse } from '@/types/hermes'
@@ -20,7 +21,7 @@ type PkceStart = Extract<OAuthStartResponse, { flow: 'pkce' }>
type DeviceStart = Extract<OAuthStartResponse, { flow: 'device_code' }>
type LoopbackStart = Extract<OAuthStartResponse, { flow: 'loopback' }>
export type OnboardingMode = 'apikey' | 'oauth'
export type OnboardingMode = 'apikey' | 'oauth' | 'gateway'
export type OnboardingFlow =
| { status: 'idle' }
@@ -34,6 +35,14 @@ export type OnboardingFlow =
| { provider: OAuthProvider; start: OAuthStartResponse; status: 'submitting' }
| { copied: boolean; provider: OAuthProvider; status: 'external_pending' }
| { provider: OAuthProvider; status: 'success' }
| {
// Gateway onboarding: a remote gateway was configured (URL + auth),
// and the connection was saved+applied. The overlay shows a brief
// "connected" confirm before completing — the apply already reloads
// the window, so this is mostly a transitional state.
gatewayUrl: string
status: 'gateway_connected'
}
| {
// After successful credential acquisition, before completing
// onboarding: show the user which model they're getting and let
@@ -77,6 +86,11 @@ export interface DesktopOnboardingState {
* custom endpoint"). Forces the API-key form with the local option
* preselected instead of the OAuth picker. */
localEndpoint: boolean
/** True when the overlay should show the gateway-connection form instead
* of the provider picker. Set on thin-client builds (where
* there's no local backend to configure providers for), or when the user
* picks "Connect to a remote gateway" from the thick-client picker. */
gatewayMode: boolean
}
export interface OnboardingContext {
@@ -156,7 +170,8 @@ const INITIAL: DesktopOnboardingState = {
requested: false,
firstRunSkipped: readCachedSkipped(),
manual: false,
localEndpoint: false
localEndpoint: false,
gatewayMode: false
}
export const $desktopOnboarding = atom<DesktopOnboardingState>(INITIAL)
@@ -430,6 +445,105 @@ export function startManualLocalEndpoint(reason: null | string = null) {
})
}
// Open the onboarding overlay on the gateway-connection form. Used on the
// thin client's first run (no local backend → must configure a remote
// gateway), and on the thick client when the user picks "Connect to a
// remote gateway" from the provider picker. The form drives the same
// desktop bridge IPC (probe / save / apply / oauth-login / test) as the
// Settings → Gateway page — just wrapped in the onboarding chrome.
export function startGatewayOnboarding(reason: null | string = null) {
pendingProviderOAuthId = null
patch({
gatewayMode: true,
manual: false,
mode: 'gateway',
requested: true,
localEndpoint: false,
reason: reason ? reason.trim() || DEFAULT_ONBOARDING_REASON : null,
flow: { status: 'idle' }
})
}
// Save + apply a remote gateway connection from the onboarding form. On
// success, `applyConnectionConfig` reloads the window — so we just set the
// transitional "gateway_connected" flow state and let the reload handle
// the rest. On failure, surface the error in the form.
export async function saveGatewayConnection(
url: string,
authMode: 'oauth' | 'token',
token: string | undefined
): Promise<{ ok: boolean; message?: string }> {
const trimmedUrl = url.trim()
if (!trimmedUrl || !/^https?:\/\//i.test(trimmedUrl)) {
return { ok: false, message: 'Enter a valid gateway URL (https://…).' }
}
try {
await window.hermesDesktop?.saveConnectionConfig({
mode: 'remote',
remoteUrl: trimmedUrl,
remoteAuthMode: authMode,
remoteToken: authMode === 'token' ? token?.trim() || undefined : undefined
})
// applyConnectionConfig restarts the backend / reloads the window.
await window.hermesDesktop?.applyConnectionConfig({
mode: 'remote',
remoteUrl: trimmedUrl,
remoteAuthMode: authMode,
remoteToken: authMode === 'token' ? token?.trim() || undefined : undefined
})
setFlow({ status: 'gateway_connected', gatewayUrl: trimmedUrl })
return { ok: true }
} catch (error) {
const msg = error instanceof Error ? error.message : String(error)
return { ok: false, message: msg }
}
}
// Initiate OAuth sign-in against a remote gateway from the onboarding form.
// Mirrors GatewaySettings.signIn: save the URL + oauth mode first (so the login
// window has a target), then open the login window. On success, apply the
// connection which reloads the window.
export async function gatewayOauthLogin(
url: string
): Promise<{ ok: boolean; connected: boolean; message?: string }> {
const trimmedUrl = url.trim()
if (!trimmedUrl) {
return { ok: false, connected: false, message: 'Enter a gateway URL first.' }
}
try {
await window.hermesDesktop?.saveConnectionConfig({
mode: 'remote',
remoteUrl: trimmedUrl,
remoteAuthMode: 'oauth'
})
const result = await window.hermesDesktop?.oauthLoginConnectionConfig(trimmedUrl)
if (result?.connected) {
await window.hermesDesktop?.applyConnectionConfig({
mode: 'remote',
remoteUrl: trimmedUrl,
remoteAuthMode: 'oauth'
})
setFlow({ status: 'gateway_connected', gatewayUrl: trimmedUrl })
return { ok: true, connected: true }
}
return { ok: false, connected: false, message: 'Sign-in was not completed.' }
} catch (error) {
const msg = error instanceof Error ? error.message : String(error)
return { ok: false, connected: false, message: msg }
}
}
// One-shot hand-off used when the dedicated Providers settings page launches a
// specific provider's sign-in: we open the manual onboarding overlay AND
// remember which provider to start, so the overlay drives that exact OAuth
@@ -461,7 +575,7 @@ export function clearPendingProviderOAuth() {
export function closeManualOnboarding() {
pendingProviderOAuthId = null
patch({ manual: false, requested: false, localEndpoint: false, flow: { status: 'idle' } })
patch({ manual: false, requested: false, localEndpoint: false, gatewayMode: false, flow: { status: 'idle' } })
}
export function completeDesktopOnboarding() {
@@ -479,7 +593,8 @@ export function completeDesktopOnboarding() {
requested: false,
firstRunSkipped: false,
manual: false,
localEndpoint: false
localEndpoint: false,
gatewayMode: false
})
}
@@ -492,13 +607,21 @@ export function completeDesktopOnboarding() {
export function dismissFirstRunOnboarding() {
clearPoll()
writeCachedSkipped(true)
patch({ firstRunSkipped: true, requested: false, manual: false, localEndpoint: false, flow: { status: 'idle' } })
patch({ firstRunSkipped: true, requested: false, manual: false, localEndpoint: false, gatewayMode: false, flow: { status: 'idle' } })
}
export function setOnboardingMode(mode: OnboardingMode) {
patch({ mode })
}
// Exit the gateway connect form and return to the provider picker.
// Only used on the thick client where the user can go back from the
// gateway form to the provider list. On thin client the gateway form
// is the only option, so the back button is hidden.
export function exitGatewayMode() {
patch({ gatewayMode: false, mode: 'oauth', flow: { status: 'idle' } })
}
export async function refreshOnboarding(ctx: OnboardingContext) {
// Manual mode (user opened the selector from a working app): never
// auto-dismiss on runtime-ready — the whole point is to let them add /
@@ -510,6 +633,34 @@ export async function refreshOnboarding(ctx: OnboardingContext) {
return false
}
// Thin client: there's no local backend, so the runtime check always
// fails. Instead of showing the provider picker, check if a remote
// gateway is already configured. If it is, the boot will succeed and
// onCompleted fires. If not, show the gateway connect form.
if (isThinClient()) {
const config = await window.hermesDesktop?.getConnectionConfig?.().catch(() => null)
if (config?.mode === 'remote' && config.remoteUrl) {
// A remote gateway is configured — let the boot proceed. The gateway
// boot hook will either connect or surface a reauth failure.
completeDesktopOnboarding()
ctx.onCompleted?.()
return true
}
// No remote gateway configured — show the gateway connect form.
writeCachedConfigured(false)
patch({
configured: false,
gatewayMode: true,
mode: 'gateway',
reason: 'Connect to a remote gateway to get started.'
})
return false
}
const runtime = await checkRuntime(ctx)
if (runtime.ready) {

View File

@@ -14,6 +14,7 @@ import type {
DesktopVersionInfo
} from '@/global'
import { checkHermesUpdate, getActionStatus, updateHermes } from '@/hermes'
import { isThinClient } from '@/lib/build-mode'
import { translateNow } from '@/i18n'
import { persistString, storedString } from '@/lib/storage'
import { dismissNotification, notify } from '@/store/notifications'
@@ -194,7 +195,8 @@ export function maybeNotifyUpdateAvailable(status: DesktopUpdateStatus | null) {
}
export function openUpdatesWindow(): void {
openUpdateOverlayFor(isRemoteMode() ? 'backend' : 'client')
// Thin client: no client self-update — always target the backend (remote).
openUpdateOverlayFor(isThinClient() || isRemoteMode() ? 'backend' : 'client')
}
/**
@@ -205,7 +207,7 @@ export function openUpdatesWindow(): void {
* only be able to open the changelog overlay.
*/
export function startActiveUpdate(): void {
const target: UpdateTarget = isRemoteMode() ? 'backend' : 'client'
const target: UpdateTarget = isThinClient() || isRemoteMode() ? 'backend' : 'client'
$updateOverlayTarget.set(target)
$updateOverlayOpen.set(true)
void (target === 'backend' ? applyBackendUpdate() : applyUpdates())
@@ -611,9 +613,18 @@ export function startUpdatePoller(): void {
}
pollerStarted = true
// Thin client: no client self-update, but still poll for backend updates
// (the remote gateway may have new versions available).
if (!isThinClient()) {
void checkUpdates()
}
void checkBackendUpdates()
void refreshDesktopVersion()
bridge.onProgress(ingestProgress)
if (!isThinClient()) {
bridge.onProgress(ingestProgress)
}
// The poller starts at mount, before the gateway connects — so the first
// backend check above sees mode≠remote and no-ops. Re-check once the

View File

@@ -1 +1,5 @@
/// <reference types="vite/client" />
// Build-time constant injected by vite.config.ts `define`.
// True for thin-client builds (no bootstrap, no local backend, no self-update).
declare const __VITE_THIN_CLIENT__: boolean

View File

@@ -3,8 +3,16 @@ import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import path from 'path'
// Thin-client builds strip bootstrap, local backend, and self-update so the
// app connects ONLY to a remote gateway. Set HERMES_DESKTOP_BUILD_MODE=thin
// at build time; the renderer reads the baked-in VITE_THIN_CLIENT constant.
const THIN_CLIENT = process.env.HERMES_DESKTOP_BUILD_MODE === 'thin'
export default defineConfig({
base: './',
define: {
__VITE_THIN_CLIENT__: JSON.stringify(THIN_CLIENT)
},
plugins: [react(), tailwindcss()],
css: {
// Pin an explicit (empty) PostCSS config. Tailwind is handled entirely by

View File

@@ -13,20 +13,21 @@
makeWrapper,
hermesNpmLib,
electron,
hermesAgent,
hermesAgent ? null,
...
}:
let
npm = hermesNpmLib.mkNpmPassthru {
folder = "apps/desktop";
attr = "desktop";
pname = "hermes-desktop";
};
packageJson = builtins.fromJSON (builtins.readFile (npm.src + "/apps/desktop/package.json"));
version = packageJson.version;
# Build the renderer (dist/ + electron/ + package.json).
isThinClient = hermesAgent == null;
# Build the renderqer (dist/ + electron/ + package.json).
renderer = pkgs.buildNpmPackage (
npm
// {
@@ -34,6 +35,9 @@ let
inherit version;
doCheck = true;
HERMES_DESKTOP_BUILD_MODE = if isThinClient then "thin" else "";
BUILD_STAMP = ''{"schemaVersion":1,"commit":"nix","branch":"nix","dirty":false,"source":"nix","builtAt":"0"}'';
buildPhase = ''
runHook preBuild
@@ -41,7 +45,6 @@ let
# at first-launch to pin the install.ps1 git ref; informational in
# nix builds (the backend comes from the derivation directly).
mkdir -p apps/desktop/build
echo '{"schemaVersion":1,"commit":"nix","branch":"nix","dirty":false,"source":"nix"}' > apps/desktop/build/install-stamp.json
# patch shebangs in node_modules/.bin so npm exec can find the
# nix-store equivalents of /usr/bin/env (which doesn't exist in the sandbox)
@@ -50,18 +53,7 @@ let
pushd apps/desktop
# stage node-pty native binaries into build/native-deps for the final nix output
npm rebuild node-pty --build-from-source
node scripts/stage-native-deps.cjs
npm exec tsc -b
npm exec vite build
# Bundle the electron main into a single self-contained file so
# the nix output doesn't need node_modules/. simple-git (the only
# external runtime dep of the electron main) gets inlined; electron
# and node-pty are external (provided by the runtime / native-deps).
# preload.cjs stays separate Electron loads it via __dirname, not
# require(), so it must remain a standalone file.
node scripts/bundle-electron-main.mjs
npm run build
popd
runHook postBuild
@@ -72,8 +64,6 @@ let
pushd apps/desktop
npm run postbuild
# validate staged node-pty native binary is present
STAGED_PTY_NODE="./build/native-deps/node-pty/build/Release/pty.node"
@@ -131,15 +121,15 @@ stdenv.mkDerivation {
substituteInPlace $out/share/hermes-desktop/electron/main.cjs \
--replace-fail "process.resourcesPath" "'$out/share/hermes-desktop'"
# Wrap the nixpkgs electron binary to launch our app. Set
# HERMES_DESKTOP_HERMES to the absolute path of the nix-built `hermes`
# binary so the desktop's resolver step 4 ("existing Hermes CLI on
# PATH") uses our fully wrapped binary venv with all deps,
# Wrap the nixpkgs electron binary to launch our app.
# If we're building thick, set HERMES_DESKTOP_HERMES to the absolute path of the
# nix-built `hermes` binary so the desktop's resolver step 4
# ("existing Hermes CLI on PATH") uses our fully wrapped binary & venv with all deps,
# bundled skills/plugins, runtime PATH (ripgrep/git/ffmpeg/etc).
# No reimplementation of the agent resolver in the wrapper.
makeWrapper ${lib.getExe electron} $out/bin/hermes-desktop \
--add-flags "$out/share/hermes-desktop" \
--set HERMES_DESKTOP_HERMES "${lib.getExe hermesAgent}" \
${if isThinClient then "" else ''--set HERMES_DESKTOP_HERMES "${lib.getExe hermesAgent}"''}\
--set ELECTRON_IS_DEV 0
runHook postInstall

View File

@@ -10,14 +10,15 @@
makeWrapper,
callPackage,
python312,
nodejs_22,
electron,
ripgrep,
git,
openssh,
ffmpeg,
tirith,
# for building stuff w/ consistent node versions
hermesNpmLib,
# linux-only deps
wl-clipboard,
xclip,
@@ -26,7 +27,6 @@
uv2nix,
pyproject-nix,
pyproject-build-systems,
npm-lockfile-fix,
# Locked git revision of the flake source — embedded so banner.py can
# check for updates without needing a local .git directory. Null for
# impure / dirty builds where flakes can't determine a rev.
@@ -36,16 +36,11 @@
extraDependencyGroups ? [ ],
}:
let
nodejs = nodejs_22;
hermesVenv = callPackage ./python.nix {
inherit uv2nix pyproject-nix pyproject-build-systems;
dependency-groups = [ "all" ] ++ extraDependencyGroups;
};
hermesNpmLib = callPackage ./lib.nix {
inherit npm-lockfile-fix nodejs;
};
hermesTui = callPackage ./tui.nix {
inherit hermesNpmLib;
};
@@ -83,7 +78,7 @@ let
bundledLocales = lib.cleanSource ../locales;
runtimeDeps = [
nodejs
hermesNpmLib.nodejs
ripgrep
git
openssh
@@ -182,7 +177,7 @@ stdenv.mkDerivation (finalAttrs: {
--set HERMES_WEB_DIST $out/share/hermes-agent/web_dist \
--set HERMES_TUI_DIR $out/ui-tui \
--set HERMES_PYTHON ${hermesVenv}/bin/python3 \
--set HERMES_NODE ${lib.getExe nodejs} \
--set HERMES_NODE ${lib.getExe hermesNpmLib.nodejs} \
${lib.optionalString (rev != null) ''--set HERMES_REVISION ${rev} \''}
${lib.optionalString (extraPythonPackages != [ ]) ''--suffix PYTHONPATH : "${pythonPath}"''}
'')
@@ -204,24 +199,10 @@ stdenv.mkDerivation (finalAttrs: {
passthru = {
inherit
hermesTui
hermesWeb
hermesNpmLib
hermesVenv
;
# `hermesDesktop` references `finalAttrs.finalPackage` (this whole
# derivation, after all overrides are applied) so the desktop wrapper
# can prepend its `/bin` to PATH. The desktop's resolver step 4
# ("existing hermes on PATH") then picks up the fully wrapped
# `hermes` binary — venv with all deps, bundled skills/plugins,
# runtime PATH (ripgrep/git/ffmpeg/etc). No re-implementation
# of the agent resolution in the desktop wrapper.
hermesDesktop = callPackage ./desktop.nix {
inherit hermesNpmLib electron;
hermesAgent = finalAttrs.finalPackage;
};
devShellHook = ''
STAMP=".nix-stamps/hermes-agent"
STAMP_VALUE="${pyprojectHash}:${uvLockHash}"

View File

@@ -11,8 +11,6 @@
# root to update the lockfile, then `npm ci` if the lockfile changed.
{
pkgs,
npm-lockfile-fix,
nodejs,
}:
let
# The workspace root — where the single package-lock.json lives.
@@ -23,17 +21,19 @@ let
# lockfile is the single source of truth — no separate dependency hash to
# keep in sync with it.
npmDeps = pkgs.importNpmLock.importNpmLock { npmRoot = src; };
nodejs = pkgs.nodejs_22;
in
{
inherit nodejs;
# Returns a buildNpmPackage-compatible attrs set that provides:
# src, npmDeps, npmRoot — workspace source + importNpmLock dep set
# npmConfigHook — importNpmLock's offline `npm install` hook
# nativeBuildInputs — [ updateLockfileScript ] (list, prepend with ++ for more)
# passthru.packageJsonPath — relative path to this workspace's package.json
# nodejs — fixed nodejs version for all packages we use in the repo
#
# Usage:
# npm = hermesNpmLib.mkNpmPassthru { folder = "ui-tui"; attr = "tui"; pname = "hermes-tui"; };
# npm = hermesNpmLib.mkNpmPassthru { folder = "ui-tui"; pname = "hermes-tui"; };
# pkgs.buildNpmPackage (npm // {
# sourceRoot = "ui-tui";
# buildPhase = '' ... '';
@@ -42,7 +42,6 @@ in
mkNpmPassthru =
{
folder, # repo-relative folder with package.json, e.g. "ui-tui"
attr, # flake package attr, e.g. "tui"
...
}:
let
@@ -61,24 +60,6 @@ in
ELECTRON_SKIP_BINARY_DOWNLOAD = 1;
nativeBuildInputs = [
(pkgs.writeShellScriptBin "update_${attr}_lockfile" ''
set -euox pipefail
REPO_ROOT=$(git rev-parse --show-toplevel)
# All workspace packages share the root lockfile.
cd "$REPO_ROOT"
rm -rf node_modules/
${pkgs.lib.getExe' nodejs "npm"} cache clean --force
CI=true ${pkgs.lib.getExe' nodejs "npm"} install --workspaces
${pkgs.lib.getExe npm-lockfile-fix} ./package-lock.json
nix build .#${attr}
echo "Lockfile updated and build verified for .#${attr}"
'')
];
passthru = {
packageJsonPath = "${folder}/package.json";
};

View File

@@ -2,15 +2,34 @@
{ inputs, ... }:
{
perSystem =
{ pkgs, lib, inputs', ... }:
{
pkgs,
lib,
...
}:
let
hermesNpmLib = pkgs.callPackage ./lib.nix { };
hermesAgent = pkgs.callPackage ./hermes-agent.nix {
inherit (inputs) uv2nix pyproject-nix pyproject-build-systems;
npm-lockfile-fix = inputs'.npm-lockfile-fix.packages.default;
# Only embed clean revs — dirtyRev doesn't represent any upstream
inherit (inputs)
uv2nix
pyproject-nix
pyproject-build-systems
;
inherit
hermesNpmLib
;
# Only embed clean revs. dirtyRev doesn't represent any upstream
# commit, so comparing it would always claim "update available".
rev = inputs.self.rev or null;
};
desktop = pkgs.callPackage ./desktop.nix {
inherit hermesAgent hermesNpmLib;
};
desktop-thin = pkgs.callPackage ./desktop.nix {
inherit hermesNpmLib;
};
in
{
packages = {
@@ -44,12 +63,11 @@
"parallel-web"
"tts-premium"
"voice"
] ++ lib.optionals pkgs.stdenv.isLinux [ "matrix" ];
]
++ lib.optionals pkgs.stdenv.isLinux [ "matrix" ];
};
tui = hermesAgent.hermesTui;
web = hermesAgent.hermesWeb;
desktop = hermesAgent.hermesDesktop;
inherit desktop desktop-thin;
};
};
}

View File

@@ -1,33 +1,39 @@
# nix/tui.nix — Hermes TUI (Ink/React) compiled with tsc and bundled
{ pkgs, hermesNpmLib, ... }:
let
npm = hermesNpmLib.mkNpmPassthru { folder = "ui-tui"; attr = "tui"; pname = "hermes-tui"; };
npm = hermesNpmLib.mkNpmPassthru {
folder = "ui-tui";
pname = "hermes-tui";
};
packageJson = builtins.fromJSON (builtins.readFile (npm.src + "/ui-tui/package.json"));
version = packageJson.version;
in
pkgs.buildNpmPackage (npm // {
pname = "hermes-tui";
inherit version;
pkgs.buildNpmPackage (
npm
// {
pname = "hermes-tui";
inherit version;
doCheck = false;
doCheck = false;
buildPhase = ''
# esbuild bundles everything no need for tsc or vite.
# Run from the workspace root where node_modules/ lives.
node ui-tui/scripts/build.mjs
'';
buildPhase = ''
# esbuild bundles everything no need for tsc or vite.
# Run from the workspace root where node_modules/ lives.
node ui-tui/scripts/build.mjs
'';
installPhase = ''
runHook preInstall
installPhase = ''
runHook preInstall
mkdir -p $out/lib/hermes-tui
# esbuild writes to ui-tui/dist/ from the source root (no cd).
cp -r ui-tui/dist $out/lib/hermes-tui/dist
mkdir -p $out/lib/hermes-tui
# esbuild writes to ui-tui/dist/ from the source root (no cd).
cp -r ui-tui/dist $out/lib/hermes-tui/dist
# package.json kept for "type": "module" resolution on `node dist/entry.js`.
cp ui-tui/package.json $out/lib/hermes-tui/
# package.json kept for "type": "module" resolution on `node dist/entry.js`.
cp ui-tui/package.json $out/lib/hermes-tui/
runHook postInstall
'';
})
runHook postInstall
'';
}
)

View File

@@ -1,34 +1,40 @@
# nix/web.nix — Hermes Web Dashboard (Vite/React) frontend build
{ pkgs, hermesNpmLib, ... }:
let
npm = hermesNpmLib.mkNpmPassthru { folder = "web"; attr = "web"; pname = "hermes-web"; };
npm = hermesNpmLib.mkNpmPassthru {
folder = "web";
pname = "hermes-web";
};
packageJson = builtins.fromJSON (builtins.readFile (npm.src + "/web/package.json"));
version = packageJson.version;
in
pkgs.buildNpmPackage (npm // {
pname = "hermes-web";
inherit version;
pkgs.buildNpmPackage (
npm
// {
pname = "hermes-web";
inherit version;
doCheck = false;
doCheck = false;
buildPhase = ''
# Build from web/ so vite.config.ts and tsconfig resolve correctly.
# The workspace root's node_modules/ is at ../node_modules/.
cd web
node ../node_modules/typescript/bin/tsc -b
# outDir in vite.config.ts points to ../hermes_cli/web_dist for the
# monorepo layout. Override with --outDir dist for the nix build.
node ../node_modules/vite/bin/vite.js build --outDir dist
buildPhase = ''
# Build from web/ so vite.config.ts and tsconfig resolve correctly.
# The workspace root's node_modules/ is at ../node_modules/.
cd web
node ../node_modules/typescript/bin/tsc -b
# outDir in vite.config.ts points to ../hermes_cli/web_dist for the
# monorepo layout. Override with --outDir dist for the nix build.
node ../node_modules/vite/bin/vite.js build --outDir dist
# Return to source root so installPhase paths are correct.
cd ..
'';
# Return to source root so installPhase paths are correct.
cd ..
'';
installPhase = ''
runHook preInstall
# vite writes to web/dist/ (we cd'd there, overrode outDir, then cd'd back).
cp -r web/dist $out
runHook postInstall
'';
})
installPhase = ''
runHook preInstall
# vite writes to web/dist/ (we cd'd there, overrode outDir, then cd'd back).
cp -r web/dist $out
runHook postInstall
'';
}
)

0
scripts/slice_tests.py Normal file
View File