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

@@ -1,9 +1,4 @@
interface AgentStep {
id: string
label: string
status: 'planning' | 'pending' | 'running' | 'completed' | 'replanned'
detail?: string
}
import type { AgentStep } from '../../types/agent'
interface AgentStepPanelProps {
steps?: AgentStep[]

View File

@@ -2,22 +2,7 @@ import { forwardRef } from 'react'
import { motion } from 'framer-motion'
import { AgentStepPanel } from './AgentStepPanel'
import { renderMessageContent } from './MessageRenderer'
interface AgentStep {
id: string
label: string
status: 'planning' | 'pending' | 'running' | 'completed' | 'replanned'
detail?: string
}
interface Message {
id: string
role: 'user' | 'bot'
text: string
time: string
streaming?: boolean
steps?: AgentStep[]
}
import type { AgentMessage as Message, AgentStep } from '../../types/agent'
interface ChatMessagesProps {
messages: Message[]

View File

@@ -1,4 +1,5 @@
import { useState, useEffect } from 'react'
import { httpClient } from '../../lib/httpClient'
// icons reserved for future use
interface TickerData {
@@ -25,8 +26,11 @@ export function MarketTicker() {
const fetchTickers = async () => {
try {
// Batch fetch: single API call for all symbols
const res = await fetch(`/api/agent/tickers?symbols=${SYMBOLS.join(',')}`)
const data = await res.json()
const res = await httpClient.request<TickerData[]>(
`/api/agent/tickers?symbols=${SYMBOLS.join(',')}`,
{ silent: true }
)
const data = res.data
const map: Record<string, TickerData> = {}
if (Array.isArray(data)) {
data.forEach((r: TickerData) => {
@@ -49,7 +53,11 @@ export function MarketTicker() {
const formatPrice = (price: string) => {
const n = parseFloat(price)
if (n >= 1000) return n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
if (n >= 1000)
return n.toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})
if (n >= 1) return n.toFixed(2)
return n.toFixed(4)
}
@@ -76,13 +84,15 @@ export function MarketTicker() {
height: 56,
}}
>
<div style={{
width: '60%',
height: 10,
background: 'rgba(255,255,255,0.04)',
borderRadius: 4,
animation: 'pulse 1.5s infinite',
}} />
<div
style={{
width: '60%',
height: 10,
background: 'rgba(255,255,255,0.04)',
borderRadius: 4,
animation: 'pulse 1.5s infinite',
}}
/>
</div>
))}
<style>{`
@@ -104,7 +114,11 @@ export function MarketTicker() {
const isUp = pct > 0
const isDown = pct < 0
const color = isUp ? '#00e5a0' : isDown ? '#F6465D' : '#6c6c82'
const bgColor = isUp ? 'rgba(0,229,160,0.06)' : isDown ? 'rgba(246,70,93,0.06)' : 'rgba(108,108,130,0.06)'
const bgColor = isUp
? 'rgba(0,229,160,0.06)'
: isDown
? 'rgba(246,70,93,0.06)'
: 'rgba(108,108,130,0.06)'
const label = sym.replace('USDT', '')
const icon = SYMBOL_ICONS[label] || label[0]
@@ -149,7 +163,14 @@ export function MarketTicker() {
{icon}
</div>
<div>
<div style={{ fontSize: 12.5, fontWeight: 600, color: '#e0e0ec', letterSpacing: '-0.01em' }}>
<div
style={{
fontSize: 12.5,
fontWeight: 600,
color: '#e0e0ec',
letterSpacing: '-0.01em',
}}
>
{label}
</div>
<div style={{ fontSize: 10, color: '#4c4c62' }}>
@@ -158,16 +179,27 @@ export function MarketTicker() {
</div>
</div>
<div style={{ textAlign: 'right' }}>
<div style={{ fontSize: 12.5, fontWeight: 600, color: '#e0e0ec', fontFamily: '"IBM Plex Mono", monospace', letterSpacing: '-0.02em' }}>
<div
style={{
fontSize: 12.5,
fontWeight: 600,
color: '#e0e0ec',
fontFamily: '"IBM Plex Mono", monospace',
letterSpacing: '-0.02em',
}}
>
${formatPrice(t.lastPrice)}
</div>
<div style={{
fontSize: 10.5,
fontWeight: 600,
color,
fontFamily: '"IBM Plex Mono", monospace',
}}>
{isUp ? '+' : ''}{pct.toFixed(2)}%
<div
style={{
fontSize: 10.5,
fontWeight: 600,
color,
fontFamily: '"IBM Plex Mono", monospace',
}}
>
{isUp ? '+' : ''}
{pct.toFixed(2)}%
</div>
</div>
</div>

View File

@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react'
import { httpClient, ApiError } from '../../lib/httpClient'
interface Preference {
id: string
@@ -27,12 +28,11 @@ export function UserPreferencesPanel({ token, language }: Props) {
setLoading(true)
try {
const res = await fetch('/api/agent/preferences', {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) throw new Error('Failed to load preferences')
const data = await res.json()
setPreferences(Array.isArray(data.preferences) ? data.preferences : [])
const resp = await httpClient.get<{ preferences: Preference[] }>(
'/api/agent/preferences'
)
const prefs = resp.data?.preferences
setPreferences(Array.isArray(prefs) ? prefs : [])
setError(null)
} catch {
setError(language === 'zh' ? '加载偏好失败' : 'Failed to load')
@@ -70,21 +70,19 @@ export function UserPreferencesPanel({ token, language }: Props) {
setSaving(true)
try {
const res = await fetch('/api/agent/preferences', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ text }),
})
const data = await res.json().catch(() => ({}))
if (!res.ok) throw new Error(data.error || 'save failed')
setPreferences(Array.isArray(data.preferences) ? data.preferences : [])
const resp = await httpClient.post<{ preferences: Preference[] }>(
'/api/agent/preferences',
{ text }
)
const prefs = resp.data?.preferences
setPreferences(Array.isArray(prefs) ? prefs : [])
setDraft('')
setError(null)
} catch {
setError(language === 'zh' ? '保存偏好失败' : 'Failed to save')
} catch (err) {
const message = err instanceof ApiError ? err.message : ''
setError(
message || (language === 'zh' ? '保存偏好失败' : 'Failed to save')
)
} finally {
setSaving(false)
}
@@ -94,13 +92,11 @@ export function UserPreferencesPanel({ token, language }: Props) {
if (!token || saving) return
setSaving(true)
try {
const res = await fetch(`/api/agent/preferences/${encodeURIComponent(id)}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
})
const data = await res.json().catch(() => ({}))
if (!res.ok) throw new Error(data.error || 'delete failed')
setPreferences(Array.isArray(data.preferences) ? data.preferences : [])
const resp = await httpClient.delete<{ preferences: Preference[] }>(
`/api/agent/preferences/${encodeURIComponent(id)}`
)
const prefs = resp.data?.preferences
setPreferences(Array.isArray(prefs) ? prefs : [])
setError(null)
} catch {
setError(language === 'zh' ? '删除偏好失败' : 'Failed to delete')
@@ -123,7 +119,14 @@ export function UserPreferencesPanel({ token, language }: Props) {
<div style={{ color: '#d7d7e0', fontSize: 12, fontWeight: 600 }}>
{language === 'zh' ? '长期偏好' : 'Persistent Preferences'}
</div>
<div style={{ color: '#77778d', fontSize: 11, lineHeight: 1.5, marginTop: 4 }}>
<div
style={{
color: '#77778d',
fontSize: 11,
lineHeight: 1.5,
marginTop: 4,
}}
>
{language === 'zh'
? '把长期偏好固定下来,比如“默认用中文回答”或“优先关注 BTC 和 ETH”。'
: 'Pin durable preferences the agent should keep in mind, like answering in Chinese or focusing on BTC and ETH.'}
@@ -138,7 +141,11 @@ export function UserPreferencesPanel({ token, language }: Props) {
onKeyDown={(e) => {
if (e.key === 'Enter') void addPreference()
}}
placeholder={language === 'zh' ? '例如:默认用中文回答,优先关注 BTC、ETH' : 'Example: Answer in Chinese and focus on BTC, ETH'}
placeholder={
language === 'zh'
? '例如:默认用中文回答,优先关注 BTC、ETH'
: 'Example: Answer in Chinese and focus on BTC, ETH'
}
style={{
flex: 1,
background: 'rgba(255,255,255,0.03)',
@@ -154,7 +161,10 @@ export function UserPreferencesPanel({ token, language }: Props) {
onClick={() => void addPreference()}
disabled={!draft.trim() || saving}
style={{
background: draft.trim() && !saving ? 'rgba(240,185,11,0.12)' : 'rgba(255,255,255,0.04)',
background:
draft.trim() && !saving
? 'rgba(240,185,11,0.12)'
: 'rgba(255,255,255,0.04)',
color: draft.trim() && !saving ? '#F0B90B' : '#6d6d82',
border: '1px solid rgba(240,185,11,0.14)',
borderRadius: 8,
@@ -168,7 +178,9 @@ export function UserPreferencesPanel({ token, language }: Props) {
</div>
{error && (
<div style={{ color: '#f08a8a', fontSize: 11, marginBottom: 8 }}>{error}</div>
<div style={{ color: '#f08a8a', fontSize: 11, marginBottom: 8 }}>
{error}
</div>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
@@ -196,7 +208,14 @@ export function UserPreferencesPanel({ token, language }: Props) {
border: '1px solid rgba(255,255,255,0.04)',
}}
>
<div style={{ flex: 1, color: '#d7d7e0', fontSize: 12, lineHeight: 1.5 }}>
<div
style={{
flex: 1,
color: '#d7d7e0',
fontSize: 12,
lineHeight: 1.5,
}}
>
{pref.text}
</div>
<button

View File

@@ -38,7 +38,7 @@ export function RiskControlEditor({
<div className="grid grid-cols-1 gap-4 mb-4">
<div
className="p-4 rounded-lg"
style={{ background: '#0B0E11', border: '1px solid #2B3139' }}
style={{ background: '#0B0E11', border: '1px solid #0ECB81' }}
>
<label className="block text-sm mb-1" style={{ color: '#EAECEF' }}>
{ts(riskControl.maxPositions, language)}
@@ -46,22 +46,14 @@ export function RiskControlEditor({
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>
{ts(riskControl.maxPositionsDesc, language)}
</p>
<input
type="number"
value={config.max_positions ?? 3}
onChange={(e) =>
updateField('max_positions', parseInt(e.target.value) || 3)
}
disabled={disabled}
min={1}
max={3}
className="w-32 px-3 py-2 rounded"
style={{
background: '#1E2329',
border: '1px solid #2B3139',
color: '#EAECEF',
}}
/>
<div className="flex items-center gap-3">
<span className="font-mono text-lg" style={{ color: '#0ECB81' }}>
{config.max_positions ?? 3}
</span>
<span className="text-xs" style={{ color: '#848E9C' }}>
System enforced
</span>
</div>
</div>
</div>
@@ -156,24 +148,15 @@ export function RiskControlEditor({
{ts(riskControl.btcEthPositionValueRatioDesc, language)}
</p>
<div className="flex items-center gap-2">
<input
type="range"
value={config.btc_eth_max_position_value_ratio ?? 5}
onChange={(e) =>
updateField('btc_eth_max_position_value_ratio', parseFloat(e.target.value))
}
disabled={disabled}
min={0.5}
max={10}
step={0.5}
className="flex-1 accent-green-500"
/>
<span
className="w-12 text-center font-mono"
style={{ color: '#0ECB81' }}
>
{config.btc_eth_max_position_value_ratio ?? 5}x
</span>
<span className="text-xs" style={{ color: '#848E9C' }}>
System enforced
</span>
</div>
</div>
@@ -188,24 +171,15 @@ export function RiskControlEditor({
{ts(riskControl.altcoinPositionValueRatioDesc, language)}
</p>
<div className="flex items-center gap-2">
<input
type="range"
value={config.altcoin_max_position_value_ratio ?? 1}
onChange={(e) =>
updateField('altcoin_max_position_value_ratio', parseFloat(e.target.value))
}
disabled={disabled}
min={0.5}
max={10}
step={0.5}
className="flex-1 accent-green-500"
/>
<span
className="w-12 text-center font-mono"
style={{ color: '#0ECB81' }}
>
{config.altcoin_max_position_value_ratio ?? 1}x
</span>
<span className="text-xs" style={{ color: '#848E9C' }}>
System enforced
</span>
</div>
</div>
</div>
@@ -264,20 +238,12 @@ export function RiskControlEditor({
{ts(riskControl.maxMarginUsageDesc, language)}
</p>
<div className="flex items-center gap-2">
<input
type="range"
value={(config.max_margin_usage ?? 0.9) * 100}
onChange={(e) =>
updateField('max_margin_usage', parseInt(e.target.value) / 100)
}
disabled={disabled}
min={10}
max={100}
className="flex-1 accent-green-500"
/>
<span className="w-12 text-center font-mono" style={{ color: '#0ECB81' }}>
{Math.round((config.max_margin_usage ?? 0.9) * 100)}%
</span>
<span className="text-xs" style={{ color: '#848E9C' }}>
System enforced
</span>
</div>
</div>
</div>
@@ -295,7 +261,7 @@ export function RiskControlEditor({
<div className="grid grid-cols-2 gap-4">
<div
className="p-4 rounded-lg"
style={{ background: '#0B0E11', border: '1px solid #2B3139' }}
style={{ background: '#0B0E11', border: '1px solid #0ECB81' }}
>
<label className="block text-sm mb-1" style={{ color: '#EAECEF' }}>
{ts(riskControl.minPositionSize, language)}
@@ -303,26 +269,16 @@ export function RiskControlEditor({
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>
{ts(riskControl.minPositionSizeDesc, language)}
</p>
<div className="flex items-center">
<input
type="number"
value={config.min_position_size ?? 12}
onChange={(e) =>
updateField('min_position_size', parseFloat(e.target.value) || 12)
}
disabled={disabled}
min={10}
max={1000}
className="w-24 px-3 py-2 rounded"
style={{
background: '#1E2329',
border: '1px solid #2B3139',
color: '#EAECEF',
}}
/>
<div className="flex items-center gap-2">
<span className="font-mono text-lg" style={{ color: '#0ECB81' }}>
{config.min_position_size ?? 12}
</span>
<span className="ml-2" style={{ color: '#848E9C' }}>
USDT
</span>
<span className="text-xs" style={{ color: '#848E9C' }}>
System enforced
</span>
</div>
</div>

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"

View File

@@ -1,20 +1,5 @@
import { create } from 'zustand'
export interface AgentStep {
id: string
label: string
status: 'planning' | 'pending' | 'running' | 'completed' | 'replanned'
detail?: string
}
export interface AgentMessage {
id: string
role: 'user' | 'bot'
text: string
time: string
streaming?: boolean
steps?: AgentStep[]
}
import type { AgentMessage } from '../types/agent'
interface AgentChatStoreState {
activeUserId?: string

15
web/src/types/agent.ts Normal file
View File

@@ -0,0 +1,15 @@
export interface AgentStep {
id: string
label: string
status: 'planning' | 'pending' | 'running' | 'completed' | 'replanned'
detail?: string
}
export interface AgentMessage {
id: string
role: 'user' | 'bot'
text: string
time: string
streaming?: boolean
steps?: AgentStep[]
}