mirror of
https://github.com/laoxong/nofx.git
synced 2026-06-04 01:48:22 +08:00
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:
@@ -27,6 +27,8 @@ Thumbs.db
|
|||||||
*.tmp
|
*.tmp
|
||||||
*.bak
|
*.bak
|
||||||
*.backup
|
*.backup
|
||||||
|
.cache/
|
||||||
|
.gh-config/
|
||||||
|
|
||||||
# 环境变量
|
# 环境变量
|
||||||
.env
|
.env
|
||||||
|
|||||||
+17
-1
@@ -10,6 +10,7 @@ import (
|
|||||||
"nofx/crypto"
|
"nofx/crypto"
|
||||||
"nofx/logger"
|
"nofx/logger"
|
||||||
"nofx/security"
|
"nofx/security"
|
||||||
|
"nofx/wallet"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@@ -31,6 +32,8 @@ type SafeModelConfig struct {
|
|||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
CustomAPIURL string `json:"customApiUrl"` // Custom API URL (usually not sensitive)
|
CustomAPIURL string `json:"customApiUrl"` // Custom API URL (usually not sensitive)
|
||||||
CustomModelName string `json:"customModelName"` // Custom model name (not sensitive)
|
CustomModelName string `json:"customModelName"` // Custom model name (not sensitive)
|
||||||
|
WalletAddress string `json:"walletAddress,omitempty"`
|
||||||
|
BalanceUSDC string `json:"balanceUsdc,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UpdateModelConfigRequest struct {
|
type UpdateModelConfigRequest struct {
|
||||||
@@ -75,7 +78,7 @@ func (s *Server) handleGetModelConfigs(c *gin.Context) {
|
|||||||
// Convert to safe response structure, remove sensitive information
|
// Convert to safe response structure, remove sensitive information
|
||||||
safeModels := make([]SafeModelConfig, len(models))
|
safeModels := make([]SafeModelConfig, len(models))
|
||||||
for i, model := range models {
|
for i, model := range models {
|
||||||
safeModels[i] = SafeModelConfig{
|
safeModel := SafeModelConfig{
|
||||||
ID: model.ID,
|
ID: model.ID,
|
||||||
Name: model.Name,
|
Name: model.Name,
|
||||||
Provider: model.Provider,
|
Provider: model.Provider,
|
||||||
@@ -83,6 +86,19 @@ func (s *Server) handleGetModelConfigs(c *gin.Context) {
|
|||||||
CustomAPIURL: model.CustomAPIURL,
|
CustomAPIURL: model.CustomAPIURL,
|
||||||
CustomModelName: model.CustomModelName,
|
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)
|
c.JSON(http.StatusOK, safeModels)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -122,6 +122,8 @@ func (s *Server) setupRoutes() {
|
|||||||
{
|
{
|
||||||
// Logout (add to blacklist)
|
// Logout (add to blacklist)
|
||||||
s.route(protected, "POST", "/logout", "Logout (blacklist token)", s.handleLogout)
|
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
|
// User account management
|
||||||
s.routeWithSchema(protected, "PUT", "/user/password", "Change current user password",
|
s.routeWithSchema(protected, "PUT", "/user/password", "Change current user password",
|
||||||
|
|||||||
+2
-1
@@ -11,6 +11,7 @@ services:
|
|||||||
- "${NOFX_BACKEND_PORT:-8080}:8080"
|
- "${NOFX_BACKEND_PORT:-8080}:8080"
|
||||||
- "6060:6060" # pprof profiling
|
- "6060:6060" # pprof profiling
|
||||||
volumes:
|
volumes:
|
||||||
|
- ./.env:/app/.env
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
- /etc/localtime:/etc/localtime:ro
|
- /etc/localtime:/etc/localtime:ro
|
||||||
env_file:
|
env_file:
|
||||||
@@ -49,4 +50,4 @@ services:
|
|||||||
|
|
||||||
networks:
|
networks:
|
||||||
nofx-network:
|
nofx-network:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
|
|||||||
+16
-1
@@ -15,6 +15,7 @@ import { FAQPage } from './pages/FAQPage'
|
|||||||
import { StrategyStudioPage } from './pages/StrategyStudioPage'
|
import { StrategyStudioPage } from './pages/StrategyStudioPage'
|
||||||
import { StrategyMarketPage } from './pages/StrategyMarketPage'
|
import { StrategyMarketPage } from './pages/StrategyMarketPage'
|
||||||
import { DataPage } from './pages/DataPage'
|
import { DataPage } from './pages/DataPage'
|
||||||
|
import { BeginnerOnboardingPage } from './pages/BeginnerOnboardingPage'
|
||||||
import { LoginRequiredOverlay } from './components/auth/LoginRequiredOverlay'
|
import { LoginRequiredOverlay } from './components/auth/LoginRequiredOverlay'
|
||||||
import HeaderBar from './components/common/HeaderBar'
|
import HeaderBar from './components/common/HeaderBar'
|
||||||
import { LanguageProvider, useLanguage } from './contexts/LanguageContext'
|
import { LanguageProvider, useLanguage } from './contexts/LanguageContext'
|
||||||
@@ -22,6 +23,7 @@ import { AuthProvider, useAuth } from './contexts/AuthContext'
|
|||||||
import { ConfirmDialogProvider } from './components/common/ConfirmDialog'
|
import { ConfirmDialogProvider } from './components/common/ConfirmDialog'
|
||||||
import { t } from './i18n/translations'
|
import { t } from './i18n/translations'
|
||||||
import { useSystemConfig } from './hooks/useSystemConfig'
|
import { useSystemConfig } from './hooks/useSystemConfig'
|
||||||
|
import { getUserMode } from './lib/onboarding'
|
||||||
|
|
||||||
import { OFFICIAL_LINKS } from './constants/branding'
|
import { OFFICIAL_LINKS } from './constants/branding'
|
||||||
import type {
|
import type {
|
||||||
@@ -132,6 +134,8 @@ function App() {
|
|||||||
}
|
}
|
||||||
const [lastUpdate, setLastUpdate] = useState<string>('--:--:--')
|
const [lastUpdate, setLastUpdate] = useState<string>('--:--:--')
|
||||||
const [decisionsLimit, setDecisionsLimit] = useState<number>(5)
|
const [decisionsLimit, setDecisionsLimit] = useState<number>(5)
|
||||||
|
const hasPersistedAuth =
|
||||||
|
!!localStorage.getItem('auth_token') && !!localStorage.getItem('auth_user')
|
||||||
|
|
||||||
// 监听URL变化,同步页面状态
|
// 监听URL变化,同步页面状态
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -347,6 +351,17 @@ function App() {
|
|||||||
}
|
}
|
||||||
return <SetupPage />
|
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') {
|
if (route === '/faq') {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -376,7 +391,7 @@ function App() {
|
|||||||
return <ResetPasswordPage />
|
return <ResetPasswordPage />
|
||||||
}
|
}
|
||||||
if (route === '/settings') {
|
if (route === '/settings') {
|
||||||
if (!user || !token) {
|
if ((!user || !token) && !hasPersistedAuth) {
|
||||||
window.location.href = '/login'
|
window.location.href = '/login'
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { useAuth } from '../../contexts/AuthContext'
|
|||||||
import { useLanguage } from '../../contexts/LanguageContext'
|
import { useLanguage } from '../../contexts/LanguageContext'
|
||||||
import { t } from '../../i18n/translations'
|
import { t } from '../../i18n/translations'
|
||||||
import { DeepVoidBackground } from '../common/DeepVoidBackground'
|
import { DeepVoidBackground } from '../common/DeepVoidBackground'
|
||||||
|
import { OnboardingModeSelector } from './OnboardingModeSelector'
|
||||||
|
import type { UserMode } from '../../lib/onboarding'
|
||||||
|
|
||||||
export function LoginPage() {
|
export function LoginPage() {
|
||||||
const { language } = useLanguage()
|
const { language } = useLanguage()
|
||||||
@@ -15,6 +17,7 @@ export function LoginPage() {
|
|||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [expiredToastId, setExpiredToastId] = useState<string | number | null>(null)
|
const [expiredToastId, setExpiredToastId] = useState<string | number | null>(null)
|
||||||
|
const [mode, setMode] = useState<UserMode>('beginner')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (sessionStorage.getItem('from401') === 'true') {
|
if (sessionStorage.getItem('from401') === 'true') {
|
||||||
@@ -28,7 +31,7 @@ export function LoginPage() {
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setError('')
|
setError('')
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
const result = await login(email, password)
|
const result = await login(email, password, mode)
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
if (expiredToastId) toast.dismiss(expiredToastId)
|
if (expiredToastId) toast.dismiss(expiredToastId)
|
||||||
@@ -109,6 +112,12 @@ export function LoginPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<OnboardingModeSelector
|
||||||
|
language={language}
|
||||||
|
mode={mode}
|
||||||
|
onChange={setMode}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Error */}
|
{/* Error */}
|
||||||
{error && (
|
{error && (
|
||||||
<p className="text-xs text-red-400 bg-red-500/10 border border-red-500/20 rounded-lg px-3 py-2">
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -4,6 +4,12 @@ import { motion, AnimatePresence } from 'framer-motion'
|
|||||||
import { Menu, X, ChevronDown, Settings } from 'lucide-react'
|
import { Menu, X, ChevronDown, Settings } from 'lucide-react'
|
||||||
import { t, type Language } from '../../i18n/translations'
|
import { t, type Language } from '../../i18n/translations'
|
||||||
import { OFFICIAL_LINKS } from '../../constants/branding'
|
import { OFFICIAL_LINKS } from '../../constants/branding'
|
||||||
|
import {
|
||||||
|
getPostAuthPath,
|
||||||
|
getUserMode,
|
||||||
|
setUserMode,
|
||||||
|
type UserMode,
|
||||||
|
} from '../../lib/onboarding'
|
||||||
|
|
||||||
type Page =
|
type Page =
|
||||||
| 'competition'
|
| 'competition'
|
||||||
@@ -44,8 +50,21 @@ export default function HeaderBar({
|
|||||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||||
const [languageDropdownOpen, setLanguageDropdownOpen] = useState(false)
|
const [languageDropdownOpen, setLanguageDropdownOpen] = useState(false)
|
||||||
const [userDropdownOpen, setUserDropdownOpen] = useState(false)
|
const [userDropdownOpen, setUserDropdownOpen] = useState(false)
|
||||||
|
const [userMode, setUserModeState] = useState<UserMode>(() => getUserMode() ?? 'advanced')
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||||
const userDropdownRef = 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
|
// Close dropdown when clicking outside
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function handleClickOutside(event: MouseEvent) {
|
function handleClickOutside(event: MouseEvent) {
|
||||||
@@ -216,6 +235,15 @@ export default function HeaderBar({
|
|||||||
<Settings className="w-3.5 h-3.5" />
|
<Settings className="w-3.5 h-3.5" />
|
||||||
Settings
|
Settings
|
||||||
</button>
|
</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 && (
|
{onLogout && (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|||||||
@@ -3,14 +3,19 @@ import { Eye, EyeOff } from 'lucide-react'
|
|||||||
import { useAuth } from '../../contexts/AuthContext'
|
import { useAuth } from '../../contexts/AuthContext'
|
||||||
import { DeepVoidBackground } from '../common/DeepVoidBackground'
|
import { DeepVoidBackground } from '../common/DeepVoidBackground'
|
||||||
import { invalidateSystemConfig } from '../../lib/config'
|
import { invalidateSystemConfig } from '../../lib/config'
|
||||||
|
import { OnboardingModeSelector } from '../auth/OnboardingModeSelector'
|
||||||
|
import type { UserMode } from '../../lib/onboarding'
|
||||||
|
import { useLanguage } from '../../contexts/LanguageContext'
|
||||||
|
|
||||||
export function SetupPage() {
|
export function SetupPage() {
|
||||||
|
const { language } = useLanguage()
|
||||||
const { register } = useAuth()
|
const { register } = useAuth()
|
||||||
const [email, setEmail] = useState('')
|
const [email, setEmail] = useState('')
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
const [showPassword, setShowPassword] = useState(false)
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [mode, setMode] = useState<UserMode>('beginner')
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -20,11 +25,10 @@ export function SetupPage() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
const result = await register(email, password)
|
const result = await register(email, password, undefined, mode)
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
invalidateSystemConfig()
|
invalidateSystemConfig()
|
||||||
window.location.href = '/traders'
|
|
||||||
} else {
|
} else {
|
||||||
setError(result.message || 'Setup failed, please try again')
|
setError(result.message || 'Setup failed, please try again')
|
||||||
}
|
}
|
||||||
@@ -87,6 +91,12 @@ export function SetupPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<OnboardingModeSelector
|
||||||
|
language={language}
|
||||||
|
mode={mode}
|
||||||
|
onChange={setMode}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Error */}
|
{/* Error */}
|
||||||
{error && (
|
{error && (
|
||||||
<p className="text-xs text-red-400 bg-red-500/10 border border-red-500/20 rounded-lg px-3 py-2">
|
<p className="text-xs text-red-400 bg-red-500/10 border border-red-500/20 rounded-lg px-3 py-2">
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { TelegramConfigModal } from './TelegramConfigModal'
|
|||||||
import { ModelConfigModal } from './ModelConfigModal'
|
import { ModelConfigModal } from './ModelConfigModal'
|
||||||
import { ConfigStatusGrid } from './ConfigStatusGrid'
|
import { ConfigStatusGrid } from './ConfigStatusGrid'
|
||||||
import { TradersList } from './TradersList'
|
import { TradersList } from './TradersList'
|
||||||
|
import { BeginnerGuideCards } from './BeginnerGuideCards'
|
||||||
import {
|
import {
|
||||||
Bot,
|
Bot,
|
||||||
Plus,
|
Plus,
|
||||||
@@ -25,6 +26,12 @@ import {
|
|||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { confirmToast } from '../../lib/notify'
|
import { confirmToast } from '../../lib/notify'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
import {
|
||||||
|
getBeginnerWalletAddress,
|
||||||
|
getUserMode,
|
||||||
|
setBeginnerWalletAddress as persistBeginnerWalletAddress,
|
||||||
|
} from '../../lib/onboarding'
|
||||||
|
import type { Strategy } from '../../types'
|
||||||
|
|
||||||
interface AITradersPageProps {
|
interface AITradersPageProps {
|
||||||
onTraderSelect?: (traderId: string) => void
|
onTraderSelect?: (traderId: string) => void
|
||||||
@@ -48,6 +55,14 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
|||||||
const [visibleTraderAddresses, setVisibleTraderAddresses] = useState<Set<string>>(new Set())
|
const [visibleTraderAddresses, setVisibleTraderAddresses] = useState<Set<string>>(new Set())
|
||||||
const [visibleExchangeAddresses, setVisibleExchangeAddresses] = useState<Set<string>>(new Set())
|
const [visibleExchangeAddresses, setVisibleExchangeAddresses] = useState<Set<string>>(new Set())
|
||||||
const [copiedId, setCopiedId] = useState<string | null>(null)
|
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
|
// Toggle wallet address visibility for a trader
|
||||||
const toggleTraderAddressVisibility = (traderId: string) => {
|
const toggleTraderAddressVisibility = (traderId: string) => {
|
||||||
@@ -91,6 +106,11 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
|||||||
api.getTraders,
|
api.getTraders,
|
||||||
{ refreshInterval: 5000 }
|
{ refreshInterval: 5000 }
|
||||||
)
|
)
|
||||||
|
const { data: strategies } = useSWR<Strategy[]>(
|
||||||
|
user && token ? 'strategies' : null,
|
||||||
|
api.getStrategies,
|
||||||
|
{ refreshInterval: 30000 }
|
||||||
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadConfigs = async () => {
|
const loadConfigs = async () => {
|
||||||
@@ -115,6 +135,12 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
|||||||
api.getSupportedModels(),
|
api.getSupportedModels(),
|
||||||
])
|
])
|
||||||
setAllModels(modelConfigs)
|
setAllModels(modelConfigs)
|
||||||
|
const clawWalletAddress =
|
||||||
|
modelConfigs.find((model) => model.provider === 'claw402')?.walletAddress || null
|
||||||
|
if (clawWalletAddress) {
|
||||||
|
setBeginnerWalletAddress(clawWalletAddress)
|
||||||
|
persistBeginnerWalletAddress(clawWalletAddress)
|
||||||
|
}
|
||||||
setAllExchanges(exchangeConfigs)
|
setAllExchanges(exchangeConfigs)
|
||||||
setSupportedModels(models)
|
setSupportedModels(models)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -616,6 +642,36 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
|||||||
setShowExchangeModal(true)
|
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 (
|
return (
|
||||||
<DeepVoidBackground className="py-8" disableAnimation>
|
<DeepVoidBackground className="py-8" disableAnimation>
|
||||||
<div className="w-full px-4 md:px-8 space-y-8 animate-fade-in">
|
<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>
|
||||||
</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 */}
|
{/* Configuration Status Grid */}
|
||||||
<ConfigStatusGrid
|
<ConfigStatusGrid
|
||||||
configuredModels={configuredModels}
|
configuredModels={configuredModels}
|
||||||
@@ -715,7 +786,7 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
|||||||
copiedId={copiedId}
|
copiedId={copiedId}
|
||||||
language={language}
|
language={language}
|
||||||
onTraderSelect={onTraderSelect}
|
onTraderSelect={onTraderSelect}
|
||||||
onNavigate={(path) => navigate(path)}
|
onNavigate={navigateInApp}
|
||||||
onEditTrader={handleEditTrader}
|
onEditTrader={handleEditTrader}
|
||||||
onToggleTrader={handleToggleTrader}
|
onToggleTrader={handleToggleTrader}
|
||||||
onToggleCompetition={handleToggleCompetition}
|
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">
|
<div className="text-[10px] text-zinc-500 font-mono flex items-center gap-2">
|
||||||
{model.customModelName || AI_PROVIDER_CONFIG[model.provider]?.defaultModel || ''}
|
{model.customModelName || AI_PROVIDER_CONFIG[model.provider]?.defaultModel || ''}
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Trash2, Brain, ExternalLink } from 'lucide-react'
|
|||||||
import type { AIModel } from '../../types'
|
import type { AIModel } from '../../types'
|
||||||
import type { Language } from '../../i18n/translations'
|
import type { Language } from '../../i18n/translations'
|
||||||
import { t } from '../../i18n/translations'
|
import { t } from '../../i18n/translations'
|
||||||
|
import { api } from '../../lib/api'
|
||||||
import { getModelIcon } from '../common/ModelIcons'
|
import { getModelIcon } from '../common/ModelIcons'
|
||||||
import { ModelStepIndicator } from './ModelStepIndicator'
|
import { ModelStepIndicator } from './ModelStepIndicator'
|
||||||
import { ModelCard } from './ModelCard'
|
import { ModelCard } from './ModelCard'
|
||||||
@@ -12,6 +13,7 @@ import {
|
|||||||
AI_PROVIDER_CONFIG,
|
AI_PROVIDER_CONFIG,
|
||||||
getShortName,
|
getShortName,
|
||||||
} from './model-constants'
|
} from './model-constants'
|
||||||
|
import { getBeginnerWalletAddress } from '../../lib/onboarding'
|
||||||
|
|
||||||
interface ModelConfigModalProps {
|
interface ModelConfigModalProps {
|
||||||
allModels: AIModel[]
|
allModels: AIModel[]
|
||||||
@@ -42,20 +44,22 @@ export function ModelConfigModal({
|
|||||||
const [apiKey, setApiKey] = useState('')
|
const [apiKey, setApiKey] = useState('')
|
||||||
const [baseUrl, setBaseUrl] = useState('')
|
const [baseUrl, setBaseUrl] = useState('')
|
||||||
const [modelName, setModelName] = useState('')
|
const [modelName, setModelName] = useState('')
|
||||||
|
const configuredModel =
|
||||||
|
configuredModels?.find((model) => model.id === selectedModelId) || null
|
||||||
|
|
||||||
// Always prefer allModels (supportedModels) for provider/id lookup;
|
// Always prefer allModels (supportedModels) for provider/id lookup;
|
||||||
// fall back to configuredModels for edit mode details (apiKey etc.)
|
// fall back to configuredModels for edit mode details (apiKey etc.)
|
||||||
const selectedModel =
|
const selectedModel =
|
||||||
allModels?.find((m) => m.id === selectedModelId) ||
|
allModels?.find((m) => m.id === selectedModelId) || configuredModel
|
||||||
configuredModels?.find((m) => m.id === selectedModelId)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (editingModelId && selectedModel) {
|
const modelDetails = configuredModel || selectedModel
|
||||||
setApiKey(selectedModel.apiKey || '')
|
if (editingModelId && modelDetails) {
|
||||||
setBaseUrl(selectedModel.customApiUrl || '')
|
setApiKey(modelDetails.apiKey || '')
|
||||||
setModelName(selectedModel.customModelName || '')
|
setBaseUrl(modelDetails.customApiUrl || '')
|
||||||
|
setModelName(modelDetails.customModelName || '')
|
||||||
}
|
}
|
||||||
}, [editingModelId, selectedModel])
|
}, [editingModelId, configuredModel, selectedModel])
|
||||||
|
|
||||||
const handleSelectModel = (modelId: string) => {
|
const handleSelectModel = (modelId: string) => {
|
||||||
setSelectedModelId(modelId)
|
setSelectedModelId(modelId)
|
||||||
@@ -79,7 +83,18 @@ export function ModelConfigModal({
|
|||||||
|
|
||||||
const availableModels = allModels || []
|
const availableModels = allModels || []
|
||||||
const configuredIds = new Set(configuredModels?.map(m => m.id) || [])
|
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 (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4 overflow-y-auto backdrop-blur-sm">
|
<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
|
<Claw402ConfigForm
|
||||||
apiKey={apiKey}
|
apiKey={apiKey}
|
||||||
modelName={modelName}
|
modelName={modelName}
|
||||||
|
configuredModel={configuredModel}
|
||||||
editingModelId={editingModelId}
|
editingModelId={editingModelId}
|
||||||
onApiKeyChange={setApiKey}
|
onApiKeyChange={setApiKey}
|
||||||
onModelNameChange={setModelName}
|
onModelNameChange={setModelName}
|
||||||
@@ -189,6 +205,10 @@ function ModelSelectionStep({
|
|||||||
onSelectModel: (modelId: string) => void
|
onSelectModel: (modelId: string) => void
|
||||||
language: Language
|
language: Language
|
||||||
}) {
|
}) {
|
||||||
|
const [showOtherProviders, setShowOtherProviders] = useState(false)
|
||||||
|
const claw402Model = availableModels.find((m) => m.provider === 'claw402')
|
||||||
|
const otherProviders = availableModels.filter((m) => m.provider !== 'claw402')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="text-sm font-semibold" style={{ color: '#EAECEF' }}>
|
<div className="text-sm font-semibold" style={{ color: '#EAECEF' }}>
|
||||||
@@ -196,12 +216,11 @@ function ModelSelectionStep({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Claw402 Featured Card */}
|
{/* Claw402 Featured Card */}
|
||||||
{availableModels.some(m => m.provider === 'claw402') && (
|
{claw402Model && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const claw = availableModels.find(m => m.provider === 'claw402')
|
onSelectModel(claw402Model.id)
|
||||||
if (claw) onSelectModel(claw.id)
|
|
||||||
}}
|
}}
|
||||||
className="w-full p-5 rounded-xl text-left transition-all hover:scale-[1.01]"
|
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)' }}
|
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>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<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="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' }}>
|
<div className="px-3 py-1.5 rounded-full text-xs font-bold" style={{ background: 'linear-gradient(135deg, #2563EB, #7C3AED)', color: '#fff' }}>
|
||||||
@@ -235,23 +254,57 @@ function ModelSelectionStep({
|
|||||||
GPT · Claude · DeepSeek · Gemini · Grok · Qwen · Kimi
|
GPT · Claude · DeepSeek · Gemini · Grok · Qwen · Kimi
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mt-4 ml-[52px] text-[11px]" style={{ color: '#A0AEC0' }}>
|
||||||
|
{t('modelConfig.claw402EntryDesc', language)}
|
||||||
|
</div>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-3 sm:grid-cols-4 gap-3">
|
{otherProviders.length > 0 && (
|
||||||
{availableModels.filter(m => m.provider !== 'claw402').map((model) => (
|
<div className="rounded-xl border border-white/10 bg-black/20 overflow-hidden">
|
||||||
<ModelCard
|
<button
|
||||||
key={model.id}
|
type="button"
|
||||||
model={model}
|
onClick={() => setShowOtherProviders((prev) => !prev)}
|
||||||
selected={selectedModelId === model.id}
|
className="w-full flex items-center justify-between px-4 py-4 text-left transition-all hover:bg-white/5"
|
||||||
onClick={() => onSelectModel(model.id)}
|
>
|
||||||
configured={configuredIds.has(model.id)}
|
<div>
|
||||||
/>
|
<div className="text-sm font-semibold" style={{ color: '#EAECEF' }}>
|
||||||
))}
|
{t('modelConfig.otherApiEntry', language)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-center pt-2" style={{ color: '#848E9C' }}>
|
<div className="mt-1 text-xs" style={{ color: '#848E9C' }}>
|
||||||
{t('modelConfig.modelsConfigured', language)}
|
{t('modelConfig.otherApiEntryDesc', language)}
|
||||||
</div>
|
</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">
|
||||||
|
{otherProviders.map((model) => (
|
||||||
|
<ModelCard
|
||||||
|
key={model.id}
|
||||||
|
model={model}
|
||||||
|
selected={selectedModelId === model.id}
|
||||||
|
onClick={() => onSelectModel(model.id)}
|
||||||
|
configured={configuredIds.has(model.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-center pt-3" style={{ color: '#848E9C' }}>
|
||||||
|
{t('modelConfig.modelsConfigured', language)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -259,6 +312,7 @@ function ModelSelectionStep({
|
|||||||
function Claw402ConfigForm({
|
function Claw402ConfigForm({
|
||||||
apiKey,
|
apiKey,
|
||||||
modelName,
|
modelName,
|
||||||
|
configuredModel,
|
||||||
editingModelId,
|
editingModelId,
|
||||||
onApiKeyChange,
|
onApiKeyChange,
|
||||||
onModelNameChange,
|
onModelNameChange,
|
||||||
@@ -268,6 +322,7 @@ function Claw402ConfigForm({
|
|||||||
}: {
|
}: {
|
||||||
apiKey: string
|
apiKey: string
|
||||||
modelName: string
|
modelName: string
|
||||||
|
configuredModel: AIModel | null
|
||||||
editingModelId: string | null
|
editingModelId: string | null
|
||||||
onApiKeyChange: (value: string) => void
|
onApiKeyChange: (value: string) => void
|
||||||
onModelNameChange: (value: string) => void
|
onModelNameChange: (value: string) => void
|
||||||
@@ -278,14 +333,21 @@ function Claw402ConfigForm({
|
|||||||
const [walletAddress, setWalletAddress] = useState('')
|
const [walletAddress, setWalletAddress] = useState('')
|
||||||
const [copiedAddr, setCopiedAddr] = useState(false)
|
const [copiedAddr, setCopiedAddr] = useState(false)
|
||||||
const [showDeposit, setShowDeposit] = useState(false)
|
const [showDeposit, setShowDeposit] = useState(false)
|
||||||
const [showNewWalletBackup, setShowNewWalletBackup] = useState(false)
|
|
||||||
const [newWalletKey, setNewWalletKey] = useState('')
|
|
||||||
const [usdcBalance, setUsdcBalance] = useState<string | null>(null)
|
const [usdcBalance, setUsdcBalance] = useState<string | null>(null)
|
||||||
const [keyError, setKeyError] = useState('')
|
const [keyError, setKeyError] = useState('')
|
||||||
const [validating, setValidating] = useState(false)
|
const [validating, setValidating] = useState(false)
|
||||||
const [claw402Status, setClaw402Status] = useState<string | null>(null)
|
const [claw402Status, setClaw402Status] = useState<string | null>(null)
|
||||||
const [testResult, setTestResult] = useState<{ status: string; message: string } | null>(null)
|
const [testResult, setTestResult] = useState<{ status: string; message: string } | null>(null)
|
||||||
const [testing, setTesting] = useState(false)
|
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
|
// Client-side validation helper
|
||||||
const getClientError = (key: string): string => {
|
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)
|
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
|
// Debounced validation when apiKey changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -347,6 +437,23 @@ function Claw402ConfigForm({
|
|||||||
setTesting(true)
|
setTesting(true)
|
||||||
setTestResult(null)
|
setTestResult(null)
|
||||||
try {
|
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', {
|
const res = await fetch('/api/wallet/validate', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -374,7 +481,7 @@ function Claw402ConfigForm({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const balanceNum = usdcBalance ? parseFloat(usdcBalance) : 0
|
const balanceNum = resolvedUsdcBalance ? parseFloat(resolvedUsdcBalance) : 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={onSubmit} className="space-y-5">
|
<form onSubmit={onSubmit} className="space-y-5">
|
||||||
@@ -396,6 +503,25 @@ function Claw402ConfigForm({
|
|||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Step 1: Select AI Model */}
|
{/* Step 1: Select AI Model */}
|
||||||
@@ -467,6 +593,33 @@ function Claw402ConfigForm({
|
|||||||
</div>
|
</div>
|
||||||
</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="space-y-1.5">
|
||||||
<div className="text-xs font-medium" style={{ color: '#A0AEC0' }}>
|
<div className="text-xs font-medium" style={{ color: '#A0AEC0' }}>
|
||||||
{t('modelConfig.walletPrivateKey', language)}
|
{t('modelConfig.walletPrivateKey', language)}
|
||||||
@@ -476,72 +629,30 @@ function Claw402ConfigForm({
|
|||||||
type="password"
|
type="password"
|
||||||
value={apiKey}
|
value={apiKey}
|
||||||
onChange={(e) => onApiKeyChange(e.target.value)}
|
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"
|
className="flex-1 px-4 py-3 rounded-xl font-mono text-sm"
|
||||||
style={{
|
style={{
|
||||||
background: '#0B0E11',
|
background: '#0B0E11',
|
||||||
border: keyError ? '1px solid #EF4444' : walletAddress ? '1px solid #00E096' : '1px solid #2B3139',
|
border: keyError ? '1px solid #EF4444' : walletAddress ? '1px solid #00E096' : '1px solid #2B3139',
|
||||||
color: '#EAECEF',
|
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>
|
</div>
|
||||||
|
|
||||||
{/* New wallet backup warning */}
|
{hasExistingWallet && !apiKey ? (
|
||||||
{showNewWalletBackup && newWalletKey && (
|
<div className="text-[11px] leading-5" style={{ color: '#848E9C' }}>
|
||||||
<div className="p-3 rounded-xl" style={{ background: 'rgba(239, 68, 68, 0.08)', border: '1px solid rgba(239, 68, 68, 0.3)' }}>
|
{language === 'zh'
|
||||||
<div className="text-xs font-bold mb-2" style={{ color: '#EF4444' }}>
|
? '后续这里只使用你第一次创建并保存的钱包;如果你要换钱包,请手动填写新的私钥。'
|
||||||
🚨 {language === 'zh' ? '重要:请立即备份私钥!' : 'Important: Backup your private key NOW!'}
|
: '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="text-[11px] mb-2" style={{ color: '#F87171' }}>
|
|
||||||
{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.'}
|
|
||||||
</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>
|
</div>
|
||||||
)}
|
) : null}
|
||||||
|
|
||||||
<div className="flex items-start gap-1.5 text-[11px]" style={{ color: '#848E9C' }}>
|
<div className="flex items-start gap-1.5 text-[11px]" style={{ color: '#848E9C' }}>
|
||||||
<span className="mt-px">🔒</span>
|
<span className="mt-px">🔒</span>
|
||||||
@@ -552,7 +663,7 @@ function Claw402ConfigForm({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Wallet Validation Results */}
|
{/* Wallet Validation Results */}
|
||||||
{apiKey && (
|
{(apiKey || hasExistingWallet) && (
|
||||||
<div className="space-y-2 pl-1">
|
<div className="space-y-2 pl-1">
|
||||||
{/* Validating spinner */}
|
{/* Validating spinner */}
|
||||||
{validating && (
|
{validating && (
|
||||||
@@ -571,7 +682,7 @@ function Claw402ConfigForm({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Success: address + balance + status */}
|
{/* 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="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">
|
<div className="flex items-center justify-between mb-1">
|
||||||
@@ -581,7 +692,7 @@ function Claw402ConfigForm({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
navigator.clipboard.writeText(walletAddress)
|
navigator.clipboard.writeText(resolvedWalletAddress)
|
||||||
setCopiedAddr(true)
|
setCopiedAddr(true)
|
||||||
setTimeout(() => setCopiedAddr(false), 2000)
|
setTimeout(() => setCopiedAddr(false), 2000)
|
||||||
}}
|
}}
|
||||||
@@ -591,16 +702,16 @@ function Claw402ConfigForm({
|
|||||||
{copiedAddr ? '✅' : '📋'}
|
{copiedAddr ? '✅' : '📋'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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' }}>
|
<div className="text-[10px] mt-1.5" style={{ color: '#F59E0B' }}>
|
||||||
⚠️ {language === 'zh' ? '请确认这是你的钱包地址(可在 MetaMask 中核对)' : 'Please confirm this is your wallet address (verify in MetaMask)'}
|
⚠️ {language === 'zh' ? '请确认这是你的钱包地址(可在 MetaMask 中核对)' : 'Please confirm this is your wallet address (verify in MetaMask)'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{usdcBalance !== null && (
|
{resolvedUsdcBalance !== null && (
|
||||||
<div className="flex items-center gap-2 text-xs">
|
<div className="flex items-center gap-2 text-xs">
|
||||||
<span>💰</span>
|
<span>💰</span>
|
||||||
<span style={{ color: balanceNum > 0 ? '#00E096' : '#F59E0B' }}>
|
<span style={{ color: balanceNum > 0 ? '#00E096' : '#F59E0B' }}>
|
||||||
{t('modelConfig.usdcBalance', language)}: ${usdcBalance}
|
{t('modelConfig.usdcBalance', language)}: ${resolvedUsdcBalance}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -621,17 +732,17 @@ function Claw402ConfigForm({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3 items-start mb-3">
|
<div className="flex gap-3 items-start mb-3">
|
||||||
<div className="shrink-0 p-1.5 rounded-lg" style={{ background: '#fff' }}>
|
<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>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="text-[11px] mb-1" style={{ color: '#A0AEC0' }}>
|
<div className="text-[11px] mb-1" style={{ color: '#A0AEC0' }}>
|
||||||
{language === 'zh' ? '扫码或复制地址转账' : 'Scan QR or copy address to transfer'}
|
{language === 'zh' ? '扫码或复制地址转账' : 'Scan QR or copy address to transfer'}
|
||||||
</div>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
navigator.clipboard.writeText(walletAddress)
|
navigator.clipboard.writeText(resolvedWalletAddress)
|
||||||
setCopiedAddr(true)
|
setCopiedAddr(true)
|
||||||
setTimeout(() => setCopiedAddr(false), 2000)
|
setTimeout(() => setCopiedAddr(false), 2000)
|
||||||
}}
|
}}
|
||||||
@@ -650,6 +761,13 @@ function Claw402ConfigForm({
|
|||||||
</div>
|
</div>
|
||||||
</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 && (
|
{claw402Status && (
|
||||||
<div className="flex items-center gap-2 text-xs" style={{ color: claw402Status === 'ok' ? '#00E096' : '#EF4444' }}>
|
<div className="flex items-center gap-2 text-xs" style={{ color: claw402Status === 'ok' ? '#00E096' : '#EF4444' }}>
|
||||||
<span>{claw402Status === 'ok' ? '🟢' : '🔴'}</span>
|
<span>{claw402Status === 'ok' ? '🟢' : '🔴'}</span>
|
||||||
@@ -662,11 +780,11 @@ function Claw402ConfigForm({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Test Connection button */}
|
{/* Test Connection button */}
|
||||||
{isKeyValid && !validating && (
|
{(isKeyValid || hasExistingWallet) && !validating && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleTestConnection}
|
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"
|
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' }}
|
style={{ background: 'rgba(37, 99, 235, 0.15)', border: '1px solid rgba(37, 99, 235, 0.3)', color: '#60A5FA' }}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import React, { createContext, useContext, useState, useEffect } from 'react'
|
import React, { createContext, useContext, useState, useEffect } from 'react'
|
||||||
|
import { flushSync } from 'react-dom'
|
||||||
import { getSystemConfig } from '../lib/config'
|
import { getSystemConfig } from '../lib/config'
|
||||||
import { reset401Flag, httpClient } from '../lib/httpClient'
|
import { reset401Flag, httpClient } from '../lib/httpClient'
|
||||||
|
import { getPostAuthPath, setUserMode, type UserMode } from '../lib/onboarding'
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
id: string
|
id: string
|
||||||
@@ -12,7 +14,8 @@ interface AuthContextType {
|
|||||||
token: string | null
|
token: string | null
|
||||||
login: (
|
login: (
|
||||||
email: string,
|
email: string,
|
||||||
password: string
|
password: string,
|
||||||
|
mode?: UserMode
|
||||||
) => Promise<{
|
) => Promise<{
|
||||||
success: boolean
|
success: boolean
|
||||||
message?: string
|
message?: string
|
||||||
@@ -24,7 +27,8 @@ interface AuthContextType {
|
|||||||
register: (
|
register: (
|
||||||
email: string,
|
email: string,
|
||||||
password: string,
|
password: string,
|
||||||
betaCode?: string
|
betaCode?: string,
|
||||||
|
mode?: UserMode
|
||||||
) => Promise<{ success: boolean; message?: string }>
|
) => Promise<{ success: boolean; message?: string }>
|
||||||
resetPassword: (
|
resetPassword: (
|
||||||
email: string,
|
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 {
|
try {
|
||||||
const response = await fetch('/api/login', {
|
const response = await fetch('/api/login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -103,26 +136,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
if (data.token) {
|
if (data.token) {
|
||||||
// Reset 401 flag on successful login
|
|
||||||
reset401Flag()
|
|
||||||
|
|
||||||
const userInfo = { id: data.user_id, email: data.email }
|
const userInfo = { id: data.user_id, email: data.email }
|
||||||
setToken(data.token)
|
handlePostAuthSuccess(data.token, userInfo, mode)
|
||||||
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'))
|
|
||||||
}
|
|
||||||
|
|
||||||
return { success: true, message: data.message }
|
return { success: true, message: data.message }
|
||||||
}
|
}
|
||||||
@@ -156,10 +171,12 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
id: data.user_id || 'admin',
|
id: data.user_id || 'admin',
|
||||||
email: data.email || 'admin@localhost',
|
email: data.email || 'admin@localhost',
|
||||||
}
|
}
|
||||||
setToken(data.token)
|
|
||||||
setUser(userInfo)
|
|
||||||
localStorage.setItem('auth_token', data.token)
|
localStorage.setItem('auth_token', data.token)
|
||||||
localStorage.setItem('auth_user', JSON.stringify(userInfo))
|
localStorage.setItem('auth_user', JSON.stringify(userInfo))
|
||||||
|
flushSync(() => {
|
||||||
|
setToken(data.token)
|
||||||
|
setUser(userInfo)
|
||||||
|
})
|
||||||
|
|
||||||
// Check and redirect to returnUrl if exists
|
// Check and redirect to returnUrl if exists
|
||||||
const returnUrl = sessionStorage.getItem('returnUrl')
|
const returnUrl = sessionStorage.getItem('returnUrl')
|
||||||
@@ -184,7 +201,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
const register = async (
|
const register = async (
|
||||||
email: string,
|
email: string,
|
||||||
password: string,
|
password: string,
|
||||||
betaCode?: string
|
betaCode?: string,
|
||||||
|
mode?: UserMode
|
||||||
) => {
|
) => {
|
||||||
const requestBody: {
|
const requestBody: {
|
||||||
email: string
|
email: string
|
||||||
@@ -204,26 +222,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}>('/api/register', requestBody)
|
}>('/api/register', requestBody)
|
||||||
|
|
||||||
if (result.success && result.data) {
|
if (result.success && result.data) {
|
||||||
// Reset 401 flag on successful login
|
|
||||||
reset401Flag()
|
|
||||||
|
|
||||||
const userInfo = { id: result.data.user_id, email: result.data.email }
|
const userInfo = { id: result.data.user_id, email: result.data.email }
|
||||||
setToken(result.data.token)
|
handlePostAuthSuccess(result.data.token, userInfo, mode)
|
||||||
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'))
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -1210,8 +1210,13 @@ export const translations = {
|
|||||||
// ModelConfigModal
|
// ModelConfigModal
|
||||||
modelConfig: {
|
modelConfig: {
|
||||||
selectModel: 'Select Model',
|
selectModel: 'Select Model',
|
||||||
|
configure: 'Configure',
|
||||||
configureApi: 'Configure API',
|
configureApi: 'Configure API',
|
||||||
|
configureWallet: 'Configure Wallet',
|
||||||
chooseProvider: 'Choose Your AI Provider',
|
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',
|
payPerCall: 'Pay-per-call USDC · All AI Models · No API Key',
|
||||||
recommended: 'Best',
|
recommended: 'Best',
|
||||||
allModelsClaw: 'Pay-per-call with USDC — supports all major AI models',
|
allModelsClaw: 'Pay-per-call with USDC — supports all major AI models',
|
||||||
@@ -2503,8 +2508,13 @@ export const translations = {
|
|||||||
|
|
||||||
modelConfig: {
|
modelConfig: {
|
||||||
selectModel: '选择模型',
|
selectModel: '选择模型',
|
||||||
|
configure: '配置',
|
||||||
configureApi: '配置 API',
|
configureApi: '配置 API',
|
||||||
|
configureWallet: '配置钱包',
|
||||||
chooseProvider: '选择 AI 模型提供商',
|
chooseProvider: '选择 AI 模型提供商',
|
||||||
|
claw402EntryDesc: '默认推荐走这条路。直接用 Base USDC 按次付费,不需要自己管理 API Key。',
|
||||||
|
otherApiEntry: '其他 API 模型',
|
||||||
|
otherApiEntryDesc: '如果你已经有自己的 OpenAI、Claude、Gemini、DeepSeek 等 API Key,再从这里进入。',
|
||||||
payPerCall: 'USDC 按次付费 · 支持全部 AI 模型 · 无需 API Key',
|
payPerCall: 'USDC 按次付费 · 支持全部 AI 模型 · 无需 API Key',
|
||||||
recommended: '推荐',
|
recommended: '推荐',
|
||||||
allModelsClaw: '用 USDC 按次付费,支持所有主流 AI 模型',
|
allModelsClaw: '用 USDC 按次付费,支持所有主流 AI 模型',
|
||||||
@@ -3601,8 +3611,13 @@ export const translations = {
|
|||||||
|
|
||||||
modelConfig: {
|
modelConfig: {
|
||||||
selectModel: 'Pilih Model',
|
selectModel: 'Pilih Model',
|
||||||
|
configure: 'Konfigurasi',
|
||||||
configureApi: 'Konfigurasi API',
|
configureApi: 'Konfigurasi API',
|
||||||
|
configureWallet: 'Konfigurasi Wallet',
|
||||||
chooseProvider: 'Pilih Penyedia AI Anda',
|
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',
|
payPerCall: 'Bayar per panggilan USDC · Semua Model AI · Tanpa API Key',
|
||||||
recommended: 'Terbaik',
|
recommended: 'Terbaik',
|
||||||
allModelsClaw: 'Bayar per panggilan dengan USDC — mendukung semua model AI utama',
|
allModelsClaw: 'Bayar per panggilan dengan USDC — mendukung semua model AI utama',
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import type {
|
|||||||
UpdateModelConfigRequest,
|
UpdateModelConfigRequest,
|
||||||
UpdateExchangeConfigRequest,
|
UpdateExchangeConfigRequest,
|
||||||
CreateExchangeRequest,
|
CreateExchangeRequest,
|
||||||
|
BeginnerOnboardingResponse,
|
||||||
|
CurrentBeginnerWalletResponse,
|
||||||
} from '../../types'
|
} from '../../types'
|
||||||
import { API_BASE, httpClient, CryptoService } from './helpers'
|
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')
|
if (!result.success) throw new Error('Failed to fetch server IP')
|
||||||
return result.data!
|
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
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -4,6 +4,12 @@ import { User, Cpu, Building2, MessageCircle, Eye, EyeOff, ChevronRight, Plus, P
|
|||||||
import { useAuth } from '../contexts/AuthContext'
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
import { useLanguage } from '../contexts/LanguageContext'
|
import { useLanguage } from '../contexts/LanguageContext'
|
||||||
import { api } from '../lib/api'
|
import { api } from '../lib/api'
|
||||||
|
import {
|
||||||
|
getPostAuthPath,
|
||||||
|
getUserMode,
|
||||||
|
setUserMode,
|
||||||
|
type UserMode,
|
||||||
|
} from '../lib/onboarding'
|
||||||
import { ExchangeConfigModal } from '../components/trader/ExchangeConfigModal'
|
import { ExchangeConfigModal } from '../components/trader/ExchangeConfigModal'
|
||||||
import { TelegramConfigModal } from '../components/trader/TelegramConfigModal'
|
import { TelegramConfigModal } from '../components/trader/TelegramConfigModal'
|
||||||
import { ModelConfigModal } from '../components/trader/ModelConfigModal'
|
import { ModelConfigModal } from '../components/trader/ModelConfigModal'
|
||||||
@@ -15,6 +21,7 @@ export function SettingsPage() {
|
|||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
const { language } = useLanguage()
|
const { language } = useLanguage()
|
||||||
const [activeTab, setActiveTab] = useState<Tab>('account')
|
const [activeTab, setActiveTab] = useState<Tab>('account')
|
||||||
|
const [userMode, setUserModeState] = useState<UserMode>(() => getUserMode() ?? 'advanced')
|
||||||
|
|
||||||
// Account state
|
// Account state
|
||||||
const [newPassword, setNewPassword] = useState('')
|
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 (
|
const handleSaveModel = async (
|
||||||
modelId: string,
|
modelId: string,
|
||||||
apiKey: string,
|
apiKey: string,
|
||||||
@@ -281,6 +308,66 @@ export function SettingsPage() {
|
|||||||
<p className="text-sm text-white font-medium">{user?.email}</p>
|
<p className="text-sm text-white font-medium">{user?.email}</p>
|
||||||
</div>
|
</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">
|
<div className="border-t border-zinc-800 pt-6">
|
||||||
<h3 className="text-sm font-semibold text-white mb-4">Change Password</h3>
|
<h3 className="text-sm font-semibold text-white mb-4">Change Password</h3>
|
||||||
<form onSubmit={handleChangePassword} className="space-y-4">
|
<form onSubmit={handleChangePassword} className="space-y-4">
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ import {
|
|||||||
Download,
|
Download,
|
||||||
Upload,
|
Upload,
|
||||||
Globe,
|
Globe,
|
||||||
X,
|
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import type { Strategy, StrategyConfig, AIModel } from '../types'
|
import type { Strategy, StrategyConfig, AIModel } from '../types'
|
||||||
import { confirmToast, notify } from '../lib/notify'
|
import { confirmToast, notify } from '../lib/notify'
|
||||||
@@ -39,10 +38,8 @@ import { RiskControlEditor } from '../components/strategy/RiskControlEditor'
|
|||||||
import { PromptSectionsEditor } from '../components/strategy/PromptSectionsEditor'
|
import { PromptSectionsEditor } from '../components/strategy/PromptSectionsEditor'
|
||||||
import { PublishSettingsEditor } from '../components/strategy/PublishSettingsEditor'
|
import { PublishSettingsEditor } from '../components/strategy/PublishSettingsEditor'
|
||||||
import { GridConfigEditor, defaultGridConfig } from '../components/strategy/GridConfigEditor'
|
import { GridConfigEditor, defaultGridConfig } from '../components/strategy/GridConfigEditor'
|
||||||
import { TokenEstimateBar } from '../components/strategy/TokenEstimateBar'
|
|
||||||
import { DeepVoidBackground } from '../components/common/DeepVoidBackground'
|
import { DeepVoidBackground } from '../components/common/DeepVoidBackground'
|
||||||
import { t } from '../i18n/translations'
|
import { t } from '../i18n/translations'
|
||||||
import { NofxSelect } from '../components/ui/select'
|
|
||||||
|
|
||||||
const API_BASE = import.meta.env.VITE_API_BASE || ''
|
const API_BASE = import.meta.env.VITE_API_BASE || ''
|
||||||
|
|
||||||
@@ -55,7 +52,6 @@ export function StrategyStudioPage() {
|
|||||||
const [editingConfig, setEditingConfig] = useState<StrategyConfig | null>(null)
|
const [editingConfig, setEditingConfig] = useState<StrategyConfig | null>(null)
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
const [isSaving, setIsSaving] = useState(false)
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
const [tokenOverflow, setTokenOverflow] = useState(false)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [hasChanges, setHasChanges] = useState(false)
|
const [hasChanges, setHasChanges] = useState(false)
|
||||||
|
|
||||||
@@ -382,10 +378,6 @@ export function StrategyStudioPage() {
|
|||||||
// Save strategy
|
// Save strategy
|
||||||
const handleSaveStrategy = async () => {
|
const handleSaveStrategy = async () => {
|
||||||
if (!token || !selectedStrategy || !editingConfig) return
|
if (!token || !selectedStrategy || !editingConfig) return
|
||||||
if (tokenOverflow && currentStrategyType === 'ai_trading') {
|
|
||||||
notify.error(tr('tokenExceedWarning'))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setIsSaving(true)
|
setIsSaving(true)
|
||||||
try {
|
try {
|
||||||
// Always sync the config language with the current interface language
|
// 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')
|
if (!response.ok) throw new Error('Failed to save strategy')
|
||||||
setHasChanges(false)
|
setHasChanges(false)
|
||||||
notify.success(tr('strategySaved'))
|
notify.success(tr('strategySaved'))
|
||||||
const savedId = selectedStrategy.id
|
|
||||||
await fetchStrategies()
|
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) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Unknown error')
|
setError(err instanceof Error ? err.message : 'Unknown error')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -659,7 +641,7 @@ export function StrategyStudioPage() {
|
|||||||
<Sparkles className="w-5 h-5 text-black" />
|
<Sparkles className="w-5 h-5 text-black" />
|
||||||
</div>
|
</div>
|
||||||
<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>
|
<p className="text-xs text-nofx-text-muted">{tr('subtitle')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -774,24 +756,34 @@ export function StrategyStudioPage() {
|
|||||||
{selectedStrategy && editingConfig ? (
|
{selectedStrategy && editingConfig ? (
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
{/* Strategy Name & Actions */}
|
{/* Strategy Name & Actions */}
|
||||||
<div className="mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
<input
|
||||||
<input
|
type="text"
|
||||||
type="text"
|
value={selectedStrategy.name}
|
||||||
value={selectedStrategy.name}
|
onChange={(e) => {
|
||||||
onChange={(e) => {
|
setSelectedStrategy({ ...selectedStrategy, name: e.target.value })
|
||||||
setSelectedStrategy({ ...selectedStrategy, name: e.target.value })
|
setHasChanges(true)
|
||||||
setHasChanges(true)
|
}}
|
||||||
}}
|
disabled={selectedStrategy.is_default}
|
||||||
disabled={selectedStrategy.is_default}
|
className="text-lg font-bold bg-transparent border-none outline-none w-full text-nofx-text placeholder-nofx-text-muted"
|
||||||
className="text-lg font-bold bg-transparent border-none outline-none flex-1 min-w-0 text-nofx-text placeholder-nofx-text-muted"
|
/>
|
||||||
/>
|
<input
|
||||||
{hasChanges && (
|
type="text"
|
||||||
<span className="text-xs text-nofx-gold whitespace-nowrap">● {tr('unsaved')}</span>
|
value={selectedStrategy.description || ''}
|
||||||
)}
|
onChange={(e) => {
|
||||||
</div>
|
setSelectedStrategy({ ...selectedStrategy, description: e.target.value })
|
||||||
<div className="flex items-center gap-2 flex-shrink-0 ml-2">
|
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 && (
|
||||||
|
<span className="text-xs text-nofx-gold">● {tr('unsaved')}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
{!selectedStrategy.is_active && (
|
{!selectedStrategy.is_active && (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleActivateStrategy(selectedStrategy.id)}
|
onClick={() => handleActivateStrategy(selectedStrategy.id)}
|
||||||
@@ -801,22 +793,10 @@ export function StrategyStudioPage() {
|
|||||||
{tr('activate')}
|
{tr('activate')}
|
||||||
</button>
|
</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 && (
|
{!selectedStrategy.is_default && (
|
||||||
<button
|
<button
|
||||||
onClick={handleSaveStrategy}
|
onClick={handleSaveStrategy}
|
||||||
disabled={isSaving || !hasChanges || (tokenOverflow && currentStrategyType === 'ai_trading')}
|
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
|
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'}`}
|
${hasChanges ? 'bg-nofx-gold text-black hover:bg-yellow-500' : 'bg-nofx-bg-lighter text-nofx-text-muted cursor-not-allowed'}`}
|
||||||
>
|
>
|
||||||
@@ -825,27 +805,8 @@ export function StrategyStudioPage() {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={selectedStrategy.description || ''}
|
|
||||||
onChange={(e) => {
|
|
||||||
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"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Token Estimate Bar */}
|
|
||||||
{currentStrategyType === 'ai_trading' && (
|
|
||||||
<div className="mb-4">
|
|
||||||
<TokenEstimateBar config={editingConfig} language={language} onOverflowChange={setTokenOverflow} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Strategy Type Selector */}
|
{/* Strategy Type Selector */}
|
||||||
{editingConfig && (
|
{editingConfig && (
|
||||||
<div className="mb-4 p-4 rounded-lg bg-nofx-bg-lighter border border-nofx-gold/20">
|
<div className="mb-4 p-4 rounded-lg bg-nofx-bg-lighter border border-nofx-gold/20">
|
||||||
@@ -857,12 +818,9 @@ export function StrategyStudioPage() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!selectedStrategy?.is_default) {
|
if (!selectedStrategy?.is_default) {
|
||||||
setEditingConfig(prev => prev ? {
|
updateConfig('strategy_type', 'ai_trading')
|
||||||
...prev,
|
// Clear grid config when switching to AI trading
|
||||||
strategy_type: 'ai_trading',
|
updateConfig('grid_config', undefined)
|
||||||
grid_config: undefined,
|
|
||||||
} : prev)
|
|
||||||
setHasChanges(true)
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
disabled={selectedStrategy?.is_default}
|
disabled={selectedStrategy?.is_default}
|
||||||
@@ -881,12 +839,11 @@ export function StrategyStudioPage() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!selectedStrategy?.is_default) {
|
if (!selectedStrategy?.is_default) {
|
||||||
setEditingConfig(prev => prev ? {
|
updateConfig('strategy_type', 'grid_trading')
|
||||||
...prev,
|
// Initialize grid config if not exists
|
||||||
strategy_type: 'grid_trading',
|
if (!editingConfig.grid_config) {
|
||||||
grid_config: prev.grid_config || defaultGridConfig,
|
updateConfig('grid_config', defaultGridConfig)
|
||||||
} : prev)
|
}
|
||||||
setHasChanges(true)
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
disabled={selectedStrategy?.is_default}
|
disabled={selectedStrategy?.is_default}
|
||||||
@@ -977,16 +934,15 @@ export function StrategyStudioPage() {
|
|||||||
<div className="p-3 space-y-3">
|
<div className="p-3 space-y-3">
|
||||||
{/* Controls */}
|
{/* Controls */}
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<NofxSelect
|
<select
|
||||||
value={selectedVariant}
|
value={selectedVariant}
|
||||||
onChange={(val) => setSelectedVariant(val)}
|
onChange={(e) => setSelectedVariant(e.target.value)}
|
||||||
options={[
|
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"
|
||||||
{ value: 'balanced', label: tr('balanced') },
|
>
|
||||||
{ value: 'aggressive', label: tr('aggressive') },
|
<option value="balanced">{tr('balanced')}</option>
|
||||||
{ value: 'conservative', label: tr('conservative') },
|
<option value="aggressive">{tr('aggressive')}</option>
|
||||||
]}
|
<option value="conservative">{tr('conservative')}</option>
|
||||||
className="px-2 py-1.5 rounded text-xs bg-nofx-bg border border-nofx-gold/20 text-nofx-text"
|
</select>
|
||||||
/>
|
|
||||||
<button
|
<button
|
||||||
onClick={fetchPromptPreview}
|
onClick={fetchPromptPreview}
|
||||||
disabled={isLoadingPrompt || !editingConfig}
|
disabled={isLoadingPrompt || !editingConfig}
|
||||||
@@ -1051,15 +1007,17 @@ export function StrategyStudioPage() {
|
|||||||
<span className="text-xs font-medium text-nofx-text">{tr('selectModel')}</span>
|
<span className="text-xs font-medium text-nofx-text">{tr('selectModel')}</span>
|
||||||
</div>
|
</div>
|
||||||
{aiModels.length > 0 ? (
|
{aiModels.length > 0 ? (
|
||||||
<NofxSelect
|
<select
|
||||||
value={selectedModelId}
|
value={selectedModelId}
|
||||||
onChange={(val) => setSelectedModelId(val)}
|
onChange={(e) => setSelectedModelId(e.target.value)}
|
||||||
options={aiModels.map((model) => ({
|
|
||||||
value: model.id,
|
|
||||||
label: `${model.name} (${model.provider})`,
|
|
||||||
}))}
|
|
||||||
className="w-full px-3 py-2 rounded-lg text-sm bg-nofx-bg border border-nofx-gold/20 text-nofx-text"
|
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">
|
<div className="px-3 py-2 rounded-lg text-sm bg-nofx-danger/10 text-nofx-danger">
|
||||||
{tr('noModel')}
|
{tr('noModel')}
|
||||||
@@ -1067,16 +1025,15 @@ export function StrategyStudioPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<NofxSelect
|
<select
|
||||||
value={selectedVariant}
|
value={selectedVariant}
|
||||||
onChange={(val) => setSelectedVariant(val)}
|
onChange={(e) => setSelectedVariant(e.target.value)}
|
||||||
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"
|
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
|
<button
|
||||||
onClick={runAiTest}
|
onClick={runAiTest}
|
||||||
disabled={isRunningAiTest || !editingConfig || !selectedModelId}
|
disabled={isRunningAiTest || !editingConfig || !selectedModelId}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ export interface AIModel {
|
|||||||
apiKey?: string
|
apiKey?: string
|
||||||
customApiUrl?: string
|
customApiUrl?: string
|
||||||
customModelName?: string
|
customModelName?: string
|
||||||
|
walletAddress?: string
|
||||||
|
balanceUsdc?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TelegramConfig {
|
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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user