初始化提交
This commit is contained in:
commit
887c1f449e
963 files changed
+166869
No files matched your search
@@ -0,0 +1,116 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/chenxin/hunjing/mcp/web-search/internal/models"
|
||||
)
|
||||
|
||||
// registerAdminRoutes 注册管理端点到 mux。
|
||||
func (h *Handler) registerAdminRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/admin/provider/", h.handleProviderAdmin)
|
||||
mux.HandleFunc("/admin/cache/clear", h.handleCacheClear)
|
||||
mux.HandleFunc("/admin/keys", h.handleKeys)
|
||||
}
|
||||
|
||||
// handleProviderAdmin 管理 provider 启用/禁用。
|
||||
// PUT /admin/provider/{name}/enable
|
||||
// PUT /admin/provider/{name}/disable
|
||||
func (h *Handler) handleProviderAdmin(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPut {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// 解析路径:/admin/provider/{name}/{action}
|
||||
parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/admin/provider/"), "/")
|
||||
if len(parts) != 2 {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "路径格式: /admin/provider/{name}/{action}"})
|
||||
return
|
||||
}
|
||||
name := models.ProviderKind(parts[0])
|
||||
action := parts[1]
|
||||
clientIP := requestIP(r)
|
||||
|
||||
if !h.balancer.HasProvider(name) {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "provider 不存在: " + string(name)})
|
||||
return
|
||||
}
|
||||
|
||||
switch action {
|
||||
case "enable":
|
||||
h.balancer.EnableProvider(name)
|
||||
slog.Info("管理: 启用 provider", "provider", name, "from", clientIP)
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "enabled", "provider": string(name)})
|
||||
case "disable":
|
||||
h.balancer.DisableProvider(name)
|
||||
slog.Info("管理: 禁用 provider", "provider", name, "from", clientIP)
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "disabled", "provider": string(name)})
|
||||
default:
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "未知操作: " + action + "(可用: enable, disable)"})
|
||||
}
|
||||
}
|
||||
|
||||
// handleCacheClear 清空缓存。
|
||||
// POST /admin/cache/clear
|
||||
func (h *Handler) handleCacheClear(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
h.cache.Clear()
|
||||
slog.Info("管理: 缓存已清空", "from", requestIP(r))
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "cleared"})
|
||||
}
|
||||
|
||||
// handleKeys 列出所有密钥状态。
|
||||
// GET /admin/keys
|
||||
func (h *Handler) handleKeys(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
type keyInfo struct {
|
||||
Provider string `json:"provider"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
var keys []keyInfo
|
||||
for _, p := range h.providerKinds {
|
||||
keys = append(keys, keyInfo{
|
||||
Provider: string(p),
|
||||
Enabled: h.balancer.IsProviderEnabled(p),
|
||||
})
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"keys": keys,
|
||||
})
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, data interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(data)
|
||||
}
|
||||
|
||||
// requestIP 从请求中提取客户端 IP。
|
||||
func requestIP(r *http.Request) string {
|
||||
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
|
||||
return fwd
|
||||
}
|
||||
// RemoteAddr 格式 "ip:port",去掉端口
|
||||
if host := r.RemoteAddr; host != "" {
|
||||
for i := len(host) - 1; i >= 0; i-- {
|
||||
if host[i] == ':' {
|
||||
return host[:i]
|
||||
}
|
||||
}
|
||||
return host
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
// Package mcp 实现 MCP 协议的工具注册、调用处理(传输层无关)。
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/chenxin/hunjing/mcp/web-search/internal/balancer"
|
||||
"github.com/chenxin/hunjing/mcp/web-search/internal/cache"
|
||||
"github.com/chenxin/hunjing/mcp/web-search/internal/config"
|
||||
"github.com/chenxin/hunjing/mcp/web-search/internal/metrics"
|
||||
"github.com/chenxin/hunjing/mcp/web-search/internal/models"
|
||||
)
|
||||
|
||||
// ToolDefinition MCP 工具定义。
|
||||
type ToolDefinition struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
InputSchema interface{} `json:"inputSchema"`
|
||||
}
|
||||
|
||||
// CallToolRequest MCP tools/call JSON-RPC 参数。
|
||||
type CallToolRequest struct {
|
||||
Name string `json:"name"`
|
||||
Input json.RawMessage `json:"input,omitempty"`
|
||||
Arguments json.RawMessage `json:"arguments,omitempty"` // MCP 标准字段名
|
||||
}
|
||||
|
||||
// CallToolResult MCP tools/call 响应体。
|
||||
type CallToolResult struct {
|
||||
Content []ContentItem `json:"content"`
|
||||
IsError bool `json:"isError,omitempty"`
|
||||
}
|
||||
|
||||
// ContentItem MCP 内容项。
|
||||
type ContentItem struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// Handler 搜索工具核心处理逻辑(传输层无关)。
|
||||
type Handler struct {
|
||||
balancer *balancer.LoadBalancer
|
||||
cache *cache.Manager
|
||||
providerKinds []models.ProviderKind
|
||||
}
|
||||
|
||||
func NewHandler(b *balancer.LoadBalancer, c *cache.Manager, providers []models.ProviderKind) *Handler {
|
||||
return &Handler{
|
||||
balancer: b,
|
||||
cache: c,
|
||||
providerKinds: providers,
|
||||
}
|
||||
}
|
||||
|
||||
// ReloadConfig 应用配置热重载变更。
|
||||
// 由 config.Reloader 在文件变更后调用,只处理 YAML 中可动态变更的字段。
|
||||
func (h *Handler) ReloadConfig(oldCfg, newCfg *config.Config) {
|
||||
// Provider 启用/禁用
|
||||
type pc struct {
|
||||
name models.ProviderKind
|
||||
old config.ProviderConfig
|
||||
new config.ProviderConfig
|
||||
}
|
||||
changes := []pc{
|
||||
{models.ProviderTavily, oldCfg.Providers.Tavily, newCfg.Providers.Tavily},
|
||||
{models.ProviderBrave, oldCfg.Providers.Brave, newCfg.Providers.Brave},
|
||||
}
|
||||
for _, c := range changes {
|
||||
if !c.old.Enabled && c.new.Enabled {
|
||||
h.balancer.EnableProvider(c.name)
|
||||
slog.Info("配置热重载: 启用 provider", "name", c.name)
|
||||
} else if c.old.Enabled && !c.new.Enabled {
|
||||
h.balancer.DisableProvider(c.name)
|
||||
slog.Info("配置热重载: 禁用 provider", "name", c.name)
|
||||
}
|
||||
}
|
||||
|
||||
// 缓存 TTL
|
||||
if oldCfg.Cache.TTL != newCfg.Cache.TTL {
|
||||
h.cache.SetTTL(newCfg.Cache.TTL)
|
||||
slog.Info("配置热重载: 缓存 TTL 更新", "old", oldCfg.Cache.TTL, "new", newCfg.Cache.TTL)
|
||||
}
|
||||
|
||||
// 负载均衡重试次数
|
||||
if oldCfg.Balancer.RetryCount != newCfg.Balancer.RetryCount {
|
||||
h.balancer.SetRetryCount(newCfg.Balancer.RetryCount)
|
||||
slog.Info("配置热重载: 重试次数更新", "old", oldCfg.Balancer.RetryCount, "new", newCfg.Balancer.RetryCount)
|
||||
}
|
||||
}
|
||||
|
||||
// ToolDefinition 返回 web_search 工具定义(MCP tools/list 用)。
|
||||
func (h *Handler) ToolDefinition() ToolDefinition {
|
||||
return ToolDefinition{
|
||||
Name: "web_search",
|
||||
Description: "联网搜索,获取最新的网络信息。支持多搜索引擎。",
|
||||
InputSchema: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"query": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "搜索关键词",
|
||||
},
|
||||
"max_results": map[string]interface{}{
|
||||
"type": "integer",
|
||||
"default": 5,
|
||||
"minimum": 1,
|
||||
"maximum": 20,
|
||||
"description": "最大返回结果数",
|
||||
},
|
||||
"mode": map[string]interface{}{
|
||||
"type": "string",
|
||||
"enum": []string{"first", "merge"},
|
||||
"default": "first",
|
||||
"description": "搜索模式:first(首个成功返回,默认)、merge(并发搜索所有引擎,去重合并)",
|
||||
},
|
||||
"providers": map[string]interface{}{
|
||||
"type": "array",
|
||||
"items": map[string]interface{}{
|
||||
"type": "string",
|
||||
},
|
||||
"description": "优先使用的搜索引擎(默认全部可用引擎)",
|
||||
},
|
||||
},
|
||||
"required": []string{"query"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// HandleSearch 执行搜索(被 stdio、SSE、HTTP 三种传输层共享)。
|
||||
func (h *Handler) HandleSearch(ctx context.Context, req *models.SearchRequest) *CallToolResult {
|
||||
start := time.Now()
|
||||
|
||||
// 校验
|
||||
if req.Query == "" {
|
||||
metrics.SearchErrors.WithLabelValues("empty_query").Inc()
|
||||
return errorResult("搜索关键词不能为空")
|
||||
}
|
||||
if req.MaxResults <= 0 {
|
||||
req.MaxResults = 5
|
||||
}
|
||||
if req.MaxResults > 20 {
|
||||
req.MaxResults = 20
|
||||
}
|
||||
if req.Mode == "" {
|
||||
req.Mode = models.ModeFirst
|
||||
}
|
||||
|
||||
// 查缓存(用空 provider 查跨 provider 缓存)
|
||||
if cached := h.cache.Get(req, ""); cached != nil {
|
||||
metrics.CacheHits.Inc()
|
||||
metrics.SearchDuration.Observe(time.Since(start).Seconds())
|
||||
return successResult(cached)
|
||||
}
|
||||
metrics.CacheMisses.Inc()
|
||||
|
||||
// 执行搜索
|
||||
if req.Mode == models.ModeMerge {
|
||||
return h.mergeSearch(ctx, req, start)
|
||||
}
|
||||
|
||||
result, err := h.balancer.Search(ctx, req)
|
||||
if err != nil {
|
||||
metrics.SearchErrors.WithLabelValues("search_failed").Inc()
|
||||
return errorResult(fmt.Sprintf("搜索失败: %v", err))
|
||||
}
|
||||
|
||||
// 写入缓存
|
||||
h.cache.Set(req, result.Provider, result)
|
||||
metrics.SearchTotal.WithLabelValues(string(result.Provider)).Inc()
|
||||
|
||||
return successResult(result)
|
||||
}
|
||||
|
||||
func successResult(result *models.SearchResult) *CallToolResult {
|
||||
jsonBytes, _ := json.Marshal(result)
|
||||
return &CallToolResult{
|
||||
Content: []ContentItem{
|
||||
{Type: "json", Text: string(jsonBytes)},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func errorResult(msg string) *CallToolResult {
|
||||
errJSON, _ := json.Marshal(map[string]string{"error": msg})
|
||||
return &CallToolResult{
|
||||
IsError: true,
|
||||
Content: []ContentItem{
|
||||
{Type: "text", Text: string(errJSON)},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// mergeSearch 并发搜索所有 provider,合并去重后返回。
|
||||
func (h *Handler) mergeSearch(ctx context.Context, req *models.SearchRequest, start time.Time) *CallToolResult {
|
||||
results := h.balancer.SearchAll(ctx, req)
|
||||
if len(results) == 0 {
|
||||
metrics.SearchErrors.WithLabelValues("merge_no_results").Inc()
|
||||
return errorResult("所有搜索引擎均未返回结果")
|
||||
}
|
||||
|
||||
// 合并 + URL 去重
|
||||
seen := make(map[string]bool)
|
||||
var merged []models.ResultItem
|
||||
for _, res := range results {
|
||||
for _, item := range res.Results {
|
||||
if seen[item.URL] {
|
||||
continue
|
||||
}
|
||||
seen[item.URL] = true
|
||||
merged = append(merged, item)
|
||||
}
|
||||
}
|
||||
|
||||
// 按 score 降序排序(无 score 的排最后)
|
||||
sort.Slice(merged, func(i, j int) bool {
|
||||
return merged[i].Score > merged[j].Score
|
||||
})
|
||||
|
||||
// 截断到 max_results
|
||||
if len(merged) > req.MaxResults {
|
||||
merged = merged[:req.MaxResults]
|
||||
}
|
||||
|
||||
final := &models.SearchResult{
|
||||
Provider: models.ProviderKind("merge"),
|
||||
Query: req.Query,
|
||||
Results: merged,
|
||||
TotalCount: len(merged),
|
||||
DurationMs: time.Since(start).Milliseconds(),
|
||||
Cached: false,
|
||||
}
|
||||
|
||||
h.cache.Set(req, "", final)
|
||||
return successResult(final)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/chenxin/hunjing/mcp/web-search/internal/cache"
|
||||
"github.com/chenxin/hunjing/mcp/web-search/internal/config"
|
||||
"github.com/chenxin/hunjing/mcp/web-search/internal/models"
|
||||
)
|
||||
|
||||
func TestHandler_ToolDefinition(t *testing.T) {
|
||||
h := NewHandler(nil, nil, nil)
|
||||
def := h.ToolDefinition()
|
||||
if def.Name != "web_search" {
|
||||
t.Errorf("expected 'web_search', got '%s'", def.Name)
|
||||
}
|
||||
if def.Description == "" {
|
||||
t.Error("expected non-empty description")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandler_HandleSearch_EmptyQuery(t *testing.T) {
|
||||
c, _ := cache.New(config.CacheConfig{Enabled: false})
|
||||
h := NewHandler(nil, c, nil)
|
||||
result := h.HandleSearch(context.Background(), &models.SearchRequest{Query: ""})
|
||||
if !result.IsError {
|
||||
t.Fatal("expected error for empty query")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandler_HandleSearch_DefaultMaxResults(t *testing.T) {
|
||||
req := &models.SearchRequest{Query: "test"}
|
||||
if req.MaxResults <= 0 {
|
||||
req.MaxResults = 5
|
||||
}
|
||||
if req.MaxResults > 20 {
|
||||
req.MaxResults = 20
|
||||
}
|
||||
if req.MaxResults != 5 {
|
||||
t.Errorf("expected default 5, got %d", req.MaxResults)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/chenxin/hunjing/mcp/web-search/internal/models"
|
||||
)
|
||||
|
||||
// HandleMonitor 返回 HTML 监控面板(GET /)。
|
||||
func (h *Handler) HandleMonitor(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
|
||||
uptime := time.Since(startTime).Round(time.Second).String()
|
||||
|
||||
// Provider 表格行
|
||||
var providerRows string
|
||||
for _, p := range h.providerKinds {
|
||||
counts := h.balancer.ActiveKeyCount()
|
||||
activeKeys := counts[p]
|
||||
enabled := h.balancer.IsProviderEnabled(p)
|
||||
statusIcon := "🟢"
|
||||
statusText := "启用"
|
||||
if !enabled {
|
||||
statusIcon = "🔴"
|
||||
statusText = "禁用"
|
||||
}
|
||||
providerRows += fmt.Sprintf(`<tr>
|
||||
<td>%s</td>
|
||||
<td><span class="badge badge-provider">%s</span></td>
|
||||
<td>%s <span class="status-text">%s</span></td>
|
||||
<td class="num">%d</td>
|
||||
</tr>`, statusIcon, p, statusIcon, statusText, activeKeys)
|
||||
}
|
||||
|
||||
// 缓存统计
|
||||
l1Hits, l2Hits, misses := h.cache.Stats()
|
||||
hitRate := h.cache.HitRate()
|
||||
|
||||
html := fmt.Sprintf(`<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>博闻 · 监控面板</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Noto Sans SC", sans-serif;
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
padding: 24px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.container { max-width: 960px; margin: 0 auto; }
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
h1 small {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: #64748b;
|
||||
}
|
||||
.subtitle {
|
||||
color: #64748b;
|
||||
font-size: 14px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.card {
|
||||
background: #1e293b;
|
||||
border-radius: 12px;
|
||||
padding: 16px 20px;
|
||||
border: 1px solid #334155;
|
||||
}
|
||||
.card .label {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.card .value {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: #38bdf8;
|
||||
}
|
||||
.card .value.green { color: #4ade80; }
|
||||
.card .value.yellow { color: #fbbf24; }
|
||||
.card .value.red { color: #f87171; }
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
table {
|
||||
width: 100%%;
|
||||
border-collapse: collapse;
|
||||
background: #1e293b;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #334155;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
th {
|
||||
text-align: left;
|
||||
padding: 12px 16px;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
background: #1a2332;
|
||||
border-bottom: 1px solid #334155;
|
||||
}
|
||||
td {
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid #1e293b;
|
||||
font-size: 14px;
|
||||
}
|
||||
tr:last-child td { border-bottom: none; }
|
||||
tr:hover { background: #243348; }
|
||||
.num { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.badge-provider {
|
||||
background: #1e3a5f;
|
||||
color: #93c5fd;
|
||||
}
|
||||
.status-text {
|
||||
font-size: 13px;
|
||||
color: #94a3b8;
|
||||
margin-left: 4px;
|
||||
}
|
||||
a { color: #38bdf8; text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
.links { margin-top: 16px; display: flex; gap: 16px; font-size: 14px; }
|
||||
.footer { margin-top: 32px; padding-top: 16px; border-top: 1px solid #334155; color: #475569; font-size: 12px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>🔍 博闻 <small>Bowen Web Search</small></h1>
|
||||
<div class="subtitle">运行时间 %s</div>
|
||||
|
||||
<div class="grid">
|
||||
<div class="card">
|
||||
<div class="label">Provider</div>
|
||||
<div class="value">%d</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="label">活跃密钥</div>
|
||||
<div class="value">%d</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="label">缓存命中率</div>
|
||||
<div class="value %s">%d%%%%</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="label">缓存条目</div>
|
||||
<div class="value">%d</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-title">📡 Provider 状态</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>状态</th><th>名称</th><th>开关</th><th class="num">活跃密钥</th></tr>
|
||||
</thead>
|
||||
<tbody>%s</tbody>
|
||||
</table>
|
||||
|
||||
<div class="section-title">💾 缓存统计</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>指标</th><th class="num">值</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td>L1 命中(内存 LRU)</td><td class="num">%d</td></tr>
|
||||
<tr><td>L2 命中(Redis)</td><td class="num">%d</td></tr>
|
||||
<tr><td>未命中</td><td class="num">%d</td></tr>
|
||||
<tr><td>总命中率</td><td class="num">%.1f%%%%</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="links">
|
||||
<span>🔗</span>
|
||||
<a href="/status">/status (JSON)</a>
|
||||
<a href="/admin/keys">/admin/keys</a>
|
||||
<a href="/health">/health</a>
|
||||
</div>
|
||||
|
||||
<div class="footer">博闻 v2.0.0 · 数据实时 · 页面每 30 秒刷新</div>
|
||||
</div>
|
||||
<script>setTimeout(function(){ location.reload(); }, 30000);</script>
|
||||
</body>
|
||||
</html>`,
|
||||
uptime,
|
||||
len(h.providerKinds),
|
||||
sumActiveKeys(h.balancer.ActiveKeyCount()),
|
||||
hitRateColor(hitRate),
|
||||
int(hitRate*100),
|
||||
l1Hits+l2Hits+misses,
|
||||
providerRows,
|
||||
l1Hits, l2Hits, misses, hitRate*100,
|
||||
)
|
||||
|
||||
fmt.Fprint(w, html)
|
||||
}
|
||||
|
||||
func sumActiveKeys(m map[models.ProviderKind]int) int {
|
||||
s := 0
|
||||
for _, v := range m {
|
||||
s += v
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func hitRateColor(rate float64) string {
|
||||
if rate >= 0.6 {
|
||||
return "green"
|
||||
} else if rate >= 0.3 {
|
||||
return "yellow"
|
||||
}
|
||||
return "red"
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// LoggingMiddleware 记录 HTTP 请求的源 IP、方法、路径和处理耗时。
|
||||
func LoggingMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
ip := r.RemoteAddr
|
||||
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
|
||||
ip = fwd
|
||||
}
|
||||
method := r.Method
|
||||
path := r.URL.Path
|
||||
next.ServeHTTP(w, r)
|
||||
slog.Info("HTTP",
|
||||
"method", method,
|
||||
"path", path,
|
||||
"from", ip,
|
||||
"ms", time.Since(start).Milliseconds(),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// ServeHTTP 启动 Streamable HTTP 传输模式的 MCP 服务。
|
||||
//
|
||||
// 传输协议:MCP Streamable HTTP(JSON-RPC over HTTP POST /)
|
||||
// POST / → MCP 处理,GET / → 帮助页
|
||||
func (h *Handler) ServeHTTP(addr string) error {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/", h.handleRoot)
|
||||
// 管理端点
|
||||
mux.HandleFunc("/health", h.HandleHealth)
|
||||
mux.HandleFunc("/status", h.handleStatus)
|
||||
h.registerAdminRoutes(mux)
|
||||
|
||||
slog.Info("博闻 HTTP 服务启动", "addr", addr, "providers", h.providerKinds)
|
||||
return http.ListenAndServe(addr, LoggingMiddleware(mux))
|
||||
}
|
||||
|
||||
// handleRoot MCP 服务根路径。
|
||||
// POST → MCP Streamable HTTP(JSON-RPC 2.0)
|
||||
// GET → 帮助页
|
||||
func (h *Handler) handleRoot(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodPost {
|
||||
h.handleStreamableHTTP(w, r)
|
||||
return
|
||||
}
|
||||
if r.URL.Path != "/" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
fmt.Fprint(w, `博闻 Bowen · MCP Web Search
|
||||
|
||||
传输协议:
|
||||
POST / MCP Streamable HTTP(JSON-RPC 2.0,单端点)
|
||||
|
||||
管理端点:
|
||||
GET /health 存活探针
|
||||
GET /status 系统状态
|
||||
GET /admin/keys 密钥状态
|
||||
PUT /admin/provider/:name/enable 启用 provider
|
||||
PUT /admin/provider/:name/disable 禁用 provider
|
||||
POST /admin/cache/clear 清空缓存
|
||||
|
||||
监控面板(metrics 端口):
|
||||
GET / HTML 仪表盘
|
||||
GET /metrics Prometheus 原始指标
|
||||
|
||||
Stdio 模式:go run ./cmd/server --stdio
|
||||
`)
|
||||
}
|
||||
|
||||
// handleStreamableHTTP MCP Streamable HTTP 传输端点。
|
||||
// 接收 JSON-RPC 2.0 POST 请求,返回 JSON-RPC 响应。
|
||||
// 现阶段返回完整响应(非流式),后续可扩展为 text/event-stream 流式传输。
|
||||
func (h *Handler) handleStreamableHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var req JSONRPCRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
json.NewEncoder(w).Encode(jsonRPCError(nil, -32700, "Parse error"))
|
||||
return
|
||||
}
|
||||
|
||||
resp := h.handleJSONRPC(req)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SystemStatus 博闻系统状态(通过 /status 或管理 API 查询)。
|
||||
type SystemStatus struct {
|
||||
Version string `json:"version"`
|
||||
Uptime string `json:"uptime"`
|
||||
Providers []ProviderStatus `json:"providers"`
|
||||
Cache CacheStats `json:"cache"`
|
||||
}
|
||||
|
||||
// ProviderStatus provider 状态摘要。
|
||||
type ProviderStatus struct {
|
||||
Name string `json:"name"`
|
||||
ActiveKeys int `json:"active_keys"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
// CacheStats 缓存状态摘要。
|
||||
type CacheStats struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
L1Hits int64 `json:"l1_hits"`
|
||||
L2Hits int64 `json:"l2_hits"`
|
||||
Misses int64 `json:"misses"`
|
||||
HitRate float64 `json:"hit_rate"`
|
||||
}
|
||||
|
||||
// startTime 记录博闻启动时间,用于 /status 的 uptime 计算。
|
||||
var startTime = time.Now()
|
||||
|
||||
// HandleHealth 存活探针(GET /health → 200)。
|
||||
func (h *Handler) HandleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
// handleStatus 系统状态(GET /status → SystemStatus)。
|
||||
func (h *Handler) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
// Provider 状态
|
||||
var providers []ProviderStatus
|
||||
for _, p := range h.providerKinds {
|
||||
counts := h.balancer.ActiveKeyCount()
|
||||
activeKeys := counts[p]
|
||||
providers = append(providers, ProviderStatus{
|
||||
Name: string(p),
|
||||
ActiveKeys: activeKeys,
|
||||
Enabled: activeKeys > 0,
|
||||
})
|
||||
}
|
||||
|
||||
// 缓存状态
|
||||
l1Hits, l2Hits, misses := h.cache.Stats()
|
||||
cacheStats := CacheStats{
|
||||
Enabled: true,
|
||||
L1Hits: l1Hits,
|
||||
L2Hits: l2Hits,
|
||||
Misses: misses,
|
||||
HitRate: h.cache.HitRate(),
|
||||
}
|
||||
|
||||
status := SystemStatus{
|
||||
Version: "2.0.0",
|
||||
Uptime: time.Since(startTime).Round(time.Second).String(),
|
||||
Providers: providers,
|
||||
Cache: cacheStats,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(status)
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
|
||||
"github.com/chenxin/hunjing/mcp/web-search/internal/models"
|
||||
)
|
||||
|
||||
// JSONRPCRequest JSON-RPC 2.0 请求。
|
||||
type JSONRPCRequest struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID *int `json:"id"`
|
||||
Method string `json:"method"`
|
||||
Params json.RawMessage `json:"params,omitempty"`
|
||||
}
|
||||
|
||||
// JSONRPCResponse JSON-RPC 2.0 响应。
|
||||
type JSONRPCResponse struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID *int `json:"id"`
|
||||
Result interface{} `json:"result,omitempty"`
|
||||
Error *RPCError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// RPCError JSON-RPC 错误。
|
||||
type RPCError struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// ServeStdio 通过 stdin/stdout 提供 MCP JSON-RPC 服务。
|
||||
func (h *Handler) ServeStdio() {
|
||||
slog.Info("博闻 stdio 模式启动", "providers", h.providerKinds)
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
encoder := json.NewEncoder(os.Stdout)
|
||||
|
||||
for {
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
slog.Error("stdio read error", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
var req JSONRPCRequest
|
||||
if err := json.Unmarshal([]byte(line), &req); err != nil {
|
||||
slog.Warn("stdio parse error", "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
resp := h.handleJSONRPC(req)
|
||||
if err := encoder.Encode(resp); err != nil {
|
||||
slog.Error("stdio write error", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) handleJSONRPC(req JSONRPCRequest) JSONRPCResponse {
|
||||
switch req.Method {
|
||||
case "tools/list":
|
||||
return JSONRPCResponse{
|
||||
JSONRPC: "2.0",
|
||||
ID: req.ID,
|
||||
Result: map[string]interface{}{
|
||||
"tools": []ToolDefinition{h.ToolDefinition()},
|
||||
},
|
||||
}
|
||||
|
||||
case "tools/call":
|
||||
var callReq CallToolRequest
|
||||
if err := json.Unmarshal(req.Params, &callReq); err != nil {
|
||||
return jsonRPCError(req.ID, -32602, "Invalid params")
|
||||
}
|
||||
if callReq.Name != "web_search" {
|
||||
return jsonRPCError(req.ID, -32601, fmt.Sprintf("Tool not found: %s", callReq.Name))
|
||||
}
|
||||
|
||||
// 优先使用 MCP 标准 arguments 字段,兼容旧 input 字段
|
||||
rawInput := callReq.Arguments
|
||||
if len(rawInput) == 0 {
|
||||
rawInput = callReq.Input
|
||||
}
|
||||
|
||||
var searchReq models.SearchRequest
|
||||
if err := json.Unmarshal(rawInput, &searchReq); err != nil {
|
||||
return jsonRPCError(req.ID, -32602, "Invalid search request parameters: "+err.Error())
|
||||
}
|
||||
|
||||
result := h.HandleSearch(context.Background(), &searchReq)
|
||||
return JSONRPCResponse{
|
||||
JSONRPC: "2.0",
|
||||
ID: req.ID,
|
||||
Result: result,
|
||||
}
|
||||
|
||||
default:
|
||||
return jsonRPCError(req.ID, -32601, fmt.Sprintf("Method not found: %s", req.Method))
|
||||
}
|
||||
}
|
||||
|
||||
func jsonRPCError(id *int, code int, msg string) JSONRPCResponse {
|
||||
return JSONRPCResponse{
|
||||
JSONRPC: "2.0",
|
||||
ID: id,
|
||||
Error: &RPCError{Code: code, Message: msg},
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user