mirror of
https://github.com/laoxong/nofx.git
synced 2026-06-04 01:48:22 +08:00
feat: Claw402 x402 payment provider + Telegram agent + x402 refactoring (#1409)
* feat(telegram): add AI agent bot with streaming and account context
- Add Telegram bot with long-polling and AI agent loop (api_call tool)
- SSE streaming with real-time message editing and ⏳ placeholder
- Account state injection at conversation start (models, exchanges,
strategies, traders, per-trader PnL and statistics)
- Lane semaphore per chat serializes concurrent messages (60s timeout)
- Idle timeout watchdog (60s) prevents hung streaming connections
- Look-ahead buffer prevents partial <api_call> tag leaking to user
- Fix PUT /strategies/:id to merge config (read-then-merge pattern)
- Add route registry with full API schema for LLM documentation
- Add TelegramConfig store and Web UI config modal
- Add GetAnyEnabled to AIModel store for bot LLM client selection
* fix(telegram): eliminate narration, add full-setup workflow and tests
- Rewrite NO NARRATION rule: response is EITHER api_call tag alone OR
final text reply — no text before api_call under any circumstances
- Ban all narration patterns: 现在我将/好的/正在/I will/Let me etc.
- Add 'create strategy + create trader + start' full setup workflow
- Add 12 automated tests covering:
- No narration leaking to user (5 narration variants tested)
- api_call tag never leaks to user
- Full setup workflow: POST strategy → verify → POST trader → start
- Start existing trader workflow
- Max iterations safety, tag stripping, parser edge cases
* refactor(agent): replace XML api_call with native function calling
Migrate the Telegram bot agent from an XML tag hack (<api_call>) to
OpenAI-native function calling via CallWithRequestFull.
Key changes:
- mcp/interface.go: add parseMCPResponseFull to clientHooks interface
- mcp/client.go: route callWithRequestFull through hooks for overridability
- mcp/claude_client.go: override parseMCPResponseFull for Claude response
format (tool_use blocks instead of choices[].message.tool_calls)
- telegram/agent/agent.go: rewrite Run() to use CallWithRequestFull;
define api_request tool with JSON Schema; implement tool-call loop
with role="tool" result messages; remove XML parsing entirely
- telegram/agent/apicall.go: remove parseAPICall (dead code)
- telegram/agent/prompt.go: simplify — remove XML format instructions,
replace with concise api_request tool usage instructions
- telegram/agent/agent_test.go: rebuild all tests using LLMResponse
objects; add TestNarrationStructurallyImpossible, TestOnChunkCalledWithFinalReply,
TestToolCallIDPropagated; remove XML-specific tests
Architecture advantage: with native function calling, the LLM returns
EITHER ToolCalls OR Content — never both. Narration is now structurally
impossible at the protocol level, not just enforced by prompt rules.
All 11 agent tests pass. mcp package tests pass.
* refactor(mcp): route buildRequestBodyFromRequest through hooks + full Anthropic format
Problem: callWithRequest/Full/Stream all called client.buildRequestBodyFromRequest
directly (not via hooks), so ClaudeClient could never override it. This meant
tool calling sent OpenAI format to Anthropic (wrong field names, wrong roles).
Changes:
mcp/interface.go
- Add buildRequestBodyFromRequest(*Request) map[string]any to clientHooks
- Improve comments: document what each hook group does and why
mcp/client.go
- All three paths (callWithRequest, callWithRequestFull, CallWithRequestStream)
now call client.hooks.buildRequestBodyFromRequest — ClaudeClient picks up
mcp/claude_client.go
- Full rewrite with format comparison table in package doc
- buildRequestBodyFromRequest: produces correct Anthropic wire format
* system prompt → top-level "system" field
* tools: parameters → input_schema, no "type:function" wrapper
* tool_choice "auto" → {"type":"auto"} object
* assistant tool calls → content[{type:tool_use, id, name, input}]
* role=tool results → role=user content[{type:tool_result,...}]
* consecutive tool results merged into single user turn
- convertMessagesToAnthropic: handles all three message types
- parseMCPResponseFull: extracts text + tool_use blocks
- parseMCPResponse: delegates to parseMCPResponseFull
All mcp and agent tests pass.
* fix(telegram): fix claude client dispatch + strategy creation workflow
- telegram/bot.go: clientForProvider now returns NewClaudeClient() for
'claude' provider (was incorrectly falling back to DeepSeekClient which
uses OpenAI wire format, breaking Anthropic API calls)
- api/server.go: fix scan_interval_minutes schema default (3, not 60);
POST /api/strategies now clearly states config is OPTIONAL with complete
working defaults; POST /api/traders removes redundant GET workflow note
- telegram/agent/prompt.go: simplify strategy creation — just POST {name}
without config (backend applies full working defaults automatically);
only include config when user requests custom settings
* test(mcp): add ClaudeClient wire format tests
Tests cover all Anthropic-specific format conversions:
- system prompt lifted to top-level field
- tools use input_schema (not parameters)
- tool_choice is object {type:auto} not string
- assistant tool calls → content[{type:tool_use}]
- consecutive tool results merged into single user turn
- parseMCPResponseFull: text, tool_use, and error cases
- x-api-key header (not Authorization: Bearer)
- /messages endpoint URL
* fix(telegram): clientForProvider returns correct client for all 7 providers
Previously qwen/kimi/grok/gemini all fell back to DeepSeekClient.
Each provider now gets its own dedicated client with correct default
base URL and model. All 7 providers now fully supported:
openai, deepseek, claude, qwen, kimi, grok, gemini
* fix(telegram): newLLMClient uses bound user's model, not any user's model
GetAnyEnabled() searched across all users in DB — if user B has an
enabled model, bot could use their API key while acting as user A.
Now uses GetDefault(botUserID) which only looks up the bound user's
enabled model, matching the same user scope as all API calls.
* fix(auth): single-user deployment by default, no open registration
Registration logic redesigned:
- Empty DB (first-time setup): registration always open, no config needed
- After first user exists: registration closed by default
- Multi-user opt-in: set REGISTRATION_ENABLED=true + MAX_USERS=N in .env
Config defaults changed:
- RegistrationEnabled: true → false (closed after first user)
- MaxUsers: 10 → 1 (single-user deployment default)
This eliminates the confusion of multiple users appearing in a personal
deployment where Telegram is bound to a single admin account.
* feat(solo): beginner-friendly onboarding — smart setup guide + direct config commands
start.sh:
- Interactive Telegram Bot Token prompt on first run
- Token format validation (must match 12345:ABC... pattern)
- Friendly step-by-step startup instructions after launch
telegram/bot.go:
- /start now shows context-aware setup guide based on actual config state:
- No AI model → explains how to configure, lists all providers
- AI model OK but no exchange → guides to configure exchange via chat
- All configured → full capabilities welcome message
- New: direct setup commands ('配置 deepseek sk-xxx') bypass LLM entirely
so AI model can be configured even before any model exists (bootstrap fix)
- All messages now in Chinese (匹配用户语言)
telegram/agent/prompt.go:
- Added first-time setup detection section
- Agent told to never ask user to visit web UI — everything via chat
* feat(i18n): bilingual EN/ZH setup guide with language selection
store/telegram_config.go:
- Add Language field to TelegramConfig (persisted in DB)
- Add SetLanguage(lang) and GetLanguage() methods
- Default language: English (en)
telegram/bot.go:
- First /start triggers language selection (1=English, 2=中文)
- /lang command to change language at any time
- awaitingLang state machine handles language choice before any other input
- buildSetupGuide() now fully bilingual (EN/ZH), context-aware:
Step 1: configure AI model (no model yet)
Step 2: configure exchange (model OK, no exchange)
Ready: show full capabilities
- tryHandleSetupCommand() bilingual: 'configure/配置 <provider> <key>'
- helpMessage(lang) fully bilingual
- All error/status messages bilingual
Default: English. isLangDefault() detects whether user has explicitly
chosen a language vs falling back to the 'en' default.
* fix(telegram): use Markdown rendering + simplify language selection condition
- sendMarkdownMsg() helper: sends with ParseMode=Markdown, falls back to plain text
- All formatted messages (langSelectionMsg, buildSetupGuide, helpMessage) now render
bold text and code blocks correctly in Telegram
- Simplify /start language check: isLangDefault(st) alone is sufficient
(lang == 'en' && isLangDefault was redundant — GetLanguage returns 'en' when empty)
* fix(start.sh): translate all user-facing text to English
Entire script was in Chinese. Now English-first throughout:
- startup banner, prompts, success/error messages
- setup_telegram(): English instructions and validation messages
- start(): English next-steps after launch
- stop/restart/clean/update/regenerate-keys/show_help: all English
* fix(telegram): remove 'default' user fallback — resolve user dynamically
- botUserID no longer captured once at startup (was 'default' if no user yet)
- resolveBotUser() reads first registered user from DB on demand:
* called on every /start (handles: registered after bot launch)
* called before every AI message (handles mid-session registration)
- If no user registered: clear English error 'No account found. Please register on the web UI first'
- start.sh: fix set_env_var appending without newline (token was concatenated to prev line)
* refactor(telegram): clean onboarding — web UI for setup, Telegram for operations
- /start shows clean status: 'setup required → open web UI' or 'ready → examples'
- Removed tryHandleSetupCommand (no more CLI-style 'configure deepseek sk-xxx')
- Removed automatic language selection on /start (use /lang anytime instead)
- newLLMClient returns nil when no model → clear guard, not fallback
- statusMsg() replaces buildSetupGuide(): two states only (missing config / ready)
- Bot is now purely an operations interface; config lives in the web UI
* refactor: single-user web-based setup — replace env config with Settings UI
Move from multi-user env-var config to single-user web-first architecture:
- Add SetupPage for first-time initialization (replaces /register)
- Add SettingsPage for AI models, exchanges, Telegram, and password management
- Enrich all API route schemas with exact ID usage documentation
- Add PUT /user/password endpoint for in-app password changes
- Remove REGISTRATION_ENABLED, MAX_USERS, TELEGRAM_BOT_TOKEN from env config
- Simplify LoginPage design, remove admin mode and registration links
- Telegram bot now resolves user email for identity display
- start.sh no longer runs interactive Telegram setup
* feat: add blockRun (x402 USDC) support to all AI model consumers
- telegram/bot.go: add blockrun-base, blockrun-sol, minimax to
clientForProvider; fix newLLMClient to prefer TelegramConfig.ModelID
over GetDefault; log USDC payment provider usage
- debate/engine.go: add blockrun-base, blockrun-sol to InitializeClients
- api/strategy.go: add blockrun-base, blockrun-sol to runRealAITest
- backtest/ai_client.go: add blockrun-base, blockrun-sol to configureMCPClient
* feat: add Claw402 (claw402.ai) x402 USDC payment provider
Add Claw402Client for claw402.ai's x402 micropayment gateway (Base USDC).
Supports 15+ AI models (GPT-5.4, Claude Opus, DeepSeek, Qwen, Grok, etc.)
with per-model endpoint routing.
- mcp/claw402.go: new client with model→endpoint mapping, x402 v2 payment flow
- mcp/blockrun_base.go: extract shared signX402Payment() for reuse
- Register "claw402" provider in all 6 consumer switch statements:
api/server.go, api/strategy.go, trader/auto_trader.go,
telegram/bot.go, debate/engine.go, backtest/ai_client.go
* feat: redesign Claw402 model config UI — friendly wallet setup, USDC guide, official logo, nginx no-cache for index.html
* refactor: centralize x402 payment flow into shared mcp/x402.go
Extract duplicated doRequestWithPayment/call/CallWithRequestFull/buildRequest/
setAuthHeader (~165 lines x3) into shared helpers in mcp/x402.go. Consolidate
shared types (x402v2PaymentRequired, x402AcceptOption, x402Resource) and remove
duplicate Solana types. Fix validAfter to 0 (official SDK standard), drain 402
body before retry, log Payment-Response tx hash, check Payment-Required before
X-Payment-Required.
* fix: stop PR template bot from overwriting user-written descriptions
The pr-template-suggester workflow was triggered on opened/edited/synchronize
events and forcefully replaced the PR body with a template when body < 100 chars.
This caused user-written descriptions to be overwritten.
Replace with a lightweight labeler (OpenClaw-style) that:
- Only adds labels (backend/frontend/docs, size: XS/S/M/L/XL)
- Never modifies the PR body
- Simplified unified PR template at .github/pull_request_template.md
* chore: simplify PR template (OpenClaw-style)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)`);
|
||||
|
||||
@@ -16,6 +16,7 @@ nofx_test
|
||||
# Go 相关
|
||||
*.test
|
||||
*.out
|
||||
.gocache/
|
||||
|
||||
# 操作系统
|
||||
.DS_Store
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
+388
-101
@@ -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})
|
||||
}
|
||||
|
||||
+66
-17
@@ -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")
|
||||
|
||||
+3
-16
@@ -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()
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
|
||||
+24
-135
@@ -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)
|
||||
}
|
||||
|
||||
+10
-104
@@ -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)
|
||||
}
|
||||
|
||||
+237
-63
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
+223
-19
@@ -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
|
||||
}
|
||||
|
||||
+37
-6
@@ -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
|
||||
}
|
||||
|
||||
+28
-3
@@ -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
@@ -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)
|
||||
}
|
||||
+8
-1
@@ -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";
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+25
-11
@@ -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 {
|
||||
|
||||
@@ -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{}{
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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`
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 797 KiB |
@@ -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 |
+35
-4
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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