初始化提交

This commit is contained in:
chenxin committed 2026-08-21 16:00:13 +08:00
commit 887c1f449e
963 files changed
+166869

No files matched your search

+125
View File
@@ -0,0 +1,125 @@
package providers
import (
"context"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/url"
"time"
"github.com/chenxin/hunjing/mcp/web-search/internal/models"
)
const braveAPIBase = "https://api.search.brave.com"
// BraveProvider 实现 SearchProvider。
type BraveProvider struct {
client *http.Client
apiBase string
maxResults int
}
func NewBraveProvider(timeout time.Duration, maxResults int) *BraveProvider {
return &BraveProvider{
client: &http.Client{
Timeout: timeout,
},
apiBase: braveAPIBase,
maxResults: maxResults,
}
}
func (b *BraveProvider) Name() models.ProviderKind {
return models.ProviderBrave
}
func (b *BraveProvider) MaxResults() int {
return b.maxResults
}
type braveWebResult struct {
Title string `json:"title"`
URL string `json:"url"`
Description string `json:"description"`
Age string `json:"age"`
}
type braveResponse struct {
Web struct {
Results []braveWebResult `json:"results"`
} `json:"web"`
}
func (b *BraveProvider) Search(ctx context.Context, apiKey string, req *models.SearchRequest) (*models.SearchResult, error) {
maxRes := req.MaxResults
if maxRes <= 0 || maxRes > b.maxResults {
maxRes = b.maxResults
}
u, _ := url.Parse(b.apiBase + "/res/v1/web/search")
q := u.Query()
q.Set("q", req.Query)
q.Set("count", fmt.Sprintf("%d", maxRes))
u.RawQuery = q.Encode()
httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
if err != nil {
return nil, fmt.Errorf("brave: create request: %w", err)
}
httpReq.Header.Set("Accept", "application/json")
httpReq.Header.Set("X-Subscription-Token", apiKey)
start := time.Now()
resp, err := b.client.Do(httpReq)
duration := time.Since(start)
if err != nil {
return nil, fmt.Errorf("brave: do request: %w", err)
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("brave: read body: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("brave: status %d: %s", resp.StatusCode, string(raw))
}
var br braveResponse
if err := json.Unmarshal(raw, &br); err != nil {
return nil, fmt.Errorf("brave: decode: %w", err)
}
items := make([]models.ResultItem, 0, len(br.Web.Results))
for _, r := range br.Web.Results {
items = append(items, models.ResultItem{
Title: r.Title,
URL: r.URL,
Snippet: r.Description,
PublishedAt: r.Age,
})
}
return &models.SearchResult{
Provider: models.ProviderBrave,
Query: req.Query,
Results: items,
TotalCount: len(items),
DurationMs: duration.Milliseconds(),
Cached: false,
}, nil
}
func (b *BraveProvider) HealthCheck(ctx context.Context) error {
var d net.Dialer
conn, err := d.DialContext(ctx, "tcp", "api.search.brave.com:443")
if err != nil {
return err
}
conn.Close()
return nil
}
@@ -0,0 +1,53 @@
package providers
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/chenxin/hunjing/mcp/web-search/internal/models"
)
func TestBraveProvider_Search_Success(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("X-Subscription-Token") == "" {
t.Error("missing subscription token header")
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{
"web": {
"results": [
{"title": "Brave Result", "url": "https://brave.com/1", "description": "Brave snippet"}
]
}
}`))
}))
defer ts.Close()
p := &BraveProvider{
client: ts.Client(),
apiBase: ts.URL,
maxResults: 10,
}
r, err := p.Search(context.Background(), "test-key", &models.SearchRequest{Query: "test"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if r.TotalCount != 1 {
t.Fatalf("expected 1 result, got %d", r.TotalCount)
}
if r.Results[0].Title != "Brave Result" {
t.Errorf("expected 'Brave Result', got '%s'", r.Results[0].Title)
}
}
func TestBraveProvider_Name(t *testing.T) {
p := NewBraveProvider(5*time.Second, 10)
if p.Name() != models.ProviderBrave {
t.Errorf("expected brave, got %s", p.Name())
}
}
@@ -0,0 +1,20 @@
// Package providers 封装搜索提供商适配。
package providers
import (
"context"
"github.com/chenxin/hunjing/mcp/web-search/internal/models"
)
// SearchProvider 搜索提供商接口。
type SearchProvider interface {
// Name 返回提供商标识。
Name() models.ProviderKind
// Search 执行搜索。apiKey 由负载均衡器传入,provider 本身不管理密钥。
Search(ctx context.Context, apiKey string, req *models.SearchRequest) (*models.SearchResult, error)
// HealthCheck 健康检查。
HealthCheck(ctx context.Context) error
// MaxResults 返回单次搜索最大数量。
MaxResults() int
}
@@ -0,0 +1,123 @@
package providers
import (
"fmt"
"log/slog"
"sync"
"time"
"github.com/chenxin/hunjing/mcp/web-search/internal/config"
"github.com/chenxin/hunjing/mcp/web-search/internal/models"
)
// Registry 管理多 provider 注册和查询。
type Registry struct {
mu sync.RWMutex
providers map[models.ProviderKind]SearchProvider
}
func NewRegistry() *Registry {
return &Registry{
providers: make(map[models.ProviderKind]SearchProvider),
}
}
// Register 注册 provider。
func (r *Registry) Register(p SearchProvider) {
r.mu.Lock()
defer r.mu.Unlock()
r.providers[p.Name()] = p
}
// Get 按名称获取 provider。
func (r *Registry) Get(name models.ProviderKind) (SearchProvider, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
p, ok := r.providers[name]
return p, ok
}
// All 返回所有已注册 provider。
func (r *Registry) All() []SearchProvider {
r.mu.RLock()
defer r.mu.RUnlock()
out := make([]SearchProvider, 0, len(r.providers))
for _, p := range r.providers {
out = append(out, p)
}
return out
}
// Names 返回所有已注册的 provider 名称。
func (r *Registry) Names() []models.ProviderKind {
r.mu.RLock()
defer r.mu.RUnlock()
out := make([]models.ProviderKind, 0, len(r.providers))
for name := range r.providers {
out = append(out, name)
}
return out
}
// SetupFromConfig 根据配置创建并注册所有启用的 provider。
func SetupFromConfig(cfg *config.Config) (*Registry, error) {
reg := NewRegistry()
if cfg.Providers.Tavily.Enabled {
if len(cfg.Providers.Tavily.APIKeys) == 0 {
slog.Warn("tavily: enabled but no API keys configured, skipping")
} else {
reg.Register(NewTavilyProvider(cfg.Providers.Tavily.Timeout, cfg.Providers.Tavily.MaxResults))
}
}
if cfg.Providers.Brave.Enabled {
if len(cfg.Providers.Brave.APIKeys) == 0 {
slog.Warn("brave: enabled but no API keys configured, skipping")
} else {
reg.Register(NewBraveProvider(cfg.Providers.Brave.Timeout, cfg.Providers.Brave.MaxResults))
}
}
if len(reg.Names()) == 0 {
return nil, fmt.Errorf("no providers enabled or configured")
}
return reg, nil
}
// SetupFromConfigWithOverrides 从配置创建注册中心,并允许使用显式 provider 配置覆盖配置文件。
// 本函数方便测试和动态配置场景。
func SetupFromConfigWithOverrides(
cfg *config.Config,
overrideTimeout time.Duration,
overrideMaxResults int,
) (*Registry, error) {
reg := NewRegistry()
if cfg.Providers.Tavily.Enabled && len(cfg.Providers.Tavily.APIKeys) > 0 {
t := cfg.Providers.Tavily.Timeout
m := cfg.Providers.Tavily.MaxResults
if overrideTimeout > 0 {
t = overrideTimeout
}
if overrideMaxResults > 0 {
m = overrideMaxResults
}
reg.Register(NewTavilyProvider(t, m))
}
if cfg.Providers.Brave.Enabled && len(cfg.Providers.Brave.APIKeys) > 0 {
t := cfg.Providers.Brave.Timeout
m := cfg.Providers.Brave.MaxResults
if overrideTimeout > 0 {
t = overrideTimeout
}
if overrideMaxResults > 0 {
m = overrideMaxResults
}
reg.Register(NewBraveProvider(t, m))
}
if len(reg.Names()) == 0 {
return nil, fmt.Errorf("no providers enabled or configured")
}
return reg, nil
}
@@ -0,0 +1,63 @@
package providers
import (
"context"
"testing"
"github.com/chenxin/hunjing/mcp/web-search/internal/models"
)
type mockProvider struct {
name models.ProviderKind
}
func (m *mockProvider) Name() models.ProviderKind { return m.name }
func (m *mockProvider) Search(ctx context.Context, s string, req *models.SearchRequest) (*models.SearchResult, error) {
return &models.SearchResult{Provider: m.name, Query: req.Query}, nil
}
func (m *mockProvider) HealthCheck(ctx context.Context) error { return nil }
func (m *mockProvider) MaxResults() int { return 10 }
func TestRegistry_RegisterAndGet(t *testing.T) {
r := NewRegistry()
m := &mockProvider{name: models.ProviderTavily}
r.Register(m)
got, ok := r.Get(models.ProviderTavily)
if !ok {
t.Fatal("expected to find provider")
}
if got.Name() != models.ProviderTavily {
t.Errorf("expected tavily, got %s", got.Name())
}
}
func TestRegistry_All(t *testing.T) {
r := NewRegistry()
r.Register(&mockProvider{name: models.ProviderTavily})
r.Register(&mockProvider{name: models.ProviderBrave})
all := r.All()
if len(all) != 2 {
t.Errorf("expected 2 providers, got %d", len(all))
}
}
func TestRegistry_GetMissing(t *testing.T) {
r := NewRegistry()
_, ok := r.Get(models.ProviderBrave)
if ok {
t.Fatal("expected missing provider")
}
}
func TestRegistry_Names(t *testing.T) {
r := NewRegistry()
r.Register(&mockProvider{name: models.ProviderTavily})
r.Register(&mockProvider{name: models.ProviderBrave})
names := r.Names()
if len(names) != 2 {
t.Errorf("expected 2 names, got %d", len(names))
}
}
+134
View File
@@ -0,0 +1,134 @@
package providers
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"time"
"github.com/chenxin/hunjing/mcp/web-search/internal/models"
)
const tavilyAPIBase = "https://api.tavily.com"
type tavilyRequest struct {
APIKey string `json:"api_key"`
Query string `json:"query"`
MaxResults int `json:"max_results"`
}
type tavilyResponse struct {
Answer string `json:"answer"`
Query string `json:"query"`
ResponseTime float64 `json:"response_time"`
Results []tavilyResult `json:"results"`
}
type tavilyResult struct {
Title string `json:"title"`
URL string `json:"url"`
Content string `json:"content"`
Score float64 `json:"score"`
PublishedAt string `json:"published_date"`
}
// TavilyProvider 实现 SearchProvider。
type TavilyProvider struct {
client *http.Client
apiBase string
maxResults int
}
func NewTavilyProvider(timeout time.Duration, maxResults int) *TavilyProvider {
return &TavilyProvider{
client: &http.Client{
Timeout: timeout,
},
apiBase: tavilyAPIBase,
maxResults: maxResults,
}
}
func (t *TavilyProvider) Name() models.ProviderKind {
return models.ProviderTavily
}
func (t *TavilyProvider) MaxResults() int {
return t.maxResults
}
func (t *TavilyProvider) Search(ctx context.Context, apiKey string, req *models.SearchRequest) (*models.SearchResult, error) {
maxRes := req.MaxResults
if maxRes <= 0 || maxRes > t.maxResults {
maxRes = t.maxResults
}
body := tavilyRequest{
APIKey: apiKey,
Query: req.Query,
MaxResults: maxRes,
}
payload, _ := json.Marshal(body)
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, t.apiBase+"/search", bytes.NewReader(payload))
if err != nil {
return nil, fmt.Errorf("tavily: create request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
start := time.Now()
resp, err := t.client.Do(httpReq)
duration := time.Since(start)
if err != nil {
return nil, fmt.Errorf("tavily: do request: %w", err)
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("tavily: read body: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("tavily: status %d: %s", resp.StatusCode, string(raw))
}
var tr tavilyResponse
if err := json.Unmarshal(raw, &tr); err != nil {
return nil, fmt.Errorf("tavily: decode response: %w", err)
}
items := make([]models.ResultItem, 0, len(tr.Results))
for _, r := range tr.Results {
items = append(items, models.ResultItem{
Title: r.Title,
URL: r.URL,
Snippet: r.Content,
Score: r.Score,
PublishedAt: r.PublishedAt,
})
}
return &models.SearchResult{
Provider: models.ProviderTavily,
Query: req.Query,
Results: items,
TotalCount: len(items),
DurationMs: duration.Milliseconds(),
Cached: false,
}, nil
}
func (t *TavilyProvider) HealthCheck(ctx context.Context) error {
var d net.Dialer
conn, err := d.DialContext(ctx, "tcp", "api.tavily.com:443")
if err != nil {
return err
}
conn.Close()
return nil
}
@@ -0,0 +1,71 @@
package providers
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/chenxin/hunjing/mcp/web-search/internal/models"
)
func TestTavilyProvider_Search_Success(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/search" {
t.Errorf("unexpected path: %s", r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{
"query": "test",
"results": [
{"title": "Result 1", "url": "https://example.com/1", "content": "Content 1", "score": 0.95}
]
}`))
}))
defer ts.Close()
p := &TavilyProvider{
client: ts.Client(),
apiBase: ts.URL,
maxResults: 10,
}
result, err := p.Search(context.Background(), "test-key", &models.SearchRequest{Query: "test"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.TotalCount != 1 {
t.Fatalf("expected 1 result, got %d", result.TotalCount)
}
if result.Results[0].Title != "Result 1" {
t.Errorf("expected 'Result 1', got '%s'", result.Results[0].Title)
}
}
func TestTavilyProvider_Search_HTTPError(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte(`{"error": "invalid api key"}`))
}))
defer ts.Close()
p := &TavilyProvider{
client: ts.Client(),
apiBase: ts.URL,
maxResults: 10,
}
_, err := p.Search(context.Background(), "bad-key", &models.SearchRequest{Query: "test"})
if err == nil {
t.Fatal("expected error, got nil")
}
}
func TestTavilyProvider_Name(t *testing.T) {
p := NewTavilyProvider(5*time.Second, 10)
if p.Name() != models.ProviderTavily {
t.Errorf("expected tavily, got %s", p.Name())
}
}