import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
import { useI18n } from '../i18n'
import mermaid from 'mermaid'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkMath from 'remark-math'
import rehypeKatex from 'rehype-katex'
import rehypeHighlight from 'rehype-highlight'
import 'katex/dist/katex.min.css'
import 'highlight.js/styles/github-dark.css'
mermaid.initialize({ startOnLoad: false, theme: 'dark', securityLevel: 'loose', fontFamily: 'var(--font-mono)' })
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 (
)
}
return (
)
}
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) {
const remaining = text.slice(lastIndex)
const openBlock = remaining.match(/```(\w*)\n?([\s\S]*)$/)
if (openBlock) {
if (openBlock.index > 0) {
parts.push({ type: 'text', content: remaining.slice(0, openBlock.index) })
}
parts.push({ type: 'code', lang: openBlock[1] || '', content: openBlock[2] || '' })
} else {
parts.push({ type: 'text', content: remaining })
}
}
return parts
}
function formatText(text) {
let html = text
.replace(/&/g, '&').replace(//g, '>')
html = html.replace(/^(\|.+\|)\n(\|[\s\-:|]+\|)\n((?:\|.+\|\n?)+)/gm, (match, headerRow, sepRow, bodyRows) => {
const headers = headerRow.split('|').filter(c => c.trim() !== '').map(c => `
${c.trim()} | `).join('')
const rows = bodyRows.trim().split('\n').map(row => {
const cells = row.split('|').filter(c => c.trim() !== '').map(c => `${c.trim()} | `).join('')
return `${cells}
`
}).join('')
return ``
})
html = html
.replace(/\*\*(.+?)\*\*/g, '$1')
.replace(/`([^`]+)`/g, '$1')
.replace(/^### (.+)$/gm, '$1
')
.replace(/^## (.+)$/gm, '$1
')
.replace(/^# (.+)$/gm, '$1
')
.replace(/^---+$/gm, '
')
.replace(/^\s*[-*] (.+)$/gm, '\u2022 $1
')
.replace(/^\s*(\d+)[.)] (.+)$/gm, '$1 $2
')
.replace(/\n/g, '
')
html = html
.replace(/
\s*
/g, '
')
.replace(/
\s*(
{raw ? : content}
)
}
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, activeAgents, onModeChange }) {
const icon = TOOL_ICONS[call.name] || '🔧'
const label = TOOL_LABELS[call.name] || call.name
const isErr = result && result.is_error
const isCrush = call.name === 'crush_run'
const isClaude = call.name === 'claude_run'
const isAgent = isCrush || isClaude
const agentType = isCrush ? 'crush' : isClaude ? 'claude' : null
const maxAgents = isCrush ? 2 : isClaude ? 2 : 0
const currentCount = agentType && activeAgents ? (activeAgents[agentType] || 0) : 0
const [mode, setMode] = useState('sync')
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
const handleModeChange = (newMode) => {
setMode(newMode)
if (onModeChange) onModeChange(call.tool_call_id, newMode)
}
return (
{icon}
{label}
{isAgent && !result && (
{currentCount}/{maxAgents}
)}
{!result && }
{result && {isErr ? '✗' : '✓'}}
{argsPreview}
{isAgent && !result && (
)}
{truncatedResult && (
)}
)
}
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 {code}
if (!svg) return Chargement...
return
}
function CodeBlockWithCopy({ part, index, copiedIdx, setCopiedIdx }) {
if (part.lang === 'mermaid') {
return (
mermaid
)
}
return (
{part.lang && {part.lang}}
{part.content}
)
}
function FeedItem({ msg, activeAgents, onModeChange, collapseHistory }) {
const isUser = msg.role === 'user'
const isSystem = msg.role === 'system'
const rank = getRank(msg.role)
const [copiedIdx, setCopiedIdx] = useState(null)
const [forceExpand, setForceExpand] = useState(false)
const timeStr = msg.time ? new Date(msg.time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : ''
const [showRawMarkdown, setShowRawMarkdown] = useState(() => {
try { return localStorage.getItem('muyue.showRawMarkdown') === 'true' } catch { return false }
})
const renderMarkdown = useCallback((content) => {
return
}, [showRawMarkdown])
let parsedToolCalls = null
let parsedToolResults = null
let parsedSegments = null
let displayContent = msg.content
try {
const parsed = JSON.parse(msg.content)
if (parsed && Array.isArray(parsed.segments)) {
parsedSegments = parsed.segments
parsedToolCalls = parsed.tool_calls || null
parsedToolResults = parsed.tool_results || null
displayContent = parsed.content || ''
} else if (parsed && Array.isArray(parsed.tool_calls)) {
parsedToolCalls = parsed.tool_calls
parsedToolResults = parsed.tool_results || null
displayContent = parsed.content || ''
}
} catch {}
if (isSystem) {
return (
{msg.content}
{timeStr &&
{timeStr}}
)
}
let cleanContent = displayContent.replace(/]*>[\s\S]*?<\/think>/gi, '')
return (
{rank.short}
{rank.label}
{timeStr && {timeStr}}
{msg.thinking &&
}
{msg.images && msg.images.length > 0 && (
{msg.images.map((imgId, i) => (

))}
)}
{parsedSegments && parsedSegments.some(s => s.type === 'tool') ? (
(() => {
const toolSegs = parsedSegments.filter(s => s.type === 'tool')
const compress = collapseHistory && !forceExpand && toolSegs.length > 1
const lastTool = toolSegs.length > 0 ? toolSegs[toolSegs.length - 1] : null
return (
<>
{compress && (
… {toolSegs.length - 1} action{toolSegs.length - 1 > 1 ? 's' : ''} précédente{toolSegs.length - 1 > 1 ? 's' : ''} masquée{toolSegs.length - 1 > 1 ? 's' : ''}
)}
{parsedSegments.map((seg, i) => {
if (seg.type === 'text') {
if (!seg.content) return null
const c = seg.content.replace(/
]*>[\s\S]*?<\/think>/gi, '')
if (!c) return null
return (
{renderMarkdown(c)}
)
}
if (seg.type === 'tool') {
if (compress && seg !== lastTool) return null
const r = seg.result
const result = r && (r.content !== undefined || r.is_error !== undefined)
? { content: r.content, is_error: r.is_error }
: null
return
}
return null
})}
>
)
})()
) : (
<>
{parsedToolCalls && (() => {
const compress = collapseHistory && !forceExpand && parsedToolCalls.length > 1
const items = compress ? parsedToolCalls.slice(-1) : parsedToolCalls
return (
<>
{compress && (
… {parsedToolCalls.length - 1} action{parsedToolCalls.length - 1 > 1 ? 's' : ''} précédente{parsedToolCalls.length - 1 > 1 ? 's' : ''} masquée{parsedToolCalls.length - 1 > 1 ? 's' : ''}
)}
{items.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
})}
>
)
})()}
{cleanContent && (
{renderMarkdown(cleanContent)}
)}
>
)}
)
}
function StreamingItem({ content, thinking, toolCalls, segments, activeAgents, onModeChange, collapseHistory }) {
const rank = RANKS.general
const cleanContent = content.replace(/]*>[\s\S]*?<\/think>/gi, '')
const hasToolCalls = toolCalls && toolCalls.length > 0
const [copiedIdx, setCopiedIdx] = useState(null)
const [forceExpand, setForceExpand] = useState(false)
const renderedContent = useMemo(() => {
if (!cleanContent) return null
return null
}, [cleanContent])
const formattedThinking = useMemo(() => {
if (!thinking) return ''
return thinking
}, [thinking])
const hasOrderedSegments = segments && segments.some(s => s.type === 'tool')
const toolSegments = (segments || []).filter(s => s.type === 'tool')
const compress = collapseHistory && !forceExpand && toolSegments.length > 1
return (
{rank.short}
{rank.label}
{thinking &&
}
{hasOrderedSegments ? (
<>
{compress && (
… {toolSegments.length - 1} action{toolSegments.length - 1 > 1 ? 's' : ''} précédente{toolSegments.length - 1 > 1 ? 's' : ''} masquée{toolSegments.length - 1 > 1 ? 's' : ''} (mode compressé)
)}
{(() => {
const lastToolId = toolSegments.length > 0 ? toolSegments[toolSegments.length - 1] : null
return segments.map((seg, i) => {
if (seg.type === 'text') {
if (!seg.content) return null
return (
)
}
if (seg.type === 'tool') {
if (compress && seg !== lastToolId) return null
return
}
return null
})
})()}
>
) : (
<>
{hasToolCalls && (compress
? [
]
: toolCalls.map((tc, i) => (
))
)}
{cleanContent && (
)}
>
)}
{!thinking && !cleanContent && !hasToolCalls && !hasOrderedSegments && (
)}
{!hasOrderedSegments && cleanContent && (
)}
)
}
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 [streamSegments, setStreamSegments] = useState(null)
const [loaded, setLoaded] = useState(false)
const [tokenInfo, setTokenInfo] = useState({ used: 0, max: 150000, summarizeAt: 120000 })
const [contextCollapsed, setContextCollapsed] = useState(false)
const [messagesCollapsed, setMessagesCollapsed] = useState(false)
const [sudoModal, setSudoModal] = useState(null)
const [attachedImages, setAttachedImages] = useState([])
const [activeAgents, setActiveAgents] = useState({ crush: 0, claude: 0 })
const [toolModes, setToolModes] = useState({})
const [advancedReflection, setAdvancedReflection] = useState(() => {
try { return localStorage.getItem('muyue.advancedReflection') === 'true' } catch { return false }
})
const [collapseHistory, setCollapseHistory] = useState(() => {
try { return localStorage.getItem('muyue.collapseHistory') !== 'false' } catch { return true }
})
const MAX_CRUSH_AGENTS = 2
const MAX_CLAUDE_AGENTS = 2
const messagesEnd = useRef(null)
const feedRef = useRef(null)
const textareaRef = useRef(null)
const abortRef = useRef(null)
const fileInputRef = useRef(null)
const ragFileRef = useRef(null)
const [ragStatus, setRagStatus] = useState(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 || 150000,
summarizeAt: data.summarize_at || 120000,
})
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(() => {
api.ragStatus().then(setRagStatus).catch(() => {})
}, [api])
useEffect(() => {
const onTab = (e) => {
if (e.key !== 'Tab') return
if (document.activeElement?.tagName === 'INPUT' || document.activeElement?.tagName === 'TEXTAREA') return
const feed = document.querySelector('.studio-feed-layout')
if (!feed?.closest('.tab-hidden')) {
e.preventDefault()
textareaRef.current?.focus()
}
}
window.addEventListener('keydown', onTab)
return () => window.removeEventListener('keydown', onTab)
}, [])
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 || 150000,
summarizeAt: data.summarize_at || 120000,
})
} 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() }])
setContextCollapsed('animating')
try {
const data = await api.summarizeChat()
setTokenInfo(prev => ({ ...prev, used: data.tokens || 0 }))
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) {
setMessages(prev => [...prev, { id: (Date.now() + 1).toString(), role: 'system', content: `Erreur de résumé: ${err.message}`, time: new Date().toISOString() }])
setContextCollapsed(false)
}
}, [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 handleImageSelect = useCallback((e) => {
const files = Array.from(e.target.files || [])
if (files.length === 0) return
const remaining = 3 - attachedImages.length
const toProcess = files.slice(0, remaining)
toProcess.forEach(file => {
if (!file.type.match(/^image\/(jpeg|jpg|png|webp)$/)) return
if (file.size > 50 * 1024 * 1024) return
const reader = new FileReader()
reader.onload = (ev) => {
setAttachedImages(prev => {
if (prev.length >= 3) return prev
return [...prev, { data: ev.target.result, filename: file.name, mime_type: file.type }]
})
}
reader.readAsDataURL(file)
})
e.target.value = ''
}, [attachedImages.length])
const removeImage = useCallback((index) => {
setAttachedImages(prev => prev.filter((_, i) => i !== index))
}, [])
const handleRAGFileSelect = useCallback(async (e) => {
const files = Array.from(e.target.files || [])
if (files.length === 0) return
for (const file of files) {
try {
await api.ragIndexFile(file)
} catch (err) {
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'system', content: `RAG: erreur d'indexation de ${file.name}: ${err.message}`, time: new Date().toISOString() }])
}
}
api.ragStatus().then(setRagStatus).catch(() => {})
e.target.value = ''
}, [api])
const handleSend = useCallback(async () => {
if (!input.trim() || loading) return
const text = input.trim()
const images = [...attachedImages]
setInput('')
setAttachedImages([])
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() }])
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: 'Commande inconnue. Tapez `/help` pour la liste des commandes.', time: new Date().toISOString() }])
return
}
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',
'- `/model` - Afficher le provider et modèle actifs',
'- `/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
}
if (text === '/summarize') {
handleSummarize()
return
}
if (text === '/model' || text === '/model change') {
if (text === '/model change') {
api.getProviders().then(data => {
const providers = data.providers || []
const minimax = providers.find(p => p.name.toUpperCase() === 'MINIMAX')
const mimo = providers.find(p => p.name.toUpperCase() === 'MIMO')
if (!minimax || !mimo) {
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: 'MiniMax et MiMo doivent être configurés pour utiliser `/model change`.', time: new Date().toISOString() }])
return
}
const active = providers.find(p => p.active)
const activeName = active ? active.name.toUpperCase() : ''
const switchTo = activeName === 'MINIMAX' ? 'MIMO' : 'MINIMAX'
const target = switchTo === 'MINIMAX' ? minimax : mimo
api.saveProvider({ name: target.name, active: true }).then(() => {
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: `✓ Provider changé: **${target.name}** (${target.model})`, time: new Date().toISOString() }])
}).catch(() => {
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: 'Erreur lors du changement de provider.', 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() }])
})
} else {
api.getProviders().then(data => {
const active = data.providers?.find(p => p.active)
const modelMsg = active ? `**${active.name}** — ${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
}
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 segments = []
let textStartIdx = 0
let thinking = ''
const _updateLastText = (text) => {
if (!text) return
const last = segments.length > 0 ? segments[segments.length - 1] : null
if (last && last.type === 'text') {
last.content = text
} else {
segments.push({ type: 'text', content: text })
}
}
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) {
_updateLastText(partial.slice(textStartIdx))
textStartIdx = partial.length
segments.push({ type: 'tool', call: event.tool_call, result: null })
const toolName = event.tool_call.name
if (toolName === 'crush_run' || toolName === 'claude_run') {
const agentType = toolName === 'crush_run' ? 'crush' : 'claude'
setActiveAgents(prev => ({ ...prev, [agentType]: prev[agentType] + 1 }))
}
const snap = segments.map(s => ({ ...s }))
setStreamToolCalls(snap.filter(s => s.type === 'tool'))
setStreamSegments(snap)
setStreaming(partial)
return
}
if (event && event.tool_result) {
if (event.tool_result.sudo_blocked) {
setSudoModal({ command: event.tool_result.command || event.tool_result.content })
}
const segIdx = segments.findIndex(s => s.type === 'tool' && s.call && s.call.tool_call_id === event.tool_result.tool_call_id)
if (segIdx >= 0) {
segments[segIdx].result = event.tool_result
const toolName = segments[segIdx].call?.name
if (toolName === 'crush_run' || toolName === 'claude_run') {
const agentType = toolName === 'crush_run' ? 'crush' : 'claude'
setActiveAgents(prev => ({ ...prev, [agentType]: Math.max(0, prev[agentType] - 1) }))
}
const snap = segments.map(s => ({ ...s }))
setStreamToolCalls(snap.filter(s => s.type === 'tool'))
setStreamSegments(snap)
}
return
}
_updateLastText(partial.slice(textStartIdx))
setStreaming(partial)
const snap = segments.map(s => ({ ...s }))
setStreamSegments(snap)
}, controller.signal, images, advancedReflection)
const allText = segments.filter(s => s.type === 'text').map(s => s.content).join('')
const toolSegs = segments.filter(s => s.type === 'tool')
const finalContent = allText || t('studio.noResponse')
const aiMsg = {
id: (Date.now() + 1).toString(),
role: 'assistant',
content: finalContent,
time: new Date().toISOString(),
}
if (thinking) aiMsg.thinking = thinking
if (toolSegs.length > 0 || segments.length > 1) {
aiMsg.content = JSON.stringify({
segments: segments.map(s => s.type === 'text'
? { type: 'text', content: s.content }
: { type: 'tool', call: s.call, result: { content: s.result?.content || '', is_error: s.result?.is_error || false, tool_call_id: s.call?.tool_call_id } }
),
content: allText,
tool_calls: toolSegs.map(s => s.call),
tool_results: toolSegs.map(s => ({
tool_call_id: s.call?.tool_call_id,
result: s.result?.content || '',
is_error: s.result?.is_error || false,
})),
})
}
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([])
setStreamSegments(null)
setActiveAgents({ crush: 0, claude: 0 })
setToolModes({})
abortRef.current = null
refreshTokens()
}
}, [input, loading, api, t, handleClear, streaming, refreshTokens, handleSummarize, attachedImages])
const handleStop = useCallback(() => {
if (abortRef.current) {
abortRef.current.abort()
}
}, [])
const handleToolModeChange = useCallback((toolCallId, mode) => {
setToolModes(prev => ({ ...prev, [toolCallId]: mode }))
}, [])
const COMMANDS = ['/clear', '/summarize', '/help', '/model', '/model change']
const handleKeyDown = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
return
}
if (e.key === 'Tab') {
e.preventDefault()
const ta = textareaRef.current
if (!ta) return
if (document.activeElement !== ta) {
ta.focus()
return
}
const val = ta.value
const pos = ta.selectionStart
const before = val.slice(0, pos)
const afterSlash = before.match(/\/[\w ]*$/)
if (afterSlash) {
const partial = afterSlash[0]
const matches = COMMANDS.filter(c => c.startsWith(partial) && c !== partial)
if (matches.length >= 1) {
let completed = matches[0]
for (const m of matches) {
while (!m.startsWith(completed)) completed = completed.slice(0, -1)
}
if (completed === partial && matches.length === 1) completed = matches[0]
if (completed.length > partial.length) {
const suffix = completed[completed.length - 1] === ' ' ? '' : (matches.length === 1 ? ' ' : '')
completed += suffix
const newText = val.slice(0, pos - afterSlash[0].length) + completed + val.slice(pos)
setInput(newText)
requestAnimationFrame(() => {
ta.selectionStart = ta.selectionEnd = pos - afterSlash[0].length + completed.length
})
}
}
}
}
}
const [summarizedExpanded, setSummarizedExpanded] = useState(false)
const handleToggleCollapsed = useCallback(() => {
setMessagesCollapsed(prev => !prev)
}, [])
const renderMessages = () => {
const summarizedMsgs = messages.filter(m => m.summarized)
const activeMsgs = messages.filter(m => !m.summarized)
const renderSummaryBlock = () => summarizedMsgs.length > 0 && (
setSummarizedExpanded(prev => !prev)}>
Résumé · {summarizedMsgs.length} messages
{summarizedExpanded ? 'masquer' : 'voir'}
{summarizedExpanded && summarizedMsgs.map(msg => (
))}
)
if (messagesCollapsed && activeMsgs.length > 4) {
const visibleCount = 4
const hiddenCount = activeMsgs.length - visibleCount
return (
<>
{renderSummaryBlock()}
{activeMsgs.slice(0, visibleCount).map(msg => (
))}
{hiddenCount} messages antérieurs compressés
clic pour développer
>
)
}
return (
<>
{renderSummaryBlock()}
{activeMsgs.map(msg => (
))}
>
)
}
if (!loaded) {
return (
)
}
return (
{renderMessages()}
{(streaming || streamThinking || loading || streamToolCalls.length > 0) && (
)}
{attachedImages.length > 0 && (
{attachedImages.map((img, i) => (
))}
)}
= tokenInfo.summarizeAt ? 'warn' : ''} ${contextCollapsed === true ? 'compressed' : ''} ${contextCollapsed === 'animating' ? 'animating' : ''}`}
style={{ width: `${Math.min(100, (tokenInfo.used / tokenInfo.max) * 100)}%` }}
/>
{(tokenInfo.used / 1000).toFixed(1)}k / {(tokenInfo.max / 1000).toFixed(0)}k tokens
{contextCollapsed === true && ' · compressé'}
{tokenInfo.used >= tokenInfo.summarizeAt && contextCollapsed !== true && ' · résumé auto.'}
{contextCollapsed === true && (
)}
{t('studio.inputHint')} · /clear /summarize /help /model · @fichier.ext pour joindre un fichier{attachedImages.length > 0 && ` · ${attachedImages.length} image${attachedImages.length > 1 ? 's' : ''} attachée${attachedImages.length > 1 ? 's' : ''}`}
{sudoModal && (
setSudoModal(null)}>
e.stopPropagation()}>
Commande bloquée
L'IA a tenté d'exécuter une commande nécessitant des privilèges administrateur :
{sudoModal.command}
La commande a été bloquée. L'IA en a été informée et cherchera une alternative.
)}
)
}