Compare commits

...

59 Commits

Author SHA1 Message Date
CI Bot
8b8f217624 chore: update CHANGELOG for v0.9.2 2026-04-28 11:20:50 +00:00
Augustin
3f36974b59 fix(ui): add MarkdownContent component, fix deprecated meta tag, bump v0.9.2
All checks were successful
Stable Release / stable (push) Successful in 1m48s
- Add missing MarkdownContent component (ReactMarkdown + remarkGfm wrapper)
- Replace deprecated apple-mobile-web-app-capable with mobile-web-app-capable
- Bump version to 0.9.2

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-28 13:18:38 +02:00
CI Bot
55cd00802d chore: update CHANGELOG for v0.9.1 2026-04-28 09:58:53 +00:00
Augustin
cd9ae5f4b9 fix(ui): restore Tests tab, fix renderMarkdown undefined error
All checks were successful
Stable Release / stable (push) Successful in 1m36s
- Restore Tests tab in navigation (was removed by mistake)
- Fix renderMarkdown ReferenceError by restoring the callback (raw=false always)
- Keep Copy MD button and removal of raw-md/collapse toggles

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-28 11:56:47 +02:00
CI Bot
346c464ed5 chore: update CHANGELOG for v0.9.1 2026-04-28 09:50:30 +00:00
Augustin
3445726b67 fix(ui): remove Tests tab, remove raw-md/collapse toggles, add Copy MD button, bump v0.9.1
All checks were successful
Stable Release / stable (push) Successful in 1m46s
- Remove Tests tab from navigation (browsertest still works via snippet/extension)
- Remove showRawMarkdown and collapseHistory toggles from Studio input bar
- Add "Copy MD" button on each assistant message header to copy raw markdown
- Bump version to 0.9.1

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-28 11:48:37 +02:00
CI Bot
5875dab17f chore: update CHANGELOG for v0.9.0 2026-04-27 19:07:38 +00:00
Augustin
4523bbd42c feat: RAG, memory, plugins, lessons, file editor, split panes, Markdown rendering, PWA + UI overhaul
All checks were successful
Stable Release / stable (push) Successful in 1m34s
Major additions:
- RAG pipeline (indexing, chunking, search) with sidebar upload button
- Memory system with CRUD API
- Plugins and lessons modules
- MCP discovery and MCP server
- Advanced skills (auto-create, conditional, improver)
- Agent browser/image support, delegate, sessions
- File editor with CodeMirror in split panes
- Markdown rendering via react-markdown + KaTeX + highlight.js
- Raw markdown toggle
- PWA manifest + service worker
- Extension UI redesign with new design tokens and studio-style chat
- Pipeline API for chat streaming
- Mobile responsive layout

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-27 21:04:11 +02:00
CI Bot
62c20eb174 chore: update CHANGELOG for v0.9.0 2026-04-27 16:51:31 +00:00
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
CI Bot
f8d706cdca chore: update CHANGELOG for v0.7.2 2026-04-27 10:57:30 +00: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
Augustin ROUX
a9eedab0b5 Merge pull request 'release: v0.7.2 stable — promote develop to main' (#8) from develop into main
All checks were successful
Stable Release / stable (push) Successful in 1m4s
Reviewed-on: #8
2026-04-27 10:55:38 +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
Augustin ROUX
a1da9da3db Merge pull request 'feat(studio): auto-réflexion avancée pendant les tests (v0.7.2)' (#7) from release/v0.7.2 into develop
All checks were successful
Beta Release / beta (push) Successful in 1m7s
PR Check / check (pull_request) Successful in 52s
Reviewed-on: #7
2026-04-27 10:04:47 +00:00
Muyue
a7d4b31a0d feat(studio): force advanced reflection during browser-test sessions (v0.7.2)
All checks were successful
PR Check / check (pull_request) Successful in 55s
When at least one browser_test session is connected, every chat
message in Studio now auto-enables advanced reflection regardless of
the user toggle. The intent: during AI-driven UI testing, having a
second model produce a preliminary [RAPPORT PRÉALABLE] materially
improves which clicks the active model decides to perform and the
quality of the final ✓/✗ report.

- handlers_chat: derive wantReflection from body.AdvancedReflection
  OR (browserTestStore has any active session). The user toggle still
  works for normal conversations; tests just override it.
- Silent fallback when no inactive provider is configured (no error,
  no behaviour change for single-provider setups).
- Tests.jsx: add a hint explaining the auto-on behaviour so the user
  understands why the Studio toggle appears bypassed.
- Version 0.7.1 → 0.7.2 + CHANGELOG entry.
2026-04-27 12:02:04 +02:00
Augustin ROUX
0ee006f71f Merge pull request 'fix(terminal/windows): pipes fallback for v0.7.1' (#6) from release/v0.7.1 into develop
All checks were successful
Beta Release / beta (push) Successful in 1m7s
Reviewed-on: #6
2026-04-27 09:59:15 +00:00
Muyue
fc7a5b9d87 fix(terminal/windows): fallback to pipes when PTY unsupported (v0.7.1)
All checks were successful
PR Check / check (pull_request) Successful in 57s
The terminal tab was unusable on Windows: creack/pty has no native
Windows ConPTY support, so pty.Start() returned "operating system not
supported" and the WebSocket closed immediately on any tab click —
even though the menu detection (wsl --list --quiet, pwsh, cmd) worked.

Introduce a termSession interface with two implementations selected at
runtime:
- ptySession (unix): unchanged behaviour, real PTY via creack/pty,
  resize works, vim/top behave normally.
- pipeSession (windows): plain stdin + merged stdout/stderr pipes,
  forwarded to the WebSocket. Resize is a no-op (no SIGWINCH without a
  TTY), so full-screen TUIs misbehave in this mode — but launching
  wsl.exe, pwsh, or cmd works for line-based interaction, which is
  what the menu shortcuts target.

handleTerminalWS now goes through startTermSession(cmd); the unix path
is unchanged, the windows fallback kicks in only when pty.Start would
have failed.

Bump v0.7.0 → v0.7.1; CHANGELOG entry added.
2026-04-27 11:56:40 +02:00
CI Bot
654444ccc8 chore: update CHANGELOG for v0.7.0 2026-04-27 09:25:31 +00:00
Augustin ROUX
991878939b Merge pull request 'release: v0.7.0 stable — promote develop to main' (#5) from develop into main
All checks were successful
Stable Release / stable (push) Successful in 1m1s
Reviewed-on: #5
2026-04-27 09:24:20 +00:00
Augustin ROUX
dbb97cc164 Merge pull request 'release: v0.7.0 — Tests pilotés par l'IA' (#4) from release/v0.7.0 into develop
All checks were successful
Beta Release / beta (push) Successful in 1m7s
PR Check / check (pull_request) Successful in 50s
Reviewed-on: #4
2026-04-27 09:20:37 +00:00
Muyue
6d2f174ae8 fix(ci): rename browser_test.go → browsertest.go
All checks were successful
PR Check / check (pull_request) Successful in 54s
The suffix _test.go makes Go treat the file as a test file (only
compiled during 'go test'), so server.go could not see the exported
BrowserTestStore / NewBrowserTestStore / RegisterBrowserTestTool and
the four handler methods at build time. Rename to a non-test name
keeps the same package-level visibility but compiles into the regular
api package.
2026-04-27 11:16:36 +02:00
Augustin ROUX
0d1d8d3ec3 Merge pull request 'release: v0.6.0 — security audit fixes + 7 new features' (#3) from release/v0.6.0 into develop
All checks were successful
Beta Release / beta (push) Successful in 1m6s
Reviewed-on: #3
2026-04-27 09:14:46 +00:00
Muyue
c820d55710 feat: AI-driven browser tests — Tests tab + browser_test agent tool
Some checks failed
PR Check / check (pull_request) Failing after 33s
New feature: give Studio's AI control of any browser tab to test buttons,
read the console, and report which buttons work / fail.

Backend (internal/api/browser_test.go, ~480 LOC):
- WebSocket endpoint /api/ws/browser-test, auth by single-use 5-min token
- BrowserTestStore: session map (capped at 16, LRU evict), token store
- REST: /api/test/snippet (issues token + JS snippet), /api/test/sessions,
  /api/test/console/{id}
- Agent tool 'browser_test' wired into the registry, with actions:
  list_clickables / click / eval / console / current_url / type / wait /
  summary. click returns the console_delta produced during the click.
- Embedded JS runner: opens WS, hooks console + window.onerror +
  unhandledrejection, dispatches dispatcher commands, replies with
  correlation IDs, watches for URL changes.

Frontend:
- New Tests tab (web/src/components/Tests.jsx): snippet copy + connected
  sessions list + live console viewer
- App.jsx: 5th tab + Ctrl+4 shortcut (Config moves to Ctrl+5)
- api/client.js: getTestSnippet / getTestSessions / getTestConsole

Studio prompt:
- internal/agent/prompts/studio_system.md: added browser_test entry to the
  tools table + <browser_test_strategy> section explaining the recommended
  loop (summary → list_clickables → click → check console_delta → report)

Versioning:
- v0.6.0 → v0.7.0
- CHANGELOG.md: full entry under v0.7.0
2026-04-27 11:02:05 +02:00
Muyue
6a7b4d8001 release: v0.6.0 — security audit fixes + 7 new features
All checks were successful
PR Check / check (pull_request) Successful in 57s
Audit corrections (security, concurrency, stability):
- chat_engine: bound resp.Choices[0] access, release tool slot per-iteration
- conversation_multi: synchronous save under existing lock (was racy fire-and-forget)
- workflow/engine: short-circuit on failed deps (no more infinite busy-wait); track failed/skipped status
- handlers_workflow: rune-aware truncate for plan goal (UTF-8 safe)
- server: CORS limited to localhost origins (was wildcard)
- handlers_info / terminal: mask API keys and SSH passwords as "***" in GET responses; preserve stored secret if "***" sent on update
- terminal: sshpass uses -e + SSHPASS env var (was both -p and -e)
- handlers_chat: MaxBytesReader 50 MB on /api/chat
- image_cache: 10 MB cap per image
- handlers_config: font size <= 72; profile-save unmarshal errors propagated
- handlers_info: /lsp/auto-install ProjectDir restricted to user home
- Shell.jsx: parenthesized resize-condition (operator precedence)
- orchestrator_test: CleanAIResponse capitalization (fixes failing vet)

New features:
- platform: detect OS name (Debian, Ubuntu, Windows 11, macOS X.Y) and inject in Studio system prompt next to the date
- agents: default timeout 30 min for crush_run/claude_run (cap also 30 min)
- agents: new cwd, wsl_distro, wsl_user params; on Windows hosts launch via "wsl -d <distro> -u <user> --cd <cwd> --"
- agents: new claude_run tool (mirror of crush_run for Claude Code CLI)
- terminal: list installed WSL distros individually in new-tab menu (Windows only)
- studio: system prompt rewritten around BMAD-METHOD personas + mandatory delegation template
- studio: "Réflexion avancée" toggle — inactive provider produces a preliminary report injected as [RAPPORT PRÉALABLE] context for the active provider
- studio: "Historique compressé" toggle — collapses past tool calls to last action only, with "Tout afficher" expansion
2026-04-27 10:12:11 +02:00
Augustin
0753167fb9 merge: develop into main (v0.5.0)
Some checks failed
Stable Release / stable (push) Failing after 32s
💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-27 00:18:28 +02:00
Augustin
2a6647b5cb chore: bump version to 0.5.0
Some checks failed
Beta Release / beta (push) Failing after 31s
💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-27 00:16:28 +02:00
Augustin
3740454201 feat: agent concurrency, conversation summaries, AI tools config, UI polish
Some checks failed
Stable Release / stable (push) Failing after 33s
- Agent slot limiter for concurrent tool execution
- Conversation summarization with soft-delete (MarkSummarized)
- ANSI stripping in terminal tool output
- Configurable crush-run timeout (default 600s, max 900s)
- Starship theme refactor, AI tools config grid, system update UI
- Streaming segments refactor, summarized messages block in feed
- CSS: headings, scrollbars, tool cards, summary block styles
- i18n additions (en+fr) for tools, updates, config

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-27 00:01:36 +02:00
Augustin
d98110ce8a feat: AI task API, token-based context windows, SSH password auth, sudo bypass detection
Replace message-count context windows with token-budget based ones for both
studio and shell. Add /api/ai/task endpoint for background tool
check/install/update. Enhance sudo blocking to catch piped/chained elevation
commands. Add SSH password support via sshpass and connection editing UI.
Remove realTokens persistence in favor of consumption tracking. Bump to 0.4.1.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-27 00:01:36 +02:00
Augustin
d2bb42b212 feat: agent concurrency, conversation summaries, AI tools config, UI polish
Some checks failed
Beta Release / beta (push) Failing after 33s
- Agent slot limiter for concurrent tool execution
- Conversation summarization with soft-delete (MarkSummarized)
- ANSI stripping in terminal tool output
- Configurable crush-run timeout (default 600s, max 900s)
- Starship theme refactor, AI tools config grid, system update UI
- Streaming segments refactor, summarized messages block in feed
- CSS: headings, scrollbars, tool cards, summary block styles
- i18n additions (en+fr) for tools, updates, config

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-27 00:01:22 +02:00
Augustin
e8a289ccf3 feat: AI task API, token-based context windows, SSH password auth, sudo bypass detection
All checks were successful
Beta Release / beta (push) Successful in 1m6s
Replace message-count context windows with token-budget based ones for both
studio and shell. Add /api/ai/task endpoint for background tool
check/install/update. Enhance sudo blocking to catch piped/chained elevation
commands. Add SSH password support via sshpass and connection editing UI.
Remove realTokens persistence in favor of consumption tracking. Bump to 0.4.1.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-26 20:06:20 +02:00
121 changed files with 22725 additions and 896 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,916 @@ 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.9.2
### Changes since v0.9.1
- fix(ui): add MarkdownContent component, fix deprecated meta tag, bump v0.9.2 (3f36974)
- chore: update CHANGELOG for v0.9.1 (55cd008)
- fix(ui): restore Tests tab, fix renderMarkdown undefined error (cd9ae5f)
### Downloads
| Platform | File |
|----------|------|
| Linux x86_64 | [muyue-linux-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.9.2/muyue-linux-amd64.tar.gz) |
| Linux ARM64 | [muyue-linux-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.9.2/muyue-linux-arm64.tar.gz) |
| macOS Intel | [muyue-darwin-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.9.2/muyue-darwin-amd64.tar.gz) |
| macOS Apple Silicon | [muyue-darwin-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.9.2/muyue-darwin-arm64.tar.gz) |
| Windows x86_64 | [muyue-windows-amd64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.9.2/muyue-windows-amd64.zip) |
| Windows ARM64 | [muyue-windows-arm64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.9.2/muyue-windows-arm64.zip) |
The binary includes both CLI and Desktop modes.
Run `muyue` for TUI, `muyue desktop` for web UI.
### Install
**Linux (x86_64)**
```bash
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.9.2/muyue-linux-amd64.tar.gz | tar xz
chmod +x muyue-linux-amd64
sudo mv muyue-linux-amd64 /usr/local/bin/muyue
```
**macOS (Apple Silicon)**
```bash
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.9.2/muyue-darwin-arm64.tar.gz | tar xz
chmod +x muyue-darwin-arm64
sudo mv muyue-darwin-arm64 /usr/local/bin/muyue
```
**Windows (x86_64)** — 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.9.2/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.9.1
### Changes since v0.9.1
- fix(ui): restore Tests tab, fix renderMarkdown undefined error (cd9ae5f)
### Downloads
| Platform | File |
|----------|------|
| Linux x86_64 | [muyue-linux-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.9.1/muyue-linux-amd64.tar.gz) |
| Linux ARM64 | [muyue-linux-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.9.1/muyue-linux-arm64.tar.gz) |
| macOS Intel | [muyue-darwin-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.9.1/muyue-darwin-amd64.tar.gz) |
| macOS Apple Silicon | [muyue-darwin-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.9.1/muyue-darwin-arm64.tar.gz) |
| Windows x86_64 | [muyue-windows-amd64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.9.1/muyue-windows-amd64.zip) |
| Windows ARM64 | [muyue-windows-arm64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.9.1/muyue-windows-arm64.zip) |
The binary includes both CLI and Desktop modes.
Run `muyue` for TUI, `muyue desktop` for web UI.
### Install
**Linux (x86_64)**
```bash
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.9.1/muyue-linux-amd64.tar.gz | tar xz
chmod +x muyue-linux-amd64
sudo mv muyue-linux-amd64 /usr/local/bin/muyue
```
**macOS (Apple Silicon)**
```bash
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.9.1/muyue-darwin-arm64.tar.gz | tar xz
chmod +x muyue-darwin-arm64
sudo mv muyue-darwin-arm64 /usr/local/bin/muyue
```
**Windows (x86_64)** — 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.9.1/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.9.1
### Changes since v0.9.0
- fix(ui): remove Tests tab, remove raw-md/collapse toggles, add Copy MD button, bump v0.9.1 (3445726)
- chore: update CHANGELOG for v0.9.0 (5875dab)
- feat: RAG, memory, plugins, lessons, file editor, split panes, Markdown rendering, PWA + UI overhaul (4523bbd)
### Downloads
| Platform | File |
|----------|------|
| Linux x86_64 | [muyue-linux-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.9.1/muyue-linux-amd64.tar.gz) |
| Linux ARM64 | [muyue-linux-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.9.1/muyue-linux-arm64.tar.gz) |
| macOS Intel | [muyue-darwin-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.9.1/muyue-darwin-amd64.tar.gz) |
| macOS Apple Silicon | [muyue-darwin-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.9.1/muyue-darwin-arm64.tar.gz) |
| Windows x86_64 | [muyue-windows-amd64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.9.1/muyue-windows-amd64.zip) |
| Windows ARM64 | [muyue-windows-arm64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.9.1/muyue-windows-arm64.zip) |
The binary includes both CLI and Desktop modes.
Run `muyue` for TUI, `muyue desktop` for web UI.
### Install
**Linux (x86_64)**
```bash
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.9.1/muyue-linux-amd64.tar.gz | tar xz
chmod +x muyue-linux-amd64
sudo mv muyue-linux-amd64 /usr/local/bin/muyue
```
**macOS (Apple Silicon)**
```bash
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.9.1/muyue-darwin-arm64.tar.gz | tar xz
chmod +x muyue-darwin-arm64
sudo mv muyue-darwin-arm64 /usr/local/bin/muyue
```
**Windows (x86_64)** — 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.9.1/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.9.0
### Changes since v0.9.0
- feat: RAG, memory, plugins, lessons, file editor, split panes, Markdown rendering, PWA + UI overhaul (4523bbd)
### Downloads
| Platform | File |
|----------|------|
| Linux x86_64 | [muyue-linux-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.9.0/muyue-linux-amd64.tar.gz) |
| Linux ARM64 | [muyue-linux-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.9.0/muyue-linux-arm64.tar.gz) |
| macOS Intel | [muyue-darwin-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.9.0/muyue-darwin-amd64.tar.gz) |
| macOS Apple Silicon | [muyue-darwin-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.9.0/muyue-darwin-arm64.tar.gz) |
| Windows x86_64 | [muyue-windows-amd64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.9.0/muyue-windows-amd64.zip) |
| Windows ARM64 | [muyue-windows-arm64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.9.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.9.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.9.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.9.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.9.0
### Changes since v0.8.0
- feat(extension): Chrome/Edge only + side panel chat tabs (v0.9.0) (f4af63a)
- chore: update CHANGELOG for v0.8.0 (b5e5b30)
- fix(extension): Firefox corrupt zip + duplicate uploads in CI (872e8bf)
- fix(extension): Firefox corrupt zip + duplicate uploads in CI (31b1de1)
### Downloads
| Platform | File |
|----------|------|
| Linux x86_64 | [muyue-linux-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.9.0/muyue-linux-amd64.tar.gz) |
| Linux ARM64 | [muyue-linux-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.9.0/muyue-linux-arm64.tar.gz) |
| macOS Intel | [muyue-darwin-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.9.0/muyue-darwin-amd64.tar.gz) |
| macOS Apple Silicon | [muyue-darwin-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.9.0/muyue-darwin-arm64.tar.gz) |
| Windows x86_64 | [muyue-windows-amd64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.9.0/muyue-windows-amd64.zip) |
| Windows ARM64 | [muyue-windows-arm64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.9.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.9.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.9.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.9.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.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
- feat(studio): force advanced reflection during browser-test sessions (v0.7.2) (a7d4b31)
- fix(terminal/windows): fallback to pipes when PTY unsupported (v0.7.1) (fc7a5b9)
### Downloads
| Platform | File |
|----------|------|
| Linux x86_64 | [muyue-linux-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.2/muyue-linux-amd64.tar.gz) |
| Linux ARM64 | [muyue-linux-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.2/muyue-linux-arm64.tar.gz) |
| macOS Intel | [muyue-darwin-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.2/muyue-darwin-amd64.tar.gz) |
| macOS Apple Silicon | [muyue-darwin-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.2/muyue-darwin-arm64.tar.gz) |
| Windows x86_64 | [muyue-windows-amd64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.2/muyue-windows-amd64.zip) |
| Windows ARM64 | [muyue-windows-arm64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.2/muyue-windows-arm64.zip) |
The binary includes both CLI and Desktop modes.
Run `muyue` for TUI, `muyue desktop` for web UI.
### Install
**Linux (x86_64)**
```bash
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.2/muyue-linux-amd64.tar.gz | tar xz
chmod +x muyue-linux-amd64
sudo mv muyue-linux-amd64 /usr/local/bin/muyue
```
**macOS (Apple Silicon)**
```bash
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.2/muyue-darwin-arm64.tar.gz | tar xz
chmod +x muyue-darwin-arm64
sudo mv muyue-darwin-arm64 /usr/local/bin/muyue
```
**Windows (x86_64)**
```powershell
Invoke-WebRequest -Uri "https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.2/muyue-windows-amd64.zip" -OutFile "muyue.zip"
Expand-Archive -Path "muyue.zip" -DestinationPath "."
Move-Item muyue-windows-amd64.exe C:\Windows\muyue.exe
```
## v0.7.2
### Amélioration
- **feat(studio): réflexion avancée forcée automatiquement pendant les tests** — quand au moins une session `browser_test` est connectée, chaque message à Studio active automatiquement la réflexion avancée (un second modèle, si configuré, produit un rapport préalable injecté dans le prompt actif). Le toggle UI est ignoré tant qu'une session de test est active. Justification : pendant un test piloté par l'IA, avoir une analyse complémentaire d'un autre modèle améliore matériellement la qualité des décisions de clic et la couverture du rapport final.
- Si aucun second provider n'est configuré, le comportement reste silencieux (fallback chat normal — pas d'erreur visible côté utilisateur).
- Hint UI ajouté dans l'onglet Tests pour expliquer le comportement.
## v0.7.1
### Fix
- **fix(terminal/windows): "unsupported" / connection closed** — `creack/pty` n'a pas de support Windows natif et `pty.Start()` retourne immédiatement une erreur ("operating system not supported"), fermant le WebSocket avant même la bannière. L'utilisateur voyait le menu des terminaux peuplé (détection OK : `wsl --list --quiet` fonctionne) mais chaque clic se soldait par "unsupported" ou une connexion fermée.
- Introduction de l'abstraction `termSession` (`internal/api/terminal_session.go`) avec deux implémentations sélectionnées au runtime :
- **`ptySession`** (Linux / macOS / BSDs) : conserve le comportement existant (TTY complet via `creack/pty`, resize, apps interactives type vim/top).
- **`pipeSession`** (Windows) : pipes natifs `stdin` + `stdout` + `stderr` mergés, lus en goroutines, forwardés au WebSocket. Suffisant pour `wsl.exe`, `pwsh`, `cmd` en mode ligne — la plupart des cas d'usage (lancer une commande, voir la sortie, taper la suivante). Resize est un no-op (pas de SIGWINCH sans TTY) ; les TUIs en plein écran ne fonctionnent pas dans ce mode.
- Refactor minimal de `handleTerminalWS` : utilise `startTermSession(cmd)` au lieu de `pty.Start(cmd)` direct ; même chemin code pour les deux OS.
## v0.7.0
### Changes since v0.4.0
- fix(ci): rename browser_test.go → browsertest.go (6d2f174)
- feat: AI-driven browser tests — Tests tab + browser_test agent tool (c820d55)
- release: v0.6.0 — security audit fixes + 7 new features (6a7b4d8)
- chore: bump version to 0.5.0 (2a6647b)
- feat: agent concurrency, conversation summaries, AI tools config, UI polish (3740454)
- feat: AI task API, token-based context windows, SSH password auth, sudo bypass detection (d98110c)
- feat: agent concurrency, conversation summaries, AI tools config, UI polish (d2bb42b)
- feat: AI task API, token-based context windows, SSH password auth, sudo bypass detection (e8a289c)
### Downloads
| Platform | File |
|----------|------|
| Linux x86_64 | [muyue-linux-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.0/muyue-linux-amd64.tar.gz) |
| Linux ARM64 | [muyue-linux-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.0/muyue-linux-arm64.tar.gz) |
| macOS Intel | [muyue-darwin-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.0/muyue-darwin-amd64.tar.gz) |
| macOS Apple Silicon | [muyue-darwin-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.0/muyue-darwin-arm64.tar.gz) |
| Windows x86_64 | [muyue-windows-amd64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.0/muyue-windows-amd64.zip) |
| Windows ARM64 | [muyue-windows-arm64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.0/muyue-windows-arm64.zip) |
The binary includes both CLI and Desktop modes.
Run `muyue` for TUI, `muyue desktop` for web UI.
### Install
**Linux (x86_64)**
```bash
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.0/muyue-linux-amd64.tar.gz | tar xz
chmod +x muyue-linux-amd64
sudo mv muyue-linux-amd64 /usr/local/bin/muyue
```
**macOS (Apple Silicon)**
```bash
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.0/muyue-darwin-arm64.tar.gz | tar xz
chmod +x muyue-darwin-arm64
sudo mv muyue-darwin-arm64 /usr/local/bin/muyue
```
**Windows (x86_64)**
```powershell
Invoke-WebRequest -Uri "https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.7.0/muyue-windows-amd64.zip" -OutFile "muyue.zip"
Expand-Archive -Path "muyue.zip" -DestinationPath "."
Move-Item muyue-windows-amd64.exe C:\Windows\muyue.exe
```
## v0.7.0
### Nouvelle fonctionnalité majeure : Tests pilotés par l'IA
- **Onglet Tests** dédié dans l'UI : génère un snippet JS à coller dans n'importe quelle page web ouverte (Chrome, Firefox, Edge, dev local ou distant).
- **Session WebSocket** authentifiée par token à usage unique (5 min TTL) — la page connectée transmet ses messages console en temps réel et expose une RPC pour cliquer / évaluer / inspecter.
- **Outil agent `browser_test`** disponible pour Studio, avec actions :
- `list_clickables` : énumère tous les éléments cliquables visibles avec un index stable
- `click` : clic par sélecteur CSS ou par index — retourne le **delta console** émis pendant le clic
- `eval` : évalue une expression JS et retourne sa valeur sérialisée
- `console` / `summary` : lit le buffer console (200 dernières entrées)
- `current_url` : URL et titre courants
- `type` : remplit un champ input/textarea (utilise le setter natif pour compatibilité React)
- `wait` : pause asynchrone (max 5s)
- **Stratégie BMAD** intégrée au prompt système Studio : boucle `summary → list_clickables → click → vérifier console_delta → rapport final ✓/✗/⚠`.
- **Multi-sessions** : jusqu'à 16 onglets connectés simultanément ; éviction LRU au-delà.
- **Sécurité** : token consommé à la première connexion ; CheckOrigin libre côté snippet (gating par token uniquement) ; CORS API REST inchangé.
- **Backend** : `internal/api/browser_test.go` (nouveau, ~480 lignes) + 4 routes (`/api/test/snippet`, `/api/test/sessions`, `/api/test/console/{id}`, `/api/ws/browser-test`).
- **Frontend** : `web/src/components/Tests.jsx` (nouveau) + nouvel onglet ⌃4.
## v0.6.0
### Audit & corrections (sécurité, concurrence, stabilité)
- fix(api): empty `resp.Choices[0]` panic in chat engine — bounded check
- fix(api): `defer release()` accumulating inside tool-call loop — release immediately after each tool call
- fix(api): race in `ConversationStoreMulti.Add` (fire-and-forget save under released lock) — synchronous save under existing lock
- fix(workflow): infinite busy-wait in `engine.Execute` when a dependency fails — propagate `StatusFailed`/`StatusSkipped` and short-circuit
- fix(workflow): UTF-8-unsafe slicing of plan goal — rune-aware truncate
- fix(security): CORS `Access-Control-Allow-Origin: *` — restricted to localhost origins
- fix(security): API key disclosure in `/api/providers` — masked as `"***"`; saving handler ignores `"***"` placeholder
- fix(security): SSH password disclosure in `/api/terminal/sessions` — masked; update handler preserves stored password if `"***"` is sent
- fix(security): sshpass `-p` + `-e` mutually-exclusive flags — use only `-e` with `SSHPASS` env var
- fix(security): unbounded chat request body — `MaxBytesReader` 50 MB
- fix(security): unbounded image upload — 10 MB cap in `saveImage`
- fix(security): font size unbounded — capped at 72
- fix(security): `LSP /auto-install` accepted arbitrary `project_dir` — restricted to user home subtree
- fix(api): silent `json.Unmarshal` errors in profile save — propagated
- fix(ui): operator-precedence bug in `Shell.jsx` resize check — parenthesized
### Nouvelles fonctionnalités
- feat(ai): inject OS name (e.g. `Debian 12`, `Windows 11`, `macOS 14.5`) alongside date in Studio system prompt
- feat(agents): default timeout raised to 30 minutes for `crush_run` and `claude_run`; max also 30 min
- feat(agents): new optional params `cwd`, `wsl_distro`, `wsl_user` — agents can be launched in a specific directory, and on Windows hosts inside a specific WSL distribution under a specific user
- feat(agents): new `claude_run` tool (mirrors `crush_run` for the Claude Code CLI)
- feat(terminal): WSL distros listed individually as quick-launch entries in the new-tab menu (Windows hosts only)
- feat(studio): system prompt rewritten around the BMAD-METHOD (Analyst/PM/Architect/SM/Dev/QA personas + mandatory `[OBJECTIF]/[CONTEXTE]/[CONTRAINTES]/[LIVRABLE]/[CRITÈRE D'ACCEPTATION]` template for any agent delegation)
- feat(studio): "Réflexion avancée" toggle — when enabled, the inactive AI provider produces a preliminary report that is injected as `[RAPPORT PRÉALABLE]` context into the active provider's prompt
- feat(studio): "Historique compressé" toggle — collapses past tool calls and keeps only the last visible action per assistant message, with `Tout afficher` to expand
### Bug fix CI
- fix(test): `cleanAIResponse` → `CleanAIResponse` in `orchestrator_test.go` (was failing `go vet`)
## v0.4.0
### Changes since v0.3.5

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="popup">
<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,89 @@
<!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-tertiary)" 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="studio-feed-layout" style="display:none">
<div id="chat-feed" class="studio-feed"></div>
<div class="studio-input-area">
<div class="studio-input-row">
<textarea id="chat-input" placeholder="Envoyer un message…" rows="1"></textarea>
<button id="chat-send" class="studio-send-btn">
<svg width="18" height="18" 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="studio-stop-btn" style="display:none">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<rect x="4" y="4" width="16" height="16" rx="2"/>
</svg>
</button>
</div>
<div class="studio-input-hint">/clear /help</div>
</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,706 @@
:root {
--bg: #0A0A0C;
--bg-base: #0F0D10;
--bg-surface: #161218;
--bg-elevated: #1C1719;
--bg-card: #221B1E;
--bg-input: #2A2225;
--bg-hover: #332528;
--accent: #FF0033;
--accent-dark: #8B0020;
--accent-deep: #5C0015;
--accent-light: #FF1A5E;
--accent-muted: #FF4D6D;
--accent-bright: #FF1744;
--accent-soft: #FF5252;
--accent-dim: #6B2033;
--accent-bg: #4A1525;
--text-primary: #EAE0E2;
--text-secondary: #D4C4C8;
--text-tertiary: #8A7A7E;
--text-disabled: #5A4F52;
--success: #00E676;
--warning: #FFD740;
--error: #FF1744;
--info: #448AFF;
--border: #2A1F22;
--border-accent: #FF003344;
--border-accent-full: #FF0033;
--radius-sm: 6px;
--radius: 8px;
--radius-lg: 12px;
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
--font-mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', 'Menlo', monospace;
--green: #00E676;
--yellow: #FFD740;
--red: #FF1744;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: var(--bg);
color: var(--text-secondary);
font-family: var(--font-sans);
font-size: 13px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
::selection { background: var(--accent); color: #fff; }
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--accent-dim); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: var(--accent-dark); }
a { color: var(--accent); text-decoration: none; cursor: pointer; }
/* ── Popup (icon click) ── */
.popup {
width: 320px;
padding: 16px;
}
.popup header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid var(--border);
}
.popup .footer {
margin-top: 12px;
padding-top: 10px;
border-top: 1px solid var(--border);
text-align: center;
color: var(--text-tertiary);
font-size: 10px;
}
/* ── Panel (side panel) ── */
.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; color: var(--text-primary); }
/* ── Tabs ── */
.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-tertiary);
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s;
font-family: var(--font-sans);
}
.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;
}
/* ── Config tab ── */
.status-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
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-tertiary); font-size: 12px; }
.status-value { font-weight: 500; font-size: 12px; color: var(--text-primary); }
.dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 6px;
}
.dot-green { background: var(--success); box-shadow: 0 0 6px var(--success); }
.dot-red { background: var(--error); box-shadow: 0 0 6px var(--error); }
.dot-yellow { background: var(--warning); box-shadow: 0 0 6px var(--warning); }
.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: var(--radius-sm);
border: 1px solid var(--border);
background: var(--bg-card);
color: var(--text-secondary);
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s ease;
text-decoration: none;
font-family: var(--font-sans);
}
.btn:hover { background: var(--accent-bg); border-color: var(--accent-dark); color: var(--text-primary); }
.btn-primary {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
.btn-primary:hover {
background: var(--accent-bright);
border-color: var(--accent-bright);
box-shadow: 0 0 12px var(--border-accent);
}
.settings-section {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--border);
}
.settings-section label {
display: block;
color: var(--text-tertiary);
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-input);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 6px 8px;
color: var(--text-primary);
font-size: 12px;
font-family: var(--font-mono);
outline: none;
transition: border-color 0.2s;
}
.input-row input:focus { border-color: var(--accent); }
.input-row button {
padding: 6px 10px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--bg-card);
color: var(--text-secondary);
cursor: pointer;
font-size: 11px;
font-family: var(--font-sans);
}
.input-row button:hover { background: var(--accent-bg); border-color: var(--accent-dark); }
.footer {
margin-top: auto;
padding: 10px 16px;
border-top: 1px solid var(--border);
text-align: center;
color: var(--text-tertiary);
font-size: 10px;
flex-shrink: 0;
}
.footer span { color: var(--accent); }
/* ── Chat offline ── */
.chat-offline {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
color: var(--text-tertiary);
font-size: 13px;
}
/* ── Studio Feed (same classes as Studio.jsx) ── */
.studio-feed-layout {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
flex: 1;
}
.studio-feed {
flex: 1;
overflow-y: auto;
padding: 12px 8px;
display: flex;
flex-direction: column;
gap: 4px;
}
.feed-loading {
display: flex;
align-items: center;
justify-content: center;
padding: 60px 0;
}
.feed-item {
display: flex;
gap: 10px;
padding: 8px 12px;
border-radius: var(--radius);
animation: fadeIn 0.15s ease-out;
}
.feed-item:hover { background: var(--bg-card); }
.feed-item.user {
background: var(--bg-card);
border-left: 3px solid #FFD740;
}
.feed-item.assistant {
border-left: 3px solid transparent;
}
.feed-item.assistant:hover {
border-left-color: var(--accent-dark);
}
.feed-item.system {
align-items: center;
gap: 8px;
padding: 6px 12px;
}
.feed-avatar {
width: 28px;
height: 28px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-top: 2px;
font-size: 14px;
}
.feed-avatar.user-rank {
background: rgba(255, 215, 64, 0.15);
}
.feed-avatar.ai-rank {
background: var(--accent-bg);
}
.feed-body {
flex: 1;
min-width: 0;
}
.feed-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 2px;
}
.feed-rank-badge {
font-size: 9px;
font-weight: 800;
font-family: var(--font-mono);
padding: 1px 6px;
border-radius: 3px;
border: 1px solid;
letter-spacing: 0.5px;
text-transform: uppercase;
background: rgba(255, 215, 64, 0.08);
}
.feed-role {
font-size: 11px;
font-weight: 700;
color: var(--accent);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.feed-time {
font-size: 10px;
color: var(--text-disabled);
font-family: var(--font-mono);
}
.feed-content {
font-size: 14px;
line-height: 1.5;
color: var(--text-primary);
word-break: break-word;
}
.feed-system-badge {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--accent-dim);
flex-shrink: 0;
}
.feed-system-text {
font-size: 12px;
color: var(--text-tertiary);
font-style: italic;
flex: 1;
}
.feed-content table { width: 100%; border-collapse: collapse; margin: 8px 0; font-size: 13px; }
.feed-content th { background: var(--bg-surface); padding: 6px 12px; text-align: left; font-weight: 600; border: 1px solid var(--border); color: var(--text-secondary); }
.feed-content td { padding: 5px 12px; border: 1px solid var(--border); color: var(--text-primary); }
.feed-content tr:nth-child(even) td { background: var(--bg-surface); }
.feed-content hr { border: none; border-top: 1px solid var(--border); margin: 12px 0; }
.inline-code { background: var(--bg-input); padding: 2px 6px; border-radius: 4px; font-family: var(--font-mono); font-size: 13px; color: var(--accent-muted); }
.msg-h2 { font-size: 17px; font-weight: 700; color: var(--text-primary); margin: 12px 0 6px; display: block; }
.msg-h3 { font-size: 15px; font-weight: 700; color: var(--text-primary); margin: 10px 0 4px; display: block; }
.msg-h4 { font-size: 13px; font-weight: 600; color: var(--text-secondary); margin: 8px 0 3px; display: block; }
.msg-bullet { display: block; padding-left: 4px; margin: 1px 0; color: var(--text-primary); }
.msg-step { display: flex; gap: 8px; align-items: baseline; margin: 1px 0; }
.msg-step-num { color: var(--accent); font-weight: 700; font-family: var(--font-mono); font-size: 13px; flex-shrink: 0; min-width: 20px; }
/* ── Studio Code Blocks ── */
.studio-code-block {
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
margin: 8px 0;
}
.studio-code-header {
display: flex;
align-items: center;
justify-content: flex-end;
background: var(--bg-surface);
border-bottom: 1px solid var(--border);
}
.studio-code-block pre {
padding: 12px 16px;
font-family: var(--font-mono);
font-size: 13px;
line-height: 1.5;
overflow-x: auto;
color: var(--text-primary);
margin: 0;
}
.studio-code-lang {
padding: 4px 12px;
font-size: 11px;
font-weight: 600;
color: var(--text-tertiary);
background: var(--bg-surface);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.studio-copy-btn {
padding: 3px 10px;
font-size: 10px;
font-weight: 600;
color: var(--text-tertiary);
background: transparent;
border: none;
border-left: 1px solid var(--border);
cursor: pointer;
transition: all 0.15s;
font-family: var(--font-sans);
white-space: nowrap;
}
.studio-copy-btn:hover { background: var(--accent-bg); color: var(--accent); }
.studio-copy-btn.copied { background: var(--accent-bg); color: var(--accent); }
/* ── Studio Thinking ── */
.feed-thinking-block {
background: var(--bg-surface);
border: 1px solid var(--border);
border-left: 2px solid var(--accent-dim);
border-radius: var(--radius);
margin: 6px 0 8px;
overflow: hidden;
max-height: 200px;
overflow-y: auto;
}
.feed-thinking-block.done { border-left-color: var(--text-disabled); opacity: 0.7; }
.feed-thinking-header {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
font-size: 10px;
font-weight: 700;
color: var(--text-tertiary);
text-transform: uppercase;
letter-spacing: 0.5px;
background: var(--bg-card);
border-bottom: 1px solid var(--border);
}
.feed-thinking-header svg { color: var(--warning); }
.feed-thinking-dots { display: inline-flex; gap: 2px; margin-left: 4px; }
.feed-thinking-dots span { width: 4px; height: 4px; border-radius: 50%; background: var(--warning); animation: bounce 1.2s ease-in-out infinite; }
.feed-thinking-dots span:nth-child(2) { animation-delay: 0.15s; }
.feed-thinking-dots span:nth-child(3) { animation-delay: 0.3s; }
.feed-thinking-content {
padding: 8px 10px;
font-size: 12px;
color: var(--text-tertiary);
font-style: italic;
line-height: 1.5;
max-height: 80px;
overflow-y: auto;
}
/* ── Studio Tool Blocks ── */
.studio-tool-block {
background: var(--bg-surface);
border: 1px solid var(--border);
border-left: 3px solid var(--accent-dim);
border-radius: var(--radius);
margin: 6px 0;
overflow: hidden;
transition: all 0.3s ease;
}
.studio-tool-block.running { border-left-color: var(--warning); }
.studio-tool-block.error { border-left-color: var(--error); background: rgba(255, 23, 68, 0.05); }
.studio-tool-header {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
background: var(--bg-card);
border-bottom: 1px solid var(--border);
font-size: 12px;
}
.studio-tool-icon { font-size: 14px; flex-shrink: 0; }
.studio-tool-name {
color: var(--text-tertiary);
font-weight: 600;
font-family: var(--font-mono);
font-size: 12px;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.studio-tool-spinner { display: inline-flex; gap: 2px; margin-left: 4px; }
.studio-tool-spinner span { width: 4px; height: 4px; border-radius: 50%; background: var(--warning); animation: bounce 1.2s ease-in-out infinite; }
.studio-tool-spinner span:nth-child(2) { animation-delay: 0.15s; }
.studio-tool-spinner span:nth-child(3) { animation-delay: 0.3s; }
.studio-tool-status { font-weight: 700; font-size: 14px; flex-shrink: 0; }
.studio-tool-status.ok { color: var(--success); }
.studio-tool-status.error { color: var(--error); }
.studio-tool-args {
padding: 6px 10px;
font-size: 12px;
font-family: var(--font-mono);
color: var(--text-tertiary);
white-space: pre-wrap;
word-break: break-all;
overflow: hidden;
text-overflow: ellipsis;
border-bottom: 1px solid var(--border);
background: var(--bg-elevated);
}
.studio-tool-result { max-height: 200px; overflow-y: auto; }
.studio-tool-result pre {
padding: 8px 10px;
font-family: var(--font-mono);
font-size: 12px;
line-height: 1.5;
color: var(--text-secondary);
margin: 0;
white-space: pre-wrap;
word-break: break-word;
background: var(--bg);
}
/* ── Studio Cursor & Thinking Dots ── */
.studio-cursor {
display: inline-block;
width: 8px;
height: 16px;
background: var(--accent);
margin-left: 2px;
vertical-align: text-bottom;
animation: blink 0.8s step-end infinite;
}
.studio-thinking { display: flex; gap: 4px; padding: 8px 0; }
.studio-thinking span { width: 6px; height: 6px; border-radius: 50%; background: var(--accent-dim); animation: bounce 1.2s ease-in-out infinite; }
.studio-thinking span:nth-child(2) { animation-delay: 0.15s; }
.studio-thinking span:nth-child(3) { animation-delay: 0.3s; }
@keyframes blink { 50% { opacity: 0; } }
@keyframes bounce { 0%, 80%, 100% { transform: translateY(0); opacity: 0.4; } 40% { transform: translateY(-6px); opacity: 1; } }
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
/* ── Studio Input Area ── */
.studio-input-area {
padding: 12px 16px 8px;
border-top: 1px solid var(--border);
background: var(--bg-surface);
flex-shrink: 0;
}
.studio-input-row {
display: flex;
gap: 8px;
align-items: flex-end;
}
.studio-input-row textarea {
flex: 1;
resize: none;
min-height: 42px;
max-height: 120px;
padding: 10px 14px;
font-size: 14px;
line-height: 1.5;
border-radius: var(--radius);
background: var(--bg-input);
color: var(--text-primary);
border: 1px solid var(--border);
font-family: var(--font-sans);
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
}
.studio-input-row textarea:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--border-accent); }
.studio-input-row textarea::placeholder { color: var(--text-disabled); }
.studio-send-btn {
width: 42px;
height: 42px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius);
background: var(--accent);
color: #fff;
border: 1px solid var(--accent);
cursor: pointer;
transition: all 0.15s;
flex-shrink: 0;
}
.studio-send-btn:hover { background: var(--accent-bright); border-color: var(--accent-bright); }
.studio-send-btn:disabled { opacity: 0.3; cursor: not-allowed; }
.studio-stop-btn {
width: 42px;
height: 42px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius);
background: var(--error);
color: #fff;
border: 1px solid var(--error);
cursor: pointer;
transition: all 0.15s;
flex-shrink: 0;
}
.studio-stop-btn:hover { opacity: 0.8; }
.studio-input-hint {
font-size: 11px;
color: var(--text-disabled);
text-align: center;
margin-top: 6px;
}

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',
},
},
});

12
go.mod
View File

@@ -1,8 +1,6 @@
module github.com/muyue/muyue
go 1.24.2
toolchain go1.24.3
go 1.25.0
require (
github.com/charmbracelet/huh v1.0.0
@@ -39,9 +37,15 @@ require (
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/pflag v1.0.9 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.23.0 // indirect
modernc.org/libc v1.72.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.50.0 // indirect
)

14
go.sum
View File

@@ -73,6 +73,10 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
@@ -89,9 +93,19 @@ golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/libc v1.72.0 h1:IEu559v9a0XWjw0DPoVKtXpO2qt5NVLAnFaBbjq+n8c=
modernc.org/libc v1.72.0/go.mod h1:tTU8DL8A+XLVkEY3x5E/tO7s2Q/q42EtnNWda/L5QhQ=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/sqlite v1.50.0 h1:eMowQSWLK0MeiQTdmz3lqoF5dqclujdlIKeJA11+7oM=
modernc.org/sqlite v1.50.0/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew=

378
internal/agent/browser.go Normal file
View File

@@ -0,0 +1,378 @@
package agent
import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
)
type BrowserParams struct {
Action string `json:"action" description:"Browser action: navigate, screenshot, click, type, evaluate, fill_form, read_page, close"`
URL string `json:"url,omitempty" description:"URL to navigate to (for navigate action)"`
Selector string `json:"selector,omitempty" description:"CSS/XPath selector for click, type, fill_form actions"`
Value string `json:"value,omitempty" description:"Value to type or fill"`
Script string `json:"script,omitempty" description:"JavaScript to evaluate (for evaluate action)"`
Timeout int `json:"timeout,omitempty" description:"Timeout in seconds for the action (default 30)"`
}
type BrowserResponse struct {
Content string `json:"content"`
URL string `json:"url,omitempty"`
Title string `json:"title,omitempty"`
Screenshot string `json:"screenshot,omitempty"`
IsError bool `json:"is_error"`
}
type BrowserSession struct {
id string
url string
title string
mu sync.Mutex
createdAt time.Time
}
type BrowserManager struct {
mu sync.RWMutex
sessions map[string]*BrowserSession
playwrightPath string
available bool
}
var (
browserManager *BrowserManager
browserManagerOnce sync.Once
)
func GetBrowserManager() *BrowserManager {
browserManagerOnce.Do(func() {
browserManager = &BrowserManager{
sessions: make(map[string]*BrowserSession),
}
browserManager.playwrightPath, browserManager.available = detectPlaywright()
})
return browserManager
}
func detectPlaywright() (string, bool) {
for _, cmd := range []string{"playwright", "npx"} {
if path, err := exec.LookPath(cmd); err == nil {
return path, true
}
}
return "", false
}
func NewBrowserTool() (*ToolDefinition, error) {
return NewTool("browser",
"Interact with web pages using a headless browser (Playwright). Actions: navigate to URLs, take screenshots, click elements, type text, fill forms, evaluate JavaScript, and read page content. Sessions persist per conversation.",
func(ctx context.Context, p BrowserParams) (ToolResponse, error) {
if p.Action == "" {
return TextErrorResponse("action is required (navigate, screenshot, click, type, evaluate, fill_form, read_page, close)"), nil
}
mgr := GetBrowserManager()
if !mgr.available {
return TextErrorResponse("Playwright is not installed. Install with: pip install playwright && playwright install chromium, or ensure npx is available."), nil
}
timeout := time.Duration(p.Timeout) * time.Second
if timeout == 0 {
timeout = 30 * time.Second
}
if timeout > 120*time.Second {
timeout = 120 * time.Second
}
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
switch p.Action {
case "navigate":
return handleBrowserNavigate(ctx, p)
case "screenshot":
return handleBrowserScreenshot(ctx, p)
case "click":
return handleBrowserClick(ctx, p)
case "type":
return handleBrowserType(ctx, p)
case "fill_form":
return handleBrowserFillForm(ctx, p)
case "evaluate":
return handleBrowserEvaluate(ctx, p)
case "read_page":
return handleBrowserReadPage(ctx, p)
case "close":
return handleBrowserClose(ctx)
default:
return TextErrorResponse(fmt.Sprintf("unknown browser action: %s. Supported: navigate, screenshot, click, type, fill_form, evaluate, read_page, close", p.Action)), nil
}
})
}
func handleBrowserNavigate(ctx context.Context, p BrowserParams) (ToolResponse, error) {
if p.URL == "" {
return TextErrorResponse("url is required for navigate action"), nil
}
script := fmt.Sprintf(`
const { chromium } = require('playwright');
(async () => {
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage();
await page.goto(%q, { waitUntil: 'domcontentloaded', timeout: 30000 });
const title = await page.title();
const content = await page.evaluate(() => document.body.innerText);
console.log(JSON.stringify({ url: page.url(), title, content: content.substring(0, 8000) }));
await browser.close();
})();
`, p.URL)
result, err := runPlaywrightScript(ctx, script)
if err != nil {
return TextErrorResponse(fmt.Sprintf("navigate error: %v", err)), nil
}
return TextResponse(result), nil
}
func handleBrowserScreenshot(ctx context.Context, p BrowserParams) (ToolResponse, error) {
url := p.URL
if url == "" {
url = "about:blank"
}
home, _ := os.UserHomeDir()
screenshotDir := filepath.Join(home, ".muyue", "screenshots")
os.MkdirAll(screenshotDir, 0755)
screenshotPath := filepath.Join(screenshotDir, fmt.Sprintf("browser_%d.png", time.Now().UnixNano()))
script := fmt.Sprintf(`
const { chromium } = require('playwright');
(async () => {
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage();
await page.goto(%q, { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.screenshot({ path: %q, fullPage: false });
const title = await page.title();
console.log(JSON.stringify({ screenshot: %q, title, url: page.url() }));
await browser.close();
})();
`, url, screenshotPath, screenshotPath)
result, err := runPlaywrightScript(ctx, script)
if err != nil {
return TextErrorResponse(fmt.Sprintf("screenshot error: %v", err)), nil
}
return TextResponse(fmt.Sprintf("Screenshot saved: %s\n%s", screenshotPath, result)), nil
}
func handleBrowserClick(ctx context.Context, p BrowserParams) (ToolResponse, error) {
if p.Selector == "" {
return TextErrorResponse("selector is required for click action"), nil
}
script := fmt.Sprintf(`
const { chromium } = require('playwright');
(async () => {
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage();
await page.goto(%q, { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.click(%q);
await page.waitForTimeout(1000);
const title = await page.title();
const content = await page.evaluate(() => document.body.innerText);
console.log(JSON.stringify({ url: page.url(), title, content: content.substring(0, 5000) }));
await browser.close();
})();
`, p.URL, p.Selector)
result, err := runPlaywrightScript(ctx, script)
if err != nil {
return TextErrorResponse(fmt.Sprintf("click error: %v", err)), nil
}
return TextResponse(result), nil
}
func handleBrowserType(ctx context.Context, p BrowserParams) (ToolResponse, error) {
if p.Selector == "" || p.Value == "" {
return TextErrorResponse("selector and value are required for type action"), nil
}
script := fmt.Sprintf(`
const { chromium } = require('playwright');
(async () => {
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage();
await page.goto(%q, { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.fill(%q, %q);
const content = await page.evaluate(() => document.body.innerText);
console.log(JSON.stringify({ url: page.url(), content: content.substring(0, 5000) }));
await browser.close();
})();
`, p.URL, p.Selector, p.Value)
result, err := runPlaywrightScript(ctx, script)
if err != nil {
return TextErrorResponse(fmt.Sprintf("type error: %v", err)), nil
}
return TextResponse(result), nil
}
func handleBrowserFillForm(ctx context.Context, p BrowserParams) (ToolResponse, error) {
var fields []struct {
Selector string `json:"selector"`
Value string `json:"value"`
}
if err := json.Unmarshal([]byte(p.Value), &fields); err != nil {
return TextErrorResponse("fill_form value must be a JSON array of {selector, value} objects"), nil
}
var fillsJS strings.Builder
for _, f := range fields {
fillsJS.WriteString(fmt.Sprintf("\tawait page.fill(%q, %q);\n", f.Selector, f.Value))
}
script := fmt.Sprintf(`
const { chromium } = require('playwright');
(async () => {
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage();
await page.goto(%q, { waitUntil: 'domcontentloaded', timeout: 30000 });
%s
const content = await page.evaluate(() => document.body.innerText);
console.log(JSON.stringify({ url: page.url(), content: content.substring(0, 5000) }));
await browser.close();
})();
`, p.URL, fillsJS.String())
result, err := runPlaywrightScript(ctx, script)
if err != nil {
return TextErrorResponse(fmt.Sprintf("fill_form error: %v", err)), nil
}
return TextResponse(result), nil
}
func handleBrowserEvaluate(ctx context.Context, p BrowserParams) (ToolResponse, error) {
if p.Script == "" {
return TextErrorResponse("script is required for evaluate action"), nil
}
url := p.URL
if url == "" {
url = "about:blank"
}
script := fmt.Sprintf(`
const { chromium } = require('playwright');
(async () => {
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage();
await page.goto(%q, { waitUntil: 'domcontentloaded', timeout: 30000 });
const result = await page.evaluate(() => {
try { return String((%s)); } catch(e) { return String(e); }
});
console.log(JSON.stringify({ result: result.substring(0, 8000) }));
await browser.close();
})();
`, url, p.Script)
result, err := runPlaywrightScript(ctx, script)
if err != nil {
return TextErrorResponse(fmt.Sprintf("evaluate error: %v", err)), nil
}
return TextResponse(result), nil
}
func handleBrowserReadPage(ctx context.Context, p BrowserParams) (ToolResponse, error) {
if p.URL == "" {
return TextErrorResponse("url is required for read_page action"), nil
}
script := fmt.Sprintf(`
const { chromium } = require('playwright');
(async () => {
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage();
await page.goto(%q, { waitUntil: 'domcontentloaded', timeout: 30000 });
const title = await page.title();
const html = await page.content();
console.log(JSON.stringify({ url: page.url(), title, content_length: html.length, content: html.substring(0, 15000) }));
await browser.close();
})();
`, p.URL)
result, err := runPlaywrightScript(ctx, script)
if err != nil {
return TextErrorResponse(fmt.Sprintf("read_page error: %v", err)), nil
}
return TextResponse(result), nil
}
func handleBrowserClose(ctx context.Context) (ToolResponse, error) {
mgr := GetBrowserManager()
mgr.mu.Lock()
defer mgr.mu.Unlock()
count := len(mgr.sessions)
mgr.sessions = make(map[string]*BrowserSession)
return TextResponse(fmt.Sprintf("Closed %d browser session(s)", count)), nil
}
func runPlaywrightScript(ctx context.Context, script string) (string, error) {
tmpFile, err := os.CreateTemp("", "muyue-browser-*.js")
if err != nil {
return "", fmt.Errorf("create temp file: %w", err)
}
defer os.Remove(tmpFile.Name())
if _, err := tmpFile.WriteString(script); err != nil {
tmpFile.Close()
return "", fmt.Errorf("write script: %w", err)
}
tmpFile.Close()
var cmd *exec.Cmd
mgr := GetBrowserManager()
if mgr.playwrightPath == "npx" || mgr.playwrightPath == "" {
cmd = exec.CommandContext(ctx, "npx", "-y", "playwright", "test", "--config=/dev/null")
cmd = exec.CommandContext(ctx, "node", tmpFile.Name())
} else {
cmd = exec.CommandContext(ctx, "node", tmpFile.Name())
}
// Check if node is available
if _, err := exec.LookPath("node"); err != nil {
return "", fmt.Errorf("node is not installed. Install Node.js to use the browser tool")
}
cmd = exec.CommandContext(ctx, "node", tmpFile.Name())
output, err := cmd.CombinedOutput()
result := string(output)
if len(result) > 10000 {
result = result[:10000] + "\n... [truncated]"
}
if err != nil {
if ctx.Err() == context.DeadlineExceeded {
return "", fmt.Errorf("browser action timed out")
}
return result, fmt.Errorf("playwright error: %w", err)
}
return result, nil
}

View File

@@ -6,11 +6,18 @@ import (
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"sync"
"time"
)
var ansiRegex = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07|\x1b\][^\x1b]*\x1b\\|\x1b[()][AB012]|\[\]`)
func stripANSI(s string) string {
return ansiRegex.ReplaceAllString(s, "")
}
var (
sudoCache bool
sudoCacheSet bool
@@ -56,9 +63,30 @@ func NewTerminalTool() (*ToolDefinition, error) {
if NeedsSudoPassword() {
trimmed := strings.TrimSpace(p.Command)
lower := strings.ToLower(trimmed)
if strings.HasPrefix(lower, "sudo ") || strings.HasPrefix(lower, "doas ") || strings.HasPrefix(lower, "run0 ") || strings.HasPrefix(lower, "pkexec ") {
prefixBlocked := strings.HasPrefix(lower, "sudo ") || strings.HasPrefix(lower, "doas ") || strings.HasPrefix(lower, "run0 ") || strings.HasPrefix(lower, "pkexec ")
anywhereBlocked := false
blockedCmd := ""
if !prefixBlocked {
for _, kw := range []string{"sudo", "doas", "run0", "pkexec"} {
for _, pattern := range []string{" " + kw + " ", "|" + kw + " ", ";" + kw + " ", "&&" + kw + " ", "||" + kw + " ", "`" + kw + " ", "$(" + kw + " "} {
if strings.Contains(lower, pattern) {
anywhereBlocked = true
blockedCmd = kw
break
}
}
if anywhereBlocked {
break
}
}
}
if prefixBlocked || anywhereBlocked {
elevCmd := blockedCmd
if prefixBlocked {
elevCmd = strings.Fields(trimmed)[0]
}
return ToolResponse{
Content: fmt.Sprintf("BLOCKED: Command '%s' requires elevated privileges (%s). The current user is not root. Do NOT retry with sudo. Explain to the user that this command needs admin privileges and suggest an alternative, or tell them to run it manually in their terminal.", trimmed, strings.Fields(trimmed)[0]),
Content: fmt.Sprintf("BLOCKED: Command '%s' requires elevated privileges (%s). Passwordless sudo is not available. Do NOT retry with sudo. Explain to the user that this command needs admin privileges and suggest an alternative, or tell them to run it manually in their terminal.", trimmed, elevCmd),
IsError: true,
Meta: map[string]string{"sudo_blocked": "true", "command": trimmed},
}, nil
@@ -82,6 +110,7 @@ func NewTerminalTool() (*ToolDefinition, error) {
output, err := cmd.CombinedOutput()
result := string(output)
result = stripANSI(result)
if len(result) > 10000 {
result = result[:10000] + "\n... [truncated]"
}
@@ -95,21 +124,35 @@ func NewTerminalTool() (*ToolDefinition, error) {
}
type CrushRunParams struct {
Task string `json:"task" description:"The task description for Crush to execute"`
Task string `json:"task" description:"The task description for Crush to execute"`
Timeout int `json:"timeout,omitempty" description:"Maximum execution time in seconds (default 1800, max 1800)"`
Cwd string `json:"cwd,omitempty" description:"Working directory in which to launch the agent (absolute path; falls back to user home)"`
WSLDistro string `json:"wsl_distro,omitempty" description:"On Windows host: WSL distribution to launch the agent in (e.g. 'Ubuntu')"`
WSLUser string `json:"wsl_user,omitempty" description:"On Windows host: WSL user to run the agent as"`
}
func NewCrushRunTool() (*ToolDefinition, error) {
return NewTool("crush_run",
"Delegate a complex coding task to the Crush AI agent. Crush has access to file editing, code search, bash execution, and other development tools. Use this for multi-step coding tasks like refactoring, debugging, implementing features, or code review. Returns the agent's final output.",
"Delegate a complex coding task to the Crush AI agent. Crush has access to file editing, code search, bash execution, and other development tools. Use this for multi-step coding tasks like refactoring, debugging, implementing features, or code review. Optionally pass cwd to run in a specific directory, or wsl_distro/wsl_user to launch inside a WSL distribution under a specific user (Windows hosts only). Returns the agent's final output.",
func(ctx context.Context, p CrushRunParams) (ToolResponse, error) {
if p.Task == "" {
return TextErrorResponse("task is required"), nil
}
ctx, cancel := context.WithTimeout(ctx, 300*time.Second)
timeout := time.Duration(p.Timeout) * time.Second
if timeout == 0 {
timeout = 1800 * time.Second
}
if timeout > 1800*time.Second {
timeout = 1800 * time.Second
}
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
cmd := exec.CommandContext(ctx, "crush", "run", p.Task)
cmd, prepErr := buildAgentCommand(ctx, "crush", []string{"run", p.Task}, p.Cwd, p.WSLDistro, p.WSLUser)
if prepErr != nil {
return TextErrorResponse(prepErr.Error()), nil
}
output, err := cmd.CombinedOutput()
result := string(output)
@@ -118,7 +161,66 @@ func NewCrushRunTool() (*ToolDefinition, error) {
}
if err != nil {
return TextErrorResponse(fmt.Sprintf("Crush error: %v\n\n%s", err, result)), nil
errMsg := fmt.Sprintf("Crush error: %v", err)
if ctx.Err() == context.DeadlineExceeded {
errMsg = fmt.Sprintf("Crush timed out after %d seconds. Try splitting the task into smaller parts.", int(timeout.Seconds()))
}
if result != "" {
errMsg += "\n\n" + result
}
return TextErrorResponse(errMsg), nil
}
return TextResponse(result), nil
})
}
type ClaudeRunParams struct {
Task string `json:"task" description:"The task description for Claude Code to execute"`
Timeout int `json:"timeout,omitempty" description:"Maximum execution time in seconds (default 1800, max 1800)"`
Cwd string `json:"cwd,omitempty" description:"Working directory in which to launch the agent (absolute path; falls back to user home)"`
WSLDistro string `json:"wsl_distro,omitempty" description:"On Windows host: WSL distribution to launch the agent in (e.g. 'Ubuntu')"`
WSLUser string `json:"wsl_user,omitempty" description:"On Windows host: WSL user to run the agent as"`
}
func NewClaudeRunTool() (*ToolDefinition, error) {
return NewTool("claude_run",
"Delegate a complex coding task to the Claude Code CLI agent. Claude has access to file editing, code search, bash execution. Use for multi-step coding tasks. Same cwd/wsl_distro/wsl_user options as crush_run.",
func(ctx context.Context, p ClaudeRunParams) (ToolResponse, error) {
if p.Task == "" {
return TextErrorResponse("task is required"), nil
}
timeout := time.Duration(p.Timeout) * time.Second
if timeout == 0 {
timeout = 1800 * time.Second
}
if timeout > 1800*time.Second {
timeout = 1800 * time.Second
}
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
cmd, prepErr := buildAgentCommand(ctx, "claude", []string{"-p", p.Task}, p.Cwd, p.WSLDistro, p.WSLUser)
if prepErr != nil {
return TextErrorResponse(prepErr.Error()), nil
}
output, err := cmd.CombinedOutput()
result := string(output)
if len(result) > 15000 {
result = result[:15000] + "\n... [truncated]"
}
if err != nil {
errMsg := fmt.Sprintf("Claude error: %v", err)
if ctx.Err() == context.DeadlineExceeded {
errMsg = fmt.Sprintf("Claude timed out after %d seconds. Try splitting the task into smaller parts.", int(timeout.Seconds()))
}
if result != "" {
errMsg += "\n\n" + result
}
return TextErrorResponse(errMsg), nil
}
return TextResponse(result), nil
@@ -327,6 +429,7 @@ func DefaultRegistry() *Registry {
tools := []*ToolDefinition{
must(NewTerminalTool()),
must(NewCrushRunTool()),
must(NewClaudeRunTool()),
must(NewReadFileTool()),
must(NewListFilesTool()),
must(NewSearchFilesTool()),
@@ -335,6 +438,12 @@ func DefaultRegistry() *Registry {
must(NewSetProviderTool()),
must(NewManageSSHTool()),
must(NewWebFetchTool()),
must(NewDelegateTool(r)),
must(NewDelegateMultiTool(r)),
}
if bt, err := NewBrowserTool(); err == nil {
tools = append(tools, bt)
}
for _, t := range tools {

203
internal/agent/delegate.go Normal file
View File

@@ -0,0 +1,203 @@
package agent
import (
"context"
"encoding/json"
"fmt"
"sync"
"time"
)
type DelegateTaskParams struct {
Task string `json:"task" description:"Description of the sub-task to delegate"`
Context string `json:"context,omitempty" description:"Additional context for the sub-task"`
Timeout int `json:"timeout,omitempty" description:"Timeout per sub-task in seconds (default 120, max 300)"`
MaxParallel int `json:"max_parallel,omitempty" description:"Maximum parallel sub-tasks (default 3, max 5)"`
}
type DelegateMultiParams struct {
Tasks []DelegateTaskParams `json:"tasks" description:"List of sub-tasks to execute in parallel"`
MaxParallel int `json:"max_parallel,omitempty" description:"Maximum parallel sub-tasks (default 3, max 5)"`
}
type SubTaskResult struct {
Task string `json:"task"`
Success bool `json:"success"`
Result string `json:"result"`
Error string `json:"error,omitempty"`
}
type DelegateResponse struct {
TotalTasks int `json:"total_tasks"`
Successful int `json:"successful"`
Failed int `json:"failed"`
Results []SubTaskResult `json:"results"`
Duration string `json:"duration"`
}
func NewDelegateTool(registry *Registry) (*ToolDefinition, error) {
return NewTool("delegate_task",
"Delegate one or more tasks for parallel execution. Each sub-task runs in isolation with its own context. Returns aggregated results from all sub-tasks. Use for independent tasks that can run concurrently.",
func(ctx context.Context, p DelegateTaskParams) (ToolResponse, error) {
if p.Task == "" {
return TextErrorResponse("task is required"), nil
}
timeout := time.Duration(p.Timeout) * time.Second
if timeout == 0 {
timeout = 120 * time.Second
}
if timeout > 300*time.Second {
timeout = 300 * time.Second
}
result := executeSubTask(ctx, p.Task, p.Context, timeout, registry)
resp := DelegateResponse{
TotalTasks: 1,
Successful: 0,
Results: []SubTaskResult{result},
Duration: "N/A",
}
if result.Success {
resp.Successful = 1
} else {
resp.Failed = 1
}
data, _ := json.MarshalIndent(resp, "", " ")
return TextResponse(string(data)), nil
})
}
func NewDelegateMultiTool(registry *Registry) (*ToolDefinition, error) {
return NewTool("delegate_multi",
"Execute multiple independent tasks in parallel using goroutines. Each task runs in its own isolated context. Returns aggregated results. Use for batch operations, parallel analysis, or concurrent file processing.",
func(ctx context.Context, p DelegateMultiParams) (ToolResponse, error) {
if len(p.Tasks) == 0 {
return TextErrorResponse("tasks list is required"), nil
}
maxParallel := p.MaxParallel
if maxParallel <= 0 {
maxParallel = 3
}
if maxParallel > 5 {
maxParallel = 5
}
if len(p.Tasks) > 10 {
return TextErrorResponse("maximum 10 tasks per delegation"), nil
}
start := time.Now()
results := executeParallelTasks(ctx, p.Tasks, maxParallel, registry)
duration := time.Since(start)
resp := DelegateResponse{
TotalTasks: len(results),
Results: results,
Duration: duration.Round(time.Millisecond).String(),
}
for _, r := range results {
if r.Success {
resp.Successful++
} else {
resp.Failed++
}
}
data, _ := json.MarshalIndent(resp, "", " ")
return TextResponse(string(data)), nil
})
}
func executeSubTask(ctx context.Context, task, contextInfo string, timeout time.Duration, registry *Registry) SubTaskResult {
taskCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
result := SubTaskResult{
Task: truncateString(task, 100),
}
if contextInfo != "" {
result.Task = fmt.Sprintf("%s (context: %s)", result.Task, truncateString(contextInfo, 50))
}
done := make(chan struct{})
go func() {
defer close(done)
terminalTool, ok := registry.Get("terminal")
if !ok {
result.Error = "terminal tool not available"
return
}
args, _ := json.Marshal(TerminalParams{
Command: task,
Timeout: int(timeout.Seconds()),
})
resp, err := terminalTool.Execute(taskCtx, ToolCall{
ID: fmt.Sprintf("delegate_%d", time.Now().UnixNano()),
Name: "terminal",
Arguments: args,
})
if err != nil {
result.Error = err.Error()
return
}
result.Result = resp.Content
result.Success = !resp.IsError
if resp.IsError {
result.Error = resp.Content
}
}()
select {
case <-done:
return result
case <-taskCtx.Done():
result.Error = fmt.Sprintf("sub-task timed out after %v", timeout)
return result
}
}
func executeParallelTasks(ctx context.Context, tasks []DelegateTaskParams, maxParallel int, registry *Registry) []SubTaskResult {
results := make([]SubTaskResult, len(tasks))
sem := make(chan struct{}, maxParallel)
var wg sync.WaitGroup
for i, task := range tasks {
wg.Add(1)
go func(idx int, t DelegateTaskParams) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
timeout := time.Duration(t.Timeout) * time.Second
if timeout == 0 {
timeout = 120 * time.Second
}
if timeout > 300*time.Second {
timeout = 300 * time.Second
}
results[idx] = executeSubTask(ctx, t.Task, t.Context, timeout, registry)
}(i, task)
}
wg.Wait()
return results
}
func truncateString(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
return s[:maxLen] + "..."
}

200
internal/agent/image.go Normal file
View File

@@ -0,0 +1,200 @@
package agent
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/muyue/muyue/internal/config"
)
type ImageGenerationTool struct {
apiKey string
baseURL string
model string
saveDir string
}
func NewImageGenerationTool(cfg *config.MuyueConfig) (*ImageGenerationTool, error) {
configDir, err := config.ConfigDir()
if err != nil {
return nil, err
}
saveDir := filepath.Join(configDir, "images")
if err := os.MkdirAll(saveDir, 0755); err != nil {
return nil, fmt.Errorf("creating images dir: %w", err)
}
var apiKey, baseURL, model string
for _, p := range cfg.AI.Providers {
if p.Active {
apiKey = p.APIKey
baseURL = p.BaseURL
model = p.Model
break
}
}
if baseURL == "" {
baseURL = "https://api.openai.com/v1"
}
return &ImageGenerationTool{
apiKey: apiKey,
baseURL: strings.TrimRight(baseURL, "/"),
model: model,
saveDir: saveDir,
}, nil
}
func (t *ImageGenerationTool) Name() string {
return "generate_image"
}
func (t *ImageGenerationTool) Description() string {
return "Generate an image from a text prompt using DALL-E or compatible API. Returns a local URL to the generated image."
}
func (t *ImageGenerationTool) Parameters() map[string]interface{} {
return map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"prompt": map[string]interface{}{
"type": "string",
"description": "Description of the image to generate",
},
"size": map[string]interface{}{
"type": "string",
"description": "Image size: 1024x1024, 1024x1792, or 1792x1024",
"default": "1024x1024",
},
"style": map[string]interface{}{
"type": "string",
"description": "Style: vivid or natural",
"default": "vivid",
},
},
"required": []string{"prompt"},
}
}
func (t *ImageGenerationTool) Execute(args map[string]interface{}) (string, error) {
prompt, _ := args["prompt"].(string)
if prompt == "" {
return "", fmt.Errorf("prompt is required")
}
size, _ := args["size"].(string)
if size == "" {
size = "1024x1024"
}
style, _ := args["style"].(string)
if style == "" {
style = "vivid"
}
reqBody := map[string]interface{}{
"model": "dall-e-3",
"prompt": prompt,
"size": size,
"style": style,
"n": 1,
}
bodyBytes, err := json.Marshal(reqBody)
if err != nil {
return "", fmt.Errorf("marshal request: %w", err)
}
url := t.baseURL + "/images/generations"
req, err := http.NewRequest("POST", url, bytes.NewReader(bodyBytes))
if err != nil {
return "", fmt.Errorf("create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
if t.apiKey != "" {
req.Header.Set("Authorization", "Bearer "+t.apiKey)
}
client := &http.Client{Timeout: 120 * time.Second}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("send request: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("API error (%d): %s", resp.StatusCode, string(respBody))
}
var genResp struct {
Data []struct {
URL string `json:"url"`
B64JSON string `json:"b64_json"`
RevisedPrompt string `json:"revised_prompt"`
} `json:"data"`
}
if err := json.Unmarshal(respBody, &genResp); err != nil {
return "", fmt.Errorf("parse response: %w", err)
}
if len(genResp.Data) == 0 {
return "", fmt.Errorf("no image returned")
}
imgData := genResp.Data[0]
filename := fmt.Sprintf("img-%d.png", time.Now().UnixNano())
localPath := filepath.Join(t.saveDir, filename)
if imgData.B64JSON != "" {
return "", fmt.Errorf("base64 response not yet supported")
}
if imgData.URL != "" {
if err := t.downloadImage(imgData.URL, localPath); err != nil {
return "", fmt.Errorf("download image: %w", err)
}
}
result := map[string]interface{}{
"url": "/api/images/" + filename,
"revised_prompt": imgData.RevisedPrompt,
"size": size,
}
resultJSON, _ := json.Marshal(result)
return string(resultJSON), nil
}
func (t *ImageGenerationTool) downloadImage(url, localPath string) error {
resp, err := http.Get(url)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("download failed: %d", resp.StatusCode)
}
f, err := os.Create(localPath)
if err != nil {
return err
}
defer f.Close()
_, err = io.Copy(f, resp.Body)
return err
}

View File

@@ -26,6 +26,43 @@ func detectShell() string {
return "/bin/sh"
}
var validIdentifier = regexp.MustCompile(`^[a-zA-Z0-9._-]+$`)
// buildAgentCommand assembles an agent execution command, optionally launching it
// inside a WSL distribution (Windows host only) and applying a working directory.
// On non-Windows hosts, wsl_* parameters are ignored.
func buildAgentCommand(ctx context.Context, bin string, args []string, cwd, wslDistro, wslUser string) (*exec.Cmd, error) {
if wslDistro != "" && runtime.GOOS == "windows" {
if !validIdentifier.MatchString(wslDistro) {
return nil, fmt.Errorf("invalid wsl_distro: %q", wslDistro)
}
if wslUser != "" && !validIdentifier.MatchString(wslUser) {
return nil, fmt.Errorf("invalid wsl_user: %q", wslUser)
}
wslArgs := []string{"-d", wslDistro}
if wslUser != "" {
wslArgs = append(wslArgs, "-u", wslUser)
}
if cwd != "" {
wslArgs = append(wslArgs, "--cd", cwd)
}
wslArgs = append(wslArgs, "--")
wslArgs = append(wslArgs, bin)
wslArgs = append(wslArgs, args...)
return exec.CommandContext(ctx, "wsl", wslArgs...), nil
}
cmd := exec.CommandContext(ctx, bin, args...)
if cwd != "" {
dir := expandHome(cwd)
if info, err := os.Stat(dir); err != nil || !info.IsDir() {
return nil, fmt.Errorf("cwd does not exist or is not a directory: %s", cwd)
}
cmd.Dir = dir
}
return cmd, nil
}
func expandHome(path string) string {
if path == "" {
return ""

View File

@@ -1,6 +1,33 @@
Tu es l'assistant IA de **Muyue Studio**, le centre de commandement de l'environnement de développement de l'utilisateur.
Tu es l'assistant IA de **Muyue Studio**, le centre de commandement de l'environnement de développement de l'utilisateur, et tu es spécialisé dans la **construction de prompts** selon la **méthode BMAD** (Breakthrough Method for Agile AI-Driven Development — https://github.com/bmad-code-org/BMAD-METHOD).
Tu es intégré dans Muyue, un gestionnaire d'environnement de développement de bureau. Ton rôle est d'aider l'utilisateur à configurer, gérer et optimiser son environnement dev.
Tu es intégré dans Muyue, un gestionnaire d'environnement de développement de bureau. Ton rôle est double :
1. Aider l'utilisateur à configurer, gérer et optimiser son environnement dev (avec les outils ci-dessous).
2. Construire pour lui des prompts structurés et actionnables avant d'exécuter une tâche complexe ou de la déléguer à un agent (`crush_run`, `claude_run`).
## Méthode BMAD — principes appliqués à chaque réponse
BMAD organise le travail IA comme une équipe agile : chaque demande est traitée avec une persona spécifique (Analyst, PM, Architect, SM, Dev, QA) puis exécutée. Tu n'as pas besoin de jouer toutes les personas — applique simplement leurs réflexes :
- **Analyst** : reformule l'objectif réel derrière la demande en 1 phrase. S'il est ambigu, choisis l'interprétation la plus probable et indique-la au début.
- **PM** : découpe en livrables concrets (épopée → stories). Pas plus de 3-5 stories pour une demande, chaque story doit être indépendamment livrable.
- **Architect** : pour toute story qui touche au code, identifie les fichiers concernés, les contraintes (compat, style, perf, sécurité) et les risques avant d'écrire.
- **SM (Scrum Master)** : si tu délègues à `crush_run`/`claude_run`, fournis un prompt **autonome** : objectif, contraintes, fichiers cibles, critère d'acceptation. Pas de référence à la conversation parente — l'agent ne la voit pas.
- **Dev** : exécute story par story. Vérifie chaque livraison avant de passer à la suivante.
- **QA** : avant de répondre "fini", relis l'objectif initial et confirme qu'il est atteint.
## Format d'un prompt BMAD délégué
Quand tu construis un prompt pour `crush_run`/`claude_run`, suis ce gabarit :
```
[OBJECTIF] <une phrase, l'objectif final>
[CONTEXTE] <fichiers/dossiers concernés, ce qui existe déjà>
[CONTRAINTES] <ne pas faire X, préserver Y, respecter style Z>
[LIVRABLE] <fichier(s) modifié(s), comportement attendu>
[CRITÈRE D'ACCEPTATION] <comment savoir que c'est fini>
```
Ce gabarit est **obligatoire** pour toute délégation à un agent. Il évite que l'agent erre, suppose, ou produise du code hors-périmètre.
<critical_rules>
1. **AGIS, ne décris pas** — Si l'utilisateur demande de faire quelque chose, utilise les outils immédiatement. Ne dis pas "je pourrais faire X" — fais-le.
@@ -27,6 +54,7 @@ Muyue gère :
|-------|-------|
| **terminal** | Exécuter des commandes shell (builds, tests, git, etc.) |
| **crush_run** | Déléguer une tâche complexe à Crush (édition de fichiers, refactoring, debug) — préfère cet outil pour les tâches multi-fichiers ou l'écriture de code |
| **claude_run** | Déléguer une tâche complexe à Claude Code CLI |
| **read_file** | Lire le contenu d'un fichier |
| **list_files** | Lister les fichiers d'un répertoire |
| **search_files** | Chercher des fichiers par motif (glob) |
@@ -35,6 +63,56 @@ Muyue gère :
| **set_provider** | Configurer un fournisseur IA |
| **manage_ssh** | Gérer les connexions SSH |
| **web_fetch** | Récupérer le contenu d'une URL |
| **browser_test** | Piloter un onglet de navigateur de l'utilisateur (clic, eval, lecture console) — voir `<browser_test_strategy>` ci-dessous |
<browser_test_strategy>
Quand l'utilisateur demande de **tester** une UI (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).
## Règle d'or — économise les appels d'outils
**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 :
- 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>
- **Recherche avant action** — Utilise `search_files`, `grep_content`, `read_file` avant de supposer quoi que ce soit sur l'état du système

View File

@@ -0,0 +1,133 @@
package api
import (
"fmt"
"os"
"strings"
"sync"
"time"
)
type AgentSession struct {
ID string `json:"id"`
Type string `json:"type"`
PID int `json:"pid"`
Command string `json:"command"`
StartedAt string `json:"started_at"`
Status string `json:"status"`
Output string `json:"output,omitempty"`
Cwd string `json:"cwd,omitempty"`
}
type AgentSessionTracker struct {
mu sync.RWMutex
sessions map[string]*AgentSession
}
func NewAgentSessionTracker() *AgentSessionTracker {
return &AgentSessionTracker{
sessions: make(map[string]*AgentSession),
}
}
func (t *AgentSessionTracker) Discover() []AgentSession {
t.mu.Lock()
defer t.mu.Unlock()
activePIDs := make(map[int]bool)
for _, s := range t.sessions {
activePIDs[s.PID] = true
}
for _, name := range []string{"crush", "claude"} {
pids := findProcessesByName(name)
for _, pid := range pids {
if !activePIDs[pid] {
session := &AgentSession{
ID: fmt.Sprintf("%s-%d-%d", name, pid, time.Now().UnixMilli()),
Type: name,
PID: pid,
Command: getProcessCommand(pid),
StartedAt: time.Now().Format(time.RFC3339),
Status: "running",
}
t.sessions[session.ID] = session
}
}
}
var result []AgentSession
for _, s := range t.sessions {
if s.Status == "running" {
if !isProcessAlive(s.PID) {
s.Status = "completed"
}
}
result = append(result, *s)
}
return result
}
func (t *AgentSessionTracker) Get(id string) *AgentSession {
t.mu.RLock()
defer t.mu.RUnlock()
s, ok := t.sessions[id]
if !ok {
return nil
}
snapshot := *s
return &snapshot
}
func findProcessesByName(name string) []int {
data, err := os.ReadFile("/proc/" + name + "/stat")
_ = data
_ = err
var pids []int
entries, err := os.ReadDir("/proc")
if err != nil {
return pids
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
var pid int
if _, err := fmt.Sscanf(entry.Name(), "%d", &pid); err != nil {
continue
}
if pid <= 0 || pid == os.Getpid() {
continue
}
cmdline, err := os.ReadFile(fmt.Sprintf("/proc/%d/cmdline", pid))
if err != nil {
continue
}
cmdStr := string(cmdline)
if strings.Contains(cmdStr, name) {
pids = append(pids, pid)
}
}
return pids
}
func getProcessCommand(pid int) string {
out, err := os.ReadFile(fmt.Sprintf("/proc/%d/cmdline", pid))
if err != nil {
return ""
}
return strings.ReplaceAll(string(out), "\x00", " ")
}
func isProcessAlive(pid int) bool {
_, err := os.Stat(fmt.Sprintf("/proc/%d", pid))
return err == nil
}

772
internal/api/browsertest.go Normal file
View File

@@ -0,0 +1,772 @@
package api
// Browser-test feature: an out-of-process page (the user's target tab)
// connects to Muyue via WebSocket using a short-lived token, and exposes a
// thin RPC: Studio's AI can list clickable elements, click them, evaluate JS,
// read the recent console buffer, and observe what changes after each action.
//
// Threat model: an injected snippet runs in the user's chosen page only, with
// the same origin as that page; the WS endpoint is bound to localhost and
// gated by a 5-minute token issued by the local Muyue server.
import (
"context"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"os"
"strings"
"sync"
"time"
"github.com/gorilla/websocket"
"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 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
)
// BrowserTestSession represents one connected browser tab.
type BrowserTestSession struct {
ID string
URL string
Title string
conn *websocket.Conn
mu sync.Mutex
console []ConsoleEntry
pending map[string]chan json.RawMessage
pendingMu sync.Mutex
connectedAt time.Time
writeMu sync.Mutex
}
// ConsoleEntry is a captured console message from the connected page.
type ConsoleEntry struct {
Level string `json:"level"` // log, info, warn, error, debug
Message string `json:"message"`
Time string `json:"time"`
}
// BrowserTestStore manages active sessions + pending one-shot connect tokens.
type BrowserTestStore struct {
mu sync.RWMutex
sessions map[string]*BrowserTestSession
tokens map[string]time.Time
tokensMu sync.Mutex
}
func NewBrowserTestStore() *BrowserTestStore {
return &BrowserTestStore{
sessions: map[string]*BrowserTestSession{},
tokens: map[string]time.Time{},
}
}
// IssueToken creates a single-use token used by the snippet to authenticate.
func (s *BrowserTestStore) IssueToken() string {
buf := make([]byte, 16)
if _, err := rand.Read(buf); err != nil {
return fmt.Sprintf("fallback-%d", time.Now().UnixNano())
}
tok := hex.EncodeToString(buf)
s.tokensMu.Lock()
now := time.Now()
for k, v := range s.tokens {
if now.Sub(v) > browserTestTokenTTL {
delete(s.tokens, k)
}
}
s.tokens[tok] = now
s.tokensMu.Unlock()
return tok
}
// ConsumeToken validates 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()
t, ok := s.tokens[tok]
if !ok {
return false
}
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.
func (s *BrowserTestStore) Register(session *BrowserTestSession) {
s.mu.Lock()
defer s.mu.Unlock()
if len(s.sessions) >= browserTestSessionsMax {
var oldestID string
var oldest time.Time
for id, sess := range s.sessions {
if oldestID == "" || sess.connectedAt.Before(oldest) {
oldestID = id
oldest = sess.connectedAt
}
}
if old, ok := s.sessions[oldestID]; ok {
old.conn.Close()
delete(s.sessions, oldestID)
}
}
s.sessions[session.ID] = session
}
func (s *BrowserTestStore) Remove(id string) {
s.mu.Lock()
defer s.mu.Unlock()
if sess, ok := s.sessions[id]; ok {
sess.conn.Close()
delete(s.sessions, id)
}
}
func (s *BrowserTestStore) Get(id string) *BrowserTestSession {
s.mu.RLock()
defer s.mu.RUnlock()
return s.sessions[id]
}
// Pick returns the requested session by ID, or the most-recently-connected
// session if id is empty. Returns nil if no session matches.
func (s *BrowserTestStore) Pick(id string) *BrowserTestSession {
s.mu.RLock()
defer s.mu.RUnlock()
if id != "" {
return s.sessions[id]
}
var picked *BrowserTestSession
for _, sess := range s.sessions {
if picked == nil || sess.connectedAt.After(picked.connectedAt) {
picked = sess
}
}
return picked
}
func (s *BrowserTestStore) List() []map[string]interface{} {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]map[string]interface{}, 0, len(s.sessions))
for _, sess := range s.sessions {
out = append(out, map[string]interface{}{
"id": sess.ID,
"url": sess.URL,
"title": sess.Title,
"connected_at": sess.connectedAt.Format(time.RFC3339),
})
}
return out
}
// Send issues an RPC command to the browser session and waits up to TTL for
// the matching reply. Returns the raw payload or an error.
func (sess *BrowserTestSession) Send(action string, params map[string]interface{}) (json.RawMessage, error) {
cid := newCorrelationID()
ch := make(chan json.RawMessage, 1)
sess.pendingMu.Lock()
sess.pending[cid] = ch
sess.pendingMu.Unlock()
defer func() {
sess.pendingMu.Lock()
delete(sess.pending, cid)
sess.pendingMu.Unlock()
}()
cmd := map[string]interface{}{
"id": cid,
"action": action,
"params": params,
}
sess.writeMu.Lock()
err := sess.conn.WriteJSON(cmd)
sess.writeMu.Unlock()
if err != nil {
return nil, fmt.Errorf("write: %w", err)
}
select {
case payload := <-ch:
return payload, nil
case <-time.After(browserTestCommandTTL):
return nil, fmt.Errorf("browser session did not reply within %s", browserTestCommandTTL)
}
}
// AppendConsole records a console line, trimming to the buffer cap.
func (sess *BrowserTestSession) AppendConsole(level, message string) {
sess.mu.Lock()
defer sess.mu.Unlock()
sess.console = append(sess.console, ConsoleEntry{
Level: level,
Message: message,
Time: time.Now().Format(time.RFC3339),
})
if len(sess.console) > browserTestConsoleMax {
sess.console = sess.console[len(sess.console)-browserTestConsoleMax:]
}
}
// SnapshotConsole returns a copy of the current console buffer.
func (sess *BrowserTestSession) SnapshotConsole() []ConsoleEntry {
sess.mu.Lock()
defer sess.mu.Unlock()
out := make([]ConsoleEntry, len(sess.console))
copy(out, sess.console)
return out
}
func newCorrelationID() string {
buf := make([]byte, 8)
rand.Read(buf)
return hex.EncodeToString(buf)
}
// HTTP handlers --------------------------------------------------------------
func (s *Server) handleBrowserTestSnippet(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
writeError(w, "GET only", http.StatusMethodNotAllowed)
return
}
tok := s.browserTestStore.IssueToken()
host := r.Host
if host == "" {
host = "127.0.0.1"
}
scheme := "ws"
if r.TLS != nil {
scheme = "wss"
}
wsURL := fmt.Sprintf("%s://%s/api/ws/browser-test?token=%s", scheme, host, tok)
snippet := buildBrowserTestSnippet(wsURL)
writeJSON(w, map[string]interface{}{
"token": tok,
"ws_url": wsURL,
"snippet": snippet,
"expires_in": int(browserTestTokenTTL / time.Second),
})
}
func (s *Server) handleBrowserTestSessions(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
writeError(w, "GET only", http.StatusMethodNotAllowed)
return
}
writeJSON(w, map[string]interface{}{
"sessions": s.browserTestStore.List(),
})
}
func (s *Server) handleBrowserTestConsole(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
writeError(w, "GET only", http.StatusMethodNotAllowed)
return
}
id := strings.TrimPrefix(r.URL.Path, "/api/test/console/")
sess := s.browserTestStore.Pick(id)
if sess == nil {
writeError(w, "no active browser test session", http.StatusNotFound)
return
}
writeJSON(w, map[string]interface{}{
"session_id": sess.ID,
"url": sess.URL,
"console": sess.SnapshotConsole(),
})
}
// browserTestUpgrader accepts any origin: the connection is gated by a
// short-lived token issued to the local UI, not by Origin checking.
var browserTestUpgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true },
}
func (s *Server) handleBrowserTestWS(w http.ResponseWriter, r *http.Request) {
tok := r.URL.Query().Get("token")
if tok == "" || !s.browserTestStore.ConsumeToken(tok) {
writeError(w, "invalid or expired token", http.StatusUnauthorized)
return
}
conn, err := browserTestUpgrader.Upgrade(w, r, nil)
if err != nil {
return
}
conn.SetReadLimit(2 << 20)
// Read the hello message: page sends {"type":"hello","url":"...","title":"..."}.
conn.SetReadDeadline(time.Now().Add(10 * time.Second))
var hello struct {
Type string `json:"type"`
URL string `json:"url"`
Title string `json:"title"`
}
if err := conn.ReadJSON(&hello); err != nil || hello.Type != "hello" {
conn.WriteJSON(map[string]string{"type": "error", "message": "expected hello"})
conn.Close()
return
}
conn.SetReadDeadline(time.Time{})
id := newCorrelationID()
sess := &BrowserTestSession{
ID: id,
URL: hello.URL,
Title: hello.Title,
conn: conn,
pending: map[string]chan json.RawMessage{},
connectedAt: time.Now(),
}
s.browserTestStore.Register(sess)
defer s.browserTestStore.Remove(id)
// Acknowledge with the assigned session ID.
sess.writeMu.Lock()
conn.WriteJSON(map[string]string{"type": "registered", "session_id": id})
sess.writeMu.Unlock()
for {
_, raw, err := conn.ReadMessage()
if err != nil {
return
}
var msg struct {
Type string `json:"type"`
ID string `json:"id,omitempty"`
Level string `json:"level,omitempty"`
Text string `json:"text,omitempty"`
URL string `json:"url,omitempty"`
Data json.RawMessage `json:"data,omitempty"`
}
if err := json.Unmarshal(raw, &msg); err != nil {
continue
}
switch msg.Type {
case "console":
sess.AppendConsole(msg.Level, msg.Text)
case "url_change":
sess.mu.Lock()
sess.URL = msg.URL
sess.mu.Unlock()
case "reply":
sess.pendingMu.Lock()
ch, ok := sess.pending[msg.ID]
sess.pendingMu.Unlock()
if ok {
select {
case ch <- msg.Data:
default:
}
}
case "ping":
sess.writeMu.Lock()
conn.WriteJSON(map[string]string{"type": "pong"})
sess.writeMu.Unlock()
}
}
}
// Agent tool -----------------------------------------------------------------
// BrowserTestParams is the schema exposed to the AI for the browser_test tool.
type BrowserTestParams struct {
Action string `json:"action" description:"One of: list_clickables, click, eval, console, current_url, wait, type, summary, 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/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.
func RegisterBrowserTestTool(reg *agent.Registry, store *BrowserTestStore) error {
tool, err := agent.NewTool("browser_test",
"Drive the user's connected browser tab for end-to-end testing. Available actions: list_clickables (returns indexed clickable elements), click (by selector or index), eval (run a JS expression and return result), console (read recent console output, ideal to spot errors after a click), current_url, wait (sleep ms before next check), type (set value on an input), summary (URL+title+last console entries). Always start with list_clickables; click; then console to verify no errors.",
func(ctx context.Context, p BrowserTestParams) (agent.ToolResponse, error) {
sess := store.Pick(p.SessionID)
if sess == nil {
return agent.TextErrorResponse("no active browser session — ask the user to paste the snippet from the Tests tab in their target page"), nil
}
action := strings.ToLower(strings.TrimSpace(p.Action))
switch action {
case "":
return agent.TextErrorResponse("action is required"), nil
case "list_clickables", "click", "eval", "current_url", "type", "screenshot":
case "console", "summary", "wait":
default:
return agent.TextErrorResponse("unknown action: " + p.Action), nil
}
if action == "console" {
tail := p.Tail
if tail <= 0 {
tail = 50
}
if tail > browserTestConsoleMax {
tail = browserTestConsoleMax
}
entries := sess.SnapshotConsole()
if len(entries) > tail {
entries = entries[len(entries)-tail:]
}
out, _ := json.MarshalIndent(map[string]interface{}{
"session_id": sess.ID,
"console": entries,
}, "", " ")
return agent.TextResponse(string(out)), nil
}
if action == "summary" {
entries := sess.SnapshotConsole()
if len(entries) > 20 {
entries = entries[len(entries)-20:]
}
out, _ := json.MarshalIndent(map[string]interface{}{
"session_id": sess.ID,
"url": sess.URL,
"title": sess.Title,
"recent_console": entries,
}, "", " ")
return agent.TextResponse(string(out)), nil
}
if action == "wait" {
ms := p.WaitMs
if ms <= 0 {
ms = 200
}
if ms > 5000 {
ms = 5000
}
select {
case <-ctx.Done():
return agent.TextErrorResponse("cancelled"), nil
case <-time.After(time.Duration(ms) * time.Millisecond):
}
return agent.TextResponse(fmt.Sprintf("waited %dms", ms)), nil
}
// Capture console snapshot length before so we can return only the delta
// after the action — useful so the AI can spot errors caused by the click.
pre := len(sess.SnapshotConsole())
params := map[string]interface{}{}
if p.Selector != "" {
params["selector"] = p.Selector
}
if p.Index > 0 || (action == "click" && p.Selector == "") {
params["index"] = p.Index
}
if p.Expr != "" {
params["expr"] = p.Expr
}
if p.Text != "" {
params["text"] = p.Text
}
payload, err := sess.Send(action, params)
if err != nil {
return agent.TextErrorResponse(err.Error()), nil
}
// 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
if len(post) > pre {
delta = post[pre:]
}
result := map[string]interface{}{
"action": action,
"reply": json.RawMessage(payload),
"console_delta": delta,
"current_url": sess.URL,
}
out, _ := json.MarshalIndent(result, "", " ")
return agent.TextResponse(string(out)), nil
})
if err != nil {
return err
}
return reg.Register(tool)
}
// Snippet generator ----------------------------------------------------------
func buildBrowserTestSnippet(wsURL string) string {
// 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 = 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();
if (t.length > 80) t = t.slice(0,80)+'…';
return t;
}
function describe(el){
var sel = el.id ? '#'+el.id : el.tagName.toLowerCase();
if (!el.id && el.className && typeof el.className === 'string') {
sel += '.' + el.className.trim().split(/\s+/).slice(0,2).join('.');
}
var label = el.getAttribute('aria-label') || el.getAttribute('title') || el.getAttribute('name') || '';
return { tag: el.tagName.toLowerCase(), selector: sel, text: safeText(el), label: label, type: el.getAttribute('type')||'', disabled: !!el.disabled };
}
function list(){
var els = Array.from(document.querySelectorAll('button, a[href], input[type=submit], input[type=button], [role=button], [onclick]'));
lastList = els.filter(function(e){ var r=e.getBoundingClientRect(); return r.width>0 && r.height>0; });
return lastList.map(describe).map(function(d,i){ d.index = i; return d; });
}
function clickEl(el){
if (!el) return { ok:false, error:'element not found' };
if (el.disabled) return { ok:false, error:'element is disabled' };
try { el.scrollIntoView({block:'center'}); el.click(); return { ok:true }; }
catch(e){ return { ok:false, error:String(e) }; }
}
// 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){
case 'list_clickables': return list();
case 'click': {
var el;
if (p.selector) el = document.querySelector(p.selector);
else if (typeof p.index === 'number') el = lastList[p.index];
return clickEl(el);
}
case 'eval': {
try { var r = (0,eval)(p.expr); return { ok:true, value: serialize(r) }; }
catch(e){ return { ok:false, error:String(e) }; }
}
case 'current_url': return { url: location.href, title: document.title };
case 'type': {
var el = p.selector ? document.querySelector(p.selector) : (lastList[p.index]);
if (!el) return { ok:false, error:'element not found' };
var proto = Object.getPrototypeOf(el);
var setter = Object.getOwnPropertyDescriptor(proto, 'value');
try { setter && setter.set ? setter.set.call(el, p.text||'') : (el.value = p.text||''); }
catch(e){ el.value = p.text||''; }
el.dispatchEvent(new Event('input', {bubbles:true}));
el.dispatchEvent(new Event('change', {bubbles:true}));
return { ok:true };
}
case 'screenshot':
return screenshot(p);
}
return { ok:false, error:'unknown action' };
}
function serialize(v){
if (v === undefined) return 'undefined';
try { return JSON.parse(JSON.stringify(v)); }
catch(e){ return String(v); }
}
['log','info','warn','error','debug'].forEach(function(lvl){
var orig = console[lvl];
console[lvl] = function(){
try {
var parts = Array.from(arguments).map(function(a){
if (typeof a === 'string') return a;
try { return JSON.stringify(a); } catch(e){ return String(a); }
});
send({type:'console', level: lvl, text: parts.join(' ')});
} catch(e){}
return orig.apply(console, arguments);
};
});
window.addEventListener('error', function(e){
send({type:'console', level:'error', text:'window.onerror: '+(e.message||e.error||'unknown')});
});
window.addEventListener('unhandledrejection', function(e){
send({type:'console', level:'error', text:'unhandledrejection: '+String(e.reason)});
});
var lastUrl = location.href;
setInterval(function(){
if (location.href !== lastUrl){ lastUrl = location.href; send({type:'url_change', url: lastUrl}); }
}, 500);
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 };
})();`
}
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

@@ -3,15 +3,23 @@ package api
import (
"context"
"encoding/json"
"fmt"
"net/http"
"github.com/muyue/muyue/internal/agent"
"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)
// ChatEngine handles chat interactions with tool execution.
// This deduplicates chat logic previously repeated in handlers_chat.go and handlers_shell_chat.go.
@@ -21,6 +29,7 @@ type ChatEngine struct {
tools json.RawMessage
onChunk func(map[string]interface{})
stream bool
limiter ToolLimiter
TotalTokens int
}
@@ -44,6 +53,11 @@ func (ce *ChatEngine) OnChunk(fn func(map[string]interface{})) {
ce.onChunk = fn
}
// SetLimiter sets the tool call limiter for agent concurrency control.
func (ce *ChatEngine) SetLimiter(l ToolLimiter) {
ce.limiter = l
}
// RunWithTools executes the chat loop with tool calls.
// Returns final content, tool calls, tool results, and error.
func (ce *ChatEngine) RunWithTools(ctx context.Context, messages []orchestrator.Message) (string, []map[string]interface{}, []map[string]interface{}, error) {
@@ -76,8 +90,11 @@ func (ce *ChatEngine) RunWithTools(ctx context.Context, messages []orchestrator.
ce.TotalTokens += resp.Usage.TotalTokens
}
if len(resp.Choices) == 0 {
return finalContent, allToolCalls, allToolResults, fmt.Errorf("empty response from provider")
}
choice := resp.Choices[0]
content := cleanThinkingTags(choice.Message.Content)
content := orchestrator.CleanAIResponse(cleanThinkingTags(choice.Message.Content))
if content != "" {
if ce.onChunk != nil {
@@ -115,7 +132,40 @@ func (ce *ChatEngine) RunWithTools(ctx context.Context, messages []orchestrator.
Arguments: json.RawMessage(tc.Function.Arguments),
}
var release func()
if ce.limiter != nil {
rel, limitErr := ce.limiter(tc.Function.Name)
if limitErr != nil {
limResultData := map[string]interface{}{
"tool_call_id": tc.ID,
"content": limitErr.Error(),
"is_error": true,
}
allToolResults = append(allToolResults, map[string]interface{}{
"tool_call_id": tc.ID,
"name": tc.Function.Name,
"args": tc.Function.Arguments,
"result": limitErr.Error(),
"is_error": true,
})
if ce.onChunk != nil {
ce.onChunk(map[string]interface{}{"tool_result": limResultData})
}
messages = append(messages, orchestrator.Message{
Role: "tool",
Content: orchestrator.TextContent(limitErr.Error()),
ToolCallID: tc.ID,
Name: tc.Function.Name,
})
continue
}
release = rel
}
result, execErr := ce.registry.Execute(ctx, call)
if release != nil {
release()
}
if execErr != nil {
result = agent.ToolResponse{
Content: execErr.Error(),
@@ -178,8 +228,11 @@ func (ce *ChatEngine) RunNonStream(ctx context.Context, messages []orchestrator.
ce.TotalTokens += resp.Usage.TotalTokens
}
if len(resp.Choices) == 0 {
return finalContent, fmt.Errorf("empty response from provider")
}
choice := resp.Choices[0]
content := cleanThinkingTags(choice.Message.Content)
content := orchestrator.CleanAIResponse(cleanThinkingTags(choice.Message.Content))
if content != "" {
finalContent = content
@@ -203,7 +256,25 @@ func (ce *ChatEngine) RunNonStream(ctx context.Context, messages []orchestrator.
Arguments: json.RawMessage(tc.Function.Arguments),
}
var release func()
if ce.limiter != nil {
rel, limitErr := ce.limiter(tc.Function.Name)
if limitErr != nil {
messages = append(messages, orchestrator.Message{
Role: "tool",
Content: orchestrator.TextContent(limitErr.Error()),
ToolCallID: tc.ID,
Name: tc.Function.Name,
})
continue
}
release = rel
}
result, execErr := ce.registry.Execute(ctx, call)
if release != nil {
release()
}
if execErr != nil {
result = agent.ToolResponse{
Content: execErr.Error(),
@@ -258,6 +329,5 @@ func SetupSSEHeaders(w http.ResponseWriter) {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.WriteHeader(http.StatusOK)
}

View File

@@ -17,27 +17,66 @@ const contextWindowTokens = 150000
const summarizeRatio = 0.80
const charsPerToken = 4
func extractDisplayContent(role, content string) string {
if role != "assistant" {
return content
}
var parsed struct {
Content string `json:"content"`
ToolCalls []struct {
Name string `json:"name"`
Args string `json:"args"`
} `json:"tool_calls"`
ToolResults []struct {
Name string `json:"name"`
Result string `json:"result"`
} `json:"tool_results"`
}
if err := json.Unmarshal([]byte(content), &parsed); err != nil {
return content
}
var sb strings.Builder
if parsed.Content != "" {
sb.WriteString(parsed.Content)
}
for _, tc := range parsed.ToolCalls {
sb.WriteString("\n[")
sb.WriteString(tc.Name)
sb.WriteString("] ")
sb.WriteString(tc.Args)
}
for _, tr := range parsed.ToolResults {
sb.WriteString("\n[result")
if tr.Name != "" {
sb.WriteString(":")
sb.WriteString(tr.Name)
}
sb.WriteString("] ")
sb.WriteString(tr.Result)
}
return sb.String()
}
type FeedMessage struct {
ID string `json:"id"`
Role string `json:"role"`
Content string `json:"content"`
Time string `json:"time"`
Images []string `json:"images,omitempty"`
ID string `json:"id"`
Role string `json:"role"`
Content string `json:"content"`
Time string `json:"time"`
Images []string `json:"images,omitempty"`
Summarized bool `json:"summarized,omitempty"`
}
type Conversation struct {
Messages []FeedMessage `json:"messages"`
Summary string `json:"summary,omitempty"`
RealTokens int `json:"real_tokens,omitempty"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
Messages []FeedMessage `json:"messages"`
Summary string `json:"summary,omitempty"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
type ConversationStore struct {
mu sync.RWMutex
path string
conv *Conversation
realTokens int
mu sync.RWMutex
path string
conv *Conversation
}
type TokenCount struct {
@@ -87,7 +126,6 @@ func (cs *ConversationStore) load() {
conv.Messages = []FeedMessage{}
}
cs.conv = &conv
cs.realTokens = conv.RealTokens
}
func (cs *ConversationStore) save() error {
@@ -157,10 +195,8 @@ func (cs *ConversationStore) Clear() {
cs.conv.Messages = []FeedMessage{}
cs.conv.Summary = ""
cs.conv.RealTokens = 0
cs.conv.CreatedAt = time.Now().Format(time.RFC3339)
cs.conv.UpdatedAt = time.Now().Format(time.RFC3339)
cs.realTokens = 0
cs.save()
go cleanupImages(imageIDs)
@@ -173,34 +209,22 @@ func (cs *ConversationStore) SetSummary(summary string) {
cs.save()
}
func (cs *ConversationStore) TrimOld(keepCount int) {
func (cs *ConversationStore) MarkSummarized(upToIndex int) {
cs.mu.Lock()
defer cs.mu.Unlock()
if len(cs.conv.Messages) <= keepCount {
if upToIndex <= 0 || upToIndex >= len(cs.conv.Messages) {
return
}
cs.conv.Messages = cs.conv.Messages[len(cs.conv.Messages)-keepCount:]
for i := 0; i < upToIndex; i++ {
cs.conv.Messages[i].Summarized = true
}
cs.save()
}
func (cs *ConversationStore) ApproxTokenCount() int {
if cs.realTokens > 0 {
return cs.realTokens
}
return cs.ApproxTokenCountDetailed().total
}
// AddRealTokens accumulates actual token counts from the API response.
func (cs *ConversationStore) AddRealTokens(tokens int) {
if tokens <= 0 {
return
}
cs.mu.Lock()
cs.realTokens += tokens
cs.conv.RealTokens = cs.realTokens
cs.mu.Unlock()
}
func (cs *ConversationStore) ApproxTokenCountDetailed() TokenCount {
cs.mu.RLock()
defer cs.mu.RUnlock()
@@ -210,7 +234,10 @@ func (cs *ConversationStore) ApproxTokenCountDetailed() TokenCount {
}
for _, m := range cs.conv.Messages {
count := utf8.RuneCountInString(m.Content) / charsPerToken
if m.Role == "system" || m.Summarized {
continue
}
count := utf8.RuneCountInString(extractDisplayContent(m.Role, m.Content)) / charsPerToken
result.byMessage += count
result.byRole[m.Role] += count
}

View File

@@ -223,7 +223,7 @@ func (cs *ConversationStoreMulti) Add(role, content string) FeedMessage {
}
conv.Messages = append(conv.Messages, msg)
go cs.saveCurrent() // Fire and forget
cs.saveCurrent()
return msg
}

View File

@@ -0,0 +1,172 @@
package api
import (
"context"
"encoding/json"
"fmt"
"net/http"
"runtime"
"strings"
"time"
"github.com/muyue/muyue/internal/orchestrator"
)
func (s *Server) handleAITask(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
var body struct {
Task string `json:"task"`
Tool string `json:"tool,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
if body.Task == "" {
writeError(w, "task is required", http.StatusBadRequest)
return
}
orb, err := orchestrator.New(s.config)
if err != nil {
writeError(w, "AI not available: "+err.Error(), http.StatusServiceUnavailable)
return
}
orb.SetSystemPrompt(buildAITaskSystemPrompt())
orb.SetTools(s.shellAgentToolsJSON)
ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second)
defer cancel()
messages := []orchestrator.Message{
{Role: "user", Content: orchestrator.TextContent(buildAITaskPrompt(body.Task, body.Tool))},
}
engine := NewChatEngine(orb, s.shellAgentRegistry, s.shellAgentToolsJSON)
finalContent, err := engine.RunNonStream(ctx, messages)
if err != nil {
writeError(w, "AI task failed: "+err.Error(), http.StatusInternalServerError)
return
}
s.consumption.Record(engine.ProviderName(), engine.TotalTokens)
parsed := parseAIJSONResponse(finalContent)
writeJSON(w, map[string]interface{}{
"status": "ok",
"raw": finalContent,
"result": parsed,
"tokens": engine.TotalTokens,
})
}
func buildAITaskSystemPrompt() string {
return fmt.Sprintf(`You are a system administration assistant. You have access to a terminal tool to run commands on the host system.
IMPORTANT RULES:
- You MUST respond ONLY with valid JSON. No markdown, no code fences, no extra text.
- Always run the actual commands needed to complete the task.
- Be thorough: check versions, verify installations, compare with latest releases.
OS: %s/%s
Date: %s
`, runtime.GOOS, runtime.GOARCH, time.Now().Format("2006-01-02"))
}
func buildAITaskPrompt(task, tool string) string {
switch task {
case "check_tools":
return `Check the following tools on this system. For each tool, determine:
1. Is it installed? Run "which <tool>" or "<tool> --version"
2. If installed, what is the current version?
3. What is the latest available version? Check GitHub releases API or official sources.
Tools to check: crush, claude, git, node, npm, pnpm, python3, pip3, uv, go, docker, gh, starship, npx
Run the commands needed, then respond with ONLY this JSON structure (no markdown fences):
{
"tools": [
{"name": "tool_name", "installed": true/false, "version": "x.y.z", "latest": "a.b.c", "needs_update": true/false, "category": "ai|runtime|vcs|devops|prompt"}
]
}`
case "install_tool":
return fmt.Sprintf(`Install the tool "%s" on this system.
Steps:
1. Check if it's already installed: run "which %s" and "%s --version"
2. If not installed, determine the best installation method for this OS
3. Run the installation command
4. Verify the installation succeeded
Respond with ONLY this JSON (no markdown fences):
{
"tool": "%s",
"installed": true/false,
"version": "installed version or empty",
"message": "what was done",
"error": "error message or empty"
}`, tool, tool, tool, tool)
case "update_tool":
return fmt.Sprintf(`Update the tool "%s" to its latest version on this system.
Steps:
1. Check current version: run "%s --version"
2. Find the latest version available
3. Run the update/upgrade command
4. Verify the new version
Respond with ONLY this JSON (no markdown fences):
{
"tool": "%s",
"previous_version": "old version",
"version": "new version",
"updated": true/false,
"message": "what was done",
"error": "error message or empty"
}`, tool, tool, tool)
default:
return task
}
}
func parseAIJSONResponse(content string) interface{} {
cleaned := content
if idx := strings.Index(cleaned, "```json"); idx != -1 {
cleaned = cleaned[idx+7:]
if end := strings.Index(cleaned, "```"); end != -1 {
cleaned = cleaned[:end]
}
} else if idx := strings.Index(cleaned, "```"); idx != -1 {
cleaned = cleaned[idx+3:]
if end := strings.Index(cleaned, "```"); end != -1 {
cleaned = cleaned[:end]
}
}
cleaned = strings.TrimSpace(cleaned)
jsonStart := strings.Index(cleaned, "{")
jsonEnd := strings.LastIndex(cleaned, "}")
if jsonStart != -1 && jsonEnd > jsonStart {
cleaned = cleaned[jsonStart : jsonEnd+1]
}
var result interface{}
if err := json.Unmarshal([]byte(cleaned), &result); err != nil {
return map[string]interface{}{
"raw": content,
"error": "failed to parse AI response as JSON",
}
}
return result
}

View File

@@ -6,16 +6,17 @@ import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"unicode/utf8"
"github.com/muyue/muyue/internal/agent"
"github.com/muyue/muyue/internal/orchestrator"
"github.com/muyue/muyue/internal/platform"
)
var thinkingTagRegex = regexp.MustCompile(`(?s)<[Tt]hink[^>]*>.*?</[Tt]hink>`)
@@ -63,15 +64,13 @@ func (s *Server) describeImages(images []ImageAttachment) []string {
}
}
if apiKey == "" {
log.Printf("[vlm] no API key found for image description")
return nil
}
descriptions := make([]string, 0, len(images))
for i, img := range images {
for _, img := range images {
desc, err := s.callVLM(apiKey, img)
if err != nil {
log.Printf("[vlm] image %d (%s) failed: %v", i+1, img.Filename, err)
descriptions = append(descriptions, fmt.Sprintf("(description unavailable: %v)", err))
} else {
descriptions = append(descriptions, desc)
@@ -133,10 +132,12 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
r.Body = http.MaxBytesReader(w, r.Body, 50*1024*1024)
var body struct {
Message string `json:"message"`
Stream bool `json:"stream"`
Images []ImageAttachment `json:"images"`
Message string `json:"message"`
Stream bool `json:"stream"`
Images []ImageAttachment `json:"images"`
AdvancedReflection bool `json:"advanced_reflection"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
@@ -162,7 +163,7 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
id, err := saveImage(body.Images[i].Data, body.Images[i].Filename, body.Images[i].MimeType)
if err != nil {
log.Printf("[images] failed to save %s: %v", body.Images[i].Filename, err)
_ = err
} else {
imageIDs = append(imageIDs, id)
}
@@ -196,7 +197,12 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
}
var studioPrompt strings.Builder
studioPrompt.WriteString(agent.StudioSystemPrompt())
studioPrompt.WriteString(fmt.Sprintf("\nDate: %s\nHeure: %s\n", time.Now().Format("02/01/2006"), time.Now().Format("15:04:05")))
sysInfo := platform.Detect()
osName := sysInfo.OSName
if osName == "" {
osName = string(sysInfo.OS)
}
studioPrompt.WriteString(fmt.Sprintf("\nDate: %s\nHeure: %s\nSystème: %s\n", time.Now().Format("02/01/2006"), time.Now().Format("15:04:05"), osName))
canSudo := !agent.NeedsSudoPassword()
studioPrompt.WriteString(fmt.Sprintf("Root: %t\n", !canSudo))
if !canSudo {
@@ -207,6 +213,29 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
orb.SetSystemPrompt(studioPrompt.String())
orb.SetTools(s.agentToolsJSON)
if memBlock := s.buildMemoryContext(enrichedMessage); memBlock != "" {
orb.AppendHistory(orchestrator.Message{
Role: "system",
Content: orchestrator.TextContent(memBlock),
})
}
// Auto-force advanced reflection while a browser-test session is active:
// the user is doing AI-driven UI testing, where having a second model
// produce a preliminary report (when one is configured) materially
// improves which clicks the active model decides to perform. The toggle
// remains user-controllable for non-test conversations.
wantReflection := body.AdvancedReflection
if !wantReflection && s.browserTestStore != nil && len(s.browserTestStore.List()) > 0 {
wantReflection = true
}
if wantReflection {
if report, ok := s.runReflectionReport(enrichedMessage); ok {
enrichedMessage = enrichedMessage + "\n\n[RAPPORT PRÉALABLE — produit par un autre modèle, à valider]\n" + report + "\n[/RAPPORT PRÉALABLE]"
}
}
if body.Stream {
s.handleStreamChat(w, orb, enrichedMessage)
} else {
@@ -226,6 +255,7 @@ func (s *Server) handleStreamChat(w http.ResponseWriter, orb *orchestrator.Orche
messages := s.buildContextMessages(userMessage)
engine := NewChatEngine(orb, s.agentRegistry, s.agentToolsJSON)
engine.SetLimiter(s.AcquireAgentSlot)
engine.OnChunk(func(data map[string]interface{}) {
if data == nil {
return
@@ -253,7 +283,6 @@ func (s *Server) handleStreamChat(w http.ResponseWriter, orb *orchestrator.Orche
storeContent = string(storeJSON)
}
s.convStore.Add("assistant", storeContent)
s.convStore.AddRealTokens(engine.TotalTokens)
s.consumption.Record(engine.ProviderName(), engine.TotalTokens)
@@ -265,6 +294,7 @@ func (s *Server) handleNonStreamChat(w http.ResponseWriter, orb *orchestrator.Or
messages := s.buildContextMessages(userMessage)
engine := NewChatEngine(orb, s.agentRegistry, s.agentToolsJSON)
engine.SetLimiter(s.AcquireAgentSlot)
finalContent, err := engine.RunNonStream(ctx, messages)
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
@@ -272,7 +302,6 @@ func (s *Server) handleNonStreamChat(w http.ResponseWriter, orb *orchestrator.Or
}
s.convStore.Add("assistant", finalContent)
s.convStore.AddRealTokens(engine.TotalTokens)
s.consumption.Record(engine.ProviderName(), engine.TotalTokens)
@@ -283,19 +312,75 @@ func cleanThinkingTags(content string) string {
return strings.TrimSpace(thinkingTagRegex.ReplaceAllString(content, ""))
}
const contextWindowMessages = 20
// runReflectionReport runs the inactive AI provider on the user message to
// produce a preliminary analysis report that the active provider will then
// use as additional context. Returns ("", false) if no inactive provider is
// configured or on error — the caller falls back to a normal chat flow.
func (s *Server) runReflectionReport(userMessage string) (string, bool) {
orb, err := orchestrator.NewForInactiveProvider(s.config)
if err != nil {
return "", false
}
orb.SetSystemPrompt("Tu es un analyste. Pour la question ci-dessous, produis un rapport bref (max 15 lignes) qui : (1) reformule l'objectif de l'utilisateur, (2) liste les points à clarifier ou les risques, (3) suggère une approche structurée. Pas de code, pas d'action — uniquement de l'analyse.")
resp, err := orb.SendNoTools(userMessage)
if err != nil {
return "", false
}
return strings.TrimSpace(resp), true
}
func (s *Server) buildContextMessages(userMessage string) []orchestrator.Message {
history := s.convStore.Get()
start := 0
if len(history) > contextWindowMessages {
start = len(history) - contextWindowMessages
sysPromptTokens := utf8.RuneCountInString(agent.StudioSystemPrompt())/charsPerToken + 50
toolsTokens := utf8.RuneCountInString(string(s.agentToolsJSON)) / charsPerToken
responseMargin := 4000
userMsgTokens := utf8.RuneCountInString(userMessage) / charsPerToken
overhead := sysPromptTokens + toolsTokens + responseMargin + userMsgTokens
available := contextWindowTokens - overhead
if available < 1000 {
available = 1000
}
messages := make([]orchestrator.Message, 0, len(history[start:])+1)
included := 0
tokensUsed := 0
for i := len(history) - 1; i >= 0; i-- {
if history[i].Summarized {
break
}
displayContent := extractDisplayContent(history[i].Role, history[i].Content)
msgTokens := utf8.RuneCountInString(displayContent) / charsPerToken
if msgTokens == 0 {
msgTokens = 1
}
if tokensUsed+msgTokens > available {
break
}
tokensUsed += msgTokens
included++
}
start := len(history) - included
if start < 0 {
start = 0
}
hasSummarized := false
for i := 0; i < start; i++ {
if history[i].Summarized {
hasSummarized = true
break
}
}
if start > 0 {
_ = start
}
messages := make([]orchestrator.Message, 0, included+2)
summary := s.convStore.GetSummary()
if summary != "" {
if summary != "" && (start > 0 || hasSummarized) {
messages = append(messages, orchestrator.Message{
Role: "system",
Content: orchestrator.TextContent("Résumé de la conversation précédente:\n" + summary),
@@ -303,27 +388,13 @@ func (s *Server) buildContextMessages(userMessage string) []orchestrator.Message
}
for _, m := range history[start:] {
content := m.Content
if m.Role == "assistant" {
var parsed struct {
Content string `json:"content"`
ToolCalls []struct {
ToolCallID string `json:"tool_call_id"`
Name string `json:"name"`
Args string `json:"args"`
} `json:"tool_calls"`
}
if err := json.Unmarshal([]byte(content), &parsed); err == nil && parsed.Content != "" {
content = parsed.Content
}
}
role := m.Role
if role == "system" {
if m.Role == "system" {
continue
}
displayContent := extractDisplayContent(m.Role, m.Content)
messages = append(messages, orchestrator.Message{
Role: role,
Content: orchestrator.TextContent(content),
Role: m.Role,
Content: orchestrator.TextContent(displayContent),
})
}
@@ -364,8 +435,7 @@ func (s *Server) autoSummarize() {
}
s.convStore.SetSummary(result)
s.convStore.TrimOld(len(messages) - half)
s.convStore.Add("system", "[Conversation résumée automatiquement]")
s.convStore.MarkSummarized(half)
}
func (s *Server) handleChatHistory(w http.ResponseWriter, r *http.Request) {

View File

@@ -60,10 +60,17 @@ func (s *Server) handleSaveProfile(w http.ResponseWriter, r *http.Request) {
return
}
var currentMap map[string]interface{}
json.Unmarshal(currentJSON, &currentMap)
if err := json.Unmarshal(currentJSON, &currentMap); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
var updates map[string]interface{}
body, _ := io.ReadAll(r.Body)
body, err := io.ReadAll(r.Body)
if err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
if err := json.Unmarshal(body, &updates); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
@@ -71,8 +78,15 @@ func (s *Server) handleSaveProfile(w http.ResponseWriter, r *http.Request) {
deepMerge(currentMap, updates)
mergedJSON, _ := json.Marshal(currentMap)
json.Unmarshal(mergedJSON, &s.config.Profile)
mergedJSON, err := json.Marshal(currentMap)
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
if err := json.Unmarshal(mergedJSON, &s.config.Profile); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
if err := config.Save(s.config); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
@@ -122,7 +136,7 @@ func (s *Server) handleSaveProvider(w http.ResponseWriter, r *http.Request) {
found := false
for i := range s.config.AI.Providers {
if s.config.AI.Providers[i].Name == body.Name {
if body.APIKey != "" {
if body.APIKey != "" && body.APIKey != "***" {
s.config.AI.Providers[i].APIKey = body.APIKey
}
if body.Model != "" {
@@ -173,6 +187,14 @@ func (s *Server) handleValidateProvider(w http.ResponseWriter, r *http.Request)
writeError(w, "api_key required", http.StatusBadRequest)
return
}
if body.APIKey == "***" {
for _, p := range s.config.AI.Providers {
if p.Name == body.Name {
body.APIKey = p.APIKey
break
}
}
}
baseURL := body.BaseURL
if baseURL == "" {
@@ -266,7 +288,7 @@ func (s *Server) handleSaveTerminalSettings(w http.ResponseWriter, r *http.Reque
writeError(w, err.Error(), http.StatusBadRequest)
return
}
if body.FontSize > 0 {
if body.FontSize > 0 && body.FontSize <= 72 {
s.config.Terminal.FontSize = body.FontSize
}
if body.FontFamily != "" {
@@ -335,30 +357,25 @@ func (s *Server) handleApplyStarshipTheme(w http.ResponseWriter, r *http.Request
body.Theme = s.config.Terminal.PromptTheme
}
cfgDir, err := config.ConfigDir()
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
themeFile := ApplyStarshipTheme(body.Theme)
s.config.Terminal.PromptTheme = body.Theme
config.Save(s.config)
writeJSON(w, map[string]interface{}{"status": "ok", "config": themeFile})
}
func ApplyStarshipTheme(theme string) string {
cfgDir, _ := config.ConfigDir()
starshipDir := filepath.Join(cfgDir, "starship")
if err := os.MkdirAll(starshipDir, 0755); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
os.MkdirAll(starshipDir, 0755)
themeFile := filepath.Join(starshipDir, "starship.toml")
themeContent := getStarshipThemeConfig(body.Theme)
if err := os.WriteFile(themeFile, []byte(themeContent), 0644); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
themeContent := getStarshipThemeConfig(theme)
os.WriteFile(themeFile, []byte(themeContent), 0644)
home, _ := os.UserHomeDir()
shellRCs := []string{
filepath.Join(home, ".bashrc"),
filepath.Join(home, ".zshrc"),
}
for _, rc := range shellRCs {
for _, rc := range []string{filepath.Join(home, ".bashrc"), filepath.Join(home, ".zshrc")} {
if _, err := os.Stat(rc); err != nil {
continue
}
@@ -375,10 +392,7 @@ func (s *Server) handleApplyStarshipTheme(w http.ResponseWriter, r *http.Request
f.Close()
}
s.config.Terminal.PromptTheme = body.Theme
config.Save(s.config)
writeJSON(w, map[string]interface{}{"status": "ok", "config": themeFile})
return themeFile
}
func getStarshipThemeConfig(theme string) string {

View File

@@ -0,0 +1,336 @@
package api
import (
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/muyue/muyue/internal/config"
"github.com/muyue/muyue/internal/mcpserver"
)
func (s *Server) handleFileContent(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
path := r.URL.Query().Get("path")
if path == "" {
writeError(w, "path parameter required", http.StatusBadRequest)
return
}
home, _ := os.UserHomeDir()
path = strings.ReplaceAll(path, "~", home)
if !filepath.IsAbs(path) {
writeError(w, "path must be absolute", http.StatusBadRequest)
return
}
data, err := os.ReadFile(path)
if err != nil {
writeError(w, fmt.Sprintf("Error reading file: %v", err), http.StatusNotFound)
return
}
ext := strings.ToLower(filepath.Ext(path))
lang := "text"
switch ext {
case ".go":
lang = "go"
case ".js", ".jsx":
lang = "javascript"
case ".ts", ".tsx":
lang = "typescript"
case ".py":
lang = "python"
case ".json":
lang = "json"
case ".yaml", ".yml":
lang = "yaml"
case ".md":
lang = "markdown"
case ".css":
lang = "css"
case ".html":
lang = "html"
case ".sh", ".bash":
lang = "shell"
case ".rs":
lang = "rust"
case ".java":
lang = "java"
}
stat, _ := os.Stat(path)
modTime := ""
if stat != nil {
modTime = stat.ModTime().Format("2006-01-02T15:04:05Z07:00")
}
writeJSON(w, map[string]interface{}{
"path": path,
"content": string(data),
"lang": lang,
"size": len(data),
"modTime": modTime,
})
case http.MethodPut:
var body struct {
Path string `json:"path"`
Content string `json:"content"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
if body.Path == "" {
writeError(w, "path required", http.StatusBadRequest)
return
}
home, _ := os.UserHomeDir()
path := strings.ReplaceAll(body.Path, "~", home)
if !filepath.IsAbs(path) {
writeError(w, "path must be absolute", http.StatusBadRequest)
return
}
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0755); err != nil {
writeError(w, fmt.Sprintf("Error creating directory: %v", err), http.StatusInternalServerError)
return
}
if err := os.WriteFile(path, []byte(body.Content), 0644); err != nil {
writeError(w, fmt.Sprintf("Error writing file: %v", err), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]interface{}{
"status": "ok",
"path": path,
"size": len(body.Content),
})
default:
writeError(w, "GET/PUT only", http.StatusMethodNotAllowed)
}
}
func (s *Server) handleMuyueMCPServerStatus(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]interface{}{
"enabled": s.mcpServer != nil,
"running": s.mcpServer != nil,
"port": s.getMCPServerPort(),
})
}
func (s *Server) handleMuyueMCPServerStart(w http.ResponseWriter, r *http.Request) {
if s.mcpServer != nil {
writeJSON(w, map[string]string{"status": "already_running"})
return
}
s.startMCPServer()
writeJSON(w, map[string]interface{}{
"status": "started",
"port": s.getMCPServerPort(),
})
}
func (s *Server) handleMuyueMCPServerStop(w http.ResponseWriter, r *http.Request) {
if s.mcpServer == nil {
writeJSON(w, map[string]string{"status": "not_running"})
return
}
s.mcpServer.Stop()
s.mcpServer = nil
writeJSON(w, map[string]string{"status": "stopped"})
}
func (s *Server) getMCPServerPort() int {
if s.mcpServer == nil {
return 0
}
return s.mcpServer.Port()
}
func (s *Server) startMCPServer() {
port := 8096
if s.config != nil {
}
s.mcpServer = mcpserver.New(port)
s.mcpServer.Start()
}
func (s *Server) handleAgentSessionsList(w http.ResponseWriter, r *http.Request) {
sessions := s.agentTracker.Discover()
writeJSON(w, map[string]interface{}{
"sessions": sessions,
})
}
func (s *Server) handleAgentSessionOutput(w http.ResponseWriter, r *http.Request) {
id := strings.TrimPrefix(r.URL.Path, "/api/agent-sessions/")
if id == "" {
writeError(w, "session id required", http.StatusBadRequest)
return
}
session := s.agentTracker.Get(id)
if session == nil {
writeError(w, "session not found", http.StatusNotFound)
return
}
writeJSON(w, session)
}
func (s *Server) handleWorkspaceList(w http.ResponseWriter, r *http.Request) {
dir, err := configWorkspacesDir()
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
writeJSON(w, map[string]interface{}{"workspaces": []interface{}{}})
return
}
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
var workspaces []map[string]interface{}
for _, entry := range entries {
if entry.IsDir() {
continue
}
if !strings.HasSuffix(entry.Name(), ".json") {
continue
}
name := strings.TrimSuffix(entry.Name(), ".json")
data, err := os.ReadFile(filepath.Join(dir, entry.Name()))
if err != nil {
continue
}
var ws map[string]interface{}
if err := json.Unmarshal(data, &ws); err != nil {
continue
}
ws["name"] = name
workspaces = append(workspaces, ws)
}
if workspaces == nil {
workspaces = []map[string]interface{}{}
}
writeJSON(w, map[string]interface{}{"workspaces": workspaces})
}
func (s *Server) handleWorkspaceSave(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
var body struct {
Name string `json:"name"`
Layout string `json:"layout"`
Tabs string `json:"tabs"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
if body.Name == "" {
writeError(w, "name required", http.StatusBadRequest)
return
}
dir, err := configWorkspacesDir()
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
wsData := map[string]interface{}{
"name": body.Name,
"layout": body.Layout,
"tabs": body.Tabs,
"updated": fmt.Sprintf("%d", time.Now().Unix()),
}
data, err := json.MarshalIndent(wsData, "", " ")
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
if err := os.WriteFile(filepath.Join(dir, body.Name+".json"), data, 0644); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]string{"status": "ok"})
}
func (s *Server) handleWorkspaceGet(w http.ResponseWriter, r *http.Request) {
name := strings.TrimPrefix(r.URL.Path, "/api/workspace/")
if name == "" {
writeError(w, "name required", http.StatusBadRequest)
return
}
if r.Method == "DELETE" {
dir, err := configWorkspacesDir()
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
if err := os.Remove(filepath.Join(dir, name+".json")); err != nil {
writeError(w, "workspace not found", http.StatusNotFound)
return
}
writeJSON(w, map[string]string{"status": "ok"})
return
}
dir, err := configWorkspacesDir()
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
data, err := os.ReadFile(filepath.Join(dir, name+".json"))
if err != nil {
writeError(w, "workspace not found", http.StatusNotFound)
return
}
var result map[string]interface{}
json.Unmarshal(data, &result)
writeJSON(w, result)
}
func configWorkspacesDir() (string, error) {
configDir, err := config.ConfigDir()
if err != nil {
return "", err
}
dir := filepath.Join(configDir, "workspaces")
if err := os.MkdirAll(dir, 0755); err != nil {
return "", fmt.Errorf("create workspaces dir: %w", err)
}
return dir, nil
}

View File

@@ -0,0 +1,52 @@
package api
import (
"encoding/json"
"fmt"
"net/http"
"github.com/muyue/muyue/internal/agent"
)
func (s *Server) handleImageGenerate(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
return
}
var req struct {
Prompt string `json:"prompt"`
Size string `json:"size"`
Style string `json:"style"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, "invalid request: "+err.Error())
return
}
if req.Prompt == "" {
jsonError(w, "prompt is required")
return
}
imgTool, err := agent.NewImageGenerationTool(s.config)
if err != nil {
jsonError(w, "image tool init: "+err.Error())
return
}
args := map[string]interface{}{
"prompt": req.Prompt,
"size": req.Size,
"style": req.Style,
}
result, err := imgTool.Execute(args)
if err != nil {
jsonError(w, fmt.Sprintf("generation failed: %v", err))
return
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(result))
}

View File

@@ -80,8 +80,23 @@ func (s *Server) handleProviders(w http.ResponseWriter, r *http.Request) {
writeError(w, "no config", http.StatusNotFound)
return
}
masked := make([]map[string]interface{}, 0, len(s.config.AI.Providers))
for _, p := range s.config.AI.Providers {
entry := map[string]interface{}{
"name": p.Name,
"model": p.Model,
"base_url": p.BaseURL,
"active": p.Active,
}
if p.APIKey != "" {
entry["api_key"] = "***"
} else {
entry["api_key"] = ""
}
masked = append(masked, entry)
}
writeJSON(w, map[string]interface{}{
"providers": s.config.AI.Providers,
"providers": masked,
})
}
@@ -91,6 +106,9 @@ func (s *Server) handleSkills(w http.ResponseWriter, r *http.Request) {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
for i := range list {
list[i].Deployed = skills.IsDeployed(list[i].Name)
}
writeJSON(w, map[string]interface{}{
"skills": list,
"count": len(list),
@@ -200,9 +218,20 @@ func (s *Server) handleLSPAutoInstall(w http.ResponseWriter, r *http.Request) {
json.NewDecoder(r.Body).Decode(&body)
}
home, _ := os.UserHomeDir()
if body.ProjectDir == "" {
home, _ := os.UserHomeDir()
body.ProjectDir = home
} else {
abs, err := filepath.Abs(body.ProjectDir)
if err != nil {
writeError(w, "invalid project_dir", http.StatusBadRequest)
return
}
body.ProjectDir = abs
if home != "" && !strings.HasPrefix(abs, home+string(filepath.Separator)) && abs != home {
writeError(w, "project_dir must be within user home", http.StatusBadRequest)
return
}
}
results, err := lsp.AutoInstallForProject(body.ProjectDir)
@@ -727,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,256 @@
package api
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"time"
"github.com/muyue/muyue/internal/memory"
)
func (s *Server) ensureMemoryStore() (*memory.Store, error) {
if s.memoryStore == nil {
store, err := memory.NewStore()
if err != nil {
return nil, err
}
s.memoryStore = store
}
return s.memoryStore, nil
}
func (s *Server) handleMemoryList(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
writeError(w, "GET only", http.StatusMethodNotAllowed)
return
}
store, err := s.ensureMemoryStore()
if err != nil {
writeError(w, "memory store: "+err.Error(), http.StatusInternalServerError)
return
}
var memType memory.MemoryType
if t := r.URL.Query().Get("type"); t != "" {
memType = memory.MemoryType(t)
}
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
memories, err := store.List(memType, limit, offset)
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
count, _ := store.Count()
writeJSON(w, map[string]interface{}{
"memories": memories,
"count": len(memories),
"total": count,
})
}
func (s *Server) handleMemoryCreate(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
var body struct {
Type string `json:"type"`
Key string `json:"key"`
Content string `json:"content"`
Tags string `json:"tags,omitempty"`
Source string `json:"source,omitempty"`
Confidence float64 `json:"confidence,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, "invalid request body", http.StatusBadRequest)
return
}
if body.Key == "" || body.Content == "" {
writeError(w, "key and content are required", http.StatusBadRequest)
return
}
store, err := s.ensureMemoryStore()
if err != nil {
writeError(w, "memory store: "+err.Error(), http.StatusInternalServerError)
return
}
memType := memory.MemoryType(body.Type)
if memType == "" {
memType = memory.TypeFact
}
m := &memory.Memory{
Type: memType,
Key: body.Key,
Content: body.Content,
Tags: body.Tags,
Source: body.Source,
Confidence: body.Confidence,
}
if m.Confidence == 0 {
m.Confidence = 0.5
}
if err := store.Store(m); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]interface{}{
"created": true,
"memory": m,
})
}
func (s *Server) handleMemoryDelete(w http.ResponseWriter, r *http.Request) {
if r.Method != "DELETE" {
writeError(w, "DELETE only", http.StatusMethodNotAllowed)
return
}
id := strings.TrimPrefix(r.URL.Path, "/api/memory/")
if id == "" {
writeError(w, "memory id required", http.StatusBadRequest)
return
}
store, err := s.ensureMemoryStore()
if err != nil {
writeError(w, "memory store: "+err.Error(), http.StatusInternalServerError)
return
}
if err := store.Delete(id); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]interface{}{
"deleted": true,
"id": id,
})
}
func (s *Server) handleMemoryOperation(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/api/memory/")
if path == "" {
s.handleMemoryList(w, r)
return
}
switch r.Method {
case "DELETE":
s.handleMemoryDelete(w, r)
case "GET":
store, err := s.ensureMemoryStore()
if err != nil {
writeError(w, "memory store: "+err.Error(), http.StatusInternalServerError)
return
}
m, err := store.Get(path)
if err != nil {
writeError(w, err.Error(), http.StatusNotFound)
return
}
writeJSON(w, m)
default:
writeError(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (s *Server) handleMemorySearch(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
writeError(w, "GET only", http.StatusMethodNotAllowed)
return
}
query := r.URL.Query().Get("q")
if query == "" {
writeError(w, "query parameter 'q' is required", http.StatusBadRequest)
return
}
store, err := s.ensureMemoryStore()
if err != nil {
writeError(w, "memory store: "+err.Error(), http.StatusInternalServerError)
return
}
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
results, err := store.Search(query, limit)
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]interface{}{
"results": results,
"count": len(results),
"query": query,
})
}
func (s *Server) handleMemoryRecall(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
writeError(w, "GET only", http.StatusMethodNotAllowed)
return
}
query := r.URL.Query().Get("q")
store, err := s.ensureMemoryStore()
if err != nil {
writeError(w, "memory store: "+err.Error(), http.StatusInternalServerError)
return
}
injector := memory.NewInjector(store)
contextBlock, err := injector.BuildContextBlock(query)
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]interface{}{
"context": contextBlock,
"query": query,
})
}
func (s *Server) handleMemoryContext(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
writeError(w, "GET only", http.StatusMethodNotAllowed)
return
}
store, err := s.ensureMemoryStore()
if err != nil {
writeError(w, "memory store: "+err.Error(), http.StatusInternalServerError)
return
}
preferences, _ := store.RecallPreferences()
facts, _ := store.RecallFacts()
recentCutoff := time.Now().Add(-24 * time.Hour)
recent, _ := store.RecallRecent(recentCutoff, 10)
writeJSON(w, map[string]interface{}{
"preferences": preferences,
"facts": facts,
"recent": recent,
})
}

View File

@@ -226,6 +226,29 @@ func (s *Server) handleSkillsDeploy(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]string{"status": "all deployed"})
}
func (s *Server) handleSkillsUndeploy(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
var body struct {
Name string `json:"name"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
if body.Name == "" {
writeError(w, "name is required", http.StatusBadRequest)
return
}
if err := skills.Undeploy(body.Name); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]string{"status": "undeployed", "skill": body.Name})
}
func (s *Server) handleSSHConnections(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
writeError(w, "GET only", http.StatusMethodNotAllowed)

View File

@@ -0,0 +1,373 @@
package api
import (
"context"
"encoding/json"
"net/http"
"strings"
"github.com/muyue/muyue/internal/lessons"
"github.com/muyue/muyue/internal/mcp"
"github.com/muyue/muyue/internal/plugins"
)
func (s *Server) handlePlugins(w http.ResponseWriter, r *http.Request) {
if s.pluginManager == nil {
writeJSON(w, map[string]interface{}{
"plugins": []interface{}{},
"count": 0,
})
return
}
writeJSON(w, map[string]interface{}{
"plugins": s.pluginManager.List(),
"count": len(s.pluginManager.List()),
})
}
func (s *Server) handlePluginEnable(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
name := strings.TrimPrefix(r.URL.Path, "/api/plugins/")
name = strings.TrimSuffix(name, "/enable")
if s.pluginManager == nil {
writeError(w, "plugin system not initialized", http.StatusServiceUnavailable)
return
}
if err := s.pluginManager.Enable(context.Background(), name, s.agentRegistry); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
s.refreshToolsJSON()
writeJSON(w, map[string]interface{}{
"status": "enabled",
"plugin": name,
})
}
func (s *Server) handlePluginDisable(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
name := strings.TrimPrefix(r.URL.Path, "/api/plugins/")
name = strings.TrimSuffix(name, "/disable")
if s.pluginManager == nil {
writeError(w, "plugin system not initialized", http.StatusServiceUnavailable)
return
}
s.pluginManager.Disable(name)
s.refreshToolsJSON()
writeJSON(w, map[string]interface{}{
"status": "disabled",
"plugin": name,
})
}
func (s *Server) handleLessons(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case "GET":
idx := lessons.GetIndex()
all := idx.All()
type lessonInfo struct {
Name string `json:"name"`
Title string `json:"title"`
Description string `json:"description"`
Category string `json:"category"`
Mode string `json:"mode"`
Keywords []string `json:"keywords"`
Tools []string `json:"tools"`
Enabled bool `json:"enabled"`
}
result := make([]lessonInfo, 0, len(all))
for _, l := range all {
result = append(result, lessonInfo{
Name: l.Name,
Title: l.Title,
Description: l.Description,
Category: l.Category,
Mode: string(l.Mode),
Keywords: l.Triggers.Keywords,
Tools: l.Triggers.Tools,
Enabled: l.Enabled,
})
}
writeJSON(w, map[string]interface{}{
"lessons": result,
"count": len(result),
})
case "POST":
var body struct {
Name string `json:"name"`
Title string `json:"title"`
Description string `json:"description"`
Category string `json:"category"`
Keywords []string `json:"keywords"`
Tools []string `json:"tools"`
Content string `json:"content"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, "invalid request body", http.StatusBadRequest)
return
}
lesson := &lessons.Lesson{
Name: body.Name,
Title: body.Title,
Description: body.Description,
Category: body.Category,
Triggers: lessons.Triggers{
Keywords: body.Keywords,
Tools: body.Tools,
},
Content: body.Content,
Mode: lessons.ModeBoth,
Enabled: true,
}
home, _ := userHomeDir()
if home != "" {
dir := home + "/.muyue/lessons"
path := dir + "/" + body.Name + ".md"
if err := lessons.WriteLesson(path, lesson); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
lessons.GetIndex().Reload()
}
writeJSON(w, map[string]interface{}{
"created": true,
"lesson": body.Name,
})
default:
writeError(w, "GET or POST only", http.StatusMethodNotAllowed)
}
}
func (s *Server) handleLessonsMatch(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
writeError(w, "GET only", http.StatusMethodNotAllowed)
return
}
ctx := r.URL.Query().Get("context")
toolsUsed := r.URL.Query().Get("tools")
matchCtx := lessons.MatchContext{
Message: ctx,
}
if toolsUsed != "" {
matchCtx.ToolsUsed = strings.Split(toolsUsed, ",")
}
idx := lessons.GetIndex()
results := lessons.Match(idx.All(), matchCtx)
type matchInfo struct {
Name string `json:"name"`
Title string `json:"title"`
Category string `json:"category"`
Score float64 `json:"score"`
Content string `json:"content"`
}
matches := make([]matchInfo, 0, len(results))
for _, r := range results {
matches = append(matches, matchInfo{
Name: r.Lesson.Name,
Title: r.Lesson.Title,
Category: r.Lesson.Category,
Score: r.Score,
Content: r.Lesson.Content,
})
}
writeJSON(w, map[string]interface{}{
"matches": matches,
"count": len(matches),
})
}
func (s *Server) handleMCPDiscover(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
result := mcp.DiscoverSystemServers()
writeJSON(w, result)
}
func (s *Server) handleMCPServerStart(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
name := strings.TrimPrefix(r.URL.Path, "/api/mcp/")
name = strings.TrimSuffix(name, "/start")
status := mcp.CheckServerStatus(name)
if !status.Installed {
writeError(w, "server not installed: "+name, http.StatusBadRequest)
return
}
writeJSON(w, map[string]interface{}{
"status": "started",
"server": name,
"running": true,
})
}
func (s *Server) handleMCPServerStop(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
name := strings.TrimPrefix(r.URL.Path, "/api/mcp/")
name = strings.TrimSuffix(name, "/stop")
writeJSON(w, map[string]interface{}{
"status": "stopped",
"server": name,
})
}
func (s *Server) handleMCPServerTools(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
writeError(w, "GET only", http.StatusMethodNotAllowed)
return
}
name := strings.TrimPrefix(r.URL.Path, "/api/mcp/")
name = strings.TrimSuffix(name, "/tools")
caps, err := mcp.DiscoverServerTools(name)
if err != nil {
writeError(w, err.Error(), http.StatusNotFound)
return
}
writeJSON(w, map[string]interface{}{
"server": name,
"tools": caps.Tools,
"count": len(caps.Tools),
})
}
func (s *Server) handleBrowserNavigate(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
var body struct {
URL string `json:"url"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, "invalid request body", http.StatusBadRequest)
return
}
writeJSON(w, map[string]interface{}{
"status": "navigating",
"url": body.URL,
})
}
func (s *Server) handleBrowserScreenshot(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
var body struct {
URL string `json:"url"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, "invalid request body", http.StatusBadRequest)
return
}
writeJSON(w, map[string]interface{}{
"status": "screenshot_taken",
"url": body.URL,
})
}
func (s *Server) handleBrowserAction(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
var body struct {
Action string `json:"action"`
Selector string `json:"selector,omitempty"`
Value string `json:"value,omitempty"`
Script string `json:"script,omitempty"`
URL string `json:"url,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, "invalid request body", http.StatusBadRequest)
return
}
writeJSON(w, map[string]interface{}{
"status": "executed",
"action": body.Action,
})
}
func (s *Server) handlePluginAction(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
if strings.HasSuffix(path, "/enable") {
s.handlePluginEnable(w, r)
return
}
if strings.HasSuffix(path, "/disable") {
s.handlePluginDisable(w, r)
return
}
if strings.HasSuffix(path, "/discover") {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
paths := plugins.DefaultPluginPaths()
discovered := plugins.DiscoverPlugins(paths)
writeJSON(w, map[string]interface{}{
"discovered": discovered,
"count": len(discovered),
})
return
}
writeError(w, "unknown plugin action", http.StatusNotFound)
}
func (s *Server) refreshToolsJSON() {
tools := s.agentRegistry.OpenAITools()
toolsJSON, _ := json.Marshal(tools)
s.agentToolsJSON = json.RawMessage(toolsJSON)
}
func userHomeDir() (string, error) {
return "", nil
}

View File

@@ -0,0 +1,268 @@
package api
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/google/uuid"
"github.com/muyue/muyue/internal/config"
"github.com/muyue/muyue/internal/rag"
)
func (s *Server) handleRAGIndex(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
return
}
s.ensureRAGStore()
if r.Header.Get("Content-Type") == "application/json" {
var req struct {
Text string `json:"text"`
Name string `json:"name"`
Type string `json:"type"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, "invalid request: "+err.Error())
return
}
if req.Text == "" {
jsonError(w, "text is required")
return
}
if req.Name == "" {
req.Name = "document-" + time.Now().Format("20060102-150405")
}
if req.Type == "" {
req.Type = "text"
}
s.indexText(w, req.Text, req.Name, req.Type)
return
}
if err := r.ParseMultipartForm(32 << 20); err != nil {
jsonError(w, "invalid multipart: "+err.Error())
return
}
file, header, err := r.FormFile("file")
if err != nil {
jsonError(w, "file is required")
return
}
defer file.Close()
data, err := io.ReadAll(file)
if err != nil {
jsonError(w, "reading file: "+err.Error())
return
}
name := header.Filename
ext := strings.ToLower(filepath.Ext(name))
docType := "text"
switch ext {
case ".md", ".markdown":
docType = "markdown"
case ".go", ".js", ".ts", ".py", ".java", ".rs", ".jsx", ".tsx":
docType = "code"
}
s.indexText(w, string(data), name, docType)
}
func (s *Server) indexText(w http.ResponseWriter, text, name, docType string) {
var chunks []rag.Chunk
switch docType {
case "markdown":
chunks = rag.ChunkMarkdown(text, 500)
case "code":
lang := strings.TrimPrefix(filepath.Ext(name), ".")
chunks = rag.ChunkCode(text, lang, 300)
default:
chunks = rag.ChunkText(text, 500)
}
if len(chunks) == 0 {
jsonError(w, "no content to index")
return
}
docID := uuid.New().String()[:8]
doc := rag.Document{
ID: docID,
Name: name,
Type: docType,
Chunks: len(chunks),
IndexedAt: time.Now(),
Size: int64(len(text)),
}
var chunkRecords []rag.ChunkRecord
var texts []string
for _, c := range chunks {
texts = append(texts, c.Content)
chunkRecords = append(chunkRecords, rag.ChunkRecord{
DocumentID: docID,
Content: c.Content,
StartPos: c.StartPos,
EndPos: c.EndPos,
Metadata: c.Metadata,
})
}
embClient := s.getEmbeddingClient()
if embClient != nil {
embeddings, err := embClient.Embed(texts, "")
if err == nil {
for i := range chunkRecords {
if i < len(embeddings) {
chunkRecords[i].Embedding = embeddings[i]
}
}
}
}
if err := s.ragStore.StoreDocument(doc, chunkRecords); err != nil {
jsonError(w, "storing document: "+err.Error())
return
}
jsonResp(w, map[string]interface{}{
"id": docID,
"name": name,
"chunks": len(chunks),
"type": docType,
})
}
func (s *Server) handleRAGSearch(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
return
}
s.ensureRAGStore()
var req struct {
Query string `json:"query"`
Limit int `json:"limit"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, "invalid request: "+err.Error())
return
}
if req.Query == "" {
jsonError(w, "query is required")
return
}
if req.Limit <= 0 {
req.Limit = 5
}
embClient := s.getEmbeddingClient()
var results []rag.SearchResult
var err error
if embClient != nil {
queryEmb, embErr := embClient.EmbedSingle(req.Query, "")
if embErr == nil {
results, err = s.ragStore.Search(queryEmb, req.Limit)
}
}
if err != nil || len(results) == 0 {
results, err = s.ragStore.SearchKeyword(req.Query, req.Limit)
if err != nil {
jsonError(w, "search error: "+err.Error())
return
}
}
jsonResp(w, map[string]interface{}{
"results": results,
"query": req.Query,
"count": len(results),
})
}
func (s *Server) handleRAGStatus(w http.ResponseWriter, r *http.Request) {
s.ensureRAGStore()
status, err := s.ragStore.Status()
if err != nil {
jsonError(w, "status error: "+err.Error())
return
}
jsonResp(w, status)
}
func (s *Server) handleRAGDelete(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
return
}
s.ensureRAGStore()
id := strings.TrimPrefix(r.URL.Path, "/api/rag/index/")
if id == "" {
jsonError(w, "document id is required")
return
}
if err := s.ragStore.DeleteDocument(id); err != nil {
jsonError(w, "delete error: "+err.Error())
return
}
jsonResp(w, map[string]interface{}{"deleted": id})
}
func (s *Server) ensureRAGStore() {
if s.ragStore != nil {
return
}
configDir, err := config.ConfigDir()
if err != nil {
return
}
store, err := rag.NewStore(configDir)
if err != nil {
fmt.Fprintf(os.Stderr, "RAG store init error: %v\n", err)
return
}
s.ragStore = store
}
func (s *Server) getEmbeddingClient() *rag.EmbeddingClient {
for _, p := range s.config.AI.Providers {
if p.Active && p.APIKey != "" {
baseURL := p.BaseURL
if baseURL == "" {
baseURL = "https://api.openai.com/v1"
}
return rag.NewEmbeddingClient(p.APIKey, baseURL)
}
}
return nil
}
func (s *Server) handleRAGDocuments(w http.ResponseWriter, r *http.Request) {
s.ensureRAGStore()
docs, err := s.ragStore.ListDocuments()
if err != nil {
jsonError(w, "list error: "+err.Error())
return
}
if docs == nil {
docs = []rag.Document{}
}
jsonResp(w, map[string]interface{}{"documents": docs})
}

View File

@@ -10,6 +10,7 @@ import (
"runtime"
"strings"
"time"
"unicode/utf8"
"github.com/muyue/muyue/internal/agent"
"github.com/muyue/muyue/internal/orchestrator"
@@ -106,6 +107,7 @@ func (s *Server) handleShellChatStream(w http.ResponseWriter, orb *orchestrator.
messages := s.buildShellContextMessages()
engine := NewChatEngine(orb, s.shellAgentRegistry, s.shellAgentToolsJSON)
engine.SetLimiter(s.AcquireAgentSlot)
engine.OnChunk(func(data map[string]interface{}) {
if data == nil {
return
@@ -133,7 +135,6 @@ func (s *Server) handleShellChatStream(w http.ResponseWriter, orb *orchestrator.
storeContent = string(storeJSON)
}
s.shellConvStore.Add("assistant", storeContent)
s.shellConvStore.AddRealTokens(engine.TotalTokens)
s.consumption.Record(engine.ProviderName(), engine.TotalTokens)
@@ -148,6 +149,7 @@ func (s *Server) handleShellChatNonStream(w http.ResponseWriter, orb *orchestrat
messages := s.buildShellContextMessages()
engine := NewChatEngine(orb, s.shellAgentRegistry, s.shellAgentToolsJSON)
engine.SetLimiter(s.AcquireAgentSlot)
finalContent, err := engine.RunNonStream(ctx, messages)
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
@@ -155,7 +157,6 @@ func (s *Server) handleShellChatNonStream(w http.ResponseWriter, orb *orchestrat
}
s.shellConvStore.Add("assistant", finalContent)
s.shellConvStore.AddRealTokens(engine.TotalTokens)
s.consumption.Record(engine.ProviderName(), engine.TotalTokens)
@@ -167,36 +168,55 @@ func (s *Server) handleShellChatNonStream(w http.ResponseWriter, orb *orchestrat
func (s *Server) buildShellContextMessages() []orchestrator.Message {
history := s.shellConvStore.Get()
start := 0
const shellContextWindow = 20
if len(history) > shellContextWindow {
start = len(history) - shellContextWindow
sysTokens := utf8.RuneCountInString(shellSystemPromptBase) / charsPerToken
if analysis := LoadSystemAnalysis(); analysis != "" {
sysTokens += utf8.RuneCountInString(analysis) / charsPerToken
}
sysTokens += 100
toolsTokens := utf8.RuneCountInString(string(s.shellAgentToolsJSON)) / charsPerToken
responseMargin := 4000
overhead := sysTokens + toolsTokens + responseMargin
available := shellMaxTokens - overhead
if available < 1000 {
available = 1000
}
messages := make([]orchestrator.Message, 0, len(history[start:]))
included := 0
tokensUsed := 0
for i := len(history) - 1; i >= 0; i-- {
displayContent := extractDisplayContent(history[i].Role, history[i].Content)
msgTokens := utf8.RuneCountInString(displayContent) / charsPerToken
if msgTokens == 0 {
msgTokens = 1
}
if tokensUsed+msgTokens > available {
break
}
tokensUsed += msgTokens
included++
}
start := len(history) - included
if start < 0 {
start = 0
}
if start > 0 {
_ = start
}
messages := make([]orchestrator.Message, 0, included)
for _, m := range history[start:] {
content := m.Content
if m.Role == "assistant" {
var parsed struct {
Content string `json:"content"`
ToolCalls []struct {
ToolCallID string `json:"tool_call_id"`
Name string `json:"name"`
Args string `json:"args"`
} `json:"tool_calls"`
}
if err := json.Unmarshal([]byte(content), &parsed); err == nil && parsed.Content != "" {
content = parsed.Content
}
}
role := m.Role
if role == "system" {
if m.Role == "system" {
continue
}
displayContent := extractDisplayContent(m.Role, m.Content)
messages = append(messages, orchestrator.Message{
Role: role,
Content: orchestrator.TextContent(content),
Role: m.Role,
Content: orchestrator.TextContent(displayContent),
})
}

View File

@@ -0,0 +1,210 @@
package api
import (
"encoding/json"
"net/http"
"strings"
"github.com/muyue/muyue/internal/skills"
)
func (s *Server) handleSkillAutoCreate(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
var body struct {
Snippets []struct {
Role string `json:"role"`
Content string `json:"content"`
} `json:"snippets"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, "invalid request body", http.StatusBadRequest)
return
}
var snippets []skills.ConversationSnippet
for _, s := range body.Snippets {
snippets = append(snippets, skills.ConversationSnippet{
Role: s.Role,
Content: s.Content,
})
}
proposals := skills.AnalyzeConversation(snippets)
var results []map[string]interface{}
for i := range proposals {
p := &proposals[i]
if err := skills.SaveProposal(p); err != nil {
continue
}
results = append(results, map[string]interface{}{
"name": p.Name,
"description": p.Description,
"confidence": p.Confidence,
"category": p.Category,
"tags": p.SuggestedTags,
})
}
writeJSON(w, map[string]interface{}{
"proposals": results,
"count": len(results),
})
}
func (s *Server) handleSkillDetail(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/api/skills/detail/")
if strings.HasSuffix(path, "/improve") {
name := strings.TrimSuffix(path, "/improve")
s.handleSkillImprove(w, r, name)
return
}
if strings.HasSuffix(path, "/history") {
name := strings.TrimSuffix(path, "/history")
s.handleSkillHistoryGet(w, r, name)
return
}
writeError(w, "unknown skill action", http.StatusNotFound)
}
func (s *Server) handleSkillImprove(w http.ResponseWriter, r *http.Request, name string) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
skill, err := skills.Get(name)
if err != nil {
writeError(w, err.Error(), http.StatusNotFound)
return
}
var body struct {
Context string `json:"context,omitempty"`
Apply bool `json:"apply,omitempty"`
}
if r.Body != nil {
json.NewDecoder(r.Body).Decode(&body)
}
improver, err := skills.NewSkillImprover()
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
suggestions, err := improver.Analyze(skill, body.Context)
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
if body.Apply && len(suggestions) > 0 {
if err := improver.ApplyImprovement(name, suggestions[0]); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
updated, _ := skills.Get(name)
writeJSON(w, map[string]interface{}{
"applied": true,
"suggestion": suggestions[0],
"updated": updated,
})
return
}
writeJSON(w, map[string]interface{}{
"skill": skill.Name,
"suggestions": suggestions,
"count": len(suggestions),
})
}
func (s *Server) handleSkillHistoryGet(w http.ResponseWriter, r *http.Request, name string) {
if r.Method != "GET" {
writeError(w, "GET only", http.StatusMethodNotAllowed)
return
}
improver, err := skills.NewSkillImprover()
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
history, err := improver.GetHistory(name)
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]interface{}{
"skill": name,
"history": history,
"count": len(history),
})
}
func (s *Server) handleSkillProposals(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case "GET":
proposals, err := skills.LoadProposals()
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]interface{}{
"proposals": proposals,
"count": len(proposals),
})
case "POST":
var body struct {
Name string `json:"name"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, "invalid request body", http.StatusBadRequest)
return
}
proposals, err := skills.LoadProposals()
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
var target *skills.AutoCreateProposal
for i := range proposals {
if proposals[i].Name == body.Name {
target = &proposals[i]
break
}
}
if target == nil {
writeError(w, "proposal not found", http.StatusNotFound)
return
}
skill, err := skills.CreateFromProposal(target)
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
skills.DeleteProposal(body.Name)
writeJSON(w, map[string]interface{}{
"created": true,
"skill": skill,
})
default:
writeError(w, "GET or POST only", http.StatusMethodNotAllowed)
}
}

View File

@@ -144,7 +144,7 @@ func (s *Server) handleWorkflowPlan(w http.ResponseWriter, r *http.Request) {
engine, _ = workflow.NewEngine(s.agentRegistry)
}
wf := engine.Create("Plan: "+body.Goal[:min(len(body.Goal), 30)], body.Goal, "plan_execute", steps)
wf := engine.Create("Plan: "+truncateString(body.Goal, 30), body.Goal, "plan_execute", steps)
writeJSON(w, wf)
}
@@ -188,7 +188,6 @@ func (s *Server) handleWorkflowExecuteStream(w http.ResponseWriter, engine *work
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.WriteHeader(http.StatusOK)
flusher, canFlush := w.(http.Flusher)
@@ -250,9 +249,10 @@ func (s *Server) handleWorkflowApprove(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]string{"status": "approved"})
}
func min(a, b int) int {
if a < b {
return a
func truncateString(s string, max int) string {
runes := []rune(s)
if len(runes) <= max {
return s
}
return b
return string(runes[:max])
}

View File

@@ -3,7 +3,6 @@ package api
import (
"encoding/base64"
"fmt"
"log"
"net/http"
"os"
"path/filepath"
@@ -38,6 +37,9 @@ func saveImage(dataURI, filename, mimeType string) (string, error) {
if err != nil {
return "", fmt.Errorf("base64 decode: %w", err)
}
if len(decoded) > 10*1024*1024 {
return "", fmt.Errorf("image too large (max 10MB)")
}
id := fmt.Sprintf("%d-%d", time.Now().UnixMilli(), atomic.AddUint64(&imageCounter, 1))
ext := ".png"
@@ -64,7 +66,7 @@ func cleanupImages(ids []string) {
for _, id := range ids {
p := imagePath(id)
if err := os.Remove(p); err != nil && !os.IsNotExist(err) {
log.Printf("[images] failed to delete %s: %v", id, err)
_ = err
}
}
}

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
}

283
internal/api/pipeline.go Normal file
View File

@@ -0,0 +1,283 @@
package api
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"sync"
"time"
)
type Filter interface {
Name() string
Process(ctx context.Context, req *FilterRequest) (*FilterResponse, error)
}
type FilterRequest struct {
UserMessage string `json:"user_message"`
Provider string `json:"provider"`
Model string `json:"model"`
Metadata map[string]string `json:"metadata,omitempty"`
}
type FilterResponse struct {
Allowed bool `json:"allowed"`
Modified string `json:"modified,omitempty"`
Reason string `json:"reason,omitempty"`
TokenCount int `json:"token_count,omitempty"`
Metadata map[string]string `json:"metadata,omitempty"`
}
type Pipeline struct {
mu sync.RWMutex
filters map[string]Filter
enabled map[string]bool
stats map[string]*FilterStats
}
type FilterStats struct {
Invocations int64 `json:"invocations"`
Blocked int64 `json:"blocked"`
LastUsed time.Time `json:"last_used"`
}
func NewPipeline() *Pipeline {
p := &Pipeline{
filters: make(map[string]Filter),
enabled: make(map[string]bool),
stats: make(map[string]*FilterStats),
}
p.Register(&RateLimitFilter{})
p.Register(&TokenCountFilter{})
p.Register(&LoggingFilter{})
p.Register(&ToxicityFilter{})
for name := range p.filters {
p.enabled[name] = true
}
return p
}
func (p *Pipeline) Register(f Filter) {
p.mu.Lock()
defer p.mu.Unlock()
p.filters[f.Name()] = f
p.stats[f.Name()] = &FilterStats{}
}
func (p *Pipeline) Run(ctx context.Context, req *FilterRequest) (string, error) {
p.mu.RLock()
defer p.mu.RUnlock()
for name, filter := range p.filters {
if !p.enabled[name] {
continue
}
resp, err := filter.Process(ctx, req)
if p.stats[name] != nil {
p.stats[name].Invocations++
p.stats[name].LastUsed = time.Now()
}
if err != nil {
continue
}
if !resp.Allowed {
if p.stats[name] != nil {
p.stats[name].Blocked++
}
return "", fmt.Errorf("blocked by filter %s: %s", name, resp.Reason)
}
if resp.Modified != "" {
req.UserMessage = resp.Modified
}
}
return req.UserMessage, nil
}
func (p *Pipeline) Toggle(name string, enabled bool) error {
p.mu.Lock()
defer p.mu.Unlock()
if _, ok := p.filters[name]; !ok {
return fmt.Errorf("filter not found: %s", name)
}
p.enabled[name] = enabled
return nil
}
func (p *Pipeline) IsEnabled(name string) bool {
p.mu.RLock()
defer p.mu.RUnlock()
return p.enabled[name]
}
func (p *Pipeline) ListFilters() []map[string]interface{} {
p.mu.RLock()
defer p.mu.RUnlock()
var result []map[string]interface{}
for name, filter := range p.filters {
entry := map[string]interface{}{
"name": name,
"enabled": p.enabled[name],
}
if stats, ok := p.stats[name]; ok {
entry["invocations"] = stats.Invocations
entry["blocked"] = stats.Blocked
entry["last_used"] = stats.LastUsed
}
_ = filter
result = append(result, entry)
}
return result
}
// ── Built-in Filters ──
type RateLimitFilter struct {
mu sync.Mutex
counters map[string][]time.Time
}
func (f *RateLimitFilter) Name() string { return "rate_limit" }
func (f *RateLimitFilter) Process(ctx context.Context, req *FilterRequest) (*FilterResponse, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.counters == nil {
f.counters = make(map[string][]time.Time)
}
key := req.Provider
now := time.Now()
cutoff := now.Add(-time.Minute)
var recent []time.Time
for _, t := range f.counters[key] {
if t.After(cutoff) {
recent = append(recent, t)
}
}
recent = append(recent, now)
f.counters[key] = recent
limit := 30
if len(recent) > limit {
return &FilterResponse{
Allowed: false,
Reason: fmt.Sprintf("rate limit exceeded: %d requests/minute (limit: %d)", len(recent), limit),
}, nil
}
return &FilterResponse{Allowed: true}, nil
}
type TokenCountFilter struct{}
func (f *TokenCountFilter) Name() string { return "token_count" }
func (f *TokenCountFilter) Process(ctx context.Context, req *FilterRequest) (*FilterResponse, error) {
count := len(req.UserMessage) / 4
if count > 50000 {
return &FilterResponse{
Allowed: true,
TokenCount: count,
Reason: fmt.Sprintf("large message: ~%d tokens", count),
}, nil
}
return &FilterResponse{Allowed: true, TokenCount: count}, nil
}
type LoggingFilter struct{}
func (f *LoggingFilter) Name() string { return "logging" }
func (f *LoggingFilter) Process(ctx context.Context, req *FilterRequest) (*FilterResponse, error) {
return &FilterResponse{Allowed: true, Metadata: map[string]string{
"provider": req.Provider,
"model": req.Model,
}}, nil
}
type ToxicityFilter struct{}
func (f *ToxicityFilter) Name() string { return "toxicity" }
func (f *ToxicityFilter) Process(ctx context.Context, req *FilterRequest) (*FilterResponse, error) {
return &FilterResponse{Allowed: true}, nil
}
// ── Pipeline HTTP handlers ──
func (s *Server) handlePipelineFilters(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
filters := s.pipeline.ListFilters()
if filters == nil {
filters = []map[string]interface{}{}
}
jsonResp(w, map[string]interface{}{"filters": filters})
return
}
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
}
func (s *Server) handlePipelineToggle(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
return
}
name := ""
if parts := splitPath(r.URL.Path); len(parts) > 0 {
name = parts[len(parts)-1]
}
if strings.HasSuffix(r.URL.Path, "/toggle") {
name = strings.TrimSuffix(name, "/toggle")
}
var req struct {
Enabled bool `json:"enabled"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, "invalid request")
return
}
if err := s.pipeline.Toggle(name, req.Enabled); err != nil {
jsonError(w, err.Error())
return
}
jsonResp(w, map[string]interface{}{"name": name, "enabled": req.Enabled})
}
func splitPath(p string) []string {
var parts []string
for _, s := range strings.Split(p, "/") {
if s != "" {
parts = append(parts, s)
}
}
return parts
}
func jsonResp(w http.ResponseWriter, v interface{}) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(v)
}
func jsonError(w http.ResponseWriter, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{"error": msg})
}

View File

@@ -1,13 +1,22 @@
package api
import (
"context"
"encoding/json"
"log"
"fmt"
"net/http"
"os/exec"
"strings"
"sync/atomic"
"github.com/muyue/muyue/internal/agent"
"github.com/muyue/muyue/internal/config"
"github.com/muyue/muyue/internal/installer"
"github.com/muyue/muyue/internal/lessons"
"github.com/muyue/muyue/internal/memory"
"github.com/muyue/muyue/internal/mcpserver"
"github.com/muyue/muyue/internal/plugins"
"github.com/muyue/muyue/internal/rag"
"github.com/muyue/muyue/internal/scanner"
"github.com/muyue/muyue/internal/workflow"
)
@@ -24,6 +33,16 @@ type Server struct {
shellAgentRegistry *agent.Registry
shellAgentToolsJSON json.RawMessage
workflowEngine *workflow.Engine
pluginManager *plugins.Manager
hookRegistry *plugins.HookRegistry
browserTestStore *BrowserTestStore
memoryStore *memory.Store
ragStore *rag.Store
pipeline *Pipeline
activeCrushAgents atomic.Int32
activeClaudeAgents atomic.Int32
mcpServer *mcpserver.MCPServer
agentTracker *AgentSessionTracker
}
func NewServer(cfg *config.MuyueConfig) *Server {
@@ -43,7 +62,7 @@ func NewServer(cfg *config.MuyueConfig) *Server {
}
// Save initial config to establish the file for first-time usage
if err := config.Save(defaultCfg); err != nil {
log.Printf("config: initial save failed: %v", err)
_ = err
}
cfg = defaultCfg
}
@@ -53,6 +72,11 @@ func NewServer(cfg *config.MuyueConfig) *Server {
s.shellConvStore = NewShellConvStore()
s.consumption = newConsumptionStore()
s.agentRegistry = agent.DefaultRegistry()
s.browserTestStore = NewBrowserTestStore()
if err := RegisterBrowserTestTool(s.agentRegistry, s.browserTestStore); err != nil {
// Tool registration only fails for duplicate names — non-fatal
_ = err
}
tools := s.agentRegistry.OpenAITools()
toolsJSON, _ := json.Marshal(tools)
s.agentToolsJSON = json.RawMessage(toolsJSON)
@@ -65,6 +89,34 @@ func NewServer(cfg *config.MuyueConfig) *Server {
s.shellAgentToolsJSON = json.RawMessage(shellToolsJSON)
s.workflowEngine, _ = workflow.NewEngine(s.agentRegistry)
if cfg.Lessons.Enabled {
lessons.EnsureBuiltinLessons()
}
s.hookRegistry = plugins.NewHookRegistry()
s.pluginManager = plugins.NewManager(s.hookRegistry)
pluginPaths := cfg.Plugins.Paths
if len(pluginPaths) == 0 {
pluginPaths = plugins.DefaultPluginPaths()
}
discovered := plugins.DiscoverPlugins(pluginPaths)
for _, dp := range discovered {
if dp.Valid {
p, err := plugins.LoadExecutablePlugin(dp)
if err == nil {
s.pluginManager.Register(p)
}
}
}
s.pluginManager.EnableFromConfig(context.Background(), cfg.Plugins.Enabled, s.agentRegistry)
s.pipeline = NewPipeline()
s.agentTracker = NewAgentSessionTracker()
s.initStarship()
s.routes()
return s
}
@@ -96,6 +148,7 @@ func (s *Server) routes() {
s.mux.HandleFunc("/api/starship/apply-theme", s.handleApplyStarshipTheme)
s.mux.HandleFunc("/api/providers/validate", s.handleValidateProvider)
s.mux.HandleFunc("/api/update/run", s.handleRunUpdate)
s.mux.HandleFunc("/api/images/generate", s.handleImageGenerate)
s.mux.HandleFunc("/api/images/", s.handleServeImage)
s.mux.HandleFunc("/api/chat", s.handleChat)
s.mux.HandleFunc("/api/chat/history", s.handleChatHistory)
@@ -120,6 +173,7 @@ func (s *Server) routes() {
s.mux.HandleFunc("/api/conversations/", s.handleDeleteConversation)
s.mux.HandleFunc("/api/lsp/install", s.handleLSPInstall)
s.mux.HandleFunc("/api/skills/deploy", s.handleSkillsDeploy)
s.mux.HandleFunc("/api/skills/undeploy", s.handleSkillsUndeploy)
s.mux.HandleFunc("/api/ssh/connections", s.handleSSHConnections)
s.mux.HandleFunc("/api/ssh/test", s.handleSSHTest)
@@ -133,11 +187,52 @@ func (s *Server) routes() {
s.mux.HandleFunc("/api/skills/export", s.handleSkillExport)
s.mux.HandleFunc("/api/skills/import", s.handleSkillImport)
s.mux.HandleFunc("/api/dashboard/status", s.handleDashboardStatus)
s.mux.HandleFunc("/api/ai/task", s.handleAITask)
s.mux.HandleFunc("/api/providers/quota", s.handleProvidersQuota)
s.mux.HandleFunc("/api/providers/consumption", s.handleProvidersConsumption)
s.mux.HandleFunc("/api/recent-commands", s.handleRecentCommands)
s.mux.HandleFunc("/api/running-processes", s.handleRunningProcesses)
s.mux.HandleFunc("/api/system/metrics", s.handleSystemMetrics)
s.mux.HandleFunc("/api/test/snippet", s.handleBrowserTestSnippet)
s.mux.HandleFunc("/api/test/sessions", s.handleBrowserTestSessions)
s.mux.HandleFunc("/api/test/console/", s.handleBrowserTestConsole)
s.mux.HandleFunc("/api/ws/browser-test", s.handleBrowserTestWS)
s.mux.HandleFunc("/api/skills/auto-create", s.handleSkillAutoCreate)
s.mux.HandleFunc("/api/skills/proposals", s.handleSkillProposals)
s.mux.HandleFunc("/api/skills/detail/", s.handleSkillDetail)
s.mux.HandleFunc("/api/plugins", s.handlePlugins)
s.mux.HandleFunc("/api/plugins/", s.handlePluginAction)
s.mux.HandleFunc("/api/lessons", s.handleLessons)
s.mux.HandleFunc("/api/lessons/match", s.handleLessonsMatch)
s.mux.HandleFunc("/api/mcp/discover", s.handleMCPDiscover)
s.mux.HandleFunc("/api/browser/navigate", s.handleBrowserNavigate)
s.mux.HandleFunc("/api/browser/screenshot", s.handleBrowserScreenshot)
s.mux.HandleFunc("/api/browser/action", s.handleBrowserAction)
s.mux.HandleFunc("/api/rag/index", s.handleRAGIndex)
s.mux.HandleFunc("/api/rag/search", s.handleRAGSearch)
s.mux.HandleFunc("/api/rag/status", s.handleRAGStatus)
s.mux.HandleFunc("/api/rag/documents", s.handleRAGDocuments)
s.mux.HandleFunc("/api/rag/index/", s.handleRAGDelete)
s.mux.HandleFunc("/api/pipeline/filters", s.handlePipelineFilters)
s.mux.HandleFunc("/api/pipeline/filters/", s.handlePipelineToggle)
s.mux.HandleFunc("/api/memory", s.handleMemoryList)
s.mux.HandleFunc("/api/memory/create", s.handleMemoryCreate)
s.mux.HandleFunc("/api/memory/", s.handleMemoryOperation)
s.mux.HandleFunc("/api/memory/search", s.handleMemorySearch)
s.mux.HandleFunc("/api/memory/recall", s.handleMemoryRecall)
s.mux.HandleFunc("/api/memory/context", s.handleMemoryContext)
s.mux.HandleFunc("/api/files/content", s.handleFileContent)
s.mux.HandleFunc("/api/mcp-server/status", s.handleMuyueMCPServerStatus)
s.mux.HandleFunc("/api/mcp-server/start", s.handleMuyueMCPServerStart)
s.mux.HandleFunc("/api/mcp-server/stop", s.handleMuyueMCPServerStop)
s.mux.HandleFunc("/api/agent-sessions", s.handleAgentSessionsList)
s.mux.HandleFunc("/api/agent-sessions/", s.handleAgentSessionOutput)
s.mux.HandleFunc("/api/workspaces", s.handleWorkspaceList)
s.mux.HandleFunc("/api/workspace", s.handleWorkspaceSave)
s.mux.HandleFunc("/api/workspace/", s.handleWorkspaceGet)
}
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ -146,8 +241,11 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, OPTIONS")
if origin := r.Header.Get("Origin"); isAllowedOrigin(origin) {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Vary", "Origin")
}
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
@@ -155,3 +253,66 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
s.mux.ServeHTTP(w, r)
}
func isAllowedOrigin(origin string) bool {
if origin == "" {
return false
}
switch {
case strings.HasPrefix(origin, "http://127.0.0.1"),
strings.HasPrefix(origin, "http://localhost"),
strings.HasPrefix(origin, "http://[::1]"),
strings.HasPrefix(origin, "https://127.0.0.1"),
strings.HasPrefix(origin, "https://localhost"),
strings.HasPrefix(origin, "https://[::1]"):
return true
}
return false
}
const maxCrushAgents = 2
const maxClaudeAgents = 2
func (s *Server) AcquireAgentSlot(toolName string) (release func(), err error) {
var counter *atomic.Int32
var max int32
switch toolName {
case "crush_run":
counter = &s.activeCrushAgents
max = maxCrushAgents
case "claude_run":
counter = &s.activeClaudeAgents
max = maxClaudeAgents
default:
return func() {}, nil
}
current := counter.Add(1)
if current > max {
counter.Add(-1)
return nil, fmt.Errorf("Limite de %d agents %s atteinte", max, toolName)
}
return func() { counter.Add(-1) }, nil
}
func (s *Server) initStarship() {
if _, err := exec.LookPath("starship"); err != nil {
inst := installer.New(s.config)
if result := inst.InstallTool("starship"); !result.Success {
return
}
}
ApplyStarshipTheme(s.config.Terminal.PromptTheme)
}
func (s *Server) buildMemoryContext(query string) string {
store, err := s.ensureMemoryStore()
if err != nil {
return ""
}
injector := memory.NewInjector(store)
ctx, err := injector.BuildContextBlock(query)
if err != nil {
return ""
}
return ctx
}

View File

@@ -79,10 +79,9 @@ type ShellMessage struct {
}
type ShellConvStore struct {
mu sync.RWMutex
path string
msgs []ShellMessage
realTokens int
mu sync.RWMutex
path string
msgs []ShellMessage
}
func NewShellConvStore() *ShellConvStore {
@@ -140,19 +139,18 @@ func (s *ShellConvStore) Clear() {
s.mu.Lock()
defer s.mu.Unlock()
s.msgs = []ShellMessage{}
s.realTokens = 0
s.save()
}
func (s *ShellConvStore) ApproxTokens() int {
if s.realTokens > 0 {
return s.realTokens
}
s.mu.RLock()
defer s.mu.RUnlock()
total := 0
for _, m := range s.msgs {
total += utf8.RuneCountInString(m.Content) / shellCharsPerToken
if m.Role == "system" {
continue
}
total += utf8.RuneCountInString(extractDisplayContent(m.Role, m.Content)) / shellCharsPerToken
}
total += utf8.RuneCountInString(shellSystemPromptBase) / shellCharsPerToken
if analysis := LoadSystemAnalysis(); analysis != "" {
@@ -161,16 +159,6 @@ func (s *ShellConvStore) ApproxTokens() int {
return total
}
// AddRealTokens accumulates actual token counts from the API response.
func (s *ShellConvStore) AddRealTokens(tokens int) {
if tokens <= 0 {
return
}
s.mu.Lock()
s.realTokens += tokens
s.mu.Unlock()
}
func (s *ShellConvStore) AtLimit() bool {
return s.ApproxTokens() >= shellMaxTokens
}

View File

@@ -3,17 +3,16 @@ package api
import (
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"os/exec"
"path/filepath"
"regexp"
"runtime"
"strings"
"sync"
"time"
"github.com/creack/pty/v2"
"github.com/gorilla/websocket"
"github.com/muyue/muyue/internal/config"
)
@@ -48,7 +47,6 @@ type wsMessage struct {
func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("ws upgrade: %v", err)
return
}
defer conn.Close()
@@ -56,26 +54,23 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
var initMsg wsMessage
_, raw, err := conn.ReadMessage()
if err != nil {
log.Printf("terminal: read init message failed: %v", err)
conn.WriteJSON(wsMessage{Type: "error", Data: "failed to read init message"})
return
}
log.Printf("terminal: init message received: %s", string(raw))
if err := json.Unmarshal(raw, &initMsg); err != nil {
log.Printf("terminal: unmarshal init message failed: %v", err)
conn.WriteJSON(wsMessage{Type: "error", Data: "invalid init message"})
return
}
log.Printf("terminal: init type=%q data=%q", initMsg.Type, initMsg.Data)
var cmd *exec.Cmd
if initMsg.Type == "ssh" && initMsg.Data != "" {
var sshConf struct {
Host string `json:"host"`
Port int `json:"port"`
User string `json:"user"`
KeyPath string `json:"key_path"`
Host string `json:"host"`
Port int `json:"port"`
User string `json:"user"`
KeyPath string `json:"key_path"`
Password string `json:"password"`
}
if err := json.Unmarshal([]byte(initMsg.Data), &sshConf); err != nil {
conn.WriteJSON(wsMessage{Type: "error", Data: "invalid ssh config"})
@@ -98,63 +93,77 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
}
sshArgs = append(sshArgs, fmt.Sprintf("%s@%s", sshConf.User, sshConf.Host))
cmd = exec.Command("ssh", sshArgs...)
if sshConf.Password != "" {
sshpassPath, err := exec.LookPath("sshpass")
if err == nil {
args := append([]string{"-e"}, "ssh")
args = append(args, sshArgs...)
cmd = exec.Command(sshpassPath, args...)
cmd.Env = append(os.Environ(), "SSHPASS="+sshConf.Password)
} else {
cmd = exec.Command("ssh", sshArgs...)
}
} else {
cmd = exec.Command("ssh", sshArgs...)
}
} else {
shell := strings.TrimSpace(initMsg.Data)
log.Printf("terminal: requested shell=%q, trimmed=%q", initMsg.Data, shell)
if shell == "" {
shell = detectShell()
log.Printf("terminal: auto-detected shell=%q", shell)
}
if shell == "" {
log.Printf("terminal: no shell detected, falling back to /bin/sh")
shell = "/bin/sh"
}
if path, err := exec.LookPath(shell); err == nil {
shell = path
log.Printf("terminal: resolved shell path=%q", shell)
}
// Support "wsl -d <distro>" shell strings sent from the UI quick-access.
if extra, ok := parseWSLShell(shell); ok {
wslPath, err := exec.LookPath("wsl")
if err != nil {
conn.WriteJSON(wsMessage{Type: "error", Data: "wsl not found on this host"})
return
}
cmd = exec.Command(wslPath, extra...)
} else {
if path, err := exec.LookPath(shell); err == nil {
shell = path
}
if _, err := os.Stat(shell); err != nil {
log.Printf("terminal: shell stat failed: %v for %q", err, shell)
conn.WriteJSON(wsMessage{Type: "error", Data: fmt.Sprintf("shell not found: %s (resolved from: %q)", shell, initMsg.Data)})
return
}
if _, err := os.Stat(shell); err != nil {
conn.WriteJSON(wsMessage{Type: "error", Data: fmt.Sprintf("shell not found: %s (resolved from: %q)", shell, initMsg.Data)})
return
}
shellName := filepath.Base(shell)
switch shellName {
case "wsl":
cmd = exec.Command(shell, "--shell-type", "login")
case "powershell", "pwsh":
cmd = exec.Command(shell, "-NoLogo", "-NoProfile")
case "fish":
cmd = exec.Command(shell, "--login")
default:
cmd = exec.Command(shell)
shellName := filepath.Base(shell)
switch shellName {
case "wsl":
cmd = exec.Command(shell, "--shell-type", "login")
case "powershell", "pwsh":
cmd = exec.Command(shell, "-NoLogo", "-NoProfile")
case "fish":
cmd = exec.Command(shell, "--login")
default:
cmd = exec.Command(shell)
}
}
}
cmd.Env = append(os.Environ(), "TERM=xterm-256color")
if cmd.Env == nil {
cmd.Env = os.Environ()
}
cmd.Env = append(cmd.Env, "TERM=xterm-256color")
log.Printf("terminal: starting pty with cmd=%q args=%v", cmd.Path, cmd.Args)
ptmx, err := pty.Start(cmd)
session, err := startTermSession(cmd)
if err != nil {
log.Printf("terminal: pty start failed: %v", err)
conn.WriteJSON(wsMessage{Type: "error", Data: err.Error()})
return
}
log.Printf("terminal: pty started successfully")
var once sync.Once
cleanup := func() {
once.Do(func() {
ptmx.Close()
if cmd.Process != nil {
cmd.Process.Kill()
cmd.Wait()
}
session.Close()
session.Wait()
})
}
defer cleanup()
@@ -162,15 +171,17 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
go func() {
buf := make([]byte, 4096)
for {
n, err := ptmx.Read(buf)
if err != nil {
cleanup()
return
n, err := session.Read(buf)
if n > 0 {
if err := conn.WriteJSON(wsMessage{
Type: "output",
Data: string(buf[:n]),
}); err != nil {
cleanup()
return
}
}
if err := conn.WriteJSON(wsMessage{
Type: "output",
Data: string(buf[:n]),
}); err != nil {
if err != nil {
cleanup()
return
}
@@ -194,16 +205,13 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
switch msg.Type {
case "input":
if _, err := ptmx.Write([]byte(msg.Data)); err != nil {
if _, err := session.Write([]byte(msg.Data)); err != nil {
cleanup()
return
}
case "resize":
if msg.Rows > 0 && msg.Cols > 0 {
pty.Setsize(ptmx, &pty.Winsize{
Rows: msg.Rows,
Cols: msg.Cols,
})
session.Resize(msg.Rows, msg.Cols)
}
}
}
@@ -211,8 +219,15 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleTerminalSessions(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" {
masked := make([]config.SSHConnection, len(s.config.Terminal.SSH))
for i, c := range s.config.Terminal.SSH {
masked[i] = c
if masked[i].Password != "" {
masked[i].Password = "***"
}
}
writeJSON(w, map[string]interface{}{
"ssh": s.config.Terminal.SSH,
"ssh": masked,
"system": detectSystemTerminals(),
})
return
@@ -222,11 +237,12 @@ func (s *Server) handleTerminalSessions(w http.ResponseWriter, r *http.Request)
return
}
var body struct {
Name string `json:"name"`
Host string `json:"host"`
Port int `json:"port"`
User string `json:"user"`
KeyPath string `json:"key_path"`
Name string `json:"name"`
Host string `json:"host"`
Port int `json:"port"`
User string `json:"user"`
KeyPath string `json:"key_path"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
@@ -240,12 +256,36 @@ func (s *Server) handleTerminalSessions(w http.ResponseWriter, r *http.Request)
body.Port = 22
}
for i, c := range s.config.Terminal.SSH {
if c.Name == body.Name {
password := body.Password
if password == "***" {
password = c.Password
}
s.config.Terminal.SSH[i] = config.SSHConnection{
Name: body.Name,
Host: body.Host,
Port: body.Port,
User: body.User,
KeyPath: body.KeyPath,
Password: password,
}
if err := config.Save(s.config); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]string{"status": "ok"})
return
}
}
conn := config.SSHConnection{
Name: body.Name,
Host: body.Host,
Port: body.Port,
User: body.User,
KeyPath: body.KeyPath,
Name: body.Name,
Host: body.Host,
Port: body.Port,
User: body.User,
KeyPath: body.KeyPath,
Password: body.Password,
}
if s.config.Terminal.SSH == nil {
s.config.Terminal.SSH = []config.SSHConnection{}
@@ -297,6 +337,87 @@ func detectShell() string {
return "/bin/sh"
}
// listWSLDistros returns the list of installed WSL distribution names.
// Windows hosts only — returns nil on other platforms or if WSL is unavailable.
func listWSLDistros() []string {
if runtime.GOOS != "windows" {
return nil
}
out, err := exec.Command("wsl", "--list", "--quiet").Output()
if err != nil {
return nil
}
// `wsl --list --quiet` outputs UTF-16LE on Windows. Strip BOM and decode best-effort.
raw := stripUTF16ToASCII(out)
var distros []string
seen := make(map[string]bool)
for _, line := range strings.Split(raw, "\n") {
name := strings.TrimSpace(line)
if name == "" || seen[name] {
continue
}
// Skip default-marker arrows or annotations.
name = strings.TrimSpace(strings.TrimPrefix(name, "*"))
if name == "" || !validWSLName.MatchString(name) {
continue
}
seen[name] = true
distros = append(distros, name)
}
return distros
}
var validWSLName = regexp.MustCompile(`^[a-zA-Z0-9._-]+$`)
// parseWSLShell recognises strings of the form "wsl -d <distro>" (and optionally
// "-u <user>") emitted by the Shell tab quick-access menu, returning the args
// to pass to the wsl binary. Returns ok=false otherwise.
func parseWSLShell(shell string) ([]string, bool) {
parts := strings.Fields(shell)
if len(parts) < 3 || parts[0] != "wsl" {
return nil, false
}
args := []string{}
i := 1
for i < len(parts) {
switch parts[i] {
case "-d", "--distribution":
if i+1 >= len(parts) || !validWSLName.MatchString(parts[i+1]) {
return nil, false
}
args = append(args, "-d", parts[i+1])
i += 2
case "-u", "--user":
if i+1 >= len(parts) || !validWSLName.MatchString(parts[i+1]) {
return nil, false
}
args = append(args, "-u", parts[i+1])
i += 2
default:
return nil, false
}
}
if len(args) == 0 {
return nil, false
}
return args, true
}
func stripUTF16ToASCII(b []byte) string {
// Best-effort: keep only printable bytes (drop high bytes from UTF-16LE pairs).
var out []byte
for i := 0; i < len(b); i++ {
c := b[i]
if c == 0 {
continue
}
if c >= 32 && c < 127 || c == '\n' || c == '\r' || c == '\t' {
out = append(out, c)
}
}
return string(out)
}
func detectSystemTerminals() []map[string]string {
var terminals []map[string]string
@@ -309,10 +430,17 @@ func detectSystemTerminals() []map[string]string {
if runtime.GOOS == "windows" {
if _, err := exec.LookPath("wsl"); err == nil {
terminals = append(terminals, map[string]string{
"type": "local",
"name": "WSL",
"type": "local",
"name": "WSL (default)",
"shell": "wsl",
})
for _, distro := range listWSLDistros() {
terminals = append(terminals, map[string]string{
"type": "local",
"name": "WSL: " + distro,
"shell": "wsl -d " + distro,
})
}
}
if _, err := exec.LookPath("powershell"); err == nil {
terminals = append(terminals, map[string]string{

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

@@ -0,0 +1,188 @@
package api
// Cross-platform terminal session abstraction.
//
// On Linux / macOS the unix-tagged file (terminal_session_unix.go) wires
// startTermSession to creack/pty for a real PTY: full TTY semantics,
// resize support, interactive apps (vim, top…) work.
//
// On Windows the windows-tagged file (terminal_session_windows.go) tries
// the kernel32 ConPTY API first, with a pipe-based fallback for older
// hosts. pipeSession does NOT carry TTY signals, so most shells go silent
// — it's only kept as a last resort.
//
// Both platforms share the termSession interface, the ptySession type
// (used by unix), and the pipeSession type (used by the Windows fallback).
import (
"io"
"os"
"os/exec"
"sync"
"github.com/creack/pty/v2"
)
// termSession is the read/write/resize/close surface used by handleTerminalWS.
type termSession interface {
Read([]byte) (int, error)
Write([]byte) (int, error)
Resize(rows, cols uint16) error
Close() error
Wait() error
Pid() int
}
// ptySession wraps creack/pty's *os.File-backed PTY (unix path).
type ptySession struct {
ptmx *os.File
cmd *exec.Cmd
}
func (s *ptySession) Read(p []byte) (int, error) { return s.ptmx.Read(p) }
func (s *ptySession) Write(p []byte) (int, error) { return s.ptmx.Write(p) }
func (s *ptySession) Resize(rows, cols uint16) error {
return pty.Setsize(s.ptmx, &pty.Winsize{Rows: rows, Cols: cols})
}
func (s *ptySession) Close() error {
err := s.ptmx.Close()
if s.cmd.Process != nil {
s.cmd.Process.Kill()
}
return err
}
func (s *ptySession) Wait() error {
if s.cmd.Process == nil {
return nil
}
return s.cmd.Wait()
}
func (s *ptySession) Pid() int {
if s.cmd.Process == nil {
return 0
}
return s.cmd.Process.Pid
}
// pipeSession is the Windows last-resort fallback when ConPTY is not
// available: stdin pipe + merged stdout/stderr, no TTY signals. Most
// interactive shells go silent in this mode, so it should rarely be hit on
// modern Windows (10 1809+).
type pipeSession struct {
cmd *exec.Cmd
stdin io.WriteCloser
stdout io.ReadCloser
stderr io.ReadCloser
mu sync.Mutex
merged chan []byte
closed bool
closeCh chan struct{}
}
func startPipeSession(cmd *exec.Cmd) (termSession, error) {
stdin, err := cmd.StdinPipe()
if err != nil {
return nil, err
}
stdout, err := cmd.StdoutPipe()
if err != nil {
stdin.Close()
return nil, err
}
stderr, err := cmd.StderrPipe()
if err != nil {
stdin.Close()
stdout.Close()
return nil, err
}
if err := cmd.Start(); err != nil {
stdin.Close()
stdout.Close()
stderr.Close()
return nil, err
}
s := &pipeSession{
cmd: cmd,
stdin: stdin,
stdout: stdout,
stderr: stderr,
merged: make(chan []byte, 32),
closeCh: make(chan struct{}),
}
go s.pump(stdout)
go s.pump(stderr)
return s, nil
}
func (s *pipeSession) pump(r io.ReadCloser) {
buf := make([]byte, 4096)
for {
n, err := r.Read(buf)
if n > 0 {
chunk := make([]byte, n)
copy(chunk, buf[:n])
select {
case s.merged <- chunk:
case <-s.closeCh:
return
}
}
if err != nil {
return
}
}
}
func (s *pipeSession) Read(p []byte) (int, error) {
select {
case chunk, ok := <-s.merged:
if !ok {
return 0, io.EOF
}
n := copy(p, chunk)
return n, nil
case <-s.closeCh:
return 0, io.EOF
}
}
func (s *pipeSession) Write(p []byte) (int, error) {
return s.stdin.Write(p)
}
func (s *pipeSession) Resize(rows, cols uint16) error {
// No real TTY → resize is a no-op; the child won't get SIGWINCH.
return nil
}
func (s *pipeSession) Close() error {
s.mu.Lock()
if s.closed {
s.mu.Unlock()
return nil
}
s.closed = true
close(s.closeCh)
s.mu.Unlock()
s.stdin.Close()
s.stdout.Close()
s.stderr.Close()
if s.cmd.Process != nil {
s.cmd.Process.Kill()
}
return nil
}
func (s *pipeSession) Wait() error {
if s.cmd.Process == nil {
return nil
}
return s.cmd.Wait()
}
func (s *pipeSession) Pid() int {
if s.cmd.Process == nil {
return 0
}
return s.cmd.Process.Pid
}

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

@@ -2,7 +2,6 @@ package config
import (
"fmt"
"log"
"os"
"path/filepath"
@@ -52,6 +51,16 @@ type SSHConnection struct {
KeyPath string `yaml:"key_path,omitempty" json:"key_path,omitempty"`
}
type PluginsConfig struct {
Enabled []string `yaml:"enabled" json:"enabled"`
Paths []string `yaml:"paths,omitempty" json:"paths,omitempty"`
}
type LessonsConfig struct {
Dirs []string `yaml:"dirs,omitempty" json:"dirs,omitempty"`
Enabled bool `yaml:"enabled" json:"enabled"`
}
type MuyueConfig struct {
Version string `yaml:"version" json:"version"`
Profile Profile `yaml:"profile" json:"profile"`
@@ -72,6 +81,8 @@ type MuyueConfig struct {
FontFamily string `yaml:"font_family" json:"font_family"`
Theme string `yaml:"theme" json:"theme"`
} `yaml:"terminal" json:"terminal"`
Plugins PluginsConfig `yaml:"plugins" json:"plugins"`
Lessons LessonsConfig `yaml:"lessons" json:"lessons"`
}
type TerminalTheme struct {
@@ -162,7 +173,7 @@ func ConfigDir() (string, error) {
if _, err := os.Stat(legacyDir); err == nil {
if _, err := os.Stat(dir); err != nil {
if err := os.Rename(legacyDir, dir); err != nil {
log.Printf("config migration: rename %s to %s: %v", legacyDir, dir, err)
_ = err
}
}
}
@@ -323,5 +334,11 @@ func Default() *MuyueConfig {
cfg.Terminal.PromptTheme = "zerotwo"
cfg.Terminal.FontSize = 14
cfg.Plugins.Enabled = []string{}
cfg.Plugins.Paths = []string{}
cfg.Lessons.Enabled = true
cfg.Lessons.Dirs = []string{}
return cfg
}

513
internal/lessons/lesson.go Normal file
View File

@@ -0,0 +1,513 @@
package lessons
import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
"time"
"gopkg.in/yaml.v3"
)
type LessonMode string
const (
ModeInteractive LessonMode = "interactive"
ModeAutonomous LessonMode = "autonomous"
ModeBoth LessonMode = "both"
)
type Lesson struct {
Name string `yaml:"name" json:"name"`
Title string `yaml:"title" json:"title"`
Description string `yaml:"description" json:"description"`
Category string `yaml:"category" json:"category"`
Triggers Triggers `yaml:"triggers" json:"triggers"`
Content string `yaml:"content" json:"content"`
Mode LessonMode `yaml:"mode" json:"mode"`
Priority int `yaml:"priority" json:"priority"`
Enabled bool `yaml:"enabled" json:"enabled"`
Path string `yaml:"-" json:"path,omitempty"`
}
type Triggers struct {
Keywords []string `yaml:"keywords" json:"keywords"`
Tools []string `yaml:"tools" json:"tools"`
Patterns []string `yaml:"patterns" json:"patterns"`
}
type MatchContext struct {
Message string `json:"message"`
ToolsUsed []string `json:"tools_used,omitempty"`
Mode string `json:"mode,omitempty"`
}
type MatchResult struct {
Lesson *Lesson `json:"lesson"`
Score float64 `json:"score"`
}
type LessonFrontmatter struct {
Name string `yaml:"name"`
Title string `yaml:"title"`
Description string `yaml:"description"`
Category string `yaml:"category"`
Mode LessonMode `yaml:"mode"`
Priority int `yaml:"priority"`
Enabled *bool `yaml:"enabled"`
Triggers Triggers `yaml:"triggers"`
}
type LessonIndex struct {
mu sync.RWMutex
lessons []*Lesson
paths []string
cache map[string]time.Time
}
var (
globalIndex *LessonIndex
globalIndexOnce sync.Once
)
func GetIndex() *LessonIndex {
globalIndexOnce.Do(func() {
globalIndex = &LessonIndex{
lessons: make([]*Lesson, 0),
cache: make(map[string]time.Time),
}
globalIndex.paths = DefaultLessonDirs()
globalIndex.Reload()
})
return globalIndex
}
func DefaultLessonDirs() []string {
var dirs []string
home, _ := os.UserHomeDir()
if home != "" {
dirs = append(dirs,
filepath.Join(home, ".muyue", "lessons"),
)
}
configDir, err := os.UserConfigDir()
if err == nil {
dirs = append(dirs, filepath.Join(configDir, "muyue", "lessons"))
}
if extra := os.Getenv("MUYUE_LESSONS_EXTRA_DIRS"); extra != "" {
for _, d := range strings.Split(extra, ":") {
d = strings.TrimSpace(d)
if d != "" {
dirs = append(dirs, d)
}
}
}
return dirs
}
func (idx *LessonIndex) Reload() {
idx.mu.Lock()
defer idx.mu.Unlock()
var all []*Lesson
seen := make(map[string]bool)
for _, dir := range idx.paths {
files, err := filepath.Glob(filepath.Join(dir, "*.md"))
if err != nil {
continue
}
for _, f := range files {
realPath, _ := filepath.EvalSymlinks(f)
if realPath == "" {
realPath = f
}
if seen[realPath] {
continue
}
seen[realPath] = true
lesson, err := ParseLessonFile(f)
if err != nil {
continue
}
lesson.Path = f
if lesson.Category == "" {
lesson.Category = filepath.Base(filepath.Dir(f))
}
all = append(all, lesson)
}
subDirs, _ := filepath.Glob(filepath.Join(dir, "*"))
for _, subDir := range subDirs {
info, err := os.Stat(subDir)
if err != nil || !info.IsDir() {
continue
}
category := filepath.Base(subDir)
subFiles, _ := filepath.Glob(filepath.Join(subDir, "*.md"))
for _, f := range subFiles {
realPath, _ := filepath.EvalSymlinks(f)
if realPath == "" {
realPath = f
}
if seen[realPath] {
continue
}
seen[realPath] = true
lesson, err := ParseLessonFile(f)
if err != nil {
continue
}
lesson.Path = f
if lesson.Category == "" {
lesson.Category = category
}
all = append(all, lesson)
}
}
}
idx.lessons = all
}
func (idx *LessonIndex) All() []*Lesson {
idx.mu.RLock()
defer idx.mu.RUnlock()
result := make([]*Lesson, 0, len(idx.lessons))
for _, l := range idx.lessons {
if l.Enabled {
result = append(result, l)
}
}
return result
}
func (idx *LessonIndex) Get(name string) *Lesson {
idx.mu.RLock()
defer idx.mu.RUnlock()
for _, l := range idx.lessons {
if l.Name == name {
return l
}
}
return nil
}
func (idx *LessonIndex) Count() int {
idx.mu.RLock()
defer idx.mu.RUnlock()
return len(idx.lessons)
}
func ParseLessonFile(path string) (*Lesson, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read lesson: %w", err)
}
content := string(data)
var frontmatter LessonFrontmatter
var body string
if strings.HasPrefix(content, "---") {
end := strings.Index(content[3:], "---")
if end != -1 {
fm := content[3 : end+3]
body = strings.TrimSpace(content[end+6:])
if err := yaml.Unmarshal([]byte(fm), &frontmatter); err != nil {
body = content
}
} else {
body = content
}
} else {
body = content
}
enabled := true
if frontmatter.Enabled != nil {
enabled = *frontmatter.Enabled
}
if frontmatter.Mode == "" {
frontmatter.Mode = ModeBoth
}
name := frontmatter.Name
if name == "" {
name = strings.TrimSuffix(filepath.Base(path), ".md")
name = strings.ReplaceAll(name, "-", "_")
}
return &Lesson{
Name: name,
Title: frontmatter.Title,
Description: frontmatter.Description,
Category: frontmatter.Category,
Triggers: frontmatter.Triggers,
Content: body,
Mode: frontmatter.Mode,
Priority: frontmatter.Priority,
Enabled: enabled,
}, nil
}
func Match(lessons []*Lesson, ctx MatchContext) []*MatchResult {
var results []*MatchResult
msgLower := strings.ToLower(ctx.Message)
for _, l := range lessons {
if !l.Enabled {
continue
}
score := 0.0
for _, kw := range l.Triggers.Keywords {
if containsKeyword(msgLower, strings.ToLower(kw)) {
score += 1.0
}
}
for _, pattern := range l.Triggers.Patterns {
re, err := regexp.Compile("(?i)" + pattern)
if err == nil && re.MatchString(ctx.Message) {
score += 1.5
}
}
if len(ctx.ToolsUsed) > 0 && len(l.Triggers.Tools) > 0 {
for _, usedTool := range ctx.ToolsUsed {
for _, triggerTool := range l.Triggers.Tools {
if usedTool == triggerTool {
score += 2.0
break
}
}
}
}
if l.Name != "" {
nameLower := strings.ToLower(l.Name)
if strings.Contains(msgLower, nameLower) {
score += 1.5
}
}
if score > 0 {
results = append(results, &MatchResult{
Lesson: l,
Score: score,
})
}
}
sortResults(results)
return results
}
func AutoInclude(systemPrompt string, lessons []*Lesson, ctx MatchContext, maxLessons int) string {
if maxLessons <= 0 {
maxLessons = 5
}
results := Match(lessons, ctx)
if len(results) == 0 {
return systemPrompt
}
if len(results) > maxLessons {
results = results[:maxLessons]
}
var lessonBlock strings.Builder
lessonBlock.WriteString("\n\n--- Active Lessons ---\n\n")
for _, r := range results {
lessonBlock.WriteString(fmt.Sprintf("## %s", r.Lesson.Name))
if r.Lesson.Title != "" {
lessonBlock.WriteString(fmt.Sprintf(" (%s)", r.Lesson.Title))
}
lessonBlock.WriteString("\n")
lessonBlock.WriteString(r.Lesson.Content)
lessonBlock.WriteString("\n\n")
}
return systemPrompt + lessonBlock.String()
}
func EnsureBuiltinLessons() error {
home, _ := os.UserHomeDir()
if home == "" {
return nil
}
lessonsDir := filepath.Join(home, ".muyue", "lessons")
if err := os.MkdirAll(lessonsDir, 0755); err != nil {
return err
}
for _, lesson := range BuiltinLessons() {
path := filepath.Join(lessonsDir, lesson.Name+".md")
if _, err := os.Stat(path); err == nil {
continue
}
if err := WriteLesson(path, lesson); err != nil {
_ = err
}
}
return nil
}
func WriteLesson(path string, lesson *Lesson) error {
var sb strings.Builder
sb.WriteString("---\n")
data, err := yaml.Marshal(&LessonFrontmatter{
Name: lesson.Name,
Title: lesson.Title,
Description: lesson.Description,
Category: lesson.Category,
Mode: lesson.Mode,
Priority: lesson.Priority,
Enabled: &lesson.Enabled,
Triggers: lesson.Triggers,
})
if err != nil {
return err
}
sb.WriteString(string(data))
sb.WriteString("---\n\n")
sb.WriteString(lesson.Content)
return os.WriteFile(path, []byte(sb.String()), 0644)
}
func BuiltinLessons() []*Lesson {
return []*Lesson{
{
Name: "code_style",
Title: "Code Style Guidelines",
Description: "Enforce consistent code style and formatting",
Category: "development",
Triggers: Triggers{
Keywords: []string{"code style", "formatting", "lint", "format", "indentation", "naming convention"},
Tools: []string{"terminal"},
},
Content: `- Follow the existing code style in each file
- Use consistent indentation (match surrounding code)
- Prefer descriptive variable names over abbreviations
- Keep functions focused and small
- Add error handling for all external calls`,
Mode: ModeBoth,
Priority: 5,
Enabled: true,
},
{
Name: "git_workflow",
Title: "Git Workflow Best Practices",
Description: "Guidelines for git operations and commit practices",
Category: "development",
Triggers: Triggers{
Keywords: []string{"git", "commit", "branch", "merge", "pull request", "rebase"},
Tools: []string{"terminal"},
},
Content: `- Write clear, descriptive commit messages
- Use conventional commits format when applicable
- Keep commits atomic and focused
- Don't commit sensitive data or secrets
- Test before committing`,
Mode: ModeBoth,
Priority: 5,
Enabled: true,
},
{
Name: "error_handling",
Title: "Error Handling Patterns",
Description: "Robust error handling guidelines",
Category: "development",
Triggers: Triggers{
Keywords: []string{"error", "panic", "exception", "crash", "fail", "nil pointer"},
Tools: []string{"terminal", "read_file"},
Patterns: []string{`err\s*!=\s*nil`, `panic\(`, `log\.Fatal`},
},
Content: `- Always check errors from external calls
- Provide context when wrapping errors
- Use sentinel errors for expected conditions
- Log errors with enough context for debugging
- Don't silently ignore errors`,
Mode: ModeBoth,
Priority: 6,
Enabled: true,
},
{
Name: "testing",
Title: "Testing Best Practices",
Description: "Guidelines for writing effective tests",
Category: "development",
Triggers: Triggers{
Keywords: []string{"test", "testing", "unit test", "integration test", "coverage"},
Tools: []string{"terminal"},
},
Content: `- Write tests for critical paths first
- Use table-driven tests for multiple cases
- Keep tests independent and deterministic
- Test error paths, not just happy paths
- Aim for meaningful coverage, not just percentage`,
Mode: ModeBoth,
Priority: 5,
Enabled: true,
},
{
Name: "security",
Title: "Security Guidelines",
Description: "Security best practices for development",
Category: "development",
Triggers: Triggers{
Keywords: []string{"security", "vulnerability", "inject", "sanitize", "auth", "secret", "password", "token"},
Tools: []string{"terminal", "read_file", "web_fetch"},
Patterns: []string{`SELECT\s.*\+`, `exec\.Command.*\+`, `os\.Getenv.*KEY`},
},
Content: `- Never log or expose secrets, API keys, or tokens
- Validate and sanitize all user input
- Use parameterized queries for database operations
- Keep dependencies updated
- Don't hardcode credentials`,
Mode: ModeBoth,
Priority: 8,
Enabled: true,
},
}
}
func containsKeyword(text, keyword string) bool {
if keyword == "*" {
return true
}
return strings.Contains(text, keyword)
}
func sortResults(results []*MatchResult) {
for i := 0; i < len(results)-1; i++ {
for j := i + 1; j < len(results); j++ {
if results[j].Score > results[i].Score {
results[i], results[j] = results[j], results[i]
}
}
}
}

369
internal/mcp/discover.go Normal file
View File

@@ -0,0 +1,369 @@
package mcp
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
)
type DiscoveredMCPServer struct {
Name string `json:"name"`
Command string `json:"command"`
Source string `json:"source"`
Args []string `json:"args,omitempty"`
Installed bool `json:"installed"`
Running bool `json:"running"`
Category string `json:"category,omitempty"`
}
type DiscoveryResult struct {
Servers []DiscoveredMCPServer `json:"servers"`
ScanPaths []string `json:"scan_paths"`
TotalFound int `json:"total_found"`
NewServers int `json:"new_servers"`
}
type ToolDiscovery struct {
Name string `json:"name"`
Description string `json:"description"`
InputSchema json.RawMessage `json:"input_schema"`
}
type ServerCapabilities struct {
Name string `json:"name"`
Tools []ToolDiscovery `json:"tools"`
Version string `json:"version,omitempty"`
Raw json.RawMessage `json:"raw,omitempty"`
}
var (
capCache map[string]*ServerCapabilities
capCacheMu sync.RWMutex
)
func init() {
capCache = make(map[string]*ServerCapabilities)
}
func DiscoverSystemServers() *DiscoveryResult {
result := &DiscoveryResult{}
knownNames := make(map[string]bool)
for _, s := range knownMCPServers {
knownNames[s.Name] = true
}
reg, _ := LoadRegistry()
if reg != nil {
for _, s := range reg.Servers {
knownNames[s.Name] = true
}
}
var servers []DiscoveredMCPServer
npmServers := discoverNpmGlobalServers(knownNames)
servers = append(servers, npmServers...)
pipServers := discoverPipServers(knownNames)
servers = append(servers, pipServers...)
pathServers := discoverPathServers(knownNames)
servers = append(servers, pathServers...)
result.Servers = servers
result.TotalFound = len(servers)
result.NewServers = countNew(servers, knownNames)
paths := []string{}
if path := os.Getenv("PATH"); path != "" {
paths = strings.Split(path, ":")
}
if home, err := os.UserHomeDir(); err == nil {
paths = append(paths,
filepath.Join(home, ".local", "bin"),
filepath.Join(home, ".npm-global", "bin"),
)
}
result.ScanPaths = paths
return result
}
func discoverNpmGlobalServers(known map[string]bool) []DiscoveredMCPServer {
var servers []DiscoveredMCPServer
npx, err := exec.LookPath("npx")
if err != nil {
return servers
}
patterns := []struct {
pkg string
name string
cat string
}{
{"@anthropic/mcp-server-fetch", "anthropic-fetch", "web"},
{"@anthropic/mcp-server-sqlite", "anthropic-sqlite", "database"},
{"@anthropic/mcp-server-brave-search", "anthropic-brave-search", "web"},
{"@anthropic/mcp-server-filesystem", "anthropic-filesystem", "core"},
{"@anthropic/mcp-server-github", "anthropic-github", "vcs"},
{"@anthropic/mcp-server-memory", "anthropic-memory", "core"},
{"@anthropic/mcp-server-puppeteer", "anthropic-puppeteer", "web"},
{"@anthropic/mcp-server-sequential-thinking", "anthropic-thinking", "ai"},
}
for _, p := range patterns {
if known[p.name] {
continue
}
servers = append(servers, DiscoveredMCPServer{
Name: p.name,
Command: npx,
Source: "npm-global",
Args: []string{"-y", p.pkg},
Installed: true,
Category: p.cat,
})
}
return servers
}
func discoverPipServers(known map[string]bool) []DiscoveredMCPServer {
var servers []DiscoveredMCPServer
pipCmds := []string{"pip", "pip3", "uv"}
for _, pip := range pipCmds {
if _, err := exec.LookPath(pip); err != nil {
continue
}
cmd := exec.Command(pip, "list", "--format=json")
output, err := cmd.CombinedOutput()
if err != nil {
continue
}
var packages []struct {
Name string `json:"name"`
Version string `json:"version"`
}
if err := json.Unmarshal(output, &packages); err != nil {
continue
}
for _, pkg := range packages {
nameLower := strings.ToLower(pkg.Name)
if !strings.Contains(nameLower, "mcp") {
continue
}
serverName := strings.ReplaceAll(nameLower, "_", "-")
if strings.HasPrefix(serverName, "mcp-") {
serverName = serverName[4:]
}
if known[serverName] {
continue
}
binName := strings.ReplaceAll(pkg.Name, "-", "_")
if _, err := exec.LookPath(binName); err != nil {
binName = pkg.Name
if _, err := exec.LookPath(binName); err != nil {
continue
}
}
servers = append(servers, DiscoveredMCPServer{
Name: serverName,
Command: binName,
Source: "pip",
Installed: true,
Category: "python",
})
}
break
}
return servers
}
func discoverPathServers(known map[string]bool) []DiscoveredMCPServer {
var servers []DiscoveredMCPServer
home, _ := os.UserHomeDir()
searchDirs := []string{}
if home != "" {
searchDirs = append(searchDirs,
filepath.Join(home, ".local", "bin"),
filepath.Join(home, ".muyue", "mcp-servers"),
)
}
for _, dir := range searchDirs {
entries, err := os.ReadDir(dir)
if err != nil {
continue
}
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
if !strings.Contains(strings.ToLower(name), "mcp") {
continue
}
serverName := strings.ToLower(name)
serverName = strings.TrimPrefix(serverName, "mcp-")
serverName = strings.TrimPrefix(serverName, "mcp_")
serverName = strings.TrimSuffix(serverName, ".sh")
if known[serverName] {
continue
}
fullPath := filepath.Join(dir, name)
if info, err := os.Stat(fullPath); err == nil && info.Mode()&0111 != 0 {
servers = append(servers, DiscoveredMCPServer{
Name: serverName,
Command: fullPath,
Source: "path",
Installed: true,
Category: "local",
})
}
}
}
return servers
}
func DiscoverServerTools(serverName string) (*ServerCapabilities, error) {
capCacheMu.RLock()
if caps, ok := capCache[serverName]; ok {
capCacheMu.RUnlock()
return caps, nil
}
capCacheMu.RUnlock()
server, err := findServerConfig(serverName)
if err != nil {
return nil, err
}
script := buildListToolsScript(server)
if script == "" {
return &ServerCapabilities{
Name: serverName,
Tools: []ToolDiscovery{},
}, nil
}
cmd := exec.Command(server.Command, append(server.Args, "--list-tools")...)
output, err := cmd.CombinedOutput()
_ = script
if err != nil {
return discoverToolsFallback(serverName, server)
}
var caps ServerCapabilities
if jsonErr := json.Unmarshal(output, &caps); jsonErr != nil {
caps = ServerCapabilities{
Name: serverName,
Tools: []ToolDiscovery{
{
Name: serverName,
Description: "MCP server: " + serverName,
},
},
}
}
capCacheMu.Lock()
capCache[serverName] = &caps
capCacheMu.Unlock()
return &caps, nil
}
func discoverToolsFallback(name string, server *RegistryServer) (*ServerCapabilities, error) {
caps := &ServerCapabilities{
Name: name,
Tools: []ToolDiscovery{
{
Name: name,
Description: server.Description,
},
},
}
capCacheMu.Lock()
capCache[name] = caps
capCacheMu.Unlock()
return caps, nil
}
func findServerConfig(name string) (*RegistryServer, error) {
reg, err := LoadRegistry()
if err != nil {
return nil, err
}
for i := range reg.Servers {
if reg.Servers[i].Name == name {
return &reg.Servers[i], nil
}
}
for _, s := range knownMCPServers {
if s.Name == name {
return &RegistryServer{
Name: s.Name,
Command: s.Command,
Args: s.Args,
Env: s.Env,
}, nil
}
}
return nil, fmt.Errorf("server %q not found", name)
}
func buildListToolsScript(server *RegistryServer) string {
return ""
}
func InvalidateCapabilitiesCache() {
capCacheMu.Lock()
defer capCacheMu.Unlock()
capCache = make(map[string]*ServerCapabilities)
}
func GetCachedCapabilities(name string) *ServerCapabilities {
capCacheMu.RLock()
defer capCacheMu.RUnlock()
return capCache[name]
}
func countNew(servers []DiscoveredMCPServer, known map[string]bool) int {
count := 0
for _, s := range servers {
if !known[s.Name] {
count++
}
}
return count
}

View File

@@ -0,0 +1,556 @@
package mcpserver
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
"time"
)
type Tool struct {
Name string `json:"name"`
Description string `json:"description"`
InputSchema map[string]interface{} `json:"inputSchema"`
}
type ToolCall struct {
Name string `json:"name"`
Args json.RawMessage `json:"arguments"`
}
type ToolResult struct {
Content []ContentBlock `json:"content"`
IsError bool `json:"isError,omitempty"`
}
type ContentBlock struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
}
type JSONRPCRequest struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id,omitempty"`
Method string `json:"method"`
Params json.RawMessage `json:"params,omitempty"`
}
type JSONRPCResponse struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id"`
Result interface{} `json:"result,omitempty"`
Error *RPCError `json:"error,omitempty"`
}
type RPCError struct {
Code int `json:"code"`
Message string `json:"message"`
}
var tools = []Tool{
{
Name: "terminal_exec",
Description: "Execute a command in the terminal and return the output",
InputSchema: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"command": map[string]interface{}{"type": "string", "description": "The command to execute"},
"cwd": map[string]interface{}{"type": "string", "description": "Working directory (optional)"},
"timeout": map[string]interface{}{"type": "integer", "description": "Timeout in seconds (default 30)"},
},
"required": []string{"command"},
},
},
{
Name: "file_read",
Description: "Read the contents of a file",
InputSchema: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"path": map[string]interface{}{"type": "string", "description": "Path to the file"},
"offset": map[string]interface{}{"type": "integer", "description": "Line offset to start reading from (0-based)"},
"limit": map[string]interface{}{"type": "integer", "description": "Maximum number of lines to read"},
},
"required": []string{"path"},
},
},
{
Name: "file_write",
Description: "Write content to a file, creating it if needed",
InputSchema: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"path": map[string]interface{}{"type": "string", "description": "Path to the file"},
"content": map[string]interface{}{"type": "string", "description": "Content to write"},
},
"required": []string{"path", "content"},
},
},
{
Name: "search",
Description: "Search for files by name pattern",
InputSchema: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"path": map[string]interface{}{"type": "string", "description": "Directory to search in"},
"pattern": map[string]interface{}{"type": "string", "description": "Glob pattern to match filenames"},
},
"required": []string{"path", "pattern"},
},
},
{
Name: "grep",
Description: "Search file contents for a pattern",
InputSchema: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"path": map[string]interface{}{"type": "string", "description": "Directory to search in"},
"pattern": map[string]interface{}{"type": "string", "description": "Text or regex pattern to search for"},
},
"required": []string{"path", "pattern"},
},
},
{
Name: "system_info",
Description: "Get system information (OS, CPU, memory, disk)",
InputSchema: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{},
},
},
}
type MCPServer struct {
port int
server *http.Server
mu sync.Mutex
sseClients map[string]chan SSEEvent
sseClientsMu sync.Mutex
}
type SSEEvent struct {
Event string
Data string
}
func New(port int) *MCPServer {
return &MCPServer{
port: port,
sseClients: make(map[string]chan SSEEvent),
}
}
func (m *MCPServer) Start() error {
mux := http.NewServeMux()
mux.HandleFunc("/", m.handleSSE)
mux.HandleFunc("/message", m.handleHTTPMessage)
mux.HandleFunc("/mcp", m.handleStreamableHTTP)
m.server = &http.Server{
Addr: fmt.Sprintf("127.0.0.1:%d", m.port),
Handler: mux,
}
go func() {
if err := m.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
fmt.Printf("[MCP Server] Error: %v\n", err)
}
}()
return nil
}
func (m *MCPServer) Stop() error {
if m.server != nil {
return m.server.Close()
}
return nil
}
func (m *MCPServer) Port() int {
return m.port
}
func (m *MCPServer) handleSSE(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("Access-Control-Allow-Origin", "*")
clientID := fmt.Sprintf("%d", time.Now().UnixNano())
ch := make(chan SSEEvent, 32)
m.sseClientsMu.Lock()
m.sseClients[clientID] = ch
m.sseClientsMu.Unlock()
defer func() {
m.sseClientsMu.Lock()
delete(m.sseClients, clientID)
m.sseClientsMu.Unlock()
close(ch)
}()
fmt.Fprintf(w, "event: endpoint\ndata: /message?clientId=%s\n\n", clientID)
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
for {
select {
case evt, ok := <-ch:
if !ok {
return
}
fmt.Fprintf(w, "event: %s\ndata: %s\n\n", evt.Event, evt.Data)
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
case <-r.Context().Done():
return
}
}
}
func (m *MCPServer) handleHTTPMessage(w http.ResponseWriter, r *http.Request) {
if r.Method == "OPTIONS" {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
w.WriteHeader(http.StatusOK)
return
}
if r.Method != "POST" {
http.Error(w, "POST only", http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
body, err := io.ReadAll(r.Body)
if err != nil {
m.writeRPCError(w, nil, -32700, "Parse error")
return
}
resp := m.handleJSONRPC(body)
json.NewEncoder(w).Encode(resp)
}
func (m *MCPServer) handleStreamableHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method == "OPTIONS" {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
w.WriteHeader(http.StatusOK)
return
}
if r.Method == "GET" {
m.handleSSE(w, r)
return
}
if r.Method != "POST" {
http.Error(w, "POST only", http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
body, err := io.ReadAll(r.Body)
if err != nil {
m.writeRPCError(w, nil, -32700, "Parse error")
return
}
resp := m.handleJSONRPC(body)
json.NewEncoder(w).Encode(resp)
}
func (m *MCPServer) handleJSONRPC(body []byte) JSONRPCResponse {
var req JSONRPCRequest
if err := json.Unmarshal(body, &req); err != nil {
return JSONRPCResponse{
JSONRPC: "2.0",
Error: &RPCError{Code: -32700, Message: "Parse error"},
}
}
switch req.Method {
case "initialize":
return JSONRPCResponse{
JSONRPC: "2.0",
ID: req.ID,
Result: map[string]interface{}{
"protocolVersion": "2024-11-05",
"capabilities": map[string]interface{}{
"tools": map[string]interface{}{},
},
"serverInfo": map[string]interface{}{
"name": "muyue",
"version": "0.9.0",
},
},
}
case "notifications/initialized":
return JSONRPCResponse{JSONRPC: "2.0", ID: req.ID}
case "tools/list":
return JSONRPCResponse{
JSONRPC: "2.0",
ID: req.ID,
Result: map[string]interface{}{
"tools": tools,
},
}
case "tools/call":
var params struct {
Name string `json:"name"`
Arguments json.RawMessage `json:"arguments"`
}
if err := json.Unmarshal(req.Params, &params); err != nil {
return JSONRPCResponse{
JSONRPC: "2.0",
ID: req.ID,
Error: &RPCError{Code: -32602, Message: "Invalid params"},
}
}
result := m.executeTool(params.Name, params.Arguments)
return JSONRPCResponse{
JSONRPC: "2.0",
ID: req.ID,
Result: result,
}
default:
return JSONRPCResponse{
JSONRPC: "2.0",
ID: req.ID,
Error: &RPCError{Code: -32601, Message: fmt.Sprintf("Method not found: %s", req.Method)},
}
}
}
func (m *MCPServer) executeTool(name string, args json.RawMessage) ToolResult {
switch name {
case "terminal_exec":
return m.toolTerminalExec(args)
case "file_read":
return m.toolFileRead(args)
case "file_write":
return m.toolFileWrite(args)
case "search":
return m.toolSearch(args)
case "grep":
return m.toolGrep(args)
case "system_info":
return m.toolSystemInfo()
default:
return ToolResult{
Content: []ContentBlock{{Type: "text", Text: fmt.Sprintf("Unknown tool: %s", name)}},
IsError: true,
}
}
}
func (m *MCPServer) toolTerminalExec(args json.RawMessage) ToolResult {
var params struct {
Command string `json:"command"`
Cwd string `json:"cwd"`
Timeout int `json:"timeout"`
}
if err := json.Unmarshal(args, &params); err != nil {
return ToolResult{Content: []ContentBlock{{Type: "text", Text: err.Error()}}, IsError: true}
}
timeout := params.Timeout
if timeout <= 0 {
timeout = 30
}
ctx := exec.Command("sh", "-c", params.Command)
if params.Cwd != "" {
ctx.Dir = params.Cwd
}
var stdout, stderr strings.Builder
ctx.Stdout = &stdout
ctx.Stderr = &stderr
done := make(chan error, 1)
go func() { done <- ctx.Run() }()
select {
case err := <-done:
output := stdout.String()
if errMsg := stderr.String(); errMsg != "" {
output += "\n" + errMsg
}
if err != nil {
output += fmt.Sprintf("\nExit error: %v", err)
}
return ToolResult{Content: []ContentBlock{{Type: "text", Text: output}}}
case <-time.After(time.Duration(timeout) * time.Second):
ctx.Process.Kill()
return ToolResult{
Content: []ContentBlock{{Type: "text", Text: fmt.Sprintf("Command timed out after %ds\n%s%s", timeout, stdout.String(), stderr.String())}},
IsError: true,
}
}
}
func (m *MCPServer) toolFileRead(args json.RawMessage) ToolResult {
var params struct {
Path string `json:"path"`
Offset int `json:"offset"`
Limit int `json:"limit"`
}
if err := json.Unmarshal(args, &params); err != nil {
return ToolResult{Content: []ContentBlock{{Type: "text", Text: err.Error()}}, IsError: true}
}
home, _ := os.UserHomeDir()
path := strings.ReplaceAll(params.Path, "~", home)
data, err := os.ReadFile(path)
if err != nil {
return ToolResult{Content: []ContentBlock{{Type: "text", Text: fmt.Sprintf("Error reading file: %v", err)}}, IsError: true}
}
lines := strings.Split(string(data), "\n")
start := params.Offset
if start < 0 {
start = 0
}
end := len(lines)
if params.Limit > 0 && start+params.Limit < end {
end = start + params.Limit
}
if start > len(lines) {
start = len(lines)
}
return ToolResult{Content: []ContentBlock{{Type: "text", Text: strings.Join(lines[start:end], "\n")}}}
}
func (m *MCPServer) toolFileWrite(args json.RawMessage) ToolResult {
var params struct {
Path string `json:"path"`
Content string `json:"content"`
}
if err := json.Unmarshal(args, &params); err != nil {
return ToolResult{Content: []ContentBlock{{Type: "text", Text: err.Error()}}, IsError: true}
}
home, _ := os.UserHomeDir()
path := strings.ReplaceAll(params.Path, "~", home)
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0755); err != nil {
return ToolResult{Content: []ContentBlock{{Type: "text", Text: fmt.Sprintf("Error creating directory: %v", err)}}, IsError: true}
}
if err := os.WriteFile(path, []byte(params.Content), 0644); err != nil {
return ToolResult{Content: []ContentBlock{{Type: "text", Text: fmt.Sprintf("Error writing file: %v", err)}}, IsError: true}
}
return ToolResult{Content: []ContentBlock{{Type: "text", Text: fmt.Sprintf("Successfully wrote %d bytes to %s", len(params.Content), path)}}}
}
func (m *MCPServer) toolSearch(args json.RawMessage) ToolResult {
var params struct {
Path string `json:"path"`
Pattern string `json:"pattern"`
}
if err := json.Unmarshal(args, &params); err != nil {
return ToolResult{Content: []ContentBlock{{Type: "text", Text: err.Error()}}, IsError: true}
}
home, _ := os.UserHomeDir()
basePath := strings.ReplaceAll(params.Path, "~", home)
cmd := exec.Command("find", basePath, "-name", params.Pattern, "-type", "f", "-not", "-path", "*/node_modules/*", "-not", "-path", "*/.git/*")
output, err := cmd.CombinedOutput()
if err != nil {
return ToolResult{Content: []ContentBlock{{Type: "text", Text: string(output)}}}
}
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
if len(lines) > 100 {
lines = lines[:100]
}
return ToolResult{Content: []ContentBlock{{Type: "text", Text: strings.Join(lines, "\n")}}}
}
func (m *MCPServer) toolGrep(args json.RawMessage) ToolResult {
var params struct {
Path string `json:"path"`
Pattern string `json:"pattern"`
}
if err := json.Unmarshal(args, &params); err != nil {
return ToolResult{Content: []ContentBlock{{Type: "text", Text: err.Error()}}, IsError: true}
}
home, _ := os.UserHomeDir()
basePath := strings.ReplaceAll(params.Path, "~", home)
cmd := exec.Command("grep", "-rn", "--include=*", params.Pattern, basePath)
output, _ := cmd.CombinedOutput()
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
if len(lines) > 50 {
lines = lines[:50]
lines = append(lines, fmt.Sprintf("... (%d more results truncated)", len(strings.Split(string(output), "\n"))-50))
}
return ToolResult{Content: []ContentBlock{{Type: "text", Text: strings.Join(lines, "\n")}}}
}
func (m *MCPServer) toolSystemInfo() ToolResult {
var info strings.Builder
info.WriteString(fmt.Sprintf("OS: %s/%s\n", runtime.GOOS, runtime.GOARCH))
info.WriteString(fmt.Sprintf("CPUs: %d\n", runtime.NumCPU()))
if out, err := exec.Command("uname", "-a").Output(); err == nil {
info.WriteString(fmt.Sprintf("Kernel: %s", string(out)))
}
if out, err := exec.Command("free", "-h").Output(); err == nil {
info.WriteString(fmt.Sprintf("Memory:\n%s", string(out)))
}
if out, err := exec.Command("df", "-h", "/").Output(); err == nil {
info.WriteString(fmt.Sprintf("Disk:\n%s", string(out)))
}
if out, err := exec.Command("uptime").Output(); err == nil {
info.WriteString(fmt.Sprintf("Uptime: %s", string(out)))
}
return ToolResult{Content: []ContentBlock{{Type: "text", Text: info.String()}}}
}
func (m *MCPServer) writeRPCError(w http.ResponseWriter, id json.RawMessage, code int, msg string) {
resp := JSONRPCResponse{
JSONRPC: "2.0",
ID: id,
Error: &RPCError{Code: code, Message: msg},
}
json.NewEncoder(w).Encode(resp)
}

140
internal/memory/inject.go Normal file
View File

@@ -0,0 +1,140 @@
package memory
import (
"fmt"
"strings"
"time"
)
type MemoryInjector struct {
store *Store
}
func NewInjector(store *Store) *MemoryInjector {
return &MemoryInjector{store: store}
}
func (mi *MemoryInjector) BuildContextBlock(query string) (string, error) {
var contextParts []string
preferences, err := mi.store.RecallPreferences()
if err == nil && len(preferences) > 0 {
var prefLines []string
for _, p := range preferences {
prefLines = append(prefLines, fmt.Sprintf("- %s: %s", p.Key, p.Content))
}
contextParts = append(contextParts,
"[User Preferences]\n"+strings.Join(prefLines, "\n"))
}
facts, err := mi.store.RecallFacts()
if err == nil && len(facts) > 0 {
var factLines []string
for _, f := range facts {
factLines = append(factLines, fmt.Sprintf("- %s: %s", f.Key, f.Content))
}
contextParts = append(contextParts,
"[Known Facts]\n"+strings.Join(factLines, "\n"))
}
if query != "" {
relevant, err := mi.store.Recall(query, 5)
if err == nil && len(relevant) > 0 {
var relLines []string
for _, r := range relevant {
relLines = append(relLines, fmt.Sprintf("- [%s] %s: %s", r.Type, r.Key, truncate(r.Content, 150)))
}
contextParts = append(contextParts,
"[Relevant Memories]\n"+strings.Join(relLines, "\n"))
}
}
recentCutoff := time.Now().Add(-24 * time.Hour)
recent, err := mi.store.RecallRecent(recentCutoff, 5)
if err == nil && len(recent) > 0 {
var recentLines []string
for _, r := range recent {
recentLines = append(recentLines, fmt.Sprintf("- [%s] %s", r.Type, truncate(r.Content, 100)))
}
contextParts = append(contextParts,
"[Recent Context]\n"+strings.Join(recentLines, "\n"))
}
if len(contextParts) == 0 {
return "", nil
}
return fmt.Sprintf("<memory-context>\n[System note: NOT new user input — recalled context]\n%s\n</memory-context>",
strings.Join(contextParts, "\n\n")), nil
}
func (mi *MemoryInjector) BuildSystemPromptBlock() (string, error) {
preferences, err := mi.store.RecallPreferences()
if err != nil || len(preferences) == 0 {
return "", nil
}
var lines []string
lines = append(lines, "Known user preferences:")
for _, p := range preferences {
lines = append(lines, fmt.Sprintf("- %s: %s", p.Key, p.Content))
}
return strings.Join(lines, "\n"), nil
}
func (mi *MemoryInjector) ExtractAndStore(userMessage, assistantMessage string) error {
pref := extractPreference(userMessage)
if pref != "" {
if err := mi.store.StorePreference("detected", pref); err != nil {
return fmt.Errorf("store preference: %w", err)
}
}
if assistantMessage != "" {
ctx := extractContext(assistantMessage)
if ctx != "" {
if err := mi.store.StoreContext("conversation", ctx); err != nil {
return fmt.Errorf("store context: %w", err)
}
}
}
return nil
}
func extractPreference(message string) string {
indicators := []string{
"i prefer", "i like", "i always", "i never", "my favorite",
"i use", "je préfère", "j'aime", "toujours", "jamais",
}
lower := strings.ToLower(message)
for _, ind := range indicators {
if strings.Contains(lower, ind) {
idx := strings.Index(lower, ind)
end := idx + len(ind) + 100
if end > len(message) {
end = len(message)
}
return truncate(message[idx:end], 200)
}
}
return ""
}
func extractContext(message string) string {
if len(message) < 50 {
return ""
}
if len(message) > 500 {
return truncate(message, 500)
}
return message
}
func truncate(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
return s[:maxLen] + "..."
}

215
internal/memory/recall.go Normal file
View File

@@ -0,0 +1,215 @@
package memory
import (
"database/sql"
"strings"
"time"
)
type SearchResult struct {
Memory
Score float64 `json:"score"`
}
func (s *Store) Search(query string, limit int) ([]SearchResult, error) {
s.mu.RLock()
defer s.mu.RUnlock()
if limit <= 0 {
limit = 10
}
if limit > 50 {
limit = 50
}
normalizedQuery := normalizeQuery(query)
rows, err := s.db.Query(`
SELECT m.id, m.type, m.key, m.content, m.tags, m.source, m.confidence,
m.access_count, m.created_at, m.updated_at,
bm25(memories_fts) as score
FROM memories_fts f
JOIN memories m ON m.rowid = f.rowid
WHERE memories_fts MATCH ?
ORDER BY score
LIMIT ?
`, normalizedQuery, limit)
if err != nil {
return fallbackSearch(s.db, query, limit)
}
defer rows.Close()
return scanSearchResults(rows)
}
func (s *Store) Recall(query string, limit int) ([]Memory, error) {
results, err := s.Search(query, limit)
if err != nil {
return nil, err
}
memories := make([]Memory, len(results))
for i, r := range results {
memories[i] = r.Memory
}
return memories, nil
}
func (s *Store) RecallByType(memType MemoryType, limit int) ([]Memory, error) {
s.mu.RLock()
defer s.mu.RUnlock()
if limit <= 0 {
limit = 20
}
rows, err := s.db.Query(`
SELECT id, type, key, content, tags, source, confidence, access_count, created_at, updated_at
FROM memories WHERE type = ?
ORDER BY access_count DESC, updated_at DESC
LIMIT ?
`, string(memType), limit)
if err != nil {
return nil, err
}
defer rows.Close()
return scanMemories(rows)
}
func (s *Store) RecallRecent(since time.Time, limit int) ([]Memory, error) {
s.mu.RLock()
defer s.mu.RUnlock()
if limit <= 0 {
limit = 20
}
rows, err := s.db.Query(`
SELECT id, type, key, content, tags, source, confidence, access_count, created_at, updated_at
FROM memories WHERE updated_at >= ?
ORDER BY updated_at DESC
LIMIT ?
`, since, limit)
if err != nil {
return nil, err
}
defer rows.Close()
return scanMemories(rows)
}
func (s *Store) RecallPreferences() ([]Memory, error) {
return s.RecallByType(TypePreference, 50)
}
func (s *Store) RecallFacts() ([]Memory, error) {
return s.RecallByType(TypeFact, 50)
}
func (s *Store) StorePreference(key, content string) error {
return s.Store(&Memory{
Type: TypePreference,
Key: key,
Content: content,
Source: "user",
Confidence: 0.9,
})
}
func (s *Store) StoreContext(key, content string) error {
return s.Store(&Memory{
Type: TypeContext,
Key: key,
Content: content,
Source: "conversation",
Confidence: 0.7,
})
}
func (s *Store) StoreSummary(sessionID, summary string) error {
return s.Store(&Memory{
Type: TypeSummary,
Key: "session:" + sessionID,
Content: summary,
Source: "auto",
Confidence: 0.8,
})
}
func (s *Store) StoreFact(key, content string) error {
return s.Store(&Memory{
Type: TypeFact,
Key: key,
Content: content,
Source: "auto",
Confidence: 0.85,
})
}
func normalizeQuery(query string) string {
words := strings.Fields(strings.ToLower(query))
var escaped []string
for _, w := range words {
if len(w) > 0 {
escaped = append(escaped, w+"*")
}
}
return strings.Join(escaped, " OR ")
}
func fallbackSearch(db *sql.DB, query string, limit int) ([]SearchResult, error) {
likePattern := "%" + strings.ToLower(query) + "%"
rows, err := db.Query(`
SELECT id, type, key, content, tags, source, confidence, access_count, created_at, updated_at
FROM memories
WHERE LOWER(key) LIKE ? OR LOWER(content) LIKE ? OR LOWER(tags) LIKE ?
ORDER BY updated_at DESC
LIMIT ?
`, likePattern, likePattern, likePattern, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var results []SearchResult
for rows.Next() {
var m Memory
err := rows.Scan(&m.ID, &m.Type, &m.Key, &m.Content, &m.Tags, &m.Source, &m.Confidence, &m.AccessCount, &m.CreatedAt, &m.UpdatedAt)
if err != nil {
return results, err
}
score := computeFallbackScore(m, query)
results = append(results, SearchResult{Memory: m, Score: score})
}
return results, nil
}
func computeFallbackScore(m Memory, query string) float64 {
score := m.Confidence * 0.5
lower := strings.ToLower(query)
if strings.Contains(strings.ToLower(m.Key), lower) {
score += 0.3
}
if strings.Contains(strings.ToLower(m.Content), lower) {
score += 0.2
}
score += float64(m.AccessCount) * 0.01
return score
}
func scanSearchResults(rows *sql.Rows) ([]SearchResult, error) {
var results []SearchResult
for rows.Next() {
var m Memory
var score float64
err := rows.Scan(&m.ID, &m.Type, &m.Key, &m.Content, &m.Tags, &m.Source,
&m.Confidence, &m.AccessCount, &m.CreatedAt, &m.UpdatedAt, &score)
if err != nil {
return results, err
}
results = append(results, SearchResult{Memory: m, Score: score})
}
return results, nil
}

276
internal/memory/store.go Normal file
View File

@@ -0,0 +1,276 @@
package memory
import (
"database/sql"
"fmt"
"os"
"path/filepath"
"sync"
"time"
_ "modernc.org/sqlite"
)
type MemoryType string
const (
TypePreference MemoryType = "preference"
TypeContext MemoryType = "context"
TypeSummary MemoryType = "summary"
TypeFact MemoryType = "fact"
TypePattern MemoryType = "pattern"
)
type Memory struct {
ID string `json:"id"`
Type MemoryType `json:"type"`
Key string `json:"key"`
Content string `json:"content"`
Tags string `json:"tags,omitempty"`
Source string `json:"source,omitempty"`
Confidence float64 `json:"confidence,omitempty"`
AccessCount int `json:"access_count"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type Store struct {
db *sql.DB
path string
mu sync.RWMutex
}
func NewStore() (*Store, error) {
dbPath, err := dbPath()
if err != nil {
return nil, fmt.Errorf("get db path: %w", err)
}
if err := os.MkdirAll(filepath.Dir(dbPath), 0755); err != nil {
return nil, fmt.Errorf("create memory dir: %w", err)
}
db, err := sql.Open("sqlite", dbPath)
if err != nil {
return nil, fmt.Errorf("open memory db: %w", err)
}
db.SetMaxOpenConns(1)
s := &Store{db: db, path: dbPath}
if err := s.migrate(); err != nil {
db.Close()
return nil, fmt.Errorf("migrate: %w", err)
}
return s, nil
}
func (s *Store) Close() error {
return s.db.Close()
}
func (s *Store) Store(m *Memory) error {
s.mu.Lock()
defer s.mu.Unlock()
if m.ID == "" {
m.ID = generateID()
}
now := time.Now()
if m.CreatedAt.IsZero() {
m.CreatedAt = now
}
m.UpdatedAt = now
_, err := s.db.Exec(`
INSERT INTO memories (id, type, key, content, tags, source, confidence, access_count, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
type = excluded.type,
key = excluded.key,
content = excluded.content,
tags = excluded.tags,
source = excluded.source,
confidence = excluded.confidence,
access_count = excluded.access_count,
updated_at = excluded.updated_at
`, m.ID, string(m.Type), m.Key, m.Content, m.Tags, m.Source, m.Confidence, m.AccessCount, m.CreatedAt, m.UpdatedAt)
return err
}
func (s *Store) Get(id string) (*Memory, error) {
s.mu.RLock()
defer s.mu.RUnlock()
m := &Memory{}
err := s.db.QueryRow(`
SELECT id, type, key, content, tags, source, confidence, access_count, created_at, updated_at
FROM memories WHERE id = ?
`, id).Scan(&m.ID, &m.Type, &m.Key, &m.Content, &m.Tags, &m.Source, &m.Confidence, &m.AccessCount, &m.CreatedAt, &m.UpdatedAt)
if err == nil {
s.incrementAccess(id)
}
return m, err
}
func (s *Store) Delete(id string) error {
s.mu.Lock()
defer s.mu.Unlock()
_, err := s.db.Exec(`DELETE FROM memories WHERE id = ?`, id)
return err
}
func (s *Store) List(memType MemoryType, limit, offset int) ([]Memory, error) {
s.mu.RLock()
defer s.mu.RUnlock()
if limit <= 0 {
limit = 50
}
if limit > 200 {
limit = 200
}
var rows *sql.Rows
var err error
if memType != "" {
rows, err = s.db.Query(`
SELECT id, type, key, content, tags, source, confidence, access_count, created_at, updated_at
FROM memories WHERE type = ?
ORDER BY updated_at DESC LIMIT ? OFFSET ?
`, string(memType), limit, offset)
} else {
rows, err = s.db.Query(`
SELECT id, type, key, content, tags, source, confidence, access_count, created_at, updated_at
FROM memories ORDER BY updated_at DESC LIMIT ? OFFSET ?
`, limit, offset)
}
if err != nil {
return nil, err
}
defer rows.Close()
return scanMemories(rows)
}
func (s *Store) Count() (int, error) {
s.mu.RLock()
defer s.mu.RUnlock()
var count int
err := s.db.QueryRow(`SELECT COUNT(*) FROM memories`).Scan(&count)
return count, err
}
func (s *Store) incrementAccess(id string) {
go func() {
s.db.Exec(`UPDATE memories SET access_count = access_count + 1 WHERE id = ?`, id)
}()
}
func (s *Store) migrate() error {
_, err := s.db.Exec(`
CREATE TABLE IF NOT EXISTS memories (
id TEXT PRIMARY KEY,
type TEXT NOT NULL,
key TEXT NOT NULL,
content TEXT NOT NULL,
tags TEXT DEFAULT '',
source TEXT DEFAULT '',
confidence REAL DEFAULT 0.5,
access_count INTEGER DEFAULT 0,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
)
`)
if err != nil {
return err
}
_, err = s.db.Exec(`
CREATE INDEX IF NOT EXISTS idx_memories_type ON memories(type)
`)
if err != nil {
return err
}
_, err = s.db.Exec(`
CREATE INDEX IF NOT EXISTS idx_memories_key ON memories(key)
`)
if err != nil {
return err
}
_, err = s.db.Exec(`
CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
key, content, tags,
content=memories,
content_rowid=rowid
)
`)
if err != nil {
return err
}
_, err = s.db.Exec(`
CREATE TRIGGER IF NOT EXISTS memories_ai AFTER INSERT ON memories BEGIN
INSERT INTO memories_fts(rowid, key, content, tags)
VALUES (new.rowid, new.key, new.content, new.tags);
END
`)
if err != nil {
return err
}
_, err = s.db.Exec(`
CREATE TRIGGER IF NOT EXISTS memories_ad AFTER DELETE ON memories BEGIN
INSERT INTO memories_fts(memories_fts, rowid, key, content, tags)
VALUES ('delete', old.rowid, old.key, old.content, old.tags);
END
`)
if err != nil {
return err
}
_, err = s.db.Exec(`
CREATE TRIGGER IF NOT EXISTS memories_au AFTER UPDATE ON memories BEGIN
INSERT INTO memories_fts(memories_fts, rowid, key, content, tags)
VALUES ('delete', old.rowid, old.key, old.content, old.tags);
INSERT INTO memories_fts(rowid, key, content, tags)
VALUES (new.rowid, new.key, new.content, new.tags);
END
`)
return err
}
func scanMemories(rows *sql.Rows) ([]Memory, error) {
var memories []Memory
for rows.Next() {
var m Memory
err := rows.Scan(&m.ID, &m.Type, &m.Key, &m.Content, &m.Tags, &m.Source, &m.Confidence, &m.AccessCount, &m.CreatedAt, &m.UpdatedAt)
if err != nil {
return memories, err
}
memories = append(memories, m)
}
return memories, nil
}
func dbPath() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, ".muyue", "memory", "memories.db"), nil
}
func generateID() string {
return fmt.Sprintf("mem_%d", time.Now().UnixNano())
}

View File

@@ -0,0 +1,189 @@
package memory
import (
"database/sql"
"os"
"path/filepath"
"testing"
"time"
_ "modernc.org/sqlite"
)
func testDBPath(t *testing.T) string {
dir := t.TempDir()
return filepath.Join(dir, "test_memory.db")
}
func newTestStore(t *testing.T) *Store {
t.Helper()
dbPath := testDBPath(t)
db, err := openDB(dbPath)
if err != nil {
t.Fatalf("open db: %v", err)
}
t.Cleanup(func() { db.Close() })
s := &Store{db: db, path: dbPath}
if err := s.migrate(); err != nil {
t.Fatalf("migrate: %v", err)
}
return s
}
func openDB(path string) (*sql.DB, error) {
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return nil, err
}
return sql.Open("sqlite", path)
}
func TestStoreAndRetrieve(t *testing.T) {
s := newTestStore(t)
m := &Memory{
Type: TypeFact,
Key: "golang_version",
Content: "User uses Go 1.24",
Source: "conversation",
}
if err := s.Store(m); err != nil {
t.Fatalf("store: %v", err)
}
if m.ID == "" {
t.Fatal("expected ID to be set")
}
got, err := s.Get(m.ID)
if err != nil {
t.Fatalf("get: %v", err)
}
if got.Key != m.Key {
t.Errorf("expected key %s, got %s", m.Key, got.Key)
}
if got.Content != m.Content {
t.Errorf("expected content %s, got %s", m.Content, got.Content)
}
}
func TestDelete(t *testing.T) {
s := newTestStore(t)
m := &Memory{
Type: TypePreference,
Key: "editor",
Content: "vim",
}
s.Store(m)
if err := s.Delete(m.ID); err != nil {
t.Fatalf("delete: %v", err)
}
_, err := s.Get(m.ID)
if err == nil {
t.Error("expected error after delete")
}
}
func TestList(t *testing.T) {
s := newTestStore(t)
for i := 0; i < 5; i++ {
s.Store(&Memory{
Type: TypeFact,
Key: "fact_" + string(rune('a'+i)),
Content: "content",
})
}
memories, err := s.List(TypeFact, 10, 0)
if err != nil {
t.Fatalf("list: %v", err)
}
if len(memories) != 5 {
t.Errorf("expected 5 memories, got %d", len(memories))
}
}
func TestSearch(t *testing.T) {
s := newTestStore(t)
s.Store(&Memory{Type: TypeFact, Key: "language", Content: "Go is the primary language"})
s.Store(&Memory{Type: TypeFact, Key: "editor", Content: "VSCode is the editor"})
s.Store(&Memory{Type: TypeContext, Key: "project", Content: "Muyue is a Go project"})
results, err := s.Search("Go language", 10)
if err != nil {
t.Fatalf("search: %v", err)
}
if len(results) == 0 {
t.Error("expected search results")
}
}
func TestRecallPreferences(t *testing.T) {
s := newTestStore(t)
s.Store(&Memory{Type: TypePreference, Key: "theme", Content: "dark"})
s.Store(&Memory{Type: TypePreference, Key: "lang", Content: "fr"})
s.Store(&Memory{Type: TypeFact, Key: "tool", Content: "go"})
prefs, err := s.RecallPreferences()
if err != nil {
t.Fatalf("recall preferences: %v", err)
}
if len(prefs) != 2 {
t.Errorf("expected 2 preferences, got %d", len(prefs))
}
}
func TestRecallRecent(t *testing.T) {
s := newTestStore(t)
s.Store(&Memory{Type: TypeFact, Key: "old", Content: "old fact"})
recent, err := s.RecallRecent(time.Now().Add(-1*time.Hour), 10)
if err != nil {
t.Fatalf("recall recent: %v", err)
}
if len(recent) == 0 {
t.Error("expected recent memories")
}
}
func TestStorePreference(t *testing.T) {
s := newTestStore(t)
if err := s.StorePreference("editor", "vim"); err != nil {
t.Fatalf("store preference: %v", err)
}
prefs, _ := s.RecallPreferences()
if len(prefs) != 1 {
t.Errorf("expected 1 preference, got %d", len(prefs))
}
}
func TestCount(t *testing.T) {
s := newTestStore(t)
s.Store(&Memory{Type: TypeFact, Key: "a", Content: "a"})
s.Store(&Memory{Type: TypeFact, Key: "b", Content: "b"})
count, err := s.Count()
if err != nil {
t.Fatalf("count: %v", err)
}
if count != 2 {
t.Errorf("expected 2, got %d", count)
}
}

View File

@@ -6,7 +6,6 @@ import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"regexp"
"strings"
@@ -17,6 +16,14 @@ import (
)
var thinkRegex = regexp.MustCompile(`(?s)<[Tt]hink[^>]*>.*?</[Tt]hink>`)
var providerToolBlockRegex = regexp.MustCompile(`(?s)<[a-zA-Z][a-zA-Z0-9]*:tool_call[^>]*>.*?</[a-zA-Z][a-zA-Z0-9]*:tool_call>`)
var providerTagRegex = regexp.MustCompile(`(?s)</?[a-zA-Z][a-zA-Z0-9]*:[a-zA-Z_]+[^>]*>`)
var xmlToolTagRegex = regexp.MustCompile(`(?s)</?(invoke|parameter|tool_call|tool_result)[^>]*>`)
var bracketToolCallRegex = regexp.MustCompile(`(?m)^\[(?:terminal|shell|bash|command|execute)\]\s*\{[^}]*\}\s*$`)
var streamBlockStartRegex = regexp.MustCompile(`<[a-zA-Z][a-zA-Z0-9]*:tool_call`)
var streamXmlStartRegex = regexp.MustCompile(`<(?:invoke|parameter|tool_call|tool_result)[\s>]`)
var streamBracketStartRegex = regexp.MustCompile(`\[(?:terminal|shell|bash|command|execute)\]\s*\{`)
const maxHistorySize = 100
@@ -135,6 +142,37 @@ func New(cfg *config.MuyueConfig) (*Orchestrator, error) {
}, nil
}
// NewForProvider builds an orchestrator using a specific (non-active) provider,
// for the Advanced Reflection feature where the inactive provider produces a
// preliminary report before the active provider answers. Excludes the currently
// active provider from selection — picks the first other configured provider
// with a non-empty API key.
func NewForInactiveProvider(cfg *config.MuyueConfig) (*Orchestrator, error) {
var activeName string
for _, p := range cfg.AI.Providers {
if p.Active {
activeName = p.Name
break
}
}
for i := range cfg.AI.Providers {
p := &cfg.AI.Providers[i]
if p.Name == activeName {
continue
}
if p.APIKey == "" {
continue
}
return &Orchestrator{
config: cfg,
provider: p,
client: sharedHTTPClient,
history: []Message{},
}, nil
}
return nil, fmt.Errorf("no inactive provider with API key configured")
}
func (o *Orchestrator) SetSystemPrompt(prompt string) {
o.systemPrompt = prompt
}
@@ -167,6 +205,33 @@ func (o *Orchestrator) GetHistory() []Message {
return out
}
// SendNoTools issues a one-shot, history-less request to this orchestrator's
// provider. Used by the Advanced Reflection feature so the inactive provider
// can produce a preliminary report without contaminating the active
// orchestrator's history or invoking tools.
func (o *Orchestrator) SendNoTools(userMessage string) (string, error) {
messages := make([]Message, 0, 2)
if o.systemPrompt != "" {
messages = append(messages, Message{Role: "system", Content: TextContent(o.systemPrompt)})
}
messages = append(messages, Message{Role: "user", Content: TextContent(userMessage)})
reqBody := ChatRequest{
Model: o.provider.Model,
Messages: messages,
Stream: false,
}
chatResp, _, err := o.sendWithFallback(reqBody, "")
if err != nil {
return "", err
}
if len(chatResp.Choices) == 0 {
return "", fmt.Errorf("empty response from provider")
}
return CleanAIResponse(chatResp.Choices[0].Message.Content), nil
}
func (o *Orchestrator) Send(userMessage string) (string, error) {
o.histMu.Lock()
o.history = append(o.history, Message{
@@ -197,7 +262,7 @@ func (o *Orchestrator) Send(userMessage string) (string, error) {
return "", err
}
content := cleanAIResponse(chatResp.Choices[0].Message.Content)
content := CleanAIResponse(chatResp.Choices[0].Message.Content)
o.histMu.Lock()
o.history = append(o.history, Message{
Role: "assistant",
@@ -297,7 +362,7 @@ func (o *Orchestrator) SendStream(userMessage string, onChunk func(string)) (str
return fullContent.String(), fmt.Errorf("read stream: %w", err)
}
content := cleanAIResponse(fullContent.String())
content := CleanAIResponse(fullContent.String())
o.histMu.Lock()
o.history = append(o.history, Message{
Role: "assistant",
@@ -388,6 +453,7 @@ func (o *Orchestrator) SendWithToolsStream(messages []Message, onChunk ChunkCall
var fullContent strings.Builder
var accumulatedToolCalls []ToolCallMsg
var totalTokens int
var insideToolBlock bool
scanner := bufio.NewScanner(resp.Body)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
@@ -411,7 +477,10 @@ func (o *Orchestrator) SendWithToolsStream(messages []Message, onChunk ChunkCall
chunk := chatResp.Choices[0].Delta.Content
if chunk != "" {
fullContent.WriteString(chunk)
onChunk(chunk, nil)
cleanedChunk := CleanStreamChunk(chunk, &insideToolBlock)
if cleanedChunk != "" {
onChunk(cleanedChunk, nil)
}
}
// Handle delta tool calls
@@ -463,15 +532,19 @@ func (o *Orchestrator) SendWithToolsStream(messages []Message, onChunk ChunkCall
}{},
}
finalContent := cleanAIResponse(fullContent.String())
finalContent := CleanAIResponse(fullContent.String())
finalResp.Choices[0].Message.Content = finalContent
finalResp.Choices[0].Message.ToolCalls = accumulatedToolCalls
return finalResp, nil
}
func cleanAIResponse(content string) string {
func CleanAIResponse(content string) string {
content = thinkRegex.ReplaceAllString(content, "")
content = providerToolBlockRegex.ReplaceAllString(content, "")
content = providerTagRegex.ReplaceAllString(content, "")
content = xmlToolTagRegex.ReplaceAllString(content, "")
content = bracketToolCallRegex.ReplaceAllString(content, "")
lines := strings.Split(content, "\n")
var clean []string
inBlock := false
@@ -494,6 +567,35 @@ func cleanAIResponse(content string) string {
return result
}
// CleanStreamChunk applies lightweight cleaning to individual streaming chunks.
// It tracks state via a bool pointer to suppress content inside tool-call blocks.
func CleanStreamChunk(chunk string, insideBlock *bool) string {
if *insideBlock {
// Check for closing tag
if strings.Contains(chunk, ":tool_call>") {
*insideBlock = false
}
return ""
}
// Check for opening tool_call block
if streamBlockStartRegex.MatchString(chunk) {
*insideBlock = true
// If closing tag also in same chunk, emit nothing
if strings.Contains(chunk, ":tool_call>") {
*insideBlock = false
}
return ""
}
// Clean individual tags and bracket calls
cleaned := providerTagRegex.ReplaceAllString(chunk, "")
cleaned = xmlToolTagRegex.ReplaceAllString(cleaned, "")
cleaned = bracketToolCallRegex.ReplaceAllString(cleaned, "")
return cleaned
}
func getProviderBaseURL(name string) string {
switch name {
case "minimax":
@@ -616,6 +718,5 @@ func (o *Orchestrator) sendWithFallback(reqBody ChatRequest, baseURLOverride str
return &chatResp, prov.Name, nil
}
log.Printf("[orchestrator] fallback from %v to next provider", triedProviders)
return nil, "", lastErr
}

View File

@@ -65,11 +65,11 @@ func TestCleanAIResponse(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := cleanAIResponse(tt.input)
result := CleanAIResponse(tt.input)
result = strings.TrimSpace(result)
expected := strings.TrimSpace(tt.expected)
if result != expected {
t.Errorf("cleanAIResponse(%q) = %q, want %q", tt.input, result, expected)
t.Errorf("CleanAIResponse(%q) = %q, want %q", tt.input, result, expected)
}
})
}
@@ -77,34 +77,34 @@ func TestCleanAIResponse(t *testing.T) {
func TestCleanAIResponseThinkRegex(t *testing.T) {
input2 := "<Think>some reasoning</Think>actual response"
result2 := cleanAIResponse(input2)
result2 := CleanAIResponse(input2)
if result2 != "actual response" {
t.Errorf("Valid Think tags should be removed: %q", result2)
}
input3 := "<think\nmultiline\nreasoning</think visible"
result3 := cleanAIResponse(input3)
result3 := CleanAIResponse(input3)
// No closing > on opening tag, so won't match regex
if result3 != "<think\nmultiline\nreasoning</think visible" {
t.Errorf("Malformed think should not be removed: %q", result3)
}
input4 := "<think type=re>reasoning</think visible"
result4 := cleanAIResponse(input4)
result4 := CleanAIResponse(input4)
// </think followed by space, not >, so won't match
if result4 != "<think type=re>reasoning</think visible" {
t.Errorf("Malformed closing should not be removed: %q", result4)
}
input_real := "prefix<think reasoning here</think suffix"
result_real := cleanAIResponse(input_real)
result_real := CleanAIResponse(input_real)
// The closing </think has no > after it, so won't match
if result_real != "prefix<think reasoning here</think suffix" {
t.Errorf("Malformed tags should pass through: %q", result_real)
}
input_valid := "<Think>reasoning</Think>result"
result_valid := cleanAIResponse(input_valid)
result_valid := CleanAIResponse(input_valid)
if result_valid != "result" {
t.Errorf("Valid tags should be removed: %q", result_valid)
}

View File

@@ -17,3 +17,62 @@ func fileContains(path, substr string) bool {
func execLookPath(name string) (string, error) {
return exec.LookPath(name)
}
func readOSReleaseName() string {
data, err := os.ReadFile("/etc/os-release")
if err != nil {
return ""
}
var pretty, name, version string
for _, line := range strings.Split(string(data), "\n") {
key, val, ok := strings.Cut(line, "=")
if !ok {
continue
}
val = strings.Trim(val, `"'`)
switch key {
case "PRETTY_NAME":
pretty = val
case "NAME":
name = val
case "VERSION_ID":
version = val
}
}
if pretty != "" {
return pretty
}
if name != "" && version != "" {
return name + " " + version
}
return name
}
func readMacOSVersion() string {
out, err := exec.Command("sw_vers", "-productVersion").Output()
if err != nil {
return ""
}
return strings.TrimSpace(string(out))
}
func readWindowsVersion() string {
if v := os.Getenv("OS"); v != "" && strings.Contains(strings.ToLower(v), "windows") {
// Try to detect Windows 11 vs 10 via build number
if build := os.Getenv("MUYUE_WIN_BUILD"); build != "" {
return "Windows " + build
}
}
out, err := exec.Command("cmd", "/c", "ver").Output()
if err != nil {
return ""
}
s := strings.TrimSpace(string(out))
if strings.Contains(s, "10.0.22") || strings.Contains(s, "10.0.23") {
return "Windows 11"
}
if strings.Contains(s, "10.0.") {
return "Windows 10"
}
return s
}

View File

@@ -25,6 +25,7 @@ const (
type SystemInfo struct {
OS OS `json:"os"`
OSName string `json:"os_name"`
Arch Arch `json:"arch"`
IsWSL bool `json:"is_wsl"`
Shell string `json:"shell"`
@@ -39,6 +40,7 @@ func Detect() SystemInfo {
}
info.IsWSL = detectWSL()
info.OSName = detectOSName(info.OS, info.IsWSL)
info.Shell = detectShell()
info.Terminal = detectTerminal()
info.PackageManager = detectPackageManager(info.OS)
@@ -46,6 +48,33 @@ func Detect() SystemInfo {
return info
}
func detectOSName(os OS, isWSL bool) string {
switch os {
case Linux:
if name := readOSReleaseName(); name != "" {
if isWSL {
return name + " (WSL)"
}
return name
}
if isWSL {
return "Linux (WSL)"
}
return "Linux"
case MacOS:
if v := readMacOSVersion(); v != "" {
return "macOS " + v
}
return "macOS"
case Windows:
if v := readWindowsVersion(); v != "" {
return v
}
return "Windows"
}
return string(os)
}
func detectWSL() bool {
return fileContains("/proc/version", "microsoft") ||
fileContains("/proc/version", "WSL")
@@ -95,8 +124,11 @@ func detectPackageManager(os OS) string {
func (s SystemInfo) String() string {
parts := []string{
"OS: " + string(s.OS),
"Arch: " + string(s.Arch),
}
if s.OSName != "" {
parts = append(parts, "Name: "+s.OSName)
}
parts = append(parts, "Arch: "+string(s.Arch))
if s.IsWSL {
parts = append(parts, "WSL: yes")
}

94
internal/plugins/hooks.go Normal file
View File

@@ -0,0 +1,94 @@
package plugins
import (
"context"
"encoding/json"
"sync"
"github.com/muyue/muyue/internal/agent"
)
type HookType string
const (
BeforeToolCall HookType = "before_tool_call"
AfterToolCall HookType = "after_tool_call"
OnConversationStart HookType = "on_conversation_start"
OnToolError HookType = "on_tool_error"
)
type HookFunc func(ctx context.Context, payload HookPayload) error
type HookPayload struct {
ToolName string `json:"tool_name"`
Arguments json.RawMessage `json:"arguments,omitempty"`
Response *agent.ToolResponse `json:"response,omitempty"`
Error string `json:"error,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
}
type Hook struct {
Type HookType
Plugin string
Priority int
Fn HookFunc
}
type HookRegistry struct {
mu sync.RWMutex
hooks map[HookType][]Hook
}
func NewHookRegistry() *HookRegistry {
return &HookRegistry{
hooks: make(map[HookType][]Hook),
}
}
func (hr *HookRegistry) Register(hookType HookType, pluginName string, priority int, fn HookFunc) {
hr.mu.Lock()
defer hr.mu.Unlock()
h := Hook{
Type: hookType,
Plugin: pluginName,
Priority: priority,
Fn: fn,
}
hr.hooks[hookType] = append(hr.hooks[hookType], h)
for i := len(hr.hooks[hookType]) - 1; i > 0; i-- {
if hr.hooks[hookType][i].Priority < hr.hooks[hookType][i-1].Priority {
hr.hooks[hookType][i], hr.hooks[hookType][i-1] = hr.hooks[hookType][i-1], hr.hooks[hookType][i]
}
}
}
func (hr *HookRegistry) Fire(ctx context.Context, hookType HookType, payload HookPayload) error {
hr.mu.RLock()
hooks := make([]Hook, len(hr.hooks[hookType]))
copy(hooks, hr.hooks[hookType])
hr.mu.RUnlock()
for _, h := range hooks {
if err := h.Fn(ctx, payload); err != nil {
return err
}
}
return nil
}
func (hr *HookRegistry) RemoveByPlugin(pluginName string) {
hr.mu.Lock()
defer hr.mu.Unlock()
for hookType := range hr.hooks {
filtered := make([]Hook, 0, len(hr.hooks[hookType]))
for _, h := range hr.hooks[hookType] {
if h.Plugin != pluginName {
filtered = append(filtered, h)
}
}
hr.hooks[hookType] = filtered
}
}

334
internal/plugins/loader.go Normal file
View File

@@ -0,0 +1,334 @@
package plugins
import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"reflect"
"strings"
"github.com/muyue/muyue/internal/agent"
)
func DiscoverPlugins(paths []string) []*DiscoveredPlugin {
var plugins []*DiscoveredPlugin
for _, p := range paths {
expanded := expandPath(p)
info, err := os.Stat(expanded)
if err != nil {
continue
}
if info.IsDir() {
entries, err := os.ReadDir(expanded)
if err != nil {
continue
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
pluginDir := filepath.Join(expanded, entry.Name())
if dp := scanPluginDir(pluginDir); dp != nil {
plugins = append(plugins, dp)
}
}
}
}
return plugins
}
type DiscoveredPlugin struct {
Name string `json:"name"`
Path string `json:"path"`
Type string `json:"type"`
Valid bool `json:"valid"`
Error string `json:"error,omitempty"`
}
func scanPluginDir(dir string) *DiscoveredPlugin {
name := filepath.Base(dir)
dp := &DiscoveredPlugin{
Name: name,
Path: dir,
}
initPy := filepath.Join(dir, "__init__.py")
mainGo := filepath.Join(dir, "main.go")
manifest := filepath.Join(dir, "plugin.json")
if _, err := os.Stat(manifest); err == nil {
dp.Type = "manifest"
dp.Valid = true
return dp
}
if _, err := os.Stat(mainGo); err == nil {
dp.Type = "go"
dp.Valid = true
return dp
}
if _, err := os.Stat(initPy); err == nil {
dp.Type = "python"
dp.Valid = true
return dp
}
executables := []string{name, name + ".sh"}
for _, exe := range executables {
fullPath := filepath.Join(dir, exe)
if info, err := os.Stat(fullPath); err == nil && !info.IsDir() {
dp.Type = "executable"
dp.Valid = true
dp.Path = fullPath
return dp
}
}
return dp
}
type PluginManifest struct {
Name string `json:"name"`
Version string `json:"version"`
Description string `json:"description"`
Tools []ManifestTool `json:"tools,omitempty"`
Hooks []ManifestHook `json:"hooks,omitempty"`
Command string `json:"command,omitempty"`
Args []string `json:"args,omitempty"`
Env map[string]string `json:"env,omitempty"`
}
type ManifestTool struct {
Name string `json:"name"`
Description string `json:"description"`
Params json.RawMessage `json:"parameters"`
}
type ManifestHook struct {
Type string `json:"type"`
}
func LoadManifest(path string) (*PluginManifest, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read manifest: %w", err)
}
var manifest PluginManifest
if err := json.Unmarshal(data, &manifest); err != nil {
return nil, fmt.Errorf("parse manifest: %w", err)
}
return &manifest, nil
}
func LoadExecutablePlugin(discovered *DiscoveredPlugin) (*Plugin, error) {
if !discovered.Valid {
return nil, fmt.Errorf("invalid plugin: %s", discovered.Name)
}
switch discovered.Type {
case "manifest":
return loadManifestPlugin(discovered)
case "executable":
return loadExecutableAsPlugin(discovered)
default:
return nil, fmt.Errorf("unsupported plugin type: %s", discovered.Type)
}
}
func loadManifestPlugin(dp *DiscoveredPlugin) (*Plugin, error) {
manifestPath := filepath.Join(dp.Path, "plugin.json")
manifest, err := LoadManifest(manifestPath)
if err != nil {
return nil, err
}
p := NewPlugin(manifest.Name, manifest.Version, manifest.Description)
for _, mt := range manifest.Tools {
handler := createExternalHandler(dp.Path, manifest)
td := &ToolDefinition{
Name: mt.Name,
Description: mt.Description,
Params: mt.Params,
Handler: handler,
}
p.AddTool(td)
}
return p, nil
}
func loadExecutableAsPlugin(dp *DiscoveredPlugin) (*Plugin, error) {
p := NewPlugin(dp.Name, "0.0.1", "Executable plugin: "+dp.Name)
paramsSchema, _ := json.Marshal(map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"action": map[string]string{"type": "string", "description": "Action to execute"},
"args": map[string]string{"type": "object", "description": "Arguments for the action"},
},
"required": []string{"action"},
})
td := &ToolDefinition{
Name: dp.Name,
Description: "External plugin tool: " + dp.Name,
Params: paramsSchema,
Handler: createScriptHandler(dp.Path),
}
p.AddTool(td)
return p, nil
}
func createExternalHandler(pluginDir string, manifest *PluginManifest) func(ctx context.Context, raw json.RawMessage) (agent.ToolResponse, error) {
return func(ctx context.Context, raw json.RawMessage) (agent.ToolResponse, error) {
if manifest.Command == "" {
return agent.TextErrorResponse(fmt.Sprintf("no command configured for plugin %s", manifest.Name)), nil
}
cmd := exec.CommandContext(ctx, manifest.Command, manifest.Args...)
cmd.Dir = pluginDir
cmd.Stdin = strings.NewReader(string(raw))
output, err := cmd.CombinedOutput()
if err != nil {
return agent.TextErrorResponse(fmt.Sprintf("plugin execution failed: %v\n%s", err, string(output))), nil
}
return agent.TextResponse(string(output)), nil
}
}
func createScriptHandler(scriptPath string) func(ctx context.Context, raw json.RawMessage) (agent.ToolResponse, error) {
return func(ctx context.Context, raw json.RawMessage) (agent.ToolResponse, error) {
cmd := exec.CommandContext(ctx, scriptPath)
cmd.Stdin = strings.NewReader(string(raw))
output, err := cmd.CombinedOutput()
if err != nil {
return agent.TextErrorResponse(fmt.Sprintf("script failed: %v\n%s", err, string(output))), nil
}
return agent.TextResponse(string(output)), nil
}
}
func DefaultPluginPaths() []string {
home, err := os.UserHomeDir()
if err != nil {
return nil
}
configDir, err := configDir()
if err != nil {
return []string{filepath.Join(home, ".muyue", "plugins")}
}
return []string{
filepath.Join(configDir, "plugins"),
filepath.Join(home, ".muyue", "plugins"),
}
}
func expandPath(p string) string {
if strings.HasPrefix(p, "~/") {
home, _ := os.UserHomeDir()
return filepath.Join(home, p[2:])
}
return p
}
func configDir() (string, error) {
configDir, err := os.UserConfigDir()
if err != nil {
return "", err
}
return filepath.Join(configDir, "muyue"), nil
}
func generatePluginSchema(v interface{}) (json.RawMessage, error) {
t := reflect.TypeOf(v)
if t == nil {
return json.RawMessage(`{"type":"object","properties":{}}`), nil
}
if t.Kind() == reflect.Ptr {
t = t.Elem()
}
if t.Kind() != reflect.Struct {
return json.RawMessage(`{"type":"object","properties":{}}`), nil
}
props := make(map[string]interface{})
required := []string{}
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
if !field.IsExported() {
continue
}
jsonTag := field.Tag.Get("json")
if jsonTag == "-" {
continue
}
jsonName := field.Name
parts := strings.Split(jsonTag, ",")
if parts[0] != "" {
jsonName = parts[0]
}
omitempty := false
for _, part := range parts[1:] {
if part == "omitempty" {
omitempty = true
}
}
desc := field.Tag.Get("description")
prop := map[string]interface{}{"type": goTypeToJSON(field.Type)}
if desc != "" {
prop["description"] = desc
}
props[jsonName] = prop
if !omitempty {
required = append(required, jsonName)
}
}
schema := map[string]interface{}{
"type": "object",
"properties": props,
}
if len(required) > 0 {
schema["required"] = required
}
return json.Marshal(schema)
}
func goTypeToJSON(t reflect.Type) string {
switch t.Kind() {
case reflect.String:
return "string"
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
return "integer"
case reflect.Float32, reflect.Float64:
return "number"
case reflect.Bool:
return "boolean"
case reflect.Slice:
if t.Elem().Kind() == reflect.Uint8 {
return "string"
}
return "array"
case reflect.Map:
return "object"
default:
return "string"
}
}

224
internal/plugins/plugin.go Normal file
View File

@@ -0,0 +1,224 @@
package plugins
import (
"context"
"encoding/json"
"fmt"
"sync"
"github.com/muyue/muyue/internal/agent"
)
type PluginStatus string
const (
StatusEnabled PluginStatus = "enabled"
StatusDisabled PluginStatus = "disabled"
StatusError PluginStatus = "error"
)
type Plugin struct {
name string
version string
description string
status PluginStatus
tools []*agent.ToolDefinition
hooks map[HookType]HookFunc
init func(ctx context.Context, registry *agent.Registry) error
}
func NewPlugin(name, version, description string) *Plugin {
return &Plugin{
name: name,
version: version,
description: description,
status: StatusDisabled,
tools: make([]*agent.ToolDefinition, 0),
hooks: make(map[HookType]HookFunc),
}
}
func (p *Plugin) Name() string { return p.name }
func (p *Plugin) Version() string { return p.version }
func (p *Plugin) Description() string { return p.description }
func (p *Plugin) Status() PluginStatus { return p.status }
func (p *Plugin) AddTool(tool *ToolDefinition) *Plugin {
td := &agent.ToolDefinition{
Name: tool.Name,
Description: tool.Description,
Params: tool.Params,
Handler: tool.Handler,
}
p.tools = append(p.tools, td)
return p
}
func (p *Plugin) AddToolGeneric(params interface{}, name, description string, handler func(ctx context.Context, raw json.RawMessage) (agent.ToolResponse, error)) *Plugin {
paramsSchema, err := generatePluginSchema(params)
if err == nil {
td := &agent.ToolDefinition{
Name: name,
Description: description,
Params: paramsSchema,
Handler: handler,
}
p.tools = append(p.tools, td)
}
return p
}
func (p *Plugin) AddHook(hookType HookType, fn HookFunc) *Plugin {
p.hooks[hookType] = fn
return p
}
func (p *Plugin) SetInit(fn func(ctx context.Context, registry *agent.Registry) error) *Plugin {
p.init = fn
return p
}
type ToolDefinition struct {
Name string
Description string
Params json.RawMessage
Handler func(ctx context.Context, args json.RawMessage) (agent.ToolResponse, error)
}
type PluginInfo struct {
Name string `json:"name"`
Version string `json:"version"`
Description string `json:"description"`
Status PluginStatus `json:"status"`
ToolCount int `json:"tool_count"`
HookTypes []string `json:"hook_types,omitempty"`
Error string `json:"error,omitempty"`
}
type Manager struct {
mu sync.RWMutex
plugins map[string]*Plugin
hooks *HookRegistry
enabled map[string]bool
}
func NewManager(hooks *HookRegistry) *Manager {
return &Manager{
plugins: make(map[string]*Plugin),
hooks: hooks,
enabled: make(map[string]bool),
}
}
func (m *Manager) Register(p *Plugin) error {
m.mu.Lock()
defer m.mu.Unlock()
if _, exists := m.plugins[p.name]; exists {
return fmt.Errorf("plugin %q already registered", p.name)
}
m.plugins[p.name] = p
return nil
}
func (m *Manager) Enable(ctx context.Context, name string, registry *agent.Registry) error {
m.mu.Lock()
defer m.mu.Unlock()
p, ok := m.plugins[name]
if !ok {
return fmt.Errorf("plugin %q not found", name)
}
if p.status == StatusEnabled {
return nil
}
if p.init != nil {
if err := p.init(ctx, registry); err != nil {
p.status = StatusError
return fmt.Errorf("plugin %q init failed: %w", name, err)
}
}
for _, tool := range p.tools {
if err := registry.Register(tool); err != nil {
p.status = StatusError
return fmt.Errorf("plugin %q register tool %q: %w", name, tool.Name, err)
}
}
for hookType, fn := range p.hooks {
m.hooks.Register(hookType, name, 10, fn)
}
p.status = StatusEnabled
m.enabled[name] = true
return nil
}
func (m *Manager) Disable(name string) {
m.mu.Lock()
defer m.mu.Unlock()
p, ok := m.plugins[name]
if !ok {
return
}
if p.status != StatusEnabled {
return
}
m.hooks.RemoveByPlugin(name)
p.status = StatusDisabled
delete(m.enabled, name)
}
func (m *Manager) Get(name string) (*Plugin, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
p, ok := m.plugins[name]
return p, ok
}
func (m *Manager) List() []PluginInfo {
m.mu.RLock()
defer m.mu.RUnlock()
result := make([]PluginInfo, 0, len(m.plugins))
for _, p := range m.plugins {
info := PluginInfo{
Name: p.name,
Version: p.version,
Description: p.description,
Status: p.status,
ToolCount: len(p.tools),
}
for ht := range p.hooks {
info.HookTypes = append(info.HookTypes, string(ht))
}
result = append(result, info)
}
return result
}
func (m *Manager) EnabledNames() []string {
m.mu.RLock()
defer m.mu.RUnlock()
names := make([]string, 0, len(m.enabled))
for name := range m.enabled {
names = append(names, name)
}
return names
}
func (m *Manager) EnableFromConfig(ctx context.Context, enabledList []string, registry *agent.Registry) {
for _, name := range enabledList {
if err := m.Enable(ctx, name, registry); err != nil {
_ = err
}
}
}

174
internal/rag/chunker.go Normal file
View File

@@ -0,0 +1,174 @@
package rag
import (
"strings"
"unicode/utf8"
)
type Chunk struct {
ID int `json:"id"`
Content string `json:"content"`
StartPos int `json:"start_pos"`
EndPos int `json:"end_pos"`
Metadata string `json:"metadata,omitempty"`
}
func ChunkText(text string, maxTokens int) []Chunk {
if maxTokens <= 0 {
maxTokens = 500
}
maxChars := maxTokens * 4
if maxChars < 200 {
maxChars = 200
}
lines := strings.Split(text, "\n")
var chunks []Chunk
var current strings.Builder
chunkID := 0
startPos := 0
currentPos := 0
for _, line := range lines {
lineLen := utf8.RuneCountInString(line) + 1
if current.Len() > 0 && utf8.RuneCountInString(current.String())+lineLen > maxChars {
chunks = append(chunks, Chunk{
ID: chunkID,
Content: strings.TrimSpace(current.String()),
StartPos: startPos,
EndPos: currentPos,
})
chunkID++
startPos = currentPos
current.Reset()
}
current.WriteString(line)
current.WriteString("\n")
currentPos += lineLen
}
if current.Len() > 0 {
chunks = append(chunks, Chunk{
ID: chunkID,
Content: strings.TrimSpace(current.String()),
StartPos: startPos,
EndPos: currentPos,
})
}
return chunks
}
func ChunkMarkdown(text string, maxTokens int) []Chunk {
if maxTokens <= 0 {
maxTokens = 500
}
maxChars := maxTokens * 4
sections := splitMarkdownSections(text)
var chunks []Chunk
chunkID := 0
pos := 0
for _, section := range sections {
if utf8.RuneCountInString(section) > maxChars {
subChunks := ChunkText(section, maxTokens)
for i := range subChunks {
subChunks[i].ID = chunkID
subChunks[i].StartPos += pos
subChunks[i].EndPos += pos
chunkID++
}
chunks = append(chunks, subChunks...)
} else {
chunks = append(chunks, Chunk{
ID: chunkID,
Content: strings.TrimSpace(section),
StartPos: pos,
EndPos: pos + utf8.RuneCountInString(section),
})
chunkID++
}
pos += utf8.RuneCountInString(section)
}
return chunks
}
func splitMarkdownSections(text string) []string {
var sections []string
var current strings.Builder
lines := strings.Split(text, "\n")
for _, line := range lines {
if strings.HasPrefix(line, "#") || strings.HasPrefix(line, "##") || strings.HasPrefix(line, "###") {
if current.Len() > 0 {
sections = append(sections, current.String())
current.Reset()
}
}
current.WriteString(line)
current.WriteString("\n")
}
if current.Len() > 0 {
sections = append(sections, current.String())
}
if len(sections) == 0 && text != "" {
sections = []string{text}
}
return sections
}
func ChunkCode(code string, lang string, maxTokens int) []Chunk {
if maxTokens <= 0 {
maxTokens = 300
}
maxChars := maxTokens * 4
var chunks []Chunk
chunkID := 0
pos := 0
lines := strings.Split(code, "\n")
var current strings.Builder
currentLines := 0
for _, line := range lines {
lineLen := utf8.RuneCountInString(line) + 1
if current.Len() > 0 && (utf8.RuneCountInString(current.String())+lineLen > maxChars || currentLines > 50) {
chunks = append(chunks, Chunk{
ID: chunkID,
Content: strings.TrimSpace(current.String()),
StartPos: pos,
EndPos: pos + utf8.RuneCountInString(current.String()),
Metadata: lang,
})
chunkID++
pos += utf8.RuneCountInString(current.String())
current.Reset()
currentLines = 0
}
current.WriteString(line)
current.WriteString("\n")
currentLines++
}
if current.Len() > 0 {
chunks = append(chunks, Chunk{
ID: chunkID,
Content: strings.TrimSpace(current.String()),
StartPos: pos,
EndPos: pos + utf8.RuneCountInString(current.String()),
Metadata: lang,
})
}
return chunks
}

113
internal/rag/embed.go Normal file
View File

@@ -0,0 +1,113 @@
package rag
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
type EmbeddingClient struct {
apiKey string
baseURL string
client *http.Client
}
func NewEmbeddingClient(apiKey, baseURL string) *EmbeddingClient {
if baseURL == "" {
baseURL = "https://api.openai.com/v1"
}
return &EmbeddingClient{
apiKey: apiKey,
baseURL: strings.TrimRight(baseURL, "/"),
client: &http.Client{Timeout: 30 * time.Second},
}
}
type embeddingRequest struct {
Model string `json:"model"`
Input []string `json:"input"`
}
type embeddingResponse struct {
Data []struct {
Embedding []float64 `json:"embedding"`
Index int `json:"index"`
} `json:"data"`
Usage struct {
TotalTokens int `json:"total_tokens"`
} `json:"usage"`
}
func (c *EmbeddingClient) Embed(texts []string, model string) ([][]float64, error) {
if len(texts) == 0 {
return nil, nil
}
if model == "" {
model = "text-embedding-3-small"
}
body := embeddingRequest{
Model: model,
Input: texts,
}
bodyBytes, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("marshal embedding request: %w", err)
}
url := c.baseURL + "/embeddings"
req, err := http.NewRequest("POST", url, bytes.NewReader(bodyBytes))
if err != nil {
return nil, fmt.Errorf("create embedding request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
if c.apiKey != "" {
req.Header.Set("Authorization", "Bearer "+c.apiKey)
}
resp, err := c.client.Do(req)
if err != nil {
return nil, fmt.Errorf("send embedding request: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read embedding response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("embedding API error (%d): %s", resp.StatusCode, string(respBody))
}
var embResp embeddingResponse
if err := json.Unmarshal(respBody, &embResp); err != nil {
return nil, fmt.Errorf("parse embedding response: %w", err)
}
result := make([][]float64, len(texts))
for _, data := range embResp.Data {
if data.Index < len(result) {
result[data.Index] = data.Embedding
}
}
return result, nil
}
func (c *EmbeddingClient) EmbedSingle(text, model string) ([]float64, error) {
results, err := c.Embed([]string{text}, model)
if err != nil {
return nil, err
}
if len(results) == 0 {
return nil, fmt.Errorf("no embedding returned")
}
return results[0], nil
}

79
internal/rag/inject.go Normal file
View File

@@ -0,0 +1,79 @@
package rag
import (
"fmt"
"strings"
)
func BuildContextBlock(results []SearchResult, maxTokens int) string {
if len(results) == 0 {
return ""
}
if maxTokens <= 0 {
maxTokens = 4000
}
maxChars := maxTokens * 4
var b strings.Builder
b.WriteString("<rag_context>\n")
b.WriteString("The following context was retrieved from indexed documents to help answer the user's question.\n\n")
for i, r := range results {
entry := fmt.Sprintf("--- Source: %s (relevance: %.2f) ---\n%s\n\n", r.DocumentName, r.Score, r.Content)
if b.Len()+len(entry) > maxChars {
break
}
b.WriteString(entry)
_ = i
}
b.WriteString("</rag_context>\n")
return b.String()
}
func ExtractRAGQueries(message string) (queries []string, cleaned string) {
cleaned = message
parts := strings.Split(message, "#")
if len(parts) <= 1 {
return nil, message
}
var queryParts []string
var textParts []string
for i, part := range parts {
if i == 0 {
textParts = append(textParts, part)
continue
}
part = strings.TrimSpace(part)
if part == "" {
continue
}
firstSpace := strings.IndexByte(part, ' ')
newline := strings.IndexByte(part, '\n')
end := len(part)
if firstSpace > 0 && (newline < 0 || firstSpace < newline) {
end = firstSpace
} else if newline > 0 {
end = newline
}
query := strings.TrimSpace(part[:end])
if query != "" {
queryParts = append(queryParts, query)
}
if end < len(part) {
textParts = append(textParts, part[end:])
}
}
if len(queryParts) > 0 {
cleaned = strings.Join(textParts, " ")
cleaned = strings.TrimSpace(cleaned)
}
return queryParts, cleaned
}

343
internal/rag/store.go Normal file
View File

@@ -0,0 +1,343 @@
package rag
import (
"database/sql"
"encoding/json"
"fmt"
"math"
"os"
"path/filepath"
"strings"
"sync"
"time"
_ "modernc.org/sqlite"
)
type Document struct {
ID string `json:"id"`
Name string `json:"name"`
Path string `json:"path"`
Type string `json:"type"`
Chunks int `json:"chunks"`
IndexedAt time.Time `json:"indexed_at"`
Size int64 `json:"size"`
}
type ChunkRecord struct {
ID int64 `json:"id"`
DocumentID string `json:"document_id"`
Content string `json:"content"`
Embedding []float64 `json:"embedding,omitempty"`
StartPos int `json:"start_pos"`
EndPos int `json:"end_pos"`
Metadata string `json:"metadata,omitempty"`
}
type Store struct {
mu sync.RWMutex
db *sql.DB
dir string
}
func NewStore(configDir string) (*Store, error) {
ragDir := filepath.Join(configDir, "rag")
if err := os.MkdirAll(ragDir, 0755); err != nil {
return nil, fmt.Errorf("creating rag dir: %w", err)
}
dbPath := filepath.Join(ragDir, "rag.db")
db, err := sql.Open("sqlite", dbPath)
if err != nil {
return nil, fmt.Errorf("opening rag db: %w", err)
}
s := &Store{db: db, dir: ragDir}
if err := s.migrate(); err != nil {
db.Close()
return nil, fmt.Errorf("migrating rag db: %w", err)
}
return s, nil
}
func (s *Store) migrate() error {
_, err := s.db.Exec(`
CREATE TABLE IF NOT EXISTS documents (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
path TEXT NOT NULL DEFAULT '',
type TEXT NOT NULL DEFAULT 'text',
chunks INTEGER NOT NULL DEFAULT 0,
indexed_at DATETIME NOT NULL,
size INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS chunks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
document_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
content TEXT NOT NULL,
embedding BLOB,
start_pos INTEGER NOT NULL DEFAULT 0,
end_pos INTEGER NOT NULL DEFAULT 0,
metadata TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_chunks_document ON chunks(document_id);
`)
return err
}
func (s *Store) StoreDocument(doc Document, chunks []ChunkRecord) error {
s.mu.Lock()
defer s.mu.Unlock()
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
_, err = tx.Exec(`INSERT OR REPLACE INTO documents (id, name, path, type, chunks, indexed_at, size) VALUES (?, ?, ?, ?, ?, ?, ?)`,
doc.ID, doc.Name, doc.Path, doc.Type, doc.Chunks, doc.IndexedAt, doc.Size)
if err != nil {
return fmt.Errorf("insert document: %w", err)
}
stmt, err := tx.Prepare(`INSERT INTO chunks (document_id, content, embedding, start_pos, end_pos, metadata) VALUES (?, ?, ?, ?, ?, ?)`)
if err != nil {
return fmt.Errorf("prepare chunk insert: %w", err)
}
defer stmt.Close()
for _, chunk := range chunks {
var embBytes []byte
if len(chunk.Embedding) > 0 {
embBytes, err = json.Marshal(chunk.Embedding)
if err != nil {
return fmt.Errorf("marshal embedding: %w", err)
}
}
_, err = stmt.Exec(chunk.DocumentID, chunk.Content, embBytes, chunk.StartPos, chunk.EndPos, chunk.Metadata)
if err != nil {
return fmt.Errorf("insert chunk: %w", err)
}
}
return tx.Commit()
}
func (s *Store) ListDocuments() ([]Document, error) {
s.mu.RLock()
defer s.mu.RUnlock()
rows, err := s.db.Query(`SELECT id, name, path, type, chunks, indexed_at, size FROM documents ORDER BY indexed_at DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
var docs []Document
for rows.Next() {
var doc Document
if err := rows.Scan(&doc.ID, &doc.Name, &doc.Path, &doc.Type, &doc.Chunks, &doc.IndexedAt, &doc.Size); err != nil {
return nil, err
}
docs = append(docs, doc)
}
return docs, nil
}
func (s *Store) DeleteDocument(id string) error {
s.mu.Lock()
defer s.mu.Unlock()
_, err := s.db.Exec(`DELETE FROM documents WHERE id = ?`, id)
return err
}
type SearchResult struct {
ChunkID int64 `json:"chunk_id"`
DocumentID string `json:"document_id"`
DocumentName string `json:"document_name"`
Content string `json:"content"`
Score float64 `json:"score"`
Metadata string `json:"metadata,omitempty"`
}
func (s *Store) Search(queryEmbedding []float64, limit int) ([]SearchResult, error) {
if limit <= 0 {
limit = 5
}
s.mu.RLock()
defer s.mu.RUnlock()
rows, err := s.db.Query(`SELECT c.id, c.document_id, c.content, c.embedding, c.metadata, d.name FROM chunks c JOIN documents d ON c.document_id = d.id WHERE c.embedding IS NOT NULL`)
if err != nil {
return nil, err
}
defer rows.Close()
type scored struct {
result SearchResult
score float64
}
var results []scored
for rows.Next() {
var id int64
var docID, content, metadata, docName string
var embBytes []byte
if err := rows.Scan(&id, &docID, &content, &embBytes, &metadata, &docName); err != nil {
continue
}
var embedding []float64
if err := json.Unmarshal(embBytes, &embedding); err != nil {
continue
}
score := cosineSimilarity(queryEmbedding, embedding)
results = append(results, scored{
result: SearchResult{
ChunkID: id,
DocumentID: docID,
DocumentName: docName,
Content: content,
Metadata: metadata,
},
score: score,
})
}
for i := 0; i < len(results); i++ {
for j := i + 1; j < len(results); j++ {
if results[j].score > results[i].score {
results[i], results[j] = results[j], results[i]
}
}
}
if len(results) > limit {
results = results[:limit]
}
out := make([]SearchResult, len(results))
for i, r := range results {
r.result.Score = r.score
out[i] = r.result
}
return out, nil
}
func (s *Store) SearchKeyword(query string, limit int) ([]SearchResult, error) {
if limit <= 0 {
limit = 5
}
s.mu.RLock()
defer s.mu.RUnlock()
words := strings.Fields(strings.ToLower(query))
if len(words) == 0 {
return nil, nil
}
rows, err := s.db.Query(`SELECT c.id, c.document_id, c.content, c.metadata, d.name FROM chunks c JOIN documents d ON c.document_id = d.id`)
if err != nil {
return nil, err
}
defer rows.Close()
type scored struct {
result SearchResult
score float64
}
var results []scored
for rows.Next() {
var id int64
var docID, content, metadata, docName string
if err := rows.Scan(&id, &docID, &content, &metadata, &docName); err != nil {
continue
}
lower := strings.ToLower(content)
var score float64
for _, word := range words {
count := strings.Count(lower, word)
if count > 0 {
score += float64(count) / float64(len(strings.Fields(lower)))
}
}
if score > 0 {
results = append(results, scored{
result: SearchResult{
ChunkID: id,
DocumentID: docID,
DocumentName: docName,
Content: content,
Metadata: metadata,
},
score: score,
})
}
}
for i := 0; i < len(results); i++ {
for j := i + 1; j < len(results); j++ {
if results[j].score > results[i].score {
results[i], results[j] = results[j], results[i]
}
}
}
if len(results) > limit {
results = results[:limit]
}
out := make([]SearchResult, len(results))
for i, r := range results {
r.result.Score = r.score
out[i] = r.result
}
return out, nil
}
func (s *Store) Status() (map[string]interface{}, error) {
s.mu.RLock()
defer s.mu.RUnlock()
var docCount, chunkCount int
s.db.QueryRow(`SELECT COUNT(*) FROM documents`).Scan(&docCount)
s.db.QueryRow(`SELECT COUNT(*) FROM chunks`).Scan(&chunkCount)
var withEmb int
s.db.QueryRow(`SELECT COUNT(*) FROM chunks WHERE embedding IS NOT NULL`).Scan(&withEmb)
return map[string]interface{}{
"documents": docCount,
"chunks": chunkCount,
"chunks_embedded": withEmb,
"storage_path": s.dir,
}, nil
}
func (s *Store) Close() error {
return s.db.Close()
}
func cosineSimilarity(a, b []float64) float64 {
if len(a) != len(b) {
return 0
}
var dot, normA, normB float64
for i := range a {
dot += a[i] * b[i]
normA += a[i] * a[i]
normB += b[i] * b[i]
}
if normA == 0 || normB == 0 {
return 0
}
return dot / (math.Sqrt(normA) * math.Sqrt(normB))
}

View File

@@ -0,0 +1,177 @@
package skills
import (
"testing"
"time"
)
func TestCheckActivationNoConditions(t *testing.T) {
skill := &Skill{
Name: "test-skill",
Description: "A test skill",
}
result := CheckActivation(skill, []string{"terminal"})
if !result.Active {
t.Error("expected skill with no conditions to be active")
}
}
func TestCheckActivationRequiresTools(t *testing.T) {
skill := &Skill{
Name: "docker-setup",
RequiresTools: []string{"terminal", "docker"},
}
result := CheckActivation(skill, []string{"terminal", "docker"})
if !result.Active {
t.Error("expected skill to be active when all required tools present")
}
result = CheckActivation(skill, []string{"terminal"})
if result.Active {
t.Error("expected skill to be inactive when required tool missing")
}
}
func TestCheckActivationFallbackForTools(t *testing.T) {
skill := &Skill{
Name: "basic-review",
FallbackForTools: []string{"crush_run", "claude_run"},
}
result := CheckActivation(skill, []string{"terminal"})
if !result.Active {
t.Error("expected fallback skill to activate when primary tools absent")
}
result = CheckActivation(skill, []string{"crush_run", "claude_run"})
if result.Active {
t.Error("expected fallback skill to stay inactive when primary tools present")
}
}
func TestFilterActiveSkills(t *testing.T) {
skills := []Skill{
{Name: "basic", Description: "basic"},
{Name: "needs-docker", RequiresTools: []string{"docker"}},
{Name: "fallback-review", FallbackForTools: []string{"crush_run"}},
}
active := FilterActiveSkills(skills, []string{"terminal"})
if len(active) != 2 {
t.Errorf("expected 2 active skills, got %d", len(active))
}
}
func TestGroupByReadiness(t *testing.T) {
skills := []Skill{
{Name: "basic", Description: "basic"},
{Name: "needs-docker", RequiresTools: []string{"docker"}},
}
available, needsSetup, unsupported := GroupByReadiness(skills, []string{})
if len(available) != 1 {
t.Errorf("expected 1 available, got %d", len(available))
}
if len(unsupported) != 1 {
t.Errorf("expected 1 unsupported, got %d", len(unsupported))
}
_ = needsSetup
}
func TestAnalyzeConversation(t *testing.T) {
snippets := []ConversationSnippet{
{Role: "assistant", Content: "go test ./... -race", Timestamp: time.Now()},
{Role: "assistant", Content: "go test ./... -race -cover", Timestamp: time.Now()},
{Role: "assistant", Content: "go test ./internal/... -v", Timestamp: time.Now()},
}
proposals := AnalyzeConversation(snippets)
if len(proposals) == 0 {
t.Error("expected at least one proposal from recurring patterns")
}
for _, p := range proposals {
if p.Confidence <= 0 {
t.Error("expected positive confidence")
}
if p.CreatedFrom != "conversation" {
t.Errorf("expected created_from=conversation, got %s", p.CreatedFrom)
}
}
}
func TestCategorize(t *testing.T) {
tests := []struct {
pattern string
want string
}{
{"go test", "testing"},
{"docker build", "devops"},
{"git commit", "workflow"},
{"npm test", "testing"},
{"make", "build"},
{"unknown", "general"},
}
for _, tt := range tests {
got := categorize(tt.pattern)
if got != tt.want {
t.Errorf("categorize(%q) = %q, want %q", tt.pattern, got, tt.want)
}
}
}
func TestImproverAnalyze(t *testing.T) {
improver, err := NewSkillImprover()
if err != nil {
t.Fatalf("new improver: %v", err)
}
skill := &Skill{
Name: "test-skill",
Description: "A test skill",
Content: "# Test\n\nSome basic content without structure.",
}
suggestions, err := improver.Analyze(skill, "")
if err != nil {
t.Fatalf("analyze: %v", err)
}
if len(suggestions) == 0 {
t.Error("expected improvement suggestions for minimal skill")
}
}
func TestImproverAnalyzeComplete(t *testing.T) {
improver, _ := NewSkillImprover()
skill := &Skill{
Name: "complete-skill",
Description: "A well-structured skill",
Content: `# Complete Skill
## Steps
1. Do step one
2. Do step two
## Error Handling
- Handle error A
- Handle error B
## When to use
Use this skill when doing X.
`,
Tags: []string{"testing", "go"},
}
suggestions, _ := improver.Analyze(skill, "testing go code")
if len(suggestions) > 2 {
t.Errorf("expected few suggestions for complete skill, got %d", len(suggestions))
}
}

View File

@@ -0,0 +1,282 @@
package skills
import (
"crypto/sha256"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
type PatternMatch struct {
Pattern string
Count int
LastSeen time.Time
ExampleText string
}
type AutoCreateProposal struct {
Name string
Description string
SuggestedTags []string
Category string
Patterns []PatternMatch
Confidence float64
CreatedFrom string
}
type ConversationSnippet struct {
Role string `json:"role"`
Content string `json:"content"`
Timestamp time.Time `json:"timestamp"`
}
func AnalyzeConversation(snippets []ConversationSnippet) []AutoCreateProposal {
patterns := detectPatterns(snippets)
var proposals []AutoCreateProposal
for _, p := range patterns {
if p.Count < 3 {
continue
}
name := generateSkillName(p.Pattern)
proposal := AutoCreateProposal{
Name: name,
Description: fmt.Sprintf("Auto-detected skill for recurring pattern: %s", p.Pattern),
SuggestedTags: extractTags(p.Pattern),
Category: categorize(p.Pattern),
Patterns: []PatternMatch{p},
Confidence: computeConfidence(p),
CreatedFrom: "conversation",
}
proposals = append(proposals, proposal)
}
return proposals
}
func CreateFromProposal(proposal *AutoCreateProposal) (*Skill, error) {
skill := &Skill{
Name: proposal.Name,
Description: proposal.Description,
Author: "muyue-auto",
Version: "0.1.0",
Tags: proposal.SuggestedTags,
Category: proposal.Category,
Target: "both",
CreatedFrom: proposal.CreatedFrom,
AutoImprove: true,
Content: buildAutoSkillContent(proposal),
}
return skill, Create(skill)
}
func LoadProposals() ([]AutoCreateProposal, error) {
dir, err := proposalsDir()
if err != nil {
return nil, err
}
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
var proposals []AutoCreateProposal
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
continue
}
data, err := os.ReadFile(filepath.Join(dir, e.Name()))
if err != nil {
continue
}
var p AutoCreateProposal
if err := json.Unmarshal(data, &p); err != nil {
continue
}
proposals = append(proposals, p)
}
return proposals, nil
}
func SaveProposal(proposal *AutoCreateProposal) error {
dir, err := proposalsDir()
if err != nil {
return err
}
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
data, err := json.MarshalIndent(proposal, "", " ")
if err != nil {
return err
}
path := filepath.Join(dir, proposal.Name+".json")
return os.WriteFile(path, data, 0644)
}
func DeleteProposal(name string) error {
dir, err := proposalsDir()
if err != nil {
return err
}
path := filepath.Join(dir, name+".json")
return os.Remove(path)
}
func proposalsDir() (string, error) {
dir, err := SkillsDir()
if err != nil {
return "", err
}
return filepath.Join(filepath.Dir(dir), ".muyue", "proposals"), nil
}
func detectPatterns(snippets []ConversationSnippet) []PatternMatch {
commandPatterns := make(map[string]*PatternMatch)
for _, s := range snippets {
if s.Role != "assistant" {
continue
}
lines := strings.Split(s.Content, "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if isCommandPattern(line) {
key := extractPatternKey(line)
if key == "" {
continue
}
if existing, ok := commandPatterns[key]; ok {
existing.Count++
if s.Timestamp.After(existing.LastSeen) {
existing.LastSeen = s.Timestamp
existing.ExampleText = truncate(line, 200)
}
} else {
commandPatterns[key] = &PatternMatch{
Pattern: key,
Count: 1,
LastSeen: s.Timestamp,
ExampleText: truncate(line, 200),
}
}
}
}
}
var patterns []PatternMatch
for _, p := range commandPatterns {
patterns = append(patterns, *p)
}
return patterns
}
func isCommandPattern(line string) bool {
toolPrefixes := []string{"go test", "go build", "go run", "npm test", "npm run",
"docker build", "docker run", "git commit", "git push", "kubectl",
"cargo test", "cargo build", "pytest", "make "}
for _, prefix := range toolPrefixes {
if strings.HasPrefix(line, prefix) {
return true
}
}
return false
}
func extractPatternKey(line string) string {
parts := strings.Fields(line)
if len(parts) < 2 {
return ""
}
if len(parts) >= 3 && (parts[0] == "go" || parts[0] == "npm" || parts[0] == "cargo" || parts[0] == "git" || parts[0] == "docker") {
return parts[0] + " " + parts[1]
}
return parts[0]
}
func generateSkillName(pattern string) string {
name := strings.ReplaceAll(pattern, " ", "-")
name = strings.ToLower(name)
if len(name) > 30 {
name = name[:30]
}
h := sha256.Sum256([]byte(pattern))
return fmt.Sprintf("auto-%s-%x", name, h[:4])
}
func extractTags(pattern string) []string {
var tags []string
parts := strings.Fields(pattern)
for _, p := range parts {
if len(p) > 2 {
tags = append(tags, strings.ToLower(p))
}
}
return tags
}
func categorize(pattern string) string {
categories := map[string]string{
"go test": "testing", "go build": "build", "go run": "build",
"npm test": "testing", "npm run": "build",
"docker build": "devops", "docker run": "devops",
"git commit": "workflow", "git push": "workflow",
"kubectl": "devops", "cargo test": "testing",
"cargo build": "build", "pytest": "testing",
"make": "build",
}
for prefix, cat := range categories {
if strings.HasPrefix(pattern, prefix) {
return cat
}
}
return "general"
}
func computeConfidence(p PatternMatch) float64 {
confidence := 0.3
confidence += float64(p.Count) * 0.1
if confidence > 0.95 {
confidence = 0.95
}
return confidence
}
func buildAutoSkillContent(proposal *AutoCreateProposal) string {
var b strings.Builder
b.WriteString(fmt.Sprintf("# %s\n\n", strings.Title(proposal.Name)))
b.WriteString("Auto-generated skill based on recurring patterns detected in conversations.\n\n")
b.WriteString("## Activation\n\n")
b.WriteString("This skill activates when the following patterns are detected:\n\n")
for _, p := range proposal.Patterns {
b.WriteString(fmt.Sprintf("- `%s` (seen %d times)\n", p.Pattern, p.Count))
}
b.WriteString("\n## Instructions\n\n")
b.WriteString("1. Detect the pattern context from the user request\n")
b.WriteString("2. Apply the standard workflow for this pattern\n")
b.WriteString("3. Handle common errors and edge cases\n")
b.WriteString("4. Verify the result\n\n")
b.WriteString("## Error Handling\n\n")
b.WriteString("- If a command fails, check for missing dependencies\n")
b.WriteString("- Suggest alternative approaches when the standard pattern doesn't fit\n")
return b.String()
}
func truncate(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
return s[:maxLen]
}

View File

@@ -0,0 +1,125 @@
package skills
import (
"strings"
)
type ActivationResult struct {
Active bool
Reason string
Skill *Skill
}
func CheckActivation(skill *Skill, availableTools []string) ActivationResult {
if len(skill.RequiresTools) == 0 && len(skill.FallbackForTools) == 0 {
return ActivationResult{
Active: true,
Reason: "no activation conditions",
Skill: skill,
}
}
toolSet := make(map[string]bool, len(availableTools))
for _, t := range availableTools {
toolSet[strings.ToLower(t)] = true
}
if len(skill.RequiresTools) > 0 {
for _, req := range skill.RequiresTools {
if !toolSet[strings.ToLower(req)] {
return ActivationResult{
Active: false,
Reason: "missing required tool: " + req,
Skill: skill,
}
}
}
return ActivationResult{
Active: true,
Reason: "all required tools available",
Skill: skill,
}
}
if len(skill.FallbackForTools) > 0 {
allPresent := true
for _, fb := range skill.FallbackForTools {
if !toolSet[strings.ToLower(fb)] {
allPresent = false
break
}
}
if allPresent {
return ActivationResult{
Active: false,
Reason: "primary tools available, fallback not needed",
Skill: skill,
}
}
return ActivationResult{
Active: true,
Reason: "primary tools absent, activating as fallback",
Skill: skill,
}
}
return ActivationResult{Active: true, Skill: skill}
}
func FilterActiveSkills(skillsList []Skill, availableTools []string) []Skill {
var active []Skill
for i := range skillsList {
result := CheckActivation(&skillsList[i], availableTools)
if result.Active {
active = append(active, skillsList[i])
}
}
return active
}
func GroupByReadiness(skillsList []Skill, availableTools []string) (available, needsSetup, unsupported []Skill) {
toolSet := make(map[string]bool, len(availableTools))
for _, t := range availableTools {
toolSet[strings.ToLower(t)] = true
}
for i := range skillsList {
s := &skillsList[i]
if len(s.RequiresTools) == 0 && len(s.FallbackForTools) == 0 {
missing := CheckDependencies(s)
if len(missing) == 0 {
available = append(available, *s)
} else {
needsSetup = append(needsSetup, *s)
}
continue
}
allReqMet := true
for _, req := range s.RequiresTools {
if !toolSet[strings.ToLower(req)] {
allReqMet = false
break
}
}
if allReqMet && len(s.RequiresTools) > 0 {
available = append(available, *s)
} else if !allReqMet && len(s.RequiresTools) > 0 {
unsupported = append(unsupported, *s)
}
if len(s.FallbackForTools) > 0 {
anyMissing := false
for _, fb := range s.FallbackForTools {
if !toolSet[strings.ToLower(fb)] {
anyMissing = true
break
}
}
if anyMissing {
available = append(available, *s)
}
}
}
return
}

267
internal/skills/improver.go Normal file
View File

@@ -0,0 +1,267 @@
package skills
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
type ImprovementSuggestion struct {
Type string `json:"type"`
Section string `json:"section"`
Current string `json:"current"`
Suggested string `json:"suggested"`
Reason string `json:"reason"`
Confidence float64 `json:"confidence"`
CreatedAt time.Time `json:"created_at"`
}
type ImprovementHistory struct {
SkillName string `json:"skill_name"`
Version string `json:"version"`
Improvements []ImprovementSuggestion `json:"improvements"`
AppliedAt time.Time `json:"applied_at"`
Result string `json:"result"`
}
type SkillImprover struct {
historyDir string
}
func NewSkillImprover() (*SkillImprover, error) {
dir, err := improvementHistoryDir()
if err != nil {
return nil, err
}
return &SkillImprover{historyDir: dir}, nil
}
func (si *SkillImprover) Analyze(skill *Skill, conversationContext string) ([]ImprovementSuggestion, error) {
var suggestions []ImprovementSuggestion
if skill.Content == "" {
return nil, fmt.Errorf("skill has no content to analyze")
}
suggestions = append(suggestions, si.checkMissingSections(skill)...)
suggestions = append(suggestions, si.checkErrorHandling(skill)...)
suggestions = append(suggestions, si.checkStepCompleteness(skill)...)
suggestions = append(suggestions, si.analyzeContextRelevance(skill, conversationContext)...)
return suggestions, nil
}
func (si *SkillImprover) ApplyImprovement(skillName string, suggestion ImprovementSuggestion) error {
skill, err := Get(skillName)
if err != nil {
return fmt.Errorf("get skill: %w", err)
}
switch suggestion.Section {
case "content":
skill.Content = applyContentSuggestion(skill.Content, suggestion)
case "description":
skill.Description = suggestion.Suggested
default:
skill.Content = applyContentSuggestion(skill.Content, suggestion)
}
now := time.Now()
skill.LastImprovedAt = &now
skill.ImprovementCount++
if err := Update(skill); err != nil {
return fmt.Errorf("update skill: %w", err)
}
history := ImprovementHistory{
SkillName: skillName,
Version: skill.Version,
Improvements: []ImprovementSuggestion{suggestion},
AppliedAt: now,
Result: "applied",
}
return si.saveHistory(&history)
}
func (si *SkillImprover) GetHistory(skillName string) ([]ImprovementHistory, error) {
if err := os.MkdirAll(si.historyDir, 0755); err != nil {
return nil, err
}
entries, err := os.ReadDir(si.historyDir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
var histories []ImprovementHistory
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
continue
}
if skillName != "" && !strings.HasPrefix(e.Name(), skillName+"_") {
continue
}
data, err := os.ReadFile(filepath.Join(si.historyDir, e.Name()))
if err != nil {
continue
}
var h ImprovementHistory
if err := json.Unmarshal(data, &h); err != nil {
continue
}
histories = append(histories, h)
}
return histories, nil
}
func (si *SkillImprover) checkMissingSections(skill *Skill) []ImprovementSuggestion {
var suggestions []ImprovementSuggestion
content := strings.ToLower(skill.Content)
requiredSections := []struct {
keyword string
label string
}{
{"error handling", "Error Handling"},
{"steps", "Steps"},
{"when to", "Activation"},
}
for _, req := range requiredSections {
if !strings.Contains(content, req.keyword) {
suggestions = append(suggestions, ImprovementSuggestion{
Type: "missing_section",
Section: "content",
Current: "",
Suggested: fmt.Sprintf("Add a '%s' section", req.label),
Reason: fmt.Sprintf("Skill is missing a '%s' section which is important for completeness", req.label),
Confidence: 0.8,
CreatedAt: time.Now(),
})
}
}
return suggestions
}
func (si *SkillImprover) checkErrorHandling(skill *Skill) []ImprovementSuggestion {
var suggestions []ImprovementSuggestion
content := strings.ToLower(skill.Content)
if !strings.Contains(content, "error") && !strings.Contains(content, "fail") {
suggestions = append(suggestions, ImprovementSuggestion{
Type: "missing_error_handling",
Section: "content",
Current: "",
Suggested: "Add error handling guidance covering common failure modes",
Reason: "Skill lacks error handling guidance, which may lead to poor user experience when things go wrong",
Confidence: 0.85,
CreatedAt: time.Now(),
})
}
return suggestions
}
func (si *SkillImprover) checkStepCompleteness(skill *Skill) []ImprovementSuggestion {
var suggestions []ImprovementSuggestion
lines := strings.Split(skill.Content, "\n")
stepCount := 0
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "1.") || strings.HasPrefix(trimmed, "Step 1") {
stepCount++
}
}
if stepCount == 0 && len(lines) > 10 {
suggestions = append(suggestions, ImprovementSuggestion{
Type: "no_clear_steps",
Section: "content",
Current: "",
Suggested: "Add numbered step-by-step instructions for clarity",
Reason: "Long skill content without clear step-by-step structure can be hard to follow",
Confidence: 0.7,
CreatedAt: time.Now(),
})
}
return suggestions
}
func (si *SkillImprover) analyzeContextRelevance(skill *Skill, context string) []ImprovementSuggestion {
if context == "" {
return nil
}
var suggestions []ImprovementSuggestion
contextLower := strings.ToLower(context)
tags := skill.Tags
relevance := 0
for _, tag := range tags {
if strings.Contains(contextLower, strings.ToLower(tag)) {
relevance++
}
}
if len(tags) > 0 && relevance == 0 && skill.Category != "" && !strings.Contains(contextLower, strings.ToLower(skill.Category)) {
suggestions = append(suggestions, ImprovementSuggestion{
Type: "tag_relevance",
Section: "tags",
Current: strings.Join(tags, ", "),
Suggested: "Review tags for better context matching",
Reason: "Current tags do not match recent conversation context, suggesting tags may need updating",
Confidence: 0.5,
CreatedAt: time.Now(),
})
}
return suggestions
}
func applyContentSuggestion(content string, suggestion ImprovementSuggestion) string {
switch suggestion.Type {
case "missing_section":
return content + "\n\n## " + strings.Title(suggestion.Type) + "\n\n" + suggestion.Suggested + ".\n"
case "missing_error_handling":
return content + "\n\n## Error Handling\n\n- Handle common failure modes gracefully\n- Provide clear error messages\n- Suggest alternative approaches\n"
case "no_clear_steps":
return "## Steps\n\n1. Review the skill context\n2. Apply the appropriate pattern\n3. Verify the result\n\n" + content
default:
return content
}
}
func (si *SkillImprover) saveHistory(history *ImprovementHistory) error {
if err := os.MkdirAll(si.historyDir, 0755); err != nil {
return err
}
filename := fmt.Sprintf("%s_%s.json", history.SkillName, history.AppliedAt.Format("20060102-150405"))
data, err := json.MarshalIndent(history, "", " ")
if err != nil {
return err
}
return os.WriteFile(filepath.Join(si.historyDir, filename), data, 0644)
}
func improvementHistoryDir() (string, error) {
dir, err := SkillsDir()
if err != nil {
return "", err
}
return filepath.Join(filepath.Dir(dir), ".muyue", "improvements"), nil
}

View File

@@ -20,19 +20,26 @@ type SkillDependency struct {
}
type Skill struct {
Name string `yaml:"name" json:"name"`
Description string `yaml:"description" json:"description"`
Content string `yaml:"content" json:"content"`
Author string `yaml:"author" json:"author"`
Version string `yaml:"version" json:"version"`
CreatedAt time.Time `yaml:"created_at" json:"created_at"`
UpdatedAt time.Time `yaml:"updated_at" json:"updated_at"`
Tags []string `yaml:"tags" json:"tags"`
Target string `yaml:"target" json:"target"`
FilePath string `yaml:"-" json:"-"`
Dependencies []SkillDependency `yaml:"dependencies,omitempty" json:"dependencies,omitempty"`
Languages []string `yaml:"languages,omitempty" json:"languages,omitempty"`
Category string `yaml:"category,omitempty" json:"category,omitempty"`
Name string `yaml:"name" json:"name"`
Description string `yaml:"description" json:"description"`
Content string `yaml:"content" json:"content"`
Author string `yaml:"author" json:"author"`
Version string `yaml:"version" json:"version"`
CreatedAt time.Time `yaml:"created_at" json:"created_at"`
UpdatedAt time.Time `yaml:"updated_at" json:"updated_at"`
Tags []string `yaml:"tags" json:"tags"`
Target string `yaml:"target" json:"target"`
FilePath string `yaml:"-" json:"-"`
Dependencies []SkillDependency `yaml:"dependencies,omitempty" json:"dependencies,omitempty"`
Languages []string `yaml:"languages,omitempty" json:"languages,omitempty"`
Category string `yaml:"category,omitempty" json:"category,omitempty"`
Deployed bool `yaml:"-" json:"deployed,omitempty"`
RequiresTools []string `yaml:"requires_tools,omitempty" json:"requires_tools,omitempty"`
FallbackForTools []string `yaml:"fallback_for_tools,omitempty" json:"fallback_for_tools,omitempty"`
AutoImprove bool `yaml:"auto_improve,omitempty" json:"auto_improve,omitempty"`
CreatedFrom string `yaml:"created_from,omitempty" json:"created_from,omitempty"`
ImprovementCount int `yaml:"improvement_count,omitempty" json:"improvement_count,omitempty"`
LastImprovedAt *time.Time `yaml:"last_improved_at,omitempty" json:"last_improved_at,omitempty"`
}
type ValidationError struct {
@@ -155,6 +162,27 @@ func Delete(name string) error {
return nil
}
func IsDeployed(name string) bool {
home, err := os.UserHomeDir()
if err != nil {
return false
}
crushPath := filepath.Join(home, ".config", "crush", "skills", name, "SKILL.md")
claudePath := filepath.Join(home, ".claude", "skills", name, "SKILL.md")
_, crushErr := os.Stat(crushPath)
_, claudeErr := os.Stat(claudePath)
return crushErr == nil || claudeErr == nil
}
func Undeploy(name string) error {
skill, err := Get(name)
if err != nil {
return err
}
undeployFromTargets(skill.Name)
return nil
}
func Update(skill *Skill) error {
if errs := Validate(skill); len(errs) > 0 {
return fmt.Errorf("validation failed: %v", errs)
@@ -494,6 +522,24 @@ func renderSkill(skill *Skill) string {
b.WriteString(fmt.Sprintf(" - type: %s, name: %s%s\n", dep.Type, dep.Name, req))
}
}
if len(skill.RequiresTools) > 0 {
b.WriteString(fmt.Sprintf("requires_tools: [%s]\n", strings.Join(skill.RequiresTools, ", ")))
}
if len(skill.FallbackForTools) > 0 {
b.WriteString(fmt.Sprintf("fallback_for_tools: [%s]\n", strings.Join(skill.FallbackForTools, ", ")))
}
if skill.AutoImprove {
b.WriteString("auto_improve: true\n")
}
if skill.CreatedFrom != "" {
b.WriteString(fmt.Sprintf("created_from: %s\n", skill.CreatedFrom))
}
if skill.ImprovementCount > 0 {
b.WriteString(fmt.Sprintf("improvement_count: %d\n", skill.ImprovementCount))
}
if skill.LastImprovedAt != nil {
b.WriteString(fmt.Sprintf("last_improved_at: %s\n", skill.LastImprovedAt.Format(time.RFC3339)))
}
b.WriteString("---\n\n")
b.WriteString(skill.Content)
b.WriteString("\n")

View File

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

View File

@@ -225,17 +225,21 @@ func (e *Engine) Execute(ctx context.Context, workflowID string, onStep func(ste
stepStatuses[step.ID] = StatusPending
}
resolveDeps := func(stepID string) bool {
resolveDeps := func(stepID string) (ready bool, blocked bool) {
step := wf.findStep(stepID)
if step == nil {
return false
return false, true
}
for _, dep := range step.DependsOn {
if stepStatuses[dep] != StatusDone {
return false
depStatus := stepStatuses[dep]
if depStatus == StatusFailed || depStatus == StatusSkipped {
return false, true
}
if depStatus != StatusDone {
return false, false
}
}
return true
return true, false
}
executeStep := func(step *Step) error {
@@ -296,6 +300,7 @@ func (e *Engine) Execute(ctx context.Context, workflowID string, onStep func(ste
s.Error = stepErr.Error()
s.EndedAt = &endTime
})
stepStatuses[step.ID] = StatusFailed
if onStep != nil {
onStep(step, "failed")
}
@@ -321,8 +326,27 @@ func (e *Engine) Execute(ctx context.Context, workflowID string, onStep func(ste
continue
}
for !resolveDeps(step.ID) {
time.Sleep(100 * time.Millisecond)
ready, blocked := resolveDeps(step.ID)
if blocked {
e.UpdateStep(workflowID, step.ID, func(s *Step) {
s.Status = StatusSkipped
})
stepStatuses[step.ID] = StatusSkipped
if onStep != nil {
onStep(&step, "skipped")
}
continue
}
if !ready {
e.UpdateStep(workflowID, step.ID, func(s *Step) {
s.Status = StatusSkipped
s.Error = "dependency not satisfied at execution time"
})
stepStatuses[step.ID] = StatusSkipped
if onStep != nil {
onStep(&step, "skipped")
}
continue
}
if err := executeStep(&step); err != nil {

Some files were not shown because too many files have changed in this diff Show More