feat: terminal sudo blocking, token tracking, mermaid & consumption UI
All checks were successful
Beta Release / beta (push) Successful in 1m3s
All checks were successful
Beta Release / beta (push) Successful in 1m3s
- Block sudo/doas commands when not running as root - Add real token counting from API responses - Track and display consumption by provider/day - Add Mermaid diagram rendering in Shell and Studio - Add copy-to-clipboard buttons for code blocks - Support tables in AI message rendering - Update system prompt with context (date, time, root status) 💘 Generated with Crush Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
This commit is contained in:
1253
web/package-lock.json
generated
1253
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -16,6 +16,7 @@
|
||||
"@xterm/addon-webgl": "^0.20.0-beta.202",
|
||||
"@xterm/xterm": "^6.1.0-beta.203",
|
||||
"lucide-react": "^1.8.0",
|
||||
"mermaid": "^11.14.0",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5"
|
||||
},
|
||||
|
||||
@@ -38,6 +38,7 @@ const api = {
|
||||
importSkill: (path) => request('/skills/import', { method: 'POST', body: JSON.stringify({ import_path: path }) }),
|
||||
getDashboardStatus: () => request('/dashboard/status'),
|
||||
getProvidersQuota: () => request('/providers/quota'),
|
||||
getProvidersConsumption: () => request('/providers/consumption'),
|
||||
getRecentCommands: () => request('/recent-commands'),
|
||||
getRunningProcesses: () => request('/running-processes'),
|
||||
getSystemMetrics: () => request('/system/metrics'),
|
||||
|
||||
@@ -94,10 +94,8 @@ export default function App() {
|
||||
shell: [
|
||||
{ keys: `${layout.keys.ctrl}+${layout.keys.shift}+C`, desc: t('statusbar.copy') },
|
||||
{ keys: `${layout.keys.ctrl}+${layout.keys.shift}+V`, desc: t('statusbar.paste') },
|
||||
{ keys: `${layout.keys.ctrl}+F`, desc: t('statusbar.search') },
|
||||
{ keys: `${layout.keys.ctrl}+/Ctrl−`, desc: t('statusbar.zoom') },
|
||||
{ keys: `Alt+1-7`, desc: t('statusbar.switchTab') },
|
||||
{ keys: `${layout.keys.shift}+Tab`, desc: t('statusbar.nextTab') },
|
||||
{ keys: `${layout.keys.ctrl}+${layout.keys.shift}+F`, desc: t('statusbar.search') },
|
||||
{ keys: `${layout.keys.ctrl}++/${layout.keys.ctrl}+−`, desc: t('statusbar.zoom') },
|
||||
{ keys: layout.keys.enter, desc: t('statusbar.runCommand') },
|
||||
{ keys: `${layout.keys.up}/${layout.keys.down}`, desc: t('statusbar.commandHistory') },
|
||||
],
|
||||
|
||||
@@ -6,6 +6,12 @@ const MAX_POINTS = 30
|
||||
const POLL_INTERVAL = 5000
|
||||
const MAX_IDLE_POLLS = 3
|
||||
|
||||
function formatTokens(n) {
|
||||
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M'
|
||||
if (n >= 1000) return (n / 1000).toFixed(1) + 'K'
|
||||
return String(n)
|
||||
}
|
||||
|
||||
function MiniGraph({ data, max, color, label, unit }) {
|
||||
if (!data || data.length < 2) return <div className="dash-graph-empty">collecting...</div>
|
||||
const m = max || Math.max(...data, 1)
|
||||
@@ -37,9 +43,28 @@ function MiniGraph({ data, max, color, label, unit }) {
|
||||
)
|
||||
}
|
||||
|
||||
function BarChart({ data, max, color }) {
|
||||
if (!data || data.length === 0) return null
|
||||
const barW = 100 / 7
|
||||
const m = max || Math.max(...data.map(d => d.tokens), 1)
|
||||
return (
|
||||
<svg viewBox="0 0 100 40" className="dash-graph-svg" preserveAspectRatio="none">
|
||||
{data.map((d, i) => {
|
||||
const h = Math.max(1, (d.tokens / m) * 36)
|
||||
const x = i * barW + barW * 0.15
|
||||
const w = barW * 0.7
|
||||
return (
|
||||
<rect key={i} x={x} y={40 - h} width={w} height={h} rx="1.5" fill={color} opacity={0.85} />
|
||||
)
|
||||
})}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Dashboard({ api, refreshRef }) {
|
||||
const { t } = useI18n()
|
||||
const [quota, setQuota] = useState(null)
|
||||
const [consumption, setConsumption] = useState(null)
|
||||
const [recentCmds, setRecentCmds] = useState([])
|
||||
const [processes, setProcesses] = useState([])
|
||||
const [metrics, setMetrics] = useState(null)
|
||||
@@ -51,13 +76,15 @@ export default function Dashboard({ api, refreshRef }) {
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
try {
|
||||
const [quotaData, cmdData, procData, metricsData] = await Promise.all([
|
||||
const [quotaData, consumData, cmdData, procData, metricsData] = await Promise.all([
|
||||
api.getProvidersQuota().catch(() => null),
|
||||
api.getProvidersConsumption().catch(() => null),
|
||||
api.getRecentCommands().catch(() => ({ commands: [] })),
|
||||
api.getRunningProcesses().catch(() => ({ processes: [] })),
|
||||
api.getSystemMetrics().catch(() => null),
|
||||
])
|
||||
setQuota(quotaData?.providers || [])
|
||||
setConsumption(consumData?.providers || {})
|
||||
setRecentCmds(cmdData.commands || [])
|
||||
setProcesses(procData.processes || [])
|
||||
if (metricsData) {
|
||||
@@ -91,7 +118,6 @@ export default function Dashboard({ api, refreshRef }) {
|
||||
}, [loadData, refreshRef])
|
||||
|
||||
const minimax = (quota || []).find(p => p.name === 'minimax')
|
||||
const mimo = (quota || []).find(p => p.name === 'mimo')
|
||||
|
||||
const EXCLUDE_CMDS = ['ls', 'cd', 'pwd', 'clear', 'exit', 'history', 'cat', 'echo', 'grep', 'export', 'alias', 'unalias', 'set', 'unset', 'source', '.', 'fg', 'bg', 'jobs', 'wait', 'true', 'false', 'yes', 'sleep', 'date', 'whoami', 'id', 'uname', 'hostname', 'uptime', 'df', 'free', 'top', 'htop', 'nano', 'vi', 'vim', 'less', 'more', 'tail', 'head', 'man', 'info', 'which', 'whereis', 'type', 'command', 'hash', 'builtin', 'help']
|
||||
|
||||
@@ -135,6 +161,12 @@ export default function Dashboard({ api, refreshRef }) {
|
||||
})
|
||||
})()
|
||||
|
||||
const providerEntries = consumption ? Object.entries(consumption) : []
|
||||
const colors = ['var(--accent)', '#34d399', '#a78bfa', '#f59e0b', '#f472b6']
|
||||
const maxDaily = providerEntries.length > 0
|
||||
? Math.max(...providerEntries.map(([, p]) => Math.max(...(p.daily || []).map(d => d.tokens), 0)), 1)
|
||||
: 1
|
||||
|
||||
return (
|
||||
<div className="dash-grid">
|
||||
{/* CPU */}
|
||||
@@ -165,43 +197,36 @@ export default function Dashboard({ api, refreshRef }) {
|
||||
<MiniGraph data={netTxRef.current} max={null} color="#f59e0b" label="TX" unit=" KB/s" />
|
||||
</div>
|
||||
|
||||
{/* API Quota */}
|
||||
{/* Consommation */}
|
||||
<div className="dash-card">
|
||||
<div className="dash-card-head">
|
||||
<span className="dash-label">API Quota</span>
|
||||
<span className="dash-label">Consommation</span>
|
||||
<span className="dash-count">7j</span>
|
||||
</div>
|
||||
<div className="dash-quota-list">
|
||||
{minimax && minimax.data?.models?.map((m, i) => (
|
||||
<div key={i} className="dash-quota-row">
|
||||
<span className="dash-quota-name">{String(m.model).replace('MiniMax-', '')}</span>
|
||||
<div className="dash-bar">
|
||||
<div className="dash-bar-fill" style={{ width: `${Math.min(100, (m.used / m.total) * 100)}%` }} />
|
||||
<div className="dash-consumption-list">
|
||||
{providerEntries.length === 0 && (
|
||||
<span className="dash-empty">Aucune donnée</span>
|
||||
)}
|
||||
{providerEntries.map(([name, p], pi) => (
|
||||
<div key={name} className="dash-consumption-provider">
|
||||
<div className="dash-consumption-head">
|
||||
<span className="dash-consumption-name" style={{ color: colors[pi % colors.length] }}>
|
||||
{name.toUpperCase()}
|
||||
</span>
|
||||
<span className="dash-consumption-total">
|
||||
{formatTokens(p.total_tokens)} tokens · {p.total_requests} req
|
||||
</span>
|
||||
</div>
|
||||
<BarChart data={p.daily || []} max={maxDaily} color={colors[pi % colors.length]} />
|
||||
<div className="dash-consumption-days">
|
||||
{(p.daily || []).map((d, i) => (
|
||||
<span key={i} className="dash-consumption-day">
|
||||
{d.date.slice(5)} <strong>{formatTokens(d.tokens)}</strong>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<span className="dash-quota-val">{m.used}/{m.total}</span>
|
||||
</div>
|
||||
))}
|
||||
{minimax && minimax.data?.models?.length === 0 && (
|
||||
<div className="dash-quota-row">
|
||||
<span className="dash-quota-name">MiniMax</span>
|
||||
<span className="dash-quota-val" style={{ color: 'var(--text-tertiary)' }}>{minimax.error || 'no data'}</span>
|
||||
</div>
|
||||
)}
|
||||
{mimo && mimo.data?.models?.map((m, i) => (
|
||||
<div key={i} className="dash-quota-row">
|
||||
<span className="dash-quota-name">{String(m.model).replace('MiMo-', '')}</span>
|
||||
<div className="dash-bar">
|
||||
<div className="dash-bar-fill" style={{ width: `${Math.min(100, (m.used / m.total) * 100)}%` }} />
|
||||
</div>
|
||||
<span className="dash-quota-val">{m.used}/{m.total}</span>
|
||||
</div>
|
||||
))}
|
||||
{mimo && !mimo.data?.models?.length && (
|
||||
<div className="dash-quota-row">
|
||||
<span className="dash-quota-name">MiMo</span>
|
||||
<span className="dash-quota-val" style={{ color: 'var(--text-tertiary)' }}>{mimo.error || (mimo.healthy ? '✓ configured' : 'no key')}</span>
|
||||
</div>
|
||||
)}
|
||||
{!minimax && !mimo && <span className="dash-quota-val" style={{ color: 'var(--text-tertiary)' }}>No providers</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -9,10 +9,14 @@ import { ImageAddon } from '@xterm/addon-image'
|
||||
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'
|
||||
import mermaid from 'mermaid'
|
||||
|
||||
mermaid.initialize({ startOnLoad: false, theme: 'dark', securityLevel: 'loose', fontFamily: 'var(--font-mono)' })
|
||||
|
||||
const AI_TAB_ID = 0
|
||||
const MAX_TABS = 7
|
||||
const SHELL_MAX_TOKENS = 100000
|
||||
const SHELL_AI_COMMANDS = ['/clear', '/help', '/model', '/model change']
|
||||
const TABS_STORAGE_KEY = 'muyue_shell_tabs'
|
||||
const TERMINAL_BUFFER_KEY = 'muyue_terminal_buffers'
|
||||
|
||||
@@ -51,6 +55,15 @@ 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>')
|
||||
@@ -63,8 +76,8 @@ function formatText(text) {
|
||||
|
||||
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(/<br\/>\s*(<h[234]|<div class="msg-|<table)/g, '$1')
|
||||
.replace(/(<\/h[234]|<\/div>|<\/table>)\s*<br\/>/g, '$1')
|
||||
.replace(/\s+on\w+=["'][^"']*["']/gi, '')
|
||||
.replace(/javascript:/gi, '')
|
||||
.replace(/data:/gi, '')
|
||||
@@ -200,11 +213,13 @@ function getTheme(themeName) {
|
||||
}
|
||||
|
||||
function createTerminal(container, settings = {}) {
|
||||
console.log('[Shell] createTerminal called with settings:', JSON.stringify({ fontSize: settings.fontSize, fontFamily: settings.fontFamily?.slice(0, 30), theme: settings.theme }))
|
||||
const theme = getTheme(settings.theme || 'system')
|
||||
const actualFontSize = settings.fontSize || 14
|
||||
const term = new XTerm({
|
||||
cursorBlink: true,
|
||||
allowProposedApi: true,
|
||||
fontSize: settings.fontSize || 14,
|
||||
fontSize: actualFontSize,
|
||||
fontFamily: settings.fontFamily || "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', 'Menlo', monospace",
|
||||
theme,
|
||||
allowTransparency: false,
|
||||
@@ -404,12 +419,13 @@ export default function Shell({ api }) {
|
||||
theme: 'system',
|
||||
})
|
||||
|
||||
const [configLoaded, setConfigLoaded] = useState(false)
|
||||
const [showSearch, setShowSearch] = useState(false)
|
||||
const [searchText, setSearchText] = useState('')
|
||||
const searchInputRef = useRef(null)
|
||||
const searchDecorationsRef = useRef(null)
|
||||
const [zoomLevel, setZoomLevel] = useState(0)
|
||||
const baseFontSizeRef = useRef(12)
|
||||
const baseFontSizeRef = useRef(14)
|
||||
|
||||
useEffect(() => { settingsRef.current = terminalSettings }, [terminalSettings])
|
||||
|
||||
@@ -450,6 +466,7 @@ export default function Shell({ api }) {
|
||||
const [analyzing, setAnalyzing] = useState(false)
|
||||
const [showAnalysis, setShowAnalysis] = useState(false)
|
||||
const [analysisContent, setAnalysisContent] = useState('')
|
||||
const [sudoModal, setSudoModal] = useState(null)
|
||||
const aiMessagesRef = useRef(null)
|
||||
const aiLoadedRef = useRef(false)
|
||||
const aiLoadingRef = useRef(false)
|
||||
@@ -473,14 +490,10 @@ export default function Shell({ api }) {
|
||||
api.getShellChatHistory().then(d => {
|
||||
if (d.messages && d.messages.length > 0) {
|
||||
setAiMessages(d.messages)
|
||||
} else {
|
||||
setAiMessages([{ role: 'assistant', content: t('shell.aiWelcome') || 'Système Analyste prêt. Tapez /help pour les commandes.' }])
|
||||
}
|
||||
setAiTokens(d.tokens || 0)
|
||||
setAiAtLimit(d.at_limit || false)
|
||||
}).catch(() => {
|
||||
setAiMessages([{ role: 'assistant', content: 'Système Analyste prêt.' }])
|
||||
})
|
||||
}).catch(() => {})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -494,14 +507,21 @@ export default function Shell({ api }) {
|
||||
setSystemTerminals(d.system || [])
|
||||
}).catch(() => {})
|
||||
api.getConfig().then(d => {
|
||||
console.log('[Shell] config response terminal:', JSON.stringify(d?.terminal))
|
||||
if (d.terminal) {
|
||||
setTerminalSettings({
|
||||
fontSize: d.terminal.font_size || 14,
|
||||
fontFamily: d.terminal.font_family || "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', 'Menlo', monospace",
|
||||
theme: d.terminal.theme || 'system',
|
||||
})
|
||||
const fontSize = d.terminal.font_size || 14
|
||||
const fontFamily = d.terminal.font_family || "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', 'Menlo', monospace"
|
||||
const theme = d.terminal.theme || 'system'
|
||||
console.log('[Shell] setting fontSize to:', fontSize, 'from config')
|
||||
setTerminalSettings({ fontSize, fontFamily, theme })
|
||||
settingsRef.current = { fontSize, fontFamily, theme }
|
||||
baseFontSizeRef.current = fontSize
|
||||
} else {
|
||||
console.log('[Shell] no terminal config in response, using defaults')
|
||||
}
|
||||
}).catch(() => {})
|
||||
setConfigLoaded(true)
|
||||
console.log('[Shell] configLoaded = true, settingsRef:', JSON.stringify(settingsRef.current))
|
||||
}).catch((err) => { console.warn('[Shell] getConfig failed:', err); setConfigLoaded(true) })
|
||||
}, [])
|
||||
|
||||
const initTerminal = useCallback((tabId, tab) => {
|
||||
@@ -512,6 +532,7 @@ export default function Shell({ api }) {
|
||||
|
||||
const s = settingsRef.current
|
||||
const effectiveFontSize = s.fontSize + zoomLevel * 2
|
||||
console.log(`[Shell] initTerminal tab=${tabId}: settingsRef.fontSize=${s.fontSize}, zoomLevel=${zoomLevel}, effectiveFontSize=${effectiveFontSize}`)
|
||||
const { term, fitAddon, searchAddon } = createTerminal(container, {
|
||||
fontSize: effectiveFontSize,
|
||||
fontFamily: s.fontFamily,
|
||||
@@ -621,6 +642,7 @@ export default function Shell({ api }) {
|
||||
}, [])
|
||||
|
||||
const initPendingTabs = useCallback(() => {
|
||||
if (!configLoaded) return
|
||||
for (const tab of tabsRef.current._tabList || []) {
|
||||
if (!tabsRef.current[tab.id]) {
|
||||
const container = document.getElementById(`terminal-${tab.id}`)
|
||||
@@ -641,7 +663,7 @@ export default function Shell({ api }) {
|
||||
}
|
||||
}, 150)
|
||||
})
|
||||
}, [initTerminal])
|
||||
}, [initTerminal, configLoaded])
|
||||
|
||||
useEffect(() => {
|
||||
tabsRef.current._tabList = tabs
|
||||
@@ -689,6 +711,7 @@ export default function Shell({ api }) {
|
||||
}
|
||||
|
||||
if (!tabsRef.current[tab.id]) {
|
||||
console.log(`[Shell] tryInitTab: calling initTerminal for tab ${tab.id}, configLoaded=${configLoaded}`)
|
||||
initTerminal(tab.id, tab)
|
||||
}
|
||||
|
||||
@@ -707,8 +730,9 @@ export default function Shell({ api }) {
|
||||
})
|
||||
}
|
||||
|
||||
console.log(`[Shell] init effect: tabs=${tabs.length}, configLoaded=${configLoaded}`)
|
||||
for (const tab of tabs) {
|
||||
if (!tabsRef.current[tab.id]) {
|
||||
if (configLoaded && !tabsRef.current[tab.id]) {
|
||||
tryInitTab(tab, 0)
|
||||
}
|
||||
}
|
||||
@@ -729,7 +753,7 @@ export default function Shell({ api }) {
|
||||
pending.forEach(clearTimeout)
|
||||
observer?.disconnect()
|
||||
}
|
||||
}, [tabs, initTerminal, initPendingTabs])
|
||||
}, [tabs, initTerminal, initPendingTabs, configLoaded])
|
||||
|
||||
useEffect(() => {
|
||||
const entry = tabsRef.current[activeTab]
|
||||
@@ -976,9 +1000,10 @@ export default function Shell({ api }) {
|
||||
if (entry) entry.term.focus()
|
||||
}, [])
|
||||
|
||||
const _sendAiMessage = useCallback(async (text, fromEvent = false) => {
|
||||
if (!text || !text.trim() || aiLoadingRef.current || aiAtLimit) return
|
||||
const _sendAiMessage = useCallback(async (text, fromEvent = false, isAnalysis = false) => {
|
||||
if (!text || !text.trim() || aiLoadingRef.current) return
|
||||
const trimmed = text.trim()
|
||||
if (aiAtLimit && !trimmed.startsWith('/')) return
|
||||
aiLoadingRef.current = true
|
||||
|
||||
if (!fromEvent) {
|
||||
@@ -986,10 +1011,21 @@ export default function Shell({ api }) {
|
||||
setTimeout(() => focusAiTerminal(), 0)
|
||||
}
|
||||
|
||||
const isSlashCommand = (t) => /^\/(clear|help|model(?:\s+\S+)?)$/.test(t)
|
||||
|
||||
if (trimmed.startsWith('/') && !isSlashCommand(trimmed)) {
|
||||
setAiMessages(prev => [...prev,
|
||||
{ role: 'user', content: trimmed },
|
||||
{ role: 'assistant', content: 'Commande inconnue. Tapez `/help` pour la liste des commandes.' }
|
||||
])
|
||||
aiLoadingRef.current = false
|
||||
return
|
||||
}
|
||||
|
||||
if (trimmed === '/clear') {
|
||||
try {
|
||||
await api.clearShellChat()
|
||||
setAiMessages([{ role: 'assistant', content: t('shell.aiWelcome') || 'Contexte effacé. Prêt.' }])
|
||||
setAiMessages([])
|
||||
setAiTokens(0)
|
||||
setAiAtLimit(false)
|
||||
} catch {}
|
||||
@@ -1000,15 +1036,50 @@ export default function Shell({ api }) {
|
||||
if (trimmed === '/help') {
|
||||
setAiMessages(prev => [...prev,
|
||||
{ role: 'user', content: trimmed },
|
||||
{ role: 'assistant', content: 'Commandes disponibles:\n• /clear — Effacer la conversation\n• /help — Afficher l\'aide\n\nJe peux exécuter des commandes via l\'outil terminal. Les blocs de code proposés peuvent aussi être copiés ou envoyés directement au terminal actif.' }
|
||||
{ role: 'assistant', content: '## Commandes Terminal\n\n- `/clear` — Effacer la conversation\n- `/help` — Afficher cette aide\n- `/model` — Afficher le provider et modèle actifs\n- `/model change` — Basculer entre les providers disponibles' }
|
||||
])
|
||||
aiLoadingRef.current = false
|
||||
return
|
||||
}
|
||||
|
||||
if (trimmed === '/model' || trimmed === '/model change') {
|
||||
if (trimmed === '/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) {
|
||||
setAiMessages(prev => [...prev, { role: 'assistant', content: 'MiniMax et MiMo doivent être configurés pour utiliser `/model change`.' }])
|
||||
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(() => {
|
||||
setAiMessages(prev => [...prev, { role: 'assistant', content: `✓ Provider changé: **${target.name}** (${target.model})` }])
|
||||
}).catch(() => {
|
||||
setAiMessages(prev => [...prev, { role: 'assistant', content: 'Erreur lors du changement de provider.' }])
|
||||
})
|
||||
}).catch(() => {
|
||||
setAiMessages(prev => [...prev, { role: 'assistant', content: 'Erreur: impossible de récupérer les providers' }])
|
||||
})
|
||||
} else {
|
||||
api.getProviders().then(data => {
|
||||
const active = data.providers?.find(p => p.active)
|
||||
const modelMsg = active ? `**${active.name}** — ${active.model}` : 'Aucun provider actif configuré'
|
||||
setAiMessages(prev => [...prev, { role: 'assistant', content: modelMsg }])
|
||||
}).catch(() => {
|
||||
setAiMessages(prev => [...prev, { role: 'assistant', content: 'Erreur: impossible de récupérer les providers' }])
|
||||
})
|
||||
}
|
||||
aiLoadingRef.current = false
|
||||
return
|
||||
}
|
||||
|
||||
const currentTab = activeTabRef.current
|
||||
console.log(`[Shell] _sendAiMessage: activeTab=${currentTab}, fromEvent=${fromEvent}, text="${trimmed.slice(0, 50)}"`)
|
||||
setAiMessages(prev => [...prev, { role: 'user', content: trimmed, _tabId: currentTab }])
|
||||
setAiMessages(prev => [...prev, { role: 'user', content: trimmed, _tabId: currentTab, _analysis: isAnalysis || undefined }])
|
||||
setAiLoading(true)
|
||||
|
||||
try {
|
||||
@@ -1026,6 +1097,9 @@ export default function Shell({ api }) {
|
||||
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 }
|
||||
@@ -1091,23 +1165,20 @@ export default function Shell({ api }) {
|
||||
return () => window.removeEventListener('ask-ai-terminal', handler)
|
||||
}, [_sendAiMessage])
|
||||
|
||||
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.'
|
||||
}])
|
||||
} catch (err) {
|
||||
setAiMessages(prev => prev.filter(m => m.content !== 'Analyse du système en cours...'))
|
||||
}
|
||||
setAnalyzing(false)
|
||||
const handleAnalyze = () => {
|
||||
_sendAiMessage(`Fais une analyse complète du système. Utilise l'outil terminal pour explorer et rédige un rapport structuré en markdown. Couvre:
|
||||
|
||||
1. **OS & Matériel** — distrib, kernel, CPU, RAM, GPU, hostname
|
||||
2. **Disque & Partitions** — occupation, points de montage, inodes
|
||||
3. **Réseau** — interfaces, IP, ports en écoute, DNS, pare-feu, SSH actif ?
|
||||
4. **Utilisateurs & Sécurité** — utilisateurs, groupes sudo, last logins, fail2ban, règles sudo
|
||||
5. **Services & Processus** — systemd actifs, top processus par CPU/RAM, load average, uptime
|
||||
6. **Outils installés** — IDE (VSCode, JetBrains...), langages (go, python, node, rust...), gestionnaire de paquets, conteneurs (docker, podman), git, outils CLI
|
||||
7. **Environnement utilisateur** — shell, $HOME (arborescence top-level, nombre de fichiers/projects), dotfiles, config notable (.bashrc, .zshrc, starship)
|
||||
8. **Paquets & Mises à jour** — paquets obsolètes, kernel à jour, security patches
|
||||
9. **Recommandations** — outils manquants, optimisations, points d'attention
|
||||
|
||||
Sois concret : cite les vraies versions, les vrais chemins, les vrais nombres. Le rapport fera office de carte d'identité du système (max ~40k tokens).`, true, true)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -1291,7 +1362,37 @@ export default function Shell({ api }) {
|
||||
<input
|
||||
value={aiInput}
|
||||
onChange={e => setAiInput(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); e.stopPropagation(); handleAiSend() } }}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); e.stopPropagation(); handleAiSend(); return }
|
||||
if (e.key === 'Tab') {
|
||||
e.preventDefault()
|
||||
const val = aiInput
|
||||
const pos = e.target.selectionStart
|
||||
const before = val.slice(0, pos)
|
||||
const afterSlash = before.match(/\/[\w ]*$/)
|
||||
if (afterSlash) {
|
||||
const partial = afterSlash[0]
|
||||
const matches = SHELL_AI_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)
|
||||
setAiInput(newText)
|
||||
requestAnimationFrame(() => {
|
||||
e.target.selectionStart = e.target.selectionEnd = pos - afterSlash[0].length + completed.length
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
}}
|
||||
placeholder={aiAtLimit ? '/clear pour continuer' : t('shell.askAi')}
|
||||
disabled={aiAtLimit && aiInput !== '/clear'}
|
||||
/>
|
||||
@@ -1308,7 +1409,12 @@ export default function Shell({ api }) {
|
||||
</div>
|
||||
<div className="shell-analysis-modal-body">
|
||||
{renderContent(analysisContent).map((part, i) =>
|
||||
part.type === 'code' ? (
|
||||
part.type === 'code' && part.lang === 'mermaid' ? (
|
||||
<div key={i} className="shell-code-block">
|
||||
<div className="shell-code-lang">mermaid</div>
|
||||
<MermaidBlock code={part.content} />
|
||||
</div>
|
||||
) : 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>
|
||||
@@ -1371,6 +1477,22 @@ export default function Shell({ api }) {
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -1409,12 +1531,36 @@ function ShellToolBlock({ call, result }) {
|
||||
)
|
||||
}
|
||||
|
||||
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 = `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="shell-mermaid-error">{code}</pre>
|
||||
if (!svg) return <div className="shell-mermaid-loading">Chargement du diagramme...</div>
|
||||
return <div className="shell-mermaid-container" ref={ref} dangerouslySetInnerHTML={{ __html: svg }} />
|
||||
}
|
||||
|
||||
function ShellAIMessage({ msg, sendToTerminal, terminalTabId }) {
|
||||
const role = msg.role === 'user' ? 'user' : msg.role === 'system' ? 'system' : 'assistant'
|
||||
const content = msg.content || ''
|
||||
const [copiedIdx, setCopiedIdx] = useState(null)
|
||||
|
||||
if (role === 'user') {
|
||||
return <div className={`ai-message user`}>{content}</div>
|
||||
return <div className={`ai-message user${msg._analysis ? ' analysis' : ''}`} dangerouslySetInnerHTML={{ __html: formatText(content) }} />
|
||||
}
|
||||
|
||||
if (role === 'system') {
|
||||
@@ -1426,16 +1572,16 @@ function ShellAIMessage({ msg, sendToTerminal, terminalTabId }) {
|
||||
let displayContent = content
|
||||
let streamingToolCalls = msg._toolCalls || null
|
||||
|
||||
if (!streamingToolCalls) {
|
||||
try {
|
||||
const parsed = JSON.parse(content)
|
||||
if (parsed && Array.isArray(parsed.tool_calls)) {
|
||||
try {
|
||||
const parsed = JSON.parse(content)
|
||||
if (parsed && Array.isArray(parsed.tool_calls)) {
|
||||
if (!streamingToolCalls) {
|
||||
parsedToolCalls = parsed.tool_calls
|
||||
parsedToolResults = parsed.tool_results || null
|
||||
displayContent = parsed.content || ''
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
displayContent = parsed.content || ''
|
||||
}
|
||||
} catch {}
|
||||
|
||||
const parts = renderContent(displayContent)
|
||||
|
||||
@@ -1454,14 +1600,26 @@ function ShellAIMessage({ msg, sendToTerminal, terminalTabId }) {
|
||||
return <ShellToolBlock key={tc.tool_call_id || i} call={tc} result={result} />
|
||||
})}
|
||||
{parts.map((part, i) => {
|
||||
if (part.type === 'code' && part.lang === 'mermaid') {
|
||||
return (
|
||||
<div key={i} className="shell-code-block">
|
||||
<div className="shell-code-lang">mermaid</div>
|
||||
<MermaidBlock code={part.content} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (part.type === 'code') {
|
||||
return (
|
||||
<div key={i} className="shell-code-block">
|
||||
{part.lang && <div className="shell-code-lang">{part.lang}</div>}
|
||||
<pre><code>{part.content}</code></pre>
|
||||
<div className="shell-code-actions">
|
||||
<button onClick={() => navigator.clipboard.writeText(part.content)} title="Copier">
|
||||
<Copy size={12} /> Copier
|
||||
<button className={copiedIdx === i ? 'copied' : ''} onClick={() => {
|
||||
navigator.clipboard.writeText(part.content)
|
||||
setCopiedIdx(i)
|
||||
setTimeout(() => setCopiedIdx(null), 1500)
|
||||
}} title="Copier">
|
||||
<Copy size={12} /> {copiedIdx === i ? 'Copié !' : 'Copier'}
|
||||
</button>
|
||||
<button onClick={() => sendToTerminal(part.content, terminalTabId)} title="Envoyer au terminal">
|
||||
<Send size={12} /> Terminal
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
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' },
|
||||
@@ -64,7 +67,16 @@ function renderContent(text) {
|
||||
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>')
|
||||
@@ -74,15 +86,15 @@ function formatText(text) {
|
||||
.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(/<br\/>\s*(<h[234]|<div class="msg-|<table)/g, '$1')
|
||||
.replace(/(<\/h[234]|<\/div>|<\/table>)\s*<br\/>/g, '$1')
|
||||
.replace(/\s+on\w+=["'][^"']*["']/gi, '')
|
||||
.replace(/javascript:/gi, '')
|
||||
.replace(/data:/gi, '')
|
||||
|
||||
|
||||
return html
|
||||
}
|
||||
|
||||
@@ -168,10 +180,69 @@ function ToolCallBlock({ call, result }) {
|
||||
)
|
||||
}
|
||||
|
||||
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' }) : ''
|
||||
|
||||
@@ -226,10 +297,7 @@ function FeedItem({ msg }) {
|
||||
<div className="feed-content">
|
||||
{renderContent(cleanContent).map((part, i) =>
|
||||
part.type === 'code' ? (
|
||||
<div key={i} className="studio-code-block">
|
||||
{part.lang && <div className="studio-code-lang">{part.lang}</div>}
|
||||
<pre><code>{part.content}</code></pre>
|
||||
</div>
|
||||
<CodeBlockWithCopy key={i} part={part} index={i} copiedIdx={copiedIdx} setCopiedIdx={setCopiedIdx} />
|
||||
) : (
|
||||
<span key={i} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} />
|
||||
)
|
||||
@@ -245,6 +313,7 @@ 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 []
|
||||
@@ -281,10 +350,7 @@ function StreamingItem({ content, thinking, toolCalls }) {
|
||||
<div className="feed-content">
|
||||
{renderedContent.map((part, i) =>
|
||||
part.type === 'code' ? (
|
||||
<div key={i} className="studio-code-block">
|
||||
{part.lang && <div className="studio-code-lang">{part.lang}</div>}
|
||||
<pre><code>{part.content}</code></pre>
|
||||
</div>
|
||||
<CodeBlockWithCopy key={i} part={part} index={i} copiedIdx={copiedIdx} setCopiedIdx={setCopiedIdx} />
|
||||
) : (
|
||||
<span key={i} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} />
|
||||
)
|
||||
@@ -309,6 +375,7 @@ export default function Studio({ api }) {
|
||||
const [tokenInfo, setTokenInfo] = useState({ used: 0, max: 100000, summarizeAt: 80000 })
|
||||
const [contextCollapsed, setContextCollapsed] = useState(false)
|
||||
const [messagesCollapsed, setMessagesCollapsed] = useState(false)
|
||||
const [sudoModal, setSudoModal] = useState(null)
|
||||
const messagesEnd = useRef(null)
|
||||
const feedRef = useRef(null)
|
||||
const textareaRef = useRef(null)
|
||||
@@ -404,7 +471,7 @@ export default function Studio({ api }) {
|
||||
const text = input.trim()
|
||||
setInput('')
|
||||
|
||||
const isSlashCommand = (t) => /^\/(clear|help|summarize|export|model(?:\s+\S+)?|plan\s+.+)$/.test(t)
|
||||
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() }])
|
||||
@@ -424,19 +491,8 @@ export default function Studio({ api }) {
|
||||
'- `/clear` - Effacer la conversation',
|
||||
'- `/summarize` - Résumer la conversation précédente',
|
||||
'- `/help` - Afficher cette aide',
|
||||
'- `/plan <objectif>` - Demander un plan structuré',
|
||||
'- `/export` - Exporter la conversation en Markdown',
|
||||
'- `/model` - Afficher le provider et modèle actifs',
|
||||
'- `/model change` - Basculer entre MiniMax et ZAI',
|
||||
'',
|
||||
'## 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',
|
||||
'- `/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
|
||||
@@ -481,31 +537,6 @@ export default function Studio({ api }) {
|
||||
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)
|
||||
@@ -537,6 +568,9 @@ export default function Studio({ api }) {
|
||||
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 }
|
||||
@@ -602,7 +636,7 @@ export default function Studio({ api }) {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const COMMANDS = ['/clear', '/summarize', '/help', '/plan', '/export', '/model', '/model change']
|
||||
const COMMANDS = ['/clear', '/summarize', '/help', '/model', '/model change']
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
@@ -744,9 +778,25 @@ export default function Studio({ api }) {
|
||||
)}
|
||||
</div>
|
||||
<div className="studio-input-hint">
|
||||
{t('studio.inputHint')} · /clear /summarize /help /plan /export /model /model change
|
||||
{t('studio.inputHint')} · /clear /summarize /help /model
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -458,6 +458,8 @@ input::placeholder { color: var(--text-disabled); }
|
||||
.shell-ai-token-text { font-size: 10px; font-family: var(--font-mono); color: var(--text-tertiary); white-space: nowrap; }
|
||||
.ai-panel-messages { flex: 1; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 8px; }
|
||||
.ai-message { padding: 8px 12px; border-radius: var(--radius); font-size: 13px; line-height: 1.5; word-break: break-word; }
|
||||
.ai-message.user { background: var(--bg-elevated); border-left: 3px solid var(--accent-bright); color: var(--text-primary); }
|
||||
.ai-message.user.analysis { border-left-color: var(--info); background: color-mix(in srgb, var(--info) 8%, var(--bg-elevated)); }
|
||||
.ai-message.assistant { background: var(--bg-card); border-left: 3px solid var(--accent); }
|
||||
.ai-message.system { background: var(--bg-elevated); border-left: 3px solid var(--info); font-style: italic; color: var(--text-tertiary); font-size: 12px; }
|
||||
.ai-message.assistant { background: var(--bg-card); border-left: 3px solid var(--accent); }
|
||||
@@ -492,6 +494,23 @@ input::placeholder { color: var(--text-disabled); }
|
||||
}
|
||||
.shell-code-actions button:last-child { border-right: none; }
|
||||
.shell-code-actions button:hover { background: var(--accent-bg); color: var(--accent); }
|
||||
.shell-code-actions button.copied { background: var(--accent-bg); color: var(--accent); animation: copy-flash 0.3s ease; }
|
||||
|
||||
.shell-mermaid-container { padding: 12px; background: var(--bg); overflow-x: auto; display: flex; justify-content: center; }
|
||||
.shell-mermaid-container svg { max-width: 100%; height: auto; }
|
||||
.shell-mermaid-loading { padding: 16px; text-align: center; color: var(--text-tertiary); font-size: 12px; }
|
||||
.shell-mermaid-error { padding: 10px 12px; color: var(--accent-bright); font-family: var(--font-mono); font-size: 12px; white-space: pre-wrap; }
|
||||
|
||||
.ai-message table { width: 100%; border-collapse: collapse; margin: 6px 0; font-size: 12px; }
|
||||
.ai-message th { background: var(--bg-surface); padding: 6px 10px; text-align: left; font-weight: 600; border: 1px solid var(--border); color: var(--text-secondary); }
|
||||
.ai-message td { padding: 5px 10px; border: 1px solid var(--border); color: var(--text-primary); }
|
||||
.ai-message tr:nth-child(even) td { background: var(--bg-surface); }
|
||||
|
||||
@keyframes copy-flash {
|
||||
0% { transform: scale(1); }
|
||||
50% { transform: scale(1.05); background: color-mix(in srgb, var(--accent) 20%, transparent); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
|
||||
.shell-analysis-modal {
|
||||
background: var(--bg-elevated); border: 1px solid var(--border);
|
||||
@@ -720,6 +739,25 @@ input::placeholder { color: var(--text-disabled); }
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Consumption */
|
||||
.dash-consumption-list { display: flex; flex-direction: column; gap: 10px; max-height: 270px; overflow-y: auto; }
|
||||
.dash-consumption-provider { display: flex; flex-direction: column; gap: 4px; }
|
||||
.dash-consumption-head { display: flex; align-items: center; justify-content: space-between; }
|
||||
.dash-consumption-name {
|
||||
font-size: 11px; font-weight: 700; letter-spacing: 0.5px;
|
||||
}
|
||||
.dash-consumption-total {
|
||||
font-size: 10px; font-family: var(--font-mono); color: var(--text-tertiary);
|
||||
}
|
||||
.dash-consumption-days {
|
||||
display: flex; gap: 4px; flex-wrap: wrap;
|
||||
}
|
||||
.dash-consumption-day {
|
||||
font-size: 9px; font-family: var(--font-mono); color: var(--text-tertiary);
|
||||
background: var(--bg-input); padding: 1px 5px; border-radius: 4px;
|
||||
}
|
||||
.dash-consumption-day strong { color: var(--text-secondary); }
|
||||
|
||||
/* Processes */
|
||||
.dash-proc-list { display: flex; flex-direction: column; gap: 4px; max-height: 270px; overflow-y: auto; }
|
||||
.dash-proc-row {
|
||||
@@ -933,11 +971,33 @@ input::placeholder { color: var(--text-disabled); }
|
||||
background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius);
|
||||
overflow: hidden; margin: 8px 0;
|
||||
}
|
||||
.studio-code-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
background: var(--bg-surface); border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.studio-code-block pre { padding: 12px 16px; font-family: var(--font-mono); font-size: 13px; line-height: 1.5; overflow-x: auto; color: var(--text-primary); margin: 0; }
|
||||
.studio-code-lang {
|
||||
padding: 4px 12px; font-size: 11px; font-weight: 600; color: var(--text-tertiary);
|
||||
background: var(--bg-surface); border-bottom: 1px solid var(--border); text-transform: uppercase; letter-spacing: 0.5px;
|
||||
background: var(--bg-surface); text-transform: uppercase; letter-spacing: 0.5px;
|
||||
}
|
||||
.studio-copy-btn {
|
||||
padding: 3px 10px; font-size: 10px; font-weight: 600; color: var(--text-tertiary);
|
||||
background: transparent; border: none; border-left: 1px solid var(--border);
|
||||
cursor: pointer; transition: all 0.15s; font-family: var(--font-sans);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.studio-copy-btn:hover { background: var(--accent-bg); color: var(--accent); }
|
||||
.studio-copy-btn.copied { background: var(--accent-bg); color: var(--accent); }
|
||||
|
||||
.studio-mermaid-container { padding: 12px; background: var(--bg); overflow-x: auto; display: flex; justify-content: center; }
|
||||
.studio-mermaid-container svg { max-width: 100%; height: auto; }
|
||||
.studio-mermaid-loading { padding: 12px; text-align: center; color: var(--text-tertiary); font-size: 12px; }
|
||||
.studio-mermaid-error { padding: 10px 12px; color: var(--accent-bright); font-family: var(--font-mono); font-size: 12px; white-space: pre-wrap; }
|
||||
|
||||
.feed-content table { width: 100%; border-collapse: collapse; margin: 8px 0; font-size: 13px; }
|
||||
.feed-content th { background: var(--bg-surface); padding: 6px 12px; text-align: left; font-weight: 600; border: 1px solid var(--border); color: var(--text-secondary); }
|
||||
.feed-content td { padding: 5px 12px; border: 1px solid var(--border); color: var(--text-primary); }
|
||||
.feed-content tr:nth-child(even) td { background: var(--bg-surface); }
|
||||
.inline-code { background: var(--bg-input); padding: 2px 6px; border-radius: 4px; font-family: var(--font-mono); font-size: 13px; color: var(--accent-muted); }
|
||||
.msg-h3 { font-size: 16px; font-weight: 700; color: var(--text-primary); margin: 10px 0 4px; display: block; }
|
||||
.msg-h4 { font-size: 14px; font-weight: 700; color: var(--text-secondary); margin: 8px 0 3px; display: block; }
|
||||
|
||||
Reference in New Issue
Block a user