mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-11 12:18:59 +00:00
feat(tools): add optional Parallel web search provider
Signed-off-by: georgeatparallel <george@parallel.ai>
This commit is contained in:
1 parent
149bb478ab
commit
eef6f27775
11 files changed
+395
-24
No files matched your search
@@ -30,6 +30,7 @@ const (
|
||||
searchProviderExa = "exa"
|
||||
searchProviderTavily = "tavily"
|
||||
searchProviderBrave = "brave"
|
||||
searchProviderParallel = "parallel"
|
||||
searchProviderDuckDuckGo = "duckduckgo"
|
||||
)
|
||||
|
||||
@@ -165,7 +166,7 @@ func (t *WebSearchTool) Parameters() map[string]any {
|
||||
},
|
||||
"provider": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Optional: force a specific provider (e.g., 'tavily', 'exa', 'brave', 'duckduckgo'). When omitted, the tenant's configured provider chain is used (first-success-wins). Use this to force cross-engine corroboration — call once with each provider and compare results.",
|
||||
"description": "Optional: force a specific provider (e.g., 'tavily', 'exa', 'brave', 'parallel', 'duckduckgo'). When omitted, the tenant's configured provider chain is used (first-success-wins). Use this to force cross-engine corroboration — call once with each provider and compare results.",
|
||||
},
|
||||
},
|
||||
"required": []string{"query"},
|
||||
|
||||
@@ -191,6 +191,16 @@ func BuildChainFromStorage(ctx context.Context, secrets store.ConfigSecretsStore
|
||||
if isDisabled(name) {
|
||||
continue
|
||||
}
|
||||
if name == searchProviderParallel {
|
||||
// Parallel is keyless and intentionally absent from the default
|
||||
// order. Reaching this branch means the tenant explicitly named it.
|
||||
maxResults := defaultSearchCount
|
||||
if po, ok := override.Providers[name]; ok && po.MaxResults > 0 {
|
||||
maxResults = po.MaxResults
|
||||
}
|
||||
chain = append(chain, buildProviderByName(name, "", maxResults))
|
||||
continue
|
||||
}
|
||||
|
||||
key, err := secrets.Get(ctx, "tools.web."+name+".api_key")
|
||||
if err != nil || key == "" {
|
||||
|
||||
@@ -146,10 +146,10 @@ func TestBuildChainFromStorage(t *testing.T) {
|
||||
wantLen: 3,
|
||||
},
|
||||
{
|
||||
name: "scenario 3: override brave only, no brave key → [duckduckgo] (DDG always present)",
|
||||
tenantID: uuid.New(),
|
||||
override: `{"provider_order":["brave"]}`,
|
||||
secrets: map[string]string{},
|
||||
name: "scenario 3: override brave only, no brave key → [duckduckgo] (DDG always present)",
|
||||
tenantID: uuid.New(),
|
||||
override: `{"provider_order":["brave"]}`,
|
||||
secrets: map[string]string{},
|
||||
wantNames: []string{"duckduckgo"},
|
||||
wantLen: 1,
|
||||
},
|
||||
@@ -164,10 +164,10 @@ func TestBuildChainFromStorage(t *testing.T) {
|
||||
wantLen: 2,
|
||||
},
|
||||
{
|
||||
name: "scenario 5: DDG explicitly disabled → still present (force-enabled)",
|
||||
tenantID: uuid.New(),
|
||||
override: `{"duckduckgo":{"enabled":false}}`,
|
||||
secrets: map[string]string{},
|
||||
name: "scenario 5: DDG explicitly disabled → still present (force-enabled)",
|
||||
tenantID: uuid.New(),
|
||||
override: `{"duckduckgo":{"enabled":false}}`,
|
||||
secrets: map[string]string{},
|
||||
wantNames: []string{"duckduckgo"},
|
||||
wantLen: 1,
|
||||
},
|
||||
@@ -181,6 +181,22 @@ func TestBuildChainFromStorage(t *testing.T) {
|
||||
wantNames: []string{"duckduckgo"},
|
||||
wantLen: 1,
|
||||
},
|
||||
{
|
||||
name: "explicit Parallel needs no secret and stays before DDG",
|
||||
tenantID: uuid.New(),
|
||||
override: `{"provider_order":["parallel"],"parallel":{"enabled":true,"max_results":3}}`,
|
||||
secrets: map[string]string{},
|
||||
wantNames: []string{"parallel", "duckduckgo"},
|
||||
wantLen: 2,
|
||||
},
|
||||
{
|
||||
name: "explicitly disabled Parallel is skipped",
|
||||
tenantID: uuid.New(),
|
||||
override: `{"provider_order":["parallel"],"parallel":{"enabled":false}}`,
|
||||
secrets: map[string]string{},
|
||||
wantNames: []string{"duckduckgo"},
|
||||
wantLen: 1,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
@@ -19,6 +19,8 @@ func buildProviderByName(name, apiKey string, maxResults int) SearchProvider {
|
||||
return newTavilySearchProvider(apiKey, maxResults)
|
||||
case searchProviderBrave:
|
||||
return newBraveSearchProvider(apiKey, maxResults)
|
||||
case searchProviderParallel:
|
||||
return newParallelSearchProvider(maxResults)
|
||||
case searchProviderDuckDuckGo:
|
||||
return newDuckDuckGoSearchProvider(maxResults)
|
||||
default:
|
||||
@@ -57,7 +59,7 @@ func NormalizeWebSearchProviderOrder(order []string) []string {
|
||||
}
|
||||
|
||||
func isKnownSearchProvider(id string) bool {
|
||||
return slices.Contains(defaultSearchProviderOrder, id)
|
||||
return id == searchProviderParallel || slices.Contains(defaultSearchProviderOrder, id)
|
||||
}
|
||||
|
||||
// --- Shared provider helpers ---
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const parallelSearchEndpoint = "https://search.parallel.ai/mcp"
|
||||
|
||||
type parallelSearchProvider struct {
|
||||
maxResults int
|
||||
client *http.Client
|
||||
endpoint string
|
||||
}
|
||||
|
||||
func newParallelSearchProvider(maxResults int) *parallelSearchProvider {
|
||||
return ¶llelSearchProvider{
|
||||
maxResults: normalizeProviderMaxResults(maxResults),
|
||||
client: &http.Client{Timeout: time.Duration(searchTimeoutSeconds) * time.Second},
|
||||
endpoint: parallelSearchEndpoint,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *parallelSearchProvider) Name() string { return searchProviderParallel }
|
||||
|
||||
type parallelRPCResponse struct {
|
||||
Result struct {
|
||||
Content []struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
} `json:"content"`
|
||||
IsError bool `json:"isError"`
|
||||
} `json:"result"`
|
||||
Error *struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
|
||||
type parallelSearchPayload struct {
|
||||
Results []struct {
|
||||
URL string `json:"url"`
|
||||
Title string `json:"title"`
|
||||
Excerpts []string `json:"excerpts"`
|
||||
} `json:"results"`
|
||||
}
|
||||
|
||||
func (p *parallelSearchProvider) Search(ctx context.Context, params searchParams) ([]searchResult, error) {
|
||||
limit := clampProviderResultCount(params.Count, p.maxResults)
|
||||
requestBody, err := json.Marshal(map[string]any{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "tools/call",
|
||||
"params": map[string]any{
|
||||
"name": "web_search",
|
||||
"arguments": map[string]any{
|
||||
"objective": params.Query,
|
||||
"search_queries": []string{params.Query},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal request: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.endpoint, bytes.NewReader(requestBody))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json, text/event-stream")
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", webSearchUserAgent)
|
||||
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("parallel Search MCP returned %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var rpc parallelRPCResponse
|
||||
if err := json.NewDecoder(io.LimitReader(resp.Body, 8<<20)).Decode(&rpc); err != nil {
|
||||
return nil, fmt.Errorf("parse response: %w", err)
|
||||
}
|
||||
if rpc.Error != nil {
|
||||
return nil, fmt.Errorf("parallel Search MCP error %d: %s", rpc.Error.Code, rpc.Error.Message)
|
||||
}
|
||||
if rpc.Result.IsError {
|
||||
return nil, fmt.Errorf("parallel web_search returned an error")
|
||||
}
|
||||
|
||||
results := make([]searchResult, 0, limit)
|
||||
foundText := false
|
||||
for _, content := range rpc.Result.Content {
|
||||
if content.Type != "text" || strings.TrimSpace(content.Text) == "" {
|
||||
continue
|
||||
}
|
||||
foundText = true
|
||||
|
||||
var payload parallelSearchPayload
|
||||
if err := json.Unmarshal([]byte(content.Text), &payload); err != nil {
|
||||
return nil, fmt.Errorf("parse search results: %w", err)
|
||||
}
|
||||
for _, raw := range payload.Results {
|
||||
if len(results) >= limit {
|
||||
break
|
||||
}
|
||||
url := strings.TrimSpace(raw.URL)
|
||||
if url == "" {
|
||||
continue
|
||||
}
|
||||
description := ""
|
||||
for _, excerpt := range raw.Excerpts {
|
||||
if excerpt = strings.TrimSpace(excerpt); excerpt != "" {
|
||||
description = truncateStr(excerpt, 240)
|
||||
break
|
||||
}
|
||||
}
|
||||
results = append(results, searchResult{
|
||||
Title: coalesceSearchText(raw.Title, url, "Untitled"),
|
||||
URL: url,
|
||||
Description: description,
|
||||
})
|
||||
}
|
||||
if len(results) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundText {
|
||||
return nil, fmt.Errorf("parallel Search MCP response contained no text results")
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestParallelSearchRequestAndResponse(t *testing.T) {
|
||||
t.Parallel()
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Errorf("method = %q, want POST", r.Method)
|
||||
}
|
||||
if got := r.Header.Get("Content-Type"); got != "application/json" {
|
||||
t.Errorf("Content-Type = %q, want application/json", got)
|
||||
}
|
||||
|
||||
var body struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
Method string `json:"method"`
|
||||
Params struct {
|
||||
Name string `json:"name"`
|
||||
Arguments struct {
|
||||
Objective string `json:"objective"`
|
||||
SearchQueries []string `json:"search_queries"`
|
||||
} `json:"arguments"`
|
||||
} `json:"params"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode request: %v", err)
|
||||
}
|
||||
if body.JSONRPC != "2.0" || body.Method != "tools/call" || body.Params.Name != "web_search" {
|
||||
t.Errorf("unexpected RPC envelope: %+v", body)
|
||||
}
|
||||
if body.Params.Arguments.Objective != "current Go release" {
|
||||
t.Errorf("objective = %q", body.Params.Arguments.Objective)
|
||||
}
|
||||
if want := []string{"current Go release"}; !reflect.DeepEqual(body.Params.Arguments.SearchQueries, want) {
|
||||
t.Errorf("search_queries = %v, want %v", body.Params.Arguments.SearchQueries, want)
|
||||
}
|
||||
|
||||
writeParallelSearchResponse(t, w, `{"results":[
|
||||
{"url":"https://go.dev/doc/devel/release","title":"Go releases","excerpts":[" Current release notes. ","More context."]},
|
||||
{"url":"https://go.dev/","title":"The Go Programming Language","excerpts":[]}
|
||||
]}`)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
provider := ¶llelSearchProvider{maxResults: 5, client: server.Client(), endpoint: server.URL}
|
||||
results, err := provider.Search(context.Background(), searchParams{Query: "current Go release", Count: 2})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []searchResult{
|
||||
{Title: "Go releases", URL: "https://go.dev/doc/devel/release", Description: "Current release notes."},
|
||||
{Title: "The Go Programming Language", URL: "https://go.dev/"},
|
||||
}
|
||||
if !reflect.DeepEqual(results, want) {
|
||||
t.Fatalf("results = %#v, want %#v", results, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParallelSearchAppliesLimitAndSkipsMissingURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
writeParallelSearchResponse(t, w, `{"results":[
|
||||
{"url":"","title":"missing URL","excerpts":["skip"]},
|
||||
{"url":"https://example.com/1","title":"one","excerpts":["one"]},
|
||||
{"url":"https://example.com/2","title":"two","excerpts":["two"]}
|
||||
]}`)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
provider := ¶llelSearchProvider{maxResults: 1, client: server.Client(), endpoint: server.URL}
|
||||
results, err := provider.Search(context.Background(), searchParams{Query: "q", Count: 5})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(results) != 1 || results[0].Title != "one" {
|
||||
t.Fatalf("results = %#v, want first valid result", results)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParallelSearchResponseErrors(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
want string
|
||||
}{
|
||||
{name: "malformed envelope", body: `{`, want: "parse response"},
|
||||
{name: "json-rpc error", body: `{"jsonrpc":"2.0","id":1,"error":{"code":-32602,"message":"bad arguments"}}`, want: "error -32602"},
|
||||
{name: "tool error", body: `{"jsonrpc":"2.0","id":1,"result":{"isError":true,"content":[]}}`, want: "returned an error"},
|
||||
{name: "missing text", body: `{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"image"}]}}`, want: "no text results"},
|
||||
{name: "malformed nested results", body: `{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"{"}]}}`, want: "parse search results"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, tc.body)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
provider := ¶llelSearchProvider{maxResults: 1, client: server.Client(), endpoint: server.URL}
|
||||
_, err := provider.Search(context.Background(), searchParams{Query: "q", Count: 1})
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("error = %v, want containing %q", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParallelSearchHTTPError(t *testing.T) {
|
||||
t.Parallel()
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
provider := ¶llelSearchProvider{maxResults: 1, client: server.Client(), endpoint: server.URL}
|
||||
_, err := provider.Search(context.Background(), searchParams{Query: "q", Count: 1})
|
||||
if err == nil || !strings.Contains(err.Error(), "returned 503") {
|
||||
t.Fatalf("error = %v, want status 503", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParallelSearchPropagatesCancellation(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
|
||||
defer cancel()
|
||||
client := &http.Client{Transport: parallelRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
<-req.Context().Done()
|
||||
return nil, req.Context().Err()
|
||||
})}
|
||||
provider := ¶llelSearchProvider{maxResults: 1, client: client, endpoint: "https://example.com/mcp"}
|
||||
_, err := provider.Search(ctx, searchParams{Query: "q", Count: 1})
|
||||
if !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatalf("error = %v, want deadline exceeded", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParallelProviderIsExplicitOnly(t *testing.T) {
|
||||
if got := NormalizeWebSearchProviderOrder(nil); containsString(got, searchProviderParallel) {
|
||||
t.Fatalf("default provider order unexpectedly contains parallel: %v", got)
|
||||
}
|
||||
got := NormalizeWebSearchProviderOrder([]string{searchProviderParallel})
|
||||
if len(got) == 0 || got[0] != searchProviderParallel {
|
||||
t.Fatalf("explicit provider order = %v, want parallel first", got)
|
||||
}
|
||||
if provider := buildProviderByName(searchProviderParallel, "", 3); provider == nil || provider.Name() != searchProviderParallel {
|
||||
t.Fatalf("parallel provider construction failed: %T", provider)
|
||||
}
|
||||
}
|
||||
|
||||
func containsString(values []string, target string) bool {
|
||||
for _, value := range values {
|
||||
if value == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type parallelRoundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f parallelRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return f(req)
|
||||
}
|
||||
|
||||
func writeParallelSearchResponse(t *testing.T, w http.ResponseWriter, payload string) {
|
||||
t.Helper()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(map[string]any{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"result": map[string]any{
|
||||
"content": []map[string]string{{"type": "text", "text": payload}},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("encode response: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -156,7 +156,7 @@
|
||||
},
|
||||
"searchChain": {
|
||||
"title": "Web Search Provider Chain",
|
||||
"description": "Drag to reorder search providers. First enabled provider with a valid API key is tried first.",
|
||||
"description": "Drag to reorder search providers. The first enabled and available provider is tried first.",
|
||||
"maxResults": "Max results",
|
||||
"locked": "Always-on fallback",
|
||||
"cancel": "Cancel",
|
||||
@@ -166,6 +166,7 @@
|
||||
"exa": "Exa",
|
||||
"tavily": "Tavily",
|
||||
"brave": "Brave Search",
|
||||
"parallel": "Parallel",
|
||||
"duckduckgo": "DuckDuckGo"
|
||||
},
|
||||
"apiKey": "API Key",
|
||||
|
||||
@@ -155,7 +155,7 @@
|
||||
},
|
||||
"searchChain": {
|
||||
"title": "Цепочка провайдеров веб-поиска",
|
||||
"description": "Перетащите, чтобы изменить порядок провайдеров поиска. Первым пробуется первый включённый провайдер с корректным ключом API.",
|
||||
"description": "Перетащите, чтобы изменить порядок провайдеров поиска. Первым пробуется первый включённый и доступный провайдер.",
|
||||
"maxResults": "Макс. результатов",
|
||||
"locked": "Постоянный резервный вариант",
|
||||
"cancel": "Отмена",
|
||||
@@ -165,6 +165,7 @@
|
||||
"exa": "Exa",
|
||||
"tavily": "Tavily",
|
||||
"brave": "Brave Search",
|
||||
"parallel": "Parallel",
|
||||
"duckduckgo": "DuckDuckGo"
|
||||
},
|
||||
"apiKey": "Ключ API",
|
||||
|
||||
@@ -156,7 +156,7 @@
|
||||
},
|
||||
"searchChain": {
|
||||
"title": "Chuỗi nhà cung cấp tìm kiếm web",
|
||||
"description": "Kéo để sắp xếp lại nhà cung cấp tìm kiếm. Nhà cung cấp đầu tiên được bật với API key hợp lệ sẽ được thử trước.",
|
||||
"description": "Kéo để sắp xếp lại nhà cung cấp tìm kiếm. Nhà cung cấp đầu tiên được bật và khả dụng sẽ được thử trước.",
|
||||
"maxResults": "Số kết quả tối đa",
|
||||
"locked": "Dự phòng luôn bật",
|
||||
"cancel": "Hủy",
|
||||
@@ -166,6 +166,7 @@
|
||||
"exa": "Exa",
|
||||
"tavily": "Tavily",
|
||||
"brave": "Brave Search",
|
||||
"parallel": "Parallel",
|
||||
"duckduckgo": "DuckDuckGo"
|
||||
},
|
||||
"apiKey": "API Key",
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
},
|
||||
"searchChain": {
|
||||
"title": "网页搜索 Provider 链",
|
||||
"description": "拖动以重新排序搜索 Provider。第一个已启用且具有有效 API Key 的 Provider 将被优先尝试。",
|
||||
"description": "拖动以重新排序搜索 Provider。第一个已启用且可用的 Provider 将被优先尝试。",
|
||||
"maxResults": "最大结果数",
|
||||
"locked": "始终启用的回退",
|
||||
"cancel": "取消",
|
||||
@@ -80,6 +80,7 @@
|
||||
"exa": "Exa",
|
||||
"tavily": "Tavily",
|
||||
"brave": "Brave Search",
|
||||
"parallel": "Parallel",
|
||||
"duckduckgo": "DuckDuckGo"
|
||||
},
|
||||
"apiKey": "API Key",
|
||||
|
||||
@@ -25,7 +25,7 @@ import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
|
||||
|
||||
type ProviderKey = "exa" | "tavily" | "brave" | "duckduckgo";
|
||||
type ProviderKey = "exa" | "tavily" | "brave" | "parallel" | "duckduckgo";
|
||||
|
||||
interface ProviderEntry {
|
||||
id: string;
|
||||
@@ -44,30 +44,34 @@ interface Props {
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const SORTABLE_PROVIDERS: ProviderKey[] = ["exa", "tavily", "brave"];
|
||||
const SORTABLE_PROVIDERS: ProviderKey[] = ["exa", "tavily", "brave", "parallel"];
|
||||
const LOCKED_PROVIDER: ProviderKey = "duckduckgo";
|
||||
const DEFAULT_ORDER: ProviderKey[] = ["exa", "tavily", "brave"];
|
||||
const DEFAULT_ORDER: ProviderKey[] = ["exa", "tavily", "brave", "parallel"];
|
||||
|
||||
const RAIL_COLOR: Record<ProviderKey, string> = {
|
||||
exa: "bg-blue-600",
|
||||
tavily: "bg-cyan-500",
|
||||
brave: "bg-orange-500",
|
||||
parallel: "bg-violet-500",
|
||||
duckduckgo: "bg-slate-500",
|
||||
};
|
||||
|
||||
function parseInitialEntries(settings: Record<string, unknown>): ProviderEntry[] {
|
||||
const rawOrder = Array.isArray(settings.provider_order)
|
||||
const savedOrder = Array.isArray(settings.provider_order)
|
||||
? (settings.provider_order as string[]).filter((p): p is ProviderKey =>
|
||||
SORTABLE_PROVIDERS.includes(p as ProviderKey),
|
||||
)
|
||||
: DEFAULT_ORDER;
|
||||
const rawOrder: ProviderKey[] = savedOrder.includes("parallel")
|
||||
? savedOrder
|
||||
: [...savedOrder, "parallel"];
|
||||
|
||||
return rawOrder.map((name) => {
|
||||
const cfg = (settings[name] ?? {}) as Record<string, unknown>;
|
||||
return {
|
||||
id: uniqueId(),
|
||||
name,
|
||||
enabled: Boolean(cfg.enabled ?? true),
|
||||
enabled: Boolean(cfg.enabled ?? name !== "parallel"),
|
||||
max_results: cfg.max_results != null ? Number(cfg.max_results) : undefined,
|
||||
};
|
||||
});
|
||||
@@ -140,8 +144,8 @@ function SortableProviderCard({ entry, index, secretsSet, onUpdate }: SortableCa
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* API key row */}
|
||||
<div className="flex items-center gap-1.5 mt-2 pl-10">
|
||||
{/* API key row — Parallel's hosted Search MCP is keyless. */}
|
||||
{entry.name !== "parallel" && <div className="flex items-center gap-1.5 mt-2 pl-10">
|
||||
<Label className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{t("builtin.searchChain.apiKey")}
|
||||
</Label>
|
||||
@@ -173,7 +177,7 @@ function SortableProviderCard({ entry, index, secretsSet, onUpdate }: SortableCa
|
||||
className="h-7 flex-1 text-base md:text-sm font-mono"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -190,7 +194,7 @@ function LockedDuckDuckGoCard({ settings }: { settings: Record<string, unknown>
|
||||
<div className="flex-1 px-3 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Lock className="size-4 text-muted-foreground shrink-0" />
|
||||
<span className="text-xs text-muted-foreground font-mono shrink-0">#4</span>
|
||||
<span className="text-xs text-muted-foreground font-mono shrink-0">#5</span>
|
||||
<Switch size="sm" checked disabled />
|
||||
<span className="text-sm font-medium flex-1">
|
||||
{t("builtin.searchChain.providers.duckduckgo")}
|
||||
@@ -238,7 +242,7 @@ export function WebSearchChainForm({ initialSettings, secretsSet, onSave, onCanc
|
||||
const cfg: Record<string, unknown> = { enabled: entry.enabled };
|
||||
if (entry.max_results != null) cfg.max_results = entry.max_results;
|
||||
// Include api_key only when user typed a new value — backend extracts and strips it
|
||||
if (entry.apiKey && entry.apiKey.trim() !== "") {
|
||||
if (entry.name !== "parallel" && entry.apiKey && entry.apiKey.trim() !== "") {
|
||||
cfg.api_key = entry.apiKey.trim();
|
||||
}
|
||||
settings[entry.name] = cfg;
|
||||
|
||||
Reference in new issue
Block a user