feat(search): refactor builtinSearch to utilize websearch tool and enhance context handling

- Updated the builtinSearch function to delegate search operations to the websearch tool, improving modularity and maintainability.
- Enhanced context handling by passing the agent context to builtinSearch, allowing for user and team identification during searches.
- Added a new DecryptValue function in cloud.go to streamline value decryption, delegating to the existing config.DecryptValue method.
- Updated .gitignore to include tools/README.md for better project organization.
This commit is contained in:
Max 2026-05-02 19:58:03 +08:00
parent a5ca482d7c
commit 7da06a4ae1
30 changed files with 1789 additions and 25 deletions

1
.gitignore vendored
View file

@ -85,3 +85,4 @@ sandbox/v2/*.md
POSTGRESQL_COMPAT.md POSTGRESQL_COMPAT.md
openapi/setting/*.md openapi/setting/*.md
agent/docs/design/*.md agent/docs/design/*.md
tools/README.md

View file

@ -3,9 +3,11 @@ package web
import ( import (
"fmt" "fmt"
"strings" "strings"
"time"
agentContext "github.com/yaoapp/yao/agent/context" agentContext "github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/search/types" "github.com/yaoapp/yao/agent/search/types"
"github.com/yaoapp/yao/tools/websearch"
) )
// Handler implements web search // Handler implements web search
@ -35,7 +37,7 @@ func (h *Handler) Search(req *types.Request) (*types.Result, error) {
func (h *Handler) SearchWithContext(ctx *agentContext.Context, req *types.Request) (*types.Result, error) { func (h *Handler) SearchWithContext(ctx *agentContext.Context, req *types.Request) (*types.Result, error) {
switch { switch {
case h.usesWeb == "builtin" || h.usesWeb == "": case h.usesWeb == "builtin" || h.usesWeb == "":
return h.builtinSearch(req) return h.builtinSearch(ctx, req)
case strings.HasPrefix(h.usesWeb, "mcp:"): case strings.HasPrefix(h.usesWeb, "mcp:"):
return h.mcpSearch(req) return h.mcpSearch(req)
default: default:
@ -54,33 +56,43 @@ func (h *Handler) SearchWithContext(ctx *agentContext.Context, req *types.Reques
} }
} }
// builtinSearch uses Tavily/Serper/SerpAPI directly // builtinSearch delegates to tools/websearch which reads Settings → ENV config.
func (h *Handler) builtinSearch(req *types.Request) (*types.Result, error) { func (h *Handler) builtinSearch(ctx *agentContext.Context, req *types.Request) (*types.Result, error) {
// Determine provider from config startTime := time.Now()
providerName := "tavily" // default
if h.config != nil && h.config.Provider != "" { limit := req.Limit
providerName = h.config.Provider if limit <= 0 {
limit = 10
} }
switch providerName { var userID, teamID string
case "tavily": if ctx != nil && ctx.Authorized != nil {
return NewTavilyProvider(h.config).Search(req) userID = ctx.Authorized.UserID
case "serper": teamID = ctx.Authorized.TeamID
// Serper (serper.dev) - POST request with X-API-KEY header
return NewSerperProvider(h.config).Search(req)
case "serpapi":
// SerpAPI (serpapi.com) - GET request with api_key parameter
return NewSerpAPIProvider(h.config).Search(req)
default:
return &types.Result{
Type: types.SearchTypeWeb,
Query: req.Query,
Source: req.Source,
Items: []*types.ResultItem{},
Total: 0,
Error: fmt.Sprintf("Unknown provider: %s (supported: tavily, serper, serpapi)", providerName),
}, nil
} }
results := websearch.Search(req.Query, limit, userID, teamID)
items := make([]*types.ResultItem, 0, len(results))
for _, r := range results {
items = append(items, &types.ResultItem{
Type: types.SearchTypeWeb,
Title: r.Title,
Content: r.Content,
URL: r.URL,
Score: r.Score,
Source: req.Source,
})
}
return &types.Result{
Type: types.SearchTypeWeb,
Query: req.Query,
Source: req.Source,
Items: items,
Total: len(items),
Duration: time.Since(startTime).Milliseconds(),
}, nil
} }
// agentSearch delegates to an assistant for AI-powered search // agentSearch delegates to an assistant for AI-powered search

54
config/decrypt.go Normal file
View file

@ -0,0 +1,54 @@
package config
import (
"crypto/aes"
"crypto/cipher"
"crypto/sha256"
"encoding/base64"
"strings"
)
const encPrefix = "enc:"
// DecryptValue decrypts a value encrypted by cloud settings.
// Returns the original string if not encrypted (no "enc:" prefix)
// or if no AES key is configured.
func DecryptValue(s string) string {
if !strings.HasPrefix(s, encPrefix) {
return s
}
secret := Conf.DB.AESKey
if secret == "" {
return strings.TrimPrefix(s, encPrefix)
}
dec, err := aesGCMDecrypt(strings.TrimPrefix(s, encPrefix), secret)
if err != nil {
return s
}
return dec
}
func aesGCMDecrypt(encoded, secret string) (string, error) {
key := sha256.Sum256([]byte(secret))
data, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
return "", err
}
block, err := aes.NewCipher(key[:])
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonceSize := gcm.NonceSize()
if len(data) < nonceSize {
return "", aes.KeySizeError(len(data))
}
plaintext, err := gcm.Open(nil, data[:nonceSize], data[nonceSize:], nil)
if err != nil {
return "", err
}
return string(plaintext), nil
}

View file

@ -13,6 +13,7 @@ import (
_ "github.com/yaoapp/yao/rss" _ "github.com/yaoapp/yao/rss"
_ "github.com/yaoapp/yao/seed" _ "github.com/yaoapp/yao/seed"
_ "github.com/yaoapp/yao/sitemap" _ "github.com/yaoapp/yao/sitemap"
_ "github.com/yaoapp/yao/tools"
_ "github.com/yaoapp/yao/trace/jsapi" _ "github.com/yaoapp/yao/trace/jsapi"
_ "github.com/yaoapp/yao/wework" _ "github.com/yaoapp/yao/wework"

View file

@ -386,6 +386,12 @@ func cloudDecrypt(value string) string {
return dec return dec
} }
// DecryptValue decrypts a value encrypted by cloudEncrypt.
// Delegates to config.DecryptValue for the actual decryption.
func DecryptValue(s string) string {
return config.DecryptValue(s)
}
func cloudMaskKey(key string) string { func cloudMaskKey(key string) string {
if key == "" { if key == "" {
return "" return ""

54
tools/docs/docs.go Normal file
View file

@ -0,0 +1,54 @@
package docs
import (
_ "embed"
goudoc "github.com/yaoapp/gou/doc"
"github.com/yaoapp/gou/process"
)
//go:embed list.json
var ListSchemaJSON []byte
//go:embed inspect.json
var InspectSchemaJSON []byte
//go:embed validate.json
var ValidateSchemaJSON []byte
// ListHandler is the tools.doclist process handler.
// Args[0]: keyword (string, optional — empty lists all)
// Args[1]: limit (int, default 20)
func ListHandler(proc *process.Process) interface{} {
keyword := proc.ArgsString(0)
limit := proc.ArgsInt(1, 20)
var results []*goudoc.Entry
if keyword != "" {
results = goudoc.List(goudoc.TypeProcess, goudoc.ListOption{Search: keyword})
} else {
results = goudoc.List(goudoc.TypeProcess)
}
if len(results) > limit {
results = results[:limit]
}
return results
}
// InspectHandler is the tools.docinspect process handler.
// Args[0]: name (string — process name, e.g. "models.user.Find")
func InspectHandler(proc *process.Process) interface{} {
name := proc.ArgsString(0)
entry, ok := goudoc.Get(goudoc.TypeProcess, name)
if !ok {
return nil
}
return entry
}
// ValidateHandler is the tools.docvalidate process handler.
// Args[0]: name (string — process name)
func ValidateHandler(proc *process.Process) interface{} {
name := proc.ArgsString(0)
return goudoc.Validate(goudoc.TypeProcess, name)
}

111
tools/docs/docs_test.go Normal file
View file

@ -0,0 +1,111 @@
package docs
import (
"testing"
goudoc "github.com/yaoapp/gou/doc"
"github.com/yaoapp/gou/process"
)
func init() {
goudoc.Register(&goudoc.Entry{
Name: "models.Find",
Type: goudoc.TypeProcess,
Group: "models",
Desc: "Find records by conditions",
Args: []goudoc.TypeValue{
{Type: "object", Desc: "query conditions"},
},
Return: &goudoc.TypeValue{Type: "array", Desc: "matched records"},
})
goudoc.Register(&goudoc.Entry{
Name: "models.Save",
Type: goudoc.TypeProcess,
Group: "models",
Desc: "Save a record",
Args: []goudoc.TypeValue{
{Type: "object", Desc: "record data"},
},
Return: &goudoc.TypeValue{Type: "number", Desc: "record ID"},
})
}
func TestListHandler_All(t *testing.T) {
proc := process.New("tools.doclist", "", 20)
result := ListHandler(proc)
entries, ok := result.([]*goudoc.Entry)
if !ok {
t.Fatalf("expected []*goudoc.Entry, got %T", result)
}
if len(entries) < 2 {
t.Errorf("expected at least 2 entries, got %d", len(entries))
}
}
func TestListHandler_Search(t *testing.T) {
proc := process.New("tools.doclist", "Find", 10)
result := ListHandler(proc)
entries, ok := result.([]*goudoc.Entry)
if !ok {
t.Fatalf("expected []*goudoc.Entry, got %T", result)
}
if len(entries) == 0 {
t.Error("expected at least one result for 'Find'")
}
for _, e := range entries {
t.Logf("found: %s - %s", e.Name, e.Desc)
}
}
func TestInspectHandler(t *testing.T) {
proc := process.New("tools.docinspect", "models.Find")
result := InspectHandler(proc)
if result == nil {
t.Fatal("expected non-nil result for models.Find")
}
entry, ok := result.(*goudoc.Entry)
if !ok {
t.Fatalf("expected *goudoc.Entry, got %T", result)
}
if entry.Name != "models.Find" {
t.Errorf("expected name 'models.Find', got '%s'", entry.Name)
}
}
func TestInspectHandler_NotFound(t *testing.T) {
proc := process.New("tools.docinspect", "nonexistent.process")
result := InspectHandler(proc)
if result != nil {
t.Error("expected nil for non-existent process")
}
}
func TestValidateHandler_Valid(t *testing.T) {
proc := process.New("tools.docvalidate", "models.Find")
result := ValidateHandler(proc)
if result == nil {
t.Fatal("expected non-nil result")
}
vr, ok := result.(*goudoc.ValidationResult)
if !ok {
t.Fatalf("expected *goudoc.ValidationResult, got %T", result)
}
if !vr.Valid {
t.Error("expected valid=true for models.Find")
}
}
func TestValidateHandler_Invalid(t *testing.T) {
proc := process.New("tools.docvalidate", "nonexistent.process")
result := ValidateHandler(proc)
if result == nil {
t.Fatal("expected non-nil result")
}
vr, ok := result.(*goudoc.ValidationResult)
if !ok {
t.Fatalf("expected *goudoc.ValidationResult, got %T", result)
}
if vr.Valid {
t.Error("expected valid=false for non-existent process")
}
}

16
tools/docs/inspect.json Normal file
View file

@ -0,0 +1,16 @@
{
"name": "docinspect",
"description": "Get detailed documentation for a specific Yao process, including arguments, return type, and methods.",
"process": "tools.docinspect",
"inputSchema": {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Process name (e.g. models.user.Find)"
}
},
"required": ["name"]
},
"x-process-args": ["$args.name"]
}

20
tools/docs/list.json Normal file
View file

@ -0,0 +1,20 @@
{
"name": "doclist",
"description": "List or search Yao process documentation. Returns matching entries with name, group, and description.",
"process": "tools.doclist",
"inputSchema": {
"type": "object",
"properties": {
"keyword": {
"type": "string",
"description": "Search keyword (empty to list all)"
},
"limit": {
"type": "integer",
"description": "Max results (default 20)",
"default": 20
}
}
},
"x-process-args": ["$args.keyword", "$args.limit"]
}

13
tools/docs/validate.json Normal file
View file

@ -0,0 +1,13 @@
{
"name": "docvalidate",
"description": "Check if a Yao process has documentation. Returns validation status and suggestions for similar processes if not found.",
"process": "tools.docvalidate",
"inputSchema": {
"type": "object",
"properties": {
"name": { "type": "string", "description": "Process name to validate" }
},
"required": ["name"]
},
"x-process-args": ["$args.name"]
}

10
tools/mcps/doc.json Normal file
View file

@ -0,0 +1,10 @@
{
"name": "yao-doc",
"transport": "process",
"description": "Yao process documentation tools",
"tools": {
"doclist": "tools.doclist",
"docinspect": "tools.docinspect",
"docvalidate": "tools.docvalidate"
}
}

8
tools/mcps/process.json Normal file
View file

@ -0,0 +1,8 @@
{
"name": "yao-process",
"transport": "process",
"description": "Yao process execution tool",
"tools": {
"processcall": "tools.processcall"
}
}

9
tools/mcps/web.json Normal file
View file

@ -0,0 +1,9 @@
{
"name": "yao-web",
"transport": "process",
"description": "Web search and fetch tools",
"tools": {
"websearch": "tools.websearch",
"webfetch": "tools.webfetch"
}
}

84
tools/proc/proc.go Normal file
View file

@ -0,0 +1,84 @@
package proc
import (
_ "embed"
"strings"
"github.com/yaoapp/gou/process"
"github.com/yaoapp/kun/exception"
)
//go:embed schema.json
var SchemaJSON []byte
// Allowed process prefixes — blocks system/internal processes.
var allowedPrefixes = []string{
"models.",
"schemas.",
"stores.",
"flows.",
"scripts.",
"services.",
"tasks.",
"schedules.",
"widgets.",
}
// Explicitly blocked prefixes for safety.
var blockedPrefixes = []string{
"yao.sys.",
"yao.env.",
"utils.",
"tools.",
}
// Handler is the tools.processcall process handler.
// Args[0]: name (string — process name, e.g. "models.user.Find")
// Args[1]: args ([]interface{} — process arguments, optional)
func Handler(p *process.Process) interface{} {
name := p.ArgsString(0)
if !isAllowedProcess(name) {
exception.New("process %s is not allowed", 403, name).Throw()
}
var args []interface{}
if len(p.Args) > 1 {
if arr, ok := p.Args[1].([]interface{}); ok {
args = arr
}
}
target, err := process.Of(name, args...)
if err != nil {
exception.New("process %s not found: %s", 404, name, err.Error()).Throw()
}
if p.Authorized != nil {
target.WithAuthorized(p.Authorized)
}
target.WithSID(p.Sid)
target.WithContext(p.Context)
if err := target.Execute(); err != nil {
exception.New("process %s execution failed: %s", 500, name, err.Error()).Throw()
}
defer target.Release()
return target.Value()
}
func isAllowedProcess(name string) bool {
lower := strings.ToLower(name)
for _, prefix := range blockedPrefixes {
if strings.HasPrefix(lower, prefix) {
return false
}
}
for _, prefix := range allowedPrefixes {
if strings.HasPrefix(lower, prefix) {
return true
}
}
return false
}

39
tools/proc/proc_test.go Normal file
View file

@ -0,0 +1,39 @@
package proc
import (
"testing"
)
func TestIsAllowedProcess(t *testing.T) {
allowed := []string{
"models.user.Find",
"schemas.user.Setting",
"stores.cache.Set",
"flows.login.Run",
"scripts.helper.Format",
"services.user.Create",
"tasks.send.Run",
"schedules.cleanup.Run",
"widgets.chart.Data",
}
for _, name := range allowed {
if !isAllowedProcess(name) {
t.Errorf("expected %q to be allowed", name)
}
}
}
func TestIsBlockedProcess(t *testing.T) {
blocked := []string{
"yao.sys.Exec",
"yao.env.Get",
"utils.str.Join",
"tools.websearch",
"unknown.process",
}
for _, name := range blocked {
if isAllowedProcess(name) {
t.Errorf("expected %q to be blocked", name)
}
}
}

21
tools/proc/schema.json Normal file
View file

@ -0,0 +1,21 @@
{
"name": "processcall",
"description": "Execute a Yao process by name. Supports models, schemas, stores, flows, and scripts.",
"process": "tools.processcall",
"inputSchema": {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Process name (e.g. models.user.Find)"
},
"args": {
"type": "array",
"description": "Process arguments",
"items": {}
}
},
"required": ["name"]
},
"x-process-args": ["$args.name", "$args.args"]
}

61
tools/tools.go Normal file
View file

@ -0,0 +1,61 @@
package tools
import (
_ "embed"
"encoding/json"
"github.com/yaoapp/gou/mcp"
mcpTypes "github.com/yaoapp/gou/mcp/types"
"github.com/yaoapp/gou/process"
"github.com/yaoapp/kun/log"
"github.com/yaoapp/yao/tools/docs"
"github.com/yaoapp/yao/tools/proc"
"github.com/yaoapp/yao/tools/webfetch"
"github.com/yaoapp/yao/tools/websearch"
)
//go:embed mcps/web.json
var mcpWebDSL []byte
//go:embed mcps/process.json
var mcpProcessDSL []byte
//go:embed mcps/doc.json
var mcpDocDSL []byte
func init() {
process.RegisterGroup("tools", map[string]process.Handler{
"websearch": websearch.Handler,
"webfetch": webfetch.Handler,
"processcall": proc.Handler,
"doclist": docs.ListHandler,
"docinspect": docs.InspectHandler,
"docvalidate": docs.ValidateHandler,
})
registerMCPServer(mcpWebDSL, "yao-web",
websearch.SchemaJSON, webfetch.SchemaJSON)
registerMCPServer(mcpProcessDSL, "yao-process",
proc.SchemaJSON)
registerMCPServer(mcpDocDSL, "yao-doc",
docs.ListSchemaJSON, docs.InspectSchemaJSON, docs.ValidateSchemaJSON)
}
func registerMCPServer(dsl []byte, id string, schemas ...[]byte) {
mapping := &mcpTypes.MappingData{
Tools: map[string]*mcpTypes.ToolSchema{},
Resources: map[string]*mcpTypes.ResourceSchema{},
Prompts: map[string]*mcpTypes.PromptSchema{},
}
for _, raw := range schemas {
var s mcpTypes.ToolSchema
if err := json.Unmarshal(raw, &s); err != nil {
log.Error("[tools] failed to parse schema: %s", err.Error())
continue
}
mapping.Tools[s.Name] = &s
}
if _, err := mcp.LoadClientSourceWithType(string(dsl), id, "", mapping); err != nil {
log.Error("[tools] failed to register MCP server %s: %s", id, err.Error())
}
}

88
tools/webfetch/cloud.go Normal file
View file

@ -0,0 +1,88 @@
package webfetch
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
func cloudFetch(cfg *fetchConfig, url, format string) *FetchResponse {
if cfg.APIURL == "" || cfg.APIKey == "" {
return &FetchResponse{
URL: url,
Content: "cloud service not configured",
Format: format,
}
}
if format != "markdown" && format != "html" {
format = "markdown"
}
payload, _ := json.Marshal(map[string]string{
"url": url,
})
endpoint := cfg.APIURL + "/v1/scrape/" + format
req, err := http.NewRequest("POST", endpoint, bytes.NewReader(payload))
if err != nil {
return &FetchResponse{
URL: url,
Content: fmt.Sprintf("cloud request build failed: %s", err.Error()),
Format: format,
}
}
req.Header.Set("Authorization", "Bearer "+cfg.APIKey)
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 60 * time.Second}
resp, err := client.Do(req)
if err != nil {
return &FetchResponse{
URL: url,
Content: fmt.Sprintf("cloud request failed: %s", err.Error()),
Format: format,
}
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, maxBodySize))
if err != nil {
return &FetchResponse{
URL: url,
Content: fmt.Sprintf("cloud read body failed: %s", err.Error()),
Format: format,
}
}
if resp.StatusCode != http.StatusOK {
return &FetchResponse{
URL: url,
Content: fmt.Sprintf("cloud HTTP %d: %s", resp.StatusCode, truncate(string(body), 200)),
Format: format,
}
}
var result struct {
Title string `json:"title"`
Content string `json:"content"`
}
if err := json.Unmarshal(body, &result); err != nil {
return &FetchResponse{
URL: url,
Title: "",
Content: string(body),
Format: format,
}
}
return &FetchResponse{
URL: url,
Title: result.Title,
Content: result.Content,
Format: format,
}
}

218
tools/webfetch/convert.go Normal file
View file

@ -0,0 +1,218 @@
package webfetch
import (
"html"
"regexp"
"strings"
)
const maxContentLen = 15000
var (
reScript = regexp.MustCompile(`(?is)<script[^>]*>.*?</script>`)
reStyle = regexp.MustCompile(`(?is)<style[^>]*>.*?</style>`)
reComment = regexp.MustCompile(`(?s)<!--.*?-->`)
reTitle = regexp.MustCompile(`(?is)<title[^>]*>(.*?)</title>`)
reMetaDesc = regexp.MustCompile(`(?is)<meta[^>]*name=["']description["'][^>]*content=["'](.*?)["']`)
reMetaDescR = regexp.MustCompile(`(?is)<meta[^>]*content=["'](.*?)["'][^>]*name=["']description["']`)
reArticle = regexp.MustCompile(`(?is)<article[^>]*>(.*?)</article>`)
reMain = regexp.MustCompile(`(?is)<main[^>]*>(.*?)</main>`)
reParagraphs = regexp.MustCompile(`(?is)<p[^>]*>(.*?)</p>`)
reBody = regexp.MustCompile(`(?is)<body[^>]*>(.*?)</body>`)
reTags = regexp.MustCompile(`<[^>]+>`)
reSpaces = regexp.MustCompile(`\s+`)
reHeading = regexp.MustCompile(`(?is)<h([1-6])[^>]*>(.*?)</h[1-6]>`)
reAnchor = regexp.MustCompile(`(?is)<a[^>]*href=["']([^"']+)["'][^>]*>(.*?)</a>`)
reLi = regexp.MustCompile(`(?is)<li[^>]*>(.*?)</li>`)
rePre = regexp.MustCompile(`(?is)<pre[^>]*>(.*?)</pre>`)
reCode = regexp.MustCompile(`(?is)<code[^>]*>(.*?)</code>`)
reBr = regexp.MustCompile(`(?is)<br\s*/?>`)
reP = regexp.MustCompile(`(?is)</?p[^>]*>`)
reBlockQ = regexp.MustCompile(`(?is)<blockquote[^>]*>(.*?)</blockquote>`)
reStrong = regexp.MustCompile(`(?is)<(?:strong|b)[^>]*>(.*?)</(?:strong|b)>`)
reEm = regexp.MustCompile(`(?is)<(?:em|i)[^>]*>(.*?)</(?:em|i)>`)
)
// ExtractTitle extracts the content of the <title> tag.
func ExtractTitle(htmlStr string) string {
m := reTitle.FindStringSubmatch(htmlStr)
if m == nil {
return ""
}
return cleanText(m[1])
}
// ExtractMetaDescription extracts the meta description content.
func ExtractMetaDescription(htmlStr string) string {
m := reMetaDesc.FindStringSubmatch(htmlStr)
if m == nil {
m = reMetaDescR.FindStringSubmatch(htmlStr)
}
if m == nil {
return ""
}
return html.UnescapeString(strings.TrimSpace(m[1]))
}
// ExtractContent extracts the main text content from HTML.
func ExtractContent(htmlStr string) string {
text := reScript.ReplaceAllString(htmlStr, "")
text = reStyle.ReplaceAllString(text, "")
text = reComment.ReplaceAllString(text, "")
for _, re := range []*regexp.Regexp{reArticle, reMain} {
matches := re.FindAllStringSubmatch(text, -1)
for _, m := range matches {
cleaned := cleanText(m[1])
if len(cleaned) > 200 {
return capStr(cleaned, maxContentLen)
}
}
}
pMatches := reParagraphs.FindAllStringSubmatch(text, -1)
if len(pMatches) > 0 {
var parts []string
for _, m := range pMatches {
p := cleanText(m[1])
if len(p) > 30 {
parts = append(parts, p)
}
}
joined := strings.Join(parts, " ")
if len(joined) > 100 {
return capStr(joined, maxContentLen)
}
}
if m := reBody.FindStringSubmatch(text); m != nil {
cleaned := cleanText(m[1])
return capStr(cleaned, maxContentLen)
}
return ""
}
// HtmlToMarkdown converts HTML to a simplified Markdown representation.
func HtmlToMarkdown(htmlStr string) string {
text := reScript.ReplaceAllString(htmlStr, "")
text = reStyle.ReplaceAllString(text, "")
text = reComment.ReplaceAllString(text, "")
content := ""
for _, re := range []*regexp.Regexp{reArticle, reMain} {
if m := re.FindStringSubmatch(text); m != nil {
if len(stripTags(m[1])) > 200 {
content = m[1]
break
}
}
}
if content == "" {
if m := reBody.FindStringSubmatch(text); m != nil {
content = m[1]
} else {
content = text
}
}
content = rePre.ReplaceAllStringFunc(content, func(s string) string {
m := rePre.FindStringSubmatch(s)
if m == nil {
return s
}
inner := reTags.ReplaceAllString(m[1], "")
return "\n```\n" + html.UnescapeString(strings.TrimSpace(inner)) + "\n```\n"
})
content = reHeading.ReplaceAllStringFunc(content, func(s string) string {
m := reHeading.FindStringSubmatch(s)
if m == nil {
return s
}
level := m[1][0] - '0'
prefix := strings.Repeat("#", int(level))
return "\n" + prefix + " " + stripTags(m[2]) + "\n"
})
content = reAnchor.ReplaceAllStringFunc(content, func(s string) string {
m := reAnchor.FindStringSubmatch(s)
if m == nil {
return s
}
linkText := stripTags(m[2])
if linkText == "" {
linkText = m[1]
}
return "[" + strings.TrimSpace(linkText) + "](" + m[1] + ")"
})
content = reStrong.ReplaceAllString(content, "**$1**")
content = reEm.ReplaceAllString(content, "*$1*")
content = reCode.ReplaceAllString(content, "`$1`")
content = reLi.ReplaceAllStringFunc(content, func(s string) string {
m := reLi.FindStringSubmatch(s)
if m == nil {
return s
}
return "\n- " + strings.TrimSpace(stripTags(m[1]))
})
content = reBlockQ.ReplaceAllStringFunc(content, func(s string) string {
m := reBlockQ.FindStringSubmatch(s)
if m == nil {
return s
}
inner := strings.TrimSpace(stripTags(m[1]))
lines := strings.Split(inner, "\n")
for i, l := range lines {
lines[i] = "> " + l
}
return "\n" + strings.Join(lines, "\n") + "\n"
})
content = reBr.ReplaceAllString(content, "\n")
content = reP.ReplaceAllString(content, "\n\n")
content = reTags.ReplaceAllString(content, "")
content = html.UnescapeString(content)
lines := strings.Split(content, "\n")
var result []string
blankCount := 0
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed == "" {
blankCount++
if blankCount <= 2 {
result = append(result, "")
}
} else {
blankCount = 0
result = append(result, trimmed)
}
}
final := strings.TrimSpace(strings.Join(result, "\n"))
return capStr(final, maxContentLen)
}
func cleanText(s string) string {
s = reTags.ReplaceAllString(s, " ")
s = html.UnescapeString(s)
s = reSpaces.ReplaceAllString(s, " ")
return strings.TrimSpace(s)
}
func stripTags(s string) string {
return strings.TrimSpace(reTags.ReplaceAllString(s, ""))
}
func capStr(s string, n int) string {
r := []rune(s)
if len(r) <= n {
return s
}
return string(r[:n])
}

View file

@ -0,0 +1,86 @@
package webfetch
import (
"strings"
"testing"
)
func TestExtractTitle(t *testing.T) {
html := `<html><head><title>Hello World</title></head><body></body></html>`
title := ExtractTitle(html)
if title != "Hello World" {
t.Errorf("expected 'Hello World', got '%s'", title)
}
}
func TestExtractTitle_Empty(t *testing.T) {
html := `<html><head></head><body></body></html>`
title := ExtractTitle(html)
if title != "" {
t.Errorf("expected empty, got '%s'", title)
}
}
func TestExtractMetaDescription(t *testing.T) {
html := `<html><head><meta name="description" content="Test description"></head></html>`
desc := ExtractMetaDescription(html)
if desc != "Test description" {
t.Errorf("expected 'Test description', got '%s'", desc)
}
}
func TestExtractContent(t *testing.T) {
html := `<html><body>
<script>var x = 1;</script>
<article>` + strings.Repeat("This is article content. ", 20) + `</article>
</body></html>`
content := ExtractContent(html)
if !strings.Contains(content, "article content") {
t.Errorf("expected content to contain 'article content', got '%s'", content)
}
}
func TestHtmlToMarkdown(t *testing.T) {
html := `<html><body>
<h1>Title</h1>
<p>Paragraph text</p>
<a href="https://example.com">Link</a>
<ul><li>Item 1</li><li>Item 2</li></ul>
</body></html>`
md := HtmlToMarkdown(html)
if !strings.Contains(md, "# Title") {
t.Error("expected markdown heading")
}
if !strings.Contains(md, "Paragraph text") {
t.Error("expected paragraph text")
}
if !strings.Contains(md, "[Link](https://example.com)") {
t.Error("expected markdown link")
}
if !strings.Contains(md, "- Item 1") {
t.Error("expected list item")
}
}
func TestHtmlToMarkdown_CodeBlock(t *testing.T) {
html := `<body><pre><code>func main() {}</code></pre></body>`
md := HtmlToMarkdown(html)
if !strings.Contains(md, "```") {
t.Error("expected fenced code block")
}
if !strings.Contains(md, "func main()") {
t.Error("expected code content")
}
}
func TestCapStr(t *testing.T) {
s := "hello world"
if capStr(s, 5) != "hello" {
t.Errorf("expected 'hello', got '%s'", capStr(s, 5))
}
if capStr(s, 100) != s {
t.Errorf("expected full string, got '%s'", capStr(s, 100))
}
}

232
tools/webfetch/fetch.go Normal file
View file

@ -0,0 +1,232 @@
package webfetch
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"path"
"strings"
"time"
)
const (
directTimeout = 15 * time.Second
brightdataTimeout = 90 * time.Second
headTimeout = 5 * time.Second
maxBodySize = 10 * 1024 * 1024 // 10 MB
minDirectBody = 500
minMarkdownBody = 100
botUserAgent = "Mozilla/5.0 (compatible; YaoBot/1.0; +https://yao.run)"
browserUserAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
)
type fetchResult struct {
Body []byte
StatusCode int
ContentType string
}
func directFetch(targetURL string, useBot bool) (*fetchResult, error) {
client := &http.Client{Timeout: directTimeout}
req, err := http.NewRequest(http.MethodGet, targetURL, nil)
if err != nil {
return nil, fmt.Errorf("build request: %w", err)
}
if useBot {
req.Header.Set("User-Agent", botUserAgent)
req.Header.Set("Accept", "text/markdown,text/plain,text/html,*/*;q=0.8")
} else {
req.Header.Set("User-Agent", browserUserAgent)
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("http get: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, maxBodySize))
if err != nil {
return nil, fmt.Errorf("read body: %w", err)
}
return &fetchResult{
Body: body,
StatusCode: resp.StatusCode,
ContentType: resp.Header.Get("Content-Type"),
}, nil
}
func brightdataFetch(targetURL, apiKey, zone string) ([]byte, error) {
payload, _ := json.Marshal(map[string]string{
"zone": zone,
"url": targetURL,
"format": "raw",
})
client := &http.Client{Timeout: brightdataTimeout}
req, err := http.NewRequest(http.MethodPost, "https://api.brightdata.com/request", bytes.NewReader(payload))
if err != nil {
return nil, fmt.Errorf("build brightdata request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+apiKey)
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("brightdata request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, maxBodySize))
if err != nil {
return nil, fmt.Errorf("read brightdata response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("brightdata HTTP %d: %s", resp.StatusCode, truncate(string(body), 200))
}
return body, nil
}
func headCheck(url string) (int, error) {
client := &http.Client{Timeout: headTimeout}
req, err := http.NewRequest(http.MethodHead, url, nil)
if err != nil {
return 0, fmt.Errorf("build head request: %w", err)
}
req.Header.Set("User-Agent", botUserAgent)
resp, err := client.Do(req)
if err != nil {
return 0, fmt.Errorf("head request: %w", err)
}
resp.Body.Close()
return resp.StatusCode, nil
}
// fetchHTML tries direct GET first, falls back to Brightdata.
func fetchHTML(cfg *fetchConfig, targetURL string) *FetchResponse {
res, err := directFetch(targetURL, false)
if err == nil && res.StatusCode == 200 && len(res.Body) >= minDirectBody {
htmlStr := string(res.Body)
return &FetchResponse{
URL: targetURL,
Title: ExtractTitle(htmlStr),
Content: ExtractContent(htmlStr),
Format: "html",
}
}
if cfg.BrightdataKey != "" {
body, err := brightdataFetch(targetURL, cfg.BrightdataKey, cfg.BrightdataZone)
if err == nil {
htmlStr := string(body)
return &FetchResponse{
URL: targetURL,
Title: ExtractTitle(htmlStr),
Content: ExtractContent(htmlStr),
Format: "html",
}
}
}
return &FetchResponse{
URL: targetURL,
Content: fmt.Sprintf("Failed to fetch %s", targetURL),
Format: "html",
}
}
// fetchMarkdown tries .md probing, then HTML -> markdown conversion.
func fetchMarkdown(cfg *fetchConfig, targetURL string) *FetchResponse {
lower := strings.ToLower(targetURL)
ext := strings.ToLower(path.Ext(strings.TrimSuffix(lower, "/")))
// Already a .md file
if ext == ".md" || ext == ".mdx" {
res, err := directFetch(targetURL, true)
if err == nil && res.StatusCode == 200 && len(res.Body) >= minMarkdownBody {
return &FetchResponse{
URL: targetURL,
Content: string(res.Body),
Format: "markdown",
}
}
}
// Probe for .md version
mdURL := buildMdURL(targetURL)
if mdURL != "" {
code, err := headCheck(mdURL)
if err == nil && code == 200 {
res, err := directFetch(mdURL, true)
if err == nil && res.StatusCode == 200 && len(res.Body) >= minMarkdownBody {
return &FetchResponse{
URL: targetURL,
Content: string(res.Body),
Format: "markdown",
}
}
}
}
// Fallback: fetch HTML and convert
htmlRes := fetchRawHTML(cfg, targetURL)
if htmlRes == nil {
return &FetchResponse{
URL: targetURL,
Content: fmt.Sprintf("Failed to fetch %s", targetURL),
Format: "markdown",
}
}
htmlStr := string(htmlRes)
md := HtmlToMarkdown(htmlStr)
title := ExtractTitle(htmlStr)
if title != "" {
md = "# " + title + "\n\n" + md
}
return &FetchResponse{
URL: targetURL,
Title: title,
Content: md,
Format: "markdown",
}
}
// fetchRawHTML fetches HTML with direct -> brightdata fallback.
func fetchRawHTML(cfg *fetchConfig, targetURL string) []byte {
res, err := directFetch(targetURL, false)
if err == nil && res.StatusCode == 200 && len(res.Body) >= minDirectBody {
return res.Body
}
if cfg.BrightdataKey != "" {
body, err := brightdataFetch(targetURL, cfg.BrightdataKey, cfg.BrightdataZone)
if err == nil {
return body
}
}
return nil
}
func buildMdURL(u string) string {
ext := strings.ToLower(path.Ext(strings.TrimSuffix(u, "/")))
if ext == ".md" || ext == ".mdx" {
return ""
}
trimmed := strings.TrimSuffix(u, "/")
return trimmed + ".md"
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "..."
}

View file

@ -0,0 +1,19 @@
{
"name": "webfetch",
"description": "Fetch a web page and return its content. Supports markdown and HTML output formats.",
"process": "tools.webfetch",
"inputSchema": {
"type": "object",
"properties": {
"url": { "type": "string", "description": "URL to fetch" },
"format": {
"type": "string",
"description": "Output format: markdown or html (default markdown)",
"enum": ["markdown", "html"],
"default": "markdown"
}
},
"required": ["url"]
},
"x-process-args": ["$args.url", "$args.format"]
}

119
tools/webfetch/webfetch.go Normal file
View file

@ -0,0 +1,119 @@
package webfetch
import (
_ "embed"
"os"
"github.com/yaoapp/gou/process"
"github.com/yaoapp/yao/config"
"github.com/yaoapp/yao/setting"
)
//go:embed schema.json
var SchemaJSON []byte
// FetchResponse is the return type for the webfetch tool.
type FetchResponse struct {
URL string `json:"url"`
Title string `json:"title"`
Content string `json:"content"`
Format string `json:"format"`
}
type fetchConfig struct {
Provider string // "cloud" / "brightdata" / "" (direct)
APIKey string
APIURL string // cloud mode endpoint
BrightdataKey string
BrightdataZone string
}
// Handler is the tools.webfetch process handler.
// Args[0]: url (string)
// Args[1]: format (string, default "markdown")
func Handler(proc *process.Process) interface{} {
url := proc.ArgsString(0)
format := proc.ArgsString(1, "markdown")
userID, teamID := getAuthInfo(proc)
cfg := getConfig(userID, teamID)
switch cfg.Provider {
case "cloud":
return cloudFetch(cfg, url, format)
default:
return localFetch(cfg, url, format)
}
}
func getAuthInfo(proc *process.Process) (userID, teamID string) {
if proc.Authorized != nil {
userID = proc.Authorized.UserID
teamID = proc.Authorized.TeamID
}
return
}
func getConfig(userID, teamID string) *fetchConfig {
cfg := &fetchConfig{}
if setting.Global != nil {
assignment, _ := setting.Global.GetMerged(userID, teamID, "search.tool_assignment")
if v, ok := assignment["web_scrape"].(string); ok && v != "" {
cfg.Provider = v
}
}
switch cfg.Provider {
case "cloud":
cfg.APIKey, cfg.APIURL = getCloudConfig(userID, teamID)
case "brightdata":
cfg.BrightdataKey, cfg.BrightdataZone = getBrightdataConfig(userID, teamID)
default:
cfg.BrightdataKey, cfg.BrightdataZone = getBrightdataConfig(userID, teamID)
}
return cfg
}
func getCloudConfig(userID, teamID string) (apiKey, apiURL string) {
if setting.Global == nil {
return
}
saved, _ := setting.Global.GetMerged(userID, teamID, "cloud")
if v, ok := saved["api_url"].(string); ok {
apiURL = v
}
if v, ok := saved["api_key"].(string); ok {
apiKey = config.DecryptValue(v)
}
return
}
func getBrightdataConfig(userID, teamID string) (apiKey, zone string) {
if setting.Global != nil {
saved, _ := setting.Global.GetMerged(userID, teamID, "search.providers.brightdata")
if fv, ok := saved["field_values"].(map[string]interface{}); ok {
if v, ok := fv["api_key"].(string); ok {
apiKey = config.DecryptValue(v)
}
if v, ok := fv["zone"].(string); ok {
zone = v
}
}
}
if apiKey == "" {
apiKey = os.Getenv("BRIGHTDATA_API_KEY")
}
if zone == "" {
zone = os.Getenv("BRIGHTDATA_ZONE")
}
return
}
func localFetch(cfg *fetchConfig, url, format string) *FetchResponse {
switch format {
case "html":
return fetchHTML(cfg, url)
default:
return fetchMarkdown(cfg, url)
}
}

View file

@ -0,0 +1,74 @@
package webfetch
import (
"testing"
)
func TestDirectFetch_Success(t *testing.T) {
res, err := directFetch("https://example.com", false)
if err != nil {
t.Fatalf("directFetch failed: %v", err)
}
if res.StatusCode != 200 {
t.Errorf("expected 200, got %d", res.StatusCode)
}
if len(res.Body) < 100 {
t.Error("expected body with at least 100 bytes")
}
}
func TestDirectFetch_Bot(t *testing.T) {
res, err := directFetch("https://example.com", true)
if err != nil {
t.Fatalf("directFetch with bot failed: %v", err)
}
if res.StatusCode != 200 {
t.Errorf("expected 200, got %d", res.StatusCode)
}
}
func TestFetchHTML_Local(t *testing.T) {
cfg := &fetchConfig{}
resp := fetchHTML(cfg, "https://example.com")
if resp == nil {
t.Fatal("expected non-nil response")
}
if resp.Format != "html" {
t.Errorf("expected format 'html', got '%s'", resp.Format)
}
if resp.Content == "" {
t.Error("expected non-empty content")
}
}
func TestFetchMarkdown_Local(t *testing.T) {
cfg := &fetchConfig{}
resp := fetchMarkdown(cfg, "https://example.com")
if resp == nil {
t.Fatal("expected non-nil response")
}
if resp.Format != "markdown" {
t.Errorf("expected format 'markdown', got '%s'", resp.Format)
}
if resp.Content == "" {
t.Error("expected non-empty content")
}
}
func TestBuildMdURL(t *testing.T) {
tests := []struct {
input string
expected string
}{
{"https://example.com/docs/page", "https://example.com/docs/page.md"},
{"https://example.com/docs/page/", "https://example.com/docs/page.md"},
{"https://example.com/docs/page.md", ""},
{"https://example.com/docs/page.MDX", ""},
}
for _, tt := range tests {
got := buildMdURL(tt.input)
if got != tt.expected {
t.Errorf("buildMdURL(%q) = %q, want %q", tt.input, got, tt.expected)
}
}
}

77
tools/websearch/cloud.go Normal file
View file

@ -0,0 +1,77 @@
package websearch
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
func cloudSearch(cfg *searchConfig, query string, limit int) []SearchResult {
if cfg.APIURL == "" || cfg.APIKey == "" {
return nil
}
payload, _ := json.Marshal(map[string]interface{}{
"query": query,
"max_results": limit,
})
tool := cfg.CloudTool
if tool == "" {
tool = "serper-search"
}
url := cfg.APIURL + "/v1/search/" + tool
req, err := http.NewRequest("POST", url, bytes.NewReader(payload))
if err != nil {
return []SearchResult{{Title: "Error", Content: fmt.Sprintf("cloud request build failed: %s", err.Error())}}
}
req.Header.Set("Authorization", "Bearer "+cfg.APIKey)
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return []SearchResult{{Title: "Error", Content: fmt.Sprintf("cloud request failed: %s", err.Error())}}
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return []SearchResult{{Title: "Error", Content: fmt.Sprintf("cloud read body failed: %s", err.Error())}}
}
if resp.StatusCode != http.StatusOK {
return []SearchResult{{Title: "Error", Content: fmt.Sprintf("cloud HTTP %d: %s", resp.StatusCode, string(body))}}
}
var result struct {
Results []struct {
Title string `json:"title"`
URL string `json:"url"`
Snippet string `json:"snippet"`
Content string `json:"content"`
Score float64 `json:"score"`
} `json:"results"`
}
if err := json.Unmarshal(body, &result); err != nil {
return []SearchResult{{Title: "Error", Content: fmt.Sprintf("cloud parse failed: %s", err.Error())}}
}
out := make([]SearchResult, 0, len(result.Results))
for _, r := range result.Results {
text := r.Snippet
if text == "" {
text = r.Content
}
out = append(out, SearchResult{
Title: r.Title,
URL: r.URL,
Content: text,
Score: r.Score,
})
}
return out
}

View file

@ -0,0 +1,18 @@
{
"name": "websearch",
"description": "Search the web for real-time information. Returns structured results with title, URL, and content snippet.",
"process": "tools.websearch",
"inputSchema": {
"type": "object",
"properties": {
"query": { "type": "string", "description": "Search query" },
"limit": {
"type": "integer",
"description": "Max results (default 10)",
"default": 10
}
},
"required": ["query"]
},
"x-process-args": ["$args.query", "$args.limit"]
}

65
tools/websearch/serper.go Normal file
View file

@ -0,0 +1,65 @@
package websearch
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
func serperSearch(apiKey, query string, limit int) []SearchResult {
if apiKey == "" {
return nil
}
payload, _ := json.Marshal(map[string]interface{}{
"q": query,
"num": limit,
})
req, err := http.NewRequest("POST", "https://google.serper.dev/search", bytes.NewReader(payload))
if err != nil {
return []SearchResult{{Title: "Error", Content: fmt.Sprintf("serper request build failed: %s", err.Error())}}
}
req.Header.Set("X-API-KEY", apiKey)
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return []SearchResult{{Title: "Error", Content: fmt.Sprintf("serper request failed: %s", err.Error())}}
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return []SearchResult{{Title: "Error", Content: fmt.Sprintf("serper read body failed: %s", err.Error())}}
}
if resp.StatusCode != http.StatusOK {
return []SearchResult{{Title: "Error", Content: fmt.Sprintf("serper HTTP %d: %s", resp.StatusCode, string(body))}}
}
var result struct {
Organic []struct {
Title string `json:"title"`
Link string `json:"link"`
Snippet string `json:"snippet"`
} `json:"organic"`
}
if err := json.Unmarshal(body, &result); err != nil {
return []SearchResult{{Title: "Error", Content: fmt.Sprintf("serper parse failed: %s", err.Error())}}
}
out := make([]SearchResult, 0, len(result.Organic))
for _, r := range result.Organic {
out = append(out, SearchResult{
Title: r.Title,
URL: r.Link,
Content: r.Snippet,
})
}
return out
}

62
tools/websearch/tavily.go Normal file
View file

@ -0,0 +1,62 @@
package websearch
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
func tavilySearch(apiKey, query string, limit int) []SearchResult {
if apiKey == "" {
return nil
}
payload, _ := json.Marshal(map[string]interface{}{
"api_key": apiKey,
"query": query,
"max_results": limit,
"include_raw_content": false,
})
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Post("https://api.tavily.com/search", "application/json", bytes.NewReader(payload))
if err != nil {
return []SearchResult{{Title: "Error", Content: fmt.Sprintf("tavily request failed: %s", err.Error())}}
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return []SearchResult{{Title: "Error", Content: fmt.Sprintf("tavily read body failed: %s", err.Error())}}
}
if resp.StatusCode != http.StatusOK {
return []SearchResult{{Title: "Error", Content: fmt.Sprintf("tavily HTTP %d: %s", resp.StatusCode, string(body))}}
}
var result struct {
Results []struct {
Title string `json:"title"`
URL string `json:"url"`
Content string `json:"content"`
Score float64 `json:"score"`
} `json:"results"`
}
if err := json.Unmarshal(body, &result); err != nil {
return []SearchResult{{Title: "Error", Content: fmt.Sprintf("tavily parse failed: %s", err.Error())}}
}
out := make([]SearchResult, 0, len(result.Results))
for _, r := range result.Results {
out = append(out, SearchResult{
Title: r.Title,
URL: r.URL,
Content: r.Content,
Score: r.Score,
})
}
return out
}

View file

@ -0,0 +1,117 @@
package websearch
import (
_ "embed"
"os"
"github.com/yaoapp/gou/process"
"github.com/yaoapp/yao/config"
"github.com/yaoapp/yao/setting"
)
//go:embed schema.json
var SchemaJSON []byte
// SearchResult is the unified return type for all search providers.
type SearchResult struct {
Title string `json:"title"`
URL string `json:"url"`
Content string `json:"content"`
Score float64 `json:"score,omitempty"`
}
type searchConfig struct {
Provider string // "tavily" / "serper" / "cloud"
APIKey string
APIURL string // cloud mode endpoint
CloudTool string // cloud search tool name, e.g. "serper-search", "tavily-search"
}
// Handler is the tools.websearch process handler.
// Args[0]: query (string)
// Args[1]: limit (int, default 10)
func Handler(proc *process.Process) interface{} {
query := proc.ArgsString(0)
limit := proc.ArgsInt(1, 10)
userID, teamID := getAuthInfo(proc)
return Search(query, limit, userID, teamID)
}
// Search executes a web search using the configured provider.
// Reads provider/key from Settings (with ENV fallback).
func Search(query string, limit int, userID, teamID string) []SearchResult {
cfg := getConfig(userID, teamID)
switch cfg.Provider {
case "cloud":
return cloudSearch(cfg, query, limit)
case "serper":
return serperSearch(cfg.APIKey, query, limit)
default:
return tavilySearch(cfg.APIKey, query, limit)
}
}
func getAuthInfo(proc *process.Process) (userID, teamID string) {
if proc.Authorized != nil {
userID = proc.Authorized.UserID
teamID = proc.Authorized.TeamID
}
return
}
func getConfig(userID, teamID string) *searchConfig {
cfg := &searchConfig{Provider: "tavily"}
if setting.Global != nil {
assignment, _ := setting.Global.GetMerged(userID, teamID, "search.tool_assignment")
if v, ok := assignment["web_search"].(string); ok && v != "" {
cfg.Provider = v
}
}
switch cfg.Provider {
case "cloud":
cfg.APIKey, cfg.APIURL, cfg.CloudTool = getCloudConfig(userID, teamID)
case "tavily":
cfg.APIKey = getProviderKey(userID, teamID, "tavily")
if cfg.APIKey == "" {
cfg.APIKey = os.Getenv("TAVILY_API_KEY")
}
case "serper":
cfg.APIKey = getProviderKey(userID, teamID, "serper")
if cfg.APIKey == "" {
cfg.APIKey = os.Getenv("SERPER_API_KEY")
}
}
return cfg
}
func getCloudConfig(userID, teamID string) (apiKey, apiURL, cloudTool string) {
if setting.Global == nil {
return
}
saved, _ := setting.Global.GetMerged(userID, teamID, "cloud")
if v, ok := saved["api_url"].(string); ok {
apiURL = v
}
if v, ok := saved["api_key"].(string); ok {
apiKey = config.DecryptValue(v)
}
if v, ok := saved["search_tool"].(string); ok && v != "" {
cloudTool = v
}
return
}
func getProviderKey(userID, teamID, presetKey string) string {
if setting.Global == nil {
return ""
}
saved, _ := setting.Global.GetMerged(userID, teamID, "search.providers."+presetKey)
if fv, ok := saved["field_values"].(map[string]interface{}); ok {
if v, ok := fv["api_key"].(string); ok {
return config.DecryptValue(v)
}
}
return ""
}

View file

@ -0,0 +1,69 @@
package websearch
import (
"os"
"testing"
)
func TestTavilySearch(t *testing.T) {
key := os.Getenv("TAVILY_API_KEY")
if key == "" {
t.Skip("TAVILY_API_KEY not set")
}
results := tavilySearch(key, "Yao application engine", 3)
if len(results) == 0 {
t.Fatal("expected at least one result from tavily")
}
for _, r := range results {
if r.Title == "Error" {
t.Fatalf("tavily returned error: %s", r.Content)
}
if r.URL == "" {
t.Error("expected non-empty URL")
}
}
t.Logf("got %d results", len(results))
}
func TestSerperSearch(t *testing.T) {
key := os.Getenv("SERPER_API_KEY")
if key == "" {
t.Skip("SERPER_API_KEY not set")
}
results := serperSearch(key, "Yao application engine", 3)
if len(results) == 0 {
t.Fatal("expected at least one result from serper")
}
for _, r := range results {
if r.Title == "Error" {
t.Fatalf("serper returned error: %s", r.Content)
}
if r.URL == "" {
t.Error("expected non-empty URL")
}
}
t.Logf("got %d results", len(results))
}
func TestTavilySearch_NoKey(t *testing.T) {
results := tavilySearch("", "test", 5)
if results != nil {
t.Error("expected nil results with empty key")
}
}
func TestSerperSearch_NoKey(t *testing.T) {
results := serperSearch("", "test", 5)
if results != nil {
t.Error("expected nil results with empty key")
}
}
func TestGetConfig_Defaults(t *testing.T) {
cfg := getConfig("", "")
if cfg.Provider != "tavily" {
t.Errorf("expected default provider 'tavily', got '%s'", cfg.Provider)
}
}