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 ( ) } 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) { parts.push({ type: 'text', content: text.slice(lastIndex) }) } return parts } function formatText(text) { // First escape HTML entities let html = text .replace(/&/g, '&').replace(//g, '>') // Apply markdown transformations (now with escaped brackets) html = html .replace(/\*\*(.+?)\*\*/g, '$1') .replace(/`([^`]+)`/g, '$1') .replace(/^### (.+)$/gm, '

$1

') .replace(/^## (.+)$/gm, '

$1

') .replace(/^# (.+)$/gm, '

$1

') .replace(/^\s*[-*] (.+)$/gm, '
• $1
') .replace(/^\s*(\d+)[.)] (.+)$/gm, '
$1 $2
') // 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 (
Reflexion {!done && }
{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 }) { 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 (
{icon} {label} {!result && } {result && {isErr ? '✗' : '✓'}}
{argsPreview}
{truncatedResult && (
{truncatedResult}
)}
) } 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 (
{msg.content}
{timeStr && {timeStr}}
) } const cleanContent = displayContent.replace(/]*>[\s\S]*?<\/think>/gi, '') return (
{rank.short} {rank.label} {timeStr && {timeStr}}
{msg.thinking && } {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 })} {cleanContent && (
{renderContent(cleanContent).map((part, i) => part.type === 'code' ? (
{part.lang &&
{part.lang}
}
{part.content}
) : ( ) )}
)}
) } function StreamingItem({ content, thinking, toolCalls }) { const rank = RANKS.general const cleanContent = content.replace(/]*>[\s\S]*?<\/think>/gi, '') const hasToolCalls = toolCalls && toolCalls.length > 0 return (
{rank.short} {rank.label}
{thinking && } {hasToolCalls && toolCalls.map((tc, i) => ( ))} {!thinking && !cleanContent && !hasToolCalls && (
)} {cleanContent && (
{renderContent(cleanContent).map((part, i) => part.type === 'code' ? (
{part.lang &&
{part.lang}
}
{part.content}
) : ( ) )}
)}
) } 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 ` - 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 `\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 (
) } return (
{messages.map(msg => ( ))} {(streaming || streamThinking || loading || streamToolCalls.length > 0) && ( )}
= tokenInfo.summarizeAt ? 'warn' : ''}`} style={{ width: `${Math.min(100, (tokenInfo.used / tokenInfo.max) * 100)}%` }} />
{(tokenInfo.used / 1000).toFixed(1)}k / {(tokenInfo.max / 1000).toFixed(0)}k tokens {tokenInfo.used >= tokenInfo.summarizeAt && ' · résumé automatique déclenché'}