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

* feat: add beginner onboarding and mode switching flow

* chore: ignore local gh auth config

* fix: restore kline fallback and align onboarding language

---------

Co-authored-by: zavier-bin <zhaobbbhhh@gmail.com>
This commit is contained in:
Zavier
2026-03-28 00:17:37 +08:00
committed by GitHub
parent 4ab4024628
commit b331733e23
22 changed files with 1504 additions and 253 deletions

View File

@@ -5,6 +5,8 @@ import { useAuth } from '../../contexts/AuthContext'
import { useLanguage } from '../../contexts/LanguageContext'
import { t } from '../../i18n/translations'
import { DeepVoidBackground } from '../common/DeepVoidBackground'
import { OnboardingModeSelector } from './OnboardingModeSelector'
import type { UserMode } from '../../lib/onboarding'
export function LoginPage() {
const { language } = useLanguage()
@@ -15,6 +17,7 @@ export function LoginPage() {
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const [expiredToastId, setExpiredToastId] = useState<string | number | null>(null)
const [mode, setMode] = useState<UserMode>('beginner')
useEffect(() => {
if (sessionStorage.getItem('from401') === 'true') {
@@ -28,7 +31,7 @@ export function LoginPage() {
e.preventDefault()
setError('')
setLoading(true)
const result = await login(email, password)
const result = await login(email, password, mode)
setLoading(false)
if (result.success) {
if (expiredToastId) toast.dismiss(expiredToastId)
@@ -109,6 +112,12 @@ export function LoginPage() {
</div>
</div>
<OnboardingModeSelector
language={language}
mode={mode}
onChange={setMode}
/>
{/* Error */}
{error && (
<p className="text-xs text-red-400 bg-red-500/10 border border-red-500/20 rounded-lg px-3 py-2">

View File

@@ -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>
)
}

View File

@@ -4,6 +4,12 @@ import { motion, AnimatePresence } from 'framer-motion'
import { Menu, X, ChevronDown, Settings } from 'lucide-react'
import { t, type Language } from '../../i18n/translations'
import { OFFICIAL_LINKS } from '../../constants/branding'
import {
getPostAuthPath,
getUserMode,
setUserMode,
type UserMode,
} from '../../lib/onboarding'
type Page =
| 'competition'
@@ -44,8 +50,21 @@ export default function HeaderBar({
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
const [languageDropdownOpen, setLanguageDropdownOpen] = useState(false)
const [userDropdownOpen, setUserDropdownOpen] = useState(false)
const [userMode, setUserModeState] = useState<UserMode>(() => getUserMode() ?? 'advanced')
const dropdownRef = useRef<HTMLDivElement>(null)
const userDropdownRef = useRef<HTMLDivElement>(null)
const navigateInApp = (path: string) => {
navigate(path)
window.dispatchEvent(new PopStateEvent('popstate'))
}
const handleSwitchMode = (nextMode: UserMode) => {
setUserMode(nextMode)
setUserModeState(nextMode)
setUserDropdownOpen(false)
navigateInApp(getPostAuthPath(nextMode))
}
// Close dropdown when clicking outside
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
@@ -216,6 +235,15 @@ export default function HeaderBar({
<Settings className="w-3.5 h-3.5" />
Settings
</button>
<button
onClick={() => handleSwitchMode(userMode === 'beginner' ? 'advanced' : 'beginner')}
className="w-full flex items-center gap-2 px-3 py-2 text-sm transition-colors hover:bg-white/5 text-nofx-text-muted hover:text-white"
>
<Settings className="w-3.5 h-3.5" />
{userMode === 'beginner'
? language === 'zh' ? '切到老手模式' : 'Switch to Advanced'
: language === 'zh' ? '切到新手模式' : 'Switch to Beginner'}
</button>
{onLogout && (
<button
onClick={() => {

View File

@@ -3,14 +3,19 @@ import { Eye, EyeOff } from 'lucide-react'
import { useAuth } from '../../contexts/AuthContext'
import { DeepVoidBackground } from '../common/DeepVoidBackground'
import { invalidateSystemConfig } from '../../lib/config'
import { OnboardingModeSelector } from '../auth/OnboardingModeSelector'
import type { UserMode } from '../../lib/onboarding'
import { useLanguage } from '../../contexts/LanguageContext'
export function SetupPage() {
const { language } = useLanguage()
const { register } = useAuth()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [showPassword, setShowPassword] = useState(false)
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const [mode, setMode] = useState<UserMode>('beginner')
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
@@ -20,11 +25,10 @@ export function SetupPage() {
return
}
setLoading(true)
const result = await register(email, password)
const result = await register(email, password, undefined, mode)
setLoading(false)
if (result.success) {
invalidateSystemConfig()
window.location.href = '/traders'
} else {
setError(result.message || 'Setup failed, please try again')
}
@@ -87,6 +91,12 @@ export function SetupPage() {
</div>
</div>
<OnboardingModeSelector
language={language}
mode={mode}
onChange={setMode}
/>
{/* Error */}
{error && (
<p className="text-xs text-red-400 bg-red-500/10 border border-red-500/20 rounded-lg px-3 py-2">

View File

@@ -18,6 +18,7 @@ import { TelegramConfigModal } from './TelegramConfigModal'
import { ModelConfigModal } from './ModelConfigModal'
import { ConfigStatusGrid } from './ConfigStatusGrid'
import { TradersList } from './TradersList'
import { BeginnerGuideCards } from './BeginnerGuideCards'
import {
Bot,
Plus,
@@ -25,6 +26,12 @@ import {
} from 'lucide-react'
import { confirmToast } from '../../lib/notify'
import { toast } from 'sonner'
import {
getBeginnerWalletAddress,
getUserMode,
setBeginnerWalletAddress as persistBeginnerWalletAddress,
} from '../../lib/onboarding'
import type { Strategy } from '../../types'
interface AITradersPageProps {
onTraderSelect?: (traderId: string) => void
@@ -48,6 +55,14 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
const [visibleTraderAddresses, setVisibleTraderAddresses] = useState<Set<string>>(new Set())
const [visibleExchangeAddresses, setVisibleExchangeAddresses] = useState<Set<string>>(new Set())
const [copiedId, setCopiedId] = useState<string | null>(null)
const [quickSetupLoading, setQuickSetupLoading] = useState(false)
const [beginnerWalletAddress, setBeginnerWalletAddress] = useState<string | null>(() => getBeginnerWalletAddress())
const isBeginnerMode = getUserMode() === 'beginner'
const navigateInApp = (path: string) => {
navigate(path)
window.dispatchEvent(new PopStateEvent('popstate'))
}
// Toggle wallet address visibility for a trader
const toggleTraderAddressVisibility = (traderId: string) => {
@@ -91,6 +106,11 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
api.getTraders,
{ refreshInterval: 5000 }
)
const { data: strategies } = useSWR<Strategy[]>(
user && token ? 'strategies' : null,
api.getStrategies,
{ refreshInterval: 30000 }
)
useEffect(() => {
const loadConfigs = async () => {
@@ -115,6 +135,12 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
api.getSupportedModels(),
])
setAllModels(modelConfigs)
const clawWalletAddress =
modelConfigs.find((model) => model.provider === 'claw402')?.walletAddress || null
if (clawWalletAddress) {
setBeginnerWalletAddress(clawWalletAddress)
persistBeginnerWalletAddress(clawWalletAddress)
}
setAllExchanges(exchangeConfigs)
setSupportedModels(models)
} catch (error) {
@@ -616,6 +642,36 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
setShowExchangeModal(true)
}
const handleQuickSetupClaw402 = async () => {
if (quickSetupLoading) return
try {
setQuickSetupLoading(true)
const result = await api.prepareBeginnerOnboarding()
setBeginnerWalletAddress(result.address)
const refreshedModels = await api.getModelConfigs()
setAllModels(refreshedModels)
toast.success(
language === 'zh'
? 'Claw402 已默认配置为 DeepSeek'
: 'Claw402 is configured with DeepSeek by default'
)
} catch (error) {
console.error('Failed to quick setup claw402:', error)
toast.error(
language === 'zh'
? '一键配置 Claw402 失败'
: 'Failed to quick setup Claw402'
)
} finally {
setQuickSetupLoading(false)
}
}
const claw402Configured = configuredModels.some((model) => model.provider === 'claw402')
const hasStrategies = (strategies?.length || 0) > 0
const canCreateTrader = configuredModels.length > 0 && configuredExchanges.length > 0
return (
<DeepVoidBackground className="py-8" disableAnimation>
<div className="w-full px-4 md:px-8 space-y-8 animate-fade-in">
@@ -687,6 +743,21 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
</div>
</div>
{isBeginnerMode ? (
<BeginnerGuideCards
language={language}
claw402Ready={claw402Configured}
exchangeReady={configuredExchanges.length > 0}
strategyReady={hasStrategies}
canCreateTrader={canCreateTrader}
walletAddress={beginnerWalletAddress}
onQuickSetupClaw402={handleQuickSetupClaw402}
onOpenExchange={handleAddExchange}
onOpenStrategy={() => navigateInApp('/strategy')}
onCreateTrader={() => setShowCreateModal(true)}
/>
) : null}
{/* Configuration Status Grid */}
<ConfigStatusGrid
configuredModels={configuredModels}
@@ -715,7 +786,7 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
copiedId={copiedId}
language={language}
onTraderSelect={onTraderSelect}
onNavigate={(path) => navigate(path)}
onNavigate={navigateInApp}
onEditTrader={handleEditTrader}
onToggleTrader={handleToggleTrader}
onToggleCompetition={handleToggleCompetition}

View File

@@ -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>
)
}

View File

@@ -92,6 +92,20 @@ export function ConfigStatusGrid({
<div className="text-[10px] text-zinc-500 font-mono flex items-center gap-2">
{model.customModelName || AI_PROVIDER_CONFIG[model.provider]?.defaultModel || ''}
</div>
{model.provider === 'claw402' && (model.balanceUsdc || model.walletAddress) ? (
<div className="mt-1.5 flex flex-wrap items-center gap-2 text-[10px] font-mono">
{model.balanceUsdc ? (
<span className="rounded border border-emerald-500/20 bg-emerald-500/10 px-1.5 py-0.5 text-emerald-400">
{model.balanceUsdc} USDC
</span>
) : null}
{model.walletAddress ? (
<span className="rounded border border-sky-500/20 bg-sky-500/10 px-1.5 py-0.5 text-sky-400">
{truncateAddress(model.walletAddress)}
</span>
) : null}
</div>
) : null}
</div>
</div>

View File

@@ -4,6 +4,7 @@ import { Trash2, Brain, ExternalLink } from 'lucide-react'
import type { AIModel } from '../../types'
import type { Language } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import { api } from '../../lib/api'
import { getModelIcon } from '../common/ModelIcons'
import { ModelStepIndicator } from './ModelStepIndicator'
import { ModelCard } from './ModelCard'
@@ -12,6 +13,7 @@ import {
AI_PROVIDER_CONFIG,
getShortName,
} from './model-constants'
import { getBeginnerWalletAddress } from '../../lib/onboarding'
interface ModelConfigModalProps {
allModels: AIModel[]
@@ -42,20 +44,22 @@ export function ModelConfigModal({
const [apiKey, setApiKey] = useState('')
const [baseUrl, setBaseUrl] = useState('')
const [modelName, setModelName] = useState('')
const configuredModel =
configuredModels?.find((model) => model.id === selectedModelId) || null
// Always prefer allModels (supportedModels) for provider/id lookup;
// fall back to configuredModels for edit mode details (apiKey etc.)
const selectedModel =
allModels?.find((m) => m.id === selectedModelId) ||
configuredModels?.find((m) => m.id === selectedModelId)
allModels?.find((m) => m.id === selectedModelId) || configuredModel
useEffect(() => {
if (editingModelId && selectedModel) {
setApiKey(selectedModel.apiKey || '')
setBaseUrl(selectedModel.customApiUrl || '')
setModelName(selectedModel.customModelName || '')
const modelDetails = configuredModel || selectedModel
if (editingModelId && modelDetails) {
setApiKey(modelDetails.apiKey || '')
setBaseUrl(modelDetails.customApiUrl || '')
setModelName(modelDetails.customModelName || '')
}
}, [editingModelId, selectedModel])
}, [editingModelId, configuredModel, selectedModel])
const handleSelectModel = (modelId: string) => {
setSelectedModelId(modelId)
@@ -79,7 +83,18 @@ export function ModelConfigModal({
const availableModels = allModels || []
const configuredIds = new Set(configuredModels?.map(m => m.id) || [])
const stepLabels = [t('modelConfig.selectModel', language), t('modelConfig.configureApi', language)]
const isClaw402Selected = selectedModel?.provider === 'claw402' || selectedModel?.id === 'claw402'
const stepLabels = [
t('modelConfig.selectModel', language),
t(
!selectedModel
? 'modelConfig.configure'
: isClaw402Selected
? 'modelConfig.configureWallet'
: 'modelConfig.configure',
language
),
]
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4 overflow-y-auto backdrop-blur-sm">
@@ -143,6 +158,7 @@ export function ModelConfigModal({
<Claw402ConfigForm
apiKey={apiKey}
modelName={modelName}
configuredModel={configuredModel}
editingModelId={editingModelId}
onApiKeyChange={setApiKey}
onModelNameChange={setModelName}
@@ -189,6 +205,10 @@ function ModelSelectionStep({
onSelectModel: (modelId: string) => void
language: Language
}) {
const [showOtherProviders, setShowOtherProviders] = useState(false)
const claw402Model = availableModels.find((m) => m.provider === 'claw402')
const otherProviders = availableModels.filter((m) => m.provider !== 'claw402')
return (
<div className="space-y-4">
<div className="text-sm font-semibold" style={{ color: '#EAECEF' }}>
@@ -196,12 +216,11 @@ function ModelSelectionStep({
</div>
{/* Claw402 Featured Card */}
{availableModels.some(m => m.provider === 'claw402') && (
{claw402Model && (
<button
type="button"
onClick={() => {
const claw = availableModels.find(m => m.provider === 'claw402')
if (claw) onSelectModel(claw.id)
onSelectModel(claw402Model.id)
}}
className="w-full p-5 rounded-xl text-left transition-all hover:scale-[1.01]"
style={{ background: 'linear-gradient(135deg, rgba(37, 99, 235, 0.15) 0%, rgba(139, 92, 246, 0.15) 100%)', border: '1.5px solid rgba(37, 99, 235, 0.4)' }}
@@ -222,7 +241,7 @@ function ModelSelectionStep({
</div>
</div>
<div className="flex items-center gap-2">
{configuredIds.has(availableModels.find(m => m.provider === 'claw402')?.id || '') && (
{configuredIds.has(claw402Model.id) && (
<div className="w-2 h-2 rounded-full" style={{ background: '#00E096' }} />
)}
<div className="px-3 py-1.5 rounded-full text-xs font-bold" style={{ background: 'linear-gradient(135deg, #2563EB, #7C3AED)', color: '#fff' }}>
@@ -235,23 +254,57 @@ function ModelSelectionStep({
GPT · Claude · DeepSeek · Gemini · Grok · Qwen · Kimi
</span>
</div>
<div className="mt-4 ml-[52px] text-[11px]" style={{ color: '#A0AEC0' }}>
{t('modelConfig.claw402EntryDesc', language)}
</div>
</button>
)}
<div className="grid grid-cols-3 sm:grid-cols-4 gap-3">
{availableModels.filter(m => m.provider !== 'claw402').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-2" style={{ color: '#848E9C' }}>
{t('modelConfig.modelsConfigured', language)}
</div>
{otherProviders.length > 0 && (
<div className="rounded-xl border border-white/10 bg-black/20 overflow-hidden">
<button
type="button"
onClick={() => setShowOtherProviders((prev) => !prev)}
className="w-full flex items-center justify-between px-4 py-4 text-left transition-all hover:bg-white/5"
>
<div>
<div className="text-sm font-semibold" style={{ color: '#EAECEF' }}>
{t('modelConfig.otherApiEntry', language)}
</div>
<div className="mt-1 text-xs" style={{ color: '#848E9C' }}>
{t('modelConfig.otherApiEntryDesc', language)}
</div>
</div>
<div className="flex items-center gap-3">
<span className="rounded-full border border-white/10 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.2em]" style={{ color: '#A0AEC0' }}>
{otherProviders.length} API
</span>
<span className="text-sm" style={{ color: '#60A5FA' }}>
{showOtherProviders ? '' : '+'}
</span>
</div>
</button>
{showOtherProviders && (
<div className="border-t border-white/5 px-4 py-4">
<div className="grid grid-cols-3 sm:grid-cols-4 gap-3">
{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>
)
}
@@ -259,6 +312,7 @@ function ModelSelectionStep({
function Claw402ConfigForm({
apiKey,
modelName,
configuredModel,
editingModelId,
onApiKeyChange,
onModelNameChange,
@@ -268,6 +322,7 @@ function Claw402ConfigForm({
}: {
apiKey: string
modelName: string
configuredModel: AIModel | null
editingModelId: string | null
onApiKeyChange: (value: string) => void
onModelNameChange: (value: string) => void
@@ -278,14 +333,21 @@ function Claw402ConfigForm({
const [walletAddress, setWalletAddress] = useState('')
const [copiedAddr, setCopiedAddr] = useState(false)
const [showDeposit, setShowDeposit] = useState(false)
const [showNewWalletBackup, setShowNewWalletBackup] = useState(false)
const [newWalletKey, setNewWalletKey] = useState('')
const [usdcBalance, setUsdcBalance] = useState<string | null>(null)
const [keyError, setKeyError] = useState('')
const [validating, setValidating] = useState(false)
const [claw402Status, setClaw402Status] = useState<string | null>(null)
const [testResult, setTestResult] = useState<{ status: string; message: string } | null>(null)
const [testing, setTesting] = useState(false)
const [serverWalletAddress, setServerWalletAddress] = useState('')
const [serverWalletBalance, setServerWalletBalance] = useState<string | null>(null)
const localWalletAddress = getBeginnerWalletAddress()?.trim() || ''
const configuredWalletAddress =
configuredModel?.walletAddress?.trim() || localWalletAddress || serverWalletAddress
const resolvedWalletAddress = walletAddress || configuredWalletAddress
const resolvedUsdcBalance =
usdcBalance ?? configuredModel?.balanceUsdc ?? serverWalletBalance ?? null
const hasExistingWallet = Boolean(configuredWalletAddress)
// Client-side validation helper
const getClientError = (key: string): string => {
@@ -298,8 +360,36 @@ function Claw402ConfigForm({
const isKeyValid = apiKey.length === 66 && apiKey.startsWith('0x') && /^0x[0-9a-fA-F]{64}$/.test(apiKey)
// Truncate address for display
useEffect(() => {
if (hasExistingWallet) {
setShowDeposit(true)
}
}, [hasExistingWallet])
useEffect(() => {
if (configuredModel?.walletAddress || localWalletAddress || serverWalletAddress) {
return
}
let cancelled = false
void api
.getCurrentBeginnerWallet()
.then((result) => {
setClaw402Status(result.claw402_status || 'unknown')
if (cancelled || !result.found || !result.address) {
return
}
setServerWalletAddress(result.address)
setServerWalletBalance(result.balance_usdc || null)
})
.catch(() => {
// Ignore silently: this is a best-effort fallback for showing the current wallet.
})
return () => {
cancelled = true
}
}, [configuredModel?.walletAddress, localWalletAddress, serverWalletAddress])
// Debounced validation when apiKey changes
useEffect(() => {
@@ -347,6 +437,23 @@ function Claw402ConfigForm({
setTesting(true)
setTestResult(null)
try {
if (!apiKey && hasExistingWallet) {
const result = await api.getCurrentBeginnerWallet()
setClaw402Status(result.claw402_status || 'unknown')
if (result.found && result.address) {
setWalletAddress(result.address)
setUsdcBalance(result.balance_usdc || '0.00')
setShowDeposit(true)
}
setTestResult({
status: result.claw402_status === 'ok' ? 'ok' : 'error',
message: result.claw402_status === 'ok'
? t('modelConfig.claw402Connected', language)
: t('modelConfig.claw402Unreachable', language),
})
return
}
const res = await fetch('/api/wallet/validate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -374,7 +481,7 @@ function Claw402ConfigForm({
}
}
const balanceNum = usdcBalance ? parseFloat(usdcBalance) : 0
const balanceNum = resolvedUsdcBalance ? parseFloat(resolvedUsdcBalance) : 0
return (
<form onSubmit={onSubmit} className="space-y-5">
@@ -396,6 +503,25 @@ function Claw402ConfigForm({
</span>
))}
</div>
<div className="mt-4 flex items-center justify-center gap-3 flex-wrap">
<button
type="button"
onClick={handleTestConnection}
disabled={testing || (!hasExistingWallet && !isKeyValid)}
className="inline-flex items-center gap-2 rounded-xl px-4 py-2 text-xs font-semibold transition-all hover:scale-[1.02] disabled:cursor-not-allowed disabled:opacity-50"
style={{ background: 'rgba(37, 99, 235, 0.15)', border: '1px solid rgba(37, 99, 235, 0.3)', color: '#60A5FA' }}
>
<span>🔗</span>
{testing ? t('modelConfig.testingConnection', language) : t('modelConfig.testConnection', language)}
</button>
{claw402Status ? (
<div className="text-xs" style={{ color: claw402Status === 'ok' ? '#00E096' : '#F59E0B' }}>
{claw402Status === 'ok'
? t('modelConfig.claw402Connected', language)
: t('modelConfig.claw402Unreachable', language)}
</div>
) : null}
</div>
</div>
{/* Step 1: Select AI Model */}
@@ -467,6 +593,33 @@ function Claw402ConfigForm({
</div>
</div>
{hasExistingWallet && (
<div className="p-3 rounded-xl" style={{ background: 'rgba(0, 224, 150, 0.05)', border: '1px solid rgba(0, 224, 150, 0.18)' }}>
<div className="text-xs font-semibold mb-1.5" style={{ color: '#00E096' }}>
{language === 'zh' ? '已自动提取当前钱包' : 'Current wallet loaded automatically'}
</div>
<div className="text-[11px] leading-5" style={{ color: '#A0AEC0' }}>
{language === 'zh'
? '你现在可以直接查看当前钱包地址、余额和充值二维码。只有在想更换钱包时,才需要重新输入新的私钥。'
: 'You can view the current wallet address, balance, and deposit QR code right away. Only enter a new private key if you want to replace this wallet.'}
</div>
{!configuredModel?.walletAddress && localWalletAddress ? (
<div className="mt-2 text-[10px]" style={{ color: '#848E9C' }}>
{language === 'zh'
? '当前地址来自本地已保存的新手钱包。'
: 'This address comes from the locally saved beginner wallet.'}
</div>
) : null}
{!configuredModel?.walletAddress && !localWalletAddress && serverWalletAddress ? (
<div className="mt-2 text-[10px]" style={{ color: '#848E9C' }}>
{language === 'zh'
? '当前地址来自后端保存的钱包配置。'
: 'This address comes from the wallet saved on the server.'}
</div>
) : null}
</div>
)}
<div className="space-y-1.5">
<div className="text-xs font-medium" style={{ color: '#A0AEC0' }}>
{t('modelConfig.walletPrivateKey', language)}
@@ -476,72 +629,30 @@ function Claw402ConfigForm({
type="password"
value={apiKey}
onChange={(e) => onApiKeyChange(e.target.value)}
placeholder="0x..."
placeholder={
hasExistingWallet
? language === 'zh'
? '如需切换钱包,请手动输入新的私钥'
: 'Enter a new private key only if you want to switch wallets'
: '0x...'
}
className="flex-1 px-4 py-3 rounded-xl font-mono text-sm"
style={{
background: '#0B0E11',
border: keyError ? '1px solid #EF4444' : walletAddress ? '1px solid #00E096' : '1px solid #2B3139',
color: '#EAECEF',
}}
required
required={!hasExistingWallet}
/>
{!apiKey && (
<button
type="button"
onClick={async () => {
try {
const res = await fetch('/api/wallet/generate', { method: 'POST' })
const data = await res.json()
if (data.private_key) {
onApiKeyChange(data.private_key)
setShowNewWalletBackup(true)
setNewWalletKey(data.private_key)
}
} catch { /* ignore */ }
}}
className="shrink-0 px-3 py-3 rounded-xl text-xs font-semibold transition-all hover:scale-[1.02]"
style={{ background: 'linear-gradient(135deg, #2563EB, #7C3AED)', color: '#fff', border: 'none', cursor: 'pointer' }}
>
{language === 'zh' ? '🔑 创建钱包' : '🔑 Create Wallet'}
</button>
)}
</div>
{/* New wallet backup warning */}
{showNewWalletBackup && newWalletKey && (
<div className="p-3 rounded-xl" style={{ background: 'rgba(239, 68, 68, 0.08)', border: '1px solid rgba(239, 68, 68, 0.3)' }}>
<div className="text-xs font-bold mb-2" style={{ color: '#EF4444' }}>
🚨 {language === 'zh' ? '重要:请立即备份私钥!' : 'Important: Backup your private key NOW!'}
</div>
<div className="text-[11px] mb-2" style={{ color: '#F87171' }}>
{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>
{hasExistingWallet && !apiKey ? (
<div className="text-[11px] leading-5" style={{ color: '#848E9C' }}>
{language === 'zh'
? '后续这里只使用你第一次创建并保存的钱包;如果你要换钱包,请手动填写新的私钥。'
: 'This screen keeps using the wallet created and saved the first time. Enter a new private key manually only if you want to switch wallets.'}
</div>
)}
) : null}
<div className="flex items-start gap-1.5 text-[11px]" style={{ color: '#848E9C' }}>
<span className="mt-px">🔒</span>
@@ -552,7 +663,7 @@ function Claw402ConfigForm({
</div>
{/* Wallet Validation Results */}
{apiKey && (
{(apiKey || hasExistingWallet) && (
<div className="space-y-2 pl-1">
{/* Validating spinner */}
{validating && (
@@ -571,7 +682,7 @@ function Claw402ConfigForm({
)}
{/* Success: address + balance + status */}
{walletAddress && !validating && !keyError && (
{resolvedWalletAddress && !validating && !keyError && (
<>
<div className="p-2.5 rounded-lg" style={{ background: 'rgba(96,165,250,0.06)', border: '1px solid rgba(96,165,250,0.15)' }}>
<div className="flex items-center justify-between mb-1">
@@ -581,7 +692,7 @@ function Claw402ConfigForm({
<button
type="button"
onClick={() => {
navigator.clipboard.writeText(walletAddress)
navigator.clipboard.writeText(resolvedWalletAddress)
setCopiedAddr(true)
setTimeout(() => setCopiedAddr(false), 2000)
}}
@@ -591,16 +702,16 @@ function Claw402ConfigForm({
{copiedAddr ? '✅' : '📋'}
</button>
</div>
<code className="text-[11px] font-mono block select-all" style={{ color: '#60A5FA' }}>{walletAddress}</code>
<code className="text-[11px] font-mono block select-all" style={{ color: '#60A5FA' }}>{resolvedWalletAddress}</code>
<div className="text-[10px] mt-1.5" style={{ color: '#F59E0B' }}>
{language === 'zh' ? '请确认这是你的钱包地址(可在 MetaMask 中核对)' : 'Please confirm this is your wallet address (verify in MetaMask)'}
</div>
</div>
{usdcBalance !== null && (
{resolvedUsdcBalance !== null && (
<div className="flex items-center gap-2 text-xs">
<span>💰</span>
<span style={{ color: balanceNum > 0 ? '#00E096' : '#F59E0B' }}>
{t('modelConfig.usdcBalance', language)}: ${usdcBalance}
{t('modelConfig.usdcBalance', language)}: ${resolvedUsdcBalance}
</span>
<button
type="button"
@@ -621,17 +732,17 @@ function Claw402ConfigForm({
</div>
<div className="flex gap-3 items-start mb-3">
<div className="shrink-0 p-1.5 rounded-lg" style={{ background: '#fff' }}>
<QRCodeSVG value={walletAddress} size={80} level="M" />
<QRCodeSVG value={resolvedWalletAddress} size={80} level="M" />
</div>
<div className="flex-1 min-w-0">
<div className="text-[11px] mb-1" style={{ color: '#A0AEC0' }}>
{language === 'zh' ? '扫码或复制地址转账' : 'Scan QR or copy address to transfer'}
</div>
<code className="text-[10px] font-mono break-all select-all block mb-1.5" style={{ color: '#60A5FA' }}>{walletAddress}</code>
<code className="text-[10px] font-mono break-all select-all block mb-1.5" style={{ color: '#60A5FA' }}>{resolvedWalletAddress}</code>
<button
type="button"
onClick={() => {
navigator.clipboard.writeText(walletAddress)
navigator.clipboard.writeText(resolvedWalletAddress)
setCopiedAddr(true)
setTimeout(() => setCopiedAddr(false), 2000)
}}
@@ -650,6 +761,13 @@ function Claw402ConfigForm({
</div>
</div>
)}
{!apiKey && hasExistingWallet && (
<div className="text-[11px]" style={{ color: '#848E9C' }}>
{language === 'zh'
? '当前正在使用这个钱包充值。若要切换钱包,再输入新的私钥并保存即可。'
: 'This wallet is currently used for funding. Enter a new private key only if you want to switch wallets.'}
</div>
)}
{claw402Status && (
<div className="flex items-center gap-2 text-xs" style={{ color: claw402Status === 'ok' ? '#00E096' : '#EF4444' }}>
<span>{claw402Status === 'ok' ? '🟢' : '🔴'}</span>
@@ -662,11 +780,11 @@ function Claw402ConfigForm({
)}
{/* Test Connection button */}
{isKeyValid && !validating && (
{(isKeyValid || hasExistingWallet) && !validating && (
<button
type="button"
onClick={handleTestConnection}
disabled={testing}
disabled={testing || (!hasExistingWallet && !isKeyValid)}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all hover:scale-[1.02] disabled:opacity-50"
style={{ background: 'rgba(37, 99, 235, 0.15)', border: '1px solid rgba(37, 99, 235, 0.3)', color: '#60A5FA' }}
>