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--/.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:]) }