mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-07 13:00:59 +08:00
change v1
This commit is contained in:
@@ -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[]
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
15
web/src/types/agent.ts
Normal 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[]
|
||||
}
|
||||
Reference in New Issue
Block a user