refactor: single-user web-based setup — replace env config with Settings UI

Move from multi-user env-var config to single-user web-first architecture:
- Add SetupPage for first-time initialization (replaces /register)
- Add SettingsPage for AI models, exchanges, Telegram, and password management
- Enrich all API route schemas with exact ID usage documentation
- Add PUT /user/password endpoint for in-app password changes
- Remove REGISTRATION_ENABLED, MAX_USERS, TELEGRAM_BOT_TOKEN from env config
- Simplify LoginPage design, remove admin mode and registration links
- Telegram bot now resolves user email for identity display
- start.sh no longer runs interactive Telegram setup
This commit is contained in:
tinkle-community
2026-03-09 23:55:39 +08:00
parent 9a3017af6d
commit 3ed0aec0ff
18 changed files with 1044 additions and 482 deletions

View File

@@ -75,46 +75,107 @@ func GenerateBotToken(userID string) (string, error) {
// and per-trader account summary + statistics) and returns it as a formatted string for
// injection into the LLM context at the start of each conversation.
func (a *Agent) buildAccountContext() string {
type q struct {
label string
path string
}
queries := []q{
{"AI Models", "/api/models"},
{"Exchanges", "/api/exchanges"},
{"Strategies", "/api/strategies"},
{"Traders", "/api/my-traders"},
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf("[Current Account State - Authenticated User ID: %s]\n\n", a.userID))
sb.WriteString(fmt.Sprintf("[Current Account State User: %s]\n\n", a.userID))
var tradersJSON string
for _, query := range queries {
result := a.apiTool.execute(&apiRequest{Method: "GET", Path: query.path})
sb.WriteString(fmt.Sprintf("%s:\n%s\n\n", query.label, result))
if query.path == "/api/my-traders" {
tradersJSON = result
}
// ── AI Models ─────────────────────────────────────────────────────────────
modelsRaw := a.apiTool.execute(&apiRequest{Method: "GET", Path: "/api/models"})
sb.WriteString("## AI Models\n")
sb.WriteString("⚠️ When creating a trader, use the EXACT \"id\" value below for \"ai_model_id\".\n")
sb.WriteString(" DO NOT use the \"provider\" field — it is NOT a valid ai_model_id.\n\n")
var models []struct {
ID string `json:"id"`
Name string `json:"name"`
Provider string `json:"provider"`
Enabled bool `json:"enabled"`
}
if err := json.Unmarshal([]byte(modelsRaw), &models); err == nil && len(models) > 0 {
for _, m := range models {
status := "disabled"
if m.Enabled {
status = "ENABLED"
}
sb.WriteString(fmt.Sprintf(" • ai_model_id=\"%s\" provider=%s name=%s [%s]\n", m.ID, m.Provider, m.Name, status))
}
} else {
sb.WriteString(modelsRaw)
}
sb.WriteString("\n")
// ── Exchanges ─────────────────────────────────────────────────────────────
exchangesRaw := a.apiTool.execute(&apiRequest{Method: "GET", Path: "/api/exchanges"})
sb.WriteString("## Exchanges\n")
sb.WriteString("⚠️ Use the EXACT \"id\" value below for \"exchange_id\" when creating a trader.\n\n")
var exchanges []struct {
ID string `json:"id"`
Name string `json:"name"`
ExchangeType string `json:"exchange_type"`
AccountName string `json:"account_name"`
Enabled bool `json:"enabled"`
}
if err := json.Unmarshal([]byte(exchangesRaw), &exchanges); err == nil && len(exchanges) > 0 {
for _, e := range exchanges {
status := "disabled"
if e.Enabled {
status = "ENABLED"
}
sb.WriteString(fmt.Sprintf(" • exchange_id=\"%s\" type=%s account=%s [%s]\n", e.ID, e.ExchangeType, e.AccountName, status))
}
} else {
sb.WriteString(exchangesRaw)
}
sb.WriteString("\n")
// ── Strategies ────────────────────────────────────────────────────────────
strategiesRaw := a.apiTool.execute(&apiRequest{Method: "GET", Path: "/api/strategies"})
sb.WriteString("## Strategies\n")
var strategies []struct {
ID string `json:"id"`
Name string `json:"name"`
}
if err := json.Unmarshal([]byte(strategiesRaw), &strategies); err == nil && len(strategies) > 0 {
for _, s := range strategies {
sb.WriteString(fmt.Sprintf(" • strategy_id=\"%s\" name=%s\n", s.ID, s.Name))
}
} else {
sb.WriteString(strategiesRaw)
}
sb.WriteString("\n")
// ── Traders ───────────────────────────────────────────────────────────────
tradersRaw := a.apiTool.execute(&apiRequest{Method: "GET", Path: "/api/my-traders"})
sb.WriteString("## Traders\n")
// For each running trader, fetch real-time account balance and trading statistics.
var traders []struct {
TraderID string `json:"trader_id"`
Name string `json:"trader_name"`
IsRunning bool `json:"is_running"`
}
if err := json.Unmarshal([]byte(tradersJSON), &traders); err == nil {
if err := json.Unmarshal([]byte(tradersRaw), &traders); err == nil && len(traders) > 0 {
for _, t := range traders {
if !t.IsRunning {
continue
status := "stopped"
if t.IsRunning {
status = "RUNNING"
}
acct := a.apiTool.execute(&apiRequest{Method: "GET", Path: "/api/account?trader_id=" + t.TraderID})
sb.WriteString(fmt.Sprintf("Account [%s] (trader_id=%s):\n%s\n\n", t.Name, t.TraderID, acct))
stats := a.apiTool.execute(&apiRequest{Method: "GET", Path: "/api/statistics?trader_id=" + t.TraderID})
sb.WriteString(fmt.Sprintf("Statistics [%s] (trader_id=%s):\n%s\n\n", t.Name, t.TraderID, stats))
sb.WriteString(fmt.Sprintf(" • trader_id=\"%s\" name=%s [%s]\n", t.TraderID, t.Name, status))
}
} else {
sb.WriteString(tradersRaw)
}
sb.WriteString("\n")
// ── Per-trader live data (running traders only) ────────────────────────────
for _, t := range traders {
if !t.IsRunning {
continue
}
acct := a.apiTool.execute(&apiRequest{Method: "GET", Path: "/api/account?trader_id=" + t.TraderID})
sb.WriteString(fmt.Sprintf("Account [%s]:\n%s\n\n", t.Name, acct))
stats := a.apiTool.execute(&apiRequest{Method: "GET", Path: "/api/statistics?trader_id=" + t.TraderID})
sb.WriteString(fmt.Sprintf("Statistics [%s]:\n%s\n\n", t.Name, stats))
}
return sb.String()

View File

@@ -21,8 +21,9 @@ type Manager struct {
}
// NewManager creates a Manager. Call api.GetAPIDocs() before this and pass the result as apiDocs.
// userID is the database user ID the bot authenticates as (used in system prompt context).
func NewManager(apiPort int, botToken, userID string, getLLM func() mcp.AIClient, apiDocs string) *Manager {
// userEmail is the registered email shown to the user when they ask "who am I".
// userID is the internal DB UUID used for API authentication.
func NewManager(apiPort int, botToken, userEmail, userID string, getLLM func() mcp.AIClient, apiDocs string) *Manager {
return &Manager{
agents: make(map[int64]*Agent),
lanes: make(map[int64]chan struct{}),
@@ -30,7 +31,7 @@ func NewManager(apiPort int, botToken, userID string, getLLM func() mcp.AIClient
botToken: botToken,
userID: userID,
getLLM: getLLM,
systemPrompt: BuildAgentPrompt(apiDocs, userID),
systemPrompt: BuildAgentPrompt(apiDocs, userEmail, userID),
}
}

View File

@@ -4,14 +4,16 @@ import "fmt"
// BuildAgentPrompt constructs the full system prompt with live API documentation injected.
// apiDocs is the output of api.GetAPIDocs() — reflects all currently registered routes with full schemas.
// userID is the actual database user ID the bot authenticates as.
func BuildAgentPrompt(apiDocs, userID string) string {
// userEmail is the registered email of the bound user (shown when user asks "who am I").
// userID is the internal DB UUID used for API authentication only.
func BuildAgentPrompt(apiDocs, userEmail, userID string) string {
return fmt.Sprintf(`You are the NOFX quantitative trading system AI assistant.
## Your Identity
- You are authenticated as user ID: %s
- You are operating as: %s
- Internal user ID (for API calls only): %s
- When asked "which user / account / email" — answer with the email address above
- All API calls are made on behalf of this user
- When asked "which user / username / email" — answer with this user ID directly, no API call needed
## Tool: api_request
Use the api_request tool to call the NOFX REST API:
@@ -23,6 +25,16 @@ Use the api_request tool to call the NOFX REST API:
%s
## CRITICAL: Exact ID Rule (read this before every API call)
API fields like "ai_model_id", "exchange_id", "strategy_id", "trader_id" require the EXACT "id" value
from the corresponding API response. NEVER use "provider", "type", or any other field as a substitute.
Wrong: {"ai_model_id": "deepseek"} ← "deepseek" is the provider, NOT the id
Correct: {"ai_model_id": "abc123_deepseek"} ← full "id" from GET /api/models
The Account State block at the start of this conversation lists every resource with its exact id.
Read the id field from there and copy it verbatim — do not abbreviate, shorten, or guess.
## Behavior Rules
1. Reply in the same language the user used (中文→中文, English→English)
2. Keep final replies concise — show results, not process
@@ -30,12 +42,6 @@ Use the api_request tool to call the NOFX REST API:
4. When user provides enough info, act immediately — no confirmation needed
5. Be decisive — infer intent from context, use schema to fill in smart defaults
## First-time Setup Detection
Check Account State at conversation start:
- If AI Models shows all disabled/unconfigured AND Exchanges empty → tell user to send /start for setup guide
- If Exchanges empty but models OK → guide user to configure exchange: ask for exchange type + API credentials in ONE message
- Never ask user to visit the web UI — everything can be done via chat
## Verification Rule (CRITICAL)
After ANY PUT or POST that creates or modifies a resource:
1. Immediately GET the resource to read actual saved values
@@ -45,7 +51,7 @@ After ANY PUT or POST that creates or modifies a resource:
## Error Handling
- 400: explain what was wrong, ask user to correct
- 404: resource doesn't exist, check IDs
- 404: resource doesn't exist — you may have used the wrong ID format; check the Account State for the exact id
- "AI model not enabled": tell user to enable the model first via PUT /api/models
- "Exchange not enabled": tell user to enable the exchange first
- 5xx: server error, ask user to try again
@@ -65,10 +71,6 @@ Use this to:
## Common Workflows
**Configure model**: Ask only for api_key. Set enabled:true, send empty strings for URL/model (backend applies provider defaults).
**Configure exchange**: Ask for all required fields in ONE message (see schema). Always set enabled:true.
**Create strategy** (independent from traders):
- Never GET trader info just to create a strategy.
- POST {"name":"<descriptive name>"} — config is OPTIONAL. Backend applies complete working defaults automatically (ai500 top coins, all indicators, standard risk control). Strategy is immediately usable.
@@ -91,5 +93,5 @@ Execute these steps IN ORDER with NO user confirmation between them:
**Start/stop existing trader**: From Account State, if only one trader, act directly. If multiple, list and ask.
**Query data**: Use trader_id from Account State, then query /api/positions?trader_id=xxx or /api/account?trader_id=xxx etc.`, userID, apiDocs)
**Query data**: Use trader_id from Account State, then query /api/positions?trader_id=xxx or /api/account?trader_id=xxx etc.`, userEmail, userID, apiDocs)
}

View File

@@ -39,13 +39,13 @@ func Start(cfg *config.Config, st *store.Store, reloadCh <-chan struct{}) {
}
}
// resolveToken returns the bot token, preferring the DB-stored value over the env/config value.
// resolveToken returns the bot token from DB (configured via Web UI).
func resolveToken(cfg *config.Config, st *store.Store) string {
dbCfg, err := st.TelegramConfig().Get()
if err == nil && dbCfg.BotToken != "" {
return dbCfg.BotToken
}
return cfg.TelegramBotToken
return ""
}
// runBot runs the bot until the updates channel closes (clean stop → true) or a fatal error (false).
@@ -57,46 +57,46 @@ func runBot(token string, cfg *config.Config, st *store.Store) bool {
}
logger.Infof("Telegram bot @%s started", bot.Self.UserName)
// Allowed chat ID: env override → DB-stored binding → 0 (unbound, first /start will bind).
allowedChatID := cfg.TelegramAdminChatID
if allowedChatID == 0 {
if id, err := st.TelegramConfig().GetBoundChatID(); err == nil && id != 0 {
allowedChatID = id
}
// Allowed chat ID: read from DB binding (0 = unbound, first /start will bind).
allowedChatID := int64(0)
if id, err := st.TelegramConfig().GetBoundChatID(); err == nil && id != 0 {
allowedChatID = id
}
// botUserID / botToken / agents are resolved lazily and refresh when user registers.
var (
botUserID string
botToken string
agents *agent.Manager
botUserID string
botUserEmail string
botToken string
agents *agent.Manager
)
resolveBotUser := func() bool {
ids, err := st.User().GetAllIDs()
if err != nil || len(ids) == 0 {
users, err := st.User().GetAll()
if err != nil || len(users) == 0 {
return false
}
newID := ids[0]
if newID == botUserID {
u := users[0]
if u.ID == botUserID {
return true
}
newToken, err := agent.GenerateBotToken(newID)
newToken, err := agent.GenerateBotToken(u.ID)
if err != nil {
logger.Errorf("Failed to generate bot JWT for user %s: %v", newID, err)
logger.Errorf("Failed to generate bot JWT for user %s: %v", u.ID, err)
return false
}
prev := botUserID
botUserID = newID
botUserID = u.ID
botUserEmail = u.Email
botToken = newToken
agents = agent.NewManager(cfg.APIServerPort, botToken, botUserID,
agents = agent.NewManager(cfg.APIServerPort, botToken, botUserEmail, botUserID,
func() mcp.AIClient { return newLLMClient(st, botUserID) },
api.GetAPIDocs(),
)
if prev == "" {
logger.Infof("Bot: resolved user %s", botUserID)
logger.Infof("Bot: resolved user %s (%s)", botUserID, botUserEmail)
} else {
logger.Infof("Bot: user changed %s %s", prev, botUserID)
logger.Infof("Bot: user changed %s (%s)", botUserID, botUserEmail)
}
return true
}