diff --git a/pkg/agent/context_seahorse.go b/pkg/agent/context_seahorse.go index 2a10d2457..6bd32c216 100644 --- a/pkg/agent/context_seahorse.go +++ b/pkg/agent/context_seahorse.go @@ -6,6 +6,7 @@ import ( "context" "encoding/json" "fmt" + "time" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/providers" @@ -200,6 +201,7 @@ func providerToSeahorseMessage(msg protocoltypes.Message) seahorse.Message { ModelName: msg.ModelName, ReasoningContent: msg.ReasoningContent, TokenCount: tokenizer.EstimateMessageTokens(msg), + CreatedAt: normalizeSeahorseMessageCreatedAt(msg.CreatedAt), } // Convert ToolCalls → MessageParts @@ -235,6 +237,13 @@ func providerToSeahorseMessage(msg protocoltypes.Message) seahorse.Message { return result } +func normalizeSeahorseMessageCreatedAt(createdAt *time.Time) time.Time { + if createdAt == nil || createdAt.IsZero() { + return time.Time{} + } + return createdAt.UTC().Truncate(time.Second) +} + // seahorseToProviderMessages converts a seahorse.AssembleResult to []providers.Message. func seahorseToProviderMessages(result *seahorse.AssembleResult) []protocoltypes.Message { messages := make([]protocoltypes.Message, 0, len(result.Messages)) diff --git a/pkg/agent/context_seahorse_test.go b/pkg/agent/context_seahorse_test.go index 101f72ee2..497e1fc44 100644 --- a/pkg/agent/context_seahorse_test.go +++ b/pkg/agent/context_seahorse_test.go @@ -171,11 +171,13 @@ func TestProviderToSeahorseMessageWithMedia(t *testing.T) { } func TestProviderToSeahorseMessageWithReasoning(t *testing.T) { + createdAt := time.Date(2026, 5, 6, 7, 8, 9, 123000000, time.UTC) msg := protocoltypes.Message{ Role: "assistant", Content: "response text", ModelName: "gpt-5.4-mini", ReasoningContent: "I thought about this carefully", + CreatedAt: &createdAt, } result := providerToSeahorseMessage(msg) @@ -185,6 +187,9 @@ func TestProviderToSeahorseMessageWithReasoning(t *testing.T) { if result.ModelName != "gpt-5.4-mini" { t.Errorf("ModelName = %q, want %q", result.ModelName, "gpt-5.4-mini") } + if !result.CreatedAt.Equal(time.Date(2026, 5, 6, 7, 8, 9, 0, time.UTC)) { + t.Errorf("CreatedAt = %v, want 2026-05-06 07:08:09 UTC", result.CreatedAt) + } } func TestSeahorseToProviderMessagesWithReasoning(t *testing.T) { diff --git a/pkg/seahorse/short_engine.go b/pkg/seahorse/short_engine.go index d275da516..ef493bd18 100644 --- a/pkg/seahorse/short_engine.go +++ b/pkg/seahorse/short_engine.go @@ -9,6 +9,7 @@ import ( "regexp" "strings" "sync" + "time" _ "modernc.org/sqlite" @@ -261,6 +262,7 @@ func (e *Engine) Ingest(ctx context.Context, sessionKey string, messages []Messa msg.ModelName, msg.ReasoningContent, msg.TokenCount, + msg.CreatedAt, ) } else { added, err = e.store.AddMessageWithReasoning( @@ -271,6 +273,7 @@ func (e *Engine) Ingest(ctx context.Context, sessionKey string, messages []Messa msg.ModelName, msg.ReasoningContent, msg.TokenCount, + msg.CreatedAt, ) } if err != nil { @@ -445,10 +448,14 @@ func (e *Engine) Bootstrap(ctx context.Context, sessionKey string, messages []Me if err != nil { return fmt.Errorf("bootstrap: repair model_name: %w", err) } - if (repairedReasoning || repairedModelName) && len(dbMsgs) == len(messages) { + repairedCreatedAt, err := e.repairBootstrapCreatedAt(ctx, dbMsgs, messages) + if err != nil { + return fmt.Errorf("bootstrap: repair created_at: %w", err) + } + if (repairedReasoning || repairedModelName || repairedCreatedAt) && len(dbMsgs) == len(messages) { matched := true for i := range messages { - if !messageMatches(dbMsgs[i], messages[i]) { + if !messagesMatch(dbMsgs[i], messages[i], messageMatchOptions{}) { matched = false break } @@ -462,7 +469,7 @@ func (e *Engine) Bootstrap(ctx context.Context, sessionKey string, messages []Me if len(dbMsgs) == len(messages) { matched := true for i := range messages { - if !messageMatches(dbMsgs[i], messages[i]) { + if !messagesMatch(dbMsgs[i], messages[i], messageMatchOptions{}) { matched = false break } @@ -477,7 +484,7 @@ func (e *Engine) Bootstrap(ctx context.Context, sessionKey string, messages []Me compareLen := min(len(dbMsgs), len(messages)) for i := range compareLen { - if messageMatches(dbMsgs[i], messages[i]) { + if messagesMatch(dbMsgs[i], messages[i], messageMatchOptions{}) { anchor = i } else { // Mismatch detected - log details and rebuild @@ -578,7 +585,11 @@ func (e *Engine) repairBootstrapReasoningContent(ctx context.Context, dbMsgs, me } for i := range overlap { - if !messageMatchesIgnoringReasoningAndModelName(dbMsgs[i], messages[i]) { + if !messagesMatch(dbMsgs[i], messages[i], messageMatchOptions{ + IgnoreReasoningContent: true, + IgnoreModelName: true, + IgnoreCreatedAt: true, + }) { return false, nil } if dbMsgs[i].ReasoningContent == messages[i].ReasoningContent { @@ -629,7 +640,11 @@ func (e *Engine) repairBootstrapModelName(ctx context.Context, dbMsgs, messages } for i := range overlap { - if !messageMatchesIgnoringReasoningAndModelName(dbMsgs[i], messages[i]) { + if !messagesMatch(dbMsgs[i], messages[i], messageMatchOptions{ + IgnoreReasoningContent: true, + IgnoreModelName: true, + IgnoreCreatedAt: true, + }) { return false, nil } if dbMsgs[i].ModelName == messages[i].ModelName { @@ -666,6 +681,64 @@ func (e *Engine) repairBootstrapModelName(ctx context.Context, dbMsgs, messages return true, nil } +func (e *Engine) repairBootstrapCreatedAt(ctx context.Context, dbMsgs, messages []Message) (bool, error) { + if len(dbMsgs) == 0 || len(messages) == 0 { + return false, nil + } + + overlap := min(len(messages), len(dbMsgs)) + + var updates []struct { + index int + messageID int64 + createdAt time.Time + } + + for i := range overlap { + if !messagesMatch(dbMsgs[i], messages[i], messageMatchOptions{ + IgnoreReasoningContent: true, + IgnoreModelName: true, + IgnoreCreatedAt: true, + }) { + return false, nil + } + + wantCreatedAt := normalizeMessageCreatedAt(messages[i].CreatedAt) + if wantCreatedAt.IsZero() { + return false, nil + } + if dbMsgs[i].CreatedAt.Equal(wantCreatedAt) { + continue + } + + updates = append(updates, struct { + index int + messageID int64 + createdAt time.Time + }{ + index: i, + messageID: dbMsgs[i].ID, + createdAt: wantCreatedAt, + }) + } + + if len(updates) == 0 { + return false, nil + } + + for _, update := range updates { + if err := e.store.UpdateMessageCreatedAt(ctx, update.messageID, update.createdAt); err != nil { + return false, err + } + dbMsgs[update.index].CreatedAt = update.createdAt + } + + logger.InfoCF("seahorse", "bootstrap: repaired message created_at", map[string]any{ + "messages": len(updates), + }) + return true, nil +} + // truncate shortens a string for logging. func truncate(s string, maxLen int) string { if len(s) <= maxLen { @@ -674,29 +747,28 @@ func truncate(s string, maxLen int) string { return s[:maxLen] + "..." } -// messageMatches compares two messages using role + reasoning_content and then -// either content or parts. TokenCount is NOT compared because it may be -// re-estimated differently during bootstrap (e.g., via tokenizer.EstimateMessageTokens). -// For messages with Parts (tool_use, tool_result), compare Parts instead of Content -// because structured messages are matched by their parts payload. -func messageMatches(a, b Message) bool { - if a.Role != b.Role || a.ReasoningContent != b.ReasoningContent || a.ModelName != b.ModelName { - return false - } - return messageMatchesIgnoringReasoning(a, b) +type messageMatchOptions struct { + IgnoreReasoningContent bool + IgnoreModelName bool + IgnoreCreatedAt bool } -func messageMatchesIgnoringReasoning(a, b Message) bool { - if a.ModelName != b.ModelName { - return false - } - return messageMatchesIgnoringReasoningAndModelName(a, b) -} - -func messageMatchesIgnoringReasoningAndModelName(a, b Message) bool { +// messagesMatch compares two messages by role and payload, plus the optional +// metadata fields used by bootstrap repair. TokenCount is intentionally ignored +// because bootstrap may re-estimate it differently. +func messagesMatch(a, b Message, opts messageMatchOptions) bool { if a.Role != b.Role { return false } + if !opts.IgnoreReasoningContent && a.ReasoningContent != b.ReasoningContent { + return false + } + if !opts.IgnoreModelName && a.ModelName != b.ModelName { + return false + } + if !opts.IgnoreCreatedAt && !messageCreatedAtMatches(a.CreatedAt, b.CreatedAt) { + return false + } // If either message has Parts, compare Parts if len(a.Parts) > 0 || len(b.Parts) > 0 { return partsMatch(a.Parts, b.Parts) @@ -705,6 +777,18 @@ func messageMatchesIgnoringReasoningAndModelName(a, b Message) bool { return a.Content == b.Content } +// messageCreatedAtMatches treats missing timestamps as compatible so bootstrap +// can preserve legacy histories while still enforcing exact equality once both +// sides carry canonical created_at values. +func messageCreatedAtMatches(a, b time.Time) bool { + na := normalizeMessageCreatedAt(a) + nb := normalizeMessageCreatedAt(b) + if na.IsZero() || nb.IsZero() { + return true + } + return na.Equal(nb) +} + // partsMatch compares two slices of MessagePart for equality. func partsMatch(a, b []MessagePart) bool { if len(a) != len(b) { diff --git a/pkg/seahorse/short_engine_test.go b/pkg/seahorse/short_engine_test.go index 337416f6f..2e198673d 100644 --- a/pkg/seahorse/short_engine_test.go +++ b/pkg/seahorse/short_engine_test.go @@ -57,8 +57,8 @@ func prepareBootstrapRepairConversation( } return conv, []Message{ - {Role: "user", Content: "hello", TokenCount: 3}, - {Role: "assistant", Content: "world", TokenCount: 3}, + {Role: "user", Content: "hello", TokenCount: 3, CreatedAt: userMsg.CreatedAt}, + {Role: "assistant", Content: "world", TokenCount: 3, CreatedAt: assistantMsg.CreatedAt}, } } @@ -464,13 +464,19 @@ func TestBootstrapRepairsReasoningContentAndModelNameTogether(t *testing.T) { } err = eng.Bootstrap(ctx, sessionKey, []Message{ - {Role: "user", Content: "hello", TokenCount: 3}, + { + Role: "user", + Content: "hello", + TokenCount: 3, + CreatedAt: time.Date(2026, 3, 4, 5, 6, 7, 0, time.UTC), + }, { Role: "assistant", Content: "world", ModelName: "gpt-5.4", ReasoningContent: "let me think this through", TokenCount: 3, + CreatedAt: time.Date(2026, 3, 4, 5, 6, 8, 0, time.UTC), }, }) if err != nil { @@ -515,6 +521,7 @@ func TestBootstrapRepairsIncorrectNonEmptyModelName(t *testing.T) { "wrong-model", "", 3, + time.Time{}, ) if err != nil { t.Fatalf("AddMessageWithReasoning assistant: %v", err) @@ -545,6 +552,64 @@ func TestBootstrapRepairsIncorrectNonEmptyModelName(t *testing.T) { } } +func TestBootstrapRepairsCreatedAt(t *testing.T) { + eng := newTestEngine(t) + ctx := context.Background() + sessionKey := "agent:repair-created-at" + conv, msgs := prepareBootstrapRepairConversation(t, eng, ctx, sessionKey) + + wantCreatedAt := time.Date(2026, 3, 4, 5, 6, 7, 0, time.UTC) + msgs[1].CreatedAt = wantCreatedAt + + err := eng.Bootstrap(ctx, sessionKey, msgs) + if err != nil { + t.Fatalf("Bootstrap: %v", err) + } + + stored, err := eng.store.GetMessages(ctx, conv.ConversationID, 10, 0) + if err != nil { + t.Fatalf("GetMessages: %v", err) + } + if len(stored) != 2 { + t.Fatalf("stored messages = %d, want 2", len(stored)) + } + if !stored[1].CreatedAt.Equal(wantCreatedAt) { + t.Fatalf("stored[1].CreatedAt = %v, want %v", stored[1].CreatedAt, wantCreatedAt) + } +} + +func TestEngineIngestPreservesCreatedAt(t *testing.T) { + eng := newTestEngine(t) + ctx := context.Background() + wantCreatedAt := time.Date(2026, 4, 5, 6, 7, 8, 0, time.UTC) + + msgs := []Message{ + { + Role: "assistant", + Content: "world", + TokenCount: 4, + CreatedAt: wantCreatedAt, + }, + } + + _, err := eng.Ingest(ctx, "agent:created-at", msgs) + if err != nil { + t.Fatalf("Ingest: %v", err) + } + + conv, _ := eng.store.GetOrCreateConversation(ctx, "agent:created-at") + stored, err := eng.store.GetMessages(ctx, conv.ConversationID, 10, 0) + if err != nil { + t.Fatalf("GetMessages: %v", err) + } + if len(stored) != 1 { + t.Fatalf("stored messages = %d, want 1", len(stored)) + } + if !stored[0].CreatedAt.Equal(wantCreatedAt) { + t.Fatalf("stored[0].CreatedAt = %v, want %v", stored[0].CreatedAt, wantCreatedAt) + } +} + func TestEngineIngestWithPartsPreservesReasoningContent(t *testing.T) { eng := newTestEngine(t) ctx := context.Background() @@ -864,8 +929,19 @@ func TestBootstrapRepairsMissingReasoningContentWithoutDroppingSummaries(t *test } err = eng.Bootstrap(ctx, sessionKey, []Message{ - {Role: "user", Content: "hello", TokenCount: 3}, - {Role: "assistant", Content: "world", ReasoningContent: "let me think this through", TokenCount: 3}, + { + Role: "user", + Content: "hello", + TokenCount: 3, + CreatedAt: time.Date(2026, 3, 4, 5, 6, 7, 0, time.UTC), + }, + { + Role: "assistant", + Content: "world", + ReasoningContent: "let me think this through", + TokenCount: 3, + CreatedAt: time.Date(2026, 3, 4, 5, 6, 8, 0, time.UTC), + }, }) if err != nil { t.Fatalf("Bootstrap: %v", err) diff --git a/pkg/seahorse/store.go b/pkg/seahorse/store.go index b5e32e89d..0a0d07044 100644 --- a/pkg/seahorse/store.go +++ b/pkg/seahorse/store.go @@ -8,6 +8,8 @@ import ( "time" ) +const sqliteTimeLayout = "2006-01-02 15:04:05" + // Store provides SQLite storage for seahorse. type Store struct { db *sql.DB @@ -75,8 +77,8 @@ func (s *Store) GetConversationBySessionKey(ctx context.Context, sessionKey stri if err != nil { return nil, fmt.Errorf("get conversation by session key: %w", err) } - conv.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt) - conv.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt) + conv.CreatedAt = parseSQLiteTime(createdAt) + conv.UpdatedAt = parseSQLiteTime(updatedAt) return &conv, nil } @@ -153,8 +155,8 @@ func (s *Store) getMessageTimeRange(ctx context.Context, convID int64) (time.Tim if err != nil || minTime == "" { return time.Time{}, time.Time{}, err } - oldest, _ := time.Parse("2006-01-02 15:04:05", minTime) - newest, _ := time.Parse("2006-01-02 15:04:05", maxTime) + oldest := parseSQLiteTime(minTime) + newest := parseSQLiteTime(maxTime) return oldest, newest, nil } @@ -162,7 +164,7 @@ func (s *Store) getMessageTimeRange(ctx context.Context, convID int64) (time.Tim // AddMessage appends a message to a conversation. func (s *Store) AddMessage(ctx context.Context, convID int64, role, content string, tokenCount int) (*Message, error) { - return s.AddMessageWithReasoning(ctx, convID, role, content, "", "", tokenCount) + return s.AddMessageWithReasoning(ctx, convID, role, content, "", "", tokenCount, time.Time{}) } // AddMessageWithReasoning appends a message with reasoning content to a conversation. @@ -171,16 +173,22 @@ func (s *Store) AddMessageWithReasoning( convID int64, role, content, modelName, reasoningContent string, tokenCount int, + createdAt time.Time, ) (*Message, error) { + storedCreatedAt := normalizeMessageCreatedAt(createdAt) + if storedCreatedAt.IsZero() { + storedCreatedAt = normalizeMessageCreatedAt(time.Now()) + } result, err := s.db.ExecContext( ctx, - "INSERT INTO messages (conversation_id, role, content, model_name, reasoning_content, token_count) VALUES (?, ?, ?, ?, ?, ?)", + "INSERT INTO messages (conversation_id, role, content, model_name, reasoning_content, token_count, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", convID, role, content, modelName, reasoningContent, tokenCount, + formatSQLiteTime(storedCreatedAt), ) if err != nil { return nil, fmt.Errorf("add message: %w", err) @@ -194,6 +202,7 @@ func (s *Store) AddMessageWithReasoning( ModelName: modelName, ReasoningContent: reasoningContent, TokenCount: tokenCount, + CreatedAt: storedCreatedAt, }, nil } @@ -231,7 +240,7 @@ func (s *Store) AddMessageWithParts( parts []MessagePart, tokenCount int, ) (*Message, error) { - return s.AddMessageWithPartsAndReasoning(ctx, convID, role, parts, "", "", tokenCount) + return s.AddMessageWithPartsAndReasoning(ctx, convID, role, parts, "", "", tokenCount, time.Time{}) } // AddMessageWithPartsAndReasoning adds a message with structured parts and reasoning content. @@ -243,6 +252,7 @@ func (s *Store) AddMessageWithPartsAndReasoning( modelName string, reasoningContent string, tokenCount int, + createdAt time.Time, ) (*Message, error) { tx, err := s.db.BeginTx(ctx, nil) if err != nil { @@ -250,18 +260,24 @@ func (s *Store) AddMessageWithPartsAndReasoning( } defer tx.Rollback() + storedCreatedAt := normalizeMessageCreatedAt(createdAt) + if storedCreatedAt.IsZero() { + storedCreatedAt = normalizeMessageCreatedAt(time.Now()) + } + // Derive readable content from Parts for FTS5 indexing and summary formatting readableContent := partsToReadableContent(parts) result, err := tx.ExecContext( ctx, - "INSERT INTO messages (conversation_id, role, content, model_name, reasoning_content, token_count) VALUES (?, ?, ?, ?, ?, ?)", + "INSERT INTO messages (conversation_id, role, content, model_name, reasoning_content, token_count, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", convID, role, readableContent, modelName, reasoningContent, tokenCount, + formatSQLiteTime(storedCreatedAt), ) if err != nil { return nil, fmt.Errorf("add message: %w", err) @@ -299,6 +315,7 @@ func (s *Store) AddMessageWithPartsAndReasoning( ModelName: modelName, ReasoningContent: reasoningContent, TokenCount: tokenCount, + CreatedAt: storedCreatedAt, Parts: make([]MessagePart, len(parts)), } for i, p := range parts { @@ -344,7 +361,7 @@ func (s *Store) GetMessages(ctx context.Context, convID int64, limit int, before ); err != nil { return nil, err } - msg.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt) + msg.CreatedAt = parseSQLiteTime(createdAt) msgs = append(msgs, msg) } if err := rows.Err(); err != nil { @@ -387,7 +404,7 @@ func (s *Store) GetMessageByID(ctx context.Context, messageID int64) (*Message, if err != nil { return nil, err } - msg.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt) + msg.CreatedAt = parseSQLiteTime(createdAt) msg.Parts, _ = s.loadMessageParts(ctx, msg.ID) return &msg, nil } @@ -435,6 +452,32 @@ func (s *Store) UpdateMessageModelName(ctx context.Context, messageID int64, mod return nil } +func (s *Store) UpdateMessageCreatedAt(ctx context.Context, messageID int64, createdAt time.Time) error { + storedCreatedAt := normalizeMessageCreatedAt(createdAt) + if storedCreatedAt.IsZero() { + return fmt.Errorf("message %d created_at cannot be zero", messageID) + } + + result, err := s.db.ExecContext( + ctx, + "UPDATE messages SET created_at = ? WHERE message_id = ?", + formatSQLiteTime(storedCreatedAt), + messageID, + ) + if err != nil { + return fmt.Errorf("update message created_at: %w", err) + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("update message created_at rows affected: %w", err) + } + if rowsAffected == 0 { + return fmt.Errorf("message %d not found", messageID) + } + return nil +} + func (s *Store) loadMessageParts(ctx context.Context, msgID int64) ([]MessagePart, error) { rows, err := s.db.QueryContext(ctx, `SELECT part_id, message_id, type, text, name, arguments, tool_call_id, media_uri, mime_type @@ -648,7 +691,7 @@ func (s *Store) GetSummarySourceMessages(ctx context.Context, summaryID string) ); err != nil { return nil, err } - msg.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt) + msg.CreatedAt = parseSQLiteTime(createdAt) msgs = append(msgs, msg) } if err := rows.Err(); err != nil { @@ -714,8 +757,7 @@ func (s *Store) GetContextItems(ctx context.Context, convID int64) ([]ContextIte item.MessageID = messageID.Int64 } if createdAt.Valid { - t, _ := time.Parse("2006-01-02 15:04:05", createdAt.String) - item.CreatedAt = t + item.CreatedAt = parseSQLiteTime(createdAt.String) } items = append(items, item) } @@ -1449,7 +1491,7 @@ func (s *Store) scanSearchResults(rows *sql.Rows, withRank bool) ([]SearchResult } } r.Kind = SummaryKind(kind) - r.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt) + r.CreatedAt = parseSQLiteTime(createdAt) results = append(results, r) } return results, nil @@ -1573,7 +1615,7 @@ func (s *Store) scanMessageSearchResults(rows *sql.Rows, withRank bool) ([]Searc } } r.Snippet = content - r.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt) + r.CreatedAt = parseSQLiteTime(createdAt) results = append(results, r) } if err := rows.Err(); err != nil { @@ -1606,7 +1648,7 @@ func (s *Store) scanSummary(ctx context.Context, where string, args ...any) (*Su return nil, err } sum.Kind = SummaryKind(kind) - sum.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt) + sum.CreatedAt = parseSQLiteTime(createdAt) if earliestAt.Valid { t, _ := time.Parse(time.RFC3339, earliestAt.String) sum.EarliestAt = &t @@ -1633,7 +1675,7 @@ func (s *Store) scanSummaries(rows *sql.Rows) ([]Summary, error) { return nil, err } sum.Kind = SummaryKind(kind) - sum.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt) + sum.CreatedAt = parseSQLiteTime(createdAt) if earliestAt.Valid { t, _ := time.Parse(time.RFC3339, earliestAt.String) sum.EarliestAt = &t @@ -1659,6 +1701,22 @@ func isUniqueViolation(err error) bool { contains(err.Error(), "constraint failed")) } +func normalizeMessageCreatedAt(createdAt time.Time) time.Time { + if createdAt.IsZero() { + return time.Time{} + } + return createdAt.UTC().Truncate(time.Second) +} + +func formatSQLiteTime(t time.Time) string { + return normalizeMessageCreatedAt(t).Format(sqliteTimeLayout) +} + +func parseSQLiteTime(raw string) time.Time { + parsed, _ := time.Parse(sqliteTimeLayout, raw) + return parsed +} + func contains(s, sub string) bool { return len(s) >= len(sub) && searchSubstring(s, sub) } diff --git a/pkg/seahorse/store_test.go b/pkg/seahorse/store_test.go index 4ed2bb3bb..785873150 100644 --- a/pkg/seahorse/store_test.go +++ b/pkg/seahorse/store_test.go @@ -213,6 +213,7 @@ func TestStoreAddAndGetMessagesWithReasoningContent(t *testing.T) { "gpt-5.4-mini", "let me think", 5, + time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC), ) if err != nil { t.Fatalf("AddMessageWithReasoning: %v", err) @@ -223,6 +224,9 @@ func TestStoreAddAndGetMessagesWithReasoningContent(t *testing.T) { if msg.ModelName != "gpt-5.4-mini" { t.Fatalf("ModelName = %q, want %q", msg.ModelName, "gpt-5.4-mini") } + if !msg.CreatedAt.Equal(time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)) { + t.Fatalf("CreatedAt = %v, want 2026-01-02 03:04:05 UTC", msg.CreatedAt) + } msgs, err := s.GetMessages(ctx, conv.ConversationID, 10, 0) if err != nil { @@ -237,6 +241,9 @@ func TestStoreAddAndGetMessagesWithReasoningContent(t *testing.T) { if msgs[0].ModelName != "gpt-5.4-mini" { t.Errorf("ModelName = %q, want %q", msgs[0].ModelName, "gpt-5.4-mini") } + if !msgs[0].CreatedAt.Equal(time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)) { + t.Errorf("CreatedAt = %v, want 2026-01-02 03:04:05 UTC", msgs[0].CreatedAt) + } found, err := s.GetMessageByID(ctx, msg.ID) if err != nil { @@ -248,6 +255,9 @@ func TestStoreAddAndGetMessagesWithReasoningContent(t *testing.T) { if found.ModelName != "gpt-5.4-mini" { t.Errorf("GetMessageByID ModelName = %q, want %q", found.ModelName, "gpt-5.4-mini") } + if !found.CreatedAt.Equal(time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)) { + t.Errorf("GetMessageByID CreatedAt = %v, want 2026-01-02 03:04:05 UTC", found.CreatedAt) + } } func TestStoreAddMessageWithParts(t *testing.T) { @@ -301,6 +311,7 @@ func TestStoreAddMessageWithPartsAndReasoningContent(t *testing.T) { "gpt-5.4", "need to inspect the file first", 10, + time.Date(2026, 2, 3, 4, 5, 6, 0, time.UTC), ) if err != nil { t.Fatalf("AddMessageWithPartsAndReasoning: %v", err) @@ -323,6 +334,9 @@ func TestStoreAddMessageWithPartsAndReasoningContent(t *testing.T) { if msgs[0].ModelName != "gpt-5.4" { t.Errorf("ModelName = %q, want %q", msgs[0].ModelName, "gpt-5.4") } + if !msgs[0].CreatedAt.Equal(time.Date(2026, 2, 3, 4, 5, 6, 0, time.UTC)) { + t.Errorf("CreatedAt = %v, want 2026-02-03 04:05:06 UTC", msgs[0].CreatedAt) + } } func TestStoreGetMessageCount(t *testing.T) { diff --git a/pkg/session/manager.go b/pkg/session/manager.go index 7ca03744d..f75e3e883 100644 --- a/pkg/session/manager.go +++ b/pkg/session/manager.go @@ -60,6 +60,21 @@ func (sm *SessionManager) GetOrCreate(key string) *Session { return session } +func ensureMessageCreatedAt(msg *providers.Message, fallback time.Time) { + if msg.CreatedAt != nil && !msg.CreatedAt.IsZero() { + return + } + ts := fallback + msg.CreatedAt = &ts +} + +func normalizeHistoryCreatedAt(history []providers.Message) { + now := time.Now() + for i := range history { + ensureMessageCreatedAt(&history[i], now) + } +} + func (sm *SessionManager) AddMessage(sessionKey, role, content string) { sm.AddFullMessage(sessionKey, providers.Message{ Role: role, @@ -88,9 +103,7 @@ func (sm *SessionManager) AddFullMessage(sessionKey string, msg providers.Messag } now := time.Now() - if msg.CreatedAt == nil { - msg.CreatedAt = &now - } + ensureMessageCreatedAt(&msg, now) session.Messages = append(session.Messages, msg) session.Updated = now @@ -280,6 +293,7 @@ func (sm *SessionManager) loadSessions() error { continue } session.Messages = messageutil.FilterInvalidHistoryMessages(session.Messages) + normalizeHistoryCreatedAt(session.Messages) sm.sessions[session.Key] = &session } @@ -305,13 +319,8 @@ func (sm *SessionManager) SetHistory(key string, history []providers.Message) { // from the caller's slice. msgs := make([]providers.Message, len(history)) copy(msgs, history) - now := time.Now() - for i := range msgs { - if msgs[i].CreatedAt == nil { - msgs[i].CreatedAt = &now - } - } + normalizeHistoryCreatedAt(msgs) session.Messages = msgs - session.Updated = now + session.Updated = time.Now() } } diff --git a/pkg/session/manager_test.go b/pkg/session/manager_test.go index bc5615966..e167941e7 100644 --- a/pkg/session/manager_test.go +++ b/pkg/session/manager_test.go @@ -83,3 +83,32 @@ func TestSave_RejectsPathTraversal(t *testing.T) { t.Errorf("expected foo_bar.json in storage (sanitized from foo/bar)") } } + +func TestLoadSessions_NormalizesMissingCreatedAt(t *testing.T) { + tmpDir := t.TempDir() + sessionPath := filepath.Join(tmpDir, "telegram_legacy.json") + legacy := `{ + "key": "telegram:legacy", + "messages": [ + { + "role": "user", + "content": "hello" + } + ], + "created": "2026-01-01T00:00:00Z", + "updated": "2026-01-01T00:00:00Z" +}` + + if err := os.WriteFile(sessionPath, []byte(legacy), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + sm := NewSessionManager(tmpDir) + history := sm.GetHistory("telegram:legacy") + if len(history) != 1 { + t.Fatalf("history = %d, want 1", len(history)) + } + if history[0].CreatedAt == nil || history[0].CreatedAt.IsZero() { + t.Fatalf("history[0].CreatedAt = %v, want non-zero timestamp", history[0].CreatedAt) + } +} diff --git a/web/frontend/src/components/agent/skills/detail-sheet.tsx b/web/frontend/src/components/agent/skills/detail-sheet.tsx index 4579926d8..9ec0c5a0c 100644 --- a/web/frontend/src/components/agent/skills/detail-sheet.tsx +++ b/web/frontend/src/components/agent/skills/detail-sheet.tsx @@ -13,6 +13,10 @@ import rehypeSanitize from "rehype-sanitize" import remarkGfm from "remark-gfm" import type { SkillDetailResponse, SkillSupportItem } from "@/api/skills" +import { + MarkdownCodeBlock, + MessageCodeBlock, +} from "@/components/chat/message-code-block" import { Sheet, SheetContent, @@ -176,6 +180,9 @@ export function DetailSheet({ {selectedSkillDetail.content} @@ -183,11 +190,12 @@ export function DetailSheet({ ) : null} {detailView === "raw" ? ( -
-
-                    {selectedSkillDetail.content}
-                  
-
+ ) : null} {detailView === "meta" ? ( diff --git a/web/frontend/src/components/channels/channel-forms/mqtt-form.tsx b/web/frontend/src/components/channels/channel-forms/mqtt-form.tsx index c52ad601c..0be02649f 100644 --- a/web/frontend/src/components/channels/channel-forms/mqtt-form.tsx +++ b/web/frontend/src/components/channels/channel-forms/mqtt-form.tsx @@ -1,4 +1,5 @@ import type { ChannelConfig } from "@/api/channels" +import { MessageCodeBlock } from "@/components/chat/message-code-block" import { getSecretInputPlaceholder } from "@/components/channels/channel-config-fields" import { Field, KeyInput } from "@/components/shared-form" import { @@ -180,9 +181,12 @@ export function MqttForm({ {t("channels.mqtt.uplink")}

{`${topicBase}/request`} -
-              {`{\n  "text": "your message"\n}`}
-            
+

@@ -199,9 +203,12 @@ export function MqttForm({ {t("channels.mqtt.downlink")}

{`${topicBase}/response`} -
-              {`{\n  "text": "agent response"\n}`}
-            
+

diff --git a/web/frontend/src/components/chat/assistant-message.tsx b/web/frontend/src/components/chat/assistant-message.tsx index 6527562fe..0a197b25e 100644 --- a/web/frontend/src/components/chat/assistant-message.tsx +++ b/web/frontend/src/components/chat/assistant-message.tsx @@ -197,7 +197,6 @@ export function AssistantMessage({ label={toolName || t("chat.toolCallArgumentsLabel")} className="my-0 shadow-none" bodyClassName="px-3 py-2 text-[12px] leading-relaxed" - wrapLongLines /> )}

diff --git a/web/frontend/src/components/chat/message-code-block.tsx b/web/frontend/src/components/chat/message-code-block.tsx index 0da081194..79a3706a2 100644 --- a/web/frontend/src/components/chat/message-code-block.tsx +++ b/web/frontend/src/components/chat/message-code-block.tsx @@ -3,17 +3,30 @@ import { IconChevronDown, IconCopy, } from "@tabler/icons-react" +import { useAtom } from "jotai" import hljs from "highlight.js/lib/core" import json from "highlight.js/lib/languages/json" -import { type ComponentProps, type ReactNode, useState } from "react" +import { + type ComponentProps, + type CSSProperties, + type ReactNode, + useState, +} from "react" import { useTranslation } from "react-i18next" import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard" import { cn } from "@/lib/utils" +import { codeBlockWrapAtom } from "@/store/code-block" import { extractCodeBlockFromPreNode, + extractCodeBlockRenderState, type MarkdownNode, + splitCodeIntoLines, + splitHighlightedHtmlIntoLines, + splitRenderedCodeContentIntoLines, + trimTrailingEmptyRenderedCodeLine, + trimTrailingEmptyStringLine, } from "./message-code-block.utils" import { Button } from "@/components/ui/button" @@ -27,10 +40,10 @@ interface MessageCodeBlockProps { code: string language?: string | null label?: string - children?: ReactNode className?: string bodyClassName?: string - wrapLongLines?: boolean + children?: ReactNode + trimTrailingEmptyLine?: boolean } interface MarkdownCodeBlockProps extends ComponentProps<"pre"> { @@ -53,13 +66,14 @@ export function MessageCodeBlock({ code, language = null, label, - children, className, bodyClassName, - wrapLongLines = false, + children, + trimTrailingEmptyLine = false, }: MessageCodeBlockProps) { const { t } = useTranslation() const { copy, isCopied } = useCopyToClipboard() + const [wrapLongLines, setWrapLongLines] = useAtom(codeBlockWrapAtom) const [isExpanded, setIsExpanded] = useState(true) const blockLabel = label ?? @@ -68,7 +82,31 @@ export function MessageCodeBlock({ : t("chat.codeLabel").toLocaleLowerCase()) const copyLabel = isCopied ? t("chat.copiedLabel") : t("chat.copyCode") const expandLabel = isExpanded ? t("chat.collapseCode") : t("chat.expandCode") + const wrapLabel = wrapLongLines + ? t("chat.disableCodeWrap") + : t("chat.enableCodeWrap") + const renderedCodeState = children + ? extractCodeBlockRenderState(children) + : { + renderedContent: null, + className: undefined, + } const highlightedHtml = !children ? getHighlightedHtml(code, language) : null + const highlightedLines = highlightedHtml + ? splitHighlightedHtmlIntoLines(highlightedHtml) + : null + const codeLines = children + ? (trimTrailingEmptyLine + ? trimTrailingEmptyRenderedCodeLine( + splitRenderedCodeContentIntoLines(renderedCodeState.renderedContent), + ) + : splitRenderedCodeContentIntoLines(renderedCodeState.renderedContent)) + : (trimTrailingEmptyLine + ? trimTrailingEmptyStringLine( + highlightedLines ?? splitCodeIntoLines(code), + ) + : (highlightedLines ?? splitCodeIntoLines(code))) + const lineNumberWidth = `${String(codeLines.length).length + 1}ch` return (
{copyLabel} +
@@ -158,6 +241,7 @@ export function MarkdownCodeBlock({ code={code} language={language} bodyClassName={className} + trimTrailingEmptyLine > {children}
diff --git a/web/frontend/src/components/chat/message-code-block.utils.ts b/web/frontend/src/components/chat/message-code-block.utils.ts index 2133ec638..40e76a2a8 100644 --- a/web/frontend/src/components/chat/message-code-block.utils.ts +++ b/web/frontend/src/components/chat/message-code-block.utils.ts @@ -1,3 +1,11 @@ +import { + Children, + cloneElement, + Fragment, + isValidElement, + type ReactNode, +} from "react" + export interface MarkdownNode { type?: string value?: string @@ -6,7 +14,7 @@ export interface MarkdownNode { children?: MarkdownNode[] } -function toClassNameTokens(className: unknown): string[] { +export function toClassNameTokens(className: unknown): string[] { if (typeof className === "string") { return className.split(/\s+/).filter(Boolean) } @@ -72,6 +80,10 @@ export function extractCodeBlockLanguage(className: unknown): string | null { return languageToken ? languageToken.slice("language-".length) : null } +export function stripSingleTrailingLineBreak(value: string): string { + return value.replace(/\r?\n$/, "") +} + export function extractCodeBlockFromPreNode(node: MarkdownNode | undefined): { code: string language: string | null @@ -79,7 +91,248 @@ export function extractCodeBlockFromPreNode(node: MarkdownNode | undefined): { const codeNode = findFirstDescendantByTagName(node, "code") return { - code: extractTextFromMarkdownNode(codeNode ?? node), + code: stripSingleTrailingLineBreak(extractTextFromMarkdownNode(codeNode ?? node)), language: extractCodeBlockLanguage(codeNode?.properties?.className), } } + +export function extractCodeBlockRenderState(children: ReactNode): { + renderedContent: ReactNode + className: string | undefined +} { + const childNodes = Children.toArray(children) + const codeChild = childNodes.find( + (child) => + isValidElement<{ children?: ReactNode; className?: unknown }>(child) && + typeof child.type === "string" && + child.type === "code", + ) + + if ( + isValidElement<{ children?: ReactNode; className?: unknown }>(codeChild) + ) { + const classNameTokens = toClassNameTokens(codeChild.props.className) + return { + renderedContent: codeChild.props.children, + className: + classNameTokens.length > 0 ? classNameTokens.join(" ") : undefined, + } + } + + return { + renderedContent: children, + className: undefined, + } +} + +function mergeNodeLineGroups( + currentLines: Node[][], + nextLines: Node[][], +): Node[][] { + if (nextLines.length === 0) { + return currentLines + } + + const mergedLines = currentLines.map((line) => [...line]) + mergedLines[mergedLines.length - 1].push(...nextLines[0]) + + for (const line of nextLines.slice(1)) { + mergedLines.push([...line]) + } + + return mergedLines +} + +function splitDomNodeIntoLines(node: Node, ownerDocument: Document): Node[][] { + if (node.nodeType === Node.TEXT_NODE) { + return (node.textContent ?? "").split("\n").map((line) => + line.length > 0 ? [ownerDocument.createTextNode(line)] : [], + ) + } + + if (node.nodeType !== Node.ELEMENT_NODE) { + return [[]] + } + + const element = node as Element + if (element.tagName.toLowerCase() === "br") { + return [ + [], + [], + ] + } + + const childLines = splitHighlightedHtmlIntoNodeLines( + Array.from(element.childNodes), + ownerDocument, + ) + + return childLines.map((lineChildren) => { + const clonedElement = element.cloneNode(false) + for (const child of lineChildren) { + clonedElement.appendChild(child) + } + + return [clonedElement] + }) +} + +function splitHighlightedHtmlIntoNodeLines( + nodes: Node[], + ownerDocument: Document, +): Node[][] { + let lines: Node[][] = [[]] + + for (const node of nodes) { + lines = mergeNodeLineGroups( + lines, + splitDomNodeIntoLines(node, ownerDocument), + ) + } + + return lines +} + +export function splitCodeIntoLines(code: string): string[] { + return code.split("\n") +} + +export function splitHighlightedHtmlIntoLines(highlightedHtml: string): string[] { + if (typeof document === "undefined") { + return splitCodeIntoLines(highlightedHtml) + } + + const container = document.createElement("div") + container.innerHTML = highlightedHtml + + return splitHighlightedHtmlIntoNodeLines( + Array.from(container.childNodes), + document, + ).map((lineNodes) => { + const lineContainer = document.createElement("div") + for (const node of lineNodes) { + lineContainer.appendChild(node) + } + + return lineContainer.innerHTML + }) +} + +export function trimTrailingEmptyStringLine(lines: string[]): string[] { + if (lines.length > 1 && lines[lines.length - 1] === "") { + return lines.slice(0, -1) + } + + return lines +} + +function isEmptyRenderedCodeNode(node: ReactNode): boolean { + if (node === null || node === undefined || typeof node === "boolean") { + return true + } + + if (typeof node === "string" || typeof node === "number") { + return String(node).length === 0 + } + + if (Array.isArray(node)) { + return node.every(isEmptyRenderedCodeNode) + } + + if (!isValidElement<{ children?: ReactNode }>(node)) { + return false + } + + return Children.toArray(node.props.children).every(isEmptyRenderedCodeNode) +} + +export function trimTrailingEmptyRenderedCodeLine( + lines: ReactNode[][], +): ReactNode[][] { + if ( + lines.length > 1 && + lines[lines.length - 1].every(isEmptyRenderedCodeNode) + ) { + return lines.slice(0, -1) + } + + return lines +} + +function mergeReactLineGroups( + currentLines: ReactNode[][], + nextLines: ReactNode[][], +): ReactNode[][] { + if (nextLines.length === 0) { + return currentLines + } + + const mergedLines = currentLines.map((line) => [...line]) + mergedLines[mergedLines.length - 1].push(...nextLines[0]) + + for (const line of nextLines.slice(1)) { + mergedLines.push([...line]) + } + + return mergedLines +} + +function splitTextNodeIntoLines(value: string | number): ReactNode[][] { + return String(value).split("\n").map((line) => (line.length > 0 ? [line] : [])) +} + +function splitReactNodeIntoLines(node: ReactNode): ReactNode[][] { + if (node === null || node === undefined || typeof node === "boolean") { + return [[]] + } + + if (typeof node === "string" || typeof node === "number") { + return splitTextNodeIntoLines(node) + } + + if (Array.isArray(node)) { + return splitRenderedCodeContentIntoLines(node) + } + + if (!isValidElement<{ children?: ReactNode }>(node)) { + return [[node]] + } + + if (node.type === Fragment) { + return splitRenderedCodeContentIntoLines(Children.toArray(node.props.children)) + } + + if (typeof node.type === "string" && node.type === "br") { + return [ + [], + [], + ] + } + + const childLines = splitRenderedCodeContentIntoLines( + Children.toArray(node.props.children), + ) + + return childLines.map((lineChildren, lineIndex) => [ + cloneElement( + node, + { + key: `${node.key ?? "code-line"}-${lineIndex}`, + }, + ...lineChildren, + ), + ]) +} + +export function splitRenderedCodeContentIntoLines( + content: ReactNode, +): ReactNode[][] { + const contentNodes = Array.isArray(content) ? content : [content] + let lines: ReactNode[][] = [[]] + + for (const node of contentNodes) { + lines = mergeReactLineGroups(lines, splitReactNodeIntoLines(node)) + } + + return lines +} diff --git a/web/frontend/src/i18n/locales/en.json b/web/frontend/src/i18n/locales/en.json index 1f15cee11..cb97f0a5e 100644 --- a/web/frontend/src/i18n/locales/en.json +++ b/web/frontend/src/i18n/locales/en.json @@ -76,6 +76,8 @@ "copyMessage": "Copy message", "copyCode": "Copy code", "copiedLabel": "Copied", + "enableCodeWrap": "Wrap lines", + "disableCodeWrap": "Disable wrap", "expandCode": "Expand code", "collapseCode": "Collapse code", "history": "History", diff --git a/web/frontend/src/i18n/locales/pt-br.json b/web/frontend/src/i18n/locales/pt-br.json index 8e27078fc..ca1f9ed32 100644 --- a/web/frontend/src/i18n/locales/pt-br.json +++ b/web/frontend/src/i18n/locales/pt-br.json @@ -76,6 +76,8 @@ "copyMessage": "Copiar mensagem", "copyCode": "Copiar código", "copiedLabel": "Copiado", + "enableCodeWrap": "Quebrar linhas", + "disableCodeWrap": "Desativar quebra", "expandCode": "Expandir código", "collapseCode": "Recolher código", "history": "Histórico", diff --git a/web/frontend/src/i18n/locales/zh.json b/web/frontend/src/i18n/locales/zh.json index bd027fee4..5590adab2 100644 --- a/web/frontend/src/i18n/locales/zh.json +++ b/web/frontend/src/i18n/locales/zh.json @@ -76,6 +76,8 @@ "copyMessage": "复制消息", "copyCode": "复制代码", "copiedLabel": "已复制", + "enableCodeWrap": "开启换行", + "disableCodeWrap": "关闭换行", "expandCode": "展开代码", "collapseCode": "折叠代码", "history": "历史记录", diff --git a/web/frontend/src/store/code-block.ts b/web/frontend/src/store/code-block.ts new file mode 100644 index 000000000..612e45fca --- /dev/null +++ b/web/frontend/src/store/code-block.ts @@ -0,0 +1,11 @@ +import { atomWithStorage } from "jotai/utils" + +export const CODE_BLOCK_WRAP_STORAGE_KEY = "picoclaw:code-block-wrap" +export const DEFAULT_CODE_BLOCK_WRAP = false + +export const codeBlockWrapAtom = atomWithStorage( + CODE_BLOCK_WRAP_STORAGE_KEY, + DEFAULT_CODE_BLOCK_WRAP, + undefined, + { getOnInit: true }, +) diff --git a/web/frontend/src/store/index.ts b/web/frontend/src/store/index.ts index a13b7b161..2ef7631fe 100644 --- a/web/frontend/src/store/index.ts +++ b/web/frontend/src/store/index.ts @@ -1,3 +1,4 @@ export * from "./gateway" export * from "./chat" +export * from "./code-block" export * from "./tour"