Files
MuyueWorkspace/internal/api/server.go
Muyue c820d55710
Some checks failed
PR Check / check (pull_request) Failing after 33s
feat: AI-driven browser tests — Tests tab + browser_test agent tool
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

230 lines
8.6 KiB
Go

package api
import (
"encoding/json"
"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/scanner"
"github.com/muyue/muyue/internal/workflow"
)
type Server struct {
config *config.MuyueConfig
scanResult *scanner.ScanResult
mux *http.ServeMux
convStore *ConversationStore
shellConvStore *ShellConvStore
consumption *consumptionStore
agentRegistry *agent.Registry
agentToolsJSON json.RawMessage
shellAgentRegistry *agent.Registry
shellAgentToolsJSON json.RawMessage
workflowEngine *workflow.Engine
browserTestStore *BrowserTestStore
activeCrushAgents atomic.Int32
activeClaudeAgents atomic.Int32
}
func NewServer(cfg *config.MuyueConfig) *Server {
s := &Server{
mux: http.NewServeMux(),
}
// Auto-initialize config if nil or if no config file exists on disk
if cfg == nil || !config.Exists() {
defaultCfg := config.Default()
if cfg != nil {
// Preserve any user-provided settings from cfg
defaultCfg.Profile = cfg.Profile
defaultCfg.AI = cfg.AI
defaultCfg.Tools = cfg.Tools
defaultCfg.BMAD = cfg.BMAD
defaultCfg.Terminal = cfg.Terminal
}
// Save initial config to establish the file for first-time usage
if err := config.Save(defaultCfg); err != nil {
_ = err
}
cfg = defaultCfg
}
s.config = cfg
s.scanResult = scanner.ScanSystem()
s.convStore = NewConversationStore()
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)
s.shellAgentRegistry = agent.NewRegistry()
terminalTool, _ := agent.NewTerminalTool()
s.shellAgentRegistry.Register(terminalTool)
shellTools := s.shellAgentRegistry.OpenAITools()
shellToolsJSON, _ := json.Marshal(shellTools)
s.shellAgentToolsJSON = json.RawMessage(shellToolsJSON)
s.workflowEngine, _ = workflow.NewEngine(s.agentRegistry)
s.initStarship()
s.routes()
return s
}
func (s *Server) routes() {
s.mux.HandleFunc("/api/info", s.handleInfo)
s.mux.HandleFunc("/api/system", s.handleSystem)
s.mux.HandleFunc("/api/tools", s.handleTools)
s.mux.HandleFunc("/api/config", s.handleConfig)
s.mux.HandleFunc("/api/providers", s.handleProviders)
s.mux.HandleFunc("/api/skills", s.handleSkills)
s.mux.HandleFunc("/api/lsp", s.handleLSP)
s.mux.HandleFunc("/api/mcp", s.handleMCP)
s.mux.HandleFunc("/api/updates", s.handleUpdates)
s.mux.HandleFunc("/api/install", s.handleInstall)
s.mux.HandleFunc("/api/scan", s.handleScan)
s.mux.HandleFunc("/api/editors", s.handleEditors)
s.mux.HandleFunc("/api/preferences", s.handleUpdatePreferences)
s.mux.HandleFunc("/api/terminal", s.handleTerminal)
s.mux.HandleFunc("/api/ws/terminal", s.handleTerminalWS)
s.mux.HandleFunc("/api/terminal/sessions", s.handleTerminalSessions)
s.mux.HandleFunc("/api/terminal/sessions/", s.handleTerminalSessionsDelete)
s.mux.HandleFunc("/api/terminal/themes", s.handleGetTerminalThemes)
s.mux.HandleFunc("/api/terminal/settings", s.handleSaveTerminalSettings)
s.mux.HandleFunc("/api/mcp/configure", s.handleMCPConfigure)
s.mux.HandleFunc("/api/config/profile", s.handleSaveProfile)
s.mux.HandleFunc("/api/config/provider", s.handleSaveProvider)
s.mux.HandleFunc("/api/config/reset", s.handleResetConfig)
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/", s.handleServeImage)
s.mux.HandleFunc("/api/chat", s.handleChat)
s.mux.HandleFunc("/api/chat/history", s.handleChatHistory)
s.mux.HandleFunc("/api/chat/clear", s.handleChatClear)
s.mux.HandleFunc("/api/chat/summarize", s.handleChatSummarize)
s.mux.HandleFunc("/api/tool/call", s.handleToolCall)
s.mux.HandleFunc("/api/tools/list", s.handleToolList)
s.mux.HandleFunc("/api/shell/chat", s.handleShellChat)
s.mux.HandleFunc("/api/shell/chat/history", s.handleShellChatHistory)
s.mux.HandleFunc("/api/shell/chat/clear", s.handleShellChatClear)
s.mux.HandleFunc("/api/shell/analyze", s.handleShellAnalyze)
s.mux.HandleFunc("/api/shell/analysis", s.handleShellAnalysisGet)
s.mux.HandleFunc("/api/workflow", s.handleWorkflowCreate)
s.mux.HandleFunc("/api/workflow/list", s.handleWorkflowList)
s.mux.HandleFunc("/api/workflow/", s.handleWorkflowGet)
s.mux.HandleFunc("/api/workflow/plan", s.handleWorkflowPlan)
s.mux.HandleFunc("/api/workflow/execute/", s.handleWorkflowExecute)
s.mux.HandleFunc("/api/workflow/approve/", s.handleWorkflowApprove)
s.mux.HandleFunc("/api/conversations", s.handleListConversations)
s.mux.HandleFunc("/api/conversations/search", s.handleSearchConversations)
s.mux.HandleFunc("/api/conversations/export", s.handleExportConversation)
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)
s.mux.HandleFunc("/api/mcp/status", s.handleMCPStatus)
s.mux.HandleFunc("/api/mcp/registry", s.handleMCPRegistry)
s.mux.HandleFunc("/api/lsp/health", s.handleLSPHealth)
s.mux.HandleFunc("/api/lsp/auto-install", s.handleLSPAutoInstall)
s.mux.HandleFunc("/api/lsp/editor-config", s.handleLSPEditorConfig)
s.mux.HandleFunc("/api/skills/validate", s.handleSkillValidate)
s.mux.HandleFunc("/api/skills/test", s.handleSkillTest)
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)
}
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/api/ws/") || strings.HasPrefix(r.URL.Path, "/api/images/") {
s.mux.ServeHTTP(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
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)
return
}
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)
}