All checks were successful
Beta Release / beta (push) Successful in 2m23s
- Add ChatEngine for deduplicated chat logic (handlers_chat/shell_chat) - Add SendWithToolsStream for real-time streaming responses - Add /help, /plan, /export, /model commands in Studio - Fix XSS: sanitize HTML after markdown rendering - Add ConversationStoreMulti for multi-conversation support - Add Anthropic headers (x-api-key, anthropic-version) - Add fallback logging when provider switch occurs - Add API handler tests (handlers_test.go) - Polish Studio: max-height 200px, word-break on tool args - Update CLI version to show full info (version, go, platform) 🤖 Generated with Crush Assisted-by: MiniMax-M2.5 via Crush <crush@charm.land>
370 lines
8.1 KiB
Go
370 lines
8.1 KiB
Go
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)
|
|
|
|
go cs.saveCurrent() // Fire and forget
|
|
|
|
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
|
|
} |