Files
MuyueWorkspace/internal/api/conversation.go
Augustin 3740454201
Some checks failed
Stable Release / stable (push) Failing after 33s
feat: agent concurrency, conversation summaries, AI tools config, UI polish
- Agent slot limiter for concurrent tool execution
- Conversation summarization with soft-delete (MarkSummarized)
- ANSI stripping in terminal tool output
- Configurable crush-run timeout (default 600s, max 900s)
- Starship theme refactor, AI tools config grid, system update UI
- Streaming segments refactor, summarized messages block in feed
- CSS: headings, scrollbars, tool cards, summary block styles
- i18n additions (en+fr) for tools, updates, config

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-27 00:01:36 +02:00

333 lines
7.3 KiB
Go

package api
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"time"
"unicode/utf8"
"github.com/muyue/muyue/internal/config"
)
const contextWindowTokens = 150000
const summarizeRatio = 0.80
const charsPerToken = 4
func extractDisplayContent(role, content string) string {
if role != "assistant" {
return content
}
var parsed struct {
Content string `json:"content"`
ToolCalls []struct {
Name string `json:"name"`
Args string `json:"args"`
} `json:"tool_calls"`
ToolResults []struct {
Name string `json:"name"`
Result string `json:"result"`
} `json:"tool_results"`
}
if err := json.Unmarshal([]byte(content), &parsed); err != nil {
return content
}
var sb strings.Builder
if parsed.Content != "" {
sb.WriteString(parsed.Content)
}
for _, tc := range parsed.ToolCalls {
sb.WriteString("\n[")
sb.WriteString(tc.Name)
sb.WriteString("] ")
sb.WriteString(tc.Args)
}
for _, tr := range parsed.ToolResults {
sb.WriteString("\n[result")
if tr.Name != "" {
sb.WriteString(":")
sb.WriteString(tr.Name)
}
sb.WriteString("] ")
sb.WriteString(tr.Result)
}
return sb.String()
}
type FeedMessage struct {
ID string `json:"id"`
Role string `json:"role"`
Content string `json:"content"`
Time string `json:"time"`
Images []string `json:"images,omitempty"`
Summarized bool `json:"summarized,omitempty"`
}
type Conversation struct {
Messages []FeedMessage `json:"messages"`
Summary string `json:"summary,omitempty"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
type ConversationStore struct {
mu sync.RWMutex
path string
conv *Conversation
}
type TokenCount struct {
total int
byRole map[string]int
byMessage int
}
type SearchResult struct {
ID string `json:"id"`
Role string `json:"role"`
Content string `json:"content"`
Time string `json:"time"`
}
func NewConversationStore() *ConversationStore {
dir, err := config.ConfigDir()
if err != nil {
dir = "/tmp/muyue"
}
path := filepath.Join(dir, "conversation.json")
cs := &ConversationStore{path: path}
cs.load()
return cs
}
func (cs *ConversationStore) load() {
data, err := os.ReadFile(cs.path)
if err != nil {
cs.conv = &Conversation{
Messages: []FeedMessage{},
CreatedAt: time.Now().Format(time.RFC3339),
UpdatedAt: time.Now().Format(time.RFC3339),
}
return
}
var conv Conversation
if err := json.Unmarshal(data, &conv); err != nil {
cs.conv = &Conversation{
Messages: []FeedMessage{},
CreatedAt: time.Now().Format(time.RFC3339),
UpdatedAt: time.Now().Format(time.RFC3339),
}
return
}
if conv.Messages == nil {
conv.Messages = []FeedMessage{}
}
cs.conv = &conv
}
func (cs *ConversationStore) save() error {
cs.conv.UpdatedAt = time.Now().Format(time.RFC3339)
data, err := json.MarshalIndent(cs.conv, "", " ")
if err != nil {
return err
}
dir := filepath.Dir(cs.path)
os.MkdirAll(dir, 0755)
return os.WriteFile(cs.path, data, 0600)
}
func (cs *ConversationStore) Get() []FeedMessage {
cs.mu.RLock()
defer cs.mu.RUnlock()
out := make([]FeedMessage, len(cs.conv.Messages))
copy(out, cs.conv.Messages)
return out
}
func (cs *ConversationStore) GetSummary() string {
cs.mu.RLock()
defer cs.mu.RUnlock()
return cs.conv.Summary
}
func (cs *ConversationStore) Add(role, content string) FeedMessage {
cs.mu.Lock()
defer cs.mu.Unlock()
msg := FeedMessage{
ID: generateMsgID(),
Role: role,
Content: content,
Time: time.Now().Format(time.RFC3339),
}
cs.conv.Messages = append(cs.conv.Messages, msg)
cs.save()
return msg
}
func (cs *ConversationStore) AddWithImages(role, content string, imageIDs []string) FeedMessage {
cs.mu.Lock()
defer cs.mu.Unlock()
msg := FeedMessage{
ID: generateMsgID(),
Role: role,
Content: content,
Time: time.Now().Format(time.RFC3339),
Images: imageIDs,
}
cs.conv.Messages = append(cs.conv.Messages, msg)
cs.save()
return msg
}
func (cs *ConversationStore) Clear() {
cs.mu.Lock()
defer cs.mu.Unlock()
var imageIDs []string
for _, m := range cs.conv.Messages {
imageIDs = append(imageIDs, m.Images...)
}
cs.conv.Messages = []FeedMessage{}
cs.conv.Summary = ""
cs.conv.CreatedAt = time.Now().Format(time.RFC3339)
cs.conv.UpdatedAt = time.Now().Format(time.RFC3339)
cs.save()
go cleanupImages(imageIDs)
}
func (cs *ConversationStore) SetSummary(summary string) {
cs.mu.Lock()
defer cs.mu.Unlock()
cs.conv.Summary = summary
cs.save()
}
func (cs *ConversationStore) MarkSummarized(upToIndex int) {
cs.mu.Lock()
defer cs.mu.Unlock()
if upToIndex <= 0 || upToIndex >= len(cs.conv.Messages) {
return
}
for i := 0; i < upToIndex; i++ {
cs.conv.Messages[i].Summarized = true
}
cs.save()
}
func (cs *ConversationStore) ApproxTokenCount() int {
return cs.ApproxTokenCountDetailed().total
}
func (cs *ConversationStore) ApproxTokenCountDetailed() TokenCount {
cs.mu.RLock()
defer cs.mu.RUnlock()
result := TokenCount{
byRole: make(map[string]int),
}
for _, m := range cs.conv.Messages {
if m.Role == "system" || m.Summarized {
continue
}
count := utf8.RuneCountInString(extractDisplayContent(m.Role, m.Content)) / charsPerToken
result.byMessage += count
result.byRole[m.Role] += count
}
if cs.conv.Summary != "" {
result.total = result.byMessage + utf8.RuneCountInString(cs.conv.Summary)/charsPerToken
} else {
result.total = result.byMessage
}
return result
}
func (cs *ConversationStore) NeedsSummarization() bool {
return cs.ApproxTokenCount() > int(float64(contextWindowTokens)*summarizeRatio)
}
func (cs *ConversationStore) Search(query string) []SearchResult {
cs.mu.RLock()
defer cs.mu.RUnlock()
var results []SearchResult
queryLower := strings.ToLower(query)
for _, msg := range cs.conv.Messages {
if strings.Contains(strings.ToLower(msg.Content), queryLower) {
results = append(results, SearchResult{
ID: msg.ID,
Role: msg.Role,
Content: msg.Content,
Time: msg.Time,
})
}
}
return results
}
func (cs *ConversationStore) ExportMarkdown() string {
cs.mu.RLock()
defer cs.mu.RUnlock()
var sb strings.Builder
sb.WriteString("# Conversation Export\n\n")
sb.WriteString(fmt.Sprintf("Exporté le: %s\n\n", time.Now().Format(time.RFC3339)))
if cs.conv.Summary != "" {
sb.WriteString("## Résumé\n\n")
sb.WriteString(cs.conv.Summary)
sb.WriteString("\n\n---\n\n")
}
sb.WriteString("## Messages\n\n")
for i, msg := range cs.conv.Messages {
roleLabel := msg.Role
if roleLabel == "user" {
roleLabel = "👤 Utilisateur"
} else if roleLabel == "assistant" {
roleLabel = "🤖 Assistant"
} else if roleLabel == "system" {
roleLabel = "⚙️ Système"
}
timestamp := ""
if msg.Time != "" {
if t, err := time.Parse(time.RFC3339, msg.Time); err == nil {
timestamp = t.Format("2006-01-02 15:04")
}
}
sb.WriteString(fmt.Sprintf("### [%d] %s (%s)\n\n", i+1, roleLabel, timestamp))
sb.WriteString(msg.Content)
sb.WriteString("\n\n---\n\n")
}
return sb.String()
}
func (cs *ConversationStore) ExportJSON() string {
cs.mu.RLock()
defer cs.mu.RUnlock()
data, err := json.MarshalIndent(cs.conv, "", " ")
if err != nil {
return "{}"
}
return string(data)
}
func generateMsgID() string {
return time.Now().Format("20060102150405.000") + "-" + fmt.Sprintf("%d", time.Now().UnixNano())
}