feat: improve user onboarding and setup UX (#1436)

* feat: add beginner onboarding and mode switching flow

* chore: ignore local gh auth config

* fix: restore kline fallback and align onboarding language

---------

Co-authored-by: zavier-bin <zhaobbbhhh@gmail.com>
This commit is contained in:
Zavier
2026-03-28 00:17:37 +08:00
committed by GitHub
parent 4ab4024628
commit b331733e23
22 changed files with 1504 additions and 253 deletions
+17 -1
View File
@@ -10,6 +10,7 @@ import (
"nofx/crypto"
"nofx/logger"
"nofx/security"
"nofx/wallet"
"github.com/gin-gonic/gin"
)
@@ -31,6 +32,8 @@ type SafeModelConfig struct {
Enabled bool `json:"enabled"`
CustomAPIURL string `json:"customApiUrl"` // Custom API URL (usually not sensitive)
CustomModelName string `json:"customModelName"` // Custom model name (not sensitive)
WalletAddress string `json:"walletAddress,omitempty"`
BalanceUSDC string `json:"balanceUsdc,omitempty"`
}
type UpdateModelConfigRequest struct {
@@ -75,7 +78,7 @@ func (s *Server) handleGetModelConfigs(c *gin.Context) {
// Convert to safe response structure, remove sensitive information
safeModels := make([]SafeModelConfig, len(models))
for i, model := range models {
safeModels[i] = SafeModelConfig{
safeModel := SafeModelConfig{
ID: model.ID,
Name: model.Name,
Provider: model.Provider,
@@ -83,6 +86,19 @@ func (s *Server) handleGetModelConfigs(c *gin.Context) {
CustomAPIURL: model.CustomAPIURL,
CustomModelName: model.CustomModelName,
}
if model.Provider == "claw402" {
if privateKey := strings.TrimSpace(model.APIKey.String()); privateKey != "" {
if walletAddress, addrErr := walletAddressFromPrivateKey(privateKey); addrErr == nil {
safeModel.WalletAddress = walletAddress
safeModel.BalanceUSDC = wallet.QueryUSDCBalanceStr(walletAddress)
} else {
logger.Warnf("⚠️ Failed to derive claw402 wallet address for model %s: %v", model.ID, addrErr)
}
}
}
safeModels[i] = safeModel
}
c.JSON(http.StatusOK, safeModels)
+323
View File
@@ -0,0 +1,323 @@
package api
import (
"bufio"
"encoding/hex"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"nofx/logger"
"nofx/wallet"
gethcrypto "github.com/ethereum/go-ethereum/crypto"
"github.com/gin-gonic/gin"
)
type beginnerOnboardingResponse struct {
Address string `json:"address"`
PrivateKey string `json:"private_key"`
Chain string `json:"chain"`
Asset string `json:"asset"`
Provider string `json:"provider"`
DefaultModel string `json:"default_model"`
ConfiguredModelID string `json:"configured_model_id"`
BalanceUSDC string `json:"balance_usdc"`
EnvSaved bool `json:"env_saved"`
EnvPath string `json:"env_path,omitempty"`
ReusedExisting bool `json:"reused_existing"`
EnvWarning string `json:"env_warning,omitempty"`
}
type currentBeginnerWalletResponse struct {
Found bool `json:"found"`
Address string `json:"address,omitempty"`
BalanceUSDC string `json:"balance_usdc,omitempty"`
Source string `json:"source,omitempty"`
Claw402Status string `json:"claw402_status"`
}
func (s *Server) handleBeginnerOnboarding(c *gin.Context) {
userID := c.GetString("user_id")
if userID == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing user context"})
return
}
privateKey, address, configuredModelID, reusedExisting, err := s.resolveBeginnerWallet(userID)
if err != nil {
logger.Errorf("Failed to resolve beginner wallet for user %s: %v", userID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to prepare beginner wallet"})
return
}
if !reusedExisting {
if err := s.store.AIModel().Update(userID, "claw402", true, privateKey, "", "deepseek"); err != nil {
logger.Errorf("Failed to save beginner claw402 config for user %s: %v", userID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save beginner model configuration"})
return
}
configuredModelID, err = s.findConfiguredClaw402ModelID(userID)
if err != nil {
logger.Warnf("Could not resolve configured claw402 model id for user %s: %v", userID, err)
}
}
os.Setenv("CLAW402_WALLET_KEY", privateKey)
os.Setenv("CLAW402_WALLET_ADDRESS", address)
os.Setenv("CLAW402_DEFAULT_MODEL", "deepseek")
envSaved, envPath, envErr := persistBeginnerWalletEnv(privateKey, address)
resp := beginnerOnboardingResponse{
Address: address,
PrivateKey: privateKey,
Chain: "base",
Asset: "USDC",
Provider: "claw402",
DefaultModel: "deepseek",
ConfiguredModelID: configuredModelID,
BalanceUSDC: wallet.QueryUSDCBalanceStr(address),
EnvSaved: envSaved,
EnvPath: envPath,
ReusedExisting: reusedExisting,
}
if envErr != nil {
resp.EnvWarning = envErr.Error()
logger.Warnf("Beginner wallet env persistence warning for user %s: %v", userID, envErr)
}
c.JSON(http.StatusOK, resp)
}
func (s *Server) handleCurrentBeginnerWallet(c *gin.Context) {
userID := c.GetString("user_id")
if userID == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing user context"})
return
}
claw402Status := checkClaw402Health()
models, err := s.store.AIModel().List(userID)
if err != nil {
logger.Errorf("Failed to load current beginner wallet for user %s: %v", userID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load current wallet"})
return
}
for _, model := range models {
if model == nil || model.Provider != "claw402" {
continue
}
privateKey := strings.TrimSpace(model.APIKey.String())
if privateKey == "" {
continue
}
address, addrErr := walletAddressFromPrivateKey(privateKey)
if addrErr != nil {
logger.Warnf("Failed to derive current beginner wallet for user %s: %v", userID, addrErr)
continue
}
c.JSON(http.StatusOK, currentBeginnerWalletResponse{
Found: true,
Address: address,
BalanceUSDC: wallet.QueryUSDCBalanceStr(address),
Source: "model",
Claw402Status: claw402Status,
})
return
}
address := strings.TrimSpace(os.Getenv("CLAW402_WALLET_ADDRESS"))
if address != "" {
c.JSON(http.StatusOK, currentBeginnerWalletResponse{
Found: true,
Address: address,
BalanceUSDC: wallet.QueryUSDCBalanceStr(address),
Source: "env",
Claw402Status: claw402Status,
})
return
}
c.JSON(http.StatusOK, currentBeginnerWalletResponse{
Found: false,
Claw402Status: claw402Status,
})
}
func (s *Server) resolveBeginnerWallet(userID string) (privateKey string, address string, configuredModelID string, reused bool, err error) {
models, err := s.store.AIModel().List(userID)
if err != nil {
return "", "", "", false, err
}
for _, model := range models {
if model == nil || model.Provider != "claw402" {
continue
}
existingKey := strings.TrimSpace(model.APIKey.String())
if existingKey == "" {
continue
}
addr, addrErr := walletAddressFromPrivateKey(existingKey)
if addrErr != nil {
logger.Warnf("Existing claw402 key for user %s is invalid, regenerating: %v", userID, addrErr)
break
}
return existingKey, addr, model.ID, true, nil
}
privateKeyObj, genErr := gethcrypto.GenerateKey()
if genErr != nil {
return "", "", "", false, genErr
}
addr := gethcrypto.PubkeyToAddress(privateKeyObj.PublicKey)
keyHex := "0x" + hex.EncodeToString(gethcrypto.FromECDSA(privateKeyObj))
return keyHex, addr.Hex(), "", false, nil
}
func (s *Server) findConfiguredClaw402ModelID(userID string) (string, error) {
models, err := s.store.AIModel().List(userID)
if err != nil {
return "", err
}
for _, model := range models {
if model != nil && model.Provider == "claw402" {
return model.ID, nil
}
}
return "", fmt.Errorf("claw402 model not found")
}
func walletAddressFromPrivateKey(privateKey string) (string, error) {
key := strings.TrimSpace(privateKey)
if !strings.HasPrefix(key, "0x") {
return "", fmt.Errorf("private key must start with 0x")
}
if len(key) != 66 {
return "", fmt.Errorf("private key must be 66 characters")
}
privateKeyObj, err := gethcrypto.HexToECDSA(strings.TrimPrefix(key, "0x"))
if err != nil {
return "", err
}
return gethcrypto.PubkeyToAddress(privateKeyObj.PublicKey).Hex(), nil
}
func persistBeginnerWalletEnv(privateKey string, address string) (bool, string, error) {
paths := uniqueEnvPaths([]string{
".env",
filepath.Join(".", ".env"),
"/app/.env",
})
var lastErr error
for _, path := range paths {
if path == "" {
continue
}
if err := upsertEnvFile(path, map[string]string{
"CLAW402_WALLET_KEY": privateKey,
"CLAW402_WALLET_ADDRESS": address,
"CLAW402_DEFAULT_MODEL": "deepseek",
}); err != nil {
lastErr = err
continue
}
return true, path, nil
}
if lastErr == nil {
lastErr = fmt.Errorf("no writable .env path found")
}
return false, "", lastErr
}
func uniqueEnvPaths(paths []string) []string {
seen := make(map[string]struct{}, len(paths))
result := make([]string, 0, len(paths))
for _, path := range paths {
clean := filepath.Clean(path)
if _, ok := seen[clean]; ok {
continue
}
seen[clean] = struct{}{}
result = append(result, clean)
}
return result
}
func upsertEnvFile(path string, values map[string]string) error {
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return err
}
existingLines := make([]string, 0)
if file, err := os.Open(path); err == nil {
scanner := bufio.NewScanner(file)
for scanner.Scan() {
existingLines = append(existingLines, scanner.Text())
}
file.Close()
if err := scanner.Err(); err != nil {
return err
}
} else if !os.IsNotExist(err) {
return err
}
remaining := make(map[string]string, len(values))
for key, value := range values {
remaining[key] = value
}
updatedLines := make([]string, 0, len(existingLines)+len(values))
for _, line := range existingLines {
trimmed := strings.TrimSpace(line)
if trimmed == "" || strings.HasPrefix(trimmed, "#") || !strings.Contains(line, "=") {
updatedLines = append(updatedLines, line)
continue
}
parts := strings.SplitN(line, "=", 2)
key := strings.TrimSpace(parts[0])
value, ok := remaining[key]
if !ok {
updatedLines = append(updatedLines, line)
continue
}
updatedLines = append(updatedLines, fmt.Sprintf("%s=%s", key, value))
delete(remaining, key)
}
for key, value := range remaining {
updatedLines = append(updatedLines, fmt.Sprintf("%s=%s", key, value))
}
content := strings.Join(updatedLines, "\n")
if content != "" && !strings.HasSuffix(content, "\n") {
content += "\n"
}
if err := os.WriteFile(path, []byte(content), 0600); err != nil {
return err
}
return nil
}
+2
View File
@@ -122,6 +122,8 @@ func (s *Server) setupRoutes() {
{
// Logout (add to blacklist)
s.route(protected, "POST", "/logout", "Logout (blacklist token)", s.handleLogout)
s.route(protected, "POST", "/onboarding/beginner", "Prepare beginner claw402 wallet and default model", s.handleBeginnerOnboarding)
s.route(protected, "GET", "/onboarding/beginner/current", "Get current beginner claw402 wallet", s.handleCurrentBeginnerWallet)
// User account management
s.routeWithSchema(protected, "PUT", "/user/password", "Change current user password",