Files
nofx/agent/skill_execution_handlers.go
shinchan-zhai 132fd93072 fix(agent,trader): guard nil TargetRef in skill handlers and fix toast indentation
- Add nil checks for session.TargetRef in all four execute*Action handlers
  (Trader/Exchange/Model/Strategy) to prevent panic on corrupted sessions;
  actions that don't need a target (query/query_list/create) are excluded.
- Fix toast.success indentation in handleToggleTrader so success messages
  only fire when the API call actually succeeds.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-26 00:00:26 +08:00

1300 lines
45 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package agent
import (
"encoding/json"
"fmt"
"regexp"
"sort"
"strconv"
"strings"
"nofx/store"
)
var (
firstIntegerPattern = regexp.MustCompile(`\d+`)
timeframeTokenRE = regexp.MustCompile(`(?i)\b\d{1,2}[mhdw]\b`)
)
func parseStandaloneInteger(text string) (int, bool) {
match := firstIntegerPattern.FindString(strings.TrimSpace(text))
if match == "" {
return 0, false
}
value, err := strconv.Atoi(match)
if err != nil {
return 0, false
}
return value, true
}
func parseEnabledValue(text string) (bool, bool) {
lower := strings.ToLower(strings.TrimSpace(text))
switch {
case containsAny(lower, []string{"启用", "打开", "开启", "enable", "enabled", "on"}):
return true, true
case containsAny(lower, []string{"禁用", "关闭", "停用", "disable", "disabled", "off"}):
return false, true
default:
return false, false
}
}
func parseFlagValue(text string, keywords []string) (bool, bool) {
lower := strings.ToLower(strings.TrimSpace(text))
if lower == "" || !containsAny(lower, keywords) {
return false, false
}
switch {
case containsAny(lower, []string{"启用", "打开", "开启", "使用", "用", "是", "true", "enable", "enabled", "on", "use"}):
return true, true
case containsAny(lower, []string{"禁用", "关闭", "停用", "不用", "不要", "否", "false", "disable", "disabled", "off", "don't use", "do not use"}):
return false, true
default:
return false, false
}
}
func extractCredentialValue(text string, keywords []string) string {
if value := extractQuotedContent(text); value != "" && containsAny(strings.ToLower(text), keywords) {
return value
}
return extractPostKeywordName(text, keywords)
}
func parseScanIntervalMinutes(text string) (int, bool) {
if value, ok := extractLabeledInt(text, []string{"扫描间隔", "扫描频率", "scan interval", "scan frequency"}); ok {
return value, true
}
lower := strings.ToLower(strings.TrimSpace(text))
if !containsAny(lower, []string{"扫描间隔", "扫描频率", "scan interval", "scan frequency"}) {
return 0, false
}
return parseStandaloneInteger(text)
}
func detectStrategyConfigField(text string) string {
lower := strings.ToLower(strings.TrimSpace(text))
switch {
case containsAny(lower, []string{"最大持仓", "最多持仓", "max positions"}):
return "max_positions"
case containsAny(lower, []string{"最低置信度", "最小置信度", "min confidence"}):
return "min_confidence"
case containsAny(lower, []string{"btc/eth杠杆", "btc eth杠杆", "btc eth leverage", "btc/eth leverage", "主流币杠杆"}):
return "btceth_max_leverage"
case containsAny(lower, []string{"山寨币杠杆", "altcoin leverage", "alts leverage"}):
return "altcoin_max_leverage"
case containsAny(lower, []string{"ema"}):
return "enable_ema"
case containsAny(lower, []string{"macd"}):
return "enable_macd"
case containsAny(lower, []string{"rsi"}):
return "enable_rsi"
case containsAny(lower, []string{"atr"}):
return "enable_atr"
case containsAny(lower, []string{"boll", "bollinger", "布林"}):
return "enable_boll"
case containsAny(lower, []string{"核心指标"}) && containsAny(lower, []string{"全选", "全部", "全开", "都开", "都启用", "全部启用"}):
return "enable_all_core_indicators"
case containsAny(lower, []string{"主周期", "主时间周期", "primary timeframe"}):
return "primary_timeframe"
case containsAny(lower, []string{"多周期", "时间框架", "timeframes", "selected timeframes"}):
return "selected_timeframes"
default:
return ""
}
}
func strategyConfigFieldDisplayName(field, lang string) string {
switch field {
case "max_positions":
if lang == "zh" {
return "最大持仓"
}
return "max positions"
case "min_confidence":
if lang == "zh" {
return "最小置信度"
}
return "min confidence"
case "btceth_max_leverage":
if lang == "zh" {
return "BTC/ETH 最大杠杆"
}
return "BTC/ETH max leverage"
case "altcoin_max_leverage":
if lang == "zh" {
return "山寨币最大杠杆"
}
return "altcoin max leverage"
case "enable_ema":
if lang == "zh" {
return "EMA"
}
return "EMA"
case "enable_macd":
if lang == "zh" {
return "MACD"
}
return "MACD"
case "enable_rsi":
if lang == "zh" {
return "RSI"
}
return "RSI"
case "enable_atr":
if lang == "zh" {
return "ATR"
}
return "ATR"
case "enable_boll":
if lang == "zh" {
return "Bollinger"
}
return "Bollinger"
case "enable_all_core_indicators":
if lang == "zh" {
return "全部核心指标"
}
return "all core indicators"
case "primary_timeframe":
if lang == "zh" {
return "主周期"
}
return "primary timeframe"
case "selected_timeframes":
if lang == "zh" {
return "多周期时间框架"
}
return "selected timeframes"
default:
return field
}
}
func extractStrategyConfigValue(text, field string) (string, bool) {
switch field {
case "max_positions":
if value, ok := extractLabeledInt(text, []string{"最大持仓", "最多持仓", "max positions"}); ok {
return strconv.Itoa(value), true
}
if value, ok := parseStandaloneInteger(text); ok {
return strconv.Itoa(value), true
}
case "min_confidence":
if value, ok := extractLabeledInt(text, []string{"最低置信度", "最小置信度", "min confidence"}); ok {
return strconv.Itoa(value), true
}
if value, ok := parseStandaloneInteger(text); ok {
return strconv.Itoa(value), true
}
case "btceth_max_leverage":
if value, ok := extractLabeledInt(text, []string{"btc/eth杠杆", "btc eth杠杆", "btc/eth leverage", "btc eth leverage", "主流币杠杆"}); ok {
return strconv.Itoa(value), true
}
if value, ok := parseStandaloneInteger(text); ok {
return strconv.Itoa(value), true
}
case "altcoin_max_leverage":
if value, ok := extractLabeledInt(text, []string{"山寨币杠杆", "altcoin leverage", "alts leverage"}); ok {
return strconv.Itoa(value), true
}
if value, ok := parseStandaloneInteger(text); ok {
return strconv.Itoa(value), true
}
case "enable_ema", "enable_macd", "enable_rsi", "enable_atr", "enable_boll":
if enabled, ok := parseEnabledValue(text); ok {
return strconv.FormatBool(enabled), true
}
case "enable_all_core_indicators":
lower := strings.ToLower(strings.TrimSpace(text))
switch {
case containsAny(lower, []string{"全选", "全部", "全开", "都开", "都启用", "全部启用"}):
return "true", true
case containsAny(lower, []string{"关闭", "停用", "禁用", "全部关闭", "全部禁用"}):
return "false", true
}
case "primary_timeframe":
if tf := extractTimeframeAfterKeywords(text, []string{"主周期", "主时间周期", "primary timeframe", "timeframe"}); tf != "" {
return tf, true
}
case "selected_timeframes":
if tfs := extractTimeframes(text); len(tfs) > 0 {
return strings.Join(tfs, ","), true
}
}
return "", false
}
type strategyConfigPatch struct {
Field string
Value string
}
func detectStrategyConfigPatches(text string) []strategyConfigPatch {
seen := map[string]string{}
addPatch := func(field, value string) {
field = strings.TrimSpace(field)
value = strings.TrimSpace(value)
if field == "" || value == "" {
return
}
seen[field] = value
}
for _, field := range []string{
"max_positions",
"min_confidence",
"btceth_max_leverage",
"altcoin_max_leverage",
"primary_timeframe",
"selected_timeframes",
"enable_ema",
"enable_macd",
"enable_rsi",
"enable_atr",
"enable_boll",
"enable_all_core_indicators",
} {
if value, ok := extractStrategyConfigValue(text, field); ok {
if field == "enable_all_core_indicators" {
addPatch("enable_ema", value)
addPatch("enable_macd", value)
addPatch("enable_rsi", value)
addPatch("enable_atr", value)
addPatch("enable_boll", value)
continue
}
addPatch(field, value)
}
}
fields := make([]string, 0, len(seen))
for field := range seen {
fields = append(fields, field)
}
sort.Strings(fields)
patches := make([]strategyConfigPatch, 0, len(fields))
for _, field := range fields {
patches = append(patches, strategyConfigPatch{Field: field, Value: seen[field]})
}
return patches
}
func applyStrategyConfigPatch(cfg *store.StrategyConfig, field, value string) error {
switch field {
case "max_positions":
parsed, err := strconv.Atoi(value)
if err != nil {
return fmt.Errorf("最大持仓需要是整数")
}
cfg.RiskControl.MaxPositions = parsed
case "min_confidence":
parsed, err := strconv.Atoi(value)
if err != nil {
return fmt.Errorf("最小置信度需要是整数")
}
cfg.RiskControl.MinConfidence = parsed
case "btceth_max_leverage":
parsed, err := strconv.Atoi(value)
if err != nil {
return fmt.Errorf("BTC/ETH 最大杠杆需要是整数")
}
cfg.RiskControl.BTCETHMaxLeverage = parsed
case "altcoin_max_leverage":
parsed, err := strconv.Atoi(value)
if err != nil {
return fmt.Errorf("山寨币最大杠杆需要是整数")
}
cfg.RiskControl.AltcoinMaxLeverage = parsed
case "primary_timeframe":
cfg.Indicators.Klines.PrimaryTimeframe = value
case "selected_timeframes":
tfs := strings.Split(value, ",")
cfg.Indicators.Klines.SelectedTimeframes = tfs
cfg.Indicators.Klines.EnableMultiTimeframe = len(tfs) > 1
case "enable_ema":
cfg.Indicators.EnableEMA = value == "true"
case "enable_macd":
cfg.Indicators.EnableMACD = value == "true"
case "enable_rsi":
cfg.Indicators.EnableRSI = value == "true"
case "enable_atr":
cfg.Indicators.EnableATR = value == "true"
case "enable_boll":
cfg.Indicators.EnableBOLL = value == "true"
default:
return fmt.Errorf("unsupported strategy config field: %s", field)
}
return nil
}
func (a *Agent) executeTraderManagementAction(storeUserID string, userID int64, lang, text string, session skillSession) string {
if session.TargetRef == nil && session.Action != "query" && session.Action != "query_list" && session.Action != "create" {
if lang == "zh" {
return "请先告诉我你要操作哪个交易员。"
}
return "Please specify which trader you want to manage."
}
switch session.Action {
case "query", "query_list":
return formatReadFastPathResponse(lang, "list_traders", a.toolListTraders(storeUserID))
case "query_detail":
if detail, ok := a.describeTrader(storeUserID, lang, session.TargetRef); ok {
return detail
}
return formatReadFastPathResponse(lang, "list_traders", a.toolListTraders(storeUserID))
case "start", "stop", "delete":
if fieldValue(session, skillDAGStepField) == "" {
setSkillDAGStep(&session, "await_confirmation")
}
if msg, waiting := beginConfirmationIfNeeded(userID, lang, &session, defaultIfEmpty(session.TargetRef.Name, session.TargetRef.ID)); waiting {
a.saveSkillSession(userID, session)
return msg
}
if msg, waiting := awaitingConfirmationButNotApproved(lang, session, text); waiting {
a.saveSkillSession(userID, session)
return msg
}
var resp string
switch session.Action {
case "start":
setSkillDAGStep(&session, "execute_start")
resp = a.toolStartTrader(storeUserID, session.TargetRef.ID)
case "stop":
setSkillDAGStep(&session, "execute_stop")
resp = a.toolStopTrader(storeUserID, session.TargetRef.ID)
case "delete":
setSkillDAGStep(&session, "execute_delete")
resp = a.toolDeleteTrader(storeUserID, session.TargetRef.ID)
}
a.clearSkillSession(userID)
if errMsg := parseSkillError(resp); strings.Contains(resp, `"error"`) {
if lang == "zh" {
return "执行失败:" + errMsg
}
return "Action failed: " + errMsg
}
if lang == "zh" {
return fmt.Sprintf("已完成交易员操作:%s。", session.Action)
}
return fmt.Sprintf("Completed trader action: %s.", session.Action)
case "update", "update_name", "update_bindings":
if session.Action == "update_bindings" {
if fieldValue(session, skillDAGStepField) == "" {
setSkillDAGStep(&session, "collect_bindings")
}
args := manageTraderArgs{Action: "update", TraderID: session.TargetRef.ID}
if match := pickMentionedOption(text, a.loadEnabledModelOptions(storeUserID)); match != nil {
args.AIModelID = match.ID
}
if match := pickMentionedOption(text, a.loadExchangeOptions(storeUserID)); match != nil {
args.ExchangeID = match.ID
}
if match := pickMentionedOption(text, a.loadStrategyOptions(storeUserID)); match != nil {
args.StrategyID = match.ID
}
if args.AIModelID != "" {
setField(&session, "ai_model_id", args.AIModelID)
}
if args.ExchangeID != "" {
setField(&session, "exchange_id", args.ExchangeID)
}
if args.StrategyID != "" {
setField(&session, "strategy_id", args.StrategyID)
}
if value := fieldValue(session, "ai_model_id"); value != "" {
args.AIModelID = value
}
if value := fieldValue(session, "exchange_id"); value != "" {
args.ExchangeID = value
}
if value := fieldValue(session, "strategy_id"); value != "" {
args.StrategyID = value
}
if args.AIModelID == "" && args.ExchangeID == "" && args.StrategyID == "" {
setSkillDAGStep(&session, "collect_bindings")
a.saveSkillSession(userID, session)
if lang == "zh" {
return "这次是更新交易员绑定,请直接说要换成哪个模型、交易所或策略。"
}
return "This action updates trader bindings. Tell me which model, exchange, or strategy to switch to."
}
setSkillDAGStep(&session, "execute_update")
resp := a.toolUpdateTrader(storeUserID, args)
a.clearSkillSession(userID)
if errMsg := parseSkillError(resp); strings.Contains(resp, `"error"`) {
if lang == "zh" {
return "更新交易员绑定失败:" + errMsg
}
return "Failed to update trader bindings: " + errMsg
}
if lang == "zh" {
return "已更新交易员绑定。"
}
return "Updated trader bindings."
}
if fieldValue(session, skillDAGStepField) == "" {
setSkillDAGStep(&session, "collect_name")
}
args := manageTraderArgs{Action: "update", TraderID: session.TargetRef.ID}
if minutes, ok := parseScanIntervalMinutes(text); ok && minutes > 0 {
args.ScanIntervalMinutes = &minutes
}
if value, ok := extractStrategyConfigValue(text, "btceth_max_leverage"); ok {
if parsed, err := strconv.Atoi(value); err == nil {
args.BTCETHLeverage = &parsed
}
}
if value, ok := extractStrategyConfigValue(text, "altcoin_max_leverage"); ok {
if parsed, err := strconv.Atoi(value); err == nil {
args.AltcoinLeverage = &parsed
}
}
if prompt := extractCredentialValue(text, []string{"自定义提示词", "提示词", "custom prompt", "prompt"}); prompt != "" &&
containsAny(strings.ToLower(text), []string{"提示词", "prompt"}) {
args.CustomPrompt = prompt
}
if enabled, ok := parseFlagValue(text, []string{"ai500"}); ok {
args.UseAI500 = &enabled
}
if enabled, ok := parseFlagValue(text, []string{"oi top", "oitop", "持仓量排名"}); ok {
args.UseOITop = &enabled
}
if args.ScanIntervalMinutes != nil || args.BTCETHLeverage != nil || args.AltcoinLeverage != nil || args.CustomPrompt != "" || args.UseAI500 != nil || args.UseOITop != nil {
setSkillDAGStep(&session, "execute_update")
resp := a.toolUpdateTrader(storeUserID, args)
a.clearSkillSession(userID)
if errMsg := parseSkillError(resp); strings.Contains(resp, `"error"`) {
if lang == "zh" {
return "更新交易员失败:" + errMsg
}
return "Failed to update trader: " + errMsg
}
if lang == "zh" {
return "已更新交易员配置。"
}
return "Updated trader config."
}
newName := extractTraderName(text)
if newName == "" {
newName = extractPostKeywordName(text, []string{"改成", "改为", "rename to"})
}
if newName != "" {
setField(&session, "name", newName)
}
newName = fieldValue(session, "name")
if newName == "" {
setSkillDAGStep(&session, "collect_name")
a.saveSkillSession(userID, session)
if lang == "zh" {
return "目前更新交易员这条 skill 先支持改名。请直接告诉我新的名字。"
}
return "This trader update skill currently supports renaming first. Tell me the new name."
}
args = manageTraderArgs{Action: "update", TraderID: session.TargetRef.ID, Name: newName}
setSkillDAGStep(&session, "execute_update")
resp := a.toolUpdateTrader(storeUserID, args)
a.clearSkillSession(userID)
if errMsg := parseSkillError(resp); strings.Contains(resp, `"error"`) {
if lang == "zh" {
return "更新交易员失败:" + errMsg
}
return "Failed to update trader: " + errMsg
}
if lang == "zh" {
return fmt.Sprintf("已将交易员改名为“%s”。", newName)
}
return fmt.Sprintf("Renamed trader to %q.", newName)
default:
return ""
}
}
func (a *Agent) executeExchangeManagementAction(storeUserID string, userID int64, lang, text string, session skillSession) string {
if session.TargetRef == nil && session.Action != "query" && session.Action != "query_list" && session.Action != "create" {
if lang == "zh" {
return "请先告诉我你要操作哪个交易所配置。"
}
return "Please specify which exchange config you want to manage."
}
switch session.Action {
case "query_detail":
if detail, ok := a.describeExchange(storeUserID, lang, session.TargetRef); ok {
return detail
}
return formatReadFastPathResponse(lang, "get_exchange_configs", a.toolGetExchangeConfigs(storeUserID))
case "delete":
if fieldValue(session, skillDAGStepField) == "" {
setSkillDAGStep(&session, "await_confirmation")
}
if msg, waiting := beginConfirmationIfNeeded(userID, lang, &session, defaultIfEmpty(session.TargetRef.Name, session.TargetRef.ID)); waiting {
a.saveSkillSession(userID, session)
return msg
}
if msg, waiting := awaitingConfirmationButNotApproved(lang, session, text); waiting {
a.saveSkillSession(userID, session)
return msg
}
setSkillDAGStep(&session, "execute_delete")
args, _ := json.Marshal(map[string]any{"action": "delete", "exchange_id": session.TargetRef.ID})
resp := a.toolManageExchangeConfig(storeUserID, string(args))
a.clearSkillSession(userID)
if errMsg := parseSkillError(resp); strings.Contains(resp, `"error"`) {
if lang == "zh" {
return "删除交易所配置失败:" + errMsg
}
return "Failed to delete exchange config: " + errMsg
}
if lang == "zh" {
return "已删除交易所配置。"
}
return "Deleted exchange config."
case "update", "update_name", "update_status":
if fieldValue(session, skillDAGStepField) == "" {
if session.Action == "update_status" {
setSkillDAGStep(&session, "collect_enabled")
} else {
setSkillDAGStep(&session, "collect_account_name")
}
}
accountName := extractTraderName(text)
if accountName == "" {
accountName = extractPostKeywordName(text, []string{"改成", "改为", "账户名改成", "rename to"})
}
if accountName != "" {
setField(&session, "account_name", accountName)
}
if enabled, ok := parseEnabledValue(text); ok {
setField(&session, "enabled", strconv.FormatBool(enabled))
}
if value := extractCredentialValue(text, []string{"api key", "apikey", "api_key"}); value != "" {
setField(&session, "api_key", value)
}
if value := extractCredentialValue(text, []string{"secret key", "secret", "secret_key"}); value != "" {
setField(&session, "secret_key", value)
}
if value := extractCredentialValue(text, []string{"passphrase", "密码短语"}); value != "" {
setField(&session, "passphrase", value)
}
if testnet, ok := parseFlagValue(text, []string{"testnet", "测试网"}); ok {
setField(&session, "testnet", strconv.FormatBool(testnet))
}
payload := map[string]any{"action": "update", "exchange_id": session.TargetRef.ID}
accountName = fieldValue(session, "account_name")
if accountName != "" && session.Action != "update_status" {
payload["account_name"] = accountName
}
if enabledRaw := fieldValue(session, "enabled"); enabledRaw != "" {
payload["enabled"] = enabledRaw == "true"
}
if value := fieldValue(session, "api_key"); value != "" {
payload["api_key"] = value
}
if value := fieldValue(session, "secret_key"); value != "" {
payload["secret_key"] = value
}
if value := fieldValue(session, "passphrase"); value != "" {
payload["passphrase"] = value
}
if value := fieldValue(session, "testnet"); value != "" {
payload["testnet"] = value == "true"
}
if session.Action == "update_status" {
delete(payload, "account_name")
}
if len(payload) == 2 {
if session.Action == "update_status" {
setSkillDAGStep(&session, "collect_enabled")
} else {
setSkillDAGStep(&session, "collect_account_name")
}
a.saveSkillSession(userID, session)
if lang == "zh" {
return "目前更新交易所 skill 支持改账户名、启用状态、API Key、Secret、Passphrase 和 testnet。请告诉我你要改什么。"
}
return "This exchange update skill supports account name, enabled state, API key, secret, passphrase, and testnet."
}
setSkillDAGStep(&session, "execute_update")
raw, _ := json.Marshal(payload)
resp := a.toolManageExchangeConfig(storeUserID, string(raw))
a.clearSkillSession(userID)
if errMsg := parseSkillError(resp); strings.Contains(resp, `"error"`) {
if lang == "zh" {
return "更新交易所配置失败:" + errMsg
}
return "Failed to update exchange config: " + errMsg
}
if lang == "zh" {
return "已更新交易所配置。"
}
return "Updated exchange config."
default:
return ""
}
}
func (a *Agent) executeModelManagementAction(storeUserID string, userID int64, lang, text string, session skillSession) string {
if session.TargetRef == nil && session.Action != "query" && session.Action != "query_list" && session.Action != "create" {
if lang == "zh" {
return "请先告诉我你要操作哪个模型配置。"
}
return "Please specify which model config you want to manage."
}
switch session.Action {
case "query_detail":
if detail, ok := a.describeModel(storeUserID, lang, session.TargetRef); ok {
return detail
}
return formatReadFastPathResponse(lang, "get_model_configs", a.toolGetModelConfigs(storeUserID))
case "delete":
if fieldValue(session, skillDAGStepField) == "" {
setSkillDAGStep(&session, "await_confirmation")
}
if msg, waiting := beginConfirmationIfNeeded(userID, lang, &session, defaultIfEmpty(session.TargetRef.Name, session.TargetRef.ID)); waiting {
a.saveSkillSession(userID, session)
return msg
}
if msg, waiting := awaitingConfirmationButNotApproved(lang, session, text); waiting {
a.saveSkillSession(userID, session)
return msg
}
setSkillDAGStep(&session, "execute_delete")
raw, _ := json.Marshal(map[string]any{"action": "delete", "model_id": session.TargetRef.ID})
resp := a.toolManageModelConfig(storeUserID, string(raw))
a.clearSkillSession(userID)
if errMsg := parseSkillError(resp); strings.Contains(resp, `"error"`) {
if lang == "zh" {
return "删除模型配置失败:" + errMsg
}
return "Failed to delete model config: " + errMsg
}
if lang == "zh" {
return "已删除模型配置。"
}
return "Deleted model config."
case "update", "update_name", "update_endpoint", "update_status":
if fieldValue(session, skillDAGStepField) == "" {
switch session.Action {
case "update_status":
setSkillDAGStep(&session, "collect_enabled")
case "update_endpoint":
setSkillDAGStep(&session, "collect_custom_api_url")
default:
setSkillDAGStep(&session, "collect_custom_model_name")
}
}
payload := map[string]any{"action": "update", "model_id": session.TargetRef.ID}
if url := extractURL(text); url != "" {
setField(&session, "custom_api_url", url)
}
if enabled, ok := parseEnabledValue(text); ok {
setField(&session, "enabled", strconv.FormatBool(enabled))
}
if apiKey := extractCredentialValue(text, []string{"api key", "apikey", "api_key"}); apiKey != "" {
setField(&session, "api_key", apiKey)
}
if modelName := extractPostKeywordName(text, []string{"model name", "模型名", "模型名称", "改成", "改为", "修改为", "换成", "换到", "切换为", "切换到", "change to", "switch to"}); modelName != "" {
setField(&session, "custom_model_name", normalizeModelName(modelName))
}
if value := fieldValue(session, "custom_api_url"); value != "" {
payload["custom_api_url"] = value
}
if value := fieldValue(session, "enabled"); value != "" {
payload["enabled"] = value == "true"
}
if value := fieldValue(session, "api_key"); value != "" {
payload["api_key"] = value
}
if value := fieldValue(session, "custom_model_name"); value != "" {
payload["custom_model_name"] = value
}
if session.Action == "update_name" {
delete(payload, "custom_api_url")
delete(payload, "enabled")
delete(payload, "api_key")
}
if session.Action == "update_status" {
delete(payload, "custom_api_url")
delete(payload, "custom_model_name")
delete(payload, "api_key")
}
if session.Action == "update_endpoint" {
delete(payload, "custom_model_name")
delete(payload, "enabled")
delete(payload, "api_key")
}
if len(payload) == 2 {
switch session.Action {
case "update_status":
setSkillDAGStep(&session, "collect_enabled")
case "update_endpoint":
setSkillDAGStep(&session, "collect_custom_api_url")
default:
setSkillDAGStep(&session, "collect_custom_model_name")
}
a.saveSkillSession(userID, session)
if lang == "zh" {
return "目前更新模型 skill 支持改 API Key、URL、模型名和启用状态。请告诉我你要改什么。"
}
return "This model update skill supports API key, URL, model name, and enabled state."
}
setSkillDAGStep(&session, "execute_update")
raw, _ := json.Marshal(payload)
resp := a.toolManageModelConfig(storeUserID, string(raw))
if errMsg := parseSkillError(resp); strings.Contains(resp, `"error"`) {
a.saveSkillSession(userID, session)
if lang == "zh" {
if strings.Contains(errMsg, "cannot enable model config before API key is configured") {
return "更新模型配置失败:这个模型还没有配置 API Key,暂时不能启用。你可以直接把 API Key 发给我,我帮你继续配置。"
}
return "更新模型配置失败:" + errMsg
}
a.saveSkillSession(userID, session)
return "Failed to update model config: " + errMsg
}
a.clearSkillSession(userID)
if lang == "zh" {
if session.Action == "update_status" {
return "已更新模型配置启用状态。"
}
return "已更新模型配置。"
}
return "Updated model config."
default:
return ""
}
}
// normalizeModelName maps common user-friendly model aliases to the canonical
// names used by claw402 and other providers (e.g. "claude opus4.6" → "claude-opus").
func normalizeModelName(name string) string {
lower := strings.ToLower(strings.TrimSpace(name))
aliases := map[string]string{
// Claude
"claude opus": "claude-opus",
"claude opus4.6": "claude-opus",
"claude opus 4.6": "claude-opus",
"claude-opus-4-6": "claude-opus",
"claude sonnet": "claude-sonnet",
"claude sonnet4.6": "claude-sonnet",
"claude sonnet 4.6": "claude-sonnet",
"claude haiku": "claude-haiku",
// GPT
"gpt5.4": "gpt-5.4",
"gpt 5.4": "gpt-5.4",
"gpt5.4pro": "gpt-5.4-pro",
"gpt 5.4pro": "gpt-5.4-pro",
"gpt 5.4 pro": "gpt-5.4-pro",
"gpt5 mini": "gpt-5-mini",
"gpt 5 mini": "gpt-5-mini",
"gpt5.3": "gpt-5.3",
"gpt 5.3": "gpt-5.3",
// DeepSeek
"deepseek reasoner": "deepseek-reasoner",
"deepseek chat": "deepseek-chat",
// Qwen (通义千问)
"qwen max": "qwen-max",
"qwen plus": "qwen-plus",
"qwen turbo": "qwen-turbo",
"qwen flash": "qwen-flash",
"通义千问": "qwen-max",
// Gemini
"gemini 3.1 pro": "gemini-3.1-pro",
"gemini 3.1pro": "gemini-3.1-pro",
// Kimi
"kimi k2.5": "kimi-k2.5",
// GLM (智谱清言)
"glm5": "glm-5",
"glm 5": "glm-5",
"glm5 turbo": "glm-5-turbo",
"glm 5 turbo": "glm-5-turbo",
"glm5-turbo": "glm-5-turbo",
"智谱清言": "glm-5",
}
if canonical, ok := aliases[lower]; ok {
return canonical
}
// Replace spaces with hyphens as a general fallback
return strings.ReplaceAll(strings.TrimSpace(name), " ", "-")
}
func (a *Agent) executeStrategyManagementAction(storeUserID string, userID int64, lang, text string, session skillSession) string {
if session.TargetRef == nil && session.Action != "query" && session.Action != "query_list" && session.Action != "create" {
if lang == "zh" {
return "请先告诉我你要操作哪个策略。"
}
return "Please specify which strategy you want to manage."
}
switch session.Action {
case "query", "query_list":
return formatReadFastPathResponse(lang, "get_strategies", a.toolGetStrategies(storeUserID))
case "query_detail":
if detail, ok := a.describeStrategy(storeUserID, lang, session.TargetRef); ok {
return detail
}
return formatReadFastPathResponse(lang, "get_strategies", a.toolGetStrategies(storeUserID))
case "activate":
raw, _ := json.Marshal(map[string]any{"action": "activate", "strategy_id": session.TargetRef.ID})
resp := a.toolManageStrategy(storeUserID, string(raw))
a.clearSkillSession(userID)
if errMsg := parseSkillError(resp); strings.Contains(resp, `"error"`) {
if lang == "zh" {
return "激活策略失败:" + errMsg
}
return "Failed to activate strategy: " + errMsg
}
if lang == "zh" {
return "已激活策略。"
}
return "Activated strategy."
case "duplicate":
if fieldValue(session, skillDAGStepField) == "" {
setSkillDAGStep(&session, "collect_name")
}
newName := extractTraderName(text)
if newName == "" {
newName = extractPostKeywordName(text, []string{"叫", "名为", "改成", "rename to"})
}
if newName != "" {
setField(&session, "name", newName)
}
newName = fieldValue(session, "name")
if newName == "" {
setSkillDAGStep(&session, "collect_name")
a.saveSkillSession(userID, session)
if lang == "zh" {
return "复制策略时,我还需要一个新名称。"
}
return "I still need a new name for the duplicated strategy."
}
setSkillDAGStep(&session, "execute_duplicate")
raw, _ := json.Marshal(map[string]any{"action": "duplicate", "strategy_id": session.TargetRef.ID, "name": newName})
resp := a.toolManageStrategy(storeUserID, string(raw))
a.clearSkillSession(userID)
if errMsg := parseSkillError(resp); strings.Contains(resp, `"error"`) {
if lang == "zh" {
return "复制策略失败:" + errMsg
}
return "Failed to duplicate strategy: " + errMsg
}
if lang == "zh" {
return fmt.Sprintf("已复制策略,新名称为“%s”。", newName)
}
return fmt.Sprintf("Duplicated strategy as %q.", newName)
case "delete":
if fieldValue(session, skillDAGStepField) == "" {
setSkillDAGStep(&session, "await_confirmation")
}
if fieldValue(session, "bulk_scope") == "all" {
strategies, err := a.store.Strategy().List(storeUserID)
if err != nil {
if lang == "zh" {
return "读取策略列表失败:" + err.Error()
}
return "Failed to load strategies: " + err.Error()
}
deletable := make([]*store.Strategy, 0, len(strategies))
skippedDefault := 0
for _, strategy := range strategies {
if strategy == nil {
continue
}
if strategy.IsDefault {
skippedDefault++
continue
}
deletable = append(deletable, strategy)
}
if len(deletable) == 0 {
a.clearSkillSession(userID)
if lang == "zh" {
return "当前没有可删除的自定义策略。"
}
return "There are no user-created strategies to delete."
}
targetLabel := fmt.Sprintf("全部自定义策略(共 %d 个)", len(deletable))
if msg, waiting := beginConfirmationIfNeeded(userID, lang, &session, targetLabel); waiting {
a.saveSkillSession(userID, session)
return msg
}
if msg, waiting := awaitingConfirmationButNotApproved(lang, session, text); waiting {
a.saveSkillSession(userID, session)
return msg
}
setSkillDAGStep(&session, "execute_delete")
deletedNames := make([]string, 0, len(deletable))
failedNames := make([]string, 0)
for _, strategy := range deletable {
raw, _ := json.Marshal(map[string]any{"action": "delete", "strategy_id": strategy.ID})
resp := a.toolManageStrategy(storeUserID, string(raw))
if errMsg := parseSkillError(resp); strings.Contains(resp, `"error"`) {
failedNames = append(failedNames, fmt.Sprintf("%s%s", strategy.Name, errMsg))
continue
}
deletedNames = append(deletedNames, strategy.Name)
}
a.clearSkillSession(userID)
if lang == "zh" {
parts := []string{fmt.Sprintf("批量删除策略已完成:成功删除 %d 个。", len(deletedNames))}
if skippedDefault > 0 {
parts = append(parts, fmt.Sprintf("已跳过系统默认策略 %d 个。", skippedDefault))
}
if len(failedNames) > 0 {
parts = append(parts, "删除失败:"+strings.Join(failedNames, ""))
}
if len(deletedNames) > 0 {
parts = append(parts, "已删除:"+strings.Join(deletedNames, "、"))
}
return strings.Join(parts, "\n")
}
parts := []string{fmt.Sprintf("Bulk strategy deletion finished: deleted %d strategy(s).", len(deletedNames))}
if skippedDefault > 0 {
parts = append(parts, fmt.Sprintf("Skipped %d default strategy(ies).", skippedDefault))
}
if len(failedNames) > 0 {
parts = append(parts, "Failed: "+strings.Join(failedNames, "; "))
}
if len(deletedNames) > 0 {
parts = append(parts, "Deleted: "+strings.Join(deletedNames, ", "))
}
return strings.Join(parts, "\n")
}
if msg, waiting := beginConfirmationIfNeeded(userID, lang, &session, defaultIfEmpty(session.TargetRef.Name, session.TargetRef.ID)); waiting {
a.saveSkillSession(userID, session)
return msg
}
if msg, waiting := awaitingConfirmationButNotApproved(lang, session, text); waiting {
a.saveSkillSession(userID, session)
return msg
}
setSkillDAGStep(&session, "execute_delete")
raw, _ := json.Marshal(map[string]any{"action": "delete", "strategy_id": session.TargetRef.ID})
resp := a.toolManageStrategy(storeUserID, string(raw))
a.clearSkillSession(userID)
if errMsg := parseSkillError(resp); strings.Contains(resp, `"error"`) {
if lang == "zh" {
return "删除策略失败:" + errMsg
}
return "Failed to delete strategy: " + errMsg
}
if lang == "zh" {
return "已删除策略。"
}
return "Deleted strategy."
case "update", "update_name", "update_config", "update_prompt":
if session.Action == "update_prompt" {
return a.executeStrategyPromptUpdate(storeUserID, userID, lang, text, session)
}
if session.Action == "update_config" {
return a.executeStrategyConfigUpdate(storeUserID, userID, lang, text, session)
}
if fieldValue(session, skillDAGStepField) == "" {
setSkillDAGStep(&session, "collect_name")
}
newName := extractTraderName(text)
if newName == "" {
newName = extractPostKeywordName(text, []string{"改成", "改为", "rename to"})
}
if newName != "" {
setField(&session, "name", newName)
}
newName = fieldValue(session, "name")
if newName == "" {
setSkillDAGStep(&session, "collect_name")
a.saveSkillSession(userID, session)
if lang == "zh" {
return "目前更新策略 skill 先支持改名。请告诉我新的策略名称。"
}
return "This strategy update skill currently supports renaming first."
}
setSkillDAGStep(&session, "execute_update")
raw, _ := json.Marshal(map[string]any{"action": "update", "strategy_id": session.TargetRef.ID, "name": newName})
resp := a.toolManageStrategy(storeUserID, string(raw))
a.clearSkillSession(userID)
if errMsg := parseSkillError(resp); strings.Contains(resp, `"error"`) {
if lang == "zh" {
return "更新策略失败:" + errMsg
}
return "Failed to update strategy: " + errMsg
}
if lang == "zh" {
return fmt.Sprintf("已将策略改名为“%s”。", newName)
}
return fmt.Sprintf("Renamed strategy to %q.", newName)
default:
return ""
}
}
func (a *Agent) executeStrategyPromptUpdate(storeUserID string, userID int64, lang, text string, session skillSession) string {
if fieldValue(session, skillDAGStepField) == "" {
setSkillDAGStep(&session, "collect_prompt")
}
strategy, cfg, err := a.loadStrategyConfigForUpdate(storeUserID, session.TargetRef.ID)
if err != nil {
if lang == "zh" {
return "读取策略失败:" + err.Error()
}
return "Failed to load strategy: " + err.Error()
}
prompt := extractQuotedContent(text)
if prompt == "" {
prompt = extractPostKeywordName(text, []string{"prompt改成", "prompt 改成", "提示词改成", "提示词改为", "custom prompt 改成"})
}
if prompt != "" {
setField(&session, "prompt", prompt)
}
prompt = fieldValue(session, "prompt")
if prompt == "" {
setSkillDAGStep(&session, "collect_prompt")
a.saveSkillSession(userID, session)
if lang == "zh" {
return "这次是更新策略 prompt,请直接把新的 prompt 内容发给我,最好放在引号里。"
}
return "This action updates the strategy prompt. Send me the new prompt text, ideally inside quotes."
}
cfg.CustomPrompt = prompt
setSkillDAGStep(&session, "execute_update")
return a.persistStrategyConfigUpdate(storeUserID, userID, lang, strategy, cfg, "已更新策略 prompt。", "Updated strategy prompt.")
}
func (a *Agent) executeStrategyConfigUpdate(storeUserID string, userID int64, lang, text string, session skillSession) string {
if _, ok := getSkillDAG("strategy_management", "update_config"); ok {
if fieldValue(session, skillDAGStepField) == "" {
setSkillDAGStep(&session, "resolve_config_field")
}
}
currentStep, _ := currentSkillDAGStep(session)
strategy, cfg, err := a.loadStrategyConfigForUpdate(storeUserID, session.TargetRef.ID)
if err != nil {
if lang == "zh" {
return "读取策略失败:" + err.Error()
}
return "Failed to load strategy: " + err.Error()
}
if fieldValue(session, "config_field") == "" && fieldValue(session, "config_value") == "" {
patches := detectStrategyConfigPatches(text)
if len(patches) > 1 {
changed := make([]string, 0, len(patches))
for _, patch := range patches {
if err := applyStrategyConfigPatch(&cfg, patch.Field, patch.Value); err != nil {
a.saveSkillSession(userID, session)
if lang == "zh" {
return "更新策略参数失败:" + err.Error()
}
return "Failed to update strategy config: " + err.Error()
}
changed = append(changed, strategyConfigFieldDisplayName(patch.Field, lang))
}
cfg.ClampLimits()
setSkillDAGStep(&session, "apply_field_update")
setSkillDAGStep(&session, "execute_update")
msgZH := "已更新策略参数:" + strings.Join(changed, "、") + "。"
msgEN := "Updated strategy config fields: " + strings.Join(changed, ", ") + "."
return a.persistStrategyConfigUpdate(storeUserID, userID, lang, strategy, cfg, msgZH, msgEN)
}
}
field := fieldValue(session, "config_field")
if field == "" {
field = detectStrategyConfigField(text)
if field != "" {
setField(&session, "config_field", field)
if currentStep.ID == "resolve_config_field" {
advanceSkillDAGStep(&session, currentStep.ID)
currentStep, _ = currentSkillDAGStep(session)
}
}
}
if field == "" {
setSkillDAGStep(&session, "resolve_config_field")
a.saveSkillSession(userID, session)
if lang == "zh" {
return "这次是更新策略参数。我当前先支持这些字段:最大持仓、最低置信度、主周期、多周期时间框架。请先告诉我要改哪个字段。"
}
return "This action updates strategy config. I currently support max positions, min confidence, primary timeframe, and selected timeframes. Tell me which field to change first."
}
if value, ok := extractStrategyConfigValue(text, field); ok {
setField(&session, "config_value", value)
if currentStep.ID == "resolve_config_value" {
advanceSkillDAGStep(&session, currentStep.ID)
currentStep, _ = currentSkillDAGStep(session)
}
}
value := fieldValue(session, "config_value")
if value == "" {
setSkillDAGStep(&session, "resolve_config_value")
a.saveSkillSession(userID, session)
if lang == "zh" {
return fmt.Sprintf("要更新策略参数,我还需要 %s 的目标值。", strategyConfigFieldDisplayName(field, lang))
}
return fmt.Sprintf("I still need the target value for %s.", strategyConfigFieldDisplayName(field, lang))
}
if err := applyStrategyConfigPatch(&cfg, field, value); err != nil {
setSkillDAGStep(&session, "resolve_config_value")
a.saveSkillSession(userID, session)
if lang == "zh" {
return err.Error()
}
return err.Error()
}
cfg.ClampLimits()
changed := []string{field}
displayChanged := make([]string, 0, len(changed))
for _, item := range changed {
displayChanged = append(displayChanged, strategyConfigFieldDisplayName(item, lang))
}
msgZH := "已更新策略参数:" + strings.Join(displayChanged, "、") + "。"
msgEN := "Updated strategy config fields: " + strings.Join(displayChanged, ", ") + "."
setSkillDAGStep(&session, "apply_field_update")
setSkillDAGStep(&session, "execute_update")
return a.persistStrategyConfigUpdate(storeUserID, userID, lang, strategy, cfg, msgZH, msgEN)
}
func (a *Agent) loadStrategyConfigForUpdate(storeUserID, strategyID string) (*store.Strategy, store.StrategyConfig, error) {
strategy, err := a.store.Strategy().Get(storeUserID, strategyID)
if err != nil {
return nil, store.StrategyConfig{}, err
}
cfg := store.GetDefaultStrategyConfig("zh")
if strings.TrimSpace(strategy.Config) != "" {
_ = json.Unmarshal([]byte(strategy.Config), &cfg)
}
return strategy, cfg, nil
}
func (a *Agent) persistStrategyConfigUpdate(storeUserID string, userID int64, lang string, strategy *store.Strategy, cfg store.StrategyConfig, zhMsg, enMsg string) string {
rawConfig, err := json.Marshal(cfg)
if err != nil {
if lang == "zh" {
return "序列化策略配置失败:" + err.Error()
}
return "Failed to serialize strategy config: " + err.Error()
}
raw, _ := json.Marshal(map[string]any{
"action": "update",
"strategy_id": strategy.ID,
"config": json.RawMessage(rawConfig),
})
resp := a.toolManageStrategy(storeUserID, string(raw))
a.clearSkillSession(userID)
if errMsg := parseSkillError(resp); strings.Contains(resp, `"error"`) {
if lang == "zh" {
return "更新策略失败:" + errMsg
}
return "Failed to update strategy: " + errMsg
}
if lang == "zh" {
return zhMsg
}
return enMsg
}
func extractQuotedContent(text string) string {
if matches := quotedNamePattern.FindStringSubmatch(text); len(matches) == 2 {
return strings.TrimSpace(matches[1])
}
return ""
}
func extractLabeledInt(text string, labels []string) (int, bool) {
lower := strings.ToLower(text)
for _, label := range labels {
idx := strings.Index(lower, strings.ToLower(label))
if idx < 0 {
continue
}
segment := text[idx:]
if match := firstIntegerPattern.FindString(segment); match != "" {
if value, err := strconv.Atoi(match); err == nil {
return value, true
}
}
}
return 0, false
}
func extractTimeframeAfterKeywords(text string, labels []string) string {
lower := strings.ToLower(text)
for _, label := range labels {
idx := strings.Index(lower, strings.ToLower(label))
if idx < 0 {
continue
}
segment := text[idx:]
if match := timeframeTokenRE.FindString(segment); match != "" {
return strings.ToLower(match)
}
}
return ""
}
func extractTimeframes(text string) []string {
matches := timeframeTokenRE.FindAllString(text, -1)
if len(matches) == 0 {
return nil
}
seen := make(map[string]struct{}, len(matches))
out := make([]string, 0, len(matches))
for _, match := range matches {
tf := strings.ToLower(strings.TrimSpace(match))
if tf == "" {
continue
}
if _, ok := seen[tf]; ok {
continue
}
seen[tf] = struct{}{}
out = append(out, tf)
}
return out
}
func (a *Agent) handleTraderDiagnosisSkill(storeUserID, lang, text string) string {
raw := a.toolListTraders(storeUserID)
list := formatReadFastPathResponse(lang, "list_traders", raw)
if lang == "zh" {
reply := "现象:这是交易员运行诊断问题。\n优先排查:\n1. 交易员是否已创建并处于运行状态。\n2. 绑定的模型、交易所、策略是否齐全。\n3. 是“没有启动”、还是“启动了但 AI 没有下单”、还是“下单失败”。\n当前交易员概览:\n" + list
if excerpt := backendLogDiagnosisExcerpt(lang, text, "trader"); excerpt != "" {
reply += "\n" + excerpt
}
return reply
}
reply := "This looks like a trader diagnosis issue.\nCheck whether the trader exists, is running, and has model/exchange/strategy bindings.\nCurrent trader overview:\n" + list
if excerpt := backendLogDiagnosisExcerpt(lang, text, "trader"); excerpt != "" {
reply += "\n" + excerpt
}
return reply
}
func (a *Agent) handleStrategyDiagnosisSkill(storeUserID, lang, text string) string {
raw := a.toolGetStrategies(storeUserID)
list := formatReadFastPathResponse(lang, "get_strategies", raw)
if lang == "zh" {
reply := "现象:这是策略或提示词生效问题。\n优先排查:\n1. 你改的是策略模板,还是 trader 上的 custom prompt。\n2. 策略是否真的保存成功。\n3. 运行结果不符合预期,是配置问题还是市场条件问题。\n当前策略概览:\n" + list
if excerpt := backendLogDiagnosisExcerpt(lang, text, "strategy"); excerpt != "" {
reply += "\n" + excerpt
}
return reply
}
reply := "This looks like a strategy or prompt diagnosis issue.\nCheck whether you changed the strategy template or a trader-specific prompt override.\nCurrent strategy overview:\n" + list
if excerpt := backendLogDiagnosisExcerpt(lang, text, "strategy"); excerpt != "" {
reply += "\n" + excerpt
}
return reply
}