All checks were successful
Beta Release / beta (push) Successful in 1m1s
- Fix token count reset on app restart: persist realTokens in conversation.json - Fix token/context window values: Studio 150K (summarize at 120K), Terminal 100K - Fix table rendering in terminal tab: correct thead/tbody display model - Fix copy button always top-right in Studio code blocks - Add markdown horizontal rule (---) support in Studio and Terminal - Fix bullet list double dot: remove CSS ::before duplicate bullet point - Add image attachments support (VLM description, file mentions @file.ext) - Add sudo detection with cache (sync.Once) - Fix message content serialization (TextContent wrapper) - Guide AI to use read_file instead of cat in studio prompt 💘 Generated with Crush Assisted-by: GLM-5.1 via Crush <crush@charm.land>
867 lines
33 KiB
JavaScript
867 lines
33 KiB
JavaScript
import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
|
||
import { useI18n } from '../i18n'
|
||
import mermaid from 'mermaid'
|
||
|
||
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 (
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke={rank.color} strokeWidth="2">
|
||
<polygon points="12,2 15.09,8.26 22,9.27 17,14.14 18.18,21.02 12,17.77 5.82,21.02 7,14.14 2,9.27 8.91,8.26"/>
|
||
</svg>
|
||
)
|
||
}
|
||
return (
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke={rank.color} strokeWidth="2">
|
||
<path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/>
|
||
</svg>
|
||
)
|
||
}
|
||
|
||
function renderContent(text) {
|
||
const parts = []
|
||
const codeBlockRegex = /(```[\s\S]*?```)/g
|
||
let match
|
||
let lastIndex = 0
|
||
while ((match = codeBlockRegex.exec(text)) !== null) {
|
||
if (match.index > lastIndex) {
|
||
parts.push({ type: 'text', content: text.slice(lastIndex, match.index) })
|
||
}
|
||
const full = match[1]
|
||
const firstNewline = full.indexOf('\n')
|
||
const lang = firstNewline > -1 ? full.slice(3, firstNewline).trim() : ''
|
||
const code = firstNewline > -1 ? full.slice(firstNewline + 1, -3) : full.slice(3, -3)
|
||
parts.push({ type: 'code', lang, content: code })
|
||
lastIndex = match.index + full.length
|
||
}
|
||
if (lastIndex < text.length) {
|
||
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, '<').replace(/>/g, '>')
|
||
|
||
html = html.replace(/^(\|.+\|)\n(\|[\s\-:|]+\|)\n((?:\|.+\|\n?)+)/gm, (match, headerRow, sepRow, bodyRows) => {
|
||
const headers = headerRow.split('|').filter(c => c.trim() !== '').map(c => `<th>${c.trim()}</th>`).join('')
|
||
const rows = bodyRows.trim().split('\n').map(row => {
|
||
const cells = row.split('|').filter(c => c.trim() !== '').map(c => `<td>${c.trim()}</td>`).join('')
|
||
return `<tr>${cells}</tr>`
|
||
}).join('')
|
||
return `<table><thead><tr>${headers}</tr></thead><tbody>${rows}</tbody></table>`
|
||
})
|
||
|
||
html = html
|
||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||
.replace(/`([^`]+)`/g, '<code class="inline-code">$1</code>')
|
||
.replace(/^### (.+)$/gm, '<h4 class="msg-h4">$1</h4>')
|
||
.replace(/^## (.+)$/gm, '<h3 class="msg-h3">$1</h3>')
|
||
.replace(/^# (.+)$/gm, '<h2 class="msg-h2">$1</h2>')
|
||
.replace(/^---+$/gm, '<hr>')
|
||
.replace(/^\s*[-*] (.+)$/gm, '<div class="msg-bullet">\u2022 $1</div>')
|
||
.replace(/^\s*(\d+)[.)] (.+)$/gm, '<div class="msg-step"><span class="msg-step-num">$1</span> $2</div>')
|
||
.replace(/\n/g, '<br/>')
|
||
|
||
html = html
|
||
.replace(/<br\/>\s*<br\/>/g, '<br/>')
|
||
.replace(/<br\/>\s*(<h[234]|<div class="msg-|<table|<hr)/g, '$1')
|
||
.replace(/(<\/h[234]|<\/div>|<\/table>|<hr>)\s*<br\/>/g, '$1')
|
||
.replace(/\s+on\w+=["'][^"']*["']/gi, '')
|
||
.replace(/javascript:/gi, '')
|
||
.replace(/data:/gi, '')
|
||
|
||
return html
|
||
}
|
||
|
||
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 }) {
|
||
const icon = TOOL_ICONS[call.name] || '🔧'
|
||
const label = TOOL_LABELS[call.name] || call.name
|
||
const isErr = result && result.is_error
|
||
|
||
let argsPreview = ''
|
||
try {
|
||
const args = typeof call.args === 'string' ? JSON.parse(call.args) : call.args
|
||
if (args.command) argsPreview = args.command
|
||
else if (args.task) argsPreview = args.task
|
||
else if (args.path) argsPreview = args.path
|
||
else if (args.pattern) argsPreview = args.pattern
|
||
else if (args.url) argsPreview = args.url
|
||
else if (args.action) argsPreview = args.action
|
||
else argsPreview = JSON.stringify(args).slice(0, 80)
|
||
} catch {
|
||
argsPreview = String(call.args).slice(0, 80)
|
||
}
|
||
|
||
const truncatedResult = result ? (result.content || '').slice(0, 2000) : null
|
||
|
||
return (
|
||
<div className={`studio-tool-block ${isErr ? 'error' : ''} ${result ? 'done' : 'running'}`}>
|
||
<div className="studio-tool-header">
|
||
<span className="studio-tool-icon">{icon}</span>
|
||
<span className="studio-tool-name">{label}</span>
|
||
{!result && <span className="studio-tool-spinner"><span/><span/><span/></span>}
|
||
{result && <span className={`studio-tool-status ${isErr ? 'error' : 'ok'}`}>{isErr ? '✗' : '✓'}</span>}
|
||
</div>
|
||
<div className="studio-tool-args" title={argsPreview}>{argsPreview}</div>
|
||
{truncatedResult && (
|
||
<div className="studio-tool-result">
|
||
<pre>{truncatedResult}</pre>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
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 <pre className="studio-mermaid-error">{code}</pre>
|
||
if (!svg) return <div className="studio-mermaid-loading">Chargement...</div>
|
||
return <div className="studio-mermaid-container" ref={ref} dangerouslySetInnerHTML={{ __html: svg }} />
|
||
}
|
||
|
||
function CodeBlockWithCopy({ part, index, copiedIdx, setCopiedIdx }) {
|
||
if (part.lang === 'mermaid') {
|
||
return (
|
||
<div className="studio-code-block">
|
||
<div className="studio-code-header">
|
||
<span className="studio-code-lang">mermaid</span>
|
||
<button className={`studio-copy-btn ${copiedIdx === index ? 'copied' : ''}`} onClick={() => {
|
||
navigator.clipboard.writeText(part.content)
|
||
setCopiedIdx(index)
|
||
setTimeout(() => setCopiedIdx(null), 1500)
|
||
}}>
|
||
{copiedIdx === index ? 'Copie!' : 'Copier'}
|
||
</button>
|
||
</div>
|
||
<MermaidBlock code={part.content} />
|
||
</div>
|
||
)
|
||
}
|
||
return (
|
||
<div className="studio-code-block">
|
||
<div className="studio-code-header">
|
||
{part.lang && <span className="studio-code-lang">{part.lang}</span>}
|
||
<button className={`studio-copy-btn ${copiedIdx === index ? 'copied' : ''}`} onClick={() => {
|
||
navigator.clipboard.writeText(part.content)
|
||
setCopiedIdx(index)
|
||
setTimeout(() => setCopiedIdx(null), 1500)
|
||
}}>
|
||
{copiedIdx === index ? 'Copie!' : 'Copier'}
|
||
</button>
|
||
</div>
|
||
<pre><code>{part.content}</code></pre>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function FeedItem({ msg }) {
|
||
const isUser = msg.role === 'user'
|
||
const isSystem = msg.role === 'system'
|
||
const rank = getRank(msg.role)
|
||
const [copiedIdx, setCopiedIdx] = useState(null)
|
||
|
||
const timeStr = msg.time ? new Date(msg.time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : ''
|
||
|
||
let parsedToolCalls = null
|
||
let parsedToolResults = null
|
||
let displayContent = msg.content
|
||
try {
|
||
const parsed = JSON.parse(msg.content)
|
||
if (parsed && Array.isArray(parsed.tool_calls)) {
|
||
parsedToolCalls = parsed.tool_calls
|
||
parsedToolResults = parsed.tool_results || null
|
||
displayContent = parsed.content || ''
|
||
}
|
||
} catch {}
|
||
|
||
if (isSystem) {
|
||
return (
|
||
<div className="feed-item system">
|
||
<div className="feed-system-badge" />
|
||
<div className="feed-system-text">{msg.content}</div>
|
||
{timeStr && <span className="feed-time">{timeStr}</span>}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
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>}
|
||
</div>
|
||
{msg.thinking && <ThinkingBlock content={formatText(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>
|
||
)}
|
||
{parsedToolCalls && parsedToolCalls.map((tc, i) => {
|
||
const resultData = parsedToolResults
|
||
? parsedToolResults.find(r => r.tool_call_id === tc.tool_call_id)
|
||
: null
|
||
const result = resultData
|
||
? { content: resultData.result, is_error: resultData.is_error }
|
||
: null
|
||
return <ToolCallBlock key={tc.tool_call_id || i} call={tc} result={result} />
|
||
})}
|
||
{cleanContent && (
|
||
<div className="feed-content">
|
||
{renderContent(cleanContent).map((part, i) =>
|
||
part.type === 'code' ? (
|
||
<CodeBlockWithCopy key={i} part={part} index={i} copiedIdx={copiedIdx} setCopiedIdx={setCopiedIdx} />
|
||
) : (
|
||
<span key={i} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} />
|
||
)
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function StreamingItem({ content, thinking, toolCalls }) {
|
||
const rank = RANKS.general
|
||
const cleanContent = content.replace(/<think[^>]*>[\s\S]*?<\/think>/gi, '')
|
||
const hasToolCalls = toolCalls && toolCalls.length > 0
|
||
const [copiedIdx, setCopiedIdx] = useState(null)
|
||
|
||
const renderedContent = useMemo(() => {
|
||
if (!cleanContent) return []
|
||
return renderContent(cleanContent)
|
||
}, [cleanContent])
|
||
|
||
const formattedThinking = useMemo(() => {
|
||
if (!thinking) return ''
|
||
return formatText(thinking)
|
||
}, [thinking])
|
||
|
||
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={formattedThinking} raw done={false} />}
|
||
{hasToolCalls && toolCalls.map((tc, i) => (
|
||
<ToolCallBlock key={tc.call?.tool_call_id || i} call={tc.call} result={tc.result} />
|
||
))}
|
||
{!thinking && !cleanContent && !hasToolCalls && (
|
||
<div className="feed-content">
|
||
<div className="studio-thinking"><span /><span /><span /></div>
|
||
</div>
|
||
)}
|
||
{cleanContent && (
|
||
<div className="feed-content">
|
||
{renderedContent.map((part, i) =>
|
||
part.type === 'code' ? (
|
||
<CodeBlockWithCopy key={i} part={part} index={i} copiedIdx={copiedIdx} setCopiedIdx={setCopiedIdx} />
|
||
) : (
|
||
<span key={i} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} />
|
||
)
|
||
)}
|
||
<span className="studio-cursor" />
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default function Studio({ api }) {
|
||
const { t } = useI18n()
|
||
const [messages, setMessages] = useState([])
|
||
const [input, setInput] = useState('')
|
||
const [loading, setLoading] = useState(false)
|
||
const [streaming, setStreaming] = useState('')
|
||
const [streamThinking, setStreamThinking] = useState('')
|
||
const [streamToolCalls, setStreamToolCalls] = useState([])
|
||
const [loaded, setLoaded] = useState(false)
|
||
const [tokenInfo, setTokenInfo] = useState({ used: 0, max: 150000, summarizeAt: 120000 })
|
||
const [contextCollapsed, setContextCollapsed] = useState(false)
|
||
const [messagesCollapsed, setMessagesCollapsed] = useState(false)
|
||
const [sudoModal, setSudoModal] = useState(null)
|
||
const [attachedImages, setAttachedImages] = useState([])
|
||
const messagesEnd = useRef(null)
|
||
const feedRef = useRef(null)
|
||
const textareaRef = useRef(null)
|
||
const abortRef = useRef(null)
|
||
const fileInputRef = 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 || 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(() => {
|
||
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 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 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])
|
||
accumulated = ''
|
||
setStreaming('')
|
||
return
|
||
}
|
||
if (event && event.tool_result) {
|
||
if (event.tool_result.sudo_blocked) {
|
||
setSudoModal({ command: event.tool_result.command || event.tool_result.content })
|
||
}
|
||
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, images)
|
||
|
||
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),
|
||
tool_results: toolCalls.map(tc => ({
|
||
tool_call_id: tc.call?.tool_call_id,
|
||
result: tc.result?.content || '',
|
||
is_error: tc.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([])
|
||
abortRef.current = null
|
||
refreshTokens()
|
||
}
|
||
}, [input, loading, api, t, handleClear, streaming, refreshTokens, handleSummarize, attachedImages])
|
||
|
||
const handleStop = useCallback(() => {
|
||
if (abortRef.current) {
|
||
abortRef.current.abort()
|
||
}
|
||
}, [])
|
||
|
||
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) {
|
||
const completed = matches[0] + ' '
|
||
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 handleToggleCollapsed = useCallback(() => {
|
||
setMessagesCollapsed(prev => !prev)
|
||
}, [])
|
||
|
||
const renderMessages = () => {
|
||
if (messagesCollapsed && messages.length > 4) {
|
||
const visibleCount = 4
|
||
const hiddenCount = messages.length - visibleCount
|
||
return (
|
||
<>
|
||
{messages.slice(0, visibleCount).map(msg => (
|
||
<FeedItem key={msg.id} msg={msg} />
|
||
))}
|
||
<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 messages.map(msg => (
|
||
<FeedItem key={msg.id} msg={msg} />
|
||
))
|
||
}
|
||
|
||
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} />
|
||
)}
|
||
<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}
|
||
/>
|
||
<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>
|
||
<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>
|
||
)
|
||
}
|