package api import ( "encoding/json" "fmt" "os" "path/filepath" "sync" "time" "github.com/google/uuid" "github.com/muyue/muyue/internal/config" ) // ConversationMeta represents metadata for a conversation (used for listing). type ConversationMeta struct { ID string `json:"id"` Title string `json:"title"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` MessageCount int `json:"message_count"` } // ConversationStoreMulti manages multiple conversations. type ConversationStoreMulti struct { mu sync.RWMutex dir string currentID string conversations map[string]*Conversation } func NewConversationStoreMulti() *ConversationStoreMulti { dir, err := config.ConfigDir() if err != nil { dir = "/tmp/muyue" } dir = filepath.Join(dir, "conversations") cs := &ConversationStoreMulti{ dir: dir, conversations: make(map[string]*Conversation), } cs.loadIndex() return cs } func (cs *ConversationStoreMulti) loadIndex() { os.MkdirAll(cs.dir, 0755) // Load index file if exists indexPath := filepath.Join(cs.dir, "index.json") data, err := os.ReadFile(indexPath) if err != nil { // Create default conversation cs.createDefault() return } var index struct { CurrentID string `json:"current_id"` Conversations []ConversationMeta `json:"conversations"` } if err := json.Unmarshal(data, &index); err != nil { cs.createDefault() return } cs.currentID = index.CurrentID if cs.currentID == "" { cs.createDefault() return } // Load all conversations for _, meta := range index.Conversations { convPath := filepath.Join(cs.dir, fmt.Sprintf("conv_%s.json", meta.ID)) data, err := os.ReadFile(convPath) if err != nil { continue } var conv Conversation if err := json.Unmarshal(data, &conv); err != nil { continue } cs.conversations[meta.ID] = &conv } // Ensure current conversation exists if _, ok := cs.conversations[cs.currentID]; !ok { cs.createDefault() } } func (cs *ConversationStoreMulti) createDefault() { cs.currentID = uuid.New().String() cs.conversations[cs.currentID] = &Conversation{ Messages: []FeedMessage{}, CreatedAt: time.Now().Format(time.RFC3339), UpdatedAt: time.Now().Format(time.RFC3339), } cs.saveIndex() } func (cs *ConversationStoreMulti) saveIndex() error { var metas []ConversationMeta for id, conv := range cs.conversations { title := "Nouvelle conversation" if len(conv.Messages) > 0 { // Use first user message as title for _, m := range conv.Messages { if m.Role == "user" { if len(m.Content) > 50 { title = m.Content[:50] + "..." } else { title = m.Content } break } } } metas = append(metas, ConversationMeta{ ID: id, Title: title, CreatedAt: conv.CreatedAt, UpdatedAt: conv.UpdatedAt, MessageCount: len(conv.Messages), }) } index := struct { CurrentID string `json:"current_id"` Conversations []ConversationMeta `json:"conversations"` }{ CurrentID: cs.currentID, Conversations: metas, } data, err := json.MarshalIndent(index, "", " ") if err != nil { return err } return os.WriteFile(filepath.Join(cs.dir, "index.json"), data, 0600) } func (cs *ConversationStoreMulti) saveCurrent() error { conv, ok := cs.conversations[cs.currentID] if !ok { return fmt.Errorf("no current conversation") } conv.UpdatedAt = time.Now().Format(time.RFC3339) data, err := json.MarshalIndent(conv, "", " ") if err != nil { return err } convPath := filepath.Join(cs.dir, fmt.Sprintf("conv_%s.json", cs.currentID)) if err := os.WriteFile(convPath, data, 0600); err != nil { return err } return cs.saveIndex() } // Current returns the current conversation store. func (cs *ConversationStoreMulti) Current() *ConversationStore { cs.mu.RLock() defer cs.mu.RUnlock() conv, ok := cs.conversations[cs.currentID] if !ok { return &ConversationStore{ conv: &Conversation{ Messages: []FeedMessage{}, CreatedAt: time.Now().Format(time.RFC3339), UpdatedAt: time.Now().Format(time.RFC3339), }, } } return &ConversationStore{ conv: conv, } } // Get returns the current conversation messages. func (cs *ConversationStoreMulti) Get() []FeedMessage { cs.mu.RLock() defer cs.mu.RUnlock() conv, ok := cs.conversations[cs.currentID] if !ok { return []FeedMessage{} } out := make([]FeedMessage, len(conv.Messages)) copy(out, conv.Messages) return out } // Add adds a message to the current conversation. func (cs *ConversationStoreMulti) Add(role, content string) FeedMessage { cs.mu.Lock() defer cs.mu.Unlock() conv, ok := cs.conversations[cs.currentID] if !ok { cs.currentID = uuid.New().String() conv = &Conversation{ Messages: []FeedMessage{}, CreatedAt: time.Now().Format(time.RFC3339), UpdatedAt: time.Now().Format(time.RFC3339), } cs.conversations[cs.currentID] = conv } msg := FeedMessage{ ID: generateMsgID(), Role: role, Content: content, Time: time.Now().Format(time.RFC3339), } conv.Messages = append(conv.Messages, msg) cs.saveCurrent() return msg } // Clear clears the current conversation. func (cs *ConversationStoreMulti) Clear() { cs.mu.Lock() defer cs.mu.Unlock() conv, ok := cs.conversations[cs.currentID] if !ok { return } conv.Messages = []FeedMessage{} conv.Summary = "" conv.CreatedAt = time.Now().Format(time.RFC3339) conv.UpdatedAt = time.Now().Format(time.RFC3339) cs.saveCurrent() } // List returns all conversations. func (cs *ConversationStoreMulti) List() []ConversationMeta { cs.mu.RLock() defer cs.mu.RUnlock() var metas []ConversationMeta for id, conv := range cs.conversations { title := "Nouvelle conversation" if len(conv.Messages) > 0 { for _, m := range conv.Messages { if m.Role == "user" { if len(m.Content) > 50 { title = m.Content[:50] + "..." } else { title = m.Content } break } } } metas = append(metas, ConversationMeta{ ID: id, Title: title, CreatedAt: conv.CreatedAt, UpdatedAt: conv.UpdatedAt, MessageCount: len(conv.Messages), }) } return metas } // Create creates a new conversation and switches to it. func (cs *ConversationStoreMulti) Create() string { cs.mu.Lock() defer cs.mu.Unlock() id := uuid.New().String() cs.conversations[id] = &Conversation{ Messages: []FeedMessage{}, CreatedAt: time.Now().Format(time.RFC3339), UpdatedAt: time.Now().Format(time.RFC3339), } cs.currentID = id cs.saveIndex() return id } // Switch switches to a different conversation. func (cs *ConversationStoreMulti) Switch(id string) error { cs.mu.Lock() defer cs.mu.Unlock() if _, ok := cs.conversations[id]; !ok { return fmt.Errorf("conversation not found: %s", id) } cs.currentID = id cs.saveIndex() return nil } // GetByID returns a conversation by ID. func (cs *ConversationStoreMulti) GetByID(id string) (*Conversation, error) { cs.mu.RLock() defer cs.mu.RUnlock() conv, ok := cs.conversations[id] if !ok { return nil, fmt.Errorf("conversation not found: %s", id) } return conv, nil } // Delete deletes a conversation. func (cs *ConversationStoreMulti) Delete(id string) error { cs.mu.Lock() defer cs.mu.Unlock() if _, ok := cs.conversations[id]; !ok { return fmt.Errorf("conversation not found: %s", id) } delete(cs.conversations, id) // Delete file convPath := filepath.Join(cs.dir, fmt.Sprintf("conv_%s.json", id)) os.Remove(convPath) // If deleted current, switch to another if cs.currentID == id { if len(cs.conversations) > 0 { for newID := range cs.conversations { cs.currentID = newID break } } else { // Create new default cs.currentID = uuid.New().String() cs.conversations[cs.currentID] = &Conversation{ Messages: []FeedMessage{}, CreatedAt: time.Now().Format(time.RFC3339), UpdatedAt: time.Now().Format(time.RFC3339), } } } cs.saveIndex() return nil } // CurrentID returns the current conversation ID. func (cs *ConversationStoreMulti) CurrentID() string { cs.mu.RLock() defer cs.mu.RUnlock() return cs.currentID }