Compare commits
9 Commits
v0.3.5-bet
...
v0.4.0-bet
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0830e64ae6 | ||
|
|
9a218b1904 | ||
|
|
399b845e14 | ||
|
|
436d5c6149 | ||
|
|
5a9edc076e | ||
|
|
5bdc7a6429 | ||
|
|
5a0480bae0 | ||
|
|
80de4dd523 | ||
|
|
de52f4ebd6 |
@@ -187,6 +187,8 @@ func (s *Server) handleValidateProvider(w http.ResponseWriter, r *http.Request)
|
|||||||
switch body.Name {
|
switch body.Name {
|
||||||
case "minimax":
|
case "minimax":
|
||||||
baseURL = "https://api.minimax.io/v1"
|
baseURL = "https://api.minimax.io/v1"
|
||||||
|
case "mimo":
|
||||||
|
baseURL = "https://token-plan-ams.xiaomimimo.com/v1"
|
||||||
case "openai":
|
case "openai":
|
||||||
baseURL = "https://api.openai.com/v1"
|
baseURL = "https://api.openai.com/v1"
|
||||||
case "anthropic":
|
case "anthropic":
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -51,26 +52,31 @@ func (s *Server) handleShellChat(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
orb.SetSystemPrompt(s.buildShellSystemPromptV2(req))
|
orb.SetSystemPrompt(s.buildShellSystemPrompt(req))
|
||||||
|
orb.SetTools(s.shellAgentToolsJSON)
|
||||||
|
|
||||||
if req.Stream {
|
if req.Stream {
|
||||||
s.handleShellChatStreamV2(w, orb)
|
s.handleShellChatStream(w, orb)
|
||||||
} else {
|
} else {
|
||||||
s.handleShellChatNonStreamV2(w, orb)
|
s.handleShellChatNonStream(w, orb)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) buildShellSystemPromptV2(_ ShellChatRequest) string {
|
func (s *Server) buildShellSystemPrompt(_ ShellChatRequest) string {
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
|
|
||||||
sb.WriteString(`Tu es l'Analyste Système de Muyue. Tu es un expert en administration système et développement.
|
sb.WriteString(`Tu es l'Analyste Système de Muyue. Tu es un expert en administration système et développement.
|
||||||
Tu aides l'utilisateur à comprendre son système, diagnostiquer des problèmes, et optimiser son environnement.
|
Tu aides l'utilisateur à comprendre son système, diagnostiquer des problèmes, et optimiser son environnement.
|
||||||
|
|
||||||
RÈGLES STRICTES:
|
OUTILS DISPONIBLES:
|
||||||
- Tu ne peux JAMAIS exécuter de commande ou de code
|
- terminal: Exécute des commandes shell sur le système local et retourne le résultat
|
||||||
- Tu ne peux que analyser, expliquer, et proposer des solutions
|
|
||||||
- Quand tu proposes du code ou des commandes, mets-les dans des blocs de code markdown avec le langage spécifié
|
RÈGLES:
|
||||||
- L'utilisateur pourra les copier ou les envoyer directement au terminal depuis les boutons
|
- Utilise l'outil terminal pour exécuter des commandes quand c'est nécessaire
|
||||||
|
- Analyse les résultats et explique-les clairement
|
||||||
|
- Formate tes réponses en markdown avec des blocs de code quand approprié
|
||||||
|
- Sois concis et technique
|
||||||
|
- Quand tu proposes des commandes alternatives, utilise des blocs de code markdown
|
||||||
|
|
||||||
`)
|
`)
|
||||||
|
|
||||||
@@ -89,43 +95,42 @@ RÈGLES STRICTES:
|
|||||||
return sb.String()
|
return sb.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleShellChatStreamV2(w http.ResponseWriter, orb *orchestrator.Orchestrator) {
|
func (s *Server) handleShellChatStream(w http.ResponseWriter, orb *orchestrator.Orchestrator) {
|
||||||
SetupSSEHeaders(w)
|
SetupSSEHeaders(w)
|
||||||
flusher, canFlush := w.(http.Flusher)
|
flusher, canFlush := w.(http.Flusher)
|
||||||
sseWriter := NewSSEWriter(w)
|
sseWriter := NewSSEWriter(w)
|
||||||
|
|
||||||
// Rebuild history into orchestrator
|
ctx := context.Background()
|
||||||
history := s.shellConvStore.Get()
|
messages := s.buildShellContextMessages()
|
||||||
for _, m := range history[:len(history)-1] { // all except last user msg
|
|
||||||
if m.Role == "system" {
|
engine := NewChatEngine(orb, s.shellAgentRegistry, s.shellAgentToolsJSON)
|
||||||
continue
|
engine.OnChunk(func(data map[string]interface{}) {
|
||||||
|
if data == nil {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
// Pre-load orchestrator history
|
sseWriter.Write(data)
|
||||||
orb.AppendHistory(orchestrator.Message{Role: m.Role, Content: m.Content})
|
|
||||||
}
|
|
||||||
|
|
||||||
lastUserMsg := history[len(history)-1].Content
|
|
||||||
|
|
||||||
var finalContent string
|
|
||||||
result, err := orb.SendStream(lastUserMsg, func(chunk string) {
|
|
||||||
finalContent = chunk
|
|
||||||
sseWriter.Write(map[string]interface{}{"content": chunk})
|
|
||||||
if canFlush {
|
if canFlush {
|
||||||
flusher.Flush()
|
flusher.Flush()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
finalContent, allToolCalls, allToolResults, err := engine.RunWithTools(ctx, messages)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
sseWriter.Write(map[string]interface{}{"error": err.Error()})
|
sseWriter.Write(map[string]interface{}{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
content := result
|
storeContent := finalContent
|
||||||
if content == "" {
|
if len(allToolCalls) > 0 {
|
||||||
content = finalContent
|
storeObj := map[string]interface{}{
|
||||||
|
"content": storeContent,
|
||||||
|
"tool_calls": allToolCalls,
|
||||||
|
"tool_results": allToolResults,
|
||||||
|
}
|
||||||
|
storeJSON, _ := json.Marshal(storeObj)
|
||||||
|
storeContent = string(storeJSON)
|
||||||
}
|
}
|
||||||
|
s.shellConvStore.Add("assistant", storeContent)
|
||||||
s.shellConvStore.Add("assistant", cleanThinkingTags(content))
|
|
||||||
|
|
||||||
sseWriter.Write(map[string]interface{}{
|
sseWriter.Write(map[string]interface{}{
|
||||||
"done": "true",
|
"done": "true",
|
||||||
@@ -133,30 +138,62 @@ func (s *Server) handleShellChatStreamV2(w http.ResponseWriter, orb *orchestrato
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleShellChatNonStreamV2(w http.ResponseWriter, orb *orchestrator.Orchestrator) {
|
func (s *Server) handleShellChatNonStream(w http.ResponseWriter, orb *orchestrator.Orchestrator) {
|
||||||
history := s.shellConvStore.Get()
|
ctx := context.Background()
|
||||||
for _, m := range history[:len(history)-1] {
|
messages := s.buildShellContextMessages()
|
||||||
if m.Role == "system" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
orb.AppendHistory(orchestrator.Message{Role: m.Role, Content: m.Content})
|
|
||||||
}
|
|
||||||
|
|
||||||
lastUserMsg := history[len(history)-1].Content
|
engine := NewChatEngine(orb, s.shellAgentRegistry, s.shellAgentToolsJSON)
|
||||||
|
finalContent, err := engine.RunNonStream(ctx, messages)
|
||||||
result, err := orb.Send(lastUserMsg)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
s.shellConvStore.Add("assistant", cleanThinkingTags(result))
|
s.shellConvStore.Add("assistant", finalContent)
|
||||||
writeJSON(w, map[string]interface{}{
|
writeJSON(w, map[string]interface{}{
|
||||||
"content": result,
|
"content": finalContent,
|
||||||
"tokens": s.shellConvStore.ApproxTokens(),
|
"tokens": s.shellConvStore.ApproxTokens(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) buildShellContextMessages() []orchestrator.Message {
|
||||||
|
history := s.shellConvStore.Get()
|
||||||
|
start := 0
|
||||||
|
const shellContextWindow = 20
|
||||||
|
if len(history) > shellContextWindow {
|
||||||
|
start = len(history) - shellContextWindow
|
||||||
|
}
|
||||||
|
|
||||||
|
messages := make([]orchestrator.Message, 0, len(history[start:]))
|
||||||
|
|
||||||
|
for _, m := range history[start:] {
|
||||||
|
content := m.Content
|
||||||
|
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
|
||||||
|
}
|
||||||
|
messages = append(messages, orchestrator.Message{
|
||||||
|
Role: role,
|
||||||
|
Content: content,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return messages
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) handleShellChatHistory(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleShellChatHistory(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)
|
||||||
|
|||||||
@@ -13,14 +13,16 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Server struct {
|
type Server struct {
|
||||||
config *config.MuyueConfig
|
config *config.MuyueConfig
|
||||||
scanResult *scanner.ScanResult
|
scanResult *scanner.ScanResult
|
||||||
mux *http.ServeMux
|
mux *http.ServeMux
|
||||||
convStore *ConversationStore
|
convStore *ConversationStore
|
||||||
shellConvStore *ShellConvStore
|
shellConvStore *ShellConvStore
|
||||||
agentRegistry *agent.Registry
|
agentRegistry *agent.Registry
|
||||||
agentToolsJSON json.RawMessage
|
agentToolsJSON json.RawMessage
|
||||||
workflowEngine *workflow.Engine
|
shellAgentRegistry *agent.Registry
|
||||||
|
shellAgentToolsJSON json.RawMessage
|
||||||
|
workflowEngine *workflow.Engine
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewServer(cfg *config.MuyueConfig) *Server {
|
func NewServer(cfg *config.MuyueConfig) *Server {
|
||||||
@@ -52,6 +54,14 @@ func NewServer(cfg *config.MuyueConfig) *Server {
|
|||||||
tools := s.agentRegistry.OpenAITools()
|
tools := s.agentRegistry.OpenAITools()
|
||||||
toolsJSON, _ := json.Marshal(tools)
|
toolsJSON, _ := json.Marshal(tools)
|
||||||
s.agentToolsJSON = json.RawMessage(toolsJSON)
|
s.agentToolsJSON = json.RawMessage(toolsJSON)
|
||||||
|
|
||||||
|
s.shellAgentRegistry = agent.NewRegistry()
|
||||||
|
terminalTool, _ := agent.NewTerminalTool()
|
||||||
|
s.shellAgentRegistry.Register(terminalTool)
|
||||||
|
shellTools := s.shellAgentRegistry.OpenAITools()
|
||||||
|
shellToolsJSON, _ := json.Marshal(shellTools)
|
||||||
|
s.shellAgentToolsJSON = json.RawMessage(shellToolsJSON)
|
||||||
|
|
||||||
s.workflowEngine, _ = workflow.NewEngine(s.agentRegistry)
|
s.workflowEngine, _ = workflow.NewEngine(s.agentRegistry)
|
||||||
s.routes()
|
s.routes()
|
||||||
return s
|
return s
|
||||||
|
|||||||
@@ -128,6 +128,22 @@ var DEFAULT_TERMINAL_THEMES = map[string]TerminalTheme{
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func migrateProviders(cfg *MuyueConfig) {
|
||||||
|
defaults := Default().AI.Providers
|
||||||
|
for _, dp := range defaults {
|
||||||
|
found := false
|
||||||
|
for _, p := range cfg.AI.Providers {
|
||||||
|
if p.Name == dp.Name {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
cfg.AI.Providers = append(cfg.AI.Providers, dp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func GetTerminalTheme(name string) TerminalTheme {
|
func GetTerminalTheme(name string) TerminalTheme {
|
||||||
if theme, ok := DEFAULT_TERMINAL_THEMES[name]; ok {
|
if theme, ok := DEFAULT_TERMINAL_THEMES[name]; ok {
|
||||||
return theme
|
return theme
|
||||||
@@ -206,6 +222,8 @@ func Load() (*MuyueConfig, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
migrateProviders(&cfg)
|
||||||
|
|
||||||
return &cfg, nil
|
return &cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -303,6 +321,7 @@ func Default() *MuyueConfig {
|
|||||||
|
|
||||||
cfg.Terminal.CustomPrompt = true
|
cfg.Terminal.CustomPrompt = true
|
||||||
cfg.Terminal.PromptTheme = "zerotwo"
|
cfg.Terminal.PromptTheme = "zerotwo"
|
||||||
|
cfg.Terminal.FontSize = 14
|
||||||
|
|
||||||
return cfg
|
return cfg
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
Name = "muyue"
|
Name = "muyue"
|
||||||
Version = "0.3.5"
|
Version = "0.4.0"
|
||||||
Author = "La Légion de Muyue"
|
Author = "La Légion de Muyue"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
1
web/.npmrc
Normal file
1
web/.npmrc
Normal file
@@ -0,0 +1 @@
|
|||||||
|
legacy-peer-deps=true
|
||||||
48
web/package-lock.json
generated
48
web/package-lock.json
generated
@@ -7,8 +7,12 @@
|
|||||||
"name": "muyue-web",
|
"name": "muyue-web",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@xterm/addon-fit": "^0.11.0",
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
|
"@xterm/addon-image": "^0.10.0-beta.203",
|
||||||
|
"@xterm/addon-search": "^0.17.0-beta.203",
|
||||||
|
"@xterm/addon-unicode11": "^0.10.0-beta.203",
|
||||||
"@xterm/addon-web-links": "^0.12.0",
|
"@xterm/addon-web-links": "^0.12.0",
|
||||||
"@xterm/xterm": "^6.0.0",
|
"@xterm/addon-webgl": "^0.20.0-beta.202",
|
||||||
|
"@xterm/xterm": "^6.1.0-beta.203",
|
||||||
"lucide-react": "^1.8.0",
|
"lucide-react": "^1.8.0",
|
||||||
"react": "^19.2.5",
|
"react": "^19.2.5",
|
||||||
"react-dom": "^19.2.5"
|
"react-dom": "^19.2.5"
|
||||||
@@ -406,16 +410,52 @@
|
|||||||
"integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==",
|
"integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@xterm/addon-image": {
|
||||||
|
"version": "0.10.0-beta.203",
|
||||||
|
"resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.203.tgz",
|
||||||
|
"integrity": "sha512-1hRy7/jYCYvUhc6GYu177EdsW44QQQHsq71Odvo6cEhHKEEoqFsrOnLpe9WuNWZXgqpCwy2Cnp6FepHm960Eiw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@xterm/xterm": "^6.1.0-beta.203"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@xterm/addon-search": {
|
||||||
|
"version": "0.17.0-beta.203",
|
||||||
|
"resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.17.0-beta.203.tgz",
|
||||||
|
"integrity": "sha512-agxzh30h4L82kjGlTwWEsaXnXzOuMIAm80+zcNElFL/hHuT/nLvcwRng+s7RzOWNNLG3pB4jbTHqbBaM+nW8mg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@xterm/xterm": "^6.1.0-beta.203"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@xterm/addon-unicode11": {
|
||||||
|
"version": "0.10.0-beta.203",
|
||||||
|
"resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.10.0-beta.203.tgz",
|
||||||
|
"integrity": "sha512-KqMOqqpeEPQw5TQLb8jNHPESjZSwenFzhBPNA1g2zcPY5JtZ15pFzzoFxXdzS5LYmdYxexpd8s2ianf8WmQKyg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@xterm/xterm": "^6.1.0-beta.203"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@xterm/addon-web-links": {
|
"node_modules/@xterm/addon-web-links": {
|
||||||
"version": "0.12.0",
|
"version": "0.12.0",
|
||||||
"resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.12.0.tgz",
|
"resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.12.0.tgz",
|
||||||
"integrity": "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==",
|
"integrity": "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@xterm/addon-webgl": {
|
||||||
|
"version": "0.20.0-beta.202",
|
||||||
|
"resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.202.tgz",
|
||||||
|
"integrity": "sha512-GCh0QlUv77XX8cJt8/7AVdDUNFpa1f6MGX/skhciu5ZRK88hR1m8T+8MZ3FYfddLV6phY0ksmiO9ErC0R+7G/A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@xterm/xterm": "^6.1.0-beta.203"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@xterm/xterm": {
|
"node_modules/@xterm/xterm": {
|
||||||
"version": "6.0.0",
|
"version": "6.1.0-beta.203",
|
||||||
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.203.tgz",
|
||||||
"integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==",
|
"integrity": "sha512-Ctqf05M6fPWZkfKxC4hy2+PP5P2BlVnJLbIsXZMpkCz/MjJvcf5OwwsGkq+nzhFDuojSX+rc2RxIetLONUBGqw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"addons/*"
|
"addons/*"
|
||||||
|
|||||||
@@ -9,8 +9,12 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@xterm/addon-fit": "^0.11.0",
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
|
"@xterm/addon-image": "^0.10.0-beta.203",
|
||||||
|
"@xterm/addon-search": "^0.17.0-beta.203",
|
||||||
|
"@xterm/addon-unicode11": "^0.10.0-beta.203",
|
||||||
"@xterm/addon-web-links": "^0.12.0",
|
"@xterm/addon-web-links": "^0.12.0",
|
||||||
"@xterm/xterm": "^6.0.0",
|
"@xterm/addon-webgl": "^0.20.0-beta.202",
|
||||||
|
"@xterm/xterm": "^6.1.0-beta.203",
|
||||||
"lucide-react": "^1.8.0",
|
"lucide-react": "^1.8.0",
|
||||||
"react": "^19.2.5",
|
"react": "^19.2.5",
|
||||||
"react-dom": "^19.2.5"
|
"react-dom": "^19.2.5"
|
||||||
|
|||||||
@@ -141,7 +141,11 @@ const api = {
|
|||||||
if (data.error) { reject(new Error(data.error)); return }
|
if (data.error) { reject(new Error(data.error)); return }
|
||||||
if (data.done) { resolve({ content: full, tokens: data.tokens }); return }
|
if (data.done) { resolve({ content: full, tokens: data.tokens }); return }
|
||||||
if (data.content) {
|
if (data.content) {
|
||||||
full = data.content
|
full += data.content
|
||||||
|
if (onChunk) onChunk(full, data)
|
||||||
|
} else if (data.tool_call || data.tool_result) {
|
||||||
|
if (onChunk) onChunk(full, data)
|
||||||
|
} else if (data.thinking !== undefined || data.thinking_end) {
|
||||||
if (onChunk) onChunk(full, data)
|
if (onChunk) onChunk(full, data)
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|||||||
@@ -94,6 +94,10 @@ export default function App() {
|
|||||||
shell: [
|
shell: [
|
||||||
{ keys: `${layout.keys.ctrl}+${layout.keys.shift}+C`, desc: t('statusbar.copy') },
|
{ keys: `${layout.keys.ctrl}+${layout.keys.shift}+C`, desc: t('statusbar.copy') },
|
||||||
{ keys: `${layout.keys.ctrl}+${layout.keys.shift}+V`, desc: t('statusbar.paste') },
|
{ keys: `${layout.keys.ctrl}+${layout.keys.shift}+V`, desc: t('statusbar.paste') },
|
||||||
|
{ keys: `${layout.keys.ctrl}+F`, desc: t('statusbar.search') },
|
||||||
|
{ keys: `${layout.keys.ctrl}+/Ctrl−`, desc: t('statusbar.zoom') },
|
||||||
|
{ keys: `Alt+1-7`, desc: t('statusbar.switchTab') },
|
||||||
|
{ keys: `${layout.keys.shift}+Tab`, desc: t('statusbar.nextTab') },
|
||||||
{ keys: layout.keys.enter, desc: t('statusbar.runCommand') },
|
{ keys: layout.keys.enter, desc: t('statusbar.runCommand') },
|
||||||
{ keys: `${layout.keys.up}/${layout.keys.down}`, desc: t('statusbar.commandHistory') },
|
{ keys: `${layout.keys.up}/${layout.keys.down}`, desc: t('statusbar.commandHistory') },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -101,9 +101,9 @@ export default function Config({ api }) {
|
|||||||
...prev,
|
...prev,
|
||||||
[p.name]: {
|
[p.name]: {
|
||||||
name: p.name,
|
name: p.name,
|
||||||
api_key: p.apiKey || '',
|
api_key: p.api_key || '',
|
||||||
model: p.model || '',
|
model: p.model || '',
|
||||||
base_url: p.baseURL || '',
|
base_url: p.base_url || '',
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
setEditProvider(p.name)
|
setEditProvider(p.name)
|
||||||
@@ -314,7 +314,7 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm
|
|||||||
const validateKey = async (p) => {
|
const validateKey = async (p) => {
|
||||||
setValidating(p.name)
|
setValidating(p.name)
|
||||||
try {
|
try {
|
||||||
await api.validateProvider({ name: p.name, api_key: p.apiKey, model: p.model, base_url: p.baseURL || '' })
|
await api.validateProvider({ name: p.name, api_key: p.api_key, model: p.model, base_url: p.base_url || '' })
|
||||||
setKeyStatus(prev => ({ ...prev, [p.name]: { valid: true, checked: true } }))
|
setKeyStatus(prev => ({ ...prev, [p.name]: { valid: true, checked: true } }))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setKeyStatus(prev => ({ ...prev, [p.name]: { valid: false, checked: true, error: err.message || 'Clé invalide' } }))
|
setKeyStatus(prev => ({ ...prev, [p.name]: { valid: false, checked: true, error: err.message || 'Clé invalide' } }))
|
||||||
@@ -324,9 +324,9 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
providers.forEach(p => {
|
providers.forEach(p => {
|
||||||
if (p.apiKey && !keyStatus[p.name]) {
|
if (p.api_key && !keyStatus[p.name]) {
|
||||||
validateKey(p)
|
validateKey(p)
|
||||||
} else if (!p.apiKey) {
|
} else if (!p.api_key) {
|
||||||
setKeyStatus(prev => ({ ...prev, [p.name]: { valid: false, checked: true, error: 'Aucune clé' } }))
|
setKeyStatus(prev => ({ ...prev, [p.name]: { valid: false, checked: true, error: 'Aucune clé' } }))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -370,7 +370,7 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm
|
|||||||
<input
|
<input
|
||||||
className="config-form-input"
|
className="config-form-input"
|
||||||
type="password"
|
type="password"
|
||||||
placeholder={p.apiKey ? '••••••••' : t('config.tokenPlaceholder')}
|
placeholder={p.api_key ? '••••••••' : t('config.tokenPlaceholder')}
|
||||||
value={isEditing ? (providerForm[p.name]?.api_key || '') : ''}
|
value={isEditing ? (providerForm[p.name]?.api_key || '') : ''}
|
||||||
onChange={e => {
|
onChange={e => {
|
||||||
if (!isEditing) openProviderEdit(p)
|
if (!isEditing) openProviderEdit(p)
|
||||||
|
|||||||
@@ -1,6 +1,77 @@
|
|||||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
|
||||||
|
import { Terminal as XTerm } from '@xterm/xterm'
|
||||||
|
import { FitAddon } from '@xterm/addon-fit'
|
||||||
|
import { WebLinksAddon } from '@xterm/addon-web-links'
|
||||||
|
import { WebglAddon } from '@xterm/addon-webgl'
|
||||||
|
import { SearchAddon } from '@xterm/addon-search'
|
||||||
|
import { Unicode11Addon } from '@xterm/addon-unicode11'
|
||||||
|
import { ImageAddon } from '@xterm/addon-image'
|
||||||
|
import { Plus, X, Monitor, Globe, ChevronDown, Pencil, Trash2, Search, Copy, Send, Eye, Bot } from 'lucide-react'
|
||||||
|
import '@xterm/xterm/css/xterm.css'
|
||||||
import { useI18n } from '../i18n'
|
import { useI18n } from '../i18n'
|
||||||
import { Monitor } from 'lucide-react'
|
|
||||||
|
const AI_TAB_ID = 0
|
||||||
|
const MAX_TABS = 7
|
||||||
|
const SHELL_MAX_TOKENS = 100000
|
||||||
|
const TABS_STORAGE_KEY = 'muyue_shell_tabs'
|
||||||
|
const TERMINAL_BUFFER_KEY = 'muyue_terminal_buffers'
|
||||||
|
|
||||||
|
function renderContent(text) {
|
||||||
|
const parts = []
|
||||||
|
const codeBlockRegex = /(```[\s\S]*?```)/g
|
||||||
|
let match
|
||||||
|
let lastIndex = 0
|
||||||
|
while ((match = codeBlockRegex.exec(text)) !== null) {
|
||||||
|
if (match.index > lastIndex) {
|
||||||
|
parts.push({ type: 'text', content: text.slice(lastIndex, match.index) })
|
||||||
|
}
|
||||||
|
const full = match[1]
|
||||||
|
const firstNewline = full.indexOf('\n')
|
||||||
|
const lang = firstNewline > -1 ? full.slice(3, firstNewline).trim() : ''
|
||||||
|
const code = firstNewline > -1 ? full.slice(firstNewline + 1, -3) : full.slice(3, -3)
|
||||||
|
parts.push({ type: 'code', lang, content: code })
|
||||||
|
lastIndex = match.index + full.length
|
||||||
|
}
|
||||||
|
if (lastIndex < text.length) {
|
||||||
|
const remaining = text.slice(lastIndex)
|
||||||
|
const openBlock = remaining.match(/```(\w*)\n?([\s\S]*)$/)
|
||||||
|
if (openBlock) {
|
||||||
|
if (openBlock.index > 0) {
|
||||||
|
parts.push({ type: 'text', content: remaining.slice(0, openBlock.index) })
|
||||||
|
}
|
||||||
|
parts.push({ type: 'code', lang: openBlock[1] || '', content: openBlock[2] || '' })
|
||||||
|
} else {
|
||||||
|
parts.push({ type: 'text', content: remaining })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parts
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatText(text) {
|
||||||
|
let html = text
|
||||||
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||||
|
|
||||||
|
html = html
|
||||||
|
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||||
|
.replace(/`([^`]+)`/g, '<code class="inline-code">$1</code>')
|
||||||
|
.replace(/^### (.+)$/gm, '<h4 class="msg-h4">$1</h4>')
|
||||||
|
.replace(/^## (.+)$/gm, '<h3 class="msg-h3">$1</h3>')
|
||||||
|
.replace(/^# (.+)$/gm, '<h2 class="msg-h2">$1</h2>')
|
||||||
|
.replace(/^\s*[-*] (.+)$/gm, '<div class="msg-bullet">• $1</div>')
|
||||||
|
.replace(/^\s*(\d+)[.)] (.+)$/gm, '<div class="msg-step"><span class="msg-step-num">$1</span> $2</div>')
|
||||||
|
.replace(/\n/g, '<br/>')
|
||||||
|
|
||||||
|
html = html
|
||||||
|
.replace(/<br\/>\s*<br\/>/g, '<br/>')
|
||||||
|
.replace(/<br\/>\s*(<h[234]|<div class="msg-)/g, '$1')
|
||||||
|
.replace(/(<\/h[234]|<\/div>)\s*<br\/>/g, '$1')
|
||||||
|
.replace(/\s+on\w+=["'][^"']*["']/gi, '')
|
||||||
|
.replace(/javascript:/gi, '')
|
||||||
|
.replace(/data:/gi, '')
|
||||||
|
|
||||||
|
return html
|
||||||
|
}
|
||||||
|
|
||||||
// === Style thème système pour xterm ===
|
// === Style thème système pour xterm ===
|
||||||
function getCSSVariable(varName) {
|
function getCSSVariable(varName) {
|
||||||
if (typeof document === 'undefined') return null;
|
if (typeof document === 'undefined') return null;
|
||||||
@@ -132,7 +203,8 @@ function createTerminal(container, settings = {}) {
|
|||||||
const theme = getTheme(settings.theme || 'system')
|
const theme = getTheme(settings.theme || 'system')
|
||||||
const term = new XTerm({
|
const term = new XTerm({
|
||||||
cursorBlink: true,
|
cursorBlink: true,
|
||||||
fontSize: settings.fontSize || 12,
|
allowProposedApi: true,
|
||||||
|
fontSize: settings.fontSize || 14,
|
||||||
fontFamily: settings.fontFamily || "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', 'Menlo', monospace",
|
fontFamily: settings.fontFamily || "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', 'Menlo', monospace",
|
||||||
theme,
|
theme,
|
||||||
allowTransparency: false,
|
allowTransparency: false,
|
||||||
@@ -141,8 +213,25 @@ function createTerminal(container, settings = {}) {
|
|||||||
|
|
||||||
const fitAddon = new FitAddon()
|
const fitAddon = new FitAddon()
|
||||||
const webLinksAddon = new WebLinksAddon()
|
const webLinksAddon = new WebLinksAddon()
|
||||||
|
const searchAddon = new SearchAddon()
|
||||||
|
const unicode11Addon = new Unicode11Addon()
|
||||||
|
const imageAddon = new ImageAddon()
|
||||||
|
|
||||||
term.loadAddon(fitAddon)
|
term.loadAddon(fitAddon)
|
||||||
term.loadAddon(webLinksAddon)
|
term.loadAddon(webLinksAddon)
|
||||||
|
term.loadAddon(searchAddon)
|
||||||
|
term.loadAddon(unicode11Addon)
|
||||||
|
term.loadAddon(imageAddon)
|
||||||
|
|
||||||
|
term.unicode.activeVersion = '11'
|
||||||
|
|
||||||
|
try {
|
||||||
|
const webglAddon = new WebglAddon()
|
||||||
|
webglAddon.onContextLoss(() => { webglAddon.dispose() })
|
||||||
|
term.loadAddon(webglAddon)
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[Shell] WebGL renderer not available, using DOM fallback:', e)
|
||||||
|
}
|
||||||
|
|
||||||
term.attachCustomKeyEventHandler((e) => {
|
term.attachCustomKeyEventHandler((e) => {
|
||||||
if (e.type !== 'keydown') return true
|
if (e.type !== 'keydown') return true
|
||||||
@@ -166,13 +255,34 @@ function createTerminal(container, settings = {}) {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (ctrl && (e.key === '=' || e.key === '+')) {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
window.dispatchEvent(new CustomEvent('shell-zoom', { detail: 1 }))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ctrl && e.key === '-') {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
window.dispatchEvent(new CustomEvent('shell-zoom', { detail: -1 }))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ctrl && e.key === '0') {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
window.dispatchEvent(new CustomEvent('shell-zoom', { detail: 0 }))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
term.open(container)
|
term.open(container)
|
||||||
fitAddon.fit()
|
fitAddon.fit()
|
||||||
|
|
||||||
return { term, fitAddon }
|
return { term, fitAddon, searchAddon }
|
||||||
}
|
}
|
||||||
|
|
||||||
function connectWebSocket(term, fitAddon, initPayload, onStateChange, onFirstMessage) {
|
function connectWebSocket(term, fitAddon, initPayload, onStateChange, onFirstMessage) {
|
||||||
@@ -237,13 +347,10 @@ function connectWebSocket(term, fitAddon, initPayload, onStateChange, onFirstMes
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Shell({ api }) {
|
export default function Shell({ api }) {
|
||||||
const MAX_TABS = 7
|
|
||||||
const TABS_STORAGE_KEY = 'muyue_shell_tabs'
|
|
||||||
const TERMINAL_BUFFER_KEY = 'muyue_terminal_buffers'
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const tabsRef = useRef({})
|
const tabsRef = useRef({})
|
||||||
const nextIdRef = useRef(1)
|
const nextIdRef = useRef(1)
|
||||||
const settingsRef = useRef({ fontSize: 12, fontFamily: "'JetBrains Mono', 'Fira Code', monospace", theme: 'system' })
|
const settingsRef = useRef({ fontSize: 14, fontFamily: "'JetBrains Mono', 'Fira Code', monospace", theme: 'system' })
|
||||||
const pendingCommandsRef = useRef({})
|
const pendingCommandsRef = useRef({})
|
||||||
|
|
||||||
const [tabs, setTabs] = useState(() => {
|
const [tabs, setTabs] = useState(() => {
|
||||||
@@ -292,13 +399,45 @@ export default function Shell({ api }) {
|
|||||||
const [editingTab, setEditingTab] = useState(null)
|
const [editingTab, setEditingTab] = useState(null)
|
||||||
const [editName, setEditName] = useState('')
|
const [editName, setEditName] = useState('')
|
||||||
const [terminalSettings, setTerminalSettings] = useState({
|
const [terminalSettings, setTerminalSettings] = useState({
|
||||||
fontSize: 12,
|
fontSize: 14,
|
||||||
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', 'Menlo', monospace",
|
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', 'Menlo', monospace",
|
||||||
theme: 'system',
|
theme: 'system',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const [showSearch, setShowSearch] = useState(false)
|
||||||
|
const [searchText, setSearchText] = useState('')
|
||||||
|
const searchInputRef = useRef(null)
|
||||||
|
const searchDecorationsRef = useRef(null)
|
||||||
|
const [zoomLevel, setZoomLevel] = useState(0)
|
||||||
|
const baseFontSizeRef = useRef(12)
|
||||||
|
|
||||||
useEffect(() => { settingsRef.current = terminalSettings }, [terminalSettings])
|
useEffect(() => { settingsRef.current = terminalSettings }, [terminalSettings])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
baseFontSizeRef.current = terminalSettings.fontSize || 14
|
||||||
|
}, [terminalSettings.fontSize])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e) => {
|
||||||
|
const direction = e.detail
|
||||||
|
setZoomLevel(prev => {
|
||||||
|
let next
|
||||||
|
if (direction === 0) next = 0
|
||||||
|
else next = Math.max(-8, Math.min(10, prev + direction))
|
||||||
|
const newSize = baseFontSizeRef.current + next * 2
|
||||||
|
for (const entry of Object.values(tabsRef.current)) {
|
||||||
|
if (entry.term && !entry.term._disposed) {
|
||||||
|
entry.term.options.fontSize = newSize
|
||||||
|
try { entry.fitAddon.fit() } catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
window.addEventListener('shell-zoom', handler)
|
||||||
|
return () => window.removeEventListener('shell-zoom', handler)
|
||||||
|
}, [])
|
||||||
|
|
||||||
const [sshForm, setSshForm] = useState({
|
const [sshForm, setSshForm] = useState({
|
||||||
name: '', host: '', port: 22, user: '', key_path: '',
|
name: '', host: '', port: 22, user: '', key_path: '',
|
||||||
})
|
})
|
||||||
@@ -357,7 +496,7 @@ export default function Shell({ api }) {
|
|||||||
api.getConfig().then(d => {
|
api.getConfig().then(d => {
|
||||||
if (d.terminal) {
|
if (d.terminal) {
|
||||||
setTerminalSettings({
|
setTerminalSettings({
|
||||||
fontSize: d.terminal.font_size || 12,
|
fontSize: d.terminal.font_size || 14,
|
||||||
fontFamily: d.terminal.font_family || "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', 'Menlo', monospace",
|
fontFamily: d.terminal.font_family || "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', 'Menlo', monospace",
|
||||||
theme: d.terminal.theme || 'system',
|
theme: d.terminal.theme || 'system',
|
||||||
})
|
})
|
||||||
@@ -372,8 +511,9 @@ export default function Shell({ api }) {
|
|||||||
if (!container) return
|
if (!container) return
|
||||||
|
|
||||||
const s = settingsRef.current
|
const s = settingsRef.current
|
||||||
const { term, fitAddon } = createTerminal(container, {
|
const effectiveFontSize = s.fontSize + zoomLevel * 2
|
||||||
fontSize: s.fontSize,
|
const { term, fitAddon, searchAddon } = createTerminal(container, {
|
||||||
|
fontSize: effectiveFontSize,
|
||||||
fontFamily: s.fontFamily,
|
fontFamily: s.fontFamily,
|
||||||
theme: s.theme,
|
theme: s.theme,
|
||||||
})
|
})
|
||||||
@@ -464,7 +604,7 @@ export default function Shell({ api }) {
|
|||||||
const bufferSaveInterval = setInterval(() => { if (!disposed) saveBuffer() }, 5000)
|
const bufferSaveInterval = setInterval(() => { if (!disposed) saveBuffer() }, 5000)
|
||||||
|
|
||||||
console.log(`[Shell] initTerminal tab=${tabId} type=${tab.type} name="${tab.name}" shell="${tab.shell || '(default)'}"`)
|
console.log(`[Shell] initTerminal tab=${tabId} type=${tab.type} name="${tab.name}" shell="${tab.shell || '(default)'}"`)
|
||||||
tabsRef.current[tabId] = { term, fitAddon, ws, resizeObserver, onResize, bufferSaveInterval, saveBuffer, disposed: () => disposed }
|
tabsRef.current[tabId] = { term, fitAddon, searchAddon, ws, resizeObserver, onResize, bufferSaveInterval, saveBuffer, disposed: () => disposed }
|
||||||
tabsRef.current[tabId]._markDisposed = () => { disposed = true }
|
tabsRef.current[tabId]._markDisposed = () => { disposed = true }
|
||||||
console.log(`[Shell] initTerminal tab=${tabId} done, tabsRef keys:`, Object.keys(tabsRef.current))
|
console.log(`[Shell] initTerminal tab=${tabId} done, tabsRef keys:`, Object.keys(tabsRef.current))
|
||||||
|
|
||||||
@@ -567,6 +707,12 @@ export default function Shell({ api }) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const tab of tabs) {
|
||||||
|
if (!tabsRef.current[tab.id]) {
|
||||||
|
tryInitTab(tab, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const wrapper = document.querySelector('.shell-layout')?.parentElement
|
const wrapper = document.querySelector('.shell-layout')?.parentElement
|
||||||
let observer
|
let observer
|
||||||
if (wrapper) {
|
if (wrapper) {
|
||||||
@@ -624,6 +770,15 @@ export default function Shell({ api }) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKey = (e) => {
|
const onKey = (e) => {
|
||||||
|
const ctrl = e.ctrlKey || e.metaKey
|
||||||
|
if (ctrl && e.shiftKey && e.key === 'F') {
|
||||||
|
const shellTab = document.querySelector('.shell-layout')
|
||||||
|
if (!shellTab || shellTab.closest('.tab-hidden')) return
|
||||||
|
e.preventDefault()
|
||||||
|
setShowSearch(prev => !prev)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
@@ -647,6 +802,49 @@ export default function Shell({ api }) {
|
|||||||
return () => window.removeEventListener('keydown', onKey)
|
return () => window.removeEventListener('keydown', onKey)
|
||||||
}, [tabs])
|
}, [tabs])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (showSearch && searchInputRef.current) {
|
||||||
|
searchInputRef.current.focus()
|
||||||
|
}
|
||||||
|
}, [showSearch])
|
||||||
|
|
||||||
|
const handleSearchChange = useCallback((value) => {
|
||||||
|
setSearchText(value)
|
||||||
|
const entry = tabsRef.current[activeTabRef.current]
|
||||||
|
if (!entry?.searchAddon) return
|
||||||
|
if (!value) {
|
||||||
|
entry.searchAddon.clearDecorations()
|
||||||
|
entry.searchAddon.clearActiveDecoration()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
searchDecorationsRef.current = entry.searchAddon.findNext(value)
|
||||||
|
} catch {}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleSearchNext = useCallback(() => {
|
||||||
|
const entry = tabsRef.current[activeTabRef.current]
|
||||||
|
if (!entry?.searchAddon || !searchText) return
|
||||||
|
try { entry.searchAddon.findNext(searchText) } catch {}
|
||||||
|
}, [searchText])
|
||||||
|
|
||||||
|
const handleSearchPrev = useCallback(() => {
|
||||||
|
const entry = tabsRef.current[activeTabRef.current]
|
||||||
|
if (!entry?.searchAddon || !searchText) return
|
||||||
|
try { entry.searchAddon.findPrevious(searchText) } catch {}
|
||||||
|
}, [searchText])
|
||||||
|
|
||||||
|
const handleCloseSearch = useCallback(() => {
|
||||||
|
setShowSearch(false)
|
||||||
|
setSearchText('')
|
||||||
|
const entry = tabsRef.current[activeTabRef.current]
|
||||||
|
if (entry?.searchAddon) {
|
||||||
|
entry.searchAddon.clearDecorations()
|
||||||
|
entry.searchAddon.clearActiveDecoration()
|
||||||
|
}
|
||||||
|
if (entry?.term) entry.term.focus()
|
||||||
|
}, [])
|
||||||
|
|
||||||
const addLocalTab = (shell, name) => {
|
const addLocalTab = (shell, name) => {
|
||||||
if (tabs.length >= MAX_TABS) return
|
if (tabs.length >= MAX_TABS) return
|
||||||
const id = nextIdRef.current++
|
const id = nextIdRef.current++
|
||||||
@@ -802,7 +1000,7 @@ export default function Shell({ api }) {
|
|||||||
if (trimmed === '/help') {
|
if (trimmed === '/help') {
|
||||||
setAiMessages(prev => [...prev,
|
setAiMessages(prev => [...prev,
|
||||||
{ role: 'user', content: trimmed },
|
{ role: 'user', content: trimmed },
|
||||||
{ role: 'assistant', content: 'Commandes disponibles:\n• /clear — Effacer la conversation\n• /help — Afficher l\'aide\n\nJe ne peux pas exécuter de code. Les blocs de code proposés peuvent être copiés ou envoyés directement au terminal actif.' }
|
{ role: 'assistant', content: 'Commandes disponibles:\n• /clear — Effacer la conversation\n• /help — Afficher l\'aide\n\nJe peux exécuter des commandes via l\'outil terminal. Les blocs de code proposés peuvent aussi être copiés ou envoyés directement au terminal actif.' }
|
||||||
])
|
])
|
||||||
aiLoadingRef.current = false
|
aiLoadingRef.current = false
|
||||||
return
|
return
|
||||||
@@ -815,17 +1013,56 @@ export default function Shell({ api }) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
let accumulated = ''
|
let accumulated = ''
|
||||||
await api.sendShellChat(trimmed, {}, true, (partial) => {
|
let toolCalls = []
|
||||||
|
const controller = new AbortController()
|
||||||
|
|
||||||
|
await api.sendShellChat(trimmed, {}, true, (partial, event) => {
|
||||||
|
if (event && event.tool_call) {
|
||||||
|
toolCalls = [...toolCalls, { call: event.tool_call, result: null }]
|
||||||
|
setAiMessages(prev => {
|
||||||
|
const filtered = prev.filter(m => !m._streaming)
|
||||||
|
return [...filtered, { role: 'assistant', content: accumulated, _streaming: true, _tabId: currentTab, _toolCalls: [...toolCalls] }]
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (event && event.tool_result) {
|
||||||
|
const idx = toolCalls.findIndex(tc => tc.call && tc.call.tool_call_id === event.tool_result.tool_call_id)
|
||||||
|
if (idx >= 0) {
|
||||||
|
toolCalls[idx] = { ...toolCalls[idx], result: event.tool_result }
|
||||||
|
setAiMessages(prev => {
|
||||||
|
const filtered = prev.filter(m => !m._streaming)
|
||||||
|
return [...filtered, { role: 'assistant', content: accumulated, _streaming: true, _tabId: currentTab, _toolCalls: [...toolCalls] }]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (event && (event.thinking !== undefined || event.thinking_end)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
accumulated = partial
|
accumulated = partial
|
||||||
setAiMessages(prev => {
|
setAiMessages(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 }]
|
return [...filtered, { role: 'assistant', content: partial, _streaming: true, _tabId: currentTab, _toolCalls: toolCalls.length > 0 ? [...toolCalls] : undefined }]
|
||||||
})
|
})
|
||||||
})
|
}, controller.signal)
|
||||||
|
|
||||||
|
const finalMsg = { role: 'assistant', content: accumulated, _tabId: currentTab }
|
||||||
|
if (toolCalls.length > 0) {
|
||||||
|
finalMsg._toolCalls = toolCalls
|
||||||
|
finalMsg.content = JSON.stringify({
|
||||||
|
content: accumulated,
|
||||||
|
tool_calls: toolCalls.map(tc => tc.call),
|
||||||
|
tool_results: toolCalls.map(tc => ({
|
||||||
|
tool_call_id: tc.call?.tool_call_id,
|
||||||
|
result: tc.result?.content || '',
|
||||||
|
is_error: tc.result?.is_error || false,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
setAiMessages(prev => {
|
setAiMessages(prev => {
|
||||||
const filtered = prev.filter(m => !m._streaming)
|
const filtered = prev.filter(m => !m._streaming)
|
||||||
return [...filtered, { role: 'assistant', content: accumulated, _tabId: currentTab }]
|
return [...filtered, finalMsg]
|
||||||
})
|
})
|
||||||
api.getShellChatHistory().then(d => {
|
api.getShellChatHistory().then(d => {
|
||||||
setAiTokens(d.tokens || 0)
|
setAiTokens(d.tokens || 0)
|
||||||
@@ -916,6 +1153,11 @@ export default function Shell({ api }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="shell-tab-actions">
|
<div className="shell-tab-actions">
|
||||||
|
{zoomLevel !== 0 && (
|
||||||
|
<span className="shell-zoom-badge">
|
||||||
|
{zoomLevel > 0 ? '+' : ''}{zoomLevel > 0 ? zoomLevel * 2 : zoomLevel * 2}px
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{tabs.length < MAX_TABS && (
|
{tabs.length < MAX_TABS && (
|
||||||
<div className="shell-new-tab-wrapper">
|
<div className="shell-new-tab-wrapper">
|
||||||
<button className="shell-new-tab-btn" onClick={() => setShowMenu(!showMenu)} title={t('shell.newTab')}>
|
<button className="shell-new-tab-btn" onClick={() => setShowMenu(!showMenu)} title={t('shell.newTab')}>
|
||||||
@@ -976,6 +1218,26 @@ export default function Shell({ api }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="shell-xterm-wrapper">
|
<div className="shell-xterm-wrapper">
|
||||||
|
{showSearch && (
|
||||||
|
<div className="shell-search-bar">
|
||||||
|
<Search size={14} className="shell-search-icon" />
|
||||||
|
<input
|
||||||
|
ref={searchInputRef}
|
||||||
|
className="shell-search-input"
|
||||||
|
value={searchText}
|
||||||
|
onChange={e => handleSearchChange(e.target.value)}
|
||||||
|
onKeyDown={e => {
|
||||||
|
if (e.key === 'Enter') { e.shiftKey ? handleSearchPrev() : handleSearchNext() }
|
||||||
|
if (e.key === 'Escape') handleCloseSearch()
|
||||||
|
e.stopPropagation()
|
||||||
|
}}
|
||||||
|
placeholder="Rechercher..."
|
||||||
|
/>
|
||||||
|
<button className="shell-search-nav" onClick={handleSearchPrev} title="Précédent (Shift+Entrée)">↑</button>
|
||||||
|
<button className="shell-search-nav" onClick={handleSearchNext} title="Suivant (Entrée)">↓</button>
|
||||||
|
<button className="shell-search-close" onClick={handleCloseSearch}><X size={14} /></button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{tabs.map(tab => (
|
{tabs.map(tab => (
|
||||||
<div
|
<div
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
@@ -1113,6 +1375,40 @@ export default function Shell({ api }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ShellToolBlock({ call, result }) {
|
||||||
|
const icon = '⌨'
|
||||||
|
const label = call.name === 'terminal' ? 'Terminal' : call.name
|
||||||
|
const isErr = result && result.is_error
|
||||||
|
|
||||||
|
let argsPreview = ''
|
||||||
|
try {
|
||||||
|
const args = typeof call.args === 'string' ? JSON.parse(call.args) : call.args
|
||||||
|
if (args.command) argsPreview = args.command
|
||||||
|
else argsPreview = JSON.stringify(args).slice(0, 80)
|
||||||
|
} catch {
|
||||||
|
argsPreview = String(call.args).slice(0, 80)
|
||||||
|
}
|
||||||
|
|
||||||
|
const truncatedResult = result ? (result.content || '').slice(0, 1500) : null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`studio-tool-block ${isErr ? 'error' : ''} ${result ? 'done' : 'running'}`}>
|
||||||
|
<div className="studio-tool-header">
|
||||||
|
<span className="studio-tool-icon">{icon}</span>
|
||||||
|
<span className="studio-tool-name">{label}</span>
|
||||||
|
{!result && <span className="studio-tool-spinner"><span/><span/><span/></span>}
|
||||||
|
{result && <span className={`studio-tool-status ${isErr ? 'error' : 'ok'}`}>{isErr ? '✗' : '✓'}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="studio-tool-args" title={argsPreview}>{argsPreview}</div>
|
||||||
|
{truncatedResult && (
|
||||||
|
<div className="studio-tool-result">
|
||||||
|
<pre>{truncatedResult}</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function ShellAIMessage({ msg, sendToTerminal, terminalTabId }) {
|
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 || ''
|
||||||
@@ -1125,10 +1421,38 @@ function ShellAIMessage({ msg, sendToTerminal, terminalTabId }) {
|
|||||||
return <div className={`ai-message system`}>{content}</div>
|
return <div className={`ai-message system`}>{content}</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
const parts = renderContent(content)
|
let parsedToolCalls = null
|
||||||
|
let parsedToolResults = null
|
||||||
|
let displayContent = content
|
||||||
|
let streamingToolCalls = msg._toolCalls || null
|
||||||
|
|
||||||
|
if (!streamingToolCalls) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(content)
|
||||||
|
if (parsed && Array.isArray(parsed.tool_calls)) {
|
||||||
|
parsedToolCalls = parsed.tool_calls
|
||||||
|
parsedToolResults = parsed.tool_results || null
|
||||||
|
displayContent = parsed.content || ''
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = renderContent(displayContent)
|
||||||
|
|
||||||
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) => {
|
||||||
|
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 <ShellToolBlock key={tc.tool_call_id || i} call={tc} result={result} />
|
||||||
|
})}
|
||||||
{parts.map((part, i) => {
|
{parts.map((part, i) => {
|
||||||
if (part.type === 'code') {
|
if (part.type === 'code') {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ const en = {
|
|||||||
newLine: 'New line',
|
newLine: 'New line',
|
||||||
copy: 'Copy',
|
copy: 'Copy',
|
||||||
paste: 'Paste',
|
paste: 'Paste',
|
||||||
|
search: 'Search',
|
||||||
|
zoom: 'Zoom +/−',
|
||||||
|
switchTab: 'Switch tab',
|
||||||
|
nextTab: 'Next tab',
|
||||||
runCommand: 'Run command',
|
runCommand: 'Run command',
|
||||||
commandHistory: 'Command history',
|
commandHistory: 'Command history',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ const fr = {
|
|||||||
newLine: 'Nouvelle ligne',
|
newLine: 'Nouvelle ligne',
|
||||||
copy: 'Copier',
|
copy: 'Copier',
|
||||||
paste: 'Coller',
|
paste: 'Coller',
|
||||||
|
search: 'Rechercher',
|
||||||
|
zoom: 'Zoom +/\u2212',
|
||||||
|
switchTab: 'Changer d\u2019onglet',
|
||||||
|
nextTab: 'Onglet suivant',
|
||||||
runCommand: 'Ex\u00e9cuter',
|
runCommand: 'Ex\u00e9cuter',
|
||||||
commandHistory: 'Historique',
|
commandHistory: 'Historique',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -329,6 +329,14 @@ input::placeholder { color: var(--text-disabled); }
|
|||||||
|
|
||||||
.shell-tab-actions { display: flex; align-items: center; gap: 4px; flex-shrink: 0; }
|
.shell-tab-actions { display: flex; align-items: center; gap: 4px; flex-shrink: 0; }
|
||||||
|
|
||||||
|
.shell-zoom-badge {
|
||||||
|
font-size: 10px; font-family: var(--font-mono); font-weight: 600;
|
||||||
|
color: var(--accent); background: var(--accent-bg);
|
||||||
|
padding: 2px 6px; border-radius: 3px;
|
||||||
|
border: 1px solid var(--accent-dim);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.shell-new-tab-wrapper { position: relative; }
|
.shell-new-tab-wrapper { position: relative; }
|
||||||
.shell-new-tab-btn {
|
.shell-new-tab-btn {
|
||||||
display: flex; align-items: center; gap: 2px;
|
display: flex; align-items: center; gap: 2px;
|
||||||
@@ -383,6 +391,36 @@ input::placeholder { color: var(--text-disabled); }
|
|||||||
.shell-menu-divider { height: 1px; background: var(--border); margin: 4px 6px; }
|
.shell-menu-divider { height: 1px; background: var(--border); margin: 4px 6px; }
|
||||||
|
|
||||||
.shell-xterm-wrapper { flex: 1; min-height: 0; background: var(--bg); overflow: hidden; position: relative; }
|
.shell-xterm-wrapper { flex: 1; min-height: 0; background: var(--bg); overflow: hidden; position: relative; }
|
||||||
|
|
||||||
|
.shell-search-bar {
|
||||||
|
position: absolute; top: 8px; right: 12px; z-index: 20;
|
||||||
|
display: flex; align-items: center; gap: 4px;
|
||||||
|
background: var(--bg-elevated); border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius); padding: 4px 6px;
|
||||||
|
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
|
||||||
|
}
|
||||||
|
.shell-search-icon { color: var(--text-tertiary); flex-shrink: 0; }
|
||||||
|
.shell-search-input {
|
||||||
|
width: 200px; font-size: 12px; padding: 3px 6px; border-radius: 4px;
|
||||||
|
background: var(--bg-input); color: var(--text-primary); border: 1px solid var(--border);
|
||||||
|
font-family: var(--font-mono); outline: none;
|
||||||
|
}
|
||||||
|
.shell-search-input:focus { border-color: var(--accent); }
|
||||||
|
.shell-search-nav {
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
width: 24px; height: 24px; border-radius: 4px;
|
||||||
|
background: transparent; border: 1px solid var(--border);
|
||||||
|
color: var(--text-tertiary); cursor: pointer; font-size: 12px;
|
||||||
|
padding: 0; transition: all 0.1s;
|
||||||
|
}
|
||||||
|
.shell-search-nav:hover { background: var(--bg-hover); color: var(--text-primary); border-color: var(--accent-dark); }
|
||||||
|
.shell-search-close {
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
width: 24px; height: 24px; border-radius: 4px;
|
||||||
|
background: transparent; border: none;
|
||||||
|
color: var(--text-disabled); cursor: pointer; padding: 0;
|
||||||
|
}
|
||||||
|
.shell-search-close:hover { color: var(--accent); }
|
||||||
.shell-xterm-instance {
|
.shell-xterm-instance {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user