diff --git a/internal/api/handlers_info.go b/internal/api/handlers_info.go index 43ec284..33a0b78 100644 --- a/internal/api/handlers_info.go +++ b/internal/api/handlers_info.go @@ -2,8 +2,14 @@ package api import ( "encoding/json" + "fmt" + "io" "net/http" "os" + "os/exec" + "path/filepath" + "strings" + "time" "github.com/muyue/muyue/internal/lsp" "github.com/muyue/muyue/internal/mcp" @@ -415,3 +421,191 @@ func (s *Server) handleEditors(w http.ResponseWriter, r *http.Request) { editors := scanner.ScanEditors() writeJSON(w, map[string]interface{}{"editors": editors}) } + +func (s *Server) handleProvidersQuota(w http.ResponseWriter, r *http.Request) { + type providerQuota struct { + Name string `json:"name"` + Active bool `json:"active"` + Healthy bool `json:"healthy"` + Data map[string]interface{} `json:"data,omitempty"` + Error string `json:"error,omitempty"` + } + var results []providerQuota + client := &http.Client{Timeout: 8 * time.Second} + for _, p := range s.config.AI.Providers { + q := providerQuota{Name: p.Name, Active: p.Active} + switch p.Name { + case "minimax": + if p.APIKey == "" { + q.Error = "no API key" + results = append(results, q) + continue + } + req, _ := http.NewRequest("GET", "https://api.minimax.io/v1/token_plan/remains", nil) + req.Header.Set("Authorization", "Bearer "+p.APIKey) + req.Header.Set("Content-Type", "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 models, ok := data["model_remains"].([]interface{}); ok { + filtered := make([]map[string]interface{}, 0) + for _, m := range models { + if mm, ok := m.(map[string]interface{}); ok { + usage, _ := mm["current_interval_usage_count"].(float64) + total, _ := mm["current_interval_total_count"].(float64) + if total > 0 { + filtered = append(filtered, map[string]interface{}{ + "model": mm["model_name"], + "used": usage, + "total": total, + "remaining": total - usage, + "weekly_used": mm["current_weekly_usage_count"], + "weekly_total": mm["current_weekly_total_count"], + }) + } + } + } + q.Data = map[string]interface{}{"models": filtered} + q.Healthy = true + } + } + } + case "zai": + 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 { + q.Data = data + q.Healthy = true + } + } + default: + q.Error = "quota not supported" + } + results = append(results, q) + } + writeJSON(w, map[string]interface{}{"providers": results}) +} + +func (s *Server) handleRecentCommands(w http.ResponseWriter, r *http.Request) { + home, _ := os.UserHomeDir() + type cmdEntry struct { + Cmd string `json:"cmd"` + Shell string `json:"shell"` + } + + var entries []cmdEntry + + for _, histFile := range []string{".bash_history", ".zsh_history"} { + path := filepath.Join(home, histFile) + data, err := os.ReadFile(path) + if err != nil { + continue + } + shell := "bash" + if strings.Contains(histFile, "zsh") { + shell = "zsh" + } + lines := strings.Split(string(data), "\n") + start := len(lines) - 25 + if start < 0 { + start = 0 + } + for i := len(lines) - 1; i >= start; i-- { + line := strings.TrimSpace(lines[i]) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + if strings.HasPrefix(line, ": ") { + parts := strings.SplitN(line, ";", 2) + if len(parts) == 2 { + line = strings.TrimSpace(parts[1]) + } else { + continue + } + } + if line == "" { + continue + } + entries = append(entries, cmdEntry{Cmd: line, Shell: shell}) + } + } + + max := 20 + if len(entries) > max { + entries = entries[:max] + } + + writeJSON(w, map[string]interface{}{"commands": entries}) +} + +func (s *Server) handleRunningProcesses(w http.ResponseWriter, r *http.Request) { + type proc struct { + PID int `json:"pid"` + Name string `json:"name"` + Command string `json:"command"` + CPU string `json:"cpu"` + Mem string `json:"mem"` + } + var procs []proc + + editors := []string{"code", "nvim", "vim", "emacs", "hx", "subl", "zed", "cursor"} + langs := []string{"node", "python", "java", "go", "rustc", "cargo", "ruby", "php"} + interesting := append(editors, langs...) + interesting = append(interesting, "muyue") + + cmd := exec.Command("ps", "aux") + out, err := cmd.Output() + if err != nil { + writeJSON(w, map[string]interface{}{"processes": procs}) + return + } + + lines := strings.Split(string(out), "\n") + for _, line := range lines[1:] { + fields := strings.Fields(line) + if len(fields) < 11 { + continue + } + fullCmd := strings.Join(fields[10:], " ") + name := filepath.Base(fields[10]) + matched := false + for _, pattern := range interesting { + if strings.Contains(name, pattern) || strings.Contains(strings.ToLower(fullCmd), pattern) { + matched = true + break + } + } + if !matched { + continue + } + var pid int + fmt.Sscanf(fields[1], "%d", &pid) + procs = append(procs, proc{ + PID: pid, + Name: name, + Command: fullCmd, + CPU: fields[2], + Mem: fields[3], + }) + } + + writeJSON(w, map[string]interface{}{"processes": procs}) +} diff --git a/internal/api/server.go b/internal/api/server.go index 1890282..02a0a1c 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -2,6 +2,7 @@ package api import ( "encoding/json" + "log" "net/http" "strings" @@ -23,9 +24,26 @@ type Server struct { func NewServer(cfg *config.MuyueConfig) *Server { s := &Server{ - config: cfg, - mux: http.NewServeMux(), + mux: http.NewServeMux(), } + // Auto-initialize config if nil or if no config file exists on disk + if cfg == nil || !config.Exists() { + defaultCfg := config.Default() + if cfg != nil { + // Preserve any user-provided settings from cfg + defaultCfg.Profile = cfg.Profile + defaultCfg.AI = cfg.AI + defaultCfg.Tools = cfg.Tools + defaultCfg.BMAD = cfg.BMAD + defaultCfg.Terminal = cfg.Terminal + } + // Save initial config to establish the file for first-time usage + if err := config.Save(defaultCfg); err != nil { + log.Printf("config: initial save failed: %v", err) + } + cfg = defaultCfg + } + s.config = cfg s.scanResult = scanner.ScanSystem() s.convStore = NewConversationStore() s.agentRegistry = agent.DefaultRegistry() @@ -95,6 +113,9 @@ func (s *Server) routes() { s.mux.HandleFunc("/api/skills/export", s.handleSkillExport) s.mux.HandleFunc("/api/skills/import", s.handleSkillImport) s.mux.HandleFunc("/api/dashboard/status", s.handleDashboardStatus) + s.mux.HandleFunc("/api/providers/quota", s.handleProvidersQuota) + s.mux.HandleFunc("/api/recent-commands", s.handleRecentCommands) + s.mux.HandleFunc("/api/running-processes", s.handleRunningProcesses) } func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { diff --git a/web/src/api/client.js b/web/src/api/client.js index 1aa75c5..68b3710 100644 --- a/web/src/api/client.js +++ b/web/src/api/client.js @@ -37,6 +37,9 @@ const api = { exportSkill: (name) => request('/skills/export', { method: 'POST', body: JSON.stringify({ name }) }), importSkill: (path) => request('/skills/import', { method: 'POST', body: JSON.stringify({ import_path: path }) }), getDashboardStatus: () => request('/dashboard/status'), + getProvidersQuota: () => request('/providers/quota'), + getRecentCommands: () => request('/recent-commands'), + getRunningProcesses: () => request('/running-processes'), savePreferences: (prefs) => request('/preferences', { method: 'PUT', body: JSON.stringify(prefs) }), saveProfile: (profile) => request('/config/profile', { method: 'PUT', body: JSON.stringify(profile) }), saveProvider: (provider) => request('/config/provider', { method: 'PUT', body: JSON.stringify(provider) }), diff --git a/web/src/components/Dashboard.jsx b/web/src/components/Dashboard.jsx index 3efde8f..fc6d171 100644 --- a/web/src/components/Dashboard.jsx +++ b/web/src/components/Dashboard.jsx @@ -1,438 +1,181 @@ import { useState, useEffect, useCallback } from 'react' import { useI18n } from '../i18n' -const TOOL_ICONS = { - crush: '⚡', - claude: '🤖', - go: '🔷', - node: '🟢', - python: '🐍', - docker: '🐳', - git: '📚', - ssh: '🌐', - starship: '🚀', - rust: '🦀', -} - -function ToolCard({ tool, onInstall, installing }) { - const { t } = useI18n() - const [showInstall, setShowInstall] = useState(false) - - const icon = TOOL_ICONS[tool.name?.toLowerCase()] || '🔧' - const isInstalled = tool.installed || tool.status === 'installed' - const version = tool.version || '' - const hasUpdate = tool.hasUpdate || tool.updateAvailable - - return ( -
-
{icon}
-
-
{tool.name || 'Unknown'}
-
- {isInstalled ? ( - {t('dashboard.installed')} - ) : ( - {t('dashboard.missing')} - )} - {version && {version}} -
-
-
- {isInstalled && hasUpdate && ( - - ↑ {tool.latestVersion || 'new'} - - )} - {!isInstalled && ( - - )} -
-
- ) -} - -function ActivityItem({ entry }) { - const time = entry.time - ? new Date(entry.time).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' }) - : '' - const type = entry.type || entry.level || 'info' - const text = entry.message || entry.text || entry.content || '' - - const typeClass = { - ok: 'notif-ok', - success: 'notif-ok', - install: 'notif-ok', - update: 'notif-info', - info: 'notif-info', - warn: 'notif-warn', - warning: 'notif-warn', - error: 'notif-error', - fail: 'notif-error', - }[type] || 'notif-info' - - const icon = { - ok: '✓', success: '✓', install: '✓', update: '→', - info: 'ℹ', warn: '⚠', warning: '⚠', error: '✗', fail: '✗', - }[type] || '•' - - return ( -
- {time} - {icon} - {text} -
- ) -} - -function QuickActionButton({ icon, label, onClick, loading, disabled }) { - return ( - - ) -} - export default function Dashboard({ api }) { const { t } = useI18n() - const [activeTab, setActiveTab] = useState('tools') const [tools, setTools] = useState([]) - const [updates, setUpdates] = useState([]) const [systemInfo, setSystemInfo] = useState(null) - const [notifications, setNotifications] = useState([]) - const [loading, setLoading] = useState(false) - const [installing, setInstalling] = useState(false) - const [scanLoading, setScanLoading] = useState(false) - const [mcpLoading, setMcpLoading] = useState(false) const [dashboardStatus, setDashboardStatus] = useState(null) + const [quota, setQuota] = useState(null) + const [recentCmds, setRecentCmds] = useState([]) + const [processes, setProcesses] = useState([]) + const [updates, setUpdates] = useState([]) const loadData = useCallback(async () => { try { - const [toolsData, updatesData, systemData] = await Promise.all([ + const [toolsData, systemData, dashData, quotaData, cmdData, procData, updatesData] = await Promise.all([ api.getTools().catch(() => ({ tools: [] })), - api.getUpdates().catch(() => ({ updates: [] })), api.getSystem().catch(() => null), + api.getDashboardStatus().catch(() => null), + api.getProvidersQuota().catch(() => null), + api.getRecentCommands().catch(() => ({ commands: [] })), + api.getRunningProcesses().catch(() => ({ processes: [] })), + api.getUpdates().catch(() => ({ updates: [] })), ]) setTools(toolsData.tools || toolsData || []) + setSystemInfo(systemData?.system || systemData) + setDashboardStatus(dashData) + setQuota(quotaData?.providers || []) + setRecentCmds(cmdData.commands || []) + setProcesses(procData.processes || []) setUpdates(updatesData.updates || updatesData || []) - setSystemInfo(systemData) - api.getDashboardStatus().then(d => setDashboardStatus(d)).catch(() => {}) } catch (err) { - console.error('Failed to load dashboard data:', err) + console.error('Dashboard load error:', err) } }, [api]) - useEffect(() => { - loadData() - }, [loadData]) - - const addNotification = (message, type = 'info') => { - const entry = { id: Date.now(), time: new Date().toISOString(), message, type } - setNotifications(prev => [entry, ...prev].slice(0, 100)) - } - - const handleRescan = async () => { - setScanLoading(true) - addNotification(t('dashboard.rescanning'), 'info') - try { - await api.runScan() - await loadData() - addNotification(t('dashboard.scanComplete'), 'ok') - } catch (err) { - addNotification(`${t('dashboard.scanFailed')}: ${err.message}`, 'error') - } finally { - setScanLoading(false) - } - } - - const handleInstallMissing = async () => { - const missing = tools.filter(t => !t.installed && t.status !== 'installed') - if (missing.length === 0) return - setInstalling(true) - addNotification(t('dashboard.installing', { count: missing.length }), 'info') - try { - await api.installTools(missing.map(t => t.name)) - addNotification(t('dashboard.installStarted'), 'ok') - setTimeout(() => handleRescan(), 2000) - } catch (err) { - addNotification(`${t('dashboard.installFailed')}: ${err.message}`, 'error') - } finally { - setInstalling(false) - } - } - - const handleCheckUpdates = async () => { - setLoading(true) - addNotification(t('config.checking'), 'info') - try { - const data = await api.getUpdates() - setUpdates(data.updates || data || []) - const count = (data.updates || data || []).length - if (count > 0) { - addNotification(t('dashboard.updatesCount', { count }), 'warn') - } else { - addNotification(t('dashboard.allUpToDate'), 'ok') - } - } catch (err) { - addNotification(`${t('dashboard.checkUpdatesFailed')}: ${err.message}`, 'error') - } finally { - setLoading(false) - } - } - - const handleConfigureMCP = async () => { - setMcpLoading(true) - addNotification(t('dashboard.configuringMCP'), 'info') - try { - await api.configureMCP() - addNotification(t('dashboard.mcpConfigured'), 'ok') - } catch (err) { - addNotification(`${t('dashboard.mcpConfigFailed')}: ${err.message}`, 'error') - } finally { - setMcpLoading(false) - } - } - - const handleInstallTool = async (name) => { - setInstalling(true) - addNotification(`${t('dashboard.installing')} ${name}...`, 'info') - try { - await api.installTools([name]) - addNotification(`${name} ${t('dashboard.installed')}`, 'ok') - setTimeout(() => loadData(), 2000) - } catch (err) { - addNotification(`${t('dashboard.installFailed')}: ${err.message}`, 'error') - } finally { - setInstalling(false) - } - } + useEffect(() => { loadData() }, [loadData]) const installedCount = tools.filter(t => t.installed || t.status === 'installed').length - const missingCount = tools.length - installedCount + const sys = systemInfo || {} + + const minimax = (quota || []).find(p => p.name === 'minimax') + const zai = (quota || []).find(p => p.name === 'zai') return ( -
-
- - - - +
+ {/* System */} +
+
+ {sys.os || sys.platform || 'System'} · {sys.arch || ''} + +
+
+ {tools.slice(0, 12).map((tool, i) => { + const ok = tool.installed || tool.status === 'installed' + return ( + + {ok ? '●' : '○'} {tool.name} + + ) + })} + {tools.length > 12 && +{tools.length - 12}} +
-
- {activeTab === 'tools' && ( -
-
-
{t('dashboard.systemOverview')}
-
- {installedCount} {t('dashboard.installed')} - {missingCount > 0 && {missingCount} {t('dashboard.missing')}} + {/* API Quota */} +
+
+ API Quota +
+
+ {minimax && minimax.data?.models?.map((m, i) => ( +
+ {String(m.model).replace('MiniMax-', '')} +
+
+ {m.remaining}/{m.total}
- {systemInfo && ( -
- {systemInfo.os || systemInfo.platform || 'Unknown'} - · - {systemInfo.arch || 'Unknown'} - {systemInfo.shell && <>·{systemInfo.shell}} -
- )} -
- {tools.length === 0 && ( -
{t('dashboard.noTools')}
- )} - {tools.map((tool, i) => ( - - ))} + ))} + {minimax && minimax.data?.models?.length === 0 && ( +
+ MiniMax + {minimax.error || 'no data'}
-
- )} + )} + {zai && ( +
+ Z.AI + {zai.healthy ? '✓ active' : zai.error || '—'} +
+ )} + {!minimax && !zai && No providers} +
+
- {activeTab === 'activity' && ( -
-
-
{t('dashboard.activityLog')}
- + {/* Running Processes */} +
+
+ Running Processes + {processes.length} +
+
+ {processes.length === 0 && No relevant processes} + {processes.slice(0, 8).map((p, i) => ( +
+ {p.name} + cpu {p.cpu}% · mem {p.mem}%
- {notifications.length === 0 ? ( -
{t('dashboard.noActivity')}
- ) : ( -
- {notifications.map(entry => ( - + ))} +
+
+ + {/* Recent Commands */} +
+
+ Recent Commands +
+
+ {recentCmds.length === 0 && No history} + {recentCmds.slice(0, 8).map((c, i) => ( +
+ {c.shell} + {c.cmd.length > 45 ? c.cmd.slice(0, 42) + '...' : c.cmd} +
+ ))} +
+
+ + {/* Status (MCP/LSP/Skills) */} +
+
+ Services +
+ {dashboardStatus ? ( +
+
+ MCP + {dashboardStatus.mcp?.healthy || 0}/{dashboardStatus.mcp?.total || 0} healthy +
+
+ LSP + {dashboardStatus.lsp?.installed || 0}/{dashboardStatus.lsp?.total || 0} installed +
+
+ Skills + {dashboardStatus.skills?.total || 0} deployed +
+ {(dashboardStatus.skills?.issues || []).length > 0 && ( +
+ {(dashboardStatus.skills.issues || []).slice(0, 3).map((issue, i) => ( +
⚠ {issue}
))}
)}
- )} - - {activeTab === 'actions' && ( -
-
-
{t('dashboard.quickActions')}
-
-
- - - - -
- - {updates.length > 0 && ( -
-
-
{t('dashboard.updates')}
- {updates.length} -
-
- {updates.map((update, i) => ( -
-
- {update.name || 'Unknown'} - - {update.current || update.version || '?'} → {update.latest || update.target || '?'} - -
- -
- ))} -
-
- )} -
- )} - - {activeTab === 'status' && ( -
- {dashboardStatus ? ( - <> -
-
MCP Servers
- {dashboardStatus.mcp?.healthy || 0}/{dashboardStatus.mcp?.total || 0} healthy -
-
- {(dashboardStatus.mcp?.servers || []).map((s, i) => ( -
-
-
{s.name}
-
- {s.healthy ? healthy : - s.installed ? installed : - not found} -
-
-
- ))} -
- -
-
LSP Servers
- {dashboardStatus.lsp?.installed || 0}/{dashboardStatus.lsp?.total || 0} installed -
-
- {(dashboardStatus.lsp?.servers || []).filter(s => s.installed).map((s, i) => ( -
-
-
{s.name}
-
- {s.language} -
-
-
- ))} -
- -
-
Skills
- {dashboardStatus.skills?.total || 0} deployed - {(dashboardStatus.skills?.issues || []).length > 0 && ( - {(dashboardStatus.skills.issues || []).length} issues - )} -
- {(dashboardStatus.skills?.issues || []).length > 0 && ( -
- {(dashboardStatus.skills.issues || []).map((issue, i) => ( -
{issue}
- ))} -
- )} - - ) : ( -
Loading status...
- )} -
+ ) : ( + Loading... )}
+ + {/* Updates */} + {updates.length > 0 && ( +
+
+ Updates Available + {updates.length} +
+
+ {updates.slice(0, 5).map((u, i) => ( +
+ {u.name} + {u.current || '?'} → {u.latest || '?'} +
+ ))} +
+
+ )}
) -} \ No newline at end of file +} diff --git a/web/src/components/OnboardingWizard.jsx b/web/src/components/OnboardingWizard.jsx index 0434c74..09d8443 100644 --- a/web/src/components/OnboardingWizard.jsx +++ b/web/src/components/OnboardingWizard.jsx @@ -1,5 +1,5 @@ -import { useState, useEffect } from 'react' -import { Sparkles, ArrowRight, ArrowLeft, Search, Loader } from 'lucide-react' +import { useState, useEffect, useRef } from 'react' +import { Sparkles, ArrowRight, ArrowLeft, Loader } from 'lucide-react' import { useI18n, LANGUAGES } from '../i18n' import { getLayoutList } from '../i18n/keyboards' @@ -32,6 +32,8 @@ export default function OnboardingWizard({ api, onComplete }) { const [validating, setValidating] = useState(false) const [keyValid, setKeyValid] = useState(false) const [scanning, setScanning] = useState(false) + const [scanMessage, setScanMessage] = useState('') + const scanAbortRef = useRef(null) const current = STEPS[step] const layouts = getLayoutList() @@ -50,7 +52,7 @@ export default function OnboardingWizard({ api, onComplete }) { case 'name': return answers.name.trim().length > 0 case 'language': return !!answers.language case 'keyboard': return !!answers.keyboard - case 'apikey': return true + case 'apikey': return keyValid && !scanning case 'editor': return true case 'done': return true default: return true @@ -61,14 +63,84 @@ export default function OnboardingWizard({ api, onComplete }) { if (step > 0) setStep(step - 1) } + const cycleOption = (key, list, dir) => { + const idx = list.findIndex(item => item.id === answers[key]) + const next = (idx + dir + list.length) % list.length + setAnswers(a => ({ ...a, [key]: list[next].id })) + } + + const cycleOptionEditor = (dir) => { + const idx = editorList.findIndex(ed => ed === answers.editor) + const next = (idx + dir + editorList.length) % editorList.length + setAnswers(a => ({ ...a, editor: editorList[next] })) + } + + const handleScanViaChat = async (apikey) => { + setScanning(true) + setScanMessage('Recherche des éditeurs sur votre système...') + setError(null) + try { + const detected = [] + const fallback = async () => { + setScanMessage('Utilisation du scan local...') + const data = await api.getEditors() + return (data.editors || []).map(e => e.name) + } + const prompt = 'Liste tous les éditeurs de texte et IDE installés sur ce système. Exécute les commandes nécessaires pour les détecter (which, command -v, etc.). Réponds UNIQUEMENT avec les noms séparés par des virgules, sans aucune autre explication. Exemples: vim, nvim, code, emacs, nano, helix, subl, zed' + const ctrl = new AbortController() + scanAbortRef.current = ctrl + const full = await api.sendChat(prompt, true, (text, data) => { + if (data.tool_call) setScanMessage('Exécution: ' + (data.tool_call.name || '...')) + else if (data.tool_result) setScanMessage('Analyse des résultats...') + else if (data.content) setScanMessage('Réception: ' + text.slice(0, 60) + (text.length > 60 ? '...' : '')) + }, ctrl.signal) + const names = full.split(/[,\n]/).map(s => s.replace(/[^a-zA-Z0-9._-]/g, '')).filter(Boolean) + if (names.length > 0) { + detected.push(...names) + } else { + detected.push(...(await fallback())) + } + const merged = [...new Set([...detected.map(n => n.toLowerCase()), ...BASE_EDITORS])] + setEditorList(merged) + setScanMessage('') + } catch (err) { + try { + setScanMessage('Fallback: scan local...') + const data = await api.getEditors() + const detected = (data.editors || []).map(e => e.name) + const merged = [...new Set([...detected, ...BASE_EDITORS])] + setEditorList(merged) + } catch {} + setScanMessage('') + } + setScanning(false) + } + useEffect(() => { const handler = (e) => { if (e.key === 'Escape') { goPrev(); return } + if (current.key === 'language') { + if (e.key === 'Tab' || e.key === 'ArrowRight') { e.preventDefault(); cycleOption('language', LANGUAGES, 1); return } + if (e.key === 'ArrowLeft') { e.preventDefault(); cycleOption('language', LANGUAGES, -1); return } + } + if (current.key === 'keyboard') { + if (e.key === 'Tab' || e.key === 'ArrowRight') { e.preventDefault(); cycleOption('keyboard', layouts, 1); return } + if (e.key === 'ArrowLeft') { e.preventDefault(); cycleOption('keyboard', layouts, -1); return } + } + if (current.key === 'editor') { + if (e.key === 'Tab' || e.key === 'ArrowRight') { e.preventDefault(); cycleOptionEditor(1); return } + if (e.key === 'ArrowLeft') { e.preventDefault(); cycleOptionEditor(-1); return } + } + if (e.key === 'Tab') { e.preventDefault(); const input = document.querySelector('.onboarding-input'); if (input) input.focus(); return } if (e.key === 'Enter' && current.key !== 'done' && current.key !== 'editor') { e.preventDefault(); goNext() } } window.addEventListener('keydown', handler) return () => window.removeEventListener('keydown', handler) - }, [step, current]) + }, [step, current, answers, editorList]) + + useEffect(() => { + return () => { if (scanAbortRef.current) scanAbortRef.current.abort() } + }, []) useEffect(() => { if (current.key === 'done' && !saving) { @@ -88,6 +160,14 @@ export default function OnboardingWizard({ api, onComplete }) { base_url: 'https://api.minimax.io/v1', }) setKeyValid(true) + await api.saveProvider({ + name: 'minimax', + api_key: answers.apikey, + model: 'MiniMax-M2.7', + base_url: 'https://api.minimax.io/v1', + active: true, + }) + handleScanViaChat(answers.apikey) } catch (err) { setError(err.message || 'Clé invalide') setKeyValid(false) @@ -95,22 +175,7 @@ export default function OnboardingWizard({ api, onComplete }) { setValidating(false) } - const handleScanEditors = async () => { - setScanning(true) - setError(null) - try { - const data = await api.getEditors() - const detected = (data.editors || []).map(e => e.name) - const merged = [...new Set([...detected, ...BASE_EDITORS])] - setEditorList(merged) - if (detected.length === 0) { - setError('Aucun éditeur détecté') - } - } catch (err) { - setError(err.message || 'Erreur lors du scan') - } - setScanning(false) - } + const handleSave = async () => { setSaving(true) @@ -154,9 +219,10 @@ export default function OnboardingWizard({ api, onComplete }) {
- {STEPS.map((_, i) => ( -
- ))} + {STEPS.filter(s => s.key !== 'done').map(s => { + const i = STEPS.indexOf(s) + return
+ })}
@@ -221,7 +287,7 @@ export default function OnboardingWizard({ api, onComplete }) {
Clé API MiniMax
- Entrez votre clé API MiniMax pour activer l'assistant IA. Vous pouvez passer cette étape et la configurer plus tard. + Entrez votre clé API MiniMax pour activer l'assistant IA. La clé est obligatoire pour continuer.
{error && !keyValid &&
{error}
} - {keyValid &&
Clé valide ✓
} + {keyValid && !scanning &&
Clé valide ✓ — Appuyez sur Entrée pour continuer
} + {scanning && ( +
+ + {scanMessage} +
+ )} + {requiredError &&
Veuillez valider votre clé API pour continuer
}
-
- {answers.apikey.trim() && !keyValid && !error && ( -
Cliquez "Valider la clé" ou "Passer"
+ {!keyValid && !error && answers.apikey.trim() && ( +
Entrez votre clé puis cliquez "Valider la clé"
)}
)} @@ -258,27 +324,19 @@ export default function OnboardingWizard({ api, onComplete }) { {current.key === 'editor' && (
Quel éditeur utilisez-vous ?
-
-
- {editorList.map(ed => ( -
setAnswers(a => ({ ...a, editor: ed }))} - > - {ed} -
- ))} -
- +
+ {scanning ? 'Détection en cours...' : 'Sélectionnez votre éditeur ou tapez-en un autre ci-dessous.'} +
+
+ {editorList.map(ed => ( +
setAnswers(a => ({ ...a, editor: ed }))} + > + {ed} +
+ ))}
setAnswers(a => ({ ...a, editor: e.target.value }))} autoFocus /> - {error &&
{error}
}
)} @@ -394,6 +451,10 @@ export default function OnboardingWizard({ api, onComplete }) { .onboarding-hint { font-size: 12px; color: var(--text-tertiary); margin-top: 4px; } + .onboarding-scanning { + display: flex; align-items: center; gap: 8px; + font-size: 13px; color: var(--accent); margin-top: 4px; + } .spin-icon { animation: spin 1s linear infinite; } diff --git a/web/src/styles/global.css b/web/src/styles/global.css index f25c2f4..0213d00 100644 --- a/web/src/styles/global.css +++ b/web/src/styles/global.css @@ -525,10 +525,122 @@ input::placeholder { color: var(--text-disabled); } .empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 40px 20px; color: var(--text-disabled); font-size: 13px; text-align: center; gap: 8px; } +/* ── Dashboard Grid ── */ +.dash-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 12px; + padding: 16px; + height: 100%; + overflow: hidden; +} +.dash-card { + background: var(--bg-card); border: 1px solid var(--border); + border-radius: var(--radius-lg); padding: 14px 16px; + display: flex; flex-direction: column; gap: 8px; + overflow: hidden; +} +.dash-span-2 { grid-column: span 2; } +.dash-card-head { + display: flex; align-items: center; justify-content: space-between; + margin-bottom: 4px; +} +.dash-label { + font-size: 11px; font-weight: 700; color: var(--accent); + text-transform: uppercase; letter-spacing: 0.5px; +} +.dash-count { + font-size: 10px; font-family: var(--font-mono); + background: var(--bg-input); padding: 1px 6px; border-radius: 10px; +} +.dash-count.warn { background: var(--accent-bg); color: var(--accent); } + +/* Tools row */ +.dash-tools-row { + display: flex; flex-wrap: wrap; gap: 6px; +} +.dash-tool-tag { + font-size: 11px; font-family: var(--font-mono); + padding: 3px 8px; border-radius: var(--radius); + background: var(--bg-surface); +} +.dash-tool-tag.ok { color: var(--success); } +.dash-tool-tag.missing { color: var(--error); } + +/* Quota */ +.dash-quota-list { display: flex; flex-direction: column; gap: 6px; } +.dash-quota-row { display: flex; align-items: center; gap: 8px; } +.dash-quota-name { + font-size: 11px; font-weight: 600; color: var(--text-primary); + min-width: 80px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.dash-bar { + flex: 1; height: 4px; background: var(--bg-input); border-radius: 2px; overflow: hidden; +} +.dash-bar-fill { + height: 100%; background: var(--accent); border-radius: 2px; + transition: width 0.3s; +} +.dash-quota-val { + font-size: 10px; font-family: var(--font-mono); color: var(--text-tertiary); + white-space: nowrap; +} + +/* Processes */ +.dash-proc-list { display: flex; flex-direction: column; gap: 4px; } +.dash-proc-row { + display: flex; justify-content: space-between; align-items: center; + padding: 4px 0; +} +.dash-proc-name { + font-size: 11px; font-weight: 600; color: var(--text-primary); + font-family: var(--font-mono); +} +.dash-proc-res { + font-size: 10px; font-family: var(--font-mono); color: var(--text-tertiary); +} + +/* Commands */ +.dash-cmd-list { display: flex; flex-direction: column; gap: 3px; } +.dash-cmd-row { + display: flex; align-items: center; gap: 6px; + padding: 3px 0; overflow: hidden; +} +.dash-cmd-shell { + font-size: 9px; font-family: var(--font-mono); color: var(--text-disabled); + background: var(--bg-input); padding: 1px 4px; border-radius: 3px; + text-transform: uppercase; flex-shrink: 0; +} +.dash-cmd-text { + font-size: 11px; font-family: var(--font-mono); color: var(--text-secondary); + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} + +/* Services */ +.dash-services { display: flex; flex-direction: column; gap: 6px; } +.dash-svc-row { + display: flex; justify-content: space-between; align-items: center; + padding: 4px 0; +} +.dash-svc-name { font-size: 12px; font-weight: 600; color: var(--text-primary); } +.dash-svc-val { font-size: 11px; font-family: var(--font-mono); color: var(--text-tertiary); } +.dash-svc-issues { margin-top: 4px; } +.dash-svc-issue { font-size: 10px; color: var(--warning); padding: 2px 0; } + +/* Updates */ +.dash-updates-list { display: flex; flex-direction: column; gap: 4px; } +.dash-update-row { + display: flex; justify-content: space-between; align-items: center; +} +.dash-update-name { font-size: 12px; font-weight: 600; color: var(--text-primary); } +.dash-update-ver { font-size: 11px; font-family: var(--font-mono); color: var(--text-tertiary); } + +.dash-empty { font-size: 11px; color: var(--text-disabled); } + +/* Legacy dashboard kept for reference */ .dashboard-layout { display: flex; flex-direction: column; height: 100%; } .dashboard-content { flex: 1; overflow-y: auto; } .dashboard-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; padding: 24px; } - .dashboard-section { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 20px; transition: border-color 0.2s; @@ -540,11 +652,8 @@ input::placeholder { color: var(--text-disabled); } font-size: 12px; font-weight: 700; color: var(--accent); text-transform: uppercase; letter-spacing: 0.5px; } - .dashboard-workflows-inline { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; } - .dashboard-notifications-inline { display: flex; flex-direction: column; gap: 2px; } - .dashboard-notifications { padding: 0; } .notif-row { display: flex; align-items: flex-start; gap: 12px; @@ -557,7 +666,6 @@ input::placeholder { color: var(--text-disabled); } .notif-ok .notif-text { color: var(--success); } .notif-warn .notif-text { color: var(--warning); } .notif-error .notif-text { color: var(--error); } - .dashboard-workflows { padding: 0; display: flex; flex-direction: column; gap: 24px; } .workflow-section { } .section-label { @@ -565,81 +673,6 @@ input::placeholder { color: var(--text-disabled); } letter-spacing: 1px; margin-bottom: 12px; padding-bottom: 6px; border-bottom: 1px solid var(--border); } -/* ── Dashboard Tabs ── */ -.dashboard-tabs { - display: flex; gap: 4px; padding: 12px 20px 0; - border-bottom: 1px solid var(--border); background: var(--bg-surface); flex-shrink: 0; -} -.dashboard-tab { - padding: 8px 16px; border-radius: var(--radius) var(--radius) 0 0; - border: 1px solid transparent; border-bottom: none; background: transparent; - color: var(--text-tertiary); font-size: 12px; font-weight: 600; cursor: pointer; - display: flex; align-items: center; gap: 6px; transition: all 0.15s; -} -.dashboard-tab:hover { color: var(--text-primary); background: var(--bg-hover); } -.dashboard-tab.active { background: var(--bg-card); color: var(--accent); border-color: var(--border); } -.dashboard-tab .tab-icon { font-size: 14px; } -.dashboard-tab .tab-count { - background: var(--bg-input); padding: 1px 6px; border-radius: 10px; font-size: 10px; font-family: var(--font-mono); -} -.dashboard-tab .tab-count.warn { background: var(--accent-bg); color: var(--accent); } - -.dashboard-tools-panel { padding: 20px 24px; } -.dashboard-tools-stats { display: flex; gap: 12px; font-size: 12px; } -.stat-ok { color: var(--success); font-family: var(--font-mono); } -.stat-missing { color: var(--error); font-family: var(--font-mono); } - -.dashboard-system-info { display: flex; gap: 8px; align-items: center; margin-bottom: 16px; font-size: 12px; color: var(--text-tertiary); } -.sys-info-item { font-family: var(--font-mono); } -.sys-info-sep { color: var(--text-disabled); } - -.tools-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px; margin-top: 8px; } -.tool-card { - background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius-lg); - padding: 14px 16px; display: flex; align-items: center; gap: 12px; transition: border-color 0.2s; -} -.tool-card:hover { border-color: var(--accent-dim); } -.tool-card.installed { border-left: 3px solid var(--success); } -.tool-card.missing { border-left: 3px solid var(--error); } -.tool-card-icon { font-size: 20px; flex-shrink: 0; } -.tool-card-info { flex: 1; min-width: 0; } -.tool-card-name { font-weight: 600; font-size: 13px; color: var(--text-primary); margin-bottom: 2px; } -.tool-card-version { font-size: 11px; color: var(--text-tertiary); display: flex; align-items: center; gap: 6px; } -.tool-version-text { font-family: var(--font-mono); font-size: 10px; color: var(--text-disabled); } -.status-ok { color: var(--success); } -.status-missing { color: var(--error); } -.tool-card-actions { flex-shrink: 0; display: flex; align-items: center; gap: 6px; } -.tool-update-badge { background: var(--accent-bg); color: var(--accent); font-size: 10px; font-family: var(--font-mono); padding: 2px 6px; border-radius: 4px; cursor: pointer; } -.tool-update-badge:hover { background: var(--accent-dim); } - -.dashboard-activity-panel { padding: 20px 24px; } -.activity-log { display: flex; flex-direction: column; gap: 2px; } -.notif-icon { font-size: 12px; width: 16px; text-align: center; } - -.dashboard-actions-panel { padding: 20px 24px; } -.quick-actions-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 10px; margin-bottom: 24px; } -.quick-action-btn { - background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius-lg); - padding: 16px 20px; display: flex; align-items: center; gap: 12px; cursor: pointer; - transition: all 0.2s; font-size: 13px; color: var(--text-secondary); -} -.quick-action-btn:hover:not(:disabled) { border-color: var(--accent-dim); background: var(--bg-hover); color: var(--text-primary); } -.quick-action-btn:disabled { opacity: 0.5; cursor: not-allowed; } -.quick-action-icon { font-size: 18px; } -.quick-action-label { font-weight: 600; } - -.dashboard-updates-section { margin-top: 16px; } -.updates-list { display: flex; flex-direction: column; gap: 6px; } -.update-row { - display: flex; align-items: center; justify-content: space-between; - padding: 10px 14px; border-radius: var(--radius); background: var(--bg-card); - border: 1px solid var(--border); -} -.update-row:hover { border-color: var(--accent-dim); } -.update-info { display: flex; align-items: center; gap: 16px; } -.update-name { font-weight: 600; color: var(--text-primary); font-size: 13px; min-width: 100px; } -.update-versions { color: var(--text-tertiary); font-size: 12px; font-family: var(--font-mono); } - .panel-header { display: flex; align-items: center; justify-content: space-between; padding: 10px 16px; border-bottom: 1px solid var(--border); background: var(--bg-surface);