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