Improve NOFXi agent product handling

This commit is contained in:
lky-spec
2026-05-02 22:55:10 +08:00
parent 25d0b30ea9
commit 159f27dfdd
19 changed files with 449 additions and 54 deletions

View File

@@ -421,6 +421,9 @@ func (a *Agent) handleMessageForStoreUser(ctx context.Context, storeUserID strin
if reply, handled := a.handleTradeConfirmation(ctx, userID, text, lang); handled {
return reply, nil
}
if reply, handled := a.handleModelWalletBalanceQuestion(storeUserID, lang, text); handled {
return reply, nil
}
// Everything else goes through the planner and tool system.
return a.thinkAndAct(ctx, storeUserID, userID, lang, text)
@@ -468,6 +471,12 @@ func (a *Agent) handleMessageStreamForStoreUser(ctx context.Context, storeUserID
}
return reply, nil
}
if reply, handled := a.handleModelWalletBalanceQuestion(storeUserID, lang, text); handled {
if onEvent != nil {
emitStreamText(onEvent, reply)
}
return reply, nil
}
return a.thinkAndActStream(ctx, storeUserID, userID, lang, text, onEvent)
}

View File

@@ -0,0 +1,86 @@
package agent
import (
"fmt"
"strconv"
"strings"
)
func isModelWalletBalanceQuestion(text string) bool {
lower := strings.ToLower(strings.TrimSpace(text))
if lower == "" || !strings.Contains(lower, "claw402") {
return false
}
return containsAny(lower, []string{"余额", "balance", "usdc"}) &&
containsAny(lower, []string{"钱包", "wallet", "主钱包", "base"})
}
func (a *Agent) handleModelWalletBalanceQuestion(storeUserID, lang, text string) (string, bool) {
if !isModelWalletBalanceQuestion(text) || a == nil || a.store == nil {
return "", false
}
models, err := a.store.AIModel().List(storeUserID)
if err != nil {
if lang == "zh" {
return "我现在读取模型配置失败,暂时查不到 claw402 钱包余额。", true
}
return "I could not read model configs, so I cannot check the claw402 wallet balance right now.", true
}
var matches []safeModelToolConfig
for _, model := range models {
if model == nil || strings.ToLower(strings.TrimSpace(model.Provider)) != "claw402" {
continue
}
matches = append(matches, safeModelForTool(model))
}
if len(matches) == 0 {
if lang == "zh" {
return "当前没有找到 claw402 模型钱包配置。", true
}
return "No claw402 model wallet config was found.", true
}
if lang == "zh" {
lines := []string{"当前 claw402 模型钱包余额:"}
for _, model := range matches {
name := defaultIfEmpty(model.Name, model.ID)
lines = append(lines, fmt.Sprintf("- %s%s USDC", name, defaultIfEmpty(model.BalanceUSDC, "暂时无法读取")))
if strings.TrimSpace(model.WalletAddress) != "" {
lines = append(lines, fmt.Sprintf(" 钱包地址:%s", model.WalletAddress))
}
if balanceIsZero(model.BalanceUSDC) {
if model.Enabled {
lines = append(lines, " 这个模型配置已启用,但钱包余额为 0 USDC这不是“未启用”而是需要先充值 Base USDC 后才能稳定调用。")
} else {
lines = append(lines, " 钱包余额为 0 USDC启用并充值 Base USDC 后才能稳定调用。")
}
}
}
lines = append(lines, "注意:这是 claw402/Base 模型支付钱包余额,不是 OKX/Binance 等交易所账户余额。")
return strings.Join(lines, "\n"), true
}
lines := []string{"Current claw402 model wallet balance:"}
for _, model := range matches {
name := defaultIfEmpty(model.Name, model.ID)
lines = append(lines, fmt.Sprintf("- %s: %s USDC", name, defaultIfEmpty(model.BalanceUSDC, "unavailable")))
if strings.TrimSpace(model.WalletAddress) != "" {
lines = append(lines, fmt.Sprintf(" Wallet address: %s", model.WalletAddress))
}
if balanceIsZero(model.BalanceUSDC) {
lines = append(lines, " This model config may be enabled, but the wallet balance is 0 USDC; recharge Base USDC before relying on it.")
}
}
lines = append(lines, "Note: this is the claw402/Base model payment wallet balance, not an exchange account balance.")
return strings.Join(lines, "\n"), true
}
func balanceIsZero(value string) bool {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return false
}
parsed, err := strconv.ParseFloat(trimmed, 64)
return err == nil && parsed <= 0
}

View File

@@ -244,6 +244,9 @@ func formatOptionList(prefix string, options []traderSkillOption) string {
if label == "" {
label = option.ID
}
if hint := strings.TrimSpace(option.Hint); hint != "" {
label += "" + hint + ""
}
if option.Enabled {
label += "(已启用)"
} else {
@@ -267,6 +270,28 @@ func parseSkillError(raw string) string {
return strings.TrimSpace(raw)
}
func modelWalletBalanceHint(model *store.AIModel) string {
if model == nil || !agentProviderSupportsUSDCBalance(model.Provider) {
return ""
}
privateKey := strings.TrimSpace(string(model.APIKey))
if privateKey == "" {
return "钱包未配置"
}
walletAddress, err := agentWalletAddressFromPrivateKey(privateKey)
if err != nil || strings.TrimSpace(walletAddress) == "" {
return "钱包私钥无效"
}
balance, err := agentQueryUSDCBalanceCached(walletAddress)
if err != nil {
return "钱包余额暂时无法读取"
}
if balance <= 0 {
return "钱包余额 0 USDC需充值后才能稳定调用"
}
return fmt.Sprintf("钱包余额 %.4g USDC", balance)
}
func (a *Agent) loadEnabledModelOptions(storeUserID string) []traderSkillOption {
if a.store == nil {
return nil
@@ -284,6 +309,7 @@ func (a *Agent) loadEnabledModelOptions(storeUserID string) []traderSkillOption
hint := strings.Join(cleanStringList([]string{
strings.TrimSpace(model.CustomModelName),
strings.TrimSpace(model.Provider),
modelWalletBalanceHint(model),
}), " / ")
out = append(out, traderSkillOption{ID: model.ID, Name: name, Hint: hint, Enabled: model.Enabled})
}
@@ -1046,7 +1072,7 @@ func findOptionByIDOrName(options []traderSkillOption, query string) *traderSkil
return nil
}
for i, opt := range options {
if opt.ID == query || strings.EqualFold(opt.Name, query) {
if opt.ID == query || strings.EqualFold(opt.Name, query) || strings.EqualFold(opt.Hint, query) {
return &options[i]
}
}
@@ -1060,7 +1086,12 @@ func findUniqueContainingOption(options []traderSkillOption, query string) *trad
}
matches := make([]traderSkillOption, 0, 1)
for _, opt := range options {
if strings.Contains(strings.ToLower(opt.Name), query) || strings.Contains(query, strings.ToLower(opt.Name)) {
name := strings.ToLower(strings.TrimSpace(opt.Name))
hint := strings.ToLower(strings.TrimSpace(opt.Hint))
id := strings.ToLower(strings.TrimSpace(opt.ID))
if (name != "" && (strings.Contains(name, query) || strings.Contains(query, name))) ||
(hint != "" && (strings.Contains(hint, query) || strings.Contains(query, hint))) ||
(id != "" && (strings.Contains(id, query) || strings.Contains(query, id))) {
matches = append(matches, opt)
}
}

View File

@@ -1472,11 +1472,37 @@ func (a *Agent) describeModel(storeUserID, lang string, target *EntityReference)
model = &payload.ModelConfigs[0]
}
if lang == "zh" {
return fmt.Sprintf("模型配置“%s”详情\n- Provider%s\n- 已启用:%t\n- API Key%t\n- URL%s\n- Model Name%s",
defaultIfEmpty(model.Name, model.ID), model.Provider, model.Enabled, model.HasAPIKey, defaultIfEmpty(model.CustomAPIURL, "未设置"), defaultIfEmpty(model.CustomModelName, "未设置")), true
lines := []string{
fmt.Sprintf("模型配置“%s”详情", defaultIfEmpty(model.Name, model.ID)),
fmt.Sprintf("- Provider%s", model.Provider),
fmt.Sprintf("- 已启用:%t", model.Enabled),
fmt.Sprintf("- API Key%t", model.HasAPIKey),
fmt.Sprintf("- URL%s", defaultIfEmpty(model.CustomAPIURL, "未设置")),
fmt.Sprintf("- Model Name%s", defaultIfEmpty(model.CustomModelName, "未设置")),
}
if strings.TrimSpace(model.WalletAddress) != "" {
lines = append(lines, fmt.Sprintf("- 钱包地址:%s", model.WalletAddress))
}
if strings.TrimSpace(model.BalanceUSDC) != "" {
lines = append(lines, fmt.Sprintf("- 钱包余额:%s USDC", model.BalanceUSDC))
}
return strings.Join(lines, "\n"), true
}
return fmt.Sprintf("Model config %q details:\n- Provider: %s\n- Enabled: %t\n- API key present: %t\n- URL: %s\n- Model name: %s",
defaultIfEmpty(model.Name, model.ID), model.Provider, model.Enabled, model.HasAPIKey, defaultIfEmpty(model.CustomAPIURL, "not set"), defaultIfEmpty(model.CustomModelName, "not set")), true
lines := []string{
fmt.Sprintf("Model config %q details:", defaultIfEmpty(model.Name, model.ID)),
fmt.Sprintf("- Provider: %s", model.Provider),
fmt.Sprintf("- Enabled: %t", model.Enabled),
fmt.Sprintf("- API key present: %t", model.HasAPIKey),
fmt.Sprintf("- URL: %s", defaultIfEmpty(model.CustomAPIURL, "not set")),
fmt.Sprintf("- Model name: %s", defaultIfEmpty(model.CustomModelName, "not set")),
}
if strings.TrimSpace(model.WalletAddress) != "" {
lines = append(lines, fmt.Sprintf("- Wallet address: %s", model.WalletAddress))
}
if strings.TrimSpace(model.BalanceUSDC) != "" {
lines = append(lines, fmt.Sprintf("- Wallet balance: %s USDC", model.BalanceUSDC))
}
return strings.Join(lines, "\n"), true
}
func findTraderByReference(items []safeTraderToolConfig, target *EntityReference) *safeTraderToolConfig {

View File

@@ -18,6 +18,8 @@ type SkillDefinition struct {
Domain string `json:"domain"`
Description string `json:"description"`
Intents []string `json:"intents,omitempty"`
Capabilities []string `json:"capabilities,omitempty"`
DynamicRules []string `json:"dynamic_rules,omitempty"`
Actions map[string]SkillActionDefinition `json:"actions,omitempty"`
ToolMapping map[string]string `json:"tool_mapping,omitempty"`
FieldConstraints map[string]SkillFieldConstraint `json:"field_constraints,omitempty"`
@@ -96,6 +98,8 @@ func normalizeSkillDefinition(def SkillDefinition) SkillDefinition {
def.Domain = strings.TrimSpace(def.Domain)
def.Description = strings.TrimSpace(def.Description)
def.Intents = cleanStringList(def.Intents)
def.Capabilities = cleanStringList(def.Capabilities)
def.DynamicRules = cleanStringList(def.DynamicRules)
if len(def.Actions) > 0 {
normalized := make(map[string]SkillActionDefinition, len(def.Actions))
@@ -194,6 +198,9 @@ func buildSkillRoutingSummary(lang string, skillNames []string) string {
continue
}
parts := []string{strings.TrimSpace(def.Description)}
if len(def.DynamicRules) > 0 {
parts = append(parts, strings.Join(def.DynamicRules, " "))
}
switch name {
case "trader_management":
if lang == "zh" {
@@ -221,6 +228,20 @@ func buildSkillDefinitionSummary(lang string, skillNames []string) string {
continue
}
parts := []string{strings.TrimSpace(def.Description)}
if len(def.Capabilities) > 0 {
if lang == "zh" {
parts = append(parts, "能力: "+strings.Join(def.Capabilities, ""))
} else {
parts = append(parts, "capabilities: "+strings.Join(def.Capabilities, "; "))
}
}
if len(def.DynamicRules) > 0 {
if lang == "zh" {
parts = append(parts, "规则: "+strings.Join(def.DynamicRules, ""))
} else {
parts = append(parts, "rules: "+strings.Join(def.DynamicRules, "; "))
}
}
if action, ok := def.Actions["create"]; ok && len(action.RequiredSlots) > 0 {
if lang == "zh" {
parts = append(parts, "创建必填: "+formatRequiredSlotList(lang, action.RequiredSlots))

View File

@@ -2,5 +2,17 @@
"name": "exchange_diagnosis",
"kind": "diagnosis",
"domain": "exchange",
"description": "当用户反馈交易所 API 连接失败、签名错误、timestamp 异常、权限不足、IP 白名单限制、账户不可用等问题时调用。适用于用户在手动配置或运行交易员时遇到的交易所接入故障。不用于创建、修改、删除或查询交易所配置这类管理操作。"
"description": "当用户反馈交易所 API 连接失败、签名错误、timestamp 异常、权限不足、IP 白名单限制、账户不可用、余额读取失败、下单失败或仓位模式错误等问题时调用。适用于用户在手动配置或运行交易员时遇到的交易所接入与执行故障。不用于创建、修改、删除或查询交易所配置这类管理操作。",
"capabilities": [
"区分凭证缺失、签名错误、时间戳偏差、IP 白名单、权限不足、余额不足、仓位模式和 symbol 不可交易等原因",
"解释不同交易所的必填字段差异,尤其是 OKX/Bitget/KuCoin passphrase、Hyperliquid 钱包地址、Aster signer/private key、Lighter API key private key",
"把交易所原始错误翻译成新手可执行的修复步骤"
],
"dynamic_rules": [
"交易所连接失败优先按顺序排查:配置是否启用 -> 必填凭证是否齐全 -> API Key/Secret/Passphrase 是否填反或过期 -> 系统时间/timestamp -> IP 白名单 -> 合约/交易权限 -> 测试网/主网是否选错。",
"OKX、Bitget、KuCoin 的 passphrase/API 口令不是可选项;如果缺失,必须明确提示补齐。",
"下单失败时继续排查:账户余额/可用保证金 -> 杠杆限制 -> 仓位模式(单向/双向) -> symbol 是否支持合约交易 -> 最小下单金额/数量。",
"Hyperliquid、Aster、Lighter 这类钱包/DEX 配置错误时,不要用 CEX 的 API Key/Secret 逻辑套用;按各自 required fields 解释。",
"诊断回复不得展示完整 API Key、Secret、Passphrase 或私钥。"
]
}

View File

@@ -28,8 +28,8 @@
},
"passphrase": {
"type": "credential",
"required_for": ["okx"],
"description": "OKX 专用 PassphraseOKX 账户启用前必须填写,其他交易所不需要。"
"required_for": ["okx", "bitget", "kucoin"],
"description": "OKX、Bitget、KuCoin 专用 Passphrase/API 口令对这些交易所启用前必须填写Binance、Bybit、Gate、Indodax 通常不需要。"
},
"testnet": {
"type": "bool",
@@ -94,11 +94,10 @@
"api_key 格式:至少 8 位字母数字,不符合时提示用户重新输入完整 Key。",
"secret_key 格式:至少 8 位字母数字,或十六进制格式,不符合时提示用户重新输入。",
"OKX 账户启用前必须填写 passphrase否则拒绝启用并提示补填。",
"Bitget/KuCoin 页面流程里也需要 passphrase缺失时应明确提示补填。",
"BitgetKuCoin 页面流程里也需要 passphrase/API 口令,不能回答“没有就留空”;缺失时应明确提示补填。",
"Hyperliquid 创建/更新时应与手动页面保持一致:至少收集 api_key + hyperliquid_wallet_addr。",
"Hyperliquid 账户启用前必须填写 hyperliquid_wallet_addr。",
"若用户使用 Hyperliquid unified account 模式,应明确记录 hyperliquid_unified_account 开关状态。",
"Aster 账户启用前必须填写 aster_user、aster_signer、aster_private_key 三个字段。",
"Aster 账户启用前必须填写 aster_user、aster_signer、aster_private_key 三个字段,任一缺失都不能启用。",
"Lighter 账户启用前必须填写 lighter_wallet_addr + lighter_api_key_private_key若当前账户模式还依赖 lighter_private_key也要先补齐后再启用。",
"lighter_api_key_index 超出 0255 时自动收敛到边界值并告知用户。",
@@ -125,6 +124,7 @@
"dynamic_rules": [
"确认 exchange_type 后,根据 per_exchange_required_fields 决定需要追问哪些凭证字段。",
"Binance/Bybit/Gate/Indodax 需要 API Key + SecretOKX/Bitget/KuCoin 还必须追问 passphraseHyperliquid 必须追问 api_key + 钱包地址,并允许记录 unified account 开关Aster 必须追问 user/signer/private_keyLighter 必须追问钱包地址和 api_key_private_key。",
"如果用户选择 OKX、Bitget 或 KuCoin不能把 passphrase 说成可选项;没有 passphrase 时应停在补字段,不要创建半成品。",
"凭证字段格式不符时,用人话告知用户正确格式,不要静默丢弃。",
"若当前父任务只是缺一个可用交易所,本动作完成后应允许父任务恢复并消费新的 exchange_id。",
"若请求只是在启用已有交易所,不应误走 create应改走 update_status。"

View File

@@ -2,5 +2,16 @@
"name": "model_diagnosis",
"kind": "diagnosis",
"domain": "model",
"description": "当用户反馈模型配置失败、API Key 无效、Base URL 非法、模型名不匹配、调用返回错误、模型不可用等问题时调用。适用于用户在接入或测试大模型时遇到的配置兼容性故障。不用于创建、修改、删除或查询模型配置这类管理操作。"
"description": "当用户反馈模型配置失败、API Key 无效、Base URL 非法、模型名不匹配、调用返回错误、模型不可用、claw402 钱包余额不足或支付失败等问题时调用。适用于用户在接入或测试大模型时遇到的配置兼容性、支付和调用故障。不用于创建、修改、删除或查询模型配置这类管理操作。",
"capabilities": [
"区分模型未启用、凭证缺失、endpoint/model name 配置错误、钱包余额不足、上游限流或网关异常",
"对 claw402 / blockrun-base 这类钱包付费模型解释钱包地址、USDC 余额和支付状态",
"给出不泄露敏感凭证的下一步修复建议"
],
"dynamic_rules": [
"诊断模型不可用时,按顺序检查:是否存在该模型配置 -> enabled 是否为 true -> provider 是否支持 -> 凭证/API Key 或钱包私钥是否存在 -> custom_api_url 是否合法 HTTPS 或可留空 -> custom_model_name 是否有默认值或已填写 -> 钱包余额/支付状态 -> 上游限流、超时或网关错误。",
"claw402 是模型 provider使用 Base USDC 钱包按次付费;余额为 0 USDC 时应明确说需要充值,不要说成“未配置模型”。",
"429/rate_limit_error、空响应、超时不应默认归因为余额不足只有工具结果或错误文本指向余额/支付失败时才这么判断。",
"任何诊断回复都不得展示 API Key、钱包私钥或完整敏感凭证。"
]
}

View File

@@ -40,6 +40,10 @@
"custom_api_url 若填写,必须是合法 HTTPS 地址,系统拒绝 HTTP 地址,提示用户改用 HTTPS。",
"启用enabled=true前必须填写 provider 对应的凭证;如果 custom_model_name 留空,则系统应先尝试使用 provider 默认模型。",
"启用enabled=truecustom_api_url 若填写必须是合法 HTTPS 地址;不允许用 HTTP 地址硬启用。",
"claw402 是 AI 模型 provider不是交易所、策略或交易员名称用户说“用 claw402”时应解释为选择/绑定 claw402 模型配置。",
"claw402 使用 Base 链 EVM 钱包 + USDC 按次付费enabled=true 只代表模型配置已启用,不代表钱包一定有余额。",
"claw402 或 blockrun-base 钱包余额为 0 USDC 时,应明确提示“钱包余额不足/需要充值”,不要说成“模型未启用”或静默改用其他模型。",
"用户明确指定某个 provider 或模型时,如果当前不可用,必须先说明不可用原因,再让用户选择修复该模型或改用其他已可用模型;不得静默替换。",
"删除操作不可逆,必须先向用户确认再执行。"
],
"actions": {
@@ -52,6 +56,7 @@
"确认 provider 后,先说明该 provider 的默认模型和凭证类型,再按 provider 特性补充追问。",
"普通 provideropenai、deepseek、claude 等)通常需要 api_keycustom_api_url 和 custom_model_name 可留空走默认值。",
"claw402 需要钱包私钥,不需要 custom_api_urlcustom_model_name 留空时默认 deepseek。",
"创建 claw402 后若钱包余额为 0 USDC应提示用户充值 Base USDC 后再用于稳定调用;不要把余额不足误报为配置未启用。",
"blockrun-base 和 blockrun-sol 需要钱包私钥,不需要 custom_api_urlcustom_model_name 留空时默认 auto。",
"若用户提供了 custom_api_url校验是否为合法 HTTPS 地址,不合法则提示修正。",
"OpenAI 的 api_key 不以 sk- 开头时,提示用户检查 Key 格式。",
@@ -68,6 +73,7 @@
"goal": "更新一个已有模型配置的指定字段,而不覆盖未提及字段。",
"dynamic_rules": [
"只更新用户明确提到的字段,不要覆盖未提及的字段。",
"如果用户只是想给 trader 改用 claw402不要在模型配置里误改显示名称应把 claw402 作为 provider/model 选择处理。",
"更新 custom_api_url 时校验 HTTPS 格式。",
"更新 api_key 时对 OpenAI 校验 sk- 前缀。"
],
@@ -79,7 +85,8 @@
"required_slots": ["target_ref", "enabled"],
"goal": "切换模型配置的启用状态。",
"dynamic_rules": [
"启用前必须确保 api_key 和 custom_model_name 已经齐全;若 provider 有特殊规则,也要在提示中体现。"
"启用前必须确保 provider 对应凭证已经齐全;若 provider 有默认模型custom_model_name 可按默认值处理。",
"启用 claw402 只校验钱包私钥等配置完整性;若钱包 0 USDC应提示充值但不要把它等同于 enabled=false。"
],
"success_output": "返回 model_id并明确告知该模型已启用或已禁用。",
"failure_output": "明确指出目标模型不存在、缺少启用前必填项,或当前状态切换失败。"
@@ -119,7 +126,8 @@
"description": "查询所有模型配置列表,包含 provider、名称、启用状态。",
"goal": "列出当前用户可见的模型配置,便于后续选择或绑定。",
"dynamic_rules": [
"优先返回 provider、名称、启用状态不返回 API Key 明文。"
"优先返回 provider、名称、启用状态不返回 API Key 明文。",
"对于 claw402 / blockrun-base若工具结果包含钱包地址或 USDC 余额,应用它解释支付状态;余额不足时要说“需要充值”,不要说“没配置”。"
],
"success_output": "返回模型配置列表摘要。",
"failure_output": "若列表为空,应明确告知当前没有模型配置。"
@@ -129,7 +137,8 @@
"required_slots": ["target_ref"],
"goal": "读取一个模型配置的详细信息。",
"dynamic_rules": [
"详情返回中只能暴露 API Key 是否存在,不得返回明文凭证。"
"详情返回中只能暴露 API Key/钱包私钥是否存在,不得返回明文凭证。",
"对于 claw402应区分三种状态配置未启用、钱包凭证缺失、钱包余额不足。"
],
"success_output": "返回目标模型配置的详细摘要。",
"failure_output": "明确指出目标模型不存在,或当前引用已经失效。"

View File

@@ -2,5 +2,17 @@
"name": "strategy_diagnosis",
"kind": "diagnosis",
"domain": "strategy",
"description": "当用户反馈策略未生效、策略输出异常、提示词或配置结果与预期不一致、策略执行表现异常时调用。适用于策略内容和执行效果相关的排障与解释。不用于创建、修改、删除、激活、复制或查询策略模板这类管理操作。"
"description": "当用户反馈策略未生效、候选币为空、策略输出异常、提示词或配置结果与预期不一致、AI 一直 hold/wait、策略执行表现异常时调用。适用于策略内容、候选币、风控边界和执行效果相关的排障与解释。不用于创建、修改、删除、激活、复制或查询策略模板这类管理操作。",
"capabilities": [
"区分策略模板配置问题、交易员绑定问题、市场数据/候选币问题、AI 决策为 hold/wait、风控拦截和交易所下单失败",
"解释 AI 策略与网格策略的字段边界、页面范围和 System enforced 字段",
"指出策略模板不能直接运行,必须由交易员绑定后执行"
],
"dynamic_rules": [
"策略没生效时,先区分:只是策略模板未被交易员绑定,还是交易员已绑定但运行结果不符合预期。",
"若候选币为空,检查 source_type/static_coins/AI500/OI 榜单/排除币/量化数据开关,不要直接归因为模型问题。",
"若 AI 一直 hold/wait先检查 min_confidence、min_risk_reward_ratio、提示词是否过于保守、行情是否满足入场条件再判断是否需要放宽策略。",
"若交易员绑定了策略但没有下单,应与 trader_diagnosis 协作区分策略无信号、风控拦截和交易所下单失败。",
"策略模板本身不保存交易所、模型、扫描间隔或初始余额;这些问题应引导到 trader/model/exchange 相关诊断。"
]
}

View File

@@ -383,7 +383,9 @@
"策略模板不能直接启动运行,只有绑定了该策略的交易员才能启动。",
"策略模板创建成功后应出现在策略列表/策略页。",
"创建策略模板时不要要求用户先绑定或添加交易所账户,也不要要求绑定 AI 模型;交易所和模型只属于 trader 创建、部署或启动流程。",
"策略是模板/规则,不保存交易所 API、模型 provider、钱包余额或扫描间隔这些属于交易员、模型或交易所配置。",
"如果用户只是创建或配置策略模板,不要把它升级成 trader 创建流程。",
"如果用户说“用某个策略创建/启动交易员”,不要新建同名策略;应先查找并绑定已有策略,只有用户明确要求新策略时才创建。",
"删除操作不可逆,必须先向用户确认再执行。",
"激活activate操作将该策略设为默认模板不是启动运行。",
"scan_interval_minutes、initial_balance、lighter_api_key_index 这类交易员/交易所边界值不属于策略本身,若用户在改策略时提到,应引导去对应 trader 或 exchange 配置。",
@@ -401,6 +403,7 @@
"若用户只是要给 trader 绑定现有策略,应优先在父任务里补 strategy 槽位,而不是误开新的 create。",
"若用户明确要求新建策略,至少先收齐名称;其他配置可继续追问或按默认值协助补齐。",
"创建策略模板本身不需要交易所账户或 AI 模型;不要在 create 策略时询问用户是否已有交易所账户。",
"如果用户同时提到模型 provider、交易所账户或扫描间隔应把这些信息留给 trader 创建流程,不要写入策略配置。",
"只有当用户明确要求运行、部署、实盘、创建交易员或绑定到交易员时,才进入 trader 流程并收集交易所/模型。",
"策略模板创建成功后应出现在策略列表/策略页。",
"只有用户要运行或部署时,才继续 trader 绑定。",
@@ -416,6 +419,7 @@
"goal": "更新一个已有策略模板的指定配置,而不覆盖未提及字段。",
"dynamic_rules": [
"只更新用户明确提到的字段,不要覆盖未提及的字段。",
"如果用户想修改绑定的模型、交易所、扫描间隔或交易员运行状态,应转去 trader_management不要写进策略。",
"杠杆超出 120 范围时,自动收敛并告知用户。",
"grid_trading 类型时lower_price 必须小于 upper_price。"
],
@@ -453,6 +457,7 @@
"dynamic_rules": [
"配置值超出手动面板边界时,应先自动收敛并明确告知用户。",
"若用户一次提到多个配置 patch可在同一轮内整体应用但要明确说明最终修改了哪些字段。",
"拒绝把 exchange_id、ai_model_id、scan_interval_minutes、initial_balance、wallet/private key 等非策略字段作为策略 config 写入。",
"当配置更新涉及 custom_prompt、role_definition、trading_frequency、entry_standards、decision_process、description、name 等文本槽位,且用户表达了“交给你”“你帮我写”“你自己设计”等委托生成意图时,严禁再次向用户索要正文。",
"此时你必须直接生成一版可用文本,写入对应 extracted 字段,并用确认式问题向用户展示:“我先为你拟了一版……,要直接按这版更新吗?”"
],

View File

@@ -11,6 +11,10 @@
"dynamic_rules": [
"当用户问“为什么报错”“为什么不交易”“为什么停了”这类问题时,优先走诊断而不是管理类 skill。",
"如果已经能唯一确定目标交易员,应优先结合 get_backend_logs、持仓、状态和决策记录一起分析而不是只看配置。",
"交易员不下单的排查顺序固定为:是否运行中 -> 是否已到扫描间隔 -> 策略候选币/行情数据是否为空 -> 最近 AI 决策是否为 hold/wait -> 风控是否拦截 -> 交易所下单是否报错 -> 余额、杠杆、仓位模式或权限是否限制。",
"如果模型是 claw402 或 blockrun-base应单独检查钱包 USDC 余额;余额不足时应说“支付余额不足/需要充值”,不要泛化成“模型没启用”。",
"如果日志显示 AI 返回 hold/wait应解释为模型判断当前没有足够交易信号不应误判为系统没有运行。",
"如果日志显示下单失败应优先归因到交易所权限、API 凭证、仓位模式、余额、杠杆或 symbol 可交易性,而不是策略没有生效。",
"当用户表达“启动不了”“启动失败”“无法启动”“一启动就报错”“为什么启动不起来”这类启动故障时,只要目标交易员能唯一确定,就优先自动读取 get_backend_logs。",
"当日志中已经出现明确错误原因时,直接用人话解释原因和下一步,不要只复述原始日志。"
],

View File

@@ -62,6 +62,9 @@
"strategy_id 对应的策略模板必须存在,否则无法创建交易员。",
"scan_interval_minutes 超出 360 范围时,系统自动收敛到边界值,并通过 LLM 告知用户已调整,询问是否接受。",
"交易员初始余额由系统在创建时自动读取绑定交易所账户净值,不接受用户手动设置、充值或修改。",
"交易员名称不能从模型 provider 自动推断;用户说“用 claw402”表示模型选择不表示交易员名称叫 claw402。",
"用户明确指定模型、交易所或策略时,若该资源不存在、被禁用、配置不完整或钱包余额不足,必须说明具体原因并让用户确认修复或替换;不得静默换成另一个资源。",
"若用户指定 claw402 作为模型,但 claw402 钱包余额为 0 USDC应提示先充值或确认临时改用其他可用模型不得说成 claw402 未启用,除非 enabled 确实为 false。",
"启动交易员前,绑定的模型必须已启用且完整,绑定的交易所也必须已启用且通过对应交易所的完整性校验,否则拒绝启动并明确指出缺哪一项。",
"若绑定的是 OKX 交易所,启用前必须已有 passphrase若绑定的是 Hyperliquid启用前必须已有 wallet_addr若绑定的是 Aster启用前必须已有 user、signer、private_key若绑定的是 Lighter启用前必须已有 wallet_addr 和 api_key_private_key。",
"启动start和停止stop操作属于高风险操作必须先向用户确认再执行。",
@@ -75,6 +78,8 @@
"goal": "创建并初始化一个交易员。",
"dynamic_rules": [
"若用户提到的交易所、模型或策略已经存在且可用,应优先直接补入对应槽位,不要重新创建。",
"如果用户明确指定某个模型 provider如 claw402应先尝试匹配该 provider 对应的模型配置;只有在说明原因并得到用户确认后,才可改用其他模型。",
"若用户没有提供交易员名称,应生成一个来自交易所/策略/方向的清晰名称,或向用户追问;不要把模型 provider、交易所类型或策略字段误用为交易员名称。",
"若依赖资源不存在、被禁用,或用户明确要求新建或启用,禁止直接报缺字段;应切去对应 management:create 或 management:update_status 子任务。",
"子任务成功后,系统会恢复当前交易员草稿并继续补齐剩余槽位。",
"scan_interval_minutes 超出 360 时,自动收敛并告知用户。",
@@ -91,7 +96,8 @@
"goal": "更新一个已有交易员的手动面板字段,但不改动策略、模型、交易所内部配置。",
"dynamic_rules": [
"只更新用户明确提到的字段,不要覆盖未提及的字段。",
"换绑交易所/模型/策略时,新的资源必须已存在且已启用,否则提示用户先启用或新建。",
"换绑交易所/模型/策略时,新的资源必须已存在且已启用;若是钱包付费模型,还要解释余额不足等支付状态。",
"用户明确要求换成某个模型/交易所/策略时,不能自动选择另一个看起来可用的资源,除非用户确认。",
"如果用户要求改名,应明确告知交易员改名不在这里处理。",
"如果用户实际上是想修改策略参数、模型配置或交易所凭证,不要继续留在 trader update应切到对应 management skill。"
],
@@ -105,6 +111,7 @@
"goal": "调整交易员手动面板可编辑的字段,而不改动无关配置。",
"dynamic_rules": [
"新绑定的资源必须已存在且已启用,否则提示用户先启用或新建。",
"当指定模型是 claw402 或 blockrun-base 且钱包余额不足时,应提示充值或让用户确认临时切换模型。",
"扫描间隔超出 360 时,自动收敛并告知用户。"
],
"success_output": "返回 trader_id并明确展示新的模型/交易所/策略绑定结果。",
@@ -135,7 +142,8 @@
"required_slots": ["target_ref", "ai_model_id"],
"goal": "为指定交易员换绑一个 AI 模型配置。",
"dynamic_rules": [
"新的模型配置必须已启用且可调用,否则提示用户先启用或补齐模型配置。"
"新的模型配置必须已启用且可调用,否则提示用户先启用或补齐模型配置。",
"若用户指定的是 claw402应优先绑定 claw402只有在钱包余额不足、凭证缺失或配置不可用且用户确认后才允许改绑其他模型。"
],
"success_output": "返回 trader_id并明确告知当前生效的 ai_model_id/模型名称。",
"failure_output": "明确指出目标交易员或模型不存在,或模型当前不可用。"
@@ -147,6 +155,7 @@
"goal": "让一个已配置好的交易员进入运行状态。",
"dynamic_rules": [
"启动前系统会自动校验绑定的交易所、模型、策略是否均可用。",
"若绑定模型为 claw402 或 blockrun-base 且钱包余额不足,应提示充值或换模型;不要把它泛化成“模型不可用”。",
"若校验失败,用人话告知用户具体哪个依赖不可用,并引导修复。"
],
"success_output": "返回 trader_id并明确告知交易员已开始运行。",

View File

@@ -938,6 +938,8 @@ type safeModelToolConfig struct {
HasAPIKey bool `json:"has_api_key"`
CustomAPIURL string `json:"custom_api_url,omitempty"`
CustomModelName string `json:"custom_model_name,omitempty"`
WalletAddress string `json:"wallet_address,omitempty"`
BalanceUSDC string `json:"balance_usdc,omitempty"`
}
type safeTraderToolConfig struct {
@@ -1115,7 +1117,7 @@ func extractTraderInitialBalance(balanceInfo map[string]interface{}) (float64, b
}
func safeModelForTool(model *store.AIModel) safeModelToolConfig {
return safeModelToolConfig{
safeModel := safeModelToolConfig{
ID: model.ID,
Name: model.Name,
Provider: model.Provider,
@@ -1124,6 +1126,18 @@ func safeModelForTool(model *store.AIModel) safeModelToolConfig {
CustomAPIURL: model.CustomAPIURL,
CustomModelName: model.CustomModelName,
}
if agentProviderSupportsUSDCBalance(model.Provider) {
privateKey := strings.TrimSpace(string(model.APIKey))
if privateKey != "" {
if walletAddress, err := agentWalletAddressFromPrivateKey(privateKey); err == nil && strings.TrimSpace(walletAddress) != "" {
safeModel.WalletAddress = walletAddress
if balance, balanceErr := agentQueryUSDCBalanceCached(walletAddress); balanceErr == nil {
safeModel.BalanceUSDC = fmt.Sprintf("%.6f", balance)
}
}
}
}
return safeModel
}
func modelConfigUsable(provider, modelID, apiKey, customAPIURL, customModelName string) bool {

View File

@@ -3,6 +3,7 @@ package agent
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
@@ -165,13 +166,23 @@ func (w *WebHandler) HandleChatStream(rw http.ResponseWriter, r *http.Request) {
defer cancel()
resp, err := w.agent.HandleMessageStreamForStoreUser(ctx, storeUserIDFromContext(r.Context()), req.UserID, msg, func(event, data string) {
if ctx.Err() != nil {
return
}
writeSSE(rw, flusher, event, data)
})
if err != nil {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) || ctx.Err() != nil {
w.logger.Info("agent stream cancelled", "user_id", req.UserID, "error", err)
return
}
w.logger.Error("agent HandleMessageStream failed", "error", err, "user_id", req.UserID)
writeSSE(rw, flusher, "error", "I ran into a problem while handling that message. Please try again.")
return
}
if ctx.Err() != nil {
return
}
// Send final done event with complete response
writeSSE(rw, flusher, "done", resp)
}

View File

@@ -1,5 +1,12 @@
import { useRef, useState, useCallback, useEffect, useImperativeHandle, forwardRef } from 'react'
import { ArrowUp } from 'lucide-react'
import {
useRef,
useState,
useCallback,
useEffect,
useImperativeHandle,
forwardRef,
} from 'react'
import { ArrowUp, Square } from 'lucide-react'
export interface ChatInputHandle {
focus: () => void
@@ -10,43 +17,60 @@ export interface ChatInputHandle {
interface ChatInputProps {
language: string
loading: boolean
value: string
onChange: (value: string) => void
onSend: (text: string) => void
onStop: () => void
}
export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
function ChatInput({ language, loading, onSend }, ref) {
const [input, setInput] = useState('')
function ChatInput(
{ language, loading, value, onChange, onSend, onStop },
ref
) {
const [composing, setComposing] = useState(false)
const inputRef = useRef<HTMLTextAreaElement>(null)
useImperativeHandle(ref, () => ({
focus: () => inputRef.current?.focus(),
clear: () => {
setInput('')
if (inputRef.current) inputRef.current.style.height = 'auto'
},
getValue: () => input,
}))
useImperativeHandle(
ref,
() => ({
focus: () => inputRef.current?.focus(),
clear: () => {
onChange('')
if (inputRef.current) inputRef.current.style.height = 'auto'
},
getValue: () => value,
}),
[onChange, value]
)
const resizeInput = useCallback(() => {
const el = inputRef.current
if (!el) return
el.style.height = 'auto'
el.style.height = Math.min(el.scrollHeight, 150) + 'px'
}, [])
const handleInputChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
setInput(e.target.value)
const el = e.target
el.style.height = 'auto'
el.style.height = Math.min(el.scrollHeight, 150) + 'px'
onChange(e.target.value)
},
[]
[onChange]
)
const handleSend = () => {
const msg = input.trim()
const msg = value.trim()
if (!msg || loading) return
setInput('')
onChange('')
if (inputRef.current) inputRef.current.style.height = 'auto'
onSend(msg)
inputRef.current?.focus()
}
useEffect(() => {
resizeInput()
}, [resizeInput, value])
// Keyboard shortcut: Cmd+K to focus
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
@@ -84,7 +108,7 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
>
<textarea
ref={inputRef}
value={input}
value={value}
onChange={handleInputChange}
onCompositionStart={() => setComposing(true)}
onCompositionEnd={() => setComposing(false)}
@@ -115,26 +139,40 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
}}
/>
<button
onClick={handleSend}
disabled={loading || !input.trim()}
onClick={loading ? onStop : handleSend}
disabled={!loading && !value.trim()}
title={
loading
? language === 'zh'
? '停止当前回复'
: 'Stop current response'
: language === 'zh'
? '发送'
: 'Send'
}
style={{
width: 36,
height: 36,
borderRadius: 12,
border: 'none',
background:
loading || !input.trim()
background: loading
? 'rgba(239,68,68,0.16)'
: !value.trim()
? 'rgba(255,255,255,0.04)'
: 'linear-gradient(135deg, #F0B90B, #d4a30a)',
color: loading || !input.trim() ? '#3c3c52' : '#000',
cursor: loading || !input.trim() ? 'not-allowed' : 'pointer',
color: loading ? '#f87171' : !value.trim() ? '#3c3c52' : '#000',
cursor: !loading && !value.trim() ? 'not-allowed' : 'pointer',
display: 'grid',
placeItems: 'center',
flexShrink: 0,
transition: 'all 0.2s ease',
}}
>
<ArrowUp size={16} strokeWidth={2.5} />
{loading ? (
<Square size={13} strokeWidth={2.6} fill="currentColor" />
) : (
<ArrowUp size={16} strokeWidth={2.5} />
)}
</button>
</div>
<div

View File

@@ -15,6 +15,10 @@ export function chatStorageKey(userId?: string) {
return `nofxi-agent-chat:${userId || 'guest'}`
}
export function chatDraftStorageKey(userId?: string) {
return `nofxi-agent-chat-draft:${userId || 'guest'}`
}
export function getStoredAuthUserId(storage: Storage = window.localStorage) {
try {
const raw = storage.getItem('auth_user')
@@ -65,8 +69,38 @@ export function persistAgentMessages<T>(
storage.setItem(chatStorageKey(userId), JSON.stringify(messages))
}
export function loadAgentDraft(storage: Storage, userId?: string) {
try {
return storage.getItem(chatDraftStorageKey(userId)) || ''
} catch {
return ''
}
}
export function persistAgentDraft(
storage: Storage,
userId: string | undefined,
draft: string
) {
storage.setItem(chatDraftStorageKey(userId), draft)
}
export function clearAgentDraft(storage: Storage, userId?: string) {
for (const key of [
chatDraftStorageKey(userId),
chatDraftStorageKey('guest'),
]) {
storage.removeItem(key)
}
}
export function prepareAgentMessagesForPersistence<
T extends { streaming?: boolean; text?: string; steps?: unknown[]; time?: string }
T extends {
streaming?: boolean
text?: string
steps?: unknown[]
time?: string
},
>(messages: T[]): T[] {
return messages.map((message) => {
if (!message.streaming) {
@@ -89,7 +123,10 @@ export function migrateAgentMessages(storage: Storage, userId?: string) {
const targetMessages = loadMessagesFromKey(storage, targetKey)
if (targetMessages.length > 0) return
for (const sourceKey of [chatStorageKey('guest'), LEGACY_AGENT_CHAT_STORAGE_KEY]) {
for (const sourceKey of [
chatStorageKey('guest'),
LEGACY_AGENT_CHAT_STORAGE_KEY,
]) {
const sourceMessages = loadMessagesFromKey(storage, sourceKey)
if (sourceMessages.length === 0) continue
storage.setItem(targetKey, JSON.stringify(sourceMessages))
@@ -101,4 +138,5 @@ export function clearAgentMessages(storage: Storage, userId?: string) {
for (const key of candidateStorageKeys(userId)) {
storage.removeItem(key)
}
clearAgentDraft(storage, userId)
}

View File

@@ -19,17 +19,17 @@ import { WelcomeScreen } from '../components/agent/WelcomeScreen'
import { ChatMessages } from '../components/agent/ChatMessages'
import { ChatInput, type ChatInputHandle } from '../components/agent/ChatInput'
import { UserPreferencesPanel } from '../components/agent/UserPreferencesPanel'
import {
useAgentChatStore,
} from '../stores/agentChatStore'
import { useAgentChatStore } from '../stores/agentChatStore'
import type { AgentMessage as Message, AgentStep } from '../types/agent'
import {
chatStorageKey,
clearAgentMessages,
getStoredAuthUserId,
loadAgentDraft,
loadAgentMessages,
migrateAgentMessages,
prepareAgentMessagesForPersistence,
persistAgentDraft,
persistAgentMessages,
} from '../lib/agentChatStorage'
@@ -50,6 +50,34 @@ function cleanupActiveAgentStream() {
activeStreamReader = null
}
function stopActiveAgentStream(userId?: string, language = 'zh') {
if (!activeStreamAbortController && !activeStreamReader) return
const stoppedText =
language === 'zh' ? '已中止当前回复。' : 'Stopped the current response.'
const now = new Date().toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})
patchMessagesInStore(
(prev) =>
prev.map((m) => {
if (m.role !== 'bot' || !m.streaming) return m
const text = m.text?.trim()
? `${m.text.trimEnd()}\n\n${stoppedText}`
: stoppedText
return {
...m,
text,
streaming: false,
time: m.time || now,
}
}),
userId
)
cleanupActiveAgentStream()
useAgentChatStore.getState().setLoading(false)
}
function persistMessagesSnapshotForUser(userId?: string) {
const { hydrated, messages } = useAgentChatStore.getState()
if (!hydrated) return
@@ -109,6 +137,7 @@ async function runAgentStream(params: {
if (text.trim() === '/clear') {
try {
clearAgentMessages(window.localStorage, storageUserId)
useAgentChatStore.getState().setDraftText('')
} catch {
// Ignore storage cleanup failure.
}
@@ -191,9 +220,7 @@ async function runAgentStream(params: {
patchMessagesInStore(
(prev) =>
prev.map((m) =>
m.id === botId
? { ...m, text: finalText, time: now() }
: m
m.id === botId ? { ...m, text: finalText, time: now() } : m
),
storageUserId
)
@@ -424,10 +451,12 @@ export function AgentChatPage() {
const [sidebarOpen, setSidebarOpen] = useState(() => window.innerWidth > 1024)
const storageKey = chatStorageKey(user?.id || storageUserId)
const messages = useAgentChatStore((state) => state.messages)
const draftText = useAgentChatStore((state) => state.draftText)
const loading = useAgentChatStore((state) => state.loading)
const historyHydrated = useAgentChatStore((state) => state.hydrated)
const activeUserId = useAgentChatStore((state) => state.activeUserId)
const resetForUser = useAgentChatStore((state) => state.resetForUser)
const setDraftText = useAgentChatStore((state) => state.setDraftText)
const messagesEndRef = useRef<HTMLDivElement>(null)
const chatInputRef = useRef<ChatInputHandle>(null)
@@ -465,10 +494,12 @@ export function AgentChatPage() {
nextUserId,
loadAgentMessages<Message>(window.localStorage, nextUserId).messages
)
setDraftText(loadAgentDraft(window.localStorage, nextUserId))
}, [
activeUserId,
historyHydrated,
resetForUser,
setDraftText,
storageKey,
storageUserId,
user?.id,
@@ -490,6 +521,21 @@ export function AgentChatPage() {
}
}, [historyHydrated, messages, storageKey, storageUserId, user?.id])
// Persist the unsent draft so navigating away from the Agent page does not
// wipe what the user was typing.
useEffect(() => {
if (!historyHydrated) return
try {
persistAgentDraft(
window.localStorage,
user?.id || storageUserId,
draftText
)
} catch {
// Ignore storage failures and keep typing responsive.
}
}, [draftText, historyHydrated, storageKey, storageUserId, user?.id])
// Responsive sidebar
useEffect(() => {
const handleResize = () => {
@@ -526,6 +572,11 @@ export function AgentChatPage() {
})
}
const stopCurrentResponse = () => {
stopActiveAgentStream(user?.id || storageUserId, language)
chatInputRef.current?.focus()
}
const quickActions =
language === 'zh'
? [
@@ -682,7 +733,10 @@ export function AgentChatPage() {
ref={chatInputRef}
language={language}
loading={loading}
value={draftText}
onChange={setDraftText}
onSend={send}
onStop={stopCurrentResponse}
/>
</div>

View File

@@ -4,10 +4,12 @@ import type { AgentMessage } from '../types/agent'
interface AgentChatStoreState {
activeUserId?: string
messages: AgentMessage[]
draftText: string
loading: boolean
hydrated: boolean
setActiveUserId: (userId?: string) => void
setMessages: (messages: AgentMessage[]) => void
setDraftText: (draftText: string) => void
updateMessages: (
updater: (messages: AgentMessage[]) => AgentMessage[]
) => void
@@ -19,10 +21,12 @@ interface AgentChatStoreState {
export const useAgentChatStore = create<AgentChatStoreState>((set) => ({
activeUserId: undefined,
messages: [],
draftText: '',
loading: false,
hydrated: false,
setActiveUserId: (userId) => set({ activeUserId: userId }),
setMessages: (messages) => set({ messages }),
setDraftText: (draftText) => set({ draftText }),
updateMessages: (updater) =>
set((state) => ({ messages: updater(state.messages) })),
setLoading: (loading) => set({ loading }),
@@ -31,6 +35,7 @@ export const useAgentChatStore = create<AgentChatStoreState>((set) => ({
set({
activeUserId: userId,
messages,
draftText: '',
loading: false,
hydrated: true,
}),