Merge branch 'mcp-client' into 'master'

Mcp client

See merge request iesqa/httprunner!79
This commit is contained in:
李隆
2025-04-27 04:02:51 +00:00
75 changed files with 2750 additions and 1127 deletions

View File

@@ -1,5 +1,9 @@
SHELL=/usr/bin/env bash
MACOS_MIN := 11.0
TARGET_OS := darwin
TARGET_ARCH := amd64
.DEFAULT_GOAL=help
.PHONY: test
@@ -15,9 +19,17 @@ bump: ## bump hrp version, e.g. make bump version=4.0.0
.PHONY: build
build: ## build hrp cli tool
# =================== 参数说明 ===================
# CGO_ENABLED=0 : 完全禁用 CGO强制使用纯 Go 实现
# -tags netgo,osusergo : 使用 Go 的 net 和 user 包的纯 Go 实现,不依赖系统库
# -trimpath : 从二进制文件中删除所有文件系统路径,增加安全性和可重现性
# -ldflags "-s -w" :
# -s : 忽略符号表和调试信息
# -w : 忽略 DWARF 调试信息
# -extldflags "-static" : 传递给外部链接器的标志,强制静态链接
@echo "[info] build hrp cli tool"
go mod tidy
go build -ldflags "\
GOOS=${TARGET_OS} GOARCH=${TARGET_ARCH} CGO_ENABLED=0 go build -tags netgo,osusergo -trimpath -ldflags "\
-s -w \
-X 'github.com/httprunner/httprunner/v5/internal/version.GitCommit=$(shell git rev-parse HEAD)' \
-X 'github.com/httprunner/httprunner/v5/internal/version.GitBranch=$(shell git rev-parse --abbrev-ref HEAD)' \

View File

@@ -57,5 +57,5 @@ func getAndroidDevices() (devices []*gadb.Device, err error) {
}
func init() {
androidRootCmd.AddCommand(listAndroidDevicesCmd)
CmdAndroidRoot.AddCommand(listAndroidDevicesCmd)
}

View File

@@ -9,7 +9,7 @@ import (
var serial string
var androidRootCmd = &cobra.Command{
var CmdAndroidRoot = &cobra.Command{
Use: "adb",
Short: "simple utils for android device management",
PersistentPreRun: func(cmd *cobra.Command, args []string) {},
@@ -22,7 +22,3 @@ func getDevice(serial string) (*uixt.AndroidDevice, error) {
}
return device, nil
}
func Init(rootCmd *cobra.Command) {
rootCmd.AddCommand(androidRootCmd)
}

View File

@@ -66,5 +66,5 @@ func init() {
installCmd.Flags().BoolVarP(&replace, "replace", "r", false, "replace existing application")
installCmd.Flags().BoolVarP(&downgrade, "downgrade", "d", false, "allow version code downgrade (debuggable packages only)")
installCmd.Flags().BoolVarP(&grant, "grant", "g", false, "grant all runtime permissions")
androidRootCmd.AddCommand(installCmd)
CmdAndroidRoot.AddCommand(installCmd)
}

View File

@@ -46,5 +46,5 @@ var screencapAndroidDevicesCmd = &cobra.Command{
func init() {
screencapAndroidDevicesCmd.Flags().StringVarP(&serial, "serial", "s", "", "filter by device's serial")
androidRootCmd.AddCommand(screencapAndroidDevicesCmd)
CmdAndroidRoot.AddCommand(screencapAndroidDevicesCmd)
}

View File

@@ -10,7 +10,7 @@ import (
"github.com/httprunner/httprunner/v5/internal/sdk"
)
var buildCmd = &cobra.Command{
var CmdBuild = &cobra.Command{
Use: "build $path ...",
Short: "build plugin for testing",
Long: `build python/go plugin for testing`,
@@ -33,7 +33,5 @@ var buildCmd = &cobra.Command{
var output string
func init() {
rootCmd.AddCommand(buildCmd)
buildCmd.Flags().StringVarP(&output, "output", "o", "", "funplugin product output path, default: cwd")
CmdBuild.Flags().StringVarP(&output, "output", "o", "", "funplugin product output path, default: cwd")
}

View File

@@ -1,14 +1,16 @@
package cmd
package main
import (
"testing"
"github.com/httprunner/httprunner/v5/cmd"
"github.com/spf13/cobra/doc"
"github.com/stretchr/testify/assert"
)
// run this test to generate markdown docs for hrp command
func TestGenMarkdownTree(t *testing.T) {
err := doc.GenMarkdownTree(rootCmd, "../../docs/cmd")
addAllCommands()
err := doc.GenMarkdownTree(cmd.RootCmd, "../../docs/cmd")
assert.Nil(t, err)
}

View File

@@ -7,8 +7,25 @@ import (
"github.com/getsentry/sentry-go"
"github.com/httprunner/httprunner/v5/cmd"
"github.com/httprunner/httprunner/v5/cmd/adb"
"github.com/httprunner/httprunner/v5/cmd/ios"
"github.com/httprunner/httprunner/v5/code"
)
func addAllCommands() {
// adds all child commands to the root command and sets flags appropriately.
cmd.RootCmd.AddCommand(cmd.CmdBuild)
cmd.RootCmd.AddCommand(cmd.CmdConvert)
cmd.RootCmd.AddCommand(cmd.CmdPytest)
cmd.RootCmd.AddCommand(cmd.CmdRun)
cmd.RootCmd.AddCommand(cmd.CmdScaffold)
cmd.RootCmd.AddCommand(cmd.CmdServer)
cmd.RootCmd.AddCommand(cmd.CmdWiki)
cmd.RootCmd.AddCommand(ios.CmdIOSRoot)
cmd.RootCmd.AddCommand(adb.CmdAndroidRoot)
}
func main() {
defer func() {
if err := recover(); err != nil {
@@ -21,6 +38,9 @@ func main() {
}
}()
exitCode := cmd.Execute()
addAllCommands()
err := cmd.RootCmd.Execute()
exitCode := code.GetErrorCode(err)
os.Exit(exitCode)
}

View File

@@ -14,7 +14,7 @@ import (
"github.com/httprunner/httprunner/v5/internal/builtin"
)
var convertCmd = &cobra.Command{
var CmdConvert = &cobra.Command{
Use: "convert $path...",
Short: "convert multiple source format to HttpRunner JSON/YAML/gotest/pytest cases",
Args: cobra.MinimumNArgs(1),
@@ -105,18 +105,16 @@ var (
)
func init() {
rootCmd.AddCommand(convertCmd)
CmdConvert.Flags().BoolVar(&fromJSONFlag, "from-json", true, "load from json case format")
CmdConvert.Flags().BoolVar(&fromYAMLFlag, "from-yaml", false, "load from yaml case format")
CmdConvert.Flags().BoolVar(&fromHARFlag, "from-har", false, "load from HAR format")
CmdConvert.Flags().BoolVar(&fromPostmanFlag, "from-postman", false, "load from postman format")
CmdConvert.Flags().BoolVar(&fromCurlFlag, "from-curl", false, "load from curl format")
convertCmd.Flags().BoolVar(&fromJSONFlag, "from-json", true, "load from json case format")
convertCmd.Flags().BoolVar(&fromYAMLFlag, "from-yaml", false, "load from yaml case format")
convertCmd.Flags().BoolVar(&fromHARFlag, "from-har", false, "load from HAR format")
convertCmd.Flags().BoolVar(&fromPostmanFlag, "from-postman", false, "load from postman format")
convertCmd.Flags().BoolVar(&fromCurlFlag, "from-curl", false, "load from curl format")
CmdConvert.Flags().BoolVar(&toJSONFlag, "to-json", true, "convert to JSON case scripts")
CmdConvert.Flags().BoolVar(&toYAMLFlag, "to-yaml", false, "convert to YAML case scripts")
CmdConvert.Flags().BoolVar(&toPyTestFlag, "to-pytest", false, "convert to pytest scripts")
convertCmd.Flags().BoolVar(&toJSONFlag, "to-json", true, "convert to JSON case scripts")
convertCmd.Flags().BoolVar(&toYAMLFlag, "to-yaml", false, "convert to YAML case scripts")
convertCmd.Flags().BoolVar(&toPyTestFlag, "to-pytest", false, "convert to pytest scripts")
convertCmd.Flags().StringVarP(&outputDir, "output-dir", "d", "", "specify output directory")
convertCmd.Flags().StringVarP(&profilePath, "profile", "p", "", "specify profile path to override headers and cookies")
CmdConvert.Flags().StringVarP(&outputDir, "output-dir", "d", "", "specify output directory")
CmdConvert.Flags().StringVarP(&profilePath, "profile", "p", "", "specify profile path to override headers and cookies")
}

View File

@@ -71,5 +71,5 @@ var appType string
func init() {
listAppsCmd.Flags().StringVarP(&udid, "udid", "u", "", "specify device by udid")
listAppsCmd.Flags().StringVarP(&appType, "type", "t", "user", "filter application type [user|system|internal|all]")
iosRootCmd.AddCommand(listAppsCmd)
CmdIOSRoot.AddCommand(listAppsCmd)
}

View File

@@ -74,5 +74,5 @@ var listDevicesCmd = &cobra.Command{
var udid string
func init() {
iosRootCmd.AddCommand(listDevicesCmd)
CmdIOSRoot.AddCommand(listDevicesCmd)
}

View File

@@ -7,7 +7,7 @@ import (
"github.com/httprunner/httprunner/v5/uixt/option"
)
var iosRootCmd = &cobra.Command{
var CmdIOSRoot = &cobra.Command{
Use: "ios",
Short: "simple utils for ios device management",
}
@@ -19,7 +19,3 @@ func getDevice(udid string) (*uixt.IOSDevice, error) {
}
return device, nil
}
func Init(rootCmd *cobra.Command) {
rootCmd.AddCommand(iosRootCmd)
}

View File

@@ -49,5 +49,5 @@ var installCmd = &cobra.Command{
func init() {
installCmd.Flags().StringVarP(&udid, "udid", "u", "", "filter by device's serial")
iosRootCmd.AddCommand(installCmd)
CmdIOSRoot.AddCommand(installCmd)
}

View File

@@ -79,5 +79,5 @@ func init() {
mountCmd.Flags().BoolVar(&unmountDeveloperDiskImage, "reset", false, "unmount developer disk images")
mountCmd.Flags().StringVarP(&developerDiskImageDir, "dir", "d", defaultDeveloperDiskImageDir, "specify developer disk image directory")
mountCmd.Flags().StringVarP(&udid, "udid", "u", "", "specify device by udid")
iosRootCmd.AddCommand(mountCmd)
CmdIOSRoot.AddCommand(mountCmd)
}

View File

@@ -46,5 +46,5 @@ var isAll bool
func init() {
psCmd.Flags().StringVarP(&udid, "udid", "u", "", "specify device by udid")
psCmd.Flags().BoolVarP(&isAll, "all", "a", false, "print all processes including system processes")
iosRootCmd.AddCommand(psCmd)
CmdIOSRoot.AddCommand(psCmd)
}

View File

@@ -40,5 +40,5 @@ var rebootCmd = &cobra.Command{
func init() {
rebootCmd.Flags().StringVarP(&udid, "udid", "u", "", "specify device by udid")
iosRootCmd.AddCommand(rebootCmd)
CmdIOSRoot.AddCommand(rebootCmd)
}

View File

@@ -36,5 +36,5 @@ var tunnelCmd = &cobra.Command{
}
func init() {
iosRootCmd.AddCommand(tunnelCmd)
CmdIOSRoot.AddCommand(tunnelCmd)
}

View File

@@ -56,5 +56,5 @@ func init() {
uninstallCmd.Flags().StringVarP(&udid, "udid", "u", "", "filter by device's serial")
uninstallCmd.Flags().StringVarP(&bundleId, "bundleId", "b", "", "bundleId to uninstall")
iosRootCmd.AddCommand(uninstallCmd)
CmdIOSRoot.AddCommand(uninstallCmd)
}

View File

@@ -52,5 +52,5 @@ func init() {
xctestCmd.Flags().StringVarP(&bundleID, "bundleID", "b", "com.gtf.wda.runner.xctrunner", "specify ios bundleID")
xctestCmd.Flags().StringVarP(&testRunnerBundleID, "testRunnerBundleID", "t", "com.gtf.wda.runner.xctrunner", "specify ios testRunnerBundleID")
xctestCmd.Flags().StringVarP(&xctestConfig, "xctestConfig", "x", "GtfWdaRunner.xctest", "specify ios xctestConfig")
iosRootCmd.AddCommand(xctestCmd)
CmdIOSRoot.AddCommand(xctestCmd)
}

View File

@@ -13,7 +13,7 @@ import (
"github.com/httprunner/httprunner/v5/internal/sdk"
)
var pytestCmd = &cobra.Command{
var CmdPytest = &cobra.Command{
Use: "pytest $path ...",
Short: "run API test with pytest",
Args: cobra.MinimumNArgs(1),
@@ -38,10 +38,6 @@ var pytestCmd = &cobra.Command{
},
}
func init() {
rootCmd.AddCommand(pytestCmd)
}
func runPytest(args []string) error {
args = append([]string{"run"}, args...)
return myexec.ExecPython3Command("httprunner", args...)

View File

@@ -4,14 +4,11 @@ import (
"github.com/spf13/cobra"
hrp "github.com/httprunner/httprunner/v5"
"github.com/httprunner/httprunner/v5/cmd/adb"
"github.com/httprunner/httprunner/v5/cmd/ios"
"github.com/httprunner/httprunner/v5/code"
"github.com/httprunner/httprunner/v5/internal/version"
)
// rootCmd represents the base command when called without any subcommands
var rootCmd = &cobra.Command{
var RootCmd = &cobra.Command{
Use: "hrp",
Short: "All-in-One Testing Framework for API, UI and Performance",
Long: `
@@ -62,16 +59,8 @@ var (
venv string
)
// Execute adds all child commands to the root command and sets flags appropriately.
// This is called by main.main(). It only needs to happen once to the rootCmd.
func Execute() int {
rootCmd.PersistentFlags().StringVarP(&logLevel, "log-level", "l", "INFO", "set log level")
rootCmd.PersistentFlags().BoolVar(&logJSON, "log-json", false, "set log to json format (default colorized console)")
rootCmd.PersistentFlags().StringVar(&venv, "venv", "", "specify python3 venv path")
ios.Init(rootCmd)
adb.Init(rootCmd)
err := rootCmd.Execute()
return code.GetErrorCode(err)
func init() {
RootCmd.PersistentFlags().StringVarP(&logLevel, "log-level", "l", "INFO", "set log level")
RootCmd.PersistentFlags().BoolVar(&logJSON, "log-json", false, "set log to json format (default colorized console)")
RootCmd.PersistentFlags().StringVar(&venv, "venv", "", "specify python3 venv path")
}

View File

@@ -7,7 +7,7 @@ import (
)
// runCmd represents the run command
var runCmd = &cobra.Command{
var CmdRun = &cobra.Command{
Use: "run $path...",
Short: "run API test with go engine",
Long: `run yaml/json testcase files for API test`,
@@ -38,15 +38,14 @@ var (
)
func init() {
rootCmd.AddCommand(runCmd)
runCmd.Flags().BoolVarP(&continueOnFailure, "continue-on-failure", "c", false, "continue running next step when failure occurs")
runCmd.Flags().BoolVar(&requestsLogOff, "log-requests-off", false, "turn off request & response details logging")
runCmd.Flags().BoolVar(&httpStatOn, "http-stat", false, "turn on HTTP latency stat (DNSLookup, TCP Connection, etc.)")
runCmd.Flags().BoolVar(&pluginLogOn, "log-plugin", false, "turn on plugin logging")
runCmd.Flags().StringVarP(&proxyUrl, "proxy-url", "p", "", "set proxy url")
runCmd.Flags().BoolVarP(&saveTests, "save-tests", "s", false, "save tests summary")
runCmd.Flags().BoolVarP(&genHTMLReport, "gen-html-report", "g", false, "generate html report")
runCmd.Flags().Float32Var(&caseTimeout, "case-timeout", 3600, "set testcase timeout (seconds)")
CmdRun.Flags().BoolVarP(&continueOnFailure, "continue-on-failure", "c", false, "continue running next step when failure occurs")
CmdRun.Flags().BoolVar(&requestsLogOff, "log-requests-off", false, "turn off request & response details logging")
CmdRun.Flags().BoolVar(&httpStatOn, "http-stat", false, "turn on HTTP latency stat (DNSLookup, TCP Connection, etc.)")
CmdRun.Flags().BoolVar(&pluginLogOn, "log-plugin", false, "turn on plugin logging")
CmdRun.Flags().StringVarP(&proxyUrl, "proxy-url", "p", "", "set proxy url")
CmdRun.Flags().BoolVarP(&saveTests, "save-tests", "s", false, "save tests summary")
CmdRun.Flags().BoolVarP(&genHTMLReport, "gen-html-report", "g", false, "generate html report")
CmdRun.Flags().Float32Var(&caseTimeout, "case-timeout", 3600, "set testcase timeout (seconds)")
}
func makeHRPRunner() *hrp.HRPRunner {

View File

@@ -9,7 +9,7 @@ import (
"github.com/httprunner/httprunner/v5/internal/scaffold"
)
var scaffoldCmd = &cobra.Command{
var CmdScaffold = &cobra.Command{
Use: "startproject $project_name",
Aliases: []string{"scaffold"},
Short: "create a scaffold project",
@@ -49,10 +49,9 @@ var (
)
func init() {
rootCmd.AddCommand(scaffoldCmd)
scaffoldCmd.Flags().BoolVarP(&force, "force", "f", false, "force to overwrite existing project")
scaffoldCmd.Flags().BoolVar(&genPythonPlugin, "py", true, "generate hashicorp python plugin")
scaffoldCmd.Flags().BoolVar(&genGoPlugin, "go", false, "generate hashicorp go plugin")
scaffoldCmd.Flags().BoolVar(&ignorePlugin, "ignore-plugin", false, "ignore function plugin")
scaffoldCmd.Flags().BoolVar(&empty, "empty", false, "generate empty project")
CmdScaffold.Flags().BoolVarP(&force, "force", "f", false, "force to overwrite existing project")
CmdScaffold.Flags().BoolVar(&genPythonPlugin, "py", true, "generate hashicorp python plugin")
CmdScaffold.Flags().BoolVar(&genGoPlugin, "go", false, "generate hashicorp go plugin")
CmdScaffold.Flags().BoolVar(&ignorePlugin, "ignore-plugin", false, "ignore function plugin")
CmdScaffold.Flags().BoolVar(&empty, "empty", false, "generate empty project")
}

View File

@@ -1,24 +1,34 @@
package cmd
import (
"os"
"github.com/httprunner/httprunner/v5/server"
"github.com/spf13/cobra"
)
// serverCmd represents the server command
var serverCmd = &cobra.Command{
var CmdServer = &cobra.Command{
Use: "server start",
Short: "start hrp server",
Long: `start hrp server, call httprunner by HTTP`,
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return server.NewRouter().Run(port)
router := server.NewRouter()
mcpConfigPath = os.ExpandEnv(mcpConfigPath)
if mcpConfigPath != "" {
router.InitMCPHub(mcpConfigPath)
}
return router.Run(port)
},
}
var port int
var (
port int
mcpConfigPath string
)
func init() {
rootCmd.AddCommand(serverCmd)
serverCmd.Flags().IntVarP(&port, "port", "p", 8082, "Port to run the server on")
CmdServer.Flags().IntVarP(&port, "port", "p", 8082, "port to run the server on")
CmdServer.Flags().StringVarP(&mcpConfigPath, "mcp-config", "c", "$HOME/.hrp/mcp.json", "path to the MCP config file")
}

View File

@@ -10,7 +10,7 @@ import (
"github.com/httprunner/httprunner/v5/internal/wiki"
)
var wikiCmd = &cobra.Command{
var CmdWiki = &cobra.Command{
Use: "wiki",
Aliases: []string{"info", "docs", "doc"},
Short: "visit https://httprunner.com",
@@ -26,7 +26,3 @@ var wikiCmd = &cobra.Command{
return wiki.OpenWiki()
},
}
func init() {
rootCmd.AddCommand(wikiCmd)
}

View File

@@ -4,7 +4,6 @@ import (
"reflect"
"github.com/httprunner/httprunner/v5/internal/builtin"
"github.com/httprunner/httprunner/v5/uixt"
"github.com/httprunner/httprunner/v5/uixt/option"
)
@@ -23,26 +22,26 @@ func NewConfig(name string) *TConfig {
// define struct for testcase config
type TConfig struct {
Name string `json:"name" yaml:"name"` // required
Verify bool `json:"verify,omitempty" yaml:"verify,omitempty"`
BaseURL string `json:"base_url,omitempty" yaml:"base_url,omitempty"` // deprecated in v4.1, moved to env
Headers map[string]string `json:"headers,omitempty" yaml:"headers,omitempty"` // public request headers
Environs map[string]string `json:"environs,omitempty" yaml:"environs,omitempty"` // environment variables
Variables map[string]interface{} `json:"variables,omitempty" yaml:"variables,omitempty"` // global variables
Parameters map[string]interface{} `json:"parameters,omitempty" yaml:"parameters,omitempty"`
ParametersSetting *TParamsConfig `json:"parameters_setting,omitempty" yaml:"parameters_setting,omitempty"`
ThinkTimeSetting *ThinkTimeConfig `json:"think_time,omitempty" yaml:"think_time,omitempty"`
WebSocketSetting *WebSocketConfig `json:"websocket,omitempty" yaml:"websocket,omitempty"`
IOS []*uixt.IOSDevice `json:"ios,omitempty" yaml:"ios,omitempty"`
Android []*uixt.AndroidDevice `json:"android,omitempty" yaml:"android,omitempty"`
Harmony []*uixt.HarmonyDevice `json:"harmony,omitempty" yaml:"harmony,omitempty"`
RequestTimeout float32 `json:"request_timeout,omitempty" yaml:"request_timeout,omitempty"` // request timeout in seconds
CaseTimeout float32 `json:"case_timeout,omitempty" yaml:"case_timeout,omitempty"` // testcase timeout in seconds
Export []string `json:"export,omitempty" yaml:"export,omitempty"`
Weight int `json:"weight,omitempty" yaml:"weight,omitempty"`
Path string `json:"path,omitempty" yaml:"path,omitempty"` // testcase file path
PluginSetting *PluginConfig `json:"plugin,omitempty" yaml:"plugin,omitempty"` // plugin config
IgnorePopup bool `json:"ignore_popup,omitempty" yaml:"ignore_popup,omitempty"`
Name string `json:"name" yaml:"name"` // required
Verify bool `json:"verify,omitempty" yaml:"verify,omitempty"`
BaseURL string `json:"base_url,omitempty" yaml:"base_url,omitempty"` // deprecated in v4.1, moved to env
Headers map[string]string `json:"headers,omitempty" yaml:"headers,omitempty"` // public request headers
Environs map[string]string `json:"environs,omitempty" yaml:"environs,omitempty"` // environment variables
Variables map[string]interface{} `json:"variables,omitempty" yaml:"variables,omitempty"` // global variables
Parameters map[string]interface{} `json:"parameters,omitempty" yaml:"parameters,omitempty"`
ParametersSetting *TParamsConfig `json:"parameters_setting,omitempty" yaml:"parameters_setting,omitempty"`
ThinkTimeSetting *ThinkTimeConfig `json:"think_time,omitempty" yaml:"think_time,omitempty"`
WebSocketSetting *WebSocketConfig `json:"websocket,omitempty" yaml:"websocket,omitempty"`
IOS []*option.IOSDeviceOptions `json:"ios,omitempty" yaml:"ios,omitempty"`
Android []*option.AndroidDeviceOptions `json:"android,omitempty" yaml:"android,omitempty"`
Harmony []*option.HarmonyDeviceOptions `json:"harmony,omitempty" yaml:"harmony,omitempty"`
RequestTimeout float32 `json:"request_timeout,omitempty" yaml:"request_timeout,omitempty"` // request timeout in seconds
CaseTimeout float32 `json:"case_timeout,omitempty" yaml:"case_timeout,omitempty"` // testcase timeout in seconds
Export []string `json:"export,omitempty" yaml:"export,omitempty"`
Weight int `json:"weight,omitempty" yaml:"weight,omitempty"`
Path string `json:"path,omitempty" yaml:"path,omitempty"` // testcase file path
PluginSetting *PluginConfig `json:"plugin,omitempty" yaml:"plugin,omitempty"` // plugin config
IgnorePopup bool `json:"ignore_popup,omitempty" yaml:"ignore_popup,omitempty"`
}
func (c *TConfig) Get() *TConfig {
@@ -120,63 +119,54 @@ func (c *TConfig) SetWebSocket(times, interval, timeout, size int64) *TConfig {
func (c *TConfig) SetIOS(opts ...option.IOSDeviceOption) *TConfig {
iosOptions := option.NewIOSDeviceOptions(opts...)
device := &uixt.IOSDevice{
Options: iosOptions,
}
// each device can have its own settings
if iosOptions.UDID != "" {
c.IOS = append(c.IOS, device)
c.IOS = append(c.IOS, iosOptions)
return c
}
// device UDID is not specified, settings will be shared
if len(c.IOS) == 0 {
c.IOS = append(c.IOS, device)
c.IOS = append(c.IOS, iosOptions)
} else {
c.IOS[0] = device
c.IOS[0] = iosOptions
}
return c
}
func (c *TConfig) SetHarmony(opts ...option.HarmonyDeviceOption) *TConfig {
harmonyOptions := option.NewHarmonyDeviceOptions(opts...)
device := &uixt.HarmonyDevice{
Options: harmonyOptions,
}
// each device can have its own settings
if harmonyOptions.ConnectKey != "" {
c.Harmony = append(c.Harmony, device)
c.Harmony = append(c.Harmony, harmonyOptions)
return c
}
// device UDID is not specified, settings will be shared
if len(c.Harmony) == 0 {
c.Harmony = append(c.Harmony, device)
c.Harmony = append(c.Harmony, harmonyOptions)
} else {
c.Harmony[0] = device
c.Harmony[0] = harmonyOptions
}
return c
}
func (c *TConfig) SetAndroid(opts ...option.AndroidDeviceOption) *TConfig {
uiaOptions := option.NewAndroidDeviceOptions(opts...)
device := &uixt.AndroidDevice{
Options: uiaOptions,
}
// each device can have its own settings
if uiaOptions.SerialNumber != "" {
c.Android = append(c.Android, device)
c.Android = append(c.Android, uiaOptions)
return c
}
// device UDID is not specified, settings will be shared
if len(c.Android) == 0 {
c.Android = append(c.Android, device)
c.Android = append(c.Android, uiaOptions)
} else {
c.Android[0] = device
c.Android[0] = uiaOptions
}
return c
}

View File

@@ -1,6 +1,6 @@
## hrp
Next-Generation API Testing Solution.
All-in-One Testing Framework for API, UI and Performance
### Synopsis
@@ -12,30 +12,52 @@ Next-Generation API Testing Solution.
██║ ██║ ██║ ██║ ██║ ██║ ██║╚██████╔╝██║ ╚████║██║ ╚████║███████╗██║ ██║
╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═══╝╚═╝ ╚═══╝╚══════╝╚═╝ ╚═╝
HttpRunner is an open source API testing tool that supports HTTP(S)/HTTP2/WebSocket/RPC
network protocols, covering API testing, performance testing and digital experience
monitoring (DEM) test types. Enjoy! ✨ 🚀 ✨
HttpRunner: Enjoy your All-in-One Testing Solution ✨ 🚀 ✨
License: Apache-2.0
💡 Simple Yet Powerful
- Natural language driven test scenarios powered by LLM
- User-friendly SDK API with IDE auto-completion
- Intuitive GoTest/YAML/JSON/Text testcase format
📌 Comprehensive Testing Capabilities
- UI Automation: Android/iOS/Harmony/Browser
- API Testing: HTTP(S)/HTTP2/WebSocket/RPC
- Load Testing: run API testcase concurrently with boomer
🧩 High Scalability
- Plugin system for custom functions
- Distributed testing support
- Cross-platform: macOS/Linux/Windows
🛠 Easy Integration
- CI/CD friendly with JSON logs and HTML reports
- Rich ecosystem tools
Learn more:
Website: https://httprunner.com
Github: https://github.com/httprunner/httprunner
Copyright 2017 debugtalk
GitHub: https://github.com/httprunner/httprunner
Copyright © 2017-present debugtalk. Apache-2.0 License.
### Options
```
-h, --help help for hrp
-h, --help help for hrp
--log-json set log to json format (default colorized console)
-l, --log-level string set log level (default "INFO")
--venv string specify python3 venv path
```
### SEE ALSO
* [hrp boom](hrp_boom.md) - run load test with boomer
* [hrp adb](hrp_adb.md) - simple utils for android device management
* [hrp build](hrp_build.md) - build plugin for testing
* [hrp convert](hrp_convert.md) - convert multiple source format to HttpRunner JSON/YAML/gotest/pytest cases
* [hrp ios](hrp_ios.md) - simple utils for ios device management
* [hrp pytest](hrp_pytest.md) - run API test with pytest
* [hrp run](hrp_run.md) - run API test with go engine
* [hrp server](hrp_server.md) - start hrp server
* [hrp startproject](hrp_startproject.md) - create a scaffold project
* [hrp wiki](hrp_wiki.md) - visit https://httprunner.com
###### Auto generated by spf13/cobra on 9-Nov-2024
###### Auto generated by spf13/cobra on 24-Apr-2025

26
docs/cmd/hrp_adb.md Normal file
View File

@@ -0,0 +1,26 @@
## hrp adb
simple utils for android device management
### Options
```
-h, --help help for adb
```
### Options inherited from parent commands
```
--log-json set log to json format (default colorized console)
-l, --log-level string set log level (default "INFO")
--venv string specify python3 venv path
```
### SEE ALSO
* [hrp](hrp.md) - All-in-One Testing Framework for API, UI and Performance
* [hrp adb devices](hrp_adb_devices.md) - List all Android devices
* [hrp adb install](hrp_adb_install.md) - push package to the device and install them automatically
* [hrp adb screencap](hrp_adb_screencap.md) - Start android screen capture
###### Auto generated by spf13/cobra on 24-Apr-2025

View File

@@ -0,0 +1,27 @@
## hrp adb devices
List all Android devices
```
hrp adb devices [flags]
```
### Options
```
-h, --help help for devices
```
### Options inherited from parent commands
```
--log-json set log to json format (default colorized console)
-l, --log-level string set log level (default "INFO")
--venv string specify python3 venv path
```
### SEE ALSO
* [hrp adb](hrp_adb.md) - simple utils for android device management
###### Auto generated by spf13/cobra on 24-Apr-2025

View File

@@ -0,0 +1,31 @@
## hrp adb install
push package to the device and install them automatically
```
hrp adb install [flags] PACKAGE
```
### Options
```
-d, --downgrade allow version code downgrade (debuggable packages only)
-g, --grant grant all runtime permissions
-h, --help help for install
-r, --replace replace existing application
-s, --serial string filter by device's serial
```
### Options inherited from parent commands
```
--log-json set log to json format (default colorized console)
-l, --log-level string set log level (default "INFO")
--venv string specify python3 venv path
```
### SEE ALSO
* [hrp adb](hrp_adb.md) - simple utils for android device management
###### Auto generated by spf13/cobra on 24-Apr-2025

View File

@@ -0,0 +1,28 @@
## hrp adb screencap
Start android screen capture
```
hrp adb screencap [flags]
```
### Options
```
-h, --help help for screencap
-s, --serial string filter by device's serial
```
### Options inherited from parent commands
```
--log-json set log to json format (default colorized console)
-l, --log-level string set log level (default "INFO")
--venv string specify python3 venv path
```
### SEE ALSO
* [hrp adb](hrp_adb.md) - simple utils for android device management
###### Auto generated by spf13/cobra on 24-Apr-2025

View File

@@ -24,8 +24,16 @@ hrp build $path ... [flags]
-o, --output string funplugin product output path, default: cwd
```
### Options inherited from parent commands
```
--log-json set log to json format (default colorized console)
-l, --log-level string set log level (default "INFO")
--venv string specify python3 venv path
```
### SEE ALSO
* [hrp](hrp.md) - Next-Generation API Testing Solution.
* [hrp](hrp.md) - All-in-One Testing Framework for API, UI and Performance
###### Auto generated by spf13/cobra on 9-Nov-2024
###### Auto generated by spf13/cobra on 24-Apr-2025

View File

@@ -22,8 +22,16 @@ hrp convert $path... [flags]
--to-yaml convert to YAML case scripts
```
### Options inherited from parent commands
```
--log-json set log to json format (default colorized console)
-l, --log-level string set log level (default "INFO")
--venv string specify python3 venv path
```
### SEE ALSO
* [hrp](hrp.md) - Next-Generation API Testing Solution.
* [hrp](hrp.md) - All-in-One Testing Framework for API, UI and Performance
###### Auto generated by spf13/cobra on 9-Nov-2024
###### Auto generated by spf13/cobra on 24-Apr-2025

32
docs/cmd/hrp_ios.md Normal file
View File

@@ -0,0 +1,32 @@
## hrp ios
simple utils for ios device management
### Options
```
-h, --help help for ios
```
### Options inherited from parent commands
```
--log-json set log to json format (default colorized console)
-l, --log-level string set log level (default "INFO")
--venv string specify python3 venv path
```
### SEE ALSO
* [hrp](hrp.md) - All-in-One Testing Framework for API, UI and Performance
* [hrp ios apps](hrp_ios_apps.md) - List all iOS installed apps
* [hrp ios devices](hrp_ios_devices.md) - List all iOS devices
* [hrp ios install](hrp_ios_install.md) - push package to the device and install them automatically
* [hrp ios mount](hrp_ios_mount.md) - A brief description of your command
* [hrp ios ps](hrp_ios_ps.md) - show running processes
* [hrp ios reboot](hrp_ios_reboot.md) - reboot ios device
* [hrp ios tunnel](hrp_ios_tunnel.md) - tunnel start
* [hrp ios uninstall](hrp_ios_uninstall.md) - uninstall package automatically
* [hrp ios xctest](hrp_ios_xctest.md) - run xctest
###### Auto generated by spf13/cobra on 24-Apr-2025

29
docs/cmd/hrp_ios_apps.md Normal file
View File

@@ -0,0 +1,29 @@
## hrp ios apps
List all iOS installed apps
```
hrp ios apps [flags]
```
### Options
```
-h, --help help for apps
-t, --type string filter application type [user|system|internal|all] (default "user")
-u, --udid string specify device by udid
```
### Options inherited from parent commands
```
--log-json set log to json format (default colorized console)
-l, --log-level string set log level (default "INFO")
--venv string specify python3 venv path
```
### SEE ALSO
* [hrp ios](hrp_ios.md) - simple utils for ios device management
###### Auto generated by spf13/cobra on 24-Apr-2025

View File

@@ -0,0 +1,27 @@
## hrp ios devices
List all iOS devices
```
hrp ios devices [flags]
```
### Options
```
-h, --help help for devices
```
### Options inherited from parent commands
```
--log-json set log to json format (default colorized console)
-l, --log-level string set log level (default "INFO")
--venv string specify python3 venv path
```
### SEE ALSO
* [hrp ios](hrp_ios.md) - simple utils for ios device management
###### Auto generated by spf13/cobra on 24-Apr-2025

View File

@@ -0,0 +1,28 @@
## hrp ios install
push package to the device and install them automatically
```
hrp ios install [flags] PACKAGE
```
### Options
```
-h, --help help for install
-u, --udid string filter by device's serial
```
### Options inherited from parent commands
```
--log-json set log to json format (default colorized console)
-l, --log-level string set log level (default "INFO")
--venv string specify python3 venv path
```
### SEE ALSO
* [hrp ios](hrp_ios.md) - simple utils for ios device management
###### Auto generated by spf13/cobra on 24-Apr-2025

31
docs/cmd/hrp_ios_mount.md Normal file
View File

@@ -0,0 +1,31 @@
## hrp ios mount
A brief description of your command
```
hrp ios mount [flags]
```
### Options
```
-d, --dir string specify developer disk image directory (default "/Users/debugtalk/.devimages")
-h, --help help for mount
--list list developer disk images
--reset unmount developer disk images
-u, --udid string specify device by udid
```
### Options inherited from parent commands
```
--log-json set log to json format (default colorized console)
-l, --log-level string set log level (default "INFO")
--venv string specify python3 venv path
```
### SEE ALSO
* [hrp ios](hrp_ios.md) - simple utils for ios device management
###### Auto generated by spf13/cobra on 24-Apr-2025

29
docs/cmd/hrp_ios_ps.md Normal file
View File

@@ -0,0 +1,29 @@
## hrp ios ps
show running processes
```
hrp ios ps [flags]
```
### Options
```
-a, --all print all processes including system processes
-h, --help help for ps
-u, --udid string specify device by udid
```
### Options inherited from parent commands
```
--log-json set log to json format (default colorized console)
-l, --log-level string set log level (default "INFO")
--venv string specify python3 venv path
```
### SEE ALSO
* [hrp ios](hrp_ios.md) - simple utils for ios device management
###### Auto generated by spf13/cobra on 24-Apr-2025

View File

@@ -0,0 +1,28 @@
## hrp ios reboot
reboot ios device
```
hrp ios reboot [flags]
```
### Options
```
-h, --help help for reboot
-u, --udid string specify device by udid
```
### Options inherited from parent commands
```
--log-json set log to json format (default colorized console)
-l, --log-level string set log level (default "INFO")
--venv string specify python3 venv path
```
### SEE ALSO
* [hrp ios](hrp_ios.md) - simple utils for ios device management
###### Auto generated by spf13/cobra on 24-Apr-2025

View File

@@ -0,0 +1,27 @@
## hrp ios tunnel
tunnel start
```
hrp ios tunnel [flags]
```
### Options
```
-h, --help help for tunnel
```
### Options inherited from parent commands
```
--log-json set log to json format (default colorized console)
-l, --log-level string set log level (default "INFO")
--venv string specify python3 venv path
```
### SEE ALSO
* [hrp ios](hrp_ios.md) - simple utils for ios device management
###### Auto generated by spf13/cobra on 24-Apr-2025

View File

@@ -0,0 +1,29 @@
## hrp ios uninstall
uninstall package automatically
```
hrp ios uninstall [flags] PACKAGE
```
### Options
```
-b, --bundleId string bundleId to uninstall
-h, --help help for uninstall
-u, --udid string filter by device's serial
```
### Options inherited from parent commands
```
--log-json set log to json format (default colorized console)
-l, --log-level string set log level (default "INFO")
--venv string specify python3 venv path
```
### SEE ALSO
* [hrp ios](hrp_ios.md) - simple utils for ios device management
###### Auto generated by spf13/cobra on 24-Apr-2025

View File

@@ -0,0 +1,31 @@
## hrp ios xctest
run xctest
```
hrp ios xctest [flags]
```
### Options
```
-b, --bundleID string specify ios bundleID (default "com.gtf.wda.runner.xctrunner")
-h, --help help for xctest
-t, --testRunnerBundleID string specify ios testRunnerBundleID (default "com.gtf.wda.runner.xctrunner")
-u, --udid string specify ios device's UDID
-x, --xctestConfig string specify ios xctestConfig (default "GtfWdaRunner.xctest")
```
### Options inherited from parent commands
```
--log-json set log to json format (default colorized console)
-l, --log-level string set log level (default "INFO")
--venv string specify python3 venv path
```
### SEE ALSO
* [hrp ios](hrp_ios.md) - simple utils for ios device management
###### Auto generated by spf13/cobra on 24-Apr-2025

View File

@@ -12,8 +12,16 @@ hrp pytest $path ... [flags]
-h, --help help for pytest
```
### Options inherited from parent commands
```
--log-json set log to json format (default colorized console)
-l, --log-level string set log level (default "INFO")
--venv string specify python3 venv path
```
### SEE ALSO
* [hrp](hrp.md) - Next-Generation API Testing Solution.
* [hrp](hrp.md) - All-in-One Testing Framework for API, UI and Performance
###### Auto generated by spf13/cobra on 9-Nov-2024
###### Auto generated by spf13/cobra on 24-Apr-2025

View File

@@ -32,8 +32,16 @@ hrp run $path... [flags]
-s, --save-tests save tests summary
```
### Options inherited from parent commands
```
--log-json set log to json format (default colorized console)
-l, --log-level string set log level (default "INFO")
--venv string specify python3 venv path
```
### SEE ALSO
* [hrp](hrp.md) - Next-Generation API Testing Solution.
* [hrp](hrp.md) - All-in-One Testing Framework for API, UI and Performance
###### Auto generated by spf13/cobra on 9-Nov-2024
###### Auto generated by spf13/cobra on 24-Apr-2025

View File

@@ -4,7 +4,7 @@ start hrp server
### Synopsis
start hrp server. exec automation by http
start hrp server, call httprunner by HTTP
```
hrp server start [flags]
@@ -13,12 +13,21 @@ hrp server start [flags]
### Options
```
-h, --help help for server
-p, --port int Port to run the server on (default 8082)
-h, --help help for server
-c, --mcp-config string path to the MCP config file (default "$HOME/.hrp/mcp.json")
-p, --port int port to run the server on (default 8082)
```
### Options inherited from parent commands
```
--log-json set log to json format (default colorized console)
-l, --log-level string set log level (default "INFO")
--venv string specify python3 venv path
```
### SEE ALSO
* [hrp](hrp.md) - Next-Generation API Testing Solution.
* [hrp](hrp.md) - All-in-One Testing Framework for API, UI and Performance
###### Auto generated by spf13/cobra on 9-Nov-2024
###### Auto generated by spf13/cobra on 24-Apr-2025

View File

@@ -17,8 +17,16 @@ hrp startproject $project_name [flags]
--py generate hashicorp python plugin (default true)
```
### Options inherited from parent commands
```
--log-json set log to json format (default colorized console)
-l, --log-level string set log level (default "INFO")
--venv string specify python3 venv path
```
### SEE ALSO
* [hrp](hrp.md) - Next-Generation API Testing Solution.
* [hrp](hrp.md) - All-in-One Testing Framework for API, UI and Performance
###### Auto generated by spf13/cobra on 9-Nov-2024
###### Auto generated by spf13/cobra on 24-Apr-2025

View File

@@ -12,8 +12,16 @@ hrp wiki [flags]
-h, --help help for wiki
```
### Options inherited from parent commands
```
--log-json set log to json format (default colorized console)
-l, --log-level string set log level (default "INFO")
--venv string specify python3 venv path
```
### SEE ALSO
* [hrp](hrp.md) - Next-Generation API Testing Solution.
* [hrp](hrp.md) - All-in-One Testing Framework for API, UI and Performance
###### Auto generated by spf13/cobra on 9-Nov-2024
###### Auto generated by spf13/cobra on 24-Apr-2025

View File

@@ -1,6 +1,6 @@
{
"config": {
"name": "播_抖极_feed卡片_android",
"name": "播_抖音_滑动场景_随机间隔_android",
"variables": {
"device": "${ENV(SerialNumber)}"
},
@@ -8,91 +8,32 @@
{
"serial": "$device",
"log_on": true,
"close_popup": true
"adb_server_host": "localhost",
"adb_server_port": 5037,
"uia2_ip": "localhost",
"uia2_port": 6790,
"uia2_server_package_name": "io.appium.uiautomator2.server",
"uia2_server_test_package_name": "io.appium.uiautomator2.server.test"
}
]
},
"teststeps": [
{
"name": "清理android无关进程",
"name": "启动抖音",
"android": {
"os_type": "android",
"actions": [
{
"method": "app_terminate",
"params": "com.ss.android.ugc.aweme"
},
{
"method": "app_terminate",
"params": "com.ss.android.ugc.aweme.lite"
},
{
"method": "app_terminate",
"params": "com.smile.gifmaker"
},
{
"method": "app_terminate",
"params": "com.kuaishou.nebula"
},
{
"method": "app_terminate",
"params": "com.tencent.mm"
},
{
"method": "app_terminate",
"params": "com.duowan.kiwi"
},
{
"method": "app_terminate",
"params": "air.tv.douyu.android"
},
{
"method": "app_terminate",
"params": "com.xingin.xhs"
},
{
"method": "app_terminate",
"params": "com.taobao.taobao"
},
{
"method": "app_terminate",
"params": "tv.danmaku.bili"
},
{
"method": "app_terminate",
"params": "com.cmcc.cmvideo"
},
{
"method": "app_terminate",
"params": "com.xunmeng.pinduoduo"
},
{
"method": "app_terminate",
"params": "com.cctv.yangshipin.app.androidp"
}
]
}
},
{
"name": "启动抖音极速版",
"android": {
"actions": [
{
"method": "app_terminate",
"params": "com.ss.android.ugc.aweme.lite"
},
{
"method": "app_launch",
"params": "com.ss.android.ugc.aweme.lite"
"params": "com.ss.android.ugc.aweme"
},
{
"method": "sleep",
"params": 10
},
{
"method": "close_popups",
"options": {
"max_retry_times": 2
}
}
]
},
@@ -100,139 +41,119 @@
{
"check": "ui_foreground_app",
"assert": "equal",
"expect": "com.ss.android.ugc.aweme.lite",
"msg": "app [com.ss.android.ugc.aweme.lite] should be in foreground"
"expect": "com.ss.android.ugc.aweme",
"msg": "app [com.ss.android.ugc.aweme] should be in foreground"
}
]
},
{
"name": "处理通讯录弹窗",
"android": {
"actions": [
{
"method": "tap_ocr",
"params": "拒绝",
"ignore_NotFoundError": true
}
]
}
},
{
"name": "处理青少年弹窗",
"android": {
"os_type": "android",
"actions": [
{
"method": "sleep",
"params": 5
},
{
"method": "tap_ocr",
"params": "我知道了",
"ignore_NotFoundError": true
"options": {
"max_retry_times": 1,
"ignore_NotFoundError": true
}
}
]
}
},
{
"name": "点击直播标签,进入直播间",
"android": {
"actions": [
{
"method": "swipe_to_tap_texts",
"params": [
"看直播开宝箱",
"最高领",
"点击进入直播间"
],
"identifier": "click_live_new",
"max_retry_times": 40,
"wait_time": 2,
"direction": [
0.5,
0.8,
0.5,
0.2
],
"scope": [
0.1,
0.5,
0.9,
0.9
],
"offset": [
0,
-100
]
}
]
}
},
{
"name": "等待30s",
"android": {
"actions": [
{
"method": "sleep",
"params": 30
}
]
}
},
{
"name": "下滑进入下一个直播间",
"name": "滑动 Feed 3 次,随机间隔 0-5s",
"loops": 3,
"android": {
"os_type": "android",
"actions": [
{
"method": "swipe",
"params": [
0.5,
0.7,
0.5,
0.1
],
"identifier": "slide_in_live_new"
"params": "up",
"options": {
"max_retry_times": 1
}
},
{
"method": "sleep",
"params": 30
"method": "sleep_random",
"params": [
0,
5
]
}
]
}
},
{
"name": "返回主界面,并打开本地时间戳",
"name": "滑动 Feed 1 次,随机间隔 5-10s",
"loops": 1,
"android": {
"os_type": "android",
"actions": [
{
"method": "swipe",
"params": "up",
"options": {
"max_retry_times": 1
}
},
{
"method": "sleep_random",
"params": [
5,
10
]
}
]
}
},
{
"name": "滑动 Feed 10 次70% 随机间隔 0-5s30% 随机间隔 5-10s",
"loops": 10,
"android": {
"os_type": "android",
"actions": [
{
"method": "swipe",
"params": "up",
"options": {
"max_retry_times": 1
}
},
{
"method": "sleep_random",
"params": [
0,
5,
0.7,
5,
10,
0.3
]
}
]
}
},
{
"name": "exit",
"android": {
"os_type": "android",
"actions": [
{
"method": "app_terminate",
"params": "com.ss.android.ugc.aweme.lite"
},
{
"method": "home"
},
{
"method": "swipe_to_tap_app",
"params": "local",
"max_retry_times": 5,
"offset": [
0,
-50
]
},
{
"method": "sleep",
"params": 10
"params": "com.ss.android.ugc.aweme"
}
]
},
"validate": [
{
"check": "ui_ocr",
"assert": "exists",
"expect": "17",
"msg": "打开本地时间戳失败"
"check": "ui_foreground_app",
"assert": "not_equal",
"expect": "com.ss.android.ugc.aweme",
"msg": "app [com.ss.android.ugc.aweme] should not be in foreground"
}
]
}
]
}
}

View File

@@ -15,7 +15,8 @@ func TestAndroidDouyinFeedTest(t *testing.T) {
WithVariables(map[string]interface{}{
"device": "${ENV(SerialNumber)}",
}).
SetAndroid(option.WithSerialNumber("$device")),
SetAndroid(option.WithSerialNumber("$device"),
option.WithAdbLogOn(true)),
TestSteps: []hrp.IStep{
hrp.NewStep("启动抖音").
Android().

60
go.mod
View File

@@ -1,15 +1,22 @@
module github.com/httprunner/httprunner/v5
go 1.22.0
go 1.23.0
toolchain go1.23.7
require (
github.com/Masterminds/semver v1.5.0
github.com/andybalholm/brotli v1.0.4
github.com/cloudwego/eino v0.3.16
github.com/cloudwego/eino-ext/components/model/openai v0.0.0-20250314110024-9e89ba18146c
github.com/bytedance/sonic v1.13.2
github.com/cloudwego/eino v0.3.26
github.com/cloudwego/eino-ext/components/model/ark v0.1.6
github.com/cloudwego/eino-ext/components/model/openai v0.0.0-20250417123744-154d7ca4d3cd
github.com/cloudwego/eino-ext/components/tool/mcp v0.0.0-20250328102648-b47e7f1587fa
github.com/cloudwego/eino-ext/libs/acl/openai v0.0.0-20250417123744-154d7ca4d3cd
github.com/danielpaulus/go-ios v1.0.161
github.com/denisbrodbeck/machineid v1.0.1
github.com/fatih/color v1.16.0
github.com/getkin/kin-openapi v0.118.0
github.com/getsentry/sentry-go v0.13.0
github.com/gin-gonic/gin v1.10.0
github.com/go-openapi/spec v0.20.7
@@ -21,35 +28,32 @@ require (
github.com/joho/godotenv v1.5.1
github.com/json-iterator/go v1.1.12
github.com/maja42/goval v1.2.1
github.com/mark3labs/mcp-go v0.22.0
github.com/mitchellh/mapstructure v1.5.0
github.com/pkg/errors v0.9.1
github.com/rs/zerolog v1.33.0
github.com/satori/go.uuid v1.2.0
github.com/spf13/cobra v1.5.0
github.com/stretchr/testify v1.10.0
golang.org/x/net v0.26.0
golang.org/x/text v0.21.0
golang.org/x/net v0.39.0
golang.org/x/text v0.24.0
gopkg.in/yaml.v2 v2.4.0
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/bytedance/sonic v1.12.2 // indirect
github.com/bytedance/sonic/loader v0.2.0 // indirect
github.com/bytedance/sonic/loader v0.2.4 // indirect
github.com/cenkalti/backoff v2.2.1+incompatible // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/eino-ext/libs/acl/openai v0.0.0-20250305023926-469de0301955 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/cloudwego/base64x v0.1.5 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/getkin/kin-openapi v0.118.0 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-errors/errors v1.4.2 // indirect
github.com/go-openapi/jsonpointer v0.19.5 // indirect
github.com/go-openapi/jsonpointer v0.21.1 // indirect
github.com/go-openapi/jsonreference v0.20.0 // indirect
github.com/go-openapi/swag v0.22.3 // indirect
github.com/go-openapi/swag v0.23.1 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.20.0 // indirect
@@ -67,12 +71,12 @@ require (
github.com/inconshreveable/mousetrap v1.0.1 // indirect
github.com/invopop/yaml v0.1.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mailru/easyjson v0.9.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/meguminnnnnnnnn/go-openai v0.0.0-20250408071642-761325becfd6 // indirect
github.com/miekg/dns v1.1.57 // indirect
github.com/mitchellh/go-testing-interface v1.14.1 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
@@ -81,33 +85,35 @@ require (
github.com/nikolalohinski/gonja v1.5.3 // indirect
github.com/oklog/run v1.1.0 // indirect
github.com/onsi/ginkgo/v2 v2.9.5 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/perimeterx/marshmallow v1.1.4 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/perimeterx/marshmallow v1.1.5 // indirect
github.com/pierrec/lz4 v2.6.1+incompatible // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/quic-go/qtls-go1-20 v0.4.1 // indirect
github.com/quic-go/quic-go v0.40.1-0.20231203135336-87ef8ec48d55 // indirect
github.com/rogpeppe/go-internal v1.12.0 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/sashabaranov/go-openai v1.32.5 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/slongfield/pyfmt v0.0.0-20220222012616-ea85ff4c361f // indirect
github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8 // indirect
github.com/spf13/cast v1.7.1 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/tadglines/go-pkgs v0.0.0-20210623144937-b983b20f54f9 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
github.com/volcengine/volc-sdk-golang v1.0.23 // indirect
github.com/volcengine/volcengine-go-sdk v1.0.185 // indirect
github.com/yargevad/filepathx v1.0.0 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
go.mozilla.org/pkcs7 v0.0.0-20210826202110-33d05740a352 // indirect
go.uber.org/mock v0.4.0 // indirect
golang.org/x/arch v0.11.0 // indirect
golang.org/x/crypto v0.31.0 // indirect
golang.org/x/exp v0.0.0-20230725093048-515e97ebf090 // indirect
golang.org/x/mod v0.17.0 // indirect
golang.org/x/sync v0.10.0 // indirect
golang.org/x/sys v0.28.0 // indirect
golang.org/x/arch v0.16.0 // indirect
golang.org/x/crypto v0.37.0 // indirect
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
golang.org/x/mod v0.24.0 // indirect
golang.org/x/sync v0.13.0 // indirect
golang.org/x/sys v0.32.0 // indirect
golang.org/x/time v0.5.0 // indirect
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect
golang.org/x/tools v0.32.0 // indirect
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240116215550-a9fa1716bcac // indirect
google.golang.org/grpc v1.57.0 // indirect

176
go.sum
View File

@@ -1,34 +1,42 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww=
github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y=
github.com/airbrake/gobrake v3.6.1+incompatible/go.mod h1:wM4gu3Cn0W0K7GUuVWnlXZU11AGBXMILnrdOU8Kn00o=
github.com/andybalholm/brotli v1.0.4 h1:V7DdXeJtZscaqfNuAdSRuRFzuiKlHSC/Zh3zl9qY3JY=
github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
github.com/avast/retry-go v3.0.0+incompatible/go.mod h1:XtSnn+n/sHqQIpZ10K1qAevBhOOCWBLXXy3hyiqqBrY=
github.com/bitly/go-simplejson v0.5.0/go.mod h1:cXHtHw4XUPsvGaxgjIAn8PhEWG9NfngEKAMDJEczWVA=
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4=
github.com/bugsnag/bugsnag-go v1.4.0/go.mod h1:2oa8nejYd4cQ/b0hMIopN0lCRxU0bueqREvZLWFrtK8=
github.com/bugsnag/panicwrap v1.2.0/go.mod h1:D/8v3kj0zr8ZAKg1AQ6crr+5VwKN5eIywRkfhyM/+dE=
github.com/bytedance/mockey v1.2.13 h1:jokWZAm/pUEbD939Rhznz615MKUCZNuvCFQlJ2+ntoo=
github.com/bytedance/mockey v1.2.13/go.mod h1:1BPHF9sol5R1ud/+0VEHGQq/+i2lN+GTsr3O2Q9IENY=
github.com/bytedance/sonic v1.12.2 h1:oaMFuRTpMHYLpCntGca65YWt5ny+wAceDERTkT2L9lg=
github.com/bytedance/sonic v1.12.2/go.mod h1:B8Gt/XvtZ3Fqj+iSKMypzymZxw/FVwgIGKzMzT9r/rk=
github.com/bytedance/mockey v1.2.14 h1:KZaFgPdiUwW+jOWFieo3Lr7INM1P+6adO3hxZhDswY8=
github.com/bytedance/mockey v1.2.14/go.mod h1:1BPHF9sol5R1ud/+0VEHGQq/+i2lN+GTsr3O2Q9IENY=
github.com/bytedance/sonic v1.13.2 h1:8/H1FempDZqC4VqjptGo14QQlJx8VdZJegxs6wwfqpQ=
github.com/bytedance/sonic v1.13.2/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/bytedance/sonic/loader v0.2.0 h1:zNprn+lsIP06C/IqCHs3gPQIvnvpKbbxyXQP1iU4kWM=
github.com/bytedance/sonic/loader v0.2.0/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY=
github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4=
github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/certifi/gocertifi v0.0.0-20190105021004-abcd57078448/go.mod h1:GJKEexRPVJrBSOjoqN5VNOIKJ5Q3RViH6eu3puDRwx4=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/eino v0.3.16 h1:ASN8zISyoEdjEsPnIw5GazSHtbNY97NDthQ2B69yiZw=
github.com/cloudwego/eino v0.3.16/go.mod h1:+kmJimGEcKuSI6OKhet7kBedkm1WUZS3H1QRazxgWUo=
github.com/cloudwego/eino-ext/components/model/openai v0.0.0-20250314110024-9e89ba18146c h1:04WQpGikdQv6fh5wzMYSQhO0SJraV8+xcb9VQ00+HX4=
github.com/cloudwego/eino-ext/components/model/openai v0.0.0-20250314110024-9e89ba18146c/go.mod h1:YGP4q3uspj5qhkv3CnvlEPSo0YGeWpvkkTUHHpLExas=
github.com/cloudwego/eino-ext/libs/acl/openai v0.0.0-20250305023926-469de0301955 h1:fgvkmTqAalDfjdy3b6Ur2mh/KEwB9L2uvqS4MFgTOqc=
github.com/cloudwego/eino-ext/libs/acl/openai v0.0.0-20250305023926-469de0301955/go.mod h1:6CThw1XQx/ASXNt31yuvp0X4Yp4GprknQuIvP9VKDpw=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/eino v0.3.26 h1:FdJJTCdNrc9xPcYkLZiEyr7AA+WgyCKCbY+VNDXIaCE=
github.com/cloudwego/eino v0.3.26/go.mod h1:wUjz990apdsaOraOXdh6CdhVXq8DJsOvLsVlxNTcNfY=
github.com/cloudwego/eino-ext/components/model/ark v0.1.6 h1:k17Z9VIRBL0/t7Ty1drGgY9tVOraM5xuO6gy7Qx7xus=
github.com/cloudwego/eino-ext/components/model/ark v0.1.6/go.mod h1:13kQjYGLMgla6xTbejlpqhuk3i5BPlNv5S+1pmknlOo=
github.com/cloudwego/eino-ext/components/model/openai v0.0.0-20250417123744-154d7ca4d3cd h1:XEI7RezzV/cnOnhc1YeBJi6a0UoM41JTph4AZZR7+D8=
github.com/cloudwego/eino-ext/components/model/openai v0.0.0-20250417123744-154d7ca4d3cd/go.mod h1:8gMakAGQUR+IaWTSD0cpcD4U5FYq5puZ73/QjXqs1oU=
github.com/cloudwego/eino-ext/components/tool/mcp v0.0.0-20250328102648-b47e7f1587fa h1:Jrmw8Q9g1WcE+x5t3o0TsEBM8RoMRURJI6P52I/ld74=
github.com/cloudwego/eino-ext/components/tool/mcp v0.0.0-20250328102648-b47e7f1587fa/go.mod h1:UzVdRk1E+TuDxjuSAdxt5dMeAc6XJGbhJscfvKGQC8Y=
github.com/cloudwego/eino-ext/libs/acl/openai v0.0.0-20250417123744-154d7ca4d3cd h1:CJkxSpN3+lhV/dye7ui8hoCHU8VV4TecQfca5c8hx9g=
github.com/cloudwego/eino-ext/libs/acl/openai v0.0.0-20250417123744-154d7ca4d3cd/go.mod h1:Ye0YAqpESCxMlnALNrjeNJjhS9q2PIdxVdJbtFeni8o=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w=
@@ -45,6 +53,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/elazarl/goproxy v0.0.0-20240726154733-8b0c20506380 h1:1NyRx2f4W4WBRyg0Kys0ZbaNmDDzZ2R/C7DTi+bbsJ0=
github.com/elazarl/goproxy v0.0.0-20240726154733-8b0c20506380/go.mod h1:thX175TtLTzLj3p7N/Q9IiKZ7NF+p72cvL91emV0hzo=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
@@ -69,16 +79,17 @@ github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3Bop
github.com/go-logr/logr v1.2.4 h1:g01GSCwiDw2xSZfjJ2/T9M+S6pFdcNtFYsp+Y43HYDQ=
github.com/go-logr/logr v1.2.4/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY=
github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
github.com/go-openapi/jsonpointer v0.21.1 h1:whnzv/pNXtK2FbX/W9yJfRmE2gsmkfahjMKB0fZvcic=
github.com/go-openapi/jsonpointer v0.21.1/go.mod h1:50I1STOfbY1ycR8jGz8DaMeLCdXiI6aDteEdRNNzpdk=
github.com/go-openapi/jsonreference v0.20.0 h1:MYlu0sBgChmCfJxxUKZ8g1cPWFOB37YSZqewK7OKeyA=
github.com/go-openapi/jsonreference v0.20.0/go.mod h1:Ag74Ico3lPc+zR+qjn4XBUmXymS4zJbYVCZmcgkasdo=
github.com/go-openapi/spec v0.20.7 h1:1Rlu/ZrOCCob0n+JKKJAWhNWMPW8bOZRg8FJaY+0SKI=
github.com/go-openapi/spec v0.20.7/go.mod h1:2OpW+JddWPrpXSCIX8eOx7lZ5iyuWj3RYR6VaaBKcWA=
github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
github.com/go-openapi/swag v0.22.3 h1:yMBqmnQ0gyZvEb/+KzuWZOXgllrXT4SADYbvDaXHv/g=
github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=
github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU=
github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
@@ -95,12 +106,27 @@ github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/btree v1.1.2 h1:xf4v41cLI2Z6FxbKm+8Bu+m8ifhj15JuZ9sa0jZCMUU=
github.com/google/btree v1.1.2/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
@@ -109,6 +135,7 @@ github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38 h1:yAJXTCF9TqKcTiHJAE
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/goph/emperror v0.17.2 h1:yLapQcmEsO0ipe9p5TaN22djm3OFV/TfM/fcYP0/J18=
@@ -154,11 +181,12 @@ github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0/go.mod h1:1NbS8ALrpOvjt0rHPNLyCIeMtbizbir8U//inJ+zuB8=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
@@ -170,10 +198,13 @@ github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjS
github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/maja42/goval v1.2.1 h1:fyEgzddqPgCZsKcFLk4C6SdCHyEaAHYvtZG4mGzQOHU=
github.com/maja42/goval v1.2.1/go.mod h1:42LU+BQXL/veE9jnTTUOSj38GRmOTSThYSXRVodI5J4=
github.com/mark3labs/mcp-go v0.22.0 h1:cCEBWi4Yy9Kio+OW1hWIyi4WLsSr+RBBK6FI5tj+b7I=
github.com/mark3labs/mcp-go v0.22.0/go.mod h1:rXqOudj/djTORU/ThxYx8fqEVj/5pvTuuebQ2RC7uk4=
github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
@@ -184,6 +215,8 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/meguminnnnnnnnn/go-openai v0.0.0-20250408071642-761325becfd6 h1:nmdXxiUX48DZ2ELC/jSYzyGUVgxVEF2QJRGhLJ933zA=
github.com/meguminnnnnnnnn/go-openai v0.0.0-20250408071642-761325becfd6/go.mod h1:kyz7fcXqXtccmRAIARn1Q+cKLNXJHC3AoqqJGeCqNI0=
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4=
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
github.com/miekg/dns v1.1.27/go.mod h1:KNUDUusw/aVsxyTYZM1oqvCicbwhgbNgztCETuNZ7xM=
@@ -212,35 +245,33 @@ github.com/onsi/ginkgo/v2 v2.9.5/go.mod h1:tvAoo1QUJwNEU2ITftXTpR7R1RbCzoZUOs3Ro
github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/onsi/gomega v1.27.6 h1:ENqfyGeS5AX/rlXDd/ETokDz93u0YufY1Pgxuy/PvWE=
github.com/onsi/gomega v1.27.6/go.mod h1:PIQNjfQwkP3aQAH7lf7j87O/5FiNr+ZR8+ipb+qQlhg=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/perimeterx/marshmallow v1.1.4 h1:pZLDH9RjlLGGorbXhcaQLhfuV0pFMNfPO55FuFkxqLw=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/perimeterx/marshmallow v1.1.4/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
github.com/pierrec/lz4 v2.6.1+incompatible h1:9UY3+iC23yxF0UfGaYrGplQ+79Rg+h/q9FV9ix19jjM=
github.com/pierrec/lz4 v2.6.1+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY=
github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4=
github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/quic-go/qtls-go1-20 v0.4.1 h1:D33340mCNDAIKBqXuAvexTNMUByrYmFYVfKfDN5nfFs=
github.com/quic-go/qtls-go1-20 v0.4.1/go.mod h1:X9Nh97ZL80Z+bX/gUXMbipO6OxdiDi58b/fMC9mAL+k=
github.com/quic-go/quic-go v0.40.1-0.20231203135336-87ef8ec48d55 h1:I4N3ZRnkZPbDN935Tg8QDf8fRpHp3bZ0U0/L42jBgNE=
github.com/quic-go/quic-go v0.40.1-0.20231203135336-87ef8ec48d55/go.mod h1:PeN7kuVJ4xZbxSv/4OX6S1USOX8MJvydwpTx31vx60c=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/rollbar/rollbar-go v1.0.2/go.mod h1:AcFs5f0I+c71bpHlXNNDbOWJiKwjFDtISeXco0L5PKQ=
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8=
github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sashabaranov/go-openai v1.32.5 h1:/eNVa8KzlE7mJdKPZDj6886MUzZQjoVHyn0sLvIt5qA=
github.com/sashabaranov/go-openai v1.32.5/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg=
github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww=
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
@@ -254,6 +285,8 @@ github.com/smartystreets/goconvey v1.8.1 h1:qGjIddxOk4grTu9JPOU31tVfq3cNdBlNa5sS
github.com/smartystreets/goconvey v1.8.1/go.mod h1:+/u4qLyY6x1jReYOp7GOM2FSt8aP9CzCZL03bI28W60=
github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8 h1:TG/diQgUe0pntT/2D9tmUCz4VNwm9MfrtPr0SU2qSX8=
github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8/go.mod h1:P5HUIBuIWKbyjl083/loAegFkfbFNx5i2qEP4CNbm7E=
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cobra v1.5.0 h1:X+jTBEBqF0bHN+9cSMgmfuvv2VHJ9ezmFNf9Y/XstYU=
github.com/spf13/cobra v1.5.0/go.mod h1:dWXEIy2H428czQCjInthrTRUg7yKbok+2Qi/yBIJoUM=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
@@ -266,14 +299,13 @@ github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tadglines/go-pkgs v0.0.0-20210623144937-b983b20f54f9 h1:aeN+ghOV0b2VCmKKO3gqnDQ8mLbpABZgRR2FVYx4ouI=
@@ -284,39 +316,56 @@ github.com/ugorji/go v1.2.7/go.mod h1:nF9osbDWLy6bDVv/Rtoh6QgnvNDpmCalQV5urGCCS6
github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/volcengine/volc-sdk-golang v1.0.23 h1:anOslb2Qp6ywnsbyq9jqR0ljuO63kg9PY+4OehIk5R8=
github.com/volcengine/volc-sdk-golang v1.0.23/go.mod h1:AfG/PZRUkHJ9inETvbjNifTDgut25Wbkm2QoYBTbvyU=
github.com/volcengine/volcengine-go-sdk v1.0.185 h1:MIH+YgdWZhO1fNg/vxLohl8ad7hlklaf46wpaTS1TN0=
github.com/volcengine/volcengine-go-sdk v1.0.185/go.mod h1:gfEDc1s7SYaGoY+WH2dRrS3qiuDJMkwqyfXWCa7+7oA=
github.com/x-cray/logrus-prefixed-formatter v0.5.2 h1:00txxvfBM9muc0jiLIEAkAcIMJzfthRT6usrui8uGmg=
github.com/x-cray/logrus-prefixed-formatter v0.5.2/go.mod h1:2duySbKsL6M18s5GU7VPsoEPHyzalCE06qoARUCeBBE=
github.com/yargevad/filepathx v1.0.0 h1:SYcT+N3tYGi+NvazubCNlvgIPbzAk7i7y2dwg3I5FYc=
github.com/yargevad/filepathx v1.0.0/go.mod h1:BprfX/gpYNJHJfc35GjRRpVcwWXS89gGulUIU5tK3tA=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
go.mozilla.org/pkcs7 v0.0.0-20210826202110-33d05740a352 h1:CCriYyAfq1Br1aIYettdHZTy8mBTIPo7We18TuO/bak=
go.mozilla.org/pkcs7 v0.0.0-20210826202110-33d05740a352/go.mod h1:SNgMg+EgDFwmvSmLRTNKC5fegJjB7v23qTQ0XLGUNHk=
go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU=
go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc=
golang.org/x/arch v0.11.0 h1:KXV8WWKCXm6tRpLirl2szsO5j/oOODwZf4hATmGVNs4=
golang.org/x/arch v0.11.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/arch v0.16.0 h1:foMtLTdyOmIniqWCHjY6+JxuC54XP1fDwx4N0ASyW+U=
golang.org/x/arch v0.16.0/go.mod h1:JmwW7aLIoRUKgaTzhkiEFxvcEiQGyOg9BMonBJUS7EE=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20220331220935-ae2d96664a29/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/exp v0.0.0-20230725093048-515e97ebf090 h1:Di6/M8l0O2lCLc6VVRWhgCiApHV8MnQurBnFSHsQtNY=
golang.org/x/exp v0.0.0-20230725093048-515e97ebf090/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc=
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ=
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -333,38 +382,59 @@ golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20191216052735-49a3e744a425/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.32.0 h1:Q7N1vhpkQv7ybVzLFtTjvQya2ewbwNDZzUgfXGqtMWU=
golang.org/x/tools v0.32.0/go.mod h1:ZxrU41P/wAbZD8EDa6dDCa6XfpkhJ7HFMjHJXfBDu8s=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240116215550-a9fa1716bcac h1:nUQEQmH/csSvFECKYRv6HWEyypysidKl2I6Qpsglq/0=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240116215550-a9fa1716bcac/go.mod h1:daQN87bsDqDoe316QbbvX60nMoJQa4r6Ds0ZuoAe5yA=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.57.0 h1:kfzNeI/klCGD2YPMUlaGNT3pxvYfga7smW3Vth8Zsiw=
google.golang.org/grpc v1.57.0/go.mod h1:Sd+9RMTACXwmub0zcNY2c4arhtrbBYD1AUHI/dt16Mo=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
@@ -383,6 +453,8 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gvisor.dev/gvisor v0.0.0-20240405191320-0878b34101b5 h1:DOUDfNS+CFMM46k18FRF5k/0yz5NhZYMiUQxf4xglIU=
gvisor.dev/gvisor v0.0.0-20240405191320-0878b34101b5/go.mod h1:NQHVAzMwvZ+Qe3ElSiHmq9RUm1MdNHpUZ52fiEqvn+0=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM=
howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=

View File

@@ -30,12 +30,12 @@ type Config struct {
}
var (
globalConfig *Config
once sync.Once
globalConfig *Config
getConfigOnce sync.Once
)
func GetConfig() *Config {
once.Do(func() {
getConfigOnce.Do(func() {
cfg := &Config{
StartTime: time.Now(),
}

View File

@@ -5,15 +5,18 @@ import (
"os"
"path/filepath"
"strconv"
"sync"
"github.com/joho/godotenv"
"github.com/rs/zerolog/log"
)
var loadEnvOnce sync.Once
// LoadEnv loads environment variables from .env file
// it will search for .env file from current working directory upward recursively
func LoadEnv() (err error) {
once.Do(func() {
loadEnvOnce.Do(func() {
// get current working directory
var cwd string
cwd, err = os.Getwd()

92
internal/mcp/config.go Normal file
View File

@@ -0,0 +1,92 @@
package mcp
import (
"encoding/json"
"fmt"
"os"
"time"
"github.com/rs/zerolog/log"
)
// MCPSettings represents the main configuration structure
type MCPSettings struct {
MCPServers map[string]ServerConfig `json:"mcpServers"`
}
// ServerConfig represents configuration for a single MCP server
type ServerConfig struct {
TransportType string `json:"transportType,omitempty"` // "sse" or "stdio"
AutoApprove []string `json:"autoApprove,omitempty"`
Disabled bool `json:"disabled,omitempty"`
Timeout time.Duration `json:"timeout,omitempty"`
// SSE specific config
URL string `json:"url,omitempty"`
// Stdio specific config
Command string `json:"command"`
Args []string `json:"args"`
Env map[string]string `json:"env,omitempty"`
}
const (
DefaultMCPTimeoutSeconds = 30
MinMCPTimeoutSeconds = 5
)
// GetTimeoutDuration converts timeout seconds to time.Duration
func (c *ServerConfig) GetTimeoutDuration() time.Duration {
if c.Timeout == 0 {
return time.Duration(DefaultMCPTimeoutSeconds) * time.Second
}
return c.Timeout
}
// LoadSettings loads MCP settings from the config file
func LoadSettings(path string) (*MCPSettings, error) {
log.Info().Str("path", path).Msg("load MCP settings")
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("failed to read settings file: %w", err)
}
var settings MCPSettings
if err := json.Unmarshal(data, &settings); err != nil {
return nil, fmt.Errorf("failed to parse settings: %w", err)
}
if err := validateSettings(&settings); err != nil {
return nil, fmt.Errorf("invalid settings: %w", err)
}
return &settings, nil
}
// validateSettings validates the MCP settings
func validateSettings(settings *MCPSettings) error {
if settings == nil {
return fmt.Errorf("settings cannot be nil")
}
for name, server := range settings.MCPServers {
if server.Timeout > 0 && server.Timeout < time.Duration(MinMCPTimeoutSeconds)*time.Second {
return fmt.Errorf("server %s: timeout must be at least %d seconds", name, MinMCPTimeoutSeconds)
}
switch server.TransportType {
case "sse":
if server.URL == "" {
return fmt.Errorf("server %s: URL is required for SSE transport", name)
}
case "stdio", "":
if server.Command == "" {
return fmt.Errorf("server %s: command is required for stdio transport", name)
}
default:
return fmt.Errorf("server %s: unsupported transport type: %s", name, server.TransportType)
}
}
return nil
}

View File

@@ -0,0 +1,31 @@
package mcp
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestLoadSettings(t *testing.T) {
// Load settings from test.mcp.json
settings, err := LoadSettings("testdata/test.mcp.json")
if err != nil {
t.Fatalf("Failed to load settings: %v", err)
}
// Verify settings are loaded correctly
assert.NotNil(t, settings)
assert.Contains(t, settings.MCPServers, "filesystem")
assert.Contains(t, settings.MCPServers, "weather")
// Verify specific server configurations
filesystemConfig := settings.MCPServers["filesystem"]
assert.Equal(t, "npx", filesystemConfig.Command)
assert.Equal(t, []string{"-y", "@modelcontextprotocol/server-filesystem", "/tmp"}, filesystemConfig.Args)
weatherConfig := settings.MCPServers["weather"]
assert.Equal(t, "uv", weatherConfig.Command)
assert.Equal(t, []string{"--directory", "/Users/debugtalk/MyProjects/HttpRunner-dev/httprunner/internal/mcp/testdata", "run", "demo_weather.py"}, weatherConfig.Args)
assert.Equal(t, []string{"get_forecast"}, weatherConfig.AutoApprove)
assert.Equal(t, map[string]string{"ABC": "123"}, weatherConfig.Env)
}

430
internal/mcp/hub.go Normal file
View File

@@ -0,0 +1,430 @@
package mcp
import (
"bufio"
"context"
"fmt"
"os"
"strings"
"sync"
"time"
"github.com/bytedance/sonic"
mcpp "github.com/cloudwego/eino-ext/components/tool/mcp"
"github.com/cloudwego/eino/components/tool"
"github.com/httprunner/httprunner/v5/internal/version"
"github.com/mark3labs/mcp-go/client"
"github.com/mark3labs/mcp-go/mcp"
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
)
type MCPTools struct {
Name string
Tools []mcp.Tool
Err error
}
type MCPHub struct {
mu sync.RWMutex
connections map[string]*Connection
config *MCPSettings
}
type Connection struct {
Client client.MCPClient
Config ServerConfig
}
func NewMCPHub(configPath string) (*MCPHub, error) {
settings, err := LoadSettings(configPath)
if err != nil {
return nil, err
}
return &MCPHub{
connections: make(map[string]*Connection),
config: settings,
}, nil
}
// InitServers initializes all enabled MCP servers
func (h *MCPHub) InitServers(ctx context.Context) error {
for name, config := range h.config.MCPServers {
if config.Disabled {
continue
}
if err := h.connectToServer(ctx, name, config); err != nil {
return fmt.Errorf("failed to connect to server %s: %w", name, err)
}
}
return nil
}
// GetClient returns the client for the specified server
func (h *MCPHub) GetClient(serverName string) (client.MCPClient, error) {
h.mu.RLock()
defer h.mu.RUnlock()
conn, exists := h.connections[serverName]
if !exists {
return nil, fmt.Errorf("no connection found for server %s", serverName)
}
if conn.Config.Disabled {
return nil, fmt.Errorf("server %s is disabled", serverName)
}
return conn.Client, nil
}
// connectToServer establishes connection to a single MCP server
func (h *MCPHub) connectToServer(ctx context.Context, serverName string, config ServerConfig) error {
h.mu.Lock()
defer h.mu.Unlock()
log.Debug().Str("server", serverName).Msg("connecting to MCP server")
// Close existing connection if any
if existing, exists := h.connections[serverName]; exists {
if err := existing.Client.Close(); err != nil {
return fmt.Errorf("failed to close existing connection: %w", err)
}
delete(h.connections, serverName)
}
var mcpClient *client.Client
var err error
// create client
switch config.TransportType {
case "sse":
mcpClient, err = client.NewSSEMCPClient(config.URL)
case "stdio", "": // default to stdio
var env []string
for k, v := range config.Env {
env = append(env, fmt.Sprintf("%s=%s", k, v))
}
mcpClient, err = client.NewStdioMCPClient(config.Command,
env, config.Args...)
default:
return fmt.Errorf("unsupported transport type: %s", config.TransportType)
}
if err != nil {
return fmt.Errorf("failed to create client: %w", err)
}
// print MCP Server logs
stderr := client.GetStderr(mcpClient)
go func() {
scanner := bufio.NewScanner(stderr)
for scanner.Scan() {
fmt.Fprintf(os.Stderr, "MCP Server %s: %s\n",
serverName, scanner.Text())
}
}()
// prepare client init request
initRequest := mcp.InitializeRequest{}
initRequest.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION
initRequest.Params.Capabilities = mcp.ClientCapabilities{}
initRequest.Params.ClientInfo = mcp.Implementation{
Name: "HttpRunner",
Version: version.VERSION,
}
// initialize client
_, err = mcpClient.Initialize(ctx, initRequest)
if err != nil {
mcpClient.Close()
return errors.Wrapf(err, "initialize MCP client for %s failed", serverName)
}
log.Info().Str("server", serverName).Msg("connected to MCP server")
h.connections[serverName] = &Connection{
Client: mcpClient,
Config: config,
}
return nil
}
// GetTools fetches available tools from all connected MCP servers
func (h *MCPHub) GetTools(ctx context.Context) map[string]MCPTools {
h.mu.RLock()
defer h.mu.RUnlock()
results := make(map[string]MCPTools)
for serverName, conn := range h.connections {
if conn.Config.Disabled {
continue
}
// get tools from MCP server tools
listResults, err := conn.Client.ListTools(ctx, mcp.ListToolsRequest{})
if err != nil {
results[serverName] = MCPTools{
Name: serverName,
Tools: nil,
Err: fmt.Errorf("failed to get tools: %w", err),
}
continue
}
results[serverName] = MCPTools{
Name: serverName,
Tools: listResults.Tools,
Err: nil,
}
}
return results
}
func (h *MCPHub) GetTool(ctx context.Context, serverName, toolName string) (*mcp.Tool, error) {
h.mu.RLock()
defer h.mu.RUnlock()
// filter MCP server by serverName
mcpTools, exists := h.GetTools(ctx)[serverName]
if !exists {
return nil, fmt.Errorf("no connection found for server %s", serverName)
} else if mcpTools.Err != nil {
return nil, mcpTools.Err
}
// filter tool by toolName
for _, tool := range mcpTools.Tools {
if tool.Name == toolName {
return &tool, nil
}
}
return nil, fmt.Errorf("tool %s not found", toolName)
}
// InvokeTool calls a tool with the given arguments
func (h *MCPHub) InvokeTool(ctx context.Context,
serverName, toolName string, arguments map[string]interface{},
) (*mcp.CallToolResult, error) {
log.Info().Str("tool", toolName).Interface("args", arguments).
Str("server", serverName).Msg("invoke tool")
conn, err := h.GetClient(serverName)
if err != nil {
return nil, errors.Wrapf(err,
"get mcp client for server %s failed", serverName)
}
mcpTool, err := h.GetTool(ctx, serverName, toolName)
if err != nil {
return nil, errors.Wrapf(err,
"get mcp tool %s/%s failed", serverName, toolName)
}
req := mcp.CallToolRequest{}
req.Params.Name = mcpTool.Name
req.Params.Arguments = arguments
callToolResult, err := conn.CallTool(ctx, req)
if err != nil {
return nil, errors.Wrapf(err,
"call tool %s/%s failed", serverName, toolName)
}
return callToolResult, nil
}
// GetEinoTool returns an eino tool from the MCP server
func (h *MCPHub) GetEinoTool(ctx context.Context, serverName, toolName string) (tool.BaseTool, error) {
h.mu.RLock()
defer h.mu.RUnlock()
// filter MCP server by serverName
conn, exists := h.connections[serverName]
if !exists {
return nil, fmt.Errorf("no connection found for server %s", serverName)
}
if conn.Config.Disabled {
return nil, fmt.Errorf("server %s is disabled", serverName)
}
// get tools from MCP server and convert to eino tools
tools, err := mcpp.GetTools(ctx, &mcpp.Config{
Cli: conn.Client,
ToolNameList: []string{toolName},
})
if err != nil || len(tools) == 0 {
log.Error().Err(err).
Str("server", serverName).Str("tool", toolName).
Msg("get MCP tool failed")
return nil, err
}
return tools[0], nil
}
// CloseServers closes all connected MCP servers
func (h *MCPHub) CloseServers() error {
h.mu.Lock()
defer h.mu.Unlock()
log.Info().Msg("Shutting down MCP servers...")
for name, client := range h.connections {
if err := client.Client.Close(); err != nil {
log.Error().Str("name", name).Err(err).Msg("Failed to close server")
} else {
delete(h.connections, name)
log.Info().Str("name", name).Msg("Server closed")
}
}
return nil
}
// MCPToolRecord represents a single tool record in the database
// Each record contains detailed information about a tool and its server
type MCPToolRecord struct {
ToolID string `json:"tool_id"` // Unique identifier for the tool record
ServerName string `json:"mcp_server"` // Name of the MCP server
ToolName string `json:"tool_name"` // Name of the tool
Description string `json:"description"` // Tool description
Parameters string `json:"parameters"` // Tool input parameters in JSON format
Returns string `json:"returns"` // Tool return value format in JSON format
CreatedAt time.Time `json:"created_at"` // Record creation time
LastUpdatedAt time.Time `json:"last_updated_at"` // Record last update time
}
// DocStringInfo contains the parsed information from a Python docstring
type DocStringInfo struct {
Description string
Parameters map[string]string
Returns map[string]string
}
// extractDocStringInfo extracts information from a Python docstring
// Example input:
// """Get weather alerts for a US state.
//
// Args:
// state: Two-letter US state code (e.g. CA, NY)
//
// Returns:
// alerts: List of active weather alerts for the specified state
// error: Error message if the request fails
// """
func extractDocStringInfo(docstring string) DocStringInfo {
info := DocStringInfo{
Parameters: make(map[string]string),
Returns: make(map[string]string),
}
// Find the Args and Returns sections
argsIndex := strings.Index(docstring, "Args:")
returnsIndex := strings.Index(docstring, "Returns:")
// Extract description (everything before Args)
if argsIndex != -1 {
info.Description = strings.TrimSpace(docstring[:argsIndex])
} else if returnsIndex != -1 {
info.Description = strings.TrimSpace(docstring[:returnsIndex])
} else {
info.Description = strings.TrimSpace(docstring)
return info
}
// Helper function to extract key-value pairs from a section
extractSection := func(content string) map[string]string {
result := make(map[string]string)
lines := strings.Split(content, "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
parts := strings.SplitN(line, ":", 2)
if len(parts) != 2 {
continue
}
key := strings.TrimSpace(parts[0])
value := strings.TrimSpace(parts[1])
if key != "" && value != "" {
result[key] = value
}
}
return result
}
// Extract Args section
if argsIndex != -1 {
endIndex := returnsIndex
if endIndex == -1 {
endIndex = len(docstring)
}
argsContent := docstring[argsIndex+len("Args:") : endIndex]
info.Parameters = extractSection(argsContent)
}
// Extract Returns section
if returnsIndex != -1 {
returnsContent := docstring[returnsIndex+len("Returns:"):]
info.Returns = extractSection(returnsContent)
}
return info
}
// ConvertToolsToRecords converts map[string]MCPTools to a list of database records
func ConvertToolsToRecords(toolsMap map[string]MCPTools) []MCPToolRecord {
var records []MCPToolRecord
now := time.Now()
for serverName, mcpTools := range toolsMap {
if mcpTools.Err != nil {
log.Error().Str("server", serverName).Err(mcpTools.Err).Msg("skip tools conversion due to error")
continue
}
for _, tool := range mcpTools.Tools {
// Generate unique ID by combining server name and tool name
id := fmt.Sprintf("%s_%s", serverName, tool.Name)
// Extract docstring information
info := extractDocStringInfo(tool.Description)
// Convert parameters and returns to JSON
paramsJSON, err := sonic.MarshalString(info.Parameters)
if err != nil {
log.Warn().Interface("params", info.Parameters).Err(err).Msg("failed to marshal parameters to JSON")
paramsJSON = "{}"
}
returnsJSON, err := sonic.MarshalString(info.Returns)
if err != nil {
log.Warn().Interface("returns", info.Returns).Err(err).Msg("failed to marshal returns to JSON")
returnsJSON = "{}"
}
record := MCPToolRecord{
ToolID: id,
ServerName: serverName,
ToolName: tool.Name,
Description: info.Description,
Parameters: paramsJSON,
Returns: returnsJSON,
CreatedAt: now,
LastUpdatedAt: now,
}
records = append(records, record)
}
}
return records
}

286
internal/mcp/hub_test.go Normal file
View File

@@ -0,0 +1,286 @@
package mcp
import (
"context"
"encoding/json"
"os"
"testing"
"time"
"github.com/cloudwego/eino/components/tool"
"github.com/mark3labs/mcp-go/mcp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGetTools(t *testing.T) {
hub, err := NewMCPHub("./testdata/test.mcp.json")
require.NoError(t, err)
ctx := context.Background()
err = hub.InitServers(ctx)
require.NoError(t, err)
tools := hub.GetTools(ctx)
assert.NoError(t, err)
assert.Equal(t, 2, len(tools))
}
func TestCallTool(t *testing.T) {
hub, err := NewMCPHub("./testdata/test.mcp.json")
require.NoError(t, err)
ctx := context.Background()
err = hub.InitServers(ctx)
require.NoError(t, err)
result, err := hub.InvokeTool(ctx, "weather", "get_alerts",
map[string]interface{}{"state": "CA"},
)
require.NoError(t, err)
t.Logf("Result: %v", result)
}
func TestCallEinoTool(t *testing.T) {
hub, err := NewMCPHub("./testdata/test.mcp.json")
require.NoError(t, err)
ctx := context.Background()
err = hub.InitServers(ctx)
require.NoError(t, err)
einoTool, err := hub.GetEinoTool(ctx, "weather", "get_alerts")
require.NoError(t, err)
t.Logf("Tool: %v", einoTool)
tool := einoTool.(tool.InvokableTool)
result, err := tool.InvokableRun(ctx, `{"state": "CA"}`)
require.NoError(t, err)
t.Logf("Result: %v", result)
}
func TestConvertToolsToRecordsFromFile(t *testing.T) {
hub, err := NewMCPHub("./testdata/test.mcp.json")
require.NoError(t, err)
ctx := context.Background()
err = hub.InitServers(ctx)
require.NoError(t, err)
tools := hub.GetTools(ctx)
require.NoError(t, err)
records := ConvertToolsToRecords(tools)
// Convert records to JSON
recordsJSON, err := json.Marshal(records)
require.NoError(t, err)
// Write JSON to file
err = os.WriteFile("./tools_records.json", recordsJSON, 0o644)
require.NoError(t, err)
t.Logf("Tools records written to ./tools_records.json")
}
func TestExtractDocStringInfo(t *testing.T) {
tests := []struct {
name string
docstring string
want DocStringInfo
}{
{
name: "complete docstring with args and returns",
docstring: `Get weather alerts for a US state.
Args:
state: Two-letter US state code (e.g. CA, NY)
Returns:
alerts: List of active weather alerts for the specified state
error: Error message if the request fails
`,
want: DocStringInfo{
Description: "Get weather alerts for a US state.",
Parameters: map[string]string{
"state": "Two-letter US state code (e.g. CA, NY)",
},
Returns: map[string]string{
"alerts": "List of active weather alerts for the specified state",
"error": "Error message if the request fails",
},
},
},
{
name: "docstring with only args",
docstring: `Do screen swipe action.
Args:
direction: swipe direction (up, down)
`,
want: DocStringInfo{
Description: "Do screen swipe action.",
Parameters: map[string]string{
"direction": "swipe direction (up, down)",
},
Returns: map[string]string{},
},
},
{
name: "docstring with only description",
docstring: "Simple tool with no parameters.",
want: DocStringInfo{
Description: "Simple tool with no parameters.",
Parameters: map[string]string{},
Returns: map[string]string{},
},
},
{
name: "docstring with multiple parameters",
docstring: `Perform complex operation.
Args:
param1: first parameter description
param2: second parameter description
param3: third parameter description
Returns:
result: operation result
`,
want: DocStringInfo{
Description: "Perform complex operation.",
Parameters: map[string]string{
"param1": "first parameter description",
"param2": "second parameter description",
"param3": "third parameter description",
},
Returns: map[string]string{
"result": "operation result",
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := extractDocStringInfo(tt.docstring)
assert.Equal(t, tt.want.Description, got.Description)
assert.Equal(t, tt.want.Parameters, got.Parameters)
assert.Equal(t, tt.want.Returns, got.Returns)
})
}
}
func TestConvertToolsToRecords(t *testing.T) {
tests := []struct {
name string
toolsMap map[string]MCPTools
want []MCPToolRecord
}{
{
name: "convert weather tool",
toolsMap: map[string]MCPTools{
"weather": {
Name: "weather",
Tools: []mcp.Tool{
{
Name: "get_alerts",
Description: `Get weather alerts for a US state.
Args:
state: Two-letter US state code (e.g. CA, NY)
Returns:
alerts: List of active weather alerts for the specified state
error: Error message if the request fails
`,
},
},
},
},
want: []MCPToolRecord{
{
ToolID: "weather_get_alerts",
ServerName: "weather",
ToolName: "get_alerts",
Description: "Get weather alerts for a US state.",
Parameters: `{"state":"Two-letter US state code (e.g. CA, NY)"}`,
Returns: `{"alerts":"List of active weather alerts for the specified state","error":"Error message if the request fails"}`,
},
},
},
{
name: "convert multiple tools",
toolsMap: map[string]MCPTools{
"ui": {
Name: "ui",
Tools: []mcp.Tool{
{
Name: "swipe",
Description: `Do screen swipe action.
Args:
direction: swipe direction (up, down)
`,
},
{
Name: "tap",
Description: "Tap on screen at specified position.",
},
},
},
},
want: []MCPToolRecord{
{
ToolID: "ui_swipe",
ServerName: "ui",
ToolName: "swipe",
Description: "Do screen swipe action.",
Parameters: `{"direction":"swipe direction (up, down)"}`,
Returns: "{}",
},
{
ToolID: "ui_tap",
ServerName: "ui",
ToolName: "tap",
Description: "Tap on screen at specified position.",
Parameters: "{}",
Returns: "{}",
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ConvertToolsToRecords(tt.toolsMap)
// Compare each record
require.Equal(t, len(tt.want), len(got))
for i := range tt.want {
assert.Equal(t, tt.want[i].ToolID, got[i].ToolID)
assert.Equal(t, tt.want[i].ServerName, got[i].ServerName)
assert.Equal(t, tt.want[i].ToolName, got[i].ToolName)
assert.Equal(t, tt.want[i].Description, got[i].Description)
// Compare JSON content (ignoring whitespace differences)
var wantParams, gotParams, wantReturns, gotReturns map[string]string
require.NoError(t, json.Unmarshal([]byte(tt.want[i].Parameters), &wantParams))
require.NoError(t, json.Unmarshal([]byte(got[i].Parameters), &gotParams))
require.NoError(t, json.Unmarshal([]byte(tt.want[i].Returns), &wantReturns))
require.NoError(t, json.Unmarshal([]byte(got[i].Returns), &gotReturns))
assert.Equal(t, wantParams, gotParams)
assert.Equal(t, wantReturns, gotReturns)
// Verify timestamps are recent (within last 5 seconds)
now := time.Now()
assert.True(t, now.Sub(got[i].CreatedAt) < 5*time.Second, "CreatedAt should be recent")
assert.True(t, now.Sub(got[i].LastUpdatedAt) < 5*time.Second, "LastUpdatedAt should be recent")
// CreatedAt and LastUpdatedAt should be the same
assert.Equal(t, got[i].CreatedAt, got[i].LastUpdatedAt)
}
})
}
}

94
internal/mcp/testdata/demo_weather.py vendored Normal file
View File

@@ -0,0 +1,94 @@
from typing import Any
import httpx
from mcp.server.fastmcp import FastMCP
# Initialize FastMCP server
mcp = FastMCP("weather")
# Constants
NWS_API_BASE = "https://api.weather.gov"
USER_AGENT = "weather-app/1.0"
async def make_nws_request(url: str) -> dict[str, Any] | None:
"""Make a request to the NWS API with proper error handling."""
headers = {
"User-Agent": USER_AGENT,
"Accept": "application/geo+json"
}
async with httpx.AsyncClient() as client:
try:
response = await client.get(url, headers=headers, timeout=30.0)
response.raise_for_status()
return response.json()
except Exception:
return None
def format_alert(feature: dict) -> str:
"""Format an alert feature into a readable string."""
props = feature["properties"]
return f"""
Event: {props.get('event', 'Unknown')}
Area: {props.get('areaDesc', 'Unknown')}
Severity: {props.get('severity', 'Unknown')}
Description: {props.get('description', 'No description available')}
Instructions: {props.get('instruction', 'No specific instructions provided')}
"""
@mcp.tool()
async def get_alerts(state: str) -> str:
"""Get weather alerts for a US state.
Args:
state: Two-letter US state code (e.g. CA, NY)
"""
url = f"{NWS_API_BASE}/alerts/active/area/{state}"
data = await make_nws_request(url)
if not data or "features" not in data:
return "Unable to fetch alerts or no alerts found."
if not data["features"]:
return "No active alerts for this state."
alerts = [format_alert(feature) for feature in data["features"]]
return "\n---\n".join(alerts)
@mcp.tool()
async def get_forecast(latitude: float, longitude: float) -> str:
"""Get weather forecast for a location.
Args:
latitude: Latitude of the location
longitude: Longitude of the location
"""
# First get the forecast grid endpoint
points_url = f"{NWS_API_BASE}/points/{latitude},{longitude}"
points_data = await make_nws_request(points_url)
if not points_data:
return "Unable to fetch forecast data for this location."
# Get the forecast URL from the points response
forecast_url = points_data["properties"]["forecast"]
forecast_data = await make_nws_request(forecast_url)
if not forecast_data:
return "Unable to fetch detailed forecast."
# Format the periods into a readable forecast
periods = forecast_data["properties"]["periods"]
forecasts = []
for period in periods[:5]: # Only show next 5 periods
forecast = f"""
{period['name']}:
Temperature: {period['temperature']}°{period['temperatureUnit']}
Wind: {period['windSpeed']} {period['windDirection']}
Forecast: {period['detailedForecast']}
"""
forecasts.append(forecast)
return "\n---\n".join(forecasts)
if __name__ == "__main__":
# Initialize and run the server
mcp.run(transport='stdio')

27
internal/mcp/testdata/test.mcp.json vendored Normal file
View File

@@ -0,0 +1,27 @@
{
"mcpServers": {
"filesystem": {
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-filesystem",
"/tmp"
]
},
"weather": {
"args": [
"--directory",
"/Users/debugtalk/MyProjects/HttpRunner-dev/httprunner/internal/mcp/testdata",
"run",
"demo_weather.py"
],
"autoApprove": [
"get_forecast"
],
"command": "uv",
"env": {
"ABC": "123"
}
}
}
}

View File

@@ -1 +1 @@
v5.0.0-beta-2504080023
v5.0.0-beta-2504271150

View File

@@ -419,14 +419,14 @@ func (r *CaseRunner) parseConfig() (parsedConfig *TConfig, err error) {
r.parametersIterator = parametersIterator
// parse android devices config
for _, androidDevice := range parsedConfig.Android {
err := r.parseDeviceConfig(androidDevice, parsedConfig.Variables)
for _, androidDeviceOptions := range parsedConfig.Android {
err := r.parseDeviceConfig(androidDeviceOptions, parsedConfig.Variables)
if err != nil {
return nil, errors.Wrap(code.InvalidCaseError,
fmt.Sprintf("parse android config failed: %v", err))
}
device, err := uixt.NewAndroidDevice(androidDevice.Options.Options()...)
device, err := uixt.NewAndroidDevice(androidDeviceOptions.Options()...)
if err != nil {
return nil, errors.Wrap(err, "init android device failed")
}
@@ -436,17 +436,17 @@ func (r *CaseRunner) parseConfig() (parsedConfig *TConfig, err error) {
}
driverExt := uixt.NewXTDriver(driver, ai.WithCVService(ai.CVServiceTypeVEDEM))
r.uixtDrivers[androidDevice.Options.SerialNumber] = driverExt
r.uixtDrivers[androidDeviceOptions.SerialNumber] = driverExt
}
// parse iOS devices config
for _, iosDevice := range parsedConfig.IOS {
err := r.parseDeviceConfig(iosDevice, parsedConfig.Variables)
for _, iosDeviceOptions := range parsedConfig.IOS {
err := r.parseDeviceConfig(iosDeviceOptions, parsedConfig.Variables)
if err != nil {
return nil, errors.Wrap(code.InvalidCaseError,
fmt.Sprintf("parse ios config failed: %v", err))
}
device, err := uixt.NewIOSDevice(iosDevice.Options.Options()...)
device, err := uixt.NewIOSDevice(iosDeviceOptions.Options()...)
if err != nil {
return nil, errors.Wrap(err, "init ios device failed")
}
@@ -456,17 +456,17 @@ func (r *CaseRunner) parseConfig() (parsedConfig *TConfig, err error) {
}
driverExt := uixt.NewXTDriver(driver, ai.WithCVService(ai.CVServiceTypeVEDEM))
r.uixtDrivers[iosDevice.Options.UDID] = driverExt
r.uixtDrivers[iosDeviceOptions.UDID] = driverExt
}
// parse harmony devices config
for _, harmonyDevice := range parsedConfig.Harmony {
err := r.parseDeviceConfig(harmonyDevice, parsedConfig.Variables)
for _, harmonyDeviceOptions := range parsedConfig.Harmony {
err := r.parseDeviceConfig(harmonyDeviceOptions, parsedConfig.Variables)
if err != nil {
return nil, errors.Wrap(code.InvalidCaseError,
fmt.Sprintf("parse harmony config failed: %v", err))
}
device, err := uixt.NewHarmonyDevice(harmonyDevice.Options.Options()...)
device, err := uixt.NewHarmonyDevice(harmonyDeviceOptions.Options()...)
if err != nil {
return nil, errors.Wrap(err, "init harmony device failed")
}
@@ -476,7 +476,7 @@ func (r *CaseRunner) parseConfig() (parsedConfig *TConfig, err error) {
}
driverExt := uixt.NewXTDriver(driver, ai.WithCVService(ai.CVServiceTypeVEDEM))
r.uixtDrivers[harmonyDevice.Options.ConnectKey] = driverExt
r.uixtDrivers[harmonyDeviceOptions.ConnectKey] = driverExt
}
return parsedConfig, nil

View File

@@ -1,9 +1,11 @@
package server
import (
"context"
"fmt"
"time"
"github.com/httprunner/httprunner/v5/internal/mcp"
"github.com/httprunner/httprunner/v5/uixt"
"github.com/gin-gonic/gin"
@@ -20,6 +22,24 @@ func NewRouter() *Router {
type Router struct {
*gin.Engine
mcpHub *mcp.MCPHub
}
func (r *Router) InitMCPHub(configPath string) error {
mcpHub, err := mcp.NewMCPHub(configPath)
if err != nil {
log.Error().Err(err).Msg("init MCP hub failed")
return err
}
err = mcpHub.InitServers(context.Background())
if err != nil {
log.Error().Err(err).Msg("init MCP servers failed")
return err
}
r.mcpHub = mcpHub
return nil
}
func (r *Router) Init() {
@@ -32,6 +52,9 @@ func (r *Router) Init() {
apiV1PlatformSerial := r.Group("/api/v1").Group("/:platform").Group("/:serial")
// tool operations
apiV1PlatformSerial.POST("/tool/invoke", r.invokeToolHandler)
// UI operations
apiV1PlatformSerial.POST("/ui/tap", r.tapHandler)
apiV1PlatformSerial.POST("/ui/right_click", r.rightClickHandler)

38
server/tool.go Normal file
View File

@@ -0,0 +1,38 @@
package server
import (
"errors"
"github.com/gin-gonic/gin"
)
type ToolRequest struct {
ServerName string `json:"mcp_server"`
ToolName string `json:"tool_name"`
Args map[string]interface{} `json:"args"`
}
func (r *Router) invokeToolHandler(c *gin.Context) {
if r.mcpHub == nil {
RenderError(c, errors.New("mcp hub not initialized"))
return
}
var req ToolRequest
if err := c.ShouldBindJSON(&req); err != nil {
RenderError(c, err)
return
}
// add platform and serial to tool invoke args
req.Args["platform"] = c.Param("platform")
req.Args["serial"] = c.Param("serial")
result, err := r.mcpHub.InvokeTool(c.Request.Context(),
req.ServerName, req.ToolName, req.Args)
if err != nil {
RenderError(c, err)
return
}
RenderSuccess(c, result)
}

View File

@@ -71,3 +71,43 @@ func TestTapHandler(t *testing.T) {
})
}
}
func TestInvokeToolHandler(t *testing.T) {
router := NewRouter()
router.InitMCPHub("../internal/mcp/testdata/test.mcp.json")
tests := []struct {
name string
path string
toolReq ToolRequest
wantStatus int
}{
{
name: "invoke tool",
path: "/api/v1/tool/invoke",
toolReq: ToolRequest{
ServerName: "weather",
ToolName: "get_alerts",
Args: map[string]interface{}{"state": "CA"},
},
wantStatus: http.StatusOK,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
reqBody, _ := json.Marshal(tt.toolReq)
req := httptest.NewRequest(http.MethodPost, tt.path, bytes.NewBuffer(reqBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, tt.wantStatus, w.Code)
var got HttpResponse
err := json.Unmarshal(w.Body.Bytes(), &got)
assert.NoError(t, err)
})
}
}

View File

@@ -15,8 +15,8 @@ import (
)
type MobileUI struct {
OSType string `json:"os_type,omitempty" yaml:"os_type,omitempty"` // ios or harmony or android
Serial string `json:"serial,omitempty" yaml:"serial,omitempty"` // android serial or ios udid
OSType string `json:"os_type,omitempty" yaml:"os_type,omitempty"` // mobile device os type
Serial string `json:"serial,omitempty" yaml:"serial,omitempty"` // mobile device serial number
uixt.MobileAction `yaml:",inline"`
Actions []uixt.MobileAction `json:"actions,omitempty" yaml:"actions,omitempty"`
}

View File

@@ -56,7 +56,7 @@ func WithLLMService(service LLMServiceType) AIServiceOption {
return func(opts *AIServices) {
if service == LLMServiceTypeGPT4o {
var err error
opts.ILLMService, err = NewGPT4oLLMService()
opts.ILLMService, err = NewPlanner(context.Background())
if err != nil {
log.Error().Err(err).Msg("init gpt-4o llm service failed")
os.Exit(code.GetErrorCode(err))
@@ -64,9 +64,10 @@ func WithLLMService(service LLMServiceType) AIServiceOption {
}
if service == LLMServiceTypeUITARS {
var err error
opts.ILLMService, err = NewPlanner(context.Background())
opts.ILLMService, err = NewUITarsPlanner(context.Background())
if err != nil {
log.Error().Err(err).Msg("init ui-tars llm service failed")
os.Exit(code.GetErrorCode(err))
}
}
}

View File

@@ -1,211 +0,0 @@
package ai
import (
"fmt"
"net/http"
"os"
"time"
"github.com/cloudwego/eino-ext/components/model/openai"
"github.com/cloudwego/eino/schema"
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
"github.com/httprunner/httprunner/v5/code"
"github.com/httprunner/httprunner/v5/internal/config"
"github.com/httprunner/httprunner/v5/internal/json"
"github.com/httprunner/httprunner/v5/uixt/types"
)
type ILLMService interface {
Call(opts *PlanningOptions) (*PlanningResult, error)
}
func NewGPT4oLLMService() (*openaiLLMService, error) {
return &openaiLLMService{}, nil
}
type openaiLLMService struct{}
func (s openaiLLMService) Call(opts *PlanningOptions) (*PlanningResult, error) {
return nil, nil
}
// PlanningOptions represents the input options for planning
type PlanningOptions struct {
UserInstruction string `json:"user_instruction"` // append to system prompt
Message *schema.Message `json:"message"`
Size types.Size `json:"size"`
}
// PlanningResult represents the result of planning
type PlanningResult struct {
NextActions []ParsedAction `json:"actions"`
ActionSummary string `json:"summary"`
}
// VLMResponse represents the response from the Vision Language Model
type VLMResponse struct {
Actions []ParsedAction `json:"actions"`
Error string `json:"error,omitempty"`
}
// ParsedAction represents a parsed action from the VLM response
type ParsedAction struct {
ActionType ActionType `json:"actionType"`
ActionInputs map[string]interface{} `json:"actionInputs"`
Thought string `json:"thought"`
}
type ActionType string
const (
ActionTypeClick ActionType = "click"
ActionTypeTap ActionType = "tap"
ActionTypeDrag ActionType = "drag"
ActionTypeSwipe ActionType = "swipe"
ActionTypeWait ActionType = "wait"
ActionTypeFinished ActionType = "finished"
ActionTypeCallUser ActionType = "call_user"
ActionTypeType ActionType = "type"
ActionTypeScroll ActionType = "scroll"
)
const (
defaultTimeout = 60 * time.Second
)
type OpenAIInitConfig struct {
ReportURL string `json:"REPORT_SERVER_URL"`
Headers map[string]string `json:"defaultHeaders"`
}
const (
EnvOpenAIBaseURL = "OPENAI_BASE_URL"
EnvOpenAIAPIKey = "OPENAI_API_KEY"
EnvModelName = "LLM_MODEL_NAME"
EnvOpenAIInitConfigJSON = "OPENAI_INIT_CONFIG_JSON"
)
func checkEnvLLM() error {
if err := config.LoadEnv(); err != nil {
return errors.Wrap(code.LoadEnvError, err.Error())
}
openaiBaseURL := os.Getenv("OPENAI_BASE_URL")
if openaiBaseURL == "" {
return errors.Wrap(code.LLMEnvMissedError, "OPENAI_BASE_URL missed")
}
log.Info().Str("OPENAI_BASE_URL", openaiBaseURL).Msg("get env")
openaiAPIKey := os.Getenv("OPENAI_API_KEY")
if openaiAPIKey == "" {
return errors.Wrap(code.LLMEnvMissedError, "OPENAI_API_KEY missed")
}
log.Info().Str("OPENAI_API_KEY", maskAPIKey(openaiAPIKey)).Msg("get env")
modelName := os.Getenv("LLM_MODEL_NAME")
if modelName == "" {
return errors.Wrap(code.LLMEnvMissedError, "LLM_MODEL_NAME missed")
}
log.Info().Str("LLM_MODEL_NAME", modelName).Msg("get env")
return nil
}
// CustomTransport is a custom RoundTripper that adds headers to every request
type CustomTransport struct {
Transport http.RoundTripper
Headers map[string]string
}
// RoundTrip executes a single HTTP transaction and adds custom headers
func (c *CustomTransport) RoundTrip(req *http.Request) (*http.Response, error) {
for key, value := range c.Headers {
req.Header.Set(key, value)
}
return c.Transport.RoundTrip(req)
}
type OutputFormat struct {
Thought string `json:"thought"`
Action string `json:"action"`
Error string `json:"error,omitempty"`
}
// GetModelConfig get OpenAI config
func GetModelConfig() (*openai.ChatModelConfig, error) {
if err := checkEnvLLM(); err != nil {
log.Error().Err(err).Msg("check LLM env failed")
return nil, err
}
envConfig := &OpenAIInitConfig{
Headers: make(map[string]string),
}
// read from JSON config first
jsonStr := config.GetEnvConfig(EnvOpenAIInitConfigJSON)
if jsonStr != "" {
if err := json.Unmarshal([]byte(jsonStr), envConfig); err != nil {
return nil, err
}
}
// outputFormatSchema, err := openapi3gen.NewSchemaRefForValue(&OutputFormat{}, nil)
// if err != nil {
// log.Fatal().Err(err).Msg("NewSchemaRefForValue failed")
// }
modelConfig := &openai.ChatModelConfig{
HTTPClient: &http.Client{
Timeout: defaultTimeout,
Transport: &CustomTransport{
Transport: http.DefaultTransport,
Headers: envConfig.Headers,
},
},
// TODO: set structured response format
// https://github.com/cloudwego/eino-ext/blob/main/components/model/openai/examples/structured/structured.go
// ResponseFormat: &openai2.ChatCompletionResponseFormat{
// Type: openai2.ChatCompletionResponseFormatTypeJSONSchema,
// JSONSchema: &openai2.ChatCompletionResponseFormatJSONSchema{
// Name: "thought_and_action",
// Description: "data that describes planning thought and action",
// Schema: outputFormatSchema.Value,
// Strict: false,
// },
// },
}
if baseURL := config.GetEnvConfig(EnvOpenAIBaseURL); baseURL != "" {
modelConfig.BaseURL = baseURL
} else {
return nil, fmt.Errorf("miss env %s", EnvOpenAIBaseURL)
}
if apiKey := config.GetEnvConfig(EnvOpenAIAPIKey); apiKey != "" {
modelConfig.APIKey = apiKey
} else {
return nil, fmt.Errorf("miss env %s", EnvOpenAIAPIKey)
}
if modelName := config.GetEnvConfig(EnvModelName); modelName != "" {
modelConfig.Model = modelName
} else {
return nil, fmt.Errorf("miss env %s", EnvModelName)
}
// log config info
log.Info().Str("model", modelConfig.Model).
Str("baseURL", modelConfig.BaseURL).
Str("apiKey", maskAPIKey(modelConfig.APIKey)).
Str("timeout", defaultTimeout.String()).
Msg("get model config")
return modelConfig, nil
}
// maskAPIKey masks the API key
func maskAPIKey(key string) string {
if len(key) <= 8 {
return "******"
}
return key[:4] + "******" + key[len(key)-4:]
}

View File

@@ -1,236 +0,0 @@
package ai
import (
"encoding/json"
"fmt"
"regexp"
"strings"
"github.com/pkg/errors"
)
// NewActionParser creates a new ActionParser instance
func NewActionParser(factor float64) *ActionParser {
return &ActionParser{
Factor: factor,
}
}
// ActionParser parses VLM responses and converts them to structured actions
type ActionParser struct {
Factor float64 // TODO
}
// Parse parses the prediction text and extracts actions
func (p *ActionParser) Parse(predictionText string) ([]ParsedAction, error) {
// try parsing JSON format, from VLM like openai/gpt-4o
var jsonActions []ParsedAction
jsonActions, jsonErr := p.parseJSON(predictionText)
if jsonErr == nil {
return jsonActions, nil
}
// json parsing failed, try parsing Thought/Action format, from VLM like UI-TARS
thoughtActions, thoughtErr := p.parseThoughtAction(predictionText)
if thoughtErr == nil {
return thoughtActions, nil
}
return nil, errors.Wrap(thoughtErr, "parse planner response failed")
}
// parseJSON tries to parse the response as JSON format
func (p *ActionParser) parseJSON(predictionText string) ([]ParsedAction, error) {
predictionText = strings.TrimSpace(predictionText)
if strings.HasPrefix(predictionText, "```json") && strings.HasSuffix(predictionText, "```") {
predictionText = strings.TrimPrefix(predictionText, "```json")
predictionText = strings.TrimSuffix(predictionText, "```")
}
predictionText = strings.TrimSpace(predictionText)
var response VLMResponse
if err := json.Unmarshal([]byte(predictionText), &response); err != nil {
return nil, fmt.Errorf("failed to parse VLM response: %v", err)
}
if response.Error != "" {
return nil, errors.New(response.Error)
}
if len(response.Actions) == 0 {
return nil, errors.New("no actions returned from VLM")
}
// normalize actions
var normalizedActions []ParsedAction
for i := range response.Actions {
// create a new variable, avoid implicit memory aliasing in for loop.
action := response.Actions[i]
if err := p.normalizeAction(&action); err != nil {
return nil, errors.Wrap(err, "failed to normalize action")
}
normalizedActions = append(normalizedActions, action)
}
return normalizedActions, nil
}
// parseThoughtAction parses the Thought/Action format response
func (p *ActionParser) parseThoughtAction(predictionText string) ([]ParsedAction, error) {
thoughtRegex := regexp.MustCompile(`(?is)Thought:(.+?)Action:`)
actionRegex := regexp.MustCompile(`(?is)Action:(.+)`)
// extract Thought part
thoughtMatch := thoughtRegex.FindStringSubmatch(predictionText)
var thought string
if len(thoughtMatch) > 1 {
thought = strings.TrimSpace(thoughtMatch[1])
}
// extract Action part, e.g. "click(start_box='(552,454)')"
actionMatch := actionRegex.FindStringSubmatch(predictionText)
if len(actionMatch) < 2 {
return nil, errors.New("no action found in the response")
}
actionText := strings.TrimSpace(actionMatch[1])
// parse action type and parameters
return p.parseActionText(actionText, thought)
}
// parseActionText parses the action text to extract the action type and parameters
func (p *ActionParser) parseActionText(actionText, thought string) ([]ParsedAction, error) {
// remove trailing comments
if idx := strings.Index(actionText, "#"); idx > 0 {
actionText = strings.TrimSpace(actionText[:idx])
}
// supported action types and regexes
actionRegexes := map[ActionType]*regexp.Regexp{
"click": regexp.MustCompile(`click\(start_box='([^']+)'\)`),
"left_double": regexp.MustCompile(`left_double\(start_box='([^']+)'\)`),
"right_single": regexp.MustCompile(`right_single\(start_box='([^']+)'\)`),
"drag": regexp.MustCompile(`drag\(start_box='([^']+)', end_box='([^']+)'\)`),
"type": regexp.MustCompile(`type\(content='([^']+)'\)`),
"scroll": regexp.MustCompile(`scroll\(start_box='([^']+)', direction='([^']+)'\)`),
"wait": regexp.MustCompile(`wait\(\)`),
"finished": regexp.MustCompile(`finished\(content='([^']+)'\)`),
"call_user": regexp.MustCompile(`call_user\(\)`),
}
parsedActions := make([]ParsedAction, 0)
for actionType, regex := range actionRegexes {
matches := regex.FindStringSubmatch(actionText)
if len(matches) == 0 {
continue
}
var action ParsedAction
action.ActionType = actionType
action.ActionInputs = make(map[string]interface{})
action.Thought = thought
// parse parameters based on action type
switch actionType {
case ActionTypeClick:
if len(matches) > 1 {
coord, err := p.normalizeCoordinates(matches[1])
if err != nil {
return nil, errors.Wrapf(err, "normalize point failed: %s", matches[1])
}
action.ActionInputs["startBox"] = coord
}
case ActionTypeDrag:
if len(matches) > 2 {
// handle start point
startBox, err := p.normalizeCoordinates(matches[1])
if err != nil {
return nil, errors.Wrapf(err, "normalize startBox failed: %s", matches[1])
}
action.ActionInputs["startBox"] = startBox
// handle end point
endBox, err := p.normalizeCoordinates(matches[2])
if err != nil {
return nil, errors.Wrapf(err, "normalize endBox failed: %s", matches[2])
}
action.ActionInputs["endBox"] = endBox
}
case ActionTypeType:
if len(matches) > 1 {
action.ActionInputs["content"] = matches[1]
}
case ActionTypeScroll:
if len(matches) > 2 {
startBox, err := p.normalizeCoordinates(matches[1])
if err != nil {
return nil, errors.Wrapf(err, "normalize startBox failed: %s", matches[1])
}
action.ActionInputs["startBox"] = startBox
action.ActionInputs["direction"] = matches[2]
}
case ActionTypeWait, ActionTypeFinished, ActionTypeCallUser:
// 这些动作没有额外参数
}
parsedActions = append(parsedActions, action)
}
if len(parsedActions) == 0 {
return nil, fmt.Errorf("no valid actions returned from VLM")
}
return parsedActions, nil
}
// normalizeAction normalizes the coordinates in the action
func (p *ActionParser) normalizeAction(action *ParsedAction) error {
switch action.ActionType {
case "click", "drag":
// handle click and drag action coordinates
if startBox, ok := action.ActionInputs["startBox"].(string); ok {
normalized, err := p.normalizeCoordinates(startBox)
if err != nil {
return fmt.Errorf("failed to normalize startBox: %w", err)
}
action.ActionInputs["startBox"] = normalized
}
if endBox, ok := action.ActionInputs["endBox"].(string); ok {
normalized, err := p.normalizeCoordinates(endBox)
if err != nil {
return fmt.Errorf("failed to normalize endBox: %w", err)
}
action.ActionInputs["endBox"] = normalized
}
}
return nil
}
// normalizeCoordinates normalizes the coordinates based on the factor
func (p *ActionParser) normalizeCoordinates(coordStr string) (coords []float64, err error) {
// check empty string
if coordStr == "" {
return nil, fmt.Errorf("empty coordinate string")
}
if !strings.Contains(coordStr, ",") {
return nil, fmt.Errorf("invalid coordinate string: %s", coordStr)
}
// remove possible brackets and split coordinates
coordStr = strings.Trim(coordStr, "[]() \t")
// try parsing JSON array
jsonStr := coordStr
if !strings.HasPrefix(jsonStr, "[") {
jsonStr = "[" + coordStr + "]"
}
err = json.Unmarshal([]byte(jsonStr), &coords)
if err != nil {
return nil, fmt.Errorf("failed to parse coordinate string: %w", err)
}
return coords, nil
}

View File

@@ -2,27 +2,64 @@ package ai
import (
"bytes"
"context"
"encoding/base64"
"fmt"
"image"
"image/color"
"image/draw"
_ "image/jpeg"
"image/png"
"math"
"os"
"strings"
"time"
"github.com/cloudwego/eino-ext/components/model/openai"
"github.com/cloudwego/eino/components/model"
"github.com/cloudwego/eino/schema"
"github.com/httprunner/httprunner/v5/uixt/types"
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
)
type ILLMService interface {
Call(opts *PlanningOptions) (*PlanningResult, error)
}
// PlanningOptions represents the input options for planning
type PlanningOptions struct {
UserInstruction string `json:"user_instruction"` // append to system prompt
Message *schema.Message `json:"message"`
Size types.Size `json:"size"`
}
// PlanningResult represents the result of planning
type PlanningResult struct {
NextActions []ParsedAction `json:"actions"`
ActionSummary string `json:"summary"`
Error string `json:"error,omitempty"`
}
// ParsedAction represents a parsed action from the VLM response
type ParsedAction struct {
ActionType ActionType `json:"actionType"`
ActionInputs map[string]interface{} `json:"actionInputs"`
Thought string `json:"thought"`
}
type ActionType string
const (
ActionTypeClick ActionType = "click"
ActionTypeTap ActionType = "tap"
ActionTypeDrag ActionType = "drag"
ActionTypeSwipe ActionType = "swipe"
ActionTypeWait ActionType = "wait"
ActionTypeFinished ActionType = "finished"
ActionTypeCallUser ActionType = "call_user"
ActionTypeType ActionType = "type"
ActionTypeScroll ActionType = "scroll"
)
const (
defaultTimeout = 60 * time.Second
)
// Error types
var (
ErrEmptyInstruction = fmt.Errorf("user instruction is empty")
@@ -30,79 +67,6 @@ var (
ErrInvalidImageData = fmt.Errorf("invalid image data")
)
func NewPlanner(ctx context.Context) (*Planner, error) {
config, err := GetModelConfig()
if err != nil {
return nil, fmt.Errorf("failed to create OpenAI config: %w", err)
}
model, err := openai.NewChatModel(ctx, config)
if err != nil {
return nil, fmt.Errorf("failed to initialize OpenAI model: %w", err)
}
parser := NewActionParser(1000)
return &Planner{
ctx: ctx,
config: config,
model: model,
parser: parser,
}, nil
}
type Planner struct {
ctx context.Context
model model.ChatModel
config *openai.ChatModelConfig
parser *ActionParser
history []*schema.Message // conversation history
}
// Call performs UI planning using Vision Language Model
func (p *Planner) Call(opts *PlanningOptions) (*PlanningResult, error) {
// validate input parameters
if err := validateInput(opts); err != nil {
return nil, errors.Wrap(err, "validate input parameters failed")
}
// prepare prompt
if len(p.history) == 0 {
// add system message
systemPrompt := uiTarsPlanningPrompt + opts.UserInstruction
p.history = []*schema.Message{
{
Role: schema.System,
Content: systemPrompt,
},
}
}
// append user image message
p.appendConversationHistory(opts.Message)
// call model service, generate response
logRequest(p.history)
startTime := time.Now()
resp, err := p.model.Generate(p.ctx, p.history)
log.Info().Float64("elapsed(s)", time.Since(startTime).Seconds()).
Str("model", p.config.Model).Msg("call model service")
if err != nil {
return nil, fmt.Errorf("request model service failed: %w", err)
}
logResponse(resp)
// parse result
result, err := p.parseResult(resp, opts.Size)
if err != nil {
return nil, errors.Wrap(err, "parse result failed")
}
// append assistant message
p.appendConversationHistory(&schema.Message{
Role: schema.Assistant,
Content: result.ActionSummary,
})
return result, nil
}
func validateInput(opts *PlanningOptions) error {
if opts.UserInstruction == "" {
return ErrEmptyInstruction
@@ -168,7 +132,7 @@ func logResponse(resp *schema.Message) {
}
// appendConversationHistory adds a message to the conversation history
func (p *Planner) appendConversationHistory(msg *schema.Message) {
func appendConversationHistory(history []*schema.Message, msg *schema.Message) {
// for user image message:
// - keep at most 4 user image messages
// - delete the oldest user image message when the limit is reached
@@ -178,7 +142,7 @@ func (p *Planner) appendConversationHistory(msg *schema.Message) {
firstUserImgIndex := -1
// calculate the number of user messages and find the index of the first user message
for i, item := range p.history {
for i, item := range history {
if item.Role == schema.User {
userImgCount++
if firstUserImgIndex == -1 {
@@ -190,173 +154,34 @@ func (p *Planner) appendConversationHistory(msg *schema.Message) {
// if there are already 4 user messages, delete the first one before adding the new message
if userImgCount >= 4 && firstUserImgIndex >= 0 {
// delete the first user message
p.history = append(
p.history[:firstUserImgIndex],
p.history[firstUserImgIndex+1:]...,
history = append(
history[:firstUserImgIndex],
history[firstUserImgIndex+1:]...,
)
}
// add the new user message to the history
p.history = append(p.history, msg)
history = append(history, msg)
}
// for assistant message:
// - keep at most the last 10 assistant messages
if msg.Role == schema.Assistant {
// add the new assistant message to the history
p.history = append(p.history, msg)
history = append(history, msg)
// if there are more than 10 assistant messages, remove the oldest ones
assistantMsgCount := 0
for i := len(p.history) - 1; i >= 0; i-- {
if p.history[i].Role == schema.Assistant {
for i := len(history) - 1; i >= 0; i-- {
if history[i].Role == schema.Assistant {
assistantMsgCount++
if assistantMsgCount > 10 {
p.history = append(p.history[:i], p.history[i+1:]...)
history = append(history[:i], history[i+1:]...)
}
}
}
}
}
func (p *Planner) parseResult(msg *schema.Message, size types.Size) (*PlanningResult, error) {
// parse response
parseActions, err := p.parser.Parse(msg.Content)
if err != nil {
return nil, fmt.Errorf("failed to parse actions: %w", err)
}
// process response
result, err := processVLMResponse(parseActions, size)
if err != nil {
return nil, errors.Wrap(err, "process VLM response failed")
}
log.Info().
Interface("summary", result.ActionSummary).
Interface("actions", result.NextActions).
Msg("get VLM planning result")
return result, nil
}
// processVLMResponse processes the VLM response and converts it to PlanningResult
func processVLMResponse(actions []ParsedAction, size types.Size) (*PlanningResult, error) {
log.Info().Msg("processing VLM response...")
if len(actions) == 0 {
return nil, fmt.Errorf("no actions returned from VLM")
}
// validate and post-process each action
for i := range actions {
// validate action type
switch actions[i].ActionType {
case "click":
if err := convertCoordinateAction(&actions[i], "startBox", size); err != nil {
return nil, errors.Wrap(err, "convert coordinate action failed")
}
case "drag":
if err := convertCoordinateAction(&actions[i], "startBox", size); err != nil {
return nil, errors.Wrap(err, "convert coordinate action failed")
}
if err := convertCoordinateAction(&actions[i], "endBox", size); err != nil {
return nil, errors.Wrap(err, "convert coordinate action failed")
}
case "type":
validateTypeContent(&actions[i])
case "wait", "finished", "call_user":
// these actions do not need extra parameters
default:
log.Printf("warning: unknown action type: %s, will try to continue processing", actions[i].ActionType)
}
}
// extract action summary
actionSummary := extractActionSummary(actions)
return &PlanningResult{
NextActions: actions,
ActionSummary: actionSummary,
}, nil
}
// extractActionSummary extracts the summary from the actions
func extractActionSummary(actions []ParsedAction) string {
if len(actions) == 0 {
return ""
}
// use the Thought of the first action as summary
if actions[0].Thought != "" {
return actions[0].Thought
}
// if no Thought, generate summary from action type
action := actions[0]
switch action.ActionType {
case "click":
return "点击操作"
case "drag":
return "拖拽操作"
case "type":
content, _ := action.ActionInputs["content"].(string)
if len(content) > 20 {
content = content[:20] + "..."
}
return fmt.Sprintf("输入文本: %s", content)
case "wait":
return "等待操作"
case "finished":
return "完成操作"
case "call_user":
return "请求用户协助"
default:
return fmt.Sprintf("执行 %s 操作", action.ActionType)
}
}
func convertCoordinateAction(action *ParsedAction, boxField string, size types.Size) error {
// The model generates a 2D coordinate output that represents relative positions.
// To convert these values to image-relative coordinates, divide each component by 1000 to obtain values in the range [0,1].
// The absolute coordinates required by the Action can be calculated by:
// - X absolute = X relative × image width / 1000
// - Y absolute = Y relative × image height / 1000
// get image width and height
imageWidth := size.Width
imageHeight := size.Height
box := action.ActionInputs[boxField]
coords, ok := box.([]float64)
if !ok {
log.Error().Interface("inputs", action.ActionInputs).Msg("invalid action inputs")
return fmt.Errorf("invalid action inputs")
}
if len(coords) == 2 {
coords[0] = math.Round((coords[0]/1000*float64(imageWidth))*10) / 10
coords[1] = math.Round((coords[1]/1000*float64(imageHeight))*10) / 10
} else if len(coords) == 4 {
coords[0] = math.Round((coords[0]/1000*float64(imageWidth))*10) / 10
coords[1] = math.Round((coords[1]/1000*float64(imageHeight))*10) / 10
coords[2] = math.Round((coords[2]/1000*float64(imageWidth))*10) / 10
coords[3] = math.Round((coords[3]/1000*float64(imageHeight))*10) / 10
} else {
log.Error().Interface("inputs", action.ActionInputs).Msg("invalid action inputs")
return fmt.Errorf("invalid action inputs")
}
return nil
}
// validateTypeContent 验证输入文本内容
func validateTypeContent(action *ParsedAction) {
if content, ok := action.ActionInputs["content"]; !ok || content == "" {
// default to empty string
action.ActionInputs["content"] = ""
log.Warn().Msg("type action missing content parameter, set to default")
}
}
// SavePositionImg saves an image with position markers
func SavePositionImg(params struct {
InputImgBase64 string
@@ -454,3 +279,12 @@ func loadImage(imagePath string) (base64Str string, size types.Size, err error)
return base64Str, size, nil
}
// maskAPIKey masks the API key
func maskAPIKey(key string) string {
if len(key) <= 8 {
return "******"
}
return key[:4] + "******" + key[len(key)-4:]
}

242
uixt/ai/planner_gpt.go Normal file
View File

@@ -0,0 +1,242 @@
package ai
import (
"context"
"fmt"
_ "image/jpeg"
"os"
"strings"
"time"
"github.com/cloudwego/eino-ext/components/model/openai"
openai2 "github.com/cloudwego/eino-ext/libs/acl/openai"
"github.com/cloudwego/eino/components/model"
"github.com/cloudwego/eino/schema"
"github.com/getkin/kin-openapi/openapi3gen"
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
"github.com/httprunner/httprunner/v5/code"
"github.com/httprunner/httprunner/v5/internal/config"
"github.com/httprunner/httprunner/v5/internal/json"
"github.com/httprunner/httprunner/v5/uixt/types"
)
const (
EnvOpenAIBaseURL = "OPENAI_BASE_URL"
EnvOpenAIAPIKey = "OPENAI_API_KEY"
EnvModelName = "LLM_MODEL_NAME"
)
// GetOpenAIModelConfig get OpenAI config
func GetOpenAIModelConfig() (*openai.ChatModelConfig, error) {
if err := config.LoadEnv(); err != nil {
return nil, errors.Wrap(code.LoadEnvError, err.Error())
}
openaiBaseURL := os.Getenv(EnvOpenAIBaseURL)
if openaiBaseURL == "" {
return nil, errors.Wrapf(code.LLMEnvMissedError,
"env %s missed", EnvOpenAIBaseURL)
}
openaiAPIKey := os.Getenv(EnvOpenAIAPIKey)
if openaiAPIKey == "" {
return nil, errors.Wrapf(code.LLMEnvMissedError,
"env %s missed", EnvOpenAIAPIKey)
}
modelName := os.Getenv(EnvModelName)
if modelName == "" {
return nil, errors.Wrapf(code.LLMEnvMissedError,
"env %s missed", EnvModelName)
}
type OutputFormat struct {
Thought string `json:"thought"`
Action string `json:"action"`
Error string `json:"error,omitempty"`
}
outputFormatSchema, err := openapi3gen.NewSchemaRefForValue(&OutputFormat{}, nil)
if err != nil {
return nil, err
}
modelConfig := &openai.ChatModelConfig{
BaseURL: openaiBaseURL,
APIKey: openaiAPIKey,
Model: modelName,
Timeout: defaultTimeout,
// set structured response format
// https://github.com/cloudwego/eino-ext/blob/main/components/model/openai/examples/structured/structured.go
ResponseFormat: &openai2.ChatCompletionResponseFormat{
Type: openai2.ChatCompletionResponseFormatTypeJSONSchema,
JSONSchema: &openai2.ChatCompletionResponseFormatJSONSchema{
Name: "thought_and_action",
Description: "data that describes planning thought and action",
Schema: outputFormatSchema.Value,
Strict: false,
},
},
}
// log config info
log.Info().Str("model", modelConfig.Model).
Str("baseURL", modelConfig.BaseURL).
Str("apiKey", maskAPIKey(modelConfig.APIKey)).
Str("timeout", defaultTimeout.String()).
Msg("get model config")
return modelConfig, nil
}
func NewPlanner(ctx context.Context) (*Planner, error) {
config, err := GetOpenAIModelConfig()
if err != nil {
return nil, fmt.Errorf("failed to create OpenAI config: %w", err)
}
model, err := openai.NewChatModel(ctx, config)
if err != nil {
return nil, fmt.Errorf("failed to initialize OpenAI model: %w", err)
}
return &Planner{
ctx: ctx,
config: config,
model: model,
systemPrompt: uiTarsPlanningPrompt, // TODO: change prompt with function calling
}, nil
}
type Planner struct {
ctx context.Context
model model.ToolCallingChatModel
config *openai.ChatModelConfig
systemPrompt string
history []*schema.Message // conversation history
}
// Call performs UI planning using Vision Language Model
func (p *Planner) Call(opts *PlanningOptions) (*PlanningResult, error) {
// validate input parameters
if err := validateInput(opts); err != nil {
return nil, errors.Wrap(err, "validate input parameters failed")
}
// prepare prompt
if len(p.history) == 0 {
// add system message
systemPrompt := uiTarsPlanningPrompt + opts.UserInstruction
p.history = []*schema.Message{
{
Role: schema.System,
Content: systemPrompt,
},
}
}
// append user image message
appendConversationHistory(p.history, opts.Message)
// call model service, generate response
logRequest(p.history)
startTime := time.Now()
resp, err := p.model.Generate(p.ctx, p.history)
log.Info().Float64("elapsed(s)", time.Since(startTime).Seconds()).
Str("model", p.config.Model).Msg("call model service")
if err != nil {
return nil, fmt.Errorf("request model service failed: %w", err)
}
logResponse(resp)
// parse result
result, err := p.parseResult(resp, opts.Size)
if err != nil {
return nil, errors.Wrap(err, "parse result failed")
}
// append assistant message
appendConversationHistory(p.history, &schema.Message{
Role: schema.Assistant,
Content: result.ActionSummary,
})
return result, nil
}
func (p *Planner) parseResult(msg *schema.Message, size types.Size) (*PlanningResult, error) {
// parse JSON format, from VLM like openai/gpt-4o
parseActions, jsonErr := parseJSON(msg.Content)
if jsonErr != nil {
return nil, jsonErr
}
// process response
result, err := processVLMResponse(parseActions, size)
if err != nil {
return nil, errors.Wrap(err, "process VLM response failed")
}
log.Info().
Interface("summary", result.ActionSummary).
Interface("actions", result.NextActions).
Msg("get VLM planning result")
return result, nil
}
// parseJSON tries to parse the response as JSON format
func parseJSON(predictionText string) ([]ParsedAction, error) {
predictionText = strings.TrimSpace(predictionText)
if strings.HasPrefix(predictionText, "```json") && strings.HasSuffix(predictionText, "```") {
predictionText = strings.TrimPrefix(predictionText, "```json")
predictionText = strings.TrimSuffix(predictionText, "```")
}
predictionText = strings.TrimSpace(predictionText)
var response PlanningResult
if err := json.Unmarshal([]byte(predictionText), &response); err != nil {
return nil, fmt.Errorf("failed to parse VLM response: %v", err)
}
if response.Error != "" {
return nil, errors.New(response.Error)
}
if len(response.NextActions) == 0 {
return nil, errors.New("no actions returned from VLM")
}
// normalize actions
var normalizedActions []ParsedAction
for i := range response.NextActions {
// create a new variable, avoid implicit memory aliasing in for loop.
action := response.NextActions[i]
if err := normalizeAction(&action); err != nil {
return nil, errors.Wrap(err, "failed to normalize action")
}
normalizedActions = append(normalizedActions, action)
}
return normalizedActions, nil
}
// normalizeAction normalizes the coordinates in the action
func normalizeAction(action *ParsedAction) error {
switch action.ActionType {
case "click", "drag":
// handle click and drag action coordinates
if startBox, ok := action.ActionInputs["startBox"].(string); ok {
normalized, err := normalizeCoordinates(startBox)
if err != nil {
return fmt.Errorf("failed to normalize startBox: %w", err)
}
action.ActionInputs["startBox"] = normalized
}
if endBox, ok := action.ActionInputs["endBox"].(string); ok {
normalized, err := normalizeCoordinates(endBox)
if err != nil {
return fmt.Errorf("failed to normalize endBox: %w", err)
}
action.ActionInputs["endBox"] = normalized
}
}
return nil
}

View File

@@ -28,7 +28,7 @@ func TestVLMPlanning(t *testing.T) {
userInstruction += "\n\n请基于以上游戏规则给出下一步可点击的两个图标坐标"
planner, err := NewPlanner(context.Background())
planner, err := NewUITarsPlanner(context.Background())
require.NoError(t, err)
opts := &PlanningOptions{
@@ -98,7 +98,7 @@ func TestXHSPlanning(t *testing.T) {
userInstruction := "点击第二个帖子的作者头像"
planner, err := NewPlanner(context.Background())
planner, err := NewUITarsPlanner(context.Background())
require.NoError(t, err)
opts := &PlanningOptions{
@@ -168,7 +168,7 @@ func TestChatList(t *testing.T) {
userInstruction := "请结合图片的文字信息,请告诉我一共有多少个群聊,哪些群聊右下角有绿点"
planner, err := NewPlanner(context.Background())
planner, err := NewUITarsPlanner(context.Background())
require.NoError(t, err)
opts := &PlanningOptions{

470
uixt/ai/planner_ui_tars.go Normal file
View File

@@ -0,0 +1,470 @@
package ai
import (
"context"
"fmt"
"math"
"os"
"regexp"
"strconv"
"strings"
"time"
"github.com/cloudwego/eino-ext/components/model/ark"
"github.com/cloudwego/eino/components/model"
"github.com/cloudwego/eino/schema"
"github.com/httprunner/httprunner/v5/code"
"github.com/httprunner/httprunner/v5/internal/config"
"github.com/httprunner/httprunner/v5/internal/json"
"github.com/httprunner/httprunner/v5/uixt/types"
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
)
const (
EnvArkBaseURL = "ARK_BASE_URL"
EnvArkAPIKey = "ARK_API_KEY"
EnvArkModelID = "ARK_MODEL_ID"
)
func GetArkModelConfig() (*ark.ChatModelConfig, error) {
if err := config.LoadEnv(); err != nil {
return nil, errors.Wrap(code.LoadEnvError, err.Error())
}
arkBaseURL := os.Getenv(EnvArkBaseURL)
arkAPIKey := os.Getenv(EnvArkAPIKey)
if arkAPIKey == "" {
return nil, errors.Wrapf(code.LLMEnvMissedError,
"env %s missed", EnvArkAPIKey)
}
modelName := os.Getenv(EnvArkModelID)
if modelName == "" {
return nil, errors.Wrapf(code.LLMEnvMissedError,
"env %s missed", EnvArkModelID)
}
timeout := defaultTimeout
temp := float32(0.7)
modelConfig := &ark.ChatModelConfig{
BaseURL: arkBaseURL,
APIKey: arkAPIKey,
Model: modelName,
Temperature: &temp,
Timeout: &timeout,
}
// log config info
log.Info().Str("model", modelConfig.Model).
Str("baseURL", modelConfig.BaseURL).
Str("apiKey", maskAPIKey(modelConfig.APIKey)).
Str("timeout", defaultTimeout.String()).
Msg("get model config")
return modelConfig, nil
}
func NewUITarsPlanner(ctx context.Context) (*UITarsPlanner, error) {
config, err := GetArkModelConfig()
if err != nil {
return nil, err
}
chatModel, err := ark.NewChatModel(ctx, config)
if err != nil {
return nil, err
}
return &UITarsPlanner{
ctx: ctx,
config: config,
model: chatModel,
systemPrompt: uiTarsPlanningPrompt,
}, nil
}
// https://www.volcengine.com/docs/82379/1536429
const uiTarsPlanningPrompt = `
You are a GUI agent. You are given a task and your action history, with screenshots. You need to perform the next action to complete the task.
## Output Format
` + "```" + `
Thought: ...
Action: ...
` + "```" + `
## Action Space
click(start_box='[x1, y1, x2, y2]')
left_double(start_box='[x1, y1, x2, y2]')
right_single(start_box='[x1, y1, x2, y2]')
drag(start_box='[x1, y1, x2, y2]', end_box='[x3, y3, x4, y4]')
hotkey(key='')
type(content='') #If you want to submit your input, use "\n" at the end of ` + "`content`" + `.
scroll(start_box='[x1, y1, x2, y2]', direction='down or up or right or left')
wait() #Sleep for 5s and take a screenshot to check for any changes.
finished(content='xxx') # Use escape characters \\', \\", and \\n in content part to ensure we can parse the content in normal python string format.
## Note
- Use Chinese in ` + "`Thought`" + ` part.
- Write a small plan and finally summarize your next action (with its target element) in one sentence in ` + "`Thought`" + ` part.
## User Instruction
`
type UITarsPlanner struct {
ctx context.Context
model model.ToolCallingChatModel
config *ark.ChatModelConfig
systemPrompt string
history []*schema.Message // conversation history
}
// Call performs UI planning using Vision Language Model
func (p *UITarsPlanner) Call(opts *PlanningOptions) (*PlanningResult, error) {
// validate input parameters
if err := validateInput(opts); err != nil {
return nil, errors.Wrap(err, "validate input parameters failed")
}
// prepare prompt
if len(p.history) == 0 {
// add system message
systemPrompt := uiTarsPlanningPrompt + opts.UserInstruction
p.history = []*schema.Message{
{
Role: schema.System,
Content: systemPrompt,
},
}
}
// append user image message
appendConversationHistory(p.history, opts.Message)
// call model service, generate response
logRequest(p.history)
startTime := time.Now()
resp, err := p.model.Generate(p.ctx, p.history)
log.Info().Float64("elapsed(s)", time.Since(startTime).Seconds()).
Str("model", p.config.Model).Msg("call model service")
if err != nil {
return nil, fmt.Errorf("request model service failed: %w", err)
}
logResponse(resp)
// parse result
result, err := p.parseResult(resp, opts.Size)
if err != nil {
return nil, errors.Wrap(err, "parse result failed")
}
// append assistant message
appendConversationHistory(p.history, &schema.Message{
Role: schema.Assistant,
Content: result.ActionSummary,
})
return result, nil
}
func (p *UITarsPlanner) parseResult(msg *schema.Message, size types.Size) (*PlanningResult, error) {
// parse Thought/Action format from UI-TARS
parseActions, thoughtErr := parseThoughtAction(msg.Content)
if thoughtErr != nil {
return nil, thoughtErr
}
// process response
result, err := processVLMResponse(parseActions, size)
if err != nil {
return nil, errors.Wrap(err, "process VLM response failed")
}
log.Info().
Interface("summary", result.ActionSummary).
Interface("actions", result.NextActions).
Msg("get VLM planning result")
return result, nil
}
// parseThoughtAction parses the Thought/Action format response
func parseThoughtAction(predictionText string) ([]ParsedAction, error) {
thoughtRegex := regexp.MustCompile(`(?is)Thought:(.+?)Action:`)
actionRegex := regexp.MustCompile(`(?is)Action:(.+)`)
// extract Thought part
thoughtMatch := thoughtRegex.FindStringSubmatch(predictionText)
var thought string
if len(thoughtMatch) > 1 {
thought = strings.TrimSpace(thoughtMatch[1])
}
// extract Action part, e.g. "click(start_box='(552,454)')"
actionMatch := actionRegex.FindStringSubmatch(predictionText)
if len(actionMatch) < 2 {
return nil, errors.New("no action found in the response")
}
actionsText := strings.TrimSpace(actionMatch[1])
// parse action type and parameters
return parseActionText(actionsText, thought)
}
// parseActionText parses the action text to extract the action type and parameters
func parseActionText(actionsText, thought string) ([]ParsedAction, error) {
// remove trailing comments
if idx := strings.Index(actionsText, "#"); idx > 0 {
actionsText = strings.TrimSpace(actionsText[:idx])
}
// supported action types and regexes
actionRegexes := map[ActionType]*regexp.Regexp{
"click": regexp.MustCompile(`click\(start_box='([^']+)'\)`),
"left_double": regexp.MustCompile(`left_double\(start_box='([^']+)'\)`),
"right_single": regexp.MustCompile(`right_single\(start_box='([^']+)'\)`),
"drag": regexp.MustCompile(`drag\(start_box='([^']+)', end_box='([^']+)'\)`),
"type": regexp.MustCompile(`type\(content='([^']+)'\)`),
"scroll": regexp.MustCompile(`scroll\(start_box='([^']+)', direction='([^']+)'\)`),
"wait": regexp.MustCompile(`wait\(\)`),
"finished": regexp.MustCompile(`finished\(content='([^']+)'\)`),
"call_user": regexp.MustCompile(`call_user\(\)`),
}
// one or multiple actions, separated by newline
// "click(start_box='<bbox>229 379 229 379</bbox>')
// "click(start_box='<bbox>229 379 229 379</bbox>')\n\nclick(start_box='<bbox>769 519 769 519</bbox>')"
parsedActions := make([]ParsedAction, 0)
for _, actionText := range strings.Split(actionsText, "\n") {
actionText = strings.TrimSpace(actionText)
for actionType, regex := range actionRegexes {
matches := regex.FindStringSubmatch(actionText)
if len(matches) == 0 {
continue
}
var action ParsedAction
action.ActionType = actionType
action.ActionInputs = make(map[string]interface{})
action.Thought = thought
// parse parameters based on action type
switch actionType {
case ActionTypeClick:
if len(matches) > 1 {
coord, err := normalizeCoordinates(matches[1])
if err != nil {
return nil, errors.Wrapf(err, "normalize point failed: %s", matches[1])
}
action.ActionInputs["startBox"] = coord
}
case ActionTypeDrag:
if len(matches) > 2 {
// handle start point
startBox, err := normalizeCoordinates(matches[1])
if err != nil {
return nil, errors.Wrapf(err, "normalize startBox failed: %s", matches[1])
}
action.ActionInputs["startBox"] = startBox
// handle end point
endBox, err := normalizeCoordinates(matches[2])
if err != nil {
return nil, errors.Wrapf(err, "normalize endBox failed: %s", matches[2])
}
action.ActionInputs["endBox"] = endBox
}
case ActionTypeType:
if len(matches) > 1 {
action.ActionInputs["content"] = matches[1]
}
case ActionTypeScroll:
if len(matches) > 2 {
startBox, err := normalizeCoordinates(matches[1])
if err != nil {
return nil, errors.Wrapf(err, "normalize startBox failed: %s", matches[1])
}
action.ActionInputs["startBox"] = startBox
action.ActionInputs["direction"] = matches[2]
}
case ActionTypeWait, ActionTypeFinished, ActionTypeCallUser:
// 这些动作没有额外参数
}
parsedActions = append(parsedActions, action)
}
}
if len(parsedActions) == 0 {
return nil, fmt.Errorf("no valid actions returned from VLM")
}
return parsedActions, nil
}
// normalizeCoordinates normalizes the coordinates based on the factor
func normalizeCoordinates(coordStr string) (coords []float64, err error) {
// check empty string
if coordStr == "" {
return nil, fmt.Errorf("empty coordinate string")
}
// handle BBox format: <bbox>x1 y1 x2 y2</bbox>
bboxRegex := regexp.MustCompile(`<bbox>(\d+\s+\d+\s+\d+\s+\d+)</bbox>`)
bboxMatches := bboxRegex.FindStringSubmatch(coordStr)
if len(bboxMatches) > 1 {
// Extract space-separated values from inside the bbox tags
bboxContent := bboxMatches[1]
// Split by whitespace
parts := strings.Fields(bboxContent)
if len(parts) == 4 {
coords = make([]float64, 4)
for i, part := range parts {
val, e := strconv.ParseFloat(part, 64)
if e != nil {
return nil, fmt.Errorf("failed to parse coordinate value '%s': %w", part, e)
}
coords[i] = val
}
// 将 val 转换为 [x,y] 坐标
x := (coords[0] + coords[2]) / 2
y := (coords[1] + coords[3]) / 2
return []float64{x, y}, nil
}
}
// handle coordinate string, e.g. "[100, 200]", "(100, 200)"
if strings.Contains(coordStr, ",") {
// remove possible brackets and split coordinates
coordStr = strings.Trim(coordStr, "[]() \t")
// try parsing JSON array
jsonStr := coordStr
if !strings.HasPrefix(jsonStr, "[") {
jsonStr = "[" + coordStr + "]"
}
err = json.Unmarshal([]byte(jsonStr), &coords)
if err != nil {
return nil, fmt.Errorf("failed to parse coordinate string: %w", err)
}
return coords, nil
}
return nil, fmt.Errorf("invalid coordinate string format: %s", coordStr)
}
// processVLMResponse processes the VLM response and converts it to PlanningResult
func processVLMResponse(actions []ParsedAction, size types.Size) (*PlanningResult, error) {
log.Info().Msg("processing VLM response...")
if len(actions) == 0 {
return nil, fmt.Errorf("no actions returned from VLM")
}
// validate and post-process each action
for i := range actions {
// validate action type
switch actions[i].ActionType {
case "click":
if err := convertCoordinateAction(&actions[i], "startBox", size); err != nil {
return nil, errors.Wrap(err, "convert coordinate action failed")
}
case "drag":
if err := convertCoordinateAction(&actions[i], "startBox", size); err != nil {
return nil, errors.Wrap(err, "convert coordinate action failed")
}
if err := convertCoordinateAction(&actions[i], "endBox", size); err != nil {
return nil, errors.Wrap(err, "convert coordinate action failed")
}
case "type":
validateTypeContent(&actions[i])
case "wait", "finished", "call_user":
// these actions do not need extra parameters
default:
log.Printf("warning: unknown action type: %s, will try to continue processing", actions[i].ActionType)
}
}
// extract action summary
actionSummary := extractActionSummary(actions)
return &PlanningResult{
NextActions: actions,
ActionSummary: actionSummary,
}, nil
}
// extractActionSummary extracts the summary from the actions
func extractActionSummary(actions []ParsedAction) string {
if len(actions) == 0 {
return ""
}
// use the Thought of the first action as summary
if actions[0].Thought != "" {
return actions[0].Thought
}
// if no Thought, generate summary from action type
action := actions[0]
switch action.ActionType {
case "click":
return "点击操作"
case "drag":
return "拖拽操作"
case "type":
content, _ := action.ActionInputs["content"].(string)
if len(content) > 20 {
content = content[:20] + "..."
}
return fmt.Sprintf("输入文本: %s", content)
case "wait":
return "等待操作"
case "finished":
return "完成操作"
case "call_user":
return "请求用户协助"
default:
return fmt.Sprintf("执行 %s 操作", action.ActionType)
}
}
func convertCoordinateAction(action *ParsedAction, boxField string, size types.Size) error {
// The model generates a 2D coordinate output that represents relative positions.
// To convert these values to image-relative coordinates, divide each component by 1000 to obtain values in the range [0,1].
// The absolute coordinates required by the Action can be calculated by:
// - X absolute = X relative × image width / 1000
// - Y absolute = Y relative × image height / 1000
// get image width and height
imageWidth := size.Width
imageHeight := size.Height
box := action.ActionInputs[boxField]
coords, ok := box.([]float64)
if !ok {
log.Error().Interface("inputs", action.ActionInputs).Msg("invalid action inputs")
return fmt.Errorf("invalid action inputs")
}
if len(coords) == 2 {
coords[0] = math.Round((coords[0]/1000*float64(imageWidth))*10) / 10
coords[1] = math.Round((coords[1]/1000*float64(imageHeight))*10) / 10
} else if len(coords) == 4 {
coords[0] = math.Round((coords[0]/1000*float64(imageWidth))*10) / 10
coords[1] = math.Round((coords[1]/1000*float64(imageHeight))*10) / 10
coords[2] = math.Round((coords[2]/1000*float64(imageWidth))*10) / 10
coords[3] = math.Round((coords[3]/1000*float64(imageHeight))*10) / 10
} else {
log.Error().Interface("inputs", action.ActionInputs).Msg("invalid action inputs")
return fmt.Errorf("invalid action inputs")
}
return nil
}
// validateTypeContent 验证输入文本内容
func validateTypeContent(action *ParsedAction) {
if content, ok := action.ActionInputs["content"]; !ok || content == "" {
// default to empty string
action.ActionInputs["content"] = ""
log.Warn().Msg("type action missing content parameter, set to default")
}
}

View File

@@ -1,24 +0,0 @@
package ai
const uiTarsPlanningPrompt = `
You are a GUI agent. You are given a task and your action history, with screenshots. You need to perform the next action to complete the task.
## Output Format
Thought: ...
Action: ...
## Action Space
click(start_box='[x1,y1]')
long_press(start_box='[x1,y1]', time='')
type(content='')
drag(start_box='[x1,y1]', end_box='[x2,y2]')
press_home()
press_back()
finished(content='') # Submit the task regardless of whether it succeeds or fails.
## Note
- Use Chinese in Thought part.
- Write a small plan and finally summarize your next action (with its target element) in one sentence in Thought part.
## User Instruction
`

View File

@@ -8,7 +8,7 @@ import (
)
type ActionOptions struct {
Context context.Context
Context context.Context `json:"-" yaml:"-"`
// log
Identifier string `json:"identifier,omitempty" yaml:"identifier,omitempty"` // used to identify the action in log