diff --git a/README.fr.md b/README.fr.md index 08a1926b6..574402a3e 100644 --- a/README.fr.md +++ b/README.fr.md @@ -649,7 +649,6 @@ PicoClaw stocke les données dans votre workspace configuré (par défaut : `~/. ├── HEARTBEAT.md # Invites de tâches périodiques (vérifiées toutes les 30 min) ├── IDENTITY.md # Identité de l'Agent ├── SOUL.md # Âme de l'Agent -├── TOOLS.md # Description des outils └── USER.md # Préférences utilisateur ``` @@ -980,6 +979,7 @@ Cette conception permet également le **support multi-agent** avec une sélectio | **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Obtenir Clé](https://cerebras.ai) | | **Volcengine** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Obtenir Clé](https://console.volcengine.com) | | **ShengsuanYun** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - | +| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [Obtenir une clé](https://longcat.chat/platform) | | **Antigravity** | `antigravity/` | Google Cloud | Custom | OAuth uniquement | | **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - | diff --git a/README.ja.md b/README.ja.md index c4c5b27a0..1eb47cfdc 100644 --- a/README.ja.md +++ b/README.ja.md @@ -610,7 +610,6 @@ PicoClaw は設定されたワークスペース(デフォルト: `~/.picoclaw ├── HEARTBEAT.md # 定期タスクプロンプト(30分ごとに確認) ├── IDENTITY.md # エージェントのアイデンティティ ├── SOUL.md # エージェントのソウル -├── TOOLS.md # ツールの説明 └── USER.md # ユーザー設定 ``` @@ -921,6 +920,7 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る | **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [キーを取得](https://cerebras.ai) | | **Volcengine** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [キーを取得](https://console.volcengine.com) | | **ShengsuanYun** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - | +| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [キーを取得](https://longcat.chat/platform) | | **Antigravity** | `antigravity/` | Google Cloud | カスタム | OAuthのみ | | **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - | diff --git a/README.md b/README.md index bae3fa681..55e9fb187 100644 --- a/README.md +++ b/README.md @@ -787,7 +787,6 @@ PicoClaw stores data in your configured workspace (default: `~/.picoclaw/workspa ├── HEARTBEAT.md # Periodic task prompts (checked every 30 min) ├── IDENTITY.md # Agent identity ├── SOUL.md # Agent soul -├── TOOLS.md # Tool descriptions └── USER.md # User preferences ``` @@ -1034,6 +1033,7 @@ This design also enables **multi-agent support** with flexible provider selectio | **火山引擎** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Get Key](https://console.volcengine.com) | | **神算云** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - | | **Vivgrid** | `vivgrid/` | `https://api.vivgrid.com/v1` | OpenAI | [Get Key](https://vivgrid.com) | +| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [Get Key](https://longcat.chat/platform) | | **Antigravity** | `antigravity/` | Google Cloud | Custom | OAuth only | | **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - | @@ -1504,3 +1504,4 @@ This happens when another instance of the bot is running. Make sure only one `pi | **SearXNG** | Unlimited (self-hosted) | Privacy-focused metasearch (70+ engines) | | **Groq** | Free tier available | Fast inference (Llama, Mixtral) | | **Cerebras** | Free tier available | Fast inference (Llama, Qwen, etc.) | +| **LongCat** | Up to 5M tokens/day | Fast inference (free tier) | diff --git a/README.pt-br.md b/README.pt-br.md index 5f37ba457..066d71d6a 100644 --- a/README.pt-br.md +++ b/README.pt-br.md @@ -645,7 +645,6 @@ O PicoClaw armazena dados no workspace configurado (padrão: `~/.picoclaw/worksp ├── HEARTBEAT.md # Prompts de tarefas periodicas (verificado a cada 30 min) ├── IDENTITY.md # Identidade do Agente ├── SOUL.md # Alma do Agente -├── TOOLS.md # Descrição das ferramentas └── USER.md # Preferencias do usuario ``` @@ -976,6 +975,7 @@ Este design também possibilita o **suporte multi-agent** com seleção flexíve | **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Obter Chave](https://cerebras.ai) | | **Volcengine** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Obter Chave](https://console.volcengine.com) | | **ShengsuanYun** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - | +| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [Obter Chave](https://longcat.chat/platform) | | **Antigravity** | `antigravity/` | Google Cloud | Custom | Apenas OAuth | | **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - | diff --git a/README.vi.md b/README.vi.md index 92c6ecbae..66573a1c5 100644 --- a/README.vi.md +++ b/README.vi.md @@ -617,7 +617,6 @@ PicoClaw lưu trữ dữ liệu trong workspace đã cấu hình (mặc định: ├── HEARTBEAT.md # Prompt tác vụ định kỳ (kiểm tra mỗi 30 phút) ├── IDENTITY.md # Danh tính Agent ├── SOUL.md # Tâm hồn/Tính cách Agent -├── TOOLS.md # Mô tả công cụ └── USER.md # Tùy chọn người dùng ``` @@ -945,6 +944,7 @@ Thiết kế này cũng cho phép **hỗ trợ đa tác nhân** với lựa ch | **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Lấy Khóa](https://cerebras.ai) | | **Volcengine** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Lấy Khóa](https://console.volcengine.com) | | **ShengsuanYun** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - | +| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [Lấy Key](https://longcat.chat/platform) | | **Antigravity** | `antigravity/` | Google Cloud | Tùy chỉnh | Chỉ OAuth | | **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - | diff --git a/README.zh.md b/README.zh.md index c744e0d20..a3a4c7f5f 100644 --- a/README.zh.md +++ b/README.zh.md @@ -365,7 +365,6 @@ PicoClaw 将数据存储在您配置的工作区中(默认:`~/.picoclaw/work ├── HEARTBEAT.md # 周期性任务提示词 (每 30 分钟检查一次) ├── IDENTITY.md # Agent 身份设定 ├── SOUL.md # Agent 灵魂/性格 -├── TOOLS.md # 工具描述 └── USER.md # 用户偏好 ``` @@ -517,6 +516,7 @@ Agent 读取 HEARTBEAT.md | **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [获取密钥](https://cerebras.ai) | | **火山引擎** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [获取密钥](https://console.volcengine.com) | | **神算云** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - | +| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [获取密钥](https://longcat.chat/platform) | | **Antigravity** | `antigravity/` | Google Cloud | 自定义 | 仅 OAuth | | **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - | @@ -879,3 +879,4 @@ Discord: [https://discord.gg/V4sAZ9XWpN](https://discord.gg/V4sAZ9XWpN) | **Brave Search** | 2000 次查询/月 | 网络搜索功能 | | **Tavily** | 1000 次查询/月 | AI Agent 搜索优化 | | **Groq** | 提供免费层级 | 极速推理 (Llama, Mixtral) | +| **LongCat** | 最多 5M tokens/天 | 推理速度快 (免费额度) | diff --git a/assets/wechat.png b/assets/wechat.png index 4442ef2c7..4cfcbbb1a 100644 Binary files a/assets/wechat.png and b/assets/wechat.png differ diff --git a/config/config.example.json b/config/config.example.json index 3754c6814..1eea37683 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -35,6 +35,11 @@ "model": "deepseek/deepseek-chat", "api_key": "sk-your-deepseek-key" }, + { + "model_name": "longcat", + "model": "longcat/LongCat-Flash-Thinking", + "api_key": "your-longcat-api-key" + }, { "model_name": "loadbalanced-gpt4", "model": "openai/gpt-5.2", @@ -274,6 +279,10 @@ "avian": { "api_key": "", "api_base": "https://api.avian.io/v1" + }, + "longcat": { + "api_key": "", + "api_base": "https://api.longcat.chat/openai" } }, "tools": { diff --git a/docs/channels/matrix/README.md b/docs/channels/matrix/README.md index c213aa80b..233f5c0a3 100644 --- a/docs/channels/matrix/README.md +++ b/docs/channels/matrix/README.md @@ -22,7 +22,8 @@ Add this to `config.json`: "enabled": true, "text": "Thinking..." }, - "reasoning_channel_id": "" + "reasoning_channel_id": "", + "message_format": "richtext" } } } @@ -42,10 +43,12 @@ Add this to `config.json`: | group_trigger | object | No | Group trigger strategy (`mention_only` / `prefixes`) | | placeholder | object | No | Placeholder message config | | reasoning_channel_id | string | No | Target channel for reasoning output | +| message_format | string | No | Output format: `"richtext"` (default) renders markdown as HTML; `"plain"` sends plain text only | ## 3. Currently Supported -- Text message send/receive +- Text message send/receive with markdown rendering (bold, italic, headers, code blocks, etc.) +- Configurable message format (`richtext` / `plain`) - Incoming image/audio/video/file download (MediaStore first, local path fallback) - Incoming audio normalization into existing transcription flow (`[audio: ...]`) - Outgoing image/audio/video/file upload and send diff --git a/go.mod b/go.mod index 098843666..3762015e9 100644 --- a/go.mod +++ b/go.mod @@ -21,6 +21,7 @@ require ( github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1 github.com/openai/openai-go/v3 v3.22.0 github.com/rivo/tview v0.42.0 + github.com/rs/zerolog v1.34.0 github.com/slack-go/slack v0.17.3 github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.11.1 @@ -50,7 +51,6 @@ require ( github.com/pmezard/go-difflib v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect - github.com/rs/zerolog v1.34.0 // indirect github.com/segmentio/asm v1.1.3 // indirect github.com/segmentio/encoding v0.5.3 // indirect github.com/spf13/pflag v1.0.10 // indirect diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index bee8d91a7..28e549ce0 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -25,7 +25,6 @@ import ( "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/constants" "github.com/sipeed/picoclaw/pkg/logger" - "github.com/sipeed/picoclaw/pkg/mcp" "github.com/sipeed/picoclaw/pkg/media" "github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/routing" @@ -48,6 +47,7 @@ type AgentLoop struct { mediaStore media.MediaStore transcriber voice.Transcriber cmdRegistry *commands.Registry + mcp mcpRuntime } // processOptions configures how a message is processed @@ -239,119 +239,8 @@ func registerSharedTools( func (al *AgentLoop) Run(ctx context.Context) error { al.running.Store(true) - - // Initialize MCP servers for all agents - if al.cfg.Tools.IsToolEnabled("mcp") { - mcpManager := mcp.NewManager() - // Ensure MCP connections are cleaned up on exit, regardless of initialization success - // This fixes resource leak when LoadFromMCPConfig partially succeeds then fails - defer func() { - if err := mcpManager.Close(); err != nil { - logger.ErrorCF("agent", "Failed to close MCP manager", - map[string]any{ - "error": err.Error(), - }) - } - }() - - defaultAgent := al.registry.GetDefaultAgent() - var workspacePath string - if defaultAgent != nil && defaultAgent.Workspace != "" { - workspacePath = defaultAgent.Workspace - } else { - workspacePath = al.cfg.WorkspacePath() - } - - if err := mcpManager.LoadFromMCPConfig(ctx, al.cfg.Tools.MCP, workspacePath); err != nil { - logger.WarnCF("agent", "Failed to load MCP servers, MCP tools will not be available", - map[string]any{ - "error": err.Error(), - }) - } else { - // Register MCP tools for all agents - servers := mcpManager.GetServers() - uniqueTools := 0 - totalRegistrations := 0 - agentIDs := al.registry.ListAgentIDs() - agentCount := len(agentIDs) - - for serverName, conn := range servers { - uniqueTools += len(conn.Tools) - for _, tool := range conn.Tools { - for _, agentID := range agentIDs { - agent, ok := al.registry.GetAgent(agentID) - if !ok { - continue - } - - mcpTool := tools.NewMCPTool(mcpManager, serverName, tool) - - if al.cfg.Tools.MCP.Discovery.Enabled { - agent.Tools.RegisterHidden(mcpTool) - } else { - agent.Tools.Register(mcpTool) - } - - totalRegistrations++ - logger.DebugCF("agent", "Registered MCP tool", - map[string]any{ - "agent_id": agentID, - "server": serverName, - "tool": tool.Name, - "name": mcpTool.Name(), - }) - } - } - } - logger.InfoCF("agent", "MCP tools registered successfully", - map[string]any{ - "server_count": len(servers), - "unique_tools": uniqueTools, - "total_registrations": totalRegistrations, - "agent_count": agentCount, - }) - - // Initializes Discovery Tools only if enabled by configuration - if al.cfg.Tools.MCP.Enabled && al.cfg.Tools.MCP.Discovery.Enabled { - useBM25 := al.cfg.Tools.MCP.Discovery.UseBM25 - useRegex := al.cfg.Tools.MCP.Discovery.UseRegex - - // Fail fast: If discovery is enabled but no search method is turned on - if !useBM25 && !useRegex { - return fmt.Errorf( - "tool discovery is enabled but neither 'use_bm25' nor 'use_regex' is set to true in the configuration", - ) - } - - ttl := al.cfg.Tools.MCP.Discovery.TTL - if ttl <= 0 { - ttl = 5 // Default value - } - - maxSearchResults := al.cfg.Tools.MCP.Discovery.MaxSearchResults - if maxSearchResults <= 0 { - maxSearchResults = 5 // Default value - } - - logger.InfoCF("agent", "Initializing tool discovery", map[string]any{ - "bm25": useBM25, "regex": useRegex, "ttl": ttl, "max_results": maxSearchResults, - }) - - for _, agentID := range agentIDs { - agent, ok := al.registry.GetAgent(agentID) - if !ok { - continue - } - - if useRegex { - agent.Tools.Register(tools.NewRegexSearchTool(agent.Tools, ttl, maxSearchResults)) - } - if useBM25 { - agent.Tools.Register(tools.NewBM25SearchTool(agent.Tools, ttl, maxSearchResults)) - } - } - } - } + if err := al.ensureMCPInitialized(ctx); err != nil { + return err } for al.running.Load() { @@ -431,6 +320,17 @@ func (al *AgentLoop) Stop() { // Close releases resources held by agent session stores. Call after Stop. func (al *AgentLoop) Close() { + mcpManager := al.mcp.takeManager() + + if mcpManager != nil { + if err := mcpManager.Close(); err != nil { + logger.ErrorCF("agent", "Failed to close MCP manager", + map[string]any{ + "error": err.Error(), + }) + } + } + al.registry.Close() } @@ -619,6 +519,10 @@ func (al *AgentLoop) ProcessDirectWithChannel( ctx context.Context, content, sessionKey, channel, chatID string, ) (string, error) { + if err := al.ensureMCPInitialized(ctx); err != nil { + return "", err + } + msg := bus.InboundMessage{ Channel: channel, SenderID: "cron", diff --git a/pkg/agent/loop_mcp.go b/pkg/agent/loop_mcp.go new file mode 100644 index 000000000..2795db52a --- /dev/null +++ b/pkg/agent/loop_mcp.go @@ -0,0 +1,184 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// Inspired by and based on nanobot: https://github.com/HKUDS/nanobot +// License: MIT +// +// Copyright (c) 2026 PicoClaw contributors + +package agent + +import ( + "context" + "fmt" + "sync" + + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/mcp" + "github.com/sipeed/picoclaw/pkg/tools" +) + +type mcpRuntime struct { + initOnce sync.Once + mu sync.Mutex + manager *mcp.Manager + initErr error +} + +func (r *mcpRuntime) setManager(manager *mcp.Manager) { + r.mu.Lock() + r.manager = manager + r.initErr = nil + r.mu.Unlock() +} + +func (r *mcpRuntime) setInitErr(err error) { + r.mu.Lock() + r.initErr = err + r.mu.Unlock() +} + +func (r *mcpRuntime) getInitErr() error { + r.mu.Lock() + defer r.mu.Unlock() + return r.initErr +} + +func (r *mcpRuntime) takeManager() *mcp.Manager { + r.mu.Lock() + defer r.mu.Unlock() + manager := r.manager + r.manager = nil + return manager +} + +func (r *mcpRuntime) hasManager() bool { + r.mu.Lock() + defer r.mu.Unlock() + return r.manager != nil +} + +// ensureMCPInitialized loads MCP servers/tools once so both Run() and direct +// agent mode share the same initialization path. +func (al *AgentLoop) ensureMCPInitialized(ctx context.Context) error { + if !al.cfg.Tools.IsToolEnabled("mcp") { + return nil + } + + al.mcp.initOnce.Do(func() { + mcpManager := mcp.NewManager() + + defaultAgent := al.registry.GetDefaultAgent() + workspacePath := al.cfg.WorkspacePath() + if defaultAgent != nil && defaultAgent.Workspace != "" { + workspacePath = defaultAgent.Workspace + } + + if err := mcpManager.LoadFromMCPConfig(ctx, al.cfg.Tools.MCP, workspacePath); err != nil { + logger.WarnCF("agent", "Failed to load MCP servers, MCP tools will not be available", + map[string]any{ + "error": err.Error(), + }) + if closeErr := mcpManager.Close(); closeErr != nil { + logger.ErrorCF("agent", "Failed to close MCP manager", + map[string]any{ + "error": closeErr.Error(), + }) + } + return + } + + // Register MCP tools for all agents + servers := mcpManager.GetServers() + uniqueTools := 0 + totalRegistrations := 0 + agentIDs := al.registry.ListAgentIDs() + agentCount := len(agentIDs) + + for serverName, conn := range servers { + uniqueTools += len(conn.Tools) + for _, tool := range conn.Tools { + for _, agentID := range agentIDs { + agent, ok := al.registry.GetAgent(agentID) + if !ok { + continue + } + + mcpTool := tools.NewMCPTool(mcpManager, serverName, tool) + + if al.cfg.Tools.MCP.Discovery.Enabled { + agent.Tools.RegisterHidden(mcpTool) + } else { + agent.Tools.Register(mcpTool) + } + + totalRegistrations++ + logger.DebugCF("agent", "Registered MCP tool", + map[string]any{ + "agent_id": agentID, + "server": serverName, + "tool": tool.Name, + "name": mcpTool.Name(), + }) + } + } + } + logger.InfoCF("agent", "MCP tools registered successfully", + map[string]any{ + "server_count": len(servers), + "unique_tools": uniqueTools, + "total_registrations": totalRegistrations, + "agent_count": agentCount, + }) + + // Initializes Discovery Tools only if enabled by configuration + if al.cfg.Tools.MCP.Enabled && al.cfg.Tools.MCP.Discovery.Enabled { + useBM25 := al.cfg.Tools.MCP.Discovery.UseBM25 + useRegex := al.cfg.Tools.MCP.Discovery.UseRegex + + // Fail fast: If discovery is enabled but no search method is turned on + if !useBM25 && !useRegex { + al.mcp.setInitErr(fmt.Errorf( + "tool discovery is enabled but neither 'use_bm25' nor 'use_regex' is set to true in the configuration", + )) + if closeErr := mcpManager.Close(); closeErr != nil { + logger.ErrorCF("agent", "Failed to close MCP manager", + map[string]any{ + "error": closeErr.Error(), + }) + } + return + } + + ttl := al.cfg.Tools.MCP.Discovery.TTL + if ttl <= 0 { + ttl = 5 // Default value + } + + maxSearchResults := al.cfg.Tools.MCP.Discovery.MaxSearchResults + if maxSearchResults <= 0 { + maxSearchResults = 5 // Default value + } + + logger.InfoCF("agent", "Initializing tool discovery", map[string]any{ + "bm25": useBM25, "regex": useRegex, "ttl": ttl, "max_results": maxSearchResults, + }) + + for _, agentID := range agentIDs { + agent, ok := al.registry.GetAgent(agentID) + if !ok { + continue + } + + if useRegex { + agent.Tools.Register(tools.NewRegexSearchTool(agent.Tools, ttl, maxSearchResults)) + } + if useBM25 { + agent.Tools.Register(tools.NewBM25SearchTool(agent.Tools, ttl, maxSearchResults)) + } + } + } + + al.mcp.setManager(mcpManager) + }) + + return al.mcp.getInitErr() +} diff --git a/pkg/agent/loop_test.go b/pkg/agent/loop_test.go index 2e456fa60..cab82e176 100644 --- a/pkg/agent/loop_test.go +++ b/pkg/agent/loop_test.go @@ -770,6 +770,56 @@ func TestAgentLoop_ContextExhaustionRetry(t *testing.T) { } } +func TestProcessDirectWithChannel_InitializesMCPInAgentMode(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + Model: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + Tools: config.ToolsConfig{ + MCP: config.MCPConfig{ + ToolConfig: config.ToolConfig{ + Enabled: true, + }, + }, + }, + } + + msgBus := bus.NewMessageBus() + provider := &mockProvider{} + al := NewAgentLoop(cfg, msgBus, provider) + defer al.Close() + + if al.mcp.hasManager() { + t.Fatal("expected MCP manager to be nil before first direct processing") + } + + _, err = al.ProcessDirectWithChannel( + context.Background(), + "hello", + "session-1", + "cli", + "direct", + ) + if err != nil { + t.Fatalf("ProcessDirectWithChannel failed: %v", err) + } + + if !al.mcp.hasManager() { + t.Fatal("expected MCP manager to be initialized in direct agent mode") + } +} + func TestTargetReasoningChannelID_AllChannels(t *testing.T) { tmpDir, err := os.MkdirTemp("", "agent-test-*") if err != nil { diff --git a/pkg/channels/dingtalk/dingtalk.go b/pkg/channels/dingtalk/dingtalk.go index 8642ad362..c03122892 100644 --- a/pkg/channels/dingtalk/dingtalk.go +++ b/pkg/channels/dingtalk/dingtalk.go @@ -10,6 +10,7 @@ import ( "github.com/open-dingtalk/dingtalk-stream-sdk-go/chatbot" "github.com/open-dingtalk/dingtalk-stream-sdk-go/client" + dinglog "github.com/open-dingtalk/dingtalk-stream-sdk-go/logger" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" @@ -39,6 +40,9 @@ func NewDingTalkChannel(cfg config.DingTalkConfig, messageBus *bus.MessageBus) ( return nil, fmt.Errorf("dingtalk client_id and client_secret are required") } + // Set the logger for the Stream SDK + dinglog.SetLogger(logger.NewLogger("dingtalk")) + base := channels.NewBaseChannel("dingtalk", cfg, messageBus, cfg.AllowFrom, channels.WithMaxMessageLength(20000), channels.WithGroupTrigger(cfg.GroupTrigger), diff --git a/pkg/channels/discord/discord.go b/pkg/channels/discord/discord.go index fbfcad151..83a04907c 100644 --- a/pkg/channels/discord/discord.go +++ b/pkg/channels/discord/discord.go @@ -45,6 +45,14 @@ type DiscordChannel struct { } func NewDiscordChannel(cfg config.DiscordConfig, bus *bus.MessageBus) (*DiscordChannel, error) { + discordgo.Logger = logger.NewLogger("discord"). + WithLevels(map[int]logger.LogLevel{ + discordgo.LogError: logger.ERROR, + discordgo.LogWarning: logger.WARN, + discordgo.LogInformational: logger.INFO, + discordgo.LogDebug: logger.DEBUG, + }).Log + session, err := discordgo.New("Bot " + cfg.Token) if err != nil { return nil, fmt.Errorf("failed to create discord session: %w", err) diff --git a/pkg/channels/matrix/matrix.go b/pkg/channels/matrix/matrix.go index d51eee8fb..a45207f12 100644 --- a/pkg/channels/matrix/matrix.go +++ b/pkg/channels/matrix/matrix.go @@ -13,6 +13,9 @@ import ( "sync" "time" + "github.com/gomarkdown/markdown" + mdhtml "github.com/gomarkdown/markdown/html" + "github.com/gomarkdown/markdown/parser" "maunium.net/go/mautrix" "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" @@ -268,6 +271,12 @@ func (c *MatrixChannel) Stop(ctx context.Context) error { return nil } +func markdownToHTML(md string) string { + p := parser.NewWithExtensions(parser.CommonExtensions | parser.AutoHeadingIDs) + renderer := mdhtml.NewRenderer(mdhtml.RendererOptions{Flags: mdhtml.CommonFlags}) + return strings.TrimSpace(string(markdown.ToHTML([]byte(md), p, renderer))) +} + func (c *MatrixChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { if !c.IsRunning() { return channels.ErrNotRunning @@ -283,16 +292,22 @@ func (c *MatrixChannel) Send(ctx context.Context, msg bus.OutboundMessage) error return nil } - _, err := c.client.SendMessageEvent(ctx, roomID, event.EventMessage, &event.MessageEventContent{ - MsgType: event.MsgText, - Body: content, - }) + _, err := c.client.SendMessageEvent(ctx, roomID, event.EventMessage, c.messageContent(content)) if err != nil { return fmt.Errorf("matrix send: %w", channels.ErrTemporary) } return nil } +func (c *MatrixChannel) messageContent(text string) *event.MessageEventContent { + mc := &event.MessageEventContent{MsgType: event.MsgText, Body: text} + if c.config.MessageFormat != "plain" { + mc.Format = event.FormatHTML + mc.FormattedBody = markdownToHTML(text) + } + return mc +} + // SendMedia implements channels.MediaSender. func (c *MatrixChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { if !c.IsRunning() { @@ -482,10 +497,7 @@ func (c *MatrixChannel) EditMessage(ctx context.Context, chatID string, messageI return fmt.Errorf("matrix message ID is empty") } - editContent := &event.MessageEventContent{ - MsgType: event.MsgText, - Body: content, - } + editContent := c.messageContent(content) editContent.SetEdit(id.EventID(messageID)) _, err := c.client.SendMessageEvent(ctx, roomID, event.EventMessage, editContent) diff --git a/pkg/channels/matrix/matrix_test.go b/pkg/channels/matrix/matrix_test.go index e76db0d3e..806a98739 100644 --- a/pkg/channels/matrix/matrix_test.go +++ b/pkg/channels/matrix/matrix_test.go @@ -4,12 +4,15 @@ import ( "context" "os" "path/filepath" + "strings" "testing" "time" "maunium.net/go/mautrix" "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" + + "github.com/sipeed/picoclaw/pkg/config" ) func TestMatrixLocalpartMentionRegexp(t *testing.T) { @@ -289,3 +292,50 @@ func TestMatrixOutboundContent(t *testing.T) { t.Fatalf("unexpected fallback body: %q", noCaption.Body) } } + +func TestMarkdownToHTML(t *testing.T) { + tests := []struct { + name string + input string + contains string + }{ + {"bold", "**hello**", "hello"}, + {"italic", "_world_", "world"}, + {"header", "### Title", "
x"},
+ {"plain text", "just text", "just text"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := markdownToHTML(tt.input)
+ if !strings.Contains(got, tt.contains) {
+ t.Fatalf("markdownToHTML(%q) = %q, want it to contain %q", tt.input, got, tt.contains)
+ }
+ })
+ }
+}
+
+func TestMessageContent(t *testing.T) {
+ richtext := &MatrixChannel{config: config.MatrixConfig{MessageFormat: "richtext"}}
+ plain := &MatrixChannel{config: config.MatrixConfig{MessageFormat: "plain"}}
+ defaultt := &MatrixChannel{config: config.MatrixConfig{}}
+
+ for _, c := range []*MatrixChannel{richtext, defaultt} {
+ mc := c.messageContent("**hi**")
+ if mc.Format != event.FormatHTML {
+ t.Errorf("format %q: expected FormatHTML, got %q", c.config.MessageFormat, mc.Format)
+ }
+ if !strings.Contains(mc.FormattedBody, "hi") {
+ t.Errorf("format %q: FormattedBody %q missing ", c.config.MessageFormat, mc.FormattedBody)
+ }
+ if mc.Body != "**hi**" {
+ t.Errorf("format %q: Body should remain plain, got %q", c.config.MessageFormat, mc.Body)
+ }
+ }
+
+ mc := plain.messageContent("**hi**")
+ if mc.Format != "" || mc.FormattedBody != "" {
+ t.Errorf("plain: expected no formatting, got format=%q formattedBody=%q", mc.Format, mc.FormattedBody)
+ }
+}
diff --git a/pkg/channels/qq/qq.go b/pkg/channels/qq/qq.go
index 540e3b7af..73200f64e 100644
--- a/pkg/channels/qq/qq.go
+++ b/pkg/channels/qq/qq.go
@@ -78,6 +78,7 @@ func (c *QQChannel) Start(ctx context.Context) error {
return fmt.Errorf("QQ app_id and app_secret not configured")
}
+ botgo.SetLogger(logger.NewLogger("botgo"))
logger.InfoC("qq", "Starting QQ bot (WebSocket mode)")
// Reinitialize shutdown signal for clean restart.
diff --git a/pkg/channels/telegram/telegram.go b/pkg/channels/telegram/telegram.go
index 4a8d34a9f..34ee46b7b 100644
--- a/pkg/channels/telegram/telegram.go
+++ b/pkg/channels/telegram/telegram.go
@@ -77,6 +77,7 @@ func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChann
if baseURL := strings.TrimRight(strings.TrimSpace(telegramCfg.BaseURL), "/"); baseURL != "" {
opts = append(opts, telego.WithAPIServer(baseURL))
}
+ opts = append(opts, telego.WithLogger(logger.NewLogger("telego")))
bot, err := telego.NewBot(telegramCfg.Token, opts...)
if err != nil {
diff --git a/pkg/config/config.go b/pkg/config/config.go
index e5a028850..c7ddf80c0 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -17,6 +17,8 @@ var rrCounter atomic.Uint64
// FlexibleStringSlice is a []string that also accepts JSON numbers,
// so allow_from can contain both "123" and 123.
+// It also supports parsing comma-separated strings from environment variables,
+// including both English (,) and Chinese (,) commas.
type FlexibleStringSlice []string
func (f *FlexibleStringSlice) UnmarshalJSON(data []byte) error {
@@ -48,6 +50,30 @@ func (f *FlexibleStringSlice) UnmarshalJSON(data []byte) error {
return nil
}
+// UnmarshalText implements encoding.TextUnmarshaler to support env variable parsing.
+// It handles comma-separated values with both English (,) and Chinese (,) commas.
+func (f *FlexibleStringSlice) UnmarshalText(text []byte) error {
+ if len(text) == 0 {
+ *f = nil
+ return nil
+ }
+
+ s := string(text)
+ // Replace Chinese comma with English comma, then split
+ s = strings.ReplaceAll(s, ",", ",")
+ parts := strings.Split(s, ",")
+
+ result := make([]string, 0, len(parts))
+ for _, part := range parts {
+ part = strings.TrimSpace(part)
+ if part != "" {
+ result = append(result, part)
+ }
+ }
+ *f = result
+ return nil
+}
+
type Config struct {
Agents AgentsConfig `json:"agents"`
Bindings []AgentBinding `json:"bindings,omitempty"`
@@ -351,16 +377,17 @@ type SlackConfig struct {
}
type MatrixConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_MATRIX_ENABLED"`
- Homeserver string `json:"homeserver" env:"PICOCLAW_CHANNELS_MATRIX_HOMESERVER"`
- UserID string `json:"user_id" env:"PICOCLAW_CHANNELS_MATRIX_USER_ID"`
- AccessToken string `json:"access_token" env:"PICOCLAW_CHANNELS_MATRIX_ACCESS_TOKEN"`
- DeviceID string `json:"device_id,omitempty" env:"PICOCLAW_CHANNELS_MATRIX_DEVICE_ID"`
- JoinOnInvite bool `json:"join_on_invite" env:"PICOCLAW_CHANNELS_MATRIX_JOIN_ON_INVITE"`
- AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_MATRIX_ALLOW_FROM"`
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_MATRIX_ENABLED"`
+ Homeserver string `json:"homeserver" env:"PICOCLAW_CHANNELS_MATRIX_HOMESERVER"`
+ UserID string `json:"user_id" env:"PICOCLAW_CHANNELS_MATRIX_USER_ID"`
+ AccessToken string `json:"access_token" env:"PICOCLAW_CHANNELS_MATRIX_ACCESS_TOKEN"`
+ DeviceID string `json:"device_id,omitempty" env:"PICOCLAW_CHANNELS_MATRIX_DEVICE_ID"`
+ JoinOnInvite bool `json:"join_on_invite" env:"PICOCLAW_CHANNELS_MATRIX_JOIN_ON_INVITE"`
+ MessageFormat string `json:"message_format,omitempty" env:"PICOCLAW_CHANNELS_MATRIX_MESSAGE_FORMAT"`
+ AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_MATRIX_ALLOW_FROM"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
- ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_MATRIX_REASONING_CHANNEL_ID"`
+ ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_MATRIX_REASONING_CHANNEL_ID"`
}
type LINEConfig struct {
@@ -527,6 +554,7 @@ type ProvidersConfig struct {
Mistral ProviderConfig `json:"mistral"`
Avian ProviderConfig `json:"avian"`
Minimax ProviderConfig `json:"minimax"`
+ LongCat ProviderConfig `json:"longcat"`
}
// IsEmpty checks if all provider configs are empty (no API keys or API bases set)
@@ -553,7 +581,8 @@ func (p ProvidersConfig) IsEmpty() bool {
p.Qwen.APIKey == "" && p.Qwen.APIBase == "" &&
p.Mistral.APIKey == "" && p.Mistral.APIBase == "" &&
p.Avian.APIKey == "" && p.Avian.APIBase == "" &&
- p.Minimax.APIKey == "" && p.Minimax.APIBase == ""
+ p.Minimax.APIKey == "" && p.Minimax.APIBase == "" &&
+ p.LongCat.APIKey == "" && p.LongCat.APIBase == ""
}
// MarshalJSON implements custom JSON marshaling for ProvidersConfig
diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go
index 7ec0e90ba..ad89d6d2e 100644
--- a/pkg/config/config_test.go
+++ b/pkg/config/config_test.go
@@ -505,3 +505,119 @@ func TestDefaultConfig_WorkspacePath_WithPicoclawHome(t *testing.T) {
t.Errorf("Workspace path with PICOCLAW_HOME = %q, want %q", cfg.Agents.Defaults.Workspace, want)
}
}
+
+// TestFlexibleStringSlice_UnmarshalText tests UnmarshalText with various comma separators
+func TestFlexibleStringSlice_UnmarshalText(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ expected []string
+ }{
+ {
+ name: "English commas only",
+ input: "123,456,789",
+ expected: []string{"123", "456", "789"},
+ },
+ {
+ name: "Chinese commas only",
+ input: "123,456,789",
+ expected: []string{"123", "456", "789"},
+ },
+ {
+ name: "Mixed English and Chinese commas",
+ input: "123,456,789",
+ expected: []string{"123", "456", "789"},
+ },
+ {
+ name: "Single value",
+ input: "123",
+ expected: []string{"123"},
+ },
+ {
+ name: "Values with whitespace",
+ input: " 123 , 456 , 789 ",
+ expected: []string{"123", "456", "789"},
+ },
+ {
+ name: "Empty string",
+ input: "",
+ expected: nil,
+ },
+ {
+ name: "Only commas - English",
+ input: ",,",
+ expected: []string{},
+ },
+ {
+ name: "Only commas - Chinese",
+ input: ",,",
+ expected: []string{},
+ },
+ {
+ name: "Mixed commas with empty parts",
+ input: "123,,456,,789",
+ expected: []string{"123", "456", "789"},
+ },
+ {
+ name: "Complex mixed values",
+ input: "user1@example.com,user2@test.com, admin@domain.org",
+ expected: []string{"user1@example.com", "user2@test.com", "admin@domain.org"},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ var f FlexibleStringSlice
+ err := f.UnmarshalText([]byte(tt.input))
+ if err != nil {
+ t.Fatalf("UnmarshalText(%q) error = %v", tt.input, err)
+ }
+
+ if tt.expected == nil {
+ if f != nil {
+ t.Errorf("UnmarshalText(%q) = %v, want nil", tt.input, f)
+ }
+ return
+ }
+
+ if len(f) != len(tt.expected) {
+ t.Errorf("UnmarshalText(%q) length = %d, want %d", tt.input, len(f), len(tt.expected))
+ return
+ }
+
+ for i, v := range tt.expected {
+ if f[i] != v {
+ t.Errorf("UnmarshalText(%q)[%d] = %q, want %q", tt.input, i, f[i], v)
+ }
+ }
+ })
+ }
+}
+
+// TestFlexibleStringSlice_UnmarshalText_EmptySliceConsistency tests nil vs empty slice behavior
+func TestFlexibleStringSlice_UnmarshalText_EmptySliceConsistency(t *testing.T) {
+ t.Run("Empty string returns nil", func(t *testing.T) {
+ var f FlexibleStringSlice
+ err := f.UnmarshalText([]byte(""))
+ if err != nil {
+ t.Fatalf("UnmarshalText error = %v", err)
+ }
+ if f != nil {
+ t.Errorf("Empty string should return nil, got %v", f)
+ }
+ })
+
+ t.Run("Commas only returns empty slice", func(t *testing.T) {
+ var f FlexibleStringSlice
+ err := f.UnmarshalText([]byte(",,,"))
+ if err != nil {
+ t.Fatalf("UnmarshalText error = %v", err)
+ }
+ if f == nil {
+ t.Error("Commas only should return empty slice, not nil")
+ }
+ if len(f) != 0 {
+ t.Errorf("Expected empty slice, got %v", f)
+ }
+ })
+}
diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go
index 50f9d58ac..492b22e3a 100644
--- a/pkg/config/defaults.go
+++ b/pkg/config/defaults.go
@@ -355,6 +355,14 @@ func DefaultConfig() *Config {
APIKey: "",
},
+ // LongCat - https://longcat.chat/platform
+ {
+ ModelName: "LongCat-Flash-Thinking",
+ Model: "longcat/LongCat-Flash-Thinking",
+ APIBase: "https://api.longcat.chat/openai",
+ APIKey: "",
+ },
+
// VLLM (local) - http://localhost:8000
{
ModelName: "local-model",
diff --git a/pkg/config/migration.go b/pkg/config/migration.go
index 51f21e4f4..8e693506b 100644
--- a/pkg/config/migration.go
+++ b/pkg/config/migration.go
@@ -407,6 +407,23 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
}, true
},
},
+ {
+ providerNames: []string{"longcat"},
+ protocol: "longcat",
+ buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
+ if p.LongCat.APIKey == "" && p.LongCat.APIBase == "" {
+ return ModelConfig{}, false
+ }
+ return ModelConfig{
+ ModelName: "longcat",
+ Model: "longcat/LongCat-Flash-Thinking",
+ APIKey: p.LongCat.APIKey,
+ APIBase: p.LongCat.APIBase,
+ Proxy: p.LongCat.Proxy,
+ RequestTimeout: p.LongCat.RequestTimeout,
+ }, true
+ },
+ },
}
// Process each provider migration
diff --git a/pkg/config/migration_test.go b/pkg/config/migration_test.go
index d3019aab0..807d93e49 100644
--- a/pkg/config/migration_test.go
+++ b/pkg/config/migration_test.go
@@ -162,14 +162,15 @@ func TestConvertProvidersToModelList_AllProviders(t *testing.T) {
Qwen: ProviderConfig{APIKey: "key17"},
Mistral: ProviderConfig{APIKey: "key18"},
Avian: ProviderConfig{APIKey: "key19"},
+ LongCat: ProviderConfig{APIKey: "key-longcat"},
},
}
result := ConvertProvidersToModelList(cfg)
- // All 21 providers should be converted
- if len(result) != 21 {
- t.Errorf("len(result) = %d, want 21", len(result))
+ // All 22 providers should be converted
+ if len(result) != 22 {
+ t.Errorf("len(result) = %d, want 22", len(result))
}
}
diff --git a/pkg/logger/logger.go b/pkg/logger/logger.go
index 56dc87a53..80adcf86c 100644
--- a/pkg/logger/logger.go
+++ b/pkg/logger/logger.go
@@ -1,24 +1,24 @@
package logger
import (
- "encoding/json"
"fmt"
- "log"
"os"
+ "path/filepath"
"runtime"
"strings"
"sync"
- "time"
+
+ "github.com/rs/zerolog"
)
-type LogLevel int
+type LogLevel = zerolog.Level
const (
- DEBUG LogLevel = iota
- INFO
- WARN
- ERROR
- FATAL
+ DEBUG = zerolog.DebugLevel
+ INFO = zerolog.InfoLevel
+ WARN = zerolog.WarnLevel
+ ERROR = zerolog.ErrorLevel
+ FATAL = zerolog.FatalLevel
)
var (
@@ -31,27 +31,24 @@ var (
}
currentLevel = INFO
- logger *Logger
+ logger zerolog.Logger
+ fileLogger zerolog.Logger
+ logFile *os.File
once sync.Once
mu sync.RWMutex
)
-type Logger struct {
- file *os.File
-}
-
-type LogEntry struct {
- Level string `json:"level"`
- Timestamp string `json:"timestamp"`
- Component string `json:"component,omitempty"`
- Message string `json:"message"`
- Fields map[string]any `json:"fields,omitempty"`
- Caller string `json:"caller,omitempty"`
-}
-
func init() {
once.Do(func() {
- logger = &Logger{}
+ zerolog.SetGlobalLevel(zerolog.InfoLevel)
+
+ consoleWriter := zerolog.ConsoleWriter{
+ Out: os.Stdout,
+ TimeFormat: "15:04:05", // TODO: make it configurable???
+ }
+
+ logger = zerolog.New(consoleWriter).With().Timestamp().Logger()
+ fileLogger = zerolog.Logger{}
})
}
@@ -59,6 +56,7 @@ func SetLevel(level LogLevel) {
mu.Lock()
defer mu.Unlock()
currentLevel = level
+ zerolog.SetGlobalLevel(level)
}
func GetLevel() LogLevel {
@@ -71,17 +69,22 @@ func EnableFileLogging(filePath string) error {
mu.Lock()
defer mu.Unlock()
- file, err := os.OpenFile(filePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
+ if err := os.MkdirAll(filepath.Dir(filePath), 0o755); err != nil {
+ return fmt.Errorf("failed to create log directory: %w", err)
+ }
+
+ newFile, err := os.OpenFile(filePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
return fmt.Errorf("failed to open log file: %w", err)
}
- if logger.file != nil {
- logger.file.Close()
+ // Close old file if exists
+ if logFile != nil {
+ logFile.Close()
}
- logger.file = file
- log.Println("File logging enabled:", filePath)
+ logFile = newFile
+ fileLogger = zerolog.New(logFile).With().Timestamp().Caller().Logger()
return nil
}
@@ -89,10 +92,57 @@ func DisableFileLogging() {
mu.Lock()
defer mu.Unlock()
- if logger.file != nil {
- logger.file.Close()
- logger.file = nil
- log.Println("File logging disabled")
+ if logFile != nil {
+ logFile.Close()
+ logFile = nil
+ }
+ fileLogger = zerolog.Logger{}
+}
+
+func getCallerInfo() (string, int, string) {
+ for i := 2; i < 15; i++ {
+ pc, file, line, ok := runtime.Caller(i)
+ if !ok {
+ continue
+ }
+
+ fn := runtime.FuncForPC(pc)
+ if fn == nil {
+ continue
+ }
+
+ // bypass common loggers
+ if strings.HasSuffix(file, "/logger.go") ||
+ strings.HasSuffix(file, "/log.go") {
+ continue
+ }
+
+ funcName := fn.Name()
+ if strings.HasPrefix(funcName, "runtime.") {
+ continue
+ }
+
+ return filepath.Base(file), line, filepath.Base(funcName)
+ }
+
+ return "???", 0, "???"
+}
+
+//nolint:zerologlint
+func getEvent(logger zerolog.Logger, level LogLevel) *zerolog.Event {
+ switch level {
+ case zerolog.DebugLevel:
+ return logger.Debug()
+ case zerolog.InfoLevel:
+ return logger.Info()
+ case zerolog.WarnLevel:
+ return logger.Warn()
+ case zerolog.ErrorLevel:
+ return logger.Error()
+ case zerolog.FatalLevel:
+ return logger.Fatal()
+ default:
+ return logger.Info()
}
}
@@ -101,65 +151,41 @@ func logMessage(level LogLevel, component string, message string, fields map[str
return
}
- entry := LogEntry{
- Level: logLevelNames[level],
- Timestamp: time.Now().UTC().Format(time.RFC3339),
- Component: component,
- Message: message,
- Fields: fields,
- }
+ callerFile, callerLine, callerFunc := getCallerInfo()
- if pc, file, line, ok := runtime.Caller(2); ok {
- fn := runtime.FuncForPC(pc)
- if fn != nil {
- entry.Caller = fmt.Sprintf("%s:%d (%s)", file, line, fn.Name())
- }
- }
+ event := getEvent(logger, level)
- if logger.file != nil {
- jsonData, err := json.Marshal(entry)
- if err == nil {
- logger.file.Write(append(jsonData, '\n'))
- }
- }
-
- var fieldStr string
- if len(fields) > 0 {
- fieldStr = " " + formatFields(fields)
+ // Build combined field with component and caller
+ if component != "" {
+ event.Str("caller", fmt.Sprintf("%-6s %s:%d (%s)", component, callerFile, callerLine, callerFunc))
} else {
- fieldStr = ""
+ event.Str("caller", fmt.Sprintf("