mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-06-06 05:51:19 +08:00
Compare commits
25 Commits
577a0918c3
...
openclaw
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4a81993bb | ||
|
|
b73617fed3 | ||
|
|
4774348ed6 | ||
|
|
e638ba8d8f | ||
|
|
156bf04bcc | ||
|
|
af250825e7 | ||
|
|
c5c5ed2a4d | ||
|
|
fcb90b77ae | ||
|
|
3ed0aec0ff | ||
|
|
9a3017af6d | ||
|
|
aebca4b16c | ||
|
|
767d8629a3 | ||
|
|
ff1ca4460d | ||
|
|
d160301359 | ||
|
|
1bbd4b44ac | ||
|
|
b2ce123df1 | ||
|
|
97f309c9b5 | ||
|
|
13d70d2598 | ||
|
|
138bbb1242 | ||
|
|
ca87dbe3bb | ||
|
|
ea7b450a7e | ||
|
|
9fcf44af65 | ||
|
|
5f47dd13db | ||
|
|
b354eb8bf2 | ||
|
|
3168a18c0d |
@@ -52,10 +52,6 @@ TRANSPORT_ENCRYPTION=false
|
||||
# Optional: External Services
|
||||
# ===========================================
|
||||
|
||||
# Telegram notifications (optional)
|
||||
# TELEGRAM_BOT_TOKEN=your-bot-token
|
||||
# TELEGRAM_CHAT_ID=your-chat-id
|
||||
|
||||
DB_TYPE=postgres
|
||||
DB_HOST=10.
|
||||
DB_PORT=5432
|
||||
|
||||
120
.github/PULL_REQUEST_TEMPLATE.md
vendored
120
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -1,100 +1,50 @@
|
||||
# Pull Request
|
||||
## Summary
|
||||
|
||||
> **📋 Choose Specialized Template**
|
||||
>
|
||||
> We now offer specialized templates for different types of PRs to help you fill out the information faster:
|
||||
>
|
||||
> - 🔧 **[Backend PR Template](./PULL_REQUEST_TEMPLATE/backend.md)** - For Go/API/Trading changes
|
||||
> - 🎨 **[Frontend PR Template](./PULL_REQUEST_TEMPLATE/frontend.md)** - For UI/UX changes
|
||||
> - 📝 **[Documentation PR Template](./PULL_REQUEST_TEMPLATE/docs.md)** - For documentation updates
|
||||
> - 📦 **[General PR Template](./PULL_REQUEST_TEMPLATE/general.md)** - For mixed or other changes
|
||||
>
|
||||
> **How to use?**
|
||||
> - When creating a PR, add `?template=backend.md` or other template name to the URL
|
||||
> - Or simply copy and paste the content from the corresponding template
|
||||
- Problem:
|
||||
- What changed:
|
||||
- What did NOT change (scope boundary):
|
||||
|
||||
---
|
||||
## Change Type
|
||||
|
||||
> **💡 Tip:** Recommended PR title format `type(scope): description`
|
||||
> Example: `feat(trader): add new strategy` | `fix(api): resolve auth issue`
|
||||
- [ ] Bug fix
|
||||
- [ ] Feature
|
||||
- [ ] Refactoring
|
||||
- [ ] Docs
|
||||
- [ ] Security fix
|
||||
- [ ] Chore / infra
|
||||
|
||||
---
|
||||
## Scope
|
||||
|
||||
## 📝 Description
|
||||
- [ ] Trading engine / strategies
|
||||
- [ ] MCP / AI clients
|
||||
- [ ] API / server
|
||||
- [ ] Telegram bot / agent
|
||||
- [ ] Web UI / frontend
|
||||
- [ ] Config / deployment
|
||||
- [ ] CI/CD / infra
|
||||
|
||||
<!-- Describe your changes in detail -->
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Type of Change
|
||||
|
||||
- [ ] 🐛 Bug fix
|
||||
- [ ] ✨ New feature
|
||||
- [ ] 💥 Breaking change
|
||||
- [ ] 📝 Documentation update
|
||||
- [ ] 🎨 Code style update
|
||||
- [ ] ♻️ Refactoring
|
||||
- [ ] ⚡ Performance improvement
|
||||
- [ ] ✅ Test update
|
||||
- [ ] 🔧 Build/config change
|
||||
- [ ] 🔒 Security fix
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Related Issues
|
||||
## Linked Issues
|
||||
|
||||
- Closes #
|
||||
- Related to #
|
||||
- Related #
|
||||
|
||||
---
|
||||
## Testing
|
||||
|
||||
## 📋 Changes Made
|
||||
What you verified and how:
|
||||
|
||||
<!-- List the specific changes made -->
|
||||
-
|
||||
-
|
||||
- [ ] `go build ./...` passes
|
||||
- [ ] `go test ./...` passes
|
||||
- [ ] Manual testing done (describe below)
|
||||
|
||||
---
|
||||
## Security Impact
|
||||
|
||||
## 🧪 Testing
|
||||
- Secrets/keys handling changed? (`Yes/No`)
|
||||
- New/changed API endpoints? (`Yes/No`)
|
||||
- User input validation affected? (`Yes/No`)
|
||||
|
||||
- [ ] Tested locally
|
||||
- [ ] Tests pass
|
||||
- [ ] Verified no existing functionality broke
|
||||
## Compatibility
|
||||
|
||||
---
|
||||
|
||||
## ✅ Checklist
|
||||
|
||||
### Code Quality
|
||||
- [ ] Code follows project style
|
||||
- [ ] Self-review completed
|
||||
- [ ] Comments added for complex logic
|
||||
|
||||
### Documentation
|
||||
- [ ] Updated relevant documentation
|
||||
|
||||
### Git
|
||||
- [ ] Commits follow conventional format
|
||||
- [ ] Rebased on latest `dev` branch
|
||||
- [ ] No merge conflicts
|
||||
|
||||
---
|
||||
|
||||
## 📚 Additional Notes
|
||||
|
||||
<!-- Any additional information or context -->
|
||||
|
||||
|
||||
---
|
||||
|
||||
**By submitting this PR, I confirm:**
|
||||
|
||||
- [ ] I have read the [Contributing Guidelines](../CONTRIBUTING.md)
|
||||
- [ ] I agree to the [Code of Conduct](../CODE_OF_CONDUCT.md)
|
||||
- [ ] My contribution is licensed under AGPL-3.0
|
||||
|
||||
---
|
||||
|
||||
🌟 **Thank you for your contribution!**
|
||||
- Backward compatible? (`Yes/No`)
|
||||
- Config/env changes? (`Yes/No`)
|
||||
- Migration needed? (`Yes/No`)
|
||||
- If yes, upgrade steps:
|
||||
|
||||
210
.github/workflows/pr-template-suggester.yml
vendored
210
.github/workflows/pr-template-suggester.yml
vendored
@@ -1,22 +1,18 @@
|
||||
name: PR Template Suggester
|
||||
name: PR Labeler
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, edited, synchronize]
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
issues: write
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
suggest-template:
|
||||
label-pr:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Analyze PR files and auto-apply template
|
||||
- name: Analyze PR and apply labels
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -25,166 +21,72 @@ jobs:
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: context.issue.number,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
let goFiles = 0, jsFiles = 0, tsFiles = 0, mdFiles = 0, otherFiles = 0;
|
||||
let additions = 0, deletions = 0;
|
||||
|
||||
for (const file of files) {
|
||||
const filename = file.filename.toLowerCase();
|
||||
if (filename.endsWith('.go')) goFiles++;
|
||||
else if (filename.endsWith('.js') || filename.endsWith('.jsx')) jsFiles++;
|
||||
else if (filename.endsWith('.ts') || filename.endsWith('.tsx') || filename.endsWith('.vue')) tsFiles++;
|
||||
else if (filename.endsWith('.md')) mdFiles++;
|
||||
const name = file.filename.toLowerCase();
|
||||
additions += file.additions || 0;
|
||||
deletions += file.deletions || 0;
|
||||
if (name.endsWith('.go')) goFiles++;
|
||||
else if (name.endsWith('.js') || name.endsWith('.jsx')) jsFiles++;
|
||||
else if (name.endsWith('.ts') || name.endsWith('.tsx') || name.endsWith('.vue')) tsFiles++;
|
||||
else if (name.endsWith('.md')) mdFiles++;
|
||||
else otherFiles++;
|
||||
}
|
||||
|
||||
const totalFiles = goFiles + jsFiles + tsFiles + mdFiles + otherFiles;
|
||||
if (totalFiles === 0) { console.log('No files changed'); return; }
|
||||
if (totalFiles === 0) return;
|
||||
|
||||
let suggestedTemplate = null, templateEmoji = '', templateLabel = '';
|
||||
// --- Scope label ---
|
||||
const labels = [];
|
||||
if (goFiles / totalFiles > 0.5) labels.push('backend');
|
||||
else if ((jsFiles + tsFiles) / totalFiles > 0.5) labels.push('frontend');
|
||||
else if (mdFiles / totalFiles > 0.7) labels.push('documentation');
|
||||
else labels.push('fullstack');
|
||||
|
||||
if (goFiles / totalFiles > 0.5) {
|
||||
suggestedTemplate = 'backend'; templateEmoji = '🔧'; templateLabel = 'backend';
|
||||
} else if ((jsFiles + tsFiles) / totalFiles > 0.5) {
|
||||
suggestedTemplate = 'frontend'; templateEmoji = '🎨'; templateLabel = 'frontend';
|
||||
} else if (mdFiles / totalFiles > 0.7) {
|
||||
suggestedTemplate = 'docs'; templateEmoji = '📝'; templateLabel = 'documentation';
|
||||
// --- Size label (like OpenClaw) ---
|
||||
const totalChanged = additions + deletions;
|
||||
const sizeLabels = ['size: XS', 'size: S', 'size: M', 'size: L', 'size: XL'];
|
||||
let sizeLabel = 'size: XL';
|
||||
if (totalChanged < 50) sizeLabel = 'size: XS';
|
||||
else if (totalChanged < 200) sizeLabel = 'size: S';
|
||||
else if (totalChanged < 500) sizeLabel = 'size: M';
|
||||
else if (totalChanged < 1000) sizeLabel = 'size: L';
|
||||
labels.push(sizeLabel);
|
||||
|
||||
// Ensure size labels exist
|
||||
for (const sl of sizeLabels) {
|
||||
try {
|
||||
await github.rest.issues.getLabel({ owner: context.repo.owner, repo: context.repo.repo, name: sl });
|
||||
} catch (e) {
|
||||
if (e.status === 404) {
|
||||
await github.rest.issues.createLabel({ owner: context.repo.owner, repo: context.repo.repo, name: sl, color: 'b76e79' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const { data: pr } = await github.rest.pulls.get({
|
||||
// Remove stale size labels
|
||||
const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({
|
||||
owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number,
|
||||
});
|
||||
for (const cl of currentLabels) {
|
||||
if (sizeLabels.includes(cl.name) && cl.name !== sizeLabel) {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, name: cl.name,
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
// Apply labels
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: context.issue.number,
|
||||
issue_number: context.issue.number,
|
||||
labels: labels,
|
||||
});
|
||||
|
||||
const prBody = pr.body || '';
|
||||
const usesBackendTemplate = prBody.includes('Pull Request - Backend');
|
||||
const usesFrontendTemplate = prBody.includes('Pull Request - Frontend');
|
||||
const usesDocsTemplate = prBody.includes('Pull Request - Documentation');
|
||||
const usesGeneralTemplate = prBody.includes('Pull Request - General');
|
||||
const usingDefaultTemplate = !usesBackendTemplate && !usesFrontendTemplate && !usesDocsTemplate && !usesGeneralTemplate;
|
||||
|
||||
if (templateLabel) {
|
||||
try {
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
labels: [templateLabel]
|
||||
});
|
||||
console.log('Added label: ' + templateLabel);
|
||||
} catch (error) {
|
||||
console.log('Label might not exist, skipping...');
|
||||
}
|
||||
}
|
||||
|
||||
function isPRBodyEmpty(body) {
|
||||
if (!body || body.trim().length < 100) return true;
|
||||
const hasEmptyDescription = body.includes('**English:**') && body.match(/\*\*English:\*\*\s*\n\s*\n\s*\n/);
|
||||
const hasEmptyChanges = body.includes('具体变更') && body.match(/\*\*中文:\*\*\s*\n\s*-\s*\n\s*-\s*\n/);
|
||||
if (hasEmptyDescription || hasEmptyChanges) return true;
|
||||
const descMatch = body.match(/\*\*English:\*\*[||]\s*\*\*中文:\*\*\s*\n\s*(.+)/);
|
||||
if (!descMatch || descMatch[1].trim().length < 10) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (suggestedTemplate && usingDefaultTemplate) {
|
||||
const shouldAutoApply = isPRBodyEmpty(prBody);
|
||||
const templatePath = '.github/PULL_REQUEST_TEMPLATE/' + suggestedTemplate + '.md';
|
||||
|
||||
if (shouldAutoApply) {
|
||||
try {
|
||||
const { data: templateFile } = await github.rest.repos.getContent({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
path: templatePath,
|
||||
ref: context.payload.pull_request.head.ref
|
||||
});
|
||||
|
||||
const templateContent = Buffer.from(templateFile.content, 'base64').toString('utf-8');
|
||||
|
||||
await github.rest.pulls.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: context.issue.number,
|
||||
body: templateContent
|
||||
});
|
||||
|
||||
console.log('Auto-applied ' + suggestedTemplate + ' template');
|
||||
|
||||
let fileStats = [];
|
||||
if (goFiles > 0) fileStats.push('- 🔧 Go files: ' + goFiles);
|
||||
if (jsFiles > 0) fileStats.push('- 🎨 JavaScript files: ' + jsFiles);
|
||||
if (tsFiles > 0) fileStats.push('- 🎨 TypeScript files: ' + tsFiles);
|
||||
if (mdFiles > 0) fileStats.push('- 📝 Markdown files: ' + mdFiles);
|
||||
if (otherFiles > 0) fileStats.push('- 📦 Other files: ' + otherFiles);
|
||||
const fileStatsText = fileStats.join('\n');
|
||||
|
||||
const notifyComment = '## ' + templateEmoji + ' 已自动应用专用模板 | Auto-Applied Template\n\n' +
|
||||
'检测到您的PR主要包含 **' + suggestedTemplate + '** 相关的变更,系统已自动为您应用相应的模板。\n\n' +
|
||||
'Detected that your PR primarily contains **' + suggestedTemplate + '** changes. The appropriate template has been automatically applied.\n\n' +
|
||||
'**文件统计 | File Statistics**\n' + fileStatsText + '\n\n' +
|
||||
'**已应用模板 | Applied Template**\n`' + templatePath + '`\n\n' +
|
||||
'✨ 您现在可以直接在PR描述中填写相关信息了!\n\n' +
|
||||
'✨ You can now fill in the relevant information in the PR description!';
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: notifyComment
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.log('Failed to fetch or apply template: ' + error.message);
|
||||
const templateUrl = 'https://raw.githubusercontent.com/' + context.repo.owner + '/' + context.repo.repo + '/dev/.github/PULL_REQUEST_TEMPLATE/' + suggestedTemplate + '.md';
|
||||
const fallbackComment = '## ' + templateEmoji + ' 建议使用专用模板 | Suggested Template\n\n' +
|
||||
'您的PR主要包含 **' + suggestedTemplate + '** 相关的变更。\n\n' +
|
||||
'**推荐模板 | Recommended Template:** `.github/PULL_REQUEST_TEMPLATE/' + suggestedTemplate + '.md`\n\n' +
|
||||
'**如何使用 | How to use:** [点击查看模板内容](' + templateUrl + ')';
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: fallbackComment
|
||||
});
|
||||
}
|
||||
} else {
|
||||
console.log('PR body has content, sending suggestion only');
|
||||
|
||||
let fileStats = [];
|
||||
if (goFiles > 0) fileStats.push('- 🔧 Go files: ' + goFiles);
|
||||
if (jsFiles > 0) fileStats.push('- 🎨 JavaScript files: ' + jsFiles);
|
||||
if (tsFiles > 0) fileStats.push('- 🎨 TypeScript files: ' + tsFiles);
|
||||
if (mdFiles > 0) fileStats.push('- 📝 Markdown files: ' + mdFiles);
|
||||
if (otherFiles > 0) fileStats.push('- 📦 Other files: ' + otherFiles);
|
||||
const fileStatsText = fileStats.join('\n');
|
||||
|
||||
const templateUrl = 'https://raw.githubusercontent.com/' + context.repo.owner + '/' + context.repo.repo + '/dev/.github/PULL_REQUEST_TEMPLATE/' + suggestedTemplate + '.md';
|
||||
|
||||
const comment = '## ' + templateEmoji + ' 建议使用专用模板 | Suggested Template\n\n' +
|
||||
'您的PR主要包含 **' + suggestedTemplate + '** 相关的变更。我们建议使用更适合的模板以简化填写。\n\n' +
|
||||
'Your PR primarily contains **' + suggestedTemplate + '** changes. We suggest using a more suitable template to simplify filling.\n\n' +
|
||||
'**文件统计 | File Statistics**\n' + fileStatsText + '\n\n' +
|
||||
'**推荐模板 | Recommended Template**\n```\n.github/PULL_REQUEST_TEMPLATE/' + suggestedTemplate + '.md\n```\n\n' +
|
||||
'**如何使用 | How to use**\n' +
|
||||
'1. 编辑PR描述 | Edit PR description\n' +
|
||||
'2. 复制 [' + suggestedTemplate + ' 模板内容](' + templateUrl + ') | Copy [' + suggestedTemplate + ' template content](' + templateUrl + ')\n' +
|
||||
'3. 或在创建PR时使用URL参数 | Or use URL parameter when creating PR\n' +
|
||||
' `?template=' + suggestedTemplate + '.md`\n\n' +
|
||||
'_这是一个自动建议,您可以继续使用当前模板。_\n\n' +
|
||||
'_This is an automated suggestion. You may continue using the current template._';
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: comment
|
||||
});
|
||||
}
|
||||
} else if (suggestedTemplate && !usingDefaultTemplate) {
|
||||
console.log('PR already uses a specific template');
|
||||
} else {
|
||||
console.log('No specific template suggestion needed - mixed changes');
|
||||
}
|
||||
console.log(`Applied labels: ${labels.join(', ')} (${totalChanged} lines changed)`);
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -16,6 +16,7 @@ nofx_test
|
||||
# Go 相关
|
||||
*.test
|
||||
*.out
|
||||
.gocache/
|
||||
|
||||
# 操作系统
|
||||
.DS_Store
|
||||
|
||||
66
api/route_registry.go
Normal file
66
api/route_registry.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// RouteDoc holds documentation for a single API route.
|
||||
type RouteDoc struct {
|
||||
Method string
|
||||
Path string
|
||||
Description string
|
||||
Schema string // optional: full parameter/body schema documentation
|
||||
}
|
||||
|
||||
// routeRegistry stores all documented routes. Populated via s.route() calls in setupRoutes.
|
||||
var routeRegistry []RouteDoc
|
||||
|
||||
// route registers an HTTP route with a one-line description.
|
||||
func (s *Server) route(g *gin.RouterGroup, method, path, description string, h gin.HandlerFunc) {
|
||||
s.routeWithSchema(g, method, path, description, "", h)
|
||||
}
|
||||
|
||||
// routeWithSchema registers an HTTP route with full parameter schema documentation.
|
||||
// schema is injected verbatim into the API docs seen by the LLM.
|
||||
func (s *Server) routeWithSchema(g *gin.RouterGroup, method, path, description, schema string, h gin.HandlerFunc) {
|
||||
fullPath := strings.TrimSuffix(g.BasePath(), "/") + "/" + strings.TrimPrefix(path, "/")
|
||||
routeRegistry = append(routeRegistry, RouteDoc{
|
||||
Method: method,
|
||||
Path: fullPath,
|
||||
Description: description,
|
||||
Schema: schema,
|
||||
})
|
||||
switch method {
|
||||
case "GET":
|
||||
g.GET(path, h)
|
||||
case "POST":
|
||||
g.POST(path, h)
|
||||
case "PUT":
|
||||
g.PUT(path, h)
|
||||
case "DELETE":
|
||||
g.DELETE(path, h)
|
||||
}
|
||||
}
|
||||
|
||||
// GetAPIDocs returns formatted API documentation for injection into the LLM system prompt.
|
||||
// Routes with schema documentation include full parameter details.
|
||||
func GetAPIDocs() string {
|
||||
var sb strings.Builder
|
||||
for _, r := range routeRegistry {
|
||||
sb.WriteString(fmt.Sprintf("%-8s %s\n", r.Method, r.Path))
|
||||
sb.WriteString(fmt.Sprintf(" %s\n", r.Description))
|
||||
if r.Schema != "" {
|
||||
// Indent each schema line for readability
|
||||
for _, line := range strings.Split(strings.TrimSpace(r.Schema), "\n") {
|
||||
sb.WriteString(" ")
|
||||
sb.WriteString(line)
|
||||
sb.WriteByte('\n')
|
||||
}
|
||||
}
|
||||
sb.WriteByte('\n')
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
489
api/server.go
489
api/server.go
@@ -40,14 +40,15 @@ import (
|
||||
|
||||
// Server HTTP API server
|
||||
type Server struct {
|
||||
router *gin.Engine
|
||||
traderManager *manager.TraderManager
|
||||
store *store.Store
|
||||
cryptoHandler *CryptoHandler
|
||||
backtestManager *backtest.Manager
|
||||
debateHandler *DebateHandler
|
||||
httpServer *http.Server
|
||||
port int
|
||||
router *gin.Engine
|
||||
traderManager *manager.TraderManager
|
||||
store *store.Store
|
||||
cryptoHandler *CryptoHandler
|
||||
backtestManager *backtest.Manager
|
||||
debateHandler *DebateHandler
|
||||
httpServer *http.Server
|
||||
port int
|
||||
telegramReloadCh chan<- struct{} // signal Telegram bot to reload
|
||||
}
|
||||
|
||||
// NewServer Creates API server
|
||||
@@ -114,108 +115,276 @@ func (s *Server) setupRoutes() {
|
||||
// Admin login (used in admin mode, public)
|
||||
|
||||
// System supported models and exchanges (no authentication required)
|
||||
api.GET("/supported-models", s.handleGetSupportedModels)
|
||||
api.GET("/supported-exchanges", s.handleGetSupportedExchanges)
|
||||
s.route(api, "GET", "/supported-models", "List supported AI model providers", s.handleGetSupportedModels)
|
||||
s.route(api, "GET", "/supported-exchanges", "List supported exchange types", s.handleGetSupportedExchanges)
|
||||
|
||||
// System config (no authentication required, for frontend to determine admin mode/registration status)
|
||||
api.GET("/config", s.handleGetSystemConfig)
|
||||
s.route(api, "GET", "/config", "Get system configuration", s.handleGetSystemConfig)
|
||||
|
||||
// Crypto related endpoints (no authentication required)
|
||||
// Crypto related endpoints (no authentication required, not exposed to bot)
|
||||
api.GET("/crypto/config", s.cryptoHandler.HandleGetCryptoConfig)
|
||||
api.GET("/crypto/public-key", s.cryptoHandler.HandleGetPublicKey)
|
||||
api.POST("/crypto/decrypt", s.cryptoHandler.HandleDecryptSensitiveData)
|
||||
|
||||
// Public competition data (no authentication required)
|
||||
api.GET("/traders", s.handlePublicTraderList)
|
||||
api.GET("/competition", s.handlePublicCompetition)
|
||||
api.GET("/top-traders", s.handleTopTraders)
|
||||
api.GET("/equity-history", s.handleEquityHistory)
|
||||
api.POST("/equity-history-batch", s.handleEquityHistoryBatch)
|
||||
api.GET("/traders/:id/public-config", s.handleGetPublicTraderConfig)
|
||||
s.route(api, "GET", "/traders", "Public trader list", s.handlePublicTraderList)
|
||||
s.route(api, "GET", "/competition", "Public competition data", s.handlePublicCompetition)
|
||||
s.route(api, "GET", "/top-traders", "Top traders leaderboard", s.handleTopTraders)
|
||||
s.route(api, "GET", "/equity-history", "Equity history for a trader", s.handleEquityHistory)
|
||||
s.route(api, "POST", "/equity-history-batch", "Batch equity history for multiple traders", s.handleEquityHistoryBatch)
|
||||
s.route(api, "GET", "/traders/:id/public-config", "Public trader configuration", s.handleGetPublicTraderConfig)
|
||||
|
||||
// Market data (no authentication required)
|
||||
api.GET("/klines", s.handleKlines)
|
||||
api.GET("/symbols", s.handleSymbols)
|
||||
s.route(api, "GET", "/klines", "Candlestick data (?symbol=&interval=&limit=)", s.handleKlines)
|
||||
s.route(api, "GET", "/symbols", "Available trading symbols", s.handleSymbols)
|
||||
|
||||
// Public strategy market (no authentication required)
|
||||
api.GET("/strategies/public", s.handlePublicStrategies)
|
||||
s.route(api, "GET", "/strategies/public", "Public strategy market", s.handlePublicStrategies)
|
||||
|
||||
// Authentication related routes (no authentication required)
|
||||
api.POST("/register", s.handleRegister)
|
||||
api.POST("/login", s.handleLogin)
|
||||
api.POST("/reset-password", s.handleResetPassword)
|
||||
s.route(api, "POST", "/register", "Register new user", s.handleRegister)
|
||||
s.route(api, "POST", "/login", "User login, returns JWT token", s.handleLogin)
|
||||
s.route(api, "POST", "/reset-password", "Reset password", s.handleResetPassword)
|
||||
|
||||
// Routes requiring authentication
|
||||
protected := api.Group("/", s.authMiddleware())
|
||||
{
|
||||
// Logout (add to blacklist)
|
||||
protected.POST("/logout", s.handleLogout)
|
||||
s.route(protected, "POST", "/logout", "Logout (blacklist token)", s.handleLogout)
|
||||
|
||||
// User account management
|
||||
s.routeWithSchema(protected, "PUT", "/user/password", "Change current user password",
|
||||
`Body: {"new_password":"<string, min 8 chars>"}`,
|
||||
s.handleChangePassword)
|
||||
|
||||
// Server IP query (requires authentication, for whitelist configuration)
|
||||
protected.GET("/server-ip", s.handleGetServerIP)
|
||||
s.route(protected, "GET", "/server-ip", "Get server public IP (for exchange whitelist)", s.handleGetServerIP)
|
||||
|
||||
// AI trader management
|
||||
protected.GET("/my-traders", s.handleTraderList)
|
||||
protected.GET("/traders/:id/config", s.handleGetTraderConfig)
|
||||
protected.POST("/traders", s.handleCreateTrader)
|
||||
protected.PUT("/traders/:id", s.handleUpdateTrader)
|
||||
protected.DELETE("/traders/:id", s.handleDeleteTrader)
|
||||
protected.POST("/traders/:id/start", s.handleStartTrader)
|
||||
protected.POST("/traders/:id/stop", s.handleStopTrader)
|
||||
protected.PUT("/traders/:id/prompt", s.handleUpdateTraderPrompt)
|
||||
protected.POST("/traders/:id/sync-balance", s.handleSyncBalance)
|
||||
protected.POST("/traders/:id/close-position", s.handleClosePosition)
|
||||
protected.PUT("/traders/:id/competition", s.handleToggleCompetition)
|
||||
protected.GET("/traders/:id/grid-risk", s.handleGetGridRiskInfo)
|
||||
s.routeWithSchema(protected, "GET", "/my-traders", "List user's traders with status",
|
||||
`Returns: [{"trader_id":"<EXACT id — use this as trader_id in all ?trader_id= queries and POST /traders/:id/start|stop>","trader_name":"<string>","is_running":<bool>}]
|
||||
NOTE: The id field is "trader_id" (NOT "id"). Always read trader_id from this endpoint before querying data.`,
|
||||
s.handleTraderList)
|
||||
s.routeWithSchema(protected, "GET", "/traders/:id/config", "Get full trader configuration",
|
||||
`:id = trader_id from GET /api/my-traders`,
|
||||
s.handleGetTraderConfig)
|
||||
s.routeWithSchema(protected, "POST", "/traders", "Create a new AI trader",
|
||||
`Body: {"name":"<string, required>","ai_model_id":"<EXACT id field from GET /api/models — e.g. 'abc123_deepseek', NOT the provider name 'deepseek'>","exchange_id":"<EXACT id field from GET /api/exchanges — e.g. '05785d3b-841e-...', NOT the type name>","strategy_id":"<EXACT id field from GET /api/strategies>","scan_interval_minutes":<int, default 3, minimum 3>}
|
||||
IMPORTANT: ai_model_id and exchange_id must be the full "id" value from the Account State, not the provider/type name.`,
|
||||
s.handleCreateTrader)
|
||||
s.routeWithSchema(protected, "PUT", "/traders/:id", "Update trader configuration",
|
||||
`:id = trader_id from GET /api/my-traders
|
||||
Body: {"name":"<string>","ai_model_id":"<EXACT id from GET /api/models>","exchange_id":"<EXACT id from GET /api/exchanges>","strategy_id":"<EXACT id from GET /api/strategies>","scan_interval_minutes":<int, min 3>,"is_cross_margin":<bool>}
|
||||
Only include fields you want to change.`,
|
||||
s.handleUpdateTrader)
|
||||
s.routeWithSchema(protected, "DELETE", "/traders/:id", "Delete trader",
|
||||
`:id = trader_id from GET /api/my-traders. Stops and permanently removes the trader and all its data.`,
|
||||
s.handleDeleteTrader)
|
||||
s.routeWithSchema(protected, "POST", "/traders/:id/start", "Start trader — begins live trading",
|
||||
`:id = trader_id from GET /api/my-traders. No request body needed. The trader must have a valid exchange and AI model configured.`,
|
||||
s.handleStartTrader)
|
||||
s.routeWithSchema(protected, "POST", "/traders/:id/stop", "Stop trader — halts live trading",
|
||||
`:id = trader_id from GET /api/my-traders. No request body needed. Gracefully stops the trading loop.`,
|
||||
s.handleStopTrader)
|
||||
s.routeWithSchema(protected, "PUT", "/traders/:id/prompt", "Override the trader's AI system prompt",
|
||||
`Body: {"prompt":"<string — the full custom prompt text>"}`,
|
||||
s.handleUpdateTraderPrompt)
|
||||
s.routeWithSchema(protected, "POST", "/traders/:id/sync-balance", "Sync account balance from exchange",
|
||||
`:id = trader_id from GET /api/my-traders. No request body needed. Refreshes initial_balance from the exchange.`,
|
||||
s.handleSyncBalance)
|
||||
s.routeWithSchema(protected, "POST", "/traders/:id/close-position", "Force-close an open position",
|
||||
`:id = trader_id from GET /api/my-traders.
|
||||
Body: {"symbol":"<string, e.g. BTCUSDT — must match an open position symbol from GET /api/positions>"}`,
|
||||
s.handleClosePosition)
|
||||
s.routeWithSchema(protected, "PUT", "/traders/:id/competition", "Toggle competition leaderboard visibility",
|
||||
`:id = trader_id from GET /api/my-traders.
|
||||
Body: {"show_in_competition":<bool>}`,
|
||||
s.handleToggleCompetition)
|
||||
s.routeWithSchema(protected, "GET", "/traders/:id/grid-risk", "Get grid trading risk info",
|
||||
`:id = trader_id from GET /api/my-traders.`,
|
||||
s.handleGetGridRiskInfo)
|
||||
|
||||
// AI model configuration
|
||||
protected.GET("/models", s.handleGetModelConfigs)
|
||||
protected.PUT("/models", s.handleUpdateModelConfigs)
|
||||
s.routeWithSchema(protected, "GET", "/models", "List AI model configs",
|
||||
`Returns: [{"id":"<EXACT id — use this as ai_model_id when creating/updating a trader>","name":"<display name>","provider":"<short provider name — NOT a valid id>","enabled":<bool>}]
|
||||
CRITICAL: The "id" field (e.g. "abc123_deepseek") is what you must use for ai_model_id. The "provider" field ("deepseek") is NOT valid as an id.`,
|
||||
s.handleGetModelConfigs)
|
||||
s.routeWithSchema(protected, "PUT", "/models", "Configure an AI model provider",
|
||||
`Body: {"models":{"<model_id>":{"enabled":<bool>,"api_key":"<string>","custom_api_url":"<string, leave empty to use provider default>","custom_model_name":"<string, leave empty to use provider default>"}}}
|
||||
model_id values: "openai","deepseek","qwen","kimi","grok","gemini","claude"
|
||||
Defaults when custom fields empty: openai→api.openai.com/v1, deepseek→api.deepseek.com, qwen→dashscope.aliyuncs.com/compatible-mode/v1, kimi→api.moonshot.ai/v1, grok→api.x.ai/v1, gemini→generativelanguage.googleapis.com/v1beta/openai, claude→api.anthropic.com/v1`,
|
||||
s.handleUpdateModelConfigs)
|
||||
|
||||
// Exchange configuration
|
||||
protected.GET("/exchanges", s.handleGetExchangeConfigs)
|
||||
protected.POST("/exchanges", s.handleCreateExchange)
|
||||
protected.PUT("/exchanges", s.handleUpdateExchangeConfigs)
|
||||
protected.DELETE("/exchanges/:id", s.handleDeleteExchange)
|
||||
s.routeWithSchema(protected, "GET", "/exchanges", "List exchange accounts",
|
||||
`Returns: [{"id":"<EXACT id — use this as exchange_id when creating/updating a trader>","exchange_type":"<e.g. okx, binance>","account_name":"<user label>","enabled":<bool>}]
|
||||
CRITICAL: Always use the "id" field for exchange_id. Do not use "exchange_type" as an id.`,
|
||||
s.handleGetExchangeConfigs)
|
||||
s.routeWithSchema(protected, "POST", "/exchanges", "Create a new exchange account",
|
||||
`Body: {"exchange_type":"<string>","account_name":"<string, user label>","enabled":true,"api_key":"<string>","secret_key":"<string>","passphrase":"<string, required for okx/gate/kucoin>"}
|
||||
exchange_type values: "binance","bybit","okx","bitget","gate","kucoin","indodax" (CEX) | "hyperliquid","aster","lighter" (DEX)
|
||||
Required fields by exchange:
|
||||
binance/bybit/bitget/indodax: api_key + secret_key
|
||||
okx/gate/kucoin: api_key + secret_key + passphrase
|
||||
hyperliquid: hyperliquid_wallet_addr
|
||||
aster: aster_user + aster_signer + aster_private_key
|
||||
lighter: lighter_wallet_addr + lighter_private_key + lighter_api_key_private_key + lighter_api_key_index`,
|
||||
s.handleCreateExchange)
|
||||
s.routeWithSchema(protected, "PUT", "/exchanges", "Update an existing exchange account configuration",
|
||||
`Body: {"id":"<EXACT id from GET /api/exchanges>","exchange_type":"<string>","account_name":"<string>","enabled":<bool>,"api_key":"<string>","secret_key":"<string>","passphrase":"<string, for okx/gate/kucoin>"}
|
||||
Use this to enable/disable an exchange or update API credentials. The "id" field is required to identify which exchange to update.`,
|
||||
s.handleUpdateExchangeConfigs)
|
||||
s.routeWithSchema(protected, "DELETE", "/exchanges/:id", "Delete exchange account",
|
||||
`:id = EXACT id from GET /api/exchanges. Permanently removes the exchange account and disconnects any traders using it.`,
|
||||
s.handleDeleteExchange)
|
||||
|
||||
// Telegram bot configuration
|
||||
s.routeWithSchema(protected, "GET", "/telegram", "Get Telegram bot configuration",
|
||||
`Returns: {"bot_token":"<string>","model_id":"<EXACT id of configured AI model>","chat_id":"<bound Telegram chat id, empty if not bound>"}`,
|
||||
s.handleGetTelegramConfig)
|
||||
s.routeWithSchema(protected, "POST", "/telegram", "Set Telegram bot token and AI model",
|
||||
`Body: {"bot_token":"<string — Telegram BotFather token>","model_id":"<EXACT id from GET /api/models>"}
|
||||
Both fields are required. After saving, the user must send /start in Telegram to bind their account.`,
|
||||
s.handleUpdateTelegramConfig)
|
||||
s.routeWithSchema(protected, "POST", "/telegram/model", "Update Telegram bot AI model only",
|
||||
`Body: {"model_id":"<EXACT id from GET /api/models>"}`,
|
||||
s.handleUpdateTelegramModel)
|
||||
s.routeWithSchema(protected, "DELETE", "/telegram/binding", "Unbind Telegram account",
|
||||
`No body needed. Clears the Telegram chat_id binding so the user can re-bind with /start.`,
|
||||
s.handleUnbindTelegram)
|
||||
|
||||
// Strategy management
|
||||
protected.GET("/strategies", s.handleGetStrategies)
|
||||
protected.GET("/strategies/active", s.handleGetActiveStrategy)
|
||||
protected.GET("/strategies/default-config", s.handleGetDefaultStrategyConfig)
|
||||
protected.POST("/strategies/preview-prompt", s.handlePreviewPrompt)
|
||||
protected.POST("/strategies/test-run", s.handleStrategyTestRun)
|
||||
protected.GET("/strategies/:id", s.handleGetStrategy)
|
||||
protected.POST("/strategies", s.handleCreateStrategy)
|
||||
protected.PUT("/strategies/:id", s.handleUpdateStrategy)
|
||||
protected.DELETE("/strategies/:id", s.handleDeleteStrategy)
|
||||
protected.POST("/strategies/:id/activate", s.handleActivateStrategy)
|
||||
protected.POST("/strategies/:id/duplicate", s.handleDuplicateStrategy)
|
||||
s.routeWithSchema(protected, "GET", "/strategies", "List user's strategies",
|
||||
`Returns: [{"id":"<EXACT id — use as strategy_id when creating/updating a trader>","name":"<string>","is_active":<bool>,"is_default":<bool>}]
|
||||
CRITICAL: Always use the "id" field for strategy_id.`,
|
||||
s.handleGetStrategies)
|
||||
s.routeWithSchema(protected, "GET", "/strategies/active", "Get the currently active strategy",
|
||||
`Returns the strategy marked is_active=true for this user, or the system default. Use this to find which strategy is currently in use.`,
|
||||
s.handleGetActiveStrategy)
|
||||
s.routeWithSchema(protected, "GET", "/strategies/default-config", "Get default strategy config with all fields and sensible values — use as reference for building configs",
|
||||
`No parameters needed. Returns a complete StrategyConfig object with all fields populated with recommended defaults. Read this before building a custom config.`,
|
||||
s.handleGetDefaultStrategyConfig)
|
||||
s.route(protected, "POST", "/strategies/preview-prompt", "Preview the AI prompt that will be generated from a config", s.handlePreviewPrompt)
|
||||
s.route(protected, "POST", "/strategies/test-run", "Test-run strategy AI analysis", s.handleStrategyTestRun)
|
||||
s.route(protected, "GET", "/strategies/:id", "Get strategy by ID", s.handleGetStrategy)
|
||||
s.routeWithSchema(protected, "POST", "/strategies", "Create a new trading strategy",
|
||||
`Body: {"name":"<string, required>","description":"<string, optional>","lang":"zh|en","config":<StrategyConfig object, OPTIONAL — if omitted the system applies complete working defaults automatically (ai500 top coins, all standard indicators, standard risk control)>}
|
||||
IMPORTANT: For most use cases just POST {"name":"<name>"} — the backend fills everything in. Only include "config" when the user explicitly requests custom settings (specific coins, custom leverage, custom timeframes).
|
||||
|
||||
StrategyConfig fields:
|
||||
coin_source.source_type: "static"(fixed coin list) | "ai500"(AI top500 ranking) | "oi_top"(OI increasing, suited for long) | "oi_low"(OI decreasing, suited for short) | "mixed"
|
||||
coin_source.static_coins: ["BTCUSDT","ETHUSDT"] — only when source_type="static"
|
||||
coin_source.use_ai500, ai500_limit: number of coins from AI500 pool (default 10)
|
||||
coin_source.use_oi_top/use_oi_low, oi_top_limit/oi_low_limit: OI-based coin selection
|
||||
indicators.klines.primary_timeframe: "1m"|"3m"|"5m"|"15m"|"1h"|"4h" — scalping→"5m", trend/swing→"1h"/"4h"
|
||||
indicators.klines.primary_count: number of candles (20-100)
|
||||
indicators.klines.enable_multi_timeframe: true for trend/swing analysis
|
||||
indicators.klines.selected_timeframes: e.g. ["5m","15m","1h","4h"]
|
||||
indicators.enable_raw_klines: ALWAYS true (raw OHLCV required)
|
||||
indicators.enable_ema: true for trend-following (EMA crossover signals)
|
||||
indicators.enable_macd: true for trend + momentum confirmation
|
||||
indicators.enable_rsi: true for overbought/oversold, divergence detection
|
||||
indicators.enable_boll: true for volatility, range trading, breakout strategies
|
||||
indicators.enable_atr: true for volatility measurement and stop-loss sizing
|
||||
indicators.enable_volume: ALWAYS true
|
||||
indicators.enable_oi: ALWAYS true (open interest data)
|
||||
indicators.enable_funding_rate: ALWAYS true
|
||||
indicators.ema_periods: [20,50] default, [9,21] for faster signals
|
||||
indicators.rsi_periods: [7,14] default
|
||||
indicators.atr_periods: [14] default
|
||||
indicators.boll_periods: [20] default
|
||||
indicators.nofxos_api_key: ALWAYS "cm_568c67eae410d912c54c"
|
||||
indicators.enable_quant_data: ALWAYS true
|
||||
indicators.enable_quant_oi: ALWAYS true
|
||||
indicators.enable_quant_netflow: ALWAYS true
|
||||
indicators.enable_oi_ranking: ALWAYS true, oi_ranking_duration:"1h", oi_ranking_limit:10
|
||||
indicators.enable_netflow_ranking: ALWAYS true, netflow_ranking_duration:"1h", netflow_ranking_limit:10
|
||||
indicators.enable_price_ranking: ALWAYS true, price_ranking_duration:"1h,4h,24h", price_ranking_limit:10
|
||||
risk_control.max_positions: max simultaneous positions (1=single coin, 3=diversified, 5=wide)
|
||||
risk_control.btc_eth_max_leverage: BTC/ETH leverage (conservative:3-5, moderate:5-10, aggressive:10-20)
|
||||
risk_control.altcoin_max_leverage: altcoin leverage (usually lower than BTC leverage)
|
||||
risk_control.btc_eth_max_position_value_ratio: max position size as multiple of equity (default 5)
|
||||
risk_control.altcoin_max_position_value_ratio: default 1
|
||||
risk_control.max_margin_usage: 0.5-0.95 (default 0.9 = use up to 90% margin)
|
||||
risk_control.min_position_size: minimum USDT per trade (default 12)
|
||||
risk_control.min_risk_reward_ratio: minimum profit/loss ratio required (default 3 = 3:1)
|
||||
risk_control.min_confidence: minimum AI confidence to open position (default 75, range 60-90)
|
||||
prompt_sections.role_definition: describe the AI's trading persona and goal
|
||||
prompt_sections.trading_frequency: guidelines on how often to trade
|
||||
prompt_sections.entry_standards: conditions that must align before entering a position
|
||||
prompt_sections.decision_process: step-by-step decision-making framework`,
|
||||
s.handleCreateStrategy)
|
||||
s.routeWithSchema(protected, "PUT", "/strategies/:id", "Update an existing strategy — WORKFLOW: 1) GET /api/strategies/:id first to read current config 2) Merge your changes into the full config 3) PUT with complete merged config 4) GET again to verify saved values",
|
||||
`Body: {"name":"<string>","description":"<string>","config":<complete StrategyConfig — same structure as POST /api/strategies>}
|
||||
IMPORTANT: config is merged with existing values server-side, but always send the complete section you are modifying.
|
||||
After updating, always GET /api/strategies/:id to verify and show the user actual saved values.`,
|
||||
s.handleUpdateStrategy)
|
||||
s.routeWithSchema(protected, "DELETE", "/strategies/:id", "Delete strategy",
|
||||
`:id = EXACT id from GET /api/strategies. Cannot delete a strategy that is currently assigned to a running trader.`,
|
||||
s.handleDeleteStrategy)
|
||||
s.routeWithSchema(protected, "POST", "/strategies/:id/activate", "Mark a strategy as the active strategy for this user",
|
||||
`:id = EXACT id from GET /api/strategies.
|
||||
No request body needed. Sets this strategy as is_active=true (and deactivates the previous active strategy).
|
||||
After activating, create or update a trader with this strategy_id to apply it.`,
|
||||
s.handleActivateStrategy)
|
||||
s.routeWithSchema(protected, "POST", "/strategies/:id/duplicate", "Duplicate an existing strategy",
|
||||
`:id = EXACT id from GET /api/strategies. Creates a copy with " (copy)" appended to the name.`,
|
||||
s.handleDuplicateStrategy)
|
||||
|
||||
// Debate Arena
|
||||
protected.GET("/debates", s.debateHandler.HandleListDebates)
|
||||
protected.GET("/debates/personalities", s.debateHandler.HandleGetPersonalities)
|
||||
protected.GET("/debates/:id", s.debateHandler.HandleGetDebate)
|
||||
protected.POST("/debates", s.debateHandler.HandleCreateDebate)
|
||||
protected.POST("/debates/:id/start", s.debateHandler.HandleStartDebate)
|
||||
protected.POST("/debates/:id/cancel", s.debateHandler.HandleCancelDebate)
|
||||
protected.POST("/debates/:id/execute", s.debateHandler.HandleExecuteDebate)
|
||||
protected.DELETE("/debates/:id", s.debateHandler.HandleDeleteDebate)
|
||||
protected.GET("/debates/:id/messages", s.debateHandler.HandleGetMessages)
|
||||
protected.GET("/debates/:id/votes", s.debateHandler.HandleGetVotes)
|
||||
protected.GET("/debates/:id/stream", s.debateHandler.HandleDebateStream)
|
||||
s.route(protected, "GET", "/debates", "List debates", s.debateHandler.HandleListDebates)
|
||||
s.route(protected, "GET", "/debates/personalities", "Available AI personalities", s.debateHandler.HandleGetPersonalities)
|
||||
s.route(protected, "GET", "/debates/:id", "Get debate details", s.debateHandler.HandleGetDebate)
|
||||
s.route(protected, "POST", "/debates", "Create debate", s.debateHandler.HandleCreateDebate)
|
||||
s.route(protected, "POST", "/debates/:id/start", "Start debate", s.debateHandler.HandleStartDebate)
|
||||
s.route(protected, "POST", "/debates/:id/cancel", "Cancel debate", s.debateHandler.HandleCancelDebate)
|
||||
s.route(protected, "POST", "/debates/:id/execute", "Execute debate consensus decision", s.debateHandler.HandleExecuteDebate)
|
||||
s.route(protected, "DELETE", "/debates/:id", "Delete debate", s.debateHandler.HandleDeleteDebate)
|
||||
s.route(protected, "GET", "/debates/:id/messages", "Get debate messages", s.debateHandler.HandleGetMessages)
|
||||
s.route(protected, "GET", "/debates/:id/votes", "Get debate votes", s.debateHandler.HandleGetVotes)
|
||||
s.route(protected, "GET", "/debates/:id/stream", "SSE stream for live debate", s.debateHandler.HandleDebateStream)
|
||||
|
||||
// Data for specified trader (using query parameter ?trader_id=xxx)
|
||||
protected.GET("/status", s.handleStatus)
|
||||
protected.GET("/account", s.handleAccount)
|
||||
protected.GET("/positions", s.handlePositions)
|
||||
protected.GET("/positions/history", s.handlePositionHistory)
|
||||
protected.GET("/trades", s.handleTrades)
|
||||
protected.GET("/orders", s.handleOrders) // Order list (all orders)
|
||||
protected.GET("/orders/:id/fills", s.handleOrderFills) // Order fill details
|
||||
protected.GET("/open-orders", s.handleOpenOrders) // Open orders from exchange (pending SL/TP)
|
||||
protected.GET("/decisions", s.handleDecisions)
|
||||
protected.GET("/decisions/latest", s.handleLatestDecisions)
|
||||
protected.GET("/statistics", s.handleStatistics)
|
||||
// IMPORTANT: All ?trader_id= values must be the EXACT "trader_id" field from GET /api/my-traders
|
||||
s.routeWithSchema(protected, "GET", "/status", "Trader running status",
|
||||
`Query: ?trader_id=<EXACT trader_id from GET /api/my-traders>
|
||||
Returns: {"is_running":<bool>,"trader_id":"<string>"}`,
|
||||
s.handleStatus)
|
||||
s.routeWithSchema(protected, "GET", "/account", "Account balance and equity",
|
||||
`Query: ?trader_id=<EXACT trader_id from GET /api/my-traders>
|
||||
Returns: {"balance":<float>,"equity":<float>,"unrealized_pnl":<float>,"initial_balance":<float>,"total_return_pct":<float>}`,
|
||||
s.handleAccount)
|
||||
s.routeWithSchema(protected, "GET", "/positions", "Current open positions",
|
||||
`Query: ?trader_id=<EXACT trader_id from GET /api/my-traders>
|
||||
Returns: [{"symbol":"<string>","side":"long|short","size":<float>,"entry_price":<float>,"mark_price":<float>,"unrealized_pnl":<float>,"leverage":<int>}]`,
|
||||
s.handlePositions)
|
||||
s.routeWithSchema(protected, "GET", "/positions/history", "Closed position history",
|
||||
`Query: ?trader_id=<EXACT trader_id from GET /api/my-traders>&limit=<int, default 20>`,
|
||||
s.handlePositionHistory)
|
||||
s.routeWithSchema(protected, "GET", "/trades", "Trade records",
|
||||
`Query: ?trader_id=<EXACT trader_id from GET /api/my-traders>&limit=<int, default 20>`,
|
||||
s.handleTrades)
|
||||
s.routeWithSchema(protected, "GET", "/orders", "All order records",
|
||||
`Query: ?trader_id=<EXACT trader_id from GET /api/my-traders>&limit=<int, default 20>`,
|
||||
s.handleOrders)
|
||||
s.routeWithSchema(protected, "GET", "/orders/:id/fills", "Order fill details",
|
||||
`:id = order id from GET /api/orders`,
|
||||
s.handleOrderFills)
|
||||
s.routeWithSchema(protected, "GET", "/open-orders", "Open orders currently on exchange",
|
||||
`Query: ?trader_id=<EXACT trader_id from GET /api/my-traders>`,
|
||||
s.handleOpenOrders)
|
||||
s.routeWithSchema(protected, "GET", "/decisions", "AI trading decisions (decision records)",
|
||||
`Query: ?trader_id=<EXACT trader_id from GET /api/my-traders>&limit=<int, default 20>
|
||||
Returns: [{"id":"<string>","symbol":"<string>","action":"open_long|open_short|close_long|close_short|hold","confidence":<int>,"reasoning":"<string>","created_at":"<timestamp>"}]`,
|
||||
s.handleDecisions)
|
||||
s.routeWithSchema(protected, "GET", "/decisions/latest", "Latest AI decisions (most recent scan results)",
|
||||
`Query: ?trader_id=<EXACT trader_id from GET /api/my-traders>
|
||||
Returns the most recent AI decision for each symbol analyzed in the last scan cycle.`,
|
||||
s.handleLatestDecisions)
|
||||
s.routeWithSchema(protected, "GET", "/statistics", "Trading performance statistics",
|
||||
`Query: ?trader_id=<EXACT trader_id from GET /api/my-traders>
|
||||
Returns: {"total_trades":<int>,"winning_trades":<int>,"win_rate":<float>,"total_pnl":<float>,"sharpe_ratio":<float>,"max_drawdown":<float>}`,
|
||||
s.handleStatistics)
|
||||
|
||||
// Backtest routes
|
||||
backtest := protected.Group("/backtest")
|
||||
@@ -234,12 +403,11 @@ func (s *Server) handleHealth(c *gin.Context) {
|
||||
|
||||
// handleGetSystemConfig Get system configuration (configuration that client needs to know)
|
||||
func (s *Server) handleGetSystemConfig(c *gin.Context) {
|
||||
cfg := config.Get()
|
||||
|
||||
userCount, _ := s.store.User().Count()
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"registration_enabled": cfg.RegistrationEnabled,
|
||||
"btc_eth_leverage": 10, // Default value
|
||||
"altcoin_leverage": 5, // Default value
|
||||
"initialized": userCount > 0,
|
||||
"btc_eth_leverage": 10,
|
||||
"altcoin_leverage": 5,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3087,11 +3255,18 @@ func (s *Server) handleLogout(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Logged out"})
|
||||
}
|
||||
|
||||
// handleRegister Handle user registration request
|
||||
// handleRegister Handle user registration request.
|
||||
// handleRegister allows registration only when no users exist yet (first-time setup).
|
||||
// This is a single-user system; subsequent registrations are permanently closed.
|
||||
func (s *Server) handleRegister(c *gin.Context) {
|
||||
// Check if registration is allowed
|
||||
if !config.Get().RegistrationEnabled {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Registration is disabled"})
|
||||
userCount, err := s.store.User().Count()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check user count"})
|
||||
return
|
||||
}
|
||||
|
||||
if userCount > 0 {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "System already initialized"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -3106,26 +3281,12 @@ func (s *Server) handleRegister(c *gin.Context) {
|
||||
}
|
||||
|
||||
// Check if email already exists
|
||||
_, err := s.store.User().GetByEmail(req.Email)
|
||||
_, err = s.store.User().GetByEmail(req.Email)
|
||||
if err == nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Email already registered"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check max users limit (only for new users)
|
||||
maxUsers := config.Get().MaxUsers
|
||||
if maxUsers > 0 {
|
||||
userCount, err := s.store.User().Count()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check user count"})
|
||||
return
|
||||
}
|
||||
if userCount >= maxUsers {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Not on whitelist"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Generate password hash
|
||||
passwordHash, err := auth.HashPassword(req.Password)
|
||||
if err != nil {
|
||||
@@ -3208,6 +3369,28 @@ func (s *Server) handleLogin(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// handleChangePassword changes the password for the currently authenticated user.
|
||||
func (s *Server) handleChangePassword(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
var req struct {
|
||||
NewPassword string `json:"new_password" binding:"required,min=8"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
SafeBadRequest(c, "new_password is required (min 8 chars)")
|
||||
return
|
||||
}
|
||||
hash, err := auth.HashPassword(req.NewPassword)
|
||||
if err != nil {
|
||||
SafeInternalError(c, "Password processing failed", err)
|
||||
return
|
||||
}
|
||||
if err := s.store.User().UpdatePassword(userID, hash); err != nil {
|
||||
SafeInternalError(c, "Failed to update password", err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Password updated"})
|
||||
}
|
||||
|
||||
// handleResetPassword Reset password via email and new password
|
||||
func (s *Server) handleResetPassword(c *gin.Context) {
|
||||
var req struct {
|
||||
@@ -3267,6 +3450,7 @@ func (s *Server) handleGetSupportedModels(c *gin.Context) {
|
||||
{"id": "minimax", "name": "MiniMax", "provider": "minimax", "defaultModel": "MiniMax-M2.5"},
|
||||
{"id": "blockrun-base", "name": "BlockRun (Base Wallet)", "provider": "blockrun-base", "defaultModel": "auto"},
|
||||
{"id": "blockrun-sol", "name": "BlockRun (Solana Wallet)", "provider": "blockrun-sol", "defaultModel": "auto"},
|
||||
{"id": "claw402", "name": "Claw402 (Base USDC)", "provider": "claw402", "defaultModel": "deepseek"},
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, supportedModels)
|
||||
@@ -3626,3 +3810,106 @@ func (s *Server) handleGetPublicTraderConfig(c *gin.Context) {
|
||||
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// SetTelegramReloadCh sets the channel used to signal the Telegram bot to reload
|
||||
func (s *Server) SetTelegramReloadCh(ch chan<- struct{}) {
|
||||
s.telegramReloadCh = ch
|
||||
}
|
||||
|
||||
// handleGetTelegramConfig returns current Telegram bot configuration and binding status
|
||||
func (s *Server) handleGetTelegramConfig(c *gin.Context) {
|
||||
cfg, err := s.store.TelegramConfig().Get()
|
||||
if err != nil {
|
||||
// Not configured yet - return empty state
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"configured": false,
|
||||
"is_bound": false,
|
||||
"token_masked": "",
|
||||
"username": "",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Mask bot token for security (show only last 6 chars)
|
||||
tokenMasked := ""
|
||||
if cfg.BotToken != "" {
|
||||
if len(cfg.BotToken) > 6 {
|
||||
tokenMasked = "***" + cfg.BotToken[len(cfg.BotToken)-6:]
|
||||
} else {
|
||||
tokenMasked = "***"
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"configured": cfg.BotToken != "",
|
||||
"is_bound": cfg.ChatID != 0,
|
||||
"username": cfg.Username,
|
||||
"bound_at": cfg.BoundAt,
|
||||
"token_masked": tokenMasked,
|
||||
"model_id": cfg.ModelID,
|
||||
})
|
||||
}
|
||||
|
||||
// handleUpdateTelegramConfig saves bot token (+ optional model ID) and triggers bot hot-reload
|
||||
func (s *Server) handleUpdateTelegramConfig(c *gin.Context) {
|
||||
var req struct {
|
||||
BotToken string `json:"bot_token"`
|
||||
ModelID string `json:"model_id"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
if req.BotToken == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "bot_token is required"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.store.TelegramConfig().Save(req.BotToken, req.ModelID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save config"})
|
||||
return
|
||||
}
|
||||
|
||||
// Signal bot hot-reload if channel is available
|
||||
if s.telegramReloadCh != nil {
|
||||
select {
|
||||
case s.telegramReloadCh <- struct{}{}:
|
||||
default: // non-blocking
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "Bot token saved. Bot will reload automatically."})
|
||||
}
|
||||
|
||||
// handleUnbindTelegram removes Telegram user binding
|
||||
func (s *Server) handleUnbindTelegram(c *gin.Context) {
|
||||
if err := s.store.TelegramConfig().Unbind(); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to unbind"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "Telegram binding removed"})
|
||||
}
|
||||
|
||||
// handleUpdateTelegramModel updates only the AI model used for Telegram replies (no token re-entry needed)
|
||||
func (s *Server) handleUpdateTelegramModel(c *gin.Context) {
|
||||
var req struct {
|
||||
ModelID string `json:"model_id"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
|
||||
cfg, err := s.store.TelegramConfig().Get()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no Telegram config found, save a bot token first"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.store.TelegramConfig().Save(cfg.BotToken, req.ModelID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save model config"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "model_id": req.ModelID})
|
||||
}
|
||||
|
||||
@@ -136,7 +136,8 @@ func (s *Server) handleGetStrategy(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// handleCreateStrategy Create strategy
|
||||
// handleCreateStrategy Create strategy.
|
||||
// If "config" is omitted from the request body, the system default config is used automatically.
|
||||
func (s *Server) handleCreateStrategy(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
if userID == "" {
|
||||
@@ -145,9 +146,10 @@ func (s *Server) handleCreateStrategy(c *gin.Context) {
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
Config store.StrategyConfig `json:"config" binding:"required"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
Lang string `json:"lang"` // "zh" or "en", used when config is omitted
|
||||
Config *store.StrategyConfig `json:"config"` // optional — uses default if omitted
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
@@ -155,6 +157,16 @@ func (s *Server) handleCreateStrategy(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Use default config when none provided
|
||||
if req.Config == nil {
|
||||
lang := req.Lang
|
||||
if lang == "" {
|
||||
lang = "zh"
|
||||
}
|
||||
defaultCfg := store.GetDefaultStrategyConfig(lang)
|
||||
req.Config = &defaultCfg
|
||||
}
|
||||
|
||||
// Serialize configuration
|
||||
configJSON, err := json.Marshal(req.Config)
|
||||
if err != nil {
|
||||
@@ -178,7 +190,7 @@ func (s *Server) handleCreateStrategy(c *gin.Context) {
|
||||
}
|
||||
|
||||
// Validate configuration and collect warnings
|
||||
warnings := validateStrategyConfig(&req.Config)
|
||||
warnings := validateStrategyConfig(req.Config)
|
||||
|
||||
response := gin.H{
|
||||
"id": strategy.ID,
|
||||
@@ -191,7 +203,10 @@ func (s *Server) handleCreateStrategy(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// handleUpdateStrategy Update strategy
|
||||
// handleUpdateStrategy Update strategy.
|
||||
// The incoming config is merged with the existing one: top-level sections present in the
|
||||
// request overwrite the corresponding existing sections; absent sections are preserved.
|
||||
// This prevents partial updates from zeroing out unmentioned fields.
|
||||
func (s *Server) handleUpdateStrategy(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
strategyID := c.Param("id")
|
||||
@@ -213,11 +228,11 @@ func (s *Server) handleUpdateStrategy(c *gin.Context) {
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Config store.StrategyConfig `json:"config"`
|
||||
IsPublic bool `json:"is_public"`
|
||||
ConfigVisible bool `json:"config_visible"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Config json.RawMessage `json:"config"` // raw JSON so we can merge
|
||||
IsPublic bool `json:"is_public"`
|
||||
ConfigVisible bool `json:"config_visible"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
@@ -225,8 +240,33 @@ func (s *Server) handleUpdateStrategy(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Serialize configuration
|
||||
configJSON, err := json.Marshal(req.Config)
|
||||
// Start with the existing config as base — preserves all unmentioned fields.
|
||||
var mergedConfig store.StrategyConfig
|
||||
if err := json.Unmarshal([]byte(existing.Config), &mergedConfig); err != nil {
|
||||
// If existing config is corrupt, start from zero
|
||||
mergedConfig = store.StrategyConfig{}
|
||||
}
|
||||
|
||||
// Apply incoming config on top: top-level sections present in the request overwrite
|
||||
// their corresponding existing section; absent sections remain unchanged.
|
||||
if len(req.Config) > 0 && string(req.Config) != "null" {
|
||||
if err := json.Unmarshal(req.Config, &mergedConfig); err != nil {
|
||||
SafeBadRequest(c, "Invalid config JSON")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Preserve existing name/description when not supplied
|
||||
name := req.Name
|
||||
if name == "" {
|
||||
name = existing.Name
|
||||
}
|
||||
description := req.Description
|
||||
if description == "" {
|
||||
description = existing.Description
|
||||
}
|
||||
|
||||
configJSON, err := json.Marshal(mergedConfig)
|
||||
if err != nil {
|
||||
SafeInternalError(c, "Serialize configuration", err)
|
||||
return
|
||||
@@ -235,8 +275,8 @@ func (s *Server) handleUpdateStrategy(c *gin.Context) {
|
||||
strategy := &store.Strategy{
|
||||
ID: strategyID,
|
||||
UserID: userID,
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
Name: name,
|
||||
Description: description,
|
||||
Config: string(configJSON),
|
||||
IsPublic: req.IsPublic,
|
||||
ConfigVisible: req.ConfigVisible,
|
||||
@@ -247,8 +287,8 @@ func (s *Server) handleUpdateStrategy(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Validate configuration and collect warnings
|
||||
warnings := validateStrategyConfig(&req.Config)
|
||||
// Validate merged configuration and collect warnings
|
||||
warnings := validateStrategyConfig(&mergedConfig)
|
||||
|
||||
response := gin.H{"message": "Strategy updated successfully"}
|
||||
if len(warnings) > 0 {
|
||||
@@ -628,6 +668,15 @@ func (s *Server) runRealAITest(userID, modelID, systemPrompt, userPrompt string)
|
||||
case "minimax":
|
||||
aiClient = mcp.NewMiniMaxClient()
|
||||
aiClient.SetAPIKey(apiKey, model.CustomAPIURL, model.CustomModelName)
|
||||
case "blockrun-base":
|
||||
aiClient = mcp.NewBlockRunBaseClient()
|
||||
aiClient.SetAPIKey(apiKey, "", model.CustomModelName)
|
||||
case "blockrun-sol":
|
||||
aiClient = mcp.NewBlockRunSolClient()
|
||||
aiClient.SetAPIKey(apiKey, "", model.CustomModelName)
|
||||
case "claw402":
|
||||
aiClient = mcp.NewClaw402Client()
|
||||
aiClient.SetAPIKey(apiKey, "", model.CustomModelName)
|
||||
default:
|
||||
// Use generic client
|
||||
aiClient = mcp.NewClient()
|
||||
|
||||
@@ -78,6 +78,27 @@ func configureMCPClient(cfg BacktestConfig, base mcp.AIClient) (mcp.AIClient, er
|
||||
mmC := mcp.NewMiniMaxClientWithOptions()
|
||||
mmC.(*mcp.MiniMaxClient).SetAPIKey(cfg.AICfg.APIKey, cfg.AICfg.BaseURL, cfg.AICfg.Model)
|
||||
return mmC, nil
|
||||
case "blockrun-base":
|
||||
if cfg.AICfg.APIKey == "" {
|
||||
return nil, fmt.Errorf("blockrun-base provider requires wallet private key")
|
||||
}
|
||||
brBase := mcp.NewBlockRunBaseClient()
|
||||
brBase.SetAPIKey(cfg.AICfg.APIKey, "", cfg.AICfg.Model)
|
||||
return brBase, nil
|
||||
case "blockrun-sol":
|
||||
if cfg.AICfg.APIKey == "" {
|
||||
return nil, fmt.Errorf("blockrun-sol provider requires wallet keypair")
|
||||
}
|
||||
brSol := mcp.NewBlockRunSolClient()
|
||||
brSol.SetAPIKey(cfg.AICfg.APIKey, "", cfg.AICfg.Model)
|
||||
return brSol, nil
|
||||
case "claw402":
|
||||
if cfg.AICfg.APIKey == "" {
|
||||
return nil, fmt.Errorf("claw402 provider requires wallet private key")
|
||||
}
|
||||
claw := mcp.NewClaw402Client()
|
||||
claw.SetAPIKey(cfg.AICfg.APIKey, "", cfg.AICfg.Model)
|
||||
return claw, nil
|
||||
case "custom":
|
||||
if cfg.AICfg.BaseURL == "" || cfg.AICfg.APIKey == "" || cfg.AICfg.Model == "" {
|
||||
return nil, fmt.Errorf("custom provider requires base_url, api key and model")
|
||||
|
||||
@@ -15,10 +15,8 @@ var global *Config
|
||||
// Only contains truly global config, trading related config is at trader/strategy level
|
||||
type Config struct {
|
||||
// Service configuration
|
||||
APIServerPort int
|
||||
JWTSecret string
|
||||
RegistrationEnabled bool
|
||||
MaxUsers int // Maximum number of users allowed (0 = unlimited, default = 10)
|
||||
APIServerPort int
|
||||
JWTSecret string
|
||||
|
||||
// Database configuration
|
||||
DBType string // sqlite or postgres
|
||||
@@ -44,14 +42,13 @@ type Config struct {
|
||||
AlpacaAPIKey string // Alpaca API key for US stocks
|
||||
AlpacaSecretKey string // Alpaca secret key
|
||||
TwelveDataKey string // TwelveData API key for forex & metals
|
||||
|
||||
}
|
||||
|
||||
// Init initializes global configuration (from .env)
|
||||
func Init() {
|
||||
cfg := &Config{
|
||||
APIServerPort: 8080,
|
||||
RegistrationEnabled: true,
|
||||
MaxUsers: 10, // Default: 10 users allowed
|
||||
ExperienceImprovement: true, // Default: enabled to help improve the product
|
||||
// Database defaults
|
||||
DBType: "sqlite",
|
||||
@@ -71,16 +68,6 @@ func Init() {
|
||||
cfg.JWTSecret = "default-jwt-secret-change-in-production"
|
||||
}
|
||||
|
||||
if v := os.Getenv("REGISTRATION_ENABLED"); v != "" {
|
||||
cfg.RegistrationEnabled = strings.ToLower(v) == "true"
|
||||
}
|
||||
|
||||
if v := os.Getenv("MAX_USERS"); v != "" {
|
||||
if maxUsers, err := strconv.Atoi(v); err == nil && maxUsers >= 0 {
|
||||
cfg.MaxUsers = maxUsers
|
||||
}
|
||||
}
|
||||
|
||||
if v := os.Getenv("API_SERVER_PORT"); v != "" {
|
||||
if port, err := strconv.Atoi(v); err == nil && port > 0 {
|
||||
cfg.APIServerPort = port
|
||||
|
||||
@@ -99,6 +99,12 @@ func (e *DebateEngine) InitializeClients(participants []*store.DebateParticipant
|
||||
client = mcp.NewKimiClient()
|
||||
case "minimax":
|
||||
client = mcp.NewMiniMaxClient()
|
||||
case "blockrun-base":
|
||||
client = mcp.NewBlockRunBaseClient()
|
||||
case "blockrun-sol":
|
||||
client = mcp.NewBlockRunSolClient()
|
||||
case "claw402":
|
||||
client = mcp.NewClaw402Client()
|
||||
default:
|
||||
client = mcp.New()
|
||||
}
|
||||
|
||||
1039
docs/plans/2026-03-06-telegram-agent-redesign.md
Normal file
1039
docs/plans/2026-03-06-telegram-agent-redesign.md
Normal file
File diff suppressed because it is too large
Load Diff
1218
docs/plans/2026-03-06-telegram-bot.md
Normal file
1218
docs/plans/2026-03-06-telegram-bot.md
Normal file
File diff suppressed because it is too large
Load Diff
10
main.go
10
main.go
@@ -11,6 +11,7 @@ import (
|
||||
"nofx/manager"
|
||||
"nofx/mcp"
|
||||
"nofx/store"
|
||||
"nofx/telegram"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
@@ -130,12 +131,21 @@ func main() {
|
||||
|
||||
// Start API server
|
||||
server := api.NewServer(traderManager, st, cryptoService, backtestManager, cfg.APIServerPort)
|
||||
|
||||
// Create hot-reload channel for Telegram bot; wire it to the API server
|
||||
// so that POST /api/telegram can trigger a bot restart when the token changes.
|
||||
telegramReloadCh := make(chan struct{}, 1)
|
||||
server.SetTelegramReloadCh(telegramReloadCh)
|
||||
|
||||
go func() {
|
||||
if err := server.Start(); err != nil {
|
||||
logger.Fatalf("❌ Failed to start API server: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Start Telegram bot (if TELEGRAM_BOT_TOKEN is configured)
|
||||
go telegram.Start(cfg, st, telegramReloadCh)
|
||||
|
||||
// Wait for interrupt signal
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/ecdsa"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -97,120 +95,24 @@ func (c *BlockRunBaseClient) SetAPIKey(apiKey string, customURL string, customMo
|
||||
}
|
||||
}
|
||||
|
||||
func (c *BlockRunBaseClient) setAuthHeader(reqHeaders http.Header) {
|
||||
// No Bearer token — payment is via x402 signing
|
||||
}
|
||||
func (c *BlockRunBaseClient) setAuthHeader(h http.Header) { x402SetAuthHeader(h) }
|
||||
|
||||
// call overrides the base call to handle HTTP 402 x402 v2 payment flow.
|
||||
func (c *BlockRunBaseClient) call(systemPrompt, userPrompt string) (string, error) {
|
||||
c.logger.Infof("📡 [BlockRun Base] Request AI Server: %s", c.BaseURL)
|
||||
|
||||
requestBody := c.hooks.buildMCPRequestBody(systemPrompt, userPrompt)
|
||||
jsonData, err := c.hooks.marshalRequestBody(requestBody)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
url := c.hooks.buildUrl()
|
||||
req, err := c.hooks.buildRequest(url, jsonData)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to send request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Handle x402 v2 Payment Required
|
||||
if resp.StatusCode == http.StatusPaymentRequired {
|
||||
paymentHeader := resp.Header.Get("X-Payment-Required")
|
||||
if paymentHeader == "" {
|
||||
return "", fmt.Errorf("received 402 but no X-Payment-Required header")
|
||||
}
|
||||
|
||||
paymentSig, err := c.signPayment(paymentHeader)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to sign x402 payment: %w", err)
|
||||
}
|
||||
|
||||
req2, err := c.hooks.buildRequest(url, jsonData)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to build retry request: %w", err)
|
||||
}
|
||||
req2.Header.Set("X-Payment", paymentSig)
|
||||
|
||||
resp2, err := c.httpClient.Do(req2)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to send payment retry: %w", err)
|
||||
}
|
||||
defer resp2.Body.Close()
|
||||
|
||||
body2, err := io.ReadAll(resp2.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read payment retry response: %w", err)
|
||||
}
|
||||
if resp2.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("BlockRun payment retry failed (status %d): %s", resp2.StatusCode, string(body2))
|
||||
}
|
||||
return c.hooks.parseMCPResponse(body2)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("BlockRun API error (status %d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
return c.hooks.parseMCPResponse(body)
|
||||
return x402Call(c.Client, c.signPayment, "BlockRun Base", systemPrompt, userPrompt)
|
||||
}
|
||||
|
||||
// x402v2PaymentRequired is the structure of the X-Payment-Required header (x402 v2).
|
||||
type x402v2PaymentRequired struct {
|
||||
X402Version int `json:"x402Version"`
|
||||
Accepts []struct {
|
||||
Scheme string `json:"scheme"`
|
||||
Network string `json:"network"`
|
||||
Amount string `json:"amount"`
|
||||
Asset string `json:"asset"`
|
||||
PayTo string `json:"payTo"`
|
||||
MaxTimeoutSeconds int `json:"maxTimeoutSeconds"`
|
||||
Extra map[string]string `json:"extra"`
|
||||
} `json:"accepts"`
|
||||
Resource *struct {
|
||||
URL string `json:"url"`
|
||||
Description string `json:"description"`
|
||||
MimeType string `json:"mimeType"`
|
||||
} `json:"resource"`
|
||||
func (c *BlockRunBaseClient) CallWithRequestFull(req *Request) (*LLMResponse, error) {
|
||||
return x402CallFull(c.Client, c.signPayment, "BlockRun Base", req)
|
||||
}
|
||||
|
||||
// signPayment parses the X-Payment-Required header (x402 v2) and returns a signed X-Payment value.
|
||||
// signPayment parses the Payment-Required header (x402 v2) and returns a signed payment value.
|
||||
func (c *BlockRunBaseClient) signPayment(paymentHeaderB64 string) (string, error) {
|
||||
if c.privateKey == nil {
|
||||
return "", fmt.Errorf("no private key set for BlockRun Base wallet")
|
||||
}
|
||||
return signBasePaymentHeader(c.privateKey, paymentHeaderB64, "BlockRun Base")
|
||||
}
|
||||
|
||||
// Decode base64 → JSON
|
||||
decoded, err := base64.RawStdEncoding.DecodeString(paymentHeaderB64)
|
||||
if err != nil {
|
||||
decoded, err = base64.StdEncoding.DecodeString(paymentHeaderB64)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to base64-decode payment header: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
var req x402v2PaymentRequired
|
||||
if err := json.Unmarshal(decoded, &req); err != nil {
|
||||
return "", fmt.Errorf("failed to parse x402 v2 payment header: %w", err)
|
||||
}
|
||||
|
||||
if len(req.Accepts) == 0 {
|
||||
return "", fmt.Errorf("no payment options in x402 response")
|
||||
}
|
||||
|
||||
opt := req.Accepts[0]
|
||||
// signX402Payment is the shared EIP-712 signing logic for x402 v2 on Base USDC.
|
||||
// Used by both BlockRunBaseClient and Claw402Client.
|
||||
func signX402Payment(privateKey *ecdsa.PrivateKey, senderAddr string, opt x402AcceptOption, resource *x402Resource) (string, error) {
|
||||
recipient := opt.PayTo
|
||||
amount := opt.Amount
|
||||
network := opt.Network
|
||||
@@ -224,28 +126,22 @@ func (c *BlockRunBaseClient) signPayment(paymentHeaderB64 string) (string, error
|
||||
resourceURL := ""
|
||||
resourceDesc := ""
|
||||
resourceMime := "application/json"
|
||||
if req.Resource != nil {
|
||||
resourceURL = req.Resource.URL
|
||||
resourceDesc = req.Resource.Description
|
||||
resourceMime = req.Resource.MimeType
|
||||
if resource != nil {
|
||||
resourceURL = resource.URL
|
||||
resourceDesc = resource.Description
|
||||
resourceMime = resource.MimeType
|
||||
}
|
||||
|
||||
// Timestamps: validAfter = now-600 (clock skew), validBefore = now+maxTimeout
|
||||
now := time.Now().Unix()
|
||||
validAfter := now - 600
|
||||
validAfter := int64(0)
|
||||
validBefore := now + int64(maxTimeout)
|
||||
|
||||
// Random nonce (bytes32)
|
||||
nonceBytes := make([]byte, 32)
|
||||
if _, err := rand.Read(nonceBytes); err != nil {
|
||||
return "", fmt.Errorf("failed to generate nonce: %w", err)
|
||||
}
|
||||
nonce := "0x" + hex.EncodeToString(nonceBytes)
|
||||
|
||||
// Sender address
|
||||
senderAddr := crypto.PubkeyToAddress(c.privateKey.PublicKey).Hex()
|
||||
|
||||
// Build EIP-712 domain separator
|
||||
domainName := "USD Coin"
|
||||
domainVersion := "2"
|
||||
if extra != nil {
|
||||
@@ -262,7 +158,6 @@ func (c *BlockRunBaseClient) signPayment(paymentHeaderB64 string) (string, error
|
||||
return "", fmt.Errorf("failed to build domain separator: %w", err)
|
||||
}
|
||||
|
||||
// Build struct hash
|
||||
amountBig, err := parseBigInt(amount)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid amount: %w", err)
|
||||
@@ -273,26 +168,22 @@ func (c *BlockRunBaseClient) signPayment(paymentHeaderB64 string) (string, error
|
||||
return "", fmt.Errorf("failed to build struct hash: %w", err)
|
||||
}
|
||||
|
||||
// EIP-712 digest
|
||||
digest := make([]byte, 0, 66)
|
||||
digest = append(digest, 0x19, 0x01)
|
||||
digest = append(digest, domainSeparator...)
|
||||
digest = append(digest, structHash...)
|
||||
hash := keccak256Bytes(digest)
|
||||
|
||||
// Sign with secp256k1
|
||||
sig, err := crypto.Sign(hash, c.privateKey)
|
||||
sig, err := crypto.Sign(hash, privateKey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to sign: %w", err)
|
||||
}
|
||||
// Adjust V: go-ethereum returns 0/1, EIP-712 expects 27/28
|
||||
if sig[64] < 27 {
|
||||
sig[64] += 27
|
||||
}
|
||||
|
||||
sigHex := "0x" + hex.EncodeToString(sig)
|
||||
|
||||
// Build x402 v2 payment payload
|
||||
paymentData := map[string]interface{}{
|
||||
"x402Version": 2,
|
||||
"resource": map[string]string{
|
||||
@@ -419,10 +310,14 @@ func hexToBytes32(s string) ([]byte, error) {
|
||||
}
|
||||
|
||||
func parseBigInt(s string) (*big.Int, error) {
|
||||
s = strings.TrimPrefix(s, "0x")
|
||||
n := new(big.Int)
|
||||
if _, ok := n.SetString(s, 16); ok {
|
||||
return n, nil
|
||||
// Only treat as hex when explicitly prefixed with 0x/0X.
|
||||
// x402 amounts are always decimal strings (e.g. "3000" = 0.003 USDC).
|
||||
if strings.HasPrefix(s, "0x") || strings.HasPrefix(s, "0X") {
|
||||
if _, ok := n.SetString(s[2:], 16); ok {
|
||||
return n, nil
|
||||
}
|
||||
return nil, fmt.Errorf("cannot parse hex big.Int from %q", s)
|
||||
}
|
||||
if _, ok := n.SetString(s, 10); ok {
|
||||
return n, nil
|
||||
@@ -445,12 +340,6 @@ func (c *BlockRunBaseClient) buildUrl() string {
|
||||
return DefaultBlockRunBaseURL + BlockRunChatEndpoint
|
||||
}
|
||||
|
||||
// buildRequest creates the HTTP request without an Authorization header.
|
||||
func (c *BlockRunBaseClient) buildRequest(url string, jsonData []byte) (*http.Request, error) {
|
||||
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fail to build request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
return req, nil
|
||||
return x402BuildRequest(url, jsonData)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
@@ -76,120 +74,34 @@ func (c *BlockRunSolClient) SetAPIKey(apiKey string, customURL string, customMod
|
||||
}
|
||||
}
|
||||
|
||||
func (c *BlockRunSolClient) setAuthHeader(reqHeaders http.Header) {
|
||||
// No Bearer token — payment is via x402 signing
|
||||
}
|
||||
func (c *BlockRunSolClient) setAuthHeader(h http.Header) { x402SetAuthHeader(h) }
|
||||
|
||||
// call overrides the base call to handle HTTP 402 x402 v2 Solana payment flow.
|
||||
func (c *BlockRunSolClient) call(systemPrompt, userPrompt string) (string, error) {
|
||||
c.logger.Infof("📡 [BlockRun Sol] Request AI Server: %s", c.BaseURL)
|
||||
|
||||
requestBody := c.hooks.buildMCPRequestBody(systemPrompt, userPrompt)
|
||||
jsonData, err := c.hooks.marshalRequestBody(requestBody)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
url := c.hooks.buildUrl()
|
||||
req, err := c.hooks.buildRequest(url, jsonData)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to send request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Handle x402 v2 Payment Required
|
||||
if resp.StatusCode == http.StatusPaymentRequired {
|
||||
paymentHeader := resp.Header.Get("X-Payment-Required")
|
||||
if paymentHeader == "" {
|
||||
return "", fmt.Errorf("received 402 but no X-Payment-Required header")
|
||||
}
|
||||
|
||||
paymentSig, err := c.signSolanaPayment(paymentHeader)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to sign Solana x402 payment: %w", err)
|
||||
}
|
||||
|
||||
req2, err := c.hooks.buildRequest(url, jsonData)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to build retry request: %w", err)
|
||||
}
|
||||
req2.Header.Set("X-Payment", paymentSig)
|
||||
|
||||
resp2, err := c.httpClient.Do(req2)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to send payment retry: %w", err)
|
||||
}
|
||||
defer resp2.Body.Close()
|
||||
|
||||
body2, err := io.ReadAll(resp2.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read payment retry response: %w", err)
|
||||
}
|
||||
if resp2.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("BlockRun Sol payment retry failed (status %d): %s", resp2.StatusCode, string(body2))
|
||||
}
|
||||
return c.hooks.parseMCPResponse(body2)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("BlockRun Sol API error (status %d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
return c.hooks.parseMCPResponse(body)
|
||||
return x402Call(c.Client, c.signSolanaPayment, "BlockRun Sol", systemPrompt, userPrompt)
|
||||
}
|
||||
|
||||
// solanaPaymentOption is an entry in the accepts[] array of the x402 v2 response.
|
||||
type solanaPaymentOption struct {
|
||||
Scheme string `json:"scheme"`
|
||||
Network string `json:"network"`
|
||||
Amount string `json:"amount"`
|
||||
Asset string `json:"asset"`
|
||||
PayTo string `json:"payTo"`
|
||||
MaxTimeoutSeconds int `json:"maxTimeoutSeconds"`
|
||||
Extra map[string]string `json:"extra"`
|
||||
func (c *BlockRunSolClient) CallWithRequestFull(req *Request) (*LLMResponse, error) {
|
||||
return x402CallFull(c.Client, c.signSolanaPayment, "BlockRun Sol", req)
|
||||
}
|
||||
|
||||
// x402v2SolanaRequired is the parsed X-Payment-Required header for Solana.
|
||||
type x402v2SolanaRequired struct {
|
||||
X402Version int `json:"x402Version"`
|
||||
Accepts []solanaPaymentOption `json:"accepts"`
|
||||
Resource *struct {
|
||||
URL string `json:"url"`
|
||||
Description string `json:"description"`
|
||||
MimeType string `json:"mimeType"`
|
||||
} `json:"resource"`
|
||||
}
|
||||
|
||||
// signSolanaPayment parses the X-Payment-Required header and builds a signed x402 v2 Solana payload.
|
||||
// signSolanaPayment parses the Payment-Required header and builds a signed x402 v2 Solana payload.
|
||||
func (c *BlockRunSolClient) signSolanaPayment(paymentHeaderB64 string) (string, error) {
|
||||
if c.keypair == nil {
|
||||
return "", fmt.Errorf("no private key set for BlockRun Sol wallet")
|
||||
}
|
||||
|
||||
// Decode base64 → JSON
|
||||
decoded, err := base64.RawStdEncoding.DecodeString(paymentHeaderB64)
|
||||
decoded, err := x402DecodeHeader(paymentHeaderB64)
|
||||
if err != nil {
|
||||
decoded, err = base64.StdEncoding.DecodeString(paymentHeaderB64)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to base64-decode payment header: %w", err)
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
|
||||
var req x402v2SolanaRequired
|
||||
var req x402v2PaymentRequired
|
||||
if err := json.Unmarshal(decoded, &req); err != nil {
|
||||
return "", fmt.Errorf("failed to parse x402 v2 Solana header: %w", err)
|
||||
}
|
||||
|
||||
// Find the Solana option
|
||||
var opt *solanaPaymentOption
|
||||
var opt *x402AcceptOption
|
||||
for i := range req.Accepts {
|
||||
if strings.HasPrefix(req.Accepts[i].Network, "solana:") {
|
||||
opt = &req.Accepts[i]
|
||||
@@ -360,12 +272,6 @@ func (c *BlockRunSolClient) buildUrl() string {
|
||||
return DefaultBlockRunSolURL + BlockRunChatEndpoint
|
||||
}
|
||||
|
||||
// buildRequest creates the HTTP request without an Authorization header.
|
||||
func (c *BlockRunSolClient) buildRequest(url string, jsonData []byte) (*http.Request, error) {
|
||||
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fail to build request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
return req, nil
|
||||
return x402BuildRequest(url, jsonData)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,19 @@
|
||||
// Package mcp — ClaudeClient implements the Anthropic Messages API.
|
||||
//
|
||||
// Wire-format differences from the OpenAI-compatible base Client:
|
||||
//
|
||||
// ┌─────────────────────┬───────────────────────────┬─────────────────────────────────┐
|
||||
// │ Concept │ OpenAI format │ Anthropic format │
|
||||
// ├─────────────────────┼───────────────────────────┼─────────────────────────────────┤
|
||||
// │ Endpoint │ /v1/chat/completions │ /v1/messages │
|
||||
// │ Auth header │ Authorization: Bearer xxx │ x-api-key: xxx │
|
||||
// │ System prompt │ messages[0] role=system │ top-level "system" field │
|
||||
// │ Tool definition │ type=function + parameters │ name + description + input_schema│
|
||||
// │ Tool choice │ "auto" (string) │ {"type":"auto"} (object) │
|
||||
// │ Assistant tool call │ tool_calls array │ content[{type:tool_use,...}] │
|
||||
// │ Tool result │ role=tool + tool_call_id │ role=user content[tool_result] │
|
||||
// │ Max tokens │ max_tokens │ max_tokens (same) │
|
||||
// └─────────────────────┴───────────────────────────┴─────────────────────────────────┘
|
||||
package mcp
|
||||
|
||||
import (
|
||||
@@ -12,75 +28,64 @@ const (
|
||||
DefaultClaudeModel = "claude-opus-4-6"
|
||||
)
|
||||
|
||||
// ClaudeClient wraps the base Client and overrides the methods that differ
|
||||
// for the Anthropic Messages API. All other behaviour (retry, timeout,
|
||||
// logging) is inherited unchanged.
|
||||
type ClaudeClient struct {
|
||||
*Client
|
||||
}
|
||||
|
||||
// NewClaudeClient creates Claude client (backward compatible)
|
||||
// NewClaudeClient creates a ClaudeClient with default settings.
|
||||
func NewClaudeClient() AIClient {
|
||||
return NewClaudeClientWithOptions()
|
||||
}
|
||||
|
||||
// NewClaudeClientWithOptions creates Claude client (supports options pattern)
|
||||
// NewClaudeClientWithOptions creates a ClaudeClient with optional overrides.
|
||||
func NewClaudeClientWithOptions(opts ...ClientOption) AIClient {
|
||||
// 1. Create Claude preset options
|
||||
claudeOpts := []ClientOption{
|
||||
baseClient := NewClient(append([]ClientOption{
|
||||
WithProvider(ProviderClaude),
|
||||
WithModel(DefaultClaudeModel),
|
||||
WithBaseURL(DefaultClaudeBaseURL),
|
||||
}
|
||||
}, opts...)...).(*Client)
|
||||
|
||||
// 2. Merge user options (user options have higher priority)
|
||||
allOpts := append(claudeOpts, opts...)
|
||||
|
||||
// 3. Create base client
|
||||
baseClient := NewClient(allOpts...).(*Client)
|
||||
|
||||
// 4. Create Claude client
|
||||
claudeClient := &ClaudeClient{
|
||||
Client: baseClient,
|
||||
}
|
||||
|
||||
// 5. Set hooks to point to ClaudeClient (implement dynamic dispatch)
|
||||
baseClient.hooks = claudeClient
|
||||
|
||||
return claudeClient
|
||||
c := &ClaudeClient{Client: baseClient}
|
||||
baseClient.hooks = c // wire dynamic dispatch to ClaudeClient
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *ClaudeClient) SetAPIKey(apiKey string, customURL string, customModel string) {
|
||||
c.APIKey = apiKey
|
||||
// ── Hook overrides ────────────────────────────────────────────────────────────
|
||||
|
||||
// SetAPIKey stores credentials and optional custom endpoint / model.
|
||||
func (c *ClaudeClient) SetAPIKey(apiKey, customURL, customModel string) {
|
||||
c.APIKey = apiKey
|
||||
if len(apiKey) > 8 {
|
||||
c.logger.Infof("🔧 [MCP] Claude API Key: %s...%s", apiKey[:4], apiKey[len(apiKey)-4:])
|
||||
}
|
||||
if customURL != "" {
|
||||
c.BaseURL = customURL
|
||||
c.logger.Infof("🔧 [MCP] Claude using custom BaseURL: %s", customURL)
|
||||
} else {
|
||||
c.logger.Infof("🔧 [MCP] Claude using default BaseURL: %s", c.BaseURL)
|
||||
c.logger.Infof("🔧 [MCP] Claude BaseURL: %s", customURL)
|
||||
}
|
||||
if customModel != "" {
|
||||
c.Model = customModel
|
||||
c.logger.Infof("🔧 [MCP] Claude using custom Model: %s", customModel)
|
||||
} else {
|
||||
c.logger.Infof("🔧 [MCP] Claude using default Model: %s", c.Model)
|
||||
c.logger.Infof("🔧 [MCP] Claude Model: %s", customModel)
|
||||
}
|
||||
}
|
||||
|
||||
// setAuthHeader Claude uses x-api-key header instead of Authorization Bearer
|
||||
func (c *ClaudeClient) setAuthHeader(reqHeaders http.Header) {
|
||||
reqHeaders.Set("x-api-key", c.APIKey)
|
||||
reqHeaders.Set("anthropic-version", "2023-06-01")
|
||||
// setAuthHeader uses x-api-key instead of Authorization: Bearer.
|
||||
func (c *ClaudeClient) setAuthHeader(h http.Header) {
|
||||
h.Set("x-api-key", c.APIKey)
|
||||
h.Set("anthropic-version", "2023-06-01")
|
||||
}
|
||||
|
||||
// buildUrl Claude uses /messages endpoint
|
||||
// buildUrl targets /messages instead of /chat/completions.
|
||||
func (c *ClaudeClient) buildUrl() string {
|
||||
return fmt.Sprintf("%s/messages", c.BaseURL)
|
||||
}
|
||||
|
||||
// buildMCPRequestBody Claude has different request format
|
||||
// buildMCPRequestBody builds the Anthropic wire format for the simple
|
||||
// CallWithMessages path (no tool support).
|
||||
func (c *ClaudeClient) buildMCPRequestBody(systemPrompt, userPrompt string) map[string]any {
|
||||
requestBody := map[string]any{
|
||||
return map[string]any{
|
||||
"model": c.Model,
|
||||
"max_tokens": c.MaxTokens,
|
||||
"system": systemPrompt,
|
||||
@@ -88,16 +93,175 @@ func (c *ClaudeClient) buildMCPRequestBody(systemPrompt, userPrompt string) map[
|
||||
{"role": "user", "content": userPrompt},
|
||||
},
|
||||
}
|
||||
|
||||
return requestBody
|
||||
}
|
||||
|
||||
// parseMCPResponse Claude has different response format
|
||||
// buildRequestBodyFromRequest converts a *Request into the Anthropic Messages
|
||||
// API wire format. This is the key override that makes tool calling work
|
||||
// correctly with Claude.
|
||||
//
|
||||
// Conversions applied:
|
||||
//
|
||||
// - System messages are lifted to the top-level "system" field.
|
||||
// - Tool definitions: parameters → input_schema, wrapper removed.
|
||||
// - Assistant messages with ToolCalls → content[{type:tool_use,...}].
|
||||
// - Tool result messages (role=tool) → role=user with tool_result blocks.
|
||||
// Consecutive tool results are merged into a single user turn (Anthropic
|
||||
// requires strictly alternating user/assistant turns).
|
||||
// - tool_choice "auto"/"any" → {"type":"auto"/"any"} object.
|
||||
func (c *ClaudeClient) buildRequestBodyFromRequest(req *Request) map[string]any {
|
||||
// ── 1. Separate system prompt from conversation messages ──────────────────
|
||||
var systemPrompt string
|
||||
var convMsgs []Message
|
||||
for _, m := range req.Messages {
|
||||
if m.Role == "system" {
|
||||
systemPrompt = m.Content
|
||||
} else {
|
||||
convMsgs = append(convMsgs, m)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 2. Convert messages to Anthropic format ───────────────────────────────
|
||||
anthropicMsgs := convertMessagesToAnthropic(convMsgs)
|
||||
|
||||
// ── 3. Convert tool definitions (parameters → input_schema) ──────────────
|
||||
var anthropicTools []map[string]any
|
||||
for _, t := range req.Tools {
|
||||
anthropicTools = append(anthropicTools, map[string]any{
|
||||
"name": t.Function.Name,
|
||||
"description": t.Function.Description,
|
||||
"input_schema": t.Function.Parameters,
|
||||
})
|
||||
}
|
||||
|
||||
// ── 4. Assemble request body ──────────────────────────────────────────────
|
||||
body := map[string]any{
|
||||
"model": req.Model,
|
||||
"max_tokens": c.MaxTokens,
|
||||
"system": systemPrompt,
|
||||
"messages": anthropicMsgs,
|
||||
}
|
||||
|
||||
if len(anthropicTools) > 0 {
|
||||
body["tools"] = anthropicTools
|
||||
}
|
||||
|
||||
// tool_choice: Anthropic uses an object, not a string.
|
||||
switch req.ToolChoice {
|
||||
case "auto":
|
||||
body["tool_choice"] = map[string]any{"type": "auto"}
|
||||
case "any":
|
||||
body["tool_choice"] = map[string]any{"type": "any"}
|
||||
case "none", "":
|
||||
// omit — no tool_choice sent
|
||||
}
|
||||
|
||||
if req.Temperature != nil {
|
||||
body["temperature"] = *req.Temperature
|
||||
}
|
||||
|
||||
return body
|
||||
}
|
||||
|
||||
// convertMessagesToAnthropic translates from the OpenAI-shaped mcp.Message
|
||||
// slice to Anthropic's messages array.
|
||||
//
|
||||
// Rules:
|
||||
// 1. role=assistant + ToolCalls → role=assistant, content=[tool_use, ...]
|
||||
// 2. role=tool (result) → role=user, content=[tool_result, ...]
|
||||
// Consecutive tool-result messages are merged into one user turn so the
|
||||
// conversation always alternates user/assistant.
|
||||
// 3. All other messages → {role, content} as-is.
|
||||
func convertMessagesToAnthropic(msgs []Message) []map[string]any {
|
||||
var out []map[string]any
|
||||
|
||||
for i := 0; i < len(msgs); {
|
||||
msg := msgs[i]
|
||||
|
||||
switch {
|
||||
// ── Assistant message carrying tool calls ─────────────────────────────
|
||||
case msg.Role == "assistant" && len(msg.ToolCalls) > 0:
|
||||
var blocks []map[string]any
|
||||
for _, tc := range msg.ToolCalls {
|
||||
// Arguments are a JSON string; Claude wants a parsed object.
|
||||
var input map[string]any
|
||||
if err := json.Unmarshal([]byte(tc.Function.Arguments), &input); err != nil {
|
||||
input = map[string]any{"_raw": tc.Function.Arguments}
|
||||
}
|
||||
blocks = append(blocks, map[string]any{
|
||||
"type": "tool_use",
|
||||
"id": tc.ID,
|
||||
"name": tc.Function.Name,
|
||||
"input": input,
|
||||
})
|
||||
}
|
||||
out = append(out, map[string]any{
|
||||
"role": "assistant",
|
||||
"content": blocks,
|
||||
})
|
||||
i++
|
||||
|
||||
// ── Tool result message(s) → single user turn ─────────────────────────
|
||||
case msg.Role == "tool":
|
||||
// Collect all consecutive tool-result messages.
|
||||
var blocks []map[string]any
|
||||
for i < len(msgs) && msgs[i].Role == "tool" {
|
||||
blocks = append(blocks, map[string]any{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": msgs[i].ToolCallID,
|
||||
"content": msgs[i].Content,
|
||||
})
|
||||
i++
|
||||
}
|
||||
out = append(out, map[string]any{
|
||||
"role": "user",
|
||||
"content": blocks,
|
||||
})
|
||||
|
||||
// ── Regular user / assistant text message ─────────────────────────────
|
||||
default:
|
||||
out = append(out, map[string]any{
|
||||
"role": msg.Role,
|
||||
"content": msg.Content,
|
||||
})
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// ── Response parsers ──────────────────────────────────────────────────────────
|
||||
|
||||
// parseMCPResponse extracts the plain-text reply from an Anthropic response.
|
||||
// Used by CallWithMessages / CallWithRequest (no tool support).
|
||||
func (c *ClaudeClient) parseMCPResponse(body []byte) (string, error) {
|
||||
var response struct {
|
||||
r, err := c.parseMCPResponseFull(body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return r.Content, nil
|
||||
}
|
||||
|
||||
// parseMCPResponseFull extracts both text and tool calls from an Anthropic
|
||||
// response envelope.
|
||||
//
|
||||
// Anthropic response shape:
|
||||
//
|
||||
// {
|
||||
// "content": [
|
||||
// {"type": "text", "text": "..."},
|
||||
// {"type": "tool_use", "id": "...", "name": "...", "input": {...}}
|
||||
// ],
|
||||
// "stop_reason": "tool_use" | "end_turn"
|
||||
// }
|
||||
func (c *ClaudeClient) parseMCPResponseFull(body []byte) (*LLMResponse, error) {
|
||||
var raw struct {
|
||||
Content []struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text,omitempty"`
|
||||
ID string `json:"id,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Input json.RawMessage `json:"input,omitempty"`
|
||||
} `json:"content"`
|
||||
Usage struct {
|
||||
InputTokens int `json:"input_tokens"`
|
||||
@@ -109,36 +273,46 @@ func (c *ClaudeClient) parseMCPResponse(body []byte) (string, error) {
|
||||
} `json:"error"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &response); err != nil {
|
||||
return "", fmt.Errorf("failed to parse Claude response: %w, body: %s", err, string(body))
|
||||
if err := json.Unmarshal(body, &raw); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse Anthropic response: %w — body: %s", err, body)
|
||||
}
|
||||
if raw.Error != nil {
|
||||
return nil, fmt.Errorf("Anthropic API error: %s — %s", raw.Error.Type, raw.Error.Message)
|
||||
}
|
||||
|
||||
if response.Error != nil {
|
||||
return "", fmt.Errorf("Claude API error: %s - %s", response.Error.Type, response.Error.Message)
|
||||
}
|
||||
|
||||
if len(response.Content) == 0 {
|
||||
return "", fmt.Errorf("Claude returned empty content, body: %s", string(body))
|
||||
}
|
||||
|
||||
// Report token usage if callback is set
|
||||
totalTokens := response.Usage.InputTokens + response.Usage.OutputTokens
|
||||
if TokenUsageCallback != nil && totalTokens > 0 {
|
||||
total := raw.Usage.InputTokens + raw.Usage.OutputTokens
|
||||
if TokenUsageCallback != nil && total > 0 {
|
||||
TokenUsageCallback(TokenUsage{
|
||||
Provider: c.Provider,
|
||||
Model: c.Model,
|
||||
PromptTokens: response.Usage.InputTokens,
|
||||
CompletionTokens: response.Usage.OutputTokens,
|
||||
TotalTokens: totalTokens,
|
||||
PromptTokens: raw.Usage.InputTokens,
|
||||
CompletionTokens: raw.Usage.OutputTokens,
|
||||
TotalTokens: total,
|
||||
})
|
||||
}
|
||||
|
||||
// Find text content
|
||||
for _, content := range response.Content {
|
||||
if content.Type == "text" {
|
||||
return content.Text, nil
|
||||
result := &LLMResponse{}
|
||||
for _, block := range raw.Content {
|
||||
switch block.Type {
|
||||
case "text":
|
||||
result.Content = block.Text
|
||||
|
||||
case "tool_use":
|
||||
// Input is a JSON object; serialise back to a JSON string so it
|
||||
// matches the ToolCallFunction.Arguments field (always a string).
|
||||
argsJSON, err := json.Marshal(block.Input)
|
||||
if err != nil {
|
||||
argsJSON = []byte("{}")
|
||||
}
|
||||
result.ToolCalls = append(result.ToolCalls, ToolCall{
|
||||
ID: block.ID,
|
||||
Type: "function",
|
||||
Function: ToolCallFunction{
|
||||
Name: block.Name,
|
||||
Arguments: string(argsJSON),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("no text content in Claude response")
|
||||
return result, nil
|
||||
}
|
||||
|
||||
248
mcp/claude_client_test.go
Normal file
248
mcp/claude_client_test.go
Normal file
@@ -0,0 +1,248 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ── buildRequestBodyFromRequest ────────────────────────────────────────────────
|
||||
|
||||
func TestClaudeClient_BuildRequestBody_SystemPromptLifted(t *testing.T) {
|
||||
c := newTestClaudeClient()
|
||||
req := &Request{
|
||||
Model: "claude-opus-4-6",
|
||||
Messages: []Message{
|
||||
{Role: "system", Content: "You are helpful."},
|
||||
{Role: "user", Content: "Hello"},
|
||||
},
|
||||
}
|
||||
body := c.buildRequestBodyFromRequest(req)
|
||||
|
||||
if body["system"] != "You are helpful." {
|
||||
t.Errorf("system not lifted to top level: %v", body["system"])
|
||||
}
|
||||
msgs := body["messages"].([]map[string]any)
|
||||
if len(msgs) != 1 || msgs[0]["role"] != "user" {
|
||||
t.Errorf("system message should be removed from messages array: %v", msgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeClient_BuildRequestBody_ToolsUseInputSchema(t *testing.T) {
|
||||
c := newTestClaudeClient()
|
||||
req := &Request{
|
||||
Model: "claude-opus-4-6",
|
||||
Messages: []Message{{Role: "user", Content: "hi"}},
|
||||
Tools: []Tool{{
|
||||
Type: "function",
|
||||
Function: FunctionDef{
|
||||
Name: "my_tool",
|
||||
Description: "does stuff",
|
||||
Parameters: map[string]any{"type": "object"},
|
||||
},
|
||||
}},
|
||||
}
|
||||
body := c.buildRequestBodyFromRequest(req)
|
||||
|
||||
tools, ok := body["tools"].([]map[string]any)
|
||||
if !ok || len(tools) != 1 {
|
||||
t.Fatalf("tools not set correctly: %v", body["tools"])
|
||||
}
|
||||
tool := tools[0]
|
||||
if tool["name"] != "my_tool" {
|
||||
t.Errorf("tool name wrong: %v", tool["name"])
|
||||
}
|
||||
if tool["input_schema"] == nil {
|
||||
t.Error("tool must use input_schema, not parameters")
|
||||
}
|
||||
if _, hasParams := tool["parameters"]; hasParams {
|
||||
t.Error("tool must NOT have parameters key (Anthropic uses input_schema)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeClient_BuildRequestBody_ToolChoiceObject(t *testing.T) {
|
||||
c := newTestClaudeClient()
|
||||
req := &Request{
|
||||
Model: "claude-opus-4-6",
|
||||
Messages: []Message{{Role: "user", Content: "hi"}},
|
||||
ToolChoice: "auto",
|
||||
}
|
||||
body := c.buildRequestBodyFromRequest(req)
|
||||
|
||||
tc, ok := body["tool_choice"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("tool_choice must be an object, got: %T %v", body["tool_choice"], body["tool_choice"])
|
||||
}
|
||||
if tc["type"] != "auto" {
|
||||
t.Errorf("tool_choice.type must be 'auto', got: %v", tc["type"])
|
||||
}
|
||||
}
|
||||
|
||||
// ── convertMessagesToAnthropic ─────────────────────────────────────────────────
|
||||
|
||||
func TestConvertMessages_AssistantToolCall(t *testing.T) {
|
||||
msgs := []Message{
|
||||
{
|
||||
Role: "assistant",
|
||||
ToolCalls: []ToolCall{{
|
||||
ID: "tc1",
|
||||
Type: "function",
|
||||
Function: ToolCallFunction{Name: "api_request", Arguments: `{"method":"GET","path":"/api/x","body":{}}`},
|
||||
}},
|
||||
},
|
||||
}
|
||||
out := convertMessagesToAnthropic(msgs)
|
||||
|
||||
if len(out) != 1 {
|
||||
t.Fatalf("expected 1 message, got %d", len(out))
|
||||
}
|
||||
msg := out[0]
|
||||
if msg["role"] != "assistant" {
|
||||
t.Errorf("role should be assistant: %v", msg["role"])
|
||||
}
|
||||
blocks := msg["content"].([]map[string]any)
|
||||
if len(blocks) != 1 || blocks[0]["type"] != "tool_use" {
|
||||
t.Errorf("content should be tool_use block: %v", blocks)
|
||||
}
|
||||
if blocks[0]["id"] != "tc1" {
|
||||
t.Errorf("tool_use id wrong: %v", blocks[0]["id"])
|
||||
}
|
||||
// Input must be parsed JSON object, not a string.
|
||||
input, ok := blocks[0]["input"].(map[string]any)
|
||||
if !ok {
|
||||
t.Errorf("tool_use input must be map, got %T", blocks[0]["input"])
|
||||
}
|
||||
if input["method"] != "GET" {
|
||||
t.Errorf("input.method wrong: %v", input)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertMessages_ToolResultMergedIntoUserTurn(t *testing.T) {
|
||||
// Anthropic requires strictly alternating turns; consecutive tool results
|
||||
// must be merged into a single user message.
|
||||
msgs := []Message{
|
||||
{Role: "tool", ToolCallID: "tc1", Content: `{"result":"a"}`},
|
||||
{Role: "tool", ToolCallID: "tc2", Content: `{"result":"b"}`},
|
||||
}
|
||||
out := convertMessagesToAnthropic(msgs)
|
||||
|
||||
if len(out) != 1 {
|
||||
t.Fatalf("consecutive tool results must be merged into one user turn, got %d messages", len(out))
|
||||
}
|
||||
if out[0]["role"] != "user" {
|
||||
t.Errorf("tool results must become role=user: %v", out[0]["role"])
|
||||
}
|
||||
blocks := out[0]["content"].([]map[string]any)
|
||||
if len(blocks) != 2 {
|
||||
t.Errorf("expected 2 tool_result blocks, got %d", len(blocks))
|
||||
}
|
||||
if blocks[0]["type"] != "tool_result" || blocks[1]["type"] != "tool_result" {
|
||||
t.Errorf("blocks should be tool_result: %v", blocks)
|
||||
}
|
||||
if blocks[0]["tool_use_id"] != "tc1" || blocks[1]["tool_use_id"] != "tc2" {
|
||||
t.Errorf("tool_use_id mismatch: %v", blocks)
|
||||
}
|
||||
}
|
||||
|
||||
// ── parseMCPResponseFull ───────────────────────────────────────────────────────
|
||||
|
||||
func TestClaudeClient_ParseResponse_TextOnly(t *testing.T) {
|
||||
c := newTestClaudeClient()
|
||||
body := []byte(`{
|
||||
"content": [{"type":"text","text":"Hello from Claude"}],
|
||||
"usage": {"input_tokens": 10, "output_tokens": 5}
|
||||
}`)
|
||||
resp, err := c.parseMCPResponseFull(body)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if resp.Content != "Hello from Claude" {
|
||||
t.Errorf("content mismatch: %q", resp.Content)
|
||||
}
|
||||
if len(resp.ToolCalls) != 0 {
|
||||
t.Errorf("expected no tool calls: %v", resp.ToolCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeClient_ParseResponse_ToolUse(t *testing.T) {
|
||||
c := newTestClaudeClient()
|
||||
body := []byte(`{
|
||||
"content": [{
|
||||
"type": "tool_use",
|
||||
"id": "toolu_01abc",
|
||||
"name": "api_request",
|
||||
"input": {"method":"POST","path":"/api/strategies","body":{"name":"BTC策略"}}
|
||||
}],
|
||||
"usage": {"input_tokens": 100, "output_tokens": 30}
|
||||
}`)
|
||||
resp, err := c.parseMCPResponseFull(body)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(resp.ToolCalls) != 1 {
|
||||
t.Fatalf("expected 1 tool call, got %d", len(resp.ToolCalls))
|
||||
}
|
||||
tc := resp.ToolCalls[0]
|
||||
if tc.ID != "toolu_01abc" {
|
||||
t.Errorf("tool call ID wrong: %v", tc.ID)
|
||||
}
|
||||
if tc.Function.Name != "api_request" {
|
||||
t.Errorf("function name wrong: %v", tc.Function.Name)
|
||||
}
|
||||
// Arguments must be a valid JSON string.
|
||||
var args map[string]any
|
||||
if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil {
|
||||
t.Errorf("arguments not valid JSON: %q — %v", tc.Function.Arguments, err)
|
||||
}
|
||||
if args["method"] != "POST" {
|
||||
t.Errorf("args.method wrong: %v", args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeClient_ParseResponse_APIError(t *testing.T) {
|
||||
c := newTestClaudeClient()
|
||||
body := []byte(`{"error":{"type":"authentication_error","message":"invalid x-api-key"}}`)
|
||||
_, err := c.parseMCPResponseFull(body)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for API error response")
|
||||
}
|
||||
if err.Error() == "" {
|
||||
t.Error("error message should not be empty")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Auth header ────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestClaudeClient_SetAuthHeader(t *testing.T) {
|
||||
c := newTestClaudeClient()
|
||||
c.APIKey = "sk-ant-test123"
|
||||
|
||||
// net/http.Header canonicalizes keys (x-api-key → X-Api-Key).
|
||||
h := make(http.Header)
|
||||
c.setAuthHeader(h)
|
||||
|
||||
if got := h.Get("x-api-key"); got != "sk-ant-test123" {
|
||||
t.Errorf("x-api-key header not set correctly: %q", got)
|
||||
}
|
||||
if h.Get("anthropic-version") == "" {
|
||||
t.Error("anthropic-version header must be set")
|
||||
}
|
||||
// Must NOT use Authorization: Bearer (that's OpenAI format).
|
||||
if h.Get("Authorization") != "" {
|
||||
t.Error("Claude must use x-api-key, not Authorization header")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeClient_BuildUrl(t *testing.T) {
|
||||
c := newTestClaudeClient()
|
||||
url := c.buildUrl()
|
||||
if url != DefaultClaudeBaseURL+"/messages" {
|
||||
t.Errorf("URL should be /messages endpoint, got: %s", url)
|
||||
}
|
||||
}
|
||||
|
||||
// ── helpers ────────────────────────────────────────────────────────────────────
|
||||
|
||||
func newTestClaudeClient() *ClaudeClient {
|
||||
return NewClaudeClientWithOptions().(*ClaudeClient)
|
||||
}
|
||||
166
mcp/claw402.go
Normal file
166
mcp/claw402.go
Normal file
@@ -0,0 +1,166 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/ethereum/go-ethereum/crypto"
|
||||
)
|
||||
|
||||
const (
|
||||
ProviderClaw402 = "claw402"
|
||||
DefaultClaw402URL = "https://claw402.ai"
|
||||
DefaultClaw402Model = "deepseek"
|
||||
)
|
||||
|
||||
// claw402ModelEndpoints maps user-friendly model names to claw402 API paths.
|
||||
var claw402ModelEndpoints = map[string]string{
|
||||
// OpenAI
|
||||
"gpt-5.4": "/api/v1/ai/openai/chat/5.4",
|
||||
"gpt-5.4-pro": "/api/v1/ai/openai/chat/5.4-pro",
|
||||
"gpt-5.3": "/api/v1/ai/openai/chat/5.3",
|
||||
"gpt-5-mini": "/api/v1/ai/openai/chat/5-mini",
|
||||
// Anthropic
|
||||
"claude-opus": "/api/v1/ai/anthropic/messages/opus",
|
||||
// DeepSeek
|
||||
"deepseek": "/api/v1/ai/deepseek/chat",
|
||||
"deepseek-reasoner": "/api/v1/ai/deepseek/chat/reasoner",
|
||||
// Qwen
|
||||
"qwen-max": "/api/v1/ai/qwen/chat/max",
|
||||
"qwen-plus": "/api/v1/ai/qwen/chat/plus",
|
||||
"qwen-turbo": "/api/v1/ai/qwen/chat/turbo",
|
||||
"qwen-flash": "/api/v1/ai/qwen/chat/flash",
|
||||
// Grok
|
||||
"grok-4.1": "/api/v1/ai/grok/chat/4.1",
|
||||
// Gemini
|
||||
"gemini-3.1-pro": "/api/v1/ai/gemini/chat/3.1-pro",
|
||||
// Kimi
|
||||
"kimi-k2.5": "/api/v1/ai/kimi/chat/k2.5",
|
||||
}
|
||||
|
||||
// Claw402Client implements AIClient using claw402.ai's x402 v2 USDC payment gateway.
|
||||
// Reuses the same EIP-712 signing as BlockRunBaseClient (same Base chain + USDC contract).
|
||||
// When the selected model routes to an Anthropic endpoint, it automatically uses
|
||||
// the Anthropic wire format for requests and responses (via an internal ClaudeClient).
|
||||
type Claw402Client struct {
|
||||
*Client
|
||||
privateKey *ecdsa.PrivateKey
|
||||
claudeProxy *ClaudeClient // non-nil when endpoint is /anthropic/
|
||||
}
|
||||
|
||||
// NewClaw402Client creates a claw402 client (backward compatible).
|
||||
func NewClaw402Client() AIClient {
|
||||
return NewClaw402ClientWithOptions()
|
||||
}
|
||||
|
||||
// NewClaw402ClientWithOptions creates a claw402 client with options.
|
||||
func NewClaw402ClientWithOptions(opts ...ClientOption) AIClient {
|
||||
baseOpts := []ClientOption{
|
||||
WithProvider(ProviderClaw402),
|
||||
WithModel(DefaultClaw402Model),
|
||||
WithBaseURL(DefaultClaw402URL),
|
||||
}
|
||||
allOpts := append(baseOpts, opts...)
|
||||
baseClient := NewClient(allOpts...).(*Client)
|
||||
baseClient.UseFullURL = true
|
||||
baseClient.BaseURL = DefaultClaw402URL + claw402ModelEndpoints[DefaultClaw402Model]
|
||||
|
||||
c := &Claw402Client{Client: baseClient}
|
||||
baseClient.hooks = c
|
||||
return c
|
||||
}
|
||||
|
||||
// SetAPIKey stores the EVM private key and selects the model endpoint.
|
||||
func (c *Claw402Client) SetAPIKey(apiKey string, _ string, customModel string) {
|
||||
hexKey := strings.TrimPrefix(apiKey, "0x")
|
||||
privKey, err := crypto.HexToECDSA(hexKey)
|
||||
if err != nil {
|
||||
c.logger.Warnf("⚠️ [MCP] Claw402: invalid private key: %v", err)
|
||||
} else {
|
||||
c.privateKey = privKey
|
||||
c.APIKey = apiKey
|
||||
addr := crypto.PubkeyToAddress(privKey.PublicKey).Hex()
|
||||
c.logger.Infof("🔧 [MCP] Claw402 wallet: %s", addr)
|
||||
}
|
||||
if customModel != "" {
|
||||
c.Model = customModel
|
||||
}
|
||||
endpoint := c.resolveEndpoint()
|
||||
c.BaseURL = DefaultClaw402URL + endpoint
|
||||
|
||||
// Anthropic endpoints need different wire format (Messages API)
|
||||
if strings.Contains(endpoint, "/anthropic/") {
|
||||
c.claudeProxy = &ClaudeClient{Client: c.Client}
|
||||
c.logger.Infof("🔧 [MCP] Claw402 model: %s → %s (Anthropic format)", c.Model, endpoint)
|
||||
} else {
|
||||
c.claudeProxy = nil
|
||||
c.logger.Infof("🔧 [MCP] Claw402 model: %s → %s", c.Model, endpoint)
|
||||
}
|
||||
}
|
||||
|
||||
// resolveEndpoint returns the API path for the configured model.
|
||||
func (c *Claw402Client) resolveEndpoint() string {
|
||||
if ep, ok := claw402ModelEndpoints[c.Model]; ok {
|
||||
return ep
|
||||
}
|
||||
// Allow raw path override (e.g. "/api/v1/ai/openai/chat/5.4")
|
||||
if strings.HasPrefix(c.Model, "/api/") {
|
||||
return c.Model
|
||||
}
|
||||
return claw402ModelEndpoints[DefaultClaw402Model]
|
||||
}
|
||||
|
||||
func (c *Claw402Client) setAuthHeader(h http.Header) { x402SetAuthHeader(h) }
|
||||
|
||||
func (c *Claw402Client) call(systemPrompt, userPrompt string) (string, error) {
|
||||
return x402Call(c.Client, c.signPayment, "Claw402", systemPrompt, userPrompt)
|
||||
}
|
||||
|
||||
func (c *Claw402Client) CallWithRequestFull(req *Request) (*LLMResponse, error) {
|
||||
return x402CallFull(c.Client, c.signPayment, "Claw402", req)
|
||||
}
|
||||
|
||||
// signPayment signs x402 v2 EIP-712 payment (same Base chain + USDC as BlockRunBase).
|
||||
func (c *Claw402Client) signPayment(paymentHeaderB64 string) (string, error) {
|
||||
return signBasePaymentHeader(c.privateKey, paymentHeaderB64, "Claw402")
|
||||
}
|
||||
|
||||
// ── Format overrides for Anthropic endpoints ─────────────────────────────────
|
||||
|
||||
func (c *Claw402Client) buildMCPRequestBody(systemPrompt, userPrompt string) map[string]any {
|
||||
if c.claudeProxy != nil {
|
||||
return c.claudeProxy.buildMCPRequestBody(systemPrompt, userPrompt)
|
||||
}
|
||||
return c.Client.buildMCPRequestBody(systemPrompt, userPrompt)
|
||||
}
|
||||
|
||||
func (c *Claw402Client) buildRequestBodyFromRequest(req *Request) map[string]any {
|
||||
if c.claudeProxy != nil {
|
||||
return c.claudeProxy.buildRequestBodyFromRequest(req)
|
||||
}
|
||||
return c.Client.buildRequestBodyFromRequest(req)
|
||||
}
|
||||
|
||||
func (c *Claw402Client) parseMCPResponse(body []byte) (string, error) {
|
||||
if c.claudeProxy != nil {
|
||||
return c.claudeProxy.parseMCPResponse(body)
|
||||
}
|
||||
return c.Client.parseMCPResponse(body)
|
||||
}
|
||||
|
||||
func (c *Claw402Client) parseMCPResponseFull(body []byte) (*LLMResponse, error) {
|
||||
if c.claudeProxy != nil {
|
||||
return c.claudeProxy.parseMCPResponseFull(body)
|
||||
}
|
||||
return c.Client.parseMCPResponseFull(body)
|
||||
}
|
||||
|
||||
// buildUrl returns the full claw402 endpoint URL.
|
||||
func (c *Claw402Client) buildUrl() string {
|
||||
return c.BaseURL
|
||||
}
|
||||
|
||||
func (c *Claw402Client) buildRequest(url string, jsonData []byte) (*http.Request, error) {
|
||||
return x402BuildRequest(url, jsonData)
|
||||
}
|
||||
242
mcp/client.go
242
mcp/client.go
@@ -1,7 +1,9 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -232,10 +234,21 @@ func (client *Client) marshalRequestBody(requestBody map[string]any) ([]byte, er
|
||||
}
|
||||
|
||||
func (client *Client) parseMCPResponse(body []byte) (string, error) {
|
||||
r, err := client.parseMCPResponseFull(body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return r.Content, nil
|
||||
}
|
||||
|
||||
// parseMCPResponseFull parses the OpenAI-format response body and returns both
|
||||
// the text content and any tool calls.
|
||||
func (client *Client) parseMCPResponseFull(body []byte) (*LLMResponse, error) {
|
||||
var result struct {
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
Content string `json:"content"`
|
||||
ToolCalls []ToolCall `json:"tool_calls"`
|
||||
} `json:"message"`
|
||||
} `json:"choices"`
|
||||
Usage struct {
|
||||
@@ -246,11 +259,11 @@ func (client *Client) parseMCPResponse(body []byte) (string, error) {
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return "", fmt.Errorf("failed to parse response: %w", err)
|
||||
return nil, fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
if len(result.Choices) == 0 {
|
||||
return "", fmt.Errorf("API returned empty response")
|
||||
return nil, fmt.Errorf("API returned empty response")
|
||||
}
|
||||
|
||||
// Report token usage if callback is set
|
||||
@@ -264,7 +277,11 @@ func (client *Client) parseMCPResponse(body []byte) (string, error) {
|
||||
})
|
||||
}
|
||||
|
||||
return result.Choices[0].Message.Content, nil
|
||||
msg := result.Choices[0].Message
|
||||
return &LLMResponse{
|
||||
Content: msg.Content,
|
||||
ToolCalls: msg.ToolCalls,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (client *Client) buildUrl() string {
|
||||
@@ -425,50 +442,106 @@ func (client *Client) CallWithRequest(req *Request) (string, error) {
|
||||
return "", fmt.Errorf("still failed after %d retries: %w", maxRetries, lastErr)
|
||||
}
|
||||
|
||||
// CallWithRequestFull calls the AI API and returns both text content and tool calls.
|
||||
func (client *Client) CallWithRequestFull(req *Request) (*LLMResponse, error) {
|
||||
if client.APIKey == "" {
|
||||
return nil, fmt.Errorf("AI API key not set, please call SetAPIKey first")
|
||||
}
|
||||
if req.Model == "" {
|
||||
req.Model = client.Model
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
maxRetries := client.config.MaxRetries
|
||||
for attempt := 1; attempt <= maxRetries; attempt++ {
|
||||
if attempt > 1 {
|
||||
client.logger.Warnf("⚠️ AI API call failed, retrying (%d/%d)...", attempt, maxRetries)
|
||||
}
|
||||
result, err := client.callWithRequestFull(req)
|
||||
if err == nil {
|
||||
return result, nil
|
||||
}
|
||||
lastErr = err
|
||||
if !client.hooks.isRetryableError(err) {
|
||||
return nil, err
|
||||
}
|
||||
if attempt < maxRetries {
|
||||
waitTime := client.config.RetryWaitBase * time.Duration(attempt)
|
||||
time.Sleep(waitTime)
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("still failed after %d retries: %w", maxRetries, lastErr)
|
||||
}
|
||||
|
||||
// callWithRequestFull single call that returns LLMResponse (content + tool calls).
|
||||
func (client *Client) callWithRequestFull(req *Request) (*LLMResponse, error) {
|
||||
client.logger.Infof("📡 [%s] Request AI Server (full): BaseURL: %s", client.String(), client.BaseURL)
|
||||
|
||||
requestBody := client.hooks.buildRequestBodyFromRequest(req)
|
||||
jsonData, err := client.hooks.marshalRequestBody(requestBody)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
url := client.hooks.buildUrl()
|
||||
httpReq, err := client.hooks.buildRequest(url, jsonData)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := client.httpClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to send request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("API returned error (status %d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
return client.hooks.parseMCPResponseFull(body)
|
||||
}
|
||||
|
||||
// callWithRequest single AI API call (using Request object)
|
||||
func (client *Client) callWithRequest(req *Request) (string, error) {
|
||||
// Print current AI configuration
|
||||
client.logger.Infof("📡 [%s] Request AI Server with Builder: BaseURL: %s", client.String(), client.BaseURL)
|
||||
client.logger.Debugf("[%s] Messages count: %d", client.String(), len(req.Messages))
|
||||
|
||||
// Build request body (from Request object)
|
||||
requestBody := client.buildRequestBodyFromRequest(req)
|
||||
requestBody := client.hooks.buildRequestBodyFromRequest(req)
|
||||
|
||||
// Serialize request body
|
||||
jsonData, err := client.hooks.marshalRequestBody(requestBody)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Build URL
|
||||
url := client.hooks.buildUrl()
|
||||
client.logger.Infof("📡 [MCP %s] Request URL: %s", client.String(), url)
|
||||
|
||||
// Create HTTP request
|
||||
httpReq, err := client.hooks.buildRequest(url, jsonData)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
// Send HTTP request
|
||||
resp, err := client.httpClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to send request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Read response body
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
// Check HTTP status code
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("API returned error (status %d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
// Parse response
|
||||
result, err := client.hooks.parseMCPResponse(body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("fail to parse AI server response: %w", err)
|
||||
@@ -479,13 +552,23 @@ func (client *Client) callWithRequest(req *Request) (string, error) {
|
||||
|
||||
// buildRequestBodyFromRequest builds request body from Request object
|
||||
func (client *Client) buildRequestBodyFromRequest(req *Request) map[string]any {
|
||||
// Convert Message to API format
|
||||
messages := make([]map[string]string, 0, len(req.Messages))
|
||||
// Convert Message to API format — must use map[string]any to support
|
||||
// tool-call messages (tool_calls, tool_call_id fields).
|
||||
messages := make([]map[string]any, 0, len(req.Messages))
|
||||
for _, msg := range req.Messages {
|
||||
messages = append(messages, map[string]string{
|
||||
"role": msg.Role,
|
||||
"content": msg.Content,
|
||||
})
|
||||
m := map[string]any{"role": msg.Role}
|
||||
if len(msg.ToolCalls) > 0 {
|
||||
// Assistant message that contains tool invocations.
|
||||
// content must be null/omitted for OpenAI compatibility.
|
||||
m["tool_calls"] = msg.ToolCalls
|
||||
} else if msg.ToolCallID != "" {
|
||||
// Tool result message (role="tool").
|
||||
m["tool_call_id"] = msg.ToolCallID
|
||||
m["content"] = msg.Content
|
||||
} else {
|
||||
m["content"] = msg.Content
|
||||
}
|
||||
messages = append(messages, m)
|
||||
}
|
||||
|
||||
// Build basic request body
|
||||
@@ -544,3 +627,124 @@ func (client *Client) buildRequestBodyFromRequest(req *Request) map[string]any {
|
||||
|
||||
return requestBody
|
||||
}
|
||||
|
||||
// CallWithRequestStream streams the LLM response via SSE (Server-Sent Events).
|
||||
// onChunk is called with the full accumulated text so far after each received chunk.
|
||||
// Returns the complete final text when the stream ends.
|
||||
//
|
||||
// Idle timeout: if no chunk arrives for 30 seconds the stream is cancelled automatically.
|
||||
// This prevents the scanner from blocking indefinitely on a hung or stalled connection.
|
||||
func (client *Client) CallWithRequestStream(req *Request, onChunk func(string)) (string, error) {
|
||||
if client.APIKey == "" {
|
||||
return "", fmt.Errorf("AI API key not set")
|
||||
}
|
||||
if req.Model == "" {
|
||||
req.Model = client.Model
|
||||
}
|
||||
req.Stream = true
|
||||
|
||||
requestBody := client.hooks.buildRequestBodyFromRequest(req)
|
||||
jsonData, err := client.hooks.marshalRequestBody(requestBody)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
url := client.hooks.buildUrl()
|
||||
httpReq, err := client.hooks.buildRequest(url, jsonData)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Idle-timeout watchdog: cancel the request if no SSE line arrives for 30 seconds.
|
||||
// This breaks the scanner out of an indefinitely blocking Read on a hung connection.
|
||||
const idleTimeout = 60 * time.Second
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
resetCh := make(chan struct{}, 1)
|
||||
go func() {
|
||||
t := time.NewTimer(idleTimeout)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
cancel() // idle timeout: kill the connection
|
||||
return
|
||||
case <-resetCh:
|
||||
// received a line — reset the idle timer
|
||||
if !t.Stop() {
|
||||
select {
|
||||
case <-t.C:
|
||||
default:
|
||||
}
|
||||
}
|
||||
t.Reset(idleTimeout)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
httpReq = httpReq.WithContext(ctx)
|
||||
resp, err := client.httpClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("streaming request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return "", fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var accumulated strings.Builder
|
||||
scanner := bufio.NewScanner(resp.Body)
|
||||
|
||||
for scanner.Scan() {
|
||||
// Ping the watchdog: we received a line, reset the idle timer.
|
||||
select {
|
||||
case resetCh <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
|
||||
line := scanner.Text()
|
||||
if !strings.HasPrefix(line, "data: ") {
|
||||
continue
|
||||
}
|
||||
data := strings.TrimPrefix(line, "data: ")
|
||||
if data == "[DONE]" {
|
||||
break
|
||||
}
|
||||
|
||||
// Parse the SSE JSON chunk
|
||||
var chunk struct {
|
||||
Choices []struct {
|
||||
Delta struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"delta"`
|
||||
FinishReason *string `json:"finish_reason"`
|
||||
} `json:"choices"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
|
||||
continue // skip malformed chunks
|
||||
}
|
||||
if len(chunk.Choices) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
delta := chunk.Choices[0].Delta.Content
|
||||
if delta == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
accumulated.WriteString(delta)
|
||||
if onChunk != nil {
|
||||
onChunk(accumulated.String())
|
||||
}
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return accumulated.String(), fmt.Errorf("stream interrupted: %w", err)
|
||||
}
|
||||
|
||||
return accumulated.String(), nil
|
||||
}
|
||||
|
||||
@@ -10,21 +10,52 @@ type AIClient interface {
|
||||
SetAPIKey(apiKey string, customURL string, customModel string)
|
||||
SetTimeout(timeout time.Duration)
|
||||
CallWithMessages(systemPrompt, userPrompt string) (string, error)
|
||||
CallWithRequest(req *Request) (string, error) // Builder pattern API (supports advanced features)
|
||||
CallWithRequest(req *Request) (string, error)
|
||||
// CallWithRequestStream streams the LLM response via SSE.
|
||||
// onChunk is called with the full accumulated text so far (not raw deltas).
|
||||
// Returns the complete final text when done.
|
||||
CallWithRequestStream(req *Request, onChunk func(string)) (string, error)
|
||||
// CallWithRequestFull returns both text content and tool calls.
|
||||
// Use this when the request includes Tools — the LLM may respond with
|
||||
// either a plain text reply (LLMResponse.Content) or tool invocations
|
||||
// (LLMResponse.ToolCalls), but not both.
|
||||
CallWithRequestFull(req *Request) (*LLMResponse, error)
|
||||
}
|
||||
|
||||
// clientHooks internal hook interface (for subclass to override specific steps)
|
||||
// These methods are only used inside the package to implement dynamic dispatch
|
||||
// clientHooks is the internal dispatch interface used to implement per-provider
|
||||
// polymorphism without Go's lack of virtual methods.
|
||||
//
|
||||
// Each method can be overridden by an embedding struct (e.g. ClaudeClient).
|
||||
// The base *Client provides OpenAI-compatible defaults; providers with a
|
||||
// different wire format (Anthropic, Gemini native, etc.) override only what
|
||||
// differs. All call-path methods in client.go invoke these via c.hooks so
|
||||
// that the override is always picked up at runtime.
|
||||
type clientHooks interface {
|
||||
// Hook methods that can be overridden by subclass
|
||||
|
||||
// ── Simple CallWithMessages path ────────────────────────────────────────
|
||||
call(systemPrompt, userPrompt string) (string, error)
|
||||
|
||||
buildMCPRequestBody(systemPrompt, userPrompt string) map[string]any
|
||||
|
||||
// ── Shared request plumbing ─────────────────────────────────────────────
|
||||
buildUrl() string
|
||||
buildRequest(url string, jsonData []byte) (*http.Request, error)
|
||||
setAuthHeader(reqHeaders http.Header)
|
||||
marshalRequestBody(requestBody map[string]any) ([]byte, error)
|
||||
|
||||
// ── Advanced (Request-object) path ──────────────────────────────────────
|
||||
// buildRequestBodyFromRequest converts a *Request into the provider's
|
||||
// native wire-format map. Providers that use a different protocol (e.g.
|
||||
// Anthropic uses "input_schema" for tools, "tool_use" content blocks, and
|
||||
// a top-level "system" field) override this method.
|
||||
buildRequestBodyFromRequest(req *Request) map[string]any
|
||||
|
||||
// parseMCPResponse extracts the plain-text reply from a non-streaming
|
||||
// response body.
|
||||
parseMCPResponse(body []byte) (string, error)
|
||||
|
||||
// parseMCPResponseFull extracts both text and tool calls. Providers whose
|
||||
// response envelope differs from the OpenAI choices[] structure (e.g.
|
||||
// Anthropic content[] with tool_use blocks) override this method.
|
||||
parseMCPResponseFull(body []byte) (*LLMResponse, error)
|
||||
|
||||
isRetryableError(err error) bool
|
||||
}
|
||||
|
||||
@@ -1,9 +1,34 @@
|
||||
package mcp
|
||||
|
||||
// Message represents a conversation message
|
||||
// Message represents a conversation message.
|
||||
// Supports plain messages (Role+Content), assistant tool-call messages (ToolCalls),
|
||||
// and tool result messages (Role="tool", ToolCallID, Content).
|
||||
type Message struct {
|
||||
Role string `json:"role"` // "system", "user", "assistant"
|
||||
Content string `json:"content"` // Message content
|
||||
Role string `json:"role"` // "system", "user", "assistant", "tool"
|
||||
Content string `json:"content,omitempty"` // Text content (omitted when ToolCalls present)
|
||||
ToolCalls []ToolCall `json:"tool_calls,omitempty"` // Set by assistant when calling tools
|
||||
ToolCallID string `json:"tool_call_id,omitempty"` // Set on role="tool" result messages
|
||||
}
|
||||
|
||||
// ToolCall is a single function call requested by the LLM.
|
||||
type ToolCall struct {
|
||||
ID string `json:"id"` // Unique call ID (e.g. "call_abc123")
|
||||
Type string `json:"type"` // Always "function"
|
||||
Function ToolCallFunction `json:"function"` // Function name and JSON-serialised arguments
|
||||
}
|
||||
|
||||
// ToolCallFunction holds the function name and raw JSON arguments string.
|
||||
type ToolCallFunction struct {
|
||||
Name string `json:"name"` // Function name
|
||||
Arguments string `json:"arguments"` // JSON-encoded argument object
|
||||
}
|
||||
|
||||
// LLMResponse is returned by CallWithRequestFull and carries both the assistant
|
||||
// text reply (Content) and any structured tool calls (ToolCalls).
|
||||
// Exactly one of the two fields will be non-empty for a well-formed response.
|
||||
type LLMResponse struct {
|
||||
Content string // Plain-text reply (final answer)
|
||||
ToolCalls []ToolCall // Structured tool invocations
|
||||
}
|
||||
|
||||
// Tool represents a tool/function that AI can call
|
||||
|
||||
219
mcp/x402.go
Normal file
219
mcp/x402.go
Normal file
@@ -0,0 +1,219 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/ecdsa"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/ethereum/go-ethereum/crypto"
|
||||
)
|
||||
|
||||
// ── Shared x402 types ────────────────────────────────────────────────────────
|
||||
|
||||
// x402v2PaymentRequired is the structure of the Payment-Required header (x402 v2).
|
||||
type x402v2PaymentRequired struct {
|
||||
X402Version int `json:"x402Version"`
|
||||
Accepts []x402AcceptOption `json:"accepts"`
|
||||
Resource *x402Resource `json:"resource"`
|
||||
}
|
||||
|
||||
// x402AcceptOption is a payment option from the x402 v2 header.
|
||||
type x402AcceptOption struct {
|
||||
Scheme string `json:"scheme"`
|
||||
Network string `json:"network"`
|
||||
Amount string `json:"amount"`
|
||||
Asset string `json:"asset"`
|
||||
PayTo string `json:"payTo"`
|
||||
MaxTimeoutSeconds int `json:"maxTimeoutSeconds"`
|
||||
Extra map[string]string `json:"extra"`
|
||||
}
|
||||
|
||||
// x402Resource describes the resource being paid for.
|
||||
type x402Resource struct {
|
||||
URL string `json:"url"`
|
||||
Description string `json:"description"`
|
||||
MimeType string `json:"mimeType"`
|
||||
}
|
||||
|
||||
// x402SignFunc is a callback that signs an x402 payment header and returns the
|
||||
// base64-encoded payment signature.
|
||||
type x402SignFunc func(paymentHeaderB64 string) (string, error)
|
||||
|
||||
// ── Shared x402 helpers ──────────────────────────────────────────────────────
|
||||
|
||||
// x402DecodeHeader decodes a base64-encoded x402 Payment-Required header,
|
||||
// trying RawStdEncoding first then StdEncoding as fallback.
|
||||
func x402DecodeHeader(b64 string) ([]byte, error) {
|
||||
decoded, err := base64.RawStdEncoding.DecodeString(b64)
|
||||
if err != nil {
|
||||
decoded, err = base64.StdEncoding.DecodeString(b64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to base64-decode payment header: %w", err)
|
||||
}
|
||||
}
|
||||
return decoded, nil
|
||||
}
|
||||
|
||||
// signBasePaymentHeader decodes a base64 x402 header, parses it, and signs with
|
||||
// EIP-712 (USDC TransferWithAuthorization). Shared by BlockRunBase and Claw402.
|
||||
func signBasePaymentHeader(privateKey *ecdsa.PrivateKey, paymentHeaderB64 string, providerName string) (string, error) {
|
||||
if privateKey == nil {
|
||||
return "", fmt.Errorf("no private key set for %s wallet", providerName)
|
||||
}
|
||||
|
||||
decoded, err := x402DecodeHeader(paymentHeaderB64)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var req x402v2PaymentRequired
|
||||
if err := json.Unmarshal(decoded, &req); err != nil {
|
||||
return "", fmt.Errorf("failed to parse x402 v2 payment header: %w", err)
|
||||
}
|
||||
if len(req.Accepts) == 0 {
|
||||
return "", fmt.Errorf("no payment options in x402 response")
|
||||
}
|
||||
|
||||
senderAddr := crypto.PubkeyToAddress(privateKey.PublicKey).Hex()
|
||||
return signX402Payment(privateKey, senderAddr, req.Accepts[0], req.Resource)
|
||||
}
|
||||
|
||||
// doX402Request executes an HTTP request and handles the x402 v2 payment flow.
|
||||
// On a 402 response it reads the Payment-Required (or X-Payment-Required) header,
|
||||
// signs via signFn, retries with Payment-Signature, and logs the Payment-Response
|
||||
// header (tx hash) on success.
|
||||
func doX402Request(
|
||||
httpClient *http.Client,
|
||||
buildReqFn func() (*http.Request, error),
|
||||
signFn x402SignFunc,
|
||||
providerTag string,
|
||||
logger Logger,
|
||||
) ([]byte, error) {
|
||||
req, err := buildReqFn()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to send request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusPaymentRequired {
|
||||
paymentHeader := resp.Header.Get("Payment-Required")
|
||||
if paymentHeader == "" {
|
||||
paymentHeader = resp.Header.Get("X-Payment-Required")
|
||||
}
|
||||
if paymentHeader == "" {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("received 402 but no Payment-Required header found. Body: %s", string(body))
|
||||
}
|
||||
|
||||
// Drain 402 body to allow HTTP connection reuse.
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
|
||||
paymentSig, err := signFn(paymentHeader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to sign x402 payment: %w", err)
|
||||
}
|
||||
|
||||
req2, err := buildReqFn()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to build retry request: %w", err)
|
||||
}
|
||||
req2.Header.Set("X-Payment", paymentSig)
|
||||
req2.Header.Set("Payment-Signature", paymentSig)
|
||||
|
||||
resp2, err := httpClient.Do(req2)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to send payment retry: %w", err)
|
||||
}
|
||||
defer resp2.Body.Close()
|
||||
|
||||
body2, err := io.ReadAll(resp2.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read payment retry response: %w", err)
|
||||
}
|
||||
if resp2.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("%s payment retry failed (status %d): %s", providerTag, resp2.StatusCode, string(body2))
|
||||
}
|
||||
|
||||
if txHash := resp2.Header.Get("Payment-Response"); txHash != "" {
|
||||
logger.Infof("💰 [%s] Payment tx: %s", providerTag, txHash)
|
||||
}
|
||||
|
||||
return body2, nil
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("%s API error (status %d): %s", providerTag, resp.StatusCode, string(body))
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// x402BuildRequest creates a POST request with Content-Type but no auth header.
|
||||
func x402BuildRequest(url string, jsonData []byte) (*http.Request, error) {
|
||||
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fail to build request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// x402SetAuthHeader is a no-op — x402 providers authenticate via payment signing.
|
||||
func x402SetAuthHeader(_ http.Header) {}
|
||||
|
||||
// x402Call handles the x402 payment flow for the simple CallWithMessages path.
|
||||
func x402Call(c *Client, signFn x402SignFunc, tag string, systemPrompt, userPrompt string) (string, error) {
|
||||
c.logger.Infof("📡 [%s] Request AI Server: %s", tag, c.BaseURL)
|
||||
|
||||
requestBody := c.hooks.buildMCPRequestBody(systemPrompt, userPrompt)
|
||||
jsonData, err := c.hooks.marshalRequestBody(requestBody)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
body, err := doX402Request(c.httpClient, func() (*http.Request, error) {
|
||||
return c.hooks.buildRequest(c.hooks.buildUrl(), jsonData)
|
||||
}, signFn, tag, c.logger)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return c.hooks.parseMCPResponse(body)
|
||||
}
|
||||
|
||||
// x402CallFull handles the x402 payment flow for the advanced Request path.
|
||||
func x402CallFull(c *Client, signFn x402SignFunc, tag string, req *Request) (*LLMResponse, error) {
|
||||
if c.APIKey == "" {
|
||||
return nil, fmt.Errorf("AI API key not set, please call SetAPIKey first")
|
||||
}
|
||||
if req.Model == "" {
|
||||
req.Model = c.Model
|
||||
}
|
||||
|
||||
c.logger.Infof("📡 [%s] Request AI (full): %s", tag, c.BaseURL)
|
||||
|
||||
requestBody := c.hooks.buildRequestBodyFromRequest(req)
|
||||
jsonData, err := c.hooks.marshalRequestBody(requestBody)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
body, err := doX402Request(c.httpClient, func() (*http.Request, error) {
|
||||
return c.hooks.buildRequest(c.hooks.buildUrl(), jsonData)
|
||||
}, signFn, tag, c.logger)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c.hooks.parseMCPResponseFull(body)
|
||||
}
|
||||
@@ -15,11 +15,18 @@ server {
|
||||
gzip_min_length 1024;
|
||||
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/javascript application/json;
|
||||
|
||||
# index.html — never cache (so new deploys take effect immediately)
|
||||
location = /index.html {
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
add_header Pragma "no-cache";
|
||||
add_header Expires 0;
|
||||
}
|
||||
|
||||
# Frontend routes (SPA) with static asset caching
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
|
||||
# Cache static assets
|
||||
# Cache hashed static assets (js/css have content hashes in filenames)
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
|
||||
183
start.sh
183
start.sh
@@ -1,7 +1,7 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# NOFX AI Trading System - Docker Quick Start Script
|
||||
# NOFX AI Trading System - Docker Management Script
|
||||
# Usage: ./start.sh [command]
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -45,10 +45,10 @@ detect_compose_cmd() {
|
||||
elif command -v docker-compose &> /dev/null; then
|
||||
COMPOSE_CMD="docker-compose"
|
||||
else
|
||||
print_error "Docker Compose 未安装!请先安装 Docker Compose"
|
||||
print_error "Docker Compose not found. Please install Docker Compose first."
|
||||
exit 1
|
||||
fi
|
||||
print_info "使用 Docker Compose 命令: $COMPOSE_CMD"
|
||||
print_info "Using Docker Compose: $COMPOSE_CMD"
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------------
|
||||
@@ -56,12 +56,12 @@ detect_compose_cmd() {
|
||||
# ------------------------------------------------------------------------
|
||||
check_docker() {
|
||||
if ! command -v docker &> /dev/null; then
|
||||
print_error "Docker 未安装!请先安装 Docker: https://docs.docker.com/get-docker/"
|
||||
print_error "Docker not found. Please install Docker: https://docs.docker.com/get-docker/"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
detect_compose_cmd
|
||||
print_success "Docker 和 Docker Compose 已安装"
|
||||
print_success "Docker and Docker Compose are installed"
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------------
|
||||
@@ -69,11 +69,11 @@ check_docker() {
|
||||
# ------------------------------------------------------------------------
|
||||
check_env() {
|
||||
if [ ! -f ".env" ]; then
|
||||
print_warning ".env 不存在,从模板复制..."
|
||||
print_warning ".env not found, copying from template..."
|
||||
cp .env.example .env
|
||||
print_info "已创建 .env 文件"
|
||||
print_info ".env file created"
|
||||
fi
|
||||
print_success "环境变量文件存在"
|
||||
print_success "Environment file exists"
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------------
|
||||
@@ -83,15 +83,15 @@ is_env_configured() {
|
||||
local var_name="$1"
|
||||
local value=$(grep "^${var_name}=" .env 2>/dev/null | cut -d'=' -f2-)
|
||||
|
||||
# 去除引号
|
||||
# Strip quotes
|
||||
value=$(echo "$value" | tr -d '"'"'")
|
||||
|
||||
# 检查是否为空或占位符
|
||||
# Check empty
|
||||
if [ -z "$value" ]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
# 检查是否是示例值
|
||||
# Check placeholder values
|
||||
case "$value" in
|
||||
*your-*|*YOUR_*|*change-this*|*CHANGE_THIS*|*example*|*EXAMPLE*)
|
||||
return 1
|
||||
@@ -102,22 +102,23 @@ is_env_configured() {
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------------
|
||||
# Helper: Generate and set env var in .env file
|
||||
# Helper: Set env var in .env file
|
||||
# ------------------------------------------------------------------------
|
||||
set_env_var() {
|
||||
local var_name="$1"
|
||||
local var_value="$2"
|
||||
|
||||
# 如果变量已存在(即使是占位符),替换它
|
||||
if grep -q "^${var_name}=" .env 2>/dev/null; then
|
||||
# macOS 和 Linux 兼容的 sed
|
||||
if [[ "$OSTYPE" == "darwin"* ]]; then
|
||||
sed -i '' "s|^${var_name}=.*|${var_name}=${var_value}|" .env
|
||||
else
|
||||
sed -i "s|^${var_name}=.*|${var_name}=${var_value}|" .env
|
||||
fi
|
||||
else
|
||||
# 变量不存在,追加
|
||||
# Ensure .env ends with a newline before appending
|
||||
if [ -s ".env" ] && [ "$(tail -c1 .env | wc -l)" -eq 0 ]; then
|
||||
echo "" >> .env
|
||||
fi
|
||||
echo "${var_name}=${var_value}" >> .env
|
||||
fi
|
||||
}
|
||||
@@ -126,51 +127,46 @@ set_env_var() {
|
||||
# Validation: Encryption Keys in .env
|
||||
# ------------------------------------------------------------------------
|
||||
check_encryption() {
|
||||
print_info "检查加密密钥配置..."
|
||||
print_info "Checking encryption keys..."
|
||||
|
||||
local generated=false
|
||||
|
||||
# 检查并生成 JWT_SECRET
|
||||
if ! is_env_configured "JWT_SECRET"; then
|
||||
print_warning "JWT_SECRET 未配置,正在生成..."
|
||||
print_warning "JWT_SECRET not set, generating..."
|
||||
local jwt_secret=$(openssl rand -base64 32)
|
||||
set_env_var "JWT_SECRET" "$jwt_secret"
|
||||
print_success "JWT_SECRET 已生成"
|
||||
print_success "JWT_SECRET generated"
|
||||
generated=true
|
||||
fi
|
||||
|
||||
# 检查并生成 DATA_ENCRYPTION_KEY
|
||||
if ! is_env_configured "DATA_ENCRYPTION_KEY"; then
|
||||
print_warning "DATA_ENCRYPTION_KEY 未配置,正在生成..."
|
||||
print_warning "DATA_ENCRYPTION_KEY not set, generating..."
|
||||
local data_key=$(openssl rand -base64 32)
|
||||
set_env_var "DATA_ENCRYPTION_KEY" "$data_key"
|
||||
print_success "DATA_ENCRYPTION_KEY 已生成"
|
||||
print_success "DATA_ENCRYPTION_KEY generated"
|
||||
generated=true
|
||||
fi
|
||||
|
||||
# 检查并生成 RSA_PRIVATE_KEY
|
||||
if ! is_env_configured "RSA_PRIVATE_KEY"; then
|
||||
print_warning "RSA_PRIVATE_KEY 未配置,正在生成..."
|
||||
# 生成 RSA 密钥并转换为单行格式(\n 替换为 \\n)
|
||||
print_warning "RSA_PRIVATE_KEY not set, generating..."
|
||||
local rsa_key=$(openssl genrsa 2048 2>/dev/null | awk '{printf "%s\\n", $0}')
|
||||
set_env_var "RSA_PRIVATE_KEY" "\"$rsa_key\""
|
||||
print_success "RSA_PRIVATE_KEY 已生成"
|
||||
print_success "RSA_PRIVATE_KEY generated"
|
||||
generated=true
|
||||
fi
|
||||
|
||||
if [ "$generated" = true ]; then
|
||||
echo ""
|
||||
print_success "所有缺失的密钥已自动生成并保存到 .env"
|
||||
print_warning "请妥善保管 .env 文件,不要提交到版本控制系统"
|
||||
print_success "Missing keys generated and saved to .env"
|
||||
print_warning "Keep .env safe — do not commit it to version control"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
print_success "加密密钥检查完成"
|
||||
print_success "Encryption keys OK"
|
||||
print_info " • JWT_SECRET: OK"
|
||||
print_info " • DATA_ENCRYPTION_KEY: OK"
|
||||
print_info " • RSA_PRIVATE_KEY: OK"
|
||||
|
||||
# 修复 .env 文件权限
|
||||
chmod 600 .env 2>/dev/null || true
|
||||
}
|
||||
|
||||
@@ -197,13 +193,12 @@ read_env_vars() {
|
||||
# Validation: Database Directory (data/)
|
||||
# ------------------------------------------------------------------------
|
||||
check_database() {
|
||||
# Ensure data directory exists
|
||||
if [ ! -d "data" ]; then
|
||||
print_warning "数据目录不存在,创建 data/ 目录..."
|
||||
print_warning "Data directory missing, creating data/..."
|
||||
install -m 700 -d data
|
||||
print_success "已创建 data/ 目录"
|
||||
print_success "data/ directory created"
|
||||
else
|
||||
print_success "数据目录存在"
|
||||
print_success "Data directory exists"
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -211,47 +206,58 @@ check_database() {
|
||||
# Service Management: Start
|
||||
# ------------------------------------------------------------------------
|
||||
start() {
|
||||
print_info "正在启动 NOFX AI Trading System..."
|
||||
echo ""
|
||||
echo -e "${CYAN}╔══════════════════════════════════════════════════════╗${NC}"
|
||||
echo -e "${CYAN}║ 🚀 NOFX AI Trading Bot — Startup ║${NC}"
|
||||
echo -e "${CYAN}╚══════════════════════════════════════════════════════╝${NC}"
|
||||
echo ""
|
||||
|
||||
read_env_vars
|
||||
|
||||
if [ ! -d "data" ]; then
|
||||
print_info "创建数据目录..."
|
||||
install -m 700 -d data
|
||||
fi
|
||||
|
||||
echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||
print_info "Starting services..."
|
||||
|
||||
if [ "$1" == "--build" ]; then
|
||||
print_info "重新构建镜像..."
|
||||
$COMPOSE_CMD up -d --build
|
||||
else
|
||||
print_info "启动容器..."
|
||||
$COMPOSE_CMD up -d
|
||||
fi
|
||||
|
||||
print_success "服务已启动!"
|
||||
print_info "Web 界面: http://localhost:${NOFX_FRONTEND_PORT}"
|
||||
print_info "API 端点: http://localhost:${NOFX_BACKEND_PORT}"
|
||||
print_info ""
|
||||
print_info "查看日志: ./start.sh logs"
|
||||
print_info "停止服务: ./start.sh stop"
|
||||
echo ""
|
||||
echo -e "${GREEN}╔══════════════════════════════════════════════════════╗${NC}"
|
||||
echo -e "${GREEN}║ ✅ Started! Next steps: ║${NC}"
|
||||
echo -e "${GREEN}╚══════════════════════════════════════════════════════╝${NC}"
|
||||
echo ""
|
||||
echo " 1. Open the web dashboard to register and configure"
|
||||
echo " 2. Add an AI model and exchange in Settings"
|
||||
echo " 3. (Optional) Add a Telegram bot token in Settings → Telegram"
|
||||
echo ""
|
||||
echo -e " Web dashboard: ${BLUE}http://localhost:${NOFX_FRONTEND_PORT}${NC}"
|
||||
echo -e " View logs: ${YELLOW}./start.sh logs${NC}"
|
||||
echo -e " Stop: ${YELLOW}./start.sh stop${NC}"
|
||||
echo ""
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------------
|
||||
# Service Management: Stop
|
||||
# ------------------------------------------------------------------------
|
||||
stop() {
|
||||
print_info "正在停止服务..."
|
||||
print_info "Stopping services..."
|
||||
$COMPOSE_CMD stop
|
||||
print_success "服务已停止"
|
||||
print_success "Services stopped"
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------------
|
||||
# Service Management: Restart
|
||||
# ------------------------------------------------------------------------
|
||||
restart() {
|
||||
print_info "正在重启服务..."
|
||||
print_info "Restarting services..."
|
||||
$COMPOSE_CMD restart
|
||||
print_success "服务已重启"
|
||||
print_success "Services restarted"
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------------
|
||||
@@ -271,25 +277,25 @@ logs() {
|
||||
status() {
|
||||
read_env_vars
|
||||
|
||||
print_info "服务状态:"
|
||||
print_info "Service status:"
|
||||
$COMPOSE_CMD ps
|
||||
echo ""
|
||||
print_info "健康检查:"
|
||||
curl -s "http://localhost:${NOFX_BACKEND_PORT}/api/health" | jq '.' || echo "后端未响应"
|
||||
print_info "Health check:"
|
||||
curl -s "http://localhost:${NOFX_BACKEND_PORT}/api/health" | jq '.' || echo "Backend not responding"
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------------
|
||||
# Maintenance: Clean (Destructive)
|
||||
# ------------------------------------------------------------------------
|
||||
clean() {
|
||||
print_warning "这将删除所有容器和数据!"
|
||||
read -p "确认删除?(yes/no): " confirm
|
||||
print_warning "This will delete all containers and data!"
|
||||
read -p "Confirm? (yes/no): " confirm
|
||||
if [ "$confirm" == "yes" ]; then
|
||||
print_info "正在清理..."
|
||||
print_info "Cleaning up..."
|
||||
$COMPOSE_CMD down -v
|
||||
print_success "清理完成"
|
||||
print_success "Cleanup complete"
|
||||
else
|
||||
print_info "已取消"
|
||||
print_info "Cancelled"
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -297,77 +303,74 @@ clean() {
|
||||
# Maintenance: Update
|
||||
# ------------------------------------------------------------------------
|
||||
update() {
|
||||
print_info "正在更新..."
|
||||
print_info "Updating..."
|
||||
git pull
|
||||
$COMPOSE_CMD up -d --build
|
||||
print_success "更新完成"
|
||||
print_success "Update complete"
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------------
|
||||
# Command: Regenerate all keys (force)
|
||||
# ------------------------------------------------------------------------
|
||||
regenerate_keys() {
|
||||
print_warning "这将重新生成所有加密密钥!"
|
||||
print_warning "如果已有加密数据,重新生成后将无法解密!"
|
||||
print_warning "This will regenerate ALL encryption keys!"
|
||||
print_warning "Any existing encrypted data will become unreadable!"
|
||||
echo ""
|
||||
read -p "确认重新生成?(yes/no): " confirm
|
||||
read -p "Confirm? (yes/no): " confirm
|
||||
if [ "$confirm" != "yes" ]; then
|
||||
print_info "已取消"
|
||||
print_info "Cancelled"
|
||||
return
|
||||
fi
|
||||
|
||||
check_env
|
||||
|
||||
print_info "正在生成新的密钥..."
|
||||
print_info "Generating new keys..."
|
||||
|
||||
# 生成 JWT_SECRET
|
||||
local jwt_secret=$(openssl rand -base64 32)
|
||||
set_env_var "JWT_SECRET" "$jwt_secret"
|
||||
print_success "JWT_SECRET 已生成"
|
||||
print_success "JWT_SECRET generated"
|
||||
|
||||
# 生成 DATA_ENCRYPTION_KEY
|
||||
local data_key=$(openssl rand -base64 32)
|
||||
set_env_var "DATA_ENCRYPTION_KEY" "$data_key"
|
||||
print_success "DATA_ENCRYPTION_KEY 已生成"
|
||||
print_success "DATA_ENCRYPTION_KEY generated"
|
||||
|
||||
# 生成 RSA_PRIVATE_KEY
|
||||
local rsa_key=$(openssl genrsa 2048 2>/dev/null | awk '{printf "%s\\n", $0}')
|
||||
set_env_var "RSA_PRIVATE_KEY" "\"$rsa_key\""
|
||||
print_success "RSA_PRIVATE_KEY 已生成"
|
||||
print_success "RSA_PRIVATE_KEY generated"
|
||||
|
||||
chmod 600 .env 2>/dev/null || true
|
||||
|
||||
echo ""
|
||||
print_success "所有密钥已重新生成并保存到 .env"
|
||||
print_warning "请妥善保管 .env 文件"
|
||||
print_success "All keys regenerated and saved to .env"
|
||||
print_warning "Keep .env safe"
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------------
|
||||
# Help: Usage Information
|
||||
# ------------------------------------------------------------------------
|
||||
show_help() {
|
||||
echo "NOFX AI Trading System - Docker 管理脚本"
|
||||
echo "NOFX AI Trading System - Docker Management Script"
|
||||
echo ""
|
||||
echo "用法: ./start.sh [command] [options]"
|
||||
echo "Usage: ./start.sh [command] [options]"
|
||||
echo ""
|
||||
echo "命令:"
|
||||
echo " start [--build] 启动服务(可选:重新构建)"
|
||||
echo " stop 停止服务"
|
||||
echo " restart 重启服务"
|
||||
echo " logs [service] 查看日志(可选:指定服务名 backend/frontend)"
|
||||
echo " status 查看服务状态"
|
||||
echo " clean 清理所有容器和数据"
|
||||
echo " update 更新代码并重启"
|
||||
echo " regenerate-keys 重新生成所有加密密钥(慎用)"
|
||||
echo " help 显示此帮助信息"
|
||||
echo "Commands:"
|
||||
echo " start [--build] Start services (optional: rebuild images)"
|
||||
echo " stop Stop services"
|
||||
echo " restart Restart services"
|
||||
echo " logs [service] View logs (optional: backend / frontend)"
|
||||
echo " status Show service status"
|
||||
echo " clean Remove all containers and data"
|
||||
echo " update Pull latest code and rebuild"
|
||||
echo " regenerate-keys Regenerate all encryption keys (destructive)"
|
||||
echo " help Show this help"
|
||||
echo ""
|
||||
echo "示例:"
|
||||
echo " ./start.sh start --build # 构建并启动"
|
||||
echo " ./start.sh logs backend # 查看后端日志"
|
||||
echo " ./start.sh status # 查看状态"
|
||||
echo "Examples:"
|
||||
echo " ./start.sh start --build # Build and start"
|
||||
echo " ./start.sh logs backend # View backend logs"
|
||||
echo " ./start.sh status # Check status"
|
||||
echo ""
|
||||
echo "首次使用:"
|
||||
echo " 直接运行 ./start.sh 即可,缺失的密钥会自动生成"
|
||||
echo "First time:"
|
||||
echo " Just run ./start.sh — missing keys are generated automatically"
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------------
|
||||
@@ -408,7 +411,7 @@ main() {
|
||||
show_help
|
||||
;;
|
||||
*)
|
||||
print_error "未知命令: $1"
|
||||
print_error "Unknown command: $1"
|
||||
show_help
|
||||
exit 1
|
||||
;;
|
||||
|
||||
@@ -137,6 +137,19 @@ func (s *AIModelStore) firstEnabled(userID string) (*AIModel, error) {
|
||||
return &model, nil
|
||||
}
|
||||
|
||||
// GetAnyEnabled returns the first enabled AI model across all users.
|
||||
// Used by single-user features (e.g. Telegram bot) that need any working LLM client.
|
||||
func (s *AIModelStore) GetAnyEnabled() (*AIModel, error) {
|
||||
var model AIModel
|
||||
err := s.db.Where("enabled = ? AND api_key != ''", true).
|
||||
Order("updated_at DESC, id ASC").
|
||||
First(&model).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &model, nil
|
||||
}
|
||||
|
||||
// Update updates AI model, creates if not exists
|
||||
// IMPORTANT: If apiKey is empty string, the existing API key will be preserved (not overwritten)
|
||||
func (s *AIModelStore) Update(userID, id string, enabled bool, apiKey, customAPIURL, customModelName string) error {
|
||||
|
||||
@@ -18,17 +18,18 @@ type Store struct {
|
||||
driver *DBDriver // Database driver for abstraction (legacy)
|
||||
|
||||
// Sub-stores (lazy initialization)
|
||||
user *UserStore
|
||||
aiModel *AIModelStore
|
||||
exchange *ExchangeStore
|
||||
trader *TraderStore
|
||||
decision *DecisionStore
|
||||
backtest *BacktestStore
|
||||
position *PositionStore
|
||||
strategy *StrategyStore
|
||||
equity *EquityStore
|
||||
order *OrderStore
|
||||
grid *GridStore
|
||||
user *UserStore
|
||||
aiModel *AIModelStore
|
||||
exchange *ExchangeStore
|
||||
trader *TraderStore
|
||||
decision *DecisionStore
|
||||
backtest *BacktestStore
|
||||
position *PositionStore
|
||||
strategy *StrategyStore
|
||||
equity *EquityStore
|
||||
order *OrderStore
|
||||
grid *GridStore
|
||||
telegramConfig TelegramConfigStore
|
||||
|
||||
mu sync.RWMutex
|
||||
}
|
||||
@@ -160,6 +161,9 @@ func (s *Store) initTables() error {
|
||||
if err := s.Grid().InitTables(); err != nil {
|
||||
return fmt.Errorf("failed to initialize grid tables: %w", err)
|
||||
}
|
||||
if err := s.TelegramConfig().(*telegramConfigStore).initTables(); err != nil {
|
||||
return fmt.Errorf("failed to initialize telegram config tables: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -293,6 +297,16 @@ func (s *Store) Grid() *GridStore {
|
||||
return s.grid
|
||||
}
|
||||
|
||||
// TelegramConfig gets Telegram bot configuration storage
|
||||
func (s *Store) TelegramConfig() TelegramConfigStore {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.telegramConfig == nil {
|
||||
s.telegramConfig = NewTelegramConfigStore(s.gdb)
|
||||
}
|
||||
return s.telegramConfig
|
||||
}
|
||||
|
||||
// Close closes database connection
|
||||
func (s *Store) Close() error {
|
||||
if s.driver != nil {
|
||||
|
||||
164
store/telegram_config.go
Normal file
164
store/telegram_config.go
Normal file
@@ -0,0 +1,164 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// TelegramConfig stores the Telegram bot binding (single row, always ID=1)
|
||||
type TelegramConfig struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
BotToken string `gorm:"column:bot_token"`
|
||||
ChatID int64 `gorm:"column:chat_id"`
|
||||
Username string `gorm:"column:username"` // @username for display
|
||||
BoundAt time.Time `gorm:"column:bound_at"`
|
||||
ModelID string `gorm:"column:model_id;default:''"` // AI model used for Telegram replies
|
||||
Language string `gorm:"column:language;default:''"` // "zh" or "en"; empty = not chosen yet
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// String returns a safe string representation of TelegramConfig with the token masked.
|
||||
func (tc TelegramConfig) String() string {
|
||||
token := "***"
|
||||
if tc.BotToken == "" {
|
||||
token = "<not set>"
|
||||
}
|
||||
return fmt.Sprintf("TelegramConfig{ID:%d, ChatID:%d, Username:%q, BotToken:%s, BoundAt:%v}",
|
||||
tc.ID, tc.ChatID, tc.Username, token, tc.BoundAt)
|
||||
}
|
||||
|
||||
// TelegramConfigStore defines the interface for Telegram bot binding operations
|
||||
type TelegramConfigStore interface {
|
||||
Get() (*TelegramConfig, error) // Get current config (may not exist)
|
||||
SaveToken(botToken string) error // Save bot token only (Web UI sets this)
|
||||
Save(botToken, modelID string) error // Save bot token + selected AI model
|
||||
BindUser(chatID int64, username string) error // Called on first /start
|
||||
IsBound() (bool, error) // Check if any user is bound
|
||||
GetBoundChatID() (int64, error) // Get bound chat ID (0 if not bound)
|
||||
Unbind() error // Remove binding
|
||||
SetLanguage(lang string) error // Set UI language ("en" or "zh")
|
||||
GetLanguage() string // Get UI language; returns "en" if not set
|
||||
}
|
||||
|
||||
type telegramConfigStore struct {
|
||||
db *gorm.DB
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewTelegramConfigStore creates a new TelegramConfigStore
|
||||
func NewTelegramConfigStore(db *gorm.DB) TelegramConfigStore {
|
||||
return &telegramConfigStore{db: db}
|
||||
}
|
||||
|
||||
func (s *telegramConfigStore) initTables() error {
|
||||
return s.db.AutoMigrate(&TelegramConfig{})
|
||||
}
|
||||
|
||||
func (s *telegramConfigStore) Get() (*TelegramConfig, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
var cfg TelegramConfig
|
||||
if err := s.db.First(&cfg, 1).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
func (s *telegramConfigStore) SaveToken(botToken string) error {
|
||||
return s.Save(botToken, "")
|
||||
}
|
||||
|
||||
func (s *telegramConfigStore) Save(botToken, modelID string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
var cfg TelegramConfig
|
||||
result := s.db.First(&cfg, 1)
|
||||
if result.Error != nil && !errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return result.Error
|
||||
}
|
||||
cfg.ID = 1
|
||||
cfg.BotToken = botToken
|
||||
cfg.ModelID = modelID
|
||||
return s.db.Save(&cfg).Error
|
||||
}
|
||||
|
||||
func (s *telegramConfigStore) BindUser(chatID int64, username string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
var cfg TelegramConfig
|
||||
result := s.db.First(&cfg, 1)
|
||||
if result.Error != nil && !errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return result.Error
|
||||
}
|
||||
cfg.ID = 1
|
||||
cfg.ChatID = chatID
|
||||
cfg.Username = username
|
||||
cfg.BoundAt = time.Now()
|
||||
return s.db.Save(&cfg).Error
|
||||
}
|
||||
|
||||
func (s *telegramConfigStore) IsBound() (bool, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
var cfg TelegramConfig
|
||||
if err := s.db.First(&cfg, 1).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
return cfg.ChatID != 0, nil
|
||||
}
|
||||
|
||||
func (s *telegramConfigStore) GetBoundChatID() (int64, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
var cfg TelegramConfig
|
||||
if err := s.db.First(&cfg, 1).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return 0, nil
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
return cfg.ChatID, nil
|
||||
}
|
||||
|
||||
func (s *telegramConfigStore) Unbind() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.db.Model(&TelegramConfig{}).Where("id = 1").Updates(map[string]interface{}{
|
||||
"chat_id": 0,
|
||||
"username": "",
|
||||
}).Error
|
||||
}
|
||||
|
||||
func (s *telegramConfigStore) SetLanguage(lang string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
var cfg TelegramConfig
|
||||
result := s.db.First(&cfg, 1)
|
||||
if result.Error != nil && !errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return result.Error
|
||||
}
|
||||
cfg.ID = 1
|
||||
cfg.Language = lang
|
||||
return s.db.Save(&cfg).Error
|
||||
}
|
||||
|
||||
func (s *telegramConfigStore) GetLanguage() string {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
var cfg TelegramConfig
|
||||
if err := s.db.First(&cfg, 1).Error; err != nil {
|
||||
return "en" // default: English
|
||||
}
|
||||
if cfg.Language == "" {
|
||||
return "en"
|
||||
}
|
||||
return cfg.Language
|
||||
}
|
||||
@@ -97,6 +97,13 @@ func (s *UserStore) GetAllIDs() ([]string, error) {
|
||||
return userIDs, err
|
||||
}
|
||||
|
||||
// GetAll returns all users ordered by creation time.
|
||||
func (s *UserStore) GetAll() ([]User, error) {
|
||||
var users []User
|
||||
err := s.db.Model(&User{}).Order("created_at").Find(&users).Error
|
||||
return users, err
|
||||
}
|
||||
|
||||
// UpdatePassword updates password
|
||||
func (s *UserStore) UpdatePassword(userID, passwordHash string) error {
|
||||
return s.db.Model(&User{}).Where("id = ?", userID).Updates(map[string]interface{}{
|
||||
|
||||
285
telegram/agent/agent.go
Normal file
285
telegram/agent/agent.go
Normal file
@@ -0,0 +1,285 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"nofx/auth"
|
||||
"nofx/logger"
|
||||
"nofx/mcp"
|
||||
"nofx/telegram/session"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const maxIterations = 10
|
||||
|
||||
// apiRequestTool is the single tool exposed to the LLM.
|
||||
// Native function calling means the LLM returns EITHER ToolCalls OR Content — never both.
|
||||
// This makes narration structurally impossible: text cannot appear alongside a tool call.
|
||||
var apiRequestTool = mcp.Tool{
|
||||
Type: "function",
|
||||
Function: mcp.FunctionDef{
|
||||
Name: "api_request",
|
||||
Description: "Call the NOFX trading system REST API",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"method": map[string]any{
|
||||
"type": "string",
|
||||
"enum": []string{"GET", "POST", "PUT", "DELETE"},
|
||||
"description": "HTTP method",
|
||||
},
|
||||
"path": map[string]any{
|
||||
"type": "string",
|
||||
"description": "API path; include query params in path: /api/positions?trader_id=xxx",
|
||||
},
|
||||
"body": map[string]any{
|
||||
"type": "object",
|
||||
"description": "Request body; use {} for GET requests",
|
||||
},
|
||||
},
|
||||
"required": []string{"method", "path", "body"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Agent is a stateful AI agent for one Telegram chat.
|
||||
// It exposes a single "api_request" tool and runs a loop until the LLM
|
||||
// returns a plain-text reply (no tool calls).
|
||||
type Agent struct {
|
||||
apiTool *apiCallTool
|
||||
getLLM func() mcp.AIClient
|
||||
memory *session.Memory
|
||||
systemPrompt string
|
||||
userID string
|
||||
}
|
||||
|
||||
// New creates an Agent for one chat session.
|
||||
func New(apiPort int, botToken, userID string, getLLM func() mcp.AIClient, systemPrompt string) *Agent {
|
||||
return &Agent{
|
||||
apiTool: newAPICallTool(apiPort, botToken),
|
||||
getLLM: getLLM,
|
||||
memory: session.NewMemory(getLLM()),
|
||||
systemPrompt: systemPrompt,
|
||||
userID: userID,
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateBotToken creates a long-lived JWT for the bot's internal API calls.
|
||||
// userID must match the actual registered user's ID so bot-made changes
|
||||
// are visible in the frontend (shared user namespace).
|
||||
func GenerateBotToken(userID string) (string, error) {
|
||||
return auth.GenerateJWT(userID, "bot@internal")
|
||||
}
|
||||
|
||||
// buildAccountContext fetches the live account state (models, exchanges, strategies, traders,
|
||||
// and per-trader account summary + statistics) and returns it as a formatted string for
|
||||
// injection into the LLM context at the start of each conversation.
|
||||
func (a *Agent) buildAccountContext() string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("[Current Account State — User: %s]\n\n", a.userID))
|
||||
|
||||
// ── AI Models ─────────────────────────────────────────────────────────────
|
||||
modelsRaw := a.apiTool.execute(&apiRequest{Method: "GET", Path: "/api/models"})
|
||||
sb.WriteString("## AI Models\n")
|
||||
sb.WriteString("⚠️ When creating a trader, use the EXACT \"id\" value below for \"ai_model_id\".\n")
|
||||
sb.WriteString(" DO NOT use the \"provider\" field — it is NOT a valid ai_model_id.\n\n")
|
||||
|
||||
var models []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Provider string `json:"provider"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(modelsRaw), &models); err == nil && len(models) > 0 {
|
||||
for _, m := range models {
|
||||
status := "disabled"
|
||||
if m.Enabled {
|
||||
status = "ENABLED"
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" • ai_model_id=\"%s\" provider=%s name=%s [%s]\n", m.ID, m.Provider, m.Name, status))
|
||||
}
|
||||
} else {
|
||||
sb.WriteString(modelsRaw)
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
|
||||
// ── Exchanges ─────────────────────────────────────────────────────────────
|
||||
exchangesRaw := a.apiTool.execute(&apiRequest{Method: "GET", Path: "/api/exchanges"})
|
||||
sb.WriteString("## Exchanges\n")
|
||||
sb.WriteString("⚠️ Use the EXACT \"id\" value below for \"exchange_id\" when creating a trader.\n\n")
|
||||
|
||||
var exchanges []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
ExchangeType string `json:"exchange_type"`
|
||||
AccountName string `json:"account_name"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(exchangesRaw), &exchanges); err == nil && len(exchanges) > 0 {
|
||||
for _, e := range exchanges {
|
||||
status := "disabled"
|
||||
if e.Enabled {
|
||||
status = "ENABLED"
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" • exchange_id=\"%s\" type=%s account=%s [%s]\n", e.ID, e.ExchangeType, e.AccountName, status))
|
||||
}
|
||||
} else {
|
||||
sb.WriteString(exchangesRaw)
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
|
||||
// ── Strategies ────────────────────────────────────────────────────────────
|
||||
strategiesRaw := a.apiTool.execute(&apiRequest{Method: "GET", Path: "/api/strategies"})
|
||||
sb.WriteString("## Strategies\n")
|
||||
|
||||
var strategies []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(strategiesRaw), &strategies); err == nil && len(strategies) > 0 {
|
||||
for _, s := range strategies {
|
||||
sb.WriteString(fmt.Sprintf(" • strategy_id=\"%s\" name=%s\n", s.ID, s.Name))
|
||||
}
|
||||
} else {
|
||||
sb.WriteString(strategiesRaw)
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
|
||||
// ── Traders ───────────────────────────────────────────────────────────────
|
||||
tradersRaw := a.apiTool.execute(&apiRequest{Method: "GET", Path: "/api/my-traders"})
|
||||
sb.WriteString("## Traders\n")
|
||||
|
||||
var traders []struct {
|
||||
TraderID string `json:"trader_id"`
|
||||
Name string `json:"trader_name"`
|
||||
IsRunning bool `json:"is_running"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(tradersRaw), &traders); err == nil && len(traders) > 0 {
|
||||
for _, t := range traders {
|
||||
status := "stopped"
|
||||
if t.IsRunning {
|
||||
status = "RUNNING"
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" • trader_id=\"%s\" name=%s [%s]\n", t.TraderID, t.Name, status))
|
||||
}
|
||||
} else {
|
||||
sb.WriteString(tradersRaw)
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
|
||||
// ── Per-trader live data (running traders only) ────────────────────────────
|
||||
for _, t := range traders {
|
||||
if !t.IsRunning {
|
||||
continue
|
||||
}
|
||||
acct := a.apiTool.execute(&apiRequest{Method: "GET", Path: "/api/account?trader_id=" + t.TraderID})
|
||||
sb.WriteString(fmt.Sprintf("Account [%s]:\n%s\n\n", t.Name, acct))
|
||||
stats := a.apiTool.execute(&apiRequest{Method: "GET", Path: "/api/statistics?trader_id=" + t.TraderID})
|
||||
sb.WriteString(fmt.Sprintf("Statistics [%s]:\n%s\n\n", t.Name, stats))
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// Run processes one user message through the native function-calling agent loop.
|
||||
//
|
||||
// Architecture:
|
||||
// - LLM receives the api_request tool definition alongside conversation history.
|
||||
// - LLM response is EITHER ToolCalls (execute API) OR Content (final reply) — never both.
|
||||
// This is enforced by the protocol: narration is structurally impossible.
|
||||
// - Loop continues until the LLM returns a plain-text reply (no tool calls).
|
||||
//
|
||||
// On the first message of a conversation the live account state is fetched and injected.
|
||||
// onChunk is optional; when set it is called once with the complete final reply text.
|
||||
func (a *Agent) Run(userMessage string, onChunk func(string)) string {
|
||||
llm := a.getLLM()
|
||||
if llm == nil {
|
||||
return "AI assistant unavailable. Please configure an AI model in the Web UI."
|
||||
}
|
||||
|
||||
// Build initial user message: prepend account state on first turn, history on subsequent turns.
|
||||
histCtx := a.memory.BuildContext()
|
||||
var firstUserContent string
|
||||
if histCtx == "" {
|
||||
accountCtx := a.buildAccountContext()
|
||||
firstUserContent = accountCtx + "\n[User Message]\n" + userMessage
|
||||
} else {
|
||||
firstUserContent = histCtx + "\n---\nUser: " + userMessage
|
||||
}
|
||||
|
||||
turnMsgs := []mcp.Message{mcp.NewUserMessage(firstUserContent)}
|
||||
|
||||
for i := 0; i < maxIterations; i++ {
|
||||
req, err := mcp.NewRequestBuilder().
|
||||
WithSystemPrompt(a.systemPrompt).
|
||||
AddConversationHistory(turnMsgs).
|
||||
AddTool(apiRequestTool).
|
||||
WithToolChoice("auto").
|
||||
Build()
|
||||
if err != nil {
|
||||
logger.Errorf("Agent: failed to build request: %v", err)
|
||||
break
|
||||
}
|
||||
|
||||
resp, err := llm.CallWithRequestFull(req)
|
||||
if err != nil {
|
||||
logger.Errorf("Agent: LLM call failed (iteration %d): %v", i+1, err)
|
||||
return "AI assistant temporarily unavailable. Please try again."
|
||||
}
|
||||
|
||||
// No tool calls → LLM returned a final text reply.
|
||||
if len(resp.ToolCalls) == 0 {
|
||||
reply := strings.TrimSpace(resp.Content)
|
||||
if onChunk != nil {
|
||||
onChunk(reply)
|
||||
}
|
||||
a.memory.Add("user", userMessage)
|
||||
a.memory.Add("assistant", reply)
|
||||
return reply
|
||||
}
|
||||
|
||||
// Tool call iteration — show thinking indicator.
|
||||
if onChunk != nil {
|
||||
onChunk("⏳")
|
||||
}
|
||||
|
||||
// Append assistant message carrying the tool calls (no content field).
|
||||
turnMsgs = append(turnMsgs, mcp.Message{
|
||||
Role: "assistant",
|
||||
ToolCalls: resp.ToolCalls,
|
||||
})
|
||||
|
||||
// Execute each tool call and append the results as tool messages.
|
||||
for _, tc := range resp.ToolCalls {
|
||||
var apiReq apiRequest
|
||||
if err := json.Unmarshal([]byte(tc.Function.Arguments), &apiReq); err != nil {
|
||||
logger.Errorf("Agent: invalid tool args for call %s: %v", tc.ID, err)
|
||||
turnMsgs = append(turnMsgs, mcp.Message{
|
||||
Role: "tool",
|
||||
ToolCallID: tc.ID,
|
||||
Content: fmt.Sprintf(`{"error":"invalid arguments: %s"}`, err.Error()),
|
||||
})
|
||||
continue
|
||||
}
|
||||
logger.Infof("Agent: iter=%d tool=%s %s %s", i+1, tc.ID, apiReq.Method, apiReq.Path)
|
||||
result := a.apiTool.execute(&apiReq)
|
||||
turnMsgs = append(turnMsgs, mcp.Message{
|
||||
Role: "tool",
|
||||
ToolCallID: tc.ID,
|
||||
Content: result,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Safety: max iterations reached.
|
||||
logger.Warnf("Agent: max iterations (%d) reached for message: %q", maxIterations, userMessage)
|
||||
reply := "操作已完成,请检查您的账户查看最新状态。"
|
||||
a.memory.Add("user", userMessage)
|
||||
a.memory.Add("assistant", reply)
|
||||
return reply
|
||||
}
|
||||
|
||||
// ResetMemory clears conversation history (called on /start).
|
||||
func (a *Agent) ResetMemory() {
|
||||
a.memory.ResetFull()
|
||||
}
|
||||
439
telegram/agent/agent_test.go
Normal file
439
telegram/agent/agent_test.go
Normal file
@@ -0,0 +1,439 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"nofx/mcp"
|
||||
)
|
||||
|
||||
// mockLLM implements mcp.AIClient using pre-programmed LLMResponse objects.
|
||||
// Native function calling: CallWithRequestFull is the primary method;
|
||||
// CallWithRequest and CallWithRequestStream are stubs kept for interface compliance.
|
||||
type mockLLM struct {
|
||||
responses []*mcp.LLMResponse
|
||||
calls int
|
||||
lastMsgs []mcp.Message
|
||||
}
|
||||
|
||||
func (m *mockLLM) SetAPIKey(_, _, _ string) {}
|
||||
func (m *mockLLM) SetTimeout(_ time.Duration) {}
|
||||
|
||||
func (m *mockLLM) CallWithMessages(_, _ string) (string, error) { return "", nil }
|
||||
|
||||
func (m *mockLLM) CallWithRequest(req *mcp.Request) (string, error) {
|
||||
r, err := m.next()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return r.Content, nil
|
||||
}
|
||||
|
||||
func (m *mockLLM) CallWithRequestStream(req *mcp.Request, onChunk func(string)) (string, error) {
|
||||
r, err := m.next()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if onChunk != nil {
|
||||
onChunk(r.Content)
|
||||
}
|
||||
return r.Content, nil
|
||||
}
|
||||
|
||||
func (m *mockLLM) CallWithRequestFull(req *mcp.Request) (*mcp.LLMResponse, error) {
|
||||
m.lastMsgs = req.Messages
|
||||
return m.next()
|
||||
}
|
||||
|
||||
func (m *mockLLM) next() (*mcp.LLMResponse, error) {
|
||||
if m.calls < len(m.responses) {
|
||||
r := m.responses[m.calls]
|
||||
m.calls++
|
||||
return r, nil
|
||||
}
|
||||
return &mcp.LLMResponse{Content: "OK"}, nil
|
||||
}
|
||||
|
||||
// toolCall builds a mock LLM response that contains a single tool invocation.
|
||||
func toolCall(id, method, path string, body string) *mcp.LLMResponse {
|
||||
if body == "" {
|
||||
body = "{}"
|
||||
}
|
||||
return &mcp.LLMResponse{
|
||||
ToolCalls: []mcp.ToolCall{{
|
||||
ID: id,
|
||||
Type: "function",
|
||||
Function: mcp.ToolCallFunction{
|
||||
Name: "api_request",
|
||||
Arguments: fmt.Sprintf(`{"method":%q,"path":%q,"body":%s}`, method, path, body),
|
||||
},
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
// textReply builds a mock LLM response with a plain-text final answer.
|
||||
func textReply(content string) *mcp.LLMResponse {
|
||||
return &mcp.LLMResponse{Content: content}
|
||||
}
|
||||
|
||||
func mockGetLLM(llm *mockLLM) func() mcp.AIClient {
|
||||
return func() mcp.AIClient { return llm }
|
||||
}
|
||||
|
||||
const testPrompt = "You are a test assistant."
|
||||
|
||||
// mockAPIServer creates a test HTTP server with configurable route handlers.
|
||||
func mockAPIServer(handlers map[string]string) (*httptest.Server, int) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
key := r.Method + " " + r.URL.Path
|
||||
if body, ok := handlers[key]; ok {
|
||||
w.Write([]byte(body)) //nolint:errcheck
|
||||
return
|
||||
}
|
||||
// Also try path-only match (for GET)
|
||||
if body, ok := handlers[r.URL.Path]; ok {
|
||||
w.Write([]byte(body)) //nolint:errcheck
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
w.Write([]byte(`{"error":"not found"}`)) //nolint:errcheck
|
||||
}))
|
||||
var port int
|
||||
fmt.Sscanf(srv.Listener.Addr().String(), "127.0.0.1:%d", &port)
|
||||
return srv, port
|
||||
}
|
||||
|
||||
// ── Basic agent behaviour ──────────────────────────────────────────────────
|
||||
|
||||
// TestAgentDirectReply: LLM replies with text (no tool calls) — one LLM call.
|
||||
func TestAgentDirectReply(t *testing.T) {
|
||||
llm := &mockLLM{responses: []*mcp.LLMResponse{textReply("Hello! How can I help you?")}}
|
||||
a := New(8080, "tok", "test-user", mockGetLLM(llm), testPrompt)
|
||||
|
||||
reply := a.Run("hello", nil)
|
||||
|
||||
if reply != "Hello! How can I help you?" {
|
||||
t.Fatalf("unexpected reply: %q", reply)
|
||||
}
|
||||
if llm.calls != 1 {
|
||||
t.Fatalf("expected 1 LLM call, got %d", llm.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgentAPICall: LLM makes one tool call, gets result, gives final reply — two LLM calls.
|
||||
func TestAgentAPICall(t *testing.T) {
|
||||
srv, port := mockAPIServer(map[string]string{
|
||||
"/api/my-traders": `[{"trader_id":"t1","trader_name":"BTC Trader","is_running":false}]`,
|
||||
})
|
||||
defer srv.Close()
|
||||
|
||||
llm := &mockLLM{responses: []*mcp.LLMResponse{
|
||||
toolCall("c1", "GET", "/api/my-traders", "{}"),
|
||||
textReply("You have one trader: BTC Trader."),
|
||||
}}
|
||||
a := New(port, "tok", "test-user", mockGetLLM(llm), testPrompt)
|
||||
|
||||
reply := a.Run("list my traders", nil)
|
||||
|
||||
if reply != "You have one trader: BTC Trader." {
|
||||
t.Fatalf("unexpected reply: %q", reply)
|
||||
}
|
||||
if llm.calls != 2 {
|
||||
t.Fatalf("expected 2 LLM calls, got %d", llm.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgentMultiStep: LLM chains two tool calls before final reply — three LLM calls.
|
||||
func TestAgentMultiStep(t *testing.T) {
|
||||
srv, port := mockAPIServer(map[string]string{
|
||||
"/api/account": `{"total_equity":1000}`,
|
||||
"/api/positions": `[]`,
|
||||
})
|
||||
defer srv.Close()
|
||||
|
||||
llm := &mockLLM{responses: []*mcp.LLMResponse{
|
||||
toolCall("c1", "GET", "/api/account", "{}"),
|
||||
toolCall("c2", "GET", "/api/positions", "{}"),
|
||||
textReply("Account looks healthy and no open positions."),
|
||||
}}
|
||||
a := New(port, "tok", "test-user", mockGetLLM(llm), testPrompt)
|
||||
|
||||
reply := a.Run("show me account status", nil)
|
||||
|
||||
if llm.calls != 3 {
|
||||
t.Fatalf("expected 3 LLM calls (2 tool + 1 final), got %d", llm.calls)
|
||||
}
|
||||
if reply != "Account looks healthy and no open positions." {
|
||||
t.Fatalf("unexpected final reply: %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgentAPIResultInContext: tool result must appear as a tool message in the next LLM call.
|
||||
func TestAgentAPIResultInContext(t *testing.T) {
|
||||
srv, port := mockAPIServer(map[string]string{
|
||||
"/api/account": `{"balance":1234.56}`,
|
||||
})
|
||||
defer srv.Close()
|
||||
|
||||
llm := &mockLLM{responses: []*mcp.LLMResponse{
|
||||
toolCall("c1", "GET", "/api/account", "{}"),
|
||||
textReply("Balance is 1234.56 USDT."),
|
||||
}}
|
||||
a := New(port, "tok", "test-user", mockGetLLM(llm), testPrompt)
|
||||
a.Run("show balance", nil)
|
||||
|
||||
// The last request must contain a tool-result message with the balance data.
|
||||
found := false
|
||||
for _, msg := range llm.lastMsgs {
|
||||
if msg.Role == "tool" && strings.Contains(msg.Content, "balance") {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("tool result message not found in subsequent LLM context; messages: %+v", llm.lastMsgs)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Narration-free architecture tests ─────────────────────────────────────
|
||||
|
||||
// TestNarrationStructurallyImpossible: when ToolCalls are present in the response,
|
||||
// any Content field is ignored and never surfaced to the user.
|
||||
// In real LLM APIs, Content is always empty alongside ToolCalls, but we verify
|
||||
// our agent handles a malformed response defensively.
|
||||
func TestNarrationStructurallyImpossible(t *testing.T) {
|
||||
srv, port := mockAPIServer(map[string]string{
|
||||
"/api/strategies": `[{"id":"s1","name":"BTC Trend"}]`,
|
||||
})
|
||||
defer srv.Close()
|
||||
|
||||
// Simulate a (malformed) response that has both Content and ToolCalls.
|
||||
malformed := &mcp.LLMResponse{
|
||||
Content: "现在我将为您查询策略。", // narration — must NOT reach user
|
||||
ToolCalls: []mcp.ToolCall{{
|
||||
ID: "c1",
|
||||
Type: "function",
|
||||
Function: mcp.ToolCallFunction{
|
||||
Name: "api_request",
|
||||
Arguments: `{"method":"GET","path":"/api/strategies","body":{}}`,
|
||||
},
|
||||
}},
|
||||
}
|
||||
|
||||
llm := &mockLLM{responses: []*mcp.LLMResponse{
|
||||
malformed,
|
||||
textReply("你有1个策略:BTC Trend。"),
|
||||
}}
|
||||
a := New(port, "tok", "test-user", mockGetLLM(llm), testPrompt)
|
||||
reply := a.Run("查询我的策略", nil)
|
||||
|
||||
if strings.Contains(reply, "现在我将") {
|
||||
t.Fatalf("narration leaked into final reply: %q", reply)
|
||||
}
|
||||
if reply != "你有1个策略:BTC Trend。" {
|
||||
t.Fatalf("unexpected reply: %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOnChunkCalledWithFinalReply: onChunk receives the complete final reply.
|
||||
func TestOnChunkCalledWithFinalReply(t *testing.T) {
|
||||
srv, port := mockAPIServer(map[string]string{
|
||||
"/api/account": `{"equity":500}`,
|
||||
})
|
||||
defer srv.Close()
|
||||
|
||||
llm := &mockLLM{responses: []*mcp.LLMResponse{
|
||||
toolCall("c1", "GET", "/api/account", "{}"),
|
||||
textReply("Equity: 500 USDT."),
|
||||
}}
|
||||
a := New(port, "tok", "test-user", mockGetLLM(llm), testPrompt)
|
||||
|
||||
var chunks []string
|
||||
reply := a.Run("show equity", func(chunk string) {
|
||||
chunks = append(chunks, chunk)
|
||||
})
|
||||
|
||||
if reply != "Equity: 500 USDT." {
|
||||
t.Fatalf("unexpected reply: %q", reply)
|
||||
}
|
||||
// Should have received ⏳ for the tool call, then the final reply.
|
||||
if len(chunks) < 2 {
|
||||
t.Fatalf("expected at least 2 chunks (⏳ + final), got: %v", chunks)
|
||||
}
|
||||
lastChunk := chunks[len(chunks)-1]
|
||||
if lastChunk != "Equity: 500 USDT." {
|
||||
t.Fatalf("last chunk should be final reply, got: %q", lastChunk)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Workflow tests ─────────────────────────────────────────────────────────
|
||||
|
||||
// TestCreateStrategyWorkflow: simulates creating a BTC trend strategy.
|
||||
// Verifies: POST strategy → GET verify → final reply shows strategy info.
|
||||
func TestCreateStrategyWorkflow(t *testing.T) {
|
||||
srv, port := mockAPIServer(map[string]string{
|
||||
"POST /api/strategies": `{"id":"s1","name":"BTC趋势"}`,
|
||||
"GET /api/strategies/s1": `{"id":"s1","name":"BTC趋势","config":{"coin_source":{"source_type":"static","static_coins":["BTC/USDT"]},"leverage":5}}`,
|
||||
})
|
||||
defer srv.Close()
|
||||
|
||||
llm := &mockLLM{responses: []*mcp.LLMResponse{
|
||||
toolCall("c1", "POST", "/api/strategies", `{"name":"BTC趋势","config":{}}`),
|
||||
toolCall("c2", "GET", "/api/strategies/s1", "{}"),
|
||||
textReply("策略已创建:BTC趋势,币种 BTC/USDT,杠杆 5x。"),
|
||||
}}
|
||||
a := New(port, "tok", "test-user", mockGetLLM(llm), testPrompt)
|
||||
reply := a.Run("帮我配置个btc趋势交易的策略", nil)
|
||||
|
||||
if llm.calls != 3 {
|
||||
t.Fatalf("expected 3 LLM calls, got %d", llm.calls)
|
||||
}
|
||||
if reply == "" {
|
||||
t.Fatalf("empty final reply")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFullSetupWorkflow: create strategy → verify → create trader → start trader.
|
||||
// This is the "帮我配置策略并跑起来" workflow.
|
||||
func TestFullSetupWorkflow(t *testing.T) {
|
||||
calls := map[string]int{}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
key := r.Method + " " + r.URL.Path
|
||||
calls[key]++
|
||||
switch key {
|
||||
case "POST /api/strategies":
|
||||
w.Write([]byte(`{"id":"s1","name":"BTC趋势"}`)) //nolint:errcheck
|
||||
case "GET /api/strategies/s1":
|
||||
w.Write([]byte(`{"id":"s1","name":"BTC趋势","config":{}}`)) //nolint:errcheck
|
||||
case "POST /api/traders":
|
||||
w.Write([]byte(`{"id":"tr1","name":"BTC趋势交易员"}`)) //nolint:errcheck
|
||||
case "POST /api/traders/tr1/start":
|
||||
w.Write([]byte(`{"ok":true}`)) //nolint:errcheck
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
var port int
|
||||
fmt.Sscanf(srv.Listener.Addr().String(), "127.0.0.1:%d", &port)
|
||||
|
||||
llm := &mockLLM{responses: []*mcp.LLMResponse{
|
||||
toolCall("c1", "POST", "/api/strategies", `{"name":"BTC趋势"}`),
|
||||
toolCall("c2", "GET", "/api/strategies/s1", "{}"),
|
||||
toolCall("c3", "POST", "/api/traders", `{"name":"BTC趋势交易员","strategy_id":"s1"}`),
|
||||
toolCall("c4", "POST", "/api/traders/tr1/start", "{}"),
|
||||
textReply("策略和交易员已创建并启动!BTC趋势交易员正在运行。"),
|
||||
}}
|
||||
a := New(port, "tok", "test-user", mockGetLLM(llm), testPrompt)
|
||||
reply := a.Run("帮我配置个btc趋势交易的策略交易 跑起来", nil)
|
||||
|
||||
if llm.calls != 5 {
|
||||
t.Fatalf("expected 5 LLM calls, got %d", llm.calls)
|
||||
}
|
||||
if calls["POST /api/strategies"] != 1 {
|
||||
t.Errorf("expected 1 POST /api/strategies, got %d", calls["POST /api/strategies"])
|
||||
}
|
||||
if calls["POST /api/traders"] != 1 {
|
||||
t.Errorf("expected 1 POST /api/traders, got %d", calls["POST /api/traders"])
|
||||
}
|
||||
if calls["POST /api/traders/tr1/start"] != 1 {
|
||||
t.Errorf("expected 1 POST /api/traders/tr1/start, got %d", calls["POST /api/traders/tr1/start"])
|
||||
}
|
||||
if reply == "" {
|
||||
t.Fatalf("empty final reply")
|
||||
}
|
||||
}
|
||||
|
||||
// TestStartExistingTrader: when trader already exists, just start it.
|
||||
func TestStartExistingTrader(t *testing.T) {
|
||||
calls := map[string]int{}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
key := r.Method + " " + r.URL.Path
|
||||
calls[key]++
|
||||
switch key {
|
||||
case "GET /api/my-traders":
|
||||
w.Write([]byte(`[{"trader_id":"tr1","trader_name":"BTC Trader","is_running":false}]`)) //nolint:errcheck
|
||||
case "POST /api/traders/tr1/start":
|
||||
w.Write([]byte(`{"ok":true}`)) //nolint:errcheck
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
var port int
|
||||
fmt.Sscanf(srv.Listener.Addr().String(), "127.0.0.1:%d", &port)
|
||||
|
||||
llm := &mockLLM{responses: []*mcp.LLMResponse{
|
||||
toolCall("c1", "GET", "/api/my-traders", "{}"),
|
||||
toolCall("c2", "POST", "/api/traders/tr1/start", "{}"),
|
||||
textReply("交易员 BTC Trader 已启动。"),
|
||||
}}
|
||||
a := New(port, "tok", "test-user", mockGetLLM(llm), testPrompt)
|
||||
reply := a.Run("启动交易员", nil)
|
||||
|
||||
if calls["POST /api/traders/tr1/start"] != 1 {
|
||||
t.Errorf("expected trader to be started, got %d start calls", calls["POST /api/traders/tr1/start"])
|
||||
}
|
||||
if reply != "交易员 BTC Trader 已启动。" {
|
||||
t.Fatalf("unexpected reply: %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Safety limit ───────────────────────────────────────────────────────────
|
||||
|
||||
// TestMaxIterations: agent terminates after maxIterations and returns fallback message.
|
||||
func TestMaxIterations(t *testing.T) {
|
||||
srv, port := mockAPIServer(map[string]string{
|
||||
"/api/account": `{"ok":true}`,
|
||||
})
|
||||
defer srv.Close()
|
||||
|
||||
// Always returns another tool call — should hit max iterations.
|
||||
responses := make([]*mcp.LLMResponse, maxIterations+2)
|
||||
for i := range responses {
|
||||
responses[i] = toolCall(fmt.Sprintf("c%d", i), "GET", "/api/account", "{}")
|
||||
}
|
||||
|
||||
llm := &mockLLM{responses: responses}
|
||||
a := New(port, "tok", "test-user", mockGetLLM(llm), testPrompt)
|
||||
reply := a.Run("loop forever", nil)
|
||||
|
||||
if reply == "" {
|
||||
t.Fatalf("expected a fallback reply, got empty string")
|
||||
}
|
||||
// Agent should have made exactly maxIterations tool-call LLM calls.
|
||||
if llm.calls != maxIterations {
|
||||
t.Fatalf("expected %d LLM calls (max iterations), got %d", maxIterations, llm.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestToolCallIDPropagated: tool result messages carry the correct ToolCallID.
|
||||
func TestToolCallIDPropagated(t *testing.T) {
|
||||
srv, port := mockAPIServer(map[string]string{
|
||||
"/api/account": `{"balance":999}`,
|
||||
})
|
||||
defer srv.Close()
|
||||
|
||||
llm := &mockLLM{responses: []*mcp.LLMResponse{
|
||||
toolCall("call-xyz-123", "GET", "/api/account", "{}"),
|
||||
textReply("Balance is 999."),
|
||||
}}
|
||||
a := New(port, "tok", "test-user", mockGetLLM(llm), testPrompt)
|
||||
a.Run("check balance", nil)
|
||||
|
||||
// Find the tool result message and verify ToolCallID matches.
|
||||
found := false
|
||||
for _, msg := range llm.lastMsgs {
|
||||
if msg.Role == "tool" && msg.ToolCallID == "call-xyz-123" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("tool result with ToolCallID='call-xyz-123' not found in messages: %+v", llm.lastMsgs)
|
||||
}
|
||||
}
|
||||
88
telegram/agent/apicall.go
Normal file
88
telegram/agent/apicall.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"nofx/logger"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// apiCallTool executes HTTP requests against the NOFX API server.
|
||||
// This is the only tool available to the agent.
|
||||
type apiCallTool struct {
|
||||
baseURL string
|
||||
token string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// apiRequest holds the arguments decoded from the LLM's api_request tool call.
|
||||
type apiRequest struct {
|
||||
Method string `json:"method"`
|
||||
Path string `json:"path"`
|
||||
Body map[string]any `json:"body"`
|
||||
}
|
||||
|
||||
func newAPICallTool(port int, token string) *apiCallTool {
|
||||
return &apiCallTool{
|
||||
baseURL: fmt.Sprintf("http://127.0.0.1:%d", port),
|
||||
token: token,
|
||||
client: &http.Client{Timeout: 30 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// execute calls the API and returns the response as a string for LLM consumption.
|
||||
func (t *apiCallTool) execute(req *apiRequest) string {
|
||||
if req.Method == "" || req.Path == "" {
|
||||
return "error: method and path are required"
|
||||
}
|
||||
if !strings.HasPrefix(req.Path, "/") {
|
||||
req.Path = "/" + req.Path
|
||||
}
|
||||
|
||||
var bodyReader io.Reader
|
||||
if req.Method != "GET" && len(req.Body) > 0 {
|
||||
b, err := json.Marshal(req.Body)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("error marshaling body: %v", err)
|
||||
}
|
||||
bodyReader = bytes.NewReader(b)
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequest(req.Method, t.baseURL+req.Path, bodyReader)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("error creating request: %v", err)
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+t.token)
|
||||
|
||||
resp, err := t.client.Do(httpReq)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("API call failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("error reading response: %v", err)
|
||||
}
|
||||
|
||||
logger.Infof("Agent api_call: %s %s -> %d", req.Method, req.Path, resp.StatusCode)
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return fmt.Sprintf("API error %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
// Pretty-print JSON for better LLM readability
|
||||
var v any
|
||||
if json.Unmarshal(body, &v) == nil {
|
||||
if pretty, err := json.MarshalIndent(v, "", " "); err == nil {
|
||||
return string(pretty)
|
||||
}
|
||||
}
|
||||
return string(body)
|
||||
}
|
||||
|
||||
79
telegram/agent/manager.go
Normal file
79
telegram/agent/manager.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"nofx/logger"
|
||||
"nofx/mcp"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Manager holds one Agent per Telegram chat ID.
|
||||
// Messages for the same chat are serialized (OpenClaw Lane Queue pattern).
|
||||
type Manager struct {
|
||||
mu sync.Mutex
|
||||
agents map[int64]*Agent
|
||||
lanes map[int64]chan struct{}
|
||||
apiPort int
|
||||
botToken string
|
||||
userID string
|
||||
getLLM func() mcp.AIClient
|
||||
systemPrompt string
|
||||
}
|
||||
|
||||
// NewManager creates a Manager. Call api.GetAPIDocs() before this and pass the result as apiDocs.
|
||||
// userEmail is the registered email shown to the user when they ask "who am I".
|
||||
// userID is the internal DB UUID used for API authentication.
|
||||
func NewManager(apiPort int, botToken, userEmail, userID string, getLLM func() mcp.AIClient, apiDocs string) *Manager {
|
||||
return &Manager{
|
||||
agents: make(map[int64]*Agent),
|
||||
lanes: make(map[int64]chan struct{}),
|
||||
apiPort: apiPort,
|
||||
botToken: botToken,
|
||||
userID: userID,
|
||||
getLLM: getLLM,
|
||||
systemPrompt: BuildAgentPrompt(apiDocs, userEmail, userID),
|
||||
}
|
||||
}
|
||||
|
||||
// Run processes a message for the given chat ID.
|
||||
// If the same chat is already processing a message, this call blocks until it completes
|
||||
// or the lane wait times out (60 s), whichever comes first.
|
||||
// onChunk is optional — when set, LLM reply chunks are forwarded progressively (SSE streaming).
|
||||
func (m *Manager) Run(chatID int64, userMessage string, onChunk func(string)) string {
|
||||
a, lane := m.getOrCreate(chatID)
|
||||
select {
|
||||
case lane <- struct{}{}:
|
||||
case <-time.After(60 * time.Second):
|
||||
logger.Warnf("Agent: lane wait timeout for chat %d — previous message still processing", chatID)
|
||||
return "上一条消息仍在处理中,请稍等片刻后再试。"
|
||||
}
|
||||
defer func() { <-lane }()
|
||||
return a.Run(userMessage, onChunk)
|
||||
}
|
||||
|
||||
// Reset clears memory for the given chat (called on /start).
|
||||
func (m *Manager) Reset(chatID int64) {
|
||||
m.mu.Lock()
|
||||
a, ok := m.agents[chatID]
|
||||
m.mu.Unlock()
|
||||
if ok {
|
||||
a.ResetMemory()
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) getOrCreate(chatID int64) (*Agent, chan struct{}) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
a, ok := m.agents[chatID]
|
||||
if !ok {
|
||||
a = New(m.apiPort, m.botToken, m.userID, m.getLLM, m.systemPrompt)
|
||||
m.agents[chatID] = a
|
||||
}
|
||||
lane, ok := m.lanes[chatID]
|
||||
if !ok {
|
||||
lane = make(chan struct{}, 1) // binary semaphore: one message at a time per chat
|
||||
m.lanes[chatID] = lane
|
||||
}
|
||||
return a, lane
|
||||
}
|
||||
97
telegram/agent/prompt.go
Normal file
97
telegram/agent/prompt.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package agent
|
||||
|
||||
import "fmt"
|
||||
|
||||
// BuildAgentPrompt constructs the full system prompt with live API documentation injected.
|
||||
// apiDocs is the output of api.GetAPIDocs() — reflects all currently registered routes with full schemas.
|
||||
// userEmail is the registered email of the bound user (shown when user asks "who am I").
|
||||
// userID is the internal DB UUID used for API authentication only.
|
||||
func BuildAgentPrompt(apiDocs, userEmail, userID string) string {
|
||||
return fmt.Sprintf(`You are the NOFX quantitative trading system AI assistant.
|
||||
|
||||
## Your Identity
|
||||
- You are operating as: %s
|
||||
- Internal user ID (for API calls only): %s
|
||||
- When asked "which user / account / email" — answer with the email address above
|
||||
- All API calls are made on behalf of this user
|
||||
|
||||
## Tool: api_request
|
||||
Use the api_request tool to call the NOFX REST API:
|
||||
- method: "GET" | "POST" | "PUT" | "DELETE"
|
||||
- path: API path; query params go in the path: /api/positions?trader_id=xxx
|
||||
- body: JSON object (use {} for GET requests)
|
||||
|
||||
## NOFX API Documentation
|
||||
|
||||
%s
|
||||
|
||||
## CRITICAL: Exact ID Rule (read this before every API call)
|
||||
API fields like "ai_model_id", "exchange_id", "strategy_id", "trader_id" require the EXACT "id" value
|
||||
from the corresponding API response. NEVER use "provider", "type", or any other field as a substitute.
|
||||
|
||||
Wrong: {"ai_model_id": "deepseek"} ← "deepseek" is the provider, NOT the id
|
||||
Correct: {"ai_model_id": "abc123_deepseek"} ← full "id" from GET /api/models
|
||||
|
||||
The Account State block at the start of this conversation lists every resource with its exact id.
|
||||
Read the id field from there and copy it verbatim — do not abbreviate, shorten, or guess.
|
||||
|
||||
## Behavior Rules
|
||||
1. Reply in the same language the user used (中文→中文, English→English)
|
||||
2. Keep final replies concise — show results, not process
|
||||
3. Ask for ALL missing required info in ONE message — never ask one field at a time
|
||||
4. When user provides enough info, act immediately — no confirmation needed
|
||||
5. Be decisive — infer intent from context, use schema to fill in smart defaults
|
||||
|
||||
## Verification Rule (CRITICAL)
|
||||
After ANY PUT or POST that creates or modifies a resource:
|
||||
1. Immediately GET the resource to read actual saved values
|
||||
2. Show the user the KEY fields they care about from the GET response
|
||||
3. NEVER just say "updated successfully" without showing the actual values
|
||||
4. If saved values look wrong, correct them automatically
|
||||
|
||||
## Error Handling
|
||||
- 400: explain what was wrong, ask user to correct
|
||||
- 404: resource doesn't exist — you may have used the wrong ID format; check the Account State for the exact id
|
||||
- "AI model not enabled": tell user to enable the model first via PUT /api/models
|
||||
- "Exchange not enabled": tell user to enable the exchange first
|
||||
- 5xx: server error, ask user to try again
|
||||
|
||||
## Account State (injected at conversation start)
|
||||
At the start of each new conversation, a [Current Account State] block is provided with:
|
||||
- AI Models: all configured models with their IDs and enabled status
|
||||
- Exchanges: all configured exchanges with their IDs and enabled status
|
||||
- Strategies: all existing strategies with their IDs
|
||||
- Traders: all existing traders with their IDs and running status
|
||||
|
||||
Use this to:
|
||||
- NEVER ask for exchange/model info that is already configured — use the existing IDs directly
|
||||
- Know instantly if the user has 0 or N resources of each type
|
||||
- If only one exchange/model exists and user doesn't specify, use it directly without asking
|
||||
- If multiple exist, list them and ask which one to use
|
||||
|
||||
## Common Workflows
|
||||
|
||||
**Create strategy** (independent from traders):
|
||||
- Never GET trader info just to create a strategy.
|
||||
- POST {"name":"<descriptive name>"} — config is OPTIONAL. Backend applies complete working defaults automatically (ai500 top coins, all indicators, standard risk control). Strategy is immediately usable.
|
||||
- Only include "config" when user explicitly requests custom settings (specific coins, custom leverage, different timeframes).
|
||||
- After POST: GET /api/strategies/:id to verify → show user: name, coin_source.source_type, key risk_control values
|
||||
|
||||
**"帮我配置策略并跑起来" / "create strategy and start" (full setup workflow)**:
|
||||
Execute these steps IN ORDER with NO user confirmation between them:
|
||||
1. POST /api/strategies — body: {"name":"<descriptive name>"} — no config needed, defaults are complete
|
||||
2. GET /api/strategies/:id — verify strategy was saved
|
||||
3. POST /api/traders — create trader: use exchange_id and model_id from Account State (if only one each, use directly); set strategy_id from step 1; set name matching the strategy
|
||||
4. POST /api/traders/:id/start — start the trader
|
||||
5. Final reply: show strategy name, trader name, coin source, confirm running
|
||||
|
||||
**Update strategy config**:
|
||||
1. GET /api/strategies/:id to read current full config
|
||||
2. Modify only what user asked (keep all other fields)
|
||||
3. PUT /api/strategies/:id with complete merged config
|
||||
4. GET /api/strategies/:id to verify → show user actual saved values for changed fields
|
||||
|
||||
**Start/stop existing trader**: From Account State, if only one trader, act directly. If multiple, list and ask.
|
||||
|
||||
**Query data**: Use trader_id from Account State, then query /api/positions?trader_id=xxx or /api/account?trader_id=xxx etc.`, userEmail, userID, apiDocs)
|
||||
}
|
||||
479
telegram/bot.go
Normal file
479
telegram/bot.go
Normal file
@@ -0,0 +1,479 @@
|
||||
package telegram
|
||||
|
||||
import (
|
||||
"nofx/api"
|
||||
"nofx/config"
|
||||
"nofx/logger"
|
||||
"nofx/mcp"
|
||||
"nofx/store"
|
||||
"nofx/telegram/agent"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||
)
|
||||
|
||||
// Start initializes and runs the Telegram bot in a blocking supervisor loop.
|
||||
// Supports hot-reload: when a signal is sent on reloadCh, the bot restarts
|
||||
// with the latest token (re-read from DB or env). Must be called as a goroutine from main.go.
|
||||
func Start(cfg *config.Config, st *store.Store, reloadCh <-chan struct{}) {
|
||||
for {
|
||||
token := resolveToken(cfg, st)
|
||||
if token == "" {
|
||||
logger.Info("Telegram bot disabled (no token configured), waiting for reload signal...")
|
||||
<-reloadCh
|
||||
continue
|
||||
}
|
||||
|
||||
stopped := runBot(token, cfg, st)
|
||||
if !stopped {
|
||||
return
|
||||
}
|
||||
|
||||
select {
|
||||
case <-reloadCh:
|
||||
logger.Info("Reloading Telegram bot with new token...")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// resolveToken returns the bot token from DB (configured via Web UI).
|
||||
func resolveToken(cfg *config.Config, st *store.Store) string {
|
||||
dbCfg, err := st.TelegramConfig().Get()
|
||||
if err == nil && dbCfg.BotToken != "" {
|
||||
return dbCfg.BotToken
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// runBot runs the bot until the updates channel closes (clean stop → true) or a fatal error (false).
|
||||
func runBot(token string, cfg *config.Config, st *store.Store) bool {
|
||||
bot, err := tgbotapi.NewBotAPI(token)
|
||||
if err != nil {
|
||||
logger.Errorf("Telegram bot failed to start: %v", err)
|
||||
return false
|
||||
}
|
||||
logger.Infof("Telegram bot @%s started", bot.Self.UserName)
|
||||
|
||||
// Allowed chat ID: read from DB binding (0 = unbound, first /start will bind).
|
||||
allowedChatID := int64(0)
|
||||
if id, err := st.TelegramConfig().GetBoundChatID(); err == nil && id != 0 {
|
||||
allowedChatID = id
|
||||
}
|
||||
|
||||
// botUserID / botToken / agents are resolved lazily and refresh when user registers.
|
||||
var (
|
||||
botUserID string
|
||||
botUserEmail string
|
||||
botToken string
|
||||
agents *agent.Manager
|
||||
)
|
||||
|
||||
resolveBotUser := func() bool {
|
||||
users, err := st.User().GetAll()
|
||||
if err != nil || len(users) == 0 {
|
||||
return false
|
||||
}
|
||||
u := users[0]
|
||||
if u.ID == botUserID {
|
||||
return true
|
||||
}
|
||||
newToken, err := agent.GenerateBotToken(u.ID)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to generate bot JWT for user %s: %v", u.ID, err)
|
||||
return false
|
||||
}
|
||||
prev := botUserID
|
||||
botUserID = u.ID
|
||||
botUserEmail = u.Email
|
||||
botToken = newToken
|
||||
agents = agent.NewManager(cfg.APIServerPort, botToken, botUserEmail, botUserID,
|
||||
func() mcp.AIClient { return newLLMClient(st, botUserID) },
|
||||
api.GetAPIDocs(),
|
||||
)
|
||||
if prev == "" {
|
||||
logger.Infof("Bot: resolved user %s (%s)", botUserID, botUserEmail)
|
||||
} else {
|
||||
logger.Infof("Bot: user changed → %s (%s)", botUserID, botUserEmail)
|
||||
}
|
||||
return true
|
||||
}
|
||||
resolveBotUser()
|
||||
|
||||
u := tgbotapi.NewUpdate(0)
|
||||
u.Timeout = 60
|
||||
updates := bot.GetUpdatesChan(u)
|
||||
|
||||
// awaitingLang is set only when the user explicitly runs /lang.
|
||||
awaitingLang := false
|
||||
|
||||
for update := range updates {
|
||||
if update.Message == nil {
|
||||
continue
|
||||
}
|
||||
chatID := update.Message.Chat.ID
|
||||
text := strings.TrimSpace(update.Message.Text)
|
||||
|
||||
// ── Language selection (triggered only by /lang) ──────────────────────
|
||||
if awaitingLang && chatID == allowedChatID {
|
||||
if lang := parseLangChoice(text); lang != "" {
|
||||
awaitingLang = false
|
||||
st.TelegramConfig().SetLanguage(lang) //nolint:errcheck
|
||||
sendMarkdownMsg(bot, chatID, statusMsg(st, botUserID, cfg.APIServerPort, lang))
|
||||
} else {
|
||||
sendMarkdownMsg(bot, chatID, langMenuMsg())
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// ── /start ────────────────────────────────────────────────────────────
|
||||
if text == "/start" {
|
||||
resolveBotUser()
|
||||
if botUserID == "" {
|
||||
sendMsg(bot, chatID,
|
||||
"No account found.\nOpen the web dashboard to register, then send /start.")
|
||||
continue
|
||||
}
|
||||
if allowedChatID == 0 {
|
||||
username := update.Message.From.UserName
|
||||
if err := st.TelegramConfig().BindUser(chatID, "@"+username); err != nil {
|
||||
logger.Errorf("Failed to bind Telegram user: %v", err)
|
||||
sendMsg(bot, chatID, "Binding failed. Please try again.")
|
||||
continue
|
||||
}
|
||||
allowedChatID = chatID
|
||||
logger.Infof("Telegram bound to @%s (chatID: %d)", username, chatID)
|
||||
} else if chatID != allowedChatID {
|
||||
sendMsg(bot, chatID, "This bot is already bound to another account.")
|
||||
continue
|
||||
} else {
|
||||
agents.Reset(chatID)
|
||||
}
|
||||
lang := st.TelegramConfig().GetLanguage()
|
||||
sendMarkdownMsg(bot, chatID, statusMsg(st, botUserID, cfg.APIServerPort, lang))
|
||||
continue
|
||||
}
|
||||
|
||||
// ── /lang ─────────────────────────────────────────────────────────────
|
||||
if text == "/lang" {
|
||||
awaitingLang = true
|
||||
sendMarkdownMsg(bot, chatID, langMenuMsg())
|
||||
continue
|
||||
}
|
||||
|
||||
// ── /help ─────────────────────────────────────────────────────────────
|
||||
if text == "/help" {
|
||||
lang := st.TelegramConfig().GetLanguage()
|
||||
sendMarkdownMsg(bot, chatID, helpMsg(lang))
|
||||
continue
|
||||
}
|
||||
|
||||
// ── Access control ────────────────────────────────────────────────────
|
||||
if allowedChatID != 0 && chatID != allowedChatID {
|
||||
sendMsg(bot, chatID, "Unauthorized.")
|
||||
continue
|
||||
}
|
||||
if allowedChatID == 0 {
|
||||
sendMsg(bot, chatID, "Send /start first.")
|
||||
continue
|
||||
}
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// ── Refresh user before every AI call ────────────────────────────────
|
||||
resolveBotUser()
|
||||
if botUserID == "" {
|
||||
sendMsg(bot, chatID, "No account found. Open the web dashboard to register.")
|
||||
continue
|
||||
}
|
||||
|
||||
lang := st.TelegramConfig().GetLanguage()
|
||||
|
||||
// ── Guard: show status if not ready for trading ───────────────────────
|
||||
if newLLMClient(st, botUserID) == nil {
|
||||
sendMarkdownMsg(bot, chatID, statusMsg(st, botUserID, cfg.APIServerPort, lang))
|
||||
continue
|
||||
}
|
||||
|
||||
// ── AI agent ─────────────────────────────────────────────────────────
|
||||
go func(chatID int64, text string) {
|
||||
sent, err := bot.Send(tgbotapi.NewMessage(chatID, "⏳"))
|
||||
placeholderID := 0
|
||||
if err == nil {
|
||||
placeholderID = sent.MessageID
|
||||
}
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
lastEdit time.Time
|
||||
)
|
||||
onChunk := func(accumulated string) {
|
||||
if placeholderID == 0 {
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if accumulated != "⏳" && time.Since(lastEdit) < time.Second {
|
||||
return
|
||||
}
|
||||
lastEdit = time.Now()
|
||||
edit := tgbotapi.NewEditMessageText(chatID, placeholderID, accumulated)
|
||||
bot.Send(edit) //nolint:errcheck
|
||||
}
|
||||
|
||||
reply := agents.Run(chatID, text, onChunk)
|
||||
|
||||
if placeholderID != 0 {
|
||||
edit := tgbotapi.NewEditMessageText(chatID, placeholderID, reply)
|
||||
edit.ParseMode = "Markdown"
|
||||
if _, err := bot.Send(edit); err != nil {
|
||||
edit2 := tgbotapi.NewEditMessageText(chatID, placeholderID, reply)
|
||||
bot.Send(edit2) //nolint:errcheck
|
||||
}
|
||||
} else {
|
||||
msg := tgbotapi.NewMessage(chatID, reply)
|
||||
msg.ParseMode = "Markdown"
|
||||
if _, err := bot.Send(msg); err != nil {
|
||||
msg.ParseMode = ""
|
||||
bot.Send(msg) //nolint:errcheck
|
||||
}
|
||||
}
|
||||
}(chatID, text)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
func sendMsg(bot *tgbotapi.BotAPI, chatID int64, text string) {
|
||||
msg := tgbotapi.NewMessage(chatID, text)
|
||||
bot.Send(msg) //nolint:errcheck
|
||||
}
|
||||
|
||||
func sendMarkdownMsg(bot *tgbotapi.BotAPI, chatID int64, text string) {
|
||||
msg := tgbotapi.NewMessage(chatID, text)
|
||||
msg.ParseMode = "Markdown"
|
||||
if _, err := bot.Send(msg); err != nil {
|
||||
plain := tgbotapi.NewMessage(chatID, text)
|
||||
bot.Send(plain) //nolint:errcheck
|
||||
}
|
||||
}
|
||||
|
||||
// ── LLM client ───────────────────────────────────────────────────────────────
|
||||
|
||||
func newLLMClient(st *store.Store, userID string) mcp.AIClient {
|
||||
// 1. Prefer the model explicitly configured for Telegram (Settings → Telegram → AI Model)
|
||||
if tgCfg, err := st.TelegramConfig().Get(); err == nil && tgCfg.ModelID != "" {
|
||||
if model, err := st.AIModel().Get(userID, tgCfg.ModelID); err == nil && model.Enabled {
|
||||
apiKey := string(model.APIKey)
|
||||
if apiKey != "" {
|
||||
client := clientForProvider(model.Provider)
|
||||
client.SetAPIKey(apiKey, model.CustomAPIURL, model.CustomModelName)
|
||||
if isUSDCProvider(model.Provider) {
|
||||
logger.Infof("Telegram agent: provider=%s (USDC payment) user=%s", model.Provider, userID)
|
||||
} else {
|
||||
logger.Infof("Telegram agent: provider=%s user=%s", model.Provider, userID)
|
||||
}
|
||||
return client
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Fall back to first enabled model
|
||||
if model, err := st.AIModel().GetDefault(userID); err == nil {
|
||||
apiKey := string(model.APIKey)
|
||||
if apiKey != "" {
|
||||
client := clientForProvider(model.Provider)
|
||||
client.SetAPIKey(apiKey, model.CustomAPIURL, model.CustomModelName)
|
||||
if isUSDCProvider(model.Provider) {
|
||||
logger.Infof("Telegram agent: provider=%s (USDC payment) user=%s", model.Provider, userID)
|
||||
} else {
|
||||
logger.Infof("Telegram agent: provider=%s user=%s", model.Provider, userID)
|
||||
}
|
||||
return client
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Environment variable fallback
|
||||
for _, pair := range []struct{ provider, key, url string }{
|
||||
{"deepseek", os.Getenv("DEEPSEEK_API_KEY"), mcp.DefaultDeepSeekBaseURL},
|
||||
{"openai", os.Getenv("OPENAI_API_KEY"), ""},
|
||||
{"claude", os.Getenv("ANTHROPIC_API_KEY"), ""},
|
||||
} {
|
||||
if pair.key != "" {
|
||||
client := clientForProvider(pair.provider)
|
||||
client.SetAPIKey(pair.key, pair.url, "")
|
||||
return client
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isUSDCProvider returns true for providers that pay per call with USDC (x402 protocol).
|
||||
func isUSDCProvider(provider string) bool {
|
||||
return provider == "blockrun-base" || provider == "blockrun-sol" || provider == "claw402"
|
||||
}
|
||||
|
||||
func clientForProvider(provider string) mcp.AIClient {
|
||||
switch provider {
|
||||
case "openai":
|
||||
return mcp.NewOpenAIClient()
|
||||
case "deepseek":
|
||||
return mcp.NewDeepSeekClient()
|
||||
case "claude":
|
||||
return mcp.NewClaudeClient()
|
||||
case "qwen":
|
||||
return mcp.NewQwenClient()
|
||||
case "kimi":
|
||||
return mcp.NewKimiClient()
|
||||
case "grok":
|
||||
return mcp.NewGrokClient()
|
||||
case "gemini":
|
||||
return mcp.NewGeminiClient()
|
||||
case "minimax":
|
||||
return mcp.NewMiniMaxClient()
|
||||
case "blockrun-base":
|
||||
return mcp.NewBlockRunBaseClient()
|
||||
case "blockrun-sol":
|
||||
return mcp.NewBlockRunSolClient()
|
||||
case "claw402":
|
||||
return mcp.NewClaw402Client()
|
||||
default:
|
||||
return mcp.NewDeepSeekClient()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Status message ────────────────────────────────────────────────────────────
|
||||
|
||||
// statusMsg is the single entry-point message shown after /start.
|
||||
// It checks what's configured and shows either a setup prompt or the ready state.
|
||||
func statusMsg(st *store.Store, userID string, apiPort int, lang string) string {
|
||||
webURL := "http://localhost:3000"
|
||||
|
||||
// Determine what's missing.
|
||||
hasModel := false
|
||||
if _, err := st.AIModel().GetDefault(userID); err == nil {
|
||||
hasModel = true
|
||||
}
|
||||
|
||||
hasExchange := false
|
||||
if exchanges, err := st.Exchange().List(userID); err == nil {
|
||||
for _, e := range exchanges {
|
||||
if e.Enabled {
|
||||
hasExchange = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !hasModel || !hasExchange {
|
||||
missing := ""
|
||||
if lang == "zh" {
|
||||
if !hasModel {
|
||||
missing += "\n❌ AI 模型 → 设置 → AI 模型 → 添加"
|
||||
}
|
||||
if !hasExchange {
|
||||
missing += "\n❌ 交易所 → 设置 → 交易所 → 添加"
|
||||
}
|
||||
return "⚙️ *需要完成初始配置*\n\n打开 Web 管理界面完成配置:\n→ " + webURL + "\n" + missing + "\n\n配置完成后发送 /start"
|
||||
}
|
||||
if !hasModel {
|
||||
missing += "\n❌ AI Model → Settings → AI Models → Add"
|
||||
}
|
||||
if !hasExchange {
|
||||
missing += "\n❌ Exchange → Settings → Exchanges → Add"
|
||||
}
|
||||
return "⚙️ *Setup required*\n\nOpen the web dashboard to complete setup:\n→ " + webURL + "\n" + missing + "\n\nSend /start when done."
|
||||
}
|
||||
|
||||
// All configured — show ready state.
|
||||
if lang == "zh" {
|
||||
return `✅ *NOFX 就绪,开始交易吧!*
|
||||
|
||||
直接告诉我你想做什么:
|
||||
|
||||
📊 "查看我的持仓"
|
||||
💰 "账户余额多少"
|
||||
🤖 "帮我创建 BTC 趋势策略并启动"
|
||||
⏹ "停止所有交易员"
|
||||
|
||||
/help 查看更多 · /lang 切换语言`
|
||||
}
|
||||
return `✅ *NOFX is ready!*
|
||||
|
||||
Just tell me what you want:
|
||||
|
||||
📊 "Show my positions"
|
||||
💰 "What's my balance?"
|
||||
🤖 "Create a BTC trend strategy and start it"
|
||||
⏹ "Stop all traders"
|
||||
|
||||
/help for more · /lang to change language`
|
||||
}
|
||||
|
||||
// ── Language ──────────────────────────────────────────────────────────────────
|
||||
|
||||
func langMenuMsg() string {
|
||||
return "🌐 *Choose your language*\n\n1 — English\n2 — 中文\n\nReply with 1 or 2"
|
||||
}
|
||||
|
||||
func parseLangChoice(text string) string {
|
||||
switch strings.TrimSpace(text) {
|
||||
case "1", "en", "EN", "English", "english":
|
||||
return "en"
|
||||
case "2", "zh", "ZH", "中文", "chinese", "Chinese":
|
||||
return "zh"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ── Help ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
func helpMsg(lang string) string {
|
||||
if lang == "zh" {
|
||||
return `*NOFX 使用指南*
|
||||
|
||||
*查询*
|
||||
• "查看我的持仓"
|
||||
• "账户余额多少"
|
||||
• "列出我的交易员"
|
||||
|
||||
*创建 & 启动*
|
||||
• "帮我创建 BTC 趋势策略并跑起来"
|
||||
• "保守型策略,只交易 BTC 和 ETH"
|
||||
|
||||
*控制*
|
||||
• "启动交易员"
|
||||
• "暂停交易员"
|
||||
• "停止所有交易"
|
||||
|
||||
*命令*
|
||||
/start — 刷新状态
|
||||
/lang — 切换语言
|
||||
/help — 帮助`
|
||||
}
|
||||
return `*NOFX Help*
|
||||
|
||||
*Query*
|
||||
• "Show my positions"
|
||||
• "What's my balance?"
|
||||
• "List my traders"
|
||||
|
||||
*Create & start*
|
||||
• "Create a BTC trend strategy and start it"
|
||||
• "Conservative strategy, BTC and ETH only"
|
||||
|
||||
*Control*
|
||||
• "Start trader"
|
||||
• "Stop trader"
|
||||
• "Stop all trading"
|
||||
|
||||
*Commands*
|
||||
/start — refresh status
|
||||
/lang — change language
|
||||
/help — show this`
|
||||
}
|
||||
105
telegram/session/memory.go
Normal file
105
telegram/session/memory.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package session
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"nofx/mcp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
compactionThresholdTokens = 3000
|
||||
charsPerToken = 3 // rough estimate for token counting
|
||||
)
|
||||
|
||||
type Message struct {
|
||||
Role string // "user" or "assistant"
|
||||
Content string
|
||||
}
|
||||
|
||||
// Memory manages conversation history with automatic compaction.
|
||||
// Inspired by openclaw's compaction pattern:
|
||||
// when ShortTerm exceeds threshold, LLM silently summarizes it into LongTerm.
|
||||
type Memory struct {
|
||||
LongTerm string // Durable summary (survives compaction, user never sees this happen)
|
||||
ShortTerm []Message // Recent conversation (cleared on compaction)
|
||||
llm mcp.AIClient
|
||||
}
|
||||
|
||||
func NewMemory(llm mcp.AIClient) *Memory {
|
||||
return &Memory{llm: llm}
|
||||
}
|
||||
|
||||
// Add appends a message and triggers compaction if threshold exceeded
|
||||
func (m *Memory) Add(role, content string) {
|
||||
m.ShortTerm = append(m.ShortTerm, Message{Role: role, Content: content})
|
||||
if m.estimateTokens() > compactionThresholdTokens {
|
||||
m.compact()
|
||||
}
|
||||
}
|
||||
|
||||
// BuildContext returns context string for the agent's conversation history.
|
||||
func (m *Memory) BuildContext() string {
|
||||
var sb strings.Builder
|
||||
if m.LongTerm != "" {
|
||||
sb.WriteString("[Summary of earlier conversation]\n")
|
||||
sb.WriteString(m.LongTerm)
|
||||
sb.WriteString("\n\n")
|
||||
}
|
||||
if len(m.ShortTerm) > 0 {
|
||||
sb.WriteString("[Recent conversation]\n")
|
||||
for _, msg := range m.ShortTerm {
|
||||
sb.WriteString(fmt.Sprintf("%s: %s\n", msg.Role, msg.Content))
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// Reset clears short-term history (LongTerm preserved intentionally)
|
||||
func (m *Memory) Reset() {
|
||||
m.ShortTerm = []Message{}
|
||||
}
|
||||
|
||||
// ResetFull clears everything including long-term memory
|
||||
func (m *Memory) ResetFull() {
|
||||
m.ShortTerm = []Message{}
|
||||
m.LongTerm = ""
|
||||
}
|
||||
|
||||
func (m *Memory) estimateTokens() int {
|
||||
total := len(m.LongTerm)
|
||||
for _, msg := range m.ShortTerm {
|
||||
total += len(msg.Content)
|
||||
}
|
||||
return total / charsPerToken
|
||||
}
|
||||
|
||||
// compact summarizes short-term history into long-term memory.
|
||||
// This runs silently - the user never sees it happen.
|
||||
// If LLM call fails, short-term is preserved as-is (no data loss).
|
||||
func (m *Memory) compact() {
|
||||
if m.llm == nil || len(m.ShortTerm) == 0 {
|
||||
return
|
||||
}
|
||||
history := m.BuildContext()
|
||||
systemPrompt := `You are a conversation summarizer. Compress the following trading assistant conversation into a concise summary.
|
||||
|
||||
Must preserve:
|
||||
- What the user is configuring (strategy/exchange/model/trader)
|
||||
- Confirmed parameters (trading pairs, leverage, stop loss, indicators, etc.)
|
||||
- Pending or missing parameters
|
||||
- User preferences and requirements
|
||||
|
||||
Output: plain text summary, under 200 words.`
|
||||
|
||||
summary, err := m.llm.CallWithMessages(systemPrompt, history)
|
||||
if err != nil {
|
||||
// Compaction failed: keep short-term as-is, never lose user data
|
||||
return
|
||||
}
|
||||
if m.LongTerm != "" {
|
||||
m.LongTerm = m.LongTerm + "\n" + summary
|
||||
} else {
|
||||
m.LongTerm = summary
|
||||
}
|
||||
m.ShortTerm = []Message{}
|
||||
}
|
||||
@@ -216,6 +216,11 @@ func NewAutoTrader(config AutoTraderConfig, st *store.Store, userID string) (*Au
|
||||
mcpClient.SetAPIKey(config.CustomAPIKey, "", config.CustomModelName)
|
||||
logger.Infof("🤖 [%s] Using BlockRun (Solana Wallet) AI", config.Name)
|
||||
|
||||
case "claw402":
|
||||
mcpClient = mcp.NewClaw402Client()
|
||||
mcpClient.SetAPIKey(config.CustomAPIKey, "", config.CustomModelName)
|
||||
logger.Infof("🤖 [%s] Using Claw402 (Base USDC) AI", config.Name)
|
||||
|
||||
case "qwen":
|
||||
mcpClient = mcp.NewQwenClient()
|
||||
apiKey := config.QwenKey
|
||||
|
||||
BIN
web/public/icons/claw402.png
Normal file
BIN
web/public/icons/claw402.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 797 KiB |
17
web/public/icons/claw402.svg
Normal file
17
web/public/icons/claw402.svg
Normal file
@@ -0,0 +1,17 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" fill="none">
|
||||
<defs>
|
||||
<linearGradient id="claw402_bg" x1="0" y1="0" x2="120" y2="120" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="#2563EB"/>
|
||||
<stop offset="100%" stop-color="#7C3AED"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="120" height="120" rx="24" fill="url(#claw402_bg)"/>
|
||||
<!-- Diamond/gem shape -->
|
||||
<path d="M60 22L88 50L60 98L32 50L60 22Z" fill="white" fill-opacity="0.95"/>
|
||||
<path d="M60 22L88 50L60 50L32 50L60 22Z" fill="white" fill-opacity="0.7"/>
|
||||
<path d="M60 50L88 50L60 98Z" fill="white" fill-opacity="0.85"/>
|
||||
<path d="M60 50L32 50L60 98Z" fill="white" fill-opacity="0.6"/>
|
||||
<!-- Subtle USDC circle hint -->
|
||||
<circle cx="60" cy="58" r="12" fill="none" stroke="white" stroke-width="2" stroke-opacity="0.3"/>
|
||||
<text x="60" y="63" text-anchor="middle" fill="white" fill-opacity="0.4" font-size="12" font-weight="700" font-family="system-ui">$</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 981 B |
@@ -6,7 +6,8 @@ import { TraderDashboardPage } from './pages/TraderDashboardPage'
|
||||
|
||||
import { AITradersPage } from './components/AITradersPage'
|
||||
import { LoginPage } from './components/LoginPage'
|
||||
import { RegisterPage } from './components/RegisterPage'
|
||||
import { SetupPage } from './components/SetupPage'
|
||||
import { SettingsPage } from './pages/SettingsPage'
|
||||
import { ResetPasswordPage } from './components/ResetPasswordPage'
|
||||
import { CompetitionPage } from './components/CompetitionPage'
|
||||
import { LandingPage } from './pages/LandingPage'
|
||||
@@ -53,7 +54,7 @@ type Page =
|
||||
function App() {
|
||||
const { language, setLanguage } = useLanguage()
|
||||
const { user, token, logout, isLoading } = useAuth()
|
||||
const { loading: configLoading } = useSystemConfig()
|
||||
const { config: systemConfig, loading: configLoading } = useSystemConfig()
|
||||
const [route, setRoute] = useState(window.location.pathname)
|
||||
|
||||
// Debug log
|
||||
@@ -341,12 +342,22 @@ function App() {
|
||||
)
|
||||
}
|
||||
|
||||
// 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 === '/register') {
|
||||
return <RegisterPage />
|
||||
if (route === '/setup') {
|
||||
// If already initialized, redirect to login
|
||||
if (systemConfig?.initialized) {
|
||||
window.location.href = '/login'
|
||||
return null
|
||||
}
|
||||
return <SetupPage />
|
||||
}
|
||||
if (route === '/faq') {
|
||||
return (
|
||||
@@ -376,6 +387,26 @@ function App() {
|
||||
if (route === '/reset-password') {
|
||||
return <ResetPasswordPage />
|
||||
}
|
||||
if (route === '/settings') {
|
||||
if (!user || !token) {
|
||||
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) => {
|
||||
|
||||
@@ -16,6 +16,7 @@ import { getModelIcon } from './ModelIcons'
|
||||
import { TraderConfigModal } from './TraderConfigModal'
|
||||
import { DeepVoidBackground } from './DeepVoidBackground'
|
||||
import { ExchangeConfigModal } from './traders/ExchangeConfigModal'
|
||||
import { TelegramConfigModal } from './traders/TelegramConfigModal'
|
||||
import { PunkAvatar, getTraderAvatar } from './PunkAvatar'
|
||||
import {
|
||||
Bot,
|
||||
@@ -31,6 +32,7 @@ import {
|
||||
ExternalLink,
|
||||
Copy,
|
||||
Check,
|
||||
MessageCircle,
|
||||
} from 'lucide-react'
|
||||
import { confirmToast } from '../lib/notify'
|
||||
import { toast } from 'sonner'
|
||||
@@ -65,6 +67,22 @@ const BLOCKRUN_MODELS = [
|
||||
{ id: 'minimax-m2.5', name: 'MiniMax M2.5', desc: 'MiniMax · Flagship' },
|
||||
]
|
||||
|
||||
// Models available through Claw402 (x402 USDC payment protocol)
|
||||
const CLAW402_MODELS = [
|
||||
{ id: 'gpt-5.4', name: 'GPT-5.4', provider: 'OpenAI', desc: 'Flagship · Fast', icon: '⚡' },
|
||||
{ id: 'gpt-5.4-pro', name: 'GPT-5.4 Pro', provider: 'OpenAI', desc: 'Reasoning · Pro', icon: '🧠' },
|
||||
{ id: 'gpt-5.3', name: 'GPT-5.3', provider: 'OpenAI', desc: 'Balanced', icon: '💡' },
|
||||
{ id: 'gpt-5-mini', name: 'GPT-5 Mini', provider: 'OpenAI', desc: 'Fast · Cheap', icon: '🚀' },
|
||||
{ id: 'claude-opus', name: 'Claude Opus', provider: 'Anthropic', desc: 'Flagship · Deep', icon: '🎯' },
|
||||
{ id: 'deepseek', name: 'DeepSeek V3', provider: 'DeepSeek', desc: 'Best Value', icon: '🔥' },
|
||||
{ id: 'deepseek-reasoner', name: 'DeepSeek R1', provider: 'DeepSeek', desc: 'Reasoning', icon: '🤔' },
|
||||
{ id: 'qwen-max', name: 'Qwen Max', provider: 'Alibaba', desc: 'Flagship', icon: '🌟' },
|
||||
{ id: 'qwen-plus', name: 'Qwen Plus', provider: 'Alibaba', desc: 'Balanced', icon: '✨' },
|
||||
{ id: 'grok-4.1', name: 'Grok 4.1', provider: 'xAI', desc: 'Flagship', icon: '⚡' },
|
||||
{ id: 'gemini-3.1-pro', name: 'Gemini 3.1 Pro', provider: 'Google', desc: 'Flagship', icon: '💎' },
|
||||
{ id: 'kimi-k2.5', name: 'Kimi K2.5', provider: 'Moonshot', desc: 'Balanced', icon: '🌙' },
|
||||
]
|
||||
|
||||
// AI Provider configuration - default models and API links
|
||||
const AI_PROVIDER_CONFIG: Record<string, {
|
||||
defaultModel: string
|
||||
@@ -111,6 +129,11 @@ const AI_PROVIDER_CONFIG: Record<string, {
|
||||
apiUrl: 'https://platform.minimax.io',
|
||||
apiName: 'MiniMax',
|
||||
},
|
||||
claw402: {
|
||||
defaultModel: 'deepseek',
|
||||
apiUrl: 'https://claw402.ai',
|
||||
apiName: 'Claw402',
|
||||
},
|
||||
'blockrun-base': {
|
||||
defaultModel: 'gpt-5.4',
|
||||
apiUrl: 'https://blockrun.ai',
|
||||
@@ -173,6 +196,7 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
||||
const [showEditModal, setShowEditModal] = useState(false)
|
||||
const [showModelModal, setShowModelModal] = useState(false)
|
||||
const [showExchangeModal, setShowExchangeModal] = useState(false)
|
||||
const [showTelegramModal, setShowTelegramModal] = useState(false)
|
||||
const [editingModel, setEditingModel] = useState<string | null>(null)
|
||||
const [editingExchange, setEditingExchange] = useState<string | null>(null)
|
||||
const [editingTrader, setEditingTrader] = useState<any>(null)
|
||||
@@ -874,6 +898,16 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setShowTelegramModal(true)}
|
||||
className="px-4 py-2 rounded text-xs font-mono uppercase tracking-wider transition-all border border-sky-900/50 bg-black/20 text-sky-500 hover:text-sky-300 hover:border-sky-700 whitespace-nowrap backdrop-blur-sm"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageCircle className="w-3 h-3" />
|
||||
<span>TELEGRAM_BOT</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
disabled={configuredModels.length === 0 || configuredExchanges.length === 0}
|
||||
@@ -1404,6 +1438,14 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
||||
language={language}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Telegram Bot Modal */}
|
||||
{showTelegramModal && (
|
||||
<TelegramConfigModal
|
||||
onClose={() => setShowTelegramModal(false)}
|
||||
language={language}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</DeepVoidBackground>
|
||||
)
|
||||
@@ -1503,7 +1545,7 @@ function ModelCard({
|
||||
}
|
||||
|
||||
// Model Configuration Modal Component
|
||||
function ModelConfigModal({
|
||||
export function ModelConfigModal({
|
||||
allModels,
|
||||
configuredModels,
|
||||
editingModelId,
|
||||
@@ -1531,9 +1573,11 @@ function ModelConfigModal({
|
||||
const [baseUrl, setBaseUrl] = useState('')
|
||||
const [modelName, setModelName] = useState('')
|
||||
|
||||
const selectedModel = editingModelId
|
||||
? configuredModels?.find((m) => m.id === selectedModelId)
|
||||
: allModels?.find((m) => m.id === selectedModelId)
|
||||
// Always prefer allModels (supportedModels) for provider/id lookup;
|
||||
// fall back to configuredModels for edit mode details (apiKey etc.)
|
||||
const selectedModel =
|
||||
allModels?.find((m) => m.id === selectedModelId) ||
|
||||
configuredModels?.find((m) => m.id === selectedModelId)
|
||||
|
||||
useEffect(() => {
|
||||
if (editingModelId && selectedModel) {
|
||||
@@ -1619,8 +1663,53 @@ function ModelConfigModal({
|
||||
<div className="text-sm font-semibold" style={{ color: '#EAECEF' }}>
|
||||
{language === 'zh' ? '选择 AI 模型提供商' : 'Choose Your AI Provider'}
|
||||
</div>
|
||||
|
||||
{/* Claw402 Featured Card — always first, always prominent */}
|
||||
{availableModels.some(m => m.provider === 'claw402') && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const claw = availableModels.find(m => m.provider === 'claw402')
|
||||
if (claw) handleSelectModel(claw.id)
|
||||
}}
|
||||
className="w-full p-5 rounded-xl text-left transition-all hover:scale-[1.01]"
|
||||
style={{ background: 'linear-gradient(135deg, rgba(37, 99, 235, 0.15) 0%, rgba(139, 92, 246, 0.15) 100%)', border: '1.5px solid rgba(37, 99, 235, 0.4)' }}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl flex items-center justify-center overflow-hidden">
|
||||
<img src="/icons/claw402.png" alt="Claw402" width={40} height={40} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-bold text-base" style={{ color: '#EAECEF' }}>
|
||||
Claw402
|
||||
</div>
|
||||
<div className="text-xs mt-0.5" style={{ color: '#A0AEC0' }}>
|
||||
{language === 'zh'
|
||||
? 'USDC 按次付费 · 支持全部 AI 模型 · 无需 API Key'
|
||||
: 'Pay-per-call USDC · All AI Models · No API Key'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{configuredIds.has(availableModels.find(m => m.provider === 'claw402')?.id || '') && (
|
||||
<div className="w-2 h-2 rounded-full" style={{ background: '#00E096' }} />
|
||||
)}
|
||||
<div className="px-3 py-1.5 rounded-full text-xs font-bold" style={{ background: 'linear-gradient(135deg, #2563EB, #7C3AED)', color: '#fff' }}>
|
||||
{language === 'zh' ? '🔥 推荐' : '🔥 Best'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-3 ml-[52px]">
|
||||
<span className="text-[11px] px-2 py-0.5 rounded-full" style={{ background: 'rgba(0, 224, 150, 0.1)', color: '#00E096', border: '1px solid rgba(0, 224, 150, 0.2)' }}>
|
||||
GPT · Claude · DeepSeek · Gemini · Grok · Qwen · Kimi
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-3 sm:grid-cols-4 gap-3">
|
||||
{availableModels.filter(m => !m.provider?.startsWith('blockrun')).map((model) => (
|
||||
{availableModels.filter(m => !m.provider?.startsWith('blockrun') && m.provider !== 'claw402').map((model) => (
|
||||
<ModelCard
|
||||
key={model.id}
|
||||
model={model}
|
||||
@@ -1658,8 +1747,169 @@ function ModelConfigModal({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 1: Configure */}
|
||||
{(currentStep === 1 || editingModelId) && selectedModel && (
|
||||
{/* Step 1: Configure — Claw402 Dedicated UI */}
|
||||
{(currentStep === 1 || editingModelId) && selectedModel && (selectedModel.provider === 'claw402' || selectedModel.id === 'claw402') && (
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{/* Claw402 Hero Header */}
|
||||
<div className="p-5 rounded-xl text-center" style={{ background: 'linear-gradient(135deg, rgba(37, 99, 235, 0.12) 0%, rgba(139, 92, 246, 0.12) 100%)', border: '1px solid rgba(37, 99, 235, 0.3)' }}>
|
||||
<div className="w-14 h-14 mx-auto rounded-2xl flex items-center justify-center mb-3 overflow-hidden">
|
||||
<img src="/icons/claw402.png" alt="Claw402" width={56} height={56} />
|
||||
</div>
|
||||
<div className="text-lg font-bold" style={{ color: '#EAECEF' }}>
|
||||
Claw402
|
||||
</div>
|
||||
<div className="text-sm mt-1" style={{ color: '#A0AEC0' }}>
|
||||
{language === 'zh'
|
||||
? '用 USDC 按次付费,支持所有主流 AI 模型'
|
||||
: 'Pay-per-call with USDC — supports all major AI models'}
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-3 mt-3 flex-wrap">
|
||||
{['GPT', 'Claude', 'DeepSeek', 'Gemini', 'Grok', 'Qwen', 'Kimi'].map(name => (
|
||||
<span key={name} className="text-[11px] px-2 py-0.5 rounded-full" style={{ background: 'rgba(255,255,255,0.06)', color: '#A0AEC0' }}>
|
||||
{name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step 1: Select AI Model */}
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-center gap-2 text-sm font-semibold" style={{ color: '#EAECEF' }}>
|
||||
<Brain className="w-4 h-4" style={{ color: '#2563EB' }} />
|
||||
{language === 'zh' ? '① 选择 AI 模型' : '① Choose AI Model'}
|
||||
</label>
|
||||
<div className="text-xs mb-2" style={{ color: '#848E9C' }}>
|
||||
{language === 'zh'
|
||||
? '所有模型通过 Claw402 统一调用,创建后可随时切换'
|
||||
: 'All models unified via Claw402. Switch anytime after setup.'}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||
{CLAW402_MODELS.map((m) => {
|
||||
const isSelected = (modelName || 'deepseek') === m.id
|
||||
return (
|
||||
<button
|
||||
key={m.id}
|
||||
type="button"
|
||||
onClick={() => setModelName(m.id)}
|
||||
className="flex items-start gap-2 px-3 py-2.5 rounded-xl text-left transition-all hover:scale-[1.02]"
|
||||
style={{
|
||||
background: isSelected ? 'rgba(37, 99, 235, 0.2)' : '#0B0E11',
|
||||
border: isSelected ? '1.5px solid #2563EB' : '1px solid #2B3139',
|
||||
}}
|
||||
>
|
||||
<span className="text-base mt-0.5">{m.icon}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xs font-semibold truncate" style={{ color: isSelected ? '#60A5FA' : '#EAECEF' }}>
|
||||
{m.name}
|
||||
</div>
|
||||
<div className="text-[10px] truncate" style={{ color: '#848E9C' }}>
|
||||
{m.provider} · {m.desc}
|
||||
</div>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<span className="text-[10px] mt-1" style={{ color: '#60A5FA' }}>✓</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step 2: Wallet Setup */}
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-center gap-2 text-sm font-semibold" style={{ color: '#EAECEF' }}>
|
||||
<svg className="w-4 h-4" style={{ color: '#2563EB' }} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z" />
|
||||
</svg>
|
||||
{language === 'zh' ? '② 设置钱包' : '② Setup Wallet'}
|
||||
</label>
|
||||
|
||||
<div className="p-3 rounded-xl" style={{ background: 'rgba(37, 99, 235, 0.06)', border: '1px solid rgba(37, 99, 235, 0.15)' }}>
|
||||
<div className="text-xs mb-2" style={{ color: '#A0AEC0' }}>
|
||||
{language === 'zh'
|
||||
? '💡 Claw402 使用 Base 链上的 USDC 付费,你需要一个 EVM 钱包'
|
||||
: '💡 Claw402 uses USDC on Base chain. You need an EVM wallet.'}
|
||||
</div>
|
||||
<div className="text-xs space-y-1" style={{ color: '#848E9C' }}>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span style={{ color: '#00E096' }}>•</span>
|
||||
{language === 'zh'
|
||||
? '可以用 MetaMask、Rabby 等钱包导出私钥'
|
||||
: 'Export private key from MetaMask, Rabby, etc.'}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span style={{ color: '#00E096' }}>•</span>
|
||||
{language === 'zh'
|
||||
? '建议新建一个专用钱包,充入少量 USDC 即可'
|
||||
: 'Recommended: create a dedicated wallet with a small USDC balance'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-xs font-medium" style={{ color: '#A0AEC0' }}>
|
||||
{language === 'zh' ? '钱包私钥(Base 链 EVM)' : 'Wallet Private Key (Base Chain EVM)'}
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
placeholder="0x..."
|
||||
className="w-full px-4 py-3 rounded-xl font-mono text-sm"
|
||||
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||
required
|
||||
/>
|
||||
<div className="flex items-start gap-1.5 text-[11px]" style={{ color: '#848E9C' }}>
|
||||
<span className="mt-px">🔒</span>
|
||||
<span>
|
||||
{language === 'zh'
|
||||
? '私钥仅在本地签名使用,不会上传或发送交易。无需 ETH,无 Gas 费用。'
|
||||
: 'Private key is only used locally for signing. Never uploaded. No ETH or gas needed.'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* USDC Recharge Guide */}
|
||||
<div className="p-4 rounded-xl" style={{ background: 'rgba(0, 224, 150, 0.05)', border: '1px solid rgba(0, 224, 150, 0.15)' }}>
|
||||
<div className="text-sm font-semibold mb-2 flex items-center gap-2" style={{ color: '#00E096' }}>
|
||||
💰 {language === 'zh' ? '如何充值 USDC' : 'How to Fund USDC'}
|
||||
</div>
|
||||
<div className="text-xs space-y-1.5" style={{ color: '#848E9C' }}>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="font-bold" style={{ color: '#A0AEC0' }}>1.</span>
|
||||
<span>{language === 'zh' ? '从交易所(Binance / OKX / Coinbase)提 USDC 到你的钱包地址' : 'Withdraw USDC from exchange (Binance/OKX/Coinbase) to your wallet'}</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="font-bold" style={{ color: '#A0AEC0' }}>2.</span>
|
||||
<span>{language === 'zh' ? '选择 Base 网络(手续费极低)' : 'Select Base network (very low fees)'}</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="font-bold" style={{ color: '#A0AEC0' }}>3.</span>
|
||||
<span>{language === 'zh' ? '充入 $5-10 USDC 即可使用很长时间(约 $0.003/次调用)' : '$5-10 USDC lasts a long time (~$0.003/call)'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button type="button" onClick={handleBack} className="flex-1 px-4 py-3 rounded-xl text-sm font-semibold transition-all hover:bg-white/5" style={{ background: '#2B3139', color: '#848E9C' }}>
|
||||
{editingModelId ? t('cancel', language) : (language === 'zh' ? '返回' : 'Back')}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!apiKey.trim()}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-xl text-sm font-bold transition-all hover:scale-[1.02] disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
style={{ background: apiKey.trim() ? 'linear-gradient(135deg, #2563EB, #7C3AED)' : '#2B3139', color: '#fff' }}
|
||||
>
|
||||
{language === 'zh' ? '🚀 开始交易' : '🚀 Start Trading'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Step 1: Configure — Standard Providers (non-claw402) */}
|
||||
{(currentStep === 1 || editingModelId) && selectedModel && selectedModel.provider !== 'claw402' && selectedModel.id !== 'claw402' && (
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{/* Selected Model Header */}
|
||||
<div className="p-4 rounded-xl flex items-center gap-4" style={{ background: '#0B0E11', border: '1px solid #2B3139' }}>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { Menu, X, ChevronDown } from 'lucide-react'
|
||||
import { Menu, X, ChevronDown, Settings } from 'lucide-react'
|
||||
import { t, type Language } from '../i18n/translations'
|
||||
import { useSystemConfig } from '../hooks/useSystemConfig'
|
||||
import { OFFICIAL_LINKS } from '../constants/branding'
|
||||
|
||||
type Page =
|
||||
@@ -49,9 +48,6 @@ export default function HeaderBar({
|
||||
const [userDropdownOpen, setUserDropdownOpen] = useState(false)
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
const userDropdownRef = useRef<HTMLDivElement>(null)
|
||||
const { config: systemConfig } = useSystemConfig()
|
||||
const registrationEnabled = systemConfig?.registration_enabled !== false
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
@@ -214,6 +210,16 @@ export default function HeaderBar({
|
||||
{user.email}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
window.location.href = '/settings'
|
||||
setUserDropdownOpen(false)
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm transition-colors hover:bg-white/5 text-nofx-text-muted hover:text-white"
|
||||
>
|
||||
<Settings className="w-3.5 h-3.5" />
|
||||
Settings
|
||||
</button>
|
||||
{onLogout && (
|
||||
<button
|
||||
onClick={() => {
|
||||
@@ -240,14 +246,6 @@ export default function HeaderBar({
|
||||
>
|
||||
{t('signIn', language)}
|
||||
</a>
|
||||
{registrationEnabled && (
|
||||
<a
|
||||
href="/register"
|
||||
className="px-4 py-2 rounded font-semibold text-sm transition-colors hover:opacity-90 bg-nofx-gold text-black"
|
||||
>
|
||||
{t('signUp', language)}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
@@ -1,277 +1,133 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Eye, EyeOff } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { useLanguage } from '../contexts/LanguageContext'
|
||||
import { t } from '../i18n/translations'
|
||||
import { Eye, EyeOff } from 'lucide-react'
|
||||
import { DeepVoidBackground } from './DeepVoidBackground'
|
||||
// import { Input } from './ui/input' // Removed unused import
|
||||
import { toast } from 'sonner'
|
||||
import { useSystemConfig } from '../hooks/useSystemConfig'
|
||||
|
||||
export function LoginPage() {
|
||||
const { language } = useLanguage()
|
||||
const { login, loginAdmin } = useAuth()
|
||||
const { login } = useAuth()
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [adminPassword, setAdminPassword] = useState('')
|
||||
const adminMode = false
|
||||
const { config: systemConfig } = useSystemConfig()
|
||||
const registrationEnabled = systemConfig?.registration_enabled !== false
|
||||
const [expiredToastId, setExpiredToastId] = useState<string | number | null>(null)
|
||||
|
||||
// Show notification if user was redirected here due to 401
|
||||
useEffect(() => {
|
||||
if (sessionStorage.getItem('from401') === 'true') {
|
||||
const id = toast.warning(t('sessionExpired', language), {
|
||||
duration: Infinity // Keep showing until user dismisses or logs in
|
||||
})
|
||||
const id = toast.warning(t('sessionExpired', language), { duration: Infinity })
|
||||
setExpiredToastId(id)
|
||||
sessionStorage.removeItem('from401')
|
||||
}
|
||||
}, [language])
|
||||
|
||||
const handleAdminLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
const result = await loginAdmin(adminPassword)
|
||||
if (!result.success) {
|
||||
const msg = result.message || t('loginFailed', language)
|
||||
setError(msg)
|
||||
toast.error(msg)
|
||||
} else {
|
||||
// Dismiss the "login expired" toast on successful login
|
||||
if (expiredToastId) {
|
||||
toast.dismiss(expiredToastId)
|
||||
}
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
|
||||
const result = await login(email, password)
|
||||
|
||||
setLoading(false)
|
||||
if (result.success) {
|
||||
// Dismiss the "login expired" toast on successful login.
|
||||
if (expiredToastId) {
|
||||
toast.dismiss(expiredToastId)
|
||||
}
|
||||
if (expiredToastId) toast.dismiss(expiredToastId)
|
||||
} else {
|
||||
const msg = result.message || t('loginFailed', language)
|
||||
setError(msg)
|
||||
toast.error(msg)
|
||||
}
|
||||
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<DeepVoidBackground className="min-h-screen flex items-center justify-center py-12 font-mono" disableAnimation>
|
||||
<DeepVoidBackground disableAnimation>
|
||||
<div className="flex-1 flex items-center justify-center px-4 py-16">
|
||||
<div className="w-full max-w-sm">
|
||||
|
||||
<div className="w-full max-w-md relative z-10 px-6">
|
||||
{/* Navigation - Top Bar (Mobile/Desktop Friendly) */}
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<button
|
||||
onClick={() => window.location.href = '/'}
|
||||
className="flex items-center gap-2 text-zinc-500 hover:text-white transition-colors group px-3 py-1.5 rounded border border-transparent hover:border-zinc-700 bg-black/20 backdrop-blur-sm"
|
||||
>
|
||||
<div className="w-2 h-2 rounded-full bg-red-500 group-hover:animate-pulse"></div>
|
||||
<span className="text-xs font-mono uppercase tracking-widest">< CANCEL_LOGIN</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Terminal Header */}
|
||||
<div className="mb-8 text-center">
|
||||
<div className="flex justify-center mb-6">
|
||||
<div className="relative">
|
||||
<div className="absolute -inset-2 bg-nofx-gold/20 rounded-full blur-xl animate-pulse"></div>
|
||||
<img
|
||||
src="/icons/nofx.svg"
|
||||
alt="NoFx Logo"
|
||||
className="w-16 h-16 object-contain relative z-10 opacity-90"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold tracking-tighter text-white uppercase mb-2">
|
||||
<span className="text-nofx-gold">SYSTEM</span> ACCESS
|
||||
</h1>
|
||||
<p className="text-zinc-500 text-xs tracking-[0.2em] uppercase">
|
||||
Authentication Protocol v3.0
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Terminal Output / Form Container */}
|
||||
<div className="bg-zinc-900/40 backdrop-blur-md border border-zinc-800 rounded-lg overflow-hidden shadow-2xl relative group">
|
||||
<div className="absolute inset-0 bg-zinc-900/50 opacity-0 group-hover:opacity-100 transition duration-700 pointer-events-none"></div>
|
||||
|
||||
{/* Window Bar */}
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-zinc-900/80 border-b border-zinc-800">
|
||||
<div className="flex gap-1.5">
|
||||
<div
|
||||
className="w-2.5 h-2.5 rounded-full bg-red-500/50 hover:bg-red-500 cursor-pointer transition-colors"
|
||||
onClick={() => window.location.href = '/'}
|
||||
title="Close / Return Home"
|
||||
></div>
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-yellow-500/50"></div>
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-green-500/50"></div>
|
||||
</div>
|
||||
<div className="text-[10px] text-zinc-600 font-mono flex items-center gap-1">
|
||||
<span className="text-emerald-500">➜</span> login.exe
|
||||
{/* 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" />
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<div className="p-6 md:p-8 relative">
|
||||
{/* Status Output */}
|
||||
<div className="mb-6 font-mono text-xs space-y-1 text-zinc-500 border-b border-zinc-800/50 pb-4">
|
||||
<div className="flex gap-2">
|
||||
<span className="text-emerald-500">➜</span>
|
||||
<span>Initiating handshake...</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<span className="text-emerald-500">➜</span>
|
||||
<span>Target: NOFX CORE HUB</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<span className="text-emerald-500">➜</span>
|
||||
<span>Status: <span className="text-zinc-300">AWAITING CREDENTIALS</span></span>
|
||||
</div>
|
||||
</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">
|
||||
|
||||
{adminMode ? (
|
||||
<form onSubmit={handleAdminLogin} className="space-y-5">
|
||||
<div>
|
||||
<label className="block text-xs uppercase tracking-wider text-nofx-gold mb-1.5 ml-1">Admin Key</label>
|
||||
{/* Email */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-zinc-400 mb-2">
|
||||
{t('email', language)}
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full bg-zinc-950/80 border border-zinc-700/80 rounded-xl px-4 py-3 text-sm text-white placeholder-zinc-600 focus:outline-none focus:border-nofx-gold/60 focus:ring-1 focus:ring-nofx-gold/30 transition-all"
|
||||
placeholder="you@example.com"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Password */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="text-xs font-medium text-zinc-400">
|
||||
{t('password', language)}
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => window.location.href = '/reset-password'}
|
||||
className="text-xs text-zinc-500 hover:text-nofx-gold transition-colors"
|
||||
>
|
||||
{t('forgotPassword', language)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="password"
|
||||
value={adminPassword}
|
||||
onChange={(e) => setAdminPassword(e.target.value)}
|
||||
className="w-full bg-black/50 border border-zinc-700 rounded px-4 py-3 text-sm focus:border-nofx-gold focus:ring-1 focus:ring-nofx-gold/50 outline-none transition-all placeholder-zinc-700 text-white font-mono"
|
||||
placeholder="ENTER_ROOT_PASSWORD"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full bg-zinc-950/80 border border-zinc-700/80 rounded-xl px-4 py-3 pr-11 text-sm text-white placeholder-zinc-600 focus:outline-none focus:border-nofx-gold/60 focus:ring-1 focus:ring-nofx-gold/30 transition-all"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3.5 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300 transition-colors"
|
||||
>
|
||||
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-xs bg-red-500/10 border border-red-500/30 text-red-500 px-3 py-2 rounded font-mono">
|
||||
[ERROR]: {error}
|
||||
</div>
|
||||
)}
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<p className="text-xs text-red-400 bg-red-500/10 border border-red-500/20 rounded-lg px-3 py-2">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-nofx-gold text-black font-bold py-3 px-4 rounded text-sm tracking-wide uppercase hover:bg-yellow-400 transition-all transform active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed font-mono shadow-[0_0_20px_rgba(255,215,0,0.1)] hover:shadow-[0_0_30px_rgba(255,215,0,0.3)]"
|
||||
>
|
||||
{loading ? '> VERIFYING...' : '> EXECUTE_LOGIN'}
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<form onSubmit={handleLogin} className="space-y-5">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs uppercase tracking-wider text-zinc-500 mb-1.5 ml-1 font-bold">{t('email', language)}</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full bg-black/50 border border-zinc-700 rounded px-4 py-3 text-sm focus:border-nofx-gold focus:ring-1 focus:ring-nofx-gold/50 outline-none transition-all placeholder-zinc-700 text-white font-mono"
|
||||
placeholder="user@nofx.os"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5 ml-1">
|
||||
<label className="block text-xs uppercase tracking-wider text-zinc-500 font-bold">{t('password', language)}</label>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full bg-black/50 border border-zinc-700 rounded px-4 py-3 text-sm focus:border-nofx-gold focus:ring-1 focus:ring-nofx-gold/50 outline-none transition-all placeholder-zinc-700 text-white font-mono pr-10"
|
||||
placeholder="••••••••••••"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-600 hover:text-zinc-400 transition-colors"
|
||||
>
|
||||
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-right mt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => window.location.href = '/reset-password'}
|
||||
className="text-[10px] uppercase tracking-wide text-zinc-500 hover:text-nofx-gold transition-colors"
|
||||
>
|
||||
> {t('forgotPassword', language)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-xs bg-red-500/10 border border-red-500/30 text-red-500 px-3 py-2 rounded font-mono flex gap-2 items-start">
|
||||
<span>⚠</span> <span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-nofx-gold text-black font-bold py-3 px-4 rounded text-sm tracking-wide uppercase hover:bg-yellow-400 transition-all transform active:scale-[0.98] disabled:opacity-50 disabled:cursor-not-allowed font-mono shadow-[0_0_15px_rgba(255,215,0,0.1)] hover:shadow-[0_0_25px_rgba(255,215,0,0.25)] flex items-center justify-center gap-2 group"
|
||||
>
|
||||
{loading ? (
|
||||
<span className="animate-pulse">PROCESSING...</span>
|
||||
) : (
|
||||
<>
|
||||
<span>AUTHENTICATE</span>
|
||||
<span className="group-hover:translate-x-1 transition-transform">-></span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Terminal Footer Info */}
|
||||
<div className="bg-zinc-900/50 p-3 flex justify-between items-center text-[10px] font-mono text-zinc-600 border-t border-zinc-800">
|
||||
<div>SECURE_CONNECTION: ENCRYPTED</div>
|
||||
<div>{new Date().toISOString().split('T')[0]}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Register Link */}
|
||||
{!adminMode && registrationEnabled && (
|
||||
<div className="text-center mt-8 space-y-4">
|
||||
<p className="text-xs font-mono text-zinc-500">
|
||||
NEW_USER_DETECTED?{' '}
|
||||
{/* Submit */}
|
||||
<button
|
||||
onClick={() => window.location.href = '/register'}
|
||||
className="text-nofx-gold hover:underline hover:text-yellow-300 transition-colors ml-1 uppercase"
|
||||
type="submit"
|
||||
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"
|
||||
>
|
||||
INITIALIZE REGISTRATION
|
||||
{loading ? t('loggingIn', language) || 'Signing in...' : t('signIn', language) || 'Sign In'}
|
||||
</button>
|
||||
</p>
|
||||
<button
|
||||
onClick={() => window.location.href = '/'}
|
||||
className="text-[10px] text-zinc-600 hover:text-red-500 transition-colors uppercase tracking-widest hover:underline decoration-red-500/30 font-mono"
|
||||
>
|
||||
[ ABORT_SESSION_RETURN_HOME ]
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</DeepVoidBackground>
|
||||
)
|
||||
|
||||
@@ -16,6 +16,7 @@ const MODEL_COLORS: Record<string, string> = {
|
||||
minimax: '#E45735',
|
||||
'blockrun-base': '#2563EB',
|
||||
'blockrun-sol': '#9945FF',
|
||||
claw402: '#7C3AED',
|
||||
}
|
||||
|
||||
// 获取AI模型图标的函数
|
||||
@@ -54,6 +55,9 @@ export const getModelIcon = (modelType: string, props: IconProps = {}) => {
|
||||
case 'blockrun-sol':
|
||||
iconPath = '/icons/blockrun.svg'
|
||||
break
|
||||
case 'claw402':
|
||||
iconPath = '/icons/claw402.png'
|
||||
break
|
||||
default:
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ export function RegisterPage() {
|
||||
getSystemConfig()
|
||||
.then((config) => {
|
||||
setBetaMode(config.beta_mode || false)
|
||||
setRegistrationEnabled(config.registration_enabled !== false)
|
||||
setRegistrationEnabled(config.initialized === false)
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Failed to fetch system config:', err)
|
||||
|
||||
115
web/src/components/SetupPage.tsx
Normal file
115
web/src/components/SetupPage.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Eye, EyeOff } from 'lucide-react'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { DeepVoidBackground } from './DeepVoidBackground'
|
||||
import { invalidateSystemConfig } from '../lib/config'
|
||||
|
||||
export function SetupPage() {
|
||||
const { register } = useAuth()
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
if (password.length < 8) {
|
||||
setError('Password must be at least 8 characters')
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
const result = await register(email, password)
|
||||
setLoading(false)
|
||||
if (result.success) {
|
||||
invalidateSystemConfig()
|
||||
window.location.href = '/traders'
|
||||
} else {
|
||||
setError(result.message || 'Setup failed, please try again')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DeepVoidBackground disableAnimation>
|
||||
<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" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-white mb-1.5">Welcome to NOFX</h1>
|
||||
<p className="text-zinc-500 text-sm">Create your account to get started</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={handleSubmit} className="space-y-5">
|
||||
|
||||
{/* Email */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-zinc-400 mb-2">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full bg-zinc-950/80 border border-zinc-700/80 rounded-xl px-4 py-3 text-sm text-white placeholder-zinc-600 focus:outline-none focus:border-nofx-gold/60 focus:ring-1 focus:ring-nofx-gold/30 transition-all"
|
||||
placeholder="you@example.com"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Password */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-zinc-400 mb-2">Password</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full bg-zinc-950/80 border border-zinc-700/80 rounded-xl px-4 py-3 pr-11 text-sm text-white placeholder-zinc-600 focus:outline-none focus:border-nofx-gold/60 focus:ring-1 focus:ring-nofx-gold/30 transition-all"
|
||||
placeholder="At least 8 characters"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3.5 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300 transition-colors"
|
||||
>
|
||||
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<p className="text-xs text-red-400 bg-red-500/10 border border-red-500/20 rounded-lg px-3 py-2">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Submit */}
|
||||
<button
|
||||
type="submit"
|
||||
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 ? 'Creating account...' : 'Get Started'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-xs text-zinc-600 mt-6">
|
||||
Single-user system — this is the only account
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</DeepVoidBackground>
|
||||
)
|
||||
}
|
||||
@@ -337,8 +337,8 @@ export function TraderConfigModal({
|
||||
{strategies.map((strategy) => (
|
||||
<option key={strategy.id} value={strategy.id}>
|
||||
{strategy.name}
|
||||
{strategy.is_active ? t('activeTag', language) : ''}
|
||||
{strategy.is_default ? t('default', language) : ''}
|
||||
{strategy.is_active ? t('strategyActive', language) : ''}
|
||||
{strategy.is_default ? t('strategyDefault', language) : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
import { motion } from 'framer-motion'
|
||||
import { X } from 'lucide-react'
|
||||
import { t, Language } from '../../i18n/translations'
|
||||
import { useSystemConfig } from '../../hooks/useSystemConfig'
|
||||
|
||||
interface LoginModalProps {
|
||||
onClose: () => void
|
||||
language: Language
|
||||
}
|
||||
|
||||
export default function LoginModal({ onClose, language }: LoginModalProps) {
|
||||
const { config: systemConfig } = useSystemConfig()
|
||||
const registrationEnabled = systemConfig?.registration_enabled !== false
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
@@ -70,25 +66,6 @@ export default function LoginModal({ onClose, language }: LoginModalProps) {
|
||||
>
|
||||
{t('signIn', language)}
|
||||
</motion.button>
|
||||
{registrationEnabled && (
|
||||
<motion.button
|
||||
onClick={() => {
|
||||
window.history.pushState({}, '', '/register')
|
||||
window.dispatchEvent(new PopStateEvent('popstate'))
|
||||
onClose()
|
||||
}}
|
||||
className="block w-full px-6 py-3 rounded-lg font-semibold text-center"
|
||||
style={{
|
||||
background: 'var(--brand-dark-gray)',
|
||||
color: 'var(--brand-light-gray)',
|
||||
border: '1px solid rgba(240, 185, 11, 0.2)',
|
||||
}}
|
||||
whileHover={{ scale: 1.05, borderColor: 'var(--brand-yellow)' }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
{t('registerNewAccount', language)}
|
||||
</motion.button>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
530
web/src/components/traders/TelegramConfigModal.tsx
Normal file
530
web/src/components/traders/TelegramConfigModal.tsx
Normal file
@@ -0,0 +1,530 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Check, ChevronLeft, ExternalLink, MessageCircle, Unlink, ArrowRight } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { api } from '../../lib/api'
|
||||
import type { TelegramConfig, AIModel } from '../../types'
|
||||
import type { Language } from '../../i18n/translations'
|
||||
|
||||
// Step indicator (reused pattern from ExchangeConfigModal)
|
||||
function StepIndicator({ currentStep, labels }: { currentStep: number; labels: string[] }) {
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-2 mb-6">
|
||||
{labels.map((label, index) => (
|
||||
<React.Fragment key={index}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold transition-all"
|
||||
style={{
|
||||
background: index < currentStep ? '#0ECB81' : index === currentStep ? '#2AABEE' : '#2B3139',
|
||||
color: index <= currentStep ? '#000' : '#848E9C',
|
||||
}}
|
||||
>
|
||||
{index < currentStep ? <Check className="w-4 h-4" /> : index + 1}
|
||||
</div>
|
||||
<span
|
||||
className="text-xs font-medium hidden sm:block"
|
||||
style={{ color: index === currentStep ? '#EAECEF' : '#848E9C' }}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
{index < labels.length - 1 && (
|
||||
<div
|
||||
className="w-8 h-0.5 mx-1"
|
||||
style={{ background: index < currentStep ? '#0ECB81' : '#2B3139' }}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface TelegramConfigModalProps {
|
||||
onClose: () => void
|
||||
language: Language
|
||||
}
|
||||
|
||||
export function TelegramConfigModal({ onClose, language }: TelegramConfigModalProps) {
|
||||
const [step, setStep] = useState(0)
|
||||
const [token, setToken] = useState('')
|
||||
const [selectedModelId, setSelectedModelId] = useState('')
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [config, setConfig] = useState<TelegramConfig | null>(null)
|
||||
const [models, setModels] = useState<AIModel[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isUnbinding, setIsUnbinding] = useState(false)
|
||||
|
||||
const zh = language === 'zh'
|
||||
|
||||
// Load current config and available models
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
api.getTelegramConfig().catch(() => null),
|
||||
api.getModelConfigs().catch(() => [] as AIModel[]),
|
||||
]).then(([cfg, allModels]) => {
|
||||
const enabledModels = allModels.filter((m) => m.enabled)
|
||||
setModels(enabledModels)
|
||||
|
||||
if (cfg) {
|
||||
setConfig(cfg)
|
||||
setSelectedModelId(cfg.model_id ?? '')
|
||||
if (cfg.is_bound) {
|
||||
setStep(2)
|
||||
} else if (cfg.token_masked && cfg.token_masked !== '') {
|
||||
setStep(1)
|
||||
}
|
||||
}
|
||||
}).finally(() => setIsLoading(false))
|
||||
}, [])
|
||||
|
||||
const handleSaveToken = async () => {
|
||||
if (!token.trim()) return
|
||||
if (isSaving) return
|
||||
|
||||
// Basic format validation: looks like "123456789:ABCdef..."
|
||||
if (!/^\d+:[A-Za-z0-9_-]{35,}$/.test(token.trim())) {
|
||||
toast.error(zh ? 'Bot Token 格式不正确,应为 "数字:字母数字串"' : 'Invalid Bot Token format. Expected "numbers:alphanumeric"')
|
||||
return
|
||||
}
|
||||
|
||||
setIsSaving(true)
|
||||
try {
|
||||
await api.updateTelegramConfig(token.trim(), selectedModelId || undefined)
|
||||
toast.success(zh ? 'Bot Token 已保存,等待绑定' : 'Bot Token saved, waiting for binding')
|
||||
const updated = await api.getTelegramConfig()
|
||||
setConfig(updated)
|
||||
setToken('')
|
||||
setStep(1)
|
||||
} catch (err) {
|
||||
toast.error(zh ? '保存失败,请检查 Token 是否正确' : 'Save failed, please verify the token')
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUnbind = async () => {
|
||||
if (isUnbinding) return
|
||||
setIsUnbinding(true)
|
||||
try {
|
||||
await api.unbindTelegram()
|
||||
toast.success(zh ? '已解绑 Telegram 账号' : 'Telegram account unbound')
|
||||
const updated = await api.getTelegramConfig()
|
||||
setConfig(updated)
|
||||
setStep(updated.token_masked ? 1 : 0)
|
||||
} catch {
|
||||
toast.error(zh ? '解绑失败' : 'Unbind failed')
|
||||
} finally {
|
||||
setIsUnbinding(false)
|
||||
}
|
||||
}
|
||||
|
||||
const stepLabels = zh
|
||||
? ['创建 Bot', '绑定账号', '完成']
|
||||
: ['Create Bot', 'Bind Account', 'Done']
|
||||
|
||||
// Model selector shared between steps
|
||||
const ModelSelector = () => (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-semibold" style={{ color: '#EAECEF' }}>
|
||||
{zh ? '选择 AI 模型(可选)' : 'Select AI Model (optional)'}
|
||||
</label>
|
||||
{models.length === 0 ? (
|
||||
<div
|
||||
className="px-4 py-3 rounded-xl text-xs"
|
||||
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#848E9C' }}
|
||||
>
|
||||
{zh ? '暂无启用的模型,请先在「AI 模型」中配置' : 'No enabled models. Configure one in AI Models first.'}
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
value={selectedModelId}
|
||||
onChange={(e) => setSelectedModelId(e.target.value)}
|
||||
className="w-full px-4 py-3 rounded-xl text-sm appearance-none"
|
||||
style={{
|
||||
background: '#0B0E11',
|
||||
border: '1px solid #2B3139',
|
||||
color: selectedModelId ? '#EAECEF' : '#848E9C',
|
||||
}}
|
||||
>
|
||||
<option value="">{zh ? '— 自动选择(推荐)' : '— Auto-select (recommended)'}</option>
|
||||
{models.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.name} ({m.provider}{m.customModelName ? ` · ${m.customModelName}` : ''})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<div className="text-xs" style={{ color: '#848E9C' }}>
|
||||
{zh
|
||||
? '不选则自动使用已启用的模型'
|
||||
: 'Leave blank to auto-use any enabled model'}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4 overflow-y-auto backdrop-blur-sm">
|
||||
<div
|
||||
className="rounded-2xl w-full max-w-lg relative my-8 shadow-2xl"
|
||||
style={{ background: 'linear-gradient(180deg, #1E2329 0%, #181A20 100%)' }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 pb-2">
|
||||
<div className="flex items-center gap-3">
|
||||
{step > 0 && !config?.is_bound && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStep(step - 1)}
|
||||
className="p-2 rounded-lg hover:bg-white/10 transition-colors"
|
||||
>
|
||||
<ChevronLeft className="w-5 h-5" style={{ color: '#848E9C' }} />
|
||||
</button>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageCircle className="w-6 h-6" style={{ color: '#2AABEE' }} />
|
||||
<h3 className="text-xl font-bold" style={{ color: '#EAECEF' }}>
|
||||
{zh ? 'Telegram Bot 配置' : 'Telegram Bot Setup'}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-lg hover:bg-white/10 transition-colors"
|
||||
style={{ color: '#848E9C' }}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Step Indicator */}
|
||||
<div className="px-6 pt-4">
|
||||
<StepIndicator currentStep={step} labels={stepLabels} />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-6 pb-6 space-y-5">
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-zinc-500 text-sm font-mono">
|
||||
{zh ? '加载中...' : 'Loading...'}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Step 0: Create bot via BotFather */}
|
||||
{step === 0 && (
|
||||
<div className="space-y-5">
|
||||
<div
|
||||
className="p-4 rounded-xl space-y-3"
|
||||
style={{ background: 'rgba(42, 171, 238, 0.1)', border: '1px solid rgba(42, 171, 238, 0.3)' }}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-2xl">🤖</span>
|
||||
<div>
|
||||
<div className="font-semibold mb-1" style={{ color: '#2AABEE' }}>
|
||||
{zh ? '第一步:在 Telegram 创建你的 Bot' : 'Step 1: Create your Bot in Telegram'}
|
||||
</div>
|
||||
<div className="text-xs space-y-1" style={{ color: '#848E9C' }}>
|
||||
<div>1. {zh ? '打开 Telegram,搜索' : 'Open Telegram, search for'} <code className="text-blue-400">@BotFather</code></div>
|
||||
<div>2. {zh ? '发送' : 'Send'} <code className="text-blue-400">/newbot</code> {zh ? '命令' : 'command'}</div>
|
||||
<div>3. {zh ? '按提示输入 Bot 名称和用户名' : 'Follow prompts to set bot name and username'}</div>
|
||||
<div>4. {zh ? 'BotFather 会返回一个 Token,复制它' : 'BotFather will return a Token, copy it'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a
|
||||
href="https://t.me/BotFather"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-3 rounded-xl font-semibold transition-all hover:scale-[1.02]"
|
||||
style={{ background: '#2AABEE', color: '#000' }}
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
{zh ? '打开 @BotFather' : 'Open @BotFather'}
|
||||
</a>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-semibold" style={{ color: '#EAECEF' }}>
|
||||
{zh ? '粘贴 Bot Token' : 'Paste Bot Token'}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
placeholder="123456789:ABCdefGHIjklmNOPQRstuvwxYZ"
|
||||
className="w-full px-4 py-3 rounded-xl font-mono text-sm"
|
||||
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||
/>
|
||||
<div className="text-xs" style={{ color: '#848E9C' }}>
|
||||
{zh ? 'Token 格式:数字:字母数字串,如 123456789:ABCdef...' : 'Format: numbers:alphanumeric, e.g. 123456789:ABCdef...'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ModelSelector />
|
||||
|
||||
<button
|
||||
onClick={handleSaveToken}
|
||||
disabled={isSaving || !token.trim()}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-3 rounded-xl text-sm font-bold transition-all hover:scale-[1.02] disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
style={{ background: '#2AABEE', color: '#000' }}
|
||||
>
|
||||
{isSaving
|
||||
? (zh ? '保存中...' : 'Saving...')
|
||||
: (<>{zh ? '保存并继续' : 'Save & Continue'} <ArrowRight className="w-4 h-4" /></>)
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 1: Send /start to activate */}
|
||||
{step === 1 && (
|
||||
<div className="space-y-5">
|
||||
<div
|
||||
className="p-4 rounded-xl space-y-3"
|
||||
style={{ background: 'rgba(14, 203, 129, 0.1)', border: '1px solid rgba(14, 203, 129, 0.3)' }}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-2xl">📱</span>
|
||||
<div>
|
||||
<div className="font-semibold mb-1" style={{ color: '#0ECB81' }}>
|
||||
{zh ? '第二步:向你的 Bot 发送 /start' : 'Step 2: Send /start to your Bot'}
|
||||
</div>
|
||||
<div className="text-xs space-y-1" style={{ color: '#848E9C' }}>
|
||||
<div>1. {zh ? '在 Telegram 中搜索你刚创建的 Bot' : 'Search for your newly created Bot in Telegram'}</div>
|
||||
<div>2. {zh ? '点击 Start 或发送' : 'Click Start or send'} <code className="text-green-400">/start</code></div>
|
||||
<div>3. {zh ? 'Bot 会自动绑定到你的账号' : 'Bot will automatically bind to your account'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{config?.token_masked && (
|
||||
<div
|
||||
className="p-3 rounded-xl flex items-center gap-3"
|
||||
style={{ background: '#0B0E11', border: '1px solid #2B3139' }}
|
||||
>
|
||||
<div className="w-2 h-2 rounded-full bg-yellow-500 animate-pulse flex-shrink-0" />
|
||||
<div>
|
||||
<div className="text-xs font-mono" style={{ color: '#848E9C' }}>
|
||||
{zh ? '当前 Token' : 'Current Token'}
|
||||
</div>
|
||||
<div className="text-sm font-mono" style={{ color: '#EAECEF' }}>
|
||||
{config.token_masked}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="p-3 rounded-xl text-center"
|
||||
style={{ background: 'rgba(240, 185, 11, 0.08)', border: '1px solid rgba(240, 185, 11, 0.2)' }}
|
||||
>
|
||||
<div className="text-xs" style={{ color: '#F0B90B' }}>
|
||||
{zh
|
||||
? '⏳ 等待你发送 /start... 发送后刷新页面查看状态'
|
||||
: '⏳ Waiting for you to send /start... Refresh page after sending'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => { setStep(0); setToken('') }}
|
||||
className="flex-1 px-4 py-3 rounded-xl text-sm font-semibold transition-all hover:bg-white/5"
|
||||
style={{ background: '#2B3139', color: '#848E9C' }}
|
||||
>
|
||||
{zh ? '重新配置 Token' : 'Reconfigure Token'}
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
const updated = await api.getTelegramConfig()
|
||||
setConfig(updated)
|
||||
if (updated.is_bound) {
|
||||
setStep(2)
|
||||
toast.success(zh ? '绑定成功!' : 'Bound successfully!')
|
||||
} else {
|
||||
toast.info(zh ? '尚未收到 /start,请先向 Bot 发送 /start' : 'No /start received yet. Please send /start to your Bot first')
|
||||
}
|
||||
} catch {
|
||||
toast.error(zh ? '检查失败' : 'Check failed')
|
||||
}
|
||||
}}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-xl text-sm font-bold transition-all hover:scale-[1.02]"
|
||||
style={{ background: '#0ECB81', color: '#000' }}
|
||||
>
|
||||
<Check className="w-4 h-4" />
|
||||
{zh ? '检查绑定状态' : 'Check Status'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: Bound & active */}
|
||||
{step === 2 && (
|
||||
<div className="space-y-5">
|
||||
<div
|
||||
className="p-5 rounded-xl text-center space-y-3"
|
||||
style={{ background: 'rgba(14, 203, 129, 0.1)', border: '1px solid rgba(14, 203, 129, 0.3)' }}
|
||||
>
|
||||
<div className="text-4xl">🎉</div>
|
||||
<div className="font-bold text-lg" style={{ color: '#0ECB81' }}>
|
||||
{zh ? 'Telegram Bot 已绑定!' : 'Telegram Bot is Active!'}
|
||||
</div>
|
||||
<div className="text-xs" style={{ color: '#848E9C' }}>
|
||||
{zh
|
||||
? '你现在可以通过 Telegram 用自然语言控制交易系统'
|
||||
: 'You can now control the trading system via natural language in Telegram'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{config?.token_masked && (
|
||||
<div
|
||||
className="p-3 rounded-xl flex items-center gap-3"
|
||||
style={{ background: '#0B0E11', border: '1px solid #2B3139' }}
|
||||
>
|
||||
<div className="w-2 h-2 rounded-full bg-green-500 flex-shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs font-mono" style={{ color: '#848E9C' }}>
|
||||
{zh ? 'Bot Token' : 'Bot Token'}
|
||||
</div>
|
||||
<div className="text-sm font-mono truncate" style={{ color: '#EAECEF' }}>
|
||||
{config.token_masked}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AI Model selector — works on active bot */}
|
||||
<BoundModelSelector
|
||||
zh={zh}
|
||||
models={models}
|
||||
currentModelId={config?.model_id ?? ''}
|
||||
onSaved={(modelId) => {
|
||||
setConfig((prev) => prev ? { ...prev, model_id: modelId } : prev)
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* What you can do */}
|
||||
<div
|
||||
className="p-4 rounded-xl space-y-2"
|
||||
style={{ background: '#0B0E11', border: '1px solid #2B3139' }}
|
||||
>
|
||||
<div className="text-xs font-semibold uppercase tracking-wide mb-2" style={{ color: '#848E9C' }}>
|
||||
{zh ? '支持的命令' : 'Supported Commands'}
|
||||
</div>
|
||||
{[
|
||||
{ cmd: '/help', desc: zh ? '查看所有命令' : 'Show all commands' },
|
||||
{ cmd: zh ? '查看交易员状态' : 'Show trader status', desc: zh ? '自然语言查询' : 'Natural language' },
|
||||
{ cmd: zh ? '启动/停止交易员' : 'Start/stop trader', desc: zh ? '自然语言控制' : 'Natural language control' },
|
||||
{ cmd: zh ? '查看持仓' : 'View positions', desc: zh ? '实时持仓查询' : 'Real-time position query' },
|
||||
{ cmd: zh ? '配置策略' : 'Configure strategy', desc: zh ? '修改交易策略' : 'Modify trading strategy' },
|
||||
].map((item, i) => (
|
||||
<div key={i} className="flex items-start gap-2 text-xs">
|
||||
<code className="font-mono px-1.5 py-0.5 rounded flex-shrink-0" style={{ background: '#1E2329', color: '#2AABEE' }}>
|
||||
{item.cmd}
|
||||
</code>
|
||||
<span style={{ color: '#848E9C' }}>{item.desc}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={handleUnbind}
|
||||
disabled={isUnbinding}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-xl text-sm font-semibold transition-all hover:bg-white/5 disabled:opacity-50"
|
||||
style={{ background: 'rgba(246, 70, 93, 0.1)', color: '#F6465D', border: '1px solid rgba(246, 70, 93, 0.2)' }}
|
||||
>
|
||||
<Unlink className="w-4 h-4" />
|
||||
{isUnbinding ? (zh ? '解绑中...' : 'Unbinding...') : (zh ? '解绑账号' : 'Unbind Account')}
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex-1 px-4 py-3 rounded-xl text-sm font-bold transition-all hover:scale-[1.02]"
|
||||
style={{ background: '#2AABEE', color: '#000' }}
|
||||
>
|
||||
{zh ? '完成' : 'Done'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// BoundModelSelector — lets the user change the AI model when the bot is already active.
|
||||
// It updates the model_id without requiring re-entry of the bot token.
|
||||
function BoundModelSelector({
|
||||
zh,
|
||||
models,
|
||||
currentModelId,
|
||||
onSaved,
|
||||
}: {
|
||||
zh: boolean
|
||||
models: AIModel[]
|
||||
currentModelId: string
|
||||
onSaved: (modelId: string) => void
|
||||
}) {
|
||||
const [modelId, setModelId] = useState(currentModelId)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
|
||||
// Keep in sync if parent updates
|
||||
useEffect(() => { setModelId(currentModelId) }, [currentModelId])
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true)
|
||||
try {
|
||||
// POST /api/telegram/model — lightweight endpoint for model-only update
|
||||
await api.updateTelegramModel(modelId)
|
||||
onSaved(modelId)
|
||||
toast.success(zh ? 'AI 模型已更新' : 'AI model updated')
|
||||
} catch {
|
||||
toast.error(zh ? '更新失败' : 'Update failed')
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (models.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-semibold" style={{ color: '#EAECEF' }}>
|
||||
{zh ? 'AI 模型(用于自然语言解析)' : 'AI Model (for natural language)'}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
value={modelId}
|
||||
onChange={(e) => setModelId(e.target.value)}
|
||||
className="flex-1 px-3 py-2.5 rounded-xl text-sm appearance-none"
|
||||
style={{
|
||||
background: '#0B0E11',
|
||||
border: '1px solid #2B3139',
|
||||
color: modelId ? '#EAECEF' : '#848E9C',
|
||||
}}
|
||||
>
|
||||
<option value="">{zh ? '— 自动选择' : '— Auto-select'}</option>
|
||||
{models.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.name}{m.customModelName ? ` · ${m.customModelName}` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving || modelId === currentModelId}
|
||||
className="px-4 py-2.5 rounded-xl text-sm font-bold transition-all hover:scale-[1.02] disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
style={{ background: '#F0B90B', color: '#000', whiteSpace: 'nowrap' }}
|
||||
>
|
||||
{isSaving ? '...' : (zh ? '保存' : 'Save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -339,8 +339,8 @@ export const translations = {
|
||||
selectTradingStrategy: 'Select Trading Strategy',
|
||||
useStrategy: 'Use Strategy',
|
||||
noStrategyManual: '-- No Strategy (Manual Configuration) --',
|
||||
activeTag: ' (Active)',
|
||||
default: ' [Default]',
|
||||
strategyActive: ' (Active)',
|
||||
strategyDefault: ' [Default]',
|
||||
noStrategyHint: 'No strategies yet, please create in Strategy Studio first',
|
||||
strategyDetails: 'Strategy Details',
|
||||
activating: 'Activating',
|
||||
@@ -1563,8 +1563,8 @@ export const translations = {
|
||||
selectTradingStrategy: '选择交易策略',
|
||||
useStrategy: '使用策略',
|
||||
noStrategyManual: '-- 不使用策略(手动配置) --',
|
||||
activeTag: ' (当前激活)',
|
||||
default: ' [默认]',
|
||||
strategyActive: ' (当前激活)',
|
||||
strategyDefault: ' [默认]',
|
||||
noStrategyHint: '暂无策略,请先在策略工作室创建策略',
|
||||
strategyDetails: '策略详情',
|
||||
activating: '激活中',
|
||||
@@ -2734,8 +2734,8 @@ export const translations = {
|
||||
selectTradingStrategy: 'Pilih Strategi Trading',
|
||||
useStrategy: 'Gunakan Strategi',
|
||||
noStrategyManual: '-- Tanpa Strategi (Konfigurasi Manual) --',
|
||||
activeTag: ' (Aktif)',
|
||||
default: ' [Default]',
|
||||
strategyActive: ' (Aktif)',
|
||||
strategyDefault: ' [Default]',
|
||||
noStrategyHint: 'Belum ada strategi, buat di Strategy Studio terlebih dahulu',
|
||||
strategyDetails: 'Detail Strategi',
|
||||
activating: 'Mengaktifkan',
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
TraderConfigData,
|
||||
AIModel,
|
||||
Exchange,
|
||||
TelegramConfig,
|
||||
CreateTraderRequest,
|
||||
CreateExchangeRequest,
|
||||
UpdateModelConfigRequest,
|
||||
@@ -785,4 +786,26 @@ export const api = {
|
||||
if (!result.success) throw new Error('获取历史仓位失败')
|
||||
return result.data!
|
||||
},
|
||||
|
||||
// Telegram Bot API
|
||||
async getTelegramConfig(): Promise<TelegramConfig> {
|
||||
const result = await httpClient.get<TelegramConfig>(`${API_BASE}/telegram`)
|
||||
if (!result.success) throw new Error('获取Telegram配置失败')
|
||||
return result.data!
|
||||
},
|
||||
|
||||
async updateTelegramConfig(token: string, modelId?: string): Promise<void> {
|
||||
const result = await httpClient.post(`${API_BASE}/telegram`, { bot_token: token, model_id: modelId ?? '' })
|
||||
if (!result.success) throw new Error('保存Telegram配置失败')
|
||||
},
|
||||
|
||||
async unbindTelegram(): Promise<void> {
|
||||
const result = await httpClient.delete(`${API_BASE}/telegram/binding`)
|
||||
if (!result.success) throw new Error('解绑Telegram失败')
|
||||
},
|
||||
|
||||
async updateTelegramModel(modelId: string): Promise<void> {
|
||||
const result = await httpClient.post(`${API_BASE}/telegram/model`, { model_id: modelId })
|
||||
if (!result.success) throw new Error('更新Telegram模型失败')
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export interface SystemConfig {
|
||||
beta_mode: boolean
|
||||
registration_enabled?: boolean
|
||||
initialized: boolean
|
||||
beta_mode?: boolean
|
||||
}
|
||||
|
||||
let configPromise: Promise<SystemConfig> | null = null
|
||||
@@ -19,8 +19,11 @@ export function getSystemConfig(): Promise<SystemConfig> {
|
||||
cachedConfig = data
|
||||
return data
|
||||
})
|
||||
.finally(() => {
|
||||
// Keep cachedConfig for reuse; allow re-fetch via explicit invalidation if added later
|
||||
})
|
||||
return configPromise
|
||||
}
|
||||
|
||||
/** Call after first-time setup completes so next check reflects initialized=true */
|
||||
export function invalidateSystemConfig() {
|
||||
cachedConfig = null
|
||||
configPromise = null
|
||||
}
|
||||
|
||||
489
web/src/pages/SettingsPage.tsx
Normal file
489
web/src/pages/SettingsPage.tsx
Normal file
@@ -0,0 +1,489 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { User, Cpu, Building2, MessageCircle, Eye, EyeOff, ChevronRight, Plus, Pencil } from 'lucide-react'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { useLanguage } from '../contexts/LanguageContext'
|
||||
import { api } from '../lib/api'
|
||||
import { ExchangeConfigModal } from '../components/traders/ExchangeConfigModal'
|
||||
import { TelegramConfigModal } from '../components/traders/TelegramConfigModal'
|
||||
import { ModelConfigModal } from '../components/AITradersPage'
|
||||
import type { Exchange, AIModel } from '../types'
|
||||
|
||||
type Tab = 'account' | 'models' | 'exchanges' | 'telegram'
|
||||
|
||||
export function SettingsPage() {
|
||||
const { user } = useAuth()
|
||||
const { language } = useLanguage()
|
||||
const [activeTab, setActiveTab] = useState<Tab>('account')
|
||||
|
||||
// Account state
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [changingPassword, setChangingPassword] = useState(false)
|
||||
|
||||
// AI Models state
|
||||
const [configuredModels, setConfiguredModels] = useState<AIModel[]>([])
|
||||
const [supportedModels, setSupportedModels] = useState<AIModel[]>([])
|
||||
const [showModelModal, setShowModelModal] = useState(false)
|
||||
const [editingModel, setEditingModel] = useState<string | null>(null)
|
||||
|
||||
// Exchanges state
|
||||
const [exchanges, setExchanges] = useState<Exchange[]>([])
|
||||
const [showExchangeModal, setShowExchangeModal] = useState(false)
|
||||
const [editingExchange, setEditingExchange] = useState<string | null>(null)
|
||||
|
||||
// Telegram state
|
||||
const [showTelegramModal, setShowTelegramModal] = useState(false)
|
||||
|
||||
// Fetch data when tabs are visited
|
||||
useEffect(() => {
|
||||
if (activeTab === 'models') {
|
||||
Promise.all([api.getModelConfigs(), api.getSupportedModels()])
|
||||
.then(([configs, supported]) => {
|
||||
setConfiguredModels(configs)
|
||||
setSupportedModels(supported)
|
||||
})
|
||||
.catch(() => toast.error('Failed to load AI models'))
|
||||
}
|
||||
if (activeTab === 'exchanges') {
|
||||
api.getExchangeConfigs()
|
||||
.then(setExchanges)
|
||||
.catch(() => toast.error('Failed to load exchanges'))
|
||||
}
|
||||
}, [activeTab])
|
||||
|
||||
const handleChangePassword = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (newPassword.length < 8) {
|
||||
toast.error('Password must be at least 8 characters')
|
||||
return
|
||||
}
|
||||
setChangingPassword(true)
|
||||
try {
|
||||
const res = await fetch('/api/user/password', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${localStorage.getItem('token') || ''}`,
|
||||
},
|
||||
body: JSON.stringify({ new_password: newPassword }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
throw new Error(data.error || 'Failed to update password')
|
||||
}
|
||||
toast.success('Password updated successfully')
|
||||
setNewPassword('')
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to update password')
|
||||
} finally {
|
||||
setChangingPassword(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveModel = async (
|
||||
modelId: string,
|
||||
apiKey: string,
|
||||
customApiUrl?: string,
|
||||
customModelName?: string
|
||||
) => {
|
||||
try {
|
||||
const existingModel = configuredModels.find((m) => m.id === modelId)
|
||||
const modelTemplate = supportedModels.find((m) => m.id === modelId)
|
||||
const modelToUpdate = existingModel || modelTemplate
|
||||
if (!modelToUpdate) { toast.error('Model not found'); return }
|
||||
|
||||
let updatedModels: AIModel[]
|
||||
if (existingModel) {
|
||||
updatedModels = configuredModels.map((m) =>
|
||||
m.id === modelId
|
||||
? { ...m, apiKey, customApiUrl: customApiUrl || '', customModelName: customModelName || '', enabled: true }
|
||||
: m
|
||||
)
|
||||
} else {
|
||||
updatedModels = [...configuredModels, {
|
||||
...modelToUpdate,
|
||||
apiKey,
|
||||
customApiUrl: customApiUrl || '',
|
||||
customModelName: customModelName || '',
|
||||
enabled: true,
|
||||
}]
|
||||
}
|
||||
|
||||
const request = {
|
||||
models: Object.fromEntries(
|
||||
updatedModels.map((m) => [m.provider, {
|
||||
enabled: m.enabled,
|
||||
api_key: m.apiKey || '',
|
||||
custom_api_url: m.customApiUrl || '',
|
||||
custom_model_name: m.customModelName || '',
|
||||
}])
|
||||
),
|
||||
}
|
||||
await toast.promise(api.updateModelConfigs(request), {
|
||||
loading: 'Saving model config...',
|
||||
success: 'Model config saved',
|
||||
error: 'Failed to save model config',
|
||||
})
|
||||
const refreshed = await api.getModelConfigs()
|
||||
setConfiguredModels(refreshed)
|
||||
setShowModelModal(false)
|
||||
setEditingModel(null)
|
||||
} catch {
|
||||
toast.error('Failed to save model config')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteModel = async (modelId: string) => {
|
||||
try {
|
||||
const updatedModels = configuredModels.map((m) =>
|
||||
m.id === modelId ? { ...m, apiKey: '', customApiUrl: '', customModelName: '', enabled: false } : m
|
||||
)
|
||||
const request = {
|
||||
models: Object.fromEntries(
|
||||
updatedModels.map((m) => [m.provider, {
|
||||
enabled: m.enabled,
|
||||
api_key: m.apiKey || '',
|
||||
custom_api_url: m.customApiUrl || '',
|
||||
custom_model_name: m.customModelName || '',
|
||||
}])
|
||||
),
|
||||
}
|
||||
await api.updateModelConfigs(request)
|
||||
const refreshed = await api.getModelConfigs()
|
||||
setConfiguredModels(refreshed)
|
||||
setShowModelModal(false)
|
||||
setEditingModel(null)
|
||||
toast.success('Model config removed')
|
||||
} catch {
|
||||
toast.error('Failed to remove model config')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveExchange = async (
|
||||
exchangeId: string | null,
|
||||
exchangeType: string,
|
||||
accountName: string,
|
||||
apiKey: string,
|
||||
secretKey?: string,
|
||||
passphrase?: string,
|
||||
testnet?: boolean,
|
||||
hyperliquidWalletAddr?: string,
|
||||
asterUser?: string,
|
||||
asterSigner?: string,
|
||||
asterPrivateKey?: string,
|
||||
lighterWalletAddr?: string,
|
||||
lighterPrivateKey?: string,
|
||||
lighterApiKeyPrivateKey?: string,
|
||||
lighterApiKeyIndex?: number
|
||||
) => {
|
||||
try {
|
||||
if (exchangeId) {
|
||||
const request = {
|
||||
exchanges: {
|
||||
[exchangeId]: {
|
||||
enabled: true,
|
||||
api_key: apiKey || '',
|
||||
secret_key: secretKey || '',
|
||||
passphrase: passphrase || '',
|
||||
testnet: testnet || false,
|
||||
hyperliquid_wallet_addr: hyperliquidWalletAddr || '',
|
||||
aster_user: asterUser || '',
|
||||
aster_signer: asterSigner || '',
|
||||
aster_private_key: asterPrivateKey || '',
|
||||
lighter_wallet_addr: lighterWalletAddr || '',
|
||||
lighter_private_key: lighterPrivateKey || '',
|
||||
lighter_api_key_private_key: lighterApiKeyPrivateKey || '',
|
||||
lighter_api_key_index: lighterApiKeyIndex || 0,
|
||||
},
|
||||
},
|
||||
}
|
||||
await toast.promise(api.updateExchangeConfigsEncrypted(request), {
|
||||
loading: 'Updating exchange config...',
|
||||
success: 'Exchange config updated',
|
||||
error: 'Failed to update exchange config',
|
||||
})
|
||||
} else {
|
||||
const createRequest = {
|
||||
exchange_type: exchangeType,
|
||||
account_name: accountName,
|
||||
enabled: true,
|
||||
api_key: apiKey || '',
|
||||
secret_key: secretKey || '',
|
||||
passphrase: passphrase || '',
|
||||
testnet: testnet || false,
|
||||
hyperliquid_wallet_addr: hyperliquidWalletAddr || '',
|
||||
aster_user: asterUser || '',
|
||||
aster_signer: asterSigner || '',
|
||||
aster_private_key: asterPrivateKey || '',
|
||||
lighter_wallet_addr: lighterWalletAddr || '',
|
||||
lighter_private_key: lighterPrivateKey || '',
|
||||
lighter_api_key_private_key: lighterApiKeyPrivateKey || '',
|
||||
lighter_api_key_index: lighterApiKeyIndex || 0,
|
||||
}
|
||||
await toast.promise(api.createExchangeEncrypted(createRequest), {
|
||||
loading: 'Creating exchange account...',
|
||||
success: 'Exchange account created',
|
||||
error: 'Failed to create exchange account',
|
||||
})
|
||||
}
|
||||
const refreshed = await api.getExchangeConfigs()
|
||||
setExchanges(refreshed)
|
||||
setShowExchangeModal(false)
|
||||
setEditingExchange(null)
|
||||
} catch {
|
||||
toast.error('Failed to save exchange config')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteExchange = async (exchangeId: string) => {
|
||||
try {
|
||||
await toast.promise(api.deleteExchange(exchangeId), {
|
||||
loading: 'Deleting exchange account...',
|
||||
success: 'Exchange account deleted',
|
||||
error: 'Failed to delete exchange account',
|
||||
})
|
||||
const refreshed = await api.getExchangeConfigs()
|
||||
setExchanges(refreshed)
|
||||
setShowExchangeModal(false)
|
||||
setEditingExchange(null)
|
||||
} catch {
|
||||
toast.error('Failed to delete exchange account')
|
||||
}
|
||||
}
|
||||
|
||||
const tabs: { key: Tab; label: string; icon: React.ReactNode }[] = [
|
||||
{ key: 'account', label: 'Account', icon: <User size={16} /> },
|
||||
{ key: 'models', label: 'AI Models', icon: <Cpu size={16} /> },
|
||||
{ key: 'exchanges', label: 'Exchanges', icon: <Building2 size={16} /> },
|
||||
{ key: 'telegram', label: 'Telegram', icon: <MessageCircle size={16} /> },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="min-h-screen pt-20 pb-12 px-4" style={{ background: '#0B0E11' }}>
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<h1 className="text-xl font-bold text-white mb-6">Settings</h1>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 mb-6 bg-zinc-900/60 border border-zinc-800 rounded-xl p-1">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
className={`flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-lg text-sm font-medium transition-all
|
||||
${activeTab === tab.key
|
||||
? 'bg-nofx-gold text-black'
|
||||
: 'text-zinc-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{tab.icon}
|
||||
<span className="hidden sm:inline">{tab.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="bg-zinc-900/60 backdrop-blur-xl border border-zinc-800/80 rounded-2xl p-6">
|
||||
|
||||
{/* Account Tab */}
|
||||
{activeTab === 'account' && (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<p className="text-xs text-zinc-500 mb-1">Email</p>
|
||||
<p className="text-sm text-white font-medium">{user?.email}</p>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-zinc-800 pt-6">
|
||||
<h3 className="text-sm font-semibold text-white mb-4">Change Password</h3>
|
||||
<form onSubmit={handleChangePassword} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-zinc-400 mb-2">New Password</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
className="w-full bg-zinc-950/80 border border-zinc-700/80 rounded-xl px-4 py-3 pr-11 text-sm text-white placeholder-zinc-600 focus:outline-none focus:border-nofx-gold/60 focus:ring-1 focus:ring-nofx-gold/30 transition-all"
|
||||
placeholder="At least 8 characters"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3.5 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300 transition-colors"
|
||||
>
|
||||
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={changingPassword || newPassword.length < 8}
|
||||
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"
|
||||
>
|
||||
{changingPassword ? 'Updating...' : 'Update Password'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AI Models Tab */}
|
||||
{activeTab === 'models' && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-zinc-400">
|
||||
{configuredModels.length} model{configuredModels.length !== 1 ? 's' : ''} configured
|
||||
</p>
|
||||
<button
|
||||
onClick={() => { setEditingModel(null); setShowModelModal(true) }}
|
||||
className="flex items-center gap-1.5 text-xs font-medium bg-nofx-gold/10 hover:bg-nofx-gold/20 text-nofx-gold px-3 py-1.5 rounded-lg transition-colors"
|
||||
>
|
||||
<Plus size={14} />
|
||||
Add Model
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{configuredModels.length === 0 ? (
|
||||
<div className="text-center py-8 text-zinc-600 text-sm">
|
||||
No AI models configured yet
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{configuredModels.map((model) => (
|
||||
<button
|
||||
key={model.id}
|
||||
onClick={() => { setEditingModel(model.id); setShowModelModal(true) }}
|
||||
className="w-full flex items-center justify-between px-4 py-3 rounded-xl bg-zinc-800/50 hover:bg-zinc-800 border border-zinc-700/50 transition-colors group"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-zinc-700 flex items-center justify-center">
|
||||
<Cpu size={14} className="text-zinc-300" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<p className="text-sm font-medium text-white">{model.name}</p>
|
||||
<p className="text-xs text-zinc-500">{model.provider}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${model.enabled ? 'bg-emerald-500/10 text-emerald-400' : 'bg-zinc-700 text-zinc-500'}`}>
|
||||
{model.enabled ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
<Pencil size={14} className="text-zinc-600 group-hover:text-zinc-400 transition-colors" />
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Exchanges Tab */}
|
||||
{activeTab === 'exchanges' && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-zinc-400">
|
||||
{exchanges.length} account{exchanges.length !== 1 ? 's' : ''} connected
|
||||
</p>
|
||||
<button
|
||||
onClick={() => { setEditingExchange(null); setShowExchangeModal(true) }}
|
||||
className="flex items-center gap-1.5 text-xs font-medium bg-nofx-gold/10 hover:bg-nofx-gold/20 text-nofx-gold px-3 py-1.5 rounded-lg transition-colors"
|
||||
>
|
||||
<Plus size={14} />
|
||||
Add Exchange
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{exchanges.length === 0 ? (
|
||||
<div className="text-center py-8 text-zinc-600 text-sm">
|
||||
No exchange accounts connected yet
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{exchanges.map((exchange) => (
|
||||
<button
|
||||
key={exchange.id}
|
||||
onClick={() => { setEditingExchange(exchange.id); setShowExchangeModal(true) }}
|
||||
className="w-full flex items-center justify-between px-4 py-3 rounded-xl bg-zinc-800/50 hover:bg-zinc-800 border border-zinc-700/50 transition-colors group"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-zinc-700 flex items-center justify-center">
|
||||
<Building2 size={14} className="text-zinc-300" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<p className="text-sm font-medium text-white">{exchange.account_name || exchange.name}</p>
|
||||
<p className="text-xs text-zinc-500 capitalize">{exchange.exchange_type || exchange.type}</p>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight size={14} className="text-zinc-600 group-hover:text-zinc-400 transition-colors" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Telegram Tab */}
|
||||
{activeTab === 'telegram' && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-zinc-400">
|
||||
Connect a Telegram bot to receive trading notifications and interact with your traders.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowTelegramModal(true)}
|
||||
className="w-full flex items-center justify-between px-4 py-3 rounded-xl bg-zinc-800/50 hover:bg-zinc-800 border border-zinc-700/50 transition-colors group"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-[#0088cc]/20 flex items-center justify-center">
|
||||
<MessageCircle size={14} className="text-[#0088cc]" />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-white">Configure Telegram Bot</span>
|
||||
</div>
|
||||
<ChevronRight size={14} className="text-zinc-600 group-hover:text-zinc-400 transition-colors" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AI Model Modal */}
|
||||
{showModelModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm px-4">
|
||||
<ModelConfigModal
|
||||
allModels={supportedModels}
|
||||
configuredModels={configuredModels}
|
||||
editingModelId={editingModel}
|
||||
onSave={handleSaveModel}
|
||||
onDelete={handleDeleteModel}
|
||||
onClose={() => { setShowModelModal(false); setEditingModel(null) }}
|
||||
language={language}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Exchange Modal */}
|
||||
{showExchangeModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm px-4">
|
||||
<ExchangeConfigModal
|
||||
allExchanges={exchanges}
|
||||
editingExchangeId={editingExchange}
|
||||
onSave={handleSaveExchange}
|
||||
onDelete={handleDeleteExchange}
|
||||
onClose={() => { setShowExchangeModal(false); setEditingExchange(null) }}
|
||||
language={language}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Telegram Modal */}
|
||||
{showTelegramModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm px-4">
|
||||
<TelegramConfigModal
|
||||
onClose={() => setShowTelegramModal(false)}
|
||||
language={language}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -116,6 +116,13 @@ export interface AIModel {
|
||||
customModelName?: string
|
||||
}
|
||||
|
||||
export interface TelegramConfig {
|
||||
token_masked: string // Masked token like "123456:ABC***XYZ"
|
||||
is_bound: boolean // Whether a user has sent /start
|
||||
bound_chat_id?: number // The bound chat ID (if any)
|
||||
model_id?: string // AI model selected for Telegram replies
|
||||
}
|
||||
|
||||
export interface Exchange {
|
||||
id: string // UUID (empty for supported exchange templates)
|
||||
exchange_type: string // "binance", "bybit", "okx", "hyperliquid", "aster", "lighter"
|
||||
|
||||
Reference in New Issue
Block a user