diff --git a/go.mod b/go.mod index 86e96fc..2383d31 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,8 @@ go 1.24.3 require ( github.com/charmbracelet/huh v1.0.0 + github.com/creack/pty/v2 v2.0.1 + github.com/gorilla/websocket v1.5.3 gopkg.in/yaml.v3 v3.0.1 ) diff --git a/go.sum b/go.sum index 05e1d54..52799d5 100644 --- a/go.sum +++ b/go.sum @@ -44,10 +44,14 @@ github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/creack/pty/v2 v2.0.1 h1:RDY1VY5b+7m2mfPsugucOYPIxMp+xal5ZheSyVzUA+k= +github.com/creack/pty/v2 v2.0.1/go.mod h1:2dSssKp3b86qYEMwA/FPwc3ff+kYpDdQI8osU8J7gxQ= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= diff --git a/internal/api/handlers.go b/internal/api/handlers.go index deda482..9793049 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -8,6 +8,7 @@ import ( "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" @@ -244,3 +245,83 @@ func (s *Server) handleTerminal(w http.ResponseWriter, r *http.Request) { } 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}) +} diff --git a/internal/api/server.go b/internal/api/server.go index 21644f4..9150623 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -2,6 +2,7 @@ package api import ( "net/http" + "strings" "github.com/muyue/muyue/internal/config" "github.com/muyue/muyue/internal/scanner" @@ -37,10 +38,15 @@ func (s *Server) routes() { s.mux.HandleFunc("/api/scan", s.handleScan) 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/mcp/configure", s.handleMCPConfigure) } func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, "/api/ws/") { + s.mux.ServeHTTP(w, r) + 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") diff --git a/internal/api/terminal.go b/internal/api/terminal.go new file mode 100644 index 0000000..0834256 --- /dev/null +++ b/internal/api/terminal.go @@ -0,0 +1,120 @@ +package api + +import ( + "encoding/json" + "log" + "net/http" + "os" + "os/exec" + "sync" + "time" + + "github.com/creack/pty/v2" + "github.com/gorilla/websocket" +) + +var upgrader = websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { return true }, +} + +type wsMessage struct { + Type string `json:"type"` + Data string `json:"data"` + Rows uint16 `json:"rows,omitempty"` + Cols uint16 `json:"cols,omitempty"` +} + +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() + + shell := "/bin/sh" + if s, err := exec.LookPath("bash"); err == nil { + shell = s + } + + cmd := exec.Command(shell) + cmd.Env = append(os.Environ(), "TERM=xterm-256color") + + ptmx, err := pty.Start(cmd) + if err != nil { + log.Printf("pty start: %v", err) + conn.WriteJSON(wsMessage{Type: "error", Data: err.Error()}) + return + } + defer func() { + ptmx.Close() + if cmd.Process != nil { + cmd.Process.Kill() + cmd.Wait() + } + }() + + var once sync.Once + cleanup := func() { + once.Do(func() { + ptmx.Close() + if cmd.Process != nil { + cmd.Process.Kill() + cmd.Wait() + } + }) + } + + // PTY -> WebSocket + go func() { + buf := make([]byte, 4096) + for { + n, err := ptmx.Read(buf) + if err != nil { + cleanup() + conn.WriteMessage(websocket.CloseMessage, + websocket.FormatCloseMessage(websocket.CloseNormalClosure, "")) + return + } + if err := conn.WriteJSON(wsMessage{ + Type: "output", + Data: string(buf[:n]), + }); err != nil { + cleanup() + return + } + } + }() + + // WebSocket -> PTY + conn.SetReadLimit(1 << 20) // 1MB + conn.SetReadDeadline(time.Time{}) + + for { + _, raw, err := conn.ReadMessage() + if err != nil { + cleanup() + return + } + + var msg wsMessage + if err := json.Unmarshal(raw, &msg); err != nil { + continue + } + + switch msg.Type { + case "input": + if _, err := ptmx.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, + }) + } + } + } +} diff --git a/internal/orchestrator/orchestrator.go b/internal/orchestrator/orchestrator.go index 792ec24..daa2d0c 100644 --- a/internal/orchestrator/orchestrator.go +++ b/internal/orchestrator/orchestrator.go @@ -41,11 +41,12 @@ type ChatResponse struct { } type Orchestrator struct { - config *config.MuyueConfig - provider *config.AIProvider - client *http.Client - history []Message - histMu sync.Mutex + config *config.MuyueConfig + provider *config.AIProvider + client *http.Client + history []Message + histMu sync.Mutex + systemPrompt string } var sharedHTTPClient = &http.Client{ @@ -77,6 +78,10 @@ func New(cfg *config.MuyueConfig) (*Orchestrator, error) { }, nil } +func (o *Orchestrator) SetSystemPrompt(prompt string) { + o.systemPrompt = prompt +} + func (o *Orchestrator) Send(userMessage string) (string, error) { o.histMu.Lock() o.history = append(o.history, Message{ @@ -88,9 +93,15 @@ func (o *Orchestrator) Send(userMessage string) (string, error) { o.history = o.history[len(o.history)-maxHistorySize:] } + messages := make([]Message, 0, len(o.history)+1) + if o.systemPrompt != "" { + messages = append(messages, Message{Role: "system", Content: o.systemPrompt}) + } + messages = append(messages, o.history...) + reqBody := ChatRequest{ Model: o.provider.Model, - Messages: o.history, + Messages: messages, Stream: false, } o.histMu.Unlock() diff --git a/internal/version/version.go b/internal/version/version.go index fef4eda..125e0ec 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -2,7 +2,7 @@ package version const ( Name = "muyue" - Version = "0.2.1" + Version = "0.3.0" Author = "La Légion de Muyue" ) diff --git a/web/package-lock.json b/web/package-lock.json index a128dc7..40cbe43 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -6,6 +6,9 @@ "": { "name": "muyue-web", "dependencies": { + "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-web-links": "^0.12.0", + "@xterm/xterm": "^6.0.0", "react": "^19.2.5", "react-dom": "^19.2.5" }, @@ -396,6 +399,27 @@ } } }, + "node_modules/@xterm/addon-fit": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", + "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==", + "license": "MIT" + }, + "node_modules/@xterm/addon-web-links": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.12.0.tgz", + "integrity": "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==", + "license": "MIT" + }, + "node_modules/@xterm/xterm": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz", + "integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==", + "license": "MIT", + "workspaces": [ + "addons/*" + ] + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", diff --git a/web/package.json b/web/package.json index 6f36f7a..e974527 100644 --- a/web/package.json +++ b/web/package.json @@ -8,6 +8,9 @@ "preview": "vite preview" }, "dependencies": { + "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-web-links": "^0.12.0", + "@xterm/xterm": "^6.0.0", "react": "^19.2.5", "react-dom": "^19.2.5" }, diff --git a/web/src/api/client.js b/web/src/api/client.js index 8470963..fc6b267 100644 --- a/web/src/api/client.js +++ b/web/src/api/client.js @@ -27,6 +27,42 @@ const api = { configureMCP: () => request('/mcp/configure', { method: 'POST' }), savePreferences: (prefs) => request('/preferences', { method: 'PUT', body: JSON.stringify(prefs) }), runCommand: (command, cwd) => request('/terminal', { method: 'POST', body: JSON.stringify({ command, cwd }) }), + sendChat: (message, stream = true) => { + if (!stream) { + return request('/chat', { method: 'POST', body: JSON.stringify({ message, stream: false }) }) + } + return new Promise((resolve, reject) => { + fetch(`${API_BASE}/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ message, stream: true }), + }).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 + } catch {} + } + } + resolve(full) + }).catch(reject) + }) + }, } export default api diff --git a/web/src/components/Shell.jsx b/web/src/components/Shell.jsx index 77a9fe4..ca16c22 100644 --- a/web/src/components/Shell.jsx +++ b/web/src/components/Shell.jsx @@ -1,65 +1,142 @@ -import { useState, useRef, useEffect } from 'react' +import { useState, useRef, useEffect, useCallback } from 'react' +import { Terminal } from '@xterm/xterm' +import { FitAddon } from '@xterm/addon-fit' +import { WebLinksAddon } from '@xterm/addon-web-links' +import '@xterm/xterm/css/xterm.css' import { useI18n } from '../i18n' export default function Shell({ api }) { const { t } = useI18n() - const [history, setHistory] = useState([]) - const [input, setInput] = useState('') - const [cwd, setCwd] = useState('~') - const [showAi, setShowAi] = useState(false) + const termRef = useRef(null) + const fitAddonRef = useRef(null) + const wsRef = useRef(null) + const containerRef = useRef(null) + const [aiMessages, setAiMessages] = useState([ { role: 'ai', content: t('shell.aiWelcome') } ]) const [aiInput, setAiInput] = useState('') const [aiLoading, setAiLoading] = useState(false) - const [cmdHistory, setCmdHistory] = useState([]) - const [histIdx, setHistIdx] = useState(-1) - const outputRef = useRef(null) + const [connected, setConnected] = useState(false) + const aiMessagesRef = useRef(null) useEffect(() => { - outputRef.current?.scrollTo(0, outputRef.current.scrollHeight) - }, [history]) + aiMessagesRef.current?.scrollTo(0, aiMessagesRef.current.scrollHeight) + }, [aiMessages]) - const handleCommand = async (cmd) => { - if (!cmd.trim()) return - if (cmd === 'clear') { setHistory([]); return } + const getWsUrl = useCallback(() => { + const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:' + return `${proto}//${window.location.host}/api/ws/terminal` + }, []) - setCmdHistory(prev => [...prev, cmd]) - setHistIdx(-1) - setHistory(prev => [...prev, { type: 'cmd', text: `${cwd} $ ${cmd}` }]) + useEffect(() => { + if (!containerRef.current) return - try { - const res = await api.runCommand(cmd, cwd === '~' ? '' : cwd) - if (res.output) setHistory(prev => [...prev, { type: 'out', text: res.output }]) - if (res.error) setHistory(prev => [...prev, { type: 'err', text: res.error }]) - if (cmd.startsWith('cd ')) { - const dir = cmd.slice(3).trim() - setCwd(dir === '~' ? '~' : dir) + const term = new Terminal({ + cursorBlink: true, + fontSize: 14, + fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', 'Menlo', monospace", + theme: { + background: '#0A0A0C', + foreground: '#EAE0E2', + cursor: '#FF0033', + cursorAccent: '#0A0A0C', + selectionBackground: '#FF003344', + selectionForeground: '#ffffff', + black: '#0A0A0C', + red: '#FF0033', + green: '#00E676', + yellow: '#FFD740', + blue: '#448AFF', + magenta: '#FF1A5E', + cyan: '#00BCD4', + white: '#EAE0E2', + brightBlack: '#5A4F52', + brightRed: '#FF5252', + brightGreen: '#69F0AE', + brightYellow: '#FFFF00', + brightBlue: '#82B1FF', + brightMagenta: '#FF80AB', + brightCyan: '#84FFFF', + brightWhite: '#FFFFFF', + }, + allowTransparency: false, + scrollback: 5000, + }) + + const fitAddon = new FitAddon() + const webLinksAddon = new WebLinksAddon() + term.loadAddon(fitAddon) + term.loadAddon(webLinksAddon) + term.open(containerRef.current) + fitAddon.fit() + + termRef.current = term + fitAddonRef.current = fitAddon + + const ws = new WebSocket(getWsUrl()) + wsRef.current = ws + + ws.onopen = () => { + setConnected(true) + const dims = fitAddon.proposeDimensions() + if (dims) { + ws.send(JSON.stringify({ type: 'resize', rows: dims.rows, cols: dims.cols })) } - } catch (err) { - setHistory(prev => [...prev, { type: 'err', text: err.message }]) } - } - const handleKeyDown = (e) => { - if (e.key === 'Enter') { - e.preventDefault() - handleCommand(input) - setInput('') - } else if (e.key === 'ArrowUp') { - e.preventDefault() - if (cmdHistory.length === 0) return - const newIdx = histIdx === -1 ? cmdHistory.length - 1 : Math.max(0, histIdx - 1) - setHistIdx(newIdx) - setInput(cmdHistory[newIdx]) - } else if (e.key === 'ArrowDown') { - e.preventDefault() - if (histIdx === -1) return - const newIdx = histIdx + 1 - if (newIdx >= cmdHistory.length) { setHistIdx(-1); setInput('') } - else { setHistIdx(newIdx); setInput(cmdHistory[newIdx]) } + ws.onmessage = (event) => { + try { + const msg = JSON.parse(event.data) + if (msg.type === 'output') { + term.write(msg.data) + } else if (msg.type === 'error') { + term.write(`\r\n\x1b[31mError: ${msg.data}\x1b[0m\r\n`) + } + } catch { + term.write(event.data) + } } - } + + ws.onclose = () => { + setConnected(false) + term.write('\r\n\x1b[33m— Connection closed —\x1b[0m\r\n') + } + + ws.onerror = () => { + setConnected(false) + term.write('\r\n\x1b[31m— Connection error —\x1b[0m\r\n') + } + + term.onData((data) => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'input', data })) + } + }) + + term.onResize(({ rows, cols }) => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'resize', rows, cols })) + } + }) + + const onResize = () => { + if (containerRef.current?.offsetParent !== null) { + fitAddon.fit() + } + } + + const resizeObserver = new ResizeObserver(onResize) + resizeObserver.observe(containerRef.current) + window.addEventListener('resize', onResize) + + return () => { + window.removeEventListener('resize', onResize) + resizeObserver.disconnect() + ws.close() + term.dispose() + } + }, [getWsUrl]) const handleAiSend = async () => { if (!aiInput.trim() || aiLoading) return @@ -78,60 +155,37 @@ export default function Shell({ api }) { } return ( -
$1')
+ .replace(/^### (.+)$/gm, '{part.content}
+ {plans.find(p => p.id === expanded).content}
+ {part.content}
+