Compare commits

...

26 Commits

Author SHA1 Message Date
Augustin
8b6a7e8bc3 fix: register missing /api/config/reset and /api/starship/apply-theme routes
All checks were successful
Beta Release / beta (push) Successful in 40s
- Add resetConfig and applyStarshipTheme to frontend api client
- Register handleResetConfig and handleApplyStarshipTheme in server mux

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-22 20:57:25 +02:00
Augustin
58f8cb0bd3 fix(config): per-provider form state to avoid field cross-talk
All checks were successful
Beta Release / beta (push) Successful in 38s
- providerForm is now keyed by provider name
- Each provider (minimax/glm/claude) has isolated form data
- Validation and save target the specific provider being edited

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-22 20:56:04 +02:00
Augustin
b52feccc17 fix(onboarding): auto-save on done step, keyboard nav, error feedback
All checks were successful
Beta Release / beta (push) Successful in 40s
- Trigger save automatically when reaching done step
- Add Escape to go back, Enter to advance (works in text fields)
- Add back button visible between step 1 and last step
- Fix accent encoding in done message
- Show saving state and error with retry button

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-22 20:53:12 +02:00
Augustin
5bbac499a7 feat(config): add system panel with reset and starship theme, add onboarding wizard
All checks were successful
Beta Release / beta (push) Successful in 41s
- Add PanelSystem with reset config and apply starship theme (charm/zerotwo/default)
- Add OnboardingWizard that activates when profile is empty on first run
- Fix <thing> tag parsing in Shell AI messages (wait for </thing> before rendering)
- Add /api/config/reset and /api/starship/apply-theme endpoints
- Wire wizard trigger in App.jsx based on profile completeness

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-22 20:36:36 +02:00
Augustin
83d7a573c7 fix: correct version from 3.2 to 0.3.2
All checks were successful
Beta Release / beta (push) Successful in 41s
The version was incorrectly bumped to 3.2 instead of 0.3.2.
This follows the existing semver pattern (v0.2.0, v0.2.1, v0.3.1).

💾 Generated with Crush

Assisted-by: GLM-5-Turbo via Crush <crush@charm.land>
2026-04-22 20:29:09 +02:00
Augustin
0fe82f67df chore: bump version to 3.2
All checks were successful
Beta Release / beta (push) Successful in 45s
2026-04-22 20:19:47 +02:00
Augustin
4b9f2c377d Merge branch 'main' into develop 2026-04-22 20:19:35 +02:00
Augustin
95bd824259 refactor(config): remove Terminal sub-tab from Configuration page
All checks were successful
Stable Release / stable (push) Successful in 41s
2026-04-22 20:19:29 +02:00
Augustin
252f178bbd fix(terminal): init payload never sent due to ws.onopen being overwritten
connectWebSocket set ws.onopen to send the shell init payload, but
initTerminal immediately overwrote it with a state-only handler.
Switched to addEventListener so both handlers coexist.
2026-04-22 20:19:29 +02:00
Augustin
7dcf505360 fix(terminal): improve shell resolution with better error handling and ws proxy support
The `len(shell) <= 1` guard was too aggressive and provided no diagnostic info.
Now trims whitespace, resolves path in all cases, falls back to /bin/sh, and
logs detailed context for debugging. Also enable WebSocket proxying in Vite dev.
2026-04-22 20:19:29 +02:00
Augustin
8fb93fa47e feat(studio): parse AI thinking and tool launch messages in terminal panel
- Add message type detection: thinking (Reflexion/Thought/>), tool (TOOL_CALL),
  and normal AI responses
- Style thinking messages with italic blue, tool messages with yellow border
- Add toolLaunched i18n key for both fr and en locales

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-22 20:19:29 +02:00
Augustin
5ec373cd6a fix(studio): forward AI thinking chunks to frontend instead of dropping them
The ThinkingBlock component existed but was dead code — the backend
silently discarded all <think chunks. Now emits thinking SSE events
so the UI can display AI reflections in real-time.

\xe2\x98\x85 Generated with Crush

Assisted-by: GLM-5-Turbo via Crush <crush@charm.land>
2026-04-22 20:19:29 +02:00
Augustin
1eb5a6d00f feat(studio): add tool execution and hide AI thinking tags
Changes:
- Hide <think> tags from user in Studio chat
- Add tool call detection [TOOL_CALL:{...}] in AI responses
- Execute crush tool when requested by AI
- Show loading animation while AI is thinking

The AI can now:
1. Respond directly to user
2. Request tool execution via [TOOL_CALL:{"tool":"crush","task":"..."}]

The system automatically executes the tool and includes results.

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-22 20:19:29 +02:00
Augustin
cd5ebe083c fix(terminal): ignore invalid shell config from race condition
Reject shell paths with length <= 1 to prevent errors when user
input is accidentally sent as init message.

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-22 20:19:29 +02:00
Augustin
2004c15dd7 feat(shell): restore AI assistant panel
Re-add the AI assistant panel that was removed in previous refactoring.
The panel includes:
- Message history display
- Input field for AI queries
- Loading state indicator

Also restored the associated CSS styles and i18n translations.

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-22 20:19:29 +02:00
Augustin
9306152736 fix(terminal): restore terminal input and cursor visibility
- Fix shell execution to avoid --login flag causing issues on some shells
- Improve terminal initialization timing with requestAnimationFrame
- Force display visibility on xterm instances via CSS
- Ensure container has proper min-height and overflow handling

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-22 20:19:29 +02:00
Augustin
e15a034de5 refactor(api): split monolithic handlers.go into focused modules
Break down the 627-line handlers.go into specialized modules:
- handlers_chat.go: chat and streaming endpoints
- handlers_config.go: configuration endpoints
- handlers_common.go: shared utilities
- handlers_info.go: info and status endpoints
- handlers_terminal.go: terminal/shell endpoints
- handlers_tools.go: tool-related endpoints

Also includes config improvements, orchestrator enhancements, and
web component updates.

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-22 20:19:29 +02:00
Augustin
3b6cc38ea0 refactor(config): remove Terminal sub-tab from Configuration page
All checks were successful
Beta Release / beta (push) Successful in 41s
2026-04-22 20:13:17 +02:00
Augustin
93a22d4075 fix(terminal): init payload never sent due to ws.onopen being overwritten
All checks were successful
Beta Release / beta (push) Successful in 40s
connectWebSocket set ws.onopen to send the shell init payload, but
initTerminal immediately overwrote it with a state-only handler.
Switched to addEventListener so both handlers coexist.
2026-04-22 20:05:10 +02:00
Augustin
e0e1e73bca fix(terminal): improve shell resolution with better error handling and ws proxy support
All checks were successful
Beta Release / beta (push) Successful in 40s
The `len(shell) <= 1` guard was too aggressive and provided no diagnostic info.
Now trims whitespace, resolves path in all cases, falls back to /bin/sh, and
logs detailed context for debugging. Also enable WebSocket proxying in Vite dev.
2026-04-22 20:02:55 +02:00
Augustin
0496ca789b feat(studio): parse AI thinking and tool launch messages in terminal panel
All checks were successful
Beta Release / beta (push) Successful in 40s
- Add message type detection: thinking (Reflexion/Thought/>), tool (TOOL_CALL),
  and normal AI responses
- Style thinking messages with italic blue, tool messages with yellow border
- Add toolLaunched i18n key for both fr and en locales

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-22 19:41:42 +02:00
Augustin
b407ab879b fix(studio): forward AI thinking chunks to frontend instead of dropping them
All checks were successful
Beta Release / beta (push) Successful in 40s
The ThinkingBlock component existed but was dead code — the backend
silently discarded all <think chunks. Now emits thinking SSE events
so the UI can display AI reflections in real-time.

\xe2\x98\x85 Generated with Crush

Assisted-by: GLM-5-Turbo via Crush <crush@charm.land>
2026-04-22 19:12:32 +02:00
Augustin
12df184e11 feat(studio): add tool execution and hide AI thinking tags
All checks were successful
Beta Release / beta (push) Successful in 40s
Changes:
- Hide <think> tags from user in Studio chat
- Add tool call detection [TOOL_CALL:{...}] in AI responses
- Execute crush tool when requested by AI
- Show loading animation while AI is thinking

The AI can now:
1. Respond directly to user
2. Request tool execution via [TOOL_CALL:{"tool":"crush","task":"..."}]

The system automatically executes the tool and includes results.

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-22 19:04:59 +02:00
Augustin
8af6d25e28 fix(terminal): ignore invalid shell config from race condition
All checks were successful
Beta Release / beta (push) Successful in 40s
Reject shell paths with length <= 1 to prevent errors when user
input is accidentally sent as init message.

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-22 18:56:33 +02:00
Augustin
4fd599adec feat(shell): restore AI assistant panel
All checks were successful
Beta Release / beta (push) Successful in 38s
Re-add the AI assistant panel that was removed in previous refactoring.
The panel includes:
- Message history display
- Input field for AI queries
- Loading state indicator

Also restored the associated CSS styles and i18n translations.

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-22 18:51:33 +02:00
CI Bot
80c11cab3f chore: update CHANGELOG for v0.3.0 2026-04-21 21:08:06 +00:00
17 changed files with 1131 additions and 214 deletions

View File

@@ -4,6 +4,67 @@ 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.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

View File

@@ -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"})
}
}

View File

@@ -3,8 +3,11 @@ package api
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
@@ -281,3 +284,203 @@ func (s *Server) handleGetTerminalThemes(w http.ResponseWriter, r *http.Request)
}
writeJSON(w, map[string]interface{}{"themes": themes})
}
func (s *Server) handleResetConfig(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
dir, err := config.ConfigDir()
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
path := filepath.Join(dir, "config.yaml")
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
s.config = config.Default()
if err := config.Save(s.config); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]string{"status": "ok"})
}
func (s *Server) handleApplyStarshipTheme(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
var body struct {
Theme string `json:"theme"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
if body.Theme == "" {
body.Theme = s.config.Terminal.PromptTheme
}
cfgDir, err := config.ConfigDir()
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
starshipDir := filepath.Join(cfgDir, "starship")
if err := os.MkdirAll(starshipDir, 0755); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
themeFile := filepath.Join(starshipDir, "starship.toml")
themeContent := getStarshipThemeConfig(body.Theme)
if err := os.WriteFile(themeFile, []byte(themeContent), 0644); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
home, _ := os.UserHomeDir()
shellRCs := []string{
filepath.Join(home, ".bashrc"),
filepath.Join(home, ".zshrc"),
}
for _, rc := range shellRCs {
if _, err := os.Stat(rc); err != nil {
continue
}
content, _ := os.ReadFile(rc)
if strings.Contains(string(content), "STARSHIP_CONFIG") {
continue
}
exportLine := fmt.Sprintf("\n# Muyue Starship config\nexport STARSHIP_CONFIG=%s\n", themeFile)
f, err := os.OpenFile(rc, os.O_APPEND|os.O_WRONLY, 0644)
if err != nil {
continue
}
f.WriteString(exportLine)
f.Close()
}
s.config.Terminal.PromptTheme = body.Theme
config.Save(s.config)
writeJSON(w, map[string]interface{}{"status": "ok", "config": themeFile})
}
func getStarshipThemeConfig(theme string) string {
switch theme {
case "charm":
return `[format]
before_format = "$"
format = """
$username$directory$git_branch$git_status$cmd_duration$line_break$character"""
[character]
success_symbol = "[➜](bold #00E676)"
error_symbol = "[✗](bold #FF0033)"
[directory]
truncation_length = 3
truncation_symbol = "…/"
style = "bold #00BCD4"
[username]
show_on_left = false
style_user = "bold #FF0033"
style_root = "bold #FF0033"
[git_branch]
symbol = " "
format = "on [$symbol$branch]($style)"
style = "bold #FFD740"
[git_status]
format = "[$all_status$ahead_behind]($style) "
style = "bold #FF1A5E"
conflicted = "!"
untracked = "?"
modified = "~"
staged = "[+]"
renamed = "»"
deleted = "-"
[cmd_duration]
min_time = 500
format = "took [$duration]($style)"
style = "bold #75715E"
`
case "zerotwo":
return `[format]
before_format = "$"
format = """
$username$directory$git_branch$git_status$cmd_duration$line_break$character"""
[character]
success_symbol = "[](bold #3B82F6)"
error_symbol = "[](bold #EF4444)"
[directory]
truncation_length = 3
truncation_symbol = "…/"
style = "bold #8B5CF6"
[username]
show_on_left = false
style_user = "bold #EC4899"
style_root = "bold #EF4444"
[git_branch]
symbol = " "
format = "on [$symbol$branch]($style)"
style = "bold #F472B6"
[git_status]
format = "[$all_status$ahead_behind]($style) "
style = "bold #EF4444"
conflicted = "!"
untracked = "?"
modified = "~"
staged = "[+]"
renamed = "»"
deleted = "-"
[cmd_duration]
min_time = 500
format = "took [$duration]($style)"
style = "bold #6B7280"
`
default:
return `[format]
before_format = "$"
format = """
$username$directory$git_branch$git_status$line_break$character"""
[character]
success_symbol = "[](bold green)"
error_symbol = "[](bold red)"
[directory]
truncation_length = 3
truncation_symbol = "…/"
style = "bold cyan"
[username]
show_on_left = false
style_user = "bold red"
style_root = "bold red"
[git_branch]
symbol = " "
format = "on [$symbol$branch]($style)"
style = "bold yellow"
[cmd_duration]
min_time = 500
format = "took [$duration]($style)"
style = "bold bright-black"
`
}
}

View 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()
}

View File

@@ -48,6 +48,8 @@ func (s *Server) routes() {
s.mux.HandleFunc("/api/mcp/configure", s.handleMCPConfigure)
s.mux.HandleFunc("/api/config/profile", s.handleSaveProfile)
s.mux.HandleFunc("/api/config/provider", s.handleSaveProvider)
s.mux.HandleFunc("/api/config/reset", s.handleResetConfig)
s.mux.HandleFunc("/api/starship/apply-theme", s.handleApplyStarshipTheme)
s.mux.HandleFunc("/api/providers/validate", s.handleValidateProvider)
s.mux.HandleFunc("/api/update/run", s.handleRunUpdate)
s.mux.HandleFunc("/api/chat", s.handleChat)

View File

@@ -56,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
@@ -96,17 +100,26 @@ 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
}
@@ -125,12 +138,14 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
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 {

View File

@@ -2,7 +2,7 @@ package version
const (
Name = "muyue"
Version = "0.3.1"
Version = "0.3.2"
Author = "La Légion de Muyue"
)

View File

@@ -28,6 +28,8 @@ const api = {
savePreferences: (prefs) => request('/preferences', { method: 'PUT', body: JSON.stringify(prefs) }),
saveProfile: (profile) => request('/config/profile', { method: 'PUT', body: JSON.stringify(profile) }),
saveProvider: (provider) => request('/config/provider', { method: 'PUT', body: JSON.stringify(provider) }),
resetConfig: () => request('/config/reset', { method: 'POST' }),
applyStarshipTheme: (theme) => request('/starship/apply-theme', { method: 'POST', body: JSON.stringify({ theme }) }),
validateProvider: (provider) => request('/providers/validate', { method: 'POST', body: JSON.stringify(provider) }),
runUpdate: (tool) => request('/update/run', { method: 'POST', body: JSON.stringify({ tool: tool || '' }) }),
runCommand: (command, cwd) => request('/terminal', { method: 'POST', body: JSON.stringify({ command, cwd }) }),
@@ -68,7 +70,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 {}
}

View File

@@ -7,6 +7,7 @@ import Dashboard from './Dashboard'
import Studio from './Studio'
import Shell from './Shell'
import Config from './Config'
import OnboardingWizard from './OnboardingWizard'
export default function App() {
const [activeTab, setActiveTab] = useState('dash')
@@ -15,6 +16,7 @@ export default function App() {
const [updates, setUpdates] = useState([])
const [tools, setTools] = useState([])
const [config, setConfig] = useState(null)
const [showOnboarding, setShowOnboarding] = useState(false)
const { t, layout } = useI18n()
const TABS = useMemo(() => [
@@ -32,8 +34,11 @@ export default function App() {
setConfig(d)
const theme = d.profile?.preferences?.theme || 'cyberpunk-red'
applyTheme(getTheme(theme))
const hasProfile = d.profile?.name || d.profile?.pseudo
if (!hasProfile) setShowOnboarding(true)
}).catch(() => {
applyTheme(getTheme('cyberpunk-red'))
setShowOnboarding(true)
})
}, [])
@@ -150,6 +155,8 @@ export default function App() {
</span>
</div>
</footer>
{showOnboarding && <OnboardingWizard api={api} onComplete={() => setShowOnboarding(false)} />}
</div>
)
}

View File

@@ -6,10 +6,10 @@ import { getLayoutList } from '../i18n/keyboards'
const PANELS = [
{ id: 'profile', icon: User },
{ id: 'providers', icon: Brain },
{ id: 'terminal', icon: Monitor },
{ id: 'updates', icon: RefreshCw },
{ id: 'locale', icon: Globe },
{ id: 'skills', icon: Wrench },
{ id: 'system', icon: Monitor },
]
export default function Config({ api }) {
@@ -25,11 +25,9 @@ export default function Config({ api }) {
const [editProfile, setEditProfile] = useState(false)
const [editProvider, setEditProvider] = useState(null)
const [profileForm, setProfileForm] = useState({})
const [providerForm, setProviderForm] = useState({})
const [providerForm, setProviderForm] = useState({}) // keyed by provider name
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])
@@ -115,9 +107,11 @@ export default function Config({ api }) {
}
}
const handleSaveProvider = async () => {
const handleSaveProvider = async (name) => {
const form = providerForm[name]
if (!form) return
try {
await api.saveProvider(providerForm)
await api.saveProvider({ name, ...form })
setEditProvider(null)
loadData()
showToast(t('config.saved'))
@@ -126,25 +120,16 @@ 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,
api_key: p.apiKey || '',
model: p.model || '',
base_url: p.baseURL || '',
})
setProviderForm(prev => ({
...prev,
[p.name]: {
name: p.name,
api_key: p.apiKey || '',
model: p.model || '',
base_url: p.baseURL || '',
},
}))
setEditProvider(p.name)
}
@@ -213,13 +198,10 @@ 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}
/>
{activePanel === 'system' && (
<PanelSystem api={api} t={t} />
)}
</div>
</div>
</div>
@@ -325,23 +307,26 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm
className="config-form-input"
type="password"
placeholder={t('config.tokenPlaceholder')}
value={isEditing ? providerForm.api_key : ''}
value={isEditing ? (providerForm[p.name]?.api_key || '') : ''}
onChange={e => {
if (!isEditing) openProviderEdit(p)
setProviderForm(f => ({ ...f, api_key: e.target.value }))
setProviderForm(prev => ({
...prev,
[p.name]: { ...(prev[p.name] || {}), api_key: e.target.value },
}))
}}
/>
</div>
<div className="provider-setup-token-actions">
<button
className="sm primary"
disabled={validating === p.name || !providerForm.api_key}
onClick={() => handleValidate(p.name, providerForm.api_key, providerForm.model, providerForm.base_url)}
disabled={validating === p.name || !providerForm[p.name]?.api_key}
onClick={() => handleValidate(p.name, providerForm[p.name]?.api_key, providerForm[p.name]?.model, providerForm[p.name]?.base_url)}
>
{validating === p.name ? t('config.validating') : t('config.validateKey')}
</button>
{isValidationTarget && validationStatus?.valid && (
<button className="sm" onClick={handleSaveProvider}>{t('config.save')}</button>
<button className="sm" onClick={() => handleSaveProvider(p.name)}>{t('config.save')}</button>
)}
</div>
</div>
@@ -470,99 +455,70 @@ 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',
function PanelSystem({ api, t }) {
const [resetConfirm, setResetConfirm] = useState(false)
const [toast, setToast] = useState(null)
const showToast = (msg) => {
setToast(msg)
setTimeout(() => setToast(null), 3000)
}
const handleReset = async () => {
try {
await api.resetConfig()
setResetConfirm(false)
showToast(t('config.resetDone'))
setTimeout(() => window.location.reload(), 1500)
} catch (err) {
showToast(`${t('config.error')}: ${err.message}`)
}
}
const handleApplyStarship = async () => {
try {
await api.applyStarshipTheme('charm')
showToast(t('config.starshipApplied'))
} catch (err) {
showToast(`${t('config.error')}: ${err.message}`)
}
}
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>
))}
<>
{toast && <div className="config-toast">{toast}</div>}
<div className="config-card">
<div className="config-card-row" style={{ marginBottom: 16 }}>
<span className="config-card-label" style={{ fontWeight: 600 }}>{t('config.applyStarship')}</span>
</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 style={{ fontSize: 12, color: 'var(--muted)', marginBottom: 12 }}>
{t('config.starshipApplied')}
</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 className="sm primary" onClick={handleApplyStarship}>
{t('config.applyStarship')}
</button>
</div>
</div>
<div className="config-card" style={{ marginTop: 12 }}>
<div className="config-card-row" style={{ marginBottom: 16 }}>
<span className="config-card-label" style={{ fontWeight: 600 }}>{t('config.resetConfig')}</span>
</div>
{resetConfirm ? (
<div>
<div style={{ fontSize: 12, color: 'var(--warning)', marginBottom: 12 }}>
{t('config.resetConfirm')}
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button className="sm" onClick={() => setResetConfirm(false)}>{t('config.cancel')}</button>
<button className="sm danger" onClick={handleReset}>{t('config.resetConfig')}</button>
</div>
</div>
) : (
<button className="sm ghost danger" onClick={() => setResetConfirm(true)}>
{t('config.resetConfig')}
</button>
)}
</div>
</>
)
}

View File

@@ -0,0 +1,262 @@
import { useState, useEffect } from 'react'
import { Sparkles, ArrowRight, ArrowLeft } from 'lucide-react'
import { useI18n, LANGUAGES } from '../i18n'
import { getLayoutList } from '../i18n/keyboards'
const STEPS = [
{ key: 'welcome', title: 'welcome', field: null },
{ key: 'name', title: 'name', field: 'name' },
{ key: 'language', title: 'language', field: 'language' },
{ key: 'keyboard', title: 'keyboard', field: 'keyboard' },
{ key: 'editor', title: 'editor', field: 'editor' },
{ key: 'done', title: 'done', field: null },
]
const EDITOR_SUGGESTIONS = ['vim', 'nvim', 'vscode', 'emacs', 'nano', 'helix']
export default function OnboardingWizard({ api, onComplete }) {
const { t, language, keyboard, setLanguage, setKeyboard } = useI18n()
const [step, setStep] = useState(0)
const [answers, setAnswers] = useState({
name: '',
language: 'fr',
keyboard: 'azerty',
editor: '',
})
const [saving, setSaving] = useState(false)
const [error, setError] = useState(null)
const current = STEPS[step]
const layouts = getLayoutList()
const goNext = () => {
if (step < STEPS.length - 1) setStep(step + 1)
}
const goPrev = () => {
if (step > 0) setStep(step - 1)
}
useEffect(() => {
const handler = (e) => {
if (e.key === 'Escape') { goPrev(); return }
if (e.key === 'Enter' && current.key !== 'done') { e.preventDefault(); goNext() }
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [step, current])
useEffect(() => {
if (current.key === 'done' && !saving) {
handleSave()
}
}, [step])
const handleSave = async () => {
setSaving(true)
setError(null)
try {
await api.saveProfile({
name: answers.name,
pseudo: answers.name.split(' ')[0] || 'user',
editor: answers.editor,
})
await api.savePreferences({
language: answers.language,
keyboard_layout: answers.keyboard,
})
onComplete()
} catch (err) {
setError(err.message || 'Erreur lors de la sauvegarde')
setSaving(false)
}
}
return (
<div className="onboarding-overlay">
<div className="onboarding-card">
<div className="onboarding-header">
<Sparkles size={20} style={{ color: 'var(--accent)' }} />
<span> Muyue Setup</span>
</div>
<div className="onboarding-progress">
{STEPS.map((_, i) => (
<div key={i} className={`onboarding-dot ${i === step ? 'active' : ''} ${i < step ? 'done' : ''}`} />
))}
</div>
<div className="onboarding-body">
{current.key === 'welcome' && (
<div className="onboarding-step">
<div className="onboarding-title">Bienvenue ! 👋</div>
<div className="onboarding-desc">
Je suis votre assistant de configuration. Quelques questions rapides pour personnaliser votre expérience.
</div>
</div>
)}
{current.key === 'name' && (
<div className="onboarding-step">
<div className="onboarding-title">Comment vous appelez-vous ?</div>
<input
className="onboarding-input"
placeholder="Votre nom..."
value={answers.name}
onChange={e => setAnswers(a => ({ ...a, name: e.target.value }))}
autoFocus
/>
</div>
)}
{current.key === 'language' && (
<div className="onboarding-step">
<div className="onboarding-title">Quelle langue pr\u00e9f\u00e9rez-vous ?</div>
<div className="onboarding-chips">
{LANGUAGES.map(lang => (
<div
key={lang.id}
className={`chip ${answers.language === lang.id ? 'active' : ''}`}
onClick={() => setAnswers(a => ({ ...a, language: lang.id }))}
>
{lang.name}
</div>
))}
</div>
</div>
)}
{current.key === 'keyboard' && (
<div className="onboarding-step">
<div className="onboarding-title">Disposition du clavier ?</div>
<div className="onboarding-chips">
{layouts.map(l => (
<div
key={l.id}
className={`chip ${answers.keyboard === l.id ? 'active' : ''}`}
onClick={() => setAnswers(a => ({ ...a, keyboard: l.id }))}
>
{l.name}
</div>
))}
</div>
</div>
)}
{current.key === 'editor' && (
<div className="onboarding-step">
<div className="onboarding-title">Quel \u00e9diteur utilisez-vous ?</div>
<div className="onboarding-chips">
{EDITOR_SUGGESTIONS.map(ed => (
<div
key={ed}
className={`chip ${answers.editor === ed ? 'active' : ''}`}
onClick={() => setAnswers(a => ({ ...a, editor: ed }))}
>
{ed}
</div>
))}
</div>
<input
className="onboarding-input"
style={{ marginTop: 12 }}
placeholder="Autre (vim, nvim, vscode...)"
value={answers.editor}
onChange={e => setAnswers(a => ({ ...a, editor: e.target.value }))}
autoFocus
/>
</div>
)}
{current.key === 'done' && (
<div className="onboarding-step">
{saving ? (
<>
<div className="onboarding-title">Configuration en cours...</div>
<div className="onboarding-desc">Sauvegarde de vos préférences.</div>
</>
) : error ? (
<>
<div className="onboarding-title" style={{ color: 'var(--error)' }}>Erreur</div>
<div className="onboarding-desc" style={{ color: 'var(--error)' }}>{error}</div>
<button className="primary" style={{ alignSelf: 'flex-start', marginTop: 8 }} onClick={() => handleSave()}>Réessayer</button>
</>
) : (
<>
<div className="onboarding-title">C'est parti ! 🚀</div>
<div className="onboarding-desc">
Votre profil est configuré. Vous pouvez toujours ajuster les paramètres dans l'onglet Configuration.
</div>
</>
)}
</div>
)}
</div>
<div className="onboarding-footer">
{step > 0 && step < STEPS.length - 1 && (
<button className="ghost" onClick={goPrev}>
<ArrowLeft size={14} /> Précédent
</button>
)}
<div style={{ flex: 1 }} />
{step < STEPS.length - 1 && (
<button className="primary" onClick={goNext}>
Suivant <ArrowRight size={14} />
</button>
)}
</div>
</div>
<style>{`
.onboarding-overlay {
position: fixed; inset: 0; z-index: 500;
background: rgba(10,10,12,0.85);
display: flex; align-items: center; justify-content: center;
backdrop-filter: blur(8px);
}
.onboarding-card {
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
width: 480px; max-width: 90vw;
box-shadow: 0 24px 64px rgba(0,0,0,0.5);
overflow: hidden;
}
.onboarding-header {
display: flex; align-items: center; gap: 8px;
padding: 16px 20px; font-size: 14px; font-weight: 700;
color: var(--accent); border-bottom: 1px solid var(--border);
background: var(--bg-surface);
}
.onboarding-progress {
display: flex; gap: 6px; padding: 14px 20px;
background: var(--bg-surface);
border-bottom: 1px solid var(--border);
}
.onboarding-dot {
width: 32px; height: 4px; border-radius: 2px;
background: var(--bg-input); transition: all 0.3s;
}
.onboarding-dot.active { background: var(--accent); }
.onboarding-dot.done { background: var(--accent-dim); }
.onboarding-body { padding: 28px 24px; min-height: 200px; }
.onboarding-step { display: flex; flex-direction: column; gap: 16px; }
.onboarding-title { font-size: 18px; font-weight: 700; color: var(--text-primary); }
.onboarding-desc { font-size: 14px; color: var(--text-tertiary); line-height: 1.6; }
.onboarding-input {
width: 100%; background: var(--bg-input); border: 1px solid var(--border);
border-radius: var(--radius); padding: 10px 14px; color: var(--text-primary);
font-size: 14px; outline: none; transition: border-color 0.2s, box-shadow 0.2s;
}
.onboarding-input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--border-accent); }
.onboarding-chips { display: flex; gap: 8px; flex-wrap: wrap; }
.onboarding-footer {
display: flex; justify-content: flex-end; gap: 8px;
padding: 16px 20px; border-top: 1px solid var(--border);
background: var(--bg-surface);
}
`}</style>
</div>
)
}

View File

@@ -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 || [])
@@ -361,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">
@@ -475,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()}>

View File

@@ -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>

View File

@@ -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: {
@@ -117,6 +121,7 @@ const en = {
updates: 'Updates',
locale: 'Language & Keyboard',
skills: 'Skills',
system: 'System',
},
profile: 'Profile',
name: 'Name',
@@ -176,6 +181,12 @@ const en = {
fontFamily: 'Font Family',
preview: 'Preview',
saving: 'Saving...',
resetConfig: 'Reset all',
resetConfirm: 'Are you sure? All preferences will be erased.',
resetDone: 'Settings reset.',
applyStarship: 'Apply starship',
starshipApplied: 'Starship theme applied! Restart your shell to see the result.',
starshipError: 'Failed to apply starship theme.',
},
}

View File

@@ -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: {
@@ -116,7 +120,8 @@ const fr = {
terminal: 'Terminal',
updates: 'Mises \u00e0 jour',
locale: 'Langue & Clavier',
skills: 'Comp\u00e9tences',
skills: 'Comp\u00e9ENCES',
system: 'Syst\u00e8me',
},
profile: 'Profil',
name: 'Nom',
@@ -139,7 +144,7 @@ const fr = {
save: 'Enregistrer',
saved: 'Enregistr\u00e9 !',
error: 'Erreur',
skills: 'Comp\u00e9tences',
skills: 'Comp\u00e9ENCES',
noSkills: 'Aucune comp\u00e9tence install\u00e9e.',
runSkillsInit: 'Ex\u00e9cutez muyue skills init',
language: 'Langue',
@@ -163,10 +168,10 @@ const fr = {
editProfile: 'Modifier',
editProvider: 'Configurer',
validateKey: 'Valider',
validating: 'Vérification...',
keyValid: 'Clé valide',
keyInvalid: 'Clé invalide',
connectionFailed: 'Connexion échouée',
validating: 'V\u00e9rification...',
keyValid: 'Cl\u00e9 valide',
keyInvalid: 'Cl\u00e9 invalide',
connectionFailed: 'Connexion \u00e9chou\u00e9e',
enterToken: 'Entrez votre token API pour {provider}',
tokenPlaceholder: 'sk-...',
setupDescription: 'Configurez le token de votre fournisseur IA pour utiliser l\'assistant.',
@@ -176,6 +181,12 @@ const fr = {
fontFamily: 'Police',
preview: 'Aper\u00e7u',
saving: 'Enregistrement...',
resetConfig: 'R\u00e9initialiser',
resetConfirm: '\u00cates-vous s\u00fbr ? Toutes les pr\u00e9f\u00e9rences seront effac\u00e9es.',
resetDone: 'Param\u00e8tres r\u00e9initialis\u00e9s.',
applyStarship: 'Appliquer starship',
starshipApplied: 'Th\u00e8me starship appliqu\u00e9 ! Red\u00e9marrez votre shell pour voir le r\u00e9sultat.',
starshipError: '\u00c9chec de l\u2019application du th\u00e8me starship.',
},
}

View File

@@ -385,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;
@@ -570,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;

View File

@@ -13,6 +13,7 @@ export default defineConfig({
'/api': {
target: 'http://127.0.0.1:8095',
changeOrigin: true,
ws: true,
},
},
},