Files
sproutclaw-web/backend/internal/services/sessions.go
2026-06-13 20:16:09 +08:00

172 lines
3.8 KiB
Go

package services
import (
"bufio"
"encoding/json"
"os"
"path/filepath"
"sort"
"strings"
"time"
"sproutclaw-web/internal/db"
"sproutclaw-web/internal/models"
)
// jsonlLine is a minimal parse of one line in a session JSONL file.
type jsonlLine struct {
Type string `json:"type"`
// session header
ID string `json:"id"`
Created string `json:"created"`
// message
Role string `json:"role"`
Content any `json:"content"`
// session_info
Name string `json:"name"`
}
// BuildSessionList reads all *.jsonl files in sessionsDir and assembles summaries.
func BuildSessionList(sessionsDir string, database *db.DB) ([]models.SessionSummary, error) {
entries, err := os.ReadDir(sessionsDir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
pinned, _ := database.GetPinnedSessions()
pinnedSet := map[string]struct{}{}
for _, p := range pinned {
pinnedSet[p] = struct{}{}
}
var summaries []models.SessionSummary
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".jsonl") {
continue
}
path := filepath.Join(sessionsDir, e.Name())
sum, err := readSessionSummary(path)
if err != nil {
continue
}
_, sum.Pinned = pinnedSet[path]
sum.Path = path
summaries = append(summaries, sum)
}
// Sort: pinned first, then by modified desc
sort.SliceStable(summaries, func(i, j int) bool {
pi, pj := summaries[i].Pinned, summaries[j].Pinned
if pi != pj {
return pi
}
return summaries[i].Modified > summaries[j].Modified
})
return summaries, nil
}
func readSessionSummary(path string) (models.SessionSummary, error) {
f, err := os.Open(path)
if err != nil {
return models.SessionSummary{}, err
}
defer f.Close()
info, _ := f.Stat()
modified := ""
if info != nil {
modified = info.ModTime().UTC().Format(time.RFC3339)
}
var sum models.SessionSummary
sum.Modified = modified
scanner := bufio.NewScanner(f)
scanner.Buffer(make([]byte, 2*1024*1024), 2*1024*1024)
msgCount := 0
for scanner.Scan() {
var line jsonlLine
if err := json.Unmarshal(scanner.Bytes(), &line); err != nil {
continue
}
switch line.Type {
case "session":
sum.Created = line.Created
case "session_info":
if line.Name != "" {
sum.Name = line.Name
}
case "message":
msgCount++
if sum.FirstMessage == "" && line.Role == "user" {
sum.FirstMessage = extractTextContent(line.Content)
}
}
}
sum.MessageCount = msgCount
return sum, nil
}
func extractTextContent(content any) string {
switch v := content.(type) {
case string:
return truncate(v, 120)
case []any:
for _, item := range v {
if m, ok := item.(map[string]any); ok {
if m["type"] == "text" {
if t, ok := m["text"].(string); ok {
return truncate(t, 120)
}
}
}
}
}
return ""
}
func truncate(s string, n int) string {
if len([]rune(s)) <= n {
return s
}
runes := []rune(s)
return string(runes[:n]) + "…"
}
// ReadSessionMessages reads all lines from a JSONL session file.
func ReadSessionMessages(path string) ([]json.RawMessage, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
var lines []json.RawMessage
scanner := bufio.NewScanner(f)
scanner.Buffer(make([]byte, 4*1024*1024), 4*1024*1024)
for scanner.Scan() {
raw := append([]byte(nil), scanner.Bytes()...)
lines = append(lines, raw)
}
return lines, nil
}
// AppendSessionName appends a session_info rename record to the JSONL file.
func AppendSessionName(path, name string) error {
f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644)
if err != nil {
return err
}
defer f.Close()
record := map[string]string{"type": "session_info", "name": name}
b, err := json.Marshal(record)
if err != nil {
return err
}
_, err = f.Write(append(b, '\n'))
return err
}