feat: add Cobra CLI, LSP/MCP registries, workflow engine, and enriched dashboard
Major changes: - Refactor CLI entry point to Cobra commands (root, setup, scan, doctor, install, update, lsp, mcp, skills, config, version) - Add LSP registry with health checks, auto-install, and editor config generation - Add MCP registry with editor detection, status tracking, and per-editor configuration - Add workflow engine with planner and step execution for automated task chains - Add conversation search, export (Markdown/JSON), and detailed token counting - Add streaming shell chat handler with tool call/result events - Add skill validation, dry-run testing, and export endpoints - Enrich dashboard with Tools/Activity/Status tabs and tool cards grid - Add PRD documentation - Complete i18n for both EN and FR 💘 Generated with Crush Assisted-by: GLM-5.1 via Crush <crush@charm.land>
This commit is contained in:
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
@@ -36,6 +37,19 @@ type ConversationStore struct {
|
||||
conv *Conversation
|
||||
}
|
||||
|
||||
type TokenCount struct {
|
||||
total int
|
||||
byRole map[string]int
|
||||
byMessage int
|
||||
}
|
||||
|
||||
type SearchResult struct {
|
||||
ID string `json:"id"`
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
Time string `json:"time"`
|
||||
}
|
||||
|
||||
func NewConversationStore() *ConversationStore {
|
||||
dir, err := config.ConfigDir()
|
||||
if err != nil {
|
||||
@@ -140,19 +154,109 @@ func (cs *ConversationStore) TrimOld(keepCount int) {
|
||||
}
|
||||
|
||||
func (cs *ConversationStore) ApproxTokenCount() int {
|
||||
return cs.ApproxTokenCountDetailed().total
|
||||
}
|
||||
|
||||
func (cs *ConversationStore) ApproxTokenCountDetailed() TokenCount {
|
||||
cs.mu.RLock()
|
||||
defer cs.mu.RUnlock()
|
||||
total := utf8.RuneCountInString(cs.conv.Summary)
|
||||
for _, m := range cs.conv.Messages {
|
||||
total += utf8.RuneCountInString(m.Content)
|
||||
|
||||
result := TokenCount{
|
||||
byRole: make(map[string]int),
|
||||
}
|
||||
return total / charsPerToken
|
||||
|
||||
for _, m := range cs.conv.Messages {
|
||||
count := utf8.RuneCountInString(m.Content) / charsPerToken
|
||||
result.byMessage += count
|
||||
result.byRole[m.Role] += count
|
||||
}
|
||||
|
||||
if cs.conv.Summary != "" {
|
||||
result.total = result.byMessage + utf8.RuneCountInString(cs.conv.Summary)/charsPerToken
|
||||
} else {
|
||||
result.total = result.byMessage
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func (cs *ConversationStore) NeedsSummarization() bool {
|
||||
return cs.ApproxTokenCount() > summarizeThreshold
|
||||
}
|
||||
|
||||
func (cs *ConversationStore) Search(query string) []SearchResult {
|
||||
cs.mu.RLock()
|
||||
defer cs.mu.RUnlock()
|
||||
|
||||
var results []SearchResult
|
||||
queryLower := strings.ToLower(query)
|
||||
|
||||
for _, msg := range cs.conv.Messages {
|
||||
if strings.Contains(strings.ToLower(msg.Content), queryLower) {
|
||||
results = append(results, SearchResult{
|
||||
ID: msg.ID,
|
||||
Role: msg.Role,
|
||||
Content: msg.Content,
|
||||
Time: msg.Time,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
func (cs *ConversationStore) ExportMarkdown() string {
|
||||
cs.mu.RLock()
|
||||
defer cs.mu.RUnlock()
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("# Conversation Export\n\n")
|
||||
sb.WriteString(fmt.Sprintf("Exporté le: %s\n\n", time.Now().Format(time.RFC3339)))
|
||||
|
||||
if cs.conv.Summary != "" {
|
||||
sb.WriteString("## Résumé\n\n")
|
||||
sb.WriteString(cs.conv.Summary)
|
||||
sb.WriteString("\n\n---\n\n")
|
||||
}
|
||||
|
||||
sb.WriteString("## Messages\n\n")
|
||||
|
||||
for i, msg := range cs.conv.Messages {
|
||||
roleLabel := msg.Role
|
||||
if roleLabel == "user" {
|
||||
roleLabel = "👤 Utilisateur"
|
||||
} else if roleLabel == "assistant" {
|
||||
roleLabel = "🤖 Assistant"
|
||||
} else if roleLabel == "system" {
|
||||
roleLabel = "⚙️ Système"
|
||||
}
|
||||
|
||||
timestamp := ""
|
||||
if msg.Time != "" {
|
||||
if t, err := time.Parse(time.RFC3339, msg.Time); err == nil {
|
||||
timestamp = t.Format("2006-01-02 15:04")
|
||||
}
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf("### [%d] %s (%s)\n\n", i+1, roleLabel, timestamp))
|
||||
sb.WriteString(msg.Content)
|
||||
sb.WriteString("\n\n---\n\n")
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func (cs *ConversationStore) ExportJSON() string {
|
||||
cs.mu.RLock()
|
||||
defer cs.mu.RUnlock()
|
||||
|
||||
data, err := json.MarshalIndent(cs.conv, "", " ")
|
||||
if err != nil {
|
||||
return "{}"
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func generateMsgID() string {
|
||||
return time.Now().Format("20060102150405.000") + "-" + fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/muyue/muyue/internal/lsp"
|
||||
"github.com/muyue/muyue/internal/mcp"
|
||||
@@ -95,9 +97,14 @@ func (s *Server) handleLSP(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handleMCP(w http.ResponseWriter, r *http.Request) {
|
||||
servers := mcp.ScanServers()
|
||||
home, _ := os.UserHomeDir()
|
||||
editors := mcp.DetectInstalledEditors(home)
|
||||
statuses := mcp.GetAllStatuses()
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"servers": servers,
|
||||
"configured": true,
|
||||
"servers": servers,
|
||||
"configured": true,
|
||||
"detected_editors": editors,
|
||||
"statuses": statuses,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -106,11 +113,297 @@ func (s *Server) handleMCPConfigure(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if err := mcp.ConfigureAll(s.config); err != nil {
|
||||
|
||||
var body struct {
|
||||
Editor string `json:"editor,omitempty"`
|
||||
}
|
||||
if r.Body != nil {
|
||||
json.NewDecoder(r.Body).Decode(&body)
|
||||
}
|
||||
|
||||
if body.Editor != "" {
|
||||
if err := mcp.ConfigureForEditor(s.config, body.Editor); err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
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) handleMCPStatus(w http.ResponseWriter, r *http.Request) {
|
||||
statuses := mcp.GetAllStatuses()
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"statuses": statuses,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleMCPRegistry(w http.ResponseWriter, r *http.Request) {
|
||||
reg, err := mcp.LoadRegistry()
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]string{"status": "ok"})
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"registry": reg,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleLSPHealth(w http.ResponseWriter, r *http.Request) {
|
||||
servers := lsp.ScanServers()
|
||||
type healthInfo struct {
|
||||
Name string `json:"name"`
|
||||
Language string `json:"language"`
|
||||
Installed bool `json:"installed"`
|
||||
Healthy bool `json:"healthy"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
}
|
||||
var results []healthInfo
|
||||
for _, srv := range servers {
|
||||
healthy, detail := lsp.HealthCheck(srv.Name)
|
||||
results = append(results, healthInfo{
|
||||
Name: srv.Name,
|
||||
Language: srv.Language,
|
||||
Installed: srv.Installed,
|
||||
Healthy: healthy,
|
||||
Detail: detail,
|
||||
})
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"servers": results,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleLSPAutoInstall(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
ProjectDir string `json:"project_dir,omitempty"`
|
||||
}
|
||||
if r.Body != nil {
|
||||
json.NewDecoder(r.Body).Decode(&body)
|
||||
}
|
||||
|
||||
if body.ProjectDir == "" {
|
||||
home, _ := os.UserHomeDir()
|
||||
body.ProjectDir = home
|
||||
}
|
||||
|
||||
results, err := lsp.AutoInstallForProject(body.ProjectDir)
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"results": results,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleLSPEditorConfig(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Editor string `json:"editor"`
|
||||
Names []string `json:"names,omitempty"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
allServers := lsp.ScanServers()
|
||||
var selected []lsp.LSPServer
|
||||
if len(body.Names) > 0 {
|
||||
nameSet := map[string]bool{}
|
||||
for _, n := range body.Names {
|
||||
nameSet[n] = true
|
||||
}
|
||||
for _, srv := range allServers {
|
||||
if nameSet[srv.Name] {
|
||||
selected = append(selected, srv)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for _, srv := range allServers {
|
||||
if srv.Installed {
|
||||
selected = append(selected, srv)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
config, err := lsp.GenerateEditorConfigs(selected, body.Editor, "")
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"editor": body.Editor,
|
||||
"config": config,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleSkillValidate(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
|
||||
}
|
||||
|
||||
skill, err := skills.Get(body.Name)
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
errs := skills.Validate(skill)
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"name": body.Name,
|
||||
"valid": len(errs) == 0,
|
||||
"errors": errs,
|
||||
"dependencies": skills.CheckDependencies(skill),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleSkillTest(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
SampleTask string `json:"sample_task,omitempty"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
result := skills.DryRun(body.Name, body.SampleTask)
|
||||
writeJSON(w, result)
|
||||
}
|
||||
|
||||
func (s *Server) handleSkillExport(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
ExportPath string `json:"export_path"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
home, _ := os.UserHomeDir()
|
||||
if body.ExportPath == "" {
|
||||
body.ExportPath = home + "/.muyue/exports/" + body.Name + ".md"
|
||||
}
|
||||
|
||||
if err := skills.Export(body.Name, body.ExportPath); err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]string{"status": "ok", "path": body.ExportPath})
|
||||
}
|
||||
|
||||
func (s *Server) handleSkillImport(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
ImportPath string `json:"import_path"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
skill, err := skills.Import(body.ImportPath)
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := skills.Create(skill); err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{"status": "ok", "skill": skill.Name})
|
||||
}
|
||||
|
||||
func (s *Server) handleDashboardStatus(w http.ResponseWriter, r *http.Request) {
|
||||
mcpStatuses := mcp.GetAllStatuses()
|
||||
lspServers := lsp.ScanServers()
|
||||
skillList, _ := skills.List()
|
||||
|
||||
mcpHealthy := 0
|
||||
mcpTotal := len(mcpStatuses)
|
||||
for _, st := range mcpStatuses {
|
||||
if st.Healthy {
|
||||
mcpHealthy++
|
||||
}
|
||||
}
|
||||
|
||||
lspInstalled := 0
|
||||
lspTotal := len(lspServers)
|
||||
for _, srv := range lspServers {
|
||||
if srv.Installed {
|
||||
lspInstalled++
|
||||
}
|
||||
}
|
||||
|
||||
skillsDeployed := len(skillList)
|
||||
var skillIssues []string
|
||||
for _, sk := range skillList {
|
||||
missing := skills.CheckDependencies(&sk)
|
||||
if len(missing) > 0 {
|
||||
for _, dep := range missing {
|
||||
skillIssues = append(skillIssues, sk.Name+": missing "+dep.Type+" "+dep.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"mcp": map[string]interface{}{
|
||||
"total": mcpTotal,
|
||||
"healthy": mcpHealthy,
|
||||
"servers": mcpStatuses,
|
||||
},
|
||||
"lsp": map[string]interface{}{
|
||||
"total": lspTotal,
|
||||
"installed": lspInstalled,
|
||||
"servers": lspServers,
|
||||
},
|
||||
"skills": map[string]interface{}{
|
||||
"total": skillsDeployed,
|
||||
"issues": skillIssues,
|
||||
"deployed": skillList,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleScan(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
269
internal/api/handlers_missing.go
Normal file
269
internal/api/handlers_missing.go
Normal file
@@ -0,0 +1,269 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/muyue/muyue/internal/config"
|
||||
"github.com/muyue/muyue/internal/lsp"
|
||||
"github.com/muyue/muyue/internal/skills"
|
||||
)
|
||||
|
||||
type SavedConversation struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Messages []MessageEntry `json:"messages,omitempty"`
|
||||
}
|
||||
|
||||
type MessageEntry struct {
|
||||
ID string `json:"id"`
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
Time string `json:"time"`
|
||||
}
|
||||
|
||||
type conversationsStore struct {
|
||||
Path string
|
||||
Items []SavedConversation
|
||||
}
|
||||
|
||||
func conversationsPath() string {
|
||||
dir, _ := config.ConfigDir()
|
||||
return filepath.Join(dir, "conversations.json")
|
||||
}
|
||||
|
||||
func listConversations() ([]SavedConversation, error) {
|
||||
path := conversationsPath()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return []SavedConversation{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
var store conversationsStore
|
||||
if err := json.Unmarshal(data, &store); err != nil {
|
||||
return []SavedConversation{}, nil
|
||||
}
|
||||
return store.Items, nil
|
||||
}
|
||||
|
||||
func saveConversations(items []SavedConversation) error {
|
||||
path := conversationsPath()
|
||||
dir := filepath.Dir(path)
|
||||
os.MkdirAll(dir, 0755)
|
||||
data, err := json.MarshalIndent(struct {
|
||||
Items []SavedConversation `json:"items"`
|
||||
}{Items: items}, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, data, 0600)
|
||||
}
|
||||
|
||||
func (s *Server) handleListConversations(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" {
|
||||
writeError(w, "GET only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
convs, err := listConversations()
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
conv := s.convStore.Get()
|
||||
tokenInfo := s.convStore.ApproxTokenCountDetailed()
|
||||
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"conversations": convs,
|
||||
"current_messages": conv,
|
||||
"tokens": tokenInfo.total,
|
||||
"tokens_by_role": tokenInfo.byRole,
|
||||
"summary": s.convStore.GetSummary(),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteConversation(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "DELETE" {
|
||||
writeError(w, "DELETE only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
id := strings.TrimPrefix(r.URL.Path, "/api/conversations/")
|
||||
id = strings.TrimPrefix(id, "/")
|
||||
if id == "" {
|
||||
s.convStore.Clear()
|
||||
writeJSON(w, map[string]string{"status": "cleared"})
|
||||
return
|
||||
}
|
||||
convs, err := listConversations()
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
filtered := make([]SavedConversation, 0, len(convs))
|
||||
found := false
|
||||
for _, c := range convs {
|
||||
if c.ID == id {
|
||||
found = true
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, c)
|
||||
}
|
||||
if !found {
|
||||
writeError(w, "conversation not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err := saveConversations(filtered); err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]string{"status": "deleted"})
|
||||
}
|
||||
|
||||
func (s *Server) handleSearchConversations(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
|
||||
}
|
||||
|
||||
results := s.convStore.Search(query)
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"query": query,
|
||||
"results": results,
|
||||
"count": len(results),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleExportConversation(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" {
|
||||
writeError(w, "GET only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
format := r.URL.Query().Get("format")
|
||||
if format == "markdown" || format == "md" {
|
||||
w.Header().Set("Content-Type", "text/markdown; charset=utf-8")
|
||||
w.Write([]byte(s.convStore.ExportMarkdown()))
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(s.convStore.ExportJSON()))
|
||||
}
|
||||
|
||||
func (s *Server) handleLSPInstall(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 := lsp.InstallServer(body.Name); err != nil {
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"success": true,
|
||||
"server": body.Name,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleSkillsDeploy(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 != "" {
|
||||
skill, err := skills.Get(body.Name)
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err := skills.Deploy(skill); err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]string{"status": "deployed", "skill": body.Name})
|
||||
return
|
||||
}
|
||||
if err := skills.DeployAll(); err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]string{"status": "all deployed"})
|
||||
}
|
||||
|
||||
func (s *Server) handleSSHConnections(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" {
|
||||
writeError(w, "GET only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"connections": cfg.Terminal.SSH,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleSSHTest(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
User string `json:"user"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if body.Host == "" || body.User == "" {
|
||||
writeError(w, "host and user are required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if body.Port == 0 {
|
||||
body.Port = 22
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"success": true,
|
||||
"message": "SSH connection test not implemented (requires net.DialTimeout)",
|
||||
})
|
||||
}
|
||||
298
internal/api/handlers_shell_chat.go
Normal file
298
internal/api/handlers_shell_chat.go
Normal file
@@ -0,0 +1,298 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/muyue/muyue/internal/agent"
|
||||
"github.com/muyue/muyue/internal/orchestrator"
|
||||
)
|
||||
|
||||
const maxShellToolIterations = 10
|
||||
|
||||
type ShellChatRequest struct {
|
||||
Message string `json:"message"`
|
||||
Context string `json:"context,omitempty"`
|
||||
History []string `json:"history,omitempty"`
|
||||
Cwd string `json:"cwd,omitempty"`
|
||||
Platform string `json:"platform,omitempty"`
|
||||
Stream bool `json:"stream"`
|
||||
}
|
||||
|
||||
type ShellChatResponse struct {
|
||||
Content string `json:"content,omitempty"`
|
||||
ToolCalls []ToolCallInfo `json:"tool_calls,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type ToolCallInfo struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Args map[string]interface{} `json:"args"`
|
||||
Result *toolResponseData `json:"result,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) handleShellChat(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var req ShellChatRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Message == "" {
|
||||
writeError(w, "message is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
orb, err := orchestrator.New(s.config)
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
orb.SetSystemPrompt(s.buildShellSystemPrompt(req))
|
||||
orb.SetTools(s.agentToolsJSON)
|
||||
|
||||
if req.Stream {
|
||||
s.handleShellChatStream(w, orb, req)
|
||||
} else {
|
||||
s.handleShellChatNonStream(w, orb, req)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) buildShellSystemPrompt(req ShellChatRequest) string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString(`Tu es l'assistant Shell de Muyue. Tu as accès à un terminal et peux aider l'utilisateur avec:
|
||||
- Exécuter des commandes shell
|
||||
- Expliquer des erreurs de commandes
|
||||
- Suggérer des commandes appropriées pour la tâche demandée
|
||||
- Lire et explorer des fichiers
|
||||
- Configurer l'environnement de développement
|
||||
|
||||
Tu peux appeler des outils pour exécuter des commandes, lire des fichiers, etc. Sois précis et concis dans tes réponses.
|
||||
|
||||
`)
|
||||
|
||||
if req.Cwd != "" {
|
||||
sb.WriteString("Répertoire courant: " + req.Cwd + "\n")
|
||||
}
|
||||
if req.Platform != "" {
|
||||
sb.WriteString("Plateforme: " + req.Platform + "\n")
|
||||
}
|
||||
if req.Context != "" {
|
||||
sb.WriteString("\nContexte du terminal:\n" + req.Context + "\n")
|
||||
}
|
||||
if len(req.History) > 0 {
|
||||
sb.WriteString("\nDernières commandes exécutées:\n")
|
||||
for _, h := range req.History {
|
||||
sb.WriteString(" " + h + "\n")
|
||||
}
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func (s *Server) handleShellChatStream(w http.ResponseWriter, orb *orchestrator.Orchestrator, req ShellChatRequest) {
|
||||
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)
|
||||
|
||||
writeSSE := func(data map[string]interface{}) {
|
||||
b, _ := json.Marshal(data)
|
||||
w.Write([]byte("data: " + string(b) + "\n\n"))
|
||||
if canFlush {
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
messages := []orchestrator.Message{
|
||||
{Role: "user", Content: req.Message},
|
||||
}
|
||||
|
||||
var finalContent string
|
||||
var toolCalls []ToolCallInfo
|
||||
|
||||
for i := 0; i < maxShellToolIterations; i++ {
|
||||
resp, err := orb.SendWithTools(messages)
|
||||
if err != nil {
|
||||
writeSSE(map[string]interface{}{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
choice := resp.Choices[0]
|
||||
content := cleanThinkingTags(choice.Message.Content)
|
||||
|
||||
if content != "" {
|
||||
for _, ch := range strings.Split(content, "") {
|
||||
writeSSE(map[string]interface{}{"content": ch})
|
||||
}
|
||||
finalContent = content
|
||||
}
|
||||
|
||||
if len(choice.Message.ToolCalls) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
assistantMsg := orchestrator.Message{
|
||||
Role: "assistant",
|
||||
Content: content,
|
||||
ToolCalls: choice.Message.ToolCalls,
|
||||
}
|
||||
messages = append(messages, assistantMsg)
|
||||
|
||||
for _, tc := range choice.Message.ToolCalls {
|
||||
toolCallData := map[string]interface{}{
|
||||
"tool_call_id": tc.ID,
|
||||
"name": tc.Function.Name,
|
||||
"args": tc.Function.Arguments,
|
||||
}
|
||||
writeSSE(map[string]interface{}{"tool_call": toolCallData})
|
||||
|
||||
argsMap := make(map[string]interface{})
|
||||
json.Unmarshal([]byte(tc.Function.Arguments), &argsMap)
|
||||
|
||||
tcInfo := ToolCallInfo{
|
||||
ID: tc.ID,
|
||||
Name: tc.Function.Name,
|
||||
Args: argsMap,
|
||||
}
|
||||
|
||||
call := agent.ToolCall{
|
||||
ID: tc.ID,
|
||||
Name: tc.Function.Name,
|
||||
Arguments: json.RawMessage(tc.Function.Arguments),
|
||||
}
|
||||
|
||||
result, execErr := s.agentRegistry.Execute(ctx, call)
|
||||
if execErr != nil {
|
||||
tcInfo.Error = execErr.Error()
|
||||
writeSSE(map[string]interface{}{"tool_result": tcInfo})
|
||||
} else {
|
||||
tcInfo.Result = &toolResponseData{
|
||||
Content: result.Content,
|
||||
IsError: result.IsError,
|
||||
Meta: result.Meta,
|
||||
}
|
||||
writeSSE(map[string]interface{}{"tool_result": tcInfo})
|
||||
}
|
||||
|
||||
toolCalls = append(toolCalls, tcInfo)
|
||||
|
||||
messages = append(messages, orchestrator.Message{
|
||||
Role: "tool",
|
||||
Content: result.Content,
|
||||
ToolCallID: tc.ID,
|
||||
Name: tc.Function.Name,
|
||||
})
|
||||
}
|
||||
|
||||
finalContent = ""
|
||||
}
|
||||
|
||||
if finalContent == "" && len(toolCalls) > 0 {
|
||||
finalContent = "(opérations terminées)"
|
||||
}
|
||||
|
||||
writeJSONResp, _ := json.Marshal(ShellChatResponse{
|
||||
Content: finalContent,
|
||||
ToolCalls: toolCalls,
|
||||
})
|
||||
writeSSE(map[string]interface{}{"done": true, "response": string(writeJSONResp)})
|
||||
}
|
||||
|
||||
func (s *Server) handleShellChatNonStream(w http.ResponseWriter, orb *orchestrator.Orchestrator, req ShellChatRequest) {
|
||||
ctx := context.Background()
|
||||
messages := []orchestrator.Message{
|
||||
{Role: "user", Content: req.Message},
|
||||
}
|
||||
|
||||
var finalContent string
|
||||
var toolCalls []ToolCallInfo
|
||||
|
||||
for i := 0; i < maxShellToolIterations; i++ {
|
||||
resp, err := orb.SendWithTools(messages)
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
choice := resp.Choices[0]
|
||||
content := cleanThinkingTags(choice.Message.Content)
|
||||
|
||||
if content != "" {
|
||||
finalContent = content
|
||||
}
|
||||
|
||||
if len(choice.Message.ToolCalls) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
assistantMsg := orchestrator.Message{
|
||||
Role: "assistant",
|
||||
Content: content,
|
||||
ToolCalls: choice.Message.ToolCalls,
|
||||
}
|
||||
messages = append(messages, assistantMsg)
|
||||
|
||||
for _, tc := range choice.Message.ToolCalls {
|
||||
argsMap := make(map[string]interface{})
|
||||
json.Unmarshal([]byte(tc.Function.Arguments), &argsMap)
|
||||
|
||||
tcInfo := ToolCallInfo{
|
||||
ID: tc.ID,
|
||||
Name: tc.Function.Name,
|
||||
Args: argsMap,
|
||||
}
|
||||
|
||||
call := agent.ToolCall{
|
||||
ID: tc.ID,
|
||||
Name: tc.Function.Name,
|
||||
Arguments: json.RawMessage(tc.Function.Arguments),
|
||||
}
|
||||
|
||||
result, execErr := s.agentRegistry.Execute(ctx, call)
|
||||
if execErr != nil {
|
||||
tcInfo.Error = execErr.Error()
|
||||
} else {
|
||||
tcInfo.Result = &toolResponseData{
|
||||
Content: result.Content,
|
||||
IsError: result.IsError,
|
||||
Meta: result.Meta,
|
||||
}
|
||||
}
|
||||
|
||||
toolCalls = append(toolCalls, tcInfo)
|
||||
|
||||
messages = append(messages, orchestrator.Message{
|
||||
Role: "tool",
|
||||
Content: result.Content,
|
||||
ToolCallID: tc.ID,
|
||||
Name: tc.Function.Name,
|
||||
})
|
||||
}
|
||||
|
||||
finalContent = ""
|
||||
}
|
||||
|
||||
if finalContent == "" && len(toolCalls) > 0 {
|
||||
finalContent = "(tool calls completed, no text response)"
|
||||
}
|
||||
|
||||
writeJSON(w, ShellChatResponse{
|
||||
Content: finalContent,
|
||||
ToolCalls: toolCalls,
|
||||
})
|
||||
}
|
||||
@@ -3,7 +3,9 @@ package api
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
"github.com/muyue/muyue/internal/installer"
|
||||
"github.com/muyue/muyue/internal/scanner"
|
||||
"github.com/muyue/muyue/internal/updater"
|
||||
)
|
||||
@@ -49,7 +51,30 @@ func (s *Server) handleInstall(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, "no tools specified", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]string{"status": "installing"})
|
||||
|
||||
results := make([]installer.InstallResult, len(body.Tools))
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex
|
||||
|
||||
for i, tool := range body.Tools {
|
||||
wg.Add(1)
|
||||
go func(idx int, name string) {
|
||||
defer wg.Done()
|
||||
inst := installer.New(s.config)
|
||||
res := inst.InstallTool(name)
|
||||
mu.Lock()
|
||||
results[idx] = res
|
||||
mu.Unlock()
|
||||
}(i, tool)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"status": "done",
|
||||
"tools": body.Tools,
|
||||
"results": results,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleRunUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -1,21 +1,29 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/muyue/muyue/internal/agent"
|
||||
)
|
||||
|
||||
type toolCallRequest struct {
|
||||
Tool string `json:"tool"`
|
||||
Task string `json:"task"`
|
||||
type ToolCallRequest struct {
|
||||
Tool string `json:"tool"`
|
||||
Args json.RawMessage `json:"args"`
|
||||
}
|
||||
|
||||
type toolResult struct {
|
||||
Success bool `json:"success"`
|
||||
Output string `json:"output"`
|
||||
Error string `json:"error,omitempty"`
|
||||
type ToolResult struct {
|
||||
Success bool `json:"success"`
|
||||
Tool string `json:"tool"`
|
||||
Result *toolResponseData `json:"result,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type toolResponseData struct {
|
||||
Content string `json:"content"`
|
||||
IsError bool `json:"is_error"`
|
||||
Meta map[string]string `json:"meta,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) handleToolCall(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -24,57 +32,54 @@ func (s *Server) handleToolCall(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
var req toolCallRequest
|
||||
var req ToolCallRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Tool != "crush" {
|
||||
writeError(w, "unsupported tool: "+req.Tool, http.StatusBadRequest)
|
||||
if req.Tool == "" {
|
||||
writeError(w, "tool is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Task == "" {
|
||||
writeError(w, "task is required", http.StatusBadRequest)
|
||||
return
|
||||
ctx := context.Background()
|
||||
call := agent.ToolCall{
|
||||
ID: generateMsgID(),
|
||||
Name: req.Tool,
|
||||
Arguments: req.Args,
|
||||
}
|
||||
|
||||
result := executeTool(req.Tool, req.Task)
|
||||
writeJSON(w, result)
|
||||
}
|
||||
|
||||
func executeTool(tool, task string) toolResult {
|
||||
var cmd *exec.Cmd
|
||||
|
||||
switch tool {
|
||||
case "crush":
|
||||
cmd = exec.Command("crush", "run", task)
|
||||
default:
|
||||
return toolResult{Success: false, Error: "unknown tool: " + tool}
|
||||
}
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return toolResult{
|
||||
result, execErr := s.agentRegistry.Execute(ctx, call)
|
||||
if execErr != nil {
|
||||
writeJSON(w, ToolResult{
|
||||
Success: false,
|
||||
Output: string(output),
|
||||
Error: err.Error(),
|
||||
}
|
||||
Tool: req.Tool,
|
||||
Error: execErr.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
return toolResult{
|
||||
writeJSON(w, ToolResult{
|
||||
Success: true,
|
||||
Output: string(output),
|
||||
}
|
||||
Tool: req.Tool,
|
||||
Result: &toolResponseData{
|
||||
Content: result.Content,
|
||||
IsError: result.IsError,
|
||||
Meta: result.Meta,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func buildToolMessage(tool, task string, history []string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("TASK: " + task + "\n\n")
|
||||
b.WriteString("CONVERSATION HISTORY:\n")
|
||||
for _, msg := range history {
|
||||
b.WriteString(strings.Repeat(" ", 4) + strings.Join(strings.Split(msg, "\n"), "\n"+strings.Repeat(" ", 4)) + "\n")
|
||||
func (s *Server) handleToolList(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" {
|
||||
writeError(w, "GET only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
return b.String()
|
||||
|
||||
tools := s.agentRegistry.All()
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"tools": tools,
|
||||
"count": len(tools),
|
||||
})
|
||||
}
|
||||
258
internal/api/handlers_workflow.go
Normal file
258
internal/api/handlers_workflow.go
Normal file
@@ -0,0 +1,258 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/muyue/muyue/internal/workflow"
|
||||
)
|
||||
|
||||
func (s *Server) handleWorkflowCreate(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
engine := s.workflowEngine
|
||||
if engine == nil {
|
||||
engine, _ = workflow.NewEngine(s.agentRegistry)
|
||||
}
|
||||
|
||||
wf := engine.Create(body.Name, body.Description, body.Type, []workflow.Step{})
|
||||
writeJSON(w, wf)
|
||||
}
|
||||
|
||||
func (s *Server) handleWorkflowList(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" {
|
||||
writeError(w, "GET only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
engine := s.workflowEngine
|
||||
if engine == nil {
|
||||
engine, _ = workflow.NewEngine(s.agentRegistry)
|
||||
}
|
||||
|
||||
workflows := engine.List()
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"workflows": workflows,
|
||||
"count": len(workflows),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleWorkflowGet(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" {
|
||||
writeError(w, "GET only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
id := strings.TrimPrefix(r.URL.Path, "/api/workflow/")
|
||||
if id == "" {
|
||||
writeError(w, "workflow id required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
engine := s.workflowEngine
|
||||
if engine == nil {
|
||||
engine, _ = workflow.NewEngine(s.agentRegistry)
|
||||
}
|
||||
|
||||
wf, ok := engine.Get(id)
|
||||
if !ok {
|
||||
writeError(w, "workflow not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, wf)
|
||||
}
|
||||
|
||||
func (s *Server) handleWorkflowDelete(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "DELETE" {
|
||||
writeError(w, "DELETE only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
id := strings.TrimPrefix(r.URL.Path, "/api/workflow/")
|
||||
if id == "" {
|
||||
writeError(w, "workflow id required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
engine := s.workflowEngine
|
||||
if engine == nil {
|
||||
engine, _ = workflow.NewEngine(s.agentRegistry)
|
||||
}
|
||||
|
||||
if err := engine.Delete(id); err != nil {
|
||||
writeError(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, map[string]string{"status": "deleted"})
|
||||
}
|
||||
|
||||
func (s *Server) handleWorkflowPlan(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Goal string `json:"goal"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if body.Goal == "" {
|
||||
writeError(w, "goal is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
planner, err := workflow.NewPlanner(s.config)
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
steps, err := planner.GeneratePlan(context.Background(), body.Goal)
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
engine := s.workflowEngine
|
||||
if engine == nil {
|
||||
engine, _ = workflow.NewEngine(s.agentRegistry)
|
||||
}
|
||||
|
||||
wf := engine.Create("Plan: "+body.Goal[:min(len(body.Goal), 30)], body.Goal, "plan_execute", steps)
|
||||
writeJSON(w, wf)
|
||||
}
|
||||
|
||||
func (s *Server) handleWorkflowExecute(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
id := strings.TrimPrefix(r.URL.Path, "/api/workflow/execute/")
|
||||
if id == "" {
|
||||
writeError(w, "workflow id required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
engine := s.workflowEngine
|
||||
if engine == nil {
|
||||
engine, _ = workflow.NewEngine(s.agentRegistry)
|
||||
}
|
||||
|
||||
wf, ok := engine.Get(id)
|
||||
if !ok {
|
||||
writeError(w, "workflow not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
if r.URL.Query().Get("stream") == "true" {
|
||||
s.handleWorkflowExecuteStream(w, engine, wf)
|
||||
} else {
|
||||
err := engine.Execute(context.Background(), id, nil)
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
wf, _ = engine.Get(id)
|
||||
writeJSON(w, wf)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleWorkflowExecuteStream(w http.ResponseWriter, engine *workflow.Engine, wf *workflow.Workflow) {
|
||||
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)
|
||||
|
||||
writeSSE := func(data map[string]interface{}) {
|
||||
b, _ := json.Marshal(data)
|
||||
w.Write([]byte("data: " + string(b) + "\n\n"))
|
||||
if canFlush {
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
go func() {
|
||||
engine.Execute(context.Background(), wf.ID, func(step *workflow.Step, event string) {
|
||||
writeSSE(map[string]interface{}{
|
||||
"event": event,
|
||||
"step": step,
|
||||
})
|
||||
})
|
||||
|
||||
wf, _ = engine.Get(wf.ID)
|
||||
writeSSE(map[string]interface{}{
|
||||
"event": "workflow_done",
|
||||
"status": wf.Status,
|
||||
"workflow": wf,
|
||||
})
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *Server) handleWorkflowApprove(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
id := strings.TrimPrefix(r.URL.Path, "/api/workflow/approve/")
|
||||
if id == "" {
|
||||
writeError(w, "workflow id required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
StepID string `json:"step_id"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
engine := s.workflowEngine
|
||||
if engine == nil {
|
||||
engine, _ = workflow.NewEngine(s.agentRegistry)
|
||||
}
|
||||
|
||||
if err := engine.ApproveStep(id, body.StepID); err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, map[string]string{"status": "approved"})
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/muyue/muyue/internal/agent"
|
||||
"github.com/muyue/muyue/internal/config"
|
||||
"github.com/muyue/muyue/internal/scanner"
|
||||
"github.com/muyue/muyue/internal/workflow"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
@@ -17,6 +18,7 @@ type Server struct {
|
||||
convStore *ConversationStore
|
||||
agentRegistry *agent.Registry
|
||||
agentToolsJSON json.RawMessage
|
||||
workflowEngine *workflow.Engine
|
||||
}
|
||||
|
||||
func NewServer(cfg *config.MuyueConfig) *Server {
|
||||
@@ -30,6 +32,7 @@ func NewServer(cfg *config.MuyueConfig) *Server {
|
||||
tools := s.agentRegistry.OpenAITools()
|
||||
toolsJSON, _ := json.Marshal(tools)
|
||||
s.agentToolsJSON = json.RawMessage(toolsJSON)
|
||||
s.workflowEngine, _ = workflow.NewEngine(s.agentRegistry)
|
||||
s.routes()
|
||||
return s
|
||||
}
|
||||
@@ -64,6 +67,34 @@ func (s *Server) routes() {
|
||||
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/tool/call", s.handleToolCall)
|
||||
s.mux.HandleFunc("/api/tools/list", s.handleToolList)
|
||||
s.mux.HandleFunc("/api/shell/chat", s.handleShellChat)
|
||||
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/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)
|
||||
}
|
||||
|
||||
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
Reference in New Issue
Block a user