feat: 昔涟工具扩展 — OpenAI Function Calling 集成 (网络搜索/网页抓取/IoT设备查询)
This commit is contained in:
@@ -8,6 +8,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@@ -96,6 +97,17 @@ func main() {
|
||||
log.Println("IoT 客户端未配置 (IOT_DEBUG_SERVICE_URL 为空)")
|
||||
}
|
||||
|
||||
// 初始化工具注册中心
|
||||
toolRegistry := tools.NewRegistry()
|
||||
if getEnvBool("ENABLE_TOOLS", true) {
|
||||
toolRegistry.Register(tools.NewWebFetchTool())
|
||||
toolRegistry.Register(tools.NewWebSearchTool())
|
||||
if iotClient != nil {
|
||||
toolRegistry.Register(tools.NewIoTQueryTool(iotClient))
|
||||
}
|
||||
log.Printf("工具注册中心已就绪: %d 个工具 (%v)", len(toolRegistry.ListTools()), toolRegistry.ListTools())
|
||||
}
|
||||
|
||||
// 初始化后台思考器
|
||||
thinkerCfg := background.DefaultThinkerConfig()
|
||||
thinker := background.NewThinker(thinkerCfg, personaLoader, memRetriever, llmAdapter, iotClient)
|
||||
@@ -110,7 +122,7 @@ func main() {
|
||||
|
||||
// 注册对话API端点
|
||||
mux.HandleFunc("/api/v1/chat", func(w http.ResponseWriter, r *http.Request) {
|
||||
handleChat(w, r, orch, ctxBuilder, llmAdapter, personaLoader, memRetriever, memExtractor, iotClient, thinker)
|
||||
handleChat(w, r, orch, ctxBuilder, llmAdapter, personaLoader, memRetriever, memExtractor, iotClient, thinker, toolRegistry)
|
||||
})
|
||||
|
||||
// 注册记忆API端点
|
||||
@@ -195,7 +207,45 @@ func getEnv(key, fallback string) string {
|
||||
return fallback
|
||||
}
|
||||
|
||||
// handleChat 处理对话请求(SSE 流式响应)
|
||||
func getEnvBool(key string, fallback bool) bool {
|
||||
v := os.Getenv(key)
|
||||
if v == "" {
|
||||
return fallback
|
||||
}
|
||||
switch strings.ToLower(v) {
|
||||
case "true", "1", "yes", "on":
|
||||
return true
|
||||
case "false", "0", "no", "off":
|
||||
return false
|
||||
default:
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
// buildOpenAITools 将工具注册中心的定义转换为 LLM 层的 OpenAITool 格式
|
||||
func buildOpenAITools(registry *tools.Registry) []llm.OpenAITool {
|
||||
if registry == nil || !registry.IsEnabled() {
|
||||
return nil
|
||||
}
|
||||
defs := registry.GetDefinitions()
|
||||
if len(defs) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make([]llm.OpenAITool, 0, len(defs))
|
||||
for _, d := range defs {
|
||||
result = append(result, llm.OpenAITool{
|
||||
Type: "function",
|
||||
Function: llm.OpenAIToolFunc{
|
||||
Name: d.Name,
|
||||
Description: d.Description,
|
||||
Parameters: d.Parameters,
|
||||
},
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// handleChat 处理对话请求(SSE 流式响应 + 工具调用)
|
||||
func handleChat(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
@@ -207,6 +257,7 @@ func handleChat(
|
||||
memExtractor *memory.Extractor,
|
||||
iotClient *tools.IoTClient,
|
||||
thinker *background.Thinker,
|
||||
toolRegistry *tools.Registry,
|
||||
) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
@@ -323,7 +374,52 @@ func handleChat(
|
||||
return
|
||||
}
|
||||
|
||||
// 5. 调用LLM流式接口
|
||||
// 5. 准备工具定义
|
||||
openAITools := buildOpenAITools(toolRegistry)
|
||||
|
||||
// 5.1 如果启用了工具,先进行同步调用检测是否需要工具调用
|
||||
if len(openAITools) > 0 {
|
||||
log.Printf("[chat] 启用工具调用: %d 个工具可用", len(openAITools))
|
||||
|
||||
syncResp, syncErr := llmAdapter.ChatWithTools(ctx, llmMessages, openAITools)
|
||||
if syncErr != nil {
|
||||
log.Printf("[chat] 工具检测调用失败: %v,降级为普通对话", syncErr)
|
||||
} else if len(syncResp.ToolCalls) > 0 {
|
||||
log.Printf("[chat] 模型请求 %d 个工具调用", len(syncResp.ToolCalls))
|
||||
|
||||
// 将助手消息(含工具调用)加入上下文
|
||||
assistantMsg := model.LLMMessage{
|
||||
Role: model.RoleAssistant,
|
||||
Content: syncResp.Content,
|
||||
ToolCalls: syncResp.ToolCalls,
|
||||
}
|
||||
llmMessages = append(llmMessages, assistantMsg)
|
||||
|
||||
// 执行每个工具调用并将结果加入上下文
|
||||
for _, tc := range syncResp.ToolCalls {
|
||||
var args map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(tc.Arguments), &args); err != nil {
|
||||
log.Printf("[chat] 工具 %s 参数解析失败: %v", tc.Name, err)
|
||||
args = make(map[string]interface{})
|
||||
}
|
||||
|
||||
result, execErr := toolRegistry.Execute(ctx, tc.Name, args)
|
||||
if execErr != nil {
|
||||
log.Printf("[chat] 工具 %s 执行失败: %v", tc.Name, execErr)
|
||||
}
|
||||
|
||||
resultJSON, _ := json.Marshal(result)
|
||||
llmMessages = append(llmMessages, model.LLMMessage{
|
||||
Role: model.RoleTool,
|
||||
Content: string(resultJSON),
|
||||
ToolCallID: tc.ID,
|
||||
})
|
||||
}
|
||||
}
|
||||
// 无论是否有工具调用,继续流式输出最终回复
|
||||
}
|
||||
|
||||
// 5.2 调用LLM流式接口(可能已附加工具结果)
|
||||
chunkCh, err := llmAdapter.ChatStream(ctx, llmMessages)
|
||||
if err != nil {
|
||||
// 流式初始化失败,返回 SSE 格式错误
|
||||
@@ -336,7 +432,6 @@ func handleChat(
|
||||
}
|
||||
|
||||
messageID := fmt.Sprintf("msg-%d", time.Now().UnixNano())
|
||||
|
||||
// 6. 逐 token 推送 SSE
|
||||
var fullContent string
|
||||
var segments []llm.Segment
|
||||
|
||||
@@ -13,6 +13,19 @@ type Adapter struct {
|
||||
provider LLMProvider
|
||||
}
|
||||
|
||||
// OpenAITool 暴露给调用方使用的工具定义(与 openai.go 的 openAITool 等价)
|
||||
type OpenAITool struct {
|
||||
Type string `json:"type"`
|
||||
Function OpenAIToolFunc `json:"function"`
|
||||
}
|
||||
|
||||
// OpenAIToolFunc 工具函数定义
|
||||
type OpenAIToolFunc struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Parameters map[string]interface{} `json:"parameters"`
|
||||
}
|
||||
|
||||
// LLMProvider LLM提供商接口
|
||||
type LLMProvider interface {
|
||||
// Chat 同步对话
|
||||
@@ -21,6 +34,12 @@ type LLMProvider interface {
|
||||
// ChatStream 流式对话,返回一个channel逐token推送
|
||||
ChatStream(ctx context.Context, messages []model.LLMMessage) (<-chan StreamChunk, error)
|
||||
|
||||
// ChatWithTools 同步对话(支持工具调用),tools 为 nil 时等价于 Chat
|
||||
ChatWithTools(ctx context.Context, messages []model.LLMMessage, tools []OpenAITool) (*model.LLMResponse, error)
|
||||
|
||||
// ChatStreamWithTools 流式对话(支持工具调用),tools 为 nil 时等价于 ChatStream
|
||||
ChatStreamWithTools(ctx context.Context, messages []model.LLMMessage, tools []OpenAITool) (<-chan StreamChunk, error)
|
||||
|
||||
// ModelName 返回当前使用的模型名称
|
||||
ModelName() string
|
||||
}
|
||||
@@ -43,11 +62,21 @@ func (a *Adapter) Chat(ctx context.Context, messages []model.LLMMessage) (*model
|
||||
return a.provider.Chat(ctx, messages)
|
||||
}
|
||||
|
||||
// ChatWithTools 同步对话(支持工具调用)
|
||||
func (a *Adapter) ChatWithTools(ctx context.Context, messages []model.LLMMessage, tools []OpenAITool) (*model.LLMResponse, error) {
|
||||
return a.provider.ChatWithTools(ctx, messages, tools)
|
||||
}
|
||||
|
||||
// ChatStream 流式对话
|
||||
func (a *Adapter) ChatStream(ctx context.Context, messages []model.LLMMessage) (<-chan StreamChunk, error) {
|
||||
return a.provider.ChatStream(ctx, messages)
|
||||
}
|
||||
|
||||
// ChatStreamWithTools 流式对话(支持工具调用)
|
||||
func (a *Adapter) ChatStreamWithTools(ctx context.Context, messages []model.LLMMessage, tools []OpenAITool) (<-chan StreamChunk, error) {
|
||||
return a.provider.ChatStreamWithTools(ctx, messages, tools)
|
||||
}
|
||||
|
||||
// ModelName 返回模型名称
|
||||
func (a *Adapter) ModelName() string {
|
||||
return a.provider.ModelName()
|
||||
|
||||
@@ -55,11 +55,28 @@ type openAIRequest struct {
|
||||
Temperature float64 `json:"temperature"`
|
||||
MaxTokens int `json:"max_tokens,omitempty"`
|
||||
Stream bool `json:"stream"`
|
||||
Tools []OpenAITool `json:"tools,omitempty"`
|
||||
ToolChoice string `json:"tool_choice,omitempty"` // "auto", "none", or specific tool
|
||||
}
|
||||
|
||||
type openAIMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
ToolCalls []openAIToolCall `json:"tool_calls,omitempty"`
|
||||
ToolCallID string `json:"tool_call_id,omitempty"`
|
||||
}
|
||||
|
||||
// openAIToolCall OpenAI工具调用
|
||||
type openAIToolCall struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Function openAIToolCallFunction `json:"function"`
|
||||
}
|
||||
|
||||
type openAIToolCallFunction struct {
|
||||
Name string `json:"name"`
|
||||
Arguments string `json:"arguments"` // JSON string
|
||||
}
|
||||
|
||||
// openAIResponse OpenAI响应结构
|
||||
@@ -74,6 +91,7 @@ type openAIResponse struct {
|
||||
type openAIChoice struct {
|
||||
Index int `json:"index"`
|
||||
Message openAIMessage `json:"message"`
|
||||
Delta openAIMessage `json:"delta,omitempty"`
|
||||
FinishReason string `json:"finish_reason"`
|
||||
}
|
||||
|
||||
@@ -91,12 +109,17 @@ type openAIError struct {
|
||||
|
||||
// Chat 同步对话
|
||||
func (p *OpenAIProvider) Chat(ctx context.Context, messages []model.LLMMessage) (*model.LLMResponse, error) {
|
||||
resp, err := p.doChat(ctx, messages, p.config.Model, false)
|
||||
return p.ChatWithTools(ctx, messages, nil)
|
||||
}
|
||||
|
||||
// ChatWithTools 同步对话(支持工具调用)
|
||||
func (p *OpenAIProvider) ChatWithTools(ctx context.Context, messages []model.LLMMessage, tools []OpenAITool) (*model.LLMResponse, error) {
|
||||
resp, err := p.doChat(ctx, messages, p.config.Model, false, tools)
|
||||
if err != nil {
|
||||
// 尝试fallback模型
|
||||
if p.config.FallbackModel != "" && p.config.FallbackModel != p.config.Model {
|
||||
log.Printf("[LLM] 主模型 %s 调用失败,降级到 %s: %v", p.config.Model, p.config.FallbackModel, err)
|
||||
return p.doChat(ctx, messages, p.config.FallbackModel, false)
|
||||
return p.doChat(ctx, messages, p.config.FallbackModel, false, tools)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
@@ -105,17 +128,22 @@ func (p *OpenAIProvider) Chat(ctx context.Context, messages []model.LLMMessage)
|
||||
|
||||
// ChatStream 流式对话
|
||||
func (p *OpenAIProvider) ChatStream(ctx context.Context, messages []model.LLMMessage) (<-chan StreamChunk, error) {
|
||||
return p.ChatStreamWithTools(ctx, messages, nil)
|
||||
}
|
||||
|
||||
// ChatStreamWithTools 流式对话(支持工具调用)
|
||||
func (p *OpenAIProvider) ChatStreamWithTools(ctx context.Context, messages []model.LLMMessage, tools []OpenAITool) (<-chan StreamChunk, error) {
|
||||
ch := make(chan StreamChunk, 100)
|
||||
|
||||
go func() {
|
||||
defer close(ch)
|
||||
|
||||
resp, err := p.doChatStream(ctx, messages, p.config.Model)
|
||||
resp, err := p.doChatStream(ctx, messages, p.config.Model, tools)
|
||||
if err != nil {
|
||||
// Fallback
|
||||
if p.config.FallbackModel != "" {
|
||||
log.Printf("[LLM] 流式调用主模型失败,降级: %v", err)
|
||||
resp, err = p.doChatStream(ctx, messages, p.config.FallbackModel)
|
||||
resp, err = p.doChatStream(ctx, messages, p.config.FallbackModel, tools)
|
||||
}
|
||||
if err != nil {
|
||||
ch <- StreamChunk{Error: err, Done: true}
|
||||
@@ -193,14 +221,31 @@ type openAIStreamChoice struct {
|
||||
}
|
||||
|
||||
// doChat 执行同步对话请求
|
||||
func (p *OpenAIProvider) doChat(ctx context.Context, messages []model.LLMMessage, modelName string, stream bool) (*model.LLMResponse, error) {
|
||||
func (p *OpenAIProvider) doChat(ctx context.Context, messages []model.LLMMessage, modelName string, stream bool, tools []OpenAITool) (*model.LLMResponse, error) {
|
||||
// 转换消息格式
|
||||
oaiMessages := make([]openAIMessage, len(messages))
|
||||
for i, msg := range messages {
|
||||
oaiMessages[i] = openAIMessage{
|
||||
Role: string(msg.Role),
|
||||
Content: msg.Content,
|
||||
oaiMsg := openAIMessage{
|
||||
Role: string(msg.Role),
|
||||
Content: msg.Content,
|
||||
Name: msg.Name,
|
||||
ToolCallID: msg.ToolCallID,
|
||||
}
|
||||
// 转换工具调用
|
||||
if len(msg.ToolCalls) > 0 {
|
||||
oaiMsg.ToolCalls = make([]openAIToolCall, len(msg.ToolCalls))
|
||||
for j, tc := range msg.ToolCalls {
|
||||
oaiMsg.ToolCalls[j] = openAIToolCall{
|
||||
ID: tc.ID,
|
||||
Type: "function",
|
||||
Function: openAIToolCallFunction{
|
||||
Name: tc.Name,
|
||||
Arguments: tc.Arguments,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
oaiMessages[i] = oaiMsg
|
||||
}
|
||||
|
||||
reqBody := openAIRequest{
|
||||
@@ -208,6 +253,10 @@ func (p *OpenAIProvider) doChat(ctx context.Context, messages []model.LLMMessage
|
||||
Messages: oaiMessages,
|
||||
Temperature: 0.8,
|
||||
Stream: stream,
|
||||
Tools: tools,
|
||||
}
|
||||
if len(tools) > 0 {
|
||||
reqBody.ToolChoice = "auto"
|
||||
}
|
||||
|
||||
jsonBody, err := json.Marshal(reqBody)
|
||||
@@ -251,25 +300,56 @@ func (p *OpenAIProvider) doChat(ctx context.Context, messages []model.LLMMessage
|
||||
return nil, fmt.Errorf("API返回空choices")
|
||||
}
|
||||
|
||||
return &model.LLMResponse{
|
||||
Content: oaiResp.Choices[0].Message.Content,
|
||||
FinishReason: oaiResp.Choices[0].FinishReason,
|
||||
// 检查是否有工具调用
|
||||
choice := oaiResp.Choices[0]
|
||||
llmResp := &model.LLMResponse{
|
||||
Content: choice.Message.Content,
|
||||
FinishReason: choice.FinishReason,
|
||||
Usage: model.Usage{
|
||||
PromptTokens: oaiResp.Usage.PromptTokens,
|
||||
CompletionTokens: oaiResp.Usage.CompletionTokens,
|
||||
TotalTokens: oaiResp.Usage.TotalTokens,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
if len(choice.Message.ToolCalls) > 0 {
|
||||
llmResp.ToolCalls = make([]model.ToolCall, 0, len(choice.Message.ToolCalls))
|
||||
for _, tc := range choice.Message.ToolCalls {
|
||||
llmResp.ToolCalls = append(llmResp.ToolCalls, model.ToolCall{
|
||||
ID: tc.ID,
|
||||
Name: tc.Function.Name,
|
||||
Arguments: tc.Function.Arguments,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return llmResp, nil
|
||||
}
|
||||
|
||||
// doChatStream 执行流式对话请求(返回原始HTTP响应)
|
||||
func (p *OpenAIProvider) doChatStream(ctx context.Context, messages []model.LLMMessage, modelName string) (*http.Response, error) {
|
||||
func (p *OpenAIProvider) doChatStream(ctx context.Context, messages []model.LLMMessage, modelName string, tools []OpenAITool) (*http.Response, error) {
|
||||
oaiMessages := make([]openAIMessage, len(messages))
|
||||
for i, msg := range messages {
|
||||
oaiMessages[i] = openAIMessage{
|
||||
Role: string(msg.Role),
|
||||
Content: msg.Content,
|
||||
oaiMsg := openAIMessage{
|
||||
Role: string(msg.Role),
|
||||
Content: msg.Content,
|
||||
Name: msg.Name,
|
||||
ToolCallID: msg.ToolCallID,
|
||||
}
|
||||
if len(msg.ToolCalls) > 0 {
|
||||
oaiMsg.ToolCalls = make([]openAIToolCall, len(msg.ToolCalls))
|
||||
for j, tc := range msg.ToolCalls {
|
||||
oaiMsg.ToolCalls[j] = openAIToolCall{
|
||||
ID: tc.ID,
|
||||
Type: "function",
|
||||
Function: openAIToolCallFunction{
|
||||
Name: tc.Name,
|
||||
Arguments: tc.Arguments,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
oaiMessages[i] = oaiMsg
|
||||
}
|
||||
|
||||
reqBody := openAIRequest{
|
||||
@@ -277,6 +357,10 @@ func (p *OpenAIProvider) doChatStream(ctx context.Context, messages []model.LLMM
|
||||
Messages: oaiMessages,
|
||||
Temperature: 0.8,
|
||||
Stream: true,
|
||||
Tools: tools,
|
||||
}
|
||||
if len(tools) > 0 {
|
||||
reqBody.ToolChoice = "auto"
|
||||
}
|
||||
|
||||
jsonBody, err := json.Marshal(reqBody)
|
||||
|
||||
@@ -14,10 +14,11 @@ const (
|
||||
|
||||
// LLMMessage 发送给LLM的消息
|
||||
type LLMMessage struct {
|
||||
Role Role `json:"role"`
|
||||
Content string `json:"content"`
|
||||
Name string `json:"name,omitempty"` // 可选发送者名称
|
||||
ToolCallID string `json:"tool_call_id,omitempty"` // 工具调用关联ID
|
||||
Role Role `json:"role"`
|
||||
Content string `json:"content"`
|
||||
Name string `json:"name,omitempty"` // 可选发送者名称
|
||||
ToolCallID string `json:"tool_call_id,omitempty"` // 工具调用关联ID (tool role 消息关联调用)
|
||||
ToolCalls []ToolCall `json:"tool_calls,omitempty"` // 助手消息中的工具调用列表
|
||||
}
|
||||
|
||||
// ChatMessage 数据库存储的对话消息
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// IoTDevice 设备结构体(与 IoT 调试服务的结构对应)
|
||||
type IoTDevice struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Status string `json:"status"`
|
||||
Brightness int `json:"brightness,omitempty"`
|
||||
Color string `json:"color,omitempty"`
|
||||
Temperature float64 `json:"temperature,omitempty"`
|
||||
Mode string `json:"mode,omitempty"`
|
||||
Position int `json:"position,omitempty"`
|
||||
Value float64 `json:"value,omitempty"`
|
||||
Unit string `json:"unit,omitempty"`
|
||||
Battery int `json:"battery,omitempty"`
|
||||
LastUpdated string `json:"last_updated"`
|
||||
}
|
||||
|
||||
// IoTClient IoT 调试服务 HTTP 客户端
|
||||
type IoTClient struct {
|
||||
baseURL string
|
||||
client *http.Client
|
||||
|
||||
// 缓存控制
|
||||
mu sync.RWMutex
|
||||
cache []IoTDevice
|
||||
cacheTime time.Time
|
||||
cacheTTL time.Duration
|
||||
}
|
||||
|
||||
// NewIoTClient 创建 IoT 客户端
|
||||
func NewIoTClient(baseURL string) *IoTClient {
|
||||
if baseURL == "" {
|
||||
baseURL = getEnv("IOT_DEBUG_SERVICE_URL", "http://localhost:8083")
|
||||
}
|
||||
return &IoTClient{
|
||||
baseURL: baseURL,
|
||||
client: &http.Client{
|
||||
Timeout: 5 * time.Second,
|
||||
},
|
||||
cacheTTL: 60 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
// GetAllDevices 获取所有设备列表(带缓存)
|
||||
func (c *IoTClient) GetAllDevices() ([]IoTDevice, error) {
|
||||
// 检查缓存
|
||||
c.mu.RLock()
|
||||
if c.cache != nil && time.Since(c.cacheTime) < c.cacheTTL {
|
||||
devices := make([]IoTDevice, len(c.cache))
|
||||
copy(devices, c.cache)
|
||||
c.mu.RUnlock()
|
||||
return devices, nil
|
||||
}
|
||||
c.mu.RUnlock()
|
||||
|
||||
// 请求 API
|
||||
resp, err := c.client.Get(c.baseURL + "/api/v1/devices")
|
||||
if err != nil {
|
||||
log.Printf("[IoT客户端] 请求失败: %v", err)
|
||||
return nil, fmt.Errorf("获取设备列表失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("获取设备列表返回状态码 %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Devices []IoTDevice `json:"devices"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("解析设备列表失败: %w", err)
|
||||
}
|
||||
|
||||
// 更新缓存
|
||||
c.mu.Lock()
|
||||
c.cache = result.Devices
|
||||
c.cacheTime = time.Now()
|
||||
c.mu.Unlock()
|
||||
|
||||
return result.Devices, nil
|
||||
}
|
||||
|
||||
// GetDevice 获取单个设备详情
|
||||
func (c *IoTClient) GetDevice(id string) (*IoTDevice, error) {
|
||||
resp, err := c.client.Get(c.baseURL + "/api/v1/devices/" + id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取设备 %s 失败: %w", id, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return nil, fmt.Errorf("设备 %s 不存在", id)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("获取设备 %s 返回状态码 %d", id, resp.StatusCode)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Device IoTDevice `json:"device"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("解析设备信息失败: %w", err)
|
||||
}
|
||||
|
||||
return &result.Device, nil
|
||||
}
|
||||
|
||||
// ToggleDevice 切换设备开关状态
|
||||
func (c *IoTClient) ToggleDevice(id string) error {
|
||||
req, err := http.NewRequest(http.MethodPost, c.baseURL+"/api/v1/devices/"+id+"/toggle", nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建切换请求失败: %w", err)
|
||||
}
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("切换设备 %s 失败: %w", id, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return fmt.Errorf("设备 %s 不存在", id)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("切换设备 %s 返回状态码 %d", id, resp.StatusCode)
|
||||
}
|
||||
|
||||
// 切换后清除缓存,确保下次查询获取最新状态
|
||||
c.mu.Lock()
|
||||
c.cache = nil
|
||||
c.mu.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetDevicesForContext 获取设备状态摘要(供上下文注入使用,失败不报错)
|
||||
func (c *IoTClient) GetDevicesForContext() []IoTDevice {
|
||||
devices, err := c.GetAllDevices()
|
||||
if err != nil {
|
||||
log.Printf("[IoT客户端] 获取设备状态摘要失败: %v", err)
|
||||
return nil
|
||||
}
|
||||
return devices
|
||||
}
|
||||
|
||||
// InvalidateCache 使缓存失效
|
||||
func (c *IoTClient) InvalidateCache() {
|
||||
c.mu.Lock()
|
||||
c.cache = nil
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
func getEnv(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// IoTQueryTool IoT 设备查询工具
|
||||
type IoTQueryTool struct {
|
||||
iotClient *IoTClient
|
||||
}
|
||||
|
||||
// NewIoTQueryTool 创建 IoT 查询工具
|
||||
func NewIoTQueryTool(iotClient *IoTClient) *IoTQueryTool {
|
||||
return &IoTQueryTool{iotClient: iotClient}
|
||||
}
|
||||
|
||||
// Definition 返回工具定义
|
||||
func (t *IoTQueryTool) Definition() ToolDefinition {
|
||||
return ToolDefinition{
|
||||
Name: "iot_query",
|
||||
Description: "查询家中智能设备状态。可以查看所有设备或指定设备的状态,包括灯光、空调、窗帘、传感器、门锁等。用于了解家中设备当前的状态。",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"device_id": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "要查询的设备ID(可选,不填则返回所有设备)。可选值: light-livingroom, light-bedroom, ac-livingroom, ac-bedroom, curtain-livingroom, sensor-temperature, sensor-humidity, lock-door",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Execute 执行查询
|
||||
func (t *IoTQueryTool) Execute(ctx context.Context, arguments map[string]interface{}) (*ToolResult, error) {
|
||||
if t.iotClient == nil {
|
||||
return &ToolResult{
|
||||
ToolName: "iot_query",
|
||||
Success: false,
|
||||
Error: "IoT 客户端未初始化",
|
||||
}, nil
|
||||
}
|
||||
|
||||
deviceID, _ := arguments["device_id"].(string)
|
||||
|
||||
if deviceID != "" {
|
||||
// 查询单个设备
|
||||
device, err := t.iotClient.GetDevice(deviceID)
|
||||
if err != nil {
|
||||
return &ToolResult{
|
||||
ToolName: "iot_query",
|
||||
Success: false,
|
||||
Error: err.Error(),
|
||||
}, nil
|
||||
}
|
||||
return &ToolResult{
|
||||
ToolName: "iot_query",
|
||||
Success: true,
|
||||
Data: formatSingleDevice(device),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// 查询所有设备
|
||||
devices, err := t.iotClient.GetAllDevices()
|
||||
if err != nil {
|
||||
return &ToolResult{
|
||||
ToolName: "iot_query",
|
||||
Success: false,
|
||||
Error: err.Error(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
var result strings.Builder
|
||||
result.WriteString(fmt.Sprintf("当前共有 %d 台智能设备:\n\n", len(devices)))
|
||||
for _, d := range devices {
|
||||
result.WriteString(formatDeviceLine(d) + "\n")
|
||||
}
|
||||
return &ToolResult{
|
||||
ToolName: "iot_query",
|
||||
Success: true,
|
||||
Data: result.String(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func formatSingleDevice(d *IoTDevice) string {
|
||||
return fmt.Sprintf("设备: %s (%s)\n状态: %s", d.Name, formatDeviceLine(*d))
|
||||
}
|
||||
|
||||
func formatDeviceLine(d IoTDevice) string {
|
||||
switch d.Type {
|
||||
case "light":
|
||||
if d.Status == "on" {
|
||||
return fmt.Sprintf("💡 %s: 开启 (亮度%d%%, %s)", d.Name, d.Brightness, d.Color)
|
||||
}
|
||||
return fmt.Sprintf("💡 %s: 关闭", d.Name)
|
||||
case "ac":
|
||||
if d.Status == "on" {
|
||||
mode := d.Mode
|
||||
switch mode {
|
||||
case "cool":
|
||||
mode = "制冷"
|
||||
case "heat":
|
||||
mode = "制热"
|
||||
case "auto":
|
||||
mode = "自动"
|
||||
}
|
||||
return fmt.Sprintf("❄️ %s: 运行中 (%s %.0f°C)", d.Name, mode, d.Temperature)
|
||||
}
|
||||
return fmt.Sprintf("❄️ %s: 关闭", d.Name)
|
||||
case "curtain":
|
||||
if d.Status == "open" {
|
||||
return fmt.Sprintf("🪟 %s: 已打开", d.Name)
|
||||
}
|
||||
return fmt.Sprintf("🪟 %s: 已关闭", d.Name)
|
||||
case "sensor":
|
||||
unit := d.Unit
|
||||
if unit == "celsius" {
|
||||
unit = "°C"
|
||||
} else if unit == "percent" {
|
||||
unit = "%"
|
||||
}
|
||||
return fmt.Sprintf("🌡️ %s: %.1f%s", d.Name, d.Value, unit)
|
||||
case "lock":
|
||||
status := "已锁定"
|
||||
if d.Status == "unlocked" {
|
||||
status = "已解锁"
|
||||
}
|
||||
return fmt.Sprintf("🔒 %s: %s (电量%d%%)", d.Name, status, d.Battery)
|
||||
default:
|
||||
return fmt.Sprintf("%s: %s", d.Name, d.Status)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// ToolDefinition 工具定义(用于 LLM function calling)
|
||||
type ToolDefinition struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Parameters map[string]interface{} `json:"parameters"`
|
||||
}
|
||||
|
||||
// ToolResult 工具执行结果
|
||||
type ToolResult struct {
|
||||
ToolName string `json:"tool_name"`
|
||||
Success bool `json:"success"`
|
||||
Data string `json:"data,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// ToolExecutor 工具执行器接口
|
||||
type ToolExecutor interface {
|
||||
// Execute 执行工具调用
|
||||
Execute(ctx context.Context, arguments map[string]interface{}) (*ToolResult, error)
|
||||
// Definition 返回工具定义
|
||||
Definition() ToolDefinition
|
||||
}
|
||||
|
||||
// Registry 工具注册中心
|
||||
type Registry struct {
|
||||
mu sync.RWMutex
|
||||
tools map[string]ToolExecutor
|
||||
enabled bool
|
||||
}
|
||||
|
||||
// NewRegistry 创建工具注册中心
|
||||
func NewRegistry() *Registry {
|
||||
return &Registry{
|
||||
tools: make(map[string]ToolExecutor),
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
// Register 注册工具
|
||||
func (r *Registry) Register(executor ToolExecutor) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
def := executor.Definition()
|
||||
r.tools[def.Name] = executor
|
||||
log.Printf("[工具注册] 已注册工具: %s", def.Name)
|
||||
}
|
||||
|
||||
// GetDefinitions 获取所有工具定义(用于 LLM function calling)
|
||||
func (r *Registry) GetDefinitions() []ToolDefinition {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
defs := make([]ToolDefinition, 0, len(r.tools))
|
||||
for _, executor := range r.tools {
|
||||
defs = append(defs, executor.Definition())
|
||||
}
|
||||
return defs
|
||||
}
|
||||
|
||||
// Execute 执行工具调用
|
||||
func (r *Registry) Execute(ctx context.Context, toolName string, arguments map[string]interface{}) (*ToolResult, error) {
|
||||
r.mu.RLock()
|
||||
executor, ok := r.tools[toolName]
|
||||
r.mu.RUnlock()
|
||||
|
||||
if !ok {
|
||||
return &ToolResult{
|
||||
ToolName: toolName,
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("未知工具: %s", toolName),
|
||||
}, nil
|
||||
}
|
||||
|
||||
log.Printf("[工具执行] 调用工具 %s,参数: %v", toolName, arguments)
|
||||
result, err := executor.Execute(ctx, arguments)
|
||||
if err != nil {
|
||||
log.Printf("[工具执行] 工具 %s 执行失败: %v", toolName, err)
|
||||
return &ToolResult{
|
||||
ToolName: toolName,
|
||||
Success: false,
|
||||
Error: err.Error(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
if result.Success {
|
||||
log.Printf("[工具执行] 工具 %s 执行成功 (数据长度: %d)", toolName, len(result.Data))
|
||||
} else {
|
||||
log.Printf("[工具执行] 工具 %s 返回错误: %s", toolName, result.Error)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// IsEnabled 检查工具系统是否启用
|
||||
func (r *Registry) IsEnabled() bool {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
return r.enabled
|
||||
}
|
||||
|
||||
// SetEnabled 启用/禁用工具系统
|
||||
func (r *Registry) SetEnabled(enabled bool) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.enabled = enabled
|
||||
}
|
||||
|
||||
// HasTool 检查工具是否存在
|
||||
func (r *Registry) HasTool(name string) bool {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
_, ok := r.tools[name]
|
||||
return ok
|
||||
}
|
||||
|
||||
// ListTools 列出所有已注册的工具名称
|
||||
func (r *Registry) ListTools() []string {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
names := make([]string, 0, len(r.tools))
|
||||
for name := range r.tools {
|
||||
names = append(names, name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// ToJSON 将工具定义序列化为 JSON(用于 LLM 请求)
|
||||
func (r *Registry) ToJSON() ([]byte, error) {
|
||||
defs := r.GetDefinitions()
|
||||
tools := make([]map[string]interface{}, 0, len(defs))
|
||||
for _, d := range defs {
|
||||
tools = append(tools, map[string]interface{}{
|
||||
"type": "function",
|
||||
"function": map[string]interface{}{
|
||||
"name": d.Name,
|
||||
"description": d.Description,
|
||||
"parameters": d.Parameters,
|
||||
},
|
||||
})
|
||||
}
|
||||
return json.Marshal(tools)
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// WebFetchTool 网络访问工具 - 允许昔涟获取网页内容
|
||||
type WebFetchTool struct {
|
||||
client *http.Client
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
// NewWebFetchTool 创建网络访问工具
|
||||
func NewWebFetchTool() *WebFetchTool {
|
||||
return &WebFetchTool{
|
||||
client: &http.Client{
|
||||
Timeout: 15 * time.Second,
|
||||
},
|
||||
timeout: 15 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
// Definition 返回工具定义
|
||||
func (t *WebFetchTool) Definition() ToolDefinition {
|
||||
return ToolDefinition{
|
||||
Name: "web_fetch",
|
||||
Description: "获取指定URL的网页内容。用于查阅新闻、文档、资料等。返回纯文本摘要(前2000字符)。仅支持 HTTP/HTTPS URL。",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"url": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "要获取的网页URL,必须是完整的 http:// 或 https:// 链接",
|
||||
},
|
||||
},
|
||||
"required": []string{"url"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Execute 执行网页获取
|
||||
func (t *WebFetchTool) Execute(ctx context.Context, arguments map[string]interface{}) (*ToolResult, error) {
|
||||
url, ok := arguments["url"].(string)
|
||||
if !ok || url == "" {
|
||||
return &ToolResult{
|
||||
ToolName: "web_fetch",
|
||||
Success: false,
|
||||
Error: "缺少 url 参数",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// 安全检查:只允许 HTTP/HTTPS
|
||||
if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
|
||||
return &ToolResult{
|
||||
ToolName: "web_fetch",
|
||||
Success: false,
|
||||
Error: "仅支持 http:// 或 https:// 链接",
|
||||
}, nil
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return &ToolResult{
|
||||
ToolName: "web_fetch",
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("创建请求失败: %v", err),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// 模拟常见浏览器 User-Agent,避免被拒
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; CyreneBot/1.0; +https://github.com/AskaEth/Cyrene)")
|
||||
req.Header.Set("Accept", "text/html,text/plain,*/*")
|
||||
|
||||
resp, err := t.client.Do(req)
|
||||
if err != nil {
|
||||
return &ToolResult{
|
||||
ToolName: "web_fetch",
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("请求失败: %v", err),
|
||||
}, nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return &ToolResult{
|
||||
ToolName: "web_fetch",
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("HTTP %d", resp.StatusCode),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// 限制读取大小(最多 100KB)
|
||||
limitedReader := io.LimitReader(resp.Body, 100*1024)
|
||||
body, err := io.ReadAll(limitedReader)
|
||||
if err != nil {
|
||||
return &ToolResult{
|
||||
ToolName: "web_fetch",
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("读取响应失败: %v", err),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// 提取纯文本摘要(去除 HTML 标签)
|
||||
text := extractText(string(body))
|
||||
|
||||
// 截断到 2000 字符
|
||||
if len([]rune(text)) > 2000 {
|
||||
runes := []rune(text)
|
||||
text = string(runes[:2000]) + "\n\n... [内容已截断,共" + fmt.Sprintf("%d", len(runes)) + "字符]"
|
||||
}
|
||||
|
||||
result := fmt.Sprintf("URL: %s\n状态: %d\n内容类型: %s\n\n%s",
|
||||
url, resp.StatusCode, resp.Header.Get("Content-Type"), text)
|
||||
|
||||
return &ToolResult{
|
||||
ToolName: "web_fetch",
|
||||
Success: true,
|
||||
Data: result,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// extractText 从 HTML/文本中提取纯文本
|
||||
func extractText(raw string) string {
|
||||
// 简单的 HTML 标签去除
|
||||
text := raw
|
||||
inTag := false
|
||||
var result []rune
|
||||
for _, r := range text {
|
||||
if r == '<' {
|
||||
inTag = true
|
||||
continue
|
||||
}
|
||||
if r == '>' {
|
||||
inTag = false
|
||||
continue
|
||||
}
|
||||
if !inTag {
|
||||
result = append(result, r)
|
||||
}
|
||||
}
|
||||
|
||||
// 去除多余空白
|
||||
trimmed := strings.TrimSpace(string(result))
|
||||
// 压缩连续空行
|
||||
lines := strings.Split(trimmed, "\n")
|
||||
var cleanLines []string
|
||||
for _, line := range lines {
|
||||
trimLine := strings.TrimSpace(line)
|
||||
if trimLine != "" {
|
||||
cleanLines = append(cleanLines, trimLine)
|
||||
}
|
||||
}
|
||||
return strings.Join(cleanLines, "\n")
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 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() ToolDefinition {
|
||||
return 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{}) (*ToolResult, error) {
|
||||
query, ok := arguments["query"].(string)
|
||||
if !ok || query == "" {
|
||||
return &ToolResult{
|
||||
ToolName: "web_search",
|
||||
Success: false,
|
||||
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 &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
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
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
|
||||
}
|
||||
|
||||
var ddg duckDuckGoResponse
|
||||
if err := json.Unmarshal(body, &ddg); 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 {
|
||||
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 &ToolResult{
|
||||
ToolName: "web_search",
|
||||
Success: true,
|
||||
Data: result.String(),
|
||||
}, 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))
|
||||
}
|
||||
Reference in New Issue
Block a user