feat(studio): improve context compression UI and provider display
All checks were successful
Beta Release / beta (push) Successful in 45s

- Add visual indicator when messages are collapsed (folder icon)
- Add animation to token bar during compression (pulse effect)
- Token bar becomes more compact after compression with "· compressé" label
- Button "voir plus" to expand collapsed messages
- Add 24px spacing at end of feed to avoid last message clipping
- Simplify provider display: show name only, badge "active" instead of key status
- Dashboard: show provider name only without model suffix
- Studio /model: show just provider name, not model
- Z.AI (GLM): mark as crush-only, no external quota check
- Claude: check /usr/bin/claude installation instead of API

💘 Generated with Crush
This commit is contained in:
Augustin
2026-04-23 21:21:59 +02:00
parent 92eb783df0
commit 6bad2948c5
4 changed files with 67 additions and 31 deletions

View File

@@ -477,25 +477,16 @@ func (s *Server) handleProvidersQuota(w http.ResponseWriter, r *http.Request) {
} }
} }
case "zai": case "zai":
if p.APIKey == "" { // Z.AI (GLM) est utilisé uniquement via Crush, pas de quota check externe
q.Error = "no API key" q.Healthy = true
results = append(results, q) q.Data = map[string]interface{}{"note": "crush-only"}
continue case "claude", "anthropic":
} // Claude Code n'a pas d'API externe, vérifier l'installation
req, _ := http.NewRequest("GET", "https://api.z.ai/api/monitor/usage/quota/limit", nil) claudePath := "/usr/bin/claude"
req.Header.Set("Authorization", "Bearer "+p.APIKey) if _, err := os.Stat(claudePath); err == nil {
req.Header.Set("Accept", "application/json") q.Healthy = true
resp, err := client.Do(req)
if err != nil {
q.Error = err.Error()
} else { } else {
body, _ := io.ReadAll(resp.Body) q.Error = "claude code not installed"
resp.Body.Close()
var data map[string]interface{}
if json.Unmarshal(body, &data) == nil {
q.Data = data
q.Healthy = true
}
} }
default: default:
q.Error = "quota not supported" q.Error = "quota not supported"

View File

@@ -353,8 +353,7 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm
<div className="provider-card-top"> <div className="provider-card-top">
<div className="provider-card-identity"> <div className="provider-card-identity">
<span className="provider-card-name">{p.name}</span> <span className="provider-card-name">{p.name}</span>
{p.apiKey && <span className="badge ok">{t('config.keyConfigured')}</span>} {p.active && <span className="badge ok" style={{ marginLeft: 6 }}>active</span>}
{!p.apiKey && <span className="badge error">{t('config.noKey')}</span>}
{isValidationTarget && validationStatus?.valid && <span className="badge ok">{t('config.keyValid')}</span>} {isValidationTarget && validationStatus?.valid && <span className="badge ok">{t('config.keyValid')}</span>}
{isValidationTarget && !validationStatus?.valid && <span className="badge error">{validationStatus?.error}</span>} {isValidationTarget && !validationStatus?.valid && <span className="badge error">{validationStatus?.error}</span>}
</div> </div>

View File

@@ -285,6 +285,8 @@ export default function Studio({ api }) {
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 [tokenInfo, setTokenInfo] = useState({ used: 0, max: 100000, summarizeAt: 80000 })
const [contextCollapsed, setContextCollapsed] = useState(false)
const [messagesCollapsed, setMessagesCollapsed] = useState(false)
const messagesEnd = useRef(null) const messagesEnd = useRef(null)
const textareaRef = useRef(null) const textareaRef = useRef(null)
const abortRef = useRef(null) const abortRef = useRef(null)
@@ -336,12 +338,18 @@ export default function Studio({ api }) {
const handleSummarize = useCallback(async () => { 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() }]) setMessages(prev => [...prev, { id: Date.now().toString(), role: 'system', content: 'Résumé de la conversation en cours...', time: new Date().toISOString() }])
setContextCollapsed('animating')
try { try {
const data = await api.summarizeChat() const data = await api.summarizeChat()
setTokenInfo(prev => ({ ...prev, used: data.tokens || 0 })) 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() }]) setTimeout(() => {
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(), compressed: true }])
setContextCollapsed(true)
setMessagesCollapsed(true)
}, 600)
} catch (err) { } catch (err) {
setMessages(prev => [...prev, { id: (Date.now() + 1).toString(), role: 'system', content: `Erreur de résumé: ${err.message}`, time: new Date().toISOString() }]) setMessages(prev => [...prev, { id: (Date.now() + 1).toString(), role: 'system', content: `Erreur de résumé: ${err.message}`, time: new Date().toISOString() }])
setContextCollapsed(false)
} }
}, [api]) }, [api])
@@ -396,7 +404,7 @@ export default function Studio({ api }) {
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)
const modelMsg = active ? `Provider: ${active.name}\nModèle: ${active.model}` : 'Aucun provider actif configuré' const modelMsg = active ? active.name : 'Aucun provider actif configuré'
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: modelMsg, time: new Date().toISOString() }]) setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: modelMsg, time: new Date().toISOString() }])
}).catch(() => { }).catch(() => {
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: 'Erreur: impossible de récupérer les providers', time: new Date().toISOString() }]) setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: 'Erreur: impossible de récupérer les providers', time: new Date().toISOString() }])
@@ -525,6 +533,34 @@ export default function Studio({ api }) {
} }
} }
const handleToggleCollapsed = useCallback(() => {
setMessagesCollapsed(prev => !prev)
}, [])
const renderMessages = () => {
if (messagesCollapsed && messages.length > 4) {
const visibleCount = 4
const hiddenCount = messages.length - visibleCount
return (
<>
{messages.slice(0, visibleCount).map(msg => (
<FeedItem key={msg.id} msg={msg} />
))}
<div className="feed-collapsed-messages" onClick={handleToggleCollapsed}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/>
</svg>
<span className="feed-collapsed-text">{hiddenCount} messages antérieurs compressés</span>
<span className="feed-collapsed-count">clic pour développer</span>
</div>
</>
)
}
return messages.map(msg => (
<FeedItem key={msg.id} msg={msg} />
))
}
if (!loaded) { if (!loaded) {
return ( return (
<div className="studio-feed-layout"> <div className="studio-feed-layout">
@@ -540,27 +576,31 @@ export default function Studio({ api }) {
return ( return (
<div className="studio-feed-layout"> <div className="studio-feed-layout">
<div className="studio-feed"> <div className="studio-feed">
{messages.map(msg => ( {renderMessages()}
<FeedItem key={msg.id} msg={msg} />
))}
{(streaming || streamThinking || loading || streamToolCalls.length > 0) && ( {(streaming || streamThinking || loading || streamToolCalls.length > 0) && (
<StreamingItem content={streaming} thinking={streamThinking} toolCalls={streamToolCalls} /> <StreamingItem content={streaming} thinking={streamThinking} toolCalls={streamToolCalls} />
)} )}
<div ref={messagesEnd} /> <div ref={messagesEnd} style={{ height: '24px' }} />
</div> </div>
<div className="studio-input-area"> <div className="studio-input-area">
<div className="studio-token-bar"> <div className={`studio-token-bar ${contextCollapsed === true ? 'compressed' : ''}`}>
<div className="studio-token-track"> <div className={`studio-token-track ${contextCollapsed === true ? 'compressed' : ''}`}>
<div <div
className={`studio-token-fill ${tokenInfo.used >= tokenInfo.summarizeAt ? 'warn' : ''}`} className={`studio-token-fill ${tokenInfo.used >= tokenInfo.summarizeAt ? 'warn' : ''} ${contextCollapsed === true ? 'compressed' : ''} ${contextCollapsed === 'animating' ? 'animating' : ''}`}
style={{ width: `${Math.min(100, (tokenInfo.used / tokenInfo.max) * 100)}%` }} style={{ width: `${Math.min(100, (tokenInfo.used / tokenInfo.max) * 100)}%` }}
/> />
</div> </div>
<span className="studio-token-text"> <span className={`studio-token-text ${contextCollapsed === true ? 'compressed' : ''}`}>
{(tokenInfo.used / 1000).toFixed(1)}k / {(tokenInfo.max / 1000).toFixed(0)}k tokens {(tokenInfo.used / 1000).toFixed(1)}k / {(tokenInfo.max / 1000).toFixed(0)}k tokens
{tokenInfo.used >= tokenInfo.summarizeAt && ' · résumé automatique déclenché'} {contextCollapsed === true && ' · compressé'}
{tokenInfo.used >= tokenInfo.summarizeAt && contextCollapsed !== true && ' · résumé auto.'}
</span> </span>
{contextCollapsed === true && (
<button className="ghost sm" onClick={handleToggleCollapsed} style={{ marginLeft: '8px', fontSize: '10px' }}>
voir plus
</button>
)}
</div> </div>
<div className="studio-input-row"> <div className="studio-input-row">
<textarea <textarea

View File

@@ -804,6 +804,12 @@ input::placeholder { color: var(--text-disabled); }
.studio-token-fill { height: 100%; background: var(--accent); border-radius: 2px; transition: width 0.4s, background 0.3s; } .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-fill.warn { background: var(--warning); }
.studio-token-fill.compressed { height: 2px; } .studio-token-fill.compressed { height: 2px; }
.studio-token-fill.animating { animation: compress-pulse 0.6s ease-in-out; }
@keyframes compress-pulse {
0% { height: 3px; opacity: 1; }
50% { height: 5px; opacity: 0.8; background: var(--accent-light); }
100% { height: 2px; opacity: 1; }
}
.studio-token-text { font-size: 10px; font-family: var(--font-mono); color: var(--text-tertiary); white-space: nowrap; } .studio-token-text { font-size: 10px; font-family: var(--font-mono); color: var(--text-tertiary); white-space: nowrap; }
.studio-token-text.compressed { font-size: 9px; } .studio-token-text.compressed { font-size: 9px; }
.studio-token-track.compressed { height: 2px; } .studio-token-track.compressed { height: 2px; }