Compare commits

...

3 Commits

Author SHA1 Message Date
Augustin
e8f6dc4b4d feat(chat): add auto-summarization with token tracking UI
All checks were successful
Beta Release / beta (push) Successful in 47s
Add /summarize command, token usage bar, and summary endpoint.
Add JSON tags to config/platform/scanner structs for API serialization.

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-23 20:42:43 +02:00
Augustin
bb03c9fe2d feat(dashboard): add background graphs to cards and improve layout
All checks were successful
Beta Release / beta (push) Successful in 39s
- Add BgGraph component for subtle background SVG graphs
- Add gradient fills to MiniGraph components
- Track process count over time
- Calculate total API quota usage
- Improve card styling with overlay content

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-23 19:55:10 +02:00
Augustin
79d082180c feat(dashboard): single-view grid with live CPU/RAM/Net graphs, API quota, processes, and sudo indicator
All checks were successful
Beta Release / beta (push) Successful in 46s
- Rewrite dashboard from 4 tabs to single grid view with 5s auto-refresh
- Add live CPU/RAM/Network SVG graphs with rolling 30-point history
- Add backend /api/system/metrics reading /proc/stat, /proc/meminfo, /proc/net/dev
- Add backend /api/providers/quota for MiniMax and Z.AI quota monitoring
- Add backend /api/recent-commands reading bash/zsh history
- Add backend /api/running-processes filtering editors/IDEs/languages
- Add sudo/root indicator ( ROOT) in footer when running as root
- Remove duplicate Ctrl+1-4 shortcut from page-specific footer (keep only right side)
- Add Ctrl+R shortcut on dashboard for metrics-only refresh
- Make API key mandatory in onboarding, auto-scan editors via AI chat
- Remove manual editor input, only show AI-detected editors
- Bump version to 0.3.3

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-23 19:46:16 +02:00
13 changed files with 461 additions and 217 deletions

View File

@@ -206,8 +206,11 @@ func (s *Server) handleChatHistory(w http.ResponseWriter, r *http.Request) {
} }
messages := s.convStore.Get() messages := s.convStore.Get()
writeJSON(w, map[string]interface{}{ writeJSON(w, map[string]interface{}{
"messages": messages, "messages": messages,
"tokens": s.convStore.ApproxTokenCount(), "tokens": s.convStore.ApproxTokenCount(),
"max_tokens": maxTokensApprox,
"summarize_at": summarizeThreshold,
"summary": s.convStore.GetSummary(),
}) })
} }
@@ -219,3 +222,16 @@ func (s *Server) handleChatClear(w http.ResponseWriter, r *http.Request) {
s.convStore.Clear() s.convStore.Clear()
writeJSON(w, map[string]string{"status": "ok"}) writeJSON(w, map[string]string{"status": "ok"})
} }
func (s *Server) handleChatSummarize(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
s.autoSummarize()
writeJSON(w, map[string]interface{}{
"status": "ok",
"tokens": s.convStore.ApproxTokenCount(),
"summary": s.convStore.GetSummary(),
})
}

View File

@@ -23,6 +23,7 @@ func (s *Server) handleInfo(w http.ResponseWriter, r *http.Request) {
"name": version.Name, "name": version.Name,
"version": version.Version, "version": version.Version,
"author": version.Author, "author": version.Author,
"sudo": os.Geteuid() == 0,
}) })
} }
@@ -609,3 +610,111 @@ func (s *Server) handleRunningProcesses(w http.ResponseWriter, r *http.Request)
writeJSON(w, map[string]interface{}{"processes": procs}) writeJSON(w, map[string]interface{}{"processes": procs})
} }
type sysMetrics struct {
CPUPercent float64 `json:"cpu_percent"`
MemPercent float64 `json:"mem_percent"`
MemUsedMB float64 `json:"mem_used_mb"`
MemTotalMB float64 `json:"mem_total_mb"`
NetRxKBs float64 `json:"net_rx_kbs"`
NetTxKBs float64 `json:"net_tx_kbs"`
}
var (
lastCPU [2]float64
lastNet [2]float64
lastNetTs time.Time
lastCPUSet bool
)
func (s *Server) handleSystemMetrics(w http.ResponseWriter, r *http.Request) {
m := sysMetrics{}
// CPU from /proc/stat
if data, err := os.ReadFile("/proc/stat"); err == nil {
line := strings.Split(string(data), "\n")[0]
fields := strings.Fields(line)
if len(fields) >= 5 {
var idle, total float64
for i := 1; i < len(fields) && i <= 4; i++ {
var v float64
fmt.Sscanf(fields[i], "%f", &v)
total += v
if i == 4 {
idle = v
}
}
if lastCPUSet {
dIdle := idle - lastCPU[0]
dTotal := total - lastCPU[1]
if dTotal > 0 {
m.CPUPercent = (1 - dIdle/dTotal) * 100
}
}
lastCPU = [2]float64{idle, total}
lastCPUSet = true
}
}
// Memory from /proc/meminfo
if data, err := os.ReadFile("/proc/meminfo"); err == nil {
var memTotal, memAvailable float64
for _, line := range strings.Split(string(data), "\n") {
fields := strings.Fields(line)
if len(fields) < 2 {
continue
}
var v float64
fmt.Sscanf(fields[1], "%f", &v)
switch fields[0] {
case "MemTotal:":
memTotal = v
case "MemAvailable:":
memAvailable = v
}
}
if memTotal > 0 {
m.MemTotalMB = memTotal / 1024
m.MemUsedMB = (memTotal - memAvailable) / 1024
m.MemPercent = (memTotal - memAvailable) / memTotal * 100
}
}
// Network from /proc/net/dev
if data, err := os.ReadFile("/proc/net/dev"); err == nil {
var rxBytes, txBytes float64
for _, line := range strings.Split(string(data), "\n")[2:] {
fields := strings.Fields(line)
if len(fields) < 10 {
continue
}
iface := strings.TrimSuffix(fields[0], ":")
if iface == "lo" {
continue
}
var rx, tx float64
fmt.Sscanf(fields[1], "%f", &rx)
fmt.Sscanf(fields[9], "%f", &tx)
rxBytes += rx
txBytes += tx
}
now := time.Now()
if !lastNetTs.IsZero() {
elapsed := now.Sub(lastNetTs).Seconds()
if elapsed > 0 {
m.NetRxKBs = (rxBytes - lastNet[0]) / 1024 / elapsed
m.NetTxKBs = (txBytes - lastNet[1]) / 1024 / elapsed
if m.NetRxKBs < 0 {
m.NetRxKBs = 0
}
if m.NetTxKBs < 0 {
m.NetTxKBs = 0
}
}
}
lastNet = [2]float64{rxBytes, txBytes}
lastNetTs = now
}
writeJSON(w, m)
}

View File

@@ -85,6 +85,7 @@ func (s *Server) routes() {
s.mux.HandleFunc("/api/chat", s.handleChat) s.mux.HandleFunc("/api/chat", s.handleChat)
s.mux.HandleFunc("/api/chat/history", s.handleChatHistory) s.mux.HandleFunc("/api/chat/history", s.handleChatHistory)
s.mux.HandleFunc("/api/chat/clear", s.handleChatClear) s.mux.HandleFunc("/api/chat/clear", s.handleChatClear)
s.mux.HandleFunc("/api/chat/summarize", s.handleChatSummarize)
s.mux.HandleFunc("/api/tool/call", s.handleToolCall) s.mux.HandleFunc("/api/tool/call", s.handleToolCall)
s.mux.HandleFunc("/api/tools/list", s.handleToolList) s.mux.HandleFunc("/api/tools/list", s.handleToolList)
s.mux.HandleFunc("/api/shell/chat", s.handleShellChat) s.mux.HandleFunc("/api/shell/chat", s.handleShellChat)
@@ -116,6 +117,7 @@ func (s *Server) routes() {
s.mux.HandleFunc("/api/providers/quota", s.handleProvidersQuota) s.mux.HandleFunc("/api/providers/quota", s.handleProvidersQuota)
s.mux.HandleFunc("/api/recent-commands", s.handleRecentCommands) s.mux.HandleFunc("/api/recent-commands", s.handleRecentCommands)
s.mux.HandleFunc("/api/running-processes", s.handleRunningProcesses) s.mux.HandleFunc("/api/running-processes", s.handleRunningProcesses)
s.mux.HandleFunc("/api/system/metrics", s.handleSystemMetrics)
} }
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {

View File

@@ -12,66 +12,66 @@ import (
) )
type Profile struct { type Profile struct {
Name string `yaml:"name"` Name string `yaml:"name" json:"name"`
Pseudo string `yaml:"pseudo"` Pseudo string `yaml:"pseudo" json:"pseudo"`
Email string `yaml:"email"` Email string `yaml:"email" json:"email"`
Languages []string `yaml:"languages"` Languages []string `yaml:"languages" json:"languages"`
Preferences struct { Preferences struct {
Editor string `yaml:"editor"` Editor string `yaml:"editor" json:"editor"`
Shell string `yaml:"shell"` Shell string `yaml:"shell" json:"shell"`
Theme string `yaml:"theme"` Theme string `yaml:"theme" json:"theme"`
DefaultAI string `yaml:"default_ai"` DefaultAI string `yaml:"default_ai" json:"default_ai"`
AutoUpdate bool `yaml:"auto_update"` AutoUpdate bool `yaml:"auto_update" json:"auto_update"`
CheckOnStart bool `yaml:"check_on_start"` CheckOnStart bool `yaml:"check_on_start" json:"check_on_start"`
Language string `yaml:"language"` Language string `yaml:"language" json:"language"`
KeyboardLayout string `yaml:"keyboard_layout"` KeyboardLayout string `yaml:"keyboard_layout" json:"keyboard_layout"`
} `yaml:"preferences"` } `yaml:"preferences" json:"preferences"`
} }
type AIProvider struct { type AIProvider struct {
Name string `yaml:"name"` Name string `yaml:"name" json:"name"`
APIKey string `yaml:"api_key,omitempty"` APIKey string `yaml:"api_key,omitempty" json:"api_key,omitempty"`
BaseURL string `yaml:"base_url,omitempty"` BaseURL string `yaml:"base_url,omitempty" json:"base_url,omitempty"`
Model string `yaml:"model"` Model string `yaml:"model" json:"model"`
Active bool `yaml:"active"` Active bool `yaml:"active" json:"active"`
} }
type ToolConfig struct { type ToolConfig struct {
Name string `yaml:"name"` Name string `yaml:"name" json:"name"`
Installed bool `yaml:"installed"` Installed bool `yaml:"installed" json:"installed"`
Version string `yaml:"version"` Version string `yaml:"version" json:"version"`
AutoUpdate bool `yaml:"auto_update"` AutoUpdate bool `yaml:"auto_update" json:"auto_update"`
} }
type SSHConnection struct { type SSHConnection struct {
Name string `yaml:"name"` Name string `yaml:"name" json:"name"`
Host string `yaml:"host"` Host string `yaml:"host" json:"host"`
Port int `yaml:"port"` Port int `yaml:"port" json:"port"`
User string `yaml:"user"` User string `yaml:"user" json:"user"`
Password string `yaml:"password,omitempty"` Password string `yaml:"password,omitempty" json:"password,omitempty"`
KeyPath string `yaml:"key_path,omitempty"` KeyPath string `yaml:"key_path,omitempty" json:"key_path,omitempty"`
} }
type MuyueConfig struct { type MuyueConfig struct {
Version string `yaml:"version"` Version string `yaml:"version" json:"version"`
Profile Profile `yaml:"profile"` Profile Profile `yaml:"profile" json:"profile"`
AI struct { AI struct {
Providers []AIProvider `yaml:"providers"` Providers []AIProvider `yaml:"providers" json:"providers"`
} `yaml:"ai"` } `yaml:"ai" json:"ai"`
Tools []ToolConfig `yaml:"tools"` Tools []ToolConfig `yaml:"tools" json:"tools"`
BMAD struct { BMAD struct {
Installed bool `yaml:"installed"` Installed bool `yaml:"installed" json:"installed"`
Version string `yaml:"version"` Version string `yaml:"version" json:"version"`
Global bool `yaml:"global"` Global bool `yaml:"global" json:"global"`
} `yaml:"bmad"` } `yaml:"bmad" json:"bmad"`
Terminal struct { Terminal struct {
CustomPrompt bool `yaml:"custom_prompt"` CustomPrompt bool `yaml:"custom_prompt" json:"custom_prompt"`
PromptTheme string `yaml:"prompt_theme"` PromptTheme string `yaml:"prompt_theme" json:"prompt_theme"`
SSH []SSHConnection `yaml:"ssh"` SSH []SSHConnection `yaml:"ssh" json:"ssh"`
FontSize int `yaml:"font_size"` FontSize int `yaml:"font_size" json:"font_size"`
FontFamily string `yaml:"font_family"` FontFamily string `yaml:"font_family" json:"font_family"`
Theme string `yaml:"theme"` Theme string `yaml:"theme" json:"theme"`
} `yaml:"terminal"` } `yaml:"terminal" json:"terminal"`
} }
type TerminalTheme struct { type TerminalTheme struct {

View File

@@ -24,12 +24,12 @@ const (
) )
type SystemInfo struct { type SystemInfo struct {
OS OS OS OS `json:"os"`
Arch Arch Arch Arch `json:"arch"`
IsWSL bool IsWSL bool `json:"is_wsl"`
Shell string Shell string `json:"shell"`
Terminal string Terminal string `json:"terminal"`
PackageManager string PackageManager string `json:"package_manager"`
} }
func Detect() SystemInfo { func Detect() SystemInfo {

View File

@@ -14,27 +14,27 @@ import (
) )
type ToolStatus struct { type ToolStatus struct {
Name string `yaml:"name"` Name string `yaml:"name" json:"name"`
Installed bool `yaml:"installed"` Installed bool `yaml:"installed" json:"installed"`
Version string `yaml:"version"` Version string `yaml:"version" json:"version"`
Path string `yaml:"path"` Path string `yaml:"path" json:"path"`
Latest string `yaml:"latest"` Latest string `yaml:"latest" json:"latest"`
NeedsUpdate bool `yaml:"needs_update"` NeedsUpdate bool `yaml:"needs_update" json:"needs_update"`
Category string `yaml:"category"` Category string `yaml:"category" json:"category"`
} }
type RuntimeStatus struct { type RuntimeStatus struct {
Name string `yaml:"name"` Name string `yaml:"name" json:"name"`
Installed bool `yaml:"installed"` Installed bool `yaml:"installed" json:"installed"`
Version string `yaml:"version"` Version string `yaml:"version" json:"version"`
} }
type ScanResult struct { type ScanResult struct {
System platform.SystemInfo `yaml:"system"` System platform.SystemInfo `yaml:"system" json:"system"`
Tools []ToolStatus `yaml:"tools"` Tools []ToolStatus `yaml:"tools" json:"tools"`
Runtimes []RuntimeStatus `yaml:"runtimes"` Runtimes []RuntimeStatus `yaml:"runtimes" json:"runtimes"`
ShellSetup bool `yaml:"shell_setup"` ShellSetup bool `yaml:"shell_setup" json:"shell_setup"`
GitConfigured bool `yaml:"git_configured"` GitConfigured bool `yaml:"git_configured" json:"git_configured"`
} }
var ( var (

View File

@@ -7,7 +7,7 @@ import (
const ( const (
Name = "muyue" Name = "muyue"
Version = "0.3.2" Version = "0.3.3"
Author = "La Légion de Muyue" Author = "La Légion de Muyue"
) )

View File

@@ -40,6 +40,7 @@ const api = {
getProvidersQuota: () => request('/providers/quota'), getProvidersQuota: () => request('/providers/quota'),
getRecentCommands: () => request('/recent-commands'), getRecentCommands: () => request('/recent-commands'),
getRunningProcesses: () => request('/running-processes'), getRunningProcesses: () => request('/running-processes'),
getSystemMetrics: () => request('/system/metrics'),
savePreferences: (prefs) => request('/preferences', { method: 'PUT', body: JSON.stringify(prefs) }), savePreferences: (prefs) => request('/preferences', { method: 'PUT', body: JSON.stringify(prefs) }),
saveProfile: (profile) => request('/config/profile', { method: 'PUT', body: JSON.stringify(profile) }), saveProfile: (profile) => request('/config/profile', { method: 'PUT', body: JSON.stringify(profile) }),
saveProvider: (provider) => request('/config/provider', { method: 'PUT', body: JSON.stringify(provider) }), saveProvider: (provider) => request('/config/provider', { method: 'PUT', body: JSON.stringify(provider) }),
@@ -55,6 +56,7 @@ const api = {
saveTerminalSettings: (settings) => request('/terminal/settings', { method: 'PUT', body: JSON.stringify(settings) }), saveTerminalSettings: (settings) => request('/terminal/settings', { method: 'PUT', body: JSON.stringify(settings) }),
getChatHistory: () => request('/chat/history'), getChatHistory: () => request('/chat/history'),
clearChat: () => request('/chat/clear', { method: 'POST' }), clearChat: () => request('/chat/clear', { method: 'POST' }),
summarizeChat: () => request('/chat/summarize', { method: 'POST' }),
sendChat: (message, stream = true, onChunk, signal) => { sendChat: (message, stream = true, onChunk, signal) => {
if (!stream) { if (!stream) {
return request('/chat', { method: 'POST', body: JSON.stringify({ message, stream: false }) }) return request('/chat', { method: 'POST', body: JSON.stringify({ message, stream: false }) })

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback, useMemo } from 'react' import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
import { LayoutDashboard, Sparkles, Terminal, Settings } from 'lucide-react' import { LayoutDashboard, Sparkles, Terminal, Settings } from 'lucide-react'
import api from '../api/client' import api from '../api/client'
import { getTheme, applyTheme } from '../themes' import { getTheme, applyTheme } from '../themes'
@@ -13,6 +13,9 @@ export default function App() {
const [activeTab, setActiveTab] = useState('dash') const [activeTab, setActiveTab] = useState('dash')
const [info, setInfo] = useState({}) const [info, setInfo] = useState({})
const [clock, setClock] = useState(new Date()) const [clock, setClock] = useState(new Date())
const [isSudo, setIsSudo] = useState(false)
const [dashRefreshKey, setDashRefreshKey] = useState(0)
const dashRefreshRef = useRef(null)
const [updates, setUpdates] = useState([]) const [updates, setUpdates] = useState([])
const [tools, setTools] = useState([]) const [tools, setTools] = useState([])
const [config, setConfig] = useState(null) const [config, setConfig] = useState(null)
@@ -27,7 +30,7 @@ export default function App() {
], [t]) ], [t])
useEffect(() => { useEffect(() => {
api.getInfo().then(setInfo).catch(() => {}) api.getInfo().then(d => { setInfo(d); setIsSudo(!!d.sudo) }).catch(() => {})
api.getTools().then(d => setTools(d.tools || [])).catch(() => {}) api.getTools().then(d => setTools(d.tools || [])).catch(() => {})
api.getUpdates().then(d => setUpdates(d.updates || [])).catch(() => {}) api.getUpdates().then(d => setUpdates(d.updates || [])).catch(() => {})
api.getConfig().then(d => { api.getConfig().then(d => {
@@ -60,6 +63,11 @@ export default function App() {
if (map[e.code]) { if (map[e.code]) {
e.preventDefault() e.preventDefault()
setActiveTab(map[e.code]) setActiveTab(map[e.code])
return
}
if (e.ctrlKey && e.code === 'KeyR') {
e.preventDefault()
if (dashRefreshRef.current) dashRefreshRef.current()
} }
} }
window.addEventListener('keydown', onKey) window.addEventListener('keydown', onKey)
@@ -72,27 +80,21 @@ export default function App() {
const installed = tools.filter(tool => tool.installed).length const installed = tools.filter(tool => tool.installed).length
const WINDOW_SHORTCUTS = useMemo(() => ({ const WINDOW_SHORTCUTS = useMemo(() => ({
dash: [ dash: [],
{ keys: `${layout.keys.ctrl}+${layout.keys.range}`, desc: t('statusbar.switchWindow') },
],
studio: [ studio: [
{ keys: layout.keys.enter, desc: t('statusbar.sendMessage') }, { keys: layout.keys.enter, desc: t('statusbar.sendMessage') },
{ keys: `${layout.keys.shift}+${layout.keys.enter}`, desc: t('statusbar.newLine') }, { keys: `${layout.keys.shift}+${layout.keys.enter}`, desc: t('statusbar.newLine') },
{ keys: `${layout.keys.ctrl}+${layout.keys.range}`, desc: t('statusbar.switchWindow') },
], ],
shell: [ shell: [
{ keys: layout.keys.enter, desc: t('statusbar.runCommand') }, { keys: layout.keys.enter, desc: t('statusbar.runCommand') },
{ keys: `${layout.keys.up}/${layout.keys.down}`, desc: t('statusbar.commandHistory') }, { keys: `${layout.keys.up}/${layout.keys.down}`, desc: t('statusbar.commandHistory') },
{ keys: `${layout.keys.ctrl}+${layout.keys.range}`, desc: t('statusbar.switchWindow') },
],
config: [
{ keys: `${layout.keys.ctrl}+${layout.keys.range}`, desc: t('statusbar.switchWindow') },
], ],
config: [],
}), [layout, t]) }), [layout, t])
const renderContent = () => { const renderContent = () => {
switch (activeTab) { switch (activeTab) {
case 'dash': return <Dashboard api={api} /> case 'dash': return <Dashboard api={api} refreshRef={dashRefreshRef} />
case 'studio': return <Studio api={api} /> case 'studio': return <Studio api={api} />
case 'shell': return <Shell api={api} /> case 'shell': return <Shell api={api} />
case 'config': return <Config api={api} /> case 'config': return <Config api={api} />
@@ -147,6 +149,12 @@ export default function App() {
<footer className="statusbar"> <footer className="statusbar">
<div className="statusbar-left"> <div className="statusbar-left">
{isSudo && <span className="statusbar-sudo"> ROOT</span>}
{activeTab === 'dash' && (
<span className="statusbar-shortcut">
<kbd>{layout.keys.ctrl}+R</kbd> refresh
</span>
)}
<FooterShortcuts shortcuts={WINDOW_SHORTCUTS[activeTab] || []} /> <FooterShortcuts shortcuts={WINDOW_SHORTCUTS[activeTab] || []} />
</div> </div>
<div className="statusbar-right"> <div className="statusbar-right">

View File

@@ -1,113 +1,203 @@
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback, useRef } from 'react'
import { useI18n } from '../i18n' import { useI18n } from '../i18n'
export default function Dashboard({ api }) { const MAX_POINTS = 30
function BgGraph({ data, max, color }) {
if (!data || data.length < 2) return null
const m = max || Math.max(...data, 1)
const w = 120
const h = 60
const points = data.map((v, i) => {
const x = (i / (data.length - 1)) * w
const y = h - (v / m) * h
return `${x},${y}`
})
const area = `${points.join(' ')} ${w},${h} 0,${h}`
const line = points.join(' ')
return (
<svg viewBox={`0 0 ${w} ${h}`} className="dash-bg-graph" preserveAspectRatio="none">
<defs>
<linearGradient id={`g-${color.replace('#','')}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={color} stopOpacity="0.25" />
<stop offset="100%" stopColor={color} stopOpacity="0" />
</linearGradient>
</defs>
<polygon fill={`url(#g-${color.replace('#','')})`} points={area} />
<polyline fill="none" stroke={color} strokeWidth="1.5" points={line} vectorEffect="non-scaling-stroke" opacity="0.6" />
</svg>
)
}
function MiniGraph({ data, max, color, label, unit }) {
if (!data || data.length < 2) return <div className="dash-graph-empty">collecting...</div>
const m = max || Math.max(...data, 1)
const w = 100
const h = 32
const points = data.map((v, i) => {
const x = (i / (data.length - 1)) * w
const y = h - (v / m) * h
return `${x},${y}`
}).join(' ')
const last = data[data.length - 1]
return (
<div className="dash-graph-wrap">
<div className="dash-graph-header">
<span className="dash-graph-label">{label}</span>
<span className="dash-graph-value" style={{ color }}>{last.toFixed(1)}{unit}</span>
</div>
<svg viewBox={`0 0 ${w} ${h}`} className="dash-graph-svg" preserveAspectRatio="none">
<defs>
<linearGradient id={`fg-${color.replace('#','').replace('var(','').replace(')','')}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={color} stopOpacity="0.3" />
<stop offset="100%" stopColor={color} stopOpacity="0.02" />
</linearGradient>
</defs>
<polygon fill={`url(#fg-${color.replace('#','').replace('var(','').replace(')','')})`} points={`${points} ${w},${h} 0,${h}`} />
<polyline fill="none" stroke={color} strokeWidth="1.5" points={points} vectorEffect="non-scaling-stroke" />
</svg>
</div>
)
}
export default function Dashboard({ api, refreshRef }) {
const { t } = useI18n() const { t } = useI18n()
const [tools, setTools] = useState([])
const [systemInfo, setSystemInfo] = useState(null)
const [dashboardStatus, setDashboardStatus] = useState(null)
const [quota, setQuota] = useState(null) const [quota, setQuota] = useState(null)
const [recentCmds, setRecentCmds] = useState([]) const [recentCmds, setRecentCmds] = useState([])
const [processes, setProcesses] = useState([]) const [processes, setProcesses] = useState([])
const [updates, setUpdates] = useState([]) const [metrics, setMetrics] = useState(null)
const cpuRef = useRef([])
const memRef = useRef([])
const netRxRef = useRef([])
const netTxRef = useRef([])
const procCountRef = useRef([])
const loadData = useCallback(async () => { const loadData = useCallback(async () => {
try { try {
const [toolsData, systemData, dashData, quotaData, cmdData, procData, updatesData] = await Promise.all([ const [quotaData, cmdData, procData, metricsData] = await Promise.all([
api.getTools().catch(() => ({ tools: [] })),
api.getSystem().catch(() => null),
api.getDashboardStatus().catch(() => null),
api.getProvidersQuota().catch(() => null), api.getProvidersQuota().catch(() => null),
api.getRecentCommands().catch(() => ({ commands: [] })), api.getRecentCommands().catch(() => ({ commands: [] })),
api.getRunningProcesses().catch(() => ({ processes: [] })), api.getRunningProcesses().catch(() => ({ processes: [] })),
api.getUpdates().catch(() => ({ updates: [] })), api.getSystemMetrics().catch(() => null),
]) ])
setTools(toolsData.tools || toolsData || [])
setSystemInfo(systemData?.system || systemData)
setDashboardStatus(dashData)
setQuota(quotaData?.providers || []) setQuota(quotaData?.providers || [])
setRecentCmds(cmdData.commands || []) setRecentCmds(cmdData.commands || [])
setProcesses(procData.processes || []) setProcesses(procData.processes || [])
setUpdates(updatesData.updates || updatesData || []) if (metricsData) {
setMetrics(metricsData)
cpuRef.current = [...cpuRef.current, metricsData.cpu_percent].slice(-MAX_POINTS)
memRef.current = [...memRef.current, metricsData.mem_percent].slice(-MAX_POINTS)
netRxRef.current = [...netRxRef.current, metricsData.net_rx_kbs].slice(-MAX_POINTS)
netTxRef.current = [...netTxRef.current, metricsData.net_tx_kbs].slice(-MAX_POINTS)
}
procCountRef.current = [...procCountRef.current, procData.processes?.length || 0].slice(-MAX_POINTS)
} catch (err) { } catch (err) {
console.error('Dashboard load error:', err) console.error('Dashboard load error:', err)
} }
}, [api]) }, [api])
useEffect(() => { loadData() }, [loadData]) useEffect(() => {
loadData()
const installedCount = tools.filter(t => t.installed || t.status === 'installed').length if (refreshRef) refreshRef.current = loadData
const sys = systemInfo || {} const iv = setInterval(loadData, 5000)
return () => clearInterval(iv)
}, [loadData, refreshRef])
const minimax = (quota || []).find(p => p.name === 'minimax') const minimax = (quota || []).find(p => p.name === 'minimax')
const zai = (quota || []).find(p => p.name === 'zai') const zai = (quota || []).find(p => p.name === 'zai')
const totalQuotaUsed = minimax?.data?.models?.reduce((s, m) => s + (m.used || 0), 0) || 0
const totalQuotaMax = minimax?.data?.models?.reduce((s, m) => s + (m.total || 0), 0) || 1
return ( return (
<div className="dash-grid"> <div className="dash-grid">
{/* System */} {/* CPU */}
<div className="dash-card dash-span-2"> <div className="dash-card dash-card-graph">
<div className="dash-card-head"> <BgGraph data={cpuRef.current} max={100} color="#06b6d4" />
<span className="dash-label">{sys.os || sys.platform || 'System'} · {sys.arch || ''}</span> <div className="dash-card-content">
<button className="sm ghost" onClick={() => api.runScan().then(loadData)}> Rescan</button> <div className="dash-card-head">
<span className="dash-label">CPU</span>
<span className="dash-count">{metrics ? metrics.cpu_percent.toFixed(0) : '—'}%</span>
</div>
<MiniGraph data={cpuRef.current} max={100} color="var(--accent)" label="CPU" unit="%" />
</div> </div>
<div className="dash-tools-row"> </div>
{tools.slice(0, 12).map((tool, i) => {
const ok = tool.installed || tool.status === 'installed' {/* RAM */}
return ( <div className="dash-card dash-card-graph">
<span key={tool.name || i} className={`dash-tool-tag ${ok ? 'ok' : 'missing'}`}> <BgGraph data={memRef.current} max={100} color="#a78bfa" />
{ok ? '●' : '○'} {tool.name} <div className="dash-card-content">
</span> <div className="dash-card-head">
) <span className="dash-label">RAM</span>
})} <span className="dash-count">{metrics ? `${metrics.mem_used_mb.toFixed(0)}/${metrics.mem_total_mb.toFixed(0)}` : '—'}</span>
{tools.length > 12 && <span className="dash-tool-tag">+{tools.length - 12}</span>} </div>
<MiniGraph data={memRef.current} max={100} color="#a78bfa" label="RAM" unit="%" />
</div>
</div>
{/* Network */}
<div className="dash-card dash-card-graph">
<BgGraph data={netRxRef.current} max={null} color="#34d399" />
<div className="dash-card-content">
<div className="dash-card-head">
<span className="dash-label">Network</span>
<span className="dash-count">{metrics ? `${metrics.net_rx_kbs.toFixed(0)}${metrics.net_tx_kbs.toFixed(0)}` : '—'}</span>
</div>
<MiniGraph data={netRxRef.current} max={null} color="#34d399" label="RX" unit=" KB/s" />
<MiniGraph data={netTxRef.current} max={null} color="#f59e0b" label="TX" unit=" KB/s" />
</div> </div>
</div> </div>
{/* API Quota */} {/* API Quota */}
<div className="dash-card"> <div className="dash-card dash-card-graph">
<div className="dash-card-head"> <BgGraph data={totalQuotaMax > 0 ? [totalQuotaUsed / totalQuotaMax * 100, ...(cpuRef.current.length > 0 ? [] : [0])] : []} max={100} color="#f472b6" />
<span className="dash-label">API Quota</span> <div className="dash-card-content">
</div> <div className="dash-card-head">
<div className="dash-quota-list"> <span className="dash-label">API Quota</span>
{minimax && minimax.data?.models?.map((m, i) => ( </div>
<div key={i} className="dash-quota-row"> <div className="dash-quota-list">
<span className="dash-quota-name">{String(m.model).replace('MiniMax-', '')}</span> {minimax && minimax.data?.models?.map((m, i) => (
<div className="dash-bar"> <div key={i} className="dash-quota-row">
<div className="dash-bar-fill" style={{ width: `${Math.min(100, (m.used / m.total) * 100)}%` }} /> <span className="dash-quota-name">{String(m.model).replace('MiniMax-', '')}</span>
<div className="dash-bar">
<div className="dash-bar-fill" style={{ width: `${Math.min(100, (m.used / m.total) * 100)}%` }} />
</div>
<span className="dash-quota-val">{m.remaining}/{m.total}</span>
</div> </div>
<span className="dash-quota-val">{m.remaining}/{m.total}</span> ))}
</div> {minimax && minimax.data?.models?.length === 0 && (
))} <div className="dash-quota-row">
{minimax && minimax.data?.models?.length === 0 && ( <span className="dash-quota-name">MiniMax</span>
<div className="dash-quota-row"> <span className="dash-quota-val" style={{ color: 'var(--text-tertiary)' }}>{minimax.error || 'no data'}</span>
<span className="dash-quota-name">MiniMax</span> </div>
<span className="dash-quota-val" style={{ color: 'var(--text-tertiary)' }}>{minimax.error || 'no data'}</span> )}
</div> {zai && (
)} <div className="dash-quota-row">
{zai && ( <span className="dash-quota-name">Z.AI</span>
<div className="dash-quota-row"> <span className="dash-quota-val">{zai.healthy ? '✓ active' : zai.error || '—'}</span>
<span className="dash-quota-name">Z.AI</span> </div>
<span className="dash-quota-val">{zai.healthy ? '✓ active' : zai.error || '—'}</span> )}
</div> {!minimax && !zai && <span className="dash-quota-val" style={{ color: 'var(--text-tertiary)' }}>No providers</span>}
)} </div>
{!minimax && !zai && <span className="dash-quota-val" style={{ color: 'var(--text-tertiary)' }}>No providers</span>}
</div> </div>
</div> </div>
{/* Running Processes */} {/* Running Processes */}
<div className="dash-card"> <div className="dash-card dash-card-graph">
<div className="dash-card-head"> <BgGraph data={procCountRef.current} max={null} color="#fb923c" />
<span className="dash-label">Running Processes</span> <div className="dash-card-content">
<span className="dash-count">{processes.length}</span> <div className="dash-card-head">
</div> <span className="dash-label">Processes</span>
<div className="dash-proc-list"> <span className="dash-count">{processes.length}</span>
{processes.length === 0 && <span className="dash-empty">No relevant processes</span>} </div>
{processes.slice(0, 8).map((p, i) => ( <div className="dash-proc-list">
<div key={i} className="dash-proc-row"> {processes.length === 0 && <span className="dash-empty">No relevant processes</span>}
<span className="dash-proc-name">{p.name}</span> {processes.slice(0, 6).map((p, i) => (
<span className="dash-proc-res">cpu {p.cpu}% · mem {p.mem}%</span> <div key={i} className="dash-proc-row">
</div> <span className="dash-proc-name">{p.name}</span>
))} <span className="dash-proc-res">cpu {p.cpu}% · mem {p.mem}%</span>
</div>
))}
</div>
</div> </div>
</div> </div>
@@ -126,56 +216,6 @@ export default function Dashboard({ api }) {
))} ))}
</div> </div>
</div> </div>
{/* Status (MCP/LSP/Skills) */}
<div className="dash-card">
<div className="dash-card-head">
<span className="dash-label">Services</span>
</div>
{dashboardStatus ? (
<div className="dash-services">
<div className="dash-svc-row">
<span className="dash-svc-name">MCP</span>
<span className="dash-svc-val">{dashboardStatus.mcp?.healthy || 0}/{dashboardStatus.mcp?.total || 0} healthy</span>
</div>
<div className="dash-svc-row">
<span className="dash-svc-name">LSP</span>
<span className="dash-svc-val">{dashboardStatus.lsp?.installed || 0}/{dashboardStatus.lsp?.total || 0} installed</span>
</div>
<div className="dash-svc-row">
<span className="dash-svc-name">Skills</span>
<span className="dash-svc-val">{dashboardStatus.skills?.total || 0} deployed</span>
</div>
{(dashboardStatus.skills?.issues || []).length > 0 && (
<div className="dash-svc-issues">
{(dashboardStatus.skills.issues || []).slice(0, 3).map((issue, i) => (
<div key={i} className="dash-svc-issue"> {issue}</div>
))}
</div>
)}
</div>
) : (
<span className="dash-empty">Loading...</span>
)}
</div>
{/* Updates */}
{updates.length > 0 && (
<div className="dash-card dash-span-2">
<div className="dash-card-head">
<span className="dash-label">Updates Available</span>
<span className="dash-count warn">{updates.length}</span>
</div>
<div className="dash-updates-list">
{updates.slice(0, 5).map((u, i) => (
<div key={u.name || i} className="dash-update-row">
<span className="dash-update-name">{u.name}</span>
<span className="dash-update-ver">{u.current || '?'} {u.latest || '?'}</span>
</div>
))}
</div>
</div>
)}
</div> </div>
) )
} }

View File

@@ -100,16 +100,14 @@ export default function OnboardingWizard({ api, onComplete }) {
} else { } else {
detected.push(...(await fallback())) detected.push(...(await fallback()))
} }
const merged = [...new Set([...detected.map(n => n.toLowerCase()), ...BASE_EDITORS])] setEditorList([...new Set(detected.map(n => n.toLowerCase()))])
setEditorList(merged)
setScanMessage('') setScanMessage('')
} catch (err) { } catch (err) {
try { try {
setScanMessage('Fallback: scan local...') setScanMessage('Fallback: scan local...')
const data = await api.getEditors() const data = await api.getEditors()
const detected = (data.editors || []).map(e => e.name) const detected = (data.editors || []).map(e => e.name)
const merged = [...new Set([...detected, ...BASE_EDITORS])] setEditorList([...new Set(detected)])
setEditorList(merged)
} catch {} } catch {}
setScanMessage('') setScanMessage('')
} }
@@ -325,7 +323,7 @@ export default function OnboardingWizard({ api, onComplete }) {
<div className="onboarding-step"> <div className="onboarding-step">
<div className="onboarding-title">Quel éditeur utilisez-vous ?</div> <div className="onboarding-title">Quel éditeur utilisez-vous ?</div>
<div className="onboarding-desc"> <div className="onboarding-desc">
{scanning ? 'Détection en cours...' : 'Sélectionnez votre éditeur ou tapez-en un autre ci-dessous.'} {scanning ? 'Détection en cours...' : 'Sélectionnez votre éditeur.'}
</div> </div>
<div className="onboarding-chips"> <div className="onboarding-chips">
{editorList.map(ed => ( {editorList.map(ed => (
@@ -338,14 +336,6 @@ export default function OnboardingWizard({ api, onComplete }) {
</div> </div>
))} ))}
</div> </div>
<input
className="onboarding-input"
style={{ marginTop: 12 }}
placeholder="Autre éditeur..."
value={answers.editor}
onChange={e => setAnswers(a => ({ ...a, editor: e.target.value }))}
autoFocus
/>
</div> </div>
)} )}

View File

@@ -284,6 +284,7 @@ export default function Studio({ api }) {
const [streamThinking, setStreamThinking] = useState('') const [streamThinking, setStreamThinking] = useState('')
const [streamToolCalls, setStreamToolCalls] = useState([]) const [streamToolCalls, setStreamToolCalls] = useState([])
const [loaded, setLoaded] = useState(false) const [loaded, setLoaded] = useState(false)
const [tokenInfo, setTokenInfo] = useState({ used: 0, max: 100000, summarizeAt: 80000 })
const messagesEnd = useRef(null) const messagesEnd = useRef(null)
const textareaRef = useRef(null) const textareaRef = useRef(null)
const abortRef = useRef(null) const abortRef = useRef(null)
@@ -297,6 +298,11 @@ export default function Studio({ api }) {
{ id: 'welcome', role: 'system', content: t('studio.welcomeNew'), time: new Date().toISOString() }, { id: 'welcome', role: 'system', content: t('studio.welcomeNew'), time: new Date().toISOString() },
]) ])
} }
setTokenInfo({
used: data.tokens || 0,
max: data.max_tokens || 100000,
summarizeAt: data.summarize_at || 80000,
})
setLoaded(true) setLoaded(true)
}).catch(() => { }).catch(() => {
setMessages([ setMessages([
@@ -317,6 +323,28 @@ export default function Studio({ api }) {
} }
}, [input]) }, [input])
const refreshTokens = useCallback(async () => {
try {
const data = await api.getChatHistory()
setTokenInfo({
used: data.tokens || 0,
max: data.max_tokens || 100000,
summarizeAt: data.summarize_at || 80000,
})
} catch {}
}, [api])
const handleSummarize = useCallback(async () => {
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'system', content: 'Résumé de la conversation en cours...', time: new Date().toISOString() }])
try {
const data = await api.summarizeChat()
setTokenInfo(prev => ({ ...prev, used: data.tokens || 0 }))
setMessages(prev => [...prev, { id: (Date.now() + 1).toString(), role: 'system', content: '✓ Conversation résumée automatiquement. Le contexte a été compressé.', time: new Date().toISOString() }])
} catch (err) {
setMessages(prev => [...prev, { id: (Date.now() + 1).toString(), role: 'system', content: `Erreur de résumé: ${err.message}`, time: new Date().toISOString() }])
}
}, [api])
const handleClear = useCallback(async () => { const handleClear = useCallback(async () => {
try { try {
await api.clearChat() await api.clearChat()
@@ -341,6 +369,7 @@ export default function Studio({ api }) {
'## Commandes Studio', '## Commandes Studio',
'', '',
'- `/clear` - Effacer la conversation', '- `/clear` - Effacer la conversation',
'- `/summarize` - Résumer la conversation précédente',
'- `/help` - Afficher cette aide', '- `/help` - Afficher cette aide',
'- `/plan <objectif>` - Demander un plan structuré', '- `/plan <objectif>` - Demander un plan structuré',
'- `/export` - Exporter la conversation en Markdown', '- `/export` - Exporter la conversation en Markdown',
@@ -359,6 +388,11 @@ export default function Studio({ api }) {
return return
} }
if (text === '/summarize') {
handleSummarize()
return
}
if (text === '/model') { if (text === '/model') {
api.getProviders().then(data => { api.getProviders().then(data => {
const active = data.providers?.find(p => p.active) const active = data.providers?.find(p => p.active)
@@ -474,8 +508,9 @@ export default function Studio({ api }) {
setStreamThinking('') setStreamThinking('')
setStreamToolCalls([]) setStreamToolCalls([])
abortRef.current = null abortRef.current = null
refreshTokens()
} }
}, [input, loading, api, t, handleClear, streaming]) }, [input, loading, api, t, handleClear, streaming, refreshTokens, handleSummarize])
const handleStop = useCallback(() => { const handleStop = useCallback(() => {
if (abortRef.current) { if (abortRef.current) {
@@ -515,6 +550,18 @@ export default function Studio({ api }) {
</div> </div>
<div className="studio-input-area"> <div className="studio-input-area">
<div className="studio-token-bar">
<div className="studio-token-track">
<div
className={`studio-token-fill ${tokenInfo.used >= tokenInfo.summarizeAt ? 'warn' : ''}`}
style={{ width: `${Math.min(100, (tokenInfo.used / tokenInfo.max) * 100)}%` }}
/>
</div>
<span className="studio-token-text">
{(tokenInfo.used / 1000).toFixed(1)}k / {(tokenInfo.max / 1000).toFixed(0)}k tokens
{tokenInfo.used >= tokenInfo.summarizeAt && ' · résumé automatique déclenché'}
</span>
</div>
<div className="studio-input-row"> <div className="studio-input-row">
<textarea <textarea
ref={textareaRef} ref={textareaRef}
@@ -543,7 +590,7 @@ export default function Studio({ api }) {
)} )}
</div> </div>
<div className="studio-input-hint"> <div className="studio-input-hint">
{t('studio.inputHint')} &middot; /clear /help /plan /export /model {t('studio.inputHint')} · /clear /summarize /help /plan /export /model
</div> </div>
</div> </div>
</div> </div>

View File

@@ -169,6 +169,12 @@ input::placeholder { color: var(--text-disabled); }
color: var(--text-disabled); color: var(--text-disabled);
} }
.statusbar-left, .statusbar-right { display: flex; align-items: center; gap: 12px; } .statusbar-left, .statusbar-right { display: flex; align-items: center; gap: 12px; }
.statusbar-sudo {
font-size: 10px; font-weight: 700; font-family: var(--font-mono);
padding: 1px 6px; border-radius: 3px;
background: rgba(239, 68, 68, 0.15); color: #ef4444; border: 1px solid rgba(239, 68, 68, 0.3);
text-transform: uppercase; letter-spacing: 0.5px;
}
.statusbar-shortcut { display: inline-flex; align-items: center; gap: 4px; } .statusbar-shortcut { display: inline-flex; align-items: center; gap: 4px; }
.statusbar-shortcut kbd { .statusbar-shortcut kbd {
display: inline-block; padding: 1px 5px; border-radius: 3px; display: inline-block; padding: 1px 5px; border-radius: 3px;
@@ -535,11 +541,22 @@ input::placeholder { color: var(--text-disabled); }
overflow: hidden; overflow: hidden;
} }
.dash-card { .dash-card {
position: relative;
background: var(--bg-card); border: 1px solid var(--border); background: var(--bg-card); border: 1px solid var(--border);
border-radius: var(--radius-lg); padding: 14px 16px; border-radius: var(--radius-lg); padding: 14px 16px;
display: flex; flex-direction: column; gap: 8px; display: flex; flex-direction: column; gap: 8px;
overflow: hidden; overflow: hidden;
} }
.dash-card-graph { padding: 0; }
.dash-bg-graph {
position: absolute; inset: 0; width: 100%; height: 100%;
opacity: 0.35; pointer-events: none;
}
.dash-card-content {
position: relative; z-index: 1;
padding: 14px 16px;
display: flex; flex-direction: column; gap: 8px;
}
.dash-span-2 { grid-column: span 2; } .dash-span-2 { grid-column: span 2; }
.dash-card-head { .dash-card-head {
display: flex; align-items: center; justify-content: space-between; display: flex; align-items: center; justify-content: space-between;
@@ -637,6 +654,14 @@ input::placeholder { color: var(--text-disabled); }
.dash-empty { font-size: 11px; color: var(--text-disabled); } .dash-empty { font-size: 11px; color: var(--text-disabled); }
/* Graph */
.dash-graph-wrap { display: flex; flex-direction: column; gap: 2px; }
.dash-graph-header { display: flex; justify-content: space-between; align-items: center; }
.dash-graph-label { font-size: 9px; color: var(--text-disabled); text-transform: uppercase; }
.dash-graph-value { font-size: 10px; font-family: var(--font-mono); font-weight: 600; }
.dash-graph-svg { width: 100%; height: 32px; }
.dash-graph-empty { font-size: 10px; color: var(--text-disabled); text-align: center; padding: 8px 0; }
/* Legacy dashboard kept for reference */ /* Legacy dashboard kept for reference */
.dashboard-layout { display: flex; flex-direction: column; height: 100%; } .dashboard-layout { display: flex; flex-direction: column; height: 100%; }
.dashboard-content { flex: 1; overflow-y: auto; } .dashboard-content { flex: 1; overflow-y: auto; }
@@ -771,6 +796,11 @@ input::placeholder { color: var(--text-disabled); }
.studio-thinking span:nth-child(3) { animation-delay: 0.3s; } .studio-thinking span:nth-child(3) { animation-delay: 0.3s; }
@keyframes bounce { 0%, 80%, 100% { transform: translateY(0); opacity: 0.4; } 40% { transform: translateY(-6px); opacity: 1; } } @keyframes bounce { 0%, 80%, 100% { transform: translateY(0); opacity: 0.4; } 40% { transform: translateY(-6px); opacity: 1; } }
.studio-input-area { padding: 12px 20px 8px; border-top: 1px solid var(--border); background: var(--bg-surface); } .studio-input-area { padding: 12px 20px 8px; border-top: 1px solid var(--border); background: var(--bg-surface); }
.studio-token-bar { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
.studio-token-track { flex: 1; height: 3px; background: var(--bg-input); border-radius: 2px; overflow: hidden; }
.studio-token-fill { height: 100%; background: var(--accent); border-radius: 2px; transition: width 0.4s, background 0.3s; }
.studio-token-fill.warn { background: var(--warning); }
.studio-token-text { font-size: 10px; font-family: var(--font-mono); color: var(--text-tertiary); white-space: nowrap; }
.studio-input-row { display: flex; gap: 8px; align-items: flex-end; } .studio-input-row { display: flex; gap: 8px; align-items: flex-end; }
.studio-input-row textarea { .studio-input-row textarea {
flex: 1; resize: none; min-height: 42px; max-height: 200px; padding: 10px 14px; flex: 1; resize: none; min-height: 42px; max-height: 200px; padding: 10px 14px;