217 lines
4.7 KiB
Go
217 lines
4.7 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"charm.land/bubbles/v2/list"
|
|
)
|
|
|
|
type SessionData struct {
|
|
ID string `json:"id"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
Title string `json:"title"`
|
|
Provider string `json:"provider"`
|
|
Model string `json:"model"`
|
|
Messages []message `json:"messages"`
|
|
History []string `json:"history"`
|
|
}
|
|
|
|
type sessionFile struct {
|
|
Session SessionData `json:"session"`
|
|
Deleted bool `json:"-"`
|
|
}
|
|
|
|
func currentSession(m *chatbot) *SessionData {
|
|
if m.sessionIdx < 0 || m.sessionIdx >= len(m.sessions) {
|
|
return nil
|
|
}
|
|
return &m.sessions[m.sessionIdx]
|
|
}
|
|
|
|
func dataDir() string {
|
|
dir := os.Getenv("XDG_DATA_HOME")
|
|
if dir == "" {
|
|
home, _ := os.UserHomeDir()
|
|
dir = filepath.Join(home, ".local", "share")
|
|
}
|
|
return filepath.Join(dir, "ludmila-ai-tui")
|
|
}
|
|
|
|
func sessionsDir() string {
|
|
return filepath.Join(dataDir(), "sessions")
|
|
}
|
|
|
|
func sessionPath(id string) string {
|
|
return filepath.Join(sessionsDir(), id+".json")
|
|
}
|
|
|
|
func ListSessions() ([]SessionData, error) {
|
|
dir := sessionsDir()
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil, nil
|
|
}
|
|
return nil, fmt.Errorf("read sessions dir: %w", err)
|
|
}
|
|
|
|
var sessions []SessionData
|
|
for _, entry := range entries {
|
|
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
|
|
continue
|
|
}
|
|
data, err := os.ReadFile(filepath.Join(dir, entry.Name()))
|
|
if err != nil {
|
|
continue
|
|
}
|
|
var file sessionFile
|
|
if err := json.Unmarshal(data, &file); err != nil {
|
|
continue
|
|
}
|
|
if file.Deleted {
|
|
continue
|
|
}
|
|
sessions = append(sessions, file.Session)
|
|
}
|
|
|
|
sort.Slice(sessions, func(i, j int) bool {
|
|
return sessions[i].UpdatedAt.After(sessions[j].UpdatedAt)
|
|
})
|
|
|
|
return sessions, nil
|
|
}
|
|
|
|
func SaveSession(s SessionData) error {
|
|
dir := sessionsDir()
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
return fmt.Errorf("create sessions dir: %w", err)
|
|
}
|
|
|
|
s.UpdatedAt = time.Now()
|
|
file := sessionFile{Session: s}
|
|
|
|
data, err := json.MarshalIndent(file, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("marshal session: %w", err)
|
|
}
|
|
|
|
if err := os.WriteFile(sessionPath(s.ID), data, 0o644); err != nil {
|
|
return fmt.Errorf("write session: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func DeleteSession(id string) error {
|
|
data, err := os.ReadFile(sessionPath(id))
|
|
if err != nil {
|
|
return fmt.Errorf("read session: %w", err)
|
|
}
|
|
|
|
var file sessionFile
|
|
if err := json.Unmarshal(data, &file); err != nil {
|
|
return fmt.Errorf("unmarshal session: %w", err)
|
|
}
|
|
|
|
file.Deleted = true
|
|
updatedData, err := json.MarshalIndent(file, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("marshal deleted session: %w", err)
|
|
}
|
|
|
|
return os.WriteFile(sessionPath(id), updatedData, 0o644)
|
|
}
|
|
|
|
func NewSessionData(provider, model string) SessionData {
|
|
now := time.Now()
|
|
|
|
return SessionData{
|
|
ID: now.Format("20060102_150405"),
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
Title: "New session",
|
|
Provider: provider,
|
|
Model: model,
|
|
}
|
|
}
|
|
|
|
func sessionTitle(s SessionData) string {
|
|
if s.Title != "" && s.Title != "New session" {
|
|
return s.Title
|
|
}
|
|
return "New session"
|
|
}
|
|
|
|
func (m *chatbot) saveCurrentSession() {
|
|
if cs := currentSession(m); cs != nil {
|
|
cs.UpdatedAt = time.Now()
|
|
cs.Messages = m.messages
|
|
cs.History = m.history
|
|
cs.Provider = m.currentProvider
|
|
cs.Model = m.currentModel
|
|
|
|
if len(cs.Messages) > 0 && (cs.Title == "" || cs.Title == "New session") {
|
|
cs.Title = sessionTitleFromMessages(cs.Messages)
|
|
}
|
|
|
|
m.sessions[m.sessionIdx] = *cs
|
|
_ = SaveSession(*cs)
|
|
}
|
|
}
|
|
|
|
func sessionTitleFromMessages(msgs []message) string {
|
|
if len(msgs) == 0 {
|
|
return "New session"
|
|
}
|
|
title := strings.TrimSpace(msgs[0].Content)
|
|
if idx := strings.IndexAny(title, ".\n"); idx > 0 {
|
|
title = title[:idx]
|
|
}
|
|
runes := []rune(title)
|
|
if len(runes) > 40 {
|
|
title = string(runes[:37]) + "..."
|
|
}
|
|
return title
|
|
}
|
|
|
|
type sessionItem struct {
|
|
session SessionData
|
|
active bool
|
|
}
|
|
|
|
func (s sessionItem) Title() string {
|
|
prefix := " "
|
|
if s.active {
|
|
prefix = "* "
|
|
}
|
|
return prefix + sessionTitle(s.session)
|
|
}
|
|
|
|
func (s sessionItem) Description() string {
|
|
parts := []string{
|
|
s.session.Provider + "/" + s.session.Model,
|
|
"(" + fmt.Sprintf("%d msgs", len(s.session.Messages)) + ")",
|
|
s.session.UpdatedAt.Format("Jan 02 15:04"),
|
|
}
|
|
return strings.Join(parts, " · ")
|
|
}
|
|
|
|
func (s sessionItem) FilterValue() string {
|
|
return s.session.Title + " " + s.session.Provider + " " + s.session.Model
|
|
}
|
|
|
|
func buildSessionItems(sessions []SessionData, activeIdx int) []list.Item {
|
|
items := make([]list.Item, len(sessions))
|
|
for i, s := range sessions {
|
|
items[i] = sessionItem{session: s, active: i == activeIdx}
|
|
}
|
|
return items
|
|
}
|