Compare commits
10 Commits
v0.7.2
...
v0.7.7-bet
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af5fbf9324 | ||
|
|
29953bde6d | ||
|
|
9d1d717999 | ||
|
|
d557b8e74c | ||
|
|
87e606c853 | ||
|
|
79e467c32a | ||
|
|
1ce5c49622 | ||
|
|
830e085c2a | ||
|
|
24b09f5700 | ||
|
|
1442b4fd8a |
@@ -68,17 +68,25 @@ jobs:
|
||||
echo "beta_num=${BETA_NUM}" >> $GITHUB_OUTPUT
|
||||
echo "Building beta release: ${VERSION}"
|
||||
|
||||
- name: Generate Windows resource (icon)
|
||||
run: |
|
||||
go install github.com/akavel/rsrc@latest
|
||||
RSRC="$(go env GOPATH)/bin/rsrc"
|
||||
$RSRC -ico assets/muyue.ico -arch amd64 -o cmd/muyue/rsrc_windows_amd64.syso
|
||||
$RSRC -ico assets/muyue.ico -arch arm64 -o cmd/muyue/rsrc_windows_arm64.syso
|
||||
|
||||
- name: Build (all platforms)
|
||||
run: |
|
||||
mkdir -p dist
|
||||
VERSION=${{ steps.version.outputs.version }}
|
||||
LDFLAGS="-s -w -X github.com/muyue/muyue/internal/version.Prerelease=${VERSION#v}"
|
||||
WIN_LDFLAGS="$LDFLAGS -H=windowsgui"
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="$LDFLAGS" -o dist/muyue-linux-amd64 ./cmd/muyue/
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="$LDFLAGS" -o dist/muyue-linux-arm64 ./cmd/muyue/
|
||||
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags="$LDFLAGS" -o dist/muyue-darwin-amd64 ./cmd/muyue/
|
||||
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags="$LDFLAGS" -o dist/muyue-darwin-arm64 ./cmd/muyue/
|
||||
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags="$LDFLAGS" -o dist/muyue-windows-amd64.exe ./cmd/muyue/
|
||||
CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -ldflags="$LDFLAGS" -o dist/muyue-windows-arm64.exe ./cmd/muyue/
|
||||
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags="$WIN_LDFLAGS" -o dist/muyue-windows-amd64.exe ./cmd/muyue/
|
||||
CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -ldflags="$WIN_LDFLAGS" -o dist/muyue-windows-arm64.exe ./cmd/muyue/
|
||||
|
||||
- name: Package archives
|
||||
run: |
|
||||
|
||||
@@ -64,16 +64,28 @@ jobs:
|
||||
echo "base=${BASE_VERSION}" >> $GITHUB_OUTPUT
|
||||
echo "Building stable release: ${VERSION}"
|
||||
|
||||
- name: Generate Windows resource (icon)
|
||||
run: |
|
||||
go install github.com/akavel/rsrc@latest
|
||||
RSRC="$(go env GOPATH)/bin/rsrc"
|
||||
$RSRC -ico assets/muyue.ico -arch amd64 -o cmd/muyue/rsrc_windows_amd64.syso
|
||||
$RSRC -ico assets/muyue.ico -arch arm64 -o cmd/muyue/rsrc_windows_arm64.syso
|
||||
|
||||
- name: Build (all platforms)
|
||||
run: |
|
||||
mkdir -p dist
|
||||
LDFLAGS="-s -w"
|
||||
# Windows builds use -H=windowsgui so the binary registers as a GUI
|
||||
# subsystem app: double-clicking from the Desktop shortcut does not
|
||||
# spawn a console window (and huh's "This is a command line tool"
|
||||
# banner can never appear).
|
||||
WIN_LDFLAGS="$LDFLAGS -H=windowsgui"
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="$LDFLAGS" -o dist/muyue-linux-amd64 ./cmd/muyue/
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="$LDFLAGS" -o dist/muyue-linux-arm64 ./cmd/muyue/
|
||||
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags="$LDFLAGS" -o dist/muyue-darwin-amd64 ./cmd/muyue/
|
||||
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags="$LDFLAGS" -o dist/muyue-darwin-arm64 ./cmd/muyue/
|
||||
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags="$LDFLAGS" -o dist/muyue-windows-amd64.exe ./cmd/muyue/
|
||||
CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -ldflags="$LDFLAGS" -o dist/muyue-windows-arm64.exe ./cmd/muyue/
|
||||
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags="$WIN_LDFLAGS" -o dist/muyue-windows-amd64.exe ./cmd/muyue/
|
||||
CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -ldflags="$WIN_LDFLAGS" -o dist/muyue-windows-arm64.exe ./cmd/muyue/
|
||||
|
||||
- name: Package archives
|
||||
run: |
|
||||
@@ -138,12 +150,17 @@ jobs:
|
||||
echo "sudo mv muyue-darwin-arm64 /usr/local/bin/muyue"
|
||||
echo "\`\`\`"
|
||||
echo ""
|
||||
echo "**Windows (x86_64)**"
|
||||
echo "**Windows (x86_64)** — sans privilèges admin, crée les raccourcis Bureau + Menu Démarrer + commande \`muyue\` dans la session courante :"
|
||||
echo "\`\`\`powershell"
|
||||
echo "Invoke-WebRequest -Uri \"${DL_URL}/muyue-windows-amd64.zip\" -OutFile \"muyue.zip\""
|
||||
echo "Expand-Archive -Path \"muyue.zip\" -DestinationPath \".\""
|
||||
echo "Move-Item muyue-windows-amd64.exe C:\\Windows\\muyue.exe"
|
||||
echo "Get-Process muyue, muyue-windows-amd64 -ErrorAction SilentlyContinue | Stop-Process -Force; Start-Sleep -Milliseconds 500"
|
||||
echo "\$dest = \"\$env:LOCALAPPDATA\\Muyue\"; New-Item -ItemType Directory -Force -Path \$dest | Out-Null"
|
||||
echo "Invoke-WebRequest -Uri \"${DL_URL}/muyue-windows-amd64.zip\" -OutFile \"\$env:TEMP\\muyue.zip\""
|
||||
echo "Expand-Archive -Path \"\$env:TEMP\\muyue.zip\" -DestinationPath \$dest -Force"
|
||||
echo "& \"\$dest\\muyue-windows-amd64.exe\" install-shortcuts"
|
||||
echo "\$env:Path += \";\$dest\""
|
||||
echo "\`\`\`"
|
||||
echo ""
|
||||
echo "Le 1ʳᵉ ligne tue toute instance Muyue déjà lancée (sinon Windows refuse d'écraser le \`.exe\` verrouillé et l'install échoue silencieusement). Si vous mettez à jour depuis une version précédente, c'est obligatoire."
|
||||
} > /tmp/stable_changelog.md
|
||||
echo "path=/tmp/stable_changelog.md" >> $GITHUB_OUTPUT
|
||||
|
||||
|
||||
1
.gitignore
vendored
@@ -24,6 +24,7 @@ Thumbs.db
|
||||
*.exe
|
||||
*.test
|
||||
*.out
|
||||
*.syso
|
||||
vendor/
|
||||
|
||||
# Config with secrets
|
||||
|
||||
137
CHANGELOG.md
@@ -4,50 +4,113 @@ 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.7.2
|
||||
## v0.7.7
|
||||
|
||||
### Changes since v0.7.0
|
||||
### Fix : install Windows échoue silencieusement quand une version précédente tourne
|
||||
|
||||
- feat(studio): force advanced reflection during browser-test sessions (v0.7.2) (a7d4b31)
|
||||
- fix(terminal/windows): fallback to pipes when PTY unsupported (v0.7.1) (fc7a5b9)
|
||||
Symptôme rapporté en mettant à jour de v0.7.5 → v0.7.6 : `Expand-Archive ... -Force` semble réussir mais le `.exe` n'est en réalité pas écrasé (Windows refuse de remplacer un fichier verrouillé), donc après l'install, `muyue` lance toujours l'ancienne version. Aucun message d'erreur visible — d'où le côté traître.
|
||||
|
||||
### Downloads
|
||||
**Fix** : ajout d'une 1ʳᵉ ligne au snippet d'install qui tue toute instance Muyue déjà lancée :
|
||||
|
||||
| Platform | File |
|
||||
|----------|------|
|
||||
| Linux x86_64 | [muyue-linux-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.2/muyue-linux-amd64.tar.gz) |
|
||||
| Linux ARM64 | [muyue-linux-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.2/muyue-linux-arm64.tar.gz) |
|
||||
| macOS Intel | [muyue-darwin-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.2/muyue-darwin-amd64.tar.gz) |
|
||||
| macOS Apple Silicon | [muyue-darwin-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.2/muyue-darwin-arm64.tar.gz) |
|
||||
| Windows x86_64 | [muyue-windows-amd64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.2/muyue-windows-amd64.zip) |
|
||||
| Windows ARM64 | [muyue-windows-arm64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.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.7.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.7.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.7.2/muyue-windows-amd64.zip" -OutFile "muyue.zip"
|
||||
Expand-Archive -Path "muyue.zip" -DestinationPath "."
|
||||
Move-Item muyue-windows-amd64.exe C:\Windows\muyue.exe
|
||||
Get-Process muyue, muyue-windows-amd64 -ErrorAction SilentlyContinue | Stop-Process -Force; Start-Sleep -Milliseconds 500
|
||||
```
|
||||
|
||||
`-ErrorAction SilentlyContinue` rend l'étape idempotente (pas d'erreur si rien ne tourne, cas d'install propre). Le `Start-Sleep` 500ms laisse Windows libérer le file handle. Le snippet officiel passe à 6 lignes ; une note explicative est ajoutée dans la section *Install* du changelog généré.
|
||||
|
||||
## v0.7.6
|
||||
|
||||
### Trois fixes Windows + une amélioration agent
|
||||
|
||||
#### Métriques dashboard à 0 sur Windows
|
||||
|
||||
Symptôme : CPU / RAM / Réseau toujours à 0 dans le panneau Dashboard sous Windows. Cause : `handleSystemMetrics` lisait exclusivement `/proc/stat`, `/proc/meminfo`, `/proc/net/dev` — fichiers absents sur Windows, donc `os.ReadFile` échouait silencieusement et la struct restait à zéro.
|
||||
|
||||
Split en fichiers `_unix.go` / `_windows.go` :
|
||||
- **`metrics_unix.go`** (`!windows`) : reprend tel quel le code `/proc/...` existant.
|
||||
- **`metrics_windows.go`** : appelle `kernel32!GetSystemTimes` (CPU, ratio idle/total entre deux samples) et `kernel32!GlobalMemoryStatusEx` (RAM totale + dispo). Pas de spawn PowerShell, ~50 µs par appel. Réseau à zéro pour l'instant — `MIB_IF_ROW2` est trop sensible aux versions de Windows pour faire ça à la main proprement (TODO à part).
|
||||
- `handleSystemMetrics` réduit à un appel à `collectSystemMetrics()`.
|
||||
|
||||
#### Terminal écran noir sur Windows
|
||||
|
||||
Symptôme : sous Windows native, le tab terminal ouvre la connexion mais l'écran reste noir, aucune sortie. Cause : `creack/pty/v2` retourne *"operating system not supported"* → fallback aux pipes. Pipes ne portent pas les signaux TTY, donc `cmd.exe` / `pwsh` / `wsl.exe` détectent l'absence de TTY et passent en mode silencieux ou attendent indéfiniment.
|
||||
|
||||
Implémentation **ConPTY** native via `kernel32!CreatePseudoConsole` (`internal/api/terminal_conpty_windows.go`) :
|
||||
- Probe runtime `canUseConPTY()` (cache la disponibilité — Windows 10 1809+ requis).
|
||||
- Crée un pseudo-console + 2 pipes anonymes, les passe au child via `STARTUPINFOEX` + `PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE` (utilise `windows.NewProcThreadAttributeList`).
|
||||
- `CreateProcessW` lance le shell avec le PC attaché → ANSI / cursor / line discipline marchent comme sur un vrai TTY.
|
||||
- `ResizePseudoConsole` câblé sur les events de redimensionnement xterm.
|
||||
- Fallback `pipeSession` conservé si `canUseConPTY()` est false (Windows < 1809) ou si `startConptySession` échoue.
|
||||
- Restructure des fichiers : `terminal_session.go` (interface + structs), `terminal_session_unix.go` (creack/pty), `terminal_session_windows.go` (ConPTY → pipe fallback), `terminal_conpty_windows.go` (impl).
|
||||
|
||||
#### Limite d'itérations d'outils agent
|
||||
|
||||
Symptôme : *"l'IA semble s'arrêter après 15 exécutions d'outils, je veux qu'elle puisse en faire 100, voire 1000"*. Cause : `MaxToolIterations = 15` dans `chat_engine.go`.
|
||||
|
||||
Bump : 15 → 500. Cap reste pour éviter les boucles infinies en cas de bug modèle, mais 500 itérations couvre largement les cas réels (refactor multi-fichiers, debug exploratoire). Documentation inline ajoutée pour expliquer pourquoi le cap existe et quand il faudrait s'inquiéter de le toucher.
|
||||
|
||||
## v0.7.5
|
||||
|
||||
### Fix Windows : commande `muyue` reconnue après install
|
||||
|
||||
Symptôme rapporté : après les commandes d'install, `muyue` retourne `n'est pas reconnu comme nom d'applet de commande`. Causes :
|
||||
- Le binaire extrait s'appelle `muyue-windows-amd64.exe` — taper `muyue` ne résoud pas
|
||||
- La PATH utilisateur a été mise à jour mais la session PowerShell courante n'en hérite que pour les NOUVEAUX processus
|
||||
|
||||
Corrections dans `install-shortcuts` :
|
||||
- **Copie canonique** : `muyue.exe` est créé à côté de `muyue-windows-amd64.exe` (copy, pas rename — le binaire en cours d'exécution est verrouillé sur Windows). Les raccourcis Bureau / Menu Démarrer ciblent désormais cette copie.
|
||||
- **Hint de session** : la commande imprime `$env:Path += ';...'` à coller pour activer `muyue` dans le shell courant sans rouvrir un terminal.
|
||||
|
||||
Snippet d'install passe à 5 lignes : la dernière (`$env:Path += ";$dest"`) rend la commande dispo immédiatement dans la session.
|
||||
|
||||
### Fix Windows : double-clic du raccourci fonctionne enfin
|
||||
|
||||
Symptôme rapporté : après installation, double-clic sur le raccourci Bureau → boîte de dialogue *"This is a command line tool. You need to open cmd.exe and run it from there."*. Cause : `charmbracelet/huh` (utilisé pour la TUI de premier lancement) détecte l'absence de TTY interactif quand le binaire est lancé via Explorer Windows et avorte avec ce message.
|
||||
|
||||
Double correctif :
|
||||
|
||||
1. **Skip de la TUI sans terminal interactif** (`cmd/muyue/commands/root.go::isInteractiveStdin`) — si `os.Stdin.Stat()` indique pas de `os.ModeCharDevice`, on saute `profiler.RunFirstTimeSetup` et on persiste un `config.Default()`. L'onboarding web (déjà existant) prend ensuite le relais dès l'ouverture du navigateur — aucune régression : avec un vrai terminal, la TUI continue de tourner comme avant.
|
||||
|
||||
2. **Build Windows en GUI subsystem** (`-H=windowsgui` ajouté aux Windows builds dans `ci-main.yml` et `ci-develop.yml`) — le binaire ne demande plus de console, donc plus aucun flash de fenêtre noire au double-clic.
|
||||
|
||||
Conséquence : les sous-commandes CLI (`muyue scan`, `muyue version`, `muyue install-shortcuts`) ne produiraient plus d'output quand lancées depuis cmd.exe. Mitigation : nouveau fichier `cmd/muyue/console_windows.go` qui appelle `kernel32!AttachConsole(ATTACH_PARENT_PROCESS)` au démarrage. Si un terminal parent existe, on s'y rattache et `os.Stdout` / `os.Stderr` / `os.Stdin` y sont rebindés ; sinon, on tourne silencieusement (cas double-clic). Compatible des deux usages sans deux binaires séparés.
|
||||
|
||||
## v0.7.4
|
||||
|
||||
### Logo Muyue intégré
|
||||
|
||||
- `LogoMuyue.png` ajouté à la racine + déclinaisons générées dans `assets/` (16/32/64/128/256/512 px) et `assets/muyue.ico` (multi-résolution 16-256 px).
|
||||
- **Binaire Windows** : icône embarquée comme ressource Windows via `github.com/akavel/rsrc` au build CI (génération de `cmd/muyue/rsrc_windows_{amd64,arm64}.syso`). Conséquences :
|
||||
- Explorateur Windows affiche l'icône Muyue sur le `.exe`
|
||||
- Les raccourcis créés par `install-shortcuts` héritent de l'icône (via `IconLocation = "$exe,0"`)
|
||||
- Aucune dépendance Go à runtime ; les `.syso` sont gitignorés et regénérés à chaque build
|
||||
- **UI web** : favicon réel (16/32 px), apple-touch-icon (256 px) et logo affiché dans le header à côté de "MUYUE".
|
||||
- Snippet d'install Windows : 1ʳᵉ ligne idempotente (`New-Item -ItemType Directory -Force`) pour gérer le cas d'une ré-exécution après install partielle.
|
||||
- Préservation du logo source en pleine résolution (912×950 RGBA) — pas de perte d'information.
|
||||
|
||||
## v0.7.3
|
||||
|
||||
### Onboarding — focus MiniMax + MiMo
|
||||
|
||||
- L'étape `apikey` du wizard de premier lancement propose désormais **les deux clés** (MiniMax + MiMo) côte à côte ; au moins une doit être validée pour continuer.
|
||||
- Les autres fournisseurs (OpenAI, Anthropic, Z.AI, Ollama) ne sont plus proposés dans le wizard — l'utilisateur les configure ensuite via l'onglet **Configuration** s'il le souhaite. Justification : pour les nouveaux utilisateurs, deux choix simples > six choix qui ralentissent le démarrage.
|
||||
- Si MiniMax est validé, il devient le provider actif. Sinon, c'est MiMo. Si les deux sont validés, MiniMax reste actif (peut être basculé via `/model change` plus tard).
|
||||
|
||||
### Install Windows — pas d'admin + raccourcis automatiques
|
||||
|
||||
- **Avant** : la 3ᵉ ligne du snippet d'install (`Move-Item ... C:\Windows\muyue.exe`) échouait avec `UnauthorizedAccessException` sur PowerShell sans élévation.
|
||||
- **Maintenant** : 4 lignes, toutes exécutables sans admin :
|
||||
```powershell
|
||||
$dest = "$env:LOCALAPPDATA\Muyue"
|
||||
Invoke-WebRequest -Uri ".../muyue-windows-amd64.zip" -OutFile "$env:TEMP\muyue.zip"
|
||||
Expand-Archive -Path "$env:TEMP\muyue.zip" -DestinationPath $dest -Force
|
||||
& "$dest\muyue-windows-amd64.exe" install-shortcuts
|
||||
```
|
||||
- Nouvelle commande `muyue install-shortcuts` (Windows uniquement) :
|
||||
- crée `Muyue.lnk` sur le Bureau et dans le Menu Démarrer (résolus via `[Environment]::GetFolderPath`, robuste OneDrive / profils non-standards) ;
|
||||
- utilise WScript.Shell COM via PowerShell pour générer les `.lnk` (pas de dépendance Go ajoutée) ;
|
||||
- ajoute le dossier d'install au `PATH` utilisateur (scope User, pas de modif système).
|
||||
- Une icône custom pourra être branchée plus tard en remplaçant la ressource embed du `.exe` ; pour l'instant, l'icône Windows par défaut du binaire est utilisée.
|
||||
|
||||
## v0.7.2
|
||||
|
||||
|
||||
BIN
LogoMuyue.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
assets/muyue-128.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
assets/muyue-16.png
Normal file
|
After Width: | Height: | Size: 750 B |
BIN
assets/muyue-256.png
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
assets/muyue-32.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
assets/muyue-512.png
Normal file
|
After Width: | Height: | Size: 307 KiB |
BIN
assets/muyue-64.png
Normal file
|
After Width: | Height: | Size: 6.0 KiB |
BIN
assets/muyue.ico
Normal file
|
After Width: | Height: | Size: 119 KiB |
189
cmd/muyue/commands/install_shortcuts.go
Normal file
@@ -0,0 +1,189 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// installShortcutsCmd creates desktop + Start Menu shortcuts on Windows so
|
||||
// non-technical users can launch Muyue without opening a terminal. It also
|
||||
// adds the install directory to the user's PATH (per-user, no admin).
|
||||
//
|
||||
// Implementation note: shortcut (.lnk) creation on Windows is most reliable
|
||||
// via WScript.Shell COM. We invoke it via PowerShell — keeps the Go binary
|
||||
// dependency-free and works on any Windows 10+ host.
|
||||
var installShortcutsCmd = &cobra.Command{
|
||||
Use: "install-shortcuts",
|
||||
Short: "Create Desktop + Start Menu shortcuts (Windows only) and add Muyue to PATH",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if runtime.GOOS != "windows" {
|
||||
fmt.Println("install-shortcuts is a Windows-only command (no-op on this platform)")
|
||||
return nil
|
||||
}
|
||||
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return fmt.Errorf("locate executable: %w", err)
|
||||
}
|
||||
exe, _ = filepath.Abs(exe)
|
||||
installDir := filepath.Dir(exe)
|
||||
|
||||
fmt.Println("Installing Muyue shortcuts...")
|
||||
fmt.Printf(" Source : %s\n", exe)
|
||||
|
||||
// Provide a clean `muyue.exe` next to the platform-suffixed binary so
|
||||
// users can type `muyue` once the install dir is on PATH. Copy (not
|
||||
// rename) because the running .exe is locked on Windows.
|
||||
canonicalExe := filepath.Join(installDir, "muyue.exe")
|
||||
if !strings.EqualFold(exe, canonicalExe) {
|
||||
if err := copyFile(exe, canonicalExe); err != nil {
|
||||
fmt.Fprintf(os.Stderr, " Copy : warning — could not create muyue.exe: %v\n", err)
|
||||
canonicalExe = exe
|
||||
} else {
|
||||
fmt.Printf(" Canonical : %s\n", canonicalExe)
|
||||
}
|
||||
}
|
||||
|
||||
desktop, err := userShellFolder("Desktop")
|
||||
if err != nil {
|
||||
return fmt.Errorf("locate Desktop folder: %w", err)
|
||||
}
|
||||
startMenu, err := userShellFolder("Programs")
|
||||
if err != nil {
|
||||
return fmt.Errorf("locate Start Menu Programs folder: %w", err)
|
||||
}
|
||||
|
||||
desktopLnk := filepath.Join(desktop, "Muyue.lnk")
|
||||
startLnk := filepath.Join(startMenu, "Muyue.lnk")
|
||||
|
||||
if err := createWindowsShortcut(desktopLnk, canonicalExe, installDir, "Muyue — AI-powered dev environment"); err != nil {
|
||||
return fmt.Errorf("create desktop shortcut: %w", err)
|
||||
}
|
||||
fmt.Printf(" Desktop : %s\n", desktopLnk)
|
||||
|
||||
if err := createWindowsShortcut(startLnk, canonicalExe, installDir, "Muyue — AI-powered dev environment"); err != nil {
|
||||
return fmt.Errorf("create Start Menu shortcut: %w", err)
|
||||
}
|
||||
fmt.Printf(" Start Menu : %s\n", startLnk)
|
||||
|
||||
if err := addUserPATH(installDir); err != nil {
|
||||
fmt.Fprintf(os.Stderr, " PATH : warning — could not add %s to user PATH: %v\n", installDir, err)
|
||||
} else {
|
||||
fmt.Printf(" PATH : added %s\n", installDir)
|
||||
}
|
||||
|
||||
fmt.Println("\nDone — double-click the Muyue icon on your Desktop to launch.")
|
||||
fmt.Println("\nTo use 'muyue' from this PowerShell session right now, run:")
|
||||
fmt.Printf(" $env:Path += ';%s'\n", installDir)
|
||||
fmt.Println("(New terminals will pick up the user PATH automatically.)")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// copyFile duplicates src to dst, overwriting an existing dst (used to drop a
|
||||
// `muyue.exe` next to the platform-suffixed binary so the command is callable
|
||||
// as `muyue` from PATH).
|
||||
func copyFile(src, dst string) error {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
out, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
if _, err := io.Copy(out, in); err != nil {
|
||||
return err
|
||||
}
|
||||
return out.Sync()
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(installShortcutsCmd)
|
||||
}
|
||||
|
||||
// userShellFolder asks Windows for a user shell folder via PowerShell —
|
||||
// resilient to OneDrive redirection and non-default profile locations.
|
||||
// `which` is one of: Desktop, Programs (Start Menu Programs), StartMenu.
|
||||
func userShellFolder(which string) (string, error) {
|
||||
ps := fmt.Sprintf(`[Environment]::GetFolderPath('%s')`, which)
|
||||
out, err := exec.Command("powershell", "-NoLogo", "-NoProfile", "-Command", ps).Output()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
path := stripTrailingWhitespace(string(out))
|
||||
if path == "" {
|
||||
return "", fmt.Errorf("empty path for %s", which)
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func stripTrailingWhitespace(s string) string {
|
||||
for len(s) > 0 && (s[len(s)-1] == '\n' || s[len(s)-1] == '\r' || s[len(s)-1] == ' ' || s[len(s)-1] == '\t') {
|
||||
s = s[:len(s)-1]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// createWindowsShortcut generates a .lnk via WScript.Shell COM. The arguments
|
||||
// are passed through PowerShell variables (not interpolated into the script
|
||||
// body) to avoid quoting issues with paths containing spaces or special chars.
|
||||
func createWindowsShortcut(lnkPath, target, workingDir, description string) error {
|
||||
script := `
|
||||
$lnk = $env:MUYUE_LNK
|
||||
$target = $env:MUYUE_TARGET
|
||||
$workdir = $env:MUYUE_WORKDIR
|
||||
$desc = $env:MUYUE_DESC
|
||||
$wsh = New-Object -ComObject WScript.Shell
|
||||
$sc = $wsh.CreateShortcut($lnk)
|
||||
$sc.TargetPath = $target
|
||||
$sc.WorkingDirectory = $workdir
|
||||
$sc.Description = $desc
|
||||
$sc.IconLocation = "$target,0"
|
||||
$sc.Save()
|
||||
`
|
||||
cmd := exec.Command("powershell", "-NoLogo", "-NoProfile", "-Command", script)
|
||||
cmd.Env = append(os.Environ(),
|
||||
"MUYUE_LNK="+lnkPath,
|
||||
"MUYUE_TARGET="+target,
|
||||
"MUYUE_WORKDIR="+workingDir,
|
||||
"MUYUE_DESC="+description,
|
||||
)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("powershell: %v: %s", err, string(out))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// addUserPATH appends installDir to the user's PATH if not already present.
|
||||
// Uses PowerShell to read/write the User-scope environment via .NET API,
|
||||
// which broadcasts WM_SETTINGCHANGE so new processes pick it up.
|
||||
func addUserPATH(installDir string) error {
|
||||
script := `
|
||||
$dir = $env:MUYUE_INSTALL_DIR
|
||||
$current = [Environment]::GetEnvironmentVariable('Path', 'User')
|
||||
if ($current -eq $null) { $current = '' }
|
||||
$parts = $current -split ';' | Where-Object { $_ -ne '' }
|
||||
if ($parts -notcontains $dir) {
|
||||
$new = if ($current -eq '') { $dir } else { "$current;$dir" }
|
||||
[Environment]::SetEnvironmentVariable('Path', $new, 'User')
|
||||
}
|
||||
`
|
||||
cmd := exec.Command("powershell", "-NoLogo", "-NoProfile", "-Command", script)
|
||||
cmd.Env = append(os.Environ(), "MUYUE_INSTALL_DIR="+installDir)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("powershell: %v: %s", err, string(out))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -24,30 +24,61 @@ func Execute() error {
|
||||
return rootCmd.Execute()
|
||||
}
|
||||
|
||||
// isInteractiveStdin reports whether os.Stdin is connected to a real terminal.
|
||||
// Used to decide between the TUI first-time setup (huh forms) and a no-op
|
||||
// fallback that defers onboarding to the web wizard. Returns false when the
|
||||
// binary is launched by a double-click on Windows (Explorer attaches a pseudo
|
||||
// console without a usable TTY) — which is the exact case where huh prints
|
||||
// "This is a command line tool. You need to open cmd.exe and run it from there."
|
||||
// and exits.
|
||||
func isInteractiveStdin() bool {
|
||||
stat, err := os.Stdin.Stat()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return (stat.Mode() & os.ModeCharDevice) != 0
|
||||
}
|
||||
|
||||
func loadOrSetupConfig() *config.MuyueConfig {
|
||||
if !config.Exists() {
|
||||
fmt.Println("First time setup detected!")
|
||||
cfg, err := profiler.RunFirstTimeSetup()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Setup error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
// No config yet. If we have a real terminal, run the rich TUI setup
|
||||
// (huh forms). Otherwise — typically when the user double-clicked the
|
||||
// shortcut on Windows — write defaults silently and let the React
|
||||
// onboarding wizard handle the real first-run flow once the browser
|
||||
// opens. This avoids huh aborting with "This is a command line tool".
|
||||
if isInteractiveStdin() {
|
||||
fmt.Println("First time setup detected!")
|
||||
cfg, err := profiler.RunFirstTimeSetup()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Setup error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
for i := range cfg.AI.Providers {
|
||||
if cfg.AI.Providers[i].Active && cfg.AI.Providers[i].APIKey == "" {
|
||||
key, err := profiler.AskAPIKey(cfg.AI.Providers[i].Name)
|
||||
if err == nil && key != "" {
|
||||
cfg.AI.Providers[i].APIKey = key
|
||||
for i := range cfg.AI.Providers {
|
||||
if cfg.AI.Providers[i].Active && cfg.AI.Providers[i].APIKey == "" {
|
||||
key, err := profiler.AskAPIKey(cfg.AI.Providers[i].Name)
|
||||
if err == nil && key != "" {
|
||||
cfg.AI.Providers[i].APIKey = key
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := config.Save(cfg); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Save error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Println("\nSetup complete! Starting muyue...")
|
||||
return cfg
|
||||
}
|
||||
|
||||
// Non-interactive — skip the TUI, persist defaults, web onboarding
|
||||
// will fill in the profile / API keys.
|
||||
cfg := config.Default()
|
||||
if err := config.Save(cfg); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Save error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Println("\nSetup complete! Starting muyue...")
|
||||
return cfg
|
||||
}
|
||||
|
||||
|
||||
54
cmd/muyue/console_windows.go
Normal file
@@ -0,0 +1,54 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
// Windows-only: with -H=windowsgui the binary is registered as a GUI
|
||||
// subsystem app, so double-clicking from the Desktop shortcut does NOT
|
||||
// spawn a console window (good for the desktop UX). The downside is that
|
||||
// sub-commands like `muyue scan`, `muyue version`, `muyue install-shortcuts`
|
||||
// produce no output when invoked from cmd.exe.
|
||||
//
|
||||
// Workaround: at process start, try to attach to the parent's console via
|
||||
// kernel32!AttachConsole(ATTACH_PARENT_PROCESS). If the parent has a console
|
||||
// (i.e. we were launched from cmd.exe / PowerShell), stdout/stderr/stdin are
|
||||
// rebound to it. If not (Explorer double-click), the call fails silently and
|
||||
// the binary runs without any console — exactly what we want.
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
const attachParentProcess = ^uint32(0) // -1 cast to DWORD
|
||||
|
||||
func init() {
|
||||
kernel32, err := syscall.LoadLibrary("kernel32.dll")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer syscall.FreeLibrary(kernel32)
|
||||
attachConsole, err := syscall.GetProcAddress(kernel32, "AttachConsole")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
r0, _, _ := syscall.SyscallN(attachConsole, uintptr(attachParentProcess))
|
||||
if r0 == 0 {
|
||||
return // parent has no console (Explorer launch) — stay silent
|
||||
}
|
||||
// Re-bind the standard streams to the freshly attached console so
|
||||
// fmt.Println / log output appear in the parent terminal.
|
||||
if h, err := syscall.GetStdHandle(syscall.STD_OUTPUT_HANDLE); err == nil && h != 0 {
|
||||
os.Stdout = os.NewFile(uintptr(h), "stdout")
|
||||
}
|
||||
if h, err := syscall.GetStdHandle(syscall.STD_ERROR_HANDLE); err == nil && h != 0 {
|
||||
os.Stderr = os.NewFile(uintptr(h), "stderr")
|
||||
}
|
||||
if h, err := syscall.GetStdHandle(syscall.STD_INPUT_HANDLE); err == nil && h != 0 {
|
||||
os.Stdin = os.NewFile(uintptr(h), "stdin")
|
||||
}
|
||||
// log.Default() captured the original os.Stderr at init time — repoint it
|
||||
// at the freshly attached console so log.Printf calls (e.g. desktop.Run)
|
||||
// surface in the parent terminal.
|
||||
log.SetOutput(os.Stderr)
|
||||
}
|
||||
@@ -10,9 +10,13 @@ import (
|
||||
"github.com/muyue/muyue/internal/orchestrator"
|
||||
)
|
||||
|
||||
const (
|
||||
MaxToolIterations = 15
|
||||
)
|
||||
// MaxToolIterations bounds the inner tool-call loop in RunWithTools /
|
||||
// RunNonStream. The cap exists only to avoid an infinite loop when a model
|
||||
// keeps calling tools forever; the value is intentionally generous so a
|
||||
// realistic agent run (multi-file refactor, exploratory debugging…) never
|
||||
// hits it. If you find yourself raising this to absurd values, look for a
|
||||
// loop bug in the model output instead.
|
||||
const MaxToolIterations = 500
|
||||
|
||||
// ToolLimiter checks if a tool call is allowed and returns a release function.
|
||||
type ToolLimiter func(toolName string) (release func(), err error)
|
||||
|
||||
@@ -756,93 +756,6 @@ var (
|
||||
)
|
||||
|
||||
func (s *Server) handleSystemMetrics(w http.ResponseWriter, r *http.Request) {
|
||||
m := sysMetrics{}
|
||||
|
||||
// CPU from /proc/stat
|
||||
if data, err := os.ReadFile("/proc/stat"); err == nil {
|
||||
line := strings.Split(string(data), "\n")[0]
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) >= 5 {
|
||||
var idle, total float64
|
||||
for i := 1; i < len(fields) && i <= 4; i++ {
|
||||
var v float64
|
||||
fmt.Sscanf(fields[i], "%f", &v)
|
||||
total += v
|
||||
if i == 4 {
|
||||
idle = v
|
||||
}
|
||||
}
|
||||
if lastCPUSet {
|
||||
dIdle := idle - lastCPU[0]
|
||||
dTotal := total - lastCPU[1]
|
||||
if dTotal > 0 {
|
||||
m.CPUPercent = (1 - dIdle/dTotal) * 100
|
||||
}
|
||||
}
|
||||
lastCPU = [2]float64{idle, total}
|
||||
lastCPUSet = true
|
||||
}
|
||||
}
|
||||
|
||||
// Memory from /proc/meminfo
|
||||
if data, err := os.ReadFile("/proc/meminfo"); err == nil {
|
||||
var memTotal, memAvailable float64
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 2 {
|
||||
continue
|
||||
}
|
||||
var v float64
|
||||
fmt.Sscanf(fields[1], "%f", &v)
|
||||
switch fields[0] {
|
||||
case "MemTotal:":
|
||||
memTotal = v
|
||||
case "MemAvailable:":
|
||||
memAvailable = v
|
||||
}
|
||||
}
|
||||
if memTotal > 0 {
|
||||
m.MemTotalMB = memTotal / 1024
|
||||
m.MemUsedMB = (memTotal - memAvailable) / 1024
|
||||
m.MemPercent = (memTotal - memAvailable) / memTotal * 100
|
||||
}
|
||||
}
|
||||
|
||||
// Network from /proc/net/dev
|
||||
if data, err := os.ReadFile("/proc/net/dev"); err == nil {
|
||||
var rxBytes, txBytes float64
|
||||
for _, line := range strings.Split(string(data), "\n")[2:] {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 10 {
|
||||
continue
|
||||
}
|
||||
iface := strings.TrimSuffix(fields[0], ":")
|
||||
if iface == "lo" {
|
||||
continue
|
||||
}
|
||||
var rx, tx float64
|
||||
fmt.Sscanf(fields[1], "%f", &rx)
|
||||
fmt.Sscanf(fields[9], "%f", &tx)
|
||||
rxBytes += rx
|
||||
txBytes += tx
|
||||
}
|
||||
now := time.Now()
|
||||
if !lastNetTs.IsZero() {
|
||||
elapsed := now.Sub(lastNetTs).Seconds()
|
||||
if elapsed > 0 {
|
||||
m.NetRxKBs = (rxBytes - lastNet[0]) / 1024 / elapsed
|
||||
m.NetTxKBs = (txBytes - lastNet[1]) / 1024 / elapsed
|
||||
if m.NetRxKBs < 0 {
|
||||
m.NetRxKBs = 0
|
||||
}
|
||||
if m.NetTxKBs < 0 {
|
||||
m.NetTxKBs = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
lastNet = [2]float64{rxBytes, txBytes}
|
||||
lastNetTs = now
|
||||
}
|
||||
|
||||
m := collectSystemMetrics()
|
||||
writeJSON(w, m)
|
||||
}
|
||||
|
||||
106
internal/api/metrics_unix.go
Normal file
@@ -0,0 +1,106 @@
|
||||
//go:build !windows
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// collectSystemMetrics reads /proc on Linux. On macOS / BSD this returns
|
||||
// zeroes for files that don't exist — the dashboard panel renders blanks
|
||||
// rather than crashing. macOS-specific metrics could be added later via
|
||||
// `vm_stat` / `iostat` parsing.
|
||||
func collectSystemMetrics() sysMetrics {
|
||||
m := sysMetrics{}
|
||||
|
||||
// CPU from /proc/stat
|
||||
if data, err := os.ReadFile("/proc/stat"); err == nil {
|
||||
line := strings.Split(string(data), "\n")[0]
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) >= 5 {
|
||||
var idle, total float64
|
||||
for i := 1; i < len(fields) && i <= 4; i++ {
|
||||
var v float64
|
||||
fmt.Sscanf(fields[i], "%f", &v)
|
||||
total += v
|
||||
if i == 4 {
|
||||
idle = v
|
||||
}
|
||||
}
|
||||
if lastCPUSet {
|
||||
dIdle := idle - lastCPU[0]
|
||||
dTotal := total - lastCPU[1]
|
||||
if dTotal > 0 {
|
||||
m.CPUPercent = (1 - dIdle/dTotal) * 100
|
||||
}
|
||||
}
|
||||
lastCPU = [2]float64{idle, total}
|
||||
lastCPUSet = true
|
||||
}
|
||||
}
|
||||
|
||||
// Memory from /proc/meminfo
|
||||
if data, err := os.ReadFile("/proc/meminfo"); err == nil {
|
||||
var memTotal, memAvailable float64
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 2 {
|
||||
continue
|
||||
}
|
||||
var v float64
|
||||
fmt.Sscanf(fields[1], "%f", &v)
|
||||
switch fields[0] {
|
||||
case "MemTotal:":
|
||||
memTotal = v
|
||||
case "MemAvailable:":
|
||||
memAvailable = v
|
||||
}
|
||||
}
|
||||
if memTotal > 0 {
|
||||
m.MemTotalMB = memTotal / 1024
|
||||
m.MemUsedMB = (memTotal - memAvailable) / 1024
|
||||
m.MemPercent = (memTotal - memAvailable) / memTotal * 100
|
||||
}
|
||||
}
|
||||
|
||||
// Network from /proc/net/dev
|
||||
if data, err := os.ReadFile("/proc/net/dev"); err == nil {
|
||||
var rxBytes, txBytes float64
|
||||
for _, line := range strings.Split(string(data), "\n")[2:] {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 10 {
|
||||
continue
|
||||
}
|
||||
iface := strings.TrimSuffix(fields[0], ":")
|
||||
if iface == "lo" {
|
||||
continue
|
||||
}
|
||||
var rx, tx float64
|
||||
fmt.Sscanf(fields[1], "%f", &rx)
|
||||
fmt.Sscanf(fields[9], "%f", &tx)
|
||||
rxBytes += rx
|
||||
txBytes += tx
|
||||
}
|
||||
now := time.Now()
|
||||
if !lastNetTs.IsZero() {
|
||||
elapsed := now.Sub(lastNetTs).Seconds()
|
||||
if elapsed > 0 {
|
||||
m.NetRxKBs = (rxBytes - lastNet[0]) / 1024 / elapsed
|
||||
m.NetTxKBs = (txBytes - lastNet[1]) / 1024 / elapsed
|
||||
if m.NetRxKBs < 0 {
|
||||
m.NetRxKBs = 0
|
||||
}
|
||||
if m.NetTxKBs < 0 {
|
||||
m.NetTxKBs = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
lastNet = [2]float64{rxBytes, txBytes}
|
||||
lastNetTs = now
|
||||
}
|
||||
|
||||
return m
|
||||
}
|
||||
129
internal/api/metrics_windows.go
Normal file
@@ -0,0 +1,129 @@
|
||||
//go:build windows
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// collectSystemMetrics reads CPU% and memory from kernel32 directly.
|
||||
// Network throughput on Windows is left at zero for now — the iphlpapi
|
||||
// MIB_IF_ROW2 layout is large and version-sensitive; reliable net stats
|
||||
// would warrant a separate, well-tested implementation. CPU + RAM are
|
||||
// enough for the dashboard's main signal.
|
||||
func collectSystemMetrics() sysMetrics {
|
||||
m := sysMetrics{}
|
||||
|
||||
if cpu, ok := readWindowsCPUPercent(); ok {
|
||||
m.CPUPercent = cpu
|
||||
}
|
||||
if memTotalMB, memUsedMB, memPct, ok := readWindowsMemory(); ok {
|
||||
m.MemTotalMB = memTotalMB
|
||||
m.MemUsedMB = memUsedMB
|
||||
m.MemPercent = memPct
|
||||
}
|
||||
// Net: zero (TODO).
|
||||
return m
|
||||
}
|
||||
|
||||
// --- CPU ---------------------------------------------------------------
|
||||
|
||||
var (
|
||||
cpuOnce sync.Once
|
||||
getSystemTimes *syscall.LazyProc
|
||||
lastWinCPUIdle uint64
|
||||
lastWinCPUTotal uint64
|
||||
lastWinCPUSet bool
|
||||
winCPUMu sync.Mutex
|
||||
)
|
||||
|
||||
func loadCPUFns() {
|
||||
cpuOnce.Do(func() {
|
||||
k := syscall.NewLazyDLL("kernel32.dll")
|
||||
getSystemTimes = k.NewProc("GetSystemTimes")
|
||||
})
|
||||
}
|
||||
|
||||
func filetimeToUint64(low, high uint32) uint64 {
|
||||
return uint64(high)<<32 | uint64(low)
|
||||
}
|
||||
|
||||
// readWindowsCPUPercent samples GetSystemTimes twice and computes the busy
|
||||
// ratio as 1 - dIdle / (dKernel + dUser). The first call returns 0% and
|
||||
// stores the baseline; subsequent calls return the delta-based percentage.
|
||||
func readWindowsCPUPercent() (float64, bool) {
|
||||
loadCPUFns()
|
||||
if getSystemTimes == nil {
|
||||
return 0, false
|
||||
}
|
||||
var idle, kernel, user windows.Filetime
|
||||
r1, _, _ := getSystemTimes.Call(
|
||||
uintptr(unsafe.Pointer(&idle)),
|
||||
uintptr(unsafe.Pointer(&kernel)),
|
||||
uintptr(unsafe.Pointer(&user)),
|
||||
)
|
||||
if r1 == 0 {
|
||||
return 0, false
|
||||
}
|
||||
idleT := filetimeToUint64(idle.LowDateTime, idle.HighDateTime)
|
||||
totalT := filetimeToUint64(kernel.LowDateTime, kernel.HighDateTime) +
|
||||
filetimeToUint64(user.LowDateTime, user.HighDateTime)
|
||||
winCPUMu.Lock()
|
||||
defer winCPUMu.Unlock()
|
||||
if !lastWinCPUSet {
|
||||
lastWinCPUIdle = idleT
|
||||
lastWinCPUTotal = totalT
|
||||
lastWinCPUSet = true
|
||||
return 0, true
|
||||
}
|
||||
dIdle := idleT - lastWinCPUIdle
|
||||
dTotal := totalT - lastWinCPUTotal
|
||||
lastWinCPUIdle = idleT
|
||||
lastWinCPUTotal = totalT
|
||||
if dTotal == 0 {
|
||||
return 0, true
|
||||
}
|
||||
pct := (1 - float64(dIdle)/float64(dTotal)) * 100
|
||||
if pct < 0 {
|
||||
pct = 0
|
||||
} else if pct > 100 {
|
||||
pct = 100
|
||||
}
|
||||
return pct, true
|
||||
}
|
||||
|
||||
// --- Memory ------------------------------------------------------------
|
||||
|
||||
type memoryStatusEx struct {
|
||||
Length uint32
|
||||
MemoryLoad uint32
|
||||
TotalPhys uint64
|
||||
AvailPhys uint64
|
||||
TotalPageFile uint64
|
||||
AvailPageFile uint64
|
||||
TotalVirtual uint64
|
||||
AvailVirtual uint64
|
||||
AvailExtendedVirtual uint64
|
||||
}
|
||||
|
||||
var globalMemoryStatusEx = syscall.NewLazyDLL("kernel32.dll").NewProc("GlobalMemoryStatusEx")
|
||||
|
||||
func readWindowsMemory() (totalMB, usedMB, percent float64, ok bool) {
|
||||
var ms memoryStatusEx
|
||||
ms.Length = uint32(unsafe.Sizeof(ms))
|
||||
r1, _, _ := globalMemoryStatusEx.Call(uintptr(unsafe.Pointer(&ms)))
|
||||
if r1 == 0 {
|
||||
return 0, 0, 0, false
|
||||
}
|
||||
const mb = 1024 * 1024
|
||||
totalMB = float64(ms.TotalPhys) / mb
|
||||
usedMB = float64(ms.TotalPhys-ms.AvailPhys) / mb
|
||||
if ms.TotalPhys > 0 {
|
||||
percent = float64(ms.TotalPhys-ms.AvailPhys) * 100 / float64(ms.TotalPhys)
|
||||
}
|
||||
return totalMB, usedMB, percent, true
|
||||
}
|
||||
265
internal/api/terminal_conpty_windows.go
Normal file
@@ -0,0 +1,265 @@
|
||||
//go:build windows
|
||||
|
||||
package api
|
||||
|
||||
// Windows ConPTY (Pseudo Console) backend for the terminal tab.
|
||||
//
|
||||
// creack/pty/v2 returns "operating system not supported" on Windows, so the
|
||||
// previous fallback was plain stdin/stdout pipes (terminal_session.go::
|
||||
// pipeSession). Pipes don't carry TTY signals, so cmd.exe / pwsh / wsl
|
||||
// detect "no TTY" and either go silent or wait forever — the user sees a
|
||||
// black screen. This file implements a real pseudo console using the
|
||||
// kernel32 ConPTY API, so the spawned shell behaves as if it were attached
|
||||
// to a real terminal: prompts render, ANSI escapes are honoured, resize
|
||||
// events propagate.
|
||||
//
|
||||
// Requires Windows 10 v1809 (build 17763) or newer. On older hosts
|
||||
// CreatePseudoConsole returns an error and startTermSession_windows falls
|
||||
// back to pipeSession.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os/exec"
|
||||
"sync"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
const (
|
||||
procThreadAttributePseudoconsole = 0x00020016
|
||||
extendedStartupinfoPresent = 0x00080000
|
||||
createUnicodeEnvironment = 0x00000400
|
||||
)
|
||||
|
||||
// conptySession drives a Windows pseudo console.
|
||||
type conptySession struct {
|
||||
hPC windows.Handle
|
||||
inWrite windows.Handle
|
||||
outRead windows.Handle
|
||||
procInfo windows.ProcessInformation
|
||||
closed bool
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// startConptySession spins up the pseudo console, plumbs the pipes, and
|
||||
// CreateProcessW's the child with the PC attached via STARTUPINFOEX.
|
||||
func startConptySession(cmd *exec.Cmd) (termSession, error) {
|
||||
// 1. Two pipe pairs: in (we write → child stdin) and out (child stdout → we read).
|
||||
var inRead, inWrite, outRead, outWrite windows.Handle
|
||||
if err := windows.CreatePipe(&inRead, &inWrite, nil, 0); err != nil {
|
||||
return nil, fmt.Errorf("create stdin pipe: %w", err)
|
||||
}
|
||||
if err := windows.CreatePipe(&outRead, &outWrite, nil, 0); err != nil {
|
||||
windows.CloseHandle(inRead)
|
||||
windows.CloseHandle(inWrite)
|
||||
return nil, fmt.Errorf("create stdout pipe: %w", err)
|
||||
}
|
||||
|
||||
// 2. Create the pseudo console. After this call ConPTY effectively owns
|
||||
// the child-facing pipe ends (inRead, outWrite); we close our copy.
|
||||
var hPC windows.Handle
|
||||
sz := windows.Coord{X: 120, Y: 30}
|
||||
if err := windows.CreatePseudoConsole(sz, inRead, outWrite, 0, &hPC); err != nil {
|
||||
windows.CloseHandle(inRead)
|
||||
windows.CloseHandle(inWrite)
|
||||
windows.CloseHandle(outRead)
|
||||
windows.CloseHandle(outWrite)
|
||||
return nil, fmt.Errorf("CreatePseudoConsole: %w", err)
|
||||
}
|
||||
windows.CloseHandle(inRead)
|
||||
windows.CloseHandle(outWrite)
|
||||
|
||||
// 3. Allocate an attribute list with one slot for the PC attribute.
|
||||
attrList, err := windows.NewProcThreadAttributeList(1)
|
||||
if err != nil {
|
||||
windows.ClosePseudoConsole(hPC)
|
||||
windows.CloseHandle(inWrite)
|
||||
windows.CloseHandle(outRead)
|
||||
return nil, fmt.Errorf("NewProcThreadAttributeList: %w", err)
|
||||
}
|
||||
if err := attrList.Update(
|
||||
procThreadAttributePseudoconsole,
|
||||
unsafe.Pointer(&hPC),
|
||||
unsafe.Sizeof(hPC),
|
||||
); err != nil {
|
||||
attrList.Delete()
|
||||
windows.ClosePseudoConsole(hPC)
|
||||
windows.CloseHandle(inWrite)
|
||||
windows.CloseHandle(outRead)
|
||||
return nil, fmt.Errorf("attrList.Update: %w", err)
|
||||
}
|
||||
|
||||
// 4. Build command line.
|
||||
cmdLine, err := buildCommandLine(cmd)
|
||||
if err != nil {
|
||||
attrList.Delete()
|
||||
windows.ClosePseudoConsole(hPC)
|
||||
windows.CloseHandle(inWrite)
|
||||
windows.CloseHandle(outRead)
|
||||
return nil, err
|
||||
}
|
||||
cmdLineUTF16, err := windows.UTF16PtrFromString(cmdLine)
|
||||
if err != nil {
|
||||
attrList.Delete()
|
||||
windows.ClosePseudoConsole(hPC)
|
||||
windows.CloseHandle(inWrite)
|
||||
windows.CloseHandle(outRead)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 5. Build the env block (key=value\0...\0\0).
|
||||
var envBlock *uint16
|
||||
if cmd.Env != nil {
|
||||
eb, err := makeEnvBlock(cmd.Env)
|
||||
if err != nil {
|
||||
attrList.Delete()
|
||||
windows.ClosePseudoConsole(hPC)
|
||||
windows.CloseHandle(inWrite)
|
||||
windows.CloseHandle(outRead)
|
||||
return nil, err
|
||||
}
|
||||
envBlock = eb
|
||||
}
|
||||
|
||||
si := windows.StartupInfoEx{}
|
||||
si.StartupInfo.Cb = uint32(unsafe.Sizeof(si))
|
||||
si.ProcThreadAttributeList = attrList.List()
|
||||
|
||||
flags := uint32(extendedStartupinfoPresent)
|
||||
if envBlock != nil {
|
||||
flags |= createUnicodeEnvironment
|
||||
}
|
||||
|
||||
var pi windows.ProcessInformation
|
||||
err = windows.CreateProcess(
|
||||
nil, // application name (null = parse from cmdline)
|
||||
cmdLineUTF16,
|
||||
nil, // process security attrs
|
||||
nil, // thread security attrs
|
||||
false, // inherit handles (ConPTY hands handles via attribute list)
|
||||
flags,
|
||||
envBlock,
|
||||
nil, // working dir
|
||||
&si.StartupInfo,
|
||||
&pi,
|
||||
)
|
||||
attrList.Delete()
|
||||
if err != nil {
|
||||
windows.ClosePseudoConsole(hPC)
|
||||
windows.CloseHandle(inWrite)
|
||||
windows.CloseHandle(outRead)
|
||||
return nil, fmt.Errorf("CreateProcess: %w", err)
|
||||
}
|
||||
|
||||
return &conptySession{
|
||||
hPC: hPC,
|
||||
inWrite: inWrite,
|
||||
outRead: outRead,
|
||||
procInfo: pi,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *conptySession) Read(p []byte) (int, error) {
|
||||
var n uint32
|
||||
err := windows.ReadFile(s.outRead, p, &n, nil)
|
||||
if err != nil {
|
||||
if n > 0 {
|
||||
return int(n), nil
|
||||
}
|
||||
return 0, io.EOF
|
||||
}
|
||||
return int(n), nil
|
||||
}
|
||||
|
||||
func (s *conptySession) Write(p []byte) (int, error) {
|
||||
var n uint32
|
||||
err := windows.WriteFile(s.inWrite, p, &n, nil)
|
||||
if err != nil {
|
||||
return int(n), err
|
||||
}
|
||||
return int(n), nil
|
||||
}
|
||||
|
||||
func (s *conptySession) Resize(rows, cols uint16) error {
|
||||
return windows.ResizePseudoConsole(s.hPC, windows.Coord{X: int16(cols), Y: int16(rows)})
|
||||
}
|
||||
|
||||
func (s *conptySession) Close() error {
|
||||
s.mu.Lock()
|
||||
if s.closed {
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
s.closed = true
|
||||
s.mu.Unlock()
|
||||
|
||||
// Order matters: close the pseudo console first so the child sees EOF,
|
||||
// then close our pipe ends, then terminate / close handles.
|
||||
windows.ClosePseudoConsole(s.hPC)
|
||||
windows.CloseHandle(s.inWrite)
|
||||
windows.CloseHandle(s.outRead)
|
||||
if s.procInfo.Process != 0 {
|
||||
windows.TerminateProcess(s.procInfo.Process, 0)
|
||||
windows.CloseHandle(s.procInfo.Process)
|
||||
}
|
||||
if s.procInfo.Thread != 0 {
|
||||
windows.CloseHandle(s.procInfo.Thread)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *conptySession) Wait() error {
|
||||
if s.procInfo.Process == 0 {
|
||||
return nil
|
||||
}
|
||||
_, err := windows.WaitForSingleObject(s.procInfo.Process, windows.INFINITE)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *conptySession) Pid() int {
|
||||
return int(s.procInfo.ProcessId)
|
||||
}
|
||||
|
||||
// --- helpers -----------------------------------------------------------
|
||||
|
||||
// buildCommandLine produces the Windows command-line string for an
|
||||
// *exec.Cmd, mirroring what os/exec uses internally (escaping spaces and
|
||||
// quotes per Windows convention).
|
||||
func buildCommandLine(cmd *exec.Cmd) (string, error) {
|
||||
if cmd.Path == "" {
|
||||
return "", fmt.Errorf("empty cmd.Path")
|
||||
}
|
||||
parts := []string{cmd.Path}
|
||||
if len(cmd.Args) > 1 {
|
||||
parts = append(parts, cmd.Args[1:]...)
|
||||
}
|
||||
out := syscall.EscapeArg(parts[0])
|
||||
for _, a := range parts[1:] {
|
||||
out += " " + syscall.EscapeArg(a)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// makeEnvBlock packs a Go environ slice into the Windows UTF-16 env block
|
||||
// format: key=value\0key=value\0\0.
|
||||
func makeEnvBlock(env []string) (*uint16, error) {
|
||||
var buf []uint16
|
||||
for _, kv := range env {
|
||||
s, err := syscall.UTF16FromString(kv)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
buf = append(buf, s...) // includes trailing NUL
|
||||
}
|
||||
buf = append(buf, 0) // final terminator
|
||||
if len(buf) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return &buf[0], nil
|
||||
}
|
||||
|
||||
// Compile-time interface assertion.
|
||||
var _ termSession = (*conptySession)(nil)
|
||||
@@ -2,19 +2,22 @@ package api
|
||||
|
||||
// Cross-platform terminal session abstraction.
|
||||
//
|
||||
// On Linux / macOS we have a real PTY via creack/pty: full TTY semantics,
|
||||
// resize support, interactive apps (vim, top…) work. On Windows the same
|
||||
// package returns "operating system not supported" at pty.Start time, so we
|
||||
// fall back to plain pipes (stdin / stdout merged with stderr). Pipes don't
|
||||
// give a real TTY — interactive TUIs misbehave — but `wsl`, `pwsh`, `cmd`,
|
||||
// and most CLI tools emit usable line-buffered output, which is what the
|
||||
// user actually clicks for.
|
||||
// On Linux / macOS the unix-tagged file (terminal_session_unix.go) wires
|
||||
// startTermSession to creack/pty for a real PTY: full TTY semantics,
|
||||
// resize support, interactive apps (vim, top…) work.
|
||||
//
|
||||
// On Windows the windows-tagged file (terminal_session_windows.go) tries
|
||||
// the kernel32 ConPTY API first, with a pipe-based fallback for older
|
||||
// hosts. pipeSession does NOT carry TTY signals, so most shells go silent
|
||||
// — it's only kept as a last resort.
|
||||
//
|
||||
// Both platforms share the termSession interface, the ptySession type
|
||||
// (used by unix), and the pipeSession type (used by the Windows fallback).
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"sync"
|
||||
|
||||
"github.com/creack/pty/v2"
|
||||
@@ -30,22 +33,7 @@ type termSession interface {
|
||||
Pid() int
|
||||
}
|
||||
|
||||
// startTermSession tries a real PTY first; on Windows or any pty.Start failure
|
||||
// it falls back to a pipe-based session.
|
||||
func startTermSession(cmd *exec.Cmd) (termSession, error) {
|
||||
if runtime.GOOS != "windows" {
|
||||
ptmx, err := pty.Start(cmd)
|
||||
if err == nil {
|
||||
return &ptySession{ptmx: ptmx, cmd: cmd}, nil
|
||||
}
|
||||
// On unix, a pty.Start error is fatal — pipes won't help interactive
|
||||
// shells without a TTY, and the unix build is the supported path.
|
||||
return nil, err
|
||||
}
|
||||
return startPipeSession(cmd)
|
||||
}
|
||||
|
||||
// ptySession wraps creack/pty's *os.File-backed PTY.
|
||||
// ptySession wraps creack/pty's *os.File-backed PTY (unix path).
|
||||
type ptySession struct {
|
||||
ptmx *os.File
|
||||
cmd *exec.Cmd
|
||||
@@ -76,8 +64,10 @@ func (s *ptySession) Pid() int {
|
||||
return s.cmd.Process.Pid
|
||||
}
|
||||
|
||||
// pipeSession is the Windows fallback: stdin pipe + merged stdout/stderr pipe,
|
||||
// running concurrently. Resize is a no-op (no TTY to send TIOCSWINSZ to).
|
||||
// pipeSession is the Windows last-resort fallback when ConPTY is not
|
||||
// available: stdin pipe + merged stdout/stderr, no TTY signals. Most
|
||||
// interactive shells go silent in this mode, so it should rarely be hit on
|
||||
// modern Windows (10 1809+).
|
||||
type pipeSession struct {
|
||||
cmd *exec.Cmd
|
||||
stdin io.WriteCloser
|
||||
@@ -115,7 +105,7 @@ func startPipeSession(cmd *exec.Cmd) (termSession, error) {
|
||||
cmd: cmd,
|
||||
stdin: stdin,
|
||||
stdout: stdout,
|
||||
stderr: stderr,
|
||||
stderr: stderr,
|
||||
merged: make(chan []byte, 32),
|
||||
closeCh: make(chan struct{}),
|
||||
}
|
||||
|
||||
19
internal/api/terminal_session_unix.go
Normal file
@@ -0,0 +1,19 @@
|
||||
//go:build !windows
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
|
||||
"github.com/creack/pty/v2"
|
||||
)
|
||||
|
||||
// startTermSession (unix) opens a real PTY via creack/pty. Fatal on error
|
||||
// — the unix build assumes PTY availability.
|
||||
func startTermSession(cmd *exec.Cmd) (termSession, error) {
|
||||
ptmx, err := pty.Start(cmd)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ptySession{ptmx: ptmx, cmd: cmd}, nil
|
||||
}
|
||||
20
internal/api/terminal_session_windows.go
Normal file
@@ -0,0 +1,20 @@
|
||||
//go:build windows
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
// startTermSession (windows) tries the kernel32 ConPTY API first. ConPTY
|
||||
// gives a real pseudo terminal, so wsl.exe / pwsh / cmd render their
|
||||
// prompt and the user can interact normally. If ConPTY is unavailable
|
||||
// (Windows < 10 1809) or the call fails for any reason, we fall back to
|
||||
// the line-buffered pipe session — degraded but functional for non-TUI
|
||||
// commands.
|
||||
func startTermSession(cmd *exec.Cmd) (termSession, error) {
|
||||
if sess, err := startConptySession(cmd); err == nil {
|
||||
return sess, nil
|
||||
}
|
||||
return startPipeSession(cmd)
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
|
||||
const (
|
||||
Name = "muyue"
|
||||
Version = "0.7.2"
|
||||
Version = "0.7.7"
|
||||
Author = "La Légion de Muyue"
|
||||
)
|
||||
|
||||
|
||||
@@ -4,8 +4,11 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#0A0A0C" />
|
||||
<title>muyue</title>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>⬡</text></svg>" />
|
||||
<title>Muyue</title>
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16.png" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/muyue.png" />
|
||||
<link rel="shortcut icon" href="/muyue.png" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
BIN
web/public/favicon-16.png
Normal file
|
After Width: | Height: | Size: 750 B |
BIN
web/public/favicon-32.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
web/public/muyue-64.png
Normal file
|
After Width: | Height: | Size: 6.0 KiB |
BIN
web/public/muyue.png
Normal file
|
After Width: | Height: | Size: 81 KiB |
@@ -103,6 +103,7 @@ export default function App() {
|
||||
<div className="app-layout">
|
||||
<header className="header">
|
||||
<div className="header-brand">
|
||||
<img src="/muyue-64.png" alt="Muyue" className="header-logo-img" width="22" height="22" style={{ borderRadius: 4, verticalAlign: 'middle' }} />
|
||||
<span className="header-logo">MUYUE</span>
|
||||
<span className="header-version">v{info.version || '...'}</span>
|
||||
</div>
|
||||
|
||||
@@ -23,8 +23,12 @@ export default function OnboardingWizard({ api, onComplete }) {
|
||||
language: 'fr',
|
||||
keyboard: 'azerty',
|
||||
apikey: '',
|
||||
apikey_mimo: '',
|
||||
editor: '',
|
||||
})
|
||||
const [keyValidMimo, setKeyValidMimo] = useState(false)
|
||||
const [errorMimo, setErrorMimo] = useState(null)
|
||||
const [validatingMimo, setValidatingMimo] = useState(false)
|
||||
const [editorList, setEditorList] = useState(BASE_EDITORS)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
@@ -52,7 +56,7 @@ export default function OnboardingWizard({ api, onComplete }) {
|
||||
case 'name': return answers.name.trim().length > 0
|
||||
case 'language': return !!answers.language
|
||||
case 'keyboard': return !!answers.keyboard
|
||||
case 'apikey': return keyValid && !scanning
|
||||
case 'apikey': return (keyValid || keyValidMimo) && !scanning
|
||||
case 'editor': return true
|
||||
case 'done': return true
|
||||
default: return true
|
||||
@@ -173,6 +177,33 @@ export default function OnboardingWizard({ api, onComplete }) {
|
||||
setValidating(false)
|
||||
}
|
||||
|
||||
const handleValidateKeyMimo = async () => {
|
||||
if (!answers.apikey_mimo.trim()) return
|
||||
setValidatingMimo(true)
|
||||
setErrorMimo(null)
|
||||
try {
|
||||
await api.validateProvider({
|
||||
name: 'mimo',
|
||||
api_key: answers.apikey_mimo,
|
||||
model: 'mimo-v2.5-pro',
|
||||
base_url: 'https://token-plan-ams.xiaomimimo.com/v1',
|
||||
})
|
||||
setKeyValidMimo(true)
|
||||
// Save MiMo. If MiniMax wasn't validated yet, MiMo becomes the active provider.
|
||||
await api.saveProvider({
|
||||
name: 'mimo',
|
||||
api_key: answers.apikey_mimo,
|
||||
model: 'mimo-v2.5-pro',
|
||||
base_url: 'https://token-plan-ams.xiaomimimo.com/v1',
|
||||
active: !keyValid,
|
||||
})
|
||||
} catch (err) {
|
||||
setErrorMimo(err.message || 'Clé invalide')
|
||||
setKeyValidMimo(false)
|
||||
}
|
||||
setValidatingMimo(false)
|
||||
}
|
||||
|
||||
|
||||
|
||||
const handleSave = async () => {
|
||||
@@ -201,6 +232,15 @@ export default function OnboardingWizard({ api, onComplete }) {
|
||||
active: true,
|
||||
})
|
||||
}
|
||||
if (answers.apikey_mimo.trim()) {
|
||||
await api.saveProvider({
|
||||
name: 'mimo',
|
||||
api_key: answers.apikey_mimo,
|
||||
model: 'mimo-v2.5-pro',
|
||||
base_url: 'https://token-plan-ams.xiaomimimo.com/v1',
|
||||
active: !answers.apikey.trim(),
|
||||
})
|
||||
}
|
||||
onComplete()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Erreur lors de la sauvegarde')
|
||||
@@ -283,38 +323,71 @@ export default function OnboardingWizard({ api, onComplete }) {
|
||||
|
||||
{current.key === 'apikey' && (
|
||||
<div className="onboarding-step">
|
||||
<div className="onboarding-title">Clé API MiniMax</div>
|
||||
<div className="onboarding-title">Clés API</div>
|
||||
<div className="onboarding-desc">
|
||||
Entrez votre clé API MiniMax pour activer l'assistant IA. La clé est obligatoire pour continuer.
|
||||
Renseignez au moins l'une des deux clés pour activer l'assistant. Les autres fournisseurs (OpenAI, Anthropic, Ollama, Z.AI) se configurent plus tard depuis l'onglet Configuration.
|
||||
</div>
|
||||
<input
|
||||
className="onboarding-input"
|
||||
placeholder="sk-xxxxxxxxxxxxxxxx"
|
||||
type="password"
|
||||
value={answers.apikey}
|
||||
onChange={e => { setAnswers(a => ({ ...a, apikey: e.target.value })); setKeyValid(false); setError(null) }}
|
||||
autoFocus
|
||||
/>
|
||||
{error && !keyValid && <div className="onboarding-required">{error}</div>}
|
||||
{keyValid && !scanning && <div className="onboarding-valid">Clé valide ✓ — Appuyez sur Entrée pour continuer</div>}
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginTop: 4 }}>
|
||||
<label style={{ fontSize: 12, color: 'var(--text-tertiary)', fontWeight: 600 }}>MiniMax</label>
|
||||
<input
|
||||
className="onboarding-input"
|
||||
placeholder="sk-xxxxxxxxxxxxxxxx (MiniMax)"
|
||||
type="password"
|
||||
value={answers.apikey}
|
||||
onChange={e => { setAnswers(a => ({ ...a, apikey: e.target.value })); setKeyValid(false); setError(null) }}
|
||||
autoFocus
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
className="sm primary"
|
||||
onClick={handleValidateKey}
|
||||
disabled={validating || !answers.apikey.trim()}
|
||||
>
|
||||
{validating ? 'Validation...' : 'Valider MiniMax'}
|
||||
</button>
|
||||
{keyValid && <span className="onboarding-valid">✓ MiniMax OK</span>}
|
||||
{error && !keyValid && <span className="onboarding-required">{error}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginTop: 12 }}>
|
||||
<label style={{ fontSize: 12, color: 'var(--text-tertiary)', fontWeight: 600 }}>MiMo (Xiaomi)</label>
|
||||
<input
|
||||
className="onboarding-input"
|
||||
placeholder="sk-xxxxxxxxxxxxxxxx (MiMo)"
|
||||
type="password"
|
||||
value={answers.apikey_mimo}
|
||||
onChange={e => { setAnswers(a => ({ ...a, apikey_mimo: e.target.value })); setKeyValidMimo(false); setErrorMimo(null) }}
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
className="sm primary"
|
||||
onClick={handleValidateKeyMimo}
|
||||
disabled={validatingMimo || !answers.apikey_mimo.trim()}
|
||||
>
|
||||
{validatingMimo ? 'Validation...' : 'Valider MiMo'}
|
||||
</button>
|
||||
{keyValidMimo && <span className="onboarding-valid">✓ MiMo OK</span>}
|
||||
{errorMimo && !keyValidMimo && <span className="onboarding-required">{errorMimo}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{scanning && (
|
||||
<div className="onboarding-scanning">
|
||||
<div className="onboarding-scanning" style={{ marginTop: 8 }}>
|
||||
<Loader size={14} className="spin-icon" />
|
||||
<span>{scanMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
{requiredError && <div className="onboarding-required">Veuillez valider votre clé API pour continuer</div>}
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 4 }}>
|
||||
<button
|
||||
className="sm primary"
|
||||
onClick={handleValidateKey}
|
||||
disabled={validating || !answers.apikey.trim()}
|
||||
>
|
||||
{validating ? 'Validation...' : 'Valider la clé'}
|
||||
</button>
|
||||
</div>
|
||||
{!keyValid && !error && answers.apikey.trim() && (
|
||||
<div className="onboarding-hint">Entrez votre clé puis cliquez "Valider la clé"</div>
|
||||
{requiredError && (
|
||||
<div className="onboarding-required" style={{ marginTop: 8 }}>
|
||||
Veuillez valider au moins une clé (MiniMax ou MiMo) pour continuer.
|
||||
</div>
|
||||
)}
|
||||
{(keyValid || keyValidMimo) && !scanning && (
|
||||
<div className="onboarding-valid" style={{ marginTop: 8 }}>
|
||||
Au moins une clé est valide — appuyez sur Suivant pour continuer.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||