Files
sproutclaw-web/backend/internal/services/sessions.go
2026-06-14 20:31:10 +08:00

321 lines
7.3 KiB
Go

package services
import (
"encoding/json"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
"sproutclaw-web/internal/db"
"sproutclaw-web/internal/models"
)
// sessionLine is one parsed line of a session JSONL file.
type sessionLine struct {
Type string `json:"type"`
ID string `json:"id"`
Timestamp string `json:"timestamp"`
Name string `json:"name"`
Message json.RawMessage `json:"message"`
}
type sessionMessage struct {
Role string `json:"role"`
Content json.RawMessage `json:"content"`
}
var (
reHex = regexp.MustCompile(`^[0-9a-fA-F]{8,}$`)
reDigits = regexp.MustCompile(`^[0-9]{10,}$`)
reWS = regexp.MustCompile(`\s+`)
reFirstSentence = regexp.MustCompile(`^(.+?[。!?.!?])(\s|$)`)
)
// listSessionFiles recursively collects every *.jsonl under sessionsDir.
// pi stores sessions in cwd-encoded subdirectories, e.g.
// sessions/--D--SmyProjects-AI-sproutclaw--/<id>.jsonl
func listSessionFiles(sessionsDir string) []string {
var files []string
_ = filepath.WalkDir(sessionsDir, func(path string, d os.DirEntry, err error) error {
if err != nil {
return nil
}
if !d.IsDir() && strings.HasSuffix(d.Name(), ".jsonl") {
files = append(files, path)
}
return nil
})
// newest first by path (ids are time-sortable); final sort happens later
sort.Sort(sort.Reverse(sort.StringSlice(files)))
return files
}
func isMachineSessionLabel(text, headerID string) bool {
t := strings.TrimSpace(text)
if t == "" {
return true
}
if headerID != "" && t == headerID {
return true
}
if reHex.MatchString(t) || reDigits.MatchString(t) {
return true
}
return false
}
func titleFromFirstUserMessage(text string) string {
const maxChars = 56
cleaned := strings.TrimSpace(reWS.ReplaceAllString(text, " "))
if cleaned == "" {
return ""
}
candidate := cleaned
if m := reFirstSentence.FindStringSubmatch(cleaned); m != nil && m[1] != "" {
candidate = strings.TrimSpace(m[1])
}
runes := []rune(candidate)
if len(runes) > maxChars {
candidate = strings.TrimRight(string(runes[:maxChars]), " ") + "…"
}
return candidate
}
func extractPreview(msg sessionMessage) string {
if len(msg.Content) == 0 {
return ""
}
// content may be a string
var s string
if json.Unmarshal(msg.Content, &s) == nil {
return truncate(s, 200)
}
// or an array of blocks
var blocks []map[string]any
if json.Unmarshal(msg.Content, &blocks) == nil {
var sb strings.Builder
imageCount := 0
for _, b := range blocks {
switch b["type"] {
case "text":
if t, ok := b["text"].(string); ok {
sb.WriteString(t)
}
case "image":
imageCount++
}
}
if txt := sb.String(); txt != "" {
return truncate(txt, 200)
}
if imageCount > 0 {
return "[" + itoa(imageCount) + " 张图片]"
}
}
return ""
}
// readSessionSummary parses one session file into a summary (nil if not a session).
func readSessionSummary(path string) (models.SessionSummary, bool) {
data, err := os.ReadFile(path)
if err != nil {
return models.SessionSummary{}, false
}
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
if len(lines) == 0 {
return models.SessionSummary{}, false
}
var header sessionLine
if json.Unmarshal([]byte(lines[0]), &header) != nil || header.Type != "session" {
return models.SessionSummary{}, false
}
info, _ := os.Stat(path)
modified := ""
if info != nil {
modified = info.ModTime().UTC().Format(time.RFC3339)
}
nameFromInfo := ""
messageCount := 0
firstMessage := ""
for _, line := range lines {
if strings.TrimSpace(line) == "" {
continue
}
var entry sessionLine
if json.Unmarshal([]byte(line), &entry) != nil {
continue
}
switch entry.Type {
case "session_info":
if entry.Name != "" {
n := strings.TrimSpace(entry.Name)
if n != "" && !isMachineSessionLabel(n, header.ID) {
nameFromInfo = n
}
}
case "message":
messageCount++
if firstMessage == "" && len(entry.Message) > 0 {
var m sessionMessage
if json.Unmarshal(entry.Message, &m) == nil && m.Role == "user" {
firstMessage = extractPreview(m)
}
}
}
}
name := nameFromInfo
if name == "" || isMachineSessionLabel(name, header.ID) {
name = titleFromFirstUserMessage(firstMessage)
}
preview := firstMessage
if preview == "" {
preview = "(空)"
}
return models.SessionSummary{
Path: path,
Name: name,
Created: header.Timestamp,
Modified: modified,
MessageCount: messageCount,
FirstMessage: preview,
}, true
}
// BuildSessionList recursively reads all sessions and assembles the response.
func BuildSessionList(sessionsDir string, database *db.DB) ([]models.SessionSummary, error) {
if _, err := os.Stat(sessionsDir); err != nil {
return nil, nil
}
pinned, _ := database.GetPinnedSessions()
pinnedOrder := map[string]int{}
for i, p := range pinned {
pinnedOrder[filepath.Clean(p)] = i
}
var summaries []models.SessionSummary
for _, f := range listSessionFiles(sessionsDir) {
sum, ok := readSessionSummary(f)
if !ok {
continue
}
_, sum.Pinned = pinnedOrder[filepath.Clean(f)]
summaries = append(summaries, sum)
}
sort.SliceStable(summaries, func(i, j int) bool {
ci, iPinned := pinnedOrder[filepath.Clean(summaries[i].Path)]
cj, jPinned := pinnedOrder[filepath.Clean(summaries[j].Path)]
if iPinned && jPinned {
return ci < cj
}
if iPinned != jPinned {
return iPinned
}
return sessionSortKey(summaries[i]) > sessionSortKey(summaries[j])
})
return summaries, nil
}
func sessionSortKey(s models.SessionSummary) string {
if s.Modified != "" {
return s.Modified
}
return s.Created
}
// ReadSessionSummaryByPath returns a single session's summary (exported).
func ReadSessionSummaryByPath(path string) (models.SessionSummary, error) {
sum, ok := readSessionSummary(path)
if !ok {
return models.SessionSummary{Path: path}, nil
}
return sum, nil
}
// ReadSessionMessages returns the `message` payload of each message line.
func ReadSessionMessages(path string) ([]json.RawMessage, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var out []json.RawMessage
for _, line := range strings.Split(strings.TrimSpace(string(data)), "\n") {
if strings.TrimSpace(line) == "" {
continue
}
var entry sessionLine
if json.Unmarshal([]byte(line), &entry) != nil {
continue
}
if entry.Type == "message" && len(entry.Message) > 0 {
out = append(out, entry.Message)
}
}
return out, nil
}
// AppendSessionName appends a session_info rename record to the JSONL file.
func AppendSessionName(path, name string) error {
trimmed := strings.TrimSpace(name)
if trimmed == "" {
return os.ErrInvalid
}
f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644)
if err != nil {
return err
}
defer f.Close()
record := map[string]any{
"type": "session_info",
"timestamp": time.Now().UTC().Format(time.RFC3339),
"name": trimmed,
}
b, err := json.Marshal(record)
if err != nil {
return err
}
_, err = f.Write(append([]byte("\n"), b...))
return err
}
func truncate(s string, n int) string {
r := []rune(s)
if len(r) <= n {
return s
}
return string(r[:n])
}
func itoa(n int) string {
if n == 0 {
return "0"
}
neg := n < 0
if neg {
n = -n
}
var b [20]byte
i := len(b)
for n > 0 {
i--
b[i] = byte('0' + n%10)
n /= 10
}
if neg {
i--
b[i] = '-'
}
return string(b[i:])
}