first commit
This commit is contained in:
@@ -1,10 +1,10 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -13,159 +13,308 @@ import (
|
||||
"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"`
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
type sessionMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content json.RawMessage `json:"content"`
|
||||
}
|
||||
|
||||
pinned, _ := database.GetPinnedSessions()
|
||||
pinnedSet := map[string]struct{}{}
|
||||
for _, p := range pinned {
|
||||
pinnedSet[p] = struct{}{}
|
||||
}
|
||||
var (
|
||||
reHex = regexp.MustCompile(`^[0-9a-fA-F]{8,}$`)
|
||||
reDigits = regexp.MustCompile(`^[0-9]{10,}$`)
|
||||
reWS = regexp.MustCompile(`\s+`)
|
||||
reFirstSentence = regexp.MustCompile(`^(.+?[。!?.!?])(\s|$)`)
|
||||
)
|
||||
|
||||
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)
|
||||
// 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 {
|
||||
continue
|
||||
return nil
|
||||
}
|
||||
_, 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
|
||||
if !d.IsDir() && strings.HasSuffix(d.Name(), ".jsonl") {
|
||||
files = append(files, path)
|
||||
}
|
||||
return summaries[i].Modified > summaries[j].Modified
|
||||
return nil
|
||||
})
|
||||
return summaries, nil
|
||||
// newest first by path (ids are time-sortable); final sort happens later
|
||||
sort.Sort(sort.Reverse(sort.StringSlice(files)))
|
||||
return files
|
||||
}
|
||||
|
||||
func readSessionSummary(path string) (models.SessionSummary, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return models.SessionSummary{}, err
|
||||
func isMachineSessionLabel(text, headerID string) bool {
|
||||
t := strings.TrimSpace(text)
|
||||
if t == "" {
|
||||
return true
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
info, _ := f.Stat()
|
||||
modified := ""
|
||||
if info != nil {
|
||||
modified = info.ModTime().UTC().Format(time.RFC3339)
|
||||
if headerID != "" && t == headerID {
|
||||
return true
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
if reHex.MatchString(t) || reDigits.MatchString(t) {
|
||||
return true
|
||||
}
|
||||
sum.MessageCount = msgCount
|
||||
return sum, nil
|
||||
return false
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
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 ""
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
if len([]rune(s)) <= n {
|
||||
return s
|
||||
// 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
|
||||
}
|
||||
runes := []rune(s)
|
||||
return string(runes[:n]) + "…"
|
||||
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
|
||||
}
|
||||
|
||||
// ReadSessionMessages reads all lines from a JSONL session file.
|
||||
// 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) {
|
||||
f, err := os.Open(path)
|
||||
data, err := os.ReadFile(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)
|
||||
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 lines, nil
|
||||
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]string{"type": "session_info", "name": name}
|
||||
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(b, '\n'))
|
||||
_, 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:])
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user