feat(chat): add auto-summarization with token tracking UI
All checks were successful
Beta Release / beta (push) Successful in 47s

Add /summarize command, token usage bar, and summary endpoint.
Add JSON tags to config/platform/scanner structs for API serialization.

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
This commit is contained in:
Augustin
2026-04-23 20:42:43 +02:00
parent bb03c9fe2d
commit e8f6dc4b4d
8 changed files with 139 additions and 69 deletions

View File

@@ -206,8 +206,11 @@ func (s *Server) handleChatHistory(w http.ResponseWriter, r *http.Request) {
} }
messages := s.convStore.Get() messages := s.convStore.Get()
writeJSON(w, map[string]interface{}{ writeJSON(w, map[string]interface{}{
"messages": messages, "messages": messages,
"tokens": s.convStore.ApproxTokenCount(), "tokens": s.convStore.ApproxTokenCount(),
"max_tokens": maxTokensApprox,
"summarize_at": summarizeThreshold,
"summary": s.convStore.GetSummary(),
}) })
} }
@@ -219,3 +222,16 @@ func (s *Server) handleChatClear(w http.ResponseWriter, r *http.Request) {
s.convStore.Clear() s.convStore.Clear()
writeJSON(w, map[string]string{"status": "ok"}) writeJSON(w, map[string]string{"status": "ok"})
} }
func (s *Server) handleChatSummarize(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
s.autoSummarize()
writeJSON(w, map[string]interface{}{
"status": "ok",
"tokens": s.convStore.ApproxTokenCount(),
"summary": s.convStore.GetSummary(),
})
}

View File

@@ -85,6 +85,7 @@ func (s *Server) routes() {
s.mux.HandleFunc("/api/chat", s.handleChat) s.mux.HandleFunc("/api/chat", s.handleChat)
s.mux.HandleFunc("/api/chat/history", s.handleChatHistory) s.mux.HandleFunc("/api/chat/history", s.handleChatHistory)
s.mux.HandleFunc("/api/chat/clear", s.handleChatClear) s.mux.HandleFunc("/api/chat/clear", s.handleChatClear)
s.mux.HandleFunc("/api/chat/summarize", s.handleChatSummarize)
s.mux.HandleFunc("/api/tool/call", s.handleToolCall) s.mux.HandleFunc("/api/tool/call", s.handleToolCall)
s.mux.HandleFunc("/api/tools/list", s.handleToolList) s.mux.HandleFunc("/api/tools/list", s.handleToolList)
s.mux.HandleFunc("/api/shell/chat", s.handleShellChat) s.mux.HandleFunc("/api/shell/chat", s.handleShellChat)

View File

@@ -12,66 +12,66 @@ import (
) )
type Profile struct { type Profile struct {
Name string `yaml:"name"` Name string `yaml:"name" json:"name"`
Pseudo string `yaml:"pseudo"` Pseudo string `yaml:"pseudo" json:"pseudo"`
Email string `yaml:"email"` Email string `yaml:"email" json:"email"`
Languages []string `yaml:"languages"` Languages []string `yaml:"languages" json:"languages"`
Preferences struct { Preferences struct {
Editor string `yaml:"editor"` Editor string `yaml:"editor" json:"editor"`
Shell string `yaml:"shell"` Shell string `yaml:"shell" json:"shell"`
Theme string `yaml:"theme"` Theme string `yaml:"theme" json:"theme"`
DefaultAI string `yaml:"default_ai"` DefaultAI string `yaml:"default_ai" json:"default_ai"`
AutoUpdate bool `yaml:"auto_update"` AutoUpdate bool `yaml:"auto_update" json:"auto_update"`
CheckOnStart bool `yaml:"check_on_start"` CheckOnStart bool `yaml:"check_on_start" json:"check_on_start"`
Language string `yaml:"language"` Language string `yaml:"language" json:"language"`
KeyboardLayout string `yaml:"keyboard_layout"` KeyboardLayout string `yaml:"keyboard_layout" json:"keyboard_layout"`
} `yaml:"preferences"` } `yaml:"preferences" json:"preferences"`
} }
type AIProvider struct { type AIProvider struct {
Name string `yaml:"name"` Name string `yaml:"name" json:"name"`
APIKey string `yaml:"api_key,omitempty"` APIKey string `yaml:"api_key,omitempty" json:"api_key,omitempty"`
BaseURL string `yaml:"base_url,omitempty"` BaseURL string `yaml:"base_url,omitempty" json:"base_url,omitempty"`
Model string `yaml:"model"` Model string `yaml:"model" json:"model"`
Active bool `yaml:"active"` Active bool `yaml:"active" json:"active"`
} }
type ToolConfig struct { type ToolConfig struct {
Name string `yaml:"name"` Name string `yaml:"name" json:"name"`
Installed bool `yaml:"installed"` Installed bool `yaml:"installed" json:"installed"`
Version string `yaml:"version"` Version string `yaml:"version" json:"version"`
AutoUpdate bool `yaml:"auto_update"` AutoUpdate bool `yaml:"auto_update" json:"auto_update"`
} }
type SSHConnection struct { type SSHConnection struct {
Name string `yaml:"name"` Name string `yaml:"name" json:"name"`
Host string `yaml:"host"` Host string `yaml:"host" json:"host"`
Port int `yaml:"port"` Port int `yaml:"port" json:"port"`
User string `yaml:"user"` User string `yaml:"user" json:"user"`
Password string `yaml:"password,omitempty"` Password string `yaml:"password,omitempty" json:"password,omitempty"`
KeyPath string `yaml:"key_path,omitempty"` KeyPath string `yaml:"key_path,omitempty" json:"key_path,omitempty"`
} }
type MuyueConfig struct { type MuyueConfig struct {
Version string `yaml:"version"` Version string `yaml:"version" json:"version"`
Profile Profile `yaml:"profile"` Profile Profile `yaml:"profile" json:"profile"`
AI struct { AI struct {
Providers []AIProvider `yaml:"providers"` Providers []AIProvider `yaml:"providers" json:"providers"`
} `yaml:"ai"` } `yaml:"ai" json:"ai"`
Tools []ToolConfig `yaml:"tools"` Tools []ToolConfig `yaml:"tools" json:"tools"`
BMAD struct { BMAD struct {
Installed bool `yaml:"installed"` Installed bool `yaml:"installed" json:"installed"`
Version string `yaml:"version"` Version string `yaml:"version" json:"version"`
Global bool `yaml:"global"` Global bool `yaml:"global" json:"global"`
} `yaml:"bmad"` } `yaml:"bmad" json:"bmad"`
Terminal struct { Terminal struct {
CustomPrompt bool `yaml:"custom_prompt"` CustomPrompt bool `yaml:"custom_prompt" json:"custom_prompt"`
PromptTheme string `yaml:"prompt_theme"` PromptTheme string `yaml:"prompt_theme" json:"prompt_theme"`
SSH []SSHConnection `yaml:"ssh"` SSH []SSHConnection `yaml:"ssh" json:"ssh"`
FontSize int `yaml:"font_size"` FontSize int `yaml:"font_size" json:"font_size"`
FontFamily string `yaml:"font_family"` FontFamily string `yaml:"font_family" json:"font_family"`
Theme string `yaml:"theme"` Theme string `yaml:"theme" json:"theme"`
} `yaml:"terminal"` } `yaml:"terminal" json:"terminal"`
} }
type TerminalTheme struct { type TerminalTheme struct {

View File

@@ -24,12 +24,12 @@ const (
) )
type SystemInfo struct { type SystemInfo struct {
OS OS OS OS `json:"os"`
Arch Arch Arch Arch `json:"arch"`
IsWSL bool IsWSL bool `json:"is_wsl"`
Shell string Shell string `json:"shell"`
Terminal string Terminal string `json:"terminal"`
PackageManager string PackageManager string `json:"package_manager"`
} }
func Detect() SystemInfo { func Detect() SystemInfo {

View File

@@ -14,27 +14,27 @@ import (
) )
type ToolStatus struct { type ToolStatus struct {
Name string `yaml:"name"` Name string `yaml:"name" json:"name"`
Installed bool `yaml:"installed"` Installed bool `yaml:"installed" json:"installed"`
Version string `yaml:"version"` Version string `yaml:"version" json:"version"`
Path string `yaml:"path"` Path string `yaml:"path" json:"path"`
Latest string `yaml:"latest"` Latest string `yaml:"latest" json:"latest"`
NeedsUpdate bool `yaml:"needs_update"` NeedsUpdate bool `yaml:"needs_update" json:"needs_update"`
Category string `yaml:"category"` Category string `yaml:"category" json:"category"`
} }
type RuntimeStatus struct { type RuntimeStatus struct {
Name string `yaml:"name"` Name string `yaml:"name" json:"name"`
Installed bool `yaml:"installed"` Installed bool `yaml:"installed" json:"installed"`
Version string `yaml:"version"` Version string `yaml:"version" json:"version"`
} }
type ScanResult struct { type ScanResult struct {
System platform.SystemInfo `yaml:"system"` System platform.SystemInfo `yaml:"system" json:"system"`
Tools []ToolStatus `yaml:"tools"` Tools []ToolStatus `yaml:"tools" json:"tools"`
Runtimes []RuntimeStatus `yaml:"runtimes"` Runtimes []RuntimeStatus `yaml:"runtimes" json:"runtimes"`
ShellSetup bool `yaml:"shell_setup"` ShellSetup bool `yaml:"shell_setup" json:"shell_setup"`
GitConfigured bool `yaml:"git_configured"` GitConfigured bool `yaml:"git_configured" json:"git_configured"`
} }
var ( var (

View File

@@ -56,6 +56,7 @@ const api = {
saveTerminalSettings: (settings) => request('/terminal/settings', { method: 'PUT', body: JSON.stringify(settings) }), saveTerminalSettings: (settings) => request('/terminal/settings', { method: 'PUT', body: JSON.stringify(settings) }),
getChatHistory: () => request('/chat/history'), getChatHistory: () => request('/chat/history'),
clearChat: () => request('/chat/clear', { method: 'POST' }), clearChat: () => request('/chat/clear', { method: 'POST' }),
summarizeChat: () => request('/chat/summarize', { method: 'POST' }),
sendChat: (message, stream = true, onChunk, signal) => { sendChat: (message, stream = true, onChunk, signal) => {
if (!stream) { if (!stream) {
return request('/chat', { method: 'POST', body: JSON.stringify({ message, stream: false }) }) return request('/chat', { method: 'POST', body: JSON.stringify({ message, stream: false }) })

View File

@@ -284,6 +284,7 @@ export default function Studio({ api }) {
const [streamThinking, setStreamThinking] = useState('') const [streamThinking, setStreamThinking] = useState('')
const [streamToolCalls, setStreamToolCalls] = useState([]) const [streamToolCalls, setStreamToolCalls] = useState([])
const [loaded, setLoaded] = useState(false) const [loaded, setLoaded] = useState(false)
const [tokenInfo, setTokenInfo] = useState({ used: 0, max: 100000, summarizeAt: 80000 })
const messagesEnd = useRef(null) const messagesEnd = useRef(null)
const textareaRef = useRef(null) const textareaRef = useRef(null)
const abortRef = useRef(null) const abortRef = useRef(null)
@@ -297,6 +298,11 @@ export default function Studio({ api }) {
{ id: 'welcome', role: 'system', content: t('studio.welcomeNew'), time: new Date().toISOString() }, { id: 'welcome', role: 'system', content: t('studio.welcomeNew'), time: new Date().toISOString() },
]) ])
} }
setTokenInfo({
used: data.tokens || 0,
max: data.max_tokens || 100000,
summarizeAt: data.summarize_at || 80000,
})
setLoaded(true) setLoaded(true)
}).catch(() => { }).catch(() => {
setMessages([ setMessages([
@@ -317,6 +323,28 @@ export default function Studio({ api }) {
} }
}, [input]) }, [input])
const refreshTokens = useCallback(async () => {
try {
const data = await api.getChatHistory()
setTokenInfo({
used: data.tokens || 0,
max: data.max_tokens || 100000,
summarizeAt: data.summarize_at || 80000,
})
} catch {}
}, [api])
const handleSummarize = useCallback(async () => {
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'system', content: 'Résumé de la conversation en cours...', time: new Date().toISOString() }])
try {
const data = await api.summarizeChat()
setTokenInfo(prev => ({ ...prev, used: data.tokens || 0 }))
setMessages(prev => [...prev, { id: (Date.now() + 1).toString(), role: 'system', content: '✓ Conversation résumée automatiquement. Le contexte a été compressé.', time: new Date().toISOString() }])
} catch (err) {
setMessages(prev => [...prev, { id: (Date.now() + 1).toString(), role: 'system', content: `Erreur de résumé: ${err.message}`, time: new Date().toISOString() }])
}
}, [api])
const handleClear = useCallback(async () => { const handleClear = useCallback(async () => {
try { try {
await api.clearChat() await api.clearChat()
@@ -341,6 +369,7 @@ export default function Studio({ api }) {
'## Commandes Studio', '## Commandes Studio',
'', '',
'- `/clear` - Effacer la conversation', '- `/clear` - Effacer la conversation',
'- `/summarize` - Résumer la conversation précédente',
'- `/help` - Afficher cette aide', '- `/help` - Afficher cette aide',
'- `/plan <objectif>` - Demander un plan structuré', '- `/plan <objectif>` - Demander un plan structuré',
'- `/export` - Exporter la conversation en Markdown', '- `/export` - Exporter la conversation en Markdown',
@@ -359,6 +388,11 @@ export default function Studio({ api }) {
return return
} }
if (text === '/summarize') {
handleSummarize()
return
}
if (text === '/model') { if (text === '/model') {
api.getProviders().then(data => { api.getProviders().then(data => {
const active = data.providers?.find(p => p.active) const active = data.providers?.find(p => p.active)
@@ -474,8 +508,9 @@ export default function Studio({ api }) {
setStreamThinking('') setStreamThinking('')
setStreamToolCalls([]) setStreamToolCalls([])
abortRef.current = null abortRef.current = null
refreshTokens()
} }
}, [input, loading, api, t, handleClear, streaming]) }, [input, loading, api, t, handleClear, streaming, refreshTokens, handleSummarize])
const handleStop = useCallback(() => { const handleStop = useCallback(() => {
if (abortRef.current) { if (abortRef.current) {
@@ -515,6 +550,18 @@ export default function Studio({ api }) {
</div> </div>
<div className="studio-input-area"> <div className="studio-input-area">
<div className="studio-token-bar">
<div className="studio-token-track">
<div
className={`studio-token-fill ${tokenInfo.used >= tokenInfo.summarizeAt ? 'warn' : ''}`}
style={{ width: `${Math.min(100, (tokenInfo.used / tokenInfo.max) * 100)}%` }}
/>
</div>
<span className="studio-token-text">
{(tokenInfo.used / 1000).toFixed(1)}k / {(tokenInfo.max / 1000).toFixed(0)}k tokens
{tokenInfo.used >= tokenInfo.summarizeAt && ' · résumé automatique déclenché'}
</span>
</div>
<div className="studio-input-row"> <div className="studio-input-row">
<textarea <textarea
ref={textareaRef} ref={textareaRef}
@@ -543,7 +590,7 @@ export default function Studio({ api }) {
)} )}
</div> </div>
<div className="studio-input-hint"> <div className="studio-input-hint">
{t('studio.inputHint')} &middot; /clear /help /plan /export /model {t('studio.inputHint')} · /clear /summarize /help /plan /export /model
</div> </div>
</div> </div>
</div> </div>

View File

@@ -796,6 +796,11 @@ input::placeholder { color: var(--text-disabled); }
.studio-thinking span:nth-child(3) { animation-delay: 0.3s; } .studio-thinking span:nth-child(3) { animation-delay: 0.3s; }
@keyframes bounce { 0%, 80%, 100% { transform: translateY(0); opacity: 0.4; } 40% { transform: translateY(-6px); opacity: 1; } } @keyframes bounce { 0%, 80%, 100% { transform: translateY(0); opacity: 0.4; } 40% { transform: translateY(-6px); opacity: 1; } }
.studio-input-area { padding: 12px 20px 8px; border-top: 1px solid var(--border); background: var(--bg-surface); } .studio-input-area { padding: 12px 20px 8px; border-top: 1px solid var(--border); background: var(--bg-surface); }
.studio-token-bar { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
.studio-token-track { flex: 1; height: 3px; background: var(--bg-input); border-radius: 2px; overflow: hidden; }
.studio-token-fill { height: 100%; background: var(--accent); border-radius: 2px; transition: width 0.4s, background 0.3s; }
.studio-token-fill.warn { background: var(--warning); }
.studio-token-text { font-size: 10px; font-family: var(--font-mono); color: var(--text-tertiary); white-space: nowrap; }
.studio-input-row { display: flex; gap: 8px; align-items: flex-end; } .studio-input-row { display: flex; gap: 8px; align-items: flex-end; }
.studio-input-row textarea { .studio-input-row textarea {
flex: 1; resize: none; min-height: 42px; max-height: 200px; padding: 10px 14px; flex: 1; resize: none; min-height: 42px; max-height: 200px; padding: 10px 14px;