Files
1Panel/agent/app/service/agents_hermes.go

695 lines
18 KiB
Go

package service
import (
"errors"
"fmt"
"path"
"strings"
"time"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/model"
providercatalog "github.com/1Panel-dev/1Panel/agent/app/provider"
"github.com/1Panel-dev/1Panel/agent/buserr"
"github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/utils/common"
"github.com/1Panel-dev/1Panel/agent/utils/files"
"gopkg.in/yaml.v3"
)
const hermesWorkspaceDir = "/opt/data/workspace"
const hermesExecutablePath = "/opt/hermes/.venv/bin/hermes"
type hermesConfig struct {
Model hermesModelConfig `yaml:"model"`
Terminal hermesTerminalConfig `yaml:"terminal"`
Timezone string `yaml:"timezone,omitempty"`
}
type hermesModelConfig struct {
Default string `yaml:"default"`
Provider string `yaml:"provider"`
BaseURL string `yaml:"base_url,omitempty"`
APIKey string `yaml:"api_key,omitempty"`
}
type hermesTerminalConfig struct {
Backend string `yaml:"backend"`
Cwd string `yaml:"cwd"`
}
type hermesEnvEntry struct {
Key string
Value string
}
func buildHermesDockerExecCommandArgs(containerName, command string, commandArgs ...string) []string {
args := []string{"exec", "-u", "hermes", containerName, command}
return append(args, commandArgs...)
}
func buildHermesDockerExecArgs(containerName string, hermesArgs ...string) []string {
return buildHermesDockerExecCommandArgs(containerName, "hermes", hermesArgs...)
}
func writeHermesConfig(confDir string, account *model.AgentAccount, modelName string, timezone string) error {
if strings.TrimSpace(confDir) == "" {
return fmt.Errorf("config dir is required")
}
if account == nil {
return fmt.Errorf("account is required")
}
if strings.TrimSpace(modelName) == "" {
return fmt.Errorf("model is required")
}
fileOp := files.NewFileOp()
if !fileOp.Stat(confDir) {
if err := fileOp.CreateDir(confDir, constant.DirPerm); err != nil {
return err
}
}
provider := resolveHermesProvider(account.Provider, account.APIType)
configPath := path.Join(confDir, "config.yaml")
cfg, err := readHermesConfigMap(configPath)
if err != nil {
return err
}
model := map[string]interface{}{
"default": resolveHermesModel(account.Provider, provider, modelName),
"provider": provider,
"base_url": account.BaseURL,
}
if provider == "custom" && account.APIKey != "" {
model["api_key"] = "${CUSTOM_API_KEY}"
}
cfg["model"] = model
cfg["terminal"] = map[string]interface{}{
"backend": "local",
"cwd": hermesWorkspaceDir,
}
if timezone != "" {
cfg["timezone"] = timezone
} else {
delete(cfg, "timezone")
}
if err := writeHermesConfigMap(configPath, cfg); err != nil {
return err
}
return writeHermesModelEnv(path.Join(confDir, ".env"), account)
}
func prepareHermesInstallFiles(appInstall *model.AppInstall, account *model.AgentAccount, modelName string) error {
if appInstall == nil {
return fmt.Errorf("app install is required")
}
dataDir := path.Join(appInstall.GetPath(), "data")
if err := writeHermesConfig(dataDir, account, modelName, normalizeHermesTimezone(common.LoadTimeZoneByCmd())); err != nil {
return err
}
return files.NewFileOp().ChownR(dataDir, "1000", "1000", true)
}
func readHermesConfig(configPath string) (*hermesConfig, error) {
content, err := files.NewFileOp().GetContent(configPath)
if err != nil {
return nil, err
}
var cfg hermesConfig
if err := yaml.Unmarshal(content, &cfg); err != nil {
return nil, err
}
return &cfg, nil
}
func readHermesConfigMap(configPath string) (map[string]interface{}, error) {
fileOp := files.NewFileOp()
if !fileOp.Stat(configPath) {
return map[string]interface{}{}, nil
}
content, err := fileOp.GetContent(configPath)
if err != nil {
return nil, err
}
cfg := map[string]interface{}{}
if strings.TrimSpace(string(content)) == "" {
return cfg, nil
}
if err := yaml.Unmarshal(content, &cfg); err != nil {
return nil, err
}
return cfg, nil
}
func writeHermesConfigMap(configPath string, cfg map[string]interface{}) error {
payload, err := yaml.Marshal(cfg)
if err != nil {
return err
}
return files.NewFileOp().SaveFile(configPath, string(payload), 0600)
}
func readHermesTelegramChannelConfig(confDir string) (*dto.AgentTelegramConfig, error) {
envMap, err := readAgentEnvMap(path.Join(confDir, ".env"))
if err != nil {
return nil, err
}
cfg, err := readHermesConfigMap(path.Join(confDir, "config.yaml"))
if err != nil {
return nil, err
}
allowFrom := splitHermesEnvList(envMap["TELEGRAM_ALLOWED_USERS"])
requireMention := extractBoolValue(childMap(cfg, "telegram")["require_mention"], false)
dmPolicy := ""
if extractHermesEnvBool(envMap, "TELEGRAM_ALLOW_ALL_USERS", false) {
dmPolicy = "open"
} else if len(allowFrom) > 0 {
dmPolicy = "allowlist"
} else if envMap["TELEGRAM_BOT_TOKEN"] != "" {
dmPolicy = "pairing"
}
groupPolicy := ""
if requireMention {
groupPolicy = "allowlist"
} else if envMap["TELEGRAM_BOT_TOKEN"] != "" {
groupPolicy = "open"
}
token := envMap["TELEGRAM_BOT_TOKEN"]
result := &dto.AgentTelegramConfig{
Enabled: token != "",
DmPolicy: dmPolicy,
AllowFrom: allowFrom,
RequireMention: requireMention,
GroupPolicy: groupPolicy,
GroupAllowFrom: []string{},
Streaming: "",
DefaultAccount: "",
}
if token != "" {
result.Bots = []dto.AgentTelegramBot{
{
AgentChannelBotBase: dto.AgentChannelBotBase{
AccountID: "default",
Name: "Default",
Enabled: true,
IsDefault: true,
},
BotToken: token,
DmPolicy: dmPolicy,
GroupPolicy: groupPolicy,
Streaming: "",
},
}
}
return result, nil
}
func writeHermesTelegramChannelConfig(confDir string, config dto.AgentTelegramConfig) error {
envPath := path.Join(confDir, ".env")
envMap, err := readAgentEnvMap(envPath)
if err != nil {
return err
}
token := firstHermesTelegramBotToken(config.Bots, config.DefaultAccount)
if token != "" {
envMap["TELEGRAM_BOT_TOKEN"] = token
} else {
delete(envMap, "TELEGRAM_BOT_TOKEN")
}
delete(envMap, "TELEGRAM_ALLOWED_USERS")
delete(envMap, "TELEGRAM_ALLOW_ALL_USERS")
switch config.DmPolicy {
case "open":
envMap["TELEGRAM_ALLOW_ALL_USERS"] = "true"
case "allowlist":
if allow := joinHermesEnvList(config.AllowFrom); allow != "" {
envMap["TELEGRAM_ALLOWED_USERS"] = allow
}
}
if err := writeAgentEnvMap(envPath, envMap, []string{
"TELEGRAM_BOT_TOKEN",
"TELEGRAM_ALLOWED_USERS",
"TELEGRAM_ALLOW_ALL_USERS",
}); err != nil {
return err
}
configPath := path.Join(confDir, "config.yaml")
cfg, err := readHermesConfigMap(configPath)
if err != nil {
return err
}
telegram := ensureChildMap(cfg, "telegram")
telegram["require_mention"] = config.RequireMention
return writeHermesConfigMap(configPath, cfg)
}
func readHermesDiscordChannelConfig(confDir string) (*dto.AgentDiscordConfig, error) {
envMap, err := readAgentEnvMap(path.Join(confDir, ".env"))
if err != nil {
return nil, err
}
cfg, err := readHermesConfigMap(path.Join(confDir, "config.yaml"))
if err != nil {
return nil, err
}
allowFrom := splitHermesEnvList(envMap["DISCORD_ALLOWED_USERS"])
requireMention := extractBoolValue(childMap(cfg, "discord")["require_mention"], false)
dmPolicy := ""
if extractHermesEnvBool(envMap, "DISCORD_ALLOW_ALL_USERS", false) {
dmPolicy = "open"
} else if len(allowFrom) > 0 {
dmPolicy = "allowlist"
} else if envMap["DISCORD_BOT_TOKEN"] != "" {
dmPolicy = "pairing"
}
groupPolicy := ""
if requireMention {
groupPolicy = "allowlist"
} else if envMap["DISCORD_BOT_TOKEN"] != "" {
groupPolicy = "open"
}
token := envMap["DISCORD_BOT_TOKEN"]
result := &dto.AgentDiscordConfig{
Enabled: token != "",
DmPolicy: dmPolicy,
AllowFrom: allowFrom,
RequireMention: requireMention,
GroupPolicy: groupPolicy,
DefaultAccount: "",
}
if token != "" {
result.Bots = []dto.AgentDiscordBot{
{
AgentChannelBotBase: dto.AgentChannelBotBase{
AccountID: "default",
Name: "Default",
Enabled: true,
IsDefault: true,
},
Token: token,
},
}
}
return result, nil
}
func writeHermesDiscordChannelConfig(confDir string, config dto.AgentDiscordConfig) error {
envPath := path.Join(confDir, ".env")
envMap, err := readAgentEnvMap(envPath)
if err != nil {
return err
}
token := firstHermesDiscordBotToken(config.Bots, config.DefaultAccount)
if token != "" {
envMap["DISCORD_BOT_TOKEN"] = token
} else {
delete(envMap, "DISCORD_BOT_TOKEN")
}
delete(envMap, "DISCORD_ALLOWED_USERS")
delete(envMap, "DISCORD_ALLOW_ALL_USERS")
switch config.DmPolicy {
case "open":
envMap["DISCORD_ALLOW_ALL_USERS"] = "true"
case "allowlist":
if allow := joinHermesEnvList(config.AllowFrom); allow != "" {
envMap["DISCORD_ALLOWED_USERS"] = allow
}
}
if err := writeAgentEnvMap(envPath, envMap, []string{
"DISCORD_BOT_TOKEN",
"DISCORD_ALLOWED_USERS",
"DISCORD_ALLOW_ALL_USERS",
}); err != nil {
return err
}
configPath := path.Join(confDir, "config.yaml")
cfg, err := readHermesConfigMap(configPath)
if err != nil {
return err
}
discord := ensureChildMap(cfg, "discord")
discord["require_mention"] = config.RequireMention
return writeHermesConfigMap(configPath, cfg)
}
func deleteHermesEnvKeys(confDir string, keys ...string) error {
envPath := path.Join(confDir, ".env")
envMap, err := readAgentEnvMap(envPath)
if err != nil {
return err
}
for _, key := range keys {
delete(envMap, key)
}
return writeAgentEnvMap(envPath, envMap, keys)
}
func deleteHermesConfigSections(confDir string, topLevelKeys []string, platformKeys []string) error {
configPath := path.Join(confDir, "config.yaml")
cfg, err := readHermesConfigMap(configPath)
if err != nil {
return err
}
for _, key := range topLevelKeys {
delete(cfg, key)
}
if len(platformKeys) > 0 {
if platforms, ok := cfg["platforms"].(map[string]interface{}); ok {
for _, key := range platformKeys {
delete(platforms, key)
}
if len(platforms) == 0 {
delete(cfg, "platforms")
}
}
}
return writeHermesConfigMap(configPath, cfg)
}
func deleteHermesTelegramChannelConfig(confDir string) error {
if err := deleteHermesEnvKeys(confDir,
"TELEGRAM_BOT_TOKEN",
"TELEGRAM_ALLOWED_USERS",
"TELEGRAM_ALLOW_ALL_USERS",
"TELEGRAM_HOME_CHANNEL",
); err != nil {
return err
}
return deleteHermesConfigSections(confDir, []string{"telegram"}, []string{"telegram"})
}
func deleteHermesDiscordChannelConfig(confDir string) error {
if err := deleteHermesEnvKeys(confDir,
"DISCORD_BOT_TOKEN",
"DISCORD_ALLOWED_USERS",
"DISCORD_ALLOW_ALL_USERS",
"DISCORD_HOME_CHANNEL",
); err != nil {
return err
}
return deleteHermesConfigSections(confDir, []string{"discord"}, []string{"discord"})
}
func normalizeHermesTimezone(timezone string) string {
timezone = strings.TrimSpace(timezone)
if timezone == "" {
return ""
}
if _, err := time.LoadLocation(timezone); err != nil {
return ""
}
return timezone
}
func resolveHermesProvider(provider, apiType string) string {
if apiType == "anthropic-messages" && (provider == "deepseek" || provider == "bailian-coding-plan" || provider == "ark-coding-plan" || provider == "xiaomi") {
return "anthropic"
}
switch provider {
case "":
return "custom"
case "moonshot":
return "kimi-coding"
case "kimi":
return "kimi-coding-cn"
case "openrouter", "anthropic", "deepseek", "gemini", "zai", "kimi-coding", "xiaomi":
return provider
case "minimax":
return "minimax-cn"
default:
return "custom"
}
}
func resolveHermesModel(sourceProvider, targetProvider, modelName string) string {
target := strings.TrimSpace(modelName)
if target == "" {
return ""
}
if targetProvider != "custom" {
return target
}
return providercatalog.NormalizeModelID(sourceProvider, target)
}
func resolveHermesConfiguredModelID(account *model.AgentAccount, accountModels []dto.AgentAccountModel, configuredModel string) (string, error) {
if account == nil {
return "", buserr.New("ErrAgentModelNotInAccount")
}
configuredModel = strings.TrimSpace(configuredModel)
if configuredModel == "" {
return "", buserr.New("ErrAgentModelNotInAccount")
}
provider := resolveHermesProvider(account.Provider, account.APIType)
for _, item := range accountModels {
if resolveHermesModel(account.Provider, provider, item.ID) == configuredModel {
return item.ID, nil
}
}
return "", buserr.New("ErrAgentModelNotInAccount")
}
func resolveHermesEnvEntries(account *model.AgentAccount) []hermesEnvEntry {
if account == nil {
return nil
}
resolvedProvider := resolveHermesProvider(account.Provider, account.APIType)
if resolvedProvider == "custom" {
if account.APIKey == "" {
return nil
}
return []hermesEnvEntry{
{Key: "CUSTOM_API_KEY", Value: account.APIKey},
}
}
apiKey := account.APIKey
baseURL := account.BaseURL
entries := make([]hermesEnvEntry, 0, 4)
appendEntry := func(key, value string) {
if key == "" || value == "" {
return
}
entries = append(entries, hermesEnvEntry{Key: key, Value: value})
}
if resolvedProvider == "anthropic" {
appendEntry("ANTHROPIC_API_KEY", apiKey)
appendEntry("ANTHROPIC_BASE_URL", baseURL)
return entries
}
switch account.Provider {
case "openrouter":
appendEntry("OPENROUTER_API_KEY", apiKey)
appendEntry("OPENROUTER_BASE_URL", baseURL)
case "anthropic":
appendEntry("ANTHROPIC_API_KEY", apiKey)
appendEntry("ANTHROPIC_BASE_URL", baseURL)
case "gemini":
appendEntry("GOOGLE_API_KEY", apiKey)
appendEntry("GEMINI_API_KEY", apiKey)
appendEntry("GEMINI_BASE_URL", baseURL)
case "zai":
appendEntry("GLM_API_KEY", apiKey)
appendEntry("ZAI_API_KEY", apiKey)
appendEntry("Z_AI_API_KEY", apiKey)
appendEntry("GLM_BASE_URL", baseURL)
case "kimi-coding", "moonshot":
appendEntry("KIMI_API_KEY", apiKey)
appendEntry("KIMI_BASE_URL", baseURL)
case "kimi":
appendEntry("KIMI_CN_API_KEY", apiKey)
appendEntry("KIMI_BASE_URL", baseURL)
case "minimax":
appendEntry("MINIMAX_CN_API_KEY", apiKey)
appendEntry("MINIMAX_CN_BASE_URL", baseURL)
case "xiaomi":
appendEntry("XIAOMI_API_KEY", apiKey)
appendEntry("XIAOMI_BASE_URL", baseURL)
case "deepseek":
appendEntry("DEEPSEEK_API_KEY", apiKey)
appendEntry("DEEPSEEK_BASE_URL", baseURL)
case "bailian-coding-plan":
appendEntry("DASHSCOPE_API_KEY", apiKey)
appendEntry("DASHSCOPE_BASE_URL", baseURL)
case "openai":
appendEntry("OPENAI_API_KEY", apiKey)
appendEntry("OPENAI_BASE_URL", baseURL)
case "custom", "vllm":
if account.APIType == "openai-completions" || account.APIType == "openai-responses" {
appendEntry("OPENAI_API_KEY", apiKey)
appendEntry("OPENAI_BASE_URL", baseURL)
}
default:
if envKey := providercatalog.EnvKey(account.Provider); envKey != "" {
appendEntry(envKey, apiKey)
}
}
return entries
}
func writeHermesModelEnv(envPath string, account *model.AgentAccount) error {
envMap, err := readAgentEnvMap(envPath)
if err != nil {
return err
}
for _, key := range hermesManagedModelEnvKeys() {
delete(envMap, key)
}
entries := resolveHermesEnvEntries(account)
order := make([]string, 0, len(entries))
for _, entry := range entries {
if entry.Key == "" || entry.Value == "" {
continue
}
envMap[entry.Key] = entry.Value
order = append(order, entry.Key)
}
return writeAgentEnvMap(envPath, envMap, order)
}
func hermesManagedModelEnvKeys() []string {
keys := []string{
"OPENROUTER_API_KEY",
"OPENROUTER_BASE_URL",
"ANTHROPIC_API_KEY",
"ANTHROPIC_BASE_URL",
"GOOGLE_API_KEY",
"GEMINI_API_KEY",
"GEMINI_BASE_URL",
"GLM_API_KEY",
"ZAI_API_KEY",
"Z_AI_API_KEY",
"GLM_BASE_URL",
"KIMI_API_KEY",
"KIMI_CN_API_KEY",
"KIMI_BASE_URL",
"MINIMAX_CN_API_KEY",
"MINIMAX_CN_BASE_URL",
"XIAOMI_API_KEY",
"XIAOMI_BASE_URL",
"DEEPSEEK_API_KEY",
"DEEPSEEK_BASE_URL",
"DASHSCOPE_API_KEY",
"DASHSCOPE_BASE_URL",
"OPENAI_API_KEY",
"OPENAI_BASE_URL",
}
seen := make(map[string]struct{}, len(keys))
result := make([]string, 0, len(keys))
appendKey := func(key string) {
if key == "" {
return
}
if _, ok := seen[key]; ok {
return
}
seen[key] = struct{}{}
result = append(result, key)
}
for _, key := range keys {
appendKey(key)
}
for _, meta := range providercatalog.All() {
appendKey(meta.EnvKey)
}
return result
}
func splitHermesEnvList(value string) []string {
if value == "" {
return []string{}
}
parts := strings.Split(value, ",")
result := make([]string, 0, len(parts))
seen := make(map[string]struct{}, len(parts))
for _, part := range parts {
item := strings.TrimSpace(part)
if item == "" {
continue
}
if _, ok := seen[item]; ok {
continue
}
seen[item] = struct{}{}
result = append(result, item)
}
return result
}
func joinHermesEnvList(values []string) string {
if len(values) == 0 {
return ""
}
result := make([]string, 0, len(values))
seen := make(map[string]struct{}, len(values))
for _, value := range values {
item := strings.TrimSpace(value)
if item == "" {
continue
}
if _, ok := seen[item]; ok {
continue
}
seen[item] = struct{}{}
result = append(result, item)
}
return strings.Join(result, ",")
}
func firstHermesTelegramBotToken(bots []dto.AgentTelegramBot, defaultAccount string) string {
for _, bot := range bots {
if bot.AccountID == defaultAccount || bot.IsDefault {
return bot.BotToken
}
}
if len(bots) == 0 {
return ""
}
return bots[0].BotToken
}
func firstHermesDiscordBotToken(bots []dto.AgentDiscordBot, defaultAccount string) string {
for _, bot := range bots {
if bot.AccountID == defaultAccount || bot.IsDefault {
return bot.Token
}
}
if len(bots) == 0 {
return ""
}
return bots[0].Token
}
func extractHermesEnvBool(envMap map[string]string, key string, defaultValue bool) bool {
value, ok := envMap[key]
if !ok || value == "" {
return defaultValue
}
return strings.EqualFold(value, "true")
}
func validateHermesPairingApproveResult(output string, err error) error {
if strings.Contains(output, "Approved!") {
return nil
}
if strings.Contains(output, "not found or expired for platform") {
return buserr.New("ErrHermesPairingCodeUnavailable")
}
if err == nil {
if strings.TrimSpace(output) == "" {
return fmt.Errorf("unexpected hermes pairing approve result")
}
return errors.New(strings.TrimSpace(output))
}
if strings.Contains(err.Error(), "not found or expired for platform") {
return buserr.New("ErrHermesPairingCodeUnavailable")
}
return err
}