diff --git a/apps/desktop/src/app/settings/gateway-settings.tsx b/apps/desktop/src/app/settings/gateway-settings.tsx index 22de3c3e349..58c384bd19e 100644 --- a/apps/desktop/src/app/settings/gateway-settings.tsx +++ b/apps/desktop/src/app/settings/gateway-settings.tsx @@ -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) + // --- 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([]) + const [cloudDiscover, setCloudDiscover] = useState('idle') + const [cloudConnectingId, setCloudConnectingId] = useState(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() { ) : null} -
+
setState(current => ({ ...current, mode: 'local' }))} title={g.localTitle} /> + setState(current => ({ ...current, mode: 'cloud' }))} + title={g.cloudTitle} + />
+ {/* 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 ? ( +
+ + + {g.cloudSignedIn} + + +
+ ) : ( + + ) + } + description={cloudSignedIn ? g.cloudSignedInDesc : g.cloudNeedsSignIn} + title={g.cloudSignInTitle} + /> + + {cloudSignedIn ? ( +
+
+
+ {g.cloudAgentsTitle} +
+ +
+ + {cloudDiscover === 'loading' ? ( +
+ + {g.cloudLoadingAgents} +
+ ) : cloudAgents.length === 0 ? ( +
+ + {g.cloudNoAgents} +
+ ) : ( +
+ {cloudAgents.map(agent => ( + void connectCloudAgent(agent)} + size="sm" + > + {cloudConnectingId === agent.id ? : null} + {agent.dashboardUrl + ? cloudConnectingId === agent.id + ? g.cloudConnecting + : g.cloudConnect + : g.cloudAgentProvisioning} + + } + description={g.cloudStatusLabel(agent.dashboardGatewayState)} + key={agent.id} + title={agent.name} + /> + ))} +
+ )} +
+ ) : null} +
+ ) : null} + + {state.mode === 'remote' && !state.envOverride ? (
) : null}
+ ) : null} {lastTest ?
{lastTest}
: null} -
- - - -
+ {/* 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' ? ( +
+ {state.mode === 'remote' ? ( + + ) : null} + + +
+ ) : null}
`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…', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index adbe452a7c9..ba2f45d859e 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -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 diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index b41b7edaacf..f7b4a7e578b 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -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: '正在检查此网关的认证方式…', diff --git a/apps/desktop/src/lib/icons.ts b/apps/desktop/src/lib/icons.ts index f7a825fda15..16ee07a63d2 100644 --- a/apps/desktop/src/lib/icons.ts +++ b/apps/desktop/src/lib/icons.ts @@ -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,