feat(nofxi): Phase 3 complete - Exchange factory, Web UI, Strategy runner, Docker

Exchange Factory:
- CreateTrader() supports Binance/OKX/Bybit/Bitget/KuCoin/Gate
- Auto-registers traders from config on startup
- Direct import of nofx/trader packages (merged into main module)

Web UI:
- Dark theme chat interface at :8900
- Quick action sidebar (analyze, watch, positions, balance)
- Real-time health check indicator
- Mobile responsive

Strategy Runner:
- /strategy start BTC 1h - AI auto-analyzes on interval
- /strategy stop <id> - stop strategy
- /strategy list - view active strategies
- Notifications pushed to Telegram on signals
- Configurable intervals: 15m/30m/1h/4h

Docker:
- Multi-stage Dockerfile (alpine, ~20MB)
- docker-compose.yml with volume persistence

Bug fixes:
- Fixed panic on empty exchanges config
- Fixed thinking mode response parsing (qwen3 content:null)
- Added request timeout (55s) in Telegram handler
- Better error logging
This commit is contained in:
shinchan-zhai
2026-03-22 22:04:37 +08:00
parent cf7bf16c28
commit 34f5e6fe71
16 changed files with 784 additions and 87 deletions

31
nofxi/Dockerfile Normal file
View File

@@ -0,0 +1,31 @@
# Build stage
FROM golang:1.25-alpine AS builder
RUN apk add --no-cache git
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /nofxi ./nofxi/cmd/nofxi/
# Runtime stage
FROM alpine:3.19
RUN apk add --no-cache ca-certificates tzdata
WORKDIR /app
COPY --from=builder /nofxi .
COPY nofxi/config.example.yaml ./config.example.yaml
# Create data directory for SQLite
RUN mkdir -p /app/data
ENV TZ=Asia/Shanghai
EXPOSE 8900
ENTRYPOINT ["./nofxi"]
CMD ["--config", "/app/config.yaml"]

20
nofxi/config.yaml Normal file
View File

@@ -0,0 +1,20 @@
agent:
name: "NOFXi"
language: "zh"
log_level: "info"
web_port: 8900
telegram:
token: "8486603557:AAEqw9whg6uG63ROGCRJbz8D_J0lsXeQKps"
allowed_ids: []
llm:
provider: "dashscope"
base_url: "https://coding.dashscope.aliyuncs.com/v1"
api_key: "sk-sp-0511a0b965af4866831a0345e8109ea0"
model: "qwen3-coder-plus"
database:
path: "nofxi.db"
exchanges: []

23
nofxi/docker-compose.yml Normal file
View File

@@ -0,0 +1,23 @@
version: '3.8'
services:
nofxi:
build:
context: ..
dockerfile: nofxi/Dockerfile
container_name: nofxi
restart: unless-stopped
ports:
- "8900:8900"
volumes:
- ./config.yaml:/app/config.yaml:ro
- nofxi-data:/app/data
environment:
- TZ=Asia/Shanghai
# Override config via env vars:
# - NOFXI_TELEGRAM_TOKEN=xxx
# - NOFXI_LLM_API_KEY=xxx
# - NOFXI_LLM_BASE_URL=xxx
volumes:
nofxi-data:

View File

@@ -1,25 +0,0 @@
module github.com/NoFxAiOS/nofx/nofxi
go 1.21
require (
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.29.6
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/mattn/go-isatty v0.0.16 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/sys v0.16.0 // indirect
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
modernc.org/libc v1.41.0 // indirect
modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.7.2 // indirect
modernc.org/strutil v1.2.0 // indirect
modernc.org/token v1.1.0 // indirect
)

View File

@@ -1,47 +0,0 @@
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 h1:wG8n/XJQ07TmjbITcGiUaOtXxdrINDz1b0J1w0SzqDc=
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0=
golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/tools v0.17.0 h1:FvmRgNOcs3kOa+T20R1uhfP9F6HgG2mfxDv1vrx1Htc=
golang.org/x/tools v0.17.0/go.mod h1:xsh6VxdV005rRVaS6SSAf9oiAqljS7UZUacMZ8Bnsps=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI=
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
modernc.org/libc v1.41.0 h1:g9YAc6BkKlgORsUWj+JwqoB1wU3o4DE3bM3yvA3k+Gk=
modernc.org/libc v1.41.0/go.mod h1:w0eszPsiXoOnoMJgrXjglgLuDy/bt5RR4y3QzUUeodY=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E=
modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E=
modernc.org/sqlite v1.29.6 h1:0lOXGrycJPptfHDuohfYgNqoe4hu+gYuN/pKgY5XjS4=
modernc.org/sqlite v1.29.6/go.mod h1:S02dvcmm7TnTRvGhv8IGYyLnIt7AS2KPaB1F/71p75U=
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

View File

@@ -17,10 +17,10 @@ import (
"strings"
"time"
"github.com/NoFxAiOS/nofx/nofxi/internal/execution"
"github.com/NoFxAiOS/nofx/nofxi/internal/memory"
"github.com/NoFxAiOS/nofx/nofxi/internal/perception"
"github.com/NoFxAiOS/nofx/nofxi/internal/thinking"
"nofx/nofxi/internal/execution"
"nofx/nofxi/internal/memory"
"nofx/nofxi/internal/perception"
"nofx/nofxi/internal/thinking"
)
// Agent is the NOFXi agent core.
@@ -34,6 +34,9 @@ type Agent struct {
// NotifyFunc sends proactive notifications to users (set by interaction layer)
NotifyFunc func(userID int64, text string) error
// Strategy runner
strategyRunner *StrategyRunner
}
// New creates a new Agent with the given config.
@@ -44,6 +47,7 @@ func New(cfg *Config, mem *memory.Store, thinker thinking.Engine, logger *slog.L
thinker: thinker,
logger: logger,
}
a.strategyRunner = NewStrategyRunner(a, logger)
return a
}
@@ -86,6 +90,8 @@ func (a *Agent) HandleMessage(ctx context.Context, userID int64, text string) (s
response, err = a.handleTrade(ctx, userID, intent)
case IntentWatch:
response = a.HandleWatchCommand(intent.Raw)
case IntentStrategy:
response = a.handleStrategyCommand(intent.Raw)
case IntentSettings:
response = a.handleSettings(intent)
default:
@@ -369,6 +375,9 @@ func (a *Agent) handleTrade(ctx context.Context, userID int64, intent Intent) (s
}
// Use first configured exchange
if len(a.config.Exchanges) == 0 {
return "⚠️ 还没有配置交易所。请在 config.yaml 的 exchanges 中添加交易所 API Key。", nil
}
exchange := a.config.Exchanges[0].Name
// Confirm with user before executing
@@ -437,6 +446,64 @@ func (a *Agent) ExecutePendingTrade(ctx context.Context, userID int64) (string,
side, symbol, quantity, leverage, exchange, result), nil
}
func (a *Agent) handleStrategyCommand(text string) string {
parts := strings.Fields(text)
if len(parts) < 2 {
return a.strategyRunner.FormatStrategyList()
}
subcmd := strings.ToLower(parts[1])
switch subcmd {
case "list":
return a.strategyRunner.FormatStrategyList()
case "start":
if len(parts) < 3 {
return "Usage: `/strategy start BTC 1h` or `/strategy start ETH 4h binance`"
}
symbol := strings.ToUpper(parts[2])
if !strings.HasSuffix(symbol, "USDT") {
symbol += "USDT"
}
interval := 1 * time.Hour
if len(parts) >= 4 {
switch parts[3] {
case "15m":
interval = 15 * time.Minute
case "30m":
interval = 30 * time.Minute
case "1h":
interval = 1 * time.Hour
case "4h":
interval = 4 * time.Hour
}
}
exchange := "binance"
if len(parts) >= 5 {
exchange = parts[4]
}
name := fmt.Sprintf("AI-%s", symbol)
id, err := a.strategyRunner.StartStrategy(name, symbol, exchange, interval)
if err != nil {
return fmt.Sprintf("⚠️ %v", err)
}
return fmt.Sprintf("🚀 Strategy started!\n\n• ID: `%s`\n• Symbol: %s\n• Interval: %s\n• Exchange: %s\n\nStop with: `/strategy stop %s`",
id, symbol, interval, exchange, id)
case "stop":
if len(parts) < 3 {
return "Usage: `/strategy stop <id>`"
}
if err := a.strategyRunner.StopStrategy(parts[2]); err != nil {
return fmt.Sprintf("⚠️ %v", err)
}
return "✅ Strategy stopped."
case "stopall":
a.strategyRunner.StopAll()
return "✅ All strategies stopped."
default:
return "Unknown subcommand. Use: `/strategy list|start|stop|stopall`"
}
}
func (a *Agent) handleSettings(intent Intent) string {
return `⚙️ *Settings*

View File

@@ -24,6 +24,7 @@ const (
IntentHelp // Help / command list
IntentStatus // Check agent/system status
IntentWatch // Watch symbols, price alerts
IntentStrategy // Start/stop/list strategies
)
var (
@@ -134,6 +135,8 @@ func routeCommand(text string) Intent {
return Intent{Type: IntentQuery, Raw: text}
case "/watch", "/unwatch", "/alert", "/price":
return Intent{Type: IntentWatch, Raw: text}
case "/strategy":
return Intent{Type: IntentStrategy, Raw: text}
case "/analyze":
parts := strings.SplitN(text, " ", 2)
detail := ""

View File

@@ -7,7 +7,7 @@ import (
"strings"
"time"
"github.com/NoFxAiOS/nofx/nofxi/internal/memory"
"nofx/nofxi/internal/memory"
)
// Scheduler handles periodic tasks: daily reports, portfolio checks, etc.

View File

@@ -0,0 +1,199 @@
package agent
import (
"context"
"fmt"
"log/slog"
"strings"
"time"
)
// StrategyRunner manages automated trading strategies.
type StrategyRunner struct {
agent *Agent
logger *slog.Logger
stopCh chan struct{}
// Active strategies
activeStrategies map[string]*RunningStrategy
}
// RunningStrategy represents an active automated strategy.
type RunningStrategy struct {
ID string
Name string
Symbol string
Interval time.Duration
Exchange string
StopCh chan struct{}
Running bool
}
// NewStrategyRunner creates a new strategy runner.
func NewStrategyRunner(agent *Agent, logger *slog.Logger) *StrategyRunner {
return &StrategyRunner{
agent: agent,
logger: logger,
stopCh: make(chan struct{}),
activeStrategies: make(map[string]*RunningStrategy),
}
}
// StartStrategy begins an AI-driven trading strategy.
// The AI will periodically analyze the market and suggest/execute trades.
func (r *StrategyRunner) StartStrategy(name, symbol, exchange string, interval time.Duration) (string, error) {
id := fmt.Sprintf("%s-%s-%d", strings.ToLower(symbol), strings.ToLower(exchange), time.Now().Unix())
if _, exists := r.activeStrategies[id]; exists {
return "", fmt.Errorf("strategy already running: %s", id)
}
strategy := &RunningStrategy{
ID: id,
Name: name,
Symbol: symbol,
Interval: interval,
Exchange: exchange,
StopCh: make(chan struct{}),
Running: true,
}
r.activeStrategies[id] = strategy
go r.runStrategy(strategy)
r.logger.Info("strategy started",
"id", id,
"symbol", symbol,
"exchange", exchange,
"interval", interval,
)
return id, nil
}
// StopStrategy stops a running strategy.
func (r *StrategyRunner) StopStrategy(id string) error {
s, ok := r.activeStrategies[id]
if !ok {
return fmt.Errorf("strategy not found: %s", id)
}
close(s.StopCh)
s.Running = false
delete(r.activeStrategies, id)
r.logger.Info("strategy stopped", "id", id)
return nil
}
// StopAll stops all running strategies.
func (r *StrategyRunner) StopAll() {
for id, s := range r.activeStrategies {
close(s.StopCh)
s.Running = false
r.logger.Info("strategy stopped", "id", id)
}
r.activeStrategies = make(map[string]*RunningStrategy)
}
// ListStrategies returns all active strategies.
func (r *StrategyRunner) ListStrategies() []*RunningStrategy {
result := make([]*RunningStrategy, 0, len(r.activeStrategies))
for _, s := range r.activeStrategies {
result = append(result, s)
}
return result
}
func (r *StrategyRunner) runStrategy(s *RunningStrategy) {
ticker := time.NewTicker(s.Interval)
defer ticker.Stop()
// Initial analysis
r.executeStrategyTick(s)
for {
select {
case <-s.StopCh:
return
case <-r.stopCh:
return
case <-ticker.C:
r.executeStrategyTick(s)
}
}
}
func (r *StrategyRunner) executeStrategyTick(s *RunningStrategy) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
r.logger.Info("strategy tick", "id", s.ID, "symbol", s.Symbol)
// Get AI analysis
prompt := fmt.Sprintf(
"You are running an automated trading strategy for %s on %s.\n"+
"Analyze the current market and decide: should we BUY, SELL, or HOLD?\n"+
"Consider risk management. Only trade on high confidence signals.\n"+
"Respond with a brief analysis and your recommendation.",
s.Symbol, s.Exchange,
)
analysis, err := r.agent.thinker.Analyze(ctx, prompt)
if err != nil {
r.logger.Error("strategy analysis failed", "id", s.ID, "error", err)
return
}
r.logger.Info("strategy analysis",
"id", s.ID,
"action", analysis.Action,
"confidence", analysis.Confidence,
)
// Only execute on high confidence
if analysis.Confidence < 0.75 {
r.logger.Info("strategy: confidence too low, holding", "id", s.ID)
return
}
// Notify user about the signal
if r.agent.NotifyFunc != nil {
msg := fmt.Sprintf("🤖 *Strategy Signal: %s*\n\n"+
"Symbol: %s\n"+
"Action: %s\n"+
"Confidence: %.0f%%\n\n"+
"%s",
s.Name, s.Symbol,
strings.ToUpper(analysis.Action),
analysis.Confidence*100,
analysis.Reasoning,
)
for _, uid := range r.agent.config.Telegram.AllowedIDs {
r.agent.NotifyFunc(uid, msg)
}
}
// TODO: Auto-execute trades based on strategy config
// For now, just notify. Users can enable auto-execution in Phase 4.
}
// FormatStrategyList formats active strategies for display.
func (r *StrategyRunner) FormatStrategyList() string {
strategies := r.ListStrategies()
if len(strategies) == 0 {
return "📭 No active strategies.\n\nUse `/strategy start BTC 1h` to start one."
}
var sb strings.Builder
sb.WriteString("🤖 *Active Strategies*\n\n")
for _, s := range strategies {
status := "🟢"
if !s.Running {
status = "🔴"
}
sb.WriteString(fmt.Sprintf("%s *%s* — %s on %s (every %s)\n ID: `%s`\n\n",
status, s.Name, s.Symbol, s.Exchange, s.Interval, s.ID))
}
sb.WriteString("Stop with: `/strategy stop <id>`")
return sb.String()
}

View File

@@ -0,0 +1,52 @@
package execution
import (
"fmt"
"strings"
"nofx/trader/binance"
"nofx/trader/bitget"
"nofx/trader/bybit"
"nofx/trader/gate"
"nofx/trader/kucoin"
"nofx/trader/okx"
)
// ExchangeConfig holds credentials for creating a trader.
type ExchangeConfig struct {
Name string
APIKey string
APISecret string
Passphrase string
Testnet bool
}
// CreateTrader creates a NofxTrader for the given exchange.
func CreateTrader(cfg ExchangeConfig) (NofxTrader, error) {
switch strings.ToLower(cfg.Name) {
case "binance":
return binance.NewFuturesTrader(cfg.APIKey, cfg.APISecret, "nofxi"), nil
case "okx":
return okx.NewOKXTrader(cfg.APIKey, cfg.APISecret, cfg.Passphrase), nil
case "bybit":
return bybit.NewBybitTrader(cfg.APIKey, cfg.APISecret), nil
case "bitget":
return bitget.NewBitgetTrader(cfg.APIKey, cfg.APISecret, cfg.Passphrase), nil
case "kucoin":
return kucoin.NewKuCoinTrader(cfg.APIKey, cfg.APISecret, cfg.Passphrase), nil
case "gate":
return gate.NewGateTrader(cfg.APIKey, cfg.APISecret), nil
// Hyperliquid needs private key, not API key/secret
// case "hyperliquid":
// return hyperliquid.NewHyperliquidTrader(cfg.APIKey, "", cfg.Testnet)
default:
return nil, fmt.Errorf("unsupported exchange: %s", cfg.Name)
}
}

View File

@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"log/slog"
"time"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
)
@@ -65,7 +66,7 @@ func (t *TelegramBot) Start(ctx context.Context) error {
}
}
func (t *TelegramBot) handleUpdate(ctx context.Context, update tgbotapi.Update) {
func (t *TelegramBot) handleUpdate(parentCtx context.Context, update tgbotapi.Update) {
msg := update.Message
userID := msg.From.ID
@@ -91,13 +92,21 @@ func (t *TelegramBot) handleUpdate(ctx context.Context, update tgbotapi.Update)
typing := tgbotapi.NewChatAction(msg.Chat.ID, tgbotapi.ChatTyping)
t.bot.Send(typing)
// Process message
// Process message with timeout
ctx, cancel := context.WithTimeout(parentCtx, 55*time.Second)
defer cancel()
response, err := t.handler(ctx, userID, text)
if err != nil {
t.logger.Error("handle message", "error", err)
response = fmt.Sprintf("⚠️ Error: %v", err)
if ctx.Err() != nil {
response = "⏱️ AI 响应超时,请稍后再试。"
} else {
response = fmt.Sprintf("⚠️ Error: %v", err)
}
}
t.logger.Info("sending response", "user_id", userID, "len", len(response))
t.sendMarkdown(msg.Chat.ID, response)
}

View File

@@ -6,23 +6,28 @@ import (
"fmt"
"log/slog"
"net/http"
"os"
"path/filepath"
"strconv"
"time"
)
// WebServer provides a REST API for NOFXi.
// WebServer provides a REST API and Web UI for NOFXi.
type WebServer struct {
handler MessageHandler
port int
webDir string // Path to web/ directory for static files
logger *slog.Logger
server *http.Server
}
// NewWebServer creates a new web API server.
func NewWebServer(port int, handler MessageHandler, logger *slog.Logger) *WebServer {
// webDir is the path to the web/ directory containing index.html.
func NewWebServer(port int, handler MessageHandler, webDir string, logger *slog.Logger) *WebServer {
return &WebServer{
handler: handler,
port: port,
webDir: webDir,
logger: logger,
}
}
@@ -135,6 +140,14 @@ func (w *WebServer) Start(ctx context.Context) error {
})
})
// Serve web UI static files
if w.webDir != "" {
if _, err := os.Stat(filepath.Join(w.webDir, "index.html")); err == nil {
mux.Handle("/", http.FileServer(http.Dir(w.webDir)))
w.logger.Info("serving web UI", "dir", w.webDir)
}
}
addr := fmt.Sprintf(":%d", w.port)
w.server = &http.Server{
Addr: addr,

View File

@@ -11,7 +11,7 @@ import (
)
// LLMEngine implements Engine using an OpenAI-compatible API.
// Works with OpenAI, claw402 (x402), DeepSeek, Qwen, etc.
// Works with OpenAI, claw402 (x402), DeepSeek, Dashscope (Qwen), etc.
type LLMEngine struct {
baseURL string
apiKey string
@@ -29,7 +29,7 @@ func NewLLMEngine(baseURL, apiKey, model string) *LLMEngine {
apiKey: apiKey,
model: model,
httpClient: &http.Client{
Timeout: 120 * time.Second,
Timeout: 60 * time.Second,
},
}
}
@@ -40,11 +40,12 @@ type chatRequest struct {
Messages []Message `json:"messages"`
}
// chatResponse is the OpenAI chat completions response body.
// chatResponse handles both standard and thinking-mode responses.
type chatResponse struct {
Choices []struct {
Message struct {
Content string `json:"content"`
Content *string `json:"content"` // Can be null in thinking mode
ReasoningContent string `json:"reasoning_content"` // Qwen3 thinking mode
} `json:"message"`
} `json:"choices"`
Error *struct {
@@ -102,7 +103,23 @@ func (e *LLMEngine) Chat(ctx context.Context, messages []Message) (string, error
return "", fmt.Errorf("LLM returned no choices")
}
return chatResp.Choices[0].Message.Content, nil
// Extract content — handle thinking mode where content can be null
choice := chatResp.Choices[0]
content := ""
if choice.Message.Content != nil {
content = *choice.Message.Content
}
// If content is empty but reasoning_content exists, use that
if content == "" && choice.Message.ReasoningContent != "" {
content = choice.Message.ReasoningContent
}
if content == "" {
return "🤔 (AI returned empty response)", nil
}
return content, nil
}
// Analyze sends an analysis prompt and parses the AI response.

BIN
nofxi/nofxi.db-shm Normal file

Binary file not shown.

0
nofxi/nofxi.db-wal Normal file
View File

335
nofxi/web/index.html Normal file
View File

@@ -0,0 +1,335 @@
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NOFXi — AI Trading Agent</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--bg: #0a0a0f;
--surface: #12121a;
--border: #1e1e2e;
--text: #e0e0e0;
--text-dim: #888;
--accent: #00d4aa;
--accent2: #7c3aed;
--red: #ef4444;
--green: #22c55e;
--yellow: #eab308;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: var(--bg);
color: var(--text);
height: 100vh;
display: flex;
flex-direction: column;
}
/* Header */
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 24px;
border-bottom: 1px solid var(--border);
background: var(--surface);
}
.header h1 {
font-size: 20px;
font-weight: 700;
background: linear-gradient(135deg, var(--accent), var(--accent2));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.status {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--text-dim);
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--green);
animation: pulse 2s infinite;
}
.status-dot.offline { background: var(--red); animation: none; }
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
/* Main area */
.main {
flex: 1;
display: flex;
overflow: hidden;
}
/* Sidebar */
.sidebar {
width: 260px;
border-right: 1px solid var(--border);
background: var(--surface);
padding: 16px;
overflow-y: auto;
}
.sidebar h3 {
font-size: 12px;
text-transform: uppercase;
color: var(--text-dim);
margin-bottom: 12px;
letter-spacing: 1px;
}
.quick-btn {
display: block;
width: 100%;
padding: 10px 12px;
margin-bottom: 6px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text);
font-size: 13px;
cursor: pointer;
text-align: left;
transition: all 0.2s;
}
.quick-btn:hover {
border-color: var(--accent);
background: rgba(0, 212, 170, 0.05);
}
.quick-btn .icon { margin-right: 8px; }
/* Chat area */
.chat-container {
flex: 1;
display: flex;
flex-direction: column;
}
.messages {
flex: 1;
overflow-y: auto;
padding: 24px;
}
.message {
margin-bottom: 16px;
display: flex;
gap: 12px;
}
.message.user { justify-content: flex-end; }
.message .avatar {
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
flex-shrink: 0;
}
.message.assistant .avatar { background: var(--accent); color: #000; }
.message.user .avatar { background: var(--accent2); color: #fff; }
.message .bubble {
max-width: 70%;
padding: 12px 16px;
border-radius: 16px;
font-size: 14px;
line-height: 1.6;
white-space: pre-wrap;
}
.message.assistant .bubble {
background: var(--surface);
border: 1px solid var(--border);
border-bottom-left-radius: 4px;
}
.message.user .bubble {
background: var(--accent2);
color: #fff;
border-bottom-right-radius: 4px;
}
.typing {
color: var(--text-dim);
font-size: 13px;
padding: 8px 24px;
}
.typing::after {
content: '...';
animation: dots 1.5s infinite;
}
@keyframes dots {
0%, 20% { content: '.'; }
40% { content: '..'; }
60%, 100% { content: '...'; }
}
/* Input */
.input-area {
padding: 16px 24px;
border-top: 1px solid var(--border);
background: var(--surface);
}
.input-wrap {
display: flex;
gap: 12px;
max-width: 800px;
margin: 0 auto;
}
.input-wrap input {
flex: 1;
padding: 12px 16px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 12px;
color: var(--text);
font-size: 14px;
outline: none;
transition: border-color 0.2s;
}
.input-wrap input:focus { border-color: var(--accent); }
.input-wrap input::placeholder { color: var(--text-dim); }
.input-wrap button {
padding: 12px 24px;
background: linear-gradient(135deg, var(--accent), var(--accent2));
border: none;
border-radius: 12px;
color: #fff;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: opacity 0.2s;
}
.input-wrap button:hover { opacity: 0.9; }
.input-wrap button:disabled { opacity: 0.5; cursor: not-allowed; }
/* Mobile */
@media (max-width: 768px) {
.sidebar { display: none; }
.message .bubble { max-width: 85%; }
}
</style>
</head>
<body>
<div class="header">
<h1>🤖 NOFXi</h1>
<div class="status">
<div class="status-dot" id="statusDot"></div>
<span id="statusText">Connecting...</span>
</div>
</div>
<div class="main">
<div class="sidebar">
<h3>Quick Actions</h3>
<button class="quick-btn" onclick="send('/help')"><span class="icon">📋</span>Help</button>
<button class="quick-btn" onclick="send('/status')"><span class="icon">📊</span>Status</button>
<button class="quick-btn" onclick="send('/positions')"><span class="icon">💼</span>Positions</button>
<button class="quick-btn" onclick="send('/balance')"><span class="icon">💰</span>Balance</button>
<h3 style="margin-top:20px">Analysis</h3>
<button class="quick-btn" onclick="send('/analyze BTC')"><span class="icon">🔍</span>Analyze BTC</button>
<button class="quick-btn" onclick="send('/analyze ETH')"><span class="icon">🔍</span>Analyze ETH</button>
<button class="quick-btn" onclick="send('/analyze SOL')"><span class="icon">🔍</span>Analyze SOL</button>
<h3 style="margin-top:20px">Monitor</h3>
<button class="quick-btn" onclick="send('/watch BTCUSDT')"><span class="icon">👁️</span>Watch BTC</button>
<button class="quick-btn" onclick="send('/watch ETHUSDT')"><span class="icon">👁️</span>Watch ETH</button>
<button class="quick-btn" onclick="send('/price BTCUSDT')"><span class="icon">💲</span>BTC Price</button>
</div>
<div class="chat-container">
<div class="messages" id="messages">
<div class="message assistant">
<div class="avatar">🤖</div>
<div class="bubble">Hey! I'm NOFXi, your AI trading agent. 🚀
Ask me anything about markets, or use quick actions on the left.
Type /help to see all commands.</div>
</div>
</div>
<div class="typing" id="typing" style="display:none">NOFXi is thinking</div>
<div class="input-area">
<div class="input-wrap">
<input type="text" id="input" placeholder="Ask NOFXi anything... (e.g. 分析BTC, /buy ETH 0.1)"
onkeydown="if(event.key==='Enter')send()">
<button id="sendBtn" onclick="send()">Send</button>
</div>
</div>
</div>
</div>
<script>
const API = window.location.origin;
const messagesEl = document.getElementById('messages');
const inputEl = document.getElementById('input');
const typingEl = document.getElementById('typing');
const sendBtn = document.getElementById('sendBtn');
const statusDot = document.getElementById('statusDot');
const statusText = document.getElementById('statusText');
// Check health
async function checkHealth() {
try {
const r = await fetch(API + '/health');
const d = await r.json();
statusDot.classList.remove('offline');
statusText.textContent = 'Online · ' + d.agent;
} catch {
statusDot.classList.add('offline');
statusText.textContent = 'Offline';
}
}
checkHealth();
setInterval(checkHealth, 30000);
function addMessage(role, text) {
const div = document.createElement('div');
div.className = 'message ' + role;
const avatar = role === 'user' ? '👤' : '🤖';
div.innerHTML = `<div class="avatar">${avatar}</div><div class="bubble">${escapeHtml(text)}</div>`;
if (role === 'user') {
div.innerHTML = `<div class="bubble">${escapeHtml(text)}</div><div class="avatar">${avatar}</div>`;
}
messagesEl.appendChild(div);
messagesEl.scrollTop = messagesEl.scrollHeight;
}
function escapeHtml(t) {
return t.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
async function send(text) {
if (!text) {
text = inputEl.value.trim();
if (!text) return;
inputEl.value = '';
}
addMessage('user', text);
typingEl.style.display = 'block';
sendBtn.disabled = true;
try {
const r = await fetch(API + '/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: text, user_id: 1 })
});
const d = await r.json();
addMessage('assistant', d.response || d.error || 'No response');
} catch (e) {
addMessage('assistant', '⚠️ Connection error: ' + e.message);
}
typingEl.style.display = 'none';
sendBtn.disabled = false;
inputEl.focus();
}
</script>
</body>
</html>