├── .env.example ├── .gitignore ├── README.md ├── api ├── handle.go ├── server.go ├── tools.go └── types.go ├── go.mod ├── go.sum ├── install_service.sh ├── main.go ├── stream ├── collector.go ├── processor.go └── types.go └── units ├── crawler.go └── search.go /.env.example: -------------------------------------------------------------------------------- 1 | # Server Configuration 2 | PORT=3014 3 | APIBASE=https://api.openai.com 4 | 5 | # Search Configuration 6 | # Available options: google, bing, serpapi, serper, search1api, duckduckgo, searxng 7 | SEARCH_SERVICE=duckduckgo 8 | MAX_RESULTS=10 9 | 10 | # Google Search 11 | GOOGLE_CX=your_google_cx 12 | GOOGLE_KEY=your_google_api_key 13 | 14 | # Other Search Services (Uncomment and configure as needed) 15 | # Bing Search 16 | #BING_KEY=your_bing_api_key 17 | 18 | # SerpAPI Search 19 | #SERPAPI_KEY=your_serpapi_key 20 | 21 | # Serper Search 22 | #SERPER_KEY=your_serper_key 23 | #GL=us # Geographic Location 24 | #HL=en # Language 25 | 26 | # Search1API 27 | #SEARCH1API_KEY=your_search1api_key 28 | 29 | # SearXNG Self-Hosted 30 | #SEARXNG_BASE_URL=your_searxng_url 31 | 32 | # Web Crawler Configuration 33 | #CRAWLER_API_URL=https://crawl.search1api.com -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Search4AI-Go 2 | 3 | 还在为大语言模型无法获取实时信息而烦恼吗?Search4AI-Go 为您带来了全新的解决方案! 4 | 5 | 基于 Go 语言打造的高性能联网方案,让您的 AI 助手秒变联网专家: 6 | - ⚡ 极速响应:第二个数据包即可识别 Function Call,比传统方案快 2 倍 7 | - 🛠️ 简单集成:与 OpenAI API 完全兼容,5 分钟完成接入 8 | - 🌐 开箱即用:默认使用免费的 DuckDuckGo,无需任何 API 密钥 9 | - 🔄 实时反馈:搜索结果实时注入到流式响应中,对话更流畅 10 | 11 | 🚀 **为什么选择 Search4AI-Go?** 12 | 13 | ### 1. 极速响应,超乎想象 14 | - **全新的流式处理引擎** 15 | - 创新的 Function Call 识别机制,仅需第二个数据包即可识别 16 | - 基于 Go channel 的流式处理,性能提升 200% 17 | - 毫秒级响应,让对话如行云流水 18 | 19 | ### 2. 稳定可靠,值得信赖 20 | - **全新的工具调用系统** 21 | - 独创的 ToolCallCollector 设计,让工具调用更加精准 22 | - 智能化参数收集,让复杂查询变得简单 23 | - 多轮对话无缝衔接,体验更加流畅 24 | 25 | ### 3. 简单易用,开箱即用 26 | - **全新的接入方式** 27 | - 5 分钟完成部署 28 | - 与 OpenAI API 完全兼容,无需改动现有代码 29 | - 默认配置即可运行,按需定制更多功能 30 | 31 | ### 4. 功能强大,覆盖全面 32 | - **全新的搜索架构** 33 | - 支持 7 大主流搜索引擎,覆盖全球搜索需求 34 | - 默认使用免费的 DuckDuckGo,无需任何 API 密钥 35 | - 支持私有部署,数据安全有保障 36 | 37 | 38 | ## 功能特点 39 | 40 | - 支持多个搜索引擎服务: 41 | - DuckDuckGo(默认,无需 API 密钥) 42 | - Google Custom Search 43 | - Bing Web Search 44 | - SerpAPI 45 | - Serper 46 | - Search1API 47 | - SearXNG(自托管选项) 48 | - 网页内容抓取和分析 49 | - 支持流式响应 50 | - 完整的 CORS 支持 51 | - 与 OpenAI API 格式兼容 52 | - 搜索结果实时返回到流式响应中 53 | 54 | ## 快速开始 55 | 56 | ### 安装方式一:自动安装(推荐) 57 | 58 | 1. 克隆仓库: 59 | 60 | ```bash 61 | git clone https://github.com/liyown/search4ai-go.git 62 | cd search4ai-go 63 | ``` 64 | 65 | 2. 配置环境变量: 66 | 67 | ```bash 68 | cp .env.example .env 69 | # 编辑 .env 文件,配置必要的环境变量 70 | ``` 71 | 72 | 3. 运行安装脚本: 73 | 74 | ```bash 75 | sudo ./install_service.sh 76 | ``` 77 | 78 | 安装脚本会自动: 79 | - 检查系统环境 80 | - 编译项目 81 | - 安装为系统服务 82 | - 配置开机自启 83 | - 启动服务 84 | 85 | 安装完成后,可以使用以下命令管理服务: 86 | ```bash 87 | # 查看服务状态 88 | sudo systemctl status search4ai 89 | 90 | # 启动服务 91 | sudo systemctl start search4ai 92 | 93 | # 停止服务 94 | sudo systemctl stop search4ai 95 | 96 | # 重启服务 97 | sudo systemctl restart search4ai 98 | ``` 99 | 100 | ### 安装方式二:手动安装 101 | 102 | 1. 克隆仓库: 103 | 104 | ```bash 105 | git clone https://github.com/liyown/search4ai-go.git 106 | cd search4ai-go 107 | ``` 108 | 109 | 2. 安装依赖: 110 | 111 | ```bash 112 | go mod download 113 | ``` 114 | 115 | 3. 配置环境变量: 116 | 117 | 复制 `.env.example` 文件为 `.env` 并根据需要修改配置: 118 | 119 | ```bash 120 | cp .env.example .env 121 | ``` 122 | 123 | ### 配置 124 | 125 | 在 `.env` 文件中设置以下配置项: 126 | 127 | ```env 128 | # 服务器配置 129 | PORT=3014 # 服务器端口 130 | APIBASE=https://api.openai.com # AI 模型 API 基础 URL 131 | 132 | # 搜索配置 133 | SEARCH_SERVICE=duckduckgo # 默认搜索服务 134 | MAX_RESULTS=10 # 每次搜索返回的最大结果数 135 | 136 | # Google 搜索配置(如果使用 Google) 137 | GOOGLE_CX=your_google_cx # Google 自定义搜索引擎 ID 138 | GOOGLE_KEY=your_google_api_key # Google API 密钥 139 | 140 | # 其他搜索服务配置(根据需要取消注释) 141 | #BING_KEY=your_bing_api_key # Bing API 密钥 142 | #SERPAPI_KEY=your_serpapi_key # SerpAPI 密钥 143 | #SERPER_KEY=your_serper_key # Serper API 密钥 144 | #SEARCH1API_KEY=your_search1api_key # Search1API 密钥 145 | #SEARXNG_BASE_URL=your_searxng_url # SearXNG 自托管 URL 146 | ``` 147 | 148 | ### 运行 149 | 150 | 启动服务器: 151 | 152 | ```bash 153 | go run main.go 154 | ``` 155 | 156 | 服务器将在配置的端口上运行(默认为 3014)。 157 | 158 | ## API 使用 159 | 160 | ### 1. 基本搜索 161 | 162 | 发送 POST 请求到 `/v1/chat/completions` 端点: 163 | 164 | ```json 165 | { 166 | "model": "moonshot-v1-128k", 167 | "messages": [ 168 | { 169 | "role": "system", 170 | "content": "你是一个有用的助手。当用户请求实时信息(例如日期、天气或新闻)时,使用函数调用来检索相关数据。" 171 | }, 172 | { 173 | "role": "user", 174 | "content": "最近的世界新闻有哪些?" 175 | } 176 | ], 177 | "stream": true, 178 | "enabledTools": { 179 | "search": true 180 | } 181 | } 182 | ``` 183 | 184 | 响应示例(流式响应的一个数据块): 185 | ```json 186 | { 187 | "id": "chatcmpl-123", 188 | "object": "chat.completion.chunk", 189 | "created": 1677652288, 190 | "model": "moonshot-v1-128k", 191 | "choices": [{ 192 | "index": 0, 193 | "delta": { 194 | "content": "根据最新的新闻报道," 195 | }, 196 | "finish_reason": null 197 | }], 198 | "system_fingerprint": "fp-123", 199 | "search_results": [{ 200 | "title": "Latest World News - Reuters", 201 | "link": "https://www.reuters.com/world/", 202 | "snippet": "Get the latest world news coverage..." 203 | }] 204 | } 205 | ``` 206 | 207 | ### 2. 网页抓取 208 | 209 | 使用网页抓取功能: 210 | 211 | ```json 212 | { 213 | "model": "moonshot-v1-128k", 214 | "messages": [ 215 | { 216 | "role": "system", 217 | "content": "你是一个有用的助手。需要详细信息时,使用爬虫功能获取网页内容。" 218 | }, 219 | { 220 | "role": "user", 221 | "content": "帮我获取并总结这个网页的内容:https://example.com" 222 | } 223 | ], 224 | "stream": true, 225 | "enabledTools": { 226 | "crawler": true 227 | } 228 | } 229 | ``` 230 | 231 | ### 工具说明 232 | 233 | 1. **search 工具** 234 | - 用于获取实时互联网信息 235 | - 自动在对话中使用,无需手动指定参数 236 | - 搜索结果会在流式响应中实时返回 237 | 238 | 2. **crawler 工具** 239 | - 用于抓取和分析特定网页内容 240 | - 自动在对话中使用,无需手动指定参数 241 | - 支持大多数常见网页格式 242 | 243 | ### 使用提示 244 | 245 | 1. **系统提示(System Prompt)** 246 | - 建议在 system 消息中说明助手的行为,特别是何时使用搜索或爬虫功能 247 | - 例如:"当需要实时信息时使用搜索功能"或"需要详细内容时使用爬虫功能" 248 | 249 | 2. **工具启用** 250 | - 使用 `enabledTools` 字段控制可用的工具 251 | - 可以同时启用多个工具:`{"search": true, "crawler": true}` 252 | 253 | 3. **流式响应** 254 | - 设置 `stream: true` 获取实时响应 255 | - 搜索结果会在 `search_results` 字段中返回 256 | - 每个数据块都包含完整的元数据 257 | 258 | ## 搜索服务说明 259 | 260 | 1. **DuckDuckGo**(默认) 261 | - 无需 API 密钥 262 | - 适合一般用途 263 | 264 | 2. **Google Custom Search** 265 | - 需要 Google Custom Search Engine ID 和 API 密钥 266 | - 提供高质量的搜索结果 267 | - 每日请求限制 268 | 269 | 3. **Bing Web Search** 270 | - 需要 Bing API 密钥 271 | - 提供全面的搜索结果 272 | 273 | 4. **SerpAPI** 274 | - 需要 SerpAPI 密钥 275 | - 提供多个搜索引擎的结果 276 | 277 | 5. **Serper** 278 | - 需要 Serper API 密钥 279 | - Google 搜索结果的替代方案 280 | 281 | 6. **Search1API** 282 | - 需要 Search1API 密钥 283 | - 提供自定义搜索功能 284 | 285 | 7. **SearXNG** 286 | - 自托管选项 287 | - 完全可控的搜索引擎元搜索引擎 288 | 289 | ## 注意事项 290 | 291 | 1. 流式响应中的搜索结果会实时返回 292 | 2. 每个搜索服务可能有不同的速率限制和定价 293 | 3. 建议在生产环境中使用环境变量管理 API 密钥 294 | 4. 确保您的 API 密钥有足够的配额 295 | 296 | ## 贡献 297 | 298 | 欢迎提交 Pull Requests 和 Issues! 299 | 300 | ## 作者 301 | 302 | - **Liu Yaowen** ([liuyaowen](https://github.com/liyown)) 303 | 304 | ## 许可证 305 | 306 | MIT License 307 | 308 | Copyright (c) 2024 Liu Yaowen 309 | 310 | Permission is hereby granted, free of charge, to any person obtaining a copy 311 | of this software and associated documentation files (the "Software"), to deal 312 | in the Software without restriction, including without limitation the rights 313 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 314 | copies of the Software, and to permit persons to whom the Software is 315 | furnished to do so, subject to the following conditions: 316 | 317 | The above copyright notice and this permission notice shall be included in all 318 | copies or substantial portions of the Software. 319 | 320 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 321 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 322 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 323 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 324 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 325 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 326 | SOFTWARE. -------------------------------------------------------------------------------- /api/handle.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "log" 9 | "net/http" 10 | "strings" 11 | 12 | "github.com/gin-gonic/gin" 13 | "github.com/liyown/search4ai-go/stream" 14 | ) 15 | 16 | func handleStreamingResponse(c *gin.Context, resp *http.Response, req *ChatCompletionRequest) { 17 | c.Writer.Header().Set("Content-Type", "text/event-stream") 18 | c.Writer.Header().Set("Cache-Control", "no-cache") 19 | c.Writer.Header().Set("Connection", "keep-alive") 20 | c.Writer.WriteHeader(resp.StatusCode) 21 | var searchResults []map[string]interface{} 22 | 23 | for { 24 | processor := stream.NewProcessor(c.Writer) 25 | message, collectedTools, needsToolExecution := processor.ProcessStream(resp.Body, searchResults) 26 | 27 | // If no tool execution is needed, we're done 28 | if !needsToolExecution { 29 | fmt.Fprintf(c.Writer, "data: [DONE]\n\n") 30 | return 31 | } 32 | 33 | // Add the assistant's tool calls message to the conversation 34 | req.Messages = append(req.Messages, map[string]interface{}{ 35 | "role": "assistant", 36 | "tool_calls": message.ToolCalls, 37 | }) 38 | 39 | // Execute collected tool calls 40 | toolCallsInterface := make([]interface{}, len(collectedTools)) 41 | for i, v := range collectedTools { 42 | toolCallsInterface[i] = v 43 | } 44 | toolResults, err := executeToolCalls(toolCallsInterface) 45 | if err != nil { 46 | log.Printf("Error executing tool calls: %v", err) 47 | fmt.Fprintf(c.Writer, "data: [DONE]\n\n") 48 | return 49 | } 50 | 51 | searchResults = toolResults 52 | 53 | // Add tool results to the conversation 54 | req.Messages = append(req.Messages, toolResults...) 55 | 56 | // Make a new request with the updated context 57 | newResp, err := forwardToOpenAI(req, strings.TrimPrefix(c.GetHeader("Authorization"), "Bearer ")) 58 | if err != nil { 59 | log.Printf("Error making recursive request: %v", err) 60 | fmt.Fprintf(c.Writer, "data: [DONE]\n\n") 61 | return 62 | } 63 | resp.Body.Close() 64 | resp = newResp 65 | 66 | } 67 | } 68 | 69 | func handleNonStreamingResponse(c *gin.Context, resp *http.Response, req *ChatCompletionRequest) { 70 | var openaiResp ChatCompletionResponseWithSearchResults 71 | if err := json.NewDecoder(resp.Body).Decode(&openaiResp); err != nil { 72 | c.JSON(http.StatusInternalServerError, gin.H{"error": "error parsing OpenAI response"}) 73 | return 74 | } 75 | 76 | // Check for tool calls 77 | if len(openaiResp.Choices) > 0 && openaiResp.Choices[0].Message != nil { 78 | if toolCalls, ok := openaiResp.Choices[0].Message["tool_calls"].([]interface{}); ok { 79 | toolResults, err := executeToolCalls(toolCalls) 80 | if err != nil { 81 | log.Printf("error executing tool calls: %v", err) 82 | c.JSON(http.StatusInternalServerError, gin.H{"error": "error executing tool calls"}) 83 | return 84 | } 85 | 86 | if len(toolResults) > 0 { 87 | // Add results to messages and make recursive request 88 | req.Messages = append(req.Messages, openaiResp.Choices[0].Message) 89 | req.Messages = append(req.Messages, toolResults...) 90 | 91 | body, err := json.Marshal(req) 92 | if err != nil { 93 | c.JSON(http.StatusInternalServerError, gin.H{"error": "error preparing tool results"}) 94 | return 95 | } 96 | 97 | // update search results 98 | c.Set("searchResults", toolResults) 99 | 100 | c.Request.Body = io.NopCloser(bytes.NewReader(body)) 101 | // 使用递归请求处理工具结果 102 | handleChatCompletions(c) 103 | return 104 | } 105 | 106 | } 107 | 108 | } 109 | 110 | // update search results 111 | searchResults, _ := c.Get("searchResults") 112 | openaiResp.SearchResults = searchResults.([]map[string]interface{}) 113 | 114 | c.JSON(resp.StatusCode, openaiResp) 115 | } 116 | 117 | // handleChatCompletions handles the chat completions endpoint 118 | func handleChatCompletions(c *gin.Context) { 119 | // Validate and prepare request 120 | req, apiKey, err := prepareRequest(c) 121 | if err != nil { 122 | c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) 123 | return 124 | } 125 | 126 | // Forward request to OpenAI 127 | resp, err := forwardToOpenAI(req, apiKey) 128 | if err != nil { 129 | c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) 130 | return 131 | } 132 | defer resp.Body.Close() 133 | 134 | // Handle response based on streaming flag 135 | if req.Stream { 136 | handleStreamingResponse(c, resp, req) 137 | } else { 138 | handleNonStreamingResponse(c, resp, req) 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /api/server.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "log" 9 | "net/http" 10 | "os" 11 | "strings" 12 | 13 | "github.com/gin-gonic/gin" 14 | "github.com/joho/godotenv" 15 | ) 16 | 17 | // setupCORS adds CORS middleware to the Gin engine 18 | func setupCORS() gin.HandlerFunc { 19 | return func(c *gin.Context) { 20 | c.Writer.Header().Set("Access-Control-Allow-Origin", "*") 21 | c.Writer.Header().Set("Access-Control-Allow-Methods", "*") 22 | c.Writer.Header().Set("Access-Control-Allow-Headers", "*") 23 | c.Writer.Header().Set("Access-Control-Max-Age", "86400") 24 | c.Writer.Header().Set("Access-Control-Allow-Credentials", "true") 25 | c.Writer.Header().Set("Access-Control-Expose-Headers", "*") 26 | 27 | if c.Request.Method == "OPTIONS" { 28 | c.AbortWithStatus(http.StatusNoContent) 29 | return 30 | } 31 | 32 | c.Next() 33 | } 34 | } 35 | 36 | func prepareRequest(c *gin.Context) (*ChatCompletionRequest, string, error) { 37 | // Get API base URL 38 | apiBase := os.Getenv("APIBASE") 39 | if apiBase == "" { 40 | apiBase = "https://api.openai.com" 41 | } 42 | 43 | // Get API key from Authorization header 44 | authHeader := c.GetHeader("Authorization") 45 | if authHeader == "" { 46 | return nil, "", fmt.Errorf("authorization header is missing") 47 | } 48 | apiKey := strings.TrimPrefix(authHeader, "Bearer ") 49 | 50 | // Read and parse request body 51 | body, err := io.ReadAll(c.Request.Body) 52 | if err != nil { 53 | return nil, "", fmt.Errorf("error reading request body: %v", err) 54 | } 55 | 56 | var req ChatCompletionRequest 57 | if err := json.Unmarshal(body, &req); err != nil { 58 | return nil, "", fmt.Errorf("error parsing request body: %v", err) 59 | } 60 | 61 | // Add tools if not present 62 | if req.Tools == nil { 63 | req.Tools = buildTools(nil) 64 | } 65 | 66 | return &req, apiKey, nil 67 | } 68 | 69 | func forwardToOpenAI(req *ChatCompletionRequest, apiKey string) (*http.Response, error) { 70 | apiBase := os.Getenv("APIBASE") 71 | if apiBase == "" { 72 | apiBase = "https://api.openai.com" 73 | } 74 | 75 | body, err := json.Marshal(req) 76 | if err != nil { 77 | return nil, fmt.Errorf("error preparing request: %v", err) 78 | } 79 | 80 | client := &http.Client{} 81 | openaiReq, err := http.NewRequest("POST", apiBase+"/v1/chat/completions", bytes.NewReader(body)) 82 | if err != nil { 83 | return nil, fmt.Errorf("error creating OpenAI request: %v", err) 84 | } 85 | 86 | openaiReq.Header.Set("Content-Type", "application/json") 87 | openaiReq.Header.Set("Authorization", "Bearer "+apiKey) 88 | 89 | return client.Do(openaiReq) 90 | } 91 | 92 | // StartServer initializes and starts the HTTP server 93 | func StartServer() error { 94 | if err := godotenv.Load(); err != nil { 95 | log.Printf("Warning: Error loading .env file: %v", err) 96 | } 97 | 98 | // Set Gin mode 99 | gin.SetMode(gin.ReleaseMode) 100 | 101 | // Create Gin engine 102 | r := gin.New() 103 | 104 | // Use middleware 105 | r.Use(gin.Recovery()) 106 | r.Use(gin.Logger()) 107 | r.Use(setupCORS()) 108 | 109 | // Root endpoint 110 | r.GET("/", func(c *gin.Context) { 111 | c.Header("Content-Type", "text/html; charset=utf-8") 112 | c.String(http.StatusOK, "

欢迎体验search4ai,让你的大模型自由联网!

") 113 | }) 114 | 115 | // Chat completions endpoint 116 | r.POST("/v1/chat/completions", handleChatCompletions) 117 | 118 | // Get port from environment 119 | port := os.Getenv("PORT") 120 | if port == "" { 121 | port = "3014" 122 | } 123 | 124 | // Start server 125 | log.Printf("Server is listening on port %s", port) 126 | return r.Run(":" + port) 127 | } 128 | -------------------------------------------------------------------------------- /api/tools.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "log" 7 | 8 | "github.com/liyown/search4ai-go/units" 9 | ) 10 | 11 | func executeToolCalls(toolCalls []interface{}) ([]map[string]interface{}, error) { 12 | var toolResults []map[string]interface{} 13 | for _, tc := range toolCalls { 14 | toolCall, ok := tc.(map[string]interface{}) 15 | if !ok { 16 | continue 17 | } 18 | 19 | result, err := executeToolCall(toolCall) 20 | if err != nil { 21 | log.Printf("Error executing tool call: %v", err) 22 | continue 23 | } 24 | 25 | toolResults = append(toolResults, map[string]interface{}{ 26 | "tool_call_id": toolCall["id"], 27 | "role": "tool", 28 | "name": toolCall["function"].(map[string]interface{})["name"], 29 | "content": result, 30 | }) 31 | } 32 | return toolResults, nil 33 | } 34 | 35 | // executeToolCall executes a tool call and returns the result 36 | func executeToolCall(toolCall map[string]interface{}) (string, error) { 37 | function, ok := toolCall["function"].(map[string]interface{}) 38 | if !ok { 39 | return "", fmt.Errorf("invalid tool call format") 40 | } 41 | 42 | name, ok := function["name"].(string) 43 | if !ok { 44 | return "", fmt.Errorf("invalid function name") 45 | } 46 | 47 | arguments, ok := function["arguments"].(string) 48 | if !ok { 49 | return "", fmt.Errorf("invalid function arguments") 50 | } 51 | 52 | var args map[string]interface{} 53 | if err := json.Unmarshal([]byte(arguments), &args); err != nil { 54 | return "", fmt.Errorf("error parsing arguments: %v", err) 55 | } 56 | 57 | switch name { 58 | case "search": 59 | query, ok := args["query"].(string) 60 | if !ok { 61 | return "", fmt.Errorf("invalid search query") 62 | } 63 | return units.Search(query) 64 | 65 | case "crawler": 66 | url, ok := args["url"].(string) 67 | if !ok { 68 | return "", fmt.Errorf("invalid crawler url") 69 | } 70 | return units.Crawler(url) 71 | 72 | default: 73 | return "", fmt.Errorf("unknown tool: %s", name) 74 | } 75 | } 76 | 77 | // buildTools creates the tools configuration 78 | func buildTools(enabledTools map[string]bool) []map[string]interface{} { 79 | tools := []map[string]interface{}{ 80 | { 81 | "type": "function", 82 | "function": map[string]interface{}{ 83 | "name": "search", 84 | "description": "搜索互联网获取实时信息。当你需要查找当前信息时使用此功能,例如日期、天气、新闻,或者可能不在你训练数据中的事实。搜索结果将包含相关网页的标题、链接和摘要。", 85 | "parameters": map[string]interface{}{ 86 | "type": "object", 87 | "properties": map[string]interface{}{ 88 | "query": map[string]interface{}{ 89 | "type": "string", 90 | "description": "搜索查询。应该具体且聚焦于所需信息。使用可能出现在相关结果中的关键词和短语。", 91 | }, 92 | }, 93 | "required": []string{"query"}, 94 | }, 95 | }, 96 | }, 97 | { 98 | "type": "function", 99 | "function": map[string]interface{}{ 100 | "name": "crawler", 101 | "description": "提取和分析特定网页URL的内容。当你需要从特定网页获取详细信息时使用此功能,包括其文本内容、标题和元数据。这对于在通过搜索找到相关URL后获取深入信息很有用。", 102 | "parameters": map[string]interface{}{ 103 | "type": "object", 104 | "properties": map[string]interface{}{ 105 | "url": map[string]interface{}{ 106 | "type": "string", 107 | "description": "要分析的网页的完整URL。必须是以http://或https://开头的有效、可访问的网址。", 108 | }, 109 | }, 110 | "required": []string{"url"}, 111 | }, 112 | }, 113 | }, 114 | } 115 | 116 | if enabledTools == nil { 117 | return tools 118 | } 119 | 120 | var filteredTools []map[string]interface{} 121 | for _, tool := range tools { 122 | name := tool["function"].(map[string]interface{})["name"].(string) 123 | if enabled, exists := enabledTools[name]; !exists || enabled { 124 | filteredTools = append(filteredTools, tool) 125 | } 126 | } 127 | return filteredTools 128 | } 129 | -------------------------------------------------------------------------------- /api/types.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | type ChatCompletionRequest struct { 4 | Model string `json:"model"` 5 | Messages []map[string]interface{} `json:"messages"` 6 | MaxTokens int `json:"max_tokens"` 7 | Tools []map[string]interface{} `json:"tools,omitempty"` 8 | ToolChoice string `json:"tool_choice,omitempty"` 9 | Stream bool `json:"stream"` 10 | } 11 | 12 | // ChatCompletionResponse represents the response structure from OpenAI 13 | type ChatCompletionResponse struct { 14 | ID string `json:"id"` 15 | Object string `json:"object"` 16 | Created int64 `json:"created"` 17 | Model string `json:"model"` 18 | Choices []struct { 19 | Index int `json:"index"` 20 | Message map[string]interface{} `json:"message"` 21 | FinishReason string `json:"finish_reason"` 22 | } `json:"choices"` 23 | } 24 | type ChatCompletionResponseWithSearchResults struct { 25 | ChatCompletionResponse 26 | SearchResults []map[string]interface{} `json:"search_results"` 27 | } 28 | 29 | // ToolCall represents a tool call from OpenAI 30 | type ToolCall struct { 31 | ID string `json:"id"` 32 | Type string `json:"type"` 33 | Function struct { 34 | Name string `json:"name"` 35 | Arguments string `json:"arguments"` 36 | } `json:"function"` 37 | } 38 | 39 | // Message represents a chat message 40 | type Message struct { 41 | Role string `json:"role"` 42 | Content string `json:"content"` 43 | ToolCalls []map[string]interface{} `json:"tool_calls,omitempty"` 44 | Name string `json:"name,omitempty"` 45 | ToolCallID string `json:"tool_call_id,omitempty"` 46 | } 47 | 48 | // Delta represents a streaming delta update 49 | type Delta struct { 50 | Role string `json:"role"` 51 | Content string `json:"content"` 52 | ToolCalls []ToolCall `json:"tool_calls"` 53 | } 54 | 55 | // StreamChoice represents a choice in the streaming response 56 | type StreamChoice struct { 57 | Delta Delta `json:"delta"` 58 | FinishReason string `json:"finish_reason"` 59 | } 60 | 61 | // StreamResponse represents a streaming response chunk 62 | type StreamResponse struct { 63 | Choices []StreamChoice `json:"choices"` 64 | } 65 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/liyown/search4ai-go 2 | 3 | go 1.20 4 | 5 | require ( 6 | github.com/gin-gonic/gin v1.9.1 7 | github.com/joho/godotenv v1.5.1 8 | ) 9 | 10 | require ( 11 | github.com/bytedance/sonic v1.9.1 // indirect 12 | github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect 13 | github.com/gabriel-vasile/mimetype v1.4.2 // indirect 14 | github.com/gin-contrib/sse v0.1.0 // indirect 15 | github.com/go-playground/locales v0.14.1 // indirect 16 | github.com/go-playground/universal-translator v0.18.1 // indirect 17 | github.com/go-playground/validator/v10 v10.14.0 // indirect 18 | github.com/goccy/go-json v0.10.2 // indirect 19 | github.com/json-iterator/go v1.1.12 // indirect 20 | github.com/klauspost/cpuid/v2 v2.2.4 // indirect 21 | github.com/leodido/go-urn v1.2.4 // indirect 22 | github.com/mattn/go-isatty v0.0.19 // indirect 23 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 24 | github.com/modern-go/reflect2 v1.0.2 // indirect 25 | github.com/pelletier/go-toml/v2 v2.0.8 // indirect 26 | github.com/twitchyliquid64/golang-asm v0.15.1 // indirect 27 | github.com/ugorji/go/codec v1.2.11 // indirect 28 | golang.org/x/arch v0.3.0 // indirect 29 | golang.org/x/crypto v0.9.0 // indirect 30 | golang.org/x/net v0.10.0 // indirect 31 | golang.org/x/sys v0.8.0 // indirect 32 | golang.org/x/text v0.9.0 // indirect 33 | google.golang.org/protobuf v1.30.0 // indirect 34 | gopkg.in/yaml.v3 v3.0.1 // indirect 35 | ) 36 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM= 2 | github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s= 3 | github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U= 4 | github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY= 5 | github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams= 6 | github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk= 7 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 8 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 9 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 10 | github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU= 11 | github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA= 12 | github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= 13 | github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= 14 | github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg= 15 | github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU= 16 | github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= 17 | github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= 18 | github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= 19 | github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= 20 | github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= 21 | github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js= 22 | github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= 23 | github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= 24 | github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= 25 | github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= 26 | github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= 27 | github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 28 | github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 29 | github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 30 | github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 31 | github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= 32 | github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 33 | github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= 34 | github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk= 35 | github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= 36 | github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q= 37 | github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4= 38 | github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= 39 | github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 40 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 41 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= 42 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 43 | github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= 44 | github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= 45 | github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ= 46 | github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4= 47 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 48 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 49 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 50 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 51 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 52 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 53 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 54 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 55 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 56 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 57 | github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 58 | github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY= 59 | github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 60 | github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= 61 | github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= 62 | github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= 63 | github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= 64 | golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= 65 | golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k= 66 | golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= 67 | golang.org/x/crypto v0.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g= 68 | golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0= 69 | golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M= 70 | golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= 71 | golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 72 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 73 | golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU= 74 | golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 75 | golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE= 76 | golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 77 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= 78 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 79 | google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= 80 | google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng= 81 | google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= 82 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 83 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 84 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 85 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 86 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 87 | rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= 88 | -------------------------------------------------------------------------------- /install_service.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # 定义服务名称和路径 4 | SERVICE_NAME="search4ai" 5 | BINARY_PATH="/usr/local/bin/search4ai" 6 | SERVICE_PATH="/etc/systemd/system/${SERVICE_NAME}.service" 7 | GO_BINARY="search4ai" 8 | ENV_SOURCE=".env" 9 | ENV_DEST="/usr/local/bin/.env" 10 | 11 | # 检查是否以 root 权限运行 12 | if [ "$EUID" -ne 0 ]; then 13 | echo "请使用 root 权限运行此脚本" 14 | exit 1 15 | fi 16 | 17 | # 检查是否安装了 Go 18 | if ! command -v go &> /dev/null; then 19 | echo "未检测到 Go 环境,请先安装 Go" 20 | exit 1 21 | fi 22 | 23 | # 检查 .env 文件是否存在 24 | if [ ! -f "${ENV_SOURCE}" ]; then 25 | echo "错误:${ENV_SOURCE} 文件不存在!" 26 | exit 1 27 | fi 28 | 29 | # 编译项目 30 | echo "开始编译项目..." 31 | go build -o ${GO_BINARY} main.go 32 | if [ $? -ne 0 ]; then 33 | echo "编译失败!" 34 | exit 1 35 | fi 36 | 37 | # 停止已存在的服务 38 | if systemctl is-active --quiet ${SERVICE_NAME}; then 39 | echo "停止已存在的服务..." 40 | systemctl stop ${SERVICE_NAME} 41 | fi 42 | 43 | # 复制二进制文件 44 | echo "复制二进制文件到 ${BINARY_PATH}..." 45 | cp ${GO_BINARY} ${BINARY_PATH} 46 | chmod +x ${BINARY_PATH} 47 | 48 | # 复制 .env 文件 49 | echo "复制 .env 文件到 ${ENV_DEST}..." 50 | cp ${ENV_SOURCE} ${ENV_DEST} 51 | chmod 600 ${ENV_DEST} # 设置适当的权限,只允许 root 读写 52 | 53 | # 创建服务文件 54 | echo "创建系统服务文件..." 55 | cat > ${SERVICE_PATH} << EOF 56 | [Unit] 57 | Description=Search4AI Service 58 | After=network.target 59 | 60 | [Service] 61 | Type=simple 62 | ExecStart=${BINARY_PATH} 63 | Restart=always 64 | RestartSec=10 65 | User=root 66 | WorkingDirectory=/usr/local/bin 67 | Environment="ENV_FILE=${ENV_DEST}" 68 | 69 | [Install] 70 | WantedBy=multi-user.target 71 | EOF 72 | 73 | # 重新加载 systemd 74 | echo "重新加载 systemd..." 75 | systemctl daemon-reload 76 | 77 | # 启用并启动服务 78 | echo "启用并启动服务..." 79 | systemctl enable ${SERVICE_NAME} 80 | systemctl start ${SERVICE_NAME} 81 | 82 | # 检查服务状态 83 | echo "检查服务状态..." 84 | systemctl status ${SERVICE_NAME} 85 | 86 | # 清理编译产物 87 | echo "清理编译文件..." 88 | rm ${GO_BINARY} 89 | 90 | echo "安装完成!" -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | 6 | "github.com/liyown/search4ai-go/api" 7 | ) 8 | 9 | func main() { 10 | log.Println("Starting search4ai-go server...") 11 | if err := api.StartServer(); err != nil { 12 | log.Fatalf("Server error: %v", err) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /stream/collector.go: -------------------------------------------------------------------------------- 1 | package stream 2 | 3 | // ToolCallCollector collects and manages tool calls 4 | type ToolCallCollector struct { 5 | currentToolCall map[string]interface{} 6 | toolCalls []map[string]interface{} 7 | toolCallResults []map[string]interface{} 8 | } 9 | 10 | // NewToolCallCollector creates a new tool call collector 11 | func NewToolCallCollector() *ToolCallCollector { 12 | return &ToolCallCollector{ 13 | currentToolCall: make(map[string]interface{}), 14 | toolCalls: make([]map[string]interface{}, 0), 15 | } 16 | } 17 | 18 | // CollectToolCall collects a tool call 19 | func (tc *ToolCallCollector) CollectToolCall(call ToolCall) { 20 | if tc.currentToolCall["id"] == nil { 21 | tc.currentToolCall = map[string]interface{}{ 22 | "id": call.ID, 23 | "type": call.Type, 24 | "function": map[string]interface{}{ 25 | "name": call.Function.Name, 26 | "arguments": call.Function.Arguments, 27 | }, 28 | } 29 | } else { 30 | if call.ID != "" { 31 | tc.currentToolCall["id"] = call.ID 32 | } 33 | if call.Function.Name != "" { 34 | tc.currentToolCall["function"].(map[string]interface{})["name"] = call.Function.Name 35 | } 36 | if call.Function.Arguments != "" { 37 | args := tc.currentToolCall["function"].(map[string]interface{})["arguments"].(string) 38 | args += call.Function.Arguments 39 | tc.currentToolCall["function"].(map[string]interface{})["arguments"] = args 40 | } 41 | } 42 | } 43 | 44 | // GetToolCalls returns the collected tool calls 45 | func (tc *ToolCallCollector) GetToolCalls() []map[string]interface{} { 46 | if tc.currentToolCall["id"] != nil { 47 | tc.toolCalls = append(tc.toolCalls, tc.currentToolCall) 48 | tc.currentToolCall = make(map[string]interface{}) 49 | } 50 | return tc.toolCalls 51 | } 52 | 53 | // GetToolCallResults returns the collected tool call results 54 | func (tc *ToolCallCollector) GetToolCallResults() []map[string]interface{} { 55 | return tc.toolCallResults 56 | } 57 | -------------------------------------------------------------------------------- /stream/processor.go: -------------------------------------------------------------------------------- 1 | package stream 2 | 3 | import ( 4 | "bufio" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "log" 9 | "net/http" 10 | "strings" 11 | ) 12 | 13 | // Processor handles stream processing 14 | type Processor struct { 15 | writer io.Writer 16 | message *Message 17 | toolCallCollector *ToolCallCollector 18 | } 19 | 20 | // NewProcessor creates a new stream processor 21 | func NewProcessor(w io.Writer) *Processor { 22 | return &Processor{ 23 | writer: w, 24 | message: &Message{ 25 | Role: "assistant", 26 | Content: "", 27 | ToolCalls: make([]map[string]interface{}, 0), 28 | }, 29 | toolCallCollector: NewToolCallCollector(), 30 | } 31 | } 32 | 33 | // ProcessStream processes the stream and returns the message, collected tool calls, and whether tool execution is needed 34 | func (p *Processor) ProcessStream(body io.ReadCloser, searchResults []map[string]interface{}) (*Message, []map[string]interface{}, bool) { 35 | scanner := bufio.NewScanner(body) 36 | chunkCount := 0 37 | isToolCallMessage := false 38 | 39 | for scanner.Scan() { 40 | line := scanner.Text() 41 | if !strings.HasPrefix(line, "data: ") { 42 | continue 43 | } 44 | 45 | data := strings.TrimPrefix(line, "data: ") 46 | if data == "[DONE]" { 47 | break 48 | } 49 | 50 | var response StreamResponse 51 | if err := json.Unmarshal([]byte(data), &response); err != nil { 52 | log.Printf("Error parsing stream data: %v", err) 53 | continue 54 | } 55 | 56 | if len(response.Choices) == 0 { 57 | continue 58 | } 59 | 60 | // Check for tool calls in the second chunk 61 | chunkCount++ 62 | if chunkCount == 2 { 63 | isToolCallMessage = len(response.Choices[0].Delta.ToolCalls) > 0 64 | } 65 | 66 | choice := response.Choices[0] 67 | delta := choice.Delta 68 | 69 | // Only process after we know if it's a tool call message (after second chunk) 70 | if chunkCount >= 2 { 71 | if isToolCallMessage { 72 | p.handleFunctionCall(delta) 73 | } else { 74 | p.handleContent(delta, response, searchResults) 75 | } 76 | } 77 | 78 | // Check if we're done with this stream 79 | if choice.FinishReason != "" { 80 | if choice.FinishReason == "tool_calls" { 81 | // Return collected tool calls for execution 82 | return p.message, p.toolCallCollector.GetToolCalls(), true 83 | } 84 | // If finish reason is not tool_calls, we're done 85 | return p.message, nil, false 86 | } 87 | } 88 | 89 | return p.message, nil, false 90 | } 91 | 92 | func (p *Processor) handleContent(delta Delta, response StreamResponse, searchResults []map[string]interface{}) { 93 | if delta.Role != "" { 94 | p.message.Role = delta.Role 95 | } 96 | 97 | if delta.Content != "" { 98 | p.message.Content += delta.Content 99 | // Stream content to client with proper escaping 100 | escapedContent := strings.Replace(delta.Content, "\"", "\\\"", -1) 101 | escapedContent = strings.Replace(escapedContent, "\n", "\\n", -1) 102 | 103 | // Include metadata from original response and add search results 104 | streamResp := StreamResponse{ 105 | ID: response.ID, 106 | Object: response.Object, 107 | Created: response.Created, 108 | Model: response.Model, 109 | SystemFingerprint: response.SystemFingerprint, 110 | Choices: []StreamChoice{ 111 | { 112 | Delta: Delta{ 113 | Content: escapedContent, 114 | }, 115 | FinishReason: response.Choices[0].FinishReason, 116 | }, 117 | }, 118 | SearchResults: searchResults, 119 | } 120 | 121 | respBytes, err := json.Marshal(streamResp) 122 | if err != nil { 123 | log.Printf("Error marshaling response: %v", err) 124 | return 125 | } 126 | 127 | fmt.Fprintf(p.writer, "data: %s\n\n", string(respBytes)) 128 | if f, ok := p.writer.(http.Flusher); ok { 129 | f.Flush() 130 | } 131 | } 132 | } 133 | 134 | func (p *Processor) handleFunctionCall(delta Delta) { 135 | if len(delta.ToolCalls) > 0 { 136 | p.toolCallCollector.CollectToolCall(delta.ToolCalls[0]) 137 | 138 | if len(p.message.ToolCalls) == 0 { 139 | p.message.ToolCalls = append(p.message.ToolCalls, map[string]interface{}{ 140 | "id": delta.ToolCalls[0].ID, 141 | "type": "function", 142 | "function": map[string]interface{}{ 143 | "name": delta.ToolCalls[0].Function.Name, 144 | "arguments": delta.ToolCalls[0].Function.Arguments, 145 | }, 146 | }) 147 | } else { 148 | // Append to existing tool call 149 | currentTool := p.message.ToolCalls[0] 150 | if delta.ToolCalls[0].ID != "" { 151 | currentTool["id"] = delta.ToolCalls[0].ID 152 | } 153 | if delta.ToolCalls[0].Function.Name != "" { 154 | currentTool["function"].(map[string]interface{})["name"] = delta.ToolCalls[0].Function.Name 155 | } 156 | if delta.ToolCalls[0].Function.Arguments != "" { 157 | args := currentTool["function"].(map[string]interface{})["arguments"].(string) 158 | args += delta.ToolCalls[0].Function.Arguments 159 | currentTool["function"].(map[string]interface{})["arguments"] = args 160 | } 161 | } 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /stream/types.go: -------------------------------------------------------------------------------- 1 | package stream 2 | 3 | // StreamResponse represents a streaming response chunk 4 | type StreamResponse struct { 5 | ID string `json:"id"` 6 | Object string `json:"object"` 7 | Created int64 `json:"created"` 8 | Model string `json:"model"` 9 | Choices []StreamChoice `json:"choices"` 10 | SystemFingerprint string `json:"system_fingerprint"` 11 | SearchResults []map[string]interface{} `json:"search_results,omitempty"` 12 | } 13 | 14 | // StreamChoice represents a choice in the streaming response 15 | type StreamChoice struct { 16 | Index int `json:"index"` 17 | Delta Delta `json:"delta"` 18 | FinishReason string `json:"finish_reason"` 19 | } 20 | 21 | // Delta represents a streaming delta update 22 | type Delta struct { 23 | Role string `json:"role"` 24 | Content string `json:"content"` 25 | ToolCalls []ToolCall `json:"tool_calls,omitempty"` 26 | } 27 | 28 | // ToolCall represents a tool call from the model 29 | type ToolCall struct { 30 | Index int `json:"index"` 31 | ID string `json:"id"` 32 | Type string `json:"type"` 33 | Function Function `json:"function"` 34 | } 35 | 36 | // Function represents the function details in a tool call 37 | type Function struct { 38 | Name string `json:"name"` 39 | Arguments string `json:"arguments"` 40 | } 41 | 42 | // Message represents a chat message 43 | type Message struct { 44 | Role string `json:"role"` 45 | Content string `json:"content"` 46 | ToolCalls []map[string]interface{} `json:"tool_calls,omitempty"` 47 | Name string `json:"name,omitempty"` 48 | ToolCallID string `json:"tool_call_id,omitempty"` 49 | } 50 | -------------------------------------------------------------------------------- /units/crawler.go: -------------------------------------------------------------------------------- 1 | package units 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | "strings" 9 | ) 10 | 11 | // Crawler performs web crawling to extract content from a URL 12 | func Crawler(url string) (string, error) { 13 | fmt.Printf("正在使用 URL 进行自定义爬取:%s\n", url) 14 | 15 | reqBody := map[string]string{ 16 | "url": url, 17 | } 18 | 19 | jsonData, err := json.Marshal(reqBody) 20 | if err != nil { 21 | return "", fmt.Errorf("JSON编码失败: %v", err) 22 | } 23 | 24 | resp, err := http.Post("https://crawl.search1api.com", "application/json", bytes.NewBuffer(jsonData)) 25 | if err != nil { 26 | return "", fmt.Errorf("API请求失败: %v", err) 27 | } 28 | defer resp.Body.Close() 29 | 30 | if resp.StatusCode != http.StatusOK { 31 | return "", fmt.Errorf("API请求失败, 状态码: %d", resp.StatusCode) 32 | } 33 | 34 | contentType := resp.Header.Get("Content-Type") 35 | if !strings.Contains(contentType, "application/json") { 36 | return "", fmt.Errorf("收到的响应不是有效的JSON格式") 37 | } 38 | 39 | var result map[string]interface{} 40 | if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { 41 | return "", fmt.Errorf("JSON解码失败: %v", err) 42 | } 43 | 44 | fmt.Println("自定义爬取服务调用完成") 45 | 46 | // Convert the result back to JSON string 47 | responseData, err := json.Marshal(result) 48 | if err != nil { 49 | return "", fmt.Errorf("响应JSON编码失败: %v", err) 50 | } 51 | 52 | return string(responseData), nil 53 | } 54 | -------------------------------------------------------------------------------- /units/search.go: -------------------------------------------------------------------------------- 1 | package units 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | "net/url" 9 | "os" 10 | ) 11 | 12 | // SearchResult represents a single search result 13 | type SearchResult struct { 14 | Title string `json:"title"` 15 | Link string `json:"link"` 16 | Snippet string `json:"snippet"` 17 | } 18 | 19 | // SearchResponse represents the response from a search 20 | type SearchResponse struct { 21 | Results []SearchResult `json:"results"` 22 | } 23 | 24 | // Search performs a search using the configured search service 25 | func Search(query string) (string, error) { 26 | fmt.Printf("正在使用查询进行自定义搜索: %s\n", query) 27 | 28 | searchService := os.Getenv("SEARCH_SERVICE") 29 | if searchService == "" { 30 | searchService = "duckduckgo" // Default to DuckDuckGo 31 | } 32 | 33 | var results []SearchResult 34 | var err error 35 | 36 | switch searchService { 37 | case "search1api": 38 | results, err = searchWithSearch1API(query) 39 | case "google": 40 | results, err = searchWithGoogle(query) 41 | case "bing": 42 | results, err = searchWithBing(query) 43 | case "serpapi": 44 | results, err = searchWithSerpAPI(query) 45 | case "serper": 46 | results, err = searchWithSerper(query) 47 | case "duckduckgo": 48 | results, err = searchWithDuckDuckGo(query) 49 | case "searxng": 50 | results, err = searchWithSearXNG(query) 51 | default: 52 | return "", fmt.Errorf("不支持的搜索服务: %s", searchService) 53 | } 54 | 55 | if err != nil { 56 | return "", fmt.Errorf("搜索失败: %v", err) 57 | } 58 | 59 | response := SearchResponse{Results: results} 60 | jsonData, err := json.Marshal(response) 61 | if err != nil { 62 | return "", fmt.Errorf("JSON编码失败: %v", err) 63 | } 64 | 65 | fmt.Println("自定义搜索服务调用完成") 66 | return string(jsonData), nil 67 | } 68 | 69 | func searchWithSearch1API(query string) ([]SearchResult, error) { 70 | apiKey := os.Getenv("SEARCH1API_KEY") 71 | maxResults := os.Getenv("MAX_RESULTS") 72 | if maxResults == "" { 73 | maxResults = "10" 74 | } 75 | 76 | reqBody := map[string]string{ 77 | "query": query, 78 | "max_results": maxResults, 79 | "crawl_results": "0", 80 | } 81 | 82 | jsonData, err := json.Marshal(reqBody) 83 | if err != nil { 84 | return nil, err 85 | } 86 | 87 | req, err := http.NewRequest("POST", "https://api.search1api.com/search/", bytes.NewBuffer(jsonData)) 88 | if err != nil { 89 | return nil, err 90 | } 91 | 92 | req.Header.Set("Content-Type", "application/json") 93 | if apiKey != "" { 94 | req.Header.Set("Authorization", "Bearer "+apiKey) 95 | } 96 | 97 | resp, err := http.DefaultClient.Do(req) 98 | if err != nil { 99 | return nil, err 100 | } 101 | defer resp.Body.Close() 102 | 103 | var response SearchResponse 104 | if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { 105 | return nil, err 106 | } 107 | 108 | return response.Results, nil 109 | } 110 | 111 | func searchWithGoogle(query string) ([]SearchResult, error) { 112 | cx := os.Getenv("GOOGLE_CX") 113 | apiKey := os.Getenv("GOOGLE_KEY") 114 | maxResults := os.Getenv("MAX_RESULTS") 115 | if maxResults == "" { 116 | maxResults = "10" 117 | } 118 | 119 | apiURL := fmt.Sprintf("https://www.googleapis.com/customsearch/v1?cx=%s&key=%s&q=%s", 120 | url.QueryEscape(cx), 121 | url.QueryEscape(apiKey), 122 | url.QueryEscape(query)) 123 | 124 | resp, err := http.Get(apiURL) 125 | if err != nil { 126 | return nil, err 127 | } 128 | defer resp.Body.Close() 129 | 130 | var googleResp struct { 131 | Items []struct { 132 | Title string `json:"title"` 133 | Link string `json:"link"` 134 | Snippet string `json:"snippet"` 135 | } `json:"items"` 136 | } 137 | 138 | if err := json.NewDecoder(resp.Body).Decode(&googleResp); err != nil { 139 | return nil, err 140 | } 141 | 142 | var results []SearchResult 143 | for _, item := range googleResp.Items { 144 | results = append(results, SearchResult{ 145 | Title: item.Title, 146 | Link: item.Link, 147 | Snippet: item.Snippet, 148 | }) 149 | } 150 | 151 | return results[:min(len(results), parseInt(maxResults))], nil 152 | } 153 | 154 | func searchWithBing(query string) ([]SearchResult, error) { 155 | apiKey := os.Getenv("BING_KEY") 156 | maxResults := os.Getenv("MAX_RESULTS") 157 | if maxResults == "" { 158 | maxResults = "10" 159 | } 160 | 161 | req, err := http.NewRequest("GET", "https://api.bing.microsoft.com/v7.0/search?q="+url.QueryEscape(query), nil) 162 | if err != nil { 163 | return nil, err 164 | } 165 | 166 | req.Header.Set("Ocp-Apim-Subscription-Key", apiKey) 167 | 168 | resp, err := http.DefaultClient.Do(req) 169 | if err != nil { 170 | return nil, err 171 | } 172 | defer resp.Body.Close() 173 | 174 | var bingResp struct { 175 | WebPages struct { 176 | Value []struct { 177 | Name string `json:"name"` 178 | URL string `json:"url"` 179 | Snippet string `json:"snippet"` 180 | } `json:"value"` 181 | } `json:"webPages"` 182 | } 183 | 184 | if err := json.NewDecoder(resp.Body).Decode(&bingResp); err != nil { 185 | return nil, err 186 | } 187 | 188 | var results []SearchResult 189 | for _, item := range bingResp.WebPages.Value { 190 | results = append(results, SearchResult{ 191 | Title: item.Name, 192 | Link: item.URL, 193 | Snippet: item.Snippet, 194 | }) 195 | } 196 | 197 | return results[:min(len(results), parseInt(maxResults))], nil 198 | } 199 | 200 | func searchWithSerpAPI(query string) ([]SearchResult, error) { 201 | apiKey := os.Getenv("SERPAPI_KEY") 202 | maxResults := os.Getenv("MAX_RESULTS") 203 | if maxResults == "" { 204 | maxResults = "10" 205 | } 206 | 207 | apiURL := fmt.Sprintf("https://serpapi.com/search?api_key=%s&engine=google&q=%s&google_domain=google.com", 208 | url.QueryEscape(apiKey), 209 | url.QueryEscape(query)) 210 | 211 | resp, err := http.Get(apiURL) 212 | if err != nil { 213 | return nil, err 214 | } 215 | defer resp.Body.Close() 216 | 217 | var serpResp struct { 218 | Organic []struct { 219 | Title string `json:"title"` 220 | Link string `json:"link"` 221 | Snippet string `json:"snippet"` 222 | } `json:"organic_results"` 223 | } 224 | 225 | if err := json.NewDecoder(resp.Body).Decode(&serpResp); err != nil { 226 | return nil, err 227 | } 228 | 229 | var results []SearchResult 230 | for _, item := range serpResp.Organic { 231 | results = append(results, SearchResult{ 232 | Title: item.Title, 233 | Link: item.Link, 234 | Snippet: item.Snippet, 235 | }) 236 | } 237 | 238 | return results[:min(len(results), parseInt(maxResults))], nil 239 | } 240 | 241 | func searchWithSerper(query string) ([]SearchResult, error) { 242 | apiKey := os.Getenv("SERPER_KEY") 243 | gl := os.Getenv("GL") 244 | if gl == "" { 245 | gl = "us" 246 | } 247 | hl := os.Getenv("HL") 248 | if hl == "" { 249 | hl = "en" 250 | } 251 | 252 | reqBody := map[string]string{ 253 | "q": query, 254 | "gl": gl, 255 | "hl": hl, 256 | } 257 | 258 | jsonData, err := json.Marshal(reqBody) 259 | if err != nil { 260 | return nil, err 261 | } 262 | 263 | req, err := http.NewRequest("POST", "https://google.serper.dev/search", bytes.NewBuffer(jsonData)) 264 | if err != nil { 265 | return nil, err 266 | } 267 | 268 | req.Header.Set("X-API-KEY", apiKey) 269 | req.Header.Set("Content-Type", "application/json") 270 | 271 | resp, err := http.DefaultClient.Do(req) 272 | if err != nil { 273 | return nil, err 274 | } 275 | defer resp.Body.Close() 276 | 277 | var serperResp struct { 278 | Organic []struct { 279 | Title string `json:"title"` 280 | Link string `json:"link"` 281 | Snippet string `json:"snippet"` 282 | } `json:"organic"` 283 | } 284 | 285 | if err := json.NewDecoder(resp.Body).Decode(&serperResp); err != nil { 286 | return nil, err 287 | } 288 | 289 | maxResults := parseInt(os.Getenv("MAX_RESULTS")) 290 | if maxResults == 0 { 291 | maxResults = 10 292 | } 293 | 294 | var results []SearchResult 295 | for _, item := range serperResp.Organic { 296 | results = append(results, SearchResult{ 297 | Title: item.Title, 298 | Link: item.Link, 299 | Snippet: item.Snippet, 300 | }) 301 | } 302 | 303 | return results[:min(len(results), maxResults)], nil 304 | } 305 | 306 | func searchWithDuckDuckGo(query string) ([]SearchResult, error) { 307 | maxResults := os.Getenv("MAX_RESULTS") 308 | if maxResults == "" { 309 | maxResults = "10" 310 | } 311 | 312 | reqBody := map[string]string{ 313 | "q": query, 314 | "max_results": maxResults, 315 | } 316 | 317 | jsonData, err := json.Marshal(reqBody) 318 | if err != nil { 319 | return nil, err 320 | } 321 | 322 | resp, err := http.Post("https://ddg.search2ai.online/search", "application/json", bytes.NewBuffer(jsonData)) 323 | if err != nil { 324 | return nil, err 325 | } 326 | defer resp.Body.Close() 327 | 328 | var duckResp struct { 329 | Results []struct { 330 | Title string `json:"title"` 331 | Href string `json:"href"` 332 | Body string `json:"body"` 333 | } `json:"results"` 334 | } 335 | 336 | if err := json.NewDecoder(resp.Body).Decode(&duckResp); err != nil { 337 | return nil, err 338 | } 339 | 340 | var results []SearchResult 341 | for _, item := range duckResp.Results { 342 | results = append(results, SearchResult{ 343 | Title: item.Title, 344 | Link: item.Href, 345 | Snippet: item.Body, 346 | }) 347 | } 348 | 349 | return results, nil 350 | } 351 | 352 | func searchWithSearXNG(query string) ([]SearchResult, error) { 353 | baseURL := os.Getenv("SEARXNG_BASE_URL") 354 | maxResults := parseInt(os.Getenv("MAX_RESULTS")) 355 | if maxResults == 0 { 356 | maxResults = 10 357 | } 358 | 359 | apiURL := fmt.Sprintf("%s/search?q=%s&category=general&format=json", 360 | baseURL, 361 | url.QueryEscape(query)) 362 | 363 | resp, err := http.Get(apiURL) 364 | if err != nil { 365 | return nil, err 366 | } 367 | defer resp.Body.Close() 368 | 369 | var searxResp struct { 370 | Results []struct { 371 | Title string `json:"title"` 372 | URL string `json:"url"` 373 | Content string `json:"content"` 374 | } `json:"results"` 375 | } 376 | 377 | if err := json.NewDecoder(resp.Body).Decode(&searxResp); err != nil { 378 | return nil, err 379 | } 380 | 381 | var results []SearchResult 382 | for _, item := range searxResp.Results { 383 | results = append(results, SearchResult{ 384 | Title: item.Title, 385 | Link: item.URL, 386 | Snippet: item.Content, 387 | }) 388 | } 389 | 390 | return results[:min(len(results), maxResults)], nil 391 | } 392 | 393 | // Helper functions 394 | func min(a, b int) int { 395 | if a < b { 396 | return a 397 | } 398 | return b 399 | } 400 | 401 | func parseInt(s string) int { 402 | var result int 403 | fmt.Sscanf(s, "%d", &result) 404 | return result 405 | } 406 | --------------------------------------------------------------------------------