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:
tinkle-community
2026-06-11 22:11:03 +08:00
parent 953240565f
commit 2c6e2827e8
12 changed files with 666 additions and 2 deletions

View 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>
)
}

View File

@@ -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}`

View File

@@ -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} />,