Compare commits
9 Commits
v0.3.0
...
v0.3.1-bet
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93a22d4075 | ||
|
|
e0e1e73bca | ||
|
|
0496ca789b | ||
|
|
b407ab879b | ||
|
|
12df184e11 | ||
|
|
8af6d25e28 | ||
|
|
4fd599adec | ||
|
|
bcba5932d5 | ||
|
|
04b0fff791 |
@@ -15,7 +15,7 @@ jobs:
|
|||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: '1.24.3'
|
go-version: '1.24'
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ jobs:
|
|||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: '1.24.3'
|
go-version: '1.24'
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ jobs:
|
|||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: '1.24.3'
|
go-version: '1.24'
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
|
|||||||
27
CHANGELOG.md
27
CHANGELOG.md
@@ -6,8 +6,35 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- **Command injection**: Removed non-functional AI sidebar from Shell.jsx that interpolated user input directly into a shell command (`echo "AI: ${text}"`). The panel was a stub with no real AI integration.
|
||||||
|
- **WebSocket origin validation**: Terminal WebSocket handler now validates the `Origin` header matches the server's own host.
|
||||||
|
- **DELETE method guard**: Terminal sessions DELETE endpoint now rejects non-DELETE methods.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Message ID collisions**: `generateMsgID()` now appends nanosecond suffix to prevent collisions under rapid creation.
|
||||||
|
- **Legacy dir migration**: Config migration from `~/.muyue` to XDG path now logs errors instead of silently failing.
|
||||||
|
- **MCP JSON parsing**: `json.Unmarshal` errors in MCP config loading are now handled instead of ignored.
|
||||||
|
- **API header merging**: `client.js` `request()` now correctly merges caller headers with defaults (was overwriting `Content-Type`).
|
||||||
|
- **Variable shadowing**: `t` translation function shadowed by `.filter(t => ...)` in Config.jsx and App.jsx — renamed to `tool`.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Real SSE streaming**: Chat endpoint now streams AI responses via SSE (`data: {"content":"..."}` chunks) instead of fake 8-rune chunking. Frontend renders responses progressively as they arrive.
|
||||||
|
- **Progressive rendering**: Studio.jsx now uses `StreamingItem` component to display partial AI output during streaming, with cursor animation.
|
||||||
|
- **Theme from config**: App.jsx loads theme from user profile preferences on startup (was hardcoded to `cyberpunk-red`).
|
||||||
|
- **Handlers split**: Monolithic `handlers.go` split into 6 focused files: `handlers_common.go`, `handlers_info.go`, `handlers_tools.go`, `handlers_config.go`, `handlers_chat.go`, `handlers_terminal.go`.
|
||||||
|
- **Dynamic version**: Config `Version` field now uses `version.Version` constant instead of hardcoded `"0.1.0"`.
|
||||||
|
- **Path construction**: `filepath.Join` used consistently in installer, MCP, scanner, and profiler for cross-platform safety.
|
||||||
|
- **CI Go version**: All 3 CI workflows updated from `go-version: '1.24.3'` to `'1.24'` to match `go.mod`.
|
||||||
|
- **Dead code removed**: Unused `addNotif` function in Dashboard.jsx, unused `layout` destructuring, dead `tools`/`updates`/`onRescan` props, dead AI sidebar in Shell.jsx, associated CSS and i18n keys.
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- **SendStream tests**: 3 new tests for the SSE streaming method (chunk parsing, history accumulation, API error handling) using `httptest` server.
|
||||||
|
|
||||||
- **Desktop mode**: React 19 web UI served locally, auto-opens in browser. Frontend embedded in Go binary via `go:embed`.
|
- **Desktop mode**: React 19 web UI served locally, auto-opens in browser. Frontend embedded in Go binary via `go:embed`.
|
||||||
- **API backend**: 15 REST endpoints (`/api/info`, `/api/system`, `/api/tools`, `/api/config`, `/api/providers`, `/api/skills`, `/api/lsp`, `/api/mcp`, `/api/updates`, `/api/scan`, `/api/install`, `/api/terminal`, `/api/mcp/configure`, `/api/preferences`).
|
- **API backend**: 15 REST endpoints (`/api/info`, `/api/system`, `/api/tools`, `/api/config`, `/api/providers`, `/api/skills`, `/api/lsp`, `/api/mcp`, `/api/updates`, `/api/scan`, `/api/install`, `/api/terminal`, `/api/mcp/configure`, `/api/preferences`).
|
||||||
- **i18n**: Full FR/EN translation system with keyboard layout awareness (AZERTY, QWERTY, QWERTZ). Preferences synced to backend.
|
- **i18n**: Full FR/EN translation system with keyboard layout awareness (AZERTY, QWERTY, QWERTZ). Preferences synced to backend.
|
||||||
|
|||||||
4
go.mod
4
go.mod
@@ -1,6 +1,8 @@
|
|||||||
module github.com/muyue/muyue
|
module github.com/muyue/muyue
|
||||||
|
|
||||||
go 1.24.3
|
go 1.24.2
|
||||||
|
|
||||||
|
toolchain go1.24.3
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/charmbracelet/huh v1.0.0
|
github.com/charmbracelet/huh v1.0.0
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -153,5 +154,5 @@ func (cs *ConversationStore) NeedsSummarization() bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func generateMsgID() string {
|
func generateMsgID() string {
|
||||||
return time.Now().Format("20060102150405.000")
|
return time.Now().Format("20060102150405.000") + "-" + fmt.Sprintf("%d", time.Now().UnixNano())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,627 +0,0 @@
|
|||||||
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"})
|
|
||||||
}
|
|
||||||
236
internal/api/handlers_chat.go
Normal file
236
internal/api/handlers_chat.go
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os/exec"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/muyue/muyue/internal/orchestrator"
|
||||||
|
)
|
||||||
|
|
||||||
|
var toolCallRegex = regexp.MustCompile(`\[TOOL_CALL:\{[^\}]+\}\]`)
|
||||||
|
|
||||||
|
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'assistant IA de Muyue Studio. Tu as accès à un outil "crush" pour exécuter des tâches complexes sur l'ordinateur de l'utilisateur.
|
||||||
|
|
||||||
|
RÈGLES ABSOLUES:
|
||||||
|
1. Tu as DEUX possibilités ONLY:
|
||||||
|
- Répondre directement à l'utilisateur avec tes connaissances
|
||||||
|
- Demander l'exécution d'une tâche via crush en utilisant ce format EXACT:
|
||||||
|
[TOOL_CALL:{"tool":"crush","task":"description de la tâche"}]
|
||||||
|
|
||||||
|
2. Quand tu utilises [TOOL_CALL:...], le système exécutera la tâche et te donnera le résultat.
|
||||||
|
Tu peux ensuite répondre à l'utilisateur avec ce résultat.
|
||||||
|
|
||||||
|
3. SOIS CONCIS - pas de blabla, vais droit au but.
|
||||||
|
|
||||||
|
4. L'utilisateur ne voit PAS tes pensées entre <think> tags.
|
||||||
|
|
||||||
|
5. EXEMPLES d'utilisation de tool:
|
||||||
|
- "cherche tous les fichiers .md dans le projet" → [TOOL_CALL:{"tool":"crush","task":"Recherche les fichiers .md dans le projet courant"}]
|
||||||
|
- "aide-moi à déboguer cette erreur" → tu peux répondre directement si tu as assez d'info, sinon utiliser tool
|
||||||
|
- "quelle est la météo?" → [TOOL_CALL:{"tool":"crush","task":"Cherche la météo actuelle"}]
|
||||||
|
|
||||||
|
6. Ne fais PAS de multi-step tool calls dans une seule réponse. Attends le résultat avant de continuer.`)
|
||||||
|
|
||||||
|
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.SendStream(body.Message, func(chunk string) {
|
||||||
|
if strings.HasPrefix(chunk, "<think") {
|
||||||
|
data, _ := json.Marshal(map[string]string{"thinking": strings.TrimPrefix(chunk, "<think")})
|
||||||
|
w.Write([]byte("data: " + string(data) + "\n\n"))
|
||||||
|
if canFlush {
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if chunk == "</think>" {
|
||||||
|
data, _ := json.Marshal(map[string]string{"thinking_end": "true"})
|
||||||
|
w.Write([]byte("data: " + string(data) + "\n\n"))
|
||||||
|
if canFlush {
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, _ := json.Marshal(map[string]string{"content": chunk})
|
||||||
|
w.Write([]byte("data: " + string(data) + "\n\n"))
|
||||||
|
if canFlush {
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process tool calls if any
|
||||||
|
cleanResult := processToolCalls(result)
|
||||||
|
s.convStore.Add("assistant", cleanResult)
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
cleanResult := processToolCalls(result)
|
||||||
|
s.convStore.Add("assistant", cleanResult)
|
||||||
|
writeJSON(w, map[string]string{"content": cleanResult})
|
||||||
|
}
|
||||||
|
|
||||||
|
func processToolCalls(content string) string {
|
||||||
|
matches := toolCallRegex.FindAllString(content, -1)
|
||||||
|
if len(matches) == 0 {
|
||||||
|
return cleanThinkingTags(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
var result strings.Builder
|
||||||
|
clean := content
|
||||||
|
|
||||||
|
for _, match := range matches {
|
||||||
|
// Extract tool and task from [TOOL_CALL:{...}]
|
||||||
|
inner := strings.TrimPrefix(match, "[TOOL_CALL:")
|
||||||
|
inner = strings.TrimSuffix(inner, "]}") + "}"
|
||||||
|
|
||||||
|
var call struct {
|
||||||
|
Tool string `json:"tool"`
|
||||||
|
Task string `json:"task"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(inner), &call); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if call.Tool == "crush" && call.Task != "" {
|
||||||
|
result.WriteString(fmt.Sprintf("> %s\n\n", call.Task))
|
||||||
|
output := executeCrush(call.Task)
|
||||||
|
result.WriteString(output)
|
||||||
|
result.WriteString("\n\n---\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
clean = strings.Replace(clean, match, "", 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
clean = cleanThinkingTags(clean)
|
||||||
|
|
||||||
|
if result.Len() > 0 {
|
||||||
|
clean = strings.TrimSpace(clean) + "\n\n" + strings.TrimSpace(result.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
return clean
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanThinkingTags(content string) string {
|
||||||
|
re := regexp.MustCompile(`(?s)<think[^>]*>.*?</think>`)
|
||||||
|
return re.ReplaceAllString(content, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func executeCrush(task string) string {
|
||||||
|
cmd := exec.Command("crush", "run", task)
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Sprintf("Erreur: %v\n%s", err, string(output))
|
||||||
|
}
|
||||||
|
return string(output)
|
||||||
|
}
|
||||||
|
|
||||||
|
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"})
|
||||||
|
}
|
||||||
17
internal/api/handlers_common.go
Normal file
17
internal/api/handlers_common.go
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
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})
|
||||||
|
}
|
||||||
283
internal/api/handlers_config.go
Normal file
283
internal/api/handlers_config.go
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/muyue/muyue/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
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) 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) 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-M2.7"
|
||||||
|
}
|
||||||
|
|
||||||
|
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"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleSaveTerminalSettings(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 {
|
||||||
|
FontSize int `json:"font_size"`
|
||||||
|
FontFamily string `json:"font_family"`
|
||||||
|
Theme string `json:"theme"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.FontSize > 0 {
|
||||||
|
s.config.Terminal.FontSize = body.FontSize
|
||||||
|
}
|
||||||
|
if body.FontFamily != "" {
|
||||||
|
s.config.Terminal.FontFamily = body.FontFamily
|
||||||
|
}
|
||||||
|
if body.Theme != "" {
|
||||||
|
s.config.Terminal.Theme = body.Theme
|
||||||
|
}
|
||||||
|
if err := config.Save(s.config); err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]interface{}{
|
||||||
|
"status": "ok",
|
||||||
|
"theme": config.GetTerminalTheme(s.config.Terminal.Theme),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleGetTerminalThemes(w http.ResponseWriter, r *http.Request) {
|
||||||
|
themes := make([]map[string]string, 0, len(config.DEFAULT_TERMINAL_THEMES))
|
||||||
|
for id, theme := range config.DEFAULT_TERMINAL_THEMES {
|
||||||
|
themes = append(themes, map[string]string{
|
||||||
|
"id": id,
|
||||||
|
"name": theme.Name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]interface{}{"themes": themes})
|
||||||
|
}
|
||||||
119
internal/api/handlers_info.go
Normal file
119
internal/api/handlers_info.go
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/muyue/muyue/internal/lsp"
|
||||||
|
"github.com/muyue/muyue/internal/mcp"
|
||||||
|
"github.com/muyue/muyue/internal/scanner"
|
||||||
|
"github.com/muyue/muyue/internal/skills"
|
||||||
|
"github.com/muyue/muyue/internal/version"
|
||||||
|
)
|
||||||
|
|
||||||
|
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) handleScan(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s.scanResult = scanner.ScanSystem()
|
||||||
|
writeJSON(w, map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
44
internal/api/handlers_terminal.go
Normal file
44
internal/api/handlers_terminal.go
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"os/exec"
|
||||||
|
)
|
||||||
|
|
||||||
|
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 := detectShell()
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
94
internal/api/handlers_tools.go
Normal file
94
internal/api/handlers_tools.go
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/muyue/muyue/internal/scanner"
|
||||||
|
"github.com/muyue/muyue/internal/updater"
|
||||||
|
)
|
||||||
|
|
||||||
|
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) 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"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeError(w, "invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
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),
|
||||||
|
})
|
||||||
|
}
|
||||||
80
internal/api/handlers_tools_exec.go
Normal file
80
internal/api/handlers_tools_exec.go
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type toolCallRequest struct {
|
||||||
|
Tool string `json:"tool"`
|
||||||
|
Task string `json:"task"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type toolResult struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Output string `json:"output"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleToolCall(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "POST" {
|
||||||
|
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Task == "" {
|
||||||
|
writeError(w, "task is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
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{
|
||||||
|
Success: false,
|
||||||
|
Output: string(output),
|
||||||
|
Error: err.Error(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return toolResult{
|
||||||
|
Success: true,
|
||||||
|
Output: string(output),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
@@ -43,6 +43,8 @@ func (s *Server) routes() {
|
|||||||
s.mux.HandleFunc("/api/ws/terminal", s.handleTerminalWS)
|
s.mux.HandleFunc("/api/ws/terminal", s.handleTerminalWS)
|
||||||
s.mux.HandleFunc("/api/terminal/sessions", s.handleTerminalSessions)
|
s.mux.HandleFunc("/api/terminal/sessions", s.handleTerminalSessions)
|
||||||
s.mux.HandleFunc("/api/terminal/sessions/", s.handleTerminalSessionsDelete)
|
s.mux.HandleFunc("/api/terminal/sessions/", s.handleTerminalSessionsDelete)
|
||||||
|
s.mux.HandleFunc("/api/terminal/themes", s.handleGetTerminalThemes)
|
||||||
|
s.mux.HandleFunc("/api/terminal/settings", s.handleSaveTerminalSettings)
|
||||||
s.mux.HandleFunc("/api/mcp/configure", s.handleMCPConfigure)
|
s.mux.HandleFunc("/api/mcp/configure", s.handleMCPConfigure)
|
||||||
s.mux.HandleFunc("/api/config/profile", s.handleSaveProfile)
|
s.mux.HandleFunc("/api/config/profile", s.handleSaveProfile)
|
||||||
s.mux.HandleFunc("/api/config/provider", s.handleSaveProvider)
|
s.mux.HandleFunc("/api/config/provider", s.handleSaveProvider)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -18,7 +19,23 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var upgrader = websocket.Upgrader{
|
var upgrader = websocket.Upgrader{
|
||||||
CheckOrigin: func(r *http.Request) bool { return true },
|
CheckOrigin: func(r *http.Request) bool {
|
||||||
|
origin := r.Header.Get("Origin")
|
||||||
|
if origin == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(origin, "http://127.0.0.1"),
|
||||||
|
strings.HasPrefix(origin, "http://localhost"),
|
||||||
|
strings.HasPrefix(origin, "http://[::1]"),
|
||||||
|
strings.HasPrefix(origin, "https://127.0.0.1"),
|
||||||
|
strings.HasPrefix(origin, "https://localhost"),
|
||||||
|
strings.HasPrefix(origin, "https://[::1]"):
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
type wsMessage struct {
|
type wsMessage struct {
|
||||||
@@ -39,13 +56,17 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
|
|||||||
var initMsg wsMessage
|
var initMsg wsMessage
|
||||||
_, raw, err := conn.ReadMessage()
|
_, raw, err := conn.ReadMessage()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Printf("terminal: read init message failed: %v", err)
|
||||||
conn.WriteJSON(wsMessage{Type: "error", Data: "failed to read init message"})
|
conn.WriteJSON(wsMessage{Type: "error", Data: "failed to read init message"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
log.Printf("terminal: init message received: %s", string(raw))
|
||||||
if err := json.Unmarshal(raw, &initMsg); err != nil {
|
if err := json.Unmarshal(raw, &initMsg); err != nil {
|
||||||
|
log.Printf("terminal: unmarshal init message failed: %v", err)
|
||||||
conn.WriteJSON(wsMessage{Type: "error", Data: "invalid init message"})
|
conn.WriteJSON(wsMessage{Type: "error", Data: "invalid init message"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
log.Printf("terminal: init type=%q data=%q", initMsg.Type, initMsg.Data)
|
||||||
|
|
||||||
var cmd *exec.Cmd
|
var cmd *exec.Cmd
|
||||||
|
|
||||||
@@ -79,37 +100,52 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
cmd = exec.Command("ssh", sshArgs...)
|
cmd = exec.Command("ssh", sshArgs...)
|
||||||
} else {
|
} else {
|
||||||
shell := initMsg.Data
|
shell := strings.TrimSpace(initMsg.Data)
|
||||||
|
log.Printf("terminal: requested shell=%q, trimmed=%q", initMsg.Data, shell)
|
||||||
if shell == "" {
|
if shell == "" {
|
||||||
shell = detectShell()
|
shell = detectShell()
|
||||||
} else {
|
log.Printf("terminal: auto-detected shell=%q", shell)
|
||||||
if path, err := exec.LookPath(shell); err == nil {
|
}
|
||||||
shell = path
|
|
||||||
}
|
if shell == "" {
|
||||||
|
log.Printf("terminal: no shell detected, falling back to /bin/sh")
|
||||||
|
shell = "/bin/sh"
|
||||||
|
}
|
||||||
|
|
||||||
|
if path, err := exec.LookPath(shell); err == nil {
|
||||||
|
shell = path
|
||||||
|
log.Printf("terminal: resolved shell path=%q", shell)
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := os.Stat(shell); err != nil {
|
if _, err := os.Stat(shell); err != nil {
|
||||||
conn.WriteJSON(wsMessage{Type: "error", Data: fmt.Sprintf("shell not found: %s", shell)})
|
log.Printf("terminal: shell stat failed: %v for %q", err, shell)
|
||||||
|
conn.WriteJSON(wsMessage{Type: "error", Data: fmt.Sprintf("shell not found: %s (resolved from: %q)", shell, initMsg.Data)})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.Contains(shell, "wsl") {
|
shellName := filepath.Base(shell)
|
||||||
|
switch shellName {
|
||||||
|
case "wsl":
|
||||||
cmd = exec.Command(shell, "--shell-type", "login")
|
cmd = exec.Command(shell, "--shell-type", "login")
|
||||||
} else if strings.Contains(shell, "powershell") || strings.Contains(shell, "pwsh") {
|
case "powershell", "pwsh":
|
||||||
cmd = exec.Command(shell, "-NoLogo", "-NoProfile")
|
cmd = exec.Command(shell, "-NoLogo", "-NoProfile")
|
||||||
} else {
|
case "fish":
|
||||||
cmd = exec.Command(shell, "--login")
|
cmd = exec.Command(shell, "--login")
|
||||||
|
default:
|
||||||
|
cmd = exec.Command(shell)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Env = append(os.Environ(), "TERM=xterm-256color")
|
cmd.Env = append(os.Environ(), "TERM=xterm-256color")
|
||||||
|
|
||||||
|
log.Printf("terminal: starting pty with cmd=%q args=%v", cmd.Path, cmd.Args)
|
||||||
ptmx, err := pty.Start(cmd)
|
ptmx, err := pty.Start(cmd)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("pty start: %v", err)
|
log.Printf("terminal: pty start failed: %v", err)
|
||||||
conn.WriteJSON(wsMessage{Type: "error", Data: err.Error()})
|
conn.WriteJSON(wsMessage{Type: "error", Data: err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
log.Printf("terminal: pty started successfully")
|
||||||
defer func() {
|
defer func() {
|
||||||
ptmx.Close()
|
ptmx.Close()
|
||||||
if cmd.Process != nil {
|
if cmd.Process != nil {
|
||||||
@@ -232,6 +268,10 @@ func (s *Server) handleTerminalSessions(w http.ResponseWriter, r *http.Request)
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleTerminalSessionsDelete(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleTerminalSessionsDelete(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "DELETE" {
|
||||||
|
writeError(w, "DELETE only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
name := strings.TrimPrefix(r.URL.Path, "/api/terminal/sessions/")
|
name := strings.TrimPrefix(r.URL.Path, "/api/terminal/sessions/")
|
||||||
if name == "" {
|
if name == "" {
|
||||||
writeError(w, "name required", http.StatusBadRequest)
|
writeError(w, "name required", http.StatusBadRequest)
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ package config
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"github.com/muyue/muyue/internal/secret"
|
"github.com/muyue/muyue/internal/secret"
|
||||||
|
"github.com/muyue/muyue/internal/version"
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -66,9 +68,73 @@ type MuyueConfig struct {
|
|||||||
CustomPrompt bool `yaml:"custom_prompt"`
|
CustomPrompt bool `yaml:"custom_prompt"`
|
||||||
PromptTheme string `yaml:"prompt_theme"`
|
PromptTheme string `yaml:"prompt_theme"`
|
||||||
SSH []SSHConnection `yaml:"ssh"`
|
SSH []SSHConnection `yaml:"ssh"`
|
||||||
|
FontSize int `yaml:"font_size"`
|
||||||
|
FontFamily string `yaml:"font_family"`
|
||||||
|
Theme string `yaml:"theme"`
|
||||||
} `yaml:"terminal"`
|
} `yaml:"terminal"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TerminalTheme struct {
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
Background string `yaml:"background"`
|
||||||
|
Foreground string `yaml:"foreground"`
|
||||||
|
Cursor string `yaml:"cursor"`
|
||||||
|
Black string `yaml:"black"`
|
||||||
|
Red string `yaml:"red"`
|
||||||
|
Green string `yaml:"green"`
|
||||||
|
Yellow string `yaml:"yellow"`
|
||||||
|
Blue string `yaml:"blue"`
|
||||||
|
Magenta string `yaml:"magenta"`
|
||||||
|
Cyan string `yaml:"cyan"`
|
||||||
|
White string `yaml:"white"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var DEFAULT_TERMINAL_THEMES = map[string]TerminalTheme{
|
||||||
|
"default": {
|
||||||
|
Name: "Default", Background: "#0A0A0C", Foreground: "#EAE0E2",
|
||||||
|
Cursor: "#FF0033", Black: "#0A0A0C", Red: "#FF0033",
|
||||||
|
Green: "#00E676", Yellow: "#FFD740", Blue: "#448AFF",
|
||||||
|
Magenta: "#FF1A5E", Cyan: "#00BCD4", White: "#EAE0E2",
|
||||||
|
},
|
||||||
|
"monokai": {
|
||||||
|
Name: "Monokai", Background: "#272822", Foreground: "#F8F8F2",
|
||||||
|
Cursor: "#F8F8F0", Black: "#272822", Red: "#F92672",
|
||||||
|
Green: "#A6E22E", Yellow: "#E6DB74", Blue: "#66D9EF",
|
||||||
|
Magenta: "#AE81FF", Cyan: "#A1EFE4", White: "#F8F8F2",
|
||||||
|
},
|
||||||
|
"gruvbox": {
|
||||||
|
Name: "Gruvbox", Background: "#282828", Foreground: "#EBDBB2",
|
||||||
|
Cursor: "#FB4934", Black: "#282828", Red: "#CC241D",
|
||||||
|
Green: "#98971A", Yellow: "#D79921", Blue: "#458588",
|
||||||
|
Magenta: "#B16286", Cyan: "#689D6A", White: "#EBDBB2",
|
||||||
|
},
|
||||||
|
"nord": {
|
||||||
|
Name: "Nord", Background: "#2E3440", Foreground: "#D8DEE9",
|
||||||
|
Cursor: "#D8DEE9", Black: "#2E3440", Red: "#BF616A",
|
||||||
|
Green: "#A3BE8C", Yellow: "#EBCB8B", Blue: "#81A1C1",
|
||||||
|
Magenta: "#B48EAD", Cyan: "#88C0D0", White: "#D8DEE9",
|
||||||
|
},
|
||||||
|
"solarized-dark": {
|
||||||
|
Name: "Solarized Dark", Background: "#002B36", Foreground: "#839496",
|
||||||
|
Cursor: "#D33682", Black: "#002B36", Red: "#DC322F",
|
||||||
|
Green: "#859900", Yellow: "#B58900", Blue: "#268BD2",
|
||||||
|
Magenta: "#D33682", Cyan: "#2AA198", White: "#FDF6E3",
|
||||||
|
},
|
||||||
|
"dracula": {
|
||||||
|
Name: "Dracula", Background: "#282A36", Foreground: "#F8F8F2",
|
||||||
|
Cursor: "#F8F8F2", Black: "#282A36", Red: "#FF5555",
|
||||||
|
Green: "#50FA7B", Yellow: "#F1FA8C", Blue: "#BD93F9",
|
||||||
|
Magenta: "#FF79C6", Cyan: "#8BE9FD", White: "#F8F8F2",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetTerminalTheme(name string) TerminalTheme {
|
||||||
|
if theme, ok := DEFAULT_TERMINAL_THEMES[name]; ok {
|
||||||
|
return theme
|
||||||
|
}
|
||||||
|
return DEFAULT_TERMINAL_THEMES["default"]
|
||||||
|
}
|
||||||
|
|
||||||
func ConfigDir() (string, error) {
|
func ConfigDir() (string, error) {
|
||||||
configDir, err := os.UserConfigDir()
|
configDir, err := os.UserConfigDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -79,7 +145,9 @@ func ConfigDir() (string, error) {
|
|||||||
legacyDir := filepath.Join(homeDir(), ".muyue")
|
legacyDir := filepath.Join(homeDir(), ".muyue")
|
||||||
if _, err := os.Stat(legacyDir); err == nil {
|
if _, err := os.Stat(legacyDir); err == nil {
|
||||||
if _, err := os.Stat(dir); err != nil {
|
if _, err := os.Stat(dir); err != nil {
|
||||||
os.Rename(legacyDir, dir)
|
if err := os.Rename(legacyDir, dir); err != nil {
|
||||||
|
log.Printf("config migration: rename %s to %s: %v", legacyDir, dir, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,7 +247,7 @@ func Save(cfg *MuyueConfig) error {
|
|||||||
|
|
||||||
func Default() *MuyueConfig {
|
func Default() *MuyueConfig {
|
||||||
cfg := &MuyueConfig{
|
cfg := &MuyueConfig{
|
||||||
Version: "0.1.0",
|
Version: version.Version,
|
||||||
Profile: Profile{
|
Profile: Profile{
|
||||||
Name: "",
|
Name: "",
|
||||||
Pseudo: "muyue",
|
Pseudo: "muyue",
|
||||||
|
|||||||
@@ -4,12 +4,14 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/muyue/muyue/internal/version"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestDefault(t *testing.T) {
|
func TestDefault(t *testing.T) {
|
||||||
cfg := Default()
|
cfg := Default()
|
||||||
if cfg.Version != "0.1.0" {
|
if cfg.Version != version.Version {
|
||||||
t.Errorf("Expected version 0.1.0, got %s", cfg.Version)
|
t.Errorf("Expected version %s, got %s", version.Version, cfg.Version)
|
||||||
}
|
}
|
||||||
if cfg.Profile.Pseudo != "muyue" {
|
if cfg.Profile.Pseudo != "muyue" {
|
||||||
t.Errorf("Expected pseudo muyue, got %s", cfg.Profile.Pseudo)
|
t.Errorf("Expected pseudo muyue, got %s", cfg.Profile.Pseudo)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -123,7 +124,7 @@ func (i *Installer) installBMAD() InstallResult {
|
|||||||
return InstallResult{Tool: "bmad", Success: false, Message: err.Error()}
|
return InstallResult{Tool: "bmad", Success: false, Message: err.Error()}
|
||||||
}
|
}
|
||||||
|
|
||||||
bmadDir := configDir + "/bmad"
|
bmadDir := filepath.Join(configDir, "bmad")
|
||||||
os.MkdirAll(bmadDir, 0755)
|
os.MkdirAll(bmadDir, 0755)
|
||||||
|
|
||||||
cmd := exec.Command("npx", "bmad-method@latest", "install",
|
cmd := exec.Command("npx", "bmad-method@latest", "install",
|
||||||
@@ -175,7 +176,7 @@ func (i *Installer) installGo() InstallResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
home, _ := os.UserHomeDir()
|
home, _ := os.UserHomeDir()
|
||||||
goDir := home + "/.local/go"
|
goDir := filepath.Join(home, ".local", "go")
|
||||||
|
|
||||||
cmd := exec.Command("bash", "-c", fmt.Sprintf(
|
cmd := exec.Command("bash", "-c", fmt.Sprintf(
|
||||||
"curl -sL https://go.dev/dl/go1.24.3.%s-%s.tar.gz | tar -C %s/.local -xzf -",
|
"curl -sL https://go.dev/dl/go1.24.3.%s-%s.tar.gz | tar -C %s/.local -xzf -",
|
||||||
@@ -291,15 +292,15 @@ func (i *Installer) installGit() InstallResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
func (i *Installer) getRCFile() string {
|
func (i *Installer) getRCFile() string {
|
||||||
home, _ := os.UserHomeDir()
|
home, _ := os.UserHomeDir()
|
||||||
switch i.system.Shell {
|
switch i.system.Shell {
|
||||||
case "zsh":
|
case "zsh":
|
||||||
return home + "/.zshrc"
|
return filepath.Join(home, ".zshrc")
|
||||||
case "fish":
|
case "fish":
|
||||||
return home + "/.config/fish/config.fish"
|
return filepath.Join(home, ".config", "fish", "config.fish")
|
||||||
default:
|
default:
|
||||||
return home + "/.bashrc"
|
return filepath.Join(home, ".bashrc")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -340,7 +341,7 @@ func (i *Installer) installUv() InstallResult {
|
|||||||
return InstallResult{Tool: "uv", Success: false, Message: fmt.Sprintf("install failed: %s: %s", err, string(output))}
|
return InstallResult{Tool: "uv", Success: false, Message: fmt.Sprintf("install failed: %s: %s", err, string(output))}
|
||||||
}
|
}
|
||||||
rcFile := i.getRCFile()
|
rcFile := i.getRCFile()
|
||||||
appendLine(rcFile, "export PATH="+home+"/.local/bin:$PATH")
|
appendLine(rcFile, "export PATH="+filepath.Join(home, ".local", "bin")+":$PATH")
|
||||||
return InstallResult{Tool: "uv", Success: true, Message: "installed (added ~/.local/bin to PATH)"}
|
return InstallResult{Tool: "uv", Success: true, Message: "installed (added ~/.local/bin to PATH)"}
|
||||||
case platform.Windows:
|
case platform.Windows:
|
||||||
cmd = exec.Command("powershell", "-Command", "irm https://astral.sh/uv/install.ps1 | iex")
|
cmd = exec.Command("powershell", "-Command", "irm https://astral.sh/uv/install.ps1 | iex")
|
||||||
|
|||||||
@@ -101,5 +101,3 @@ func InstallForLanguages(languages []string) []LSPServer {
|
|||||||
|
|
||||||
return results
|
return results
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ func ScanServers() []MCPServer {
|
|||||||
|
|
||||||
func getCoreEntries(homeDir string) []mcpEntry {
|
func getCoreEntries(homeDir string) []mcpEntry {
|
||||||
return []mcpEntry{
|
return []mcpEntry{
|
||||||
{"filesystem", "npx", []string{"-y", "@modelcontextprotocol/server-filesystem", homeDir + "/projects"}, nil},
|
{"filesystem", "npx", []string{"-y", "@modelcontextprotocol/server-filesystem", filepath.Join(homeDir, "projects")}, nil},
|
||||||
{"fetch", "npx", []string{"-y", "@modelcontextprotocol/server-fetch"}, nil},
|
{"fetch", "npx", []string{"-y", "@modelcontextprotocol/server-fetch"}, nil},
|
||||||
{"memory", "npx", []string{"-y", "@modelcontextprotocol/server-memory"}, nil},
|
{"memory", "npx", []string{"-y", "@modelcontextprotocol/server-memory"}, nil},
|
||||||
}
|
}
|
||||||
@@ -86,7 +86,9 @@ func writeMCPConfig(configPath string, mcpKey string, entries []mcpEntry) error
|
|||||||
existing := map[string]interface{}{}
|
existing := map[string]interface{}{}
|
||||||
data, err := os.ReadFile(configPath)
|
data, err := os.ReadFile(configPath)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
json.Unmarshal(data, &existing)
|
if err := json.Unmarshal(data, &existing); err != nil {
|
||||||
|
return fmt.Errorf("parse existing config: %w", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
mcpMap := map[string]interface{}{}
|
mcpMap := map[string]interface{}{}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package orchestrator
|
package orchestrator
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -34,6 +35,9 @@ type ChatResponse struct {
|
|||||||
Message struct {
|
Message struct {
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
} `json:"message"`
|
} `json:"message"`
|
||||||
|
Delta struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
} `json:"delta"`
|
||||||
} `json:"choices"`
|
} `json:"choices"`
|
||||||
Usage struct {
|
Usage struct {
|
||||||
TotalTokens int `json:"total_tokens"`
|
TotalTokens int `json:"total_tokens"`
|
||||||
@@ -161,6 +165,104 @@ func (o *Orchestrator) Send(userMessage string) (string, error) {
|
|||||||
return content, nil
|
return content, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (o *Orchestrator) SendStream(userMessage string, onChunk func(string)) (string, error) {
|
||||||
|
o.histMu.Lock()
|
||||||
|
o.history = append(o.history, Message{
|
||||||
|
Role: "user",
|
||||||
|
Content: userMessage,
|
||||||
|
})
|
||||||
|
|
||||||
|
if len(o.history) > maxHistorySize {
|
||||||
|
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: messages,
|
||||||
|
Stream: true,
|
||||||
|
}
|
||||||
|
o.histMu.Unlock()
|
||||||
|
|
||||||
|
body, err := json.Marshal(reqBody)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("marshal request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
baseURL := o.provider.BaseURL
|
||||||
|
if baseURL == "" {
|
||||||
|
baseURL = getProviderBaseURL(o.provider.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
url := strings.TrimRight(baseURL, "/") + "/chat/completions"
|
||||||
|
|
||||||
|
req, err := http.NewRequest("POST", url, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+o.provider.APIKey)
|
||||||
|
|
||||||
|
resp, err := o.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("send request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
respBody, _ := io.ReadAll(resp.Body)
|
||||||
|
return "", fmt.Errorf("API error (%d): %s", resp.StatusCode, string(respBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
var fullContent strings.Builder
|
||||||
|
scanner := bufio.NewScanner(resp.Body)
|
||||||
|
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||||
|
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
if !strings.HasPrefix(line, "data: ") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
data := strings.TrimPrefix(line, "data: ")
|
||||||
|
if data == "[DONE]" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
var chatResp ChatResponse
|
||||||
|
if err := json.Unmarshal([]byte(data), &chatResp); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(chatResp.Choices) > 0 {
|
||||||
|
chunk := chatResp.Choices[0].Delta.Content
|
||||||
|
if chunk != "" {
|
||||||
|
fullContent.WriteString(chunk)
|
||||||
|
onChunk(chunk)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
return fullContent.String(), fmt.Errorf("read stream: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
content := cleanAIResponse(fullContent.String())
|
||||||
|
o.histMu.Lock()
|
||||||
|
o.history = append(o.history, Message{
|
||||||
|
Role: "assistant",
|
||||||
|
Content: content,
|
||||||
|
})
|
||||||
|
o.histMu.Unlock()
|
||||||
|
|
||||||
|
return content, nil
|
||||||
|
}
|
||||||
|
|
||||||
func cleanAIResponse(content string) string {
|
func cleanAIResponse(content string) string {
|
||||||
content = thinkRegex.ReplaceAllString(content, "")
|
content = thinkRegex.ReplaceAllString(content, "")
|
||||||
lines := strings.Split(content, "\n")
|
lines := strings.Split(content, "\n")
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package orchestrator
|
package orchestrator
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -14,7 +17,7 @@ func TestCleanAIResponse(t *testing.T) {
|
|||||||
expected string
|
expected string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
"removes standard think tags",
|
"malformed think tags pass through",
|
||||||
"<think internal reasoning</think Hello world",
|
"<think internal reasoning</think Hello world",
|
||||||
"<think internal reasoning</think Hello world",
|
"<think internal reasoning</think Hello world",
|
||||||
},
|
},
|
||||||
@@ -24,7 +27,7 @@ func TestCleanAIResponse(t *testing.T) {
|
|||||||
"response",
|
"response",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"removes think with attrs",
|
"think with attrs, no closing bracket",
|
||||||
"<think type=re>reasoning</think result",
|
"<think type=re>reasoning</think result",
|
||||||
"<think type=re>reasoning</think result",
|
"<think type=re>reasoning</think result",
|
||||||
},
|
},
|
||||||
@@ -49,12 +52,12 @@ func TestCleanAIResponse(t *testing.T) {
|
|||||||
"",
|
"",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"removes valid think block",
|
"malformed think block no closing bracket",
|
||||||
"<think some reasoning here</think rest",
|
"<think some reasoning here</think rest",
|
||||||
"<think some reasoning here</think rest",
|
"<think some reasoning here</think rest",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"removes simple think",
|
"malformed simple think no closing bracket",
|
||||||
"before<think reasoning</think after",
|
"before<think reasoning</think after",
|
||||||
"before<think reasoning</think after",
|
"before<think reasoning</think after",
|
||||||
},
|
},
|
||||||
@@ -146,3 +149,128 @@ func TestNewNoAPIKey(t *testing.T) {
|
|||||||
t.Error("Should fail with no API key")
|
t.Error("Should fail with no API key")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSendStreamChunks(t *testing.T) {
|
||||||
|
sseBody := `data: {"choices":[{"delta":{"content":"Hello"}}]}
|
||||||
|
data: {"choices":[{"delta":{"content":" world"}}]}
|
||||||
|
data: {"choices":[{"delta":{"content":"!"}}]}
|
||||||
|
data: [DONE]
|
||||||
|
`
|
||||||
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Header.Get("Authorization") != "Bearer test-key" {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var reqBody ChatRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil {
|
||||||
|
http.Error(w, "bad request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !reqBody.Stream {
|
||||||
|
http.Error(w, "stream must be true", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
w.Write([]byte(sseBody))
|
||||||
|
}))
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
cfg := config.Default()
|
||||||
|
cfg.AI.Providers[0].Active = true
|
||||||
|
cfg.AI.Providers[0].APIKey = "test-key"
|
||||||
|
cfg.AI.Providers[0].BaseURL = ts.URL
|
||||||
|
|
||||||
|
orb, err := New(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("New: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var chunks []string
|
||||||
|
result, err := orb.SendStream("hi", func(chunk string) {
|
||||||
|
chunks = append(chunks, chunk)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SendStream: %v", err)
|
||||||
|
}
|
||||||
|
if result != "Hello world!" {
|
||||||
|
t.Errorf("SendStream result = %q, want %q", result, "Hello world!")
|
||||||
|
}
|
||||||
|
if len(chunks) != 3 {
|
||||||
|
t.Fatalf("expected 3 chunks, got %d: %v", len(chunks), chunks)
|
||||||
|
}
|
||||||
|
if strings.Join(chunks, "") != "Hello world!" {
|
||||||
|
t.Errorf("chunks joined = %q, want %q", strings.Join(chunks, ""), "Hello world!")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendStreamHistory(t *testing.T) {
|
||||||
|
callCount := 0
|
||||||
|
sseBody := `data: {"choices":[{"delta":{"content":"reply"}}]}
|
||||||
|
data: [DONE]
|
||||||
|
`
|
||||||
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
callCount++
|
||||||
|
var reqBody ChatRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil {
|
||||||
|
http.Error(w, "bad request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if callCount == 1 {
|
||||||
|
if len(reqBody.Messages) != 2 {
|
||||||
|
t.Errorf("first call: expected 2 messages (system + 1 user), got %d", len(reqBody.Messages))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if len(reqBody.Messages) != 4 {
|
||||||
|
t.Errorf("second call: expected 4 messages (system + 3 history), got %d", len(reqBody.Messages))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
w.Write([]byte(sseBody))
|
||||||
|
}))
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
cfg := config.Default()
|
||||||
|
cfg.AI.Providers[0].Active = true
|
||||||
|
cfg.AI.Providers[0].APIKey = "test-key"
|
||||||
|
cfg.AI.Providers[0].BaseURL = ts.URL
|
||||||
|
|
||||||
|
orb, err := New(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("New: %v", err)
|
||||||
|
}
|
||||||
|
orb.SetSystemPrompt("you are helpful")
|
||||||
|
|
||||||
|
_, _ = orb.SendStream("first", func(string) {})
|
||||||
|
_, _ = orb.SendStream("second", func(string) {})
|
||||||
|
|
||||||
|
orb.histMu.Lock()
|
||||||
|
if len(orb.history) != 4 {
|
||||||
|
t.Errorf("expected 4 history entries (2 user + 2 assistant), got %d", len(orb.history))
|
||||||
|
}
|
||||||
|
orb.histMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendStreamAPIError(t *testing.T) {
|
||||||
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Error(w, `{"error":"rate limited"}`, http.StatusTooManyRequests)
|
||||||
|
}))
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
cfg := config.Default()
|
||||||
|
cfg.AI.Providers[0].Active = true
|
||||||
|
cfg.AI.Providers[0].APIKey = "test-key"
|
||||||
|
cfg.AI.Providers[0].BaseURL = ts.URL
|
||||||
|
|
||||||
|
orb, err := New(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("New: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = orb.SendStream("hi", func(string) {})
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for non-200 response")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "429") {
|
||||||
|
t.Errorf("error should mention status code, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package platform
|
package platform
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -43,16 +44,9 @@ func TestString(t *testing.T) {
|
|||||||
if s == "" {
|
if s == "" {
|
||||||
t.Error("String should not be empty")
|
t.Error("String should not be empty")
|
||||||
}
|
}
|
||||||
if !contains(s, "linux") {
|
if !strings.Contains(s, "linux") {
|
||||||
t.Error("Should contain OS")
|
t.Error("Should contain OS")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func contains(s, sub string) bool {
|
|
||||||
for i := 0; i+len(sub) <= len(s); i++ {
|
|
||||||
if s[i:i+len(sub)] == sub {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -279,7 +279,7 @@ func AskAPIKey(providerName string) (string, error) {
|
|||||||
|
|
||||||
field := huh.NewInput().
|
field := huh.NewInput().
|
||||||
Title(fmt.Sprintf("Enter your %s API key:", providerName)).
|
Title(fmt.Sprintf("Enter your %s API key:", providerName)).
|
||||||
Description("The key will be stored locally in ~/.muyue/config.yaml").
|
Description("The key will be stored locally in ~/.config/muyue/config.yaml").
|
||||||
EchoMode(huh.EchoModePassword).
|
EchoMode(huh.EchoModePassword).
|
||||||
Value(&apiKey)
|
Value(&apiKey)
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -169,7 +170,7 @@ func checkShellSetup() bool {
|
|||||||
home, _ := os.UserHomeDir()
|
home, _ := os.UserHomeDir()
|
||||||
rcFiles := []string{".bashrc", ".zshrc", ".config/fish/config.fish"}
|
rcFiles := []string{".bashrc", ".zshrc", ".config/fish/config.fish"}
|
||||||
for _, f := range rcFiles {
|
for _, f := range rcFiles {
|
||||||
data, err := os.ReadFile(home + "/" + f)
|
data, err := os.ReadFile(filepath.Join(home, f))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ package version
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
Name = "muyue"
|
Name = "muyue"
|
||||||
Version = "0.3.0"
|
Version = "0.3.1"
|
||||||
Author = "La Légion de Muyue"
|
Author = "La Légion de Muyue"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ const API_BASE = '/api'
|
|||||||
|
|
||||||
async function request(path, options = {}) {
|
async function request(path, options = {}) {
|
||||||
const res = await fetch(`${API_BASE}${path}`, {
|
const res = await fetch(`${API_BASE}${path}`, {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
...options,
|
...options,
|
||||||
|
headers: { 'Content-Type': 'application/json', ...(options.headers || {}) },
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = await res.json().catch(() => ({ error: res.statusText }))
|
const err = await res.json().catch(() => ({ error: res.statusText }))
|
||||||
@@ -34,9 +34,11 @@ const api = {
|
|||||||
getTerminalSessions: () => request('/terminal/sessions'),
|
getTerminalSessions: () => request('/terminal/sessions'),
|
||||||
addSSHConnection: (conn) => request('/terminal/sessions', { method: 'POST', body: JSON.stringify(conn) }),
|
addSSHConnection: (conn) => request('/terminal/sessions', { method: 'POST', body: JSON.stringify(conn) }),
|
||||||
deleteSSHConnection: (name) => request(`/terminal/sessions/${encodeURIComponent(name)}`, { method: 'DELETE' }),
|
deleteSSHConnection: (name) => request(`/terminal/sessions/${encodeURIComponent(name)}`, { method: 'DELETE' }),
|
||||||
|
getTerminalThemes: () => request('/terminal/themes'),
|
||||||
|
saveTerminalSettings: (settings) => request('/terminal/settings', { method: 'PUT', body: JSON.stringify(settings) }),
|
||||||
getChatHistory: () => request('/chat/history'),
|
getChatHistory: () => request('/chat/history'),
|
||||||
clearChat: () => request('/chat/clear', { method: 'POST' }),
|
clearChat: () => request('/chat/clear', { method: 'POST' }),
|
||||||
sendChat: (message, stream = true) => {
|
sendChat: (message, stream = true, onChunk) => {
|
||||||
if (!stream) {
|
if (!stream) {
|
||||||
return request('/chat', { method: 'POST', body: JSON.stringify({ message, stream: false }) })
|
return request('/chat', { method: 'POST', body: JSON.stringify({ message, stream: false }) })
|
||||||
}
|
}
|
||||||
@@ -64,7 +66,12 @@ const api = {
|
|||||||
const data = JSON.parse(line.slice(6))
|
const data = JSON.parse(line.slice(6))
|
||||||
if (data.error) { reject(new Error(data.error)); return }
|
if (data.error) { reject(new Error(data.error)); return }
|
||||||
if (data.done) { resolve(full); return }
|
if (data.done) { resolve(full); return }
|
||||||
if (data.content) full += data.content
|
if (data.content) {
|
||||||
|
full += data.content
|
||||||
|
if (onChunk) onChunk(full, data)
|
||||||
|
} else if (data.thinking !== undefined || data.thinking_end) {
|
||||||
|
if (onChunk) onChunk(full, data)
|
||||||
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export default function App() {
|
|||||||
const [clock, setClock] = useState(new Date())
|
const [clock, setClock] = useState(new Date())
|
||||||
const [updates, setUpdates] = useState([])
|
const [updates, setUpdates] = useState([])
|
||||||
const [tools, setTools] = useState([])
|
const [tools, setTools] = useState([])
|
||||||
|
const [config, setConfig] = useState(null)
|
||||||
const { t, layout } = useI18n()
|
const { t, layout } = useI18n()
|
||||||
|
|
||||||
const TABS = useMemo(() => [
|
const TABS = useMemo(() => [
|
||||||
@@ -27,7 +28,13 @@ export default function App() {
|
|||||||
api.getInfo().then(setInfo).catch(() => {})
|
api.getInfo().then(setInfo).catch(() => {})
|
||||||
api.getTools().then(d => setTools(d.tools || [])).catch(() => {})
|
api.getTools().then(d => setTools(d.tools || [])).catch(() => {})
|
||||||
api.getUpdates().then(d => setUpdates(d.updates || [])).catch(() => {})
|
api.getUpdates().then(d => setUpdates(d.updates || [])).catch(() => {})
|
||||||
applyTheme(getTheme('cyberpunk-red'))
|
api.getConfig().then(d => {
|
||||||
|
setConfig(d)
|
||||||
|
const theme = d.profile?.preferences?.theme || 'cyberpunk-red'
|
||||||
|
applyTheme(getTheme(theme))
|
||||||
|
}).catch(() => {
|
||||||
|
applyTheme(getTheme('cyberpunk-red'))
|
||||||
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -57,7 +64,7 @@ export default function App() {
|
|||||||
const switchTab = useCallback((tabId) => setActiveTab(tabId), [])
|
const switchTab = useCallback((tabId) => setActiveTab(tabId), [])
|
||||||
|
|
||||||
const hasUpdates = updates.some(u => u.needsUpdate)
|
const hasUpdates = updates.some(u => u.needsUpdate)
|
||||||
const installed = tools.filter(t => t.installed).length
|
const installed = tools.filter(tool => tool.installed).length
|
||||||
|
|
||||||
const WINDOW_SHORTCUTS = useMemo(() => ({
|
const WINDOW_SHORTCUTS = useMemo(() => ({
|
||||||
dash: [
|
dash: [
|
||||||
@@ -80,10 +87,10 @@ export default function App() {
|
|||||||
|
|
||||||
const renderContent = () => {
|
const renderContent = () => {
|
||||||
switch (activeTab) {
|
switch (activeTab) {
|
||||||
case 'dash': return <Dashboard tools={tools} updates={updates} api={api} onRescan={t => setTools(t)} />
|
case 'dash': return <Dashboard api={api} />
|
||||||
case 'studio': return <Studio api={api} />
|
case 'studio': return <Studio api={api} />
|
||||||
case 'shell': return <Shell api={api} />
|
case 'shell': return <Shell api={api} />
|
||||||
case 'config': return <Config api={api} onThemeChange={() => {}} />
|
case 'config': return <Config api={api} />
|
||||||
default: return null
|
default: return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { User, Brain, RefreshCw, Globe, Wrench } from 'lucide-react'
|
import { User, Brain, RefreshCw, Globe, Wrench, Monitor } from 'lucide-react'
|
||||||
import { useI18n, LANGUAGES } from '../i18n'
|
import { useI18n, LANGUAGES } from '../i18n'
|
||||||
import { getLayoutList } from '../i18n/keyboards'
|
import { getLayoutList } from '../i18n/keyboards'
|
||||||
|
|
||||||
const PANELS = [
|
const PANELS = [
|
||||||
{ id: 'profile', icon: User },
|
{ id: 'profile', icon: User },
|
||||||
{ id: 'providers', icon: Brain },
|
{ id: 'providers', icon: Brain },
|
||||||
|
{ id: 'terminal', icon: Monitor },
|
||||||
{ id: 'updates', icon: RefreshCw },
|
{ id: 'updates', icon: RefreshCw },
|
||||||
{ id: 'locale', icon: Globe },
|
{ id: 'locale', icon: Globe },
|
||||||
{ id: 'skills', icon: Wrench },
|
{ id: 'skills', icon: Wrench },
|
||||||
@@ -26,6 +27,9 @@ export default function Config({ api }) {
|
|||||||
const [profileForm, setProfileForm] = useState({})
|
const [profileForm, setProfileForm] = useState({})
|
||||||
const [providerForm, setProviderForm] = useState({})
|
const [providerForm, setProviderForm] = useState({})
|
||||||
const [toast, setToast] = useState(null)
|
const [toast, setToast] = useState(null)
|
||||||
|
const [terminalThemes, setTerminalThemes] = useState([])
|
||||||
|
const [terminalSettings, setTerminalSettings] = useState({ font_size: 14, font_family: '', theme: 'default' })
|
||||||
|
const [savingTerminal, setSavingTerminal] = useState(false)
|
||||||
|
|
||||||
const layouts = getLayoutList()
|
const layouts = getLayoutList()
|
||||||
|
|
||||||
@@ -39,11 +43,19 @@ export default function Config({ api }) {
|
|||||||
editor: d.profile?.preferences?.editor || '',
|
editor: d.profile?.preferences?.editor || '',
|
||||||
shell: d.profile?.preferences?.shell || '',
|
shell: d.profile?.preferences?.shell || '',
|
||||||
})
|
})
|
||||||
|
if (d.terminal) {
|
||||||
|
setTerminalSettings({
|
||||||
|
font_size: d.terminal.font_size || 14,
|
||||||
|
font_family: d.terminal.font_family || '',
|
||||||
|
theme: d.terminal.theme || 'default',
|
||||||
|
})
|
||||||
|
}
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
api.getProviders().then(d => setProviders(d.providers || [])).catch(() => {})
|
api.getProviders().then(d => setProviders(d.providers || [])).catch(() => {})
|
||||||
api.getSkills().then(d => setSkillList(d.skills || [])).catch(() => {})
|
api.getSkills().then(d => setSkillList(d.skills || [])).catch(() => {})
|
||||||
api.getUpdates().then(d => setUpdates(d.updates || [])).catch(() => {})
|
api.getUpdates().then(d => setUpdates(d.updates || [])).catch(() => {})
|
||||||
api.getTools().then(d => setTools(d.tools || [])).catch(() => {})
|
api.getTools().then(d => setTools(d.tools || [])).catch(() => {})
|
||||||
|
api.getTerminalThemes().then(d => setTerminalThemes(d.themes || [])).catch(() => {})
|
||||||
}, [api])
|
}, [api])
|
||||||
|
|
||||||
useEffect(() => { loadData() }, [loadData])
|
useEffect(() => { loadData() }, [loadData])
|
||||||
@@ -114,6 +126,18 @@ export default function Config({ api }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleSaveTerminalSettings = async () => {
|
||||||
|
setSavingTerminal(true)
|
||||||
|
try {
|
||||||
|
await api.saveTerminalSettings(terminalSettings)
|
||||||
|
showToast(t('config.saved'))
|
||||||
|
window.location.reload()
|
||||||
|
} catch (err) {
|
||||||
|
showToast(`${t('config.error')}: ${err.message}`)
|
||||||
|
}
|
||||||
|
setSavingTerminal(false)
|
||||||
|
}
|
||||||
|
|
||||||
const openProviderEdit = (p) => {
|
const openProviderEdit = (p) => {
|
||||||
setProviderForm({
|
setProviderForm({
|
||||||
name: p.name,
|
name: p.name,
|
||||||
@@ -125,8 +149,8 @@ export default function Config({ api }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const needsUpdateCount = updates.filter(u => u.needsUpdate).length
|
const needsUpdateCount = updates.filter(u => u.needsUpdate).length
|
||||||
const installedCount = tools.filter(t => t.installed).length
|
const installedCount = tools.filter(tool => tool.installed).length
|
||||||
const missingCount = tools.filter(t => !t.installed).length
|
const missingCount = tools.filter(tool => !tool.installed).length
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="config-window">
|
<div className="config-window">
|
||||||
@@ -189,6 +213,13 @@ export default function Config({ api }) {
|
|||||||
{activePanel === 'skills' && (
|
{activePanel === 'skills' && (
|
||||||
<PanelSkills skillList={skillList} t={t} />
|
<PanelSkills skillList={skillList} t={t} />
|
||||||
)}
|
)}
|
||||||
|
{activePanel === 'terminal' && (
|
||||||
|
<PanelTerminal
|
||||||
|
settings={terminalSettings} setSettings={setTerminalSettings}
|
||||||
|
themes={terminalThemes} saving={savingTerminal}
|
||||||
|
onSave={handleSaveTerminalSettings} t={t}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -281,8 +312,8 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm
|
|||||||
<span className="provider-card-name">{p.name}</span>
|
<span className="provider-card-name">{p.name}</span>
|
||||||
{p.apiKey && <span className="badge ok">{t('config.keyConfigured')}</span>}
|
{p.apiKey && <span className="badge ok">{t('config.keyConfigured')}</span>}
|
||||||
{!p.apiKey && <span className="badge error">{t('config.noKey')}</span>}
|
{!p.apiKey && <span className="badge error">{t('config.noKey')}</span>}
|
||||||
{isValidationTarget && validationStatus.valid && <span className="badge ok">{t('config.keyValid')}</span>}
|
{isValidationTarget && validationStatus?.valid && <span className="badge ok">{t('config.keyValid')}</span>}
|
||||||
{isValidationTarget && !validationStatus.valid && <span className="badge error">{validationStatus.error}</span>}
|
{isValidationTarget && !validationStatus?.valid && <span className="badge error">{validationStatus?.error}</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -309,7 +340,7 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm
|
|||||||
>
|
>
|
||||||
{validating === p.name ? t('config.validating') : t('config.validateKey')}
|
{validating === p.name ? t('config.validating') : t('config.validateKey')}
|
||||||
</button>
|
</button>
|
||||||
{isValidationTarget && validationStatus.valid && (
|
{isValidationTarget && validationStatus?.valid && (
|
||||||
<button className="sm" onClick={handleSaveProvider}>{t('config.save')}</button>
|
<button className="sm" onClick={handleSaveProvider}>{t('config.save')}</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -439,6 +470,102 @@ function PanelSkills({ skillList, t }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function PanelTerminal({ settings, setSettings, themes, saving, onSave, t }) {
|
||||||
|
const previewTheme = {
|
||||||
|
background: settings.theme === 'default' ? '#0A0A0C' :
|
||||||
|
settings.theme === 'monokai' ? '#272822' :
|
||||||
|
settings.theme === 'gruvbox' ? '#282828' :
|
||||||
|
settings.theme === 'nord' ? '#2E3440' :
|
||||||
|
settings.theme === 'solarized-dark' ? '#002B36' :
|
||||||
|
settings.theme === 'dracula' ? '#282A36' : '#0A0A0C',
|
||||||
|
foreground: settings.theme === 'default' ? '#EAE0E2' :
|
||||||
|
settings.theme === 'monokai' ? '#F8F8F2' :
|
||||||
|
settings.theme === 'gruvbox' ? '#EBDBB2' :
|
||||||
|
settings.theme === 'nord' ? '#D8DEE9' :
|
||||||
|
settings.theme === 'solarized-dark' ? '#839496' :
|
||||||
|
settings.theme === 'dracula' ? '#F8F8F2' : '#EAE0E2',
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="config-card">
|
||||||
|
<div className="config-card-group">
|
||||||
|
<span className="config-card-group-label">{t('config.terminalTheme')}</span>
|
||||||
|
<div className="chip-row">
|
||||||
|
{themes.map(th => (
|
||||||
|
<div
|
||||||
|
key={th.id}
|
||||||
|
className={`chip ${settings.theme === th.id ? 'active' : ''}`}
|
||||||
|
onClick={() => setSettings(s => ({ ...s, theme: th.id }))}
|
||||||
|
>
|
||||||
|
{th.name}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="config-card-group">
|
||||||
|
<span className="config-card-group-label">{t('config.fontSize')}</span>
|
||||||
|
<div className="chip-row">
|
||||||
|
{[12, 14, 16, 18, 20, 24].map(size => (
|
||||||
|
<div
|
||||||
|
key={size}
|
||||||
|
className={`chip ${settings.font_size === size ? 'active' : ''}`}
|
||||||
|
onClick={() => setSettings(s => ({ ...s, font_size: size }))}
|
||||||
|
>
|
||||||
|
{size}px
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="config-card-group">
|
||||||
|
<span className="config-card-group-label">{t('config.fontFamily')}</span>
|
||||||
|
<select
|
||||||
|
className="config-form-input"
|
||||||
|
value={settings.font_family}
|
||||||
|
onChange={e => setSettings(s => ({ ...s, font_family: e.target.value }))}
|
||||||
|
style={{ maxWidth: 300 }}
|
||||||
|
>
|
||||||
|
<option value="">Default (JetBrains Mono)</option>
|
||||||
|
<option value="'Fira Code', monospace">Fira Code</option>
|
||||||
|
<option value="'Cascadia Code', 'SF Mono', monospace">Cascadia Code</option>
|
||||||
|
<option value="'SF Mono', 'Menlo', monospace">SF Mono</option>
|
||||||
|
<option value="'Source Code Pro', monospace">Source Code Pro</option>
|
||||||
|
<option value="monospace">System Monospace</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="config-card-group">
|
||||||
|
<span className="config-card-group-label">{t('config.preview')}</span>
|
||||||
|
<div style={{
|
||||||
|
background: previewTheme.background,
|
||||||
|
color: previewTheme.foreground,
|
||||||
|
padding: '16px 20px',
|
||||||
|
borderRadius: 'var(--radius)',
|
||||||
|
fontFamily: settings.font_family || "'JetBrains Mono', monospace",
|
||||||
|
fontSize: settings.font_size || 14,
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
}}>
|
||||||
|
<span style={{ color: '#00E676' }}>➜</span> <span>~/projects</span>
|
||||||
|
<span style={{ color: '#448AFF' }}> git status</span>
|
||||||
|
<br />
|
||||||
|
<span>On branch </span>
|
||||||
|
<span style={{ color: '#FFD740' }}>main</span>
|
||||||
|
<br />
|
||||||
|
<span style={{ opacity: 0.6 }}>Type a command...</span>
|
||||||
|
<span style={{ animation: 'blink 1s step-end infinite' }}> ▋</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="config-card-actions" style={{ marginTop: 16 }}>
|
||||||
|
<button className="primary sm" onClick={onSave} disabled={saving}>
|
||||||
|
{saving ? t('config.saving') : t('config.save')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function FormInput({ label, value, onChange, type = 'text' }) {
|
function FormInput({ label, value, onChange, type = 'text' }) {
|
||||||
return (
|
return (
|
||||||
<div className="config-form-field">
|
<div className="config-form-field">
|
||||||
|
|||||||
@@ -1,14 +1,10 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useI18n } from '../i18n'
|
import { useI18n } from '../i18n'
|
||||||
|
|
||||||
export default function Dashboard({ api, onRescan }) {
|
export default function Dashboard({ api }) {
|
||||||
const { t, layout } = useI18n()
|
const { t } = useI18n()
|
||||||
const [notifications, setNotifications] = useState([])
|
const [notifications, setNotifications] = useState([])
|
||||||
|
|
||||||
const addNotif = (text, type) => {
|
|
||||||
setNotifications(prev => [{ text, type, id: Date.now(), time: new Date() }, ...prev])
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="dashboard-layout">
|
<div className="dashboard-layout">
|
||||||
<div className="dashboard-content">
|
<div className="dashboard-content">
|
||||||
@@ -47,7 +43,7 @@ export default function Dashboard({ api, onRescan }) {
|
|||||||
{notifications.map(n => (
|
{notifications.map(n => (
|
||||||
<div key={n.id} className={`notif-row notif-${n.type}`}>
|
<div key={n.id} className={`notif-row notif-${n.type}`}>
|
||||||
<span className="notif-time">
|
<span className="notif-time">
|
||||||
{n.time.toLocaleTimeString(layout.locale, { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
|
{n.time.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
|
||||||
</span>
|
</span>
|
||||||
<span className="notif-text">{n.text}</span>
|
<span className="notif-text">{n.text}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,37 +8,74 @@ import { useI18n } from '../i18n'
|
|||||||
|
|
||||||
const MAX_TABS = 7
|
const MAX_TABS = 7
|
||||||
|
|
||||||
const XTERM_THEME = {
|
const THEMES = {
|
||||||
background: '#0A0A0C',
|
default: {
|
||||||
foreground: '#EAE0E2',
|
background: '#0A0A0C', foreground: '#EAE0E2', cursor: '#FF0033',
|
||||||
cursor: '#FF0033',
|
cursorAccent: '#0A0A0C', selectionBackground: '#FF003344', selectionForeground: '#ffffff',
|
||||||
cursorAccent: '#0A0A0C',
|
black: '#0A0A0C', red: '#FF0033', green: '#00E676', yellow: '#FFD740',
|
||||||
selectionBackground: '#FF003344',
|
blue: '#448AFF', magenta: '#FF1A5E', cyan: '#00BCD4', white: '#EAE0E2',
|
||||||
selectionForeground: '#ffffff',
|
brightBlack: '#5A4F52', brightRed: '#FF5252', brightGreen: '#69F0AE',
|
||||||
black: '#0A0A0C',
|
brightYellow: '#FFFF00', brightBlue: '#82B1FF', brightMagenta: '#FF80AB',
|
||||||
red: '#FF0033',
|
brightCyan: '#84FFFF', brightWhite: '#FFFFFF',
|
||||||
green: '#00E676',
|
},
|
||||||
yellow: '#FFD740',
|
monokai: {
|
||||||
blue: '#448AFF',
|
background: '#272822', foreground: '#F8F8F2', cursor: '#F8F8F0',
|
||||||
magenta: '#FF1A5E',
|
cursorAccent: '#272822', selectionBackground: '#F8F8F244', selectionForeground: '#ffffff',
|
||||||
cyan: '#00BCD4',
|
black: '#272822', red: '#F92672', green: '#A6E22E', yellow: '#E6DB74',
|
||||||
white: '#EAE0E2',
|
blue: '#66D9EF', magenta: '#AE81FF', cyan: '#A1EFE4', white: '#F8F8F2',
|
||||||
brightBlack: '#5A4F52',
|
brightBlack: '#75715E', brightRed: '#F92672', brightGreen: '#A6E22E',
|
||||||
brightRed: '#FF5252',
|
brightYellow: '#E6DB74', brightBlue: '#66D9EF', brightMagenta: '#AE81FF',
|
||||||
brightGreen: '#69F0AE',
|
brightCyan: '#A1EFE4', brightWhite: '#F8F8F2',
|
||||||
brightYellow: '#FFFF00',
|
},
|
||||||
brightBlue: '#82B1FF',
|
gruvbox: {
|
||||||
brightMagenta: '#FF80AB',
|
background: '#282828', foreground: '#EBDBB2', cursor: '#FB4934',
|
||||||
brightCyan: '#84FFFF',
|
cursorAccent: '#282828', selectionBackground: '#EBDBB244', selectionForeground: '#ffffff',
|
||||||
brightWhite: '#FFFFFF',
|
black: '#282828', red: '#CC241D', green: '#98971A', yellow: '#D79921',
|
||||||
|
blue: '#458588', magenta: '#B16286', cyan: '#689D6A', white: '#EBDBB2',
|
||||||
|
brightBlack: '#928374', brightRed: '#FB4934', brightGreen: '#B8BB26',
|
||||||
|
brightYellow: '#FABC2A', brightBlue: '#83A598', brightMagenta: '#D3869B',
|
||||||
|
brightCyan: '#8EC07C', brightWhite: '#EBDBB2',
|
||||||
|
},
|
||||||
|
nord: {
|
||||||
|
background: '#2E3440', foreground: '#D8DEE9', cursor: '#D8DEE9',
|
||||||
|
cursorAccent: '#2E3440', selectionBackground: '#D8DEE944', selectionForeground: '#ffffff',
|
||||||
|
black: '#2E3440', red: '#BF616A', green: '#A3BE8C', yellow: '#EBCB8B',
|
||||||
|
blue: '#81A1C1', magenta: '#B48EAD', cyan: '#88C0D0', white: '#D8DEE9',
|
||||||
|
brightBlack: '#4C566A', brightRed: '#BF616A', brightGreen: '#A3BE8C',
|
||||||
|
brightYellow: '#EBCB8B', brightBlue: '#81A1C1', brightMagenta: '#B48EAD',
|
||||||
|
brightCyan: '#8FBCBB', brightWhite: '#ECEFF4',
|
||||||
|
},
|
||||||
|
'solarized-dark': {
|
||||||
|
background: '#002B36', foreground: '#839496', cursor: '#D33682',
|
||||||
|
cursorAccent: '#002B36', selectionBackground: '#83949644', selectionForeground: '#ffffff',
|
||||||
|
black: '#002B36', red: '#DC322F', green: '#859900', yellow: '#B58900',
|
||||||
|
blue: '#268BD2', magenta: '#D33682', cyan: '#2AA198', white: '#FDF6E3',
|
||||||
|
brightBlack: '#073642', brightRed: '#CB4B16', brightGreen: '#586E75',
|
||||||
|
brightYellow: '#657B83', brightBlue: '#6C71C4', brightMagenta: '#6C71C4',
|
||||||
|
brightCyan: '#93A1A1', brightWhite: '#FDF6E3',
|
||||||
|
},
|
||||||
|
dracula: {
|
||||||
|
background: '#282A36', foreground: '#F8F8F2', cursor: '#F8F8F2',
|
||||||
|
cursorAccent: '#282A36', selectionBackground: '#F8F8F244', selectionForeground: '#ffffff',
|
||||||
|
black: '#282A36', red: '#FF5555', green: '#50FA7B', yellow: '#F1FA8C',
|
||||||
|
blue: '#BD93F9', magenta: '#FF79C6', cyan: '#8BE9FD', white: '#F8F8F2',
|
||||||
|
brightBlack: '#6272A4', brightRed: '#FF6E6E', brightGreen: '#69FF94',
|
||||||
|
brightYellow: '#FFFFA5', brightBlue: '#D6ACFF', brightMagenta: '#FF92DF',
|
||||||
|
brightCyan: '#A4FFFF', brightWhite: '#FFFFFF',
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
function createTerminal(container) {
|
function getTheme(themeName) {
|
||||||
|
return THEMES[themeName] || THEMES.default
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTerminal(container, settings = {}) {
|
||||||
|
const theme = getTheme(settings.theme || 'default')
|
||||||
const term = new XTerm({
|
const term = new XTerm({
|
||||||
cursorBlink: true,
|
cursorBlink: true,
|
||||||
fontSize: 14,
|
fontSize: settings.fontSize || 14,
|
||||||
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', 'Menlo', monospace",
|
fontFamily: settings.fontFamily || "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', 'Menlo', monospace",
|
||||||
theme: XTERM_THEME,
|
theme,
|
||||||
allowTransparency: false,
|
allowTransparency: false,
|
||||||
scrollback: 5000,
|
scrollback: 5000,
|
||||||
})
|
})
|
||||||
@@ -57,15 +94,15 @@ function connectWebSocket(term, fitAddon, initPayload) {
|
|||||||
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||||
const ws = new WebSocket(`${proto}//${window.location.host}/api/ws/terminal`)
|
const ws = new WebSocket(`${proto}//${window.location.host}/api/ws/terminal`)
|
||||||
|
|
||||||
ws.onopen = () => {
|
ws.addEventListener('open', () => {
|
||||||
ws.send(JSON.stringify(initPayload))
|
ws.send(JSON.stringify(initPayload))
|
||||||
const dims = fitAddon.proposeDimensions()
|
const dims = fitAddon.proposeDimensions()
|
||||||
if (dims) {
|
if (dims) {
|
||||||
ws.send(JSON.stringify({ type: 'resize', rows: dims.rows, cols: dims.cols }))
|
ws.send(JSON.stringify({ type: 'resize', rows: dims.rows, cols: dims.cols }))
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
|
|
||||||
ws.onmessage = (event) => {
|
ws.addEventListener('message', (event) => {
|
||||||
try {
|
try {
|
||||||
const msg = JSON.parse(event.data)
|
const msg = JSON.parse(event.data)
|
||||||
if (msg.type === 'output') {
|
if (msg.type === 'output') {
|
||||||
@@ -76,15 +113,15 @@ function connectWebSocket(term, fitAddon, initPayload) {
|
|||||||
} catch {
|
} catch {
|
||||||
term.write(event.data)
|
term.write(event.data)
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
|
|
||||||
ws.onclose = () => {
|
ws.addEventListener('close', () => {
|
||||||
term.write('\r\n\x1b[33m— Connection closed —\x1b[0m\r\n')
|
term.write('\r\n\x1b[33m— Connection closed —\x1b[0m\r\n')
|
||||||
}
|
})
|
||||||
|
|
||||||
ws.onerror = () => {
|
ws.addEventListener('error', () => {
|
||||||
term.write('\r\n\x1b[31m— Connection error —\x1b[0m\r\n')
|
term.write('\r\n\x1b[31m— Connection error —\x1b[0m\r\n')
|
||||||
}
|
})
|
||||||
|
|
||||||
term.onData((data) => {
|
term.onData((data) => {
|
||||||
if (ws.readyState === WebSocket.OPEN) {
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
@@ -116,6 +153,11 @@ export default function Shell({ api }) {
|
|||||||
const [showSshModal, setShowSshModal] = useState(false)
|
const [showSshModal, setShowSshModal] = useState(false)
|
||||||
const [editingTab, setEditingTab] = useState(null)
|
const [editingTab, setEditingTab] = useState(null)
|
||||||
const [editName, setEditName] = useState('')
|
const [editName, setEditName] = useState('')
|
||||||
|
const [terminalSettings, setTerminalSettings] = useState({
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', 'Menlo', monospace",
|
||||||
|
theme: 'default',
|
||||||
|
})
|
||||||
|
|
||||||
const [sshForm, setSshForm] = useState({
|
const [sshForm, setSshForm] = useState({
|
||||||
name: '', host: '', port: 22, user: '', key_path: '',
|
name: '', host: '', port: 22, user: '', key_path: '',
|
||||||
@@ -137,6 +179,15 @@ export default function Shell({ api }) {
|
|||||||
setSshConnections(d.ssh || [])
|
setSshConnections(d.ssh || [])
|
||||||
setSystemTerminals(d.system || [])
|
setSystemTerminals(d.system || [])
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
|
api.getConfig().then(d => {
|
||||||
|
if (d.terminal) {
|
||||||
|
setTerminalSettings({
|
||||||
|
fontSize: d.terminal.font_size || 14,
|
||||||
|
fontFamily: d.terminal.font_family || "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', 'Menlo', monospace",
|
||||||
|
theme: d.terminal.theme || 'default',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}).catch(() => {})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const initTerminal = useCallback((tabId, tab) => {
|
const initTerminal = useCallback((tabId, tab) => {
|
||||||
@@ -145,7 +196,11 @@ export default function Shell({ api }) {
|
|||||||
const container = document.getElementById(`terminal-${tabId}`)
|
const container = document.getElementById(`terminal-${tabId}`)
|
||||||
if (!container) return
|
if (!container) return
|
||||||
|
|
||||||
const { term, fitAddon } = createTerminal(container)
|
const { term, fitAddon } = createTerminal(container, {
|
||||||
|
fontSize: terminalSettings.fontSize,
|
||||||
|
fontFamily: terminalSettings.fontFamily,
|
||||||
|
theme: terminalSettings.theme,
|
||||||
|
})
|
||||||
|
|
||||||
let initPayload
|
let initPayload
|
||||||
if (tab.type === 'ssh') {
|
if (tab.type === 'ssh') {
|
||||||
@@ -195,15 +250,25 @@ export default function Shell({ api }) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const tab = tabs.find(t => t.id === activeTab)
|
const tab = tabs.find(t => t.id === activeTab)
|
||||||
if (tab && !tabsRef.current[tab.id]) {
|
if (!tab) return
|
||||||
const timer = setTimeout(() => initTerminal(tab.id, tab), 50)
|
|
||||||
return () => clearTimeout(timer)
|
const container = document.getElementById(`terminal-${tab.id}`)
|
||||||
} else if (tab && tabsRef.current[tab.id]) {
|
if (!container) return
|
||||||
|
|
||||||
|
if (!tabsRef.current[tab.id]) {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
const { fitAddon } = tabsRef.current[tab.id]
|
initTerminal(tab.id, tab)
|
||||||
fitAddon.fit()
|
requestAnimationFrame(() => {
|
||||||
}, 50)
|
const entry = tabsRef.current[tab.id]
|
||||||
|
if (entry) entry.fitAddon.fit()
|
||||||
|
})
|
||||||
|
}, 100)
|
||||||
return () => clearTimeout(timer)
|
return () => clearTimeout(timer)
|
||||||
|
} else {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const entry = tabsRef.current[tab.id]
|
||||||
|
if (entry) entry.fitAddon.fit()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}, [activeTab, tabs, initTerminal])
|
}, [activeTab, tabs, initTerminal])
|
||||||
|
|
||||||
@@ -315,13 +380,61 @@ export default function Shell({ api }) {
|
|||||||
setAiLoading(true)
|
setAiLoading(true)
|
||||||
try {
|
try {
|
||||||
const res = await api.runCommand(`echo "AI: ${text}"`, '')
|
const res = await api.runCommand(`echo "AI: ${text}"`, '')
|
||||||
setAiMessages(prev => [...prev, { role: 'ai', content: res.output || t('shell.noResponse') }])
|
const output = res.output || t('shell.noResponse')
|
||||||
|
parseAndAddAiMessages(output)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setAiMessages(prev => [...prev, { role: 'ai', content: `${t('shell.error')}: ${err.message}` }])
|
setAiMessages(prev => [...prev, { role: 'ai', content: `${t('shell.error')}: ${err.message}` }])
|
||||||
}
|
}
|
||||||
setAiLoading(false)
|
setAiLoading(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const parseAndAddAiMessages = (text) => {
|
||||||
|
const lines = text.split('\n')
|
||||||
|
let buffer = ''
|
||||||
|
let inBlock = false
|
||||||
|
|
||||||
|
const flushBuffer = () => {
|
||||||
|
if (buffer.trim()) {
|
||||||
|
setAiMessages(prev => [...prev, { role: 'ai', content: buffer.trim() }])
|
||||||
|
}
|
||||||
|
buffer = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const toolMatch = line.match(/^\[TOOL_CALL:\{.*\}\]$/)
|
||||||
|
if (toolMatch) {
|
||||||
|
flushBuffer()
|
||||||
|
try {
|
||||||
|
const toolData = JSON.parse(toolMatch[0].slice(10, -1))
|
||||||
|
setAiMessages(prev => [...prev, {
|
||||||
|
role: 'tool',
|
||||||
|
content: `${t('shell.toolLaunched')}: ${toolData.tool || 'tool'}`,
|
||||||
|
args: toolData.task || toolData.args || '',
|
||||||
|
}])
|
||||||
|
} catch {
|
||||||
|
setAiMessages(prev => [...prev, { role: 'tool', content: line, args: '' }])
|
||||||
|
}
|
||||||
|
} else if (line.match(/^(Reflexion|Thought|thinking):/i) || line.startsWith('>')) {
|
||||||
|
if (buffer.trim() && !inBlock) {
|
||||||
|
flushBuffer()
|
||||||
|
}
|
||||||
|
inBlock = true
|
||||||
|
const cleaned = line.replace(/^(Reflexion|Thought|thinking):\s*/i, '').replace(/^>\s*/, '')
|
||||||
|
if (buffer) buffer += ' '
|
||||||
|
buffer += cleaned
|
||||||
|
} else {
|
||||||
|
if (inBlock && buffer.trim()) {
|
||||||
|
setAiMessages(prev => [...prev, { role: 'thinking', content: buffer.trim() }])
|
||||||
|
buffer = ''
|
||||||
|
}
|
||||||
|
inBlock = false
|
||||||
|
if (buffer) buffer += '\n'
|
||||||
|
buffer += line
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flushBuffer()
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="shell-layout">
|
<div className="shell-layout">
|
||||||
<div className="shell-terminal-col">
|
<div className="shell-terminal-col">
|
||||||
@@ -442,6 +555,7 @@ export default function Shell({ api }) {
|
|||||||
{aiMessages.map((msg, i) => (
|
{aiMessages.map((msg, i) => (
|
||||||
<div key={i} className={`ai-message ${msg.role}`}>
|
<div key={i} className={`ai-message ${msg.role}`}>
|
||||||
{msg.content}
|
{msg.content}
|
||||||
|
{msg.args && <div className="tool-args">{msg.args}</div>}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{aiLoading && <div style={{ textAlign: 'center', padding: 8 }}><span className="spinner" /></div>}
|
{aiLoading && <div style={{ textAlign: 'center', padding: 8 }}><span className="spinner" /></div>}
|
||||||
|
|||||||
@@ -1,6 +1,35 @@
|
|||||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||||
import { useI18n } from '../i18n'
|
import { useI18n } from '../i18n'
|
||||||
|
|
||||||
|
const RANKS = {
|
||||||
|
commandant: { label: 'Commandant', short: 'CDT', color: '#FFD740' },
|
||||||
|
general: { label: 'General', short: 'GEN', color: '#FF9100' },
|
||||||
|
colonel: { label: 'Colonel', short: 'COL', color: '#FF6D00' },
|
||||||
|
lieutenant: { label: 'Lieutenant', short: 'LT', color: '#448AFF' },
|
||||||
|
soldat: { label: 'Soldat', short: 'SDT', color: '#00E676' },
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRank(role) {
|
||||||
|
if (role === 'user') return RANKS.commandant
|
||||||
|
if (role === 'system') return null
|
||||||
|
return RANKS.general
|
||||||
|
}
|
||||||
|
|
||||||
|
function RankIcon({ rank }) {
|
||||||
|
if (rank === RANKS.commandant) {
|
||||||
|
return (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke={rank.color} strokeWidth="2">
|
||||||
|
<polygon points="12,2 15.09,8.26 22,9.27 17,14.14 18.18,21.02 12,17.77 5.82,21.02 7,14.14 2,9.27 8.91,8.26"/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke={rank.color} strokeWidth="2">
|
||||||
|
<path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function renderContent(text) {
|
function renderContent(text) {
|
||||||
const parts = []
|
const parts = []
|
||||||
const codeBlockRegex = /(```[\s\S]*?```)/g
|
const codeBlockRegex = /(```[\s\S]*?```)/g
|
||||||
@@ -34,17 +63,25 @@ function formatText(text) {
|
|||||||
.replace(/^\s*(\d+)[.)] (.+)$/gm, '<span class="msg-step"><span class="msg-step-num">$1</span>$2</span>')
|
.replace(/^\s*(\d+)[.)] (.+)$/gm, '<span class="msg-step"><span class="msg-step-num">$1</span>$2</span>')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ThinkingBlock({ content, done }) {
|
||||||
|
return (
|
||||||
|
<div className={`feed-thinking-block ${done ? 'done' : 'active'}`}>
|
||||||
|
<div className="feed-thinking-header">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/>
|
||||||
|
</svg>
|
||||||
|
<span>Reflexion</span>
|
||||||
|
{!done && <span className="feed-thinking-dots"><span/><span/><span/></span>}
|
||||||
|
</div>
|
||||||
|
<div className="feed-thinking-content">{content}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function FeedItem({ msg }) {
|
function FeedItem({ msg }) {
|
||||||
const isUser = msg.role === 'user'
|
const isUser = msg.role === 'user'
|
||||||
const isSystem = msg.role === 'system'
|
const isSystem = msg.role === 'system'
|
||||||
|
const rank = getRank(msg.role)
|
||||||
const roleLabel = isUser ? null : isSystem ? null : (
|
|
||||||
<div className="feed-avatar">
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
||||||
<path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
const timeStr = msg.time ? new Date(msg.time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : ''
|
const timeStr = msg.time ? new Date(msg.time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : ''
|
||||||
|
|
||||||
@@ -58,16 +95,24 @@ function FeedItem({ msg }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cleanContent = msg.content.replace(/<think[^>]*>[\s\S]*?<\/think>/gi, '')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`feed-item ${msg.role}`}>
|
<div className={`feed-item ${msg.role}`}>
|
||||||
{roleLabel}
|
<div className={`feed-avatar ${isUser ? 'user-rank' : 'ai-rank'}`}>
|
||||||
|
<RankIcon rank={rank} />
|
||||||
|
</div>
|
||||||
<div className="feed-body">
|
<div className="feed-body">
|
||||||
<div className="feed-header">
|
<div className="feed-header">
|
||||||
<span className="feed-role">{isUser ? 'Vous' : 'IA'}</span>
|
<span className="feed-rank-badge" style={{ color: rank.color, borderColor: rank.color }}>
|
||||||
|
{rank.short}
|
||||||
|
</span>
|
||||||
|
<span className="feed-role">{rank.label}</span>
|
||||||
{timeStr && <span className="feed-time">{timeStr}</span>}
|
{timeStr && <span className="feed-time">{timeStr}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
{msg.thinking && <ThinkingBlock content={msg.thinking} done />}
|
||||||
<div className="feed-content">
|
<div className="feed-content">
|
||||||
{renderContent(msg.content).map((part, i) =>
|
{renderContent(cleanContent).map((part, i) =>
|
||||||
part.type === 'code' ? (
|
part.type === 'code' ? (
|
||||||
<div key={i} className="studio-code-block">
|
<div key={i} className="studio-code-block">
|
||||||
{part.lang && <div className="studio-code-lang">{part.lang}</div>}
|
{part.lang && <div className="studio-code-lang">{part.lang}</div>}
|
||||||
@@ -83,31 +128,43 @@ function FeedItem({ msg }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function StreamingItem({ content }) {
|
function StreamingItem({ content, thinking }) {
|
||||||
|
const rank = RANKS.general
|
||||||
|
const cleanContent = content.replace(/<think[^>]*>[\s\S]*?<\/think>/gi, '')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="feed-item assistant">
|
<div className="feed-item assistant">
|
||||||
<div className="feed-avatar">
|
<div className="feed-avatar ai-rank">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<RankIcon rank={rank} />
|
||||||
<path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="feed-body">
|
<div className="feed-body">
|
||||||
<div className="feed-header">
|
<div className="feed-header">
|
||||||
<span className="feed-role">IA</span>
|
<span className="feed-rank-badge" style={{ color: rank.color, borderColor: rank.color }}>
|
||||||
</div>
|
{rank.short}
|
||||||
<div className="feed-content">
|
</span>
|
||||||
{renderContent(content).map((part, i) =>
|
<span className="feed-role">{rank.label}</span>
|
||||||
part.type === 'code' ? (
|
|
||||||
<div key={i} className="studio-code-block">
|
|
||||||
{part.lang && <div className="studio-code-lang">{part.lang}</div>}
|
|
||||||
<pre><code>{part.content}</code></pre>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<span key={i} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} />
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
<span className="studio-cursor" />
|
|
||||||
</div>
|
</div>
|
||||||
|
{thinking && <ThinkingBlock content={thinking} done={false} />}
|
||||||
|
{!thinking && !cleanContent && (
|
||||||
|
<div className="feed-content">
|
||||||
|
<div className="studio-thinking"><span /><span /><span /></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{cleanContent && (
|
||||||
|
<div className="feed-content">
|
||||||
|
{renderContent(cleanContent).map((part, i) =>
|
||||||
|
part.type === 'code' ? (
|
||||||
|
<div key={i} className="studio-code-block">
|
||||||
|
{part.lang && <div className="studio-code-lang">{part.lang}</div>}
|
||||||
|
<pre><code>{part.content}</code></pre>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span key={i} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} />
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
<span className="studio-cursor" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -119,6 +176,7 @@ export default function Studio({ api }) {
|
|||||||
const [input, setInput] = useState('')
|
const [input, setInput] = useState('')
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [streaming, setStreaming] = useState('')
|
const [streaming, setStreaming] = useState('')
|
||||||
|
const [streamThinking, setStreamThinking] = useState('')
|
||||||
const [loaded, setLoaded] = useState(false)
|
const [loaded, setLoaded] = useState(false)
|
||||||
const messagesEnd = useRef(null)
|
const messagesEnd = useRef(null)
|
||||||
const textareaRef = useRef(null)
|
const textareaRef = useRef(null)
|
||||||
@@ -143,7 +201,7 @@ export default function Studio({ api }) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
messagesEnd.current?.scrollIntoView({ behavior: 'smooth' })
|
messagesEnd.current?.scrollIntoView({ behavior: 'smooth' })
|
||||||
}, [messages, streaming])
|
}, [messages, streaming, streamThinking])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (textareaRef.current) {
|
if (textareaRef.current) {
|
||||||
@@ -175,20 +233,33 @@ export default function Studio({ api }) {
|
|||||||
setMessages(prev => [...prev, userMsg])
|
setMessages(prev => [...prev, userMsg])
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setStreaming('')
|
setStreaming('')
|
||||||
|
setStreamThinking('')
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let accumulated = ''
|
let accumulated = ''
|
||||||
await api.sendChat(text, true).then(full => {
|
let thinking = ''
|
||||||
accumulated = full
|
|
||||||
}).catch(() => {})
|
await api.sendChat(text, true, (partial, event) => {
|
||||||
|
if (event && (event.thinking_start || event.thinking_end || event.thinking !== undefined)) {
|
||||||
|
if (event.thinking !== undefined) {
|
||||||
|
thinking += event.thinking
|
||||||
|
setStreamThinking(thinking)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
accumulated = partial
|
||||||
|
setStreaming(partial)
|
||||||
|
})
|
||||||
|
|
||||||
const finalContent = accumulated || t('studio.noResponse')
|
const finalContent = accumulated || t('studio.noResponse')
|
||||||
setMessages(prev => [...prev, {
|
const aiMsg = {
|
||||||
id: (Date.now() + 1).toString(),
|
id: (Date.now() + 1).toString(),
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content: finalContent,
|
content: finalContent,
|
||||||
time: new Date().toISOString(),
|
time: new Date().toISOString(),
|
||||||
}])
|
}
|
||||||
|
if (thinking) aiMsg.thinking = thinking
|
||||||
|
setMessages(prev => [...prev, aiMsg])
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setMessages(prev => [...prev, {
|
setMessages(prev => [...prev, {
|
||||||
id: (Date.now() + 1).toString(),
|
id: (Date.now() + 1).toString(),
|
||||||
@@ -199,6 +270,7 @@ export default function Studio({ api }) {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
setStreaming('')
|
setStreaming('')
|
||||||
|
setStreamThinking('')
|
||||||
}
|
}
|
||||||
}, [input, loading, api, t, handleClear])
|
}, [input, loading, api, t, handleClear])
|
||||||
|
|
||||||
@@ -227,20 +299,8 @@ export default function Studio({ api }) {
|
|||||||
{messages.map(msg => (
|
{messages.map(msg => (
|
||||||
<FeedItem key={msg.id} msg={msg} />
|
<FeedItem key={msg.id} msg={msg} />
|
||||||
))}
|
))}
|
||||||
{streaming && <StreamingItem content={streaming} />}
|
{(streaming || streamThinking || loading) && (
|
||||||
{loading && !streaming && (
|
<StreamingItem content={streaming} thinking={streamThinking} />
|
||||||
<div className="feed-item assistant">
|
|
||||||
<div className="feed-avatar">
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
||||||
<path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div className="feed-body">
|
|
||||||
<div className="feed-content">
|
|
||||||
<div className="studio-thinking"><span /><span /><span /></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
<div ref={messagesEnd} />
|
<div ref={messagesEnd} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -81,10 +81,6 @@ const en = {
|
|||||||
|
|
||||||
shell: {
|
shell: {
|
||||||
terminal: 'Terminal',
|
terminal: 'Terminal',
|
||||||
hideAi: 'Hide AI',
|
|
||||||
aiAssistant: 'AI Assistant',
|
|
||||||
aiWelcome: 'I know your system inside out. Ask me anything.',
|
|
||||||
askAi: 'Ask AI...',
|
|
||||||
send: 'Send',
|
send: 'Send',
|
||||||
noResponse: 'No response',
|
noResponse: 'No response',
|
||||||
error: 'Error',
|
error: 'Error',
|
||||||
@@ -111,12 +107,17 @@ const en = {
|
|||||||
systemTerminals: 'System terminals',
|
systemTerminals: 'System terminals',
|
||||||
switchTerminal: 'Switch terminal',
|
switchTerminal: 'Switch terminal',
|
||||||
localShell: 'Local Shell',
|
localShell: 'Local Shell',
|
||||||
|
aiAssistant: 'AI Assistant',
|
||||||
|
aiWelcome: 'Hello! I can help you with terminal commands. Ask me anything!',
|
||||||
|
askAi: 'Ask AI assistant...',
|
||||||
|
toolLaunched: 'Tool launched',
|
||||||
},
|
},
|
||||||
|
|
||||||
config: {
|
config: {
|
||||||
panels: {
|
panels: {
|
||||||
profile: 'Profile',
|
profile: 'Profile',
|
||||||
providers: 'AI Providers',
|
providers: 'AI Providers',
|
||||||
|
terminal: 'Terminal',
|
||||||
updates: 'Updates',
|
updates: 'Updates',
|
||||||
locale: 'Language & Keyboard',
|
locale: 'Language & Keyboard',
|
||||||
skills: 'Skills',
|
skills: 'Skills',
|
||||||
@@ -174,6 +175,11 @@ const en = {
|
|||||||
enterToken: 'Enter your API token for {provider}',
|
enterToken: 'Enter your API token for {provider}',
|
||||||
tokenPlaceholder: 'sk-...',
|
tokenPlaceholder: 'sk-...',
|
||||||
setupDescription: 'Configure your AI provider token to use the assistant.',
|
setupDescription: 'Configure your AI provider token to use the assistant.',
|
||||||
|
terminalTheme: 'Terminal Theme',
|
||||||
|
fontSize: 'Font Size',
|
||||||
|
fontFamily: 'Font Family',
|
||||||
|
preview: 'Preview',
|
||||||
|
saving: 'Saving...',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -81,10 +81,6 @@ const fr = {
|
|||||||
|
|
||||||
shell: {
|
shell: {
|
||||||
terminal: 'Terminal',
|
terminal: 'Terminal',
|
||||||
hideAi: 'Masquer IA',
|
|
||||||
aiAssistant: 'Assistant IA',
|
|
||||||
aiWelcome: 'Je connais votre syst\u00e8me sur le bout des doigts. Demandez-moi n\u2019importe quoi.',
|
|
||||||
askAi: 'Demander \u00e0 l\u2019IA...',
|
|
||||||
send: 'Envoyer',
|
send: 'Envoyer',
|
||||||
noResponse: 'Pas de r\u00e9ponse',
|
noResponse: 'Pas de r\u00e9ponse',
|
||||||
error: 'Erreur',
|
error: 'Erreur',
|
||||||
@@ -111,12 +107,17 @@ const fr = {
|
|||||||
systemTerminals: 'Terminaux syst\u00e8me',
|
systemTerminals: 'Terminaux syst\u00e8me',
|
||||||
switchTerminal: 'Changer de terminal',
|
switchTerminal: 'Changer de terminal',
|
||||||
localShell: 'Shell local',
|
localShell: 'Shell local',
|
||||||
|
aiAssistant: 'Assistant IA',
|
||||||
|
aiWelcome: 'Bonjour ! Je peux vous aider avec les commandes du terminal. Demandez-moi n\'importe quoi !',
|
||||||
|
askAi: 'Interroger l\'assistant IA...',
|
||||||
|
toolLaunched: 'Outil lanc\u00e9',
|
||||||
},
|
},
|
||||||
|
|
||||||
config: {
|
config: {
|
||||||
panels: {
|
panels: {
|
||||||
profile: 'Profil',
|
profile: 'Profil',
|
||||||
providers: 'Fournisseurs IA',
|
providers: 'Fournisseurs IA',
|
||||||
|
terminal: 'Terminal',
|
||||||
updates: 'Mises \u00e0 jour',
|
updates: 'Mises \u00e0 jour',
|
||||||
locale: 'Langue & Clavier',
|
locale: 'Langue & Clavier',
|
||||||
skills: 'Comp\u00e9tences',
|
skills: 'Comp\u00e9tences',
|
||||||
@@ -174,6 +175,11 @@ const fr = {
|
|||||||
tokenPlaceholder: 'sk-...',
|
tokenPlaceholder: 'sk-...',
|
||||||
setupDescription: 'Configurez le token de votre fournisseur IA pour utiliser l\'assistant.',
|
setupDescription: 'Configurez le token de votre fournisseur IA pour utiliser l\'assistant.',
|
||||||
cancel: 'Annuler',
|
cancel: 'Annuler',
|
||||||
|
terminalTheme: 'Th\u00e8me du terminal',
|
||||||
|
fontSize: 'Taille de police',
|
||||||
|
fontFamily: 'Police',
|
||||||
|
preview: 'Aper\u00e7u',
|
||||||
|
saving: 'Enregistrement...',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -269,7 +269,7 @@ input::placeholder { color: var(--text-disabled); }
|
|||||||
.sidebar-tab.active { background: var(--accent); color: #fff; font-weight: 600; }
|
.sidebar-tab.active { background: var(--accent); color: #fff; font-weight: 600; }
|
||||||
|
|
||||||
.shell-layout { display: flex; height: 100%; }
|
.shell-layout { display: flex; height: 100%; }
|
||||||
.shell-terminal-col { flex: 1; display: flex; flex-direction: column; min-width: 0; }
|
.shell-terminal-col { flex: 1; display: flex; flex-direction: column; min-width: 0; min-height: 0; overflow: hidden; }
|
||||||
|
|
||||||
.shell-tabs-bar {
|
.shell-tabs-bar {
|
||||||
display: flex; align-items: center; background: var(--bg-surface);
|
display: flex; align-items: center; background: var(--bg-surface);
|
||||||
@@ -377,14 +377,27 @@ input::placeholder { color: var(--text-disabled); }
|
|||||||
.shell-xterm-wrapper { flex: 1; background: var(--bg); overflow: hidden; position: relative; }
|
.shell-xterm-wrapper { flex: 1; background: var(--bg); overflow: hidden; position: relative; }
|
||||||
.shell-xterm-instance {
|
.shell-xterm-instance {
|
||||||
position: absolute; inset: 0; padding: 4px;
|
position: absolute; inset: 0; padding: 4px;
|
||||||
|
display: block !important;
|
||||||
}
|
}
|
||||||
.shell-xterm-instance .xterm { height: 100%; padding: 4px; }
|
.shell-xterm-instance .xterm { height: 100%; padding: 4px; }
|
||||||
|
|
||||||
.shell-ai-col { width: 340px; border-left: 1px solid var(--border); background: var(--bg-surface); display: flex; flex-direction: column; flex-shrink: 0; }
|
|
||||||
.connection-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; flex-shrink: 0; }
|
.connection-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; flex-shrink: 0; }
|
||||||
.connection-dot.on { background: var(--success); box-shadow: 0 0 6px var(--success); }
|
.connection-dot.on { background: var(--success); box-shadow: 0 0 6px var(--success); }
|
||||||
.connection-dot.off { background: var(--error); }
|
.connection-dot.off { background: var(--error); }
|
||||||
|
|
||||||
|
.shell-ai-col { width: 320px; border-left: 1px solid var(--border); background: var(--bg-surface); display: flex; flex-direction: column; flex-shrink: 0; }
|
||||||
|
.ai-panel-header { padding: 12px 16px; border-bottom: 1px solid var(--border); font-weight: 700; font-size: 13px; color: var(--accent); }
|
||||||
|
.ai-panel-messages { flex: 1; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.ai-message { padding: 8px 12px; border-radius: var(--radius); font-size: 13px; line-height: 1.5; word-break: break-word; }
|
||||||
|
.ai-message.ai { background: var(--bg-card); border-left: 3px solid var(--accent); }
|
||||||
|
.ai-message.user { background: var(--accent-bg); border-left: 3px solid var(--accent-muted); }
|
||||||
|
.ai-message.thinking { background: var(--bg-elevated); border-left: 3px solid var(--info); font-style: italic; color: var(--text-tertiary); }
|
||||||
|
.ai-message.tool { background: var(--bg-elevated); border-left: 3px solid var(--warning); }
|
||||||
|
.ai-message.tool .tool-name { font-weight: 700; color: var(--warning); }
|
||||||
|
.ai-message.tool .tool-args { font-family: var(--font-mono); font-size: 12px; color: var(--text-tertiary); margin-top: 4px; }
|
||||||
|
.ai-panel-input { display: flex; gap: 6px; padding: 10px 12px; border-top: 1px solid var(--border); }
|
||||||
|
.ai-panel-input input { flex: 1; font-size: 13px; padding: 6px 10px; }
|
||||||
|
|
||||||
.shell-modal-overlay {
|
.shell-modal-overlay {
|
||||||
position: fixed; inset: 0; background: rgba(0,0,0,0.6);
|
position: fixed; inset: 0; background: rgba(0,0,0,0.6);
|
||||||
display: flex; align-items: center; justify-content: center; z-index: 1000;
|
display: flex; align-items: center; justify-content: center; z-index: 1000;
|
||||||
@@ -510,14 +523,6 @@ input::placeholder { color: var(--text-disabled); }
|
|||||||
.agent-name { font-weight: 600; color: var(--text-primary); font-size: 13px; }
|
.agent-name { font-weight: 600; color: var(--text-primary); font-size: 13px; }
|
||||||
.agent-status { font-size: 11px; color: var(--text-tertiary); margin-top: 2px; }
|
.agent-status { font-size: 11px; color: var(--text-tertiary); margin-top: 2px; }
|
||||||
|
|
||||||
.ai-panel-header { padding: 12px 16px; border-bottom: 1px solid var(--border); font-weight: 700; font-size: 13px; color: var(--accent); }
|
|
||||||
.ai-panel-messages { flex: 1; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 8px; }
|
|
||||||
.ai-message { padding: 8px 12px; border-radius: var(--radius); font-size: 13px; line-height: 1.5; word-break: break-word; }
|
|
||||||
.ai-message.ai { background: var(--bg-card); border-left: 3px solid var(--accent); }
|
|
||||||
.ai-message.user { background: var(--accent-bg); border-left: 3px solid var(--accent-muted); }
|
|
||||||
.ai-panel-input { display: flex; gap: 6px; padding: 10px 12px; border-top: 1px solid var(--border); }
|
|
||||||
.ai-panel-input input { flex: 1; font-size: 13px; padding: 6px 10px; }
|
|
||||||
|
|
||||||
.empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 40px 20px; color: var(--text-disabled); font-size: 13px; text-align: center; gap: 8px; }
|
.empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 40px 20px; color: var(--text-disabled); font-size: 13px; text-align: center; gap: 8px; }
|
||||||
|
|
||||||
.dashboard-layout { display: flex; flex-direction: column; height: 100%; }
|
.dashboard-layout { display: flex; flex-direction: column; height: 100%; }
|
||||||
@@ -578,18 +583,60 @@ input::placeholder { color: var(--text-disabled); }
|
|||||||
.feed-loading { display: flex; align-items: center; justify-content: center; padding: 60px 0; }
|
.feed-loading { display: flex; align-items: center; justify-content: center; padding: 60px 0; }
|
||||||
.feed-item { display: flex; gap: 10px; padding: 8px 12px; border-radius: var(--radius); animation: fadeIn 0.15s ease-out; }
|
.feed-item { display: flex; gap: 10px; padding: 8px 12px; border-radius: var(--radius); animation: fadeIn 0.15s ease-out; }
|
||||||
.feed-item:hover { background: var(--bg-card); }
|
.feed-item:hover { background: var(--bg-card); }
|
||||||
.feed-item.user { background: var(--bg-card); border-left: 3px solid var(--accent-muted); }
|
.feed-item.user { background: var(--bg-card); border-left: 3px solid #FFD740; }
|
||||||
.feed-item.assistant { }
|
.feed-item.assistant { border-left: 3px solid transparent; }
|
||||||
|
.feed-item.assistant:hover { border-left-color: var(--accent-dark); }
|
||||||
.feed-item.system { align-items: center; gap: 8px; padding: 6px 12px; }
|
.feed-item.system { align-items: center; gap: 8px; padding: 6px 12px; }
|
||||||
.feed-avatar { width: 24px; height: 24px; border-radius: 50%; background: var(--accent-bg); color: var(--accent); display: flex; align-items: center; justify-content: center; flex-shrink: 0; margin-top: 2px; }
|
.feed-avatar { width: 28px; height: 28px; border-radius: 50%; display: flex; align-items: center; justify-content: center; flex-shrink: 0; margin-top: 2px; font-size: 14px; }
|
||||||
|
.feed-avatar.user-rank { background: rgba(255, 215, 64, 0.15); }
|
||||||
|
.feed-avatar.ai-rank { background: var(--accent-bg); }
|
||||||
|
.feed-rank-icon { display: flex; align-items: center; justify-content: center; }
|
||||||
.feed-body { flex: 1; min-width: 0; }
|
.feed-body { flex: 1; min-width: 0; }
|
||||||
.feed-header { display: flex; align-items: center; gap: 8px; margin-bottom: 2px; }
|
.feed-header { display: flex; align-items: center; gap: 8px; margin-bottom: 2px; }
|
||||||
|
.feed-rank-badge {
|
||||||
|
font-size: 9px; font-weight: 800; font-family: var(--font-mono);
|
||||||
|
padding: 1px 6px; border-radius: 3px; border: 1px solid;
|
||||||
|
letter-spacing: 0.5px; text-transform: uppercase;
|
||||||
|
background: rgba(255, 215, 64, 0.08);
|
||||||
|
}
|
||||||
.feed-role { font-size: 11px; font-weight: 700; color: var(--accent); text-transform: uppercase; letter-spacing: 0.5px; }
|
.feed-role { font-size: 11px; font-weight: 700; color: var(--accent); text-transform: uppercase; letter-spacing: 0.5px; }
|
||||||
.feed-time { font-size: 10px; color: var(--text-disabled); font-family: var(--font-mono); }
|
.feed-time { font-size: 10px; color: var(--text-disabled); font-family: var(--font-mono); }
|
||||||
.feed-content { font-size: 14px; line-height: 1.7; color: var(--text-primary); word-break: break-word; }
|
.feed-content { font-size: 14px; line-height: 1.7; color: var(--text-primary); word-break: break-word; }
|
||||||
.feed-system-badge { width: 6px; height: 6px; border-radius: 50%; background: var(--accent-dim); flex-shrink: 0; }
|
.feed-system-badge { width: 6px; height: 6px; border-radius: 50%; background: var(--accent-dim); flex-shrink: 0; }
|
||||||
.feed-system-text { font-size: 12px; color: var(--text-tertiary); font-style: italic; flex: 1; }
|
.feed-system-text { font-size: 12px; color: var(--text-tertiary); font-style: italic; flex: 1; }
|
||||||
|
|
||||||
|
.feed-thinking-block {
|
||||||
|
background: var(--bg-surface); border: 1px solid var(--border); border-left: 2px solid var(--accent-dim);
|
||||||
|
border-radius: var(--radius); margin: 6px 0 8px; overflow: hidden;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
.feed-thinking-block.active {
|
||||||
|
border-left-color: var(--warning);
|
||||||
|
}
|
||||||
|
.feed-thinking-block.done {
|
||||||
|
border-left-color: var(--text-disabled);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
.feed-thinking-block.done .feed-thinking-content {
|
||||||
|
max-height: 80px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.feed-thinking-header {
|
||||||
|
display: flex; align-items: center; gap: 6px;
|
||||||
|
padding: 6px 10px; font-size: 10px; font-weight: 700;
|
||||||
|
color: var(--text-tertiary); text-transform: uppercase; letter-spacing: 0.5px;
|
||||||
|
background: var(--bg-card); border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.feed-thinking-header svg { color: var(--warning); }
|
||||||
|
.feed-thinking-dots { display: inline-flex; gap: 2px; margin-left: 4px; }
|
||||||
|
.feed-thinking-dots span { width: 4px; height: 4px; border-radius: 50%; background: var(--warning); animation: bounce 1.2s ease-in-out infinite; }
|
||||||
|
.feed-thinking-dots span:nth-child(2) { animation-delay: 0.15s; }
|
||||||
|
.feed-thinking-dots span:nth-child(3) { animation-delay: 0.3s; }
|
||||||
|
.feed-thinking-content {
|
||||||
|
padding: 8px 10px; font-size: 12px; color: var(--text-tertiary);
|
||||||
|
font-style: italic; line-height: 1.5; max-height: 120px; overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.studio-code-block {
|
.studio-code-block {
|
||||||
background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius);
|
background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius);
|
||||||
overflow: hidden; margin: 8px 0;
|
overflow: hidden; margin: 8px 0;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export default defineConfig({
|
|||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://127.0.0.1:8095',
|
target: 'http://127.0.0.1:8095',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
|
ws: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user