diff --git a/cmd/picoclaw/internal/gateway/helpers.go b/cmd/picoclaw/internal/gateway/helpers.go index 39b97de48..2d2a1883d 100644 --- a/cmd/picoclaw/internal/gateway/helpers.go +++ b/cmd/picoclaw/internal/gateway/helpers.go @@ -68,8 +68,8 @@ func gatewayCmd(debug bool) error { // Initialize request logger requestLogger := requestlog.NewLogger(requestlog.DefaultConfig(), msgBus, cfg.WorkspacePath()) fmt.Printf(" • Request log dir: %s\n", requestLogger.LogDir()) - if err := requestLogger.Start(); err != nil { - fmt.Printf(" ⚠️ Failed to start request logger: %v\n", err) + if startErr := requestLogger.Start(); startErr != nil { + fmt.Printf(" ⚠️ Failed to start request logger: %v\n", startErr) } agentLoop := agent.NewAgentLoop(cfg, msgBus, provider) diff --git a/pkg/bus/bus.go b/pkg/bus/bus.go index fe082f465..548f4d56a 100644 --- a/pkg/bus/bus.go +++ b/pkg/bus/bus.go @@ -21,11 +21,11 @@ type MessageBus struct { done chan struct{} closed atomic.Bool - inboundMu sync.RWMutex - inboundSubs []chan InboundMessage - outboundMu sync.RWMutex - outboundSubs []chan OutboundMessage - outboundMediaMu sync.RWMutex + inboundMu sync.RWMutex + inboundSubs []chan InboundMessage + outboundMu sync.RWMutex + outboundSubs []chan OutboundMessage + outboundMediaMu sync.RWMutex outboundMediaSubs []chan OutboundMediaMessage } diff --git a/pkg/config/config.go b/pkg/config/config.go index ceea2ff40..e1b14f0db 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -75,18 +75,18 @@ func (f *FlexibleStringSlice) UnmarshalText(text []byte) error { } type Config struct { - Agents AgentsConfig `json:"agents"` - Bindings []AgentBinding `json:"bindings,omitempty"` - Session SessionConfig `json:"session,omitempty"` - Channels ChannelsConfig `json:"channels"` - Providers ProvidersConfig `json:"providers,omitempty"` - ModelList []ModelConfig `json:"model_list"` // New model-centric provider configuration - Gateway GatewayConfig `json:"gateway"` - Tools ToolsConfig `json:"tools"` - Heartbeat HeartbeatConfig `json:"heartbeat"` - Devices DevicesConfig `json:"devices"` - Voice VoiceConfig `json:"voice"` - RequestLog RequestLogConfig `json:"request_log,omitempty"` + Agents AgentsConfig `json:"agents"` + Bindings []AgentBinding `json:"bindings,omitempty"` + Session SessionConfig `json:"session,omitempty"` + Channels ChannelsConfig `json:"channels"` + Providers ProvidersConfig `json:"providers,omitempty"` + ModelList []ModelConfig `json:"model_list"` // New model-centric provider configuration + Gateway GatewayConfig `json:"gateway"` + Tools ToolsConfig `json:"tools"` + Heartbeat HeartbeatConfig `json:"heartbeat"` + Devices DevicesConfig `json:"devices"` + Voice VoiceConfig `json:"voice"` + RequestLog RequestLogConfig `json:"request_log,omitempty"` // BuildInfo contains build-time version information BuildInfo BuildInfo `json:"build_info,omitempty"` } diff --git a/pkg/requestlog/archiver_test.go b/pkg/requestlog/archiver_test.go index 17d1edae4..1fccdbaa9 100644 --- a/pkg/requestlog/archiver_test.go +++ b/pkg/requestlog/archiver_test.go @@ -49,7 +49,7 @@ func TestArchiver_Archive(t *testing.T) { oldFile := filepath.Join(tmpDir, "requests-2024-01-01.jsonl") content := []byte(`{"timestamp":"2024-01-01T00:00:00Z","request_id":"1","channel":"test"}` + "\n") - if err := os.WriteFile(oldFile, content, 0644); err != nil { + if err := os.WriteFile(oldFile, content, 0o644); err != nil { t.Fatalf("WriteFile failed: %v", err) } @@ -86,7 +86,7 @@ func TestArchiver_ArchiveNoCompress(t *testing.T) { oldFile := filepath.Join(tmpDir, "requests-2024-01-01.jsonl") content := []byte(`{"timestamp":"2024-01-01T00:00:00Z","request_id":"1","channel":"test"}` + "\n") - if err := os.WriteFile(oldFile, content, 0644); err != nil { + if err := os.WriteFile(oldFile, content, 0o644); err != nil { t.Fatalf("WriteFile failed: %v", err) } @@ -120,7 +120,7 @@ func TestArchiver_CleanupOldFiles(t *testing.T) { for i := range 5 { filename := filepath.Join(tmpDir, "requests-2024-01-"+padInt(i)+".jsonl") content := make([]byte, 1024*1024) - if err := os.WriteFile(filename, content, 0644); err != nil { + if err := os.WriteFile(filename, content, 0o644); err != nil { t.Fatalf("WriteFile failed: %v", err) } @@ -158,7 +158,7 @@ func TestArchiver_CompressFile(t *testing.T) { srcFile := filepath.Join(tmpDir, "test.jsonl") content := []byte(`{"test":"data"}` + "\n") - if err := os.WriteFile(srcFile, content, 0644); err != nil { + if err := os.WriteFile(srcFile, content, 0o644); err != nil { t.Fatalf("WriteFile failed: %v", err) } @@ -198,7 +198,7 @@ func TestArchiver_ArchiveRecentFiles(t *testing.T) { recentFile := filepath.Join(tmpDir, "requests-"+time.Now().Format("2006-01-02")+".jsonl") content := []byte(`{"timestamp":"2024-01-01T00:00:00Z","request_id":"1","channel":"test"}` + "\n") - if err := os.WriteFile(recentFile, content, 0644); err != nil { + if err := os.WriteFile(recentFile, content, 0o644); err != nil { t.Fatalf("WriteFile failed: %v", err) } diff --git a/pkg/requestlog/logger.go b/pkg/requestlog/logger.go index 7657b3f28..b10acce5d 100644 --- a/pkg/requestlog/logger.go +++ b/pkg/requestlog/logger.go @@ -14,6 +14,7 @@ import ( "time" "github.com/google/uuid" + "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/logger" ) @@ -124,54 +125,7 @@ func (r *Reader) GetStats(startTime, endTime time.Time) (map[string]any, error) return nil, err } - byChannel := make(map[string]int) - byDay := make(map[string]int) - topSenders := make(map[string]int) - - for _, rec := range records { - byChannel[rec.Channel]++ - - day := rec.Timestamp.Format("2006-01-02") - byDay[day]++ - - senderKey := rec.SenderID + ":" + rec.Channel - topSenders[senderKey]++ - } - - result := map[string]any{ - "total": len(records), - "by_channel": byChannel, - "by_day": byDay, - } - - type senderStat struct { - Sender string `json:"sender"` - Channel string `json:"channel"` - Count int `json:"count"` - } - - var topList []senderStat - for k, v := range topSenders { - parts := strings.SplitN(k, ":", 2) - if len(parts) == 2 { - topList = append(topList, senderStat{ - Sender: parts[0], - Channel: parts[1], - Count: v, - }) - } - } - - sort.Slice(topList, func(i, j int) bool { - return topList[i].Count > topList[j].Count - }) - - if len(topList) > 10 { - topList = topList[:10] - } - result["top_senders"] = topList - - return result, nil + return calculateStats(records), nil } func (l *Logger) GetConfig() Config { @@ -306,7 +260,7 @@ func NewStorage(logDir string, maxFileSizeMB int) *Storage { } func (s *Storage) Init() error { - if err := os.MkdirAll(s.logDir, 0755); err != nil { + if err := os.MkdirAll(s.logDir, 0o755); err != nil { return err } return s.rotateFile() @@ -346,7 +300,7 @@ func (s *Storage) rotateFile() error { dateStr := time.Now().Format("2006-01-02") filename := filepath.Join(s.logDir, "requests-"+dateStr+".jsonl") - f, err := os.OpenFile(filename, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + f, err := os.OpenFile(filename, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) if err != nil { return err } @@ -371,15 +325,55 @@ func (s *Storage) Close() error { return nil } -func fileExists(path string) (bool, error) { - _, err := os.Stat(path) - if err == nil { - return true, nil +func calculateStats(records []RequestRecord) map[string]any { + byChannel := make(map[string]int) + byDay := make(map[string]int) + topSenders := make(map[string]int) + + for _, rec := range records { + byChannel[rec.Channel]++ + + day := rec.Timestamp.Format("2006-01-02") + byDay[day]++ + + senderKey := rec.SenderID + ":" + rec.Channel + topSenders[senderKey]++ } - if os.IsNotExist(err) { - return false, nil + + result := map[string]any{ + "total": len(records), + "by_channel": byChannel, + "by_day": byDay, } - return false, err + + type senderStat struct { + Sender string `json:"sender"` + Channel string `json:"channel"` + Count int `json:"count"` + } + + var topList []senderStat + for k, v := range topSenders { + parts := strings.SplitN(k, ":", 2) + if len(parts) == 2 { + topList = append(topList, senderStat{ + Sender: parts[0], + Channel: parts[1], + Count: v, + }) + } + } + + sort.Slice(topList, func(i, j int) bool { + return topList[i].Count > topList[j].Count + }) + + if len(topList) > 10 { + topList = topList[:10] + } + result["top_senders"] = topList + + return result } type QueryOptions struct { @@ -550,52 +544,5 @@ func (l *Logger) GetStats(startTime, endTime time.Time) (map[string]any, error) return nil, err } - byChannel := make(map[string]int) - byDay := make(map[string]int) - topSenders := make(map[string]int) - - for _, r := range records { - byChannel[r.Channel]++ - - day := r.Timestamp.Format("2006-01-02") - byDay[day]++ - - senderKey := r.SenderID + ":" + r.Channel - topSenders[senderKey]++ - } - - result := map[string]any{ - "total": len(records), - "by_channel": byChannel, - "by_day": byDay, - } - - type senderStat struct { - Sender string `json:"sender"` - Channel string `json:"channel"` - Count int `json:"count"` - } - - var topList []senderStat - for k, v := range topSenders { - parts := strings.SplitN(k, ":", 2) - if len(parts) == 2 { - topList = append(topList, senderStat{ - Sender: parts[0], - Channel: parts[1], - Count: v, - }) - } - } - - sort.Slice(topList, func(i, j int) bool { - return topList[i].Count > topList[j].Count - }) - - if len(topList) > 10 { - topList = topList[:10] - } - result["top_senders"] = topList - - return result, nil + return calculateStats(records), nil } diff --git a/pkg/requestlog/logger_test.go b/pkg/requestlog/logger_test.go index d514ca44f..65cb64bf3 100644 --- a/pkg/requestlog/logger_test.go +++ b/pkg/requestlog/logger_test.go @@ -48,8 +48,8 @@ func TestStorage_WriteAndQuery(t *testing.T) { t.Fatalf("json.Marshal failed: %v", err) } - if err := storage.Write(data); err != nil { - t.Fatalf("Write failed: %v", err) + if writeErr := storage.Write(data); writeErr != nil { + t.Fatalf("Write failed: %v", writeErr) } records, err := storage.Query(QueryOptions{Limit: 10}) @@ -108,8 +108,12 @@ func TestStorage_QueryWithFilter(t *testing.T) { expected: 1, }, { - name: "filter by time range", - opts: QueryOptions{StartTime: now.Add(-90 * time.Minute), EndTime: now.Add(10 * time.Minute), Limit: 10}, + name: "filter by time range", + opts: QueryOptions{ + StartTime: now.Add(-90 * time.Minute), + EndTime: now.Add(10 * time.Minute), + Limit: 10, + }, expected: 2, }, { diff --git a/web/backend/api/requestlog.go b/web/backend/api/requestlog.go index 9a0d37f10..ca7ce8249 100644 --- a/web/backend/api/requestlog.go +++ b/web/backend/api/requestlog.go @@ -245,7 +245,19 @@ func (h *Handler) exportCSV(w http.ResponseWriter, records []requestlog.RequestR writer := csv.NewWriter(w) defer writer.Flush() - header := []string{"timestamp", "request_id", "channel", "sender_id", "chat_id", "content", "content_length", "message_id", "media_count", "session_key", "processing_time_ms"} + header := []string{ + "timestamp", + "request_id", + "channel", + "sender_id", + "chat_id", + "content", + "content_length", + "message_id", + "media_count", + "session_key", + "processing_time_ms", + } writer.Write(header) for _, r := range records { diff --git a/web/backend/api/requestlog_test.go b/web/backend/api/requestlog_test.go index ba3a05e51..5b32ee014 100644 --- a/web/backend/api/requestlog_test.go +++ b/web/backend/api/requestlog_test.go @@ -204,7 +204,7 @@ func TestHandlePutRequestLogConfig(t *testing.T) { func TestHandleArchiveNow(t *testing.T) { tmpDir := t.TempDir() logDir := filepath.Join(tmpDir, "logs", "requests") - os.MkdirAll(logDir, 0755) + os.MkdirAll(logDir, 0o755) logger := requestlog.NewLogger(requestlog.DefaultConfig(), nil, tmpDir) diff --git a/web/backend/main.go b/web/backend/main.go index d06191288..693d37c73 100644 --- a/web/backend/main.go +++ b/web/backend/main.go @@ -121,12 +121,12 @@ func main() { apiHandler.SetServerOptions(portNum, effectivePublic, explicitPublic, launcherCfg.AllowedCIDRs) // Initialize requestlog Reader for stats API - if cfg, err := config.LoadConfig(absPath); err == nil { - workspacePath := cfg.WorkspacePath() + if loadedCfg, loadErr := config.LoadConfig(absPath); loadErr == nil { + workspacePath := loadedCfg.WorkspacePath() logDir := filepath.Join(workspacePath, "logs", "requests") // Ensure log directory exists - if err := os.MkdirAll(logDir, 0755); err != nil { - log.Printf("Warning: failed to create request log directory: %v", err) + if mkdirErr := os.MkdirAll(logDir, 0o755); mkdirErr != nil { + log.Printf("Warning: failed to create request log directory: %v", mkdirErr) } reader := requestlog.NewReader(logDir, 100) apiHandler.SetRequestLogReader(reader) @@ -134,10 +134,10 @@ func main() { // Enable file logging appLogDir := filepath.Join(workspacePath, "logs") - if err := os.MkdirAll(appLogDir, 0755); err == nil { + if mkdirErr := os.MkdirAll(appLogDir, 0o755); mkdirErr == nil { logFile := filepath.Join(appLogDir, "launcher.log") - if err := logger.EnableFileLogging(logFile); err != nil { - log.Printf("Warning: failed to enable file logging: %v", err) + if fileErr := logger.EnableFileLogging(logFile); fileErr != nil { + log.Printf("Warning: failed to enable file logging: %v", fileErr) } } } diff --git a/web/frontend/src/components/logs/log-settings-panel.tsx b/web/frontend/src/components/logs/log-settings-panel.tsx new file mode 100644 index 000000000..d0e60bf9e --- /dev/null +++ b/web/frontend/src/components/logs/log-settings-panel.tsx @@ -0,0 +1,191 @@ +import { useEffect, useState } from "react" +import { useTranslation } from "react-i18next" + +import { + archiveNow, + getRequestLogConfig, + updateRequestLogConfig, + type RequestLogConfig, +} from "@/api/stats" +import { PageHeader } from "@/components/page-header" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Switch } from "@/components/ui/switch" +import { Field } from "@/components/shared-form" + +export function LogSettingsPanel() { + const { t } = useTranslation() + const [config, setConfig] = useState(null) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + const [success, setSuccess] = useState(null) + + useEffect(() => { + loadConfig() + }, []) + + async function loadConfig() { + try { + const cfg = await getRequestLogConfig() + setConfig(cfg) + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load config") + } finally { + setLoading(false) + } + } + + async function handleSave() { + if (!config) return + setSaving(true) + setError(null) + setSuccess(null) + try { + await updateRequestLogConfig(config) + setSuccess(t("pages.config.save_success")) + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to save config") + } finally { + setSaving(false) + } + } + + async function handleArchiveNow() { + try { + await archiveNow() + setSuccess(t("pages.logs.archive_success")) + } catch (err) { + setError(err instanceof Error ? err.message : "Archive failed") + } + } + + function updateField( + key: K, + value: RequestLogConfig[K] + ) { + if (!config) return + setConfig({ ...config, [key]: value }) + } + + if (loading) { + return ( +
+ +
{t("labels.loading")}
+
+ ) + } + + if (!config) { + return ( +
+ +
{error || t("pages.logs.config_unavailable")}
+
+ ) + } + + return ( +
+ + +
+
+ {error && ( +
{error}
+ )} + {success && ( +
{success}
+ )} + +
+ + updateField("enabled", checked)} + /> + + + + updateField("max_file_size_mb", parseInt(e.target.value) || 0)} + /> + + + + updateField("max_files", parseInt(e.target.value) || 0)} + /> + + + + updateField("retention_days", parseInt(e.target.value) || 0)} + /> + + + + updateField("archive_interval", e.target.value)} + placeholder="24h" + /> + + + + updateField("compress_archive", checked)} + /> + + + + updateField("log_content_max_length", parseInt(e.target.value) || 0)} + /> + +
+ +
+ + +
+
+
+
+ ) +} diff --git a/web/frontend/src/components/logs/request-log-viewer.tsx b/web/frontend/src/components/logs/request-log-viewer.tsx new file mode 100644 index 000000000..255786a03 --- /dev/null +++ b/web/frontend/src/components/logs/request-log-viewer.tsx @@ -0,0 +1,194 @@ +import { IconDownload, IconFilter, IconRefresh } from "@tabler/icons-react" +import { useEffect, useState } from "react" +import { useTranslation } from "react-i18next" + +import { + getRequestLogs, + getExportLogsUrl, + type RequestRecord, +} from "@/api/stats" +import { PageHeader } from "@/components/page-header" +import { Button } from "@/components/ui/button" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { ScrollArea } from "@/components/ui/scroll-area" + +const CHANNELS = ["", "telegram", "discord", "slack", "feishu", "dingtalk", "irc"] + +export function RequestLogViewer() { + const { t } = useTranslation() + const [records, setRecords] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [channel, setChannel] = useState("") + const [offset, setOffset] = useState(0) + const [limit] = useState(50) + + useEffect(() => { + loadLogs() + }, [channel, offset]) + + async function loadLogs() { + setLoading(true) + setError(null) + try { + const response = await getRequestLogs({ + channel: channel || undefined, + limit, + offset, + }) + setRecords(response.records) + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load logs") + } finally { + setLoading(false) + } + } + + function handleRefresh() { + loadLogs() + } + + function handleExport(format: "json" | "csv") { + const url = getExportLogsUrl({ + channel: channel || undefined, + format, + }) + window.open(url, "_blank") + } + + function formatTimestamp(ts: string) { + return new Date(ts).toLocaleString() + } + + function truncateContent(content: string, maxLength = 100) { + if (content.length <= maxLength) return content + return content.slice(0, maxLength) + "..." + } + + return ( +
+ + +
+
+
+
+ + +
+ + +
+ +
+ + +
+
+ + {error && ( +
+ {error} +
+ )} + +
+ + {loading ? ( +
+ {t("labels.loading")} +
+ ) : records.length === 0 ? ( +
+ {t("pages.logs.no_logs")} +
+ ) : ( +
+
+
{t("pages.logs.timestamp")}
+
{t("pages.logs.channel")}
+
{t("pages.logs.sender")}
+
{t("pages.logs.content")}
+
{t("pages.logs.proc_time")}
+
+ {records.map((record) => ( +
+
+ {formatTimestamp(record.timestamp)} +
+
{record.channel}
+
+ {record.sender_info.username || record.sender_id} +
+
+ {truncateContent(record.content)} +
+
+ {record.processing_time_ms}ms +
+
+ ))} +
+ )} +
+
+ + {records.length > 0 && ( +
+
+ {t("pages.logs.showing")} {offset + 1}-{offset + records.length} +
+
+ + +
+
+ )} +
+
+ ) +}