feat: agent concurrency, conversation summaries, AI tools config, UI polish
Some checks failed
Beta Release / beta (push) Failing after 33s
Some checks failed
Beta Release / beta (push) Failing after 33s
- Agent slot limiter for concurrent tool execution - Conversation summarization with soft-delete (MarkSummarized) - ANSI stripping in terminal tool output - Configurable crush-run timeout (default 600s, max 900s) - Starship theme refactor, AI tools config grid, system update UI - Streaming segments refactor, summarized messages block in feed - CSS: headings, scrollbars, tool cards, summary block styles - i18n additions (en+fr) for tools, updates, config 💘 Generated with Crush Assisted-by: GLM-5.1 via Crush <crush@charm.land>
This commit is contained in:
@@ -6,11 +6,18 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var ansiRegex = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07|\x1b\][^\x1b]*\x1b\\|\x1b[()][AB012]|\[\]`)
|
||||||
|
|
||||||
|
func stripANSI(s string) string {
|
||||||
|
return ansiRegex.ReplaceAllString(s, "")
|
||||||
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
sudoCache bool
|
sudoCache bool
|
||||||
sudoCacheSet bool
|
sudoCacheSet bool
|
||||||
@@ -103,6 +110,7 @@ func NewTerminalTool() (*ToolDefinition, error) {
|
|||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
|
|
||||||
result := string(output)
|
result := string(output)
|
||||||
|
result = stripANSI(result)
|
||||||
if len(result) > 10000 {
|
if len(result) > 10000 {
|
||||||
result = result[:10000] + "\n... [truncated]"
|
result = result[:10000] + "\n... [truncated]"
|
||||||
}
|
}
|
||||||
@@ -116,7 +124,8 @@ func NewTerminalTool() (*ToolDefinition, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type CrushRunParams struct {
|
type CrushRunParams struct {
|
||||||
Task string `json:"task" description:"The task description for Crush to execute"`
|
Task string `json:"task" description:"The task description for Crush to execute"`
|
||||||
|
Timeout int `json:"timeout,omitempty" description:"Maximum execution time in seconds (default 600, max 900)"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewCrushRunTool() (*ToolDefinition, error) {
|
func NewCrushRunTool() (*ToolDefinition, error) {
|
||||||
@@ -127,7 +136,14 @@ func NewCrushRunTool() (*ToolDefinition, error) {
|
|||||||
return TextErrorResponse("task is required"), nil
|
return TextErrorResponse("task is required"), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(ctx, 300*time.Second)
|
timeout := time.Duration(p.Timeout) * time.Second
|
||||||
|
if timeout == 0 {
|
||||||
|
timeout = 600 * time.Second
|
||||||
|
}
|
||||||
|
if timeout > 900*time.Second {
|
||||||
|
timeout = 900 * time.Second
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, timeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
cmd := exec.CommandContext(ctx, "crush", "run", p.Task)
|
cmd := exec.CommandContext(ctx, "crush", "run", p.Task)
|
||||||
@@ -139,7 +155,14 @@ func NewCrushRunTool() (*ToolDefinition, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return TextErrorResponse(fmt.Sprintf("Crush error: %v\n\n%s", err, result)), nil
|
errMsg := fmt.Sprintf("Crush error: %v", err)
|
||||||
|
if ctx.Err() == context.DeadlineExceeded {
|
||||||
|
errMsg = fmt.Sprintf("Crush timed out after %d seconds. Try splitting the task into smaller parts.", int(timeout.Seconds()))
|
||||||
|
}
|
||||||
|
if result != "" {
|
||||||
|
errMsg += "\n\n" + result
|
||||||
|
}
|
||||||
|
return TextErrorResponse(errMsg), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return TextResponse(result), nil
|
return TextResponse(result), nil
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ const (
|
|||||||
MaxToolIterations = 15
|
MaxToolIterations = 15
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ToolLimiter checks if a tool call is allowed and returns a release function.
|
||||||
|
type ToolLimiter func(toolName string) (release func(), err error)
|
||||||
|
|
||||||
// ChatEngine handles chat interactions with tool execution.
|
// ChatEngine handles chat interactions with tool execution.
|
||||||
// This deduplicates chat logic previously repeated in handlers_chat.go and handlers_shell_chat.go.
|
// This deduplicates chat logic previously repeated in handlers_chat.go and handlers_shell_chat.go.
|
||||||
type ChatEngine struct {
|
type ChatEngine struct {
|
||||||
@@ -21,6 +24,7 @@ type ChatEngine struct {
|
|||||||
tools json.RawMessage
|
tools json.RawMessage
|
||||||
onChunk func(map[string]interface{})
|
onChunk func(map[string]interface{})
|
||||||
stream bool
|
stream bool
|
||||||
|
limiter ToolLimiter
|
||||||
TotalTokens int
|
TotalTokens int
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,6 +48,11 @@ func (ce *ChatEngine) OnChunk(fn func(map[string]interface{})) {
|
|||||||
ce.onChunk = fn
|
ce.onChunk = fn
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetLimiter sets the tool call limiter for agent concurrency control.
|
||||||
|
func (ce *ChatEngine) SetLimiter(l ToolLimiter) {
|
||||||
|
ce.limiter = l
|
||||||
|
}
|
||||||
|
|
||||||
// RunWithTools executes the chat loop with tool calls.
|
// RunWithTools executes the chat loop with tool calls.
|
||||||
// Returns final content, tool calls, tool results, and error.
|
// Returns final content, tool calls, tool results, and error.
|
||||||
func (ce *ChatEngine) RunWithTools(ctx context.Context, messages []orchestrator.Message) (string, []map[string]interface{}, []map[string]interface{}, error) {
|
func (ce *ChatEngine) RunWithTools(ctx context.Context, messages []orchestrator.Message) (string, []map[string]interface{}, []map[string]interface{}, error) {
|
||||||
@@ -77,7 +86,7 @@ func (ce *ChatEngine) RunWithTools(ctx context.Context, messages []orchestrator.
|
|||||||
}
|
}
|
||||||
|
|
||||||
choice := resp.Choices[0]
|
choice := resp.Choices[0]
|
||||||
content := cleanThinkingTags(choice.Message.Content)
|
content := orchestrator.CleanAIResponse(cleanThinkingTags(choice.Message.Content))
|
||||||
|
|
||||||
if content != "" {
|
if content != "" {
|
||||||
if ce.onChunk != nil {
|
if ce.onChunk != nil {
|
||||||
@@ -115,6 +124,35 @@ func (ce *ChatEngine) RunWithTools(ctx context.Context, messages []orchestrator.
|
|||||||
Arguments: json.RawMessage(tc.Function.Arguments),
|
Arguments: json.RawMessage(tc.Function.Arguments),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ce.limiter != nil {
|
||||||
|
release, limitErr := ce.limiter(tc.Function.Name)
|
||||||
|
if limitErr != nil {
|
||||||
|
limResultData := map[string]interface{}{
|
||||||
|
"tool_call_id": tc.ID,
|
||||||
|
"content": limitErr.Error(),
|
||||||
|
"is_error": true,
|
||||||
|
}
|
||||||
|
allToolResults = append(allToolResults, map[string]interface{}{
|
||||||
|
"tool_call_id": tc.ID,
|
||||||
|
"name": tc.Function.Name,
|
||||||
|
"args": tc.Function.Arguments,
|
||||||
|
"result": limitErr.Error(),
|
||||||
|
"is_error": true,
|
||||||
|
})
|
||||||
|
if ce.onChunk != nil {
|
||||||
|
ce.onChunk(map[string]interface{}{"tool_result": limResultData})
|
||||||
|
}
|
||||||
|
messages = append(messages, orchestrator.Message{
|
||||||
|
Role: "tool",
|
||||||
|
Content: orchestrator.TextContent(limitErr.Error()),
|
||||||
|
ToolCallID: tc.ID,
|
||||||
|
Name: tc.Function.Name,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
defer release()
|
||||||
|
}
|
||||||
|
|
||||||
result, execErr := ce.registry.Execute(ctx, call)
|
result, execErr := ce.registry.Execute(ctx, call)
|
||||||
if execErr != nil {
|
if execErr != nil {
|
||||||
result = agent.ToolResponse{
|
result = agent.ToolResponse{
|
||||||
@@ -179,7 +217,7 @@ func (ce *ChatEngine) RunNonStream(ctx context.Context, messages []orchestrator.
|
|||||||
}
|
}
|
||||||
|
|
||||||
choice := resp.Choices[0]
|
choice := resp.Choices[0]
|
||||||
content := cleanThinkingTags(choice.Message.Content)
|
content := orchestrator.CleanAIResponse(cleanThinkingTags(choice.Message.Content))
|
||||||
|
|
||||||
if content != "" {
|
if content != "" {
|
||||||
finalContent = content
|
finalContent = content
|
||||||
@@ -203,6 +241,20 @@ func (ce *ChatEngine) RunNonStream(ctx context.Context, messages []orchestrator.
|
|||||||
Arguments: json.RawMessage(tc.Function.Arguments),
|
Arguments: json.RawMessage(tc.Function.Arguments),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ce.limiter != nil {
|
||||||
|
release, limitErr := ce.limiter(tc.Function.Name)
|
||||||
|
if limitErr != nil {
|
||||||
|
messages = append(messages, orchestrator.Message{
|
||||||
|
Role: "tool",
|
||||||
|
Content: orchestrator.TextContent(limitErr.Error()),
|
||||||
|
ToolCallID: tc.ID,
|
||||||
|
Name: tc.Function.Name,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
defer release()
|
||||||
|
}
|
||||||
|
|
||||||
result, execErr := ce.registry.Execute(ctx, call)
|
result, execErr := ce.registry.Execute(ctx, call)
|
||||||
if execErr != nil {
|
if execErr != nil {
|
||||||
result = agent.ToolResponse{
|
result = agent.ToolResponse{
|
||||||
|
|||||||
@@ -17,12 +17,53 @@ const contextWindowTokens = 150000
|
|||||||
const summarizeRatio = 0.80
|
const summarizeRatio = 0.80
|
||||||
const charsPerToken = 4
|
const charsPerToken = 4
|
||||||
|
|
||||||
|
func extractDisplayContent(role, content string) string {
|
||||||
|
if role != "assistant" {
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
var parsed struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
ToolCalls []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Args string `json:"args"`
|
||||||
|
} `json:"tool_calls"`
|
||||||
|
ToolResults []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Result string `json:"result"`
|
||||||
|
} `json:"tool_results"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(content), &parsed); err != nil {
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
var sb strings.Builder
|
||||||
|
if parsed.Content != "" {
|
||||||
|
sb.WriteString(parsed.Content)
|
||||||
|
}
|
||||||
|
for _, tc := range parsed.ToolCalls {
|
||||||
|
sb.WriteString("\n[")
|
||||||
|
sb.WriteString(tc.Name)
|
||||||
|
sb.WriteString("] ")
|
||||||
|
sb.WriteString(tc.Args)
|
||||||
|
}
|
||||||
|
for _, tr := range parsed.ToolResults {
|
||||||
|
sb.WriteString("\n[result")
|
||||||
|
if tr.Name != "" {
|
||||||
|
sb.WriteString(":")
|
||||||
|
sb.WriteString(tr.Name)
|
||||||
|
}
|
||||||
|
sb.WriteString("] ")
|
||||||
|
sb.WriteString(tr.Result)
|
||||||
|
}
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
type FeedMessage struct {
|
type FeedMessage struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
Time string `json:"time"`
|
Time string `json:"time"`
|
||||||
Images []string `json:"images,omitempty"`
|
Images []string `json:"images,omitempty"`
|
||||||
|
Summarized bool `json:"summarized,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Conversation struct {
|
type Conversation struct {
|
||||||
@@ -168,13 +209,15 @@ func (cs *ConversationStore) SetSummary(summary string) {
|
|||||||
cs.save()
|
cs.save()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cs *ConversationStore) TrimOld(keepCount int) {
|
func (cs *ConversationStore) MarkSummarized(upToIndex int) {
|
||||||
cs.mu.Lock()
|
cs.mu.Lock()
|
||||||
defer cs.mu.Unlock()
|
defer cs.mu.Unlock()
|
||||||
if len(cs.conv.Messages) <= keepCount {
|
if upToIndex <= 0 || upToIndex >= len(cs.conv.Messages) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
cs.conv.Messages = cs.conv.Messages[len(cs.conv.Messages)-keepCount:]
|
for i := 0; i < upToIndex; i++ {
|
||||||
|
cs.conv.Messages[i].Summarized = true
|
||||||
|
}
|
||||||
cs.save()
|
cs.save()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,7 +234,10 @@ func (cs *ConversationStore) ApproxTokenCountDetailed() TokenCount {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, m := range cs.conv.Messages {
|
for _, m := range cs.conv.Messages {
|
||||||
count := utf8.RuneCountInString(m.Content) / charsPerToken
|
if m.Role == "system" || m.Summarized {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
count := utf8.RuneCountInString(extractDisplayContent(m.Role, m.Content)) / charsPerToken
|
||||||
result.byMessage += count
|
result.byMessage += count
|
||||||
result.byRole[m.Role] += count
|
result.byRole[m.Role] += count
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -64,15 +63,13 @@ func (s *Server) describeImages(images []ImageAttachment) []string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if apiKey == "" {
|
if apiKey == "" {
|
||||||
log.Printf("[vlm] no API key found for image description")
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
descriptions := make([]string, 0, len(images))
|
descriptions := make([]string, 0, len(images))
|
||||||
for i, img := range images {
|
for _, img := range images {
|
||||||
desc, err := s.callVLM(apiKey, img)
|
desc, err := s.callVLM(apiKey, img)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[vlm] image %d (%s) failed: %v", i+1, img.Filename, err)
|
|
||||||
descriptions = append(descriptions, fmt.Sprintf("(description unavailable: %v)", err))
|
descriptions = append(descriptions, fmt.Sprintf("(description unavailable: %v)", err))
|
||||||
} else {
|
} else {
|
||||||
descriptions = append(descriptions, desc)
|
descriptions = append(descriptions, desc)
|
||||||
@@ -163,7 +160,7 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
id, err := saveImage(body.Images[i].Data, body.Images[i].Filename, body.Images[i].MimeType)
|
id, err := saveImage(body.Images[i].Data, body.Images[i].Filename, body.Images[i].MimeType)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[images] failed to save %s: %v", body.Images[i].Filename, err)
|
_ = err
|
||||||
} else {
|
} else {
|
||||||
imageIDs = append(imageIDs, id)
|
imageIDs = append(imageIDs, id)
|
||||||
}
|
}
|
||||||
@@ -227,6 +224,7 @@ func (s *Server) handleStreamChat(w http.ResponseWriter, orb *orchestrator.Orche
|
|||||||
messages := s.buildContextMessages(userMessage)
|
messages := s.buildContextMessages(userMessage)
|
||||||
|
|
||||||
engine := NewChatEngine(orb, s.agentRegistry, s.agentToolsJSON)
|
engine := NewChatEngine(orb, s.agentRegistry, s.agentToolsJSON)
|
||||||
|
engine.SetLimiter(s.AcquireAgentSlot)
|
||||||
engine.OnChunk(func(data map[string]interface{}) {
|
engine.OnChunk(func(data map[string]interface{}) {
|
||||||
if data == nil {
|
if data == nil {
|
||||||
return
|
return
|
||||||
@@ -265,6 +263,7 @@ func (s *Server) handleNonStreamChat(w http.ResponseWriter, orb *orchestrator.Or
|
|||||||
messages := s.buildContextMessages(userMessage)
|
messages := s.buildContextMessages(userMessage)
|
||||||
|
|
||||||
engine := NewChatEngine(orb, s.agentRegistry, s.agentToolsJSON)
|
engine := NewChatEngine(orb, s.agentRegistry, s.agentToolsJSON)
|
||||||
|
engine.SetLimiter(s.AcquireAgentSlot)
|
||||||
finalContent, err := engine.RunNonStream(ctx, messages)
|
finalContent, err := engine.RunNonStream(ctx, messages)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
@@ -299,7 +298,11 @@ func (s *Server) buildContextMessages(userMessage string) []orchestrator.Message
|
|||||||
included := 0
|
included := 0
|
||||||
tokensUsed := 0
|
tokensUsed := 0
|
||||||
for i := len(history) - 1; i >= 0; i-- {
|
for i := len(history) - 1; i >= 0; i-- {
|
||||||
msgTokens := utf8.RuneCountInString(history[i].Content) / charsPerToken
|
if history[i].Summarized {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
displayContent := extractDisplayContent(history[i].Role, history[i].Content)
|
||||||
|
msgTokens := utf8.RuneCountInString(displayContent) / charsPerToken
|
||||||
if msgTokens == 0 {
|
if msgTokens == 0 {
|
||||||
msgTokens = 1
|
msgTokens = 1
|
||||||
}
|
}
|
||||||
@@ -315,14 +318,21 @@ func (s *Server) buildContextMessages(userMessage string) []orchestrator.Message
|
|||||||
start = 0
|
start = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hasSummarized := false
|
||||||
|
for i := 0; i < start; i++ {
|
||||||
|
if history[i].Summarized {
|
||||||
|
hasSummarized = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
if 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)
|
_ = start
|
||||||
}
|
}
|
||||||
|
|
||||||
messages := make([]orchestrator.Message, 0, included+2)
|
messages := make([]orchestrator.Message, 0, included+2)
|
||||||
|
|
||||||
summary := s.convStore.GetSummary()
|
summary := s.convStore.GetSummary()
|
||||||
if summary != "" && start > 0 {
|
if summary != "" && (start > 0 || hasSummarized) {
|
||||||
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),
|
||||||
@@ -330,27 +340,13 @@ func (s *Server) buildContextMessages(userMessage string) []orchestrator.Message
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, m := range history[start:] {
|
for _, m := range history[start:] {
|
||||||
content := m.Content
|
if m.Role == "system" {
|
||||||
if m.Role == "assistant" {
|
|
||||||
var parsed struct {
|
|
||||||
Content string `json:"content"`
|
|
||||||
ToolCalls []struct {
|
|
||||||
ToolCallID string `json:"tool_call_id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Args string `json:"args"`
|
|
||||||
} `json:"tool_calls"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal([]byte(content), &parsed); err == nil && parsed.Content != "" {
|
|
||||||
content = parsed.Content
|
|
||||||
}
|
|
||||||
}
|
|
||||||
role := m.Role
|
|
||||||
if role == "system" {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
displayContent := extractDisplayContent(m.Role, m.Content)
|
||||||
messages = append(messages, orchestrator.Message{
|
messages = append(messages, orchestrator.Message{
|
||||||
Role: role,
|
Role: m.Role,
|
||||||
Content: orchestrator.TextContent(content),
|
Content: orchestrator.TextContent(displayContent),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -391,8 +387,7 @@ func (s *Server) autoSummarize() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
s.convStore.SetSummary(result)
|
s.convStore.SetSummary(result)
|
||||||
s.convStore.TrimOld(len(messages) - half)
|
s.convStore.MarkSummarized(half)
|
||||||
s.convStore.Add("system", "[Conversation résumée automatiquement]")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleChatHistory(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleChatHistory(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -335,30 +335,25 @@ func (s *Server) handleApplyStarshipTheme(w http.ResponseWriter, r *http.Request
|
|||||||
body.Theme = s.config.Terminal.PromptTheme
|
body.Theme = s.config.Terminal.PromptTheme
|
||||||
}
|
}
|
||||||
|
|
||||||
cfgDir, err := config.ConfigDir()
|
themeFile := ApplyStarshipTheme(body.Theme)
|
||||||
if err != nil {
|
|
||||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
s.config.Terminal.PromptTheme = body.Theme
|
||||||
return
|
config.Save(s.config)
|
||||||
}
|
|
||||||
|
writeJSON(w, map[string]interface{}{"status": "ok", "config": themeFile})
|
||||||
|
}
|
||||||
|
|
||||||
|
func ApplyStarshipTheme(theme string) string {
|
||||||
|
cfgDir, _ := config.ConfigDir()
|
||||||
starshipDir := filepath.Join(cfgDir, "starship")
|
starshipDir := filepath.Join(cfgDir, "starship")
|
||||||
if err := os.MkdirAll(starshipDir, 0755); err != nil {
|
os.MkdirAll(starshipDir, 0755)
|
||||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
themeFile := filepath.Join(starshipDir, "starship.toml")
|
themeFile := filepath.Join(starshipDir, "starship.toml")
|
||||||
|
|
||||||
themeContent := getStarshipThemeConfig(body.Theme)
|
themeContent := getStarshipThemeConfig(theme)
|
||||||
if err := os.WriteFile(themeFile, []byte(themeContent), 0644); err != nil {
|
os.WriteFile(themeFile, []byte(themeContent), 0644)
|
||||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
home, _ := os.UserHomeDir()
|
home, _ := os.UserHomeDir()
|
||||||
shellRCs := []string{
|
for _, rc := range []string{filepath.Join(home, ".bashrc"), filepath.Join(home, ".zshrc")} {
|
||||||
filepath.Join(home, ".bashrc"),
|
|
||||||
filepath.Join(home, ".zshrc"),
|
|
||||||
}
|
|
||||||
for _, rc := range shellRCs {
|
|
||||||
if _, err := os.Stat(rc); err != nil {
|
if _, err := os.Stat(rc); err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -375,10 +370,7 @@ func (s *Server) handleApplyStarshipTheme(w http.ResponseWriter, r *http.Request
|
|||||||
f.Close()
|
f.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
s.config.Terminal.PromptTheme = body.Theme
|
return themeFile
|
||||||
config.Save(s.config)
|
|
||||||
|
|
||||||
writeJSON(w, map[string]interface{}{"status": "ok", "config": themeFile})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func getStarshipThemeConfig(theme string) string {
|
func getStarshipThemeConfig(theme string) string {
|
||||||
|
|||||||
@@ -91,6 +91,9 @@ func (s *Server) handleSkills(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
for i := range list {
|
||||||
|
list[i].Deployed = skills.IsDeployed(list[i].Name)
|
||||||
|
}
|
||||||
writeJSON(w, map[string]interface{}{
|
writeJSON(w, map[string]interface{}{
|
||||||
"skills": list,
|
"skills": list,
|
||||||
"count": len(list),
|
"count": len(list),
|
||||||
|
|||||||
@@ -226,6 +226,29 @@ func (s *Server) handleSkillsDeploy(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, map[string]string{"status": "all deployed"})
|
writeJSON(w, map[string]string{"status": "all deployed"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleSkillsUndeploy(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "POST" {
|
||||||
|
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.Name == "" {
|
||||||
|
writeError(w, "name is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := skills.Undeploy(body.Name); err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]string{"status": "undeployed", "skill": body.Name})
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) handleSSHConnections(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleSSHConnections(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != "GET" {
|
if r.Method != "GET" {
|
||||||
writeError(w, "GET only", http.StatusMethodNotAllowed)
|
writeError(w, "GET only", http.StatusMethodNotAllowed)
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
@@ -108,6 +107,7 @@ func (s *Server) handleShellChatStream(w http.ResponseWriter, orb *orchestrator.
|
|||||||
messages := s.buildShellContextMessages()
|
messages := s.buildShellContextMessages()
|
||||||
|
|
||||||
engine := NewChatEngine(orb, s.shellAgentRegistry, s.shellAgentToolsJSON)
|
engine := NewChatEngine(orb, s.shellAgentRegistry, s.shellAgentToolsJSON)
|
||||||
|
engine.SetLimiter(s.AcquireAgentSlot)
|
||||||
engine.OnChunk(func(data map[string]interface{}) {
|
engine.OnChunk(func(data map[string]interface{}) {
|
||||||
if data == nil {
|
if data == nil {
|
||||||
return
|
return
|
||||||
@@ -149,6 +149,7 @@ func (s *Server) handleShellChatNonStream(w http.ResponseWriter, orb *orchestrat
|
|||||||
messages := s.buildShellContextMessages()
|
messages := s.buildShellContextMessages()
|
||||||
|
|
||||||
engine := NewChatEngine(orb, s.shellAgentRegistry, s.shellAgentToolsJSON)
|
engine := NewChatEngine(orb, s.shellAgentRegistry, s.shellAgentToolsJSON)
|
||||||
|
engine.SetLimiter(s.AcquireAgentSlot)
|
||||||
finalContent, err := engine.RunNonStream(ctx, messages)
|
finalContent, err := engine.RunNonStream(ctx, messages)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
@@ -185,7 +186,8 @@ func (s *Server) buildShellContextMessages() []orchestrator.Message {
|
|||||||
included := 0
|
included := 0
|
||||||
tokensUsed := 0
|
tokensUsed := 0
|
||||||
for i := len(history) - 1; i >= 0; i-- {
|
for i := len(history) - 1; i >= 0; i-- {
|
||||||
msgTokens := utf8.RuneCountInString(history[i].Content) / charsPerToken
|
displayContent := extractDisplayContent(history[i].Role, history[i].Content)
|
||||||
|
msgTokens := utf8.RuneCountInString(displayContent) / charsPerToken
|
||||||
if msgTokens == 0 {
|
if msgTokens == 0 {
|
||||||
msgTokens = 1
|
msgTokens = 1
|
||||||
}
|
}
|
||||||
@@ -202,33 +204,19 @@ func (s *Server) buildShellContextMessages() []orchestrator.Message {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if 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)
|
_ = start
|
||||||
}
|
}
|
||||||
|
|
||||||
messages := make([]orchestrator.Message, 0, included)
|
messages := make([]orchestrator.Message, 0, included)
|
||||||
|
|
||||||
for _, m := range history[start:] {
|
for _, m := range history[start:] {
|
||||||
content := m.Content
|
if m.Role == "system" {
|
||||||
if m.Role == "assistant" {
|
|
||||||
var parsed struct {
|
|
||||||
Content string `json:"content"`
|
|
||||||
ToolCalls []struct {
|
|
||||||
ToolCallID string `json:"tool_call_id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Args string `json:"args"`
|
|
||||||
} `json:"tool_calls"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal([]byte(content), &parsed); err == nil && parsed.Content != "" {
|
|
||||||
content = parsed.Content
|
|
||||||
}
|
|
||||||
}
|
|
||||||
role := m.Role
|
|
||||||
if role == "system" {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
displayContent := extractDisplayContent(m.Role, m.Content)
|
||||||
messages = append(messages, orchestrator.Message{
|
messages = append(messages, orchestrator.Message{
|
||||||
Role: role,
|
Role: m.Role,
|
||||||
Content: orchestrator.TextContent(content),
|
Content: orchestrator.TextContent(displayContent),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package api
|
|||||||
import (
|
import (
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -64,7 +63,7 @@ func cleanupImages(ids []string) {
|
|||||||
for _, id := range ids {
|
for _, id := range ids {
|
||||||
p := imagePath(id)
|
p := imagePath(id)
|
||||||
if err := os.Remove(p); err != nil && !os.IsNotExist(err) {
|
if err := os.Remove(p); err != nil && !os.IsNotExist(err) {
|
||||||
log.Printf("[images] failed to delete %s: %v", id, err)
|
_ = err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,15 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"log"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
|
||||||
"github.com/muyue/muyue/internal/agent"
|
"github.com/muyue/muyue/internal/agent"
|
||||||
"github.com/muyue/muyue/internal/config"
|
"github.com/muyue/muyue/internal/config"
|
||||||
|
"github.com/muyue/muyue/internal/installer"
|
||||||
"github.com/muyue/muyue/internal/scanner"
|
"github.com/muyue/muyue/internal/scanner"
|
||||||
"github.com/muyue/muyue/internal/workflow"
|
"github.com/muyue/muyue/internal/workflow"
|
||||||
)
|
)
|
||||||
@@ -24,6 +27,8 @@ type Server struct {
|
|||||||
shellAgentRegistry *agent.Registry
|
shellAgentRegistry *agent.Registry
|
||||||
shellAgentToolsJSON json.RawMessage
|
shellAgentToolsJSON json.RawMessage
|
||||||
workflowEngine *workflow.Engine
|
workflowEngine *workflow.Engine
|
||||||
|
activeCrushAgents atomic.Int32
|
||||||
|
activeClaudeAgents atomic.Int32
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewServer(cfg *config.MuyueConfig) *Server {
|
func NewServer(cfg *config.MuyueConfig) *Server {
|
||||||
@@ -43,7 +48,7 @@ func NewServer(cfg *config.MuyueConfig) *Server {
|
|||||||
}
|
}
|
||||||
// Save initial config to establish the file for first-time usage
|
// Save initial config to establish the file for first-time usage
|
||||||
if err := config.Save(defaultCfg); err != nil {
|
if err := config.Save(defaultCfg); err != nil {
|
||||||
log.Printf("config: initial save failed: %v", err)
|
_ = err
|
||||||
}
|
}
|
||||||
cfg = defaultCfg
|
cfg = defaultCfg
|
||||||
}
|
}
|
||||||
@@ -65,6 +70,7 @@ func NewServer(cfg *config.MuyueConfig) *Server {
|
|||||||
s.shellAgentToolsJSON = json.RawMessage(shellToolsJSON)
|
s.shellAgentToolsJSON = json.RawMessage(shellToolsJSON)
|
||||||
|
|
||||||
s.workflowEngine, _ = workflow.NewEngine(s.agentRegistry)
|
s.workflowEngine, _ = workflow.NewEngine(s.agentRegistry)
|
||||||
|
s.initStarship()
|
||||||
s.routes()
|
s.routes()
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
@@ -120,6 +126,7 @@ func (s *Server) routes() {
|
|||||||
s.mux.HandleFunc("/api/conversations/", s.handleDeleteConversation)
|
s.mux.HandleFunc("/api/conversations/", s.handleDeleteConversation)
|
||||||
s.mux.HandleFunc("/api/lsp/install", s.handleLSPInstall)
|
s.mux.HandleFunc("/api/lsp/install", s.handleLSPInstall)
|
||||||
s.mux.HandleFunc("/api/skills/deploy", s.handleSkillsDeploy)
|
s.mux.HandleFunc("/api/skills/deploy", s.handleSkillsDeploy)
|
||||||
|
s.mux.HandleFunc("/api/skills/undeploy", s.handleSkillsUndeploy)
|
||||||
s.mux.HandleFunc("/api/ssh/connections", s.handleSSHConnections)
|
s.mux.HandleFunc("/api/ssh/connections", s.handleSSHConnections)
|
||||||
s.mux.HandleFunc("/api/ssh/test", s.handleSSHTest)
|
s.mux.HandleFunc("/api/ssh/test", s.handleSSHTest)
|
||||||
|
|
||||||
@@ -156,3 +163,37 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
s.mux.ServeHTTP(w, r)
|
s.mux.ServeHTTP(w, r)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const maxCrushAgents = 2
|
||||||
|
const maxClaudeAgents = 2
|
||||||
|
|
||||||
|
func (s *Server) AcquireAgentSlot(toolName string) (release func(), err error) {
|
||||||
|
var counter *atomic.Int32
|
||||||
|
var max int32
|
||||||
|
switch toolName {
|
||||||
|
case "crush_run":
|
||||||
|
counter = &s.activeCrushAgents
|
||||||
|
max = maxCrushAgents
|
||||||
|
case "claude_run":
|
||||||
|
counter = &s.activeClaudeAgents
|
||||||
|
max = maxClaudeAgents
|
||||||
|
default:
|
||||||
|
return func() {}, nil
|
||||||
|
}
|
||||||
|
current := counter.Add(1)
|
||||||
|
if current > max {
|
||||||
|
counter.Add(-1)
|
||||||
|
return nil, fmt.Errorf("Limite de %d agents %s atteinte", max, toolName)
|
||||||
|
}
|
||||||
|
return func() { counter.Add(-1) }, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) initStarship() {
|
||||||
|
if _, err := exec.LookPath("starship"); err != nil {
|
||||||
|
inst := installer.New(s.config)
|
||||||
|
if result := inst.InstallTool("starship"); !result.Success {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ApplyStarshipTheme(s.config.Terminal.PromptTheme)
|
||||||
|
}
|
||||||
|
|||||||
@@ -147,7 +147,10 @@ func (s *ShellConvStore) ApproxTokens() int {
|
|||||||
defer s.mu.RUnlock()
|
defer s.mu.RUnlock()
|
||||||
total := 0
|
total := 0
|
||||||
for _, m := range s.msgs {
|
for _, m := range s.msgs {
|
||||||
total += utf8.RuneCountInString(m.Content) / shellCharsPerToken
|
if m.Role == "system" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
total += utf8.RuneCountInString(extractDisplayContent(m.Role, m.Content)) / shellCharsPerToken
|
||||||
}
|
}
|
||||||
total += utf8.RuneCountInString(shellSystemPromptBase) / shellCharsPerToken
|
total += utf8.RuneCountInString(shellSystemPromptBase) / shellCharsPerToken
|
||||||
if analysis := LoadSystemAnalysis(); analysis != "" {
|
if analysis := LoadSystemAnalysis(); analysis != "" {
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package api
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
@@ -48,7 +47,6 @@ type wsMessage struct {
|
|||||||
func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
|
||||||
conn, err := upgrader.Upgrade(w, r, nil)
|
conn, err := upgrader.Upgrade(w, r, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("ws upgrade: %v", err)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer conn.Close()
|
defer conn.Close()
|
||||||
@@ -56,17 +54,13 @@ 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
|
||||||
|
|
||||||
@@ -111,24 +105,19 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
shell := strings.TrimSpace(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()
|
||||||
log.Printf("terminal: auto-detected shell=%q", shell)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if shell == "" {
|
if shell == "" {
|
||||||
log.Printf("terminal: no shell detected, falling back to /bin/sh")
|
|
||||||
shell = "/bin/sh"
|
shell = "/bin/sh"
|
||||||
}
|
}
|
||||||
|
|
||||||
if path, err := exec.LookPath(shell); err == nil {
|
if path, err := exec.LookPath(shell); err == nil {
|
||||||
shell = path
|
shell = path
|
||||||
log.Printf("terminal: resolved shell path=%q", shell)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := os.Stat(shell); err != nil {
|
if _, err := os.Stat(shell); err != nil {
|
||||||
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)})
|
conn.WriteJSON(wsMessage{Type: "error", Data: fmt.Sprintf("shell not found: %s (resolved from: %q)", shell, initMsg.Data)})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -148,14 +137,11 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
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("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")
|
|
||||||
|
|
||||||
var once sync.Once
|
var once sync.Once
|
||||||
cleanup := func() {
|
cleanup := func() {
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package config
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
@@ -162,7 +161,7 @@ func ConfigDir() (string, error) {
|
|||||||
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 {
|
||||||
if err := os.Rename(legacyDir, dir); err != nil {
|
if err := os.Rename(legacyDir, dir); err != nil {
|
||||||
log.Printf("config migration: rename %s to %s: %v", legacyDir, dir, err)
|
_ = err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -17,6 +16,14 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var thinkRegex = regexp.MustCompile(`(?s)<[Tt]hink[^>]*>.*?</[Tt]hink>`)
|
var thinkRegex = regexp.MustCompile(`(?s)<[Tt]hink[^>]*>.*?</[Tt]hink>`)
|
||||||
|
var providerToolBlockRegex = regexp.MustCompile(`(?s)<[a-zA-Z][a-zA-Z0-9]*:tool_call[^>]*>.*?</[a-zA-Z][a-zA-Z0-9]*:tool_call>`)
|
||||||
|
var providerTagRegex = regexp.MustCompile(`(?s)</?[a-zA-Z][a-zA-Z0-9]*:[a-zA-Z_]+[^>]*>`)
|
||||||
|
var xmlToolTagRegex = regexp.MustCompile(`(?s)</?(invoke|parameter|tool_call|tool_result)[^>]*>`)
|
||||||
|
var bracketToolCallRegex = regexp.MustCompile(`(?m)^\[(?:terminal|shell|bash|command|execute)\]\s*\{[^}]*\}\s*$`)
|
||||||
|
|
||||||
|
var streamBlockStartRegex = regexp.MustCompile(`<[a-zA-Z][a-zA-Z0-9]*:tool_call`)
|
||||||
|
var streamXmlStartRegex = regexp.MustCompile(`<(?:invoke|parameter|tool_call|tool_result)[\s>]`)
|
||||||
|
var streamBracketStartRegex = regexp.MustCompile(`\[(?:terminal|shell|bash|command|execute)\]\s*\{`)
|
||||||
|
|
||||||
const maxHistorySize = 100
|
const maxHistorySize = 100
|
||||||
|
|
||||||
@@ -197,7 +204,7 @@ func (o *Orchestrator) Send(userMessage string) (string, error) {
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
content := cleanAIResponse(chatResp.Choices[0].Message.Content)
|
content := CleanAIResponse(chatResp.Choices[0].Message.Content)
|
||||||
o.histMu.Lock()
|
o.histMu.Lock()
|
||||||
o.history = append(o.history, Message{
|
o.history = append(o.history, Message{
|
||||||
Role: "assistant",
|
Role: "assistant",
|
||||||
@@ -297,7 +304,7 @@ func (o *Orchestrator) SendStream(userMessage string, onChunk func(string)) (str
|
|||||||
return fullContent.String(), fmt.Errorf("read stream: %w", err)
|
return fullContent.String(), fmt.Errorf("read stream: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
content := cleanAIResponse(fullContent.String())
|
content := CleanAIResponse(fullContent.String())
|
||||||
o.histMu.Lock()
|
o.histMu.Lock()
|
||||||
o.history = append(o.history, Message{
|
o.history = append(o.history, Message{
|
||||||
Role: "assistant",
|
Role: "assistant",
|
||||||
@@ -388,6 +395,7 @@ func (o *Orchestrator) SendWithToolsStream(messages []Message, onChunk ChunkCall
|
|||||||
var fullContent strings.Builder
|
var fullContent strings.Builder
|
||||||
var accumulatedToolCalls []ToolCallMsg
|
var accumulatedToolCalls []ToolCallMsg
|
||||||
var totalTokens int
|
var totalTokens int
|
||||||
|
var insideToolBlock bool
|
||||||
|
|
||||||
scanner := bufio.NewScanner(resp.Body)
|
scanner := bufio.NewScanner(resp.Body)
|
||||||
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||||
@@ -411,7 +419,10 @@ func (o *Orchestrator) SendWithToolsStream(messages []Message, onChunk ChunkCall
|
|||||||
chunk := chatResp.Choices[0].Delta.Content
|
chunk := chatResp.Choices[0].Delta.Content
|
||||||
if chunk != "" {
|
if chunk != "" {
|
||||||
fullContent.WriteString(chunk)
|
fullContent.WriteString(chunk)
|
||||||
onChunk(chunk, nil)
|
cleanedChunk := CleanStreamChunk(chunk, &insideToolBlock)
|
||||||
|
if cleanedChunk != "" {
|
||||||
|
onChunk(cleanedChunk, nil)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle delta tool calls
|
// Handle delta tool calls
|
||||||
@@ -463,15 +474,19 @@ func (o *Orchestrator) SendWithToolsStream(messages []Message, onChunk ChunkCall
|
|||||||
}{},
|
}{},
|
||||||
}
|
}
|
||||||
|
|
||||||
finalContent := cleanAIResponse(fullContent.String())
|
finalContent := CleanAIResponse(fullContent.String())
|
||||||
finalResp.Choices[0].Message.Content = finalContent
|
finalResp.Choices[0].Message.Content = finalContent
|
||||||
finalResp.Choices[0].Message.ToolCalls = accumulatedToolCalls
|
finalResp.Choices[0].Message.ToolCalls = accumulatedToolCalls
|
||||||
|
|
||||||
return finalResp, nil
|
return finalResp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func cleanAIResponse(content string) string {
|
func CleanAIResponse(content string) string {
|
||||||
content = thinkRegex.ReplaceAllString(content, "")
|
content = thinkRegex.ReplaceAllString(content, "")
|
||||||
|
content = providerToolBlockRegex.ReplaceAllString(content, "")
|
||||||
|
content = providerTagRegex.ReplaceAllString(content, "")
|
||||||
|
content = xmlToolTagRegex.ReplaceAllString(content, "")
|
||||||
|
content = bracketToolCallRegex.ReplaceAllString(content, "")
|
||||||
lines := strings.Split(content, "\n")
|
lines := strings.Split(content, "\n")
|
||||||
var clean []string
|
var clean []string
|
||||||
inBlock := false
|
inBlock := false
|
||||||
@@ -494,6 +509,35 @@ func cleanAIResponse(content string) string {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CleanStreamChunk applies lightweight cleaning to individual streaming chunks.
|
||||||
|
// It tracks state via a bool pointer to suppress content inside tool-call blocks.
|
||||||
|
func CleanStreamChunk(chunk string, insideBlock *bool) string {
|
||||||
|
if *insideBlock {
|
||||||
|
// Check for closing tag
|
||||||
|
if strings.Contains(chunk, ":tool_call>") {
|
||||||
|
*insideBlock = false
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for opening tool_call block
|
||||||
|
if streamBlockStartRegex.MatchString(chunk) {
|
||||||
|
*insideBlock = true
|
||||||
|
// If closing tag also in same chunk, emit nothing
|
||||||
|
if strings.Contains(chunk, ":tool_call>") {
|
||||||
|
*insideBlock = false
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean individual tags and bracket calls
|
||||||
|
cleaned := providerTagRegex.ReplaceAllString(chunk, "")
|
||||||
|
cleaned = xmlToolTagRegex.ReplaceAllString(cleaned, "")
|
||||||
|
cleaned = bracketToolCallRegex.ReplaceAllString(cleaned, "")
|
||||||
|
|
||||||
|
return cleaned
|
||||||
|
}
|
||||||
|
|
||||||
func getProviderBaseURL(name string) string {
|
func getProviderBaseURL(name string) string {
|
||||||
switch name {
|
switch name {
|
||||||
case "minimax":
|
case "minimax":
|
||||||
@@ -616,6 +660,5 @@ func (o *Orchestrator) sendWithFallback(reqBody ChatRequest, baseURLOverride str
|
|||||||
return &chatResp, prov.Name, nil
|
return &chatResp, prov.Name, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("[orchestrator] fallback from %v to next provider", triedProviders)
|
|
||||||
return nil, "", lastErr
|
return nil, "", lastErr
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ type Skill struct {
|
|||||||
Dependencies []SkillDependency `yaml:"dependencies,omitempty" json:"dependencies,omitempty"`
|
Dependencies []SkillDependency `yaml:"dependencies,omitempty" json:"dependencies,omitempty"`
|
||||||
Languages []string `yaml:"languages,omitempty" json:"languages,omitempty"`
|
Languages []string `yaml:"languages,omitempty" json:"languages,omitempty"`
|
||||||
Category string `yaml:"category,omitempty" json:"category,omitempty"`
|
Category string `yaml:"category,omitempty" json:"category,omitempty"`
|
||||||
|
Deployed bool `yaml:"-" json:"deployed,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ValidationError struct {
|
type ValidationError struct {
|
||||||
@@ -155,6 +156,27 @@ func Delete(name string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func IsDeployed(name string) bool {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
crushPath := filepath.Join(home, ".config", "crush", "skills", name, "SKILL.md")
|
||||||
|
claudePath := filepath.Join(home, ".claude", "skills", name, "SKILL.md")
|
||||||
|
_, crushErr := os.Stat(crushPath)
|
||||||
|
_, claudeErr := os.Stat(claudePath)
|
||||||
|
return crushErr == nil || claudeErr == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func Undeploy(name string) error {
|
||||||
|
skill, err := Get(name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
undeployFromTargets(skill.Name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func Update(skill *Skill) error {
|
func Update(skill *Skill) error {
|
||||||
if errs := Validate(skill); len(errs) > 0 {
|
if errs := Validate(skill); len(errs) > 0 {
|
||||||
return fmt.Errorf("validation failed: %v", errs)
|
return fmt.Errorf("validation failed: %v", errs)
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ const api = {
|
|||||||
testSkill: (name, sampleTask) => request('/skills/test', { method: 'POST', body: JSON.stringify({ name, sample_task: sampleTask || '' }) }),
|
testSkill: (name, sampleTask) => request('/skills/test', { method: 'POST', body: JSON.stringify({ name, sample_task: sampleTask || '' }) }),
|
||||||
exportSkill: (name) => request('/skills/export', { method: 'POST', body: JSON.stringify({ name }) }),
|
exportSkill: (name) => request('/skills/export', { method: 'POST', body: JSON.stringify({ name }) }),
|
||||||
importSkill: (path) => request('/skills/import', { method: 'POST', body: JSON.stringify({ import_path: path }) }),
|
importSkill: (path) => request('/skills/import', { method: 'POST', body: JSON.stringify({ import_path: path }) }),
|
||||||
|
deploySkill: (name) => request('/skills/deploy', { method: 'POST', body: JSON.stringify({ name }) }),
|
||||||
|
undeploySkill: (name) => request('/skills/undeploy', { method: 'POST', body: JSON.stringify({ name }) }),
|
||||||
getDashboardStatus: () => request('/dashboard/status'),
|
getDashboardStatus: () => request('/dashboard/status'),
|
||||||
getProvidersQuota: () => request('/providers/quota'),
|
getProvidersQuota: () => request('/providers/quota'),
|
||||||
getProvidersConsumption: () => request('/providers/consumption'),
|
getProvidersConsumption: () => request('/providers/consumption'),
|
||||||
|
|||||||
@@ -16,8 +16,6 @@ export default function App() {
|
|||||||
const [isSudo, setIsSudo] = useState(false)
|
const [isSudo, setIsSudo] = useState(false)
|
||||||
const [dashRefreshKey, setDashRefreshKey] = useState(0)
|
const [dashRefreshKey, setDashRefreshKey] = useState(0)
|
||||||
const dashRefreshRef = useRef(null)
|
const dashRefreshRef = useRef(null)
|
||||||
const [updates, setUpdates] = useState([])
|
|
||||||
const [tools, setTools] = useState([])
|
|
||||||
const [config, setConfig] = useState(null)
|
const [config, setConfig] = useState(null)
|
||||||
const [showOnboarding, setShowOnboarding] = useState(false)
|
const [showOnboarding, setShowOnboarding] = useState(false)
|
||||||
const { t, layout } = useI18n()
|
const { t, layout } = useI18n()
|
||||||
@@ -31,8 +29,6 @@ export default function App() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.getInfo().then(d => { setInfo(d); setIsSudo(!!d.sudo) }).catch(() => {})
|
api.getInfo().then(d => { setInfo(d); setIsSudo(!!d.sudo) }).catch(() => {})
|
||||||
api.getTools().then(d => setTools(d.tools || [])).catch(() => {})
|
|
||||||
api.getUpdates().then(d => setUpdates(d.updates || [])).catch(() => {})
|
|
||||||
api.getConfig().then(d => {
|
api.getConfig().then(d => {
|
||||||
setConfig(d)
|
setConfig(d)
|
||||||
const theme = d.profile?.preferences?.theme || 'cyberpunk-red'
|
const theme = d.profile?.preferences?.theme || 'cyberpunk-red'
|
||||||
@@ -82,9 +78,6 @@ export default function App() {
|
|||||||
return () => window.removeEventListener('navigate-to-shell', handler)
|
return () => window.removeEventListener('navigate-to-shell', handler)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const hasUpdates = updates.some(u => u.needsUpdate)
|
|
||||||
const installed = tools.filter(tool => tool.installed).length
|
|
||||||
|
|
||||||
const WINDOW_SHORTCUTS = useMemo(() => ({
|
const WINDOW_SHORTCUTS = useMemo(() => ({
|
||||||
dash: [],
|
dash: [],
|
||||||
studio: [
|
studio: [
|
||||||
@@ -127,17 +120,6 @@ export default function App() {
|
|||||||
|
|
||||||
<div className="header-spacer" />
|
<div className="header-spacer" />
|
||||||
|
|
||||||
<div className="header-indicators">
|
|
||||||
<span
|
|
||||||
className={`indicator ${installed > 0 ? 'ok' : 'off'}`}
|
|
||||||
title={t('header.toolsInstalled', { count: installed })}
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
className={`indicator ${hasUpdates ? 'warn' : 'ok'}`}
|
|
||||||
title={hasUpdates ? t('header.updatesAvailable') : t('header.upToDate')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<span className="header-clock">
|
<span className="header-clock">
|
||||||
{clock.toLocaleTimeString(layout.locale, { hour: '2-digit', minute: '2-digit' })}
|
{clock.toLocaleTimeString(layout.locale, { hour: '2-digit', minute: '2-digit' })}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { User, Brain, RefreshCw, Wrench, Monitor, AlertTriangle } from 'lucide-react'
|
import { User, Brain, Wrench, Monitor, AlertTriangle, Bot, Sparkles, Zap, GitBranch, Container, Circle, Hexagon, Code, Rocket, Download } from 'lucide-react'
|
||||||
import { useI18n } from '../i18n'
|
import { useI18n } from '../i18n'
|
||||||
|
|
||||||
const PANELS = [
|
const PANELS = [
|
||||||
{ id: 'profile', icon: User },
|
{ id: 'profile', icon: User },
|
||||||
{ id: 'providers', icon: Brain },
|
{ id: 'providers', icon: Brain },
|
||||||
{ id: 'updates', icon: RefreshCw },
|
|
||||||
{ id: 'skills', icon: Wrench },
|
{ id: 'skills', icon: Wrench },
|
||||||
{ id: 'system', icon: Monitor },
|
{ id: 'system', icon: Monitor },
|
||||||
]
|
]
|
||||||
@@ -16,10 +15,7 @@ export default function Config({ api }) {
|
|||||||
const [config, setConfig] = useState(null)
|
const [config, setConfig] = useState(null)
|
||||||
const [providers, setProviders] = useState([])
|
const [providers, setProviders] = useState([])
|
||||||
const [skillList, setSkillList] = useState([])
|
const [skillList, setSkillList] = useState([])
|
||||||
const [updates, setUpdates] = useState([])
|
|
||||||
const [tools, setTools] = useState([])
|
|
||||||
const [checking, setChecking] = useState(false)
|
|
||||||
const [updating, setUpdating] = useState(null)
|
|
||||||
const [editProfile, setEditProfile] = useState(false)
|
const [editProfile, setEditProfile] = useState(false)
|
||||||
const [editProvider, setEditProvider] = useState(null)
|
const [editProvider, setEditProvider] = useState(null)
|
||||||
const [profileForm, setProfileForm] = useState({})
|
const [profileForm, setProfileForm] = useState({})
|
||||||
@@ -34,8 +30,6 @@ export default function Config({ api }) {
|
|||||||
}).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.getTools().then(d => setTools(d.tools || [])).catch(() => {})
|
|
||||||
|
|
||||||
}, [api])
|
}, [api])
|
||||||
|
|
||||||
@@ -46,83 +40,6 @@ export default function Config({ api }) {
|
|||||||
setTimeout(() => setToast(null), 2500)
|
setTimeout(() => setToast(null), 2500)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCheckUpdates = async () => {
|
|
||||||
setChecking(true)
|
|
||||||
try {
|
|
||||||
const d = await api.aiTask('check_tools')
|
|
||||||
const result = d.result
|
|
||||||
if (result && result.tools) {
|
|
||||||
const aiTools = result.tools
|
|
||||||
const newUpdates = aiTools.filter(t => t.installed).map(t => ({
|
|
||||||
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) {
|
|
||||||
showToast(`${t('config.error')}: ${err.message}`)
|
|
||||||
}
|
|
||||||
setChecking(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleUpdateTool = async (tool) => {
|
|
||||||
setUpdating(tool)
|
|
||||||
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 handleInstallTool = async (tool) => {
|
|
||||||
setUpdating(`install-${tool}`)
|
|
||||||
try {
|
|
||||||
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 () => {
|
||||||
try {
|
try {
|
||||||
@@ -161,9 +78,7 @@ export default function Config({ api }) {
|
|||||||
setEditProvider(p.name)
|
setEditProvider(p.name)
|
||||||
}
|
}
|
||||||
|
|
||||||
const needsUpdateCount = updates.filter(u => u.needsUpdate).length
|
|
||||||
const installedCount = tools.filter(tool => tool.installed).length
|
|
||||||
const missingCount = tools.filter(tool => !tool.installed).length
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="config-window">
|
<div className="config-window">
|
||||||
@@ -204,21 +119,8 @@ export default function Config({ api }) {
|
|||||||
t={t}
|
t={t}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{activePanel === 'updates' && (
|
|
||||||
<PanelUpdates
|
|
||||||
updates={updates} tools={tools}
|
|
||||||
checking={checking} updating={updating}
|
|
||||||
needsUpdateCount={needsUpdateCount}
|
|
||||||
installedCount={installedCount} missingCount={missingCount}
|
|
||||||
handleCheckUpdates={handleCheckUpdates}
|
|
||||||
handleUpdateTool={handleUpdateTool}
|
|
||||||
handleInstallTool={handleInstallTool}
|
|
||||||
handleUpdateAll={handleUpdateAll}
|
|
||||||
t={t}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{activePanel === 'skills' && (
|
{activePanel === 'skills' && (
|
||||||
<PanelSkills skillList={skillList} t={t} />
|
<PanelSkills skillList={skillList} api={api} loadData={loadData} t={t} />
|
||||||
)}
|
)}
|
||||||
{activePanel === 'system' && (
|
{activePanel === 'system' && (
|
||||||
<PanelSystem api={api} t={t} />
|
<PanelSystem api={api} t={t} />
|
||||||
@@ -459,176 +361,80 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function PanelUpdates({ updates, tools, checking, updating, needsUpdateCount, installedCount, missingCount, handleCheckUpdates, handleUpdateTool, handleInstallTool, handleUpdateAll, t }) {
|
function PanelSkills({ skillList, api, loadData, t }) {
|
||||||
|
const [deploying, setDeploying] = useState(null)
|
||||||
|
|
||||||
const missingTools = tools.filter(tool => !tool.installed)
|
const handleDeploy = async (name) => {
|
||||||
|
setDeploying(name + '-deploy')
|
||||||
|
try {
|
||||||
|
await api.deploySkill(name)
|
||||||
|
loadData()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('deploy skill:', err)
|
||||||
|
}
|
||||||
|
setDeploying(null)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
const handleUndeploy = async (name) => {
|
||||||
<>
|
setDeploying(name + '-undeploy')
|
||||||
<div className="config-card">
|
try {
|
||||||
<div className="config-update-controls">
|
await api.undeploySkill(name)
|
||||||
<div className="config-update-stats">
|
loadData()
|
||||||
<span className="badge ok">{installedCount} {t('config.installed')}</span>
|
} catch (err) {
|
||||||
{missingCount > 0 && <span className="badge error">{missingCount} {t('config.missing')}</span>}
|
console.error('undeploy skill:', err)
|
||||||
{needsUpdateCount > 0 && <span className="badge warn">{needsUpdateCount} {t('config.needsUpdate')}</span>}
|
}
|
||||||
</div>
|
setDeploying(null)
|
||||||
<div className="config-update-buttons">
|
}
|
||||||
<button className="sm" onClick={handleCheckUpdates} disabled={checking}>
|
|
||||||
{checking ? <><RefreshCw size={12} className="spin-icon" /> {t('config.checking')}</> : t('config.checkUpdates')}
|
|
||||||
</button>
|
|
||||||
{needsUpdateCount > 0 && (
|
|
||||||
<button className="sm primary" onClick={handleUpdateAll} disabled={updating === '__all__'}>
|
|
||||||
{updating === '__all__' ? t('config.updating') : `${t('config.updateAll')} (${needsUpdateCount})`}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{missingTools.length > 0 && (
|
|
||||||
<>
|
|
||||||
<div className="section-title" style={{ marginTop: 12, marginBottom: 4 }}>{t('config.missing') || 'Modules manquants'}</div>
|
|
||||||
<div className="config-update-list">
|
|
||||||
{missingTools.map((tool, i) => (
|
|
||||||
<div key={`miss-${i}`} className="config-update-row">
|
|
||||||
<div className="config-update-info">
|
|
||||||
<span className="config-update-name">{tool.name}</span>
|
|
||||||
<span className="config-update-versions">
|
|
||||||
<span style={{ color: 'var(--danger)' }}>{t('config.notInstalled') || 'Non installé'}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
className="sm primary"
|
|
||||||
onClick={() => handleInstallTool(tool.name)}
|
|
||||||
>
|
|
||||||
{t('config.install') || 'Installer'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{updates.length === 0 ? (
|
|
||||||
<div className="config-card">
|
|
||||||
<div className="empty-state">{t('config.noUpdates')}</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="config-update-list">
|
|
||||||
{updates.map((u, i) => (
|
|
||||||
<div key={i} className="config-update-row">
|
|
||||||
<div className="config-update-info">
|
|
||||||
<span className="config-update-name">{u.tool}</span>
|
|
||||||
<span className="config-update-versions">
|
|
||||||
{u.needsUpdate ? (
|
|
||||||
<>{u.current} → <span style={{ color: 'var(--warning)' }}>{u.latest}</span></>
|
|
||||||
) : (
|
|
||||||
<span style={{ color: 'var(--success)' }}>{u.current}</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{u.needsUpdate && (
|
|
||||||
<button
|
|
||||||
className="sm"
|
|
||||||
onClick={() => handleUpdateTool(u.tool)}
|
|
||||||
disabled={updating === u.tool}
|
|
||||||
>
|
|
||||||
{updating === u.tool ? t('config.updating') : t('config.updateTool')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
function PanelSkills({ skillList, t }) {
|
|
||||||
const [selected, setSelected] = useState(null)
|
|
||||||
|
|
||||||
if (skillList.length === 0) {
|
if (skillList.length === 0) {
|
||||||
return <div className="empty-state" style={{ color: 'var(--text-disabled)', padding: 40 }}>{t('config.noSkills')}</div>
|
return <div className="empty-state" style={{ color: 'var(--text-disabled)', padding: 40 }}>{t('config.noSkills')}</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="skills-list">
|
||||||
<div className="skill-tiles">
|
{skillList.map((s, i) => (
|
||||||
{skillList.map((s, i) => (
|
<div key={i} className="config-update-row" style={{ alignItems: 'center' }}>
|
||||||
<div key={i} className="skill-tile" onClick={() => setSelected(s)}>
|
<div className="skill-list-info">
|
||||||
<div className="skill-tile-name">{s.name}</div>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
<div className="skill-tile-desc">{s.description}</div>
|
<span className="config-update-name">{s.name}</span>
|
||||||
<div className="skill-tile-tags">
|
{s.deployed ? (
|
||||||
{s.target && <span className="badge neutral">{s.target}</span>}
|
<span className="badge ok">{t('config.installed')}</span>
|
||||||
{s.version && <span className="badge">{s.version}</span>}
|
) : (
|
||||||
{s.category && <span className="badge" style={{ opacity: 0.7 }}>{s.category}</span>}
|
<span className="badge neutral">{t('config.notInstalled')}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--text-tertiary)', marginTop: 2 }}>{s.description}</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
<div style={{ display: 'flex', gap: 6, flexShrink: 0 }}>
|
||||||
</div>
|
<button
|
||||||
{selected && (
|
className="sm primary"
|
||||||
<div className="skill-detail-overlay" onClick={() => setSelected(null)}>
|
disabled={s.deployed || deploying === s.name + '-deploy'}
|
||||||
<div className="skill-detail-panel" onClick={e => e.stopPropagation()}>
|
onClick={() => handleDeploy(s.name)}
|
||||||
<div className="skill-detail-header">
|
>
|
||||||
<span className="skill-detail-name">{selected.name}</span>
|
{deploying === s.name + '-deploy' ? '...' : t('config.apply')}
|
||||||
<button className="ghost sm" onClick={() => setSelected(null)}>✕</button>
|
</button>
|
||||||
</div>
|
<button
|
||||||
<div className="skill-detail-body">
|
className="sm ghost"
|
||||||
<div className="skill-detail-section">
|
disabled={!s.deployed || deploying === s.name + '-undeploy'}
|
||||||
<div className="skill-detail-label">Description</div>
|
onClick={() => handleUndeploy(s.name)}
|
||||||
<div style={{ fontSize: 13, color: 'var(--text-secondary)' }}>{selected.description}</div>
|
>
|
||||||
</div>
|
{deploying === s.name + '-undeploy' ? '...' : t('config.remove')}
|
||||||
<div className="skill-detail-section">
|
</button>
|
||||||
<div className="skill-detail-label">Métadonnées</div>
|
|
||||||
<div className="skill-detail-meta">
|
|
||||||
{selected.target && <span className="badge neutral">{selected.target}</span>}
|
|
||||||
{selected.version && <span className="badge">{selected.version}</span>}
|
|
||||||
{selected.category && <span className="badge">{selected.category}</span>}
|
|
||||||
{selected.author && <span className="badge ghost">{selected.author}</span>}
|
|
||||||
{selected.languages && selected.languages.map(l => <span key={l} className="badge ghost">{l}</span>)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{selected.tags && selected.tags.length > 0 && (
|
|
||||||
<div className="skill-detail-section">
|
|
||||||
<div className="skill-detail-label">Tags</div>
|
|
||||||
<div className="chip-row">
|
|
||||||
{selected.tags.map(tag => <span key={tag} className="badge">{tag}</span>)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{selected.content && (
|
|
||||||
<div className="skill-detail-section">
|
|
||||||
<div className="skill-detail-label">Contenu</div>
|
|
||||||
<div className="skill-detail-content">{selected.content}</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{selected.dependencies && selected.dependencies.length > 0 && (
|
|
||||||
<div className="skill-detail-section">
|
|
||||||
<div className="skill-detail-label">Dépendances</div>
|
|
||||||
<div className="skill-detail-deps">
|
|
||||||
{selected.dependencies.map((d, i) => (
|
|
||||||
<div key={i} className="skill-detail-dep">
|
|
||||||
<span className="badge">{d.type}</span>
|
|
||||||
<span>{d.name}</span>
|
|
||||||
{d.required === false && <span style={{ fontSize: 11, color: 'var(--text-disabled)' }}>optionnel</span>}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
))}
|
||||||
</>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function PanelSystem({ api, t }) {
|
function PanelSystem({ api, t }) {
|
||||||
const [showResetModal, setShowResetModal] = useState(false)
|
const [showResetModal, setShowResetModal] = useState(false)
|
||||||
const [toast, setToast] = useState(null)
|
const [toast, setToast] = useState(null)
|
||||||
|
const [isSudo, setIsSudo] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.getInfo().then(d => setIsSudo(!!d.sudo)).catch(() => {})
|
||||||
|
}, [api])
|
||||||
|
|
||||||
const showToast = (msg) => {
|
const showToast = (msg) => {
|
||||||
setToast(msg)
|
setToast(msg)
|
||||||
@@ -646,26 +452,123 @@ function PanelSystem({ api, t }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleApplyStarship = () => {
|
const handleSystemUpdate = () => {
|
||||||
window.dispatchEvent(new CustomEvent('navigate-to-shell', {}))
|
window.dispatchEvent(new CustomEvent('navigate-to-shell'))
|
||||||
window.dispatchEvent(new CustomEvent('ask-ai-terminal', { detail: { message: `Vérifie si starship est installé sur le système. S'il ne l'est pas, installe-le (avec curl ou le gestionnaire de paquets). Ensuite, applique la configuration du thème "charm" pour starship. Assure-toi que starship est bien initialisé dans le shell de l'utilisateur.` } }))
|
if (isSudo) {
|
||||||
|
window.dispatchEvent(new CustomEvent('ask-ai-terminal', { detail: { message: `Mets à jour le système et tous les outils utilisés par l'application Muyue. Exécute les commandes suivantes dans l'ordre :\n1. Met à jour les paquets système : sudo apt update && sudo apt upgrade -y\n2. Installe les dépendances utiles si manquantes : sudo apt install -y sshpass git curl wget\n3. Mets à jour les outils installés : crush, claude, gh, go, node/npm, python3/pip3/uv, docker, starship\n4. Pour chaque outil, vérifie la version actuelle, mets à jour si possible, puis vérifie la nouvelle version\n5. Donne un récapitulatif final de tout ce qui a été mis à jour ou installé` } }))
|
||||||
|
} else {
|
||||||
|
window.dispatchEvent(new CustomEvent('ask-ai-terminal', { detail: { message: `Je n'ai pas les droits sudo sur ce système. Donne-moi les commandes nécessaires pour mettre à jour le système et les outils suivants. Pour chaque outil, indique la commande exacte à exécuter :\n1. Paquets système (apt update && apt upgrade)\n2. Outils à mettre à jour : crush, claude, gh, go, node/npm, python3/pip3/uv, docker, starship\n3. Dépendances utiles à installer : sshpass, git, curl, wget\n4. Présente les commandes dans un tableau markdown avec le nom de l'outil, la commande, et si sudo est requis` } }))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const configureTool = (tool) => {
|
||||||
|
window.dispatchEvent(new CustomEvent('navigate-to-shell'))
|
||||||
|
window.dispatchEvent(new CustomEvent('ask-ai-terminal', { detail: { message: tool.prompt } }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const AI_TOOLS = [
|
||||||
|
{
|
||||||
|
id: 'crush',
|
||||||
|
name: 'Crush',
|
||||||
|
icon: 'Zap',
|
||||||
|
description: t('config.toolCrushDesc'),
|
||||||
|
prompt: `Configure l'outil Crush sur ce système. Vérifie d'abord s'il est installé avec "crush --version". S'il n'est pas installé, installe-le avec la méthode appropriée (npm install -g @anthropic/crush ou via le script officiel). S'il est déjà installé, vérifie sa configuration dans ~/.config/crush/ et affiche son état. Demande-moi les informations nécessaires si besoin (clés API, préférences, etc.).`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'claude',
|
||||||
|
name: 'Claude Code',
|
||||||
|
icon: 'Bot',
|
||||||
|
description: t('config.toolClaudeDesc'),
|
||||||
|
prompt: `Configure l'outil Claude Code (claude) sur ce système. Vérifie d'abord s'il est installé avec "claude --version". S'il n'est pas installé, installe-le avec npm install -g @anthropic-ai/claude-code. S'il est installé, vérifie sa configuration et son authentification. Demande-moi les informations nécessaires si besoin (clé API Anthropic, etc.).`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'gh',
|
||||||
|
name: 'GitHub CLI',
|
||||||
|
icon: 'GitBranch',
|
||||||
|
description: t('config.toolGhDesc'),
|
||||||
|
prompt: `Configure l'outil GitHub CLI (gh) sur ce système. Vérifie d'abord s'il est installé avec "gh --version". S'il n'est pas installé, installe-le avec la méthode appropriée pour ce système. S'il est installé, vérifie son authentification avec "gh auth status". Si non authentifié, guide-moi pour le configurer avec "gh auth login". Demande-moi le token si nécessaire.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'docker',
|
||||||
|
name: 'Docker',
|
||||||
|
icon: 'Container',
|
||||||
|
description: t('config.toolDockerDesc'),
|
||||||
|
prompt: `Configure Docker sur ce système. Vérifie d'abord s'il est installé avec "docker --version". Vérifie aussi si le daemon tourne avec "docker info". S'il n'est pas installé, installe-le avec la méthode appropriée. Vérifie que l'utilisateur est dans le groupe docker. Si des problèmes de permissions existent, explique comment les résoudre.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'go',
|
||||||
|
name: 'Go',
|
||||||
|
icon: 'Circle',
|
||||||
|
description: t('config.toolGoDesc'),
|
||||||
|
prompt: `Configure l'environnement Go sur ce système. Vérifie s'il est installé avec "go version". Vérifie le GOPATH, GOROOT et les variables d'environnement. S'il n'est pas installé, installe-le avec la méthode appropriée. Vérifie que les binaires Go sont dans le PATH.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'node',
|
||||||
|
name: 'Node.js',
|
||||||
|
icon: 'Hexagon',
|
||||||
|
description: t('config.toolNodeDesc'),
|
||||||
|
prompt: `Configure l'environnement Node.js sur ce système. Vérifie s'il est installé avec "node --version" et "npm --version". Vérifie aussi pnpm et npx. S'il n'est pas installé, installe-le avec la méthode recommandée (nvm, fnm ou le gestionnaire de paquets). Vérifie la version LTS vs Current.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'python',
|
||||||
|
name: 'Python',
|
||||||
|
icon: 'Code',
|
||||||
|
description: t('config.toolPythonDesc'),
|
||||||
|
prompt: `Configure l'environnement Python sur ce système. Vérifie python3 --version, pip3 --version, et uv --version. S'ils ne sont pas installés, installe-les avec la méthode appropriée. Vérifie les paquets essentiels (venv, pip). Configure uv si nécessaire.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'starship',
|
||||||
|
name: 'Starship',
|
||||||
|
icon: 'Rocket',
|
||||||
|
description: t('config.toolStarshipDesc'),
|
||||||
|
prompt: `Configure Starship (prompt shell) sur ce système. Vérifie s'il est installé avec "starship --version". S'il n'est pas installé, installe-le. Ensuite, configure le thème "charm" dans ~/.config/starship.toml. Assure-toi que starship est initialisé dans le shell de l'utilisateur (.bashrc, .zshrc ou config fish).`,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const ICON_MAP = { Zap, Bot, GitBranch, Container, Circle, Hexagon, Code, Rocket }
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{toast && <div className="config-toast">{toast}</div>}
|
{toast && <div className="config-toast">{toast}</div>}
|
||||||
|
|
||||||
<div className="section-title" style={{ marginBottom: 8 }}>Configuration Système</div>
|
<div className="section-title" style={{ marginBottom: 8 }}>{t('config.systemConfig')}</div>
|
||||||
<div className="config-card">
|
|
||||||
<div className="config-card-row" style={{ marginBottom: 16 }}>
|
<div className="section-title" style={{ marginTop: 4, marginBottom: 8, fontSize: 12, color: 'var(--text-tertiary)', textTransform: 'none', letterSpacing: 0 }}>
|
||||||
<span className="config-card-label" style={{ fontWeight: 600 }}>{t('config.applyStarship')}</span>
|
<Bot size={13} style={{ verticalAlign: 'middle', marginRight: 6 }} />
|
||||||
|
{t('config.aiToolsConfig')}
|
||||||
|
</div>
|
||||||
|
<div className="config-ai-tools-grid">
|
||||||
|
{AI_TOOLS.map(tool => {
|
||||||
|
const Icon = ICON_MAP[tool.icon] || Bot
|
||||||
|
return (
|
||||||
|
<div key={tool.id} className="config-ai-tool-card">
|
||||||
|
<div className="config-ai-tool-header">
|
||||||
|
<span className="config-ai-tool-icon"><Icon size={16} /></span>
|
||||||
|
<span className="config-ai-tool-name">{tool.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="config-ai-tool-desc">{tool.description}</div>
|
||||||
|
<button className="sm primary" onClick={() => configureTool(tool)} style={{ marginTop: 'auto' }}>
|
||||||
|
<Sparkles size={12} style={{ verticalAlign: 'middle', marginRight: 4 }} />
|
||||||
|
{t('config.configureViaAI')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="config-card" style={{ marginTop: 12, marginBottom: 4 }}>
|
||||||
|
<div className="config-card-row" style={{ alignItems: 'center' }}>
|
||||||
|
<div>
|
||||||
|
<span className="config-card-label" style={{ fontWeight: 600 }}>{t('config.systemUpdate')}</span>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--text-tertiary)', marginTop: 2 }}>
|
||||||
|
{isSudo ? t('config.systemUpdateDescSudo') : t('config.systemUpdateDescNoSudo')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button className="sm primary" onClick={handleSystemUpdate}>
|
||||||
|
<Download size={12} style={{ verticalAlign: 'middle', marginRight: 4 }} />
|
||||||
|
{t('config.updateBtn')}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 12, color: 'var(--text-tertiary)', marginBottom: 12 }}>
|
|
||||||
Vérifie l'installation de starship et configure le thème charm via l'IA.
|
|
||||||
</div>
|
|
||||||
<button className="sm primary" onClick={handleApplyStarship}>
|
|
||||||
{t('config.applyStarship')}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="section-title" style={{ marginTop: 20, marginBottom: 8, color: 'var(--danger)' }}>
|
<div className="section-title" style={{ marginTop: 20, marginBottom: 8, color: 'var(--danger)' }}>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
|
import { useState, useRef, useEffect, useCallback, useMemo, memo, Fragment } from 'react'
|
||||||
import { Terminal as XTerm } from '@xterm/xterm'
|
import { Terminal as XTerm } from '@xterm/xterm'
|
||||||
import { FitAddon } from '@xterm/addon-fit'
|
import { FitAddon } from '@xterm/addon-fit'
|
||||||
import { WebLinksAddon } from '@xterm/addon-web-links'
|
import { WebLinksAddon } from '@xterm/addon-web-links'
|
||||||
@@ -409,6 +409,7 @@ export default function Shell({ api, isSudo }) {
|
|||||||
})
|
})
|
||||||
const activeTabRef = useRef(activeTab)
|
const activeTabRef = useRef(activeTab)
|
||||||
useEffect(() => { activeTabRef.current = activeTab }, [activeTab])
|
useEffect(() => { activeTabRef.current = activeTab }, [activeTab])
|
||||||
|
const tabIdsKey = useMemo(() => tabs.map(t => t.id).join(','), [tabs])
|
||||||
const [sshConnections, setSshConnections] = useState([])
|
const [sshConnections, setSshConnections] = useState([])
|
||||||
const [systemTerminals, setSystemTerminals] = useState([])
|
const [systemTerminals, setSystemTerminals] = useState([])
|
||||||
const [showMenu, setShowMenu] = useState(false)
|
const [showMenu, setShowMenu] = useState(false)
|
||||||
@@ -474,8 +475,22 @@ export default function Shell({ api, isSudo }) {
|
|||||||
const aiLoadedRef = useRef(false)
|
const aiLoadedRef = useRef(false)
|
||||||
const aiLoadingRef = useRef(false)
|
const aiLoadingRef = useRef(false)
|
||||||
const analysisSavingRef = useRef(false)
|
const analysisSavingRef = useRef(false)
|
||||||
|
const _streamRafRef = useRef(null)
|
||||||
|
const _streamPendingRef = useRef(null)
|
||||||
|
|
||||||
|
const _flushStreamUpdate = useCallback(() => {
|
||||||
|
_streamRafRef.current = null
|
||||||
|
const pending = _streamPendingRef.current
|
||||||
|
if (!pending) return
|
||||||
|
_streamPendingRef.current = null
|
||||||
|
setAiMessages(pending)
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (_streamRafRef.current) {
|
||||||
|
cancelAnimationFrame(_streamRafRef.current)
|
||||||
|
_streamRafRef.current = null
|
||||||
|
}
|
||||||
aiMessagesRef.current?.scrollTo(0, aiMessagesRef.current.scrollHeight)
|
aiMessagesRef.current?.scrollTo(0, aiMessagesRef.current.scrollHeight)
|
||||||
}, [aiMessages])
|
}, [aiMessages])
|
||||||
|
|
||||||
@@ -760,7 +775,7 @@ export default function Shell({ api, isSudo }) {
|
|||||||
pending.forEach(clearTimeout)
|
pending.forEach(clearTimeout)
|
||||||
observer?.disconnect()
|
observer?.disconnect()
|
||||||
}
|
}
|
||||||
}, [tabs, initTerminal, initPendingTabs, configLoaded])
|
}, [tabIdsKey, initTerminal, initPendingTabs, configLoaded])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const entry = tabsRef.current[activeTab]
|
const entry = tabsRef.current[activeTab]
|
||||||
@@ -778,12 +793,18 @@ export default function Shell({ api, isSudo }) {
|
|||||||
const wrapper = document.querySelector('.shell-layout')?.parentElement
|
const wrapper = document.querySelector('.shell-layout')?.parentElement
|
||||||
if (wrapper && wrapper.classList.contains('tab-hidden')) return
|
if (wrapper && wrapper.classList.contains('tab-hidden')) return
|
||||||
const entry = tabsRef.current[activeTabRef.current]
|
const entry = tabsRef.current[activeTabRef.current]
|
||||||
if (entry) {
|
if (entry && entry.fitAddon && entry.term) {
|
||||||
entry.fitAddon.fit()
|
const container = document.getElementById(`terminal-${activeTabRef.current}`)
|
||||||
|
if (!container) return
|
||||||
|
const rect = container.getBoundingClientRect()
|
||||||
|
const dims = entry.fitAddon.proposeDimensions()
|
||||||
|
if (dims && entry.term.cols !== dims.cols || entry.term.rows !== dims.rows) {
|
||||||
|
entry.fitAddon.fit()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, 2000)
|
}, 2000)
|
||||||
return () => clearInterval(iv)
|
return () => clearInterval(iv)
|
||||||
}, [tabs])
|
}, [tabIdsKey])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@@ -813,25 +834,26 @@ export default function Shell({ api, isSudo }) {
|
|||||||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return
|
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return
|
||||||
if (!e.altKey && !(e.key === 'Tab' && e.shiftKey)) return
|
if (!e.altKey && !(e.key === 'Tab' && e.shiftKey)) return
|
||||||
|
|
||||||
|
const currentTabs = tabsRef.current._tabList || []
|
||||||
if (e.key === 'Tab' && e.shiftKey) {
|
if (e.key === 'Tab' && e.shiftKey) {
|
||||||
const shellTab = document.querySelector('.shell-layout')
|
const shellTab = document.querySelector('.shell-layout')
|
||||||
if (!shellTab || shellTab.closest('.tab-hidden')) return
|
if (!shellTab || shellTab.closest('.tab-hidden')) return
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const idx = tabs.findIndex(t => t.id === activeTab)
|
const idx = currentTabs.findIndex(t => t.id === activeTabRef.current)
|
||||||
const next = (idx + 1) % tabs.length
|
const next = (idx + 1) % currentTabs.length
|
||||||
setActiveTab(tabs[next].id)
|
setActiveTab(currentTabs[next].id)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const num = parseInt(e.key)
|
const num = parseInt(e.key)
|
||||||
if (num >= 1 && num <= tabs.length) {
|
if (num >= 1 && num <= currentTabs.length) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setActiveTab(tabs[num - 1].id)
|
setActiveTab(currentTabs[num - 1].id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
window.addEventListener('keydown', onKey)
|
window.addEventListener('keydown', onKey)
|
||||||
return () => window.removeEventListener('keydown', onKey)
|
return () => window.removeEventListener('keydown', onKey)
|
||||||
}, [tabs])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (showSearch && searchInputRef.current) {
|
if (showSearch && searchInputRef.current) {
|
||||||
@@ -1103,16 +1125,31 @@ export default function Shell({ api, isSudo }) {
|
|||||||
setAiLoading(true)
|
setAiLoading(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let accumulated = ''
|
let segments = []
|
||||||
let toolCalls = []
|
let textStartIdx = 0
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
|
|
||||||
|
const _updateLastText = (text) => {
|
||||||
|
if (!text) return
|
||||||
|
const last = segments.length > 0 ? segments[segments.length - 1] : null
|
||||||
|
if (last && last.type === 'text') {
|
||||||
|
last.content = text
|
||||||
|
} else {
|
||||||
|
segments.push({ type: 'text', content: text })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await api.sendShellChat(trimmed, {}, true, (partial, event) => {
|
await api.sendShellChat(trimmed, {}, true, (partial, event) => {
|
||||||
if (event && event.tool_call) {
|
if (event && event.tool_call) {
|
||||||
toolCalls = [...toolCalls, { call: event.tool_call, result: null }]
|
_updateLastText(partial.slice(textStartIdx))
|
||||||
|
textStartIdx = partial.length
|
||||||
|
segments.push({ type: 'tool', call: event.tool_call, result: null })
|
||||||
|
const snap = segments.map(s => ({ ...s, ...(s.type === 'tool' ? { call: s.call, result: s.result } : {}) }))
|
||||||
|
if (_streamRafRef.current) { cancelAnimationFrame(_streamRafRef.current); _streamRafRef.current = null }
|
||||||
|
_streamPendingRef.current = null
|
||||||
setAiMessages(prev => {
|
setAiMessages(prev => {
|
||||||
const filtered = prev.filter(m => !m._streaming)
|
const filtered = prev.filter(m => !m._streaming)
|
||||||
return [...filtered, { role: 'assistant', content: accumulated, _streaming: true, _tabId: currentTab, _toolCalls: [...toolCalls] }]
|
return [...filtered, { role: 'assistant', _streaming: true, _tabId: currentTab, _segments: snap }]
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1120,12 +1157,15 @@ export default function Shell({ api, isSudo }) {
|
|||||||
if (event.tool_result.sudo_blocked) {
|
if (event.tool_result.sudo_blocked) {
|
||||||
setSudoModal({ command: event.tool_result.command || event.tool_result.content })
|
setSudoModal({ command: event.tool_result.command || event.tool_result.content })
|
||||||
}
|
}
|
||||||
const idx = toolCalls.findIndex(tc => tc.call && tc.call.tool_call_id === event.tool_result.tool_call_id)
|
const segIdx = segments.findIndex(s => s.type === 'tool' && s.call && s.call.tool_call_id === event.tool_result.tool_call_id)
|
||||||
if (idx >= 0) {
|
if (segIdx >= 0) {
|
||||||
toolCalls[idx] = { ...toolCalls[idx], result: event.tool_result }
|
segments[segIdx].result = event.tool_result
|
||||||
|
const snap = segments.map(s => ({ ...s, ...(s.type === 'tool' ? { call: s.call, result: s.result } : {}) }))
|
||||||
|
if (_streamRafRef.current) { cancelAnimationFrame(_streamRafRef.current); _streamRafRef.current = null }
|
||||||
|
_streamPendingRef.current = null
|
||||||
setAiMessages(prev => {
|
setAiMessages(prev => {
|
||||||
const filtered = prev.filter(m => !m._streaming)
|
const filtered = prev.filter(m => !m._streaming)
|
||||||
return [...filtered, { role: 'assistant', content: accumulated, _streaming: true, _tabId: currentTab, _toolCalls: [...toolCalls] }]
|
return [...filtered, { role: 'assistant', _streaming: true, _tabId: currentTab, _segments: snap }]
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
@@ -1133,23 +1173,37 @@ export default function Shell({ api, isSudo }) {
|
|||||||
if (event && (event.thinking !== undefined || event.thinking_end)) {
|
if (event && (event.thinking !== undefined || event.thinking_end)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
accumulated = partial
|
_updateLastText(partial.slice(textStartIdx))
|
||||||
setAiMessages(prev => {
|
const nextMsgs = prev => {
|
||||||
const filtered = prev.filter(m => !m._streaming)
|
const filtered = prev.filter(m => !m._streaming)
|
||||||
return [...filtered, { role: 'assistant', content: partial, _streaming: true, _tabId: currentTab, _toolCalls: toolCalls.length > 0 ? [...toolCalls] : undefined }]
|
const snap = segments.map(s => ({ ...s, ...(s.type === 'tool' ? { call: s.call, result: s.result } : {}) }))
|
||||||
})
|
return [...filtered, { role: 'assistant', _streaming: true, _tabId: currentTab, _segments: snap }]
|
||||||
|
}
|
||||||
|
_streamPendingRef.current = nextMsgs
|
||||||
|
if (!_streamRafRef.current) {
|
||||||
|
_streamRafRef.current = requestAnimationFrame(_flushStreamUpdate)
|
||||||
|
}
|
||||||
}, controller.signal)
|
}, controller.signal)
|
||||||
|
|
||||||
const finalMsg = { role: 'assistant', content: accumulated, _tabId: currentTab }
|
if (_streamRafRef.current) { cancelAnimationFrame(_streamRafRef.current); _streamRafRef.current = null }
|
||||||
if (toolCalls.length > 0) {
|
_streamPendingRef.current = null
|
||||||
finalMsg._toolCalls = toolCalls
|
|
||||||
|
const allText = segments.filter(s => s.type === 'text').map(s => s.content).join('')
|
||||||
|
const toolSegs = segments.filter(s => s.type === 'tool')
|
||||||
|
|
||||||
|
const finalMsg = { role: 'assistant', content: allText, _tabId: currentTab }
|
||||||
|
if (toolSegs.length > 0 || segments.length > 1) {
|
||||||
finalMsg.content = JSON.stringify({
|
finalMsg.content = JSON.stringify({
|
||||||
content: accumulated,
|
segments: segments.map(s => s.type === 'text'
|
||||||
tool_calls: toolCalls.map(tc => tc.call),
|
? { type: 'text', content: s.content }
|
||||||
tool_results: toolCalls.map(tc => ({
|
: { type: 'tool', call: s.call, result: { content: s.result?.content || '', is_error: s.result?.is_error || false, tool_call_id: s.call?.tool_call_id } }
|
||||||
tool_call_id: tc.call?.tool_call_id,
|
),
|
||||||
result: tc.result?.content || '',
|
content: allText,
|
||||||
is_error: tc.result?.is_error || false,
|
tool_calls: toolSegs.map(s => s.call),
|
||||||
|
tool_results: toolSegs.map(s => ({
|
||||||
|
tool_call_id: s.call?.tool_call_id,
|
||||||
|
result: s.result?.content || '',
|
||||||
|
is_error: s.result?.is_error || false,
|
||||||
})),
|
})),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1159,10 +1213,10 @@ export default function Shell({ api, isSudo }) {
|
|||||||
return [...filtered, finalMsg]
|
return [...filtered, finalMsg]
|
||||||
})
|
})
|
||||||
|
|
||||||
if (analysisSavingRef.current && accumulated) {
|
if (analysisSavingRef.current && allText) {
|
||||||
analysisSavingRef.current = false
|
analysisSavingRef.current = false
|
||||||
setAnalysisContent(accumulated)
|
setAnalysisContent(allText)
|
||||||
try { localStorage.setItem('shell_analysis', accumulated) } catch {}
|
try { localStorage.setItem('shell_analysis', allText) } catch {}
|
||||||
setAnalyzing(false)
|
setAnalyzing(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1182,7 +1236,7 @@ export default function Shell({ api, isSudo }) {
|
|||||||
}
|
}
|
||||||
setAiLoading(false)
|
setAiLoading(false)
|
||||||
aiLoadingRef.current = false
|
aiLoadingRef.current = false
|
||||||
}, [api, t, aiAtLimit, focusAiTerminal])
|
}, [api, t, aiAtLimit, focusAiTerminal, _flushStreamUpdate])
|
||||||
|
|
||||||
const handleAiSend = () => _sendAiMessage(aiInput, false)
|
const handleAiSend = () => _sendAiMessage(aiInput, false)
|
||||||
|
|
||||||
@@ -1190,7 +1244,7 @@ export default function Shell({ api, isSudo }) {
|
|||||||
const handler = (e) => {
|
const handler = (e) => {
|
||||||
const msg = e.detail?.message
|
const msg = e.detail?.message
|
||||||
if (!msg) return
|
if (!msg) return
|
||||||
setAiInput(msg)
|
setAiInput('')
|
||||||
setTimeout(() => _sendAiMessage(msg, true), 100)
|
setTimeout(() => _sendAiMessage(msg, true), 100)
|
||||||
}
|
}
|
||||||
window.addEventListener('ask-ai-terminal', handler)
|
window.addEventListener('ask-ai-terminal', handler)
|
||||||
@@ -1378,11 +1432,9 @@ 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">
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
<span style={{ flex: 1 }}>Analyste Système</span>
|
||||||
<span>Analyste Système</span>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexShrink: 0 }}>
|
||||||
<span className={`sudo-indicator ${isSudo ? 'sudo-ok' : 'sudo-blocked'}`} title={isSudo ? 'Sudo sans mot de passe disponible' : 'Sudo bloqué — mot de passe requis'} />
|
<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 }}>
|
|
||||||
<button
|
<button
|
||||||
className="shell-analyze-btn"
|
className="shell-analyze-btn"
|
||||||
onClick={() => setShowAnalysis(true)}
|
onClick={() => setShowAnalysis(true)}
|
||||||
@@ -1627,7 +1679,39 @@ function MermaidBlock({ code }) {
|
|||||||
return <div className="shell-mermaid-container" ref={ref} dangerouslySetInnerHTML={{ __html: svg }} />
|
return <div className="shell-mermaid-container" ref={ref} dangerouslySetInnerHTML={{ __html: svg }} />
|
||||||
}
|
}
|
||||||
|
|
||||||
function ShellAIMessage({ msg, sendToTerminal, terminalTabId }) {
|
const _renderParts = (parts, copiedIdx, setCopiedIdx, sendToTerminal, terminalTabId) => parts.map((part, i) => {
|
||||||
|
if (part.type === 'code' && part.lang === 'mermaid') {
|
||||||
|
return (
|
||||||
|
<div key={i} className="shell-code-block">
|
||||||
|
<div className="shell-code-lang">mermaid</div>
|
||||||
|
<MermaidBlock code={part.content} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (part.type === 'code') {
|
||||||
|
return (
|
||||||
|
<div key={i} className="shell-code-block">
|
||||||
|
{part.lang && <div className="shell-code-lang">{part.lang}</div>}
|
||||||
|
<pre><code>{part.content}</code></pre>
|
||||||
|
<div className="shell-code-actions">
|
||||||
|
<button className={copiedIdx === i ? 'copied' : ''} onClick={() => {
|
||||||
|
navigator.clipboard.writeText(part.content)
|
||||||
|
setCopiedIdx(i)
|
||||||
|
setTimeout(() => setCopiedIdx(null), 1500)
|
||||||
|
}} title="Copier">
|
||||||
|
<Copy size={12} /> {copiedIdx === i ? 'Copié !' : 'Copier'}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => sendToTerminal(part.content, terminalTabId)} title="Envoyer au terminal">
|
||||||
|
<Send size={12} /> Terminal
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return <span key={i} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} />
|
||||||
|
})
|
||||||
|
|
||||||
|
const ShellAIMessage = memo(function ShellAIMessage({ msg, sendToTerminal, terminalTabId }) {
|
||||||
const role = msg.role === 'user' ? 'user' : msg.role === 'system' ? 'system' : 'assistant'
|
const role = msg.role === 'user' ? 'user' : msg.role === 'system' ? 'system' : 'assistant'
|
||||||
const content = msg.content || ''
|
const content = msg.content || ''
|
||||||
const [copiedIdx, setCopiedIdx] = useState(null)
|
const [copiedIdx, setCopiedIdx] = useState(null)
|
||||||
@@ -1640,18 +1724,51 @@ function ShellAIMessage({ msg, sendToTerminal, terminalTabId }) {
|
|||||||
return <div className={`ai-message system`}>{content}</div>
|
return <div className={`ai-message system`}>{content}</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ordered segments (streaming or final with segments)
|
||||||
|
let segments = msg._segments || null
|
||||||
|
if (!segments) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(content)
|
||||||
|
if (parsed && Array.isArray(parsed.segments)) {
|
||||||
|
segments = parsed.segments
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (segments && segments.length > 0) {
|
||||||
|
const hasTools = segments.some(s => s.type === 'tool')
|
||||||
|
if (hasTools) {
|
||||||
|
return (
|
||||||
|
<div className="ai-message assistant">
|
||||||
|
{segments.map((seg, i) => {
|
||||||
|
if (seg.type === 'text') {
|
||||||
|
if (!seg.content) return null
|
||||||
|
return <Fragment key={`t${i}`}>{_renderParts(renderContent(seg.content), copiedIdx, setCopiedIdx, sendToTerminal, terminalTabId)}</Fragment>
|
||||||
|
}
|
||||||
|
if (seg.type === 'tool') {
|
||||||
|
const r = seg.result
|
||||||
|
const result = r && (r.content !== undefined || r.is_error !== undefined)
|
||||||
|
? { content: r.content, is_error: r.is_error }
|
||||||
|
: null
|
||||||
|
return <ShellToolBlock key={`tc${i}`} call={seg.call} result={result} />
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: old format (all tools then all text)
|
||||||
let parsedToolCalls = null
|
let parsedToolCalls = null
|
||||||
let parsedToolResults = null
|
let parsedToolResults = null
|
||||||
let displayContent = content
|
let displayContent = content
|
||||||
let streamingToolCalls = msg._toolCalls || null
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(content)
|
const parsed = JSON.parse(content)
|
||||||
if (parsed && Array.isArray(parsed.tool_calls)) {
|
if (parsed && Array.isArray(parsed.tool_calls)) {
|
||||||
if (!streamingToolCalls) {
|
parsedToolCalls = parsed.tool_calls
|
||||||
parsedToolCalls = parsed.tool_calls
|
parsedToolResults = parsed.tool_results || null
|
||||||
parsedToolResults = parsed.tool_results || null
|
|
||||||
}
|
|
||||||
displayContent = parsed.content || ''
|
displayContent = parsed.content || ''
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
@@ -1660,9 +1777,6 @@ function ShellAIMessage({ msg, sendToTerminal, terminalTabId }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`ai-message assistant`}>
|
<div className={`ai-message assistant`}>
|
||||||
{streamingToolCalls && streamingToolCalls.map((tc, i) => (
|
|
||||||
<ShellToolBlock key={tc.call?.tool_call_id || i} call={tc.call} result={tc.result} />
|
|
||||||
))}
|
|
||||||
{parsedToolCalls && parsedToolCalls.map((tc, i) => {
|
{parsedToolCalls && parsedToolCalls.map((tc, i) => {
|
||||||
const resultData = parsedToolResults
|
const resultData = parsedToolResults
|
||||||
? parsedToolResults.find(r => r.tool_call_id === tc.tool_call_id)
|
? parsedToolResults.find(r => r.tool_call_id === tc.tool_call_id)
|
||||||
@@ -1672,37 +1786,7 @@ function ShellAIMessage({ msg, sendToTerminal, terminalTabId }) {
|
|||||||
: null
|
: null
|
||||||
return <ShellToolBlock key={tc.tool_call_id || i} call={tc} result={result} />
|
return <ShellToolBlock key={tc.tool_call_id || i} call={tc} result={result} />
|
||||||
})}
|
})}
|
||||||
{parts.map((part, i) => {
|
{_renderParts(parts, copiedIdx, setCopiedIdx, sendToTerminal, terminalTabId)}
|
||||||
if (part.type === 'code' && part.lang === 'mermaid') {
|
|
||||||
return (
|
|
||||||
<div key={i} className="shell-code-block">
|
|
||||||
<div className="shell-code-lang">mermaid</div>
|
|
||||||
<MermaidBlock code={part.content} />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (part.type === 'code') {
|
|
||||||
return (
|
|
||||||
<div key={i} className="shell-code-block">
|
|
||||||
{part.lang && <div className="shell-code-lang">{part.lang}</div>}
|
|
||||||
<pre><code>{part.content}</code></pre>
|
|
||||||
<div className="shell-code-actions">
|
|
||||||
<button className={copiedIdx === i ? 'copied' : ''} onClick={() => {
|
|
||||||
navigator.clipboard.writeText(part.content)
|
|
||||||
setCopiedIdx(i)
|
|
||||||
setTimeout(() => setCopiedIdx(null), 1500)
|
|
||||||
}} title="Copier">
|
|
||||||
<Copy size={12} /> {copiedIdx === i ? 'Copié !' : 'Copier'}
|
|
||||||
</button>
|
|
||||||
<button onClick={() => sendToTerminal(part.content, terminalTabId)} title="Envoyer au terminal">
|
|
||||||
<Send size={12} /> Terminal
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return <span key={i} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} />
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
|||||||
@@ -142,10 +142,17 @@ const TOOL_LABELS = {
|
|||||||
web_fetch: 'Web Fetch',
|
web_fetch: 'Web Fetch',
|
||||||
}
|
}
|
||||||
|
|
||||||
function ToolCallBlock({ call, result }) {
|
function ToolCallBlock({ call, result, activeAgents, onModeChange }) {
|
||||||
const icon = TOOL_ICONS[call.name] || '🔧'
|
const icon = TOOL_ICONS[call.name] || '🔧'
|
||||||
const label = TOOL_LABELS[call.name] || call.name
|
const label = TOOL_LABELS[call.name] || call.name
|
||||||
const isErr = result && result.is_error
|
const isErr = result && result.is_error
|
||||||
|
const isCrush = call.name === 'crush_run'
|
||||||
|
const isClaude = call.name === 'claude_run'
|
||||||
|
const isAgent = isCrush || isClaude
|
||||||
|
const agentType = isCrush ? 'crush' : isClaude ? 'claude' : null
|
||||||
|
const maxAgents = isCrush ? 2 : isClaude ? 2 : 0
|
||||||
|
const currentCount = agentType && activeAgents ? (activeAgents[agentType] || 0) : 0
|
||||||
|
const [mode, setMode] = useState('sync')
|
||||||
|
|
||||||
let argsPreview = ''
|
let argsPreview = ''
|
||||||
try {
|
try {
|
||||||
@@ -163,15 +170,39 @@ function ToolCallBlock({ call, result }) {
|
|||||||
|
|
||||||
const truncatedResult = result ? (result.content || '').slice(0, 2000) : null
|
const truncatedResult = result ? (result.content || '').slice(0, 2000) : null
|
||||||
|
|
||||||
|
const handleModeChange = (newMode) => {
|
||||||
|
setMode(newMode)
|
||||||
|
if (onModeChange) onModeChange(call.tool_call_id, newMode)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`studio-tool-block ${isErr ? 'error' : ''} ${result ? 'done' : 'running'}`}>
|
<div className={`studio-tool-block ${isErr ? 'error' : ''} ${result ? 'done' : 'running'}`}>
|
||||||
<div className="studio-tool-header">
|
<div className="studio-tool-header">
|
||||||
<span className="studio-tool-icon">{icon}</span>
|
<span className="studio-tool-icon">{icon}</span>
|
||||||
<span className="studio-tool-name">{label}</span>
|
<span className="studio-tool-name">{label}</span>
|
||||||
|
{isAgent && !result && (
|
||||||
|
<span className="studio-agent-badge">{currentCount}/{maxAgents}</span>
|
||||||
|
)}
|
||||||
{!result && <span className="studio-tool-spinner"><span/><span/><span/></span>}
|
{!result && <span className="studio-tool-spinner"><span/><span/><span/></span>}
|
||||||
{result && <span className={`studio-tool-status ${isErr ? 'error' : 'ok'}`}>{isErr ? '✗' : '✓'}</span>}
|
{result && <span className={`studio-tool-status ${isErr ? 'error' : 'ok'}`}>{isErr ? '✗' : '✓'}</span>}
|
||||||
</div>
|
</div>
|
||||||
<div className="studio-tool-args" title={argsPreview}>{argsPreview}</div>
|
<div className="studio-tool-args" title={argsPreview}>{argsPreview}</div>
|
||||||
|
{isAgent && !result && (
|
||||||
|
<div className="studio-agent-mode">
|
||||||
|
<button
|
||||||
|
className={`studio-mode-btn ${mode === 'sync' ? 'active' : ''}`}
|
||||||
|
onClick={() => handleModeChange('sync')}
|
||||||
|
>
|
||||||
|
Exécuter et attendre
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`studio-mode-btn ${mode === 'async' ? 'active' : ''}`}
|
||||||
|
onClick={() => handleModeChange('async')}
|
||||||
|
>
|
||||||
|
Exécuter en arrière-plan
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{truncatedResult && (
|
{truncatedResult && (
|
||||||
<div className="studio-tool-result">
|
<div className="studio-tool-result">
|
||||||
<pre>{truncatedResult}</pre>
|
<pre>{truncatedResult}</pre>
|
||||||
@@ -249,10 +280,16 @@ function FeedItem({ msg }) {
|
|||||||
|
|
||||||
let parsedToolCalls = null
|
let parsedToolCalls = null
|
||||||
let parsedToolResults = null
|
let parsedToolResults = null
|
||||||
|
let parsedSegments = null
|
||||||
let displayContent = msg.content
|
let displayContent = msg.content
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(msg.content)
|
const parsed = JSON.parse(msg.content)
|
||||||
if (parsed && Array.isArray(parsed.tool_calls)) {
|
if (parsed && Array.isArray(parsed.segments)) {
|
||||||
|
parsedSegments = parsed.segments
|
||||||
|
parsedToolCalls = parsed.tool_calls || null
|
||||||
|
parsedToolResults = parsed.tool_results || null
|
||||||
|
displayContent = parsed.content || ''
|
||||||
|
} else if (parsed && Array.isArray(parsed.tool_calls)) {
|
||||||
parsedToolCalls = parsed.tool_calls
|
parsedToolCalls = parsed.tool_calls
|
||||||
parsedToolResults = parsed.tool_results || null
|
parsedToolResults = parsed.tool_results || null
|
||||||
displayContent = parsed.content || ''
|
displayContent = parsed.content || ''
|
||||||
@@ -292,32 +329,63 @@ function FeedItem({ msg }) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{parsedToolCalls && parsedToolCalls.map((tc, i) => {
|
{parsedSegments && parsedSegments.some(s => s.type === 'tool') ? (
|
||||||
const resultData = parsedToolResults
|
parsedSegments.map((seg, i) => {
|
||||||
? parsedToolResults.find(r => r.tool_call_id === tc.tool_call_id)
|
if (seg.type === 'text') {
|
||||||
: null
|
if (!seg.content) return null
|
||||||
const result = resultData
|
const c = seg.content.replace(/<think[^>]*>[\s\S]*?<\/think>/gi, '')
|
||||||
? { content: resultData.result, is_error: resultData.is_error }
|
if (!c) return null
|
||||||
: null
|
return (
|
||||||
return <ToolCallBlock key={tc.tool_call_id || i} call={tc} result={result} />
|
<div key={`t${i}`} className="feed-content">
|
||||||
})}
|
{renderContent(c).map((part, j) =>
|
||||||
{cleanContent && (
|
part.type === 'code' ? (
|
||||||
<div className="feed-content">
|
<CodeBlockWithCopy key={j} part={part} index={j} copiedIdx={copiedIdx} setCopiedIdx={setCopiedIdx} />
|
||||||
{renderContent(cleanContent).map((part, i) =>
|
) : (
|
||||||
part.type === 'code' ? (
|
<span key={j} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} />
|
||||||
<CodeBlockWithCopy key={i} part={part} index={i} copiedIdx={copiedIdx} setCopiedIdx={setCopiedIdx} />
|
)
|
||||||
) : (
|
)}
|
||||||
<span key={i} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} />
|
</div>
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
if (seg.type === 'tool') {
|
||||||
|
const r = seg.result
|
||||||
|
const result = r && (r.content !== undefined || r.is_error !== undefined)
|
||||||
|
? { content: r.content, is_error: r.is_error }
|
||||||
|
: null
|
||||||
|
return <ToolCallBlock key={`tc${i}`} call={seg.call} result={result} activeAgents={activeAgents} onModeChange={handleToolModeChange} />
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{parsedToolCalls && parsedToolCalls.map((tc, i) => {
|
||||||
|
const resultData = parsedToolResults
|
||||||
|
? parsedToolResults.find(r => r.tool_call_id === tc.tool_call_id)
|
||||||
|
: null
|
||||||
|
const result = resultData
|
||||||
|
? { content: resultData.result, is_error: resultData.is_error }
|
||||||
|
: null
|
||||||
|
return <ToolCallBlock key={tc.tool_call_id || i} call={tc} result={result} activeAgents={activeAgents} onModeChange={handleToolModeChange} />
|
||||||
|
})}
|
||||||
|
{cleanContent && (
|
||||||
|
<div className="feed-content">
|
||||||
|
{renderContent(cleanContent).map((part, i) =>
|
||||||
|
part.type === 'code' ? (
|
||||||
|
<CodeBlockWithCopy key={i} part={part} index={i} copiedIdx={copiedIdx} setCopiedIdx={setCopiedIdx} />
|
||||||
|
) : (
|
||||||
|
<span key={i} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} />
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function StreamingItem({ content, thinking, toolCalls }) {
|
function StreamingItem({ content, thinking, toolCalls, segments }) {
|
||||||
const rank = RANKS.general
|
const rank = RANKS.general
|
||||||
const cleanContent = content.replace(/<think[^>]*>[\s\S]*?<\/think>/gi, '')
|
const cleanContent = content.replace(/<think[^>]*>[\s\S]*?<\/think>/gi, '')
|
||||||
const hasToolCalls = toolCalls && toolCalls.length > 0
|
const hasToolCalls = toolCalls && toolCalls.length > 0
|
||||||
@@ -333,6 +401,8 @@ function StreamingItem({ content, thinking, toolCalls }) {
|
|||||||
return formatText(thinking)
|
return formatText(thinking)
|
||||||
}, [thinking])
|
}, [thinking])
|
||||||
|
|
||||||
|
const hasOrderedSegments = segments && segments.some(s => s.type === 'tool')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="feed-item assistant">
|
<div className="feed-item assistant">
|
||||||
<div className="feed-avatar ai-rank">
|
<div className="feed-avatar ai-rank">
|
||||||
@@ -346,25 +416,54 @@ function StreamingItem({ content, thinking, toolCalls }) {
|
|||||||
<span className="feed-role">{rank.label}</span>
|
<span className="feed-role">{rank.label}</span>
|
||||||
</div>
|
</div>
|
||||||
{thinking && <ThinkingBlock content={formattedThinking} raw done={false} />}
|
{thinking && <ThinkingBlock content={formattedThinking} raw done={false} />}
|
||||||
{hasToolCalls && toolCalls.map((tc, i) => (
|
{hasOrderedSegments ? (
|
||||||
<ToolCallBlock key={tc.call?.tool_call_id || i} call={tc.call} result={tc.result} />
|
segments.map((seg, i) => {
|
||||||
))}
|
if (seg.type === 'text') {
|
||||||
{!thinking && !cleanContent && !hasToolCalls && (
|
if (!seg.content) return null
|
||||||
|
const parts = renderContent(seg.content)
|
||||||
|
return (
|
||||||
|
<div key={`t${i}`} className="feed-content">
|
||||||
|
{parts.map((part, j) =>
|
||||||
|
part.type === 'code' ? (
|
||||||
|
<CodeBlockWithCopy key={j} part={part} index={j} copiedIdx={copiedIdx} setCopiedIdx={setCopiedIdx} />
|
||||||
|
) : (
|
||||||
|
<span key={j} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} />
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (seg.type === 'tool') {
|
||||||
|
return <ToolCallBlock key={`tc${i}`} call={seg.call} result={seg.result} activeAgents={activeAgents} onModeChange={handleToolModeChange} />
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{hasToolCalls && toolCalls.map((tc, i) => (
|
||||||
|
<ToolCallBlock key={tc.call?.tool_call_id || i} call={tc.call} result={tc.result} activeAgents={activeAgents} onModeChange={handleToolModeChange} />
|
||||||
|
))}
|
||||||
|
{cleanContent && (
|
||||||
|
<div className="feed-content">
|
||||||
|
{renderedContent.map((part, i) =>
|
||||||
|
part.type === 'code' ? (
|
||||||
|
<CodeBlockWithCopy key={i} part={part} index={i} copiedIdx={copiedIdx} setCopiedIdx={setCopiedIdx} />
|
||||||
|
) : (
|
||||||
|
<span key={i} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} />
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
<span className="studio-cursor" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!thinking && !cleanContent && !hasToolCalls && !hasOrderedSegments && (
|
||||||
<div className="feed-content">
|
<div className="feed-content">
|
||||||
<div className="studio-thinking"><span /><span /><span /></div>
|
<div className="studio-thinking"><span /><span /><span /></div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{cleanContent && (
|
{!hasOrderedSegments && cleanContent && (
|
||||||
<div className="feed-content">
|
<span className="studio-cursor" />
|
||||||
{renderedContent.map((part, i) =>
|
|
||||||
part.type === 'code' ? (
|
|
||||||
<CodeBlockWithCopy key={i} part={part} index={i} copiedIdx={copiedIdx} setCopiedIdx={setCopiedIdx} />
|
|
||||||
) : (
|
|
||||||
<span key={i} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} />
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
<span className="studio-cursor" />
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -379,12 +478,17 @@ export default function Studio({ api }) {
|
|||||||
const [streaming, setStreaming] = useState('')
|
const [streaming, setStreaming] = useState('')
|
||||||
const [streamThinking, setStreamThinking] = useState('')
|
const [streamThinking, setStreamThinking] = useState('')
|
||||||
const [streamToolCalls, setStreamToolCalls] = useState([])
|
const [streamToolCalls, setStreamToolCalls] = useState([])
|
||||||
|
const [streamSegments, setStreamSegments] = useState(null)
|
||||||
const [loaded, setLoaded] = useState(false)
|
const [loaded, setLoaded] = useState(false)
|
||||||
const [tokenInfo, setTokenInfo] = useState({ used: 0, max: 150000, summarizeAt: 120000 })
|
const [tokenInfo, setTokenInfo] = useState({ used: 0, max: 150000, summarizeAt: 120000 })
|
||||||
const [contextCollapsed, setContextCollapsed] = useState(false)
|
const [contextCollapsed, setContextCollapsed] = useState(false)
|
||||||
const [messagesCollapsed, setMessagesCollapsed] = useState(false)
|
const [messagesCollapsed, setMessagesCollapsed] = useState(false)
|
||||||
const [sudoModal, setSudoModal] = useState(null)
|
const [sudoModal, setSudoModal] = useState(null)
|
||||||
const [attachedImages, setAttachedImages] = useState([])
|
const [attachedImages, setAttachedImages] = useState([])
|
||||||
|
const [activeAgents, setActiveAgents] = useState({ crush: 0, claude: 0 })
|
||||||
|
const [toolModes, setToolModes] = useState({})
|
||||||
|
const MAX_CRUSH_AGENTS = 2
|
||||||
|
const MAX_CLAUDE_AGENTS = 2
|
||||||
const messagesEnd = useRef(null)
|
const messagesEnd = useRef(null)
|
||||||
const feedRef = useRef(null)
|
const feedRef = useRef(null)
|
||||||
const textareaRef = useRef(null)
|
const textareaRef = useRef(null)
|
||||||
@@ -584,9 +688,19 @@ export default function Studio({ api }) {
|
|||||||
abortRef.current = controller
|
abortRef.current = controller
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let accumulated = ''
|
let segments = []
|
||||||
|
let textStartIdx = 0
|
||||||
let thinking = ''
|
let thinking = ''
|
||||||
let toolCalls = []
|
|
||||||
|
const _updateLastText = (text) => {
|
||||||
|
if (!text) return
|
||||||
|
const last = segments.length > 0 ? segments[segments.length - 1] : null
|
||||||
|
if (last && last.type === 'text') {
|
||||||
|
last.content = text
|
||||||
|
} else {
|
||||||
|
segments.push({ type: 'text', content: text })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await api.sendChat(text, true, (partial, event) => {
|
await api.sendChat(text, true, (partial, event) => {
|
||||||
if (event && (event.thinking_start || event.thinking_end || event.thinking !== undefined)) {
|
if (event && (event.thinking_start || event.thinking_end || event.thinking !== undefined)) {
|
||||||
@@ -597,28 +711,47 @@ export default function Studio({ api }) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (event && event.tool_call) {
|
if (event && event.tool_call) {
|
||||||
toolCalls = [...toolCalls, { call: event.tool_call, result: null }]
|
_updateLastText(partial.slice(textStartIdx))
|
||||||
setStreamToolCalls([...toolCalls])
|
textStartIdx = partial.length
|
||||||
accumulated = ''
|
segments.push({ type: 'tool', call: event.tool_call, result: null })
|
||||||
setStreaming('')
|
const toolName = event.tool_call.name
|
||||||
|
if (toolName === 'crush_run' || toolName === 'claude_run') {
|
||||||
|
const agentType = toolName === 'crush_run' ? 'crush' : 'claude'
|
||||||
|
setActiveAgents(prev => ({ ...prev, [agentType]: prev[agentType] + 1 }))
|
||||||
|
}
|
||||||
|
const snap = segments.map(s => ({ ...s }))
|
||||||
|
setStreamToolCalls(snap.filter(s => s.type === 'tool'))
|
||||||
|
setStreamSegments(snap)
|
||||||
|
setStreaming(partial)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (event && event.tool_result) {
|
if (event && event.tool_result) {
|
||||||
if (event.tool_result.sudo_blocked) {
|
if (event.tool_result.sudo_blocked) {
|
||||||
setSudoModal({ command: event.tool_result.command || event.tool_result.content })
|
setSudoModal({ command: event.tool_result.command || event.tool_result.content })
|
||||||
}
|
}
|
||||||
const idx = toolCalls.findIndex(tc => tc.call && tc.call.tool_call_id === event.tool_result.tool_call_id)
|
const segIdx = segments.findIndex(s => s.type === 'tool' && s.call && s.call.tool_call_id === event.tool_result.tool_call_id)
|
||||||
if (idx >= 0) {
|
if (segIdx >= 0) {
|
||||||
toolCalls[idx] = { ...toolCalls[idx], result: event.tool_result }
|
segments[segIdx].result = event.tool_result
|
||||||
setStreamToolCalls([...toolCalls])
|
const toolName = segments[segIdx].call?.name
|
||||||
|
if (toolName === 'crush_run' || toolName === 'claude_run') {
|
||||||
|
const agentType = toolName === 'crush_run' ? 'crush' : 'claude'
|
||||||
|
setActiveAgents(prev => ({ ...prev, [agentType]: Math.max(0, prev[agentType] - 1) }))
|
||||||
|
}
|
||||||
|
const snap = segments.map(s => ({ ...s }))
|
||||||
|
setStreamToolCalls(snap.filter(s => s.type === 'tool'))
|
||||||
|
setStreamSegments(snap)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
accumulated = partial
|
_updateLastText(partial.slice(textStartIdx))
|
||||||
setStreaming(partial)
|
setStreaming(partial)
|
||||||
|
const snap = segments.map(s => ({ ...s }))
|
||||||
|
setStreamSegments(snap)
|
||||||
}, controller.signal, images)
|
}, controller.signal, images)
|
||||||
|
|
||||||
const finalContent = accumulated || t('studio.noResponse')
|
const allText = segments.filter(s => s.type === 'text').map(s => s.content).join('')
|
||||||
|
const toolSegs = segments.filter(s => s.type === 'tool')
|
||||||
|
const finalContent = allText || t('studio.noResponse')
|
||||||
const aiMsg = {
|
const aiMsg = {
|
||||||
id: (Date.now() + 1).toString(),
|
id: (Date.now() + 1).toString(),
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
@@ -626,14 +759,18 @@ export default function Studio({ api }) {
|
|||||||
time: new Date().toISOString(),
|
time: new Date().toISOString(),
|
||||||
}
|
}
|
||||||
if (thinking) aiMsg.thinking = thinking
|
if (thinking) aiMsg.thinking = thinking
|
||||||
if (toolCalls.length > 0) {
|
if (toolSegs.length > 0 || segments.length > 1) {
|
||||||
aiMsg.content = JSON.stringify({
|
aiMsg.content = JSON.stringify({
|
||||||
content: finalContent,
|
segments: segments.map(s => s.type === 'text'
|
||||||
tool_calls: toolCalls.map(tc => tc.call),
|
? { type: 'text', content: s.content }
|
||||||
tool_results: toolCalls.map(tc => ({
|
: { type: 'tool', call: s.call, result: { content: s.result?.content || '', is_error: s.result?.is_error || false, tool_call_id: s.call?.tool_call_id } }
|
||||||
tool_call_id: tc.call?.tool_call_id,
|
),
|
||||||
result: tc.result?.content || '',
|
content: allText,
|
||||||
is_error: tc.result?.is_error || false,
|
tool_calls: toolSegs.map(s => s.call),
|
||||||
|
tool_results: toolSegs.map(s => ({
|
||||||
|
tool_call_id: s.call?.tool_call_id,
|
||||||
|
result: s.result?.content || '',
|
||||||
|
is_error: s.result?.is_error || false,
|
||||||
})),
|
})),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -661,6 +798,9 @@ export default function Studio({ api }) {
|
|||||||
setStreaming('')
|
setStreaming('')
|
||||||
setStreamThinking('')
|
setStreamThinking('')
|
||||||
setStreamToolCalls([])
|
setStreamToolCalls([])
|
||||||
|
setStreamSegments(null)
|
||||||
|
setActiveAgents({ crush: 0, claude: 0 })
|
||||||
|
setToolModes({})
|
||||||
abortRef.current = null
|
abortRef.current = null
|
||||||
refreshTokens()
|
refreshTokens()
|
||||||
}
|
}
|
||||||
@@ -672,6 +812,10 @@ export default function Studio({ api }) {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const handleToolModeChange = useCallback((toolCallId, mode) => {
|
||||||
|
setToolModes(prev => ({ ...prev, [toolCallId]: mode }))
|
||||||
|
}, [])
|
||||||
|
|
||||||
const COMMANDS = ['/clear', '/summarize', '/help', '/model', '/model change']
|
const COMMANDS = ['/clear', '/summarize', '/help', '/model', '/model change']
|
||||||
|
|
||||||
const handleKeyDown = (e) => {
|
const handleKeyDown = (e) => {
|
||||||
@@ -695,29 +839,61 @@ export default function Studio({ api }) {
|
|||||||
if (afterSlash) {
|
if (afterSlash) {
|
||||||
const partial = afterSlash[0]
|
const partial = afterSlash[0]
|
||||||
const matches = COMMANDS.filter(c => c.startsWith(partial) && c !== partial)
|
const matches = COMMANDS.filter(c => c.startsWith(partial) && c !== partial)
|
||||||
if (matches.length === 1) {
|
if (matches.length >= 1) {
|
||||||
const completed = matches[0] + ' '
|
let completed = matches[0]
|
||||||
const newText = val.slice(0, pos - afterSlash[0].length) + completed + val.slice(pos)
|
for (const m of matches) {
|
||||||
setInput(newText)
|
while (!m.startsWith(completed)) completed = completed.slice(0, -1)
|
||||||
requestAnimationFrame(() => {
|
}
|
||||||
ta.selectionStart = ta.selectionEnd = pos - afterSlash[0].length + completed.length
|
if (completed === partial && matches.length === 1) completed = matches[0]
|
||||||
})
|
if (completed.length > partial.length) {
|
||||||
|
const suffix = completed[completed.length - 1] === ' ' ? '' : (matches.length === 1 ? ' ' : '')
|
||||||
|
completed += suffix
|
||||||
|
const newText = val.slice(0, pos - afterSlash[0].length) + completed + val.slice(pos)
|
||||||
|
setInput(newText)
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
ta.selectionStart = ta.selectionEnd = pos - afterSlash[0].length + completed.length
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const [summarizedExpanded, setSummarizedExpanded] = useState(false)
|
||||||
|
|
||||||
const handleToggleCollapsed = useCallback(() => {
|
const handleToggleCollapsed = useCallback(() => {
|
||||||
setMessagesCollapsed(prev => !prev)
|
setMessagesCollapsed(prev => !prev)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const renderMessages = () => {
|
const renderMessages = () => {
|
||||||
if (messagesCollapsed && messages.length > 4) {
|
const summarizedMsgs = messages.filter(m => m.summarized)
|
||||||
|
const activeMsgs = messages.filter(m => !m.summarized)
|
||||||
|
|
||||||
|
const renderSummaryBlock = () => summarizedMsgs.length > 0 && (
|
||||||
|
<div className="feed-summary-block">
|
||||||
|
<div className="feed-summary-header" onClick={() => setSummarizedExpanded(prev => !prev)}>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/>
|
||||||
|
<polyline points="14 2 14 8 20 8"/>
|
||||||
|
<line x1="16" y1="13" x2="8" y2="13"/>
|
||||||
|
<line x1="16" y1="17" x2="8" y2="17"/>
|
||||||
|
</svg>
|
||||||
|
<span className="feed-summary-text">Résumé · {summarizedMsgs.length} messages</span>
|
||||||
|
<span className="feed-summary-toggle">{summarizedExpanded ? 'masquer' : 'voir'}</span>
|
||||||
|
</div>
|
||||||
|
{summarizedExpanded && summarizedMsgs.map(msg => (
|
||||||
|
<FeedItem key={msg.id} msg={msg} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (messagesCollapsed && activeMsgs.length > 4) {
|
||||||
const visibleCount = 4
|
const visibleCount = 4
|
||||||
const hiddenCount = messages.length - visibleCount
|
const hiddenCount = activeMsgs.length - visibleCount
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{messages.slice(0, visibleCount).map(msg => (
|
{renderSummaryBlock()}
|
||||||
|
{activeMsgs.slice(0, visibleCount).map(msg => (
|
||||||
<FeedItem key={msg.id} msg={msg} />
|
<FeedItem key={msg.id} msg={msg} />
|
||||||
))}
|
))}
|
||||||
<div className="feed-collapsed-messages" onClick={handleToggleCollapsed}>
|
<div className="feed-collapsed-messages" onClick={handleToggleCollapsed}>
|
||||||
@@ -730,9 +906,15 @@ export default function Studio({ api }) {
|
|||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return messages.map(msg => (
|
|
||||||
<FeedItem key={msg.id} msg={msg} />
|
return (
|
||||||
))
|
<>
|
||||||
|
{renderSummaryBlock()}
|
||||||
|
{activeMsgs.map(msg => (
|
||||||
|
<FeedItem key={msg.id} msg={msg} />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!loaded) {
|
if (!loaded) {
|
||||||
@@ -753,7 +935,7 @@ export default function Studio({ api }) {
|
|||||||
<div className="studio-feed" ref={feedRef}>
|
<div className="studio-feed" ref={feedRef}>
|
||||||
{renderMessages()}
|
{renderMessages()}
|
||||||
{(streaming || streamThinking || loading || streamToolCalls.length > 0) && (
|
{(streaming || streamThinking || loading || streamToolCalls.length > 0) && (
|
||||||
<StreamingItem content={streaming} thinking={streamThinking} toolCalls={streamToolCalls} />
|
<StreamingItem content={streaming} thinking={streamThinking} toolCalls={streamToolCalls} segments={streamSegments} />
|
||||||
)}
|
)}
|
||||||
<div ref={messagesEnd} style={{ height: '24px' }} />
|
<div ref={messagesEnd} style={{ height: '24px' }} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -211,8 +211,27 @@ const en = {
|
|||||||
resetConfirm: 'Are you sure? All preferences will be erased.',
|
resetConfirm: 'Are you sure? All preferences will be erased.',
|
||||||
resetDone: 'Settings reset.',
|
resetDone: 'Settings reset.',
|
||||||
applyStarship: 'Apply starship',
|
applyStarship: 'Apply starship',
|
||||||
|
apply: 'Apply',
|
||||||
|
remove: 'Remove',
|
||||||
starshipApplied: 'Starship theme applied! Restart your shell to see the result.',
|
starshipApplied: 'Starship theme applied! Restart your shell to see the result.',
|
||||||
starshipError: 'Failed to apply starship theme.',
|
starshipError: 'Failed to apply starship theme.',
|
||||||
|
systemConfig: 'System Configuration',
|
||||||
|
aiToolsConfig: 'Tools & Environments',
|
||||||
|
configureViaAI: 'Configure',
|
||||||
|
toolCrushDesc: 'Autonomous AI agent for code writing and refactoring.',
|
||||||
|
toolClaudeDesc: 'AI coding assistant by Anthropic.',
|
||||||
|
toolGhDesc: 'Command-line interface for GitHub.',
|
||||||
|
toolDockerDesc: 'Application containerization platform.',
|
||||||
|
toolGoDesc: 'Programming language and runtime environment.',
|
||||||
|
toolNodeDesc: 'JavaScript runtime and package manager.',
|
||||||
|
toolPythonDesc: 'Programming language, pip and uv manager.',
|
||||||
|
toolStarshipDesc: 'Modern and customizable shell prompt.',
|
||||||
|
systemUpdate: 'System Update',
|
||||||
|
systemUpdateDescSudo: 'Updates the system and all tools (sshpass, crush, claude, gh, etc.).',
|
||||||
|
systemUpdateDescNoSudo: 'Shows update commands to run manually.',
|
||||||
|
updateBtn: 'Update',
|
||||||
|
notInstalled: 'Not installed',
|
||||||
|
install: 'Install',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -211,8 +211,27 @@ const fr = {
|
|||||||
resetConfirm: '\u00cates-vous s\u00fbr ? Toutes les pr\u00e9f\u00e9rences seront effac\u00e9es.',
|
resetConfirm: '\u00cates-vous s\u00fbr ? Toutes les pr\u00e9f\u00e9rences seront effac\u00e9es.',
|
||||||
resetDone: 'Param\u00e8tres r\u00e9initialis\u00e9s.',
|
resetDone: 'Param\u00e8tres r\u00e9initialis\u00e9s.',
|
||||||
applyStarship: 'Appliquer starship',
|
applyStarship: 'Appliquer starship',
|
||||||
|
apply: 'Appliquer',
|
||||||
|
remove: 'Retirer',
|
||||||
starshipApplied: 'Th\u00e8me starship appliqu\u00e9 ! Red\u00e9marrez votre shell pour voir le r\u00e9sultat.',
|
starshipApplied: 'Th\u00e8me starship appliqu\u00e9 ! Red\u00e9marrez votre shell pour voir le r\u00e9sultat.',
|
||||||
starshipError: '\u00c9chec de l\u2019application du th\u00e8me starship.',
|
starshipError: '\u00c9chec de l\u2019application du th\u00e8me starship.',
|
||||||
|
systemConfig: 'Configuration Syst\u00e8me',
|
||||||
|
aiToolsConfig: 'Outils & Environnements',
|
||||||
|
configureViaAI: 'Configurer',
|
||||||
|
toolCrushDesc: 'Agent IA autonome pour l\u2019\u00e9criture et le refactoring de code.',
|
||||||
|
toolClaudeDesc: 'Assistant de codage IA par Anthropic.',
|
||||||
|
toolGhDesc: 'Interface en ligne de commande pour GitHub.',
|
||||||
|
toolDockerDesc: 'Plateforme de conteneurisation d\u2019applications.',
|
||||||
|
toolGoDesc: 'Langage de programmation et environnement d\u2019ex\u00e9cution.',
|
||||||
|
toolNodeDesc: 'Environnement d\u2019ex\u00e9cution JavaScript et gestionnaire de paquets.',
|
||||||
|
toolPythonDesc: 'Langage de programmation, pip et gestionnaire uv.',
|
||||||
|
toolStarshipDesc: 'Prompt shell moderne et personnalisable.',
|
||||||
|
systemUpdate: 'Mise à jour système',
|
||||||
|
systemUpdateDescSudo: 'Met à jour le système et tous les outils (sshpass, crush, claude, gh, etc.).',
|
||||||
|
systemUpdateDescNoSudo: 'Affiche les commandes de mise à jour à exécuter manuellement.',
|
||||||
|
updateBtn: 'Mettre à jour',
|
||||||
|
notInstalled: 'Non installé',
|
||||||
|
install: 'Installer',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -379,11 +379,11 @@ input::placeholder { color: var(--text-disabled); }
|
|||||||
.shell-menu-item-row { display: flex; align-items: center; }
|
.shell-menu-item-row { display: flex; align-items: center; }
|
||||||
.shell-menu-item-icon {
|
.shell-menu-item-icon {
|
||||||
display: flex; align-items: center; justify-content: center;
|
display: flex; align-items: center; justify-content: center;
|
||||||
width: 24px; height: 24px; border-radius: var(--radius);
|
width: 26px; height: 26px; border-radius: var(--radius);
|
||||||
background: transparent; border: none; color: var(--text-disabled);
|
background: var(--bg-card); border: 1px solid var(--border); color: var(--text-secondary);
|
||||||
cursor: pointer; transition: all 0.1s; flex-shrink: 0;
|
cursor: pointer; transition: all 0.1s; flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.shell-menu-item-icon:hover { background: var(--accent-bg); color: var(--accent); }
|
.shell-menu-item-icon:hover { background: var(--accent-bg); color: var(--accent); border-color: var(--accent-dim); }
|
||||||
.shell-menu-empty {
|
.shell-menu-empty {
|
||||||
font-size: 12px; color: var(--text-disabled); padding: 8px 10px;
|
font-size: 12px; color: var(--text-disabled); padding: 8px 10px;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
@@ -459,7 +459,7 @@ input::placeholder { color: var(--text-disabled); }
|
|||||||
.shell-ai-token-fill { height: 100%; background: var(--accent); border-radius: 2px; transition: width 0.4s, background 0.3s; }
|
.shell-ai-token-fill { height: 100%; background: var(--accent); border-radius: 2px; transition: width 0.4s, background 0.3s; }
|
||||||
.shell-ai-token-fill.warn { background: var(--warning); }
|
.shell-ai-token-fill.warn { background: var(--warning); }
|
||||||
.shell-ai-token-text { font-size: 10px; font-family: var(--font-mono); color: var(--text-tertiary); white-space: nowrap; }
|
.shell-ai-token-text { font-size: 10px; font-family: var(--font-mono); color: var(--text-tertiary); white-space: nowrap; }
|
||||||
.ai-panel-messages { flex: 1; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 8px; }
|
.ai-panel-messages { flex: 1; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 8px; scrollbar-width: thin; scrollbar-color: var(--border) transparent; }
|
||||||
.ai-message { padding: 8px 12px; border-radius: var(--radius); font-size: 13px; line-height: 1.5; word-break: break-word; }
|
.ai-message { padding: 8px 12px; border-radius: var(--radius); font-size: 13px; line-height: 1.5; word-break: break-word; }
|
||||||
.ai-message.user { background: var(--bg-elevated); border-left: 3px solid var(--accent-bright); color: var(--text-primary); }
|
.ai-message.user { background: var(--bg-elevated); border-left: 3px solid var(--accent-bright); color: var(--text-primary); }
|
||||||
.ai-message.user.analysis { border-left-color: var(--accent); background: color-mix(in srgb, var(--accent) 10%, var(--bg-elevated)); }
|
.ai-message.user.analysis { border-left-color: var(--accent); background: color-mix(in srgb, var(--accent) 10%, var(--bg-elevated)); }
|
||||||
@@ -511,7 +511,7 @@ input::placeholder { color: var(--text-disabled); }
|
|||||||
.shell-mermaid-loading { padding: 16px; text-align: center; color: var(--text-tertiary); font-size: 12px; }
|
.shell-mermaid-loading { padding: 16px; text-align: center; color: var(--text-tertiary); font-size: 12px; }
|
||||||
.shell-mermaid-error { padding: 10px 12px; color: var(--accent-bright); font-family: var(--font-mono); font-size: 12px; white-space: pre-wrap; }
|
.shell-mermaid-error { padding: 10px 12px; color: var(--accent-bright); font-family: var(--font-mono); font-size: 12px; white-space: pre-wrap; }
|
||||||
|
|
||||||
.ai-message table { width: 100%; border-collapse: collapse; margin: 6px 0; font-size: 12px; display: block; overflow-x: auto; }
|
.ai-message table { width: 100%; border-collapse: collapse; margin: 6px 0; font-size: 12px; display: block; overflow-x: auto; scrollbar-width: thin; scrollbar-color: var(--border) transparent; }
|
||||||
.ai-message thead, .ai-message tbody { display: table-row-group; }
|
.ai-message thead, .ai-message tbody { display: table-row-group; }
|
||||||
.ai-message th { background: var(--bg-surface); padding: 4px 8px; text-align: left; font-weight: 600; border: 1px solid var(--border); color: var(--text-secondary); white-space: nowrap; }
|
.ai-message th { background: var(--bg-surface); padding: 4px 8px; text-align: left; font-weight: 600; border: 1px solid var(--border); color: var(--text-secondary); white-space: nowrap; }
|
||||||
.ai-message td { padding: 3px 8px; border: 1px solid var(--border); color: var(--text-primary); white-space: nowrap; }
|
.ai-message td { padding: 3px 8px; border: 1px solid var(--border); color: var(--text-primary); white-space: nowrap; }
|
||||||
@@ -1024,8 +1024,10 @@ input::placeholder { color: var(--text-disabled); }
|
|||||||
.feed-content tr:nth-child(even) td { background: var(--bg-surface); }
|
.feed-content tr:nth-child(even) td { background: var(--bg-surface); }
|
||||||
.feed-content hr, .ai-message hr { border: none; border-top: 1px solid var(--border); margin: 12px 0; }
|
.feed-content hr, .ai-message hr { border: none; border-top: 1px solid var(--border); margin: 12px 0; }
|
||||||
.inline-code { background: var(--bg-input); padding: 2px 6px; border-radius: 4px; font-family: var(--font-mono); font-size: 13px; color: var(--accent-muted); }
|
.inline-code { background: var(--bg-input); padding: 2px 6px; border-radius: 4px; font-family: var(--font-mono); font-size: 13px; color: var(--accent-muted); }
|
||||||
.msg-h3 { font-size: 16px; font-weight: 700; color: var(--text-primary); margin: 10px 0 4px; display: block; }
|
.msg-h1 { font-size: 20px; font-weight: 800; color: var(--accent); margin: 16px 0 8px; display: block; }
|
||||||
.msg-h4 { font-size: 14px; font-weight: 700; color: var(--text-secondary); margin: 8px 0 3px; display: block; }
|
.msg-h2 { font-size: 17px; font-weight: 700; color: var(--text-primary); margin: 12px 0 6px; display: block; }
|
||||||
|
.msg-h3 { font-size: 15px; font-weight: 700; color: var(--text-primary); margin: 10px 0 4px; display: block; }
|
||||||
|
.msg-h4 { font-size: 13px; font-weight: 600; color: var(--text-secondary); margin: 8px 0 3px; display: block; }
|
||||||
.msg-bullet { display: block; padding-left: 4px; margin: 1px 0; color: var(--text-primary); }
|
.msg-bullet { display: block; padding-left: 4px; margin: 1px 0; color: var(--text-primary); }
|
||||||
.msg-step { display: flex; gap: 8px; align-items: baseline; margin: 1px 0; }
|
.msg-step { display: flex; gap: 8px; align-items: baseline; margin: 1px 0; }
|
||||||
.msg-step-num { color: var(--accent); font-weight: 700; font-family: var(--font-mono); font-size: 13px; flex-shrink: 0; min-width: 20px; }
|
.msg-step-num { color: var(--accent); font-weight: 700; font-family: var(--font-mono); font-size: 13px; flex-shrink: 0; min-width: 20px; }
|
||||||
@@ -1133,6 +1135,22 @@ input::placeholder { color: var(--text-disabled); }
|
|||||||
.feed-collapsed-count { font-size: 10px; color: var(--text-disabled); font-family: var(--font-mono); }
|
.feed-collapsed-count { font-size: 10px; color: var(--text-disabled); font-family: var(--font-mono); }
|
||||||
.feed-expanded-messages { animation: fadeIn 0.2s ease-out; }
|
.feed-expanded-messages { animation: fadeIn 0.2s ease-out; }
|
||||||
|
|
||||||
|
.feed-summary-block { margin: 4px 0; }
|
||||||
|
.feed-summary-header {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: var(--bg-surface); border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius); cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
.feed-summary-header:hover { background: var(--bg-hover); border-color: var(--accent-dim); }
|
||||||
|
.feed-summary-header svg { color: var(--accent); flex-shrink: 0; }
|
||||||
|
.feed-summary-text { font-size: 11px; color: var(--text-tertiary); flex: 1; font-weight: 600; }
|
||||||
|
.feed-summary-toggle { font-size: 10px; color: var(--accent); font-family: var(--font-mono); }
|
||||||
|
|
||||||
|
.skill-list-info { display: flex; flex-direction: column; flex: 1; min-width: 0; }
|
||||||
|
.skills-list { display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
|
||||||
/* ── Studio Tool Blocks ── */
|
/* ── Studio Tool Blocks ── */
|
||||||
.studio-tool-block {
|
.studio-tool-block {
|
||||||
background: var(--bg-surface);
|
background: var(--bg-surface);
|
||||||
@@ -1294,3 +1312,51 @@ input::placeholder { color: var(--text-disabled); }
|
|||||||
.shell-xterm-instance .xterm-link:hover {
|
.shell-xterm-instance .xterm-link:hover {
|
||||||
color: var(--accent-muted) !important;
|
color: var(--accent-muted) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.config-ai-tools-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-ai-tool-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 14px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
min-height: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-ai-tool-card:hover {
|
||||||
|
border-color: var(--accent-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-ai-tool-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-ai-tool-icon {
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-ai-tool-name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-ai-tool-desc {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
line-height: 1.4;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user