diff --git a/web/src/components/Config.jsx b/web/src/components/Config.jsx index f96bc2c..a5b41e2 100644 --- a/web/src/components/Config.jsx +++ b/web/src/components/Config.jsx @@ -1,13 +1,11 @@ import { useState, useEffect, useCallback } from 'react' -import { User, Brain, RefreshCw, Globe, Wrench, Monitor } from 'lucide-react' -import { useI18n, LANGUAGES } from '../i18n' -import { getLayoutList } from '../i18n/keyboards' +import { User, Brain, RefreshCw, Wrench, Monitor, AlertTriangle, X } from 'lucide-react' +import { useI18n } from '../i18n' const PANELS = [ { id: 'profile', icon: User }, { id: 'providers', icon: Brain }, { id: 'updates', icon: RefreshCw }, - { id: 'locale', icon: Globe }, { id: 'skills', icon: Wrench }, { id: 'system', icon: Monitor }, ] @@ -29,8 +27,6 @@ export default function Config({ api }) { const [toast, setToast] = useState(null) - const layouts = getLayoutList() - const loadData = useCallback(() => { api.getConfig().then(d => { setConfig(d) @@ -168,13 +164,6 @@ export default function Config({ api }) { t={t} /> )} - {activePanel === 'locale' && ( - - )} {activePanel === 'skills' && ( )} @@ -320,21 +309,36 @@ function getFieldLabel(key, t) { function PanelProviders({ providers, editProvider, providerForm, setProviderForm, setEditProvider, openProviderEdit, handleSaveProvider, api, loadData, t }) { const [validating, setValidating] = useState(null) - const [validationStatus, setValidationStatus] = useState(null) + const [keyStatus, setKeyStatus] = useState({}) + + useEffect(() => { + providers.forEach(p => { + if (p.apiKey && !keyStatus[p.name]) { + validateKey(p) + } else if (!p.apiKey) { + setKeyStatus(prev => ({ ...prev, [p.name]: { valid: false, checked: true, error: 'Aucune clé' } })) + } + }) + }, [providers]) + + const validateKey = async (p) => { + setValidating(p.name) + try { + await api.validateProvider({ name: p.name, api_key: p.apiKey, model: p.model, base_url: p.baseURL || '' }) + setKeyStatus(prev => ({ ...prev, [p.name]: { valid: true, checked: true } })) + } catch (err) { + setKeyStatus(prev => ({ ...prev, [p.name]: { valid: false, checked: true, error: err.message || 'Clé invalide' } })) + } + setValidating(null) + } const handleValidate = async (name, apiKey, model, baseUrl) => { setValidating(name) - setValidationStatus(null) try { await api.validateProvider({ name, api_key: apiKey, model, base_url: baseUrl }) - setValidationStatus({ provider: name, valid: true }) + setKeyStatus(prev => ({ ...prev, [name]: { valid: true, checked: true } })) } catch (err) { - const msg = err.message || '' - if (msg.includes('invalid_api_key')) { - setValidationStatus({ provider: name, valid: false, error: t('config.keyInvalid') }) - } else { - setValidationStatus({ provider: name, valid: false, error: `${t('config.connectionFailed')}: ${msg}` }) - } + setKeyStatus(prev => ({ ...prev, [name]: { valid: false, checked: true, error: err.message || 'Clé invalide' } })) } setValidating(null) } @@ -345,8 +349,8 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm
{displayed.map((p, i) => { const isEditing = editProvider === p.name - const isValidationTarget = validationStatus?.provider === p.name const currentModel = providerForm[p.name]?.model || p.model + const status = keyStatus[p.name] return (
@@ -354,8 +358,8 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm
{p.name.toUpperCase()} {p.active && active} - {isValidationTarget && validationStatus?.valid && {t('config.keyValid')}} - {isValidationTarget && !validationStatus?.valid && {validationStatus?.error}} + {status?.checked && status?.valid && ✓ {t('config.keyValid')}} + {status?.checked && !status?.valid && ✗ {status.error || t('config.keyInvalid')}}
@@ -402,7 +406,14 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm ) } -function PanelUpdates({ updates, checking, updating, needsUpdateCount, installedCount, missingCount, handleCheckUpdates, handleUpdateTool, handleUpdateAll, t }) { +function PanelUpdates({ updates, tools, checking, updating, needsUpdateCount, installedCount, missingCount, handleCheckUpdates, handleUpdateTool, handleUpdateAll, t }) { + const handleInstallTool = (tool) => { + window.dispatchEvent(new CustomEvent('navigate-to-shell', {})) + window.dispatchEvent(new CustomEvent('ask-ai-terminal', { detail: { message: `Installe l'outil ${tool} sur mon système. Vérifie d'abord s'il est déjà installé, puis installe-le si nécessaire avec les commandes appropriées.` } })) + } + + const missingTools = tools.filter(t => !t.installed) + return ( <>
@@ -425,6 +436,30 @@ function PanelUpdates({ updates, checking, updating, needsUpdateCount, installed
+ {missingTools.length > 0 && ( + <> +
{t('config.missing') || 'Modules manquants'}
+
+ {missingTools.map((tool, i) => ( +
+
+ {tool.name} + + {t('config.notInstalled') || 'Non installé'} + +
+ +
+ ))} +
+ + )} + {updates.length === 0 ? (
{t('config.noUpdates')}
@@ -460,98 +495,7 @@ function PanelUpdates({ updates, checking, updating, needsUpdateCount, installed ) } -function PanelLocale({ language, keyboard, layouts, api, t }) { - const { setLanguage, setKeyboard } = useI18n() - const [editLocale, setEditLocale] = useState(false) - const [draftLang, setDraftLang] = useState(language) - const [draftKbd, setDraftKbd] = useState(keyboard) - const [saving, setSaving] = useState(false) - const [toast, setToast] = useState(null) - const showToast = (msg) => { - setToast(msg) - setTimeout(() => setToast(null), 2500) - } - - const handleSave = async () => { - setSaving(true) - try { - await api.savePreferences({ language: draftLang, keyboard_layout: draftKbd }) - setLanguage(draftLang) - setKeyboard(draftKbd) - setEditLocale(false) - showToast(t('config.saved')) - } catch (err) { - showToast(`${t('config.error')}: ${err.message}`) - } - setSaving(false) - } - - const currentLang = LANGUAGES.find(l => l.id === language) - const currentKbd = layouts.find(l => l.id === keyboard) - - return ( -
- {toast &&
{toast}
} -
-
- {t('config.language')} - {currentLang?.name || language} -
-
- {t('config.keyboardLayout')} - {currentKbd?.name || keyboard} -
-
- {editLocale && ( -
-
- {t('config.language')} -
- {LANGUAGES.map(lang => ( -
setDraftLang(lang.id)} - > - {lang.name} -
- ))} -
-
-
- {t('config.keyboardLayout')} -
- {layouts.map(l => ( -
setDraftKbd(l.id)} - > - {l.name} -
- ))} -
-
-
- )} -
-
- {editLocale ? ( - <> - - - - ) : ( - - )} -
-
-
- ) -} function PanelSkills({ skillList, t }) { const [selected, setSelected] = useState(null) @@ -634,7 +578,7 @@ function PanelSkills({ skillList, t }) { } function PanelSystem({ api, t }) { - const [resetConfirm, setResetConfirm] = useState(false) + const [showResetModal, setShowResetModal] = useState(false) const [toast, setToast] = useState(null) const showToast = (msg) => { @@ -645,7 +589,7 @@ function PanelSystem({ api, t }) { const handleReset = async () => { try { await api.resetConfig() - setResetConfirm(false) + setShowResetModal(false) showToast(t('config.resetDone')) setTimeout(() => window.location.reload(), 1500) } catch (err) { @@ -653,49 +597,66 @@ function PanelSystem({ api, t }) { } } - const handleApplyStarship = async () => { - try { - await api.applyStarshipTheme('charm') - showToast(t('config.starshipApplied')) - } catch (err) { - showToast(`${t('config.error')}: ${err.message}`) - } + const handleApplyStarship = () => { + window.dispatchEvent(new CustomEvent('navigate-to-shell', {})) + window.dispatchEvent(new CustomEvent('ask-ai-terminal', { detail: { message: `Vérifie si starship est installé sur le système. S'il ne l'est pas, installe-le (avec curl ou le gestionnaire de paquets). Ensuite, applique la configuration du thème "charm" pour starship. Assure-toi que starship est bien initialisé dans le shell de l'utilisateur.` } })) } return ( <> {toast &&
{toast}
} + +
Configuration Système
{t('config.applyStarship')}
-
- {t('config.starshipApplied')} +
+ Vérifie l'installation de starship et configure le thème charm via l'IA.
-
+ +
+ + Zone Rouge +
+
- {t('config.resetConfig')} + {t('config.resetConfig')}
- {resetConfirm ? ( -
-
- {t('config.resetConfirm')} +
+ Cette action supprimera toute votre configuration et relancera l'application. +
+ +
+ + {showResetModal && ( +
setShowResetModal(false)}> +
e.stopPropagation()}> +
+ + {t('config.resetConfig')}
-
- - +
+

+ {t('config.resetConfirm')} +

+

+ Cette action est irréversible. Toute votre configuration (profil, clés API, préférences) sera supprimée. +

+
+
+ +
- ) : ( - - )} -
+
+ )} ) } diff --git a/web/src/components/Shell.jsx b/web/src/components/Shell.jsx index 2a695d2..859d4a0 100644 --- a/web/src/components/Shell.jsx +++ b/web/src/components/Shell.jsx @@ -1,15 +1,15 @@ -import { useState, useRef, useEffect, useCallback, useMemo } from 'react' +import { useState, useRef, useEffect, useCallback } 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, Eye, Bot } from 'lucide-react' +import { Plus, X, Monitor, Globe, ChevronDown, Pencil, Trash2, Search, Copy, Send, Eye } 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' +const TERMINAL_BUFFER_KEY = 'muyue_terminal_buffers' function renderContent(text) { const parts = [] @@ -132,7 +132,7 @@ function createTerminal(container, settings = {}) { const theme = getTheme(settings.theme || 'default') const term = new XTerm({ cursorBlink: true, - fontSize: settings.fontSize || 14, + fontSize: settings.fontSize || 12, fontFamily: settings.fontFamily || "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', 'Menlo', monospace", theme, allowTransparency: false, @@ -201,7 +201,7 @@ 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 settingsRef = useRef({ fontSize: 12, fontFamily: "'JetBrains Mono', 'Fira Code', monospace", theme: 'default' }) const savedTabs = (() => { try { @@ -217,15 +217,13 @@ export default function Shell({ api }) { })() 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(() => { if (savedTabs) { - const aiTab = savedTabs.find(t => t.ai) - return aiTab ? aiTab.id : savedTabs[0].id + return savedTabs[0]?.id || 1 } - return AI_TAB_ID + return 1 }) const [sshConnections, setSshConnections] = useState([]) const [systemTerminals, setSystemTerminals] = useState([]) @@ -234,7 +232,7 @@ export default function Shell({ api }) { const [editingTab, setEditingTab] = useState(null) const [editName, setEditName] = useState('') const [terminalSettings, setTerminalSettings] = useState({ - fontSize: 14, + fontSize: 12, fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', 'Menlo', monospace", theme: 'default', }) @@ -253,7 +251,6 @@ export default function Shell({ api }) { 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) @@ -261,12 +258,6 @@ 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) @@ -305,7 +296,7 @@ export default function Shell({ api }) { api.getConfig().then(d => { if (d.terminal) { setTerminalSettings({ - fontSize: d.terminal.font_size || 14, + fontSize: d.terminal.font_size || 12, fontFamily: d.terminal.font_family || "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', 'Menlo', monospace", theme: d.terminal.theme || 'default', }) @@ -346,11 +337,58 @@ export default function Shell({ api }) { const ws = connectWebSocket(term, fitAddon, initPayload) + // Restore saved terminal buffer + try { + const savedBuffers = JSON.parse(localStorage.getItem(TERMINAL_BUFFER_KEY) || '{}') + if (savedBuffers[tabId]) { + term.write(savedBuffers[tabId]) + } + } catch {} + + const saveBuffer = () => { + try { + const buf = term.buffer.active + const lines = [] + for (let i = 0; i < buf.length; i++) { + const line = buf.getLine(i) + if (line) lines.push(line.translateToString(true)) + } + const savedBuffers = JSON.parse(localStorage.getItem(TERMINAL_BUFFER_KEY) || '{}') + savedBuffers[tabId] = lines.join('\n') + localStorage.setItem(TERMINAL_BUFFER_KEY, JSON.stringify(savedBuffers)) + } catch {} + } + + const bufferSaveInterval = setInterval(saveBuffer, 5000) + + // Detect clear command to wipe saved buffer + let inputBuffer = '' + term.onData((data) => { + if (data === '\r') { + const cmd = inputBuffer.replace(/[\x1b\x00-\x1f]/g, '').trim().toLowerCase() + if (cmd === 'clear') { + try { + const savedBuffers = JSON.parse(localStorage.getItem(TERMINAL_BUFFER_KEY) || '{}') + delete savedBuffers[tabId] + localStorage.setItem(TERMINAL_BUFFER_KEY, JSON.stringify(savedBuffers)) + } catch {} + } + inputBuffer = '' + } else if (data === '\x7f' || data === '\b') { + inputBuffer = inputBuffer.slice(0, -1) + } else if (data === '\x03') { + inputBuffer = '' + } else if (data.length === 1 && data.charCodeAt(0) >= 32) { + inputBuffer += data + } + }) + ws.onopen = () => { setTabs(prev => prev.map(t => t.id === tabId ? { ...t, connected: true } : t)) } ws.onclose = () => { + saveBuffer() setTabs(prev => prev.map(t => t.id === tabId ? { ...t, connected: false } : t)) } @@ -369,7 +407,7 @@ export default function Shell({ api }) { resizeObserver.observe(container) window.addEventListener('resize', onResize) - tabsRef.current[tabId] = { term, fitAddon, ws, resizeObserver, onResize } + tabsRef.current[tabId] = { term, fitAddon, ws, resizeObserver, onResize, bufferSaveInterval, saveBuffer } }, []) useEffect(() => { @@ -444,7 +482,7 @@ export default function Shell({ api }) { if (tabs.length >= MAX_TABS) return const id = nextIdRef.current++ 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 }) + 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, host: t.host, port: t.port, user: t.user, key_path: t.key_path })))); return next }) setActiveTab(id) setShowMenu(false) } @@ -462,7 +500,7 @@ export default function Shell({ api }) { key_path: conn.key_path || '', 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 }) + 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, host: t.host, port: t.port, user: t.user, key_path: t.key_path })))); return next }) setActiveTab(id) setShowMenu(false) } @@ -470,10 +508,12 @@ export default function Shell({ api }) { const closeTab = (tabId, e) => { if (e) e.stopPropagation() const tab = tabs.find(t => t.id === tabId) - if (!tab || tab.ai || tabs.length <= 1) return + if (!tab || tabs.length <= 1) return if (tabsRef.current[tabId]) { - const { ws, resizeObserver, onResize, term } = tabsRef.current[tabId] + const { ws, resizeObserver, onResize, term, bufferSaveInterval, saveBuffer } = tabsRef.current[tabId] + if (saveBuffer) saveBuffer() + if (bufferSaveInterval) clearInterval(bufferSaveInterval) window.removeEventListener('resize', onResize) resizeObserver.disconnect() ws.close() @@ -527,19 +567,16 @@ export default function Shell({ api }) { } const sendToTerminal = useCallback((code) => { - 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 entry = tabsRef.current[activeTab] + if (entry?.ws && entry.ws.readyState === WebSocket.OPEN) { + entry.ws.send(JSON.stringify({ type: 'input', data: code + '\r' })) } - }, []) + }, [activeTab]) const focusAiTerminal = useCallback(() => { - setActiveTab(AI_TAB_ID) - setTimeout(() => { - const entry = tabsRef.current[AI_TAB_ID] - if (entry) entry.term.focus() - }, 150) - }, []) + const entry = tabsRef.current[activeTab] + if (entry) entry.term.focus() + }, [activeTab]) const handleAiSend = async () => { if (!aiInput.trim() || aiLoading || aiAtLimit) return @@ -596,21 +633,7 @@ 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) => { + const handleAiSendDirect = useCallback(async (text) => { if (!text || aiLoading || aiAtLimit) return setAiInput('') @@ -652,7 +675,20 @@ export default function Shell({ api }) { setAiMessages(prev => [...prev.filter(m => !m._streaming), { role: 'assistant', content: `Erreur: ${err.message}` }]) } setAiLoading(false) - } + }, [api, t, aiLoading, aiAtLimit]) + + useEffect(() => { + const handler = (e) => { + const msg = e.detail?.message + if (!msg) return + setAiInput(msg) + setTimeout(() => { + handleAiSendDirect(msg) + }, 100) + } + window.addEventListener('ask-ai-terminal', handler) + return () => window.removeEventListener('ask-ai-terminal', handler) + }, [handleAiSendDirect]) const handleAnalyze = async () => { setAnalyzing(true) @@ -681,14 +717,13 @@ export default function Shell({ api }) { {tabs.map((tab, i) => (
setActiveTab(tab.id)} - onDoubleClick={(e) => !tab.ai && startRename(tab.id, e)} + onDoubleClick={(e) => startRename(tab.id, e)} > - {tab.ai && } - {!tab.ai && tab.type === 'ssh' && } - {!tab.ai && tab.type === 'local' && } + {tab.type === 'ssh' && } + {tab.type === 'local' && } {editingTab === tab.id ? ( {tab.name} )} {i + 1} - {!tab.ai && tabs.length > 1 && ( + {tabs.length > 1 && (
{aiMessages.map((msg, i) => ( - + ))} {aiLoading &&
}
@@ -915,7 +950,7 @@ export default function Shell({ api }) { ) } -function ShellAIMessage({ msg, sendToTerminal, renderTick }) { +function ShellAIMessage({ msg, sendToTerminal }) { const role = msg.role === 'user' ? 'user' : msg.role === 'system' ? 'system' : 'assistant' const content = msg.content || '' @@ -934,7 +969,7 @@ function ShellAIMessage({ msg, sendToTerminal, renderTick }) { {parts.map((part, i) => { if (part.type === 'code') { return ( -
+
{part.lang &&
{part.lang}
}
{part.content}
@@ -948,7 +983,7 @@ function ShellAIMessage({ msg, sendToTerminal, renderTick }) {
) } - return + return })}
) diff --git a/web/src/components/Studio.jsx b/web/src/components/Studio.jsx index 915ffda..c35e2a1 100644 --- a/web/src/components/Studio.jsx +++ b/web/src/components/Studio.jsx @@ -309,7 +309,6 @@ 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 [renderTick, setRenderTick] = useState(0) const messagesEnd = useRef(null) const feedRef = useRef(null) const textareaRef = useRef(null) @@ -342,12 +341,6 @@ export default function Studio({ api }) { messagesEnd.current?.scrollIntoView({ behavior: 'smooth' }) }, [messages, streaming, streamThinking, streamToolCalls]) - useEffect(() => { - const ms = loading ? 1000 : 5000 - const iv = setInterval(() => setRenderTick(t => t + 1), ms) - return () => clearInterval(iv) - }, [loading]) - useEffect(() => { const onTab = (e) => { if (e.key !== 'Tab') return @@ -648,7 +641,7 @@ export default function Studio({ api }) { return ( <> {messages.slice(0, visibleCount).map(msg => ( - + ))}
@@ -661,7 +654,7 @@ export default function Studio({ api }) { ) } return messages.map(msg => ( - + )) }