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 && (
- onInstall(tool.name)}
- disabled={installing}
- >
- {installing ? '...' : t('dashboard.install')}
-
- )}
-
-
- )
-}
-
-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 (
-
- {loading ? : {icon} }
- {label}
-
- )
-}
-
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 (
-
-
-
setActiveTab('tools')}
- >
- 🔧
- {t('dashboard.tools')}
- {installedCount}
-
-
setActiveTab('activity')}
- >
- 📋
- {t('dashboard.activity')}
- {notifications.length > 0 && {notifications.length} }
-
-
setActiveTab('actions')}
- >
- ⚡
- {t('dashboard.quickActions')}
-
-
setActiveTab('status')}
- >
- 📡
- {t('dashboard.status') || 'Status'}
-
+
+ {/* System */}
+
+
+ {sys.os || sys.platform || 'System'} · {sys.arch || ''}
+ api.runScan().then(loadData)}>↻ Rescan
+
+
+ {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')}
-
setNotifications([])} disabled={notifications.length === 0}>
- {t('dashboard.clearLog')}
-
+ {/* 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 || '?'}
-
-
-
api.runUpdate(update.name)}
- disabled={loading}
- >
- {t('dashboard.update')}
-
-
- ))}
-
-
- )}
-
- )}
-
- {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
}
{validating ? 'Validation...' : 'Valider la clé'}
-
- Passer
-
- {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 ? : }
-
+
+ {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);