feat: terminal sudo blocking, token tracking, mermaid & consumption UI
All checks were successful
Beta Release / beta (push) Successful in 1m3s

- Block sudo/doas commands when not running as root
- Add real token counting from API responses
- Track and display consumption by provider/day
- Add Mermaid diagram rendering in Shell and Studio
- Add copy-to-clipboard buttons for code blocks
- Support tables in AI message rendering
- Update system prompt with context (date, time, root status)

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
This commit is contained in:
Augustin
2026-04-26 12:43:15 +02:00
parent 0830e64ae6
commit cb3d35756a
21 changed files with 2166 additions and 208 deletions

View File

@@ -1,5 +1,8 @@
import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
import { useI18n } from '../i18n'
import mermaid from 'mermaid'
mermaid.initialize({ startOnLoad: false, theme: 'dark', securityLevel: 'loose', fontFamily: 'var(--font-mono)' })
const RANKS = {
commandant: { label: 'Commandant', short: 'CDT', color: '#FFD740' },
@@ -64,7 +67,16 @@ function renderContent(text) {
function formatText(text) {
let html = text
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
html = html.replace(/^(\|.+\|)\n(\|[\s\-:|]+\|)\n((?:\|.+\|\n?)+)/gm, (match, headerRow, sepRow, bodyRows) => {
const headers = headerRow.split('|').filter(c => c.trim() !== '').map(c => `<th>${c.trim()}</th>`).join('')
const rows = bodyRows.trim().split('\n').map(row => {
const cells = row.split('|').filter(c => c.trim() !== '').map(c => `<td>${c.trim()}</td>`).join('')
return `<tr>${cells}</tr>`
}).join('')
return `<table><thead><tr>${headers}</tr></thead><tbody>${rows}</tbody></table>`
})
html = html
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/`([^`]+)`/g, '<code class="inline-code">$1</code>')
@@ -74,15 +86,15 @@ function formatText(text) {
.replace(/^\s*[-*] (.+)$/gm, '<div class="msg-bullet">• $1</div>')
.replace(/^\s*(\d+)[.)] (.+)$/gm, '<div class="msg-step"><span class="msg-step-num">$1</span> $2</div>')
.replace(/\n/g, '<br/>')
html = html
.replace(/<br\/>\s*<br\/>/g, '<br/>')
.replace(/<br\/>\s*(<h[234]|<div class="msg-)/g, '$1')
.replace(/(<\/h[234]|<\/div>)\s*<br\/>/g, '$1')
.replace(/<br\/>\s*(<h[234]|<div class="msg-|<table)/g, '$1')
.replace(/(<\/h[234]|<\/div>|<\/table>)\s*<br\/>/g, '$1')
.replace(/\s+on\w+=["'][^"']*["']/gi, '')
.replace(/javascript:/gi, '')
.replace(/data:/gi, '')
return html
}
@@ -168,10 +180,69 @@ function ToolCallBlock({ call, result }) {
)
}
let mermaidIdCounter = 0
function MermaidBlock({ code }) {
const ref = useRef(null)
const [svg, setSvg] = useState('')
const [error, setError] = useState(false)
useEffect(() => {
let cancelled = false
const id = `studio-mermaid-${++mermaidIdCounter}`
mermaid.render(id, code).then(({ svg }) => {
if (!cancelled) setSvg(svg)
}).catch(() => {
if (!cancelled) setError(true)
})
return () => { cancelled = true }
}, [code])
if (error) return <pre className="studio-mermaid-error">{code}</pre>
if (!svg) return <div className="studio-mermaid-loading">Chargement...</div>
return <div className="studio-mermaid-container" ref={ref} dangerouslySetInnerHTML={{ __html: svg }} />
}
function CodeBlockWithCopy({ part, index, copiedIdx, setCopiedIdx }) {
if (part.lang === 'mermaid') {
return (
<div className="studio-code-block">
<div className="studio-code-header">
<span className="studio-code-lang">mermaid</span>
<button className={`studio-copy-btn ${copiedIdx === index ? 'copied' : ''}`} onClick={() => {
navigator.clipboard.writeText(part.content)
setCopiedIdx(index)
setTimeout(() => setCopiedIdx(null), 1500)
}}>
{copiedIdx === index ? 'Copie!' : 'Copier'}
</button>
</div>
<MermaidBlock code={part.content} />
</div>
)
}
return (
<div className="studio-code-block">
<div className="studio-code-header">
{part.lang && <span className="studio-code-lang">{part.lang}</span>}
<button className={`studio-copy-btn ${copiedIdx === index ? 'copied' : ''}`} onClick={() => {
navigator.clipboard.writeText(part.content)
setCopiedIdx(index)
setTimeout(() => setCopiedIdx(null), 1500)
}}>
{copiedIdx === index ? 'Copie!' : 'Copier'}
</button>
</div>
<pre><code>{part.content}</code></pre>
</div>
)
}
function FeedItem({ msg }) {
const isUser = msg.role === 'user'
const isSystem = msg.role === 'system'
const rank = getRank(msg.role)
const [copiedIdx, setCopiedIdx] = useState(null)
const timeStr = msg.time ? new Date(msg.time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : ''
@@ -226,10 +297,7 @@ function FeedItem({ msg }) {
<div className="feed-content">
{renderContent(cleanContent).map((part, i) =>
part.type === 'code' ? (
<div key={i} className="studio-code-block">
{part.lang && <div className="studio-code-lang">{part.lang}</div>}
<pre><code>{part.content}</code></pre>
</div>
<CodeBlockWithCopy key={i} part={part} index={i} copiedIdx={copiedIdx} setCopiedIdx={setCopiedIdx} />
) : (
<span key={i} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} />
)
@@ -245,6 +313,7 @@ function StreamingItem({ content, thinking, toolCalls }) {
const rank = RANKS.general
const cleanContent = content.replace(/<think[^>]*>[\s\S]*?<\/think>/gi, '')
const hasToolCalls = toolCalls && toolCalls.length > 0
const [copiedIdx, setCopiedIdx] = useState(null)
const renderedContent = useMemo(() => {
if (!cleanContent) return []
@@ -281,10 +350,7 @@ function StreamingItem({ content, thinking, toolCalls }) {
<div className="feed-content">
{renderedContent.map((part, i) =>
part.type === 'code' ? (
<div key={i} className="studio-code-block">
{part.lang && <div className="studio-code-lang">{part.lang}</div>}
<pre><code>{part.content}</code></pre>
</div>
<CodeBlockWithCopy key={i} part={part} index={i} copiedIdx={copiedIdx} setCopiedIdx={setCopiedIdx} />
) : (
<span key={i} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} />
)
@@ -309,6 +375,7 @@ export default function Studio({ api }) {
const [tokenInfo, setTokenInfo] = useState({ used: 0, max: 100000, summarizeAt: 80000 })
const [contextCollapsed, setContextCollapsed] = useState(false)
const [messagesCollapsed, setMessagesCollapsed] = useState(false)
const [sudoModal, setSudoModal] = useState(null)
const messagesEnd = useRef(null)
const feedRef = useRef(null)
const textareaRef = useRef(null)
@@ -404,7 +471,7 @@ export default function Studio({ api }) {
const text = input.trim()
setInput('')
const isSlashCommand = (t) => /^\/(clear|help|summarize|export|model(?:\s+\S+)?|plan\s+.+)$/.test(t)
const isSlashCommand = (t) => /^\/(clear|help|summarize|model(?:\s+\S+)?)$/.test(t)
if (text.startsWith('/') && !isSlashCommand(text)) {
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'user', content: text, time: new Date().toISOString() }])
@@ -424,19 +491,8 @@ export default function Studio({ api }) {
'- `/clear` - Effacer la conversation',
'- `/summarize` - Résumer la conversation précédente',
'- `/help` - Afficher cette aide',
'- `/plan <objectif>` - Demander un plan structuré',
'- `/export` - Exporter la conversation en Markdown',
'- `/model` - Afficher le provider et modèle actifs',
'- `/model change` - Basculer entre MiniMax et ZAI',
'',
'## Tools disponibles',
'- Terminal - Exécuter des commandes',
'- read_file - Lire des fichiers',
'- list_files - Lister des fichiers',
'- search_files - Rechercher des fichiers',
'- grep_content - Rechercher dans le contenu',
'- get_config - Lire la configuration',
'- web_fetch - Récupérer une page web',
'- `/model change` - Basculer entre MiniMax et MiMo',
].join('\n')
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: helpMsg, time: new Date().toISOString() }])
return
@@ -481,31 +537,6 @@ export default function Studio({ api }) {
return
}
if (text.startsWith('/plan ')) {
const objective = text.slice(6).trim()
if (!objective) {
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: 'Usage: `/plan <objectif>`\nEx: `/plan créer un fichier de test`', time: new Date().toISOString() }])
return
}
setInput(`Crée un plan structuré en étapes numérotées pour: ${objective}. Chaque étape devrait avoir une estimation de complexité et de temps.`)
handleSend()
return
}
if (text === '/export') {
api.getChatHistory().then(data => {
let markdown = '# Conversation Export\n\n'
data.messages?.forEach((msg, i) => {
const roleLabel = msg.role === 'user' ? '👤' : (msg.role === 'assistant' ? '🤖' : '⚙️')
markdown += `## [${i + 1}] ${roleLabel} ${msg.role}\n${msg.content}\n\n---\n\n`
})
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: 'Conversation exportée:\n```markdown\n' + markdown + '```', time: new Date().toISOString() }])
}).catch(() => {
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: 'Erreur: impossible d\'exporter la conversation', time: new Date().toISOString() }])
})
return
}
const userMsg = { id: Date.now().toString(), role: 'user', content: text, time: new Date().toISOString() }
setMessages(prev => [...prev, userMsg])
setLoading(true)
@@ -537,6 +568,9 @@ export default function Studio({ api }) {
return
}
if (event && event.tool_result) {
if (event.tool_result.sudo_blocked) {
setSudoModal({ command: event.tool_result.command || event.tool_result.content })
}
const idx = toolCalls.findIndex(tc => tc.call && tc.call.tool_call_id === event.tool_result.tool_call_id)
if (idx >= 0) {
toolCalls[idx] = { ...toolCalls[idx], result: event.tool_result }
@@ -602,7 +636,7 @@ export default function Studio({ api }) {
}
}, [])
const COMMANDS = ['/clear', '/summarize', '/help', '/plan', '/export', '/model', '/model change']
const COMMANDS = ['/clear', '/summarize', '/help', '/model', '/model change']
const handleKeyDown = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
@@ -744,9 +778,25 @@ export default function Studio({ api }) {
)}
</div>
<div className="studio-input-hint">
{t('studio.inputHint')} · /clear /summarize /help /plan /export /model /model change
{t('studio.inputHint')} · /clear /summarize /help /model
</div>
</div>
{sudoModal && (
<div className="shell-modal-overlay" onClick={() => setSudoModal(null)}>
<div className="shell-modal" onClick={e => e.stopPropagation()}>
<div className="shell-modal-header">Commande bloquée</div>
<div className="shell-modal-body">
<p style={{ color: 'var(--accent-bright)', fontWeight: 600, marginBottom: 8 }}>L'IA a tenté d'exécuter une commande nécessitant des privilèges administrateur :</p>
<pre style={{ background: 'var(--bg)', padding: '10px 12px', borderRadius: 'var(--radius)', fontSize: 12, overflow: 'auto', fontFamily: 'var(--font-mono)' }}>{sudoModal.command}</pre>
<p style={{ color: 'var(--text-secondary)', fontSize: 12, marginTop: 12 }}>La commande a été bloquée. L'IA en a été informée et cherchera une alternative.</p>
</div>
<div className="shell-modal-footer">
<button className="primary" onClick={() => setSudoModal(null)}>Compris</button>
</div>
</div>
</div>
)}
</div>
)
}