Compare commits
20 Commits
v0.3.3
...
v0.3.3-bet
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b55feaed09 | ||
|
|
54621bd960 | ||
|
|
6bad2948c5 | ||
|
|
92eb783df0 | ||
|
|
8005e978f0 | ||
|
|
6e76e7dca6 | ||
|
|
e8f6dc4b4d | ||
|
|
bb03c9fe2d | ||
|
|
79d082180c | ||
|
|
7682717093 | ||
|
|
3948a4c656 | ||
|
|
65804aae4e | ||
|
|
2e50366cd8 | ||
|
|
66b773ff86 | ||
|
|
bc5c2956b4 | ||
|
|
e19122dad9 | ||
|
|
8b6a7e8bc3 | ||
|
|
58f8cb0bd3 | ||
|
|
b52feccc17 | ||
|
|
5bbac499a7 |
172
CHANGELOG.md
172
CHANGELOG.md
@@ -4,178 +4,6 @@ All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
## v0.3.3
|
||||
|
||||
### Changes since v0.3.2
|
||||
|
||||
- feat(dashboard): single-view grid with live CPU/RAM/Net graphs, API quota, processes, and sudo indicator (328e9e6)
|
||||
- feat(dashboard): add quota monitoring, process list, and command history (c81ebb4)
|
||||
- refactor(chat): deduplicate streaming code, add multi-conv, and XSS protection (b0865bc)
|
||||
- fix(studio): improve chat context, thinking tags, streaming, and tool results (0d8e1b1)
|
||||
- feat: add Cobra CLI, LSP/MCP registries, workflow engine, and enriched dashboard (485e085)
|
||||
- feat(agent): refactor AI chat with streaming, agent registry, and tool execution (61da803)
|
||||
- feat(onboarding): add minimax api key step and AI-powered editor scan (65df154)
|
||||
- fix(onboarding): require fields before advancing steps (b6147dd)
|
||||
- fix: register missing /api/config/reset and /api/starship/apply-theme routes (275a9a4)
|
||||
- fix(config): per-provider form state to avoid field cross-talk (e92a2f0)
|
||||
- fix(onboarding): auto-save on done step, keyboard nav, error feedback (1f12b8a)
|
||||
- feat(config): add system panel with reset and starship theme, add onboarding wizard (9188231)
|
||||
|
||||
### Downloads
|
||||
|
||||
| Platform | File |
|
||||
|----------|------|
|
||||
| Linux x86_64 | [muyue-linux-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.3/muyue-linux-amd64.tar.gz) |
|
||||
| Linux ARM64 | [muyue-linux-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.3/muyue-linux-arm64.tar.gz) |
|
||||
| macOS Intel | [muyue-darwin-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.3/muyue-darwin-amd64.tar.gz) |
|
||||
| macOS Apple Silicon | [muyue-darwin-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.3/muyue-darwin-arm64.tar.gz) |
|
||||
| Windows x86_64 | [muyue-windows-amd64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.3/muyue-windows-amd64.zip) |
|
||||
| Windows ARM64 | [muyue-windows-arm64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.3/muyue-windows-arm64.zip) |
|
||||
|
||||
The binary includes both CLI and Desktop modes.
|
||||
Run `muyue` for TUI, `muyue desktop` for web UI.
|
||||
|
||||
### Install
|
||||
|
||||
**Linux (x86_64)**
|
||||
```bash
|
||||
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.3/muyue-linux-amd64.tar.gz | tar xz
|
||||
chmod +x muyue-linux-amd64
|
||||
sudo mv muyue-linux-amd64 /usr/local/bin/muyue
|
||||
```
|
||||
|
||||
**macOS (Apple Silicon)**
|
||||
```bash
|
||||
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.3/muyue-darwin-arm64.tar.gz | tar xz
|
||||
chmod +x muyue-darwin-arm64
|
||||
sudo mv muyue-darwin-arm64 /usr/local/bin/muyue
|
||||
```
|
||||
|
||||
**Windows (x86_64)**
|
||||
```powershell
|
||||
Invoke-WebRequest -Uri "https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.3/muyue-windows-amd64.zip" -OutFile "muyue.zip"
|
||||
Expand-Archive -Path "muyue.zip" -DestinationPath "."
|
||||
Move-Item muyue-windows-amd64.exe C:\Windows\muyue.exe
|
||||
```
|
||||
|
||||
|
||||
## v0.3.2
|
||||
|
||||
### Changes since v0.3.1
|
||||
|
||||
- chore: update CHANGELOG for v0.3.2-beta.1 (51a599f)
|
||||
- fix: correct version from 3.2 to 0.3.2 (83d7a57)
|
||||
- chore: bump version to 3.2 (0fe82f6)
|
||||
- refactor(config): remove Terminal sub-tab from Configuration page (3b6cc38)
|
||||
- fix(terminal): init payload never sent due to ws.onopen being overwritten (93a22d4)
|
||||
- fix(terminal): improve shell resolution with better error handling and ws proxy support (e0e1e73)
|
||||
- feat(studio): parse AI thinking and tool launch messages in terminal panel (0496ca7)
|
||||
- fix(studio): forward AI thinking chunks to frontend instead of dropping them (b407ab8)
|
||||
- feat(studio): add tool execution and hide AI thinking tags (12df184)
|
||||
- fix(terminal): ignore invalid shell config from race condition (8af6d25)
|
||||
- feat(shell): restore AI assistant panel (4fd599a)
|
||||
- fix(terminal): restore terminal input and cursor visibility (bcba593)
|
||||
- refactor(api): split monolithic handlers.go into focused modules (04b0fff)
|
||||
|
||||
### Downloads
|
||||
|
||||
| Platform | File |
|
||||
|----------|------|
|
||||
| Linux x86_64 | [muyue-linux-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.2/muyue-linux-amd64.tar.gz) |
|
||||
| Linux ARM64 | [muyue-linux-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.2/muyue-linux-arm64.tar.gz) |
|
||||
| macOS Intel | [muyue-darwin-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.2/muyue-darwin-amd64.tar.gz) |
|
||||
| macOS Apple Silicon | [muyue-darwin-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.2/muyue-darwin-arm64.tar.gz) |
|
||||
| Windows x86_64 | [muyue-windows-amd64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.2/muyue-windows-amd64.zip) |
|
||||
| Windows ARM64 | [muyue-windows-arm64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.2/muyue-windows-arm64.zip) |
|
||||
|
||||
The binary includes both CLI and Desktop modes.
|
||||
Run `muyue` for TUI, `muyue desktop` for web UI.
|
||||
|
||||
### Install
|
||||
|
||||
**Linux (x86_64)**
|
||||
```bash
|
||||
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.2/muyue-linux-amd64.tar.gz | tar xz
|
||||
chmod +x muyue-linux-amd64
|
||||
sudo mv muyue-linux-amd64 /usr/local/bin/muyue
|
||||
```
|
||||
|
||||
**macOS (Apple Silicon)**
|
||||
```bash
|
||||
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.2/muyue-darwin-arm64.tar.gz | tar xz
|
||||
chmod +x muyue-darwin-arm64
|
||||
sudo mv muyue-darwin-arm64 /usr/local/bin/muyue
|
||||
```
|
||||
|
||||
**Windows (x86_64)**
|
||||
```powershell
|
||||
Invoke-WebRequest -Uri "https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.2/muyue-windows-amd64.zip" -OutFile "muyue.zip"
|
||||
Expand-Archive -Path "muyue.zip" -DestinationPath "."
|
||||
Move-Item muyue-windows-amd64.exe C:\Windows\muyue.exe
|
||||
```
|
||||
|
||||
|
||||
## v0.3.2-beta.1 (Beta)
|
||||
|
||||
### Commits since v0.3.1
|
||||
|
||||
- fix: correct version from 3.2 to 0.3.2 (83d7a57)
|
||||
|
||||
> This is a **beta** release. Use at your own risk.
|
||||
|
||||
## v0.3.1
|
||||
|
||||
### Changes since v0.3.0
|
||||
|
||||
- refactor(config): remove Terminal sub-tab from Configuration page (95bd824)
|
||||
- fix(terminal): init payload never sent due to ws.onopen being overwritten (252f178)
|
||||
- fix(terminal): improve shell resolution with better error handling and ws proxy support (7dcf505)
|
||||
- feat(studio): parse AI thinking and tool launch messages in terminal panel (8fb93fa)
|
||||
- fix(studio): forward AI thinking chunks to frontend instead of dropping them (5ec373c)
|
||||
- feat(studio): add tool execution and hide AI thinking tags (1eb5a6d)
|
||||
- fix(terminal): ignore invalid shell config from race condition (cd5ebe0)
|
||||
- feat(shell): restore AI assistant panel (2004c15)
|
||||
- fix(terminal): restore terminal input and cursor visibility (9306152)
|
||||
- refactor(api): split monolithic handlers.go into focused modules (e15a034)
|
||||
|
||||
### Downloads
|
||||
|
||||
| Platform | File |
|
||||
|----------|------|
|
||||
| Linux x86_64 | [muyue-linux-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.1/muyue-linux-amd64.tar.gz) |
|
||||
| Linux ARM64 | [muyue-linux-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.1/muyue-linux-arm64.tar.gz) |
|
||||
| macOS Intel | [muyue-darwin-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.1/muyue-darwin-amd64.tar.gz) |
|
||||
| macOS Apple Silicon | [muyue-darwin-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.1/muyue-darwin-arm64.tar.gz) |
|
||||
| Windows x86_64 | [muyue-windows-amd64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.1/muyue-windows-amd64.zip) |
|
||||
| Windows ARM64 | [muyue-windows-arm64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.1/muyue-windows-arm64.zip) |
|
||||
|
||||
The binary includes both CLI and Desktop modes.
|
||||
Run `muyue` for TUI, `muyue desktop` for web UI.
|
||||
|
||||
### Install
|
||||
|
||||
**Linux (x86_64)**
|
||||
```bash
|
||||
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.1/muyue-linux-amd64.tar.gz | tar xz
|
||||
chmod +x muyue-linux-amd64
|
||||
sudo mv muyue-linux-amd64 /usr/local/bin/muyue
|
||||
```
|
||||
|
||||
**macOS (Apple Silicon)**
|
||||
```bash
|
||||
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.1/muyue-darwin-arm64.tar.gz | tar xz
|
||||
chmod +x muyue-darwin-arm64
|
||||
sudo mv muyue-darwin-arm64 /usr/local/bin/muyue
|
||||
```
|
||||
|
||||
**Windows (x86_64)**
|
||||
```powershell
|
||||
Invoke-WebRequest -Uri "https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.1/muyue-windows-amd64.zip" -OutFile "muyue.zip"
|
||||
Expand-Archive -Path "muyue.zip" -DestinationPath "."
|
||||
Move-Item muyue-windows-amd64.exe C:\Windows\muyue.exe
|
||||
```
|
||||
|
||||
|
||||
## v0.3.0
|
||||
|
||||
### Changes since v0.2.1
|
||||
|
||||
@@ -206,8 +206,11 @@ func (s *Server) handleChatHistory(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
messages := s.convStore.Get()
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"messages": messages,
|
||||
"tokens": s.convStore.ApproxTokenCount(),
|
||||
"messages": messages,
|
||||
"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()
|
||||
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(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -53,32 +53,27 @@ func (s *Server) handleSaveProfile(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, "no config", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
Pseudo string `json:"pseudo"`
|
||||
Email string `json:"email"`
|
||||
Editor string `json:"editor"`
|
||||
Shell string `json:"shell"`
|
||||
|
||||
currentJSON, err := json.Marshal(s.config.Profile)
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
var currentMap map[string]interface{}
|
||||
json.Unmarshal(currentJSON, ¤tMap)
|
||||
|
||||
var updates map[string]interface{}
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
if err := json.Unmarshal(body, &updates); err != nil {
|
||||
writeError(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if body.Name != "" {
|
||||
s.config.Profile.Name = body.Name
|
||||
}
|
||||
if body.Pseudo != "" {
|
||||
s.config.Profile.Pseudo = body.Pseudo
|
||||
}
|
||||
if body.Email != "" {
|
||||
s.config.Profile.Email = body.Email
|
||||
}
|
||||
if body.Editor != "" {
|
||||
s.config.Profile.Preferences.Editor = body.Editor
|
||||
}
|
||||
if body.Shell != "" {
|
||||
s.config.Profile.Preferences.Shell = body.Shell
|
||||
}
|
||||
|
||||
deepMerge(currentMap, updates)
|
||||
|
||||
mergedJSON, _ := json.Marshal(currentMap)
|
||||
json.Unmarshal(mergedJSON, &s.config.Profile)
|
||||
|
||||
if err := config.Save(s.config); err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
@@ -86,6 +81,20 @@ func (s *Server) handleSaveProfile(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func deepMerge(dst, src map[string]interface{}) {
|
||||
for k, sv := range src {
|
||||
if dv, ok := dst[k]; ok {
|
||||
dstMap, dOk := dv.(map[string]interface{})
|
||||
srcMap, sOk := sv.(map[string]interface{})
|
||||
if dOk && sOk {
|
||||
deepMerge(dstMap, srcMap)
|
||||
continue
|
||||
}
|
||||
}
|
||||
dst[k] = sv
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleSaveProvider(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "PUT" {
|
||||
writeError(w, "PUT only", http.StatusMethodNotAllowed)
|
||||
|
||||
@@ -477,25 +477,16 @@ func (s *Server) handleProvidersQuota(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
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()
|
||||
// Z.AI (GLM) est utilisé uniquement via Crush, pas de quota check externe
|
||||
q.Healthy = true
|
||||
q.Data = map[string]interface{}{"note": "crush-only"}
|
||||
case "claude", "anthropic":
|
||||
// Claude Code n'a pas d'API externe, vérifier l'installation
|
||||
claudePath := "/usr/bin/claude"
|
||||
if _, err := os.Stat(claudePath); err == nil {
|
||||
q.Healthy = true
|
||||
} 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
|
||||
}
|
||||
q.Error = "claude code not installed"
|
||||
}
|
||||
default:
|
||||
q.Error = "quota not supported"
|
||||
|
||||
@@ -85,6 +85,7 @@ func (s *Server) routes() {
|
||||
s.mux.HandleFunc("/api/chat", s.handleChat)
|
||||
s.mux.HandleFunc("/api/chat/history", s.handleChatHistory)
|
||||
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/tools/list", s.handleToolList)
|
||||
s.mux.HandleFunc("/api/shell/chat", s.handleShellChat)
|
||||
|
||||
@@ -12,66 +12,66 @@ import (
|
||||
)
|
||||
|
||||
type Profile struct {
|
||||
Name string `yaml:"name"`
|
||||
Pseudo string `yaml:"pseudo"`
|
||||
Email string `yaml:"email"`
|
||||
Languages []string `yaml:"languages"`
|
||||
Name string `yaml:"name" json:"name"`
|
||||
Pseudo string `yaml:"pseudo" json:"pseudo"`
|
||||
Email string `yaml:"email" json:"email"`
|
||||
Languages []string `yaml:"languages" json:"languages"`
|
||||
Preferences struct {
|
||||
Editor string `yaml:"editor"`
|
||||
Shell string `yaml:"shell"`
|
||||
Theme string `yaml:"theme"`
|
||||
DefaultAI string `yaml:"default_ai"`
|
||||
AutoUpdate bool `yaml:"auto_update"`
|
||||
CheckOnStart bool `yaml:"check_on_start"`
|
||||
Language string `yaml:"language"`
|
||||
KeyboardLayout string `yaml:"keyboard_layout"`
|
||||
} `yaml:"preferences"`
|
||||
Editor string `yaml:"editor" json:"editor"`
|
||||
Shell string `yaml:"shell" json:"shell"`
|
||||
Theme string `yaml:"theme" json:"theme"`
|
||||
DefaultAI string `yaml:"default_ai" json:"default_ai"`
|
||||
AutoUpdate bool `yaml:"auto_update" json:"auto_update"`
|
||||
CheckOnStart bool `yaml:"check_on_start" json:"check_on_start"`
|
||||
Language string `yaml:"language" json:"language"`
|
||||
KeyboardLayout string `yaml:"keyboard_layout" json:"keyboard_layout"`
|
||||
} `yaml:"preferences" json:"preferences"`
|
||||
}
|
||||
|
||||
type AIProvider struct {
|
||||
Name string `yaml:"name"`
|
||||
APIKey string `yaml:"api_key,omitempty"`
|
||||
BaseURL string `yaml:"base_url,omitempty"`
|
||||
Model string `yaml:"model"`
|
||||
Active bool `yaml:"active"`
|
||||
Name string `yaml:"name" json:"name"`
|
||||
APIKey string `yaml:"api_key,omitempty" json:"api_key,omitempty"`
|
||||
BaseURL string `yaml:"base_url,omitempty" json:"base_url,omitempty"`
|
||||
Model string `yaml:"model" json:"model"`
|
||||
Active bool `yaml:"active" json:"active"`
|
||||
}
|
||||
|
||||
type ToolConfig struct {
|
||||
Name string `yaml:"name"`
|
||||
Installed bool `yaml:"installed"`
|
||||
Version string `yaml:"version"`
|
||||
AutoUpdate bool `yaml:"auto_update"`
|
||||
Name string `yaml:"name" json:"name"`
|
||||
Installed bool `yaml:"installed" json:"installed"`
|
||||
Version string `yaml:"version" json:"version"`
|
||||
AutoUpdate bool `yaml:"auto_update" json:"auto_update"`
|
||||
}
|
||||
|
||||
type SSHConnection struct {
|
||||
Name string `yaml:"name"`
|
||||
Host string `yaml:"host"`
|
||||
Port int `yaml:"port"`
|
||||
User string `yaml:"user"`
|
||||
Password string `yaml:"password,omitempty"`
|
||||
KeyPath string `yaml:"key_path,omitempty"`
|
||||
Name string `yaml:"name" json:"name"`
|
||||
Host string `yaml:"host" json:"host"`
|
||||
Port int `yaml:"port" json:"port"`
|
||||
User string `yaml:"user" json:"user"`
|
||||
Password string `yaml:"password,omitempty" json:"password,omitempty"`
|
||||
KeyPath string `yaml:"key_path,omitempty" json:"key_path,omitempty"`
|
||||
}
|
||||
|
||||
type MuyueConfig struct {
|
||||
Version string `yaml:"version"`
|
||||
Profile Profile `yaml:"profile"`
|
||||
Version string `yaml:"version" json:"version"`
|
||||
Profile Profile `yaml:"profile" json:"profile"`
|
||||
AI struct {
|
||||
Providers []AIProvider `yaml:"providers"`
|
||||
} `yaml:"ai"`
|
||||
Tools []ToolConfig `yaml:"tools"`
|
||||
Providers []AIProvider `yaml:"providers" json:"providers"`
|
||||
} `yaml:"ai" json:"ai"`
|
||||
Tools []ToolConfig `yaml:"tools" json:"tools"`
|
||||
BMAD struct {
|
||||
Installed bool `yaml:"installed"`
|
||||
Version string `yaml:"version"`
|
||||
Global bool `yaml:"global"`
|
||||
} `yaml:"bmad"`
|
||||
Installed bool `yaml:"installed" json:"installed"`
|
||||
Version string `yaml:"version" json:"version"`
|
||||
Global bool `yaml:"global" json:"global"`
|
||||
} `yaml:"bmad" json:"bmad"`
|
||||
Terminal struct {
|
||||
CustomPrompt bool `yaml:"custom_prompt"`
|
||||
PromptTheme string `yaml:"prompt_theme"`
|
||||
SSH []SSHConnection `yaml:"ssh"`
|
||||
FontSize int `yaml:"font_size"`
|
||||
FontFamily string `yaml:"font_family"`
|
||||
Theme string `yaml:"theme"`
|
||||
} `yaml:"terminal"`
|
||||
CustomPrompt bool `yaml:"custom_prompt" json:"custom_prompt"`
|
||||
PromptTheme string `yaml:"prompt_theme" json:"prompt_theme"`
|
||||
SSH []SSHConnection `yaml:"ssh" json:"ssh"`
|
||||
FontSize int `yaml:"font_size" json:"font_size"`
|
||||
FontFamily string `yaml:"font_family" json:"font_family"`
|
||||
Theme string `yaml:"theme" json:"theme"`
|
||||
} `yaml:"terminal" json:"terminal"`
|
||||
}
|
||||
|
||||
type TerminalTheme struct {
|
||||
|
||||
@@ -24,12 +24,12 @@ const (
|
||||
)
|
||||
|
||||
type SystemInfo struct {
|
||||
OS OS
|
||||
Arch Arch
|
||||
IsWSL bool
|
||||
Shell string
|
||||
Terminal string
|
||||
PackageManager string
|
||||
OS OS `json:"os"`
|
||||
Arch Arch `json:"arch"`
|
||||
IsWSL bool `json:"is_wsl"`
|
||||
Shell string `json:"shell"`
|
||||
Terminal string `json:"terminal"`
|
||||
PackageManager string `json:"package_manager"`
|
||||
}
|
||||
|
||||
func Detect() SystemInfo {
|
||||
|
||||
@@ -14,27 +14,27 @@ import (
|
||||
)
|
||||
|
||||
type ToolStatus struct {
|
||||
Name string `yaml:"name"`
|
||||
Installed bool `yaml:"installed"`
|
||||
Version string `yaml:"version"`
|
||||
Path string `yaml:"path"`
|
||||
Latest string `yaml:"latest"`
|
||||
NeedsUpdate bool `yaml:"needs_update"`
|
||||
Category string `yaml:"category"`
|
||||
Name string `yaml:"name" json:"name"`
|
||||
Installed bool `yaml:"installed" json:"installed"`
|
||||
Version string `yaml:"version" json:"version"`
|
||||
Path string `yaml:"path" json:"path"`
|
||||
Latest string `yaml:"latest" json:"latest"`
|
||||
NeedsUpdate bool `yaml:"needs_update" json:"needs_update"`
|
||||
Category string `yaml:"category" json:"category"`
|
||||
}
|
||||
|
||||
type RuntimeStatus struct {
|
||||
Name string `yaml:"name"`
|
||||
Installed bool `yaml:"installed"`
|
||||
Version string `yaml:"version"`
|
||||
Name string `yaml:"name" json:"name"`
|
||||
Installed bool `yaml:"installed" json:"installed"`
|
||||
Version string `yaml:"version" json:"version"`
|
||||
}
|
||||
|
||||
type ScanResult struct {
|
||||
System platform.SystemInfo `yaml:"system"`
|
||||
Tools []ToolStatus `yaml:"tools"`
|
||||
Runtimes []RuntimeStatus `yaml:"runtimes"`
|
||||
ShellSetup bool `yaml:"shell_setup"`
|
||||
GitConfigured bool `yaml:"git_configured"`
|
||||
System platform.SystemInfo `yaml:"system" json:"system"`
|
||||
Tools []ToolStatus `yaml:"tools" json:"tools"`
|
||||
Runtimes []RuntimeStatus `yaml:"runtimes" json:"runtimes"`
|
||||
ShellSetup bool `yaml:"shell_setup" json:"shell_setup"`
|
||||
GitConfigured bool `yaml:"git_configured" json:"git_configured"`
|
||||
}
|
||||
|
||||
var (
|
||||
|
||||
@@ -56,6 +56,7 @@ const api = {
|
||||
saveTerminalSettings: (settings) => request('/terminal/settings', { method: 'PUT', body: JSON.stringify(settings) }),
|
||||
getChatHistory: () => request('/chat/history'),
|
||||
clearChat: () => request('/chat/clear', { method: 'POST' }),
|
||||
summarizeChat: () => request('/chat/summarize', { method: 'POST' }),
|
||||
sendChat: (message, stream = true, onChunk, signal) => {
|
||||
if (!stream) {
|
||||
return request('/chat', { method: 'POST', body: JSON.stringify({ message, stream: false }) })
|
||||
|
||||
@@ -34,14 +34,7 @@ export default function Config({ api }) {
|
||||
const loadData = useCallback(() => {
|
||||
api.getConfig().then(d => {
|
||||
setConfig(d)
|
||||
setProfileForm({
|
||||
name: d.profile?.name || '',
|
||||
pseudo: d.profile?.pseudo || '',
|
||||
email: d.profile?.email || '',
|
||||
editor: d.profile?.preferences?.editor || '',
|
||||
shell: d.profile?.preferences?.shell || '',
|
||||
})
|
||||
|
||||
setProfileForm(d.profile ? JSON.parse(JSON.stringify(d.profile)) : {})
|
||||
}).catch(() => {})
|
||||
api.getProviders().then(d => setProviders(d.providers || [])).catch(() => {})
|
||||
api.getSkills().then(d => setSkillList(d.skills || [])).catch(() => {})
|
||||
@@ -190,8 +183,8 @@ export default function Config({ api }) {
|
||||
)}
|
||||
{activePanel === 'locale' && (
|
||||
<PanelLocale
|
||||
language={keyboard} layouts={layouts}
|
||||
setLanguage={setLanguage} setKeyboard={setKeyboard}
|
||||
language={language} keyboard={keyboard} layouts={layouts}
|
||||
api={api}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
@@ -209,57 +202,135 @@ export default function Config({ api }) {
|
||||
}
|
||||
|
||||
function PanelProfile({ config, editProfile, profileForm, setProfileForm, setEditProfile, handleSaveProfile, t }) {
|
||||
const updateField = (path, value) => {
|
||||
setProfileForm(prev => {
|
||||
const next = JSON.parse(JSON.stringify(prev))
|
||||
const keys = path.split('.')
|
||||
let target = next
|
||||
for (let i = 0; i < keys.length - 1; i++) {
|
||||
if (target[keys[i]] == null) target[keys[i]] = {}
|
||||
target = target[keys[i]]
|
||||
}
|
||||
target[keys[keys.length - 1]] = value
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const profile = editProfile ? profileForm : config?.profile
|
||||
|
||||
if (!profile) {
|
||||
return (
|
||||
<div className="config-profile-center">
|
||||
<div className="config-card">
|
||||
<div className="empty-state">{t('config.loadingProfile')}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const personalKeys = Object.entries(profile).filter(([k, v]) => k !== 'preferences' && typeof v !== 'object')
|
||||
const personalObj = Object.fromEntries(personalKeys)
|
||||
const preferences = profile.preferences || null
|
||||
|
||||
return (
|
||||
<div className="config-card">
|
||||
{config?.profile && !editProfile ? (
|
||||
<>
|
||||
<div className="config-card-row">
|
||||
<span className="config-card-label">{t('config.name')}</span>
|
||||
<span className="config-card-value">{config.profile.name || '—'}</span>
|
||||
</div>
|
||||
<div className="config-card-row">
|
||||
<span className="config-card-label">{t('config.pseudo')}</span>
|
||||
<span className="config-card-value">{config.profile.pseudo || '—'}</span>
|
||||
</div>
|
||||
<div className="config-card-row">
|
||||
<span className="config-card-label">{t('config.email')}</span>
|
||||
<span className="config-card-value">{config.profile.email || '—'}</span>
|
||||
</div>
|
||||
<div className="config-card-row">
|
||||
<span className="config-card-label">{t('config.editor')}</span>
|
||||
<span className="config-card-value mono">{config.profile.preferences?.editor || '—'}</span>
|
||||
</div>
|
||||
<div className="config-card-row">
|
||||
<span className="config-card-label">{t('config.shell')}</span>
|
||||
<span className="config-card-value mono">{config.profile.preferences?.shell || '—'}</span>
|
||||
</div>
|
||||
<div className="config-card-row">
|
||||
<span className="config-card-label">{t('config.languages')}</span>
|
||||
<span className="config-card-value">{config.profile.languages?.join(', ') || '—'}</span>
|
||||
</div>
|
||||
<div className="config-card-actions">
|
||||
<button className="primary sm" onClick={() => setEditProfile(true)}>{t('config.editProfile')}</button>
|
||||
</div>
|
||||
</>
|
||||
) : editProfile ? (
|
||||
<>
|
||||
<FormInput label={t('config.name')} value={profileForm.name} onChange={v => setProfileForm(p => ({ ...p, name: v }))} />
|
||||
<FormInput label={t('config.pseudo')} value={profileForm.pseudo} onChange={v => setProfileForm(p => ({ ...p, pseudo: v }))} />
|
||||
<FormInput label={t('config.email')} value={profileForm.email} onChange={v => setProfileForm(p => ({ ...p, email: v }))} type="email" />
|
||||
<FormInput label={t('config.editor')} value={profileForm.editor} onChange={v => setProfileForm(p => ({ ...p, editor: v }))} />
|
||||
<FormInput label={t('config.shell')} value={profileForm.shell} onChange={v => setProfileForm(p => ({ ...p, shell: v }))} />
|
||||
<div className="config-card-actions">
|
||||
<button className="primary sm" onClick={handleSaveProfile}>{t('config.save')}</button>
|
||||
<button className="ghost sm" onClick={() => setEditProfile(false)}>{t('config.cancel')}</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="empty-state">{t('config.loadingProfile')}</div>
|
||||
)}
|
||||
<div className="config-profile-center">
|
||||
<div className="config-card">
|
||||
<div className="section-title">{t('config.profileInfo') || 'Informations personnelles'}</div>
|
||||
<RenderFields obj={personalObj} path="" editing={editProfile} onChange={updateField} t={t} />
|
||||
</div>
|
||||
<div className="config-card">
|
||||
<div className="section-title">{t('config.profilePrefs') || 'Préférences'}</div>
|
||||
{preferences ? (
|
||||
<RenderFields obj={preferences} path="preferences" editing={editProfile} onChange={updateField} t={t} />
|
||||
) : (
|
||||
<div className="config-card-row"><span className="config-card-value" style={{ color: 'var(--text-disabled)' }}>—</span></div>
|
||||
)}
|
||||
</div>
|
||||
<div className="config-card">
|
||||
<div className="config-card-actions" style={{ justifyContent: 'center' }}>
|
||||
{editProfile ? (
|
||||
<>
|
||||
<button className="primary sm" onClick={handleSaveProfile}>{t('config.save')}</button>
|
||||
<button className="ghost sm" onClick={() => setEditProfile(false)}>{t('config.cancel')}</button>
|
||||
</>
|
||||
) : (
|
||||
<button className="primary sm" onClick={() => {
|
||||
setProfileForm(config.profile ? JSON.parse(JSON.stringify(config.profile)) : {})
|
||||
setEditProfile(true)
|
||||
}}>{t('config.editProfile')}</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RenderFields({ obj, path, editing, onChange, t }) {
|
||||
if (!obj || typeof obj !== 'object') return null
|
||||
|
||||
return Object.entries(obj).filter(([, v]) => v === null || typeof v !== 'object').map(([key, value]) => {
|
||||
const fieldPath = path ? `${path}.${key}` : key
|
||||
const label = getFieldLabel(key, t)
|
||||
|
||||
if (editing) {
|
||||
if (typeof value === 'boolean') {
|
||||
return (
|
||||
<div key={key} className="config-card-row">
|
||||
<span className="config-card-label">{label}</span>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={value} onChange={e => onChange(fieldPath, e.target.checked)} />
|
||||
<span style={{ fontSize: 12, color: 'var(--text-tertiary)' }}>{value ? 'On' : 'Off'}</span>
|
||||
</label>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return (
|
||||
<div key={key} className="config-form-field">
|
||||
<label className="config-form-label">{label}</label>
|
||||
<input className="config-form-input" value={value.join(', ')} onChange={e => onChange(fieldPath, e.target.value.split(',').map(s => s.trim()).filter(Boolean))} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div key={key} className="config-form-field">
|
||||
<label className="config-form-label">{label}</label>
|
||||
<input className="config-form-input" type={typeof value === 'number' ? 'number' : 'text'} value={value ?? ''} onChange={e => onChange(fieldPath, typeof value === 'number' ? Number(e.target.value) : e.target.value)} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (typeof value === 'boolean') {
|
||||
return (
|
||||
<div key={key} className="config-card-row">
|
||||
<span className="config-card-label">{label}</span>
|
||||
<span className="config-card-value">{value ? 'On' : 'Off'}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return (
|
||||
<div key={key} className="config-card-row">
|
||||
<span className="config-card-label">{label}</span>
|
||||
<span className="config-card-value">{value.length > 0 ? value.join(', ') : '—'}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div key={key} className="config-card-row">
|
||||
<span className="config-card-label">{label}</span>
|
||||
<span className="config-card-value">{value != null && value !== '' ? String(value) : '—'}</span>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function getFieldLabel(key, t) {
|
||||
const translated = t(`config.${key}`)
|
||||
if (translated !== `config.${key}`) return translated
|
||||
return key.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
|
||||
}
|
||||
|
||||
function PanelProviders({ providers, editProvider, providerForm, setProviderForm, setEditProvider, openProviderEdit, handleSaveProvider, api, loadData, t }) {
|
||||
const [validating, setValidating] = useState(null)
|
||||
const [validationStatus, setValidationStatus] = useState(null)
|
||||
@@ -292,8 +363,7 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm
|
||||
<div className="provider-card-top">
|
||||
<div className="provider-card-identity">
|
||||
<span className="provider-card-name">{p.name}</span>
|
||||
{p.apiKey && <span className="badge ok">{t('config.keyConfigured')}</span>}
|
||||
{!p.apiKey && <span className="badge error">{t('config.noKey')}</span>}
|
||||
{p.active && <span className="badge ok" style={{ marginLeft: 6 }}>active</span>}
|
||||
{isValidationTarget && validationStatus?.valid && <span className="badge ok">{t('config.keyValid')}</span>}
|
||||
{isValidationTarget && !validationStatus?.valid && <span className="badge error">{validationStatus?.error}</span>}
|
||||
</div>
|
||||
@@ -331,7 +401,8 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm
|
||||
</div>
|
||||
</div>
|
||||
<div className="provider-card-meta" style={{ marginTop: 8 }}>
|
||||
<span className="mono">{p.model || '—'}</span>
|
||||
{p.active && <span className="badge ok" style={{ marginRight: 6 }}>active</span>}
|
||||
{p.model && p.model !== p.name && <span className="mono">{p.model}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -399,35 +470,93 @@ function PanelUpdates({ updates, checking, updating, needsUpdateCount, installed
|
||||
)
|
||||
}
|
||||
|
||||
function PanelLocale({ language, keyboard, layouts, setLanguage, setKeyboard, t }) {
|
||||
function PanelLocale({ language, keyboard, layouts, api, t }) {
|
||||
const { setLanguage, setKeyboard } = useI18n()
|
||||
const [editLocale, setEditLocale] = useState(false)
|
||||
const [draftLang, setDraftLang] = useState(language)
|
||||
const [draftKbd, setDraftKbd] = useState(keyboard)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [toast, setToast] = useState(null)
|
||||
|
||||
const showToast = (msg) => {
|
||||
setToast(msg)
|
||||
setTimeout(() => setToast(null), 2500)
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
await api.savePreferences({ language: draftLang, keyboard_layout: draftKbd })
|
||||
setLanguage(draftLang)
|
||||
setKeyboard(draftKbd)
|
||||
setEditLocale(false)
|
||||
showToast(t('config.saved'))
|
||||
} catch (err) {
|
||||
showToast(`${t('config.error')}: ${err.message}`)
|
||||
}
|
||||
setSaving(false)
|
||||
}
|
||||
|
||||
const currentLang = LANGUAGES.find(l => l.id === language)
|
||||
const currentKbd = layouts.find(l => l.id === keyboard)
|
||||
|
||||
return (
|
||||
<div className="config-card">
|
||||
<div className="config-card-group">
|
||||
<span className="config-card-group-label">{t('config.language')}</span>
|
||||
<div className="chip-row">
|
||||
{LANGUAGES.map(lang => (
|
||||
<div
|
||||
key={lang.id}
|
||||
className={`chip ${language === lang.id ? 'active' : ''}`}
|
||||
onClick={() => setLanguage(lang.id)}
|
||||
>
|
||||
{lang.name}
|
||||
</div>
|
||||
))}
|
||||
<div className="config-profile-center">
|
||||
{toast && <div className="config-toast">{toast}</div>}
|
||||
<div className="config-card">
|
||||
<div className="config-card-row">
|
||||
<span className="config-card-label">{t('config.language')}</span>
|
||||
<span className="config-card-value">{currentLang?.name || language}</span>
|
||||
</div>
|
||||
<div className="config-card-row">
|
||||
<span className="config-card-label">{t('config.keyboardLayout')}</span>
|
||||
<span className="config-card-value">{currentKbd?.name || keyboard}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="config-card-group">
|
||||
<span className="config-card-group-label">{t('config.keyboardLayout')}</span>
|
||||
<div className="chip-row">
|
||||
{layouts.map(l => (
|
||||
<div
|
||||
key={l.id}
|
||||
className={`chip ${keyboard === l.id ? 'active' : ''}`}
|
||||
onClick={() => setKeyboard(l.id)}
|
||||
>
|
||||
{l.name}
|
||||
{editLocale && (
|
||||
<div className="config-card">
|
||||
<div className="config-card-group">
|
||||
<span className="config-card-group-label">{t('config.language')}</span>
|
||||
<div className="chip-row">
|
||||
{LANGUAGES.map(lang => (
|
||||
<div
|
||||
key={lang.id}
|
||||
className={`chip ${draftLang === lang.id ? 'active' : ''}`}
|
||||
onClick={() => setDraftLang(lang.id)}
|
||||
>
|
||||
{lang.name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="config-card-group">
|
||||
<span className="config-card-group-label">{t('config.keyboardLayout')}</span>
|
||||
<div className="chip-row">
|
||||
{layouts.map(l => (
|
||||
<div
|
||||
key={l.id}
|
||||
className={`chip ${draftKbd === l.id ? 'active' : ''}`}
|
||||
onClick={() => setDraftKbd(l.id)}
|
||||
>
|
||||
{l.name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="config-card">
|
||||
<div className="config-card-actions" style={{ justifyContent: 'center' }}>
|
||||
{editLocale ? (
|
||||
<>
|
||||
<button className="primary sm" onClick={handleSave} disabled={saving}>
|
||||
{saving ? t('config.saving') : t('config.save')}
|
||||
</button>
|
||||
<button className="ghost sm" onClick={() => setEditLocale(false)}>{t('config.cancel')}</button>
|
||||
</>
|
||||
) : (
|
||||
<button className="primary sm" onClick={() => { setDraftLang(language); setDraftKbd(keyboard); setEditLocale(true) }}>{t('config.editProfile')}</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -21,6 +21,13 @@ function MiniGraph({ data, max, color, label, unit }) {
|
||||
<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>
|
||||
@@ -29,7 +36,6 @@ function MiniGraph({ data, max, color, label, unit }) {
|
||||
|
||||
export default function Dashboard({ api, refreshRef }) {
|
||||
const { t } = useI18n()
|
||||
const [dashboardStatus, setDashboardStatus] = useState(null)
|
||||
const [quota, setQuota] = useState(null)
|
||||
const [recentCmds, setRecentCmds] = useState([])
|
||||
const [processes, setProcesses] = useState([])
|
||||
@@ -41,14 +47,12 @@ export default function Dashboard({ api, refreshRef }) {
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
try {
|
||||
const [dashData, quotaData, cmdData, procData, metricsData] = await Promise.all([
|
||||
api.getDashboardStatus().catch(() => null),
|
||||
const [quotaData, cmdData, procData, metricsData] = await Promise.all([
|
||||
api.getProvidersQuota().catch(() => null),
|
||||
api.getRecentCommands().catch(() => ({ commands: [] })),
|
||||
api.getRunningProcesses().catch(() => ({ processes: [] })),
|
||||
api.getSystemMetrics().catch(() => null),
|
||||
])
|
||||
setDashboardStatus(dashData)
|
||||
setQuota(quotaData?.providers || [])
|
||||
setRecentCmds(cmdData.commands || [])
|
||||
setProcesses(procData.processes || [])
|
||||
@@ -76,7 +80,7 @@ export default function Dashboard({ api, refreshRef }) {
|
||||
|
||||
return (
|
||||
<div className="dash-grid">
|
||||
{/* CPU / RAM / Network Graphs */}
|
||||
{/* CPU */}
|
||||
<div className="dash-card">
|
||||
<div className="dash-card-head">
|
||||
<span className="dash-label">CPU</span>
|
||||
@@ -85,18 +89,20 @@ export default function Dashboard({ api, refreshRef }) {
|
||||
<MiniGraph data={cpuRef.current} max={100} color="var(--accent)" label="CPU" unit="%" />
|
||||
</div>
|
||||
|
||||
{/* RAM */}
|
||||
<div className="dash-card">
|
||||
<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)} MB` : '—'}</span>
|
||||
<span className="dash-count">{metrics ? `${metrics.mem_used_mb.toFixed(0)}/${metrics.mem_total_mb.toFixed(0)}` : '—'}</span>
|
||||
</div>
|
||||
<MiniGraph data={memRef.current} max={100} color="#a78bfa" label="RAM" unit="%" />
|
||||
</div>
|
||||
|
||||
{/* Network */}
|
||||
<div className="dash-card">
|
||||
<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)} KB/s` : '—'}</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" />
|
||||
@@ -114,7 +120,7 @@ export default function Dashboard({ api, refreshRef }) {
|
||||
<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>
|
||||
<span className="dash-quota-val">{m.used}/{m.total}</span>
|
||||
</div>
|
||||
))}
|
||||
{minimax && minimax.data?.models?.length === 0 && (
|
||||
@@ -136,12 +142,12 @@ export default function Dashboard({ api, refreshRef }) {
|
||||
{/* Running Processes */}
|
||||
<div className="dash-card">
|
||||
<div className="dash-card-head">
|
||||
<span className="dash-label">Running Processes</span>
|
||||
<span className="dash-label">Processes</span>
|
||||
<span className="dash-count">{processes.length}</span>
|
||||
</div>
|
||||
<div className="dash-proc-list">
|
||||
{processes.length === 0 && <span className="dash-empty">No relevant processes</span>}
|
||||
{processes.slice(0, 8).map((p, i) => (
|
||||
{processes.map((p, i) => (
|
||||
<div key={i} className="dash-proc-row">
|
||||
<span className="dash-proc-name">{p.name}</span>
|
||||
<span className="dash-proc-res">cpu {p.cpu}% · mem {p.mem}%</span>
|
||||
@@ -157,7 +163,7 @@ export default function Dashboard({ api, refreshRef }) {
|
||||
</div>
|
||||
<div className="dash-cmd-list">
|
||||
{recentCmds.length === 0 && <span className="dash-empty">No history</span>}
|
||||
{recentCmds.slice(0, 8).map((c, i) => (
|
||||
{recentCmds.map((c, i) => (
|
||||
<div key={i} className="dash-cmd-row" title={c.cmd}>
|
||||
<span className="dash-cmd-shell">{c.shell}</span>
|
||||
<span className="dash-cmd-text">{c.cmd.length > 45 ? c.cmd.slice(0, 42) + '...' : c.cmd}</span>
|
||||
@@ -165,38 +171,6 @@ export default function Dashboard({ api, refreshRef }) {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Services */}
|
||||
<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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -284,6 +284,9 @@ export default function Studio({ api }) {
|
||||
const [streamThinking, setStreamThinking] = useState('')
|
||||
const [streamToolCalls, setStreamToolCalls] = useState([])
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
const [tokenInfo, setTokenInfo] = useState({ used: 0, max: 100000, summarizeAt: 80000 })
|
||||
const [contextCollapsed, setContextCollapsed] = useState(false)
|
||||
const [messagesCollapsed, setMessagesCollapsed] = useState(false)
|
||||
const messagesEnd = useRef(null)
|
||||
const textareaRef = useRef(null)
|
||||
const abortRef = useRef(null)
|
||||
@@ -297,6 +300,11 @@ export default function Studio({ api }) {
|
||||
{ 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)
|
||||
}).catch(() => {
|
||||
setMessages([
|
||||
@@ -317,6 +325,34 @@ export default function Studio({ api }) {
|
||||
}
|
||||
}, [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() }])
|
||||
setContextCollapsed('animating')
|
||||
try {
|
||||
const data = await api.summarizeChat()
|
||||
setTokenInfo(prev => ({ ...prev, used: data.tokens || 0 }))
|
||||
setTimeout(() => {
|
||||
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(), compressed: true }])
|
||||
setContextCollapsed(true)
|
||||
setMessagesCollapsed(true)
|
||||
}, 600)
|
||||
} catch (err) {
|
||||
setMessages(prev => [...prev, { id: (Date.now() + 1).toString(), role: 'system', content: `Erreur de résumé: ${err.message}`, time: new Date().toISOString() }])
|
||||
setContextCollapsed(false)
|
||||
}
|
||||
}, [api])
|
||||
|
||||
const handleClear = useCallback(async () => {
|
||||
try {
|
||||
await api.clearChat()
|
||||
@@ -341,6 +377,7 @@ export default function Studio({ api }) {
|
||||
'## Commandes Studio',
|
||||
'',
|
||||
'- `/clear` - Effacer la conversation',
|
||||
'- `/summarize` - Résumer la conversation précédente',
|
||||
'- `/help` - Afficher cette aide',
|
||||
'- `/plan <objectif>` - Demander un plan structuré',
|
||||
'- `/export` - Exporter la conversation en Markdown',
|
||||
@@ -359,10 +396,15 @@ export default function Studio({ api }) {
|
||||
return
|
||||
}
|
||||
|
||||
if (text === '/summarize') {
|
||||
handleSummarize()
|
||||
return
|
||||
}
|
||||
|
||||
if (text === '/model') {
|
||||
api.getProviders().then(data => {
|
||||
const active = data.providers?.find(p => p.active)
|
||||
const modelMsg = active ? `Provider: ${active.name}\nModèle: ${active.model}` : 'Aucun provider actif configuré'
|
||||
const modelMsg = active ? active.name : 'Aucun provider actif configuré'
|
||||
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: modelMsg, time: new Date().toISOString() }])
|
||||
}).catch(() => {
|
||||
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: 'Erreur: impossible de récupérer les providers', time: new Date().toISOString() }])
|
||||
@@ -474,8 +516,9 @@ export default function Studio({ api }) {
|
||||
setStreamThinking('')
|
||||
setStreamToolCalls([])
|
||||
abortRef.current = null
|
||||
refreshTokens()
|
||||
}
|
||||
}, [input, loading, api, t, handleClear, streaming])
|
||||
}, [input, loading, api, t, handleClear, streaming, refreshTokens, handleSummarize])
|
||||
|
||||
const handleStop = useCallback(() => {
|
||||
if (abortRef.current) {
|
||||
@@ -490,6 +533,34 @@ export default function Studio({ api }) {
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleCollapsed = useCallback(() => {
|
||||
setMessagesCollapsed(prev => !prev)
|
||||
}, [])
|
||||
|
||||
const renderMessages = () => {
|
||||
if (messagesCollapsed && messages.length > 4) {
|
||||
const visibleCount = 4
|
||||
const hiddenCount = messages.length - visibleCount
|
||||
return (
|
||||
<>
|
||||
{messages.slice(0, visibleCount).map(msg => (
|
||||
<FeedItem key={msg.id} msg={msg} />
|
||||
))}
|
||||
<div className="feed-collapsed-messages" onClick={handleToggleCollapsed}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/>
|
||||
</svg>
|
||||
<span className="feed-collapsed-text">{hiddenCount} messages antérieurs compressés</span>
|
||||
<span className="feed-collapsed-count">clic pour développer</span>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
return messages.map(msg => (
|
||||
<FeedItem key={msg.id} msg={msg} />
|
||||
))
|
||||
}
|
||||
|
||||
if (!loaded) {
|
||||
return (
|
||||
<div className="studio-feed-layout">
|
||||
@@ -505,16 +576,32 @@ export default function Studio({ api }) {
|
||||
return (
|
||||
<div className="studio-feed-layout">
|
||||
<div className="studio-feed">
|
||||
{messages.map(msg => (
|
||||
<FeedItem key={msg.id} msg={msg} />
|
||||
))}
|
||||
{renderMessages()}
|
||||
{(streaming || streamThinking || loading || streamToolCalls.length > 0) && (
|
||||
<StreamingItem content={streaming} thinking={streamThinking} toolCalls={streamToolCalls} />
|
||||
)}
|
||||
<div ref={messagesEnd} />
|
||||
<div ref={messagesEnd} style={{ height: '24px' }} />
|
||||
</div>
|
||||
|
||||
<div className="studio-input-area">
|
||||
<div className={`studio-token-bar ${contextCollapsed === true ? 'compressed' : ''}`}>
|
||||
<div className={`studio-token-track ${contextCollapsed === true ? 'compressed' : ''}`}>
|
||||
<div
|
||||
className={`studio-token-fill ${tokenInfo.used >= tokenInfo.summarizeAt ? 'warn' : ''} ${contextCollapsed === true ? 'compressed' : ''} ${contextCollapsed === 'animating' ? 'animating' : ''}`}
|
||||
style={{ width: `${Math.min(100, (tokenInfo.used / tokenInfo.max) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className={`studio-token-text ${contextCollapsed === true ? 'compressed' : ''}`}>
|
||||
{(tokenInfo.used / 1000).toFixed(1)}k / {(tokenInfo.max / 1000).toFixed(0)}k tokens
|
||||
{contextCollapsed === true && ' · compressé'}
|
||||
{tokenInfo.used >= tokenInfo.summarizeAt && contextCollapsed !== true && ' · résumé auto.'}
|
||||
</span>
|
||||
{contextCollapsed === true && (
|
||||
<button className="ghost sm" onClick={handleToggleCollapsed} style={{ marginLeft: '8px', fontSize: '10px' }}>
|
||||
voir plus
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="studio-input-row">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
@@ -543,7 +630,7 @@ export default function Studio({ api }) {
|
||||
)}
|
||||
</div>
|
||||
<div className="studio-input-hint">
|
||||
{t('studio.inputHint')} · /clear /help /plan /export /model
|
||||
{t('studio.inputHint')} · /clear /summarize /help /plan /export /model
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -182,6 +182,8 @@ const en = {
|
||||
installed: 'Installed',
|
||||
missing: 'Missing',
|
||||
editProfile: 'Edit',
|
||||
profileInfo: 'Personal Info',
|
||||
profilePrefs: 'Preferences',
|
||||
cancel: 'Cancel',
|
||||
editProvider: 'Configure',
|
||||
validateKey: 'Validate',
|
||||
|
||||
@@ -182,6 +182,8 @@ const fr = {
|
||||
installed: 'Install\u00e9',
|
||||
missing: 'Manquant',
|
||||
editProfile: 'Modifier',
|
||||
profileInfo: 'Informations personnelles',
|
||||
profilePrefs: 'Préférences',
|
||||
editProvider: 'Configurer',
|
||||
validateKey: 'Valider',
|
||||
validating: 'V\u00e9rification...',
|
||||
|
||||
@@ -429,12 +429,16 @@ input::placeholder { color: var(--text-disabled); }
|
||||
.config-window { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
|
||||
|
||||
.config-tabs-bar {
|
||||
display: flex; gap: 4px; padding: 12px 20px 0; background: var(--bg-surface);
|
||||
display: flex; gap: 4px; padding: 12px 20px; background: var(--bg-surface);
|
||||
border-bottom: 1px solid var(--border); flex-shrink: 0;
|
||||
}
|
||||
|
||||
.config-panel-area { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
|
||||
.config-panel-body { flex: 1; overflow-y: auto; padding: 16px 28px 28px; }
|
||||
.config-profile-center {
|
||||
max-width: 540px; margin: 0 auto; width: 100%;
|
||||
display: flex; flex-direction: column; gap: 12px;
|
||||
}
|
||||
|
||||
.config-card {
|
||||
background: var(--bg-card); border: 1px solid var(--border);
|
||||
@@ -541,11 +545,13 @@ input::placeholder { color: var(--text-disabled); }
|
||||
overflow: hidden;
|
||||
}
|
||||
.dash-card {
|
||||
position: relative;
|
||||
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;
|
||||
@@ -574,7 +580,7 @@ input::placeholder { color: var(--text-disabled); }
|
||||
.dash-tool-tag.missing { color: var(--error); }
|
||||
|
||||
/* Quota */
|
||||
.dash-quota-list { display: flex; flex-direction: column; gap: 6px; }
|
||||
.dash-quota-list { display: flex; flex-direction: column; gap: 6px; max-height: 270px; overflow-y: auto; }
|
||||
.dash-quota-row { display: flex; align-items: center; gap: 8px; }
|
||||
.dash-quota-name {
|
||||
font-size: 11px; font-weight: 600; color: var(--text-primary);
|
||||
@@ -593,7 +599,7 @@ input::placeholder { color: var(--text-disabled); }
|
||||
}
|
||||
|
||||
/* Processes */
|
||||
.dash-proc-list { display: flex; flex-direction: column; gap: 4px; }
|
||||
.dash-proc-list { display: flex; flex-direction: column; gap: 4px; max-height: 270px; overflow-y: auto; }
|
||||
.dash-proc-row {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 4px 0;
|
||||
@@ -607,7 +613,7 @@ input::placeholder { color: var(--text-disabled); }
|
||||
}
|
||||
|
||||
/* Commands */
|
||||
.dash-cmd-list { display: flex; flex-direction: column; gap: 3px; }
|
||||
.dash-cmd-list { display: flex; flex-direction: column; gap: 3px; max-height: 270px; overflow-y: auto; }
|
||||
.dash-cmd-row {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
padding: 3px 0; overflow: hidden;
|
||||
@@ -620,6 +626,7 @@ input::placeholder { color: var(--text-disabled); }
|
||||
.dash-cmd-text {
|
||||
font-size: 11px; font-family: var(--font-mono); color: var(--text-secondary);
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
flex: 1; min-width: 0;
|
||||
}
|
||||
|
||||
/* Services */
|
||||
@@ -726,6 +733,18 @@ input::placeholder { color: var(--text-disabled); }
|
||||
.feed-content { font-size: 14px; line-height: 1.7; color: var(--text-primary); word-break: break-word; }
|
||||
.feed-system-badge { width: 6px; height: 6px; border-radius: 50%; background: var(--accent-dim); flex-shrink: 0; }
|
||||
.feed-system-text { font-size: 12px; color: var(--text-tertiary); font-style: italic; flex: 1; }
|
||||
.feed-system-text.compressed { color: var(--accent); font-style: normal; }
|
||||
.feed-compressed-indicator {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 10px 12px; margin: 4px 0;
|
||||
background: var(--bg-card); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.feed-compressed-indicator:hover { background: var(--bg-hover); border-color: var(--accent-dim); }
|
||||
.feed-compressed-indicator svg { color: var(--accent); flex-shrink: 0; }
|
||||
.feed-compressed-text { font-size: 12px; color: var(--text-tertiary); flex: 1; }
|
||||
.feed-compressed-count { font-size: 11px; color: var(--text-disabled); font-family: var(--font-mono); }
|
||||
|
||||
.feed-thinking-block {
|
||||
background: var(--bg-surface); border: 1px solid var(--border); border-left: 2px solid var(--accent-dim);
|
||||
@@ -785,6 +804,22 @@ input::placeholder { color: var(--text-disabled); }
|
||||
.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; } }
|
||||
.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-fill.compressed { height: 2px; }
|
||||
.studio-token-fill.animating { animation: compress-pulse 0.6s ease-in-out; }
|
||||
@keyframes compress-pulse {
|
||||
0% { height: 3px; opacity: 1; }
|
||||
50% { height: 5px; opacity: 0.8; background: var(--accent-light); }
|
||||
100% { height: 2px; opacity: 1; }
|
||||
}
|
||||
.studio-token-text { font-size: 10px; font-family: var(--font-mono); color: var(--text-tertiary); white-space: nowrap; }
|
||||
.studio-token-text.compressed { font-size: 9px; }
|
||||
.studio-token-track.compressed { height: 2px; }
|
||||
.studio-token-bar.compressed { margin-bottom: 4px; }
|
||||
|
||||
.studio-input-row { display: flex; gap: 8px; align-items: flex-end; }
|
||||
.studio-input-row textarea {
|
||||
flex: 1; resize: none; min-height: 42px; max-height: 200px; padding: 10px 14px;
|
||||
@@ -809,6 +844,21 @@ input::placeholder { color: var(--text-disabled); }
|
||||
.studio-stop-btn:hover { opacity: 0.8; }
|
||||
.studio-input-hint { font-size: 11px; color: var(--text-disabled); text-align: center; margin-top: 6px; }
|
||||
|
||||
/* ── Collapsed Messages ── */
|
||||
.feed-collapsed-messages {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 8px 16px; margin: 4px 0;
|
||||
background: linear-gradient(135deg, var(--bg-surface), var(--bg-elevated));
|
||||
border: 1px dashed var(--border-accent);
|
||||
border-radius: var(--radius); cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.feed-collapsed-messages:hover { background: var(--bg-hover); border-color: var(--accent); }
|
||||
.feed-collapsed-messages svg { color: var(--accent); flex-shrink: 0; }
|
||||
.feed-collapsed-text { font-size: 11px; color: var(--text-tertiary); flex: 1; }
|
||||
.feed-collapsed-count { font-size: 10px; color: var(--text-disabled); font-family: var(--font-mono); }
|
||||
.feed-expanded-messages { animation: fadeIn 0.2s ease-out; }
|
||||
|
||||
/* ── Studio Tool Blocks ── */
|
||||
.studio-tool-block {
|
||||
background: var(--bg-surface);
|
||||
|
||||
Reference in New Issue
Block a user