feat: init sproutclaw-web — Go+Gin backend + React frontend
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
171
backend/internal/services/sessions.go
Normal file
171
backend/internal/services/sessions.go
Normal file
@@ -0,0 +1,171 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user