Compare commits
27 Commits
v0.3.1-bet
...
v0.3.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
28e5113733 | ||
|
|
51a599fc83 | ||
|
|
d8384cad00 | ||
|
|
83d7a573c7 | ||
|
|
5b4a70e690 | ||
|
|
0fe82f67df | ||
|
|
4b9f2c377d | ||
|
|
95bd824259 | ||
|
|
252f178bbd | ||
|
|
7dcf505360 | ||
|
|
8fb93fa47e | ||
|
|
5ec373cd6a | ||
|
|
1eb5a6d00f | ||
|
|
cd5ebe083c | ||
|
|
2004c15dd7 | ||
|
|
9306152736 | ||
|
|
e15a034de5 | ||
|
|
3b6cc38ea0 | ||
|
|
93a22d4075 | ||
|
|
e0e1e73bca | ||
|
|
0496ca789b | ||
|
|
b407ab879b | ||
|
|
12df184e11 | ||
|
|
8af6d25e28 | ||
|
|
4fd599adec | ||
|
|
bcba5932d5 | ||
|
|
80c11cab3f |
178
CHANGELOG.md
178
CHANGELOG.md
@@ -4,6 +4,184 @@ All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
## v0.3.2
|
||||
|
||||
### Changes since v0.3.1
|
||||
|
||||
- chore: update CHANGELOG for v0.3.2-beta.1 (51a599f)
|
||||
- fix: correct version from 3.2 to 0.3.2 (83d7a57)
|
||||
- chore: bump version to 3.2 (0fe82f6)
|
||||
- refactor(config): remove Terminal sub-tab from Configuration page (3b6cc38)
|
||||
- fix(terminal): init payload never sent due to ws.onopen being overwritten (93a22d4)
|
||||
- fix(terminal): improve shell resolution with better error handling and ws proxy support (e0e1e73)
|
||||
- feat(studio): parse AI thinking and tool launch messages in terminal panel (0496ca7)
|
||||
- fix(studio): forward AI thinking chunks to frontend instead of dropping them (b407ab8)
|
||||
- feat(studio): add tool execution and hide AI thinking tags (12df184)
|
||||
- fix(terminal): ignore invalid shell config from race condition (8af6d25)
|
||||
- feat(shell): restore AI assistant panel (4fd599a)
|
||||
- fix(terminal): restore terminal input and cursor visibility (bcba593)
|
||||
- refactor(api): split monolithic handlers.go into focused modules (04b0fff)
|
||||
|
||||
### Downloads
|
||||
|
||||
| Platform | File |
|
||||
|----------|------|
|
||||
| Linux x86_64 | [muyue-linux-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.2/muyue-linux-amd64.tar.gz) |
|
||||
| Linux ARM64 | [muyue-linux-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.2/muyue-linux-arm64.tar.gz) |
|
||||
| macOS Intel | [muyue-darwin-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.2/muyue-darwin-amd64.tar.gz) |
|
||||
| macOS Apple Silicon | [muyue-darwin-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.2/muyue-darwin-arm64.tar.gz) |
|
||||
| Windows x86_64 | [muyue-windows-amd64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.2/muyue-windows-amd64.zip) |
|
||||
| Windows ARM64 | [muyue-windows-arm64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.2/muyue-windows-arm64.zip) |
|
||||
|
||||
The binary includes both CLI and Desktop modes.
|
||||
Run `muyue` for TUI, `muyue desktop` for web UI.
|
||||
|
||||
### Install
|
||||
|
||||
**Linux (x86_64)**
|
||||
```bash
|
||||
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.2/muyue-linux-amd64.tar.gz | tar xz
|
||||
chmod +x muyue-linux-amd64
|
||||
sudo mv muyue-linux-amd64 /usr/local/bin/muyue
|
||||
```
|
||||
|
||||
**macOS (Apple Silicon)**
|
||||
```bash
|
||||
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.2/muyue-darwin-arm64.tar.gz | tar xz
|
||||
chmod +x muyue-darwin-arm64
|
||||
sudo mv muyue-darwin-arm64 /usr/local/bin/muyue
|
||||
```
|
||||
|
||||
**Windows (x86_64)**
|
||||
```powershell
|
||||
Invoke-WebRequest -Uri "https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.2/muyue-windows-amd64.zip" -OutFile "muyue.zip"
|
||||
Expand-Archive -Path "muyue.zip" -DestinationPath "."
|
||||
Move-Item muyue-windows-amd64.exe C:\Windows\muyue.exe
|
||||
```
|
||||
|
||||
|
||||
## v0.3.2-beta.1 (Beta)
|
||||
|
||||
### Commits since v0.3.1
|
||||
|
||||
- fix: correct version from 3.2 to 0.3.2 (83d7a57)
|
||||
|
||||
> This is a **beta** release. Use at your own risk.
|
||||
|
||||
## v0.3.1
|
||||
|
||||
### Changes since v0.3.0
|
||||
|
||||
- refactor(config): remove Terminal sub-tab from Configuration page (95bd824)
|
||||
- fix(terminal): init payload never sent due to ws.onopen being overwritten (252f178)
|
||||
- fix(terminal): improve shell resolution with better error handling and ws proxy support (7dcf505)
|
||||
- feat(studio): parse AI thinking and tool launch messages in terminal panel (8fb93fa)
|
||||
- fix(studio): forward AI thinking chunks to frontend instead of dropping them (5ec373c)
|
||||
- feat(studio): add tool execution and hide AI thinking tags (1eb5a6d)
|
||||
- fix(terminal): ignore invalid shell config from race condition (cd5ebe0)
|
||||
- feat(shell): restore AI assistant panel (2004c15)
|
||||
- fix(terminal): restore terminal input and cursor visibility (9306152)
|
||||
- refactor(api): split monolithic handlers.go into focused modules (e15a034)
|
||||
|
||||
### Downloads
|
||||
|
||||
| Platform | File |
|
||||
|----------|------|
|
||||
| Linux x86_64 | [muyue-linux-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.1/muyue-linux-amd64.tar.gz) |
|
||||
| Linux ARM64 | [muyue-linux-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.1/muyue-linux-arm64.tar.gz) |
|
||||
| macOS Intel | [muyue-darwin-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.1/muyue-darwin-amd64.tar.gz) |
|
||||
| macOS Apple Silicon | [muyue-darwin-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.1/muyue-darwin-arm64.tar.gz) |
|
||||
| Windows x86_64 | [muyue-windows-amd64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.1/muyue-windows-amd64.zip) |
|
||||
| Windows ARM64 | [muyue-windows-arm64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.1/muyue-windows-arm64.zip) |
|
||||
|
||||
The binary includes both CLI and Desktop modes.
|
||||
Run `muyue` for TUI, `muyue desktop` for web UI.
|
||||
|
||||
### Install
|
||||
|
||||
**Linux (x86_64)**
|
||||
```bash
|
||||
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.1/muyue-linux-amd64.tar.gz | tar xz
|
||||
chmod +x muyue-linux-amd64
|
||||
sudo mv muyue-linux-amd64 /usr/local/bin/muyue
|
||||
```
|
||||
|
||||
**macOS (Apple Silicon)**
|
||||
```bash
|
||||
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.1/muyue-darwin-arm64.tar.gz | tar xz
|
||||
chmod +x muyue-darwin-arm64
|
||||
sudo mv muyue-darwin-arm64 /usr/local/bin/muyue
|
||||
```
|
||||
|
||||
**Windows (x86_64)**
|
||||
```powershell
|
||||
Invoke-WebRequest -Uri "https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.1/muyue-windows-amd64.zip" -OutFile "muyue.zip"
|
||||
Expand-Archive -Path "muyue.zip" -DestinationPath "."
|
||||
Move-Item muyue-windows-amd64.exe C:\Windows\muyue.exe
|
||||
```
|
||||
|
||||
|
||||
## v0.3.0
|
||||
|
||||
### Changes since v0.2.1
|
||||
|
||||
- fix(terminal): resolve PTY shell exec error, simplify CLI, unify Config tabs, restore Studio CSS (0b22109)
|
||||
- feat: add API key validation flow for AI provider config (7f67473)
|
||||
- feat(studio): replace sidebar layout with unified execution feed styles (040e482)
|
||||
- fix: guard against empty tabs array in closeTab (c8903ef)
|
||||
- refactor: redesign Config as settings window with sidebar panels, remove system overview from Dashboard (f3cb306)
|
||||
- feat: add multi-tab terminal with SSH support, config editing, and dashboard redesign (3cdcb22)
|
||||
- feat(studio): add i18n keys and CSS for redesigned AI chat interface (ee18bbe)
|
||||
- chore: bump version to 0.3.0 (b0b0e1d)
|
||||
- chore: remove dead code (packages, functions, types, constants) (fc79810)
|
||||
- docs: rewrite README and CHANGELOG for desktop app mode (f7222b0)
|
||||
- feat(web): add i18n support with FR/EN locales and keyboard layout awareness (11417d3)
|
||||
- refactor(web): redesign frontend for native web UX (3dc24ae)
|
||||
- refactor: remove TUI, desktop web UI is now the default and only mode (aa0ff19)
|
||||
- refactor: unify into single `muyue` binary with embedded desktop mode (3463605)
|
||||
- fix(ci): add frontend build step before Go vet/test/build (097cf40)
|
||||
- feat: add desktop app with React frontend, API backend, theme system (#2) (88d2a03)
|
||||
- chore: update CHANGELOG for v0.2.1 (1830c18)
|
||||
- feat: complete TUI redesign with cyberpunk theme (#1) (cb8e3d0)
|
||||
|
||||
### Downloads
|
||||
|
||||
| Platform | File |
|
||||
|----------|------|
|
||||
| Linux x86_64 | [muyue-linux-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.0/muyue-linux-amd64.tar.gz) |
|
||||
| Linux ARM64 | [muyue-linux-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.0/muyue-linux-arm64.tar.gz) |
|
||||
| macOS Intel | [muyue-darwin-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.0/muyue-darwin-amd64.tar.gz) |
|
||||
| macOS Apple Silicon | [muyue-darwin-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.0/muyue-darwin-arm64.tar.gz) |
|
||||
| Windows x86_64 | [muyue-windows-amd64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.0/muyue-windows-amd64.zip) |
|
||||
| Windows ARM64 | [muyue-windows-arm64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.0/muyue-windows-arm64.zip) |
|
||||
|
||||
The binary includes both CLI and Desktop modes.
|
||||
Run `muyue` for TUI, `muyue desktop` for web UI.
|
||||
|
||||
### Install
|
||||
|
||||
**Linux (x86_64)**
|
||||
```bash
|
||||
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.0/muyue-linux-amd64.tar.gz | tar xz
|
||||
chmod +x muyue-linux-amd64
|
||||
sudo mv muyue-linux-amd64 /usr/local/bin/muyue
|
||||
```
|
||||
|
||||
**macOS (Apple Silicon)**
|
||||
```bash
|
||||
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.0/muyue-darwin-arm64.tar.gz | tar xz
|
||||
chmod +x muyue-darwin-arm64
|
||||
sudo mv muyue-darwin-arm64 /usr/local/bin/muyue
|
||||
```
|
||||
|
||||
**Windows (x86_64)**
|
||||
```powershell
|
||||
Invoke-WebRequest -Uri "https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.0/muyue-windows-amd64.zip" -OutFile "muyue.zip"
|
||||
Expand-Archive -Path "muyue.zip" -DestinationPath "."
|
||||
Move-Item muyue-windows-amd64.exe C:\Windows\muyue.exe
|
||||
```
|
||||
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Security
|
||||
|
||||
@@ -2,11 +2,17 @@ package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/muyue/muyue/internal/orchestrator"
|
||||
)
|
||||
|
||||
var toolCallRegex = regexp.MustCompile(`\[TOOL_CALL:\{[^\}]+\}\]`)
|
||||
|
||||
func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||
@@ -36,13 +42,27 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, err.Error(), http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
orb.SetSystemPrompt(`Tu es l'orchestrateur IA de Muyue Studio. Tu aides l'utilisateur dans ses tâches de développement logiciel. Tu peux :
|
||||
|- Créer et gérer des plans de développement étape par étape
|
||||
|- Proposer des agents (outils comme Crush, Claude Code, etc.) pour exécuter des tâches spécifiques
|
||||
|- Suivre la progression de tâches multi-étapes
|
||||
|- Suggérer des modifications de fichiers, des revues de code, et des décisions d'architecture
|
||||
orb.SetSystemPrompt(`Tu es l'assistant IA de Muyue Studio. Tu as accès à un outil "crush" pour exécuter des tâches complexes sur l'ordinateur de l'utilisateur.
|
||||
|
||||
Sois concis, actionnable, et structuré. Quand tu proposes un plan, utilise des étapes numérotées claires. Quand tu références des fichiers, utilise des chemins relatifs. Tu es intégré dans l'application desktop Muyue.`)
|
||||
RÈGLES ABSOLUES:
|
||||
1. Tu as DEUX possibilités ONLY:
|
||||
- Répondre directement à l'utilisateur avec tes connaissances
|
||||
- Demander l'exécution d'une tâche via crush en utilisant ce format EXACT:
|
||||
[TOOL_CALL:{"tool":"crush","task":"description de la tâche"}]
|
||||
|
||||
2. Quand tu utilises [TOOL_CALL:...], le système exécutera la tâche et te donnera le résultat.
|
||||
Tu peux ensuite répondre à l'utilisateur avec ce résultat.
|
||||
|
||||
3. SOIS CONCIS - pas de blabla, vais droit au but.
|
||||
|
||||
4. L'utilisateur ne voit PAS tes pensées entre <think> tags.
|
||||
|
||||
5. EXEMPLES d'utilisation de tool:
|
||||
- "cherche tous les fichiers .md dans le projet" → [TOOL_CALL:{"tool":"crush","task":"Recherche les fichiers .md dans le projet courant"}]
|
||||
- "aide-moi à déboguer cette erreur" → tu peux répondre directement si tu as assez d'info, sinon utiliser tool
|
||||
- "quelle est la météo?" → [TOOL_CALL:{"tool":"crush","task":"Cherche la météo actuelle"}]
|
||||
|
||||
6. Ne fais PAS de multi-step tool calls dans une seule réponse. Attends le résultat avant de continuer.`)
|
||||
|
||||
if body.Stream {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
@@ -53,6 +73,22 @@ Sois concis, actionnable, et structuré. Quand tu proposes un plan, utilise des
|
||||
flusher, canFlush := w.(http.Flusher)
|
||||
|
||||
result, err := orb.SendStream(body.Message, func(chunk string) {
|
||||
if strings.HasPrefix(chunk, "<think") {
|
||||
data, _ := json.Marshal(map[string]string{"thinking": strings.TrimPrefix(chunk, "<think")})
|
||||
w.Write([]byte("data: " + string(data) + "\n\n"))
|
||||
if canFlush {
|
||||
flusher.Flush()
|
||||
}
|
||||
return
|
||||
}
|
||||
if chunk == "</think>" {
|
||||
data, _ := json.Marshal(map[string]string{"thinking_end": "true"})
|
||||
w.Write([]byte("data: " + string(data) + "\n\n"))
|
||||
if canFlush {
|
||||
flusher.Flush()
|
||||
}
|
||||
return
|
||||
}
|
||||
data, _ := json.Marshal(map[string]string{"content": chunk})
|
||||
w.Write([]byte("data: " + string(data) + "\n\n"))
|
||||
if canFlush {
|
||||
@@ -68,7 +104,9 @@ Sois concis, actionnable, et structuré. Quand tu proposes un plan, utilise des
|
||||
return
|
||||
}
|
||||
|
||||
s.convStore.Add("assistant", result)
|
||||
// Process tool calls if any
|
||||
cleanResult := processToolCalls(result)
|
||||
s.convStore.Add("assistant", cleanResult)
|
||||
|
||||
data, _ := json.Marshal(map[string]string{"done": "true"})
|
||||
w.Write([]byte("data: " + string(data) + "\n\n"))
|
||||
@@ -83,8 +121,64 @@ Sois concis, actionnable, et structuré. Quand tu proposes un plan, utilise des
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.convStore.Add("assistant", result)
|
||||
writeJSON(w, map[string]string{"content": result})
|
||||
cleanResult := processToolCalls(result)
|
||||
s.convStore.Add("assistant", cleanResult)
|
||||
writeJSON(w, map[string]string{"content": cleanResult})
|
||||
}
|
||||
|
||||
func processToolCalls(content string) string {
|
||||
matches := toolCallRegex.FindAllString(content, -1)
|
||||
if len(matches) == 0 {
|
||||
return cleanThinkingTags(content)
|
||||
}
|
||||
|
||||
var result strings.Builder
|
||||
clean := content
|
||||
|
||||
for _, match := range matches {
|
||||
// Extract tool and task from [TOOL_CALL:{...}]
|
||||
inner := strings.TrimPrefix(match, "[TOOL_CALL:")
|
||||
inner = strings.TrimSuffix(inner, "]}") + "}"
|
||||
|
||||
var call struct {
|
||||
Tool string `json:"tool"`
|
||||
Task string `json:"task"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(inner), &call); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if call.Tool == "crush" && call.Task != "" {
|
||||
result.WriteString(fmt.Sprintf("> %s\n\n", call.Task))
|
||||
output := executeCrush(call.Task)
|
||||
result.WriteString(output)
|
||||
result.WriteString("\n\n---\n\n")
|
||||
}
|
||||
|
||||
clean = strings.Replace(clean, match, "", 1)
|
||||
}
|
||||
|
||||
clean = cleanThinkingTags(clean)
|
||||
|
||||
if result.Len() > 0 {
|
||||
clean = strings.TrimSpace(clean) + "\n\n" + strings.TrimSpace(result.String())
|
||||
}
|
||||
|
||||
return clean
|
||||
}
|
||||
|
||||
func cleanThinkingTags(content string) string {
|
||||
re := regexp.MustCompile(`(?s)<think[^>]*>.*?</think>`)
|
||||
return re.ReplaceAllString(content, "")
|
||||
}
|
||||
|
||||
func executeCrush(task string) string {
|
||||
cmd := exec.Command("crush", "run", task)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Sprintf("Erreur: %v\n%s", err, string(output))
|
||||
}
|
||||
return string(output)
|
||||
}
|
||||
|
||||
func (s *Server) autoSummarize() {
|
||||
@@ -139,4 +233,4 @@ func (s *Server) handleChatClear(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
s.convStore.Clear()
|
||||
writeJSON(w, map[string]string{"status": "ok"})
|
||||
}
|
||||
}
|
||||
80
internal/api/handlers_tools_exec.go
Normal file
80
internal/api/handlers_tools_exec.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type toolCallRequest struct {
|
||||
Tool string `json:"tool"`
|
||||
Task string `json:"task"`
|
||||
}
|
||||
|
||||
type toolResult struct {
|
||||
Success bool `json:"success"`
|
||||
Output string `json:"output"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) handleToolCall(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var req toolCallRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Tool != "crush" {
|
||||
writeError(w, "unsupported tool: "+req.Tool, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Task == "" {
|
||||
writeError(w, "task is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
result := executeTool(req.Tool, req.Task)
|
||||
writeJSON(w, result)
|
||||
}
|
||||
|
||||
func executeTool(tool, task string) toolResult {
|
||||
var cmd *exec.Cmd
|
||||
|
||||
switch tool {
|
||||
case "crush":
|
||||
cmd = exec.Command("crush", "run", task)
|
||||
default:
|
||||
return toolResult{Success: false, Error: "unknown tool: " + tool}
|
||||
}
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return toolResult{
|
||||
Success: false,
|
||||
Output: string(output),
|
||||
Error: err.Error(),
|
||||
}
|
||||
}
|
||||
|
||||
return toolResult{
|
||||
Success: true,
|
||||
Output: string(output),
|
||||
}
|
||||
}
|
||||
|
||||
func buildToolMessage(tool, task string, history []string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("TASK: " + task + "\n\n")
|
||||
b.WriteString("CONVERSATION HISTORY:\n")
|
||||
for _, msg := range history {
|
||||
b.WriteString(strings.Repeat(" ", 4) + strings.Join(strings.Split(msg, "\n"), "\n"+strings.Repeat(" ", 4)) + "\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -55,13 +56,17 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
|
||||
var initMsg wsMessage
|
||||
_, raw, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
log.Printf("terminal: read init message failed: %v", err)
|
||||
conn.WriteJSON(wsMessage{Type: "error", Data: "failed to read init message"})
|
||||
return
|
||||
}
|
||||
log.Printf("terminal: init message received: %s", string(raw))
|
||||
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"})
|
||||
return
|
||||
}
|
||||
log.Printf("terminal: init type=%q data=%q", initMsg.Type, initMsg.Data)
|
||||
|
||||
var cmd *exec.Cmd
|
||||
|
||||
@@ -95,37 +100,52 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
cmd = exec.Command("ssh", sshArgs...)
|
||||
} else {
|
||||
shell := initMsg.Data
|
||||
shell := strings.TrimSpace(initMsg.Data)
|
||||
log.Printf("terminal: requested shell=%q, trimmed=%q", initMsg.Data, shell)
|
||||
if shell == "" {
|
||||
shell = detectShell()
|
||||
} else {
|
||||
if path, err := exec.LookPath(shell); err == nil {
|
||||
shell = path
|
||||
}
|
||||
log.Printf("terminal: auto-detected shell=%q", shell)
|
||||
}
|
||||
|
||||
if shell == "" {
|
||||
log.Printf("terminal: no shell detected, falling back to /bin/sh")
|
||||
shell = "/bin/sh"
|
||||
}
|
||||
|
||||
if path, err := exec.LookPath(shell); err == nil {
|
||||
shell = path
|
||||
log.Printf("terminal: resolved shell path=%q", shell)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(shell); err != nil {
|
||||
conn.WriteJSON(wsMessage{Type: "error", Data: fmt.Sprintf("shell not found: %s", shell)})
|
||||
log.Printf("terminal: shell stat failed: %v for %q", err, shell)
|
||||
conn.WriteJSON(wsMessage{Type: "error", Data: fmt.Sprintf("shell not found: %s (resolved from: %q)", shell, initMsg.Data)})
|
||||
return
|
||||
}
|
||||
|
||||
if strings.Contains(shell, "wsl") {
|
||||
shellName := filepath.Base(shell)
|
||||
switch shellName {
|
||||
case "wsl":
|
||||
cmd = exec.Command(shell, "--shell-type", "login")
|
||||
} else if strings.Contains(shell, "powershell") || strings.Contains(shell, "pwsh") {
|
||||
case "powershell", "pwsh":
|
||||
cmd = exec.Command(shell, "-NoLogo", "-NoProfile")
|
||||
} else {
|
||||
case "fish":
|
||||
cmd = exec.Command(shell, "--login")
|
||||
default:
|
||||
cmd = exec.Command(shell)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
log.Printf("pty start: %v", err)
|
||||
log.Printf("terminal: pty start failed: %v", err)
|
||||
conn.WriteJSON(wsMessage{Type: "error", Data: err.Error()})
|
||||
return
|
||||
}
|
||||
log.Printf("terminal: pty started successfully")
|
||||
defer func() {
|
||||
ptmx.Close()
|
||||
if cmd.Process != nil {
|
||||
|
||||
@@ -2,7 +2,7 @@ package version
|
||||
|
||||
const (
|
||||
Name = "muyue"
|
||||
Version = "0.3.1"
|
||||
Version = "0.3.2"
|
||||
Author = "La Légion de Muyue"
|
||||
)
|
||||
|
||||
|
||||
@@ -68,7 +68,9 @@ const api = {
|
||||
if (data.done) { resolve(full); return }
|
||||
if (data.content) {
|
||||
full += data.content
|
||||
if (onChunk) onChunk(full)
|
||||
if (onChunk) onChunk(full, data)
|
||||
} else if (data.thinking !== undefined || data.thinking_end) {
|
||||
if (onChunk) onChunk(full, data)
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { User, Brain, RefreshCw, Globe, Wrench, Monitor } from 'lucide-react'
|
||||
import { User, Brain, RefreshCw, Globe, Wrench } from 'lucide-react'
|
||||
import { useI18n, LANGUAGES } from '../i18n'
|
||||
import { getLayoutList } from '../i18n/keyboards'
|
||||
|
||||
@@ -27,9 +27,7 @@ export default function Config({ api }) {
|
||||
const [profileForm, setProfileForm] = useState({})
|
||||
const [providerForm, setProviderForm] = useState({})
|
||||
const [toast, setToast] = useState(null)
|
||||
const [terminalThemes, setTerminalThemes] = useState([])
|
||||
const [terminalSettings, setTerminalSettings] = useState({ font_size: 14, font_family: '', theme: 'default' })
|
||||
const [savingTerminal, setSavingTerminal] = useState(false)
|
||||
|
||||
|
||||
const layouts = getLayoutList()
|
||||
|
||||
@@ -43,19 +41,13 @@ export default function Config({ api }) {
|
||||
editor: d.profile?.preferences?.editor || '',
|
||||
shell: d.profile?.preferences?.shell || '',
|
||||
})
|
||||
if (d.terminal) {
|
||||
setTerminalSettings({
|
||||
font_size: d.terminal.font_size || 14,
|
||||
font_family: d.terminal.font_family || '',
|
||||
theme: d.terminal.theme || 'default',
|
||||
})
|
||||
}
|
||||
|
||||
}).catch(() => {})
|
||||
api.getProviders().then(d => setProviders(d.providers || [])).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.getTerminalThemes().then(d => setTerminalThemes(d.themes || [])).catch(() => {})
|
||||
|
||||
}, [api])
|
||||
|
||||
useEffect(() => { loadData() }, [loadData])
|
||||
@@ -126,18 +118,6 @@ export default function Config({ api }) {
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveTerminalSettings = async () => {
|
||||
setSavingTerminal(true)
|
||||
try {
|
||||
await api.saveTerminalSettings(terminalSettings)
|
||||
showToast(t('config.saved'))
|
||||
window.location.reload()
|
||||
} catch (err) {
|
||||
showToast(`${t('config.error')}: ${err.message}`)
|
||||
}
|
||||
setSavingTerminal(false)
|
||||
}
|
||||
|
||||
const openProviderEdit = (p) => {
|
||||
setProviderForm({
|
||||
name: p.name,
|
||||
@@ -213,13 +193,7 @@ export default function Config({ api }) {
|
||||
{activePanel === 'skills' && (
|
||||
<PanelSkills skillList={skillList} t={t} />
|
||||
)}
|
||||
{activePanel === 'terminal' && (
|
||||
<PanelTerminal
|
||||
settings={terminalSettings} setSettings={setTerminalSettings}
|
||||
themes={terminalThemes} saving={savingTerminal}
|
||||
onSave={handleSaveTerminalSettings} t={t}
|
||||
/>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -470,102 +444,6 @@ function PanelSkills({ skillList, t }) {
|
||||
)
|
||||
}
|
||||
|
||||
function PanelTerminal({ settings, setSettings, themes, saving, onSave, t }) {
|
||||
const previewTheme = {
|
||||
background: settings.theme === 'default' ? '#0A0A0C' :
|
||||
settings.theme === 'monokai' ? '#272822' :
|
||||
settings.theme === 'gruvbox' ? '#282828' :
|
||||
settings.theme === 'nord' ? '#2E3440' :
|
||||
settings.theme === 'solarized-dark' ? '#002B36' :
|
||||
settings.theme === 'dracula' ? '#282A36' : '#0A0A0C',
|
||||
foreground: settings.theme === 'default' ? '#EAE0E2' :
|
||||
settings.theme === 'monokai' ? '#F8F8F2' :
|
||||
settings.theme === 'gruvbox' ? '#EBDBB2' :
|
||||
settings.theme === 'nord' ? '#D8DEE9' :
|
||||
settings.theme === 'solarized-dark' ? '#839496' :
|
||||
settings.theme === 'dracula' ? '#F8F8F2' : '#EAE0E2',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="config-card">
|
||||
<div className="config-card-group">
|
||||
<span className="config-card-group-label">{t('config.terminalTheme')}</span>
|
||||
<div className="chip-row">
|
||||
{themes.map(th => (
|
||||
<div
|
||||
key={th.id}
|
||||
className={`chip ${settings.theme === th.id ? 'active' : ''}`}
|
||||
onClick={() => setSettings(s => ({ ...s, theme: th.id }))}
|
||||
>
|
||||
{th.name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="config-card-group">
|
||||
<span className="config-card-group-label">{t('config.fontSize')}</span>
|
||||
<div className="chip-row">
|
||||
{[12, 14, 16, 18, 20, 24].map(size => (
|
||||
<div
|
||||
key={size}
|
||||
className={`chip ${settings.font_size === size ? 'active' : ''}`}
|
||||
onClick={() => setSettings(s => ({ ...s, font_size: size }))}
|
||||
>
|
||||
{size}px
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="config-card-group">
|
||||
<span className="config-card-group-label">{t('config.fontFamily')}</span>
|
||||
<select
|
||||
className="config-form-input"
|
||||
value={settings.font_family}
|
||||
onChange={e => setSettings(s => ({ ...s, font_family: e.target.value }))}
|
||||
style={{ maxWidth: 300 }}
|
||||
>
|
||||
<option value="">Default (JetBrains Mono)</option>
|
||||
<option value="'Fira Code', monospace">Fira Code</option>
|
||||
<option value="'Cascadia Code', 'SF Mono', monospace">Cascadia Code</option>
|
||||
<option value="'SF Mono', 'Menlo', monospace">SF Mono</option>
|
||||
<option value="'Source Code Pro', monospace">Source Code Pro</option>
|
||||
<option value="monospace">System Monospace</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="config-card-group">
|
||||
<span className="config-card-group-label">{t('config.preview')}</span>
|
||||
<div style={{
|
||||
background: previewTheme.background,
|
||||
color: previewTheme.foreground,
|
||||
padding: '16px 20px',
|
||||
borderRadius: 'var(--radius)',
|
||||
fontFamily: settings.font_family || "'JetBrains Mono', monospace",
|
||||
fontSize: settings.font_size || 14,
|
||||
border: '1px solid var(--border)',
|
||||
}}>
|
||||
<span style={{ color: '#00E676' }}>➜</span> <span>~/projects</span>
|
||||
<span style={{ color: '#448AFF' }}> git status</span>
|
||||
<br />
|
||||
<span>On branch </span>
|
||||
<span style={{ color: '#FFD740' }}>main</span>
|
||||
<br />
|
||||
<span style={{ opacity: 0.6 }}>Type a command...</span>
|
||||
<span style={{ animation: 'blink 1s step-end infinite' }}> ▋</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="config-card-actions" style={{ marginTop: 16 }}>
|
||||
<button className="primary sm" onClick={onSave} disabled={saving}>
|
||||
{saving ? t('config.saving') : t('config.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FormInput({ label, value, onChange, type = 'text' }) {
|
||||
return (
|
||||
<div className="config-form-field">
|
||||
|
||||
@@ -94,15 +94,15 @@ function connectWebSocket(term, fitAddon, initPayload) {
|
||||
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const ws = new WebSocket(`${proto}//${window.location.host}/api/ws/terminal`)
|
||||
|
||||
ws.onopen = () => {
|
||||
ws.addEventListener('open', () => {
|
||||
ws.send(JSON.stringify(initPayload))
|
||||
const dims = fitAddon.proposeDimensions()
|
||||
if (dims) {
|
||||
ws.send(JSON.stringify({ type: 'resize', rows: dims.rows, cols: dims.cols }))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
ws.addEventListener('message', (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data)
|
||||
if (msg.type === 'output') {
|
||||
@@ -113,15 +113,15 @@ function connectWebSocket(term, fitAddon, initPayload) {
|
||||
} catch {
|
||||
term.write(event.data)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
ws.onclose = () => {
|
||||
ws.addEventListener('close', () => {
|
||||
term.write('\r\n\x1b[33m— Connection closed —\x1b[0m\r\n')
|
||||
}
|
||||
})
|
||||
|
||||
ws.onerror = () => {
|
||||
ws.addEventListener('error', () => {
|
||||
term.write('\r\n\x1b[31m— Connection error —\x1b[0m\r\n')
|
||||
}
|
||||
})
|
||||
|
||||
term.onData((data) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
@@ -163,6 +163,17 @@ export default function Shell({ api }) {
|
||||
name: '', host: '', port: 22, user: '', key_path: '',
|
||||
})
|
||||
|
||||
const [aiMessages, setAiMessages] = useState([
|
||||
{ role: 'ai', content: t('shell.aiWelcome') }
|
||||
])
|
||||
const [aiInput, setAiInput] = useState('')
|
||||
const [aiLoading, setAiLoading] = useState(false)
|
||||
const aiMessagesRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
aiMessagesRef.current?.scrollTo(0, aiMessagesRef.current.scrollHeight)
|
||||
}, [aiMessages])
|
||||
|
||||
useEffect(() => {
|
||||
api.getTerminalSessions().then(d => {
|
||||
setSshConnections(d.ssh || [])
|
||||
@@ -239,15 +250,25 @@ export default function Shell({ api }) {
|
||||
|
||||
useEffect(() => {
|
||||
const tab = tabs.find(t => t.id === activeTab)
|
||||
if (tab && !tabsRef.current[tab.id]) {
|
||||
const timer = setTimeout(() => initTerminal(tab.id, tab), 50)
|
||||
return () => clearTimeout(timer)
|
||||
} else if (tab && tabsRef.current[tab.id]) {
|
||||
if (!tab) return
|
||||
|
||||
const container = document.getElementById(`terminal-${tab.id}`)
|
||||
if (!container) return
|
||||
|
||||
if (!tabsRef.current[tab.id]) {
|
||||
const timer = setTimeout(() => {
|
||||
const { fitAddon } = tabsRef.current[tab.id]
|
||||
fitAddon.fit()
|
||||
}, 50)
|
||||
initTerminal(tab.id, tab)
|
||||
requestAnimationFrame(() => {
|
||||
const entry = tabsRef.current[tab.id]
|
||||
if (entry) entry.fitAddon.fit()
|
||||
})
|
||||
}, 100)
|
||||
return () => clearTimeout(timer)
|
||||
} else {
|
||||
requestAnimationFrame(() => {
|
||||
const entry = tabsRef.current[tab.id]
|
||||
if (entry) entry.fitAddon.fit()
|
||||
})
|
||||
}
|
||||
}, [activeTab, tabs, initTerminal])
|
||||
|
||||
@@ -351,6 +372,69 @@ export default function Shell({ api }) {
|
||||
}
|
||||
}
|
||||
|
||||
const handleAiSend = async () => {
|
||||
if (!aiInput.trim() || aiLoading) return
|
||||
const text = aiInput.trim()
|
||||
setAiMessages(prev => [...prev, { role: 'user', content: text }])
|
||||
setAiInput('')
|
||||
setAiLoading(true)
|
||||
try {
|
||||
const res = await api.runCommand(`echo "AI: ${text}"`, '')
|
||||
const output = res.output || t('shell.noResponse')
|
||||
parseAndAddAiMessages(output)
|
||||
} catch (err) {
|
||||
setAiMessages(prev => [...prev, { role: 'ai', content: `${t('shell.error')}: ${err.message}` }])
|
||||
}
|
||||
setAiLoading(false)
|
||||
}
|
||||
|
||||
const parseAndAddAiMessages = (text) => {
|
||||
const lines = text.split('\n')
|
||||
let buffer = ''
|
||||
let inBlock = false
|
||||
|
||||
const flushBuffer = () => {
|
||||
if (buffer.trim()) {
|
||||
setAiMessages(prev => [...prev, { role: 'ai', content: buffer.trim() }])
|
||||
}
|
||||
buffer = ''
|
||||
}
|
||||
|
||||
for (const line of lines) {
|
||||
const toolMatch = line.match(/^\[TOOL_CALL:\{.*\}\]$/)
|
||||
if (toolMatch) {
|
||||
flushBuffer()
|
||||
try {
|
||||
const toolData = JSON.parse(toolMatch[0].slice(10, -1))
|
||||
setAiMessages(prev => [...prev, {
|
||||
role: 'tool',
|
||||
content: `${t('shell.toolLaunched')}: ${toolData.tool || 'tool'}`,
|
||||
args: toolData.task || toolData.args || '',
|
||||
}])
|
||||
} catch {
|
||||
setAiMessages(prev => [...prev, { role: 'tool', content: line, args: '' }])
|
||||
}
|
||||
} else if (line.match(/^(Reflexion|Thought|thinking):/i) || line.startsWith('>')) {
|
||||
if (buffer.trim() && !inBlock) {
|
||||
flushBuffer()
|
||||
}
|
||||
inBlock = true
|
||||
const cleaned = line.replace(/^(Reflexion|Thought|thinking):\s*/i, '').replace(/^>\s*/, '')
|
||||
if (buffer) buffer += ' '
|
||||
buffer += cleaned
|
||||
} else {
|
||||
if (inBlock && buffer.trim()) {
|
||||
setAiMessages(prev => [...prev, { role: 'thinking', content: buffer.trim() }])
|
||||
buffer = ''
|
||||
}
|
||||
inBlock = false
|
||||
if (buffer) buffer += '\n'
|
||||
buffer += line
|
||||
}
|
||||
}
|
||||
flushBuffer()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="shell-layout">
|
||||
<div className="shell-terminal-col">
|
||||
@@ -465,6 +549,28 @@ export default function Shell({ api }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="shell-ai-col">
|
||||
<div className="ai-panel-header">{t('shell.aiAssistant')}</div>
|
||||
<div className="ai-panel-messages" ref={aiMessagesRef}>
|
||||
{aiMessages.map((msg, i) => (
|
||||
<div key={i} className={`ai-message ${msg.role}`}>
|
||||
{msg.content}
|
||||
{msg.args && <div className="tool-args">{msg.args}</div>}
|
||||
</div>
|
||||
))}
|
||||
{aiLoading && <div style={{ textAlign: 'center', padding: 8 }}><span className="spinner" /></div>}
|
||||
</div>
|
||||
<div className="ai-panel-input">
|
||||
<input
|
||||
value={aiInput}
|
||||
onChange={e => setAiInput(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleAiSend()}
|
||||
placeholder={t('shell.askAi')}
|
||||
/>
|
||||
<button className="sm" onClick={handleAiSend} disabled={!aiInput.trim()}>{t('shell.send')}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showSshModal && (
|
||||
<div className="shell-modal-overlay" onClick={() => setShowSshModal(false)}>
|
||||
<div className="shell-modal" onClick={e => e.stopPropagation()}>
|
||||
|
||||
@@ -1,6 +1,35 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { useI18n } from '../i18n'
|
||||
|
||||
const RANKS = {
|
||||
commandant: { label: 'Commandant', short: 'CDT', color: '#FFD740' },
|
||||
general: { label: 'General', short: 'GEN', color: '#FF9100' },
|
||||
colonel: { label: 'Colonel', short: 'COL', color: '#FF6D00' },
|
||||
lieutenant: { label: 'Lieutenant', short: 'LT', color: '#448AFF' },
|
||||
soldat: { label: 'Soldat', short: 'SDT', color: '#00E676' },
|
||||
}
|
||||
|
||||
function getRank(role) {
|
||||
if (role === 'user') return RANKS.commandant
|
||||
if (role === 'system') return null
|
||||
return RANKS.general
|
||||
}
|
||||
|
||||
function RankIcon({ rank }) {
|
||||
if (rank === RANKS.commandant) {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke={rank.color} strokeWidth="2">
|
||||
<polygon points="12,2 15.09,8.26 22,9.27 17,14.14 18.18,21.02 12,17.77 5.82,21.02 7,14.14 2,9.27 8.91,8.26"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke={rank.color} strokeWidth="2">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function renderContent(text) {
|
||||
const parts = []
|
||||
const codeBlockRegex = /(```[\s\S]*?```)/g
|
||||
@@ -34,17 +63,25 @@ function formatText(text) {
|
||||
.replace(/^\s*(\d+)[.)] (.+)$/gm, '<span class="msg-step"><span class="msg-step-num">$1</span>$2</span>')
|
||||
}
|
||||
|
||||
function ThinkingBlock({ content, done }) {
|
||||
return (
|
||||
<div className={`feed-thinking-block ${done ? 'done' : 'active'}`}>
|
||||
<div className="feed-thinking-header">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/>
|
||||
</svg>
|
||||
<span>Reflexion</span>
|
||||
{!done && <span className="feed-thinking-dots"><span/><span/><span/></span>}
|
||||
</div>
|
||||
<div className="feed-thinking-content">{content}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FeedItem({ msg }) {
|
||||
const isUser = msg.role === 'user'
|
||||
const isSystem = msg.role === 'system'
|
||||
|
||||
const roleLabel = isUser ? null : isSystem ? null : (
|
||||
<div className="feed-avatar">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/>
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
const rank = getRank(msg.role)
|
||||
|
||||
const timeStr = msg.time ? new Date(msg.time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : ''
|
||||
|
||||
@@ -58,16 +95,24 @@ function FeedItem({ msg }) {
|
||||
)
|
||||
}
|
||||
|
||||
const cleanContent = msg.content.replace(/<think[^>]*>[\s\S]*?<\/think>/gi, '')
|
||||
|
||||
return (
|
||||
<div className={`feed-item ${msg.role}`}>
|
||||
{roleLabel}
|
||||
<div className={`feed-avatar ${isUser ? 'user-rank' : 'ai-rank'}`}>
|
||||
<RankIcon rank={rank} />
|
||||
</div>
|
||||
<div className="feed-body">
|
||||
<div className="feed-header">
|
||||
<span className="feed-role">{isUser ? 'Vous' : 'IA'}</span>
|
||||
<span className="feed-rank-badge" style={{ color: rank.color, borderColor: rank.color }}>
|
||||
{rank.short}
|
||||
</span>
|
||||
<span className="feed-role">{rank.label}</span>
|
||||
{timeStr && <span className="feed-time">{timeStr}</span>}
|
||||
</div>
|
||||
{msg.thinking && <ThinkingBlock content={msg.thinking} done />}
|
||||
<div className="feed-content">
|
||||
{renderContent(msg.content).map((part, i) =>
|
||||
{renderContent(cleanContent).map((part, i) =>
|
||||
part.type === 'code' ? (
|
||||
<div key={i} className="studio-code-block">
|
||||
{part.lang && <div className="studio-code-lang">{part.lang}</div>}
|
||||
@@ -83,31 +128,43 @@ function FeedItem({ msg }) {
|
||||
)
|
||||
}
|
||||
|
||||
function StreamingItem({ content }) {
|
||||
function StreamingItem({ content, thinking }) {
|
||||
const rank = RANKS.general
|
||||
const cleanContent = content.replace(/<think[^>]*>[\s\S]*?<\/think>/gi, '')
|
||||
|
||||
return (
|
||||
<div className="feed-item assistant">
|
||||
<div className="feed-avatar">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/>
|
||||
</svg>
|
||||
<div className="feed-avatar ai-rank">
|
||||
<RankIcon rank={rank} />
|
||||
</div>
|
||||
<div className="feed-body">
|
||||
<div className="feed-header">
|
||||
<span className="feed-role">IA</span>
|
||||
</div>
|
||||
<div className="feed-content">
|
||||
{renderContent(content).map((part, i) =>
|
||||
part.type === 'code' ? (
|
||||
<div key={i} className="studio-code-block">
|
||||
{part.lang && <div className="studio-code-lang">{part.lang}</div>}
|
||||
<pre><code>{part.content}</code></pre>
|
||||
</div>
|
||||
) : (
|
||||
<span key={i} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} />
|
||||
)
|
||||
)}
|
||||
<span className="studio-cursor" />
|
||||
<span className="feed-rank-badge" style={{ color: rank.color, borderColor: rank.color }}>
|
||||
{rank.short}
|
||||
</span>
|
||||
<span className="feed-role">{rank.label}</span>
|
||||
</div>
|
||||
{thinking && <ThinkingBlock content={thinking} done={false} />}
|
||||
{!thinking && !cleanContent && (
|
||||
<div className="feed-content">
|
||||
<div className="studio-thinking"><span /><span /><span /></div>
|
||||
</div>
|
||||
)}
|
||||
{cleanContent && (
|
||||
<div className="feed-content">
|
||||
{renderContent(cleanContent).map((part, i) =>
|
||||
part.type === 'code' ? (
|
||||
<div key={i} className="studio-code-block">
|
||||
{part.lang && <div className="studio-code-lang">{part.lang}</div>}
|
||||
<pre><code>{part.content}</code></pre>
|
||||
</div>
|
||||
) : (
|
||||
<span key={i} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} />
|
||||
)
|
||||
)}
|
||||
<span className="studio-cursor" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -119,6 +176,7 @@ export default function Studio({ api }) {
|
||||
const [input, setInput] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [streaming, setStreaming] = useState('')
|
||||
const [streamThinking, setStreamThinking] = useState('')
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
const messagesEnd = useRef(null)
|
||||
const textareaRef = useRef(null)
|
||||
@@ -143,7 +201,7 @@ export default function Studio({ api }) {
|
||||
|
||||
useEffect(() => {
|
||||
messagesEnd.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [messages, streaming])
|
||||
}, [messages, streaming, streamThinking])
|
||||
|
||||
useEffect(() => {
|
||||
if (textareaRef.current) {
|
||||
@@ -175,21 +233,33 @@ export default function Studio({ api }) {
|
||||
setMessages(prev => [...prev, userMsg])
|
||||
setLoading(true)
|
||||
setStreaming('')
|
||||
setStreamThinking('')
|
||||
|
||||
try {
|
||||
let accumulated = ''
|
||||
await api.sendChat(text, true, (partial) => {
|
||||
let thinking = ''
|
||||
|
||||
await api.sendChat(text, true, (partial, event) => {
|
||||
if (event && (event.thinking_start || event.thinking_end || event.thinking !== undefined)) {
|
||||
if (event.thinking !== undefined) {
|
||||
thinking += event.thinking
|
||||
setStreamThinking(thinking)
|
||||
}
|
||||
return
|
||||
}
|
||||
accumulated = partial
|
||||
setStreaming(partial)
|
||||
})
|
||||
|
||||
const finalContent = accumulated || t('studio.noResponse')
|
||||
setMessages(prev => [...prev, {
|
||||
const aiMsg = {
|
||||
id: (Date.now() + 1).toString(),
|
||||
role: 'assistant',
|
||||
content: finalContent,
|
||||
time: new Date().toISOString(),
|
||||
}])
|
||||
}
|
||||
if (thinking) aiMsg.thinking = thinking
|
||||
setMessages(prev => [...prev, aiMsg])
|
||||
} catch (err) {
|
||||
setMessages(prev => [...prev, {
|
||||
id: (Date.now() + 1).toString(),
|
||||
@@ -200,6 +270,7 @@ export default function Studio({ api }) {
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setStreaming('')
|
||||
setStreamThinking('')
|
||||
}
|
||||
}, [input, loading, api, t, handleClear])
|
||||
|
||||
@@ -228,20 +299,8 @@ export default function Studio({ api }) {
|
||||
{messages.map(msg => (
|
||||
<FeedItem key={msg.id} msg={msg} />
|
||||
))}
|
||||
{streaming && <StreamingItem content={streaming} />}
|
||||
{loading && !streaming && (
|
||||
<div className="feed-item assistant">
|
||||
<div className="feed-avatar">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="feed-body">
|
||||
<div className="feed-content">
|
||||
<div className="studio-thinking"><span /><span /><span /></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{(streaming || streamThinking || loading) && (
|
||||
<StreamingItem content={streaming} thinking={streamThinking} />
|
||||
)}
|
||||
<div ref={messagesEnd} />
|
||||
</div>
|
||||
|
||||
@@ -107,6 +107,10 @@ const en = {
|
||||
systemTerminals: 'System terminals',
|
||||
switchTerminal: 'Switch terminal',
|
||||
localShell: 'Local Shell',
|
||||
aiAssistant: 'AI Assistant',
|
||||
aiWelcome: 'Hello! I can help you with terminal commands. Ask me anything!',
|
||||
askAi: 'Ask AI assistant...',
|
||||
toolLaunched: 'Tool launched',
|
||||
},
|
||||
|
||||
config: {
|
||||
|
||||
@@ -107,6 +107,10 @@ const fr = {
|
||||
systemTerminals: 'Terminaux syst\u00e8me',
|
||||
switchTerminal: 'Changer de terminal',
|
||||
localShell: 'Shell local',
|
||||
aiAssistant: 'Assistant IA',
|
||||
aiWelcome: 'Bonjour ! Je peux vous aider avec les commandes du terminal. Demandez-moi n\'importe quoi !',
|
||||
askAi: 'Interroger l\'assistant IA...',
|
||||
toolLaunched: 'Outil lanc\u00e9',
|
||||
},
|
||||
|
||||
config: {
|
||||
|
||||
@@ -269,7 +269,7 @@ input::placeholder { color: var(--text-disabled); }
|
||||
.sidebar-tab.active { background: var(--accent); color: #fff; font-weight: 600; }
|
||||
|
||||
.shell-layout { display: flex; height: 100%; }
|
||||
.shell-terminal-col { flex: 1; display: flex; flex-direction: column; min-width: 0; }
|
||||
.shell-terminal-col { flex: 1; display: flex; flex-direction: column; min-width: 0; min-height: 0; overflow: hidden; }
|
||||
|
||||
.shell-tabs-bar {
|
||||
display: flex; align-items: center; background: var(--bg-surface);
|
||||
@@ -377,6 +377,7 @@ input::placeholder { color: var(--text-disabled); }
|
||||
.shell-xterm-wrapper { flex: 1; background: var(--bg); overflow: hidden; position: relative; }
|
||||
.shell-xterm-instance {
|
||||
position: absolute; inset: 0; padding: 4px;
|
||||
display: block !important;
|
||||
}
|
||||
.shell-xterm-instance .xterm { height: 100%; padding: 4px; }
|
||||
|
||||
@@ -384,6 +385,19 @@ input::placeholder { color: var(--text-disabled); }
|
||||
.connection-dot.on { background: var(--success); box-shadow: 0 0 6px var(--success); }
|
||||
.connection-dot.off { background: var(--error); }
|
||||
|
||||
.shell-ai-col { width: 320px; border-left: 1px solid var(--border); background: var(--bg-surface); display: flex; flex-direction: column; flex-shrink: 0; }
|
||||
.ai-panel-header { padding: 12px 16px; border-bottom: 1px solid var(--border); font-weight: 700; font-size: 13px; color: var(--accent); }
|
||||
.ai-panel-messages { flex: 1; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 8px; }
|
||||
.ai-message { padding: 8px 12px; border-radius: var(--radius); font-size: 13px; line-height: 1.5; word-break: break-word; }
|
||||
.ai-message.ai { background: var(--bg-card); border-left: 3px solid var(--accent); }
|
||||
.ai-message.user { background: var(--accent-bg); border-left: 3px solid var(--accent-muted); }
|
||||
.ai-message.thinking { background: var(--bg-elevated); border-left: 3px solid var(--info); font-style: italic; color: var(--text-tertiary); }
|
||||
.ai-message.tool { background: var(--bg-elevated); border-left: 3px solid var(--warning); }
|
||||
.ai-message.tool .tool-name { font-weight: 700; color: var(--warning); }
|
||||
.ai-message.tool .tool-args { font-family: var(--font-mono); font-size: 12px; color: var(--text-tertiary); margin-top: 4px; }
|
||||
.ai-panel-input { display: flex; gap: 6px; padding: 10px 12px; border-top: 1px solid var(--border); }
|
||||
.ai-panel-input input { flex: 1; font-size: 13px; padding: 6px 10px; }
|
||||
|
||||
.shell-modal-overlay {
|
||||
position: fixed; inset: 0; background: rgba(0,0,0,0.6);
|
||||
display: flex; align-items: center; justify-content: center; z-index: 1000;
|
||||
@@ -569,18 +583,60 @@ input::placeholder { color: var(--text-disabled); }
|
||||
.feed-loading { display: flex; align-items: center; justify-content: center; padding: 60px 0; }
|
||||
.feed-item { display: flex; gap: 10px; padding: 8px 12px; border-radius: var(--radius); animation: fadeIn 0.15s ease-out; }
|
||||
.feed-item:hover { background: var(--bg-card); }
|
||||
.feed-item.user { background: var(--bg-card); border-left: 3px solid var(--accent-muted); }
|
||||
.feed-item.assistant { }
|
||||
.feed-item.user { background: var(--bg-card); border-left: 3px solid #FFD740; }
|
||||
.feed-item.assistant { border-left: 3px solid transparent; }
|
||||
.feed-item.assistant:hover { border-left-color: var(--accent-dark); }
|
||||
.feed-item.system { align-items: center; gap: 8px; padding: 6px 12px; }
|
||||
.feed-avatar { width: 24px; height: 24px; border-radius: 50%; background: var(--accent-bg); color: var(--accent); display: flex; align-items: center; justify-content: center; flex-shrink: 0; margin-top: 2px; }
|
||||
.feed-avatar { width: 28px; height: 28px; border-radius: 50%; display: flex; align-items: center; justify-content: center; flex-shrink: 0; margin-top: 2px; font-size: 14px; }
|
||||
.feed-avatar.user-rank { background: rgba(255, 215, 64, 0.15); }
|
||||
.feed-avatar.ai-rank { background: var(--accent-bg); }
|
||||
.feed-rank-icon { display: flex; align-items: center; justify-content: center; }
|
||||
.feed-body { flex: 1; min-width: 0; }
|
||||
.feed-header { display: flex; align-items: center; gap: 8px; margin-bottom: 2px; }
|
||||
.feed-rank-badge {
|
||||
font-size: 9px; font-weight: 800; font-family: var(--font-mono);
|
||||
padding: 1px 6px; border-radius: 3px; border: 1px solid;
|
||||
letter-spacing: 0.5px; text-transform: uppercase;
|
||||
background: rgba(255, 215, 64, 0.08);
|
||||
}
|
||||
.feed-role { font-size: 11px; font-weight: 700; color: var(--accent); text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.feed-time { font-size: 10px; color: var(--text-disabled); font-family: var(--font-mono); }
|
||||
.feed-content { font-size: 14px; line-height: 1.7; color: var(--text-primary); word-break: break-word; }
|
||||
.feed-system-badge { width: 6px; height: 6px; border-radius: 50%; background: var(--accent-dim); flex-shrink: 0; }
|
||||
.feed-system-text { font-size: 12px; color: var(--text-tertiary); font-style: italic; flex: 1; }
|
||||
|
||||
.feed-thinking-block {
|
||||
background: var(--bg-surface); border: 1px solid var(--border); border-left: 2px solid var(--accent-dim);
|
||||
border-radius: var(--radius); margin: 6px 0 8px; overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.feed-thinking-block.active {
|
||||
border-left-color: var(--warning);
|
||||
}
|
||||
.feed-thinking-block.done {
|
||||
border-left-color: var(--text-disabled);
|
||||
opacity: 0.7;
|
||||
}
|
||||
.feed-thinking-block.done .feed-thinking-content {
|
||||
max-height: 80px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.feed-thinking-header {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
padding: 6px 10px; font-size: 10px; font-weight: 700;
|
||||
color: var(--text-tertiary); text-transform: uppercase; letter-spacing: 0.5px;
|
||||
background: var(--bg-card); border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.feed-thinking-header svg { color: var(--warning); }
|
||||
.feed-thinking-dots { display: inline-flex; gap: 2px; margin-left: 4px; }
|
||||
.feed-thinking-dots span { width: 4px; height: 4px; border-radius: 50%; background: var(--warning); animation: bounce 1.2s ease-in-out infinite; }
|
||||
.feed-thinking-dots span:nth-child(2) { animation-delay: 0.15s; }
|
||||
.feed-thinking-dots span:nth-child(3) { animation-delay: 0.3s; }
|
||||
.feed-thinking-content {
|
||||
padding: 8px 10px; font-size: 12px; color: var(--text-tertiary);
|
||||
font-style: italic; line-height: 1.5; max-height: 120px; overflow-y: auto;
|
||||
}
|
||||
|
||||
.studio-code-block {
|
||||
background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius);
|
||||
overflow: hidden; margin: 8px 0;
|
||||
|
||||
@@ -13,6 +13,7 @@ export default defineConfig({
|
||||
'/api': {
|
||||
target: 'http://127.0.0.1:8095',
|
||||
changeOrigin: true,
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user