Add explorer AI chat and context endpoints

This commit is contained in:
defiQUG
2026-03-27 13:37:53 -07:00
parent 0f4630f443
commit f6f25aa457
4 changed files with 1272 additions and 5 deletions

958
backend/api/rest/ai.go Normal file
View File

@@ -0,0 +1,958 @@
package rest
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
)
const (
defaultExplorerAIModel = "gpt-5.4-mini"
defaultExplorerAIReasoningEffort = "low"
maxExplorerAIMessages = 12
maxExplorerAIMessageChars = 4000
maxExplorerAIContextChars = 22000
maxExplorerAIDocSnippets = 6
)
var (
addressPattern = regexp.MustCompile(`0x[a-fA-F0-9]{40}`)
transactionPattern = regexp.MustCompile(`0x[a-fA-F0-9]{64}`)
blockRefPattern = regexp.MustCompile(`(?i)\bblock\s+#?(\d+)\b`)
)
type AIChatMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
type AIChatRequest struct {
Messages []AIChatMessage `json:"messages"`
PageContext map[string]string `json:"pageContext,omitempty"`
}
type AIContextResponse struct {
Enabled bool `json:"enabled"`
Query string `json:"query,omitempty"`
GeneratedAt string `json:"generatedAt"`
Model string `json:"model"`
Context AIContextEnvelope `json:"context"`
Warnings []string `json:"warnings,omitempty"`
}
type AIChatResponse struct {
Reply string `json:"reply"`
Model string `json:"model"`
GeneratedAt string `json:"generatedAt"`
Context AIContextEnvelope `json:"context"`
Warnings []string `json:"warnings,omitempty"`
}
type AIContextEnvelope struct {
ChainID int `json:"chainId"`
Explorer string `json:"explorer"`
PageContext map[string]string `json:"pageContext,omitempty"`
Stats map[string]any `json:"stats,omitempty"`
Address map[string]any `json:"address,omitempty"`
Transaction map[string]any `json:"transaction,omitempty"`
Block map[string]any `json:"block,omitempty"`
RouteMatches []map[string]any `json:"routeMatches,omitempty"`
DocSnippets []AIDocSnippet `json:"docSnippets,omitempty"`
CapabilityNotice string `json:"capabilityNotice"`
Sources []AIContextSource `json:"sources,omitempty"`
}
type AIDocSnippet struct {
Path string `json:"path"`
Line int `json:"line"`
Snippet string `json:"snippet"`
}
type AIContextSource struct {
Type string `json:"type"`
Label string `json:"label"`
Origin string `json:"origin,omitempty"`
}
type openAIResponsesRequest struct {
Model string `json:"model"`
Input []openAIInputMessage `json:"input"`
Reasoning *openAIReasoning `json:"reasoning,omitempty"`
}
type openAIReasoning struct {
Effort string `json:"effort,omitempty"`
}
type openAIInputMessage struct {
Role string `json:"role"`
Content []openAIInputContent `json:"content"`
}
type openAIInputContent struct {
Type string `json:"type"`
Text string `json:"text"`
}
type openAIResponsesResponse struct {
Model string `json:"model"`
OutputText string `json:"output_text"`
Output []openAIOutputItem `json:"output"`
}
type openAIOutputItem struct {
Type string `json:"type"`
Content []openAIOutputContent `json:"content"`
}
type openAIOutputContent struct {
Type string `json:"type"`
Text string `json:"text"`
}
func (s *Server) handleAIContext(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeMethodNotAllowed(w)
return
}
query := strings.TrimSpace(r.URL.Query().Get("q"))
pageContext := map[string]string{
"path": strings.TrimSpace(r.URL.Query().Get("path")),
"view": strings.TrimSpace(r.URL.Query().Get("view")),
}
ctxEnvelope, warnings := s.buildAIContext(r.Context(), query, pageContext)
response := AIContextResponse{
Enabled: explorerAIEnabled(),
Query: query,
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
Model: explorerAIModel(),
Context: ctxEnvelope,
Warnings: warnings,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
func (s *Server) handleAIChat(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeMethodNotAllowed(w)
return
}
if !explorerAIEnabled() {
writeError(w, http.StatusServiceUnavailable, "service_unavailable", "explorer ai is not configured; set OPENAI_API_KEY on the backend")
return
}
defer r.Body.Close()
body := http.MaxBytesReader(w, r.Body, 1<<20)
var chatReq AIChatRequest
if err := json.NewDecoder(body).Decode(&chatReq); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "invalid ai chat payload")
return
}
messages := normalizeAIMessages(chatReq.Messages)
if len(messages) == 0 {
writeError(w, http.StatusBadRequest, "bad_request", "at least one non-empty ai message is required")
return
}
latestUser := latestUserMessage(messages)
ctxEnvelope, warnings := s.buildAIContext(r.Context(), latestUser, chatReq.PageContext)
reply, model, err := s.callOpenAIResponses(r.Context(), messages, ctxEnvelope)
if err != nil {
writeError(w, http.StatusBadGateway, "bad_gateway", fmt.Sprintf("explorer ai request failed: %v", err))
return
}
response := AIChatResponse{
Reply: reply,
Model: model,
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
Context: ctxEnvelope,
Warnings: warnings,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
func explorerAIEnabled() bool {
return strings.TrimSpace(os.Getenv("OPENAI_API_KEY")) != ""
}
func explorerAIModel() string {
if model := strings.TrimSpace(os.Getenv("OPENAI_MODEL")); model != "" {
return model
}
if model := strings.TrimSpace(os.Getenv("EXPLORER_AI_MODEL")); model != "" {
return model
}
return defaultExplorerAIModel
}
func explorerAIReasoningEffort() string {
if effort := strings.TrimSpace(os.Getenv("OPENAI_REASONING_EFFORT")); effort != "" {
return effort
}
if effort := strings.TrimSpace(os.Getenv("EXPLORER_AI_REASONING_EFFORT")); effort != "" {
return effort
}
return defaultExplorerAIReasoningEffort
}
func (s *Server) buildAIContext(ctx context.Context, query string, pageContext map[string]string) (AIContextEnvelope, []string) {
warnings := []string{}
envelope := AIContextEnvelope{
ChainID: s.chainID,
Explorer: "SolaceScanScout",
PageContext: compactStringMap(pageContext),
CapabilityNotice: "This assistant is wired for read-only explorer analysis. It can summarize indexed chain data, liquidity routes, and curated workspace docs, but it does not sign transactions or execute private operations.",
}
sources := []AIContextSource{
{Type: "system", Label: "Explorer REST backend"},
}
if stats, err := s.queryAIStats(ctx); err == nil {
envelope.Stats = stats
sources = append(sources, AIContextSource{Type: "database", Label: "Explorer indexer database"})
} else if err != nil {
warnings = append(warnings, "indexed explorer stats unavailable: "+err.Error())
}
if strings.TrimSpace(query) != "" {
if txHash := firstRegexMatch(transactionPattern, query); txHash != "" && s.db != nil {
if tx, err := s.queryAITransaction(ctx, txHash); err == nil && len(tx) > 0 {
envelope.Transaction = tx
} else if err != nil {
warnings = append(warnings, "transaction context unavailable: "+err.Error())
}
}
if addr := firstRegexMatch(addressPattern, query); addr != "" && s.db != nil {
if addressInfo, err := s.queryAIAddress(ctx, addr); err == nil && len(addressInfo) > 0 {
envelope.Address = addressInfo
} else if err != nil {
warnings = append(warnings, "address context unavailable: "+err.Error())
}
}
if blockNumber := extractBlockReference(query); blockNumber > 0 && s.db != nil {
if block, err := s.queryAIBlock(ctx, blockNumber); err == nil && len(block) > 0 {
envelope.Block = block
} else if err != nil {
warnings = append(warnings, "block context unavailable: "+err.Error())
}
}
}
if routeMatches, routeWarning := s.queryAIRoutes(ctx, query); len(routeMatches) > 0 {
envelope.RouteMatches = routeMatches
sources = append(sources, AIContextSource{Type: "routes", Label: "Token aggregation live routes", Origin: firstNonEmptyEnv("TOKEN_AGGREGATION_API_BASE", "TOKEN_AGGREGATION_URL", "TOKEN_AGGREGATION_BASE_URL")})
} else if routeWarning != "" {
warnings = append(warnings, routeWarning)
}
if docs, root, docWarning := loadAIDocSnippets(query); len(docs) > 0 {
envelope.DocSnippets = docs
sources = append(sources, AIContextSource{Type: "docs", Label: "Workspace docs", Origin: root})
} else if docWarning != "" {
warnings = append(warnings, docWarning)
}
envelope.Sources = sources
return envelope, uniqueStrings(warnings)
}
func (s *Server) queryAIStats(ctx context.Context) (map[string]any, error) {
if s.db == nil {
return nil, fmt.Errorf("database unavailable")
}
ctx, cancel := context.WithTimeout(ctx, 4*time.Second)
defer cancel()
stats := map[string]any{}
var totalBlocks int64
if err := s.db.QueryRow(ctx, `SELECT COUNT(*) FROM blocks WHERE chain_id = $1`, s.chainID).Scan(&totalBlocks); err == nil {
stats["total_blocks"] = totalBlocks
}
var totalTransactions int64
if err := s.db.QueryRow(ctx, `SELECT COUNT(*) FROM transactions WHERE chain_id = $1`, s.chainID).Scan(&totalTransactions); err == nil {
stats["total_transactions"] = totalTransactions
}
var totalAddresses int64
if err := s.db.QueryRow(ctx, `SELECT COUNT(DISTINCT from_address) + COUNT(DISTINCT to_address) FROM transactions WHERE chain_id = $1`, s.chainID).Scan(&totalAddresses); err == nil {
stats["total_addresses"] = totalAddresses
}
var latestBlock int64
if err := s.db.QueryRow(ctx, `SELECT COALESCE(MAX(number), 0) FROM blocks WHERE chain_id = $1`, s.chainID).Scan(&latestBlock); err == nil {
stats["latest_block"] = latestBlock
}
if len(stats) == 0 {
return nil, fmt.Errorf("no indexed stats available")
}
return stats, nil
}
func (s *Server) queryAITransaction(ctx context.Context, hash string) (map[string]any, error) {
ctx, cancel := context.WithTimeout(ctx, 4*time.Second)
defer cancel()
query := `
SELECT hash, block_number, from_address, to_address, value, gas_used, gas_price, status, timestamp_iso
FROM transactions
WHERE chain_id = $1 AND hash = $2
LIMIT 1
`
var txHash, fromAddress, value string
var blockNumber int64
var toAddress *string
var gasUsed, gasPrice *int64
var status *int64
var timestampISO *string
err := s.db.QueryRow(ctx, query, s.chainID, hash).Scan(
&txHash, &blockNumber, &fromAddress, &toAddress, &value, &gasUsed, &gasPrice, &status, &timestampISO,
)
if err != nil {
return nil, err
}
tx := map[string]any{
"hash": txHash,
"block_number": blockNumber,
"from_address": fromAddress,
"value": value,
}
if toAddress != nil {
tx["to_address"] = *toAddress
}
if gasUsed != nil {
tx["gas_used"] = *gasUsed
}
if gasPrice != nil {
tx["gas_price"] = *gasPrice
}
if status != nil {
tx["status"] = *status
}
if timestampISO != nil {
tx["timestamp_iso"] = *timestampISO
}
return tx, nil
}
func (s *Server) queryAIAddress(ctx context.Context, address string) (map[string]any, error) {
ctx, cancel := context.WithTimeout(ctx, 4*time.Second)
defer cancel()
result := map[string]any{
"address": address,
}
var txCount int64
if err := s.db.QueryRow(ctx, `SELECT COUNT(*) FROM transactions WHERE chain_id = $1 AND (from_address = $2 OR to_address = $2)`, s.chainID, address).Scan(&txCount); err == nil {
result["transaction_count"] = txCount
}
var tokenCount int64
if err := s.db.QueryRow(ctx, `SELECT COUNT(DISTINCT token_address) FROM token_transfers WHERE chain_id = $1 AND (from_address = $2 OR to_address = $2)`, s.chainID, address).Scan(&tokenCount); err == nil {
result["token_count"] = tokenCount
}
var recentHashes []string
rows, err := s.db.Query(ctx, `
SELECT hash
FROM transactions
WHERE chain_id = $1 AND (from_address = $2 OR to_address = $2)
ORDER BY block_number DESC, transaction_index DESC
LIMIT 5
`, s.chainID, address)
if err == nil {
defer rows.Close()
for rows.Next() {
var hash string
if scanErr := rows.Scan(&hash); scanErr == nil {
recentHashes = append(recentHashes, hash)
}
}
}
if len(recentHashes) > 0 {
result["recent_transactions"] = recentHashes
}
if len(result) == 1 {
return nil, fmt.Errorf("address not found")
}
return result, nil
}
func (s *Server) queryAIBlock(ctx context.Context, blockNumber int64) (map[string]any, error) {
ctx, cancel := context.WithTimeout(ctx, 4*time.Second)
defer cancel()
query := `
SELECT number, hash, parent_hash, transaction_count, gas_used, gas_limit, timestamp_iso
FROM blocks
WHERE chain_id = $1 AND number = $2
LIMIT 1
`
var number int64
var hash, parentHash string
var transactionCount int64
var gasUsed, gasLimit int64
var timestampISO *string
err := s.db.QueryRow(ctx, query, s.chainID, blockNumber).Scan(&number, &hash, &parentHash, &transactionCount, &gasUsed, &gasLimit, &timestampISO)
if err != nil {
return nil, err
}
block := map[string]any{
"number": number,
"hash": hash,
"parent_hash": parentHash,
"transaction_count": transactionCount,
"gas_used": gasUsed,
"gas_limit": gasLimit,
}
if timestampISO != nil {
block["timestamp_iso"] = *timestampISO
}
return block, nil
}
func (s *Server) queryAIRoutes(ctx context.Context, query string) ([]map[string]any, string) {
baseURL := strings.TrimSpace(firstNonEmptyEnv(
"TOKEN_AGGREGATION_API_BASE",
"TOKEN_AGGREGATION_URL",
"TOKEN_AGGREGATION_BASE_URL",
))
if baseURL == "" {
return nil, "token aggregation api base url is not configured for ai route retrieval"
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(baseURL, "/")+"/api/v1/routes/ingestion?fromChainId=138", nil)
if err != nil {
return nil, "unable to build token aggregation ai request"
}
client := &http.Client{Timeout: 6 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, "token aggregation live routes unavailable: " + err.Error()
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return nil, fmt.Sprintf("token aggregation live routes returned %d", resp.StatusCode)
}
var payload struct {
Routes []map[string]any `json:"routes"`
}
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
return nil, "unable to decode token aggregation live routes"
}
if len(payload.Routes) == 0 {
return nil, "token aggregation returned no live routes"
}
matches := filterAIRouteMatches(payload.Routes, query)
return matches, ""
}
func filterAIRouteMatches(routes []map[string]any, query string) []map[string]any {
query = strings.ToLower(strings.TrimSpace(query))
matches := make([]map[string]any, 0, 6)
for _, route := range routes {
if query != "" && !routeMatchesQuery(route, query) {
continue
}
trimmed := map[string]any{
"routeId": route["routeId"],
"status": route["status"],
"routeType": route["routeType"],
"fromChainId": route["fromChainId"],
"toChainId": route["toChainId"],
"tokenInSymbol": route["tokenInSymbol"],
"tokenOutSymbol": route["tokenOutSymbol"],
"assetSymbol": route["assetSymbol"],
"label": route["label"],
"aggregatorFamilies": route["aggregatorFamilies"],
"hopCount": route["hopCount"],
"bridgeType": route["bridgeType"],
"tags": route["tags"],
}
matches = append(matches, compactAnyMap(trimmed))
if len(matches) >= 6 {
break
}
}
if len(matches) == 0 {
for _, route := range routes {
trimmed := map[string]any{
"routeId": route["routeId"],
"status": route["status"],
"routeType": route["routeType"],
"fromChainId": route["fromChainId"],
"toChainId": route["toChainId"],
"tokenInSymbol": route["tokenInSymbol"],
"tokenOutSymbol": route["tokenOutSymbol"],
"assetSymbol": route["assetSymbol"],
"label": route["label"],
"aggregatorFamilies": route["aggregatorFamilies"],
}
matches = append(matches, compactAnyMap(trimmed))
if len(matches) >= 4 {
break
}
}
}
return matches
}
func routeMatchesQuery(route map[string]any, query string) bool {
fields := []string{
stringValue(route["routeId"]),
stringValue(route["routeType"]),
stringValue(route["tokenInSymbol"]),
stringValue(route["tokenOutSymbol"]),
stringValue(route["assetSymbol"]),
stringValue(route["label"]),
}
for _, field := range fields {
if strings.Contains(strings.ToLower(field), query) {
return true
}
}
for _, value := range stringSliceValue(route["aggregatorFamilies"]) {
if strings.Contains(strings.ToLower(value), query) {
return true
}
}
for _, value := range stringSliceValue(route["tags"]) {
if strings.Contains(strings.ToLower(value), query) {
return true
}
}
for _, symbol := range []string{"cusdt", "cusdc", "cxauc", "ceurt", "usdt", "usdc", "weth"} {
if strings.Contains(query, symbol) {
if strings.Contains(strings.ToLower(strings.Join(fields, " ")), symbol) {
return true
}
}
}
return false
}
func loadAIDocSnippets(query string) ([]AIDocSnippet, string, string) {
root := findAIWorkspaceRoot()
if root == "" {
return nil, "", "workspace docs root unavailable for ai doc retrieval"
}
relativePaths := []string{
"docs/11-references/ADDRESS_MATRIX_AND_STATUS.md",
"docs/11-references/LIQUIDITY_POOLS_MASTER_MAP.md",
"docs/11-references/DEPLOYED_TOKENS_BRIDGES_LPS_AND_ROUTING_STATUS.md",
"docs/11-references/EXPLORER_TOKEN_LIST_CROSSCHECK.md",
"explorer-monorepo/docs/EXPLORER_API_ACCESS.md",
}
terms := buildDocSearchTerms(query)
if len(terms) == 0 {
terms = []string{"chain 138", "bridge", "liquidity"}
}
snippets := []AIDocSnippet{}
for _, rel := range relativePaths {
fullPath := filepath.Join(root, rel)
fileSnippets := scanDocForTerms(fullPath, rel, terms)
snippets = append(snippets, fileSnippets...)
if len(snippets) >= maxExplorerAIDocSnippets {
break
}
}
if len(snippets) == 0 {
return nil, root, "no matching workspace docs found for ai context"
}
if len(snippets) > maxExplorerAIDocSnippets {
snippets = snippets[:maxExplorerAIDocSnippets]
}
return snippets, root, ""
}
func findAIWorkspaceRoot() string {
candidates := []string{}
if envRoot := strings.TrimSpace(os.Getenv("EXPLORER_AI_WORKSPACE_ROOT")); envRoot != "" {
candidates = append(candidates, envRoot)
}
if cwd, err := os.Getwd(); err == nil {
candidates = append(candidates, cwd)
dir := cwd
for i := 0; i < 4; i++ {
dir = filepath.Dir(dir)
candidates = append(candidates, dir)
}
}
candidates = append(candidates, "/opt/explorer-monorepo", "/home/intlc/projects/proxmox")
for _, candidate := range candidates {
if candidate == "" {
continue
}
if fileExists(filepath.Join(candidate, "docs")) && (fileExists(filepath.Join(candidate, "explorer-monorepo")) || fileExists(filepath.Join(candidate, "smom-dbis-138")) || fileExists(filepath.Join(candidate, "config"))) {
return candidate
}
}
return ""
}
func scanDocForTerms(fullPath, relativePath string, terms []string) []AIDocSnippet {
file, err := os.Open(fullPath)
if err != nil {
return nil
}
defer file.Close()
normalizedTerms := make([]string, 0, len(terms))
for _, term := range terms {
term = strings.ToLower(strings.TrimSpace(term))
if len(term) >= 3 {
normalizedTerms = append(normalizedTerms, term)
}
}
scanner := bufio.NewScanner(file)
lineNumber := 0
snippets := []AIDocSnippet{}
for scanner.Scan() {
lineNumber++
line := scanner.Text()
lower := strings.ToLower(line)
for _, term := range normalizedTerms {
if strings.Contains(lower, term) {
snippets = append(snippets, AIDocSnippet{
Path: relativePath,
Line: lineNumber,
Snippet: clipString(strings.TrimSpace(line), 280),
})
break
}
}
if len(snippets) >= 2 {
break
}
}
return snippets
}
func buildDocSearchTerms(query string) []string {
words := strings.Fields(strings.ToLower(query))
stopWords := map[string]bool{
"what": true, "when": true, "where": true, "which": true, "with": true, "from": true,
"that": true, "this": true, "have": true, "about": true, "into": true, "show": true,
"live": true, "help": true, "explain": true, "tell": true,
}
terms := []string{}
for _, word := range words {
word = strings.Trim(word, ".,:;!?()[]{}\"'")
if len(word) < 4 || stopWords[word] {
continue
}
terms = append(terms, word)
}
for _, match := range addressPattern.FindAllString(query, -1) {
terms = append(terms, strings.ToLower(match))
}
for _, symbol := range []string{"cUSDT", "cUSDC", "cXAUC", "cEURT", "USDT", "USDC", "WETH", "WETH10", "Mainnet", "bridge", "liquidity", "pool"} {
if strings.Contains(strings.ToLower(query), strings.ToLower(symbol)) {
terms = append(terms, strings.ToLower(symbol))
}
}
return uniqueStrings(terms)
}
func normalizeAIMessages(messages []AIChatMessage) []AIChatMessage {
normalized := make([]AIChatMessage, 0, len(messages))
for _, message := range messages {
role := strings.ToLower(strings.TrimSpace(message.Role))
if role != "assistant" && role != "user" && role != "system" {
continue
}
content := clipString(strings.TrimSpace(message.Content), maxExplorerAIMessageChars)
if content == "" {
continue
}
normalized = append(normalized, AIChatMessage{
Role: role,
Content: content,
})
}
if len(normalized) > maxExplorerAIMessages {
normalized = normalized[len(normalized)-maxExplorerAIMessages:]
}
return normalized
}
func latestUserMessage(messages []AIChatMessage) string {
for i := len(messages) - 1; i >= 0; i-- {
if messages[i].Role == "user" {
return messages[i].Content
}
}
if len(messages) == 0 {
return ""
}
return messages[len(messages)-1].Content
}
func (s *Server) callOpenAIResponses(ctx context.Context, messages []AIChatMessage, contextEnvelope AIContextEnvelope) (string, string, error) {
apiKey := strings.TrimSpace(os.Getenv("OPENAI_API_KEY"))
if apiKey == "" {
return "", "", fmt.Errorf("OPENAI_API_KEY is not configured")
}
model := explorerAIModel()
baseURL := strings.TrimRight(strings.TrimSpace(os.Getenv("OPENAI_BASE_URL")), "/")
if baseURL == "" {
baseURL = "https://api.openai.com/v1"
}
contextJSON, _ := json.MarshalIndent(contextEnvelope, "", " ")
contextText := clipString(string(contextJSON), maxExplorerAIContextChars)
input := []openAIInputMessage{
{
Role: "system",
Content: []openAIInputContent{
{
Type: "input_text",
Text: "You are the SolaceScanScout ecosystem assistant for Chain 138. Answer using the supplied indexed explorer data, route inventory, and workspace documentation. Be concise, operationally useful, and explicit about uncertainty. Never claim a route, deployment, or production status is live unless the provided context says it is live. If data is missing, say exactly what is missing.",
},
},
},
{
Role: "system",
Content: []openAIInputContent{
{
Type: "input_text",
Text: "Retrieved ecosystem context:\n" + contextText,
},
},
},
}
for _, message := range messages {
input = append(input, openAIInputMessage{
Role: message.Role,
Content: []openAIInputContent{
{
Type: "input_text",
Text: message.Content,
},
},
})
}
payload := openAIResponsesRequest{
Model: model,
Input: input,
Reasoning: &openAIReasoning{
Effort: explorerAIReasoningEffort(),
},
}
body, err := json.Marshal(payload)
if err != nil {
return "", model, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+"/responses", bytes.NewReader(body))
if err != nil {
return "", model, err
}
req.Header.Set("Authorization", "Bearer "+apiKey)
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 45 * time.Second}
resp, err := client.Do(req)
if err != nil {
return "", model, err
}
defer resp.Body.Close()
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return "", model, err
}
if resp.StatusCode >= 400 {
return "", model, fmt.Errorf("openai responses api returned %d: %s", resp.StatusCode, clipString(string(responseBody), 400))
}
var response openAIResponsesResponse
if err := json.Unmarshal(responseBody, &response); err != nil {
return "", model, fmt.Errorf("unable to decode openai response: %w", err)
}
reply := strings.TrimSpace(response.OutputText)
if reply == "" {
reply = strings.TrimSpace(extractOutputText(response.Output))
}
if reply == "" {
return "", model, fmt.Errorf("openai response did not include output text")
}
if strings.TrimSpace(response.Model) != "" {
model = response.Model
}
return reply, model, nil
}
func extractOutputText(items []openAIOutputItem) string {
parts := []string{}
for _, item := range items {
for _, content := range item.Content {
if strings.TrimSpace(content.Text) != "" {
parts = append(parts, strings.TrimSpace(content.Text))
}
}
}
return strings.Join(parts, "\n\n")
}
func extractBlockReference(query string) int64 {
match := blockRefPattern.FindStringSubmatch(query)
if len(match) != 2 {
return 0
}
var value int64
fmt.Sscan(match[1], &value)
return value
}
func firstRegexMatch(pattern *regexp.Regexp, value string) string {
match := pattern.FindString(value)
return strings.TrimSpace(match)
}
func compactStringMap(values map[string]string) map[string]string {
if len(values) == 0 {
return nil
}
out := map[string]string{}
for key, value := range values {
if trimmed := strings.TrimSpace(value); trimmed != "" {
out[key] = trimmed
}
}
if len(out) == 0 {
return nil
}
return out
}
func compactAnyMap(values map[string]any) map[string]any {
out := map[string]any{}
for key, value := range values {
if value == nil {
continue
}
switch typed := value.(type) {
case string:
if strings.TrimSpace(typed) == "" {
continue
}
case []string:
if len(typed) == 0 {
continue
}
case []any:
if len(typed) == 0 {
continue
}
}
out[key] = value
}
return out
}
func stringValue(value any) string {
switch typed := value.(type) {
case string:
return typed
case fmt.Stringer:
return typed.String()
default:
return fmt.Sprintf("%v", value)
}
}
func stringSliceValue(value any) []string {
switch typed := value.(type) {
case []string:
return typed
case []any:
out := make([]string, 0, len(typed))
for _, item := range typed {
out = append(out, stringValue(item))
}
return out
default:
return nil
}
}
func uniqueStrings(values []string) []string {
seen := map[string]bool{}
out := []string{}
for _, value := range values {
trimmed := strings.TrimSpace(value)
if trimmed == "" || seen[trimmed] {
continue
}
seen[trimmed] = true
out = append(out, trimmed)
}
sort.Strings(out)
return out
}
func clipString(value string, limit int) string {
value = strings.TrimSpace(value)
if limit <= 0 || len(value) <= limit {
return value
}
return strings.TrimSpace(value[:limit]) + "..."
}
func fileExists(path string) bool {
if path == "" {
return false
}
info, err := os.Stat(path)
return err == nil && info != nil
}

View File

@@ -1,6 +1,7 @@
package rest_test
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
@@ -109,8 +110,8 @@ func TestSearchEndpoint(t *testing.T) {
_, mux := setupTestServer(t)
testCases := []struct {
name string
query string
name string
query string
wantCode int
}{
{"block number", "?q=1000", http.StatusOK},
@@ -179,7 +180,7 @@ func TestErrorHandling(t *testing.T) {
mux.ServeHTTP(w, req)
assert.True(t, w.Code >= http.StatusBadRequest)
var errorResponse map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &errorResponse)
if err == nil {
@@ -213,6 +214,33 @@ func TestPagination(t *testing.T) {
}
}
func TestAIContextEndpoint(t *testing.T) {
_, mux := setupTestServer(t)
req := httptest.NewRequest("GET", "/api/v1/ai/context?q=cUSDT+bridge", nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var response map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
assert.NotNil(t, response["context"])
}
func TestAIChatEndpointRequiresOpenAIKey(t *testing.T) {
_, mux := setupTestServer(t)
body := bytes.NewBufferString(`{"messages":[{"role":"user","content":"What is live on Chain 138?"}]}`)
req := httptest.NewRequest("POST", "/api/v1/ai/chat", body)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
assert.Equal(t, http.StatusServiceUnavailable, w.Code)
}
// TestRequestTimeout tests request timeout handling
func TestRequestTimeout(t *testing.T) {
// This would test timeout behavior
@@ -225,11 +253,10 @@ func BenchmarkListBlocks(b *testing.B) {
_, mux := setupTestServer(&testing.T{})
req := httptest.NewRequest("GET", "/api/v1/blocks?limit=10&page=1", nil)
b.ResetTimer()
for i := 0; i < b.N; i++ {
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
}
}

View File

@@ -39,6 +39,10 @@ func (s *Server) SetupRoutes(mux *http.ServeMux) {
// Feature flags endpoint
mux.HandleFunc("/api/v1/features", s.handleFeatures)
// Explorer AI endpoints
mux.HandleFunc("/api/v1/ai/context", s.handleAIContext)
mux.HandleFunc("/api/v1/ai/chat", s.handleAIChat)
// Route decision tree proxy
mux.HandleFunc("/api/v1/routes/tree", s.handleRouteDecisionTree)
mux.HandleFunc("/api/v1/routes/depth", s.handleRouteDepth)

View File

@@ -1,5 +1,6 @@
const API_BASE = '/api';
const TOKEN_AGGREGATION_API_BASE = '/token-aggregation/api';
const EXPLORER_AI_API_BASE = API_BASE + '/v1/ai';
const FETCH_TIMEOUT_MS = 15000;
const RPC_HEALTH_TIMEOUT_MS = 5000;
const FETCH_MAX_RETRIES = 3;
@@ -34,6 +35,16 @@
const RPC_WS_URL = (typeof window !== 'undefined' && window.location && window.location.protocol === 'https:') ? RPC_WS_FQDN : RPC_WS_IP;
let _rpcUrlIndex = 0;
let _blocksScrollAnimationId = null;
let _explorerAIState = {
open: false,
loading: false,
messages: [
{
role: 'assistant',
content: 'Explorer AI is ready for read-only ecosystem analysis. Ask about routes, liquidity, bridges, addresses, transactions, or current Chain 138 status.'
}
]
};
async function getRpcUrl() {
if (RPC_URLS.length <= 1) return RPC_URLS[0];
const ac = new AbortController();
@@ -2313,6 +2324,48 @@
}
}
async function postJSON(url, payload) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS * 2);
try {
const response = await fetch(url, {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
},
credentials: 'omit',
signal: controller.signal,
body: JSON.stringify(payload || {})
});
clearTimeout(timeoutId);
const text = await response.text();
let parsed = {};
if (text) {
try {
parsed = JSON.parse(text);
} catch (e) {
parsed = { reply: text };
}
}
if (!response.ok) {
var message = (parsed && parsed.error && (parsed.error.message || parsed.error.code)) || text || response.statusText || 'Request failed';
throw new Error('HTTP ' + response.status + ': ' + message);
}
return parsed;
} catch (error) {
clearTimeout(timeoutId);
if (error.name === 'AbortError') {
throw new Error('Request timeout. Please try again.');
}
throw error;
}
}
async function loadStats() {
const statsGrid = document.getElementById('statsGrid');
if (!statsGrid) return;
@@ -5358,6 +5411,230 @@
return ether.toFixed(6).replace(/\.?0+$/, '');
}
function getExplorerAIPageContext() {
return {
path: (window.location && window.location.pathname) ? window.location.pathname : '/home',
view: currentView || 'home'
};
}
function renderExplorerAIMessages() {
var list = document.getElementById('explorerAIMessageList');
var status = document.getElementById('explorerAIStatus');
if (!list) return;
list.innerHTML = _explorerAIState.messages.map(function(message) {
var isAssistant = message.role === 'assistant';
var bubbleStyle = isAssistant
? 'background: rgba(37,99,235,0.10); border:1px solid rgba(37,99,235,0.18);'
: 'background: rgba(15,23,42,0.06); border:1px solid rgba(148,163,184,0.25);';
return '<div style="display:flex; justify-content:' + (isAssistant ? 'flex-start' : 'flex-end') + ';">' +
'<div style="max-width: 88%; padding: 0.85rem 0.95rem; border-radius: 16px; ' + bubbleStyle + '">' +
'<div style="font-size:0.72rem; letter-spacing:0.06em; text-transform:uppercase; color:var(--text-light); margin-bottom:0.35rem;">' + (isAssistant ? 'Explorer AI' : 'You') + '</div>' +
'<div style="white-space:pre-wrap; line-height:1.55;">' + escapeHtml(message.content || '') + '</div>' +
'</div>' +
'</div>';
}).join('');
if (_explorerAIState.loading) {
list.innerHTML += '<div style="display:flex; justify-content:flex-start;"><div style="padding:0.8rem 0.95rem; border-radius:16px; background:rgba(37,99,235,0.08); border:1px solid rgba(37,99,235,0.16); color:var(--text-light);">Thinking through indexed data, live routes, and docs...</div></div>';
}
list.scrollTop = list.scrollHeight;
if (status) {
status.textContent = _explorerAIState.loading
? 'Querying explorer data and the model...'
: 'Read-only assistant using indexed explorer data, route APIs, and curated docs.';
}
}
function setExplorerAIOpen(open) {
_explorerAIState.open = !!open;
var panel = document.getElementById('explorerAIPanel');
var button = document.getElementById('explorerAIFab');
if (panel) panel.style.display = open ? 'flex' : 'none';
if (button) button.setAttribute('aria-expanded', open ? 'true' : 'false');
if (open) {
renderExplorerAIMessages();
var input = document.getElementById('explorerAIInput');
if (input) setTimeout(function() { input.focus(); }, 30);
}
}
function toggleExplorerAIPanel(forceOpen) {
if (typeof forceOpen === 'boolean') {
setExplorerAIOpen(forceOpen);
return;
}
setExplorerAIOpen(!_explorerAIState.open);
}
window.toggleExplorerAIPanel = toggleExplorerAIPanel;
function buildExplorerAISourceSummary(context) {
if (!context || !Array.isArray(context.sources) || !context.sources.length) return '';
return context.sources.map(function(source) {
return source.label || source.type || 'source';
}).filter(Boolean).join(' | ');
}
async function submitExplorerAIMessage(prefill) {
var input = document.getElementById('explorerAIInput');
var raw = typeof prefill === 'string' ? prefill : (input ? input.value : '');
var question = String(raw || '').trim();
if (!question || _explorerAIState.loading) return;
_explorerAIState.messages.push({ role: 'user', content: question });
if (input) input.value = '';
_explorerAIState.loading = true;
renderExplorerAIMessages();
try {
var payload = {
messages: _explorerAIState.messages.slice(-8),
pageContext: getExplorerAIPageContext()
};
var response = await postJSON(EXPLORER_AI_API_BASE + '/chat', payload);
var reply = (response && response.reply) ? String(response.reply) : 'No reply returned.';
var sourceSummary = buildExplorerAISourceSummary(response && response.context);
if (sourceSummary) {
reply += '\n\nSources: ' + sourceSummary;
}
if (response && Array.isArray(response.warnings) && response.warnings.length) {
reply += '\n\nWarnings: ' + response.warnings.join(' | ');
}
_explorerAIState.messages.push({ role: 'assistant', content: reply });
} catch (error) {
_explorerAIState.messages.push({
role: 'assistant',
content: 'Explorer AI could not complete that request.\n\n' + (error && error.message ? error.message : 'Unknown error') + '\n\nIf this is production, confirm the backend has OPENAI_API_KEY and TOKEN_AGGREGATION_API_BASE configured.'
});
} finally {
_explorerAIState.loading = false;
renderExplorerAIMessages();
}
}
window.submitExplorerAIMessage = submitExplorerAIMessage;
function initExplorerAIPanel() {
if (document.getElementById('explorerAIPanel') || !document.body) return;
var style = document.createElement('style');
style.textContent = `
#explorerAIFab {
position: fixed;
right: 20px;
bottom: 20px;
z-index: 20010;
border: 0;
border-radius: 999px;
padding: 0.9rem 1rem;
background: linear-gradient(135deg, #0f172a, #2563eb);
color: #fff;
box-shadow: 0 16px 36px rgba(15,23,42,0.28);
cursor: pointer;
font-weight: 700;
letter-spacing: 0.02em;
}
#explorerAIPanel {
position: fixed;
right: 20px;
bottom: 84px;
width: min(420px, calc(100vw - 24px));
height: min(72vh, 680px);
display: none;
flex-direction: column;
z-index: 20010;
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 22px;
box-shadow: 0 24px 60px rgba(15,23,42,0.25);
overflow: hidden;
}
#explorerAIPanel textarea {
width: 100%;
min-height: 88px;
resize: vertical;
border-radius: 14px;
border: 1px solid var(--border);
background: var(--light);
color: var(--text);
padding: 0.85rem 0.9rem;
font: inherit;
}
@media (max-width: 680px) {
#explorerAIPanel {
right: 12px;
left: 12px;
bottom: 76px;
width: auto;
height: min(74vh, 720px);
}
#explorerAIFab {
right: 12px;
bottom: 12px;
}
}
`;
document.head.appendChild(style);
var button = document.createElement('button');
button.id = 'explorerAIFab';
button.type = 'button';
button.setAttribute('aria-expanded', 'false');
button.setAttribute('aria-controls', 'explorerAIPanel');
button.innerHTML = '<i class="fas fa-robot" aria-hidden="true" style="margin-right:0.45rem;"></i>Explorer AI';
button.addEventListener('click', function() { toggleExplorerAIPanel(); });
var panel = document.createElement('section');
panel.id = 'explorerAIPanel';
panel.setAttribute('aria-label', 'Explorer AI');
panel.innerHTML = '' +
'<div style="padding:1rem 1rem 0.85rem; border-bottom:1px solid var(--border); background:linear-gradient(180deg, rgba(37,99,235,0.10), rgba(37,99,235,0));">' +
'<div style="display:flex; align-items:flex-start; justify-content:space-between; gap:1rem;">' +
'<div>' +
'<div style="font-size:1rem; font-weight:800;">Explorer AI</div>' +
'<div id="explorerAIStatus" style="font-size:0.84rem; color:var(--text-light); margin-top:0.2rem;">Read-only assistant using indexed explorer data, route APIs, and curated docs.</div>' +
'</div>' +
'<button type="button" class="btn btn-secondary" style="padding:0.35rem 0.6rem;" onclick="toggleExplorerAIPanel(false)">Close</button>' +
'</div>' +
'<div style="display:flex; flex-wrap:wrap; gap:0.45rem; margin-top:0.85rem;">' +
'<button type="button" class="btn btn-secondary" style="padding:0.35rem 0.65rem;" onclick="submitExplorerAIMessage(\'Which Chain 138 routes are live right now?\')">Live routes</button>' +
'<button type="button" class="btn btn-secondary" style="padding:0.35rem 0.65rem;" onclick="submitExplorerAIMessage(\'Why would a route show partial instead of live?\')">Route status</button>' +
'<button type="button" class="btn btn-secondary" style="padding:0.35rem 0.65rem;" onclick="submitExplorerAIMessage(\'Summarize the current page context and what I can do next.\')">Current page</button>' +
'</div>' +
'</div>' +
'<div id="explorerAIMessageList" style="flex:1; overflow:auto; padding:1rem; display:grid; gap:0.7rem; background:linear-gradient(180deg, rgba(15,23,42,0.02), rgba(15,23,42,0));"></div>' +
'<div style="padding:1rem; border-top:1px solid var(--border); display:grid; gap:0.7rem;">' +
'<div style="font-size:0.78rem; color:var(--text-light);">Public explorer and route data only. No private key handling, no transaction execution.</div>' +
'<textarea id="explorerAIInput" placeholder="Ask about a tx hash, address, bridge path, liquidity pool, or route status..."></textarea>' +
'<div style="display:flex; justify-content:space-between; align-items:center; gap:0.75rem;">' +
'<div style="font-size:0.78rem; color:var(--text-light);">Shift+Enter for a new line. Enter to send.</div>' +
'<button type="button" class="btn btn-primary" id="explorerAISendBtn">Ask Explorer AI</button>' +
'</div>' +
'</div>';
document.body.appendChild(button);
document.body.appendChild(panel);
var input = document.getElementById('explorerAIInput');
var sendButton = document.getElementById('explorerAISendBtn');
if (sendButton) {
sendButton.addEventListener('click', function() {
submitExplorerAIMessage();
});
}
if (input) {
input.addEventListener('keydown', function(e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
submitExplorerAIMessage();
}
});
}
renderExplorerAIMessages();
}
// Export functions
function exportBlockData(blockNumber) {
// Fetch block data and export as JSON
@@ -5458,6 +5735,7 @@
// Search launcher, modal handlers, and mobile nav close on link click
document.addEventListener('DOMContentLoaded', () => {
initExplorerAIPanel();
const launchBtn = document.getElementById('searchLauncherBtn');
const modal = document.getElementById('smartSearchModal');
const backdrop = document.getElementById('smartSearchBackdrop');