All checks were successful
Beta Release / beta (push) Successful in 37s
- Add POST /api/providers/validate backend endpoint that sends a test request to the provider's chat/completions API to verify the key - Add validateProvider to frontend API client - Redesign PanelProviders: show token input inline with Validate button, display valid/invalid badge after validation, Save only appears after successful validation - Add i18n keys (EN/FR) for validation flow 💾 Generated with Crush Assisted-by: GLM-5-Turbo via Crush <crush@charm.land>
628 lines
16 KiB
Go
628 lines
16 KiB
Go
package api
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"os/exec"
|
|
"strings"
|
|
"time"
|
|
|
|
"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"
|
|
)
|
|
|
|
const summarizePrompt = `Résume la conversation suivante de manière concise et structurée. Garde les points clés, les décisions prises, le contexte technique important. Le résumé doit permettre de continuer la conversation sans perte de contexte. Réponds uniquement avec le résumé, sans meta-commentaire.`
|
|
|
|
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
|
|
}
|
|
|
|
s.convStore.Add("user", body.Message)
|
|
|
|
if s.convStore.NeedsSummarization() {
|
|
s.autoSummarize()
|
|
}
|
|
|
|
orb, err := orchestrator.New(s.config)
|
|
if err != nil {
|
|
writeError(w, err.Error(), http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
orb.SetSystemPrompt(`Tu es l'orchestrateur IA de Muyue Studio. Tu aides l'utilisateur dans ses tâches de développement logiciel. Tu peux :
|
|
- Créer et gérer des plans de développement étape par étape
|
|
- Proposer des agents (outils comme Crush, Claude Code, etc.) pour exécuter des tâches spécifiques
|
|
- Suivre la progression de tâches multi-étapes
|
|
- Suggérer des modifications de fichiers, des revues de code, et des décisions d'architecture
|
|
|
|
Sois concis, actionnable, et structuré. Quand tu proposes un plan, utilise des étapes numérotées claires. Quand tu références des fichiers, utilise des chemins relatifs. Tu es intégré dans l'application desktop Muyue.`)
|
|
|
|
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)
|
|
|
|
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
|
|
}
|
|
|
|
s.convStore.Add("assistant", result)
|
|
|
|
chunkSize := 8
|
|
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
|
|
}
|
|
s.convStore.Add("assistant", result)
|
|
writeJSON(w, map[string]string{"content": result})
|
|
}
|
|
|
|
func (s *Server) autoSummarize() {
|
|
messages := s.convStore.Get()
|
|
if len(messages) < 10 {
|
|
return
|
|
}
|
|
|
|
half := len(messages) / 2
|
|
var oldText string
|
|
for _, m := range messages[:half] {
|
|
oldText += m.Role + ": " + m.Content + "\n\n"
|
|
}
|
|
|
|
summary := s.convStore.GetSummary()
|
|
if summary != "" {
|
|
oldText = "Résumé précédent:\n" + summary + "\n\nNouveaux échanges:\n" + oldText
|
|
}
|
|
|
|
orb, err := orchestrator.New(s.config)
|
|
if err != nil {
|
|
return
|
|
}
|
|
orb.SetSystemPrompt(summarizePrompt)
|
|
|
|
result, err := orb.Send(oldText)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
s.convStore.SetSummary(result)
|
|
s.convStore.TrimOld(len(messages) - half)
|
|
s.convStore.Add("system", "[Conversation résumée automatiquement]")
|
|
}
|
|
|
|
func (s *Server) handleChatHistory(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != "GET" {
|
|
writeError(w, "GET only", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
messages := s.convStore.Get()
|
|
writeJSON(w, map[string]interface{}{
|
|
"messages": messages,
|
|
"tokens": s.convStore.ApproxTokenCount(),
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleChatClear(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != "POST" {
|
|
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
s.convStore.Clear()
|
|
writeJSON(w, map[string]string{"status": "ok"})
|
|
}
|
|
|
|
func (s *Server) handleSaveProfile(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 {
|
|
Name string `json:"name"`
|
|
Pseudo string `json:"pseudo"`
|
|
Email string `json:"email"`
|
|
Editor string `json:"editor"`
|
|
Shell string `json:"shell"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
writeError(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
if body.Name != "" {
|
|
s.config.Profile.Name = body.Name
|
|
}
|
|
if body.Pseudo != "" {
|
|
s.config.Profile.Pseudo = body.Pseudo
|
|
}
|
|
if body.Email != "" {
|
|
s.config.Profile.Email = body.Email
|
|
}
|
|
if body.Editor != "" {
|
|
s.config.Profile.Preferences.Editor = body.Editor
|
|
}
|
|
if body.Shell != "" {
|
|
s.config.Profile.Preferences.Shell = body.Shell
|
|
}
|
|
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) handleSaveProvider(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 {
|
|
Name string `json:"name"`
|
|
APIKey string `json:"api_key"`
|
|
Model string `json:"model"`
|
|
BaseURL string `json:"base_url"`
|
|
Active *bool `json:"active"`
|
|
}
|
|
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
|
|
}
|
|
found := false
|
|
for i := range s.config.AI.Providers {
|
|
if s.config.AI.Providers[i].Name == body.Name {
|
|
if body.APIKey != "" {
|
|
s.config.AI.Providers[i].APIKey = body.APIKey
|
|
}
|
|
if body.Model != "" {
|
|
s.config.AI.Providers[i].Model = body.Model
|
|
}
|
|
if body.BaseURL != "" {
|
|
s.config.AI.Providers[i].BaseURL = body.BaseURL
|
|
}
|
|
if body.Active != nil {
|
|
if *body.Active {
|
|
for j := range s.config.AI.Providers {
|
|
s.config.AI.Providers[j].Active = false
|
|
}
|
|
}
|
|
s.config.AI.Providers[i].Active = *body.Active
|
|
}
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
writeError(w, "provider not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
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) handleRunUpdate(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != "POST" {
|
|
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
var body struct {
|
|
Tool string `json:"tool"`
|
|
}
|
|
json.NewDecoder(r.Body).Decode(&body)
|
|
|
|
result := scanner.ScanSystem()
|
|
statuses := updater.CheckUpdates(result)
|
|
|
|
if body.Tool != "" {
|
|
for _, u := range statuses {
|
|
if u.Tool == body.Tool && u.NeedsUpdate {
|
|
updater.RunAutoUpdate([]updater.UpdateStatus{u})
|
|
}
|
|
}
|
|
writeJSON(w, map[string]string{"status": "ok", "tool": body.Tool})
|
|
return
|
|
}
|
|
|
|
needsUpdate := make([]updater.UpdateStatus, 0)
|
|
for _, u := range statuses {
|
|
if u.NeedsUpdate {
|
|
needsUpdate = append(needsUpdate, u)
|
|
}
|
|
}
|
|
if len(needsUpdate) > 0 {
|
|
updater.RunAutoUpdate(needsUpdate)
|
|
}
|
|
writeJSON(w, map[string]interface{}{
|
|
"status": "ok",
|
|
"updated": len(needsUpdate),
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleValidateProvider(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != "POST" {
|
|
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
var body struct {
|
|
Name string `json:"name"`
|
|
APIKey string `json:"api_key"`
|
|
Model string `json:"model"`
|
|
BaseURL string `json:"base_url"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
writeError(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
if body.APIKey == "" {
|
|
writeError(w, "api_key required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
baseURL := body.BaseURL
|
|
if baseURL == "" {
|
|
for _, p := range s.config.AI.Providers {
|
|
if p.Name == body.Name {
|
|
baseURL = p.BaseURL
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if baseURL == "" {
|
|
switch body.Name {
|
|
case "minimax":
|
|
baseURL = "https://api.minimax.io/v1"
|
|
case "openai":
|
|
baseURL = "https://api.openai.com/v1"
|
|
case "anthropic":
|
|
baseURL = "https://api.anthropic.com/v1"
|
|
default:
|
|
baseURL = "https://api.minimax.io/v1"
|
|
}
|
|
}
|
|
|
|
model := body.Model
|
|
if model == "" {
|
|
for _, p := range s.config.AI.Providers {
|
|
if p.Name == body.Name {
|
|
model = p.Model
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if model == "" {
|
|
model = "MiniMax-Text-01"
|
|
}
|
|
|
|
reqBody, _ := json.Marshal(map[string]interface{}{
|
|
"model": model,
|
|
"messages": []map[string]string{{"role": "user", "content": "Hi"}},
|
|
"max_tokens": 5,
|
|
"stream": false,
|
|
})
|
|
|
|
url := strings.TrimRight(baseURL, "/") + "/chat/completions"
|
|
req, err := http.NewRequest("POST", url, bytes.NewReader(reqBody))
|
|
if err != nil {
|
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Authorization", "Bearer "+body.APIKey)
|
|
|
|
client := &http.Client{Timeout: 15 * time.Second}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
writeError(w, "connection failed: "+err.Error(), http.StatusBadGateway)
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
respBody, _ := io.ReadAll(resp.Body)
|
|
|
|
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
|
|
writeError(w, "invalid_api_key", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
writeError(w, "api_error: "+string(respBody), http.StatusBadGateway)
|
|
return
|
|
}
|
|
|
|
writeJSON(w, map[string]interface{}{"status": "valid"})
|
|
}
|