Files
MuyueWorkspace/web/src/components/Studio.jsx
Augustin 3f4432d88a
All checks were successful
Stable Release / stable (push) Successful in 1m58s
fix(ui): add missing copiedMsg state, remove dead code, bump v0.9.3
The copiedMsg state was referenced in the Copy MD button but never declared,
causing a ReferenceError crash at runtime. Also removed unused compress
logic (collapseHistory/forceExpand), dead functions (renderContent, formatText,
CodeBlockWithCopy, MermaidBlock), and unused imports (useMemo, mermaid).

🤗 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-28 14:51:01 +02:00

990 lines
39 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useRef, useEffect, useCallback } from 'react'
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'
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 ThinkingBlock({ content, done, raw }) {
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">
{raw ? <span dangerouslySetInnerHTML={{ __html: 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, 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 (
<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>
{isAgent && !result && (
<span className="studio-agent-badge">{currentCount}/{maxAgents}</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>
{isAgent && !result && (
<div className="studio-agent-mode">
<button
className={`studio-mode-btn ${mode === 'sync' ? 'active' : ''}`}
onClick={() => handleModeChange('sync')}
>
Exécuter et attendre
</button>
<button
className={`studio-mode-btn ${mode === 'async' ? 'active' : ''}`}
onClick={() => handleModeChange('async')}
>
Exécuter en arrière-plan
</button>
</div>
)}
{truncatedResult && (
<div className="studio-tool-result">
<pre>{truncatedResult}</pre>
</div>
)}
</div>
)
}
function MarkdownContent({ content, raw }) {
if (raw) {
return <pre className="feed-content" style={{ whiteSpace: 'pre-wrap', fontFamily: 'var(--font-mono)', fontSize: '0.9em' }}>{content}</pre>
}
return (
<div className="feed-content">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
</div>
)
}
function FeedItem({ msg, activeAgents, onModeChange }) {
const isUser = msg.role === 'user'
const isSystem = msg.role === 'system'
const rank = getRank(msg.role)
const [copiedMsg, setCopiedMsg] = useState(false)
const timeStr = msg.time ? new Date(msg.time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : ''
const renderMarkdown = useCallback((content) => {
return <MarkdownContent content={content} raw={false} />
}, [])
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 (
<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>
)
}
let 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>}
{!isUser && !isSystem && (
<button
className="studio-copy-btn"
onClick={() => {
navigator.clipboard.writeText(displayContent)
setCopiedMsg(true)
setTimeout(() => setCopiedMsg(false), 1500)
}}
style={{ marginLeft: 'auto', fontSize: '0.7em', opacity: copiedMsg ? 1 : 0.5, transition: 'opacity 0.15s' }}
>
{copiedMsg ? '✓' : 'Copy MD'}
</button>
)}
</div>
{msg.thinking && <ThinkingBlock content={msg.thinking} done raw />}
{msg.images && msg.images.length > 0 && (
<div className="feed-images">
{msg.images.map((imgId, i) => (
<img key={i} className="feed-image" src={`/api/images/${imgId}`} alt={`Image ${i + 1}`} />
))}
</div>
)}
{parsedSegments && parsedSegments.some(s => s.type === 'tool') ? (
(() => {
return (
<>
{parsedSegments.map((seg, i) => {
if (seg.type === 'text') {
if (!seg.content) return null
const c = seg.content.replace(/<think[^>]*>[\s\S]*?<\/think>/gi, '')
if (!c) return null
return (
<div key={`t${i}`} className="feed-content">
{renderMarkdown(c)}
</div>
)
}
if (seg.type === 'tool') {
const r = seg.result
const result = r && (r.content !== undefined || r.is_error !== undefined)
? { content: r.content, is_error: r.is_error }
: null
return <ToolCallBlock key={`tc${i}`} call={seg.call} result={result} activeAgents={activeAgents} onModeChange={onModeChange} />
}
return null
})}
</>
)
})()
) : (
<>
{parsedToolCalls && (() => {
return (
<>
{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} activeAgents={activeAgents} onModeChange={onModeChange} />
})}
</>
)
})()}
{cleanContent && (
<div className="feed-content">
{renderMarkdown(cleanContent)}
</div>
)}
</>
)}
</div>
</div>
)
}
function StreamingItem({ content, thinking, toolCalls, segments, activeAgents, onModeChange }) {
const rank = RANKS.general
const cleanContent = content.replace(/<think[^>]*>[\s\S]*?<\/think>/gi, '')
const hasToolCalls = toolCalls && toolCalls.length > 0
const hasOrderedSegments = segments && segments.some(s => s.type === 'tool')
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} raw done={false} />}
{hasOrderedSegments ? (
<>
{segments.map((seg, i) => {
if (seg.type === 'text') {
if (!seg.content) return null
return (
<div key={`t${i}`} className="feed-content">
<MarkdownContent content={seg.content} raw={false} />
</div>
)
}
if (seg.type === 'tool') {
return <ToolCallBlock key={`tc${i}`} call={seg.call} result={seg.result} activeAgents={activeAgents} onModeChange={onModeChange} />
}
return null
})}
</>
) : (
<>
{hasToolCalls && toolCalls.map((tc, i) => (
<ToolCallBlock key={tc.call?.tool_call_id || i} call={tc.call} result={tc.result} activeAgents={activeAgents} onModeChange={onModeChange} />
))
}
{cleanContent && (
<div className="feed-content">
<MarkdownContent content={cleanContent} raw={false} />
<span className="studio-cursor" />
</div>
)}
</>
)}
{!thinking && !cleanContent && !hasToolCalls && !hasOrderedSegments && (
<div className="feed-content">
<div className="studio-thinking"><span /><span /><span /></div>
</div>
)}
{!hasOrderedSegments && cleanContent && (
<span className="studio-cursor" />
)}
</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 [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 [advancedReflection, setAdvancedReflection] = useState(() => {
try { return localStorage.getItem('muyue.advancedReflection') === 'true' } catch { return false }
})
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 && (
<div className="feed-summary-block">
<div className="feed-summary-header" onClick={() => setSummarizedExpanded(prev => !prev)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="16" y1="13" x2="8" y2="13"/>
<line x1="16" y1="17" x2="8" y2="17"/>
</svg>
<span className="feed-summary-text">Résumé · {summarizedMsgs.length} messages</span>
<span className="feed-summary-toggle">{summarizedExpanded ? 'masquer' : 'voir'}</span>
</div>
{summarizedExpanded && summarizedMsgs.map(msg => (
<FeedItem key={msg.id} msg={msg} activeAgents={activeAgents} onModeChange={handleToolModeChange} />
))}
</div>
)
if (messagesCollapsed && activeMsgs.length > 4) {
const visibleCount = 4
const hiddenCount = activeMsgs.length - visibleCount
return (
<>
{renderSummaryBlock()}
{activeMsgs.slice(0, visibleCount).map(msg => (
<FeedItem key={msg.id} msg={msg} activeAgents={activeAgents} onModeChange={handleToolModeChange} />
))}
<div className="feed-collapsed-messages" onClick={handleToggleCollapsed}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/>
</svg>
<span className="feed-collapsed-text">{hiddenCount} messages antérieurs compressés</span>
<span className="feed-collapsed-count">clic pour développer</span>
</div>
</>
)
}
return (
<>
{renderSummaryBlock()}
{activeMsgs.map(msg => (
<FeedItem key={msg.id} msg={msg} activeAgents={activeAgents} onModeChange={handleToolModeChange} />
))}
</>
)
}
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-scroll-wrap">
<div className="studio-feed" ref={feedRef}>
{renderMessages()}
{(streaming || streamThinking || loading || streamToolCalls.length > 0) && (
<StreamingItem content={streaming} thinking={streamThinking} toolCalls={streamToolCalls} segments={streamSegments} activeAgents={activeAgents} onModeChange={handleToolModeChange} />
)}
<div ref={messagesEnd} style={{ height: '24px' }} />
</div>
<div className="studio-scroll-btns">
<button className="studio-scroll-btn" onClick={() => feedRef.current?.scrollTo({ top: 0, behavior: 'smooth' })} title="Remonter">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M18 15l-6-6-6 6"/></svg>
</button>
<button className="studio-scroll-btn" onClick={() => messagesEnd.current?.scrollIntoView({ behavior: 'smooth' })} title="Descendre">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M6 9l6 6 6-6"/></svg>
</button>
</div>
</div>
<div className="studio-input-area">
{attachedImages.length > 0 && (
<div className="studio-image-previews">
{attachedImages.map((img, i) => (
<div key={i} className="studio-image-preview">
<img src={img.data} alt={img.filename} />
<button className="studio-image-remove" onClick={() => removeImage(i)}>×</button>
</div>
))}
</div>
)}
<div className={`studio-token-bar ${contextCollapsed === true ? 'compressed' : ''}`}>
<div className={`studio-token-track ${contextCollapsed === true ? 'compressed' : ''}`}>
<div
className={`studio-token-fill ${tokenInfo.used >= tokenInfo.summarizeAt ? 'warn' : ''} ${contextCollapsed === true ? 'compressed' : ''} ${contextCollapsed === 'animating' ? 'animating' : ''}`}
style={{ width: `${Math.min(100, (tokenInfo.used / tokenInfo.max) * 100)}%` }}
/>
</div>
<span className={`studio-token-text ${contextCollapsed === true ? 'compressed' : ''}`}>
{(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.'}
</span>
{contextCollapsed === true && (
<button className="ghost sm" onClick={handleToggleCollapsed} style={{ marginLeft: '8px', fontSize: '10px' }}>
voir plus
</button>
)}
</div>
<div className="studio-input-row">
<input
type="file"
ref={fileInputRef}
accept="image/jpeg,image/png,image/webp"
multiple
style={{ display: 'none' }}
onChange={handleImageSelect}
/>
<input
type="file"
ref={ragFileRef}
accept=".txt,.md,.go,.js,.ts,.py,.java,.rs,.jsx,.tsx,.json,.yaml,.yml,.csv,.html,.css,.sh,.bash,.zsh,.fish"
multiple
style={{ display: 'none' }}
onChange={handleRAGFileSelect}
/>
<button
className="studio-attach-btn"
onClick={() => fileInputRef.current?.click()}
disabled={loading || attachedImages.length >= 3}
title="Joindre des images (max 3)"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/>
</svg>
</button>
<button
className="studio-attach-btn"
onClick={() => ragFileRef.current?.click()}
disabled={loading}
title={ragStatus ? `RAG: ${ragStatus.documents || 0} docs, ${ragStatus.chunks || 0} chunks` : 'Ajouter un contexte RAG'}
style={ragStatus && ragStatus.documents > 0 ? { color: 'var(--accent, #6c5ce7)', borderColor: 'var(--accent, #6c5ce7)' } : undefined}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/>
</svg>
</button>
<button
className="studio-attach-btn"
onClick={() => {
const next = !advancedReflection
setAdvancedReflection(next)
try { localStorage.setItem('muyue.advancedReflection', String(next)) } catch {}
}}
disabled={loading}
title={advancedReflection ? "Réflexion avancée: ON (un autre modèle produit un rapport préalable)" : "Réflexion avancée: OFF"}
style={advancedReflection ? { color: 'var(--accent, #6c5ce7)', borderColor: 'var(--accent, #6c5ce7)' } : undefined}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/>
</svg>
</button>
<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 /model · @fichier.ext pour joindre un fichier{attachedImages.length > 0 && ` · ${attachedImages.length} image${attachedImages.length > 1 ? 's' : ''} attachée${attachedImages.length > 1 ? 's' : ''}`}
</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>
)
}