diff --git a/.gitignore b/.gitignore index 3a82b85..ea17a21 100644 --- a/.gitignore +++ b/.gitignore @@ -15,8 +15,11 @@ dist/ testdist/ # Logs -*.logz_bridge_gen.go\nz_entry.c\nbuild/\ndist/ +*.log + +# Generated bridge files z_bridge_gen.go z_entry.c -build/ -dist/ + +# Binary +plugindev diff --git a/example/ai_image/README.md b/example/ai_image/README.md new file mode 100644 index 0000000..dd7f750 --- /dev/null +++ b/example/ai_image/README.md @@ -0,0 +1,13 @@ +# ai_image + +ai_image plugin + +## Build + +```bash +plugindev build +``` + +## Install + +Upload the .hmap file through the Plugin Manager API. diff --git a/example/ai_image/go.mod b/example/ai_image/go.mod new file mode 100644 index 0000000..3349672 --- /dev/null +++ b/example/ai_image/go.mod @@ -0,0 +1,7 @@ +module ai_image + +go 1.25.0 + +require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0 + +replace gitcode.com/JianFeeeee/homeagent-sdk => /tmp/opencode/sdk-clone diff --git a/example/ai_image/plg.json b/example/ai_image/plg.json new file mode 100644 index 0000000..678af1e --- /dev/null +++ b/example/ai_image/plg.json @@ -0,0 +1,11 @@ +{ + "name": "ai_image", + "name_zh": "AI绘图", + "name_en": "AI Image", + "version": "1.0.0", + "description": "AI 图像生成插件,支持 OpenAI DALL·E / Stable Diffusion", + "author": "HomeAgent", + "entry": "plugin.so", + "tags": ["ai", "image", "draw", "generate"], + "targets": "linux/amd64" +} diff --git a/example/ai_image/plugin.go b/example/ai_image/plugin.go new file mode 100644 index 0000000..88a0ab0 --- /dev/null +++ b/example/ai_image/plugin.go @@ -0,0 +1,344 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "time" + + sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk" +) + +type Plugin struct { + name string + sdk *sdk.PluginSDK + client *http.Client + apiKey string + provider string + model string + size string +} + +func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) { + return &Plugin{name: name}, nil +} + +func (p *Plugin) Name() string { return p.name } + +func readCfg[T string | int64 | float64](s sdk.SettingsAPI, key string, fallback T) T { + v, err := s.Get(key) + if err == nil && v != nil { + if sv, ok := v.(string); ok && sv != "" { + switch any(fallback).(type) { + case string: + return any(sv).(T) + case int64: + if n, err := strconv.ParseInt(sv, 10, 64); err == nil { + return any(n).(T) + } + case float64: + if n, err := strconv.ParseFloat(sv, 64); err == nil { + return any(n).(T) + } + } + } + } + v2, err2 := s.GetCore("plugin." + "ai_image" + "." + key) + if err2 == nil && v2 != nil { + if sv, ok := v2.(string); ok && sv != "" { + switch any(fallback).(type) { + case string: + return any(sv).(T) + case int64: + if n, err := strconv.ParseInt(sv, 10, 64); err == nil { + return any(n).(T) + } + case float64: + if n, err := strconv.ParseFloat(sv, 64); err == nil { + return any(n).(T) + } + } + } + } + return fallback +} + +func readArg[T string | int64 | float64](args map[string]interface{}, key string, fallback T) T { + v, ok := args[key] + if !ok || v == nil { + return fallback + } + switch any(fallback).(type) { + case string: + if s, ok := v.(string); ok { + return any(s).(T) + } + case int64: + switch n := v.(type) { + case float64: + return any(int64(n)).(T) + case int64: + return any(n).(T) + case string: + if i, err := strconv.ParseInt(n, 10, 64); err == nil { + return any(i).(T) + } + } + case float64: + switch n := v.(type) { + case float64: + return any(n).(T) + case int64: + return any(float64(n)).(T) + case string: + if f, err := strconv.ParseFloat(n, 64); err == nil { + return any(f).(T) + } + } + } + return fallback +} + +func (p *Plugin) Start(s *sdk.PluginSDK) error { + p.sdk = s + + p.apiKey = readCfg(s.Settings(), "api_key", "") + p.provider = readCfg(s.Settings(), "provider", "openai") + p.model = readCfg(s.Settings(), "model", "dall-e-3") + p.size = readCfg(s.Settings(), "size", "1024x1024") + + p.client = &http.Client{Timeout: 120 * time.Second} + + s.Settings().RegisterDef(sdk.ConfigDef{ + Key: "plugin.ai_image.api_key", Default: "", Type: "string", + DisplayName: "API Key", Description: "OpenAI / Stable Diffusion API Key", + Category: "ai_image", Secret: true, + }) + s.Settings().RegisterDef(sdk.ConfigDef{ + Key: "plugin.ai_image.provider", Default: "openai", Type: "string", + DisplayName: "Provider", Description: "Image generation provider: openai / stability", + Category: "ai_image", + }) + s.Settings().RegisterDef(sdk.ConfigDef{ + Key: "plugin.ai_image.model", Default: "dall-e-3", Type: "string", + DisplayName: "Model", Description: "Model name (dall-e-3, sd-xl, etc.)", + Category: "ai_image", + }) + s.Settings().RegisterDef(sdk.ConfigDef{ + Key: "plugin.ai_image.size", Default: "1024x1024", Type: "string", + DisplayName: "Size", Description: "Default image size (1024x1024, 1024x1792, 1792x1024)", + Category: "ai_image", + }) + + tp := p.name + "_" + s.RegisterTool(tp+"generate", sdk.ToolDef{ + Name: tp + "generate", Description: "Generate image from text prompt using AI. Returns image URL.", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "prompt": map[string]interface{}{"type": "string", "description": "Text description of the image to generate"}, + "size": map[string]interface{}{"type": "string", "description": "Image size (1024x1024, 1024x1792, 1792x1024), default from config"}, + "model": map[string]interface{}{"type": "string", "description": "Model override (dall-e-3, dall-e-2)"}, + "n": map[string]interface{}{"type": "integer", "description": "Number of images to generate (1-10), default 1"}, + }, + "required": []string{"prompt"}, + }, + }, p.handleGenerate) + + fmt.Printf("[%s] started (provider=%s, model=%s)\n", p.name, p.provider, p.model) + return nil +} + +func (p *Plugin) Stop() error { + fmt.Printf("[%s] stopped\n", p.name) + return nil +} + +type openAIReq struct { + Model string `json:"model"` + Prompt string `json:"prompt"` + N int `json:"n"` + Size string `json:"size"` + ResponseFormat string `json:"response_format"` +} + +type openAIResp struct { + Created int64 `json:"created"` + Data []struct { + RevisedPrompt string `json:"revised_prompt"` + URL string `json:"url"` + } `json:"data"` + Error *struct { + Message string `json:"message"` + Type string `json:"type"` + } `json:"error"` +} + +func (p *Plugin) handleGenerate(args map[string]interface{}) (interface{}, error) { + prompt := readArg(args, "prompt", "") + if prompt == "" { + return map[string]interface{}{"isError": true, "content": "prompt is required"}, nil + } + + p.apiKey = readCfg(p.sdk.Settings(), "api_key", p.apiKey) + if p.apiKey == "" { + return map[string]interface{}{"isError": true, "content": "API key not configured. Set plugin.ai_image.api_key via CLI."}, nil + } + + provider := readCfg(p.sdk.Settings(), "provider", p.provider) + model := readArg(args, "model", readCfg(p.sdk.Settings(), "model", p.model)) + size := readArg(args, "size", readCfg(p.sdk.Settings(), "size", p.size)) + n := readArg(args, "n", int64(1)) + if n < 1 { + n = 1 + } + if n > 10 { + n = 10 + } + + switch provider { + case "openai": + return p.generateOpenAI(prompt, model, size, int(n)) + case "stability": + return p.generateStability(prompt, model, size, int(n)) + default: + return map[string]interface{}{"isError": true, "content": "Unknown provider: " + provider + ". Supported: openai, stability"}, nil + } +} + +func (p *Plugin) generateOpenAI(prompt, model, size string, n int) (interface{}, error) { + body := openAIReq{ + Model: model, + Prompt: prompt, + N: n, + Size: size, + ResponseFormat: "url", + } + + b, _ := json.Marshal(body) + req, _ := http.NewRequest("POST", "https://api.openai.com/v1/images/generations", bytes.NewReader(b)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+p.apiKey) + + resp, err := p.client.Do(req) + if err != nil { + return map[string]interface{}{"isError": true, "content": "Request failed: " + err.Error()}, nil + } + defer resp.Body.Close() + + respBody, _ := io.ReadAll(resp.Body) + var result openAIResp + if err := json.Unmarshal(respBody, &result); err != nil { + return map[string]interface{}{"isError": true, "content": "Failed to parse response: " + err.Error()}, nil + } + + if result.Error != nil { + return map[string]interface{}{"isError": true, "content": "API error: " + result.Error.Message}, nil + } + + if len(result.Data) == 0 { + return map[string]interface{}{"isError": true, "content": "No images returned"}, nil + } + + urls := make([]string, len(result.Data)) + for i, d := range result.Data { + urls[i] = d.URL + } + + return map[string]interface{}{ + "content": fmt.Sprintf("Generated %d image(s) with model %s:\n%s", len(urls), model, strings.Join(urls, "\n")), + "images": urls, + "prompt": prompt, + "model": model, + }, nil +} + +type stabilityReq struct { + TextPrompts []stabilityPrompt `json:"text_prompts"` + Width int `json:"width"` + Height int `json:"height"` + Samples int `json:"samples"` +} + +type stabilityPrompt struct { + Text string `json:"text"` + Weight float64 `json:"weight,omitempty"` +} + +type stabilityArtifact struct { + Base64 string `json:"base64"` + Seed int `json:"seed"` +} + +type stabilityResp struct { + Artifacts []stabilityArtifact `json:"artifacts"` + Message string `json:"message,omitempty"` +} + +func (p *Plugin) generateStability(prompt, model, size string, n int) (interface{}, error) { + width, height := 1024, 1024 + if parts := strings.Split(size, "x"); len(parts) == 2 { + if w, err := strconv.Atoi(parts[0]); err == nil { + width = w + } + if h, err := strconv.Atoi(parts[1]); err == nil { + height = h + } + } + + body := stabilityReq{ + TextPrompts: []stabilityPrompt{{Text: prompt, Weight: 1.0}}, + Width: width, + Height: height, + Samples: n, + } + + apiURL := "https://api.stability.ai/v1/generation/stable-diffusion-xl-1024-v1-0/text-to-image" + + b, _ := json.Marshal(body) + req, _ := http.NewRequest("POST", apiURL, bytes.NewReader(b)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+p.apiKey) + req.Header.Set("Accept", "application/json") + + resp, err := p.client.Do(req) + if err != nil { + return map[string]interface{}{"isError": true, "content": "Request failed: " + err.Error()}, nil + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + respBody, _ := io.ReadAll(resp.Body) + return map[string]interface{}{"isError": true, "content": fmt.Sprintf("API error (status %d): %s", resp.StatusCode, string(respBody))}, nil + } + + respBody, _ := io.ReadAll(resp.Body) + var result stabilityResp + if err := json.Unmarshal(respBody, &result); err != nil { + return map[string]interface{}{"isError": true, "content": "Failed to parse response: " + err.Error()}, nil + } + + if len(result.Artifacts) == 0 { + msg := result.Message + if msg == "" { + msg = "No images returned" + } + return map[string]interface{}{"isError": true, "content": msg}, nil + } + + urls := make([]string, len(result.Artifacts)) + for i, a := range result.Artifacts { + urls[i] = "data:image/png;base64," + a.Base64 + } + + return map[string]interface{}{ + "content": fmt.Sprintf("Generated %d image(s) via Stability AI:\n%s", len(urls), strings.Join(urls, "\n")), + "images": urls, + "prompt": prompt, + "model": model, + }, nil +} diff --git a/example/browser/go.mod b/example/browser/go.mod index 45d9c1a..f56bb87 100644 --- a/example/browser/go.mod +++ b/example/browser/go.mod @@ -1,7 +1,21 @@ -module browser +module browser-plugin go 1.25.0 -require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0-20260708004841-e9bdcf9304b0 +require ( + gitcode.com/JianFeeeee/homeagent-sdk v0.0.0 + github.com/chromedp/chromedp v0.9.5 +) -replace gitcode.com/JianFeeeee/homeagent-sdk => ../../. +require ( + github.com/chromedp/cdproto v0.0.0-20240202021202-6d0b6a386732 // indirect + github.com/chromedp/sysutil v1.0.0 // indirect + github.com/gobwas/httphead v0.1.0 // indirect + github.com/gobwas/pool v0.2.1 // indirect + github.com/gobwas/ws v1.3.2 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + golang.org/x/sys v0.16.0 // indirect +) + +replace gitcode.com/JianFeeeee/homeagent-sdk => /tmp/opencode/sdk-clone diff --git a/example/browser/go.sum b/example/browser/go.sum new file mode 100644 index 0000000..0744849 --- /dev/null +++ b/example/browser/go.sum @@ -0,0 +1,23 @@ +github.com/chromedp/cdproto v0.0.0-20240202021202-6d0b6a386732 h1:XYUCaZrW8ckGWlCRJKCSoh/iFwlpX316a8yY9IFEzv8= +github.com/chromedp/cdproto v0.0.0-20240202021202-6d0b6a386732/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs= +github.com/chromedp/chromedp v0.9.5 h1:viASzruPJOiThk7c5bueOUY91jGLJVximoEMGoH93rg= +github.com/chromedp/chromedp v0.9.5/go.mod h1:D4I2qONslauw/C7INoCir1BJkSwBYMyZgx8X276z3+Y= +github.com/chromedp/sysutil v1.0.0 h1:+ZxhTpfpZlmchB58ih/LBHX52ky7w2VhQVKQMucy3Ic= +github.com/chromedp/sysutil v1.0.0/go.mod h1:kgWmDdq8fTzXYcKIBqIYvRRTnYb9aNS9moAV0xufSww= +github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= +github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= +github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= +github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= +github.com/gobwas/ws v1.3.2 h1:zlnbNHxumkRvfPWgfXu8RBwyNR1x8wh9cf5PTOCqs9Q= +github.com/gobwas/ws v1.3.2/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo= +github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw= +github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= +golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= diff --git a/example/browser/plg.json b/example/browser/plg.json index 52c862f..0cbb719 100644 --- a/example/browser/plg.json +++ b/example/browser/plg.json @@ -1,11 +1,11 @@ { "name": "browser", "name_zh": "浏览器", - "name_en": "browser", - "version": "1.0.0", - "description": "网络资源搜索与获取:搜索引擎查询(browser_search)、网页抓取(browser_fetch,SSRF防护)、无头浏览器渲染(browser_render)", + "name_en": "Browser", + "version": "2.0.0", + "description": "统一浏览器插件:搜索、HTTP抓取(quick)、无头渲染(normal)、交互式浏览器(interactive/CDP)", "author": "HomeAgent", "entry": "plugin.so", - "tags": ["web", "search", "fetch", "browser"], + "tags": ["web", "search", "fetch", "browser", "cdp"], "targets": "linux/amd64" } diff --git a/example/browser/plugin.go b/example/browser/plugin.go index 4bc1ac5..33e9879 100644 --- a/example/browser/plugin.go +++ b/example/browser/plugin.go @@ -2,6 +2,8 @@ package main import ( "bytes" + "context" + "encoding/base64" "encoding/json" "fmt" "io" @@ -12,21 +14,122 @@ import ( "os" "os/exec" "regexp" + "strconv" "strings" "sync" "time" "unicode" - "gitcode.com/JianFeeeee/homeagent-sdk/sdk" + sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk" + + "github.com/chromedp/chromedp" ) type Plugin struct { - name string - sdk *sdk.PluginSDK - mu sync.RWMutex - timeout int - proxy string - client *http.Client + name string + sdk *sdk.PluginSDK + mu sync.RWMutex + timeout int + proxy string + dataDir string + client *http.Client + + sessions map[string]*BrowserSession + nextID int + wg sync.WaitGroup + stopCh chan struct{} +} + +type BrowserSession struct { + id string + allocCtx context.Context + cancel context.CancelFunc + ctx context.Context + createdAt time.Time + timeout time.Duration + closed bool + mu sync.Mutex + currentURL string +} + +func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) { + return &Plugin{name: name, stopCh: make(chan struct{}), sessions: make(map[string]*BrowserSession)}, nil +} + +func (p *Plugin) Name() string { return p.name } + +func readCfg[T string | int64 | float64](s sdk.SettingsAPI, key string, fallback T) T { + v, err := s.Get(key) + if err == nil && v != nil { + if s, ok := v.(string); ok && s != "" { + switch any(fallback).(type) { + case string: + return any(s).(T) + case int64: + if n, err := strconv.ParseInt(s, 10, 64); err == nil { + return any(n).(T) + } + case float64: + if n, err := strconv.ParseFloat(s, 64); err == nil { + return any(n).(T) + } + } + } + } + v2, err2 := s.GetCore("plugin." + "browser" + "." + key) + if err2 == nil && v2 != nil { + if s, ok := v2.(string); ok && s != "" { + switch any(fallback).(type) { + case string: + return any(s).(T) + case int64: + if n, err := strconv.ParseInt(s, 10, 64); err == nil { + return any(n).(T) + } + case float64: + if n, err := strconv.ParseFloat(s, 64); err == nil { + return any(n).(T) + } + } + } + } + return fallback +} + +func readArg[T string | int64 | float64](args map[string]interface{}, key string, fallback T) T { + v, ok := args[key] + if !ok || v == nil { + return fallback + } + switch any(fallback).(type) { + case string: + if s, ok := v.(string); ok { + return any(s).(T) + } + case int64: + switch val := v.(type) { + case float64: + return any(int64(val)).(T) + case string: + if n, err := strconv.ParseInt(val, 10, 64); err == nil { + return any(n).(T) + } + } + case float64: + switch val := v.(type) { + case float64: + return any(val).(T) + case string: + if n, err := strconv.ParseFloat(val, 64); err == nil { + return any(n).(T) + } + } + } + return fallback +} + +func errResult(msg string) map[string]interface{} { + return map[string]interface{}{"isError": true, "content": msg} } func newHTTPClient(timeout int, proxyURL string) *http.Client { @@ -39,8 +142,7 @@ func newHTTPClient(timeout int, proxyURL string) *http.Client { ResponseHeaderTimeout: time.Duration(timeout) * time.Second, } if proxyURL != "" { - u, err := url.Parse(proxyURL) - if err == nil { + if u, err := url.Parse(proxyURL); err == nil { transport.Proxy = http.ProxyURL(u) } } @@ -54,106 +156,219 @@ func newHTTPClient(timeout int, proxyURL string) *http.Client { return nil }, } + } -func (p *Plugin) Name() string { return p.name } +// ── Start / Stop ────────────────────────────────────────── func (p *Plugin) Start(s *sdk.PluginSDK) error { - s.SetAutoRestart(true) p.sdk = s + s.SetAutoRestart(true) s.Settings().RegisterDef(sdk.ConfigDef{ - Key: "plugin.browser.timeout", Default: "30", Type: "int", + Key: "timeout", Default: "30", Type: "int", DisplayName: "HTTP 超时(秒)", Description: "HTTP 请求超时时间", Category: "browser", }) s.Settings().RegisterDef(sdk.ConfigDef{ - Key: "plugin.browser.proxy", Default: "", Type: "string", - DisplayName: "HTTP 代理", Description: "HTTP 代理地址,如 http://proxy:port。为空则不使用代理", + Key: "proxy", Default: "", Type: "string", + DisplayName: "HTTP 代理", Description: "HTTP 代理地址,如 http://proxy:port", + Category: "browser", + }) + s.Settings().RegisterDef(sdk.ConfigDef{ + Key: "data_dir", Default: "", Type: "string", + DisplayName: "浏览器数据目录", Description: "Chromium 用户数据目录路径(持久化 cookies/登录状态)。留空则每次启动临时目录。", Category: "browser", }) - t := getSetting[float64](s.Settings(), "timeout", 30) - p.timeout = int(t) - if p.timeout < 5 { p.timeout = 5 } - if p.timeout > 120 { p.timeout = 120 } - p.proxy = getSetting[string](s.Settings(), "proxy", "") + t := readCfg(s.Settings(), "timeout", float64(30)) + p.timeout = int(t) + if p.timeout < 5 { + p.timeout = 5 + } + if p.timeout > 120 { + p.timeout = 120 + } + p.proxy = readCfg(s.Settings(), "proxy", "") + p.dataDir = readCfg(s.Settings(), "data_dir", "") + if p.dataDir != "" { + os.MkdirAll(p.dataDir, 0700) + } p.client = newHTTPClient(p.timeout, p.proxy) tp := p.name + "_" s.RegisterTool(tp+"search", sdk.ToolDef{ - Name: tp + "search", - Description: "Search the web for current information using DuckDuckGo. Returns formatted results with titles, URLs, and snippets.", + Name: tp + "search", + Description: "使用 Bing 搜索网页。返回标题、URL 和摘要。", Parameters: map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ - "query": map[string]interface{}{"type": "string", "description": "Search query"}, - "count": map[string]interface{}{"type": "integer", "description": "Number of results (1-20, default 5)"}, + "query": map[string]interface{}{"type": "string", "description": "搜索关键词"}, + "count": map[string]interface{}{"type": "integer", "description": "结果数量(1-20,默认5)"}, }, "required": []string{"query"}, }, }, p.handleSearch) s.RegisterTool(tp+"fetch", sdk.ToolDef{ - Name: tp + "fetch", - Description: "Fetch a URL and extract readable content as markdown-like text. Blocked on private/internal IPs.", + Name: tp + "fetch", + Description: "快速抓取 URL 内容(quick 模式)。纯 HTTP 请求,不支持 JS 渲染,有 SSRF 防护。", Parameters: map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ - "url": map[string]interface{}{"type": "string", "description": "HTTP/HTTPS URL to fetch"}, - "max_chars": map[string]interface{}{"type": "integer", "description": "Max characters to return (default 20000)"}, + "url": map[string]interface{}{"type": "string", "description": "HTTP/HTTPS URL"}, + "max_chars": map[string]interface{}{"type": "integer", "description": "最大返回字符数(默认20000)"}, }, "required": []string{"url"}, }, }, p.handleFetch) s.RegisterTool(tp+"render", sdk.ToolDef{ - Name: tp + "render", - Description: "Render a web page using headless Chromium browser and extract the text content. Supports JavaScript-rendered pages. Returns title and first 5000 characters.", + Name: tp + "render", + Description: "无头 Chromium 渲染网页并提取文本(normal 模式)。支持 JS 渲染的页面。", Parameters: map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ - "url": map[string]interface{}{"type": "string", "description": "URL to render"}, - "wait": map[string]interface{}{"type": "integer", "description": "Seconds to wait for JS rendering (default 0)"}, + "url": map[string]interface{}{"type": "string", "description": "URL"}, + "wait": map[string]interface{}{"type": "integer", "description": "等待 JS 渲染的秒数(默认0)"}, }, "required": []string{"url"}, }, }, p.handleRender) + s.RegisterTool(tp+"start", sdk.ToolDef{ + Name: tp + "start", + Description: "启动交互式浏览器会话(interactive 模式)。通过 CDP 连接 Chromium,支持导航、截图、点击、输入等操作。返回会话 ID。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "url": map[string]interface{}{"type": "string", "description": "初始导航 URL(可选)"}, + "timeout": map[string]interface{}{"type": "string", "description": "会话超时(如 5m, 10m,默认 10m)"}, + }, + }, + }, p.handleBrowserStart) + + s.RegisterTool(tp+"navigate", sdk.ToolDef{ + Name: tp + "navigate", + Description: "在交互式浏览器中导航到指定 URL。自动等待页面 body 加载完成。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "id": map[string]interface{}{"type": "string", "description": "浏览器会话 ID"}, + "url": map[string]interface{}{"type": "string", "description": "目标 URL"}, + "wait": map[string]interface{}{"type": "integer", "description": "页面加载后额外等待秒数(默认2,反爬页面建议5)"}, + }, + "required": []string{"id", "url"}, + }, + }, p.handleNavigate) + + s.RegisterTool(tp+"screenshot", sdk.ToolDef{ + Name: tp + "screenshot", + Description: "对交互式浏览器当前页面截图。返回 base64 编码的 PNG 图片。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "id": map[string]interface{}{"type": "string", "description": "浏览器会话 ID"}, + "full": map[string]interface{}{"type": "boolean", "description": "是否全页截图(默认 false,仅视口)"}, + "format": map[string]interface{}{"type": "string", "description": "图片格式: png 或 jpeg(默认 png)"}, + }, + "required": []string{"id"}, + }, + }, p.handleScreenshot) + + s.RegisterTool(tp+"html", sdk.ToolDef{ + Name: tp + "html", + Description: "获取交互式浏览器当前页面 JS 渲染后的完整 HTML。用于模型分析页面结构、定位元素。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "id": map[string]interface{}{"type": "string", "description": "浏览器会话 ID"}, + "max_chars": map[string]interface{}{"type": "integer", "description": "最大返回字符数(默认50000)"}, + }, + "required": []string{"id"}, + }, + }, p.handleHTML) + + s.RegisterTool(tp+"click", sdk.ToolDef{ + Name: tp + "click", + Description: "在交互式浏览器中点击元素。自动等待元素可见后再点击。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "id": map[string]interface{}{"type": "string", "description": "浏览器会话 ID"}, + "selector": map[string]interface{}{"type": "string", "description": "CSS 选择器"}, + "wait": map[string]interface{}{"type": "integer", "description": "等待元素出现的超时毫秒数(默认3000)"}, + }, + "required": []string{"id", "selector"}, + }, + }, p.handleClick) + + s.RegisterTool(tp+"type", sdk.ToolDef{ + Name: tp + "type", + Description: "在交互式浏览器中向输入框输入文字。自动等待元素可见、聚焦后清空再输入。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "id": map[string]interface{}{"type": "string", "description": "浏览器会话 ID"}, + "selector": map[string]interface{}{"type": "string", "description": "CSS 选择器"}, + "text": map[string]interface{}{"type": "string", "description": "要输入的文字"}, + "submit": map[string]interface{}{"type": "boolean", "description": "输入后按回车(默认 false)"}, + "wait": map[string]interface{}{"type": "integer", "description": "等待元素出现的超时毫秒数(默认3000)"}, + }, + "required": []string{"id", "selector", "text"}, + }, + }, p.handleType) + + s.RegisterTool(tp+"scroll", sdk.ToolDef{ + Name: tp + "scroll", + Description: "在交互式浏览器中滚动页面。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "id": map[string]interface{}{"type": "string", "description": "浏览器会话 ID"}, + "dir": map[string]interface{}{"type": "string", "description": "方向: up, down, left, right(默认 down)"}, + "amount": map[string]interface{}{"type": "integer", "description": "滚动像素数(默认 500)"}, + }, + "required": []string{"id"}, + }, + }, p.handleScroll) + + s.RegisterTool(tp+"close", sdk.ToolDef{ + Name: tp + "close", + Description: "关闭交互式浏览器会话,释放资源。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "id": map[string]interface{}{"type": "string", "description": "浏览器会话 ID"}, + }, + "required": []string{"id"}, + }, + }, p.handleBrowserClose) + + p.wg.Add(1) + go p.cleanupLoop() + log.Printf("[%s] started, timeout=%ds proxy=%q", p.name, p.timeout, p.proxy) return nil } func (p *Plugin) Stop() error { + close(p.stopCh) + p.wg.Wait() if p.client != nil { p.client.CloseIdleConnections() } + p.mu.Lock() + for _, s := range p.sessions { + s.Close() + } + p.sessions = nil + p.mu.Unlock() log.Printf("[%s] stopped", p.name) return nil } -func getSetting[T any](s sdk.SettingsAPI, key string, def T) T { - v, err := s.Get(key) - if err != nil || v == nil { return def } - val, ok := v.(T) - if !ok { return def } - return val -} - -func convInt64(v interface{}) (int64, error) { - switch x := v.(type) { - case float64: return int64(x), nil - case int64: return x, nil - case json.Number: return x.Int64() - default: return 0, fmt.Errorf("cannot convert %T to int64", v) - } -} - -func errorResult(msg string) map[string]interface{} { - return map[string]interface{}{"isError": true, "content": msg} -} - // ── SSRF ────────────────────────────────────────────────── var privateCIDRs []*net.IPNet @@ -165,25 +380,33 @@ func init() { "::1/128", "fc00::/7", "fe80::/10", } { _, n, _ := net.ParseCIDR(c) - if n != nil { privateCIDRs = append(privateCIDRs, n) } + if n != nil { + privateCIDRs = append(privateCIDRs, n) + } } } func isPrivateIP(ip net.IP) bool { for _, n := range privateCIDRs { - if n.Contains(ip) { return true } + if n.Contains(ip) { + return true + } } return false } func (p *Plugin) ssrfCheck(rawURL string) error { u, err := url.Parse(rawURL) - if err != nil { return fmt.Errorf("invalid URL: %w", err) } + if err != nil { + return fmt.Errorf("invalid URL: %w", err) + } if u.Scheme != "http" && u.Scheme != "https" { - return fmt.Errorf("only http/https URLs allowed, got: %s", u.Scheme) + return fmt.Errorf("only http/https allowed, got: %s", u.Scheme) } ips, err := net.LookupHost(u.Hostname()) - if err != nil { return fmt.Errorf("DNS lookup failed: %w", err) } + if err != nil { + return fmt.Errorf("DNS lookup failed: %w", err) + } for _, ip := range ips { if parsed := net.ParseIP(ip); parsed != nil && isPrivateIP(parsed) { return fmt.Errorf("blocked request to private IP: %s (%s)", u.Hostname(), ip) @@ -192,95 +415,77 @@ func (p *Plugin) ssrfCheck(rawURL string) error { return nil } -// ── DuckDuckGo Search ───────────────────────────────────── +// ── Bing Search ─────────────────────────────────────────── -type ddgResult struct { +type searchResult struct { Title, URL, Snippet string } -func (p *Plugin) ddgSearch(query string, count int) ([]ddgResult, error) { - form := url.Values{"q": {query}} - req, _ := http.NewRequest("POST", "https://html.duckduckgo.com/html/", strings.NewReader(form.Encode())) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36") +func (p *Plugin) bingSearch(query string, count int) ([]searchResult, error) { + u := fmt.Sprintf("https://www.bing.com/search?q=%s&count=%d", url.QueryEscape(query), count) + req, _ := http.NewRequest("GET", u, nil) + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36") + req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8") resp, err := p.client.Do(req) - if err != nil { return nil, fmt.Errorf("request failed: %w", err) } + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) - return parseDDGResults(string(body), count), nil + return parseBingResults(string(body), count), nil } -func parseDDGResults(html string, count int) []ddgResult { - var results []ddgResult - marker := `result__body"` - for i := 0; i < len(html); i++ { - idx := strings.Index(html[i:], marker) - if idx < 0 { break } - i += idx - closeIdx := findClosingTag(html, i, "") - if closeIdx < 0 { break } - if r := parseSingleDDGResult(html[i : closeIdx+6]); r.URL != "" { - results = append(results, r) - if len(results) >= count { break } +func parseBingResults(html string, count int) []searchResult { + var results []searchResult + re := regexp.MustCompile(`
(.*?)
`) + if sm := snipRe.FindStringSubmatch(block); len(sm) > 1 { + r.Snippet = stripTags(sm[1]) + } + if r.URL != "" && r.Title != "" { + results = append(results, r) } - i = closeIdx + 6 } return results } -func findClosingTag(s string, start int, tag string) int { - depth := 1 - for pos := start; pos < len(s); { - nextOpen := strings.Index(s[pos:], `