change v1

This commit is contained in:
lky-spec
2026-04-25 16:18:45 +08:00
parent 737f9bca95
commit c244e4cdf1
89 changed files with 17382 additions and 6198 deletions

View File

@@ -21,9 +21,8 @@ import { ChatInput, type ChatInputHandle } from '../components/agent/ChatInput'
import { UserPreferencesPanel } from '../components/agent/UserPreferencesPanel'
import {
useAgentChatStore,
type AgentMessage as Message,
type AgentStep,
} from '../stores/agentChatStore'
import type { AgentMessage as Message, AgentStep } from '../types/agent'
import {
chatStorageKey,
clearAgentMessages,
@@ -35,10 +34,347 @@ import {
} from '../lib/agentChatStorage'
let msgIdCounter = 0
let activeStreamAbortController: AbortController | null = null
let activeStreamReader: ReadableStreamDefaultReader<Uint8Array> | null = null
function nextId() {
return `msg-${Date.now()}-${++msgIdCounter}`
}
function cleanupActiveAgentStream() {
activeStreamAbortController?.abort()
activeStreamAbortController = null
void activeStreamReader?.cancel().catch(() => {
// Ignore stream cancellation races during teardown.
})
activeStreamReader = null
}
function persistMessagesSnapshotForUser(userId?: string) {
const { hydrated, messages } = useAgentChatStore.getState()
if (!hydrated) return
const persistable = prepareAgentMessagesForPersistence(messages).slice(-100)
persistAgentMessages(window.localStorage, userId, persistable)
}
function replaceMessagesInStore(nextMessages: Message[], userId?: string) {
useAgentChatStore.getState().setMessages(nextMessages)
persistMessagesSnapshotForUser(userId)
}
function patchMessagesInStore(
updater: (prev: Message[]) => Message[],
userId?: string
) {
const nextMessages = updater(useAgentChatStore.getState().messages)
useAgentChatStore.getState().updateMessages(() => nextMessages)
persistMessagesSnapshotForUser(userId)
}
async function runAgentStream(params: {
text: string
token?: string | null
language: string
storageUserId?: string
onDone?: () => void
}) {
const { text, token, language, storageUserId, onDone } = params
if (!text || useAgentChatStore.getState().loading) return
const time = new Date().toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})
const userMsg: Message = { id: nextId(), role: 'user', text, time }
const botId = nextId()
const nextConversation: Message[] = [
userMsg,
{
id: botId,
role: 'bot',
text: '',
time: '',
streaming: true,
},
]
replaceMessagesInStore(
text.trim() === '/clear'
? nextConversation
: [...useAgentChatStore.getState().messages, ...nextConversation],
storageUserId
)
useAgentChatStore.getState().setLoading(true)
if (text.trim() === '/clear') {
try {
clearAgentMessages(window.localStorage, storageUserId)
} catch {
// Ignore storage cleanup failure.
}
}
let controller: AbortController | null = null
try {
activeStreamAbortController?.abort()
controller = new AbortController()
activeStreamAbortController = controller
const res = await fetch('/api/agent/chat/stream', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({ message: text, lang: language }),
signal: controller.signal,
})
if (!res.ok) {
const errData = await res.json().catch(() => ({}))
throw new Error(errData.error || `Server error (${res.status})`)
}
const reader = res.body?.getReader()
const decoder = new TextDecoder()
if (!reader) throw new Error('No response body')
activeStreamReader = reader
controller.signal.addEventListener(
'abort',
() => {
void reader.cancel().catch(() => {
// Ignore double-cancel races.
})
},
{ once: true }
)
let buffer = ''
let finalText = ''
let stepCounter = 0
const now = () =>
new Date().toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})
const mergeStreamText = (current: string, incoming: string) => {
if (!incoming) return current
if (!current) return incoming
if (incoming === current) return current
if (incoming.startsWith(current)) return incoming
if (current.startsWith(incoming)) return current
return current + incoming
}
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || ''
let eventType = ''
for (const line of lines) {
if (line.startsWith('event: ')) {
eventType = line.slice(7).trim()
} else if (line.startsWith('data: ') && eventType) {
const rawData = line.slice(6)
let data: string
try {
data = JSON.parse(rawData)
} catch {
eventType = ''
continue
}
if (eventType === 'delta') {
finalText = mergeStreamText(finalText, data)
patchMessagesInStore(
(prev) =>
prev.map((m) =>
m.id === botId
? { ...m, text: finalText, time: now() }
: m
),
storageUserId
)
} else if (eventType === 'plan') {
const parsedSteps = parsePlanSteps(data)
patchMessagesInStore(
(prev) =>
prev.map((m) =>
m.id === botId
? {
...m,
steps: parsedSteps.length > 0 ? parsedSteps : m.steps,
time: now(),
}
: m
),
storageUserId
)
} else if (eventType === 'step_start') {
stepCounter += 1
const nextStep = parseStepEvent(data, stepCounter)
patchMessagesInStore(
(prev) =>
prev.map((m) =>
m.id === botId
? {
...m,
steps: appendStep(m.steps, nextStep),
time: now(),
}
: m
),
storageUserId
)
} else if (eventType === 'step_complete') {
patchMessagesInStore(
(prev) =>
prev.map((m) =>
m.id === botId
? {
...m,
steps: markLatestRunningCompleted(m.steps, data),
time: now(),
}
: m
),
storageUserId
)
} else if (eventType === 'replan') {
patchMessagesInStore(
(prev) =>
prev.map((m) =>
m.id === botId
? {
...m,
steps: appendStep(m.steps, {
id: `replan-${Date.now()}`,
label: data,
status: 'replanned',
detail: data,
}),
time: now(),
}
: m
),
storageUserId
)
} else if (eventType === 'tool') {
patchMessagesInStore(
(prev) =>
prev.map((m) =>
m.id === botId
? {
...m,
steps: appendStep(m.steps, {
id: `tool-${Date.now()}`,
label: `Tool: ${data}`,
status: 'running',
detail: data,
}),
time: now(),
}
: m
),
storageUserId
)
} else if (eventType === 'done') {
patchMessagesInStore(
(prev) =>
prev.map((m) =>
m.id === botId
? {
...m,
text: finalText || m.text || data,
time: now(),
streaming: false,
}
: m
),
storageUserId
)
} else if (eventType === 'error') {
throw new Error(data)
}
eventType = ''
}
}
}
patchMessagesInStore(
(prev) =>
prev.map((m) =>
m.id === botId && m.streaming
? {
...m,
text: finalText || m.text || 'No response',
streaming: false,
time: now(),
}
: m
),
storageUserId
)
window.dispatchEvent(new CustomEvent('agent-preferences-refresh'))
window.dispatchEvent(new CustomEvent('agent-config-refresh'))
} catch (e: any) {
if (e.name === 'AbortError') {
patchMessagesInStore(
(prev) =>
prev.map((m) =>
m.id === botId
? {
...m,
streaming: false,
time:
m.time ||
new Date().toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
}),
}
: m
),
storageUserId
)
} else {
patchMessagesInStore(
(prev) =>
prev.map((m) =>
m.id === botId
? {
...m,
text: '⚠️ Error: ' + e.message,
time: new Date().toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
}),
streaming: false,
}
: m
),
storageUserId
)
}
}
if (controller && activeStreamAbortController === controller) {
activeStreamAbortController = null
}
if (activeStreamReader) {
try {
activeStreamReader.releaseLock()
} catch {
// Ignore lock-release races when the stream is already closed.
}
activeStreamReader = null
}
useAgentChatStore.getState().setLoading(false)
onDone?.()
}
function appendStep(
existing: AgentStep[] | undefined,
step: AgentStep
@@ -63,7 +399,9 @@ function parsePlanSteps(data: string): AgentStep[] {
}
function parseStepEvent(data: string, fallbackIndex: number): AgentStep {
const match = data.match(/Step\s+(\d+)\/(\d+):\s+(.+)$/i) || data.match(/步骤\s+(\d+)\/(\d+):\s+(.+)$/)
const match =
data.match(/Step\s+(\d+)\/(\d+):\s+(.+)$/i) ||
data.match(/步骤\s+(\d+)\/(\d+):\s+(.+)$/)
if (match) {
const id = `action-${match[1]}`
return {
@@ -81,7 +419,10 @@ function parseStepEvent(data: string, fallbackIndex: number): AgentStep {
}
}
function markLatestRunningCompleted(existing: AgentStep[] | undefined, detail: string): AgentStep[] {
function markLatestRunningCompleted(
existing: AgentStep[] | undefined,
detail: string
): AgentStep[] {
const prev = existing ?? []
for (let i = prev.length - 1; i >= 0; i--) {
if (prev[i].status === 'running') {
@@ -96,20 +437,18 @@ function markLatestRunningCompleted(existing: AgentStep[] | undefined, detail: s
export function AgentChatPage() {
const { language } = useLanguage()
const { token, user } = useAuth()
const [storageUserId, setStorageUserId] = useState<string | undefined>(() => getStoredAuthUserId())
const [storageUserId, setStorageUserId] = useState<string | undefined>(() =>
getStoredAuthUserId()
)
const [sidebarOpen, setSidebarOpen] = useState(() => window.innerWidth > 1024)
const storageKey = chatStorageKey(user?.id || storageUserId)
const messages = useAgentChatStore((state) => state.messages)
const loading = useAgentChatStore((state) => state.loading)
const historyHydrated = useAgentChatStore((state) => state.hydrated)
const activeUserId = useAgentChatStore((state) => state.activeUserId)
const setMessages = useAgentChatStore((state) => state.setMessages)
const updateMessages = useAgentChatStore((state) => state.updateMessages)
const setLoading = useAgentChatStore((state) => state.setLoading)
const resetForUser = useAgentChatStore((state) => state.resetForUser)
const messagesEndRef = useRef<HTMLDivElement>(null)
const chatInputRef = useRef<ChatInputHandle>(null)
const abortRef = useRef<AbortController | null>(null)
// Sidebar section collapse state
const [sections, setSections] = useState({
@@ -145,39 +484,31 @@ export function AgentChatPage() {
nextUserId,
loadAgentMessages<Message>(window.localStorage, nextUserId).messages
)
}, [activeUserId, historyHydrated, resetForUser, storageKey, storageUserId, user?.id])
}, [
activeUserId,
historyHydrated,
resetForUser,
storageKey,
storageUserId,
user?.id,
])
// Persist chat history locally so page navigation does not wipe the conversation.
useEffect(() => {
if (!historyHydrated) return
try {
const persistable = prepareAgentMessagesForPersistence(messages).slice(-100)
persistAgentMessages(window.localStorage, user?.id || storageUserId, persistable)
const persistable =
prepareAgentMessagesForPersistence(messages).slice(-100)
persistAgentMessages(
window.localStorage,
user?.id || storageUserId,
persistable
)
} catch {
// Ignore storage failures and keep the chat usable.
}
}, [historyHydrated, messages, storageKey, storageUserId, user?.id])
const persistMessagesSnapshot = (nextMessages: Message[]) => {
const persistable = prepareAgentMessagesForPersistence(nextMessages).slice(-100)
persistAgentMessages(window.localStorage, user?.id || storageUserId, persistable)
}
const replaceMessages = (nextMessages: Message[]) => {
setMessages(nextMessages)
if (historyHydrated) {
persistMessagesSnapshot(nextMessages)
}
}
const patchMessages = (updater: (prev: Message[]) => Message[]) => {
const nextMessages = updater(useAgentChatStore.getState().messages)
updateMessages(() => nextMessages)
if (useAgentChatStore.getState().hydrated) {
persistMessagesSnapshot(nextMessages)
}
}
// Responsive sidebar
useEffect(() => {
const handleResize = () => {
@@ -187,6 +518,12 @@ export function AgentChatPage() {
return () => window.removeEventListener('resize', handleResize)
}, [])
useEffect(() => {
const handlePageHide = () => cleanupActiveAgentStream()
window.addEventListener('pagehide', handlePageHide)
return () => window.removeEventListener('pagehide', handlePageHide)
}, [])
// Escape to close sidebar on mobile
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
@@ -199,253 +536,33 @@ export function AgentChatPage() {
}, [])
const send = async (text: string) => {
if (!text || loading) return
const time = new Date().toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
await runAgentStream({
text,
token,
language,
storageUserId: user?.id || storageUserId,
onDone: () => chatInputRef.current?.focus(),
})
const userMsg: Message = { id: nextId(), role: 'user', text, time }
const botId = nextId()
const nextConversation: Message[] = [
userMsg,
{
id: botId,
role: 'bot',
text: '',
time: '',
streaming: true,
},
]
replaceMessages(
text.trim() === '/clear'
? nextConversation
: [...useAgentChatStore.getState().messages, ...nextConversation]
)
setLoading(true)
if (text.trim() === '/clear') {
try {
clearAgentMessages(window.localStorage, user?.id || storageUserId)
} catch {
// Ignore storage cleanup failure.
}
}
try {
// Abort any in-flight request
abortRef.current?.abort()
const controller = new AbortController()
abortRef.current = controller
const res = await fetch('/api/agent/chat/stream', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({ message: text, lang: language, user_key: user?.id }),
signal: controller.signal,
})
if (!res.ok) {
const errData = await res.json().catch(() => ({}))
throw new Error(errData.error || `Server error (${res.status})`)
}
// Real SSE streaming
const reader = res.body?.getReader()
const decoder = new TextDecoder()
if (!reader) throw new Error('No response body')
let buffer = ''
let finalText = ''
let stepCounter = 0
const now = () =>
new Date().toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || '' // Keep incomplete line in buffer
let eventType = ''
for (const line of lines) {
if (line.startsWith('event: ')) {
eventType = line.slice(7).trim()
} else if (line.startsWith('data: ') && eventType) {
const rawData = line.slice(6)
let data: string
try {
data = JSON.parse(rawData)
} catch {
// Ignore malformed SSE data lines
eventType = ''
continue
}
if (eventType === 'delta') {
// data is the accumulated text so far
finalText = data
patchMessages((prev) =>
prev.map((m) =>
m.id === botId
? { ...m, text: data, time: now() }
: m
)
)
} else if (eventType === 'plan') {
const parsedSteps = parsePlanSteps(data)
patchMessages((prev) =>
prev.map((m) =>
m.id === botId
? {
...m,
steps: parsedSteps.length > 0 ? parsedSteps : m.steps,
time: now(),
}
: m
)
)
} else if (eventType === 'step_start') {
stepCounter += 1
const nextStep = parseStepEvent(data, stepCounter)
patchMessages((prev) =>
prev.map((m) =>
m.id === botId
? {
...m,
steps: appendStep(m.steps, nextStep),
time: now(),
}
: m
)
)
} else if (eventType === 'step_complete') {
patchMessages((prev) =>
prev.map((m) =>
m.id === botId
? {
...m,
steps: markLatestRunningCompleted(m.steps, data),
time: now(),
}
: m
)
)
} else if (eventType === 'replan') {
patchMessages((prev) =>
prev.map((m) =>
m.id === botId
? {
...m,
steps: appendStep(m.steps, {
id: `replan-${Date.now()}`,
label: data,
status: 'replanned',
detail: data,
}),
time: now(),
}
: m
)
)
} else if (
eventType === 'tool'
) {
// Show tool being called as a status indicator
patchMessages((prev) =>
prev.map((m) =>
m.id === botId
? {
...m,
steps: appendStep(m.steps, {
id: `tool-${Date.now()}`,
label: `Tool: ${data}`,
status: 'running',
detail: data,
}),
time: now(),
}
: m
)
)
} else if (eventType === 'done') {
finalText = data
patchMessages((prev) =>
prev.map((m) =>
m.id === botId
? { ...m, text: data, time: now(), streaming: false }
: m
)
)
} else if (eventType === 'error') {
throw new Error(data)
}
eventType = ''
}
}
}
// If stream ended without a "done" event, mark as done
patchMessages((prev) =>
prev.map((m) =>
m.id === botId && m.streaming
? {
...m,
text: finalText || m.text || 'No response',
streaming: false,
time: now(),
}
: m
)
)
window.dispatchEvent(new CustomEvent('agent-preferences-refresh'))
window.dispatchEvent(new CustomEvent('agent-config-refresh'))
} catch (e: any) {
if (e.name === 'AbortError') {
// Request was cancelled (e.g. user sent a new message), clean up silently
patchMessages((prev) => prev.filter((m) => m.id !== botId))
} else {
patchMessages((prev) =>
prev.map((m) =>
m.id === botId
? {
...m,
text: '⚠️ Error: ' + e.message,
time: new Date().toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
}),
streaming: false,
}
: m
)
)
}
}
setLoading(false)
chatInputRef.current?.focus()
}
const quickActions = language === 'zh'
? [
{ label: '💼 持仓', cmd: '/positions' },
{ label: '💰 余额', cmd: '/balance' },
{ label: '📋 Traders', cmd: '/traders' },
{ label: '🧹 清除记忆', cmd: '/clear' },
{ label: '❓ 帮助', cmd: '/help' },
]
: [
{ label: '💼 Positions', cmd: '/positions' },
{ label: '💰 Balance', cmd: '/balance' },
{ label: '📋 Traders', cmd: '/traders' },
{ label: '🧹 Clear', cmd: '/clear' },
{ label: '❓ Help', cmd: '/help' },
]
const quickActions =
language === 'zh'
? [
{ label: '💼 持仓', cmd: '/positions' },
{ label: '💰 余额', cmd: '/balance' },
{ label: '📋 Traders', cmd: '/traders' },
{ label: '📊 系统状态', cmd: '/status' },
{ label: '🧹 清除记忆', cmd: '/clear' },
{ label: '❓ 帮助', cmd: '/help' },
]
: [
{ label: '💼 Positions', cmd: '/positions' },
{ label: '💰 Balance', cmd: '/balance' },
{ label: '📋 Traders', cmd: '/traders' },
{ label: '📊 Status', cmd: '/status' },
{ label: '🧹 Clear', cmd: '/clear' },
{ label: '❓ Help', cmd: '/help' },
]
const sidebarSections = [
{
@@ -548,8 +665,12 @@ export function AgentChatPage() {
transition: 'color 0.2s',
}}
title={sidebarOpen ? 'Hide sidebar' : 'Show sidebar'}
onMouseEnter={(e) => { e.currentTarget.style.color = '#8a8aa0' }}
onMouseLeave={(e) => { e.currentTarget.style.color = '#4c4c62' }}
onMouseEnter={(e) => {
e.currentTarget.style.color = '#8a8aa0'
}}
onMouseLeave={(e) => {
e.currentTarget.style.color = '#4c4c62'
}}
>
{sidebarOpen ? (
<PanelRightClose size={18} />
@@ -648,7 +769,8 @@ export function AgentChatPage() {
fontFamily: 'inherit',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(255,255,255,0.03)'
e.currentTarget.style.background =
'rgba(255,255,255,0.03)'
e.currentTarget.style.color = '#a0a0b0'
}}
onMouseLeave={(e) => {
@@ -658,7 +780,12 @@ export function AgentChatPage() {
>
{section.icon}
<span>{section.title}</span>
<span style={{ marginLeft: 'auto', transition: 'transform 0.2s' }}>
<span
style={{
marginLeft: 'auto',
transition: 'transform 0.2s',
}}
>
{sections[section.key] ? (
<ChevronDown size={14} />
) : (
@@ -675,9 +802,7 @@ export function AgentChatPage() {
transition={{ duration: 0.15 }}
style={{ overflow: 'hidden', padding: '0 4px' }}
>
<div style={{ paddingTop: 4 }}>
{section.component}
</div>
<div style={{ paddingTop: 4 }}>{section.component}</div>
</motion.div>
)}
</AnimatePresence>

View File

@@ -30,14 +30,22 @@ import {
Upload,
Globe,
} from 'lucide-react'
import type { Strategy, StrategyConfig, AIModel, GridStrategyConfig } from '../types'
import type {
Strategy,
StrategyConfig,
AIModel,
GridStrategyConfig,
} from '../types'
import { confirmToast, notify } from '../lib/notify'
import { CoinSourceEditor } from '../components/strategy/CoinSourceEditor'
import { IndicatorEditor } from '../components/strategy/IndicatorEditor'
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 {
GridConfigEditor,
defaultGridConfig,
} from '../components/strategy/GridConfigEditor'
import { TokenEstimateBar } from '../components/strategy/TokenEstimateBar'
import { DeepVoidBackground } from '../components/common/DeepVoidBackground'
import { t } from '../i18n/translations'
@@ -49,8 +57,12 @@ export function StrategyStudioPage() {
const { language } = useLanguage()
const [strategies, setStrategies] = useState<Strategy[]>([])
const [selectedStrategy, setSelectedStrategy] = useState<Strategy | null>(null)
const [editingConfig, setEditingConfig] = useState<StrategyConfig | null>(null)
const [selectedStrategy, setSelectedStrategy] = useState<Strategy | null>(
null
)
const [editingConfig, setEditingConfig] = useState<StrategyConfig | null>(
null
)
const [isLoading, setIsLoading] = useState(true)
const [isSaving, setIsSaving] = useState(false)
const [estimatedTokens, setEstimatedTokens] = useState(0)
@@ -73,7 +85,9 @@ export function StrategyStudioPage() {
})
// Right panel states
const [activeRightTab, setActiveRightTab] = useState<'prompt' | 'test'>('prompt')
const [activeRightTab, setActiveRightTab] = useState<'prompt' | 'test'>(
'prompt'
)
const [promptPreview, setPromptPreview] = useState<{
system_prompt: string
user_prompt?: string
@@ -95,6 +109,8 @@ export function StrategyStudioPage() {
} | null>(null)
const [isRunningAiTest, setIsRunningAiTest] = useState(false)
const gridConfigCacheRef = useRef<Record<string, GridStrategyConfig>>({})
const selectedStrategyIDRef = useRef<string>('')
const hasChangesRef = useRef(false)
const toggleSection = (section: keyof typeof expandedSections) => {
setExpandedSections((prev) => ({
@@ -113,7 +129,7 @@ export function StrategyStudioPage() {
if (response.ok) {
const data = await response.json()
// Backend returns an array, not { models: [] }
const allModels = Array.isArray(data) ? data : (data.models || [])
const allModels = Array.isArray(data) ? data : data.models || []
const enabledModels = allModels.filter((m: AIModel) => m.enabled)
setAiModels(enabledModels)
if (enabledModels.length > 0 && !selectedModelId) {
@@ -134,16 +150,25 @@ export function StrategyStudioPage() {
})
if (!response.ok) throw new Error('Failed to fetch strategies')
const data = await response.json()
setStrategies(data.strategies || [])
const nextStrategies: Strategy[] = data.strategies || []
setStrategies(nextStrategies)
// Select active or first strategy
const active = data.strategies?.find((s: Strategy) => s.is_active)
if (active) {
setSelectedStrategy(active)
setEditingConfig(active.config)
} else if (data.strategies?.length > 0) {
setSelectedStrategy(data.strategies[0])
setEditingConfig(data.strategies[0].config)
const preservedSelection = selectedStrategyIDRef.current
? nextStrategies.find((s) => s.id === selectedStrategyIDRef.current)
: null
const active = nextStrategies.find((s) => s.is_active)
const nextSelected =
preservedSelection || active || nextStrategies[0] || null
setSelectedStrategy(nextSelected)
selectedStrategyIDRef.current = nextSelected?.id || ''
if (!hasChangesRef.current || !preservedSelection) {
setEditingConfig(nextSelected?.config || null)
}
if (!nextSelected) {
setEditingConfig(null)
setHasChanges(false)
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error')
@@ -157,10 +182,37 @@ export function StrategyStudioPage() {
fetchAiModels()
}, [fetchStrategies, fetchAiModels])
useEffect(() => {
selectedStrategyIDRef.current = selectedStrategy?.id || ''
}, [selectedStrategy?.id])
useEffect(() => {
hasChangesRef.current = hasChanges
}, [hasChanges])
useEffect(() => {
if (!token) return
const refreshIfVisible = () => {
if (document.visibilityState === 'visible') {
void fetchStrategies()
}
}
window.addEventListener('focus', refreshIfVisible)
document.addEventListener('visibilitychange', refreshIfVisible)
return () => {
window.removeEventListener('focus', refreshIfVisible)
document.removeEventListener('visibilitychange', refreshIfVisible)
}
}, [fetchStrategies, token])
useEffect(() => {
if (!selectedStrategy?.id || !editingConfig?.grid_config) return
gridConfigCacheRef.current[selectedStrategy.id] = { ...editingConfig.grid_config }
gridConfigCacheRef.current[selectedStrategy.id] = {
...editingConfig.grid_config,
}
}, [selectedStrategy?.id, editingConfig?.grid_config])
// Track previous language to detect actual changes
@@ -185,7 +237,7 @@ export function StrategyStudioPage() {
const defaultConfig = await response.json()
// Update only the prompt sections and language field
setEditingConfig(prev => {
setEditingConfig((prev) => {
if (!prev) return prev
return {
...prev,
@@ -280,14 +332,11 @@ export function StrategyStudioPage() {
// fetch failed — proceed, backend will guard
}
const confirmed = await confirmToast(
tr('confirmDeleteStrategy'),
{
title: tr('confirmDelete'),
okText: tr('delete'),
cancelText: tr('cancel'),
}
)
const confirmed = await confirmToast(tr('confirmDeleteStrategy'), {
title: tr('confirmDelete'),
okText: tr('delete'),
cancelText: tr('cancel'),
})
if (!confirmed) return
try {
@@ -316,16 +365,19 @@ export function StrategyStudioPage() {
const handleDuplicateStrategy = async (id: string) => {
if (!token) return
try {
const response = await fetch(`${API_BASE}/api/strategies/${id}/duplicate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
name: tr('strategyCopy'),
}),
})
const response = await fetch(
`${API_BASE}/api/strategies/${id}/duplicate`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
name: tr('strategyCopy'),
}),
}
)
if (!response.ok) throw new Error('Failed to duplicate strategy')
await fetchStrategies()
} catch (err) {
@@ -337,10 +389,13 @@ export function StrategyStudioPage() {
const handleActivateStrategy = async (id: string) => {
if (!token) return
try {
const response = await fetch(`${API_BASE}/api/strategies/${id}/activate`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
})
const response = await fetch(
`${API_BASE}/api/strategies/${id}/activate`,
{
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
}
)
if (!response.ok) throw new Error('Failed to activate strategy')
await fetchStrategies()
} catch (err) {
@@ -357,7 +412,9 @@ export function StrategyStudioPage() {
exported_at: new Date().toISOString(),
version: '1.0',
}
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' })
const blob = new Blob([JSON.stringify(exportData, null, 2)], {
type: 'application/json',
})
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
@@ -370,7 +427,9 @@ export function StrategyStudioPage() {
}
// Import strategy from JSON file
const handleImportStrategy = async (event: React.ChangeEvent<HTMLInputElement>) => {
const handleImportStrategy = async (
event: React.ChangeEvent<HTMLInputElement>
) => {
const file = event.target.files?.[0]
if (!file || !token) return
@@ -466,7 +525,9 @@ export function StrategyStudioPage() {
setHasChanges(true)
}
const handleStrategyTypeChange = (strategyType: NonNullable<StrategyConfig['strategy_type']>) => {
const handleStrategyTypeChange = (
strategyType: NonNullable<StrategyConfig['strategy_type']>
) => {
if (selectedStrategy?.is_default) return
const cachedGridConfig = selectedStrategy?.id
@@ -478,7 +539,9 @@ export function StrategyStudioPage() {
if (strategyType === 'ai_trading') {
if (selectedStrategy?.id && prev.grid_config) {
gridConfigCacheRef.current[selectedStrategy.id] = { ...prev.grid_config }
gridConfigCacheRef.current[selectedStrategy.id] = {
...prev.grid_config,
}
}
return {
@@ -492,7 +555,8 @@ export function StrategyStudioPage() {
return {
...prev,
strategy_type: 'grid_trading',
grid_config: cachedGridConfig ?? prev.grid_config ?? { ...defaultGridConfig },
grid_config: cachedGridConfig ??
prev.grid_config ?? { ...defaultGridConfig },
}
})
@@ -506,18 +570,21 @@ export function StrategyStudioPage() {
if (!token || !editingConfig) return
setIsLoadingPrompt(true)
try {
const response = await fetch(`${API_BASE}/api/strategies/preview-prompt`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
config: editingConfig,
account_equity: 1000,
prompt_variant: selectedVariant,
}),
})
const response = await fetch(
`${API_BASE}/api/strategies/preview-prompt`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
config: editingConfig,
account_equity: 1000,
prompt_variant: selectedVariant,
}),
}
)
if (!response.ok) throw new Error('Failed to fetch prompt preview')
const data = await response.json()
setPromptPreview(data)
@@ -649,7 +716,9 @@ export function StrategyStudioPage() {
content: editingConfig && (
<PromptSectionsEditor
config={editingConfig.prompt_sections}
onChange={(promptSections) => updateConfig('prompt_sections', promptSections)}
onChange={(promptSections) =>
updateConfig('prompt_sections', promptSections)
}
disabled={selectedStrategy?.is_default}
language={language}
/>
@@ -672,7 +741,11 @@ export function StrategyStudioPage() {
disabled={selectedStrategy?.is_default}
placeholder={tr('customPromptPlaceholder')}
className="w-full h-32 px-3 py-2 rounded-lg resize-none font-mono text-xs"
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
style={{
background: '#0B0E11',
border: '1px solid #2B3139',
color: '#EAECEF',
}}
/>
</div>
),
@@ -700,13 +773,14 @@ export function StrategyStudioPage() {
/>
),
},
].filter(section =>
section.forStrategyType === 'both' || section.forStrategyType === currentStrategyType
].filter(
(section) =>
section.forStrategyType === 'both' ||
section.forStrategyType === currentStrategyType
)
return (
<DeepVoidBackground className="h-[calc(100vh-64px)] flex flex-col bg-nofx-bg relative overflow-hidden">
{/* Header */}
{/* Header */}
<div className="flex-shrink-0 px-4 py-3 border-b border-nofx-gold/20 bg-nofx-bg/60 backdrop-blur-md z-10">
@@ -716,14 +790,21 @@ 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('title')}
</h1>
<p className="text-xs text-nofx-text-muted">{tr('subtitle')}</p>
</div>
</div>
{error && (
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs bg-nofx-danger/10 text-nofx-danger">
{error}
<button onClick={() => setError(null)} className="hover:underline">×</button>
<button
onClick={() => setError(null)}
className="hover:underline"
>
×
</button>
</div>
)}
</div>
@@ -735,10 +816,15 @@ export function StrategyStudioPage() {
<div className="w-48 flex-shrink-0 border-r border-nofx-gold/20 overflow-y-auto bg-nofx-bg/30 backdrop-blur-sm z-10">
<div className="p-2">
<div className="flex items-center justify-between mb-2 px-2">
<span className="text-xs font-medium text-nofx-text-muted">{tr('strategies')}</span>
<span className="text-xs font-medium text-nofx-text-muted">
{tr('strategies')}
</span>
<div className="flex items-center gap-1">
{/* Import button with hidden file input */}
<label className="p-1 rounded hover:bg-white/10 transition-colors cursor-pointer text-nofx-text-muted hover:text-white" title={tr('importStrategy')}>
<label
className="p-1 rounded hover:bg-white/10 transition-colors cursor-pointer text-nofx-text-muted hover:text-white"
title={tr('importStrategy')}
>
<Upload className="w-4 h-4" />
<input
type="file"
@@ -767,16 +853,24 @@ export function StrategyStudioPage() {
setPromptPreview(null)
setAiTestResult(null)
}}
className={`group px-2 py-2 rounded-lg cursor-pointer transition-all ${selectedStrategy?.id === strategy.id
? 'ring-1 ring-nofx-gold/50 bg-nofx-gold/10 shadow-[0_0_15px_rgba(240,185,11,0.1)]'
: 'hover:bg-nofx-bg-lighter/60 ring-1 ring-white/10 hover:ring-nofx-gold/20 bg-transparent'
}`}
className={`group px-2 py-2 rounded-lg cursor-pointer transition-all ${
selectedStrategy?.id === strategy.id
? 'ring-1 ring-nofx-gold/50 bg-nofx-gold/10 shadow-[0_0_15px_rgba(240,185,11,0.1)]'
: 'hover:bg-nofx-bg-lighter/60 ring-1 ring-white/10 hover:ring-nofx-gold/20 bg-transparent'
}`}
>
<div className="flex items-start justify-between">
<span className={`line-clamp-2 text-nofx-text ${language === 'zh' ? 'text-sm' : 'text-xs'}`}>{strategy.name}</span>
<span
className={`line-clamp-2 text-nofx-text ${language === 'zh' ? 'text-sm' : 'text-xs'}`}
>
{strategy.name}
</span>
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={(e) => { e.stopPropagation(); handleExportStrategy(strategy) }}
onClick={(e) => {
e.stopPropagation()
handleExportStrategy(strategy)
}}
className="p-1 rounded hover:bg-white/10 text-nofx-text-muted hover:text-white"
title={tr('export')}
>
@@ -785,17 +879,27 @@ export function StrategyStudioPage() {
{!strategy.is_default && (
<>
<button
onClick={(e) => { e.stopPropagation(); handleDuplicateStrategy(strategy.id) }}
onClick={(e) => {
e.stopPropagation()
handleDuplicateStrategy(strategy.id)
}}
className="p-1 rounded hover:bg-white/10 text-nofx-text-muted hover:text-white"
title={tr('duplicate')}
>
<Copy className="w-3 h-3" />
</button>
<button
onClick={(e) => { e.stopPropagation(); handleDeleteStrategy(strategy.id) }}
onClick={(e) => {
e.stopPropagation()
handleDeleteStrategy(strategy.id)
}}
disabled={strategy.is_active}
className="p-1 rounded hover:bg-nofx-danger/20 text-nofx-danger disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-transparent"
title={strategy.is_active ? tr('cannotDeleteActiveStrategy') : tr('deleteTooltip')}
title={
strategy.is_active
? tr('cannotDeleteActiveStrategy')
: tr('deleteTooltip')
}
>
<Trash2 className="w-3 h-3" />
</button>
@@ -838,7 +942,10 @@ export function StrategyStudioPage() {
type="text"
value={selectedStrategy.name}
onChange={(e) => {
setSelectedStrategy({ ...selectedStrategy, name: e.target.value })
setSelectedStrategy({
...selectedStrategy,
name: e.target.value,
})
setHasChanges(true)
}}
disabled={selectedStrategy.is_default}
@@ -848,7 +955,10 @@ export function StrategyStudioPage() {
type="text"
value={selectedStrategy.description || ''}
onChange={(e) => {
setSelectedStrategy({ ...selectedStrategy, description: e.target.value })
setSelectedStrategy({
...selectedStrategy,
description: e.target.value,
})
setHasChanges(true)
}}
disabled={selectedStrategy.is_default}
@@ -856,13 +966,17 @@ export function StrategyStudioPage() {
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>
<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)}
onClick={() =>
handleActivateStrategy(selectedStrategy.id)
}
className="flex items-center gap-1 px-3 py-1.5 rounded-lg text-xs transition-colors bg-nofx-success/10 border border-nofx-success/30 text-nofx-success hover:bg-nofx-success/20"
>
<Check className="w-3 h-3" />
@@ -886,7 +1000,11 @@ export function StrategyStudioPage() {
{/* Token Estimate Bar */}
{currentStrategyType === 'ai_trading' && (
<div className="mb-4">
<TokenEstimateBar config={editingConfig} language={language} onTokenCountChange={setEstimatedTokens} />
<TokenEstimateBar
config={editingConfig}
language={language}
onTokenCountChange={setEstimatedTokens}
/>
</div>
)}
@@ -895,23 +1013,30 @@ export function StrategyStudioPage() {
<div className="mb-4 p-4 rounded-lg bg-nofx-bg-lighter border border-nofx-gold/20">
<div className="flex items-center gap-2 mb-3">
<Zap className="w-4 h-4" style={{ color: '#F0B90B' }} />
<span className="text-sm font-medium text-nofx-text">{tr('strategyType')}</span>
<span className="text-sm font-medium text-nofx-text">
{tr('strategyType')}
</span>
</div>
<div className="grid grid-cols-2 gap-3">
<button
onClick={() => handleStrategyTypeChange('ai_trading')}
disabled={selectedStrategy?.is_default}
className={`p-3 rounded-lg border transition-all ${
(!editingConfig.strategy_type || editingConfig.strategy_type === 'ai_trading')
!editingConfig.strategy_type ||
editingConfig.strategy_type === 'ai_trading'
? 'border-nofx-gold bg-nofx-gold/10'
: 'border-nofx-border hover:border-nofx-gold/50'
}`}
>
<div className="flex items-center gap-2 mb-1">
<Bot className="w-4 h-4" style={{ color: '#F0B90B' }} />
<span className="text-sm font-medium text-nofx-text">{tr('aiTrading')}</span>
<span className="text-sm font-medium text-nofx-text">
{tr('aiTrading')}
</span>
</div>
<p className="text-xs text-nofx-text-muted text-left">{tr('aiTradingDesc')}</p>
<p className="text-xs text-nofx-text-muted text-left">
{tr('aiTradingDesc')}
</p>
</button>
<button
onClick={() => handleStrategyTypeChange('grid_trading')}
@@ -923,10 +1048,17 @@ export function StrategyStudioPage() {
}`}
>
<div className="flex items-center gap-2 mb-1">
<Activity className="w-4 h-4" style={{ color: '#0ECB81' }} />
<span className="text-sm font-medium text-nofx-text">{tr('gridTrading')}</span>
<Activity
className="w-4 h-4"
style={{ color: '#0ECB81' }}
/>
<span className="text-sm font-medium text-nofx-text">
{tr('gridTrading')}
</span>
</div>
<p className="text-xs text-nofx-text-muted text-left">{tr('gridTradingDesc')}</p>
<p className="text-xs text-nofx-text-muted text-left">
{tr('gridTradingDesc')}
</p>
</button>
</div>
</div>
@@ -934,32 +1066,34 @@ export function StrategyStudioPage() {
{/* Config Sections */}
<div className="space-y-2">
{configSections.map(({ key, icon: Icon, color, title, content }) => (
<div
key={key}
className="rounded-lg overflow-hidden bg-nofx-bg-lighter border border-nofx-gold/20"
>
<button
onClick={() => toggleSection(key)}
className="w-full flex items-center justify-between px-3 py-2.5 hover:bg-white/5 transition-colors"
{configSections.map(
({ key, icon: Icon, color, title, content }) => (
<div
key={key}
className="rounded-lg overflow-hidden bg-nofx-bg-lighter border border-nofx-gold/20"
>
<div className="flex items-center gap-2">
<Icon className="w-4 h-4" style={{ color }} />
<span className="text-sm font-medium text-nofx-text">{title}</span>
</div>
{expandedSections[key] ? (
<ChevronDown className="w-4 h-4 text-nofx-text-muted" />
) : (
<ChevronRight className="w-4 h-4 text-nofx-text-muted" />
<button
onClick={() => toggleSection(key)}
className="w-full flex items-center justify-between px-3 py-2.5 hover:bg-white/5 transition-colors"
>
<div className="flex items-center gap-2">
<Icon className="w-4 h-4" style={{ color }} />
<span className="text-sm font-medium text-nofx-text">
{title}
</span>
</div>
{expandedSections[key] ? (
<ChevronDown className="w-4 h-4 text-nofx-text-muted" />
) : (
<ChevronRight className="w-4 h-4 text-nofx-text-muted" />
)}
</button>
{expandedSections[key] && (
<div className="px-3 pb-3">{content}</div>
)}
</button>
{expandedSections[key] && (
<div className="px-3 pb-3">
{content}
</div>
)}
</div>
))}
</div>
)
)}
</div>
</div>
) : (
@@ -980,16 +1114,22 @@ export function StrategyStudioPage() {
<div className="flex-shrink-0 flex border-b border-nofx-gold/20">
<button
onClick={() => setActiveRightTab('prompt')}
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2.5 text-sm font-medium transition-colors ${activeRightTab === 'prompt' ? 'border-b-2 border-purple-500 text-purple-500' : 'opacity-60 hover:opacity-100 text-nofx-text-muted'
}`}
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2.5 text-sm font-medium transition-colors ${
activeRightTab === 'prompt'
? 'border-b-2 border-purple-500 text-purple-500'
: 'opacity-60 hover:opacity-100 text-nofx-text-muted'
}`}
>
<Eye className="w-4 h-4" />
{tr('promptPreview')}
</button>
<button
onClick={() => setActiveRightTab('test')}
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2.5 text-sm font-medium transition-colors ${activeRightTab === 'test' ? 'border-b-2 border-green-500 text-green-500' : 'opacity-60 hover:opacity-100 text-nofx-text-muted'
}`}
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2.5 text-sm font-medium transition-colors ${
activeRightTab === 'test'
? 'border-b-2 border-green-500 text-green-500'
: 'opacity-60 hover:opacity-100 text-nofx-text-muted'
}`}
>
<Play className="w-4 h-4" />
{tr('aiTestRun')}
@@ -1017,7 +1157,11 @@ export function StrategyStudioPage() {
disabled={isLoadingPrompt || !editingConfig}
className="flex items-center gap-1.5 px-3 py-1.5 rounded text-xs font-medium transition-colors disabled:opacity-50 bg-purple-600 hover:bg-purple-700 text-white"
>
{isLoadingPrompt ? <Loader2 className="w-3 h-3 animate-spin" /> : <RefreshCw className="w-3 h-3" />}
{isLoadingPrompt ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<RefreshCw className="w-3 h-3" />
)}
{promptPreview ? tr('refreshPrompt') : tr('loadPrompt')}
</button>
</div>
@@ -1028,15 +1172,23 @@ export function StrategyStudioPage() {
<div className="p-2 rounded-lg bg-nofx-bg border border-nofx-gold/20">
<div className="flex items-center gap-1.5 mb-2">
<Code className="w-3 h-3 text-purple-500" />
<span className="text-xs font-medium text-purple-500">Config</span>
<span className="text-xs font-medium text-purple-500">
Config
</span>
</div>
<div className="grid grid-cols-3 gap-2 text-xs">
{Object.entries(promptPreview.config_summary || {}).map(([key, value]) => (
<div key={key}>
<div className="text-nofx-text-muted">{key.replace(/_/g, ' ')}</div>
<div className="text-nofx-text">{String(value)}</div>
</div>
))}
{Object.entries(promptPreview.config_summary || {}).map(
([key, value]) => (
<div key={key}>
<div className="text-nofx-text-muted">
{key.replace(/_/g, ' ')}
</div>
<div className="text-nofx-text">
{String(value)}
</div>
</div>
)
)}
</div>
</div>
@@ -1045,10 +1197,13 @@ export function StrategyStudioPage() {
<div className="flex items-center justify-between mb-1.5">
<div className="flex items-center gap-1.5">
<FileText className="w-3 h-3 text-purple-500" />
<span className="text-xs font-medium text-nofx-text">{tr('systemPrompt')}</span>
<span className="text-xs font-medium text-nofx-text">
{tr('systemPrompt')}
</span>
</div>
<span className="text-[10px] px-1.5 py-0.5 rounded bg-nofx-bg-lighter text-nofx-text-muted">
{promptPreview.system_prompt.length.toLocaleString()} chars
{promptPreview.system_prompt.length.toLocaleString()}{' '}
chars
</span>
</div>
<pre
@@ -1073,7 +1228,9 @@ export function StrategyStudioPage() {
<div className="space-y-2">
<div className="flex items-center gap-2">
<Bot className="w-4 h-4 text-green-500" />
<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>
{aiModels.length > 0 ? (
<select
@@ -1105,7 +1262,9 @@ export function StrategyStudioPage() {
</select>
<button
onClick={runAiTest}
disabled={isRunningAiTest || !editingConfig || !selectedModelId}
disabled={
isRunningAiTest || !editingConfig || !selectedModelId
}
className="flex-1 flex items-center justify-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all disabled:opacity-50 text-white shadow-lg shadow-green-500/20 bg-gradient-to-br from-green-500 to-green-600"
>
{isRunningAiTest ? (
@@ -1121,7 +1280,9 @@ export function StrategyStudioPage() {
)}
</button>
</div>
<p className="text-[10px] text-nofx-text-muted">{tr('testNote')}</p>
<p className="text-[10px] text-nofx-text-muted">
{tr('testNote')}
</p>
</div>
{/* Test Results */}
@@ -1129,7 +1290,9 @@ export function StrategyStudioPage() {
<div className="space-y-3">
{aiTestResult.error ? (
<div className="p-3 rounded-lg bg-nofx-danger/10 border border-nofx-danger/30">
<p className="text-sm text-nofx-danger">{aiTestResult.error}</p>
<p className="text-sm text-nofx-danger">
{aiTestResult.error}
</p>
</div>
) : (
<>
@@ -1137,7 +1300,8 @@ export function StrategyStudioPage() {
<div className="flex items-center gap-2">
<Clock className="w-3 h-3 text-nofx-text-muted" />
<span className="text-xs text-nofx-text-muted">
{tr('duration')}: {(aiTestResult.duration_ms / 1000).toFixed(2)}s
{tr('duration')}:{' '}
{(aiTestResult.duration_ms / 1000).toFixed(2)}s
</span>
</div>
)}
@@ -1147,7 +1311,9 @@ export function StrategyStudioPage() {
<div>
<div className="flex items-center gap-1.5 mb-1.5">
<Terminal className="w-3 h-3 text-blue-400" />
<span className="text-xs font-medium text-nofx-text">{tr('userPrompt')} (Input)</span>
<span className="text-xs font-medium text-nofx-text">
{tr('userPrompt')} (Input)
</span>
</div>
<pre
className="p-2 rounded-lg text-[10px] font-mono overflow-auto bg-nofx-bg border border-nofx-gold/20 text-nofx-text"
@@ -1163,7 +1329,9 @@ export function StrategyStudioPage() {
<div>
<div className="flex items-center gap-1.5 mb-1.5">
<Sparkles className="w-3 h-3 text-nofx-gold" />
<span className="text-xs font-medium text-nofx-text">{tr('reasoning')}</span>
<span className="text-xs font-medium text-nofx-text">
{tr('reasoning')}
</span>
</div>
<pre
className="p-2 rounded-lg text-[10px] font-mono overflow-auto whitespace-pre-wrap bg-nofx-bg border border-nofx-gold/30 text-nofx-text"
@@ -1175,27 +1343,36 @@ export function StrategyStudioPage() {
)}
{/* AI Decisions */}
{aiTestResult.decisions && aiTestResult.decisions.length > 0 && (
<div>
<div className="flex items-center gap-1.5 mb-1.5">
<Activity className="w-3 h-3 text-green-500" />
<span className="text-xs font-medium text-nofx-text">{tr('decisions')}</span>
{aiTestResult.decisions &&
aiTestResult.decisions.length > 0 && (
<div>
<div className="flex items-center gap-1.5 mb-1.5">
<Activity className="w-3 h-3 text-green-500" />
<span className="text-xs font-medium text-nofx-text">
{tr('decisions')}
</span>
</div>
<pre
className="p-2 rounded-lg text-[10px] font-mono overflow-auto bg-nofx-bg border border-green-500/30 text-nofx-text"
style={{ maxHeight: '200px' }}
>
{JSON.stringify(
aiTestResult.decisions,
null,
2
)}
</pre>
</div>
<pre
className="p-2 rounded-lg text-[10px] font-mono overflow-auto bg-nofx-bg border border-green-500/30 text-nofx-text"
style={{ maxHeight: '200px' }}
>
{JSON.stringify(aiTestResult.decisions, null, 2)}
</pre>
</div>
)}
)}
{/* Raw AI Response */}
{aiTestResult.ai_response && (
<div>
<div className="flex items-center gap-1.5 mb-1.5">
<FileText className="w-3 h-3 text-nofx-text-muted" />
<span className="text-xs font-medium text-nofx-text">{tr('aiOutput')} (Raw)</span>
<span className="text-xs font-medium text-nofx-text">
{tr('aiOutput')} (Raw)
</span>
</div>
<pre
className="p-2 rounded-lg text-[10px] font-mono overflow-auto whitespace-pre-wrap bg-nofx-bg border border-nofx-gold/20 text-nofx-text"