mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-12 15:26:55 +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:
60
provider/nofxos/ai500_cache.go
Normal file
60
provider/nofxos/ai500_cache.go
Normal 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
|
||||
}
|
||||
86
provider/nofxos/ai500_cache_test.go
Normal file
86
provider/nofxos/ai500_cache_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
32
provider/nofxos/client_resolve.go
Normal file
32
provider/nofxos/client_resolve.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user