feat: AI terminal, Z.AI quota, /model change, formatting fixes, update redirects
All checks were successful
Beta Release / beta (push) Successful in 49s
All checks were successful
Beta Release / beta (push) Successful in 49s
- Add dedicated AI Terminal tab (non-deletable) shared between user and AI - Add Z.AI quota display on dashboard via /api/monitor/usage/quota/limit - Add /model change command in Studio to toggle MiniMax/ZAI - Apply Studio formatting (formatText, renderContent) to Shell AI messages - Add render tick refresh for Shell (1s streaming, 5s idle) - Add analysis viewer modal (Eye button) in Shell panel - Fix multi-shell tab creation with retry init and settings ref - Persist shell tabs to localStorage - Fix line spacing in Studio (line-height 1.7→1.5, cleanup stray <br/>) - Redirect Config updates to AI terminal via custom events - Fix CI: delete existing release before recreating - Bump version to 0.3.4 💘 Generated with Crush Assisted-by: GLM-5.1 via Crush <crush@charm.land>
This commit is contained in:
@@ -1,13 +1,62 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
|
||||
import { Terminal as XTerm } from '@xterm/xterm'
|
||||
import { FitAddon } from '@xterm/addon-fit'
|
||||
import { WebLinksAddon } from '@xterm/addon-web-links'
|
||||
import { Plus, X, Monitor, Globe, ChevronDown, Pencil, Trash2, Search, Copy, Send } from 'lucide-react'
|
||||
import { Plus, X, Monitor, Globe, ChevronDown, Pencil, Trash2, Search, Copy, Send, Eye, Bot } from 'lucide-react'
|
||||
import '@xterm/xterm/css/xterm.css'
|
||||
import { useI18n } from '../i18n'
|
||||
|
||||
const AI_TAB_ID = 0
|
||||
const MAX_TABS = 7
|
||||
const SHELL_MAX_TOKENS = 100000
|
||||
const TABS_STORAGE_KEY = 'muyue_shell_tabs'
|
||||
|
||||
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) {
|
||||
let html = text
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
|
||||
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(/^\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>')
|
||||
.replace(/\n/g, '<br/>')
|
||||
|
||||
html = html
|
||||
.replace(/<br\/>\s*<br\/>/g, '<br/>')
|
||||
.replace(/<br\/>\s*(<h[234]|<div class="msg-)/g, '$1')
|
||||
.replace(/(<\/h[234]|<\/div>)\s*<br\/>/g, '$1')
|
||||
.replace(/\s+on\w+=["'][^"']*["']/gi, '')
|
||||
.replace(/javascript:/gi, '')
|
||||
.replace(/data:/gi, '')
|
||||
|
||||
return html
|
||||
}
|
||||
|
||||
const THEMES = {
|
||||
default: {
|
||||
@@ -143,11 +192,32 @@ export default function Shell({ api }) {
|
||||
const { t } = useI18n()
|
||||
const tabsRef = useRef({})
|
||||
const nextIdRef = useRef(1)
|
||||
const settingsRef = useRef({ fontSize: 14, fontFamily: "'JetBrains Mono', 'Fira Code', monospace", theme: 'default' })
|
||||
|
||||
const [tabs, setTabs] = useState([
|
||||
const savedTabs = (() => {
|
||||
try {
|
||||
const raw = localStorage.getItem(TABS_STORAGE_KEY)
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw)
|
||||
if (Array.isArray(parsed) && parsed.length > 0) {
|
||||
return parsed.map(t => ({ ...t, connected: false }))
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
return null
|
||||
})()
|
||||
|
||||
const [tabs, setTabs] = useState(savedTabs || [
|
||||
{ id: AI_TAB_ID, name: 'AI Terminal', type: 'ai', shell: '', connected: false, ai: true },
|
||||
{ id: 1, name: 'Local Shell', type: 'local', shell: '', connected: false },
|
||||
])
|
||||
const [activeTab, setActiveTab] = useState(1)
|
||||
const [activeTab, setActiveTab] = useState(() => {
|
||||
if (savedTabs) {
|
||||
const aiTab = savedTabs.find(t => t.ai)
|
||||
return aiTab ? aiTab.id : savedTabs[0].id
|
||||
}
|
||||
return AI_TAB_ID
|
||||
})
|
||||
const [sshConnections, setSshConnections] = useState([])
|
||||
const [systemTerminals, setSystemTerminals] = useState([])
|
||||
const [showMenu, setShowMenu] = useState(false)
|
||||
@@ -160,6 +230,8 @@ export default function Shell({ api }) {
|
||||
theme: 'default',
|
||||
})
|
||||
|
||||
useEffect(() => { settingsRef.current = terminalSettings }, [terminalSettings])
|
||||
|
||||
const [sshForm, setSshForm] = useState({
|
||||
name: '', host: '', port: 22, user: '', key_path: '',
|
||||
})
|
||||
@@ -170,6 +242,9 @@ export default function Shell({ api }) {
|
||||
const [aiTokens, setAiTokens] = useState(0)
|
||||
const [aiAtLimit, setAiAtLimit] = useState(false)
|
||||
const [analyzing, setAnalyzing] = useState(false)
|
||||
const [showAnalysis, setShowAnalysis] = useState(false)
|
||||
const [analysisContent, setAnalysisContent] = useState('')
|
||||
const [renderTick, setRenderTick] = useState(0)
|
||||
const aiMessagesRef = useRef(null)
|
||||
const aiLoadedRef = useRef(false)
|
||||
|
||||
@@ -177,6 +252,21 @@ export default function Shell({ api }) {
|
||||
aiMessagesRef.current?.scrollTo(0, aiMessagesRef.current.scrollHeight)
|
||||
}, [aiMessages])
|
||||
|
||||
useEffect(() => {
|
||||
const ms = aiLoading ? 1000 : 5000
|
||||
const iv = setInterval(() => setRenderTick(t => t + 1), ms)
|
||||
return () => clearInterval(iv)
|
||||
}, [aiLoading])
|
||||
|
||||
useEffect(() => {
|
||||
api.getShellAnalysis?.().then(d => {
|
||||
if (d?.analysis) setAnalysisContent(d.analysis)
|
||||
}).catch(() => {
|
||||
const stored = localStorage.getItem('shell_analysis')
|
||||
if (stored) setAnalysisContent(stored)
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (aiLoadedRef.current) return
|
||||
aiLoadedRef.current = true
|
||||
@@ -193,6 +283,11 @@ export default function Shell({ api }) {
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const maxId = tabs.reduce((max, t) => Math.max(max, t.id), 0)
|
||||
nextIdRef.current = maxId + 1
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
api.getTerminalSessions().then(d => {
|
||||
setSshConnections(d.ssh || [])
|
||||
@@ -213,12 +308,13 @@ export default function Shell({ api }) {
|
||||
if (tabsRef.current[tabId]) return
|
||||
|
||||
const container = document.getElementById(`terminal-${tabId}`)
|
||||
if (!container) return
|
||||
if (!container || container.offsetHeight === 0) return
|
||||
|
||||
const s = settingsRef.current
|
||||
const { term, fitAddon } = createTerminal(container, {
|
||||
fontSize: terminalSettings.fontSize,
|
||||
fontFamily: terminalSettings.fontFamily,
|
||||
theme: terminalSettings.theme,
|
||||
fontSize: s.fontSize,
|
||||
fontFamily: s.fontFamily,
|
||||
theme: s.theme,
|
||||
})
|
||||
|
||||
let initPayload
|
||||
@@ -271,26 +367,40 @@ export default function Shell({ api }) {
|
||||
const tab = tabs.find(t => t.id === activeTab)
|
||||
if (!tab) return
|
||||
|
||||
const container = document.getElementById(`terminal-${tab.id}`)
|
||||
if (!container) return
|
||||
|
||||
if (!tabsRef.current[tab.id]) {
|
||||
const timer = setTimeout(() => {
|
||||
const tryInit = (attempt) => {
|
||||
if (attempt > 10) return
|
||||
const container = document.getElementById(`terminal-${tab.id}`)
|
||||
if (!container || container.offsetHeight === 0) {
|
||||
setTimeout(() => tryInit(attempt + 1), 100)
|
||||
return
|
||||
}
|
||||
if (!tabsRef.current[tab.id]) {
|
||||
initTerminal(tab.id, tab)
|
||||
requestAnimationFrame(() => {
|
||||
const entry = tabsRef.current[tab.id]
|
||||
if (entry) entry.fitAddon.fit()
|
||||
})
|
||||
}, 100)
|
||||
return () => clearTimeout(timer)
|
||||
} else {
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
const entry = tabsRef.current[tab.id]
|
||||
if (entry) entry.fitAddon.fit()
|
||||
})
|
||||
}
|
||||
|
||||
tryInit(0)
|
||||
}, [activeTab, tabs, initTerminal])
|
||||
|
||||
useEffect(() => {
|
||||
const iv = setInterval(() => {
|
||||
for (const tab of tabs) {
|
||||
const entry = tabsRef.current[tab.id]
|
||||
if (entry) {
|
||||
const el = document.getElementById(`terminal-${tab.id}`)
|
||||
if (el && el.offsetParent !== null) {
|
||||
entry.fitAddon.fit()
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 2000)
|
||||
return () => clearInterval(iv)
|
||||
}, [tabs])
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e) => {
|
||||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return
|
||||
@@ -309,8 +419,8 @@ export default function Shell({ api }) {
|
||||
const addLocalTab = (shell, name) => {
|
||||
if (tabs.length >= MAX_TABS) return
|
||||
const id = nextIdRef.current++
|
||||
const newTab = { id, name: name || `${t('shell.localShell')} ${tabs.length + 1}`, type: 'local', shell: shell || '', connected: false }
|
||||
setTabs(prev => [...prev, newTab])
|
||||
const newTab = { id, name: name || `${t('shell.localShell')} ${tabs.length}`, type: 'local', shell: shell || '', connected: false }
|
||||
setTabs(prev => { const next = [...prev, newTab]; localStorage.setItem(TABS_STORAGE_KEY, JSON.stringify(next.map(t => ({ id: t.id, name: t.name, type: t.type, shell: t.shell, ai: t.ai || false, host: t.host, port: t.port, user: t.user, key_path: t.key_path })))); return next })
|
||||
setActiveTab(id)
|
||||
setShowMenu(false)
|
||||
}
|
||||
@@ -328,14 +438,15 @@ export default function Shell({ api }) {
|
||||
key_path: conn.key_path || '',
|
||||
connected: false,
|
||||
}
|
||||
setTabs(prev => [...prev, newTab])
|
||||
setTabs(prev => { const next = [...prev, newTab]; localStorage.setItem(TABS_STORAGE_KEY, JSON.stringify(next.map(t => ({ id: t.id, name: t.name, type: t.type, shell: t.shell, ai: t.ai || false, host: t.host, port: t.port, user: t.user, key_path: t.key_path })))); return next })
|
||||
setActiveTab(id)
|
||||
setShowMenu(false)
|
||||
}
|
||||
|
||||
const closeTab = (tabId, e) => {
|
||||
if (e) e.stopPropagation()
|
||||
if (tabs.length <= 1) return
|
||||
const tab = tabs.find(t => t.id === tabId)
|
||||
if (!tab || tab.ai || tabs.length <= 1) return
|
||||
|
||||
if (tabsRef.current[tabId]) {
|
||||
const { ws, resizeObserver, onResize, term } = tabsRef.current[tabId]
|
||||
@@ -392,17 +503,25 @@ export default function Shell({ api }) {
|
||||
}
|
||||
|
||||
const sendToTerminal = useCallback((code) => {
|
||||
const tab = tabs.find(t => t.id === activeTab)
|
||||
if (!tab) return
|
||||
const entry = tabsRef.current[tab.id]
|
||||
if (!entry?.ws || entry.ws.readyState !== WebSocket.OPEN) return
|
||||
entry.ws.send(JSON.stringify({ type: 'input', data: code + '\r' }))
|
||||
}, [tabs, activeTab])
|
||||
const aiEntry = tabsRef.current[AI_TAB_ID]
|
||||
if (aiEntry?.ws && aiEntry.ws.readyState === WebSocket.OPEN) {
|
||||
aiEntry.ws.send(JSON.stringify({ type: 'input', data: code + '\r' }))
|
||||
}
|
||||
}, [])
|
||||
|
||||
const focusAiTerminal = useCallback(() => {
|
||||
setActiveTab(AI_TAB_ID)
|
||||
setTimeout(() => {
|
||||
const entry = tabsRef.current[AI_TAB_ID]
|
||||
if (entry) entry.term.focus()
|
||||
}, 150)
|
||||
}, [])
|
||||
|
||||
const handleAiSend = async () => {
|
||||
if (!aiInput.trim() || aiLoading || aiAtLimit) return
|
||||
const text = aiInput.trim()
|
||||
setAiInput('')
|
||||
focusAiTerminal()
|
||||
|
||||
if (text === '/clear') {
|
||||
try {
|
||||
@@ -453,11 +572,73 @@ export default function Shell({ api }) {
|
||||
setAiLoading(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e) => {
|
||||
const msg = e.detail?.message
|
||||
if (!msg) return
|
||||
setAiInput(msg)
|
||||
setActiveTab(AI_TAB_ID)
|
||||
setTimeout(() => {
|
||||
handleAiSendDirect(msg)
|
||||
}, 100)
|
||||
}
|
||||
window.addEventListener('ask-ai-terminal', handler)
|
||||
return () => window.removeEventListener('ask-ai-terminal', handler)
|
||||
}, [])
|
||||
|
||||
const handleAiSendDirect = async (text) => {
|
||||
if (!text || aiLoading || aiAtLimit) return
|
||||
setAiInput('')
|
||||
|
||||
if (text === '/clear') {
|
||||
try {
|
||||
await api.clearShellChat()
|
||||
setAiMessages([{ role: 'assistant', content: t('shell.aiWelcome') || 'Contexte effacé. Prêt.' }])
|
||||
setAiTokens(0)
|
||||
setAiAtLimit(false)
|
||||
} catch {}
|
||||
return
|
||||
}
|
||||
|
||||
setAiMessages(prev => [...prev, { role: 'user', content: text }])
|
||||
setAiLoading(true)
|
||||
|
||||
try {
|
||||
let accumulated = ''
|
||||
await api.sendShellChat(text, {}, true, (partial) => {
|
||||
accumulated = partial
|
||||
setAiMessages(prev => {
|
||||
const filtered = prev.filter(m => !m._streaming)
|
||||
return [...filtered, { role: 'assistant', content: partial, _streaming: true }]
|
||||
})
|
||||
})
|
||||
|
||||
setAiMessages(prev => {
|
||||
const filtered = prev.filter(m => !m._streaming)
|
||||
return [...filtered, { role: 'assistant', content: accumulated }]
|
||||
})
|
||||
api.getShellChatHistory().then(d => {
|
||||
setAiTokens(d.tokens || 0)
|
||||
setAiAtLimit(d.at_limit || false)
|
||||
}).catch(() => {})
|
||||
} catch (err) {
|
||||
if (err.message.includes('context limit')) {
|
||||
setAiAtLimit(true)
|
||||
}
|
||||
setAiMessages(prev => [...prev.filter(m => !m._streaming), { role: 'assistant', content: `Erreur: ${err.message}` }])
|
||||
}
|
||||
setAiLoading(false)
|
||||
}
|
||||
|
||||
const handleAnalyze = async () => {
|
||||
setAnalyzing(true)
|
||||
setAiMessages(prev => [...prev, { role: 'system', content: 'Analyse du système en cours...' }])
|
||||
try {
|
||||
const d = await api.analyzeSystem()
|
||||
if (d.analysis) {
|
||||
setAnalysisContent(d.analysis)
|
||||
localStorage.setItem('shell_analysis', d.analysis)
|
||||
}
|
||||
setAiMessages(prev => [...prev.filter(m => m.content !== 'Analyse du système en cours...'), {
|
||||
role: 'system',
|
||||
content: 'Analyse système terminée et sauvegardée. Le contexte système est maintenant disponible.'
|
||||
@@ -476,13 +657,14 @@ export default function Shell({ api }) {
|
||||
{tabs.map((tab, i) => (
|
||||
<div
|
||||
key={tab.id}
|
||||
className={`shell-tab ${activeTab === tab.id ? 'active' : ''}`}
|
||||
className={`shell-tab ${activeTab === tab.id ? 'active' : ''} ${tab.ai ? 'ai-tab' : ''}`}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
onDoubleClick={(e) => startRename(tab.id, e)}
|
||||
onDoubleClick={(e) => !tab.ai && startRename(tab.id, e)}
|
||||
>
|
||||
<span className={`connection-dot ${tab.connected ? 'on' : 'off'}`} />
|
||||
{tab.type === 'ssh' && <Globe size={12} />}
|
||||
{tab.type === 'local' && <Monitor size={12} />}
|
||||
{tab.ai && <Bot size={12} />}
|
||||
{!tab.ai && tab.type === 'ssh' && <Globe size={12} />}
|
||||
{!tab.ai && tab.type === 'local' && <Monitor size={12} />}
|
||||
{editingTab === tab.id ? (
|
||||
<input
|
||||
className="shell-tab-rename"
|
||||
@@ -497,7 +679,7 @@ export default function Shell({ api }) {
|
||||
<span className="shell-tab-name">{tab.name}</span>
|
||||
)}
|
||||
<span className="shell-tab-index">{i + 1}</span>
|
||||
{tabs.length > 1 && (
|
||||
{!tab.ai && tabs.length > 1 && (
|
||||
<button
|
||||
className="shell-tab-close"
|
||||
onClick={(e) => closeTab(tab.id, e)}
|
||||
@@ -585,15 +767,26 @@ export default function Shell({ api }) {
|
||||
<div className="shell-ai-col">
|
||||
<div className="ai-panel-header">
|
||||
<span>Analyste Système</span>
|
||||
<button
|
||||
className="shell-analyze-btn"
|
||||
onClick={handleAnalyze}
|
||||
disabled={analyzing}
|
||||
title="Analyser le système"
|
||||
>
|
||||
<Search size={13} />
|
||||
{analyzing ? '...' : 'Analyser'}
|
||||
</button>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<button
|
||||
className="shell-analyze-btn"
|
||||
onClick={() => setShowAnalysis(true)}
|
||||
disabled={!analysisContent}
|
||||
title="Voir l'analyse"
|
||||
>
|
||||
<Eye size={13} />
|
||||
Analyse
|
||||
</button>
|
||||
<button
|
||||
className="shell-analyze-btn"
|
||||
onClick={handleAnalyze}
|
||||
disabled={analyzing}
|
||||
title="Analyser le système"
|
||||
>
|
||||
<Search size={13} />
|
||||
{analyzing ? '...' : 'Analyser'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="shell-ai-token-bar">
|
||||
<div className="shell-ai-token-track">
|
||||
@@ -606,7 +799,7 @@ export default function Shell({ api }) {
|
||||
</div>
|
||||
<div className="ai-panel-messages" ref={aiMessagesRef}>
|
||||
{aiMessages.map((msg, i) => (
|
||||
<ShellAIMessage key={i} msg={msg} sendToTerminal={sendToTerminal} />
|
||||
<ShellAIMessage key={`${i}-${renderTick}`} msg={msg} sendToTerminal={sendToTerminal} renderTick={renderTick} />
|
||||
))}
|
||||
{aiLoading && <div style={{ textAlign: 'center', padding: 8 }}><span className="spinner" /></div>}
|
||||
</div>
|
||||
@@ -622,6 +815,29 @@ export default function Shell({ api }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showAnalysis && analysisContent && (
|
||||
<div className="shell-modal-overlay" onClick={() => setShowAnalysis(false)}>
|
||||
<div className="shell-analysis-modal" onClick={e => e.stopPropagation()}>
|
||||
<div className="shell-analysis-modal-header">
|
||||
<span>Analyse Système</span>
|
||||
<button className="shell-tab-close" onClick={() => setShowAnalysis(false)}><X size={16} /></button>
|
||||
</div>
|
||||
<div className="shell-analysis-modal-body">
|
||||
{renderContent(analysisContent).map((part, i) =>
|
||||
part.type === 'code' ? (
|
||||
<div key={i} className="shell-code-block">
|
||||
{part.lang && <div className="shell-code-lang">{part.lang}</div>}
|
||||
<pre><code>{part.content}</code></pre>
|
||||
</div>
|
||||
) : (
|
||||
<span key={i} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showSshModal && (
|
||||
<div className="shell-modal-overlay" onClick={() => setShowSshModal(false)}>
|
||||
<div className="shell-modal" onClick={e => e.stopPropagation()}>
|
||||
@@ -675,49 +891,41 @@ export default function Shell({ api }) {
|
||||
)
|
||||
}
|
||||
|
||||
function ShellAIMessage({ msg, sendToTerminal }) {
|
||||
function ShellAIMessage({ msg, sendToTerminal, renderTick }) {
|
||||
const role = msg.role === 'user' ? 'user' : msg.role === 'system' ? 'system' : 'assistant'
|
||||
const parts = parseMarkdown(msg.content || '')
|
||||
const content = msg.content || ''
|
||||
|
||||
if (role === 'user') {
|
||||
return <div className={`ai-message user`}>{content}</div>
|
||||
}
|
||||
|
||||
if (role === 'system') {
|
||||
return <div className={`ai-message system`}>{content}</div>
|
||||
}
|
||||
|
||||
const parts = renderContent(content)
|
||||
|
||||
return (
|
||||
<div className={`ai-message ${role}`}>
|
||||
<div className={`ai-message assistant`}>
|
||||
{parts.map((part, i) => {
|
||||
if (part.type === 'code') {
|
||||
return (
|
||||
<div key={i} className="shell-code-block">
|
||||
<div key={`${i}-${renderTick}`} className="shell-code-block">
|
||||
{part.lang && <div className="shell-code-lang">{part.lang}</div>}
|
||||
<pre><code>{part.code}</code></pre>
|
||||
<pre><code>{part.content}</code></pre>
|
||||
<div className="shell-code-actions">
|
||||
<button onClick={() => navigator.clipboard.writeText(part.code)} title="Copier">
|
||||
<button onClick={() => navigator.clipboard.writeText(part.content)} title="Copier">
|
||||
<Copy size={12} /> Copier
|
||||
</button>
|
||||
<button onClick={() => sendToTerminal(part.code)} title="Envoyer au terminal">
|
||||
<button onClick={() => sendToTerminal(part.content)} title="Envoyer au terminal">
|
||||
<Send size={12} /> Terminal
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return <span key={i}>{part.text}</span>
|
||||
return <span key={`${i}-${renderTick}`} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} />
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function parseMarkdown(text) {
|
||||
const parts = []
|
||||
const regex = /```(\w*)\n([\s\S]*?)```/g
|
||||
let last = 0
|
||||
let match
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
if (match.index > last) {
|
||||
parts.push({ type: 'text', text: text.slice(last, match.index) })
|
||||
}
|
||||
parts.push({ type: 'code', lang: match[1] || '', code: match[2].replace(/\n$/, '') })
|
||||
last = match.index + match[0].length
|
||||
}
|
||||
if (last < text.length) {
|
||||
parts.push({ type: 'text', text: text.slice(last) })
|
||||
}
|
||||
return parts.length > 0 ? parts : [{ type: 'text', text }]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user