feat: AI task API, token-based context windows, SSH password auth, sudo bypass detection
All checks were successful
Beta Release / beta (push) Successful in 1m6s
All checks were successful
Beta Release / beta (push) Successful in 1m6s
Replace message-count context windows with token-budget based ones for both studio and shell. Add /api/ai/task endpoint for background tool check/install/update. Enhance sudo blocking to catch piped/chained elevation commands. Add SSH password support via sshpass and connection editing UI. Remove realTokens persistence in favor of consumption tracking. Bump to 0.4.1. 💘 Generated with Crush Assisted-by: GLM-5.1 via Crush <crush@charm.land>
This commit is contained in:
@@ -56,9 +56,30 @@ func NewTerminalTool() (*ToolDefinition, error) {
|
|||||||
if NeedsSudoPassword() {
|
if NeedsSudoPassword() {
|
||||||
trimmed := strings.TrimSpace(p.Command)
|
trimmed := strings.TrimSpace(p.Command)
|
||||||
lower := strings.ToLower(trimmed)
|
lower := strings.ToLower(trimmed)
|
||||||
if strings.HasPrefix(lower, "sudo ") || strings.HasPrefix(lower, "doas ") || strings.HasPrefix(lower, "run0 ") || strings.HasPrefix(lower, "pkexec ") {
|
prefixBlocked := strings.HasPrefix(lower, "sudo ") || strings.HasPrefix(lower, "doas ") || strings.HasPrefix(lower, "run0 ") || strings.HasPrefix(lower, "pkexec ")
|
||||||
|
anywhereBlocked := false
|
||||||
|
blockedCmd := ""
|
||||||
|
if !prefixBlocked {
|
||||||
|
for _, kw := range []string{"sudo", "doas", "run0", "pkexec"} {
|
||||||
|
for _, pattern := range []string{" " + kw + " ", "|" + kw + " ", ";" + kw + " ", "&&" + kw + " ", "||" + kw + " ", "`" + kw + " ", "$(" + kw + " "} {
|
||||||
|
if strings.Contains(lower, pattern) {
|
||||||
|
anywhereBlocked = true
|
||||||
|
blockedCmd = kw
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if anywhereBlocked {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if prefixBlocked || anywhereBlocked {
|
||||||
|
elevCmd := blockedCmd
|
||||||
|
if prefixBlocked {
|
||||||
|
elevCmd = strings.Fields(trimmed)[0]
|
||||||
|
}
|
||||||
return ToolResponse{
|
return ToolResponse{
|
||||||
Content: fmt.Sprintf("BLOCKED: Command '%s' requires elevated privileges (%s). The current user is not root. Do NOT retry with sudo. Explain to the user that this command needs admin privileges and suggest an alternative, or tell them to run it manually in their terminal.", trimmed, strings.Fields(trimmed)[0]),
|
Content: fmt.Sprintf("BLOCKED: Command '%s' requires elevated privileges (%s). Passwordless sudo is not available. Do NOT retry with sudo. Explain to the user that this command needs admin privileges and suggest an alternative, or tell them to run it manually in their terminal.", trimmed, elevCmd),
|
||||||
IsError: true,
|
IsError: true,
|
||||||
Meta: map[string]string{"sudo_blocked": "true", "command": trimmed},
|
Meta: map[string]string{"sudo_blocked": "true", "command": trimmed},
|
||||||
}, nil
|
}, nil
|
||||||
|
|||||||
@@ -26,18 +26,16 @@ type FeedMessage struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Conversation struct {
|
type Conversation struct {
|
||||||
Messages []FeedMessage `json:"messages"`
|
Messages []FeedMessage `json:"messages"`
|
||||||
Summary string `json:"summary,omitempty"`
|
Summary string `json:"summary,omitempty"`
|
||||||
RealTokens int `json:"real_tokens,omitempty"`
|
CreatedAt string `json:"created_at"`
|
||||||
CreatedAt string `json:"created_at"`
|
UpdatedAt string `json:"updated_at"`
|
||||||
UpdatedAt string `json:"updated_at"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type ConversationStore struct {
|
type ConversationStore struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
path string
|
path string
|
||||||
conv *Conversation
|
conv *Conversation
|
||||||
realTokens int
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type TokenCount struct {
|
type TokenCount struct {
|
||||||
@@ -87,7 +85,6 @@ func (cs *ConversationStore) load() {
|
|||||||
conv.Messages = []FeedMessage{}
|
conv.Messages = []FeedMessage{}
|
||||||
}
|
}
|
||||||
cs.conv = &conv
|
cs.conv = &conv
|
||||||
cs.realTokens = conv.RealTokens
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cs *ConversationStore) save() error {
|
func (cs *ConversationStore) save() error {
|
||||||
@@ -157,10 +154,8 @@ func (cs *ConversationStore) Clear() {
|
|||||||
|
|
||||||
cs.conv.Messages = []FeedMessage{}
|
cs.conv.Messages = []FeedMessage{}
|
||||||
cs.conv.Summary = ""
|
cs.conv.Summary = ""
|
||||||
cs.conv.RealTokens = 0
|
|
||||||
cs.conv.CreatedAt = time.Now().Format(time.RFC3339)
|
cs.conv.CreatedAt = time.Now().Format(time.RFC3339)
|
||||||
cs.conv.UpdatedAt = time.Now().Format(time.RFC3339)
|
cs.conv.UpdatedAt = time.Now().Format(time.RFC3339)
|
||||||
cs.realTokens = 0
|
|
||||||
cs.save()
|
cs.save()
|
||||||
|
|
||||||
go cleanupImages(imageIDs)
|
go cleanupImages(imageIDs)
|
||||||
@@ -184,23 +179,9 @@ func (cs *ConversationStore) TrimOld(keepCount int) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (cs *ConversationStore) ApproxTokenCount() int {
|
func (cs *ConversationStore) ApproxTokenCount() int {
|
||||||
if cs.realTokens > 0 {
|
|
||||||
return cs.realTokens
|
|
||||||
}
|
|
||||||
return cs.ApproxTokenCountDetailed().total
|
return cs.ApproxTokenCountDetailed().total
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddRealTokens accumulates actual token counts from the API response.
|
|
||||||
func (cs *ConversationStore) AddRealTokens(tokens int) {
|
|
||||||
if tokens <= 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
cs.mu.Lock()
|
|
||||||
cs.realTokens += tokens
|
|
||||||
cs.conv.RealTokens = cs.realTokens
|
|
||||||
cs.mu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (cs *ConversationStore) ApproxTokenCountDetailed() TokenCount {
|
func (cs *ConversationStore) ApproxTokenCountDetailed() TokenCount {
|
||||||
cs.mu.RLock()
|
cs.mu.RLock()
|
||||||
defer cs.mu.RUnlock()
|
defer cs.mu.RUnlock()
|
||||||
|
|||||||
172
internal/api/handlers_ai_task.go
Normal file
172
internal/api/handlers_ai_task.go
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/muyue/muyue/internal/orchestrator"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) handleAITask(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "POST" {
|
||||||
|
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var body struct {
|
||||||
|
Task string `json:"task"`
|
||||||
|
Tool string `json:"tool,omitempty"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.Task == "" {
|
||||||
|
writeError(w, "task is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
orb, err := orchestrator.New(s.config)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, "AI not available: "+err.Error(), http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
orb.SetSystemPrompt(buildAITaskSystemPrompt())
|
||||||
|
orb.SetTools(s.shellAgentToolsJSON)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
messages := []orchestrator.Message{
|
||||||
|
{Role: "user", Content: orchestrator.TextContent(buildAITaskPrompt(body.Task, body.Tool))},
|
||||||
|
}
|
||||||
|
|
||||||
|
engine := NewChatEngine(orb, s.shellAgentRegistry, s.shellAgentToolsJSON)
|
||||||
|
finalContent, err := engine.RunNonStream(ctx, messages)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, "AI task failed: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.consumption.Record(engine.ProviderName(), engine.TotalTokens)
|
||||||
|
|
||||||
|
parsed := parseAIJSONResponse(finalContent)
|
||||||
|
|
||||||
|
writeJSON(w, map[string]interface{}{
|
||||||
|
"status": "ok",
|
||||||
|
"raw": finalContent,
|
||||||
|
"result": parsed,
|
||||||
|
"tokens": engine.TotalTokens,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildAITaskSystemPrompt() string {
|
||||||
|
return fmt.Sprintf(`You are a system administration assistant. You have access to a terminal tool to run commands on the host system.
|
||||||
|
|
||||||
|
IMPORTANT RULES:
|
||||||
|
- You MUST respond ONLY with valid JSON. No markdown, no code fences, no extra text.
|
||||||
|
- Always run the actual commands needed to complete the task.
|
||||||
|
- Be thorough: check versions, verify installations, compare with latest releases.
|
||||||
|
|
||||||
|
OS: %s/%s
|
||||||
|
Date: %s
|
||||||
|
`, runtime.GOOS, runtime.GOARCH, time.Now().Format("2006-01-02"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildAITaskPrompt(task, tool string) string {
|
||||||
|
switch task {
|
||||||
|
case "check_tools":
|
||||||
|
return `Check the following tools on this system. For each tool, determine:
|
||||||
|
1. Is it installed? Run "which <tool>" or "<tool> --version"
|
||||||
|
2. If installed, what is the current version?
|
||||||
|
3. What is the latest available version? Check GitHub releases API or official sources.
|
||||||
|
|
||||||
|
Tools to check: crush, claude, git, node, npm, pnpm, python3, pip3, uv, go, docker, gh, starship, npx
|
||||||
|
|
||||||
|
Run the commands needed, then respond with ONLY this JSON structure (no markdown fences):
|
||||||
|
{
|
||||||
|
"tools": [
|
||||||
|
{"name": "tool_name", "installed": true/false, "version": "x.y.z", "latest": "a.b.c", "needs_update": true/false, "category": "ai|runtime|vcs|devops|prompt"}
|
||||||
|
]
|
||||||
|
}`
|
||||||
|
|
||||||
|
case "install_tool":
|
||||||
|
return fmt.Sprintf(`Install the tool "%s" on this system.
|
||||||
|
|
||||||
|
Steps:
|
||||||
|
1. Check if it's already installed: run "which %s" and "%s --version"
|
||||||
|
2. If not installed, determine the best installation method for this OS
|
||||||
|
3. Run the installation command
|
||||||
|
4. Verify the installation succeeded
|
||||||
|
|
||||||
|
Respond with ONLY this JSON (no markdown fences):
|
||||||
|
{
|
||||||
|
"tool": "%s",
|
||||||
|
"installed": true/false,
|
||||||
|
"version": "installed version or empty",
|
||||||
|
"message": "what was done",
|
||||||
|
"error": "error message or empty"
|
||||||
|
}`, tool, tool, tool, tool)
|
||||||
|
|
||||||
|
case "update_tool":
|
||||||
|
return fmt.Sprintf(`Update the tool "%s" to its latest version on this system.
|
||||||
|
|
||||||
|
Steps:
|
||||||
|
1. Check current version: run "%s --version"
|
||||||
|
2. Find the latest version available
|
||||||
|
3. Run the update/upgrade command
|
||||||
|
4. Verify the new version
|
||||||
|
|
||||||
|
Respond with ONLY this JSON (no markdown fences):
|
||||||
|
{
|
||||||
|
"tool": "%s",
|
||||||
|
"previous_version": "old version",
|
||||||
|
"version": "new version",
|
||||||
|
"updated": true/false,
|
||||||
|
"message": "what was done",
|
||||||
|
"error": "error message or empty"
|
||||||
|
}`, tool, tool, tool)
|
||||||
|
|
||||||
|
default:
|
||||||
|
return task
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseAIJSONResponse(content string) interface{} {
|
||||||
|
cleaned := content
|
||||||
|
|
||||||
|
if idx := strings.Index(cleaned, "```json"); idx != -1 {
|
||||||
|
cleaned = cleaned[idx+7:]
|
||||||
|
if end := strings.Index(cleaned, "```"); end != -1 {
|
||||||
|
cleaned = cleaned[:end]
|
||||||
|
}
|
||||||
|
} else if idx := strings.Index(cleaned, "```"); idx != -1 {
|
||||||
|
cleaned = cleaned[idx+3:]
|
||||||
|
if end := strings.Index(cleaned, "```"); end != -1 {
|
||||||
|
cleaned = cleaned[:end]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cleaned = strings.TrimSpace(cleaned)
|
||||||
|
|
||||||
|
jsonStart := strings.Index(cleaned, "{")
|
||||||
|
jsonEnd := strings.LastIndex(cleaned, "}")
|
||||||
|
if jsonStart != -1 && jsonEnd > jsonStart {
|
||||||
|
cleaned = cleaned[jsonStart : jsonEnd+1]
|
||||||
|
}
|
||||||
|
|
||||||
|
var result interface{}
|
||||||
|
if err := json.Unmarshal([]byte(cleaned), &result); err != nil {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"raw": content,
|
||||||
|
"error": "failed to parse AI response as JSON",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/muyue/muyue/internal/agent"
|
"github.com/muyue/muyue/internal/agent"
|
||||||
"github.com/muyue/muyue/internal/orchestrator"
|
"github.com/muyue/muyue/internal/orchestrator"
|
||||||
@@ -253,7 +254,6 @@ func (s *Server) handleStreamChat(w http.ResponseWriter, orb *orchestrator.Orche
|
|||||||
storeContent = string(storeJSON)
|
storeContent = string(storeJSON)
|
||||||
}
|
}
|
||||||
s.convStore.Add("assistant", storeContent)
|
s.convStore.Add("assistant", storeContent)
|
||||||
s.convStore.AddRealTokens(engine.TotalTokens)
|
|
||||||
|
|
||||||
s.consumption.Record(engine.ProviderName(), engine.TotalTokens)
|
s.consumption.Record(engine.ProviderName(), engine.TotalTokens)
|
||||||
|
|
||||||
@@ -272,7 +272,6 @@ func (s *Server) handleNonStreamChat(w http.ResponseWriter, orb *orchestrator.Or
|
|||||||
}
|
}
|
||||||
|
|
||||||
s.convStore.Add("assistant", finalContent)
|
s.convStore.Add("assistant", finalContent)
|
||||||
s.convStore.AddRealTokens(engine.TotalTokens)
|
|
||||||
|
|
||||||
s.consumption.Record(engine.ProviderName(), engine.TotalTokens)
|
s.consumption.Record(engine.ProviderName(), engine.TotalTokens)
|
||||||
|
|
||||||
@@ -283,19 +282,47 @@ func cleanThinkingTags(content string) string {
|
|||||||
return strings.TrimSpace(thinkingTagRegex.ReplaceAllString(content, ""))
|
return strings.TrimSpace(thinkingTagRegex.ReplaceAllString(content, ""))
|
||||||
}
|
}
|
||||||
|
|
||||||
const contextWindowMessages = 20
|
|
||||||
|
|
||||||
func (s *Server) buildContextMessages(userMessage string) []orchestrator.Message {
|
func (s *Server) buildContextMessages(userMessage string) []orchestrator.Message {
|
||||||
history := s.convStore.Get()
|
history := s.convStore.Get()
|
||||||
start := 0
|
|
||||||
if len(history) > contextWindowMessages {
|
sysPromptTokens := utf8.RuneCountInString(agent.StudioSystemPrompt())/charsPerToken + 50
|
||||||
start = len(history) - contextWindowMessages
|
toolsTokens := utf8.RuneCountInString(string(s.agentToolsJSON)) / charsPerToken
|
||||||
|
responseMargin := 4000
|
||||||
|
userMsgTokens := utf8.RuneCountInString(userMessage) / charsPerToken
|
||||||
|
|
||||||
|
overhead := sysPromptTokens + toolsTokens + responseMargin + userMsgTokens
|
||||||
|
available := contextWindowTokens - overhead
|
||||||
|
if available < 1000 {
|
||||||
|
available = 1000
|
||||||
}
|
}
|
||||||
|
|
||||||
messages := make([]orchestrator.Message, 0, len(history[start:])+1)
|
included := 0
|
||||||
|
tokensUsed := 0
|
||||||
|
for i := len(history) - 1; i >= 0; i-- {
|
||||||
|
msgTokens := utf8.RuneCountInString(history[i].Content) / charsPerToken
|
||||||
|
if msgTokens == 0 {
|
||||||
|
msgTokens = 1
|
||||||
|
}
|
||||||
|
if tokensUsed+msgTokens > available {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
tokensUsed += msgTokens
|
||||||
|
included++
|
||||||
|
}
|
||||||
|
|
||||||
|
start := len(history) - included
|
||||||
|
if start < 0 {
|
||||||
|
start = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if start > 0 {
|
||||||
|
log.Printf("[studio] context budget: %d/%d tokens, including %d/%d messages (dropped %d older)", tokensUsed+overhead, contextWindowTokens, included, len(history), start)
|
||||||
|
}
|
||||||
|
|
||||||
|
messages := make([]orchestrator.Message, 0, included+2)
|
||||||
|
|
||||||
summary := s.convStore.GetSummary()
|
summary := s.convStore.GetSummary()
|
||||||
if summary != "" {
|
if summary != "" && start > 0 {
|
||||||
messages = append(messages, orchestrator.Message{
|
messages = append(messages, orchestrator.Message{
|
||||||
Role: "system",
|
Role: "system",
|
||||||
Content: orchestrator.TextContent("Résumé de la conversation précédente:\n" + summary),
|
Content: orchestrator.TextContent("Résumé de la conversation précédente:\n" + summary),
|
||||||
|
|||||||
@@ -4,12 +4,14 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/muyue/muyue/internal/agent"
|
"github.com/muyue/muyue/internal/agent"
|
||||||
"github.com/muyue/muyue/internal/orchestrator"
|
"github.com/muyue/muyue/internal/orchestrator"
|
||||||
@@ -133,7 +135,6 @@ func (s *Server) handleShellChatStream(w http.ResponseWriter, orb *orchestrator.
|
|||||||
storeContent = string(storeJSON)
|
storeContent = string(storeJSON)
|
||||||
}
|
}
|
||||||
s.shellConvStore.Add("assistant", storeContent)
|
s.shellConvStore.Add("assistant", storeContent)
|
||||||
s.shellConvStore.AddRealTokens(engine.TotalTokens)
|
|
||||||
|
|
||||||
s.consumption.Record(engine.ProviderName(), engine.TotalTokens)
|
s.consumption.Record(engine.ProviderName(), engine.TotalTokens)
|
||||||
|
|
||||||
@@ -155,7 +156,6 @@ func (s *Server) handleShellChatNonStream(w http.ResponseWriter, orb *orchestrat
|
|||||||
}
|
}
|
||||||
|
|
||||||
s.shellConvStore.Add("assistant", finalContent)
|
s.shellConvStore.Add("assistant", finalContent)
|
||||||
s.shellConvStore.AddRealTokens(engine.TotalTokens)
|
|
||||||
|
|
||||||
s.consumption.Record(engine.ProviderName(), engine.TotalTokens)
|
s.consumption.Record(engine.ProviderName(), engine.TotalTokens)
|
||||||
|
|
||||||
@@ -167,13 +167,45 @@ func (s *Server) handleShellChatNonStream(w http.ResponseWriter, orb *orchestrat
|
|||||||
|
|
||||||
func (s *Server) buildShellContextMessages() []orchestrator.Message {
|
func (s *Server) buildShellContextMessages() []orchestrator.Message {
|
||||||
history := s.shellConvStore.Get()
|
history := s.shellConvStore.Get()
|
||||||
start := 0
|
|
||||||
const shellContextWindow = 20
|
sysTokens := utf8.RuneCountInString(shellSystemPromptBase) / charsPerToken
|
||||||
if len(history) > shellContextWindow {
|
if analysis := LoadSystemAnalysis(); analysis != "" {
|
||||||
start = len(history) - shellContextWindow
|
sysTokens += utf8.RuneCountInString(analysis) / charsPerToken
|
||||||
|
}
|
||||||
|
sysTokens += 100
|
||||||
|
toolsTokens := utf8.RuneCountInString(string(s.shellAgentToolsJSON)) / charsPerToken
|
||||||
|
responseMargin := 4000
|
||||||
|
|
||||||
|
overhead := sysTokens + toolsTokens + responseMargin
|
||||||
|
available := shellMaxTokens - overhead
|
||||||
|
if available < 1000 {
|
||||||
|
available = 1000
|
||||||
}
|
}
|
||||||
|
|
||||||
messages := make([]orchestrator.Message, 0, len(history[start:]))
|
included := 0
|
||||||
|
tokensUsed := 0
|
||||||
|
for i := len(history) - 1; i >= 0; i-- {
|
||||||
|
msgTokens := utf8.RuneCountInString(history[i].Content) / charsPerToken
|
||||||
|
if msgTokens == 0 {
|
||||||
|
msgTokens = 1
|
||||||
|
}
|
||||||
|
if tokensUsed+msgTokens > available {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
tokensUsed += msgTokens
|
||||||
|
included++
|
||||||
|
}
|
||||||
|
|
||||||
|
start := len(history) - included
|
||||||
|
if start < 0 {
|
||||||
|
start = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if start > 0 {
|
||||||
|
log.Printf("[shell] context budget: %d/%d tokens, including %d/%d messages (dropped %d older)", tokensUsed+overhead, shellMaxTokens, included, len(history), start)
|
||||||
|
}
|
||||||
|
|
||||||
|
messages := make([]orchestrator.Message, 0, included)
|
||||||
|
|
||||||
for _, m := range history[start:] {
|
for _, m := range history[start:] {
|
||||||
content := m.Content
|
content := m.Content
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ func (s *Server) routes() {
|
|||||||
s.mux.HandleFunc("/api/skills/export", s.handleSkillExport)
|
s.mux.HandleFunc("/api/skills/export", s.handleSkillExport)
|
||||||
s.mux.HandleFunc("/api/skills/import", s.handleSkillImport)
|
s.mux.HandleFunc("/api/skills/import", s.handleSkillImport)
|
||||||
s.mux.HandleFunc("/api/dashboard/status", s.handleDashboardStatus)
|
s.mux.HandleFunc("/api/dashboard/status", s.handleDashboardStatus)
|
||||||
|
s.mux.HandleFunc("/api/ai/task", s.handleAITask)
|
||||||
s.mux.HandleFunc("/api/providers/quota", s.handleProvidersQuota)
|
s.mux.HandleFunc("/api/providers/quota", s.handleProvidersQuota)
|
||||||
s.mux.HandleFunc("/api/providers/consumption", s.handleProvidersConsumption)
|
s.mux.HandleFunc("/api/providers/consumption", s.handleProvidersConsumption)
|
||||||
s.mux.HandleFunc("/api/recent-commands", s.handleRecentCommands)
|
s.mux.HandleFunc("/api/recent-commands", s.handleRecentCommands)
|
||||||
|
|||||||
@@ -79,10 +79,9 @@ type ShellMessage struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ShellConvStore struct {
|
type ShellConvStore struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
path string
|
path string
|
||||||
msgs []ShellMessage
|
msgs []ShellMessage
|
||||||
realTokens int
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewShellConvStore() *ShellConvStore {
|
func NewShellConvStore() *ShellConvStore {
|
||||||
@@ -140,14 +139,10 @@ func (s *ShellConvStore) Clear() {
|
|||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
s.msgs = []ShellMessage{}
|
s.msgs = []ShellMessage{}
|
||||||
s.realTokens = 0
|
|
||||||
s.save()
|
s.save()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ShellConvStore) ApproxTokens() int {
|
func (s *ShellConvStore) ApproxTokens() int {
|
||||||
if s.realTokens > 0 {
|
|
||||||
return s.realTokens
|
|
||||||
}
|
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
defer s.mu.RUnlock()
|
defer s.mu.RUnlock()
|
||||||
total := 0
|
total := 0
|
||||||
@@ -161,16 +156,6 @@ func (s *ShellConvStore) ApproxTokens() int {
|
|||||||
return total
|
return total
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddRealTokens accumulates actual token counts from the API response.
|
|
||||||
func (s *ShellConvStore) AddRealTokens(tokens int) {
|
|
||||||
if tokens <= 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.mu.Lock()
|
|
||||||
s.realTokens += tokens
|
|
||||||
s.mu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ShellConvStore) AtLimit() bool {
|
func (s *ShellConvStore) AtLimit() bool {
|
||||||
return s.ApproxTokens() >= shellMaxTokens
|
return s.ApproxTokens() >= shellMaxTokens
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,10 +72,11 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
if initMsg.Type == "ssh" && initMsg.Data != "" {
|
if initMsg.Type == "ssh" && initMsg.Data != "" {
|
||||||
var sshConf struct {
|
var sshConf struct {
|
||||||
Host string `json:"host"`
|
Host string `json:"host"`
|
||||||
Port int `json:"port"`
|
Port int `json:"port"`
|
||||||
User string `json:"user"`
|
User string `json:"user"`
|
||||||
KeyPath string `json:"key_path"`
|
KeyPath string `json:"key_path"`
|
||||||
|
Password string `json:"password"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal([]byte(initMsg.Data), &sshConf); err != nil {
|
if err := json.Unmarshal([]byte(initMsg.Data), &sshConf); err != nil {
|
||||||
conn.WriteJSON(wsMessage{Type: "error", Data: "invalid ssh config"})
|
conn.WriteJSON(wsMessage{Type: "error", Data: "invalid ssh config"})
|
||||||
@@ -98,7 +99,16 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
sshArgs = append(sshArgs, fmt.Sprintf("%s@%s", sshConf.User, sshConf.Host))
|
sshArgs = append(sshArgs, fmt.Sprintf("%s@%s", sshConf.User, sshConf.Host))
|
||||||
|
|
||||||
cmd = exec.Command("ssh", sshArgs...)
|
if sshConf.Password != "" {
|
||||||
|
sshpassPath, err := exec.LookPath("sshpass")
|
||||||
|
if err == nil {
|
||||||
|
cmd = exec.Command(sshpassPath, append([]string{"-p", sshConf.Password}, append([]string{"-e"}, sshArgs...)...)...)
|
||||||
|
} else {
|
||||||
|
cmd = exec.Command("ssh", sshArgs...)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
cmd = exec.Command("ssh", sshArgs...)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
shell := strings.TrimSpace(initMsg.Data)
|
shell := strings.TrimSpace(initMsg.Data)
|
||||||
log.Printf("terminal: requested shell=%q, trimmed=%q", initMsg.Data, shell)
|
log.Printf("terminal: requested shell=%q, trimmed=%q", initMsg.Data, shell)
|
||||||
@@ -222,11 +232,12 @@ func (s *Server) handleTerminalSessions(w http.ResponseWriter, r *http.Request)
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
var body struct {
|
var body struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Host string `json:"host"`
|
Host string `json:"host"`
|
||||||
Port int `json:"port"`
|
Port int `json:"port"`
|
||||||
User string `json:"user"`
|
User string `json:"user"`
|
||||||
KeyPath string `json:"key_path"`
|
KeyPath string `json:"key_path"`
|
||||||
|
Password string `json:"password"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
writeError(w, err.Error(), http.StatusBadRequest)
|
writeError(w, err.Error(), http.StatusBadRequest)
|
||||||
@@ -240,12 +251,32 @@ func (s *Server) handleTerminalSessions(w http.ResponseWriter, r *http.Request)
|
|||||||
body.Port = 22
|
body.Port = 22
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for i, c := range s.config.Terminal.SSH {
|
||||||
|
if c.Name == body.Name {
|
||||||
|
s.config.Terminal.SSH[i] = config.SSHConnection{
|
||||||
|
Name: body.Name,
|
||||||
|
Host: body.Host,
|
||||||
|
Port: body.Port,
|
||||||
|
User: body.User,
|
||||||
|
KeyPath: body.KeyPath,
|
||||||
|
Password: body.Password,
|
||||||
|
}
|
||||||
|
if err := config.Save(s.config); err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]string{"status": "ok"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
conn := config.SSHConnection{
|
conn := config.SSHConnection{
|
||||||
Name: body.Name,
|
Name: body.Name,
|
||||||
Host: body.Host,
|
Host: body.Host,
|
||||||
Port: body.Port,
|
Port: body.Port,
|
||||||
User: body.User,
|
User: body.User,
|
||||||
KeyPath: body.KeyPath,
|
KeyPath: body.KeyPath,
|
||||||
|
Password: body.Password,
|
||||||
}
|
}
|
||||||
if s.config.Terminal.SSH == nil {
|
if s.config.Terminal.SSH == nil {
|
||||||
s.config.Terminal.SSH = []config.SSHConnection{}
|
s.config.Terminal.SSH = []config.SSHConnection{}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
Name = "muyue"
|
Name = "muyue"
|
||||||
Version = "0.4.0"
|
Version = "0.4.1"
|
||||||
Author = "La Légion de Muyue"
|
Author = "La Légion de Muyue"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ const api = {
|
|||||||
applyStarshipTheme: (theme) => request('/starship/apply-theme', { method: 'POST', body: JSON.stringify({ theme }) }),
|
applyStarshipTheme: (theme) => request('/starship/apply-theme', { method: 'POST', body: JSON.stringify({ theme }) }),
|
||||||
validateProvider: (provider) => request('/providers/validate', { method: 'POST', body: JSON.stringify(provider) }),
|
validateProvider: (provider) => request('/providers/validate', { method: 'POST', body: JSON.stringify(provider) }),
|
||||||
runUpdate: (tool) => request('/update/run', { method: 'POST', body: JSON.stringify({ tool: tool || '' }) }),
|
runUpdate: (tool) => request('/update/run', { method: 'POST', body: JSON.stringify({ tool: tool || '' }) }),
|
||||||
|
aiTask: (task, tool) => request('/ai/task', { method: 'POST', body: JSON.stringify({ task, tool: tool || '' }) }),
|
||||||
runCommand: (command, cwd) => request('/terminal', { method: 'POST', body: JSON.stringify({ command, cwd }) }),
|
runCommand: (command, cwd) => request('/terminal', { method: 'POST', body: JSON.stringify({ command, cwd }) }),
|
||||||
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) }),
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ export default function App() {
|
|||||||
<main className="content">
|
<main className="content">
|
||||||
<div className={activeTab === 'dash' ? '' : 'tab-hidden'}><Dashboard api={api} refreshRef={dashRefreshRef} /></div>
|
<div className={activeTab === 'dash' ? '' : 'tab-hidden'}><Dashboard api={api} refreshRef={dashRefreshRef} /></div>
|
||||||
<div className={activeTab === 'studio' ? '' : 'tab-hidden'}><Studio api={api} /></div>
|
<div className={activeTab === 'studio' ? '' : 'tab-hidden'}><Studio api={api} /></div>
|
||||||
<div className={activeTab === 'shell' ? '' : 'tab-hidden'}><Shell api={api} /></div>
|
<div className={activeTab === 'shell' ? '' : 'tab-hidden'}><Shell api={api} isSudo={isSudo} /></div>
|
||||||
<div className={activeTab === 'config' ? '' : 'tab-hidden'}><Config api={api} /></div>
|
<div className={activeTab === 'config' ? '' : 'tab-hidden'}><Config api={api} /></div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|||||||
@@ -49,27 +49,79 @@ export default function Config({ api }) {
|
|||||||
const handleCheckUpdates = async () => {
|
const handleCheckUpdates = async () => {
|
||||||
setChecking(true)
|
setChecking(true)
|
||||||
try {
|
try {
|
||||||
await api.runScan()
|
const d = await api.aiTask('check_tools')
|
||||||
const d = await api.getUpdates()
|
const result = d.result
|
||||||
setUpdates(d.updates || [])
|
if (result && result.tools) {
|
||||||
const td = await api.getTools()
|
const aiTools = result.tools
|
||||||
setTools(td.tools || [])
|
const newUpdates = aiTools.filter(t => t.installed).map(t => ({
|
||||||
showToast(t('config.upToDate'))
|
tool: t.name,
|
||||||
|
current: t.version || '',
|
||||||
|
latest: t.latest || '',
|
||||||
|
needsUpdate: t.needs_update || false,
|
||||||
|
error: t.error || '',
|
||||||
|
}))
|
||||||
|
const newTools = aiTools.map(t => ({
|
||||||
|
name: t.name,
|
||||||
|
installed: t.installed,
|
||||||
|
version: t.version || '',
|
||||||
|
category: t.category || '',
|
||||||
|
}))
|
||||||
|
setUpdates(newUpdates)
|
||||||
|
setTools(newTools)
|
||||||
|
showToast(t('config.upToDate'))
|
||||||
|
} else {
|
||||||
|
showToast(t('config.error'))
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast(`${t('config.error')}: ${err.message}`)
|
showToast(`${t('config.error')}: ${err.message}`)
|
||||||
}
|
}
|
||||||
setChecking(false)
|
setChecking(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleUpdateTool = (tool) => {
|
const handleUpdateTool = async (tool) => {
|
||||||
window.dispatchEvent(new CustomEvent('navigate-to-shell', {}))
|
setUpdating(tool)
|
||||||
window.dispatchEvent(new CustomEvent('ask-ai-terminal', { detail: { message: `Met à jour l'outil ${tool} sur mon système. Exécute les commandes nécessaires.` } }))
|
try {
|
||||||
|
const d = await api.aiTask('update_tool', tool)
|
||||||
|
if (d.result && d.result.updated) {
|
||||||
|
showToast(`${tool} ${t('config.updated') || 'mis à jour'}`)
|
||||||
|
} else {
|
||||||
|
showToast(d.result?.error || d.result?.message || t('config.error'))
|
||||||
|
}
|
||||||
|
handleCheckUpdates()
|
||||||
|
} catch (err) {
|
||||||
|
showToast(`${t('config.error')}: ${err.message}`)
|
||||||
|
}
|
||||||
|
setUpdating(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleUpdateAll = () => {
|
const handleInstallTool = async (tool) => {
|
||||||
const toUpdate = updates.filter(u => u.needsUpdate).map(u => u.tool)
|
setUpdating(`install-${tool}`)
|
||||||
window.dispatchEvent(new CustomEvent('navigate-to-shell', {}))
|
try {
|
||||||
window.dispatchEvent(new CustomEvent('ask-ai-terminal', { detail: { message: `Met à jour tous les outils suivants sur mon système : ${toUpdate.join(', ')}. Exécute les commandes nécessaires une par une.` } }))
|
const d = await api.aiTask('install_tool', tool)
|
||||||
|
if (d.result && d.result.installed) {
|
||||||
|
showToast(`${tool} ${t('config.installed') || 'installé'}`)
|
||||||
|
} else {
|
||||||
|
showToast(d.result?.error || d.result?.message || t('config.error'))
|
||||||
|
}
|
||||||
|
handleCheckUpdates()
|
||||||
|
} catch (err) {
|
||||||
|
showToast(`${t('config.error')}: ${err.message}`)
|
||||||
|
}
|
||||||
|
setUpdating(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpdateAll = async () => {
|
||||||
|
const toUpdate = updates.filter(u => u.needsUpdate)
|
||||||
|
setUpdating('__all__')
|
||||||
|
for (const u of toUpdate) {
|
||||||
|
try {
|
||||||
|
await api.aiTask('update_tool', u.tool)
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Failed to update ${u.tool}:`, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setUpdating(null)
|
||||||
|
handleCheckUpdates()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSaveProfile = async () => {
|
const handleSaveProfile = async () => {
|
||||||
@@ -160,6 +212,7 @@ export default function Config({ api }) {
|
|||||||
installedCount={installedCount} missingCount={missingCount}
|
installedCount={installedCount} missingCount={missingCount}
|
||||||
handleCheckUpdates={handleCheckUpdates}
|
handleCheckUpdates={handleCheckUpdates}
|
||||||
handleUpdateTool={handleUpdateTool}
|
handleUpdateTool={handleUpdateTool}
|
||||||
|
handleInstallTool={handleInstallTool}
|
||||||
handleUpdateAll={handleUpdateAll}
|
handleUpdateAll={handleUpdateAll}
|
||||||
t={t}
|
t={t}
|
||||||
/>
|
/>
|
||||||
@@ -406,11 +459,7 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function PanelUpdates({ updates, tools, checking, updating, needsUpdateCount, installedCount, missingCount, handleCheckUpdates, handleUpdateTool, handleUpdateAll, t }) {
|
function PanelUpdates({ updates, tools, checking, updating, needsUpdateCount, installedCount, missingCount, handleCheckUpdates, handleUpdateTool, handleInstallTool, handleUpdateAll, t }) {
|
||||||
const handleInstallTool = (tool) => {
|
|
||||||
window.dispatchEvent(new CustomEvent('navigate-to-shell', {}))
|
|
||||||
window.dispatchEvent(new CustomEvent('ask-ai-terminal', { detail: { message: `Installe l'outil ${tool} sur mon système. Vérifie d'abord s'il est déjà installé, puis installe-le si nécessaire avec les commandes appropriées.` } }))
|
|
||||||
}
|
|
||||||
|
|
||||||
const missingTools = tools.filter(tool => !tool.installed)
|
const missingTools = tools.filter(tool => !tool.installed)
|
||||||
|
|
||||||
|
|||||||
@@ -225,6 +225,7 @@ function createTerminal(container, settings = {}) {
|
|||||||
theme,
|
theme,
|
||||||
allowTransparency: false,
|
allowTransparency: false,
|
||||||
scrollback: 5000,
|
scrollback: 5000,
|
||||||
|
bracketedPaste: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
const fitAddon = new FitAddon()
|
const fitAddon = new FitAddon()
|
||||||
@@ -362,7 +363,7 @@ function connectWebSocket(term, fitAddon, initPayload, onStateChange, onFirstMes
|
|||||||
return ws
|
return ws
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Shell({ api }) {
|
export default function Shell({ api, isSudo }) {
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const tabsRef = useRef({})
|
const tabsRef = useRef({})
|
||||||
const nextIdRef = useRef(1)
|
const nextIdRef = useRef(1)
|
||||||
@@ -456,8 +457,9 @@ export default function Shell({ api }) {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const [sshForm, setSshForm] = useState({
|
const [sshForm, setSshForm] = useState({
|
||||||
name: '', host: '', port: 22, user: '', key_path: '',
|
name: '', host: '', port: 22, user: '', key_path: '', password: '',
|
||||||
})
|
})
|
||||||
|
const [sshEditing, setSshEditing] = useState(null)
|
||||||
|
|
||||||
const [aiMessages, setAiMessages] = useState([])
|
const [aiMessages, setAiMessages] = useState([])
|
||||||
const [aiInput, setAiInput] = useState('')
|
const [aiInput, setAiInput] = useState('')
|
||||||
@@ -552,6 +554,7 @@ export default function Shell({ api }) {
|
|||||||
port: tab.port || 22,
|
port: tab.port || 22,
|
||||||
user: tab.user || 'root',
|
user: tab.user || 'root',
|
||||||
key_path: tab.key_path || '',
|
key_path: tab.key_path || '',
|
||||||
|
password: tab.password || '',
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -893,6 +896,7 @@ export default function Shell({ api }) {
|
|||||||
port: conn.port || 22,
|
port: conn.port || 22,
|
||||||
user: conn.user || 'root',
|
user: conn.user || 'root',
|
||||||
key_path: conn.key_path || '',
|
key_path: conn.key_path || '',
|
||||||
|
password: conn.password || '',
|
||||||
connected: false,
|
connected: false,
|
||||||
}
|
}
|
||||||
setTabs(prev => { const next = [...prev, newTab]; localStorage.setItem(TABS_STORAGE_KEY, JSON.stringify(next.map(t => ({ id: t.id, name: t.name, type: t.type, shell: t.shell, host: t.host, port: t.port, user: t.user, key_path: t.key_path })))); return next })
|
setTabs(prev => { const next = [...prev, newTab]; localStorage.setItem(TABS_STORAGE_KEY, JSON.stringify(next.map(t => ({ id: t.id, name: t.name, type: t.type, shell: t.shell, host: t.host, port: t.port, user: t.user, key_path: t.key_path })))); return next })
|
||||||
@@ -963,14 +967,26 @@ export default function Shell({ api }) {
|
|||||||
if (!sshForm.name.trim() || !sshForm.host.trim()) return
|
if (!sshForm.name.trim() || !sshForm.host.trim()) return
|
||||||
try {
|
try {
|
||||||
await api.addSSHConnection(sshForm)
|
await api.addSSHConnection(sshForm)
|
||||||
setSshConnections(prev => [...prev, { ...sshForm }])
|
if (sshEditing) {
|
||||||
setSshForm({ name: '', host: '', port: 22, user: '', key_path: '' })
|
setSshConnections(prev => prev.map(c => c.name === sshEditing ? { ...sshForm } : c))
|
||||||
|
} else {
|
||||||
|
setSshConnections(prev => [...prev, { ...sshForm }])
|
||||||
|
}
|
||||||
|
setSshForm({ name: '', host: '', port: 22, user: '', key_path: '', password: '' })
|
||||||
|
setSshEditing(null)
|
||||||
setShowSshModal(false)
|
setShowSshModal(false)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const editSSHConnection = (conn) => {
|
||||||
|
setSshForm({ name: conn.name, host: conn.host, port: conn.port || 22, user: conn.user || '', key_path: conn.key_path || '', password: conn.password || '' })
|
||||||
|
setSshEditing(conn.name)
|
||||||
|
setShowSshModal(true)
|
||||||
|
setShowMenu(false)
|
||||||
|
}
|
||||||
|
|
||||||
const deleteSSHConnection = async (name) => {
|
const deleteSSHConnection = async (name) => {
|
||||||
try {
|
try {
|
||||||
await api.deleteSSHConnection(name)
|
await api.deleteSSHConnection(name)
|
||||||
@@ -1300,6 +1316,13 @@ Sois concret : cite les vraies versions, les vrais chemins, les vrais nombres. L
|
|||||||
<span>{conn.name}</span>
|
<span>{conn.name}</span>
|
||||||
<span className="shell-menu-item-sub">{conn.user}@{conn.host}:{conn.port}</span>
|
<span className="shell-menu-item-sub">{conn.user}@{conn.host}:{conn.port}</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className="shell-menu-item-icon"
|
||||||
|
onClick={(e) => { e.stopPropagation(); editSSHConnection(conn) }}
|
||||||
|
title={t('shell.editConnection')}
|
||||||
|
>
|
||||||
|
<Pencil size={11} />
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
className="shell-menu-item-icon"
|
className="shell-menu-item-icon"
|
||||||
onClick={(e) => { e.stopPropagation(); deleteSSHConnection(conn.name) }}
|
onClick={(e) => { e.stopPropagation(); deleteSSHConnection(conn.name) }}
|
||||||
@@ -1355,7 +1378,10 @@ Sois concret : cite les vraies versions, les vrais chemins, les vrais nombres. L
|
|||||||
|
|
||||||
<div className="shell-ai-col">
|
<div className="shell-ai-col">
|
||||||
<div className="ai-panel-header">
|
<div className="ai-panel-header">
|
||||||
<span>Analyste Système</span>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<span>Analyste Système</span>
|
||||||
|
<span className={`sudo-indicator ${isSudo ? 'sudo-ok' : 'sudo-blocked'}`} title={isSudo ? 'Sudo sans mot de passe disponible' : 'Sudo bloqué — mot de passe requis'} />
|
||||||
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 6 }}>
|
<div style={{ display: 'flex', gap: 6 }}>
|
||||||
<button
|
<button
|
||||||
className="shell-analyze-btn"
|
className="shell-analyze-btn"
|
||||||
@@ -1468,15 +1494,16 @@ Sois concret : cite les vraies versions, les vrais chemins, les vrais nombres. L
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{showSshModal && (
|
{showSshModal && (
|
||||||
<div className="shell-modal-overlay" onClick={() => setShowSshModal(false)}>
|
<div className="shell-modal-overlay" onClick={() => { setShowSshModal(false); setSshEditing(null) }}>
|
||||||
<div className="shell-modal" onClick={e => e.stopPropagation()}>
|
<div className="shell-modal" onClick={e => e.stopPropagation()}>
|
||||||
<div className="shell-modal-header">{t('shell.addConnection')}</div>
|
<div className="shell-modal-header">{sshEditing ? t('shell.editConnection') : t('shell.addConnection')}</div>
|
||||||
<div className="shell-modal-body">
|
<div className="shell-modal-body">
|
||||||
<label className="shell-modal-label">{t('shell.connectionName')}</label>
|
<label className="shell-modal-label">{t('shell.connectionName')}</label>
|
||||||
<input
|
<input
|
||||||
value={sshForm.name}
|
value={sshForm.name}
|
||||||
onChange={e => setSshForm(f => ({ ...f, name: e.target.value }))}
|
onChange={e => setSshForm(f => ({ ...f, name: e.target.value }))}
|
||||||
placeholder="prod-server"
|
placeholder="prod-server"
|
||||||
|
disabled={!!sshEditing}
|
||||||
/>
|
/>
|
||||||
<label className="shell-modal-label">{t('shell.host')}</label>
|
<label className="shell-modal-label">{t('shell.host')}</label>
|
||||||
<input
|
<input
|
||||||
@@ -1508,9 +1535,16 @@ Sois concret : cite les vraies versions, les vrais chemins, les vrais nombres. L
|
|||||||
onChange={e => setSshForm(f => ({ ...f, key_path: e.target.value }))}
|
onChange={e => setSshForm(f => ({ ...f, key_path: e.target.value }))}
|
||||||
placeholder="~/.ssh/id_rsa"
|
placeholder="~/.ssh/id_rsa"
|
||||||
/>
|
/>
|
||||||
|
<label className="shell-modal-label">{t('shell.password')} <span style={{ fontWeight: 400, fontSize: 10, color: 'var(--text-disabled)' }}>({t('shell.passwordHint')})</span></label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={sshForm.password}
|
||||||
|
onChange={e => setSshForm(f => ({ ...f, password: e.target.value }))}
|
||||||
|
placeholder="••••••"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="shell-modal-footer">
|
<div className="shell-modal-footer">
|
||||||
<button className="ghost" onClick={() => setShowSshModal(false)}>{t('shell.cancel')}</button>
|
<button className="ghost" onClick={() => { setShowSshModal(false); setSshEditing(null) }}>{t('shell.cancel')}</button>
|
||||||
<button className="primary" onClick={saveSSHConnection}>{t('shell.save')}</button>
|
<button className="primary" onClick={saveSSHConnection}>{t('shell.save')}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -120,6 +120,8 @@ const en = {
|
|||||||
port: 'Port',
|
port: 'Port',
|
||||||
user: 'User',
|
user: 'User',
|
||||||
keyPath: 'SSH key path',
|
keyPath: 'SSH key path',
|
||||||
|
password: 'Password',
|
||||||
|
passwordHint: 'requires sshpass installed',
|
||||||
connect: 'Connect',
|
connect: 'Connect',
|
||||||
save: 'Save',
|
save: 'Save',
|
||||||
cancel: 'Cancel',
|
cancel: 'Cancel',
|
||||||
|
|||||||
@@ -120,6 +120,8 @@ const fr = {
|
|||||||
port: 'Port',
|
port: 'Port',
|
||||||
user: 'Utilisateur',
|
user: 'Utilisateur',
|
||||||
keyPath: 'Chemin cl\u00e9 SSH',
|
keyPath: 'Chemin cl\u00e9 SSH',
|
||||||
|
password: 'Mot de passe',
|
||||||
|
passwordHint: 'n\u00e9cessite sshpass install\u00e9',
|
||||||
connect: 'Se connecter',
|
connect: 'Se connecter',
|
||||||
save: 'Enregistrer',
|
save: 'Enregistrer',
|
||||||
cancel: 'Annuler',
|
cancel: 'Annuler',
|
||||||
|
|||||||
@@ -442,6 +442,9 @@ input::placeholder { color: var(--text-disabled); }
|
|||||||
|
|
||||||
.shell-ai-col { width: 320px; max-width: 320px; border-left: 1px solid var(--border); background: var(--bg-surface); display: flex; flex-direction: column; flex-shrink: 0; overflow: hidden; }
|
.shell-ai-col { width: 320px; max-width: 320px; border-left: 1px solid var(--border); background: var(--bg-surface); display: flex; flex-direction: column; flex-shrink: 0; overflow: hidden; }
|
||||||
.ai-panel-header { padding: 12px 16px; border-bottom: 1px solid var(--border); font-weight: 700; font-size: 13px; color: var(--accent); display: flex; align-items: center; justify-content: space-between; }
|
.ai-panel-header { padding: 12px 16px; border-bottom: 1px solid var(--border); font-weight: 700; font-size: 13px; color: var(--accent); display: flex; align-items: center; justify-content: space-between; }
|
||||||
|
.sudo-indicator { width: 8px; height: 8px; border-radius: 50%; display: inline-block; flex-shrink: 0; }
|
||||||
|
.sudo-indicator.sudo-ok { background: #22c55e; box-shadow: 0 0 6px rgba(34, 197, 94, 0.5); }
|
||||||
|
.sudo-indicator.sudo-blocked { background: #ef4444; box-shadow: 0 0 6px rgba(239, 68, 68, 0.5); }
|
||||||
.shell-analyze-btn {
|
.shell-analyze-btn {
|
||||||
display: flex; align-items: center; gap: 4px;
|
display: flex; align-items: center; gap: 4px;
|
||||||
padding: 4px 10px; border-radius: var(--radius);
|
padding: 4px 10px; border-radius: var(--radius);
|
||||||
|
|||||||
Reference in New Issue
Block a user