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 shinchan-zhai
parent 39b05b1a09
commit b9a33ce809
22 changed files with 1504 additions and 253 deletions

View File

@@ -0,0 +1,264 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { Copy, Eye, EyeOff, RefreshCw, Shield, Wallet, Sparkles } from 'lucide-react'
import { QRCodeSVG } from 'qrcode.react'
import { toast } from 'sonner'
import { DeepVoidBackground } from '../components/common/DeepVoidBackground'
import { useLanguage } from '../contexts/LanguageContext'
import { api } from '../lib/api'
import type { BeginnerOnboardingResponse } from '../types'
import { setBeginnerWalletAddress } from '../lib/onboarding'
export function BeginnerOnboardingPage() {
const { language } = useLanguage()
const [data, setData] = useState<BeginnerOnboardingResponse | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [showPrivateKey, setShowPrivateKey] = useState(false)
const [refreshingBalance, setRefreshingBalance] = useState(false)
const hasRequestedRef = useRef(false)
const isZh = language === 'zh'
const loadOnboarding = async (showLoading: boolean) => {
if (showLoading) {
setLoading(true)
} else {
setRefreshingBalance(true)
}
setError('')
try {
const result = await api.prepareBeginnerOnboarding()
setData(result)
setBeginnerWalletAddress(result.address)
} catch (err) {
setError(
err instanceof Error
? err.message
: isZh
? '新手钱包准备失败'
: 'Failed to prepare beginner wallet'
)
} finally {
if (showLoading) {
setLoading(false)
} else {
setRefreshingBalance(false)
}
}
}
useEffect(() => {
if (hasRequestedRef.current) {
return
}
hasRequestedRef.current = true
void loadOnboarding(true)
}, [])
const hints = useMemo(
() =>
isZh
? [
'这是你的专属 Base 钱包,只用于后续调用大模型。',
'请保存私钥。丢失后无法恢复。',
'只往这个地址充值 Base 链 USDC不要充到别的链。',
]
: [
'This dedicated Base wallet is only used to pay for model calls.',
'Save the private key now. It cannot be recovered later.',
'Deposit USDC on Base only. Do not send funds from another chain.',
],
[isZh]
)
const copyText = async (value: string, label: string) => {
try {
await navigator.clipboard.writeText(value)
toast.success(isZh ? `${label}已复制` : `${label} copied`)
} catch {
toast.error(isZh ? '复制失败' : 'Copy failed')
}
}
const handleContinue = () => {
window.history.pushState({}, '', '/traders')
window.dispatchEvent(new PopStateEvent('popstate'))
}
return (
<DeepVoidBackground disableAnimation>
<div className="mx-auto flex min-h-screen max-w-5xl items-center px-4 py-12">
<div className="grid w-full gap-8 lg:grid-cols-[1.05fr_0.95fr]">
<section className="rounded-[28px] border border-white/10 bg-zinc-950/70 p-8 shadow-2xl backdrop-blur-xl">
<div className="mb-6 flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-nofx-gold/15 text-nofx-gold">
<Shield className="h-6 w-6" />
</div>
<div>
<div className="text-xs font-semibold uppercase tracking-[0.28em] text-nofx-gold/80">
{isZh ? '新手保护' : 'Beginner Guard'}
</div>
<h1 className="mt-1 text-3xl font-bold text-white">
{isZh ? '钱包已经帮你准备好了' : 'Your wallet is ready'}
</h1>
</div>
</div>
<p className="max-w-xl text-sm leading-7 text-zinc-300">
{isZh
? '我们已经为你生成了一个专属钱包,并默认接入 Claw402 + DeepSeek。你现在只需要保存私钥然后往这个地址充值 Base 链 USDC后面调用大模型时会自动从这里扣费。'
: 'We generated a dedicated wallet for you and preconfigured Claw402 + DeepSeek. Save the private key, then deposit Base USDC to this address so future model calls can be paid automatically.'}
</p>
<div className="mt-6 grid gap-3">
{hints.map((hint) => (
<div
key={hint}
className="flex items-start gap-3 rounded-2xl border border-white/8 bg-white/5 px-4 py-3"
>
<Sparkles className="mt-0.5 h-4 w-4 shrink-0 text-nofx-gold" />
<div className="text-sm leading-6 text-zinc-300">{hint}</div>
</div>
))}
</div>
<div className="mt-8 rounded-[24px] border border-sky-500/20 bg-sky-500/5 p-5">
<div className="flex items-center gap-2 text-sm font-semibold text-sky-300">
<Wallet className="h-4 w-4" />
<span>{isZh ? '为什么要充值?' : 'Why fund this wallet?'}</span>
</div>
<p className="mt-2 text-sm leading-6 text-zinc-300">
{isZh
? '这里只负责大模型调用费用,不会自动替你充值交易所。先充少量 USDC 就够了,通常 $5-$10 可以用很久。'
: 'This wallet only covers LLM usage costs. It does not fund your exchange automatically. A small amount of USDC is enough to get started, usually $5-$10.'}
</p>
</div>
{error ? (
<div className="mt-6 rounded-2xl border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-300">
{error}
</div>
) : null}
</section>
<section className="rounded-[28px] border border-white/10 bg-black/60 p-8 shadow-2xl backdrop-blur-xl">
{loading ? (
<div className="flex min-h-[420px] items-center justify-center text-sm text-zinc-400">
{isZh ? '正在准备你的 Base 钱包...' : 'Preparing your Base wallet...'}
</div>
) : data ? (
<div className="space-y-6">
<div className="rounded-[24px] border border-white/10 bg-white/5 p-5">
<div className="text-xs uppercase tracking-[0.28em] text-zinc-500">
{isZh ? '默认模型' : 'Default Model'}
</div>
<div className="mt-2 text-2xl font-bold text-white">Claw402 + DeepSeek</div>
<div className="mt-2 text-sm text-zinc-400">
{isZh ? '按次付费,无需 API Key' : 'Pay per call, no API key needed'}
</div>
</div>
<div className="rounded-[24px] border border-white/10 bg-white p-5 text-center">
<div className="inline-flex rounded-2xl bg-white p-3">
<QRCodeSVG value={data.address} size={180} level="M" />
</div>
<div className="mt-4 text-sm font-semibold text-zinc-900">
{isZh ? '充值地址Base 链 USDC' : 'Deposit Address (Base USDC)'}
</div>
<div className="mt-2 break-all rounded-2xl bg-zinc-100 px-3 py-3 font-mono text-xs text-zinc-700">
{data.address}
</div>
<button
type="button"
onClick={() => copyText(data.address, isZh ? '地址' : 'Address')}
className="mt-3 inline-flex items-center gap-2 rounded-xl bg-zinc-900 px-4 py-2 text-sm font-semibold text-white transition hover:bg-zinc-800"
>
<Copy className="h-4 w-4" />
{isZh ? '复制地址' : 'Copy address'}
</button>
<div className="mt-4 rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-left">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-[11px] font-semibold uppercase tracking-[0.22em] text-emerald-700">
{isZh ? '当前余额' : 'Current Balance'}
</div>
<div className="mt-1 text-2xl font-bold text-emerald-900">
{data.balance_usdc} USDC
</div>
<div className="mt-1 text-xs text-emerald-700/80">
{isZh ? 'Base 链钱包余额' : 'Base wallet balance'}
</div>
</div>
<button
type="button"
onClick={() => void loadOnboarding(false)}
disabled={refreshingBalance}
className="inline-flex items-center gap-2 rounded-xl border border-emerald-300 bg-white px-3 py-2 text-xs font-semibold text-emerald-800 transition hover:bg-emerald-100 disabled:cursor-not-allowed disabled:opacity-60"
>
<RefreshCw className={`h-3.5 w-3.5 ${refreshingBalance ? 'animate-spin' : ''}`} />
{isZh ? '刷新余额' : 'Refresh'}
</button>
</div>
</div>
</div>
<div className="rounded-[24px] border border-amber-500/20 bg-amber-500/8 p-5">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-sm font-semibold text-amber-200">
{isZh ? '钱包私钥' : 'Wallet Private Key'}
</div>
<div className="mt-1 text-xs leading-5 text-amber-100/75">
{isZh ? '请先备份,再进入下一步。' : 'Back this up before you continue.'}
</div>
</div>
<button
type="button"
onClick={() => setShowPrivateKey((prev) => !prev)}
className="rounded-xl border border-amber-400/20 px-3 py-2 text-amber-200 transition hover:bg-amber-400/10"
>
{showPrivateKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
<div className="mt-4 break-all rounded-2xl bg-black/25 px-3 py-3 font-mono text-xs text-amber-50">
{showPrivateKey ? data.private_key : '0x' + '•'.repeat(64)}
</div>
<button
type="button"
onClick={() => copyText(data.private_key, isZh ? '私钥' : 'Private key')}
className="mt-3 inline-flex items-center gap-2 rounded-xl border border-amber-300/20 px-4 py-2 text-sm font-semibold text-amber-100 transition hover:bg-amber-400/10"
>
<Copy className="h-4 w-4" />
{isZh ? '复制私钥' : 'Copy private key'}
</button>
</div>
<div className="rounded-[24px] border border-white/10 bg-white/5 p-4 text-xs leading-6 text-zinc-400">
<div>
{data.env_saved
? isZh
? `已同步保存到环境文件:${data.env_path || '.env'}`
: `Also saved to env: ${data.env_path || '.env'}`
: isZh
? '当前运行环境没有成功写回 .env但产品已完成默认配置。'
: 'The app is configured, but this runtime could not write back to .env.'}
</div>
{data.env_warning ? <div className="mt-2 text-amber-300">{data.env_warning}</div> : null}
</div>
<button
type="button"
onClick={handleContinue}
className="w-full rounded-2xl bg-nofx-gold px-5 py-4 text-sm font-bold text-black transition hover:bg-yellow-400"
>
{isZh ? '我已保存,进入下一步' : 'I saved it, continue'}
</button>
</div>
) : null}
</section>
</div>
</div>
</DeepVoidBackground>
)
}

View File

@@ -4,6 +4,12 @@ import { User, Cpu, Building2, MessageCircle, Eye, EyeOff, ChevronRight, Plus, P
import { useAuth } from '../contexts/AuthContext'
import { useLanguage } from '../contexts/LanguageContext'
import { api } from '../lib/api'
import {
getPostAuthPath,
getUserMode,
setUserMode,
type UserMode,
} from '../lib/onboarding'
import { ExchangeConfigModal } from '../components/trader/ExchangeConfigModal'
import { TelegramConfigModal } from '../components/trader/TelegramConfigModal'
import { ModelConfigModal } from '../components/trader/ModelConfigModal'
@@ -15,6 +21,7 @@ export function SettingsPage() {
const { user } = useAuth()
const { language } = useLanguage()
const [activeTab, setActiveTab] = useState<Tab>('account')
const [userMode, setUserModeState] = useState<UserMode>(() => getUserMode() ?? 'advanced')
// Account state
const [newPassword, setNewPassword] = useState('')
@@ -81,6 +88,26 @@ export function SettingsPage() {
}
}
const handleSwitchMode = (nextMode: UserMode) => {
if (nextMode === userMode) {
return
}
setUserMode(nextMode)
setUserModeState(nextMode)
toast.success(
language === 'zh'
? `已切换到${nextMode === 'beginner' ? '新手模式' : '老手模式'}`
: nextMode === 'beginner'
? 'Switched to beginner mode'
: 'Switched to advanced mode'
)
const nextPath = getPostAuthPath(nextMode)
window.history.pushState({}, '', nextPath)
window.dispatchEvent(new PopStateEvent('popstate'))
}
const handleSaveModel = async (
modelId: string,
apiKey: string,
@@ -281,6 +308,66 @@ export function SettingsPage() {
<p className="text-sm text-white font-medium">{user?.email}</p>
</div>
<div className="border-t border-zinc-800 pt-6">
<div className="flex items-center justify-between gap-4">
<div>
<h3 className="text-sm font-semibold text-white">
{language === 'zh' ? '使用模式' : 'Usage Mode'}
</h3>
<p className="mt-1 text-xs text-zinc-500">
{language === 'zh'
? '新手模式会显示钱包引导和 4 步卡片;老手模式保持原来的专业界面。'
: 'Beginner mode shows wallet onboarding and quickstart cards. Advanced mode keeps the original pro workflow.'}
</p>
</div>
<span className="rounded-full border border-nofx-gold/20 bg-nofx-gold/10 px-3 py-1 text-xs font-semibold text-nofx-gold">
{userMode === 'beginner'
? language === 'zh' ? '当前:新手模式' : 'Current: Beginner'
: language === 'zh' ? '当前:老手模式' : 'Current: Advanced'}
</span>
</div>
<div className="mt-4 grid gap-3 sm:grid-cols-2">
<button
type="button"
onClick={() => handleSwitchMode('beginner')}
className={`rounded-2xl border px-4 py-4 text-left transition-all ${
userMode === 'beginner'
? 'border-nofx-gold bg-nofx-gold/10'
: 'border-zinc-800 bg-zinc-950/70 hover:border-zinc-700'
}`}
>
<div className="text-sm font-semibold text-white">
{language === 'zh' ? '新手模式' : 'Beginner Mode'}
</div>
<div className="mt-1 text-xs text-zinc-500">
{language === 'zh'
? '更简单,优先显示钱包、充值和快速上手引导。'
: 'Simpler flow with wallet, funding, and quickstart guidance first.'}
</div>
</button>
<button
type="button"
onClick={() => handleSwitchMode('advanced')}
className={`rounded-2xl border px-4 py-4 text-left transition-all ${
userMode === 'advanced'
? 'border-nofx-gold bg-nofx-gold/10'
: 'border-zinc-800 bg-zinc-950/70 hover:border-zinc-700'
}`}
>
<div className="text-sm font-semibold text-white">
{language === 'zh' ? '老手模式' : 'Advanced Mode'}
</div>
<div className="mt-1 text-xs text-zinc-500">
{language === 'zh'
? '保持原来的配置与交易流程,不展示新手引导。'
: 'Keeps the original configuration and trading workflow without beginner hints.'}
</div>
</button>
</div>
</div>
<div className="border-t border-zinc-800 pt-6">
<h3 className="text-sm font-semibold text-white mb-4">Change Password</h3>
<form onSubmit={handleChangePassword} className="space-y-4">

View File

@@ -29,7 +29,6 @@ import {
Download,
Upload,
Globe,
X,
} from 'lucide-react'
import type { Strategy, StrategyConfig, AIModel } from '../types'
import { confirmToast, notify } from '../lib/notify'
@@ -39,10 +38,8 @@ import { RiskControlEditor } from '../components/strategy/RiskControlEditor'
import { PromptSectionsEditor } from '../components/strategy/PromptSectionsEditor'
import { PublishSettingsEditor } from '../components/strategy/PublishSettingsEditor'
import { GridConfigEditor, defaultGridConfig } from '../components/strategy/GridConfigEditor'
import { TokenEstimateBar } from '../components/strategy/TokenEstimateBar'
import { DeepVoidBackground } from '../components/common/DeepVoidBackground'
import { t } from '../i18n/translations'
import { NofxSelect } from '../components/ui/select'
const API_BASE = import.meta.env.VITE_API_BASE || ''
@@ -55,7 +52,6 @@ export function StrategyStudioPage() {
const [editingConfig, setEditingConfig] = useState<StrategyConfig | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [isSaving, setIsSaving] = useState(false)
const [tokenOverflow, setTokenOverflow] = useState(false)
const [error, setError] = useState<string | null>(null)
const [hasChanges, setHasChanges] = useState(false)
@@ -382,10 +378,6 @@ export function StrategyStudioPage() {
// Save strategy
const handleSaveStrategy = async () => {
if (!token || !selectedStrategy || !editingConfig) return
if (tokenOverflow && currentStrategyType === 'ai_trading') {
notify.error(tr('tokenExceedWarning'))
return
}
setIsSaving(true)
try {
// Always sync the config language with the current interface language
@@ -413,17 +405,7 @@ export function StrategyStudioPage() {
if (!response.ok) throw new Error('Failed to save strategy')
setHasChanges(false)
notify.success(tr('strategySaved'))
const savedId = selectedStrategy.id
await fetchStrategies()
// Stay on the strategy we just saved instead of jumping to active
setStrategies(prev => {
const saved = prev.find(s => s.id === savedId)
if (saved) {
setSelectedStrategy(saved)
setEditingConfig(saved.config)
}
return prev
})
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error')
} finally {
@@ -659,7 +641,7 @@ export function StrategyStudioPage() {
<Sparkles className="w-5 h-5 text-black" />
</div>
<div>
<h1 className="text-lg font-bold text-nofx-text">{tr('title')}</h1>
<h1 className="text-lg font-bold text-nofx-text">{tr('strategyStudio')}</h1>
<p className="text-xs text-nofx-text-muted">{tr('subtitle')}</p>
</div>
</div>
@@ -774,24 +756,34 @@ export function StrategyStudioPage() {
{selectedStrategy && editingConfig ? (
<div className="p-4">
{/* Strategy Name & Actions */}
<div className="mb-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 flex-1 min-w-0">
<input
type="text"
value={selectedStrategy.name}
onChange={(e) => {
setSelectedStrategy({ ...selectedStrategy, name: e.target.value })
setHasChanges(true)
}}
disabled={selectedStrategy.is_default}
className="text-lg font-bold bg-transparent border-none outline-none flex-1 min-w-0 text-nofx-text placeholder-nofx-text-muted"
/>
{hasChanges && (
<span className="text-xs text-nofx-gold whitespace-nowrap"> {tr('unsaved')}</span>
)}
</div>
<div className="flex items-center gap-2 flex-shrink-0 ml-2">
<div className="flex items-center justify-between mb-4">
<div className="flex-1 min-w-0">
<input
type="text"
value={selectedStrategy.name}
onChange={(e) => {
setSelectedStrategy({ ...selectedStrategy, name: e.target.value })
setHasChanges(true)
}}
disabled={selectedStrategy.is_default}
className="text-lg font-bold bg-transparent border-none outline-none w-full text-nofx-text placeholder-nofx-text-muted"
/>
<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"
/>
{hasChanges && (
<span className="text-xs text-nofx-gold"> {tr('unsaved')}</span>
)}
</div>
<div className="flex items-center gap-2 flex-shrink-0">
{!selectedStrategy.is_active && (
<button
onClick={() => handleActivateStrategy(selectedStrategy.id)}
@@ -801,22 +793,10 @@ export function StrategyStudioPage() {
{tr('activate')}
</button>
)}
{!selectedStrategy.is_default && hasChanges && (
<button
onClick={() => {
setEditingConfig(selectedStrategy.config)
setHasChanges(false)
}}
className="flex items-center gap-1 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors text-nofx-text-muted hover:text-nofx-text hover:bg-nofx-bg-lighter border border-nofx-border"
>
<X className="w-3 h-3" />
{tr('discardChanges')}
</button>
)}
{!selectedStrategy.is_default && (
<button
onClick={handleSaveStrategy}
disabled={isSaving || !hasChanges || (tokenOverflow && currentStrategyType === 'ai_trading')}
disabled={isSaving || !hasChanges}
className={`flex items-center gap-1 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors disabled:opacity-50
${hasChanges ? 'bg-nofx-gold text-black hover:bg-yellow-500' : 'bg-nofx-bg-lighter text-nofx-text-muted cursor-not-allowed'}`}
>
@@ -825,27 +805,8 @@ export function StrategyStudioPage() {
</button>
)}
</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>
{/* Token Estimate Bar */}
{currentStrategyType === 'ai_trading' && (
<div className="mb-4">
<TokenEstimateBar config={editingConfig} language={language} onOverflowChange={setTokenOverflow} />
</div>
)}
{/* Strategy Type Selector */}
{editingConfig && (
<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
onClick={() => {
if (!selectedStrategy?.is_default) {
setEditingConfig(prev => prev ? {
...prev,
strategy_type: 'ai_trading',
grid_config: undefined,
} : prev)
setHasChanges(true)
updateConfig('strategy_type', 'ai_trading')
// Clear grid config when switching to AI trading
updateConfig('grid_config', undefined)
}
}}
disabled={selectedStrategy?.is_default}
@@ -881,12 +839,11 @@ export function StrategyStudioPage() {
<button
onClick={() => {
if (!selectedStrategy?.is_default) {
setEditingConfig(prev => prev ? {
...prev,
strategy_type: 'grid_trading',
grid_config: prev.grid_config || defaultGridConfig,
} : prev)
setHasChanges(true)
updateConfig('strategy_type', 'grid_trading')
// Initialize grid config if not exists
if (!editingConfig.grid_config) {
updateConfig('grid_config', defaultGridConfig)
}
}
}}
disabled={selectedStrategy?.is_default}
@@ -977,16 +934,15 @@ export function StrategyStudioPage() {
<div className="p-3 space-y-3">
{/* Controls */}
<div className="flex items-center gap-2 flex-wrap">
<NofxSelect
<select
value={selectedVariant}
onChange={(val) => setSelectedVariant(val)}
options={[
{ value: 'balanced', label: tr('balanced') },
{ value: 'aggressive', label: tr('aggressive') },
{ value: 'conservative', label: tr('conservative') },
]}
className="px-2 py-1.5 rounded text-xs bg-nofx-bg border border-nofx-gold/20 text-nofx-text"
/>
onChange={(e) => setSelectedVariant(e.target.value)}
className="px-2 py-1.5 rounded text-xs bg-nofx-bg border border-nofx-gold/20 text-nofx-text outline-none focus:border-nofx-gold"
>
<option value="balanced">{tr('balanced')}</option>
<option value="aggressive">{tr('aggressive')}</option>
<option value="conservative">{tr('conservative')}</option>
</select>
<button
onClick={fetchPromptPreview}
disabled={isLoadingPrompt || !editingConfig}
@@ -1051,15 +1007,17 @@ export function StrategyStudioPage() {
<span className="text-xs font-medium text-nofx-text">{tr('selectModel')}</span>
</div>
{aiModels.length > 0 ? (
<NofxSelect
<select
value={selectedModelId}
onChange={(val) => setSelectedModelId(val)}
options={aiModels.map((model) => ({
value: model.id,
label: `${model.name} (${model.provider})`,
}))}
onChange={(e) => setSelectedModelId(e.target.value)}
className="w-full px-3 py-2 rounded-lg text-sm bg-nofx-bg border border-nofx-gold/20 text-nofx-text"
/>
>
{aiModels.map((model) => (
<option key={model.id} value={model.id}>
{model.name} ({model.provider})
</option>
))}
</select>
) : (
<div className="px-3 py-2 rounded-lg text-sm bg-nofx-danger/10 text-nofx-danger">
{tr('noModel')}
@@ -1067,16 +1025,15 @@ export function StrategyStudioPage() {
)}
<div className="flex items-center gap-2">
<NofxSelect
<select
value={selectedVariant}
onChange={(val) => setSelectedVariant(val)}
options={[
{ value: 'balanced', label: tr('balanced') },
{ value: 'aggressive', label: tr('aggressive') },
{ value: 'conservative', label: tr('conservative') },
]}
onChange={(e) => setSelectedVariant(e.target.value)}
className="px-2 py-1.5 rounded text-xs bg-nofx-bg border border-nofx-gold/20 text-nofx-text"
/>
>
<option value="balanced">{tr('balanced')}</option>
<option value="aggressive">{tr('aggressive')}</option>
<option value="conservative">{tr('conservative')}</option>
</select>
<button
onClick={runAiTest}
disabled={isRunningAiTest || !editingConfig || !selectedModelId}