Compare commits

..

2 Commits

Author SHA1 Message Date
Augustin
f7222b0f6c docs: rewrite README and CHANGELOG for desktop app mode
All checks were successful
Beta Release / beta (push) Successful in 32s
Update README to reflect TUI removal and new React desktop UI with
API backend, i18n, themes, and keyboard layout support. Fix duplicate
v0.2.1 entries in CHANGELOG and add [Unreleased] section for recent
desktop/i18n/theme changes.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-21 22:02:09 +02:00
Augustin
11417d3ea7 feat(web): add i18n support with FR/EN locales and keyboard layout awareness
All checks were successful
Beta Release / beta (push) Successful in 36s
Add full internationalization system with React context, French/English
translations, and AZERTY/QWERTY keyboard layout support. Dashboard now
uses a tabbed layout (Tools, Notifications, Workflows). Config page exposes
language and keyboard preferences persisted via new /api/preferences endpoint.

💕 Generated with Crush

Assisted-by: GLM-5-Turbo via Crush <crush@charm.land>
2026-04-21 21:48:36 +02:00
17 changed files with 884 additions and 280 deletions

View File

@@ -4,12 +4,33 @@ 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/).
## [Unreleased]
### Added
- **Desktop mode**: React 19 web UI served locally, auto-opens in browser. Frontend embedded in Go binary via `go:embed`.
- **API backend**: 15 REST endpoints (`/api/info`, `/api/system`, `/api/tools`, `/api/config`, `/api/providers`, `/api/skills`, `/api/lsp`, `/api/mcp`, `/api/updates`, `/api/scan`, `/api/install`, `/api/terminal`, `/api/mcp/configure`, `/api/preferences`).
- **i18n**: Full FR/EN translation system with keyboard layout awareness (AZERTY, QWERTY, QWERTZ). Preferences synced to backend.
- **Themes**: 4 built-in themes (Cyberpunk Red, Cyberpunk Pink, Midnight Blue, Matrix Green) with 30+ CSS custom properties applied at runtime.
- **Desktop flags**: `--port=PORT` to specify port, `--no-open` to skip browser auto-open.
- **SPA routing**: Frontend handles client-side routing via catch-all fallback.
- **CI**: Frontend build step (`npm ci && npm run build`) added to all 3 CI pipelines.
### Changed
- **Default mode**: `muyue` now launches the desktop web app instead of the TUI. The TUI has been removed entirely.
- **Single binary**: `cmd/muyue-desktop` merged into `cmd/muyue`. Only one binary needed.
- **Frontend**: Moved from `cmd/muyue-desktop/frontend/` to `web/` and embedded via `web/embed.go`.
- **Go module**: Dependencies cleaned up — removed indirect TUI-related packages.
- **Makefile**: `build` target now runs `frontend` (npm build) automatically. Added `dev-desktop` target for Vite dev server.
### Removed
- **TUI**: All `internal/tui/` code removed (model, views, handlers, animations, terminal, styles).
- **`cmd/muyue-desktop/`**: Separate desktop binary removed; merged into main binary.
## v0.2.1
### Changes since v0.2.1
- feat: complete TUI redesign with cyberpunk theme (#1) (cb8e3d0)
### Downloads
| Platform | File |
@@ -21,47 +42,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
| Windows x86_64 | [muyue-windows-amd64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.1/muyue-windows-amd64.zip) |
| Windows ARM64 | [muyue-windows-arm64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.1/muyue-windows-arm64.zip) |
### Install
**Linux (x86_64)**
```bash
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.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.2.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.2.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.2.1
### Changes since v0.2.0
- feat: complete TUI redesign with cyberpunk theme (#1) (cb8e3d0)
- chore: bump version to 0.2.1, update README for TUI redesign (22fb282)
### Downloads
| Platform | File |
|----------|------|
| Linux x86_64 | [muyue-linux-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.1/muyue-linux-amd64.tar.gz) |
| Linux ARM64 | [muyue-linux-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.1/muyue-linux-arm64.tar.gz) |
| macOS Intel | [muyue-darwin-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.1/muyue-darwin-amd64.tar.gz) |
| macOS Apple Silicon | [muyue-darwin-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.1/muyue-darwin-arm64.tar.gz) |
| Windows x86_64 | [muyue-windows-amd64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.1/muyue-windows-amd64.zip) |
| Windows ARM64 | [muyue-windows-arm64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.1/muyue-windows-arm64.zip) |
### Install
**Linux (x86_64)**
@@ -85,9 +70,19 @@ Expand-Archive -Path "muyue.zip" -DestinationPath "."
Move-Item muyue-windows-amd64.exe C:\Windows\muyue.exe
```
## v0.2.0
### Downloads
| Platform | File |
|----------|------|
| Linux x86_64 | [muyue-linux-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.0/muyue-linux-amd64.tar.gz) |
| Linux ARM64 | [muyue-linux-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.0/muyue-linux-arm64.tar.gz) |
| macOS Intel | [muyue-darwin-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.0/muyue-darwin-amd64.tar.gz) |
| macOS Apple Silicon | [muyue-darwin-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.0/muyue-darwin-arm64.tar.gz) |
| Windows x86_64 | [muyue-windows-amd64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.0/muyue-windows-amd64.zip) |
| Windows ARM64 | [muyue-windows-arm64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.0/muyue-windows-arm64.zip) |
### Changes since start
- refactor: redesign TUI with 4 tabs, red/rose theme, split layouts (035e923)
@@ -121,17 +116,6 @@ Move-Item muyue-windows-amd64.exe C:\Windows\muyue.exe
- ci: fix Gitea Actions - native checkout + auto-release on push (78c7239)
- ci: migrate workflows to Gitea Actions with self-hosted runner (811a9aa)
### Downloads
| Platform | File |
|----------|------|
| Linux x86_64 | [muyue-linux-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.0/muyue-linux-amd64.tar.gz) |
| Linux ARM64 | [muyue-linux-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.0/muyue-linux-arm64.tar.gz) |
| macOS Intel | [muyue-darwin-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.0/muyue-darwin-amd64.tar.gz) |
| macOS Apple Silicon | [muyue-darwin-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.0/muyue-darwin-arm64.tar.gz) |
| Windows x86_64 | [muyue-windows-amd64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.0/muyue-windows-amd64.zip) |
| Windows ARM64 | [muyue-windows-arm64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.0/muyue-windows-arm64.zip) |
### Install
**Linux (x86_64)**
@@ -155,7 +139,6 @@ Expand-Archive -Path "muyue.zip" -DestinationPath "."
Move-Item muyue-windows-amd64.exe C:\Windows\muyue.exe
```
## [0.2.0] - 2026-04-20
### Added

174
README.md
View File

@@ -4,25 +4,30 @@ AI-powered development environment assistant by **La Légion de Muyue**.
## What it does
`muyue` is a single binary that transforms your entire development environment:
`muyue` is a single binary (frontend embedded) that transforms your entire development environment:
- **Desktop app** — React web UI served locally, auto-opens in your browser
- **Scans** your system for tools, runtimes, and configs
- **Installs** missing tools automatically (Crush, Claude Code, BMAD, Starship, runtimes...)
- **Updates** everything in the background
- **Profiles** you on first run to personalize the experience
- **Unifies** control of Crush and Claude Code from one TUI
- **Unifies** control of Crush and Claude Code from one interface
- **Orchestrates** AI agents via MiniMax M2.7
- **Customizes** your terminal prompt (branch, commits, language, etc.)
- **Configures** MCP servers, LSPs, and skills automatically
- **Previews** HTML/visual outputs in your browser
- **i18n** — Full FR/EN support with keyboard layout awareness (AZERTY, QWERTY, QWERTZ)
- **4 themes** — Cyberpunk Red, Cyberpunk Pink, Midnight Blue, Matrix Green
## Tech Stack
- **Go** — single binary, no dependencies
- **Charm** — Bubble Tea, Lip Gloss, Huh (TUI, styling, forms)
- **Starship** — terminal prompt customization
- **MiniMax M2.7** — AI orchestration
- **BMAD-METHOD** — structured development workflows
| Layer | Technology |
|-------|-----------|
| **Backend** | Go 1.24 — single binary, no runtime dependencies |
| **Frontend** | React 19, Vite 8 — embedded via `go:embed` |
| **Styling** | CSS custom properties, 4 built-in themes |
| **i18n** | Custom FR/EN system with keyboard layout awareness |
| **CLI** | Charm (Bubble Tea, Huh) — for setup wizard, profiler, and CLI commands |
| **AI** | MiniMax M2.7 — orchestration |
| **CI/CD** | Gitea Actions — Go + Node build, multi-platform releases |
## Install
@@ -37,10 +42,14 @@ make build
make install-local
```
The frontend is built automatically during `make build` (runs `npm ci && npm run build` in `web/`).
## Usage
```bash
muyue # Start interactive TUI
muyue # Launch desktop app (opens browser)
muyue --port=8080 # Launch on a specific port
muyue --no-open # Launch without opening the browser
muyue scan # Scan system
muyue install # Install missing tools
muyue update # Check and apply updates
@@ -76,55 +85,116 @@ muyue skills deploy # Deploy skills to Crush and Claude Code
muyue skills delete <name> # Delete a skill
```
## TUI — 4 Tabs
## Desktop App — 4 Tabs
The TUI is organized into 4 tabs with a red/rose theme (`#E8364F``#FF6B8A`):
The web UI is organized into 4 tabs with a cyberpunk dark theme. Navigate with `Ctrl+1` through `Ctrl+4`.
### Dashboard
### Dashboard
System overview: installed tools with status, active agents, updates, LSP/MCP/daemon status, and quick actions (install, update, scan).
System overview with sub-tabs:
- **Tools** — installed/missing tools with status badges and version info
- **Notifications** — activity log with colored severity
- **Workflows** — quick actions (install missing, check updates, rescan, configure MCP)
### Studio
### ⟨⟩ Studio
Central AI chat with a collapsible sidebar (`Ctrl+S`) containing 3 panels:
AI chat interface with a sidebar containing 3 panels:
| Panel | Shortcut | Description |
|-------|----------|-------------|
| **Chat** | `1` | AI conversation, `/plan <goal>` to start workflows |
| **Agents** | `2` | Start/stop Crush and Claude Code agents |
| **Workflows** | `3` | Plan→Execute workflow controls (approve, reject, next step) |
| Panel | Description |
|-------|-------------|
| **Chat** | AI conversation, `/plan <goal>` to start workflows |
| **Agents** | Status of Crush and Claude Code agents |
| **Workflows** | Plan→Execute workflow controls |
### Shell
### $ Shell
Split-view terminal with an AI assistant panel (`Ctrl+A` to toggle). The AI knows your system and suggests commands you can easily copy into the terminal.
Split-view: terminal emulator on the left (sends commands to the Go backend), collapsible AI assistant panel on the right. Full command history with `↑`/`↓` navigation.
### ⚙ Config
Profile, API providers, terminal/starship settings, BMAD, and skills — displayed in a two-column layout.
Two-column profile settings:
- **Profile** — name, pseudo, email, editor, shell, default AI, languages
- **AI Providers** — active provider, API key status, model info
- **Theme** — 4 swatches (Cyberpunk Red, Cyberpunk Pink, Midnight Blue, Matrix Green)
- **Language** — FR/EN with keyboard layout selection (AZERTY, QWERTY, QWERTZ)
- **Skills** — installed skills list
### Keyboard Shortcuts
| Key | Context | Action |
|-----|---------|--------|
| `Ctrl+T` | Global | Open tab switcher |
| `Ctrl+S` | Studio | Toggle sidebar |
| `Ctrl+A` | Shell | Toggle AI assistant panel |
| `Ctrl+C` | Global | Quit confirmation |
| `i` | Dashboard | Install missing tools |
| `u` | Dashboard | Check for updates |
| `s` | Dashboard | Rescan system |
| `1` `2` `3` | Studio sidebar | Switch panels (Chat/Agents/Workflows) |
| `a` | Workflow | Approve plan |
| `r` | Workflow | Reject plan |
| `g` | Workflow | Generate plan |
| `n` | Workflow | Next step |
| `x` | Workflow | Cancel workflow |
| `Ctrl+1` | Global | Dashboard tab |
| `Ctrl+2` | Global | Studio tab |
| `Ctrl+3` | Global | Shell tab |
| `Ctrl+4` | Global | Config tab |
| `Enter` | Studio | Send message |
| `Shift+Enter` | Studio | New line |
| `Enter` | Shell | Run command |
| ``/`↓` | Shell | Command history |
## API Endpoints
The Go backend serves 15 REST endpoints under `/api/`:
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/api/info` | GET | App name, version, author |
| `/api/system` | GET | OS, arch, platform info |
| `/api/tools` | GET | Tool scan results |
| `/api/config` | GET | Profile, terminal, BMAD config |
| `/api/providers` | GET | AI provider list |
| `/api/skills` | GET | Installed skills |
| `/api/lsp` | GET | LSP server scan |
| `/api/mcp` | GET | MCP server scan |
| `/api/updates` | GET | Update check results |
| `/api/scan` | POST | Trigger system rescan |
| `/api/install` | POST | Install tools `{"tools": [...]}` |
| `/api/terminal` | POST | Execute command `{"command": "...", "cwd": "..."}` |
| `/api/mcp/configure` | POST | Configure MCP servers |
| `/api/preferences` | PUT | Save language/keyboard preferences |
## Project Structure
```
.
├── cmd/muyue/main.go # CLI entry point + command routing
├── internal/
│ ├── api/ # HTTP server + handlers (15 endpoints)
│ ├── config/ # YAML config + XDG paths
│ ├── daemon/ # Background daemon
│ ├── desktop/ # Desktop mode (HTTP server + SPA)
│ ├── installer/ # Tool installation logic
│ ├── lsp/ # LSP server scan + install
│ ├── mcp/ # MCP server configuration
│ ├── orchestrator/ # AI agent orchestration
│ ├── platform/ # Cross-platform abstractions
│ ├── preview/ # HTML preview server
│ ├── profiler/ # First-run setup wizard
│ ├── proxy/ # AI proxy agents
│ ├── scanner/ # System tool/runtime scanner
│ ├── secret/ # AES-256-GCM key encryption
│ ├── skills/ # Skills management (CRUD, deploy, AI-generate)
│ ├── updater/ # Tool auto-updater
│ ├── version/ # Version constants
│ └── workflow/ # Plan→Execute workflow engine
├── web/ # Frontend (React 19 + Vite)
│ ├── embed.go # go:embed dist/
│ ├── src/
│ │ ├── api/client.js # API client
│ │ ├── components/ # App, Dashboard, Studio, Shell, Config
│ │ ├── i18n/ # FR/EN translations + keyboard layouts
│ │ ├── styles/global.css # Full CSS theme system
│ │ └── themes/index.js # 4 themes with CSS variable injection
│ └── vite.config.js # Vite + dev proxy to :8095
├── .gitea/workflows/ # CI/CD (PR check, beta, stable)
└── Makefile # build, test, lint, cross-compile
```
## Configuration
Config stored at `$XDG_CONFIG_HOME/muyue/config.yaml` (defaults to `~/.config/muyue/config.yaml`).
API keys are encrypted at rest using AES-GCM with a machine-local key stored in `~/.muyue_key`.
API keys are encrypted at rest using AES-256-GCM with a machine-local key stored in `~/.muyue_key`.
First run launches an interactive profiling wizard that:
1. Asks your name, pseudo, email
@@ -133,17 +203,39 @@ First run launches an interactive profiling wizard that:
4. Scans your system
5. Installs missing tools
## Themes
4 built-in themes, selectable from the Config tab:
| Theme | Accent Color |
|-------|-------------|
| Cyberpunk Red | `#FF0033` |
| Cyberpunk Pink | `#FF1A8C` |
| Midnight Blue | `#0088FF` |
| Matrix Green | `#00FF41` |
Themes are applied via CSS custom properties injected at runtime. All colors (30+ variables) adapt automatically.
## i18n & Keyboard Layouts
- **Languages**: Français, English
- **Keyboard layouts**: AZERTY (fr-FR), QWERTY (en-US), QWERTZ (de-DE)
- Keyboard layout affects displayed shortcuts in the status bar (e.g., `Ctrl+&-é-"-'` on AZERTY vs `Ctrl+1-4` on QWERTY)
- Preferences saved to backend and synced across sessions
## Security
- API keys are encrypted at rest (AES-256-GCM) with a per-machine key
- API keys encrypted at rest (AES-256-GCM) with a per-machine key
- Config files use restrictive permissions (0600)
- MCP config files use restrictive permissions (0600)
- Integrated terminal blocks dangerous commands (rm -rf /, mkfs, fork bombs, etc.)
- Terminal API executes commands via shell — only accessible on localhost
## Cross-Platform
Built for Linux (primary), macOS, and Windows. WSL supported.
Single binary includes both CLI and embedded web frontend.
## Contributing — GitFlow Workflow
This project uses a **lightweight GitFlow** with 2 permanent branches and conventional commits.
@@ -179,6 +271,8 @@ hotfix/xxx ──PR (squash)──▶ main (+ backport develop)
| `ci-develop.yml` | Push to `develop` | vet + test + build all platforms + create beta release |
| `ci-main.yml` | Push to `main` | vet + test + build all platforms + update CHANGELOG.md + create stable release |
All CI pipelines build the frontend (`npm ci && npm run build`) before Go vet/test/build.
### Step-by-step: contribute a feature
```bash

View File

@@ -5,6 +5,7 @@ import (
"net/http"
"os/exec"
"github.com/muyue/muyue/internal/config"
"github.com/muyue/muyue/internal/lsp"
"github.com/muyue/muyue/internal/mcp"
"github.com/muyue/muyue/internal/scanner"
@@ -174,6 +175,36 @@ func (s *Server) handleScan(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]string{"status": "ok"})
}
func (s *Server) handleUpdatePreferences(w http.ResponseWriter, r *http.Request) {
if r.Method != "PUT" {
writeError(w, "PUT only", http.StatusMethodNotAllowed)
return
}
if s.config == nil {
writeError(w, "no config", http.StatusNotFound)
return
}
var body struct {
Language string `json:"language"`
KeyboardLayout string `json:"keyboard_layout"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
if body.Language != "" {
s.config.Profile.Preferences.Language = body.Language
}
if body.KeyboardLayout != "" {
s.config.Profile.Preferences.KeyboardLayout = body.KeyboardLayout
}
if err := config.Save(s.config); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]string{"status": "ok"})
}
func (s *Server) handleTerminal(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)

View File

@@ -35,6 +35,7 @@ func (s *Server) routes() {
s.mux.HandleFunc("/api/updates", s.handleUpdates)
s.mux.HandleFunc("/api/install", s.handleInstall)
s.mux.HandleFunc("/api/scan", s.handleScan)
s.mux.HandleFunc("/api/preferences", s.handleUpdatePreferences)
s.mux.HandleFunc("/api/terminal", s.handleTerminal)
s.mux.HandleFunc("/api/mcp/configure", s.handleMCPConfigure)
}
@@ -42,7 +43,7 @@ func (s *Server) routes() {
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)

View File

@@ -15,12 +15,14 @@ type Profile struct {
Email string `yaml:"email"`
Languages []string `yaml:"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"`
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"`
}
@@ -179,6 +181,8 @@ func Default() *MuyueConfig {
cfg.Profile.Preferences.AutoUpdate = true
cfg.Profile.Preferences.CheckOnStart = true
cfg.Profile.Preferences.Theme = "charm"
cfg.Profile.Preferences.Language = "fr"
cfg.Profile.Preferences.KeyboardLayout = "azerty"
cfg.AI.Providers = []AIProvider{
{

View File

@@ -25,6 +25,7 @@ const api = {
runScan: () => request('/scan', { method: 'POST' }),
installTools: (tools) => request('/install', { method: 'POST', body: JSON.stringify({ tools }) }),
configureMCP: () => request('/mcp/configure', { method: 'POST' }),
savePreferences: (prefs) => request('/preferences', { method: 'PUT', body: JSON.stringify(prefs) }),
runCommand: (command, cwd) => request('/terminal', { method: 'POST', body: JSON.stringify({ command, cwd }) }),
}

View File

@@ -1,24 +1,26 @@
import { useState, useEffect, useCallback } from 'react'
import { useState, useEffect, useCallback, useMemo } from 'react'
import api from '../api/client'
import { getTheme, getThemeNames, applyTheme } from '../themes'
import { useI18n } from '../i18n'
import Dashboard from './Dashboard'
import Studio from './Studio'
import Shell from './Shell'
import Config from './Config'
const TABS = [
{ id: 'dash', label: 'Dashboard', icon: '\u25A0' },
{ id: 'studio', label: 'Studio', icon: '\u27E8\u27E9' },
{ id: 'shell', label: 'Shell', icon: '$' },
{ id: 'config', label: 'Config', icon: '\u2699' },
]
export default function App() {
const [activeTab, setActiveTab] = useState('dash')
const [info, setInfo] = useState({})
const [clock, setClock] = useState(new Date())
const [updates, setUpdates] = useState([])
const [tools, setTools] = useState([])
const { t, layout } = useI18n()
const TABS = useMemo(() => [
{ id: 'dash', label: t('tabs.dashboard'), icon: '\u25A0' },
{ id: 'studio', label: t('tabs.studio'), icon: '\u27E8\u27E9' },
{ id: 'shell', label: t('tabs.shell'), icon: '$' },
{ id: 'config', label: t('tabs.config'), icon: '\u2699' },
], [t])
useEffect(() => {
api.getInfo().then(setInfo).catch(() => {})
@@ -35,10 +37,16 @@ export default function App() {
useEffect(() => {
const onKey = (e) => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return
const map = { '1': 'dash', '2': 'studio', '3': 'shell', '4': 'config' }
if (map[e.key]) {
if (!e.ctrlKey && !e.metaKey) return
const map = {
Digit1: 'dash',
Digit2: 'studio',
Digit3: 'shell',
Digit4: 'config',
}
if (map[e.code]) {
e.preventDefault()
setActiveTab(map[e.key])
setActiveTab(map[e.code])
}
}
window.addEventListener('keydown', onKey)
@@ -50,6 +58,25 @@ export default function App() {
const hasUpdates = updates.some(u => u.needsUpdate)
const installed = tools.filter(t => t.installed).length
const WINDOW_SHORTCUTS = useMemo(() => ({
dash: [
{ keys: `${layout.keys.ctrl}+${layout.keys.range}`, desc: t('statusbar.switchWindow') },
],
studio: [
{ keys: layout.keys.enter, desc: t('statusbar.sendMessage') },
{ keys: `${layout.keys.shift}+${layout.keys.enter}`, desc: t('statusbar.newLine') },
{ keys: `${layout.keys.ctrl}+${layout.keys.range}`, desc: t('statusbar.switchWindow') },
],
shell: [
{ keys: layout.keys.enter, desc: t('statusbar.runCommand') },
{ 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') },
],
}), [layout, t])
const renderContent = () => {
switch (activeTab) {
case 'dash': return <Dashboard tools={tools} updates={updates} api={api} onRescan={t => setTools(t)} />
@@ -86,28 +113,43 @@ export default function App() {
<div className="header-spacer" />
<div className="header-indicators">
<span className={`indicator ${installed > 0 ? 'ok' : 'off'}`} title={`${installed} tools installed`} />
<span className={`indicator ${hasUpdates ? 'warn' : 'ok'}`} title={hasUpdates ? 'Updates available' : 'Up to date'} />
<span
className={`indicator ${installed > 0 ? 'ok' : 'off'}`}
title={t('header.toolsInstalled', { count: installed })}
/>
<span
className={`indicator ${hasUpdates ? 'warn' : 'ok'}`}
title={hasUpdates ? t('header.updatesAvailable') : t('header.upToDate')}
/>
</div>
<span className="header-clock">
{clock.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })}
{clock.toLocaleTimeString(layout.locale, { hour: '2-digit', minute: '2-digit' })}
</span>
</header>
<main className="content fade-in" key={activeTab}>
<main className="content fade-in" key={`${activeTab}-${TABS.length}`}>
{renderContent()}
</main>
<footer className="statusbar">
<div className="statusbar-left">
<span>Press 1-4 to switch tabs</span>
<FooterShortcuts shortcuts={WINDOW_SHORTCUTS[activeTab] || []} />
</div>
<div className="statusbar-right">
{hasUpdates && <span style={{ color: 'var(--warning)' }}>Updates available</span>}
<span>v{info.version || '...'}</span>
<span style={{ fontFamily: 'var(--font-mono)' }}>
{layout.keys.ctrl}+{layout.keys.range} {t('statusbar.switchWindow')}
</span>
</div>
</footer>
</div>
)
}
function FooterShortcuts({ shortcuts }) {
return shortcuts.map((s, i) => (
<span key={i} className="statusbar-shortcut">
<kbd>{s.keys}</kbd> {s.desc}
</span>
))
}

View File

@@ -1,7 +1,10 @@
import { useState, useEffect } from 'react'
import { getThemeNames, applyTheme, getTheme } from '../themes'
import { useI18n, LANGUAGES } from '../i18n'
import { getLayoutList } from '../i18n/keyboards'
export default function Config({ api }) {
const { t, language, keyboard, setLanguage, setKeyboard, layout } = useI18n()
const [config, setConfig] = useState(null)
const [providers, setProviders] = useState([])
const [skillList, setSkillList] = useState([])
@@ -14,6 +17,7 @@ export default function Config({ api }) {
}, [])
const themes = getThemeNames()
const layouts = getLayoutList()
const handleThemeChange = (themeId) => {
applyTheme(getTheme(themeId))
@@ -30,35 +34,65 @@ export default function Config({ api }) {
return (
<div className="config-layout">
<div className="config-section">
<div className="config-section-title">Profile</div>
<div className="config-section-title">{t('config.profile')}</div>
{config?.profile ? (
<div>
<FieldRow label="Name" value={config.profile.name} />
<FieldRow label="Pseudo" value={config.profile.pseudo} />
<FieldRow label="Email" value={config.profile.email} />
<FieldRow label="Editor" value={config.profile.preferences?.editor} />
<FieldRow label="Shell" value={config.profile.preferences?.shell} />
<FieldRow label="Default AI" value={config.profile.preferences?.defaultAI} />
<FieldRow label="Languages" value={config.profile.languages?.join(', ')} />
<FieldRow label={t('config.name')} value={config.profile.name} />
<FieldRow label={t('config.pseudo')} value={config.profile.pseudo} />
<FieldRow label={t('config.email')} value={config.profile.email} />
<FieldRow label={t('config.editor')} value={config.profile.preferences?.editor} />
<FieldRow label={t('config.shell')} value={config.profile.preferences?.shell} />
<FieldRow label={t('config.defaultAi')} value={config.profile.preferences?.defaultAI} />
<FieldRow label={t('config.languages')} value={config.profile.languages?.join(', ')} />
</div>
) : (
<div className="empty-state">Loading profile...</div>
<div className="empty-state">{t('config.loadingProfile')}</div>
)}
</div>
<div className="config-section">
<div className="config-section-title">AI Providers</div>
<div className="config-section-title">{t('config.language')}</div>
<div className="actions-stack">
{LANGUAGES.map(lang => (
<div
key={lang.id}
className={`chip ${language === lang.id ? 'active' : ''}`}
onClick={() => setLanguage(lang.id)}
>
{lang.name}
</div>
))}
</div>
</div>
<div className="config-section">
<div className="config-section-title">{t('config.keyboardLayout')}</div>
<div className="actions-stack">
{layouts.map(l => (
<div
key={l.id}
className={`chip ${keyboard === l.id ? 'active' : ''}`}
onClick={() => setKeyboard(l.id)}
>
{l.name}
</div>
))}
</div>
</div>
<div className="config-section">
<div className="config-section-title">{t('config.aiProviders')}</div>
{providers.map((p, i) => (
<div key={i} className="provider-card">
<div className="provider-info">
<div className="provider-name">
{p.name}
{p.active && <span className="badge accent" style={{ marginLeft: 8 }}>Active</span>}
{p.active && <span className="badge accent" style={{ marginLeft: 8 }}>{t('config.active')}</span>}
</div>
<div className="provider-meta">
<span>{p.model}</span>
<span style={{ color: p.apiKey ? 'var(--success)' : 'var(--error)' }}>
{p.apiKey ? 'Key configured' : 'No key'}
{p.apiKey ? t('config.keyConfigured') : t('config.noKey')}
</span>
</div>
</div>
@@ -67,32 +101,32 @@ export default function Config({ api }) {
</div>
<div className="config-section">
<div className="config-section-title">Theme</div>
<div className="config-section-title">{t('config.theme')}</div>
<div className="theme-picker">
{themes.map(t => (
{themes.map(th => (
<div
key={t.id}
className={`theme-swatch ${currentTheme === t.id ? 'active' : ''}`}
style={{ background: themeColors[t.id] || '#FF0033' }}
onClick={() => handleThemeChange(t.id)}
title={t.name}
key={th.id}
className={`theme-swatch ${currentTheme === th.id ? 'active' : ''}`}
style={{ background: themeColors[th.id] || '#FF0033' }}
onClick={() => handleThemeChange(th.id)}
title={th.name}
/>
))}
</div>
</div>
<div className="config-section">
<div className="config-section-title">Skills ({skillList.length})</div>
<div className="config-section-title">{t('config.skills')} ({skillList.length})</div>
{skillList.length === 0 ? (
<div className="empty-state">
No skills installed.
<span style={{ fontFamily: 'var(--font-mono)' }}>Run muyue skills init</span>
{t('config.noSkills')}
<span style={{ fontFamily: 'var(--font-mono)' }}>{t('config.runSkillsInit')}</span>
</div>
) : (
skillList.map((s, i) => (
<div key={i} className="tool-row">
<span className="tool-name">{s.name}</span>
<span className={`badge neutral`}>{s.target || 'both'}</span>
<span className="badge neutral">{s.target || 'both'}</span>
<span style={{ color: 'var(--text-tertiary)', fontSize: 12 }}>{s.description}</span>
</div>
))
@@ -106,7 +140,7 @@ function FieldRow({ label, value }) {
return (
<div className="field-row">
<span className="field-label">{label}</span>
<span className={`field-value ${!value ? 'empty' : ''}`}>{value || 'Not set'}</span>
<span className={`field-value ${!value ? 'empty' : ''}`}>{value || ''}</span>
</div>
)
}

View File

@@ -1,126 +1,101 @@
import { useState } from 'react'
import { useI18n } from '../i18n'
export default function Dashboard({ tools, updates, api, onRescan }) {
const [installing, setInstalling] = useState(false)
const [log, setLog] = useState([])
const { t, layout } = useI18n()
const [activeSection, setActiveSection] = useState('tools')
const [notifications, setNotifications] = useState([])
const installed = tools.filter(t => t.installed).length
const installed = tools.filter(tool => tool.installed).length
const total = tools.length
const pct = total > 0 ? Math.round((installed / total) * 100) : 0
const missing = tools.filter(t => !t.installed).map(t => t.name || t.Name)
const handleInstall = async () => {
if (missing.length === 0) return
setInstalling(true)
addLog(`Installing ${missing.length} tools...`, 'info')
try {
await api.installTools(missing)
addLog('Install started. Rescanning...', 'ok')
await api.runScan()
const data = await api.getTools()
onRescan(data.tools || [])
addLog('Done.', 'ok')
} catch (err) {
addLog(err.message, 'error')
}
setInstalling(false)
const addNotif = (text, type) => {
setNotifications(prev => [{ text, type, id: Date.now(), time: new Date() }, ...prev])
}
const handleScan = async () => {
addLog('Scanning system...', 'info')
await api.runScan()
const data = await api.getTools()
onRescan(data.tools || [])
addLog('Scan complete.', 'ok')
}
const handleCheckUpdates = async () => {
const data = await api.getUpdates().catch(() => ({ updates: [] }))
const count = (data.updates || []).filter(u => u.needsUpdate).length
addLog(count > 0 ? `${count} updates available.` : 'All tools up to date.', count > 0 ? 'warn' : 'ok')
}
const addLog = (text, type) => setLog(prev => [...prev, { text, type, id: Date.now() }])
const sections = [
{ id: 'tools', label: t('dashboard.systemOverview') },
{ id: 'notifications', label: t('dashboard.activityLog') },
{ id: 'workflows', label: t('studio.workflows') },
]
return (
<div className="grid-2">
<div style={{ overflow: 'auto' }}>
<div className="card">
<div className="card-header">
System Overview &mdash; {installed}/{total} tools ({pct}%)
<div className="dashboard-layout">
<div className="dashboard-tabs">
{sections.map(s => (
<div
key={s.id}
className={`dashboard-tab ${activeSection === s.id ? 'active' : ''}`}
onClick={() => setActiveSection(s.id)}
>
{s.label}
{s.id === 'tools' && total > 0 && (
<span className="tab-count">{installed}/{total}</span>
)}
{s.id === 'notifications' && notifications.length > 0 && (
<span className="tab-count warn">{notifications.length}</span>
)}
</div>
<div className="progress" style={{ marginBottom: 16 }}>
<div className="progress-fill" style={{ width: `${pct}%` }} />
</div>
<div>
{tools.map((t, i) => {
const name = t.name || t.Name
const ver = extractVersion(t.Version || t.version)
return (
<div key={i} className="tool-row">
<span className={`badge ${t.installed ? 'ok' : 'error'}`}>
{t.installed ? 'Installed' : 'Missing'}
</span>
<span className="tool-name">{name}</span>
{ver && <span className="tool-version">{ver}</span>}
</div>
)
})}
</div>
</div>
))}
</div>
<div style={{ overflow: 'auto', display: 'flex', flexDirection: 'column', gap: 20 }}>
<div className="card">
<div className="card-header">Quick Actions</div>
<div className="actions-stack">
<button onClick={handleInstall} disabled={installing || missing.length === 0}>
{installing && <span className="spinner" />}
Install missing ({missing.length})
</button>
<button onClick={handleCheckUpdates}>Check for updates</button>
<button onClick={handleScan}>Rescan system</button>
<button onClick={() => api.configureMCP().then(() => addLog('MCP configured.', 'ok'))}>
Configure MCP
</button>
<div className="dashboard-content">
{activeSection === 'tools' && (
<div className="dashboard-tools">
{tools.length === 0 ? (
<div className="empty-state">{t('dashboard.noUpdateData')}</div>
) : (
<div className="tools-compact">
{tools.map((tool, i) => {
const name = tool.name || tool.Name
const ver = extractVersion(tool.Version || tool.version)
return (
<div key={i} className="tool-compact-row">
<span className={`badge sm ${tool.installed ? 'ok' : 'error'}`}>
{tool.installed ? '\u2713' : '\u2717'}
</span>
<span className="tool-compact-name">{name}</span>
{ver && <span className="tool-compact-ver">{ver}</span>}
{tool.installed && <span className="tool-compact-installed">{t('dashboard.installed')}</span>}
</div>
)
})}
</div>
)}
</div>
</div>
)}
<div className="card" style={{ flex: 1 }}>
<div className="card-header">Updates</div>
{updates.length === 0 ? (
<div className="empty-state">No update data yet.</div>
) : (
updates.map((u, i) => (
<div key={i} className="tool-row">
<span className={`badge ${u.needsUpdate ? 'warn' : 'ok'}`}>
{u.needsUpdate ? 'Update' : 'Latest'}
</span>
<span className="tool-name">{u.tool}</span>
{u.needsUpdate && (
<span style={{ color: 'var(--warning)', fontSize: 12, fontFamily: 'var(--font-mono)' }}>
{u.current} &rarr; {u.latest}
{activeSection === 'notifications' && (
<div className="dashboard-notifications">
{notifications.length === 0 ? (
<div className="empty-state">{t('dashboard.noUpdateData')}</div>
) : (
notifications.map(n => (
<div key={n.id} className={`notif-row notif-${n.type}`}>
<span className="notif-time">
{n.time.toLocaleTimeString(layout.locale, { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
</span>
)}
</div>
))
)}
</div>
<span className="notif-text">{n.text}</span>
</div>
))
)}
</div>
)}
{log.length > 0 && (
<div className="card">
<div className="card-header">Activity Log</div>
{log.map(entry => (
<div key={entry.id} style={{
fontSize: 12,
padding: '4px 0',
color: entry.type === 'error' ? 'var(--error)' :
entry.type === 'warn' ? 'var(--warning)' :
entry.type === 'ok' ? 'var(--success)' : 'var(--text-tertiary)',
}}>
{entry.text}
{activeSection === 'workflows' && (
<div className="dashboard-workflows">
<div className="workflow-section">
<div className="section-label">{t('studio.workflows')}</div>
<div className="empty-state" style={{ padding: 20 }}>
{t('studio.noWorkflow')}
</div>
))}
</div>
<div className="workflow-section">
<div className="section-label">{t('studio.activeAgents')}</div>
<div className="empty-state" style={{ padding: 20 }}>
{t('studio.noWorkflow')}
</div>
</div>
</div>
)}
</div>

View File

@@ -1,12 +1,14 @@
import { useState, useRef, useEffect } from 'react'
import { useI18n } from '../i18n'
export default function Shell({ api }) {
const { t } = useI18n()
const [history, setHistory] = useState([])
const [input, setInput] = useState('')
const [cwd, setCwd] = useState('~')
const [showAi, setShowAi] = useState(false)
const [aiMessages, setAiMessages] = useState([
{ role: 'ai', content: 'I know your system inside out. Ask me anything.' }
{ role: 'ai', content: t('shell.aiWelcome') }
])
const [aiInput, setAiInput] = useState('')
const [aiLoading, setAiLoading] = useState(false)
@@ -68,9 +70,9 @@ export default function Shell({ api }) {
try {
const res = await api.runCommand(`echo "AI: ${text}"`, '')
setAiMessages(prev => [...prev, { role: 'ai', content: res.output || 'No response' }])
setAiMessages(prev => [...prev, { role: 'ai', content: res.output || t('shell.noResponse') }])
} catch (err) {
setAiMessages(prev => [...prev, { role: 'ai', content: `Error: ${err.message}` }])
setAiMessages(prev => [...prev, { role: 'ai', content: `${t('shell.error')}: ${err.message}` }])
}
setAiLoading(false)
}
@@ -80,11 +82,11 @@ export default function Shell({ api }) {
<div className="terminal" style={{ flex: 1 }}>
<div className="panel-header">
<span className="panel-title">
Terminal
{t('shell.terminal')}
<span className="panel-subtitle">{cwd}</span>
</span>
<button className="ghost sm" onClick={() => setShowAi(!showAi)}>
{showAi ? 'Hide AI' : 'AI Assistant'}
{showAi ? t('shell.hideAi') : t('shell.aiAssistant')}
</button>
</div>
@@ -110,7 +112,7 @@ export default function Shell({ api }) {
{showAi && (
<div className="ai-panel">
<div className="ai-panel-header">AI Assistant</div>
<div className="ai-panel-header">{t('shell.aiAssistant')}</div>
<div className="ai-panel-messages">
{aiMessages.map((msg, i) => (
<div key={i} className={`ai-message ${msg.role}`}>
@@ -124,9 +126,9 @@ export default function Shell({ api }) {
value={aiInput}
onChange={e => setAiInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleAiSend()}
placeholder="Ask AI..."
placeholder={t('shell.askAi')}
/>
<button className="sm" onClick={handleAiSend} disabled={!aiInput.trim()}>Send</button>
<button className="sm" onClick={handleAiSend} disabled={!aiInput.trim()}>{t('shell.send')}</button>
</div>
</div>
)}

View File

@@ -1,9 +1,11 @@
import { useState, useRef, useEffect } from 'react'
import { useI18n } from '../i18n'
export default function Studio({ api }) {
const { t, layout } = useI18n()
const [messages, setMessages] = useState([
{ role: 'ai', content: 'Welcome to Studio! Chat with your AI assistant here.' },
{ role: 'ai', content: 'Configure agents and workflows from the sidebar.' },
{ role: 'ai', content: t('studio.welcome') },
{ role: 'ai', content: t('studio.configureHint') },
])
const [input, setInput] = useState('')
const [sidebarPanel, setSidebarPanel] = useState('chat')
@@ -23,10 +25,10 @@ export default function Studio({ api }) {
api.runCommand(`echo "AI response simulation for: ${text}"`, '')
.then(res => {
setMessages(prev => [...prev, { role: 'ai', content: res.output || res.error || 'No response' }])
setMessages(prev => [...prev, { role: 'ai', content: res.output || res.error || t('studio.noResponse') }])
})
.catch(err => {
setMessages(prev => [...prev, { role: 'ai', content: `Error: ${err.message}` }])
setMessages(prev => [...prev, { role: 'ai', content: `${t('studio.error')}: ${err.message}` }])
})
.finally(() => setLoading(false))
}
@@ -39,9 +41,9 @@ export default function Studio({ api }) {
}
const sidebarItems = [
{ id: 'chat', label: 'Chat', icon: '#' },
{ id: 'agents', label: 'Agents', icon: '*' },
{ id: 'workflows', label: 'Workflows', icon: '~' },
{ id: 'chat', label: t('studio.chat'), icon: '#' },
{ id: 'agents', label: t('studio.agents'), icon: '*' },
{ id: 'workflows', label: t('studio.workflows'), icon: '~' },
]
return (
@@ -49,7 +51,7 @@ export default function Studio({ api }) {
<div className="chat-layout" style={{ flex: 1, borderRight: '1px solid var(--border)' }}>
<div className="panel-header">
<span className="panel-title">
Chat
{t('studio.chat')}
{loading && <span className="spinner" />}
</span>
</div>
@@ -68,11 +70,11 @@ export default function Studio({ api }) {
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type a message... (Enter to send)"
placeholder={t('studio.placeholder')}
disabled={loading}
/>
<button className="primary" onClick={handleSend} disabled={loading || !input.trim()}>
Send
{t('studio.send')}
</button>
</div>
</div>
@@ -93,42 +95,42 @@ export default function Studio({ api }) {
{sidebarPanel === 'chat' && (
<div>
<div className="section-title">Commands</div>
<div className="section-title">{t('studio.commands')}</div>
<div style={{ fontSize: 12, fontFamily: 'var(--font-mono)', color: 'var(--text-tertiary)' }}>
/plan &lt;goal&gt;<br />
/help
{t('studio.planGoal')}<br />
{t('studio.help')}
</div>
</div>
)}
{sidebarPanel === 'agents' && (
<div>
<div className="section-title">Active Agents</div>
<div className="section-title">{t('studio.activeAgents')}</div>
<div className="agent-card">
<div className="agent-avatar">C</div>
<div>
<div className="agent-name">Crush</div>
<div className="agent-status">Stopped</div>
<div className="agent-name">{t('studio.crush')}</div>
<div className="agent-status">{t('studio.stopped')}</div>
</div>
<span className="badge neutral" style={{ marginLeft: 'auto' }}>Inactive</span>
<span className="badge neutral" style={{ marginLeft: 'auto' }}>{t('studio.inactive')}</span>
</div>
<div className="agent-card">
<div className="agent-avatar">CC</div>
<div>
<div className="agent-name">Claude Code</div>
<div className="agent-status">Stopped</div>
<div className="agent-name">{t('studio.claudeCode')}</div>
<div className="agent-status">{t('studio.stopped')}</div>
</div>
<span className="badge neutral" style={{ marginLeft: 'auto' }}>Inactive</span>
<span className="badge neutral" style={{ marginLeft: 'auto' }}>{t('studio.inactive')}</span>
</div>
</div>
)}
{sidebarPanel === 'workflows' && (
<div>
<div className="section-title">Workflows</div>
<div className="section-title">{t('studio.workflows')}</div>
<div className="empty-state">
No active workflow.
<span style={{ fontFamily: 'var(--font-mono)' }}>Use /plan &lt;goal&gt; in chat to start.</span>
{t('studio.noWorkflow')}
<span style={{ fontFamily: 'var(--font-mono)' }}>{t('studio.usePlan')}</span>
</div>
</div>
)}

105
web/src/i18n/en.js Normal file
View File

@@ -0,0 +1,105 @@
const en = {
tabs: {
dashboard: 'Dashboard',
studio: 'Studio',
shell: 'Shell',
config: 'Config',
},
header: {
toolsInstalled: '{count} tools installed',
updatesAvailable: 'Updates available',
upToDate: 'Up to date',
},
statusbar: {
switchWindow: 'Switch window',
sendMessage: 'Send message',
newLine: 'New line',
runCommand: 'Run command',
commandHistory: 'Command history',
},
dashboard: {
systemOverview: 'System Overview',
tools: 'tools',
installed: 'Installed',
missing: 'Missing',
quickActions: 'Quick Actions',
installMissing: 'Install missing',
checkUpdates: 'Check for updates',
rescanSystem: 'Rescan system',
configureMCP: 'Configure MCP',
updates: 'Updates',
update: 'Update',
latest: 'Latest',
activityLog: 'Activity Log',
noUpdateData: 'No update data yet.',
installing: 'Installing {count} tools...',
installStarted: 'Install started. Rescanning...',
done: 'Done.',
scanComplete: 'Scan complete.',
updatesCount: '{count} updates available.',
allUpToDate: 'All tools up to date.',
mcpConfigured: 'MCP configured.',
},
studio: {
welcome: 'Welcome to Studio! Chat with your AI assistant here.',
configureHint: 'Configure agents and workflows from the sidebar.',
chat: 'Chat',
agents: 'Agents',
workflows: 'Workflows',
placeholder: 'Type a message... (Enter to send)',
send: 'Send',
commands: 'Commands',
planGoal: '/plan <goal>',
help: '/help',
activeAgents: 'Active Agents',
crush: 'Crush',
claudeCode: 'Claude Code',
stopped: 'Stopped',
inactive: 'Inactive',
noWorkflow: 'No active workflow.',
usePlan: 'Use /plan <goal> in chat to start.',
noResponse: 'No response',
error: 'Error',
},
shell: {
terminal: 'Terminal',
hideAi: 'Hide AI',
aiAssistant: 'AI Assistant',
aiWelcome: 'I know your system inside out. Ask me anything.',
askAi: 'Ask AI...',
send: 'Send',
noResponse: 'No response',
error: 'Error',
},
config: {
profile: 'Profile',
name: 'Name',
pseudo: 'Pseudo',
email: 'Email',
editor: 'Editor',
shell: 'Shell',
defaultAi: 'Default AI',
languages: 'Languages',
loadingProfile: 'Loading profile...',
notSet: 'Not set',
aiProviders: 'AI Providers',
active: 'Active',
keyConfigured: 'Key configured',
noKey: 'No key',
theme: 'Theme',
skills: 'Skills',
noSkills: 'No skills installed.',
runSkillsInit: 'Run muyue skills init',
language: 'Language',
keyboardLayout: 'Keyboard Layout',
target: 'Target',
},
}
export default en

105
web/src/i18n/fr.js Normal file
View File

@@ -0,0 +1,105 @@
const fr = {
tabs: {
dashboard: 'Tableau de bord',
studio: 'Studio',
shell: 'Terminal',
config: 'Configuration',
},
header: {
toolsInstalled: '{count} outils install\u00e9s',
updatesAvailable: 'Mises \u00e0 jour disponibles',
upToDate: '\u00c0 jour',
},
statusbar: {
switchWindow: 'Changer de fen\u00eatre',
sendMessage: 'Envoyer le message',
newLine: 'Nouvelle ligne',
runCommand: 'Ex\u00e9cuter',
commandHistory: 'Historique',
},
dashboard: {
systemOverview: 'Vue d\u2019ensemble du syst\u00e8me',
tools: 'outils',
installed: 'Install\u00e9',
missing: 'Manquant',
quickActions: 'Actions rapides',
installMissing: 'Installer les manquants',
checkUpdates: 'V\u00e9rifier les mises \u00e0 jour',
rescanSystem: 'Rescanner le syst\u00e8me',
configureMCP: 'Configurer MCP',
updates: 'Mises \u00e0 jour',
update: 'Mise \u00e0 jour',
latest: '\u00c0 jour',
activityLog: 'Journal d\u2019activit\u00e9',
noUpdateData: 'Aucune donn\u00e9e de mise \u00e0 jour.',
installing: 'Installation de {count} outils...',
installStarted: 'Installation lanc\u00e9e. Rescan en cours...',
done: 'Termin\u00e9.',
scanComplete: 'Scan termin\u00e9.',
updatesCount: '{count} mises \u00e0 jour disponibles.',
allUpToDate: 'Tous les outils sont \u00e0 jour.',
mcpConfigured: 'MCP configur\u00e9.',
},
studio: {
welcome: 'Bienvenue dans Studio ! Discutez avec votre assistant IA ici.',
configureHint: 'Configurez les agents et workflows depuis la barre lat\u00e9rale.',
chat: 'Chat',
agents: 'Agents',
workflows: 'Workflows',
placeholder: 'Tapez un message... (Entr\u00e9e pour envoyer)',
send: 'Envoyer',
commands: 'Commandes',
planGoal: '/plan <objectif>',
help: '/help',
activeAgents: 'Agents actifs',
crush: 'Crush',
claudeCode: 'Claude Code',
stopped: 'Arr\u00eat\u00e9',
inactive: 'Inactif',
noWorkflow: 'Aucun workflow actif.',
usePlan: 'Utilisez /plan <objectif> dans le chat pour d\u00e9marrer.',
noResponse: 'Pas de r\u00e9ponse',
error: 'Erreur',
},
shell: {
terminal: 'Terminal',
hideAi: 'Masquer IA',
aiAssistant: 'Assistant IA',
aiWelcome: 'Je connais votre syst\u00e8me sur le bout des doigts. Demandez-moi n\u2019importe quoi.',
askAi: 'Demander \u00e0 l\u2019IA...',
send: 'Envoyer',
noResponse: 'Pas de r\u00e9ponse',
error: 'Erreur',
},
config: {
profile: 'Profil',
name: 'Nom',
pseudo: 'Pseudo',
email: 'Email',
editor: '\u00c9diteur',
shell: 'Shell',
defaultAi: 'IA par d\u00e9faut',
languages: 'Langages',
loadingProfile: 'Chargement du profil...',
notSet: 'Non d\u00e9fini',
aiProviders: 'Fournisseurs IA',
active: 'Actif',
keyConfigured: 'Cl\u00e9 configur\u00e9e',
noKey: 'Pas de cl\u00e9',
theme: 'Th\u00e8me',
skills: 'Comp\u00e9tences',
noSkills: 'Aucune comp\u00e9tence install\u00e9e.',
runSkillsInit: 'Ex\u00e9cutez muyue skills init',
language: 'Langue',
keyboardLayout: 'Disposition du clavier',
target: 'Cible',
},
}
export default fr

101
web/src/i18n/index.jsx Normal file
View File

@@ -0,0 +1,101 @@
import { createContext, useContext, useState, useCallback, useEffect, useMemo, useRef } from 'react'
import en from './en'
import fr from './fr'
import { getLayout, getLayoutList } from './keyboards'
import api from '../api/client'
const translations = { en, fr }
const STORAGE_KEY_LANG = 'muyue-language'
const STORAGE_KEY_KBD = 'muyue-keyboard'
const I18nContext = createContext(null)
function resolveLocale(layout) {
const l = getLayout(layout)
return l.locale
}
export function I18nProvider({ children }) {
const [language, setLanguageState] = useState(() => localStorage.getItem(STORAGE_KEY_LANG) || 'fr')
const [keyboard, setKeyboardState] = useState(() => localStorage.getItem(STORAGE_KEY_KBD) || 'azerty')
const [loaded, setLoaded] = useState(false)
const pendingSave = useRef(null)
useEffect(() => {
api.getConfig()
.then(d => {
const prefs = d.profile?.preferences
if (prefs?.language) setLanguageState(prefs.language)
if (prefs?.keyboard_layout) setKeyboardState(prefs.keyboard_layout)
})
.catch(() => {})
.finally(() => setLoaded(true))
}, [])
useEffect(() => {
if (!loaded) return
if (pendingSave.current) clearTimeout(pendingSave.current)
pendingSave.current = setTimeout(() => {
api.savePreferences({ language, keyboard_layout: keyboard }).catch(() => {})
}, 500)
return () => { if (pendingSave.current) clearTimeout(pendingSave.current) }
}, [language, keyboard, loaded])
const setLanguage = useCallback((lang) => {
setLanguageState(lang)
localStorage.setItem(STORAGE_KEY_LANG, lang)
}, [])
const setKeyboard = useCallback((kbd) => {
setKeyboardState(kbd)
localStorage.setItem(STORAGE_KEY_KBD, kbd)
}, [])
const layout = useMemo(() => getLayout(keyboard), [keyboard])
const t = useCallback((key, params) => {
const dict = translations[language] || translations.fr
const keys = key.split('.')
let value = dict
for (const k of keys) {
if (value == null) return key
value = value[k]
}
if (typeof value !== 'string') return key
if (params) {
return Object.entries(params).reduce((str, [k, v]) => str.replace(`{${k}}`, v), value)
}
return value
}, [language])
const clockLocale = useMemo(() => resolveLocale(keyboard), [keyboard])
const contextValue = useMemo(() => ({
language,
keyboard,
layout,
setLanguage,
setKeyboard,
t,
clockLocale,
layouts: getLayoutList(),
}), [language, keyboard, layout, t, clockLocale])
return (
<I18nContext.Provider value={contextValue}>
{children}
</I18nContext.Provider>
)
}
export function useI18n() {
const ctx = useContext(I18nContext)
if (!ctx) throw new Error('useI18n must be used within I18nProvider')
return ctx
}
export const LANGUAGES = [
{ id: 'fr', name: 'Fran\u00e7ais' },
{ id: 'en', name: 'English' },
]

61
web/src/i18n/keyboards.js Normal file
View File

@@ -0,0 +1,61 @@
export const LAYOUTS = {
qwerty: {
id: 'qwerty',
name: 'QWERTY',
locale: 'en-US',
keys: {
tab1: '1',
tab2: '2',
tab3: '3',
tab4: '4',
ctrl: 'Ctrl',
enter: 'Enter',
shift: 'Shift',
up: '\u2191',
down: '\u2193',
range: '1-4',
},
},
azerty: {
id: 'azerty',
name: 'AZERTY',
locale: 'fr-FR',
keys: {
tab1: '&',
tab2: '\u00e9',
tab3: '"',
tab4: "'",
ctrl: 'Ctrl',
enter: 'Entr\u00e9e',
shift: 'Maj',
up: '\u2191',
down: '\u2193',
range: '&-\u00e9-"-\'',
},
},
qwertz: {
id: 'qwertz',
name: 'QWERTZ',
locale: 'de-DE',
keys: {
tab1: '1',
tab2: '2',
tab3: '3',
tab4: '4',
ctrl: 'Strg',
enter: 'Enter',
shift: 'Umschalt',
up: '\u2191',
down: '\u2193',
range: '1-4',
},
},
}
export function getLayout(id) {
return LAYOUTS[id] || LAYOUTS.azerty
}
export function getLayoutList() {
return Object.values(LAYOUTS)
}

View File

@@ -1,10 +1,13 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { I18nProvider } from './i18n'
import './styles/global.css'
import App from './components/App'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
<I18nProvider>
<App />
</I18nProvider>
</React.StrictMode>
)

View File

@@ -168,6 +168,12 @@ input::placeholder { color: var(--text-disabled); }
color: var(--text-disabled);
}
.statusbar-left, .statusbar-right { display: flex; align-items: center; gap: 12px; }
.statusbar-shortcut { display: inline-flex; align-items: center; gap: 4px; }
.statusbar-shortcut kbd {
display: inline-block; padding: 1px 5px; border-radius: 3px;
background: var(--bg-card); border: 1px solid var(--border);
font-family: var(--font-mono); font-size: 10px; color: var(--text-tertiary);
}
.card {
background: var(--bg-card);
@@ -328,6 +334,60 @@ input::placeholder { color: var(--text-disabled); }
.empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 40px 20px; color: var(--text-disabled); font-size: 13px; text-align: center; gap: 8px; }
.dashboard-layout { display: flex; flex-direction: column; height: 100%; }
.dashboard-tabs {
display: flex; gap: 0; border-bottom: 1px solid var(--border);
background: var(--bg-surface); flex-shrink: 0;
}
.dashboard-tab {
padding: 10px 24px; font-size: 13px; font-weight: 600;
color: var(--text-tertiary); cursor: pointer; transition: all 0.15s;
display: flex; align-items: center; gap: 8px; border-bottom: 2px solid transparent;
user-select: none;
}
.dashboard-tab:hover { color: var(--text-primary); background: var(--bg-card); }
.dashboard-tab.active { color: var(--accent); border-bottom-color: var(--accent); }
.tab-count {
font-size: 10px; padding: 1px 6px; border-radius: 99px;
background: var(--bg-card); color: var(--text-tertiary); font-family: var(--font-mono);
}
.tab-count.warn { background: rgba(255,215,64,0.15); color: var(--warning); }
.dashboard-content { flex: 1; overflow-y: auto; }
.dashboard-tools { padding: 16px 24px; }
.tools-compact { display: flex; flex-direction: column; gap: 2px; }
.tool-compact-row {
display: flex; align-items: center; gap: 10px;
padding: 6px 12px; border-radius: var(--radius);
font-size: 13px; transition: background 0.1s;
}
.tool-compact-row:hover { background: var(--bg-card); }
.badge.sm { padding: 1px 5px; font-size: 10px; }
.tool-compact-name { color: var(--text-primary); font-weight: 500; flex: 1; }
.tool-compact-ver { color: var(--text-tertiary); font-size: 11px; font-family: var(--font-mono); }
.tool-compact-installed { color: var(--success); font-size: 11px; font-family: var(--font-mono); opacity: 0.7; }
.dashboard-notifications { padding: 16px 24px; }
.notif-row {
display: flex; align-items: flex-start; gap: 12px;
padding: 8px 12px; border-radius: var(--radius); margin-bottom: 4px;
}
.notif-row:hover { background: var(--bg-card); }
.notif-time { color: var(--text-disabled); font-size: 11px; font-family: var(--font-mono); flex-shrink: 0; padding-top: 1px; }
.notif-text { font-size: 13px; color: var(--text-secondary); }
.notif-info .notif-text { color: var(--info); }
.notif-ok .notif-text { color: var(--success); }
.notif-warn .notif-text { color: var(--warning); }
.notif-error .notif-text { color: var(--error); }
.dashboard-workflows { padding: 16px 24px; display: flex; flex-direction: column; gap: 24px; }
.workflow-section { }
.section-label {
font-size: 11px; font-weight: 700; color: var(--accent); text-transform: uppercase;
letter-spacing: 1px; margin-bottom: 12px; padding-bottom: 6px; border-bottom: 1px solid var(--border);
}
.panel-header {
display: flex; align-items: center; justify-content: space-between; padding: 10px 16px;
border-bottom: 1px solid var(--border); background: var(--bg-surface);