diff --git a/config/config.example.json b/config/config.example.json index ee3ac97f7..54e0d45ca 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -103,10 +103,20 @@ }, "tools": { "web": { - "search": { + "brave": { + "enabled": false, "api_key": "YOUR_BRAVE_API_KEY", "max_results": 5 + }, + "duckduckgo": { + "enabled": true, + "max_results": 5 } + }, + "media": { + "max_inbound_image_bytes": 5242880, + "max_inbound_images": 3, + "max_outbound_file_bytes": 10485760 } }, "heartbeat": { diff --git a/pkg/agent/context.go b/pkg/agent/context.go index cf5ce2913..744607572 100644 --- a/pkg/agent/context.go +++ b/pkg/agent/context.go @@ -1,7 +1,10 @@ package agent import ( + "encoding/base64" "fmt" + "net/http" + "mime" "os" "path/filepath" "runtime" @@ -14,11 +17,18 @@ import ( "github.com/sipeed/picoclaw/pkg/tools" ) +const ( + maxVisionImagesPerMessage = 3 + maxVisionImageBytes = 5 * 1024 * 1024 +) + type ContextBuilder struct { workspace string skillsLoader *skills.SkillsLoader memory *MemoryStore tools *tools.ToolRegistry // Direct reference to tool registry + maxImages int + maxImageSize int64 } func getGlobalConfigDir() string { @@ -40,6 +50,8 @@ func NewContextBuilder(workspace string) *ContextBuilder { workspace: workspace, skillsLoader: skills.NewSkillsLoader(workspace, globalSkillsDir, builtinSkillsDir), memory: NewMemoryStore(workspace), + maxImages: maxVisionImagesPerMessage, + maxImageSize: maxVisionImageBytes, } } @@ -48,6 +60,15 @@ func (cb *ContextBuilder) SetToolsRegistry(registry *tools.ToolRegistry) { cb.tools = registry } +func (cb *ContextBuilder) SetMediaLimits(maxImages int, maxImageSize int64) { + if maxImages > 0 { + cb.maxImages = maxImages + } + if maxImageSize > 0 { + cb.maxImageSize = maxImageSize + } +} + func (cb *ContextBuilder) getIdentity() string { now := time.Now().Format("2006-01-02 15:04 (Monday)") workspacePath, _ := filepath.Abs(filepath.Join(cb.workspace)) @@ -207,14 +228,99 @@ func (cb *ContextBuilder) BuildMessages(history []providers.Message, summary str messages = append(messages, history...) - messages = append(messages, providers.Message{ + userMsg := providers.Message{ Role: "user", Content: currentMessage, - }) + } + + visionMedia, skipped := cb.buildVisionMedia(media) + if len(visionMedia) > 0 { + userMsg.Media = visionMedia + } + if len(skipped) > 0 { + notice := "\n\n[media-note]\n" + strings.Join(skipped, "\n") + userMsg.Content += notice + } + + messages = append(messages, userMsg) return messages } +func (cb *ContextBuilder) buildVisionMedia(paths []string) ([]providers.MediaItem, []string) { + if len(paths) == 0 { + return nil, nil + } + + items := make([]providers.MediaItem, 0, len(paths)) + skipped := make([]string, 0) + + for _, p := range paths { + if len(items) >= cb.maxImages { + skipped = append(skipped, fmt.Sprintf("- skipped %q: image limit reached (%d)", p, cb.maxImages)) + continue + } + + item, reason, ok := loadImageAsDataURL(p, cb.maxImageSize) + if !ok { + if reason != "" { + skipped = append(skipped, fmt.Sprintf("- skipped %q: %s", p, reason)) + } + continue + } + items = append(items, item) + } + + return items, skipped +} + +func loadImageAsDataURL(path string, maxImageSize int64) (providers.MediaItem, string, bool) { + info, err := os.Stat(path) + if err != nil { + return providers.MediaItem{}, "file not found or unreadable", false + } + if info.IsDir() { + return providers.MediaItem{}, "is a directory", false + } + if maxImageSize > 0 && info.Size() > maxImageSize { + return providers.MediaItem{}, fmt.Sprintf("too large (%d bytes > %d bytes)", info.Size(), maxImageSize), false + } + + content, err := os.ReadFile(path) + if err != nil { + return providers.MediaItem{}, "failed to read file", false + } + + mimeType := detectImageMIME(path, content) + if !strings.HasPrefix(mimeType, "image/") { + return providers.MediaItem{}, "", false + } + + encoded := base64.StdEncoding.EncodeToString(content) + return providers.MediaItem{ + Type: "image_url", + URL: fmt.Sprintf("data:%s;base64,%s", mimeType, encoded), + MIMEType: mimeType, + SourceRef: path, + }, "", true +} + +func detectImageMIME(path string, content []byte) string { + if ext := strings.ToLower(filepath.Ext(path)); ext != "" { + if mt := mime.TypeByExtension(ext); mt != "" { + if semi := strings.Index(mt, ";"); semi > 0 { + return mt[:semi] + } + return mt + } + } + detected := http.DetectContentType(content) + if semi := strings.Index(detected, ";"); semi > 0 { + return detected[:semi] + } + return detected +} + func (cb *ContextBuilder) AddToolResult(messages []providers.Message, toolCallID, toolName, result string) []providers.Message { messages = append(messages, providers.Message{ Role: "tool", diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index ac8da9ffd..6b9f017f8 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -49,6 +49,7 @@ type processOptions struct { Channel string // Target channel for tool execution ChatID string // Target chat ID for tool execution UserMessage string // User message content (may include prefix) + Media []string DefaultResponse string // Response when LLM returns empty EnableSummary bool // Whether to trigger summarization SendResponse bool // Whether to send response via bus @@ -59,6 +60,10 @@ type processOptions struct { // This is shared between main agent and subagents. func createToolRegistry(workspace string, restrict bool, cfg *config.Config, msgBus *bus.MessageBus) *tools.ToolRegistry { registry := tools.NewToolRegistry() + maxOutboundFileBytes := cfg.Tools.Media.MaxOutboundFileBytes + if maxOutboundFileBytes <= 0 { + maxOutboundFileBytes = 10 * 1024 * 1024 + } // File system tools registry.Register(tools.NewReadFileTool(workspace, restrict)) @@ -94,6 +99,20 @@ func createToolRegistry(workspace string, restrict bool, cfg *config.Config, msg }) registry.Register(messageTool) + sendFileTool := tools.NewSendFileTool(workspace, restrict, maxOutboundFileBytes) + sendFileTool.SetSendCallback(func(msg bus.OutboundMessage) error { + msgBus.PublishOutbound(msg) + return nil + }) + registry.Register(sendFileTool) + + screenshotTool := tools.NewScreenshotTool(workspace, restrict, maxOutboundFileBytes) + screenshotTool.SetSendCallback(func(msg bus.OutboundMessage) error { + msgBus.PublishOutbound(msg) + return nil + }) + registry.Register(screenshotTool) + return registry } @@ -128,6 +147,7 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers // Create context builder and set tools registry contextBuilder := NewContextBuilder(workspace) contextBuilder.SetToolsRegistry(toolsRegistry) + contextBuilder.SetMediaLimits(cfg.Tools.Media.MaxInboundImages, cfg.Tools.Media.MaxInboundImageBytes) return &AgentLoop{ bus: msgBus, @@ -264,6 +284,7 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) Channel: msg.Channel, ChatID: msg.ChatID, UserMessage: msg.Content, + Media: msg.Media, DefaultResponse: "I've completed processing but have no response to give.", EnableSummary: true, SendResponse: false, @@ -350,7 +371,7 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, opts processOptions) (str history, summary, opts.UserMessage, - nil, + opts.Media, opts.Channel, opts.ChatID, ) @@ -581,6 +602,16 @@ func (al *AgentLoop) updateToolContexts(channel, chatID string) { st.SetContext(channel, chatID) } } + if tool, ok := al.tools.Get("send_file"); ok { + if ft, ok := tool.(tools.ContextualTool); ok { + ft.SetContext(channel, chatID) + } + } + if tool, ok := al.tools.Get("screenshot"); ok { + if st, ok := tool.(tools.ContextualTool); ok { + st.SetContext(channel, chatID) + } + } } // maybeSummarize triggers summarization if the session history exceeds thresholds. diff --git a/pkg/bus/types.go b/pkg/bus/types.go index 44f9181a5..f1375cbd7 100644 --- a/pkg/bus/types.go +++ b/pkg/bus/types.go @@ -10,10 +10,19 @@ type InboundMessage struct { Metadata map[string]string `json:"metadata,omitempty"` } +type Attachment struct { + Type string `json:"type"` // image | file | audio | video + Path string `json:"path,omitempty"` + URL string `json:"url,omitempty"` + FileName string `json:"file_name,omitempty"` + MIMEType string `json:"mime_type,omitempty"` +} + type OutboundMessage struct { - Channel string `json:"channel"` - ChatID string `json:"chat_id"` - Content string `json:"content"` + Channel string `json:"channel"` + ChatID string `json:"chat_id"` + Content string `json:"content"` + Attachments []Attachment `json:"attachments,omitempty"` } type MessageHandler func(InboundMessage) error diff --git a/pkg/channels/line.go b/pkg/channels/line.go index ffb5533e8..1109ae286 100644 --- a/pkg/channels/line.go +++ b/pkg/channels/line.go @@ -10,7 +10,6 @@ import ( "fmt" "io" "net/http" - "os" "strings" "sync" "time" @@ -309,17 +308,6 @@ func (c *LINEChannel) processEvent(event lineEvent) { var mediaPaths []string localFiles := []string{} - defer func() { - for _, file := range localFiles { - if err := os.Remove(file); err != nil { - logger.DebugCF("line", "Failed to cleanup temp file", map[string]interface{}{ - "file": file, - "error": err.Error(), - }) - } - } - }() - switch msg.Type { case "text": content = msg.Text @@ -378,6 +366,9 @@ func (c *LINEChannel) processEvent(event lineEvent) { c.sendLoading(senderID) c.HandleMessage(senderID, chatID, content, mediaPaths, metadata) + for _, file := range localFiles { + utils.ScheduleFileCleanup(file, 15*time.Minute, "line") + } } // isBotMentioned checks if the bot is mentioned in the message. diff --git a/pkg/channels/slack.go b/pkg/channels/slack.go index d86d08a9d..6d5b73f57 100644 --- a/pkg/channels/slack.go +++ b/pkg/channels/slack.go @@ -3,7 +3,6 @@ package channels import ( "context" "fmt" - "os" "strings" "sync" "time" @@ -232,18 +231,6 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) { var mediaPaths []string localFiles := []string{} // 跟踪需要清理的本地文件 - // 确保临时文件在函数返回时被清理 - defer func() { - for _, file := range localFiles { - if err := os.Remove(file); err != nil { - logger.DebugCF("slack", "Failed to cleanup temp file", map[string]interface{}{ - "file": file, - "error": err.Error(), - }) - } - } - }() - if ev.Message != nil && len(ev.Message.Files) > 0 { for _, file := range ev.Message.Files { localPath := c.downloadSlackFile(file) @@ -289,6 +276,9 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) { }) c.HandleMessage(senderID, chatID, content, mediaPaths, metadata) + for _, file := range localFiles { + utils.ScheduleFileCleanup(file, 15*time.Minute, "slack") + } } func (c *SlackChannel) handleAppMention(ev *slackevents.AppMentionEvent) { diff --git a/pkg/channels/telegram.go b/pkg/channels/telegram.go index 0934dbd08..5552e98d0 100644 --- a/pkg/channels/telegram.go +++ b/pkg/channels/telegram.go @@ -1,11 +1,16 @@ package channels import ( + "bytes" "context" + "encoding/json" "fmt" + "io" + "mime/multipart" "net/http" "net/url" "os" + "path/filepath" "regexp" "strings" "sync" @@ -24,6 +29,7 @@ import ( type TelegramChannel struct { *BaseChannel bot *telego.Bot + httpClient *http.Client config config.TelegramConfig chatIDs map[string]int64 transcriber *voice.GroqTranscriber @@ -43,17 +49,19 @@ func (c *thinkingCancel) Cancel() { func NewTelegramChannel(cfg config.TelegramConfig, bus *bus.MessageBus) (*TelegramChannel, error) { var opts []telego.BotOption + httpClient := &http.Client{} if cfg.Proxy != "" { proxyURL, parseErr := url.Parse(cfg.Proxy) if parseErr != nil { return nil, fmt.Errorf("invalid proxy URL %q: %w", cfg.Proxy, parseErr) } - opts = append(opts, telego.WithHTTPClient(&http.Client{ + httpClient = &http.Client{ Transport: &http.Transport{ Proxy: http.ProxyURL(proxyURL), }, - })) + } + opts = append(opts, telego.WithHTTPClient(httpClient)) } bot, err := telego.NewBot(cfg.Token, opts...) @@ -66,6 +74,7 @@ func NewTelegramChannel(cfg config.TelegramConfig, bus *bus.MessageBus) (*Telegr return &TelegramChannel{ BaseChannel: base, bot: bot, + httpClient: httpClient, config: cfg, chatIDs: make(map[string]int64), transcriber: nil, @@ -136,25 +145,43 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err } c.stopThinking.Delete(msg.ChatID) } + placeholderEdited := false + if len(msg.Attachments) > 0 { + placeholderEdited = c.editPlaceholder(ctx, msg.ChatID, chatID, "Attachment sent.") - htmlContent := markdownToTelegramHTML(msg.Content) - - // Try to edit placeholder - if pID, ok := c.placeholders.Load(msg.ChatID); ok { - c.placeholders.Delete(msg.ChatID) - editMsg := tu.EditMessageText(tu.ID(chatID), pID.(int), htmlContent) - editMsg.ParseMode = telego.ModeHTML - - if _, err = c.bot.EditMessageText(ctx, editMsg); err == nil { - return nil + caption, truncated := truncateTelegramCaption(strings.TrimSpace(msg.Content)) + for i, attachment := range msg.Attachments { + cap := "" + if i == 0 { + cap = caption + } + if err := c.sendAttachment(ctx, chatID, attachment, cap); err != nil { + return err + } } - // Fallback to new message if edit fails + + if truncated { + if err := c.sendTextMessage(ctx, msg.ChatID, chatID, msg.Content, placeholderEdited); err != nil { + return err + } + } + return nil + } + + return c.sendTextMessage(ctx, msg.ChatID, chatID, msg.Content, false) +} + +func (c *TelegramChannel) sendTextMessage(ctx context.Context, chatIDKey string, chatID int64, content string, skipPlaceholder bool) error { + htmlContent := markdownToTelegramHTML(content) + + if !skipPlaceholder && c.editPlaceholder(ctx, chatIDKey, chatID, htmlContent) { + return nil } tgMsg := tu.Message(tu.ID(chatID), htmlContent) tgMsg.ParseMode = telego.ModeHTML - if _, err = c.bot.SendMessage(ctx, tgMsg); err != nil { + if _, err := c.bot.SendMessage(ctx, tgMsg); err != nil { logger.ErrorCF("telegram", "HTML parse failed, falling back to plain text", map[string]interface{}{ "error": err.Error(), }) @@ -166,6 +193,129 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err return nil } +func (c *TelegramChannel) editPlaceholder(ctx context.Context, chatIDKey string, chatID int64, htmlContent string) bool { + if pID, ok := c.placeholders.Load(chatIDKey); ok { + c.placeholders.Delete(chatIDKey) + editMsg := tu.EditMessageText(tu.ID(chatID), pID.(int), htmlContent) + editMsg.ParseMode = telego.ModeHTML + + if _, err := c.bot.EditMessageText(ctx, editMsg); err == nil { + return true + } + } + return false +} + +func truncateTelegramCaption(content string) (string, bool) { + if content == "" { + return "", false + } + const maxCaptionLen = 1024 + if len(content) <= maxCaptionLen { + return content, false + } + return content[:maxCaptionLen], true +} + +func (c *TelegramChannel) sendAttachment(ctx context.Context, chatID int64, attachment bus.Attachment, caption string) error { + path := strings.TrimSpace(attachment.Path) + if path == "" { + return fmt.Errorf("telegram attachment requires local file path") + } + + file, err := os.Open(path) + if err != nil { + return fmt.Errorf("open attachment %q: %w", path, err) + } + defer file.Close() + + var endpoint string + var formField string + if isTelegramPhoto(attachment, path) { + endpoint = "sendPhoto" + formField = "photo" + } else { + endpoint = "sendDocument" + formField = "document" + } + + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + + if err := writer.WriteField("chat_id", fmt.Sprintf("%d", chatID)); err != nil { + return fmt.Errorf("write chat_id field: %w", err) + } + if caption != "" { + if err := writer.WriteField("caption", caption); err != nil { + return fmt.Errorf("write caption field: %w", err) + } + } + + filename := attachment.FileName + if filename == "" { + filename = filepath.Base(path) + } + + part, err := writer.CreateFormFile(formField, filename) + if err != nil { + return fmt.Errorf("create multipart form file: %w", err) + } + if _, err := io.Copy(part, file); err != nil { + return fmt.Errorf("copy file content: %w", err) + } + if err := writer.Close(); err != nil { + return fmt.Errorf("close multipart body: %w", err) + } + + apiURL := fmt.Sprintf("https://api.telegram.org/bot%s/%s", c.config.Token, endpoint) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, body) + if err != nil { + return fmt.Errorf("create request: %w", err) + } + req.Header.Set("Content-Type", writer.FormDataContentType()) + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("send telegram attachment: %w", err) + } + defer resp.Body.Close() + + respBody, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("telegram attachment API failed: status=%d body=%s", resp.StatusCode, strings.TrimSpace(string(respBody))) + } + + var apiResp struct { + OK bool `json:"ok"` + Description string `json:"description"` + Result json.RawMessage `json:"result"` + } + if err := json.Unmarshal(respBody, &apiResp); err != nil { + return fmt.Errorf("parse telegram attachment response: %w", err) + } + if !apiResp.OK { + return fmt.Errorf("telegram attachment API error: %s", apiResp.Description) + } + + return nil +} + +func isTelegramPhoto(attachment bus.Attachment, path string) bool { + if strings.EqualFold(attachment.Type, "image") { + return true + } + if strings.HasPrefix(strings.ToLower(attachment.MIMEType), "image/") { + return true + } + + switch strings.ToLower(filepath.Ext(path)) { + case ".jpg", ".jpeg", ".png", ".gif", ".webp": + return true + default: + return false + } +} + func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Update) { message := update.Message if message == nil { @@ -197,19 +347,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Updat content := "" mediaPaths := []string{} - localFiles := []string{} // 跟踪需要清理的本地文件 - - // 确保临时文件在函数返回时被清理 - defer func() { - for _, file := range localFiles { - if err := os.Remove(file); err != nil { - logger.DebugCF("telegram", "Failed to cleanup temp file", map[string]interface{}{ - "file": file, - "error": err.Error(), - }) - } - } - }() + localFiles := []string{} if message.Text != "" { content += message.Text @@ -362,6 +500,11 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Updat } c.HandleMessage(senderID, fmt.Sprintf("%d", chatID), content, mediaPaths, metadata) + + for _, file := range localFiles { + // Delay cleanup so downstream processing (vision, file tools) can access the file. + utils.ScheduleFileCleanup(file, 15*time.Minute, "telegram") + } } func (c *TelegramChannel) downloadPhoto(ctx context.Context, fileID string) string { diff --git a/pkg/config/config.go b/pkg/config/config.go index 6af9438a5..2e8a5b4e2 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -191,8 +191,15 @@ type WebToolsConfig struct { DuckDuckGo DuckDuckGoConfig `json:"duckduckgo"` } +type MediaToolsConfig struct { + MaxInboundImageBytes int64 `json:"max_inbound_image_bytes" env:"PICOCLAW_TOOLS_MEDIA_MAX_INBOUND_IMAGE_BYTES"` + MaxInboundImages int `json:"max_inbound_images" env:"PICOCLAW_TOOLS_MEDIA_MAX_INBOUND_IMAGES"` + MaxOutboundFileBytes int64 `json:"max_outbound_file_bytes" env:"PICOCLAW_TOOLS_MEDIA_MAX_OUTBOUND_FILE_BYTES"` +} + type ToolsConfig struct { - Web WebToolsConfig `json:"web"` + Web WebToolsConfig `json:"web"` + Media MediaToolsConfig `json:"media"` } func DefaultConfig() *Config { @@ -294,6 +301,11 @@ func DefaultConfig() *Config { MaxResults: 5, }, }, + Media: MediaToolsConfig{ + MaxInboundImageBytes: 5 * 1024 * 1024, + MaxInboundImages: 3, + MaxOutboundFileBytes: 10 * 1024 * 1024, + }, }, Heartbeat: HeartbeatConfig{ Enabled: true, diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 14618b109..4f374d010 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -145,6 +145,16 @@ func TestDefaultConfig_WebTools(t *testing.T) { if cfg.Tools.Web.DuckDuckGo.MaxResults != 5 { t.Error("Expected DuckDuckGo MaxResults 5, got ", cfg.Tools.Web.DuckDuckGo.MaxResults) } + + if cfg.Tools.Media.MaxInboundImageBytes <= 0 { + t.Error("Expected positive MaxInboundImageBytes") + } + if cfg.Tools.Media.MaxInboundImages <= 0 { + t.Error("Expected positive MaxInboundImages") + } + if cfg.Tools.Media.MaxOutboundFileBytes <= 0 { + t.Error("Expected positive MaxOutboundFileBytes") + } } // TestConfig_Complete verifies all config fields are set diff --git a/pkg/providers/http_provider.go b/pkg/providers/http_provider.go index fc78a182f..e91705504 100644 --- a/pkg/providers/http_provider.go +++ b/pkg/providers/http_provider.go @@ -62,7 +62,7 @@ func (p *HTTPProvider) Chat(ctx context.Context, messages []Message, tools []Too requestBody := map[string]interface{}{ "model": model, - "messages": messages, + "messages": buildOpenAICompatMessages(messages), } if len(tools) > 0 { @@ -126,7 +126,7 @@ func (p *HTTPProvider) parseResponse(body []byte) (*LLMResponse, error) { var apiResponse struct { Choices []struct { Message struct { - Content string `json:"content"` + Content json.RawMessage `json:"content"` ToolCalls []struct { ID string `json:"id"` Type string `json:"type"` @@ -153,6 +153,7 @@ func (p *HTTPProvider) parseResponse(body []byte) (*LLMResponse, error) { } choice := apiResponse.Choices[0] + content := parseOpenAIContent(choice.Message.Content) toolCalls := make([]ToolCall, 0, len(choice.Message.ToolCalls)) for _, tc := range choice.Message.ToolCalls { @@ -185,13 +186,118 @@ func (p *HTTPProvider) parseResponse(body []byte) (*LLMResponse, error) { } return &LLMResponse{ - Content: choice.Message.Content, + Content: content, ToolCalls: toolCalls, FinishReason: choice.FinishReason, Usage: apiResponse.Usage, }, nil } +func buildOpenAICompatMessages(messages []Message) []map[string]interface{} { + out := make([]map[string]interface{}, 0, len(messages)) + for _, msg := range messages { + out = append(out, buildOpenAICompatMessage(msg)) + } + return out +} + +func buildOpenAICompatMessage(msg Message) map[string]interface{} { + result := map[string]interface{}{ + "role": msg.Role, + } + + switch msg.Role { + case "user": + contentParts := make([]map[string]interface{}, 0, 1+len(msg.Media)) + if strings.TrimSpace(msg.Content) != "" { + contentParts = append(contentParts, map[string]interface{}{ + "type": "text", + "text": msg.Content, + }) + } + for _, media := range msg.Media { + if media.Type != "image_url" || media.URL == "" { + continue + } + contentParts = append(contentParts, map[string]interface{}{ + "type": "image_url", + "image_url": map[string]interface{}{ + "url": media.URL, + }, + }) + } + if len(contentParts) == 0 { + result["content"] = msg.Content + } else { + result["content"] = contentParts + } + case "assistant": + result["content"] = msg.Content + if len(msg.ToolCalls) > 0 { + toolCalls := make([]map[string]interface{}, 0, len(msg.ToolCalls)) + for _, tc := range msg.ToolCalls { + name := tc.Name + args := "" + if tc.Function != nil { + if name == "" { + name = tc.Function.Name + } + args = tc.Function.Arguments + } + if args == "" && len(tc.Arguments) > 0 { + argsJSON, _ := json.Marshal(tc.Arguments) + args = string(argsJSON) + } + toolCalls = append(toolCalls, map[string]interface{}{ + "id": tc.ID, + "type": "function", + "function": map[string]interface{}{ + "name": name, + "arguments": args, + }, + }) + } + result["tool_calls"] = toolCalls + } + case "tool": + result["content"] = msg.Content + if msg.ToolCallID != "" { + result["tool_call_id"] = msg.ToolCallID + } + default: + result["content"] = msg.Content + } + + return result +} + +func parseOpenAIContent(raw json.RawMessage) string { + if len(raw) == 0 { + return "" + } + + var text string + if err := json.Unmarshal(raw, &text); err == nil { + return text + } + + var parts []struct { + Type string `json:"type"` + Text string `json:"text"` + } + if err := json.Unmarshal(raw, &parts); err != nil { + return "" + } + + lines := make([]string, 0, len(parts)) + for _, p := range parts { + if p.Type == "text" && p.Text != "" { + lines = append(lines, p.Text) + } + } + return strings.Join(lines, "\n") +} + func (p *HTTPProvider) GetDefaultModel() string { return "" } diff --git a/pkg/providers/http_provider_test.go b/pkg/providers/http_provider_test.go new file mode 100644 index 000000000..926cbb1f5 --- /dev/null +++ b/pkg/providers/http_provider_test.go @@ -0,0 +1,54 @@ +package providers + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestBuildOpenAICompatMessages_WithImageMedia(t *testing.T) { + messages := []Message{ + { + Role: "user", + Content: "Describe this image", + Media: []MediaItem{ + {Type: "image_url", URL: "data:image/png;base64,abc"}, + }, + }, + } + + out := buildOpenAICompatMessages(messages) + if len(out) != 1 { + t.Fatalf("expected 1 message, got %d", len(out)) + } + + content, ok := out[0]["content"].([]map[string]interface{}) + if !ok { + t.Fatalf("expected content parts array") + } + if len(content) != 2 { + t.Fatalf("expected 2 content parts, got %d", len(content)) + } + if content[1]["type"] != "image_url" { + t.Fatalf("expected image_url part") + } +} + +func TestParseOpenAIContent_TextArray(t *testing.T) { + raw := json.RawMessage(`[ + {"type":"text","text":"line1"}, + {"type":"text","text":"line2"} + ]`) + got := parseOpenAIContent(raw) + if !strings.Contains(got, "line1") || !strings.Contains(got, "line2") { + t.Fatalf("unexpected parsed content: %q", got) + } +} + +func TestParseOpenAIContent_String(t *testing.T) { + raw := json.RawMessage(`"hello"`) + got := parseOpenAIContent(raw) + if got != "hello" { + t.Fatalf("expected hello, got %q", got) + } +} diff --git a/pkg/providers/types.go b/pkg/providers/types.go index 88b62e975..36765f72d 100644 --- a/pkg/providers/types.go +++ b/pkg/providers/types.go @@ -31,10 +31,18 @@ type UsageInfo struct { type Message struct { Role string `json:"role"` Content string `json:"content"` + Media []MediaItem `json:"media,omitempty"` ToolCalls []ToolCall `json:"tool_calls,omitempty"` ToolCallID string `json:"tool_call_id,omitempty"` } +type MediaItem struct { + Type string `json:"type"` // currently supports "image_url" + URL string `json:"url,omitempty"` + MIMEType string `json:"mime_type,omitempty"` + SourceRef string `json:"source_ref,omitempty"` +} + type LLMProvider interface { Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) GetDefaultModel() string diff --git a/pkg/tools/screenshot.go b/pkg/tools/screenshot.go new file mode 100644 index 000000000..f91de79ff --- /dev/null +++ b/pkg/tools/screenshot.go @@ -0,0 +1,294 @@ +package tools + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/utils" +) + +type ScreenshotTool struct { + workspace string + restrict bool + maxFileBytes int64 + timeout time.Duration + sendCallback SendFileCallback + defaultChannel string + defaultChatID string + lookPath func(string) (string, error) + runCommand func(ctx context.Context, command string, args ...string) error + now func() time.Time +} + +func NewScreenshotTool(workspace string, restrict bool, maxFileBytes int64) *ScreenshotTool { + return &ScreenshotTool{ + workspace: workspace, + restrict: restrict, + maxFileBytes: maxFileBytes, + timeout: 45 * time.Second, + lookPath: exec.LookPath, + runCommand: func(ctx context.Context, command string, args ...string) error { + cmd := exec.CommandContext(ctx, command, args...) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("%w: %s", err, strings.TrimSpace(string(output))) + } + return nil + }, + now: time.Now, + } +} + +func (t *ScreenshotTool) Name() string { + return "screenshot" +} + +func (t *ScreenshotTool) Description() string { + return "Capture a screenshot (web URL or desktop) and optionally send it to user." +} + +func (t *ScreenshotTool) Parameters() map[string]interface{} { + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "url": map[string]interface{}{ + "type": "string", + "description": "Web page URL to capture. If omitted, captures local desktop screen.", + }, + "path": map[string]interface{}{ + "type": "string", + "description": "Optional output image path. Defaults to workspace/tmp/screenshots/*.png", + }, + "caption": map[string]interface{}{ + "type": "string", + "description": "Optional caption when sending screenshot.", + }, + "send": map[string]interface{}{ + "type": "boolean", + "description": "Whether to send the screenshot to chat. Default true.", + }, + "keep_local": map[string]interface{}{ + "type": "boolean", + "description": "Whether to keep local screenshot file after sending. Default false.", + }, + "width": map[string]interface{}{ + "type": "integer", + "description": "Browser viewport width for URL screenshot. Default 1366.", + }, + "height": map[string]interface{}{ + "type": "integer", + "description": "Browser viewport height for URL screenshot. Default 768.", + }, + "channel": map[string]interface{}{ + "type": "string", + "description": "Optional target channel override.", + }, + "chat_id": map[string]interface{}{ + "type": "string", + "description": "Optional target chat id override.", + }, + }, + } +} + +func (t *ScreenshotTool) SetContext(channel, chatID string) { + t.defaultChannel = channel + t.defaultChatID = chatID +} + +func (t *ScreenshotTool) SetSendCallback(callback SendFileCallback) { + t.sendCallback = callback +} + +func (t *ScreenshotTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult { + outputPath, err := t.resolveOutputPath(args) + if err != nil { + return ErrorResult(err.Error()) + } + + width := intFromArgs(args, "width", 1366) + height := intFromArgs(args, "height", 768) + targetURL, _ := args["url"].(string) + + cmdCtx, cancel := context.WithTimeout(ctx, t.timeout) + defer cancel() + + if strings.TrimSpace(targetURL) != "" { + if err := t.captureURL(cmdCtx, targetURL, outputPath, width, height); err != nil { + return ErrorResult(fmt.Sprintf("capture URL screenshot failed: %v", err)) + } + } else { + if err := t.captureDesktop(cmdCtx, outputPath); err != nil { + return ErrorResult(fmt.Sprintf("capture desktop screenshot failed: %v", err)) + } + } + + info, err := os.Stat(outputPath) + if err != nil { + return ErrorResult(fmt.Sprintf("screenshot not created: %v", err)) + } + if info.Size() == 0 { + return ErrorResult("screenshot is empty") + } + if t.maxFileBytes > 0 && info.Size() > t.maxFileBytes { + return ErrorResult(fmt.Sprintf("screenshot too large: %d bytes (limit %d bytes)", info.Size(), t.maxFileBytes)) + } + + send := boolFromArgs(args, "send", true) + keepLocal := boolFromArgs(args, "keep_local", false) + caption, _ := args["caption"].(string) + + if !send { + return UserResult(fmt.Sprintf("Screenshot saved: %s", outputPath)) + } + + channel, _ := args["channel"].(string) + chatID, _ := args["chat_id"].(string) + if channel == "" { + channel = t.defaultChannel + } + if chatID == "" { + chatID = t.defaultChatID + } + if channel == "" || chatID == "" { + return ErrorResult("No target channel/chat specified") + } + if t.sendCallback == nil { + return ErrorResult("Screenshot sending not configured") + } + + if err := t.sendCallback(bus.OutboundMessage{ + Channel: channel, + ChatID: chatID, + Content: caption, + Attachments: []bus.Attachment{ + { + Type: "image", + Path: outputPath, + FileName: filepath.Base(outputPath), + MIMEType: "image/png", + }, + }, + }); err != nil { + return ErrorResult(fmt.Sprintf("sending screenshot: %v", err)) + } + + if !keepLocal { + utils.ScheduleFileCleanup(outputPath, 15*time.Minute, "screenshot") + } + + return SilentResult(fmt.Sprintf("Screenshot sent to %s:%s", channel, chatID)) +} + +func (t *ScreenshotTool) resolveOutputPath(args map[string]interface{}) (string, error) { + rawPath, _ := args["path"].(string) + if strings.TrimSpace(rawPath) == "" { + rawPath = filepath.Join("tmp", "screenshots", t.now().Format("20060102_150405")+".png") + } + + resolvedPath, err := validatePath(rawPath, t.workspace, t.restrict) + if err != nil { + return "", err + } + if ext := strings.ToLower(filepath.Ext(resolvedPath)); ext == "" { + resolvedPath += ".png" + } + + if err := os.MkdirAll(filepath.Dir(resolvedPath), 0755); err != nil { + return "", fmt.Errorf("failed to create screenshot directory: %w", err) + } + return resolvedPath, nil +} + +func (t *ScreenshotTool) captureURL(ctx context.Context, targetURL, outputPath string, width, height int) error { + browsers := []string{"chromium-browser", "chromium", "google-chrome", "google-chrome-stable"} + var browser string + for _, candidate := range browsers { + if _, err := t.lookPath(candidate); err == nil { + browser = candidate + break + } + } + if browser == "" { + return fmt.Errorf("no headless browser found (tried: %s)", strings.Join(browsers, ", ")) + } + + args := []string{ + "--headless", + "--disable-gpu", + "--hide-scrollbars", + "--no-sandbox", + fmt.Sprintf("--window-size=%d,%d", width, height), + fmt.Sprintf("--screenshot=%s", outputPath), + targetURL, + } + return t.runCommand(ctx, browser, args...) +} + +func (t *ScreenshotTool) captureDesktop(ctx context.Context, outputPath string) error { + type command struct { + name string + args []string + } + + candidates := []command{ + {name: "scrot", args: []string{outputPath}}, + {name: "grim", args: []string{outputPath}}, + {name: "import", args: []string{"-window", "root", outputPath}}, + } + + var available []command + for _, candidate := range candidates { + if _, err := t.lookPath(candidate.name); err == nil { + available = append(available, candidate) + } + } + if len(available) == 0 { + return fmt.Errorf("no desktop screenshot command found (tried: scrot, grim, import)") + } + + var lastErr error + for _, cmd := range available { + if err := t.runCommand(ctx, cmd.name, cmd.args...); err == nil { + return nil + } else { + lastErr = err + } + } + return lastErr +} + +func intFromArgs(args map[string]interface{}, key string, fallback int) int { + v, ok := args[key] + if !ok { + return fallback + } + switch n := v.(type) { + case int: + if n > 0 { + return n + } + case float64: + if int(n) > 0 { + return int(n) + } + } + return fallback +} + +func boolFromArgs(args map[string]interface{}, key string, fallback bool) bool { + v, ok := args[key] + if !ok { + return fallback + } + if b, ok := v.(bool); ok { + return b + } + return fallback +} diff --git a/pkg/tools/screenshot_test.go b/pkg/tools/screenshot_test.go new file mode 100644 index 000000000..a305f42a5 --- /dev/null +++ b/pkg/tools/screenshot_test.go @@ -0,0 +1,104 @@ +package tools + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "github.com/sipeed/picoclaw/pkg/bus" +) + +func TestScreenshotTool_Execute_URL_Send(t *testing.T) { + tmpDir := t.TempDir() + tool := NewScreenshotTool(tmpDir, true, 1024*1024) + tool.SetContext("telegram", "999") + + tool.lookPath = func(s string) (string, error) { return "/usr/bin/" + s, nil } + tool.runCommand = func(ctx context.Context, command string, args ...string) error { + var output string + for _, arg := range args { + if len(arg) > len("--screenshot=") && arg[:13] == "--screenshot=" { + output = arg[13:] + } + } + if output == "" { + t.Fatalf("missing screenshot output arg") + } + return os.WriteFile(output, []byte("fake-image"), 0644) + } + + var sent bus.OutboundMessage + tool.SetSendCallback(func(msg bus.OutboundMessage) error { + sent = msg + return nil + }) + + result := tool.Execute(context.Background(), map[string]interface{}{ + "url": "https://example.com", + "caption": "cap", + }) + if result.IsError { + t.Fatalf("expected success, got error: %s", result.ForLLM) + } + if !result.Silent { + t.Fatalf("expected silent result") + } + if len(sent.Attachments) != 1 { + t.Fatalf("expected one attachment, got %d", len(sent.Attachments)) + } + if sent.Attachments[0].Type != "image" { + t.Fatalf("expected image attachment") + } +} + +func TestScreenshotTool_Execute_NoSendReturnsPath(t *testing.T) { + tmpDir := t.TempDir() + tool := NewScreenshotTool(tmpDir, true, 1024*1024) + + tool.lookPath = func(s string) (string, error) { return "/usr/bin/" + s, nil } + tool.runCommand = func(ctx context.Context, command string, args ...string) error { + output := filepath.Join(tmpDir, "tmp", "screenshots", "out.png") + for _, arg := range args { + if len(arg) > len("--screenshot=") && arg[:13] == "--screenshot=" { + output = arg[13:] + } + } + return os.WriteFile(output, []byte("fake-image"), 0644) + } + tool.now = func() time.Time { return time.Date(2026, 2, 14, 10, 0, 0, 0, time.UTC) } + + result := tool.Execute(context.Background(), map[string]interface{}{ + "url": "https://example.com", + "send": false, + }) + if result.IsError { + t.Fatalf("expected success, got %s", result.ForLLM) + } + if result.ForUser == "" { + t.Fatalf("expected user-visible path") + } +} + +func TestScreenshotTool_Execute_NoTargetWhenSend(t *testing.T) { + tmpDir := t.TempDir() + tool := NewScreenshotTool(tmpDir, true, 1024*1024) + tool.lookPath = func(s string) (string, error) { return "/usr/bin/" + s, nil } + tool.runCommand = func(ctx context.Context, command string, args ...string) error { + for _, arg := range args { + if len(arg) > len("--screenshot=") && arg[:13] == "--screenshot=" { + return os.WriteFile(arg[13:], []byte("fake-image"), 0644) + } + } + return nil + } + tool.SetSendCallback(func(msg bus.OutboundMessage) error { return nil }) + + result := tool.Execute(context.Background(), map[string]interface{}{ + "url": "https://example.com", + }) + if !result.IsError { + t.Fatalf("expected error without target context") + } +} diff --git a/pkg/tools/send_file.go b/pkg/tools/send_file.go new file mode 100644 index 000000000..2a1647f18 --- /dev/null +++ b/pkg/tools/send_file.go @@ -0,0 +1,165 @@ +package tools + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/sipeed/picoclaw/pkg/bus" +) + +type SendFileCallback func(msg bus.OutboundMessage) error + +type SendFileTool struct { + workspace string + restrict bool + maxFileBytes int64 + sendCallback SendFileCallback + defaultChannel string + defaultChatID string +} + +func NewSendFileTool(workspace string, restrict bool, maxFileBytes int64) *SendFileTool { + return &SendFileTool{ + workspace: workspace, + restrict: restrict, + maxFileBytes: maxFileBytes, + } +} + +func (t *SendFileTool) Name() string { + return "send_file" +} + +func (t *SendFileTool) Description() string { + return "Send a local file to the user on the current chat channel." +} + +func (t *SendFileTool) Parameters() map[string]interface{} { + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "path": map[string]interface{}{ + "type": "string", + "description": "Path to the local file. Relative paths are resolved from workspace.", + }, + "caption": map[string]interface{}{ + "type": "string", + "description": "Optional text to include with the file.", + }, + "attachment_type": map[string]interface{}{ + "type": "string", + "description": "Optional attachment type: image or file.", + }, + "filename": map[string]interface{}{ + "type": "string", + "description": "Optional custom filename shown to user.", + }, + "mime_type": map[string]interface{}{ + "type": "string", + "description": "Optional mime type hint, for example image/png.", + }, + "channel": map[string]interface{}{ + "type": "string", + "description": "Optional target channel override.", + }, + "chat_id": map[string]interface{}{ + "type": "string", + "description": "Optional target chat id override.", + }, + }, + "required": []string{"path"}, + } +} + +func (t *SendFileTool) SetContext(channel, chatID string) { + t.defaultChannel = channel + t.defaultChatID = chatID +} + +func (t *SendFileTool) SetSendCallback(callback SendFileCallback) { + t.sendCallback = callback +} + +func (t *SendFileTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult { + path, ok := args["path"].(string) + if !ok || strings.TrimSpace(path) == "" { + return ErrorResult("path is required") + } + + channel, _ := args["channel"].(string) + chatID, _ := args["chat_id"].(string) + if channel == "" { + channel = t.defaultChannel + } + if chatID == "" { + chatID = t.defaultChatID + } + if channel == "" || chatID == "" { + return ErrorResult("No target channel/chat specified") + } + if t.sendCallback == nil { + return ErrorResult("File sending not configured") + } + + resolvedPath, err := validatePath(path, t.workspace, t.restrict) + if err != nil { + return ErrorResult(err.Error()) + } + + info, err := os.Stat(resolvedPath) + if err != nil { + return ErrorResult(fmt.Sprintf("failed to stat file: %v", err)) + } + if info.IsDir() { + return ErrorResult("path points to a directory, expected a file") + } + if t.maxFileBytes > 0 && info.Size() > t.maxFileBytes { + return ErrorResult(fmt.Sprintf("file too large: %d bytes (limit %d bytes)", info.Size(), t.maxFileBytes)) + } + + attachmentType, _ := args["attachment_type"].(string) + if attachmentType == "" { + attachmentType = detectAttachmentType(resolvedPath) + } + if attachmentType != "image" && attachmentType != "file" { + attachmentType = "file" + } + + fileName, _ := args["filename"].(string) + if fileName == "" { + fileName = filepath.Base(resolvedPath) + } + mimeType, _ := args["mime_type"].(string) + caption, _ := args["caption"].(string) + + outbound := bus.OutboundMessage{ + Channel: channel, + ChatID: chatID, + Content: caption, + Attachments: []bus.Attachment{ + { + Type: attachmentType, + Path: resolvedPath, + FileName: fileName, + MIMEType: mimeType, + }, + }, + } + if err := t.sendCallback(outbound); err != nil { + return ErrorResult(fmt.Sprintf("sending file: %v", err)) + } + + return SilentResult(fmt.Sprintf("File sent to %s:%s (%s)", channel, chatID, fileName)) +} + +func detectAttachmentType(path string) string { + switch strings.ToLower(filepath.Ext(path)) { + case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp": + return "image" + default: + return "file" + } +} diff --git a/pkg/tools/send_file_test.go b/pkg/tools/send_file_test.go new file mode 100644 index 000000000..f98c41793 --- /dev/null +++ b/pkg/tools/send_file_test.go @@ -0,0 +1,86 @@ +package tools + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/sipeed/picoclaw/pkg/bus" +) + +func TestSendFileTool_Execute_Success(t *testing.T) { + tmpDir := t.TempDir() + filePath := filepath.Join(tmpDir, "test.png") + if err := os.WriteFile(filePath, []byte("png-data"), 0644); err != nil { + t.Fatalf("write file: %v", err) + } + + tool := NewSendFileTool(tmpDir, true, 1024*1024) + tool.SetContext("telegram", "123") + + var sent bus.OutboundMessage + tool.SetSendCallback(func(msg bus.OutboundMessage) error { + sent = msg + return nil + }) + + result := tool.Execute(context.Background(), map[string]interface{}{ + "path": "test.png", + "caption": "hello", + }) + + if result.IsError { + t.Fatalf("expected success, got error: %s", result.ForLLM) + } + if !result.Silent { + t.Fatalf("expected silent result") + } + if sent.Channel != "telegram" || sent.ChatID != "123" { + t.Fatalf("unexpected target: %s:%s", sent.Channel, sent.ChatID) + } + if len(sent.Attachments) != 1 { + t.Fatalf("expected one attachment, got %d", len(sent.Attachments)) + } + if sent.Attachments[0].Type != "image" { + t.Fatalf("expected image attachment, got %s", sent.Attachments[0].Type) + } +} + +func TestSendFileTool_Execute_NoTarget(t *testing.T) { + tmpDir := t.TempDir() + filePath := filepath.Join(tmpDir, "test.txt") + if err := os.WriteFile(filePath, []byte("hello"), 0644); err != nil { + t.Fatalf("write file: %v", err) + } + + tool := NewSendFileTool(tmpDir, true, 1024*1024) + tool.SetSendCallback(func(msg bus.OutboundMessage) error { return nil }) + + result := tool.Execute(context.Background(), map[string]interface{}{ + "path": filePath, + }) + + if !result.IsError { + t.Fatalf("expected error when no channel/chat context") + } +} + +func TestSendFileTool_Execute_TooLarge(t *testing.T) { + tmpDir := t.TempDir() + filePath := filepath.Join(tmpDir, "big.bin") + if err := os.WriteFile(filePath, make([]byte, 64), 0644); err != nil { + t.Fatalf("write file: %v", err) + } + + tool := NewSendFileTool(tmpDir, true, 32) + tool.SetContext("telegram", "123") + tool.SetSendCallback(func(msg bus.OutboundMessage) error { return nil }) + + result := tool.Execute(context.Background(), map[string]interface{}{ + "path": filePath, + }) + if !result.IsError { + t.Fatalf("expected too large error") + } +} diff --git a/pkg/utils/media.go b/pkg/utils/media.go index 6345da8fc..f7e82e598 100644 --- a/pkg/utils/media.go +++ b/pkg/utils/media.go @@ -141,3 +141,30 @@ func DownloadFileSimple(url, filename string) string { LoggerPrefix: "media", }) } + +// ScheduleFileCleanup removes a file after delay in a background goroutine. +// Use this for transient media files that must survive past the current stack frame. +func ScheduleFileCleanup(path string, delay time.Duration, loggerPrefix string) { + if path == "" { + return + } + if delay <= 0 { + delay = 10 * time.Minute + } + if loggerPrefix == "" { + loggerPrefix = "media" + } + + go func() { + timer := time.NewTimer(delay) + defer timer.Stop() + <-timer.C + + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + logger.DebugCF(loggerPrefix, "Failed to cleanup temp file", map[string]interface{}{ + "path": path, + "error": err.Error(), + }) + } + }() +}