78e3f450c2
- Fix: Session history flash (race condition + WS guard) - Fix: Chat background overlay + sidebar transparency - Fix: IoT device control (Chinese action names, status field) - Feat: Independent memory-service (port 8091, 13 endpoints) - Feat: Independent tool-engine service (port 8092, 13 tools) - Feat: Tool call logs with paginated DevTools panel - Feat: Thinking log records with DevTools panel - Feat: Future development roadmap document - Chore: Updated .gitignore, go.work, DevTools config - Chore: 5-service health check, project review docs
224 lines
5.2 KiB
Go
224 lines
5.2 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/yourname/cyrene-ai/tool-engine/internal/model"
|
|
)
|
|
|
|
// WebSearchTool 网页搜索工具 - 基于 DuckDuckGo Instant Answer API
|
|
type WebSearchTool struct {
|
|
client *http.Client
|
|
timeout time.Duration
|
|
}
|
|
|
|
// NewWebSearchTool 创建网页搜索工具
|
|
func NewWebSearchTool() *WebSearchTool {
|
|
return &WebSearchTool{
|
|
client: &http.Client{
|
|
Timeout: 10 * time.Second,
|
|
},
|
|
timeout: 10 * time.Second,
|
|
}
|
|
}
|
|
|
|
// Definition 返回工具定义
|
|
func (t *WebSearchTool) Definition() model.ToolDefinition {
|
|
return model.ToolDefinition{
|
|
Name: "web_search",
|
|
Description: "搜索互联网信息。用于查找新闻、资料、知识等。返回搜索结果摘要(最多5条)。",
|
|
Parameters: map[string]interface{}{
|
|
"type": "object",
|
|
"properties": map[string]interface{}{
|
|
"query": map[string]interface{}{
|
|
"type": "string",
|
|
"description": "搜索关键词",
|
|
},
|
|
},
|
|
"required": []string{"query"},
|
|
},
|
|
}
|
|
}
|
|
|
|
// duckDuckGoResponse DuckDuckGo API 响应
|
|
type duckDuckGoResponse struct {
|
|
AbstractText string `json:"AbstractText"`
|
|
AbstractURL string `json:"AbstractURL"`
|
|
AbstractSource string `json:"AbstractSource"`
|
|
Heading string `json:"Heading"`
|
|
Answer string `json:"Answer"`
|
|
AnswerType string `json:"AnswerType"`
|
|
RelatedTopics []duckDuckGoRelated `json:"RelatedTopics"`
|
|
Results []duckDuckGoResult `json:"Results"`
|
|
}
|
|
|
|
type duckDuckGoRelated struct {
|
|
Text string `json:"Text"`
|
|
FirstURL string `json:"FirstURL"`
|
|
}
|
|
|
|
type duckDuckGoResult struct {
|
|
Text string `json:"Text"`
|
|
FirstURL string `json:"FirstURL"`
|
|
}
|
|
|
|
// Execute 执行网页搜索
|
|
func (t *WebSearchTool) Execute(ctx context.Context, arguments map[string]interface{}) (*model.ToolResult, error) {
|
|
query, ok := arguments["query"].(string)
|
|
if !ok || query == "" {
|
|
return &model.ToolResult{
|
|
Output: "",
|
|
Error: "缺少 query 参数",
|
|
}, nil
|
|
}
|
|
|
|
// 使用 DuckDuckGo Instant Answer API
|
|
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 &model.ToolResult{
|
|
Output: "",
|
|
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 &model.ToolResult{
|
|
Output: "",
|
|
Error: fmt.Sprintf("请求失败: %v", err),
|
|
}, nil
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return &model.ToolResult{
|
|
Output: "",
|
|
Error: fmt.Sprintf("HTTP %d", resp.StatusCode),
|
|
}, nil
|
|
}
|
|
|
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 500*1024))
|
|
if err != nil {
|
|
return &model.ToolResult{
|
|
Output: "",
|
|
Error: fmt.Sprintf("读取响应失败: %v", err),
|
|
}, nil
|
|
}
|
|
|
|
var ddg duckDuckGoResponse
|
|
if err := json.Unmarshal(body, &ddg); err != nil {
|
|
return &model.ToolResult{
|
|
Output: "",
|
|
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 {
|
|
runes := []rune(abstract)
|
|
abstract = string(runes[:500]) + "..."
|
|
}
|
|
result.WriteString(fmt.Sprintf("摘要: %s\n", abstract))
|
|
if ddg.AbstractURL != "" {
|
|
result.WriteString(fmt.Sprintf("来源: %s\n", ddg.AbstractURL))
|
|
}
|
|
result.WriteString("\n")
|
|
}
|
|
|
|
// 3. 相关话题
|
|
topics := ddg.RelatedTopics
|
|
if len(ddg.Results) > 0 {
|
|
// 优先用 Results
|
|
count := 0
|
|
for _, r := range ddg.Results {
|
|
if count >= 5 {
|
|
break
|
|
}
|
|
if r.Text != "" {
|
|
text := stripHTML(r.Text)
|
|
if len([]rune(text)) > 200 {
|
|
runes := []rune(text)
|
|
text = string(runes[:200]) + "..."
|
|
}
|
|
result.WriteString(fmt.Sprintf("\n🔗 %s\n", text))
|
|
if r.FirstURL != "" {
|
|
result.WriteString(fmt.Sprintf(" %s\n", r.FirstURL))
|
|
}
|
|
count++
|
|
}
|
|
}
|
|
} else {
|
|
count := 0
|
|
for _, topic := range topics {
|
|
if count >= 5 {
|
|
break
|
|
}
|
|
if topic.Text != "" {
|
|
text := stripHTML(topic.Text)
|
|
if len([]rune(text)) > 200 {
|
|
runes := []rune(text)
|
|
text = string(runes[:200]) + "..."
|
|
}
|
|
result.WriteString(fmt.Sprintf("\n🔗 %s\n", text))
|
|
if topic.FirstURL != "" {
|
|
result.WriteString(fmt.Sprintf(" %s\n", topic.FirstURL))
|
|
}
|
|
count++
|
|
}
|
|
}
|
|
}
|
|
|
|
if result.Len() == 0 {
|
|
result.WriteString("未找到相关结果。")
|
|
}
|
|
|
|
return &model.ToolResult{
|
|
Output: result.String(),
|
|
Error: "",
|
|
}, nil
|
|
}
|
|
|
|
// stripHTML 去除 HTML 标签
|
|
func stripHTML(s string) string {
|
|
inTag := false
|
|
var result []rune
|
|
for _, r := range s {
|
|
if r == '<' {
|
|
inTag = true
|
|
continue
|
|
}
|
|
if r == '>' {
|
|
inTag = false
|
|
// 替换常见块级标签为空格
|
|
result = append(result, ' ')
|
|
continue
|
|
}
|
|
if !inTag {
|
|
result = append(result, r)
|
|
}
|
|
}
|
|
return strings.TrimSpace(string(result))
|
|
}
|