Compare commits

...

11 Commits

Author SHA1 Message Date
CI Bot
80c11cab3f chore: update CHANGELOG for v0.3.0 2026-04-21 21:08:06 +00:00
Augustin
0b221094f2 fix(terminal): resolve PTY shell exec error, simplify CLI, unify Config tabs, restore Studio CSS
All checks were successful
Beta Release / beta (push) Successful in 37s
Stable Release / stable (push) Successful in 37s
- Fix detectShell() to return full paths via LookPath (was returning bare
  names causing exec error on some systems)
- Add shell path validation before pty.Start to prevent crashes
- Simplify CLI: remove all subcommands, keep only desktop launch with --port
- Restore missing Studio shared CSS (code blocks, input area, animations)
- Replace Config vertical sidebar with horizontal nav-tabs matching main layout

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-21 23:06:39 +02:00
Augustin
7f674730c7 feat: add API key validation flow for AI provider config
All checks were successful
Beta Release / beta (push) Successful in 37s
- Add POST /api/providers/validate backend endpoint that sends a test
  request to the provider's chat/completions API to verify the key
- Add validateProvider to frontend API client
- Redesign PanelProviders: show token input inline with Validate button,
  display valid/invalid badge after validation, Save only appears after
  successful validation
- Add i18n keys (EN/FR) for validation flow

💾 Generated with Crush

Assisted-by: GLM-5-Turbo via Crush <crush@charm.land>
2026-04-21 22:53:52 +02:00
Augustin
040e482c75 feat(studio): replace sidebar layout with unified execution feed styles
All checks were successful
Beta Release / beta (push) Successful in 36s
Replace old Studio sidebar/chat bubble CSS with new feed-based layout.
Add studio.cleared i18n key for /clear command feedback.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-21 22:46:36 +02:00
Augustin
c8903efa5e fix: guard against empty tabs array in closeTab
All checks were successful
Beta Release / beta (push) Successful in 37s
💾 Generated with Crush

Assisted-by: GLM-5-Turbo via Crush <crush@charm.land>
2026-04-21 22:43:38 +02:00
Augustin
f3cb306053 refactor: redesign Config as settings window with sidebar panels, remove system overview from Dashboard
All checks were successful
Beta Release / beta (push) Successful in 38s
- Config: sidebar navigation with 5 panels (Profile, AI Providers, Updates, Locale, Skills)
- Dashboard: remove duplicated system overview section, keep workflows and activity log
- New CSS for config window layout, cards, provider cards, update rows
- Add i18n panel keys (FR/EN)

💾 Generated with Crush

Assisted-by: GLM-5-Turbo via Crush <crush@charm.land>
2026-04-21 22:41:25 +02:00
Augustin
3cdcb22068 feat: add multi-tab terminal with SSH support, config editing, and dashboard redesign
All checks were successful
Beta Release / beta (push) Successful in 39s
- Terminal: multi-tab sessions, SSH connections, shell detection (zsh/bash/fish/wsl/powershell)
- Config: inline profile & provider editing, system update management
- Dashboard: grid layout with inline tools/notifications/workflows sections
- Add lucide-react icons, i18n keys (FR/EN), and new CSS components

💾 Generated with Crush

Assisted-by: GLM-5-Turbo via Crush <crush@charm.land>
2026-04-21 22:35:49 +02:00
Augustin
ee18bbeb53 feat(studio): add i18n keys and CSS for redesigned AI chat interface
All checks were successful
Beta Release / beta (push) Successful in 2m10s
Add missing English translations and full cyberpunk-themed CSS for the
new Studio tab: central AI chat with context panels (plans, agents,
activity), streaming cursor, plan detail expansion, and collapsible
sidebar.

Generated with Crush

Assisted-by: GLM-5-Turbo via Crush <crush@charm.land>
2026-04-21 22:18:31 +02:00
Augustin
b0b0e1d308 chore: bump version to 0.3.0
Some checks failed
Beta Release / beta (push) Has been cancelled
feat(shell): real terminal with xterm.js + PTY over WebSocket

Replace fake shell input with a full PTY-backed terminal using xterm.js.
Apps like btop, vim, htop now work. AI chat panel is always visible.

Backend:
- Add WebSocket handler /api/ws/terminal with creack/pty
- Allocate real pseudo-terminal with TERM=xterm-256color
- Bidirectional I/O + dynamic resize via pty.Setsize
- Skip JSON headers on /api/ws/* paths for WebSocket upgrade

Frontend:
- Integrate xterm.js with FitAddon and WebLinksAddon
- Cyberpunk color theme matching app design
- ResizeObserver for automatic terminal resizing
- AI assistant panel always visible (340px, no toggle)
- Connection status indicator (green/red dot)

Dependencies:
- Go: github.com/gorilla/websocket, github.com/creack/pty/v2
- npm: @xterm/xterm, @xterm/addon-fit, @xterm/addon-web-links

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-21 22:17:24 +02:00
Augustin
fc7981037f chore: remove dead code (packages, functions, types, constants)
All checks were successful
Beta Release / beta (push) Successful in 34s
Remove 5 unused packages (daemon, preview, proxy, workflow) and dead
symbols across 7 files: orchestrator workflow engine, skills Target type
and Update(), LSP config generation, installer SetupPrompt(), unexported
desktop options, and version License/Prerelease. Total: -1453 lines.
2026-04-21 22:09:42 +02:00
Augustin
f7222b0f6c docs: rewrite README and CHANGELOG for desktop app mode
All checks were successful
Beta Release / beta (push) Successful in 32s
Update README to reflect TUI removal and new React desktop UI with
API backend, i18n, themes, and keyboard layout support. Fix duplicate
v0.2.1 entries in CHANGELOG and add [Unreleased] section for recent
desktop/i18n/theme changes.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-21 22:02:09 +02:00
34 changed files with 2799 additions and 2515 deletions

View File

@@ -4,53 +4,94 @@ 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.2.1
## 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.2.1/muyue-linux-amd64.tar.gz) |
| Linux ARM64 | [muyue-linux-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.1/muyue-linux-arm64.tar.gz) |
| macOS Intel | [muyue-darwin-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.1/muyue-darwin-amd64.tar.gz) |
| macOS Apple Silicon | [muyue-darwin-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.1/muyue-darwin-arm64.tar.gz) |
| Windows x86_64 | [muyue-windows-amd64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.1/muyue-windows-amd64.zip) |
| Windows ARM64 | [muyue-windows-arm64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.1/muyue-windows-arm64.zip) |
| 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.2.1/muyue-linux-amd64.tar.gz | tar xz
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.2.1/muyue-darwin-arm64.tar.gz | tar xz
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.2.1/muyue-windows-amd64.zip" -OutFile "muyue.zip"
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]
### Added
- **Desktop mode**: React 19 web UI served locally, auto-opens in browser. Frontend embedded in Go binary via `go:embed`.
- **API backend**: 15 REST endpoints (`/api/info`, `/api/system`, `/api/tools`, `/api/config`, `/api/providers`, `/api/skills`, `/api/lsp`, `/api/mcp`, `/api/updates`, `/api/scan`, `/api/install`, `/api/terminal`, `/api/mcp/configure`, `/api/preferences`).
- **i18n**: Full FR/EN translation system with keyboard layout awareness (AZERTY, QWERTY, QWERTZ). Preferences synced to backend.
- **Themes**: 4 built-in themes (Cyberpunk Red, Cyberpunk Pink, Midnight Blue, Matrix Green) with 30+ CSS custom properties applied at runtime.
- **Desktop flags**: `--port=PORT` to specify port, `--no-open` to skip browser auto-open.
- **SPA routing**: Frontend handles client-side routing via catch-all fallback.
- **CI**: Frontend build step (`npm ci && npm run build`) added to all 3 CI pipelines.
### Changed
- **Default mode**: `muyue` now launches the desktop web app instead of the TUI. The TUI has been removed entirely.
- **Single binary**: `cmd/muyue-desktop` merged into `cmd/muyue`. Only one binary needed.
- **Frontend**: Moved from `cmd/muyue-desktop/frontend/` to `web/` and embedded via `web/embed.go`.
- **Go module**: Dependencies cleaned up — removed indirect TUI-related packages.
- **Makefile**: `build` target now runs `frontend` (npm build) automatically. Added `dev-desktop` target for Vite dev server.
### Removed
- **TUI**: All `internal/tui/` code removed (model, views, handlers, animations, terminal, styles).
- **`cmd/muyue-desktop/`**: Separate desktop binary removed; merged into main binary.
## v0.2.1
### Changes since v0.2.0
- chore: bump version to 0.2.1, update README for TUI redesign (22fb282)
### Downloads
| Platform | File |
@@ -62,6 +103,11 @@ Move-Item muyue-windows-amd64.exe C:\Windows\muyue.exe
| Windows x86_64 | [muyue-windows-amd64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.1/muyue-windows-amd64.zip) |
| Windows ARM64 | [muyue-windows-arm64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.1/muyue-windows-arm64.zip) |
### Changes since v0.2.0
- feat: complete TUI redesign with cyberpunk theme (#1) (cb8e3d0)
- chore: bump version to 0.2.1, update README for TUI redesign (22fb282)
### Install
**Linux (x86_64)**
@@ -85,9 +131,19 @@ Expand-Archive -Path "muyue.zip" -DestinationPath "."
Move-Item muyue-windows-amd64.exe C:\Windows\muyue.exe
```
## v0.2.0
### Downloads
| Platform | File |
|----------|------|
| Linux x86_64 | [muyue-linux-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.0/muyue-linux-amd64.tar.gz) |
| Linux ARM64 | [muyue-linux-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.0/muyue-linux-arm64.tar.gz) |
| macOS Intel | [muyue-darwin-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.0/muyue-darwin-amd64.tar.gz) |
| macOS Apple Silicon | [muyue-darwin-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.0/muyue-darwin-arm64.tar.gz) |
| Windows x86_64 | [muyue-windows-amd64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.0/muyue-windows-amd64.zip) |
| Windows ARM64 | [muyue-windows-arm64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.0/muyue-windows-arm64.zip) |
### Changes since start
- refactor: redesign TUI with 4 tabs, red/rose theme, split layouts (035e923)
@@ -121,17 +177,6 @@ Move-Item muyue-windows-amd64.exe C:\Windows\muyue.exe
- ci: fix Gitea Actions - native checkout + auto-release on push (78c7239)
- ci: migrate workflows to Gitea Actions with self-hosted runner (811a9aa)
### Downloads
| Platform | File |
|----------|------|
| Linux x86_64 | [muyue-linux-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.0/muyue-linux-amd64.tar.gz) |
| Linux ARM64 | [muyue-linux-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.0/muyue-linux-arm64.tar.gz) |
| macOS Intel | [muyue-darwin-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.0/muyue-darwin-amd64.tar.gz) |
| macOS Apple Silicon | [muyue-darwin-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.0/muyue-darwin-arm64.tar.gz) |
| Windows x86_64 | [muyue-windows-amd64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.0/muyue-windows-amd64.zip) |
| Windows ARM64 | [muyue-windows-arm64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.0/muyue-windows-arm64.zip) |
### Install
**Linux (x86_64)**
@@ -155,7 +200,6 @@ Expand-Archive -Path "muyue.zip" -DestinationPath "."
Move-Item muyue-windows-amd64.exe C:\Windows\muyue.exe
```
## [0.2.0] - 2026-04-20
### Added

174
README.md
View File

@@ -4,25 +4,30 @@ AI-powered development environment assistant by **La Légion de Muyue**.
## What it does
`muyue` is a single binary that transforms your entire development environment:
`muyue` is a single binary (frontend embedded) that transforms your entire development environment:
- **Desktop app** — React web UI served locally, auto-opens in your browser
- **Scans** your system for tools, runtimes, and configs
- **Installs** missing tools automatically (Crush, Claude Code, BMAD, Starship, runtimes...)
- **Updates** everything in the background
- **Profiles** you on first run to personalize the experience
- **Unifies** control of Crush and Claude Code from one TUI
- **Unifies** control of Crush and Claude Code from one interface
- **Orchestrates** AI agents via MiniMax M2.7
- **Customizes** your terminal prompt (branch, commits, language, etc.)
- **Configures** MCP servers, LSPs, and skills automatically
- **Previews** HTML/visual outputs in your browser
- **i18n** — Full FR/EN support with keyboard layout awareness (AZERTY, QWERTY, QWERTZ)
- **4 themes** — Cyberpunk Red, Cyberpunk Pink, Midnight Blue, Matrix Green
## Tech Stack
- **Go** — single binary, no dependencies
- **Charm** — Bubble Tea, Lip Gloss, Huh (TUI, styling, forms)
- **Starship** — terminal prompt customization
- **MiniMax M2.7** — AI orchestration
- **BMAD-METHOD** — structured development workflows
| Layer | Technology |
|-------|-----------|
| **Backend** | Go 1.24 — single binary, no runtime dependencies |
| **Frontend** | React 19, Vite 8 — embedded via `go:embed` |
| **Styling** | CSS custom properties, 4 built-in themes |
| **i18n** | Custom FR/EN system with keyboard layout awareness |
| **CLI** | Charm (Bubble Tea, Huh) — for setup wizard, profiler, and CLI commands |
| **AI** | MiniMax M2.7 — orchestration |
| **CI/CD** | Gitea Actions — Go + Node build, multi-platform releases |
## Install
@@ -37,10 +42,14 @@ make build
make install-local
```
The frontend is built automatically during `make build` (runs `npm ci && npm run build` in `web/`).
## Usage
```bash
muyue # Start interactive TUI
muyue # Launch desktop app (opens browser)
muyue --port=8080 # Launch on a specific port
muyue --no-open # Launch without opening the browser
muyue scan # Scan system
muyue install # Install missing tools
muyue update # Check and apply updates
@@ -76,55 +85,116 @@ muyue skills deploy # Deploy skills to Crush and Claude Code
muyue skills delete <name> # Delete a skill
```
## TUI — 4 Tabs
## Desktop App — 4 Tabs
The TUI is organized into 4 tabs with a red/rose theme (`#E8364F``#FF6B8A`):
The web UI is organized into 4 tabs with a cyberpunk dark theme. Navigate with `Ctrl+1` through `Ctrl+4`.
### Dashboard
### Dashboard
System overview: installed tools with status, active agents, updates, LSP/MCP/daemon status, and quick actions (install, update, scan).
System overview with sub-tabs:
- **Tools** — installed/missing tools with status badges and version info
- **Notifications** — activity log with colored severity
- **Workflows** — quick actions (install missing, check updates, rescan, configure MCP)
### Studio
### ⟨⟩ Studio
Central AI chat with a collapsible sidebar (`Ctrl+S`) containing 3 panels:
AI chat interface with a sidebar containing 3 panels:
| Panel | Shortcut | Description |
|-------|----------|-------------|
| **Chat** | `1` | AI conversation, `/plan <goal>` to start workflows |
| **Agents** | `2` | Start/stop Crush and Claude Code agents |
| **Workflows** | `3` | Plan→Execute workflow controls (approve, reject, next step) |
| Panel | Description |
|-------|-------------|
| **Chat** | AI conversation, `/plan <goal>` to start workflows |
| **Agents** | Status of Crush and Claude Code agents |
| **Workflows** | Plan→Execute workflow controls |
### Shell
### $ Shell
Split-view terminal with an AI assistant panel (`Ctrl+A` to toggle). The AI knows your system and suggests commands you can easily copy into the terminal.
Split-view: terminal emulator on the left (sends commands to the Go backend), collapsible AI assistant panel on the right. Full command history with `↑`/`↓` navigation.
### ⚙ Config
Profile, API providers, terminal/starship settings, BMAD, and skills — displayed in a two-column layout.
Two-column profile settings:
- **Profile** — name, pseudo, email, editor, shell, default AI, languages
- **AI Providers** — active provider, API key status, model info
- **Theme** — 4 swatches (Cyberpunk Red, Cyberpunk Pink, Midnight Blue, Matrix Green)
- **Language** — FR/EN with keyboard layout selection (AZERTY, QWERTY, QWERTZ)
- **Skills** — installed skills list
### Keyboard Shortcuts
| Key | Context | Action |
|-----|---------|--------|
| `Ctrl+T` | Global | Open tab switcher |
| `Ctrl+S` | Studio | Toggle sidebar |
| `Ctrl+A` | Shell | Toggle AI assistant panel |
| `Ctrl+C` | Global | Quit confirmation |
| `i` | Dashboard | Install missing tools |
| `u` | Dashboard | Check for updates |
| `s` | Dashboard | Rescan system |
| `1` `2` `3` | Studio sidebar | Switch panels (Chat/Agents/Workflows) |
| `a` | Workflow | Approve plan |
| `r` | Workflow | Reject plan |
| `g` | Workflow | Generate plan |
| `n` | Workflow | Next step |
| `x` | Workflow | Cancel workflow |
| `Ctrl+1` | Global | Dashboard tab |
| `Ctrl+2` | Global | Studio tab |
| `Ctrl+3` | Global | Shell tab |
| `Ctrl+4` | Global | Config tab |
| `Enter` | Studio | Send message |
| `Shift+Enter` | Studio | New line |
| `Enter` | Shell | Run command |
| ``/`↓` | Shell | Command history |
## API Endpoints
The Go backend serves 15 REST endpoints under `/api/`:
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/api/info` | GET | App name, version, author |
| `/api/system` | GET | OS, arch, platform info |
| `/api/tools` | GET | Tool scan results |
| `/api/config` | GET | Profile, terminal, BMAD config |
| `/api/providers` | GET | AI provider list |
| `/api/skills` | GET | Installed skills |
| `/api/lsp` | GET | LSP server scan |
| `/api/mcp` | GET | MCP server scan |
| `/api/updates` | GET | Update check results |
| `/api/scan` | POST | Trigger system rescan |
| `/api/install` | POST | Install tools `{"tools": [...]}` |
| `/api/terminal` | POST | Execute command `{"command": "...", "cwd": "..."}` |
| `/api/mcp/configure` | POST | Configure MCP servers |
| `/api/preferences` | PUT | Save language/keyboard preferences |
## Project Structure
```
.
├── cmd/muyue/main.go # CLI entry point + command routing
├── internal/
│ ├── api/ # HTTP server + handlers (15 endpoints)
│ ├── config/ # YAML config + XDG paths
│ ├── daemon/ # Background daemon
│ ├── desktop/ # Desktop mode (HTTP server + SPA)
│ ├── installer/ # Tool installation logic
│ ├── lsp/ # LSP server scan + install
│ ├── mcp/ # MCP server configuration
│ ├── orchestrator/ # AI agent orchestration
│ ├── platform/ # Cross-platform abstractions
│ ├── preview/ # HTML preview server
│ ├── profiler/ # First-run setup wizard
│ ├── proxy/ # AI proxy agents
│ ├── scanner/ # System tool/runtime scanner
│ ├── secret/ # AES-256-GCM key encryption
│ ├── skills/ # Skills management (CRUD, deploy, AI-generate)
│ ├── updater/ # Tool auto-updater
│ ├── version/ # Version constants
│ └── workflow/ # Plan→Execute workflow engine
├── web/ # Frontend (React 19 + Vite)
│ ├── embed.go # go:embed dist/
│ ├── src/
│ │ ├── api/client.js # API client
│ │ ├── components/ # App, Dashboard, Studio, Shell, Config
│ │ ├── i18n/ # FR/EN translations + keyboard layouts
│ │ ├── styles/global.css # Full CSS theme system
│ │ └── themes/index.js # 4 themes with CSS variable injection
│ └── vite.config.js # Vite + dev proxy to :8095
├── .gitea/workflows/ # CI/CD (PR check, beta, stable)
└── Makefile # build, test, lint, cross-compile
```
## Configuration
Config stored at `$XDG_CONFIG_HOME/muyue/config.yaml` (defaults to `~/.config/muyue/config.yaml`).
API keys are encrypted at rest using AES-GCM with a machine-local key stored in `~/.muyue_key`.
API keys are encrypted at rest using AES-256-GCM with a machine-local key stored in `~/.muyue_key`.
First run launches an interactive profiling wizard that:
1. Asks your name, pseudo, email
@@ -133,17 +203,39 @@ First run launches an interactive profiling wizard that:
4. Scans your system
5. Installs missing tools
## Themes
4 built-in themes, selectable from the Config tab:
| Theme | Accent Color |
|-------|-------------|
| Cyberpunk Red | `#FF0033` |
| Cyberpunk Pink | `#FF1A8C` |
| Midnight Blue | `#0088FF` |
| Matrix Green | `#00FF41` |
Themes are applied via CSS custom properties injected at runtime. All colors (30+ variables) adapt automatically.
## i18n & Keyboard Layouts
- **Languages**: Français, English
- **Keyboard layouts**: AZERTY (fr-FR), QWERTY (en-US), QWERTZ (de-DE)
- Keyboard layout affects displayed shortcuts in the status bar (e.g., `Ctrl+&-é-"-'` on AZERTY vs `Ctrl+1-4` on QWERTY)
- Preferences saved to backend and synced across sessions
## Security
- API keys are encrypted at rest (AES-256-GCM) with a per-machine key
- API keys encrypted at rest (AES-256-GCM) with a per-machine key
- Config files use restrictive permissions (0600)
- MCP config files use restrictive permissions (0600)
- Integrated terminal blocks dangerous commands (rm -rf /, mkfs, fork bombs, etc.)
- Terminal API executes commands via shell — only accessible on localhost
## Cross-Platform
Built for Linux (primary), macOS, and Windows. WSL supported.
Single binary includes both CLI and embedded web frontend.
## Contributing — GitFlow Workflow
This project uses a **lightweight GitFlow** with 2 permanent branches and conventional commits.
@@ -179,6 +271,8 @@ hotfix/xxx ──PR (squash)──▶ main (+ backport develop)
| `ci-develop.yml` | Push to `develop` | vet + test + build all platforms + create beta release |
| `ci-main.yml` | Push to `main` | vet + test + build all platforms + update CHANGELOG.md + create stable release |
All CI pipelines build the frontend (`npm ci && npm run build`) before Go vet/test/build.
### Step-by-step: contribute a feature
```bash

View File

@@ -3,103 +3,15 @@ package main
import (
"fmt"
"os"
"os/exec"
"github.com/muyue/muyue/internal/config"
"github.com/muyue/muyue/internal/desktop"
"github.com/muyue/muyue/internal/installer"
"github.com/muyue/muyue/internal/lsp"
"github.com/muyue/muyue/internal/mcp"
"github.com/muyue/muyue/internal/orchestrator"
"github.com/muyue/muyue/internal/profiler"
"github.com/muyue/muyue/internal/scanner"
"github.com/muyue/muyue/internal/skills"
"github.com/muyue/muyue/internal/updater"
"github.com/muyue/muyue/internal/version"
)
func main() {
if len(os.Args) > 1 {
if isCommand(os.Args[1]) {
handleCommand(os.Args[1:])
return
}
}
runDesktop(os.Args[1:])
}
func isCommand(arg string) bool {
switch arg {
case "version", "-v", "--version",
"scan", "install", "update", "setup",
"config", "doctor", "lsp", "mcp", "skills",
"help", "-h", "--help":
return true
}
return false
}
func handleCommand(args []string) {
switch args[0] {
case "version", "-v", "--version":
fmt.Println(version.FullVersion())
case "scan":
runScan()
case "install":
runInstall(args[1:])
case "update":
runUpdate()
case "setup":
runSetup()
case "config":
showConfig()
case "doctor":
runDoctor()
case "lsp":
runLSP(args[1:])
case "mcp":
runMCP(args[1:])
case "skills":
runSkills(args[1:])
case "help", "-h", "--help":
printHelp()
}
}
func printHelp() {
fmt.Printf(`%s - AI-powered development environment assistant
Usage:
muyue Launch desktop app (opens browser)
muyue <command> Run a specific command
Options:
--port=PORT Specify port (default: auto)
--no-open Don't open browser automatically
Commands:
version Show version
scan Scan your system for tools and runtimes
install [tools] Install missing tools (needs sudo for some tools)
update Check and apply updates for all tools
setup Run first-time setup wizard
config Show current configuration
doctor Check that everything is properly configured
lsp [scan|install] Scan or install LSP servers
mcp [config|scan] Configure MCP servers for Crush and Claude Code
skills [list|generate|deploy|init|delete] Manage AI coding skills
help Show this help
Note:
Some tools (docker, gh, etc.) require elevated privileges.
Run 'sudo muyue install' or use 'pkexec muyue install' if needed.
`, version.FullVersion())
}
func runDesktop(args []string) {
cfg := loadOrSetupConfig()
if err := desktop.Run(cfg, args); err != nil {
if err := desktop.Run(cfg, os.Args[1:]); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
@@ -140,453 +52,3 @@ func loadOrSetupConfig() *config.MuyueConfig {
return cfg
}
func runScan() {
fmt.Println("Scanning system...")
result := scanner.ScanSystem()
fmt.Println(result.Summary())
}
func runInstall(tools []string) {
cfg := loadOrSetupConfig()
inst := installer.New(cfg)
if len(tools) == 0 {
result := scanner.ScanSystem()
var missing []string
for _, t := range result.Tools {
if !t.Installed {
missing = append(missing, t.Name)
}
}
if len(missing) == 0 {
fmt.Println("All tools are installed!")
return
}
fmt.Printf("Missing tools: %v\nInstalling...\n", missing)
tools = missing
}
if needsSudo(tools) && os.Geteuid() != 0 {
fmt.Println("Some tools require elevated privileges.")
if path, err := exec.LookPath("sudo"); err == nil {
fmt.Printf("Re-running with sudo...\n")
cmd := exec.Command(path, append([]string{os.Args[0], "install"}, tools...)...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
fmt.Fprintf(os.Stderr, "sudo install failed: %v\n", err)
os.Exit(1)
}
config.Save(cfg)
return
}
if path, err := exec.LookPath("pkexec"); err == nil {
fmt.Printf("Re-running with pkexec...\n")
cmd := exec.Command(path, append([]string{os.Args[0], "install"}, tools...)...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
fmt.Fprintf(os.Stderr, "pkexec install failed: %v\n", err)
os.Exit(1)
}
config.Save(cfg)
return
}
fmt.Println("Neither sudo nor pkexec found. Some installs may fail.")
fmt.Println("Try running: sudo muyue install")
}
results := inst.InstallAll(tools)
for _, r := range results {
status := "[OK]"
if !r.Success {
status = "[FAIL]"
}
fmt.Printf(" %s %s: %s\n", status, r.Tool, r.Message)
}
config.Save(cfg)
}
func needsSudo(tools []string) bool {
sudoTools := map[string]bool{
"docker": true, "git": true, "gh": true, "node": true, "python": true,
}
for _, t := range tools {
if sudoTools[t] {
return true
}
}
return false
}
func runUpdate() {
fmt.Println("Checking for updates...")
result := scanner.ScanSystem()
statuses := updater.CheckUpdates(result)
needsUpdate := false
for _, s := range statuses {
if s.NeedsUpdate {
fmt.Printf(" [!] %s: %s -> %s\n", s.Tool, s.Current, s.Latest)
needsUpdate = true
} else if s.Error == "" {
fmt.Printf(" [v] %s: up to date (%s)\n", s.Tool, s.Current)
} else {
fmt.Printf(" [?] %s: %s\n", s.Tool, s.Error)
}
}
if needsUpdate {
fmt.Println("\nApplying updates...")
results := updater.RunAutoUpdate(statuses)
for _, r := range results {
fmt.Printf(" %s: %s\n", r.Tool, r.Message)
}
}
}
func runSetup() {
cfg, err := profiler.RunFirstTimeSetup()
if err != nil {
fmt.Fprintf(os.Stderr, "Setup error: %v\n", err)
os.Exit(1)
}
for i := range cfg.AI.Providers {
if cfg.AI.Providers[i].Active && cfg.AI.Providers[i].APIKey == "" {
key, err := profiler.AskAPIKey(cfg.AI.Providers[i].Name)
if err == nil && key != "" {
cfg.AI.Providers[i].APIKey = key
}
}
}
if err := config.Save(cfg); err != nil {
fmt.Fprintf(os.Stderr, "Save error: %v\n", err)
os.Exit(1)
}
fmt.Println("Setup complete!")
}
func showConfig() {
cfg, err := config.Load()
if err != nil {
fmt.Fprintf(os.Stderr, "Config not found. Run `muyue setup` first.\n")
os.Exit(1)
}
fmt.Printf("Profile: %s (%s)\n", cfg.Profile.Name, cfg.Profile.Pseudo)
fmt.Printf("Email: %s\n", cfg.Profile.Email)
fmt.Printf("Editor: %s\n", cfg.Profile.Preferences.Editor)
fmt.Printf("Default AI: %s\n", cfg.Profile.Preferences.DefaultAI)
fmt.Printf("Languages: %v\n", cfg.Profile.Languages)
for _, p := range cfg.AI.Providers {
active := ""
if p.Active {
active = " (active)"
}
keyStatus := "no key"
if p.APIKey != "" {
keyStatus = "configured"
}
fmt.Printf(" %s: model=%s, key=%s%s\n", p.Name, p.Model, keyStatus, active)
}
fmt.Printf("BMAD: installed=%v, global=%v\n", cfg.BMAD.Installed, cfg.BMAD.Global)
fmt.Printf("Custom Prompt: %v\n", cfg.Terminal.CustomPrompt)
}
func runDoctor() {
ok := true
fmt.Println("Running diagnostics...")
fmt.Println()
fmt.Println("Configuration:")
if !config.Exists() {
fmt.Println(" [FAIL] Config file not found. Run 'muyue setup' first.")
ok = false
} else {
cfg, err := config.Load()
if err != nil {
fmt.Printf(" [FAIL] Config load error: %v\n", err)
ok = false
} else {
fmt.Println(" [OK] Config file present")
hasKey := false
for _, p := range cfg.AI.Providers {
if p.Active && p.APIKey != "" {
hasKey = true
}
}
if hasKey {
fmt.Println(" [OK] API key configured")
} else {
fmt.Println(" [FAIL] No API key set for active provider")
ok = false
}
}
}
fmt.Println("\nTools:")
result := scanner.ScanSystem()
installed := 0
for _, t := range result.Tools {
if t.Installed {
installed++
fmt.Printf(" [OK] %s\n", t.Name)
} else {
fmt.Printf(" [FAIL] %s (not installed)\n", t.Name)
}
}
fmt.Printf(" Installed: %d/%d\n", installed, len(result.Tools))
fmt.Println("\nLSP Servers:")
servers := lsp.ScanServers()
lspOK := 0
for _, s := range servers {
if s.Installed {
lspOK++
fmt.Printf(" [OK] %s (%s)\n", s.Name, s.Language)
}
}
fmt.Printf(" Available: %d/%d\n", lspOK, len(servers))
fmt.Println("\nMCP Servers:")
mcpServers := mcp.ScanServers()
mcpOK := 0
for _, s := range mcpServers {
if s.Installed {
mcpOK++
}
}
fmt.Printf(" Available: %d/%d\n", mcpOK, len(mcpServers))
fmt.Println("\nSkills:")
skillList, err := skills.List()
if err != nil || len(skillList) == 0 {
fmt.Println(" [FAIL] No skills. Run 'muyue skills init'.")
ok = false
} else {
fmt.Printf(" [OK] %d skills installed\n", len(skillList))
}
fmt.Println()
if ok {
fmt.Println("All checks passed!")
} else {
fmt.Println("Some checks failed. Review the output above.")
os.Exit(1)
}
}
func runLSP(args []string) {
if len(args) == 0 {
args = []string{"scan"}
}
switch args[0] {
case "scan":
fmt.Println("Scanning LSP servers...")
servers := lsp.ScanServers()
installed := 0
for _, s := range servers {
if s.Installed {
installed++
fmt.Printf(" [v] %-35s (%s)\n", s.Name, s.Language)
} else {
fmt.Printf(" [ ] %-35s (%s)\n", s.Name, s.Language)
}
}
fmt.Printf("\nInstalled: %d/%d\n", installed, len(servers))
case "install":
if len(args) < 2 {
cfg := loadOrSetupConfig()
fmt.Printf("Installing LSP servers for: %v\n", cfg.Profile.Languages)
results := lsp.InstallForLanguages(cfg.Profile.Languages)
for _, r := range results {
if r.Installed {
fmt.Printf(" [OK] %s (%s)\n", r.Name, r.Language)
} else {
fmt.Printf(" [FAIL] %s (%s)\n", r.Name, r.Language)
}
}
} else {
for _, name := range args[1:] {
fmt.Printf("Installing %s...\n", name)
if err := lsp.InstallServer(name); err != nil {
fmt.Printf(" [FAIL] %s: %s\n", name, err)
} else {
fmt.Printf(" [OK] %s\n", name)
}
}
}
default:
fmt.Printf("Unknown lsp subcommand: %s (scan, install)\n", args[0])
}
}
func runMCP(args []string) {
if len(args) == 0 {
args = []string{"config"}
}
switch args[0] {
case "config":
cfg := loadOrSetupConfig()
fmt.Println("Configuring MCP servers for Crush and Claude Code...")
if err := mcp.ConfigureAll(cfg); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
fmt.Println("Done! MCP servers configured.")
case "scan":
fmt.Println("Scanning MCP servers...")
servers := mcp.ScanServers()
available := 0
for _, s := range servers {
if s.Installed {
available++
fmt.Printf(" [v] %-30s (%s)\n", s.Name, s.Category)
} else {
fmt.Printf(" [ ] %-30s (%s)\n", s.Name, s.Category)
}
}
fmt.Printf("\nAvailable: %d/%d\n", available, len(servers))
default:
fmt.Printf("Unknown mcp subcommand: %s (config, scan)\n", args[0])
}
}
func runSkills(args []string) {
if len(args) == 0 {
args = []string{"list"}
}
switch args[0] {
case "list", "ls":
skillsList, err := skills.List()
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
if len(skillsList) == 0 {
fmt.Println("No skills found. Run `muyue skills init` to install built-in skills.")
return
}
fmt.Printf("Skills (%d):\n", len(skillsList))
for _, s := range skillsList {
target := s.Target
if target == "" {
target = "both"
}
fmt.Printf(" %-20s %-8s %s\n", s.Name, target, s.Description)
}
case "init":
fmt.Println("Installing built-in skills...")
if err := skills.InstallBuiltinSkills(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
fmt.Println("Deploying to Crush and Claude Code...")
if err := skills.DeployAll(); err != nil {
fmt.Fprintf(os.Stderr, "Deploy error: %v\n", err)
}
fmt.Println("Done! Built-in skills installed and deployed.")
case "show":
if len(args) < 2 {
fmt.Println("Usage: muyue skills show <name>")
return
}
skill, err := skills.Get(args[1])
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
fmt.Printf("Name: %s\n", skill.Name)
fmt.Printf("Description: %s\n", skill.Description)
fmt.Printf("Author: %s\n", skill.Author)
fmt.Printf("Version: %s\n", skill.Version)
fmt.Printf("Target: %s\n", skill.Target)
fmt.Printf("Tags: %v\n", skill.Tags)
fmt.Printf("Path: %s\n", skill.FilePath)
fmt.Printf("\n--- Content ---\n%s\n", skill.Content)
case "generate":
if len(args) < 3 {
fmt.Println("Usage: muyue skills generate <name> <description> [crush|claude|both]")
fmt.Println("Example: muyue skills generate docker-setup \"Set up Docker for a project\" both")
return
}
name := args[1]
description := args[2]
target := "both"
if len(args) > 3 {
target = args[3]
}
cfg := loadOrSetupConfig()
orch, err := orchestrator.New(cfg)
if err != nil {
fmt.Fprintf(os.Stderr, "AI not configured: %v\n", err)
os.Exit(1)
}
fmt.Printf("Generating skill '%s'...\n", name)
prompt := skills.BuildAIGeneratePrompt(name, description, target)
resp, err := orch.Send(prompt)
if err != nil {
fmt.Fprintf(os.Stderr, "Generation error: %v\n", err)
os.Exit(1)
}
skill := &skills.Skill{
Name: name,
Description: description,
Content: resp,
Author: "muyue-generated",
Version: "0.1.0",
Target: target,
Tags: []string{"generated"},
}
if err := skills.Create(skill); err != nil {
fmt.Fprintf(os.Stderr, "Save error: %v\n", err)
os.Exit(1)
}
fmt.Printf("Skill '%s' created and deployed!\n", name)
case "deploy":
fmt.Println("Deploying all skills to Crush and Claude Code...")
if err := skills.DeployAll(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
fmt.Println("Done!")
case "delete":
if len(args) < 2 {
fmt.Println("Usage: muyue skills delete <name>")
return
}
if err := skills.Delete(args[1]); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
fmt.Printf("Skill '%s' deleted.\n", args[1])
default:
fmt.Printf("Unknown skills subcommand: %s\n", args[0])
fmt.Println("Available: list, show, generate, deploy, init, delete")
}
}

2
go.mod
View File

@@ -4,6 +4,8 @@ go 1.24.3
require (
github.com/charmbracelet/huh v1.0.0
github.com/creack/pty/v2 v2.0.1
github.com/gorilla/websocket v1.5.3
gopkg.in/yaml.v3 v3.0.1
)

4
go.sum
View File

@@ -44,10 +44,14 @@ github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/creack/pty/v2 v2.0.1 h1:RDY1VY5b+7m2mfPsugucOYPIxMp+xal5ZheSyVzUA+k=
github.com/creack/pty/v2 v2.0.1/go.mod h1:2dSssKp3b86qYEMwA/FPwc3ff+kYpDdQI8osU8J7gxQ=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=

View File

@@ -0,0 +1,157 @@
package api
import (
"encoding/json"
"os"
"path/filepath"
"sync"
"time"
"unicode/utf8"
"github.com/muyue/muyue/internal/config"
)
const maxTokensApprox = 100000
const summarizeThreshold = 80000
const charsPerToken = 4
type FeedMessage struct {
ID string `json:"id"`
Role string `json:"role"`
Content string `json:"content"`
Time string `json:"time"`
}
type Conversation struct {
Messages []FeedMessage `json:"messages"`
Summary string `json:"summary,omitempty"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
type ConversationStore struct {
mu sync.RWMutex
path string
conv *Conversation
}
func NewConversationStore() *ConversationStore {
dir, err := config.ConfigDir()
if err != nil {
dir = "/tmp/muyue"
}
path := filepath.Join(dir, "conversation.json")
cs := &ConversationStore{path: path}
cs.load()
return cs
}
func (cs *ConversationStore) load() {
data, err := os.ReadFile(cs.path)
if err != nil {
cs.conv = &Conversation{
Messages: []FeedMessage{},
CreatedAt: time.Now().Format(time.RFC3339),
UpdatedAt: time.Now().Format(time.RFC3339),
}
return
}
var conv Conversation
if err := json.Unmarshal(data, &conv); err != nil {
cs.conv = &Conversation{
Messages: []FeedMessage{},
CreatedAt: time.Now().Format(time.RFC3339),
UpdatedAt: time.Now().Format(time.RFC3339),
}
return
}
if conv.Messages == nil {
conv.Messages = []FeedMessage{}
}
cs.conv = &conv
}
func (cs *ConversationStore) save() error {
cs.conv.UpdatedAt = time.Now().Format(time.RFC3339)
data, err := json.MarshalIndent(cs.conv, "", " ")
if err != nil {
return err
}
dir := filepath.Dir(cs.path)
os.MkdirAll(dir, 0755)
return os.WriteFile(cs.path, data, 0600)
}
func (cs *ConversationStore) Get() []FeedMessage {
cs.mu.RLock()
defer cs.mu.RUnlock()
out := make([]FeedMessage, len(cs.conv.Messages))
copy(out, cs.conv.Messages)
return out
}
func (cs *ConversationStore) GetSummary() string {
cs.mu.RLock()
defer cs.mu.RUnlock()
return cs.conv.Summary
}
func (cs *ConversationStore) Add(role, content string) FeedMessage {
cs.mu.Lock()
defer cs.mu.Unlock()
msg := FeedMessage{
ID: generateMsgID(),
Role: role,
Content: content,
Time: time.Now().Format(time.RFC3339),
}
cs.conv.Messages = append(cs.conv.Messages, msg)
cs.save()
return msg
}
func (cs *ConversationStore) Clear() {
cs.mu.Lock()
defer cs.mu.Unlock()
cs.conv.Messages = []FeedMessage{}
cs.conv.Summary = ""
cs.conv.CreatedAt = time.Now().Format(time.RFC3339)
cs.conv.UpdatedAt = time.Now().Format(time.RFC3339)
cs.save()
}
func (cs *ConversationStore) SetSummary(summary string) {
cs.mu.Lock()
defer cs.mu.Unlock()
cs.conv.Summary = summary
cs.save()
}
func (cs *ConversationStore) TrimOld(keepCount int) {
cs.mu.Lock()
defer cs.mu.Unlock()
if len(cs.conv.Messages) <= keepCount {
return
}
cs.conv.Messages = cs.conv.Messages[len(cs.conv.Messages)-keepCount:]
cs.save()
}
func (cs *ConversationStore) ApproxTokenCount() int {
cs.mu.RLock()
defer cs.mu.RUnlock()
total := utf8.RuneCountInString(cs.conv.Summary)
for _, m := range cs.conv.Messages {
total += utf8.RuneCountInString(m.Content)
}
return total / charsPerToken
}
func (cs *ConversationStore) NeedsSummarization() bool {
return cs.ApproxTokenCount() > summarizeThreshold
}
func generateMsgID() string {
return time.Now().Format("20060102150405.000")
}

View File

@@ -1,19 +1,26 @@
package api
import (
"bytes"
"encoding/json"
"io"
"net/http"
"os/exec"
"strings"
"time"
"github.com/muyue/muyue/internal/config"
"github.com/muyue/muyue/internal/lsp"
"github.com/muyue/muyue/internal/mcp"
"github.com/muyue/muyue/internal/orchestrator"
"github.com/muyue/muyue/internal/scanner"
"github.com/muyue/muyue/internal/skills"
"github.com/muyue/muyue/internal/updater"
"github.com/muyue/muyue/internal/version"
)
const summarizePrompt = `Résume la conversation suivante de manière concise et structurée. Garde les points clés, les décisions prises, le contexte technique important. Le résumé doit permettre de continuer la conversation sans perte de contexte. Réponds uniquement avec le résumé, sans meta-commentaire.`
func writeJSON(w http.ResponseWriter, data interface{}) {
json.NewEncoder(w).Encode(data)
}
@@ -244,3 +251,377 @@ func (s *Server) handleTerminal(w http.ResponseWriter, r *http.Request) {
}
writeJSON(w, result)
}
func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
var body struct {
Message string `json:"message"`
Stream bool `json:"stream"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
if body.Message == "" {
writeError(w, "no message", http.StatusBadRequest)
return
}
s.convStore.Add("user", body.Message)
if s.convStore.NeedsSummarization() {
s.autoSummarize()
}
orb, err := orchestrator.New(s.config)
if err != nil {
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
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.`)
if body.Stream {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.WriteHeader(http.StatusOK)
flusher, canFlush := w.(http.Flusher)
result, err := orb.Send(body.Message)
if err != nil {
data, _ := json.Marshal(map[string]string{"error": err.Error()})
w.Write([]byte("data: " + string(data) + "\n\n"))
if canFlush {
flusher.Flush()
}
return
}
s.convStore.Add("assistant", result)
chunkSize := 8
runes := []rune(result)
for i := 0; i < len(runes); i += chunkSize {
end := i + chunkSize
if end > len(runes) {
end = len(runes)
}
chunk := string(runes[i:end])
data, _ := json.Marshal(map[string]string{"content": chunk})
w.Write([]byte("data: " + string(data) + "\n\n"))
if canFlush {
flusher.Flush()
}
}
data, _ := json.Marshal(map[string]string{"done": "true"})
w.Write([]byte("data: " + string(data) + "\n\n"))
if canFlush {
flusher.Flush()
}
return
}
result, err := orb.Send(body.Message)
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
s.convStore.Add("assistant", result)
writeJSON(w, map[string]string{"content": result})
}
func (s *Server) autoSummarize() {
messages := s.convStore.Get()
if len(messages) < 10 {
return
}
half := len(messages) / 2
var oldText string
for _, m := range messages[:half] {
oldText += m.Role + ": " + m.Content + "\n\n"
}
summary := s.convStore.GetSummary()
if summary != "" {
oldText = "Résumé précédent:\n" + summary + "\n\nNouveaux échanges:\n" + oldText
}
orb, err := orchestrator.New(s.config)
if err != nil {
return
}
orb.SetSystemPrompt(summarizePrompt)
result, err := orb.Send(oldText)
if err != nil {
return
}
s.convStore.SetSummary(result)
s.convStore.TrimOld(len(messages) - half)
s.convStore.Add("system", "[Conversation résumée automatiquement]")
}
func (s *Server) handleChatHistory(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
writeError(w, "GET only", http.StatusMethodNotAllowed)
return
}
messages := s.convStore.Get()
writeJSON(w, map[string]interface{}{
"messages": messages,
"tokens": s.convStore.ApproxTokenCount(),
})
}
func (s *Server) handleChatClear(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
s.convStore.Clear()
writeJSON(w, map[string]string{"status": "ok"})
}
func (s *Server) handleSaveProfile(w http.ResponseWriter, r *http.Request) {
if r.Method != "PUT" {
writeError(w, "PUT only", http.StatusMethodNotAllowed)
return
}
if s.config == nil {
writeError(w, "no config", http.StatusNotFound)
return
}
var body struct {
Name string `json:"name"`
Pseudo string `json:"pseudo"`
Email string `json:"email"`
Editor string `json:"editor"`
Shell string `json:"shell"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
if body.Name != "" {
s.config.Profile.Name = body.Name
}
if body.Pseudo != "" {
s.config.Profile.Pseudo = body.Pseudo
}
if body.Email != "" {
s.config.Profile.Email = body.Email
}
if body.Editor != "" {
s.config.Profile.Preferences.Editor = body.Editor
}
if body.Shell != "" {
s.config.Profile.Preferences.Shell = body.Shell
}
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) handleSaveProvider(w http.ResponseWriter, r *http.Request) {
if r.Method != "PUT" {
writeError(w, "PUT only", http.StatusMethodNotAllowed)
return
}
if s.config == nil {
writeError(w, "no config", http.StatusNotFound)
return
}
var body struct {
Name string `json:"name"`
APIKey string `json:"api_key"`
Model string `json:"model"`
BaseURL string `json:"base_url"`
Active *bool `json:"active"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
if body.Name == "" {
writeError(w, "name required", http.StatusBadRequest)
return
}
found := false
for i := range s.config.AI.Providers {
if s.config.AI.Providers[i].Name == body.Name {
if body.APIKey != "" {
s.config.AI.Providers[i].APIKey = body.APIKey
}
if body.Model != "" {
s.config.AI.Providers[i].Model = body.Model
}
if body.BaseURL != "" {
s.config.AI.Providers[i].BaseURL = body.BaseURL
}
if body.Active != nil {
if *body.Active {
for j := range s.config.AI.Providers {
s.config.AI.Providers[j].Active = false
}
}
s.config.AI.Providers[i].Active = *body.Active
}
found = true
break
}
}
if !found {
writeError(w, "provider not found", http.StatusNotFound)
return
}
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) handleRunUpdate(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
var body struct {
Tool string `json:"tool"`
}
json.NewDecoder(r.Body).Decode(&body)
result := scanner.ScanSystem()
statuses := updater.CheckUpdates(result)
if body.Tool != "" {
for _, u := range statuses {
if u.Tool == body.Tool && u.NeedsUpdate {
updater.RunAutoUpdate([]updater.UpdateStatus{u})
}
}
writeJSON(w, map[string]string{"status": "ok", "tool": body.Tool})
return
}
needsUpdate := make([]updater.UpdateStatus, 0)
for _, u := range statuses {
if u.NeedsUpdate {
needsUpdate = append(needsUpdate, u)
}
}
if len(needsUpdate) > 0 {
updater.RunAutoUpdate(needsUpdate)
}
writeJSON(w, map[string]interface{}{
"status": "ok",
"updated": len(needsUpdate),
})
}
func (s *Server) handleValidateProvider(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
var body struct {
Name string `json:"name"`
APIKey string `json:"api_key"`
Model string `json:"model"`
BaseURL string `json:"base_url"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
if body.APIKey == "" {
writeError(w, "api_key required", http.StatusBadRequest)
return
}
baseURL := body.BaseURL
if baseURL == "" {
for _, p := range s.config.AI.Providers {
if p.Name == body.Name {
baseURL = p.BaseURL
break
}
}
}
if baseURL == "" {
switch body.Name {
case "minimax":
baseURL = "https://api.minimax.io/v1"
case "openai":
baseURL = "https://api.openai.com/v1"
case "anthropic":
baseURL = "https://api.anthropic.com/v1"
default:
baseURL = "https://api.minimax.io/v1"
}
}
model := body.Model
if model == "" {
for _, p := range s.config.AI.Providers {
if p.Name == body.Name {
model = p.Model
break
}
}
}
if model == "" {
model = "MiniMax-Text-01"
}
reqBody, _ := json.Marshal(map[string]interface{}{
"model": model,
"messages": []map[string]string{{"role": "user", "content": "Hi"}},
"max_tokens": 5,
"stream": false,
})
url := strings.TrimRight(baseURL, "/") + "/chat/completions"
req, err := http.NewRequest("POST", url, bytes.NewReader(reqBody))
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+body.APIKey)
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Do(req)
if err != nil {
writeError(w, "connection failed: "+err.Error(), http.StatusBadGateway)
return
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
writeError(w, "invalid_api_key", http.StatusUnauthorized)
return
}
if resp.StatusCode != http.StatusOK {
writeError(w, "api_error: "+string(respBody), http.StatusBadGateway)
return
}
writeJSON(w, map[string]interface{}{"status": "valid"})
}

View File

@@ -2,6 +2,7 @@ package api
import (
"net/http"
"strings"
"github.com/muyue/muyue/internal/config"
"github.com/muyue/muyue/internal/scanner"
@@ -11,6 +12,7 @@ type Server struct {
config *config.MuyueConfig
scanResult *scanner.ScanResult
mux *http.ServeMux
convStore *ConversationStore
}
func NewServer(cfg *config.MuyueConfig) *Server {
@@ -19,6 +21,7 @@ func NewServer(cfg *config.MuyueConfig) *Server {
mux: http.NewServeMux(),
}
s.scanResult = scanner.ScanSystem()
s.convStore = NewConversationStore()
s.routes()
return s
}
@@ -37,10 +40,24 @@ func (s *Server) routes() {
s.mux.HandleFunc("/api/scan", s.handleScan)
s.mux.HandleFunc("/api/preferences", s.handleUpdatePreferences)
s.mux.HandleFunc("/api/terminal", s.handleTerminal)
s.mux.HandleFunc("/api/ws/terminal", s.handleTerminalWS)
s.mux.HandleFunc("/api/terminal/sessions", s.handleTerminalSessions)
s.mux.HandleFunc("/api/terminal/sessions/", s.handleTerminalSessionsDelete)
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/providers/validate", s.handleValidateProvider)
s.mux.HandleFunc("/api/update/run", s.handleRunUpdate)
s.mux.HandleFunc("/api/chat", s.handleChat)
s.mux.HandleFunc("/api/chat/history", s.handleChatHistory)
s.mux.HandleFunc("/api/chat/clear", s.handleChatClear)
}
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/api/ws/") {
s.mux.ServeHTTP(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, OPTIONS")

310
internal/api/terminal.go Normal file
View File

@@ -0,0 +1,310 @@
package api
import (
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"os/exec"
"runtime"
"strings"
"sync"
"time"
"github.com/creack/pty/v2"
"github.com/gorilla/websocket"
"github.com/muyue/muyue/internal/config"
)
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true },
}
type wsMessage struct {
Type string `json:"type"`
Data string `json:"data"`
Rows uint16 `json:"rows,omitempty"`
Cols uint16 `json:"cols,omitempty"`
}
func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("ws upgrade: %v", err)
return
}
defer conn.Close()
var initMsg wsMessage
_, raw, err := conn.ReadMessage()
if err != nil {
conn.WriteJSON(wsMessage{Type: "error", Data: "failed to read init message"})
return
}
if err := json.Unmarshal(raw, &initMsg); err != nil {
conn.WriteJSON(wsMessage{Type: "error", Data: "invalid init message"})
return
}
var cmd *exec.Cmd
if initMsg.Type == "ssh" && initMsg.Data != "" {
var sshConf struct {
Host string `json:"host"`
Port int `json:"port"`
User string `json:"user"`
KeyPath string `json:"key_path"`
}
if err := json.Unmarshal([]byte(initMsg.Data), &sshConf); err != nil {
conn.WriteJSON(wsMessage{Type: "error", Data: "invalid ssh config"})
return
}
if sshConf.Port == 0 {
sshConf.Port = 22
}
sshArgs := []string{
"-o", "StrictHostKeyChecking=accept-new",
"-o", "UserKnownHostsFile=/dev/null",
"-o", "LogLevel=ERROR",
}
if sshConf.KeyPath != "" {
sshArgs = append(sshArgs, "-i", sshConf.KeyPath)
}
if sshConf.Port != 22 {
sshArgs = append(sshArgs, "-p", fmt.Sprintf("%d", sshConf.Port))
}
sshArgs = append(sshArgs, fmt.Sprintf("%s@%s", sshConf.User, sshConf.Host))
cmd = exec.Command("ssh", sshArgs...)
} else {
shell := initMsg.Data
if shell == "" {
shell = detectShell()
} else {
if path, err := exec.LookPath(shell); err == nil {
shell = path
}
}
if _, err := os.Stat(shell); err != nil {
conn.WriteJSON(wsMessage{Type: "error", Data: fmt.Sprintf("shell not found: %s", shell)})
return
}
if strings.Contains(shell, "wsl") {
cmd = exec.Command(shell, "--shell-type", "login")
} else if strings.Contains(shell, "powershell") || strings.Contains(shell, "pwsh") {
cmd = exec.Command(shell, "-NoLogo", "-NoProfile")
} else {
cmd = exec.Command(shell, "--login")
}
}
cmd.Env = append(os.Environ(), "TERM=xterm-256color")
ptmx, err := pty.Start(cmd)
if err != nil {
log.Printf("pty start: %v", err)
conn.WriteJSON(wsMessage{Type: "error", Data: err.Error()})
return
}
defer func() {
ptmx.Close()
if cmd.Process != nil {
cmd.Process.Kill()
cmd.Wait()
}
}()
var once sync.Once
cleanup := func() {
once.Do(func() {
ptmx.Close()
if cmd.Process != nil {
cmd.Process.Kill()
cmd.Wait()
}
})
}
go func() {
buf := make([]byte, 4096)
for {
n, err := ptmx.Read(buf)
if err != nil {
cleanup()
conn.WriteMessage(websocket.CloseMessage,
websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""))
return
}
if err := conn.WriteJSON(wsMessage{
Type: "output",
Data: string(buf[:n]),
}); err != nil {
cleanup()
return
}
}
}()
conn.SetReadLimit(1 << 20)
conn.SetReadDeadline(time.Time{})
for {
_, raw, err := conn.ReadMessage()
if err != nil {
cleanup()
return
}
var msg wsMessage
if err := json.Unmarshal(raw, &msg); err != nil {
continue
}
switch msg.Type {
case "input":
if _, err := ptmx.Write([]byte(msg.Data)); err != nil {
cleanup()
return
}
case "resize":
if msg.Rows > 0 && msg.Cols > 0 {
pty.Setsize(ptmx, &pty.Winsize{
Rows: msg.Rows,
Cols: msg.Cols,
})
}
}
}
}
func (s *Server) handleTerminalSessions(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" {
writeJSON(w, map[string]interface{}{
"ssh": s.config.Terminal.SSH,
"system": detectSystemTerminals(),
})
return
}
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
var body struct {
Name string `json:"name"`
Host string `json:"host"`
Port int `json:"port"`
User string `json:"user"`
Password string `json:"password"`
KeyPath string `json:"key_path"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
if body.Name == "" || body.Host == "" {
writeError(w, "name and host required", http.StatusBadRequest)
return
}
if body.Port == 0 {
body.Port = 22
}
conn := config.SSHConnection{
Name: body.Name,
Host: body.Host,
Port: body.Port,
User: body.User,
KeyPath: body.KeyPath,
}
if s.config.Terminal.SSH == nil {
s.config.Terminal.SSH = []config.SSHConnection{}
}
s.config.Terminal.SSH = append(s.config.Terminal.SSH, conn)
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) handleTerminalSessionsDelete(w http.ResponseWriter, r *http.Request) {
name := strings.TrimPrefix(r.URL.Path, "/api/terminal/sessions/")
if name == "" {
writeError(w, "name required", http.StatusBadRequest)
return
}
found := false
for i, c := range s.config.Terminal.SSH {
if c.Name == name {
s.config.Terminal.SSH = append(s.config.Terminal.SSH[:i], s.config.Terminal.SSH[i+1:]...)
found = true
break
}
}
if !found {
writeError(w, "not found", http.StatusNotFound)
return
}
if err := config.Save(s.config); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]string{"status": "ok"})
}
func detectShell() string {
shells := []string{"zsh", "bash", "fish", "pwsh", "powershell"}
for _, s := range shells {
if path, err := exec.LookPath(s); err == nil {
return path
}
}
return "/bin/sh"
}
func detectSystemTerminals() []map[string]string {
var terminals []map[string]string
terminals = append(terminals, map[string]string{
"type": "local",
"name": "Default Shell",
"shell": detectShell(),
})
if runtime.GOOS == "windows" {
if _, err := exec.LookPath("wsl"); err == nil {
terminals = append(terminals, map[string]string{
"type": "local",
"name": "WSL",
"shell": "wsl",
})
}
if _, err := exec.LookPath("powershell"); err == nil {
terminals = append(terminals, map[string]string{
"type": "local",
"name": "PowerShell",
"shell": "powershell",
})
}
if _, err := exec.LookPath("pwsh"); err == nil {
terminals = append(terminals, map[string]string{
"type": "local",
"name": "PowerShell Core",
"shell": "pwsh",
})
}
if _, err := exec.LookPath("cmd"); err == nil {
terminals = append(terminals, map[string]string{
"type": "local",
"name": "Command Prompt",
"shell": "cmd",
})
}
}
return terminals
}

View File

@@ -41,6 +41,15 @@ type ToolConfig struct {
AutoUpdate bool `yaml:"auto_update"`
}
type SSHConnection struct {
Name string `yaml:"name"`
Host string `yaml:"host"`
Port int `yaml:"port"`
User string `yaml:"user"`
Password string `yaml:"password,omitempty"`
KeyPath string `yaml:"key_path,omitempty"`
}
type MuyueConfig struct {
Version string `yaml:"version"`
Profile Profile `yaml:"profile"`
@@ -54,8 +63,9 @@ type MuyueConfig struct {
Global bool `yaml:"global"`
} `yaml:"bmad"`
Terminal struct {
CustomPrompt bool `yaml:"custom_prompt"`
PromptTheme string `yaml:"prompt_theme"`
CustomPrompt bool `yaml:"custom_prompt"`
PromptTheme string `yaml:"prompt_theme"`
SSH []SSHConnection `yaml:"ssh"`
} `yaml:"terminal"`
}

View File

@@ -1,173 +0,0 @@
package daemon
import (
"fmt"
"os"
"os/signal"
"sync"
"syscall"
"time"
"github.com/muyue/muyue/internal/config"
"github.com/muyue/muyue/internal/scanner"
"github.com/muyue/muyue/internal/updater"
)
type Daemon struct {
config *config.MuyueConfig
interval time.Duration
stopCh chan struct{}
mu sync.RWMutex
running bool
lastCheck time.Time
lastStatus []updater.UpdateStatus
logs []string
onUpdate func([]updater.UpdateStatus)
}
func NewDaemon(cfg *config.MuyueConfig, interval time.Duration) *Daemon {
if interval == 0 {
interval = 1 * time.Hour
}
return &Daemon{
config: cfg,
interval: interval,
stopCh: make(chan struct{}, 1),
logs: []string{},
}
}
func (d *Daemon) OnUpdate(fn func([]updater.UpdateStatus)) {
d.onUpdate = fn
}
func (d *Daemon) Start() error {
d.mu.Lock()
if d.running {
d.mu.Unlock()
return fmt.Errorf("daemon already running")
}
d.running = true
d.mu.Unlock()
d.log("daemon started (interval: %s)", d.interval)
go d.run()
return nil
}
func (d *Daemon) Stop() {
d.mu.Lock()
defer d.mu.Unlock()
if !d.running {
return
}
d.running = false
d.stopCh <- struct{}{}
d.log("daemon stopped")
}
func (d *Daemon) IsRunning() bool {
d.mu.RLock()
defer d.mu.RUnlock()
return d.running
}
func (d *Daemon) LastCheck() time.Time {
d.mu.RLock()
defer d.mu.RUnlock()
return d.lastCheck
}
func (d *Daemon) LastStatus() []updater.UpdateStatus {
d.mu.RLock()
defer d.mu.RUnlock()
return d.lastStatus
}
func (d *Daemon) Logs() []string {
d.mu.RLock()
defer d.mu.RUnlock()
return d.logs
}
func (d *Daemon) TriggerCheck() []updater.UpdateStatus {
return d.checkUpdates()
}
func (d *Daemon) run() {
d.checkUpdates()
ticker := time.NewTicker(d.interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
d.checkUpdates()
case <-d.stopCh:
return
}
}
}
func (d *Daemon) checkUpdates() []updater.UpdateStatus {
d.log("checking for updates...")
result := scanner.ScanSystem()
statuses := updater.CheckUpdates(result)
needsUpdate := false
for _, s := range statuses {
if s.NeedsUpdate {
needsUpdate = true
d.log("update available: %s %s -> %s", s.Tool, s.Current, s.Latest)
}
}
if !needsUpdate {
d.log("all tools up to date")
}
d.mu.Lock()
d.lastCheck = time.Now()
d.lastStatus = statuses
d.mu.Unlock()
if d.config.Profile.Preferences.AutoUpdate && needsUpdate {
d.log("auto-updating...")
results := updater.RunAutoUpdate(statuses)
for _, r := range results {
if r.Message != "" {
d.log(" %s: %s", r.Tool, r.Message)
}
}
}
if d.onUpdate != nil {
d.onUpdate(statuses)
}
return statuses
}
func (d *Daemon) log(format string, args ...interface{}) {
msg := fmt.Sprintf("[%s] %s", time.Now().Format("15:04:05"), fmt.Sprintf(format, args...))
d.mu.Lock()
d.logs = append(d.logs, msg)
if len(d.logs) > 500 {
d.logs = d.logs[250:]
}
d.mu.Unlock()
}
func RunStandalone(cfg *config.MuyueConfig) {
d := NewDaemon(cfg, 1*time.Hour)
d.Start()
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
<-sigCh
d.Stop()
}

View File

@@ -26,11 +26,11 @@ type options struct {
type option func(*options)
func WithPort(port int) option {
func withPort(port int) option {
return func(o *options) { o.port = port }
}
func WithNoOpen(noOpen bool) option {
func withNoOpen(noOpen bool) option {
return func(o *options) { o.noOpen = noOpen }
}
@@ -39,10 +39,10 @@ func parseFlags(args []string) []option {
for _, arg := range args {
switch {
case arg == "--no-open":
opts = append(opts, WithNoOpen(true))
opts = append(opts, withNoOpen(true))
case strings.HasPrefix(arg, "--port="):
if p, err := strconv.Atoi(strings.TrimPrefix(arg, "--port=")); err == nil {
opts = append(opts, WithPort(p))
opts = append(opts, withPort(p))
}
case arg == "--port":
// handled as prefix case

View File

@@ -290,46 +290,6 @@ func (i *Installer) installGit() InstallResult {
return InstallResult{Tool: "git", Success: true, Message: "installed and configured"}
}
func (i *Installer) SetupPrompt() error {
starshipPath, err := exec.LookPath("starship")
if err != nil {
return fmt.Errorf("starship not found")
}
rcFile := i.getRCFile()
line := fmt.Sprintf("eval \"$(" + starshipPath + " init %s)\"", i.system.Shell)
appendLine(rcFile, line)
configDir, _ := config.ConfigDir()
starshipConfig := `format = """
$directory\
$git_branch\
$git_status\
$git_metrics\
$nodejs\
$python\
$golang\
$rust\
$cmd_duration\
$line_break\
$character"""
[character]
success_symbol = "[](bold green)"
error_symbol = "[](bold red)"
[git_branch]
format = "[$symbol$branch]($style) "
[git_status]
format = '([$all_status$ahead_behind]($style) )'
`
configPath := configDir + "/starship.toml"
os.MkdirAll(configDir, 0755)
os.WriteFile(configPath, []byte(starshipConfig), 0644)
return nil
}
func (i *Installer) getRCFile() string {
home, _ := os.UserHomeDir()

View File

@@ -1,13 +1,9 @@
package lsp
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"github.com/muyue/muyue/internal/config"
)
type LSPServer struct {
@@ -15,14 +11,9 @@ type LSPServer struct {
Language string `json:"language"`
Command string `json:"command"`
InstallCmd string `json:"install_cmd"`
ConfigFile string `json:"config_file"`
Installed bool `json:"installed"`
}
type LSPConfig struct {
Servers []LSPServer `json:"servers"`
}
var knownServers = []LSPServer{
{Name: "gopls", Language: "go", Command: "gopls", InstallCmd: "go install golang.org/x/tools/gopls@latest"},
{Name: "pyright", Language: "python", Command: "pyright", InstallCmd: "npm install -g pyright"},
@@ -111,85 +102,4 @@ func InstallForLanguages(languages []string) []LSPServer {
return results
}
func GenerateCrushConfig(cfg *config.MuyueConfig) error {
if cfg == nil {
return fmt.Errorf("config is nil")
}
configDir, err := config.ConfigDir()
if err != nil {
return err
}
type lspEntry struct {
Command []string `json:"command"`
}
lspConfig := map[string]lspEntry{}
for _, lang := range cfg.Profile.Languages {
switch lang {
case "go":
lspConfig["go"] = lspEntry{Command: []string{"gopls"}}
case "python":
lspConfig["python"] = lspEntry{Command: []string{"pyright-langserver", "--stdio"}}
case "typescript", "javascript":
lspConfig["typescript"] = lspEntry{Command: []string{"typescript-language-server", "--stdio"}}
case "rust":
lspConfig["rust"] = lspEntry{Command: []string{"rust-analyzer"}}
case "c", "cpp":
lspConfig["c"] = lspEntry{Command: []string{"clangd"}}
case "lua":
lspConfig["lua"] = lspEntry{Command: []string{"lua-language-server"}}
}
}
if len(lspConfig) == 0 {
return nil
}
data, err := json.MarshalIndent(lspConfig, "", " ")
if err != nil {
return err
}
lspPath := filepath.Join(configDir, "crush.json")
existing, err := os.ReadFile(lspPath)
if err == nil {
var existingConfig map[string]interface{}
if unmarshalErr := json.Unmarshal(existing, &existingConfig); unmarshalErr == nil {
var newConfig map[string]interface{}
if unmarshalErr2 := json.Unmarshal(data, &newConfig); unmarshalErr2 == nil {
for k, v := range newConfig {
existingConfig[k] = v
}
data, _ = json.MarshalIndent(existingConfig, "", " ")
}
}
}
return os.WriteFile(lspPath, data, 0644)
}
func EnsureCrushConfig(cfg *config.MuyueConfig) error {
configDir, _ := config.ConfigDir()
crusherPath := filepath.Join(configDir, "crush.json")
if _, err := os.Stat(crusherPath); err != nil {
home, _ := os.UserHomeDir()
homeCrush := filepath.Join(home, ".config", "crush", "crush.json")
if _, err := os.Stat(homeCrush); err == nil {
return nil
}
defaultConfig := map[string]interface{}{
"version": "1",
}
data, _ := json.MarshalIndent(defaultConfig, "", " ")
os.MkdirAll(filepath.Dir(crusherPath), 0755)
return os.WriteFile(crusherPath, data, 0644)
}
return nil
}

View File

@@ -12,7 +12,6 @@ import (
"time"
"github.com/muyue/muyue/internal/config"
"github.com/muyue/muyue/internal/workflow"
)
var thinkRegex = regexp.MustCompile(`(?s)<[Tt]hink[^>]*>.*?</[Tt]hink>`)
@@ -42,12 +41,12 @@ type ChatResponse struct {
}
type Orchestrator struct {
config *config.MuyueConfig
provider *config.AIProvider
client *http.Client
history []Message
histMu sync.Mutex
Workflow *workflow.Workflow
config *config.MuyueConfig
provider *config.AIProvider
client *http.Client
history []Message
histMu sync.Mutex
systemPrompt string
}
var sharedHTTPClient = &http.Client{
@@ -72,14 +71,17 @@ func New(cfg *config.MuyueConfig) (*Orchestrator, error) {
}
return &Orchestrator{
config: cfg,
config: cfg,
provider: provider,
client: sharedHTTPClient,
history: []Message{},
Workflow: workflow.New(),
client: sharedHTTPClient,
history: []Message{},
}, nil
}
func (o *Orchestrator) SetSystemPrompt(prompt string) {
o.systemPrompt = prompt
}
func (o *Orchestrator) Send(userMessage string) (string, error) {
o.histMu.Lock()
o.history = append(o.history, Message{
@@ -91,9 +93,15 @@ func (o *Orchestrator) Send(userMessage string) (string, error) {
o.history = o.history[len(o.history)-maxHistorySize:]
}
messages := make([]Message, 0, len(o.history)+1)
if o.systemPrompt != "" {
messages = append(messages, Message{Role: "system", Content: o.systemPrompt})
}
messages = append(messages, o.history...)
reqBody := ChatRequest{
Model: o.provider.Model,
Messages: o.history,
Messages: messages,
Stream: false,
}
o.histMu.Unlock()
@@ -153,156 +161,6 @@ func (o *Orchestrator) Send(userMessage string) (string, error) {
return content, nil
}
func (o *Orchestrator) StartWorkflow(goal string) (string, error) {
o.Workflow.Start(goal)
prompt := fmt.Sprintf("I want to: %s\nWhat questions do you need to ask me to fully understand this requirement? Ask ALL questions at once.", goal)
o.history = []Message{
{Role: "system", Content: workflow.BuildSystemPrompt(workflow.PhaseGathering, o.Workflow.Plan)},
{Role: "user", Content: prompt},
}
reqBody := ChatRequest{
Model: o.provider.Model,
Messages: o.history,
Stream: false,
}
body, err := json.Marshal(reqBody)
if err != nil {
return "", fmt.Errorf("marshal request: %w", err)
}
baseURL := o.provider.BaseURL
if baseURL == "" {
baseURL = getProviderBaseURL(o.provider.Name)
}
url := strings.TrimRight(baseURL, "/") + "/chat/completions"
req, err := http.NewRequest("POST", url, bytes.NewReader(body))
if err != nil {
return "", fmt.Errorf("create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+o.provider.APIKey)
resp, err := o.client.Do(req)
if err != nil {
return "", fmt.Errorf("send request: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("API error (%d): %s", resp.StatusCode, string(respBody))
}
var chatResp ChatResponse
if err := json.Unmarshal(respBody, &chatResp); err != nil {
return "", fmt.Errorf("parse response: %w", err)
}
if len(chatResp.Choices) == 0 {
return "", fmt.Errorf("no response from AI")
}
content := cleanAIResponse(chatResp.Choices[0].Message.Content)
o.history = append(o.history, Message{
Role: "assistant",
Content: content,
})
return content, nil
}
func (o *Orchestrator) AnswerQuestion(answer string) (string, error) {
o.Workflow.AddAnswer(answer)
return o.Send(answer)
}
func (o *Orchestrator) GeneratePlan() (string, error) {
o.Workflow.Phase = workflow.PhasePlanning
o.history = append(o.history, Message{
Role: "system",
Content: workflow.BuildSystemPrompt(workflow.PhasePlanning, o.Workflow.Plan),
})
prompt := "All questions have been answered. Now create a detailed step-by-step execution plan as a JSON array. Each step should have: id, title, description, agent (crush/claude/muyue)."
if len(o.Workflow.Plan.PreviewFiles) > 0 {
prompt += "\nInclude visual previews where helpful using the PREVIEW_JSON format."
}
resp, err := o.Send(prompt)
if err != nil {
return "", err
}
steps, parseErr := workflow.ParsePlanResponse(resp)
if parseErr == nil {
o.Workflow.SetPlan("")
o.Workflow.Plan.Steps = steps
o.Workflow.Phase = workflow.PhaseReviewing
}
previewFiles := workflow.ParsePreviewFiles(resp)
if len(previewFiles) > 0 {
o.Workflow.SetPreviewFiles(previewFiles)
}
return resp, nil
}
func (o *Orchestrator) ReviewPlan(approved bool, feedback string) (string, error) {
if approved {
o.Workflow.Approve()
return o.executeNextStep()
}
o.Workflow.Reject(feedback)
return o.Send(fmt.Sprintf("The plan was rejected. Reason: %s. Please revise the plan.", feedback))
}
func (o *Orchestrator) executeNextStep() (string, error) {
step := o.Workflow.CurrentStep()
if step == nil {
return "All steps completed!", nil
}
o.history = append(o.history, Message{
Role: "system",
Content: workflow.BuildSystemPrompt(workflow.PhaseExecuting, o.Workflow.Plan),
})
return o.Send(fmt.Sprintf("Execute step %s: %s\n%s", step.ID, step.Title, step.Description))
}
func (o *Orchestrator) ContinueExecution(output string) (string, error) {
o.Workflow.AdvanceStep(output)
if o.Workflow.Phase == workflow.PhaseDone {
return "Workflow completed! All steps have been executed.", nil
}
return o.executeNextStep()
}
func (o *Orchestrator) History() []Message {
o.histMu.Lock()
defer o.histMu.Unlock()
cp := make([]Message, len(o.history))
copy(cp, o.history)
return cp
}
func (o *Orchestrator) ClearHistory() {
o.histMu.Lock()
o.history = []Message{}
o.histMu.Unlock()
o.Workflow.Reset()
}
func cleanAIResponse(content string) string {
content = thinkRegex.ReplaceAllString(content, "")
lines := strings.Split(content, "\n")

View File

@@ -7,13 +7,6 @@ import (
"github.com/muyue/muyue/internal/config"
)
func testConfig() *config.MuyueConfig {
cfg := config.Default()
cfg.AI.Providers[0].Active = true
cfg.AI.Providers[0].APIKey = "test-api-key-12345"
return cfg
}
func TestCleanAIResponse(t *testing.T) {
tests := []struct {
name string
@@ -153,58 +146,3 @@ func TestNewNoAPIKey(t *testing.T) {
t.Error("Should fail with no API key")
}
}
func TestHistoryManagement(t *testing.T) {
cfg := testConfig()
orch, err := New(cfg)
if err != nil {
t.Fatalf("New failed: %v", err)
}
h := orch.History()
if len(h) != 0 {
t.Errorf("Expected empty history, got %d", len(h))
}
orch.ClearHistory()
h = orch.History()
if len(h) != 0 {
t.Errorf("Expected 0 after clear, got %d", len(h))
}
}
func TestHistoryCopy(t *testing.T) {
cfg := testConfig()
orch, _ := New(cfg)
orch.history = []Message{
{Role: "user", Content: "hello"},
}
h := orch.History()
h[0].Content = "modified"
orig := orch.History()
if orig[0].Content == "modified" {
t.Error("History should return a copy")
}
}
func TestMaxHistorySize(t *testing.T) {
cfg := testConfig()
orch, _ := New(cfg)
for i := 0; i < maxHistorySize+10; i++ {
orch.histMu.Lock()
orch.history = append(orch.history, Message{Role: "user", Content: "msg"})
if len(orch.history) > maxHistorySize {
orch.history = orch.history[len(orch.history)-maxHistorySize:]
}
orch.histMu.Unlock()
}
h := orch.History()
if len(h) > maxHistorySize {
t.Errorf("History should be capped at %d, got %d", maxHistorySize, len(h))
}
}

View File

@@ -1,79 +0,0 @@
package preview
import (
"fmt"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"time"
)
type PreviewServer struct {
dir string
server *http.Server
}
func NewPreviewServer(dir string) *PreviewServer {
return &PreviewServer{dir: dir}
}
func (p *PreviewServer) Start(port int) error {
fs := http.FileServer(http.Dir(p.dir))
mux := http.NewServeMux()
mux.Handle("/", fs)
p.server = &http.Server{
Addr: fmt.Sprintf("127.0.0.1:%d", port),
Handler: mux,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
}
go func() {
if err := p.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
fmt.Printf("Preview server error: %s\n", err)
}
}()
url := fmt.Sprintf("http://127.0.0.1:%d", port)
fmt.Printf("Preview server running at %s\n", url)
return openBrowser(url)
}
func (p *PreviewServer) Stop() error {
if p.server != nil {
return p.server.Close()
}
return nil
}
func CreatePreviewFile(dir, filename, content string) error {
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
return os.WriteFile(filepath.Join(dir, filename), []byte(content), 0644)
}
func openBrowser(url string) error {
var cmd string
var args []string
switch runtime.GOOS {
case "linux":
cmd = "xdg-open"
args = []string{url}
case "darwin":
cmd = "open"
args = []string{url}
case "windows":
cmd = "cmd"
args = []string{"/c", "start", url}
default:
return fmt.Errorf("unsupported platform")
}
return exec.Command(cmd, args...).Start()
}

View File

@@ -1,250 +0,0 @@
package proxy
import (
"bufio"
"context"
"fmt"
"io"
"os"
"os/exec"
"strings"
"sync"
"time"
)
type AgentType string
const (
AgentCrush AgentType = "crush"
AgentClaude AgentType = "claude"
)
type AgentStatus string
const (
StatusIdle AgentStatus = "idle"
StatusRunning AgentStatus = "running"
StatusStopped AgentStatus = "stopped"
StatusError AgentStatus = "error"
)
type LogEntry struct {
Timestamp time.Time
Agent AgentType
Level string
Message string
}
type Agent struct {
Type AgentType
Status AgentStatus
cmd *exec.Cmd
stdout io.Reader
stderr io.Reader
cancel context.CancelFunc
mu sync.Mutex
logs []LogEntry
}
type Manager struct {
agents map[AgentType]*Agent
mu sync.RWMutex
}
func NewManager() *Manager {
return &Manager{
agents: make(map[AgentType]*Agent),
}
}
func (m *Manager) Start(agentType AgentType, args ...string) error {
m.mu.Lock()
defer m.mu.Unlock()
if a, exists := m.agents[agentType]; exists && a.Status == StatusRunning {
return fmt.Errorf("%s already running", agentType)
}
ctx, cancel := context.WithCancel(context.Background())
var cmdName string
switch agentType {
case AgentCrush:
cmdName = "crush"
case AgentClaude:
cmdName = "claude"
default:
cancel()
return fmt.Errorf("unknown agent type: %s", agentType)
}
cmd := exec.CommandContext(ctx, cmdName, args...)
cmd.Env = os.Environ()
stdout, pipeErr := cmd.StdoutPipe()
if pipeErr != nil {
cancel()
return fmt.Errorf("stdout pipe: %w", pipeErr)
}
stderr, pipeErr := cmd.StderrPipe()
if pipeErr != nil {
cancel()
return fmt.Errorf("stderr pipe: %w", pipeErr)
}
agent := &Agent{
Type: agentType,
Status: StatusRunning,
cmd: cmd,
stdout: stdout,
stderr: stderr,
cancel: cancel,
}
m.agents[agentType] = agent
go agent.captureOutput(stdout, "info")
go agent.captureOutput(stderr, "error")
if err := cmd.Start(); err != nil {
agent.Status = StatusError
cancel()
return fmt.Errorf("start %s: %w", agentType, err)
}
go func() {
err := cmd.Wait()
m.mu.Lock()
defer m.mu.Unlock()
if err != nil && ctx.Err() == nil {
agent.Status = StatusError
agent.log("error", fmt.Sprintf("exited with error: %s", err))
} else {
agent.Status = StatusStopped
agent.log("info", "stopped")
}
}()
return nil
}
func (m *Manager) Stop(agentType AgentType) error {
m.mu.Lock()
defer m.mu.Unlock()
agent, exists := m.agents[agentType]
if !exists {
return fmt.Errorf("%s not found", agentType)
}
if agent.Status != StatusRunning {
return fmt.Errorf("%s is not running", agentType)
}
agent.cancel()
agent.Status = StatusStopped
return nil
}
func (m *Manager) Status(agentType AgentType) (AgentStatus, []LogEntry) {
m.mu.RLock()
defer m.mu.RUnlock()
agent, exists := m.agents[agentType]
if !exists {
return StatusIdle, nil
}
agent.mu.Lock()
defer agent.mu.Unlock()
return agent.Status, agent.logs
}
func (m *Manager) AllStatus() map[AgentType]AgentStatus {
m.mu.RLock()
defer m.mu.RUnlock()
statuses := make(map[AgentType]AgentStatus)
for t, a := range m.agents {
statuses[t] = a.Status
}
return statuses
}
func (a *Agent) captureOutput(reader io.Reader, level string) {
scanner := bufio.NewScanner(reader)
for scanner.Scan() {
line := scanner.Text()
a.mu.Lock()
a.logs = append(a.logs, LogEntry{
Timestamp: time.Now(),
Agent: a.Type,
Level: level,
Message: line,
})
if len(a.logs) > 1000 {
a.logs = a.logs[500:]
}
a.mu.Unlock()
}
}
func (a *Agent) log(level, msg string) {
a.mu.Lock()
defer a.mu.Unlock()
a.logs = append(a.logs, LogEntry{
Timestamp: time.Now(),
Agent: a.Type,
Level: level,
Message: msg,
})
}
func (m *Manager) IsAvailable(agentType AgentType) bool {
var cmdName string
switch agentType {
case AgentCrush:
cmdName = "crush"
case AgentClaude:
cmdName = "claude"
default:
return false
}
path, err := exec.LookPath(cmdName)
return err == nil && path != ""
}
func (m *Manager) GetLogs(agentType AgentType, lastN int) []LogEntry {
m.mu.RLock()
agent, exists := m.agents[agentType]
m.mu.RUnlock()
if !exists {
return nil
}
agent.mu.Lock()
defer agent.mu.Unlock()
logs := agent.logs
if lastN > 0 && len(logs) > lastN {
logs = logs[len(logs)-lastN:]
}
return logs
}
func FormatLogs(logs []LogEntry) string {
var b strings.Builder
for _, l := range logs {
b.WriteString(fmt.Sprintf("[%s] %s %s: %s\n",
l.Timestamp.Format("15:04:05"),
l.Agent,
l.Level,
l.Message,
))
}
return b.String()
}

View File

@@ -24,14 +24,6 @@ type Skill struct {
FilePath string `yaml:"-" json:"-"`
}
type Target string
const (
TargetCrush Target = "crush"
TargetClaude Target = "claude"
TargetBoth Target = "both"
)
func SkillsDir() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
@@ -122,27 +114,6 @@ func Create(skill *Skill) error {
return Deploy(skill)
}
func Update(skill *Skill) error {
dir, err := SkillsDir()
if err != nil {
return err
}
skillDir := filepath.Join(dir, skill.Name)
skillPath := filepath.Join(skillDir, "SKILL.md")
if _, err := os.Stat(skillPath); err != nil {
return fmt.Errorf("skill '%s' not found", skill.Name)
}
skill.UpdatedAt = time.Now()
content := renderSkill(skill)
if err := os.WriteFile(skillPath, []byte(content), 0644); err != nil {
return err
}
return Deploy(skill)
}
func Delete(name string) error {
dir, err := SkillsDir()
if err != nil {
@@ -164,7 +135,7 @@ func Deploy(skill *Skill) error {
return fmt.Errorf("get home dir: %w", err)
}
if skill.Target == string(TargetCrush) || skill.Target == string(TargetBoth) {
if skill.Target == "crush" || skill.Target == "both" {
crushSkillsDir := filepath.Join(home, ".config", "crush", "skills")
if err := os.MkdirAll(crushSkillsDir, 0755); err != nil {
return fmt.Errorf("create crush skills dir: %w", err)
@@ -179,7 +150,7 @@ func Deploy(skill *Skill) error {
}
}
if skill.Target == string(TargetClaude) || skill.Target == string(TargetBoth) {
if skill.Target == "claude" || skill.Target == "both" {
claudeSkillsDir := filepath.Join(home, ".claude", "skills")
if err := os.MkdirAll(claudeSkillsDir, 0755); err != nil {
return fmt.Errorf("create claude skills dir: %w", err)

View File

@@ -2,17 +2,10 @@ package version
const (
Name = "muyue"
Version = "0.2.1"
Version = "0.3.0"
Author = "La Légion de Muyue"
License = "MIT"
)
var Prerelease string
func FullVersion() string {
v := Name + " v" + Version
if Prerelease != "" {
v += "-" + Prerelease
}
return v
return Name + " v" + Version
}

View File

@@ -15,28 +15,6 @@ func TestFullVersion(t *testing.T) {
}
}
func TestFullVersionWithPrerelease(t *testing.T) {
original := Prerelease
Prerelease = "beta.1"
defer func() { Prerelease = original }()
v := FullVersion()
if !strings.Contains(v, "beta.1") {
t.Errorf("FullVersion should contain prerelease suffix, got %s", v)
}
}
func TestFullVersionWithoutPrerelease(t *testing.T) {
original := Prerelease
Prerelease = ""
defer func() { Prerelease = original }()
v := FullVersion()
if strings.Contains(v, "-") {
t.Errorf("FullVersion should not contain prerelease suffix, got %s", v)
}
}
func TestConstants(t *testing.T) {
if Name == "" {
t.Error("Name should not be empty")
@@ -47,7 +25,4 @@ func TestConstants(t *testing.T) {
if Author == "" {
t.Error("Author should not be empty")
}
if License == "" {
t.Error("License should not be empty")
}
}

View File

@@ -1,280 +0,0 @@
package workflow
import (
"encoding/json"
"fmt"
"strings"
)
type Phase string
const (
PhaseIdle Phase = "idle"
PhaseGathering Phase = "gathering"
PhasePlanning Phase = "planning"
PhaseReviewing Phase = "reviewing"
PhaseExecuting Phase = "executing"
PhaseDone Phase = "done"
PhaseError Phase = "error"
)
type Step struct {
ID string `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
Status string `json:"status"`
Agent string `json:"agent"`
Output string `json:"output,omitempty"`
}
type Plan struct {
Goal string `json:"goal"`
Context string `json:"context"`
Questions []string `json:"questions"`
Answers []string `json:"answers"`
Steps []Step `json:"steps"`
StepIndex int `json:"current_step"`
PreviewFiles []PreviewFile `json:"preview_files,omitempty"`
}
type PreviewFile struct {
Filename string `json:"filename"`
Content string `json:"content"`
Type string `json:"type"`
}
type Workflow struct {
Phase Phase
Plan *Plan
History []string
}
func New() *Workflow {
return &Workflow{
Phase: PhaseIdle,
Plan: &Plan{},
History: []string{},
}
}
func (w *Workflow) Start(goal string) {
w.Phase = PhaseGathering
w.Plan = &Plan{
Goal: goal,
Steps: []Step{},
Answers: []string{},
}
w.History = append(w.History, fmt.Sprintf("[started] %s", goal))
}
func (w *Workflow) AddAnswer(answer string) {
w.Plan.Answers = append(w.Plan.Answers, answer)
if len(w.Plan.Answers) >= len(w.Plan.Questions) {
w.Phase = PhasePlanning
w.History = append(w.History, "[gathering complete, moving to planning]")
}
}
func (w *Workflow) SetPlan(planJSON string) error {
var steps []Step
if err := json.Unmarshal([]byte(planJSON), &steps); err != nil {
if err2 := json.Unmarshal([]byte("["+planJSON+"]"), &steps); err2 != nil {
return fmt.Errorf("parse plan: %w", err)
}
}
w.Plan.Steps = steps
w.Phase = PhaseReviewing
w.History = append(w.History, fmt.Sprintf("[plan created] %d steps", len(steps)))
return nil
}
func (w *Workflow) SetPreviewFiles(files []PreviewFile) {
w.Plan.PreviewFiles = files
}
func (w *Workflow) Approve() {
w.Phase = PhaseExecuting
w.Plan.StepIndex = 0
w.History = append(w.History, "[plan approved, starting execution]")
}
func (w *Workflow) Reject(feedback string) {
w.Phase = PhasePlanning
w.History = append(w.History, fmt.Sprintf("[plan rejected: %s]", feedback))
}
func (w *Workflow) AdvanceStep(output string) {
if w.Plan.StepIndex < len(w.Plan.Steps) {
w.Plan.Steps[w.Plan.StepIndex].Status = "done"
w.Plan.Steps[w.Plan.StepIndex].Output = output
w.Plan.StepIndex++
w.History = append(w.History, fmt.Sprintf("[step %d done]", w.Plan.StepIndex))
if w.Plan.StepIndex >= len(w.Plan.Steps) {
w.Phase = PhaseDone
w.History = append(w.History, "[all steps complete]")
}
}
}
func (w *Workflow) FailStep(errMsg string) {
if w.Plan.StepIndex < len(w.Plan.Steps) {
w.Plan.Steps[w.Plan.StepIndex].Status = "error"
w.Plan.Steps[w.Plan.StepIndex].Output = errMsg
w.Phase = PhaseError
w.History = append(w.History, fmt.Sprintf("[step %d failed: %s]", w.Plan.StepIndex+1, errMsg))
}
}
func (w *Workflow) Reset() {
w.Phase = PhaseIdle
w.Plan = &Plan{}
}
func (w *Workflow) CurrentStep() *Step {
if w.Plan.StepIndex < len(w.Plan.Steps) {
return &w.Plan.Steps[w.Plan.StepIndex]
}
return nil
}
func (w *Workflow) Progress() (done, total int) {
for _, s := range w.Plan.Steps {
if s.Status == "done" {
done++
}
total++
}
return
}
func BuildSystemPrompt(phase Phase, plan *Plan) string {
base := `You are muyue, an AI-powered development environment assistant.
You follow a structured workflow: GATHER requirements → PLAN → REVIEW → EXECUTE.
RULES:
- Always respond in the same language the user writes in.
- When in GATHERING phase, ask clarifying questions ONE AT A TIME to understand the requirement fully.
- When in PLANNING phase, create a detailed step-by-step plan as a JSON array of objects.
- When in REVIEWING phase, present the plan clearly and wait for approval.
- When in EXECUTING phase, execute one step at a time and report results.
- If the user wants a visual preview, generate 1-2 HTML files wrapped in a PREVIEW_JSON block.`
switch phase {
case PhaseGathering:
base += fmt.Sprintf(`
CURRENT PHASE: GATHERING
Goal: %s
Questions to ask: %v
Answers received: %v
Remaining questions: %d
Ask the NEXT question that hasn't been answered yet. If all questions are answered, say "GATHERING_COMPLETE".`,
plan.Goal, plan.Questions, plan.Answers,
len(plan.Questions)-len(plan.Answers))
case PhasePlanning:
qa := ""
for i, q := range plan.Questions {
a := ""
if i < len(plan.Answers) {
a = plan.Answers[i]
}
qa += fmt.Sprintf("\nQ: %s\nA: %s", q, a)
}
base += fmt.Sprintf(`
CURRENT PHASE: PLANNING
Goal: %s
%s
Create a step-by-step plan. Output ONLY a JSON array of steps:
[
{"id": "1", "title": "...", "description": "...", "agent": "crush|claude|muyue", "status": "pending"},
...
]
If the user needs a visual preview, wrap HTML in:
<<<PREVIEW_JSON>>>
[{"filename":"preview.html","content":"<html>...</html>","type":"html"}]
<<<END_PREVIEW>>>`,
plan.Goal, qa)
case PhaseReviewing:
steps, _ := json.MarshalIndent(plan.Steps, "", " ")
base += fmt.Sprintf(`
CURRENT PHASE: REVIEWING
Present the plan below clearly and ask for approval:
%s
Say "PLAN_APPROVED" if the user approves, or "PLAN_REJECTED: <reason>" if not.`,
string(steps))
case PhaseExecuting:
if plan.StepIndex < len(plan.Steps) {
step := plan.Steps[plan.StepIndex]
base += fmt.Sprintf(`
CURRENT PHASE: EXECUTING
Current step: %s — %s (agent: %s)
Execute this step and report the result.`,
step.Title, step.Description, step.Agent)
}
}
return base
}
func ParsePlanResponse(response string) ([]Step, error) {
response = strings.TrimSpace(response)
start := strings.Index(response, "[")
end := strings.LastIndex(response, "]")
if start == -1 || end == -1 || end <= start {
return nil, fmt.Errorf("no JSON array found in response")
}
jsonStr := response[start : end+1]
var steps []Step
if err := json.Unmarshal([]byte(jsonStr), &steps); err != nil {
return nil, fmt.Errorf("parse steps: %w", err)
}
for i := range steps {
steps[i].Status = "pending"
}
return steps, nil
}
func ParsePreviewFiles(response string) []PreviewFile {
startMarker := "<<<PREVIEW_JSON>>>"
endMarker := "<<<END_PREVIEW>>>"
start := strings.Index(response, startMarker)
end := strings.Index(response, endMarker)
if start == -1 || end == -1 {
return nil
}
jsonStr := strings.TrimSpace(response[start+len(startMarker) : end])
var files []PreviewFile
if err := json.Unmarshal([]byte(jsonStr), &files); err != nil {
return nil
}
return files
}
func ParseApproval(response string) (approved bool, feedback string) {
lower := strings.ToLower(strings.TrimSpace(response))
if strings.Contains(lower, "plan_approved") || strings.Contains(lower, "approved") || strings.Contains(lower, "yes") || strings.Contains(lower, "go ahead") || strings.Contains(lower, "oui") || strings.Contains(lower, "ok") {
return true, ""
}
if strings.Contains(lower, "plan_rejected:") {
parts := strings.SplitN(lower, "plan_rejected:", 2)
if len(parts) > 1 {
return false, strings.TrimSpace(parts[1])
}
}
return false, response
}

View File

@@ -1,255 +0,0 @@
package workflow
import (
"testing"
)
func TestNew(t *testing.T) {
wf := New()
if wf.Phase != PhaseIdle {
t.Errorf("Expected PhaseIdle, got %s", wf.Phase)
}
if wf.Plan == nil {
t.Error("Plan should not be nil")
}
}
func TestStart(t *testing.T) {
wf := New()
wf.Start("Build a REST API")
if wf.Phase != PhaseGathering {
t.Errorf("Expected PhaseGathering, got %s", wf.Phase)
}
if wf.Plan.Goal != "Build a REST API" {
t.Errorf("Expected goal 'Build a REST API', got %s", wf.Plan.Goal)
}
}
func TestAddAnswer(t *testing.T) {
wf := New()
wf.Start("test goal")
wf.Plan.Questions = []string{"Q1?", "Q2?"}
wf.AddAnswer("A1")
if wf.Phase != PhaseGathering {
t.Errorf("Should still be gathering, got %s", wf.Phase)
}
wf.AddAnswer("A2")
if wf.Phase != PhasePlanning {
t.Errorf("Should move to planning, got %s", wf.Phase)
}
}
func TestSetPlan(t *testing.T) {
wf := New()
planJSON := `[{"id":"1","title":"Step 1","description":"Do something","agent":"crush","status":"pending"}]`
err := wf.SetPlan(planJSON)
if err != nil {
t.Fatalf("SetPlan failed: %v", err)
}
if len(wf.Plan.Steps) != 1 {
t.Errorf("Expected 1 step, got %d", len(wf.Plan.Steps))
}
if wf.Phase != PhaseReviewing {
t.Errorf("Expected PhaseReviewing, got %s", wf.Phase)
}
}
func TestApprove(t *testing.T) {
wf := New()
wf.Start("test")
wf.Plan.Steps = []Step{{ID: "1", Title: "Step 1", Status: "pending"}}
wf.Phase = PhaseReviewing
wf.Approve()
if wf.Phase != PhaseExecuting {
t.Errorf("Expected PhaseExecuting, got %s", wf.Phase)
}
if wf.Plan.StepIndex != 0 {
t.Errorf("Expected step index 0, got %d", wf.Plan.StepIndex)
}
}
func TestReject(t *testing.T) {
wf := New()
wf.Phase = PhaseReviewing
wf.Reject("too complex")
if wf.Phase != PhasePlanning {
t.Errorf("Expected PhasePlanning, got %s", wf.Phase)
}
}
func TestAdvanceStep(t *testing.T) {
wf := New()
wf.Plan.Steps = []Step{
{ID: "1", Title: "Step 1", Status: "pending"},
{ID: "2", Title: "Step 2", Status: "pending"},
}
wf.Phase = PhaseExecuting
wf.AdvanceStep("output1")
if wf.Plan.Steps[0].Status != "done" {
t.Error("First step should be done")
}
if wf.Plan.StepIndex != 1 {
t.Errorf("Expected step index 1, got %d", wf.Plan.StepIndex)
}
if wf.Phase != PhaseExecuting {
t.Errorf("Should still be executing, got %s", wf.Phase)
}
wf.AdvanceStep("output2")
if wf.Phase != PhaseDone {
t.Errorf("Expected PhaseDone, got %s", wf.Phase)
}
}
func TestFailStep(t *testing.T) {
wf := New()
wf.Plan.Steps = []Step{{ID: "1", Title: "Step 1"}}
wf.Phase = PhaseExecuting
wf.FailStep("something broke")
if wf.Phase != PhaseError {
t.Errorf("Expected PhaseError, got %s", wf.Phase)
}
if wf.Plan.Steps[0].Status != "error" {
t.Error("Step should have error status")
}
}
func TestReset(t *testing.T) {
wf := New()
wf.Start("test")
wf.Phase = PhaseExecuting
wf.Reset()
if wf.Phase != PhaseIdle {
t.Errorf("Expected PhaseIdle, got %s", wf.Phase)
}
}
func TestCurrentStep(t *testing.T) {
wf := New()
if wf.CurrentStep() != nil {
t.Error("Should be nil with no steps")
}
wf.Plan.Steps = []Step{{ID: "1"}, {ID: "2"}}
wf.Plan.StepIndex = 0
step := wf.CurrentStep()
if step == nil || step.ID != "1" {
t.Error("Should return first step")
}
wf.Plan.StepIndex = 2
if wf.CurrentStep() != nil {
t.Error("Should be nil when past all steps")
}
}
func TestProgress(t *testing.T) {
wf := New()
wf.Plan.Steps = []Step{
{ID: "1", Status: "done"},
{ID: "2", Status: "pending"},
{ID: "3", Status: "done"},
}
done, total := wf.Progress()
if done != 2 || total != 3 {
t.Errorf("Expected 2/3, got %d/%d", done, total)
}
}
func TestParsePlanResponse(t *testing.T) {
resp := `Here is the plan:
[
{"id": "1", "title": "Setup", "description": "Init project", "agent": "crush"},
{"id": "2", "title": "Build", "description": "Write code", "agent": "claude"}
]`
steps, err := ParsePlanResponse(resp)
if err != nil {
t.Fatalf("ParsePlanResponse failed: %v", err)
}
if len(steps) != 2 {
t.Errorf("Expected 2 steps, got %d", len(steps))
}
if steps[0].ID != "1" {
t.Errorf("Expected step ID 1, got %s", steps[0].ID)
}
for _, s := range steps {
if s.Status != "pending" {
t.Errorf("Steps should be pending, got %s", s.Status)
}
}
}
func TestParsePlanResponseInvalid(t *testing.T) {
_, err := ParsePlanResponse("no json here")
if err == nil {
t.Error("Should fail with no JSON")
}
}
func TestParseApproval(t *testing.T) {
tests := []struct {
input string
approved bool
}{
{"plan_approved", true},
{"approved", true},
{"yes", true},
{"ok", true},
{"oui", true},
{"go ahead", true},
{"no", false},
{"plan_rejected: too complex", false},
{"I don't like it", false},
}
for _, tt := range tests {
approved, feedback := ParseApproval(tt.input)
if approved != tt.approved {
t.Errorf("ParseApproval(%q) = %v, want %v", tt.input, approved, tt.approved)
}
if !approved && tt.input == "plan_rejected: too complex" {
if feedback != "too complex" {
t.Errorf("Expected feedback 'too complex', got %s", feedback)
}
}
}
}
func TestParsePreviewFiles(t *testing.T) {
resp := `Some text
<<<PREVIEW_JSON>>>
[{"filename":"test.html","content":"<h1>Hello</h1>","type":"html"}]
<<<END_PREVIEW>>>`
files := ParsePreviewFiles(resp)
if len(files) != 1 {
t.Fatalf("Expected 1 file, got %d", len(files))
}
if files[0].Filename != "test.html" {
t.Errorf("Expected test.html, got %s", files[0].Filename)
}
}
func TestParsePreviewFilesNone(t *testing.T) {
files := ParsePreviewFiles("no preview here")
if files != nil {
t.Error("Should return nil")
}
}
func TestBuildSystemPrompt(t *testing.T) {
prompt := BuildSystemPrompt(PhaseIdle, &Plan{})
if prompt == "" {
t.Error("Prompt should not be empty")
}
if len(prompt) < 100 {
t.Error("Prompt seems too short")
}
prompt = BuildSystemPrompt(PhaseGathering, &Plan{Goal: "test"})
if prompt == "" {
t.Error("Gathering prompt should not be empty")
}
}

34
web/package-lock.json generated
View File

@@ -6,6 +6,10 @@
"": {
"name": "muyue-web",
"dependencies": {
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
"lucide-react": "^1.8.0",
"react": "^19.2.5",
"react-dom": "^19.2.5"
},
@@ -396,6 +400,27 @@
}
}
},
"node_modules/@xterm/addon-fit": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz",
"integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==",
"license": "MIT"
},
"node_modules/@xterm/addon-web-links": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.12.0.tgz",
"integrity": "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==",
"license": "MIT"
},
"node_modules/@xterm/xterm": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",
"integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==",
"license": "MIT",
"workspaces": [
"addons/*"
]
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -712,6 +737,15 @@
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lucide-react": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.8.0.tgz",
"integrity": "sha512-WuvlsjngSk7TnTBJ1hsCy3ql9V9VOdcPkd3PKcSmM34vJD8KG6molxz7m7zbYFgICwsanQWmJ13JlYs4Zp7Arw==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",

View File

@@ -8,6 +8,10 @@
"preview": "vite preview"
},
"dependencies": {
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
"lucide-react": "^1.8.0",
"react": "^19.2.5",
"react-dom": "^19.2.5"
},

View File

@@ -26,7 +26,52 @@ const api = {
installTools: (tools) => request('/install', { method: 'POST', body: JSON.stringify({ tools }) }),
configureMCP: () => request('/mcp/configure', { method: 'POST' }),
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) }),
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 }) }),
getTerminalSessions: () => request('/terminal/sessions'),
addSSHConnection: (conn) => request('/terminal/sessions', { method: 'POST', body: JSON.stringify(conn) }),
deleteSSHConnection: (name) => request(`/terminal/sessions/${encodeURIComponent(name)}`, { method: 'DELETE' }),
getChatHistory: () => request('/chat/history'),
clearChat: () => request('/chat/clear', { method: 'POST' }),
sendChat: (message, stream = true) => {
if (!stream) {
return request('/chat', { method: 'POST', body: JSON.stringify({ message, stream: false }) })
}
return new Promise((resolve, reject) => {
fetch(`${API_BASE}/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message, stream: true }),
}).then(async (res) => {
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }))
reject(new Error(err.error || res.statusText))
return
}
const reader = res.body.getReader()
const decoder = new TextDecoder()
let full = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
const text = decoder.decode(value, { stream: true })
for (const line of text.split('\n')) {
if (!line.startsWith('data: ')) continue
try {
const data = JSON.parse(line.slice(6))
if (data.error) { reject(new Error(data.error)); return }
if (data.done) { resolve(full); return }
if (data.content) full += data.content
} catch {}
}
}
resolve(full)
}).catch(reject)
})
},
}
export default api

View File

@@ -1,6 +1,7 @@
import { useState, useEffect, useCallback, useMemo } from 'react'
import { LayoutDashboard, Sparkles, Terminal, Settings } from 'lucide-react'
import api from '../api/client'
import { getTheme, getThemeNames, applyTheme } from '../themes'
import { getTheme, applyTheme } from '../themes'
import { useI18n } from '../i18n'
import Dashboard from './Dashboard'
import Studio from './Studio'
@@ -16,10 +17,10 @@ export default function App() {
const { t, layout } = useI18n()
const TABS = useMemo(() => [
{ id: 'dash', label: t('tabs.dashboard'), icon: '\u25A0' },
{ id: 'studio', label: t('tabs.studio'), icon: '\u27E8\u27E9' },
{ id: 'shell', label: t('tabs.shell'), icon: '$' },
{ id: 'config', label: t('tabs.config'), icon: '\u2699' },
{ id: 'dash', label: t('tabs.dashboard'), icon: <LayoutDashboard size={15} /> },
{ id: 'studio', label: t('tabs.studio'), icon: <Sparkles size={15} /> },
{ id: 'shell', label: t('tabs.shell'), icon: <Terminal size={15} /> },
{ id: 'config', label: t('tabs.config'), icon: <Settings size={15} /> },
], [t])
useEffect(() => {

View File

@@ -1,58 +1,394 @@
import { useState, useEffect } from 'react'
import { getThemeNames, applyTheme, getTheme } from '../themes'
import { useState, useEffect, useCallback } from 'react'
import { User, Brain, RefreshCw, Globe, Wrench } from 'lucide-react'
import { useI18n, LANGUAGES } from '../i18n'
import { getLayoutList } from '../i18n/keyboards'
const PANELS = [
{ id: 'profile', icon: User },
{ id: 'providers', icon: Brain },
{ id: 'updates', icon: RefreshCw },
{ id: 'locale', icon: Globe },
{ id: 'skills', icon: Wrench },
]
export default function Config({ api }) {
const { t, language, keyboard, setLanguage, setKeyboard, layout } = useI18n()
const { t, language, keyboard, setLanguage, setKeyboard } = useI18n()
const [activePanel, setActivePanel] = useState('profile')
const [config, setConfig] = useState(null)
const [providers, setProviders] = useState([])
const [skillList, setSkillList] = useState([])
const [currentTheme, setCurrentTheme] = useState('cyberpunk-red')
const [updates, setUpdates] = useState([])
const [tools, setTools] = useState([])
const [checking, setChecking] = useState(false)
const [updating, setUpdating] = useState(null)
const [editProfile, setEditProfile] = useState(false)
const [editProvider, setEditProvider] = useState(null)
const [profileForm, setProfileForm] = useState({})
const [providerForm, setProviderForm] = useState({})
const [toast, setToast] = useState(null)
useEffect(() => {
api.getConfig().then(d => setConfig(d)).catch(() => {})
api.getProviders().then(d => setProviders(d.providers || [])).catch(() => {})
api.getSkills().then(d => setSkillList(d.skills || [])).catch(() => {})
}, [])
const themes = getThemeNames()
const layouts = getLayoutList()
const handleThemeChange = (themeId) => {
applyTheme(getTheme(themeId))
setCurrentTheme(themeId)
const loadData = useCallback(() => {
api.getConfig().then(d => {
setConfig(d)
setProfileForm({
name: d.profile?.name || '',
pseudo: d.profile?.pseudo || '',
email: d.profile?.email || '',
editor: d.profile?.preferences?.editor || '',
shell: d.profile?.preferences?.shell || '',
})
}).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])
useEffect(() => { loadData() }, [loadData])
const showToast = (msg) => {
setToast(msg)
setTimeout(() => setToast(null), 2500)
}
const themeColors = {
'cyberpunk-red': '#FF0033',
'cyberpunk-pink': '#FF1A8C',
'midnight-blue': '#0088FF',
'matrix-green': '#00FF41',
const handleCheckUpdates = async () => {
setChecking(true)
try {
await api.runScan()
const d = await api.getUpdates()
setUpdates(d.updates || [])
const td = await api.getTools()
setTools(td.tools || [])
showToast(t('config.upToDate'))
} catch (err) {
showToast(`${t('config.error')}: ${err.message}`)
}
setChecking(false)
}
const handleUpdateTool = async (tool) => {
setUpdating(tool)
try {
await api.runUpdate(tool)
await handleCheckUpdates()
showToast(`${tool}`)
} catch (err) {
showToast(`${t('config.error')}: ${err.message}`)
}
setUpdating(null)
}
const handleUpdateAll = async () => {
setUpdating('__all__')
try {
await api.runUpdate('')
await handleCheckUpdates()
showToast(t('config.saved'))
} catch (err) {
showToast(`${t('config.error')}: ${err.message}`)
}
setUpdating(null)
}
const handleSaveProfile = async () => {
try {
await api.saveProfile(profileForm)
setEditProfile(false)
loadData()
showToast(t('config.saved'))
} catch (err) {
showToast(`${t('config.error')}: ${err.message}`)
}
}
const handleSaveProvider = async () => {
try {
await api.saveProvider(providerForm)
setEditProvider(null)
loadData()
showToast(t('config.saved'))
} catch (err) {
showToast(`${t('config.error')}: ${err.message}`)
}
}
const openProviderEdit = (p) => {
setProviderForm({
name: p.name,
api_key: p.apiKey || '',
model: p.model || '',
base_url: p.baseURL || '',
})
setEditProvider(p.name)
}
const needsUpdateCount = updates.filter(u => u.needsUpdate).length
const installedCount = tools.filter(t => t.installed).length
const missingCount = tools.filter(t => !t.installed).length
return (
<div className="config-window">
{toast && <div className="config-toast">{toast}</div>}
<div className="config-tabs-bar">
{PANELS.map(p => {
const Icon = p.icon
return (
<div
key={p.id}
className={`nav-tab ${activePanel === p.id ? 'active' : ''}`}
onClick={() => setActivePanel(p.id)}
>
<span className="tab-icon"><Icon size={15} /></span>
{t(`config.panels.${p.id}`)}
</div>
)
})}
</div>
<div className="config-panel-area">
<div className="config-panel-body">
{activePanel === 'profile' && (
<PanelProfile
config={config} editProfile={editProfile}
profileForm={profileForm} setProfileForm={setProfileForm}
setEditProfile={setEditProfile} handleSaveProfile={handleSaveProfile}
t={t}
/>
)}
{activePanel === 'providers' && (
<PanelProviders
providers={providers} editProvider={editProvider}
providerForm={providerForm} setProviderForm={setProviderForm}
setEditProvider={setEditProvider} openProviderEdit={openProviderEdit}
handleSaveProvider={handleSaveProvider} api={api} loadData={loadData}
t={t}
/>
)}
{activePanel === 'updates' && (
<PanelUpdates
updates={updates} tools={tools}
checking={checking} updating={updating}
needsUpdateCount={needsUpdateCount}
installedCount={installedCount} missingCount={missingCount}
handleCheckUpdates={handleCheckUpdates}
handleUpdateTool={handleUpdateTool}
handleUpdateAll={handleUpdateAll}
t={t}
/>
)}
{activePanel === 'locale' && (
<PanelLocale
language={keyboard} layouts={layouts}
setLanguage={setLanguage} setKeyboard={setKeyboard}
t={t}
/>
)}
{activePanel === 'skills' && (
<PanelSkills skillList={skillList} t={t} />
)}
</div>
</div>
</div>
)
}
function PanelProfile({ config, editProfile, profileForm, setProfileForm, setEditProfile, handleSaveProfile, t }) {
return (
<div className="config-card">
{config?.profile && !editProfile ? (
<>
<div className="config-card-row">
<span className="config-card-label">{t('config.name')}</span>
<span className="config-card-value">{config.profile.name || '—'}</span>
</div>
<div className="config-card-row">
<span className="config-card-label">{t('config.pseudo')}</span>
<span className="config-card-value">{config.profile.pseudo || '—'}</span>
</div>
<div className="config-card-row">
<span className="config-card-label">{t('config.email')}</span>
<span className="config-card-value">{config.profile.email || '—'}</span>
</div>
<div className="config-card-row">
<span className="config-card-label">{t('config.editor')}</span>
<span className="config-card-value mono">{config.profile.preferences?.editor || '—'}</span>
</div>
<div className="config-card-row">
<span className="config-card-label">{t('config.shell')}</span>
<span className="config-card-value mono">{config.profile.preferences?.shell || '—'}</span>
</div>
<div className="config-card-row">
<span className="config-card-label">{t('config.languages')}</span>
<span className="config-card-value">{config.profile.languages?.join(', ') || '—'}</span>
</div>
<div className="config-card-actions">
<button className="primary sm" onClick={() => setEditProfile(true)}>{t('config.editProfile')}</button>
</div>
</>
) : editProfile ? (
<>
<FormInput label={t('config.name')} value={profileForm.name} onChange={v => setProfileForm(p => ({ ...p, name: v }))} />
<FormInput label={t('config.pseudo')} value={profileForm.pseudo} onChange={v => setProfileForm(p => ({ ...p, pseudo: v }))} />
<FormInput label={t('config.email')} value={profileForm.email} onChange={v => setProfileForm(p => ({ ...p, email: v }))} type="email" />
<FormInput label={t('config.editor')} value={profileForm.editor} onChange={v => setProfileForm(p => ({ ...p, editor: v }))} />
<FormInput label={t('config.shell')} value={profileForm.shell} onChange={v => setProfileForm(p => ({ ...p, shell: v }))} />
<div className="config-card-actions">
<button className="primary sm" onClick={handleSaveProfile}>{t('config.save')}</button>
<button className="ghost sm" onClick={() => setEditProfile(false)}>{t('config.cancel')}</button>
</div>
</>
) : (
<div className="empty-state">{t('config.loadingProfile')}</div>
)}
</div>
)
}
function PanelProviders({ providers, editProvider, providerForm, setProviderForm, setEditProvider, openProviderEdit, handleSaveProvider, api, loadData, t }) {
const [validating, setValidating] = useState(null)
const [validationStatus, setValidationStatus] = useState(null)
const handleValidate = async (name, apiKey, model, baseUrl) => {
setValidating(name)
setValidationStatus(null)
try {
await api.validateProvider({ name, api_key: apiKey, model, base_url: baseUrl })
setValidationStatus({ provider: name, valid: true })
} catch (err) {
const msg = err.message || ''
if (msg.includes('invalid_api_key')) {
setValidationStatus({ provider: name, valid: false, error: t('config.keyInvalid') })
} else {
setValidationStatus({ provider: name, valid: false, error: `${t('config.connectionFailed')}: ${msg}` })
}
}
setValidating(null)
}
return (
<div className="config-layout">
<div className="config-section">
<div className="config-section-title">{t('config.profile')}</div>
{config?.profile ? (
<div>
<FieldRow label={t('config.name')} value={config.profile.name} />
<FieldRow label={t('config.pseudo')} value={config.profile.pseudo} />
<FieldRow label={t('config.email')} value={config.profile.email} />
<FieldRow label={t('config.editor')} value={config.profile.preferences?.editor} />
<FieldRow label={t('config.shell')} value={config.profile.preferences?.shell} />
<FieldRow label={t('config.defaultAi')} value={config.profile.preferences?.defaultAI} />
<FieldRow label={t('config.languages')} value={config.profile.languages?.join(', ')} />
<div className="config-providers-list">
<div className="provider-setup-hint">{t('config.setupDescription')}</div>
{providers.map((p, i) => {
const isEditing = editProvider === p.name
const isValidationTarget = validationStatus?.provider === p.name
return (
<div key={i} className="config-card provider-card-v2">
<div className="provider-card-top">
<div className="provider-card-identity">
<span className="provider-card-name">{p.name}</span>
{p.apiKey && <span className="badge ok">{t('config.keyConfigured')}</span>}
{!p.apiKey && <span className="badge error">{t('config.noKey')}</span>}
{isValidationTarget && validationStatus.valid && <span className="badge ok">{t('config.keyValid')}</span>}
{isValidationTarget && !validationStatus.valid && <span className="badge error">{validationStatus.error}</span>}
</div>
</div>
<div className="provider-card-form">
<div className="provider-setup-token-row">
<div className="provider-setup-token-input">
<label className="config-form-label">{t('config.apiKey')}</label>
<input
className="config-form-input"
type="password"
placeholder={t('config.tokenPlaceholder')}
value={isEditing ? providerForm.api_key : ''}
onChange={e => {
if (!isEditing) openProviderEdit(p)
setProviderForm(f => ({ ...f, 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)}
>
{validating === p.name ? t('config.validating') : t('config.validateKey')}
</button>
{isValidationTarget && validationStatus.valid && (
<button className="sm" onClick={handleSaveProvider}>{t('config.save')}</button>
)}
</div>
</div>
<div className="provider-card-meta" style={{ marginTop: 8 }}>
<span className="mono">{p.model || '—'}</span>
</div>
</div>
</div>
) : (
<div className="empty-state">{t('config.loadingProfile')}</div>
)}
)
})}
</div>
)
}
function PanelUpdates({ updates, checking, updating, needsUpdateCount, installedCount, missingCount, handleCheckUpdates, handleUpdateTool, handleUpdateAll, t }) {
return (
<>
<div className="config-card">
<div className="config-update-controls">
<div className="config-update-stats">
<span className="badge ok">{installedCount} {t('config.installed')}</span>
{missingCount > 0 && <span className="badge error">{missingCount} {t('config.missing')}</span>}
{needsUpdateCount > 0 && <span className="badge warn">{needsUpdateCount} {t('config.needsUpdate')}</span>}
</div>
<div className="config-update-buttons">
<button className="sm" onClick={handleCheckUpdates} disabled={checking}>
{checking ? <><RefreshCw size={12} className="spin-icon" /> {t('config.checking')}</> : t('config.checkUpdates')}
</button>
{needsUpdateCount > 0 && (
<button className="sm primary" onClick={handleUpdateAll} disabled={updating === '__all__'}>
{updating === '__all__' ? t('config.updating') : `${t('config.updateAll')} (${needsUpdateCount})`}
</button>
)}
</div>
</div>
</div>
<div className="config-section">
<div className="config-section-title">{t('config.language')}</div>
<div className="actions-stack">
{updates.length === 0 ? (
<div className="config-card">
<div className="empty-state">{t('config.noUpdates')}</div>
</div>
) : (
<div className="config-update-list">
{updates.map((u, i) => (
<div key={i} className="config-update-row">
<div className="config-update-info">
<span className="config-update-name">{u.tool}</span>
<span className="config-update-versions">
{u.needsUpdate ? (
<>{u.current} <span style={{ color: 'var(--warning)' }}>{u.latest}</span></>
) : (
<span style={{ color: 'var(--success)' }}>{u.current}</span>
)}
</span>
</div>
{u.needsUpdate && (
<button
className="sm"
onClick={() => handleUpdateTool(u.tool)}
disabled={updating === u.tool}
>
{updating === u.tool ? t('config.updating') : t('config.updateTool')}
</button>
)}
</div>
))}
</div>
)}
</>
)
}
function PanelLocale({ language, keyboard, layouts, setLanguage, setKeyboard, t }) {
return (
<div className="config-card">
<div className="config-card-group">
<span className="config-card-group-label">{t('config.language')}</span>
<div className="chip-row">
{LANGUAGES.map(lang => (
<div
key={lang.id}
@@ -64,10 +400,9 @@ export default function Config({ api }) {
))}
</div>
</div>
<div className="config-section">
<div className="config-section-title">{t('config.keyboardLayout')}</div>
<div className="actions-stack">
<div className="config-card-group">
<span className="config-card-group-label">{t('config.keyboardLayout')}</span>
<div className="chip-row">
{layouts.map(l => (
<div
key={l.id}
@@ -79,68 +414,41 @@ export default function Config({ api }) {
))}
</div>
</div>
<div className="config-section">
<div className="config-section-title">{t('config.aiProviders')}</div>
{providers.map((p, i) => (
<div key={i} className="provider-card">
<div className="provider-info">
<div className="provider-name">
{p.name}
{p.active && <span className="badge accent" style={{ marginLeft: 8 }}>{t('config.active')}</span>}
</div>
<div className="provider-meta">
<span>{p.model}</span>
<span style={{ color: p.apiKey ? 'var(--success)' : 'var(--error)' }}>
{p.apiKey ? t('config.keyConfigured') : t('config.noKey')}
</span>
</div>
</div>
</div>
))}
</div>
<div className="config-section">
<div className="config-section-title">{t('config.theme')}</div>
<div className="theme-picker">
{themes.map(th => (
<div
key={th.id}
className={`theme-swatch ${currentTheme === th.id ? 'active' : ''}`}
style={{ background: themeColors[th.id] || '#FF0033' }}
onClick={() => handleThemeChange(th.id)}
title={th.name}
/>
))}
</div>
</div>
<div className="config-section">
<div className="config-section-title">{t('config.skills')} ({skillList.length})</div>
{skillList.length === 0 ? (
<div className="empty-state">
{t('config.noSkills')}
<span style={{ fontFamily: 'var(--font-mono)' }}>{t('config.runSkillsInit')}</span>
</div>
) : (
skillList.map((s, i) => (
<div key={i} className="tool-row">
<span className="tool-name">{s.name}</span>
<span className="badge neutral">{s.target || 'both'}</span>
<span style={{ color: 'var(--text-tertiary)', fontSize: 12 }}>{s.description}</span>
</div>
))
)}
</div>
</div>
)
}
function FieldRow({ label, value }) {
function PanelSkills({ skillList, t }) {
return (
<div className="field-row">
<span className="field-label">{label}</span>
<span className={`field-value ${!value ? 'empty' : ''}`}>{value || '—'}</span>
<div className="config-card">
{skillList.length === 0 ? (
<div className="empty-state">
{t('config.noSkills')}
<span style={{ fontFamily: 'var(--font-mono)' }}>{t('config.runSkillsInit')}</span>
</div>
) : (
skillList.map((s, i) => (
<div key={i} className="config-skill-row">
<span className="config-skill-name">{s.name}</span>
<span className="badge neutral">{s.target || 'both'}</span>
<span className="config-skill-desc">{s.description}</span>
</div>
))
)}
</div>
)
}
function FormInput({ label, value, onChange, type = 'text' }) {
return (
<div className="config-form-field">
<label className="config-form-label">{label}</label>
<input
className="config-form-input"
type={type}
value={value}
onChange={e => onChange(e.target.value)}
/>
</div>
)
}

View File

@@ -1,110 +1,62 @@
import { useState } from 'react'
import { useI18n } from '../i18n'
export default function Dashboard({ tools, updates, api, onRescan }) {
export default function Dashboard({ api, onRescan }) {
const { t, layout } = useI18n()
const [activeSection, setActiveSection] = useState('tools')
const [notifications, setNotifications] = useState([])
const installed = tools.filter(tool => tool.installed).length
const total = tools.length
const addNotif = (text, type) => {
setNotifications(prev => [{ text, type, id: Date.now(), time: new Date() }, ...prev])
}
const sections = [
{ id: 'tools', label: t('dashboard.systemOverview') },
{ id: 'notifications', label: t('dashboard.activityLog') },
{ id: 'workflows', label: t('studio.workflows') },
]
return (
<div className="dashboard-layout">
<div className="dashboard-tabs">
{sections.map(s => (
<div
key={s.id}
className={`dashboard-tab ${activeSection === s.id ? 'active' : ''}`}
onClick={() => setActiveSection(s.id)}
>
{s.label}
{s.id === 'tools' && total > 0 && (
<span className="tab-count">{installed}/{total}</span>
)}
{s.id === 'notifications' && notifications.length > 0 && (
<span className="tab-count warn">{notifications.length}</span>
)}
</div>
))}
</div>
<div className="dashboard-content">
{activeSection === 'tools' && (
<div className="dashboard-tools">
{tools.length === 0 ? (
<div className="empty-state">{t('dashboard.noUpdateData')}</div>
) : (
<div className="tools-compact">
{tools.map((tool, i) => {
const name = tool.name || tool.Name
const ver = extractVersion(tool.Version || tool.version)
return (
<div key={i} className="tool-compact-row">
<span className={`badge sm ${tool.installed ? 'ok' : 'error'}`}>
{tool.installed ? '\u2713' : '\u2717'}
</span>
<span className="tool-compact-name">{name}</span>
{ver && <span className="tool-compact-ver">{ver}</span>}
{tool.installed && <span className="tool-compact-installed">{t('dashboard.installed')}</span>}
</div>
)
})}
<div className="dashboard-grid">
<div className="dashboard-section">
<div className="dashboard-section-header">
<div className="dashboard-section-title">{t('studio.workflows')}</div>
</div>
<div className="dashboard-workflows-inline">
<div className="workflow-section">
<div className="section-label">{t('studio.workflows')}</div>
<div className="empty-state" style={{ padding: 20 }}>
{t('studio.noWorkflow')}
</div>
</div>
)}
<div className="workflow-section">
<div className="section-label">{t('studio.activeAgents')}</div>
<div className="empty-state" style={{ padding: 20 }}>
{t('studio.noWorkflow')}
</div>
</div>
</div>
</div>
)}
{activeSection === 'notifications' && (
<div className="dashboard-notifications">
<div className="dashboard-section">
<div className="dashboard-section-header">
<div className="dashboard-section-title">{t('dashboard.activityLog')}</div>
{notifications.length > 0 && (
<span className="badge warn">{notifications.length}</span>
)}
</div>
{notifications.length === 0 ? (
<div className="empty-state">{t('dashboard.noUpdateData')}</div>
) : (
notifications.map(n => (
<div key={n.id} className={`notif-row notif-${n.type}`}>
<span className="notif-time">
{n.time.toLocaleTimeString(layout.locale, { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
</span>
<span className="notif-text">{n.text}</span>
</div>
))
<div className="dashboard-notifications-inline">
{notifications.map(n => (
<div key={n.id} className={`notif-row notif-${n.type}`}>
<span className="notif-time">
{n.time.toLocaleTimeString(layout.locale, { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
</span>
<span className="notif-text">{n.text}</span>
</div>
))}
</div>
)}
</div>
)}
{activeSection === 'workflows' && (
<div className="dashboard-workflows">
<div className="workflow-section">
<div className="section-label">{t('studio.workflows')}</div>
<div className="empty-state" style={{ padding: 20 }}>
{t('studio.noWorkflow')}
</div>
</div>
<div className="workflow-section">
<div className="section-label">{t('studio.activeAgents')}</div>
<div className="empty-state" style={{ padding: 20 }}>
{t('studio.noWorkflow')}
</div>
</div>
</div>
)}
</div>
</div>
</div>
)
}
function extractVersion(s) {
if (!s) return ''
const m = s.match(/\d+\.\d+\.\d+/)
return m ? m[0] : s.slice(0, 12)
}

View File

@@ -1,63 +1,309 @@
import { useState, useRef, useEffect } from 'react'
import { useState, useRef, useEffect, useCallback } from 'react'
import { Terminal as XTerm } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit'
import { WebLinksAddon } from '@xterm/addon-web-links'
import { Plus, X, Monitor, Globe, ChevronDown, Pencil, Trash2 } from 'lucide-react'
import '@xterm/xterm/css/xterm.css'
import { useI18n } from '../i18n'
const MAX_TABS = 7
const XTERM_THEME = {
background: '#0A0A0C',
foreground: '#EAE0E2',
cursor: '#FF0033',
cursorAccent: '#0A0A0C',
selectionBackground: '#FF003344',
selectionForeground: '#ffffff',
black: '#0A0A0C',
red: '#FF0033',
green: '#00E676',
yellow: '#FFD740',
blue: '#448AFF',
magenta: '#FF1A5E',
cyan: '#00BCD4',
white: '#EAE0E2',
brightBlack: '#5A4F52',
brightRed: '#FF5252',
brightGreen: '#69F0AE',
brightYellow: '#FFFF00',
brightBlue: '#82B1FF',
brightMagenta: '#FF80AB',
brightCyan: '#84FFFF',
brightWhite: '#FFFFFF',
}
function createTerminal(container) {
const term = new XTerm({
cursorBlink: true,
fontSize: 14,
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', 'Menlo', monospace",
theme: XTERM_THEME,
allowTransparency: false,
scrollback: 5000,
})
const fitAddon = new FitAddon()
const webLinksAddon = new WebLinksAddon()
term.loadAddon(fitAddon)
term.loadAddon(webLinksAddon)
term.open(container)
fitAddon.fit()
return { term, fitAddon }
}
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.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) => {
try {
const msg = JSON.parse(event.data)
if (msg.type === 'output') {
term.write(msg.data)
} else if (msg.type === 'error') {
term.write(`\r\n\x1b[31mError: ${msg.data}\x1b[0m\r\n`)
}
} catch {
term.write(event.data)
}
}
ws.onclose = () => {
term.write('\r\n\x1b[33m— Connection closed —\x1b[0m\r\n')
}
ws.onerror = () => {
term.write('\r\n\x1b[31m— Connection error —\x1b[0m\r\n')
}
term.onData((data) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'input', data }))
}
})
term.onResize(({ rows, cols }) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'resize', rows, cols }))
}
})
return ws
}
export default function Shell({ api }) {
const { t } = useI18n()
const [history, setHistory] = useState([])
const [input, setInput] = useState('')
const [cwd, setCwd] = useState('~')
const [showAi, setShowAi] = useState(false)
const tabsRef = useRef({})
const nextIdRef = useRef(1)
const [tabs, setTabs] = useState([
{ id: 1, name: 'Local Shell', type: 'local', shell: '', connected: false },
])
const [activeTab, setActiveTab] = useState(1)
const [sshConnections, setSshConnections] = useState([])
const [systemTerminals, setSystemTerminals] = useState([])
const [showMenu, setShowMenu] = useState(false)
const [showSshModal, setShowSshModal] = useState(false)
const [editingTab, setEditingTab] = useState(null)
const [editName, setEditName] = useState('')
const [sshForm, setSshForm] = useState({
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 [cmdHistory, setCmdHistory] = useState([])
const [histIdx, setHistIdx] = useState(-1)
const outputRef = useRef(null)
const aiMessagesRef = useRef(null)
useEffect(() => {
outputRef.current?.scrollTo(0, outputRef.current.scrollHeight)
}, [history])
aiMessagesRef.current?.scrollTo(0, aiMessagesRef.current.scrollHeight)
}, [aiMessages])
const handleCommand = async (cmd) => {
if (!cmd.trim()) return
if (cmd === 'clear') { setHistory([]); return }
useEffect(() => {
api.getTerminalSessions().then(d => {
setSshConnections(d.ssh || [])
setSystemTerminals(d.system || [])
}).catch(() => {})
}, [])
setCmdHistory(prev => [...prev, cmd])
setHistIdx(-1)
setHistory(prev => [...prev, { type: 'cmd', text: `${cwd} $ ${cmd}` }])
const initTerminal = useCallback((tabId, tab) => {
if (tabsRef.current[tabId]) return
try {
const res = await api.runCommand(cmd, cwd === '~' ? '' : cwd)
if (res.output) setHistory(prev => [...prev, { type: 'out', text: res.output }])
if (res.error) setHistory(prev => [...prev, { type: 'err', text: res.error }])
if (cmd.startsWith('cd ')) {
const dir = cmd.slice(3).trim()
setCwd(dir === '~' ? '~' : dir)
const container = document.getElementById(`terminal-${tabId}`)
if (!container) return
const { term, fitAddon } = createTerminal(container)
let initPayload
if (tab.type === 'ssh') {
initPayload = {
type: 'ssh',
data: JSON.stringify({
host: tab.host,
port: tab.port || 22,
user: tab.user || 'root',
key_path: tab.key_path || '',
}),
}
} else {
initPayload = {
type: 'shell',
data: tab.shell || '',
}
}
const ws = connectWebSocket(term, fitAddon, initPayload)
ws.onopen = () => {
setTabs(prev => prev.map(t => t.id === tabId ? { ...t, connected: true } : t))
}
ws.onclose = () => {
setTabs(prev => prev.map(t => t.id === tabId ? { ...t, connected: false } : t))
}
ws.onerror = () => {
setTabs(prev => prev.map(t => t.id === tabId ? { ...t, connected: false } : t))
}
const onResize = () => {
const el = document.getElementById(`terminal-${tabId}`)
if (el && el.offsetParent !== null) {
fitAddon.fit()
}
}
const resizeObserver = new ResizeObserver(onResize)
resizeObserver.observe(container)
window.addEventListener('resize', onResize)
tabsRef.current[tabId] = { term, fitAddon, ws, resizeObserver, onResize }
}, [])
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]) {
const timer = setTimeout(() => {
const { fitAddon } = tabsRef.current[tab.id]
fitAddon.fit()
}, 50)
return () => clearTimeout(timer)
}
}, [activeTab, tabs, initTerminal])
useEffect(() => {
const onKey = (e) => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return
if (!e.altKey) return
const num = parseInt(e.key)
if (num >= 1 && num <= tabs.length) {
e.preventDefault()
setActiveTab(tabs[num - 1].id)
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [tabs])
const addLocalTab = (shell, name) => {
if (tabs.length >= MAX_TABS) return
const id = nextIdRef.current++
const newTab = { id, name: name || `${t('shell.localShell')} ${tabs.length + 1}`, type: 'local', shell: shell || '', connected: false }
setTabs(prev => [...prev, newTab])
setActiveTab(id)
setShowMenu(false)
}
const addSSHTab = (conn) => {
if (tabs.length >= MAX_TABS) return
const id = nextIdRef.current++
const newTab = {
id,
name: conn.name || `${conn.user}@${conn.host}`,
type: 'ssh',
host: conn.host,
port: conn.port || 22,
user: conn.user || 'root',
key_path: conn.key_path || '',
connected: false,
}
setTabs(prev => [...prev, newTab])
setActiveTab(id)
setShowMenu(false)
}
const closeTab = (tabId, e) => {
if (e) e.stopPropagation()
if (tabs.length <= 1) return
if (tabsRef.current[tabId]) {
const { ws, resizeObserver, onResize, term } = tabsRef.current[tabId]
window.removeEventListener('resize', onResize)
resizeObserver.disconnect()
ws.close()
term.dispose()
delete tabsRef.current[tabId]
}
setTabs(prev => {
const next = prev.filter(t => t.id !== tabId)
if (activeTab === tabId && next.length > 0) {
setActiveTab(next[next.length - 1].id)
}
return next
})
}
const startRename = (tabId, e) => {
if (e) e.stopPropagation()
const tab = tabs.find(t => t.id === tabId)
setEditingTab(tabId)
setEditName(tab.name)
}
const finishRename = () => {
if (editName.trim() && editingTab) {
setTabs(prev => prev.map(t => t.id === editingTab ? { ...t, name: editName.trim() } : t))
}
setEditingTab(null)
setEditName('')
}
const saveSSHConnection = async () => {
if (!sshForm.name.trim() || !sshForm.host.trim()) return
try {
await api.addSSHConnection(sshForm)
setSshConnections(prev => [...prev, { ...sshForm }])
setSshForm({ name: '', host: '', port: 22, user: '', key_path: '' })
setShowSshModal(false)
} catch (err) {
setHistory(prev => [...prev, { type: 'err', text: err.message }])
console.error(err)
}
}
const handleKeyDown = (e) => {
if (e.key === 'Enter') {
e.preventDefault()
handleCommand(input)
setInput('')
} else if (e.key === 'ArrowUp') {
e.preventDefault()
if (cmdHistory.length === 0) return
const newIdx = histIdx === -1 ? cmdHistory.length - 1 : Math.max(0, histIdx - 1)
setHistIdx(newIdx)
setInput(cmdHistory[newIdx])
} else if (e.key === 'ArrowDown') {
e.preventDefault()
if (histIdx === -1) return
const newIdx = histIdx + 1
if (newIdx >= cmdHistory.length) { setHistIdx(-1); setInput('') }
else { setHistIdx(newIdx); setInput(cmdHistory[newIdx]) }
const deleteSSHConnection = async (name) => {
try {
await api.deleteSSHConnection(name)
setSshConnections(prev => prev.filter(c => c.name !== name))
} catch (err) {
console.error(err)
}
}
@@ -67,7 +313,6 @@ export default function Shell({ api }) {
setAiMessages(prev => [...prev, { role: 'user', content: text }])
setAiInput('')
setAiLoading(true)
try {
const res = await api.runCommand(`echo "AI: ${text}"`, '')
setAiMessages(prev => [...prev, { role: 'ai', content: res.output || t('shell.noResponse') }])
@@ -78,57 +323,186 @@ export default function Shell({ api }) {
}
return (
<div className="split-horizontal" style={{ height: '100%' }}>
<div className="terminal" style={{ flex: 1 }}>
<div className="panel-header">
<span className="panel-title">
{t('shell.terminal')}
<span className="panel-subtitle">{cwd}</span>
</span>
<button className="ghost sm" onClick={() => setShowAi(!showAi)}>
{showAi ? t('shell.hideAi') : t('shell.aiAssistant')}
</button>
<div className="shell-layout">
<div className="shell-terminal-col">
<div className="shell-tabs-bar">
<div className="shell-tabs">
{tabs.map((tab, i) => (
<div
key={tab.id}
className={`shell-tab ${activeTab === tab.id ? 'active' : ''}`}
onClick={() => setActiveTab(tab.id)}
onDoubleClick={(e) => startRename(tab.id, e)}
>
<span className={`connection-dot ${tab.connected ? 'on' : 'off'}`} />
{tab.type === 'ssh' && <Globe size={12} />}
{tab.type === 'local' && <Monitor size={12} />}
{editingTab === tab.id ? (
<input
className="shell-tab-rename"
value={editName}
onChange={e => setEditName(e.target.value)}
onBlur={finishRename}
onKeyDown={e => { if (e.key === 'Enter') finishRename(); if (e.key === 'Escape') setEditingTab(null) }}
autoFocus
onClick={e => e.stopPropagation()}
/>
) : (
<span className="shell-tab-name">{tab.name}</span>
)}
<span className="shell-tab-index">{i + 1}</span>
{tabs.length > 1 && (
<button
className="shell-tab-close"
onClick={(e) => closeTab(tab.id, e)}
title={t('shell.closeTab')}
>
<X size={12} />
</button>
)}
</div>
))}
</div>
<div className="shell-tab-actions">
{tabs.length < MAX_TABS && (
<div className="shell-new-tab-wrapper">
<button className="shell-new-tab-btn" onClick={() => setShowMenu(!showMenu)} title={t('shell.newTab')}>
<Plus size={16} />
<ChevronDown size={12} />
</button>
{showMenu && (
<>
<div className="shell-menu-overlay" onClick={() => setShowMenu(false)} />
<div className="shell-new-tab-menu">
<div className="shell-menu-label">{t('shell.systemTerminals')}</div>
{systemTerminals.map(st => (
<button
key={st.name}
className="shell-menu-item"
onClick={() => addLocalTab(st.shell, st.name)}
>
<Monitor size={14} />
<span>{st.name}</span>
<span className="shell-menu-item-sub">{st.shell}</span>
</button>
))}
<div className="shell-menu-divider" />
<div className="shell-menu-label">{t('shell.savedConnections')}</div>
{sshConnections.length === 0 && (
<div className="shell-menu-empty">{t('shell.noConnections')}</div>
)}
{sshConnections.map(conn => (
<div key={conn.name} className="shell-menu-item-row">
<button
className="shell-menu-item"
onClick={() => addSSHTab(conn)}
>
<Globe size={14} />
<span>{conn.name}</span>
<span className="shell-menu-item-sub">{conn.user}@{conn.host}:{conn.port}</span>
</button>
<button
className="shell-menu-item-icon"
onClick={(e) => { e.stopPropagation(); deleteSSHConnection(conn.name) }}
title={t('shell.deleteConnection')}
>
<Trash2 size={12} />
</button>
</div>
))}
<div className="shell-menu-divider" />
<button className="shell-menu-item accent" onClick={() => { setShowSshModal(true); setShowMenu(false) }}>
<Plus size={14} />
<span>{t('shell.addConnection')}</span>
</button>
</div>
</>
)}
</div>
)}
</div>
</div>
<div className="terminal-output" ref={outputRef}>
{history.map((line, i) => (
<div key={i} className={`terminal-line ${line.type}`}>
{line.text}
</div>
<div className="shell-xterm-wrapper">
{tabs.map(tab => (
<div
key={tab.id}
id={`terminal-${tab.id}`}
className="shell-xterm-instance"
style={{ display: activeTab === tab.id ? 'block' : 'none' }}
/>
))}
</div>
<div className="terminal-input-bar">
<span className="terminal-prompt">&rsaquo;</span>
<input
className="terminal-input"
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={handleKeyDown}
autoFocus
/>
</div>
</div>
{showAi && (
<div className="ai-panel">
<div className="ai-panel-header">{t('shell.aiAssistant')}</div>
<div className="ai-panel-messages">
{aiMessages.map((msg, i) => (
<div key={i} className={`ai-message ${msg.role}`}>
{msg.content}
<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}
</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()}>
<div className="shell-modal-header">{t('shell.addConnection')}</div>
<div className="shell-modal-body">
<label className="shell-modal-label">{t('shell.connectionName')}</label>
<input
value={sshForm.name}
onChange={e => setSshForm(f => ({ ...f, name: e.target.value }))}
placeholder="prod-server"
/>
<label className="shell-modal-label">{t('shell.host')}</label>
<input
value={sshForm.host}
onChange={e => setSshForm(f => ({ ...f, host: e.target.value }))}
placeholder="192.168.1.100"
/>
<div className="shell-modal-row">
<div className="shell-modal-field">
<label className="shell-modal-label">{t('shell.port')}</label>
<input
type="number"
value={sshForm.port}
onChange={e => setSshForm(f => ({ ...f, port: parseInt(e.target.value) || 22 }))}
/>
</div>
<div className="shell-modal-field">
<label className="shell-modal-label">{t('shell.user')}</label>
<input
value={sshForm.user}
onChange={e => setSshForm(f => ({ ...f, user: e.target.value }))}
placeholder="root"
/>
</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>
<label className="shell-modal-label">{t('shell.keyPath')} ({t('shell.local')})</label>
<input
value={sshForm.key_path}
onChange={e => setSshForm(f => ({ ...f, key_path: e.target.value }))}
placeholder="~/.ssh/id_rsa"
/>
</div>
<div className="shell-modal-footer">
<button className="ghost" onClick={() => setShowSshModal(false)}>{t('shell.cancel')}</button>
<button className="primary" onClick={saveSSHConnection}>{t('shell.save')}</button>
</div>
</div>
</div>
)}

View File

@@ -1,37 +1,206 @@
import { useState, useRef, useEffect } from 'react'
import { useState, useRef, useEffect, useCallback } from 'react'
import { useI18n } from '../i18n'
function renderContent(text) {
const parts = []
const codeBlockRegex = /(```[\s\S]*?```)/g
let match
let lastIndex = 0
while ((match = codeBlockRegex.exec(text)) !== null) {
if (match.index > lastIndex) {
parts.push({ type: 'text', content: text.slice(lastIndex, match.index) })
}
const full = match[1]
const firstNewline = full.indexOf('\n')
const lang = firstNewline > -1 ? full.slice(3, firstNewline).trim() : ''
const code = firstNewline > -1 ? full.slice(firstNewline + 1, -3) : full.slice(3, -3)
parts.push({ type: 'code', lang, content: code })
lastIndex = match.index + full.length
}
if (lastIndex < text.length) {
parts.push({ type: 'text', content: text.slice(lastIndex) })
}
return parts
}
function formatText(text) {
return text
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/`([^`]+)`/g, '<code class="inline-code">$1</code>')
.replace(/^### (.+)$/gm, '<h4 class="msg-h4">$1</h4>')
.replace(/^## (.+)$/gm, '<h3 class="msg-h3">$1</h3>')
.replace(/^\s*[-*] (.+)$/gm, '<span class="msg-bullet">$1</span>')
.replace(/^\s*(\d+)[.)] (.+)$/gm, '<span class="msg-step"><span class="msg-step-num">$1</span>$2</span>')
}
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 timeStr = msg.time ? new Date(msg.time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : ''
if (isSystem) {
return (
<div className="feed-item system">
<div className="feed-system-badge" />
<div className="feed-system-text">{msg.content}</div>
{timeStr && <span className="feed-time">{timeStr}</span>}
</div>
)
}
return (
<div className={`feed-item ${msg.role}`}>
{roleLabel}
<div className="feed-body">
<div className="feed-header">
<span className="feed-role">{isUser ? 'Vous' : 'IA'}</span>
{timeStr && <span className="feed-time">{timeStr}</span>}
</div>
<div className="feed-content">
{renderContent(msg.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) }} />
)
)}
</div>
</div>
</div>
)
}
function StreamingItem({ content }) {
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>
<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" />
</div>
</div>
</div>
)
}
export default function Studio({ api }) {
const { t, layout } = useI18n()
const [messages, setMessages] = useState([
{ role: 'ai', content: t('studio.welcome') },
{ role: 'ai', content: t('studio.configureHint') },
])
const { t } = useI18n()
const [messages, setMessages] = useState([])
const [input, setInput] = useState('')
const [sidebarPanel, setSidebarPanel] = useState('chat')
const [loading, setLoading] = useState(false)
const [streaming, setStreaming] = useState('')
const [loaded, setLoaded] = useState(false)
const messagesEnd = useRef(null)
const textareaRef = useRef(null)
useEffect(() => {
api.getChatHistory().then(data => {
if (data.messages && data.messages.length > 0) {
setMessages(data.messages)
} else {
setMessages([
{ id: 'welcome', role: 'system', content: t('studio.welcomeNew'), time: new Date().toISOString() },
])
}
setLoaded(true)
}).catch(() => {
setMessages([
{ id: 'welcome', role: 'system', content: t('studio.welcomeNew'), time: new Date().toISOString() },
])
setLoaded(true)
})
}, [])
useEffect(() => {
messagesEnd.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages])
}, [messages, streaming])
const handleSend = () => {
useEffect(() => {
if (textareaRef.current) {
textareaRef.current.style.height = 'auto'
textareaRef.current.style.height = Math.min(textareaRef.current.scrollHeight, 200) + 'px'
}
}, [input])
const handleClear = useCallback(async () => {
try {
await api.clearChat()
setMessages([
{ id: 'clear-' + Date.now(), role: 'system', content: t('studio.cleared'), time: new Date().toISOString() },
])
} catch {}
}, [api, t])
const handleSend = useCallback(async () => {
if (!input.trim() || loading) return
const text = input.trim()
setMessages(prev => [...prev, { role: 'user', content: text }])
setInput('')
setLoading(true)
api.runCommand(`echo "AI response simulation for: ${text}"`, '')
.then(res => {
setMessages(prev => [...prev, { role: 'ai', content: res.output || res.error || t('studio.noResponse') }])
})
.catch(err => {
setMessages(prev => [...prev, { role: 'ai', content: `${t('studio.error')}: ${err.message}` }])
})
.finally(() => setLoading(false))
}
if (text === '/clear') {
handleClear()
return
}
const userMsg = { id: Date.now().toString(), role: 'user', content: text, time: new Date().toISOString() }
setMessages(prev => [...prev, userMsg])
setLoading(true)
setStreaming('')
try {
let accumulated = ''
await api.sendChat(text, true).then(full => {
accumulated = full
}).catch(() => {})
const finalContent = accumulated || t('studio.noResponse')
setMessages(prev => [...prev, {
id: (Date.now() + 1).toString(),
role: 'assistant',
content: finalContent,
time: new Date().toISOString(),
}])
} catch (err) {
setMessages(prev => [...prev, {
id: (Date.now() + 1).toString(),
role: 'system',
content: `${t('studio.error')}: ${err.message}`,
time: new Date().toISOString(),
}])
} finally {
setLoading(false)
setStreaming('')
}
}, [input, loading, api, t, handleClear])
const handleKeyDown = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
@@ -40,100 +209,66 @@ export default function Studio({ api }) {
}
}
const sidebarItems = [
{ id: 'chat', label: t('studio.chat'), icon: '#' },
{ id: 'agents', label: t('studio.agents'), icon: '*' },
{ id: 'workflows', label: t('studio.workflows'), icon: '~' },
]
if (!loaded) {
return (
<div className="studio-feed-layout">
<div className="studio-feed">
<div className="feed-loading">
<div className="studio-thinking"><span /><span /><span /></div>
</div>
</div>
</div>
)
}
return (
<div className="split-horizontal">
<div className="chat-layout" style={{ flex: 1, borderRight: '1px solid var(--border)' }}>
<div className="panel-header">
<span className="panel-title">
{t('studio.chat')}
{loading && <span className="spinner" />}
</span>
</div>
<div className="chat-messages">
{messages.map((msg, i) => (
<div key={i} className={`message ${msg.role}`}>
{msg.content}
<div className="studio-feed-layout">
<div className="studio-feed">
{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 ref={messagesEnd} />
</div>
<div className="feed-body">
<div className="feed-content">
<div className="studio-thinking"><span /><span /><span /></div>
</div>
</div>
</div>
)}
<div ref={messagesEnd} />
</div>
<div className="chat-input-bar">
<input
<div className="studio-input-area">
<div className="studio-input-row">
<textarea
ref={textareaRef}
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={t('studio.placeholder')}
placeholder={t('studio.placeholderNew')}
disabled={loading}
rows={1}
/>
<button className="primary" onClick={handleSend} disabled={loading || !input.trim()}>
{t('studio.send')}
<button
className="studio-send-btn"
onClick={handleSend}
disabled={loading || !input.trim()}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/>
</svg>
</button>
</div>
</div>
<div className="split-right">
<div className="sidebar-nav">
{sidebarItems.map(item => (
<div
key={item.id}
className={`sidebar-tab ${sidebarPanel === item.id ? 'active' : ''}`}
onClick={() => setSidebarPanel(item.id)}
>
<span style={{ fontFamily: 'var(--font-mono)', width: 16 }}>{item.icon}</span>
{item.label}
</div>
))}
<div className="studio-input-hint">
{t('studio.inputHint')} &middot; /clear
</div>
{sidebarPanel === 'chat' && (
<div>
<div className="section-title">{t('studio.commands')}</div>
<div style={{ fontSize: 12, fontFamily: 'var(--font-mono)', color: 'var(--text-tertiary)' }}>
{t('studio.planGoal')}<br />
{t('studio.help')}
</div>
</div>
)}
{sidebarPanel === 'agents' && (
<div>
<div className="section-title">{t('studio.activeAgents')}</div>
<div className="agent-card">
<div className="agent-avatar">C</div>
<div>
<div className="agent-name">{t('studio.crush')}</div>
<div className="agent-status">{t('studio.stopped')}</div>
</div>
<span className="badge neutral" style={{ marginLeft: 'auto' }}>{t('studio.inactive')}</span>
</div>
<div className="agent-card">
<div className="agent-avatar">CC</div>
<div>
<div className="agent-name">{t('studio.claudeCode')}</div>
<div className="agent-status">{t('studio.stopped')}</div>
</div>
<span className="badge neutral" style={{ marginLeft: 'auto' }}>{t('studio.inactive')}</span>
</div>
</div>
)}
{sidebarPanel === 'workflows' && (
<div>
<div className="section-title">{t('studio.workflows')}</div>
<div className="empty-state">
{t('studio.noWorkflow')}
<span style={{ fontFamily: 'var(--font-mono)' }}>{t('studio.usePlan')}</span>
</div>
</div>
)}
</div>
</div>
)

View File

@@ -46,11 +46,13 @@ const en = {
studio: {
welcome: 'Welcome to Studio! Chat with your AI assistant here.',
welcomeNew: 'Welcome to Muyue Studio. I am your AI orchestrator. Describe your project and I will create a plan, propose agents, and track each step.',
configureHint: 'Configure agents and workflows from the sidebar.',
chat: 'Chat',
agents: 'Agents',
workflows: 'Workflows',
placeholder: 'Type a message... (Enter to send)',
placeholderNew: 'Describe your project or ask a question...',
send: 'Send',
commands: 'Commands',
planGoal: '/plan <goal>',
@@ -64,6 +66,17 @@ const en = {
usePlan: 'Use /plan <goal> in chat to start.',
noResponse: 'No response',
error: 'Error',
inputHint: 'Enter to send, Shift+Enter for new line',
context: 'Context',
plans: 'Plans',
activity: 'Activity',
noPlansYet: 'No plans detected. Ask the AI to create a plan.',
noAgentsYet: 'No agents mentioned.',
planDetail: 'Plan detail',
steps: 'steps',
you: 'You',
mentioned: 'mentioned',
cleared: 'Conversation cleared.',
},
shell: {
@@ -75,9 +88,39 @@ const en = {
send: 'Send',
noResponse: 'No response',
error: 'Error',
newTab: 'New tab',
closeTab: 'Close tab',
maxTabsReached: 'Maximum 7 terminals reached',
renameTab: 'Rename',
local: 'Local',
ssh: 'SSH',
connections: 'Connections',
addConnection: 'Add SSH connection',
editConnection: 'Edit connection',
deleteConnection: 'Delete',
connectionName: 'Name',
host: 'Host',
port: 'Port',
user: 'User',
keyPath: 'SSH key path',
connect: 'Connect',
save: 'Save',
cancel: 'Cancel',
savedConnections: 'Saved connections',
noConnections: 'No saved SSH connections.',
systemTerminals: 'System terminals',
switchTerminal: 'Switch terminal',
localShell: 'Local Shell',
},
config: {
panels: {
profile: 'Profile',
providers: 'AI Providers',
updates: 'Updates',
locale: 'Language & Keyboard',
skills: 'Skills',
},
profile: 'Profile',
name: 'Name',
pseudo: 'Pseudo',
@@ -90,15 +133,47 @@ const en = {
notSet: 'Not set',
aiProviders: 'AI Providers',
active: 'Active',
activate: 'Activate',
keyConfigured: 'Key configured',
noKey: 'No key',
theme: 'Theme',
apiKey: 'API Key',
model: 'Model',
baseUrl: 'Base URL',
save: 'Save',
saved: 'Saved!',
error: 'Error',
skills: 'Skills',
noSkills: 'No skills installed.',
runSkillsInit: 'Run muyue skills init',
language: 'Language',
keyboardLayout: 'Keyboard Layout',
target: 'Target',
updates: 'Updates',
systemUpdates: 'System Updates',
checkUpdates: 'Check for updates',
updateAll: 'Update all',
updateTool: 'Update',
checking: 'Checking...',
updating: 'Updating...',
upToDate: 'Up to date',
needsUpdate: 'Update available',
current: 'Current',
latest: 'Latest',
noUpdates: 'All tools are up to date.',
version: 'Version',
installed: 'Installed',
missing: 'Missing',
editProfile: 'Edit',
cancel: 'Cancel',
editProvider: 'Configure',
validateKey: 'Validate',
validating: 'Validating...',
keyValid: 'Valid key',
keyInvalid: 'Invalid key',
connectionFailed: 'Connection failed',
enterToken: 'Enter your API token for {provider}',
tokenPlaceholder: 'sk-...',
setupDescription: 'Configure your AI provider token to use the assistant.',
},
}

View File

@@ -46,11 +46,13 @@ const fr = {
studio: {
welcome: 'Bienvenue dans Studio ! Discutez avec votre assistant IA ici.',
welcomeNew: 'Bienvenue dans Muyue Studio. Je suis votre orchestrateur IA. D\u00e9crivez votre projet et je cr\u00e9erai un plan, proposerai des agents, et suivrai chaque \u00e9tape.',
configureHint: 'Configurez les agents et workflows depuis la barre lat\u00e9rale.',
chat: 'Chat',
agents: 'Agents',
workflows: 'Workflows',
placeholder: 'Tapez un message... (Entr\u00e9e pour envoyer)',
placeholderNew: 'D\u00e9crivez votre projet ou posez une question...',
send: 'Envoyer',
commands: 'Commandes',
planGoal: '/plan <objectif>',
@@ -64,6 +66,17 @@ const fr = {
usePlan: 'Utilisez /plan <objectif> dans le chat pour d\u00e9marrer.',
noResponse: 'Pas de r\u00e9ponse',
error: 'Erreur',
inputHint: 'Entr\u00e9e pour envoyer, Shift+Entr\u00e9e pour un retour \u00e0 la ligne',
context: 'Contexte',
plans: 'Plans',
activity: 'Activit\u00e9',
noPlansYet: 'Aucun plan d\u00e9tect\u00e9. Demandez \u00e0 l\u2019IA de cr\u00e9er un plan.',
noAgentsYet: 'Aucun agent mentionn\u00e9.',
planDetail: 'D\u00e9tail du plan',
steps: '\u00e9tapes',
you: 'Vous',
mentioned: 'mentionn\u00e9',
cleared: 'Conversation effac\u00e9e.',
},
shell: {
@@ -75,9 +88,39 @@ const fr = {
send: 'Envoyer',
noResponse: 'Pas de r\u00e9ponse',
error: 'Erreur',
newTab: 'Nouvel onglet',
closeTab: 'Fermer l\u2019onglet',
maxTabsReached: 'Maximum 7 terminaux atteint',
renameTab: 'Renommer',
local: 'Local',
ssh: 'SSH',
connections: 'Connexions',
addConnection: 'Ajouter une connexion SSH',
editConnection: 'Modifier la connexion',
deleteConnection: 'Supprimer',
connectionName: 'Nom',
host: 'H\u00f4te',
port: 'Port',
user: 'Utilisateur',
keyPath: 'Chemin cl\u00e9 SSH',
connect: 'Se connecter',
save: 'Enregistrer',
cancel: 'Annuler',
savedConnections: 'Connexions enregistr\u00e9es',
noConnections: 'Aucune connexion SSH enregistr\u00e9e.',
systemTerminals: 'Terminaux syst\u00e8me',
switchTerminal: 'Changer de terminal',
localShell: 'Shell local',
},
config: {
panels: {
profile: 'Profil',
providers: 'Fournisseurs IA',
updates: 'Mises \u00e0 jour',
locale: 'Langue & Clavier',
skills: 'Comp\u00e9tences',
},
profile: 'Profil',
name: 'Nom',
pseudo: 'Pseudo',
@@ -90,15 +133,47 @@ const fr = {
notSet: 'Non d\u00e9fini',
aiProviders: 'Fournisseurs IA',
active: 'Actif',
activate: 'Activer',
keyConfigured: 'Cl\u00e9 configur\u00e9e',
noKey: 'Pas de cl\u00e9',
theme: 'Th\u00e8me',
apiKey: 'Cl\u00e9 API',
model: 'Mod\u00e8le',
baseUrl: 'URL de base',
save: 'Enregistrer',
saved: 'Enregistr\u00e9 !',
error: 'Erreur',
skills: 'Comp\u00e9tences',
noSkills: 'Aucune comp\u00e9tence install\u00e9e.',
runSkillsInit: 'Ex\u00e9cutez muyue skills init',
language: 'Langue',
keyboardLayout: 'Disposition du clavier',
target: 'Cible',
updates: 'Mises \u00e0 jour',
systemUpdates: 'Mises \u00e0 jour syst\u00e8me',
checkUpdates: 'V\u00e9rifier les mises \u00e0 jour',
updateAll: 'Tout mettre \u00e0 jour',
updateTool: 'Mettre \u00e0 jour',
checking: 'V\u00e9rification...',
updating: 'Mise \u00e0 jour...',
upToDate: '\u00c0 jour',
needsUpdate: 'Mise \u00e0 jour disponible',
current: 'Actuel',
latest: 'Dernier',
noUpdates: 'Tous les outils sont \u00e0 jour.',
version: 'Version',
installed: 'Install\u00e9',
missing: 'Manquant',
editProfile: 'Modifier',
editProvider: 'Configurer',
validateKey: 'Valider',
validating: 'Vérification...',
keyValid: 'Clé valide',
keyInvalid: 'Clé invalide',
connectionFailed: 'Connexion échouée',
enterToken: 'Entrez votre token API pour {provider}',
tokenPlaceholder: 'sk-...',
setupDescription: 'Configurez le token de votre fournisseur IA pour utiliser l\'assistant.',
cancel: 'Annuler',
},
}

View File

@@ -141,6 +141,7 @@ input::placeholder { color: var(--text-disabled); }
}
.nav-tab:hover { color: var(--text-primary); background: var(--bg-card); }
.nav-tab.active { color: #fff; background: var(--accent); }
.tab-icon { display: flex; align-items: center; }
.header-spacer { flex: 1; }
@@ -267,50 +268,236 @@ input::placeholder { color: var(--text-disabled); }
.sidebar-tab:hover { background: var(--bg-card); color: var(--text-primary); }
.sidebar-tab.active { background: var(--accent); color: #fff; font-weight: 600; }
.terminal { display: flex; flex-direction: column; height: 100%; background: var(--bg); }
.terminal-output { flex: 1; padding: 16px; font-family: var(--font-mono); font-size: 13px; line-height: 1.6; overflow-y: auto; white-space: pre-wrap; word-break: break-all; }
.terminal-line { margin-bottom: 2px; }
.terminal-line.cmd { color: var(--accent-dim); }
.terminal-line.out { color: var(--text-primary); }
.terminal-line.err { color: var(--error); }
.terminal-input-bar { display: flex; align-items: center; padding: 10px 16px; background: var(--bg-surface); border-top: 1px solid var(--border); gap: 8px; }
.terminal-prompt { color: var(--success); font-family: var(--font-mono); font-weight: 700; font-size: 14px; flex-shrink: 0; }
.terminal-input { flex: 1; background: transparent; border: none; outline: none; color: var(--text-primary); font-family: var(--font-mono); font-size: 13px; padding: 0; }
.terminal-input:focus { box-shadow: none; border-color: transparent; }
.shell-layout { display: flex; height: 100%; }
.shell-terminal-col { flex: 1; display: flex; flex-direction: column; min-width: 0; }
.config-layout { max-width: 840px; margin: 0 auto; padding: 24px; overflow-y: auto; height: 100%; }
.config-section { margin-bottom: 28px; }
.config-section-title {
font-size: 12px; font-weight: 700; color: var(--accent); text-transform: uppercase;
letter-spacing: 1px; margin-bottom: 16px; padding-bottom: 8px; border-bottom: 1px solid var(--border);
.shell-tabs-bar {
display: flex; align-items: center; background: var(--bg-surface);
border-bottom: 1px solid var(--border); flex-shrink: 0;
height: 36px; padding: 0 8px; gap: 4px;
}
.field-row { display: flex; align-items: center; padding: 10px 0; border-bottom: 1px solid var(--border); gap: 12px; }
.field-row:last-child { border-bottom: none; }
.field-label { width: 140px; flex-shrink: 0; color: var(--text-tertiary); font-size: 13px; }
.field-value { color: var(--text-primary); font-size: 14px; flex: 1; }
.field-value.empty { color: var(--text-disabled); font-style: italic; }
.shell-tabs {
display: flex; align-items: center; gap: 2px; flex: 1; overflow-x: auto;
scrollbar-width: none;
}
.shell-tabs::-webkit-scrollbar { display: none; }
.provider-card {
background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius);
padding: 14px 16px; margin-bottom: 8px; display: flex; align-items: center; justify-content: space-between;
transition: border-color 0.2s;
.shell-tab {
display: flex; align-items: center; gap: 6px;
padding: 4px 10px; border-radius: var(--radius) var(--radius) 0 0;
font-size: 12px; font-weight: 500; color: var(--text-tertiary);
cursor: pointer; transition: all 0.15s; user-select: none;
border: 1px solid transparent; border-bottom: none;
white-space: nowrap; max-width: 180px; position: relative;
background: transparent;
}
.provider-card:hover { border-color: var(--accent-dim); }
.provider-info { display: flex; flex-direction: column; gap: 4px; }
.provider-name { font-weight: 600; color: var(--text-primary); font-size: 14px; }
.provider-meta { display: flex; gap: 12px; font-size: 12px; color: var(--text-tertiary); font-family: var(--font-mono); }
.shell-tab:hover { color: var(--text-primary); background: var(--bg-card); }
.shell-tab.active {
color: var(--text-primary); background: var(--bg);
border-color: var(--border); border-bottom-color: var(--bg);
margin-bottom: -1px;
}
.shell-tab-name {
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
max-width: 120px; font-size: 12px;
}
.shell-tab-index {
font-size: 9px; color: var(--text-disabled); font-family: var(--font-mono);
padding: 0 3px; background: var(--bg-input); border-radius: 3px; line-height: 1.4;
}
.shell-tab-close {
display: flex; align-items: center; justify-content: center;
width: 16px; height: 16px; border-radius: 3px; border: none;
background: transparent; color: var(--text-disabled); cursor: pointer;
padding: 0; transition: all 0.1s; flex-shrink: 0;
}
.shell-tab-close:hover { background: var(--accent-bg); color: var(--accent); }
.theme-picker { display: flex; gap: 8px; flex-wrap: wrap; }
.theme-swatch {
width: 48px; height: 48px; border-radius: var(--radius); border: 2px solid var(--border);
cursor: pointer; transition: all 0.15s; position: relative;
.shell-tab-rename {
width: 80px; font-size: 12px; padding: 1px 4px; border-radius: 3px;
background: var(--bg-input); color: var(--text-primary); border: 1px solid var(--accent);
outline: none; font-family: var(--font-sans);
}
.theme-swatch:hover { transform: scale(1.1); border-color: var(--accent-dim); }
.theme-swatch.active { border-color: var(--accent); box-shadow: 0 0 0 3px var(--border-accent); }
.theme-swatch.active::after {
content: '\2713'; position: absolute; inset: 0; display: flex; align-items: center;
justify-content: center; color: #fff; font-size: 18px; font-weight: 700; text-shadow: 0 1px 3px rgba(0,0,0,0.5);
.shell-tab-actions { display: flex; align-items: center; gap: 4px; flex-shrink: 0; }
.shell-new-tab-wrapper { position: relative; }
.shell-new-tab-btn {
display: flex; align-items: center; gap: 2px;
padding: 4px 8px; border-radius: var(--radius);
background: transparent; border: 1px solid var(--border);
color: var(--text-tertiary); cursor: pointer; transition: all 0.15s;
font-size: 12px;
}
.shell-new-tab-btn:hover { color: var(--text-primary); background: var(--bg-card); border-color: var(--accent-dark); }
.shell-menu-overlay {
position: fixed; inset: 0; z-index: 998;
}
.shell-new-tab-menu {
position: absolute; top: 100%; right: 0; z-index: 999;
background: var(--bg-elevated); border: 1px solid var(--border);
border-radius: var(--radius-lg); padding: 6px;
min-width: 260px; max-height: 400px; overflow-y: auto;
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
}
.shell-menu-label {
font-size: 10px; font-weight: 700; color: var(--text-disabled);
text-transform: uppercase; letter-spacing: 0.5px;
padding: 6px 10px 4px;
}
.shell-menu-item {
display: flex; align-items: center; gap: 8px;
width: 100%; padding: 7px 10px; border-radius: var(--radius);
background: transparent; border: none; color: var(--text-secondary);
cursor: pointer; transition: all 0.1s; font-size: 12px;
text-align: left; font-family: var(--font-sans);
}
.shell-menu-item:hover { background: var(--bg-hover); color: var(--text-primary); }
.shell-menu-item.accent { color: var(--accent); }
.shell-menu-item.accent:hover { background: var(--accent-bg); }
.shell-menu-item-sub {
font-size: 10px; color: var(--text-disabled); font-family: var(--font-mono);
margin-left: auto;
}
.shell-menu-item-row { display: flex; align-items: center; }
.shell-menu-item-icon {
display: flex; align-items: center; justify-content: center;
width: 24px; height: 24px; border-radius: var(--radius);
background: transparent; border: none; color: var(--text-disabled);
cursor: pointer; transition: all 0.1s; flex-shrink: 0;
}
.shell-menu-item-icon:hover { background: var(--accent-bg); color: var(--accent); }
.shell-menu-empty {
font-size: 12px; color: var(--text-disabled); padding: 8px 10px;
font-style: italic;
}
.shell-menu-divider { height: 1px; background: var(--border); margin: 4px 6px; }
.shell-xterm-wrapper { flex: 1; background: var(--bg); overflow: hidden; position: relative; }
.shell-xterm-instance {
position: absolute; inset: 0; padding: 4px;
}
.shell-xterm-instance .xterm { height: 100%; padding: 4px; }
.shell-ai-col { width: 340px; border-left: 1px solid var(--border); background: var(--bg-surface); display: flex; flex-direction: column; flex-shrink: 0; }
.connection-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; flex-shrink: 0; }
.connection-dot.on { background: var(--success); box-shadow: 0 0 6px var(--success); }
.connection-dot.off { background: var(--error); }
.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;
}
.shell-modal {
background: var(--bg-elevated); border: 1px solid var(--border);
border-radius: var(--radius-lg); min-width: 380px; max-width: 480px;
box-shadow: 0 16px 48px rgba(0,0,0,0.5);
}
.shell-modal-header {
padding: 16px 20px; font-size: 14px; font-weight: 700;
color: var(--text-primary); border-bottom: 1px solid var(--border);
}
.shell-modal-body { padding: 16px 20px; display: flex; flex-direction: column; gap: 10px; }
.shell-modal-label { font-size: 11px; font-weight: 600; color: var(--text-tertiary); margin-bottom: 2px; }
.shell-modal-row { display: grid; grid-template-columns: 1fr 2fr; gap: 12px; }
.shell-modal-field { display: flex; flex-direction: column; }
.shell-modal-footer {
padding: 12px 20px; border-top: 1px solid var(--border);
display: flex; justify-content: flex-end; gap: 8px;
}
.config-window { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
.config-tabs-bar {
display: flex; gap: 4px; padding: 12px 20px 0; background: var(--bg-surface);
border-bottom: 1px solid var(--border); flex-shrink: 0;
}
.config-panel-area { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
.config-panel-body { flex: 1; overflow-y: auto; padding: 16px 28px 28px; }
.config-card {
background: var(--bg-card); border: 1px solid var(--border);
border-radius: var(--radius-lg); padding: 20px 24px; margin-bottom: 16px;
}
.config-card-row {
display: flex; align-items: center; padding: 10px 0;
border-bottom: 1px solid var(--border); gap: 16px;
}
.config-card-row:last-of-type { border-bottom: none; }
.config-card-label { width: 130px; flex-shrink: 0; color: var(--text-tertiary); font-size: 13px; }
.config-card-value { color: var(--text-primary); font-size: 14px; flex: 1; }
.config-card-value.mono { font-family: var(--font-mono); }
.config-card-value:not(.mono)[style*="—"] { color: var(--text-disabled); font-style: italic; }
.config-card-actions { display: flex; gap: 8px; padding-top: 16px; }
.config-form-field { margin-bottom: 14px; }
.config-form-label { display: block; font-size: 11px; font-weight: 600; color: var(--text-tertiary); margin-bottom: 5px; text-transform: uppercase; letter-spacing: 0.3px; }
.config-form-input {
width: 100%; background: var(--bg-input); border: 1px solid var(--border);
border-radius: var(--radius); padding: 8px 12px; color: var(--text-primary);
font-size: 13px; font-family: var(--font-mono); outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
}
.config-form-input:focus { border-color: var(--accent); box-shadow: 0 0 0 2px var(--border-accent); }
.config-card-group { margin-bottom: 20px; }
.config-card-group:last-child { margin-bottom: 0; }
.config-card-group-label { display: block; font-size: 11px; font-weight: 700; color: var(--text-tertiary); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 10px; }
.config-providers-list { display: flex; flex-direction: column; gap: 12px; }
.provider-card-v2 {
background: var(--bg-card); border: 1px solid var(--border);
border-radius: var(--radius-lg); padding: 16px 20px; transition: border-color 0.2s;
}
.provider-card-v2:hover { border-color: var(--accent-dim); }
.provider-card-top { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.provider-card-identity { display: flex; align-items: center; gap: 10px; }
.provider-card-name { font-weight: 600; color: var(--text-primary); font-size: 14px; }
.provider-card-actions { display: flex; gap: 8px; flex-shrink: 0; }
.provider-card-meta { display: flex; gap: 16px; font-size: 12px; color: var(--text-tertiary); font-family: var(--font-mono); margin-top: 8px; }
.provider-card-form { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border); }
.provider-setup-hint {
font-size: 13px; color: var(--text-tertiary); margin-bottom: 16px;
padding: 10px 14px; border-radius: var(--radius); background: var(--bg-surface);
border-left: 3px solid var(--accent-dim);
}
.provider-setup-token-row { display: flex; gap: 12px; align-items: flex-end; }
.provider-setup-token-input { flex: 1; }
.provider-setup-token-actions { display: flex; gap: 8px; flex-shrink: 0; padding-bottom: 1px; }
.config-update-controls {
display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap;
}
.config-update-stats { display: flex; gap: 8px; }
.config-update-buttons { display: flex; gap: 8px; }
.config-update-list { display: flex; flex-direction: column; gap: 2px; }
.config-update-row { display: flex; align-items: center; justify-content: space-between; padding: 10px 16px; border-radius: var(--radius); background: var(--bg-card); border: 1px solid var(--border); margin-bottom: 6px; }
.config-update-row:hover { border-color: var(--accent-dim); }
.config-update-info { display: flex; align-items: center; gap: 16px; flex: 1; }
.config-update-name { color: var(--text-primary); font-weight: 600; font-size: 13px; min-width: 100px; }
.config-update-versions { color: var(--text-tertiary); font-size: 12px; font-family: var(--font-mono); }
.config-skill-row { display: flex; align-items: center; gap: 10px; padding: 8px 0; border-bottom: 1px solid var(--border); font-size: 13px; }
.config-skill-row:last-child { border-bottom: none; }
.config-skill-name { color: var(--text-primary); font-weight: 600; min-width: 120px; }
.config-skill-desc { color: var(--text-tertiary); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.chip-row { display: flex; gap: 8px; flex-wrap: wrap; }
.config-toast {
position: fixed; bottom: 40px; left: 50%; transform: translateX(-50%);
background: var(--accent); color: #fff; padding: 10px 24px; border-radius: var(--radius-lg);
font-size: 13px; font-weight: 600; z-index: 100; animation: fadeIn 0.2s ease-out;
box-shadow: 0 4px 24px rgba(255, 0, 51, 0.3);
}
.spin-icon { animation: spin 0.8s linear infinite; display: inline-block; vertical-align: middle; }
.mono { font-family: var(--font-mono); }
.section-title { font-size: 12px; font-weight: 700; color: var(--accent); text-transform: uppercase; letter-spacing: 1px; margin-bottom: 12px; }
.actions-stack { display: flex; flex-direction: column; gap: 6px; }
@@ -323,7 +510,6 @@ input::placeholder { color: var(--text-disabled); }
.agent-name { font-weight: 600; color: var(--text-primary); font-size: 13px; }
.agent-status { font-size: 11px; color: var(--text-tertiary); margin-top: 2px; }
.ai-panel { width: 320px; border-left: 1px solid var(--border); background: var(--bg-surface); display: flex; flex-direction: column; }
.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; }
@@ -335,40 +521,26 @@ input::placeholder { color: var(--text-disabled); }
.empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 40px 20px; color: var(--text-disabled); font-size: 13px; text-align: center; gap: 8px; }
.dashboard-layout { display: flex; flex-direction: column; height: 100%; }
.dashboard-tabs {
display: flex; gap: 0; border-bottom: 1px solid var(--border);
background: var(--bg-surface); flex-shrink: 0;
}
.dashboard-tab {
padding: 10px 24px; font-size: 13px; font-weight: 600;
color: var(--text-tertiary); cursor: pointer; transition: all 0.15s;
display: flex; align-items: center; gap: 8px; border-bottom: 2px solid transparent;
user-select: none;
}
.dashboard-tab:hover { color: var(--text-primary); background: var(--bg-card); }
.dashboard-tab.active { color: var(--accent); border-bottom-color: var(--accent); }
.tab-count {
font-size: 10px; padding: 1px 6px; border-radius: 99px;
background: var(--bg-card); color: var(--text-tertiary); font-family: var(--font-mono);
}
.tab-count.warn { background: rgba(255,215,64,0.15); color: var(--warning); }
.dashboard-content { flex: 1; overflow-y: auto; }
.dashboard-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; padding: 24px; }
.dashboard-tools { padding: 16px 24px; }
.tools-compact { display: flex; flex-direction: column; gap: 2px; }
.tool-compact-row {
display: flex; align-items: center; gap: 10px;
padding: 6px 12px; border-radius: var(--radius);
font-size: 13px; transition: background 0.1s;
.dashboard-section {
background: var(--bg-card); border: 1px solid var(--border);
border-radius: var(--radius-lg); padding: 20px; transition: border-color 0.2s;
}
.dashboard-section:hover { border-color: var(--accent-dim); }
.dashboard-section.full-width { grid-column: 1 / -1; }
.dashboard-section-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
.dashboard-section-title {
font-size: 12px; font-weight: 700; color: var(--accent); text-transform: uppercase;
letter-spacing: 0.5px;
}
.tool-compact-row:hover { background: var(--bg-card); }
.badge.sm { padding: 1px 5px; font-size: 10px; }
.tool-compact-name { color: var(--text-primary); font-weight: 500; flex: 1; }
.tool-compact-ver { color: var(--text-tertiary); font-size: 11px; font-family: var(--font-mono); }
.tool-compact-installed { color: var(--success); font-size: 11px; font-family: var(--font-mono); opacity: 0.7; }
.dashboard-notifications { padding: 16px 24px; }
.dashboard-workflows-inline { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
.dashboard-notifications-inline { display: flex; flex-direction: column; gap: 2px; }
.dashboard-notifications { padding: 0; }
.notif-row {
display: flex; align-items: flex-start; gap: 12px;
padding: 8px 12px; border-radius: var(--radius); margin-bottom: 4px;
@@ -381,7 +553,7 @@ input::placeholder { color: var(--text-disabled); }
.notif-warn .notif-text { color: var(--warning); }
.notif-error .notif-text { color: var(--error); }
.dashboard-workflows { padding: 16px 24px; display: flex; flex-direction: column; gap: 24px; }
.dashboard-workflows { padding: 0; display: flex; flex-direction: column; gap: 24px; }
.workflow-section { }
.section-label {
font-size: 11px; font-weight: 700; color: var(--accent); text-transform: uppercase;
@@ -399,3 +571,63 @@ input::placeholder { color: var(--text-disabled); }
.spinner { width: 16px; height: 16px; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.6s linear infinite; display: inline-block; vertical-align: middle; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }
.fade-in { animation: fadeIn 0.2s ease-out; }
/* ── Studio Feed ── */
.studio-feed-layout { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
.studio-feed { flex: 1; overflow-y: auto; padding: 20px 24px; display: flex; flex-direction: column; gap: 4px; }
.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.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-body { flex: 1; min-width: 0; }
.feed-header { display: flex; align-items: center; gap: 8px; margin-bottom: 2px; }
.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; }
.studio-code-block {
background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius);
overflow: hidden; margin: 8px 0;
}
.studio-code-block pre { padding: 12px 16px; font-family: var(--font-mono); font-size: 13px; line-height: 1.5; overflow-x: auto; color: var(--text-primary); margin: 0; }
.studio-code-lang {
padding: 4px 12px; font-size: 11px; font-weight: 600; color: var(--text-tertiary);
background: var(--bg-surface); border-bottom: 1px solid var(--border); text-transform: uppercase; letter-spacing: 0.5px;
}
.inline-code { background: var(--bg-input); padding: 2px 6px; border-radius: 4px; font-family: var(--font-mono); font-size: 13px; color: var(--accent-muted); }
.msg-h3 { font-size: 16px; font-weight: 700; color: var(--text-primary); margin: 16px 0 8px; display: block; }
.msg-h4 { font-size: 14px; font-weight: 700; color: var(--text-secondary); margin: 12px 0 6px; display: block; }
.msg-bullet { display: block; padding-left: 16px; position: relative; margin: 2px 0; }
.msg-bullet::before { content: '\2022'; position: absolute; left: 4px; color: var(--accent); }
.msg-step { display: flex; gap: 8px; align-items: baseline; margin: 3px 0; }
.msg-step-num { color: var(--accent); font-weight: 700; font-family: var(--font-mono); font-size: 13px; flex-shrink: 0; min-width: 20px; }
.studio-cursor { display: inline-block; width: 8px; height: 16px; background: var(--accent); margin-left: 2px; vertical-align: text-bottom; animation: blink 0.8s step-end infinite; }
@keyframes blink { 50% { opacity: 0; } }
.studio-thinking { display: flex; gap: 4px; padding: 8px 0; }
.studio-thinking span { width: 6px; height: 6px; border-radius: 50%; background: var(--accent-dim); animation: bounce 1.2s ease-in-out infinite; }
.studio-thinking span:nth-child(2) { animation-delay: 0.15s; }
.studio-thinking span:nth-child(3) { animation-delay: 0.3s; }
@keyframes bounce { 0%, 80%, 100% { transform: translateY(0); opacity: 0.4; } 40% { transform: translateY(-6px); opacity: 1; } }
.studio-input-area { padding: 12px 20px 8px; border-top: 1px solid var(--border); background: var(--bg-surface); }
.studio-input-row { display: flex; gap: 8px; align-items: flex-end; }
.studio-input-row textarea {
flex: 1; resize: none; min-height: 42px; max-height: 200px; padding: 10px 14px;
font-size: 14px; line-height: 1.5; border-radius: var(--radius);
background: var(--bg-input); color: var(--text-primary); border: 1px solid var(--border);
font-family: var(--font-sans); outline: none; transition: border-color 0.2s, box-shadow 0.2s;
}
.studio-input-row textarea:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--border-accent); }
.studio-input-row textarea::placeholder { color: var(--text-disabled); }
.studio-send-btn {
width: 42px; height: 42px; padding: 0; display: flex; align-items: center; justify-content: center;
border-radius: var(--radius); background: var(--accent); color: #fff; border: 1px solid var(--accent);
cursor: pointer; transition: all 0.15s; flex-shrink: 0;
}
.studio-send-btn:hover:not(:disabled) { background: var(--accent-bright); border-color: var(--accent-bright); }
.studio-send-btn:disabled { opacity: 0.3; cursor: not-allowed; }
.studio-input-hint { font-size: 11px; color: var(--text-disabled); text-align: center; margin-top: 6px; }