Files
MuyueWorkspace/web/src/components/Studio.jsx
Augustin e8f6dc4b4d
All checks were successful
Beta Release / beta (push) Successful in 47s
feat(chat): add auto-summarization with token tracking UI
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>
2026-04-23 20:42:43 +02:00

599 lines
21 KiB
JavaScript

import { useState, useRef, useEffect, useCallback } from 'react'
import { useI18n } from '../i18n'
const RANKS = {
commandant: { label: 'Commandant', short: 'CDT', color: '#FFD740' },
general: { label: 'General', short: 'GEN', color: '#FF9100' },
colonel: { label: 'Colonel', short: 'COL', color: '#FF6D00' },
lieutenant: { label: 'Lieutenant', short: 'LT', color: '#448AFF' },
soldat: { label: 'Soldat', short: 'SDT', color: '#00E676' },
}
function getRank(role) {
if (role === 'user') return RANKS.commandant
if (role === 'system') return null
return RANKS.general
}
function RankIcon({ rank }) {
if (rank === RANKS.commandant) {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke={rank.color} strokeWidth="2">
<polygon points="12,2 15.09,8.26 22,9.27 17,14.14 18.18,21.02 12,17.77 5.82,21.02 7,14.14 2,9.27 8.91,8.26"/>
</svg>
)
}
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke={rank.color} strokeWidth="2">
<path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/>
</svg>
)
}
function renderContent(text) {
const parts = []
const codeBlockRegex = /(```[\s\S]*?```)/g
let match
let lastIndex = 0
while ((match = codeBlockRegex.exec(text)) !== null) {
if (match.index > lastIndex) {
parts.push({ type: 'text', content: text.slice(lastIndex, match.index) })
}
const full = match[1]
const firstNewline = full.indexOf('\n')
const lang = firstNewline > -1 ? full.slice(3, firstNewline).trim() : ''
const code = firstNewline > -1 ? full.slice(firstNewline + 1, -3) : full.slice(3, -3)
parts.push({ type: 'code', lang, content: code })
lastIndex = match.index + full.length
}
if (lastIndex < text.length) {
parts.push({ type: 'text', content: text.slice(lastIndex) })
}
return parts
}
function formatText(text) {
// First escape HTML entities
let html = text
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
// Apply markdown transformations (now with escaped brackets)
html = html
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/`([^`]+)`/g, '<code class="inline-code">$1</code>')
.replace(/^### (.+)$/gm, '<h4 class="msg-h4">$1</h4>')
.replace(/^## (.+)$/gm, '<h3 class="msg-h3">$1</h3>')
.replace(/^# (.+)$/gm, '<h2 class="msg-h2">$1</h2>')
.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>')
// Sanitize: remove event handlers and dangerous protocols
html = html
.replace(/\s+on\w+=["'][^"']*["']/gi, '') // Remove on* event handlers
.replace(/javascript:/gi, '')
.replace(/data:/gi, '')
return html
}
function ThinkingBlock({ content, done }) {
return (
<div className={`feed-thinking-block ${done ? 'done' : 'active'}`}>
<div className="feed-thinking-header">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/>
</svg>
<span>Reflexion</span>
{!done && <span className="feed-thinking-dots"><span/><span/><span/></span>}
</div>
<div className="feed-thinking-content">{content}</div>
</div>
)
}
const TOOL_ICONS = {
terminal: '⌨',
crush_run: '⚡',
read_file: '📄',
list_files: '📁',
search_files: '🔍',
grep_content: '🔎',
get_config: '⚙',
set_provider: '🔑',
manage_ssh: '🌐',
web_fetch: '🌐',
}
const TOOL_LABELS = {
terminal: 'Terminal',
crush_run: 'Crush Agent',
read_file: 'Read File',
list_files: 'List Files',
search_files: 'Search Files',
grep_content: 'Grep',
get_config: 'Config',
set_provider: 'Set Provider',
manage_ssh: 'SSH',
web_fetch: 'Web Fetch',
}
function ToolCallBlock({ call, result }) {
const icon = TOOL_ICONS[call.name] || '🔧'
const label = TOOL_LABELS[call.name] || call.name
const isErr = result && result.is_error
let argsPreview = ''
try {
const args = typeof call.args === 'string' ? JSON.parse(call.args) : call.args
if (args.command) argsPreview = args.command
else if (args.task) argsPreview = args.task
else if (args.path) argsPreview = args.path
else if (args.pattern) argsPreview = args.pattern
else if (args.url) argsPreview = args.url
else if (args.action) argsPreview = args.action
else argsPreview = JSON.stringify(args).slice(0, 80)
} catch {
argsPreview = String(call.args).slice(0, 80)
}
const truncatedResult = result ? (result.content || '').slice(0, 2000) : null
return (
<div className={`studio-tool-block ${isErr ? 'error' : ''} ${result ? 'done' : 'running'}`}>
<div className="studio-tool-header">
<span className="studio-tool-icon">{icon}</span>
<span className="studio-tool-name">{label}</span>
{!result && <span className="studio-tool-spinner"><span/><span/><span/></span>}
{result && <span className={`studio-tool-status ${isErr ? 'error' : 'ok'}`}>{isErr ? '✗' : '✓'}</span>}
</div>
<div className="studio-tool-args" title={argsPreview}>{argsPreview}</div>
{truncatedResult && (
<div className="studio-tool-result">
<pre>{truncatedResult}</pre>
</div>
)}
</div>
)
}
function FeedItem({ msg }) {
const isUser = msg.role === 'user'
const isSystem = msg.role === 'system'
const rank = getRank(msg.role)
const timeStr = msg.time ? new Date(msg.time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : ''
let parsedToolCalls = null
let parsedToolResults = null
let displayContent = msg.content
try {
const parsed = JSON.parse(msg.content)
if (parsed && Array.isArray(parsed.tool_calls)) {
parsedToolCalls = parsed.tool_calls
parsedToolResults = parsed.tool_results || null
displayContent = parsed.content || ''
}
} catch {}
if (isSystem) {
return (
<div className="feed-item system">
<div className="feed-system-badge" />
<div className="feed-system-text">{msg.content}</div>
{timeStr && <span className="feed-time">{timeStr}</span>}
</div>
)
}
const cleanContent = displayContent.replace(/<think[^>]*>[\s\S]*?<\/think>/gi, '')
return (
<div className={`feed-item ${msg.role}`}>
<div className={`feed-avatar ${isUser ? 'user-rank' : 'ai-rank'}`}>
<RankIcon rank={rank} />
</div>
<div className="feed-body">
<div className="feed-header">
<span className="feed-rank-badge" style={{ color: rank.color, borderColor: rank.color }}>
{rank.short}
</span>
<span className="feed-role">{rank.label}</span>
{timeStr && <span className="feed-time">{timeStr}</span>}
</div>
{msg.thinking && <ThinkingBlock content={msg.thinking} done />}
{parsedToolCalls && parsedToolCalls.map((tc, i) => {
const resultData = parsedToolResults
? parsedToolResults.find(r => r.tool_call_id === tc.tool_call_id)
: null
const result = resultData
? { content: resultData.result, is_error: resultData.is_error }
: null
return <ToolCallBlock key={tc.tool_call_id || i} call={tc} result={result} />
})}
{cleanContent && (
<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>
) : (
<span key={i} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} />
)
)}
</div>
)}
</div>
</div>
)
}
function StreamingItem({ content, thinking, toolCalls }) {
const rank = RANKS.general
const cleanContent = content.replace(/<think[^>]*>[\s\S]*?<\/think>/gi, '')
const hasToolCalls = toolCalls && toolCalls.length > 0
return (
<div className="feed-item assistant">
<div className="feed-avatar ai-rank">
<RankIcon rank={rank} />
</div>
<div className="feed-body">
<div className="feed-header">
<span className="feed-rank-badge" style={{ color: rank.color, borderColor: rank.color }}>
{rank.short}
</span>
<span className="feed-role">{rank.label}</span>
</div>
{thinking && <ThinkingBlock content={thinking} done={false} />}
{hasToolCalls && toolCalls.map((tc, i) => (
<ToolCallBlock key={tc.call?.tool_call_id || i} call={tc.call} result={tc.result} />
))}
{!thinking && !cleanContent && !hasToolCalls && (
<div className="feed-content">
<div className="studio-thinking"><span /><span /><span /></div>
</div>
)}
{cleanContent && (
<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>
) : (
<span key={i} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} />
)
)}
<span className="studio-cursor" />
</div>
)}
</div>
</div>
)
}
export default function Studio({ api }) {
const { t } = useI18n()
const [messages, setMessages] = useState([])
const [input, setInput] = useState('')
const [loading, setLoading] = useState(false)
const [streaming, setStreaming] = useState('')
const [streamThinking, setStreamThinking] = useState('')
const [streamToolCalls, setStreamToolCalls] = useState([])
const [loaded, setLoaded] = useState(false)
const [tokenInfo, setTokenInfo] = useState({ used: 0, max: 100000, summarizeAt: 80000 })
const messagesEnd = useRef(null)
const textareaRef = useRef(null)
const abortRef = useRef(null)
useEffect(() => {
api.getChatHistory().then(data => {
if (data.messages && data.messages.length > 0) {
setMessages(data.messages)
} else {
setMessages([
{ 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)
}).catch(() => {
setMessages([
{ id: 'welcome', role: 'system', content: t('studio.welcomeNew'), time: new Date().toISOString() },
])
setLoaded(true)
})
}, [])
useEffect(() => {
messagesEnd.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages, streaming, streamThinking, streamToolCalls])
useEffect(() => {
if (textareaRef.current) {
textareaRef.current.style.height = 'auto'
textareaRef.current.style.height = Math.min(textareaRef.current.scrollHeight, 200) + 'px'
}
}, [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 () => {
try {
await api.clearChat()
setMessages([
{ id: 'clear-' + Date.now(), role: 'system', content: t('studio.cleared'), time: new Date().toISOString() },
])
} catch {}
}, [api, t])
const handleSend = useCallback(async () => {
if (!input.trim() || loading) return
const text = input.trim()
setInput('')
if (text === '/clear') {
handleClear()
return
}
if (text === '/help') {
const helpMsg = [
'## Commandes Studio',
'',
'- `/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',
'',
'## 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',
].join('\n')
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: helpMsg, time: new Date().toISOString() }])
return
}
if (text === '/summarize') {
handleSummarize()
return
}
if (text === '/model') {
api.getProviders().then(data => {
const active = data.providers?.find(p => p.active)
const modelMsg = active ? `Provider: ${active.name}\nModèle: ${active.model}` : 'Aucun provider actif configuré'
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: modelMsg, time: new Date().toISOString() }])
}).catch(() => {
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: 'Erreur: impossible de récupérer les providers', time: new Date().toISOString() }])
})
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)
setStreaming('')
setStreamThinking('')
setStreamToolCalls([])
const controller = new AbortController()
abortRef.current = controller
try {
let accumulated = ''
let thinking = ''
let toolCalls = []
await api.sendChat(text, true, (partial, event) => {
if (event && (event.thinking_start || event.thinking_end || event.thinking !== undefined)) {
if (event.thinking !== undefined) {
thinking += event.thinking
setStreamThinking(thinking)
}
return
}
if (event && event.tool_call) {
toolCalls = [...toolCalls, { call: event.tool_call, result: null }]
setStreamToolCalls([...toolCalls])
return
}
if (event && event.tool_result) {
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 }
setStreamToolCalls([...toolCalls])
}
return
}
accumulated = partial
setStreaming(partial)
}, controller.signal)
const finalContent = accumulated || t('studio.noResponse')
const aiMsg = {
id: (Date.now() + 1).toString(),
role: 'assistant',
content: finalContent,
time: new Date().toISOString(),
}
if (thinking) aiMsg.thinking = thinking
if (toolCalls.length > 0) {
aiMsg.content = JSON.stringify({
content: finalContent,
tool_calls: toolCalls.map(tc => tc.call),
})
}
setMessages(prev => [...prev, aiMsg])
} catch (err) {
if (err.name === 'AbortError') {
if (streaming) {
setMessages(prev => [...prev, {
id: (Date.now() + 1).toString(),
role: 'system',
content: t('studio.cancelled'),
time: new Date().toISOString(),
}])
}
} else {
setMessages(prev => [...prev, {
id: (Date.now() + 1).toString(),
role: 'system',
content: `${t('studio.error')}: ${err.message}`,
time: new Date().toISOString(),
}])
}
} finally {
setLoading(false)
setStreaming('')
setStreamThinking('')
setStreamToolCalls([])
abortRef.current = null
refreshTokens()
}
}, [input, loading, api, t, handleClear, streaming, refreshTokens, handleSummarize])
const handleStop = useCallback(() => {
if (abortRef.current) {
abortRef.current.abort()
}
}, [])
const handleKeyDown = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
}
}
if (!loaded) {
return (
<div className="studio-feed-layout">
<div className="studio-feed">
<div className="feed-loading">
<div className="studio-thinking"><span /><span /><span /></div>
</div>
</div>
</div>
)
}
return (
<div className="studio-feed-layout">
<div className="studio-feed">
{messages.map(msg => (
<FeedItem key={msg.id} msg={msg} />
))}
{(streaming || streamThinking || loading || streamToolCalls.length > 0) && (
<StreamingItem content={streaming} thinking={streamThinking} toolCalls={streamToolCalls} />
)}
<div ref={messagesEnd} />
</div>
<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">
<textarea
ref={textareaRef}
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={t('studio.placeholderNew')}
disabled={loading}
rows={1}
/>
<button
className="studio-send-btn"
onClick={handleSend}
disabled={loading || !input.trim()}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/>
</svg>
</button>
{loading && (
<button className="studio-stop-btn" onClick={handleStop} title={t('studio.stop')}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<rect x="4" y="4" width="16" height="16" rx="2"/>
</svg>
</button>
)}
</div>
<div className="studio-input-hint">
{t('studio.inputHint')} · /clear /summarize /help /plan /export /model
</div>
</div>
</div>
)
}