mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
feat(desktop): Hermes Cloud mode card + agent picker in Gateway settings
Phase 4 of cloud-auto-discovery — the UI on top of the Phase 3 cloud plumbing. Adds a third 'Hermes Cloud' ModeCard alongside Local/Remote in gateway-settings. Selecting it reveals the cloud panel instead of the URL/token form: - signed-out → 'Sign in to Hermes Cloud' (one portal login in the OAuth partition) - signed-in → a discovered-agent picker (loading / empty / list states) with a Refresh control. Selecting an agent drives the silent per-agent cascade (cloud.agentSignIn) then applies a mode:'cloud' connection pointed at its dashboardUrl — no second sign-in prompt. Cloud auto-discovers on entering the mode when a portal session already exists. Test/Save bottom-row actions are hidden in cloud mode (selection applies the connection); the remote URL/token form is now gated to remote mode only. Wires the renderer to the Phase 3 IPC (window.hermesDesktop.cloud.*). i18n strings added to en + zh (full) and the Translations type; ja/zh-hant inherit via defineLocale fallback. New 'Cloud' icon (IconCloud) exported from lib/icons. Validated: tsc clean, eslint clean, vite renderer build succeeds, 52 electron + 16 vitest tests pass. cloud-auto-discovery Phase 4.
This commit is contained in:
parent
2704e6e39c
commit
318910ce80
5 changed files with 370 additions and 22 deletions
|
|
@ -3,9 +3,9 @@ import { useEffect, useMemo, useRef, useState } from 'react'
|
|||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import type { DesktopAuthProvider, DesktopConnectionProbeResult } from '@/global'
|
||||
import type { DesktopAuthProvider, DesktopCloudAgent, DesktopConnectionProbeResult } from '@/global'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { AlertCircle, Check, FileText, Globe, Loader2, LogIn, Monitor } from '@/lib/icons'
|
||||
import { AlertCircle, Check, Cloud, FileText, Globe, Loader2, LogIn, Monitor, RefreshCw } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import { $profiles, refreshActiveProfile } from '@/store/profile'
|
||||
|
|
@ -16,6 +16,8 @@ import { EmptyState, ListRow, LoadingState, Pill, SettingsContent } from './prim
|
|||
type Mode = 'local' | 'remote' | 'cloud'
|
||||
type AuthMode = 'oauth' | 'token'
|
||||
type ProbeStatus = 'idle' | 'probing' | 'done' | 'error'
|
||||
// Hermes Cloud discovery lifecycle for the cloud-mode panel.
|
||||
type CloudDiscoverStatus = 'idle' | 'loading' | 'done' | 'error'
|
||||
|
||||
interface GatewaySettingsState {
|
||||
envOverride: boolean
|
||||
|
|
@ -105,6 +107,16 @@ export function GatewaySettings() {
|
|||
const [remoteToken, setRemoteToken] = useState('')
|
||||
const [lastTest, setLastTest] = useState<null | string>(null)
|
||||
|
||||
// --- Hermes Cloud (cloud mode) state ---
|
||||
// One portal session powers discovery + the silent per-agent cascade. These
|
||||
// track the cloud panel: whether we're signed in, the discovered agent list,
|
||||
// and which agent is mid-connect.
|
||||
const [cloudSignedIn, setCloudSignedIn] = useState(false)
|
||||
const [cloudSigningIn, setCloudSigningIn] = useState(false)
|
||||
const [cloudAgents, setCloudAgents] = useState<DesktopCloudAgent[]>([])
|
||||
const [cloudDiscover, setCloudDiscover] = useState<CloudDiscoverStatus>('idle')
|
||||
const [cloudConnectingId, setCloudConnectingId] = useState<null | string>(null)
|
||||
|
||||
// Connection scope: null = the global/default connection (the original
|
||||
// behavior); a profile name = that profile's per-profile remote override, so
|
||||
// each profile can point at its own backend.
|
||||
|
|
@ -379,6 +391,171 @@ export function GatewaySettings() {
|
|||
}
|
||||
}
|
||||
|
||||
// --- Hermes Cloud handlers ---
|
||||
|
||||
// Pull the discovered agent list over the shared portal session. Tolerant of
|
||||
// a lapsed session: a needsCloudLogin error flips us back to signed-out.
|
||||
const discoverCloud = async () => {
|
||||
const desktop = window.hermesDesktop
|
||||
|
||||
if (!desktop?.cloud) {
|
||||
return
|
||||
}
|
||||
|
||||
setCloudDiscover('loading')
|
||||
|
||||
try {
|
||||
const { agents } = await desktop.cloud.discover()
|
||||
setCloudAgents(agents)
|
||||
setCloudDiscover('done')
|
||||
} catch (err) {
|
||||
setCloudAgents([])
|
||||
setCloudDiscover('error')
|
||||
|
||||
// A lapsed/absent portal session means we're effectively signed out.
|
||||
if (err && typeof err === 'object' && 'needsCloudLogin' in err) {
|
||||
setCloudSignedIn(false)
|
||||
}
|
||||
|
||||
notifyError(err, g.cloudDiscoverFailed)
|
||||
}
|
||||
}
|
||||
|
||||
// On entering cloud mode (or scope change), read the portal session status and
|
||||
// auto-discover when already signed in, so the picker is populated on open.
|
||||
useEffect(() => {
|
||||
if (state.mode !== 'cloud') {
|
||||
return
|
||||
}
|
||||
|
||||
const desktop = window.hermesDesktop
|
||||
|
||||
if (!desktop?.cloud) {
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
desktop.cloud
|
||||
.status()
|
||||
.then(status => {
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
|
||||
setCloudSignedIn(status.signedIn)
|
||||
|
||||
if (status.signedIn) {
|
||||
void discoverCloud()
|
||||
} else {
|
||||
setCloudAgents([])
|
||||
setCloudDiscover('idle')
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setCloudSignedIn(false)
|
||||
}
|
||||
})
|
||||
|
||||
return () => void (cancelled = true)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- reload on mode/scope change only
|
||||
}, [state.mode, scope])
|
||||
|
||||
const cloudSignIn = async () => {
|
||||
const desktop = window.hermesDesktop
|
||||
|
||||
if (!desktop?.cloud) {
|
||||
return
|
||||
}
|
||||
|
||||
setCloudSigningIn(true)
|
||||
|
||||
try {
|
||||
const result = await desktop.cloud.login()
|
||||
setCloudSignedIn(result.signedIn)
|
||||
|
||||
if (result.signedIn) {
|
||||
await discoverCloud()
|
||||
}
|
||||
} catch (err) {
|
||||
notifyError(err, g.cloudSignInFailed)
|
||||
} finally {
|
||||
setCloudSigningIn(false)
|
||||
}
|
||||
}
|
||||
|
||||
const cloudSignOut = async () => {
|
||||
const desktop = window.hermesDesktop
|
||||
|
||||
if (!desktop?.cloud) {
|
||||
return
|
||||
}
|
||||
|
||||
setCloudSigningIn(true)
|
||||
|
||||
try {
|
||||
await desktop.cloud.logout()
|
||||
setCloudSignedIn(false)
|
||||
setCloudAgents([])
|
||||
setCloudDiscover('idle')
|
||||
notify({ kind: 'success', title: g.cloudSignedOutTitle, message: g.cloudSignedOutMessage })
|
||||
} catch (err) {
|
||||
notifyError(err, g.signOutFailed)
|
||||
} finally {
|
||||
setCloudSigningIn(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Select a discovered agent: drive the silent per-agent cascade (no second
|
||||
// prompt — the shared portal session auto-approves), then persist a cloud-mode
|
||||
// connection pointed at its dashboardUrl and apply it (reconnects the window).
|
||||
const connectCloudAgent = async (agent: DesktopCloudAgent) => {
|
||||
if (!agent.dashboardUrl) {
|
||||
return
|
||||
}
|
||||
|
||||
const desktop = window.hermesDesktop
|
||||
|
||||
if (!desktop?.cloud) {
|
||||
return
|
||||
}
|
||||
|
||||
setCloudConnectingId(agent.id)
|
||||
|
||||
try {
|
||||
const result = await desktop.cloud.agentSignIn(agent.dashboardUrl)
|
||||
|
||||
if (!result.connected) {
|
||||
notify({
|
||||
kind: 'warning',
|
||||
title: t.boot.failure.signInIncompleteTitle,
|
||||
message: t.boot.failure.signInIncompleteMessage
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Persist a cloud-mode connection (remote-shaped, oauth) and reconnect.
|
||||
const next = await desktop.applyConnectionConfig({
|
||||
mode: 'cloud',
|
||||
profile: scope ?? undefined,
|
||||
remoteAuthMode: 'oauth',
|
||||
remoteUrl: agent.dashboardUrl
|
||||
})
|
||||
|
||||
setState(next)
|
||||
notify({ kind: 'success', title: g.cloudConnectedTitle, message: g.cloudConnectedTo(agent.name) })
|
||||
} catch (err) {
|
||||
if (err && typeof err === 'object' && 'needsCloudLogin' in err) {
|
||||
setCloudSignedIn(false)
|
||||
}
|
||||
|
||||
notifyError(err, g.cloudConnectFailed)
|
||||
} finally {
|
||||
setCloudConnectingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const testRemote = async () => {
|
||||
if (!canUseRemote) {
|
||||
notify({
|
||||
|
|
@ -465,7 +642,7 @@ export function GatewaySettings() {
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<ModeCard
|
||||
active={state.mode === 'local'}
|
||||
description={g.localDesc}
|
||||
|
|
@ -474,6 +651,14 @@ export function GatewaySettings() {
|
|||
onSelect={() => setState(current => ({ ...current, mode: 'local' }))}
|
||||
title={g.localTitle}
|
||||
/>
|
||||
<ModeCard
|
||||
active={state.mode === 'cloud'}
|
||||
description={g.cloudDesc}
|
||||
disabled={state.envOverride}
|
||||
icon={Cloud}
|
||||
onSelect={() => setState(current => ({ ...current, mode: 'cloud' }))}
|
||||
title={g.cloudTitle}
|
||||
/>
|
||||
<ModeCard
|
||||
active={state.mode === 'remote'}
|
||||
description={g.remoteDesc}
|
||||
|
|
@ -484,6 +669,92 @@ export function GatewaySettings() {
|
|||
/>
|
||||
</div>
|
||||
|
||||
{/* Hermes Cloud panel: one portal sign-in, then a discovered-agent picker
|
||||
whose selection drives the silent per-agent cascade + a cloud
|
||||
connection. Replaces the URL/token form while in cloud mode. */}
|
||||
{state.mode === 'cloud' && !state.envOverride ? (
|
||||
<div className="mt-5 grid gap-1">
|
||||
<ListRow
|
||||
action={
|
||||
cloudSignedIn ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Pill tone="primary">
|
||||
<Check className="size-3" /> {g.cloudSignedIn}
|
||||
</Pill>
|
||||
<Button disabled={cloudSigningIn} onClick={() => void cloudSignOut()} variant="outline">
|
||||
{cloudSigningIn ? <Loader2 className="animate-spin" /> : null}
|
||||
{g.signOut}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button disabled={cloudSigningIn} onClick={() => void cloudSignIn()}>
|
||||
{cloudSigningIn ? <Loader2 className="animate-spin" /> : <LogIn />}
|
||||
{g.cloudSignIn}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
description={cloudSignedIn ? g.cloudSignedInDesc : g.cloudNeedsSignIn}
|
||||
title={g.cloudSignInTitle}
|
||||
/>
|
||||
|
||||
{cloudSignedIn ? (
|
||||
<div className="mt-3">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="text-[length:var(--conversation-caption-font-size)] font-medium text-(--ui-text-secondary)">
|
||||
{g.cloudAgentsTitle}
|
||||
</div>
|
||||
<Button
|
||||
disabled={cloudDiscover === 'loading'}
|
||||
onClick={() => void discoverCloud()}
|
||||
size="sm"
|
||||
variant="text"
|
||||
>
|
||||
{cloudDiscover === 'loading' ? <Loader2 className="animate-spin" /> : <RefreshCw />}
|
||||
{g.cloudRefresh}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{cloudDiscover === 'loading' ? (
|
||||
<div className="flex items-center gap-2 py-3 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
{g.cloudLoadingAgents}
|
||||
</div>
|
||||
) : cloudAgents.length === 0 ? (
|
||||
<div className="flex items-start gap-2 py-3 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||
<AlertCircle className="mt-0.5 size-4 shrink-0" />
|
||||
{g.cloudNoAgents}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-1">
|
||||
{cloudAgents.map(agent => (
|
||||
<ListRow
|
||||
action={
|
||||
<Button
|
||||
disabled={!agent.dashboardUrl || cloudConnectingId !== null}
|
||||
onClick={() => void connectCloudAgent(agent)}
|
||||
size="sm"
|
||||
>
|
||||
{cloudConnectingId === agent.id ? <Loader2 className="animate-spin" /> : null}
|
||||
{agent.dashboardUrl
|
||||
? cloudConnectingId === agent.id
|
||||
? g.cloudConnecting
|
||||
: g.cloudConnect
|
||||
: g.cloudAgentProvisioning}
|
||||
</Button>
|
||||
}
|
||||
description={g.cloudStatusLabel(agent.dashboardGatewayState)}
|
||||
key={agent.id}
|
||||
title={agent.name}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{state.mode === 'remote' && !state.envOverride ? (
|
||||
<div className="mt-5 grid gap-1">
|
||||
<ListRow
|
||||
action={
|
||||
|
|
@ -568,28 +839,36 @@ export function GatewaySettings() {
|
|||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{lastTest ? <div className="mt-4 text-xs text-primary">{lastTest}</div> : null}
|
||||
|
||||
<div className="mt-6 flex flex-wrap items-center justify-end gap-4">
|
||||
<Button
|
||||
className="mr-auto"
|
||||
disabled={state.envOverride || testing || !canUseRemote}
|
||||
onClick={() => void testRemote()}
|
||||
size="sm"
|
||||
variant="text"
|
||||
>
|
||||
{testing ? <Loader2 className="animate-spin" /> : null}
|
||||
{g.testRemote}
|
||||
</Button>
|
||||
<Button disabled={state.envOverride || saving} onClick={() => void save(false)} size="sm" variant="textStrong">
|
||||
{g.saveForRestart}
|
||||
</Button>
|
||||
<Button disabled={state.envOverride || saving} onClick={() => void save(true)} size="sm">
|
||||
{saving ? <Loader2 className="animate-spin" /> : null}
|
||||
{g.saveAndReconnect}
|
||||
</Button>
|
||||
</div>
|
||||
{/* Test/Save apply to local + remote. Cloud connects via the agent picker
|
||||
above (which applies a cloud connection on select), so its only
|
||||
bottom-row action would be redundant — hidden in cloud mode. */}
|
||||
{state.mode !== 'cloud' ? (
|
||||
<div className="mt-6 flex flex-wrap items-center justify-end gap-4">
|
||||
{state.mode === 'remote' ? (
|
||||
<Button
|
||||
className="mr-auto"
|
||||
disabled={state.envOverride || testing || !canUseRemote}
|
||||
onClick={() => void testRemote()}
|
||||
size="sm"
|
||||
variant="text"
|
||||
>
|
||||
{testing ? <Loader2 className="animate-spin" /> : null}
|
||||
{g.testRemote}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button disabled={state.envOverride || saving} onClick={() => void save(false)} size="sm" variant="textStrong">
|
||||
{g.saveForRestart}
|
||||
</Button>
|
||||
<Button disabled={state.envOverride || saving} onClick={() => void save(true)} size="sm">
|
||||
{saving ? <Loader2 className="animate-spin" /> : null}
|
||||
{g.saveAndReconnect}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-6 grid gap-1">
|
||||
<ListRow
|
||||
|
|
|
|||
|
|
@ -530,6 +530,29 @@ export const en: Translations = {
|
|||
remoteTitle: 'Remote gateway',
|
||||
remoteDesc:
|
||||
'Connect this desktop shell to a remote Hermes backend. Hosted gateways use OAuth or a username and password; self-hosted ones may use a session token.',
|
||||
cloudTitle: 'Hermes Cloud',
|
||||
cloudDesc:
|
||||
'Sign in once to Hermes Cloud and pick from the agents on your account — no URL to paste. Connects to the one you choose.',
|
||||
cloudSignInTitle: 'Hermes Cloud',
|
||||
cloudSignIn: 'Sign in to Hermes Cloud',
|
||||
cloudSignedIn: 'Signed in to Hermes Cloud',
|
||||
cloudNeedsSignIn: 'Sign in to Hermes Cloud to discover the agents on your account.',
|
||||
cloudSignedInDesc: 'You are signed in. Pick an agent below; the session refreshes automatically.',
|
||||
cloudAgentsTitle: 'Your agents',
|
||||
cloudLoadingAgents: 'Loading your agents…',
|
||||
cloudNoAgents: 'No agents found on this account. Create one in the Nous portal, then refresh.',
|
||||
cloudRefresh: 'Refresh',
|
||||
cloudConnect: 'Connect',
|
||||
cloudConnecting: 'Connecting…',
|
||||
cloudDiscoverFailed: 'Could not load your Hermes Cloud agents',
|
||||
cloudConnectFailed: 'Could not connect to that agent',
|
||||
cloudSignInFailed: 'Hermes Cloud sign-in failed',
|
||||
cloudSignedOutTitle: 'Signed out of Hermes Cloud',
|
||||
cloudSignedOutMessage: 'Cleared the Hermes Cloud session.',
|
||||
cloudConnectedTitle: 'Connected',
|
||||
cloudConnectedTo: name => `Connected to ${name}.`,
|
||||
cloudAgentProvisioning: 'Provisioning…',
|
||||
cloudStatusLabel: status => `Status: ${status}`,
|
||||
remoteUrlTitle: 'Remote URL',
|
||||
remoteUrlDesc: 'Base URL for the remote dashboard backend. Path prefixes are supported, for example /hermes.',
|
||||
probing: 'Checking how this gateway authenticates…',
|
||||
|
|
|
|||
|
|
@ -442,6 +442,28 @@ export interface Translations {
|
|||
localDesc: string
|
||||
remoteTitle: string
|
||||
remoteDesc: string
|
||||
cloudTitle: string
|
||||
cloudDesc: string
|
||||
cloudSignInTitle: string
|
||||
cloudSignIn: string
|
||||
cloudSignedIn: string
|
||||
cloudNeedsSignIn: string
|
||||
cloudSignedInDesc: string
|
||||
cloudAgentsTitle: string
|
||||
cloudLoadingAgents: string
|
||||
cloudNoAgents: string
|
||||
cloudRefresh: string
|
||||
cloudConnect: string
|
||||
cloudConnecting: string
|
||||
cloudDiscoverFailed: string
|
||||
cloudConnectFailed: string
|
||||
cloudSignInFailed: string
|
||||
cloudSignedOutTitle: string
|
||||
cloudSignedOutMessage: string
|
||||
cloudConnectedTitle: string
|
||||
cloudConnectedTo: (name: string) => string
|
||||
cloudAgentProvisioning: string
|
||||
cloudStatusLabel: (status: string) => string
|
||||
remoteUrlTitle: string
|
||||
remoteUrlDesc: string
|
||||
probing: string
|
||||
|
|
|
|||
|
|
@ -720,6 +720,28 @@ export const zh: Translations = {
|
|||
remoteTitle: '远程网关',
|
||||
remoteDesc:
|
||||
'将此桌面外壳连接到远程 Hermes 后端。托管网关使用 OAuth 或用户名密码;自托管网关也可能使用会话 token。',
|
||||
cloudTitle: 'Hermes Cloud',
|
||||
cloudDesc: '只需登录 Hermes Cloud 一次,即可从你账户下的智能体中选择——无需粘贴 URL。连接到你所选的那一个。',
|
||||
cloudSignInTitle: 'Hermes Cloud',
|
||||
cloudSignIn: '登录 Hermes Cloud',
|
||||
cloudSignedIn: '已登录 Hermes Cloud',
|
||||
cloudNeedsSignIn: '登录 Hermes Cloud 以发现你账户下的智能体。',
|
||||
cloudSignedInDesc: '你已登录。在下方选择一个智能体;会话会自动刷新。',
|
||||
cloudAgentsTitle: '你的智能体',
|
||||
cloudLoadingAgents: '正在加载你的智能体…',
|
||||
cloudNoAgents: '此账户下未找到智能体。请在 Nous 门户中创建一个,然后刷新。',
|
||||
cloudRefresh: '刷新',
|
||||
cloudConnect: '连接',
|
||||
cloudConnecting: '正在连接…',
|
||||
cloudDiscoverFailed: '无法加载你的 Hermes Cloud 智能体',
|
||||
cloudConnectFailed: '无法连接到该智能体',
|
||||
cloudSignInFailed: 'Hermes Cloud 登录失败',
|
||||
cloudSignedOutTitle: '已退出 Hermes Cloud',
|
||||
cloudSignedOutMessage: '已清除 Hermes Cloud 会话。',
|
||||
cloudConnectedTitle: '已连接',
|
||||
cloudConnectedTo: name => `已连接到 ${name}。`,
|
||||
cloudAgentProvisioning: '正在配置…',
|
||||
cloudStatusLabel: status => `状态:${status}`,
|
||||
remoteUrlTitle: '远程 URL',
|
||||
remoteUrlDesc: '远程 dashboard 后端的基础 URL。支持路径前缀,例如 /hermes。',
|
||||
probing: '正在检查此网关的认证方式…',
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import {
|
|||
IconCircle as CircleIcon,
|
||||
IconClipboard as Clipboard,
|
||||
IconClock as Clock,
|
||||
IconCloud as Cloud,
|
||||
IconCommand as Command,
|
||||
IconCopy as Copy,
|
||||
IconCopy as CopyIcon,
|
||||
|
|
@ -139,6 +140,7 @@ export {
|
|||
CircleIcon,
|
||||
Clipboard,
|
||||
Clock,
|
||||
Cloud,
|
||||
Command,
|
||||
Copy,
|
||||
CopyIcon,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue