Files
nofx/agent/ai500.go
tinkle-community 2c6e2827e8 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
2026-06-11 22:11:03 +08:00

123 lines
3.3 KiB
Go

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