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 `${headers}${rows}
` }) 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*(
Reflexion {!done && }
{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 && (
{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) => ( {`Image ))}
)} {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) => (
{img.filename}
))}
)}
= 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 && ( )}