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

@@ -587,6 +587,7 @@ func (a *Agent) buildSystemPromptForStoreUser(lang, storeUserID string) string {
- **get_strategies / manage_strategy** — 查看、新增、修改、删除、激活、复制策略模板
- **manage_trader** — 查看、新增、修改、删除、启动、停止交易员
- **get_watchlist / manage_watchlist** — 查看、添加、移除运行时监控币对,适合“把 BTC 加入监控”“别再监控 SOL”这类请求
- **get_ai500_list** — 获取 AI500 指数榜单AI 评分 0-100 + 入选以来涨幅,按评分排序)。**用户要选币、要推荐标的、或创建策略/交易员没指定币种时,默认先调这个工具,从评分高、表现好的标的里选**;用户问"AI500 里有什么"时也用它
### 配置、策略与交易员管理规则
- 当用户要求创建、修改、删除、激活、复制策略模板时,优先使用 get_strategies / manage_strategy
@@ -671,6 +672,7 @@ You can call these tools to take action:
- **get_balance** — View account balance and equity
- **get_market_price** — Get real-time price from the exchange (crypto or stock symbol)
- **get_kline** — Get recent candlestick / kline data for a crypto symbol
- **get_ai500_list** — AI500 index board (AI score 0-100 + gain since entry, sorted by score). **When the user wants coin picks, recommendations, or creates a strategy/trader without naming coins, call this first and choose from the high-scoring, well-performing entries**; also use it when asked what's in AI500
- **get_exchange_configs / manage_exchange_config** — View, create, update, and delete exchange bindings
- **get_model_configs / manage_model_config** — View, create, update, and delete AI model bindings
- **get_strategies / manage_strategy** — View, create, update, delete, activate, and duplicate strategy templates

122
agent/ai500.go Normal file
View File

@@ -0,0 +1,122 @@
package agent
import (
"encoding/json"
"fmt"
"sort"
"strings"
"nofx/provider/nofxos"
"nofx/store"
)
const (
ai500DefaultLimit = 20
ai500MaxLimit = 100
)
// fetchAI500ForTool is swappable in tests. It resolves a nofxos client
// (routed through claw402 when a wallet key is available) and returns the
// cached AI500 board.
var fetchAI500ForTool = func(walletKey string) ([]nofxos.CoinData, error) {
return nofxos.GetAI500ListCached(nofxos.ResolveClient(walletKey))
}
// Claw402WalletKeyForStoreUser returns the wallet private key of the user's
// enabled claw402 model, if any, so data requests can be routed through the
// claw402 payment gateway on the user's own account.
func Claw402WalletKeyForStoreUser(st *store.Store, storeUserID string) string {
if st == nil {
return ""
}
if strings.TrimSpace(storeUserID) == "" {
storeUserID = "default"
}
models, err := st.AIModel().List(storeUserID)
if err != nil {
return ""
}
for _, model := range models {
if model == nil || !model.Enabled {
continue
}
if strings.EqualFold(strings.TrimSpace(model.Provider), "claw402") && len(model.APIKey) > 0 {
return string(model.APIKey)
}
}
return ""
}
// AI500BoardEntry is the display shape for one AI500 constituent.
type AI500BoardEntry struct {
Pair string `json:"pair"`
Score float64 `json:"score"`
MaxScore float64 `json:"max_score"`
IncreasePercent float64 `json:"increase_percent"`
StartPrice float64 `json:"start_price"`
StartTime int64 `json:"start_time"`
}
// AI500Board returns the AI500 constituents sorted by score (descending),
// truncated to limit.
func AI500Board(walletKey string, limit int) ([]AI500BoardEntry, error) {
if limit <= 0 {
limit = ai500DefaultLimit
}
if limit > ai500MaxLimit {
limit = ai500MaxLimit
}
coins, err := fetchAI500ForTool(walletKey)
if err != nil {
return nil, err
}
sorted := make([]nofxos.CoinData, len(coins))
copy(sorted, coins)
sort.SliceStable(sorted, func(i, j int) bool {
return sorted[i].Score > sorted[j].Score
})
if len(sorted) > limit {
sorted = sorted[:limit]
}
out := make([]AI500BoardEntry, 0, len(sorted))
for _, coin := range sorted {
out = append(out, AI500BoardEntry{
Pair: coin.Pair,
Score: coin.Score,
MaxScore: coin.MaxScore,
IncreasePercent: coin.IncreasePercent,
StartPrice: coin.StartPrice,
StartTime: coin.StartTime,
})
}
return out, nil
}
// toolGetAI500List exposes the AI500 board to the chat agent.
func (a *Agent) toolGetAI500List(storeUserID, argsJSON string) string {
var args struct {
Limit int `json:"limit"`
}
if strings.TrimSpace(argsJSON) != "" {
if err := json.Unmarshal([]byte(argsJSON), &args); err != nil {
return fmt.Sprintf(`{"error":"invalid arguments: %s"}`, err)
}
}
walletKey := Claw402WalletKeyForStoreUser(a.store, storeUserID)
entries, err := AI500Board(walletKey, args.Limit)
if err != nil {
return fmt.Sprintf(`{"error":"failed to fetch AI500 list: %s"}`, err)
}
payload, err := json.Marshal(map[string]any{
"status": "ok",
"count": len(entries),
"coins": entries,
"note": "AI500 is an AI-scored crypto index; score is 0-100, increase_percent is the gain since the coin entered the index.",
})
if err != nil {
return fmt.Sprintf(`{"error":"failed to serialize AI500 list: %s"}`, err)
}
return string(payload)
}

104
agent/ai500_test.go Normal file
View File

@@ -0,0 +1,104 @@
package agent
import (
"encoding/json"
"errors"
"log/slog"
"strings"
"testing"
"nofx/provider/nofxos"
)
func withStubbedAI500(t *testing.T, fn func(walletKey string) ([]nofxos.CoinData, error)) {
t.Helper()
original := fetchAI500ForTool
fetchAI500ForTool = fn
t.Cleanup(func() { fetchAI500ForTool = original })
}
func TestToolGetAI500ListSortsByScoreAndLimits(t *testing.T) {
withStubbedAI500(t, func(walletKey string) ([]nofxos.CoinData, error) {
return []nofxos.CoinData{
{Pair: "LOWUSDT", Score: 10, IncreasePercent: -3},
{Pair: "TOPUSDT", Score: 99, IncreasePercent: 42},
{Pair: "MIDUSDT", Score: 55, IncreasePercent: 7},
}, nil
})
a := New(nil, nil, DefaultConfig(), slog.Default())
raw := a.toolGetAI500List("default", `{"limit": 2}`)
var resp struct {
Status string `json:"status"`
Count int `json:"count"`
Coins []struct {
Pair string `json:"pair"`
Score float64 `json:"score"`
IncreasePercent float64 `json:"increase_percent"`
} `json:"coins"`
}
if err := json.Unmarshal([]byte(raw), &resp); err != nil {
t.Fatalf("invalid JSON %q: %v", raw, err)
}
if resp.Status != "ok" || resp.Count != 2 || len(resp.Coins) != 2 {
t.Fatalf("unexpected response: %+v", resp)
}
if resp.Coins[0].Pair != "TOPUSDT" || resp.Coins[1].Pair != "MIDUSDT" {
t.Fatalf("expected score-descending order, got %+v", resp.Coins)
}
}
func TestToolGetAI500ListDefaultLimit(t *testing.T) {
coins := make([]nofxos.CoinData, 30)
for i := range coins {
coins[i] = nofxos.CoinData{Pair: "C", Score: float64(i)}
}
withStubbedAI500(t, func(walletKey string) ([]nofxos.CoinData, error) {
return coins, nil
})
a := New(nil, nil, DefaultConfig(), slog.Default())
var resp struct {
Count int `json:"count"`
}
if err := json.Unmarshal([]byte(a.toolGetAI500List("default", "")), &resp); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if resp.Count != 20 {
t.Fatalf("default limit = %d, want 20", resp.Count)
}
}
func TestToolGetAI500ListUpstreamError(t *testing.T) {
withStubbedAI500(t, func(walletKey string) ([]nofxos.CoinData, error) {
return nil, errors.New("upstream down")
})
a := New(nil, nil, DefaultConfig(), slog.Default())
raw := a.toolGetAI500List("default", "")
if !strings.Contains(raw, `"error"`) {
t.Fatalf("expected error payload, got %q", raw)
}
}
func TestHandleToolCallDispatchesAI500(t *testing.T) {
withStubbedAI500(t, func(walletKey string) ([]nofxos.CoinData, error) {
return []nofxos.CoinData{{Pair: "BTCUSDT", Score: 90}}, nil
})
a := New(nil, nil, DefaultConfig(), slog.Default())
raw := a.handleToolCall(t.Context(), "default", 1, "zh", toolCall("c1", "get_ai500_list", "{}"))
if !strings.Contains(raw, "BTCUSDT") {
t.Fatalf("dispatch failed, got %q", raw)
}
}
func TestAgentToolsIncludeAI500(t *testing.T) {
for _, tool := range agentTools() {
if tool.Function.Name == "get_ai500_list" {
return
}
}
t.Fatal("get_ai500_list missing from agent toolset")
}

View File

@@ -104,7 +104,7 @@ func plannerToolNamesForDomain(domain string) []string {
"get_strategies", "manage_strategy",
"manage_trader",
"get_balance", "get_positions", "get_trade_history",
"get_candidate_coins",
"get_candidate_coins", "get_ai500_list",
"get_watchlist", "manage_watchlist",
// Trade execution
"execute_trade",
@@ -115,7 +115,7 @@ func plannerToolNamesForDomain(domain string) []string {
case "__all__", "":
return all
case "market":
return []string{"get_market_snapshot", "get_market_price", "get_kline", "search_stock"}
return []string{"get_market_snapshot", "get_market_price", "get_kline", "search_stock", "get_ai500_list"}
case "account":
return []string{"get_balance", "get_positions", "get_trade_history", "get_exchange_configs"}
case "trader":
@@ -858,6 +858,22 @@ func buildAgentTools() []mcp.Tool {
},
},
},
{
Type: "function",
Function: mcp.FunctionDef{
Name: "get_ai500_list",
Description: "Get the AI500 index board: crypto symbols scored 0-100 by AI with their gain since entering the index, sorted by score. Use this whenever the user asks what's in AI500, wants coin recommendations, or asks you to pick promising/strong coins and hasn't named specific ones — the top entries are the well-performing candidates.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"limit": map[string]any{
"type": "integer",
"description": "Max entries to return, default 20, max 100.",
},
},
},
},
},
{
Type: "function",
Function: mcp.FunctionDef{
@@ -934,6 +950,8 @@ func (a *Agent) handleToolCall(ctx context.Context, storeUserID string, userID i
return a.toolGetTradeHistory(tc.Function.Arguments)
case "get_candidate_coins":
return a.toolGetCandidateCoins(storeUserID, userID, tc.Function.Arguments)
case "get_ai500_list":
return a.toolGetAI500List(storeUserID, tc.Function.Arguments)
case "get_watchlist":
return a.toolGetWatchlist(lang)
case "manage_watchlist":

43
api/handler_ai500.go Normal file
View File

@@ -0,0 +1,43 @@
package api
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"nofx/agent"
)
// handleAI500List serves the AI500 index board for the agent UI panel.
// Data is fetched through the user's claw402 wallet when one is configured
// (falling back to the direct nofxos client) and served from a 5-minute
// cache, so panel polling never hammers the upstream.
func (s *Server) handleAI500List(c *gin.Context) {
userID := c.GetString("user_id")
limit := 0
if raw := c.Query("limit"); raw != "" {
parsed, err := strconv.Atoi(raw)
if err != nil || parsed < 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "limit must be a non-negative integer"})
return
}
limit = parsed
}
walletKey := agent.Claw402WalletKeyForStoreUser(s.store, userID)
entries, err := agent.AI500Board(walletKey, limit)
if err != nil {
SafeInternalError(c, "Get AI500 list", err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"coins": entries,
"count": len(entries),
},
})
}

View File

@@ -213,6 +213,9 @@ func (s *Server) setupRoutes() {
// Server IP query (requires authentication, for whitelist configuration)
s.route(protected, "GET", "/server-ip", "Get server public IP (for exchange whitelist)", s.handleGetServerIP)
// AI500 index board (cached; routed through claw402 when configured)
s.route(protected, "GET", "/ai500", "AI500 index board (?limit=20)", s.handleAI500List)
// AI trader management
s.routeWithSchema(protected, "GET", "/my-traders", "List user's traders with status",
`Returns: [{"trader_id":"<EXACT id — use this as trader_id in all ?trader_id= queries and POST /traders/:id/start|stop>","trader_name":"<string>","is_running":<bool>}]

View File

@@ -0,0 +1,60 @@
package nofxos
import (
"log"
"sync"
"time"
)
// ai500CacheTTL bounds how often the AI500 board is re-fetched. The list is
// refreshed upstream on the order of minutes, every claw402-routed call costs
// money, and the agent UI polls this for display — so short staleness is
// preferable to per-render upstream calls.
const ai500CacheTTL = 5 * time.Minute
type ai500CacheStore struct {
mu sync.Mutex
coins []CoinData
fetchedAt time.Time
}
var ai500Cache = &ai500CacheStore{}
// fetchAI500ListFn is swappable in tests.
var fetchAI500ListFn = func(c *Client) ([]CoinData, error) {
return c.GetAI500List()
}
// GetAI500ListCached returns the AI500 coin list served from a TTL cache.
// When the upstream fetch fails and stale data exists, the stale board is
// served instead of an error so displays keep working through flakiness.
func GetAI500ListCached(c *Client) ([]CoinData, error) {
ai500Cache.mu.Lock()
defer ai500Cache.mu.Unlock()
hasCache := len(ai500Cache.coins) > 0
if hasCache && time.Since(ai500Cache.fetchedAt) < ai500CacheTTL {
return copyCoinData(ai500Cache.coins), nil
}
coins, err := fetchAI500ListFn(c)
if err != nil {
if hasCache {
log.Printf("⚠️ AI500 fetch failed (%v); serving cached list from %s",
err, ai500Cache.fetchedAt.Format(time.RFC3339))
return copyCoinData(ai500Cache.coins), nil
}
return nil, err
}
ai500Cache.coins = coins
ai500Cache.fetchedAt = time.Now()
return copyCoinData(coins), nil
}
// copyCoinData returns a defensive copy so callers cannot mutate the cache.
func copyCoinData(coins []CoinData) []CoinData {
out := make([]CoinData, len(coins))
copy(out, coins)
return out
}

View File

@@ -0,0 +1,86 @@
package nofxos
import (
"errors"
"testing"
"time"
)
func withStubbedAI500Fetch(t *testing.T, fn func(c *Client) ([]CoinData, error)) {
t.Helper()
original := fetchAI500ListFn
fetchAI500ListFn = fn
ai500Cache.mu.Lock()
ai500Cache.coins = nil
ai500Cache.fetchedAt = time.Time{}
ai500Cache.mu.Unlock()
t.Cleanup(func() {
fetchAI500ListFn = original
ai500Cache.mu.Lock()
ai500Cache.coins = nil
ai500Cache.fetchedAt = time.Time{}
ai500Cache.mu.Unlock()
})
}
func TestGetAI500ListCachedWithinTTL(t *testing.T) {
calls := 0
withStubbedAI500Fetch(t, func(c *Client) ([]CoinData, error) {
calls++
return []CoinData{{Pair: "BTCUSDT", Score: 95.5}}, nil
})
client := NewClient("", "")
first, err := GetAI500ListCached(client)
if err != nil {
t.Fatalf("first call: %v", err)
}
second, err := GetAI500ListCached(client)
if err != nil {
t.Fatalf("second call: %v", err)
}
if calls != 1 {
t.Fatalf("fetch calls = %d, want 1 (second call must hit cache)", calls)
}
if len(first) != 1 || len(second) != 1 || second[0].Pair != "BTCUSDT" {
t.Fatalf("unexpected results: first=%v second=%v", first, second)
}
}
func TestGetAI500ListCachedServesStaleOnError(t *testing.T) {
calls := 0
withStubbedAI500Fetch(t, func(c *Client) ([]CoinData, error) {
calls++
if calls == 1 {
return []CoinData{{Pair: "ETHUSDT", Score: 88}}, nil
}
return nil, errors.New("API returned status 429")
})
client := NewClient("", "")
if _, err := GetAI500ListCached(client); err != nil {
t.Fatalf("first call: %v", err)
}
ai500Cache.mu.Lock()
ai500Cache.fetchedAt = time.Now().Add(-2 * ai500CacheTTL)
ai500Cache.mu.Unlock()
coins, err := GetAI500ListCached(client)
if err != nil {
t.Fatalf("expected stale data instead of error, got: %v", err)
}
if len(coins) != 1 || coins[0].Pair != "ETHUSDT" {
t.Fatalf("expected stale ETH entry, got %v", coins)
}
}
func TestGetAI500ListCachedErrorsWithoutCache(t *testing.T) {
withStubbedAI500Fetch(t, func(c *Client) ([]CoinData, error) {
return nil, errors.New("upstream down")
})
if _, err := GetAI500ListCached(NewClient("", "")); err == nil {
t.Fatal("expected error when upstream fails with empty cache")
}
}

View File

@@ -0,0 +1,32 @@
package nofxos
import (
"os"
"strings"
"nofx/logger"
)
// ResolveClient returns a nofxos data client, routed through the claw402
// x402 payment gateway when a wallet key is available. Resolution order:
// the explicit walletKey argument, then the CLAW402_WALLET_KEY environment
// variable, then the direct nofxos.ai client with the default auth key.
func ResolveClient(walletKey string) *Client {
walletKey = strings.TrimSpace(walletKey)
if walletKey == "" {
walletKey = strings.TrimSpace(os.Getenv("CLAW402_WALLET_KEY"))
}
client := NewClient(DefaultBaseURL, DefaultAuthKey)
if walletKey == "" {
return client
}
claw402URL := strings.TrimSpace(os.Getenv("CLAW402_URL"))
claw402Client, err := NewClaw402DataClient(claw402URL, walletKey, &logger.MCPLogger{})
if err != nil {
logger.Warnf("⚠️ Failed to init claw402 data client: %v (using direct nofxos.ai)", err)
return client
}
client.SetClaw402(claw402Client)
return client
}

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