v0.0.4: add sessions and refine tui
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
package session
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"agentu/internal/llm"
|
||||
)
|
||||
|
||||
// Session represents a persisted conversation.
|
||||
type Session struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Provider string `json:"provider"`
|
||||
Model string `json:"model"`
|
||||
Messages []llm.Message `json:"messages"`
|
||||
}
|
||||
|
||||
// SessionMeta is a lightweight summary for listing.
|
||||
type SessionMeta struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Provider string `json:"provider"`
|
||||
Model string `json:"model"`
|
||||
MessageCount int `json:"message_count"`
|
||||
}
|
||||
|
||||
// Store manages session files under a directory.
|
||||
type Store struct {
|
||||
dir string
|
||||
}
|
||||
|
||||
// NewStore creates a store, ensuring the directory exists.
|
||||
func NewStore(dir string) (*Store, error) {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("create session dir: %w", err)
|
||||
}
|
||||
return &Store{dir: dir}, nil
|
||||
}
|
||||
|
||||
// Save writes a session to disk.
|
||||
func (s *Store) Save(sess *Session) error {
|
||||
if sess.ID == "" {
|
||||
return fmt.Errorf("session ID is required")
|
||||
}
|
||||
sess.UpdatedAt = time.Now().UTC()
|
||||
if sess.CreatedAt.IsZero() {
|
||||
sess.CreatedAt = sess.UpdatedAt
|
||||
}
|
||||
data, err := json.MarshalIndent(sess, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal session: %w", err)
|
||||
}
|
||||
path := s.filePath(sess.ID)
|
||||
if err := os.WriteFile(path, data, 0o644); err != nil {
|
||||
return fmt.Errorf("write session: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Load reads a session by ID.
|
||||
func (s *Store) Load(id string) (*Session, error) {
|
||||
path := s.filePath(id)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("session %q not found", id)
|
||||
}
|
||||
return nil, fmt.Errorf("read session: %w", err)
|
||||
}
|
||||
var sess Session
|
||||
if err := json.Unmarshal(data, &sess); err != nil {
|
||||
return nil, fmt.Errorf("parse session: %w", err)
|
||||
}
|
||||
return &sess, nil
|
||||
}
|
||||
|
||||
// List returns metadata for all sessions, sorted by updated_at descending.
|
||||
func (s *Store) List() ([]SessionMeta, error) {
|
||||
entries, err := os.ReadDir(s.dir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read session dir: %w", err)
|
||||
}
|
||||
var metas []SessionMeta
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
|
||||
continue
|
||||
}
|
||||
path := filepath.Join(s.dir, entry.Name())
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var sess Session
|
||||
if err := json.Unmarshal(data, &sess); err != nil {
|
||||
continue
|
||||
}
|
||||
metas = append(metas, SessionMeta{
|
||||
ID: sess.ID,
|
||||
Name: sess.Name,
|
||||
CreatedAt: sess.CreatedAt,
|
||||
UpdatedAt: sess.UpdatedAt,
|
||||
Provider: sess.Provider,
|
||||
Model: sess.Model,
|
||||
MessageCount: len(sess.Messages),
|
||||
})
|
||||
}
|
||||
sort.Slice(metas, func(i, j int) bool {
|
||||
return metas[i].UpdatedAt.After(metas[j].UpdatedAt)
|
||||
})
|
||||
return metas, nil
|
||||
}
|
||||
|
||||
// Delete removes a session file.
|
||||
func (s *Store) Delete(id string) error {
|
||||
path := s.filePath(id)
|
||||
if err := os.Remove(path); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Errorf("session %q not found", id)
|
||||
}
|
||||
return fmt.Errorf("delete session: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) filePath(id string) string {
|
||||
return filepath.Join(s.dir, id+".json")
|
||||
}
|
||||
|
||||
// GenerateID creates a random 8-character hex session ID.
|
||||
func GenerateID() string {
|
||||
b := make([]byte, 4)
|
||||
_, _ = rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// DefaultName derives a session name from the first user message.
|
||||
func DefaultName(messages []llm.Message) string {
|
||||
for _, msg := range messages {
|
||||
if msg.Role != llm.RoleUser {
|
||||
continue
|
||||
}
|
||||
text := strings.TrimSpace(msg.Content)
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
// Take first line, truncate at 40 chars at word boundary
|
||||
if idx := strings.IndexByte(text, '\n'); idx >= 0 {
|
||||
text = text[:idx]
|
||||
}
|
||||
text = strings.TrimSpace(text)
|
||||
if len(text) > 40 {
|
||||
text = text[:40]
|
||||
if idx := strings.LastIndexByte(text, ' '); idx > 20 {
|
||||
text = text[:idx]
|
||||
}
|
||||
text += "..."
|
||||
}
|
||||
return text
|
||||
}
|
||||
return ""
|
||||
}
|
||||
Reference in New Issue
Block a user