feat: SearXNG 搜索集成 + DevTools Docker + PG 备份 + 文档更新
- web_search 工具/插件接入自托管 SearXNG,支持百度/必应/搜狗/360搜索 - DevTools 加入 docker-compose.dev.yml,devtools/Dockerfile - scripts/pg-backup.sh 数据库备份恢复脚本,docs/pg-backup-migration.md - 后台思考 + datetime 插件时区默认 Asia/Shanghai - docker-compose 对齐 volume 名称,清理 tool-engine 残留引用 - README.md / Deploy.md 更新至当前架构(移除简报/tool-engine,新增搜索/跨端同步/DevTools) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -303,11 +303,15 @@ func (t *DateTimeTool) handleTimezoneList() (*ToolResult, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// getTimezone extracts the timezone from arguments, defaulting to local.
|
||||
// getTimezone extracts the timezone from arguments, defaulting to Asia/Shanghai.
|
||||
func (t *DateTimeTool) getTimezone(arguments map[string]interface{}) (*time.Location, error) {
|
||||
tzName, _ := arguments["timezone"].(string)
|
||||
if tzName == "" {
|
||||
return time.Local, nil
|
||||
loc, err := time.LoadLocation("Asia/Shanghai")
|
||||
if err != nil {
|
||||
return time.Local, nil
|
||||
}
|
||||
return loc, nil
|
||||
}
|
||||
loc, err := time.LoadLocation(tzName)
|
||||
if err != nil {
|
||||
|
||||
@@ -11,10 +11,11 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// WebSearchTool 网页搜索工具 - 基于 DuckDuckGo Instant Answer API
|
||||
// WebSearchTool 网页搜索工具 - 基于 SearXNG (或 DuckDuckGo fallback)
|
||||
type WebSearchTool struct {
|
||||
client *http.Client
|
||||
timeout time.Duration
|
||||
client *http.Client
|
||||
timeout time.Duration
|
||||
searxngURL string
|
||||
}
|
||||
|
||||
// NewWebSearchTool 创建网页搜索工具
|
||||
@@ -27,6 +28,17 @@ func NewWebSearchTool() *WebSearchTool {
|
||||
}
|
||||
}
|
||||
|
||||
// NewWebSearchToolWithURL 使用 SearXNG 创建搜索工具
|
||||
func NewWebSearchToolWithURL(searxngURL string) *WebSearchTool {
|
||||
return &WebSearchTool{
|
||||
client: &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
},
|
||||
timeout: 10 * time.Second,
|
||||
searxngURL: strings.TrimRight(searxngURL, "/"),
|
||||
}
|
||||
}
|
||||
|
||||
// Definition 返回工具定义
|
||||
func (t *WebSearchTool) Definition() ToolDefinition {
|
||||
return ToolDefinition{
|
||||
@@ -78,66 +90,124 @@ func (t *WebSearchTool) Execute(ctx context.Context, arguments map[string]interf
|
||||
}, nil
|
||||
}
|
||||
|
||||
// 使用 DuckDuckGo Instant Answer API
|
||||
if t.searxngURL != "" {
|
||||
return t.searchViaSearXNG(ctx, query)
|
||||
}
|
||||
return t.searchViaDuckDuckGo(ctx, query)
|
||||
}
|
||||
|
||||
func (t *WebSearchTool) searchViaSearXNG(ctx context.Context, query string) (*ToolResult, error) {
|
||||
apiURL := fmt.Sprintf("%s/search?format=json&engines=baidu,sogou,360search,bing&q=%s",
|
||||
t.searxngURL, url.QueryEscape(query))
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
|
||||
if err != nil {
|
||||
return &ToolResult{ToolName: "web_search", Success: false, Error: fmt.Sprintf("创建请求失败: %v", err)}, nil
|
||||
}
|
||||
|
||||
resp, err := t.client.Do(req)
|
||||
if err != nil {
|
||||
return &ToolResult{ToolName: "web_search", Success: false, Error: fmt.Sprintf("SearXNG 请求失败: %v", err)}, nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return &ToolResult{ToolName: "web_search", Success: false, Error: fmt.Sprintf("SearXNG HTTP %d", resp.StatusCode)}, nil
|
||||
}
|
||||
|
||||
var sr searxngAPIResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&sr); err != nil {
|
||||
return &ToolResult{ToolName: "web_search", Success: false, Error: fmt.Sprintf("SearXNG 解析失败: %v", err)}, nil
|
||||
}
|
||||
|
||||
var result strings.Builder
|
||||
result.WriteString(fmt.Sprintf("搜索关键词: %s (共%d条结果)\n\n", query, sr.NumberOrResults))
|
||||
|
||||
for _, answer := range sr.Answers {
|
||||
result.WriteString(fmt.Sprintf("📌 %s\n\n", answer))
|
||||
}
|
||||
|
||||
count := 0
|
||||
for _, r := range sr.Results {
|
||||
if count >= 5 {
|
||||
break
|
||||
}
|
||||
if r.Title == "" || r.URL == "" {
|
||||
continue
|
||||
}
|
||||
snippet := cleanSnippet(r.Content)
|
||||
result.WriteString(fmt.Sprintf("%d. %s\n %s\n %s\n\n", count+1, r.Title, r.URL, snippet))
|
||||
count++
|
||||
}
|
||||
|
||||
if result.Len() == 0 {
|
||||
result.WriteString("未找到相关结果。")
|
||||
}
|
||||
|
||||
return &ToolResult{ToolName: "web_search", Success: true, Data: result.String()}, nil
|
||||
}
|
||||
|
||||
// searxngAPIResponse SearXNG JSON 响应
|
||||
type searxngAPIResponse struct {
|
||||
NumberOrResults int `json:"number_of_results"`
|
||||
Results []searxngResult `json:"results"`
|
||||
Answers []string `json:"answers"`
|
||||
}
|
||||
|
||||
type searxngResult struct {
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
Content string `json:"content"`
|
||||
Score float64 `json:"score"`
|
||||
}
|
||||
|
||||
func cleanSnippet(s string) string {
|
||||
text := stripHTML(s)
|
||||
runes := []rune(text)
|
||||
if len(runes) > 200 {
|
||||
return string(runes[:200]) + "..."
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
func (t *WebSearchTool) searchViaDuckDuckGo(ctx context.Context, query string) (*ToolResult, error) {
|
||||
apiURL := fmt.Sprintf("https://api.duckduckgo.com/?q=%s&format=json&no_html=1&skip_disambig=1",
|
||||
url.QueryEscape(query))
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
|
||||
if err != nil {
|
||||
return &ToolResult{
|
||||
ToolName: "web_search",
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("创建请求失败: %v", err),
|
||||
}, nil
|
||||
return &ToolResult{ToolName: "web_search", Success: false, Error: fmt.Sprintf("创建请求失败: %v", err)}, nil
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; CyreneBot/1.0)")
|
||||
|
||||
resp, err := t.client.Do(req)
|
||||
if err != nil {
|
||||
return &ToolResult{
|
||||
ToolName: "web_search",
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("请求失败: %v", err),
|
||||
}, nil
|
||||
return &ToolResult{ToolName: "web_search", Success: false, Error: fmt.Sprintf("请求失败: %v", err)}, nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return &ToolResult{
|
||||
ToolName: "web_search",
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("HTTP %d", resp.StatusCode),
|
||||
}, nil
|
||||
return &ToolResult{ToolName: "web_search", Success: false, Error: fmt.Sprintf("HTTP %d", resp.StatusCode)}, nil
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 500*1024))
|
||||
if err != nil {
|
||||
return &ToolResult{
|
||||
ToolName: "web_search",
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("读取响应失败: %v", err),
|
||||
}, nil
|
||||
return &ToolResult{ToolName: "web_search", Success: false, Error: fmt.Sprintf("读取响应失败: %v", err)}, nil
|
||||
}
|
||||
|
||||
var ddg duckDuckGoResponse
|
||||
if err := json.Unmarshal(body, &ddg); err != nil {
|
||||
return &ToolResult{
|
||||
ToolName: "web_search",
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("解析响应失败: %v", err),
|
||||
}, nil
|
||||
return &ToolResult{ToolName: "web_search", Success: false, Error: fmt.Sprintf("解析响应失败: %v", err)}, nil
|
||||
}
|
||||
|
||||
var result strings.Builder
|
||||
result.WriteString(fmt.Sprintf("搜索关键词: %s\n\n", query))
|
||||
|
||||
// 1. 如果有即时答案
|
||||
if ddg.Answer != "" {
|
||||
result.WriteString(fmt.Sprintf("📌 即时答案: %s\n\n", ddg.Answer))
|
||||
}
|
||||
|
||||
// 2. 摘要
|
||||
if ddg.AbstractText != "" {
|
||||
abstract := ddg.AbstractText
|
||||
if len([]rune(abstract)) > 500 {
|
||||
@@ -151,10 +221,8 @@ func (t *WebSearchTool) Execute(ctx context.Context, arguments map[string]interf
|
||||
result.WriteString("\n")
|
||||
}
|
||||
|
||||
// 3. 相关话题
|
||||
topics := ddg.RelatedTopics
|
||||
if len(ddg.Results) > 0 {
|
||||
// 优先用 Results
|
||||
count := 0
|
||||
for _, r := range ddg.Results {
|
||||
if count >= 5 {
|
||||
@@ -198,11 +266,7 @@ func (t *WebSearchTool) Execute(ctx context.Context, arguments map[string]interf
|
||||
result.WriteString("未找到相关结果。")
|
||||
}
|
||||
|
||||
return &ToolResult{
|
||||
ToolName: "web_search",
|
||||
Success: true,
|
||||
Data: result.String(),
|
||||
}, nil
|
||||
return &ToolResult{ToolName: "web_search", Success: true, Data: result.String()}, nil
|
||||
}
|
||||
|
||||
// stripHTML 去除 HTML 标签
|
||||
|
||||
Reference in New Issue
Block a user