mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
WIPipw wipwip
This commit is contained in:
parent
4dce531189
commit
0a8d4da69a
14 changed files with 560 additions and 27 deletions
|
|
@ -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()
|
||||
|
|
|
|||
238
apps/desktop/src/components/OnboardingGatewayConnection.tsx
Normal file
238
apps/desktop/src/components/OnboardingGatewayConnection.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -38,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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,7 +42,8 @@ function resetStores() {
|
|||
requested: false,
|
||||
firstRunSkipped: false,
|
||||
manual: false,
|
||||
localEndpoint: false
|
||||
localEndpoint: false,
|
||||
gatewayMode: false
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ function baseState(overrides: Partial<DesktopOnboardingState> = {}): DesktopOnbo
|
|||
firstRunSkipped: false,
|
||||
manual: false,
|
||||
localEndpoint: false,
|
||||
gatewayMode: false,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
0
scripts/slice_tests.py
Normal file
0
scripts/slice_tests.py
Normal file
Loading…
Add table
Add a link
Reference in a new issue