├── .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 |
--------------------------------------------------------------------------------