31 Commits

Author SHA1 Message Date
shinchan-zhai
0537ff3961 feat: forgot account reset flow + frontend default model fix to GLM
- Add forgot account reset flow with wallet preservation
- Update frontend default model references from DeepSeek to GLM
- Add reset-account API endpoint
- Add orphan record adoption for wallet/exchange preservation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 00:30:42 +08:00
deanokk
a99718ac60 fix(dashboard): preserve trader selection in URL and silence background requests (#1459)
* refactor: streamline trader selection logic and URL handling in App component

* refactor: update API request handling across components to use silent mode for improved error management

---------

Co-authored-by: Dean <afei.wuhao@gmail.com>
2026-04-04 23:39:05 +08:00
shinchan-zhai
b7635b0238 feat: change claw402 default model from deepseek to glm-5 2026-04-04 23:39:05 +08:00
Zavier
d353d8aed9 fix: improve trader error feedback, stale balance cleanup, and claw402 warnings (#1452)
* fix: improve trader error handling and balance validation

* fix: localize structured trader failure reasons

---------

Co-authored-by: apple <apple@MacbookPro-zbh.local>
2026-04-04 23:39:05 +08:00
deanokk
085ae3c875 feat: add exchange account states and refine beginner trader creation flow (#1450)
* feat: implement exchange account state management and UI updates

- Added functionality to invalidate exchange account state cache on exchange config updates, creation, and deletion.
- Introduced new API endpoint to fetch exchange account states.
- Updated frontend components to display exchange account states, including status and balance information.
- Enhanced user experience by refreshing exchange account states after relevant actions.

* feat: enhance trader creation readiness in AITradersPage and BeginnerGuideCards

---------

Co-authored-by: Dean <afei.wuhao@gmail.com>
2026-04-04 23:39:05 +08:00
shinchan-zhai
649ef50e44 docs: add MiniMax to AI models and beginner mode to setup across all i18n READMEs
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 23:39:05 +08:00
Zavier
47fb1c4675 perf: reduce frontend login and dashboard friction (#1447)
Co-authored-by: apple <apple@MacbookPro-zbh.local>
2026-04-04 23:39:05 +08:00
shinchan-zhai
fa048b44ac fix: auto re-fetch system config after invalidation
- invalidateSystemConfig() now dispatches a custom event
- useSystemConfig() listens for the event and re-fetches automatically
- Fixes stale initialized=false after register/logout causing
  incorrect redirect to SetupPage
2026-04-04 23:39:05 +08:00
shinchan-zhai
620eca08ec fix: clean stale auth state on login/setup, unify language switcher
- LoginPage/SetupPage: clear localStorage auth tokens on mount
- AuthContext: clear onboarding state on register, invalidate config on logout
- Extract shared LanguageSwitcher component for consistent UI
- Merge duplicate config import in AuthContext
2026-04-04 23:39:05 +08:00
deanokk
d7461c739a feat(beginner): protect default AI model and prevent repeated onboarding (#1444)
Co-authored-by: Dean <afei.wuhao@gmail.com>
2026-04-04 23:39:05 +08:00
shinchan-zhai
796606a8a8 fix: division by zero guard, logout redirect, onboarding close button
- auto_trader_risk: skip drawdown check when entryPrice <= 0
- AuthContext: redirect to / on logout
- App.tsx: simplify data page navigation
- BeginnerOnboardingPage: add close button to overlay
2026-04-04 23:39:05 +08:00
Zavier
4173c7678a feat: refine beginner wallet onboarding modal (#1438)
Co-authored-by: Codex <codex@openai.com>
2026-04-04 23:39:05 +08:00
shinchan-zhai
886650cc0e fix: guard short trader ID, i18n setup page, simplify onboarding UX
- main.go: prevent panic when trader ID < 8 chars
- SetupPage: add zh/en i18n labels
- BeginnerOnboardingPage: show private key by default, simplify code
2026-04-04 23:39:05 +08:00
shinchan-zhai
7ea813a46b fix(deps): resolve 11 npm vulnerabilities in frontend dependencies
Update react-router, rollup, picomatch, and yaml to patched versions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 23:39:05 +08:00
Dean
9a64d0f485 docs: update token estimation values for candidate coins in Chinese documentation 2026-04-04 23:39:05 +08:00
Dean
07fbe6d053 fix: update token limits and error handling in Trader Dashboard 2026-04-04 23:39:05 +08:00
Dean
c50b964fd1 fix: reduce candidate coin limit to 10, fix Select scroll and flash
- Lower MaxCandidateCoins from 50 to 10 (backend)
- Update CoinSourceEditor: options 1-10, default 3, max static coins 10
- Fix NofxSelect dropdown closing on internal scroll
- Fix NofxSelect position flash on open (useLayoutEffect)
2026-04-04 23:39:05 +08:00
Dean
a8057f6e9e style: apply gofmt to api/strategy.go and store/strategy.go 2026-04-04 23:39:05 +08:00
Dean
8c6ca75e93 fix: update error handling for account data fetch on Trader Dashboard 2026-04-04 23:39:05 +08:00
Dean
b9f6a32ad6 feat: localize default strategy names by UI language at registration
- Pass `lang` from register request body to createDefaultStrategies
- Support zh/en/id locales for strategy names and descriptions
- Wrap strategy creation in a transaction to prevent partial writes
- Frontend sends current UI language in register request body
- Strategy list UI: 2-line clamp, unselected border, larger spacing, smaller font for non-zh
2026-04-04 23:39:05 +08:00
Dean
eae05c5715 docs: add token estimation analysis for candidate coin limits 2026-04-04 23:39:05 +08:00
Dean
d6e3088998 fix: show -- instead of 0 when account data fetch fails on dashboard
Replace zero-value fallback with undefined, pass accountFailed prop to
distinguish load failure from initial loading skeleton.
2026-04-04 23:39:05 +08:00
Dean
4a483eaca6 feat: implement default strategy creation for new users 2026-04-04 23:39:05 +08:00
Dean
655b49450e feat: enhance token estimation and context limit handling in strategy configurations 2026-04-04 23:39:05 +08:00
Dean
85e1f7f963 feat: enhance strategy deletion process with user feedback and validation checks 2026-04-04 23:39:05 +08:00
Zavier
b9a33ce809 feat: improve user onboarding and setup UX (#1436)
* feat: add beginner onboarding and mode switching flow

* chore: ignore local gh auth config

* fix: restore kline fallback and align onboarding language

---------

Co-authored-by: zavier-bin <zhaobbbhhh@gmail.com>
2026-04-04 23:39:05 +08:00
shinchan-zhai
39b05b1a09 fix: fallback to Binance kline when coinank returns empty data for non-Binance exchanges
CoinAnk recently stopped providing free kline data for OKX/Bitget/Gate
exchanges (returns success but empty array). This caused '3-minute
k-line data is empty' errors for all users on those exchanges.

Fix: detect empty kline response and automatically fallback to Binance
kline data, which is always available.
2026-04-04 23:39:05 +08:00
deanokk
79a3be1874 fix: prevent DeepSeek token overflow with product-level limits (#1431)
* feat: enforce strategy limits to prevent token overflow

* fix: tune token limits after real-world testing

- Relax kline max 20→30, timeframes 3→4 (tested ~41K tokens, safe under 131K)
- Restore ranking limits to original [5,10,15,20] options (only ~1.5K token impact)
- Add static coins limit (max 3) with toast notification
- Add timeframe limit toast when exceeding 4
- Log SSE token usage (prompt/completion/total) from API response
- Fix nil logger crash in claw402 data client (engine.go)

* feat: add token estimation functionality for strategy configurations

* feat: add discard changes button in Strategy Studio for unsaved modifications

* feat: retain selected strategy after saving in Strategy Studio

* feat: enhance strategy display in Strategy Studio with improved layout and sorting of token limits

* refactor: improve layout and styling of stats display in CompetitionPage

* refactor: replace select elements with NofxSelect component for improved consistency in strategy configuration forms

* style: update NofxSelect component to use smaller text size for improved readability

* feat: implement token overflow handling in strategy updates and UI

---------

Co-authored-by: Dean <afei.wuhao@gmail.com>
2026-04-04 23:39:05 +08:00
shinchan-zhai
b705810ec2 feat: auto-reuse claw402 wallet for nofxos data — no extra config needed
When a trader uses claw402 as AI provider, the same wallet private key
is now automatically used to route nofxos data API calls (AI500, OI,
NetFlow, etc.) through claw402 payment as well.

Users don't need to configure anything extra — if they already set up
claw402 for AI, data APIs automatically go through claw402 too.
2026-04-04 23:39:05 +08:00
shinchan-zhai
a942c5312f feat: route nofxos data API calls through claw402 x402 payment
When CLAW402_WALLET_KEY env var is set, all nofxos.ai data API calls
(AI500, OI rankings, NetFlow, price rankings) are automatically routed
through claw402.ai with x402 USDC micropayment.

- provider/nofxos/claw402.go: x402 GET request client for data APIs
- provider/nofxos/client.go: claw402 mode support in doRequest()
- kernel/engine.go: auto-detect CLAW402_WALLET_KEY and enable routing
- mcp/payment/x402.go: MakeClaw402SignFunc helper

Without CLAW402_WALLET_KEY, falls back to direct nofxos.ai (backward compat).
2026-04-04 23:39:04 +08:00
Hansen1018
c18d3d5682 feat: update default MiniMax model to M2.7 (#1428) 2026-04-04 23:39:04 +08:00
132 changed files with 2200 additions and 25931 deletions

3
.gitignore vendored
View File

@@ -126,6 +126,3 @@ dmypy.json
# Pyre type checker
.pyre/
PR_DESCRIPTION.md
# Go build artifacts
/nofx-server

152
README.md
View File

@@ -45,20 +45,6 @@ Open **http://127.0.0.1:3000**. Done.
---
## Quick Demo
<p align="center">
<a href="https://drive.google.com/file/d/1frzw-HDZ3viQvLOQKsAJGc9bT0dXs68D/view">
<img src="screenshots/demo-cover.png" alt="NOFX quick demo video" width="900"/>
</a>
</p>
<p align="center">
Click the cover image to watch the demo video.
</p>
---
## How x402 Works
Traditional flow: register account → buy credits → get API key → manage quota → rotate keys.
@@ -73,22 +59,22 @@ No accounts. No API keys. No prepaid credits. One wallet, every model.
### Built-in x402 Providers
| Provider | Chain | Models |
| :--------------------------------------------------------------------------------------------------------------------------------- | :---- | :-------------------------------------------------------------------- |
| <img src="web/public/icons/claw402.png" width="20" height="20" style="vertical-align: middle;"/> **[Claw402](https://claw402.ai)** | Base | GPT-5.4, Claude Opus, DeepSeek, Qwen, Grok, Gemini, Kimi — 15+ models |
| Provider | Chain | Models |
|:---------|:------|:-------|
| <img src="web/public/icons/claw402.png" width="20" height="20" style="vertical-align: middle;"/> **[Claw402](https://claw402.ai)** | Base | GPT-5.4, Claude Opus, DeepSeek, Qwen, Grok, Gemini, Kimi — 15+ models |
---
## What It Does
| Feature | Description |
| :------------------ | :------------------------------------------------------------------------ |
| **Multi-AI** | DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi, MiniMax — switch anytime |
| **Multi-Exchange** | Binance, Bybit, OKX, Bitget, KuCoin, Gate, Hyperliquid, Aster, Lighter |
| **Strategy Studio** | Visual builder — coin sources, indicators, risk controls |
| **AI Competition** | AIs compete in real-time, leaderboard ranks performance |
| **Telegram Agent** | Chat with your trading assistant — streaming, tool calling, memory |
| **Dashboard** | Live positions, P/L, AI decision logs with Chain of Thought |
| Feature | Description |
|:--------|:------------|
| **Multi-AI** | DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi, MiniMax — switch anytime |
| **Multi-Exchange** | Binance, Bybit, OKX, Bitget, KuCoin, Gate, Hyperliquid, Aster, Lighter |
| **Strategy Studio** | Visual builder — coin sources, indicators, risk controls |
| **AI Competition** | AIs compete in real-time, leaderboard ranks performance |
| **Telegram Agent** | Chat with your trading assistant — streaming, tool calling, memory |
| **Dashboard** | Live positions, P/L, AI decision logs with Chain of Thought |
### Markets
@@ -96,35 +82,35 @@ Crypto · US Stocks · Forex · Metals
### Exchanges (CEX)
| Exchange | Status | Register (Fee Discount) |
| :-------------------------------------------------------------------------------------------------------------------- | :----: | :----------------------------------------------------------------------------------- |
| <img src="web/public/exchange-icons/binance.jpg" width="20" height="20" style="vertical-align: middle;"/> **Binance** | | [Register](https://www.binance.com/join?ref=NOFXENG) |
| <img src="web/public/exchange-icons/bybit.png" width="20" height="20" style="vertical-align: middle;"/> **Bybit** | ✅ | [Register](https://partner.bybit.com/b/83856) |
| <img src="web/public/exchange-icons/okx.svg" width="20" height="20" style="vertical-align: middle;"/> **OKX** | ✅ | [Register](https://www.okx.com/join/1865360) |
| <img src="web/public/exchange-icons/bitget.svg" width="20" height="20" style="vertical-align: middle;"/> **Bitget** | ✅ | [Register](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
| <img src="web/public/exchange-icons/kucoin.svg" width="20" height="20" style="vertical-align: middle;"/> **KuCoin** | ✅ | [Register](https://www.kucoin.com/r/broker/CXEV7XKK) |
| <img src="web/public/exchange-icons/gate.svg" width="20" height="20" style="vertical-align: middle;"/> **Gate** | ✅ | [Register](https://www.gatenode.xyz/share/VQBGUAxY) |
| Exchange | Status | Register (Fee Discount) |
|:---------|:------:|:------------------------|
| <img src="web/public/exchange-icons/binance.jpg" width="20" height="20" style="vertical-align: middle;"/> **Binance** | | [Register](https://www.binance.com/join?ref=NOFXENG) |
| <img src="web/public/exchange-icons/bybit.png" width="20" height="20" style="vertical-align: middle;"/> **Bybit** | ✅ | [Register](https://partner.bybit.com/b/83856) |
| <img src="web/public/exchange-icons/okx.svg" width="20" height="20" style="vertical-align: middle;"/> **OKX** | ✅ | [Register](https://www.okx.com/join/1865360) |
| <img src="web/public/exchange-icons/bitget.svg" width="20" height="20" style="vertical-align: middle;"/> **Bitget** | ✅ | [Register](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
| <img src="web/public/exchange-icons/kucoin.svg" width="20" height="20" style="vertical-align: middle;"/> **KuCoin** | ✅ | [Register](https://www.kucoin.com/r/broker/CXEV7XKK) |
| <img src="web/public/exchange-icons/gate.svg" width="20" height="20" style="vertical-align: middle;"/> **Gate** | ✅ | [Register](https://www.gatenode.xyz/share/VQBGUAxY) |
### Exchanges (Perp-DEX)
| Exchange | Status | Register (Fee Discount) |
| :---------------------------------------------------------------------------------------------------------------------------- | :----: | :------------------------------------------------------ |
| <img src="web/public/exchange-icons/hyperliquid.png" width="20" height="20" style="vertical-align: middle;"/> **Hyperliquid** | | [Register](https://app.hyperliquid.xyz/join/AITRADING) |
| <img src="web/public/exchange-icons/aster.svg" width="20" height="20" style="vertical-align: middle;"/> **Aster DEX** | ✅ | [Register](https://www.asterdex.com/en/referral/fdfc0e) |
| <img src="web/public/exchange-icons/lighter.png" width="20" height="20" style="vertical-align: middle;"/> **Lighter** | ✅ | [Register](https://app.lighter.xyz/?referral=68151432) |
| Exchange | Status | Register (Fee Discount) |
|:---------|:------:|:------------------------|
| <img src="web/public/exchange-icons/hyperliquid.png" width="20" height="20" style="vertical-align: middle;"/> **Hyperliquid** | | [Register](https://app.hyperliquid.xyz/join/AITRADING) |
| <img src="web/public/exchange-icons/aster.svg" width="20" height="20" style="vertical-align: middle;"/> **Aster DEX** | ✅ | [Register](https://www.asterdex.com/en/referral/fdfc0e) |
| <img src="web/public/exchange-icons/lighter.png" width="20" height="20" style="vertical-align: middle;"/> **Lighter** | ✅ | [Register](https://app.lighter.xyz/?referral=68151432) |
### AI Models (API Key Mode)
| AI Model | Status | Get API Key |
| :--------------------------------------------------------------------------------------------------------------- | :----: | :-------------------------------------------------- |
| <img src="web/public/icons/deepseek.svg" width="20" height="20" style="vertical-align: middle;"/> **DeepSeek** | ✅ | [Get API Key](https://platform.deepseek.com) |
| <img src="web/public/icons/qwen.svg" width="20" height="20" style="vertical-align: middle;"/> **Qwen** | ✅ | [Get API Key](https://dashscope.console.aliyun.com) |
| <img src="web/public/icons/openai.svg" width="20" height="20" style="vertical-align: middle;"/> **OpenAI (GPT)** | | [Get API Key](https://platform.openai.com) |
| <img src="web/public/icons/claude.svg" width="20" height="20" style="vertical-align: middle;"/> **Claude** | ✅ | [Get API Key](https://console.anthropic.com) |
| <img src="web/public/icons/gemini.svg" width="20" height="20" style="vertical-align: middle;"/> **Gemini** | ✅ | [Get API Key](https://aistudio.google.com) |
| <img src="web/public/icons/grok.svg" width="20" height="20" style="vertical-align: middle;"/> **Grok** | ✅ | [Get API Key](https://console.x.ai) |
| <img src="web/public/icons/kimi.svg" width="20" height="20" style="vertical-align: middle;"/> **Kimi** | ✅ | [Get API Key](https://platform.moonshot.cn) |
| <img src="web/public/icons/minimax.svg" width="20" height="20" style="vertical-align: middle;"/> **MiniMax** | ✅ | [Get API Key](https://platform.minimaxi.com) |
| AI Model | Status | Get API Key |
|:---------|:------:|:------------|
| <img src="web/public/icons/deepseek.svg" width="20" height="20" style="vertical-align: middle;"/> **DeepSeek** | ✅ | [Get API Key](https://platform.deepseek.com) |
| <img src="web/public/icons/qwen.svg" width="20" height="20" style="vertical-align: middle;"/> **Qwen** | ✅ | [Get API Key](https://dashscope.console.aliyun.com) |
| <img src="web/public/icons/openai.svg" width="20" height="20" style="vertical-align: middle;"/> **OpenAI (GPT)** | | [Get API Key](https://platform.openai.com) |
| <img src="web/public/icons/claude.svg" width="20" height="20" style="vertical-align: middle;"/> **Claude** | ✅ | [Get API Key](https://console.anthropic.com) |
| <img src="web/public/icons/gemini.svg" width="20" height="20" style="vertical-align: middle;"/> **Gemini** | ✅ | [Get API Key](https://aistudio.google.com) |
| <img src="web/public/icons/grok.svg" width="20" height="20" style="vertical-align: middle;"/> **Grok** | ✅ | [Get API Key](https://console.x.ai) |
| <img src="web/public/icons/kimi.svg" width="20" height="20" style="vertical-align: middle;"/> **Kimi** | ✅ | [Get API Key](https://platform.moonshot.cn) |
| <img src="web/public/icons/minimax.svg" width="20" height="20" style="vertical-align: middle;"/> **MiniMax** | ✅ | [Get API Key](https://platform.minimaxi.com) |
### AI Models (x402 Mode — No API Key)
@@ -137,45 +123,41 @@ Crypto · US Stocks · Forex · Metals
<details>
<summary><b>Config Page</b></summary>
| AI Models & Exchanges | Traders List |
| :----------------------------------------------------------: | :----------------------------------------------------------: |
| AI Models & Exchanges | Traders List |
|:---:|:---:|
| <img src="screenshots/config-ai-exchanges.png" width="400"/> | <img src="screenshots/config-traders-list.png" width="400"/> |
</details>
<details>
<summary><b>Dashboard</b></summary>
| Overview | Market Chart |
| :-----------------------------------------------------: | :-------------------------------------------------------------: |
| Overview | Market Chart |
|:---:|:---:|
| <img src="screenshots/dashboard-page.png" width="400"/> | <img src="screenshots/dashboard-market-chart.png" width="400"/> |
| Trading Stats | Position History |
| :--------------------------------------------------------------: | :-----------------------------------------------------------------: |
| Trading Stats | Position History |
|:---:|:---:|
| <img src="screenshots/dashboard-trading-stats.png" width="400"/> | <img src="screenshots/dashboard-position-history.png" width="400"/> |
| Positions | Trader Details |
| :----------------------------------------------------------: | :---------------------------------------------------: |
| Positions | Trader Details |
|:---:|:---:|
| <img src="screenshots/dashboard-positions.png" width="400"/> | <img src="screenshots/details-page.png" width="400"/> |
</details>
<details>
<summary><b>Strategy Studio</b></summary>
| Strategy Editor | Indicators Config |
| :------------------------------------------------------: | :----------------------------------------------------------: |
| Strategy Editor | Indicators Config |
|:---:|:---:|
| <img src="screenshots/strategy-studio.png" width="400"/> | <img src="screenshots/strategy-indicators.png" width="400"/> |
</details>
<details>
<summary><b>Competition</b></summary>
| Competition Mode |
| :-------------------------------------------------------: |
| Competition Mode |
|:---:|
| <img src="screenshots/competition-page.png" width="400"/> |
</details>
---
@@ -247,14 +229,12 @@ Everything through the web UI at **http://127.0.0.1:3000**.
## Deploy to Server
**HTTP (quick):**
```bash
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
# Access via http://YOUR_IP:3000
```
**HTTPS (Cloudflare):**
1. Add domain to [Cloudflare](https://dash.cloudflare.com) (free plan)
2. A record → your server IP (Proxied)
3. SSL/TLS → Flexible
@@ -292,12 +272,12 @@ curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bas
## Docs
| | |
| :------------------------------------------------------ | :------------------------------------ |
| [Architecture](docs/architecture/README.md) | System design and module index |
| | |
|:--|:--|
| [Architecture](docs/architecture/README.md) | System design and module index |
| [Strategy Module](docs/architecture/STRATEGY_MODULE.md) | Coin selection, AI prompts, execution |
| [FAQ](docs/faq/README.md) | Common questions |
| [Getting Started](docs/getting-started/README.md) | Deployment guide |
| [FAQ](docs/faq/README.md) | Common questions |
| [Getting Started](docs/getting-started/README.md) | Deployment guide |
---
@@ -311,26 +291,26 @@ All contributions are tracked. When NOFX generates revenue, contributors receive
**[Pinned Issues](https://github.com/NoFxAiOS/nofx/issues) get the highest rewards.**
| Contribution | Weight |
| :---------------- | :----: |
| Pinned Issue PRs | ★★★★★★ |
| Code (Merged PRs) | ★★★★★ |
| Bug Fixes | ★★★★ |
| Feature Ideas | ★★★ |
| Bug Reports | ★★ |
| Documentation | ★★ |
| Contribution | Weight |
|:-------------|:------:|
| Pinned Issue PRs | ★★★★★★ |
| Code (Merged PRs) | ★★★★★ |
| Bug Fixes | ★★★★ |
| Feature Ideas | ★★★ |
| Bug Reports | ★★ |
| Documentation | ★★ |
---
## Links
| | |
| :-------- | :---------------------------------------------------- |
| Website | [nofxai.com](https://nofxai.com) |
| Dashboard | [nofxos.ai/dashboard](https://nofxos.ai/dashboard) |
| API Docs | [nofxos.ai/api-docs](https://nofxos.ai/api-docs) |
| Telegram | [nofx_dev_community](https://t.me/nofx_dev_community) |
| Twitter | [@nofx_official](https://x.com/nofx_official) |
| | |
|:--|:--|
| Website | [nofxai.com](https://nofxai.com) |
| Dashboard | [nofxos.ai/dashboard](https://nofxos.ai/dashboard) |
| API Docs | [nofxos.ai/api-docs](https://nofxos.ai/api-docs) |
| Telegram | [nofx_dev_community](https://t.me/nofx_dev_community) |
| Twitter | [@nofx_official](https://x.com/nofx_official) |
> **Risk Warning**: AI auto-trading carries significant risks. Recommended for learning/research or small amounts only.

View File

@@ -1,825 +0,0 @@
// Package agent implements the NOFXi Agent Core.
//
// Architecture: ALL user messages go to the LLM. The LLM understands intent
// and calls tools to execute actions. No regex routing, no pattern matching.
// The LLM IS the brain — just like how OpenClaw works.
package agent
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"sort"
"strconv"
"strings"
"sync"
"time"
"nofx/manager"
"nofx/market"
"nofx/mcp"
"nofx/store"
)
type Agent struct {
traderManager *manager.TraderManager
store *store.Store
aiClient mcp.AIClient
config *Config
sentinel *Sentinel
brain *Brain
scheduler *Scheduler
logger *slog.Logger
history *chatHistory
pending *pendingTrades
stopCh chan struct{} // signals background goroutines to stop
stopOnce sync.Once
NotifyFunc func(userID int64, text string) error
}
type Config struct {
Language string `json:"language"`
WatchSymbols []string `json:"watch_symbols"`
EnableBriefs bool `json:"enable_briefs"`
EnableNews bool `json:"enable_news"`
EnableSentinel bool `json:"enable_sentinel"`
BriefTimes []int `json:"brief_times"`
}
func DefaultConfig() *Config {
return &Config{
Language: "zh", WatchSymbols: []string{"BTCUSDT", "ETHUSDT", "SOLUSDT"},
EnableBriefs: true, EnableNews: true, EnableSentinel: true, BriefTimes: []int{8, 20},
}
}
func New(tm *manager.TraderManager, st *store.Store, cfg *Config, logger *slog.Logger) *Agent {
if cfg == nil {
cfg = DefaultConfig()
}
return &Agent{traderManager: tm, store: st, config: cfg, logger: logger, history: newChatHistory(100), pending: newPendingTrades(), stopCh: make(chan struct{})}
}
func (a *Agent) SetAIClient(c mcp.AIClient) { a.aiClient = c }
func (a *Agent) ensureHistory() {
if a.history == nil {
a.history = newChatHistory(100)
}
}
func (a *Agent) log() *slog.Logger {
if a != nil && a.logger != nil {
return a.logger
}
return slog.Default()
}
func (a *Agent) EnsureAIClient() {
a.ensureAIClientForStoreUser("default")
}
func (a *Agent) ensureAIClientForStoreUser(storeUserID string) {
if storeUserID == "" {
storeUserID = "default"
}
if a.store != nil {
if client, modelName, ok := a.loadAIClientFromStoreUser(storeUserID); ok {
a.aiClient = client
a.log().Info("agent AI client ready", "store_user_id", storeUserID, "model", modelName)
return
}
}
if a.aiClient != nil {
a.log().Warn("clearing stale AI client for store user", "store_user_id", storeUserID)
a.aiClient = nil
}
a.log().Warn("no AI client — agent will have limited capabilities", "store_user_id", storeUserID)
}
func (a *Agent) loadAIClientFromStoreUser(storeUserID string) (mcp.AIClient, string, bool) {
if a.store == nil {
a.log().Warn("cannot load AI client: store unavailable", "store_user_id", storeUserID)
return nil, "", false
}
if storeUserID == "" {
storeUserID = "default"
}
model, err := a.store.AIModel().GetDefault(storeUserID)
if err != nil || model == nil {
a.log().Warn("no enabled AI model found for store user", "store_user_id", storeUserID, "error", err)
return nil, "", false
}
a.log().Info(
"agent selected AI model config",
"store_user_id", storeUserID,
"model_id", model.ID,
"provider", model.Provider,
"enabled", model.Enabled,
"has_api_key", len(model.APIKey) > 0,
"custom_api_url", strings.TrimSpace(model.CustomAPIURL),
"custom_model_name", strings.TrimSpace(model.CustomModelName),
)
apiKey := string(model.APIKey)
customAPIURL := strings.TrimSpace(model.CustomAPIURL)
modelName := strings.TrimSpace(model.CustomModelName)
provider := strings.ToLower(strings.TrimSpace(model.Provider))
// Use the provider registry for providers like claw402 that have their own
// client implementation (x402 payment, custom auth, etc.).
if client := mcp.NewAIClientByProvider(provider); client != nil {
if modelName == "" {
modelName = model.ID
}
client.SetAPIKey(apiKey, customAPIURL, modelName)
return client, modelName, true
}
customAPIURL, modelName = resolveModelRuntimeConfig(provider, customAPIURL, modelName, model.ID)
if apiKey == "" || customAPIURL == "" {
a.log().Warn(
"enabled AI model is incomplete",
"store_user_id", storeUserID,
"model_id", model.ID,
"provider", model.Provider,
"has_api_key", apiKey != "",
"has_custom_api_url", customAPIURL != "",
)
return nil, "", false
}
httpClient := &http.Client{Timeout: 60 * time.Second}
client := mcp.NewClient(mcp.WithHTTPClient(httpClient))
name := modelName
client.SetAPIKey(apiKey, customAPIURL, name)
return client, name, true
}
func resolveModelRuntimeConfig(provider, customAPIURL, customModelName, fallbackModelID string) (string, string) {
provider = strings.ToLower(strings.TrimSpace(provider))
customAPIURL = strings.TrimSpace(customAPIURL)
customModelName = strings.TrimSpace(customModelName)
fallbackModelID = strings.TrimSpace(fallbackModelID)
type providerDefaults struct {
url string
model string
}
defaults := map[string]providerDefaults{
"deepseek": {url: "https://api.deepseek.com/v1", model: "deepseek-chat"},
"qwen": {url: "https://dashscope.aliyuncs.com/compatible-mode/v1", model: "qwen3-max"},
"openai": {url: "https://api.openai.com/v1", model: "gpt-5.2"},
"claude": {url: "https://api.anthropic.com/v1", model: "claude-opus-4-6"},
"gemini": {url: "https://generativelanguage.googleapis.com/v1beta/openai", model: "gemini-3-pro-preview"},
"grok": {url: "https://api.x.ai/v1", model: "grok-3-latest"},
"kimi": {url: "https://api.moonshot.ai/v1", model: "moonshot-v1-auto"},
"minimax": {url: "https://api.minimax.chat/v1", model: "MiniMax-M2.5"},
}
if customAPIURL == "" {
if cfg, ok := defaults[provider]; ok {
customAPIURL = cfg.url
}
}
if customModelName == "" {
if cfg, ok := defaults[provider]; ok {
customModelName = cfg.model
}
}
if customModelName == "" {
customModelName = fallbackModelID
}
return customAPIURL, customModelName
}
func (a *Agent) Start() {
a.logger.Info("starting NOFXi agent...")
a.EnsureAIClient()
if a.config.EnableSentinel {
a.sentinel = NewSentinel(a.config.WatchSymbols, a.handleSignal, a.logger)
a.sentinel.Start()
}
a.brain = NewBrain(a, a.logger)
if a.config.EnableNews {
a.brain.StartNewsScan(5 * time.Minute)
}
if a.config.EnableBriefs {
a.brain.StartMarketBriefs(a.config.BriefTimes)
}
a.scheduler = NewScheduler(a, a.logger)
a.scheduler.Start(context.Background())
a.logger.Info("NOFXi agent is online 🚀")
}
func (a *Agent) Stop() {
// Signal all background goroutines (e.g. chat-history-cleanup) to exit.
a.stopOnce.Do(func() { close(a.stopCh) })
if a.sentinel != nil {
a.sentinel.Stop()
}
if a.brain != nil {
a.brain.Stop()
}
if a.scheduler != nil {
a.scheduler.Stop()
}
}
// HandleMessage — the core. Everything goes through the LLM.
func (a *Agent) HandleMessage(ctx context.Context, userID int64, text string) (string, error) {
a.EnsureAIClient()
return a.handleMessageForStoreUser(ctx, "default", userID, text)
}
// HandleMessageForStoreUser is like HandleMessage but stores setup artifacts
// (exchange/model) under the provided authenticated store user ID.
func (a *Agent) HandleMessageForStoreUser(ctx context.Context, storeUserID string, userID int64, text string) (string, error) {
return a.handleMessageForStoreUser(ctx, storeUserID, userID, text)
}
func (a *Agent) handleMessageForStoreUser(ctx context.Context, storeUserID string, userID int64, text string) (string, error) {
a.ensureAIClientForStoreUser(storeUserID)
lang := a.config.Language
if strings.HasPrefix(text, "[lang:") {
if end := strings.Index(text, "] "); end > 0 {
lang = text[6:end]
text = text[end+2:]
}
}
a.logger.Info("message", "user_id", userID, "text", text)
// Only keep a tiny command surface outside the planner.
if text == "/status" {
return a.handleStatus(lang), nil
}
if text == "/clear" {
a.history.Clear(userID)
a.clearTaskState(userID)
a.clearExecutionState(userID)
if lang == "zh" {
return "🧹 对话记忆已清除。", nil
}
return "🧹 Conversation history cleared.", nil
}
if reply, handled := a.handleTradeConfirmation(ctx, userID, text, lang); handled {
return reply, nil
}
// Everything else goes through the planner and tool system.
return a.thinkAndAct(ctx, storeUserID, userID, lang, text)
}
// HandleMessageStream is like HandleMessage but streams the final LLM response via SSE.
// onEvent is called with (eventType, data) — see StreamEvent* constants.
// Non-streamable responses (commands, trade confirmations) return immediately without events.
func (a *Agent) HandleMessageStream(ctx context.Context, userID int64, text string, onEvent func(event, data string)) (string, error) {
a.EnsureAIClient()
return a.handleMessageStreamForStoreUser(ctx, "default", userID, text, onEvent)
}
// HandleMessageStreamForStoreUser mirrors HandleMessageForStoreUser for SSE responses.
func (a *Agent) HandleMessageStreamForStoreUser(ctx context.Context, storeUserID string, userID int64, text string, onEvent func(event, data string)) (string, error) {
return a.handleMessageStreamForStoreUser(ctx, storeUserID, userID, text, onEvent)
}
func (a *Agent) handleMessageStreamForStoreUser(ctx context.Context, storeUserID string, userID int64, text string, onEvent func(event, data string)) (string, error) {
a.ensureAIClientForStoreUser(storeUserID)
lang := a.config.Language
if strings.HasPrefix(text, "[lang:") {
if end := strings.Index(text, "] "); end > 0 {
lang = text[6:end]
text = text[end+2:]
}
}
a.logger.Info("message (stream)", "user_id", userID, "text", text)
if text == "/status" {
return a.handleStatus(lang), nil
}
if text == "/clear" {
a.history.Clear(userID)
a.clearTaskState(userID)
a.clearExecutionState(userID)
if lang == "zh" {
return "🧹 对话记忆已清除。", nil
}
return "🧹 Conversation history cleared.", nil
}
if reply, handled := a.handleTradeConfirmation(ctx, userID, text, lang); handled {
if onEvent != nil {
onEvent(StreamEventDelta, reply)
}
return reply, nil
}
return a.thinkAndActStream(ctx, storeUserID, userID, lang, text, onEvent)
}
// StreamEvent types sent via SSE to the frontend.
const (
StreamEventPlanning = "planning"
StreamEventPlan = "plan"
StreamEventStepStart = "step_start"
StreamEventStepComplete = "step_complete"
StreamEventReplan = "replan"
StreamEventTool = "tool" // Tool is being called (shows status to user)
StreamEventDelta = "delta" // Text chunk from LLM streaming
StreamEventDone = "done" // Stream complete
StreamEventError = "error" // Error occurred
)
// buildSystemPrompt creates the system prompt that makes NOFXi behave like a real agent.
func (a *Agent) buildSystemPrompt(lang string) string {
// Gather live system state
traderInfo := a.getTradersSummary()
watchlist := ""
if a.sentinel != nil {
watchlist = a.sentinel.FormatWatchlist(lang)
}
skillCatalog := skillCatalogPrompt(lang)
if lang == "zh" {
return fmt.Sprintf(`你是 NOFXi一个专业的 AI 交易 Agent。你不是一个简单的聊天机器人——你是用户的交易伙伴。
## 你的核心能力
1. **市场分析** — 加密货币BTC/ETH/SOL等有实时数据A股/港股/美股/外汇你可以基于知识分析
2. **交易管理** — 查看持仓、余额、交易历史、Trader 状态
3. **策略建议** — 根据用户需求制定交易策略
4. **策略模板管理** — 创建、查看、修改、删除、激活策略模板
5. **风险管理** — 评估风险、建议止损止盈
6. **配置引导** — 用户说"开始配置"时引导配置交易所和AI模型
## 当前系统状态
%s
%s
## 数据说明(极其重要,违反即失职!)
- 加密货币BTC/ETH等交易所实时数据标注 [Real-time]
- A股/港股/美股:**必须调用 search_stock 工具**获取实时行情。不调工具就没有数据。
- 美股盘前盘后search_stock 返回的 quote 中 ext_price/ext_change_pct/ext_time
- 外汇/指数期货:当前没有数据源,如实告知
### 铁律:禁止编造任何价格!
- **你的训练数据中的价格全部过时,不可使用**
- **没有通过工具获取的价格 = 你不知道 = 不能说**
- 用户问多只股票的盘前数据?→ 对每只股票调用 search_stock 工具
- 用户问"盘前概览"?→ 调用 search_stock 查主要股票AAPL、TSLA、NVDA、MSFT、GOOGL、AMZN、META等用真实数据回答
- **绝对不允许**不调工具就给出具体价格数字(如 $421.85
- 如果某只股票 search_stock 查不到数据,就说"暂时无法获取该股票数据"
- 指数期货(纳指、标普、道琼斯期货)我们目前没有数据源,直接说"暂不支持指数期货数据"
## 工具使用
你可以调用以下工具来执行操作:
- **search_stock** — 搜索股票(支持中文名、英文名、代码)。当用户提到你不认识的股票时,先用这个工具搜索。
- **execute_trade** — 下单交易加密货币或美股。美股open_long=买入close_long=卖出。调用后创建待确认订单,用户需回复"确认 trade_xxx"。
- **get_positions** — 查看当前所有持仓(加密货币 + 股票)
- **get_balance** — 查看账户余额
- **get_market_price** — 获取实时价格(加密货币或股票代码)
- **get_exchange_configs / manage_exchange_config** — 查看、新增、修改、删除交易所绑定配置
- **get_model_configs / manage_model_config** — 查看、新增、修改、删除 AI 模型配置
- **get_strategies / manage_strategy** — 查看、新增、修改、删除、激活、复制策略模板
- **manage_trader** — 查看、新增、修改、删除、启动、停止交易员
### 配置、策略与交易员管理规则
- 当用户要求创建、修改、删除、激活、复制策略模板时,优先使用 get_strategies / manage_strategy
- **策略模板本身是独立资源,不默认依赖交易所或 AI 模型**
- 只有当用户要求“运行策略 / 创建交易员 / 把策略部署到账户”时,才需要进一步关联交易所、模型或 trader
- 当用户要求配置交易所、绑定 API Key、修改交易所账户时优先使用 manage_exchange_config
- 当用户要求配置大模型、设置 API Key、切换模型、修改模型地址时优先使用 manage_model_config
- 当用户要求创建、修改、删除、启动、停止交易员时,优先使用 manage_trader
- 如果缺少必要字段,先追问缺失信息,再调用工具
- **在这些工具存在时,不要说“系统没有这个能力”**
- 对敏感信息API Key、Secret、Private Key只保存不要在最终回复中完整回显
%s
### 交易安全规则
- 用户明确要求交易时才调用 execute_trade
- 分析和建议不需要调用工具,直接回复即可
- 交易确认信息要清晰展示:品种、方向、数量、杠杆
- 提醒用户确认命令格式
### 数据真实性规则(极其重要!)
- **持仓信息必须且只能通过 get_positions 工具获取**,绝对禁止编造持仓
- **余额信息必须且只能通过 get_balance 工具获取**,绝对禁止编造余额
- 如果用户问持仓但 get_positions 返回空,就说"当前没有持仓",不要编造
- 如果工具返回 error如未配置交易所如实告知用户
- **你不知道用户持有什么股票/币种,除非工具返回了数据**
- 查股票行情 ≠ 用户持有该股票。不要混淆"查价格"和"有持仓"
## 行为准则
- 简洁、专业、有观点。不说废话。
- 用户问什么答什么,不要推销配置。
- 有实时数据时给具体价位,没有时给策略框架和思路。
- **诚实是第一原则** — 不确定就说不确定,没数据就说没数据。绝不编造。
- 用交易相关的 emoji 让回复更直观。
- 用中文回复。
当前时间: %s`, traderInfo, watchlist, skillCatalog, time.Now().Format("2006-01-02 15:04:05"))
}
return fmt.Sprintf(`You are NOFXi, a professional AI trading agent. Not a chatbot — a trading partner.
## Capabilities
1. Market analysis — crypto with real-time data, stocks/forex with knowledge
2. Trade management — positions, balance, history, trader status
3. Strategy — build trading strategies based on user needs
4. Strategy template management — create, inspect, update, delete, and activate strategy templates
5. Risk management — assess risk, suggest stop-loss/take-profit
6. Setup — guide exchange/AI configuration when user asks
## Current System State
%s
%s
## Data Notice (CRITICAL — violating this is unacceptable!)
- Crypto (BTC/ETH): Exchange real-time data, marked [Real-time]
- Stocks: You MUST call search_stock tool to get real-time quotes. No tool call = no data.
- US stocks pre/after-hours: ext_price/ext_change_pct/ext_time in search_stock results
- Forex/Index futures: No data source currently — tell user honestly
### ABSOLUTE RULE: NEVER fabricate any price!
- Your training data prices are ALL outdated and MUST NOT be used
- No tool result = you don't know = you cannot state a price
- User asks multiple stocks? → Call search_stock for EACH one
- User asks "pre-market overview"? → Call search_stock for major stocks (AAPL, TSLA, NVDA, MSFT, GOOGL, AMZN, META etc.) and use real data
- NEVER output a specific price number (like $421.85) without a tool having returned it
- If search_stock fails for a stock, say "unable to fetch data for this stock"
- Index futures (NDX, SPX, DJI futures) — we have no data source, say "index futures not supported yet"
## Tools
You can call these tools to take action:
- **search_stock** — Search for stocks by name, ticker, or code. Covers A-share, HK, and US markets. Use when the user mentions an unknown stock.
- **execute_trade** — Place a trade order (crypto or US stocks). For stocks: open_long=buy, close_long=sell. Creates a pending order that requires user confirmation.
- **get_positions** — View all current open positions (crypto + stocks)
- **get_balance** — View account balance and equity
- **get_market_price** — Get real-time price from the exchange (crypto or stock symbol)
- **get_exchange_configs / manage_exchange_config** — View, create, update, and delete exchange bindings
- **get_model_configs / manage_model_config** — View, create, update, and delete AI model bindings
- **get_strategies / manage_strategy** — View, create, update, delete, activate, and duplicate strategy templates
- **manage_trader** — List, create, update, delete, start, and stop traders
### Configuration, Strategy, and Trader Rules
- When the user wants to create, edit, delete, activate, or duplicate a strategy template, prefer get_strategies / manage_strategy
- **A strategy template is an independent asset and does not require exchange or model bindings by default**
- Only ask for exchange/model/trader details when the user wants to run, deploy, or attach a strategy to a trader
- When the user wants to bind or edit an exchange account, prefer manage_exchange_config
- When the user wants to bind or edit an AI model, prefer manage_model_config
- When the user wants to create, edit, delete, start, or stop a trader, prefer manage_trader
- If required fields are missing, ask a focused follow-up question first, then call the tool
- **Do not claim the system lacks these capabilities when the tools exist**
- For secrets such as API keys, secrets, and private keys: store them, but never echo them back in full
%s
### Trade Safety Rules
- Only call execute_trade when user explicitly requests a trade
- Analysis and advice don't need tools — just reply directly
- Show trade details clearly: symbol, direction, quantity, leverage
- Remind user of the confirmation command format
### Data Truthfulness Rules (CRITICAL!)
- **Position data MUST come from get_positions tool only** — NEVER fabricate positions
- **Balance data MUST come from get_balance tool only** — NEVER fabricate balances
- If get_positions returns empty, say "no open positions" — do NOT make up holdings
- If a tool returns an error (e.g. no exchange configured), tell the user honestly
- **You do NOT know what the user holds unless a tool tells you**
- Checking a stock price ≠ user owns that stock. Never confuse "quote lookup" with "holding"
## Behavior
- Concise, professional, opinionated. No fluff.
- Answer what's asked. Don't push setup.
- With real-time data: give specific levels. Without: give strategy frameworks.
- **Honesty is rule #1** — uncertain = say uncertain, no data = say no data.
- Use trading emojis.
Current time: %s`, traderInfo, watchlist, skillCatalog, time.Now().Format("2006-01-02 15:04:05"))
}
// gatherContext collects real-time market data relevant to the user's message.
func (a *Agent) gatherContext(text string) string {
var parts []string
upper := strings.ToUpper(text)
// Crypto — detect symbols dynamically
// 1. Check known popular symbols (fast path)
// 2. Extract any "XXXUSDT" pattern from text (catches arbitrary pairs)
knownSymbols := []string{
"BTC", "ETH", "SOL", "BNB", "XRP", "DOGE", "ADA", "AVAX", "DOT", "LINK",
"PEPE", "SHIB", "ARB", "OP", "SUI", "APT", "SEI", "TIA", "JUP", "WIF",
"NEAR", "ATOM", "FTM", "MATIC", "INJ", "RENDER", "FET", "TAO", "WLD",
"AAVE", "UNI", "LDO", "MKR", "CRV", "PENDLE", "ENA", "ONDO", "TRUMP",
}
matched := make(map[string]bool)
for _, sym := range knownSymbols {
if strings.Contains(upper, sym) {
matched[sym] = true
}
}
// Also extract "XXXUSDT" patterns for coins not in the known list
for _, word := range strings.Fields(upper) {
word = strings.Trim(word, ".,!?;:()[]{}\"'")
if strings.HasSuffix(word, "USDT") && len(word) > 4 && len(word) <= 15 {
sym := strings.TrimSuffix(word, "USDT")
if len(sym) >= 2 && len(sym) <= 10 {
matched[sym] = true
}
}
}
// Collect and sort matched symbols for deterministic selection
sortedSymbols := make([]string, 0, len(matched))
for sym := range matched {
sortedSymbols = append(sortedSymbols, sym)
}
sort.Strings(sortedSymbols)
// Cap at 5 symbols to avoid slow context gathering
count := 0
for _, sym := range sortedSymbols {
if count >= 5 {
break
}
md, err := market.Get(sym + "USDT")
if err == nil && md.CurrentPrice > 0 {
parts = append(parts, fmt.Sprintf("[%s/USDT Real-time]\nPrice: $%.4f | 1h: %+.2f%% | 4h: %+.2f%% | RSI7: %.1f | EMA20: %.4f | MACD: %.6f | Funding: %.4f%%",
sym, md.CurrentPrice, md.PriceChange1h, md.PriceChange4h, md.CurrentRSI7, md.CurrentEMA20, md.CurrentMACD, md.FundingRate*100))
count++
}
}
// A-share / stocks — only call Sina API when text likely references stocks.
// Skip for purely crypto conversations to avoid unnecessary external API calls.
if looksLikeStockQuery(text) {
stockCode, stockName := resolveStockCodeDynamic(text)
if stockCode != "" {
quote, err := fetchStockQuote(stockCode)
if err == nil && quote.Price > 0 {
parts = append(parts, fmt.Sprintf("[%s(%s) Real-time A-share Data]\n%s", quote.Name, quote.Code, formatStockQuote(quote)))
} else if err != nil {
a.logger.Error("fetch stock quote", "code", stockCode, "name", stockName, "error", err)
}
}
}
// Trader positions
if a.traderManager != nil {
for _, t := range a.traderManager.GetAllTraders() {
positions, err := t.GetPositions()
if err != nil {
continue
}
for _, p := range positions {
size := toFloat(p["size"])
if size == 0 {
continue
}
parts = append(parts, fmt.Sprintf("[Position] %s %s: size=%.4f entry=$%.4f mark=$%.4f pnl=$%.2f",
p["symbol"], p["side"], size, toFloat(p["entryPrice"]), toFloat(p["markPrice"]), toFloat(p["unrealizedPnl"])))
}
}
}
return strings.Join(parts, "\n")
}
func (a *Agent) getTradersSummary() string {
if a.traderManager == nil {
return "Traders: none configured"
}
traders := a.traderManager.GetAllTraders()
if len(traders) == 0 {
return "Traders: none configured"
}
var lines []string
for id, t := range traders {
s := t.GetStatus()
running, _ := s["is_running"].(bool)
status := "stopped"
if running {
status = "running"
}
tid := id
if len(tid) > 8 {
tid = tid[:8]
}
lines = append(lines, fmt.Sprintf("• %s [%s] %s | %s", t.GetName(), tid, status, t.GetExchange()))
}
return "Traders:\n" + strings.Join(lines, "\n")
}
func (a *Agent) handleStatus(L string) string {
tc, rc := 0, 0
if a.traderManager != nil {
all := a.traderManager.GetAllTraders()
tc = len(all)
for _, t := range all {
if s := t.GetStatus(); s["is_running"] == true {
rc++
}
}
}
wc := 0
if a.sentinel != nil {
wc = a.sentinel.SymbolCount()
}
ai := "❌"
if a.aiClient != nil {
ai = "✅"
}
return fmt.Sprintf(a.msg(L, "status"), rc, tc, wc, ai, time.Now().Format("2006-01-02 15:04:05"))
}
// noAIFallback — when no AI is available, still try to be useful.
func (a *Agent) noAIFallback(lang, text string) (string, error) {
upper := strings.ToUpper(text)
// Try to provide market data directly
for _, sym := range []string{"BTC", "ETH", "SOL", "BNB", "XRP", "DOGE"} {
if strings.Contains(upper, sym) {
md, err := market.Get(sym + "USDT")
if err == nil {
return fmt.Sprintf("📊 *%s/USDT*\n\n%s\n\n💡 配置 AI 模型后我能给你更深度的分析。发送 *开始配置* 开始。", sym, market.Format(md)), nil
}
}
}
// Check if asking about positions/balance
if strings.Contains(text, "持仓") || strings.Contains(upper, "POSITION") {
return a.queryPositionsDirect(lang)
}
if strings.Contains(text, "余额") || strings.Contains(upper, "BALANCE") {
return a.queryBalancesDirect(lang)
}
if lang == "zh" {
return "🤖 我是 NOFXi。配置 AI 模型后我就能理解你的任何问题——分析股票、制定策略、管理交易。\n\n现在可用\n• 加密货币实时行情试试「BTC」\n• `/status` 系统状态\n\n发送 *开始配置* 配置 AI 模型。", nil
}
return "🤖 I'm NOFXi. Configure an AI model and I can understand anything — analyze stocks, build strategies, manage trades.\n\nAvailable now:\n• Crypto real-time data (try 'BTC')\n• `/status` system status\n\nSend *setup* to configure AI.", nil
}
func (a *Agent) aiServiceFailure(lang string, err error) (string, error) {
reason := "unknown error"
if err != nil {
reason = summarizeObservation(err.Error())
}
a.logger.Error("AI service call failed", "error", reason)
if lang == "zh" {
return fmt.Sprintf("当前 AI 服务调用失败:%s\n\n这不是“未配置模型”。更可能是模型服务余额不足、接口报错或超时。请检查当前启用模型的 API 状态后再试。", reason), nil
}
return fmt.Sprintf("The AI service call failed: %s\n\nThis is not a missing-model issue. The active model provider likely returned an error, timed out, or has insufficient balance. Please check the active model API and try again.", reason), nil
}
func (a *Agent) queryPositionsDirect(L string) (string, error) {
if a.traderManager == nil {
return a.msg(L, "no_traders"), nil
}
var sb strings.Builder
sb.WriteString("📊 *Positions*\n\n")
hasAny := false
for id, t := range a.traderManager.GetAllTraders() {
positions, err := t.GetPositions()
if err != nil {
continue
}
for _, p := range positions {
size := toFloat(p["size"])
if size == 0 {
continue
}
hasAny = true
pnl := toFloat(p["unrealizedPnl"])
e := "🟢"
if pnl < 0 {
e = "🔴"
}
tid := id
if len(tid) > 8 {
tid = tid[:8]
}
sb.WriteString(fmt.Sprintf("%s *%s* %s — $%.2f | Trader: %s\n", e, p["symbol"], p["side"], pnl, tid))
}
}
if !hasAny {
return a.msg(L, "no_positions"), nil
}
return sb.String(), nil
}
func (a *Agent) queryBalancesDirect(L string) (string, error) {
if a.traderManager == nil {
return a.msg(L, "no_traders"), nil
}
var sb strings.Builder
sb.WriteString("💰 *Balance*\n\n")
for id, t := range a.traderManager.GetAllTraders() {
info, err := t.GetAccountInfo()
if err != nil {
continue
}
tid := id
if len(tid) > 8 {
tid = tid[:8]
}
sb.WriteString(fmt.Sprintf("*%s* (%s): $%.2f\n", t.GetName(), tid, toFloat(info["total_equity"])))
}
return sb.String(), nil
}
func (a *Agent) handleSignal(sig Signal) {
if a.brain != nil {
a.brain.HandleSignal(sig)
}
}
func (a *Agent) notifyAll(text string) {
if a.NotifyFunc != nil {
a.NotifyFunc(0, text)
}
}
// looksLikeStockQuery returns true if the text likely references stocks rather
// than being a pure crypto/general query. This avoids hitting the Sina search
// API on every single message (saves ~200ms latency + external API call).
func looksLikeStockQuery(text string) bool {
upper := strings.ToUpper(text)
// Check for known stock-related Chinese keywords
stockKeywords := []string{
"股", "A股", "港股", "美股", "股票", "涨停", "跌停", "大盘",
"沪指", "深指", "恒指", "纳指", "标普", "道琼斯",
"茅台", "比亚迪", "宁德", "腾讯", "阿里", "美团", "小米",
"京东", "百度", "苹果", "特斯拉", "英伟达", "微软", "谷歌",
"盘前", "盘后", "开盘", "收盘", "涨幅", "跌幅",
}
for _, kw := range stockKeywords {
if strings.Contains(text, kw) {
return true
}
}
// Check for US stock ticker patterns (1-5 uppercase letters not matching crypto)
for _, word := range strings.Fields(upper) {
word = strings.Trim(word, ".,!?;:()[]{}\"'")
if len(word) >= 1 && len(word) <= 5 {
allLetter := true
for _, c := range word {
if c < 'A' || c > 'Z' {
allLetter = false
break
}
}
if allLetter {
// Check if it's in the known US ticker map
if _, ok := usTickerMap[word]; ok {
return true
}
}
}
}
// Check for 6-digit A-share codes or 5-digit HK codes
for _, w := range strings.Fields(text) {
w = strings.TrimSpace(w)
if len(w) == 5 || len(w) == 6 {
if _, err := strconv.Atoi(w); err == nil {
return true
}
}
}
return false
}
func toFloat(v interface{}) float64 {
switch x := v.(type) {
case float64:
return x
case float32:
return float64(x)
case int:
return float64(x)
case int64:
return float64(x)
case int32:
return float64(x)
case string:
f, _ := strconv.ParseFloat(x, 64)
return f
case json.Number:
f, _ := x.Float64()
return f
}
return 0
}

View File

@@ -1,127 +0,0 @@
package agent
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"nofx/store"
)
func TestReadBackendLogEntriesReturnsRecentErrorLines(t *testing.T) {
wd, err := os.Getwd()
if err != nil {
t.Fatalf("Getwd() error = %v", err)
}
tmp := t.TempDir()
if err := os.Chdir(tmp); err != nil {
t.Fatalf("Chdir(tmp) error = %v", err)
}
t.Cleanup(func() {
_ = os.Chdir(wd)
})
if err := os.MkdirAll("data", 0o755); err != nil {
t.Fatalf("MkdirAll(data) error = %v", err)
}
logPath := filepath.Join("data", "nofx_2099-01-01.log")
content := strings.Join([]string{
"04-19 13:00:00 [INFO] api/server.go:590 API server starting",
"04-19 13:00:01 [ERRO] api/server.go:600 invalid signature for okx account",
"04-19 13:00:02 [ERRO] agent/tools.go:123 model update failed: missing api key",
}, "\n") + "\n"
if err := os.WriteFile(logPath, []byte(content), 0o644); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
path, entries, err := readBackendLogEntries(10, "model", true)
if err != nil {
t.Fatalf("readBackendLogEntries() error = %v", err)
}
if !strings.Contains(path, "nofx_2099-01-01.log") {
t.Fatalf("unexpected log path: %s", path)
}
if len(entries) != 1 || !strings.Contains(entries[0], "missing api key") {
t.Fatalf("unexpected filtered entries: %#v", entries)
}
}
func TestToolGetBackendLogsRequiresOwnedTrader(t *testing.T) {
wd, err := os.Getwd()
if err != nil {
t.Fatalf("Getwd() error = %v", err)
}
tmp := t.TempDir()
if err := os.Chdir(tmp); err != nil {
t.Fatalf("Chdir(tmp) error = %v", err)
}
t.Cleanup(func() {
_ = os.Chdir(wd)
})
if err := os.MkdirAll("data", 0o755); err != nil {
t.Fatalf("MkdirAll(data) error = %v", err)
}
logPath := filepath.Join("data", "nofx_2099-01-01.log")
content := strings.Join([]string{
"04-19 13:00:00 [INFO] api/server.go:590 API server starting",
"04-19 13:00:01 [ERRO] trader/runtime.go:88 trader_id=trader-owned strategy execution failed",
"04-19 13:00:02 [ERRO] trader/runtime.go:89 trader_id=trader-other strategy execution failed",
}, "\n") + "\n"
if err := os.WriteFile(logPath, []byte(content), 0o644); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
a := newTestAgentWithStore(t)
if err := a.store.Trader().Create(&store.Trader{
ID: "trader-owned",
UserID: "user-1",
Name: "Owned Trader",
AIModelID: "model-1",
ExchangeID: "exchange-1",
StrategyID: "strategy-1",
InitialBalance: 1000,
}); err != nil {
t.Fatalf("create owned trader: %v", err)
}
if err := a.store.Trader().Create(&store.Trader{
ID: "trader-other",
UserID: "user-2",
Name: "Other Trader",
AIModelID: "model-2",
ExchangeID: "exchange-2",
StrategyID: "strategy-2",
InitialBalance: 1000,
}); err != nil {
t.Fatalf("create other trader: %v", err)
}
resp := a.toolGetBackendLogs("user-1", `{"trader_id":"trader-owned","limit":5}`)
var okResult struct {
TraderID string `json:"trader_id"`
Entries []string `json:"entries"`
Count int `json:"count"`
}
if err := json.Unmarshal([]byte(resp), &okResult); err != nil {
t.Fatalf("unmarshal owned response: %v\nraw=%s", err, resp)
}
if okResult.TraderID != "trader-owned" || okResult.Count != 1 {
t.Fatalf("unexpected owned response: %+v", okResult)
}
if len(okResult.Entries) != 1 || !strings.Contains(okResult.Entries[0], "trader-owned") {
t.Fatalf("unexpected owned entries: %#v", okResult.Entries)
}
resp = a.toolGetBackendLogs("user-1", `{"trader_id":"trader-other","limit":5}`)
var denied struct {
Error string `json:"error"`
}
if err := json.Unmarshal([]byte(resp), &denied); err != nil {
t.Fatalf("unmarshal denied response: %v\nraw=%s", err, resp)
}
if denied.Error != "trader not found for current user" {
t.Fatalf("unexpected denied response: %+v", denied)
}
}

View File

@@ -1,184 +0,0 @@
package agent
import (
"encoding/json"
"fmt"
"log/slog"
"net/http"
"nofx/safe"
"strings"
"sync"
"time"
)
// Brain handles proactive intelligence: signals, news, market briefs.
type Brain struct {
agent *Agent
logger *slog.Logger
http *http.Client
stopCh chan struct{}
stopOnce sync.Once
recentSignals sync.Map // debounce
}
func NewBrain(agent *Agent, logger *slog.Logger) *Brain {
return &Brain{
agent: agent,
logger: logger,
http: &http.Client{Timeout: 15 * time.Second},
stopCh: make(chan struct{}),
}
}
func (b *Brain) Stop() { b.stopOnce.Do(func() { close(b.stopCh) }) }
// cleanStaleSignals removes debounce entries older than 30 minutes.
func (b *Brain) cleanStaleSignals() {
cutoff := time.Now().Add(-30 * time.Minute)
b.recentSignals.Range(func(key, value any) bool {
if t, ok := value.(time.Time); ok && t.Before(cutoff) {
b.recentSignals.Delete(key)
}
return true
})
}
func (b *Brain) HandleSignal(sig Signal) {
key := fmt.Sprintf("%s:%s", sig.Type, sig.Symbol)
if v, ok := b.recentSignals.Load(key); ok {
if time.Since(v.(time.Time)) < 10*time.Minute {
return
}
}
b.recentSignals.Store(key, time.Now())
emoji := map[string]string{"info": "", "warning": "⚠️", "critical": "🚨"}
e := emoji[sig.Severity]
if e == "" { e = "📊" }
b.agent.notifyAll(fmt.Sprintf("%s *%s*\n\n%s", e, sig.Title, sig.Detail))
}
func (b *Brain) StartNewsScan(interval time.Duration) {
seen := make(map[string]bool)
safe.GoNamed("brain-news-scan", func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
cleanTick := 0
for {
select {
case <-b.stopCh: return
case <-ticker.C:
b.scanNews(seen)
cleanTick++
if cleanTick%6 == 0 { // every ~30 min
b.cleanStaleSignals()
}
}
}
})
}
func (b *Brain) scanNews(seen map[string]bool) {
resp, err := b.http.Get("https://min-api.cryptocompare.com/data/v2/news/?lang=EN&sortOrder=latest")
if err != nil { return }
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b.logger.Debug("news API non-200", "status", resp.StatusCode)
return
}
body, err := safe.ReadAllLimited(resp.Body, 1024*1024) // 1MB limit
if err != nil { return }
var result struct {
Data []struct {
Title string `json:"title"`
Source string `json:"source"`
URL string `json:"url"`
Body string `json:"body"`
Categories string `json:"categories"`
PublishedOn int64 `json:"published_on"`
} `json:"Data"`
}
if err := json.Unmarshal(body, &result); err != nil { return }
bullish := []string{"surge", "rally", "bullish", "breakout", "ath", "pump", "adoption"}
bearish := []string{"crash", "dump", "bearish", "sell-off", "plunge", "hack", "ban", "fraud"}
for _, d := range result.Data {
if seen[d.URL] { continue }
seen[d.URL] = true
if time.Since(time.Unix(d.PublishedOn, 0)) > 10*time.Minute { continue }
lower := strings.ToLower(d.Title + " " + d.Body)
bc, brc := 0, 0
for _, w := range bullish { if strings.Contains(lower, w) { bc++ } }
for _, w := range bearish { if strings.Contains(lower, w) { brc++ } }
if bc == 0 && brc == 0 { continue }
emoji := "📰"
sentiment := "NEUTRAL"
if bc > brc { emoji = "🟢"; sentiment = "BULLISH" }
if brc > bc { emoji = "🔴"; sentiment = "BEARISH" }
b.agent.notifyAll(fmt.Sprintf("%s *News*\n\n%s\n\n• Source: %s\n• Sentiment: %s",
emoji, d.Title, d.Source, sentiment))
}
// Evict ~half when seen map gets large (keep recent half to avoid re-notifying)
if len(seen) > 1000 {
i, half := 0, len(seen)/2
for k := range seen {
if i >= half { break }
delete(seen, k)
i++
}
}
}
func (b *Brain) StartMarketBriefs(hours []int) {
safe.GoNamed("brain-market-briefs", func() {
ticker := time.NewTicker(1 * time.Minute)
defer ticker.Stop()
sent := make(map[string]bool)
for {
select {
case <-b.stopCh: return
case now := <-ticker.C:
key := now.Format("2006-01-02-15")
for _, h := range hours {
if now.Hour() == h && now.Minute() == 30 && !sent[key] {
sent[key] = true
b.sendBrief(h)
}
}
}
}
})
}
func (b *Brain) sendBrief(hour int) {
title := "☀️ *早间市场简报*"
if hour >= 18 { title = "🌙 *晚间市场简报*" }
// Fetch BTC/ETH prices for the brief
var btcPrice, ethPrice, btcChg, ethChg string
for _, sym := range []string{"BTCUSDT", "ETHUSDT"} {
resp, err := b.http.Get(fmt.Sprintf("https://fapi.binance.com/fapi/v1/ticker/24hr?symbol=%s", sym))
if err != nil { continue }
body, readErr := safe.ReadAllLimited(resp.Body, 64*1024) // 64KB limit
statusOK := resp.StatusCode == http.StatusOK
resp.Body.Close()
if readErr != nil || !statusOK { continue }
var t map[string]string
if err := json.Unmarshal(body, &t); err != nil { continue }
if sym == "BTCUSDT" { btcPrice = t["lastPrice"]; btcChg = t["priceChangePercent"] }
if sym == "ETHUSDT" { ethPrice = t["lastPrice"]; ethChg = t["priceChangePercent"] }
}
brief := fmt.Sprintf("%s\n\n• BTC: $%s (%s%%)\n• ETH: $%s (%s%%)\n\n_%s_",
title, btcPrice, btcChg, ethPrice, ethChg, time.Now().Format("2006-01-02 15:04"))
b.agent.notifyAll(brief)
}

View File

@@ -1,387 +0,0 @@
package agent
import (
"encoding/json"
"path/filepath"
"strings"
"testing"
"nofx/store"
)
func newTestAgentWithStore(t *testing.T) *Agent {
t.Helper()
st, err := store.New(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("create test store: %v", err)
}
t.Cleanup(func() {
_ = st.Close()
})
return &Agent{store: st}
}
func TestToolManageExchangeConfigLifecycle(t *testing.T) {
a := newTestAgentWithStore(t)
createResp := a.toolManageExchangeConfig("user-1", `{
"action":"create",
"exchange_type":"binance",
"account_name":"Main",
"enabled":true,
"testnet":true
}`)
var created struct {
Status string `json:"status"`
Action string `json:"action"`
Exchange safeExchangeToolConfig `json:"exchange"`
}
if err := json.Unmarshal([]byte(createResp), &created); err != nil {
t.Fatalf("unmarshal create response: %v\nraw=%s", err, createResp)
}
if created.Status != "ok" || created.Action != "create" {
t.Fatalf("unexpected create response: %+v", created)
}
if created.Exchange.AccountName != "Main" || created.Exchange.ExchangeType != "binance" {
t.Fatalf("unexpected exchange payload: %+v", created.Exchange)
}
updateResp := a.toolManageExchangeConfig("user-1", `{
"action":"update",
"exchange_id":"`+created.Exchange.ID+`",
"account_name":"Renamed",
"enabled":false
}`)
var updated struct {
Status string `json:"status"`
Action string `json:"action"`
Exchange safeExchangeToolConfig `json:"exchange"`
}
if err := json.Unmarshal([]byte(updateResp), &updated); err != nil {
t.Fatalf("unmarshal update response: %v\nraw=%s", err, updateResp)
}
if updated.Exchange.AccountName != "Renamed" || updated.Exchange.Enabled {
t.Fatalf("unexpected updated exchange payload: %+v", updated.Exchange)
}
deleteResp := a.toolManageExchangeConfig("user-1", `{
"action":"delete",
"exchange_id":"`+created.Exchange.ID+`"
}`)
var deleted map[string]any
if err := json.Unmarshal([]byte(deleteResp), &deleted); err != nil {
t.Fatalf("unmarshal delete response: %v\nraw=%s", err, deleteResp)
}
if deleted["status"] != "ok" || deleted["action"] != "delete" {
t.Fatalf("unexpected delete response: %+v", deleted)
}
}
func TestToolManageModelConfigLifecycle(t *testing.T) {
a := newTestAgentWithStore(t)
createResp := a.toolManageModelConfig("user-1", `{
"action":"create",
"provider":"openai",
"enabled":true,
"api_key":"sk-test",
"custom_api_url":"https://api.openai.com/v1",
"custom_model_name":"gpt-5-mini"
}`)
var created struct {
Status string `json:"status"`
Action string `json:"action"`
Model safeModelToolConfig `json:"model"`
}
if err := json.Unmarshal([]byte(createResp), &created); err != nil {
t.Fatalf("unmarshal create response: %v\nraw=%s", err, createResp)
}
if created.Status != "ok" || created.Action != "create" {
t.Fatalf("unexpected create response: %+v", created)
}
if created.Model.Provider != "openai" || created.Model.CustomModelName != "gpt-5-mini" {
t.Fatalf("unexpected model payload: %+v", created.Model)
}
updateResp := a.toolManageModelConfig("user-1", `{
"action":"update",
"model_id":"`+created.Model.ID+`",
"enabled":false,
"custom_model_name":"gpt-5"
}`)
var updated struct {
Status string `json:"status"`
Action string `json:"action"`
Model safeModelToolConfig `json:"model"`
}
if err := json.Unmarshal([]byte(updateResp), &updated); err != nil {
t.Fatalf("unmarshal update response: %v\nraw=%s", err, updateResp)
}
if updated.Model.Enabled || updated.Model.CustomModelName != "gpt-5" {
t.Fatalf("unexpected updated model payload: %+v", updated.Model)
}
deleteResp := a.toolManageModelConfig("user-1", `{
"action":"delete",
"model_id":"`+created.Model.ID+`"
}`)
var deleted map[string]any
if err := json.Unmarshal([]byte(deleteResp), &deleted); err != nil {
t.Fatalf("unmarshal delete response: %v\nraw=%s", err, deleteResp)
}
if deleted["status"] != "ok" || deleted["action"] != "delete" {
t.Fatalf("unexpected delete response: %+v", deleted)
}
}
func TestToolManageModelConfigRejectsEnableWithoutAPIKey(t *testing.T) {
a := newTestAgentWithStore(t)
createResp := a.toolManageModelConfig("user-1", `{
"action":"create",
"provider":"openai",
"enabled":false,
"custom_model_name":"gpt-4o"
}`)
var created struct {
Model safeModelToolConfig `json:"model"`
}
if err := json.Unmarshal([]byte(createResp), &created); err != nil {
t.Fatalf("unmarshal create response: %v\nraw=%s", err, createResp)
}
updateResp := a.toolManageModelConfig("user-1", `{
"action":"update",
"model_id":"`+created.Model.ID+`",
"enabled":true
}`)
if !strings.Contains(updateResp, "cannot enable model config before API key is configured") {
t.Fatalf("expected enabling incomplete model to fail, got %s", updateResp)
}
}
func TestGetDefaultSkipsEnabledModelWithoutAPIKey(t *testing.T) {
a := newTestAgentWithStore(t)
incompleteCreate := a.toolManageModelConfig("user-1", `{
"action":"create",
"provider":"openai",
"enabled":true,
"custom_model_name":"gpt-4o"
}`)
var incomplete struct {
Model safeModelToolConfig `json:"model"`
}
if err := json.Unmarshal([]byte(incompleteCreate), &incomplete); err != nil {
t.Fatalf("unmarshal incomplete create response: %v\nraw=%s", err, incompleteCreate)
}
completeCreate := a.toolManageModelConfig("user-1", `{
"action":"create",
"provider":"deepseek",
"enabled":true,
"api_key":"sk-test",
"custom_model_name":"deepseek-chat"
}`)
var complete struct {
Model safeModelToolConfig `json:"model"`
}
if err := json.Unmarshal([]byte(completeCreate), &complete); err != nil {
t.Fatalf("unmarshal complete create response: %v\nraw=%s", err, completeCreate)
}
model, err := a.store.AIModel().GetDefault("user-1")
if err != nil {
t.Fatalf("GetDefault() error = %v", err)
}
if model.ID != complete.Model.ID {
t.Fatalf("expected GetDefault to skip incomplete enabled model and return %s, got %s", complete.Model.ID, model.ID)
}
}
func TestToolManageTraderLifecycle(t *testing.T) {
a := newTestAgentWithStore(t)
modelResp := a.toolManageModelConfig("user-1", `{
"action":"create",
"provider":"openai",
"enabled":true,
"api_key":"sk-test",
"custom_api_url":"https://api.openai.com/v1",
"custom_model_name":"gpt-5-mini"
}`)
var modelCreated struct {
Model safeModelToolConfig `json:"model"`
}
if err := json.Unmarshal([]byte(modelResp), &modelCreated); err != nil {
t.Fatalf("unmarshal model response: %v", err)
}
exchangeResp := a.toolManageExchangeConfig("user-1", `{
"action":"create",
"exchange_type":"binance",
"account_name":"Main",
"enabled":true
}`)
var exchangeCreated struct {
Exchange safeExchangeToolConfig `json:"exchange"`
}
if err := json.Unmarshal([]byte(exchangeResp), &exchangeCreated); err != nil {
t.Fatalf("unmarshal exchange response: %v", err)
}
createResp := a.toolManageTrader("user-1", `{
"action":"create",
"name":"Momentum Trader",
"ai_model_id":"`+modelCreated.Model.ID+`",
"exchange_id":"`+exchangeCreated.Exchange.ID+`",
"scan_interval_minutes":5
}`)
var created struct {
Status string `json:"status"`
Action string `json:"action"`
Trader safeTraderToolConfig `json:"trader"`
}
if err := json.Unmarshal([]byte(createResp), &created); err != nil {
t.Fatalf("unmarshal create trader response: %v\nraw=%s", err, createResp)
}
if created.Status != "ok" || created.Action != "create" {
t.Fatalf("unexpected create trader response: %+v", created)
}
if created.Trader.Name != "Momentum Trader" || created.Trader.ScanIntervalMinutes != 5 {
t.Fatalf("unexpected created trader: %+v", created.Trader)
}
listResp := a.toolManageTrader("user-1", `{"action":"list"}`)
var listed struct {
Count int `json:"count"`
Traders []safeTraderToolConfig `json:"traders"`
}
if err := json.Unmarshal([]byte(listResp), &listed); err != nil {
t.Fatalf("unmarshal list response: %v\nraw=%s", err, listResp)
}
if listed.Count != 1 || len(listed.Traders) != 1 {
t.Fatalf("unexpected trader list: %+v", listed)
}
updateResp := a.toolManageTrader("user-1", `{
"action":"update",
"trader_id":"`+created.Trader.ID+`",
"name":"Renamed Trader",
"scan_interval_minutes":8
}`)
var updated struct {
Status string `json:"status"`
Action string `json:"action"`
Trader safeTraderToolConfig `json:"trader"`
}
if err := json.Unmarshal([]byte(updateResp), &updated); err != nil {
t.Fatalf("unmarshal update trader response: %v\nraw=%s", err, updateResp)
}
if updated.Trader.Name != "Renamed Trader" || updated.Trader.ScanIntervalMinutes != 8 {
t.Fatalf("unexpected updated trader: %+v", updated.Trader)
}
deleteResp := a.toolManageTrader("user-1", `{
"action":"delete",
"trader_id":"`+created.Trader.ID+`"
}`)
var deleted map[string]any
if err := json.Unmarshal([]byte(deleteResp), &deleted); err != nil {
t.Fatalf("unmarshal delete trader response: %v\nraw=%s", err, deleteResp)
}
if deleted["status"] != "ok" || deleted["action"] != "delete" {
t.Fatalf("unexpected delete trader response: %+v", deleted)
}
}
func TestToolManageStrategyLifecycle(t *testing.T) {
a := newTestAgentWithStore(t)
createResp := a.toolManageStrategy("user-1", `{
"action":"create",
"name":"激进",
"description":"激进策略模板",
"lang":"zh"
}`)
var created struct {
Status string `json:"status"`
Action string `json:"action"`
Strategy safeStrategyToolConfig `json:"strategy"`
}
if err := json.Unmarshal([]byte(createResp), &created); err != nil {
t.Fatalf("unmarshal create response: %v\nraw=%s", err, createResp)
}
if created.Status != "ok" || created.Action != "create" {
t.Fatalf("unexpected create response: %+v", created)
}
if created.Strategy.Name != "激进" {
t.Fatalf("unexpected strategy payload: %+v", created.Strategy)
}
listResp := a.toolGetStrategies("user-1")
if !strings.Contains(listResp, "激进") {
t.Fatalf("expected created strategy in list, got %s", listResp)
}
updateResp := a.toolManageStrategy("user-1", `{
"action":"update",
"strategy_id":"`+created.Strategy.ID+`",
"description":"更新后的描述"
}`)
var updated struct {
Status string `json:"status"`
Action string `json:"action"`
Strategy safeStrategyToolConfig `json:"strategy"`
}
if err := json.Unmarshal([]byte(updateResp), &updated); err != nil {
t.Fatalf("unmarshal update response: %v\nraw=%s", err, updateResp)
}
if updated.Strategy.Description != "更新后的描述" {
t.Fatalf("unexpected updated strategy payload: %+v", updated.Strategy)
}
activateResp := a.toolManageStrategy("user-1", `{
"action":"activate",
"strategy_id":"`+created.Strategy.ID+`"
}`)
if !strings.Contains(activateResp, `"action":"activate"`) {
t.Fatalf("unexpected activate response: %s", activateResp)
}
deleteResp := a.toolManageStrategy("user-1", `{
"action":"delete",
"strategy_id":"`+created.Strategy.ID+`"
}`)
if !strings.Contains(deleteResp, `"action":"delete"`) {
t.Fatalf("unexpected delete response: %s", deleteResp)
}
}
func TestLoadAIClientFromStoreUserUsesUserSpecificEnabledModel(t *testing.T) {
a := newTestAgentWithStore(t)
if err := a.store.AIModel().Update("user-42", "openai", true, "sk-test", "https://api.openai.com/v1", "gpt-5-mini"); err != nil {
t.Fatalf("seed model: %v", err)
}
client, modelName, ok := a.loadAIClientFromStoreUser("user-42")
if !ok {
t.Fatal("expected AI client to load from user-specific model")
}
if client == nil {
t.Fatal("expected non-nil AI client")
}
if modelName != "gpt-5-mini" {
t.Fatalf("unexpected model name: %s", modelName)
}
// After the provider registry refactor, registered providers (like openai)
// return their own AIClient implementation, not *mcp.Client.
if client == nil {
t.Fatal("expected non-nil AI client from provider registry")
}
}

View File

@@ -1,339 +0,0 @@
package agent
import (
"encoding/json"
"fmt"
"strings"
"time"
)
const (
executionStatusPlanning = "planning"
executionStatusRunning = "running"
executionStatusWaitingUser = "waiting_user"
executionStatusCompleted = "completed"
executionStatusFailed = "failed"
)
const (
planStepTypeTool = "tool"
planStepTypeReason = "reason"
planStepTypeAskUser = "ask_user"
planStepTypeRespond = "respond"
)
const (
planStepStatusPending = "pending"
planStepStatusRunning = "running"
planStepStatusCompleted = "completed"
planStepStatusFailed = "failed"
)
type ExecutionState struct {
SessionID string `json:"session_id"`
UserID int64 `json:"user_id"`
Goal string `json:"goal"`
Status string `json:"status"`
PlanID string `json:"plan_id"`
Steps []PlanStep `json:"steps,omitempty"`
CurrentStepID string `json:"current_step_id,omitempty"`
CurrentReferences *CurrentReferences `json:"current_references,omitempty"`
DynamicSnapshots []Observation `json:"dynamic_snapshots,omitempty"`
ExecutionLog []Observation `json:"execution_log,omitempty"`
SummaryNotes []Observation `json:"summary_notes,omitempty"`
Waiting *WaitingState `json:"waiting,omitempty"`
Observations []Observation `json:"observations,omitempty"`
FinalAnswer string `json:"final_answer,omitempty"`
LastError string `json:"last_error,omitempty"`
UpdatedAt string `json:"updated_at"`
}
type PlanStep struct {
ID string `json:"id"`
Type string `json:"type"`
Title string `json:"title,omitempty"`
Status string `json:"status,omitempty"`
ToolName string `json:"tool_name,omitempty"`
ToolArgs map[string]any `json:"tool_args,omitempty"`
Instruction string `json:"instruction,omitempty"`
RequiresConfirmation bool `json:"requires_confirmation,omitempty"`
OutputSummary string `json:"output_summary,omitempty"`
Error string `json:"error,omitempty"`
}
type Observation struct {
StepID string `json:"step_id,omitempty"`
Kind string `json:"kind"`
Summary string `json:"summary"`
RawJSON string `json:"raw_json,omitempty"`
CreatedAt string `json:"created_at"`
}
type WaitingState struct {
Question string `json:"question,omitempty"`
Intent string `json:"intent,omitempty"`
PendingFields []string `json:"pending_fields,omitempty"`
ConfirmationTarget string `json:"confirmation_target,omitempty"`
CreatedAt string `json:"created_at,omitempty"`
}
type EntityReference struct {
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
}
type CurrentReferences struct {
Strategy *EntityReference `json:"strategy,omitempty"`
Trader *EntityReference `json:"trader,omitempty"`
Model *EntityReference `json:"model,omitempty"`
Exchange *EntityReference `json:"exchange,omitempty"`
}
type executionPlan struct {
Goal string `json:"goal"`
Steps []PlanStep `json:"steps"`
}
const (
executionLogMaxEntries = 8
summaryNotesMaxEntries = 4
)
func ExecutionStateConfigKey(userID int64) string {
return fmt.Sprintf("agent_execution_state_%d", userID)
}
func (a *Agent) getExecutionState(userID int64) ExecutionState {
if a.store == nil {
return ExecutionState{}
}
raw, err := a.store.GetSystemConfig(ExecutionStateConfigKey(userID))
if err != nil {
a.logger.Warn("failed to load execution state", "error", err, "user_id", userID)
return ExecutionState{}
}
raw = strings.TrimSpace(raw)
if raw == "" {
return ExecutionState{}
}
var state ExecutionState
if err := json.Unmarshal([]byte(raw), &state); err != nil {
a.logger.Warn("failed to parse execution state", "error", err, "user_id", userID)
return ExecutionState{}
}
return normalizeExecutionState(state)
}
func (a *Agent) saveExecutionState(state ExecutionState) error {
if a.store == nil {
return fmt.Errorf("store unavailable")
}
state = normalizeExecutionState(state)
if state.SessionID == "" {
return a.store.SetSystemConfig(ExecutionStateConfigKey(state.UserID), "")
}
data, err := json.Marshal(state)
if err != nil {
return err
}
return a.store.SetSystemConfig(ExecutionStateConfigKey(state.UserID), string(data))
}
func (a *Agent) clearExecutionState(userID int64) {
if a.store == nil {
return
}
if err := a.store.SetSystemConfig(ExecutionStateConfigKey(userID), ""); err != nil {
a.logger.Warn("failed to clear execution state", "error", err, "user_id", userID)
}
}
func newExecutionState(userID int64, goal string) ExecutionState {
now := time.Now().UTC().Format(time.RFC3339)
return normalizeExecutionState(ExecutionState{
SessionID: fmt.Sprintf("sess_%d", time.Now().UTC().UnixNano()),
UserID: userID,
Goal: strings.TrimSpace(goal),
Status: executionStatusPlanning,
PlanID: fmt.Sprintf("plan_%d", time.Now().UTC().UnixNano()),
UpdatedAt: now,
})
}
func normalizeExecutionState(state ExecutionState) ExecutionState {
state.Goal = strings.TrimSpace(state.Goal)
state.Status = strings.TrimSpace(state.Status)
state.CurrentStepID = strings.TrimSpace(state.CurrentStepID)
state.FinalAnswer = strings.TrimSpace(state.FinalAnswer)
state.LastError = strings.TrimSpace(state.LastError)
state.CurrentReferences = normalizeCurrentReferences(state.CurrentReferences)
state.Waiting = normalizeWaitingState(state.Waiting)
if state.Status == "" && state.SessionID != "" {
state.Status = executionStatusPlanning
}
for i := range state.Steps {
state.Steps[i].ID = strings.TrimSpace(state.Steps[i].ID)
if state.Steps[i].ID == "" {
state.Steps[i].ID = fmt.Sprintf("step_%d", i+1)
}
state.Steps[i].Type = strings.TrimSpace(state.Steps[i].Type)
state.Steps[i].Title = strings.TrimSpace(state.Steps[i].Title)
state.Steps[i].ToolName = strings.TrimSpace(state.Steps[i].ToolName)
state.Steps[i].Instruction = strings.TrimSpace(state.Steps[i].Instruction)
state.Steps[i].OutputSummary = strings.TrimSpace(state.Steps[i].OutputSummary)
state.Steps[i].Error = strings.TrimSpace(state.Steps[i].Error)
if state.Steps[i].Status == "" {
state.Steps[i].Status = planStepStatusPending
}
}
if len(state.Observations) > 0 {
state.ExecutionLog = append(state.ExecutionLog, state.Observations...)
state.Observations = nil
}
state.DynamicSnapshots = normalizeObservationList(state.DynamicSnapshots)
state.ExecutionLog = normalizeObservationList(state.ExecutionLog)
state.SummaryNotes = normalizeObservationList(state.SummaryNotes)
state = compactExecutionLog(state)
if state.UpdatedAt == "" && state.SessionID != "" {
state.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
}
return state
}
func normalizeWaitingState(waiting *WaitingState) *WaitingState {
if waiting == nil {
return nil
}
waiting.Question = strings.TrimSpace(waiting.Question)
waiting.Intent = strings.TrimSpace(waiting.Intent)
waiting.PendingFields = cleanStringList(waiting.PendingFields)
waiting.ConfirmationTarget = strings.TrimSpace(waiting.ConfirmationTarget)
if waiting.CreatedAt == "" && (waiting.Question != "" || waiting.Intent != "" || len(waiting.PendingFields) > 0 || waiting.ConfirmationTarget != "") {
waiting.CreatedAt = time.Now().UTC().Format(time.RFC3339)
}
if waiting.Question == "" && waiting.Intent == "" && len(waiting.PendingFields) == 0 && waiting.ConfirmationTarget == "" {
return nil
}
return waiting
}
func normalizeEntityReference(ref *EntityReference) *EntityReference {
if ref == nil {
return nil
}
ref.ID = strings.TrimSpace(ref.ID)
ref.Name = strings.TrimSpace(ref.Name)
if ref.ID == "" && ref.Name == "" {
return nil
}
return ref
}
func normalizeCurrentReferences(refs *CurrentReferences) *CurrentReferences {
if refs == nil {
return nil
}
refs.Strategy = normalizeEntityReference(refs.Strategy)
refs.Trader = normalizeEntityReference(refs.Trader)
refs.Model = normalizeEntityReference(refs.Model)
refs.Exchange = normalizeEntityReference(refs.Exchange)
if refs.Strategy == nil && refs.Trader == nil && refs.Model == nil && refs.Exchange == nil {
return nil
}
return refs
}
func normalizeObservationList(values []Observation) []Observation {
if len(values) == 0 {
return nil
}
out := make([]Observation, 0, len(values))
for _, value := range values {
value.StepID = strings.TrimSpace(value.StepID)
value.Kind = strings.TrimSpace(value.Kind)
value.Summary = strings.TrimSpace(value.Summary)
value.RawJSON = strings.TrimSpace(value.RawJSON)
if value.Kind == "" && value.Summary == "" && value.RawJSON == "" {
continue
}
if value.CreatedAt == "" {
value.CreatedAt = time.Now().UTC().Format(time.RFC3339)
}
out = append(out, value)
}
if len(out) == 0 {
return nil
}
return out
}
func compactExecutionLog(state ExecutionState) ExecutionState {
if len(state.ExecutionLog) <= executionLogMaxEntries {
if len(state.SummaryNotes) > summaryNotesMaxEntries {
state.SummaryNotes = state.SummaryNotes[len(state.SummaryNotes)-summaryNotesMaxEntries:]
}
return state
}
overflow := state.ExecutionLog[:len(state.ExecutionLog)-executionLogMaxEntries]
state.ExecutionLog = state.ExecutionLog[len(state.ExecutionLog)-executionLogMaxEntries:]
summary := summarizeExecutionOverflow(overflow)
if summary != nil {
state.SummaryNotes = append(state.SummaryNotes, *summary)
if len(state.SummaryNotes) > summaryNotesMaxEntries {
state.SummaryNotes = state.SummaryNotes[len(state.SummaryNotes)-summaryNotesMaxEntries:]
}
}
return state
}
func summarizeExecutionOverflow(values []Observation) *Observation {
if len(values) == 0 {
return nil
}
summaries := make([]string, 0, len(values))
for _, value := range values {
label := value.Kind
if label == "" {
label = "observation"
}
if value.Summary != "" {
summaries = append(summaries, fmt.Sprintf("%s: %s", label, value.Summary))
} else if value.RawJSON != "" {
summaries = append(summaries, fmt.Sprintf("%s: %s", label, value.RawJSON))
}
}
if len(summaries) == 0 {
return nil
}
text := strings.Join(summaries, " | ")
if len(text) > 500 {
text = text[:500] + "..."
}
return &Observation{
Kind: "execution_summary",
Summary: text,
CreatedAt: time.Now().UTC().Format(time.RFC3339),
}
}
func appendDynamicSnapshot(state *ExecutionState, obs Observation) {
state.DynamicSnapshots = append(state.DynamicSnapshots, obs)
state.DynamicSnapshots = normalizeObservationList(state.DynamicSnapshots)
}
func appendExecutionLog(state *ExecutionState, obs Observation) {
state.ExecutionLog = append(state.ExecutionLog, obs)
*state = normalizeExecutionState(*state)
}
func buildObservationContext(state ExecutionState) map[string]any {
state = normalizeExecutionState(state)
return map[string]any{
"current_references": state.CurrentReferences,
"dynamic_snapshots": state.DynamicSnapshots,
"execution_log": state.ExecutionLog,
"summary_notes": state.SummaryNotes,
}
}

View File

@@ -1,103 +0,0 @@
package agent
import (
"sync"
"time"
)
// chatMessage represents a single message in conversation history.
type chatMessage struct {
Role string `json:"role"` // "user" or "assistant"
Content string `json:"content"`
Timestamp time.Time `json:"timestamp"`
}
// chatHistory stores conversation history per user.
type chatHistory struct {
mu sync.RWMutex
sessions map[int64][]chatMessage
maxTurns int // hard safety cap in messages per user
}
func newChatHistory(maxTurns int) *chatHistory {
if maxTurns <= 0 {
maxTurns = 100 // default hard cap; recent-window trimming is handled separately
}
return &chatHistory{
sessions: make(map[int64][]chatMessage),
maxTurns: maxTurns,
}
}
// Add appends a message to the user's history.
func (h *chatHistory) Add(userID int64, role, content string) {
h.mu.Lock()
defer h.mu.Unlock()
h.sessions[userID] = append(h.sessions[userID], chatMessage{
Role: role,
Content: content,
Timestamp: time.Now(),
})
// Hard safety cap in case summarization is unavailable.
msgs := h.sessions[userID]
if len(msgs) > h.maxTurns {
h.sessions[userID] = msgs[len(msgs)-h.maxTurns:]
}
}
// Get returns the conversation history for a user.
func (h *chatHistory) Get(userID int64) []chatMessage {
h.mu.RLock()
defer h.mu.RUnlock()
msgs := h.sessions[userID]
if msgs == nil {
return nil
}
// Return a copy
result := make([]chatMessage, len(msgs))
copy(result, msgs)
return result
}
func (h *chatHistory) Replace(userID int64, msgs []chatMessage) {
h.mu.Lock()
defer h.mu.Unlock()
if len(msgs) == 0 {
delete(h.sessions, userID)
return
}
if len(msgs) > h.maxTurns {
msgs = msgs[len(msgs)-h.maxTurns:]
}
cloned := make([]chatMessage, len(msgs))
copy(cloned, msgs)
h.sessions[userID] = cloned
}
// Clear resets conversation history for a user.
func (h *chatHistory) Clear(userID int64) {
h.mu.Lock()
defer h.mu.Unlock()
delete(h.sessions, userID)
}
// CleanOld removes sessions older than the given duration.
func (h *chatHistory) CleanOld(maxAge time.Duration) {
h.mu.Lock()
defer h.mu.Unlock()
now := time.Now()
for uid, msgs := range h.sessions {
if len(msgs) > 0 {
lastMsg := msgs[len(msgs)-1]
if now.Sub(lastMsg.Timestamp) > maxAge {
delete(h.sessions, uid)
}
}
}
}

View File

@@ -1,86 +0,0 @@
package agent
var i18nMessages = map[string]map[string]string{
"help": {
"zh": "🤖 *NOFXi — 你的 AI 交易 Agent*\n\n" +
"*交易:* /buy /sell /long /short + 交易对 数量 杠杆\n" +
"*查询:* /positions /balance /pnl /traders\n" +
"*分析:* /analyze BTC\n" +
"*监控:* /watch BTC · /unwatch BTC\n" +
"*策略:* /strategy\n" +
"*系统:* /status /help\n\n" +
"直接跟我说话就行,中英文都可以 💬",
"en": "🤖 *NOFXi — Your AI Trading Agent*\n\n" +
"*Trade:* /buy /sell /long /short + symbol qty leverage\n" +
"*Query:* /positions /balance /pnl /traders\n" +
"*Analyze:* /analyze BTC\n" +
"*Monitor:* /watch BTC · /unwatch BTC\n" +
"*Strategy:* /strategy\n" +
"*System:* /status /help\n\n" +
"Just talk to me in any language 💬",
},
"status": {
"zh": "📊 *NOFXi 状态*\n\n• Traders: %d/%d 运行中\n• 监控: %d 个交易对\n• AI: %s\n• 时间: %s",
"en": "📊 *NOFXi Status*\n\n• Traders: %d/%d running\n• Watching: %d symbols\n• AI: %s\n• Time: %s",
},
"no_traders": {
"zh": "📭 暂无 Trader。请在 Web UI 中创建和配置。",
"en": "📭 No traders configured. Create one in Web UI.",
},
"no_running_trader": {
"zh": "⚠️ 没有运行中的 Trader。请在 Web UI 中启动。",
"en": "⚠️ No running trader. Start one in Web UI.",
},
"no_positions": {
"zh": "📭 当前没有持仓。",
"en": "📭 No open positions.",
},
"positions_header": {
"zh": "📊 *当前持仓*\n\n",
"en": "📊 *Open Positions*\n\n",
},
"total_pnl": {
"zh": "💰 *总未实现盈亏: $%.2f*",
"en": "💰 *Total Unrealized P/L: $%.2f*",
},
"balance_header": {
"zh": "💰 *账户余额*\n\n",
"en": "💰 *Account Balances*\n\n",
},
"traders_header": {
"zh": "🤖 *Traders*\n\n",
"en": "🤖 *Traders*\n\n",
},
"trade_usage": {
"zh": "用法: `/buy BTC 0.01` 或 `/sell ETH 0.5 3x`",
"en": "Usage: `/buy BTC 0.01` or `/sell ETH 0.5 3x`",
},
"invalid_qty": {
"zh": "❓ 无效数量: %s",
"en": "❓ Invalid quantity: %s",
},
"analysis_header": {
"zh": "🔍 *%s 市场分析*",
"en": "🔍 *%s Analysis*",
},
"sentinel_off": {
"zh": "⚠️ Sentinel 未启用。",
"en": "⚠️ Sentinel not enabled.",
},
"system_prompt": {
"zh": "你是 NOFXi一个专业的 AI 交易 Agent。简洁、专业、用中文回复。使用交易相关 emoji。",
"en": "You are NOFXi, a professional AI trading agent. Be concise, professional. Use trading emojis.",
},
}
func (a *Agent) msg(lang, key string) string {
if m, ok := i18nMessages[key]; ok {
if s, ok := m[lang]; ok {
return s
}
if s, ok := m["en"]; ok {
return s
}
}
return key
}

View File

@@ -1,344 +0,0 @@
package agent
import (
"context"
"encoding/json"
"fmt"
"strings"
"nofx/mcp"
)
type llmSkillRouteDecision struct {
Route string `json:"route"`
Skill string `json:"skill,omitempty"`
Action string `json:"action,omitempty"`
Filter string `json:"filter,omitempty"`
}
func (a *Agent) tryLLMSkillRoute(ctx context.Context, storeUserID string, userID int64, lang, text string, onEvent func(event, data string)) (string, bool, error) {
if a.aiClient == nil {
return "", false, nil
}
text = strings.TrimSpace(text)
if text == "" {
return "", false, nil
}
recentConversationCtx := a.buildRecentConversationContext(userID, text)
taskStateCtx := buildTaskStateContext(a.getTaskState(userID))
executionState := normalizeExecutionState(a.getExecutionState(userID))
executionJSON, _ := json.Marshal(executionState)
systemPrompt := `You are the lightweight skill router for NOFXi.
Decide whether the user's message should go to a structured skill or continue to the planner.
Return JSON only. Do not return markdown.
Use route "skill" only when the user intent is clear enough to send directly to one structured skill.
Use route "planner" for ambiguous, multi-step, open-ended, analytical, or diagnostic requests.
Available skills:
- trader_management
- exchange_management
- model_management
- strategy_management
- trader_diagnosis
- exchange_diagnosis
- model_diagnosis
- strategy_diagnosis
For management skills, choose one atomic action from:
- query_list
- query_detail
- query_running
- create
- update_name
- update_bindings
- update_status
- update_endpoint
- update_config
- update_prompt
- delete
- start
- stop
- activate
- duplicate
Set filter only when it is clearly implied by the user. Use values like:
- running_only
- stopped_only
- enabled_only
- disabled_only
- active_only
- default_only
Rules:
- Prefer route "planner" when uncertain.
- Prefer route "planner" for market analysis, broad advice, multi-step troubleshooting, or requests that need synthesis.
- Prefer route "skill" for straightforward management requests like listing, creating, starting, stopping, enabling, disabling, renaming, or deleting known entities.
- Questions like "当前有运行中的trader吗" and "有没有 trader 在跑" are trader_management with action "query_running".
- Questions about one entity's details, config, parameters, or prompt should prefer action "query_detail".
- Do not use route "skill" for casual chat.
- Consider Recent conversation, Task state, and Execution state JSON before deciding.
Return JSON with this exact shape:
{"route":"skill|planner","skill":"","action":"","filter":""}`
userPrompt := fmt.Sprintf("Language: %s\nUser message: %s\n\nRecent conversation:\n%s\n\nTask state:\n%s\n\nExecution state JSON:\n%s", lang, text, recentConversationCtx, taskStateCtx, string(executionJSON))
stageCtx, cancel := withPlannerStageTimeout(ctx, directReplyTimeout)
defer cancel()
raw, err := a.aiClient.CallWithRequest(&mcp.Request{
Messages: []mcp.Message{
mcp.NewSystemMessage(systemPrompt),
mcp.NewUserMessage(userPrompt),
},
Ctx: stageCtx,
})
if err != nil {
return "", false, nil
}
decision, err := parseLLMSkillRouteDecision(raw)
if err != nil || decision.Route != "skill" {
return "", false, nil
}
outcome, ok := a.executeLLMSkillRoute(storeUserID, userID, lang, text, decision)
if !ok {
return "", false, nil
}
review, err := a.reviewTaskCompletion(ctx, userID, lang, text, outcome)
if err != nil {
if outcome.Status == skillOutcomeRecoverableError || outcome.Status == skillOutcomeFatalError || outcome.Status == skillOutcomeNotHandled {
return "", false, nil
}
review = taskReviewDecision{Route: "complete", Answer: outcome.UserMessage}
}
if review.Route == "replan" {
answer, planErr := a.runPlannedAgent(ctx, storeUserID, userID, lang, fmt.Sprintf("Original user request:\n%s\n\nPrevious skill outcome JSON:\n%s", text, mustMarshalJSON(outcome)), onEvent)
return answer, true, planErr
}
answer := strings.TrimSpace(review.Answer)
if answer == "" {
answer = strings.TrimSpace(outcome.UserMessage)
}
if answer == "" {
return "", false, nil
}
a.recordSkillInteraction(userID, text, answer)
if onEvent != nil {
label := "llm_skill_route"
if decision.Skill != "" {
label += ":" + decision.Skill
}
if decision.Action != "" {
label += ":" + decision.Action
}
onEvent(StreamEventTool, label)
onEvent(StreamEventDelta, answer)
}
return answer, true, nil
}
func parseLLMSkillRouteDecision(raw string) (llmSkillRouteDecision, error) {
raw = strings.TrimSpace(raw)
raw = strings.TrimPrefix(raw, "```json")
raw = strings.TrimPrefix(raw, "```")
raw = strings.TrimSuffix(raw, "```")
raw = strings.TrimSpace(raw)
var decision llmSkillRouteDecision
if err := json.Unmarshal([]byte(raw), &decision); err == nil {
return normalizeLLMSkillRouteDecision(decision), nil
}
start := strings.Index(raw, "{")
end := strings.LastIndex(raw, "}")
if start >= 0 && end > start {
if err := json.Unmarshal([]byte(raw[start:end+1]), &decision); err == nil {
return normalizeLLMSkillRouteDecision(decision), nil
}
}
return llmSkillRouteDecision{}, fmt.Errorf("invalid llm skill route json")
}
func normalizeLLMSkillRouteDecision(decision llmSkillRouteDecision) llmSkillRouteDecision {
decision.Route = strings.TrimSpace(strings.ToLower(decision.Route))
decision.Skill = strings.TrimSpace(strings.ToLower(decision.Skill))
decision.Filter = strings.TrimSpace(strings.ToLower(decision.Filter))
if decision.Action == "query" && decision.Filter == "running_only" && decision.Skill == "trader_management" {
decision.Action = "query_running"
} else {
decision.Action = normalizeAtomicSkillAction(decision.Skill, decision.Action)
}
return decision
}
func (a *Agent) executeLLMSkillRoute(storeUserID string, userID int64, lang, text string, decision llmSkillRouteDecision) (skillOutcome, bool) {
session := skillSession{Name: decision.Skill, Action: decision.Action}
switch decision.Skill {
case "trader_management":
if decision.Action == "create" {
answer, handled := a.handleCreateTraderSkill(storeUserID, userID, lang, text, session)
if !handled {
return skillOutcome{}, false
}
return inferSkillOutcome(decision.Skill, decision.Action, answer, a.getSkillSession(userID), skillDataForAction(storeUserID, decision.Skill, decision.Action, a)), true
}
answer, handled := a.handleTraderManagementSkill(storeUserID, userID, lang, text, session)
if handled && decision.Action == "query_running" {
answer = applyTraderQueryFilter(lang, answer, a.toolListTraders(storeUserID), "running_only")
}
if !handled {
return skillOutcome{}, false
}
return inferSkillOutcome(decision.Skill, decision.Action, answer, a.getSkillSession(userID), skillDataForAction(storeUserID, decision.Skill, decision.Action, a)), true
case "exchange_management":
answer, handled := a.handleExchangeManagementSkill(storeUserID, userID, lang, text, session)
if !handled {
return skillOutcome{}, false
}
return inferSkillOutcome(decision.Skill, decision.Action, answer, a.getSkillSession(userID), skillDataForAction(storeUserID, decision.Skill, decision.Action, a)), true
case "model_management":
answer, handled := a.handleModelManagementSkill(storeUserID, userID, lang, text, session)
if !handled {
return skillOutcome{}, false
}
return inferSkillOutcome(decision.Skill, decision.Action, answer, a.getSkillSession(userID), skillDataForAction(storeUserID, decision.Skill, decision.Action, a)), true
case "strategy_management":
answer, handled := a.handleStrategyManagementSkill(storeUserID, userID, lang, text, session)
if !handled {
return skillOutcome{}, false
}
return inferSkillOutcome(decision.Skill, decision.Action, answer, a.getSkillSession(userID), skillDataForAction(storeUserID, decision.Skill, decision.Action, a)), true
case "model_diagnosis":
return skillOutcome{
Skill: decision.Skill,
Action: defaultIfEmpty(decision.Action, "diagnose"),
Status: skillOutcomeSuccess,
GoalAchieved: true,
UserMessage: a.handleModelDiagnosisSkill(storeUserID, lang, text),
}, true
case "exchange_diagnosis":
return skillOutcome{
Skill: decision.Skill,
Action: defaultIfEmpty(decision.Action, "diagnose"),
Status: skillOutcomeSuccess,
GoalAchieved: true,
UserMessage: a.handleExchangeDiagnosisSkill(storeUserID, lang, text),
}, true
case "trader_diagnosis":
return skillOutcome{
Skill: decision.Skill,
Action: defaultIfEmpty(decision.Action, "diagnose"),
Status: skillOutcomeSuccess,
GoalAchieved: true,
UserMessage: a.handleTraderDiagnosisSkill(storeUserID, lang, text),
}, true
case "strategy_diagnosis":
return skillOutcome{
Skill: decision.Skill,
Action: defaultIfEmpty(decision.Action, "diagnose"),
Status: skillOutcomeSuccess,
GoalAchieved: true,
UserMessage: a.handleStrategyDiagnosisSkill(storeUserID, lang, text),
}, true
default:
return skillOutcome{}, false
}
}
func skillDataForAction(storeUserID, skill, action string, a *Agent) map[string]any {
var raw string
switch skill {
case "trader_management":
if strings.HasPrefix(action, "query") {
raw = a.toolListTraders(storeUserID)
}
case "exchange_management":
if strings.HasPrefix(action, "query") {
raw = a.toolGetExchangeConfigs(storeUserID)
}
case "model_management":
if strings.HasPrefix(action, "query") {
raw = a.toolGetModelConfigs(storeUserID)
}
case "strategy_management":
if strings.HasPrefix(action, "query") {
raw = a.toolGetStrategies(storeUserID)
}
}
if strings.TrimSpace(raw) == "" {
return nil
}
var data map[string]any
if err := json.Unmarshal([]byte(raw), &data); err != nil {
return nil
}
return data
}
func mustMarshalJSON(v any) string {
data, _ := json.Marshal(v)
return string(data)
}
func applyTraderQueryFilter(lang, fallback, raw, filter string) string {
filter = strings.TrimSpace(strings.ToLower(filter))
if filter == "" {
return fallback
}
var payload struct {
Traders []struct {
Name string `json:"name"`
IsRunning bool `json:"is_running"`
} `json:"traders"`
}
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
return fallback
}
switch filter {
case "running_only":
names := make([]string, 0, len(payload.Traders))
for _, trader := range payload.Traders {
if trader.IsRunning {
names = append(names, strings.TrimSpace(trader.Name))
}
}
if lang == "zh" {
if len(names) == 0 {
return "当前没有运行中的交易员。"
}
return fmt.Sprintf("当前有 %d 个运行中的交易员:%s。", len(names), strings.Join(names, "、"))
}
if len(names) == 0 {
return "There are no running traders right now."
}
return fmt.Sprintf("There are %d running traders right now: %s.", len(names), strings.Join(names, ", "))
case "stopped_only":
names := make([]string, 0, len(payload.Traders))
for _, trader := range payload.Traders {
if !trader.IsRunning {
names = append(names, strings.TrimSpace(trader.Name))
}
}
if lang == "zh" {
if len(names) == 0 {
return "当前没有已停止的交易员。"
}
return fmt.Sprintf("当前有 %d 个未运行的交易员:%s。", len(names), strings.Join(names, "、"))
}
if len(names) == 0 {
return "There are no stopped traders right now."
}
return fmt.Sprintf("There are %d stopped traders right now: %s.", len(names), strings.Join(names, ", "))
default:
return fallback
}
}

View File

@@ -1,467 +0,0 @@
package agent
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"nofx/mcp"
)
const (
recentConversationRounds = 3
recentConversationMessages = recentConversationRounds * 2
taskStateSummaryTokenLimit = 1200
shortTermCompressThreshold = 900
incrementalTaskStateMessages = 6
incrementalTaskStateTokenLimit = 500
)
type DecisionMemory struct {
Action string `json:"action,omitempty"`
Reason string `json:"reason,omitempty"`
StillValid bool `json:"still_valid,omitempty"`
Timestamp string `json:"timestamp,omitempty"`
}
type TaskState struct {
CurrentGoal string `json:"current_goal,omitempty"`
ActiveFlow string `json:"active_flow,omitempty"`
// OpenLoops stores only high-level unresolved issues that still matter across turns.
// Step-level pending work belongs in ExecutionState, not here.
OpenLoops []string `json:"open_loops,omitempty"`
ImportantFacts []string `json:"important_facts,omitempty"`
LastDecision *DecisionMemory `json:"last_decision,omitempty"`
UpdatedAt string `json:"updated_at,omitempty"`
}
func TaskStateConfigKey(userID int64) string {
return fmt.Sprintf("agent_task_state_%d", userID)
}
func (a *Agent) getTaskState(userID int64) TaskState {
if a.store == nil {
return TaskState{}
}
raw, err := a.store.GetSystemConfig(TaskStateConfigKey(userID))
if err != nil {
a.logger.Warn("failed to load task state", "error", err, "user_id", userID)
return TaskState{}
}
raw = strings.TrimSpace(raw)
if raw == "" {
return TaskState{}
}
var state TaskState
if err := json.Unmarshal([]byte(raw), &state); err != nil {
a.logger.Warn("failed to parse task state", "error", err, "user_id", userID)
return TaskState{}
}
return normalizeTaskState(state)
}
func (a *Agent) saveTaskState(userID int64, state TaskState) error {
if a.store == nil {
return fmt.Errorf("store unavailable")
}
state = normalizeTaskState(state)
if isZeroTaskState(state) {
return a.store.SetSystemConfig(TaskStateConfigKey(userID), "")
}
data, err := json.Marshal(state)
if err != nil {
return err
}
return a.store.SetSystemConfig(TaskStateConfigKey(userID), string(data))
}
func (a *Agent) clearTaskState(userID int64) {
if a.store == nil {
return
}
if err := a.store.SetSystemConfig(TaskStateConfigKey(userID), ""); err != nil {
a.logger.Warn("failed to clear task state", "error", err, "user_id", userID)
}
}
func normalizeTaskState(state TaskState) TaskState {
state.CurrentGoal = strings.TrimSpace(state.CurrentGoal)
state.ActiveFlow = strings.TrimSpace(state.ActiveFlow)
state.OpenLoops = filterTaskStateOpenLoops(cleanStringList(state.OpenLoops))
state.ImportantFacts = cleanStringList(state.ImportantFacts)
if state.LastDecision != nil {
state.LastDecision.Action = strings.TrimSpace(state.LastDecision.Action)
state.LastDecision.Reason = strings.TrimSpace(state.LastDecision.Reason)
state.LastDecision.Timestamp = strings.TrimSpace(state.LastDecision.Timestamp)
if state.LastDecision.Timestamp == "" && (state.LastDecision.Action != "" || state.LastDecision.Reason != "") {
state.LastDecision.Timestamp = time.Now().UTC().Format(time.RFC3339)
}
if state.LastDecision.Action == "" && state.LastDecision.Reason == "" {
state.LastDecision = nil
}
}
if state.UpdatedAt == "" && !isZeroTaskState(state) {
state.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
}
return state
}
func isZeroTaskState(state TaskState) bool {
return state.CurrentGoal == "" &&
state.ActiveFlow == "" &&
len(state.OpenLoops) == 0 &&
len(state.ImportantFacts) == 0 &&
state.LastDecision == nil
}
func cleanStringList(values []string) []string {
if len(values) == 0 {
return nil
}
out := make([]string, 0, len(values))
seen := make(map[string]struct{}, len(values))
for _, v := range values {
v = strings.TrimSpace(v)
if v == "" {
continue
}
key := strings.ToLower(v)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, v)
}
if len(out) == 0 {
return nil
}
return out
}
func filterTaskStateOpenLoops(values []string) []string {
if len(values) == 0 {
return nil
}
rejectedPrefixes := []string{
"wait for ",
"waiting for ",
"ask for ",
"call ",
"run ",
"execute ",
"invoke ",
"use tool",
"step ",
}
rejectedContains := []string{
"current step",
"tool call",
"api key",
"api secret",
"secret key",
"passphrase",
"model id",
"exchange id",
}
filtered := make([]string, 0, len(values))
for _, value := range values {
lower := strings.ToLower(strings.TrimSpace(value))
if lower == "" {
continue
}
if matchesAnyPrefix(lower, rejectedPrefixes) || matchesAnyContains(lower, rejectedContains) {
continue
}
filtered = append(filtered, value)
}
if len(filtered) == 0 {
return nil
}
return filtered
}
func matchesAnyPrefix(value string, prefixes []string) bool {
for _, prefix := range prefixes {
if strings.HasPrefix(value, prefix) {
return true
}
}
return false
}
func matchesAnyContains(value string, patterns []string) bool {
for _, pattern := range patterns {
if strings.Contains(value, pattern) {
return true
}
}
return false
}
func buildTaskStateContext(state TaskState) string {
state = normalizeTaskState(state)
if isZeroTaskState(state) {
return ""
}
var sb strings.Builder
sb.WriteString("[Structured Task State - durable, non-derivable context]\n")
if state.CurrentGoal != "" {
sb.WriteString("- Current goal: ")
sb.WriteString(state.CurrentGoal)
sb.WriteString("\n")
}
if state.ActiveFlow != "" {
sb.WriteString("- Active flow: ")
sb.WriteString(state.ActiveFlow)
sb.WriteString("\n")
}
for _, loop := range state.OpenLoops {
sb.WriteString("- High-level open loop: ")
sb.WriteString(loop)
sb.WriteString("\n")
}
for _, fact := range state.ImportantFacts {
sb.WriteString("- Important fact: ")
sb.WriteString(fact)
sb.WriteString("\n")
}
if state.LastDecision != nil {
sb.WriteString("- Last decision: ")
sb.WriteString(state.LastDecision.Action)
if state.LastDecision.Reason != "" {
sb.WriteString(" | reason: ")
sb.WriteString(state.LastDecision.Reason)
}
if state.LastDecision.StillValid {
sb.WriteString(" | still valid")
}
sb.WriteString("\n")
}
return strings.TrimSpace(sb.String())
}
func estimateChatMessagesTokens(msgs []chatMessage) int {
total := 0
for _, msg := range msgs {
total += len([]rune(msg.Content))/3 + 10
}
return total
}
func formatChatMessagesForSummary(msgs []chatMessage) string {
var sb strings.Builder
for _, msg := range msgs {
if strings.TrimSpace(msg.Content) == "" {
continue
}
role := "User"
if msg.Role == "assistant" {
role = "Assistant"
}
sb.WriteString(role)
sb.WriteString(": ")
sb.WriteString(msg.Content)
sb.WriteString("\n")
}
return strings.TrimSpace(sb.String())
}
func (a *Agent) maybeCompressHistory(ctx context.Context, userID int64) {
if a.aiClient == nil || a.history == nil {
return
}
msgs := a.history.Get(userID)
if len(msgs) <= recentConversationMessages {
return
}
if estimateChatMessagesTokens(msgs) <= shortTermCompressThreshold {
return
}
splitAt := len(msgs) - recentConversationMessages
if splitAt <= 0 {
return
}
oldPart := msgs[:splitAt]
recentPart := msgs[splitAt:]
existingState := a.getTaskState(userID)
updatedState, err := a.summarizeConversationToTaskState(ctx, userID, existingState, oldPart)
if err != nil {
a.logger.Warn("failed to compress chat history", "error", err, "user_id", userID)
return
}
if err := a.saveTaskState(userID, updatedState); err != nil {
a.log().Warn("failed to persist task state", "error", err, "user_id", userID)
return
}
a.history.Replace(userID, recentPart)
}
func (a *Agent) maybeUpdateTaskStateIncrementally(ctx context.Context, userID int64) {
if a.aiClient == nil || a.history == nil {
return
}
msgs := a.history.Get(userID)
if len(msgs) < 2 {
return
}
window := msgs
if len(window) > incrementalTaskStateMessages {
window = window[len(window)-incrementalTaskStateMessages:]
}
existingState := a.getTaskState(userID)
updatedState, err := a.summarizeRecentConversationToTaskState(ctx, userID, existingState, window)
if err != nil {
a.log().Warn("failed to incrementally update task state", "error", err, "user_id", userID)
return
}
if err := a.saveTaskState(userID, updatedState); err != nil {
a.log().Warn("failed to persist incremental task state", "error", err, "user_id", userID)
}
}
func (a *Agent) summarizeConversationToTaskState(ctx context.Context, userID int64, existing TaskState, oldPart []chatMessage) (TaskState, error) {
transcript := formatChatMessagesForSummary(oldPart)
if transcript == "" {
return normalizeTaskState(existing), nil
}
existingJSON, err := json.Marshal(normalizeTaskState(existing))
if err != nil {
return TaskState{}, err
}
systemPrompt := `You maintain structured task state for a trading assistant.
Update the task state using the existing state plus archived dialogue.
Return JSON only. Do not return markdown.
Rules:
- Keep only durable, non-derivable context useful for future turns.
- Do not store market prices, balances, positions, or anything tools can fetch again.
- Do not store chit-chat or repeated wording.
- current_goal: the user's active objective, if any.
- active_flow: a named flow such as onboarding, trading_confirmation, market_analysis, or empty.
- open_loops: only high-level unresolved issues that still matter across turns.
- Do not put execution-step pending work into open_loops.
- Bad open_loops examples: "wait for API secret", "call get_exchange_configs", "run step 2", "ask user for exchange_id".
- Good open_loops examples: "finish trader setup after external configuration is ready", "user still wants to complete onboarding".
- important_facts: non-derivable facts worth remembering briefly.
- last_decision: keep only one current relevant decision; omit if none.
- Replace stale items instead of appending blindly.
- If a field is no longer relevant, return it empty or omit it.
- Never invent facts.`
userPrompt := fmt.Sprintf("Existing task state JSON:\n%s\n\nArchived dialogue to compress:\n%s\n\nReturn the new task state JSON with this exact shape:\n{\"current_goal\":\"\",\"active_flow\":\"\",\"open_loops\":[],\"important_facts\":[],\"last_decision\":{\"action\":\"\",\"reason\":\"\",\"still_valid\":false,\"timestamp\":\"\"},\"updated_at\":\"\"}", string(existingJSON), transcript)
req := &mcp.Request{
Messages: []mcp.Message{
mcp.NewSystemMessage(systemPrompt),
mcp.NewUserMessage(userPrompt),
},
Ctx: ctx,
MaxTokens: intPtr(taskStateSummaryTokenLimit),
}
resp, err := a.aiClient.CallWithRequest(req)
if err != nil {
return TaskState{}, err
}
state, err := parseTaskStateJSON(resp)
if err != nil {
return TaskState{}, err
}
state = normalizeTaskState(state)
a.log().Info("compressed chat history into task state", "user_id", userID, "archived_messages", len(oldPart))
return state, nil
}
func (a *Agent) summarizeRecentConversationToTaskState(ctx context.Context, userID int64, existing TaskState, recentPart []chatMessage) (TaskState, error) {
transcript := formatChatMessagesForSummary(recentPart)
if transcript == "" {
return normalizeTaskState(existing), nil
}
existingJSON, err := json.Marshal(normalizeTaskState(existing))
if err != nil {
return TaskState{}, err
}
systemPrompt := `You maintain structured task state for a trading assistant.
Update the task state incrementally using the existing state plus the latest conversation window.
Return JSON only. Do not return markdown.
Rules:
- Capture newly confirmed facts from the latest few turns immediately.
- Preserve important existing facts that still matter; replace stale items when contradicted.
- Keep only durable, non-derivable context useful for the next turns.
- current_goal: the user's active objective right now.
- active_flow: a named flow such as onboarding, trading_confirmation, market_analysis, strategy_debugging, or empty.
- open_loops: only high-level unresolved issues that still matter across turns.
- important_facts: include recently confirmed concrete facts, such as the current trader under discussion, the reported runtime error, the user's claimed config value, or the environment where the issue occurs.
- Do not store execution-step pending work or tool instructions.
- Do not store market prices, balances, or anything tools can fetch again.
- Keep last_decision only if there is a current relevant decision; omit it otherwise.
- Never invent facts.`
userPrompt := fmt.Sprintf("Existing task state JSON:\n%s\n\nLatest conversation window:\n%s\n\nReturn the updated task state JSON with this exact shape:\n{\"current_goal\":\"\",\"active_flow\":\"\",\"open_loops\":[],\"important_facts\":[],\"last_decision\":{\"action\":\"\",\"reason\":\"\",\"still_valid\":false,\"timestamp\":\"\"},\"updated_at\":\"\"}", string(existingJSON), transcript)
req := &mcp.Request{
Messages: []mcp.Message{
mcp.NewSystemMessage(systemPrompt),
mcp.NewUserMessage(userPrompt),
},
Ctx: ctx,
MaxTokens: intPtr(incrementalTaskStateTokenLimit),
}
resp, err := a.aiClient.CallWithRequest(req)
if err != nil {
return TaskState{}, err
}
state, err := parseTaskStateJSON(resp)
if err != nil {
return TaskState{}, err
}
state = normalizeTaskState(state)
a.log().Info("incrementally refreshed task state", "user_id", userID, "window_messages", len(recentPart))
return state, nil
}
func parseTaskStateJSON(raw string) (TaskState, error) {
raw = strings.TrimSpace(raw)
raw = strings.TrimPrefix(raw, "```json")
raw = strings.TrimPrefix(raw, "```")
raw = strings.TrimSuffix(raw, "```")
raw = strings.TrimSpace(raw)
var state TaskState
if err := json.Unmarshal([]byte(raw), &state); err == nil {
return state, nil
}
start := strings.Index(raw, "{")
end := strings.LastIndex(raw, "}")
if start >= 0 && end > start {
if err := json.Unmarshal([]byte(raw[start:end+1]), &state); err == nil {
return state, nil
}
}
return TaskState{}, fmt.Errorf("invalid task state json")
}
func intPtr(v int) *int {
return &v
}

View File

@@ -1,132 +0,0 @@
package agent
import (
"context"
"log/slog"
"path/filepath"
"strings"
"testing"
"time"
"nofx/mcp"
"nofx/store"
)
type fakeAIClient struct {
callCount int
}
func (f *fakeAIClient) SetAPIKey(string, string, string) {}
func (f *fakeAIClient) SetTimeout(time.Duration) {}
func (f *fakeAIClient) CallWithMessages(string, string) (string, error) {
return "", nil
}
func (f *fakeAIClient) CallWithRequest(req *mcp.Request) (string, error) {
f.callCount++
return `{"current_goal":"continue setup","active_flow":"onboarding","open_loops":["finish trader setup after external exchange/model configuration is ready"],"important_facts":["user selected OKX"],"last_decision":{"action":"paused setup","reason":"user asked a market question","still_valid":true},"updated_at":"2026-04-01T00:00:00Z"}`, nil
}
func (f *fakeAIClient) CallWithRequestStream(req *mcp.Request, onChunk func(string)) (string, error) {
return "", nil
}
func (f *fakeAIClient) CallWithRequestFull(req *mcp.Request) (*mcp.LLMResponse, error) {
return nil, nil
}
func TestMaybeCompressHistoryKeepsRecentThreeRounds(t *testing.T) {
st, err := store.New(filepath.Join(t.TempDir(), "nofxi-test.db"))
if err != nil {
t.Fatalf("store.New() error = %v", err)
}
fakeClient := &fakeAIClient{}
a := &Agent{
store: st,
logger: slog.Default(),
history: newChatHistory(100),
aiClient: fakeClient,
}
userID := int64(42)
payload := strings.Repeat("BTC ETH market context ", 20)
for i := 0; i < 6; i++ {
a.history.Add(userID, "user", "user turn #"+string(rune('0'+i))+" "+payload)
a.history.Add(userID, "assistant", "assistant turn #"+string(rune('0'+i))+" "+payload)
}
a.maybeCompressHistory(context.Background(), userID)
msgs := a.history.Get(userID)
if len(msgs) != recentConversationMessages {
t.Fatalf("expected %d recent messages, got %d", recentConversationMessages, len(msgs))
}
if fakeClient.callCount != 1 {
t.Fatalf("expected summarizer to be called once, got %d", fakeClient.callCount)
}
state := a.getTaskState(userID)
if state.CurrentGoal != "continue setup" {
t.Fatalf("expected persisted task state goal, got %#v", state)
}
if state.LastDecision == nil || state.LastDecision.Action != "paused setup" {
t.Fatalf("expected persisted last_decision, got %#v", state.LastDecision)
}
if len(state.OpenLoops) != 1 || state.OpenLoops[0] != "finish trader setup after external exchange/model configuration is ready" {
t.Fatalf("expected high-level open loop, got %#v", state.OpenLoops)
}
if strings.Contains(msgs[0].Content, "#0") {
t.Fatalf("expected oldest round to be compressed away, first recent message = %q", msgs[0].Content)
}
if !strings.Contains(msgs[0].Content, "#3") {
t.Fatalf("expected recent window to start from round #3, got %q", msgs[0].Content)
}
if !strings.Contains(msgs[len(msgs)-1].Content, "#5") {
t.Fatalf("expected latest round to remain in short-term history, got %q", msgs[len(msgs)-1].Content)
}
}
func TestNormalizeTaskStateDropsExecutionLevelOpenLoops(t *testing.T) {
state := normalizeTaskState(TaskState{
OpenLoops: []string{
"wait for API secret",
"call get_exchange_configs",
"finish trader setup after external configuration is ready",
},
})
if len(state.OpenLoops) != 1 {
t.Fatalf("expected only one high-level open loop to remain, got %#v", state.OpenLoops)
}
if state.OpenLoops[0] != "finish trader setup after external configuration is ready" {
t.Fatalf("unexpected open loop after normalization: %#v", state.OpenLoops)
}
}
func TestMaybeUpdateTaskStateIncrementallyPersistsShortConversationFacts(t *testing.T) {
st, err := store.New(filepath.Join(t.TempDir(), "nofxi-test.db"))
if err != nil {
t.Fatalf("store.New() error = %v", err)
}
fakeClient := &fakeAIClient{}
a := &Agent{
store: st,
logger: slog.Default(),
history: newChatHistory(100),
aiClient: fakeClient,
}
userID := int64(7)
a.history.Add(userID, "user", "我是在运行测试1交易员时遇到的错误是运行时出现的")
a.history.Add(userID, "assistant", "我会继续排查测试1交易员的运行时错误")
a.maybeUpdateTaskStateIncrementally(context.Background(), userID)
if fakeClient.callCount != 1 {
t.Fatalf("expected incremental summarizer to be called once, got %d", fakeClient.callCount)
}
state := a.getTaskState(userID)
if state.CurrentGoal != "continue setup" {
t.Fatalf("expected incrementally persisted task state, got %#v", state)
}
}

View File

@@ -1,606 +0,0 @@
package agent
import (
"fmt"
"strings"
"time"
"golang.org/x/text/cases"
"golang.org/x/text/language"
"nofx/store"
)
var titleCaser = cases.Title(language.English)
const setupExchangeAccountName = "Default"
// Onboard handles first-time setup through natural language.
// When there's no trader configured, the agent guides the user.
// SetupState tracks where the user is in the setup flow.
type SetupState struct {
Step string // "", "await_exchange", "await_api_key", "await_api_secret", "await_passphrase", "await_ai_model", "await_ai_key"
Exchange string
ExchangeID string
APIKey string
APISecret string
Passphrase string
AIProvider string
AIModel string
AIModelID string
AIKey string
AIBaseURL string
}
// needsSetup returns true if no traders are configured.
func (a *Agent) needsSetup() bool {
if a.traderManager == nil {
return true
}
return len(a.traderManager.GetAllTraders()) == 0
}
// getSetupState loads the current setup state from user preferences.
func (a *Agent) getSetupState(userID int64) *SetupState {
step, _ := a.store.GetSystemConfig(fmt.Sprintf("setup_step_%d", userID))
if step == "" {
return &SetupState{}
}
return &SetupState{
Step: step,
Exchange: getConfig(a.store, userID, "exchange"),
ExchangeID: getConfig(a.store, userID, "exchange_id"),
APIKey: getConfig(a.store, userID, "api_key"),
APISecret: getConfig(a.store, userID, "api_secret"),
Passphrase: getConfig(a.store, userID, "passphrase"),
AIProvider: getConfig(a.store, userID, "ai_provider"),
AIModel: getConfig(a.store, userID, "ai_model"),
AIModelID: getConfig(a.store, userID, "ai_model_id"),
AIKey: getConfig(a.store, userID, "ai_key"),
AIBaseURL: getConfig(a.store, userID, "ai_base_url"),
}
}
func (a *Agent) saveSetupState(userID int64, s *SetupState) {
a.store.SetSystemConfig(fmt.Sprintf("setup_step_%d", userID), s.Step)
setConfig(a.store, userID, "exchange", s.Exchange)
setConfig(a.store, userID, "exchange_id", s.ExchangeID)
// Store only a masked marker for secrets — full values stay in memory only.
// This prevents plaintext credentials from lingering in the config store
// if the setup flow is interrupted before clearSetupState runs.
if s.APIKey != "" {
setConfig(a.store, userID, "api_key", "****")
}
if s.APISecret != "" {
setConfig(a.store, userID, "api_secret", "****")
}
if s.Passphrase != "" {
setConfig(a.store, userID, "passphrase", "****")
}
setConfig(a.store, userID, "ai_provider", s.AIProvider)
setConfig(a.store, userID, "ai_model", s.AIModel)
setConfig(a.store, userID, "ai_model_id", s.AIModelID)
if s.AIKey != "" {
setConfig(a.store, userID, "ai_key", "****")
}
setConfig(a.store, userID, "ai_base_url", s.AIBaseURL)
}
func (a *Agent) clearSetupState(userID int64) {
for _, k := range []string{"step", "exchange", "exchange_id", "api_key", "api_secret", "passphrase", "ai_provider", "ai_model", "ai_model_id", "ai_key", "ai_base_url"} {
if err := a.store.SetSystemConfig(fmt.Sprintf("setup_%s_%d", k, userID), ""); err != nil {
a.log().Warn("clearSetupState: failed to clear key", "key", k, "error", err)
}
}
}
func getConfig(st *store.Store, uid int64, key string) string {
v, _ := st.GetSystemConfig(fmt.Sprintf("setup_%s_%d", key, uid))
return v
}
func setConfig(st *store.Store, uid int64, key, val string) {
st.SetSystemConfig(fmt.Sprintf("setup_%s_%d", key, uid), val)
}
// handleSetupFlow processes the setup conversation.
// Returns (response, handled). If handled=false, continue to normal routing.
func (a *Agent) handleSetupFlow(userID int64, text string, L string) (string, bool) {
return a.handleSetupFlowForStoreUser("default", userID, text, L)
}
func (a *Agent) handleSetupFlowForStoreUser(storeUserID string, userID int64, text string, L string) (string, bool) {
state := a.getSetupState(userID)
lower := strings.ToLower(text)
// Cancel setup — explicit or implicit (user asking unrelated questions)
if lower == "cancel" || lower == "取消" || lower == "/cancel" {
a.clearSetupState(userID)
return a.setupMsg(L, "cancelled"), true
}
// If in a step that expects a key/secret, check if user is NOT sending a key
// Keys are typically long strings without spaces and Chinese characters
if state.Step == "await_api_key" || state.Step == "await_api_secret" || state.Step == "await_passphrase" || state.Step == "await_ai_key" {
trimmed := strings.TrimSpace(text)
hasChinese := false
for _, r := range trimmed {
if r >= 0x4e00 && r <= 0x9fff {
hasChinese = true
break
}
}
hasSpaces := strings.Contains(trimmed, " ") && !strings.HasPrefix(trimmed, "sk-")
tooShort := len(trimmed) < 8
if hasChinese || hasSpaces || tooShort {
// User is probably asking a question, not providing a key
a.clearSetupState(userID)
if L == "zh" {
return "👌 配置已暂停。我先回答你的问题——\n\n随时发送 *开始配置* 继续配置。", false
}
return "👌 Setup paused. Let me answer your question first—\n\nSend *setup* anytime to continue.", false
}
}
switch state.Step {
case "await_exchange":
return a.handleExchangeChoice(userID, text, state, L)
case "await_api_key":
state.APIKey = strings.TrimSpace(text)
state.Step = "await_api_secret"
a.saveSetupState(userID, state)
return a.setupMsg(L, "ask_secret"), true
case "await_api_secret":
state.APISecret = strings.TrimSpace(text)
// OKX/Bitget/KuCoin need passphrase
if needsPassphrase(state.Exchange) {
state.Step = "await_passphrase"
a.saveSetupState(userID, state)
return a.setupMsg(L, "ask_passphrase"), true
}
exchangeID, err := a.saveSetupExchange(storeUserID, state)
if err != nil {
a.logger.Error("save exchange from setup failed", "error", err, "exchange", state.Exchange, "store_user_id", storeUserID)
if L == "zh" {
return fmt.Sprintf("⚠️ 交易所配置保存失败: %v\n请再试一次或稍后去 Web UI 继续。", err), true
}
return fmt.Sprintf("⚠️ Failed to save exchange config: %v\nPlease try again, or continue later in the Web UI.", err), true
}
state.ExchangeID = exchangeID
state.Step = "await_ai_model"
a.saveSetupState(userID, state)
if L == "zh" {
return "✅ 交易所配置已保存,在配置页里现在就能看到。\n\n" + a.setupMsg(L, "ask_ai"), true
}
return "✅ Exchange config saved. It should now be visible in the config page.\n\n" + a.setupMsg(L, "ask_ai"), true
case "await_passphrase":
state.Passphrase = strings.TrimSpace(text)
exchangeID, err := a.saveSetupExchange(storeUserID, state)
if err != nil {
a.logger.Error("save exchange from setup failed", "error", err, "exchange", state.Exchange, "store_user_id", storeUserID)
if L == "zh" {
return fmt.Sprintf("⚠️ 交易所配置保存失败: %v\n请再试一次或稍后去 Web UI 继续。", err), true
}
return fmt.Sprintf("⚠️ Failed to save exchange config: %v\nPlease try again, or continue later in the Web UI.", err), true
}
state.ExchangeID = exchangeID
state.Step = "await_ai_model"
a.saveSetupState(userID, state)
if L == "zh" {
return "✅ 交易所配置已保存,在配置页里现在就能看到。\n\n" + a.setupMsg(L, "ask_ai"), true
}
return "✅ Exchange config saved. It should now be visible in the config page.\n\n" + a.setupMsg(L, "ask_ai"), true
case "await_ai_model":
return a.handleAIChoice(storeUserID, userID, text, state, L)
case "await_ai_key":
state.AIKey = strings.TrimSpace(text)
aiModelID, err := a.saveSetupAIModel(storeUserID, state)
if err != nil {
a.logger.Error("save AI model from setup failed", "error", err, "provider", state.AIProvider, "store_user_id", storeUserID)
if L == "zh" {
return fmt.Sprintf("⚠️ AI 模型配置保存失败: %v\n请再试一次或稍后去 Web UI 继续。", err), true
}
return fmt.Sprintf("⚠️ Failed to save AI model config: %v\nPlease try again, or continue later in the Web UI.", err), true
}
state.AIModelID = aiModelID
return a.finishSetup(storeUserID, userID, state, L)
}
// Not in setup flow — only enter setup for a tiny set of explicit commands.
// Natural-language configuration requests should go to the planner first,
// including phrases like "开始配置" or "帮我配置交易所".
if isDirectSetupCommand(lower) {
state.Step = "await_exchange"
a.saveSetupState(userID, state)
return a.setupMsg(L, "ask_exchange"), true
}
// Everything else — let normal routing handle it
return "", false
}
func isDirectSetupCommand(text string) bool {
text = strings.ToLower(strings.TrimSpace(text))
if text == "" {
return false
}
switch text {
case "setup", "/setup", "开始配置", "配置", "开始设置":
return true
default:
return false
}
}
func (a *Agent) handleExchangeChoice(userID int64, text string, state *SetupState, L string) (string, bool) {
lower := strings.ToLower(strings.TrimSpace(text))
exchanges := map[string]string{
"binance": "binance", "币安": "binance", "1": "binance",
"okx": "okx", "欧易": "okx", "2": "okx",
"bybit": "bybit", "3": "bybit",
"bitget": "bitget", "4": "bitget",
"gate": "gate", "5": "gate",
"kucoin": "kucoin", "库币": "kucoin", "6": "kucoin",
"hyperliquid": "hyperliquid", "7": "hyperliquid",
}
ex, ok := exchanges[lower]
if !ok {
return a.setupMsg(L, "invalid_exchange"), true
}
state.Exchange = ex
state.Step = "await_api_key"
a.saveSetupState(userID, state)
if L == "zh" {
return fmt.Sprintf("✅ 选择了 *%s*\n\n请发送你的 API Key", titleCaser.String(ex)), true
}
return fmt.Sprintf("✅ Selected *%s*\n\nPlease send your API Key:", titleCaser.String(ex)), true
}
func (a *Agent) handleAIChoice(storeUserID string, userID int64, text string, state *SetupState, L string) (string, bool) {
lower := strings.ToLower(strings.TrimSpace(text))
models := map[string]struct{ provider, model, url string }{
"deepseek": {"deepseek", "deepseek-chat", "https://api.deepseek.com/v1"},
"1": {"deepseek", "deepseek-chat", "https://api.deepseek.com/v1"},
"qwen": {"qwen", "qwen-plus", "https://dashscope.aliyuncs.com/compatible-mode/v1"},
"通义": {"qwen", "qwen-plus", "https://dashscope.aliyuncs.com/compatible-mode/v1"},
"2": {"qwen", "qwen-plus", "https://dashscope.aliyuncs.com/compatible-mode/v1"},
"openai": {"openai", "gpt-4o", "https://api.openai.com/v1"},
"gpt": {"openai", "gpt-4o", "https://api.openai.com/v1"},
"3": {"openai", "gpt-4o", "https://api.openai.com/v1"},
"claude": {"claude", "claude-3-5-sonnet-20241022", "https://api.anthropic.com/v1"},
"4": {"claude", "claude-3-5-sonnet-20241022", "https://api.anthropic.com/v1"},
"skip": {"", "", ""},
"跳过": {"", "", ""},
"5": {"", "", ""},
}
choice, ok := models[lower]
if !ok {
return a.setupMsg(L, "invalid_ai"), true
}
if choice.model == "" {
// Skip AI, just create trader with exchange
state.AIProvider = ""
state.AIModel = ""
state.AIModelID = ""
state.AIKey = ""
return a.finishSetup(storeUserID, userID, state, L)
}
state.AIProvider = choice.provider
state.AIModel = choice.model
state.AIBaseURL = choice.url
state.Step = "await_ai_key"
a.saveSetupState(userID, state)
if L == "zh" {
return fmt.Sprintf("✅ AI 模型: *%s*\n\n请发送你的 API Key", choice.model), true
}
return fmt.Sprintf("✅ AI Model: *%s*\n\nPlease send your API Key:", choice.model), true
}
func (a *Agent) finishSetup(storeUserID string, userID int64, state *SetupState, L string) (string, bool) {
// Create exchange in store
a.logger.Info("creating trader from setup",
"exchange", state.Exchange,
"ai_model", state.AIModel,
"store_user_id", storeUserID,
)
// TODO: Use store to create exchange + trader config
// For now, log the config and tell user
a.clearSetupState(userID)
result := ""
maskedKey := maskKey(state.APIKey)
if L == "zh" {
result = fmt.Sprintf("🎉 *配置完成!*\n\n"+
"• 交易所: %s\n"+
"• API Key: %s\n",
titleCaser.String(state.Exchange), maskedKey)
if state.AIModel != "" {
result += fmt.Sprintf("• AI 模型: %s\n", state.AIModel)
}
result += "\n正在创建 Trader..."
} else {
result = fmt.Sprintf("🎉 *Setup Complete!*\n\n"+
"• Exchange: %s\n"+
"• API Key: %s\n",
titleCaser.String(state.Exchange), maskedKey)
if state.AIModel != "" {
result += fmt.Sprintf("• AI Model: %s\n", state.AIModel)
}
result += "\nCreating Trader..."
}
// Actually create the trader via store
err := a.createTraderFromSetupForStoreUser(storeUserID, state)
if err != nil {
a.logger.Error("create trader failed", "error", err)
if L == "zh" {
result += fmt.Sprintf("\n\n⚠ 创建失败: %v\n交易所配置已保存下次配置时可直接复用。\n也可以在 Web UI 中继续完成。", err)
} else {
result += fmt.Sprintf("\n\n⚠ Failed: %v\nYour exchange config was saved, so you can reuse it next time.\nYou can also finish setup in the Web UI.", err)
}
} else {
if L == "zh" {
result += "\n\n✅ Trader 已创建!现在你可以:\n• `/analyze BTC` — 分析市场\n• `/positions` — 查看持仓\n• 或者直接跟我聊天"
} else {
result += "\n\n✅ Trader created! Now you can:\n• `/analyze BTC` — analyze market\n• `/positions` — view positions\n• Or just chat with me"
}
}
return result, true
}
func (a *Agent) createTraderFromSetup(state *SetupState) error {
return a.createTraderFromSetupForStoreUser("default", state)
}
func (a *Agent) createTraderFromSetupForStoreUser(storeUserID string, state *SetupState) error {
if a.store == nil {
return fmt.Errorf("store not available")
}
exchangeID := state.ExchangeID
if exchangeID == "" {
var err error
exchangeID, err = a.saveSetupExchange(storeUserID, state)
if err != nil {
return fmt.Errorf("save exchange: %w", err)
}
}
aiModelID := state.AIModelID
if state.AIModel != "" && state.AIKey != "" && aiModelID == "" {
var err error
aiModelID, err = a.saveSetupAIModel(storeUserID, state)
if err != nil {
a.logger.Error("save AI model", "error", err)
}
}
// Reuse an existing trader if the same exchange/model pair already exists.
existingTraders, err := a.store.Trader().List(storeUserID)
if err != nil {
return fmt.Errorf("list traders: %w", err)
}
for _, existing := range existingTraders {
if existing.ExchangeID == exchangeID && existing.AIModelID == aiModelID {
a.logger.Info("reusing existing trader created via chat setup",
"trader", existing.Name,
"exchange_id", exchangeID,
"ai_model_id", aiModelID,
)
return nil
}
}
// Create trader config
exchangeIDShort := exchangeID
if len(exchangeIDShort) > 8 {
exchangeIDShort = exchangeIDShort[:8]
}
modelPart := aiModelID
if modelPart == "" {
modelPart = "manual"
}
trader := &store.Trader{
ID: fmt.Sprintf("%s_%s_%d", exchangeIDShort, modelPart, time.Now().UnixNano()),
Name: fmt.Sprintf("NOFXi-%s", titleCaser.String(state.Exchange)),
UserID: storeUserID,
ExchangeID: exchangeID,
AIModelID: aiModelID,
IsRunning: false,
}
if err := a.store.Trader().Create(trader); err != nil {
return fmt.Errorf("save trader: %w", err)
}
a.logger.Info("trader created via chat",
"trader", trader.Name,
"exchange", state.Exchange,
"ai", aiModelID,
)
return nil
}
func (a *Agent) saveSetupExchange(storeUserID string, state *SetupState) (string, error) {
if a.store == nil {
return "", fmt.Errorf("store not available")
}
hlWallet := ""
hlUnified := false
passphrase := state.Passphrase
apiKey := state.APIKey
apiSecret := state.APISecret
if state.Exchange == "hyperliquid" {
hlWallet = state.APISecret
apiKey = ""
apiSecret = state.APIKey
}
exchanges, err := a.store.Exchange().List(storeUserID)
if err != nil {
return "", err
}
for _, ex := range exchanges {
if ex.ExchangeType == state.Exchange && ex.AccountName == setupExchangeAccountName {
if err := a.store.Exchange().Update(
storeUserID, ex.ID, true,
apiKey, apiSecret, passphrase,
false,
hlWallet, hlUnified,
"", "", "",
"", "", "", 0,
); err != nil {
return "", err
}
return ex.ID, nil
}
}
return a.store.Exchange().Create(
storeUserID,
state.Exchange,
setupExchangeAccountName,
true,
apiKey, apiSecret, passphrase,
false,
hlWallet, hlUnified,
"", "", "",
"", "", "", 0,
)
}
func (a *Agent) saveSetupAIModel(storeUserID string, state *SetupState) (string, error) {
if a.store == nil {
return "", fmt.Errorf("store not available")
}
if state.AIProvider == "" {
return "", nil
}
modelID := state.AIProvider
if err := a.store.AIModel().Update(
storeUserID,
modelID,
true,
state.AIKey,
state.AIBaseURL,
state.AIModel,
); err != nil {
return "", err
}
modelID = fmt.Sprintf("%s_%s", storeUserID, state.AIProvider)
return modelID, nil
}
func maskKey(key string) string {
if len(key) <= 8 {
return "****"
}
return key[:4] + "****" + key[len(key)-4:]
}
func needsPassphrase(exchange string) bool {
return exchange == "okx" || exchange == "bitget" || exchange == "kucoin"
}
func containsAny(s string, words []string) bool {
for _, w := range words {
if strings.Contains(s, w) {
return true
}
}
return false
}
var setupMessages = map[string]map[string]string{
"welcome": {
"zh": "👋 你好!我是 *NOFXi*,你的 AI 交易 Agent。\n\n" +
"我发现你还没有配置交易所,让我帮你搞定吧!\n\n" +
"发送 *开始配置* 或 *setup* 开始\n" +
"发送 *取消* 随时退出",
"en": "👋 Hi! I'm *NOFXi*, your AI trading agent.\n\n" +
"I see you haven't configured an exchange yet. Let me help!\n\n" +
"Send *setup* to begin\n" +
"Send *cancel* to exit anytime",
},
"ask_exchange": {
"zh": "🏦 *选择你的交易所*\n\n" +
"1⃣ Binance币安\n" +
"2⃣ OKX欧易\n" +
"3⃣ Bybit\n" +
"4⃣ Bitget\n" +
"5⃣ Gate\n" +
"6⃣ KuCoin库币\n" +
"7⃣ Hyperliquid\n\n" +
"发送数字或名称选择:",
"en": "🏦 *Choose your exchange*\n\n" +
"1⃣ Binance\n" +
"2⃣ OKX\n" +
"3⃣ Bybit\n" +
"4⃣ Bitget\n" +
"5⃣ Gate\n" +
"6⃣ KuCoin\n" +
"7⃣ Hyperliquid\n\n" +
"Send number or name:",
},
"invalid_exchange": {
"zh": "❓ 没有识别到交易所。请发送数字 1-7 或交易所名称。",
"en": "❓ Exchange not recognized. Send a number 1-7 or exchange name.",
},
"ask_secret": {
"zh": "🔑 收到 API Key。\n\n现在请发送你的 *API Secret*",
"en": "🔑 Got API Key.\n\nNow send your *API Secret*:",
},
"ask_passphrase": {
"zh": "🔐 收到 API Secret。\n\n这个交易所还需要 *Passphrase*,请发送:",
"en": "🔐 Got API Secret.\n\nThis exchange also needs a *Passphrase*. Please send it:",
},
"ask_ai": {
"zh": "🤖 *选择 AI 模型*\n\n" +
"1⃣ DeepSeek推荐便宜好用\n" +
"2⃣ 通义千问 (Qwen)\n" +
"3⃣ OpenAI (GPT-4o)\n" +
"4⃣ Claude\n" +
"5⃣ 跳过(不配置 AI\n\n" +
"发送数字或名称选择:",
"en": "🤖 *Choose AI model*\n\n" +
"1⃣ DeepSeek (recommended, affordable)\n" +
"2⃣ Qwen\n" +
"3⃣ OpenAI (GPT-4o)\n" +
"4⃣ Claude\n" +
"5⃣ Skip (no AI)\n\n" +
"Send number or name:",
},
"invalid_ai": {
"zh": "❓ 没有识别到 AI 模型。请发送数字 1-5 或模型名称。",
"en": "❓ AI model not recognized. Send a number 1-5 or model name.",
},
"cancelled": {
"zh": "👌 配置已取消。随时发送 *开始配置* 重新开始。",
"en": "👌 Setup cancelled. Send *setup* anytime to restart.",
},
}
func (a *Agent) setupMsg(L, key string) string {
if m, ok := setupMessages[key]; ok {
if s, ok := m[L]; ok {
return s
}
return m["en"]
}
return key
}

View File

@@ -1,26 +0,0 @@
package agent
import "testing"
func TestIsDirectSetupCommand(t *testing.T) {
cases := []struct {
text string
want bool
}{
{text: "setup", want: true},
{text: "/setup", want: true},
{text: "开始配置", want: true},
{text: "配置", want: true},
{text: "开始设置", want: true},
{text: "/开始配置", want: false},
{text: "创建全新的配置,杠杆你定", want: false},
{text: "帮我配置一个 deepseek 模型", want: false},
{text: "绑定交易所 okx", want: false},
}
for _, tc := range cases {
if got := isDirectSetupCommand(tc.text); got != tc.want {
t.Fatalf("isDirectSetupCommand(%q) = %v, want %v", tc.text, got, tc.want)
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,807 +0,0 @@
package agent
import (
"context"
"encoding/json"
"errors"
"log/slog"
"strings"
"testing"
"time"
"nofx/mcp"
)
func TestIsConfigOrTraderIntent(t *testing.T) {
cases := []struct {
text string
want bool
}{
{text: "帮我创建一个交易员", want: true},
{text: "我已经配置好了 OKX 和 DeepSeek", want: true},
{text: "List my traders", want: true},
{text: "BTC 接下来怎么看", want: false},
}
for _, tc := range cases {
if got := isConfigOrTraderIntent(tc.text); got != tc.want {
t.Fatalf("isConfigOrTraderIntent(%q) = %v, want %v", tc.text, got, tc.want)
}
}
}
func TestIsRealtimeAccountIntent(t *testing.T) {
cases := []struct {
text string
want bool
}{
{text: "现在余额多少", want: true},
{text: "我的仓位还在吗", want: true},
{text: "show recent trade history", want: true},
{text: "帮我创建交易员", want: false},
}
for _, tc := range cases {
if got := isRealtimeAccountIntent(tc.text); got != tc.want {
t.Fatalf("isRealtimeAccountIntent(%q) = %v, want %v", tc.text, got, tc.want)
}
}
}
func TestDetectReadFastPath(t *testing.T) {
cases := []struct {
text string
want string
}{
{text: "/traders", want: "list_traders"},
{text: "/strategies", want: "get_strategies"},
{text: "/models", want: "get_model_configs"},
{text: "/exchanges", want: "get_exchange_configs"},
{text: "/balance", want: "get_balance"},
{text: "/positions", want: "get_positions"},
{text: "/history", want: "get_trade_history"},
{text: "/trades", want: "get_trade_history"},
{text: "列出我当前的策略", want: ""},
{text: "查看当前交易员", want: ""},
{text: "现在余额多少", want: ""},
{text: "我的仓位还在吗", want: ""},
{text: "我现在有哪些账户", want: ""},
{text: "我的余额", want: ""},
{text: "根据我的余额帮我分析我应该买什么", want: ""},
{text: "我的策略是AI100但是No candidate coins available, cycle skipped", want: ""},
{text: "帮我创建一个 trader", want: ""},
}
for _, tc := range cases {
req := detectReadFastPath(tc.text)
got := ""
if req != nil {
got = req.Kind
}
if got != tc.want {
t.Fatalf("detectReadFastPath(%q) = %q, want %q", tc.text, got, tc.want)
}
}
}
func TestShouldResetExecutionStateForNewAttempt(t *testing.T) {
state := ExecutionState{
SessionID: "sess_1",
Status: executionStatusWaitingUser,
}
if !shouldResetExecutionStateForNewAttempt("我已经配置好了,继续创建交易员", state) {
t.Fatalf("expected retry-style config request to reset execution state")
}
if shouldResetExecutionStateForNewAttempt("BTC 价格多少", state) {
t.Fatalf("did not expect generic market query to reset execution state")
}
}
func TestLatestAskedQuestion(t *testing.T) {
state := ExecutionState{
Status: executionStatusWaitingUser,
Steps: []PlanStep{
{ID: "step_1", Type: planStepTypeTool, Status: planStepStatusCompleted},
{ID: "step_2", Type: planStepTypeAskUser, Status: planStepStatusCompleted, Instruction: "需要我用正确的参数重试创建交易员 lky 吗?"},
},
}
got := latestAskedQuestion(state)
want := "需要我用正确的参数重试创建交易员 lky 吗?"
if got != want {
t.Fatalf("latestAskedQuestion() = %q, want %q", got, want)
}
}
func TestLatestAskedQuestionPrefersStructuredWaitingState(t *testing.T) {
state := ExecutionState{
Status: executionStatusWaitingUser,
Waiting: &WaitingState{
Question: "请确认是否继续创建交易员 lky",
Intent: "confirm_action",
},
Steps: []PlanStep{
{ID: "step_2", Type: planStepTypeAskUser, Status: planStepStatusCompleted, Instruction: "旧问题"},
},
}
if got := latestAskedQuestion(state); got != "请确认是否继续创建交易员 lky" {
t.Fatalf("latestAskedQuestion() = %q, want structured waiting question", got)
}
}
func TestRefreshStateForDynamicRequestsAddsFreshSnapshots(t *testing.T) {
a := newTestAgentWithStore(t)
_ = a.toolManageModelConfig("user-1", `{
"action":"create",
"provider":"openai",
"enabled":true,
"custom_api_url":"https://api.openai.com/v1",
"custom_model_name":"gpt-5-mini"
}`)
_ = a.toolManageExchangeConfig("user-1", `{
"action":"create",
"exchange_type":"okx",
"account_name":"Main",
"enabled":true
}`)
state := ExecutionState{
SessionID: "sess_1",
UserID: 1,
DynamicSnapshots: []Observation{
{Kind: "current_model_configs", Summary: "stale"},
},
ExecutionLog: []Observation{{Kind: "user_reply", Summary: "continue"}},
}
refreshed := a.refreshStateForDynamicRequests("user-1", "帮我创建交易员", state)
if len(refreshed.DynamicSnapshots) < 3 {
t.Fatalf("expected refreshed observations to include snapshots, got %+v", refreshed.DynamicSnapshots)
}
var foundModel, foundExchange, foundTraders bool
for _, obs := range refreshed.DynamicSnapshots {
switch obs.Kind {
case "current_model_configs":
foundModel = strings.Contains(obs.RawJSON, "openai")
case "current_exchange_configs":
foundExchange = strings.Contains(obs.RawJSON, "okx")
case "current_traders":
foundTraders = strings.Contains(obs.RawJSON, `"traders"`)
}
}
if !foundModel || !foundExchange || !foundTraders {
t.Fatalf("missing fresh snapshots: %+v", refreshed.DynamicSnapshots)
}
}
func TestRefreshStateForRealtimeAccountRequestsAddsFreshSnapshots(t *testing.T) {
a := newTestAgentWithStore(t)
state := ExecutionState{
SessionID: "sess_2",
UserID: 1,
DynamicSnapshots: []Observation{
{Kind: "current_balances", Summary: "stale balances"},
{Kind: "current_positions", Summary: "stale positions"},
},
ExecutionLog: []Observation{{Kind: "user_reply", Summary: "现在余额多少"}},
}
refreshed := a.refreshStateForDynamicRequests("user-1", "现在余额多少,我的仓位还在吗", state)
var keptBalances, keptPositions, foundHistory bool
for _, obs := range refreshed.DynamicSnapshots {
switch obs.Kind {
case "current_balances":
keptBalances = strings.Contains(obs.Summary, "stale balances")
case "current_positions":
keptPositions = strings.Contains(obs.Summary, "stale positions")
case "recent_trade_history":
foundHistory = obs.RawJSON != ""
}
}
if !keptBalances || !keptPositions || foundHistory {
t.Fatalf("expected realtime snapshots to stay untouched, got %+v", refreshed.DynamicSnapshots)
}
}
func TestThinkAndActNaturalLanguageReadCanBeHandledByHighLevelSkill(t *testing.T) {
a := newTestAgentWithStore(t)
_ = a.toolManageStrategy("user-1", `{
"action":"create",
"name":"激进",
"description":"激进策略模板",
"lang":"zh"
}`)
resp, err := a.thinkAndAct(context.Background(), "user-1", 1, "zh", "列出我当前的策略")
if err != nil {
t.Fatalf("thinkAndAct() error = %v", err)
}
if !strings.Contains(resp, "当前策略") || !strings.Contains(resp, "激进") {
t.Fatalf("expected natural-language read to be handled by high-level skill, got %q", resp)
}
}
func TestNormalizeExecutionStateMigratesLegacyObservations(t *testing.T) {
state := normalizeExecutionState(ExecutionState{
SessionID: "sess_legacy",
UserID: 1,
Observations: []Observation{
{Kind: "tool_result", Summary: "legacy tool result"},
},
})
if len(state.Observations) != 0 {
t.Fatalf("expected legacy observations field to be cleared, got %+v", state.Observations)
}
if len(state.ExecutionLog) != 1 || state.ExecutionLog[0].Summary != "legacy tool result" {
t.Fatalf("expected legacy observations to migrate into execution log, got %+v", state.ExecutionLog)
}
}
func TestBuildWaitingStateForTraderConfirmation(t *testing.T) {
state := ExecutionState{Goal: "创建交易员 lky"}
step := PlanStep{
ID: "step_ask_1",
Type: planStepTypeAskUser,
Instruction: "需要我用正确的参数重试创建交易员 lky 吗?",
RequiresConfirmation: true,
}
waiting := buildWaitingState(state, step, step.Instruction)
if waiting == nil {
t.Fatal("expected waiting state")
}
if waiting.Intent != "confirm_action" {
t.Fatalf("unexpected waiting intent: %+v", waiting)
}
if waiting.ConfirmationTarget != "trader" {
t.Fatalf("unexpected confirmation target: %+v", waiting)
}
}
func TestNormalizeWaitingStateCleansFields(t *testing.T) {
state := normalizeExecutionState(ExecutionState{
SessionID: "sess_waiting",
UserID: 1,
Waiting: &WaitingState{
Question: " 请提供 strategy_id ",
Intent: " complete_trader_setup ",
PendingFields: []string{" strategy_id ", "strategy_id"},
ConfirmationTarget: " trader ",
},
})
if state.Waiting == nil {
t.Fatal("expected normalized waiting state")
}
if state.Waiting.Question != "请提供 strategy_id" {
t.Fatalf("unexpected normalized question: %+v", state.Waiting)
}
if len(state.Waiting.PendingFields) != 1 || state.Waiting.PendingFields[0] != "strategy_id" {
t.Fatalf("unexpected pending fields: %+v", state.Waiting)
}
if state.Waiting.ConfirmationTarget != "trader" {
t.Fatalf("unexpected confirmation target: %+v", state.Waiting)
}
}
func TestRefreshCurrentReferencesForUserTextMatchesStrategyName(t *testing.T) {
a := newTestAgentWithStore(t)
_ = a.toolManageStrategy("user-1", `{
"action":"create",
"name":"激进",
"description":"激进策略模板",
"lang":"zh"
}`)
state := newExecutionState(1, "帮我改一下激进这个策略")
a.refreshCurrentReferencesForUserText("user-1", "帮我改一下激进这个策略", &state)
if state.CurrentReferences == nil || state.CurrentReferences.Strategy == nil {
t.Fatalf("expected strategy reference, got %+v", state.CurrentReferences)
}
if state.CurrentReferences.Strategy.Name != "激进" {
t.Fatalf("unexpected strategy reference: %+v", state.CurrentReferences.Strategy)
}
}
func TestUpdateCurrentReferencesFromToolResultTracksCreatedStrategy(t *testing.T) {
state := newExecutionState(1, "创建策略")
changed := updateCurrentReferencesFromToolResult(&state, "manage_strategy", `{
"status":"ok",
"action":"create",
"strategy":{"id":"strategy_1","name":"激进"}
}`)
if !changed {
t.Fatalf("expected reference update to report changed")
}
if state.CurrentReferences == nil || state.CurrentReferences.Strategy == nil {
t.Fatalf("expected strategy reference after tool result, got %+v", state.CurrentReferences)
}
if state.CurrentReferences.Strategy.ID != "strategy_1" {
t.Fatalf("unexpected strategy reference: %+v", state.CurrentReferences.Strategy)
}
}
func TestShouldAttemptReplan(t *testing.T) {
state := ExecutionState{
Steps: []PlanStep{
{ID: "step_1", Type: planStepTypeTool, Status: planStepStatusCompleted},
{ID: "step_2", Type: planStepTypeRespond, Status: planStepStatusPending},
},
}
if !shouldAttemptReplan(state, PlanStep{
Type: planStepTypeTool,
ToolName: "manage_trader",
ToolArgs: map[string]any{"action": "create"},
OutputSummary: `{"status":"ok","action":"create"}`,
}, false) {
t.Fatalf("expected create trader step to trigger replan")
}
if shouldAttemptReplan(state, PlanStep{
Type: planStepTypeTool,
ToolName: "get_balance",
OutputSummary: `{"balances":[]}`,
}, false) {
t.Fatalf("did not expect read-only balance step to trigger replan")
}
if !shouldAttemptReplan(state, PlanStep{
Type: planStepTypeTool,
ToolName: "get_balance",
OutputSummary: `{"error":"ai_model_id is required"}`,
}, false) {
t.Fatalf("expected dependency/error result to trigger replan")
}
}
type failingAIClient struct{}
func (f *failingAIClient) SetAPIKey(string, string, string) {}
func (f *failingAIClient) SetTimeout(_ time.Duration) {}
func (f *failingAIClient) CallWithMessages(string, string) (string, error) {
return "", errors.New("unexpected CallWithMessages")
}
func (f *failingAIClient) CallWithRequest(*mcp.Request) (string, error) {
return "", errors.New("API returned error (status 402): insufficient balance")
}
func (f *failingAIClient) CallWithRequestStream(*mcp.Request, func(string)) (string, error) {
return "", errors.New("unexpected CallWithRequestStream")
}
func (f *failingAIClient) CallWithRequestFull(*mcp.Request) (*mcp.LLMResponse, error) {
return nil, errors.New("API returned error (status 402): insufficient balance")
}
type capturePlannerAIClient struct {
systemPrompt string
userPrompt string
}
func (c *capturePlannerAIClient) SetAPIKey(string, string, string) {}
func (c *capturePlannerAIClient) SetTimeout(time.Duration) {}
func (c *capturePlannerAIClient) CallWithMessages(string, string) (string, error) {
return "", errors.New("unexpected CallWithMessages")
}
func (c *capturePlannerAIClient) CallWithRequest(req *mcp.Request) (string, error) {
if len(req.Messages) > 0 {
c.systemPrompt = req.Messages[0].Content
}
if len(req.Messages) > 1 {
c.userPrompt = req.Messages[1].Content
}
return `{"goal":"test goal","steps":[{"id":"step_1","type":"respond","instruction":"ok"}]}`, nil
}
func (c *capturePlannerAIClient) CallWithRequestStream(*mcp.Request, func(string)) (string, error) {
return "", errors.New("unexpected CallWithRequestStream")
}
func (c *capturePlannerAIClient) CallWithRequestFull(*mcp.Request) (*mcp.LLMResponse, error) {
return nil, errors.New("unexpected CallWithRequestFull")
}
type blockingAIClient struct{}
func (b *blockingAIClient) SetAPIKey(string, string, string) {}
func (b *blockingAIClient) SetTimeout(time.Duration) {}
func (b *blockingAIClient) CallWithMessages(string, string) (string, error) {
return "", errors.New("unexpected CallWithMessages")
}
func (b *blockingAIClient) CallWithRequest(req *mcp.Request) (string, error) {
<-req.Ctx.Done()
return "", req.Ctx.Err()
}
func (b *blockingAIClient) CallWithRequestStream(*mcp.Request, func(string)) (string, error) {
return "", errors.New("unexpected CallWithRequestStream")
}
func (b *blockingAIClient) CallWithRequestFull(*mcp.Request) (*mcp.LLMResponse, error) {
return nil, errors.New("unexpected CallWithRequestFull")
}
type directReplyAIClient struct {
lastSystemPrompt string
lastUserPrompt string
routerPrompt string
skillRouterPrompt string
plannerPrompt string
}
func (d *directReplyAIClient) SetAPIKey(string, string, string) {}
func (d *directReplyAIClient) SetTimeout(time.Duration) {}
func (d *directReplyAIClient) CallWithMessages(string, string) (string, error) {
return "", errors.New("unexpected CallWithMessages")
}
func (d *directReplyAIClient) CallWithRequest(req *mcp.Request) (string, error) {
if len(req.Messages) > 0 {
d.lastSystemPrompt = req.Messages[0].Content
}
if len(req.Messages) > 1 {
d.lastUserPrompt = req.Messages[1].Content
}
if strings.Contains(d.lastSystemPrompt, "first-pass router for NOFXi") {
d.routerPrompt = d.lastSystemPrompt
if strings.Contains(d.lastUserPrompt, "你好") {
return `{"action":"direct_answer","answer":"你好,我在。想聊策略、配置还是排障?"}`, nil
}
return `{"action":"defer","answer":""}`, nil
}
if strings.Contains(d.lastSystemPrompt, "lightweight skill router for NOFXi") {
d.skillRouterPrompt = d.lastSystemPrompt
if strings.Contains(d.lastUserPrompt, "运行中的trader") || strings.Contains(d.lastUserPrompt, "有没有 trader 在跑") {
return `{"route":"skill","skill":"trader_management","action":"query","filter":"running_only"}`, nil
}
return `{"route":"planner","skill":"","action":"","filter":""}`, nil
}
if strings.Contains(d.lastSystemPrompt, "planning module for NOFXi") {
d.plannerPrompt = d.lastSystemPrompt
}
return `{"goal":"test goal","steps":[{"id":"step_1","type":"respond","instruction":"ok"}]}`, nil
}
func (d *directReplyAIClient) CallWithRequestStream(*mcp.Request, func(string)) (string, error) {
return "", errors.New("unexpected CallWithRequestStream")
}
func (d *directReplyAIClient) CallWithRequestFull(*mcp.Request) (*mcp.LLMResponse, error) {
return nil, errors.New("unexpected CallWithRequestFull")
}
func TestThinkAndActLegacyReturnsProviderFailureInsteadOfNoAIFallback(t *testing.T) {
a := &Agent{
aiClient: &failingAIClient{},
config: DefaultConfig(),
logger: slog.Default(),
history: newChatHistory(10),
}
resp, err := a.thinkAndActLegacy(context.Background(), 42, "zh", "你好", nil)
if err != nil {
t.Fatalf("thinkAndActLegacy() error = %v", err)
}
if strings.Contains(resp, "发送 *开始配置* 配置 AI 模型") {
t.Fatalf("expected provider failure message, got fallback: %q", resp)
}
if !strings.Contains(resp, "AI 服务调用失败") {
t.Fatalf("expected provider failure message, got %q", resp)
}
}
func TestThinkAndActUsesDirectReplyGateForConversationalQuestion(t *testing.T) {
client := &directReplyAIClient{}
a := &Agent{
aiClient: client,
config: DefaultConfig(),
logger: slog.Default(),
history: newChatHistory(10),
}
resp, err := a.thinkAndAct(context.Background(), "user-1", 88, "zh", "你好")
if err != nil {
t.Fatalf("thinkAndAct() error = %v", err)
}
if !strings.Contains(resp, "你好,我在") {
t.Fatalf("expected direct reply response, got %q", resp)
}
if !strings.Contains(client.routerPrompt, "first-pass router for NOFXi") {
t.Fatalf("expected direct reply router prompt, got %q", client.routerPrompt)
}
}
func TestThinkAndActDefersFromDirectReplyGateToHardSkill(t *testing.T) {
a := newTestAgentWithStore(t)
a.aiClient = &directReplyAIClient{}
resp, err := a.thinkAndAct(context.Background(), "user-1", 89, "zh", "帮我创建一个 DeepSeek 模型配置")
if err != nil {
t.Fatalf("thinkAndAct() error = %v", err)
}
if !strings.Contains(resp, "已创建模型配置") {
t.Fatalf("expected direct reply gate to defer to hard skill, got %q", resp)
}
}
func TestThinkAndActUsesLLMSkillRouterForNaturalLanguageTraderQuery(t *testing.T) {
client := &directReplyAIClient{}
a := newTestAgentWithStore(t)
a.aiClient = client
a.history = newChatHistory(10)
modelResp := a.toolManageModelConfig("user-1", `{
"action":"create",
"provider":"openai",
"enabled":true,
"custom_api_url":"https://api.openai.com/v1",
"custom_model_name":"gpt-5-mini"
}`)
var modelCreated struct {
Model safeModelToolConfig `json:"model"`
}
if err := json.Unmarshal([]byte(modelResp), &modelCreated); err != nil {
t.Fatalf("unmarshal model response: %v", err)
}
exchangeResp := a.toolManageExchangeConfig("user-1", `{
"action":"create",
"exchange_type":"binance",
"account_name":"Main",
"enabled":true
}`)
var exchangeCreated struct {
Exchange safeExchangeToolConfig `json:"exchange"`
}
if err := json.Unmarshal([]byte(exchangeResp), &exchangeCreated); err != nil {
t.Fatalf("unmarshal exchange response: %v", err)
}
createResp := a.toolManageTrader("user-1", `{
"action":"create",
"name":"Momentum Trader",
"ai_model_id":"`+modelCreated.Model.ID+`",
"exchange_id":"`+exchangeCreated.Exchange.ID+`",
"scan_interval_minutes":5
}`)
var created struct {
Trader safeTraderToolConfig `json:"trader"`
}
if err := json.Unmarshal([]byte(createResp), &created); err != nil {
t.Fatalf("unmarshal create trader response: %v\nraw=%s", err, createResp)
}
if err := a.store.Trader().UpdateStatus("user-1", created.Trader.ID, true); err != nil {
t.Fatalf("update trader status: %v", err)
}
resp, err := a.thinkAndAct(context.Background(), "user-1", 90, "zh", "当前有运行中的trader吗")
if err != nil {
t.Fatalf("thinkAndAct() error = %v", err)
}
if !strings.Contains(resp, "运行中的交易员") || !strings.Contains(resp, "Momentum Trader") {
t.Fatalf("expected routed running-trader answer, got %q", resp)
}
if client.skillRouterPrompt == "" {
t.Fatal("expected lightweight skill router prompt to be used")
}
if client.plannerPrompt != "" {
t.Fatalf("expected planner to be skipped, got prompt %q", client.plannerPrompt)
}
}
func TestThinkAndActPrioritizesActiveExecutionStateOverDirectReply(t *testing.T) {
client := &directReplyAIClient{}
a := newTestAgentWithStore(t)
a.aiClient = client
a.history = newChatHistory(10)
a.logger = slog.Default()
userID := int64(90)
state := newExecutionState(userID, "继续完成当前任务")
state.Status = executionStatusWaitingUser
state.Waiting = &WaitingState{
Question: "请确认是否继续",
Intent: "confirm_action",
}
if err := a.saveExecutionState(state); err != nil {
t.Fatalf("saveExecutionState() error = %v", err)
}
resp, err := a.thinkAndAct(context.Background(), "user-1", userID, "zh", "你好")
if err != nil {
t.Fatalf("thinkAndAct() error = %v", err)
}
if strings.Contains(resp, "你好,我在") {
t.Fatalf("expected active execution state to bypass direct reply gate, got %q", resp)
}
if !strings.Contains(client.plannerPrompt, "planning module for NOFXi") {
t.Fatalf("expected planner prompt when execution state is active, got %q", client.plannerPrompt)
}
}
func TestThinkAndActInterruptsWaitingExecutionStateForNewTopic(t *testing.T) {
a := newTestAgentWithStore(t)
a.history = newChatHistory(10)
_ = a.toolManageStrategy("user-1", `{
"action":"create",
"name":"激进",
"lang":"zh"
}`)
userID := int64(91)
state := newExecutionState(userID, "创建交易员")
state.Status = executionStatusWaitingUser
state.Waiting = &WaitingState{
Question: "请告诉我交易员名称",
PendingFields: []string{"name"},
}
if err := a.saveExecutionState(state); err != nil {
t.Fatalf("saveExecutionState() error = %v", err)
}
resp, err := a.thinkAndAct(context.Background(), "user-1", userID, "zh", "列出我当前的策略")
if err != nil {
t.Fatalf("thinkAndAct() error = %v", err)
}
if !strings.Contains(resp, "当前策略") || !strings.Contains(resp, "激进") {
t.Fatalf("expected new topic to be handled, got %q", resp)
}
if got := a.getExecutionState(userID); got.SessionID != "" {
t.Fatalf("expected execution state to be cleared, got %+v", got)
}
}
func TestCreateExecutionPlanIncludesRecentConversation(t *testing.T) {
client := &capturePlannerAIClient{}
a := &Agent{
aiClient: client,
config: DefaultConfig(),
logger: slog.Default(),
history: newChatHistory(10),
}
userID := int64(42)
a.history.Add(userID, "user", "先帮我看一下当前trader")
a.history.Add(userID, "assistant", "当前只有测试1这个trader。")
a.history.Add(userID, "user", "好的那就按当前trader来")
_, err := a.createExecutionPlan(context.Background(), userID, "zh", "好的那就按当前trader来", newExecutionState(userID, "好的那就按当前trader来"))
if err != nil {
t.Fatalf("createExecutionPlan() error = %v", err)
}
if !strings.Contains(client.userPrompt, "Recent conversation:") {
t.Fatalf("expected planner prompt to include recent conversation, got %q", client.userPrompt)
}
if !strings.Contains(client.userPrompt, "先帮我看一下当前trader") {
t.Fatalf("expected previous user turn in recent conversation, got %q", client.userPrompt)
}
if !strings.Contains(client.userPrompt, "当前只有测试1这个trader") {
t.Fatalf("expected previous assistant turn in recent conversation, got %q", client.userPrompt)
}
recentIdx := strings.Index(client.userPrompt, "Recent conversation:\n")
toolsIdx := strings.Index(client.userPrompt, "\n\nAvailable tools JSON:")
if recentIdx == -1 || toolsIdx == -1 || toolsIdx <= recentIdx {
t.Fatalf("expected recent conversation block boundaries, got %q", client.userPrompt)
}
recentBlock := client.userPrompt[recentIdx:toolsIdx]
if strings.Contains(recentBlock, "好的那就按当前trader来") {
t.Fatalf("expected current user text to stay out of recent conversation block, got %q", recentBlock)
}
if !strings.Contains(client.systemPrompt, "Memory priority order:") {
t.Fatalf("expected planner system prompt to include memory priority guidance, got %q", client.systemPrompt)
}
if !strings.Contains(client.systemPrompt, "Execution state JSON = current operational truth") {
t.Fatalf("expected planner system prompt to prioritize execution state, got %q", client.systemPrompt)
}
if !strings.Contains(client.systemPrompt, "Do not ask the user to repeat a fact") {
t.Fatalf("expected planner system prompt to forbid unnecessary repeated questions, got %q", client.systemPrompt)
}
}
func TestCreateExecutionPlanIncludesRecentConversationForFreshRequest(t *testing.T) {
client := &capturePlannerAIClient{}
a := &Agent{
aiClient: client,
config: DefaultConfig(),
logger: slog.Default(),
history: newChatHistory(10),
}
userID := int64(99)
a.history.Add(userID, "user", "先帮我看一下当前trader")
a.history.Add(userID, "assistant", "当前只有测试1这个trader。")
_, err := a.createExecutionPlan(context.Background(), userID, "zh", "帮我分析一下比特币", ExecutionState{})
if err != nil {
t.Fatalf("createExecutionPlan() error = %v", err)
}
if !strings.Contains(client.userPrompt, "Recent conversation:") {
t.Fatalf("expected fresh request to still include recent conversation block, got %q", client.userPrompt)
}
if !strings.Contains(client.userPrompt, "先帮我看一下当前trader") {
t.Fatalf("expected previous user turn in recent conversation, got %q", client.userPrompt)
}
if !strings.Contains(client.userPrompt, "当前只有测试1这个trader") {
t.Fatalf("expected previous assistant turn in recent conversation, got %q", client.userPrompt)
}
}
func TestCreateExecutionPlanIncludesQuotedEarlierAssistantClaim(t *testing.T) {
client := &capturePlannerAIClient{}
a := &Agent{
aiClient: client,
config: DefaultConfig(),
logger: slog.Default(),
history: newChatHistory(10),
}
userID := int64(100)
a.history.Add(userID, "user", "配置页怎么只有三个交易所")
a.history.Add(userID, "assistant", "目前你看到的是三个交易所。")
_, err := a.createExecutionPlan(context.Background(), userID, "zh", "你前面也跟我说只有三个交易所", ExecutionState{})
if err != nil {
t.Fatalf("createExecutionPlan() error = %v", err)
}
if !strings.Contains(client.userPrompt, "目前你看到的是三个交易所") {
t.Fatalf("expected planner prompt to include earlier assistant claim, got %q", client.userPrompt)
}
if !strings.Contains(client.userPrompt, "配置页怎么只有三个交易所") {
t.Fatalf("expected planner prompt to include earlier user complaint, got %q", client.userPrompt)
}
}
func TestRunPlannedAgentReturnsTimeoutMessageOnPlannerTimeout(t *testing.T) {
oldTimeout := plannerCreateTimeout
plannerCreateTimeout = 10 * time.Millisecond
defer func() { plannerCreateTimeout = oldTimeout }()
a := &Agent{
aiClient: &blockingAIClient{},
config: DefaultConfig(),
logger: slog.Default(),
history: newChatHistory(10),
}
resp, err := a.runPlannedAgent(context.Background(), "default", 7, "zh", "帮我分析一下当前市场", nil)
if err != nil {
t.Fatalf("runPlannedAgent() error = %v", err)
}
if !strings.Contains(resp, "处理超时") {
t.Fatalf("expected timeout message, got %q", resp)
}
}
func TestHandleMessageForStoreUserBypassesPlannerForTradeConfirmation(t *testing.T) {
a := &Agent{
config: DefaultConfig(),
logger: slog.Default(),
history: newChatHistory(10),
pending: newPendingTrades(),
}
resp, err := a.handleMessageForStoreUser(context.Background(), "default", 1, "确认 trade_missing")
if err != nil {
t.Fatalf("handleMessageForStoreUser() error = %v", err)
}
if !strings.Contains(resp, "交易已过期或不存在") {
t.Fatalf("expected direct trade confirmation handling, got %q", resp)
}
}
func TestResolveModelRuntimeConfigUsesProviderDefaults(t *testing.T) {
url, model := resolveModelRuntimeConfig("deepseek", "", "", "user_deepseek")
if url != "https://api.deepseek.com/v1" {
t.Fatalf("unexpected deepseek default url: %q", url)
}
if model != "deepseek-chat" {
t.Fatalf("unexpected deepseek default model: %q", model)
}
url, model = resolveModelRuntimeConfig("deepseek", "", "deepseek1", "user_deepseek")
if url != "https://api.deepseek.com/v1" {
t.Fatalf("unexpected resolved url: %q", url)
}
if model != "deepseek1" {
t.Fatalf("expected existing custom model name to win, got %q", model)
}
}

View File

@@ -1,161 +0,0 @@
package agent
import (
"encoding/json"
"fmt"
"hash/fnv"
"strings"
"time"
)
// PersistentPreference is a durable user instruction shown in the UI and
// injected into the agent context for future conversations.
type PersistentPreference struct {
ID string `json:"id"`
Text string `json:"text"`
CreatedAt string `json:"created_at,omitempty"`
}
func NewPersistentPreference(text string) (PersistentPreference, error) {
text = strings.TrimSpace(text)
if text == "" {
return PersistentPreference{}, fmt.Errorf("text required")
}
now := time.Now().UTC()
return PersistentPreference{
ID: now.Format("20060102150405.000000000"),
Text: text,
CreatedAt: now.Format(time.RFC3339),
}, nil
}
// SessionUserIDFromKey maps a stable user key (for example a UUID string from
// auth) to the int64 session id expected by the current agent implementation.
func SessionUserIDFromKey(userKey string) int64 {
if strings.TrimSpace(userKey) == "" {
return 1
}
h := fnv.New64a()
_, _ = h.Write([]byte(userKey))
sum := h.Sum64() & 0x7fffffffffffffff
if sum == 0 {
return 1
}
return int64(sum)
}
func PreferencesConfigKey(userID int64) string {
return fmt.Sprintf("agent_preferences_%d", userID)
}
func (a *Agent) getPersistentPreferences(userID int64) []PersistentPreference {
if a.store == nil {
return nil
}
raw, err := a.store.GetSystemConfig(PreferencesConfigKey(userID))
if err != nil || strings.TrimSpace(raw) == "" {
return nil
}
var prefs []PersistentPreference
if err := json.Unmarshal([]byte(raw), &prefs); err != nil {
a.logger.Warn("failed to parse persistent preferences", "error", err, "user_id", userID)
return nil
}
return prefs
}
func (a *Agent) savePersistentPreferences(userID int64, prefs []PersistentPreference) error {
if a.store == nil {
return fmt.Errorf("store unavailable")
}
data, err := json.Marshal(prefs)
if err != nil {
return err
}
return a.store.SetSystemConfig(PreferencesConfigKey(userID), string(data))
}
func (a *Agent) addPersistentPreference(userID int64, text string) ([]PersistentPreference, PersistentPreference, error) {
created, err := NewPersistentPreference(text)
if err != nil {
return nil, PersistentPreference{}, err
}
prefs := a.getPersistentPreferences(userID)
prefs = append([]PersistentPreference{created}, prefs...)
if len(prefs) > 20 {
prefs = prefs[:20]
}
if err := a.savePersistentPreferences(userID, prefs); err != nil {
return nil, PersistentPreference{}, err
}
return prefs, created, nil
}
func (a *Agent) updatePersistentPreference(userID int64, match, replacement string) ([]PersistentPreference, *PersistentPreference, error) {
match = strings.TrimSpace(match)
replacement = strings.TrimSpace(replacement)
if match == "" || replacement == "" {
return nil, nil, fmt.Errorf("match and replacement are required")
}
prefs := a.getPersistentPreferences(userID)
for i := range prefs {
if prefs[i].ID == match || strings.Contains(strings.ToLower(prefs[i].Text), strings.ToLower(match)) {
prefs[i].Text = replacement
if err := a.savePersistentPreferences(userID, prefs); err != nil {
return nil, nil, err
}
return prefs, &prefs[i], nil
}
}
return prefs, nil, fmt.Errorf("preference not found")
}
func (a *Agent) deletePersistentPreference(userID int64, match string) ([]PersistentPreference, *PersistentPreference, error) {
match = strings.TrimSpace(match)
if match == "" {
return nil, nil, fmt.Errorf("match required")
}
prefs := a.getPersistentPreferences(userID)
filtered := make([]PersistentPreference, 0, len(prefs))
var removed *PersistentPreference
for i := range prefs {
p := prefs[i]
if removed == nil && (p.ID == match || strings.Contains(strings.ToLower(p.Text), strings.ToLower(match))) {
cp := p
removed = &cp
continue
}
filtered = append(filtered, p)
}
if removed == nil {
return prefs, nil, fmt.Errorf("preference not found")
}
if err := a.savePersistentPreferences(userID, filtered); err != nil {
return nil, nil, err
}
return filtered, removed, nil
}
func (a *Agent) buildPersistentPreferencesContext(userID int64) string {
prefs := a.getPersistentPreferences(userID)
if len(prefs) == 0 {
return ""
}
var sb strings.Builder
sb.WriteString("[Persistent User Preferences - follow unless the user explicitly overrides them]\n")
for _, pref := range prefs {
if strings.TrimSpace(pref.Text) == "" {
continue
}
sb.WriteString("- ")
sb.WriteString(pref.Text)
sb.WriteString("\n")
}
return strings.TrimSpace(sb.String())
}

View File

@@ -1,31 +0,0 @@
package agent
import (
"strings"
"testing"
)
func TestNewPersistentPreference(t *testing.T) {
pref, err := NewPersistentPreference(" Always answer in Chinese. ")
if err != nil {
t.Fatalf("expected preference to be created, got error: %v", err)
}
if pref.ID == "" {
t.Fatal("expected non-empty preference id")
}
if pref.Text != "Always answer in Chinese." {
t.Fatalf("expected trimmed text, got %q", pref.Text)
}
if pref.CreatedAt == "" {
t.Fatal("expected created_at to be set")
}
if strings.Contains(pref.ID, "Always") {
t.Fatalf("expected generated id, got %q", pref.ID)
}
}
func TestNewPersistentPreferenceRejectsEmptyText(t *testing.T) {
if _, err := NewPersistentPreference(" "); err == nil {
t.Fatal("expected empty text to be rejected")
}
}

View File

@@ -1,107 +0,0 @@
package agent
import (
"context"
"fmt"
"log/slog"
"nofx/safe"
"strings"
"sync"
"time"
)
type Scheduler struct {
agent *Agent
logger *slog.Logger
stopCh chan struct{}
stopOnce sync.Once
}
func NewScheduler(a *Agent, l *slog.Logger) *Scheduler {
return &Scheduler{agent: a, logger: l, stopCh: make(chan struct{})}
}
func (s *Scheduler) Start(ctx context.Context) {
safe.GoNamed("agent-scheduler", func() {
ticker := time.NewTicker(1 * time.Minute)
defer ticker.Stop()
lastReport := time.Time{}
lastCheck := time.Time{}
for {
select {
case <-ctx.Done(): return
case <-s.stopCh: return
case now := <-ticker.C:
// Daily report at 21:00
if now.Hour() == 21 && now.Sub(lastReport) > 12*time.Hour {
s.dailyReport()
lastReport = now
}
// Position risk check every 4h
if now.Sub(lastCheck) > 4*time.Hour {
s.riskCheck()
lastCheck = now
}
// Clean expired pending trades every hour.
if now.Minute() == 0 {
if s.agent.pending != nil {
s.agent.pending.CleanExpired()
}
}
}
}
})
}
func (s *Scheduler) Stop() { s.stopOnce.Do(func() { close(s.stopCh) }) }
func (s *Scheduler) dailyReport() {
if s.agent.traderManager == nil { return }
traders := s.agent.traderManager.GetAllTraders()
if len(traders) == 0 { return }
var sb strings.Builder
sb.WriteString(fmt.Sprintf("📊 *NOFXi 每日报告 — %s*\n\n", time.Now().Format("2006-01-02")))
totalPnL := 0.0
for _, t := range traders {
info, err := t.GetAccountInfo()
if err != nil { continue }
equity := toFloat(info["total_equity"])
pnl := toFloat(info["unrealized_pnl"])
sb.WriteString(fmt.Sprintf("• %s: $%.2f (P/L: $%.2f)\n", t.GetName(), equity, pnl))
totalPnL += pnl
}
e := "📈"
if totalPnL < 0 { e = "📉" }
sb.WriteString(fmt.Sprintf("\n%s Total P/L: $%.2f", e, totalPnL))
s.agent.notifyAll(sb.String())
}
func (s *Scheduler) riskCheck() {
if s.agent.traderManager == nil { return }
var alerts []string
for _, t := range s.agent.traderManager.GetAllTraders() {
positions, err := t.GetPositions()
if err != nil { continue }
for _, p := range positions {
pnl := toFloat(p["unrealizedPnl"])
size := toFloat(p["size"])
if size == 0 { continue }
entry := toFloat(p["entryPrice"])
if entry > 0 {
pnlPct := (pnl / (entry * size)) * 100
if pnlPct < -5 {
alerts = append(alerts, fmt.Sprintf("⚠️ *%s* %s: %.1f%% ($%.2f)",
p["symbol"], p["side"], pnlPct, pnl))
}
}
}
}
if len(alerts) > 0 {
s.agent.notifyAll("🚨 *持仓风险提醒*\n\n" + strings.Join(alerts, "\n"))
}
}

View File

@@ -1,173 +0,0 @@
package agent
import (
"encoding/json"
"fmt"
"log/slog"
"math"
"net/http"
"nofx/safe"
"strconv"
"strings"
"sync"
"time"
)
type SignalType string
const (
SignalPriceBreakout SignalType = "price_breakout"
SignalVolumeSpike SignalType = "volume_spike"
SignalFundingRate SignalType = "funding_rate"
)
type Signal struct {
Type SignalType
Symbol string
Severity string
Title string
Detail string
Price float64
Change float64
}
type SignalCallback func(Signal)
type Sentinel struct {
mu sync.RWMutex
symbols []string
history map[string][]pricePt
onSignal SignalCallback
http *http.Client
logger *slog.Logger
stopCh chan struct{}
stopOnce sync.Once
}
type pricePt struct {
Price float64
Volume float64
Time time.Time
}
func NewSentinel(symbols []string, cb SignalCallback, logger *slog.Logger) *Sentinel {
return &Sentinel{
symbols: symbols,
history: make(map[string][]pricePt),
onSignal: cb,
http: &http.Client{Timeout: 10 * time.Second},
logger: logger,
stopCh: make(chan struct{}),
}
}
func (s *Sentinel) Start() {
safe.GoNamed("sentinel", func() {
ticker := time.NewTicker(60 * time.Second)
defer ticker.Stop()
s.scan()
for {
select {
case <-s.stopCh:
return
case <-ticker.C:
s.scan()
}
}
})
}
func (s *Sentinel) Stop() { s.stopOnce.Do(func() { close(s.stopCh) }) }
func (s *Sentinel) SymbolCount() int { s.mu.RLock(); defer s.mu.RUnlock(); return len(s.symbols) }
func (s *Sentinel) AddSymbol(sym string) { s.mu.Lock(); defer s.mu.Unlock(); for _, x := range s.symbols { if x == sym { return } }; s.symbols = append(s.symbols, sym) }
func (s *Sentinel) RemoveSymbol(sym string) { s.mu.Lock(); defer s.mu.Unlock(); for i, x := range s.symbols { if x == sym { s.symbols = append(s.symbols[:i], s.symbols[i+1:]...); return } } }
func (s *Sentinel) FormatWatchlist(L string) string {
s.mu.RLock()
defer s.mu.RUnlock()
if len(s.symbols) == 0 {
if L == "zh" { return "📭 监控列表为空。用 `/watch BTC` 添加。" }
return "📭 Watchlist empty. Use `/watch BTC` to add."
}
var sb strings.Builder
if L == "zh" { sb.WriteString("👁️ *监控列表*\n\n") } else { sb.WriteString("👁️ *Watchlist*\n\n") }
for _, sym := range s.symbols {
if pts, ok := s.history[sym]; ok && len(pts) > 0 {
last := pts[len(pts)-1]
sb.WriteString(fmt.Sprintf("• *%s*: $%.4f (%s)\n", sym, last.Price, last.Time.Format("15:04")))
} else {
sb.WriteString(fmt.Sprintf("• *%s*: waiting...\n", sym))
}
}
return sb.String()
}
func (s *Sentinel) scan() {
s.mu.RLock()
syms := make([]string, len(s.symbols))
copy(syms, s.symbols)
s.mu.RUnlock()
for _, sym := range syms {
s.check(sym)
}
}
func (s *Sentinel) check(symbol string) {
resp, err := s.http.Get(fmt.Sprintf("https://fapi.binance.com/fapi/v1/ticker/24hr?symbol=%s", symbol))
if err != nil { return }
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
s.logger.Debug("sentinel ticker non-200", "symbol", symbol, "status", resp.StatusCode)
return
}
body, err := safe.ReadAllLimited(resp.Body, 256*1024) // 256KB limit
if err != nil { return }
var t map[string]interface{}
if err := json.Unmarshal(body, &t); err != nil { return }
price, _ := strconv.ParseFloat(fmt.Sprint(t["lastPrice"]), 64)
vol, _ := strconv.ParseFloat(fmt.Sprint(t["quoteVolume"]), 64)
chg, _ := strconv.ParseFloat(fmt.Sprint(t["priceChangePercent"]), 64)
pt := pricePt{Price: price, Volume: vol, Time: time.Now()}
s.mu.Lock()
h := s.history[symbol]
h = append(h, pt)
if len(h) > 60 { h = h[len(h)-60:] }
s.history[symbol] = h
s.mu.Unlock()
if len(h) < 5 { return }
// Price breakout (>3% in 5 min)
old := h[len(h)-5]
pct := ((price - old.Price) / old.Price) * 100
if math.Abs(pct) >= 3.0 {
sev := "warning"
if math.Abs(pct) >= 6.0 { sev = "critical" }
dir := "📈 拉升"
if pct < 0 { dir = "📉 下跌" }
s.emit(Signal{Type: SignalPriceBreakout, Symbol: symbol, Severity: sev,
Title: fmt.Sprintf("%s %s %.1f%%", symbol, dir, math.Abs(pct)),
Detail: fmt.Sprintf("5min: $%.2f → $%.2f (24h: %.1f%%)", old.Price, price, chg),
Price: price, Change: pct})
}
// Volume spike (>3x avg)
if len(h) >= 10 {
var avg float64
for i := 0; i < len(h)-1; i++ { avg += h[i].Volume }
avg /= float64(len(h) - 1)
if avg > 0 && vol > avg*3 {
s.emit(Signal{Type: SignalVolumeSpike, Symbol: symbol, Severity: "warning",
Title: fmt.Sprintf("%s 成交量异常 %.1fx", symbol, vol/avg),
Detail: fmt.Sprintf("Price: $%.2f (24h: %.1f%%)", price, chg),
Price: price, Change: chg})
}
}
}
func (s *Sentinel) emit(sig Signal) {
s.logger.Info("signal", "type", sig.Type, "symbol", sig.Symbol, "title", sig.Title)
if s.onSignal != nil { s.onSignal(sig) }
}

View File

@@ -1,97 +0,0 @@
package agent
func skillCatalogPrompt(lang string) string {
if lang == "zh" {
return `## 多轮与 Skill-First 工作模式
- 对于高频已知任务,优先按 skill 执行,不要每次从零规划
- 如果用户仍在同一任务里,继续当前 flow不要重新路由
- 只追问继续执行所需的最少必要字段,不要让用户重复已确认信息
- 高风险动作(删除、启动实盘、停止运行中 trader、覆盖关键配置必须单独确认
- 对诊断类问题,优先做“问题归类 -> 可能原因 -> 核查项 -> 下一步建议”
## 当前重点技能
### 1. 模型配置与诊断
- ` + "`skill_model_api_setup`" + `:用户问某个大模型的 API key 去哪申请、base URL 怎么填、model name 怎么填时,给步骤化指导
- ` + "`skill_model_config_diagnosis`" + `:当用户遇到模型配置失败、调用失败、保存后不可用时,优先检查:
1. 是否已启用模型
2. API Key 是否为空
3. custom_api_url 是否为合法 HTTPS 地址
4. custom_model_name 是否为空或填错
5. 保存后是否需要重新加载 trader
- 已知事实:
- 系统会拒绝非 HTTPS 的 custom_api_url
- 已启用模型如果缺少 API Key 或 custom_api_url会导致 agent 不可用
### 2. 交易所配置与诊断
- ` + "`skill_exchange_api_setup`" + `:指导用户创建交易所 API明确需要哪些权限、哪些权限不要开、哪些交易所需要额外字段
- ` + "`skill_exchange_api_diagnosis`" + `:用户遇到 invalid signature、timestamp、permission denied、IP not allowed 时,优先排查:
1. 系统时间是否同步
2. API Key / Secret 是否填反或过期
3. IP 白名单是否包含服务器 IP
4. 是否启用了合约/交易权限
5. OKX 是否遗漏 passphrase
- 已知事实:
- OKX 除 API Key 和 Secret 外还需要 passphrase
- invalid signature / timestamp 常见根因是时间不同步或密钥不匹配
### 3. Trader 启动与运行诊断
- ` + "`skill_trader_start_diagnosis`" + `:当用户说 trader 启动不了、启动后不交易、没有持仓、没有决策时,优先排查:
1. 是否存在可用且启用的模型配置
2. 是否存在可用且启用的交易所配置
3. trader 绑定的 strategy / exchange / model 是否齐全
4. 账户余额和权限是否满足下单要求
5. AI 是否一直返回 wait / hold
- 如果用户问“为什么没有开仓”,要明确区分:
- 系统没启动
- 启动了但 AI 决策为 wait
- 有信号但下单失败
### 4. 交易行为异常诊断
- ` + "`skill_order_execution_diagnosis`" + `:当用户问仓位开不出来、只开单边、杠杆报错时,优先排查:
1. 是否为交易所模式问题(例如 Binance One-way / Hedge Mode
2. 是否为子账户杠杆限制
3. 是否为合约权限或 symbol 不可交易
4. 是否为余额不足或保证金占用过高
- 已知事实:
- Binance 若不是 Hedge Mode可能出现 position side mismatch 或只开单边
- 某些子账户杠杆受限,超过限制会直接报错
### 5. 策略与提示词诊断
- ` + "`skill_strategy_diagnosis`" + `:当用户说策略没生效、提示词不对、预览和实际不一致时,优先建议:
1. 查看当前 strategy 配置
2. 区分策略模板本身和 trader 上的 custom prompt
3. 必要时预览 prompt 或读取当前保存值后再判断
## 回答格式要求
- 诊断类问题尽量按“现象 / 原因 / 先检查什么 / 怎么修复”回答
- 配置指导类问题尽量按步骤回答
- 如果已有工具能验证当前状态,先查再下结论
- 如果结论是推测,必须明确说是“更可能”或“优先怀疑”`
}
return `## Multi-turn and Skill-First Operating Mode
- For high-frequency known tasks, prefer stable skills instead of replanning from scratch
- If the user is still in the same task, continue the active flow
- Ask only for the minimum missing fields required to proceed
- Require explicit confirmation for destructive or financially sensitive actions
- For diagnostic requests, use: issue class -> likely causes -> checks -> next steps
## Priority Skills
- skill_model_api_setup / skill_model_config_diagnosis
- skill_exchange_api_setup / skill_exchange_api_diagnosis
- skill_trader_start_diagnosis
- skill_order_execution_diagnosis
- skill_strategy_diagnosis
Known facts:
- custom_api_url must be a valid HTTPS URL
- OKX requires passphrase in addition to API key and secret
- invalid signature / timestamp often means clock skew or mismatched credentials
- missing enabled model or exchange config can block trader startup
- Binance position-side issues are often caused by One-way Mode vs Hedge Mode
Response style:
- Diagnostics: symptom -> cause -> checks -> fix
- Setup guidance: step-by-step
- Verify with tools when possible before concluding`
}

View File

@@ -1,35 +0,0 @@
package agent
import (
"log/slog"
"strings"
"testing"
)
func TestSkillCatalogPromptZHIncludesDiagnosisSkills(t *testing.T) {
got := skillCatalogPrompt("zh")
for _, want := range []string{
"多轮与 Skill-First 工作模式",
"skill_model_config_diagnosis",
"skill_exchange_api_diagnosis",
"skill_trader_start_diagnosis",
} {
if !strings.Contains(got, want) {
t.Fatalf("skillCatalogPrompt(zh) missing %q\n%s", want, got)
}
}
}
func TestBuildSystemPromptIncludesSkillCatalog(t *testing.T) {
a := New(nil, nil, DefaultConfig(), slog.Default())
got := a.buildSystemPrompt("zh")
for _, want := range []string{
"多轮与 Skill-First 工作模式",
"skill_exchange_api_setup",
"skill_order_execution_diagnosis",
} {
if !strings.Contains(got, want) {
t.Fatalf("buildSystemPrompt(zh) missing %q", want)
}
}
}

View File

@@ -1,277 +0,0 @@
package agent
import "strings"
type SkillDAG struct {
SkillName string
Action string
Steps []SkillDAGStep
}
type SkillDAGStep struct {
ID string
Kind string
RequiredFields []string
OptionalFields []string
Next []string
Terminal bool
}
var skillDAGRegistry = buildSkillDAGRegistry()
func buildSkillDAGRegistry() map[string]SkillDAG {
dags := []SkillDAG{
{
SkillName: "trader_management",
Action: "create",
Steps: []SkillDAGStep{
{ID: "resolve_name", Kind: "collect_slot", RequiredFields: []string{"name"}, Next: []string{"resolve_exchange"}},
{ID: "resolve_exchange", Kind: "collect_slot", RequiredFields: []string{"exchange_id"}, OptionalFields: []string{"exchange_name"}, Next: []string{"resolve_model"}},
{ID: "resolve_model", Kind: "collect_slot", RequiredFields: []string{"model_id"}, OptionalFields: []string{"model_name"}, Next: []string{"resolve_strategy"}},
{ID: "resolve_strategy", Kind: "collect_slot", RequiredFields: []string{"strategy_id"}, OptionalFields: []string{"strategy_name"}, Next: []string{"maybe_confirm_start"}},
{ID: "maybe_confirm_start", Kind: "branch", OptionalFields: []string{"auto_start"}, Next: []string{"await_start_confirmation", "execute_create_only"}},
{ID: "await_start_confirmation", Kind: "confirm", RequiredFields: []string{"auto_start"}, Next: []string{"execute_create_and_start", "execute_create_only"}},
{ID: "execute_create_only", Kind: "execute", RequiredFields: []string{"name", "exchange_id", "model_id", "strategy_id"}, Terminal: true},
{ID: "execute_create_and_start", Kind: "execute", RequiredFields: []string{"name", "exchange_id", "model_id", "strategy_id"}, OptionalFields: []string{"auto_start"}, Terminal: true},
},
},
{
SkillName: "trader_management",
Action: "update_name",
Steps: []SkillDAGStep{
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"collect_name"}},
{ID: "collect_name", Kind: "collect_slot", RequiredFields: []string{"name"}, Next: []string{"execute_update"}},
{ID: "execute_update", Kind: "execute", RequiredFields: []string{"target_ref", "name"}, Terminal: true},
},
},
{
SkillName: "trader_management",
Action: "update_bindings",
Steps: []SkillDAGStep{
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"collect_bindings"}},
{ID: "collect_bindings", Kind: "collect_slot", RequiredFields: []string{"binding_update"}, OptionalFields: []string{"ai_model_id", "exchange_id", "strategy_id"}, Next: []string{"execute_update"}},
{ID: "execute_update", Kind: "execute", RequiredFields: []string{"target_ref", "binding_update"}, OptionalFields: []string{"ai_model_id", "exchange_id", "strategy_id"}, Terminal: true},
},
},
{
SkillName: "trader_management",
Action: "start",
Steps: []SkillDAGStep{
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"await_confirmation"}},
{ID: "await_confirmation", Kind: "confirm", RequiredFields: []string{"target_ref"}, Next: []string{"execute_start"}},
{ID: "execute_start", Kind: "execute", RequiredFields: []string{"target_ref"}, Terminal: true},
},
},
{
SkillName: "trader_management",
Action: "stop",
Steps: []SkillDAGStep{
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"await_confirmation"}},
{ID: "await_confirmation", Kind: "confirm", RequiredFields: []string{"target_ref"}, Next: []string{"execute_stop"}},
{ID: "execute_stop", Kind: "execute", RequiredFields: []string{"target_ref"}, Terminal: true},
},
},
{
SkillName: "trader_management",
Action: "delete",
Steps: []SkillDAGStep{
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"await_confirmation"}},
{ID: "await_confirmation", Kind: "confirm", RequiredFields: []string{"target_ref"}, Next: []string{"execute_delete"}},
{ID: "execute_delete", Kind: "execute", RequiredFields: []string{"target_ref"}, Terminal: true},
},
},
{
SkillName: "strategy_management",
Action: "create",
Steps: []SkillDAGStep{
{ID: "resolve_name", Kind: "collect_slot", RequiredFields: []string{"name"}, OptionalFields: []string{"lang", "description", "config"}, Next: []string{"execute_create"}},
{ID: "execute_create", Kind: "execute", RequiredFields: []string{"name"}, OptionalFields: []string{"lang", "description", "config"}, Terminal: true},
},
},
{
SkillName: "strategy_management",
Action: "update_name",
Steps: []SkillDAGStep{
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"collect_name"}},
{ID: "collect_name", Kind: "collect_slot", RequiredFields: []string{"name"}, Next: []string{"execute_update"}},
{ID: "execute_update", Kind: "execute", RequiredFields: []string{"target_ref", "name"}, Terminal: true},
},
},
{
SkillName: "strategy_management",
Action: "update_prompt",
Steps: []SkillDAGStep{
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"collect_prompt"}},
{ID: "collect_prompt", Kind: "collect_slot", RequiredFields: []string{"prompt"}, Next: []string{"load_config"}},
{ID: "load_config", Kind: "load_state", RequiredFields: []string{"target_ref"}, Next: []string{"execute_update"}},
{ID: "execute_update", Kind: "execute", RequiredFields: []string{"target_ref", "prompt"}, Terminal: true},
},
},
{
SkillName: "strategy_management",
Action: "update_config",
Steps: []SkillDAGStep{
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"resolve_config_field"}},
{ID: "resolve_config_field", Kind: "collect_slot", RequiredFields: []string{"config_field"}, Next: []string{"resolve_config_value"}},
{ID: "resolve_config_value", Kind: "collect_slot", RequiredFields: []string{"config_value"}, Next: []string{"load_config"}},
{ID: "load_config", Kind: "load_state", RequiredFields: []string{"target_ref"}, Next: []string{"apply_field_update"}},
{ID: "apply_field_update", Kind: "transform", RequiredFields: []string{"config_field", "config_value"}, Next: []string{"execute_update"}},
{ID: "execute_update", Kind: "execute", RequiredFields: []string{"target_ref", "config_field", "config_value"}, Terminal: true},
},
},
{
SkillName: "strategy_management",
Action: "duplicate",
Steps: []SkillDAGStep{
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"collect_name"}},
{ID: "collect_name", Kind: "collect_slot", RequiredFields: []string{"name"}, Next: []string{"execute_duplicate"}},
{ID: "execute_duplicate", Kind: "execute", RequiredFields: []string{"target_ref", "name"}, Terminal: true},
},
},
{
SkillName: "strategy_management",
Action: "activate",
Steps: []SkillDAGStep{
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"execute_activate"}},
{ID: "execute_activate", Kind: "execute", RequiredFields: []string{"target_ref"}, Terminal: true},
},
},
{
SkillName: "strategy_management",
Action: "delete",
Steps: []SkillDAGStep{
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"await_confirmation"}},
{ID: "await_confirmation", Kind: "confirm", RequiredFields: []string{"target_ref"}, Next: []string{"execute_delete"}},
{ID: "execute_delete", Kind: "execute", RequiredFields: []string{"target_ref"}, Terminal: true},
},
},
{
SkillName: "model_management",
Action: "create",
Steps: []SkillDAGStep{
{ID: "resolve_provider", Kind: "collect_slot", RequiredFields: []string{"provider"}, Next: []string{"collect_optional_fields"}},
{ID: "collect_optional_fields", Kind: "collect_slot", OptionalFields: []string{"name", "custom_api_url", "custom_model_name"}, Next: []string{"execute_create"}},
{ID: "execute_create", Kind: "execute", RequiredFields: []string{"provider"}, OptionalFields: []string{"name", "custom_api_url", "custom_model_name"}, Terminal: true},
},
},
{
SkillName: "model_management",
Action: "update_status",
Steps: []SkillDAGStep{
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"collect_enabled"}},
{ID: "collect_enabled", Kind: "collect_slot", RequiredFields: []string{"enabled"}, Next: []string{"execute_update"}},
{ID: "execute_update", Kind: "execute", RequiredFields: []string{"target_ref", "enabled"}, Terminal: true},
},
},
{
SkillName: "model_management",
Action: "update_endpoint",
Steps: []SkillDAGStep{
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"collect_custom_api_url"}},
{ID: "collect_custom_api_url", Kind: "collect_slot", RequiredFields: []string{"custom_api_url"}, Next: []string{"execute_update"}},
{ID: "execute_update", Kind: "execute", RequiredFields: []string{"target_ref", "custom_api_url"}, Terminal: true},
},
},
{
SkillName: "model_management",
Action: "update_name",
Steps: []SkillDAGStep{
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"collect_custom_model_name"}},
{ID: "collect_custom_model_name", Kind: "collect_slot", RequiredFields: []string{"custom_model_name"}, Next: []string{"execute_update"}},
{ID: "execute_update", Kind: "execute", RequiredFields: []string{"target_ref", "custom_model_name"}, Terminal: true},
},
},
{
SkillName: "model_management",
Action: "delete",
Steps: []SkillDAGStep{
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"await_confirmation"}},
{ID: "await_confirmation", Kind: "confirm", RequiredFields: []string{"target_ref"}, Next: []string{"execute_delete"}},
{ID: "execute_delete", Kind: "execute", RequiredFields: []string{"target_ref"}, Terminal: true},
},
},
{
SkillName: "exchange_management",
Action: "create",
Steps: []SkillDAGStep{
{ID: "resolve_exchange_type", Kind: "collect_slot", RequiredFields: []string{"exchange_type"}, Next: []string{"collect_account_name"}},
{ID: "collect_account_name", Kind: "collect_slot", OptionalFields: []string{"account_name"}, Next: []string{"execute_create"}},
{ID: "execute_create", Kind: "execute", RequiredFields: []string{"exchange_type"}, OptionalFields: []string{"account_name"}, Terminal: true},
},
},
{
SkillName: "exchange_management",
Action: "update_name",
Steps: []SkillDAGStep{
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"collect_account_name"}},
{ID: "collect_account_name", Kind: "collect_slot", RequiredFields: []string{"account_name"}, Next: []string{"execute_update"}},
{ID: "execute_update", Kind: "execute", RequiredFields: []string{"target_ref", "account_name"}, Terminal: true},
},
},
{
SkillName: "exchange_management",
Action: "update_status",
Steps: []SkillDAGStep{
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"collect_enabled"}},
{ID: "collect_enabled", Kind: "collect_slot", RequiredFields: []string{"enabled"}, Next: []string{"execute_update"}},
{ID: "execute_update", Kind: "execute", RequiredFields: []string{"target_ref", "enabled"}, Terminal: true},
},
},
{
SkillName: "exchange_management",
Action: "delete",
Steps: []SkillDAGStep{
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"await_confirmation"}},
{ID: "await_confirmation", Kind: "confirm", RequiredFields: []string{"target_ref"}, Next: []string{"execute_delete"}},
{ID: "execute_delete", Kind: "execute", RequiredFields: []string{"target_ref"}, Terminal: true},
},
},
}
registry := make(map[string]SkillDAG, len(dags))
for _, dag := range dags {
dag = normalizeSkillDAG(dag)
if dag.SkillName == "" || dag.Action == "" {
continue
}
registry[skillDAGKey(dag.SkillName, dag.Action)] = dag
}
return registry
}
func normalizeSkillDAG(dag SkillDAG) SkillDAG {
dag.SkillName = strings.TrimSpace(dag.SkillName)
dag.Action = strings.TrimSpace(dag.Action)
steps := make([]SkillDAGStep, 0, len(dag.Steps))
for _, step := range dag.Steps {
step.ID = strings.TrimSpace(step.ID)
step.Kind = strings.TrimSpace(step.Kind)
step.RequiredFields = cleanStringList(step.RequiredFields)
step.OptionalFields = cleanStringList(step.OptionalFields)
step.Next = cleanStringList(step.Next)
if step.ID == "" {
continue
}
steps = append(steps, step)
}
dag.Steps = steps
return dag
}
func skillDAGKey(skillName, action string) string {
return strings.TrimSpace(skillName) + ":" + strings.TrimSpace(action)
}
func getSkillDAG(skillName, action string) (SkillDAG, bool) {
dag, ok := skillDAGRegistry[skillDAGKey(skillName, action)]
return dag, ok
}
func listSkillDAGs() []SkillDAG {
out := make([]SkillDAG, 0, len(skillDAGRegistry))
for _, dag := range skillDAGRegistry {
out = append(out, dag)
}
return out
}

View File

@@ -1,51 +0,0 @@
package agent
const skillDAGStepField = "_dag_step"
func currentSkillDAGStep(session skillSession) (SkillDAGStep, bool) {
dag, ok := getSkillDAG(session.Name, session.Action)
if !ok || len(dag.Steps) == 0 {
return SkillDAGStep{}, false
}
stepID := fieldValue(session, skillDAGStepField)
if stepID == "" {
return dag.Steps[0], true
}
for _, step := range dag.Steps {
if step.ID == stepID {
return step, true
}
}
return dag.Steps[0], true
}
func setSkillDAGStep(session *skillSession, stepID string) {
ensureSkillFields(session)
if stepID == "" {
delete(session.Fields, skillDAGStepField)
return
}
session.Fields[skillDAGStepField] = stepID
}
func clearSkillDAGStep(session *skillSession) {
if session == nil || session.Fields == nil {
return
}
delete(session.Fields, skillDAGStepField)
}
func advanceSkillDAGStep(session *skillSession, currentStepID string) {
dag, ok := getSkillDAG(session.Name, session.Action)
if !ok {
return
}
for _, step := range dag.Steps {
if step.ID != currentStepID || len(step.Next) == 0 {
continue
}
setSkillDAGStep(session, step.Next[0])
return
}
}

View File

@@ -1,27 +0,0 @@
package agent
import "testing"
func TestCurrentSkillDAGStepDefaultsToFirstStep(t *testing.T) {
session := skillSession{Name: "strategy_management", Action: "update_config"}
step, ok := currentSkillDAGStep(session)
if !ok {
t.Fatal("expected dag step")
}
if step.ID != "resolve_target" {
t.Fatalf("expected first step resolve_target, got %s", step.ID)
}
}
func TestAdvanceSkillDAGStepMovesToNextStep(t *testing.T) {
session := skillSession{Name: "strategy_management", Action: "update_config"}
setSkillDAGStep(&session, "resolve_config_field")
advanceSkillDAGStep(&session, "resolve_config_field")
step, ok := currentSkillDAGStep(session)
if !ok {
t.Fatal("expected dag step")
}
if step.ID != "resolve_config_value" {
t.Fatalf("expected resolve_config_value, got %s", step.ID)
}
}

View File

@@ -1,67 +0,0 @@
package agent
import "testing"
func TestGetSkillDAGForStructuredActions(t *testing.T) {
tests := []struct {
skill string
action string
}{
{skill: "trader_management", action: "create"},
{skill: "trader_management", action: "update_bindings"},
{skill: "strategy_management", action: "update_config"},
{skill: "strategy_management", action: "update_prompt"},
{skill: "model_management", action: "update_status"},
{skill: "exchange_management", action: "update_name"},
}
for _, tt := range tests {
dag, ok := getSkillDAG(tt.skill, tt.action)
if !ok {
t.Fatalf("expected DAG for %s/%s", tt.skill, tt.action)
}
if dag.SkillName != tt.skill || dag.Action != tt.action {
t.Fatalf("unexpected dag identity: %+v", dag)
}
if len(dag.Steps) == 0 {
t.Fatalf("expected DAG steps for %s/%s", tt.skill, tt.action)
}
}
}
func TestStructuredDAGsHaveTerminalStep(t *testing.T) {
for _, dag := range listSkillDAGs() {
hasTerminal := false
for _, step := range dag.Steps {
if step.Terminal {
hasTerminal = true
break
}
}
if !hasTerminal {
t.Fatalf("expected terminal step for %s/%s", dag.SkillName, dag.Action)
}
}
}
func TestStrategyUpdateConfigDAGMatchesCurrentAtomicFlow(t *testing.T) {
dag, ok := getSkillDAG("strategy_management", "update_config")
if !ok {
t.Fatal("missing strategy update_config dag")
}
if len(dag.Steps) != 6 {
t.Fatalf("expected 6 steps, got %d", len(dag.Steps))
}
if dag.Steps[0].ID != "resolve_target" {
t.Fatalf("expected first step resolve_target, got %s", dag.Steps[0].ID)
}
if dag.Steps[1].ID != "resolve_config_field" {
t.Fatalf("expected second step resolve_config_field, got %s", dag.Steps[1].ID)
}
if dag.Steps[2].ID != "resolve_config_value" {
t.Fatalf("expected third step resolve_config_value, got %s", dag.Steps[2].ID)
}
if dag.Steps[5].ID != "execute_update" || !dag.Steps[5].Terminal {
t.Fatalf("expected final terminal execute step, got %+v", dag.Steps[5])
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,828 +0,0 @@
package agent
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"time"
"nofx/mcp"
)
func TestCreateTraderSkillCollectsMissingFieldsAndCreatesTrader(t *testing.T) {
a := newTestAgentWithStore(t)
modelResp := a.toolManageModelConfig("user-1", `{
"action":"create",
"provider":"deepseek",
"enabled":true,
"api_key":"sk-test",
"custom_api_url":"https://api.deepseek.com/v1",
"custom_model_name":"deepseek-chat"
}`)
if strings.Contains(modelResp, `"error"`) {
t.Fatalf("failed to create model: %s", modelResp)
}
exchangeResp := a.toolManageExchangeConfig("user-1", `{
"action":"create",
"exchange_type":"okx",
"account_name":"主账户",
"enabled":true
}`)
if strings.Contains(exchangeResp, `"error"`) {
t.Fatalf("failed to create exchange: %s", exchangeResp)
}
strategyResp := a.toolManageStrategy("user-1", `{
"action":"create",
"name":"趋势策略",
"lang":"zh"
}`)
if strings.Contains(strategyResp, `"error"`) {
t.Fatalf("failed to create strategy: %s", strategyResp)
}
resp, err := a.thinkAndAct(context.Background(), "user-1", 1, "zh", "帮我创建一个交易员")
if err != nil {
t.Fatalf("thinkAndAct() error = %v", err)
}
if !strings.Contains(resp, "还缺这些信息") || !strings.Contains(resp, "名称") {
t.Fatalf("expected missing-field prompt, got %q", resp)
}
resp, err = a.thinkAndAct(context.Background(), "user-1", 1, "zh", "叫 波段一号")
if err != nil {
t.Fatalf("thinkAndAct() second turn error = %v", err)
}
if !strings.Contains(resp, "已创建交易员") || !strings.Contains(resp, "波段一号") {
t.Fatalf("expected trader creation confirmation, got %q", resp)
}
listResp := a.toolListTraders("user-1")
if !strings.Contains(listResp, "波段一号") {
t.Fatalf("expected created trader in list, got %s", listResp)
}
}
func TestCreateTraderSkillReportsAllMissingPrerequisitesAtOnce(t *testing.T) {
a := newTestAgentWithStore(t)
resp, err := a.thinkAndAct(context.Background(), "user-1", 11, "zh", "帮我创建一个交易员")
if err != nil {
t.Fatalf("thinkAndAct() error = %v", err)
}
for _, want := range []string{"名称", "交易所", "模型", "策略"} {
if !strings.Contains(resp, want) {
t.Fatalf("expected response to mention %q, got %q", want, resp)
}
}
for _, want := range []string{"当前还没有可用交易所配置", "当前还没有可用模型配置", "当前还没有可用策略"} {
if !strings.Contains(resp, want) {
t.Fatalf("expected response to mention prerequisite %q, got %q", want, resp)
}
}
}
func TestActiveSkillSessionYieldsToNewTopic(t *testing.T) {
a := newTestAgentWithStore(t)
_ = a.toolManageStrategy("user-1", `{
"action":"create",
"name":"测试策略",
"lang":"zh"
}`)
resp, err := a.thinkAndAct(context.Background(), "user-1", 13, "zh", "帮我创建一个交易员")
if err != nil {
t.Fatalf("thinkAndAct() error = %v", err)
}
if !strings.Contains(resp, "还缺这些信息") {
t.Fatalf("expected trader creation flow prompt, got %q", resp)
}
resp, err = a.thinkAndAct(context.Background(), "user-1", 13, "zh", "列出我当前的策略")
if err != nil {
t.Fatalf("thinkAndAct() interrupt error = %v", err)
}
if !strings.Contains(resp, "当前策略") || !strings.Contains(resp, "测试策略") {
t.Fatalf("expected new topic to be handled, got %q", resp)
}
if a.hasActiveSkillSession(13) {
t.Fatal("expected skill session to be cleared after interruption")
}
}
func TestCreateTraderSkillRequestsStartConfirmation(t *testing.T) {
a := newTestAgentWithStore(t)
_ = a.toolManageModelConfig("user-1", `{
"action":"create",
"provider":"openai",
"enabled":true,
"api_key":"sk-test",
"custom_api_url":"https://api.openai.com/v1",
"custom_model_name":"gpt-5"
}`)
_ = a.toolManageExchangeConfig("user-1", `{
"action":"create",
"exchange_type":"binance",
"account_name":"Main",
"enabled":true
}`)
_ = a.toolManageStrategy("user-1", `{
"action":"create",
"name":"保守策略",
"lang":"zh"
}`)
resp, err := a.thinkAndAct(context.Background(), "user-1", 2, "zh", "创建一个叫“实盘一号”的交易员并启动")
if err != nil {
t.Fatalf("thinkAndAct() error = %v", err)
}
if !strings.Contains(resp, "高风险动作") || !strings.Contains(resp, "确认") {
t.Fatalf("expected start confirmation prompt, got %q", resp)
}
resp, err = a.thinkAndAct(context.Background(), "user-1", 2, "zh", "先不用")
if err != nil {
t.Fatalf("thinkAndAct() confirmation error = %v", err)
}
if !strings.Contains(resp, "已创建交易员") || strings.Contains(resp, "已创建并启动") {
t.Fatalf("expected create-without-start response, got %q", resp)
}
}
func TestModelDiagnosisSkillHandledWithoutAIClient(t *testing.T) {
a := newTestAgentWithStore(t)
resp, err := a.thinkAndAct(context.Background(), "user-1", 3, "zh", "为什么我的模型配置失败了")
if err != nil {
t.Fatalf("thinkAndAct() error = %v", err)
}
if !strings.Contains(resp, "模型配置") {
t.Fatalf("expected model diagnosis response, got %q", resp)
}
}
func TestExchangeDiagnosisSkillHandledWithoutAIClient(t *testing.T) {
a := newTestAgentWithStore(t)
resp, err := a.thinkAndAct(context.Background(), "user-1", 4, "zh", "交易所 API 报 invalid signature 怎么办")
if err != nil {
t.Fatalf("thinkAndAct() error = %v", err)
}
if !strings.Contains(resp, "invalid signature") && !strings.Contains(resp, "签名") {
t.Fatalf("expected exchange diagnosis response, got %q", resp)
}
}
func TestExchangeManagementCreateAndQuerySkill(t *testing.T) {
a := newTestAgentWithStore(t)
resp, err := a.thinkAndAct(context.Background(), "user-1", 5, "zh", "帮我创建一个 OKX 交易所配置")
if err != nil {
t.Fatalf("thinkAndAct() error = %v", err)
}
if !strings.Contains(resp, "已创建交易所配置") {
t.Fatalf("expected exchange create response, got %q", resp)
}
resp, err = a.thinkAndAct(context.Background(), "user-1", 5, "zh", "列出我的交易所配置")
if err != nil {
t.Fatalf("thinkAndAct() query error = %v", err)
}
if !strings.Contains(resp, "当前交易所配置") && !strings.Contains(resp, "Default") {
t.Fatalf("expected exchange query response, got %q", resp)
}
}
func TestModelManagementCreateSkill(t *testing.T) {
a := newTestAgentWithStore(t)
resp, err := a.thinkAndAct(context.Background(), "user-1", 6, "zh", "帮我创建一个 DeepSeek 模型配置")
if err != nil {
t.Fatalf("thinkAndAct() error = %v", err)
}
if !strings.Contains(resp, "已创建模型配置") {
t.Fatalf("expected model create response, got %q", resp)
}
}
func TestStrategyManagementCreateAndActivateSkill(t *testing.T) {
a := newTestAgentWithStore(t)
resp, err := a.thinkAndAct(context.Background(), "user-1", 7, "zh", "创建一个叫“趋势策略B”的策略")
if err != nil {
t.Fatalf("thinkAndAct() create error = %v", err)
}
if !strings.Contains(resp, "已创建策略") {
t.Fatalf("expected strategy create response, got %q", resp)
}
resp, err = a.thinkAndAct(context.Background(), "user-1", 7, "zh", "激活趋势策略B")
if err != nil {
t.Fatalf("thinkAndAct() activate error = %v", err)
}
if !strings.Contains(resp, "已激活策略") {
t.Fatalf("expected strategy activate response, got %q", resp)
}
}
func TestStrategyManagementQueryCanExplainStrategyDetails(t *testing.T) {
a := newTestAgentWithStore(t)
resp, err := a.thinkAndAct(context.Background(), "user-1", 12, "zh", "创建一个叫“激进的”的策略")
if err != nil {
t.Fatalf("thinkAndAct() create error = %v", err)
}
if !strings.Contains(resp, "已创建策略") {
t.Fatalf("expected strategy create response, got %q", resp)
}
resp, err = a.thinkAndAct(context.Background(), "user-1", 12, "zh", "这个策略里面的参数和prompt分别是什么样的")
if err != nil {
t.Fatalf("thinkAndAct() detail query error = %v", err)
}
for _, want := range []string{"策略“激进的”概览", "K线周期", "仓位风险", "Prompt"} {
if !strings.Contains(resp, want) {
t.Fatalf("expected response to mention %q, got %q", want, resp)
}
}
}
func TestTraderManagementQueryAndDiagnosisSkill(t *testing.T) {
a := newTestAgentWithStore(t)
modelResp := a.toolManageModelConfig("user-1", `{
"action":"create",
"provider":"openai",
"enabled":true,
"api_key":"sk-test",
"custom_api_url":"https://api.openai.com/v1",
"custom_model_name":"gpt-5"
}`)
var modelCreated struct {
Model safeModelToolConfig `json:"model"`
}
if err := json.Unmarshal([]byte(modelResp), &modelCreated); err != nil {
t.Fatalf("unmarshal model response: %v", err)
}
exchangeResp := a.toolManageExchangeConfig("user-1", `{
"action":"create",
"exchange_type":"binance",
"account_name":"Main",
"enabled":true
}`)
var exchangeCreated struct {
Exchange safeExchangeToolConfig `json:"exchange"`
}
if err := json.Unmarshal([]byte(exchangeResp), &exchangeCreated); err != nil {
t.Fatalf("unmarshal exchange response: %v", err)
}
_ = a.toolManageStrategy("user-1", `{
"action":"create",
"name":"测试策略",
"lang":"zh"
}`)
_ = a.toolManageTrader("user-1", `{
"action":"create",
"name":"测试交易员",
"ai_model_id":"`+modelCreated.Model.ID+`",
"exchange_id":"`+exchangeCreated.Exchange.ID+`",
"strategy_id":""
}`)
resp, err := a.thinkAndAct(context.Background(), "user-1", 8, "zh", "查看我的交易员")
if err != nil {
t.Fatalf("thinkAndAct() query error = %v", err)
}
if !strings.Contains(resp, "当前交易员") && !strings.Contains(resp, "测试交易员") {
t.Fatalf("expected trader query response, got %q", resp)
}
resp, err = a.thinkAndAct(context.Background(), "user-1", 8, "zh", "为什么我的交易员不交易")
if err != nil {
t.Fatalf("thinkAndAct() diagnosis error = %v", err)
}
if !strings.Contains(resp, "交易员运行诊断") {
t.Fatalf("expected trader diagnosis response, got %q", resp)
}
}
func TestExchangeManagementAtomicUpdates(t *testing.T) {
a := newTestAgentWithStore(t)
createResp := a.toolManageExchangeConfig("user-1", `{
"action":"create",
"exchange_type":"okx",
"account_name":"主账户",
"enabled":true
}`)
var created struct {
Exchange safeExchangeToolConfig `json:"exchange"`
}
if err := json.Unmarshal([]byte(createResp), &created); err != nil {
t.Fatalf("unmarshal exchange response: %v", err)
}
resp, err := a.thinkAndAct(context.Background(), "user-1", 14, "zh", "更新交易所,把主账户改名为备用账户")
if err != nil {
t.Fatalf("rename exchange error = %v", err)
}
if !strings.Contains(resp, "已更新交易所配置") {
t.Fatalf("expected exchange update response, got %q", resp)
}
raw := a.toolGetExchangeConfigs("user-1")
if !strings.Contains(raw, "备用账户") {
t.Fatalf("expected renamed exchange in list, got %s", raw)
}
resp, err = a.thinkAndAct(context.Background(), "user-1", 14, "zh", "禁用这个交易所配置")
if err != nil {
t.Fatalf("disable exchange error = %v", err)
}
if !strings.Contains(resp, "已更新交易所配置") {
t.Fatalf("expected exchange status update response, got %q", resp)
}
raw = a.toolGetExchangeConfigs("user-1")
if strings.Contains(raw, `"enabled":true`) && strings.Contains(raw, "备用账户") {
t.Fatalf("expected exchange to be disabled, got %s", raw)
}
}
func TestModelManagementAtomicUpdates(t *testing.T) {
a := newTestAgentWithStore(t)
createResp := a.toolManageModelConfig("user-1", `{
"action":"create",
"provider":"deepseek",
"enabled":true,
"custom_api_url":"https://api.deepseek.com/v1",
"custom_model_name":"deepseek-chat"
}`)
var created struct {
Model safeModelToolConfig `json:"model"`
}
if err := json.Unmarshal([]byte(createResp), &created); err != nil {
t.Fatalf("unmarshal model response: %v", err)
}
resp, err := a.thinkAndAct(context.Background(), "user-1", 15, "zh", "更新模型,把模型名称改成 deepseek-reasoner")
if err != nil {
t.Fatalf("rename model error = %v", err)
}
if !strings.Contains(resp, "已更新模型配置") {
t.Fatalf("expected model update response, got %q", resp)
}
resp, err = a.thinkAndAct(context.Background(), "user-1", 15, "zh", "更新模型,把接口地址改成 https://api.deepseek.com/beta")
if err != nil {
t.Fatalf("update model endpoint error = %v", err)
}
if !strings.Contains(resp, "已更新模型配置") {
t.Fatalf("expected model endpoint update response, got %q", resp)
}
resp, err = a.thinkAndAct(context.Background(), "user-1", 15, "zh", "禁用这个模型配置")
if err != nil {
t.Fatalf("disable model error = %v", err)
}
if !strings.Contains(resp, "已更新模型配置") {
t.Fatalf("expected model status update response, got %q", resp)
}
raw := a.toolGetModelConfigs("user-1")
if !strings.Contains(raw, "deepseek-reasoner") || !strings.Contains(raw, "https://api.deepseek.com/beta") {
t.Fatalf("expected updated model fields, got %s", raw)
}
if strings.Contains(raw, `"enabled":true`) && strings.Contains(raw, created.Model.ID) {
t.Fatalf("expected model to be disabled, got %s", raw)
}
}
func TestStrategyManagementAtomicUpdates(t *testing.T) {
a := newTestAgentWithStore(t)
resp, err := a.thinkAndAct(context.Background(), "user-1", 16, "zh", "创建一个叫“激进策略C”的策略")
if err != nil {
t.Fatalf("create strategy error = %v", err)
}
if !strings.Contains(resp, "已创建策略") {
t.Fatalf("expected strategy create response, got %q", resp)
}
resp, err = a.thinkAndAct(context.Background(), "user-1", 16, "zh", "更新这个策略的prompt把提示词改成“优先观察BTC和ETH信号不一致时不要开仓”")
if err != nil {
t.Fatalf("update strategy prompt error = %v", err)
}
if !strings.Contains(resp, "已更新策略 prompt") {
t.Fatalf("expected strategy prompt update response, got %q", resp)
}
resp, err = a.thinkAndAct(context.Background(), "user-1", 16, "zh", "更新这个策略参数把最大持仓改成2最低置信度改成80主周期改成15m并使用15m 1h 4h")
if err != nil {
t.Fatalf("update strategy config error = %v", err)
}
if !strings.Contains(resp, "已更新策略参数") {
t.Fatalf("expected strategy config update response, got %q", resp)
}
listRaw := a.toolGetStrategies("user-1")
if !strings.Contains(listRaw, "优先观察BTC和ETH") || !strings.Contains(listRaw, `"max_positions":2`) || !strings.Contains(listRaw, `"min_confidence":80`) || !strings.Contains(listRaw, `"primary_timeframe":"15m"`) {
t.Fatalf("expected updated strategy config, got %s", listRaw)
}
}
func TestTraderManagementAtomicBindingUpdate(t *testing.T) {
a := newTestAgentWithStore(t)
modelOpenAI := a.toolManageModelConfig("user-1", `{
"action":"create",
"provider":"openai",
"enabled":true,
"custom_api_url":"https://api.openai.com/v1",
"custom_model_name":"gpt-5-mini"
}`)
var openAI struct {
Model safeModelToolConfig `json:"model"`
}
if err := json.Unmarshal([]byte(modelOpenAI), &openAI); err != nil {
t.Fatalf("unmarshal openai model: %v", err)
}
modelDeepSeek := a.toolManageModelConfig("user-1", `{
"action":"create",
"provider":"deepseek",
"enabled":true,
"custom_api_url":"https://api.deepseek.com/v1",
"custom_model_name":"deepseek-chat"
}`)
var deepSeek struct {
Model safeModelToolConfig `json:"model"`
}
if err := json.Unmarshal([]byte(modelDeepSeek), &deepSeek); err != nil {
t.Fatalf("unmarshal deepseek model: %v", err)
}
exchangeBinance := a.toolManageExchangeConfig("user-1", `{
"action":"create",
"exchange_type":"binance",
"account_name":"Binance 主账户",
"enabled":true
}`)
var binance struct {
Exchange safeExchangeToolConfig `json:"exchange"`
}
if err := json.Unmarshal([]byte(exchangeBinance), &binance); err != nil {
t.Fatalf("unmarshal binance exchange: %v", err)
}
exchangeOKX := a.toolManageExchangeConfig("user-1", `{
"action":"create",
"exchange_type":"okx",
"account_name":"OKX 主账户",
"enabled":true
}`)
var okx struct {
Exchange safeExchangeToolConfig `json:"exchange"`
}
if err := json.Unmarshal([]byte(exchangeOKX), &okx); err != nil {
t.Fatalf("unmarshal okx exchange: %v", err)
}
strategyA := a.toolManageStrategy("user-1", `{"action":"create","name":"策略A","lang":"zh"}`)
var stA struct {
Strategy safeStrategyToolConfig `json:"strategy"`
}
if err := json.Unmarshal([]byte(strategyA), &stA); err != nil {
t.Fatalf("unmarshal strategy A: %v", err)
}
strategyB := a.toolManageStrategy("user-1", `{"action":"create","name":"策略B","lang":"zh"}`)
var stB struct {
Strategy safeStrategyToolConfig `json:"strategy"`
}
if err := json.Unmarshal([]byte(strategyB), &stB); err != nil {
t.Fatalf("unmarshal strategy B: %v", err)
}
createTrader := a.toolManageTrader("user-1", `{
"action":"create",
"name":"实盘一号",
"ai_model_id":"`+openAI.Model.ID+`",
"exchange_id":"`+binance.Exchange.ID+`",
"strategy_id":"`+stA.Strategy.ID+`"
}`)
var trader struct {
Trader safeTraderToolConfig `json:"trader"`
}
if err := json.Unmarshal([]byte(createTrader), &trader); err != nil {
t.Fatalf("unmarshal trader: %v", err)
}
resp, err := a.thinkAndAct(context.Background(), "user-1", 17, "zh", "更新交易员绑定,把实盘一号换成 deepseek-chat、OKX 主账户 和 策略B")
if err != nil {
t.Fatalf("update trader bindings error = %v", err)
}
if !strings.Contains(resp, "已更新交易员绑定") {
t.Fatalf("expected trader binding update response, got %q", resp)
}
listRaw := a.toolListTraders("user-1")
if !strings.Contains(listRaw, deepSeek.Model.ID) || !strings.Contains(listRaw, okx.Exchange.ID) || !strings.Contains(listRaw, stB.Strategy.ID) {
t.Fatalf("expected trader bindings to change, got %s", listRaw)
}
}
func TestStrategyManagementDeleteAllUserStrategies(t *testing.T) {
a := newTestAgentWithStore(t)
for _, name := range []string{"趋势策略A", "趋势策略B"} {
resp := a.toolManageStrategy("user-1", `{
"action":"create",
"name":"`+name+`",
"lang":"zh"
}`)
if strings.Contains(resp, `"error"`) {
t.Fatalf("failed to create strategy %q: %s", name, resp)
}
}
resp, err := a.thinkAndAct(context.Background(), "user-1", 21, "zh", "现在把所有的策略全部删除")
if err != nil {
t.Fatalf("thinkAndAct() bulk delete start error = %v", err)
}
if !strings.Contains(resp, "确认") || !strings.Contains(resp, "全部自定义策略") {
t.Fatalf("expected bulk delete confirmation, got %q", resp)
}
resp, err = a.thinkAndAct(context.Background(), "user-1", 21, "zh", "确认")
if err != nil {
t.Fatalf("thinkAndAct() bulk delete confirm error = %v", err)
}
if !strings.Contains(resp, "成功删除 2 个") {
t.Fatalf("expected bulk delete success summary, got %q", resp)
}
listResp := a.toolGetStrategies("user-1")
if strings.Contains(listResp, "趋势策略A") || strings.Contains(listResp, "趋势策略B") {
t.Fatalf("expected created strategies to be deleted, got %s", listResp)
}
}
func TestCreateTraderSkillRejectsDisabledExchangeWithClearPrompt(t *testing.T) {
a := newTestAgentWithStore(t)
_ = a.toolManageModelConfig("user-1", `{
"action":"create",
"provider":"deepseek",
"enabled":true,
"api_key":"sk-test",
"custom_api_url":"https://api.deepseek.com/v1",
"custom_model_name":"deepseek-chat"
}`)
enabledExchange := a.toolManageExchangeConfig("user-1", `{
"action":"create",
"exchange_type":"okx",
"account_name":"test",
"enabled":true
}`)
if strings.Contains(enabledExchange, `"error"`) {
t.Fatalf("failed to create enabled exchange: %s", enabledExchange)
}
anotherEnabledExchange := a.toolManageExchangeConfig("user-1", `{
"action":"create",
"exchange_type":"okx",
"account_name":"lky",
"enabled":true
}`)
if strings.Contains(anotherEnabledExchange, `"error"`) {
t.Fatalf("failed to create second enabled exchange: %s", anotherEnabledExchange)
}
disabledExchange := a.toolManageExchangeConfig("user-1", `{
"action":"create",
"exchange_type":"okx",
"account_name":"new",
"enabled":false
}`)
if strings.Contains(disabledExchange, `"error"`) {
t.Fatalf("failed to create disabled exchange: %s", disabledExchange)
}
_ = a.toolManageStrategy("user-1", `{"action":"create","name":"激进","lang":"zh"}`)
resp, err := a.thinkAndAct(context.Background(), "user-1", 24, "zh", "给我创建一个trader")
if err != nil {
t.Fatalf("create trader start error = %v", err)
}
if !strings.Contains(resp, "new已禁用") {
t.Fatalf("expected disabled exchange to be labelled, got %q", resp)
}
resp, err = a.thinkAndAct(context.Background(), "user-1", 24, "zh", "名称叫test交易所用new、策略用激进")
if err != nil {
t.Fatalf("disabled exchange selection error = %v", err)
}
if !strings.Contains(resp, "当前已禁用") {
t.Fatalf("expected disabled exchange warning, got %q", resp)
}
}
func TestCancelReplyExitsExchangeUpdateFlow(t *testing.T) {
a := newTestAgentWithStore(t)
_ = a.toolManageModelConfig("user-1", `{
"action":"create",
"provider":"deepseek",
"enabled":true,
"api_key":"sk-test",
"custom_api_url":"https://api.deepseek.com/v1",
"custom_model_name":"deepseek-chat"
}`)
exchangeResp := a.toolManageExchangeConfig("user-1", `{
"action":"create",
"exchange_type":"okx",
"account_name":"test",
"enabled":true
}`)
if strings.Contains(exchangeResp, `"error"`) {
t.Fatalf("failed to create exchange: %s", exchangeResp)
}
resp, err := a.thinkAndAct(context.Background(), "user-1", 25, "zh", "把test这个交易所改一下")
if err != nil {
t.Fatalf("enter exchange update flow error = %v", err)
}
if !strings.Contains(resp, "请告诉我你要改什么") {
t.Fatalf("expected exchange update prompt, got %q", resp)
}
resp, err = a.thinkAndAct(context.Background(), "user-1", 25, "zh", "不改")
if err != nil {
t.Fatalf("cancel exchange flow error = %v", err)
}
if !strings.Contains(resp, "已取消当前流程") {
t.Fatalf("expected flow cancellation, got %q", resp)
}
}
func TestClassifySkillSessionInputInterruptsOnDeflection(t *testing.T) {
session := skillSession{Name: "exchange_management", Action: "update"}
a := &Agent{}
if got := a.classifySkillSessionInput(context.Background(), 0, "zh", session, "你能帮我看下报错吗"); got != "interrupt" {
t.Fatalf("expected diagnosis deflection to interrupt current skill flow, got %q", got)
}
if got := a.classifySkillSessionInput(context.Background(), 0, "zh", session, "换话题了大哥"); got != "cancel" {
t.Fatalf("expected topic shift to cancel current skill flow, got %q", got)
}
}
type skillSessionClassifierAIClient struct {
lastSystemPrompt string
lastUserPrompt string
response string
}
func (c *skillSessionClassifierAIClient) SetAPIKey(string, string, string) {}
func (c *skillSessionClassifierAIClient) SetTimeout(time.Duration) {}
func (c *skillSessionClassifierAIClient) CallWithMessages(string, string) (string, error) {
return "", errors.New("unexpected CallWithMessages")
}
func (c *skillSessionClassifierAIClient) CallWithRequest(req *mcp.Request) (string, error) {
if len(req.Messages) > 0 {
c.lastSystemPrompt = req.Messages[0].Content
}
if len(req.Messages) > 1 {
c.lastUserPrompt = req.Messages[1].Content
}
return c.response, nil
}
func (c *skillSessionClassifierAIClient) CallWithRequestStream(*mcp.Request, func(string)) (string, error) {
return "", errors.New("unexpected CallWithRequestStream")
}
func (c *skillSessionClassifierAIClient) CallWithRequestFull(*mcp.Request) (*mcp.LLMResponse, error) {
return nil, errors.New("unexpected CallWithRequestFull")
}
func TestClassifySkillSessionInputUsesSlotExpectationWithoutLLM(t *testing.T) {
client := &skillSessionClassifierAIClient{response: `{"decision":"interrupt"}`}
a := &Agent{aiClient: client}
session := skillSession{
Name: "strategy_management",
Action: "update_config",
Fields: map[string]string{
skillDAGStepField: "resolve_config_value",
"config_field": "min_confidence",
},
}
if got := a.classifySkillSessionInput(context.Background(), 0, "zh", session, "70"); got != "continue" {
t.Fatalf("expected numeric slot fill to continue, got %q", got)
}
if client.lastSystemPrompt != "" {
t.Fatalf("expected no LLM call for direct slot expectation, got prompt %q", client.lastSystemPrompt)
}
}
func TestClassifySkillSessionInputUsesLLMOnlyForAmbiguousDeflection(t *testing.T) {
client := &skillSessionClassifierAIClient{response: `{"decision":"interrupt"}`}
a := &Agent{
aiClient: client,
history: newChatHistory(10),
}
session := skillSession{
Name: "exchange_management",
Action: "update",
Fields: map[string]string{
skillDAGStepField: "collect_account_name",
},
}
if got := a.classifySkillSessionInput(context.Background(), 0, "zh", session, "你能帮我看下报错吗"); got != "interrupt" {
t.Fatalf("expected ambiguous deflection to interrupt, got %q", got)
}
if !strings.Contains(client.lastSystemPrompt, "classify one user message while a NOFXi structured management flow is active") {
t.Fatalf("expected LLM classifier prompt, got %q", client.lastSystemPrompt)
}
}
func TestClassifySkillSessionInputUsesLLMForUnmatchedActiveSessionInput(t *testing.T) {
client := &skillSessionClassifierAIClient{response: `{"decision":"continue"}`}
a := &Agent{
aiClient: client,
history: newChatHistory(10),
}
session := skillSession{
Name: "model_management",
Action: "create",
Fields: map[string]string{
skillDAGStepField: "collect_optional_fields",
"provider": "openai",
},
}
if got := a.classifySkillSessionInput(context.Background(), 0, "zh", session, "新增一个"); got != "continue" {
t.Fatalf("expected unmatched active-session input to follow LLM decision, got %q", got)
}
if !strings.Contains(client.lastSystemPrompt, "classify one user message while a NOFXi structured management flow is active") {
t.Fatalf("expected LLM classifier prompt, got %q", client.lastSystemPrompt)
}
}
func TestStrategyManagementCanDescribeDefaultConfig(t *testing.T) {
a := newTestAgentWithStore(t)
_ = a.toolManageModelConfig("user-1", `{
"action":"create",
"provider":"deepseek",
"enabled":true,
"api_key":"sk-test",
"custom_api_url":"https://api.deepseek.com/v1",
"custom_model_name":"deepseek-chat"
}`)
resp, err := a.thinkAndAct(context.Background(), "user-1", 22, "zh", "看一下默认配置")
if err != nil {
t.Fatalf("thinkAndAct() default config error = %v", err)
}
if !strings.Contains(resp, "默认策略模板") || !strings.Contains(resp, "最低置信度") {
t.Fatalf("expected default strategy config response, got %q", resp)
}
}
func TestStrategyManagementSupportsMultiFieldConfigUpdate(t *testing.T) {
a := newTestAgentWithStore(t)
_ = a.toolManageModelConfig("user-1", `{
"action":"create",
"provider":"deepseek",
"enabled":true,
"api_key":"sk-test",
"custom_api_url":"https://api.deepseek.com/v1",
"custom_model_name":"deepseek-chat"
}`)
createResp := a.toolManageStrategy("user-1", `{
"action":"create",
"name":"趋势策略A",
"lang":"zh"
}`)
if strings.Contains(createResp, `"error"`) {
t.Fatalf("failed to create strategy: %s", createResp)
}
resp, err := a.thinkAndAct(context.Background(), "user-1", 23, "zh", "把趋势策略A的最小置信度改成70核心指标都全选")
if err != nil {
t.Fatalf("thinkAndAct() multi-field update error = %v", err)
}
if !strings.Contains(resp, "最小置信度") || !strings.Contains(resp, "EMA") {
t.Fatalf("expected multi-field update confirmation, got %q", resp)
}
strategiesRaw := a.toolGetStrategies("user-1")
if !strings.Contains(strategiesRaw, `"min_confidence":70`) ||
!strings.Contains(strategiesRaw, `"enable_ema":true`) ||
!strings.Contains(strategiesRaw, `"enable_macd":true`) ||
!strings.Contains(strategiesRaw, `"enable_rsi":true`) ||
!strings.Contains(strategiesRaw, `"enable_atr":true`) ||
!strings.Contains(strategiesRaw, `"enable_boll":true`) {
t.Fatalf("expected strategy config to include updated confidence and indicators, got %s", strategiesRaw)
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,931 +0,0 @@
package agent
import (
"encoding/json"
"fmt"
"regexp"
"sort"
"strings"
"nofx/store"
)
var urlPattern = regexp.MustCompile(`https://[^\s"'<>]+`)
func detectTraderManagementIntent(text string) bool {
lower := strings.ToLower(strings.TrimSpace(text))
if lower == "" {
return false
}
return containsAny(lower, []string{"交易员", "trader", "agent"}) &&
containsAny(lower, []string{"修改", "编辑", "更新", "改", "改一下", "删除", "删了", "启动", "停止", "查看", "查询", "列出", "rename", "update", "delete", "start", "stop", "list", "show"})
}
func detectExchangeManagementIntent(text string) bool {
lower := strings.ToLower(strings.TrimSpace(text))
if lower == "" {
return false
}
return containsAny(lower, []string{"交易所", "exchange", "okx", "binance", "bybit", "gate", "kucoin", "hyperliquid"}) &&
containsAny(lower, []string{"创建", "新建", "修改", "编辑", "更新", "改", "改一下", "删除", "删了", "查询", "查看", "列出", "启用", "禁用", "改名", "rename", "create", "update", "delete", "list", "show", "enable", "disable"})
}
func detectModelManagementIntent(text string) bool {
lower := strings.ToLower(strings.TrimSpace(text))
if lower == "" {
return false
}
return containsAny(lower, []string{"模型", "model", "provider", "deepseek", "openai", "claude", "gemini", "qwen", "kimi", "grok", "minimax"}) &&
containsAny(lower, []string{"创建", "新建", "修改", "编辑", "更新", "改", "改一下", "删除", "删了", "查询", "查看", "列出", "启用", "禁用", "改名", "rename", "create", "update", "delete", "list", "show", "enable", "disable"})
}
func detectStrategyManagementIntent(text string) bool {
lower := strings.ToLower(strings.TrimSpace(text))
if lower == "" {
return false
}
if wantsDefaultStrategyConfig(text) {
return true
}
return containsAny(lower, []string{"策略", "strategy"}) &&
containsAny(lower, []string{"创建", "新建", "修改", "编辑", "更新", "改", "改一下", "改成", "改为", "删除", "删了", "查询", "查看", "列出", "激活", "复制", "参数", "配置", "详情", "详细", "prompt", "提示词", "什么样", "怎么样", "create", "update", "delete", "list", "show", "activate", "duplicate", "detail", "details", "config", "configuration", "parameter", "prompt", "what kind"})
}
func detectTraderDiagnosisSkill(text string) bool {
lower := strings.ToLower(strings.TrimSpace(text))
return containsAny(lower, []string{"交易员", "trader"}) &&
containsAny(lower, []string{"启动失败", "不交易", "没开仓", "无法启动", "异常", "失败", "diagnose", "error", "not trading"})
}
func detectStrategyDiagnosisSkill(text string) bool {
lower := strings.ToLower(strings.TrimSpace(text))
return containsAny(lower, []string{"策略", "strategy", "prompt"}) &&
containsAny(lower, []string{"不生效", "没生效", "异常", "失败", "不一致", "失效", "diagnose", "error"})
}
func detectManagementAction(text string, domain string) string {
lower := strings.ToLower(strings.TrimSpace(text))
if lower == "" {
return ""
}
hasUpdateVerb := containsAny(lower, []string{"修改", "编辑", "更新", "改", "rename", "update", "切换", "换成", "换到"})
switch {
case containsAny(lower, []string{"删除", "删掉", "删了", "remove", "delete"}):
return "delete"
case containsAny(lower, []string{"启动", "开始", "run", "start"}) && domain == "trader":
return "start"
case containsAny(lower, []string{"停止", "停掉", "stop", "pause"}) && domain == "trader":
return "stop"
case containsAny(lower, []string{"激活", "activate"}) && domain == "strategy":
return "activate"
case containsAny(lower, []string{"复制", "duplicate"}) && domain == "strategy":
return "duplicate"
case containsAny(lower, []string{"改名", "重命名", "rename"}):
return "update_name"
case domain == "trader" && containsAny(lower, []string{"换模型", "换交易所", "换策略", "绑定", "切换模型", "切换交易所", "切换策略"}):
return "update_bindings"
case (domain == "exchange" || domain == "model") && containsAny(lower, []string{"启用", "禁用", "enable", "disable"}):
return "update_status"
case domain == "model" && hasUpdateVerb && containsAny(lower, []string{"url", "endpoint", "地址", "接口"}):
return "update_endpoint"
case domain == "strategy" && hasUpdateVerb && containsAny(lower, []string{"prompt", "提示词"}):
return "update_prompt"
case domain == "strategy" && hasUpdateVerb && containsAny(lower, []string{
"参数", "配置", "config", "configuration", "parameter",
"最大持仓", "最小置信度", "最低置信度", "主周期", "多周期", "时间框架",
"btc/eth杠杆", "btc eth杠杆", "山寨币杠杆",
"核心指标", "ema", "macd", "rsi", "atr", "boll", "bollinger", "布林",
}):
return "update_config"
case containsAny(lower, []string{"修改", "编辑", "更新", "改", "rename", "update"}):
return "update"
case domain == "trader" && containsAny(lower, []string{"运行中的", "在跑", "running"}):
return "query_running"
case !containsAny(lower, []string{"创建", "新建", "create", "new"}) &&
containsAny(lower, []string{"详情", "详细", "prompt", "提示词", "什么样", "怎么样", "detail", "details", "what kind"}):
return "query_detail"
case containsAny(lower, []string{"查询", "查看", "列出", "list", "show", "有哪些"}):
return "query_list"
case containsAny(lower, []string{"创建", "新建", "加一个", "create", "new"}):
return "create"
default:
return ""
}
}
func exchangeTypeFromText(text string) string {
lower := strings.ToLower(text)
candidates := []string{"binance", "okx", "bybit", "gate", "kucoin", "hyperliquid", "aster", "lighter"}
for _, candidate := range candidates {
if strings.Contains(lower, candidate) {
return candidate
}
}
switch {
case strings.Contains(text, "币安"):
return "binance"
case strings.Contains(text, "欧易"):
return "okx"
case strings.Contains(text, "库币"):
return "kucoin"
default:
return ""
}
}
func providerFromText(text string) string {
lower := strings.ToLower(text)
candidates := []string{"openai", "deepseek", "claude", "gemini", "qwen", "kimi", "grok", "minimax"}
for _, candidate := range candidates {
if strings.Contains(lower, candidate) {
return candidate
}
}
if strings.Contains(text, "通义") {
return "qwen"
}
return ""
}
func extractURL(text string) string {
return strings.TrimSpace(urlPattern.FindString(text))
}
func extractPostKeywordName(text string, keywords []string) string {
trimmed := strings.TrimSpace(text)
for _, keyword := range keywords {
if idx := strings.Index(trimmed, keyword); idx >= 0 {
name := strings.TrimSpace(trimmed[idx+len(keyword):])
name = strings.Trim(name, "“”\"': ")
if name != "" && len([]rune(name)) <= 50 {
return name
}
}
}
return ""
}
func setField(session *skillSession, key, value string) {
ensureSkillFields(session)
value = strings.TrimSpace(value)
if value == "" {
return
}
session.Fields[key] = value
}
func fieldValue(session skillSession, key string) string {
if session.Fields == nil {
return ""
}
return strings.TrimSpace(session.Fields[key])
}
func textMeansAllTargets(text string) bool {
lower := strings.ToLower(strings.TrimSpace(text))
if lower == "" {
return false
}
return containsAny(lower, []string{
"全部", "所有", "全都", "全部策略", "所有策略",
"all", "all strategies", "every strategy",
})
}
func supportsBulkTargetSelection(skillName, action string) bool {
return skillName == "strategy_management" && action == "delete"
}
func resolveTargetFromText(text string, options []traderSkillOption, existing *EntityReference) *EntityReference {
if existing != nil && (existing.ID != "" || existing.Name != "") {
return existing
}
if match := pickMentionedOption(text, options); match != nil {
return &EntityReference{ID: match.ID, Name: match.Name}
}
if choice := choosePreferredOption(options); choice != nil {
return &EntityReference{ID: choice.ID, Name: choice.Name}
}
return nil
}
func (a *Agent) handleTraderManagementSkill(storeUserID string, userID int64, lang, text string, session skillSession) (string, bool) {
action := detectManagementAction(text, "trader")
if session.Name == "trader_management" && session.Action != "" {
action = session.Action
}
if action == "" || action == "create" {
return "", false
}
if action == "query_running" {
answer := formatReadFastPathResponse(lang, "list_traders", a.toolListTraders(storeUserID))
return applyTraderQueryFilter(lang, answer, a.toolListTraders(storeUserID), "running_only"), true
}
if action == "query_detail" {
options := a.loadTraderOptions(storeUserID)
target := resolveTargetFromText(text, options, session.TargetRef)
if detail, ok := a.describeTrader(storeUserID, lang, target); ok {
return detail, true
}
return formatReadFastPathResponse(lang, "list_traders", a.toolListTraders(storeUserID)), true
}
return a.handleSimpleEntitySkill(storeUserID, userID, lang, text, session, "trader_management", action, a.loadTraderOptions(storeUserID))
}
func (a *Agent) handleExchangeManagementSkill(storeUserID string, userID int64, lang, text string, session skillSession) (string, bool) {
action := detectManagementAction(text, "exchange")
if session.Name == "exchange_management" && session.Action != "" {
action = session.Action
}
if action == "" {
return "", false
}
options := a.loadExchangeOptions(storeUserID)
switch action {
case "query_list":
return formatReadFastPathResponse(lang, "get_exchange_configs", a.toolGetExchangeConfigs(storeUserID)), true
case "query_detail":
target := resolveTargetFromText(text, options, session.TargetRef)
if detail, ok := a.describeExchange(storeUserID, lang, target); ok {
return detail, true
}
return formatReadFastPathResponse(lang, "get_exchange_configs", a.toolGetExchangeConfigs(storeUserID)), true
case "create":
return a.handleExchangeCreateSkill(storeUserID, userID, lang, text, session), true
default:
return a.handleSimpleEntitySkill(storeUserID, userID, lang, text, session, "exchange_management", action, options)
}
}
func (a *Agent) handleModelManagementSkill(storeUserID string, userID int64, lang, text string, session skillSession) (string, bool) {
action := detectManagementAction(text, "model")
if session.Name == "model_management" && session.Action != "" {
action = session.Action
}
if action == "" {
return "", false
}
options := a.loadEnabledModelOptions(storeUserID)
switch action {
case "query_list":
return formatReadFastPathResponse(lang, "get_model_configs", a.toolGetModelConfigs(storeUserID)), true
case "query_detail":
target := resolveTargetFromText(text, options, session.TargetRef)
if detail, ok := a.describeModel(storeUserID, lang, target); ok {
return detail, true
}
return formatReadFastPathResponse(lang, "get_model_configs", a.toolGetModelConfigs(storeUserID)), true
case "create":
return a.handleModelCreateSkill(storeUserID, userID, lang, text, session), true
default:
return a.handleSimpleEntitySkill(storeUserID, userID, lang, text, session, "model_management", action, options)
}
}
func (a *Agent) handleStrategyManagementSkill(storeUserID string, userID int64, lang, text string, session skillSession) (string, bool) {
action := detectManagementAction(text, "strategy")
if session.Name == "strategy_management" && session.Action != "" {
action = session.Action
}
if action == "" && wantsStrategyDetails(text) {
action = "query_detail"
}
if action == "" {
return "", false
}
options := a.loadStrategyOptions(storeUserID)
switch action {
case "query_detail":
if wantsDefaultStrategyConfig(text) {
return a.describeDefaultStrategyConfig(lang), true
}
target := resolveTargetFromText(text, options, session.TargetRef)
if detail, ok := a.describeStrategy(storeUserID, lang, target); ok {
return detail, true
}
return formatReadFastPathResponse(lang, "get_strategies", a.toolGetStrategies(storeUserID)), true
case "query_list":
return formatReadFastPathResponse(lang, "get_strategies", a.toolGetStrategies(storeUserID)), true
case "create":
return a.handleStrategyCreateSkill(storeUserID, userID, lang, text, session), true
default:
return a.handleSimpleEntitySkill(storeUserID, userID, lang, text, session, "strategy_management", action, options)
}
}
func wantsStrategyDetails(text string) bool {
lower := strings.ToLower(strings.TrimSpace(text))
if lower == "" {
return false
}
return containsAny(lower, []string{
"什么样", "怎么样", "详情", "详细", "参数", "配置", "prompt", "提示词",
"what kind", "details", "detail", "config", "configuration", "parameter", "prompt",
})
}
func wantsDefaultStrategyConfig(text string) bool {
lower := strings.ToLower(strings.TrimSpace(text))
if lower == "" {
return false
}
return containsAny(lower, []string{
"默认配置", "默认策略", "默认模板", "模板配置",
"default config", "default strategy", "default template",
})
}
func (a *Agent) describeStrategy(storeUserID, lang string, target *EntityReference) (string, bool) {
if a.store == nil {
return "", false
}
var strategy *store.Strategy
var err error
if target != nil && strings.TrimSpace(target.ID) != "" {
strategy, err = a.store.Strategy().Get(storeUserID, strings.TrimSpace(target.ID))
} else if target != nil && strings.TrimSpace(target.Name) != "" {
strategies, listErr := a.store.Strategy().List(storeUserID)
if listErr != nil {
return "", false
}
for _, item := range strategies {
if item != nil && strings.EqualFold(strings.TrimSpace(item.Name), strings.TrimSpace(target.Name)) {
strategy = item
break
}
}
} else {
strategies, listErr := a.store.Strategy().List(storeUserID)
if listErr != nil || len(strategies) != 1 {
return "", false
}
strategy = strategies[0]
}
if err != nil || strategy == nil {
return "", false
}
var cfg store.StrategyConfig
if strings.TrimSpace(strategy.Config) != "" {
_ = json.Unmarshal([]byte(strategy.Config), &cfg)
}
return formatStrategyDetailResponse(lang, strategy, cfg), true
}
func formatStrategyDetailResponse(lang string, strategy *store.Strategy, cfg store.StrategyConfig) string {
name := strings.TrimSpace(strategy.Name)
if name == "" {
name = strings.TrimSpace(strategy.ID)
}
sourceBits := make([]string, 0, 4)
if strings.TrimSpace(cfg.CoinSource.SourceType) != "" {
sourceBits = append(sourceBits, cfg.CoinSource.SourceType)
}
if cfg.CoinSource.UseAI500 {
sourceBits = append(sourceBits, fmt.Sprintf("AI500=%d", cfg.CoinSource.AI500Limit))
}
if cfg.CoinSource.UseOITop {
sourceBits = append(sourceBits, fmt.Sprintf("OITop=%d", cfg.CoinSource.OITopLimit))
}
if cfg.CoinSource.UseOILow {
sourceBits = append(sourceBits, fmt.Sprintf("OILow=%d", cfg.CoinSource.OILowLimit))
}
if len(cfg.CoinSource.StaticCoins) > 0 {
sourceBits = append(sourceBits, "static="+strings.Join(cfg.CoinSource.StaticCoins, ","))
}
timeframes := append([]string(nil), cfg.Indicators.Klines.SelectedTimeframes...)
if len(timeframes) == 0 {
timeframes = cleanStringList([]string{cfg.Indicators.Klines.PrimaryTimeframe, cfg.Indicators.Klines.LongerTimeframe})
}
indicatorBits := make([]string, 0, 8)
if cfg.Indicators.EnableRawKlines {
indicatorBits = append(indicatorBits, "raw_klines")
}
if cfg.Indicators.EnableVolume {
indicatorBits = append(indicatorBits, "volume")
}
if cfg.Indicators.EnableOI {
indicatorBits = append(indicatorBits, "oi")
}
if cfg.Indicators.EnableFundingRate {
indicatorBits = append(indicatorBits, "funding_rate")
}
if cfg.Indicators.EnableEMA {
indicatorBits = append(indicatorBits, "ema")
}
if cfg.Indicators.EnableMACD {
indicatorBits = append(indicatorBits, "macd")
}
if cfg.Indicators.EnableRSI {
indicatorBits = append(indicatorBits, "rsi")
}
if cfg.Indicators.EnableATR {
indicatorBits = append(indicatorBits, "atr")
}
if cfg.Indicators.EnableBOLL {
indicatorBits = append(indicatorBits, "boll")
}
sort.Strings(indicatorBits)
promptBits := make([]string, 0, 5)
if strings.TrimSpace(cfg.PromptSections.RoleDefinition) != "" {
promptBits = append(promptBits, "role_definition")
}
if strings.TrimSpace(cfg.PromptSections.TradingFrequency) != "" {
promptBits = append(promptBits, "trading_frequency")
}
if strings.TrimSpace(cfg.PromptSections.EntryStandards) != "" {
promptBits = append(promptBits, "entry_standards")
}
if strings.TrimSpace(cfg.PromptSections.DecisionProcess) != "" {
promptBits = append(promptBits, "decision_process")
}
customPrompt := strings.TrimSpace(cfg.CustomPrompt)
customPromptPreview := customPrompt
if len([]rune(customPromptPreview)) > 120 {
runes := []rune(customPromptPreview)
customPromptPreview = string(runes[:120]) + "..."
}
if lang == "zh" {
lines := []string{
fmt.Sprintf("策略“%s”概览", name),
fmt.Sprintf("- 类型:%s", defaultIfEmpty(strings.TrimSpace(cfg.StrategyType), "ai_trading")),
fmt.Sprintf("- 语言:%s", defaultIfEmpty(strings.TrimSpace(cfg.Language), "zh")),
}
if strings.TrimSpace(strategy.Description) != "" {
lines = append(lines, fmt.Sprintf("- 描述:%s", strings.TrimSpace(strategy.Description)))
}
if len(sourceBits) > 0 {
lines = append(lines, "- 标的来源:"+strings.Join(sourceBits, " | "))
}
if len(timeframes) > 0 {
lines = append(lines, "- K线周期"+strings.Join(timeframes, " / "))
}
lines = append(lines, fmt.Sprintf("- 仓位风险:最多持仓 %dBTC/ETH 最大杠杆 %d山寨最大杠杆 %d最低置信度 %d",
cfg.RiskControl.MaxPositions, cfg.RiskControl.BTCETHMaxLeverage, cfg.RiskControl.AltcoinMaxLeverage, cfg.RiskControl.MinConfidence))
if len(indicatorBits) > 0 {
lines = append(lines, "- 已启用指标:"+strings.Join(indicatorBits, "、"))
}
if len(promptBits) > 0 {
lines = append(lines, "- Prompt 模块:"+strings.Join(promptBits, "、"))
}
if customPromptPreview != "" {
lines = append(lines, "- 自定义 Prompt"+customPromptPreview)
} else {
lines = append(lines, "- 自定义 Prompt当前为空主要使用策略模板内置 prompt sections。")
}
lines = append(lines, "- 如果你要,我还可以继续展开这条策略的完整参数 JSON或者逐段解释它的 prompt。")
return strings.Join(lines, "\n")
}
lines := []string{
fmt.Sprintf("Strategy %q overview:", name),
fmt.Sprintf("- Type: %s", defaultIfEmpty(strings.TrimSpace(cfg.StrategyType), "ai_trading")),
fmt.Sprintf("- Language: %s", defaultIfEmpty(strings.TrimSpace(cfg.Language), "en")),
}
if strings.TrimSpace(strategy.Description) != "" {
lines = append(lines, fmt.Sprintf("- Description: %s", strings.TrimSpace(strategy.Description)))
}
if len(sourceBits) > 0 {
lines = append(lines, "- Coin source: "+strings.Join(sourceBits, " | "))
}
if len(timeframes) > 0 {
lines = append(lines, "- Timeframes: "+strings.Join(timeframes, " / "))
}
lines = append(lines, fmt.Sprintf("- Risk: max positions %d, BTC/ETH max leverage %d, alt max leverage %d, min confidence %d",
cfg.RiskControl.MaxPositions, cfg.RiskControl.BTCETHMaxLeverage, cfg.RiskControl.AltcoinMaxLeverage, cfg.RiskControl.MinConfidence))
if len(indicatorBits) > 0 {
lines = append(lines, "- Enabled indicators: "+strings.Join(indicatorBits, ", "))
}
if len(promptBits) > 0 {
lines = append(lines, "- Prompt modules: "+strings.Join(promptBits, ", "))
}
if customPromptPreview != "" {
lines = append(lines, "- Custom prompt: "+customPromptPreview)
} else {
lines = append(lines, "- Custom prompt: empty right now; it mainly uses the built-in prompt sections from the strategy template.")
}
lines = append(lines, "- I can also expand the full strategy config JSON or walk through the prompt section by section.")
return strings.Join(lines, "\n")
}
func (a *Agent) describeDefaultStrategyConfig(lang string) string {
if lang != "zh" {
lang = "en"
}
cfg := store.GetDefaultStrategyConfig(lang)
name := "Default Strategy Template"
description := "System default strategy configuration template"
if lang == "zh" {
name = "默认策略模板"
description = "系统默认策略配置模板"
}
return formatStrategyDetailResponse(lang, &store.Strategy{
ID: "default_strategy_template",
Name: name,
Description: description,
}, cfg)
}
func (a *Agent) describeTrader(storeUserID, lang string, target *EntityReference) (string, bool) {
raw := a.toolListTraders(storeUserID)
var payload struct {
Traders []safeTraderToolConfig `json:"traders"`
}
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
return "", false
}
trader := findTraderByReference(payload.Traders, target)
if trader == nil {
if len(payload.Traders) != 1 {
return "", false
}
trader = &payload.Traders[0]
}
if lang == "zh" {
status := "未运行"
if trader.IsRunning {
status = "运行中"
}
return fmt.Sprintf("交易员“%s”详情\n- 状态:%s\n- 模型:%s\n- 交易所:%s\n- 策略:%s\n- 扫描间隔:%d 分钟\n- 初始余额:%.2f",
trader.Name, status, trader.AIModelID, trader.ExchangeID, defaultIfEmpty(trader.StrategyID, "未绑定"), trader.ScanIntervalMinutes, trader.InitialBalance), true
}
status := "stopped"
if trader.IsRunning {
status = "running"
}
return fmt.Sprintf("Trader %q details:\n- Status: %s\n- Model: %s\n- Exchange: %s\n- Strategy: %s\n- Scan interval: %d minutes\n- Initial balance: %.2f",
trader.Name, status, trader.AIModelID, trader.ExchangeID, defaultIfEmpty(trader.StrategyID, "none"), trader.ScanIntervalMinutes, trader.InitialBalance), true
}
func (a *Agent) describeExchange(storeUserID, lang string, target *EntityReference) (string, bool) {
raw := a.toolGetExchangeConfigs(storeUserID)
var payload struct {
ExchangeConfigs []safeExchangeToolConfig `json:"exchange_configs"`
}
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
return "", false
}
exchange := findExchangeByReference(payload.ExchangeConfigs, target)
if exchange == nil {
if len(payload.ExchangeConfigs) != 1 {
return "", false
}
exchange = &payload.ExchangeConfigs[0]
}
if lang == "zh" {
return fmt.Sprintf("交易所配置“%s”详情\n- 交易所:%s\n- 已启用:%t\n- API Key%t\n- Secret%t\n- Passphrase%t\n- Testnet%t",
defaultIfEmpty(exchange.AccountName, exchange.ID), exchange.ExchangeType, exchange.Enabled, exchange.HasAPIKey, exchange.HasSecretKey, exchange.HasPassphrase, exchange.Testnet), true
}
return fmt.Sprintf("Exchange config %q details:\n- Exchange: %s\n- Enabled: %t\n- API key present: %t\n- Secret present: %t\n- Passphrase present: %t\n- Testnet: %t",
defaultIfEmpty(exchange.AccountName, exchange.ID), exchange.ExchangeType, exchange.Enabled, exchange.HasAPIKey, exchange.HasSecretKey, exchange.HasPassphrase, exchange.Testnet), true
}
func (a *Agent) describeModel(storeUserID, lang string, target *EntityReference) (string, bool) {
raw := a.toolGetModelConfigs(storeUserID)
var payload struct {
ModelConfigs []safeModelToolConfig `json:"model_configs"`
}
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
return "", false
}
model := findModelByReference(payload.ModelConfigs, target)
if model == nil {
if len(payload.ModelConfigs) != 1 {
return "", false
}
model = &payload.ModelConfigs[0]
}
if lang == "zh" {
return fmt.Sprintf("模型配置“%s”详情\n- Provider%s\n- 已启用:%t\n- API Key%t\n- URL%s\n- Model Name%s",
defaultIfEmpty(model.Name, model.ID), model.Provider, model.Enabled, model.HasAPIKey, defaultIfEmpty(model.CustomAPIURL, "未设置"), defaultIfEmpty(model.CustomModelName, "未设置")), true
}
return fmt.Sprintf("Model config %q details:\n- Provider: %s\n- Enabled: %t\n- API key present: %t\n- URL: %s\n- Model name: %s",
defaultIfEmpty(model.Name, model.ID), model.Provider, model.Enabled, model.HasAPIKey, defaultIfEmpty(model.CustomAPIURL, "not set"), defaultIfEmpty(model.CustomModelName, "not set")), true
}
func findTraderByReference(items []safeTraderToolConfig, target *EntityReference) *safeTraderToolConfig {
if target == nil {
return nil
}
for i := range items {
if strings.TrimSpace(target.ID) != "" && items[i].ID == strings.TrimSpace(target.ID) {
return &items[i]
}
if strings.TrimSpace(target.Name) != "" && strings.EqualFold(strings.TrimSpace(items[i].Name), strings.TrimSpace(target.Name)) {
return &items[i]
}
}
return nil
}
func findExchangeByReference(items []safeExchangeToolConfig, target *EntityReference) *safeExchangeToolConfig {
if target == nil {
return nil
}
for i := range items {
name := defaultIfEmpty(items[i].AccountName, items[i].Name)
if strings.TrimSpace(target.ID) != "" && items[i].ID == strings.TrimSpace(target.ID) {
return &items[i]
}
if strings.TrimSpace(target.Name) != "" && strings.EqualFold(strings.TrimSpace(name), strings.TrimSpace(target.Name)) {
return &items[i]
}
}
return nil
}
func findModelByReference(items []safeModelToolConfig, target *EntityReference) *safeModelToolConfig {
if target == nil {
return nil
}
for i := range items {
if strings.TrimSpace(target.ID) != "" && items[i].ID == strings.TrimSpace(target.ID) {
return &items[i]
}
if strings.TrimSpace(target.Name) != "" && strings.EqualFold(strings.TrimSpace(items[i].Name), strings.TrimSpace(target.Name)) {
return &items[i]
}
}
return nil
}
func (a *Agent) loadTraderOptions(storeUserID string) []traderSkillOption {
if a.store == nil {
return nil
}
traders, err := a.store.Trader().List(storeUserID)
if err != nil {
return nil
}
out := make([]traderSkillOption, 0, len(traders))
for _, trader := range traders {
out = append(out, traderSkillOption{ID: trader.ID, Name: trader.Name, Enabled: trader.IsRunning})
}
return out
}
func (a *Agent) handleExchangeCreateSkill(storeUserID string, userID int64, lang, text string, session skillSession) string {
if session.Name == "" {
session = skillSession{Name: "exchange_management", Action: "create", Phase: "collecting"}
}
if fieldValue(session, skillDAGStepField) == "" {
setSkillDAGStep(&session, "resolve_exchange_type")
}
if isCancelSkillReply(text) {
a.clearSkillSession(userID)
if lang == "zh" {
return "已取消当前创建交易所配置流程。"
}
return "Cancelled the current exchange creation flow."
}
if v := exchangeTypeFromText(text); fieldValue(session, "exchange_type") == "" && v != "" {
setField(&session, "exchange_type", v)
}
if v := extractTraderName(text); fieldValue(session, "account_name") == "" && v != "" {
setField(&session, "account_name", v)
}
exType := fieldValue(session, "exchange_type")
if actionRequiresSlot("exchange_management", "create", "exchange_type") && exType == "" {
setSkillDAGStep(&session, "resolve_exchange_type")
a.saveSkillSession(userID, session)
if lang == "zh" {
return "要创建交易所配置,我还需要:" + slotDisplayName("exchange_type", lang) + "。例如OKX、Binance、Bybit。"
}
return "To create an exchange config, tell me which exchange to use, for example OKX, Binance, or Bybit."
}
accountName := fieldValue(session, "account_name")
if accountName == "" {
accountName = "Default"
}
setSkillDAGStep(&session, "execute_create")
args := map[string]any{
"action": "create",
"exchange_type": exType,
"account_name": accountName,
}
raw, _ := json.Marshal(args)
resp := a.toolManageExchangeConfig(storeUserID, string(raw))
if errMsg := parseSkillError(resp); strings.Contains(resp, `"error"`) {
a.saveSkillSession(userID, session)
if lang == "zh" {
return "创建交易所配置失败:" + errMsg
}
return "Failed to create exchange config: " + errMsg
}
a.clearSkillSession(userID)
if lang == "zh" {
return fmt.Sprintf("已创建交易所配置:%s%s。如需继续补 API Key、Secret 或 Passphrase可以直接继续说。", accountName, exType)
}
return fmt.Sprintf("Created exchange config %s (%s). You can continue by adding API key, secret, or passphrase.", accountName, exType)
}
func (a *Agent) handleModelCreateSkill(storeUserID string, userID int64, lang, text string, session skillSession) string {
if session.Name == "" {
session = skillSession{Name: "model_management", Action: "create", Phase: "collecting"}
}
if fieldValue(session, skillDAGStepField) == "" {
setSkillDAGStep(&session, "resolve_provider")
}
if isCancelSkillReply(text) {
a.clearSkillSession(userID)
if lang == "zh" {
return "已取消当前创建模型配置流程。"
}
return "Cancelled the current model creation flow."
}
if v := providerFromText(text); fieldValue(session, "provider") == "" && v != "" {
setField(&session, "provider", v)
}
if v := extractTraderName(text); fieldValue(session, "name") == "" && v != "" {
setField(&session, "name", v)
}
if v := extractURL(text); fieldValue(session, "custom_api_url") == "" && v != "" {
setField(&session, "custom_api_url", v)
}
provider := fieldValue(session, "provider")
if actionRequiresSlot("model_management", "create", "provider") && provider == "" {
setSkillDAGStep(&session, "resolve_provider")
a.saveSkillSession(userID, session)
if lang == "zh" {
return "要创建模型配置,我还需要:" + slotDisplayName("provider", lang) + "例如OpenAI、DeepSeek、Claude、Gemini。"
}
return "To create a model config, I need the provider first, for example OpenAI, DeepSeek, Claude, or Gemini."
}
setSkillDAGStep(&session, "execute_create")
args := map[string]any{
"action": "create",
"provider": provider,
"name": defaultIfEmpty(fieldValue(session, "name"), provider),
"custom_api_url": fieldValue(session, "custom_api_url"),
"custom_model_name": fieldValue(session, "custom_model_name"),
}
raw, _ := json.Marshal(args)
resp := a.toolManageModelConfig(storeUserID, string(raw))
if errMsg := parseSkillError(resp); strings.Contains(resp, `"error"`) {
a.saveSkillSession(userID, session)
if lang == "zh" {
return "创建模型配置失败:" + errMsg
}
return "Failed to create model config: " + errMsg
}
a.clearSkillSession(userID)
if lang == "zh" {
return fmt.Sprintf("已创建模型配置:%s。你后续还可以继续补 API Key、URL 或模型名。", provider)
}
return fmt.Sprintf("Created model config for %s. You can continue by adding API key, URL, or model name.", provider)
}
func (a *Agent) handleStrategyCreateSkill(storeUserID string, userID int64, lang, text string, session skillSession) string {
if session.Name == "" {
session = skillSession{Name: "strategy_management", Action: "create", Phase: "collecting"}
}
if fieldValue(session, skillDAGStepField) == "" {
setSkillDAGStep(&session, "resolve_name")
}
if isCancelSkillReply(text) {
a.clearSkillSession(userID)
if lang == "zh" {
return "已取消当前创建策略流程。"
}
return "Cancelled the current strategy creation flow."
}
name := fieldValue(session, "name")
if name == "" {
name = extractTraderName(text)
if name == "" {
name = extractPostKeywordName(text, []string{"叫", "名为", "策略叫", "strategy called"})
}
if name != "" {
setField(&session, "name", name)
}
}
if actionRequiresSlot("strategy_management", "create", "name") && name == "" {
setSkillDAGStep(&session, "resolve_name")
a.saveSkillSession(userID, session)
if lang == "zh" {
return "要创建策略,我还需要:" + slotDisplayName("name", lang) + "。你可以直接说创建一个叫“趋势策略A”的策略。"
}
return "To create a strategy, I need a strategy name. You can say: create a strategy called 'Trend A'."
}
setSkillDAGStep(&session, "execute_create")
args := map[string]any{"action": "create", "name": name, "lang": "zh"}
raw, _ := json.Marshal(args)
resp := a.toolManageStrategy(storeUserID, string(raw))
if errMsg := parseSkillError(resp); strings.Contains(resp, `"error"`) {
a.saveSkillSession(userID, session)
if lang == "zh" {
return "创建策略失败:" + errMsg
}
return "Failed to create strategy: " + errMsg
}
a.clearSkillSession(userID)
if lang == "zh" {
return fmt.Sprintf("已创建策略“%s”。默认配置已就绪你后续可以继续让我帮你改细节。", name)
}
return fmt.Sprintf("Created strategy %q with the default configuration.", name)
}
func (a *Agent) handleSimpleEntitySkill(storeUserID string, userID int64, lang, text string, session skillSession, skillName, action string, options []traderSkillOption) (string, bool) {
if isCancelSkillReply(text) {
a.clearSkillSession(userID)
if lang == "zh" {
return "已取消当前流程。", true
}
return "Cancelled the current flow.", true
}
if session.Name == "" {
session = skillSession{Name: skillName, Action: action, Phase: "collecting"}
}
if session.Name != skillName || session.Action != action {
return "", false
}
if dag, ok := getSkillDAG(skillName, action); ok && len(dag.Steps) > 0 {
currentStep, _ := currentSkillDAGStep(session)
if currentStep.ID == "resolve_target" {
if supportsBulkTargetSelection(skillName, action) && textMeansAllTargets(text) {
setField(&session, "bulk_scope", "all")
advanceSkillDAGStep(&session, currentStep.ID)
} else {
session.TargetRef = resolveTargetFromText(text, options, session.TargetRef)
}
if session.TargetRef == nil {
if !(supportsBulkTargetSelection(skillName, action) && fieldValue(session, "bulk_scope") == "all") {
setSkillDAGStep(&session, "resolve_target")
a.saveSkillSession(userID, session)
label := "可选对象:"
if lang != "zh" {
label = "Available targets:"
}
optionList := formatOptionList(label, options)
if lang == "zh" {
reply := "当前这一步需要先确定目标对象。请告诉我你要操作哪一个。"
if optionList != "" {
reply += "\n" + optionList
}
return reply, true
}
reply := "This step needs a target object first. Tell me which one to operate on."
if optionList != "" {
reply += "\n" + optionList
}
return reply, true
}
}
if fieldValue(session, skillDAGStepField) == currentStep.ID {
advanceSkillDAGStep(&session, currentStep.ID)
}
}
} else {
if supportsBulkTargetSelection(skillName, action) && textMeansAllTargets(text) {
setField(&session, "bulk_scope", "all")
} else {
session.TargetRef = resolveTargetFromText(text, options, session.TargetRef)
}
if session.TargetRef == nil && fieldValue(session, "bulk_scope") != "all" && action != "query" && action != "query_list" && action != "query_detail" && action != "query_running" {
a.saveSkillSession(userID, session)
label := formatOptionList("可选对象:", options)
if lang == "zh" {
reply := "我还需要你明确要操作的是哪一个对象。"
if label != "" {
reply += "\n" + label
}
return reply, true
}
reply := "I still need you to specify which object to operate on."
if label != "" {
reply += "\n" + label
}
return reply, true
}
}
switch skillName {
case "trader_management":
return a.executeTraderManagementAction(storeUserID, userID, lang, text, session), true
case "exchange_management":
return a.executeExchangeManagementAction(storeUserID, userID, lang, text, session), true
case "model_management":
return a.executeModelManagementAction(storeUserID, userID, lang, text, session), true
case "strategy_management":
return a.executeStrategyManagementAction(storeUserID, userID, lang, text, session), true
default:
return "", false
}
}
func defaultIfEmpty(value, fallback string) string {
value = strings.TrimSpace(value)
if value == "" {
return strings.TrimSpace(fallback)
}
return value
}

View File

@@ -1,180 +0,0 @@
package agent
import (
"context"
"encoding/json"
"fmt"
"strings"
"nofx/mcp"
)
const (
skillOutcomeSuccess = "success"
skillOutcomeNeedMoreInfo = "need_more_info"
skillOutcomeRecoverableError = "recoverable_error"
skillOutcomeFatalError = "fatal_error"
skillOutcomeNotHandled = "not_handled"
)
type skillOutcome struct {
Skill string `json:"skill"`
Action string `json:"action"`
Status string `json:"status"`
GoalAchieved bool `json:"goal_achieved"`
UserMessage string `json:"user_message,omitempty"`
ErrorCode string `json:"error_code,omitempty"`
Error string `json:"error,omitempty"`
Data map[string]any `json:"data,omitempty"`
}
type taskReviewDecision struct {
Route string `json:"route"`
Answer string `json:"answer,omitempty"`
}
func normalizeAtomicSkillAction(skill, action string) string {
action = strings.TrimSpace(strings.ToLower(action))
switch skill {
case "trader_management":
switch action {
case "query", "query_list":
return "query_list"
case "query_running":
return "query_running"
case "query_detail":
return "query_detail"
case "update":
return "update_name"
case "update_name", "update_bindings":
return action
}
case "exchange_management":
switch action {
case "query", "query_list":
return "query_list"
case "query_detail":
return "query_detail"
case "update":
return "update_name"
case "update_name", "update_status":
return action
}
case "model_management":
switch action {
case "query", "query_list":
return "query_list"
case "query_detail":
return "query_detail"
case "update":
return "update_name"
case "update_name", "update_endpoint", "update_status":
return action
}
case "strategy_management":
switch action {
case "query", "query_list":
return "query_list"
case "query_detail":
return "query_detail"
case "update":
return "update_name"
case "update_name", "update_config", "update_prompt":
return action
}
}
return action
}
func inferSkillOutcome(skill, action, answer string, activeSession skillSession, data map[string]any) skillOutcome {
outcome := skillOutcome{
Skill: skill,
Action: action,
Status: skillOutcomeSuccess,
UserMessage: strings.TrimSpace(answer),
Data: data,
}
if activeSession.Name != "" {
outcome.Status = skillOutcomeNeedMoreInfo
outcome.GoalAchieved = false
return outcome
}
lower := strings.ToLower(strings.TrimSpace(answer))
switch {
case lower == "":
outcome.Status = skillOutcomeNotHandled
case strings.Contains(lower, "失败") || strings.Contains(lower, "failed") || strings.Contains(lower, "error"):
outcome.Status = skillOutcomeRecoverableError
outcome.Error = strings.TrimSpace(answer)
default:
outcome.GoalAchieved = true
}
return outcome
}
func parseTaskReviewDecision(raw string) (taskReviewDecision, error) {
raw = strings.TrimSpace(raw)
raw = strings.TrimPrefix(raw, "```json")
raw = strings.TrimPrefix(raw, "```")
raw = strings.TrimSuffix(raw, "```")
raw = strings.TrimSpace(raw)
var decision taskReviewDecision
if err := json.Unmarshal([]byte(raw), &decision); err == nil {
decision.Route = strings.TrimSpace(strings.ToLower(decision.Route))
decision.Answer = strings.TrimSpace(decision.Answer)
return decision, nil
}
start := strings.Index(raw, "{")
end := strings.LastIndex(raw, "}")
if start >= 0 && end > start {
if err := json.Unmarshal([]byte(raw[start:end+1]), &decision); err == nil {
decision.Route = strings.TrimSpace(strings.ToLower(decision.Route))
decision.Answer = strings.TrimSpace(decision.Answer)
return decision, nil
}
}
return taskReviewDecision{}, fmt.Errorf("invalid task review json")
}
func (a *Agent) reviewTaskCompletion(ctx context.Context, userID int64, lang, text string, outcome skillOutcome) (taskReviewDecision, error) {
if a.aiClient == nil {
if outcome.Status == skillOutcomeRecoverableError || outcome.Status == skillOutcomeFatalError || outcome.Status == skillOutcomeNotHandled {
return taskReviewDecision{Route: "replan"}, nil
}
return taskReviewDecision{Route: "complete", Answer: outcome.UserMessage}, nil
}
recentConversationCtx := a.buildRecentConversationContext(userID, text)
outcomeJSON, _ := json.Marshal(outcome)
systemPrompt := `You are the task-level Plan-Execute-Review supervisor for NOFXi.
You are reviewing the JSON result returned by one structured skill execution.
Return JSON only. Do not return markdown.
Rules:
- Decide whether the OVERALL user task is finished, not whether the skill itself ran successfully.
- Use route "complete" only when the user's task is now complete or the best next message is a final user-facing reply.
- Use route "replan" when the user's task is not complete yet and the planner should continue from the new skill outcome.
- Prefer route "replan" for recoverable errors, unmet goals, missing prerequisites, or cases where another skill/tool sequence may help.
- If you choose "complete", produce the final user-facing answer in the user's language.
Return JSON with this exact shape:
{"route":"complete|replan","answer":""}`
userPrompt := fmt.Sprintf("Language: %s\nUser message: %s\n\nRecent conversation:\n%s\n\nSkill outcome JSON:\n%s", lang, text, recentConversationCtx, string(outcomeJSON))
stageCtx, cancel := withPlannerStageTimeout(ctx, directReplyTimeout)
defer cancel()
raw, err := a.aiClient.CallWithRequest(&mcp.Request{
Messages: []mcp.Message{
mcp.NewSystemMessage(systemPrompt),
mcp.NewUserMessage(userPrompt),
},
Ctx: stageCtx,
})
if err != nil {
return taskReviewDecision{}, err
}
return parseTaskReviewDecision(raw)
}

View File

@@ -1,119 +0,0 @@
package agent
import (
"embed"
"encoding/json"
"fmt"
"sort"
"strings"
)
//go:embed skills/*.json
var embeddedSkillDefinitions embed.FS
type SkillDefinition struct {
Name string `json:"name"`
Kind string `json:"kind"`
Domain string `json:"domain"`
Description string `json:"description"`
Intents []string `json:"intents,omitempty"`
Actions map[string]SkillActionDefinition `json:"actions,omitempty"`
ToolMapping map[string]string `json:"tool_mapping,omitempty"`
}
type SkillActionDefinition struct {
Description string `json:"description,omitempty"`
RequiredSlots []string `json:"required_slots,omitempty"`
OptionalSlots []string `json:"optional_slots,omitempty"`
NeedsConfirmation bool `json:"needs_confirmation,omitempty"`
}
var skillRegistry = mustLoadSkillRegistry()
func mustLoadSkillRegistry() map[string]SkillDefinition {
registry, err := loadSkillRegistry()
if err != nil {
panic(err)
}
return registry
}
func loadSkillRegistry() (map[string]SkillDefinition, error) {
entries, err := embeddedSkillDefinitions.ReadDir("skills")
if err != nil {
return nil, err
}
registry := make(map[string]SkillDefinition, len(entries))
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
continue
}
raw, err := embeddedSkillDefinitions.ReadFile("skills/" + entry.Name())
if err != nil {
return nil, err
}
var def SkillDefinition
if err := json.Unmarshal(raw, &def); err != nil {
return nil, fmt.Errorf("parse skill definition %s: %w", entry.Name(), err)
}
def = normalizeSkillDefinition(def)
if def.Name == "" {
return nil, fmt.Errorf("skill definition %s has empty name", entry.Name())
}
registry[def.Name] = def
}
return registry, nil
}
func normalizeSkillDefinition(def SkillDefinition) SkillDefinition {
def.Name = strings.TrimSpace(def.Name)
def.Kind = strings.TrimSpace(def.Kind)
def.Domain = strings.TrimSpace(def.Domain)
def.Description = strings.TrimSpace(def.Description)
def.Intents = cleanStringList(def.Intents)
if len(def.Actions) > 0 {
normalized := make(map[string]SkillActionDefinition, len(def.Actions))
for key, action := range def.Actions {
key = strings.TrimSpace(key)
if key == "" {
continue
}
action.Description = strings.TrimSpace(action.Description)
action.RequiredSlots = cleanStringList(action.RequiredSlots)
action.OptionalSlots = cleanStringList(action.OptionalSlots)
normalized[key] = action
}
def.Actions = normalized
}
if len(def.ToolMapping) > 0 {
normalized := make(map[string]string, len(def.ToolMapping))
for key, value := range def.ToolMapping {
key = strings.TrimSpace(key)
value = strings.TrimSpace(value)
if key == "" || value == "" {
continue
}
normalized[key] = value
}
def.ToolMapping = normalized
}
return def
}
func getSkillDefinition(name string) (SkillDefinition, bool) {
def, ok := skillRegistry[strings.TrimSpace(name)]
return def, ok
}
func listSkillNames() []string {
names := make([]string, 0, len(skillRegistry))
for name := range skillRegistry {
names = append(names, name)
}
sort.Strings(names)
return names
}

View File

@@ -1,55 +0,0 @@
package agent
import "testing"
func TestSkillRegistryLoadsDefinitions(t *testing.T) {
names := listSkillNames()
if len(names) < 4 {
t.Fatalf("expected skill registry to load definitions, got %v", names)
}
for _, name := range []string{
"trader_management",
"exchange_management",
"model_management",
"strategy_management",
"exchange_diagnosis",
"model_diagnosis",
} {
if _, ok := getSkillDefinition(name); !ok {
t.Fatalf("missing skill definition %q", name)
}
}
}
func TestTraderManagementDefinitionHasCreateAction(t *testing.T) {
def, ok := getSkillDefinition("trader_management")
if !ok {
t.Fatalf("missing trader_management definition")
}
action, ok := def.Actions["create"]
if !ok {
t.Fatalf("missing create action in trader_management")
}
if len(action.RequiredSlots) == 0 {
t.Fatalf("expected required slots for trader_management create action")
}
}
func TestActionNeedsConfirmationUsesSkillDefinition(t *testing.T) {
if !actionNeedsConfirmation("exchange_management", "delete") {
t.Fatalf("expected exchange_management delete to require confirmation")
}
if actionNeedsConfirmation("exchange_management", "query") {
t.Fatalf("did not expect exchange_management query to require confirmation")
}
}
func TestActionRequiresSlotUsesSkillDefinition(t *testing.T) {
if !actionRequiresSlot("model_management", "create", "provider") {
t.Fatalf("expected model_management create to require provider")
}
if actionRequiresSlot("model_management", "create", "target_ref") {
t.Fatalf("did not expect model_management create to require target_ref")
}
}

View File

@@ -1,144 +0,0 @@
package agent
import (
"fmt"
"strings"
)
type skillActionRuntime struct {
Skill SkillDefinition
Name string
Action SkillActionDefinition
}
func getSkillActionRuntime(skillName, action string) (skillActionRuntime, bool) {
def, ok := getSkillDefinition(skillName)
if !ok {
return skillActionRuntime{}, false
}
action = strings.TrimSpace(action)
if action == "" {
return skillActionRuntime{Skill: def}, true
}
actionDef, ok := def.Actions[action]
if !ok {
return skillActionRuntime{}, false
}
return skillActionRuntime{
Skill: def,
Name: action,
Action: actionDef,
}, true
}
func actionNeedsConfirmation(skillName, action string) bool {
runtime, ok := getSkillActionRuntime(skillName, action)
if !ok {
return false
}
return runtime.Action.NeedsConfirmation
}
func actionRequiresSlot(skillName, action, slot string) bool {
runtime, ok := getSkillActionRuntime(skillName, action)
if !ok {
return false
}
slot = strings.TrimSpace(slot)
for _, candidate := range runtime.Action.RequiredSlots {
if candidate == slot {
return true
}
}
return false
}
func slotDisplayName(slot, lang string) string {
slot = strings.TrimSpace(slot)
if lang != "zh" {
switch slot {
case "target_ref":
return "target"
case "name":
return "name"
case "exchange":
return "exchange"
case "model":
return "model"
case "strategy":
return "strategy"
case "exchange_type":
return "exchange type"
case "provider":
return "provider"
default:
return slot
}
}
switch slot {
case "target_ref":
return "目标对象"
case "name":
return "名称"
case "exchange":
return "交易所"
case "model":
return "模型"
case "strategy":
return "策略"
case "exchange_type":
return "交易所类型"
case "provider":
return "provider"
default:
return slot
}
}
func formatAwaitConfirmationMessage(lang, action, targetLabel string) string {
actionLabel := action
if lang == "zh" {
switch action {
case "start":
actionLabel = "启动"
case "stop":
actionLabel = "停止"
case "delete":
actionLabel = "删除"
case "activate":
actionLabel = "激活"
default:
actionLabel = action
}
return fmt.Sprintf("即将%s“%s”。这是需要确认的操作请回复“确认”继续回复“取消”终止。", actionLabel, targetLabel)
}
return fmt.Sprintf("You are about to %s %q. Please reply 'confirm' to continue or 'cancel' to stop.", actionLabel, targetLabel)
}
func formatStillWaitingConfirmationMessage(lang string) string {
if lang == "zh" {
return "当前流程仍在等待你确认。回复“确认”继续,或“取消”终止。"
}
return "This flow is still waiting for your confirmation."
}
func beginConfirmationIfNeeded(userID int64, lang string, session *skillSession, targetLabel string) (string, bool) {
if session == nil || !actionNeedsConfirmation(session.Name, session.Action) {
return "", false
}
if session.Phase != "await_confirmation" {
session.Phase = "await_confirmation"
return formatAwaitConfirmationMessage(lang, session.Action, targetLabel), true
}
return "", false
}
func awaitingConfirmationButNotApproved(lang string, session skillSession, text string) (string, bool) {
if !actionNeedsConfirmation(session.Name, session.Action) || session.Phase != "await_confirmation" {
return "", false
}
if isYesReply(text) {
return "", false
}
return formatStillWaitingConfirmationMessage(lang), true
}

View File

@@ -1,6 +0,0 @@
{
"name": "exchange_diagnosis",
"kind": "diagnosis",
"domain": "exchange",
"description": "当用户反馈交易所 API 连接失败、签名错误、timestamp 异常、权限不足、IP 白名单限制、账户不可用等问题时调用。适用于用户在手动配置或运行交易员时遇到的交易所接入故障。不用于创建、修改、删除或查询交易所配置这类管理操作。"
}

View File

@@ -1,32 +0,0 @@
{
"name": "exchange_management",
"kind": "management",
"domain": "exchange",
"description": "当用户想创建、查看、修改或删除交易所账户配置时调用。适用于用户提到交易所账户、API Key、Secret、Passphrase、测试网开关、启用状态等配置管理需求。不用于排查 invalid signature、timestamp、权限不足、白名单限制等连接或鉴权诊断问题。",
"actions": {
"create": {
"description": "创建新的交易所配置。",
"required_slots": ["exchange_type"],
"optional_slots": ["account_name", "api_key", "secret_key", "passphrase", "testnet"]
},
"update": {
"description": "更新已有交易所配置。",
"required_slots": ["target_ref"],
"optional_slots": ["account_name", "api_key", "secret_key", "passphrase", "enabled", "testnet"]
},
"delete": {
"description": "删除交易所配置。",
"required_slots": ["target_ref"],
"needs_confirmation": true
},
"query": {
"description": "查询交易所配置。"
}
},
"tool_mapping": {
"create": "manage_exchange_config:create",
"update": "manage_exchange_config:update",
"delete": "manage_exchange_config:delete",
"query": "get_exchange_configs"
}
}

View File

@@ -1,6 +0,0 @@
{
"name": "model_diagnosis",
"kind": "diagnosis",
"domain": "model",
"description": "当用户反馈模型配置失败、API Key 无效、Base URL 非法、模型名不匹配、调用返回错误、模型不可用等问题时调用。适用于用户在接入或测试大模型时遇到的配置与兼容性故障。不用于创建、修改、删除或查询模型配置这类管理操作。"
}

View File

@@ -1,32 +0,0 @@
{
"name": "model_management",
"kind": "management",
"domain": "model",
"description": "当用户想创建、查看、修改或删除 AI 模型配置时调用。适用于用户提到 provider、API Key、Base URL、模型名称、启用状态等配置管理需求。不用于排查模型调用失败、接口不兼容、鉴权错误、模型不存在等诊断问题。",
"actions": {
"create": {
"description": "创建新的模型配置。",
"required_slots": ["provider"],
"optional_slots": ["name", "api_key", "custom_api_url", "custom_model_name", "enabled"]
},
"update": {
"description": "更新已有模型配置。",
"required_slots": ["target_ref"],
"optional_slots": ["api_key", "custom_api_url", "custom_model_name", "enabled"]
},
"delete": {
"description": "删除模型配置。",
"required_slots": ["target_ref"],
"needs_confirmation": true
},
"query": {
"description": "查询模型配置。"
}
},
"tool_mapping": {
"create": "manage_model_config:create",
"update": "manage_model_config:update",
"delete": "manage_model_config:delete",
"query": "get_model_configs"
}
}

View File

@@ -1,6 +0,0 @@
{
"name": "strategy_diagnosis",
"kind": "diagnosis",
"domain": "strategy",
"description": "当用户反馈策略未生效、策略输出异常、提示词或配置结果与预期不一致、策略执行表现异常时调用。适用于策略内容和执行效果相关的排障与解释。不用于创建、修改、删除、激活、复制或查询策略模板这类管理操作。"
}

View File

@@ -1,42 +0,0 @@
{
"name": "strategy_management",
"kind": "management",
"domain": "strategy",
"description": "当用户想创建、查看、修改、删除、激活或复制策略模板时调用。适用于用户提到策略名称、策略配置、描述、语言、激活状态、复制新版本等管理需求。不用于排查策略未生效、策略输出异常、执行结果异常等诊断问题。",
"actions": {
"create": {
"description": "创建策略模板。",
"required_slots": ["name"],
"optional_slots": ["config", "description", "lang"]
},
"update": {
"description": "更新策略模板。",
"required_slots": ["target_ref"],
"optional_slots": ["name", "config", "description"]
},
"delete": {
"description": "删除策略模板。",
"required_slots": ["target_ref"],
"needs_confirmation": true
},
"activate": {
"description": "激活策略模板。",
"required_slots": ["target_ref"]
},
"duplicate": {
"description": "复制策略模板。",
"required_slots": ["target_ref", "name"]
},
"query": {
"description": "查询策略模板。"
}
},
"tool_mapping": {
"create": "manage_strategy:create",
"update": "manage_strategy:update",
"delete": "manage_strategy:delete",
"activate": "manage_strategy:activate",
"duplicate": "manage_strategy:duplicate",
"query": "get_strategies"
}
}

View File

@@ -1,6 +0,0 @@
{
"name": "trader_diagnosis",
"kind": "diagnosis",
"domain": "trader",
"description": "当用户反馈交易员无法启动、启动后不交易、绑定模型或交易所缺失、运行状态异常、收益或仓位表现异常时调用。适用于交易员运行过程中的排障与原因定位。不用于创建、修改、删除、启动、停止或查询交易员这类管理操作。"
}

View File

@@ -1,52 +0,0 @@
{
"name": "trader_management",
"kind": "management",
"domain": "trader",
"description": "当用户想创建、查看、修改、删除、启动或停止交易员时调用。适用于用户提到交易员名称、绑定交易所、绑定模型、绑定策略、扫描频率、自定义提示词、运行状态等管理需求。不用于排查交易员启动失败、未下单、收益异常、仓位异常等诊断问题。",
"intents": [
"创建交易员",
"修改交易员",
"删除交易员",
"启动交易员",
"停止交易员",
"查询交易员"
],
"actions": {
"create": {
"description": "创建新的交易员。",
"required_slots": ["name", "exchange", "model"],
"optional_slots": ["strategy", "auto_start"]
},
"update": {
"description": "更新已有交易员。",
"required_slots": ["target_ref"],
"optional_slots": ["name", "exchange", "model", "strategy", "scan_interval_minutes", "custom_prompt"]
},
"delete": {
"description": "删除交易员。",
"required_slots": ["target_ref"],
"needs_confirmation": true
},
"start": {
"description": "启动交易员。",
"required_slots": ["target_ref"],
"needs_confirmation": true
},
"stop": {
"description": "停止交易员。",
"required_slots": ["target_ref"],
"needs_confirmation": true
},
"query": {
"description": "查询交易员列表或状态。"
}
},
"tool_mapping": {
"create": "manage_trader:create",
"update": "manage_trader:update",
"delete": "manage_trader:delete",
"start": "manage_trader:start",
"stop": "manage_trader:stop",
"query": "manage_trader:list"
}
}

View File

@@ -1,444 +0,0 @@
package agent
import (
"nofx/safe"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"golang.org/x/text/encoding/simplifiedchinese"
"golang.org/x/text/transform"
)
// stockHTTPClient is a shared HTTP client for stock API requests.
// Reused across calls for connection pooling.
var stockHTTPClient = &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
MaxIdleConns: 10,
MaxIdleConnsPerHost: 5,
IdleConnTimeout: 90 * time.Second,
},
}
// StockQuote holds real-time stock data.
type StockQuote struct {
Name string
Code string
Market string // "A股", "港股", "美股"
Currency string // "CNY", "HKD", "USD"
Open float64
PrevClose float64
Price float64
High float64
Low float64
Volume float64
Turnover float64
Date string
Time string
Change float64
ChangePct float64
// 盘前盘后 (美股)
ExtPrice float64 // 盘前/盘后价格
ExtChangePct float64 // 盘前/盘后涨跌幅%
ExtChange float64 // 盘前/盘后涨跌额
ExtTime string // 盘前/盘后时间
IsExtHours bool // 是否在盘前盘后时段
}
// knownStocks maps Chinese names to stock codes.
var knownStocks = map[string]string{
// A股
"拓维信息": "sz002261", "比亚迪": "sz002594", "宁德时代": "sz300750",
"贵州茅台": "sh600519", "中国平安": "sh601318", "招商银行": "sh600036",
"中芯国际": "sh688981", "工商银行": "sh601398", "建设银行": "sh601939",
"中国银行": "sh601988", "农业银行": "sh601288", "中信证券": "sh600030",
"海康威视": "sz002415", "立讯精密": "sz002475", "东方财富": "sz300059",
"隆基绿能": "sh601012", "长城汽车": "sh601633", "科大讯飞": "sz002230",
"三六零": "sh601360", "中兴通讯": "sz000063",
// 港股
"腾讯": "hk00700", "阿里巴巴": "hk09988", "美团": "hk03690",
"小米": "hk01810", "京东": "hk09618", "网易": "hk09999",
"百度": "hk09888", "快手": "hk01024", "哔哩哔哩": "hk09626",
"理想汽车": "hk02015", "蔚来": "hk09866", "小鹏汽车": "hk09868",
// 华为 is not publicly listed — removed incorrect Tencent fallback
// 美股
"苹果": "gb_aapl", "特斯拉": "gb_tsla", "英伟达": "gb_nvda",
"微软": "gb_msft", "谷歌": "gb_googl", "亚马逊": "gb_amzn",
"meta": "gb_meta", "奈飞": "gb_nflx", "台积电": "gb_tsm",
"拼多多": "gb_pdd", "蔚来汽车": "gb_nio",
}
// US stock ticker mapping
var usTickerMap = map[string]string{
"AAPL": "gb_aapl", "TSLA": "gb_tsla", "NVDA": "gb_nvda", "MSFT": "gb_msft",
"GOOGL": "gb_googl", "AMZN": "gb_amzn", "META": "gb_meta", "NFLX": "gb_nflx",
"TSM": "gb_tsm", "PDD": "gb_pdd", "NIO": "gb_nio", "BABA": "gb_baba",
"JD": "gb_jd", "BIDU": "gb_bidu", "AMD": "gb_amd", "INTC": "gb_intc",
"COIN": "gb_coin", "MARA": "gb_mara", "RIOT": "gb_riot",
}
func resolveStockCode(text string) (string, string) {
// Known Chinese names
for name, code := range knownStocks {
if strings.Contains(text, name) {
return code, name
}
}
// US ticker symbols (uppercase)
upper := strings.ToUpper(text)
for ticker, code := range usTickerMap {
if strings.Contains(upper, ticker) {
return code, ticker
}
}
// 6-digit A-share code
for _, w := range strings.Fields(text) {
w = strings.TrimSpace(w)
if len(w) == 6 {
if _, err := strconv.Atoi(w); err == nil {
prefix := "sz"
if w[0] == '6' || w[0] == '9' { prefix = "sh" }
return prefix + w, w
}
}
// 5-digit HK code
if len(w) == 5 {
if _, err := strconv.Atoi(w); err == nil {
return "hk" + w, w
}
}
}
return "", ""
}
// SearchResult represents a stock search result from Sina suggest API.
type SearchResult struct {
Name string // Display name
Code string // Sina-style code (e.g. sz300750, hk00700, gb_tsla)
Ticker string // Raw ticker (e.g. 300750, 00700, tsla)
Type string // Market type code: 11=A股, 31=港股, 41=美股
Market string // "A股", "港股", "美股"
}
// searchStock queries Sina's suggest API for dynamic stock search.
// Returns matching stocks across A-share, HK, and US markets.
func searchStock(keyword string) ([]SearchResult, error) {
// type=11 (A股), 31 (港股), 41 (美股)
u := fmt.Sprintf("https://suggest3.sinajs.cn/suggest/type=11,31,41&key=%s&name=suggestdata",
url.QueryEscape(keyword))
req, _ := http.NewRequest("GET", u, nil)
req.Header.Set("Referer", "https://finance.sina.com.cn")
resp, err := stockHTTPClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("stock search API returned status %d", resp.StatusCode)
}
reader := transform.NewReader(io.LimitReader(resp.Body, 256*1024), simplifiedchinese.GBK.NewDecoder())
body, err := safe.ReadAllLimited(reader)
if err != nil {
return nil, err
}
line := string(body)
// Parse: var suggestdata="item1;item2;..."
start := strings.Index(line, "\"")
end := strings.LastIndex(line, "\"")
if start == -1 || end <= start {
return nil, fmt.Errorf("invalid suggest response")
}
data := line[start+1 : end]
if data == "" {
return nil, nil // no results
}
var results []SearchResult
items := strings.Split(data, ";")
for _, item := range items {
item = strings.TrimSpace(item)
if item == "" {
continue
}
fields := strings.Split(item, ",")
if len(fields) < 5 {
continue
}
// fields: [0]=name, [1]=type, [2]=ticker, [3]=sinaCode, [4]=displayName
typeCode := fields[1]
ticker := fields[2]
sinaCode := fields[3]
displayName := fields[4]
if displayName == "" {
displayName = fields[0]
}
var mkt, code string
switch typeCode {
case "11": // A股
mkt = "A股"
code = sinaCode // already like sz300750, sh600519
if code == "" {
// Build from ticker
prefix := "sz"
if len(ticker) == 6 && (ticker[0] == '6' || ticker[0] == '9') {
prefix = "sh"
}
code = prefix + ticker
}
case "31": // 港股
mkt = "港股"
code = "hk" + ticker
case "41": // 美股
mkt = "美股"
code = "gb_" + ticker
default:
continue // skip funds (201), indices, etc.
}
results = append(results, SearchResult{
Name: displayName,
Code: code,
Ticker: ticker,
Type: typeCode,
Market: mkt,
})
}
return results, nil
}
// resolveStockCodeDynamic tries local map first, then falls back to Sina search API.
func resolveStockCodeDynamic(text string) (string, string) {
// First try the static map
code, name := resolveStockCode(text)
if code != "" {
return code, name
}
// Fall back to Sina search API
// Extract a meaningful search keyword from the text
keyword := extractStockKeyword(text)
if keyword == "" {
return "", ""
}
results, err := searchStock(keyword)
if err != nil || len(results) == 0 {
return "", ""
}
// Return the first (best) result
return results[0].Code, results[0].Name
}
// extractStockKeyword extracts a likely stock name/ticker from user text.
func extractStockKeyword(text string) string {
// Remove common prefixes/suffixes that aren't stock names
text = strings.TrimSpace(text)
// If the text itself is short enough, use it directly
// (e.g. "中远海控" or "AAPL")
if len([]rune(text)) <= 10 {
return text
}
// Try to extract quoted terms first: 「xxx」 or "xxx"
quotePairs := [][2]string{
{"「", "」"},
{"\u201c", "\u201d"},
{"\u2018", "\u2019"},
{"\"", "\""},
}
for _, pair := range quotePairs {
if s := strings.Index(text, pair[0]); s >= 0 {
if e := strings.Index(text[s+len(pair[0]):], pair[1]); e >= 0 {
return text[s+len(pair[0]) : s+len(pair[0])+e]
}
}
}
// Look for patterns like "查 XXX", "搜索 XXX", "查一下 XXX"
for _, prefix := range []string{"查一下", "搜索", "查询", "看看", "搜一下", "查", "看", "search ", "find "} {
if idx := strings.Index(text, prefix); idx >= 0 {
rest := strings.TrimSpace(text[idx+len(prefix):])
// Take the first "word" (either Chinese characters or English word)
words := strings.Fields(rest)
if len(words) > 0 {
return words[0]
}
}
}
// Last resort: use first few words
words := strings.Fields(text)
if len(words) > 0 {
return words[0]
}
return ""
}
func fetchStockQuote(code string) (*StockQuote, error) {
url := fmt.Sprintf("https://hq.sinajs.cn/list=%s", code)
req, _ := http.NewRequest("GET", url, nil)
req.Header.Set("Referer", "https://finance.sina.com.cn")
resp, err := stockHTTPClient.Do(req)
if err != nil { return nil, err }
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("stock quote API returned status %d", resp.StatusCode)
}
reader := transform.NewReader(io.LimitReader(resp.Body, 256*1024), simplifiedchinese.GBK.NewDecoder())
body, err := safe.ReadAllLimited(reader)
if err != nil { return nil, err }
line := string(body)
start := strings.Index(line, "\"")
end := strings.LastIndex(line, "\"")
if start == -1 || end <= start { return nil, fmt.Errorf("invalid response") }
data := line[start+1 : end]
if data == "" { return nil, fmt.Errorf("empty data for %s", code) }
if strings.HasPrefix(code, "sh") || strings.HasPrefix(code, "sz") {
return parseAShare(code, data)
} else if strings.HasPrefix(code, "hk") {
return parseHKShare(code, data)
} else if strings.HasPrefix(code, "gb_") {
return parseUSShare(code, data)
}
return nil, fmt.Errorf("unsupported market: %s", code)
}
func parseAShare(code, data string) (*StockQuote, error) {
f := strings.Split(data, ",")
if len(f) < 32 { return nil, fmt.Errorf("too few fields") }
q := &StockQuote{Name: f[0], Code: code, Market: "A股", Currency: "CNY"}
q.Open, _ = strconv.ParseFloat(f[1], 64)
q.PrevClose, _ = strconv.ParseFloat(f[2], 64)
q.Price, _ = strconv.ParseFloat(f[3], 64)
q.High, _ = strconv.ParseFloat(f[4], 64)
q.Low, _ = strconv.ParseFloat(f[5], 64)
q.Volume, _ = strconv.ParseFloat(f[8], 64)
q.Turnover, _ = strconv.ParseFloat(f[9], 64)
q.Date = f[30]; q.Time = f[31]
if q.PrevClose > 0 { q.Change = q.Price - q.PrevClose; q.ChangePct = (q.Change / q.PrevClose) * 100 }
return q, nil
}
func parseHKShare(code, data string) (*StockQuote, error) {
f := strings.Split(data, ",")
if len(f) < 18 { return nil, fmt.Errorf("too few fields") }
q := &StockQuote{Name: f[1], Code: code, Market: "港股", Currency: "HKD"}
q.PrevClose, _ = strconv.ParseFloat(f[3], 64)
q.Open, _ = strconv.ParseFloat(f[2], 64)
q.High, _ = strconv.ParseFloat(f[4], 64)
q.Low, _ = strconv.ParseFloat(f[5], 64)
q.Price, _ = strconv.ParseFloat(f[6], 64)
q.Change, _ = strconv.ParseFloat(f[7], 64)
q.ChangePct, _ = strconv.ParseFloat(f[8], 64)
q.Turnover, _ = strconv.ParseFloat(f[10], 64)
q.Volume, _ = strconv.ParseFloat(f[11], 64)
if len(f) > 17 { q.Date = f[17]; q.Time = f[17] }
return q, nil
}
func parseUSShare(code, data string) (*StockQuote, error) {
f := strings.Split(data, ",")
if len(f) < 30 { return nil, fmt.Errorf("too few fields") }
q := &StockQuote{Name: f[0], Code: code, Market: "美股", Currency: "USD"}
q.Price, _ = strconv.ParseFloat(f[1], 64)
q.ChangePct, _ = strconv.ParseFloat(f[2], 64)
q.Change, _ = strconv.ParseFloat(f[4], 64)
q.Open, _ = strconv.ParseFloat(f[5], 64)
q.High, _ = strconv.ParseFloat(f[6], 64)
q.Low, _ = strconv.ParseFloat(f[7], 64)
// 52wk high/low
high52, _ := strconv.ParseFloat(f[8], 64)
low52, _ := strconv.ParseFloat(f[9], 64)
q.Volume, _ = strconv.ParseFloat(f[10], 64)
q.Turnover, _ = strconv.ParseFloat(f[11], 64)
if len(f) > 25 { q.Date = f[25]; q.Time = f[26] }
q.PrevClose = q.Price - q.Change
_ = high52; _ = low52
// 盘前盘后数据 (字段21=价格, 22=涨跌幅%, 23=涨跌额, 24=时间)
if len(f) > 24 {
extPrice, _ := strconv.ParseFloat(f[21], 64)
extPct, _ := strconv.ParseFloat(f[22], 64)
extChg, _ := strconv.ParseFloat(f[23], 64)
if extPrice > 0 {
q.ExtPrice = extPrice
q.ExtChangePct = extPct
q.ExtChange = extChg
q.ExtTime = strings.TrimSpace(f[24])
q.IsExtHours = true
}
}
return q, nil
}
func formatStockQuote(q *StockQuote) string {
emoji := "🟢"
if q.ChangePct < 0 { emoji = "🔴" }
sym := "¥"
if q.Currency == "USD" { sym = "$" }
if q.Currency == "HKD" { sym = "HK$" }
volStr := fmt.Sprintf("%.0f", q.Volume)
if q.Volume > 1000000 { volStr = fmt.Sprintf("%.1f万", q.Volume/10000) }
if q.Volume > 100000000 { volStr = fmt.Sprintf("%.2f亿", q.Volume/100000000) }
turnStr := fmt.Sprintf("%.0f", q.Turnover)
if q.Turnover > 100000000 { turnStr = fmt.Sprintf("%.2f亿", q.Turnover/100000000) }
result := fmt.Sprintf(`%s *%s* (%s · %s)
💰 现价: %s%.2f (%+.2f%%)
📊 开盘: %s%.2f | 昨收: %s%.2f
📈 最高: %s%.2f | 最低: %s%.2f
📦 成交: %s | 额: %s
🕐 %s`,
emoji, q.Name, q.Code, q.Market,
sym, q.Price, q.ChangePct,
sym, q.Open, sym, q.PrevClose,
sym, q.High, sym, q.Low,
volStr, turnStr,
q.Date)
// 盘前盘后数据
if q.IsExtHours && q.ExtPrice > 0 {
extEmoji := "🟢"
if q.ExtChangePct < 0 { extEmoji = "🔴" }
extLabel := "🌙 盘后"
if strings.Contains(strings.ToLower(q.ExtTime), "am") {
extLabel = "🌅 盘前"
}
result += fmt.Sprintf("\n%s %s: %s%.2f (%+.2f%%) %s",
extLabel, extEmoji, sym, q.ExtPrice, q.ExtChangePct, q.ExtTime)
}
return result
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,65 +0,0 @@
package agent
import "testing"
func TestIsStockSymbol(t *testing.T) {
tests := []struct {
sym string
want bool
}{
// Known crypto base symbols — must NOT be detected as stock
{"BTC", false},
{"ETH", false},
{"SOL", false},
{"BNB", false},
{"XRP", false},
{"DOGE", false},
{"ADA", false},
{"AVAX", false},
{"DOT", false},
{"LINK", false},
{"PEPE", false},
{"SHIB", false},
{"TRUMP", false},
{"USDT", false},
{"USDC", false},
{"W", false}, // single letter crypto
// Crypto pairs — must NOT be stock
{"BTCUSDT", false},
{"ETHUSDT", false},
{"SOLUSDT", false},
{"DOGEUSDT", false},
// Real stock tickers — must be detected as stock
{"AAPL", true},
{"TSLA", true},
{"NVDA", true},
{"MSFT", true},
{"GOOGL", true},
{"AMZN", true},
{"META", true},
{"AMD", true},
{"PLTR", true},
{"BA", true},
{"F", true}, // Ford — 1 letter
{"GM", true}, // 2 letters
{"JPM", true}, // 3 letters
// Mixed / edge cases
{"btc", false}, // lowercase crypto
{"aapl", true}, // lowercase stock (uppercased internally)
{"BTC123", false}, // not pure letters
{"123456", false}, // digits
{"", false},
}
for _, tt := range tests {
t.Run(tt.sym, func(t *testing.T) {
got := isStockSymbol(tt.sym)
if got != tt.want {
t.Errorf("isStockSymbol(%q) = %v, want %v", tt.sym, got, tt.want)
}
})
}
}

View File

@@ -1,356 +0,0 @@
package agent
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"strings"
"sync"
"time"
)
// TradeAction represents a parsed trade intent from the LLM or user.
type TradeAction struct {
ID string `json:"id"`
Action string `json:"action"` // "open_long", "open_short", "close_long", "close_short"
Symbol string `json:"symbol"` // e.g. "BTCUSDT"
Quantity float64 `json:"quantity"` // amount
Leverage int `json:"leverage"` // leverage multiplier
TraderID string `json:"trader_id"` // which trader to use
Status string `json:"status"` // "pending", "confirmed", "executed", "failed", "expired"
CreatedAt int64 `json:"created_at"`
Error string `json:"error,omitempty"`
}
// pendingTrades stores pending trade confirmations.
type pendingTrades struct {
mu sync.RWMutex
trades map[string]*TradeAction // id -> trade
}
func newPendingTrades() *pendingTrades {
return &pendingTrades{trades: make(map[string]*TradeAction)}
}
func (p *pendingTrades) Add(t *TradeAction) {
p.mu.Lock()
defer p.mu.Unlock()
p.trades[t.ID] = t
}
func (p *pendingTrades) Get(id string) *TradeAction {
p.mu.RLock()
defer p.mu.RUnlock()
return p.trades[id]
}
func (p *pendingTrades) Remove(id string) {
p.mu.Lock()
defer p.mu.Unlock()
delete(p.trades, id)
}
// CleanExpired removes trades older than 5 minutes.
func (p *pendingTrades) CleanExpired() {
p.mu.Lock()
defer p.mu.Unlock()
cutoff := time.Now().Add(-5 * time.Minute).Unix()
for id, t := range p.trades {
if t.CreatedAt < cutoff {
delete(p.trades, id)
}
}
}
// parseTradeCommand parses natural language trade commands.
// Returns nil if the message is not a trade command.
func parseTradeCommand(text string) *TradeAction {
upper := strings.ToUpper(strings.TrimSpace(text))
// Pattern: "做多 BTC 0.01" / "做空 ETH 0.1" / "long BTC 0.01" / "short ETH 0.1"
// Also: "平多 BTC" / "平空 ETH" / "close long BTC" / "close short ETH"
var action, symbol string
var quantity float64
var leverage int
words := strings.Fields(upper)
if len(words) < 2 {
return nil
}
switch words[0] {
case "做多", "LONG", "BUY":
action = "open_long"
case "做空", "SHORT", "SELL":
action = "open_short"
case "平多":
action = "close_long"
case "平空":
action = "close_short"
case "CLOSE":
if len(words) >= 3 {
switch words[1] {
case "LONG":
action = "close_long"
words = append(words[:1], words[2:]...) // remove "LONG"
case "SHORT":
action = "close_short"
words = append(words[:1], words[2:]...) // remove "SHORT"
}
}
if action == "" {
return nil
}
default:
return nil
}
// Parse symbol
if len(words) < 2 {
return nil
}
symbol = words[1]
// Only append USDT for crypto symbols, not stock tickers
if !isStockSymbol(symbol) && !strings.HasSuffix(symbol, "USDT") {
symbol += "USDT"
}
// Parse quantity (optional)
if len(words) >= 3 {
fmt.Sscanf(words[2], "%f", &quantity)
}
// Parse leverage (optional, "x10" or "10x")
if len(words) >= 4 {
lev := strings.TrimSuffix(strings.TrimPrefix(words[3], "X"), "X")
fmt.Sscanf(lev, "%d", &leverage)
}
if action == "" || symbol == "" {
return nil
}
return &TradeAction{
ID: fmt.Sprintf("trade_%d", time.Now().UnixNano()),
Action: action,
Symbol: symbol,
Quantity: quantity,
Leverage: leverage,
Status: "pending",
CreatedAt: time.Now().Unix(),
}
}
// executeTrade performs the actual trade execution via TraderManager.
func (a *Agent) executeTrade(ctx context.Context, trade *TradeAction) error {
if a.traderManager == nil {
return fmt.Errorf("no trader manager available")
}
traders := a.traderManager.GetAllTraders()
if len(traders) == 0 {
return fmt.Errorf("no traders configured")
}
// Determine if this is a stock trade to route to the right exchange
wantStock := isStockSymbol(trade.Symbol)
// Find a running trader's underlying exchange interface
var underlyingTrader interface {
OpenLong(symbol string, quantity float64, leverage int) (map[string]interface{}, error)
OpenShort(symbol string, quantity float64, leverage int) (map[string]interface{}, error)
CloseLong(symbol string, quantity float64) (map[string]interface{}, error)
CloseShort(symbol string, quantity float64) (map[string]interface{}, error)
}
for _, t := range traders {
s := t.GetStatus()
running, _ := s["is_running"].(bool)
if running {
ut := t.GetUnderlyingTrader()
if ut == nil {
continue
}
// Route stock symbols to alpaca traders, crypto to others
exchange := t.GetExchange()
isAlpaca := exchange == "alpaca"
if wantStock && !isAlpaca {
continue // Skip non-stock traders for stock symbols
}
if !wantStock && isAlpaca {
continue // Skip stock traders for crypto symbols
}
underlyingTrader = ut
break
}
}
if underlyingTrader == nil {
if wantStock {
return fmt.Errorf("no running stock trader (Alpaca) found — configure one to trade stocks")
}
return fmt.Errorf("no running trader supports trade execution")
}
// Sanity caps to prevent LLM hallucinations or input errors from causing damage.
const maxQuantity = 100000.0
const maxLeverage = 125
if trade.Leverage > maxLeverage {
return fmt.Errorf("leverage %dx exceeds maximum allowed (%dx)", trade.Leverage, maxLeverage)
}
switch trade.Action {
case "open_long":
if trade.Quantity <= 0 {
return fmt.Errorf("quantity must be > 0")
}
if trade.Quantity > maxQuantity {
return fmt.Errorf("quantity %.4f exceeds maximum allowed (%.0f)", trade.Quantity, maxQuantity)
}
_, err := underlyingTrader.OpenLong(trade.Symbol, trade.Quantity, trade.Leverage)
return err
case "open_short":
if trade.Quantity <= 0 {
return fmt.Errorf("quantity must be > 0")
}
if trade.Quantity > maxQuantity {
return fmt.Errorf("quantity %.4f exceeds maximum allowed (%.0f)", trade.Quantity, maxQuantity)
}
_, err := underlyingTrader.OpenShort(trade.Symbol, trade.Quantity, trade.Leverage)
return err
case "close_long":
_, err := underlyingTrader.CloseLong(trade.Symbol, trade.Quantity)
return err
case "close_short":
_, err := underlyingTrader.CloseShort(trade.Symbol, trade.Quantity)
return err
default:
return fmt.Errorf("unknown action: %s", trade.Action)
}
}
// formatTradeConfirmation creates a confirmation message for a pending trade.
func formatTradeConfirmation(trade *TradeAction, lang string) string {
actionNames := map[string]string{
"open_long": "做多 (Long)",
"open_short": "做空 (Short)",
"close_long": "平多 (Close Long)",
"close_short": "平空 (Close Short)",
}
symbol := trade.Symbol
if strings.HasSuffix(symbol, "USDT") {
symbol = strings.TrimSuffix(symbol, "USDT")
}
actionName := actionNames[trade.Action]
if actionName == "" {
actionName = trade.Action
}
if lang == "zh" {
msg := fmt.Sprintf("⚠️ **交易确认**\n\n"+
"操作: %s\n"+
"品种: %s\n", actionName, symbol)
if trade.Quantity > 0 {
msg += fmt.Sprintf("数量: %.4f\n", trade.Quantity)
}
if trade.Leverage > 0 {
msg += fmt.Sprintf("杠杆: %dx\n", trade.Leverage)
}
msg += fmt.Sprintf("\n发送 `确认 %s` 执行交易,或忽略取消。", trade.ID)
return msg
}
msg := fmt.Sprintf("⚠️ **Trade Confirmation**\n\n"+
"Action: %s\n"+
"Symbol: %s\n", actionName, symbol)
if trade.Quantity > 0 {
msg += fmt.Sprintf("Quantity: %.4f\n", trade.Quantity)
}
if trade.Leverage > 0 {
msg += fmt.Sprintf("Leverage: %dx\n", trade.Leverage)
}
msg += fmt.Sprintf("\nSend `confirm %s` to execute, or ignore to cancel.", trade.ID)
return msg
}
// handleTradeConfirmation processes a trade confirmation message.
func (a *Agent) handleTradeConfirmation(ctx context.Context, userID int64, text, lang string) (string, bool) {
upper := strings.ToUpper(strings.TrimSpace(text))
var tradeID string
if strings.HasPrefix(upper, "确认 ") || strings.HasPrefix(upper, "CONFIRM ") {
parts := strings.Fields(text)
if len(parts) >= 2 {
tradeID = parts[1]
}
}
if tradeID == "" {
return "", false
}
if a.pending == nil {
return "", false
}
trade := a.pending.Get(tradeID)
if trade == nil {
if lang == "zh" {
return "❌ 交易已过期或不存在。", true
}
return "❌ Trade expired or not found.", true
}
a.pending.Remove(tradeID)
trade.Status = "confirmed"
a.logger.Info("executing trade",
slog.String("id", trade.ID),
slog.String("action", trade.Action),
slog.String("symbol", trade.Symbol),
slog.Float64("quantity", trade.Quantity),
)
err := a.executeTrade(ctx, trade)
if err != nil {
trade.Status = "failed"
trade.Error = err.Error()
if lang == "zh" {
return fmt.Sprintf("❌ 交易执行失败: %s", err.Error()), true
}
return fmt.Sprintf("❌ Trade execution failed: %s", err.Error()), true
}
trade.Status = "executed"
symbol := trade.Symbol
if strings.HasSuffix(symbol, "USDT") {
symbol = strings.TrimSuffix(symbol, "USDT")
}
actionEmoji := "📈"
if strings.Contains(trade.Action, "short") {
actionEmoji = "📉"
}
if strings.Contains(trade.Action, "close") {
actionEmoji = "✅"
}
qtyStr := ""
if trade.Quantity > 0 {
qtyStr = fmt.Sprintf(" %.4f", trade.Quantity)
}
if lang == "zh" {
return fmt.Sprintf("%s 交易已执行!\n%s %s%s", actionEmoji, trade.Action, symbol, qtyStr), true
}
return fmt.Sprintf("%s Trade executed!\n%s %s%s", actionEmoji, trade.Action, symbol, qtyStr), true
}
// marshals trade action to JSON for embedding in responses
func marshalTradeAction(trade *TradeAction) string {
b, _ := json.Marshal(trade)
return string(b)
}

View File

@@ -1,343 +0,0 @@
package agent
import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"nofx/safe"
"regexp"
"time"
)
type storeUserIDContextKey struct{}
// WithStoreUserID annotates an HTTP request context with the authenticated store user ID.
func WithStoreUserID(ctx context.Context, storeUserID string) context.Context {
return context.WithValue(ctx, storeUserIDContextKey{}, storeUserID)
}
func storeUserIDFromContext(ctx context.Context) string {
if v, ok := ctx.Value(storeUserIDContextKey{}).(string); ok && v != "" {
return v
}
return "default"
}
// validSymbolRe matches only alphanumeric trading symbols (e.g. BTCUSDT, ETH-USD).
var validSymbolRe = regexp.MustCompile(`^[A-Za-z0-9\-_]{1,20}$`)
// validIntervalRe matches only valid kline intervals (e.g. 1m, 5m, 1h, 4h, 1d, 1w).
var validIntervalRe = regexp.MustCompile(`^[0-9]{1,2}[mhHdDwWM]$`)
// binanceClient is a shared HTTP client for proxying Binance API requests.
// Reused across requests to benefit from connection pooling.
var binanceClient = &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
MaxIdleConns: 20,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
},
}
// WebHandler provides HTTP endpoints for the NOFXi agent.
type WebHandler struct {
agent *Agent
logger *slog.Logger
}
func NewWebHandler(agent *Agent, logger *slog.Logger) *WebHandler {
return &WebHandler{agent: agent, logger: logger}
}
// HandleHealth handles GET /api/agent/health.
func (w *WebHandler) HandleHealth(rw http.ResponseWriter, r *http.Request) {
writeJSON(rw, 200, map[string]string{"status": "ok", "agent": "NOFXi", "time": time.Now().Format(time.RFC3339)})
}
// HandleChat handles POST /api/agent/chat.
func (w *WebHandler) HandleChat(rw http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(rw, "method not allowed", 405)
return
}
var req struct {
Message string `json:"message"`
UserID int64 `json:"user_id"`
UserKey string `json:"user_key"`
Lang string `json:"lang"`
}
// Limit request body to 64KB to prevent abuse
if err := json.NewDecoder(io.LimitReader(r.Body, 64*1024)).Decode(&req); err != nil {
writeJSON(rw, 400, map[string]string{"error": "invalid request"})
return
}
if req.Message == "" {
writeJSON(rw, 400, map[string]string{"error": "message required"})
return
}
if req.UserID == 0 {
req.UserID = SessionUserIDFromKey(req.UserKey)
}
msg := req.Message
if req.Lang != "" {
msg = "[lang:" + req.Lang + "] " + msg
}
ctx, cancel := context.WithTimeout(r.Context(), 55*time.Second)
defer cancel()
resp, err := w.agent.HandleMessageForStoreUser(ctx, storeUserIDFromContext(r.Context()), req.UserID, msg)
if err != nil {
w.logger.Error("agent HandleMessage failed", "error", err, "user_id", req.UserID)
writeJSON(rw, 500, map[string]string{"error": "Failed to process message. Please try again."})
return
}
writeJSON(rw, 200, map[string]string{"response": resp})
}
// HandleChatStream handles POST /api/agent/chat/stream — SSE streaming chat.
// Sends server-sent events with types including planning, plan, step_start,
// step_complete, replan, tool, delta, done, error.
func (w *WebHandler) HandleChatStream(rw http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(rw, "method not allowed", 405)
return
}
var req struct {
Message string `json:"message"`
UserID int64 `json:"user_id"`
UserKey string `json:"user_key"`
Lang string `json:"lang"`
}
if err := json.NewDecoder(io.LimitReader(r.Body, 64*1024)).Decode(&req); err != nil {
writeJSON(rw, 400, map[string]string{"error": "invalid request"})
return
}
if req.Message == "" {
writeJSON(rw, 400, map[string]string{"error": "message required"})
return
}
if req.UserID == 0 {
req.UserID = SessionUserIDFromKey(req.UserKey)
}
msg := req.Message
if req.Lang != "" {
msg = "[lang:" + req.Lang + "] " + msg
}
// Set SSE headers
rw.Header().Set("Content-Type", "text/event-stream")
rw.Header().Set("Cache-Control", "no-cache")
rw.Header().Set("Connection", "keep-alive")
rw.Header().Set("X-Accel-Buffering", "no") // Disable nginx buffering
rw.WriteHeader(200)
flusher, ok := rw.(http.Flusher)
if !ok {
writeSSE(rw, nil, "error", "streaming not supported")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 120*time.Second)
defer cancel()
resp, err := w.agent.HandleMessageStreamForStoreUser(ctx, storeUserIDFromContext(r.Context()), req.UserID, msg, func(event, data string) {
writeSSE(rw, flusher, event, data)
})
if err != nil {
w.logger.Error("agent HandleMessageStream failed", "error", err, "user_id", req.UserID)
writeSSE(rw, flusher, "error", "Failed to process message. Please try again.")
return
}
// Send final done event with complete response
writeSSE(rw, flusher, "done", resp)
}
// writeSSE writes a single SSE event.
func writeSSE(w http.ResponseWriter, flusher http.Flusher, event, data string) {
fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event, sseEscape(data))
if flusher != nil {
flusher.Flush()
}
}
// sseEscape escapes newlines in SSE data (each line needs a "data: " prefix).
func sseEscape(s string) string {
// SSE spec: multi-line data uses multiple "data:" lines
// But we use JSON encoding to avoid this complexity
b, _ := json.Marshal(s)
return string(b)
}
// HandleKlines proxies kline data from Binance.
func (w *WebHandler) HandleKlines(rw http.ResponseWriter, r *http.Request) {
symbol := r.URL.Query().Get("symbol")
if symbol == "" {
symbol = "BTCUSDT"
}
interval := r.URL.Query().Get("interval")
if interval == "" {
interval = "1h"
}
if !validSymbolRe.MatchString(symbol) {
writeJSON(rw, 400, map[string]string{"error": "invalid symbol"})
return
}
if !validIntervalRe.MatchString(interval) {
writeJSON(rw, 400, map[string]string{"error": "invalid interval"})
return
}
proxyBinance(rw, r.Context(), fmt.Sprintf("https://fapi.binance.com/fapi/v1/klines?symbol=%s&interval=%s&limit=300", symbol, interval))
}
// HandleTicker proxies ticker data from Binance.
func (w *WebHandler) HandleTicker(rw http.ResponseWriter, r *http.Request) {
symbol := r.URL.Query().Get("symbol")
if symbol == "" {
symbol = "BTCUSDT"
}
if !validSymbolRe.MatchString(symbol) {
writeJSON(rw, 400, map[string]string{"error": "invalid symbol"})
return
}
proxyBinance(rw, r.Context(), fmt.Sprintf("https://fapi.binance.com/fapi/v1/ticker/24hr?symbol=%s", symbol))
}
// HandleTickers handles GET /api/agent/tickers?symbols=BTCUSDT,ETHUSDT,SOLUSDT
// Batch endpoint: fetches multiple tickers concurrently, returns array.
func (w *WebHandler) HandleTickers(rw http.ResponseWriter, r *http.Request) {
symbolsParam := r.URL.Query().Get("symbols")
if symbolsParam == "" {
symbolsParam = "BTCUSDT,ETHUSDT,SOLUSDT"
}
// Validate symbols
var symbols []string
for _, s := range splitComma(symbolsParam) {
if validSymbolRe.MatchString(s) {
symbols = append(symbols, s)
}
}
if len(symbols) == 0 {
writeJSON(rw, 400, map[string]string{"error": "no valid symbols"})
return
}
if len(symbols) > 20 {
writeJSON(rw, 400, map[string]string{"error": "max 20 symbols"})
return
}
// Fetch all tickers concurrently with context propagation
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
type result struct {
idx int
data json.RawMessage
}
results := make(chan result, len(symbols))
for i, sym := range symbols {
idx, s := i, sym
safe.GoNamed("ticker-fetch-"+s, func() {
req, err := http.NewRequestWithContext(ctx, "GET",
fmt.Sprintf("https://fapi.binance.com/fapi/v1/ticker/24hr?symbol=%s", s), nil)
if err != nil {
results <- result{idx: idx}
return
}
resp, err := binanceClient.Do(req)
if err != nil {
results <- result{idx: idx}
return
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
results <- result{idx: idx}
return
}
body, err := safe.ReadAllLimited(resp.Body, 16*1024)
if err != nil {
results <- result{idx: idx}
return
}
results <- result{idx: idx, data: body}
})
}
// Collect results in order
ordered := make([]json.RawMessage, len(symbols))
for range symbols {
r := <-results
if r.data != nil {
ordered[r.idx] = r.data
}
}
// Filter out nil entries and write response
out := make([]json.RawMessage, 0, len(ordered))
for _, d := range ordered {
if d != nil {
out = append(out, d)
}
}
rw.Header().Set("Content-Type", "application/json")
json.NewEncoder(rw).Encode(out)
}
// commaRe is pre-compiled for splitComma — avoids recompiling on every call.
var commaRe = regexp.MustCompile(`\s*,\s*`)
// splitComma splits a comma-separated string, trims whitespace, skips empty.
func splitComma(s string) []string {
var parts []string
for _, p := range commaRe.Split(s, -1) {
if p != "" {
parts = append(parts, p)
}
}
return parts
}
func proxyBinance(rw http.ResponseWriter, ctx context.Context, url string) {
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
writeJSON(rw, 500, map[string]string{"error": "failed to create request"})
return
}
resp, err := binanceClient.Do(req)
if err != nil {
// Distinguish client cancellation from upstream failures
if ctx.Err() != nil {
return // Client disconnected, no point writing response
}
writeJSON(rw, 502, map[string]string{"error": "upstream request failed"})
return
}
defer resp.Body.Close()
// Forward upstream error status codes instead of silently proxying bad data
if resp.StatusCode != http.StatusOK {
writeJSON(rw, 502, map[string]string{"error": fmt.Sprintf("upstream returned status %d", resp.StatusCode)})
return
}
rw.Header().Set("Content-Type", "application/json")
// CORS is handled by the gin middleware — no need to set it here
// Limit response body to 2MB to prevent memory exhaustion
io.Copy(rw, io.LimitReader(resp.Body, 2*1024*1024))
}
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
w.Header().Set("Content-Type", "application/json")
// CORS is handled by the gin middleware — no need to set it here
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}

View File

@@ -1,521 +0,0 @@
package agent
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"nofx/mcp"
)
const (
workflowTaskPending = "pending"
workflowTaskRunning = "running"
workflowTaskCompleted = "completed"
workflowTaskFailed = "failed"
)
type WorkflowTask struct {
ID string `json:"id,omitempty"`
Skill string `json:"skill,omitempty"`
Action string `json:"action,omitempty"`
Request string `json:"request,omitempty"`
DependsOn []string `json:"depends_on,omitempty"`
Status string `json:"status,omitempty"`
Error string `json:"error,omitempty"`
}
type WorkflowSession struct {
UserID int64 `json:"user_id"`
OriginalRequest string `json:"original_request,omitempty"`
Tasks []WorkflowTask `json:"tasks,omitempty"`
UpdatedAt string `json:"updated_at,omitempty"`
}
type workflowDecomposition struct {
Tasks []WorkflowTask `json:"tasks"`
}
func workflowSessionConfigKey(userID int64) string {
return fmt.Sprintf("agent_workflow_session_%d", userID)
}
func normalizeWorkflowSession(session WorkflowSession) WorkflowSession {
session.OriginalRequest = strings.TrimSpace(session.OriginalRequest)
normalized := make([]WorkflowTask, 0, len(session.Tasks))
for i, task := range session.Tasks {
task.ID = strings.TrimSpace(task.ID)
if task.ID == "" {
task.ID = fmt.Sprintf("task_%d", i+1)
}
task.Skill = strings.TrimSpace(task.Skill)
task.Action = normalizeAtomicSkillAction(task.Skill, task.Action)
task.Request = strings.TrimSpace(task.Request)
task.DependsOn = cleanStringList(task.DependsOn)
task.Status = strings.TrimSpace(task.Status)
if task.Status == "" {
task.Status = workflowTaskPending
}
task.Error = strings.TrimSpace(task.Error)
if task.Skill == "" || task.Action == "" || task.Request == "" {
continue
}
normalized = append(normalized, task)
}
session.Tasks = normalized
if len(session.Tasks) == 0 {
return WorkflowSession{}
}
if session.UpdatedAt == "" {
session.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
}
return session
}
func (a *Agent) getWorkflowSession(userID int64) WorkflowSession {
if a.store == nil {
return WorkflowSession{}
}
raw, err := a.store.GetSystemConfig(workflowSessionConfigKey(userID))
if err != nil || strings.TrimSpace(raw) == "" {
return WorkflowSession{}
}
var session WorkflowSession
if err := json.Unmarshal([]byte(raw), &session); err != nil {
return WorkflowSession{}
}
return normalizeWorkflowSession(session)
}
func (a *Agent) saveWorkflowSession(userID int64, session WorkflowSession) {
if a.store == nil {
return
}
session = normalizeWorkflowSession(session)
if len(session.Tasks) == 0 {
_ = a.store.SetSystemConfig(workflowSessionConfigKey(userID), "")
return
}
session.UserID = userID
session.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
data, err := json.Marshal(session)
if err != nil {
return
}
_ = a.store.SetSystemConfig(workflowSessionConfigKey(userID), string(data))
}
func (a *Agent) clearWorkflowSession(userID int64) {
if a.store == nil {
return
}
_ = a.store.SetSystemConfig(workflowSessionConfigKey(userID), "")
}
func hasActiveWorkflowSession(session WorkflowSession) bool {
if len(session.Tasks) == 0 {
return false
}
for _, task := range session.Tasks {
if task.Status == workflowTaskPending || task.Status == workflowTaskRunning {
return true
}
}
return false
}
func nextRunnableWorkflowTask(session WorkflowSession) (WorkflowTask, int, bool) {
for i, task := range session.Tasks {
if task.Status != workflowTaskPending && task.Status != workflowTaskRunning {
continue
}
depsReady := true
for _, dep := range task.DependsOn {
ok := false
for _, candidate := range session.Tasks {
if candidate.ID == dep && candidate.Status == workflowTaskCompleted {
ok = true
break
}
}
if !ok {
depsReady = false
break
}
}
if depsReady {
return task, i, true
}
}
return WorkflowTask{}, -1, false
}
func supportedWorkflowSkill(skill, action string) bool {
skill = strings.TrimSpace(skill)
action = normalizeAtomicSkillAction(skill, action)
if skill == "" || action == "" {
return false
}
if _, ok := getSkillDAG(skill, action); ok {
return true
}
switch skill {
case "trader_management", "strategy_management", "model_management", "exchange_management":
switch action {
case "create", "query_list", "query_detail", "query_running", "activate":
return true
}
}
return false
}
func (a *Agent) tryWorkflowIntent(ctx context.Context, storeUserID string, userID int64, lang, text string, onEvent func(event, data string)) (string, bool, error) {
if session := a.getWorkflowSession(userID); hasActiveWorkflowSession(session) {
return a.handleWorkflowSession(ctx, storeUserID, userID, lang, text, session, onEvent)
}
decomposition, err := a.decomposeWorkflowIntent(ctx, userID, lang, text)
if err != nil || len(decomposition.Tasks) <= 1 {
return "", false, err
}
session := WorkflowSession{
UserID: userID,
OriginalRequest: text,
Tasks: decomposition.Tasks,
}
a.saveWorkflowSession(userID, session)
return a.handleWorkflowSession(ctx, storeUserID, userID, lang, text, session, onEvent)
}
func (a *Agent) handleWorkflowSession(ctx context.Context, storeUserID string, userID int64, lang, text string, session WorkflowSession, onEvent func(event, data string)) (string, bool, error) {
if isExplicitFlowAbort(text) {
a.clearSkillSession(userID)
a.clearWorkflowSession(userID)
if lang == "zh" {
return "已取消当前任务流。", true, nil
}
return "Cancelled the current workflow.", true, nil
}
if activeSkill := a.getSkillSession(userID); strings.TrimSpace(activeSkill.Name) != "" {
answer, handled := a.tryHardSkill(ctx, storeUserID, userID, lang, text, onEvent)
if !handled {
return "", false, nil
}
session = a.getWorkflowSession(userID)
if hasActiveWorkflowSession(session) && strings.TrimSpace(a.getSkillSession(userID).Name) == "" {
session = markCurrentWorkflowTask(session, workflowTaskCompleted, "")
a.saveWorkflowSession(userID, session)
if final, done, err := a.maybeAdvanceWorkflow(ctx, storeUserID, userID, lang, session, onEvent); done || err != nil {
if final != "" && answer != "" {
return answer + "\n\n" + final, true, err
}
if answer != "" {
return answer, true, err
}
return final, true, err
}
}
return answer, true, nil
}
return a.maybeAdvanceWorkflow(ctx, storeUserID, userID, lang, session, onEvent)
}
func (a *Agent) maybeAdvanceWorkflow(ctx context.Context, storeUserID string, userID int64, lang string, session WorkflowSession, onEvent func(event, data string)) (string, bool, error) {
task, index, ok := nextRunnableWorkflowTask(session)
if !ok {
summary := a.generateWorkflowSummary(ctx, userID, lang, session)
a.clearWorkflowSession(userID)
if summary == "" {
if lang == "zh" {
summary = "已完成当前任务流。"
} else {
summary = "Completed the current workflow."
}
}
if onEvent != nil {
onEvent(StreamEventPlan, summary)
onEvent(StreamEventDelta, summary)
}
return summary, true, nil
}
session.Tasks[index].Status = workflowTaskRunning
a.saveWorkflowSession(userID, session)
taskSession := skillSession{Name: task.Skill, Action: task.Action, Phase: "collecting"}
a.saveSkillSession(userID, taskSession)
if onEvent != nil {
onEvent(StreamEventPlan, a.formatWorkflowStatus(lang, session))
onEvent(StreamEventTool, "workflow:"+task.Skill+":"+task.Action)
}
answer, handled := a.tryHardSkill(ctx, storeUserID, userID, lang, task.Request, onEvent)
if !handled {
session.Tasks[index].Status = workflowTaskFailed
session.Tasks[index].Error = "task_not_handled"
a.saveWorkflowSession(userID, session)
return "", false, nil
}
if strings.TrimSpace(a.getSkillSession(userID).Name) == "" {
session = a.getWorkflowSession(userID)
session = markCurrentWorkflowTask(session, workflowTaskCompleted, "")
a.saveWorkflowSession(userID, session)
if more, ok, err := a.maybeAdvanceWorkflow(ctx, storeUserID, userID, lang, session, onEvent); ok || err != nil {
if answer != "" && more != "" {
return answer + "\n\n" + more, true, err
}
if answer != "" {
return answer, true, err
}
return more, true, err
}
}
return answer, true, nil
}
func markCurrentWorkflowTask(session WorkflowSession, status, errMsg string) WorkflowSession {
for i := range session.Tasks {
if session.Tasks[i].Status == workflowTaskRunning {
session.Tasks[i].Status = status
session.Tasks[i].Error = strings.TrimSpace(errMsg)
return session
}
}
return session
}
func (a *Agent) formatWorkflowStatus(lang string, session WorkflowSession) string {
parts := make([]string, 0, len(session.Tasks))
for _, task := range session.Tasks {
label := task.Request
if label == "" {
label = task.Skill + ":" + task.Action
}
switch task.Status {
case workflowTaskCompleted:
label = "✓ " + label
case workflowTaskRunning:
label = "→ " + label
default:
label = "· " + label
}
parts = append(parts, label)
}
if lang == "zh" {
return "任务流:" + strings.Join(parts, " | ")
}
return "Workflow: " + strings.Join(parts, " | ")
}
func (a *Agent) generateWorkflowSummary(ctx context.Context, userID int64, lang string, session WorkflowSession) string {
completed := make([]string, 0, len(session.Tasks))
for _, task := range session.Tasks {
if task.Status == workflowTaskCompleted {
completed = append(completed, task.Request)
}
}
if len(completed) == 0 {
return ""
}
if a.aiClient == nil {
if lang == "zh" {
return "已完成这些任务:" + strings.Join(completed, "")
}
return "Completed these tasks: " + strings.Join(completed, "; ")
}
stageCtx, cancel := withPlannerStageTimeout(ctx, directReplyTimeout)
defer cancel()
systemPrompt := `You are summarizing a finished workflow for NOFXi.
Return one short user-facing summary in the user's language.
Do not mention internal DAG, scheduler, or JSON.`
userPrompt := fmt.Sprintf("Language: %s\nOriginal request: %s\nCompleted tasks:\n- %s", lang, session.OriginalRequest, strings.Join(completed, "\n- "))
raw, err := a.aiClient.CallWithRequest(&mcp.Request{
Messages: []mcp.Message{
mcp.NewSystemMessage(systemPrompt),
mcp.NewUserMessage(userPrompt),
},
Ctx: stageCtx,
})
if err != nil {
if lang == "zh" {
return "已完成这些任务:" + strings.Join(completed, "")
}
return "Completed these tasks: " + strings.Join(completed, "; ")
}
return strings.TrimSpace(raw)
}
func (a *Agent) decomposeWorkflowIntent(ctx context.Context, userID int64, lang, text string) (workflowDecomposition, error) {
if !looksLikeMultiTaskIntent(text) {
return workflowDecomposition{}, nil
}
if a.aiClient != nil {
if dec, err := a.decomposeWorkflowIntentWithLLM(ctx, userID, lang, text); err == nil && len(dec.Tasks) > 1 {
return dec, nil
}
}
return a.decomposeWorkflowIntentFallback(text), nil
}
func looksLikeMultiTaskIntent(text string) bool {
lower := strings.ToLower(strings.TrimSpace(text))
if lower == "" {
return false
}
connectors := []string{"", ",", "然后", "再", "并且", "并", "同时", "and", "then"}
count := 0
for _, c := range connectors {
if strings.Contains(lower, c) {
count++
}
}
return count > 0
}
func (a *Agent) decomposeWorkflowIntentWithLLM(ctx context.Context, userID int64, lang, text string) (workflowDecomposition, error) {
stageCtx, cancel := withPlannerStageTimeout(ctx, directReplyTimeout)
defer cancel()
systemPrompt := `You decompose one NOFXi user request into a small task graph.
Return JSON only. No markdown.
Only use these skills: trader_management, strategy_management, model_management, exchange_management.
Only use one atomic action per task.
Each task must include:
- id
- skill
- action
- request
- depends_on (array, may be empty)
If the request is effectively a single task, return one task only.`
userPrompt := fmt.Sprintf("Language: %s\nUser request: %s", lang, text)
raw, err := a.aiClient.CallWithRequest(&mcp.Request{
Messages: []mcp.Message{
mcp.NewSystemMessage(systemPrompt),
mcp.NewUserMessage(userPrompt),
},
Ctx: stageCtx,
})
if err != nil {
return workflowDecomposition{}, err
}
return parseWorkflowDecomposition(raw)
}
func parseWorkflowDecomposition(raw string) (workflowDecomposition, error) {
raw = strings.TrimSpace(raw)
raw = strings.TrimPrefix(raw, "```json")
raw = strings.TrimPrefix(raw, "```")
raw = strings.TrimSuffix(raw, "```")
raw = strings.TrimSpace(raw)
var out workflowDecomposition
if err := json.Unmarshal([]byte(raw), &out); err == nil {
out = normalizeWorkflowDecomposition(out)
return out, nil
}
start := strings.Index(raw, "{")
end := strings.LastIndex(raw, "}")
if start >= 0 && end > start {
if err := json.Unmarshal([]byte(raw[start:end+1]), &out); err == nil {
out = normalizeWorkflowDecomposition(out)
return out, nil
}
}
return workflowDecomposition{}, fmt.Errorf("invalid workflow json")
}
func normalizeWorkflowDecomposition(out workflowDecomposition) workflowDecomposition {
normalized := make([]WorkflowTask, 0, len(out.Tasks))
for i, task := range out.Tasks {
task.ID = strings.TrimSpace(task.ID)
if task.ID == "" {
task.ID = fmt.Sprintf("task_%d", i+1)
}
task.Skill = strings.TrimSpace(task.Skill)
task.Action = normalizeAtomicSkillAction(task.Skill, task.Action)
task.Request = strings.TrimSpace(task.Request)
task.DependsOn = cleanStringList(task.DependsOn)
if !supportedWorkflowSkill(task.Skill, task.Action) || task.Request == "" {
continue
}
task.Status = workflowTaskPending
normalized = append(normalized, task)
}
out.Tasks = normalized
return out
}
func (a *Agent) decomposeWorkflowIntentFallback(text string) workflowDecomposition {
segments := splitWorkflowSegments(text)
tasks := make([]WorkflowTask, 0, len(segments))
for i, segment := range segments {
task, ok := classifyWorkflowTask(segment)
if !ok {
continue
}
task.ID = fmt.Sprintf("task_%d", i+1)
task.Status = workflowTaskPending
if len(tasks) > 0 {
task.DependsOn = []string{tasks[len(tasks)-1].ID}
}
tasks = append(tasks, task)
}
return workflowDecomposition{Tasks: tasks}
}
func splitWorkflowSegments(text string) []string {
parts := []string{strings.TrimSpace(text)}
separators := []string{"", ",", "然后", "再", "并且", "同时", " and then ", " then ", " and "}
for _, sep := range separators {
next := make([]string, 0, len(parts))
for _, part := range parts {
split := strings.Split(part, sep)
for _, candidate := range split {
candidate = strings.TrimSpace(candidate)
if candidate != "" {
next = append(next, candidate)
}
}
}
parts = next
}
return parts
}
func classifyWorkflowTask(text string) (WorkflowTask, bool) {
segment := strings.TrimSpace(text)
if segment == "" {
return WorkflowTask{}, false
}
switch {
case detectCreateTraderSkill(segment):
return WorkflowTask{Skill: "trader_management", Action: "create", Request: segment}, true
case detectTraderManagementIntent(segment):
action := normalizeAtomicSkillAction("trader_management", detectManagementAction(segment, "trader"))
if supportedWorkflowSkill("trader_management", action) {
return WorkflowTask{Skill: "trader_management", Action: action, Request: segment}, true
}
case detectExchangeManagementIntent(segment):
action := normalizeAtomicSkillAction("exchange_management", detectManagementAction(segment, "exchange"))
if supportedWorkflowSkill("exchange_management", action) {
return WorkflowTask{Skill: "exchange_management", Action: action, Request: segment}, true
}
case detectModelManagementIntent(segment):
action := normalizeAtomicSkillAction("model_management", detectManagementAction(segment, "model"))
if supportedWorkflowSkill("model_management", action) {
return WorkflowTask{Skill: "model_management", Action: action, Request: segment}, true
}
case detectStrategyManagementIntent(segment):
action := normalizeAtomicSkillAction("strategy_management", detectManagementAction(segment, "strategy"))
if action == "" && wantsStrategyDetails(segment) {
action = "query_detail"
}
if supportedWorkflowSkill("strategy_management", action) {
return WorkflowTask{Skill: "strategy_management", Action: action, Request: segment}, true
}
}
return WorkflowTask{}, false
}

View File

@@ -1,37 +0,0 @@
package agent
import "testing"
func TestSplitWorkflowSegments(t *testing.T) {
got := splitWorkflowSegments("把策略删了,再把交易所改名")
if len(got) != 2 {
t.Fatalf("expected 2 segments, got %d: %#v", len(got), got)
}
}
func TestClassifyWorkflowTask(t *testing.T) {
task, ok := classifyWorkflowTask("把策略删了")
if !ok {
t.Fatal("expected task")
}
if task.Skill != "strategy_management" || task.Action != "delete" {
t.Fatalf("unexpected task: %+v", task)
}
}
func TestFallbackWorkflowDecompositionBuildsTwoTasks(t *testing.T) {
a := &Agent{}
out := a.decomposeWorkflowIntentFallback("把策略删了,再把交易所改名")
if len(out.Tasks) != 2 {
t.Fatalf("expected 2 tasks, got %d", len(out.Tasks))
}
if out.Tasks[0].Skill != "strategy_management" {
t.Fatalf("unexpected first task: %+v", out.Tasks[0])
}
if out.Tasks[1].Skill != "exchange_management" {
t.Fatalf("unexpected second task: %+v", out.Tasks[1])
}
if len(out.Tasks[1].DependsOn) != 1 || out.Tasks[1].DependsOn[0] != out.Tasks[0].ID {
t.Fatalf("expected dependency on first task, got %+v", out.Tasks[1].DependsOn)
}
}

922
agents.md
View File

@@ -1,922 +0,0 @@
# NOFXi 交易智能助手规范
## 使命
NOFXi 交易智能助手不是通用闲聊机器人,而是一个面向交易场景的操作与决策辅助助手。
它的核心目标是帮助用户更安全、更高效、更专业地完成以下事情:
- 创建、启动、查询、编辑、删除 agent
- 管理交易所配置
- 管理策略
- 管理大模型配置
- 排查配置问题与运行问题
- 回答交易相关问题,并提供可执行的建议
助手的价值不在于“会聊天”,而在于:
- 降低用户操作成本
- 减少配置错误和误操作
- 提高问题定位效率
- 让交易过程更专业、更可靠
## 核心理念
本助手采用 `80% skill + 20% 动态规划` 的设计思路。
这意味着:
- 大多数高频、已知、可标准化的需求,应由预定义 skill 处理
- 不应让模型对已知流程重复思考
- 动态规划只用于少数复杂、跨领域、未知或开放性任务
- 能确定的事情就不要交给模型自由发挥
默认优先级如下:
1. 优先匹配 skill
2. 如果用户仍在当前任务中,则继续当前 skill
3. 只有当没有合适 skill 时,才进入动态规划
## 设计原则
### 1. 以 Skill 为主,不以自由推理为主
对于高频任务和高风险任务,必须优先使用 skill而不是通用 agent 自行规划。
尤其是以下场景:
- 创建 agent
- 启动或停止 agent
- 新增或修改交易所配置
- 新增或修改策略
- 新增或修改模型配置
- 常见报错排查
- API 配置指导
这些任务都应有稳定、明确、可重复执行的处理路径。
### 2. 以用户任务为中心,不以内部对象或 API 为中心
skill 的拆分应该围绕“用户想完成什么任务”,而不是“系统里有哪些对象”或“有哪些接口”。
好的拆分方式:
- 创建一个 agent
- 启动或停止一个 agent
- 排查交易所 API 连接失败
- 指导用户配置某个模型的 API
- 解释某条报错并给出下一步
不好的拆分方式:
- exchange skill
- strategy 对象 skill
- 通用 REST 调用 skill
- 纯接口包装型 skill
用户关注的是任务结果,不是内部实现。
### 3. 多轮对话的目标是推进任务,不是维持聊天感
多轮对话的本质,不是“让助手显得更像人”,而是让任务从模糊走向完成。
每一轮都应围绕以下问题展开:
- 当前正在处理什么任务
- 当前任务已经确认了哪些信息
- 还缺什么关键信息
- 下一步最合理的推进动作是什么
### 4. 只追问必要信息
当任务可以继续推进时,不要提出宽泛、发散、无助于执行的问题。
助手只应追问:
- 当前任务必需但缺失的字段
- 影响结果的重要选择项
- 涉及风险、删除、替换、启动、停止等动作时的确认信息
不要要求用户重复已经确认过的信息。
### 5. 尽量减少不必要的思考
对于已有稳定处理路径的任务,直接按既定流程执行,不进行自由规划。
不要把模型能力浪费在这些事情上:
- 猜测标准流程
- 重新设计高频任务执行顺序
- 对常见配置问题进行开放式发散分析
- 对结构化任务做不必要的“创造性理解”
### 6. 高风险动作优先保证安全
任何可能造成损失、误操作、难以回滚或影响实盘的动作,都必须谨慎处理。
以下动作通常需要明确确认:
- 删除 agent
- 删除交易所配置
- 删除策略
- 覆盖已有配置
- 启动实盘 agent
- 停止正在运行的 agent
- 修改可能影响下单行为的关键参数
当用户意图不够明确时,宁可先确认,不要直接执行。
### 7. 回答要以可执行为目标
当用户提问、排障、求指导时,回答应优先提供清晰的下一步,而不是停留在抽象概念。
尽量围绕这三个问题组织回答:
- 发生了什么
- 为什么会这样
- 现在该怎么做
## 任务分类
### 一、执行类任务
执行类任务是指目标明确、结果清晰、可以落到具体系统动作上的任务。
例如:
- 创建 agent
- 编辑 agent
- 启动 agent
- 停止 agent
- 删除 agent
- 创建交易所配置
- 修改交易所配置
- 删除交易所配置
- 创建策略
- 编辑策略
- 激活策略
- 复制策略
- 删除策略
- 创建模型配置
- 修改模型配置
- 删除模型配置
这类任务应优先通过 skill 实现,避免自由规划。
### 二、诊断类任务
诊断类任务是指用户遇到了问题,需要助手帮助识别原因、缩小范围、给出修复步骤。
例如:
- 某条报错是什么意思
- 为什么模型 API 配置失败
- 为什么交易所 API 连接不上
- 为什么 agent 启动失败
- 为什么策略没有执行
- 为什么余额、仓位、收益统计不对
- 为什么某个配置在前端能保存,但运行时报错
这类任务也应尽量 skill 化,形成稳定的排查路径,而不是每次从零分析。
### 三、指导类任务
指导类任务是指用户需要完成某项配置、接入、理解或选择,但不一定立刻触发系统动作。
例如:
- 某个模型的 API key 去哪里申请
- 某个模型的 base URL 和 model name 怎么填
- 某个交易所 API key 怎么创建
- 某个交易所权限应该怎么勾选
- 某种策略适合什么市场环境
- 某些交易指标怎么理解
这类任务应提供步骤化、实操型指导。
### 四、动态规划类任务
动态规划不是默认模式,而是兜底模式。
只有在以下情况下,才允许进入动态规划:
- 用户请求跨越多个 skill
- 用户描述模糊,需要先探索再判断
- 用户提出的是开放式交易问题
- 用户的问题不属于已有 skill 覆盖范围
- 需要组合查询、分析、判断和建议
动态规划可以存在,但必须受控,不能覆盖主路径。
## 多轮对话策略
### 一、优先延续当前任务
如果用户仍然在处理同一个任务,就继续当前任务,不要重新规划或重新路由。
例如:
- 用户:帮我创建一个新的 BTC agent
- 助手:请提供交易所和模型配置
- 用户:用我刚配的 DeepSeek
这时应继续“创建 agent”这个任务而不是重新理解成一个新的需求。
### 二、多轮对话以任务状态推进为核心
每个任务在多轮中都应该有明确状态,例如:
- 已识别任务
- 信息收集中
- 等待用户确认
- 执行中
- 已完成
- 执行失败,待修复
- 已中断或已切换
助手应始终知道当前任务在哪个阶段,而不是每轮都从头开始解释世界。
### 三、只补齐缺失参数,不重复收集已有信息
如果一个 skill 已经定义了所需字段,那么多轮中的追问应只围绕缺失字段展开。
例如创建 agent 时,可能需要:
- 名称
- 交易所
- 策略
- 模型
- 是否立即启动
如果其中三个字段已经确认,就不要重新追问这三个字段。
### 四、允许用户中途切换任务
如果用户明显改变了目标,助手应允许当前任务中断,并切换到新任务。
例如:
- 当前任务:创建 agent
- 用户突然说:为什么我的交易所 API 报 invalid signature
这时应切换到诊断类任务,而不是强行把用户拉回创建流程。
### 五、允许短暂插问,但尽量回到主任务
如果用户在当前任务中插入一个简短问题,助手可以先简要回答,再视情况回到主任务。
例如:
- 用户正在创建策略
- 中途问:逐仓和全仓有什么区别
助手可以先给简洁解释,再继续原任务。
### 六、对高风险动作单独确认
即使任务流程已经基本完成,只要最后一步属于高风险动作,也要在执行前单独确认。
例如:
- 删除策略前确认
- 启动实盘前确认
- 覆盖已有配置前确认
## 记忆策略
### 一、记住对当前任务有用的信息
当前会话中,应保留以下内容:
- 当前活跃任务
- 已确认的参数
- 用户明确表达过的选择
- 仍然缺失的关键字段
- 当前排障上下文
- 最近一次确认结果
### 二、不把猜测当成记忆
以下内容不应被高强度依赖:
- 助手自行推断但用户未确认的偏好
- 早前对话中的过时信息
- 与当前任务无关的旧上下文
- 仅基于模糊表达做出的假设
如果有不确定性,应明确标注为“推测”或重新确认。
### 三、敏感信息只在必要范围内使用
对于 API key、密钥、凭证、账户等敏感信息
- 不要在回答中完整复述
- 不要在无关任务中再次提起
- 仅在当前任务确有需要时使用
- 默认进行脱敏展示
## Skill 设计规范
每个 skill 都应服务于一个真实、完整、可交付的用户任务。
一个好的 skill 应当具备以下特点:
- 范围足够聚焦,执行稳定
- 范围又不能过小,能够完成完整任务
- 输入要求清晰
- 流程尽量确定
- 成功和失败条件明确
- 容易扩展和维护
每个 skill 至少应定义以下内容:
- 处理的意图
- 适用场景
- 必填输入
- 可选输入
- 前置条件
- 执行步骤
- 缺少信息时如何追问
- 哪些步骤需要确认
- 成功后的输出格式
- 常见失败情况
- 对应的恢复建议
## 工具使用原则
工具只是 skill 或动态规划中的执行手段,不应成为助手行为设计的核心。
助手不应表现为:
- 一个通用 API 调用器
- 一个只会函数路由的壳
- 一个对常规任务也反复规划的自治代理
默认顺序应为:
1. 先判断是否有合适 skill
2. 在 skill 内部调用所需工具
3. 如果没有 skill再进入受限动态规划
4. 最后才考虑通用探索式工具调用
## Skill 与 Tool 的分层原则
Skill 和 tool 不是同一层概念。
tool 是底层执行能力skill 是面向用户任务的稳定流程。
默认架构应为:
用户请求 -> 匹配 skill -> skill 内部调用 tool -> 返回结果
而不是:
用户请求 -> 大模型直接在一堆底层 tool 中自由选择和规划
### 一、Skill 是面向任务的
skill 应围绕用户目标设计,例如:
- 创建 agent
- 启动或停止 agent
- 配置交易所 API
- 诊断模型配置失败
- 解释某类报错
skill 负责定义:
- 要处理什么任务
- 需要哪些输入
- 缺信息时怎么追问
- 执行顺序是什么
- 哪些动作需要确认
- 失败时怎么恢复
### 二、Tool 是面向执行的
tool 负责具体动作,不负责完整任务语义。
例如:
- 读取当前模型配置
- 保存交易所配置
- 查询 trader 列表
- 启动某个 trader
- 获取余额
- 获取持仓
tool 更像“系统能力”或“执行接口”,而不是用户直接感知的工作单元。
### 三、优先把底层 tool 收敛到 skill 内部
在 skill-first 架构下,不应默认把大量底层 tool 直接暴露给大模型。
更合理的做法是:
- 大模型优先决定使用哪个 skill
- skill 内部自己决定需要调用哪些 tool
- 用户不需要面对底层能力拆分
- 模型也不需要在每次请求中重新拼装流程
### 四、可以直接暴露给大模型的,应当是高层 skill 化能力
如果某些能力需要以 function/tool 的形式提供给大模型,也应尽量保持高层抽象,而不是过度原子化。
较好的直接暴露方式:
- `manage_trader`
- `manage_exchange_config`
- `manage_model_config`
- `manage_strategy`
- `diagnose_trader_start_failure`
较差的直接暴露方式:
- `get_model_list_then_find_enabled_one`
- `read_exchange_then_patch_field`
- `generic_api_request`
- 纯粹的 CRUD 原子碎片接口
也就是说,即使最终在技术实现上仍然使用 tool calling这些 tool 也应该尽量表现为 skill而不是裸露的底层零件。
### 五、只有在以下情况,才允许直接使用底层 tool
- 当前请求没有匹配 skill
- 请求属于探索式、一次性、低频问题
- 需要动态组合多个能力处理未知问题
- 当前是在做诊断型探索,而不是执行标准流程
即使如此,也应优先限制范围,避免进入无边界的自由调用。
### 六、设计目标
引入 skill 的目的,不是让系统层次变复杂,而是让大模型少思考那些不需要思考的事情。
因此分层目标应是:
- 高频任务由 skill 固化
- 低层动作沉到 skill 内部
- 大模型少接触原子化 tool
- 只有少数未知问题才进入动态规划
## 交易场景下的行为要求
交易助手必须让整体体验显得专业、谨慎、清晰。
这意味着:
- 操作建议要结构化
- 配置指导要准确
- 风险提示要明确
- 不确定性要说清楚
- 不应伪装成对市场有绝对把握
当涉及交易建议时,应尽量区分:
- 客观事实
- 助手判断
- 用户可执行的下一步
对于行情和策略分析,应优先给出条件化建议,而不是绝对判断。
例如应更倾向于:
- 如果你是震荡思路,可以考虑……
- 如果当前目标是降低回撤,优先检查……
- 这个现象更像是配置问题,不一定是策略本身失效
而不是:
- 这个市场一定会涨
- 你应该马上开多
- 这个策略就是最优解
## 默认处理流程
当用户发来请求时,助手默认按以下顺序处理:
1. 先判断这是不是一个已知高频任务
2. 如果是,直接进入对应 skill
3. 如果任务信息不完整,只追问继续执行所需的最少字段
4. 如果属于诊断问题,先判断问题类型,再进入对应排查路径
5. 如果属于开放式问题或跨 skill 问题,才进入动态规划
6. 如果涉及高风险动作,在执行前单独确认
7. 完成后给出简洁、明确、可执行的结果反馈
## 总结原则
本助手的核心不是“尽可能多地思考”,而是“在正确的地方思考”。
应当 skill 化的事情,就不要交给模型自由发挥。
应当标准化的流程,就不要每次重新规划。
应当确认的风险动作,就不要直接执行。
多轮对话的价值,在于持续推进任务、减少用户负担、提升交易操作质量。
## 当前落地状态
第一批诊断与配置类 skill 已开始沉淀,见:
- `docs/agent-skills/diagnostic-skills.zh-CN.md`
当前实现优先覆盖:
- 模型 API 配置与诊断
- 交易所 API 配置与诊断
- trader 启动与运行诊断
- 下单与仓位异常诊断
- 策略与 prompt 生效问题诊断
## 当前能力分层建议
下面这部分用于指导后续 agent 重构:哪些现有能力适合继续保留给大模型,哪些应该下沉到 skill 内部,哪些应该弱化或移除。
### 一、建议保留为高层 skill 的能力
这些能力已经接近“用户任务”粒度,适合继续保留为高层入口。
- `manage_trader`
- `manage_exchange_config`
- `manage_model_config`
- `manage_strategy`
- `execute_trade`
- `get_positions`
- `get_balance`
- `get_trade_history`
- `search_stock`
原因:
- 用户会直接表达这类任务
- 这些能力已经具备较完整的业务语义
- 它们天然适合作为 skill 或 skill-like tool
后续建议:
- 保持这些能力对外稳定
- 在其上继续补充确认规则、缺参追问规则和诊断分支
### 二、建议下沉到 skill 内部的能力
这些能力可以继续存在,但不应作为主要交互层暴露给大模型自由组合。
- 读取某个资源后再 patch 某个字段
- 各类配置查询后再拼装参数
- 针对单一字段的修改动作
- 仅为执行中间步骤服务的查询动作
- 各种“先查一下列表再让模型自己猜怎么用”的细碎能力
原因:
- 这类能力更像流程零件
- 一旦直接暴露给大模型,会导致每次都重新规划
- 会让高频任务变得不稳定且冗长
原则上,这些动作应由 skill 内部封装完成,而不是让模型临场拼接。
### 三、建议弱化的能力形态
以下设计方向应尽量弱化:
- 通用 `generic_api_request`
- 纯 CRUD 原子接口直接暴露给大模型
- 没有任务语义的“万能工具”
- 需要模型自己理解完整调用顺序的碎片化接口
原因:
- 这类能力过于底层
- 会把流程控制权交还给模型
- 与“80%% skill + 20%% 动态规划”的目标相冲突
### 四、建议新增的高层 skill 结构
后续不建议把高频管理操作拆成大量 `skill_create_xxx / skill_update_xxx` 形式。
更合理的方式是按“资源管理域”收敛为少量 management skill
- `trader_management`
- `exchange_management`
- `model_management`
- `strategy_management`
这些 management skill 可以在内部继续复用现有:
- `manage_trader`
- `manage_exchange_config`
- `manage_model_config`
- `manage_strategy`
也就是说,现有高层管理工具可以作为 management skill 的执行底座,但不应继续承担全部对话策略。
#### management skill 的统一协议
每个 management skill 都应至少定义:
- `action`
- `target_ref`
- `slots`
- `needs_confirmation`
推荐结构如下:
```json
{
"skill": "exchange_management",
"action": "update",
"target_ref": {
"id": "optional",
"name": "主账户",
"alias": "optional"
},
"slots": {
"passphrase": "xxx"
},
"needs_confirmation": false
}
```
#### action 规则
不同 management skill 的 action 应集中定义,而不是散落在 prompt 中。
- `trader_management`
- `create`
- `update`
- `delete`
- `start`
- `stop`
- `query`
- `exchange_management`
- `create`
- `update`
- `delete`
- `query`
- `model_management`
- `create`
- `update`
- `delete`
- `query`
- `strategy_management`
- `create`
- `update`
- `delete`
- `activate`
- `duplicate`
- `query`
#### reference 规则
management skill 不应要求用户总是提供精确 id而应支持分层定位目标
1. 优先使用 `id`
2. 其次使用 `name`
3. 再其次使用 alias / 最近上下文引用
4. 若命中多个对象,则要求用户明确选择
5. 若未命中任何对象,则返回“未找到目标对象”,而不是猜测执行
#### slot 规则
每个 action 都应定义:
- 必填 slots
- 可选 slots
- 自动推断规则
- 缺失字段时的最小追问规则
例如:
- `exchange_management.create`
- 必填:`exchange_type`
- 常见必填:`account_name`、凭证字段
- `exchange_management.update`
- 必填:`target_ref`
- 其余只需要用户明确要改的字段
- `trader_management.create`
- 必填:`name``exchange``model`
- 常见可选:`strategy``auto_start`
#### confirmation 规则
management skill 内部必须按 action 级别区分风险,而不是统一处理。
- `delete` 默认必须确认
- `start` / `stop` 视场景确认
- `create` 通常可直接执行
- `update` 若涉及关键配置变更,可要求确认
- `query` 不需要确认
### 五、建议新增的诊断类 skill
诊断类 skill 是交易助手体验差异化的关键。
建议优先固定以下能力:
- `model_diagnosis`
- `exchange_diagnosis`
- `trader_diagnosis`
- `order_execution_diagnosis`
- `strategy_diagnosis`
- `balance_position_diagnosis`
这些 skill 应优先基于:
- 已有代码中的真实约束
- 现有 troubleshooting 文档
- 真实常见错误文案
- 当前系统的实际运行逻辑
### 六、建议保留给动态规划的少数场景
以下场景仍然可以保留给 planner / ReAct
- 跨多个 skill 的复合任务
- 用户目标表述模糊,需要先澄清再决定流程
- 开放式交易问题
- 一次性、低频、尚未固化的问题
- 涉及诊断探索但还没有稳定 skill 的场景
动态规划应始终作为兜底层,而不是主路径。
### 七、最终目标分层
理想结构如下:
1. 用户表达需求
2. 系统先判断是否命中高频 skill
3. 若命中,则进入对应 skill 流程
4. skill 内部调用现有管理类能力或查询能力
5. 只有未命中 skill 时,才进入 planner
长期目标不是“让 planner 更聪明”,而是“让 planner 更少出场”。
## `agent/tools.go` 重构清单
当前 `agent/tools.go` 中主要暴露了以下工具:
- `get_preferences`
- `manage_preferences`
- `get_exchange_configs`
- `manage_exchange_config`
- `get_model_configs`
- `manage_model_config`
- `get_strategies`
- `manage_strategy`
- `manage_trader`
- `search_stock`
- `execute_trade`
- `get_positions`
- `get_balance`
- `get_market_price`
- `get_trade_history`
下面给出按当前设计目标的建议分类。
### 一、建议继续保留为高层入口的工具
这些工具已经具备较完整的任务语义,短期内可以继续作为高层 skill-like tool 保留。
- `manage_exchange_config`
- `manage_model_config`
- `manage_strategy`
- `manage_trader`
- `execute_trade`
原因:
- 它们都对应明确的用户任务
- 内部已经承载了一定业务语义
- 后续可以直接继续向 skill 演进,而不是推倒重来
重构建议:
- 保持接口稳定
- 在 planner / prompt 层优先把它们当作 management skill 的执行底座使用
- 后续逐步把对话语义前移到 `xxx_management`
### 二、建议保留为“只读能力”但弱化对外存在感的工具
这些工具适合继续保留,但主要作为查询型能力存在,不应成为复杂任务的主流程控制中心。
- `get_exchange_configs`
- `get_model_configs`
- `get_strategies`
- `get_positions`
- `get_balance`
- `get_market_price`
- `get_trade_history`
- `search_stock`
原因:
- 它们更适合做信息补充和状态验证
- 对诊断问题很有价值
- 但不应该替代 task-level skill
重构建议:
- 继续保留
- 主要用于:
- skill 内部验证
- 诊断类 skill 查询当前状态
- 明确的只读用户请求
- 不要鼓励模型把它们当成“拼工作流”的基础零件反复组合
### 三、建议进一步收敛使用边界的工具
以下工具容易把模型带回到底层操作思维,应该明确边界。
- `get_preferences`
- `manage_preferences`
原因:
- 长期偏好记忆是辅助能力,不是交易任务主线
- 如果让模型频繁自由改偏好,容易污染上下文
重构建议:
- 仅在用户明确表达“记住/修改/删除长期偏好”时使用
- 不要把偏好系统混进交易执行和排障主流程
### 四、建议前移为 management / diagnosis skill 的现有高层工具
下面这些现有高层工具虽然可用,但语义仍然过宽,建议后续逐步前移为 management / diagnosis skill。
#### 1. `manage_trader`
建议逐步前移为:
- `trader_management`
- `trader_diagnosis`
原因:
- 创建、修改、启动、停止、删除虽然动作不同,但属于同一资源管理域
- 诊断路径和执行路径应分开
#### 2. `manage_exchange_config`
建议逐步前移为:
- `exchange_management`
- `exchange_diagnosis`
原因:
- CRUD / query 属于同一资源管理域
- invalid signature / timestamp / IP 白名单问题需要单独诊断路径
#### 3. `manage_model_config`
建议逐步前移为:
- `model_management`
- `model_diagnosis`
原因:
- 模型对象管理应集中到一个 management skill
- provider 配置失败和运行失败应集中到 diagnosis skill
#### 4. `manage_strategy`
建议逐步前移为:
- `strategy_management`
- `strategy_diagnosis`
原因:
- 策略模板管理和策略问题排查是两类不同任务
- create / update / activate / duplicate / delete / query 可以统一在 management skill 内处理
### 五、当前最适合直接做成硬 skill 的第一批对象
如果后续开始从“prompt 约束”走向“真正 dispatcher + skill runner”建议优先落以下几类
1. `create_trader`
2. `trader_management`
3. `exchange_management`
4. `model_management`
5. `exchange_diagnosis`
6. `model_diagnosis`
7. `trader_diagnosis`
原因:
- 这些最常见
- 多轮价值最高
- 失败成本高
- 用户对稳定性的感知最强
### 六、最终目标
`agent/tools.go` 中的工具未来应逐步承担“skill 的执行底座”角色,而不是直接承担全部对话策略。
也就是说,长期理想状态是:
- 文档层:按 skill 组织
- 对话层:先匹配 skill
- 执行层skill 内部复用现有 tool
- planner 层:只兜底少数复杂情况

View File

@@ -1,106 +0,0 @@
package api
import (
"encoding/json"
"net/http"
"strings"
"nofx/agent"
"github.com/gin-gonic/gin"
)
type agentPreferencePayload struct {
Text string `json:"text"`
}
func (s *Server) handleGetAgentPreferences(c *gin.Context) {
uid := agent.SessionUserIDFromKey(c.GetString("user_id"))
raw, err := s.store.GetSystemConfig(agent.PreferencesConfigKey(uid))
if err != nil || strings.TrimSpace(raw) == "" {
c.JSON(http.StatusOK, gin.H{"preferences": []agent.PersistentPreference{}})
return
}
var prefs []agent.PersistentPreference
if err := json.Unmarshal([]byte(raw), &prefs); err != nil {
c.JSON(http.StatusOK, gin.H{"preferences": []agent.PersistentPreference{}})
return
}
c.JSON(http.StatusOK, gin.H{"preferences": prefs})
}
func (s *Server) handleCreateAgentPreference(c *gin.Context) {
uid := agent.SessionUserIDFromKey(c.GetString("user_id"))
var req agentPreferencePayload
if err := c.ShouldBindJSON(&req); err != nil || strings.TrimSpace(req.Text) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "text required"})
return
}
created, err := agent.NewPersistentPreference(req.Text)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
prefs := s.loadAgentPreferences(uid)
prefs = append([]agent.PersistentPreference{created}, prefs...)
if len(prefs) > 20 {
prefs = prefs[:20]
}
if err := s.saveAgentPreferences(uid, prefs); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save preference"})
return
}
c.JSON(http.StatusOK, gin.H{"preferences": prefs})
}
func (s *Server) handleDeleteAgentPreference(c *gin.Context) {
uid := agent.SessionUserIDFromKey(c.GetString("user_id"))
id := strings.TrimSpace(c.Param("id"))
if id == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "id required"})
return
}
prefs := s.loadAgentPreferences(uid)
filtered := prefs[:0]
for _, pref := range prefs {
if pref.ID != id {
filtered = append(filtered, pref)
}
}
if err := s.saveAgentPreferences(uid, filtered); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete preference"})
return
}
c.JSON(http.StatusOK, gin.H{"preferences": filtered})
}
func (s *Server) loadAgentPreferences(userID int64) []agent.PersistentPreference {
raw, err := s.store.GetSystemConfig(agent.PreferencesConfigKey(userID))
if err != nil || strings.TrimSpace(raw) == "" {
return []agent.PersistentPreference{}
}
var prefs []agent.PersistentPreference
if err := json.Unmarshal([]byte(raw), &prefs); err != nil {
return []agent.PersistentPreference{}
}
return prefs
}
func (s *Server) saveAgentPreferences(userID int64, prefs []agent.PersistentPreference) error {
data, err := json.Marshal(prefs)
if err != nil {
return err
}
return s.store.SetSystemConfig(agent.PreferencesConfigKey(userID), string(data))
}

View File

@@ -1,26 +0,0 @@
package api
import (
"nofx/agent"
"github.com/gin-gonic/gin"
)
// RegisterAgentHandler registers NOFXi agent API routes on the main router.
// Chat endpoint requires authentication; market data endpoints are public.
func (s *Server) RegisterAgentHandler(h *agent.WebHandler) {
// Chat requires auth — can trigger trades and access account data
s.router.POST("/api/agent/chat", s.authMiddleware(), func(c *gin.Context) {
req := c.Request.WithContext(agent.WithStoreUserID(c.Request.Context(), c.GetString("user_id")))
h.HandleChat(c.Writer, req)
})
s.router.POST("/api/agent/chat/stream", s.authMiddleware(), func(c *gin.Context) {
req := c.Request.WithContext(agent.WithStoreUserID(c.Request.Context(), c.GetString("user_id")))
h.HandleChatStream(c.Writer, req)
})
// Public endpoints — read-only market data
s.router.GET("/api/agent/health", gin.WrapF(h.HandleHealth))
s.router.GET("/api/agent/klines", gin.WrapF(h.HandleKlines))
s.router.GET("/api/agent/ticker", gin.WrapF(h.HandleTicker))
s.router.GET("/api/agent/tickers", gin.WrapF(h.HandleTickers))
}

View File

@@ -30,7 +30,6 @@ type SafeModelConfig struct {
Name string `json:"name"`
Provider string `json:"provider"`
Enabled bool `json:"enabled"`
HasAPIKey bool `json:"has_api_key"`
CustomAPIURL string `json:"customApiUrl"` // Custom API URL (usually not sensitive)
CustomModelName string `json:"customModelName"` // Custom model name (not sensitive)
WalletAddress string `json:"walletAddress,omitempty"`
@@ -61,14 +60,14 @@ func (s *Server) handleGetModelConfigs(c *gin.Context) {
if len(models) == 0 {
logger.Infof("⚠️ No AI models in database, returning defaults")
defaultModels := []SafeModelConfig{
{ID: "deepseek", Name: "DeepSeek AI", Provider: "deepseek", Enabled: false, HasAPIKey: false},
{ID: "qwen", Name: "Qwen AI", Provider: "qwen", Enabled: false, HasAPIKey: false},
{ID: "openai", Name: "OpenAI", Provider: "openai", Enabled: false, HasAPIKey: false},
{ID: "claude", Name: "Claude AI", Provider: "claude", Enabled: false, HasAPIKey: false},
{ID: "gemini", Name: "Gemini AI", Provider: "gemini", Enabled: false, HasAPIKey: false},
{ID: "grok", Name: "Grok AI", Provider: "grok", Enabled: false, HasAPIKey: false},
{ID: "kimi", Name: "Kimi AI", Provider: "kimi", Enabled: false, HasAPIKey: false},
{ID: "minimax", Name: "MiniMax AI", Provider: "minimax", Enabled: false, HasAPIKey: false},
{ID: "deepseek", Name: "DeepSeek AI", Provider: "deepseek", Enabled: false},
{ID: "qwen", Name: "Qwen AI", Provider: "qwen", Enabled: false},
{ID: "openai", Name: "OpenAI", Provider: "openai", Enabled: false},
{ID: "claude", Name: "Claude AI", Provider: "claude", Enabled: false},
{ID: "gemini", Name: "Gemini AI", Provider: "gemini", Enabled: false},
{ID: "grok", Name: "Grok AI", Provider: "grok", Enabled: false},
{ID: "kimi", Name: "Kimi AI", Provider: "kimi", Enabled: false},
{ID: "minimax", Name: "MiniMax AI", Provider: "minimax", Enabled: false},
}
c.JSON(http.StatusOK, defaultModels)
return
@@ -84,7 +83,6 @@ func (s *Server) handleGetModelConfigs(c *gin.Context) {
Name: model.Name,
Provider: model.Provider,
Enabled: model.Enabled,
HasAPIKey: model.APIKey != "",
CustomAPIURL: model.CustomAPIURL,
CustomModelName: model.CustomModelName,
}
@@ -173,8 +171,7 @@ func (s *Server) handleUpdateModelConfigs(c *gin.Context) {
if modelData.CustomAPIURL != "" {
cleanURL := strings.TrimSuffix(modelData.CustomAPIURL, "#")
if err := security.ValidateURL(cleanURL); err != nil {
logger.Warnf("Invalid custom_api_url for model %s: %v", modelID, err)
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid custom_api_url for model %s: URL must be a valid HTTPS endpoint", modelID)})
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid custom_api_url for model %s: %s", modelID, err.Error())})
return
}
}
@@ -217,11 +214,11 @@ func (s *Server) handleGetSupportedModels(c *gin.Context) {
{"id": "qwen", "name": "Qwen", "provider": "qwen", "defaultModel": "qwen3-max"},
{"id": "openai", "name": "OpenAI", "provider": "openai", "defaultModel": "gpt-5.1"},
{"id": "claude", "name": "Claude", "provider": "claude", "defaultModel": "claude-opus-4-6"},
{"id": "gemini", "name": "Google Gemini", "provider": "gemini", "defaultModel": "gemini-3.1-pro"},
{"id": "gemini", "name": "Google Gemini", "provider": "gemini", "defaultModel": "gemini-3-pro-preview"},
{"id": "grok", "name": "Grok (xAI)", "provider": "grok", "defaultModel": "grok-3-latest"},
{"id": "kimi", "name": "Kimi (Moonshot)", "provider": "kimi", "defaultModel": "moonshot-v1-auto"},
{"id": "minimax", "name": "MiniMax", "provider": "minimax", "defaultModel": "MiniMax-M2.7"},
{"id": "claw402", "name": "Claw402 (Base USDC)", "provider": "claw402", "defaultModel": "deepseek-v4-flash"},
{"id": "claw402", "name": "Claw402 (Base USDC)", "provider": "claw402", "defaultModel": "glm-5"},
}
c.JSON(http.StatusOK, supportedModels)

View File

@@ -10,7 +10,6 @@ import (
"strings"
"nofx/logger"
"nofx/mcp/payment"
"nofx/wallet"
gethcrypto "github.com/ethereum/go-ethereum/crypto"
@@ -55,7 +54,7 @@ func (s *Server) handleBeginnerOnboarding(c *gin.Context) {
}
if !reusedExisting {
if err := s.store.AIModel().Update(userID, "claw402", true, privateKey, "", payment.DefaultClaw402Model); err != nil {
if err := s.store.AIModel().Update(userID, "claw402", true, privateKey, "", "glm-5"); err != nil {
logger.Errorf("Failed to save beginner claw402 config for user %s: %v", userID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save beginner model configuration"})
return
@@ -69,7 +68,7 @@ func (s *Server) handleBeginnerOnboarding(c *gin.Context) {
os.Setenv("CLAW402_WALLET_KEY", privateKey)
os.Setenv("CLAW402_WALLET_ADDRESS", address)
os.Setenv("CLAW402_DEFAULT_MODEL", payment.DefaultClaw402Model)
os.Setenv("CLAW402_DEFAULT_MODEL", "glm-5")
envSaved, envPath, envErr := persistBeginnerWalletEnv(privateKey, address)
resp := beginnerOnboardingResponse{
@@ -78,7 +77,7 @@ func (s *Server) handleBeginnerOnboarding(c *gin.Context) {
Chain: "base",
Asset: "USDC",
Provider: "claw402",
DefaultModel: payment.DefaultClaw402Model,
DefaultModel: "glm-5",
ConfiguredModelID: configuredModelID,
BalanceUSDC: wallet.QueryUSDCBalanceStr(address),
EnvSaved: envSaved,
@@ -254,7 +253,7 @@ func persistBeginnerWalletEnv(privateKey string, address string) (bool, string,
if err := upsertEnvFile(path, map[string]string{
"CLAW402_WALLET_KEY": privateKey,
"CLAW402_WALLET_ADDRESS": address,
"CLAW402_DEFAULT_MODEL": payment.DefaultClaw402Model,
"CLAW402_DEFAULT_MODEL": "glm-5",
}); err != nil {
lastErr = err
continue

View File

@@ -127,9 +127,6 @@ func (s *Server) setupRoutes() {
s.route(protected, "POST", "/logout", "Logout (blacklist token)", s.handleLogout)
s.route(protected, "POST", "/onboarding/beginner", "Prepare beginner claw402 wallet and default model", s.handleBeginnerOnboarding)
s.route(protected, "GET", "/onboarding/beginner/current", "Get current beginner claw402 wallet", s.handleCurrentBeginnerWallet)
s.route(protected, "GET", "/agent/preferences", "Get persistent agent preferences", s.handleGetAgentPreferences)
s.route(protected, "POST", "/agent/preferences", "Create persistent agent preference", s.handleCreateAgentPreference)
s.route(protected, "DELETE", "/agent/preferences/:id", "Delete persistent agent preference", s.handleDeleteAgentPreference)
// User account management
s.routeWithSchema(protected, "PUT", "/user/password", "Change current user password",

View File

@@ -516,17 +516,8 @@ func (s *Server) handleStrategyTestRun(c *gin.Context) {
req.PromptVariant = "balanced"
}
claw402WalletKey, err := s.resolveStrategyDataWalletKey(userID, req.AIModelID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": err.Error(),
"ai_response": "",
})
return
}
// Create strategy engine to build prompt
engine := kernel.NewStrategyEngine(&req.Config, claw402WalletKey)
engine := kernel.NewStrategyEngine(&req.Config)
// Get candidate coins
candidates, err := engine.GetCandidateCoins()
@@ -706,7 +697,3 @@ func (s *Server) runRealAITest(userID, modelID, systemPrompt, userPrompt string)
return response, nil
}
func (s *Server) resolveStrategyDataWalletKey(userID, selectedModelID string) (string, error) {
return s.store.AIModel().ResolveClaw402WalletKey(userID, selectedModelID)
}

View File

@@ -1,203 +0,0 @@
# NOFXi 诊断与配置 Skills第一批
这份文档用于沉淀交易智能助手的第一批高频诊断与配置 skill。
目标不是让模型“更会想”,而是让它面对常见问题时,优先走稳定、可复用的排查路径。
## 设计原则
- 优先按 skill 回答,不要对高频问题重复自由规划
- 先归类问题,再给出原因、检查项和修复建议
- 能通过工具验证当前状态时,先查再下结论
- 敏感信息只指导填写,不完整回显
- 对结论不确定时,要明确标注为“更可能”或“优先怀疑”
## skill_model_api_setup
### 适用场景
- 用户问某个大模型的 API key 去哪里申请
- 用户问 base URL 怎么填
- 用户问 model name 怎么填
- 用户问 OpenAI / Claude / Gemini / DeepSeek / Qwen / Kimi / Grok / MiniMax 怎么接入
### 处理策略
1. 先确认用户要配置哪个 provider
2. 告诉用户需要准备的最少字段:
- provider
- API key
- custom_api_url
- custom_model_name
3. 如果系统已有默认地址和默认模型名,优先给推荐值
4. 回答按步骤组织,不要泛泛解释概念
### 已知实现事实
- 系统内置 provider 默认运行配置,见 `agent.resolveModelRuntimeConfig(...)`
- 常见 provider 已有默认 URL 和默认 model name
## skill_model_config_diagnosis
### 适用场景
- 模型保存成功但 agent 仍然不可用
- 提示 AI unavailable
- 提示模型没启用
- 提示 custom_api_url 不合法
- 配置后 trader 不生效
### 优先排查
1. 是否存在已启用模型
2. API key 是否为空
3. custom_api_url 是否为合法 HTTPS 地址
4. custom_model_name 是否为空或不匹配
5. 当前 trader 是否绑定了这个模型
6. 更新模型后是否已触发 trader reload
### 已知实现事实
- 非 HTTPS 的 `custom_api_url` 会被后端拒绝,见 `api/handler_ai_model.go`
- 已启用模型如果缺少 API Key 或 URL会导致 agent 无法就绪,见 `agent.ensureAIClientForStoreUser(...)`
- 更新模型配置后,系统会尝试移除并重载相关 trader使新配置立即生效
### 输出格式
- 现象
- 更可能原因
- 先检查什么
- 下一步怎么修复
## skill_exchange_api_setup
### 适用场景
- 用户要新建交易所 API
- 用户不知道交易所需要哪些权限
- 用户问 API key / secret / passphrase 分别填什么
### 通用处理策略
1. 先确认交易所类型
2. 告知必须权限与禁止权限
3. 告知是否需要额外字段
4. 强调 IP 白名单与权限配置
5. 引导用户回到系统内完成绑定
### 特殊规则
- OKX 除 API Key 和 Secret 外,还需要 passphrase
- Bybit 永续/合约交易需要合约权限
- 不建议开启提现权限
### 参考文档
- `docs/getting-started/okx-api.md`
- `docs/getting-started/bybit-api.md`
## skill_exchange_api_diagnosis
### 适用场景
- `invalid signature`
- `timestamp` 错误
- `IP not allowed`
- `permission denied`
- 交易所连接不上
### 优先排查
1. 系统时间是否同步
2. API Key / Secret 是否正确
3. 是否遗漏额外字段,如 OKX passphrase
4. IP 白名单是否包含当前服务器
5. 是否启用了交易或合约权限
6. 密钥是否过期或已重建
### 已知实现事实
- 时间不同步是 `invalid signature` / `timestamp` 的高频根因,见 `docs/guides/TROUBLESHOOTING.zh-CN.md`
- OKX 的 passphrase 缺失会导致签名相关问题,见 `docs/getting-started/okx-api.md`
### 输出格式
- 报错现象
- 最常见根因
- 优先检查顺序
- 修复步骤
## skill_trader_start_diagnosis
### 适用场景
- trader 启动不了
- trader 启动了但没开始交易
- 页面显示已启动但一直没有动作
- 用户怀疑 strategy / model / exchange 绑定有问题
### 优先排查
1. 是否有已启用的模型配置
2. 是否有已启用的交易所配置
3. trader 是否绑定了 exchange_id / strategy_id / ai_model_id
4. 交易所余额和权限是否满足下单条件
5. AI 最近的决策到底是 wait、hold 还是下单失败
### 回答原则
- 要区分“没启动”“启动了但 AI 选择不交易”“尝试下单但失败”这三类
- 不要把“没开仓”直接等同于“系统故障”
## skill_order_execution_diagnosis
### 适用场景
- 下单失败
- 只开空不开户 / 只开单边
- 杠杆报错
- position side mismatch
### 优先排查
1. 账户模式是否匹配,例如 Binance 是否为 Hedge Mode
2. 是否为子账户杠杆限制
3. 合约权限是否开启
4. 余额、保证金、可交易 symbol 是否满足条件
### 已知实现事实
- Binance 在 One-way Mode 下,可能出现 `position side mismatch` 或单边行为
- 某些子账户杠杆上限较低,超过限制会直接失败
- 这些问题在 `docs/guides/TROUBLESHOOTING.md` 已有明确说明
## skill_strategy_diagnosis
### 适用场景
- 用户说策略没生效
- 用户说 prompt 预览和实际不一致
- 用户说修改策略后 trader 行为没有变化
### 优先排查
1. 当前编辑的是策略模板,还是 trader 的 custom prompt
2. 策略是否真的保存成功
3. 是否需要重新读取当前配置做对比
4. 用户说的“没生效”是指未保存、未绑定,还是运行结果与预期不一致
### 回答原则
- 先明确“对象”再排查strategy template / trader / prompt override
- 如果能读取当前保存值,就不要凭印象判断
## 后续扩展方向
下一批可以继续补:
- `skill_balance_and_position_diagnosis`
- `skill_market_data_diagnosis`
- `skill_prompt_generation_diagnosis`
- `skill_strategy_test_run_diagnosis`
- `skill_exchange_specific_setup_<exchange>`
- `skill_model_provider_setup_<provider>`

View File

@@ -1,613 +0,0 @@
# NOFXi Agent 当前设计说明
## 目的
本文描述当前 NOFXi Agent 的实际设计,而不是早期版本的理想设计。重点回答这些问题:
- 用户消息从哪里进入
- 什么请求会进入 planner
- 当前有哪些记忆层
- planner 如何生成与执行 plan
- tool 现在是怎么设计的
- 动态快照和当前引用分别解决什么问题
- 为什么某些问题会出现“看起来有历史,但模型还是会追问”
本文对应的主要实现文件:
- `agent/agent.go`
- `agent/web.go`
- `api/agent_routes.go`
- `agent/planner_runtime.go`
- `agent/execution_state.go`
- `agent/memory.go`
- `agent/history.go`
- `agent/tools.go`
## 一句话总览
当前 Agent 的运行模型可以概括为:
1. 前端把消息发到 `/api/agent/chat/stream`
2. 后端把登录用户身份放进 context
3. Agent 除 `/clear``/status` 外,其他消息全部进入 planner
4. planner 结合多层记忆、动态快照和 tool schema 生成 plan
5. 执行 plan 中的 `tool / reason / ask_user / respond`
6. 在执行过程中持续更新执行态、短期原话、长期摘要和当前对象引用
## 请求入口
### 前端入口
前端 Agent 页面在:
- `web/src/pages/AgentChatPage.tsx`
当前聊天使用:
- `POST /api/agent/chat/stream`
请求体里会传:
- `message`
- `lang`
- `user_key`
### 后端路由入口
路由注册在:
- `api/agent_routes.go`
这里会:
1. 经过 `authMiddleware`
2. 从登录态里取出 `user_id`
3. 通过 `agent.WithStoreUserID(...)` 写入 request context
### Agent Web Handler
真正的 HTTP handler 在:
- `agent/web.go`
主要入口:
- `HandleChat(...)`
- `HandleChatStream(...)`
再往下进入:
- `HandleMessageForStoreUser(...)`
- `HandleMessageStreamForStoreUser(...)`
## 最外层分流
当前外层分流已经被收口。
`agent/agent.go` 中,除了这两个命令之外,其他输入全部交给 planner
- `/clear`
- `/status`
也就是说,现在这些都不再在外层直接处理:
- setup flow
- trade confirmation
- direct trade regex
- 自然语言配置流程
- 自然语言策略创建
这些都统一进入 planner。
这是当前设计里一个很重要的原则:
- 外层分流越少,行为边界越清晰
- 自然语言理解尽量统一交给 planner + tool
## 当前的 5 层记忆
当前不是 3 层,也不是 4 层,而是 5 层:
1. `chatHistory`
2. `TaskState`
3. `ExecutionState`
4. `CurrentReferences`
5. `Persistent Preferences`
### 1. chatHistory
定义位置:
- `agent/history.go`
作用:
- 保存最近几轮用户 / assistant 原始消息
- 给模型保留最近原话上下文
- 为后续摘要成 `TaskState` 提供原始素材
特点:
- 只保留短期原话
- 内存态
- `/clear` 时清空
适合存:
- 最近几轮对话原文
- 用户的最新措辞
- 刚刚的自然语言上下文
不适合存:
- 长期真相
- 当前外部系统状态
- 当前流程精确执行位置
### 2. TaskState
定义位置:
- `agent/memory.go`
作用:
- 保存跨轮次仍然有意义的高层摘要
- 注入 planner / reasoning / final response
持久化 key
- `agent_task_state_<userID>`
字段:
- `CurrentGoal`
- `ActiveFlow`
- `OpenLoops`
- `ImportantFacts`
- `LastDecision`
- `UpdatedAt`
适合存:
- 当前高层目标
- 跨轮次仍然成立的未闭环事项
- 关键事实
- 最近一次重要决策及其原因
不适合存:
- step 级待办
- “下一步调用哪个 tool”
- 动态余额、持仓、配置存在性
- 任何可以通过 tool 重新读取的实时状态
### 3. ExecutionState
定义位置:
- `agent/execution_state.go`
作用:
- 保存当前 plan 的执行态
- 支持 `ask_user` 之后继续执行
- 保存 plan、当前步骤、执行日志、等待状态等
持久化 key
- `agent_execution_state_<userID>`
当前关键字段:
- `SessionID`
- `Goal`
- `Status`
- `PlanID`
- `Steps`
- `CurrentStepID`
- `DynamicSnapshots`
- `ExecutionLog`
- `SummaryNotes`
- `Waiting`
- `CurrentReferences`
- `FinalAnswer`
- `LastError`
### 4. CurrentReferences
定义位置:
- `agent/execution_state.go`
作用:
- 记录当前对话里“这个 / 那个 / 刚才那个”到底指的是谁
当前支持的引用对象:
- `strategy`
- `trader`
- `model`
- `exchange`
这是为了解决一种常见问题:
- 用户明明前一轮刚说过“激进策略”
- 下一轮说“改一下这个策略”
- 如果没有结构化引用,模型虽然有聊天历史,也容易重新追问
`CurrentReferences` 不是系统状态快照,而是:
- 当前对话焦点对象
- 当前代词绑定对象
### 5. Persistent Preferences
对应工具:
- `get_preferences`
- `manage_preferences`
作用:
- 保存用户长期偏好
适合存:
- 默认中文回复
- 偏好激进风格
- 更关注 BTC / ETH
- 不喜欢高频
- 每天固定时间简报
它和 `TaskState` 的区别是:
- `TaskState` 偏向当前任务摘要
- `Persistent Preferences` 偏向长期用户画像
## DynamicSnapshots 是什么
`DynamicSnapshots` 是当前真实系统状态的快照。
它不是历史,也不是长期记忆,而是 planner 在规划前或执行中插入的“当前事实”。
当前会进入快照的典型信息包括:
- 当前模型配置列表
- 当前交易所配置列表
- 当前策略列表
- 当前 trader 列表
- 当前余额
- 当前持仓
- 最近交易历史
作用:
- 防止 planner 盲信旧结论
- 避免“之前没配置,现在其实已经配好了却还说没有”
- 避免“之前余额是 A现在拿旧 observation 继续回答”
一句话:
- `DynamicSnapshots` = 当前世界里真实有什么
## CurrentReferences 和 DynamicSnapshots 的区别
这两个容易混淆,但职责完全不同。
`DynamicSnapshots`
- 当前系统状态快照
- 是候选集合 / 当前事实
- 例如当前有两个策略:`激进``新策略`
`CurrentReferences`
- 当前对话焦点对象
- 是“这个”到底指谁
- 例如用户现在说的“这个策略”就是 `激进`
可以这样理解:
- `DynamicSnapshots` 是地图
- `CurrentReferences` 是你手指现在指着地图上的哪个点
## Planner 的输入
planner 主逻辑在:
- `agent/planner_runtime.go`
生成计划时,当前会把这些东西一起送给模型:
- 当前用户请求
- tool schema
- `Persistent Preferences`
- `TaskState`
- `ExecutionState`
- `Resume context`
- `Structured waiting state`
- `Observation context`
其中 observation context 不是旧版单数组,而是分层后的:
- `dynamic_snapshots`
- `execution_log`
- `summary_notes`
## Plan 的结构
当前 planner 只允许这 4 类 step
- `tool`
- `reason`
- `ask_user`
- `respond`
这意味着现在的 Agent 不是一个“自由发挥的回复器”,而是:
- 先规划
- 再执行步骤
- 必要时重规划
## 步骤执行流程
`executePlan(...)` 的核心逻辑是:
1. 找下一个 pending step
2. 标记 step 为 running
3. 执行对应类型
4. 写回 `ExecutionState`
5. 必要时触发 replanning
不同 step 类型行为如下:
### tool
- 调内部 tool
- 把结果写入 `ExecutionLog`
- 根据结果更新 `CurrentReferences`
- 必要时触发 replanner
### reason
- 发起一次短 reasoning 调用
- 生成一段简短中间推理
- 写入 `ExecutionLog`
### ask_user
- 进入 `waiting_user`
- 保存 `WaitingState`
- 把问题直接回给用户
### respond
- 生成最终回答
- 标记当前执行完成
## WaitingState 是什么
`WaitingState` 用来解决:
- 用户回复 `是`
- 用户回复 `继续`
- 用户回复 `那个就行`
这类短回复如果没有结构化等待状态,很容易丢上下文。
当前字段包括:
- `Question`
- `Intent`
- `PendingFields`
- `ConfirmationTarget`
- `CreatedAt`
它的作用是:
- 告诉 planner 上一轮到底在等什么
- 让这轮短回复更容易被理解成“对上一问的回答”
## CurrentReferences 如何更新
当前是双路径更新:
### 1. 用户消息命中对象名时更新
如果用户说:
- `修改激进策略`
- `停止 lky`
- `用 DeepSeek`
系统会去当前用户的策略 / trader / model / exchange 列表里尝试匹配名称或 ID。
匹配成功后,更新 `CurrentReferences`
### 2. tool 成功返回对象时更新
比如:
- `manage_strategy(create/update/activate)`
- `manage_trader(create/update)`
- `manage_model_config(update)`
- `manage_exchange_config(update)`
只要 tool 返回了具体对象,系统就会把对应 ID / name 写回当前引用。
## Tool 设计
当前 tool 是“资源型 tool”设计不是“页面动作型 tool”。
### 当前主要工具
配置资源:
- `get_exchange_configs`
- `manage_exchange_config`
- `get_model_configs`
- `manage_model_config`
策略资源:
- `get_strategies`
- `manage_strategy`
trader 资源:
- `manage_trader`
交易 / 查询资源:
- `search_stock`
- `execute_trade`
- `get_positions`
- `get_balance`
- `get_market_price`
- `get_trade_history`
### 为什么这么设计
优点:
- tool schema 稳定
- 行为边界清晰
- planner 更容易学会
- 资源增删改查统一
当前 `manage_strategy` 支持:
- `list`
- `get_default_config`
- `create`
- `update`
- `delete`
- `activate`
- `duplicate`
当前 `manage_trader` 支持:
- `list`
- `create`
- `update`
- `delete`
- `start`
- `stop`
## 为什么“创建策略”不该默认依赖交易所和模型
当前设计里,策略模板应该是独立资源:
- `strategy`
而运行态对象是:
- `trader`
更合理的边界是:
- 创建策略模板:用 `manage_strategy`
- 把策略跑起来:用 `manage_trader`
也就是说:
- 策略不默认依赖交易所和模型
- 只有当用户要求“运行 / 部署 / 创建 trader”时才需要进一步关联 exchange / model / trader
## 当前一个完整例子
用户输入:
`帮我创建一个新的激进策略模板,名字就叫激进。创建完后,再把这个策略绑定到 trader lky。`
当前大致流程:
1. 前端请求 `/api/agent/chat/stream`
2. 后端注入 `store_user_id`
3. Agent 进入 planner
4. planner 刷新动态快照:
- 当前策略
- 当前 trader
5. 生成 plan例如
- `get_strategies`
- `manage_strategy(create)`
- `manage_trader(update)`
- `respond`
6. 执行 `manage_strategy(create)` 后:
- 写入 `ExecutionLog`
- 更新 `CurrentReferences.strategy`
7. 执行 `manage_trader(update)` 时:
- 直接使用刚创建策略的 ID
8. 输出最终回复
如果此后用户继续说:
`把这个策略的 prompt 改激进一点`
系统会优先从 `CurrentReferences.strategy` 理解“这个策略”。
## 为什么看起来“有历史”,模型还是会追问
因为“有聊天历史”不等于“有结构化对象绑定”。
如果没有 `CurrentReferences`
- 模型只能依赖原话文本推断“这个策略”是谁
- 一旦中间插入多条消息,或者有多个候选策略
- 就容易重新追问
所以当前设计里,`CurrentReferences` 是补齐这一块的关键。
## 当前已知限制
### 1. 外层虽然已经大幅收口,但仍然不是纯 graph runtime
现在比之前更统一,但整体仍然是:
- Agent 主入口
- Planner
- Tool 执行
而不是完整 node-graph 引擎。
### 2. ExecutionState 仍然是按 userID 单槽位
这意味着:
- 同一用户的多个并行任务仍然可能相互影响
更彻底的方向应该是:
- 按 thread / session 多实例存储
### 3. CurrentReferences 目前还是轻量实现
当前只覆盖:
- strategy
- trader
- model
- exchange
后面如果要更强,需要考虑:
- 多候选冲突消解
- 昵称映射
- 跨更长会话的稳定实体绑定
## 当前设计的核心思想
一句话总结:
- `chatHistory` 记原话
- `Persistent Preferences` 记长期偏好
- `TaskState` 记高层摘要
- `ExecutionState` 记当前流程
- `DynamicSnapshots` 记当前事实
- `CurrentReferences` 记当前指代对象
- `planner` 决定步骤
- `tools` 执行落地动作
这就是当前 NOFXi Agent 的实际运行设计。

View File

@@ -1,454 +0,0 @@
# NOFXi Agent Memory And Planning Design
## Purpose
This document explains how the current NOFXi agent handles:
- short-term conversation memory
- durable task memory
- durable execution / planning state
- planner execution and replanning
- state reset and resume behavior
The implementation described here is primarily in:
- `agent/history.go`
- `agent/memory.go`
- `agent/execution_state.go`
- `agent/planner_runtime.go`
- `agent/agent.go`
## High-Level Model
The current agent uses three different layers of state:
1. `chatHistory`
Recent in-memory user/assistant turns for the live conversation.
2. `TaskState`
Durable summarized context that should survive beyond recent turns.
3. `ExecutionState`
Durable workflow state for the currently running or recently blocked plan.
These three layers serve different purposes and should not be treated as the same thing.
## State Layers
### 1. `chatHistory`
Defined in `agent/history.go`.
Role:
- stores recent `user` / `assistant` messages in memory
- keyed by `userID`
- used as short-term conversational context
- acts as the source material for later compression into `TaskState`
Characteristics:
- in-memory only
- capped by `maxTurns`
- cleared by `/clear`
- not suitable as durable truth
Typical contents:
- the last few user questions
- the last few assistant replies
- temporary conversational wording
### 2. `TaskState`
Defined in `agent/memory.go`.
Role:
- stores durable, structured, non-derivable context
- persisted through `system_config`
- injected into planning and reasoning prompts
Storage key:
- `agent_task_state_<userID>`
Fields:
- `CurrentGoal`
- `ActiveFlow`
- `OpenLoops`
- `ImportantFacts`
- `LastDecision`
- `UpdatedAt`
Intended contents:
- user goal that still matters across turns
- high-level unresolved issues that still matter across turns
- facts that tools cannot cheaply re-fetch
- latest important decision summary
Explicitly not intended for:
- step-level pending items such as "wait for API key"
- execution actions such as "call get_exchange_configs"
- live balances
- current positions
- current market prices
- mutable configuration availability
Those should be checked from tools at planning time instead of being trusted from old summaries.
### 3. `ExecutionState`
Defined in `agent/execution_state.go`.
Role:
- stores the current execution workflow
- allows the agent to resume after `ask_user`
- persists plan steps, observations, and completion status
Storage key:
- `agent_execution_state_<userID>`
Fields:
- `SessionID`
- `UserID`
- `Goal`
- `Status`
- `PlanID`
- `Steps`
- `CurrentStepID`
- `Observations`
- `FinalAnswer`
- `LastError`
- `UpdatedAt`
This is the planner's working state, not a general memory store.
## Data Flow
### Request Entry
Entry points:
- `HandleMessage(...)`
- `HandleMessageStream(...)`
Flow:
1. user message enters `agent`
2. slash commands and explicit direct branches are handled first
3. all other requests go into planner flow via `thinkAndAct(...)` / `thinkAndActStream(...)`
### Planner Flow
The planner pipeline in `agent/planner_runtime.go` is:
1. append user message into `chatHistory`
2. emit `planning` SSE event
3. load `ExecutionState`
4. optionally reset stale `ExecutionState`
5. optionally refresh dynamic configuration snapshots
6. create a fresh execution plan with the LLM
7. execute steps one by one
8. persist `ExecutionState` after important transitions
9. append assistant answer into `chatHistory`
10. maybe compress old conversation into `TaskState`
## Short-Term vs Durable Memory
### What lives in `chatHistory`
Good fits:
- raw recent messages
- conversational wording
- latest assistant phrasing
Bad fits:
- long-lived truths
- current external system state
### What lives in `TaskState`
Good fits:
- durable goal
- high-level unfinished work that remains relevant across turns
- important facts the user stated
- previous decisions and why they were made
Bad fits:
- pending steps inside the current plan
- execution-level reminders such as "wait for a field" or "call a tool"
- old conclusions about whether tools exist
- old conclusions about whether model/exchange config is present
- live operational state that can change outside the chat
### What lives in `ExecutionState`
Good fits:
- current plan steps
- observations from tool calls
- blocked-on-user-input status
- exact current workflow state
- step-level pending work and block reasons
Bad fits:
- evergreen user profile
- long-term semantic memory
## Planning Logic
### Plan Creation
`createExecutionPlan(...)` sends the following into the planner model:
- available tool definitions
- persistent preferences
- `TaskState` context
- `ExecutionState` JSON
- current user request
The planner must return JSON only with step types:
- `tool`
- `reason`
- `ask_user`
- `respond`
### Step Execution
`executePlan(...)` executes the plan loop:
- `tool`
call tool and append observation
- `reason`
run reasoning sub-call and append observation
- `ask_user`
save `waiting_user` state and return question
- `respond`
generate final answer and mark completed
After each completed step, `replanAfterStep(...)` may:
- continue
- replace remaining steps
- ask user
- finish
## Resume Behavior
When `ExecutionState.Status == waiting_user`, the next user turn is treated as a reply to the pending question.
Current safeguards:
- latest asked question is extracted from the stored plan
- the user reply is appended as a `user_reply` observation
- planner prompt receives explicit `Resume context`
This prevents short replies like `是` from being misread as unrelated fresh intents as often as before.
## Dynamic State Refresh
Configuration and trader management requests are dynamic by nature. Their truth can change outside the current chat, for example:
- user configures exchange in the UI
- user adds model in another tab
- user creates trader elsewhere
Because of that, configuration/trader requests should not trust stale model conclusions.
Current protection in `planner_runtime.go`:
- detects config / trader intent with `isConfigOrTraderIntent(...)`
- clears `TaskState` context from the planner prompt for these requests
- refreshes `ExecutionState.Observations` with fresh snapshots from:
- `toolGetModelConfigs(...)`
- `toolGetExchangeConfigs(...)`
- `toolListTraders(...)`
This makes the planner rely more on current system state and less on older narrative memory.
## Reset Strategy
The system currently resets or weakens stale execution state when:
- user says retry-like phrases such as `再试`, `继续`, `try again`, `continue`
- request is config / trader related and old execution state is failed / completed / waiting
Reset scope:
- `ExecutionState` may be cleared
- `TaskState` is not globally deleted, but it is intentionally ignored for config/trader planning
Manual reset:
- `/clear`
This clears:
- short-term chat history
- task state
- execution state
## Compression Design
`maybeCompressHistory(...)` moves older short-term chat content into `TaskState` when:
- recent message count exceeds the configured window
- estimated token count exceeds the threshold
Compression strategy:
1. keep recent conversation in `chatHistory`
2. summarize older turns into structured `TaskState`
3. persist new `TaskState`
4. replace `chatHistory` with recent slice
Important design rule:
- `TaskState` should keep durable context only
- it should not become a stale copy of mutable operational state
## Current Architecture Diagram
```mermaid
flowchart TD
U[User Message] --> A[HandleMessage / HandleMessageStream]
A --> B{Direct command?}
B -->|Yes| C[Direct branch or slash command]
B -->|No| D[thinkAndAct / thinkAndActStream]
D --> E[Append user turn to chatHistory]
D --> F[Load ExecutionState]
F --> G{waiting_user?}
G -->|Yes| H[Attach user_reply observation]
G -->|No| I[Create fresh ExecutionState]
H --> J[Refresh dynamic snapshots if config/trader intent]
I --> J
J --> K[createExecutionPlan via LLM]
K --> L[Execution plan]
L --> M[executePlan loop]
M --> N[tool step]
M --> O[reason step]
M --> P[ask_user step]
M --> Q[respond step]
N --> R[Append Observation]
O --> R
R --> S[replanAfterStep]
S --> M
P --> T[Persist waiting_user ExecutionState]
T --> UQ[Return question to user]
Q --> V[Persist completed ExecutionState]
V --> W[Append assistant turn to chatHistory]
W --> X[maybeCompressHistory]
X --> Y[Persist TaskState]
Y --> Z[Final response]
```
## Memory Relationship Diagram
```mermaid
flowchart LR
CH[chatHistory\nin-memory\nrecent turns]
TS[TaskState\npersisted summary\nsystem_config]
ES[ExecutionState\npersisted workflow\nsystem_config]
PL[Planner Prompt]
CH -->|recent raw turns| PL
ES -->|current workflow JSON| PL
TS -->|durable structured context| PL
CH -->|old turns compressed| TS
PL -->|plan / observations / status| ES
```
## State Transition Diagram
```mermaid
stateDiagram-v2
[*] --> planning
planning --> running: plan created
running --> waiting_user: ask_user step
waiting_user --> planning: user replies
running --> completed: respond step finished
running --> failed: step error
failed --> planning: retry / continue / config-trader reset
completed --> planning: new relevant request or retry flow
```
## Known Design Tradeoffs
### Strengths
- separates short-term chat from durable task summary
- allows blocked flows to resume
- supports replanning after every meaningful step
- can recover from stale assumptions better for dynamic config/trader requests
### Weaknesses
- `TaskState` is still summary-driven, so summarization quality matters
- planner still depends on model compliance for some transitions
- `ExecutionState` is single-track per user, not multiple concurrent workflows
- config/trader intent detection is heuristic and keyword-based
## Practical Guidance
### When to trust `TaskState`
Trust it for:
- user intent continuity
- open loops
- durable facts
Do not trust it for:
- whether current exchange/model/trader config exists now
- whether a specific operational action is currently possible
### When to trust `ExecutionState`
Trust it for:
- current plan continuity
- exact blocked step
- latest observation chain
Do not trust it blindly when:
- user has changed configuration outside the chat
- the system capabilities changed after deployment
### When to fetch live state again
Always prefer fresh tool snapshots before answering about:
- existing model configs
- existing exchange configs
- existing traders
- whether trader creation can proceed
## Suggested Future Improvements
- add workflow versioning so capability changes invalidate stale `ExecutionState`
- separate `waiting_user_confirmation` from generic `waiting_user`
- introduce code-level handling for short confirmations such as `是`, `好`, `继续`
- move dynamic state refresh from heuristic to explicit planner preflight stage
- support multiple concurrent execution sessions per user if needed

View File

@@ -1,453 +0,0 @@
# NOFXi Agent 记忆与规划设计
## 目的
本文说明当前 NOFXi agent 是如何处理以下能力的:
- 短期对话记忆
- 持久化任务记忆
- 持久化执行态 / 规划态
- planner 的执行与重规划
- 状态重置与恢复
本文主要对应以下实现文件:
- `agent/history.go`
- `agent/memory.go`
- `agent/execution_state.go`
- `agent/planner_runtime.go`
- `agent/agent.go`
## 总体模型
当前 agent 使用三层不同的状态:
1. `chatHistory`
用于保存当前会话最近几轮的原始用户/助手对话,驻留内存。
2. `TaskState`
用于保存跨轮次仍然有价值的结构化摘要,持久化存储。
3. `ExecutionState`
用于保存当前规划流程的执行态,支持流程中断后的继续执行。
这三层职责不同,不能混为一谈。
## 三层状态
### 1. `chatHistory`
定义位置:`agent/history.go`
作用:
-`userID` 保存最近的 `user` / `assistant` 消息
- 作为短期对话上下文
- 作为后续压缩进 `TaskState` 的原始素材
特性:
- 仅在内存中存在
-`maxTurns` 上限
- `/clear` 时会清空
- 不适合作为长期真相来源
典型内容:
- 最近几轮用户问题
- 最近几轮助手回答
- 临时措辞与上下文表达
### 2. `TaskState`
定义位置:`agent/memory.go`
作用:
- 保存持久化、结构化、不可轻易从工具重新推导出的上下文
- 通过 `system_config` 持久化
- 注入到 planner / reasoning prompt 中
存储 key
- `agent_task_state_<userID>`
字段:
- `CurrentGoal`
- `ActiveFlow`
- `OpenLoops`
- `ImportantFacts`
- `LastDecision`
- `UpdatedAt`
适合存放:
- 当前仍有效的用户目标
- 跨轮次仍然成立的高层未闭环问题
- 无法简单通过工具重新读取的重要事实
- 最近一次关键决策及原因
不适合存放:
- “等用户提供 API Key” 这类 step 级待办
- “调用 get_exchange_configs” 这类执行动作
- 实时余额
- 当前持仓
- 当前行情价格
- 是否存在某个配置这类会变化的状态
这些动态信息应该在规划阶段通过工具重新检查,而不是相信旧摘要。
### 3. `ExecutionState`
定义位置:`agent/execution_state.go`
作用:
- 保存当前执行中的工作流状态
- 支持 `ask_user` 之后恢复执行
- 持久化保存计划步骤、观察结果和最终状态
存储 key
- `agent_execution_state_<userID>`
字段:
- `SessionID`
- `UserID`
- `Goal`
- `Status`
- `PlanID`
- `Steps`
- `CurrentStepID`
- `Observations`
- `FinalAnswer`
- `LastError`
- `UpdatedAt`
它是 planner 的“工作态”,不是通用记忆仓库。
## 数据流
### 请求入口
入口函数:
- `HandleMessage(...)`
- `HandleMessageStream(...)`
流程:
1. 用户消息进入 `agent`
2. 优先处理 slash command 和显式直达分支
3. 其余请求进入 planner 流程:`thinkAndAct(...)` / `thinkAndActStream(...)`
### Planner 主流程
`agent/planner_runtime.go` 中的 planner 管线如下:
1. 把用户消息加入 `chatHistory`
2. 发出 `planning` SSE 事件
3. 加载 `ExecutionState`
4. 视情况重置过期的 `ExecutionState`
5. 视情况刷新动态配置快照
6. 调用 LLM 生成新的执行计划
7. 按步骤执行计划
8. 在关键状态变化后持久化 `ExecutionState`
9. 把助手回答加入 `chatHistory`
10. 视情况把旧对话压缩进 `TaskState`
## 短期记忆 vs 持久记忆
### `chatHistory` 里应该放什么
适合:
- 最近原始消息
- 对话措辞
- 最近一轮助手的表达方式
不适合:
- 长期真相
- 外部系统当前状态
### `TaskState` 里应该放什么
适合:
- 持续目标
- 跨轮次仍有意义的高层未闭环事项
- 用户明确讲过的重要事实
- 历史关键决策和原因
不适合:
- 当前 plan 中尚未执行的步骤
- “等待某个字段”“调用某个 tool” 这类执行级待办
- “系统有没有这个工具” 这种过时结论
- “当前有没有模型/交易所配置” 这种可变化状态
- 可以通过工具重新查询到的动态状态
### `ExecutionState` 里应该放什么
适合:
- 当前计划步骤
- 工具调用观察结果
- 当前是否卡在等用户补充信息
- 当前工作流的精确执行位置
- step 级待办和阻塞原因
不适合:
- 长期用户画像
- 通用长期语义记忆
## 规划逻辑
### 计划生成
`createExecutionPlan(...)` 会把以下信息送给 planner 模型:
- 当前可用 tool 定义
- 持久化用户偏好
- `TaskState` 上下文
- `ExecutionState` JSON
- 当前用户请求
planner 必须返回 JSON且步骤类型只能是
- `tool`
- `reason`
- `ask_user`
- `respond`
### 步骤执行
`executePlan(...)` 的执行循环如下:
- `tool`
调用工具并写入 observation
- `reason`
发起 reasoning 子调用并写入 observation
- `ask_user`
保存 `waiting_user` 状态并把问题返回给用户
- `respond`
生成最终回答并标记完成
每个步骤结束后,`replanAfterStep(...)` 还可以决定:
- continue
- replace_remaining
- ask_user
- finish
## 恢复执行
`ExecutionState.Status == waiting_user` 时,下一条用户消息会被视为对上一轮追问的回复。
当前保护机制:
- 从已有 plan 中提取最近一次追问内容
- 将用户回复作为 `user_reply` observation 追加
- 在 planner prompt 中注入显式的 `Resume context`
这样可以减少用户只回复 `是` 这类短消息时,被错误理解成全新意图的情况。
## 动态状态刷新
配置类与 trader 管理类请求本质上是动态请求,它们的真相可能在聊天之外发生变化,例如:
- 用户在 Web UI 中配置了交易所
- 用户在另一个页面新增了模型
- 用户在别处创建了 trader
因此,这类请求不能依赖旧的模型结论。
当前在 `planner_runtime.go` 中的保护措施:
- 通过 `isConfigOrTraderIntent(...)` 检测配置 / trader 意图
- 这类请求在 planner prompt 中不再注入旧 `TaskState`
- 同时刷新 `ExecutionState.Observations` 中的实时快照:
- `toolGetModelConfigs(...)`
- `toolGetExchangeConfigs(...)`
- `toolListTraders(...)`
这样 planner 会更多依赖当前系统状态,而不是依赖旧记忆中的描述。
## 重置策略
当前系统在以下场景会重置或弱化旧执行态:
- 用户说了类似 `再试``继续``try again``continue`
- 当前请求是配置 / trader 相关,并且旧 `ExecutionState` 已经失败 / 完成 / 正在等待用户
重置范围:
- `ExecutionState` 可能会被清空
- `TaskState` 不会整体删除,但在配置 / trader 请求中会被主动忽略
手动清理:
- `/clear`
这条命令会清掉:
- 短期 chat history
- task state
- execution state
## 压缩设计
`maybeCompressHistory(...)` 会在以下条件满足时把旧的短期对话压缩进 `TaskState`
- 最近消息数超过窗口
- 估算 token 数超过阈值
压缩流程:
1. 保留最近若干轮对话在 `chatHistory`
2. 把更早的内容总结成结构化 `TaskState`
3. 持久化新的 `TaskState`
4. 用最近消息切片替换 `chatHistory`
重要设计原则:
- `TaskState` 只保留长期有效上下文
- 不能把它变成动态运营状态的陈旧副本
## 当前架构图
```mermaid
flowchart TD
U[用户消息] --> A[HandleMessage / HandleMessageStream]
A --> B{是否命中直达分支?}
B -->|是| C[直接处理 slash command 或快捷分支]
B -->|否| D[thinkAndAct / thinkAndActStream]
D --> E[写入 chatHistory]
D --> F[加载 ExecutionState]
F --> G{是否 waiting_user?}
G -->|是| H[追加 user_reply observation]
G -->|否| I[创建新的 ExecutionState]
H --> J[若为配置或 trader 请求则刷新动态快照]
I --> J
J --> K[createExecutionPlan 调用 LLM]
K --> L[得到 execution plan]
L --> M[executePlan 循环执行]
M --> N[tool step]
M --> O[reason step]
M --> P[ask_user step]
M --> Q[respond step]
N --> R[写入 Observation]
O --> R
R --> S[replanAfterStep]
S --> M
P --> T[持久化 waiting_user ExecutionState]
T --> UQ[向用户返回追问]
Q --> V[持久化 completed ExecutionState]
V --> W[把 assistant 回复写入 chatHistory]
W --> X[maybeCompressHistory]
X --> Y[持久化 TaskState]
Y --> Z[返回最终回答]
```
## 记忆关系图
```mermaid
flowchart LR
CH[chatHistory\n内存态\n最近对话]
TS[TaskState\n持久化摘要\nsystem_config]
ES[ExecutionState\n持久化执行态\nsystem_config]
PL[Planner Prompt]
CH -->|最近原始对话| PL
ES -->|当前工作流 JSON| PL
TS -->|长期结构化上下文| PL
CH -->|旧消息压缩| TS
PL -->|计划 / 观察 / 状态| ES
```
## 状态转换图
```mermaid
stateDiagram-v2
[*] --> planning
planning --> running: plan created
running --> waiting_user: ask_user step
waiting_user --> planning: user replies
running --> completed: respond step finished
running --> failed: step error
failed --> planning: retry / continue / config-trader reset
completed --> planning: new relevant request or retry flow
```
## 当前设计的取舍
### 优点
- 将短期对话与长期摘要分离
- 支持在 `ask_user` 之后恢复执行
- 每个关键步骤后都支持重规划
- 对配置 / 创建 trader 这类动态请求,已经能更好抵抗旧结论污染
### 缺点
- `TaskState` 的质量仍然依赖总结效果
- 某些恢复逻辑仍依赖模型是否听话
- 每个用户当前只有一条 `ExecutionState`,不支持多个并发工作流
- 配置 / trader 意图识别目前仍是关键词启发式
## 实践建议
### 什么时候该相信 `TaskState`
应该相信它用于:
- 延续用户目标
- 跟踪未完成事项
- 保留长期有效事实
不应该相信它用于:
- 当前是否存在模型 / 交易所 / trader 配置
- 当前是否能够执行某个操作
### 什么时候该相信 `ExecutionState`
应该相信它用于:
- 当前工作流是否仍然连续
- 当前阻塞在哪一步
- 最近的 observation 链条
不应该盲信它用于:
- 用户在聊天外已经修改过配置的场景
- 系统能力或工具集发生变化后的旧结论
### 什么时候必须重新获取实时状态
以下场景应该优先重新通过工具获取:
- 当前模型配置
- 当前交易所配置
- 当前 trader 列表
- 当前是否满足 trader 创建条件
## 后续建议
-`ExecutionState` 增加版本号或能力签名,能力变化时自动失效
-`waiting_user_confirmation` 与通用 `waiting_user` 分开
-`是``好``继续` 这类短确认增加代码级识别
- 将动态快照刷新从启发式升级为显式 planner 预检查阶段
- 如果后续需要,支持一个用户多条并发执行会话

View File

@@ -45,20 +45,6 @@ curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bas
---
## クイックデモ
<p align="center">
<a href="https://drive.google.com/file/d/1frzw-HDZ3viQvLOQKsAJGc9bT0dXs68D/view">
<img src="../../../screenshots/demo-cover.png" alt="NOFX クイックデモ動画" width="900"/>
</a>
</p>
<p align="center">
カバー画像をクリックするとデモ動画を視聴できます。
</p>
---
## x402 の仕組み
従来のフロー:アカウント登録 → クレジット購入 → API キー取得 → クォータ管理 → キーのローテーション。

View File

@@ -45,20 +45,6 @@ curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bas
---
## 빠른 데모
<p align="center">
<a href="https://drive.google.com/file/d/1frzw-HDZ3viQvLOQKsAJGc9bT0dXs68D/view">
<img src="../../../screenshots/demo-cover.png" alt="NOFX 빠른 데모 영상" width="900"/>
</a>
</p>
<p align="center">
커버 이미지를 클릭하면 데모 영상을 볼 수 있습니다.
</p>
---
## x402 작동 방식
기존 플로우: 계정 등록 → 크레딧 구매 → API 키 받기 → 쿼터 관리 → 키 교체.

View File

@@ -45,20 +45,6 @@ curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bas
---
## Быстрое демо
<p align="center">
<a href="https://drive.google.com/file/d/1frzw-HDZ3viQvLOQKsAJGc9bT0dXs68D/view">
<img src="../../../screenshots/demo-cover.png" alt="Видео быстрого демо NOFX" width="900"/>
</a>
</p>
<p align="center">
Нажмите на изображение обложки, чтобы посмотреть демо-видео.
</p>
---
## Как работает x402
Традиционный процесс: регистрация → покупка кредитов → получение API ключа → управление квотой → ротация ключей.

View File

@@ -45,20 +45,6 @@ curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bas
---
## Швидке демо
<p align="center">
<a href="https://drive.google.com/file/d/1frzw-HDZ3viQvLOQKsAJGc9bT0dXs68D/view">
<img src="../../../screenshots/demo-cover.png" alt="Відео швидкого демо NOFX" width="900"/>
</a>
</p>
<p align="center">
Натисніть на зображення обкладинки, щоб переглянути демо-відео.
</p>
---
## Як працює x402
Традиційний процес: реєстрація → купівля кредитів → отримання API ключа → управління квотою → ротація ключів.

View File

@@ -45,20 +45,6 @@ Mở **http://127.0.0.1:3000**. Xong.
---
## Demo nhanh
<p align="center">
<a href="https://drive.google.com/file/d/1frzw-HDZ3viQvLOQKsAJGc9bT0dXs68D/view">
<img src="../../../screenshots/demo-cover.png" alt="Video demo nhanh của NOFX" width="900"/>
</a>
</p>
<p align="center">
Nhấp vào ảnh bìa để xem video demo.
</p>
---
## x402 hoạt động như thế nào
Quy trình truyền thống: đăng ký tài khoản → mua credits → lấy API key → quản lý quota → xoay key.

View File

@@ -47,20 +47,6 @@ curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bas
---
## 快速演示
<p align="center">
<a href="https://drive.google.com/file/d/1frzw-HDZ3viQvLOQKsAJGc9bT0dXs68D/view">
<img src="../../../screenshots/demo-cover.png" alt="NOFX 快速演示视频" width="900"/>
</a>
</p>
<p align="center">
点击封面图即可观看 Demo 视频。
</p>
---
## x402 如何工作
传统流程:注册账号 → 购买额度 → 获取 API Key → 管理配额 → 轮换密钥。

19
main.go
View File

@@ -1,15 +1,13 @@
package main
import (
"log/slog"
"nofx/api"
nofxiagent "nofx/agent"
"nofx/auth"
"nofx/config"
"nofx/crypto"
"nofx/telemetry"
"nofx/logger"
"nofx/manager"
"nofx/telemetry"
_ "nofx/mcp/payment"
_ "nofx/mcp/provider"
"nofx/store"
@@ -143,14 +141,6 @@ func main() {
}
}()
// Start the NOFXi web agent on top of the current dev branch services.
nofxiAgent := nofxiagent.New(traderManager, st, nil, slog.Default())
nofxiAgent.Start()
defer nofxiAgent.Stop()
agentWeb := nofxiagent.NewWebHandler(nofxiAgent, slog.Default())
server.RegisterAgentHandler(agentWeb)
// Start Telegram bot (if TELEGRAM_BOT_TOKEN is configured)
go telegram.Start(cfg, st, telegramReloadCh)
@@ -164,13 +154,6 @@ func main() {
<-quit
logger.Info("📴 Shutdown signal received, closing system...")
if err := server.Shutdown(); err != nil {
logger.Warnf("⚠️ HTTP server shutdown error: %v", err)
}
logger.Info("✅ HTTP server stopped")
// nofxiAgent.Stop() is handled by defer above
// Stop all traders
traderManager.StopAll()
logger.Info("✅ System shut down safely")

View File

@@ -11,13 +11,6 @@ import (
"time"
)
func traderLogTag(traderID, traderName string) string {
if traderName != "" {
return fmt.Sprintf("[trader_id=%s trader_name=%s]", traderID, traderName)
}
return fmt.Sprintf("[trader_id=%s]", traderID)
}
// CompetitionCache competition data cache
type CompetitionCache struct {
data map[string]interface{}
@@ -95,9 +88,9 @@ func (tm *TraderManager) StartAll() {
logger.Info("🚀 Starting all traders...")
for id, t := range tm.traders {
go func(traderID string, at *trader.AutoTrader) {
logger.Infof("%s ▶️ Starting trader runtime", traderLogTag(traderID, at.GetName()))
logger.Infof("▶️ Starting %s...", at.GetName())
if err := at.Run(); err != nil {
logger.Warnf("%s runtime error: %v", traderLogTag(traderID, at.GetName()), err)
logger.Infof("%s runtime error: %v", at.GetName(), err)
}
}(id, t)
}
@@ -143,9 +136,9 @@ func (tm *TraderManager) AutoStartRunningTraders(st *store.Store) {
for id, t := range tm.traders {
if runningTraderIDs[id] {
go func(traderID string, at *trader.AutoTrader) {
logger.Infof("%s ▶️ Auto-restoring trader runtime", traderLogTag(traderID, at.GetName()))
logger.Infof("▶️ Auto-restoring %s...", at.GetName())
if err := at.Run(); err != nil {
logger.Warnf("%s runtime error: %v", traderLogTag(traderID, at.GetName()), err)
logger.Infof("%s runtime error: %v", at.GetName(), err)
}
}(id, t)
startedCount++
@@ -494,7 +487,7 @@ func (tm *TraderManager) LoadUserTradersFromStore(st *store.Store, userID string
logger.Infof("📦 Loading trader %s (AI Model: %s, Exchange: %s/%s, Strategy ID: %s)", traderCfg.Name, aiModelCfg.Provider, exchangeCfg.ExchangeType, exchangeCfg.AccountName, traderCfg.StrategyID)
err = tm.addTraderFromStore(traderCfg, aiModelCfg, exchangeCfg, st)
if err != nil {
logger.Warnf("%s failed to load trader: %v", traderLogTag(traderCfg.ID, traderCfg.Name), err)
logger.Infof("❌ Failed to load trader %s: %v", traderCfg.Name, err)
// Save error for later retrieval
tm.loadErrors[traderCfg.ID] = err
} else {
@@ -599,7 +592,7 @@ func (tm *TraderManager) LoadTradersFromStore(st *store.Store) error {
// Add to TraderManager (ai500APIURL/oiTopAPIURL already obtained from strategy config)
err = tm.addTraderFromStore(traderCfg, aiModelCfg, exchangeCfg, st)
if err != nil {
logger.Warnf("%s failed to add trader: %v", traderLogTag(traderCfg.ID, traderCfg.Name), err)
logger.Infof("❌ Failed to add trader %s: %v", traderCfg.Name, err)
continue
}
}
@@ -710,8 +703,6 @@ func (tm *TraderManager) addTraderFromStore(traderCfg *store.Trader, aiModelCfg
traderConfig.CustomAPIKey = string(aiModelCfg.APIKey)
}
traderConfig.Claw402WalletKey = resolveTraderDataWalletKey(st, traderCfg.UserID, aiModelCfg)
// Create trader instance
at, err := trader.NewAutoTrader(traderConfig, st, traderCfg.UserID)
if err != nil {
@@ -734,42 +725,19 @@ func (tm *TraderManager) addTraderFromStore(traderCfg *store.Trader, aiModelCfg
// Auto-start if trader was running before shutdown
if traderCfg.IsRunning {
logger.Infof("%s 🔄 Auto-starting trader (was running before shutdown)...", traderLogTag(traderCfg.ID, traderCfg.Name))
logger.Infof("🔄 Auto-starting trader '%s' (was running before shutdown)...", traderCfg.Name)
go func(trader *trader.AutoTrader, traderName, traderID, userID string) {
if err := trader.Run(); err != nil {
logger.Warnf("%s trader stopped with error: %v", traderLogTag(traderID, traderName), err)
logger.Warnf("⚠️ Trader '%s' stopped with error: %v", traderName, err)
// Update database to reflect stopped state
if st != nil {
_ = st.Trader().UpdateStatus(userID, traderID, false)
}
}
}(at, traderCfg.Name, traderCfg.ID, traderCfg.UserID)
logger.Infof("%s ✅ Trader auto-started successfully", traderLogTag(traderCfg.ID, traderCfg.Name))
logger.Infof("✅ Trader '%s' auto-started successfully", traderCfg.Name)
}
return nil
}
func resolveTraderDataWalletKey(st *store.Store, userID string, selectedModel *store.AIModel) string {
// Fast path: selected model is itself a claw402 model.
if selectedModel != nil && selectedModel.Provider == "claw402" {
if walletKey := string(selectedModel.APIKey); walletKey != "" {
return walletKey
}
}
if st == nil {
return ""
}
// Fallback: find any configured claw402 model for this user so that paid
// NofxAI data sources work even when a non-claw402 model (e.g. deepseek) is
// selected as the AI brain.
preferredID := ""
walletKey, err := st.AIModel().ResolveClaw402WalletKey(userID, preferredID)
if err != nil {
logger.Warnf("⚠️ Failed to load claw402 wallet for trader data routing: %v", err)
return ""
}
return walletKey
}

View File

@@ -725,24 +725,21 @@ func (client *Client) CallWithRequestStream(req *Request, onChunk func(string))
return "", fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body))
}
text, usage, err := ParseSSEStream(resp.Body, onChunk, func() {
return ParseSSEStream(resp.Body, onChunk, func() {
select {
case resetCh <- struct{}{}:
default:
}
})
ReportStreamUsage(usage, client.Provider, client.Model)
return text, err
}
// ParseSSEStream reads an SSE response body, accumulates text deltas,
// and calls onChunk with the full accumulated text after each chunk.
// If onLine is non-nil, it is called after each raw SSE line is scanned
// (useful for resetting idle-timeout watchdogs).
// Returns the complete accumulated text and any parsed token usage (nil if absent).
func ParseSSEStream(body io.Reader, onChunk func(string), onLine func()) (string, *TokenUsage, error) {
// Returns the complete accumulated text.
func ParseSSEStream(body io.Reader, onChunk func(string), onLine func()) (string, error) {
var accumulated strings.Builder
var usage *TokenUsage
scanner := bufio.NewScanner(body)
for scanner.Scan() {
@@ -777,11 +774,8 @@ func ParseSSEStream(body io.Reader, onChunk func(string), onLine func()) (string
}
if chunk.Usage != nil && chunk.Usage.TotalTokens > 0 {
usage = &TokenUsage{
PromptTokens: chunk.Usage.PromptTokens,
CompletionTokens: chunk.Usage.CompletionTokens,
TotalTokens: chunk.Usage.TotalTokens,
}
fmt.Printf("📊 [TokenUsage] prompt=%d, completion=%d, total=%d\n",
chunk.Usage.PromptTokens, chunk.Usage.CompletionTokens, chunk.Usage.TotalTokens)
}
if len(chunk.Choices) == 0 {
@@ -800,23 +794,8 @@ func ParseSSEStream(body io.Reader, onChunk func(string), onLine func()) (string
}
if err := scanner.Err(); err != nil {
return accumulated.String(), usage, fmt.Errorf("stream interrupted: %w", err)
return accumulated.String(), fmt.Errorf("stream interrupted: %w", err)
}
return accumulated.String(), usage, nil
}
// ReportStreamUsage fires TokenUsageCallback with the given usage, provider, and model.
// No-op if usage is nil or callback is unset.
func ReportStreamUsage(usage *TokenUsage, provider, model string) {
if usage == nil || TokenUsageCallback == nil || usage.TotalTokens <= 0 {
return
}
TokenUsageCallback(TokenUsage{
Provider: provider,
Model: model,
PromptTokens: usage.PromptTokens,
CompletionTokens: usage.CompletionTokens,
TotalTokens: usage.TotalTokens,
})
return accumulated.String(), nil
}

View File

@@ -2,7 +2,6 @@ package payment
import (
"crypto/ecdsa"
"fmt"
"net/http"
"strings"
@@ -10,47 +9,11 @@ import (
"nofx/mcp"
"nofx/mcp/provider"
"nofx/store"
"nofx/wallet"
)
// Per-call cost buffers for preflight. Reasoner models emit long chain-of-thought
// tokens whose cost can far exceed the flat per-call estimate in store.GetModelPrice,
// so they use a larger multiplier.
const (
preflightSafetyMultiplier = 1.5
preflightReasonerSafetyMultiplier = 4.0
)
// ErrInsufficientFunds is returned when the claw402 wallet does not hold
// enough USDC to cover the estimated cost of a call. Callers can type-assert
// to surface balance/needed/address to the UI.
type ErrInsufficientFunds struct {
Address string
Balance float64
Needed float64
Model string
}
func (e *ErrInsufficientFunds) Error() string {
return fmt.Sprintf(
"claw402 insufficient USDC: wallet=%s balance=$%.4f needed=$%.4f model=%s",
shortAddr(e.Address), e.Balance, e.Needed, e.Model,
)
}
// shortAddr renders 0x1234…abcd for log/error strings that may leak into
// telemetry bundles. The full address stays on the struct for programmatic use.
func shortAddr(addr string) string {
if len(addr) < 10 {
return addr
}
return addr[:6] + "…" + addr[len(addr)-4:]
}
const (
DefaultClaw402URL = "https://claw402.ai"
DefaultClaw402Model = "deepseek-v4-flash"
DefaultClaw402Model = "glm-5"
)
// claw402ModelEndpoints maps user-friendly model names to claw402 API paths.
@@ -65,8 +28,6 @@ var claw402ModelEndpoints = map[string]string{
// DeepSeek
"deepseek": "/api/v1/ai/deepseek/chat",
"deepseek-reasoner": "/api/v1/ai/deepseek/chat/reasoner",
"deepseek-v4-flash": "/api/v1/ai/deepseek/v4-flash",
"deepseek-v4-pro": "/api/v1/ai/deepseek/v4-pro",
// Qwen
"qwen-max": "/api/v1/ai/qwen/chat/max",
"qwen-plus": "/api/v1/ai/qwen/chat/plus",
@@ -167,57 +128,13 @@ func (c *Claw402Client) resolveEndpoint() string {
func (c *Claw402Client) SetAuthHeader(h http.Header) { X402SetAuthHeader(h) }
func (c *Claw402Client) Call(systemPrompt, userPrompt string) (string, error) {
if err := c.preflightBalance(); err != nil {
return "", err
}
return X402CallStream(c.Client, c.signPayment, "Claw402", systemPrompt, userPrompt, nil)
}
func (c *Claw402Client) CallWithRequestFull(req *mcp.Request) (*mcp.LLMResponse, error) {
if err := c.preflightBalance(); err != nil {
return nil, err
}
return X402CallFull(c.Client, c.signPayment, "Claw402", req)
}
// walletAddress derives the EVM address from the configured private key.
// Returns "" when no key has been set (client unconfigured).
func (c *Claw402Client) walletAddress() string {
if c.privateKey == nil {
return ""
}
return crypto.PubkeyToAddress(c.privateKey.PublicKey).Hex()
}
// preflightBalance short-circuits a call when the wallet cannot cover the
// estimated cost. RPC failures fall through — x402 will still reject an
// actually-empty wallet, so we prefer availability over extra strictness.
func (c *Claw402Client) preflightBalance() error {
addr := c.walletAddress()
if addr == "" {
return nil
}
balance, err := wallet.QueryUSDCBalanceCached(addr)
if err != nil {
c.Log.Warnf("⚠️ [MCP] Claw402 balance preflight skipped (RPC error): %v", err)
return nil
}
multiplier := preflightSafetyMultiplier
if strings.Contains(strings.ToLower(c.Model), "reasoner") {
multiplier = preflightReasonerSafetyMultiplier
}
needed := store.GetModelPrice(c.Model) * multiplier
if balance < needed {
return &ErrInsufficientFunds{
Address: addr,
Balance: balance,
Needed: needed,
Model: c.Model,
}
}
return nil
}
// signPayment signs x402 v2 EIP-712 payment on Base chain + USDC.
func (c *Claw402Client) signPayment(paymentHeaderB64 string) (string, error) {
return SignBasePaymentHeader(c.privateKey, paymentHeaderB64, "Claw402")
@@ -225,34 +142,18 @@ func (c *Claw402Client) signPayment(paymentHeaderB64 string) (string, error) {
// ── Format overrides for Anthropic endpoints ─────────────────────────────────
// stripMaxTokens removes per-call max_tokens caps from a body destined for
// claw402. The gateway already enforces a per-route default/floor/cap
// (see providers/*.yaml token_default_max_out / token_min_max_out /
// token_max_out_cap). Sending a small max_tokens here on a thinking model
// (Kimi K2.5, DeepSeek R1/V4) caused reasoning tokens to consume the entire
// budget and left `delta.content` empty, surfacing as "no content received".
// upto settles on real usage, so removing the cap costs nothing extra.
func stripMaxTokens(body map[string]any) map[string]any {
if body == nil {
return body
}
delete(body, "max_tokens")
delete(body, "max_completion_tokens")
return body
}
func (c *Claw402Client) BuildMCPRequestBody(systemPrompt, userPrompt string) map[string]any {
if c.claudeProxy != nil {
return c.claudeProxy.BuildMCPRequestBody(systemPrompt, userPrompt)
}
return stripMaxTokens(c.Client.BuildMCPRequestBody(systemPrompt, userPrompt))
return c.Client.BuildMCPRequestBody(systemPrompt, userPrompt)
}
func (c *Claw402Client) BuildRequestBodyFromRequest(req *mcp.Request) map[string]any {
if c.claudeProxy != nil {
return c.claudeProxy.BuildRequestBodyFromRequest(req)
}
return stripMaxTokens(c.Client.BuildRequestBodyFromRequest(req))
return c.Client.BuildRequestBodyFromRequest(req)
}
func (c *Claw402Client) ParseMCPResponse(body []byte) (string, error) {

View File

@@ -452,8 +452,7 @@ func X402CallStream(c *mcp.Client, signFn X402SignFunc, tag string, systemPrompt
var bodyBuf bytes.Buffer
tee := io.TeeReader(resp.Body, &bodyBuf)
text, usage, sseErr := mcp.ParseSSEStream(tee, onChunk, onLine)
mcp.ReportStreamUsage(usage, c.Provider, c.Model)
text, sseErr := mcp.ParseSSEStream(tee, onChunk, onLine)
if text != "" {
c.Log.Infof("📡 [%s] SSE stream complete, got %d chars", tag, len(text))

View File

@@ -8,7 +8,7 @@ import (
const (
DefaultGeminiBaseURL = "https://generativelanguage.googleapis.com/v1beta/openai"
DefaultGeminiModel = "gemini-3.1-pro"
DefaultGeminiModel = "gemini-3-pro-preview"
)
func init() {

View File

@@ -1,7 +1,5 @@
package mcp
import "context"
// Message represents a conversation message.
// Supports plain messages (Role+Content), assistant tool-call messages (ToolCalls),
// and tool result messages (Role="tool", ToolCallID, Content).
@@ -64,9 +62,6 @@ type Request struct {
// Advanced features
Tools []Tool `json:"tools,omitempty"` // Available tools list
ToolChoice string `json:"tool_choice,omitempty"` // Tool choice strategy ("auto", "none", {"type": "function", "function": {"name": "xxx"}})
// Context for cancellation; not serialized.
Ctx context.Context `json:"-"`
}
// NewMessage creates a message

View File

@@ -1,59 +0,0 @@
// Package safe provides panic-recovery wrappers for goroutines.
// A panic in any bare goroutine tears down the entire process.
// Use safe.Go instead of `go func()` in long-running or critical paths.
package safe
import (
"fmt"
"nofx/logger"
"runtime/debug"
)
// Go launches fn in a new goroutine with automatic panic recovery.
// If fn panics, the panic is logged (with stack trace) but the process
// continues running. An optional onPanic callback receives the recovered value.
func Go(fn func(), onPanic ...func(recovered interface{})) {
go func() {
defer func() {
if r := recover(); r != nil {
stack := string(debug.Stack())
logger.Errorf("🔥 goroutine panic recovered: %v\n%s", r, stack)
for _, cb := range onPanic {
func() {
defer func() {
if r2 := recover(); r2 != nil {
logger.Errorf("🔥 onPanic callback itself panicked: %v", r2)
}
}()
cb(r)
}()
}
}
}()
fn()
}()
}
// GoNamed is like Go but tags the log line with a human-readable name.
func GoNamed(name string, fn func(), onPanic ...func(recovered interface{})) {
Go(func() {
fn()
}, append([]func(interface{}){
func(r interface{}) {
logger.Errorf("🔥 [%s] goroutine panicked: %v", name, r)
},
}, onPanic...)...)
}
// Must converts a panic into an error. Useful inside goroutines where you
// want to handle panics as errors in the caller's recovery flow.
func Must(fn func()) (err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("panic: %v\n%s", r, debug.Stack())
}
}()
fn()
return nil
}

View File

@@ -1,29 +0,0 @@
// Package safe provides safe I/O helpers.
package safe
import (
"fmt"
"io"
)
// MaxResponseBody is the default maximum size for HTTP response bodies (10MB).
const MaxResponseBody = 10 * 1024 * 1024
// ReadAllLimited reads all bytes from r up to maxBytes.
// If maxBytes <= 0, it defaults to MaxResponseBody (10MB).
// Returns an error if the response exceeds the limit.
func ReadAllLimited(r io.Reader, maxBytes ...int64) ([]byte, error) {
limit := int64(MaxResponseBody)
if len(maxBytes) > 0 && maxBytes[0] > 0 {
limit = maxBytes[0]
}
lr := io.LimitReader(r, limit+1)
data, err := io.ReadAll(lr)
if err != nil {
return nil, err
}
if int64(len(data)) > limit {
return nil, fmt.Errorf("response body exceeds %d bytes limit", limit)
}
return data, nil
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

View File

@@ -22,20 +22,18 @@ func (AICharge) TableName() string { return "ai_charges" }
var modelPrices = map[string]float64{
"deepseek": 0.003,
"deepseek-reasoner": 0.005,
"deepseek-v4-flash": 0.003,
"deepseek-v4-pro": 0.01,
"gpt-5.4": 0.05,
"gpt-5.4-pro": 0.50,
"gpt-5.3": 0.01,
"gpt-5-mini": 0.005,
"claude-opus": 0.12,
"qwen-max": 0.01,
"qwen-plus": 0.005,
"qwen-turbo": 0.002,
"qwen-flash": 0.002,
"grok-4.1": 0.06,
"gemini-3.1-pro": 0.03,
"kimi-k2.5": 0.008,
"claude-opus": 0.12,
"qwen-max": 0.01,
"qwen-plus": 0.005,
"qwen-turbo": 0.002,
"qwen-flash": 0.002,
"grok-4.1": 0.06,
"gemini-3.1-pro": 0.03,
"kimi-k2.5": 0.008,
}
// GetModelPrice returns the price per call for a given model

View File

@@ -131,7 +131,7 @@ func (s *AIModelStore) GetDefault(userID string) (*AIModel, error) {
if userID == "" {
userID = "default"
}
model, err := s.firstEnabledUsable(userID)
model, err := s.firstEnabled(userID)
if err == nil {
return model, nil
}
@@ -139,14 +139,14 @@ func (s *AIModelStore) GetDefault(userID string) (*AIModel, error) {
return nil, err
}
if userID != "default" {
return s.firstEnabledUsable("default")
return s.firstEnabled("default")
}
return nil, fmt.Errorf("please configure an available AI model in the system first")
}
func (s *AIModelStore) firstEnabledUsable(userID string) (*AIModel, error) {
func (s *AIModelStore) firstEnabled(userID string) (*AIModel, error) {
var model AIModel
err := s.db.Where("user_id = ? AND enabled = ? AND api_key != ''", userID, true).
err := s.db.Where("user_id = ? AND enabled = ?", userID, true).
Order("updated_at DESC, id ASC").
First(&model).Error
if err != nil {
@@ -253,43 +253,6 @@ func (s *AIModelStore) Update(userID, id string, enabled bool, apiKey, customAPI
}
// Create creates an AI model
// ResolveClaw402WalletKey returns the claw402 wallet private key for a user.
// If preferredModelID is non-empty and points to a claw402 model, its key is returned first.
// Otherwise the first enabled claw402 model in the user's model list is used.
// Returns ("", nil) when no claw402 model is configured — callers should treat this as
// "no paid data routing" rather than an error.
func (s *AIModelStore) ResolveClaw402WalletKey(userID, preferredModelID string) (string, error) {
if preferredModelID != "" {
model, err := s.Get(userID, preferredModelID)
if err != nil {
return "", fmt.Errorf("failed to load selected AI model")
}
if model.Provider == "claw402" {
walletKey := string(model.APIKey)
if walletKey == "" {
return "", fmt.Errorf("selected claw402 model is missing wallet private key")
}
return walletKey, nil
}
}
models, err := s.List(userID)
if err != nil {
return "", fmt.Errorf("failed to load AI models")
}
for _, model := range models {
if model == nil || model.Provider != "claw402" {
continue
}
if walletKey := string(model.APIKey); walletKey != "" {
return walletKey, nil
}
}
return "", nil
}
func (s *AIModelStore) Create(userID, id, name, provider string, enabled bool, apiKey, customAPIURL string) error {
model := &AIModel{
ID: id,
@@ -303,16 +266,3 @@ func (s *AIModelStore) Create(userID, id, name, provider string, enabled bool, a
// Use FirstOrCreate to ignore if already exists
return s.db.Where("id = ?", id).FirstOrCreate(model).Error
}
// Delete removes a user-owned AI model configuration.
func (s *AIModelStore) Delete(userID, id string) error {
result := s.db.Where("user_id = ? AND id = ?", userID, id).Delete(&AIModel{})
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return fmt.Errorf("ai model not found: id=%s, userID=%s", id, userID)
}
logger.Infof("🗑️ Deleted AI model: id=%s, userID=%s", id, userID)
return nil
}

View File

@@ -444,9 +444,6 @@ func (s *StrategyStore) Delete(userID, id string) error {
if st.IsDefault {
return fmt.Errorf("cannot delete system default strategy")
}
if st.IsActive {
return fmt.Errorf("cannot delete active strategy")
}
}
// Check if any trader references this strategy

View File

@@ -2,13 +2,14 @@ package trader
import (
"fmt"
"github.com/ethereum/go-ethereum/crypto"
"nofx/kernel"
"nofx/logger"
"nofx/mcp"
_ "nofx/mcp/payment"
_ "nofx/mcp/provider"
"nofx/store"
"nofx/wallet"
"github.com/ethereum/go-ethereum/crypto"
"nofx/trader/aster"
"nofx/trader/binance"
"nofx/trader/bitget"
@@ -19,36 +20,10 @@ import (
"nofx/trader/kucoin"
"nofx/trader/lighter"
"nofx/trader/okx"
"nofx/wallet"
"sync"
"time"
)
func (at *AutoTrader) logTag() string {
if at == nil {
return "[trader_id=unknown]"
}
if at.name != "" {
return fmt.Sprintf("[trader_id=%s trader_name=%s]", at.id, at.name)
}
return fmt.Sprintf("[trader_id=%s]", at.id)
}
func (at *AutoTrader) logInfof(format string, args ...interface{}) {
values := append([]interface{}{at.logTag()}, args...)
logger.Infof("%s "+format, values...)
}
func (at *AutoTrader) logWarnf(format string, args ...interface{}) {
values := append([]interface{}{at.logTag()}, args...)
logger.Warnf("%s "+format, values...)
}
func (at *AutoTrader) logErrorf(format string, args ...interface{}) {
values := append([]interface{}{at.logTag()}, args...)
logger.Errorf("%s "+format, values...)
}
// AutoTraderConfig auto trading configuration (simplified version - AI makes all decisions)
type AutoTraderConfig struct {
// Trader identification
@@ -115,10 +90,9 @@ type AutoTraderConfig struct {
QwenKey string
// Custom AI API configuration
CustomAPIURL string
CustomAPIKey string
CustomModelName string
Claw402WalletKey string
CustomAPIURL string
CustomAPIKey string
CustomModelName string
// Scan configuration
ScanInterval time.Duration // Scan interval (recommended 3 minutes)
@@ -174,9 +148,9 @@ type AutoTrader struct {
userID string // User ID
gridState *GridState // Grid trading state (only used when StrategyType == "grid_trading")
claw402WalletAddr string // Claw402 wallet address (derived from private key at start)
consecutiveAIFailures int // Consecutive AI call failures
safeMode bool // Safe mode: no new positions, protect existing ones
safeModeReason string // Why safe mode was activated
consecutiveAIFailures int // Consecutive AI call failures
safeMode bool // Safe mode: no new positions, protect existing ones
safeModeReason string // Why safe mode was activated
}
// NewAutoTrader creates an automatic trader
@@ -361,8 +335,8 @@ func NewAutoTrader(config AutoTraderConfig, st *store.Store, userID string) (*Au
}
// Pass claw402 wallet key to strategy engine so nofxos data requests
// are routed through claw402 (reuses the same wallet as AI calls)
claw402Key := config.Claw402WalletKey
if claw402Key == "" && config.AIModel == "claw402" && config.CustomAPIKey != "" {
var claw402Key string
if config.AIModel == "claw402" && config.CustomAPIKey != "" {
claw402Key = config.CustomAPIKey
}
strategyEngine := kernel.NewStrategyEngine(config.StrategyConfig, claw402Key)
@@ -406,8 +380,8 @@ func (at *AutoTrader) Run() error {
at.startTime = time.Now()
logger.Info("🚀 AI-driven automatic trading system started")
at.logInfof("💰 Initial balance: %.2f USDT", at.initialBalance)
at.logInfof("⚙️ Scan interval: %v", at.config.ScanInterval)
logger.Infof("💰 Initial balance: %.2f USDT", at.initialBalance)
logger.Infof("⚙️ Scan interval: %v", at.config.ScanInterval)
logger.Info("🤖 AI will make full decisions on leverage, position size, stop loss/take profit, etc.")
// Pre-launch checks for claw402 users
@@ -422,7 +396,7 @@ func (at *AutoTrader) Run() error {
if at.exchange == "lighter" {
if lighterTrader, ok := at.trader.(*lighter.LighterTraderV2); ok && at.store != nil {
lighterTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second)
at.logInfof("🔄 Lighter order+position sync enabled (every 30s)")
logger.Infof("🔄 [%s] Lighter order+position sync enabled (every 30s)", at.name)
}
}
@@ -430,7 +404,7 @@ func (at *AutoTrader) Run() error {
if at.exchange == "hyperliquid" {
if hyperliquidTrader, ok := at.trader.(*hyperliquid.HyperliquidTrader); ok && at.store != nil {
hyperliquidTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second)
at.logInfof("🔄 Hyperliquid order+position sync enabled (every 30s)")
logger.Infof("🔄 [%s] Hyperliquid order+position sync enabled (every 30s)", at.name)
}
}
@@ -438,7 +412,7 @@ func (at *AutoTrader) Run() error {
if at.exchange == "bybit" {
if bybitTrader, ok := at.trader.(*bybit.BybitTrader); ok && at.store != nil {
bybitTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second)
at.logInfof("🔄 Bybit order+position sync enabled (every 30s)")
logger.Infof("🔄 [%s] Bybit order+position sync enabled (every 30s)", at.name)
}
}
@@ -446,7 +420,7 @@ func (at *AutoTrader) Run() error {
if at.exchange == "okx" {
if okxTrader, ok := at.trader.(*okx.OKXTrader); ok && at.store != nil {
okxTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second)
at.logInfof("🔄 OKX order+position sync enabled (every 30s)")
logger.Infof("🔄 [%s] OKX order+position sync enabled (every 30s)", at.name)
}
}
@@ -454,7 +428,7 @@ func (at *AutoTrader) Run() error {
if at.exchange == "bitget" {
if bitgetTrader, ok := at.trader.(*bitget.BitgetTrader); ok && at.store != nil {
bitgetTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second)
at.logInfof("🔄 Bitget order+position sync enabled (every 30s)")
logger.Infof("🔄 [%s] Bitget order+position sync enabled (every 30s)", at.name)
}
}
@@ -462,7 +436,7 @@ func (at *AutoTrader) Run() error {
if at.exchange == "aster" {
if asterTrader, ok := at.trader.(*aster.AsterTrader); ok && at.store != nil {
asterTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second)
at.logInfof("🔄 Aster order+position sync enabled (every 30s)")
logger.Infof("🔄 [%s] Aster order+position sync enabled (every 30s)", at.name)
}
}
@@ -470,7 +444,7 @@ func (at *AutoTrader) Run() error {
if at.exchange == "binance" {
if binanceTrader, ok := at.trader.(*binance.FuturesTrader); ok && at.store != nil {
binanceTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second)
at.logInfof("🔄 Binance order+position sync enabled (every 30s)")
logger.Infof("🔄 [%s] Binance order+position sync enabled (every 30s)", at.name)
}
}
@@ -478,7 +452,7 @@ func (at *AutoTrader) Run() error {
if at.exchange == "gate" {
if gateTrader, ok := at.trader.(*gate.GateTrader); ok && at.store != nil {
gateTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second)
at.logInfof("🔄 Gate order+position sync enabled (every 30s)")
logger.Infof("🔄 [%s] Gate order+position sync enabled (every 30s)", at.name)
}
}
@@ -486,7 +460,7 @@ func (at *AutoTrader) Run() error {
if at.exchange == "kucoin" {
if kucoinTrader, ok := at.trader.(*kucoin.KuCoinTrader); ok && at.store != nil {
kucoinTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second)
at.logInfof("🔄 KuCoin order+position sync enabled (every 30s)")
logger.Infof("🔄 [%s] KuCoin order+position sync enabled (every 30s)", at.name)
}
}
@@ -496,9 +470,9 @@ func (at *AutoTrader) Run() error {
// Check if this is a grid trading strategy
isGridStrategy := at.IsGridStrategy()
if isGridStrategy {
at.logInfof("🔲 Grid trading strategy detected, initializing grid...")
logger.Infof("🔲 [%s] Grid trading strategy detected, initializing grid...", at.name)
if err := at.InitializeGrid(); err != nil {
at.logErrorf("❌ Failed to initialize grid: %v", err)
logger.Errorf("❌ [%s] Failed to initialize grid: %v", at.name, err)
return fmt.Errorf("grid initialization failed: %w", err)
}
}
@@ -506,11 +480,11 @@ func (at *AutoTrader) Run() error {
// Execute immediately on first run
if isGridStrategy {
if err := at.RunGridCycle(); err != nil {
at.logErrorf("❌ Grid execution failed: %v", err)
logger.Infof("❌ Grid execution failed: %v", err)
}
} else {
if err := at.runCycle(); err != nil {
at.logErrorf("❌ Execution failed: %v", err)
logger.Infof("❌ Execution failed: %v", err)
}
}
@@ -527,15 +501,15 @@ func (at *AutoTrader) Run() error {
case <-ticker.C:
if isGridStrategy {
if err := at.RunGridCycle(); err != nil {
at.logErrorf("❌ Grid execution failed: %v", err)
logger.Infof("❌ Grid execution failed: %v", err)
}
} else {
if err := at.runCycle(); err != nil {
at.logErrorf("❌ Execution failed: %v", err)
logger.Infof("❌ Execution failed: %v", err)
}
}
case <-at.stopMonitorCh:
at.logInfof("⏹ Stop signal received, exiting automatic trading main loop")
logger.Infof("[%s] ⏹ Stop signal received, exiting automatic trading main loop", at.name)
return nil
}
}
@@ -615,22 +589,6 @@ func (at *AutoTrader) GetSystemPromptTemplate() string {
return "strategy"
}
// GetCandidateCoins returns the current candidate coin set from the trader's strategy engine.
func (at *AutoTrader) GetCandidateCoins() ([]kernel.CandidateCoin, error) {
if at.strategyEngine == nil {
return nil, fmt.Errorf("strategy engine not configured")
}
return at.strategyEngine.GetCandidateCoins()
}
// GetStrategyConfig returns the current strategy config used by the trader.
func (at *AutoTrader) GetStrategyConfig() *store.StrategyConfig {
if at.strategyEngine == nil {
return at.config.StrategyConfig
}
return at.strategyEngine.GetConfig()
}
// GetStore gets data store (for external access to decision records, etc.)
func (at *AutoTrader) GetStore() *store.Store {
return at.store

View File

@@ -324,17 +324,6 @@ func (at *AutoTrader) InitializeGrid() error {
at.gridState.IsInitialized = true
// Keep grid orders aligned with the trader's configured cross/isolated mode.
if err := at.trader.SetMarginMode(gridConfig.Symbol, at.config.IsCrossMargin); err != nil {
logger.Warnf("[Grid] Failed to set margin mode for %s: %v", gridConfig.Symbol, err)
} else {
marginMode := "cross"
if !at.config.IsCrossMargin {
marginMode = "isolated"
}
logger.Infof("[Grid] Margin mode set to %s for %s", marginMode, gridConfig.Symbol)
}
// CRITICAL: Set leverage on exchange before trading
if err := at.trader.SetLeverage(gridConfig.Symbol, gridConfig.Leverage); err != nil {
logger.Warnf("[Grid] Failed to set leverage %dx on exchange: %v", gridConfig.Leverage, err)

View File

@@ -24,7 +24,7 @@ func (at *AutoTrader) runCycle() error {
running := at.isRunning
at.isRunningMutex.RUnlock()
if !running {
at.logInfof("⏹ Trader is stopped, aborting cycle #%d", at.callCount)
logger.Infof("⏹ Trader is stopped, aborting cycle #%d", at.callCount)
return nil
}
@@ -42,7 +42,7 @@ func (at *AutoTrader) runCycle() error {
// 1. Check if trading needs to be stopped
if time.Now().Before(at.stopUntil) {
remaining := at.stopUntil.Sub(time.Now())
at.logWarnf("⏸ Risk control: Trading paused, remaining %.0f minutes", remaining.Minutes())
logger.Infof("⏸ Risk control: Trading paused, remaining %.0f minutes", remaining.Minutes())
record.Success = false
record.ErrorMessage = fmt.Sprintf("Risk control paused, remaining %.0f minutes", remaining.Minutes())
at.saveDecision(record)
@@ -59,7 +59,6 @@ func (at *AutoTrader) runCycle() error {
// 4. Collect trading context
ctx, err := at.buildTradingContext()
if err != nil {
at.logErrorf("failed to build trading context: %v", err)
record.Success = false
record.ErrorMessage = fmt.Sprintf("Failed to build trading context: %v", err)
at.saveDecision(record)
@@ -72,7 +71,7 @@ func (at *AutoTrader) runCycle() error {
// If no candidate coins available, log but do not error
if len(ctx.CandidateCoins) == 0 {
at.logInfof(" No candidate coins available, skipping this cycle")
logger.Infof(" No candidate coins available, skipping this cycle")
record.Success = true // Not an error, just no candidate coins
record.ExecutionLog = append(record.ExecutionLog, "No candidate coins available, cycle skipped")
record.AccountState = store.AccountSnapshot{
@@ -91,16 +90,16 @@ func (at *AutoTrader) runCycle() error {
record.CandidateCoins = append(record.CandidateCoins, coin.Symbol)
}
at.logInfof("📊 Account equity: %.2f USDT | Available: %.2f USDT | Positions: %d",
logger.Infof("📊 Account equity: %.2f USDT | Available: %.2f USDT | Positions: %d",
ctx.Account.TotalEquity, ctx.Account.AvailableBalance, ctx.Account.PositionCount)
// 5. Use strategy engine to call AI for decision
at.logInfof("🤖 Requesting AI analysis and decision... [Strategy Engine]")
logger.Infof("🤖 Requesting AI analysis and decision... [Strategy Engine]")
aiDecision, err := kernel.GetFullDecisionWithStrategy(ctx, at.mcpClient, at.strategyEngine, "balanced")
if aiDecision != nil && aiDecision.AIRequestDurationMs > 0 {
record.AIRequestDurationMs = aiDecision.AIRequestDurationMs
at.logInfof("⏱️ AI call duration: %.2f seconds", float64(record.AIRequestDurationMs)/1000)
logger.Infof("⏱️ AI call duration: %.2f seconds", float64(record.AIRequestDurationMs)/1000)
record.ExecutionLog = append(record.ExecutionLog,
fmt.Sprintf("AI call duration: %d ms", record.AIRequestDurationMs))
}
@@ -120,7 +119,7 @@ func (at *AutoTrader) runCycle() error {
// Record AI charge (track cost regardless of decision outcome)
if aiDecision != nil && at.store != nil {
if chargeErr := at.store.AICharge().Record(at.id, at.aiModel, at.config.AIModel); chargeErr != nil {
at.logWarnf("⚠️ Failed to record AI charge: %v", chargeErr)
logger.Warnf("⚠️ Failed to record AI charge: %v", chargeErr)
}
}
@@ -133,9 +132,10 @@ func (at *AutoTrader) runCycle() error {
if at.consecutiveAIFailures >= 3 && !at.safeMode {
at.safeMode = true
at.safeModeReason = fmt.Sprintf("AI failed %d consecutive times: %v", at.consecutiveAIFailures, err)
at.logErrorf("🛡️ SAFE MODE ACTIVATED — AI failed %d times in a row. No new positions will be opened. Existing positions are protected with current stop-loss settings.", at.consecutiveAIFailures)
at.logErrorf("🛡️ Reason: %v", err)
at.logErrorf("🛡️ Action: Will keep trying AI each cycle. Safe mode auto-deactivates when AI recovers.")
logger.Errorf("🛡️ [%s] SAFE MODE ACTIVATED — AI failed %d times in a row. No new positions will be opened. Existing positions are protected with current stop-loss settings.",
at.name, at.consecutiveAIFailures)
logger.Errorf("🛡️ [%s] Reason: %v", at.name, err)
logger.Errorf("🛡️ [%s] Action: Will keep trying AI each cycle. Safe mode auto-deactivates when AI recovers.", at.name)
}
// Print system prompt and AI chain of thought (output even with errors for debugging)
@@ -159,7 +159,7 @@ func (at *AutoTrader) runCycle() error {
// In safe mode, don't return error — keep the loop running to retry next cycle
if at.safeMode {
at.logWarnf("🛡️ Safe mode: skipping this cycle, will retry in %v", at.config.ScanInterval)
logger.Warnf("🛡️ [%s] Safe mode: skipping this cycle, will retry in %v", at.name, at.config.ScanInterval)
return nil
}
@@ -168,11 +168,11 @@ func (at *AutoTrader) runCycle() error {
// AI succeeded — reset failure counter and deactivate safe mode
if at.consecutiveAIFailures > 0 {
at.logInfof("✅ AI recovered after %d consecutive failures", at.consecutiveAIFailures)
logger.Infof("✅ [%s] AI recovered after %d consecutive failures", at.name, at.consecutiveAIFailures)
}
at.consecutiveAIFailures = 0
if at.safeMode {
at.logInfof("🛡️ SAFE MODE DEACTIVATED — AI is working again. Resuming normal trading.")
logger.Infof("🛡️ [%s] SAFE MODE DEACTIVATED — AI is working again. Resuming normal trading.", at.name)
at.safeMode = false
at.safeModeReason = ""
}
@@ -219,7 +219,7 @@ func (at *AutoTrader) runCycle() error {
running = at.isRunning
at.isRunningMutex.RUnlock()
if !running {
at.logInfof("⏹ Trader stopped before decision execution, aborting cycle #%d", at.callCount)
logger.Infof("⏹ Trader stopped before decision execution, aborting cycle #%d", at.callCount)
return nil
}
@@ -228,14 +228,14 @@ func (at *AutoTrader) runCycle() error {
filtered := make([]kernel.Decision, 0)
for _, d := range sortedDecisions {
if d.Action == "open_long" || d.Action == "open_short" {
at.logWarnf("🛡️ Safe mode: BLOCKED %s %s (no new positions allowed)", d.Action, d.Symbol)
logger.Warnf("🛡️ [%s] Safe mode: BLOCKED %s %s (no new positions allowed)", at.name, d.Action, d.Symbol)
continue
}
filtered = append(filtered, d)
}
sortedDecisions = filtered
if len(sortedDecisions) == 0 {
at.logInfof("🛡️ Safe mode: all decisions were open positions, nothing to execute")
logger.Infof("🛡️ [%s] Safe mode: all decisions were open positions, nothing to execute", at.name)
}
}
@@ -246,7 +246,7 @@ func (at *AutoTrader) runCycle() error {
running = at.isRunning
at.isRunningMutex.RUnlock()
if !running {
at.logInfof("⏹ Trader stopped during decision execution, aborting remaining decisions")
logger.Infof("⏹ Trader stopped during decision execution, aborting remaining decisions")
break
}
@@ -265,7 +265,7 @@ func (at *AutoTrader) runCycle() error {
}
if err := at.executeDecisionWithRecord(&d, &actionRecord); err != nil {
at.logErrorf("❌ Failed to execute decision (%s %s): %v", d.Symbol, d.Action, err)
logger.Infof("❌ Failed to execute decision (%s %s): %v", d.Symbol, d.Action, err)
actionRecord.Error = err.Error()
record.ExecutionLog = append(record.ExecutionLog, fmt.Sprintf("❌ %s %s failed: %v", d.Symbol, d.Action, err))
} else {
@@ -280,7 +280,7 @@ func (at *AutoTrader) runCycle() error {
// 9. Save decision record
if err := at.saveDecision(record); err != nil {
at.logWarnf("⚠ Failed to save decision record: %v", err)
logger.Infof("⚠ Failed to save decision record: %v", err)
}
return nil
@@ -417,12 +417,12 @@ func (at *AutoTrader) buildTradingContext() (*kernel.Context, error) {
// 3. Use strategy engine to get candidate coins (must have strategy engine)
var candidateCoins []kernel.CandidateCoin
if at.strategyEngine == nil {
at.logWarnf("⚠️ No strategy engine configured, skipping candidate coins")
logger.Infof("⚠️ [%s] No strategy engine configured, skipping candidate coins", at.name)
} else {
coins, err := at.strategyEngine.GetCandidateCoins()
if err != nil {
// Log warning but don't fail - equity snapshot should still be saved
at.logWarnf("⚠️ Failed to get candidate coins: %v (will use empty list)", err)
logger.Infof("⚠️ [%s] Failed to get candidate coins: %v (will use empty list)", at.name, err)
} else {
candidateCoins = coins
logger.Infof("📋 [%s] Strategy engine fetched candidate coins: %d", at.name, len(candidateCoins))
@@ -473,7 +473,7 @@ func (at *AutoTrader) buildTradingContext() (*kernel.Context, error) {
// Get recent 10 closed trades for AI context
recentTrades, err := at.store.Position().GetRecentTrades(at.id, 10)
if err != nil {
at.logWarnf("⚠️ Failed to get recent trades: %v", err)
logger.Infof("⚠️ [%s] Failed to get recent trades: %v", at.name, err)
} else {
logger.Infof("📊 [%s] Found %d recent closed trades for AI context", at.name, len(recentTrades))
for _, trade := range recentTrades {
@@ -503,11 +503,11 @@ func (at *AutoTrader) buildTradingContext() (*kernel.Context, error) {
// Get trading statistics for AI context
stats, err := at.store.Position().GetFullStats(at.id)
if err != nil {
at.logWarnf("⚠️ Failed to get trading stats: %v", err)
logger.Infof("⚠️ [%s] Failed to get trading stats: %v", at.name, err)
} else if stats == nil {
at.logWarnf("⚠️ GetFullStats returned nil")
logger.Infof("⚠️ [%s] GetFullStats returned nil", at.name)
} else if stats.TotalTrades == 0 {
at.logWarnf("⚠️ GetFullStats returned 0 trades")
logger.Infof("⚠️ [%s] GetFullStats returned 0 trades (traderID=%s)", at.name, at.id)
} else {
ctx.TradingStats = &kernel.TradingStats{
TotalTrades: stats.TotalTrades,
@@ -523,7 +523,7 @@ func (at *AutoTrader) buildTradingContext() (*kernel.Context, error) {
at.name, stats.TotalTrades, stats.WinRate, stats.ProfitFactor, stats.SharpeRatio, stats.MaxDrawdownPct)
}
} else {
at.logWarnf("⚠️ Store is nil, cannot get recent trades")
logger.Infof("⚠️ [%s] Store is nil, cannot get recent trades", at.name)
}
// 8. Get quantitative data (if enabled in strategy config)
@@ -630,15 +630,15 @@ func (at *AutoTrader) checkClaw402Balance() {
if at.claw402WalletAddr != "" {
balance, err := wallet.QueryUSDCBalance(at.claw402WalletAddr)
if err != nil {
at.logWarnf("⚠️ Failed to query USDC balance: %v", err)
logger.Warnf("⚠️ [%s] Failed to query USDC balance: %v", at.name, err)
return
}
if balance < 1.0 {
at.logWarnf("⚠️ Low USDC balance: $%.2f — AI may stop soon!", balance)
logger.Warnf("⚠️ [%s] Low USDC balance: $%.2f — AI may stop soon!", at.name, balance)
}
if balance <= 0 {
at.logErrorf("🚨 USDC balance is ZERO — AI calls will fail!")
logger.Errorf("🚨 [%s] USDC balance is ZERO — AI calls will fail!", at.name)
}
runway := float64(0)

View File

@@ -41,7 +41,7 @@ type OKXTrader struct {
secretKey string
passphrase string
// Margin mode setting used for new orders and leverage changes.
// Margin mode setting
isCrossMargin bool
// Position mode: "long_short_mode" (hedge) or "net_mode" (one-way)
@@ -121,7 +121,6 @@ func NewOKXTrader(apiKey, secretKey, passphrase string) *OKXTrader {
apiKey: apiKey,
secretKey: secretKey,
passphrase: passphrase,
isCrossMargin: true,
httpClient: httpClient,
cacheDuration: 15 * time.Second,
instrumentsCache: make(map[string]*OKXInstrument),
@@ -140,18 +139,10 @@ func NewOKXTrader(apiKey, secretKey, passphrase string) *OKXTrader {
}
}
logger.Infof("✓ OKX trader initialized with position mode: %s, default margin mode: %s",
trader.positionMode, trader.marginMode())
logger.Infof("✓ OKX trader initialized with position mode: %s", trader.positionMode)
return trader
}
func (t *OKXTrader) marginMode() string {
if t.isCrossMargin {
return "cross"
}
return "isolated"
}
// detectPositionMode gets current position mode from account config
func (t *OKXTrader) detectPositionMode() error {
data, err := t.doRequest("GET", okxAccountConfigPath, nil)

View File

@@ -80,42 +80,49 @@ func (t *OKXTrader) GetBalance() (map[string]interface{}, error) {
return result, nil
}
// SetMarginMode configures the margin mode (cross/isolated) that will be applied
// to all subsequent leverage and order requests for this trader instance.
//
// OKX V5 unified accounts do not expose a per-symbol mode-switch endpoint that
// works reliably — the legacy /api/v5/account/set-isolated-mode endpoint returns
// error 51000 ("Parameter isoMode error") when called on a unified account.
// Instead, OKX applies the mode per-request via the mgnMode field on
// /api/v5/account/set-leverage and via the tdMode field on order placement.
//
// This implementation therefore stores the configured mode locally and injects it
// into each subsequent API request, rather than making an API call here.
// NOTE: unlike Binance/Bybit implementations of this interface, no network call
// is made — the method only updates local state.
// SetMarginMode sets margin mode
func (t *OKXTrader) SetMarginMode(symbol string, isCrossMargin bool) error {
t.isCrossMargin = isCrossMargin
mgnMode := t.marginMode()
instId := t.convertSymbol(symbol)
// OKX V5 unified account applies cross/isolated per order via tdMode,
// while leverage uses mgnMode on /account/set-leverage.
// Persist the configured mode locally so subsequent leverage/order calls use it,
// instead of calling the legacy isolated-mode endpoint that returns 51000 errors.
logger.Infof(" ✓ %s margin mode configured as %s (applied via tdMode/mgnMode on subsequent requests)", symbol, mgnMode)
mgnMode := "isolated"
if isCrossMargin {
mgnMode = "cross"
}
body := map[string]interface{}{
"instId": instId,
"mgnMode": mgnMode,
}
_, err := t.doRequest("POST", "/api/v5/account/set-isolated-mode", body)
if err != nil {
// Ignore error if already in target mode
if strings.Contains(err.Error(), "already") {
logger.Infof(" ✓ %s margin mode is already %s", symbol, mgnMode)
return nil
}
// Cannot change when there are positions
if strings.Contains(err.Error(), "position") {
logger.Infof(" ⚠️ %s has positions, cannot change margin mode", symbol)
return nil
}
return err
}
logger.Infof(" ✓ %s margin mode set to %s", symbol, mgnMode)
return nil
}
// SetLeverage sets leverage
func (t *OKXTrader) SetLeverage(symbol string, leverage int) error {
instId := t.convertSymbol(symbol)
marginMode := t.marginMode()
// Set leverage for both long and short
for _, posSide := range []string{"long", "short"} {
body := map[string]interface{}{
"instId": instId,
"lever": strconv.Itoa(leverage),
"mgnMode": marginMode,
"mgnMode": "cross",
"posSide": posSide,
}
@@ -129,7 +136,7 @@ func (t *OKXTrader) SetLeverage(symbol string, leverage int) error {
}
}
logger.Infof(" ✓ %s leverage set to %dx (%s)", symbol, leverage, marginMode)
logger.Infof(" ✓ %s leverage set to %dx", symbol, leverage)
return nil
}

View File

@@ -1,247 +0,0 @@
package okx
import (
"bytes"
"encoding/json"
"io"
"net/http"
"strings"
"testing"
"time"
"nofx/trader/types"
)
type capturedRequest struct {
Method string
Path string
Body map[string]interface{}
}
type recordingTransport struct {
requests []capturedRequest
}
func (rt *recordingTransport) RoundTrip(req *http.Request) (*http.Response, error) {
var body map[string]interface{}
if req.Body != nil {
data, _ := io.ReadAll(req.Body)
if len(data) > 0 && strings.HasPrefix(strings.TrimSpace(string(data)), "{") {
_ = json.Unmarshal(data, &body)
}
}
rt.requests = append(rt.requests, capturedRequest{
Method: req.Method,
Path: req.URL.Path,
Body: body,
})
response := `{"code":"0","msg":"","data":[]}`
switch req.URL.Path {
case okxInstrumentsPath:
response = `{"code":"0","msg":"","data":[{"instId":"BTC-USDT-SWAP","ctVal":"0.01","ctMult":"1","lotSz":"1","minSz":"1","maxMktSz":"100000","tickSz":"0.1","ctType":"linear"}]}`
case okxOrderPath:
response = `{"code":"0","msg":"","data":[{"ordId":"123","clOrdId":"abc","sCode":"0","sMsg":""}]}`
}
return &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: io.NopCloser(bytes.NewBufferString(response)),
}, nil
}
func (rt *recordingTransport) requestsForPath(path string) []capturedRequest {
var matches []capturedRequest
for _, req := range rt.requests {
if req.Path == path {
matches = append(matches, req)
}
}
return matches
}
func newTestOKXTrader(rt *recordingTransport, isCrossMargin bool) *OKXTrader {
return &OKXTrader{
apiKey: "key",
secretKey: "secret",
passphrase: "pass",
isCrossMargin: isCrossMargin,
positionMode: "long_short_mode",
httpClient: &http.Client{
Transport: rt,
},
cacheDuration: 15 * time.Second,
instrumentsCache: make(map[string]*OKXInstrument),
instrumentsCacheTime: time.Now(),
}
}
func TestOKXSetLeverageUsesConfiguredMarginMode(t *testing.T) {
rt := &recordingTransport{}
trader := newTestOKXTrader(rt, false)
if err := trader.SetLeverage("BTCUSDT", 5); err != nil {
t.Fatalf("SetLeverage failed: %v", err)
}
leverageRequests := rt.requestsForPath(okxLeveragePath)
if len(leverageRequests) != 2 {
t.Fatalf("expected 2 leverage requests, got %d", len(leverageRequests))
}
for _, req := range leverageRequests {
if req.Body["mgnMode"] != "isolated" {
t.Fatalf("expected isolated leverage mode, got %#v", req.Body["mgnMode"])
}
}
}
func TestOKXSetMarginModeUpdatesFutureRequestsWithoutAPIError(t *testing.T) {
rt := &recordingTransport{}
trader := newTestOKXTrader(rt, true)
if err := trader.SetMarginMode("BTCUSDT", false); err != nil {
t.Fatalf("SetMarginMode failed: %v", err)
}
if len(rt.requestsForPath("/api/v5/account/set-isolated-mode")) != 0 {
t.Fatal("expected SetMarginMode not to call legacy isolated-mode endpoint")
}
if err := trader.SetLeverage("BTCUSDT", 5); err != nil {
t.Fatalf("SetLeverage failed: %v", err)
}
leverageRequests := rt.requestsForPath(okxLeveragePath)
if len(leverageRequests) != 2 {
t.Fatalf("expected 2 leverage requests, got %d", len(leverageRequests))
}
for _, req := range leverageRequests {
if req.Body["mgnMode"] != "isolated" {
t.Fatalf("expected isolated leverage mode after SetMarginMode(false), got %#v", req.Body["mgnMode"])
}
}
}
func TestOKXOpenLongUsesConfiguredMarginMode(t *testing.T) {
rt := &recordingTransport{}
trader := newTestOKXTrader(rt, false)
if _, err := trader.OpenLong("BTCUSDT", 0.1, 5); err != nil {
t.Fatalf("OpenLong failed: %v", err)
}
orderRequests := rt.requestsForPath(okxOrderPath)
if len(orderRequests) == 0 {
t.Fatal("expected at least one order request")
}
lastOrder := orderRequests[len(orderRequests)-1]
if lastOrder.Body["tdMode"] != "isolated" {
t.Fatalf("expected isolated tdMode, got %#v", lastOrder.Body["tdMode"])
}
}
func TestOKXSetStopLossUsesConfiguredMarginMode(t *testing.T) {
rt := &recordingTransport{}
trader := newTestOKXTrader(rt, false)
if err := trader.SetStopLoss("BTCUSDT", "LONG", 0.1, 90000); err != nil {
t.Fatalf("SetStopLoss failed: %v", err)
}
algoRequests := rt.requestsForPath(okxAlgoOrderPath)
if len(algoRequests) != 1 {
t.Fatalf("expected 1 algo order request, got %d", len(algoRequests))
}
if algoRequests[0].Body["tdMode"] != "isolated" {
t.Fatalf("expected isolated tdMode, got %#v", algoRequests[0].Body["tdMode"])
}
}
func TestOKXPlaceLimitOrderUsesConfiguredMarginMode(t *testing.T) {
rt := &recordingTransport{}
trader := newTestOKXTrader(rt, false)
_, err := trader.PlaceLimitOrder(&types.LimitOrderRequest{
Symbol: "BTCUSDT",
Side: "BUY",
PositionSide: "LONG",
Price: 95000,
Quantity: 0.1,
Leverage: 3,
})
if err != nil {
t.Fatalf("PlaceLimitOrder failed: %v", err)
}
orderRequests := rt.requestsForPath(okxOrderPath)
if len(orderRequests) != 1 {
t.Fatalf("expected 1 limit order request, got %d", len(orderRequests))
}
if orderRequests[0].Body["tdMode"] != "isolated" {
t.Fatalf("expected isolated tdMode, got %#v", orderRequests[0].Body["tdMode"])
}
}
func TestOKXCrossMarginModeUsedInLeverage(t *testing.T) {
rt := &recordingTransport{}
trader := newTestOKXTrader(rt, true) // cross margin
if err := trader.SetLeverage("BTCUSDT", 10); err != nil {
t.Fatalf("SetLeverage failed: %v", err)
}
leverageRequests := rt.requestsForPath(okxLeveragePath)
if len(leverageRequests) != 2 {
t.Fatalf("expected 2 leverage requests, got %d", len(leverageRequests))
}
for _, req := range leverageRequests {
if req.Body["mgnMode"] != "cross" {
t.Fatalf("expected cross leverage mode, got %#v", req.Body["mgnMode"])
}
}
}
func TestOKXOpenShortUsesConfiguredMarginMode(t *testing.T) {
rt := &recordingTransport{}
trader := newTestOKXTrader(rt, false) // isolated
if _, err := trader.OpenShort("BTCUSDT", 0.1, 5); err != nil {
t.Fatalf("OpenShort failed: %v", err)
}
orderRequests := rt.requestsForPath(okxOrderPath)
if len(orderRequests) == 0 {
t.Fatal("expected at least one order request")
}
lastOrder := orderRequests[len(orderRequests)-1]
if lastOrder.Body["tdMode"] != "isolated" {
t.Fatalf("expected isolated tdMode for OpenShort, got %#v", lastOrder.Body["tdMode"])
}
}
func TestOKXSetTakeProfitUsesConfiguredMarginMode(t *testing.T) {
rt := &recordingTransport{}
trader := newTestOKXTrader(rt, false) // isolated
if err := trader.SetTakeProfit("BTCUSDT", "LONG", 0.1, 100000); err != nil {
t.Fatalf("SetTakeProfit failed: %v", err)
}
algoRequests := rt.requestsForPath(okxAlgoOrderPath)
if len(algoRequests) != 1 {
t.Fatalf("expected 1 algo order request, got %d", len(algoRequests))
}
if algoRequests[0].Body["tdMode"] != "isolated" {
t.Fatalf("expected isolated tdMode for SetTakeProfit, got %#v", algoRequests[0].Body["tdMode"])
}
}

View File

@@ -41,11 +41,9 @@ func (t *OKXTrader) OpenLong(symbol string, quantity float64, leverage int) (map
szStr = t.formatSize(sz, inst)
}
marginMode := t.marginMode()
body := map[string]interface{}{
"instId": instId,
"tdMode": marginMode,
"tdMode": "cross",
"side": "buy",
"posSide": "long",
"ordType": "market",
@@ -120,11 +118,9 @@ func (t *OKXTrader) OpenShort(symbol string, quantity float64, leverage int) (ma
szStr = t.formatSize(sz, inst)
}
marginMode := t.marginMode()
body := map[string]interface{}{
"instId": instId,
"tdMode": marginMode,
"tdMode": "cross",
"side": "sell",
"posSide": "short",
"ordType": "market",
@@ -414,11 +410,9 @@ func (t *OKXTrader) SetStopLoss(symbol string, positionSide string, quantity, st
posSide = "short"
}
marginMode := t.marginMode()
body := map[string]interface{}{
"instId": instId,
"tdMode": marginMode,
"tdMode": "cross",
"side": side,
"posSide": posSide,
"ordType": "conditional",
@@ -459,11 +453,9 @@ func (t *OKXTrader) SetTakeProfit(symbol string, positionSide string, quantity,
posSide = "short"
}
marginMode := t.marginMode()
body := map[string]interface{}{
"instId": instId,
"tdMode": marginMode,
"tdMode": "cross",
"side": side,
"posSide": posSide,
"ordType": "conditional",
@@ -823,11 +815,9 @@ func (t *OKXTrader) PlaceLimitOrder(req *types.LimitOrderRequest) (*types.LimitO
posSide = "short"
}
marginMode := t.marginMode()
body := map[string]interface{}{
"instId": instId,
"tdMode": marginMode,
"tdMode": "cross",
"side": side,
"posSide": posSide,
"ordType": "limit",

View File

@@ -1,67 +0,0 @@
package wallet
import (
"strings"
"sync"
"time"
)
// balanceCacheTTL is how long a balance reading is trusted before re-querying.
const balanceCacheTTL = 30 * time.Second
type balanceEntry struct {
value float64
fetchedAt time.Time
}
var (
balanceCache sync.Map
balanceFetchMu sync.Map
)
// QueryUSDCBalanceCached returns the USDC balance for an address, using a
// short-lived cache to avoid hammering the Base RPC. Addresses are
// case-insensitive.
func QueryUSDCBalanceCached(address string) (float64, error) {
key := strings.ToLower(strings.TrimSpace(address))
if key == "" {
return 0, nil
}
if v, ok := balanceCache.Load(key); ok {
e := v.(balanceEntry)
if time.Since(e.fetchedAt) < balanceCacheTTL {
return e.value, nil
}
}
muAny, _ := balanceFetchMu.LoadOrStore(key, &sync.Mutex{})
mu := muAny.(*sync.Mutex)
mu.Lock()
defer mu.Unlock()
if v, ok := balanceCache.Load(key); ok {
e := v.(balanceEntry)
if time.Since(e.fetchedAt) < balanceCacheTTL {
return e.value, nil
}
}
balance, err := QueryUSDCBalance(address)
if err != nil {
return 0, err
}
balanceCache.Store(key, balanceEntry{value: balance, fetchedAt: time.Now()})
return balance, nil
}
// InvalidateBalanceCache drops the cached balance for an address, forcing the
// next query to hit the chain. Use after a known-spending action or when the
// caller suspects the cache is stale.
func InvalidateBalanceCache(address string) {
key := strings.ToLower(strings.TrimSpace(address))
if key == "" {
return
}
balanceCache.Delete(key)
}

View File

@@ -18,26 +18,21 @@ const (
USDCDecimals = 6
)
// QueryUSDCBalance queries USDC balance on Base chain. RPC / decode failures
// are surfaced as errors so callers can distinguish a real zero balance from
// an unreachable RPC.
// QueryUSDCBalance queries USDC balance on Base chain and returns as float64
func QueryUSDCBalance(address string) (float64, error) {
return queryUSDCBalanceRPC(address)
}
// QueryUSDCBalanceStr is the display-oriented counterpart to QueryUSDCBalance:
// it swallows errors and returns "0.00" so UI handlers always have a string to
// render. Use QueryUSDCBalance when you need to react to failure.
func QueryUSDCBalanceStr(address string) string {
balance, err := queryUSDCBalanceRPC(address)
balanceStr := QueryUSDCBalanceStr(address)
var balance float64
_, err := fmt.Sscanf(balanceStr, "%f", &balance)
if err != nil {
return "0.00"
return 0, fmt.Errorf("failed to parse balance: %w", err)
}
return fmt.Sprintf("%.6f", balance)
return balance, nil
}
func queryUSDCBalanceRPC(address string) (float64, error) {
// Build balanceOf(address) call data — function selector 0x70a08231.
// QueryUSDCBalanceStr queries USDC balance on Base chain and returns as formatted string
func QueryUSDCBalanceStr(address string) string {
// Build balanceOf(address) call data
// Function selector: 0x70a08231
addrNoPre := strings.TrimPrefix(strings.ToLower(address), "0x")
data := "0x70a08231" + fmt.Sprintf("%064s", addrNoPre)
@@ -56,50 +51,41 @@ func queryUSDCBalanceRPC(address string) (float64, error) {
body, err := json.Marshal(payload)
if err != nil {
return 0, fmt.Errorf("marshal rpc payload: %w", err)
return "0.00"
}
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Post(BaseRPCURL, "application/json", bytes.NewReader(body))
if err != nil {
return 0, fmt.Errorf("rpc post: %w", err)
return "0.00"
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return 0, fmt.Errorf("read rpc response: %w", err)
return "0.00"
}
var rpcResp struct {
Result string `json:"result"`
Error json.RawMessage `json:"error"`
Result string `json:"result"`
}
if err := json.Unmarshal(respBody, &rpcResp); err != nil {
return 0, fmt.Errorf("decode rpc response: %w", err)
}
if len(rpcResp.Error) > 0 && string(rpcResp.Error) != "null" {
return 0, fmt.Errorf("rpc error: %s", string(rpcResp.Error))
return "0.00"
}
// Parse hex result
hexStr := strings.TrimPrefix(rpcResp.Result, "0x")
if hexStr == "" {
return 0, nil
}
balance, ok := new(big.Int).SetString(hexStr, 16)
if !ok {
return 0, fmt.Errorf("invalid hex balance: %q", rpcResp.Result)
if hexStr == "" || hexStr == "0" {
return "0.00"
}
balance := new(big.Int)
balance.SetString(hexStr, 16)
// Convert to float with 6 decimals
divisor := new(big.Int).Exp(big.NewInt(10), big.NewInt(USDCDecimals), nil)
whole := new(big.Int).Quo(balance, divisor)
whole := new(big.Int).Div(balance, divisor)
remainder := new(big.Int).Mod(balance, divisor)
// Preserve 6-decimal precision without float drift.
frac := fmt.Sprintf("%06d", remainder.Int64())
combined := whole.String() + "." + frac
var out float64
if _, err := fmt.Sscanf(combined, "%f", &out); err != nil {
return 0, fmt.Errorf("parse balance %q: %w", combined, err)
}
return out, nil
return fmt.Sprintf("%d.%06d", whole, remainder)
}

View File

@@ -1,14 +1,718 @@
import { ConfirmDialogProvider } from './components/common/ConfirmDialog'
import { AuthProvider } from './contexts/AuthContext'
import { LanguageProvider } from './contexts/LanguageContext'
import { AppRoutes } from './router/AppRoutes'
import { useEffect, useState } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import useSWR from 'swr'
import { api } from './lib/api'
import { TraderDashboardPage } from './pages/TraderDashboardPage'
export default function App() {
import { AITradersPage } from './components/trader/AITradersPage'
import { LoginPage } from './components/auth/LoginPage'
import { SetupPage } from './components/modals/SetupPage'
import { SettingsPage } from './pages/SettingsPage'
import { ResetPasswordPage } from './components/auth/ResetPasswordPage'
import { CompetitionPage } from './components/trader/CompetitionPage'
import { LandingPage } from './pages/LandingPage'
import { FAQPage } from './pages/FAQPage'
import { StrategyStudioPage } from './pages/StrategyStudioPage'
import { StrategyMarketPage } from './pages/StrategyMarketPage'
import { DataPage } from './pages/DataPage'
import { BeginnerOnboardingPage } from './pages/BeginnerOnboardingPage'
import { LoginRequiredOverlay } from './components/auth/LoginRequiredOverlay'
import HeaderBar from './components/common/HeaderBar'
import { LanguageProvider, useLanguage } from './contexts/LanguageContext'
import { AuthProvider, useAuth } from './contexts/AuthContext'
import { ConfirmDialogProvider } from './components/common/ConfirmDialog'
import { t } from './i18n/translations'
import { useSystemConfig } from './hooks/useSystemConfig'
import { getUserMode, hasCompletedBeginnerOnboarding } from './lib/onboarding'
import { OFFICIAL_LINKS } from './constants/branding'
import type {
SystemStatus,
AccountInfo,
Position,
DecisionRecord,
Statistics,
TraderInfo,
Exchange,
} from './types'
type Page =
| 'competition'
| 'traders'
| 'trader'
| 'strategy'
| 'strategy-market'
| 'data'
| 'faq'
| 'login'
| 'register'
function App() {
const { language, setLanguage } = useLanguage()
const { user, token, logout, isLoading } = useAuth()
const { config: systemConfig, loading: configLoading } = useSystemConfig()
const [route, setRoute] = useState(window.location.pathname)
// 从URL路径读取初始页面状态支持刷新保持页面
const getInitialPage = (): Page => {
const path = window.location.pathname
const hash = window.location.hash.slice(1) // 去掉 #
if (path === '/welcome') return 'traders'
if (path === '/traders' || hash === 'traders') return 'traders'
if (path === '/strategy' || hash === 'strategy') return 'strategy'
if (path === '/strategy-market' || hash === 'strategy-market') return 'strategy-market'
if (path === '/data' || hash === 'data') return 'data'
if (path === '/dashboard' || hash === 'trader' || hash === 'details')
return 'trader'
return 'competition' // 默认为竞赛页面
}
// Login required overlay state
const [loginOverlayOpen, setLoginOverlayOpen] = useState(false)
const [loginOverlayFeature, setLoginOverlayFeature] = useState('')
const handleLoginRequired = (featureName: string) => {
setLoginOverlayFeature(featureName)
setLoginOverlayOpen(true)
}
// Unified page navigation handler
const navigateToPage = (page: Page) => {
const pathMap: Record<Page, string> = {
'competition': '/competition',
'strategy-market': '/strategy-market',
'data': '/data',
'traders': '/traders',
'trader': '/dashboard',
'strategy': '/strategy',
'faq': '/faq',
'login': '/login',
'register': '/register',
}
const path = pathMap[page]
if (path) {
window.history.pushState({}, '', path)
setRoute(path)
setCurrentPage(page)
}
}
const [currentPage, setCurrentPage] = useState<Page>(getInitialPage())
// 从 URL 参数读取初始 trader 标识(格式: name-id前4位
const [selectedTraderSlug, setSelectedTraderSlug] = useState<string | undefined>(() => {
const params = new URLSearchParams(window.location.search)
return params.get('trader') || undefined
})
const [selectedTraderId, setSelectedTraderId] = useState<string | undefined>()
// 生成 trader URL slugname + ID 前 4 位)
const getTraderSlug = (trader: TraderInfo) => {
const idPrefix = trader.trader_id.slice(0, 4)
return `${trader.trader_name}-${idPrefix}`
}
// 从 slug 解析并匹配 trader
const findTraderBySlug = (slug: string, traderList: TraderInfo[]) => {
// slug 格式: name-xxxx (xxxx 是 ID 前 4 位)
const lastDashIndex = slug.lastIndexOf('-')
if (lastDashIndex === -1) {
// 没有 dash直接按 name 匹配
return traderList.find(t => t.trader_name === slug)
}
const name = slug.slice(0, lastDashIndex)
const idPrefix = slug.slice(lastDashIndex + 1)
return traderList.find(t =>
t.trader_name === name && t.trader_id.startsWith(idPrefix)
)
}
const [lastUpdate, setLastUpdate] = useState<string>('--:--:--')
const [decisionsLimit, setDecisionsLimit] = useState<number>(5)
const hasPersistedAuth =
!!localStorage.getItem('auth_token') && !!localStorage.getItem('auth_user')
// Poll-off states: stop polling after 3 consecutive failures
const [accountPollOff, setAccountPollOff] = useState(false)
const [positionsPollOff, setPositionsPollOff] = useState(false)
const [decisionsPollOff, setDecisionsPollOff] = useState(false)
// Reset poll-off states when trader changes
useEffect(() => {
setAccountPollOff(false)
setPositionsPollOff(false)
setDecisionsPollOff(false)
}, [selectedTraderId])
// 监听URL变化同步页面状态
useEffect(() => {
const handleRouteChange = () => {
const path = window.location.pathname
const hash = window.location.hash.slice(1)
const params = new URLSearchParams(window.location.search)
const traderParam = params.get('trader')
if (path === '/welcome') {
setCurrentPage('traders')
} else if (path === '/traders' || hash === 'traders') {
setCurrentPage('traders')
} else if (path === '/strategy' || hash === 'strategy') {
setCurrentPage('strategy')
} else if (path === '/strategy-market' || hash === 'strategy-market') {
setCurrentPage('strategy-market')
} else if (path === '/data' || hash === 'data') {
setCurrentPage('data')
} else if (
path === '/dashboard' ||
hash === 'trader' ||
hash === 'details'
) {
setCurrentPage('trader')
// 如果 URL 中有 trader 参数slug 格式),更新选中的 trader
setSelectedTraderSlug(traderParam || undefined)
} else if (
path === '/competition' ||
hash === 'competition' ||
hash === ''
) {
setCurrentPage('competition')
}
setRoute(path)
}
window.addEventListener('hashchange', handleRouteChange)
window.addEventListener('popstate', handleRouteChange)
return () => {
window.removeEventListener('hashchange', handleRouteChange)
window.removeEventListener('popstate', handleRouteChange)
}
}, [])
// 切换页面时更新URL hash (当前通过按钮直接调用setCurrentPage这个函数暂时保留用于未来扩展)
// const navigateToPage = (page: Page) => {
// setCurrentPage(page);
// window.location.hash = page === 'competition' ? '' : 'trader';
// };
// 获取trader列表仅在用户登录时
const { data: traders, error: tradersError } = useSWR<TraderInfo[]>(
user && token ? 'traders' : null,
() => api.getTraders(currentPage === 'trader'),
{
refreshInterval: 10000,
shouldRetryOnError: false, // 避免在后端未运行时无限重试
}
)
// 获取exchanges列表用于显示交易所名称
const { data: exchanges } = useSWR<Exchange[]>(
user && token ? 'exchanges' : null,
api.getExchangeConfigs,
{
refreshInterval: 60000, // 1分钟刷新一次
shouldRetryOnError: false,
}
)
// 当获取到traders后根据 URL 中的 trader slug 设置选中的 trader或默认选中第一个
useEffect(() => {
if (!traders || traders.length === 0) {
return
}
if (selectedTraderSlug) {
// 通过 slug 找到对应的 trader
const trader = findTraderBySlug(selectedTraderSlug, traders)
const nextTraderId = trader?.trader_id || traders[0].trader_id
if (nextTraderId !== selectedTraderId) {
setSelectedTraderId(nextTraderId)
}
return
}
if (!selectedTraderId) {
setSelectedTraderId(traders[0].trader_id)
}
}, [traders, selectedTraderId, selectedTraderSlug])
// 如果在trader页面获取该trader的数据
const { data: status } = useSWR<SystemStatus>(
currentPage === 'trader' && selectedTraderId
? `status-${selectedTraderId}`
: null,
() => api.getStatus(selectedTraderId, true),
{
refreshInterval: 15000, // 15秒刷新配合后端15秒缓存
revalidateOnFocus: false, // 禁用聚焦时重新验证,减少请求
dedupingInterval: 10000, // 10秒去重防止短时间内重复请求
}
)
const { data: account } = useSWR<AccountInfo>(
currentPage === 'trader' && selectedTraderId
? `account-${selectedTraderId}`
: null,
() => api.getAccount(selectedTraderId, true),
{
refreshInterval: accountPollOff ? 0 : 15000,
revalidateOnFocus: false,
dedupingInterval: 10000,
onErrorRetry: (_err, _key, _config, revalidate, { retryCount }) => {
if (retryCount >= 2) { setAccountPollOff(true); return }
setTimeout(() => revalidate({ retryCount }), 500)
},
onSuccess: () => { if (accountPollOff) setAccountPollOff(false) },
}
)
const { data: positions } = useSWR<Position[]>(
currentPage === 'trader' && selectedTraderId
? `positions-${selectedTraderId}`
: null,
() => api.getPositions(selectedTraderId, true),
{
refreshInterval: positionsPollOff ? 0 : 15000,
revalidateOnFocus: false,
dedupingInterval: 10000,
onErrorRetry: (_err, _key, _config, revalidate, { retryCount }) => {
if (retryCount >= 2) { setPositionsPollOff(true); return }
setTimeout(() => revalidate({ retryCount }), 500)
},
onSuccess: () => { if (positionsPollOff) setPositionsPollOff(false) },
}
)
const { data: decisions } = useSWR<DecisionRecord[]>(
currentPage === 'trader' && selectedTraderId
? `decisions/latest-${selectedTraderId}-${decisionsLimit}`
: null,
() => api.getLatestDecisions(selectedTraderId, decisionsLimit, true),
{
refreshInterval: decisionsPollOff ? 0 : 30000,
revalidateOnFocus: false,
dedupingInterval: 20000,
onErrorRetry: (_err, _key, _config, revalidate, { retryCount }) => {
if (retryCount >= 2) { setDecisionsPollOff(true); return }
setTimeout(() => revalidate({ retryCount }), 500)
},
onSuccess: () => { if (decisionsPollOff) setDecisionsPollOff(false) },
}
)
const { data: stats } = useSWR<Statistics>(
currentPage === 'trader' && selectedTraderId
? `statistics-${selectedTraderId}`
: null,
() => api.getStatistics(selectedTraderId, true),
{
refreshInterval: 30000, // 30秒刷新统计数据更新频率较低
revalidateOnFocus: false,
dedupingInterval: 20000,
}
)
useEffect(() => {
if (account) {
const now = new Date().toLocaleTimeString()
setLastUpdate(now)
}
}, [account])
const selectedTrader = traders?.find((t) => t.trader_id === selectedTraderId)
const effectiveAccount = account
const effectivePositions = positions
const effectiveDecisions = decisions
// Handle routing
useEffect(() => {
const handlePopState = () => {
setRoute(window.location.pathname)
}
window.addEventListener('popstate', handlePopState)
return () => window.removeEventListener('popstate', handlePopState)
}, [])
// Set current page based on route for consistent navigation state
useEffect(() => {
if (route === '/welcome') {
setCurrentPage('traders')
} else if (route === '/competition') {
setCurrentPage('competition')
} else if (route === '/traders') {
setCurrentPage('traders')
} else if (route === '/dashboard') {
setCurrentPage('trader')
}
}, [route])
const showBeginnerOnboarding =
route === '/welcome' && (!!user || hasPersistedAuth) && getUserMode() === 'beginner' && !hasCompletedBeginnerOnboarding()
// Show loading spinner while checking auth or config
if (isLoading || configLoading) {
return (
<div
className="min-h-screen flex items-center justify-center"
style={{ background: '#0B0E11' }}
>
<div className="text-center">
<img
src="/icons/nofx.svg"
alt="NoFx Logo"
className="w-16 h-16 mx-auto mb-4 animate-pulse"
/>
<p style={{ color: '#EAECEF' }}>{t('loading', language)}</p>
</div>
</div>
)
}
// First-time setup: redirect to /setup if system not initialized
if (systemConfig && !systemConfig.initialized && !user) {
return <SetupPage />
}
// Handle specific routes regardless of authentication
if (route === '/login') {
return <LoginPage />
}
if (route === '/setup') {
// If already initialized, redirect to login
if (systemConfig?.initialized) {
window.location.href = '/login'
return null
}
return <SetupPage />
}
if (route === '/welcome') {
if ((!user || !token) && !hasPersistedAuth) {
window.location.href = '/login'
return null
}
if (getUserMode() !== 'beginner') {
window.location.href = '/traders'
return null
}
}
if (route === '/faq') {
return (
<div
className="min-h-screen"
style={{ background: '#0B0E11', color: '#EAECEF' }}
>
<HeaderBar
isLoggedIn={!!user}
currentPage="faq"
language={language}
onLanguageChange={setLanguage}
user={user}
onLogout={logout}
onLoginRequired={handleLoginRequired}
onPageChange={navigateToPage}
/>
<FAQPage />
<LoginRequiredOverlay
isOpen={loginOverlayOpen}
onClose={() => setLoginOverlayOpen(false)}
featureName={loginOverlayFeature}
/>
</div>
)
}
if (route === '/reset-password') {
return <ResetPasswordPage />
}
if (route === '/settings') {
if ((!user || !token) && !hasPersistedAuth) {
window.location.href = '/login'
return null
}
return (
<div className="min-h-screen" style={{ background: '#0B0E11', color: '#EAECEF' }}>
<HeaderBar
isLoggedIn={!!user}
language={language}
onLanguageChange={setLanguage}
user={user}
onLogout={logout}
onLoginRequired={handleLoginRequired}
onPageChange={navigateToPage}
/>
<SettingsPage />
</div>
)
}
// Data page - publicly accessible with embedded dashboard
if (route === '/data') {
const dataPageNavigate = (page: Page) => {
navigateToPage(page)
}
return (
<div
className="min-h-screen"
style={{ background: '#0B0E11', color: '#EAECEF' }}
>
<HeaderBar
isLoggedIn={!!user}
currentPage="data"
language={language}
onLanguageChange={setLanguage}
user={user}
onLogout={logout}
onLoginRequired={handleLoginRequired}
onPageChange={dataPageNavigate}
/>
<main className="pt-16">
<DataPage />
</main>
<LoginRequiredOverlay
isOpen={loginOverlayOpen}
onClose={() => setLoginOverlayOpen(false)}
featureName={loginOverlayFeature}
/>
</div>
)
}
// Show landing page for root route
if (route === '/' || route === '') {
return <LandingPage />
}
// Redirect unauthenticated users to landing page
if (!user || !token) {
return <LandingPage />
}
return (
<div
className="min-h-screen"
style={{ background: '#0B0E11', color: '#EAECEF' }}
>
<HeaderBar
isLoggedIn={!!user}
currentPage={currentPage}
language={language}
onLanguageChange={setLanguage}
user={user}
onLogout={logout}
onLoginRequired={handleLoginRequired}
onPageChange={navigateToPage}
/>
{/* Main Content with Page Transitions */}
<main className="min-h-screen pt-16">
<AnimatePresence mode="wait">
<motion.div
key={currentPage}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={{ duration: 0.15, ease: 'easeOut' }}
>
{currentPage === 'competition' ? (
<CompetitionPage />
) : currentPage === 'data' ? (
<DataPage />
) : currentPage === 'strategy-market' ? (
<StrategyMarketPage />
) : currentPage === 'traders' ? (
<AITradersPage
onTraderSelect={(traderId) => {
setSelectedTraderId(traderId)
const trader = traders?.find((item) => item.trader_id === traderId)
const url = new URL(window.location.href)
url.pathname = '/dashboard'
if (trader) {
const slug = getTraderSlug(trader)
url.searchParams.set('trader', slug)
setSelectedTraderSlug(slug)
} else {
url.searchParams.delete('trader')
setSelectedTraderSlug(undefined)
}
window.history.pushState({}, '', url.toString())
setRoute('/dashboard')
setCurrentPage('trader')
}}
/>
) : currentPage === 'strategy' ? (
<StrategyStudioPage />
) : (
<TraderDashboardPage
selectedTrader={selectedTrader}
status={status}
account={effectiveAccount}
accountFailed={accountPollOff}
positions={effectivePositions}
positionsFailed={positionsPollOff}
decisions={effectiveDecisions}
decisionsFailed={decisionsPollOff}
decisionsLimit={decisionsLimit}
onDecisionsLimitChange={setDecisionsLimit}
stats={stats}
lastUpdate={lastUpdate}
language={language}
traders={traders}
tradersError={tradersError}
selectedTraderId={selectedTraderId}
onTraderSelect={(traderId) => {
setSelectedTraderId(traderId)
// 更新 URL 参数(使用 slug: name-id前4位
const trader = traders?.find(t => t.trader_id === traderId)
if (trader) {
const slug = getTraderSlug(trader)
setSelectedTraderSlug(slug)
const url = new URL(window.location.href)
url.searchParams.set('trader', slug)
window.history.replaceState({}, '', url.toString())
}
}}
onNavigateToTraders={() => {
window.history.pushState({}, '', '/traders')
setRoute('/traders')
setCurrentPage('traders')
}}
exchanges={exchanges}
/>
)}
</motion.div>
</AnimatePresence>
</main>
{/* Footer */}
<footer
className="mt-16"
style={{ borderTop: '1px solid #2B3139', background: '#181A20' }}
>
<div
className="max-w-[1920px] mx-auto px-6 py-6 text-center text-sm"
style={{ color: '#5E6673' }}
>
<p>{t('footerTitle', language)}</p>
<p className="mt-1">{t('footerWarning', language)}</p>
<div className="mt-4 flex items-center justify-center gap-3 flex-wrap">
{/* GitHub */}
<a
href={OFFICIAL_LINKS.github}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-3 py-2 rounded text-sm font-semibold transition-all hover:scale-105"
style={{
background: '#1E2329',
color: '#848E9C',
border: '1px solid #2B3139',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#2B3139'
e.currentTarget.style.color = '#EAECEF'
e.currentTarget.style.borderColor = '#F0B90B'
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = '#1E2329'
e.currentTarget.style.color = '#848E9C'
e.currentTarget.style.borderColor = '#2B3139'
}}
>
<svg
width="18"
height="18"
viewBox="0 0 16 16"
fill="currentColor"
>
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
</svg>
GitHub
</a>
{/* Twitter/X */}
<a
href={OFFICIAL_LINKS.twitter}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-3 py-2 rounded text-sm font-semibold transition-all hover:scale-105"
style={{
background: '#1E2329',
color: '#848E9C',
border: '1px solid #2B3139',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#2B3139'
e.currentTarget.style.color = '#EAECEF'
e.currentTarget.style.borderColor = '#1DA1F2'
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = '#1E2329'
e.currentTarget.style.color = '#848E9C'
e.currentTarget.style.borderColor = '#2B3139'
}}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
</svg>
Twitter
</a>
{/* Telegram */}
<a
href={OFFICIAL_LINKS.telegram}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-3 py-2 rounded text-sm font-semibold transition-all hover:scale-105"
style={{
background: '#1E2329',
color: '#848E9C',
border: '1px solid #2B3139',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#2B3139'
e.currentTarget.style.color = '#EAECEF'
e.currentTarget.style.borderColor = '#0088cc'
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = '#1E2329'
e.currentTarget.style.color = '#848E9C'
e.currentTarget.style.borderColor = '#2B3139'
}}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z" />
</svg>
Telegram
</a>
</div>
</div>
</footer>
{/* Login Required Overlay */}
<LoginRequiredOverlay
isOpen={loginOverlayOpen}
onClose={() => setLoginOverlayOpen(false)}
featureName={loginOverlayFeature}
/>
{showBeginnerOnboarding && <BeginnerOnboardingPage />}
</div>
)
}
// Wrap App with providers
export default function AppWithProviders() {
return (
<LanguageProvider>
<AuthProvider>
<ConfirmDialogProvider>
<AppRoutes />
<App />
</ConfirmDialogProvider>
</AuthProvider>
</LanguageProvider>

View File

@@ -1,104 +0,0 @@
interface AgentStep {
id: string
label: string
status: 'planning' | 'pending' | 'running' | 'completed' | 'replanned'
detail?: string
}
interface AgentStepPanelProps {
steps?: AgentStep[]
visible?: boolean
}
const statusStyles: Record<AgentStep['status'], { dot: string; text: string }> = {
planning: { dot: '#7c3aed', text: '#c4b5fd' },
pending: { dot: 'rgba(255,255,255,0.18)', text: '#818198' },
running: { dot: '#F0B90B', text: '#f6d67a' },
completed: { dot: '#00e5a0', text: '#9cf5d5' },
replanned: { dot: '#38bdf8', text: '#9bdcf7' },
}
export function AgentStepPanel({ steps, visible }: AgentStepPanelProps) {
if (!visible || !steps || steps.length === 0) {
return null
}
return (
<div
style={{
marginBottom: 12,
padding: '10px 12px',
borderRadius: 12,
background: 'linear-gradient(180deg, rgba(255,255,255,0.03), rgba(255,255,255,0.015))',
border: '1px solid rgba(255,255,255,0.06)',
}}
>
<div
style={{
fontSize: 11,
fontWeight: 700,
letterSpacing: '0.08em',
textTransform: 'uppercase',
color: '#7b7b91',
marginBottom: 10,
}}
>
Live Run
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{steps.map((step) => {
const style = statusStyles[step.status]
return (
<div
key={step.id}
style={{
display: 'grid',
gridTemplateColumns: '14px 1fr',
gap: 8,
alignItems: 'start',
}}
>
<span
style={{
width: 8,
height: 8,
borderRadius: 999,
marginTop: 5,
background: style.dot,
boxShadow:
step.status === 'running'
? '0 0 0 4px rgba(240,185,11,0.08)'
: 'none',
}}
/>
<div>
<div
style={{
fontSize: 12.5,
lineHeight: 1.5,
color: style.text,
fontWeight: step.status === 'running' ? 600 : 500,
}}
>
{step.label}
</div>
{step.detail && (
<div
style={{
fontSize: 11.5,
lineHeight: 1.45,
color: '#6e6e86',
marginTop: 2,
}}
>
{step.detail}
</div>
)}
</div>
</div>
)
})}
</div>
</div>
)
}

View File

@@ -1,154 +0,0 @@
import { useRef, useState, useCallback, useEffect, useImperativeHandle, forwardRef } from 'react'
import { ArrowUp } from 'lucide-react'
export interface ChatInputHandle {
focus: () => void
clear: () => void
getValue: () => string
}
interface ChatInputProps {
language: string
loading: boolean
onSend: (text: string) => void
}
export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
function ChatInput({ language, loading, onSend }, ref) {
const [input, setInput] = useState('')
const [composing, setComposing] = useState(false)
const inputRef = useRef<HTMLTextAreaElement>(null)
useImperativeHandle(ref, () => ({
focus: () => inputRef.current?.focus(),
clear: () => {
setInput('')
if (inputRef.current) inputRef.current.style.height = 'auto'
},
getValue: () => input,
}))
const handleInputChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
setInput(e.target.value)
const el = e.target
el.style.height = 'auto'
el.style.height = Math.min(el.scrollHeight, 150) + 'px'
},
[]
)
const handleSend = () => {
const msg = input.trim()
if (!msg || loading) return
setInput('')
if (inputRef.current) inputRef.current.style.height = 'auto'
onSend(msg)
inputRef.current?.focus()
}
// Keyboard shortcut: Cmd+K to focus
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault()
inputRef.current?.focus()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [])
return (
<div
style={{
padding: '12px 16px 20px',
borderTop: '1px solid rgba(255,255,255,0.04)',
background: 'linear-gradient(to top, #09090b 80%, transparent)',
}}
>
<div
className="chat-input-wrapper"
style={{
maxWidth: 720,
margin: '0 auto',
display: 'flex',
gap: 8,
background: 'rgba(255,255,255,0.03)',
border: '1px solid rgba(255,255,255,0.07)',
borderRadius: 18,
padding: '4px 4px 4px 16px',
alignItems: 'flex-end',
transition: 'all 0.2s ease',
}}
>
<textarea
ref={inputRef}
value={input}
onChange={handleInputChange}
onCompositionStart={() => setComposing(true)}
onCompositionEnd={() => setComposing(false)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey && !composing) {
e.preventDefault()
handleSend()
}
}}
placeholder={
language === 'zh'
? '跟 NOFXi 聊点什么... ⌘K'
: 'Ask NOFXi anything... ⌘K'
}
rows={1}
style={{
flex: 1,
background: 'none',
border: 'none',
color: '#eaeaf0',
fontSize: 13.5,
outline: 'none',
padding: '10px 0',
fontFamily: 'inherit',
resize: 'none',
lineHeight: 1.5,
maxHeight: 150,
}}
/>
<button
onClick={handleSend}
disabled={loading || !input.trim()}
style={{
width: 36,
height: 36,
borderRadius: 12,
border: 'none',
background:
loading || !input.trim()
? 'rgba(255,255,255,0.04)'
: 'linear-gradient(135deg, #F0B90B, #d4a30a)',
color: loading || !input.trim() ? '#3c3c52' : '#000',
cursor: loading || !input.trim() ? 'not-allowed' : 'pointer',
display: 'grid',
placeItems: 'center',
flexShrink: 0,
transition: 'all 0.2s ease',
}}
>
<ArrowUp size={16} strokeWidth={2.5} />
</button>
</div>
<div
style={{
maxWidth: 720,
margin: '6px auto 0',
textAlign: 'center',
fontSize: 10,
color: '#1e1e32',
}}
>
NOFXi may make mistakes. Always verify trading decisions.
</div>
</div>
)
}
)

View File

@@ -1,151 +0,0 @@
import { forwardRef } from 'react'
import { motion } from 'framer-motion'
import { AgentStepPanel } from './AgentStepPanel'
import { renderMessageContent } from './MessageRenderer'
interface AgentStep {
id: string
label: string
status: 'planning' | 'pending' | 'running' | 'completed' | 'replanned'
detail?: string
}
interface Message {
id: string
role: 'user' | 'bot'
text: string
time: string
streaming?: boolean
steps?: AgentStep[]
}
interface ChatMessagesProps {
messages: Message[]
}
function hasMeaningfulExecutionSteps(steps?: AgentStep[]) {
if (!steps || steps.length === 0) return false
return steps.some((step) => step.status !== 'planning')
}
export const ChatMessages = forwardRef<HTMLDivElement, ChatMessagesProps>(
function ChatMessages({ messages }, ref) {
return (
<div style={{ maxWidth: 720, margin: '0 auto', padding: '0 20px' }}>
{messages.map((m) => (
<motion.div
key={m.id}
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2 }}
style={{
display: 'flex',
gap: 12,
marginBottom: 24,
flexDirection: m.role === 'user' ? 'row-reverse' : 'row',
}}
>
{/* Avatar */}
<div
style={{
width: 30,
height: 30,
borderRadius: 10,
display: 'grid',
placeItems: 'center',
fontSize: 14,
flexShrink: 0,
marginTop: 2,
background:
m.role === 'user'
? 'linear-gradient(135deg, rgba(139,92,246,.12), rgba(139,92,246,.04))'
: 'linear-gradient(135deg, rgba(240,185,11,.08), rgba(0,229,160,.04))',
border:
'1px solid ' +
(m.role === 'user'
? 'rgba(139,92,246,.15)'
: 'rgba(240,185,11,.1)'),
}}
>
{m.role === 'user' ? '👤' : '⚡'}
</div>
{/* Message content */}
<div style={{ maxWidth: '78%', minWidth: 0 }}>
{m.role === 'user' ? (
<div
style={{
padding: '10px 16px',
borderRadius: 18,
borderTopRightRadius: 4,
fontSize: 13.5,
lineHeight: 1.7,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
background: 'linear-gradient(135deg, #7c3aed, #6d28d9)',
color: '#fff',
}}
>
{m.text}
</div>
) : (
<div
style={{
padding: '12px 16px',
borderRadius: 18,
borderTopLeftRadius: 4,
fontSize: 13.5,
lineHeight: 1.7,
wordBreak: 'break-word',
background: 'rgba(255,255,255,0.03)',
color: '#dcdce8',
border: '1px solid rgba(255,255,255,0.05)',
}}
>
<AgentStepPanel steps={m.steps} visible={hasMeaningfulExecutionSteps(m.steps)} />
{renderMessageContent(m.text)}
{m.streaming && m.text === '' && (
<div style={{ display: 'flex', gap: 4, padding: '4px 0' }}>
<span className="typing-dot" style={{ animationDelay: '0ms' }} />
<span className="typing-dot" style={{ animationDelay: '150ms' }} />
<span className="typing-dot" style={{ animationDelay: '300ms' }} />
</div>
)}
{m.streaming && m.text !== '' && (
<span
style={{
display: 'inline-block',
width: 2,
height: 15,
background: '#F0B90B',
marginLeft: 1,
borderRadius: 1,
animation: 'blink 0.8s infinite',
verticalAlign: 'text-bottom',
}}
/>
)}
</div>
)}
{m.time && !m.streaming && (
<div
style={{
fontSize: 10,
color: '#2c2c42',
marginTop: 4,
textAlign: m.role === 'user' ? 'right' : 'left',
paddingLeft: m.role === 'bot' ? 4 : 0,
paddingRight: m.role === 'user' ? 4 : 0,
}}
>
{m.role === 'bot' && 'NOFXi · '}{m.time}
</div>
)}
</div>
</motion.div>
))}
<div ref={ref} />
</div>
)
}
)

View File

@@ -1,178 +0,0 @@
import { useState, useEffect } from 'react'
// icons reserved for future use
interface TickerData {
symbol: string
lastPrice: string
priceChangePercent: string
highPrice: string
lowPrice: string
volume: string
}
const SYMBOLS = ['BTCUSDT', 'ETHUSDT', 'SOLUSDT']
const SYMBOL_ICONS: Record<string, string> = {
BTC: '₿',
ETH: 'Ξ',
SOL: '◎',
}
export function MarketTicker() {
const [tickers, setTickers] = useState<Record<string, TickerData>>({})
const [loading, setLoading] = useState(true)
const fetchTickers = async () => {
try {
// Batch fetch: single API call for all symbols
const res = await fetch(`/api/agent/tickers?symbols=${SYMBOLS.join(',')}`)
const data = await res.json()
const map: Record<string, TickerData> = {}
if (Array.isArray(data)) {
data.forEach((r: TickerData) => {
if (r.lastPrice && r.symbol) map[r.symbol] = r
})
}
setTickers(map)
} catch {
// ignore — will retry on next interval
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchTickers()
const interval = setInterval(fetchTickers, 15000)
return () => clearInterval(interval)
}, [])
const formatPrice = (price: string) => {
const n = parseFloat(price)
if (n >= 1000) return n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
if (n >= 1) return n.toFixed(2)
return n.toFixed(4)
}
const formatVolume = (vol: string) => {
const n = parseFloat(vol)
if (n >= 1e9) return (n / 1e9).toFixed(1) + 'B'
if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M'
if (n >= 1e3) return (n / 1e3).toFixed(1) + 'K'
return n.toFixed(0)
}
if (loading) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{SYMBOLS.map((sym) => (
<div
key={sym}
style={{
padding: '12px',
background: 'rgba(255,255,255,0.02)',
borderRadius: 10,
border: '1px solid rgba(255,255,255,0.04)',
height: 56,
}}
>
<div style={{
width: '60%',
height: 10,
background: 'rgba(255,255,255,0.04)',
borderRadius: 4,
animation: 'pulse 1.5s infinite',
}} />
</div>
))}
<style>{`
@keyframes pulse {
0%, 100% { opacity: 0.4; }
50% { opacity: 0.8; }
}
`}</style>
</div>
)
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{SYMBOLS.map((sym) => {
const t = tickers[sym]
if (!t) return null
const pct = parseFloat(t.priceChangePercent)
const isUp = pct > 0
const isDown = pct < 0
const color = isUp ? '#00e5a0' : isDown ? '#F6465D' : '#6c6c82'
const bgColor = isUp ? 'rgba(0,229,160,0.06)' : isDown ? 'rgba(246,70,93,0.06)' : 'rgba(108,108,130,0.06)'
const label = sym.replace('USDT', '')
const icon = SYMBOL_ICONS[label] || label[0]
return (
<div
key={sym}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '10px 11px',
background: 'rgba(255,255,255,0.02)',
borderRadius: 10,
border: '1px solid rgba(255,255,255,0.04)',
transition: 'all 0.15s ease',
cursor: 'default',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(255,255,255,0.04)'
e.currentTarget.style.borderColor = 'rgba(255,255,255,0.08)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(255,255,255,0.02)'
e.currentTarget.style.borderColor = 'rgba(255,255,255,0.04)'
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div
style={{
width: 28,
height: 28,
borderRadius: 8,
background: bgColor,
display: 'grid',
placeItems: 'center',
fontSize: 13,
fontWeight: 700,
color: color,
fontFamily: 'system-ui',
}}
>
{icon}
</div>
<div>
<div style={{ fontSize: 12.5, fontWeight: 600, color: '#e0e0ec', letterSpacing: '-0.01em' }}>
{label}
</div>
<div style={{ fontSize: 10, color: '#4c4c62' }}>
Vol {formatVolume(t.volume)}
</div>
</div>
</div>
<div style={{ textAlign: 'right' }}>
<div style={{ fontSize: 12.5, fontWeight: 600, color: '#e0e0ec', fontFamily: '"IBM Plex Mono", monospace', letterSpacing: '-0.02em' }}>
${formatPrice(t.lastPrice)}
</div>
<div style={{
fontSize: 10.5,
fontWeight: 600,
color,
fontFamily: '"IBM Plex Mono", monospace',
}}>
{isUp ? '+' : ''}{pct.toFixed(2)}%
</div>
</div>
</div>
)
})}
</div>
)
}

View File

@@ -1,187 +0,0 @@
/**
* MessageRenderer — markdown-to-JSX renderer for agent chat messages.
* Supports: headers, bold, italic, inline code, code blocks, lists, links, HR.
*/
// Inline formatting: bold, italic, code, links
export function renderInline(text: string): (string | JSX.Element)[] {
const parts = text.split(/(```[\s\S]*?```|`[^`]+`|\*\*[^*]+\*\*|\*[^*]+\*|\[([^\]]+)\]\(([^)]+)\))/g)
const result: (string | JSX.Element)[] = []
for (let i = 0; i < parts.length; i++) {
const part = parts[i]
if (!part) continue
if (part.startsWith('`') && part.endsWith('`') && !part.startsWith('```')) {
result.push(
<code
key={i}
style={{
background: 'rgba(240,185,11,0.08)',
padding: '2px 6px',
borderRadius: 5,
fontSize: '0.88em',
fontFamily: '"IBM Plex Mono", monospace',
color: '#F0B90B',
border: '1px solid rgba(240,185,11,0.12)',
}}
>
{part.slice(1, -1)}
</code>
)
} else if (part.startsWith('**') && part.endsWith('**')) {
result.push(
<strong key={i} style={{ fontWeight: 600, color: '#f0f0f8' }}>
{part.slice(2, -2)}
</strong>
)
} else if (part.startsWith('*') && part.endsWith('*') && !part.startsWith('**')) {
result.push(
<em key={i} style={{ fontStyle: 'italic', color: '#d0d0e0' }}>
{part.slice(1, -1)}
</em>
)
} else if (part.match(/^\[([^\]]+)\]\(([^)]+)\)$/)) {
const match = part.match(/^\[([^\]]+)\]\(([^)]+)\)$/)
if (match) {
const href = match[2]
// Only allow http/https links to prevent javascript: XSS
const safeHref = /^https?:\/\//i.test(href) ? href : '#'
result.push(
<a
key={i}
href={safeHref}
target="_blank"
rel="noopener noreferrer"
style={{ color: '#F0B90B', textDecoration: 'underline', textUnderlineOffset: 2 }}
>
{match[1]}
</a>
)
}
} else {
result.push(part)
}
}
return result
}
// Enhanced markdown renderer: headers, bold, italic, code, lists, links
export function renderMessageContent(text: string) {
const lines = text.split('\n')
const elements: JSX.Element[] = []
let inCodeBlock = false
let codeContent = ''
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
// Code block toggle
if (line.startsWith('```')) {
if (inCodeBlock) {
elements.push(
<pre
key={`code-${i}`}
style={{
background: '#0a0a12',
border: '1px solid rgba(255,255,255,0.06)',
borderRadius: 10,
padding: '12px 14px',
fontSize: 12,
overflowX: 'auto',
margin: '8px 0',
fontFamily: '"IBM Plex Mono", monospace',
color: '#c0c0d0',
lineHeight: 1.6,
}}
>
{codeContent.trim()}
</pre>
)
codeContent = ''
inCodeBlock = false
} else {
inCodeBlock = true
}
continue
}
if (inCodeBlock) {
codeContent += (codeContent ? '\n' : '') + line
continue
}
// Headers
if (line.startsWith('### ')) {
elements.push(
<div key={i} style={{ fontSize: 14, fontWeight: 700, color: '#f0f0f8', margin: '12px 0 6px', letterSpacing: '-0.01em' }}>
{renderInline(line.slice(4))}
</div>
)
continue
}
if (line.startsWith('## ')) {
elements.push(
<div key={i} style={{ fontSize: 15, fontWeight: 700, color: '#f0f0f8', margin: '14px 0 6px', letterSpacing: '-0.01em' }}>
{renderInline(line.slice(3))}
</div>
)
continue
}
if (line.startsWith('# ')) {
elements.push(
<div key={i} style={{ fontSize: 16, fontWeight: 700, color: '#f0f0f8', margin: '16px 0 8px', letterSpacing: '-0.02em' }}>
{renderInline(line.slice(2))}
</div>
)
continue
}
// Bullet lists
if (line.match(/^[-•*]\s/)) {
elements.push(
<div key={i} style={{ display: 'flex', gap: 8, padding: '2px 0', lineHeight: 1.65 }}>
<span style={{ color: '#F0B90B', flexShrink: 0, fontSize: 8, marginTop: 7 }}></span>
<span>{renderInline(line.replace(/^[-•*]\s/, ''))}</span>
</div>
)
continue
}
// Numbered lists
if (line.match(/^\d+\.\s/)) {
const num = line.match(/^(\d+)\./)?.[1]
elements.push(
<div key={i} style={{ display: 'flex', gap: 8, padding: '2px 0', lineHeight: 1.65 }}>
<span style={{ color: '#8a8aa0', flexShrink: 0, fontSize: 12, fontWeight: 600, minWidth: 16, fontFamily: '"IBM Plex Mono", monospace' }}>{num}.</span>
<span>{renderInline(line.replace(/^\d+\.\s/, ''))}</span>
</div>
)
continue
}
// Horizontal rule
if (line.match(/^---+$/)) {
elements.push(
<hr key={i} style={{ border: 'none', borderTop: '1px solid rgba(255,255,255,0.06)', margin: '12px 0' }} />
)
continue
}
// Empty line → small gap
if (line.trim() === '') {
elements.push(<div key={i} style={{ height: 6 }} />)
continue
}
// Regular paragraph
elements.push(
<div key={i} style={{ lineHeight: 1.7, padding: '1px 0' }}>
{renderInline(line)}
</div>
)
}
return elements
}

View File

@@ -1,154 +0,0 @@
import useSWR from 'swr'
import { useAuth } from '../../contexts/AuthContext'
import { api } from '../../lib/api'
import { ArrowUpRight, ArrowDownRight, Wallet } from 'lucide-react'
import type { Position, TraderInfo } from '../../types'
export function PositionsPanel() {
const { user, token } = useAuth()
const { data: traders } = useSWR<TraderInfo[]>(
user && token ? 'agent-traders' : null,
api.getTraders,
{ refreshInterval: 30000, shouldRetryOnError: false }
)
// Get first running trader's positions
const runningTrader = traders?.find((t) => t.is_running)
const traderId = runningTrader?.trader_id
const { data: positions } = useSWR<Position[]>(
traderId ? `agent-positions-${traderId}` : null,
() => api.getPositions(traderId),
{ refreshInterval: 15000, shouldRetryOnError: false }
)
if (!user || !token) {
return (
<div
style={{
padding: '20px 14px',
textAlign: 'center',
color: '#5c5c72',
fontSize: 12,
}}
>
<Wallet size={20} style={{ margin: '0 auto 8px', opacity: 0.5 }} />
<div>Login to view positions</div>
</div>
)
}
const openPositions = positions?.filter((p) => p.quantity !== 0) || []
if (openPositions.length === 0) {
return (
<div
style={{
padding: '16px 14px',
textAlign: 'center',
color: '#5c5c72',
fontSize: 12,
}}
>
No open positions
</div>
)
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{openPositions.map((pos, i) => {
const pnl = pos.unrealized_pnl
const isProfit = pnl >= 0
const color = isProfit ? '#00e5a0' : '#F6465D'
const side = pos.side?.toUpperCase() || (pos.quantity > 0 ? 'LONG' : 'SHORT')
const rawSymbol = pos.symbol || ''
// Stock symbols are pure letters (1-5 chars), crypto has USDT suffix
const isStock = /^[A-Z]{1,5}$/.test(rawSymbol) && !rawSymbol.endsWith('USDT')
const symbol = isStock ? rawSymbol : rawSymbol.replace('USDT', '')
const currencyPrefix = isStock ? '$' : ''
return (
<div
key={i}
style={{
padding: '10px 12px',
background: '#0d0d15',
borderRadius: 10,
border: '1px solid #1a1a28',
}}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 6,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span
style={{
fontSize: 13,
fontWeight: 600,
color: '#eaeaf0',
}}
>
{symbol}
</span>
{isStock && (
<span style={{ fontSize: 10, color: '#8b8ba0' }}>🇺🇸</span>
)}
<span
style={{
fontSize: 10,
fontWeight: 600,
padding: '1px 5px',
borderRadius: 4,
background:
side === 'LONG'
? 'rgba(0,229,160,0.12)'
: 'rgba(246,70,93,0.12)',
color: side === 'LONG' ? '#00e5a0' : '#F6465D',
}}
>
{isStock ? (side === 'LONG' ? 'HOLD' : 'SHORT') : side}
</span>
</div>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 3,
color,
fontSize: 12,
fontWeight: 600,
}}
>
{isProfit ? (
<ArrowUpRight size={12} />
) : (
<ArrowDownRight size={12} />
)}
{isProfit ? '+' : ''}
{currencyPrefix}{pnl.toFixed(2)}
</div>
</div>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
fontSize: 11,
color: '#5c5c72',
}}
>
<span>{isStock ? 'Shares' : 'Qty'}: {pos.quantity}</span>
<span>Entry: {currencyPrefix}{pos.entry_price.toFixed(2)}</span>
</div>
</div>
)
})}
</div>
)
}

View File

@@ -1,110 +0,0 @@
import useSWR from 'swr'
import { useAuth } from '../../contexts/AuthContext'
import { api } from '../../lib/api'
import { Activity, CircleOff, Bot } from 'lucide-react'
import type { TraderInfo } from '../../types'
export function TraderStatusPanel() {
const { user, token } = useAuth()
const { data: traders } = useSWR<TraderInfo[]>(
user && token ? 'agent-sidebar-traders' : null,
api.getTraders,
{ refreshInterval: 30000, shouldRetryOnError: false }
)
if (!user || !token) {
return (
<div
style={{
padding: '20px 14px',
textAlign: 'center',
color: '#5c5c72',
fontSize: 12,
}}
>
<Bot size={20} style={{ margin: '0 auto 8px', opacity: 0.5 }} />
<div>Login to view traders</div>
</div>
)
}
if (!traders || traders.length === 0) {
return (
<div
style={{
padding: '16px 14px',
textAlign: 'center',
color: '#5c5c72',
fontSize: 12,
}}
>
No traders configured
</div>
)
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{traders.map((trader) => (
<div
key={trader.trader_id}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '10px 12px',
background: '#0d0d15',
borderRadius: 10,
border: '1px solid #1a1a28',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div
style={{
width: 28,
height: 28,
borderRadius: 7,
background: trader.is_running
? 'rgba(0,229,160,0.08)'
: 'rgba(92,92,114,0.08)',
display: 'grid',
placeItems: 'center',
}}
>
{trader.is_running ? (
<Activity size={14} color="#00e5a0" />
) : (
<CircleOff size={14} color="#5c5c72" />
)}
</div>
<div>
<div
style={{ fontSize: 13, fontWeight: 600, color: '#eaeaf0' }}
>
{trader.trader_name}
</div>
<div style={{ fontSize: 10, color: '#5c5c72' }}>
{trader.trader_id.slice(0, 8)}...
</div>
</div>
</div>
<div
style={{
fontSize: 10,
fontWeight: 600,
padding: '3px 8px',
borderRadius: 6,
background: trader.is_running
? 'rgba(0,229,160,0.12)'
: 'rgba(92,92,114,0.12)',
color: trader.is_running ? '#00e5a0' : '#5c5c72',
}}
>
{trader.is_running ? 'RUNNING' : 'STOPPED'}
</div>
</div>
))}
</div>
)
}

View File

@@ -1,222 +0,0 @@
import { useEffect, useState } from 'react'
interface Preference {
id: string
text: string
created_at?: string
}
interface Props {
token: string | null
language: string
}
export function UserPreferencesPanel({ token, language }: Props) {
const [preferences, setPreferences] = useState<Preference[]>([])
const [draft, setDraft] = useState('')
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const loadPreferences = async () => {
if (!token) {
setPreferences([])
setLoading(false)
return
}
setLoading(true)
try {
const res = await fetch('/api/agent/preferences', {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) throw new Error('Failed to load preferences')
const data = await res.json()
setPreferences(Array.isArray(data.preferences) ? data.preferences : [])
setError(null)
} catch {
setError(language === 'zh' ? '加载偏好失败' : 'Failed to load')
} finally {
setLoading(false)
}
}
useEffect(() => {
if (!token) {
setPreferences([])
setLoading(false)
return
}
let cancelled = false
void (async () => {
await loadPreferences()
})()
const handleRefresh = () => {
if (!cancelled) void loadPreferences()
}
window.addEventListener('agent-preferences-refresh', handleRefresh)
return () => {
cancelled = true
window.removeEventListener('agent-preferences-refresh', handleRefresh)
}
}, [token, language])
const addPreference = async () => {
const text = draft.trim()
if (!text || !token || saving) return
setSaving(true)
try {
const res = await fetch('/api/agent/preferences', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ text }),
})
const data = await res.json().catch(() => ({}))
if (!res.ok) throw new Error(data.error || 'save failed')
setPreferences(Array.isArray(data.preferences) ? data.preferences : [])
setDraft('')
setError(null)
} catch {
setError(language === 'zh' ? '保存偏好失败' : 'Failed to save')
} finally {
setSaving(false)
}
}
const removePreference = async (id: string) => {
if (!token || saving) return
setSaving(true)
try {
const res = await fetch(`/api/agent/preferences/${encodeURIComponent(id)}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
})
const data = await res.json().catch(() => ({}))
if (!res.ok) throw new Error(data.error || 'delete failed')
setPreferences(Array.isArray(data.preferences) ? data.preferences : [])
setError(null)
} catch {
setError(language === 'zh' ? '删除偏好失败' : 'Failed to delete')
} finally {
setSaving(false)
}
}
return (
<div
id="agent-preferences-panel"
style={{
background: 'rgba(255,255,255,0.02)',
border: '1px solid rgba(255,255,255,0.05)',
borderRadius: 12,
padding: 10,
}}
>
<div style={{ marginBottom: 8 }}>
<div style={{ color: '#d7d7e0', fontSize: 12, fontWeight: 600 }}>
{language === 'zh' ? '长期偏好' : 'Persistent Preferences'}
</div>
<div style={{ color: '#77778d', fontSize: 11, lineHeight: 1.5, marginTop: 4 }}>
{language === 'zh'
? '把长期偏好固定下来,比如“默认用中文回答”或“优先关注 BTC 和 ETH”。'
: 'Pin durable preferences the agent should keep in mind, like answering in Chinese or focusing on BTC and ETH.'}
</div>
</div>
<div style={{ display: 'flex', gap: 6, marginBottom: 8 }}>
<input
data-agent-preferences-input="true"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') void addPreference()
}}
placeholder={language === 'zh' ? '例如:默认用中文回答,优先关注 BTC、ETH' : 'Example: Answer in Chinese and focus on BTC, ETH'}
style={{
flex: 1,
background: 'rgba(255,255,255,0.03)',
border: '1px solid rgba(255,255,255,0.08)',
color: '#e8e8f0',
borderRadius: 8,
padding: '8px 10px',
fontSize: 12,
outline: 'none',
}}
/>
<button
onClick={() => void addPreference()}
disabled={!draft.trim() || saving}
style={{
background: draft.trim() && !saving ? 'rgba(240,185,11,0.12)' : 'rgba(255,255,255,0.04)',
color: draft.trim() && !saving ? '#F0B90B' : '#6d6d82',
border: '1px solid rgba(240,185,11,0.14)',
borderRadius: 8,
padding: '0 10px',
fontSize: 12,
cursor: draft.trim() && !saving ? 'pointer' : 'default',
}}
>
{language === 'zh' ? '添加' : 'Add'}
</button>
</div>
{error && (
<div style={{ color: '#f08a8a', fontSize: 11, marginBottom: 8 }}>{error}</div>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{loading ? (
<div style={{ color: '#77778d', fontSize: 11 }}>
{language === 'zh' ? '加载中...' : 'Loading...'}
</div>
) : preferences.length === 0 ? (
<div style={{ color: '#77778d', fontSize: 11, lineHeight: 1.5 }}>
{language === 'zh'
? '还没有长期偏好。你可以把关注标的、风险倾向、回答习惯放在这里。'
: 'No persistent preferences yet. Add watchlists, risk preferences, or response habits here.'}
</div>
) : (
preferences.map((pref) => (
<div
key={pref.id}
style={{
display: 'flex',
alignItems: 'flex-start',
gap: 8,
padding: 8,
borderRadius: 10,
background: 'rgba(255,255,255,0.025)',
border: '1px solid rgba(255,255,255,0.04)',
}}
>
<div style={{ flex: 1, color: '#d7d7e0', fontSize: 12, lineHeight: 1.5 }}>
{pref.text}
</div>
<button
onClick={() => void removePreference(pref.id)}
disabled={saving}
style={{
background: 'transparent',
border: 'none',
color: '#8b8ba0',
fontSize: 11,
cursor: saving ? 'default' : 'pointer',
padding: 0,
}}
>
{language === 'zh' ? '删除' : 'Delete'}
</button>
</div>
))
)}
</div>
</div>
)
}

View File

@@ -1,138 +0,0 @@
import { motion } from 'framer-motion'
import {
Zap,
BarChart3,
Lightbulb,
Search,
} from 'lucide-react'
interface SuggestionCard {
icon: JSX.Element
title: string
subtitle: string
cmd: string
}
interface WelcomeScreenProps {
language: string
onSend: (cmd: string) => void
}
export function WelcomeScreen({ language, onSend }: WelcomeScreenProps) {
const suggestions: SuggestionCard[] = language === 'zh'
? [
{ icon: <BarChart3 size={18} />, title: '分析 BTC 走势', subtitle: '技术分析 + 市场情绪', cmd: '分析一下 BTC 的走势' },
{ icon: <Zap size={18} />, title: '做多 ETH', subtitle: 'Agent 帮你自动下单', cmd: '帮我做多 ETH 0.01 手' },
{ icon: <Search size={18} />, title: '搜索股票', subtitle: '输入名称或代码即可', cmd: '搜索一下中远海控' },
{ icon: <Lightbulb size={18} />, title: '策略建议', subtitle: '根据当前市场给出建议', cmd: '当前市场适合什么策略?' },
]
: [
{ icon: <BarChart3 size={18} />, title: 'Analyze BTC', subtitle: 'Technical analysis + sentiment', cmd: 'Analyze BTC price action' },
{ icon: <Zap size={18} />, title: 'Trade ETH', subtitle: 'Agent executes for you', cmd: 'Open a long position on ETH 0.01' },
{ icon: <Search size={18} />, title: 'Search Stocks', subtitle: 'Enter name or ticker', cmd: 'Search for NVIDIA stock' },
{ icon: <Lightbulb size={18} />, title: 'Strategy Ideas', subtitle: 'Market-based suggestions', cmd: 'What strategy fits the current market?' },
]
return (
<div style={{
maxWidth: 640,
margin: '0 auto',
padding: '0 20px',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
minHeight: 400,
}}>
{/* Logo / greeting */}
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, ease: 'easeOut' }}
style={{ textAlign: 'center', marginBottom: 40 }}
>
<div style={{
width: 56,
height: 56,
borderRadius: 16,
background: 'linear-gradient(135deg, rgba(240,185,11,0.12), rgba(0,229,160,0.06))',
border: '1px solid rgba(240,185,11,0.15)',
display: 'grid',
placeItems: 'center',
margin: '0 auto 16px',
fontSize: 24,
}}>
</div>
<h1 style={{
fontSize: 22,
fontWeight: 700,
color: '#f0f0f8',
margin: '0 0 8px',
letterSpacing: '-0.02em',
}}>
{language === 'zh' ? '跟 NOFXi 聊点什么' : 'What can I help with?'}
</h1>
<p style={{
fontSize: 13.5,
color: '#5c5c72',
margin: 0,
lineHeight: 1.5,
}}>
{language === 'zh'
? '分析行情、执行交易、搜索股票 — 用自然语言就行'
: 'Analyze markets, execute trades, search stocks — just ask'}
</p>
</motion.div>
{/* Suggestion cards grid */}
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.1, ease: 'easeOut' }}
style={{
display: 'grid',
gridTemplateColumns: 'repeat(2, 1fr)',
gap: 10,
width: '100%',
maxWidth: 520,
}}
>
{suggestions.map((s, i) => (
<button
key={i}
onClick={() => onSend(s.cmd)}
className="suggestion-card"
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
gap: 6,
padding: '16px 14px',
background: 'rgba(255,255,255,0.02)',
border: '1px solid rgba(255,255,255,0.06)',
borderRadius: 14,
cursor: 'pointer',
textAlign: 'left',
fontFamily: 'inherit',
transition: 'all 0.2s ease',
}}
>
<div style={{ color: '#F0B90B', opacity: 0.7 }}>
{s.icon}
</div>
<div>
<div style={{ fontSize: 13, fontWeight: 600, color: '#d0d0e0', marginBottom: 2 }}>
{s.title}
</div>
<div style={{ fontSize: 11.5, color: '#5c5c72' }}>
{s.subtitle}
</div>
</div>
</button>
))}
</motion.div>
</div>
)
}

View File

@@ -1,6 +1,5 @@
import React, { useState, useEffect } from 'react'
import { Eye, EyeOff } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import { toast } from 'sonner'
import { useAuth } from '../../contexts/AuthContext'
import { useLanguage } from '../../contexts/LanguageContext'
@@ -14,15 +13,12 @@ import { invalidateSystemConfig } from '../../lib/config'
export function LoginPage() {
const { language } = useLanguage()
const { login } = useAuth()
const navigate = useNavigate()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [showPassword, setShowPassword] = useState(false)
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const [expiredToastId, setExpiredToastId] = useState<string | number | null>(
null
)
const [expiredToastId, setExpiredToastId] = useState<string | number | null>(null)
const [mode, setMode] = useState<UserMode>('beginner')
// Clean up stale auth state once on mount
@@ -35,9 +31,7 @@ export function LoginPage() {
// Show session-expired toast (re-runs on language change to update text)
useEffect(() => {
if (sessionStorage.getItem('from401') === 'true') {
const id = toast.warning(t('sessionExpired', language), {
duration: Infinity,
})
const id = toast.warning(t('sessionExpired', language), { duration: Infinity })
setExpiredToastId(id)
sessionStorage.removeItem('from401')
}
@@ -54,9 +48,7 @@ export function LoginPage() {
sessionStorage.removeItem('from401')
invalidateSystemConfig()
toast.success(t('forgotAccountSuccess', language))
setTimeout(() => {
navigate('/setup')
}, 1500)
setTimeout(() => { window.location.href = '/setup' }, 1500)
} else {
const data = await res.json()
toast.error(data.error || 'Reset failed')
@@ -87,27 +79,23 @@ export function LoginPage() {
<div className="flex-1 flex items-center justify-center px-4 py-16">
<div className="w-full max-w-sm">
{/* Logo + Title */}
<div className="text-center mb-10">
<div className="flex justify-center mb-5">
<div className="relative">
<div className="absolute -inset-3 bg-nofx-gold/15 rounded-full blur-2xl" />
<img
src="/icons/nofx.svg"
alt="NOFX"
className="w-14 h-14 relative z-10"
/>
<img src="/icons/nofx.svg" alt="NOFX" className="w-14 h-14 relative z-10" />
</div>
</div>
<h1 className="text-2xl font-bold text-white mb-1.5">
Welcome back
</h1>
<h1 className="text-2xl font-bold text-white mb-1.5">Welcome back</h1>
<p className="text-zinc-500 text-sm">Sign in to your account</p>
</div>
{/* Card */}
<div className="bg-zinc-900/60 backdrop-blur-xl border border-zinc-800/80 rounded-2xl p-8 shadow-2xl">
<form onSubmit={handleLogin} className="space-y-5">
{/* Email */}
<div>
<label className="block text-xs font-medium text-zinc-400 mb-2">
@@ -132,7 +120,7 @@ export function LoginPage() {
</label>
<button
type="button"
onClick={() => navigate('/reset-password')}
onClick={() => window.location.href = '/reset-password'}
className="text-xs text-zinc-500 hover:text-nofx-gold transition-colors"
>
{t('forgotPassword', language)}
@@ -176,9 +164,7 @@ export function LoginPage() {
disabled={loading}
className="w-full bg-nofx-gold hover:bg-yellow-400 active:scale-[0.98] text-black font-semibold py-3 rounded-xl text-sm transition-all disabled:opacity-50 disabled:cursor-not-allowed mt-2"
>
{loading
? t('loggingIn', language) || 'Signing in...'
: t('signIn', language) || 'Sign In'}
{loading ? t('loggingIn', language) || 'Signing in...' : t('signIn', language) || 'Sign In'}
</button>
</form>
@@ -192,6 +178,7 @@ export function LoginPage() {
</button>
</div>
</div>
</div>
</div>
</DeepVoidBackground>

View File

@@ -1,6 +1,5 @@
import { motion, AnimatePresence } from 'framer-motion'
import { LogIn, UserPlus, X, AlertTriangle, Terminal } from 'lucide-react'
import { Link } from 'react-router-dom'
import { DeepVoidBackground } from '../common/DeepVoidBackground'
import { useLanguage } from '../../contexts/LanguageContext'
import { t } from '../../i18n/translations'
@@ -11,11 +10,7 @@ interface LoginRequiredOverlayProps {
featureName?: string
}
export function LoginRequiredOverlay({
isOpen,
onClose,
featureName,
}: LoginRequiredOverlayProps) {
export function LoginRequiredOverlay({ isOpen, onClose, featureName }: LoginRequiredOverlayProps) {
const { language } = useLanguage()
const tr = (key: string, params?: Record<string, string | number>) =>
@@ -25,7 +20,11 @@ export function LoginRequiredOverlay({
? tr('subtitleWithFeature', { featureName })
: tr('subtitleDefault')
const benefits = [tr('benefit1'), tr('benefit2'), tr('benefit4')]
const benefits = [
tr('benefit1'),
tr('benefit2'),
tr('benefit4'),
]
return (
<AnimatePresence>
@@ -41,6 +40,7 @@ export function LoginRequiredOverlay({
disableAnimation
onClick={onClose}
>
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
@@ -53,9 +53,7 @@ export function LoginRequiredOverlay({
<div className="flex items-center justify-between px-3 py-2 bg-nofx-bg-lighter border-b border-nofx-gold/20">
<div className="flex items-center gap-2">
<Terminal size={12} className="text-nofx-gold" />
<span className="text-[10px] text-nofx-text-muted uppercase tracking-wider">
auth_protocol.exe
</span>
<span className="text-[10px] text-nofx-text-muted uppercase tracking-wider">auth_protocol.exe</span>
</div>
<button
onClick={onClose}
@@ -77,9 +75,7 @@ export function LoginRequiredOverlay({
<div className="absolute inset-0 bg-red-500/20 blur-xl animate-pulse"></div>
<div className="bg-nofx-bg border border-red-500/50 text-red-500 px-4 py-2 flex items-center gap-3 shadow-[0_0_15px_rgba(239,68,68,0.2)]">
<AlertTriangle size={18} className="animate-pulse" />
<span className="font-bold tracking-widest text-sm uppercase">
{tr('accessDenied')}
</span>
<span className="font-bold tracking-widest text-sm uppercase">{tr('accessDenied')}</span>
</div>
</div>
</div>
@@ -87,12 +83,8 @@ export function LoginRequiredOverlay({
{/* Terminal Text */}
<div className="space-y-4 mb-8">
<div className="text-center">
<h2 className="text-xl font-bold text-white uppercase tracking-wider mb-2">
{tr('title')}
</h2>
<p className="text-nofx-gold text-xs uppercase tracking-widest border-b border-nofx-gold/20 pb-4 inline-block">
{subtitle}
</p>
<h2 className="text-xl font-bold text-white uppercase tracking-wider mb-2">{tr('title')}</h2>
<p className="text-nofx-gold text-xs uppercase tracking-widest border-b border-nofx-gold/20 pb-4 inline-block">{subtitle}</p>
</div>
<div className="bg-nofx-bg-lighter border-l-2 border-nofx-gold/20 p-3 my-4">
@@ -104,10 +96,7 @@ export function LoginRequiredOverlay({
<div className="grid grid-cols-2 gap-2">
{benefits.map((benefit, i) => (
<div
key={i}
className="flex items-center gap-2 text-[10px] text-nofx-text-muted uppercase tracking-wide"
>
<div key={i} className="flex items-center gap-2 text-[10px] text-nofx-text-muted uppercase tracking-wide">
<span className="text-nofx-gold"></span> {benefit}
</div>
))}
@@ -116,24 +105,22 @@ export function LoginRequiredOverlay({
{/* Action Buttons */}
<div className="space-y-3">
<Link
to="/login"
<a
href="/login"
className="flex items-center justify-center gap-2 w-full py-3 bg-nofx-gold text-black font-bold text-xs uppercase tracking-widest hover:bg-yellow-400 transition-all shadow-neon hover:shadow-[0_0_25px_rgba(240,185,11,0.4)] group"
>
<LogIn size={14} />
<span>{tr('loginButton')}</span>
<span className="opacity-0 group-hover:opacity-100 transition-opacity -ml-2 group-hover:ml-0">
-&gt;
</span>
</Link>
<span className="opacity-0 group-hover:opacity-100 transition-opacity -ml-2 group-hover:ml-0">-&gt;</span>
</a>
<Link
to="/register"
<a
href="/register"
className="flex items-center justify-center gap-2 w-full py-3 bg-transparent border border-nofx-gold/20 text-nofx-text-muted hover:text-white hover:border-nofx-gold font-bold text-xs uppercase tracking-widest transition-all hover:bg-nofx-gold/10"
>
<UserPlus size={14} />
<span>{tr('registerButton')}</span>
</Link>
</a>
</div>
<div className="mt-4 text-center">
@@ -144,12 +131,14 @@ export function LoginRequiredOverlay({
[ {tr('abort')} ]
</button>
</div>
</div>
</div>
{/* Corner Accents */}
<div className="absolute top-0 right-0 w-2 h-2 border-t border-r border-nofx-gold"></div>
<div className="absolute bottom-0 left-0 w-2 h-2 border-b border-l border-nofx-gold"></div>
</motion.div>
</DeepVoidBackground>
</motion.div>

Some files were not shown because too many files have changed in this diff Show More