Compare commits
17 Commits
v0.6.0-bet
...
v0.7.6-bet
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d1d717999 | ||
|
|
d557b8e74c | ||
|
|
87e606c853 | ||
|
|
79e467c32a | ||
|
|
1ce5c49622 | ||
|
|
830e085c2a | ||
|
|
24b09f5700 | ||
|
|
1442b4fd8a | ||
|
|
a1da9da3db | ||
|
|
a7d4b31a0d | ||
|
|
0ee006f71f | ||
|
|
fc7a5b9d87 | ||
|
|
654444ccc8 | ||
|
|
991878939b | ||
|
|
dbb97cc164 | ||
|
|
6d2f174ae8 | ||
|
|
c820d55710 |
@@ -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,11 +150,13 @@ 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 "\$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 "\`\`\`"
|
||||
} > /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
|
||||
|
||||
183
CHANGELOG.md
@@ -4,6 +4,189 @@ 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.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
|
||||
|
||||
### Amélioration
|
||||
|
||||
- **feat(studio): réflexion avancée forcée automatiquement pendant les tests** — quand au moins une session `browser_test` est connectée, chaque message à Studio active automatiquement la réflexion avancée (un second modèle, si configuré, produit un rapport préalable injecté dans le prompt actif). Le toggle UI est ignoré tant qu'une session de test est active. Justification : pendant un test piloté par l'IA, avoir une analyse complémentaire d'un autre modèle améliore matériellement la qualité des décisions de clic et la couverture du rapport final.
|
||||
- Si aucun second provider n'est configuré, le comportement reste silencieux (fallback chat normal — pas d'erreur visible côté utilisateur).
|
||||
- Hint UI ajouté dans l'onglet Tests pour expliquer le comportement.
|
||||
|
||||
## v0.7.1
|
||||
|
||||
### Fix
|
||||
|
||||
- **fix(terminal/windows): "unsupported" / connection closed** — `creack/pty` n'a pas de support Windows natif et `pty.Start()` retourne immédiatement une erreur ("operating system not supported"), fermant le WebSocket avant même la bannière. L'utilisateur voyait le menu des terminaux peuplé (détection OK : `wsl --list --quiet` fonctionne) mais chaque clic se soldait par "unsupported" ou une connexion fermée.
|
||||
- Introduction de l'abstraction `termSession` (`internal/api/terminal_session.go`) avec deux implémentations sélectionnées au runtime :
|
||||
- **`ptySession`** (Linux / macOS / BSDs) : conserve le comportement existant (TTY complet via `creack/pty`, resize, apps interactives type vim/top).
|
||||
- **`pipeSession`** (Windows) : pipes natifs `stdin` + `stdout` + `stderr` mergés, lus en goroutines, forwardés au WebSocket. Suffisant pour `wsl.exe`, `pwsh`, `cmd` en mode ligne — la plupart des cas d'usage (lancer une commande, voir la sortie, taper la suivante). Resize est un no-op (pas de SIGWINCH sans TTY) ; les TUIs en plein écran ne fonctionnent pas dans ce mode.
|
||||
- Refactor minimal de `handleTerminalWS` : utilise `startTermSession(cmd)` au lieu de `pty.Start(cmd)` direct ; même chemin code pour les deux OS.
|
||||
|
||||
## v0.7.0
|
||||
|
||||
### Changes since v0.4.0
|
||||
|
||||
- fix(ci): rename browser_test.go → browsertest.go (6d2f174)
|
||||
- feat: AI-driven browser tests — Tests tab + browser_test agent tool (c820d55)
|
||||
- release: v0.6.0 — security audit fixes + 7 new features (6a7b4d8)
|
||||
- chore: bump version to 0.5.0 (2a6647b)
|
||||
- feat: agent concurrency, conversation summaries, AI tools config, UI polish (3740454)
|
||||
- feat: AI task API, token-based context windows, SSH password auth, sudo bypass detection (d98110c)
|
||||
- feat: agent concurrency, conversation summaries, AI tools config, UI polish (d2bb42b)
|
||||
- feat: AI task API, token-based context windows, SSH password auth, sudo bypass detection (e8a289c)
|
||||
|
||||
### Downloads
|
||||
|
||||
| Platform | File |
|
||||
|----------|------|
|
||||
| Linux x86_64 | [muyue-linux-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.0/muyue-linux-amd64.tar.gz) |
|
||||
| Linux ARM64 | [muyue-linux-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.0/muyue-linux-arm64.tar.gz) |
|
||||
| macOS Intel | [muyue-darwin-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.0/muyue-darwin-amd64.tar.gz) |
|
||||
| macOS Apple Silicon | [muyue-darwin-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.0/muyue-darwin-arm64.tar.gz) |
|
||||
| Windows x86_64 | [muyue-windows-amd64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.0/muyue-windows-amd64.zip) |
|
||||
| Windows ARM64 | [muyue-windows-arm64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.0/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.0/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.0/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.0/muyue-windows-amd64.zip" -OutFile "muyue.zip"
|
||||
Expand-Archive -Path "muyue.zip" -DestinationPath "."
|
||||
Move-Item muyue-windows-amd64.exe C:\Windows\muyue.exe
|
||||
```
|
||||
|
||||
|
||||
## v0.7.0
|
||||
|
||||
### Nouvelle fonctionnalité majeure : Tests pilotés par l'IA
|
||||
|
||||
- **Onglet Tests** dédié dans l'UI : génère un snippet JS à coller dans n'importe quelle page web ouverte (Chrome, Firefox, Edge, dev local ou distant).
|
||||
- **Session WebSocket** authentifiée par token à usage unique (5 min TTL) — la page connectée transmet ses messages console en temps réel et expose une RPC pour cliquer / évaluer / inspecter.
|
||||
- **Outil agent `browser_test`** disponible pour Studio, avec actions :
|
||||
- `list_clickables` : énumère tous les éléments cliquables visibles avec un index stable
|
||||
- `click` : clic par sélecteur CSS ou par index — retourne le **delta console** émis pendant le clic
|
||||
- `eval` : évalue une expression JS et retourne sa valeur sérialisée
|
||||
- `console` / `summary` : lit le buffer console (200 dernières entrées)
|
||||
- `current_url` : URL et titre courants
|
||||
- `type` : remplit un champ input/textarea (utilise le setter natif pour compatibilité React)
|
||||
- `wait` : pause asynchrone (max 5s)
|
||||
- **Stratégie BMAD** intégrée au prompt système Studio : boucle `summary → list_clickables → click → vérifier console_delta → rapport final ✓/✗/⚠`.
|
||||
- **Multi-sessions** : jusqu'à 16 onglets connectés simultanément ; éviction LRU au-delà.
|
||||
- **Sécurité** : token consommé à la première connexion ; CheckOrigin libre côté snippet (gating par token uniquement) ; CORS API REST inchangé.
|
||||
- **Backend** : `internal/api/browser_test.go` (nouveau, ~480 lignes) + 4 routes (`/api/test/snippet`, `/api/test/sessions`, `/api/test/console/{id}`, `/api/ws/browser-test`).
|
||||
- **Frontend** : `web/src/components/Tests.jsx` (nouveau) + nouvel onglet ⌃4.
|
||||
|
||||
## v0.6.0
|
||||
|
||||
### Audit & corrections (sécurité, concurrence, stabilité)
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -54,6 +54,7 @@ Muyue gère :
|
||||
|-------|-------|
|
||||
| **terminal** | Exécuter des commandes shell (builds, tests, git, etc.) |
|
||||
| **crush_run** | Déléguer une tâche complexe à Crush (édition de fichiers, refactoring, debug) — préfère cet outil pour les tâches multi-fichiers ou l'écriture de code |
|
||||
| **claude_run** | Déléguer une tâche complexe à Claude Code CLI |
|
||||
| **read_file** | Lire le contenu d'un fichier |
|
||||
| **list_files** | Lister les fichiers d'un répertoire |
|
||||
| **search_files** | Chercher des fichiers par motif (glob) |
|
||||
@@ -62,6 +63,27 @@ Muyue gère :
|
||||
| **set_provider** | Configurer un fournisseur IA |
|
||||
| **manage_ssh** | Gérer les connexions SSH |
|
||||
| **web_fetch** | Récupérer le contenu d'une URL |
|
||||
| **browser_test** | Piloter un onglet de navigateur de l'utilisateur (clic, eval, lecture console) — voir `<browser_test_strategy>` ci-dessous |
|
||||
|
||||
<browser_test_strategy>
|
||||
Quand l'utilisateur demande de **tester** une UI / une page (ses boutons, ses formulaires, son comportement), utilise `browser_test`. La page cible doit déjà être connectée via le snippet de l'onglet "Tests" — sinon, l'outil te le dira et tu demandes à l'utilisateur de coller le snippet.
|
||||
|
||||
Boucle recommandée :
|
||||
|
||||
1. `browser_test` action `summary` — voir l'URL, le titre et les dernières erreurs console déjà présentes.
|
||||
2. `browser_test` action `list_clickables` — récupérer la liste indexée des boutons / liens / inputs cliquables.
|
||||
3. Pour chaque cible : `browser_test` action `click` (avec `index` ou `selector`).
|
||||
4. Immédiatement après chaque clic, **regarde le `console_delta` retourné** : c'est la liste des messages console émis pendant le clic. `level: "error"` = bouton cassé.
|
||||
5. Vérifie aussi `current_url` retourné — un changement d'URL inattendu peut signaler un bug.
|
||||
6. Si l'élément ouvre un dialog ou modifie le DOM, refais `list_clickables` pour découvrir les nouveaux éléments.
|
||||
7. Pour les inputs : utilise `type` avant `click` sur le bouton de soumission.
|
||||
8. À la fin, fournis un **rapport** structuré : ✓ boutons OK / ✗ boutons cassés (avec le message d'erreur exact) / ⚠ boutons disabled ou non trouvés.
|
||||
|
||||
Astuces :
|
||||
- Préfère cliquer **par `index`** que par sélecteur — le sélecteur change avec le DOM, l'index reste stable jusqu'au prochain `list_clickables`.
|
||||
- Entre deux actions sensibles, `wait` 200-500 ms si la page a des transitions / fetches asynchrones.
|
||||
- N'utilise jamais `eval` pour cliquer si `click` suffit.
|
||||
</browser_test_strategy>
|
||||
|
||||
<tool_strategy>
|
||||
- **Recherche avant action** — Utilise `search_files`, `grep_content`, `read_file` avant de supposer quoi que ce soit sur l'état du système
|
||||
|
||||
612
internal/api/browsertest.go
Normal file
@@ -0,0 +1,612 @@
|
||||
package api
|
||||
|
||||
// Browser-test feature: an out-of-process page (the user's target tab)
|
||||
// connects to Muyue via WebSocket using a short-lived token, and exposes a
|
||||
// thin RPC: Studio's AI can list clickable elements, click them, evaluate JS,
|
||||
// read the recent console buffer, and observe what changes after each action.
|
||||
//
|
||||
// Threat model: an injected snippet runs in the user's chosen page only, with
|
||||
// the same origin as that page; the WS endpoint is bound to localhost and
|
||||
// gated by a 5-minute token issued by the local Muyue server.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/muyue/muyue/internal/agent"
|
||||
)
|
||||
|
||||
const (
|
||||
browserTestTokenTTL = 5 * time.Minute
|
||||
browserTestCommandTTL = 30 * time.Second
|
||||
browserTestConsoleMax = 200
|
||||
browserTestSessionsMax = 16
|
||||
)
|
||||
|
||||
// BrowserTestSession represents one connected browser tab.
|
||||
type BrowserTestSession struct {
|
||||
ID string
|
||||
URL string
|
||||
Title string
|
||||
conn *websocket.Conn
|
||||
mu sync.Mutex
|
||||
console []ConsoleEntry
|
||||
pending map[string]chan json.RawMessage
|
||||
pendingMu sync.Mutex
|
||||
connectedAt time.Time
|
||||
writeMu sync.Mutex
|
||||
}
|
||||
|
||||
// ConsoleEntry is a captured console message from the connected page.
|
||||
type ConsoleEntry struct {
|
||||
Level string `json:"level"` // log, info, warn, error, debug
|
||||
Message string `json:"message"`
|
||||
Time string `json:"time"`
|
||||
}
|
||||
|
||||
// BrowserTestStore manages active sessions + pending one-shot connect tokens.
|
||||
type BrowserTestStore struct {
|
||||
mu sync.RWMutex
|
||||
sessions map[string]*BrowserTestSession
|
||||
tokens map[string]time.Time
|
||||
tokensMu sync.Mutex
|
||||
}
|
||||
|
||||
func NewBrowserTestStore() *BrowserTestStore {
|
||||
return &BrowserTestStore{
|
||||
sessions: map[string]*BrowserTestSession{},
|
||||
tokens: map[string]time.Time{},
|
||||
}
|
||||
}
|
||||
|
||||
// IssueToken creates a single-use token used by the snippet to authenticate.
|
||||
func (s *BrowserTestStore) IssueToken() string {
|
||||
buf := make([]byte, 16)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return fmt.Sprintf("fallback-%d", time.Now().UnixNano())
|
||||
}
|
||||
tok := hex.EncodeToString(buf)
|
||||
s.tokensMu.Lock()
|
||||
now := time.Now()
|
||||
for k, v := range s.tokens {
|
||||
if now.Sub(v) > browserTestTokenTTL {
|
||||
delete(s.tokens, k)
|
||||
}
|
||||
}
|
||||
s.tokens[tok] = now
|
||||
s.tokensMu.Unlock()
|
||||
return tok
|
||||
}
|
||||
|
||||
// ConsumeToken validates and removes a token in one step.
|
||||
func (s *BrowserTestStore) ConsumeToken(tok string) bool {
|
||||
s.tokensMu.Lock()
|
||||
defer s.tokensMu.Unlock()
|
||||
t, ok := s.tokens[tok]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
delete(s.tokens, tok)
|
||||
return time.Since(t) <= browserTestTokenTTL
|
||||
}
|
||||
|
||||
// Register inserts a new session, evicting the oldest if at capacity.
|
||||
func (s *BrowserTestStore) Register(session *BrowserTestSession) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if len(s.sessions) >= browserTestSessionsMax {
|
||||
var oldestID string
|
||||
var oldest time.Time
|
||||
for id, sess := range s.sessions {
|
||||
if oldestID == "" || sess.connectedAt.Before(oldest) {
|
||||
oldestID = id
|
||||
oldest = sess.connectedAt
|
||||
}
|
||||
}
|
||||
if old, ok := s.sessions[oldestID]; ok {
|
||||
old.conn.Close()
|
||||
delete(s.sessions, oldestID)
|
||||
}
|
||||
}
|
||||
s.sessions[session.ID] = session
|
||||
}
|
||||
|
||||
func (s *BrowserTestStore) Remove(id string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if sess, ok := s.sessions[id]; ok {
|
||||
sess.conn.Close()
|
||||
delete(s.sessions, id)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *BrowserTestStore) Get(id string) *BrowserTestSession {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.sessions[id]
|
||||
}
|
||||
|
||||
// Pick returns the requested session by ID, or the most-recently-connected
|
||||
// session if id is empty. Returns nil if no session matches.
|
||||
func (s *BrowserTestStore) Pick(id string) *BrowserTestSession {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
if id != "" {
|
||||
return s.sessions[id]
|
||||
}
|
||||
var picked *BrowserTestSession
|
||||
for _, sess := range s.sessions {
|
||||
if picked == nil || sess.connectedAt.After(picked.connectedAt) {
|
||||
picked = sess
|
||||
}
|
||||
}
|
||||
return picked
|
||||
}
|
||||
|
||||
func (s *BrowserTestStore) List() []map[string]interface{} {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := make([]map[string]interface{}, 0, len(s.sessions))
|
||||
for _, sess := range s.sessions {
|
||||
out = append(out, map[string]interface{}{
|
||||
"id": sess.ID,
|
||||
"url": sess.URL,
|
||||
"title": sess.Title,
|
||||
"connected_at": sess.connectedAt.Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Send issues an RPC command to the browser session and waits up to TTL for
|
||||
// the matching reply. Returns the raw payload or an error.
|
||||
func (sess *BrowserTestSession) Send(action string, params map[string]interface{}) (json.RawMessage, error) {
|
||||
cid := newCorrelationID()
|
||||
ch := make(chan json.RawMessage, 1)
|
||||
sess.pendingMu.Lock()
|
||||
sess.pending[cid] = ch
|
||||
sess.pendingMu.Unlock()
|
||||
defer func() {
|
||||
sess.pendingMu.Lock()
|
||||
delete(sess.pending, cid)
|
||||
sess.pendingMu.Unlock()
|
||||
}()
|
||||
|
||||
cmd := map[string]interface{}{
|
||||
"id": cid,
|
||||
"action": action,
|
||||
"params": params,
|
||||
}
|
||||
sess.writeMu.Lock()
|
||||
err := sess.conn.WriteJSON(cmd)
|
||||
sess.writeMu.Unlock()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("write: %w", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case payload := <-ch:
|
||||
return payload, nil
|
||||
case <-time.After(browserTestCommandTTL):
|
||||
return nil, fmt.Errorf("browser session did not reply within %s", browserTestCommandTTL)
|
||||
}
|
||||
}
|
||||
|
||||
// AppendConsole records a console line, trimming to the buffer cap.
|
||||
func (sess *BrowserTestSession) AppendConsole(level, message string) {
|
||||
sess.mu.Lock()
|
||||
defer sess.mu.Unlock()
|
||||
sess.console = append(sess.console, ConsoleEntry{
|
||||
Level: level,
|
||||
Message: message,
|
||||
Time: time.Now().Format(time.RFC3339),
|
||||
})
|
||||
if len(sess.console) > browserTestConsoleMax {
|
||||
sess.console = sess.console[len(sess.console)-browserTestConsoleMax:]
|
||||
}
|
||||
}
|
||||
|
||||
// SnapshotConsole returns a copy of the current console buffer.
|
||||
func (sess *BrowserTestSession) SnapshotConsole() []ConsoleEntry {
|
||||
sess.mu.Lock()
|
||||
defer sess.mu.Unlock()
|
||||
out := make([]ConsoleEntry, len(sess.console))
|
||||
copy(out, sess.console)
|
||||
return out
|
||||
}
|
||||
|
||||
func newCorrelationID() string {
|
||||
buf := make([]byte, 8)
|
||||
rand.Read(buf)
|
||||
return hex.EncodeToString(buf)
|
||||
}
|
||||
|
||||
// HTTP handlers --------------------------------------------------------------
|
||||
|
||||
func (s *Server) handleBrowserTestSnippet(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" {
|
||||
writeError(w, "GET only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
tok := s.browserTestStore.IssueToken()
|
||||
host := r.Host
|
||||
if host == "" {
|
||||
host = "127.0.0.1"
|
||||
}
|
||||
scheme := "ws"
|
||||
if r.TLS != nil {
|
||||
scheme = "wss"
|
||||
}
|
||||
wsURL := fmt.Sprintf("%s://%s/api/ws/browser-test?token=%s", scheme, host, tok)
|
||||
snippet := buildBrowserTestSnippet(wsURL)
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"token": tok,
|
||||
"ws_url": wsURL,
|
||||
"snippet": snippet,
|
||||
"expires_in": int(browserTestTokenTTL / time.Second),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleBrowserTestSessions(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" {
|
||||
writeError(w, "GET only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"sessions": s.browserTestStore.List(),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleBrowserTestConsole(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" {
|
||||
writeError(w, "GET only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
id := strings.TrimPrefix(r.URL.Path, "/api/test/console/")
|
||||
sess := s.browserTestStore.Pick(id)
|
||||
if sess == nil {
|
||||
writeError(w, "no active browser test session", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"session_id": sess.ID,
|
||||
"url": sess.URL,
|
||||
"console": sess.SnapshotConsole(),
|
||||
})
|
||||
}
|
||||
|
||||
// browserTestUpgrader accepts any origin: the connection is gated by a
|
||||
// short-lived token issued to the local UI, not by Origin checking.
|
||||
var browserTestUpgrader = websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool { return true },
|
||||
}
|
||||
|
||||
func (s *Server) handleBrowserTestWS(w http.ResponseWriter, r *http.Request) {
|
||||
tok := r.URL.Query().Get("token")
|
||||
if tok == "" || !s.browserTestStore.ConsumeToken(tok) {
|
||||
writeError(w, "invalid or expired token", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
conn, err := browserTestUpgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
conn.SetReadLimit(2 << 20)
|
||||
|
||||
// Read the hello message: page sends {"type":"hello","url":"...","title":"..."}.
|
||||
conn.SetReadDeadline(time.Now().Add(10 * time.Second))
|
||||
var hello struct {
|
||||
Type string `json:"type"`
|
||||
URL string `json:"url"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
if err := conn.ReadJSON(&hello); err != nil || hello.Type != "hello" {
|
||||
conn.WriteJSON(map[string]string{"type": "error", "message": "expected hello"})
|
||||
conn.Close()
|
||||
return
|
||||
}
|
||||
conn.SetReadDeadline(time.Time{})
|
||||
|
||||
id := newCorrelationID()
|
||||
sess := &BrowserTestSession{
|
||||
ID: id,
|
||||
URL: hello.URL,
|
||||
Title: hello.Title,
|
||||
conn: conn,
|
||||
pending: map[string]chan json.RawMessage{},
|
||||
connectedAt: time.Now(),
|
||||
}
|
||||
s.browserTestStore.Register(sess)
|
||||
defer s.browserTestStore.Remove(id)
|
||||
|
||||
// Acknowledge with the assigned session ID.
|
||||
sess.writeMu.Lock()
|
||||
conn.WriteJSON(map[string]string{"type": "registered", "session_id": id})
|
||||
sess.writeMu.Unlock()
|
||||
|
||||
for {
|
||||
_, raw, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var msg struct {
|
||||
Type string `json:"type"`
|
||||
ID string `json:"id,omitempty"`
|
||||
Level string `json:"level,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Data json.RawMessage `json:"data,omitempty"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &msg); err != nil {
|
||||
continue
|
||||
}
|
||||
switch msg.Type {
|
||||
case "console":
|
||||
sess.AppendConsole(msg.Level, msg.Text)
|
||||
case "url_change":
|
||||
sess.mu.Lock()
|
||||
sess.URL = msg.URL
|
||||
sess.mu.Unlock()
|
||||
case "reply":
|
||||
sess.pendingMu.Lock()
|
||||
ch, ok := sess.pending[msg.ID]
|
||||
sess.pendingMu.Unlock()
|
||||
if ok {
|
||||
select {
|
||||
case ch <- msg.Data:
|
||||
default:
|
||||
}
|
||||
}
|
||||
case "ping":
|
||||
sess.writeMu.Lock()
|
||||
conn.WriteJSON(map[string]string{"type": "pong"})
|
||||
sess.writeMu.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Agent tool -----------------------------------------------------------------
|
||||
|
||||
// BrowserTestParams is the schema exposed to the AI for the browser_test tool.
|
||||
type BrowserTestParams struct {
|
||||
Action string `json:"action" description:"One of: list_clickables, click, eval, console, current_url, wait, type, summary"`
|
||||
SessionID string `json:"session_id,omitempty" description:"Browser session id (optional, defaults to most recent)"`
|
||||
Selector string `json:"selector,omitempty" description:"CSS selector for click/type actions"`
|
||||
Index int `json:"index,omitempty" description:"Alternative to selector: index into the last list_clickables result (0-based)"`
|
||||
Expr string `json:"expr,omitempty" description:"JS expression to evaluate (eval action only)"`
|
||||
Text string `json:"text,omitempty" description:"Text to type (type action only)"`
|
||||
WaitMs int `json:"wait_ms,omitempty" description:"Milliseconds to wait (wait action only, max 5000)"`
|
||||
Tail int `json:"tail,omitempty" description:"Console action: how many recent lines to return (default 50, max 200)"`
|
||||
}
|
||||
|
||||
// RegisterBrowserTestTool wires the agent tool against a session store.
|
||||
func RegisterBrowserTestTool(reg *agent.Registry, store *BrowserTestStore) error {
|
||||
tool, err := agent.NewTool("browser_test",
|
||||
"Drive the user's connected browser tab for end-to-end testing. Available actions: list_clickables (returns indexed clickable elements), click (by selector or index), eval (run a JS expression and return result), console (read recent console output, ideal to spot errors after a click), current_url, wait (sleep ms before next check), type (set value on an input), summary (URL+title+last console entries). Always start with list_clickables; click; then console to verify no errors.",
|
||||
func(ctx context.Context, p BrowserTestParams) (agent.ToolResponse, error) {
|
||||
sess := store.Pick(p.SessionID)
|
||||
if sess == nil {
|
||||
return agent.TextErrorResponse("no active browser session — ask the user to paste the snippet from the Tests tab in their target page"), nil
|
||||
}
|
||||
|
||||
action := strings.ToLower(strings.TrimSpace(p.Action))
|
||||
switch action {
|
||||
case "":
|
||||
return agent.TextErrorResponse("action is required"), nil
|
||||
case "list_clickables", "click", "eval", "current_url", "type":
|
||||
case "console", "summary", "wait":
|
||||
default:
|
||||
return agent.TextErrorResponse("unknown action: " + p.Action), nil
|
||||
}
|
||||
|
||||
if action == "console" {
|
||||
tail := p.Tail
|
||||
if tail <= 0 {
|
||||
tail = 50
|
||||
}
|
||||
if tail > browserTestConsoleMax {
|
||||
tail = browserTestConsoleMax
|
||||
}
|
||||
entries := sess.SnapshotConsole()
|
||||
if len(entries) > tail {
|
||||
entries = entries[len(entries)-tail:]
|
||||
}
|
||||
out, _ := json.MarshalIndent(map[string]interface{}{
|
||||
"session_id": sess.ID,
|
||||
"console": entries,
|
||||
}, "", " ")
|
||||
return agent.TextResponse(string(out)), nil
|
||||
}
|
||||
|
||||
if action == "summary" {
|
||||
entries := sess.SnapshotConsole()
|
||||
if len(entries) > 20 {
|
||||
entries = entries[len(entries)-20:]
|
||||
}
|
||||
out, _ := json.MarshalIndent(map[string]interface{}{
|
||||
"session_id": sess.ID,
|
||||
"url": sess.URL,
|
||||
"title": sess.Title,
|
||||
"recent_console": entries,
|
||||
}, "", " ")
|
||||
return agent.TextResponse(string(out)), nil
|
||||
}
|
||||
|
||||
if action == "wait" {
|
||||
ms := p.WaitMs
|
||||
if ms <= 0 {
|
||||
ms = 200
|
||||
}
|
||||
if ms > 5000 {
|
||||
ms = 5000
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return agent.TextErrorResponse("cancelled"), nil
|
||||
case <-time.After(time.Duration(ms) * time.Millisecond):
|
||||
}
|
||||
return agent.TextResponse(fmt.Sprintf("waited %dms", ms)), nil
|
||||
}
|
||||
|
||||
// Capture console snapshot length before so we can return only the delta
|
||||
// after the action — useful so the AI can spot errors caused by the click.
|
||||
pre := len(sess.SnapshotConsole())
|
||||
|
||||
params := map[string]interface{}{}
|
||||
if p.Selector != "" {
|
||||
params["selector"] = p.Selector
|
||||
}
|
||||
if p.Index > 0 || (action == "click" && p.Selector == "") {
|
||||
params["index"] = p.Index
|
||||
}
|
||||
if p.Expr != "" {
|
||||
params["expr"] = p.Expr
|
||||
}
|
||||
if p.Text != "" {
|
||||
params["text"] = p.Text
|
||||
}
|
||||
|
||||
payload, err := sess.Send(action, params)
|
||||
if err != nil {
|
||||
return agent.TextErrorResponse(err.Error()), nil
|
||||
}
|
||||
|
||||
// Console delta: messages logged during this command.
|
||||
post := sess.SnapshotConsole()
|
||||
var delta []ConsoleEntry
|
||||
if len(post) > pre {
|
||||
delta = post[pre:]
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"action": action,
|
||||
"reply": json.RawMessage(payload),
|
||||
"console_delta": delta,
|
||||
"current_url": sess.URL,
|
||||
}
|
||||
out, _ := json.MarshalIndent(result, "", " ")
|
||||
return agent.TextResponse(string(out)), nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return reg.Register(tool)
|
||||
}
|
||||
|
||||
// Snippet generator ----------------------------------------------------------
|
||||
|
||||
func buildBrowserTestSnippet(wsURL string) string {
|
||||
// Note: this is the JS injected into the user's target page. It opens the
|
||||
// WS, hooks console, and dispatches commands. Kept terse on purpose.
|
||||
return `(function(){
|
||||
if (window.__muyueTestRunner) { console.log('[Muyue] runner already attached'); return; }
|
||||
var WS_URL = ` + jsString(wsURL) + `;
|
||||
var ws = new WebSocket(WS_URL);
|
||||
var lastList = [];
|
||||
function send(obj){ try{ ws.send(JSON.stringify(obj)); }catch(e){} }
|
||||
function reply(id, data){ send({type:'reply', id:id, data:data}); }
|
||||
function safeText(el){
|
||||
var t = (el.innerText || el.textContent || '').trim();
|
||||
if (t.length > 80) t = t.slice(0,80)+'…';
|
||||
return t;
|
||||
}
|
||||
function describe(el){
|
||||
var sel = el.id ? '#'+el.id : el.tagName.toLowerCase();
|
||||
if (!el.id && el.className && typeof el.className === 'string') {
|
||||
sel += '.' + el.className.trim().split(/\s+/).slice(0,2).join('.');
|
||||
}
|
||||
var label = el.getAttribute('aria-label') || el.getAttribute('title') || el.getAttribute('name') || '';
|
||||
return { tag: el.tagName.toLowerCase(), selector: sel, text: safeText(el), label: label, type: el.getAttribute('type')||'', disabled: !!el.disabled };
|
||||
}
|
||||
function list(){
|
||||
var els = Array.from(document.querySelectorAll('button, a[href], input[type=submit], input[type=button], [role=button], [onclick]'));
|
||||
lastList = els.filter(function(e){ var r=e.getBoundingClientRect(); return r.width>0 && r.height>0; });
|
||||
return lastList.map(describe).map(function(d,i){ d.index = i; return d; });
|
||||
}
|
||||
function clickEl(el){
|
||||
if (!el) return { ok:false, error:'element not found' };
|
||||
if (el.disabled) return { ok:false, error:'element is disabled' };
|
||||
try { el.scrollIntoView({block:'center'}); el.click(); return { ok:true }; }
|
||||
catch(e){ return { ok:false, error:String(e) }; }
|
||||
}
|
||||
function dispatch(msg){
|
||||
var p = msg.params || {};
|
||||
switch(msg.action){
|
||||
case 'list_clickables': return list();
|
||||
case 'click': {
|
||||
var el;
|
||||
if (p.selector) el = document.querySelector(p.selector);
|
||||
else if (typeof p.index === 'number') el = lastList[p.index];
|
||||
return clickEl(el);
|
||||
}
|
||||
case 'eval': {
|
||||
try { var r = (0,eval)(p.expr); return { ok:true, value: serialize(r) }; }
|
||||
catch(e){ return { ok:false, error:String(e) }; }
|
||||
}
|
||||
case 'current_url': return { url: location.href, title: document.title };
|
||||
case 'type': {
|
||||
var el = p.selector ? document.querySelector(p.selector) : (lastList[p.index]);
|
||||
if (!el) return { ok:false, error:'element not found' };
|
||||
var proto = Object.getPrototypeOf(el);
|
||||
var setter = Object.getOwnPropertyDescriptor(proto, 'value');
|
||||
try { setter && setter.set ? setter.set.call(el, p.text||'') : (el.value = p.text||''); }
|
||||
catch(e){ el.value = p.text||''; }
|
||||
el.dispatchEvent(new Event('input', {bubbles:true}));
|
||||
el.dispatchEvent(new Event('change', {bubbles:true}));
|
||||
return { ok:true };
|
||||
}
|
||||
}
|
||||
return { ok:false, error:'unknown action' };
|
||||
}
|
||||
function serialize(v){
|
||||
if (v === undefined) return 'undefined';
|
||||
try { return JSON.parse(JSON.stringify(v)); }
|
||||
catch(e){ return String(v); }
|
||||
}
|
||||
['log','info','warn','error','debug'].forEach(function(lvl){
|
||||
var orig = console[lvl];
|
||||
console[lvl] = function(){
|
||||
try {
|
||||
var parts = Array.from(arguments).map(function(a){
|
||||
if (typeof a === 'string') return a;
|
||||
try { return JSON.stringify(a); } catch(e){ return String(a); }
|
||||
});
|
||||
send({type:'console', level: lvl, text: parts.join(' ')});
|
||||
} catch(e){}
|
||||
return orig.apply(console, arguments);
|
||||
};
|
||||
});
|
||||
window.addEventListener('error', function(e){
|
||||
send({type:'console', level:'error', text:'window.onerror: '+(e.message||e.error||'unknown')});
|
||||
});
|
||||
window.addEventListener('unhandledrejection', function(e){
|
||||
send({type:'console', level:'error', text:'unhandledrejection: '+String(e.reason)});
|
||||
});
|
||||
var lastUrl = location.href;
|
||||
setInterval(function(){
|
||||
if (location.href !== lastUrl){ lastUrl = location.href; send({type:'url_change', url: lastUrl}); }
|
||||
}, 500);
|
||||
ws.onopen = function(){ send({type:'hello', url: location.href, title: document.title}); };
|
||||
ws.onmessage = function(ev){
|
||||
try { var msg = JSON.parse(ev.data); }
|
||||
catch(e){ return; }
|
||||
if (msg.type === 'registered') { console.log('[Muyue] connected — session', msg.session_id); return; }
|
||||
if (msg.action) reply(msg.id, dispatch(msg));
|
||||
};
|
||||
ws.onclose = function(){ console.log('[Muyue] runner disconnected'); window.__muyueTestRunner = null; };
|
||||
window.__muyueTestRunner = { ws: ws, list: list };
|
||||
})();`
|
||||
}
|
||||
|
||||
func jsString(s string) string {
|
||||
b, _ := json.Marshal(s)
|
||||
return string(b)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -213,7 +213,17 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
|
||||
orb.SetSystemPrompt(studioPrompt.String())
|
||||
orb.SetTools(s.agentToolsJSON)
|
||||
|
||||
if body.AdvancedReflection {
|
||||
// Auto-force advanced reflection while a browser-test session is active:
|
||||
// the user is doing AI-driven UI testing, where having a second model
|
||||
// produce a preliminary report (when one is configured) materially
|
||||
// improves which clicks the active model decides to perform. The toggle
|
||||
// remains user-controllable for non-test conversations.
|
||||
wantReflection := body.AdvancedReflection
|
||||
if !wantReflection && s.browserTestStore != nil && len(s.browserTestStore.List()) > 0 {
|
||||
wantReflection = true
|
||||
}
|
||||
|
||||
if wantReflection {
|
||||
if report, ok := s.runReflectionReport(enrichedMessage); ok {
|
||||
enrichedMessage = enrichedMessage + "\n\n[RAPPORT PRÉALABLE — produit par un autre modèle, à valider]\n" + report + "\n[/RAPPORT PRÉALABLE]"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -27,6 +27,7 @@ type Server struct {
|
||||
shellAgentRegistry *agent.Registry
|
||||
shellAgentToolsJSON json.RawMessage
|
||||
workflowEngine *workflow.Engine
|
||||
browserTestStore *BrowserTestStore
|
||||
activeCrushAgents atomic.Int32
|
||||
activeClaudeAgents atomic.Int32
|
||||
}
|
||||
@@ -58,6 +59,11 @@ func NewServer(cfg *config.MuyueConfig) *Server {
|
||||
s.shellConvStore = NewShellConvStore()
|
||||
s.consumption = newConsumptionStore()
|
||||
s.agentRegistry = agent.DefaultRegistry()
|
||||
s.browserTestStore = NewBrowserTestStore()
|
||||
if err := RegisterBrowserTestTool(s.agentRegistry, s.browserTestStore); err != nil {
|
||||
// Tool registration only fails for duplicate names — non-fatal
|
||||
_ = err
|
||||
}
|
||||
tools := s.agentRegistry.OpenAITools()
|
||||
toolsJSON, _ := json.Marshal(tools)
|
||||
s.agentToolsJSON = json.RawMessage(toolsJSON)
|
||||
@@ -146,6 +152,11 @@ func (s *Server) routes() {
|
||||
s.mux.HandleFunc("/api/recent-commands", s.handleRecentCommands)
|
||||
s.mux.HandleFunc("/api/running-processes", s.handleRunningProcesses)
|
||||
s.mux.HandleFunc("/api/system/metrics", s.handleSystemMetrics)
|
||||
|
||||
s.mux.HandleFunc("/api/test/snippet", s.handleBrowserTestSnippet)
|
||||
s.mux.HandleFunc("/api/test/sessions", s.handleBrowserTestSessions)
|
||||
s.mux.HandleFunc("/api/test/console/", s.handleBrowserTestConsole)
|
||||
s.mux.HandleFunc("/api/ws/browser-test", s.handleBrowserTestWS)
|
||||
}
|
||||
|
||||
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -13,7 +13,6 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/creack/pty/v2"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/muyue/muyue/internal/config"
|
||||
)
|
||||
@@ -154,7 +153,7 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
cmd.Env = append(cmd.Env, "TERM=xterm-256color")
|
||||
|
||||
ptmx, err := pty.Start(cmd)
|
||||
session, err := startTermSession(cmd)
|
||||
if err != nil {
|
||||
conn.WriteJSON(wsMessage{Type: "error", Data: err.Error()})
|
||||
return
|
||||
@@ -163,11 +162,8 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
|
||||
var once sync.Once
|
||||
cleanup := func() {
|
||||
once.Do(func() {
|
||||
ptmx.Close()
|
||||
if cmd.Process != nil {
|
||||
cmd.Process.Kill()
|
||||
cmd.Wait()
|
||||
}
|
||||
session.Close()
|
||||
session.Wait()
|
||||
})
|
||||
}
|
||||
defer cleanup()
|
||||
@@ -175,15 +171,17 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
|
||||
go func() {
|
||||
buf := make([]byte, 4096)
|
||||
for {
|
||||
n, err := ptmx.Read(buf)
|
||||
if err != nil {
|
||||
cleanup()
|
||||
return
|
||||
n, err := session.Read(buf)
|
||||
if n > 0 {
|
||||
if err := conn.WriteJSON(wsMessage{
|
||||
Type: "output",
|
||||
Data: string(buf[:n]),
|
||||
}); err != nil {
|
||||
cleanup()
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := conn.WriteJSON(wsMessage{
|
||||
Type: "output",
|
||||
Data: string(buf[:n]),
|
||||
}); err != nil {
|
||||
if err != nil {
|
||||
cleanup()
|
||||
return
|
||||
}
|
||||
@@ -207,16 +205,13 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
switch msg.Type {
|
||||
case "input":
|
||||
if _, err := ptmx.Write([]byte(msg.Data)); err != nil {
|
||||
if _, err := session.Write([]byte(msg.Data)); err != nil {
|
||||
cleanup()
|
||||
return
|
||||
}
|
||||
case "resize":
|
||||
if msg.Rows > 0 && msg.Cols > 0 {
|
||||
pty.Setsize(ptmx, &pty.Winsize{
|
||||
Rows: msg.Rows,
|
||||
Cols: msg.Cols,
|
||||
})
|
||||
session.Resize(msg.Rows, msg.Cols)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
188
internal/api/terminal_session.go
Normal file
@@ -0,0 +1,188 @@
|
||||
package api
|
||||
|
||||
// Cross-platform terminal session abstraction.
|
||||
//
|
||||
// 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"
|
||||
"sync"
|
||||
|
||||
"github.com/creack/pty/v2"
|
||||
)
|
||||
|
||||
// termSession is the read/write/resize/close surface used by handleTerminalWS.
|
||||
type termSession interface {
|
||||
Read([]byte) (int, error)
|
||||
Write([]byte) (int, error)
|
||||
Resize(rows, cols uint16) error
|
||||
Close() error
|
||||
Wait() error
|
||||
Pid() int
|
||||
}
|
||||
|
||||
// ptySession wraps creack/pty's *os.File-backed PTY (unix path).
|
||||
type ptySession struct {
|
||||
ptmx *os.File
|
||||
cmd *exec.Cmd
|
||||
}
|
||||
|
||||
func (s *ptySession) Read(p []byte) (int, error) { return s.ptmx.Read(p) }
|
||||
func (s *ptySession) Write(p []byte) (int, error) { return s.ptmx.Write(p) }
|
||||
func (s *ptySession) Resize(rows, cols uint16) error {
|
||||
return pty.Setsize(s.ptmx, &pty.Winsize{Rows: rows, Cols: cols})
|
||||
}
|
||||
func (s *ptySession) Close() error {
|
||||
err := s.ptmx.Close()
|
||||
if s.cmd.Process != nil {
|
||||
s.cmd.Process.Kill()
|
||||
}
|
||||
return err
|
||||
}
|
||||
func (s *ptySession) Wait() error {
|
||||
if s.cmd.Process == nil {
|
||||
return nil
|
||||
}
|
||||
return s.cmd.Wait()
|
||||
}
|
||||
func (s *ptySession) Pid() int {
|
||||
if s.cmd.Process == nil {
|
||||
return 0
|
||||
}
|
||||
return s.cmd.Process.Pid
|
||||
}
|
||||
|
||||
// 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
|
||||
stdout io.ReadCloser
|
||||
stderr io.ReadCloser
|
||||
mu sync.Mutex
|
||||
merged chan []byte
|
||||
closed bool
|
||||
closeCh chan struct{}
|
||||
}
|
||||
|
||||
func startPipeSession(cmd *exec.Cmd) (termSession, error) {
|
||||
stdin, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
stdin.Close()
|
||||
return nil, err
|
||||
}
|
||||
stderr, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
stdin.Close()
|
||||
stdout.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
stdin.Close()
|
||||
stdout.Close()
|
||||
stderr.Close()
|
||||
return nil, err
|
||||
}
|
||||
s := &pipeSession{
|
||||
cmd: cmd,
|
||||
stdin: stdin,
|
||||
stdout: stdout,
|
||||
stderr: stderr,
|
||||
merged: make(chan []byte, 32),
|
||||
closeCh: make(chan struct{}),
|
||||
}
|
||||
go s.pump(stdout)
|
||||
go s.pump(stderr)
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *pipeSession) pump(r io.ReadCloser) {
|
||||
buf := make([]byte, 4096)
|
||||
for {
|
||||
n, err := r.Read(buf)
|
||||
if n > 0 {
|
||||
chunk := make([]byte, n)
|
||||
copy(chunk, buf[:n])
|
||||
select {
|
||||
case s.merged <- chunk:
|
||||
case <-s.closeCh:
|
||||
return
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *pipeSession) Read(p []byte) (int, error) {
|
||||
select {
|
||||
case chunk, ok := <-s.merged:
|
||||
if !ok {
|
||||
return 0, io.EOF
|
||||
}
|
||||
n := copy(p, chunk)
|
||||
return n, nil
|
||||
case <-s.closeCh:
|
||||
return 0, io.EOF
|
||||
}
|
||||
}
|
||||
|
||||
func (s *pipeSession) Write(p []byte) (int, error) {
|
||||
return s.stdin.Write(p)
|
||||
}
|
||||
|
||||
func (s *pipeSession) Resize(rows, cols uint16) error {
|
||||
// No real TTY → resize is a no-op; the child won't get SIGWINCH.
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *pipeSession) Close() error {
|
||||
s.mu.Lock()
|
||||
if s.closed {
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
s.closed = true
|
||||
close(s.closeCh)
|
||||
s.mu.Unlock()
|
||||
s.stdin.Close()
|
||||
s.stdout.Close()
|
||||
s.stderr.Close()
|
||||
if s.cmd.Process != nil {
|
||||
s.cmd.Process.Kill()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *pipeSession) Wait() error {
|
||||
if s.cmd.Process == nil {
|
||||
return nil
|
||||
}
|
||||
return s.cmd.Wait()
|
||||
}
|
||||
|
||||
func (s *pipeSession) Pid() int {
|
||||
if s.cmd.Process == nil {
|
||||
return 0
|
||||
}
|
||||
return s.cmd.Process.Pid
|
||||
}
|
||||
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.6.0"
|
||||
Version = "0.7.6"
|
||||
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 |
@@ -44,6 +44,9 @@ const api = {
|
||||
getRecentCommands: () => request('/recent-commands'),
|
||||
getRunningProcesses: () => request('/running-processes'),
|
||||
getSystemMetrics: () => request('/system/metrics'),
|
||||
getTestSnippet: () => request('/test/snippet'),
|
||||
getTestSessions: () => request('/test/sessions'),
|
||||
getTestConsole: (sessionId) => request(`/test/console/${encodeURIComponent(sessionId || '')}`),
|
||||
savePreferences: (prefs) => request('/preferences', { method: 'PUT', body: JSON.stringify(prefs) }),
|
||||
saveProfile: (profile) => request('/config/profile', { method: 'PUT', body: JSON.stringify(profile) }),
|
||||
saveProvider: (provider) => request('/config/provider', { method: 'PUT', body: JSON.stringify(provider) }),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
||||
import { LayoutDashboard, Sparkles, Terminal, Settings } from 'lucide-react'
|
||||
import { LayoutDashboard, Sparkles, Terminal, Settings, TestTube2 } from 'lucide-react'
|
||||
import api from '../api/client'
|
||||
import { getTheme, applyTheme } from '../themes'
|
||||
import { useI18n } from '../i18n'
|
||||
@@ -7,6 +7,7 @@ import Dashboard from './Dashboard'
|
||||
import Studio from './Studio'
|
||||
import Shell from './Shell'
|
||||
import Config from './Config'
|
||||
import Tests from './Tests'
|
||||
import OnboardingWizard from './OnboardingWizard'
|
||||
|
||||
export default function App() {
|
||||
@@ -24,6 +25,7 @@ export default function App() {
|
||||
{ id: 'dash', label: t('tabs.dashboard'), icon: <LayoutDashboard size={15} /> },
|
||||
{ id: 'studio', label: t('tabs.studio'), icon: <Sparkles size={15} /> },
|
||||
{ id: 'shell', label: t('tabs.shell'), icon: <Terminal size={15} /> },
|
||||
{ id: 'tests', label: 'Tests', icon: <TestTube2 size={15} /> },
|
||||
{ id: 'config', label: t('tabs.config'), icon: <Settings size={15} /> },
|
||||
], [t])
|
||||
|
||||
@@ -54,7 +56,8 @@ export default function App() {
|
||||
Digit1: 'dash',
|
||||
Digit2: 'studio',
|
||||
Digit3: 'shell',
|
||||
Digit4: 'config',
|
||||
Digit4: 'tests',
|
||||
Digit5: 'config',
|
||||
}
|
||||
if (map[e.code]) {
|
||||
e.preventDefault()
|
||||
@@ -92,6 +95,7 @@ export default function App() {
|
||||
{ keys: layout.keys.enter, desc: t('statusbar.runCommand') },
|
||||
{ keys: `${layout.keys.up}/${layout.keys.down}`, desc: t('statusbar.commandHistory') },
|
||||
],
|
||||
tests: [],
|
||||
config: [],
|
||||
}), [layout, t])
|
||||
|
||||
@@ -99,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>
|
||||
@@ -129,6 +134,7 @@ export default function App() {
|
||||
<div className={activeTab === 'dash' ? '' : 'tab-hidden'}><Dashboard api={api} refreshRef={dashRefreshRef} /></div>
|
||||
<div className={activeTab === 'studio' ? '' : 'tab-hidden'}><Studio api={api} /></div>
|
||||
<div className={activeTab === 'shell' ? '' : 'tab-hidden'}><Shell api={api} isSudo={isSudo} /></div>
|
||||
<div className={activeTab === 'tests' ? '' : 'tab-hidden'}><Tests api={api} /></div>
|
||||
<div className={activeTab === 'config' ? '' : 'tab-hidden'}><Config api={api} /></div>
|
||||
</main>
|
||||
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
241
web/src/components/Tests.jsx
Normal file
@@ -0,0 +1,241 @@
|
||||
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||
import { TestTube2, Copy, RefreshCw, CheckCircle2, AlertTriangle, Globe, Terminal as TerminalIcon } from 'lucide-react'
|
||||
|
||||
export default function Tests({ api }) {
|
||||
const [snippet, setSnippet] = useState(null)
|
||||
const [snippetError, setSnippetError] = useState('')
|
||||
const [sessions, setSessions] = useState([])
|
||||
const [console_, setConsole_] = useState([])
|
||||
const [activeSessionId, setActiveSessionId] = useState('')
|
||||
const [copied, setCopied] = useState(false)
|
||||
const pollRef = useRef(null)
|
||||
|
||||
const refreshSnippet = useCallback(async () => {
|
||||
try {
|
||||
const data = await api.getTestSnippet()
|
||||
setSnippet(data)
|
||||
setSnippetError('')
|
||||
} catch (err) {
|
||||
setSnippetError(err.message || 'Failed to load snippet')
|
||||
}
|
||||
}, [api])
|
||||
|
||||
const refreshSessions = useCallback(async () => {
|
||||
try {
|
||||
const data = await api.getTestSessions()
|
||||
const next = data.sessions || []
|
||||
setSessions(next)
|
||||
if (!activeSessionId && next.length > 0) {
|
||||
setActiveSessionId(next[0].id)
|
||||
} else if (activeSessionId && !next.find(s => s.id === activeSessionId)) {
|
||||
setActiveSessionId(next.length > 0 ? next[0].id : '')
|
||||
}
|
||||
} catch {}
|
||||
}, [api, activeSessionId])
|
||||
|
||||
const refreshConsole = useCallback(async () => {
|
||||
if (!activeSessionId) {
|
||||
setConsole_([])
|
||||
return
|
||||
}
|
||||
try {
|
||||
const data = await api.getTestConsole(activeSessionId)
|
||||
setConsole_(data.console || [])
|
||||
} catch {
|
||||
setConsole_([])
|
||||
}
|
||||
}, [api, activeSessionId])
|
||||
|
||||
useEffect(() => {
|
||||
refreshSnippet()
|
||||
}, [refreshSnippet])
|
||||
|
||||
useEffect(() => {
|
||||
refreshSessions()
|
||||
refreshConsole()
|
||||
pollRef.current = setInterval(() => {
|
||||
refreshSessions()
|
||||
refreshConsole()
|
||||
}, 2000)
|
||||
return () => clearInterval(pollRef.current)
|
||||
}, [refreshSessions, refreshConsole])
|
||||
|
||||
const copySnippet = useCallback(async () => {
|
||||
if (!snippet) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(snippet.snippet)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 1500)
|
||||
} catch {}
|
||||
}, [snippet])
|
||||
|
||||
const activeSession = sessions.find(s => s.id === activeSessionId) || null
|
||||
|
||||
return (
|
||||
<div className="tests-layout" style={{ padding: '20px', display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '20px', height: '100%', overflow: 'auto' }}>
|
||||
<section className="tests-pane">
|
||||
<header style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
|
||||
<TestTube2 size={18} />
|
||||
<h2 style={{ margin: 0, fontSize: '1.1em' }}>Tests pilotés par l'IA</h2>
|
||||
</header>
|
||||
|
||||
<p style={{ marginTop: 0, opacity: 0.85, lineHeight: 1.5 }}>
|
||||
Donnez à l'IA Studio le contrôle d'un onglet de votre navigateur pour tester chaque bouton et détecter les erreurs console.
|
||||
</p>
|
||||
|
||||
<div style={{ borderTop: '1px solid var(--border, rgba(128,128,128,0.3))', paddingTop: 12, marginTop: 12 }}>
|
||||
<h3 style={{ fontSize: '0.95em', margin: '0 0 8px' }}>1. Connexion</h3>
|
||||
<ol style={{ paddingLeft: 18, lineHeight: 1.6 }}>
|
||||
<li>Ouvrez la page à tester dans n'importe quel navigateur (Chrome, Firefox, Edge…).</li>
|
||||
<li>Ouvrez la console développeur (<kbd>F12</kbd>).</li>
|
||||
<li>Collez ce snippet et appuyez sur <kbd>Entrée</kbd> :</li>
|
||||
</ol>
|
||||
|
||||
{snippetError && (
|
||||
<div style={{ background: 'rgba(220,80,80,0.1)', border: '1px solid rgba(220,80,80,0.3)', padding: 8, borderRadius: 4, marginBottom: 8 }}>
|
||||
{snippetError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ position: 'relative', marginBottom: 12 }}>
|
||||
<pre style={{
|
||||
background: 'var(--bg-secondary, rgba(0,0,0,0.3))',
|
||||
padding: '10px 12px',
|
||||
borderRadius: 4,
|
||||
fontSize: '0.75em',
|
||||
maxHeight: 180,
|
||||
overflow: 'auto',
|
||||
border: '1px solid var(--border, rgba(128,128,128,0.3))',
|
||||
margin: 0,
|
||||
}}>
|
||||
{snippet?.snippet || 'Chargement…'}
|
||||
</pre>
|
||||
<button
|
||||
onClick={copySnippet}
|
||||
disabled={!snippet}
|
||||
title="Copier"
|
||||
style={{
|
||||
position: 'absolute', top: 6, right: 6,
|
||||
background: 'var(--bg-tertiary, rgba(255,255,255,0.08))',
|
||||
border: '1px solid var(--border, rgba(128,128,128,0.3))',
|
||||
color: 'inherit', padding: '4px 8px', borderRadius: 3,
|
||||
cursor: 'pointer', fontSize: '0.75em',
|
||||
display: 'flex', alignItems: 'center', gap: 4,
|
||||
}}
|
||||
>
|
||||
<Copy size={11} /> {copied ? 'Copié !' : 'Copier'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button onClick={refreshSnippet} style={{ background: 'transparent', border: '1px solid var(--border, rgba(128,128,128,0.3))', color: 'inherit', padding: '4px 10px', borderRadius: 3, cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: '0.85em' }}>
|
||||
<RefreshCw size={12} /> Régénérer le token
|
||||
</button>
|
||||
<small style={{ display: 'block', opacity: 0.6, marginTop: 4 }}>
|
||||
Le token expire après {snippet?.expires_in ? Math.round(snippet.expires_in / 60) : 5} minutes ou dès la première connexion.
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div style={{ borderTop: '1px solid var(--border, rgba(128,128,128,0.3))', paddingTop: 12, marginTop: 16 }}>
|
||||
<h3 style={{ fontSize: '0.95em', margin: '0 0 8px' }}>2. Pilotage par l'IA</h3>
|
||||
<p style={{ margin: '0 0 8px', lineHeight: 1.5 }}>
|
||||
Une fois la session connectée, allez dans l'onglet <strong>Studio</strong> et demandez par exemple :
|
||||
</p>
|
||||
<pre style={{ background: 'var(--bg-secondary, rgba(0,0,0,0.3))', padding: 8, borderRadius: 4, fontSize: '0.85em', margin: 0 }}>
|
||||
{`Teste tous les boutons de cette page,
|
||||
clique sur chacun, et dis-moi
|
||||
lesquels déclenchent une erreur console.`}
|
||||
</pre>
|
||||
<p style={{ margin: '8px 0 0', opacity: 0.75, fontSize: '0.85em' }}>
|
||||
L'IA dispose de l'outil <code>browser_test</code> avec les actions <code>list_clickables</code>, <code>click</code>, <code>console</code>, <code>eval</code>, <code>type</code>, <code>current_url</code>, <code>wait</code>, <code>summary</code>.
|
||||
</p>
|
||||
<p style={{ margin: '8px 0 0', padding: 8, fontSize: '0.85em', background: 'var(--accent-bg, rgba(108,92,231,0.1))', border: '1px solid var(--accent, #6c5ce7)', borderRadius: 4 }}>
|
||||
<strong>Réflexion avancée auto :</strong> tant qu'au moins une session de test est connectée, chaque message dans Studio utilise automatiquement la réflexion avancée — un second modèle (s'il est configuré) produit un rapport d'analyse préalable injecté dans le prompt actif. Le toggle Studio est ignoré pendant la session.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="tests-pane">
|
||||
<header style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12, justifyContent: 'space-between' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<Globe size={16} />
|
||||
<h2 style={{ margin: 0, fontSize: '1.1em' }}>Sessions connectées</h2>
|
||||
</div>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: '0.85em' }}>
|
||||
{sessions.length > 0 ? <CheckCircle2 size={14} color="#3aaa61" /> : <span style={{ display: 'inline-block', width: 8, height: 8, borderRadius: '50%', background: '#888' }} />}
|
||||
{sessions.length} session{sessions.length > 1 ? 's' : ''}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
{sessions.length === 0 ? (
|
||||
<div style={{ padding: 16, textAlign: 'center', opacity: 0.7, border: '1px dashed var(--border, rgba(128,128,128,0.3))', borderRadius: 4 }}>
|
||||
<AlertTriangle size={20} style={{ opacity: 0.4 }} />
|
||||
<div style={{ marginTop: 6 }}>Aucune session active.</div>
|
||||
<small>Collez le snippet dans une page pour démarrer.</small>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginBottom: 12 }}>
|
||||
{sessions.map(s => (
|
||||
<button
|
||||
key={s.id}
|
||||
onClick={() => setActiveSessionId(s.id)}
|
||||
style={{
|
||||
textAlign: 'left',
|
||||
background: s.id === activeSessionId ? 'var(--accent-bg, rgba(108,92,231,0.15))' : 'transparent',
|
||||
border: '1px solid ' + (s.id === activeSessionId ? 'var(--accent, #6c5ce7)' : 'var(--border, rgba(128,128,128,0.3))'),
|
||||
color: 'inherit',
|
||||
padding: 8, borderRadius: 4, cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 500, fontSize: '0.9em', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{s.title || s.url || s.id}
|
||||
</div>
|
||||
<div style={{ fontSize: '0.75em', opacity: 0.65, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{s.url} · session {s.id.slice(0, 8)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeSession && (
|
||||
<div style={{ borderTop: '1px solid var(--border, rgba(128,128,128,0.3))', paddingTop: 12 }}>
|
||||
<header style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 8 }}>
|
||||
<TerminalIcon size={14} />
|
||||
<h3 style={{ margin: 0, fontSize: '0.95em' }}>Console (live, dernières {console_.length})</h3>
|
||||
</header>
|
||||
<div style={{
|
||||
background: 'var(--bg-secondary, rgba(0,0,0,0.3))',
|
||||
padding: 8,
|
||||
borderRadius: 4,
|
||||
maxHeight: 380,
|
||||
overflow: 'auto',
|
||||
fontSize: '0.8em',
|
||||
fontFamily: 'var(--font-mono, ui-monospace, monospace)',
|
||||
border: '1px solid var(--border, rgba(128,128,128,0.3))',
|
||||
}}>
|
||||
{console_.length === 0 ? (
|
||||
<div style={{ opacity: 0.5 }}>(aucun message console)</div>
|
||||
) : (
|
||||
console_.map((c, i) => (
|
||||
<div key={i} style={{ color: levelColor(c.level), padding: '2px 0', borderBottom: '1px dashed rgba(128,128,128,0.15)' }}>
|
||||
<span style={{ opacity: 0.55, fontSize: '0.85em' }}>[{c.time?.slice(11, 19)} {c.level}]</span> {c.message}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function levelColor(lvl) {
|
||||
switch (lvl) {
|
||||
case 'error': return '#ff6b6b'
|
||||
case 'warn': return '#f5a623'
|
||||
case 'info': return '#4dabf7'
|
||||
case 'debug': return '#888'
|
||||
default: return 'inherit'
|
||||
}
|
||||
}
|
||||