From b331733e234533ee298740197e2eb54888707160 Mon Sep 17 00:00:00 2001 From: Zavier Date: Sat, 28 Mar 2026 00:17:37 +0800 Subject: [PATCH] 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 --- .gitignore | 2 + api/handler_ai_model.go | 18 +- api/handler_onboarding.go | 323 ++++++++++++++++++ api/server.go | 2 + docker-compose.yml | 3 +- web/src/App.tsx | 17 +- web/src/components/auth/LoginPage.tsx | 11 +- .../auth/OnboardingModeSelector.tsx | 75 ++++ web/src/components/common/HeaderBar.tsx | 28 ++ web/src/components/modals/SetupPage.tsx | 14 +- web/src/components/trader/AITradersPage.tsx | 73 +++- .../components/trader/BeginnerGuideCards.tsx | 169 +++++++++ .../components/trader/ConfigStatusGrid.tsx | 14 + .../components/trader/ModelConfigModal.tsx | 312 +++++++++++------ web/src/contexts/AuthContext.tsx | 88 ++--- web/src/i18n/translations.ts | 15 + web/src/lib/api/config.ts | 22 ++ web/src/lib/onboarding.ts | 28 ++ web/src/pages/BeginnerOnboardingPage.tsx | 264 ++++++++++++++ web/src/pages/SettingsPage.tsx | 87 +++++ web/src/pages/StrategyStudioPage.tsx | 167 ++++----- web/src/types/config.ts | 25 ++ 22 files changed, 1504 insertions(+), 253 deletions(-) create mode 100644 api/handler_onboarding.go create mode 100644 web/src/components/auth/OnboardingModeSelector.tsx create mode 100644 web/src/components/trader/BeginnerGuideCards.tsx create mode 100644 web/src/lib/onboarding.ts create mode 100644 web/src/pages/BeginnerOnboardingPage.tsx diff --git a/.gitignore b/.gitignore index db7745d5..cae87c9e 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,8 @@ Thumbs.db *.tmp *.bak *.backup +.cache/ +.gh-config/ # 环境变量 .env diff --git a/api/handler_ai_model.go b/api/handler_ai_model.go index 48889d6a..4ffce4f2 100644 --- a/api/handler_ai_model.go +++ b/api/handler_ai_model.go @@ -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) diff --git a/api/handler_onboarding.go b/api/handler_onboarding.go new file mode 100644 index 00000000..263d1909 --- /dev/null +++ b/api/handler_onboarding.go @@ -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 +} diff --git a/api/server.go b/api/server.go index f9377934..4f5ea098 100644 --- a/api/server.go +++ b/api/server.go @@ -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", diff --git a/docker-compose.yml b/docker-compose.yml index 82723977..7d36c2c1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,6 +11,7 @@ services: - "${NOFX_BACKEND_PORT:-8080}:8080" - "6060:6060" # pprof profiling volumes: + - ./.env:/app/.env - ./data:/app/data - /etc/localtime:/etc/localtime:ro env_file: @@ -49,4 +50,4 @@ services: networks: nofx-network: - driver: bridge \ No newline at end of file + driver: bridge diff --git a/web/src/App.tsx b/web/src/App.tsx index 17a9c2b1..6c0b04be 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -15,6 +15,7 @@ import { FAQPage } from './pages/FAQPage' import { StrategyStudioPage } from './pages/StrategyStudioPage' import { StrategyMarketPage } from './pages/StrategyMarketPage' import { DataPage } from './pages/DataPage' +import { BeginnerOnboardingPage } from './pages/BeginnerOnboardingPage' import { LoginRequiredOverlay } from './components/auth/LoginRequiredOverlay' import HeaderBar from './components/common/HeaderBar' import { LanguageProvider, useLanguage } from './contexts/LanguageContext' @@ -22,6 +23,7 @@ import { AuthProvider, useAuth } from './contexts/AuthContext' import { ConfirmDialogProvider } from './components/common/ConfirmDialog' import { t } from './i18n/translations' import { useSystemConfig } from './hooks/useSystemConfig' +import { getUserMode } from './lib/onboarding' import { OFFICIAL_LINKS } from './constants/branding' import type { @@ -132,6 +134,8 @@ function App() { } const [lastUpdate, setLastUpdate] = useState('--:--:--') const [decisionsLimit, setDecisionsLimit] = useState(5) + const hasPersistedAuth = + !!localStorage.getItem('auth_token') && !!localStorage.getItem('auth_user') // 监听URL变化,同步页面状态 useEffect(() => { @@ -347,6 +351,17 @@ function App() { } return } + if (route === '/welcome') { + if ((!user || !token) && !hasPersistedAuth) { + window.location.href = '/login' + return null + } + if (getUserMode() !== 'beginner') { + window.location.href = '/traders' + return null + } + return + } if (route === '/faq') { return (
} if (route === '/settings') { - if (!user || !token) { + if ((!user || !token) && !hasPersistedAuth) { window.location.href = '/login' return null } diff --git a/web/src/components/auth/LoginPage.tsx b/web/src/components/auth/LoginPage.tsx index d7fe3845..2f911132 100644 --- a/web/src/components/auth/LoginPage.tsx +++ b/web/src/components/auth/LoginPage.tsx @@ -5,6 +5,8 @@ import { useAuth } from '../../contexts/AuthContext' import { useLanguage } from '../../contexts/LanguageContext' import { t } from '../../i18n/translations' import { DeepVoidBackground } from '../common/DeepVoidBackground' +import { OnboardingModeSelector } from './OnboardingModeSelector' +import type { UserMode } from '../../lib/onboarding' export function LoginPage() { const { language } = useLanguage() @@ -15,6 +17,7 @@ export function LoginPage() { const [error, setError] = useState('') const [loading, setLoading] = useState(false) const [expiredToastId, setExpiredToastId] = useState(null) + const [mode, setMode] = useState('beginner') useEffect(() => { if (sessionStorage.getItem('from401') === 'true') { @@ -28,7 +31,7 @@ export function LoginPage() { e.preventDefault() setError('') setLoading(true) - const result = await login(email, password) + const result = await login(email, password, mode) setLoading(false) if (result.success) { if (expiredToastId) toast.dismiss(expiredToastId) @@ -109,6 +112,12 @@ export function LoginPage() {
+ + {/* Error */} {error && (

diff --git a/web/src/components/auth/OnboardingModeSelector.tsx b/web/src/components/auth/OnboardingModeSelector.tsx new file mode 100644 index 00000000..5d403a3a --- /dev/null +++ b/web/src/components/auth/OnboardingModeSelector.tsx @@ -0,0 +1,75 @@ +import type { UserMode } from '../../lib/onboarding' + +interface OnboardingModeSelectorProps { + language: string + mode: UserMode + onChange: (mode: UserMode) => void +} + +export function OnboardingModeSelector({ + language, + mode, + onChange, +}: OnboardingModeSelectorProps) { + const isZh = language === 'zh' + + const options: Array<{ + id: UserMode + title: string + badge?: string + description: string + }> = [ + { + id: 'beginner', + title: isZh ? '新手模式' : 'Beginner Mode', + badge: isZh ? '推荐' : 'Recommended', + description: isZh + ? '自动生成 Base 钱包,默认接入 Claw402 + DeepSeek,最快完成首次启动。' + : 'Generate a Base wallet automatically and start with Claw402 + DeepSeek by default.', + }, + { + id: 'advanced', + title: isZh ? '老手模式' : 'Advanced Mode', + description: isZh + ? '保持现在的完整配置流程,你自己决定模型、钱包和交易所。' + : 'Keep the full manual flow and configure models, wallets, and exchanges yourself.', + }, + ] + + return ( +

+
+ {isZh ? '使用模式' : 'Experience'} +
+
+ {options.map((option) => { + const selected = option.id === mode + return ( + + ) + })} +
+
+ ) +} diff --git a/web/src/components/common/HeaderBar.tsx b/web/src/components/common/HeaderBar.tsx index 1b580b53..3922392b 100644 --- a/web/src/components/common/HeaderBar.tsx +++ b/web/src/components/common/HeaderBar.tsx @@ -4,6 +4,12 @@ import { motion, AnimatePresence } from 'framer-motion' import { Menu, X, ChevronDown, Settings } from 'lucide-react' import { t, type Language } from '../../i18n/translations' import { OFFICIAL_LINKS } from '../../constants/branding' +import { + getPostAuthPath, + getUserMode, + setUserMode, + type UserMode, +} from '../../lib/onboarding' type Page = | 'competition' @@ -44,8 +50,21 @@ export default function HeaderBar({ const [mobileMenuOpen, setMobileMenuOpen] = useState(false) const [languageDropdownOpen, setLanguageDropdownOpen] = useState(false) const [userDropdownOpen, setUserDropdownOpen] = useState(false) + const [userMode, setUserModeState] = useState(() => getUserMode() ?? 'advanced') const dropdownRef = useRef(null) const userDropdownRef = useRef(null) + + const navigateInApp = (path: string) => { + navigate(path) + window.dispatchEvent(new PopStateEvent('popstate')) + } + + const handleSwitchMode = (nextMode: UserMode) => { + setUserMode(nextMode) + setUserModeState(nextMode) + setUserDropdownOpen(false) + navigateInApp(getPostAuthPath(nextMode)) + } // Close dropdown when clicking outside useEffect(() => { function handleClickOutside(event: MouseEvent) { @@ -216,6 +235,15 @@ export default function HeaderBar({ Settings + {onLogout && ( + + ) + })} + + + ) +} diff --git a/web/src/components/trader/ConfigStatusGrid.tsx b/web/src/components/trader/ConfigStatusGrid.tsx index fb6c3197..75dc3d31 100644 --- a/web/src/components/trader/ConfigStatusGrid.tsx +++ b/web/src/components/trader/ConfigStatusGrid.tsx @@ -92,6 +92,20 @@ export function ConfigStatusGrid({
{model.customModelName || AI_PROVIDER_CONFIG[model.provider]?.defaultModel || ''}
+ {model.provider === 'claw402' && (model.balanceUsdc || model.walletAddress) ? ( +
+ {model.balanceUsdc ? ( + + {model.balanceUsdc} USDC + + ) : null} + {model.walletAddress ? ( + + {truncateAddress(model.walletAddress)} + + ) : null} +
+ ) : null} diff --git a/web/src/components/trader/ModelConfigModal.tsx b/web/src/components/trader/ModelConfigModal.tsx index 7b8780a9..5124e69e 100644 --- a/web/src/components/trader/ModelConfigModal.tsx +++ b/web/src/components/trader/ModelConfigModal.tsx @@ -4,6 +4,7 @@ import { Trash2, Brain, ExternalLink } from 'lucide-react' import type { AIModel } from '../../types' import type { Language } from '../../i18n/translations' import { t } from '../../i18n/translations' +import { api } from '../../lib/api' import { getModelIcon } from '../common/ModelIcons' import { ModelStepIndicator } from './ModelStepIndicator' import { ModelCard } from './ModelCard' @@ -12,6 +13,7 @@ import { AI_PROVIDER_CONFIG, getShortName, } from './model-constants' +import { getBeginnerWalletAddress } from '../../lib/onboarding' interface ModelConfigModalProps { allModels: AIModel[] @@ -42,20 +44,22 @@ export function ModelConfigModal({ const [apiKey, setApiKey] = useState('') const [baseUrl, setBaseUrl] = useState('') const [modelName, setModelName] = useState('') + const configuredModel = + configuredModels?.find((model) => model.id === selectedModelId) || null // Always prefer allModels (supportedModels) for provider/id lookup; // fall back to configuredModels for edit mode details (apiKey etc.) const selectedModel = - allModels?.find((m) => m.id === selectedModelId) || - configuredModels?.find((m) => m.id === selectedModelId) + allModels?.find((m) => m.id === selectedModelId) || configuredModel useEffect(() => { - if (editingModelId && selectedModel) { - setApiKey(selectedModel.apiKey || '') - setBaseUrl(selectedModel.customApiUrl || '') - setModelName(selectedModel.customModelName || '') + const modelDetails = configuredModel || selectedModel + if (editingModelId && modelDetails) { + setApiKey(modelDetails.apiKey || '') + setBaseUrl(modelDetails.customApiUrl || '') + setModelName(modelDetails.customModelName || '') } - }, [editingModelId, selectedModel]) + }, [editingModelId, configuredModel, selectedModel]) const handleSelectModel = (modelId: string) => { setSelectedModelId(modelId) @@ -79,7 +83,18 @@ export function ModelConfigModal({ const availableModels = allModels || [] const configuredIds = new Set(configuredModels?.map(m => m.id) || []) - const stepLabels = [t('modelConfig.selectModel', language), t('modelConfig.configureApi', language)] + const isClaw402Selected = selectedModel?.provider === 'claw402' || selectedModel?.id === 'claw402' + const stepLabels = [ + t('modelConfig.selectModel', language), + t( + !selectedModel + ? 'modelConfig.configure' + : isClaw402Selected + ? 'modelConfig.configureWallet' + : 'modelConfig.configure', + language + ), + ] return (
@@ -143,6 +158,7 @@ export function ModelConfigModal({ void language: Language }) { + const [showOtherProviders, setShowOtherProviders] = useState(false) + const claw402Model = availableModels.find((m) => m.provider === 'claw402') + const otherProviders = availableModels.filter((m) => m.provider !== 'claw402') + return (
@@ -196,12 +216,11 @@ function ModelSelectionStep({
{/* Claw402 Featured Card */} - {availableModels.some(m => m.provider === 'claw402') && ( + {claw402Model && (
- {configuredIds.has(availableModels.find(m => m.provider === 'claw402')?.id || '') && ( + {configuredIds.has(claw402Model.id) && (
)}
@@ -235,23 +254,57 @@ function ModelSelectionStep({ GPT · Claude · DeepSeek · Gemini · Grok · Qwen · Kimi
+
+ {t('modelConfig.claw402EntryDesc', language)} +
)} -
- {availableModels.filter(m => m.provider !== 'claw402').map((model) => ( - onSelectModel(model.id)} - configured={configuredIds.has(model.id)} - /> - ))} -
-
- {t('modelConfig.modelsConfigured', language)} -
+ {otherProviders.length > 0 && ( +
+ + + {showOtherProviders && ( +
+
+ {otherProviders.map((model) => ( + onSelectModel(model.id)} + configured={configuredIds.has(model.id)} + /> + ))} +
+
+ {t('modelConfig.modelsConfigured', language)} +
+
+ )} +
+ )}
) } @@ -259,6 +312,7 @@ function ModelSelectionStep({ function Claw402ConfigForm({ apiKey, modelName, + configuredModel, editingModelId, onApiKeyChange, onModelNameChange, @@ -268,6 +322,7 @@ function Claw402ConfigForm({ }: { apiKey: string modelName: string + configuredModel: AIModel | null editingModelId: string | null onApiKeyChange: (value: string) => void onModelNameChange: (value: string) => void @@ -278,14 +333,21 @@ function Claw402ConfigForm({ const [walletAddress, setWalletAddress] = useState('') const [copiedAddr, setCopiedAddr] = useState(false) const [showDeposit, setShowDeposit] = useState(false) - const [showNewWalletBackup, setShowNewWalletBackup] = useState(false) - const [newWalletKey, setNewWalletKey] = useState('') const [usdcBalance, setUsdcBalance] = useState(null) const [keyError, setKeyError] = useState('') const [validating, setValidating] = useState(false) const [claw402Status, setClaw402Status] = useState(null) const [testResult, setTestResult] = useState<{ status: string; message: string } | null>(null) const [testing, setTesting] = useState(false) + const [serverWalletAddress, setServerWalletAddress] = useState('') + const [serverWalletBalance, setServerWalletBalance] = useState(null) + const localWalletAddress = getBeginnerWalletAddress()?.trim() || '' + const configuredWalletAddress = + configuredModel?.walletAddress?.trim() || localWalletAddress || serverWalletAddress + const resolvedWalletAddress = walletAddress || configuredWalletAddress + const resolvedUsdcBalance = + usdcBalance ?? configuredModel?.balanceUsdc ?? serverWalletBalance ?? null + const hasExistingWallet = Boolean(configuredWalletAddress) // Client-side validation helper const getClientError = (key: string): string => { @@ -298,8 +360,36 @@ function Claw402ConfigForm({ const isKeyValid = apiKey.length === 66 && apiKey.startsWith('0x') && /^0x[0-9a-fA-F]{64}$/.test(apiKey) - // Truncate address for display + useEffect(() => { + if (hasExistingWallet) { + setShowDeposit(true) + } + }, [hasExistingWallet]) + useEffect(() => { + if (configuredModel?.walletAddress || localWalletAddress || serverWalletAddress) { + return + } + + let cancelled = false + void api + .getCurrentBeginnerWallet() + .then((result) => { + setClaw402Status(result.claw402_status || 'unknown') + if (cancelled || !result.found || !result.address) { + return + } + setServerWalletAddress(result.address) + setServerWalletBalance(result.balance_usdc || null) + }) + .catch(() => { + // Ignore silently: this is a best-effort fallback for showing the current wallet. + }) + + return () => { + cancelled = true + } + }, [configuredModel?.walletAddress, localWalletAddress, serverWalletAddress]) // Debounced validation when apiKey changes useEffect(() => { @@ -347,6 +437,23 @@ function Claw402ConfigForm({ setTesting(true) setTestResult(null) try { + if (!apiKey && hasExistingWallet) { + const result = await api.getCurrentBeginnerWallet() + setClaw402Status(result.claw402_status || 'unknown') + if (result.found && result.address) { + setWalletAddress(result.address) + setUsdcBalance(result.balance_usdc || '0.00') + setShowDeposit(true) + } + setTestResult({ + status: result.claw402_status === 'ok' ? 'ok' : 'error', + message: result.claw402_status === 'ok' + ? t('modelConfig.claw402Connected', language) + : t('modelConfig.claw402Unreachable', language), + }) + return + } + const res = await fetch('/api/wallet/validate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -374,7 +481,7 @@ function Claw402ConfigForm({ } } - const balanceNum = usdcBalance ? parseFloat(usdcBalance) : 0 + const balanceNum = resolvedUsdcBalance ? parseFloat(resolvedUsdcBalance) : 0 return (
@@ -396,6 +503,25 @@ function Claw402ConfigForm({ ))}
+
+ + {claw402Status ? ( +
+ {claw402Status === 'ok' + ? t('modelConfig.claw402Connected', language) + : t('modelConfig.claw402Unreachable', language)} +
+ ) : null} +
{/* Step 1: Select AI Model */} @@ -467,6 +593,33 @@ function Claw402ConfigForm({ + {hasExistingWallet && ( +
+
+ {language === 'zh' ? '已自动提取当前钱包' : 'Current wallet loaded automatically'} +
+
+ {language === 'zh' + ? '你现在可以直接查看当前钱包地址、余额和充值二维码。只有在想更换钱包时,才需要重新输入新的私钥。' + : 'You can view the current wallet address, balance, and deposit QR code right away. Only enter a new private key if you want to replace this wallet.'} +
+ {!configuredModel?.walletAddress && localWalletAddress ? ( +
+ {language === 'zh' + ? '当前地址来自本地已保存的新手钱包。' + : 'This address comes from the locally saved beginner wallet.'} +
+ ) : null} + {!configuredModel?.walletAddress && !localWalletAddress && serverWalletAddress ? ( +
+ {language === 'zh' + ? '当前地址来自后端保存的钱包配置。' + : 'This address comes from the wallet saved on the server.'} +
+ ) : null} +
+ )} +
{t('modelConfig.walletPrivateKey', language)} @@ -476,72 +629,30 @@ function Claw402ConfigForm({ type="password" value={apiKey} onChange={(e) => onApiKeyChange(e.target.value)} - placeholder="0x..." + placeholder={ + hasExistingWallet + ? language === 'zh' + ? '如需切换钱包,请手动输入新的私钥' + : 'Enter a new private key only if you want to switch wallets' + : '0x...' + } className="flex-1 px-4 py-3 rounded-xl font-mono text-sm" style={{ background: '#0B0E11', border: keyError ? '1px solid #EF4444' : walletAddress ? '1px solid #00E096' : '1px solid #2B3139', color: '#EAECEF', }} - required + required={!hasExistingWallet} /> - {!apiKey && ( - - )}
- {/* New wallet backup warning */} - {showNewWalletBackup && newWalletKey && ( -
-
- 🚨 {language === 'zh' ? '重要:请立即备份私钥!' : 'Important: Backup your private key NOW!'} -
-
- {language === 'zh' - ? '这是你的钱包私钥,丢失后无法恢复,钱包里的资产将永久丢失。请复制并安全保存。' - : 'This is your wallet private key. If lost, it cannot be recovered and all assets will be permanently lost. Copy and save it securely.'} -
-
- - {newWalletKey} - - -
-
-
✅ {language === 'zh' ? '建议保存到密码管理器(1Password / Bitwarden)' : 'Save to a password manager (1Password / Bitwarden)'}
-
✅ {language === 'zh' ? '或抄在纸上放安全的地方' : 'Or write it down and store it safely'}
-
❌ {language === 'zh' ? '不要截图发给别人' : 'Do NOT screenshot or share with anyone'}
-
+ {hasExistingWallet && !apiKey ? ( +
+ {language === 'zh' + ? '后续这里只使用你第一次创建并保存的钱包;如果你要换钱包,请手动填写新的私钥。' + : 'This screen keeps using the wallet created and saved the first time. Enter a new private key manually only if you want to switch wallets.'}
- )} + ) : null}
🔒 @@ -552,7 +663,7 @@ function Claw402ConfigForm({
{/* Wallet Validation Results */} - {apiKey && ( + {(apiKey || hasExistingWallet) && (
{/* Validating spinner */} {validating && ( @@ -571,7 +682,7 @@ function Claw402ConfigForm({ )} {/* Success: address + balance + status */} - {walletAddress && !validating && !keyError && ( + {resolvedWalletAddress && !validating && !keyError && ( <>
@@ -581,7 +692,7 @@ function Claw402ConfigForm({
- {walletAddress} + {resolvedWalletAddress}
⚠️ {language === 'zh' ? '请确认这是你的钱包地址(可在 MetaMask 中核对)' : 'Please confirm this is your wallet address (verify in MetaMask)'}
- {usdcBalance !== null && ( + {resolvedUsdcBalance !== null && (
💰 0 ? '#00E096' : '#F59E0B' }}> - {t('modelConfig.usdcBalance', language)}: ${usdcBalance} + {t('modelConfig.usdcBalance', language)}: ${resolvedUsdcBalance}
)} + {!apiKey && hasExistingWallet && ( +
+ {language === 'zh' + ? '当前正在使用这个钱包充值。若要切换钱包,再输入新的私钥并保存即可。' + : 'This wallet is currently used for funding. Enter a new private key only if you want to switch wallets.'} +
+ )} {claw402Status && (
{claw402Status === 'ok' ? '🟢' : '🔴'} @@ -662,11 +780,11 @@ function Claw402ConfigForm({ )} {/* Test Connection button */} - {isKeyValid && !validating && ( + {(isKeyValid || hasExistingWallet) && !validating && ( + +
+
+
+
+ {isZh ? '当前余额' : 'Current Balance'} +
+
+ {data.balance_usdc} USDC +
+
+ {isZh ? 'Base 链钱包余额' : 'Base wallet balance'} +
+
+ +
+
+
+ +
+
+
+
+ {isZh ? '钱包私钥' : 'Wallet Private Key'} +
+
+ {isZh ? '请先备份,再进入下一步。' : 'Back this up before you continue.'} +
+
+ +
+
+ {showPrivateKey ? data.private_key : '0x' + '•'.repeat(64)} +
+ +
+ +
+
+ {data.env_saved + ? isZh + ? `已同步保存到环境文件:${data.env_path || '.env'}` + : `Also saved to env: ${data.env_path || '.env'}` + : isZh + ? '当前运行环境没有成功写回 .env,但产品已完成默认配置。' + : 'The app is configured, but this runtime could not write back to .env.'} +
+ {data.env_warning ?
{data.env_warning}
: null} +
+ + +
+ ) : null} + +
+ + + ) +} diff --git a/web/src/pages/SettingsPage.tsx b/web/src/pages/SettingsPage.tsx index d278e1ca..78c50808 100644 --- a/web/src/pages/SettingsPage.tsx +++ b/web/src/pages/SettingsPage.tsx @@ -4,6 +4,12 @@ import { User, Cpu, Building2, MessageCircle, Eye, EyeOff, ChevronRight, Plus, P import { useAuth } from '../contexts/AuthContext' import { useLanguage } from '../contexts/LanguageContext' import { api } from '../lib/api' +import { + getPostAuthPath, + getUserMode, + setUserMode, + type UserMode, +} from '../lib/onboarding' import { ExchangeConfigModal } from '../components/trader/ExchangeConfigModal' import { TelegramConfigModal } from '../components/trader/TelegramConfigModal' import { ModelConfigModal } from '../components/trader/ModelConfigModal' @@ -15,6 +21,7 @@ export function SettingsPage() { const { user } = useAuth() const { language } = useLanguage() const [activeTab, setActiveTab] = useState('account') + const [userMode, setUserModeState] = useState(() => getUserMode() ?? 'advanced') // Account state const [newPassword, setNewPassword] = useState('') @@ -81,6 +88,26 @@ export function SettingsPage() { } } + const handleSwitchMode = (nextMode: UserMode) => { + if (nextMode === userMode) { + return + } + + setUserMode(nextMode) + setUserModeState(nextMode) + toast.success( + language === 'zh' + ? `已切换到${nextMode === 'beginner' ? '新手模式' : '老手模式'}` + : nextMode === 'beginner' + ? 'Switched to beginner mode' + : 'Switched to advanced mode' + ) + + const nextPath = getPostAuthPath(nextMode) + window.history.pushState({}, '', nextPath) + window.dispatchEvent(new PopStateEvent('popstate')) + } + const handleSaveModel = async ( modelId: string, apiKey: string, @@ -281,6 +308,66 @@ export function SettingsPage() {

{user?.email}

+
+
+
+

+ {language === 'zh' ? '使用模式' : 'Usage Mode'} +

+

+ {language === 'zh' + ? '新手模式会显示钱包引导和 4 步卡片;老手模式保持原来的专业界面。' + : 'Beginner mode shows wallet onboarding and quickstart cards. Advanced mode keeps the original pro workflow.'} +

+
+ + {userMode === 'beginner' + ? language === 'zh' ? '当前:新手模式' : 'Current: Beginner' + : language === 'zh' ? '当前:老手模式' : 'Current: Advanced'} + +
+ +
+ + + +
+
+

Change Password

diff --git a/web/src/pages/StrategyStudioPage.tsx b/web/src/pages/StrategyStudioPage.tsx index 8bf10a71..fbc90f36 100644 --- a/web/src/pages/StrategyStudioPage.tsx +++ b/web/src/pages/StrategyStudioPage.tsx @@ -29,7 +29,6 @@ import { Download, Upload, Globe, - X, } from 'lucide-react' import type { Strategy, StrategyConfig, AIModel } from '../types' import { confirmToast, notify } from '../lib/notify' @@ -39,10 +38,8 @@ import { RiskControlEditor } from '../components/strategy/RiskControlEditor' import { PromptSectionsEditor } from '../components/strategy/PromptSectionsEditor' import { PublishSettingsEditor } from '../components/strategy/PublishSettingsEditor' import { GridConfigEditor, defaultGridConfig } from '../components/strategy/GridConfigEditor' -import { TokenEstimateBar } from '../components/strategy/TokenEstimateBar' import { DeepVoidBackground } from '../components/common/DeepVoidBackground' import { t } from '../i18n/translations' -import { NofxSelect } from '../components/ui/select' const API_BASE = import.meta.env.VITE_API_BASE || '' @@ -55,7 +52,6 @@ export function StrategyStudioPage() { const [editingConfig, setEditingConfig] = useState(null) const [isLoading, setIsLoading] = useState(true) const [isSaving, setIsSaving] = useState(false) - const [tokenOverflow, setTokenOverflow] = useState(false) const [error, setError] = useState(null) const [hasChanges, setHasChanges] = useState(false) @@ -382,10 +378,6 @@ export function StrategyStudioPage() { // Save strategy const handleSaveStrategy = async () => { if (!token || !selectedStrategy || !editingConfig) return - if (tokenOverflow && currentStrategyType === 'ai_trading') { - notify.error(tr('tokenExceedWarning')) - return - } setIsSaving(true) try { // Always sync the config language with the current interface language @@ -413,17 +405,7 @@ export function StrategyStudioPage() { if (!response.ok) throw new Error('Failed to save strategy') setHasChanges(false) notify.success(tr('strategySaved')) - const savedId = selectedStrategy.id await fetchStrategies() - // Stay on the strategy we just saved instead of jumping to active - setStrategies(prev => { - const saved = prev.find(s => s.id === savedId) - if (saved) { - setSelectedStrategy(saved) - setEditingConfig(saved.config) - } - return prev - }) } catch (err) { setError(err instanceof Error ? err.message : 'Unknown error') } finally { @@ -659,7 +641,7 @@ export function StrategyStudioPage() {
-

{tr('title')}

+

{tr('strategyStudio')}

{tr('subtitle')}

@@ -774,24 +756,34 @@ export function StrategyStudioPage() { {selectedStrategy && editingConfig ? (
{/* Strategy Name & Actions */} -
-
-
- { - setSelectedStrategy({ ...selectedStrategy, name: e.target.value }) - setHasChanges(true) - }} - disabled={selectedStrategy.is_default} - className="text-lg font-bold bg-transparent border-none outline-none flex-1 min-w-0 text-nofx-text placeholder-nofx-text-muted" - /> - {hasChanges && ( - ● {tr('unsaved')} - )} -
-
+
+
+ { + setSelectedStrategy({ ...selectedStrategy, name: e.target.value }) + setHasChanges(true) + }} + disabled={selectedStrategy.is_default} + className="text-lg font-bold bg-transparent border-none outline-none w-full text-nofx-text placeholder-nofx-text-muted" + /> + { + setSelectedStrategy({ ...selectedStrategy, description: e.target.value }) + setHasChanges(true) + }} + disabled={selectedStrategy.is_default} + placeholder={tr('addDescription')} + className="text-xs bg-transparent border-none outline-none w-full text-nofx-text-muted placeholder-nofx-text-muted/50 mt-1" + /> + {hasChanges && ( + ● {tr('unsaved')} + )} +
+
{!selectedStrategy.is_active && ( )} - {!selectedStrategy.is_default && hasChanges && ( - - )} {!selectedStrategy.is_default && ( )}
-
- { - setSelectedStrategy({ ...selectedStrategy, description: e.target.value }) - setHasChanges(true) - }} - disabled={selectedStrategy.is_default} - placeholder={tr('addDescription')} - className="text-xs bg-transparent border-none outline-none w-full text-nofx-text-muted placeholder-nofx-text-muted/50 mt-1" - />
- {/* Token Estimate Bar */} - {currentStrategyType === 'ai_trading' && ( -
- -
- )} - {/* Strategy Type Selector */} {editingConfig && (
@@ -857,12 +818,9 @@ export function StrategyStudioPage() {
{aiModels.length > 0 ? ( - setSelectedModelId(val)} - options={aiModels.map((model) => ({ - value: model.id, - label: `${model.name} (${model.provider})`, - }))} + onChange={(e) => setSelectedModelId(e.target.value)} className="w-full px-3 py-2 rounded-lg text-sm bg-nofx-bg border border-nofx-gold/20 text-nofx-text" - /> + > + {aiModels.map((model) => ( + + ))} + ) : (
{tr('noModel')} @@ -1067,16 +1025,15 @@ export function StrategyStudioPage() { )}
- setSelectedVariant(val)} - options={[ - { value: 'balanced', label: tr('balanced') }, - { value: 'aggressive', label: tr('aggressive') }, - { value: 'conservative', label: tr('conservative') }, - ]} + onChange={(e) => setSelectedVariant(e.target.value)} className="px-2 py-1.5 rounded text-xs bg-nofx-bg border border-nofx-gold/20 text-nofx-text" - /> + > + + + +