Merge PR #1522
This commit is contained in:
commit
e431fd5496
16 changed files with 492 additions and 14 deletions
2
Makefile
2
Makefile
|
|
@ -16,7 +16,7 @@ LDFLAGS=-ldflags "-X $(CONFIG_PKG).Version=$(VERSION) -X $(CONFIG_PKG).GitCommit
|
||||||
|
|
||||||
# Go variables
|
# Go variables
|
||||||
GO?=CGO_ENABLED=0 go
|
GO?=CGO_ENABLED=0 go
|
||||||
GOFLAGS?=-v -tags stdjson
|
GOFLAGS?=-v -tags stdjson -buildvcs=false
|
||||||
|
|
||||||
# Patch MIPS LE ELF e_flags (offset 36) for NaN2008-only kernels (e.g. Ingenic X2600).
|
# Patch MIPS LE ELF e_flags (offset 36) for NaN2008-only kernels (e.g. Ingenic X2600).
|
||||||
#
|
#
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package api
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
|
|
@ -18,6 +19,7 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/credential"
|
||||||
"github.com/sipeed/picoclaw/web/backend/utils"
|
"github.com/sipeed/picoclaw/web/backend/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -74,7 +76,15 @@ func (h *Handler) TryAutoStartGateway() {
|
||||||
|
|
||||||
ready, reason, err := h.gatewayStartReady()
|
ready, reason, err := h.gatewayStartReady()
|
||||||
if err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
if !ready {
|
if !ready {
|
||||||
|
|
@ -91,6 +101,8 @@ func (h *Handler) TryAutoStartGateway() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// gatewayStartReady validates whether current config can start the gateway.
|
// 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) {
|
func (h *Handler) gatewayStartReady() (bool, string, error) {
|
||||||
cfg, err := config.LoadConfig(h.configPath)
|
cfg, err := config.LoadConfig(h.configPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -256,7 +268,18 @@ func (h *Handler) startGatewayLocked(initialStatus string) (int, error) {
|
||||||
execPath := utils.FindPicoclawBinary()
|
execPath := utils.FindPicoclawBinary()
|
||||||
|
|
||||||
cmd := exec.Command(execPath, "gateway")
|
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
|
// Forward the launcher's config path via the environment variable that
|
||||||
// GetConfigPath() already reads, so the gateway sub-process uses the same
|
// GetConfigPath() already reads, so the gateway sub-process uses the same
|
||||||
// config file without requiring a --config flag on the gateway subcommand.
|
// 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
|
// Wait for exit in background and clean up
|
||||||
go func() {
|
go func() {
|
||||||
if err := cmd.Wait(); err != nil {
|
exitErr := cmd.Wait()
|
||||||
log.Printf("Gateway process exited: %v", err)
|
if exitErr != nil {
|
||||||
|
log.Printf("Gateway process exited: %v", exitErr)
|
||||||
} else {
|
} else {
|
||||||
log.Printf("Gateway process exited normally")
|
log.Printf("Gateway process exited normally")
|
||||||
}
|
}
|
||||||
|
|
@ -329,6 +353,25 @@ func (h *Handler) startGatewayLocked(initialStatus string) (int, error) {
|
||||||
}
|
}
|
||||||
gateway.mu.Unlock()
|
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 {
|
if shouldBroadcastStopped {
|
||||||
gateway.events.Broadcast(GatewayEvent{
|
gateway.events.Broadcast(GatewayEvent{
|
||||||
Status: "stopped",
|
Status: "stopped",
|
||||||
|
|
@ -662,6 +705,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
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -772,3 +822,17 @@ func scanPipe(r io.Reader, buf *LogBuffer) {
|
||||||
buf.Append(scanner.Text())
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
80
web/backend/api/passphrase.go
Normal file
80
web/backend/api/passphrase.go
Normal file
|
|
@ -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(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -4,9 +4,22 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/credential"
|
||||||
"github.com/sipeed/picoclaw/web/backend/launcherconfig"
|
"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.
|
// Handler serves HTTP API requests.
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
configPath string
|
configPath string
|
||||||
|
|
@ -17,15 +30,19 @@ type Handler struct {
|
||||||
oauthMu sync.Mutex
|
oauthMu sync.Mutex
|
||||||
oauthFlows map[string]*oauthFlow
|
oauthFlows map[string]*oauthFlow
|
||||||
oauthState map[string]string
|
oauthState map[string]string
|
||||||
|
passphraseStore *credential.SecureStore
|
||||||
|
passphraseMu sync.Mutex
|
||||||
|
passphraseLastState passphraseState
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHandler creates an instance of the API handler.
|
// NewHandler creates an instance of the API handler.
|
||||||
func NewHandler(configPath string) *Handler {
|
func NewHandler(configPath string) *Handler {
|
||||||
return &Handler{
|
return &Handler{
|
||||||
configPath: configPath,
|
configPath: configPath,
|
||||||
serverPort: launcherconfig.DefaultPort,
|
serverPort: launcherconfig.DefaultPort,
|
||||||
oauthFlows: make(map[string]*oauthFlow),
|
oauthFlows: make(map[string]*oauthFlow),
|
||||||
oauthState: make(map[string]string),
|
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...)
|
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.
|
// RegisterRoutes binds all API endpoint handlers to the ServeMux.
|
||||||
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
||||||
// Config CRUD
|
// Config CRUD
|
||||||
|
|
@ -54,6 +86,9 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
||||||
// OAuth login and credential management
|
// OAuth login and credential management
|
||||||
h.registerOAuthRoutes(mux)
|
h.registerOAuthRoutes(mux)
|
||||||
|
|
||||||
|
// Passphrase management (in-memory store for encrypted credentials)
|
||||||
|
h.registerPassphraseRoutes(mux)
|
||||||
|
|
||||||
// Model list management
|
// Model list management
|
||||||
h.registerModelRoutes(mux)
|
h.registerModelRoutes(mux)
|
||||||
|
|
||||||
|
|
|
||||||
1
web/backend/dist/.gitkeep
vendored
1
web/backend/dist/.gitkeep
vendored
|
|
@ -1 +0,0 @@
|
||||||
# Keep the embedded web backend dist directory in version control.
|
|
||||||
|
|
@ -22,6 +22,7 @@ import (
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/credential"
|
||||||
"github.com/sipeed/picoclaw/web/backend/api"
|
"github.com/sipeed/picoclaw/web/backend/api"
|
||||||
"github.com/sipeed/picoclaw/web/backend/launcherconfig"
|
"github.com/sipeed/picoclaw/web/backend/launcherconfig"
|
||||||
"github.com/sipeed/picoclaw/web/backend/middleware"
|
"github.com/sipeed/picoclaw/web/backend/middleware"
|
||||||
|
|
@ -115,6 +116,21 @@ func main() {
|
||||||
// API Routes (e.g. /api/status)
|
// API Routes (e.g. /api/status)
|
||||||
apiHandler := api.NewHandler(absPath)
|
apiHandler := api.NewHandler(absPath)
|
||||||
apiHandler.SetServerOptions(portNum, effectivePublic, explicitPublic, launcherCfg.AllowedCIDRs)
|
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/<pid>/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)
|
apiHandler.RegisterRoutes(mux)
|
||||||
|
|
||||||
// Frontend Embedded Assets
|
// Frontend Embedded Assets
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ interface GatewayStatusResponse {
|
||||||
gateway_status: "running" | "starting" | "restarting" | "stopped" | "error"
|
gateway_status: "running" | "starting" | "restarting" | "stopped" | "error"
|
||||||
gateway_start_allowed?: boolean
|
gateway_start_allowed?: boolean
|
||||||
gateway_start_reason?: string
|
gateway_start_reason?: string
|
||||||
|
passphrase_state?: "" | "pending" | "failed"
|
||||||
gateway_restart_required?: boolean
|
gateway_restart_required?: boolean
|
||||||
pid?: number
|
pid?: number
|
||||||
boot_default_model?: string
|
boot_default_model?: string
|
||||||
|
|
|
||||||
30
web/frontend/src/api/passphrase.ts
Normal file
30
web/frontend/src/api/passphrase.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
// API client for in-memory passphrase management.
|
||||||
|
|
||||||
|
const BASE_URL = ""
|
||||||
|
|
||||||
|
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||||
|
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<T>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PassphraseStatusResponse {
|
||||||
|
passphrase_set: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns whether a passphrase is currently held in the launcher. */
|
||||||
|
export async function getPassphraseStatus(): Promise<PassphraseStatusResponse> {
|
||||||
|
return request<PassphraseStatusResponse>("/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 }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -1,26 +1,118 @@
|
||||||
import {
|
import {
|
||||||
|
IconKey,
|
||||||
|
IconLoader2,
|
||||||
|
IconLock,
|
||||||
IconPlugConnectedX,
|
IconPlugConnectedX,
|
||||||
IconRobot,
|
IconRobot,
|
||||||
IconRobotOff,
|
IconRobotOff,
|
||||||
IconStar,
|
IconStar,
|
||||||
} from "@tabler/icons-react"
|
} from "@tabler/icons-react"
|
||||||
import { Link } from "@tanstack/react-router"
|
import { Link } from "@tanstack/react-router"
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
|
|
||||||
|
import { setPassphrase } from "@/api/passphrase"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
|
||||||
interface ChatEmptyStateProps {
|
interface ChatEmptyStateProps {
|
||||||
hasConfiguredModels: boolean
|
hasConfiguredModels: boolean
|
||||||
defaultModelName: string
|
defaultModelName: string
|
||||||
isConnected: boolean
|
isConnected: boolean
|
||||||
|
gatewayStartReason?: string
|
||||||
|
passphraseState?: "" | "pending" | "failed"
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatEmptyState({
|
export function ChatEmptyState({
|
||||||
hasConfiguredModels,
|
hasConfiguredModels,
|
||||||
defaultModelName,
|
defaultModelName,
|
||||||
isConnected,
|
isConnected,
|
||||||
|
gatewayStartReason = "",
|
||||||
|
passphraseState = "",
|
||||||
}: ChatEmptyStateProps) {
|
}: ChatEmptyStateProps) {
|
||||||
const { t } = useTranslation()
|
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 (
|
||||||
|
<div className="flex flex-col items-center justify-center py-20">
|
||||||
|
<div className="mb-6 flex h-16 w-16 items-center justify-center rounded-2xl bg-blue-500/10 text-blue-500">
|
||||||
|
<IconLock className="h-8 w-8" />
|
||||||
|
</div>
|
||||||
|
<h3 className="mb-2 text-xl font-medium">
|
||||||
|
{t("credentials.passphrase.title")}
|
||||||
|
</h3>
|
||||||
|
<p className="text-muted-foreground mb-6 text-center text-sm">
|
||||||
|
{t("credentials.passphrase.description")}
|
||||||
|
</p>
|
||||||
|
{saved ? (
|
||||||
|
<p className="text-green-600 dark:text-green-400 text-sm">
|
||||||
|
{t("credentials.passphrase.successMessage")}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex w-full max-w-sm flex-col gap-2">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
placeholder={t("credentials.passphrase.placeholder")}
|
||||||
|
value={passphrase}
|
||||||
|
onChange={(e) => setPassphraseValue(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === "Enter") void handleUnlock() }}
|
||||||
|
disabled={saving}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
disabled={saving || !passphrase.trim()}
|
||||||
|
onClick={() => void handleUnlock()}
|
||||||
|
>
|
||||||
|
{saving
|
||||||
|
? <IconLoader2 className="size-4 animate-spin" />
|
||||||
|
: <IconKey className="size-4" />}
|
||||||
|
{saving
|
||||||
|
? t("credentials.passphrase.saving")
|
||||||
|
: t("credentials.passphrase.save")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<p className="text-destructive text-xs">{error}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (!hasConfiguredModels) {
|
if (!hasConfiguredModels) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -85,3 +177,4 @@ export function ChatEmptyState({
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ export function ChatPage() {
|
||||||
newChat,
|
newChat,
|
||||||
} = usePicoChat()
|
} = usePicoChat()
|
||||||
|
|
||||||
const { state: gwState } = useGateway()
|
const { state: gwState, startReason, passphraseState } = useGateway()
|
||||||
const isConnected = gwState === "running"
|
const isConnected = gwState === "running"
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
|
@ -144,6 +144,8 @@ export function ChatPage() {
|
||||||
hasConfiguredModels={hasConfiguredModels}
|
hasConfiguredModels={hasConfiguredModels}
|
||||||
defaultModelName={defaultModelName}
|
defaultModelName={defaultModelName}
|
||||||
isConnected={isConnected}
|
isConnected={isConnected}
|
||||||
|
gatewayStartReason={startReason}
|
||||||
|
passphraseState={passphraseState}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import { AntigravityCredentialCard } from "./antigravity-credential-card"
|
||||||
import { DeviceCodeSheet } from "./device-code-sheet"
|
import { DeviceCodeSheet } from "./device-code-sheet"
|
||||||
import { LogoutConfirmDialog } from "./logout-confirm-dialog"
|
import { LogoutConfirmDialog } from "./logout-confirm-dialog"
|
||||||
import { OpenAICredentialCard } from "./openai-credential-card"
|
import { OpenAICredentialCard } from "./openai-credential-card"
|
||||||
|
import { PassphraseCard } from "./passphrase-card"
|
||||||
|
|
||||||
export function CredentialsPage() {
|
export function CredentialsPage() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
@ -51,6 +52,11 @@ export function CredentialsPage() {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Passphrase card is always visible — independent of OAuth loading state */}
|
||||||
|
<div className="pt-5">
|
||||||
|
<PassphraseCard />
|
||||||
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="text-destructive bg-destructive/10 mt-4 rounded-lg px-4 py-3 text-sm">
|
<div className="text-destructive bg-destructive/10 mt-4 rounded-lg px-4 py-3 text-sm">
|
||||||
{error}
|
{error}
|
||||||
|
|
|
||||||
114
web/frontend/src/components/credentials/passphrase-card.tsx
Normal file
114
web/frontend/src/components/credentials/passphrase-card.tsx
Normal file
|
|
@ -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<boolean | null>(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 (
|
||||||
|
<section className="bg-card flex h-full flex-col rounded-xl border p-4">
|
||||||
|
<div className="min-h-16">
|
||||||
|
<h3 className="text-base font-semibold inline-flex items-center gap-2">
|
||||||
|
<span className="border-muted inline-flex size-6 items-center justify-center rounded-full border">
|
||||||
|
<IconKey className="size-3.5" />
|
||||||
|
</span>
|
||||||
|
{t("credentials.passphrase.title")}
|
||||||
|
</h3>
|
||||||
|
<p className="text-muted-foreground mt-1 text-xs">
|
||||||
|
{t("credentials.passphrase.description")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 flex items-center gap-2 text-xs">
|
||||||
|
{isSet === null ? (
|
||||||
|
<IconLoader2 className="size-3.5 animate-spin text-muted-foreground" />
|
||||||
|
) : isSet ? (
|
||||||
|
<>
|
||||||
|
<IconLockOpen className="size-3.5 text-green-500" />
|
||||||
|
<span className="text-green-600 dark:text-green-400">
|
||||||
|
{t("credentials.passphrase.statusSet")}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<IconLock className="size-3.5 text-muted-foreground" />
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{t("credentials.passphrase.statusNotSet")}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-auto flex flex-col gap-4 pt-4">
|
||||||
|
<div className="border-muted flex h-[120px] flex-col justify-center rounded-lg border p-3">
|
||||||
|
<div className="flex h-full flex-col gap-3">
|
||||||
|
<div className="flex h-full items-center gap-2">
|
||||||
|
<Input
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") void handleSave()
|
||||||
|
}}
|
||||||
|
type="password"
|
||||||
|
placeholder={t("credentials.passphrase.placeholder")}
|
||||||
|
disabled={saving}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="w-fit"
|
||||||
|
disabled={saving || !value.trim()}
|
||||||
|
onClick={() => void handleSave()}
|
||||||
|
>
|
||||||
|
{saving && <IconLoader2 className="size-4 animate-spin" />}
|
||||||
|
{saving
|
||||||
|
? t("credentials.passphrase.saving")
|
||||||
|
: t("credentials.passphrase.save")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{message && (
|
||||||
|
<p
|
||||||
|
className={`text-xs ${message.error ? "text-destructive" : "text-green-600 dark:text-green-400"}`}
|
||||||
|
>
|
||||||
|
{message.text}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="min-h-8" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -19,7 +19,7 @@ let sseInitialized = false
|
||||||
|
|
||||||
export function useGateway() {
|
export function useGateway() {
|
||||||
const gateway = useAtomValue(gatewayAtom)
|
const gateway = useAtomValue(gatewayAtom)
|
||||||
const { status: state, canStart, restartRequired } = gateway
|
const { status: state, canStart, startReason, passphraseState, restartRequired } = gateway
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
const applyGatewayStatus = useCallback((data: GatewayStatusResponse) => {
|
const applyGatewayStatus = useCallback((data: GatewayStatusResponse) => {
|
||||||
|
|
@ -37,6 +37,8 @@ export function useGateway() {
|
||||||
updateGatewayStore({
|
updateGatewayStore({
|
||||||
status: "unknown",
|
status: "unknown",
|
||||||
canStart: true,
|
canStart: true,
|
||||||
|
startReason: "",
|
||||||
|
passphraseState: "",
|
||||||
restartRequired: false,
|
restartRequired: false,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
@ -144,5 +146,5 @@ export function useGateway() {
|
||||||
}
|
}
|
||||||
}, [applyGatewayStatus, canStart, restartRequired, state])
|
}, [applyGatewayStatus, canStart, restartRequired, state])
|
||||||
|
|
||||||
return { state, loading, canStart, restartRequired, start, stop, restart }
|
return { state, loading, canStart, startReason, passphraseState, restartRequired, start, stop, restart }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,19 @@
|
||||||
"credentials": {
|
"credentials": {
|
||||||
"description": "Manage OAuth and token-based credentials for supported providers.",
|
"description": "Manage OAuth and token-based credentials for supported providers.",
|
||||||
"loading": "Loading credentials...",
|
"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": {
|
"providers": {
|
||||||
"openai": {
|
"openai": {
|
||||||
"description": "Supports browser OAuth, device code, and token login."
|
"description": "Supports browser OAuth, device code, and token login."
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,19 @@
|
||||||
"credentials": {
|
"credentials": {
|
||||||
"description": "管理已支持服务商的 OAuth 与 Token 凭据。",
|
"description": "管理已支持服务商的 OAuth 与 Token 凭据。",
|
||||||
"loading": "正在加载凭据...",
|
"loading": "正在加载凭据...",
|
||||||
|
"passphrase": {
|
||||||
|
"title": "加密密码",
|
||||||
|
"description": "用于解密配置中的 enc:// API Key,仅存储在内存中,不会写入磁盘。",
|
||||||
|
"statusSet": "密码已加载",
|
||||||
|
"statusNotSet": "未设置密码",
|
||||||
|
"placeholder": "输入密码",
|
||||||
|
"save": "解锁",
|
||||||
|
"saving": "解锁中...",
|
||||||
|
"successMessage": "密码已存储,网关正在启动...",
|
||||||
|
"errorEmpty": "密码不能为空。",
|
||||||
|
"errorSave": "存储密码失败。",
|
||||||
|
"errorWrongPassphrase": "密码错误,网关启动失败,请重试。"
|
||||||
|
},
|
||||||
"providers": {
|
"providers": {
|
||||||
"openai": {
|
"openai": {
|
||||||
"description": "支持浏览器 OAuth、设备码和 Token 登录。"
|
"description": "支持浏览器 OAuth、设备码和 Token 登录。"
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,8 @@ export type GatewayState =
|
||||||
export interface GatewayStoreState {
|
export interface GatewayStoreState {
|
||||||
status: GatewayState
|
status: GatewayState
|
||||||
canStart: boolean
|
canStart: boolean
|
||||||
|
startReason: string
|
||||||
|
passphraseState: "" | "pending" | "failed"
|
||||||
restartRequired: boolean
|
restartRequired: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -21,6 +23,8 @@ type GatewayStorePatch = Partial<GatewayStoreState>
|
||||||
const DEFAULT_GATEWAY_STATE: GatewayStoreState = {
|
const DEFAULT_GATEWAY_STATE: GatewayStoreState = {
|
||||||
status: "unknown",
|
status: "unknown",
|
||||||
canStart: true,
|
canStart: true,
|
||||||
|
startReason: "",
|
||||||
|
passphraseState: "",
|
||||||
restartRequired: false,
|
restartRequired: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -49,13 +53,19 @@ export function applyGatewayStatusToStore(
|
||||||
data: Partial<
|
data: Partial<
|
||||||
Pick<
|
Pick<
|
||||||
GatewayStatusResponse,
|
GatewayStatusResponse,
|
||||||
"gateway_status" | "gateway_start_allowed" | "gateway_restart_required"
|
| "gateway_status"
|
||||||
|
| "gateway_start_allowed"
|
||||||
|
| "gateway_start_reason"
|
||||||
|
| "gateway_restart_required"
|
||||||
|
| "passphrase_state"
|
||||||
>
|
>
|
||||||
>,
|
>,
|
||||||
) {
|
) {
|
||||||
updateGatewayStore((prev) => ({
|
updateGatewayStore((prev) => ({
|
||||||
status: data.gateway_status ?? prev.status,
|
status: data.gateway_status ?? prev.status,
|
||||||
canStart: data.gateway_start_allowed ?? prev.canStart,
|
canStart: data.gateway_start_allowed ?? prev.canStart,
|
||||||
|
startReason: data.gateway_start_reason ?? prev.startReason,
|
||||||
|
passphraseState: data.passphrase_state ?? prev.passphraseState,
|
||||||
restartRequired:
|
restartRequired:
|
||||||
data.gateway_restart_required ??
|
data.gateway_restart_required ??
|
||||||
(data.gateway_status && data.gateway_status !== "running"
|
(data.gateway_status && data.gateway_status !== "running"
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue