Some checks failed
Beta Release / beta (push) Has been cancelled
feat(shell): real terminal with xterm.js + PTY over WebSocket Replace fake shell input with a full PTY-backed terminal using xterm.js. Apps like btop, vim, htop now work. AI chat panel is always visible. Backend: - Add WebSocket handler /api/ws/terminal with creack/pty - Allocate real pseudo-terminal with TERM=xterm-256color - Bidirectional I/O + dynamic resize via pty.Setsize - Skip JSON headers on /api/ws/* paths for WebSocket upgrade Frontend: - Integrate xterm.js with FitAddon and WebLinksAddon - Cyberpunk color theme matching app design - ResizeObserver for automatic terminal resizing - AI assistant panel always visible (340px, no toggle) - Connection status indicator (green/red dot) Dependencies: - Go: github.com/gorilla/websocket, github.com/creack/pty/v2 - npm: @xterm/xterm, @xterm/addon-fit, @xterm/addon-web-links 💘 Generated with Crush Assisted-by: GLM-5.1 via Crush <crush@charm.land>
328 lines
8.5 KiB
Go
328 lines
8.5 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"os/exec"
|
|
|
|
"github.com/muyue/muyue/internal/config"
|
|
"github.com/muyue/muyue/internal/lsp"
|
|
"github.com/muyue/muyue/internal/mcp"
|
|
"github.com/muyue/muyue/internal/orchestrator"
|
|
"github.com/muyue/muyue/internal/scanner"
|
|
"github.com/muyue/muyue/internal/skills"
|
|
"github.com/muyue/muyue/internal/updater"
|
|
"github.com/muyue/muyue/internal/version"
|
|
)
|
|
|
|
func writeJSON(w http.ResponseWriter, data interface{}) {
|
|
json.NewEncoder(w).Encode(data)
|
|
}
|
|
|
|
func writeError(w http.ResponseWriter, msg string, code int) {
|
|
w.WriteHeader(code)
|
|
json.NewEncoder(w).Encode(map[string]string{"error": msg})
|
|
}
|
|
|
|
func (s *Server) handleInfo(w http.ResponseWriter, r *http.Request) {
|
|
writeJSON(w, map[string]interface{}{
|
|
"name": version.Name,
|
|
"version": version.Version,
|
|
"author": version.Author,
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleSystem(w http.ResponseWriter, r *http.Request) {
|
|
if s.scanResult == nil {
|
|
s.scanResult = scanner.ScanSystem()
|
|
}
|
|
writeJSON(w, map[string]interface{}{
|
|
"system": s.scanResult.System,
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleTools(w http.ResponseWriter, r *http.Request) {
|
|
if s.scanResult == nil {
|
|
s.scanResult = scanner.ScanSystem()
|
|
}
|
|
type toolInfo struct {
|
|
Name string `json:"name"`
|
|
Installed bool `json:"installed"`
|
|
Version string `json:"version"`
|
|
Path string `json:"path"`
|
|
}
|
|
tools := make([]toolInfo, len(s.scanResult.Tools))
|
|
for i, t := range s.scanResult.Tools {
|
|
tools[i] = toolInfo{
|
|
Name: t.Name,
|
|
Installed: t.Installed,
|
|
Version: t.Version,
|
|
Path: t.Path,
|
|
}
|
|
}
|
|
writeJSON(w, map[string]interface{}{
|
|
"tools": tools,
|
|
"total": len(tools),
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) {
|
|
if s.config == nil {
|
|
writeError(w, "no config", http.StatusNotFound)
|
|
return
|
|
}
|
|
writeJSON(w, map[string]interface{}{
|
|
"profile": s.config.Profile,
|
|
"terminal": s.config.Terminal,
|
|
"bmad": s.config.BMAD,
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleProviders(w http.ResponseWriter, r *http.Request) {
|
|
if s.config == nil {
|
|
writeError(w, "no config", http.StatusNotFound)
|
|
return
|
|
}
|
|
writeJSON(w, map[string]interface{}{
|
|
"providers": s.config.AI.Providers,
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleSkills(w http.ResponseWriter, r *http.Request) {
|
|
list, err := skills.List()
|
|
if err != nil {
|
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
writeJSON(w, map[string]interface{}{
|
|
"skills": list,
|
|
"count": len(list),
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleLSP(w http.ResponseWriter, r *http.Request) {
|
|
servers := lsp.ScanServers()
|
|
writeJSON(w, map[string]interface{}{
|
|
"servers": servers,
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleMCP(w http.ResponseWriter, r *http.Request) {
|
|
servers := mcp.ScanServers()
|
|
writeJSON(w, map[string]interface{}{
|
|
"servers": servers,
|
|
"configured": true,
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleMCPConfigure(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != "POST" {
|
|
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
if err := mcp.ConfigureAll(s.config); err != nil {
|
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
writeJSON(w, map[string]string{"status": "ok"})
|
|
}
|
|
|
|
func (s *Server) handleUpdates(w http.ResponseWriter, r *http.Request) {
|
|
result := scanner.ScanSystem()
|
|
statuses := updater.CheckUpdates(result)
|
|
type updateInfo struct {
|
|
Tool string `json:"tool"`
|
|
Current string `json:"current"`
|
|
Latest string `json:"latest"`
|
|
NeedsUpdate bool `json:"needsUpdate"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
updates := make([]updateInfo, len(statuses))
|
|
for i, u := range statuses {
|
|
updates[i] = updateInfo{
|
|
Tool: u.Tool,
|
|
Current: u.Current,
|
|
Latest: u.Latest,
|
|
NeedsUpdate: u.NeedsUpdate,
|
|
Error: u.Error,
|
|
}
|
|
}
|
|
writeJSON(w, map[string]interface{}{
|
|
"updates": updates,
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleInstall(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != "POST" {
|
|
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
var body struct {
|
|
Tools []string `json:"tools"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
writeError(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
if len(body.Tools) == 0 {
|
|
writeError(w, "no tools specified", http.StatusBadRequest)
|
|
return
|
|
}
|
|
writeJSON(w, map[string]string{"status": "installing"})
|
|
}
|
|
|
|
func (s *Server) handleScan(w http.ResponseWriter, r *http.Request) {
|
|
s.scanResult = scanner.ScanSystem()
|
|
writeJSON(w, map[string]string{"status": "ok"})
|
|
}
|
|
|
|
func (s *Server) handleUpdatePreferences(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != "PUT" {
|
|
writeError(w, "PUT only", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
if s.config == nil {
|
|
writeError(w, "no config", http.StatusNotFound)
|
|
return
|
|
}
|
|
var body struct {
|
|
Language string `json:"language"`
|
|
KeyboardLayout string `json:"keyboard_layout"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
writeError(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
if body.Language != "" {
|
|
s.config.Profile.Preferences.Language = body.Language
|
|
}
|
|
if body.KeyboardLayout != "" {
|
|
s.config.Profile.Preferences.KeyboardLayout = body.KeyboardLayout
|
|
}
|
|
if err := config.Save(s.config); err != nil {
|
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
writeJSON(w, map[string]string{"status": "ok"})
|
|
}
|
|
|
|
func (s *Server) handleTerminal(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != "POST" {
|
|
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
var body struct {
|
|
Command string `json:"command"`
|
|
Cwd string `json:"cwd"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
writeError(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
if body.Command == "" {
|
|
writeError(w, "no command", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
shell := "/bin/sh"
|
|
if s, err := exec.LookPath("bash"); err == nil {
|
|
shell = s
|
|
}
|
|
|
|
cmd := exec.Command(shell, "-c", body.Command)
|
|
if body.Cwd != "" {
|
|
cmd.Dir = body.Cwd
|
|
}
|
|
out, err := cmd.CombinedOutput()
|
|
|
|
type termResult struct {
|
|
Output string `json:"output"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
result := termResult{Output: string(out)}
|
|
if err != nil {
|
|
result.Error = err.Error()
|
|
}
|
|
writeJSON(w, result)
|
|
}
|
|
|
|
func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != "POST" {
|
|
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
var body struct {
|
|
Message string `json:"message"`
|
|
Stream bool `json:"stream"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
writeError(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
if body.Message == "" {
|
|
writeError(w, "no message", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
orb, err := orchestrator.New(s.config)
|
|
if err != nil {
|
|
writeError(w, err.Error(), http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
orb.SetSystemPrompt(`You are Muyue Studio's AI orchestrator. You help the user with software development tasks. You can:
|
|
- Create and manage development plans with step-by-step workflows
|
|
- Propose agents (tools like Crush, Claude Code, etc.) to execute specific tasks
|
|
- Track progress across multi-step tasks
|
|
- Suggest file modifications, code reviews, and architecture decisions
|
|
|
|
Be concise, actionable, and structured. When proposing a plan, use clear numbered steps. When referencing files, use relative paths. You are embedded in the Muyue desktop app.`)
|
|
|
|
if body.Stream {
|
|
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)
|
|
|
|
chunkSize := 8
|
|
result, err := orb.Send(body.Message)
|
|
if err != nil {
|
|
data, _ := json.Marshal(map[string]string{"error": err.Error()})
|
|
w.Write([]byte("data: " + string(data) + "\n\n"))
|
|
if canFlush {
|
|
flusher.Flush()
|
|
}
|
|
return
|
|
}
|
|
|
|
runes := []rune(result)
|
|
for i := 0; i < len(runes); i += chunkSize {
|
|
end := i + chunkSize
|
|
if end > len(runes) {
|
|
end = len(runes)
|
|
}
|
|
chunk := string(runes[i:end])
|
|
data, _ := json.Marshal(map[string]string{"content": chunk})
|
|
w.Write([]byte("data: " + string(data) + "\n\n"))
|
|
if canFlush {
|
|
flusher.Flush()
|
|
}
|
|
}
|
|
|
|
data, _ := json.Marshal(map[string]string{"done": "true"})
|
|
w.Write([]byte("data: " + string(data) + "\n\n"))
|
|
if canFlush {
|
|
flusher.Flush()
|
|
}
|
|
return
|
|
}
|
|
|
|
result, err := orb.Send(body.Message)
|
|
if err != nil {
|
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
writeJSON(w, map[string]string{"content": result})
|
|
}
|