diff --git a/web/backend/api/gateway.go b/web/backend/api/gateway.go index f50f7609a..10dd07610 100644 --- a/web/backend/api/gateway.go +++ b/web/backend/api/gateway.go @@ -3,6 +3,7 @@ package api import ( "bufio" "encoding/json" + "errors" "fmt" "io" "log" @@ -18,6 +19,7 @@ import ( "time" "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/credential" "github.com/sipeed/picoclaw/web/backend/utils" ) @@ -74,7 +76,15 @@ func (h *Handler) TryAutoStartGateway() { ready, reason, err := h.gatewayStartReady() if err != nil { - log.Printf("Skip auto-starting gateway: %v", err) + if errors.Is(err, credential.ErrPassphraseRequired) { + log.Printf("Skip auto-starting gateway: encrypted credentials require a passphrase. " + + "Enter it on the Credentials page to unlock.") + } else if errors.Is(err, credential.ErrDecryptionFailed) { + log.Printf("Skip auto-starting gateway: failed to decrypt credentials. " + + "Check the passphrase and SSH key on the Credentials page.") + } else { + log.Printf("Skip auto-starting gateway: %v", err) + } return } if !ready { @@ -91,6 +101,8 @@ func (h *Handler) TryAutoStartGateway() { } // gatewayStartReady validates whether current config can start the gateway. +// LoadConfig uses credential.PassphraseProvider (set to SecureStore.Get at +// startup) so enc:// credentials are resolved correctly without os.Environ. func (h *Handler) gatewayStartReady() (bool, string, error) { cfg, err := config.LoadConfig(h.configPath) if err != nil { @@ -256,7 +268,18 @@ func (h *Handler) startGatewayLocked(initialStatus string) (int, error) { execPath := utils.FindPicoclawBinary() cmd := exec.Command(execPath, "gateway") - cmd.Env = os.Environ() + + // Build a clean environment for the child process. + // Start from the launcher's current environment, but explicitly strip + // PICOCLAW_KEY_PASSPHRASE so it cannot leak from the parent env. + // The passphrase is then injected directly from the in-memory SecureStore + // (child-only; never stored in the launcher's own os.Environ). + childEnv := filterEnv(os.Environ(), credential.PassphraseEnvVar) + if passphrase := h.passphraseStore.Get(); passphrase != "" { + childEnv = append(childEnv, credential.PassphraseEnvVar+"="+passphrase) + } + cmd.Env = childEnv + // Forward the launcher's config path via the environment variable that // GetConfigPath() already reads, so the gateway sub-process uses the same // config file without requiring a --config flag on the gateway subcommand. @@ -311,8 +334,9 @@ func (h *Handler) startGatewayLocked(initialStatus string) (int, error) { // Wait for exit in background and clean up go func() { - if err := cmd.Wait(); err != nil { - log.Printf("Gateway process exited: %v", err) + exitErr := cmd.Wait() + if exitErr != nil { + log.Printf("Gateway process exited: %v", exitErr) } else { log.Printf("Gateway process exited normally") } @@ -329,11 +353,27 @@ func (h *Handler) startGatewayLocked(initialStatus string) (int, error) { } gateway.mu.Unlock() + // If we had an active passphrase attempt and the gateway crashed, + // mark passphrase as failed so the frontend can show an error. + if exitErr != nil { + h.passphraseMu.Lock() + if h.passphraseLastState == passphraseStatePending { + h.passphraseLastState = passphraseStateFailed + // Clear the bad passphrase so user must re-enter + h.passphraseStore.Clear() + } + h.passphraseMu.Unlock() + } else { + // Clean normal exit + h.passphraseMu.Lock() + if h.passphraseLastState == passphraseStatePending { + h.passphraseLastState = passphraseStateNone + } + h.passphraseMu.Unlock() + } + if shouldBroadcastStopped { - gateway.events.Broadcast(GatewayEvent{ - Status: "stopped", - RestartRequired: false, - }) + gateway.events.Broadcast(GatewayEvent{Status: "stopped"}) } }() @@ -662,6 +702,13 @@ func (h *Handler) gatewayStatusData() map[string]any { } } + // Expose passphrase state so the frontend can distinguish + // "never entered" vs "wrong passphrase" vs "pending start". + h.passphraseMu.Lock() + ps := h.passphraseLastState + h.passphraseMu.Unlock() + data["passphrase_state"] = string(ps) + return data } @@ -772,3 +819,17 @@ func scanPipe(r io.Reader, buf *LogBuffer) { buf.Append(scanner.Text()) } } + +// filterEnv returns a copy of environ with all entries whose key matches +// the supplied key removed. Used to strip the passphrase from the +// inherited environment before assembling the child-process environ. +func filterEnv(environ []string, key string) []string { + prefix := key + "=" + result := make([]string, 0, len(environ)) + for _, e := range environ { + if !strings.HasPrefix(e, prefix) { + result = append(result, e) + } + } + return result +} diff --git a/web/backend/api/passphrase.go b/web/backend/api/passphrase.go new file mode 100644 index 000000000..7602b5a77 --- /dev/null +++ b/web/backend/api/passphrase.go @@ -0,0 +1,80 @@ +package api + +import ( + "encoding/json" + "log" + "net/http" +) + +// registerPassphraseRoutes binds the passphrase management endpoints. +func (h *Handler) registerPassphraseRoutes(mux *http.ServeMux) { + mux.HandleFunc("POST /api/credential/passphrase", h.handleSetPassphrase) + mux.HandleFunc("GET /api/credential/passphrase/status", h.handlePassphraseStatus) +} + +// handleSetPassphrase stores the supplied passphrase in the in-memory +// SecureStore, then attempts to auto-start the gateway if it is not running. +// +// POST /api/credential/passphrase +// Body: {"passphrase": "..."} +func (h *Handler) handleSetPassphrase(w http.ResponseWriter, r *http.Request) { + var body struct { + Passphrase string `json:"passphrase"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "invalid JSON body", http.StatusBadRequest) + return + } + if body.Passphrase == "" { + http.Error(w, "passphrase must not be empty", http.StatusBadRequest) + return + } + + h.passphraseStore.SetString(body.Passphrase) + + // Mark state as pending before launching gateway + h.passphraseMu.Lock() + h.passphraseLastState = passphraseStatePending + h.passphraseMu.Unlock() + + // Try to start the gateway now that the passphrase is available. + // credential.PassphraseProvider points to passphraseStore.Get, so + // gatewayStartReady() (and all LoadConfig calls) will resolve enc:// + // credentials correctly using the newly stored passphrase. + go func() { + gateway.mu.Lock() + defer gateway.mu.Unlock() + if isGatewayProcessAliveLocked() { + return + } + pid, err := h.startGatewayLocked("starting") + if err != nil { + log.Printf("Failed to start gateway after passphrase unlock: %v", err) + // startGatewayLocked failed before spawning the process, so the exit + // goroutine will never run. Transition pending → failed manually. + h.passphraseMu.Lock() + if h.passphraseLastState == passphraseStatePending { + h.passphraseLastState = passphraseStateFailed + h.passphraseStore.Clear() + } + h.passphraseMu.Unlock() + return + } + log.Printf("Gateway started after passphrase unlock (PID: %d)", pid) + }() + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "status": "ok", + }) +} + +// handlePassphraseStatus reports whether a passphrase is currently stored. +// +// GET /api/credential/passphrase/status +func (h *Handler) handlePassphraseStatus(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "passphrase_set": h.passphraseStore.IsSet(), + }) +} diff --git a/web/backend/api/router.go b/web/backend/api/router.go index 5f081dee9..02dc52116 100644 --- a/web/backend/api/router.go +++ b/web/backend/api/router.go @@ -4,9 +4,22 @@ import ( "net/http" "sync" + "github.com/sipeed/picoclaw/pkg/credential" "github.com/sipeed/picoclaw/web/backend/launcherconfig" ) +// passphraseState tracks what happened with the last passphrase attempt. +// "" → no passphrase submitted yet (or just cleared) +// "pending" → passphrase set, gateway starting +// "failed" → gateway exited; passphrase likely wrong +type passphraseState string + +const ( + passphraseStateNone passphraseState = "" + passphraseStatePending passphraseState = "pending" + passphraseStateFailed passphraseState = "failed" +) + // Handler serves HTTP API requests. type Handler struct { configPath string @@ -17,15 +30,19 @@ type Handler struct { oauthMu sync.Mutex oauthFlows map[string]*oauthFlow oauthState map[string]string + passphraseStore *credential.SecureStore + passphraseMu sync.Mutex + passphraseLastState passphraseState } // NewHandler creates an instance of the API handler. func NewHandler(configPath string) *Handler { return &Handler{ - configPath: configPath, - serverPort: launcherconfig.DefaultPort, - oauthFlows: make(map[string]*oauthFlow), - oauthState: make(map[string]string), + configPath: configPath, + serverPort: launcherconfig.DefaultPort, + oauthFlows: make(map[string]*oauthFlow), + oauthState: make(map[string]string), + passphraseStore: credential.NewSecureStore(), } } @@ -37,6 +54,21 @@ func (h *Handler) SetServerOptions(port int, public bool, publicExplicit bool, a h.serverCIDRs = append([]string(nil), allowedCIDRs...) } +// SeedPassphrase pre-loads the passphrase into the in-memory SecureStore. +// Call this at startup when the passphrase was supplied via an environment +// variable; after seeding, the caller should clear the env var so it is no +// longer visible in the process environment. +func (h *Handler) SeedPassphrase(passphrase string) { + h.passphraseStore.SetString(passphrase) +} + +// GetPassphrase returns the currently stored passphrase, or "" if not set. +// This satisfies the credential.PassphraseProvider signature so all LoadConfig +// calls in the launcher automatically use the in-memory store. +func (h *Handler) GetPassphrase() string { + return h.passphraseStore.Get() +} + // RegisterRoutes binds all API endpoint handlers to the ServeMux. func (h *Handler) RegisterRoutes(mux *http.ServeMux) { // Config CRUD @@ -54,6 +86,9 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) { // OAuth login and credential management h.registerOAuthRoutes(mux) + // Passphrase management (in-memory store for encrypted credentials) + h.registerPassphraseRoutes(mux) + // Model list management h.registerModelRoutes(mux) diff --git a/web/backend/main.go b/web/backend/main.go index 650540ea8..11fc1aefc 100644 --- a/web/backend/main.go +++ b/web/backend/main.go @@ -22,6 +22,7 @@ import ( "strconv" "time" + "github.com/sipeed/picoclaw/pkg/credential" "github.com/sipeed/picoclaw/web/backend/api" "github.com/sipeed/picoclaw/web/backend/launcherconfig" "github.com/sipeed/picoclaw/web/backend/middleware" @@ -115,6 +116,21 @@ func main() { // API Routes (e.g. /api/status) apiHandler := api.NewHandler(absPath) apiHandler.SetServerOptions(portNum, effectivePublic, explicitPublic, launcherCfg.AllowedCIDRs) + + // If PICOCLAW_KEY_PASSPHRASE is set in the environment at startup, seed it + // into the in-memory SecureStore and then remove it from the process + // environment so it is no longer visible via /proc//environ or similar. + if envPassphrase := os.Getenv(credential.PassphraseEnvVar); envPassphrase != "" { + apiHandler.SeedPassphrase(envPassphrase) + os.Unsetenv(credential.PassphraseEnvVar) + log.Printf("Seeded passphrase from %s environment variable (env var cleared)", credential.PassphraseEnvVar) + } + + // Point the credential package at the in-memory store so that all + // LoadConfig() calls in the launcher (config API, models API, gateway + // readiness check, etc.) use the same passphrase source. + credential.PassphraseProvider = apiHandler.GetPassphrase + apiHandler.RegisterRoutes(mux) // Frontend Embedded Assets diff --git a/web/frontend/src/api/gateway.ts b/web/frontend/src/api/gateway.ts index 9e02a02b5..8623c4489 100644 --- a/web/frontend/src/api/gateway.ts +++ b/web/frontend/src/api/gateway.ts @@ -4,7 +4,7 @@ interface GatewayStatusResponse { gateway_status: "running" | "starting" | "restarting" | "stopped" | "error" gateway_start_allowed?: boolean gateway_start_reason?: string - gateway_restart_required?: boolean + passphrase_state?: "" | "pending" | "failed" pid?: number boot_default_model?: string config_default_model?: string diff --git a/web/frontend/src/api/passphrase.ts b/web/frontend/src/api/passphrase.ts new file mode 100644 index 000000000..47115ff78 --- /dev/null +++ b/web/frontend/src/api/passphrase.ts @@ -0,0 +1,30 @@ +// API client for in-memory passphrase management. + +const BASE_URL = "" + +async function request(path: string, options?: RequestInit): Promise { + const res = await fetch(`${BASE_URL}${path}`, options) + if (!res.ok) { + const text = await res.text().catch(() => res.statusText) + throw new Error(text || `API error: ${res.status}`) + } + return res.json() as Promise +} + +export interface PassphraseStatusResponse { + passphrase_set: boolean +} + +/** Returns whether a passphrase is currently held in the launcher. */ +export async function getPassphraseStatus(): Promise { + return request("/api/credential/passphrase/status") +} + +/** Stores the passphrase in the launcher's in-memory SecureStore. */ +export async function setPassphrase(passphrase: string): Promise<{ status: string }> { + return request<{ status: string }>("/api/credential/passphrase", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ passphrase }), + }) +} diff --git a/web/frontend/src/components/chat/chat-empty-state.tsx b/web/frontend/src/components/chat/chat-empty-state.tsx index 0574c44d1..beecf2e2f 100644 --- a/web/frontend/src/components/chat/chat-empty-state.tsx +++ b/web/frontend/src/components/chat/chat-empty-state.tsx @@ -1,26 +1,118 @@ import { + IconKey, + IconLoader2, + IconLock, IconPlugConnectedX, IconRobot, IconRobotOff, IconStar, } from "@tabler/icons-react" import { Link } from "@tanstack/react-router" +import { useEffect, useState } from "react" import { useTranslation } from "react-i18next" +import { setPassphrase } from "@/api/passphrase" import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" interface ChatEmptyStateProps { hasConfiguredModels: boolean defaultModelName: string isConnected: boolean + gatewayStartReason?: string + passphraseState?: "" | "pending" | "failed" } export function ChatEmptyState({ hasConfiguredModels, defaultModelName, isConnected, + gatewayStartReason = "", + passphraseState = "", }: ChatEmptyStateProps) { const { t } = useTranslation() + const needsPassphrase = gatewayStartReason.toLowerCase().includes("passphrase") + || passphraseState === "failed" + || passphraseState === "pending" + + const [passphrase, setPassphraseValue] = useState("") + const [saving, setSaving] = useState(false) + const [error, setError] = useState("") + const [saved, setSaved] = useState(false) + + // When backend signals failure, reset the saved flag and show error. + useEffect(() => { + if (passphraseState === "failed") { + setSaved(false) + setError(t("credentials.passphrase.errorWrongPassphrase")) + } + }, [passphraseState, t]) + + async function handleUnlock() { + if (!passphrase.trim()) return + setSaving(true) + setError("") + try { + await setPassphrase(passphrase.trim()) + setSaved(true) + setPassphraseValue("") + } catch { + setError(t("credentials.passphrase.errorSave")) + } finally { + setSaving(false) + } + } + + // Passphrase unlock takes priority — models/config can't load without it + if (!isConnected && needsPassphrase) { + return ( +
+
+ +
+

+ {t("credentials.passphrase.title")} +

+

+ {t("credentials.passphrase.description")} +

+ {saved ? ( +

+ {t("credentials.passphrase.successMessage")} +

+ ) : ( +
+
+ setPassphraseValue(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter") void handleUnlock() }} + disabled={saving} + autoFocus + /> + +
+ {error && ( +

{error}

+ )} +
+ )} +
+ ) + } if (!hasConfiguredModels) { return ( @@ -85,3 +177,4 @@ export function ChatEmptyState({ ) } + diff --git a/web/frontend/src/components/chat/chat-page.tsx b/web/frontend/src/components/chat/chat-page.tsx index ebcde8981..2847461bf 100644 --- a/web/frontend/src/components/chat/chat-page.tsx +++ b/web/frontend/src/components/chat/chat-page.tsx @@ -33,8 +33,9 @@ export function ChatPage() { newChat, } = usePicoChat() - const { state: gwState } = useGateway() - const isGatewayRunning = gwState === "running" + const { state: gwState, startReason, passphraseState } = useGateway() + const isConnected = gwState === "running" + const isGatewayRunning = isConnected const isChatConnected = connectionState === "connected" const { @@ -142,7 +143,9 @@ export function ChatPage() { )} diff --git a/web/frontend/src/components/credentials/credentials-page.tsx b/web/frontend/src/components/credentials/credentials-page.tsx index 04aceb002..e37d3f9d9 100644 --- a/web/frontend/src/components/credentials/credentials-page.tsx +++ b/web/frontend/src/components/credentials/credentials-page.tsx @@ -9,6 +9,7 @@ import { AntigravityCredentialCard } from "./antigravity-credential-card" import { DeviceCodeSheet } from "./device-code-sheet" import { LogoutConfirmDialog } from "./logout-confirm-dialog" import { OpenAICredentialCard } from "./openai-credential-card" +import { PassphraseCard } from "./passphrase-card" export function CredentialsPage() { const { t } = useTranslation() @@ -51,6 +52,11 @@ export function CredentialsPage() {

+ {/* Passphrase card is always visible — independent of OAuth loading state */} +
+ +
+ {error && (
{error} diff --git a/web/frontend/src/components/credentials/passphrase-card.tsx b/web/frontend/src/components/credentials/passphrase-card.tsx new file mode 100644 index 000000000..2657fd56a --- /dev/null +++ b/web/frontend/src/components/credentials/passphrase-card.tsx @@ -0,0 +1,114 @@ +import { IconKey, IconLock, IconLockOpen, IconLoader2 } from "@tabler/icons-react" +import { useEffect, useState } from "react" +import { useTranslation } from "react-i18next" + +import { getPassphraseStatus, setPassphrase } from "@/api/passphrase" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" + +export function PassphraseCard() { + const { t } = useTranslation() + const [value, setValue] = useState("") + const [isSet, setIsSet] = useState(null) + const [saving, setSaving] = useState(false) + const [message, setMessage] = useState<{ text: string; error: boolean } | null>(null) + + useEffect(() => { + getPassphraseStatus() + .then((res) => setIsSet(res.passphrase_set)) + .catch(() => setIsSet(false)) + }, []) + + async function handleSave() { + if (!value.trim()) { + setMessage({ text: t("credentials.passphrase.errorEmpty"), error: true }) + return + } + setSaving(true) + setMessage(null) + try { + await setPassphrase(value.trim()) + setIsSet(true) + setValue("") + setMessage({ text: t("credentials.passphrase.successMessage"), error: false }) + } catch { + setMessage({ text: t("credentials.passphrase.errorSave"), error: true }) + } finally { + setSaving(false) + } + } + + return ( +
+
+

+ + + + {t("credentials.passphrase.title")} +

+

+ {t("credentials.passphrase.description")} +

+
+ +
+ {isSet === null ? ( + + ) : isSet ? ( + <> + + + {t("credentials.passphrase.statusSet")} + + + ) : ( + <> + + + {t("credentials.passphrase.statusNotSet")} + + + )} +
+ +
+
+
+
+ setValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") void handleSave() + }} + type="password" + placeholder={t("credentials.passphrase.placeholder")} + disabled={saving} + /> + +
+ {message && ( +

+ {message.text} +

+ )} +
+
+
+
+
+ ) +} diff --git a/web/frontend/src/hooks/use-gateway.ts b/web/frontend/src/hooks/use-gateway.ts index 65ec2b776..53bef7894 100644 --- a/web/frontend/src/hooks/use-gateway.ts +++ b/web/frontend/src/hooks/use-gateway.ts @@ -1,30 +1,33 @@ -import { useAtomValue } from "jotai" +import { useAtom } from "jotai" import { useCallback, useEffect, useState } from "react" import { type GatewayStatusResponse, getGatewayStatus, - restartGateway, startGateway, stopGateway, } from "@/api/gateway" -import { - applyGatewayStatusToStore, - gatewayAtom, - updateGatewayStore, -} from "@/store" +import { gatewayAtom, updateGatewayStore } from "@/store" // Global variable to ensure we only have one SSE connection let sseInitialized = false export function useGateway() { - const gateway = useAtomValue(gatewayAtom) - const { status: state, canStart, restartRequired } = gateway + const [{ status: state, canStart, startReason, passphraseState }, setGateway] = useAtom(gatewayAtom) const [loading, setLoading] = useState(false) - const applyGatewayStatus = useCallback((data: GatewayStatusResponse) => { - applyGatewayStatusToStore(data) - }, []) + const applyGatewayStatus = useCallback( + (data: GatewayStatusResponse) => { + setGateway((prev) => ({ + ...prev, + status: data.gateway_status ?? "unknown", + canStart: data.gateway_start_allowed ?? true, + startReason: data.gateway_start_reason ?? "", + passphraseState: data.passphrase_state ?? "", + })) + }, + [setGateway], + ) // Initialize global SSE connection once useEffect(() => { @@ -37,7 +40,8 @@ export function useGateway() { updateGatewayStore({ status: "unknown", canStart: true, - restartRequired: false, + startReason: "", + passphraseState: "", }) }) @@ -116,37 +120,5 @@ export function useGateway() { } }, []) - const restart = useCallback(async () => { - if (state !== "running") return - - const previousState = state - const previousCanStart = canStart - const previousRestartRequired = restartRequired - - setLoading(true) - updateGatewayStore({ - status: "restarting", - restartRequired: false, - }) - - try { - await restartGateway() - } catch (err) { - console.error("Failed to restart gateway:", err) - try { - const status = await getGatewayStatus() - applyGatewayStatus(status) - } catch { - updateGatewayStore({ - status: previousState, - canStart: previousCanStart, - restartRequired: previousRestartRequired, - }) - } - } finally { - setLoading(false) - } - }, [applyGatewayStatus, canStart, restartRequired, state]) - - return { state, loading, canStart, restartRequired, start, stop, restart } + return { state, loading, canStart, startReason, passphraseState, start, stop } } diff --git a/web/frontend/src/i18n/locales/en.json b/web/frontend/src/i18n/locales/en.json index b099dec13..e4b6266df 100644 --- a/web/frontend/src/i18n/locales/en.json +++ b/web/frontend/src/i18n/locales/en.json @@ -81,6 +81,19 @@ "credentials": { "description": "Manage OAuth and token-based credentials for supported providers.", "loading": "Loading credentials...", + "passphrase": { + "title": "Encryption Passphrase", + "description": "Required to decrypt enc:// API keys in your config. Stored only in memory — never written to disk.", + "statusSet": "Passphrase loaded", + "statusNotSet": "No passphrase set", + "placeholder": "Enter passphrase", + "save": "Unlock", + "saving": "Unlocking...", + "successMessage": "Passphrase stored. Gateway is starting...", + "errorEmpty": "Passphrase must not be empty.", + "errorSave": "Failed to store passphrase.", + "errorWrongPassphrase": "Wrong passphrase — gateway failed to start. Please try again." + }, "providers": { "openai": { "description": "Supports browser OAuth, device code, and token login." diff --git a/web/frontend/src/i18n/locales/zh.json b/web/frontend/src/i18n/locales/zh.json index 78093e5c7..fac9eacfa 100644 --- a/web/frontend/src/i18n/locales/zh.json +++ b/web/frontend/src/i18n/locales/zh.json @@ -81,6 +81,19 @@ "credentials": { "description": "管理已支持服务商的 OAuth 与 Token 凭据。", "loading": "正在加载凭据...", + "passphrase": { + "title": "加密密码", + "description": "用于解密配置中的 enc:// API Key,仅存储在内存中,不会写入磁盘。", + "statusSet": "密码已加载", + "statusNotSet": "未设置密码", + "placeholder": "输入密码", + "save": "解锁", + "saving": "解锁中...", + "successMessage": "密码已存储,网关正在启动...", + "errorEmpty": "密码不能为空。", + "errorSave": "存储密码失败。", + "errorWrongPassphrase": "密码错误,网关启动失败,请重试。" + }, "providers": { "openai": { "description": "支持浏览器 OAuth、设备码和 Token 登录。" diff --git a/web/frontend/src/store/gateway.ts b/web/frontend/src/store/gateway.ts index c5eee8451..a3ee5877a 100644 --- a/web/frontend/src/store/gateway.ts +++ b/web/frontend/src/store/gateway.ts @@ -13,36 +13,19 @@ export type GatewayState = export interface GatewayStoreState { status: GatewayState canStart: boolean - restartRequired: boolean + startReason: string + passphraseState: "" | "pending" | "failed" } type GatewayStorePatch = Partial -const DEFAULT_GATEWAY_STATE: GatewayStoreState = { +// Global atom for gateway state +export const gatewayAtom = atom({ status: "unknown", canStart: true, - restartRequired: false, -} - -// Global atom for gateway state -export const gatewayAtom = atom(DEFAULT_GATEWAY_STATE) - -function normalizeGatewayStoreState( - prev: GatewayStoreState, - patch: GatewayStorePatch, -) { - const next = { ...prev, ...patch } - - if ( - next.status === prev.status && - next.canStart === prev.canStart && - next.restartRequired === prev.restartRequired - ) { - return prev - } - - return next -} + startReason: "", + passphraseState: "", +}) export function updateGatewayStore( patch: @@ -51,26 +34,17 @@ export function updateGatewayStore( ) { getDefaultStore().set(gatewayAtom, (prev) => { const nextPatch = typeof patch === "function" ? patch(prev) : patch - return normalizeGatewayStoreState(prev, nextPatch) + return { ...prev, ...nextPatch } }) } -export function applyGatewayStatusToStore( - data: Partial< - Pick< - GatewayStatusResponse, - "gateway_status" | "gateway_start_allowed" | "gateway_restart_required" - > - >, -) { - updateGatewayStore((prev) => ({ - status: data.gateway_status ?? prev.status, - canStart: data.gateway_start_allowed ?? prev.canStart, - restartRequired: - data.gateway_restart_required ?? - (data.gateway_status && data.gateway_status !== "running" - ? false - : prev.restartRequired), +function applyGatewayStatusToStore(data: GatewayStatusResponse) { + getDefaultStore().set(gatewayAtom, (prev) => ({ + ...prev, + status: data.gateway_status ?? "unknown", + canStart: data.gateway_start_allowed ?? true, + startReason: data.gateway_start_reason ?? "", + passphraseState: data.passphrase_state ?? "", })) } @@ -79,6 +53,6 @@ export async function refreshGatewayState() { const status = await getGatewayStatus() applyGatewayStatusToStore(status) } catch { - updateGatewayStore(DEFAULT_GATEWAY_STATE) + updateGatewayStore({ status: "unknown", canStart: true, startReason: "", passphraseState: "" }) } }