diff --git a/agent/agent.go b/agent/agent.go index b8c80004..09a7f941 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -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 diff --git a/agent/ai500.go b/agent/ai500.go new file mode 100644 index 00000000..ccfcae77 --- /dev/null +++ b/agent/ai500.go @@ -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) +} diff --git a/agent/ai500_test.go b/agent/ai500_test.go new file mode 100644 index 00000000..8ab22e84 --- /dev/null +++ b/agent/ai500_test.go @@ -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") +} diff --git a/agent/tools.go b/agent/tools.go index 3d7043fc..40f5b775 100644 --- a/agent/tools.go +++ b/agent/tools.go @@ -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": diff --git a/api/handler_ai500.go b/api/handler_ai500.go new file mode 100644 index 00000000..6ff2fdc0 --- /dev/null +++ b/api/handler_ai500.go @@ -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), + }, + }) +} diff --git a/api/server.go b/api/server.go index bb2f73b6..098e4579 100644 --- a/api/server.go +++ b/api/server.go @@ -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":"","trader_name":"","is_running":}] diff --git a/provider/nofxos/ai500_cache.go b/provider/nofxos/ai500_cache.go new file mode 100644 index 00000000..fc0dd8f3 --- /dev/null +++ b/provider/nofxos/ai500_cache.go @@ -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 +} diff --git a/provider/nofxos/ai500_cache_test.go b/provider/nofxos/ai500_cache_test.go new file mode 100644 index 00000000..d6539731 --- /dev/null +++ b/provider/nofxos/ai500_cache_test.go @@ -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") + } +} diff --git a/provider/nofxos/client_resolve.go b/provider/nofxos/client_resolve.go new file mode 100644 index 00000000..41535f6c --- /dev/null +++ b/provider/nofxos/client_resolve.go @@ -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 +} diff --git a/web/src/components/agent/AI500Panel.tsx b/web/src/components/agent/AI500Panel.tsx new file mode 100644 index 00000000..aef087ce --- /dev/null +++ b/web/src/components/agent/AI500Panel.tsx @@ -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([]) + 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 ( +
+ {language === 'zh' ? '正在加载 AI500 榜单…' : 'Loading AI500 board…'} +
+ ) + } + + if (error) { + return ( +
+ {language === 'zh' ? 'AI500 榜单加载失败:' : 'Failed to load AI500: '} + {error} +
+ ) + } + + if (coins.length === 0) { + return ( +
+ {language === 'zh' ? '当前没有符合条件的 AI500 标的。' : 'No AI500 constituents right now.'} +
+ ) + } + + return ( +
+
+ + {language === 'zh' + ? 'AI 评分精选 · 点击标的让 Agent 分析' + : 'AI-scored picks · click to ask the agent'} +
+
+ {coins.map((coin, idx) => { + const display = coin.pair.replace(/USDT$/i, '') + const gain = Number(coin.increase_percent || 0) + return ( + + ) + })} +
+
+ ) +} diff --git a/web/src/lib/api/data.ts b/web/src/lib/api/data.ts index f94cdfbd..0cd70016 100644 --- a/web/src/lib/api/data.ts +++ b/web/src/lib/api/data.ts @@ -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 { const result = await httpClient.get( @@ -38,6 +52,12 @@ export const dataApi = { return result.data || { exchange, symbols: [], count: 0 } }, + async getAI500List(limit = 20): Promise { + const result = await httpClient.get(`${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 { const url = traderId ? `${API_BASE}/status?trader_id=${traderId}` diff --git a/web/src/pages/AgentChatPage.tsx b/web/src/pages/AgentChatPage.tsx index 6bf2cb3a..d7f56ce3 100644 --- a/web/src/pages/AgentChatPage.tsx +++ b/web/src/pages/AgentChatPage.tsx @@ -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: , }, + { + key: 'ai500' as const, + icon: , + title: language === 'zh' ? 'AI500 精选' : 'AI500 Picks', + component: ( + + ), + }, { key: 'hyperliquid' as const, icon: ,