refactor(chat): deduplicate streaming code, add multi-conv, and XSS protection
All checks were successful
Beta Release / beta (push) Successful in 2m23s

- Add ChatEngine for deduplicated chat logic (handlers_chat/shell_chat)
- Add SendWithToolsStream for real-time streaming responses
- Add /help, /plan, /export, /model commands in Studio
- Fix XSS: sanitize HTML after markdown rendering
- Add ConversationStoreMulti for multi-conversation support
- Add Anthropic headers (x-api-key, anthropic-version)
- Add fallback logging when provider switch occurs
- Add API handler tests (handlers_test.go)
- Polish Studio: max-height 200px, word-break on tool args
- Update CLI version to show full info (version, go, platform)

🤖 Generated with Crush

Assisted-by: MiniMax-M2.5 via Crush <crush@charm.land>
This commit is contained in:
Augustin
2026-04-22 22:58:05 +02:00
parent 65804aae4e
commit 3948a4c656
12 changed files with 1024 additions and 312 deletions

View File

@@ -53,8 +53,12 @@ function renderContent(text) {
}
function formatText(text) {
return text
// First escape HTML entities
let html = text
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
// Apply markdown transformations (now with escaped brackets)
html = html
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/`([^`]+)`/g, '<code class="inline-code">$1</code>')
.replace(/^### (.+)$/gm, '<h4 class="msg-h4">$1</h4>')
@@ -62,6 +66,14 @@ function formatText(text) {
.replace(/^# (.+)$/gm, '<h2 class="msg-h2">$1</h2>')
.replace(/^\s*[-*] (.+)$/gm, '<div class="msg-bullet">• $1</div>')
.replace(/^\s*(\d+)[.)] (.+)$/gm, '<div class="msg-step"><span class="msg-step-num">$1</span> $2</div>')
// 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 }) {
@@ -324,6 +336,65 @@ export default function Studio({ api }) {
return
}
if (text === '/help') {
const helpMsg = [
'## Commandes Studio',
'',
'- `/clear` - Effacer la conversation',
'- `/help` - Afficher cette aide',
'- `/plan <objectif>` - 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 === '/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 <objectif>`\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)
@@ -472,7 +543,7 @@ export default function Studio({ api }) {
)}
</div>
<div className="studio-input-hint">
{t('studio.inputHint')} &middot; /clear
{t('studio.inputHint')} &middot; /clear /help /plan /export /model
</div>
</div>
</div>

View File

@@ -684,6 +684,8 @@ input::placeholder { color: var(--text-disabled); }
background: var(--bg-surface); border: 1px solid var(--border); border-left: 2px solid var(--accent-dim);
border-radius: var(--radius); margin: 6px 0 8px; overflow: hidden;
transition: all 0.3s ease;
max-height: 200px;
overflow-y: auto;
}
.feed-thinking-block.active {
border-left-color: var(--warning);
@@ -826,7 +828,8 @@ input::placeholder { color: var(--text-disabled); }
font-size: 12px;
font-family: var(--font-mono);
color: var(--text-tertiary);
white-space: nowrap;
white-space: pre-wrap;
word-break: break-all;
overflow: hidden;
text-overflow: ellipsis;
border-bottom: 1px solid var(--border);