All checks were successful
PR Check / check (pull_request) Successful in 57s
Audit corrections (security, concurrency, stability): - chat_engine: bound resp.Choices[0] access, release tool slot per-iteration - conversation_multi: synchronous save under existing lock (was racy fire-and-forget) - workflow/engine: short-circuit on failed deps (no more infinite busy-wait); track failed/skipped status - handlers_workflow: rune-aware truncate for plan goal (UTF-8 safe) - server: CORS limited to localhost origins (was wildcard) - handlers_info / terminal: mask API keys and SSH passwords as "***" in GET responses; preserve stored secret if "***" sent on update - terminal: sshpass uses -e + SSHPASS env var (was both -p and -e) - handlers_chat: MaxBytesReader 50 MB on /api/chat - image_cache: 10 MB cap per image - handlers_config: font size <= 72; profile-save unmarshal errors propagated - handlers_info: /lsp/auto-install ProjectDir restricted to user home - Shell.jsx: parenthesized resize-condition (operator precedence) - orchestrator_test: CleanAIResponse capitalization (fixes failing vet) New features: - platform: detect OS name (Debian, Ubuntu, Windows 11, macOS X.Y) and inject in Studio system prompt next to the date - agents: default timeout 30 min for crush_run/claude_run (cap also 30 min) - agents: new cwd, wsl_distro, wsl_user params; on Windows hosts launch via "wsl -d <distro> -u <user> --cd <cwd> --" - agents: new claude_run tool (mirror of crush_run for Claude Code CLI) - terminal: list installed WSL distros individually in new-tab menu (Windows only) - studio: system prompt rewritten around BMAD-METHOD personas + mandatory delegation template - studio: "Réflexion avancée" toggle — inactive provider produces a preliminary report injected as [RAPPORT PRÉALABLE] context for the active provider - studio: "Historique compressé" toggle — collapses past tool calls to last action only, with "Tout afficher" expansion
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)
|
|
|
|
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
|
|
} |