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
+2
View File
@@ -27,6 +27,8 @@ Thumbs.db
*.tmp
*.bak
*.backup
.cache/
.gh-config/
# 环境变量
.env
+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",
+1
View File
@@ -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:
+16 -1
View File
@@ -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<string>('--:--:--')
const [decisionsLimit, setDecisionsLimit] = useState<number>(5)
const hasPersistedAuth =
!!localStorage.getItem('auth_token') && !!localStorage.getItem('auth_user')
// 监听URL变化,同步页面状态
useEffect(() => {
@@ -347,6 +351,17 @@ function App() {
}
return <SetupPage />
}
if (route === '/welcome') {
if ((!user || !token) && !hasPersistedAuth) {
window.location.href = '/login'
return null
}
if (getUserMode() !== 'beginner') {
window.location.href = '/traders'
return null
}
return <BeginnerOnboardingPage />
}
if (route === '/faq') {
return (
<div
@@ -376,7 +391,7 @@ function App() {
return <ResetPasswordPage />
}
if (route === '/settings') {
if (!user || !token) {
if ((!user || !token) && !hasPersistedAuth) {
window.location.href = '/login'
return null
}
+10 -1
View File
@@ -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<string | number | null>(null)
const [mode, setMode] = useState<UserMode>('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() {
</div>
</div>
<OnboardingModeSelector
language={language}
mode={mode}
onChange={setMode}
/>
{/* Error */}
{error && (
<p className="text-xs text-red-400 bg-red-500/10 border border-red-500/20 rounded-lg px-3 py-2">
@@ -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 (
<div className="space-y-2">
<div className="text-xs font-medium text-zinc-400">
{isZh ? '使用模式' : 'Experience'}
</div>
<div className="grid grid-cols-1 gap-2">
{options.map((option) => {
const selected = option.id === mode
return (
<button
key={option.id}
type="button"
onClick={() => onChange(option.id)}
className={`w-full rounded-xl border px-4 py-3 text-left transition-all ${
selected
? 'border-nofx-gold/60 bg-nofx-gold/10 shadow-[0_0_0_1px_rgba(240,185,11,0.15)]'
: 'border-zinc-800 bg-zinc-950/60 hover:border-zinc-700'
}`}
>
<div className="flex items-center gap-2 text-sm font-semibold text-white">
<span>{option.title}</span>
{option.badge ? (
<span className="rounded-full bg-nofx-gold px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-black">
{option.badge}
</span>
) : null}
</div>
<p className="mt-1 text-xs leading-5 text-zinc-400">
{option.description}
</p>
</button>
)
})}
</div>
</div>
)
}
+28
View File
@@ -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<UserMode>(() => getUserMode() ?? 'advanced')
const dropdownRef = useRef<HTMLDivElement>(null)
const userDropdownRef = useRef<HTMLDivElement>(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 className="w-3.5 h-3.5" />
Settings
</button>
<button
onClick={() => handleSwitchMode(userMode === 'beginner' ? 'advanced' : 'beginner')}
className="w-full flex items-center gap-2 px-3 py-2 text-sm transition-colors hover:bg-white/5 text-nofx-text-muted hover:text-white"
>
<Settings className="w-3.5 h-3.5" />
{userMode === 'beginner'
? language === 'zh' ? '切到老手模式' : 'Switch to Advanced'
: language === 'zh' ? '切到新手模式' : 'Switch to Beginner'}
</button>
{onLogout && (
<button
onClick={() => {
+12 -2
View File
@@ -3,14 +3,19 @@ import { Eye, EyeOff } from 'lucide-react'
import { useAuth } from '../../contexts/AuthContext'
import { DeepVoidBackground } from '../common/DeepVoidBackground'
import { invalidateSystemConfig } from '../../lib/config'
import { OnboardingModeSelector } from '../auth/OnboardingModeSelector'
import type { UserMode } from '../../lib/onboarding'
import { useLanguage } from '../../contexts/LanguageContext'
export function SetupPage() {
const { language } = useLanguage()
const { register } = useAuth()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [showPassword, setShowPassword] = useState(false)
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const [mode, setMode] = useState<UserMode>('beginner')
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
@@ -20,11 +25,10 @@ export function SetupPage() {
return
}
setLoading(true)
const result = await register(email, password)
const result = await register(email, password, undefined, mode)
setLoading(false)
if (result.success) {
invalidateSystemConfig()
window.location.href = '/traders'
} else {
setError(result.message || 'Setup failed, please try again')
}
@@ -87,6 +91,12 @@ export function SetupPage() {
</div>
</div>
<OnboardingModeSelector
language={language}
mode={mode}
onChange={setMode}
/>
{/* Error */}
{error && (
<p className="text-xs text-red-400 bg-red-500/10 border border-red-500/20 rounded-lg px-3 py-2">
+72 -1
View File
@@ -18,6 +18,7 @@ import { TelegramConfigModal } from './TelegramConfigModal'
import { ModelConfigModal } from './ModelConfigModal'
import { ConfigStatusGrid } from './ConfigStatusGrid'
import { TradersList } from './TradersList'
import { BeginnerGuideCards } from './BeginnerGuideCards'
import {
Bot,
Plus,
@@ -25,6 +26,12 @@ import {
} from 'lucide-react'
import { confirmToast } from '../../lib/notify'
import { toast } from 'sonner'
import {
getBeginnerWalletAddress,
getUserMode,
setBeginnerWalletAddress as persistBeginnerWalletAddress,
} from '../../lib/onboarding'
import type { Strategy } from '../../types'
interface AITradersPageProps {
onTraderSelect?: (traderId: string) => void
@@ -48,6 +55,14 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
const [visibleTraderAddresses, setVisibleTraderAddresses] = useState<Set<string>>(new Set())
const [visibleExchangeAddresses, setVisibleExchangeAddresses] = useState<Set<string>>(new Set())
const [copiedId, setCopiedId] = useState<string | null>(null)
const [quickSetupLoading, setQuickSetupLoading] = useState(false)
const [beginnerWalletAddress, setBeginnerWalletAddress] = useState<string | null>(() => getBeginnerWalletAddress())
const isBeginnerMode = getUserMode() === 'beginner'
const navigateInApp = (path: string) => {
navigate(path)
window.dispatchEvent(new PopStateEvent('popstate'))
}
// Toggle wallet address visibility for a trader
const toggleTraderAddressVisibility = (traderId: string) => {
@@ -91,6 +106,11 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
api.getTraders,
{ refreshInterval: 5000 }
)
const { data: strategies } = useSWR<Strategy[]>(
user && token ? 'strategies' : null,
api.getStrategies,
{ refreshInterval: 30000 }
)
useEffect(() => {
const loadConfigs = async () => {
@@ -115,6 +135,12 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
api.getSupportedModels(),
])
setAllModels(modelConfigs)
const clawWalletAddress =
modelConfigs.find((model) => model.provider === 'claw402')?.walletAddress || null
if (clawWalletAddress) {
setBeginnerWalletAddress(clawWalletAddress)
persistBeginnerWalletAddress(clawWalletAddress)
}
setAllExchanges(exchangeConfigs)
setSupportedModels(models)
} catch (error) {
@@ -616,6 +642,36 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
setShowExchangeModal(true)
}
const handleQuickSetupClaw402 = async () => {
if (quickSetupLoading) return
try {
setQuickSetupLoading(true)
const result = await api.prepareBeginnerOnboarding()
setBeginnerWalletAddress(result.address)
const refreshedModels = await api.getModelConfigs()
setAllModels(refreshedModels)
toast.success(
language === 'zh'
? 'Claw402 已默认配置为 DeepSeek'
: 'Claw402 is configured with DeepSeek by default'
)
} catch (error) {
console.error('Failed to quick setup claw402:', error)
toast.error(
language === 'zh'
? '一键配置 Claw402 失败'
: 'Failed to quick setup Claw402'
)
} finally {
setQuickSetupLoading(false)
}
}
const claw402Configured = configuredModels.some((model) => model.provider === 'claw402')
const hasStrategies = (strategies?.length || 0) > 0
const canCreateTrader = configuredModels.length > 0 && configuredExchanges.length > 0
return (
<DeepVoidBackground className="py-8" disableAnimation>
<div className="w-full px-4 md:px-8 space-y-8 animate-fade-in">
@@ -687,6 +743,21 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
</div>
</div>
{isBeginnerMode ? (
<BeginnerGuideCards
language={language}
claw402Ready={claw402Configured}
exchangeReady={configuredExchanges.length > 0}
strategyReady={hasStrategies}
canCreateTrader={canCreateTrader}
walletAddress={beginnerWalletAddress}
onQuickSetupClaw402={handleQuickSetupClaw402}
onOpenExchange={handleAddExchange}
onOpenStrategy={() => navigateInApp('/strategy')}
onCreateTrader={() => setShowCreateModal(true)}
/>
) : null}
{/* Configuration Status Grid */}
<ConfigStatusGrid
configuredModels={configuredModels}
@@ -715,7 +786,7 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
copiedId={copiedId}
language={language}
onTraderSelect={onTraderSelect}
onNavigate={(path) => navigate(path)}
onNavigate={navigateInApp}
onEditTrader={handleEditTrader}
onToggleTrader={handleToggleTrader}
onToggleCompetition={handleToggleCompetition}
@@ -0,0 +1,169 @@
import { Brain, Landmark, Rocket, Sparkles } from 'lucide-react'
interface BeginnerGuideCardsProps {
language: string
claw402Ready: boolean
exchangeReady: boolean
strategyReady: boolean
canCreateTrader: boolean
walletAddress?: string | null
onQuickSetupClaw402: () => void
onOpenExchange: () => void
onOpenStrategy: () => void
onCreateTrader: () => void
}
function truncateAddress(address: string) {
if (address.length <= 12) return address
return `${address.slice(0, 6)}...${address.slice(-4)}`
}
export function BeginnerGuideCards({
language,
claw402Ready,
exchangeReady,
strategyReady,
canCreateTrader,
walletAddress,
onQuickSetupClaw402,
onOpenExchange,
onOpenStrategy,
onCreateTrader,
}: BeginnerGuideCardsProps) {
const isZh = language === 'zh'
const cards = [
{
key: 'model',
icon: Brain,
title: isZh ? '1. 极速模型' : '1. Fast AI',
desc: isZh
? '默认就是 Claw402 + DeepSeek。第一次不用挑模型,先跑起来。'
: 'Start with Claw402 + DeepSeek. No model picking needed for the first run.',
meta: walletAddress
? isZh
? `钱包 ${truncateAddress(walletAddress)}`
: `Wallet ${truncateAddress(walletAddress)}`
: isZh
? 'Base 链 USDC 按次付费'
: 'Pay per call with Base USDC',
ready: claw402Ready,
actionLabel: claw402Ready
? isZh ? '已配置' : 'Configured'
: isZh ? '一键配置' : 'One-click setup',
onAction: onQuickSetupClaw402,
disabled: claw402Ready,
},
{
key: 'exchange',
icon: Landmark,
title: isZh ? '2. 连接交易所' : '2. Add Exchange',
desc: isZh
? '交易所接好以后,AI 才能真正下单。'
: 'Connect an exchange so the AI can actually place trades.',
meta: exchangeReady
? isZh ? '已准备好' : 'Ready'
: isZh ? 'Binance / OKX / Bybit / Hyperliquid' : 'Binance / OKX / Bybit / Hyperliquid',
ready: exchangeReady,
actionLabel: exchangeReady
? isZh ? '继续管理' : 'Manage'
: isZh ? '去配置' : 'Configure',
onAction: onOpenExchange,
disabled: false,
},
{
key: 'strategy',
icon: Sparkles,
title: isZh ? '3. 选择策略' : '3. Pick Strategy',
desc: isZh
? '先用默认策略也可以,后面再慢慢细调。'
: 'You can start with a default strategy and fine-tune later.',
meta: strategyReady
? isZh ? '已有策略可用' : 'Strategy ready'
: isZh ? '可选,但建议提前看一眼' : 'Optional, but worth a quick look',
ready: strategyReady,
actionLabel: isZh ? '打开策略页' : 'Open strategy',
onAction: onOpenStrategy,
disabled: false,
},
{
key: 'trader',
icon: Rocket,
title: isZh ? '4. 创建 Trader' : '4. Create Trader',
desc: isZh
? '最后一步,把模型和交易所绑在一起,就能开始运行。'
: 'Last step: bind your model and exchange, then start running.',
meta: canCreateTrader
? isZh ? '已经可以创建' : 'Ready to create'
: isZh ? '先完成前两步' : 'Finish the first two steps first',
ready: canCreateTrader,
actionLabel: isZh ? '立即创建' : 'Create now',
onAction: onCreateTrader,
disabled: !canCreateTrader,
},
]
return (
<section className="space-y-4 rounded-[28px] border border-white/10 bg-zinc-950/60 p-5 backdrop-blur-xl">
<div className="flex items-center justify-between gap-4">
<div>
<div className="text-xs font-semibold uppercase tracking-[0.3em] text-nofx-gold/80">
{isZh ? '新手引导' : 'Quickstart'}
</div>
<h2 className="mt-1 text-xl font-bold text-white">
{isZh ? '先按这 4 步走,最快上手' : 'Follow these 4 steps to get started fast'}
</h2>
</div>
<div className="rounded-full border border-white/10 bg-white/5 px-3 py-1 text-xs text-zinc-400">
{isZh ? '老手模式不会看到这块' : 'Hidden in advanced mode'}
</div>
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{cards.map((card) => {
const Icon = card.icon
return (
<div
key={card.key}
className="rounded-[22px] border border-white/8 bg-black/25 p-4"
>
<div className="flex items-center justify-between gap-3">
<div className="flex h-11 w-11 items-center justify-center rounded-2xl bg-white/6 text-nofx-gold">
<Icon className="h-5 w-5" />
</div>
<span
className={`rounded-full px-2.5 py-1 text-[10px] font-bold uppercase tracking-[0.22em] ${
card.ready
? 'bg-emerald-500/15 text-emerald-300'
: 'bg-zinc-800 text-zinc-400'
}`}
>
{card.ready ? (isZh ? '已就绪' : 'Ready') : (isZh ? '待完成' : 'Pending')}
</span>
</div>
<h3 className="mt-4 text-base font-semibold text-white">{card.title}</h3>
<p className="mt-2 min-h-[72px] text-sm leading-6 text-zinc-400">
{card.desc}
</p>
<div className="mt-3 text-xs text-zinc-500">{card.meta}</div>
<button
type="button"
onClick={card.onAction}
disabled={card.disabled}
className={`mt-5 w-full rounded-2xl px-4 py-3 text-sm font-semibold transition ${
card.disabled
? 'cursor-not-allowed bg-zinc-900 text-zinc-500'
: 'bg-nofx-gold text-black hover:bg-yellow-400'
}`}
>
{card.actionLabel}
</button>
</div>
)
})}
</div>
</section>
)
}
@@ -92,6 +92,20 @@ export function ConfigStatusGrid({
<div className="text-[10px] text-zinc-500 font-mono flex items-center gap-2">
{model.customModelName || AI_PROVIDER_CONFIG[model.provider]?.defaultModel || ''}
</div>
{model.provider === 'claw402' && (model.balanceUsdc || model.walletAddress) ? (
<div className="mt-1.5 flex flex-wrap items-center gap-2 text-[10px] font-mono">
{model.balanceUsdc ? (
<span className="rounded border border-emerald-500/20 bg-emerald-500/10 px-1.5 py-0.5 text-emerald-400">
{model.balanceUsdc} USDC
</span>
) : null}
{model.walletAddress ? (
<span className="rounded border border-sky-500/20 bg-sky-500/10 px-1.5 py-0.5 text-sky-400">
{truncateAddress(model.walletAddress)}
</span>
) : null}
</div>
) : null}
</div>
</div>
+202 -84
View File
@@ -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 (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4 overflow-y-auto backdrop-blur-sm">
@@ -143,6 +158,7 @@ export function ModelConfigModal({
<Claw402ConfigForm
apiKey={apiKey}
modelName={modelName}
configuredModel={configuredModel}
editingModelId={editingModelId}
onApiKeyChange={setApiKey}
onModelNameChange={setModelName}
@@ -189,6 +205,10 @@ function ModelSelectionStep({
onSelectModel: (modelId: string) => 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 (
<div className="space-y-4">
<div className="text-sm font-semibold" style={{ color: '#EAECEF' }}>
@@ -196,12 +216,11 @@ function ModelSelectionStep({
</div>
{/* Claw402 Featured Card */}
{availableModels.some(m => m.provider === 'claw402') && (
{claw402Model && (
<button
type="button"
onClick={() => {
const claw = availableModels.find(m => m.provider === 'claw402')
if (claw) onSelectModel(claw.id)
onSelectModel(claw402Model.id)
}}
className="w-full p-5 rounded-xl text-left transition-all hover:scale-[1.01]"
style={{ background: 'linear-gradient(135deg, rgba(37, 99, 235, 0.15) 0%, rgba(139, 92, 246, 0.15) 100%)', border: '1.5px solid rgba(37, 99, 235, 0.4)' }}
@@ -222,7 +241,7 @@ function ModelSelectionStep({
</div>
</div>
<div className="flex items-center gap-2">
{configuredIds.has(availableModels.find(m => m.provider === 'claw402')?.id || '') && (
{configuredIds.has(claw402Model.id) && (
<div className="w-2 h-2 rounded-full" style={{ background: '#00E096' }} />
)}
<div className="px-3 py-1.5 rounded-full text-xs font-bold" style={{ background: 'linear-gradient(135deg, #2563EB, #7C3AED)', color: '#fff' }}>
@@ -235,11 +254,41 @@ function ModelSelectionStep({
GPT · Claude · DeepSeek · Gemini · Grok · Qwen · Kimi
</span>
</div>
<div className="mt-4 ml-[52px] text-[11px]" style={{ color: '#A0AEC0' }}>
{t('modelConfig.claw402EntryDesc', language)}
</div>
</button>
)}
{otherProviders.length > 0 && (
<div className="rounded-xl border border-white/10 bg-black/20 overflow-hidden">
<button
type="button"
onClick={() => setShowOtherProviders((prev) => !prev)}
className="w-full flex items-center justify-between px-4 py-4 text-left transition-all hover:bg-white/5"
>
<div>
<div className="text-sm font-semibold" style={{ color: '#EAECEF' }}>
{t('modelConfig.otherApiEntry', language)}
</div>
<div className="mt-1 text-xs" style={{ color: '#848E9C' }}>
{t('modelConfig.otherApiEntryDesc', language)}
</div>
</div>
<div className="flex items-center gap-3">
<span className="rounded-full border border-white/10 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.2em]" style={{ color: '#A0AEC0' }}>
{otherProviders.length} API
</span>
<span className="text-sm" style={{ color: '#60A5FA' }}>
{showOtherProviders ? '' : '+'}
</span>
</div>
</button>
{showOtherProviders && (
<div className="border-t border-white/5 px-4 py-4">
<div className="grid grid-cols-3 sm:grid-cols-4 gap-3">
{availableModels.filter(m => m.provider !== 'claw402').map((model) => (
{otherProviders.map((model) => (
<ModelCard
key={model.id}
model={model}
@@ -249,16 +298,21 @@ function ModelSelectionStep({
/>
))}
</div>
<div className="text-xs text-center pt-2" style={{ color: '#848E9C' }}>
<div className="text-xs text-center pt-3" style={{ color: '#848E9C' }}>
{t('modelConfig.modelsConfigured', language)}
</div>
</div>
)}
</div>
)}
</div>
)
}
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<string | null>(null)
const [keyError, setKeyError] = useState('')
const [validating, setValidating] = useState(false)
const [claw402Status, setClaw402Status] = useState<string | null>(null)
const [testResult, setTestResult] = useState<{ status: string; message: string } | null>(null)
const [testing, setTesting] = useState(false)
const [serverWalletAddress, setServerWalletAddress] = useState('')
const [serverWalletBalance, setServerWalletBalance] = useState<string | null>(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 (
<form onSubmit={onSubmit} className="space-y-5">
@@ -396,6 +503,25 @@ function Claw402ConfigForm({
</span>
))}
</div>
<div className="mt-4 flex items-center justify-center gap-3 flex-wrap">
<button
type="button"
onClick={handleTestConnection}
disabled={testing || (!hasExistingWallet && !isKeyValid)}
className="inline-flex items-center gap-2 rounded-xl px-4 py-2 text-xs font-semibold transition-all hover:scale-[1.02] disabled:cursor-not-allowed disabled:opacity-50"
style={{ background: 'rgba(37, 99, 235, 0.15)', border: '1px solid rgba(37, 99, 235, 0.3)', color: '#60A5FA' }}
>
<span>🔗</span>
{testing ? t('modelConfig.testingConnection', language) : t('modelConfig.testConnection', language)}
</button>
{claw402Status ? (
<div className="text-xs" style={{ color: claw402Status === 'ok' ? '#00E096' : '#F59E0B' }}>
{claw402Status === 'ok'
? t('modelConfig.claw402Connected', language)
: t('modelConfig.claw402Unreachable', language)}
</div>
) : null}
</div>
</div>
{/* Step 1: Select AI Model */}
@@ -467,6 +593,33 @@ function Claw402ConfigForm({
</div>
</div>
{hasExistingWallet && (
<div className="p-3 rounded-xl" style={{ background: 'rgba(0, 224, 150, 0.05)', border: '1px solid rgba(0, 224, 150, 0.18)' }}>
<div className="text-xs font-semibold mb-1.5" style={{ color: '#00E096' }}>
{language === 'zh' ? '已自动提取当前钱包' : 'Current wallet loaded automatically'}
</div>
<div className="text-[11px] leading-5" style={{ color: '#A0AEC0' }}>
{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.'}
</div>
{!configuredModel?.walletAddress && localWalletAddress ? (
<div className="mt-2 text-[10px]" style={{ color: '#848E9C' }}>
{language === 'zh'
? '当前地址来自本地已保存的新手钱包。'
: 'This address comes from the locally saved beginner wallet.'}
</div>
) : null}
{!configuredModel?.walletAddress && !localWalletAddress && serverWalletAddress ? (
<div className="mt-2 text-[10px]" style={{ color: '#848E9C' }}>
{language === 'zh'
? '当前地址来自后端保存的钱包配置。'
: 'This address comes from the wallet saved on the server.'}
</div>
) : null}
</div>
)}
<div className="space-y-1.5">
<div className="text-xs font-medium" style={{ color: '#A0AEC0' }}>
{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 && (
<button
type="button"
onClick={async () => {
try {
const res = await fetch('/api/wallet/generate', { method: 'POST' })
const data = await res.json()
if (data.private_key) {
onApiKeyChange(data.private_key)
setShowNewWalletBackup(true)
setNewWalletKey(data.private_key)
}
} catch { /* ignore */ }
}}
className="shrink-0 px-3 py-3 rounded-xl text-xs font-semibold transition-all hover:scale-[1.02]"
style={{ background: 'linear-gradient(135deg, #2563EB, #7C3AED)', color: '#fff', border: 'none', cursor: 'pointer' }}
>
{language === 'zh' ? '🔑 创建钱包' : '🔑 Create Wallet'}
</button>
)}
</div>
{/* New wallet backup warning */}
{showNewWalletBackup && newWalletKey && (
<div className="p-3 rounded-xl" style={{ background: 'rgba(239, 68, 68, 0.08)', border: '1px solid rgba(239, 68, 68, 0.3)' }}>
<div className="text-xs font-bold mb-2" style={{ color: '#EF4444' }}>
🚨 {language === 'zh' ? '重要:请立即备份私钥!' : 'Important: Backup your private key NOW!'}
</div>
<div className="text-[11px] mb-2" style={{ color: '#F87171' }}>
{hasExistingWallet && !apiKey ? (
<div className="text-[11px] leading-5" style={{ color: '#848E9C' }}>
{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.'}
? '后续这里只使用你第一次创建并保存的钱包;如果你要换钱包,请手动填写新的私钥。'
: '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.'}
</div>
<div className="flex items-center gap-2 mb-2">
<code className="text-[10px] font-mono break-all select-all flex-1 p-2 rounded" style={{ background: '#0B0E11', color: '#F87171' }}>
{newWalletKey}
</code>
<button
type="button"
onClick={() => {
navigator.clipboard.writeText(newWalletKey)
setCopiedAddr(true)
setTimeout(() => setCopiedAddr(false), 2000)
}}
className="shrink-0 text-[10px] px-2 py-1 rounded"
style={{ background: 'rgba(239,68,68,0.15)', color: '#F87171', border: 'none', cursor: 'pointer' }}
>
{copiedAddr ? '✅ Copied' : '📋 Copy Key'}
</button>
</div>
<div className="text-[10px] space-y-1" style={{ color: '#848E9C' }}>
<div> {language === 'zh' ? '建议保存到密码管理器(1Password / Bitwarden' : 'Save to a password manager (1Password / Bitwarden)'}</div>
<div> {language === 'zh' ? '或抄在纸上放安全的地方' : 'Or write it down and store it safely'}</div>
<div> {language === 'zh' ? '不要截图发给别人' : 'Do NOT screenshot or share with anyone'}</div>
</div>
</div>
)}
) : null}
<div className="flex items-start gap-1.5 text-[11px]" style={{ color: '#848E9C' }}>
<span className="mt-px">🔒</span>
@@ -552,7 +663,7 @@ function Claw402ConfigForm({
</div>
{/* Wallet Validation Results */}
{apiKey && (
{(apiKey || hasExistingWallet) && (
<div className="space-y-2 pl-1">
{/* Validating spinner */}
{validating && (
@@ -571,7 +682,7 @@ function Claw402ConfigForm({
)}
{/* Success: address + balance + status */}
{walletAddress && !validating && !keyError && (
{resolvedWalletAddress && !validating && !keyError && (
<>
<div className="p-2.5 rounded-lg" style={{ background: 'rgba(96,165,250,0.06)', border: '1px solid rgba(96,165,250,0.15)' }}>
<div className="flex items-center justify-between mb-1">
@@ -581,7 +692,7 @@ function Claw402ConfigForm({
<button
type="button"
onClick={() => {
navigator.clipboard.writeText(walletAddress)
navigator.clipboard.writeText(resolvedWalletAddress)
setCopiedAddr(true)
setTimeout(() => setCopiedAddr(false), 2000)
}}
@@ -591,16 +702,16 @@ function Claw402ConfigForm({
{copiedAddr ? '✅' : '📋'}
</button>
</div>
<code className="text-[11px] font-mono block select-all" style={{ color: '#60A5FA' }}>{walletAddress}</code>
<code className="text-[11px] font-mono block select-all" style={{ color: '#60A5FA' }}>{resolvedWalletAddress}</code>
<div className="text-[10px] mt-1.5" style={{ color: '#F59E0B' }}>
{language === 'zh' ? '请确认这是你的钱包地址(可在 MetaMask 中核对)' : 'Please confirm this is your wallet address (verify in MetaMask)'}
</div>
</div>
{usdcBalance !== null && (
{resolvedUsdcBalance !== null && (
<div className="flex items-center gap-2 text-xs">
<span>💰</span>
<span style={{ color: balanceNum > 0 ? '#00E096' : '#F59E0B' }}>
{t('modelConfig.usdcBalance', language)}: ${usdcBalance}
{t('modelConfig.usdcBalance', language)}: ${resolvedUsdcBalance}
</span>
<button
type="button"
@@ -621,17 +732,17 @@ function Claw402ConfigForm({
</div>
<div className="flex gap-3 items-start mb-3">
<div className="shrink-0 p-1.5 rounded-lg" style={{ background: '#fff' }}>
<QRCodeSVG value={walletAddress} size={80} level="M" />
<QRCodeSVG value={resolvedWalletAddress} size={80} level="M" />
</div>
<div className="flex-1 min-w-0">
<div className="text-[11px] mb-1" style={{ color: '#A0AEC0' }}>
{language === 'zh' ? '扫码或复制地址转账' : 'Scan QR or copy address to transfer'}
</div>
<code className="text-[10px] font-mono break-all select-all block mb-1.5" style={{ color: '#60A5FA' }}>{walletAddress}</code>
<code className="text-[10px] font-mono break-all select-all block mb-1.5" style={{ color: '#60A5FA' }}>{resolvedWalletAddress}</code>
<button
type="button"
onClick={() => {
navigator.clipboard.writeText(walletAddress)
navigator.clipboard.writeText(resolvedWalletAddress)
setCopiedAddr(true)
setTimeout(() => setCopiedAddr(false), 2000)
}}
@@ -650,6 +761,13 @@ function Claw402ConfigForm({
</div>
</div>
)}
{!apiKey && hasExistingWallet && (
<div className="text-[11px]" style={{ color: '#848E9C' }}>
{language === 'zh'
? '当前正在使用这个钱包充值。若要切换钱包,再输入新的私钥并保存即可。'
: 'This wallet is currently used for funding. Enter a new private key only if you want to switch wallets.'}
</div>
)}
{claw402Status && (
<div className="flex items-center gap-2 text-xs" style={{ color: claw402Status === 'ok' ? '#00E096' : '#EF4444' }}>
<span>{claw402Status === 'ok' ? '🟢' : '🔴'}</span>
@@ -662,11 +780,11 @@ function Claw402ConfigForm({
)}
{/* Test Connection button */}
{isKeyValid && !validating && (
{(isKeyValid || hasExistingWallet) && !validating && (
<button
type="button"
onClick={handleTestConnection}
disabled={testing}
disabled={testing || (!hasExistingWallet && !isKeyValid)}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all hover:scale-[1.02] disabled:opacity-50"
style={{ background: 'rgba(37, 99, 235, 0.15)', border: '1px solid rgba(37, 99, 235, 0.3)', color: '#60A5FA' }}
>
+44 -44
View File
@@ -1,6 +1,8 @@
import React, { createContext, useContext, useState, useEffect } from 'react'
import { flushSync } from 'react-dom'
import { getSystemConfig } from '../lib/config'
import { reset401Flag, httpClient } from '../lib/httpClient'
import { getPostAuthPath, setUserMode, type UserMode } from '../lib/onboarding'
interface User {
id: string
@@ -12,7 +14,8 @@ interface AuthContextType {
token: string | null
login: (
email: string,
password: string
password: string,
mode?: UserMode
) => Promise<{
success: boolean
message?: string
@@ -24,7 +27,8 @@ interface AuthContextType {
register: (
email: string,
password: string,
betaCode?: string
betaCode?: string,
mode?: UserMode
) => Promise<{ success: boolean; message?: string }>
resetPassword: (
email: string,
@@ -89,7 +93,36 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
}
}, [])
const login = async (email: string, password: string) => {
const handlePostAuthSuccess = (
authToken: string,
userInfo: User,
mode?: UserMode
) => {
reset401Flag()
if (mode) {
setUserMode(mode)
}
localStorage.setItem('auth_token', authToken)
localStorage.setItem('auth_user', JSON.stringify(userInfo))
localStorage.setItem('user_id', userInfo.id)
flushSync(() => {
setToken(authToken)
setUser(userInfo)
})
const returnUrl = sessionStorage.getItem('returnUrl')
const nextPath = returnUrl || getPostAuthPath(mode)
if (returnUrl) {
sessionStorage.removeItem('returnUrl')
}
window.history.pushState({}, '', nextPath)
window.dispatchEvent(new PopStateEvent('popstate'))
}
const login = async (email: string, password: string, mode?: UserMode) => {
try {
const response = await fetch('/api/login', {
method: 'POST',
@@ -103,26 +136,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
if (response.ok) {
if (data.token) {
// Reset 401 flag on successful login
reset401Flag()
const userInfo = { id: data.user_id, email: data.email }
setToken(data.token)
setUser(userInfo)
localStorage.setItem('auth_token', data.token)
localStorage.setItem('auth_user', JSON.stringify(userInfo))
// Check and redirect to returnUrl if exists
const returnUrl = sessionStorage.getItem('returnUrl')
if (returnUrl) {
sessionStorage.removeItem('returnUrl')
window.history.pushState({}, '', returnUrl)
window.dispatchEvent(new PopStateEvent('popstate'))
} else {
// Redirect to config page
window.history.pushState({}, '', '/traders')
window.dispatchEvent(new PopStateEvent('popstate'))
}
handlePostAuthSuccess(data.token, userInfo, mode)
return { success: true, message: data.message }
}
@@ -156,10 +171,12 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
id: data.user_id || 'admin',
email: data.email || 'admin@localhost',
}
setToken(data.token)
setUser(userInfo)
localStorage.setItem('auth_token', data.token)
localStorage.setItem('auth_user', JSON.stringify(userInfo))
flushSync(() => {
setToken(data.token)
setUser(userInfo)
})
// Check and redirect to returnUrl if exists
const returnUrl = sessionStorage.getItem('returnUrl')
@@ -184,7 +201,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const register = async (
email: string,
password: string,
betaCode?: string
betaCode?: string,
mode?: UserMode
) => {
const requestBody: {
email: string
@@ -204,26 +222,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
}>('/api/register', requestBody)
if (result.success && result.data) {
// Reset 401 flag on successful login
reset401Flag()
const userInfo = { id: result.data.user_id, email: result.data.email }
setToken(result.data.token)
setUser(userInfo)
localStorage.setItem('auth_token', result.data.token)
localStorage.setItem('auth_user', JSON.stringify(userInfo))
// Check and redirect to returnUrl if exists
const returnUrl = sessionStorage.getItem('returnUrl')
if (returnUrl) {
sessionStorage.removeItem('returnUrl')
window.history.pushState({}, '', returnUrl)
window.dispatchEvent(new PopStateEvent('popstate'))
} else {
// Redirect to config page
window.history.pushState({}, '', '/traders')
window.dispatchEvent(new PopStateEvent('popstate'))
}
handlePostAuthSuccess(result.data.token, userInfo, mode)
return {
success: true,
+15
View File
@@ -1210,8 +1210,13 @@ export const translations = {
// ModelConfigModal
modelConfig: {
selectModel: 'Select Model',
configure: 'Configure',
configureApi: 'Configure API',
configureWallet: 'Configure Wallet',
chooseProvider: 'Choose Your AI Provider',
claw402EntryDesc: 'Recommended default path. Use Base USDC pay-per-call instead of managing API keys.',
otherApiEntry: 'Other API Providers',
otherApiEntryDesc: 'Use your own API key for OpenAI, Claude, Gemini, DeepSeek, and more.',
payPerCall: 'Pay-per-call USDC · All AI Models · No API Key',
recommended: 'Best',
allModelsClaw: 'Pay-per-call with USDC — supports all major AI models',
@@ -2503,8 +2508,13 @@ export const translations = {
modelConfig: {
selectModel: '选择模型',
configure: '配置',
configureApi: '配置 API',
configureWallet: '配置钱包',
chooseProvider: '选择 AI 模型提供商',
claw402EntryDesc: '默认推荐走这条路。直接用 Base USDC 按次付费,不需要自己管理 API Key。',
otherApiEntry: '其他 API 模型',
otherApiEntryDesc: '如果你已经有自己的 OpenAI、Claude、Gemini、DeepSeek 等 API Key,再从这里进入。',
payPerCall: 'USDC 按次付费 · 支持全部 AI 模型 · 无需 API Key',
recommended: '推荐',
allModelsClaw: '用 USDC 按次付费,支持所有主流 AI 模型',
@@ -3601,8 +3611,13 @@ export const translations = {
modelConfig: {
selectModel: 'Pilih Model',
configure: 'Konfigurasi',
configureApi: 'Konfigurasi API',
configureWallet: 'Konfigurasi Wallet',
chooseProvider: 'Pilih Penyedia AI Anda',
claw402EntryDesc: 'Jalur default yang direkomendasikan. Gunakan Base USDC bayar per panggilan tanpa mengelola API key.',
otherApiEntry: 'Penyedia API Lain',
otherApiEntryDesc: 'Gunakan API key Anda sendiri untuk OpenAI, Claude, Gemini, DeepSeek, dan lainnya.',
payPerCall: 'Bayar per panggilan USDC · Semua Model AI · Tanpa API Key',
recommended: 'Terbaik',
allModelsClaw: 'Bayar per panggilan dengan USDC — mendukung semua model AI utama',
+22
View File
@@ -4,6 +4,8 @@ import type {
UpdateModelConfigRequest,
UpdateExchangeConfigRequest,
CreateExchangeRequest,
BeginnerOnboardingResponse,
CurrentBeginnerWalletResponse,
} from '../../types'
import { API_BASE, httpClient, CryptoService } from './helpers'
@@ -183,4 +185,24 @@ export const configApi = {
if (!result.success) throw new Error('Failed to fetch server IP')
return result.data!
},
async prepareBeginnerOnboarding(): Promise<BeginnerOnboardingResponse> {
const result = await httpClient.post<BeginnerOnboardingResponse>(
`${API_BASE}/onboarding/beginner`
)
if (!result.success || !result.data) {
throw new Error(result.message || 'Failed to prepare beginner onboarding')
}
return result.data
},
async getCurrentBeginnerWallet(): Promise<CurrentBeginnerWalletResponse> {
const result = await httpClient.get<CurrentBeginnerWalletResponse>(
`${API_BASE}/onboarding/beginner/current`
)
if (!result.success || !result.data) {
throw new Error(result.message || 'Failed to fetch current beginner wallet')
}
return result.data
},
}
+28
View File
@@ -0,0 +1,28 @@
export type UserMode = 'beginner' | 'advanced'
const USER_MODE_KEY = 'nofx_user_mode'
const BEGINNER_WALLET_ADDRESS_KEY = 'nofx_beginner_wallet_address'
export function getUserMode(): UserMode | null {
const value = localStorage.getItem(USER_MODE_KEY)
if (value === 'beginner' || value === 'advanced') {
return value
}
return null
}
export function setUserMode(mode: UserMode) {
localStorage.setItem(USER_MODE_KEY, mode)
}
export function getPostAuthPath(mode: UserMode | null | undefined): string {
return mode === 'beginner' ? '/welcome' : '/traders'
}
export function setBeginnerWalletAddress(address: string) {
localStorage.setItem(BEGINNER_WALLET_ADDRESS_KEY, address)
}
export function getBeginnerWalletAddress(): string | null {
return localStorage.getItem(BEGINNER_WALLET_ADDRESS_KEY)
}
+264
View File
@@ -0,0 +1,264 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { Copy, Eye, EyeOff, RefreshCw, Shield, Wallet, Sparkles } from 'lucide-react'
import { QRCodeSVG } from 'qrcode.react'
import { toast } from 'sonner'
import { DeepVoidBackground } from '../components/common/DeepVoidBackground'
import { useLanguage } from '../contexts/LanguageContext'
import { api } from '../lib/api'
import type { BeginnerOnboardingResponse } from '../types'
import { setBeginnerWalletAddress } from '../lib/onboarding'
export function BeginnerOnboardingPage() {
const { language } = useLanguage()
const [data, setData] = useState<BeginnerOnboardingResponse | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [showPrivateKey, setShowPrivateKey] = useState(false)
const [refreshingBalance, setRefreshingBalance] = useState(false)
const hasRequestedRef = useRef(false)
const isZh = language === 'zh'
const loadOnboarding = async (showLoading: boolean) => {
if (showLoading) {
setLoading(true)
} else {
setRefreshingBalance(true)
}
setError('')
try {
const result = await api.prepareBeginnerOnboarding()
setData(result)
setBeginnerWalletAddress(result.address)
} catch (err) {
setError(
err instanceof Error
? err.message
: isZh
? '新手钱包准备失败'
: 'Failed to prepare beginner wallet'
)
} finally {
if (showLoading) {
setLoading(false)
} else {
setRefreshingBalance(false)
}
}
}
useEffect(() => {
if (hasRequestedRef.current) {
return
}
hasRequestedRef.current = true
void loadOnboarding(true)
}, [])
const hints = useMemo(
() =>
isZh
? [
'这是你的专属 Base 钱包,只用于后续调用大模型。',
'请保存私钥。丢失后无法恢复。',
'只往这个地址充值 Base 链 USDC,不要充到别的链。',
]
: [
'This dedicated Base wallet is only used to pay for model calls.',
'Save the private key now. It cannot be recovered later.',
'Deposit USDC on Base only. Do not send funds from another chain.',
],
[isZh]
)
const copyText = async (value: string, label: string) => {
try {
await navigator.clipboard.writeText(value)
toast.success(isZh ? `${label}已复制` : `${label} copied`)
} catch {
toast.error(isZh ? '复制失败' : 'Copy failed')
}
}
const handleContinue = () => {
window.history.pushState({}, '', '/traders')
window.dispatchEvent(new PopStateEvent('popstate'))
}
return (
<DeepVoidBackground disableAnimation>
<div className="mx-auto flex min-h-screen max-w-5xl items-center px-4 py-12">
<div className="grid w-full gap-8 lg:grid-cols-[1.05fr_0.95fr]">
<section className="rounded-[28px] border border-white/10 bg-zinc-950/70 p-8 shadow-2xl backdrop-blur-xl">
<div className="mb-6 flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-nofx-gold/15 text-nofx-gold">
<Shield className="h-6 w-6" />
</div>
<div>
<div className="text-xs font-semibold uppercase tracking-[0.28em] text-nofx-gold/80">
{isZh ? '新手保护' : 'Beginner Guard'}
</div>
<h1 className="mt-1 text-3xl font-bold text-white">
{isZh ? '钱包已经帮你准备好了' : 'Your wallet is ready'}
</h1>
</div>
</div>
<p className="max-w-xl text-sm leading-7 text-zinc-300">
{isZh
? '我们已经为你生成了一个专属钱包,并默认接入 Claw402 + DeepSeek。你现在只需要保存私钥,然后往这个地址充值 Base 链 USDC,后面调用大模型时会自动从这里扣费。'
: 'We generated a dedicated wallet for you and preconfigured Claw402 + DeepSeek. Save the private key, then deposit Base USDC to this address so future model calls can be paid automatically.'}
</p>
<div className="mt-6 grid gap-3">
{hints.map((hint) => (
<div
key={hint}
className="flex items-start gap-3 rounded-2xl border border-white/8 bg-white/5 px-4 py-3"
>
<Sparkles className="mt-0.5 h-4 w-4 shrink-0 text-nofx-gold" />
<div className="text-sm leading-6 text-zinc-300">{hint}</div>
</div>
))}
</div>
<div className="mt-8 rounded-[24px] border border-sky-500/20 bg-sky-500/5 p-5">
<div className="flex items-center gap-2 text-sm font-semibold text-sky-300">
<Wallet className="h-4 w-4" />
<span>{isZh ? '为什么要充值?' : 'Why fund this wallet?'}</span>
</div>
<p className="mt-2 text-sm leading-6 text-zinc-300">
{isZh
? '这里只负责大模型调用费用,不会自动替你充值交易所。先充少量 USDC 就够了,通常 $5-$10 可以用很久。'
: 'This wallet only covers LLM usage costs. It does not fund your exchange automatically. A small amount of USDC is enough to get started, usually $5-$10.'}
</p>
</div>
{error ? (
<div className="mt-6 rounded-2xl border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-300">
{error}
</div>
) : null}
</section>
<section className="rounded-[28px] border border-white/10 bg-black/60 p-8 shadow-2xl backdrop-blur-xl">
{loading ? (
<div className="flex min-h-[420px] items-center justify-center text-sm text-zinc-400">
{isZh ? '正在准备你的 Base 钱包...' : 'Preparing your Base wallet...'}
</div>
) : data ? (
<div className="space-y-6">
<div className="rounded-[24px] border border-white/10 bg-white/5 p-5">
<div className="text-xs uppercase tracking-[0.28em] text-zinc-500">
{isZh ? '默认模型' : 'Default Model'}
</div>
<div className="mt-2 text-2xl font-bold text-white">Claw402 + DeepSeek</div>
<div className="mt-2 text-sm text-zinc-400">
{isZh ? '按次付费,无需 API Key' : 'Pay per call, no API key needed'}
</div>
</div>
<div className="rounded-[24px] border border-white/10 bg-white p-5 text-center">
<div className="inline-flex rounded-2xl bg-white p-3">
<QRCodeSVG value={data.address} size={180} level="M" />
</div>
<div className="mt-4 text-sm font-semibold text-zinc-900">
{isZh ? '充值地址(Base 链 USDC' : 'Deposit Address (Base USDC)'}
</div>
<div className="mt-2 break-all rounded-2xl bg-zinc-100 px-3 py-3 font-mono text-xs text-zinc-700">
{data.address}
</div>
<button
type="button"
onClick={() => copyText(data.address, isZh ? '地址' : 'Address')}
className="mt-3 inline-flex items-center gap-2 rounded-xl bg-zinc-900 px-4 py-2 text-sm font-semibold text-white transition hover:bg-zinc-800"
>
<Copy className="h-4 w-4" />
{isZh ? '复制地址' : 'Copy address'}
</button>
<div className="mt-4 rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-left">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-[11px] font-semibold uppercase tracking-[0.22em] text-emerald-700">
{isZh ? '当前余额' : 'Current Balance'}
</div>
<div className="mt-1 text-2xl font-bold text-emerald-900">
{data.balance_usdc} USDC
</div>
<div className="mt-1 text-xs text-emerald-700/80">
{isZh ? 'Base 链钱包余额' : 'Base wallet balance'}
</div>
</div>
<button
type="button"
onClick={() => void loadOnboarding(false)}
disabled={refreshingBalance}
className="inline-flex items-center gap-2 rounded-xl border border-emerald-300 bg-white px-3 py-2 text-xs font-semibold text-emerald-800 transition hover:bg-emerald-100 disabled:cursor-not-allowed disabled:opacity-60"
>
<RefreshCw className={`h-3.5 w-3.5 ${refreshingBalance ? 'animate-spin' : ''}`} />
{isZh ? '刷新余额' : 'Refresh'}
</button>
</div>
</div>
</div>
<div className="rounded-[24px] border border-amber-500/20 bg-amber-500/8 p-5">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-sm font-semibold text-amber-200">
{isZh ? '钱包私钥' : 'Wallet Private Key'}
</div>
<div className="mt-1 text-xs leading-5 text-amber-100/75">
{isZh ? '请先备份,再进入下一步。' : 'Back this up before you continue.'}
</div>
</div>
<button
type="button"
onClick={() => setShowPrivateKey((prev) => !prev)}
className="rounded-xl border border-amber-400/20 px-3 py-2 text-amber-200 transition hover:bg-amber-400/10"
>
{showPrivateKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
<div className="mt-4 break-all rounded-2xl bg-black/25 px-3 py-3 font-mono text-xs text-amber-50">
{showPrivateKey ? data.private_key : '0x' + '•'.repeat(64)}
</div>
<button
type="button"
onClick={() => copyText(data.private_key, isZh ? '私钥' : 'Private key')}
className="mt-3 inline-flex items-center gap-2 rounded-xl border border-amber-300/20 px-4 py-2 text-sm font-semibold text-amber-100 transition hover:bg-amber-400/10"
>
<Copy className="h-4 w-4" />
{isZh ? '复制私钥' : 'Copy private key'}
</button>
</div>
<div className="rounded-[24px] border border-white/10 bg-white/5 p-4 text-xs leading-6 text-zinc-400">
<div>
{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.'}
</div>
{data.env_warning ? <div className="mt-2 text-amber-300">{data.env_warning}</div> : null}
</div>
<button
type="button"
onClick={handleContinue}
className="w-full rounded-2xl bg-nofx-gold px-5 py-4 text-sm font-bold text-black transition hover:bg-yellow-400"
>
{isZh ? '我已保存,进入下一步' : 'I saved it, continue'}
</button>
</div>
) : null}
</section>
</div>
</div>
</DeepVoidBackground>
)
}
+87
View File
@@ -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<Tab>('account')
const [userMode, setUserModeState] = useState<UserMode>(() => 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() {
<p className="text-sm text-white font-medium">{user?.email}</p>
</div>
<div className="border-t border-zinc-800 pt-6">
<div className="flex items-center justify-between gap-4">
<div>
<h3 className="text-sm font-semibold text-white">
{language === 'zh' ? '使用模式' : 'Usage Mode'}
</h3>
<p className="mt-1 text-xs text-zinc-500">
{language === 'zh'
? '新手模式会显示钱包引导和 4 步卡片;老手模式保持原来的专业界面。'
: 'Beginner mode shows wallet onboarding and quickstart cards. Advanced mode keeps the original pro workflow.'}
</p>
</div>
<span className="rounded-full border border-nofx-gold/20 bg-nofx-gold/10 px-3 py-1 text-xs font-semibold text-nofx-gold">
{userMode === 'beginner'
? language === 'zh' ? '当前:新手模式' : 'Current: Beginner'
: language === 'zh' ? '当前:老手模式' : 'Current: Advanced'}
</span>
</div>
<div className="mt-4 grid gap-3 sm:grid-cols-2">
<button
type="button"
onClick={() => handleSwitchMode('beginner')}
className={`rounded-2xl border px-4 py-4 text-left transition-all ${
userMode === 'beginner'
? 'border-nofx-gold bg-nofx-gold/10'
: 'border-zinc-800 bg-zinc-950/70 hover:border-zinc-700'
}`}
>
<div className="text-sm font-semibold text-white">
{language === 'zh' ? '新手模式' : 'Beginner Mode'}
</div>
<div className="mt-1 text-xs text-zinc-500">
{language === 'zh'
? '更简单,优先显示钱包、充值和快速上手引导。'
: 'Simpler flow with wallet, funding, and quickstart guidance first.'}
</div>
</button>
<button
type="button"
onClick={() => handleSwitchMode('advanced')}
className={`rounded-2xl border px-4 py-4 text-left transition-all ${
userMode === 'advanced'
? 'border-nofx-gold bg-nofx-gold/10'
: 'border-zinc-800 bg-zinc-950/70 hover:border-zinc-700'
}`}
>
<div className="text-sm font-semibold text-white">
{language === 'zh' ? '老手模式' : 'Advanced Mode'}
</div>
<div className="mt-1 text-xs text-zinc-500">
{language === 'zh'
? '保持原来的配置与交易流程,不展示新手引导。'
: 'Keeps the original configuration and trading workflow without beginner hints.'}
</div>
</button>
</div>
</div>
<div className="border-t border-zinc-800 pt-6">
<h3 className="text-sm font-semibold text-white mb-4">Change Password</h3>
<form onSubmit={handleChangePassword} className="space-y-4">
+62 -105
View File
@@ -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<StrategyConfig | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [isSaving, setIsSaving] = useState(false)
const [tokenOverflow, setTokenOverflow] = useState(false)
const [error, setError] = useState<string | null>(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() {
<Sparkles className="w-5 h-5 text-black" />
</div>
<div>
<h1 className="text-lg font-bold text-nofx-text">{tr('title')}</h1>
<h1 className="text-lg font-bold text-nofx-text">{tr('strategyStudio')}</h1>
<p className="text-xs text-nofx-text-muted">{tr('subtitle')}</p>
</div>
</div>
@@ -774,9 +756,8 @@ export function StrategyStudioPage() {
{selectedStrategy && editingConfig ? (
<div className="p-4">
{/* Strategy Name & Actions */}
<div className="mb-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 flex-1 min-w-0">
<div className="flex items-center justify-between mb-4">
<div className="flex-1 min-w-0">
<input
type="text"
value={selectedStrategy.name}
@@ -785,47 +766,8 @@ export function StrategyStudioPage() {
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"
className="text-lg font-bold bg-transparent border-none outline-none w-full text-nofx-text placeholder-nofx-text-muted"
/>
{hasChanges && (
<span className="text-xs text-nofx-gold whitespace-nowrap"> {tr('unsaved')}</span>
)}
</div>
<div className="flex items-center gap-2 flex-shrink-0 ml-2">
{!selectedStrategy.is_active && (
<button
onClick={() => handleActivateStrategy(selectedStrategy.id)}
className="flex items-center gap-1 px-3 py-1.5 rounded-lg text-xs transition-colors bg-nofx-success/10 border border-nofx-success/30 text-nofx-success hover:bg-nofx-success/20"
>
<Check className="w-3 h-3" />
{tr('activate')}
</button>
)}
{!selectedStrategy.is_default && hasChanges && (
<button
onClick={() => {
setEditingConfig(selectedStrategy.config)
setHasChanges(false)
}}
className="flex items-center gap-1 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors text-nofx-text-muted hover:text-nofx-text hover:bg-nofx-bg-lighter border border-nofx-border"
>
<X className="w-3 h-3" />
{tr('discardChanges')}
</button>
)}
{!selectedStrategy.is_default && (
<button
onClick={handleSaveStrategy}
disabled={isSaving || !hasChanges || (tokenOverflow && currentStrategyType === 'ai_trading')}
className={`flex items-center gap-1 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors disabled:opacity-50
${hasChanges ? 'bg-nofx-gold text-black hover:bg-yellow-500' : 'bg-nofx-bg-lighter text-nofx-text-muted cursor-not-allowed'}`}
>
<Save className="w-3 h-3" />
{isSaving ? tr('saving') : tr('save')}
</button>
)}
</div>
</div>
<input
type="text"
value={selectedStrategy.description || ''}
@@ -837,14 +779,33 @@ export function StrategyStudioPage() {
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"
/>
</div>
{/* Token Estimate Bar */}
{currentStrategyType === 'ai_trading' && (
<div className="mb-4">
<TokenEstimateBar config={editingConfig} language={language} onOverflowChange={setTokenOverflow} />
</div>
{hasChanges && (
<span className="text-xs text-nofx-gold"> {tr('unsaved')}</span>
)}
</div>
<div className="flex items-center gap-2 flex-shrink-0">
{!selectedStrategy.is_active && (
<button
onClick={() => handleActivateStrategy(selectedStrategy.id)}
className="flex items-center gap-1 px-3 py-1.5 rounded-lg text-xs transition-colors bg-nofx-success/10 border border-nofx-success/30 text-nofx-success hover:bg-nofx-success/20"
>
<Check className="w-3 h-3" />
{tr('activate')}
</button>
)}
{!selectedStrategy.is_default && (
<button
onClick={handleSaveStrategy}
disabled={isSaving || !hasChanges}
className={`flex items-center gap-1 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors disabled:opacity-50
${hasChanges ? 'bg-nofx-gold text-black hover:bg-yellow-500' : 'bg-nofx-bg-lighter text-nofx-text-muted cursor-not-allowed'}`}
>
<Save className="w-3 h-3" />
{isSaving ? tr('saving') : tr('save')}
</button>
)}
</div>
</div>
{/* Strategy Type Selector */}
{editingConfig && (
@@ -857,12 +818,9 @@ export function StrategyStudioPage() {
<button
onClick={() => {
if (!selectedStrategy?.is_default) {
setEditingConfig(prev => prev ? {
...prev,
strategy_type: 'ai_trading',
grid_config: undefined,
} : prev)
setHasChanges(true)
updateConfig('strategy_type', 'ai_trading')
// Clear grid config when switching to AI trading
updateConfig('grid_config', undefined)
}
}}
disabled={selectedStrategy?.is_default}
@@ -881,12 +839,11 @@ export function StrategyStudioPage() {
<button
onClick={() => {
if (!selectedStrategy?.is_default) {
setEditingConfig(prev => prev ? {
...prev,
strategy_type: 'grid_trading',
grid_config: prev.grid_config || defaultGridConfig,
} : prev)
setHasChanges(true)
updateConfig('strategy_type', 'grid_trading')
// Initialize grid config if not exists
if (!editingConfig.grid_config) {
updateConfig('grid_config', defaultGridConfig)
}
}
}}
disabled={selectedStrategy?.is_default}
@@ -977,16 +934,15 @@ export function StrategyStudioPage() {
<div className="p-3 space-y-3">
{/* Controls */}
<div className="flex items-center gap-2 flex-wrap">
<NofxSelect
<select
value={selectedVariant}
onChange={(val) => setSelectedVariant(val)}
options={[
{ value: 'balanced', label: tr('balanced') },
{ value: 'aggressive', label: tr('aggressive') },
{ value: 'conservative', label: tr('conservative') },
]}
className="px-2 py-1.5 rounded text-xs bg-nofx-bg border border-nofx-gold/20 text-nofx-text"
/>
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 outline-none focus:border-nofx-gold"
>
<option value="balanced">{tr('balanced')}</option>
<option value="aggressive">{tr('aggressive')}</option>
<option value="conservative">{tr('conservative')}</option>
</select>
<button
onClick={fetchPromptPreview}
disabled={isLoadingPrompt || !editingConfig}
@@ -1051,15 +1007,17 @@ export function StrategyStudioPage() {
<span className="text-xs font-medium text-nofx-text">{tr('selectModel')}</span>
</div>
{aiModels.length > 0 ? (
<NofxSelect
<select
value={selectedModelId}
onChange={(val) => 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) => (
<option key={model.id} value={model.id}>
{model.name} ({model.provider})
</option>
))}
</select>
) : (
<div className="px-3 py-2 rounded-lg text-sm bg-nofx-danger/10 text-nofx-danger">
{tr('noModel')}
@@ -1067,16 +1025,15 @@ export function StrategyStudioPage() {
)}
<div className="flex items-center gap-2">
<NofxSelect
<select
value={selectedVariant}
onChange={(val) => 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"
/>
>
<option value="balanced">{tr('balanced')}</option>
<option value="aggressive">{tr('aggressive')}</option>
<option value="conservative">{tr('conservative')}</option>
</select>
<button
onClick={runAiTest}
disabled={isRunningAiTest || !editingConfig || !selectedModelId}
+25
View File
@@ -6,6 +6,8 @@ export interface AIModel {
apiKey?: string
customApiUrl?: string
customModelName?: string
walletAddress?: string
balanceUsdc?: string
}
export interface TelegramConfig {
@@ -110,3 +112,26 @@ export interface UpdateExchangeConfigRequest {
}
}
}
export interface BeginnerOnboardingResponse {
address: string
private_key: string
chain: string
asset: string
provider: string
default_model: string
configured_model_id: string
balance_usdc: string
env_saved: boolean
env_path?: string
reused_existing: boolean
env_warning?: string
}
export interface CurrentBeginnerWalletResponse {
found: boolean
address?: string
balance_usdc?: string
source?: string
claw402_status?: string
}