mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-07 13:00:59 +08:00
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:
31
nofxi/Dockerfile
Normal file
31
nofxi/Dockerfile
Normal 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
20
nofxi/config.yaml
Normal 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
23
nofxi/docker-compose.yml
Normal 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:
|
||||
25
nofxi/go.mod
25
nofxi/go.mod
@@ -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
|
||||
)
|
||||
47
nofxi/go.sum
47
nofxi/go.sum
@@ -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=
|
||||
@@ -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*
|
||||
|
||||
|
||||
@@ -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 := ""
|
||||
|
||||
@@ -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.
|
||||
|
||||
199
nofxi/internal/agent/strategy.go
Normal file
199
nofxi/internal/agent/strategy.go
Normal 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()
|
||||
}
|
||||
52
nofxi/internal/execution/factory.go
Normal file
52
nofxi/internal/execution/factory.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
BIN
nofxi/nofxi.db-shm
Normal file
Binary file not shown.
0
nofxi/nofxi.db-wal
Normal file
0
nofxi/nofxi.db-wal
Normal file
335
nofxi/web/index.html
Normal file
335
nofxi/web/index.html
Normal 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,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
|
||||
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>
|
||||
Reference in New Issue
Block a user