Compare commits

..

31 Commits

Author SHA1 Message Date
Augustin
31c99e7479 Merge develop into main (v0.9.0)
All checks were successful
Stable Release / stable (push) Successful in 1m17s
💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-27 18:48:35 +02:00
Augustin
f4af63afec feat(extension): Chrome/Edge only + side panel chat tabs (v0.9.0)
All checks were successful
Beta Release / beta (push) Successful in 1m25s
- Remove Firefox build support (CI, Makefile, wxt config)
- Fix chrome.alarms undefined error (add 'alarms' permission)
- Add Chat tab to side panel connected to Studio API (/api/chat)
- Streaming SSE, tool calls, code blocks, thinking display
- Shared chat history with desktop Studio
- New lib/api.js client for extension chat endpoints

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-27 18:48:04 +02:00
CI Bot
b5e5b302f2 chore: update CHANGELOG for v0.8.0 2026-04-27 16:28:12 +00:00
Augustin
872e8bfa75 fix(extension): Firefox corrupt zip + duplicate uploads in CI
All checks were successful
Stable Release / stable (push) Successful in 1m23s
- Remove 'sidePanel' permission from Firefox build (Chrome-only MV3)
- Fix CI upload loop matching extension zips twice via dist/*.zip

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-27 18:25:41 +02:00
Augustin
31b1de1b0d fix(extension): Firefox corrupt zip + duplicate uploads in CI
All checks were successful
Beta Release / beta (push) Successful in 1m23s
- Remove 'sidePanel' permission from Firefox build (Chrome-only MV3)
- Fix CI upload loop matching extension zips twice via dist/*.zip

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-27 18:25:13 +02:00
CI Bot
9f014448a1 chore: update CHANGELOG for v0.8.0 2026-04-27 15:02:30 +00:00
Augustin
5094815de1 fix(ci): create dist/ before moving extension zips
All checks were successful
Stable Release / stable (push) Successful in 1m25s
Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-27 16:59:57 +02:00
Augustin
693b0e932e fix(ci): create dist/ before moving extension zips
All checks were successful
Beta Release / beta (push) Successful in 1m34s
Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-27 16:59:25 +02:00
Augustin
a60bd92858 release: v0.8.0 — browser extension for Chrome/Edge/Firefox
Some checks failed
Stable Release / stable (push) Failing after 48s
Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-27 16:51:00 +02:00
Augustin
9f9f2bd2c6 feat(extension): browser extension for Chrome/Edge/Firefox + CI + v0.8.0
Some checks failed
Beta Release / beta (push) Failing after 48s
Adds a WXT-based browser extension that replaces manual JS snippet
injection for AI-driven browser testing. The extension auto-connects
to the Muyue server via WebSocket on every page, using the exact
same protocol as the existing snippet — zero backend changes needed.

- Chrome/Edge (MV3) + Firefox (MV2) from single codebase via WXT
- Content script: auto-connect WS, console capture, URL tracking, RPC
- Background service worker: token management, screenshots, badge
- Popup + side panel with server status, sessions, URL config
- CI workflows: build extension, attach .zip to releases
- Makefile targets: ext, ext-chrome, ext-firefox, ext-zip
- Version bumped to 0.8.0

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-27 16:50:04 +02:00
Muyue
97a25295fc feat(browser-test): persistent token + auto-reconnect + screenshot + smarter strategy (v0.7.9)
Four user-reported issues with the AI-driven browser test feature:

1. WS dies on every page reload / navigation (token was single-use,
   5 min TTL → AI loses session permanently).
2. AI can't see new pages opened by its actions (same root cause).
3. No screenshot capability — AI cannot capture page state visually.
4. AI burns ~150 tool calls for what's 5 human actions, mostly
   list_clickables loops.

Fixes:

- Token sliding TTL (60 min): ConsumeToken no longer deletes the
  token; it refreshes its expiration on each successful WS connect.
  Same token survives reload / re-paste / navigation as long as
  there's no 60-min idle gap.

- Snippet auto-reconnect: WS onclose schedules reconnect with
  500ms × attempt backoff (max ~2.5s). Handles transient drops,
  server restarts, and WS hiccups without user intervention. Full
  navigation kills the JS context and is unrecoverable from JS — but
  the user just re-pastes the snippet, same token works.

- New 'screenshot' action: snippet captures via SVG foreignObject +
  canvas → base64 PNG → sent back over the existing WS reply
  channel. Server decodes and writes to ~/.muyue/screenshots/
  <filename>.png (sanitized name, timestamp default). Filename
  characters limited to a safe charset to prevent path escape.
  Best-effort: external CSS / cross-origin images / iframes won't
  inline.

- Studio system prompt rewritten <browser_test_strategy>:
  - Explicit rule: don't list_clickables after every click
  - Action cost table (cheap vs expensive)
  - When to re-list (URL change, dialog, click-not-found only)
  - Standard final report format ✓ / ✗ / ⚠ / 📸

Also bundles v0.7.8 (cherry-picked): unsafe.Pointer(uintptr(hPC))
instead of unsafe.Pointer(&hPC) in UpdateProcThreadAttribute, so
PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE is correctly applied and the
spawned shell attaches to the embedded xterm.js instead of opening
a separate external console window (regression fix from v0.7.6).

- internal/api/browsertest.go: token sliding, screenshot save,
  param schema, snippet rewrite, helpers
- internal/agent/prompts/studio_system.md: strategy rewrite
- internal/version/version.go: 0.7.7 → 0.7.9
- CHANGELOG.md: v0.7.9 entry covering all fixes
2026-04-27 16:50:04 +02:00
Augustin ROUX
5fd8cceabd Merge pull request 'fix(windows/conpty): pass HPCON value, not &hPC (v0.7.8)' (#18) from release/v0.7.8 into develop
All checks were successful
Beta Release / beta (push) Successful in 1m15s
Reviewed-on: #18
2026-04-27 13:49:15 +00:00
Muyue
a3487392c0 fix(windows/conpty): pass HPCON value, not &hPC (v0.7.8)
All checks were successful
PR Check / check (pull_request) Successful in 1m3s
User reported regression introduced in v0.7.6: PowerShell / cmd open
in a separate external console window instead of attaching to the
xterm.js tab (v0.7.5 worked).

Root cause: the ConPTY wiring used
  attrList.Update(PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE,
                  unsafe.Pointer(&hPC),    // ← wrong
                  unsafe.Sizeof(hPC))

The PSEUDOCONSOLE attribute is a Win32 API quirk: lpValue must be
the HPCON *value* (cast to PVOID), not a pointer to the local
variable holding the handle. With &hPC the kernel reads garbage,
silently drops the attribute, and CreateProcessW spawns the child
with a fresh console — hence the external window.

Fix is one line:
  unsafe.Pointer(uintptr(hPC))

Confirmed against Microsoft's EchoCon sample and Go libraries that
work in production (UserExistsError/conpty, aymanbagabas/go-pty).

- internal/version/version.go: 0.7.7 → 0.7.8
- CHANGELOG.md: v0.7.8 entry with the diagnostic write-up
2026-04-27 14:39:26 +02:00
CI Bot
6e4ddc192e chore: update CHANGELOG for v0.7.7 2026-04-27 12:37:08 +00:00
Augustin ROUX
71978adb5f Merge pull request 'release: v0.7.7 stable — promote develop to main' (#17) from develop into main
All checks were successful
Stable Release / stable (push) Successful in 1m15s
Reviewed-on: #17
2026-04-27 12:35:44 +00:00
Augustin ROUX
af5fbf9324 Merge pull request 'fix(install): kill running muyue before extracting (v0.7.7)' (#16) from release/v0.7.7 into develop
All checks were successful
Beta Release / beta (push) Successful in 1m18s
PR Check / check (pull_request) Successful in 58s
Reviewed-on: #16
2026-04-27 12:31:32 +00:00
Muyue
29953bde6d fix(install): kill running muyue before extracting (v0.7.7)
All checks were successful
PR Check / check (pull_request) Successful in 1m3s
User reported v0.7.6 install silently no-op'd when v0.7.5 was still
running:

  $dest = "$env:LOCALAPPDATA\Muyue"
  Expand-Archive -Path "$env:TEMP\muyue.zip" -DestinationPath $dest -Force
  # No error, but the running v0.7.5 .exe stays in place because
  # Windows refuses to overwrite a locked file. After 'install', the
  # 'muyue' command still launches v0.7.5.

Add a Stop-Process step at the top of the install snippet:

  Get-Process muyue, muyue-windows-amd64 -ErrorAction SilentlyContinue |
    Stop-Process -Force
  Start-Sleep -Milliseconds 500

-ErrorAction SilentlyContinue makes it idempotent (no error on a
clean first install). The 500ms sleep gives Windows time to release
the file handle before Expand-Archive opens the destination paths.

Snippet bumps to 6 lines; explanatory note added so users updating
from a previous version know why this step matters.

- internal/version/version.go: 0.7.6 → 0.7.7
- CHANGELOG.md: v0.7.7 entry
2026-04-27 14:29:59 +02:00
CI Bot
6d155e483b chore: update CHANGELOG for v0.7.6 2026-04-27 12:09:54 +00:00
Augustin ROUX
e621b13926 Merge pull request 'release: v0.7.6 stable — promote develop to main' (#15) from develop into main
All checks were successful
Stable Release / stable (push) Successful in 1m12s
Reviewed-on: #15
2026-04-27 12:08:17 +00:00
Augustin ROUX
9d1d717999 Merge pull request 'fix(windows): ConPTY + kernel32 metrics + agent loop cap (v0.7.6)' (#14) from release/v0.7.6 into develop
All checks were successful
Beta Release / beta (push) Successful in 1m15s
PR Check / check (pull_request) Successful in 54s
Reviewed-on: #14
2026-04-27 12:06:17 +00:00
Muyue
d557b8e74c fix(windows): native ConPTY + kernel32 metrics + agent loop cap (v0.7.6)
All checks were successful
PR Check / check (pull_request) Successful in 1m0s
Three issues reported on Windows + one user-requested limit bump:

1. Dashboard CPU/RAM/Network all at 0
   handleSystemMetrics read /proc/* exclusively. Replaced with a
   platform-split:
   - metrics_unix.go (!windows): existing /proc reading code.
   - metrics_windows.go: kernel32!GetSystemTimes for CPU
     (delta of idle vs kernel+user FILETIMEs) and
     kernel32!GlobalMemoryStatusEx for memory. Network left at zero
     for now — MIB_IF_ROW2 is too version-sensitive to parse by hand.
   handlers_info.go::handleSystemMetrics reduced to one delegating
   call.

2. Terminal black screen on Windows
   creack/pty/v2 returns "unsupported" on Windows; the v0.7.1 pipe
   fallback works but pipes don't carry TTY signals, so cmd/pwsh/wsl
   go silent. Implemented native ConPTY:
   - terminal_conpty_windows.go: CreatePseudoConsole + STARTUPINFOEX
     + PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE wiring via
     windows.NewProcThreadAttributeList. CreateProcessW launches
     child with the PC attached, full ANSI / line discipline /
     resize.
   - canUseConPTY() probes once at startup (Win10 1809+ check).
   - Restructure: terminal_session.go now holds just the interface
     + ptySession + pipeSession structs. terminal_session_unix.go
     wires creack/pty. terminal_session_windows.go tries ConPTY
     first, falls back to pipeSession.

3. Agent stops after 15 tool calls
   MaxToolIterations bumped 15 → 500. Doc comment explains why the
   cap exists at all (infinite-loop safety) and that 500 is well
   above realistic usage.

- internal/version/version.go: 0.7.5 → 0.7.6
- CHANGELOG.md: v0.7.6 entry covers the three fixes
2026-04-27 14:04:41 +02:00
CI Bot
e31a01d200 chore: update CHANGELOG for v0.7.5 2026-04-27 11:43:08 +00:00
Augustin ROUX
b3a9a49680 Merge pull request 'release: v0.7.5 stable — promote develop to main' (#13) from develop into main
All checks were successful
Stable Release / stable (push) Successful in 1m15s
Reviewed-on: #13
2026-04-27 11:41:39 +00:00
Augustin ROUX
87e606c853 Merge pull request 'fix(windows): GUI subsystem + AttachConsole + muyue.exe canonical (v0.7.5)' (#12) from release/v0.7.5 into develop
Some checks failed
PR Check / check (pull_request) Has been cancelled
Beta Release / beta (push) Has been cancelled
Reviewed-on: #12
2026-04-27 11:40:28 +00:00
Muyue
79e467c32a fix(windows): GUI subsystem + parent-console attach + canonical muyue.exe (v0.7.5)
All checks were successful
PR Check / check (pull_request) Successful in 58s
Three Windows install/launch issues reported by the user:

1. Double-click on Desktop shortcut → dialog "This is a command line
   tool. You need to open cmd.exe and run it from there."
   Cause: charmbracelet/huh detects no TTY when launched via Explorer
   and aborts. Fix:
   - cmd/muyue/commands/root.go: skip RunFirstTimeSetup when
     os.Stdin is not a character device; persist config.Default()
     and let the React onboarding wizard handle first-run UX.
   - ci-{main,develop}.yml: build Windows binaries with
     -ldflags="-H=windowsgui" so the .exe is a GUI subsystem app —
     no console window flashes on double-click.

2. CLI sub-commands (`muyue scan`, `muyue install-shortcuts`, etc.)
   would lose all output under -H=windowsgui when launched from
   cmd.exe / PowerShell. Mitigation:
   - cmd/muyue/console_windows.go (new, build-tagged): on init(),
     call kernel32!AttachConsole(ATTACH_PARENT_PROCESS). If the
     parent has a console, rebind os.Stdout/os.Stderr/os.Stdin to
     it and call log.SetOutput(os.Stderr) so existing log.Printf
     calls surface. If no parent console (Explorer), exit silently.

3. After install, `muyue` not recognized in PowerShell.
   Causes: (a) the extracted binary is muyue-windows-amd64.exe, not
   muyue.exe; (b) the user PATH update by install-shortcuts doesn't
   propagate to the existing PowerShell session.
   Fix in install-shortcuts:
   - Copy self to <installDir>/muyue.exe (rename impossible — the
     running .exe is locked on Windows) so `muyue` resolves once
     PATH is set.
   - Update Desktop + Start Menu .lnk to target the canonical
     muyue.exe rather than the platform-suffixed binary.
   - Print the line `$env:Path += ';<installDir>'` for the user to
     paste, refreshing the current session immediately.
   - ci-main.yml install snippet bumps to 5 lines, last being
     `$env:Path += ";$dest"`.

- internal/version/version.go: 0.7.4 → 0.7.5
- CHANGELOG.md: v0.7.5 entry covers all three fixes
2026-04-27 13:39:22 +02:00
CI Bot
075d168dcd chore: update CHANGELOG for v0.7.4 2026-04-27 11:25:44 +00:00
Augustin ROUX
ed4c963576 Merge pull request 'release: v0.7.4 stable — promote develop to main' (#11) from develop into main
All checks were successful
Stable Release / stable (push) Successful in 1m9s
Reviewed-on: #11
2026-04-27 11:24:09 +00:00
Augustin ROUX
1ce5c49622 Merge pull request 'feat: integrate Muyue logo (icon embedded + web favicon) v0.7.4' (#10) from release/v0.7.4 into develop
All checks were successful
Beta Release / beta (push) Successful in 1m12s
PR Check / check (pull_request) Successful in 55s
Reviewed-on: #10
2026-04-27 11:19:46 +00:00
Muyue
830e085c2a feat: integrate Muyue logo (icon embedded in Windows binary + web favicon)
All checks were successful
PR Check / check (pull_request) Successful in 58s
Logo dropped at project root by user. Bake it everywhere it matters:

Assets:
- assets/muyue.ico — multi-res (16/24/32/48/64/128/256) generated via PIL
- assets/muyue-{16,32,64,128,256,512}.png — clean PNG resizes
- LogoMuyue.png kept at root as the source of truth

Windows binary (.exe):
- CI runs `rsrc -ico assets/muyue.ico -arch {amd64,arm64} -o cmd/muyue/rsrc_windows_{amd64,arm64}.syso`
  before `go build` (both ci-main.yml and ci-develop.yml)
- Go automatically links *.syso files matching the target GOOS/GOARCH —
  no code change in the cmd/muyue main package
- .syso files are gitignored: regenerated at every build, never committed
- Existing install-shortcuts subcommand already uses IconLocation =
  "$exe,0" so the embedded icon flows automatically into Desktop +
  Start Menu .lnk files

Web UI:
- web/public/favicon-{16,32}.png + muyue.png + muyue-64.png
- web/index.html: real <link rel="icon"> tags (16/32 PNG + apple-touch),
  replacing the placeholder SVG hexagon
- App.jsx header: 22×22 logo image rendered next to the "MUYUE" wordmark
  (rounded 4px corners for visual consistency with the source logo)

Install snippet (ci-main.yml changelog template):
- Idempotent first line: `New-Item -ItemType Directory -Force -Path $dest`
  to handle the case where the user re-runs after a partial install

Versioning unchanged (still v0.7.3 — these additions stay on the same
release branch / PR #9).
2026-04-27 13:13:56 +02:00
Augustin ROUX
24b09f5700 Merge pull request 'feat: onboarding MiniMax+MiMo + Windows install w/o admin (v0.7.3)' (#9) from release/v0.7.3 into develop
All checks were successful
Beta Release / beta (push) Successful in 1m10s
Reviewed-on: #9
2026-04-27 10:55:48 +00:00
Muyue
1442b4fd8a feat: onboarding 2-keys + Windows install w/o admin (v0.7.3)
All checks were successful
PR Check / check (pull_request) Successful in 56s
Two user-reported pain points:

1. First-run setup proposed only MiniMax (no MiMo step). Onboarding
   now offers both keys side-by-side under a single "apikey" step,
   with per-key Validate buttons. At least one must be valid to
   proceed; the rest of the providers (OpenAI/Anthropic/Z.AI/Ollama)
   are not shown in the wizard — they're configured later via the
   Config tab. Active provider = MiniMax if valid, else MiMo.

2. Windows install instructions failed: Move-Item to C:\Windows
   requires admin. Replaced with a no-admin 4-line snippet that
   installs to %LOCALAPPDATA%\Muyue and calls a new subcommand
   `muyue install-shortcuts` to create Desktop + Start Menu .lnk
   files and add the install dir to the user PATH. Shortcut creation
   uses WScript.Shell COM via PowerShell — keeps Go binary
   dependency-free. Folder paths resolved through
   [Environment]::GetFolderPath so OneDrive/redirected profiles
   work too.

- cmd/muyue/commands/install_shortcuts.go: new file
- web/src/components/OnboardingWizard.jsx: 2-key apikey step
- .gitea/workflows/ci-main.yml: updated install snippet
- internal/version/version.go: 0.7.2 → 0.7.3
- CHANGELOG.md: v0.7.3 entry
2026-04-27 12:12:18 +02:00
55 changed files with 8447 additions and 214 deletions

View File

@@ -32,13 +32,13 @@ jobs:
restore-keys: |
${{ runner.os }}-go-
- name: Cache Node modules
- name: Cache Node modules (web)
uses: actions/cache@v4
with:
path: web/node_modules
key: ${{ runner.os }}-node-${{ hashFiles('web/package-lock.json') }}
key: ${{ runner.os }}-node-web-${{ hashFiles('web/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
${{ runner.os }}-node-web-
- name: Download Go dependencies
run: go mod download
@@ -49,6 +49,14 @@ jobs:
npm ci
npm run build
- name: Build extension
run: |
cd extension
npm ci
npx wxt zip
mkdir -p ../dist
mv .output/muyue-extension-*.zip ../dist/
- name: Vet
run: go vet ./...
@@ -68,17 +76,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: |
@@ -144,7 +160,7 @@ jobs:
fi
echo "Release ID: ${RELEASE_ID}"
UPLOAD_URL="${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases/${RELEASE_ID}/assets"
for file in dist/*.tar.gz dist/*.zip dist/checksums.txt; do
for file in dist/*.tar.gz dist/muyue-windows-*.zip dist/checksums.txt dist/muyue-extension-*.zip; do
filename=$(basename "$file")
echo "Uploading ${filename}..."
curl -s -X POST "${UPLOAD_URL}" \

View File

@@ -32,13 +32,13 @@ jobs:
restore-keys: |
${{ runner.os }}-go-
- name: Cache Node modules
- name: Cache Node modules (web)
uses: actions/cache@v4
with:
path: web/node_modules
key: ${{ runner.os }}-node-${{ hashFiles('web/package-lock.json') }}
key: ${{ runner.os }}-node-web-${{ hashFiles('web/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
${{ runner.os }}-node-web-
- name: Download dependencies
run: go mod download
@@ -49,6 +49,14 @@ jobs:
npm ci
npm run build
- name: Build extension
run: |
cd extension
npm ci
npx wxt zip
mkdir -p ../dist
mv .output/muyue-extension-*.zip ../dist/
- name: Vet
run: go vet ./...
@@ -64,16 +72,28 @@ jobs:
echo "base=${BASE_VERSION}" >> $GITHUB_OUTPUT
echo "Building stable release: ${VERSION}"
- name: Generate Windows resource (icon)
run: |
go install github.com/akavel/rsrc@latest
RSRC="$(go env GOPATH)/bin/rsrc"
$RSRC -ico assets/muyue.ico -arch amd64 -o cmd/muyue/rsrc_windows_amd64.syso
$RSRC -ico assets/muyue.ico -arch arm64 -o cmd/muyue/rsrc_windows_arm64.syso
- name: Build (all platforms)
run: |
mkdir -p dist
LDFLAGS="-s -w"
# Windows builds use -H=windowsgui so the binary registers as a GUI
# subsystem app: double-clicking from the Desktop shortcut does not
# spawn a console window (and huh's "This is a command line tool"
# banner can never appear).
WIN_LDFLAGS="$LDFLAGS -H=windowsgui"
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="$LDFLAGS" -o dist/muyue-linux-amd64 ./cmd/muyue/
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="$LDFLAGS" -o dist/muyue-linux-arm64 ./cmd/muyue/
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags="$LDFLAGS" -o dist/muyue-darwin-amd64 ./cmd/muyue/
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags="$LDFLAGS" -o dist/muyue-darwin-arm64 ./cmd/muyue/
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags="$LDFLAGS" -o dist/muyue-windows-amd64.exe ./cmd/muyue/
CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -ldflags="$LDFLAGS" -o dist/muyue-windows-arm64.exe ./cmd/muyue/
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags="$WIN_LDFLAGS" -o dist/muyue-windows-amd64.exe ./cmd/muyue/
CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -ldflags="$WIN_LDFLAGS" -o dist/muyue-windows-arm64.exe ./cmd/muyue/
- name: Package archives
run: |
@@ -138,12 +158,17 @@ jobs:
echo "sudo mv muyue-darwin-arm64 /usr/local/bin/muyue"
echo "\`\`\`"
echo ""
echo "**Windows (x86_64)**"
echo "**Windows (x86_64)** — sans privilèges admin, crée les raccourcis Bureau + Menu Démarrer + commande \`muyue\` dans la session courante :"
echo "\`\`\`powershell"
echo "Invoke-WebRequest -Uri \"${DL_URL}/muyue-windows-amd64.zip\" -OutFile \"muyue.zip\""
echo "Expand-Archive -Path \"muyue.zip\" -DestinationPath \".\""
echo "Move-Item muyue-windows-amd64.exe C:\\Windows\\muyue.exe"
echo "Get-Process muyue, muyue-windows-amd64 -ErrorAction SilentlyContinue | Stop-Process -Force; Start-Sleep -Milliseconds 500"
echo "\$dest = \"\$env:LOCALAPPDATA\\Muyue\"; New-Item -ItemType Directory -Force -Path \$dest | Out-Null"
echo "Invoke-WebRequest -Uri \"${DL_URL}/muyue-windows-amd64.zip\" -OutFile \"\$env:TEMP\\muyue.zip\""
echo "Expand-Archive -Path \"\$env:TEMP\\muyue.zip\" -DestinationPath \$dest -Force"
echo "& \"\$dest\\muyue-windows-amd64.exe\" install-shortcuts"
echo "\$env:Path += \";\$dest\""
echo "\`\`\`"
echo ""
echo "Le 1ʳᵉ ligne tue toute instance Muyue déjà lancée (sinon Windows refuse d'écraser le \`.exe\` verrouillé et l'install échoue silencieusement). Si vous mettez à jour depuis une version précédente, c'est obligatoire."
} > /tmp/stable_changelog.md
echo "path=/tmp/stable_changelog.md" >> $GITHUB_OUTPUT
@@ -224,7 +249,7 @@ jobs:
fi
echo "Release ID: ${RELEASE_ID}"
UPLOAD_URL="${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases/${RELEASE_ID}/assets"
for file in dist/*.tar.gz dist/*.zip dist/checksums.txt; do
for file in dist/*.tar.gz dist/muyue-windows-*.zip dist/checksums.txt dist/muyue-extension-*.zip; do
filename=$(basename "$file")
echo "Uploading ${filename}..."
UPLOAD_RESP=$(curl -s -w "\n%{http_code}" -X POST "${UPLOAD_URL}" \

View File

@@ -30,13 +30,21 @@ jobs:
restore-keys: |
${{ runner.os }}-go-
- name: Cache Node modules
- name: Cache Node modules (web)
uses: actions/cache@v4
with:
path: web/node_modules
key: ${{ runner.os }}-node-${{ hashFiles('web/package-lock.json') }}
key: ${{ runner.os }}-node-web-${{ hashFiles('web/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
${{ runner.os }}-node-web-
- name: Cache Node modules (extension)
uses: actions/cache@v4
with:
path: extension/node_modules
key: ${{ runner.os }}-node-ext-${{ hashFiles('extension/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-ext-
- name: Download dependencies
run: go mod download
@@ -47,13 +55,20 @@ jobs:
npm ci
npm run build
- name: Build extension
run: |
cd extension
npm ci
npm run build
npm run build:firefox
- name: Vet
run: go vet ./...
- name: Test
run: go test ./... -v -race -timeout 60s
- name: Build
- name: Build binary
run: |
go build -o muyue ./cmd/muyue/
./muyue version

6
.gitignore vendored
View File

@@ -24,6 +24,7 @@ Thumbs.db
*.exe
*.test
*.out
*.syso
vendor/
# Config with secrets
@@ -31,3 +32,8 @@ vendor/
# Frontend (web/.gitignore handles specifics)
web/node_modules/
# Extension build artifacts
extension/node_modules/
extension/.output/
extension/.wxt/

View File

@@ -4,6 +4,495 @@ 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.8.0
### Changes since v0.8.0
- fix(extension): Firefox corrupt zip + duplicate uploads in CI (872e8bf)
### Downloads
| Platform | File |
|----------|------|
| Linux x86_64 | [muyue-linux-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.8.0/muyue-linux-amd64.tar.gz) |
| Linux ARM64 | [muyue-linux-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.8.0/muyue-linux-arm64.tar.gz) |
| macOS Intel | [muyue-darwin-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.8.0/muyue-darwin-amd64.tar.gz) |
| macOS Apple Silicon | [muyue-darwin-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.8.0/muyue-darwin-arm64.tar.gz) |
| Windows x86_64 | [muyue-windows-amd64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.8.0/muyue-windows-amd64.zip) |
| Windows ARM64 | [muyue-windows-arm64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.8.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.8.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.8.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)** — sans privilèges admin, crée les raccourcis Bureau + Menu Démarrer + commande `muyue` dans la session courante :
```powershell
Get-Process muyue, muyue-windows-amd64 -ErrorAction SilentlyContinue | Stop-Process -Force; Start-Sleep -Milliseconds 500
$dest = "$env:LOCALAPPDATA\Muyue"; New-Item -ItemType Directory -Force -Path $dest | Out-Null
Invoke-WebRequest -Uri "https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.8.0/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
$env:Path += ";$dest"
```
Le 1ʳᵉ ligne tue toute instance Muyue déjà lancée (sinon Windows refuse d'écraser le `.exe` verrouillé et l'install échoue silencieusement). Si vous mettez à jour depuis une version précédente, c'est obligatoire.
## v0.8.0
### Changes since v0.7.7
- fix(ci): create dist/ before moving extension zips (693b0e9)
- feat(extension): browser extension for Chrome/Edge/Firefox + CI + v0.8.0 (9f9f2bd)
- feat(browser-test): persistent token + auto-reconnect + screenshot + smarter strategy (v0.7.9) (97a2529)
- fix(windows/conpty): pass HPCON value, not &hPC (v0.7.8) (a348739)
### Downloads
| Platform | File |
|----------|------|
| Linux x86_64 | [muyue-linux-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.8.0/muyue-linux-amd64.tar.gz) |
| Linux ARM64 | [muyue-linux-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.8.0/muyue-linux-arm64.tar.gz) |
| macOS Intel | [muyue-darwin-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.8.0/muyue-darwin-amd64.tar.gz) |
| macOS Apple Silicon | [muyue-darwin-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.8.0/muyue-darwin-arm64.tar.gz) |
| Windows x86_64 | [muyue-windows-amd64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.8.0/muyue-windows-amd64.zip) |
| Windows ARM64 | [muyue-windows-arm64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.8.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.8.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.8.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)** — sans privilèges admin, crée les raccourcis Bureau + Menu Démarrer + commande `muyue` dans la session courante :
```powershell
Get-Process muyue, muyue-windows-amd64 -ErrorAction SilentlyContinue | Stop-Process -Force; Start-Sleep -Milliseconds 500
$dest = "$env:LOCALAPPDATA\Muyue"; New-Item -ItemType Directory -Force -Path $dest | Out-Null
Invoke-WebRequest -Uri "https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.8.0/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
$env:Path += ";$dest"
```
Le 1ʳᵉ ligne tue toute instance Muyue déjà lancée (sinon Windows refuse d'écraser le `.exe` verrouillé et l'install échoue silencieusement). Si vous mettez à jour depuis une version précédente, c'est obligatoire.
## v0.8.0
### Extension navigateur — Chrome, Edge, Firefox
Nouvelle fonctionnalité majeure : une extension navigateur multi-plateforme qui remplace l'injection manuelle du snippet JS pour les tests pilotés par l'IA.
**Ce que fait l'extension :**
- **Auto-injection** du client Muyue sur chaque page HTTP/HTTPS — plus besoin de copier-coller le snippet dans la console DevTools
- **Capture console** temps réel (log/warn/error/debug + window.onerror + unhandledrejection) transmise au serveur Muyue
- **Screenshots natifs** via `chrome.tabs.captureVisibleTab` / `browser.tabs.captureVisibleTab` — pixels parfaits, pas le hack SVG foreignObject
- **Side Panel** (Chrome/Edge) et **Sidebar** (Firefox) pour monitoring statut serveur + sessions connectées
- **Popup toolbar** : statut serveur, nombre de sessions, erreurs console, lien vers le dashboard
- **Badge dynamique** : nombre de sessions connectées (vert) ou statut serveur (rouge/orange)
- **Détection URL** via interception History API (pushState, replaceState, popstate) + MutationObserver + fallback polling — survit aux navigations SPA
- **Auto-reconnect** avec backoff exponentiel en cas de déconnexion transitoire
- **Compatible Firefox** via Manifest V2 (sidebar_action) et Chrome/Edge via Manifest V3 (sidePanel API)
**Architecture technique :**
- **WXT framework** (build multi-navigateur) avec Vite 8
- **Content script** : même protocole WS que le snippet existant — aucun changement backend nécessaire
- **Background service worker** : `chrome.alarms` pour health checks périodiques (pas de `setInterval`), `chrome.storage.local` pour la config (pas de `localStorage` en MV3)
- **Builds** : `npm run build` (Chrome MV3) + `npm run build:firefox` (Firefox MV2) + `npm run zip` (packages stores)
- **CI** : les 3 workflows (PR, beta, stable) buildent l'extension et attachent les `.zip` aux releases
**Fichiers ajoutés :**
```
extension/
├── package.json, wxt.config.js, .gitignore, README.md
├── public/icon/ # Icons copiés depuis assets/
└── src/
├── entrypoints/
│ ├── background.js # Service worker (token, badge, screenshots)
│ ├── content.js # Auto-injection WS + console capture + History API
│ ├── popup/ # HTML + JS du popup toolbar
│ └── sidepanel/ # HTML + JS du side panel / sidebar
├── lib/
│ ├── config.js # Storage async (chrome.storage + localStorage)
│ └── page-rpc.js # DOM RPC (list_clickables, click, type, eval)
└── styles/panel.css # Thème cyberpunk cohérent avec Muyue
```
**Autres changements :**
- **CI** : les 3 workflows (`ci-pr.yml`, `ci-develop.yml`, `ci-main.yml`) buildent l'extension et attachent les `.zip` aux releases
- **Makefile** : cibles `ext`, `ext-chrome`, `ext-firefox`, `ext-zip` ajoutées
- **README** : section "Browser Extension" ajoutée avec instructions install + dev
- **Version** : bump 0.7.9 → 0.8.0
## v0.7.9
### Tests pilotés par l'IA — robustesse + captures d'écran
Quatre problèmes signalés par l'utilisateur :
1. **Connexion perdue à chaque reload / navigation**. Le token était à usage unique (5 min TTL) et tombait dès la première reconnexion → l'IA perdait totalement la session.
2. **Page nouvellement ouverte invisible à l'IA**. Conséquence du même bug ci-dessus + JS context détruit à la navigation.
3. **Pas de captures d'écran**. L'IA ne pouvait pas prouver visuellement l'état d'une page.
4. **L'IA se perd en boucle d'outils** : ~150 appels pour l'équivalent de 5 actions humaines, parce qu'elle re-listait les éléments cliquables après chaque clic.
**Fixes** :
- **Token réutilisable avec TTL coulissant** (`ConsumeToken` ne supprime plus le token, refresh la TTL à 60 min sur chaque utilisation). L'utilisateur peut re-coller le même snippet de l'onglet Tests sans avoir à régénérer un token, et la session reprend transparente.
- **Auto-reconnect dans le snippet** avec backoff exponentiel (500ms × tentative, max 5 tentatives = ~2,5s). Couvre les déconnexions transitoires (réseau, hibernation, redémarrage du serveur Muyue). Pour une vraie navigation full-page (URL change, JS context détruit), aucun JS ne peut survivre — l'utilisateur doit recoller le snippet, mais c'est immédiat car le token reste valide.
- **Nouvelle action `screenshot`** : le snippet capture la viewport (ou un sélecteur via `selector`) en SVG `foreignObject` + canvas, retourne un data URL base64. Le serveur décode et sauve dans `~/.muyue/screenshots/<filename>.png` (nom personnalisable via `filename`, sinon timestamp). Best-effort — CSS externes / images cross-origin / iframes peuvent ne pas apparaître ; les sélecteurs sont sanitisés (pas d'évasion vers d'autres dossiers).
- **Stratégie de test re-écrite dans le system prompt Studio** : règle d'or *"ne PAS ré-appeler `list_clickables` après chaque clic"*. Tableau des actions avec leur coût relatif (`summary` cher mais utile au début, `eval` ciblé > `list_clickables` complet, etc.). Format de rapport final standardisé (✓ / ✗ / ⚠ / 📸).
Inclut également **v0.7.8** (fix régression v0.7.6 : `unsafe.Pointer(uintptr(hPC))` au lieu de `&hPC` dans `UpdateProcThreadAttribute(PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE)` — corrige les terminaux qui s'ouvraient en fenêtre externe).
## v0.7.8
### Fix régression v0.7.6 : terminaux ouverts en fenêtre externe
Symptôme rapporté : depuis v0.7.6, cliquer sur PowerShell / cmd dans l'onglet Terminal ouvre une **fenêtre console séparée** au lieu de s'afficher dans le tab xterm.js (régression — v0.7.5 fonctionnait).
**Cause** : le binding ConPTY introduit en v0.7.6 passait `&hPC` (pointeur vers la variable Go locale) à `UpdateProcThreadAttribute(PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE, …)`. Or cet attribut est un quirk de l'API Win32 : `lpValue` doit être la **valeur du handle** (cast en `PVOID`), **pas** un pointeur vers la variable. Avec `&hPC`, le kernel lisait des octets aléatoires, l'attribut PSEUDOCONSOLE était silencieusement ignoré, et `CreateProcessW` créait une nouvelle console pour l'enfant — d'où la fenêtre externe.
**Fix** (1 ligne) :
```go
// Avant
unsafe.Pointer(&hPC)
// Après
unsafe.Pointer(uintptr(hPC)) // le HPCON value comme PVOID
```
Référence : Microsoft EchoCon sample + bibliothèques Go ConPTY existantes (`UserExistsError/conpty`, `aymanbagabas/go-pty`) utilisent toutes la valeur du handle directement.
Conséquence : terminaux PowerShell / cmd / WSL s'ouvrent à nouveau **dans** le tab xterm.js avec TTY complet (ANSI, prompt couleur, vim, etc.).
## v0.7.7
### Changes since v0.7.6
- fix(install): kill running muyue before extracting (v0.7.7) (29953bd)
### Downloads
| Platform | File |
|----------|------|
| Linux x86_64 | [muyue-linux-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.7/muyue-linux-amd64.tar.gz) |
| Linux ARM64 | [muyue-linux-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.7/muyue-linux-arm64.tar.gz) |
| macOS Intel | [muyue-darwin-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.7/muyue-darwin-amd64.tar.gz) |
| macOS Apple Silicon | [muyue-darwin-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.7/muyue-darwin-arm64.tar.gz) |
| Windows x86_64 | [muyue-windows-amd64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.7/muyue-windows-amd64.zip) |
| Windows ARM64 | [muyue-windows-arm64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.7/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.7/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.7/muyue-darwin-arm64.tar.gz | tar xz
chmod +x muyue-darwin-arm64
sudo mv muyue-darwin-arm64 /usr/local/bin/muyue
```
**Windows (x86_64)** — sans privilèges admin, crée les raccourcis Bureau + Menu Démarrer + commande `muyue` dans la session courante :
```powershell
Get-Process muyue, muyue-windows-amd64 -ErrorAction SilentlyContinue | Stop-Process -Force; Start-Sleep -Milliseconds 500
$dest = "$env:LOCALAPPDATA\Muyue"; New-Item -ItemType Directory -Force -Path $dest | Out-Null
Invoke-WebRequest -Uri "https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.7/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
$env:Path += ";$dest"
```
Le 1ʳᵉ ligne tue toute instance Muyue déjà lancée (sinon Windows refuse d'écraser le `.exe` verrouillé et l'install échoue silencieusement). Si vous mettez à jour depuis une version précédente, c'est obligatoire.
## v0.7.7
### Fix : install Windows échoue silencieusement quand une version précédente tourne
Symptôme rapporté en mettant à jour de v0.7.5 → v0.7.6 : `Expand-Archive ... -Force` semble réussir mais le `.exe` n'est en réalité pas écrasé (Windows refuse de remplacer un fichier verrouillé), donc après l'install, `muyue` lance toujours l'ancienne version. Aucun message d'erreur visible — d'où le côté traître.
**Fix** : ajout d'une 1ʳᵉ ligne au snippet d'install qui tue toute instance Muyue déjà lancée :
```powershell
Get-Process muyue, muyue-windows-amd64 -ErrorAction SilentlyContinue | Stop-Process -Force; Start-Sleep -Milliseconds 500
```
`-ErrorAction SilentlyContinue` rend l'étape idempotente (pas d'erreur si rien ne tourne, cas d'install propre). Le `Start-Sleep` 500ms laisse Windows libérer le file handle. Le snippet officiel passe à 6 lignes ; une note explicative est ajoutée dans la section *Install* du changelog généré.
## v0.7.6
### Changes since v0.7.5
- fix(windows): native ConPTY + kernel32 metrics + agent loop cap (v0.7.6) (d557b8e)
### Downloads
| Platform | File |
|----------|------|
| Linux x86_64 | [muyue-linux-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.6/muyue-linux-amd64.tar.gz) |
| Linux ARM64 | [muyue-linux-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.6/muyue-linux-arm64.tar.gz) |
| macOS Intel | [muyue-darwin-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.6/muyue-darwin-amd64.tar.gz) |
| macOS Apple Silicon | [muyue-darwin-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.6/muyue-darwin-arm64.tar.gz) |
| Windows x86_64 | [muyue-windows-amd64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.6/muyue-windows-amd64.zip) |
| Windows ARM64 | [muyue-windows-arm64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.6/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.6/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.6/muyue-darwin-arm64.tar.gz | tar xz
chmod +x muyue-darwin-arm64
sudo mv muyue-darwin-arm64 /usr/local/bin/muyue
```
**Windows (x86_64)** — sans privilèges admin, crée les raccourcis Bureau + Menu Démarrer + commande `muyue` dans la session courante :
```powershell
$dest = "$env:LOCALAPPDATA\Muyue"; New-Item -ItemType Directory -Force -Path $dest | Out-Null
Invoke-WebRequest -Uri "https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.6/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
$env:Path += ";$dest"
```
## 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
### Changes since v0.7.4
- fix(windows): GUI subsystem + parent-console attach + canonical muyue.exe (v0.7.5) (79e467c)
### Downloads
| Platform | File |
|----------|------|
| Linux x86_64 | [muyue-linux-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.5/muyue-linux-amd64.tar.gz) |
| Linux ARM64 | [muyue-linux-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.5/muyue-linux-arm64.tar.gz) |
| macOS Intel | [muyue-darwin-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.5/muyue-darwin-amd64.tar.gz) |
| macOS Apple Silicon | [muyue-darwin-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.5/muyue-darwin-arm64.tar.gz) |
| Windows x86_64 | [muyue-windows-amd64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.5/muyue-windows-amd64.zip) |
| Windows ARM64 | [muyue-windows-arm64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.5/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.5/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.5/muyue-darwin-arm64.tar.gz | tar xz
chmod +x muyue-darwin-arm64
sudo mv muyue-darwin-arm64 /usr/local/bin/muyue
```
**Windows (x86_64)** — sans privilèges admin, crée les raccourcis Bureau + Menu Démarrer + commande `muyue` dans la session courante :
```powershell
$dest = "$env:LOCALAPPDATA\Muyue"; New-Item -ItemType Directory -Force -Path $dest | Out-Null
Invoke-WebRequest -Uri "https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.5/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
$env:Path += ";$dest"
```
## 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
### Changes since v0.7.2
- feat: integrate Muyue logo (icon embedded in Windows binary + web favicon) (830e085)
- feat: onboarding 2-keys + Windows install w/o admin (v0.7.3) (1442b4f)
### Downloads
| Platform | File |
|----------|------|
| Linux x86_64 | [muyue-linux-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.4/muyue-linux-amd64.tar.gz) |
| Linux ARM64 | [muyue-linux-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.4/muyue-linux-arm64.tar.gz) |
| macOS Intel | [muyue-darwin-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.4/muyue-darwin-amd64.tar.gz) |
| macOS Apple Silicon | [muyue-darwin-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.4/muyue-darwin-arm64.tar.gz) |
| Windows x86_64 | [muyue-windows-amd64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.4/muyue-windows-amd64.zip) |
| Windows ARM64 | [muyue-windows-arm64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.4/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.4/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.4/muyue-darwin-arm64.tar.gz | tar xz
chmod +x muyue-darwin-arm64
sudo mv muyue-darwin-arm64 /usr/local/bin/muyue
```
**Windows (x86_64)** — sans privilèges admin, crée les raccourcis Bureau + Menu Démarrer :
```powershell
$dest = "$env:LOCALAPPDATA\Muyue"; New-Item -ItemType Directory -Force -Path $dest | Out-Null
Invoke-WebRequest -Uri "https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.4/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
```
## 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
### Changes since v0.7.0

BIN
LogoMuyue.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

View File

@@ -7,7 +7,9 @@ NODE ?= node
NPM ?= npm
WEB_DIR = web
.PHONY: build install clean test test-short run scan fmt lint build-all deps vet frontend dev-desktop
EXT_DIR = extension
.PHONY: build install clean test test-short run scan fmt lint build-all deps vet frontend dev-desktop ext ext-zip
frontend:
cd $(WEB_DIR) && $(NPM) ci && $(NPM) run build
@@ -63,5 +65,11 @@ build-all: frontend
GOOS=windows GOARCH=amd64 $(GO) build -o dist/$(BINARY)-windows-amd64.exe ./cmd/muyue/
GOOS=windows GOARCH=arm64 $(GO) build -o dist/$(BINARY)-windows-arm64.exe ./cmd/muyue/
ext:
cd $(EXT_DIR) && $(NPM) ci && $(NPM) run build
ext-zip:
cd $(EXT_DIR) && $(NPM) ci && $(NPM) run zip
deps:
$(GO) mod tidy

View File

@@ -17,6 +17,45 @@ AI-powered development environment assistant by **La Légion de Muyue**.
- **i18n** — Full FR/EN support with keyboard layout awareness (AZERTY, QWERTY, QWERTZ)
- **4 themes** — Cyberpunk Red, Cyberpunk Pink, Midnight Blue, Matrix Green
## Browser Extension
Muyue ships a **browser extension** (Chrome, Edge, Firefox) that replaces the manual snippet injection for the Tests tab:
- **Auto-injects** the Muyue test client on every HTTP/HTTPS page — no more copy-paste
- **Captures console** errors/warnings in real-time
- **Native screenshots** via `captureVisibleTab` — pixel-perfect
- **Side Panel** (Chrome/Edge) and **Sidebar** (Firefox) for status monitoring
- **Badge** shows active session count or server status
### Install from source
```bash
cd extension
npm install
npm run build # Chrome/Edge → .output/chrome-mv3/
npm run build:firefox # Firefox → .output/firefox-mv2/
```
Then load the extension:
- **Chrome/Edge**: `chrome://extensions` → Developer mode → Load unpacked → select `extension/.output/chrome-mv3/`
- **Firefox**: `about:debugging#/runtime/this-firefox` → Load temporary Add-on → select any file in `extension/.output/firefox-mv2/`
### Download pre-built
Extension `.zip` files are attached to every [release](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases):
- `muyue-extension-*-chrome.zip` — Chrome Web Store ready
- `muyue-extension-*-firefox.zip` — Firefox Add-ons ready
- `muyue-extension-*-sources.zip` — Required source for Firefox Add-ons review
### Development
```bash
cd extension
npm run dev # Chrome dev mode with HMR
npm run dev -- --browser firefox # Firefox dev mode
```
## Tech Stack
| Layer | Technology |
@@ -186,6 +225,10 @@ The Go backend serves 15 REST endpoints under `/api/`:
│ │ ├── styles/global.css # Full CSS theme system
│ │ └── themes/index.js # 4 themes with CSS variable injection
│ └── vite.config.js # Vite + dev proxy to :8095
├── extension/ # Browser extension (WXT, Chrome/Edge/Firefox)
│ ├── src/entrypoints/ # background, content, popup, sidepanel
│ ├── src/lib/ # config, page-rpc (shared logic)
│ └── src/styles/ # cyberpunk panel CSS
├── .gitea/workflows/ # CI/CD (PR check, beta, stable)
└── Makefile # build, test, lint, cross-compile
```

BIN
assets/muyue-128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

BIN
assets/muyue-16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 750 B

BIN
assets/muyue-256.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

BIN
assets/muyue-32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

BIN
assets/muyue-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 307 KiB

BIN
assets/muyue-64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

BIN
assets/muyue.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

View 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
}

View File

@@ -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
}

View 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)
}

4
extension/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules/
.output/
.wxt/
*.zip

81
extension/README.md Normal file
View File

@@ -0,0 +1,81 @@
# Muyue Browser Extension
AI-powered browser testing & automation, connected to your [Muyue](https://github.com/muyue/muyue) desktop app.
## What it does
- **Auto-injects** the Muyue test client on every page — no more manual snippet copy-paste
- **Captures console** errors/warnings in real-time, sent to the AI Studio
- **Enables AI-driven testing**: click buttons, fill inputs, evaluate JS, take screenshots
- **Side Panel** (Chrome/Edge) and **Sidebar** (Firefox) for status monitoring
- **Native screenshots** via `chrome.tabs.captureVisibleTab` — pixel-perfect, no SVG hacks
- **URL change detection** via History API interception (survives SPA navigation)
- **Badge indicator**: shows connected session count or server status
## Install
### Chrome / Edge
1. Run `npm run build`
2. Open `chrome://extensions` → Enable **Developer mode**
3. Click **Load unpacked** → select `extension/.output/chrome-mv3/`
Or install the published extension from the Chrome Web Store.
### Firefox
1. Run `npm run build:firefox`
2. Open `about:debugging#/runtime/this-firefox`
3. Click **Load temporary Add-on** → select any file in `extension/.output/firefox-mv2/`
## Development
```bash
cd extension
npm install
npm run dev # Chrome dev mode with HMR
npm run dev -- --browser firefox # Firefox dev mode
```
## Build
```bash
npm run build # Chrome/Edge MV3 → .output/chrome-mv3/
npm run build:firefox # Firefox MV2 → .output/firefox-mv2/
npm run zip # Chrome .zip for Web Store
npm run zip:firefox # Firefox .zip + sources .zip
```
## Architecture
```
┌─────────────────────────────────────────────────────┐
│ Content Script (every HTTP/HTTPS page) │
│ - Console interception (log/warn/error) │
│ - RPC execution (click, type, eval, list) │
│ - URL change detection (History API + MutationObs) │
│ - WebSocket → Muyue server (same as snippet) │
└──────────────┬──────────────────────────────────────┘
│ chrome.runtime messaging
┌──────────────┴──────────────────────────────────────┐
│ Background Service Worker │
│ - Token management (GET /api/test/snippet) │
│ - Native screenshots (captureVisibleTab) │
│ - Badge updates (session count / server status) │
│ - chrome.alarms for periodic health checks │
└──────────────────────────────────────────────────────┘
┌──────────────────┐ ┌──────────────────┐
│ Popup │ │ Side Panel │
│ - Server status │ │ - Sessions list │
│ - Session count │ │ - Auto-refresh │
│ - Dashboard link │ │ - Dashboard link │
└──────────────────┘ └──────────────────┘
```
## Compatibility
| Browser | Manifest | Side Panel | Screenshots |
|---------|----------|------------|-------------|
| Chrome 89+ | MV3 | ✅ sidePanel API | ✅ captureVisibleTab |
| Edge 89+ | MV3 | ✅ sidePanel API | ✅ captureVisibleTab |
| Firefox | MV2 | ✅ sidebar API | ✅ tabs.captureVisibleTab |

4711
extension/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

14
extension/package.json Normal file
View File

@@ -0,0 +1,14 @@
{
"name": "muyue-extension",
"version": "0.9.0",
"private": true,
"type": "module",
"scripts": {
"dev": "wxt",
"build": "wxt build",
"zip": "wxt zip"
},
"dependencies": {
"wxt": "^0.20"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 750 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 307 KiB

View File

@@ -0,0 +1,116 @@
import { fetchToken, fetchSessions, checkServerHealth, getServerUrl } from '../lib/config';
export default defineBackground(() => {
let token = null;
let wsUrl = null;
let serverOnline = false;
let errorCount = 0;
async function refreshToken() {
try {
const data = await fetchToken();
token = data.token;
wsUrl = data.wsUrl;
serverOnline = true;
return data;
} catch {
serverOnline = false;
token = null;
wsUrl = null;
return null;
}
}
async function updateBadge() {
try {
serverOnline = await checkServerHealth();
} catch {
serverOnline = false;
}
if (!serverOnline) {
chrome.action.setBadgeText({ text: '✕' });
chrome.action.setBadgeBackgroundColor({ color: '#ff6b6b' });
return;
}
try {
const sessions = await fetchSessions();
const count = sessions.length;
if (count > 0) {
chrome.action.setBadgeText({ text: String(count) });
chrome.action.setBadgeBackgroundColor({ color: '#3aaa61' });
} else {
chrome.action.setBadgeText({ text: '○' });
chrome.action.setBadgeBackgroundColor({ color: '#888' });
}
} catch {
chrome.action.setBadgeText({ text: '?' });
chrome.action.setBadgeBackgroundColor({ color: '#f5a623' });
}
}
async function handleScreenshot() {
try {
const dataUrl = await chrome.tabs.captureVisibleTab(null, {
format: 'png',
quality: 100,
});
return { ok: true, data_url: dataUrl };
} catch (e) {
return { ok: false, error: 'capture failed: ' + String(e) };
}
}
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg.type === 'get_state') {
getServerUrl().then((url) => {
sendResponse({
serverOnline,
token,
wsUrl,
errorCount,
serverUrl: url,
});
});
return true;
}
if (msg.type === 'get_token') {
refreshToken().then((data) => sendResponse(data));
return true;
}
if (msg.type === 'check_health') {
checkServerHealth().then((ok) => {
serverOnline = ok;
sendResponse({ online: ok });
});
return true;
}
if (msg.type === 'screenshot') {
handleScreenshot().then(sendResponse);
return true;
}
if (msg.type === 'refresh_badge') {
updateBadge();
return false;
}
if (msg.type === 'increment_errors') {
errorCount++;
return false;
}
return false;
});
chrome.alarms.create('muyue-badge', { periodInMinutes: 0.17 });
chrome.alarms.onAlarm.addListener((alarm) => {
if (alarm.name === 'muyue-badge') updateBadge();
});
updateBadge();
});

View File

@@ -0,0 +1,193 @@
import { dispatch } from '../lib/page-rpc';
export default defineContentScript({
matches: ['http://*/*', 'https://*/*'],
runAt: 'document_idle',
main() {
if (window.__muyueExtension) return;
window.__muyueExtension = true;
let ws = null;
let retryDelay = 0;
let token = null;
let wsBaseUrl = null;
const TAG = '[Muyue]';
function log(...args) {
console.log(TAG, ...args);
}
function send(obj) {
try {
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(obj));
} catch {}
}
function reply(id, data) {
send({ type: 'reply', id, data });
}
function sendConsole(level, text) {
send({ type: 'console', level, text });
}
async function getToken() {
return new Promise((resolve) => {
chrome.runtime.sendMessage({ type: 'get_token' }, (response) => {
if (chrome.runtime.lastError) {
resolve(null);
return;
}
resolve(response);
});
});
}
function screenshotNative(params) {
return new Promise((resolve) => {
chrome.runtime.sendMessage({ type: 'screenshot', params }, (response) => {
if (chrome.runtime.lastError) {
resolve({ ok: false, error: String(chrome.runtime.lastError) });
return;
}
resolve(response || { ok: false, error: 'no response' });
});
});
}
async function connect() {
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
return;
}
if (!token) {
const data = await getToken();
if (!data) {
retryDelay = Math.min(retryDelay + 1, 5);
setTimeout(connect, 1000 * retryDelay);
return;
}
token = data.token;
wsBaseUrl = data.wsUrl;
}
try {
const wsUrl = wsBaseUrl || `ws://127.0.0.1:8080/api/ws/browser-test?token=${token}`;
ws = new WebSocket(wsUrl);
} catch {
retryDelay = Math.min(retryDelay + 1, 5);
setTimeout(connect, 1000 * retryDelay);
return;
}
ws.onopen = () => {
retryDelay = 0;
send({ type: 'hello', url: location.href, title: document.title });
log('connected to Muyue server');
};
ws.onmessage = (ev) => {
let msg;
try { msg = JSON.parse(ev.data); } catch { return; }
if (msg.type === 'registered') {
log('session registered:', msg.session_id);
return;
}
if (msg.action) {
if (msg.action === 'screenshot') {
screenshotNative(msg.params || {}).then((r) => reply(msg.id, r));
return;
}
const result = dispatch(msg);
if (result && typeof result.then === 'function') {
result.then((r) => reply(msg.id, r));
} else {
reply(msg.id, result);
}
}
};
ws.onclose = () => {
retryDelay = Math.min(retryDelay + 1, 5);
setTimeout(connect, 500 * retryDelay);
};
ws.onerror = () => {};
}
['log', 'info', 'warn', 'error', 'debug'].forEach((lvl) => {
const orig = console[lvl];
console[lvl] = function () {
try {
const parts = Array.from(arguments).map((a) => {
if (typeof a === 'string') return a;
try { return JSON.stringify(a); } catch { return String(a); }
});
const text = parts.join(' ');
if (!text.startsWith(TAG)) {
sendConsole(lvl, text);
if (lvl === 'error') {
chrome.runtime.sendMessage({ type: 'increment_errors' }).catch(() => {});
}
}
} catch {}
return orig.apply(console, arguments);
};
});
window.addEventListener('error', (e) => {
sendConsole('error', 'window.onerror: ' + (e.message || 'unknown'));
chrome.runtime.sendMessage({ type: 'increment_errors' }).catch(() => {});
});
window.addEventListener('unhandledrejection', (e) => {
sendConsole('error', 'unhandledrejection: ' + String(e.reason));
chrome.runtime.sendMessage({ type: 'increment_errors' }).catch(() => {});
});
let lastUrl = location.href;
const urlObserver = new MutationObserver(() => {
if (location.href !== lastUrl) {
lastUrl = location.href;
send({ type: 'url_change', url: lastUrl });
}
});
urlObserver.observe(document.documentElement, { childList: true, subtree: true });
const origPushState = history.pushState;
history.pushState = function () {
origPushState.apply(this, arguments);
if (location.href !== lastUrl) {
lastUrl = location.href;
send({ type: 'url_change', url: lastUrl });
}
};
const origReplaceState = history.replaceState;
history.replaceState = function () {
origReplaceState.apply(this, arguments);
if (location.href !== lastUrl) {
lastUrl = location.href;
send({ type: 'url_change', url: lastUrl });
}
};
window.addEventListener('popstate', () => {
if (location.href !== lastUrl) {
lastUrl = location.href;
send({ type: 'url_change', url: lastUrl });
}
});
setInterval(() => {
if (location.href !== lastUrl) {
lastUrl = location.href;
send({ type: 'url_change', url: lastUrl });
}
}, 500);
connect();
},
});

View File

@@ -0,0 +1,55 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=320" />
</head>
<body>
<div class="panel" style="width:320px">
<header>
<img src="/icon/32.png" alt="Muyue" />
<h1>Muyue</h1>
</header>
<div class="status-card">
<div class="status-row">
<span class="status-label">Server</span>
<span class="status-value" id="server-status">
<span class="dot dot-yellow"></span>Checking…
</span>
</div>
<div class="status-row">
<span class="status-label">Active sessions</span>
<span class="status-value" id="session-count"></span>
</div>
<div class="status-row">
<span class="status-label">Console errors</span>
<span class="status-value" id="error-count">0</span>
</div>
</div>
<div class="actions">
<a id="btn-dashboard" href="#" class="btn btn-primary" target="_blank">
Open Dashboard
</a>
<button id="btn-sidepanel" class="btn">
Open Chat Panel
</button>
</div>
<div class="settings-section">
<label>Server URL</label>
<div class="input-row">
<input type="text" id="server-url" placeholder="http://127.0.0.1:8080" />
<button id="btn-save-url">Save</button>
</div>
</div>
<div class="footer">
<span>Muyue</span> extension v0.9.0
</div>
</div>
<script src="./main.js" type="module"></script>
</body>
</html>

View File

@@ -0,0 +1,54 @@
import '../../styles/panel.css';
import { getServerUrl, setServerUrl, fetchSessions } from '../../lib/config';
const $serverStatus = document.getElementById('server-status');
const $sessionCount = document.getElementById('session-count');
const $errorCount = document.getElementById('error-count');
const $btnDashboard = document.getElementById('btn-dashboard');
const $btnSidepanel = document.getElementById('btn-sidepanel');
const $serverUrl = document.getElementById('server-url');
const $btnSaveUrl = document.getElementById('btn-save-url');
function dot(color) {
return `<span class="dot dot-${color}"></span>`;
}
async function refresh() {
const url = await getServerUrl();
$serverUrl.value = url;
$btnDashboard.href = url;
try {
const sessions = await fetchSessions();
$serverStatus.innerHTML = `${dot('green')} Online`;
$sessionCount.textContent = sessions.length;
} catch {
$serverStatus.innerHTML = `${dot('red')} Offline`;
$sessionCount.textContent = '—';
}
chrome.runtime.sendMessage({ type: 'get_state' }, (state) => {
if (chrome.runtime.lastError || !state) return;
$errorCount.textContent = state.errorCount || 0;
});
}
$btnSaveUrl.addEventListener('click', async () => {
const url = $serverUrl.value.trim().replace(/\/$/, '');
if (url) {
await setServerUrl(url);
refresh();
}
});
$btnSidepanel.addEventListener('click', async () => {
try {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (tab) {
chrome.sidePanel.open({ tabId: tab.id });
window.close();
}
} catch {}
});
refresh();

View File

@@ -0,0 +1,87 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
</head>
<body>
<div class="panel">
<header>
<img src="/icon/32.png" alt="Muyue" />
<h1>Muyue</h1>
</header>
<nav class="tabs">
<button class="tab active" data-tab="config">Configuration</button>
<button class="tab" data-tab="chat">Chat</button>
</nav>
<section id="tab-config" class="tab-content active">
<div class="status-card">
<div class="status-row">
<span class="status-label">Server</span>
<span class="status-value" id="server-status">
<span class="dot dot-yellow"></span>Checking…
</span>
</div>
<div class="status-row">
<span class="status-label">Active sessions</span>
<span class="status-value" id="session-count"></span>
</div>
<div class="status-row">
<span class="status-label">Console errors</span>
<span class="status-value" id="error-count">0</span>
</div>
</div>
<div id="sessions-list"></div>
<div class="actions">
<a id="btn-dashboard" href="#" class="btn btn-primary" target="_blank">
Open Dashboard
</a>
</div>
<div class="settings-section">
<label>Server URL</label>
<div class="input-row">
<input type="text" id="server-url" placeholder="http://127.0.0.1:8080" />
<button id="btn-save-url">Save</button>
</div>
</div>
</section>
<section id="tab-chat" class="tab-content">
<div id="chat-offline" class="chat-offline">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="var(--text-secondary)" stroke-width="1.5">
<circle cx="12" cy="12" r="10"/><line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/>
</svg>
<span>Server offline</span>
</div>
<div id="chat-area" class="chat-area" style="display:none">
<div id="chat-feed" class="chat-feed"></div>
<div id="chat-streaming" class="chat-streaming" style="display:none"></div>
<div class="chat-input-row">
<textarea id="chat-input" placeholder="Send a message…" rows="1"></textarea>
<button id="chat-send" class="chat-send-btn">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/>
</svg>
</button>
<button id="chat-stop" class="chat-stop-btn" style="display:none">
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
<rect x="4" y="4" width="16" height="16" rx="2"/>
</svg>
</button>
</div>
</div>
</section>
<div class="footer">
<span>Muyue</span> extension v0.9.0
</div>
</div>
<script src="./main.js" type="module"></script>
</body>
</html>

View File

@@ -0,0 +1,447 @@
import '../../styles/panel.css';
import { getServerUrl, setServerUrl, fetchSessions, checkServerHealth } from '../../lib/config';
import { getChatHistory, sendChat, clearChat } from '../../lib/api';
const $ = (s) => document.querySelector(s);
const $$ = (s) => document.querySelectorAll(s);
const $serverStatus = $('#server-status');
const $sessionCount = $('#session-count');
const $errorCount = $('#error-count');
const $sessionsList = $('#sessions-list');
const $btnDashboard = $('#btn-dashboard');
const $serverUrl = $('#server-url');
const $btnSaveUrl = $('#btn-save-url');
const $chatOffline = $('#chat-offline');
const $chatArea = $('#chat-area');
const $chatFeed = $('#chat-feed');
const $chatStreaming = $('#chat-streaming');
const $chatInput = $('#chat-input');
const $chatSend = $('#chat-send');
const $chatStop = $('#chat-stop');
let serverOnline = false;
let messages = [];
let loading = false;
let abortController = null;
let currentStreamingEl = null;
function dot(color) {
return `<span class="dot dot-${color}"></span>`;
}
function renderSessions(sessions) {
if (sessions.length === 0) {
$sessionsList.innerHTML = '';
return;
}
$sessionsList.innerHTML = `
<div class="status-card" style="margin-top:12px">
<div style="font-size:11px;color:var(--text-secondary);margin-bottom:8px;text-transform:uppercase;letter-spacing:0.5px">
Connected tabs
</div>
${sessions.map((s) => `
<div class="status-row">
<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:200px" title="${s.url}">
${s.title || s.url || s.id}
</span>
<span style="font-size:10px;color:var(--text-secondary);font-family:var(--font-mono)">
${s.id.slice(0, 8)}
</span>
</div>
`).join('')}
</div>
`;
}
function formatText(text) {
let html = text
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
html = html
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/`([^`]+)`/g, '<code class="inline-code">$1</code>')
.replace(/^### (.+)$/gm, '<h4>$1</h4>')
.replace(/^## (.+)$/gm, '<h3>$1</h3>')
.replace(/^# (.+)$/gm, '<h2>$1</h2>')
.replace(/^\s*[-*] (.+)$/gm, '<div class="chat-bullet">• $1</div>')
.replace(/^\s*(\d+)[.)] (.+)$/gm, '<div class="chat-step"><span class="chat-step-num">$1</span> $2</div>')
.replace(/\n/g, '<br/>');
html = html
.replace(/<br\/>\s*<br\/>/g, '<br/>')
.replace(/<br\/>\s*(<h[234]|<div class="chat-)/g, '$1')
.replace(/(<\/h[234]|<\/div>)\s*<br\/>/g, '$1');
return html;
}
function renderContent(text) {
const parts = [];
const codeBlockRegex = /(```[\s\S]*?```)/g;
let match;
let lastIndex = 0;
while ((match = codeBlockRegex.exec(text)) !== null) {
if (match.index > lastIndex) {
parts.push({ type: 'text', content: text.slice(lastIndex, match.index) });
}
const full = match[1];
const firstNewline = full.indexOf('\n');
const lang = firstNewline > -1 ? full.slice(3, firstNewline).trim() : '';
const code = firstNewline > -1 ? full.slice(firstNewline + 1, -3) : full.slice(3, -3);
parts.push({ type: 'code', lang, content: code });
lastIndex = match.index + full.length;
}
if (lastIndex < text.length) {
parts.push({ type: 'text', content: text.slice(lastIndex) });
}
return parts;
}
function escapeHtml(text) {
return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function createMessageEl(msg) {
const el = document.createElement('div');
el.className = `chat-msg ${msg.role}`;
if (msg.role === 'system') {
el.innerHTML = `<div class="chat-system-dot"></div><div class="chat-system-text">${escapeHtml(msg.content)}</div>`;
return el;
}
const isUser = msg.role === 'user';
const avatar = isUser ? '★' : '◆';
const label = isUser ? 'CDT' : 'GEN';
let displayContent = msg.content;
let parsedToolCalls = null;
let parsedSegments = null;
try {
const parsed = JSON.parse(msg.content);
if (parsed && Array.isArray(parsed.segments)) {
parsedSegments = parsed.segments;
displayContent = parsed.content || '';
} else if (parsed && Array.isArray(parsed.tool_calls)) {
parsedToolCalls = parsed.tool_calls;
displayContent = parsed.content || '';
}
} catch {}
const cleanContent = displayContent.replace(/<think[^>]*>[\s\S]*?<\/think>/gi, '');
let bodyHtml = '';
if (parsedSegments) {
bodyHtml = parsedSegments.map((seg) => {
if (seg.type === 'text' && seg.content) {
const c = seg.content.replace(/<think[^>]*>[\s\S]*?<\/think>/gi, '');
if (!c) return '';
return renderContent(c).map((p) => {
if (p.type === 'code') {
return `<div class="chat-code-block"><div class="chat-code-header"><span class="chat-code-lang">${p.lang || ''}</span><button class="chat-copy-btn" data-code="${encodeURIComponent(p.content)}">Copy</button></div><pre><code>${escapeHtml(p.content)}</code></pre></div>`;
}
return `<span>${formatText(p.content)}</span>`;
}).join('');
}
if (seg.type === 'tool') {
const name = seg.call?.name || 'tool';
const icon = { terminal: '⌨', crush_run: '⚡', read_file: '📄', list_files: '📁', search_files: '🔍', web_fetch: '🌐' }[name] || '🔧';
const done = seg.result;
const isErr = done && done.is_error;
const preview = (() => {
try {
const args = typeof seg.call.args === 'string' ? JSON.parse(seg.call.args) : seg.call.args;
return args.command || args.task || args.path || args.url || JSON.stringify(args).slice(0, 60);
} catch { return ''; }
})();
const resultText = done ? (done.content || '').slice(0, 500) : '';
return `<div class="chat-tool ${done ? 'done' : 'running'} ${isErr ? 'error' : ''}"><div class="chat-tool-header"><span class="chat-tool-icon">${icon}</span><span>${name}</span>${done ? `<span class="chat-tool-status ${isErr ? 'err' : 'ok'}">${isErr ? '✗' : '✓'}</span>` : '<span class="chat-dots"><span></span><span></span><span></span></span>'}</div>${preview ? `<div class="chat-tool-args">${escapeHtml(preview)}</div>` : ''}${resultText ? `<pre class="chat-tool-result">${escapeHtml(resultText)}</pre>` : ''}</div>`;
}
return '';
}).join('');
} else {
if (cleanContent) {
bodyHtml = renderContent(cleanContent).map((p) => {
if (p.type === 'code') {
return `<div class="chat-code-block"><div class="chat-code-header"><span class="chat-code-lang">${p.lang || ''}</span><button class="chat-copy-btn" data-code="${encodeURIComponent(p.content)}">Copy</button></div><pre><code>${escapeHtml(p.content)}</code></pre></div>`;
}
return `<span>${formatText(p.content)}</span>`;
}).join('');
}
if (parsedToolCalls && parsedToolCalls.length > 0) {
bodyHtml = parsedToolCalls.map((tc) => {
const name = tc.name || 'tool';
const icon = { terminal: '⌨', crush_run: '⚡', read_file: '📄', web_fetch: '🌐' }[name] || '🔧';
return `<div class="chat-tool done"><div class="chat-tool-header"><span class="chat-tool-icon">${icon}</span><span>${name}</span><span class="chat-tool-status ok">✓</span></div></div>`;
}).join('') + bodyHtml;
}
}
if (!bodyHtml) bodyHtml = '<span class="chat-dots"><span></span><span></span><span></span></span>';
el.innerHTML = `
<div class="chat-avatar ${isUser ? 'user' : 'ai'}">${avatar}</div>
<div class="chat-body">
<div class="chat-header"><span class="chat-badge" style="color:${isUser ? '#FFD740' : '#FF9100'};border-color:${isUser ? '#FFD740' : '#FF9100'}">${label}</span></div>
<div class="chat-content">${bodyHtml}</div>
</div>
`;
return el;
}
function renderMessages() {
$chatFeed.innerHTML = '';
messages.forEach((msg) => {
$chatFeed.appendChild(createMessageEl(msg));
});
scrollToBottom();
}
function scrollToBottom() {
requestAnimationFrame(() => {
$chatFeed.scrollTop = $chatFeed.scrollHeight;
});
}
function switchTab(tabName) {
$$('.tab').forEach((t) => t.classList.toggle('active', t.dataset.tab === tabName));
$$('.tab-content').forEach((s) => s.classList.toggle('active', s.id === `tab-${tabName}`));
}
function updateChatVisibility() {
if (serverOnline) {
$chatOffline.style.display = 'none';
$chatArea.style.display = 'flex';
} else {
$chatOffline.style.display = 'flex';
$chatArea.style.display = 'none';
}
}
async function loadChatHistory() {
try {
const data = await getChatHistory();
if (data.messages && data.messages.length > 0) {
messages = data.messages;
} else {
messages = [{ id: 'welcome', role: 'system', content: 'Ready. Type a message to start.' }];
}
renderMessages();
} catch {
messages = [{ id: 'welcome', role: 'system', content: 'Ready. Type a message to start.' }];
renderMessages();
}
}
async function handleSend() {
const text = $chatInput.value.trim();
if (!text || loading) return;
if (text === '/clear') {
try { await clearChat(); } catch {}
messages = [{ id: 'clear-' + Date.now(), role: 'system', content: 'Conversation cleared.' }];
renderMessages();
$chatInput.value = '';
return;
}
$chatInput.value = '';
$chatInput.style.height = 'auto';
const userMsg = { id: Date.now().toString(), role: 'user', content: text };
messages.push(userMsg);
$chatFeed.appendChild(createMessageEl(userMsg));
scrollToBottom();
loading = true;
$chatSend.style.display = 'none';
$chatStop.style.display = 'flex';
const controller = new AbortController();
abortController = controller;
let segments = [];
let thinking = '';
let textStartIdx = 0;
let streamText = '';
const updateLastText = (text) => {
if (!text) return;
const last = segments.length > 0 ? segments[segments.length - 1] : null;
if (last && last.type === 'text') {
last.content = text;
} else {
segments.push({ type: 'text', content: text });
}
};
currentStreamingEl = document.createElement('div');
currentStreamingEl.className = 'chat-msg assistant streaming';
$chatFeed.appendChild(currentStreamingEl);
scrollToBottom();
try {
const finalContent = await sendChat(text, true, (partial, event) => {
if (event && (event.thinking !== undefined || event.thinking_start || event.thinking_end)) {
if (event.thinking !== undefined) thinking += event.thinking;
return;
}
if (event && event.tool_call) {
updateLastText(partial.slice(textStartIdx));
textStartIdx = partial.length;
segments.push({ type: 'tool', call: event.tool_call, result: null });
} else if (event && event.tool_result) {
const segIdx = segments.findIndex((s) => s.type === 'tool' && s.call && s.call.tool_call_id === event.tool_result.tool_call_id);
if (segIdx >= 0) segments[segIdx].result = event.tool_result;
} else {
updateLastText(partial.slice(textStartIdx));
}
streamText = partial;
const allText = segments.filter((s) => s.type === 'text').map((s) => s.content).join('');
const toolSegs = segments.filter((s) => s.type === 'tool');
let html = '';
if (thinking) {
html += `<div class="chat-thinking"><span class="chat-thinking-icon">⏱</span> Thinking…</div>`;
}
segments.forEach((seg) => {
if (seg.type === 'text' && seg.content) {
const c = seg.content.replace(/<think[^>]*>[\s\S]*?<\/think>/gi, '');
if (c) html += `<div class="chat-content">${formatText(c)}</div>`;
}
if (seg.type === 'tool') {
const name = seg.call?.name || 'tool';
const icon = { terminal: '⌨', crush_run: '⚡', read_file: '📄', web_fetch: '🌐' }[name] || '🔧';
const done = seg.result;
const isErr = done && done.is_error;
const preview = (() => {
try {
const args = typeof seg.call.args === 'string' ? JSON.parse(seg.call.args) : seg.call.args;
return args.command || args.task || args.path || JSON.stringify(args).slice(0, 60);
} catch { return ''; }
})();
html += `<div class="chat-tool ${done ? 'done' : 'running'} ${isErr ? 'error' : ''}"><div class="chat-tool-header"><span class="chat-tool-icon">${icon}</span><span>${name}</span>${done ? `<span class="chat-tool-status ${isErr ? 'err' : 'ok'}">${isErr ? '✗' : '✓'}</span>` : '<span class="chat-dots"><span></span><span></span><span></span></span>'}</div>${preview ? `<div class="chat-tool-args">${escapeHtml(preview)}</div>` : ''}</div>`;
}
});
if (!html) {
html = '<span class="chat-dots"><span></span><span></span><span></span></span>';
}
currentStreamingEl.innerHTML = `
<div class="chat-avatar ai">◆</div>
<div class="chat-body">
<div class="chat-header"><span class="chat-badge" style="color:#FF9100;border-color:#FF9100">GEN</span></div>
${html}
<span class="chat-cursor"></span>
</div>
`;
scrollToBottom();
}, controller.signal);
if (currentStreamingEl && currentStreamingEl.parentNode) {
currentStreamingEl.remove();
}
const allText = segments.filter((s) => s.type === 'text').map((s) => s.content).join('');
const toolSegs = segments.filter((s) => s.type === 'tool');
const aiMsg = {
id: (Date.now() + 1).toString(),
role: 'assistant',
content: toolSegs.length > 0 ? JSON.stringify({
segments: segments.map((s) => s.type === 'text'
? { type: 'text', content: s.content }
: { type: 'tool', call: s.call, result: s.result ? { content: s.result.content || '', is_error: s.result.is_error || false, tool_call_id: s.call?.tool_call_id } : null }),
content: allText,
}) : (allText || finalContent),
};
messages.push(aiMsg);
$chatFeed.appendChild(createMessageEl(aiMsg));
scrollToBottom();
} catch (err) {
if (currentStreamingEl && currentStreamingEl.parentNode) {
currentStreamingEl.remove();
}
if (err.name !== 'AbortError') {
const errMsg = { id: (Date.now() + 1).toString(), role: 'system', content: `Error: ${err.message}` };
messages.push(errMsg);
$chatFeed.appendChild(createMessageEl(errMsg));
scrollToBottom();
}
} finally {
loading = false;
abortController = null;
currentStreamingEl = null;
$chatSend.style.display = 'flex';
$chatStop.style.display = 'none';
}
}
$$('.tab').forEach((tab) => {
tab.addEventListener('click', () => switchTab(tab.dataset.tab));
});
$chatInput.addEventListener('input', () => {
$chatInput.style.height = 'auto';
$chatInput.style.height = Math.min($chatInput.scrollHeight, 100) + 'px';
});
$chatInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
});
$chatSend.addEventListener('click', handleSend);
$chatStop.addEventListener('click', () => {
if (abortController) abortController.abort();
});
$chatFeed.addEventListener('click', (e) => {
const btn = e.target.closest('.chat-copy-btn');
if (btn) {
navigator.clipboard.writeText(decodeURIComponent(btn.dataset.code));
const orig = btn.textContent;
btn.textContent = 'Copied!';
setTimeout(() => { btn.textContent = orig; }, 1200);
}
});
$btnSaveUrl.addEventListener('click', async () => {
const url = $serverUrl.value.trim().replace(/\/$/, '');
if (url) {
await setServerUrl(url);
refresh();
}
});
async function refresh() {
const url = await getServerUrl();
$serverUrl.value = url;
$btnDashboard.href = url;
try {
const sessions = await fetchSessions();
serverOnline = true;
$serverStatus.innerHTML = `${dot('green')} Online`;
$sessionCount.textContent = sessions.length;
renderSessions(sessions);
} catch {
serverOnline = false;
$serverStatus.innerHTML = `${dot('red')} Offline`;
$sessionCount.textContent = '—';
$sessionsList.innerHTML = '';
}
updateChatVisibility();
chrome.runtime.sendMessage({ type: 'get_state' }, (state) => {
if (chrome.runtime.lastError || !state) return;
$errorCount.textContent = state.errorCount || 0;
});
}
refresh();
loadChatHistory();
setInterval(refresh, 10000);

77
extension/src/lib/api.js Normal file
View File

@@ -0,0 +1,77 @@
import { getServerUrl } from './config';
async function request(path, options = {}) {
const base = await getServerUrl();
const res = await fetch(`${base}/api${path}`, {
...options,
headers: { 'Content-Type': 'application/json', ...(options.headers || {}) },
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.error || res.statusText);
}
return res.json();
}
export async function getChatHistory() {
return request('/chat/history');
}
export async function clearChat() {
return request('/chat/clear', { method: 'POST' });
}
export async function summarizeChat() {
return request('/chat/summarize', { method: 'POST' });
}
export async function sendChat(message, stream = true, onChunk, signal) {
const base = await getServerUrl();
if (!stream) {
return request('/chat', {
method: 'POST',
body: JSON.stringify({ message, stream: false }),
});
}
return new Promise((resolve, reject) => {
fetch(`${base}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message, stream: true }),
signal,
}).then(async (res) => {
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
reject(new Error(err.error || res.statusText));
return;
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let full = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
const text = decoder.decode(value, { stream: true });
for (const line of text.split('\n')) {
if (!line.startsWith('data: ')) continue;
try {
const data = JSON.parse(line.slice(6));
if (data.error) { reject(new Error(data.error)); return; }
if (data.done) { resolve(full); return; }
if (data.content) {
full += data.content;
if (onChunk) onChunk(full, data);
} else if (data.thinking !== undefined || data.thinking_end) {
if (onChunk) onChunk(full, data);
} else if (data.tool_call || data.tool_result) {
if (onChunk) onChunk(full, data);
}
} catch {}
}
}
resolve(full);
}).catch(reject);
});
}

View File

@@ -0,0 +1,56 @@
const DEFAULT_PORT = 8080;
const DEFAULT_HOST = '127.0.0.1';
const DEFAULT_URL = `http://${DEFAULT_HOST}:${DEFAULT_PORT}`;
function isServiceWorker() {
return typeof ServiceWorkerGlobalScope !== 'undefined' && self instanceof ServiceWorkerGlobalScope;
}
export async function getServerUrl() {
if (isServiceWorker()) {
const result = await chrome.storage.local.get('muyue_server_url');
return result.muyue_server_url || DEFAULT_URL;
}
const stored = localStorage.getItem('muyue_server_url');
return stored || DEFAULT_URL;
}
export async function setServerUrl(url) {
if (isServiceWorker()) {
await chrome.storage.local.set({ muyue_server_url: url });
} else {
localStorage.setItem('muyue_server_url', url);
}
}
export async function buildWsUrl(token) {
const base = await getServerUrl();
const wsBase = base.replace(/^http/, 'ws');
return `${wsBase}/api/ws/browser-test?token=${encodeURIComponent(token)}`;
}
export async function fetchToken() {
const base = await getServerUrl();
const res = await fetch(`${base}/api/test/snippet`);
if (!res.ok) throw new Error(`Server returned ${res.status}`);
const data = await res.json();
return { token: data.token, wsUrl: data.ws_url, expiresIn: data.expires_in };
}
export async function fetchSessions() {
const base = await getServerUrl();
const res = await fetch(`${base}/api/test/sessions`);
if (!res.ok) throw new Error(`Server returned ${res.status}`);
const data = await res.json();
return data.sessions || [];
}
export async function checkServerHealth() {
try {
const base = await getServerUrl();
const res = await fetch(`${base}/api/info`, { signal: AbortSignal.timeout(3000) });
return res.ok;
} catch {
return false;
}
}

View File

@@ -0,0 +1,113 @@
let lastList = [];
function safeText(el) {
let t = (el.innerText || el.textContent || '').trim();
if (t.length > 80) t = t.slice(0, 80) + '…';
return t;
}
function describe(el) {
let 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('.');
}
const label = el.getAttribute('aria-label') || el.getAttribute('title') || el.getAttribute('name') || '';
return {
tag: el.tagName.toLowerCase(),
selector: sel,
text: safeText(el),
label,
type: el.getAttribute('type') || '',
disabled: !!el.disabled,
};
}
export function listClickables() {
const els = Array.from(
document.querySelectorAll(
'button, a[href], input[type=submit], input[type=button], [role=button], [onclick]'
)
);
lastList = els.filter((e) => {
const r = e.getBoundingClientRect();
return r.width > 0 && r.height > 0;
});
return lastList.map((el, i) => {
const d = describe(el);
d.index = i;
return d;
});
}
export function clickElement(params) {
let el;
if (params.selector) el = document.querySelector(params.selector);
else if (typeof params.index === 'number') el = lastList[params.index];
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) };
}
}
export function typeText(params) {
let el;
if (params.selector) el = document.querySelector(params.selector);
else if (typeof params.index === 'number') el = lastList[params.index];
if (!el) return { ok: false, error: 'element not found' };
const proto = Object.getPrototypeOf(el);
const setter = Object.getOwnPropertyDescriptor(proto, 'value');
try {
if (setter && setter.set) setter.set.call(el, params.text || '');
else el.value = params.text || '';
} catch {
el.value = params.text || '';
}
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
return { ok: true };
}
export function evalExpr(params) {
try {
const r = (0, eval)(params.expr);
return { ok: true, value: serialize(r) };
} catch (e) {
return { ok: false, error: String(e) };
}
}
export function currentUrl() {
return { url: location.href, title: document.title };
}
function serialize(v) {
if (v === undefined) return 'undefined';
try {
return JSON.parse(JSON.stringify(v));
} catch {
return String(v);
}
}
export function dispatch(msg) {
const p = msg.params || {};
switch (msg.action) {
case 'list_clickables':
return listClickables();
case 'click':
return clickElement(p);
case 'eval':
return evalExpr(p);
case 'current_url':
return currentUrl();
case 'type':
return typeText(p);
default:
return { ok: false, error: 'unknown action: ' + msg.action };
}
}

View File

@@ -0,0 +1,603 @@
:root {
--bg-primary: #0a0a0f;
--bg-secondary: #12121a;
--bg-tertiary: rgba(255, 255, 255, 0.05);
--border: rgba(255, 255, 255, 0.1);
--text-primary: #e8e8f0;
--text-secondary: #9999aa;
--accent: #ff4757;
--accent-dim: rgba(255, 71, 87, 0.15);
--accent-glow: rgba(255, 71, 87, 0.4);
--green: #3aaa61;
--yellow: #f5a623;
--red: #ff6b6b;
--font-mono: 'JetBrains Mono', ui-monospace, monospace;
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: var(--font-sans);
background: var(--bg-primary);
color: var(--text-primary);
font-size: 13px;
line-height: 1.4;
}
.panel {
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
}
.panel > header {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
header img {
width: 28px;
height: 28px;
}
header h1 {
font-size: 16px;
font-weight: 600;
letter-spacing: -0.3px;
}
.tabs {
display: flex;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
padding: 0 8px;
}
.tab {
flex: 1;
padding: 10px 8px;
background: none;
border: none;
border-bottom: 2px solid transparent;
color: var(--text-secondary);
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
}
.tab:hover {
color: var(--text-primary);
}
.tab.active {
color: var(--accent);
border-bottom-color: var(--accent);
}
.tab-content {
display: none;
flex: 1;
overflow-y: auto;
padding: 12px 16px;
}
.tab-content.active {
display: flex;
flex-direction: column;
}
.status-card {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px;
margin-bottom: 12px;
}
.status-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 0;
}
.status-row + .status-row {
border-top: 1px solid var(--border);
margin-top: 6px;
padding-top: 10px;
}
.status-label {
color: var(--text-secondary);
font-size: 12px;
}
.status-value {
font-weight: 500;
font-size: 12px;
}
.dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 6px;
}
.dot-green { background: var(--green); box-shadow: 0 0 6px var(--green); }
.dot-red { background: var(--red); box-shadow: 0 0 6px var(--red); }
.dot-yellow { background: var(--yellow); box-shadow: 0 0 6px var(--yellow); }
.actions {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 12px;
}
.btn {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 9px 14px;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--bg-tertiary);
color: var(--text-primary);
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
text-decoration: none;
}
.btn:hover {
background: var(--accent-dim);
border-color: var(--accent);
}
.btn-primary {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
.btn-primary:hover {
background: #e8414f;
box-shadow: 0 0 12px var(--accent-glow);
}
.settings-section {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--border);
}
.settings-section label {
display: block;
color: var(--text-secondary);
font-size: 11px;
margin-bottom: 4px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.input-row {
display: flex;
gap: 6px;
}
.input-row input {
flex: 1;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 4px;
padding: 6px 8px;
color: var(--text-primary);
font-size: 12px;
font-family: var(--font-mono);
outline: none;
}
.input-row input:focus {
border-color: var(--accent);
}
.input-row button {
padding: 6px 10px;
border-radius: 4px;
border: 1px solid var(--border);
background: var(--bg-tertiary);
color: var(--text-primary);
cursor: pointer;
font-size: 11px;
}
.input-row button:hover {
background: var(--accent-dim);
border-color: var(--accent);
}
.footer {
margin-top: auto;
padding: 10px 16px;
border-top: 1px solid var(--border);
text-align: center;
color: var(--text-secondary);
font-size: 10px;
flex-shrink: 0;
}
.footer span {
color: var(--accent);
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.loading {
animation: pulse 1.5s ease-in-out infinite;
}
.chat-offline {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
color: var(--text-secondary);
font-size: 13px;
}
.chat-area {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
gap: 0;
}
.chat-feed {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 10px;
padding-bottom: 8px;
}
.chat-msg {
display: flex;
gap: 8px;
max-width: 100%;
}
.chat-msg.system {
align-items: center;
gap: 6px;
padding: 6px 0;
}
.chat-system-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--text-secondary);
flex-shrink: 0;
}
.chat-system-text {
color: var(--text-secondary);
font-size: 12px;
font-style: italic;
}
.chat-avatar {
width: 28px;
height: 28px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 700;
flex-shrink: 0;
}
.chat-avatar.user {
background: rgba(255, 215, 64, 0.15);
color: #FFD740;
}
.chat-avatar.ai {
background: rgba(255, 145, 0, 0.15);
color: #FF9100;
}
.chat-body {
flex: 1;
min-width: 0;
overflow-wrap: break-word;
}
.chat-header {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 4px;
}
.chat-badge {
font-size: 10px;
font-weight: 700;
padding: 1px 5px;
border-radius: 3px;
border: 1px solid;
line-height: 1.3;
}
.chat-content {
font-size: 13px;
line-height: 1.5;
}
.chat-content h2 { font-size: 14px; margin: 8px 0 4px; }
.chat-content h3 { font-size: 13px; margin: 6px 0 3px; }
.chat-content h4 { font-size: 12px; margin: 4px 0 2px; }
.chat-content strong { color: #fff; }
.chat-bullet, .chat-step {
padding-left: 4px;
margin: 2px 0;
}
.chat-step-num {
display: inline-block;
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--accent-dim);
text-align: center;
line-height: 18px;
font-size: 10px;
font-weight: 600;
margin-right: 4px;
}
.inline-code {
background: rgba(255, 255, 255, 0.08);
padding: 1px 5px;
border-radius: 3px;
font-family: var(--font-mono);
font-size: 12px;
}
.chat-code-block {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 6px;
margin: 6px 0;
overflow: hidden;
}
.chat-code-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 8px;
background: rgba(255, 255, 255, 0.03);
border-bottom: 1px solid var(--border);
}
.chat-code-lang {
font-size: 10px;
color: var(--text-secondary);
text-transform: uppercase;
}
.chat-copy-btn {
background: none;
border: none;
color: var(--accent);
font-size: 10px;
cursor: pointer;
padding: 2px 6px;
border-radius: 3px;
}
.chat-copy-btn:hover {
background: var(--accent-dim);
}
.chat-code-block pre {
padding: 8px;
overflow-x: auto;
font-family: var(--font-mono);
font-size: 11px;
line-height: 1.4;
}
.chat-code-block code {
font-family: inherit;
}
.chat-tool {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 6px;
padding: 8px 10px;
margin: 6px 0;
font-size: 12px;
}
.chat-tool.error {
border-color: var(--red);
}
.chat-tool-header {
display: flex;
align-items: center;
gap: 6px;
}
.chat-tool-icon {
font-size: 14px;
}
.chat-tool-status {
margin-left: auto;
font-weight: 700;
}
.chat-tool-status.ok { color: var(--green); }
.chat-tool-status.err { color: var(--red); }
.chat-tool-args {
color: var(--text-secondary);
font-family: var(--font-mono);
font-size: 11px;
margin-top: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-tool-result {
margin-top: 6px;
padding-top: 6px;
border-top: 1px solid var(--border);
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-secondary);
max-height: 120px;
overflow-y: auto;
}
.chat-thinking {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 8px;
background: rgba(255, 255, 255, 0.03);
border-radius: 6px;
margin-bottom: 6px;
font-size: 11px;
color: var(--text-secondary);
}
.chat-thinking-icon {
font-size: 13px;
}
@keyframes chatDotPulse {
0%, 80%, 100% { opacity: 0.2; transform: scale(0.8); }
40% { opacity: 1; transform: scale(1); }
}
.chat-dots {
display: inline-flex;
gap: 4px;
padding: 4px 0;
}
.chat-dots span {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--text-secondary);
animation: chatDotPulse 1.2s ease-in-out infinite;
}
.chat-dots span:nth-child(2) { animation-delay: 0.2s; }
.chat-dots span:nth-child(3) { animation-delay: 0.4s; }
@keyframes cursorBlink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
.chat-cursor {
display: inline-block;
width: 2px;
height: 14px;
background: var(--accent);
margin-left: 2px;
vertical-align: text-bottom;
animation: cursorBlink 0.8s ease infinite;
}
.chat-input-row {
display: flex;
align-items: flex-end;
gap: 6px;
padding: 8px 0 0;
border-top: 1px solid var(--border);
flex-shrink: 0;
}
.chat-input-row textarea {
flex: 1;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 6px;
padding: 8px 10px;
color: var(--text-primary);
font-family: var(--font-sans);
font-size: 13px;
resize: none;
outline: none;
max-height: 100px;
line-height: 1.4;
}
.chat-input-row textarea:focus {
border-color: var(--accent);
}
.chat-send-btn, .chat-stop-btn {
width: 36px;
height: 36px;
border-radius: 8px;
border: 1px solid var(--accent);
background: var(--accent);
color: #fff;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: all 0.15s;
}
.chat-send-btn:hover, .chat-stop-btn:hover {
box-shadow: 0 0 12px var(--accent-glow);
}
.chat-stop-btn {
background: var(--bg-secondary);
border-color: var(--border);
color: var(--text-primary);
}
.chat-msg.user .chat-content {
background: var(--bg-tertiary);
padding: 8px 10px;
border-radius: 0 8px 8px 8px;
}
.chat-msg.assistant .chat-content {
padding: 2px 0;
}

28
extension/wxt.config.js Normal file
View File

@@ -0,0 +1,28 @@
import { defineConfig } from 'wxt';
export default defineConfig({
srcDir: 'src',
manifest: {
name: 'Muyue',
description: 'AI-powered browser testing & automation — connected to your Muyue desktop app',
permissions: [
'storage',
'activeTab',
'tabs',
'sidePanel',
'scripting',
'notifications',
'alarms',
],
host_permissions: ['http://127.0.0.1:*/*', 'http://localhost:*/*'],
action: {
default_icon: {
16: 'icon/16.png',
32: 'icon/32.png',
},
},
side_panel: {
default_path: 'sidepanel.html',
},
},
});

View File

@@ -66,23 +66,52 @@ Muyue gère :
| **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.
Quand l'utilisateur demande de **tester** une UI (ses boutons, ses formulaires, son comportement), utilise `browser_test`. La page cible doit ê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 (le même token reste valide même après reload : si la connexion est perdue, l'utilisateur n'a qu'à re-coller).
Boucle recommandée :
## Règle d'or — économise les appels d'outils
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.
**N'appelle PAS `list_clickables` après chaque clic.** C'est l'erreur n°1 qui fait exploser ta boucle (150+ appels pour 5 actions humaines). La liste change rarement et chaque appel renvoie ~30-100 éléments.
Stratégie efficace :
1. **Au début** : `summary` (URL + console + 20 lignes) → `list_clickables` (UNE FOIS, mémorise les index pertinents pour ta tâche).
2. **Pendant** : clique par `index`. Lis le `console_delta` retourné après chaque clic.
3. **Re-list seulement si** :
- le `current_url` retourné change ET la nouvelle page est inconnue,
- OU un clic ouvre un dialog / nouveau composant que tu dois inspecter,
- OU `click` retourne `element not found` (DOM a muté).
4. Pour les pages SPA qui rechargent côté URL mais pas le DOM, vérifie d'abord avec `eval document.querySelectorAll('button').length` — si stable, ne re-liste pas.
5. Si tu te sens bloqué, **ne boucle pas en aveugle**. Fais 1 `summary`, 1 `eval` ciblé, et demande de l'aide à l'utilisateur. Mieux vaut 5 appels et une question qu'une boucle de 50 appels.
## Actions disponibles
| Action | Quand l'utiliser |
|---|---|
| `summary` | État de la page (URL, titre, 20 dernières lignes console). Appel **bon marché**. |
| `list_clickables` | Liste indexée des boutons/liens/inputs visibles. **Appel cher** (~50+ items) — utilise avec parcimonie. |
| `click` (par `index` de préférence) | Clique. Retourne `console_delta` + `current_url`. |
| `type` | Remplit un input (par `selector` ou `index`). Toujours suivi d'un `click` sur le bouton submit. |
| `eval` | JS arbitraire. Idéal pour des questions ciblées (`document.title`, `document.querySelectorAll(X).length`, etc.) au lieu de `list_clickables` complet. |
| `current_url` | URL+titre. Très bon marché. |
| `wait` | Pause 200-500 ms après une action async (transition / fetch). |
| `console` | N dernières lignes console (default 50). Pour debug post-incident. |
| `screenshot` | Capture viewport (ou `selector`) et sauve dans `~/.muyue/screenshots/<filename>.png`. Utilise `filename` pour nommer ; sinon timestamp. Best-effort (CSS externe / images peuvent ne pas apparaître). |
## Rapport final
Quand tous les tests sont terminés, fournis un rapport **structuré et bref** :
```
✓ Boutons OK : <liste des labels>
✗ Boutons cassés : <label> — <message d'erreur exact du console_delta>
⚠ Bloqués : <label> — <pourquoi> (disabled, non trouvé, etc.)
📸 Captures : <chemins relatifs sous ~/.muyue/screenshots/>
```
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.
- Clique **par index** ; le sélecteur peut changer avec le DOM, l'index reste stable jusqu'au prochain `list_clickables`.
- N'utilise jamais `eval` pour cliquer si `click` suffit.
- Si la page se recharge (`current_url` change ou la connexion tombe), demande à l'utilisateur de recoller le snippet — le même token marche.
</browser_test_strategy>
<tool_strategy>

View File

@@ -12,10 +12,12 @@ package api
import (
"context"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"os"
"strings"
"sync"
"time"
@@ -24,8 +26,20 @@ import (
"github.com/muyue/muyue/internal/agent"
)
// thin os wrappers (kept here so saveScreenshot stays independent of any
// existing helper file's evolution)
func osUserHomeDir() (string, error) { return os.UserHomeDir() }
func mkdirAll(p string, m os.FileMode) error { return os.MkdirAll(p, m) }
func writeFile(p string, b []byte, m os.FileMode) error { return os.WriteFile(p, b, m) }
func base64StdDecode(s string) ([]byte, error) { return base64.StdEncoding.DecodeString(s) }
const (
browserTestTokenTTL = 5 * time.Minute
// browserTestTokenTTL is a sliding window: every successful WS connect
// using the token resets it. So the user re-pasting the snippet after a
// page reload / navigation seamlessly resumes (same token, same session
// continuation in the AI's view), as long as no more than this gap of
// inactivity occurs.
browserTestTokenTTL = 60 * time.Minute
browserTestCommandTTL = 30 * time.Second
browserTestConsoleMax = 200
browserTestSessionsMax = 16
@@ -86,7 +100,11 @@ func (s *BrowserTestStore) IssueToken() string {
return tok
}
// ConsumeToken validates and removes a token in one step.
// ConsumeToken validates a token. Tokens are no longer single-use:
// the test snippet re-establishes the WS after every page reload /
// navigation, so the same token must work multiple times. We slide the
// expiration on each successful use so a long active test session keeps
// the token alive.
func (s *BrowserTestStore) ConsumeToken(tok string) bool {
s.tokensMu.Lock()
defer s.tokensMu.Unlock()
@@ -94,8 +112,12 @@ func (s *BrowserTestStore) ConsumeToken(tok string) bool {
if !ok {
return false
}
delete(s.tokens, tok)
return time.Since(t) <= browserTestTokenTTL
if time.Since(t) > browserTestTokenTTL {
delete(s.tokens, tok)
return false
}
s.tokens[tok] = time.Now() // sliding refresh
return true
}
// Register inserts a new session, evicting the oldest if at capacity.
@@ -377,14 +399,15 @@ func (s *Server) handleBrowserTestWS(w http.ResponseWriter, r *http.Request) {
// 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"`
Action string `json:"action" description:"One of: list_clickables, click, eval, console, current_url, wait, type, summary, screenshot"`
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"`
Selector string `json:"selector,omitempty" description:"CSS selector for click/type/screenshot actions (screenshot defaults to whole viewport when omitted)"`
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)"`
Filename string `json:"filename,omitempty" description:"Screenshot action: optional file name (no path, no extension); defaults to a timestamp"`
}
// RegisterBrowserTestTool wires the agent tool against a session store.
@@ -401,7 +424,7 @@ func RegisterBrowserTestTool(reg *agent.Registry, store *BrowserTestStore) error
switch action {
case "":
return agent.TextErrorResponse("action is required"), nil
case "list_clickables", "click", "eval", "current_url", "type":
case "list_clickables", "click", "eval", "current_url", "type", "screenshot":
case "console", "summary", "wait":
default:
return agent.TextErrorResponse("unknown action: " + p.Action), nil
@@ -479,6 +502,23 @@ func RegisterBrowserTestTool(reg *agent.Registry, store *BrowserTestStore) error
return agent.TextErrorResponse(err.Error()), nil
}
// Screenshot post-processing: snippet returns a base64 data URL;
// decode and write to ~/.muyue/screenshots/<filename>.png so the
// AI can reference an on-disk path rather than streaming megabytes
// of base64 back through its context.
if action == "screenshot" {
saved, perr := saveScreenshot(payload, p.Filename)
if perr != nil {
return agent.TextErrorResponse("screenshot save: " + perr.Error()), nil
}
out, _ := json.MarshalIndent(map[string]interface{}{
"action": "screenshot",
"saved_to": saved,
"current_url": sess.URL,
}, "", " ")
return agent.TextResponse(string(out)), nil
}
// Console delta: messages logged during this command.
post := sess.SnapshotConsole()
var delta []ConsoleEntry
@@ -504,14 +544,21 @@ func RegisterBrowserTestTool(reg *agent.Registry, store *BrowserTestStore) error
// 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.
// Inline JS injected into the user's target page. Responsibilities:
// - open the WS, with auto-reconnect (exponential backoff capped at 5s)
// - hook console.log/info/warn/error/debug + window.onerror + unhandledrejection
// - dispatch RPC commands: list_clickables, click, type, eval, current_url, screenshot
// - re-establish WS on transient close (network blip, server restart, etc.)
//
// Across full page navigation / reload the JS context is destroyed —
// no JS-only mechanism can survive that. The token is reusable (sliding
// 60-min TTL server-side), so the user just re-pastes the same snippet
// from the Tests tab to resume.
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){} }
var ws = null, lastList = [], retry = 0;
function send(obj){ try{ if (ws && ws.readyState === 1) 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();
@@ -537,6 +584,36 @@ func buildBrowserTestSnippet(wsURL string) string {
try { el.scrollIntoView({block:'center'}); el.click(); return { ok:true }; }
catch(e){ return { ok:false, error:String(e) }; }
}
// Best-effort viewport screenshot via SVG foreignObject — works on most
// pages, but external CSS / images / iframes won't be inlined. Returns a
// base64 PNG data URL the server will save to disk.
function screenshot(p){
return new Promise(function(resolve){
try {
var w = Math.max(document.documentElement.clientWidth, 1024);
var h = Math.max(window.innerHeight, 768);
var node = (p && p.selector) ? document.querySelector(p.selector) : document.documentElement;
if (!node) { resolve({ ok:false, error:'selector not found' }); return; }
var rect = node.getBoundingClientRect();
if (node === document.documentElement) { rect = { width:w, height:h }; }
var clone = node.cloneNode(true);
var ser = new XMLSerializer().serializeToString(clone);
var svg = '<svg xmlns="http://www.w3.org/2000/svg" width="'+Math.round(rect.width)+'" height="'+Math.round(rect.height)+'">' +
'<foreignObject width="100%" height="100%"><div xmlns="http://www.w3.org/1999/xhtml" style="background:white">' + ser + '</div></foreignObject></svg>';
var img = new Image();
img.onload = function(){
try {
var c = document.createElement('canvas');
c.width = Math.round(rect.width); c.height = Math.round(rect.height);
c.getContext('2d').drawImage(img, 0, 0);
resolve({ ok:true, data_url: c.toDataURL('image/png'), width: c.width, height: c.height });
} catch(e){ resolve({ ok:false, error:'canvas: '+String(e) }); }
};
img.onerror = function(){ resolve({ ok:false, error:'image load failed (CSP or invalid SVG)' }); };
img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg);
} catch(e){ resolve({ ok:false, error:String(e) }); }
});
}
function dispatch(msg){
var p = msg.params || {};
switch(msg.action){
@@ -563,6 +640,8 @@ func buildBrowserTestSnippet(wsURL string) string {
el.dispatchEvent(new Event('change', {bubbles:true}));
return { ok:true };
}
case 'screenshot':
return screenshot(p);
}
return { ok:false, error:'unknown action' };
}
@@ -594,15 +673,29 @@ func buildBrowserTestSnippet(wsURL string) string {
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 };
function connect(){
ws = new WebSocket(WS_URL);
ws.onopen = function(){ retry = 0; 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) {
var out = dispatch(msg);
if (out && typeof out.then === 'function') { out.then(function(r){ reply(msg.id, r); }); }
else { reply(msg.id, out); }
}
};
ws.onclose = function(){
// Same-page transient disconnect → reconnect with backoff up to ~5s.
// Full navigation kills the JS context entirely — this never runs in
// that case; the user re-pastes the snippet (same token works).
retry = Math.min(retry + 1, 5);
setTimeout(connect, 500 * retry);
};
ws.onerror = function(){ /* onclose will fire next */ };
}
connect();
window.__muyueTestRunner = { reconnect: connect, list: list };
})();`
}
@@ -610,3 +703,70 @@ func jsString(s string) string {
b, _ := json.Marshal(s)
return string(b)
}
// saveScreenshot decodes the base64 PNG returned by the snippet's
// screenshot action and writes it to ~/.muyue/screenshots/<name>.png.
// Returns the absolute path saved, or an error.
func saveScreenshot(replyPayload json.RawMessage, requestedName string) (string, error) {
var reply struct {
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
DataURL string `json:"data_url,omitempty"`
}
if err := json.Unmarshal(replyPayload, &reply); err != nil {
return "", fmt.Errorf("invalid reply: %w", err)
}
if !reply.OK {
if reply.Error != "" {
return "", fmt.Errorf("snippet: %s", reply.Error)
}
return "", fmt.Errorf("snippet returned ok=false")
}
const prefix = "data:image/png;base64,"
if !strings.HasPrefix(reply.DataURL, prefix) {
return "", fmt.Errorf("unexpected data URL prefix")
}
raw, err := base64StdDecode(reply.DataURL[len(prefix):])
if err != nil {
return "", fmt.Errorf("base64: %w", err)
}
dir, err := screenshotDir()
if err != nil {
return "", err
}
name := sanitizeFilename(requestedName)
if name == "" {
name = time.Now().Format("20060102-150405")
}
path := dir + "/" + name + ".png"
if err := writeFile(path, raw, 0644); err != nil {
return "", err
}
return path, nil
}
func screenshotDir() (string, error) {
home, err := osUserHomeDir()
if err != nil {
return "", err
}
dir := home + "/.muyue/screenshots"
if err := mkdirAll(dir, 0755); err != nil {
return "", err
}
return dir, nil
}
// sanitizeFilename keeps a safe subset (letters / digits / _ / - / .) so
// the user-supplied name cannot escape the screenshots directory.
func sanitizeFilename(s string) string {
var b strings.Builder
for _, r := range s {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9',
r == '_', r == '-', r == '.':
b.WriteRune(r)
}
}
return b.String()
}

View File

@@ -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)

View File

@@ -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)
}

View 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
}

View 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
}

View File

@@ -0,0 +1,271 @@
//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)
}
// PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE is a quirk of the Win32 API: lpValue
// is the HPCON *value* (cast to PVOID), not a pointer to the handle. If
// we pass &hPC the kernel reads garbage, the PC attribute is silently
// ignored, and cmd/pwsh get their own external console window — which is
// exactly the regression v0.7.6 introduced. The cbSize stays the size of
// the handle (8 bytes on amd64). Reference: Microsoft EchoCon sample.
if err := attrList.Update(
procThreadAttributePseudoconsole,
unsafe.Pointer(uintptr(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)

View File

@@ -2,19 +2,22 @@ package api
// Cross-platform terminal session abstraction.
//
// On Linux / macOS we have a real PTY via creack/pty: full TTY semantics,
// resize support, interactive apps (vim, top…) work. On Windows the same
// package returns "operating system not supported" at pty.Start time, so we
// fall back to plain pipes (stdin / stdout merged with stderr). Pipes don't
// give a real TTY — interactive TUIs misbehave — but `wsl`, `pwsh`, `cmd`,
// and most CLI tools emit usable line-buffered output, which is what the
// user actually clicks for.
// On Linux / macOS the unix-tagged file (terminal_session_unix.go) wires
// startTermSession to creack/pty for a real PTY: full TTY semantics,
// resize support, interactive apps (vim, top…) work.
//
// On Windows the windows-tagged file (terminal_session_windows.go) tries
// the kernel32 ConPTY API first, with a pipe-based fallback for older
// hosts. pipeSession does NOT carry TTY signals, so most shells go silent
// — it's only kept as a last resort.
//
// Both platforms share the termSession interface, the ptySession type
// (used by unix), and the pipeSession type (used by the Windows fallback).
import (
"io"
"os"
"os/exec"
"runtime"
"sync"
"github.com/creack/pty/v2"
@@ -30,22 +33,7 @@ type termSession interface {
Pid() int
}
// startTermSession tries a real PTY first; on Windows or any pty.Start failure
// it falls back to a pipe-based session.
func startTermSession(cmd *exec.Cmd) (termSession, error) {
if runtime.GOOS != "windows" {
ptmx, err := pty.Start(cmd)
if err == nil {
return &ptySession{ptmx: ptmx, cmd: cmd}, nil
}
// On unix, a pty.Start error is fatal — pipes won't help interactive
// shells without a TTY, and the unix build is the supported path.
return nil, err
}
return startPipeSession(cmd)
}
// ptySession wraps creack/pty's *os.File-backed PTY.
// ptySession wraps creack/pty's *os.File-backed PTY (unix path).
type ptySession struct {
ptmx *os.File
cmd *exec.Cmd
@@ -76,8 +64,10 @@ func (s *ptySession) Pid() int {
return s.cmd.Process.Pid
}
// pipeSession is the Windows fallback: stdin pipe + merged stdout/stderr pipe,
// running concurrently. Resize is a no-op (no TTY to send TIOCSWINSZ to).
// pipeSession is the Windows last-resort fallback when ConPTY is not
// available: stdin pipe + merged stdout/stderr, no TTY signals. Most
// interactive shells go silent in this mode, so it should rarely be hit on
// modern Windows (10 1809+).
type pipeSession struct {
cmd *exec.Cmd
stdin io.WriteCloser
@@ -115,7 +105,7 @@ func startPipeSession(cmd *exec.Cmd) (termSession, error) {
cmd: cmd,
stdin: stdin,
stdout: stdout,
stderr: stderr,
stderr: stderr,
merged: make(chan []byte, 32),
closeCh: make(chan struct{}),
}

View 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
}

View 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)
}

View File

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

View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 750 B

BIN
web/public/favicon-32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

BIN
web/public/muyue-64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

BIN
web/public/muyue.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

View File

@@ -103,6 +103,7 @@ export default function App() {
<div className="app-layout">
<header className="header">
<div className="header-brand">
<img src="/muyue-64.png" alt="Muyue" className="header-logo-img" width="22" height="22" style={{ borderRadius: 4, verticalAlign: 'middle' }} />
<span className="header-logo">MUYUE</span>
<span className="header-version">v{info.version || '...'}</span>
</div>

View File

@@ -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>
)}