diff --git a/.gitea/workflows/ci-main.yml b/.gitea/workflows/ci-main.yml index 9421d47..b32cd79 100644 --- a/.gitea/workflows/ci-main.yml +++ b/.gitea/workflows/ci-main.yml @@ -170,7 +170,7 @@ jobs: - name: Commit changelog env: - GITEA_TOKEN: ${{ secrets.GITEATOKEN }} + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} run: | git config user.name "CI Bot" git config user.email "ci@legion-muyue.fr" @@ -181,16 +181,28 @@ jobs: - name: Create release env: - GITEA_TOKEN: ${{ secrets.GITEATOKEN }} + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} run: | if [ -z "$GITEA_TOKEN" ]; then - echo "Warning: GITEATOKEN not set, skipping release" - exit 0 + echo "Error: GITEA_TOKEN secret is not set" + exit 1 fi VERSION=${{ steps.version.outputs.version }} API="${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases" + echo "=== Debug ===" + echo "GITEA_TOKEN length: ${#GITEA_TOKEN}" + echo "API endpoint: ${API}" + echo "Creating release ${VERSION} at ${API}" + + EXISTING_ID=$(curl -s -H "Authorization: token ${GITEA_TOKEN}" "${API}/tags/${VERSION}" | grep -o '"id":[0-9]*' | head -1 | grep -o '[0-9]*') + if [ -n "$EXISTING_ID" ]; then + echo "Release ${VERSION} already exists (ID: ${EXISTING_ID}), deleting..." + curl -s -X DELETE -H "Authorization: token ${GITEA_TOKEN}" "${API}/${EXISTING_ID}" + echo "Deleted existing release" + fi + BODY=$(cat /tmp/stable_changelog.md) - RESPONSE=$(curl -s -X POST "${API}" \ + RESPONSE=$(curl -s -w "\n%{http_code}" -X POST "${API}" \ -H "Authorization: token ${GITEA_TOKEN}" \ -H "Content-Type: application/json" \ -d "{ @@ -201,10 +213,13 @@ jobs: \"draft\":false, \"prerelease\":false }") - RELEASE_ID=$(echo "$RESPONSE" | grep -o '"id":[0-9]*' | head -1 | grep -o '[0-9]*') + HTTP_CODE=$(echo "$RESPONSE" | tail -1) + RESPONSE_BODY=$(echo "$RESPONSE" | sed '$d') + echo "HTTP Status: ${HTTP_CODE}" + echo "Response: ${RESPONSE_BODY}" + RELEASE_ID=$(echo "$RESPONSE_BODY" | grep -o '"id":[0-9]*' | head -1 | grep -o '[0-9]*') if [ -z "$RELEASE_ID" ]; then - echo "Failed to create release:" - echo "$RESPONSE" + echo "Failed to create release" exit 1 fi echo "Release ID: ${RELEASE_ID}" @@ -212,8 +227,12 @@ jobs: for file in dist/*.tar.gz dist/*.zip dist/checksums.txt; do filename=$(basename "$file") echo "Uploading ${filename}..." - curl -s -X POST "${UPLOAD_URL}" \ + UPLOAD_RESP=$(curl -s -w "\n%{http_code}" -X POST "${UPLOAD_URL}" \ -H "Authorization: token ${GITEA_TOKEN}" \ - -F "attachment=@${file};filename=${filename}" > /dev/null + -F "attachment=@${file};filename=${filename}") + UPLOAD_CODE=$(echo "$UPLOAD_RESP" | tail -1) + if [ "$UPLOAD_CODE" != "201" ]; then + echo "Upload failed with status ${UPLOAD_CODE}" + fi done echo "Stable release ${VERSION} published!" diff --git a/internal/api/handlers_info.go b/internal/api/handlers_info.go index 4e3190c..f031df0 100644 --- a/internal/api/handlers_info.go +++ b/internal/api/handlers_info.go @@ -477,9 +477,46 @@ func (s *Server) handleProvidersQuota(w http.ResponseWriter, r *http.Request) { } } case "zai": - // Z.AI (GLM) est utilisé uniquement via Crush, pas de quota check externe - q.Healthy = true - q.Data = map[string]interface{}{"note": "crush-only"} + if p.APIKey == "" { + q.Error = "no API key" + results = append(results, q) + continue + } + req, _ := http.NewRequest("GET", "https://api.z.ai/api/monitor/usage/quota/limit", nil) + req.Header.Set("Authorization", "Bearer "+p.APIKey) + req.Header.Set("Accept", "application/json") + resp, err := client.Do(req) + if err != nil { + q.Error = err.Error() + } else { + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + var data map[string]interface{} + if json.Unmarshal(body, &data) == nil { + if d, ok := data["data"].(map[string]interface{}); ok { + if limits, ok := d["limits"].([]interface{}); ok { + timeLimit := map[string]interface{}{} + for _, l := range limits { + if lm, ok := l.(map[string]interface{}); ok && lm["type"] == "TIME_LIMIT" { + usage, _ := lm["usage"].(float64) + remaining, _ := lm["remaining"].(float64) + total := usage + remaining + timeLimit = map[string]interface{}{ + "model": "Z.AI", + "used": usage, + "total": total, + "remaining": remaining, + } + } + } + if len(timeLimit) > 0 { + q.Data = map[string]interface{}{"models": []map[string]interface{}{timeLimit}} + q.Healthy = true + } + } + } + } + } case "claude", "anthropic": // Claude Code n'a pas d'API externe, vérifier l'installation claudePath := "/usr/bin/claude" diff --git a/internal/api/handlers_shell_chat.go b/internal/api/handlers_shell_chat.go index 2ac14b3..630ca24 100644 --- a/internal/api/handlers_shell_chat.go +++ b/internal/api/handlers_shell_chat.go @@ -277,3 +277,16 @@ Sois concret et technique. Le rapport sera utilisé comme contexte pour un assis "analysis": result, }) } + +func (s *Server) handleShellAnalysisGet(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + writeError(w, "GET only", http.StatusMethodNotAllowed) + return + } + analysis := LoadSystemAnalysis() + if analysis == "" { + writeJSON(w, map[string]interface{}{"analysis": nil}) + return + } + writeJSON(w, map[string]interface{}{"analysis": analysis}) +} diff --git a/internal/api/server.go b/internal/api/server.go index 3ec9b2e..4b89dd7 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -94,6 +94,7 @@ func (s *Server) routes() { s.mux.HandleFunc("/api/shell/chat/history", s.handleShellChatHistory) s.mux.HandleFunc("/api/shell/chat/clear", s.handleShellChatClear) s.mux.HandleFunc("/api/shell/analyze", s.handleShellAnalyze) + s.mux.HandleFunc("/api/shell/analysis", s.handleShellAnalysisGet) s.mux.HandleFunc("/api/workflow", s.handleWorkflowCreate) s.mux.HandleFunc("/api/workflow/list", s.handleWorkflowList) s.mux.HandleFunc("/api/workflow/", s.handleWorkflowGet) diff --git a/internal/version/version.go b/internal/version/version.go index e5377aa..8ac3608 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -7,7 +7,7 @@ import ( const ( Name = "muyue" - Version = "0.3.3" + Version = "0.3.4" Author = "La Légion de Muyue" ) diff --git a/web/src/api/client.js b/web/src/api/client.js index 4bd9be6..0946736 100644 --- a/web/src/api/client.js +++ b/web/src/api/client.js @@ -60,6 +60,7 @@ const api = { getShellChatHistory: () => request('/shell/chat/history'), clearShellChat: () => request('/shell/chat/clear', { method: 'POST' }), analyzeSystem: () => request('/shell/analyze', { method: 'POST' }), + getShellAnalysis: () => request('/shell/analysis'), sendChat: (message, stream = true, onChunk, signal) => { if (!stream) { return request('/chat', { method: 'POST', body: JSON.stringify({ message, stream: false }) }) diff --git a/web/src/components/App.jsx b/web/src/components/App.jsx index 332df5f..1a681c6 100644 --- a/web/src/components/App.jsx +++ b/web/src/components/App.jsx @@ -76,6 +76,12 @@ export default function App() { const switchTab = useCallback((tabId) => setActiveTab(tabId), []) + useEffect(() => { + const handler = () => setActiveTab('shell') + window.addEventListener('navigate-to-shell', handler) + return () => window.removeEventListener('navigate-to-shell', handler) + }, []) + const hasUpdates = updates.some(u => u.needsUpdate) const installed = tools.filter(tool => tool.installed).length diff --git a/web/src/components/Config.jsx b/web/src/components/Config.jsx index 9611a97..f96bc2c 100644 --- a/web/src/components/Config.jsx +++ b/web/src/components/Config.jsx @@ -65,28 +65,15 @@ export default function Config({ api }) { setChecking(false) } - const handleUpdateTool = async (tool) => { - setUpdating(tool) - try { - await api.runUpdate(tool) - await handleCheckUpdates() - showToast(`${tool} ✓`) - } catch (err) { - showToast(`${t('config.error')}: ${err.message}`) - } - setUpdating(null) + const handleUpdateTool = (tool) => { + window.dispatchEvent(new CustomEvent('navigate-to-shell', {})) + window.dispatchEvent(new CustomEvent('ask-ai-terminal', { detail: { message: `Met à jour l'outil ${tool} sur mon système. Exécute les commandes nécessaires.` } })) } - const handleUpdateAll = async () => { - setUpdating('__all__') - try { - await api.runUpdate('') - await handleCheckUpdates() - showToast(t('config.saved')) - } catch (err) { - showToast(`${t('config.error')}: ${err.message}`) - } - setUpdating(null) + const handleUpdateAll = () => { + const toUpdate = updates.filter(u => u.needsUpdate).map(u => u.tool) + window.dispatchEvent(new CustomEvent('navigate-to-shell', {})) + window.dispatchEvent(new CustomEvent('ask-ai-terminal', { detail: { message: `Met à jour tous les outils suivants sur mon système : ${toUpdate.join(', ')}. Exécute les commandes nécessaires une par une.` } })) } const handleSaveProfile = async () => { @@ -403,20 +390,9 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm )} -
- {t('config.model')} - { - setProviderForm(prev => ({ - ...prev, - [p.name]: { ...(prev[p.name] || {}), model: e.target.value }, - })) - setEditProvider(p.name) - }} - placeholder="model-name" - /> +
+ {t('config.model')} + {p.model || '—'}
diff --git a/web/src/components/Dashboard.jsx b/web/src/components/Dashboard.jsx index 6342766..e74080b 100644 --- a/web/src/components/Dashboard.jsx +++ b/web/src/components/Dashboard.jsx @@ -43,6 +43,7 @@ export default function Dashboard({ api, refreshRef }) { const [recentCmds, setRecentCmds] = useState([]) const [processes, setProcesses] = useState([]) const [metrics, setMetrics] = useState(null) + const [copiedIdx, setCopiedIdx] = useState(-1) const cpuRef = useRef([]) const memRef = useRef([]) const netRxRef = useRef([]) @@ -158,10 +159,19 @@ export default function Dashboard({ api, refreshRef }) { {minimax.error || 'no data'} )} - {zai && ( + {zai && zai.data?.models?.map((m, i) => ( +
+ {String(m.model)} +
+
+
+ {m.used}/{m.total} +
+ ))} + {zai && !zai.data?.models?.length && (
Z.AI - {zai.healthy ? '✓ active' : zai.error || '—'} + {zai.error || 'no data'}
)} {!minimax && !zai && No providers} @@ -193,8 +203,8 @@ export default function Dashboard({ api, refreshRef }) { {topCmds.length > 0 && (
{topCmds.map((c, i) => ( -
navigator.clipboard.writeText(c.cmd)} title="Copier"> - {c.cmd} +
{ navigator.clipboard.writeText(c.cmd); setCopiedIdx(i); setTimeout(() => setCopiedIdx(-1), 1200); }}> + {copiedIdx === i ? '✓ Copié' : c.cmd} {c.count}×
))} diff --git a/web/src/components/Shell.jsx b/web/src/components/Shell.jsx index 230f573..dd8a6f5 100644 --- a/web/src/components/Shell.jsx +++ b/web/src/components/Shell.jsx @@ -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, '>') + + html = html + .replace(/\*\*(.+?)\*\*/g, '$1') + .replace(/`([^`]+)`/g, '$1') + .replace(/^### (.+)$/gm, '

$1

') + .replace(/^## (.+)$/gm, '

$1

') + .replace(/^# (.+)$/gm, '

$1

') + .replace(/^\s*[-*] (.+)$/gm, '
• $1
') + .replace(/^\s*(\d+)[.)] (.+)$/gm, '
$1 $2
') + .replace(/\n/g, '
') + + html = html + .replace(/\s*/g, '
') + .replace(/\s*( { + 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) => (
setActiveTab(tab.id)} - onDoubleClick={(e) => startRename(tab.id, e)} + onDoubleClick={(e) => !tab.ai && startRename(tab.id, e)} > - {tab.type === 'ssh' && } - {tab.type === 'local' && } + {tab.ai && } + {!tab.ai && tab.type === 'ssh' && } + {!tab.ai && tab.type === 'local' && } {editingTab === tab.id ? ( {tab.name} )} {i + 1} - {tabs.length > 1 && ( + {!tab.ai && tabs.length > 1 && ( +
+ + +
@@ -606,7 +799,7 @@ export default function Shell({ api }) {
{aiMessages.map((msg, i) => ( - + ))} {aiLoading &&
}
@@ -622,6 +815,29 @@ export default function Shell({ api }) {
+ {showAnalysis && analysisContent && ( +
setShowAnalysis(false)}> +
e.stopPropagation()}> +
+ Analyse Système + +
+
+ {renderContent(analysisContent).map((part, i) => + part.type === 'code' ? ( +
+ {part.lang &&
{part.lang}
} +
{part.content}
+
+ ) : ( + + ) + )} +
+
+
+ )} + {showSshModal && (
setShowSshModal(false)}>
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
{content}
+ } + + if (role === 'system') { + return
{content}
+ } + + const parts = renderContent(content) return ( -
+
{parts.map((part, i) => { if (part.type === 'code') { return ( -
+
{part.lang &&
{part.lang}
} -
{part.code}
+
{part.content}
- -
) } - return {part.text} + return })}
) } - -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 }] -} diff --git a/web/src/components/Studio.jsx b/web/src/components/Studio.jsx index abd5398..11d48fc 100644 --- a/web/src/components/Studio.jsx +++ b/web/src/components/Studio.jsx @@ -67,6 +67,9 @@ function formatText(text) { .replace(/\n/g, '
') html = html + .replace(/\s*/g, '
') + .replace(/\s*( { + 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 @@ -392,6 +402,14 @@ export default function Studio({ api }) { const text = input.trim() setInput('') + const isSlashCommand = (t) => /^\/(clear|help|summarize|export|model(?:\s+\S+)?|plan\s+.+)$/.test(t) + + if (text.startsWith('/') && !isSlashCommand(text)) { + setMessages(prev => [...prev, { id: Date.now().toString(), role: 'user', content: text, time: new Date().toISOString() }]) + setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: 'Commande inconnue. Tapez `/help` pour la liste des commandes.', time: new Date().toISOString() }]) + return + } + if (text === '/clear') { handleClear() return @@ -407,6 +425,7 @@ export default function Studio({ api }) { '- `/plan ` - 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', @@ -426,14 +445,37 @@ export default function Studio({ api }) { return } - if (text === '/model') { - api.getProviders().then(data => { - const active = data.providers?.find(p => p.active) - const modelMsg = active ? active.name : 'Aucun provider actif configuré' - setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: modelMsg, time: new Date().toISOString() }]) - }).catch(() => { - setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: 'Erreur: impossible de récupérer les providers', time: new Date().toISOString() }]) - }) + if (text === '/model' || text === '/model change') { + if (text === '/model change') { + api.getProviders().then(data => { + const providers = data.providers || [] + const minimax = providers.find(p => p.name.toUpperCase() === 'MINIMAX') + const zai = providers.find(p => p.name.toUpperCase() === 'ZAI') + if (!minimax || !zai) { + setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: 'MiniMax et ZAI doivent être configurés pour utiliser `/model change`.', time: new Date().toISOString() }]) + return + } + const active = providers.find(p => p.active) + const activeName = active ? active.name.toUpperCase() : '' + const switchTo = activeName === 'MINIMAX' ? 'ZAI' : 'MINIMAX' + const target = switchTo === 'MINIMAX' ? minimax : zai + api.saveProvider({ name: target.name, active: true }).then(() => { + setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: `✓ Provider changé: **${target.name}** (${target.model})`, time: new Date().toISOString() }]) + }).catch(() => { + setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: 'Erreur lors du changement de provider.', time: new Date().toISOString() }]) + }) + }).catch(() => { + setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: 'Erreur: impossible de récupérer les providers', time: new Date().toISOString() }]) + }) + } else { + api.getProviders().then(data => { + const active = data.providers?.find(p => p.active) + const modelMsg = active ? `**${active.name}** — ${active.model}` : 'Aucun provider actif configuré' + setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: modelMsg, time: new Date().toISOString() }]) + }).catch(() => { + setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: 'Erreur: impossible de récupérer les providers', time: new Date().toISOString() }]) + }) + } return } @@ -551,7 +593,7 @@ export default function Studio({ api }) { } }, []) - const COMMANDS = ['/clear', '/summarize', '/help', '/plan', '/export', '/model'] + const COMMANDS = ['/clear', '/summarize', '/help', '/plan', '/export', '/model', '/model change'] const handleKeyDown = (e) => { if (e.key === 'Enter' && !e.shiftKey) { @@ -570,7 +612,7 @@ export default function Studio({ api }) { const val = ta.value const pos = ta.selectionStart const before = val.slice(0, pos) - const afterSlash = before.match(/\/(\w*)$/) + const afterSlash = before.match(/\/[\w ]*$/) if (afterSlash) { const partial = afterSlash[0] const matches = COMMANDS.filter(c => c.startsWith(partial) && c !== partial) @@ -597,7 +639,7 @@ export default function Studio({ api }) { return ( <> {messages.slice(0, visibleCount).map(msg => ( - + ))}
@@ -610,7 +652,7 @@ export default function Studio({ api }) { ) } return messages.map(msg => ( - + )) } @@ -693,7 +735,7 @@ export default function Studio({ api }) { )}
- {t('studio.inputHint')} · /clear /summarize /help /plan /export /model + {t('studio.inputHint')} · /clear /summarize /help /plan /export /model /model change
diff --git a/web/src/styles/global.css b/web/src/styles/global.css index 8bdb9e6..aee1922 100644 --- a/web/src/styles/global.css +++ b/web/src/styles/global.css @@ -393,6 +393,9 @@ input::placeholder { color: var(--text-disabled); } .connection-dot.on { background: var(--success); box-shadow: 0 0 6px var(--success); } .connection-dot.off { background: var(--error); } +.shell-tab.ai-tab .shell-tab-name { color: var(--accent); } +.shell-tab.ai-tab { border-bottom-color: var(--accent); } + .shell-ai-col { width: 320px; border-left: 1px solid var(--border); background: var(--bg-surface); display: flex; flex-direction: column; flex-shrink: 0; } .ai-panel-header { padding: 12px 16px; border-bottom: 1px solid var(--border); font-weight: 700; font-size: 13px; color: var(--accent); display: flex; align-items: center; justify-content: space-between; } .shell-analyze-btn { @@ -446,6 +449,21 @@ 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-analysis-modal { + background: var(--bg-elevated); border: 1px solid var(--border); + border-radius: var(--radius-lg); width: 720px; max-width: 90vw; max-height: 80vh; + display: flex; flex-direction: column; overflow: hidden; +} +.shell-analysis-modal-header { + display: flex; align-items: center; justify-content: space-between; + padding: 14px 20px; border-bottom: 1px solid var(--border); + font-weight: 700; font-size: 15px; color: var(--accent); +} +.shell-analysis-modal-body { + flex: 1; overflow-y: auto; padding: 20px; font-size: 14px; line-height: 1.5; + color: var(--text-primary); word-break: break-word; +} + .shell-modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; z-index: 1000; @@ -522,6 +540,9 @@ input::placeholder { color: var(--text-disabled); } .provider-card-name { font-weight: 600; color: var(--text-primary); font-size: 14px; } .provider-card-actions { display: flex; gap: 8px; flex-shrink: 0; } .provider-card-meta { display: flex; gap: 16px; font-size: 12px; color: var(--text-tertiary); font-family: var(--font-mono); margin-top: 8px; } +.provider-card-model { display: flex; align-items: center; justify-content: center; gap: 8px; margin-top: 12px; padding-top: 10px; border-top: 1px solid var(--border); } +.provider-card-model-label { font-size: 11px; color: var(--text-tertiary); text-transform: uppercase; letter-spacing: 0.5px; } +.provider-card-model-value { font-size: 14px; font-weight: 600; font-family: var(--font-mono); color: var(--accent); } .provider-card-form { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border); } .provider-setup-hint { @@ -694,6 +715,8 @@ input::placeholder { color: var(--text-disabled); } cursor: pointer; transition: all 0.15s; } .dash-cmd-chip:hover { border-color: var(--accent-dim); background: var(--accent-bg); } +.dash-cmd-chip-copied { border-color: var(--accent) !important; background: var(--accent-bg) !important; } +.dash-cmd-chip-copied .dash-cmd-chip-name { color: var(--accent); } .dash-cmd-chip-name { font-size: 13px; font-weight: 700; font-family: var(--font-mono); color: var(--text-primary); } .dash-cmd-chip-count { font-size: 10px; font-family: var(--font-mono); color: var(--accent); } @@ -808,7 +831,7 @@ input::placeholder { color: var(--text-disabled); } } .feed-role { font-size: 11px; font-weight: 700; color: var(--accent); text-transform: uppercase; letter-spacing: 0.5px; } .feed-time { font-size: 10px; color: var(--text-disabled); font-family: var(--font-mono); } -.feed-content { font-size: 14px; line-height: 1.7; color: var(--text-primary); word-break: break-word; } +.feed-content { font-size: 14px; line-height: 1.5; color: var(--text-primary); word-break: break-word; } .feed-system-badge { width: 6px; height: 6px; border-radius: 50%; background: var(--accent-dim); flex-shrink: 0; } .feed-system-text { font-size: 12px; color: var(--text-tertiary); font-style: italic; flex: 1; } .feed-system-text.compressed { color: var(--accent); font-style: normal; } @@ -868,11 +891,11 @@ input::placeholder { color: var(--text-disabled); } background: var(--bg-surface); border-bottom: 1px solid var(--border); text-transform: uppercase; letter-spacing: 0.5px; } .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: 16px 0 8px; display: block; } -.msg-h4 { font-size: 14px; font-weight: 700; color: var(--text-secondary); margin: 12px 0 6px; display: block; } -.msg-bullet { display: block; padding-left: 16px; position: relative; margin: 2px 0; } +.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; } +.msg-bullet { display: block; padding-left: 16px; position: relative; margin: 1px 0; } .msg-bullet::before { content: '\2022'; position: absolute; left: 4px; color: var(--accent); } -.msg-step { display: flex; gap: 8px; align-items: baseline; margin: 3px 0; } +.msg-step { display: flex; gap: 8px; align-items: baseline; margin: 1px 0; } .msg-step-num { color: var(--accent); font-weight: 700; font-family: var(--font-mono); font-size: 13px; flex-shrink: 0; min-width: 20px; } .studio-cursor { display: inline-block; width: 8px; height: 16px; background: var(--accent); margin-left: 2px; vertical-align: text-bottom; animation: blink 0.8s step-end infinite; } @keyframes blink { 50% { opacity: 0; } }