From 50e4a1e1c7a6e823adce696c4cbe3cec70ada8b0 Mon Sep 17 00:00:00 2001 From: t <452214834@qq.com> Date: Sun, 8 Mar 2026 18:03:18 +0800 Subject: [PATCH] add conversation log --- config/config.example.json | 3 + pkg/agent/loop.go | 28 +++++ pkg/config/config.go | 8 ++ pkg/logger/conversation_logger.go | 189 ++++++++++++++++++++++++++++++ 4 files changed, 228 insertions(+) create mode 100644 pkg/logger/conversation_logger.go diff --git a/config/config.example.json b/config/config.example.json index 27e41d4c9..9544f7b73 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -439,6 +439,9 @@ }, "llm_call_log": { "enabled":false + }, + "conversation_log" :{ + "enabled":false } }, "heartbeat": { diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 36b1c0427..72affa020 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -82,6 +82,9 @@ func NewAgentLoop( workspace := cfg.WorkspacePath() logger.InitLLMLogger(cfg.Tools.LLMCallLog, workspace) + // Initialize conversation logger + logger.InitConversationLogger(cfg.Tools.ConversationLog, workspace) + // Wrap provider with logging if enabled llmLogger := logger.GetLLMLogger() if llmLogger != nil && llmLogger.IsEnabled() { @@ -801,6 +804,18 @@ func (al *AgentLoop) runAgentLoop( // 2. Save user message to session agent.Sessions.AddMessage(opts.SessionKey, "user", opts.UserMessage) + // Log conversation + if convLogger := logger.GetConversationLogger(); convLogger != nil && convLogger.IsEnabled() { + convLogger.Log(&logger.ConversationRecord{ + Timestamp: time.Now().Format(time.RFC3339), + SessionKey: opts.SessionKey, + Role: "user", + Content: opts.UserMessage, + Channel: opts.Channel, + ChatID: opts.ChatID, + }) + } + // 3. Run LLM iteration loop finalContent, iteration, err := al.runLLMIteration(ctx, agent, messages, opts) if err != nil { @@ -817,6 +832,19 @@ func (al *AgentLoop) runAgentLoop( // 5. Save final assistant message to session agent.Sessions.AddMessage(opts.SessionKey, "assistant", finalContent) + + // Log conversation + if convLogger := logger.GetConversationLogger(); convLogger != nil && convLogger.IsEnabled() { + convLogger.Log(&logger.ConversationRecord{ + Timestamp: time.Now().Format(time.RFC3339), + SessionKey: opts.SessionKey, + Role: "assistant", + Content: finalContent, + Channel: opts.Channel, + ChatID: opts.ChatID, + }) + } + agent.Sessions.Save(opts.SessionKey) // 6. Optional: summarization diff --git a/pkg/config/config.go b/pkg/config/config.go index 4ebdb8310..1beffe3cc 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -662,6 +662,7 @@ type ToolsConfig struct { MediaCleanup MediaCleanupConfig `json:"media_cleanup"` MCP MCPConfig `json:"mcp"` LLMCallLog LLMCallLogConfig `json:"llm_call_log"` + ConversationLog ConversationLogConfig `json:"conversation_log"` AppendFile ToolConfig `json:"append_file" envPrefix:"PICOCLAW_TOOLS_APPEND_FILE_"` EditFile ToolConfig `json:"edit_file" envPrefix:"PICOCLAW_TOOLS_EDIT_FILE_"` FindSkills ToolConfig `json:"find_skills" envPrefix:"PICOCLAW_TOOLS_FIND_SKILLS_"` @@ -685,6 +686,13 @@ type LLMCallLogConfig struct { MaxFiles int `json:"max_files" env:"PICOCLAW_TOOLS_LLM_CALL_LOG_MAX_FILES"` } +// ConversationLogConfig 配置对话日志 +type ConversationLogConfig struct { + Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_CONVERSATION_LOG_ENABLED"` + LogDir string `json:"log_dir" env:"PICOCLAW_TOOLS_CONVERSATION_LOG_DIR"` + MaxFiles int `json:"max_files" env:"PICOCLAW_TOOLS_CONVERSATION_LOG_MAX_FILES"` +} + type SearchCacheConfig struct { MaxSize int `json:"max_size" env:"PICOCLAW_SKILLS_SEARCH_CACHE_MAX_SIZE"` TTLSeconds int `json:"ttl_seconds" env:"PICOCLAW_SKILLS_SEARCH_CACHE_TTL_SECONDS"` diff --git a/pkg/logger/conversation_logger.go b/pkg/logger/conversation_logger.go new file mode 100644 index 000000000..5a37cbe55 --- /dev/null +++ b/pkg/logger/conversation_logger.go @@ -0,0 +1,189 @@ +package logger + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/sipeed/picoclaw/pkg/config" +) + +// ConversationRecord 对话记录 +type ConversationRecord struct { + Timestamp string `json:"timestamp"` + SessionKey string `json:"session_key"` + Role string `json:"role"` + Content string `json:"content"` + Channel string `json:"channel,omitempty"` + ChatID string `json:"chat_id,omitempty"` +} + +// ConversationLogger 记录对话日志 +type ConversationLogger struct { + config config.ConversationLogConfig + mu sync.Mutex + logFiles map[string]*os.File // 按日期缓存的文件句柄 + baseDir string +} + +var ( + conversationLogger *ConversationLogger + conversationOnce sync.Once +) + +// InitConversationLogger 初始化全局对话日志记录器 +func InitConversationLogger(cfg config.ConversationLogConfig, workspace string) { + conversationOnce.Do(func() { + logDir := cfg.LogDir + if logDir == "" { + // 默认路径: workspace/logs/conversations/ + logDir = filepath.Join(workspace, "logs", "conversations") + } + + // 展开 ~ 路径 + if strings.HasPrefix(logDir, "~") { + home, _ := os.UserHomeDir() + logDir = filepath.Join(home, logDir[1:]) + } + + conversationLogger = &ConversationLogger{ + config: cfg, + logFiles: make(map[string]*os.File), + baseDir: logDir, + } + + // 确保目录存在 + os.MkdirAll(logDir, 0755) + + // 启动清理旧日志的 goroutine + if cfg.MaxFiles > 0 { + go conversationLogger.cleanupOldLogs() + } + }) +} + +// GetConversationLogger 获取全局对话日志记录器 +func GetConversationLogger() *ConversationLogger { + return conversationLogger +} + +// IsEnabled 检查日志是否启用 +func (l *ConversationLogger) IsEnabled() bool { + return l != nil && l.config.Enabled +} + +// Log 记录一条对话 +func (l *ConversationLogger) Log(record *ConversationRecord) error { + if !l.IsEnabled() { + return nil + } + + l.mu.Lock() + defer l.mu.Unlock() + + // 获取当天的日志文件 + dateKey := time.Now().Format("2006-01-02") + file, err := l.getLogFile(dateKey) + if err != nil { + return fmt.Errorf("failed to get log file: %w", err) + } + + // 写入 JSON 行 + data, err := json.Marshal(record) + if err != nil { + return fmt.Errorf("failed to marshal record: %w", err) + } + + _, err = fmt.Fprintf(file, "%s\n", data) + if err != nil { + return fmt.Errorf("failed to write log: %w", err) + } + + return nil +} + +// getLogFile 获取或创建指定日期的日志文件 +func (l *ConversationLogger) getLogFile(dateKey string) (*os.File, error) { + if file, ok := l.logFiles[dateKey]; ok { + return file, nil + } + + // 创建新文件 + filename := fmt.Sprintf("%s.jsonl", dateKey) + path := filepath.Join(l.baseDir, filename) + + file, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return nil, err + } + + l.logFiles[dateKey] = file + return file, nil +} + +// cleanupOldLogs 清理旧日志文件 +func (l *ConversationLogger) cleanupOldLogs() { + ticker := time.NewTicker(24 * time.Hour) + defer ticker.Stop() + + for range ticker.C { + l.doCleanup() + } +} + +// doCleanup 执行清理 +func (l *ConversationLogger) doCleanup() { + l.mu.Lock() + defer l.mu.Unlock() + + // 读取目录中的所有日志文件 + entries, err := os.ReadDir(l.baseDir) + if err != nil { + return + } + + // 收集所有日志文件 + var files []string + for _, entry := range entries { + if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".jsonl") { + files = append(files, entry.Name()) + } + } + + // 如果文件数量超过限制,删除最旧的 + if len(files) > l.config.MaxFiles { + // 按文件名排序(文件名包含日期) + // 删除最旧的文件 + for i := 0; i < len(files)-l.config.MaxFiles; i++ { + path := filepath.Join(l.baseDir, files[i]) + os.Remove(path) + + // 关闭可能打开的文件句柄 + dateKey := strings.TrimSuffix(files[i], ".jsonl") + if file, ok := l.logFiles[dateKey]; ok { + file.Close() + delete(l.logFiles, dateKey) + } + } + } +} + +// Close 关闭所有打开的文件句柄 +func (l *ConversationLogger) Close() error { + l.mu.Lock() + defer l.mu.Unlock() + + var lastErr error + for dateKey, file := range l.logFiles { + if err := file.Close(); err != nil { + lastErr = err + } + delete(l.logFiles, dateKey) + } + + return lastErr +} \ No newline at end of file