Files
BlackBean/internal/agent/session.go
T
2026-08-14 23:41:57 +08:00

339 lines
7.7 KiB
Go

package agent
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"unicode/utf8"
)
type SessionStore struct {
mu sync.RWMutex
path string
sessions map[string]*Session
order []string
}
func NewSessionStore(dataDir string) (*SessionStore, error) {
if err := os.MkdirAll(dataDir, 0o755); err != nil {
return nil, err
}
path := filepath.Join(dataDir, "sessions.json")
store := &SessionStore{
path: path,
sessions: make(map[string]*Session),
order: make([]string, 0),
}
_ = store.load()
return store, nil
}
func (s *SessionStore) load() error {
data, err := os.ReadFile(s.path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil
}
return err
}
if len(strings.TrimSpace(string(data))) == 0 {
return nil
}
var sessions map[string]*Session
if err := json.Unmarshal(data, &sessions); err != nil {
return err
}
for id, session := range sessions {
if session == nil || session.ID == "" {
continue
}
s.sessions[id] = session
s.order = append(s.order, id)
}
s.sortOrder()
return nil
}
func (s *SessionStore) saveLocked() error {
data, err := json.MarshalIndent(s.sessions, "", " ")
if err != nil {
return err
}
tmp := s.path + ".tmp"
if err := os.WriteFile(tmp, data, 0o600); err != nil {
return err
}
return os.Rename(tmp, s.path)
}
func (s *SessionStore) List() []SessionSummary {
s.mu.RLock()
defer s.mu.RUnlock()
return s.listLocked("", 0, 0)
}
// Search 按关键词过滤会话(标题或任意消息内容,忽略大小写)。
func (s *SessionStore) Search(query string) []SessionSummary {
s.mu.RLock()
defer s.mu.RUnlock()
return s.listLocked(query, 0, 0)
}
// listLocked 返回会话摘要列表;query 非空时按关键词过滤,limit>0 时截断。
// 必须持读锁调用。
func (s *SessionStore) listLocked(query string, limit, offset int) []SessionSummary {
query = strings.ToLower(strings.TrimSpace(query))
out := make([]SessionSummary, 0, len(s.order))
skip := 0
for _, id := range s.order {
session, ok := s.sessions[id]
if !ok {
continue
}
if query != "" && !sessionMatches(session, query) {
continue
}
if offset > 0 && skip < offset {
skip++
continue
}
if limit > 0 && len(out) >= limit {
break
}
out = append(out, summarize(session))
}
return out
}
func sessionMatches(session *Session, query string) bool {
if strings.Contains(strings.ToLower(session.Title), query) {
return true
}
for _, message := range session.Messages {
if message.Content != nil && strings.Contains(strings.ToLower(*message.Content), query) {
return true
}
for _, call := range message.ToolCalls {
if strings.Contains(strings.ToLower(call.Function.Name), query) ||
strings.Contains(strings.ToLower(call.Function.Arguments), query) {
return true
}
}
}
return false
}
// Messages 分页返回会话消息(按时间正序),offset 从最新一条往前数,
// 即 offset=0 返回最新 limit 条;返回 has_more 表示还有更早的消息。
func (s *SessionStore) Messages(id string, limit, offset int) ([]Message, bool, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
session, ok := s.sessions[id]
if !ok {
return nil, false, false
}
total := len(session.Messages)
if limit <= 0 {
limit = 50
}
if offset < 0 {
offset = 0
}
start := total - offset - limit
if start < 0 {
start = 0
}
messages := make([]Message, 0, limit)
for i := start; i < total-offset; i++ {
messages = append(messages, cloneMessage(session.Messages[i]))
}
hasMore := start > 0
return messages, true, hasMore
}
// Clear 清空会话消息(保留会话本身)。
func (s *SessionStore) Clear(id string) bool {
s.mu.Lock()
defer s.mu.Unlock()
session, ok := s.sessions[id]
if !ok {
return false
}
session.Messages = make([]Message, 0)
session.UpdatedAt = time.Now()
_ = s.saveLocked()
return true
}
// TotalMessages 返回会话消息总数(用于分页计算)。
func (s *SessionStore) TotalMessages(id string) (int, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
session, ok := s.sessions[id]
if !ok {
return 0, false
}
return len(session.Messages), true
}
func cloneMessage(message Message) Message {
copy := message
if message.Content != nil {
content := *message.Content
copy.Content = &content
}
copy.ToolCalls = append([]ToolCall(nil), message.ToolCalls...)
return copy
}
func (s *SessionStore) Get(id string) (*Session, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
session, ok := s.sessions[id]
return session, ok
}
func (s *SessionStore) Create() *Session {
s.mu.Lock()
defer s.mu.Unlock()
session := &Session{
ID: newID(),
Title: "新对话",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
Messages: make([]Message, 0),
}
s.sessions[session.ID] = session
s.order = append([]string{session.ID}, s.order...)
_ = s.saveLocked()
return session
}
func (s *SessionStore) Delete(id string) bool {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.sessions[id]; !ok {
return false
}
delete(s.sessions, id)
for i, item := range s.order {
if item == id {
s.order = append(s.order[:i], s.order[i+1:]...)
break
}
}
_ = s.saveLocked()
return true
}
func (s *SessionStore) Save(session *Session) {
session.UpdatedAt = time.Now()
s.mu.Lock()
// 存储克隆而非原对象:会话对象可能仍在 Agent 循环中被修改,
// 直接存指针会导致 saveLocked 序列化时与其他会话的写入产生数据竞争。
s.sessions[session.ID] = cloneSession(session)
s.order = append([]string{session.ID}, removeString(s.order, session.ID)...)
err := s.saveLocked()
s.mu.Unlock()
if err != nil {
// A failed save should not break an in-memory conversation.
return
}
}
func (s *SessionStore) sortOrder() {
sort.SliceStable(s.order, func(i, j int) bool {
a, aOK := s.sessions[s.order[i]]
b, bOK := s.sessions[s.order[j]]
if !aOK || !bOK {
return aOK && !bOK
}
return a.UpdatedAt.After(b.UpdatedAt)
})
}
func summarize(session *Session) SessionSummary {
summary := SessionSummary{
ID: session.ID,
Title: session.Title,
CreatedAt: session.CreatedAt,
UpdatedAt: session.UpdatedAt,
MessageCount: len(session.Messages),
}
for i := len(session.Messages) - 1; i >= 0; i-- {
message := session.Messages[i]
if message.Role != "user" && message.Role != "assistant" {
continue
}
if message.Content != nil {
summary.Preview = truncateRunes(*message.Content, 120)
break
}
}
return summary
}
func newID() string {
buf := make([]byte, 12)
_, _ = rand.Read(buf)
return hex.EncodeToString(buf)
}
// cloneSession 深拷贝会话对象,保证持久化对象在存储后不再被外部修改,
// 从而消除 Agent 循环写入与 saveLocked 序列化之间的数据竞争。
func cloneSession(session *Session) *Session {
if session == nil {
return nil
}
clone := *session
clone.Messages = make([]Message, len(session.Messages))
for i, message := range session.Messages {
copy := message
if message.Content != nil {
content := *message.Content
copy.Content = &content
}
copy.ToolCalls = append([]ToolCall(nil), message.ToolCalls...)
clone.Messages[i] = copy
}
return &clone
}
func removeString(items []string, target string) []string {
result := make([]string, 0, len(items))
for _, item := range items {
if item != target {
result = append(result, item)
}
}
return result
}
func truncateRunes(value string, max int) string {
runes := []rune(value)
if len(runes) <= max {
return value
}
return string(runes[:max]) + "..."
}
func firstRunes(value string, max int) string {
runes := []rune(strings.TrimSpace(value))
if len(runes) <= max {
return string(runes)
}
return string(runes[:max])
}
func countRunes(value string) int {
return utf8.RuneCountInString(value)
}