Enhance Search API with New V8 Binding Methods

- Introduced a new search object in the context to expose search methods (Web, KB, DB, All, Any, Race) for JavaScript integration.
- Implemented individual search methods with argument validation and error handling, improving the robustness of the API.
- Updated the JSAPI implementation to utilize the new search object, ensuring seamless interaction with the search functionalities.
- Enhanced documentation in DESIGN.md to reflect the new V8 binding methods and their usage, providing clear guidance for developers.
This commit is contained in:
Max 2025-12-13 16:11:30 +08:00
parent fc9e00c917
commit 7768ca73b3
7 changed files with 1198 additions and 92 deletions

View file

@ -25,8 +25,25 @@ func init() {
return &agentCallerWrapper{ast: ast}, nil return &agentCallerWrapper{ast: ast}, nil
} }
// Initialize Search JSAPI factory // Initialize Search JSAPI factory with config getter
search.SetJSAPIFactory() search.SetJSAPIFactory(func(assistantID string) (*searchTypes.Config, *search.Uses) {
ast, err := Get(assistantID)
if err != nil || ast == nil {
return nil, nil
}
// Convert assistant.Uses to search.Uses
var uses *search.Uses
if ast.Uses != nil {
uses = &search.Uses{
Search: ast.Uses.Search,
Web: ast.Uses.Web,
Keyword: ast.Uses.Keyword,
QueryDSL: ast.Uses.QueryDSL,
Rerank: ast.Uses.Rerank,
}
}
return ast.Search, uses
})
} }
// agentCallerWrapper wraps Assistant to implement AgentCaller interface // agentCallerWrapper wraps Assistant to implement AgentCaller interface

View file

@ -62,6 +62,9 @@ func (ctx *Context) NewObject(v8ctx *v8go.Context) (*v8go.Value, error) {
// Set mcp object // Set mcp object
jsObject.Set("mcp", ctx.newMCPObject(v8ctx.Isolate())) jsObject.Set("mcp", ctx.newMCPObject(v8ctx.Isolate()))
// Set search object
jsObject.Set("search", ctx.newSearchObject(v8ctx.Isolate()))
// Note: Space object will be set after instance creation (requires v8ctx) // Note: Space object will be set after instance creation (requires v8ctx)
// Create instance // Create instance

View file

@ -1,5 +1,10 @@
package context package context
import (
"github.com/yaoapp/gou/runtime/v8/bridge"
"rogchap.com/v8go"
)
// SearchAPI defines the search JSAPI interface for ctx.search.* // SearchAPI defines the search JSAPI interface for ctx.search.*
// This interface is defined here to avoid circular dependency between context and search packages. // This interface is defined here to avoid circular dependency between context and search packages.
// The actual implementation is in agent/search/jsapi.go // The actual implementation is in agent/search/jsapi.go
@ -37,3 +42,321 @@ func (ctx *Context) Search() SearchAPI {
} }
return SearchAPIFactory(ctx) return SearchAPIFactory(ctx)
} }
// newSearchObject creates a new search object with all search methods
// This is called from jsapi.go NewObject() to mount ctx.search
func (ctx *Context) newSearchObject(iso *v8go.Isolate) *v8go.ObjectTemplate {
searchObj := v8go.NewObjectTemplate(iso)
// Single search methods
searchObj.Set("Web", ctx.searchWebMethod(iso))
searchObj.Set("KB", ctx.searchKBMethod(iso))
searchObj.Set("DB", ctx.searchDBMethod(iso))
// Parallel search methods - inspired by JavaScript Promise
searchObj.Set("All", ctx.searchAllMethod(iso))
searchObj.Set("Any", ctx.searchAnyMethod(iso))
searchObj.Set("Race", ctx.searchRaceMethod(iso))
return searchObj
}
// searchWebMethod implements ctx.search.Web(query, options?)
// Options:
// - limit: number - max results (default: 10)
// - sites: string[] - restrict to specific sites
// - time_range: string - "day", "week", "month", "year"
// - rerank: { top_n: number } - rerank options
func (ctx *Context) searchWebMethod(iso *v8go.Isolate) *v8go.FunctionTemplate {
return v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value {
v8ctx := info.Context()
args := info.Args()
// Validate arguments
if len(args) < 1 {
return bridge.JsException(v8ctx, "Web requires query parameter")
}
// Get query string
if !args[0].IsString() {
return bridge.JsException(v8ctx, "query must be a string")
}
query := args[0].String()
// Parse options (optional)
var opts map[string]interface{}
if len(args) >= 2 && !args[1].IsUndefined() && !args[1].IsNull() {
goVal, err := bridge.GoValue(args[1], v8ctx)
if err != nil {
return bridge.JsException(v8ctx, "invalid options: "+err.Error())
}
if optsMap, ok := goVal.(map[string]interface{}); ok {
opts = optsMap
}
}
// Get search API
searchAPI := ctx.Search()
if searchAPI == nil {
return bridge.JsException(v8ctx, "search API not available")
}
// Execute search
result := searchAPI.Web(query, opts)
// Convert result to JS value
jsVal, err := bridge.JsValue(v8ctx, result)
if err != nil {
return bridge.JsException(v8ctx, "failed to convert result: "+err.Error())
}
return jsVal
})
}
// searchKBMethod implements ctx.search.KB(query, options?)
// Options:
// - collections: string[] - collection IDs
// - threshold: number - similarity threshold (0-1)
// - limit: number - max results
// - graph: boolean - enable graph association
// - rerank: { top_n: number } - rerank options
func (ctx *Context) searchKBMethod(iso *v8go.Isolate) *v8go.FunctionTemplate {
return v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value {
v8ctx := info.Context()
args := info.Args()
// Validate arguments
if len(args) < 1 {
return bridge.JsException(v8ctx, "KB requires query parameter")
}
// Get query string
if !args[0].IsString() {
return bridge.JsException(v8ctx, "query must be a string")
}
query := args[0].String()
// Parse options (optional)
var opts map[string]interface{}
if len(args) >= 2 && !args[1].IsUndefined() && !args[1].IsNull() {
goVal, err := bridge.GoValue(args[1], v8ctx)
if err != nil {
return bridge.JsException(v8ctx, "invalid options: "+err.Error())
}
if optsMap, ok := goVal.(map[string]interface{}); ok {
opts = optsMap
}
}
// Get search API
searchAPI := ctx.Search()
if searchAPI == nil {
return bridge.JsException(v8ctx, "search API not available")
}
// Execute search
result := searchAPI.KB(query, opts)
// Convert result to JS value
jsVal, err := bridge.JsValue(v8ctx, result)
if err != nil {
return bridge.JsException(v8ctx, "failed to convert result: "+err.Error())
}
return jsVal
})
}
// searchDBMethod implements ctx.search.DB(query, options?)
// Options:
// - models: string[] - model IDs
// - wheres: Where[] - pre-defined filters (GOU QueryDSL Where format)
// - orders: Order[] - sort orders (GOU QueryDSL Order format)
// - select: string[] - fields to return
// - limit: number - max results
// - rerank: { top_n: number } - rerank options
func (ctx *Context) searchDBMethod(iso *v8go.Isolate) *v8go.FunctionTemplate {
return v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value {
v8ctx := info.Context()
args := info.Args()
// Validate arguments
if len(args) < 1 {
return bridge.JsException(v8ctx, "DB requires query parameter")
}
// Get query string
if !args[0].IsString() {
return bridge.JsException(v8ctx, "query must be a string")
}
query := args[0].String()
// Parse options (optional)
var opts map[string]interface{}
if len(args) >= 2 && !args[1].IsUndefined() && !args[1].IsNull() {
goVal, err := bridge.GoValue(args[1], v8ctx)
if err != nil {
return bridge.JsException(v8ctx, "invalid options: "+err.Error())
}
if optsMap, ok := goVal.(map[string]interface{}); ok {
opts = optsMap
}
}
// Get search API
searchAPI := ctx.Search()
if searchAPI == nil {
return bridge.JsException(v8ctx, "search API not available")
}
// Execute search
result := searchAPI.DB(query, opts)
// Convert result to JS value
jsVal, err := bridge.JsValue(v8ctx, result)
if err != nil {
return bridge.JsException(v8ctx, "failed to convert result: "+err.Error())
}
return jsVal
})
}
// searchAllMethod implements ctx.search.All(requests)
// Waits for all searches to complete (like Promise.all)
// Each request should have:
// - type: string - "web", "kb", or "db"
// - query: string - search query
// - ... other type-specific options
func (ctx *Context) searchAllMethod(iso *v8go.Isolate) *v8go.FunctionTemplate {
return v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value {
v8ctx := info.Context()
args := info.Args()
// Validate arguments
if len(args) < 1 {
return bridge.JsException(v8ctx, "All requires requests parameter")
}
// Parse requests array
goVal, err := bridge.GoValue(args[0], v8ctx)
if err != nil {
return bridge.JsException(v8ctx, "invalid requests: "+err.Error())
}
requestsArray, ok := goVal.([]interface{})
if !ok {
return bridge.JsException(v8ctx, "requests must be an array")
}
// Get search API
searchAPI := ctx.Search()
if searchAPI == nil {
return bridge.JsException(v8ctx, "search API not available")
}
// Execute parallel search
results := searchAPI.All(requestsArray)
// Convert results to JS value
jsVal, err := bridge.JsValue(v8ctx, results)
if err != nil {
return bridge.JsException(v8ctx, "failed to convert results: "+err.Error())
}
return jsVal
})
}
// searchAnyMethod implements ctx.search.Any(requests)
// Returns when any search succeeds with results (like Promise.any)
// Each request should have:
// - type: string - "web", "kb", or "db"
// - query: string - search query
// - ... other type-specific options
func (ctx *Context) searchAnyMethod(iso *v8go.Isolate) *v8go.FunctionTemplate {
return v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value {
v8ctx := info.Context()
args := info.Args()
// Validate arguments
if len(args) < 1 {
return bridge.JsException(v8ctx, "Any requires requests parameter")
}
// Parse requests array
goVal, err := bridge.GoValue(args[0], v8ctx)
if err != nil {
return bridge.JsException(v8ctx, "invalid requests: "+err.Error())
}
requestsArray, ok := goVal.([]interface{})
if !ok {
return bridge.JsException(v8ctx, "requests must be an array")
}
// Get search API
searchAPI := ctx.Search()
if searchAPI == nil {
return bridge.JsException(v8ctx, "search API not available")
}
// Execute parallel search
results := searchAPI.Any(requestsArray)
// Convert results to JS value
jsVal, err := bridge.JsValue(v8ctx, results)
if err != nil {
return bridge.JsException(v8ctx, "failed to convert results: "+err.Error())
}
return jsVal
})
}
// searchRaceMethod implements ctx.search.Race(requests)
// Returns when any search completes (like Promise.race)
// Each request should have:
// - type: string - "web", "kb", or "db"
// - query: string - search query
// - ... other type-specific options
func (ctx *Context) searchRaceMethod(iso *v8go.Isolate) *v8go.FunctionTemplate {
return v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value {
v8ctx := info.Context()
args := info.Args()
// Validate arguments
if len(args) < 1 {
return bridge.JsException(v8ctx, "Race requires requests parameter")
}
// Parse requests array
goVal, err := bridge.GoValue(args[0], v8ctx)
if err != nil {
return bridge.JsException(v8ctx, "invalid requests: "+err.Error())
}
requestsArray, ok := goVal.([]interface{})
if !ok {
return bridge.JsException(v8ctx, "requests must be an array")
}
// Get search API
searchAPI := ctx.Search()
if searchAPI == nil {
return bridge.JsException(v8ctx, "search API not available")
}
// Execute parallel search
results := searchAPI.Race(requestsArray)
// Convert results to JS value
jsVal, err := bridge.JsValue(v8ctx, results)
if err != nil {
return bridge.JsException(v8ctx, "failed to convert results: "+err.Error())
}
return jsVal
})
}

View file

@ -0,0 +1,342 @@
package context_test
import (
stdContext "context"
"encoding/json"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yaoapp/yao/agent/assistant"
"github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/search/types"
"github.com/yaoapp/yao/agent/testutils"
oauthTypes "github.com/yaoapp/yao/openapi/oauth/types"
)
// Note: SearchAPIFactory is set by assistant.init() with proper config getter
// We import assistant package to ensure init() runs before tests
// newSearchTestContext creates a Context for search JSAPI testing
func newSearchTestContext(chatID, assistantID string) *context.Context {
authorized := &oauthTypes.AuthorizedInfo{
Subject: "test-user",
ClientID: "test-client-id",
Scope: "openid profile email",
SessionID: "test-session-id",
UserID: "test-user-123",
}
ctx := context.New(stdContext.Background(), authorized, chatID)
ctx.AssistantID = assistantID
ctx.Locale = "en-us"
ctx.Referer = context.RefererAPI
ctx.Accept = context.AcceptWebCUI
ctx.Metadata = make(map[string]interface{})
return ctx
}
// getResponseContent extracts the content from the first assistant message
func getResponseContent(res *context.HookCreateResponse) string {
if res == nil || len(res.Messages) == 0 {
return ""
}
for _, msg := range res.Messages {
if msg.Role == "assistant" {
if content, ok := msg.Content.(string); ok {
return content
}
}
}
return ""
}
// TestSearchJSAPI_Web tests ctx.search.Web() via Create Hook
func TestSearchJSAPI_Web(t *testing.T) {
testutils.Prepare(t)
defer testutils.Clean(t)
// Load the search-jsapi test assistant
agent, err := assistant.Get("tests.search-jsapi")
require.NoError(t, err, "Failed to get tests.search-jsapi assistant")
require.NotNil(t, agent.HookScript, "The tests.search-jsapi assistant has no script")
ctx := newSearchTestContext("chat-search-web", "tests.search-jsapi")
// Call Create hook with test:web command
res, _, err := agent.HookScript.Create(ctx, []context.Message{{Role: "user", Content: "test:web Yao App Engine"}})
require.NoError(t, err, "Create hook failed")
require.NotNil(t, res, "Expected non-nil response")
// Get response content from messages
content := getResponseContent(res)
require.NotEmpty(t, content, "Expected response content")
// Parse the JSON response
var result types.Result
err = json.Unmarshal([]byte(content), &result)
require.NoError(t, err, "Response should be valid JSON: %s", content)
// Verify result
assert.Equal(t, types.SearchTypeWeb, result.Type, "type should be web")
assert.Equal(t, "Yao App Engine", result.Query, "query should match")
assert.Empty(t, result.Error, "should not have error: %s", result.Error)
assert.Greater(t, len(result.Items), 0, "should have items")
t.Logf("Web search returned %d items", len(result.Items))
for i, item := range result.Items {
if i < 3 {
t.Logf(" [%s] %s - %s", item.CitationID, item.Title, item.URL)
}
}
}
// TestSearchJSAPI_WebWithSites tests ctx.search.Web() with site restriction
func TestSearchJSAPI_WebWithSites(t *testing.T) {
testutils.Prepare(t)
defer testutils.Clean(t)
agent, err := assistant.Get("tests.search-jsapi")
require.NoError(t, err)
require.NotNil(t, agent.HookScript)
ctx := newSearchTestContext("chat-search-web-sites", "tests.search-jsapi")
res, _, err := agent.HookScript.Create(ctx, []context.Message{{Role: "user", Content: "test:web_sites Yao App Engine"}})
require.NoError(t, err)
require.NotNil(t, res)
content := getResponseContent(res)
require.NotEmpty(t, content, "Expected response content")
var result types.Result
err = json.Unmarshal([]byte(content), &result)
require.NoError(t, err, "Response should be valid JSON: %s", content)
assert.Equal(t, types.SearchTypeWeb, result.Type)
assert.Empty(t, result.Error, "should not have error: %s", result.Error)
assert.Greater(t, len(result.Items), 0, "should have items")
// Verify all results are from allowed sites
allowedSites := []string{"github.com", "yaoapps.com"}
for _, item := range result.Items {
isAllowed := false
for _, site := range allowedSites {
if strings.Contains(item.URL, site) {
isAllowed = true
break
}
}
assert.True(t, isAllowed, "URL %s should be from allowed sites", item.URL)
}
t.Logf("Site-restricted search returned %d items", len(result.Items))
}
// TestSearchJSAPI_KB tests ctx.search.KB() via Create Hook (skeleton)
func TestSearchJSAPI_KB(t *testing.T) {
testutils.Prepare(t)
defer testutils.Clean(t)
agent, err := assistant.Get("tests.search-jsapi")
require.NoError(t, err)
require.NotNil(t, agent.HookScript)
ctx := newSearchTestContext("chat-search-kb", "tests.search-jsapi")
res, _, err := agent.HookScript.Create(ctx, []context.Message{{Role: "user", Content: "test:kb test query"}})
require.NoError(t, err)
require.NotNil(t, res)
content := getResponseContent(res)
require.NotEmpty(t, content, "Expected response content")
var result types.Result
err = json.Unmarshal([]byte(content), &result)
require.NoError(t, err, "Response should be valid JSON: %s", content)
assert.Equal(t, types.SearchTypeKB, result.Type, "type should be kb")
assert.Equal(t, "test query", result.Query, "query should match")
assert.Equal(t, types.SourceHook, result.Source, "source should be hook")
}
// TestSearchJSAPI_DB tests ctx.search.DB() via Create Hook (skeleton)
func TestSearchJSAPI_DB(t *testing.T) {
testutils.Prepare(t)
defer testutils.Clean(t)
agent, err := assistant.Get("tests.search-jsapi")
require.NoError(t, err)
require.NotNil(t, agent.HookScript)
ctx := newSearchTestContext("chat-search-db", "tests.search-jsapi")
res, _, err := agent.HookScript.Create(ctx, []context.Message{{Role: "user", Content: "test:db test query"}})
require.NoError(t, err)
require.NotNil(t, res)
content := getResponseContent(res)
require.NotEmpty(t, content, "Expected response content")
var result types.Result
err = json.Unmarshal([]byte(content), &result)
require.NoError(t, err, "Response should be valid JSON: %s", content)
assert.Equal(t, types.SearchTypeDB, result.Type, "type should be db")
assert.Equal(t, "test query", result.Query, "query should match")
assert.Equal(t, types.SourceHook, result.Source, "source should be hook")
}
// TestSearchJSAPI_All tests ctx.search.All() via Create Hook
func TestSearchJSAPI_All(t *testing.T) {
testutils.Prepare(t)
defer testutils.Clean(t)
agent, err := assistant.Get("tests.search-jsapi")
require.NoError(t, err)
require.NotNil(t, agent.HookScript)
ctx := newSearchTestContext("chat-search-all", "tests.search-jsapi")
res, _, err := agent.HookScript.Create(ctx, []context.Message{{Role: "user", Content: "test:all"}})
require.NoError(t, err)
require.NotNil(t, res)
content := getResponseContent(res)
require.NotEmpty(t, content, "Expected response content")
// Parse as array of results
var results []*types.Result
err = json.Unmarshal([]byte(content), &results)
require.NoError(t, err, "Response should be valid JSON array: %s", content)
assert.Len(t, results, 2, "should have 2 results")
// Both should succeed
successCount := 0
totalItems := 0
for _, r := range results {
if r != nil && r.Error == "" {
successCount++
totalItems += len(r.Items)
}
}
assert.Equal(t, 2, successCount, "both searches should succeed")
assert.Greater(t, totalItems, 0, "should have items")
t.Logf("All search: %d results, %d total items", len(results), totalItems)
}
// TestSearchJSAPI_Any tests ctx.search.Any() via Create Hook
func TestSearchJSAPI_Any(t *testing.T) {
testutils.Prepare(t)
defer testutils.Clean(t)
agent, err := assistant.Get("tests.search-jsapi")
require.NoError(t, err)
require.NotNil(t, agent.HookScript)
ctx := newSearchTestContext("chat-search-any", "tests.search-jsapi")
res, _, err := agent.HookScript.Create(ctx, []context.Message{{Role: "user", Content: "test:any"}})
require.NoError(t, err)
require.NotNil(t, res)
content := getResponseContent(res)
require.NotEmpty(t, content, "Expected response content")
var results []*types.Result
err = json.Unmarshal([]byte(content), &results)
require.NoError(t, err, "Response should be valid JSON array: %s", content)
assert.Len(t, results, 2, "should have 2 result slots")
// At least one should have results
hasSuccess := false
for _, r := range results {
if r != nil && len(r.Items) > 0 && r.Error == "" {
hasSuccess = true
break
}
}
assert.True(t, hasSuccess, "at least one search should succeed")
t.Logf("Any search completed")
}
// TestSearchJSAPI_Race tests ctx.search.Race() via Create Hook
func TestSearchJSAPI_Race(t *testing.T) {
testutils.Prepare(t)
defer testutils.Clean(t)
agent, err := assistant.Get("tests.search-jsapi")
require.NoError(t, err)
require.NotNil(t, agent.HookScript)
ctx := newSearchTestContext("chat-search-race", "tests.search-jsapi")
res, _, err := agent.HookScript.Create(ctx, []context.Message{{Role: "user", Content: "test:race"}})
require.NoError(t, err)
require.NotNil(t, res)
content := getResponseContent(res)
require.NotEmpty(t, content, "Expected response content")
var results []*types.Result
err = json.Unmarshal([]byte(content), &results)
require.NoError(t, err, "Response should be valid JSON array: %s", content)
assert.Len(t, results, 2, "should have 2 result slots")
// At least one should have completed
hasResult := false
for _, r := range results {
if r != nil {
hasResult = true
break
}
}
assert.True(t, hasResult, "at least one search should complete")
t.Logf("Race search completed")
}
// TestSearchJSAPI_InvalidCommand tests invalid test command
func TestSearchJSAPI_InvalidCommand(t *testing.T) {
testutils.Prepare(t)
defer testutils.Clean(t)
agent, err := assistant.Get("tests.search-jsapi")
require.NoError(t, err)
require.NotNil(t, agent.HookScript)
ctx := newSearchTestContext("chat-search-invalid", "tests.search-jsapi")
res, _, err := agent.HookScript.Create(ctx, []context.Message{{Role: "user", Content: "invalid command"}})
require.NoError(t, err)
require.NotNil(t, res)
content := getResponseContent(res)
assert.Contains(t, content, "Invalid test command", "should return error message")
}
// TestSearchJSAPI_UnknownMethod tests unknown test method
func TestSearchJSAPI_UnknownMethod(t *testing.T) {
testutils.Prepare(t)
defer testutils.Clean(t)
agent, err := assistant.Get("tests.search-jsapi")
require.NoError(t, err)
require.NotNil(t, agent.HookScript)
ctx := newSearchTestContext("chat-search-unknown", "tests.search-jsapi")
res, _, err := agent.HookScript.Create(ctx, []context.Message{{Role: "user", Content: "test:unknown"}})
require.NoError(t, err)
require.NotNil(t, res)
content := getResponseContent(res)
assert.Contains(t, content, "Unknown test method", "should return error message")
}

View file

@ -892,26 +892,44 @@ The Search module is exposed via `ctx.search` object in hook scripts.
To avoid circular dependency between `context` and `search` packages: To avoid circular dependency between `context` and `search` packages:
``` ```
agent/context/jsapi_search.go agent/search/jsapi.go agent/context/jsapi_search.go agent/search/jsapi.go
┌─────────────────────────┐ ┌─────────────────────────┐ ┌─────────────────────────────┐ ┌─────────────────────────┐
│ SearchAPI interface │◄──────│ JSAPI struct │ │ SearchAPI interface │◄───────│ JSAPI struct │
│ SearchAPIFactory var │ │ (implements SearchAPI) │ │ SearchAPIFactory var │ │ (implements SearchAPI) │
│ ctx.Search() method │ │ SetJSAPIFactory() │ │ V8 binding methods: │ │ NewJSAPI() │
└─────────────────────────┘ └─────────────────────────┘ │ newSearchObject() │ │ Web/KB/DB() │
▲ │ │ searchWebMethod() │ │ All/Any/Race() │
│ │ │ searchKBMethod() │ │ buildRequest() │
└──────────────────────────────────┘ │ searchDBMethod() │ │ parseRequests() │
│ searchAllMethod() │ │ ConfigGetter type │
│ searchAnyMethod() │ │ SetJSAPIFactory() │
│ searchRaceMethod() │ └─────────────────────────┘
└─────────────────────────────┘ │
▲ │
│ │
└───────────────────────────────────────┘
Factory registration Factory registration
(in assistant/init) (with ConfigGetter in assistant/init)
agent/context/jsapi.go
┌─────────────────────────────┐
│ NewObject() │
│ jsObject.Set("search", │
│ ctx.newSearchObject()) │
└─────────────────────────────┘
``` ```
**Key Files:** **Key Files:**
| File | Description | | File | Description |
| ----------------------------- | ------------------------------ | | -------------------------------- | ---------------------------------------------------------------- |
| `context/jsapi_search.go` | SearchAPI interface definition | | `context/jsapi_search.go` | SearchAPI interface + V8 binding methods |
| `search/jsapi.go` | JSAPI implementation | | `context/jsapi_search_test.go` | Integration tests (real V8 calls via test assistant) |
| `assistant/assistant.go:init` | Factory registration | | `context/jsapi.go` | Mount search object to ctx |
| `search/jsapi.go` | JSAPI implementation (calls Searcher) + ConfigGetter |
| `search/jsapi_test.go` | Black-box unit tests |
| `assistant/assistant.go:init` | Factory registration via SetJSAPIFactory(ConfigGetter) |
| `assistants/tests/search-jsapi/` | Test assistant for JSAPI integration tests (Create hook, no LLM) |
### API Methods ### API Methods

View file

@ -8,17 +8,15 @@ import (
// JSAPI implements context.SearchAPI interface // JSAPI implements context.SearchAPI interface
// Provides ctx.search.Web(), ctx.search.KB(), ctx.search.DB(), ctx.search.All(), ctx.search.Any(), ctx.search.Race() // Provides ctx.search.Web(), ctx.search.KB(), ctx.search.DB(), ctx.search.All(), ctx.search.Any(), ctx.search.Race()
type JSAPI struct { type JSAPI struct {
ctx *context.Context ctx *context.Context
config *types.Config searcher *Searcher
uses *Uses
} }
// NewJSAPI creates a new search JSAPI instance // NewJSAPI creates a new search JSAPI instance
func NewJSAPI(ctx *context.Context, config *types.Config, uses *Uses) *JSAPI { func NewJSAPI(ctx *context.Context, config *types.Config, uses *Uses) *JSAPI {
return &JSAPI{ return &JSAPI{
ctx: ctx, ctx: ctx,
config: config, searcher: New(config, uses),
uses: uses,
} }
} }
@ -29,15 +27,9 @@ func NewJSAPI(ctx *context.Context, config *types.Config, uses *Uses) *JSAPI {
// - time_range: string - "day", "week", "month", "year" // - time_range: string - "day", "week", "month", "year"
// - rerank: map[string]interface{} - rerank options // - rerank: map[string]interface{} - rerank options
func (api *JSAPI) Web(query string, opts map[string]interface{}) interface{} { func (api *JSAPI) Web(query string, opts map[string]interface{}) interface{} {
// TODO: Implement web search req := api.buildRequest(types.SearchTypeWeb, query, opts)
// 1. Build Request from query and opts result, _ := api.searcher.Search(api.ctx, req)
// 2. Call web handler return result
// 3. Return Result or error
return &types.Result{
Type: types.SearchTypeWeb,
Query: query,
Error: "not implemented",
}
} }
// KB executes knowledge base search // KB executes knowledge base search
@ -48,15 +40,9 @@ func (api *JSAPI) Web(query string, opts map[string]interface{}) interface{} {
// - graph: bool - enable graph association // - graph: bool - enable graph association
// - rerank: map[string]interface{} - rerank options // - rerank: map[string]interface{} - rerank options
func (api *JSAPI) KB(query string, opts map[string]interface{}) interface{} { func (api *JSAPI) KB(query string, opts map[string]interface{}) interface{} {
// TODO: Implement KB search req := api.buildRequest(types.SearchTypeKB, query, opts)
// 1. Build Request from query and opts result, _ := api.searcher.Search(api.ctx, req)
// 2. Call KB handler return result
// 3. Return Result or error
return &types.Result{
Type: types.SearchTypeKB,
Query: query,
Error: "not implemented",
}
} }
// DB executes database search // DB executes database search
@ -68,15 +54,9 @@ func (api *JSAPI) KB(query string, opts map[string]interface{}) interface{} {
// - limit: int - max results // - limit: int - max results
// - rerank: map[string]interface{} - rerank options // - rerank: map[string]interface{} - rerank options
func (api *JSAPI) DB(query string, opts map[string]interface{}) interface{} { func (api *JSAPI) DB(query string, opts map[string]interface{}) interface{} {
// TODO: Implement DB search req := api.buildRequest(types.SearchTypeDB, query, opts)
// 1. Build Request from query and opts result, _ := api.searcher.Search(api.ctx, req)
// 2. Call DB handler return result
// 3. Return Result or error
return &types.Result{
Type: types.SearchTypeDB,
Query: query,
Error: "not implemented",
}
} }
// All executes all searches and waits for all to complete (like Promise.all) // All executes all searches and waits for all to complete (like Promise.all)
@ -85,17 +65,9 @@ func (api *JSAPI) DB(query string, opts map[string]interface{}) interface{} {
// - query: string - search query // - query: string - search query
// - ... other type-specific options // - ... other type-specific options
func (api *JSAPI) All(requests []interface{}) []interface{} { func (api *JSAPI) All(requests []interface{}) []interface{} {
// TODO: Implement All search reqs := api.parseRequests(requests)
// 1. Parse requests into []Request results, _ := api.searcher.All(api.ctx, reqs)
// 2. Call Searcher.All() return api.convertResults(results)
// 3. Return []Result
results := make([]interface{}, len(requests))
for i := range requests {
results[i] = &types.Result{
Error: "not implemented",
}
}
return results
} }
// Any returns as soon as any search succeeds with results (like Promise.any) // Any returns as soon as any search succeeds with results (like Promise.any)
@ -104,17 +76,9 @@ func (api *JSAPI) All(requests []interface{}) []interface{} {
// - query: string - search query // - query: string - search query
// - ... other type-specific options // - ... other type-specific options
func (api *JSAPI) Any(requests []interface{}) []interface{} { func (api *JSAPI) Any(requests []interface{}) []interface{} {
// TODO: Implement Any search reqs := api.parseRequests(requests)
// 1. Parse requests into []Request results, _ := api.searcher.Any(api.ctx, reqs)
// 2. Call Searcher.Any() return api.convertResults(results)
// 3. Return []Result
results := make([]interface{}, len(requests))
for i := range requests {
results[i] = &types.Result{
Error: "not implemented",
}
}
return results
} }
// Race returns as soon as any search completes (like Promise.race) // Race returns as soon as any search completes (like Promise.race)
@ -123,32 +87,143 @@ func (api *JSAPI) Any(requests []interface{}) []interface{} {
// - query: string - search query // - query: string - search query
// - ... other type-specific options // - ... other type-specific options
func (api *JSAPI) Race(requests []interface{}) []interface{} { func (api *JSAPI) Race(requests []interface{}) []interface{} {
// TODO: Implement Race search reqs := api.parseRequests(requests)
// 1. Parse requests into []Request results, _ := api.searcher.Race(api.ctx, reqs)
// 2. Call Searcher.Race() return api.convertResults(results)
// 3. Return []Result
results := make([]interface{}, len(requests))
for i := range requests {
results[i] = &types.Result{
Error: "not implemented",
}
}
return results
} }
// init registers the JSAPI factory with context package // buildRequest builds a Request from query and options
func init() { func (api *JSAPI) buildRequest(searchType types.SearchType, query string, opts map[string]interface{}) *types.Request {
// Note: The actual factory is set by assistant package during initialization req := &types.Request{
// This avoids circular dependency: context -> search -> context Type: searchType,
// See: assistant/assistant.go init() Query: query,
Source: types.SourceHook, // JSAPI calls are from hooks
}
if opts == nil {
return req
}
// Common options
if limit, ok := opts["limit"].(float64); ok {
req.Limit = int(limit)
} else if limit, ok := opts["limit"].(int); ok {
req.Limit = limit
}
// Web-specific options
if searchType == types.SearchTypeWeb {
if sites, ok := opts["sites"].([]interface{}); ok {
req.Sites = toStringSlice(sites)
}
if timeRange, ok := opts["time_range"].(string); ok {
req.TimeRange = timeRange
}
}
// KB-specific options
if searchType == types.SearchTypeKB {
if collections, ok := opts["collections"].([]interface{}); ok {
req.Collections = toStringSlice(collections)
}
if threshold, ok := opts["threshold"].(float64); ok {
req.Threshold = threshold
}
if graph, ok := opts["graph"].(bool); ok {
req.Graph = graph
}
}
// DB-specific options
if searchType == types.SearchTypeDB {
if models, ok := opts["models"].([]interface{}); ok {
req.Models = toStringSlice(models)
}
if selectFields, ok := opts["select"].([]interface{}); ok {
req.Select = toStringSlice(selectFields)
}
// Note: wheres and orders are more complex, handled by QueryDSL generator
}
// Rerank options
if rerankOpts, ok := opts["rerank"].(map[string]interface{}); ok {
req.Rerank = &types.RerankOptions{}
if topN, ok := rerankOpts["top_n"].(float64); ok {
req.Rerank.TopN = int(topN)
} else if topN, ok := rerankOpts["top_n"].(int); ok {
req.Rerank.TopN = topN
}
}
return req
} }
// parseRequests parses an array of request objects into typed Requests
func (api *JSAPI) parseRequests(requests []interface{}) []*types.Request {
reqs := make([]*types.Request, 0, len(requests))
for _, r := range requests {
reqMap, ok := r.(map[string]interface{})
if !ok {
continue
}
// Get type
typeStr, ok := reqMap["type"].(string)
if !ok {
continue
}
searchType := types.SearchType(typeStr)
// Get query
query, ok := reqMap["query"].(string)
if !ok {
continue
}
// Build request with remaining options
req := api.buildRequest(searchType, query, reqMap)
reqs = append(reqs, req)
}
return reqs
}
// convertResults converts typed Results to interface slice for JS
func (api *JSAPI) convertResults(results []*types.Result) []interface{} {
out := make([]interface{}, len(results))
for i, r := range results {
out[i] = r
}
return out
}
// toStringSlice converts []interface{} to []string
func toStringSlice(arr []interface{}) []string {
result := make([]string, 0, len(arr))
for _, v := range arr {
if s, ok := v.(string); ok {
result = append(result, s)
}
}
return result
}
// ConfigGetter is a function type that retrieves search config and uses for an assistant
type ConfigGetter func(assistantID string) (*types.Config, *Uses)
// configGetter is set by assistant package during initialization
var configGetter ConfigGetter
// SetJSAPIFactory sets the factory function for creating SearchAPI instances // SetJSAPIFactory sets the factory function for creating SearchAPI instances
// Called by assistant package during initialization // Called by assistant package during initialization
func SetJSAPIFactory() { // getter: function to get search config and uses from assistant ID
func SetJSAPIFactory(getter ConfigGetter) {
configGetter = getter
context.SearchAPIFactory = func(ctx *context.Context) context.SearchAPI { context.SearchAPIFactory = func(ctx *context.Context) context.SearchAPI {
// Get config and uses from context or use defaults var config *types.Config
// TODO: Get actual config from assistant var uses *Uses
return NewJSAPI(ctx, nil, nil) if configGetter != nil && ctx.AssistantID != "" {
config, uses = configGetter(ctx.AssistantID)
}
return NewJSAPI(ctx, config, uses)
} }
} }

328
agent/search/jsapi_test.go Normal file
View file

@ -0,0 +1,328 @@
package search_test
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/search"
"github.com/yaoapp/yao/agent/search/types"
)
func TestNewJSAPI(t *testing.T) {
api := search.NewJSAPI(nil, nil, nil)
require.NotNil(t, api)
}
func TestJSAPI_Web(t *testing.T) {
api := search.NewJSAPI(nil, &types.Config{
Web: &types.WebConfig{Provider: "tavily"},
}, &search.Uses{Web: "builtin"})
result := api.Web("test query", nil)
require.NotNil(t, result)
r, ok := result.(*types.Result)
require.True(t, ok)
assert.Equal(t, types.SearchTypeWeb, r.Type)
assert.Equal(t, "test query", r.Query)
assert.Equal(t, types.SourceHook, r.Source)
}
func TestJSAPI_Web_WithOptions(t *testing.T) {
api := search.NewJSAPI(nil, &types.Config{
Web: &types.WebConfig{Provider: "tavily"},
}, &search.Uses{Web: "builtin"})
opts := map[string]interface{}{
"limit": float64(5),
"sites": []interface{}{"github.com", "stackoverflow.com"},
"time_range": "week",
}
result := api.Web("golang concurrency", opts)
require.NotNil(t, result)
r, ok := result.(*types.Result)
require.True(t, ok)
assert.Equal(t, types.SearchTypeWeb, r.Type)
assert.Equal(t, "golang concurrency", r.Query)
}
func TestJSAPI_KB(t *testing.T) {
api := search.NewJSAPI(nil, &types.Config{
KB: &types.KBConfig{Collections: []string{"docs"}},
}, nil)
result := api.KB("test query", nil)
require.NotNil(t, result)
r, ok := result.(*types.Result)
require.True(t, ok)
assert.Equal(t, types.SearchTypeKB, r.Type)
assert.Equal(t, "test query", r.Query)
assert.Equal(t, types.SourceHook, r.Source)
}
func TestJSAPI_KB_WithOptions(t *testing.T) {
api := search.NewJSAPI(nil, &types.Config{
KB: &types.KBConfig{Collections: []string{"docs"}},
}, nil)
opts := map[string]interface{}{
"collections": []interface{}{"docs", "faq"},
"threshold": 0.8,
"limit": float64(10),
"graph": true,
}
result := api.KB("knowledge base query", opts)
require.NotNil(t, result)
r, ok := result.(*types.Result)
require.True(t, ok)
assert.Equal(t, types.SearchTypeKB, r.Type)
assert.Equal(t, "knowledge base query", r.Query)
}
func TestJSAPI_DB(t *testing.T) {
api := search.NewJSAPI(nil, &types.Config{
DB: &types.DBConfig{Models: []string{"product"}},
}, &search.Uses{QueryDSL: "builtin"})
result := api.DB("test query", nil)
require.NotNil(t, result)
r, ok := result.(*types.Result)
require.True(t, ok)
assert.Equal(t, types.SearchTypeDB, r.Type)
assert.Equal(t, "test query", r.Query)
assert.Equal(t, types.SourceHook, r.Source)
}
func TestJSAPI_DB_WithOptions(t *testing.T) {
api := search.NewJSAPI(nil, &types.Config{
DB: &types.DBConfig{Models: []string{"product"}},
}, &search.Uses{QueryDSL: "builtin"})
opts := map[string]interface{}{
"models": []interface{}{"product", "order"},
"select": []interface{}{"id", "name", "price"},
"limit": float64(20),
}
result := api.DB("database query", opts)
require.NotNil(t, result)
r, ok := result.(*types.Result)
require.True(t, ok)
assert.Equal(t, types.SearchTypeDB, r.Type)
assert.Equal(t, "database query", r.Query)
}
func TestJSAPI_All(t *testing.T) {
api := search.NewJSAPI(nil, &types.Config{
KB: &types.KBConfig{Collections: []string{"docs"}},
DB: &types.DBConfig{Models: []string{"product"}},
}, nil)
requests := []interface{}{
map[string]interface{}{
"type": "kb",
"query": "KB query",
},
map[string]interface{}{
"type": "db",
"query": "DB query",
},
}
results := api.All(requests)
require.Len(t, results, 2)
// First result (KB)
r0, ok := results[0].(*types.Result)
require.True(t, ok)
assert.Equal(t, types.SearchTypeKB, r0.Type)
assert.Equal(t, "KB query", r0.Query)
// Second result (DB)
r1, ok := results[1].(*types.Result)
require.True(t, ok)
assert.Equal(t, types.SearchTypeDB, r1.Type)
assert.Equal(t, "DB query", r1.Query)
}
func TestJSAPI_Any(t *testing.T) {
api := search.NewJSAPI(nil, &types.Config{
KB: &types.KBConfig{Collections: []string{"docs"}},
DB: &types.DBConfig{Models: []string{"product"}},
}, nil)
requests := []interface{}{
map[string]interface{}{
"type": "kb",
"query": "KB query",
},
map[string]interface{}{
"type": "db",
"query": "DB query",
},
}
results := api.Any(requests)
require.Len(t, results, 2)
// At least one result should be present
hasResult := false
for _, r := range results {
if r != nil {
hasResult = true
break
}
}
assert.True(t, hasResult)
}
func TestJSAPI_Race(t *testing.T) {
api := search.NewJSAPI(nil, &types.Config{
KB: &types.KBConfig{Collections: []string{"docs"}},
DB: &types.DBConfig{Models: []string{"product"}},
}, nil)
requests := []interface{}{
map[string]interface{}{
"type": "kb",
"query": "KB query",
},
map[string]interface{}{
"type": "db",
"query": "DB query",
},
}
results := api.Race(requests)
require.Len(t, results, 2)
// At least one result should be present
hasResult := false
for _, r := range results {
if r != nil {
hasResult = true
break
}
}
assert.True(t, hasResult)
}
func TestJSAPI_All_Empty(t *testing.T) {
api := search.NewJSAPI(nil, nil, nil)
results := api.All([]interface{}{})
assert.Len(t, results, 0)
}
func TestJSAPI_Any_Empty(t *testing.T) {
api := search.NewJSAPI(nil, nil, nil)
results := api.Any([]interface{}{})
assert.Len(t, results, 0)
}
func TestJSAPI_Race_Empty(t *testing.T) {
api := search.NewJSAPI(nil, nil, nil)
results := api.Race([]interface{}{})
assert.Len(t, results, 0)
}
func TestJSAPI_Web_WithRerank(t *testing.T) {
api := search.NewJSAPI(nil, &types.Config{
Web: &types.WebConfig{Provider: "tavily"},
}, &search.Uses{Web: "builtin"})
opts := map[string]interface{}{
"limit": float64(10),
"rerank": map[string]interface{}{
"top_n": float64(5),
},
}
result := api.Web("test query", opts)
require.NotNil(t, result)
r, ok := result.(*types.Result)
require.True(t, ok)
assert.Equal(t, types.SearchTypeWeb, r.Type)
}
func TestJSAPI_All_InvalidRequests(t *testing.T) {
api := search.NewJSAPI(nil, &types.Config{
Web: &types.WebConfig{Provider: "tavily"},
}, &search.Uses{Web: "builtin"})
// Mix of invalid and valid requests
requests := []interface{}{
"invalid", // Not a map
map[string]interface{}{
"query": "no type", // Missing type
},
map[string]interface{}{
"type": "web", // Missing query
},
map[string]interface{}{
"type": "web",
"query": "valid query",
},
}
results := api.All(requests)
// Only the valid request should produce a result
assert.Len(t, results, 1)
}
func TestSetJSAPIFactory(t *testing.T) {
// Reset factory
context.SearchAPIFactory = nil
// Set factory with nil getter (uses defaults)
search.SetJSAPIFactory(nil)
// Verify factory is set
require.NotNil(t, context.SearchAPIFactory)
// Create a mock context
ctx := &context.Context{}
// Get search API
searchAPI := context.SearchAPIFactory(ctx)
require.NotNil(t, searchAPI)
}
func TestSetJSAPIFactory_WithGetter(t *testing.T) {
// Reset factory
context.SearchAPIFactory = nil
// Set factory with custom getter
search.SetJSAPIFactory(func(assistantID string) (*types.Config, *search.Uses) {
if assistantID == "test-assistant" {
return &types.Config{
Web: &types.WebConfig{Provider: "tavily"},
}, &search.Uses{Web: "builtin"}
}
return nil, nil
})
// Verify factory is set
require.NotNil(t, context.SearchAPIFactory)
// Create a context with assistant ID
ctx := &context.Context{AssistantID: "test-assistant"}
// Get search API
searchAPI := context.SearchAPIFactory(ctx)
require.NotNil(t, searchAPI)
}
func TestJSAPI_ImplementsSearchAPI(t *testing.T) {
// Verify JSAPI implements context.SearchAPI interface
var _ context.SearchAPI = search.NewJSAPI(nil, nil, nil)
}