mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-07 04:50:57 +08:00
feat(agent): surface the AI500 index board in chat, tools, and sidebar
- provider/nofxos: GetAI500ListCached — 5min TTL cache with stale fallback on upstream failure; ResolveClient routes through the claw402 gateway when a wallet key is configured (user's claw402 model key -> CLAW402_WALLET_KEY env -> direct nofxos) - new GET /api/ai500 endpoint serving the score-sorted board - new get_ai500_list agent tool + prompt rule: when the user wants coin picks or creates a strategy without naming coins, consult AI500's high-scoring entries by default - web: AI500 sidebar panel (rank, score badge, gain since entry, 5min auto-refresh); clicking an entry asks the agent to analyze it
This commit is contained in:
149
web/src/components/agent/AI500Panel.tsx
Normal file
149
web/src/components/agent/AI500Panel.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Sparkles } from 'lucide-react'
|
||||
import { api } from '../../lib/api'
|
||||
import type { AI500Coin } from '../../lib/api/data'
|
||||
|
||||
interface AI500PanelProps {
|
||||
language: string
|
||||
disabled?: boolean
|
||||
onAnalyzeSymbol: (coin: AI500Coin) => void
|
||||
}
|
||||
|
||||
const REFRESH_INTERVAL_MS = 5 * 60 * 1000 // matches the backend cache TTL
|
||||
|
||||
function formatGain(value?: number) {
|
||||
const n = Number(value || 0)
|
||||
if (!Number.isFinite(n) || n === 0) return '—'
|
||||
return `${n > 0 ? '+' : ''}${n.toFixed(2)}%`
|
||||
}
|
||||
|
||||
function scoreColor(score: number) {
|
||||
if (score >= 80) return '#0ECB81'
|
||||
if (score >= 60) return '#F0B90B'
|
||||
return '#848E9C'
|
||||
}
|
||||
|
||||
export function AI500Panel({ language, disabled, onAnalyzeSymbol }: AI500PanelProps) {
|
||||
const [coins, setCoins] = useState<AI500Coin[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const load = () => {
|
||||
api
|
||||
.getAI500List(20)
|
||||
.then((res) => {
|
||||
if (cancelled) return
|
||||
setCoins(res.coins || [])
|
||||
setError('')
|
||||
})
|
||||
.catch((err) => {
|
||||
if (cancelled) return
|
||||
setError(err?.message || 'Failed to load AI500')
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false)
|
||||
})
|
||||
}
|
||||
load()
|
||||
const timer = setInterval(load, REFRESH_INTERVAL_MS)
|
||||
return () => {
|
||||
cancelled = true
|
||||
clearInterval(timer)
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ padding: 12, color: '#848E9C', fontSize: 12 }}>
|
||||
{language === 'zh' ? '正在加载 AI500 榜单…' : 'Loading AI500 board…'}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div style={{ padding: 12, color: '#F6465D', fontSize: 12 }}>
|
||||
{language === 'zh' ? 'AI500 榜单加载失败:' : 'Failed to load AI500: '}
|
||||
{error}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (coins.length === 0) {
|
||||
return (
|
||||
<div style={{ padding: 12, color: '#848E9C', fontSize: 12 }}>
|
||||
{language === 'zh' ? '当前没有符合条件的 AI500 标的。' : 'No AI500 constituents right now.'}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, color: '#848E9C', fontSize: 10.5, padding: '0 2px' }}>
|
||||
<Sparkles size={11} color="#F0B90B" />
|
||||
{language === 'zh'
|
||||
? 'AI 评分精选 · 点击标的让 Agent 分析'
|
||||
: 'AI-scored picks · click to ask the agent'}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, maxHeight: 340, overflowY: 'auto', paddingRight: 2 }}>
|
||||
{coins.map((coin, idx) => {
|
||||
const display = coin.pair.replace(/USDT$/i, '')
|
||||
const gain = Number(coin.increase_percent || 0)
|
||||
return (
|
||||
<button
|
||||
key={coin.pair}
|
||||
disabled={disabled}
|
||||
onClick={() => onAnalyzeSymbol(coin)}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr auto',
|
||||
gap: 8,
|
||||
alignItems: 'center',
|
||||
textAlign: 'left',
|
||||
padding: '10px 11px',
|
||||
borderRadius: 10,
|
||||
border: '1px solid rgba(255,255,255,0.05)',
|
||||
background: 'rgba(255,255,255,0.025)',
|
||||
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||
opacity: disabled ? 0.6 : 1,
|
||||
}}
|
||||
title={
|
||||
language === 'zh' ? `让 Agent 分析 ${display}` : `Ask the agent to analyze ${display}`
|
||||
}
|
||||
>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span style={{ color: '#4c4c62', fontSize: 10, width: 16 }}>#{idx + 1}</span>
|
||||
<span style={{ color: '#EAECEF', fontWeight: 700, fontSize: 12.5 }}>{display}</span>
|
||||
</div>
|
||||
<div style={{ color: '#6c6c82', fontSize: 10.5, marginTop: 3, paddingLeft: 22 }}>
|
||||
{language === 'zh' ? '入选以来' : 'Since entry'}{' '}
|
||||
<span style={{ color: gain >= 0 ? '#0ECB81' : '#F6465D', fontWeight: 700 }}>
|
||||
{formatGain(coin.increase_percent)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-end',
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<span style={{ color: scoreColor(coin.score), fontWeight: 700, fontSize: 14 }}>
|
||||
{coin.score.toFixed(1)}
|
||||
</span>
|
||||
<span style={{ color: '#4c4c62', fontSize: 9.5 }}>
|
||||
{language === 'zh' ? 'AI 评分' : 'AI score'}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -29,6 +29,20 @@ export interface SymbolListResponse {
|
||||
count: number
|
||||
}
|
||||
|
||||
export interface AI500Coin {
|
||||
pair: string
|
||||
score: number
|
||||
max_score?: number
|
||||
increase_percent?: number
|
||||
start_price?: number
|
||||
start_time?: number
|
||||
}
|
||||
|
||||
export interface AI500ListResponse {
|
||||
coins: AI500Coin[]
|
||||
count: number
|
||||
}
|
||||
|
||||
export const dataApi = {
|
||||
async getSymbols(exchange = 'hyperliquid-xyz'): Promise<SymbolListResponse> {
|
||||
const result = await httpClient.get<SymbolListResponse>(
|
||||
@@ -38,6 +52,12 @@ export const dataApi = {
|
||||
return result.data || { exchange, symbols: [], count: 0 }
|
||||
},
|
||||
|
||||
async getAI500List(limit = 20): Promise<AI500ListResponse> {
|
||||
const result = await httpClient.get<AI500ListResponse>(`${API_BASE}/ai500?limit=${limit}`)
|
||||
if (!result.success) throw new Error('Failed to fetch AI500 list')
|
||||
return result.data || { coins: [], count: 0 }
|
||||
},
|
||||
|
||||
async getStatus(traderId?: string, silent?: boolean): Promise<SystemStatus> {
|
||||
const url = traderId
|
||||
? `${API_BASE}/status?trader_id=${traderId}`
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Bot,
|
||||
Bookmark,
|
||||
Zap,
|
||||
Sparkles,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
} from 'lucide-react'
|
||||
@@ -21,6 +22,8 @@ import { ChatMessages } from '../components/agent/ChatMessages'
|
||||
import { ChatInput, type ChatInputHandle } from '../components/agent/ChatInput'
|
||||
import { UserPreferencesPanel } from '../components/agent/UserPreferencesPanel'
|
||||
import { HyperliquidSymbolsPanel } from '../components/agent/HyperliquidSymbolsPanel'
|
||||
import { AI500Panel } from '../components/agent/AI500Panel'
|
||||
import type { AI500Coin } from '../lib/api/data'
|
||||
import { createHyperliquidQuickTrader } from '../lib/hyperliquidQuickTrade'
|
||||
import { useAgentChatStore } from '../stores/agentChatStore'
|
||||
import type { AgentMessage as Message, AgentStep } from '../types/agent'
|
||||
@@ -467,6 +470,7 @@ export function AgentChatPage() {
|
||||
// Sidebar section collapse state
|
||||
const [sections, setSections] = useState({
|
||||
market: true,
|
||||
ai500: true,
|
||||
positions: true,
|
||||
traders: false,
|
||||
preferences: true,
|
||||
@@ -582,6 +586,15 @@ export function AgentChatPage() {
|
||||
chatInputRef.current?.focus()
|
||||
}
|
||||
|
||||
const analyzeAI500Symbol = (coin: AI500Coin) => {
|
||||
const display = coin.pair.replace(/USDT$/i, '')
|
||||
const text =
|
||||
language === 'zh'
|
||||
? `分析一下 AI500 里的 ${display}(当前 AI 评分 ${coin.score.toFixed(1)}),给出趋势判断和交易建议`
|
||||
: `Analyze ${display} from the AI500 index (current AI score ${coin.score.toFixed(1)}) and give me a trend read plus a trading suggestion`
|
||||
void send(text)
|
||||
}
|
||||
|
||||
const tradeHyperliquidSymbol = async (symbol: { symbol: string; display?: string; category?: string }) => {
|
||||
const label = symbol.display || symbol.symbol
|
||||
const time = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
@@ -720,6 +733,18 @@ export function AgentChatPage() {
|
||||
title: language === 'zh' ? '市场行情' : 'Market',
|
||||
component: <MarketTicker />,
|
||||
},
|
||||
{
|
||||
key: 'ai500' as const,
|
||||
icon: <Sparkles size={14} />,
|
||||
title: language === 'zh' ? 'AI500 精选' : 'AI500 Picks',
|
||||
component: (
|
||||
<AI500Panel
|
||||
language={language}
|
||||
disabled={loading}
|
||||
onAnalyzeSymbol={analyzeAI500Symbol}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'hyperliquid' as const,
|
||||
icon: <Zap size={14} />,
|
||||
|
||||
Reference in New Issue
Block a user