add conversation log

This commit is contained in:
t 2026-03-08 18:03:18 +08:00
parent 9a898191b2
commit 50e4a1e1c7
4 changed files with 228 additions and 0 deletions

View file

@ -439,6 +439,9 @@
},
"llm_call_log": {
"enabled":false
},
"conversation_log" :{
"enabled":false
}
},
"heartbeat": {

View file

@ -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

View file

@ -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"`

View file

@ -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
}